diff --git a/fhem/CHANGED b/fhem/CHANGED index 2609688db..e2c276c74 100644 --- a/fhem/CHANGED +++ b/fhem/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: 39_gassistant: Google Assistant support - bugfix: 70_BOTVAC: vendor name is no longer case sensitive - feature: 49_SSCam: V8.8.0, send snapshots integrated by telegram - change: 93_DbRep: running though tableCurrentFillup if database is closed diff --git a/fhem/FHEM/39_gassistant.pm b/fhem/FHEM/39_gassistant.pm new file mode 100755 index 000000000..fcbd90ffb --- /dev/null +++ b/fhem/FHEM/39_gassistant.pm @@ -0,0 +1,779 @@ + +# $Id: 39_gassistant.pm 18283 2019-01-16 16:58:23Z justme1968 $ + +package main; + +use strict; +use warnings; + +use CoProcess; + +use JSON; +use Data::Dumper; + +use POSIX; +use Socket; + +use vars qw(%modules); +use vars qw(%defs); +use vars qw(%attr); +use vars qw($readingFnAttributes); +use vars qw($FW_ME); + +sub Log($$); +sub Log3($$$); + +sub +gassistant_Initialize($) +{ + my ($hash) = @_; + + $hash->{ReadFn} = "gassistant_Read"; + + $hash->{DefFn} = "gassistant_Define"; + $hash->{NotifyFn} = "gassistant_Notify"; + $hash->{UndefFn} = "gassistant_Undefine"; + $hash->{DelayedShutdownFn} = "gassistant_DelayedShutdownFn"; + $hash->{ShutdownFn} = "gassistant_Shutdown"; + $hash->{SetFn} = "gassistant_Set"; + $hash->{GetFn} = "gassistant_Get"; + $hash->{AttrFn} = "gassistant_Attr"; + $hash->{AttrList} = "articles prepositions ". + "gassistantFHEM-cmd ". + "gassistantFHEM-config ". + "gassistantFHEM-home ". + "gassistantFHEM-log ". + "gassistantFHEM-params ". + "gassistantFHEM-auth ". + #"gassistantFHEM-filter ". + #"gassistantFHEM-sshHost gassistantFHEM-sshUser ". + "nrarchive ". + "disable:1 disabledForIntervals ". + $readingFnAttributes; + + $hash->{FW_detailFn} = "gassistant_detailFn"; + $hash->{FW_deviceOverview} = 1; +} + +##################################### + +sub +gassistant_AttrDefaults($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + +} + +sub +gassistant_Define($$) +{ + my ($hash, $def) = @_; + + my @a = split("[ \t][ \t]*", $def); + + return "Usage: define gassistant" if(@a != 2); + + my $name = $a[0]; + $hash->{NAME} = $name; + + my $d = $modules{$hash->{TYPE}}{defptr}; + return "$hash->{TYPE} device already defined as $d->{NAME}." if( defined($d) && $name ne $d->{NAME} ); + $modules{$hash->{TYPE}}{defptr} = $hash; + + gassistant_AttrDefaults($hash); + + $hash->{NOTIFYDEV} = "global"; + + if( $attr{global}{logdir} ) { + CommandAttr(undef, "$name gassistantFHEM-log %L/gassistant-%Y-%m-%d.log") if( !AttrVal($name, 'gassistantFHEM-log', undef ) ); + } else { + CommandAttr(undef, "$name gassistantFHEM-log ./log/gassistant-%Y-%m-%d.log") if( !AttrVal($name, 'gassistantFHEM-log', undef ) ); + } + + #CommandAttr(undef, "$name gassistantFHEM-filter room=GoogleAssistant") if( !AttrVal($name, 'gassistantFHEM-filter', undef ) ); + + if( !AttrVal($name, 'devStateIcon', undef ) ) { + CommandAttr(undef, "$name stateFormat gassistant-fhem"); + CommandAttr(undef, "$name devStateIcon stopped:control_home\@red:start stopping:control_on_off\@orange running.*:control_on_off\@green:stop") + } + + $hash->{CoProcess} = { name => 'gassistant-fhem', + cmdFn => 'gassistant_getCMD', + }; + + if( $init_done ) { + CoProcess::start($hash); + } else { + $hash->{STATE} = 'active'; + } + + return undef; +} + +sub +gassistant_Notify($$) +{ + my ($hash,$dev) = @_; + + return if($dev->{NAME} ne "global"); + return if(!grep(m/^INITIALIZED|REREADCFG$/, @{$dev->{CHANGED}})); + + CoProcess::start($hash); + + return undef; +} + +sub +gassistant_Undefine($$) +{ + my ($hash, $name) = @_; + + if( $hash->{PID} ) { + $hash->{undefine} = 1; + $hash->{undefine} = $hash->{CL} if( $hash->{CL} ); + + $hash->{reason} = 'delete'; + CoProcess::stop($hash); + + return "$name will be deleted after gassistant-fhem has stopped or after 5 seconds. whatever comes first."; + } + + delete $modules{$hash->{TYPE}}{defptr}; + + return undef; +} +sub +gassistant_DelayedShutdownFn($) +{ + my ($hash) = @_; + + if( $hash->{PID} ) { + $hash->{shutdown} = 1; + $hash->{shutdown} = $hash->{CL} if( $hash->{CL} ); + + $hash->{reason} = 'shutdown'; + CoProcess::stop($hash); + + return 1; + } + + return undef; +} +sub +gassistant_Shutdown($) +{ + my ($hash) = @_; + + CoProcess::terminate($hash); + + delete $modules{$hash->{TYPE}}{defptr}; + + return undef; +} + +sub +gassistant_detailFn($$$$) +{ + my ($FW_wname, $d, $room, $pageHash) = @_; # pageHash is set for summaryFn. + my $hash = $defs{$d}; + my $name = $hash->{NAME}; + + my $ret; + + my $logfile = AttrVal($name, 'gassistantFHEM-log', 'FHEM' ); + if( $logfile && $logfile ne 'FHEM' ) { + my $name = 'gassistantFHEMlog'; + $ret .= "". AttrVal($name, "alias", "Logfile") ."
"; + } + + #if( my $url = ReadingsVal($name, 'gassistantFHEM.loginURL', undef ) ) { + # $ret .= "Login
"; + #} + + return $ret; +} + +sub +gassistant_Read($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + my $buf = CoProcess::readFn($hash); + return undef if( !$buf ); + + if( $buf =~ m/^\*\*\* ([^\s]+) (.+)/ ) { + my $service = $1; + my $message = $2; + + if( $service eq 'FHEM:' ) { + if( $message =~ m/^connection failed(: (.*))?/ ) { + my $reason = $2; + + $hash->{reason} = 'failed to connect to fhem'; + $hash->{reason} .= ": $reason" if( $reason ); + CoProcess::stop($hash); + } + } + } + + return undef; +} + +sub +gassistant_getLocalIP() +{ + my $socket = IO::Socket::INET->new( + Proto => 'udp', + PeerAddr => '8.8.8.8:53', # google dns + #PeerAddr => '198.41.0.4:53', # a.root-servers.net + ); + return '' if( !$socket ); + + my $ip = $socket->sockhost; + close( $socket ); + + return $ip if( $ip ); + + #$ip = inet_ntoa( scalar gethostbyname( hostname() || 'localhost' ) ); + #return $ip if( $ip ); + + return ''; +} +sub +gassistant_configDefault($;$) +{ + my ($hash,$force) = @_; + my $name = $hash->{NAME}; + + my $json; + my $fh; + + my $configfile = $attr{global}{configfile}; + $configfile = substr( $configfile, 0, rindex($configfile,'/')+1 ); + $configfile .= 'gassistant-fhem.cfg'; + + local *gassistant_readAndBackup = sub() { + if( -e $configfile ) { + my $json; + if( open( my $fh, "<$configfile") ) { + Log3 $name, 3, "$name: found old config at $configfile"; + + local $/; + $json = <$fh>; + close( $fh ); + } else { + Log3 $name, 2, "$name: can't read $configfile"; + } + + if( rename( $configfile, $configfile.".previous" ) ) { + Log3 $name, 4, "$name: renamed $configfile to $configfile.previous"; + } else { + Log3 $name, 2, "$name: could not rename $configfile to $configfile.previous :$!"; + } + + return $json; + } + }; + + $json = gassistant_readAndBackup(); + if( !open( $fh, ">$configfile") ) { + Log3 $name, 2, "$name: can't write $configfile"; + + $configfile = $attr{global}{statefile}; + $configfile = substr( $configfile, 0, rindex($configfile,'/')+1 ); + $configfile .= 'gassistant-fhem.cfg'; + + $json = gassistant_readAndBackup(); + if( !open( $fh, ">$configfile") ) { + Log3 $name, 2, "$name: can't write $configfile"; + $configfile = '/tmp/gassistant-fhem.cfg'; + + $json = gassistant_readAndBackup(); + if( !open( $fh, ">$configfile") ) { + Log3 $name, 2, "$name: can't write $configfile"; + + return ""; + } + } + } + + if( $fh ) { + my $ip = '127.0.0.1'; + if( AttrVal($name, 'gassistantFHEM-sshHost', undef ) ) { + $ip = gassistant_getLocalIP(); + } + + my $conf; + $conf = eval { decode_json($json) } if( $json && !$force ); + + if( !$conf->{gassistant} ) { + $conf->{gassistant} = { description => 'FHEM Connect', + }; + } + + $conf->{connections} = [{}] if( !$conf->{connections} ); + $conf->{connections}[0]->{name} = 'FHEM' if( !$conf->{connections}[0]->{name} ); + $conf->{connections}[0]->{server} = $ip if( !$conf->{connections}[0]->{server} ); + $conf->{connections}[0]->{filter} = 'room=GoogleAssistant' if( !$conf->{connections}[0]->{filter} ); + $conf->{connections}[0]->{uid} = $< if( $conf->{sshproxy} ); + + my $web = $defs{WEB}; + if( !$web ) { + if( my @names = devspec2array('TYPE=FHEMWEB:FILTER=TEMPORARY!=1') ) { + $web = $defs{$names[0]} if( defined($defs{$names[0]}) ); + + Log3 $name, 4, "$name: using $names[0] as FHEMWEB device." if( $web ); + } + } else { + Log3 $name, 4, "$name: using WEB as FHEMWEB device." if( $web ); + } + + if( $web ) { + $conf->{connections}[0]->{port} = $web->{PORT} if( !$conf->{connections}[0]->{port} ); + $conf->{connections}[0]->{webname} = AttrVal( 'WEB', 'webname', 'fhem' ) if( !$conf->{connections}[0]->{webname} ); + } else { + Log3 $name, 2, "$name: no FHEMWEB device found. please adjust config file manualy."; + } + + $json = JSON->new->pretty->utf8->encode($conf); + print $fh $json; + close( $fh ); + + if( index($configfile,'/') == 0 ) { + system( "ln -sf $configfile $attr{global}{modpath}/FHEM/gassistant-fhem.cfg" ); + } else { + system( "ln -sf `pwd`/$configfile $attr{global}{modpath}/FHEM/gassistant-fhem.cfg" ); + } + } + + $configfile = "./$configfile" if( index($configfile,'/') == -1 ); + + Log3 $name, 2, "$name: created default configfile: $configfile"; + + CommandAttr(undef, "$name gassistantFHEM-config $configfile") if( !AttrVal($name, 'gassistantFHEM-config', undef ) ); + + CommandSave(undef,undef) if( AttrVal( "autocreate", "autosave", 1 ) ); + + return $configfile; +} + +sub +gassistant_getCMD($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + return undef if( !$init_done ); + + my $url = ReadingsVal($name, 'gassistantFHEM.loginURL', undef); + if( !$url ) { + my $url = getKeyValue('gassistantFHEM.loginURL'); + readingsSingleUpdate($hash, 'gassistantFHEM.loginURL', $url, 1 ) if( $url ); + } + my $token = ReadingsVal($name, 'gassistantFHEM.refreshToken', undef); + if( !$token ) { + my $token = getKeyValue('gassistantFHEM.refreshToken'); + readingsSingleUpdate($hash, 'gassistantFHEM.refreshToken', $token, 1 ) if( $token ); + } elsif( $token !~ m/^crypt:/ ) { + fhem( "set $name refreshToken $token" ); + } + + + if( !AttrVal($name, 'gassistantFHEM-config', undef ) ) { + gassistant_configDefault($hash); + } + + return undef if( IsDisabled($name) ); + #return undef if( ReadingsVal($name, 'gassistant-fhem', 'unknown') =~ m/^running/ ); + + + my $ssh_cmd; + if( my $host = AttrVal($name, 'gassistantFHEM-sshHost', undef ) ) { + my $ssh = qx( which ssh ); chomp( $ssh ); + if( my $user = AttrVal($name, 'gassistantFHEM-sshUser', undef ) ) { + $ssh_cmd = "$ssh $host -u $user"; + } else { + $ssh_cmd = "$ssh $host"; + } + + Log3 $name, 3, "$name: using ssh cmd $ssh_cmd"; + } + + my $cmd; + if( $ssh_cmd ) { + $cmd = AttrVal( $name, "gassistantFHEM-cmd", qx( $ssh_cmd which gassistant-fhem ) ); + } else { + $cmd = AttrVal( $name, "gassistantFHEM-cmd", qx( which gassistant-fhem ) ); + } + chomp( $cmd ); + + if( !$ssh_cmd && !(-X $cmd) ) { + my $msg = "gassistant-fhem not installed. install with 'sudo npm install -g gassistant-fhem'."; + $msg = "$cmd does not exist" if( $cmd ); + return (undef, $msg); + } + + $cmd = "$ssh_cmd $cmd" if( $ssh_cmd ); + + if( my $home = AttrVal($name, 'gassistantFHEM-home', undef ) ) { + $home = $ENV{'PWD'} if( $home eq 'PWD' ); + $ENV{'HOME'} = $home; + Log3 $name, 2, "$name: setting \$HOME to $home"; + } + if( my $config = AttrVal($name, 'gassistantFHEM-config', undef ) ) { + if( $ssh_cmd ) { + qx( $ssh_cmd "cat > /tmp/gassistant-fhem.cfg" < $config ); + $cmd .= " -c /tmp/gassistant-fhem.cfg"; + } else { + $cmd .= " -c $config"; + } + } + if( my $auth = AttrVal($name, 'gassistantFHEM-auth', undef ) ) { + $auth = gassistant_decrypt( $auth ); + $cmd .= " -a $auth"; + } + if( my $ssl = AttrVal('WEB', "HTTPS", undef ) ) { + $cmd .= " -s"; + } + if( my $params = AttrVal($name, 'gassistantFHEM-params', undef ) ) { + $cmd .= " $params"; + } + + if( AttrVal( $name, 'verbose', 3 ) == 5 ) { + Log3 $name, 2, "$name: starting gassistant-fhem: $cmd"; + } else { + my $msg = $cmd; + $msg =~ s/-a\s+[^:]+:[^\s]+/-a xx:xx/g; + Log3 $name, 2, "$name: starting gassistant-fhem: $msg"; + } + + return $cmd; + +} + +sub +gassistant_Set($$@) +{ + my ($hash, $name, $cmd, @args) = @_; + + my $list = "authcode refreshToken createDefaultConfig:noArg clearCredentials:noArg unregister:noArg reload:noArg"; + + if( $cmd eq 'reload' ) { + $hash->{".triggerUsed"} = 1; + if( @args ) { + FW_directNotify($name, "reload $args[0]"); + } else { + FW_directNotify($name, 'reload'); + } + DoTrigger( $name, "reload" ); + + return undef; + + } elsif( $cmd eq 'createDefaultConfig' ) { + my $force = 0; + $force = 1 if( $args[0] && $args[0] eq 'force' ); + my $config = gassistant_configDefault($hash, $force); + + return "created default config: $config"; + + } elsif( $cmd eq 'loginURL' ) { + return "usage: set $name $cmd " if( !@args ); + my $url = $args[0]; + + $url = "$url
"; + + $hash->{".triggerUsed"} = 1; + + setKeyValue('gassistantFHEM.loginURL', $url ); + readingsSingleUpdate($hash, 'gassistantFHEM.loginURL', $url, 1 ); + + CommandSave(undef,undef) if( AttrVal( "autocreate", "autosave", 1 ) ); + + return undef; + + } elsif( $cmd eq 'authcode' ) { + return "usage: set $name $cmd " if( !@args ); + my $authcode = $args[0]; + + $hash->{".triggerUsed"} = 1; + + DoTrigger( $name, "authcode: $authcode" ); + + return undef; + + } elsif( $cmd eq 'refreshToken' ) { + return "usage: set $name $cmd " if( !@args ); + my $token = $args[0]; + + $hash->{".triggerUsed"} = 1; + + $token = gassistant_encrypt($token); + setKeyValue('gassistantFHEM.refreshToken', $token ); + readingsSingleUpdate($hash, 'gassistantFHEM.refreshToken', $token, 1 ); + + CommandSave(undef,undef) if( AttrVal( "autocreate", "autosave", 1 ) ); + + return undef; + + } elsif( $cmd eq 'clearCredentials' ) { + setKeyValue('gassistantFHEM.loginURL', undef ); + setKeyValue('gassistantFHEM.refreshToken', undef ); + + readingsBeginUpdate($hash); + readingsBulkUpdate($hash, 'gassistantFHEM.loginURL', '', 1 ); + readingsBulkUpdate($hash, 'gassistantFHEM.refreshToken', '', 1 ); + readingsEndUpdate($hash,1); + + CommandSave(undef,undef) if( AttrVal( "autocreate", "autosave", 1 ) ); + + FW_directNotify($name, 'clearCredentials'); + + return undef; + + } elsif( $cmd eq 'unregister' ) { + FW_directNotify($name, 'unregister'); + DoTrigger( $name, "unregister" ); + + fhem( "set $name clearCredentials" ); + + CommandAttr( undef, '$name disable 1' ); + + CommandSave(undef,undef) if( AttrVal( "autocreate", "autosave", 1 ) ); + + return undef; + } + + return CoProcess::setCommands($hash, $list, $cmd, @args); + + return "Unknown argument $cmd, choose one of $list"; +} + + + +sub +gassistant_Get($$@) +{ + my ($hash, $name, $cmd) = @_; + + my $list = "loginURL refreshToken"; + + if( $cmd eq 'loginURL' ) { + my $url = ReadingsVal($name, 'gassistantFHEM.loginURL', undef); + + return $url; + + } elsif( $cmd eq 'refreshToken' ) { + my $token = ReadingsVal($name, 'gassistantFHEM.refreshToken', undef); + + return gassistant_decrypt($token); + + + } + + return "Unknown argument $cmd, choose one of $list"; +} + +sub +gassistant_Parse($$;$) +{ + my ($hash,$data,$peerhost) = @_; + my $name = $hash->{NAME}; +} + +sub +gassistant_encrypt($) +{ + my ($decoded) = @_; + my $key = getUniqueId(); + + return "" if( !$decoded ); + return $decoded if( $decoded =~ /^crypt:(.*)/ ); + + my $encoded; + for my $char (split //, $decoded) { + my $encode = chop($key); + $encoded .= sprintf("%.2x",ord($char)^ord($encode)); + $key = $encode.$key; + } + + return 'crypt:'. $encoded; +} +sub +gassistant_decrypt($) +{ + my ($encoded) = @_; + my $key = getUniqueId(); + + return "" if( !$encoded ); + + $encoded = $1 if( $encoded =~ /^crypt:(.*)/ ); + + my $decoded; + for my $char (map { pack('C', hex($_)) } ($encoded =~ /(..)/g)) { + my $decode = chop($key); + $decoded .= chr(ord($char)^ord($decode)); + $key = $decode.$key; + } + + return $decoded; +} + +sub +gassistant_Attr($$$) +{ + my ($cmd, $name, $attrName, $attrVal) = @_; + + my $orig = $attrVal; + + my $hash = $defs{$name}; + if( $attrName eq 'disable' ) { + my $hash = $defs{$name}; + if( $cmd eq "set" && $attrVal ne "0" ) { + $attrVal = 1; + CoProcess::stop($hash); + + } else { + $attr{$name}{$attrName} = 0; + CoProcess::start($hash); + + } + + } elsif( $attrName eq 'disabledForIntervals' ) { + $attr{$name}{$attrName} = $attrVal; + + CoProcess::start($hash); + + } elsif( $attrName eq 'gassistantFHEM-log' ) { + if( $cmd eq "set" && $attrVal && $attrVal ne 'FHEM' ) { + fhem( "defmod -temporary gassistantFHEMlog FileLog $attrVal fakelog" ); + CommandAttr( undef, 'gassistantFHEMlog room hidden' ); + #if( my $room = AttrVal($name, "room", undef ) ) { + # CommandAttr( undef,"gassistantFHEMlog room $room" ); + #} + $hash->{logfile} = $attrVal; + } else { + fhem( "delete gassistantFHEMlog" ); + } + + $attr{$name}{$attrName} = $attrVal; + + CoProcess::start($hash); + + } elsif( $attrName eq 'gassistantFHEM-auth' ) { + if( $cmd eq "set" && $attrVal ) { + $attrVal = gassistant_encrypt($attrVal); + } + $attr{$name}{$attrName} = $attrVal; + + CoProcess::start($hash); + + if( $cmd eq "set" && $orig ne $attrVal ) { + $attr{$name}{$attrName} = $attrVal; + return "stored obfuscated auth data"; + } + + } elsif( $attrName eq 'gassistantFHEM-params' ) { + $attr{$name}{$attrName} = $attrVal; + + CoProcess::start($hash); + + } elsif( $attrName eq 'gassistantFHEM-sshHost' ) { + $attr{$name}{$attrName} = $attrVal; + + CoProcess::start($hash); + + } elsif( $attrName eq 'gassistantFHEM-sshUser' ) { + $attr{$name}{$attrName} = $attrVal; + + CoProcess::start($hash); + + } + + + if( $cmd eq 'set' ) { + if( $orig ne $attrVal ) { + $attr{$name}{$attrName} = $attrVal; + return "stored modified value"; + } + + } else { + delete $attr{$name}{$attrName}; + + RemoveInternalTimer($hash); + InternalTimer(gettimeofday(), "gassistant_AttrDefaults", $hash, 0); + } + + return; +} + + +1; + +=pod +=item summary Module to control the FHEM/Google Assistant integration +=item summary_DE Modul zur Konfiguration der FHEM/Google Assistant Integration +=begin html + + +

