diff --git a/CHANGED b/CHANGED index bad4c64d2..b297bd77b 100644 --- a/CHANGED +++ b/CHANGED @@ -1,5 +1,6 @@ # Add changes at the top of the list. Keep it in ASCII, and 80-char wide. # Do not insert empty lines here, update check depends on it. + - new: 98_serviced: new modul for linux services (systemd and initd) - bugfix: 12_HProtocolGateway: start poll timer - bugfix: 73_AutoShuttersControl: fix many bugs, support for max window contact sensors, fix set partyMode then Reading and set command diff --git a/FHEM/98_serviced.pm b/FHEM/98_serviced.pm new file mode 100644 index 000000000..c299fc8c3 --- /dev/null +++ b/FHEM/98_serviced.pm @@ -0,0 +1,789 @@ +##################################################################################### +# $Id$ +# +# Usage +# +# define serviced [user@ip-address] +# +##################################################################################### + +package main; + +use strict; +use warnings; + +use Blocking; +use Time::HiRes; +use vars qw{%defs}; + +my $servicedVersion = "1.2.4"; + +sub serviced_shutdownwait($); + +sub serviced_Initialize($) +{ + my ($hash) = @_; + $hash->{AttrFn} = "serviced_Attr"; + $hash->{DefFn} = "serviced_Define"; + $hash->{GetFn} = "serviced_Get"; + $hash->{NotifyFn} = "serviced_Notify"; + $hash->{SetFn} = "serviced_Set"; + $hash->{ShutdownFn} = "serviced_Shutdown"; + $hash->{UndefFn} = "serviced_Undef"; + $hash->{AttrList} = "disable:1,0 ". + "serviceAutostart ". + "serviceAutostop ". + "serviceGetStatusOnInit:0,1 ". + "serviceInitd:1,0 ". + "serviceLogin ". + "serviceRegexFailed ". + "serviceRegexStarted ". + "serviceRegexStarting ". + "serviceRegexStopped ". + "serviceStatusInterval ". + "serviceStatusLine:1,2,3,4,5,6,7,8,9,last ". + "serviceSudo:0,1 ". + $readingFnAttributes; +} + +sub serviced_Define($$) +{ + my ($hash,$def) = @_; + my @args = split " ",$def; + return "Usage: define serviced [user\@ip-address]" + if (@args < 3 || @args > 4); + my ($name,$type,$service,$remote) = @args; + return "Remote host must be like 'pi\@192.168.2.22' or 'pi\@myserver'!" + if ($remote && $remote !~ /^\w{2,}@(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|[\w\.\-_]{2,})$/); + RemoveInternalTimer($hash); + $hash->{NOTIFYDEV} = "global"; + $hash->{SERVICENAME} = $service; + $hash->{VERSION} = $servicedVersion; + if ($init_done && !defined $hash->{OLDDEF}) + { + $attr{$name}{alias} = "Service $service"; + $attr{$name}{cmdIcon} = "restart:rc_REPEAT stop:rc_STOP status:rc_INFO start:rc_PLAY"; + $attr{$name}{devStateIcon} = "Initialized|status:light_question error|failed:light_exclamation running:audio_play:stop stopped:audio_stop:start stopping:audio_stop .*starting:audio_repeat"; + $attr{$name}{room} = "Services"; + $attr{$name}{icon} = "hue_room_garage"; + $attr{$name}{serviceLogin} = $remote if ($remote); + $attr{$name}{webCmd} = "start:restart:stop:status"; + if (grep /^homebridgeMapping/,split(" ",AttrVal("global","userattr",""))) + { + $attr{$name}{genericDeviceType} = "switch"; + $attr{$name}{homebridgeMapping} = "On=state,valueOff=/stopped|failed/,cmdOff=stop,cmdOn=start\n". + "StatusJammed=state,values=/error|failed/:JAMMED;/.*/:NOT_JAMMED"; + } + } + readingsSingleUpdate($hash,"state","Initialized",1) if ($init_done); + serviced_GetUpdate($hash); + return undef; +} + +sub serviced_Undef($$) +{ + my ($hash,$name) = @_; + RemoveInternalTimer($hash); + BlockingKill($hash->{helper}{RUNNING_PID}) if ($hash->{helper}{RUNNING_PID}); + return undef; +} + +sub serviced_Notify($$) +{ + my ($hash,$dev) = @_; + my $name = $hash->{NAME}; + my $devname = $dev->{NAME}; + return if (IsDisabled($name)); + my $events = deviceEvents($dev,0); + return if (!$events); + if ($devname eq "global" && grep /^INITIALIZED$/,@{$events}) + { + if (AttrNum($name,"serviceGetStatusOnInit",1) && !AttrNum($name,"serviceStatusInterval",0)) + { + Log3 $name,3,"$name: get status of service \"$hash->{SERVICENAME}\" due to startup"; + serviced_Set($hash,$name,"status"); + } + my $delay = AttrVal($name,"serviceAutostart",0); + $delay = $delay > 300 ? 300 : $delay; + if ($delay) + { + Log3 $name,3,"$name: starting service \"$hash->{SERVICENAME}\" with delay of $delay seconds"; + InternalTimer(gettimeofday() + $delay,"serviced_Set","$name|start"); + } + } + return; +} + +sub serviced_Get($@) +{ + my ($hash,$name,$cmd) = @_; + return if (IsDisabled($name) && $cmd ne "?"); + my $params = "status:noArg"; + if ($cmd eq "status") + { + return "Work already/still in progress... Please wait for the current process to finish." + if ($hash->{helper}{RUNNING_PID} && !$hash->{helper}{RUNNING_PID}{terminated}); + serviced_Set($hash,$name,"status"); + } + else + { + return "Unknown argument $cmd for $name, choose one of $params"; + } +} + +sub serviced_Set($@) +{ + my ($hash,$name,$cmd) = @_; + if (ref $hash ne "HASH") + { + ($name,$cmd) = split /\|/,$hash; + $hash = $defs{$name}; + } + return if (IsDisabled($name) && $cmd ne "?"); + my $params = "start:noArg stop:noArg restart:noArg status:noArg"; + return "$cmd is not a valid command for $name, please choose one of $params" + if (!$cmd || $cmd eq "?" || !grep(/^$cmd:noArg$/,split " ",$params)); + return "Work already/still in progress... Please wait for the current process to finish." + if ($hash->{helper}{RUNNING_PID} && !$hash->{helper}{RUNNING_PID}{terminated}); + $cmd = "restart" + if ($cmd eq "start" && ReadingsVal($name,"state","") =~ /^running|starting|failed$/); + my $service = $hash->{SERVICENAME}; + my $login = AttrVal($name,"serviceLogin",""); + my $sudo = AttrNum($name,"serviceSudo",1) || $login !~ /^root@/ ? "sudo " : ""; + my $line = AttrVal($name,"serviceStatusLine",3); + my $com; + $com .= "ssh $login '" if ($login); + $com .= $sudo; + if (AttrNum($name,"serviceInitd",0)) + { + $com .= "service $service $cmd"; + } + else + { + $com .= "systemctl $cmd $service"; + } + $com .= "'" if ($login); + Log3 $name,5,"$name: serviced_Set executing shell command: $com"; + $com = encode_base64($com,""); + if ($hash->{LOCKFILE}) + { + $hash->{helper}{RUNNING_PID} = BlockingCall("serviced_ExecCmd","$name|$cmd|$com|$line","serviced_ExecFinished"); + } + else + { + $hash->{helper}{RUNNING_PID} = BlockingCall("serviced_ExecCmd","$name|$cmd|$com|$line","serviced_ExecFinished",301,"serviced_ExecAborted",$hash); + } + my $state = $cmd eq "status" ? $cmd : $cmd =~ /start/ ? $cmd."ing" : $cmd."ping"; + readingsSingleUpdate($hash,"state",$state,1); + return; +} + +sub serviced_Attr(@) +{ + my ($cmd,$name,$attr_name,$attr_value) = @_; + my $hash = $defs{$name}; + if ($cmd eq "set") + { + if ($attr_name =~ /^disable|serviceGetStatusOnInit|serviceInitd|serviceSudo$/) + { + return "$attr_value not valid, can only be 0 or 1!" + if ($attr_value !~ /^1|0$/); + if ($attr_name eq "disable") + { + BlockingKill($hash->{helper}{RUNNING_PID}) if ($hash->{helper}{RUNNING_PID}); + serviced_GetUpdate($hash) if (AttrNum($name,"serviceStatusInterval",0)); + } + } + elsif ($attr_name =~ /^serviceAutostart|serviceAutostop$/) + { + my $er = "$attr_value not valid for $attr_name, must be a number in seconds like 5 to automatically (re)start service after init of fhem (min: 1, max: 300)!"; + $er = "$attr_value not valid for $attr_name, must be a timeout in seconds like 1 to automatically stop service while shutdown of fhem (min: 1, max: 300)!" if ($attr_name eq "serviceAutostop"); + return $er + if ($attr_value !~ /^(\d{1,3})$/ || $1 > 300 || $1 < 1); + } + elsif ($attr_name eq "serviceLogin") + { + return "$attr_value not valid for $attr_name, must be a ssh login string like 'pi\@192.168.2.22' or 'pi\@myserver'!" + if ($attr_value !~ /^\w{2,}@(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|[\w\.\-_]{2,})$/); + } + elsif ($attr_name =~ /^serviceRegexFailed|serviceRegexStopped|serviceRegexStarted|serviceRegexStarting$/) + { + my $ex = "dead|failed|exited"; + $ex = "inactive|stopped" if ($attr_name eq "serviceRegexStopped"); + $ex = "running|active" if ($attr_name eq "serviceRegexStarted"); + $ex = "activating|starting" if ($attr_name eq "serviceRegexStarting"); + return "$attr_value not valid for $attr_name, must be a regex like '$ex'!" + if ($attr_value !~ /^[\w\-_]{3,}(\|[\w\-_]{3,}){0,}$/); + } + elsif ($attr_name eq "serviceStatusInterval") + { + return "$attr_value not valid for $attr_name, must be a number in seconds like 300 (min: 5, max: 999999)!" + if ($attr_value !~ /^(\d{1,6})$/ || $1 < 5); + $hash->{helper}{interval} = $attr_value; + serviced_GetUpdate($hash); + } + elsif ($attr_name eq "serviceStatusLine") + { + return "$attr_value not valid for $attr_name, must be a number like 2, for 2nd line of status output, or 'last' for last line of status output!" + if ($attr_value !~ /^(\d{1,2}|last)$/); + } + } + else + { + if ($attr_name eq "disable") + { + serviced_GetUpdate($hash); + } + elsif ($attr_name eq "serviceStatusInterval") + { + $hash->{helper}{interval} = 0; + serviced_GetUpdate($hash); + } + } + return; +} + +sub serviced_ExecCmd($) +{ + my ($string) = @_; + my @a = split /\|/,$string; + my $name = $a[0]; + my $cmd = $a[1]; + my $com = decode_base64($a[2]); + my $line = $a[3]; + my $hash = $defs{$name}; + my $lockfile = $hash->{LOCKFILE}; + my $er = 0; + $com .= " 2>&1" if ($cmd ne "status"); + my @qx = qx($com); + if (!@qx && $cmd eq "status") + { + $com .= " 2>&1"; + @qx = qx($com); + $er = 1; + } + Log3 $name,5,"$name: serviced_ExecCmd com: $com, line: $line"; + my @ret; + my $re = ""; + foreach (@qx) + { + chomp; + $_ =~ s/[\s\t ]{1,}/ /g; + $_ =~ s/(^ {1,}| {1,}$)//g; + push @ret,$_ if ($_); + } + $er = 1 if (@ret && $cmd ne "status"); + if (!$er && @ret) + { + $re = $ret[@ret-1] if ($line eq "last" && $ret[@ret-1]); + $re = $ret[$line-1] if ($line =~ /^\d/ && $ret[$line-1]); + } + elsif (@ret) + { + $re = join " ",@ret; + } + if ($lockfile) + { + Log3 $name,3,"$name: shutdown sequence of service $name finished"; + my $er = FileDelete($lockfile); + if ($er) + { + Log3 $name,2,"$name: error while deleting controlfile \"$lockfile\": $er"; + } + else + { + Log3 $name,4,"$name: controlfile \"$lockfile\" deleted successfully"; + } + } + $re = encode_base64($re,""); + return "$name|$er|$re"; +} + +sub serviced_ExecFinished($) +{ + my ($string) = @_; + my @a = split /\|/,$string; + my $name = $a[0]; + my $er = $a[1]; + my $ret = decode_base64($a[2]) if ($a[2]); + my $hash = $defs{$name}; + my $service = $hash->{SERVICENAME}; + delete $hash->{helper}{RUNNING_PID}; + if ($er) + { + readingsBeginUpdate($hash); + readingsBulkUpdate($hash,"state","error"); + readingsBulkUpdate($hash,"error",$ret); + readingsEndUpdate($hash,1); + Log3 $name,3,"$name: Error: $ret"; + } + elsif ($ret) + { + my $refail = AttrVal($name,"serviceRegexFailed","dead|failed|exited"); + my $restop = AttrVal($name,"serviceRegexStopped","inactive|stopped"); + my $restart = AttrVal($name,"serviceRegexStarted","running|active"); + my $restarting = AttrVal($name,"serviceRegexStarting","activating|starting"); + readingsBeginUpdate($hash); + readingsBulkUpdate($hash,"error","none"); + readingsBulkUpdate($hash,"status",$ret); + if ($ret =~ /$restarting/) + { + readingsBulkUpdate($hash,"state","starting"); + Log3 $name,4,"$name: Service \"$hash->{SERVICENAME}\" is starting"; + } + elsif ($ret =~ /$restop/) + { + readingsBulkUpdate($hash,"state","stopped"); + Log3 $name,4,"$name: Service \"$hash->{SERVICENAME}\" is stopped"; + } + elsif ($ret =~ /$refail/) + { + readingsBulkUpdate($hash,"state","failed"); + Log3 $name,4,"$name: Service \"$hash->{SERVICENAME}\" is failed"; + } + elsif ($ret =~ /$restart/) + { + readingsBulkUpdate($hash,"state","running"); + Log3 $name,4,"$name: Service \"$hash->{SERVICENAME}\" is started"; + } + readingsEndUpdate($hash,1); + } + else + { + if (AttrNum($name,"serviceStatusInterval",0)) + { + serviced_GetUpdate($hash); + } + else + { + serviced_Set($hash,$name,"status"); + } + } + return undef; +} + +sub serviced_ExecAborted($) +{ + my ($hash,$cause) = @_; + $cause = "BlockingCall was aborted due to 301 seconds timeout" if (!$cause); + my $name = $hash->{NAME}; + delete $hash->{helper}{RUNNING_PID}; + Log3 $name,2,"$name: BlockingCall aborted: $cause"; + readingsBeginUpdate($hash); + readingsBulkUpdate($hash,"state","error"); + readingsBulkUpdate($hash,"error",$cause); + readingsEndUpdate($hash,1); + return undef; +} + +sub serviced_GetUpdate(@) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + my $sec = defined $hash->{helper}{interval} ? $hash->{helper}{interval} : AttrNum($name,"serviceStatusInterval",undef); + delete $hash->{helper}{interval} if (defined $hash->{helper}{interval}); + RemoveInternalTimer($hash); + return if (IsDisabled($name) || !$sec); + InternalTimer(gettimeofday() + $sec,"serviced_GetUpdate",$hash); + serviced_Set($hash,$name,"status"); + return undef; +} + +sub serviced_Shutdown($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + my $autostop = AttrNum($name,"serviceAutostop",0); + $autostop = $autostop > 300 ? 300 : $autostop; + if ($autostop) + { + $hash->{SHUTDOWNTIME} = time; + Log3 $name,3,"$name: stopping service \"$hash->{SERVICENAME}\" due to shutdown"; + my $lockfile = AttrVal("global","modpath",".")."/log/".$name."_shut.lock"; + my $er = FileWrite($lockfile,"controlfile shutdown sequence"); + if ($er) + { + Log3 $name,2,"$name: error while creating controlfile \"$lockfile\": $er"; + } + else + { + Log3 $name,4,"$name: controlfile \"$lockfile\" created successfully for shutdown-sequence \"$hash->{SERVICENAME}\" "; + $hash->{LOCKFILE} = $lockfile; + serviced_Set($hash,$name,"stop"); + serviced_shutdownwait($hash); + } + } + return undef; +} + +sub serviced_shutdownwait($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + my $autostop = AttrVal($name,"serviceAutostop",0); + $autostop = $autostop > 300 ? 300 : $autostop; + my $lockfile = $hash->{LOCKFILE}; + my ($er,undef) = FileRead($lockfile); + if (!$er) + { + sleep 1; + if (time > $hash->{SHUTDOWNTIME} + $autostop) + { + $er = FileDelete($lockfile); + if ($er) + { + Log3 $name,2,"$name: Error while deleting controlfile \"$lockfile\": $er"; + Log3 $name,3,"$name: Maximum shutdown waittime of $autostop seconds excceeded, force shutdown."; + } + else + { + Log3 $name,3,"$name: Maximum shutdown waittime of $autostop seconds excceeded. Controlfile \"$lockfile\" deleted and force sutdown."; + } + } + else + { + return serviced_shutdownwait($hash); + } + } + return undef; +} + +1; + +=pod +=item device +=item summary local/remote services management +=item summary_DE lokale/entfernte Dienste Verwaltung +=begin html + + +

