################################################################ # $Id$ # # Release 2018-11-01 SetExtension # # Copyright notice # # (c) 2016 Copyright: Volker Kettenbach # e-mail: volker at kettenbach minus it dot de # # Description: # This is an FHEM-Module for the TP Link TPLinkHS110110/110 # wifi controlled power outlet. # It support switching on and of the outlet as well as switching # on and of the nightmode (green led off). # It supports reading several readings as well as the # realtime power readings of the HS110. # # Requirements # Perl Module: IO::Socket::INET # Perl Module: IO::Socket::Timeout # # In recent debian based distributions IO::Socket::Timeout can # be installed by "apt-get install libio-socket-timeout-perl" # In older distribution try "cpan IO::Socket::Timeout" # # Origin: # https://github.com/kettenbach-it/FHEM-TPLink-HS110 # ################################################################ package main; use strict; use warnings; use IO::Socket::INET; use IO::Socket::Timeout; use JSON; use SetExtensions; use Data::Dumper; ##################################### sub TPLinkHS110_Initialize($) { my ($hash) = @_; $hash->{DefFn} = "TPLinkHS110_Define"; $hash->{ReadFn} = "TPLinkHS110_Get"; $hash->{SetFn} = "TPLinkHS110_Set"; $hash->{UndefFn} = "TPLinkHS110_Undefine"; $hash->{DeleteFn} = "TPLinkHS110_Delete"; $hash->{AttrFn} = "TPLinkHS110_Attr"; $hash->{AttrList} = "interval ". "disable:0,1 " . "nightmode:on,off " . "timeout " . "$readingFnAttributes"; } ##################################### sub TPLinkHS110_Define($$) { my ($hash, $def) = @_; my $name= $hash->{NAME}; my @a = split( "[ \t][ \t]*", $def ); return "Wrong syntax: use define TPLinkHS110 " if (int(@a) != 3); $hash->{INTERVAL}=300; $hash->{TIMEOUT}=1; $hash->{HOST}=$a[2]; $attr{$name}{"disable"} = 0; # initial request after 2 secs, there timer is set to interval for further update InternalTimer(gettimeofday()+2, "TPLinkHS110_Get", $hash, 0); Log3 $hash, 3, "TPLinkHS110: $name defined."; return undef; } ##################################### sub TPLinkHS110_Get($$) { my ($hash) = @_; my $name = $hash->{NAME}; my($success,$json,$realtimejson); return "Device disabled in config" if ($attr{$name}{"disable"} eq "1"); RemoveInternalTimer($hash); InternalTimer(gettimeofday()+$hash->{INTERVAL}, "TPLinkHS110_Get", $hash, 1); $hash->{NEXTUPDATE}=localtime(gettimeofday()+$hash->{INTERVAL}); my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime(time); $mon++; $year += 1900; my $remote_host = $hash->{HOST}; my $remote_port = 9999; my $command = '{"system":{"get_sysinfo":{}}}'; my $c = encrypt($command); my $socket = IO::Socket::INET->new(PeerAddr => $remote_host, PeerPort => $remote_port, Proto => 'tcp', Type => SOCK_STREAM, Timeout => $hash->{TIMEOUT} ) or return "Couldn't connect to $remote_host:$remote_port: $@\n"; $socket->write($c); IO::Socket::Timeout->enable_timeouts_on($socket); $socket->read_timeout(.5); my $data; $data = <$socket>; $socket->close(); readingsBeginUpdate($hash); $data = decrypt(substr($data,4)); ($success,$json) = TPLinkHS110__evaljson($name,$data); if(!$success) { readingsEndUpdate($hash, 1); return; } Log3 $hash, 3, "TPLinkHS110: $name Get called. Relay state: $json->{'system'}->{'get_sysinfo'}->{'relay_state'}, RSSI: $json->{'system'}->{'get_sysinfo'}->{'rssi'}"; my $hw_ver = $json->{'system'}->{'get_sysinfo'}->{'hw_ver'}; my %hwMap = hwMapping(); foreach my $key (sort keys %{$json->{'system'}->{'get_sysinfo'}}) { my $sysinfoValue = $json->{'system'}->{'get_sysinfo'}->{$key}; #adjust different hw_ver readings if (exists($hwMap{$hw_ver}{'system'}{'get_sysinfo'}{$key})) { if (exists($hwMap{$hw_ver}{'system'}{'get_sysinfo'}{$key}{'factor'})) { $sysinfoValue = $sysinfoValue * $hwMap{$hw_ver}{'system'}{'get_sysinfo'}{$key}{'factor'}; } $key = $hwMap{$hw_ver}{'system'}{'get_sysinfo'}{$key}{'name'} } readingsBulkUpdate($hash, $key, $sysinfoValue); } if ($json->{'system'}->{'get_sysinfo'}->{'relay_state'} == 0) { readingsBulkUpdate($hash, "state", "off"); } if ($json->{'system'}->{'get_sysinfo'}->{'relay_state'} == 1) { readingsBulkUpdate($hash, "state", "on"); } # If the device is a HS110, get realtime data: if ($json->{'system'}->{'get_sysinfo'}->{'model'} eq "HS110(EU)" or $json->{'system'}->{'get_sysinfo'}->{'model'} eq "HS110(UK)") { my $realtimejcommand='{"emeter":{"get_realtime":{}}}'; my $rc = encrypt($realtimejcommand); my $socket = IO::Socket::INET->new(PeerAddr => $remote_host, PeerPort => $remote_port, Proto => 'tcp', Type => SOCK_STREAM, Timeout => $hash->{TIMEOUT} ) or return "Couldn't connect to $remote_host:$remote_port: $@\n"; $socket->write($rc); IO::Socket::Timeout->enable_timeouts_on($socket); $socket->read_timeout(.5); my $rdata; $rdata = <$socket>; $rdata = decrypt(substr($rdata,4)); if (length($rdata)==0) { Log3 $hash, 1, "TPLinkHS110: $name: Received zero bytes of realtime data. Cannot process realtime data"; return; } ($success,$realtimejson) = TPLinkHS110__evaljson($name,$rdata); if(!$success) { readingsEndUpdate($hash, 1); return; } else { Log3 $hash, 2, "TPLinkHS110: $name Realtime data updated"; } my %emeterReadings = (); foreach my $key2 (sort keys %{$realtimejson->{'emeter'}->{'get_realtime'}}) { my $emeterValue = $realtimejson->{'emeter'}->{'get_realtime'}->{$key2}; #adjust different hw_ver readings, be sure to list all emeter readings in hwMapping if (exists($hwMap{$hw_ver}{'emeter'}{'get_realtime'}{$key2})) { if (exists($hwMap{$hw_ver}{'emeter'}{'get_realtime'}{$key2}{'factor'})) { $emeterValue = $emeterValue * $hwMap{$hw_ver}{'emeter'}{'get_realtime'}{$key2}{'factor'}; } $key2 = $hwMap{$hw_ver}{'emeter'}{'get_realtime'}{$key2}{'name'}; readingsBulkUpdate($hash, $key2, $emeterValue); $emeterReadings{$key2} = $emeterValue; } else { return "Check supported hw_ver of device: $hw_ver\n"; } } Log3 $hash, 3, "TPLinkHS110: $name Device is an HS110. Got extra realtime data: $emeterReadings{'power'} Watt, $emeterReadings{'voltage'} Volt, $emeterReadings{'current'} Ampere"; # Get Daily Stats my $command = '{"emeter":{"get_daystat":{"month":'.$mon.',"year":'.$year.'}}}'; my $c = encrypt($command); $socket = IO::Socket::INET->new(PeerAddr => $remote_host, PeerPort => $remote_port, Proto => 'tcp', Type => SOCK_STREAM, Timeout => $hash->{TIMEOUT} ) or return "Couldn't connect to $remote_host:$remote_port: $@\n"; $socket->write($c); IO::Socket::Timeout->enable_timeouts_on($socket); $socket->read_timeout(.5); my $data; $data = <$socket>; $socket->close(); $data = decrypt(substr($data,4)); Log3 $hash, 3, "TPLinkHS110: $name Updating daystat. Data: " . $data; ($success,$json) = TPLinkHS110__evaljson($name,$data); if($success && $json) { my $total=0; foreach my $key (sort keys @{$json->{'emeter'}->{'get_daystat'}->{'day_list'}}) { foreach my $key2 ($json->{'emeter'}->{'get_daystat'}->{'day_list'}[$key]) { if ($hw_ver eq "1.0") { $total = $total+ $key2->{'energy'}; if ($key2->{'day'} == $mday) { readingsBulkUpdate($hash, "daily_total", sprintf("%.3f", $key2->{'energy'})); } } else { $total = $total+ $key2->{'energy_wh'}; if ($key2->{'day'} == $mday) { readingsBulkUpdate($hash, "daily_total", sprintf("%.3f", $key2->{'energy_wh'})); } } } } my $count=1; $count = @{$json->{'emeter'}->{'get_daystat'}->{'day_list'}}; readingsBulkUpdate($hash, "monthly_total", $total); if ($count) { readingsBulkUpdate($hash, "daily_average", $total/$count)}; Log3 $hash, 2, "TPLinkHS110: $name Daystat updated"; } else { Log3 $hash, 1, "TPLinkHS110: $name Error updating daystat. Success: " . $success . ", json: " . $json; Log3 $hash, 3, "TPLinkHS110: $name Updating readings"; readingsEndUpdate($hash, 1); Log3 $hash, 3, "TPLinkHS110: $name Get end"; return; } } Log3 $hash, 3, "TPLinkHS110: $name Updating readings"; readingsEndUpdate($hash, 1); Log3 $hash, 3, "TPLinkHS110: $name Get end"; } ##################################### sub TPLinkHS110_Set($$) { my ( $hash, $name, $cmd, @args ) = @_; my $cmdList = "on off"; my($success,$json,$realtimejson); return "\"set $name\" needs at least one argument" unless(defined($cmd)); return if ($attr{$name}{"disable"} eq "1"); Log3 $hash, 3, "TPLinkHS110: $name Set <". $cmd ."> called" if ($cmd !~ /\?/); my $command=""; if($cmd eq "on") { $command = '{"system":{"set_relay_state":{"state":1}}}'; } elsif($cmd eq "off") { $command = '{"system":{"set_relay_state":{"state":0}}}'; } else # wenn der übergebene Befehl nicht durch X_Set() verarbeitet werden kann, Weitergabe an SetExtensions { return SetExtensions($hash, $cmdList, $name, $cmd, @args); } my $remote_host = $hash->{HOST}; my $remote_port = 9999; my $c = encrypt($command); my $socket = IO::Socket::INET->new(PeerAddr => $remote_host, PeerPort => $remote_port, Proto => 'tcp', Type => SOCK_STREAM, Timeout => $hash->{TIMEOUT}) or return "Couldn't connect to $remote_host:$remote_port: $@\n"; $socket->write($c); IO::Socket::Timeout->enable_timeouts_on($socket); $socket->read_timeout(.5); my $data; $data = <$socket>; $socket->close(); readingsBeginUpdate($hash); $data = decrypt(substr($data,4)); ($success,$json) = TPLinkHS110__evaljson($name,$data); if(!$success) { readingsEndUpdate($hash, 1); return; } if ($json->{'system'}->{'set_relay_state'}->{'err_code'} eq "0") { TPLinkHS110_Get($hash,""); } else { return "Command failed!"; } return undef; } ##################################### sub TPLinkHS110_Undefine($$) { my ($hash, $arg) = @_; my $name= $hash->{NAME}; RemoveInternalTimer($hash); Log3 $hash, 3, "TPLinkHS110: $name undefined."; return; } ##################################### sub TPLinkHS110_Delete { my ($hash, $arg) = @_; my $name= $hash->{NAME}; Log3 $hash, 3, "TPLinkHS110: $name deleted."; return undef; } ##################################### sub TPLinkHS110_Attr { my ($cmd,$name,$aName,$aVal) = @_; my $hash = $defs{$name}; if ($aName eq "interval") { if ($cmd eq "set") { $hash->{INTERVAL} = $aVal; } else { $hash->{INTERVAL} = 300; } Log3 $hash, 3, "TPLinkHS110: $name INTERVAL set to " . $hash->{INTERVAL}; } if ($aName eq "timeout") { if ($cmd eq "set") { $hash->{TIMEOUT} = $aVal; } else { $hash->{TIMEOUT} = 1; } Log3 $hash, 3, "TPLinkHS110: $name TIMEOUT set to " . $hash->{TIMEOUT}; } if ($aName eq "nightmode") { my $command; if ($cmd eq "set") { $hash->{NIGHTMODE} = $aVal; Log3 $hash, 3, "TPLinkHS110: $name Nightmode $aVal."; $command = '{"system":{"set_led_off":{"off":1}}}' if ($aVal eq "on"); $command = '{"system":{"set_led_off":{"off":0}}}' if ($aVal eq "off"); } if ($cmd eq "del") { Log3 $hash, 3, "TPLinkHS110: $name Nightmode attribute removed. Nightmode disabled."; $command = '{"system":{"set_led_off":{"off":0}}}'; $hash->{NIGHTMODE} = "off"; } my $remote_host = $hash->{HOST}; my $remote_port = 9999; my $c = encrypt($command); my $socket = IO::Socket::INET->new(PeerAddr => $remote_host, PeerPort => $remote_port, Proto => 'tcp', Type => SOCK_STREAM, Timeout => $hash->{TIMEOUT} ) or return "Couldn't connect to $remote_host:$remote_port: $@\n"; $socket->write($c); IO::Socket::Timeout->enable_timeouts_on($socket); $socket->read_timeout(.5); my $data; $data = <$socket>; $socket->close(); $data = decrypt(substr($data,4)); my $json; eval { $json = decode_json($data); } or do { Log3 $hash, 2, "TPLinkHS110: $name json-decoding failed. Problem decoding getting statistical data"; return; }; } return undef; } # Encryption and Decryption of TP-Link Smart Home Protocol # XOR Autokey Cipher with starting key = 171 # Based on https://www.softscheck.com/en/reverse-engineering-tp-link-hs110/ sub encrypt { my $key = 171; my @string=split(//, $_[0]); my $result = "\0\0\0".chr(@string); foreach (@string) { my $a = $key ^ ord($_); $key = $a; $result .= chr($a); } return $result; } sub decrypt { my $key = 171; my $result = ""; my @string=split(//, $_[0]); foreach (@string) { my $a = $key ^ ord($_); $key = ord($_); $result .= chr($a); } return $result; } # mapping for different hardware versions sub hwMapping { my %hwMap = (); $hwMap{'1.0'}{'system'}{'get_sysinfo'}{'longitude'}{'name'} = 'longitude'; $hwMap{'1.0'}{'system'}{'get_sysinfo'}{'longitude'}{'factor'} = 1; $hwMap{'1.0'}{'system'}{'get_sysinfo'}{'latitude'}{'name'} = 'latitude'; $hwMap{'1.0'}{'system'}{'get_sysinfo'}{'latitude'}{'factor'} = 1; $hwMap{'2.0'}{'system'}{'get_sysinfo'}{'longitude_i'}{'name'} = 'longitude'; $hwMap{'2.0'}{'system'}{'get_sysinfo'}{'longitude_i'}{'factor'} = 0.0001; $hwMap{'2.0'}{'system'}{'get_sysinfo'}{'latitude_i'}{'name'} = 'latitude'; $hwMap{'2.0'}{'system'}{'get_sysinfo'}{'latitude_i'}{'factor'} = 0.0001; $hwMap{'1.0'}{'emeter'}{'get_realtime'}{'power'}{'name'} = 'power'; $hwMap{'1.0'}{'emeter'}{'get_realtime'}{'power'}{'factor'} = 1; $hwMap{'1.0'}{'emeter'}{'get_realtime'}{'voltage'}{'name'} = 'voltage'; $hwMap{'1.0'}{'emeter'}{'get_realtime'}{'voltage'}{'factor'} = 1; $hwMap{'1.0'}{'emeter'}{'get_realtime'}{'current'}{'name'} = 'current'; $hwMap{'1.0'}{'emeter'}{'get_realtime'}{'current'}{'factor'} = 1; $hwMap{'1.0'}{'emeter'}{'get_realtime'}{'total'}{'name'} = 'total'; $hwMap{'1.0'}{'emeter'}{'get_realtime'}{'total'}{'factor'} = 1; $hwMap{'1.0'}{'emeter'}{'get_realtime'}{'err_code'}{'name'} = 'err_code'; $hwMap{'1.0'}{'emeter'}{'get_realtime'}{'err_code'}{'factor'} = 1; $hwMap{'2.0'}{'emeter'}{'get_realtime'}{'power_mw'}{'name'} = 'power'; $hwMap{'2.0'}{'emeter'}{'get_realtime'}{'power_mw'}{'factor'} = 0.001; $hwMap{'2.0'}{'emeter'}{'get_realtime'}{'voltage_mv'}{'name'} = 'voltage'; $hwMap{'2.0'}{'emeter'}{'get_realtime'}{'voltage_mv'}{'factor'} = 0.001; $hwMap{'2.0'}{'emeter'}{'get_realtime'}{'current_ma'}{'name'} = 'current'; $hwMap{'2.0'}{'emeter'}{'get_realtime'}{'current_ma'}{'factor'} = 0.001; $hwMap{'2.0'}{'emeter'}{'get_realtime'}{'total_wh'}{'name'} = 'total'; $hwMap{'2.0'}{'emeter'}{'get_realtime'}{'total_wh'}{'factor'} = 0.001; $hwMap{'2.0'}{'emeter'}{'get_realtime'}{'err_code'}{'name'} = 'err_code'; $hwMap{'2.0'}{'emeter'}{'get_realtime'}{'err_code'}{'factor'} = 1; return %hwMap; } ############################################################################### # Test ob JSON-String empfangen wurde sub TPLinkHS110__evaljson($$) { my ($name,$data)= @_; my $hash = $defs{$name}; my $json; my $success = 1; my $jerr = "ok"; Log3 $name, 5, "$name - Data returned: ". Dumper $data; eval {$json = decode_json($data);} or do { $success = 0; }; if($@) { $jerr = $@; }; readingsBulkUpdate($hash, "decode_json", $jerr); if($success) { return($success,$json); } else { return($success,undef); } } ###################################################################################### 1; =pod =begin html

TPLinkHS110

=end html =begin html_DE

TPLinkHS110

=end html_DE =item summary Support for TPLink HS100/100 wifi controlled power outlet =item summary_DE Support für die TPLink HS100/110 WLAN Steckdosen