gassistant

+
    + Module to control the integration of Google Assistant devices with FHEM.

    + + Notes: + + + + Set +
      +
    • reload
      + Reloads the device name or all devices in gassistant-fhem. + Will try to send a proacive event to amazon. If this succedes no manual device discovery is needed. + If this fails you have to you have to manually start a device discovery + for the home automation skill in the amazon gassistant app.
    • + +
    • createDefaultConfig
      + adds the default config for the sshproxy to the existing config file or creates a new config file. sets the + gassistantFHEM-config attribut if not already set.
    • + +
    • clearCredentials
      + clears all stored sshproxy credentials
    • + +
    • unregister
      + unregister and delete all data in FHEM Connect
    • +
      +
    + + + Get +
      +
    + + + Attr +
      +
    • gassistantFHEM-auth
      + the user:password combination to use to connect to fhem.
    • +
    • gassistantFHEM-cmd
      + The command to use as gassistant-fhem.
    • +
    • gassistantFHEM-config
      + The config file to use for gassistant-fhem.
    • +
    • gassistantFHEM-log
      + The log file to use for gassistant-fhem. For possible %-wildcards see FileLog.
    • . +
    • nrarchive
      + see FileLog
    • . +
    • gassistantFHEM-params
      + Additional gassistant-fhem cmdline params.
    • + +
    • gassistantName
      + The name to use for a device with gassistant.
    • +
    • realRoom
      + The room name to use for a device with gassistant.
    • +
    +

+ +=end html +=cut diff --git a/fhem/MAINTAINER.txt b/fhem/MAINTAINER.txt index e6c49a6ab..315bbced6 100644 --- a/fhem/MAINTAINER.txt +++ b/fhem/MAINTAINER.txt @@ -217,6 +217,7 @@ FHEM/38_CO20.pm markus-m Sonstiges FHEM/38_JawboneUp.pm domschl Sonstiges FHEM/38_netatmo.pm markus-m http://forum.fhem.de/index.php/topic,53500.0.html FHEM/39_alexa.pm justme1968 Frontends/Sprachsteuerung +FHEM/39_gassistant.pm dominik Frontends/Sprachsteuerung FHEM/39_siri.pm justme1968 Frontends/Sprachsteuerung FHEM/39_Talk2Fhem.pm Phill Frontends/Sprachsteuerung FHEM/40_RFXCOM.pm wherzig RFXTRX