serviced

+
    + With serviced you are able to control running services either running on localhost or a remote host.
    + The usual command are available: start/restart/stop/status.
    +
    + +

    Define

    +
      + define <name> serviced <service name> [<user@ip-address>]
      +
    +
    + Example for running serviced for local service(s): +

    +
      + define hb serviced homebridge
      +
    +
    + Example for running serviced for remote service(s): +

    +
      + define hyp serviced hyperion pi@192.168.1.4
      +
    +
    + For remote services you have to grant passwordless ssh access for the user which is running FHEM (usually fhem). You'll find a tutorial how to do that by visiting this link. +

    + To use systemctl (systemd) or service (initd) you have to grant permissions to the system commands for the user which is running FHEM (usually fhem) by editing the sudoers file (/etc/sudoers) (visudo). +

    + For systemd (please check your local paths): +
    +
      + fhem ALL=(ALL) NOPASSWD:/bin/systemctl +
    +
    + For initd (please check your local paths): +
    +
      + fhem ALL=(ALL) NOPASSWD:/usr/sbin/service +
    +

    + If you have homebridgeMapping in your attributes an appropriate mapping will be added, genericDeviceType as well. +
    + +

    Set

    +
      +
    • + start
      + start the stopped service +
    • +
    • + stop
      + stop the started service +
    • +
    • + restart
      + restart the service +
    • +
    • + status
      + get status of service +
    • +
    +
    + +

    Get

    +
      +
    • + status
      + get status of service
      + same like 'set status' +
    • +
    +
    + +

    Attributes

    +
      +
    • + disable
      + stop polling and disable device completely
      + default: 0 +
    • +
    • + serviceAutostart
      + delay in seconds to automatically (re)start service after start of FHEM
      + default: +
    • +
    • + serviceAutostop
      + timeout in seconds to automatically stop service while shutdown of FHEM
      + default: +
    • +
    • + serviceGetStatusOnInit
      + get status of service automatically on FHEM start
      + default: 1 +
    • +
    • + serviceInitd
      + use initd (system) instead of systemd (systemctl)
      + default: 0 +
    • +
    • + serviceLogin
      + ssh login string for services running on remote hosts
      + passwordless ssh is mandatory
      + default: +
    • +
    • + serviceRegexFailed
      + regex for failed status
      + default: dead|failed|exited +
    • +
    • + serviceRegexStarted
      + regex for running status
      + default: running|active +
    • +
    • + serviceRegexStarting
      + regex for starting status
      + default: activating|starting +
    • +
    • + serviceRegexStopped
      + regex for stopped status
      + default: inactive|stopped +
    • +
    • + serviceStatusInterval
      + interval of getting status automatically
      + default: +
    • +
    • + serviceStatusLine
      + line number of status output containing the status information
      + default: 3 +
    • +
    • + serviceSudo
      + use sudo
      + default: 1 +
    • +
    +
    + +

    Readings

    +

    All readings updates will create events.

    +
      +
    • + error
      + last occured error, none if no error occured
      +
    • +
    • + state
      + current state +
    • +
    • + status
      + last status line from 'get/set status' +
    • +
    +
+ +=end html +=begin html_DE + + +

serviced

+
    + Mit serviced können lokale und entfernte Dienste verwaltet werden.
    + Die üblichen Kommandos sind verfügbar: start/restart/stop/status.
    +
    + +

    Define

    +
      + define <name> serviced <Dienst Name> [<user@ip-adresse>]
      +
    +
    + Beispiel serviced für lokale Dienste: +

    +
      + define hb serviced homebridge
      +
    +
    + Beispiel serviced für entfernte Dienste: +

    +
      + define hyp serviced hyperion pi@192.168.1.4
      +
    +
    + Für entfernte Dienste muss dem Benutzer unter dem FHEM läuft dass passwortlose Anmelden per SSH erlaubt werden. Eine Anleitung wie das zu machen geht ist unter diesem Link abrufbar. +

    + Zur Benutzung von systemctl (systemd) oder service (initd) müssen dem Benutzer unter dem FHEM läuft die entsprechenden Rechte in der sudoers Datei erteilt werden (/etc/sudoers) (visudo). +

    + Für systemd (bitte mit eigenen Pfaden abgleichen): +
    +
      + fhem ALL=(ALL) NOPASSWD:/bin/systemctl +
    +
    + Für initd (bitte mit eigenen Pfaden abgleichen): +
    +
      + fhem ALL=(ALL) NOPASSWD:/usr/sbin/service +
    +

    + Wenn homebridgeMapping in der Attributliste ist, so wird ein entsprechendes Mapping hinzugefügt, ebenso genericDeviceType. +
    + +

    Set

    +
      +
    • + start
      + angehaltenen Dienst starten +
    • +
    • + stop
      + laufenden Dienst anhalten +
    • +
    • + restart
      + Dienst neu starten +
    • +
    • + status
      + Status des Dienstes abrufen +
    • +
    +
    + +

    Get

    +
      +
    • + status
      + Status des Dienstes abrufen
      + identisch zu 'set status' +
    • +
    +
    + +

    Attribute

    +
      +
    • + disable
      + Anhalten der automatischen Abfrage und komplett deaktivieren
      + Voreinstellung: 0 +
    • +
    • + serviceAutostart
      + Verzögerung in Sekunden um den Dienst nach Start von FHEM (neu) zu starten
      + Voreinstellung: +
    • +
    • + serviceAutostop
      + Timeout in Sekunden um den Dienst bei Beenden von FHEM ebenso zu beenden
      + Voreinstellung: +
    • +
    • + serviceGetStatusOnInit
      + beim Start von FHEM automatisch den Status des Dienstes abrufen
      + Voreinstellung: 1 +
    • +
    • + serviceInitd
      + benutze initd (system) statt systemd (systemctl)
      + Voreinstellung: 0 +
    • +
    • + serviceLogin
      + SSH Anmeldedaten für entfernten Dienst
      + passwortloser SSH Zugang ist Grundvoraussetzung
      + Voreinstellung: +
    • +
    • + serviceRegexFailed
      + Regex für failed Status
      + Voreinstellung: dead|failed|exited +
    • +
    • + serviceRegexStarted
      + Regex für running Status
      + Voreinstellung: running|active +
    • +
    • + serviceRegexStarting
      + Regex für starting Status
      + Voreinstellung: activating|starting +
    • +
    • + serviceRegexStopped
      + Regex für stopped Status
      + Voreinstellung: inactive|stopped +
    • +
    • + serviceStatusInterval
      + Interval um den Status automatisch zu aktualisieren
      + Voreinstellung: +
    • +
    • + serviceStatusLine
      + Zeilennummer der Status Rückgabe welche die Status Information enthält
      + Voreinstellung: 3 +
    • +
    • + serviceSudo
      + sudo benutzen
      + Voreinstellung: 1 +
    • +
    +
    + +

    Readings

    +

    Alle Aktualisierungen der Readings erzeugen Events.

    +
      +
    • + error
      + letzter aufgetretener Fehler, none wenn kein Fehler aufgetreten ist +
    • +
    • + state
      + aktueller Zustand +
    • +
    • + status
      + letzte Statuszeile von 'get/set status' +
    • +
    +
+ +=end html_DE +=cut diff --git a/MAINTAINER.txt b/MAINTAINER.txt index e87d170c8..220de72e7 100644 --- a/MAINTAINER.txt +++ b/MAINTAINER.txt @@ -497,6 +497,7 @@ FHEM/98_rssFeed.pm Benni Unterstuetzende Dienste FHEM/98_Siro.pm Byte09 / Dr. Smag Sonstige Systeme FHEM/98_SmarterCoffee CoolTux Sonstige Systeme FHEM/98_Sprinkle.pm Tobias Unterstuetzende Dienste +FHEM/98_serviced.pm DeeSPe Unterstuetzende Dienste FHEM/98_statistics.pm tupol Unterstuetzende Dienste (Link als PM an tupol) FHEM/98_STOCKQUOTES.pm vbs Unterstuetzende Dienste FHEM/98_structure.pm rudolfkoenig Automatisierung