diff --git a/fhem/CHANGED b/fhem/CHANGED index c16bffbd0..f3b1bfb63 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: 10_WS980: new module to control the WS980Wifi weather station - feature: 93_DbRep: the "explain" SQL-command is possible now in sqlCmd - feature: 10_MYSENSORS_DEVICE: add attrTemplate support - change: 00_MYSENSORS: enhance support for node functions diff --git a/fhem/FHEM/10_WS980.pm b/fhem/FHEM/10_WS980.pm new file mode 100644 index 000000000..2f6b8c898 --- /dev/null +++ b/fhem/FHEM/10_WS980.pm @@ -0,0 +1,1566 @@ +######################################################################################## +# +# WS980.pm +# +# FHEM module for WS980-Wifi Weather Station +# +# Christian Hoenig +# +# $Id$ +# +######################################################################################## +# +# This programm is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# The GNU General Public License can be found at +# http://www.gnu.org/copyleft/gpl.html. +# A copy is found in the textfile GPL.txt and important notices to the license +# from the author is found in LICENSE.txt distributed with these scripts. +# +# This script is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +######################################################################################## +package main; + +use strict; +use warnings; +use IO::Socket::INET; +use POSIX qw(strftime); + +my $version = "1.0.0"; + +#------------------------------------------------------------------------------------------------------ +# global constants +#------------------------------------------------------------------------------------------------------ +use constant REQUESTS => { + "firmware" => { # \xff\xff\x50\x03\x53 + "type" => "\x50", + "value" => { + "name" => "firmware", + "width" => "auto", + } + }, + "current" => { # \xff\xff\x0b\x00\x06\x04\x04\x19 + "type" => "\x0b", + "subtype" => "\x04", + }, + "historyMax" => { # \xff\xff\x0b\x00\x06\x05\x05\x1b, + "type" => "\x0b", + "subtype" => "\x05", + "postfix" => "_historyMax", + }, + "historyMin" => { # \xff\xff\x0b\x00\x06\x06\x06\x1d + "type" => "\x0b", + "subtype" => "\x06", + "postfix" => "_historyMin", + }, + "todayMax" => { # \xff\xff\x0b\x00\x06\x07\x07\x1f + "type" => "\x0b", + "subtype" => "\x07", + "postfix" => "_todayMax", + }, + "todayMin" => { #\xff\xff\x0b\x00\x06\x08\x08\x21 + "type" => "\x0b", + "subtype" => "\x08", + "postfix" => "_todayMin", + }, +}; + +use constant HAS_TIME => 0x40; +use constant HAS_DATE => 0x80; +use constant VALUES => { + 0x01 => {"name" => "temperatureInside", "bytes" => 2, "factor" => 10, "format" => "%.1f", "unit" => "°C" }, # °C ## x / 10.0 - 40.0 + 0x02 => {"name" => "temperature", "bytes" => 2, "factor" => 10, "format" => "%.1f", "unit" => "°C" }, # °C ## x / 10.0 - 40.0 + 0x03 => {"name" => "dewPoint", "bytes" => 2, "factor" => 10, "format" => "%.1f", "unit" => "°C" }, # °C ## x / 10.0 - 40.0 + 0x04 => {"name" => "windChill", "bytes" => 2, "factor" => 10, "format" => "%.1f", "unit" => "°C" }, # °C ## x / 10.0 - 40.0 + 0x05 => {"name" => "heatIndex", "bytes" => 2, "factor" => 10, "format" => "%.1f", "unit" => "°C" }, # °C ## x / 10.0 - 40.0 + 0x06 => {"name" => "humidityInside", "bytes" => 1, "factor" => 1, "format" => "%d" , "unit" => "%" }, # % + 0x07 => {"name" => "humidity", "bytes" => 1, "factor" => 1, "format" => "%d" , "unit" => "%" }, # % + 0x08 => {"name" => "pressureAbs", "bytes" => 2, "factor" => 10, "format" => "%.1f", "unit" => "hPa" }, # hPa + 0x09 => {"name" => "pressureRel", "bytes" => 2, "factor" => 10, "format" => "%.1f", "unit" => "hPa" }, # hPa + 0x0A => {"name" => "windDirection", "bytes" => 2, "factor" => 1, "format" => "%d" , "unit" => "deg" }, # ° + 0x0B => {"name" => "wind", "bytes" => 2, "factor" => 10, "format" => "%.1f", "unit" => "m/s" }, # m/s + 0x0C => {"name" => "windGusts", "bytes" => 2, "factor" => 10, "format" => "%.1f", "unit" => "m/s" }, # m/s + 0x0D => {"name" => "rainEvent", "bytes" => 4, "factor" => 10, "format" => "%.1f", "unit" => "mm" }, # mm + 0x0E => {"name" => "rainRate", "bytes" => 4, "factor" => 10, "format" => "%.1f", "unit" => "mm" }, # mm + 0x0F => {"name" => "rainPerHour", "bytes" => 4, "factor" => 10, "format" => "%.1f", "unit" => "mm" }, # + 0x10 => {"name" => "rainPerDay", "bytes" => 4, "factor" => 10, "format" => "%.1f", "unit" => "mm" }, # mm + 0x11 => {"name" => "rainPerWeek", "bytes" => 4, "factor" => 10, "format" => "%.1f", "unit" => "mm" }, # mm + 0x12 => {"name" => "rainPerMonth", "bytes" => 4, "factor" => 10, "format" => "%.1f", "unit" => "mm" }, # mm + 0x13 => {"name" => "rainPerYear", "bytes" => 4, "factor" => 10, "format" => "%.1f", "unit" => "mm" }, # mm + 0x14 => {"name" => "rainTotal", "bytes" => 4, "factor" => 10, "format" => "%.1f", "unit" => "mm" }, # mm + 0x15 => {"name" => "brightness", "bytes" => 4, "factor" => 10, "format" => "%d" , "unit" => "lux" }, # lux + 0x16 => {"name" => "uv", "bytes" => 2, "factor" => 1, "format" => "%d" , "unit" => "uW/m^2"}, # uW/m^2 + 0x17 => {"name" => "uvIndex", "bytes" => 1, "factor" => 1, "format" => "%d" , "unit" => "uvi" }, # 0-15 index ?? +}; + +use constant UNIT_CONVERSIONS => { + "°C" => { + "attr" => "unit_temperature", # °C °F + "fnc" => { + "°C" => sub { my ($c) = @_; return $c }, + "°F" => sub { my ($c) = @_; return 9/5 * $c + 32 }, + }, + }, + "hPa" => { + "attr" => "unit_pressure", # hPa inHg mmHg + "fnc" => { + "hPa" => sub { my ($c) = @_; return $c }, + "inHg" => sub { my ($c) = @_; return $c * 0.75006375541921 / 10 / 2.54}, + "mmHg" => sub { my ($c) = @_; return $c * 0.75006375541921}, + }, + }, + "m/s" => { + "attr" => "unit_wind", # m/s km/h knot mph bft + "fnc" => { + "m/s" => sub { my ($c) = @_; return $c }, + "km/h" => sub { my ($c) = @_; return $c * 3.6 }, + "knot" => sub { my ($c) = @_; return $c * 1.943844 }, + "mph" => sub { my ($c) = @_; return $c * 2.236936 }, + "bft" => sub { my ($c) = @_; return ($c / 0.836) ** (2/3) }, + }, + }, + "mm" => { + "attr" => "unit_rain", # mm in + "fnc" => { + "mm" => sub { my ($c) = @_; return $c }, + "in" => sub { my ($c) = @_; return $c / 10 / 2.54 }, + }, + }, + "lux" => { + "attr" => "unit_light", # lux fc w/m^2 + "fnc" => { + "lux" => sub { my ($c) = @_; return $c }, + "fc" => sub { my ($c) = @_; return $c * 0.09290304000008}, + "w/m^2" => sub { my ($c) = @_; return $c * 0.001464128843338}, + }, + }, +}; + + +#------------------------------------------------------------------------------------------------------ +# Initialize +#------------------------------------------------------------------------------------------------------ +sub WS980_Initialize($) +{ + my ($hash) = @_; + + Log3 undef, 4, "WS980 - WS980_Initialize() called"; + + $hash->{DefFn} = "WS980_DefFn"; + $hash->{UndefFn} = "WS980_UndefFn"; + $hash->{AttrFn} = "WS980_AttrFn"; + $hash->{SetFn} = "WS980_SetFn"; + #$hash->{GetFn} = "WS980_GetFn"; + + $hash->{ReadFn} = "WS980_ReadFn"; + $hash->{WriteFn} = "WS980_WriteFn"; + + $hash->{AttrList} = "altitude ". + "events:textField-long ". + "connection:Keep-Alive,Close ". + "requests:multiple-strict,".join(",", sort keys %{REQUESTS()})." ". + "showRawBuffer:1 ". + "disable:1 ". + WS980_extractAttrsFromUnits() . + $readingFnAttributes; + + foreach my $d (sort keys %{$modules{WS980}{defptr}}) { + my $hash = $modules{WS980}{defptr}{$d}; + # update version in devices + $hash->{VERSION} = $version; + # initialize PORT if not done yet - PORT was introduced in 0.12.0 + if (!defined($hash->{PORT})) { + $hash->{PORT} = 45000; + } + $hash->{helper}{requestInProgress} = 0; + } +} + + +#------------------------------------------------------------------------------------------------------ +# Define +#------------------------------------------------------------------------------------------------------ +sub WS980_DefFn($$) +{ + my ( $hash, $def ) = @_; + + my @a = split( "[ \t]+", $def ); + splice( @a, 1, 1 ); + + # check syntax + if(int(@a) < 1) { + return "Wrong syntax: use define WS980 [IP] [INTERVAL]"; + } + + my ($name, $ip, $interval) = @a; + Log3 $name, 4, "WS980 ($name) - WS980_DefFn() called"; + + my $port = 45000; + # try to auto-discover the IP + if (!defined($ip)) { + ($ip, $port) = WS980_autodiscoverIP($hash); + } + return "Autodiscovery failed, please set IP by hand" unless (defined($ip)); + + $hash->{IP} = $ip; + $hash->{PORT} = $port; + $hash->{VERSION} = $version; + $hash->{INTERVAL} = $interval ? $interval : 30; + $hash->{helper}{requestInProgress} = 0; + + $modules{WS980}{defptr}{$hash->{IP}} = $hash; + + my $eventsCfg = AttrVal($name, "events", ""); + if ($eventsCfg ne "") { + WS980_parseEventsAttr($hash, $eventsCfg) + } + + if ($init_done) { + InternalTimer( gettimeofday()+0, "WS980_updateValues", $hash); + } else { + InternalTimer( gettimeofday()+10, "WS980_updateValues", $hash); + } + + return undef; +} + + +#------------------------------------------------------------------------------------------------------ +# Undefine +#------------------------------------------------------------------------------------------------------ +sub WS980_UndefFn($$) +{ + my ($hash, $arg) = @_; + my $name = $hash->{NAME}; + + Log3 $name, 4, "WS980 ($name) - WS980_UndefFn() called"; + + delete $modules{WS980}{defptr}{$hash->{IP}}; + + RemoveInternalTimer($hash); + WS980_Close($hash); + + return undef; +} + + +#------------------------------------------------------------------------------------------------------ +# AttrFn +#------------------------------------------------------------------------------------------------------ +sub WS980_AttrFn(@) +{ + my ($cmd, $name, $attrName, $attrVal) = @_; + my $hash = $defs{$name}; + + ################## + #### altitude #### + + if ($attrName eq "altitude") { + if ($cmd eq "set") { + my $isNumeric = ($attrVal eq $attrVal+0); + if ($isNumeric) { + WS980_updateRelPressure($hash); + return undef; + } else { + return "'altitude' must be a numeric value"; + } + } + } + + #################### + #### connection #### + + if ($attrName eq "connection") { + if ($cmd eq "set") { + if ($attrVal eq "Keep-Alive") { + return undef; + } + elsif ($attrVal eq "Close") { + WS980_Close($hash); + return undef; + } + else { + return "'connection' must be either Keep-Alive or Close"; + } + } + } + + ################ + #### events #### + + if ($attrName eq "events") { + if ($cmd eq "set") { + WS980_parseEventsAttr($hash, $attrVal); + } elsif ($cmd eq "del") { + WS980_parseEventsAttr($hash, undef); + } + } + + ################## + #### requests #### + + if ($attrName eq "requests") { + if ($cmd eq "set") { + my @parts = split(/[, ]/, $attrVal); + foreach my $part (@parts) { + if (!defined REQUESTS->{$part}) { + return "Invalid 'requests'-type: $part"; + } + } + } + } + + ################# + #### disable #### + + if ($attrName eq "disable") { + if ($cmd eq "set" and $attrVal eq "1") { + WS980_Close($hash); + readingsSingleUpdate ( $hash, "state", "disabled", 1 ); + Log3 $name, 2, "WS980 ($name) - disabled"; + } + elsif ($cmd eq "del") { + readingsSingleUpdate ( $hash, "state", "active", 1 ); + Log3 $name, 2, "WS980 ($name) - enabled"; + } + } + + ####################### + #### showRawBuffer #### + + if ($attrName eq "showRawBuffer") { + if ($cmd eq "set" and $attrVal eq "1") { + # nothing here :) + } + elsif ($cmd eq "del") { + CommandDeleteReading(undef, "$name rawBuffer.*"); + } + } + + return undef; +} + + +#------------------------------------------------------------------------------------------------------ +# SetFn +#------------------------------------------------------------------------------------------------------ +sub WS980_SetFn($$@) +{ + my ($hash, $name, @aa) = @_; + my ($cmd, @args) = @aa; + + if ($cmd eq "?") { + return "Unknown argument $cmd, choose one of " if WS980_isDisabled($hash); + } + + if ($cmd eq 'update') { + return "usage: update" if( @args != 0 ); + WS980_updateValues($hash); + return undef; + } + + my $list = "update:noArg"; + return "Unknown argument $cmd, choose one of $list"; +} + +#------------------------------------------------------------------------------------------------------ +# request values from WS980 device +#------------------------------------------------------------------------------------------------------ +sub WS980_autodiscoverIP($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + Log3 $name, 1, "WS980 ($name) - WS980_autodiscoverIP"; + + my $socket = IO::Socket::INET->new( + PeerAddr => inet_ntoa(INADDR_BROADCAST), + Broadcast => 1, + ReusAddr => 1, + ReusePort => 1, + PeerPort => '46000', + Proto => 'udp', + Type => SOCK_DGRAM + ); + + if (!$socket) { + Log3 $name, 1, "WS980 ($name) - autodiscovery failed: no socket"; + return (undef,undef); + } + + my $recvSocket = IO::Socket::INET->new( + Proto => 'udp', + LocalPort => $socket->sockport(), + ReusAddr => 1, + ReusePort => 1, + ); + + if (!$recvSocket) { + Log3 $name, 1, "WS980 ($name) - autodiscovery failed: no recvSocket"; + return (undef,undef); + } + + # set receive timeout to 500msecs second (format is: secs, microsecs) + if (!$recvSocket->setsockopt(SOL_SOCKET, SO_RCVTIMEO, pack('l!l!', 0, 500*1000))) { + Log3 $name, 1, "WS980 ($name) - autodiscovery failed: could not set SO_RCVTIMEO on recvSocket"; + return (undef,undef); + } + + # Broadcast auf 255.255.255.255:46000 + # -> ffff12000416 + my $req = WS980_createRequestRaw("\x12"); + + # send request + Log3 $name, 5, "WS980 ($name) - broadcasting auto-discovery: " . WS980_hexDump($req); + if ($socket->send($req) == 0) { + Log3 $name, 1, "WS980 ($name) - autodiscovery failed: cannot send request"; + return (undef,undef); + } + $socket->close(); + + # receive a response of up to 10240 characters from server + my $rawbuf; + $recvSocket->recv($rawbuf, 10240); + $recvSocket->close(); + + # ffff 12 LLLL ?? ?? ?? ?? ?? ?? I1 I2 I3 I4 PPPP LN NN..NN C2 + # 84 f3 eb 21 8c d1 + Log3 $name, 5, "WS980 ($name) - received raw reply: " . WS980_hexDump($rawbuf); + + my ($typeStr, $buf) = WS980_handleReply($hash, $rawbuf); + my ($ip1, $ip2, $ip3, $ip4, $port, $stationName) = unpack("x[6]CCCCnC/A", $buf); + Log3 $name, 1, "WS980 ($name) - reply: $ip1, $ip2, $ip3, $ip4, $port, $stationName"; + + return (sprintf("%d.%d.%d.%d", $ip1, $ip2, $ip3, $ip4), $port); +} + + +#------------------------------------------------------------------------------------------------------ +# request values from WS980 device +#------------------------------------------------------------------------------------------------------ +sub WS980_updateValues($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + my $ip = $hash->{IP}; + + Log3 $name, 4, "WS980 ($name) - WS980_updateValues called"; + + my $interval = $hash->{INTERVAL}; + RemoveInternalTimer($hash, "WS980_updateValues"); + InternalTimer(gettimeofday()+$interval, "WS980_updateValues", $hash); + + return undef if (WS980_isDisabled($hash)); + + if ($hash->{helper}{requestInProgress} == 1) { + Log3 $name, 1, "WS980 ($name) - looks like the last request did not receive an answer, trying to reconnect"; + WS980_Close($hash); + } + + my @activeRequests = split(/[, ]/, AttrVal($name, "requests", "")); + if (!@activeRequests) { + @activeRequests = keys %{REQUESTS()}; + } + $hash->{helper}{activeRequests} = \@activeRequests; + + my $ok = WS980_Open($hash); + WS980_writeNextActiveRequest($hash) if ($ok); + + return undef; +} + + +#------------------------------------------------------------------------------------------------------ +# takes the next request from @activeRequests and sends it to the WS980 +#------------------------------------------------------------------------------------------------------ +sub WS980_writeNextActiveRequest($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + Log3 $name, 3, "WS980 ($name) - activeRquests: " . join(" ", @{$hash->{helper}{activeRequests}}); + + my $valueType = shift(@{$hash->{helper}{activeRequests}}); + if (!defined($valueType)) { + my $datestring = strftime "%F %T", localtime; + readingsSingleUpdate($hash, "lastUpdate", "$datestring", 1); + + if (AttrVal($name, "connection", "Keep-Alive") eq "Close") { + WS980_Close($hash); + } + return; + } + + my $buf = WS980_createRequest($hash, $valueType); + if (defined $buf) { + Log3 $name, 5, "WS980 ($name) - Sending new request for '$valueType'..."; + WS980_WriteFn($hash, $buf); + } else { + WS980_Close($hash); + } +} + + +#------------------------------------------------------------------------------------------------------ +# update multiple 'values' +#------------------------------------------------------------------------------------------------------ +sub WS980_handleMultiValuesUpdate($$$) +{ + my ($hash, $valueType, $buf) = @_; + my $name = $hash->{NAME}; + + Log3 $name, 5, "WS980 ($name) - decoding block: " . WS980_hexDump($buf); + + for (my $i = 0; $i < length($buf); ) + { + my $id = unpack("x[$i] C", $buf); + $i += 1; + + my $hasDate = ($id & HAS_DATE); + my $hasTime = ($id & HAS_TIME); + + $id = $id & ~HAS_DATE & ~HAS_TIME; + + if (!(exists VALUES->{$id})) { + my $decs = ""; + my $hexs = ""; + for (my $j = $i; $j < length($buf); $j++) { + my $hex = WS980_binToHex(substr($buf,$j,1)); + $decs .= sprintf("%d ", hex($hex)); + $hexs .= sprintf("%s ", $hex); + } + WS980_error($hash, sprintf("%d not found\n[%s]\n[%s]", $id, $decs, $hexs)); + last; + } + + my $reading = VALUES->{$id}{"name"}; + if (defined(REQUESTS->{$valueType}{"prefix"})) { + $reading = REQUESTS->{$valueType}{"prefix"} . $reading; + }; + if (defined(REQUESTS->{$valueType}{"postfix"})) { + $reading .= REQUESTS->{$valueType}{"postfix"}; + }; + + my $bytes = VALUES->{$id}{"bytes"}; + my $factor = VALUES->{$id}{"factor"}; + my $format = VALUES->{$id}{"format"}; + my $unit = VALUES->{$id}{"unit"}; + + my $ffff = "\xff"x($bytes); + + my $value = substr($buf, $i, $bytes); + $i += $bytes; + + # just print the hex values if $format is "raw" + if ($format eq "raw") { + $value = WS980_hexDump($value); + } elsif ($value eq $ffff) { + $value = "n/a"; + } else { + $value = hex(WS980_binToHex($value)); + # convert negative values + my $lbit = 1 << ($bytes * 2 * 4) - 1; + if ($value & $lbit) { + $value = $value - ($lbit << 1); + } + # respect $factor + $value = $value / $factor; + + # handle unit conversion + if (defined(UNIT_CONVERSIONS->{$unit})) { + my $newUnit = AttrVal($name, UNIT_CONVERSIONS->{$unit}{attr}, $unit); + if ($newUnit ne $unit) { + if (defined(UNIT_CONVERSIONS->{$unit}{"fnc"}{$newUnit})) { + my $fnc = UNIT_CONVERSIONS->{$unit}{"fnc"}{$newUnit}; + $value = $fnc->($value); + } + } + } + + # and format + $value = sprintf($format, $value) + } + + my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime(); + if ($hasDate) { + ($year, $mon, $mday) = unpack("x[$i] CCC", $buf); + $year += 2000; + $i += 1 + 1 + 1; # 2000+year + month + day + WS980_error($hash, sprintf("hasDate is not handeled")); + } + + if ($hasTime) { + ($hour, $min) = unpack("x[$i] CC", $buf); + $i += 1 + 1; # hour + minute + } + + my $ts; + if ($hasDate || $hasTime) { + $ts = sprintf("%04d-%02d-%02d %02d:%02d:00", $year+1900, $mon+1, $mday, $hour, $min); + } + + readingsBeginUpdate($hash); + if ($hasDate || $hasTime) { + $hash->{".updateTimestamp"} = $ts;; + } + my $offset = $#{ $hash->{CHANGED} }; + readingsBulkUpdate($hash, $reading, $value, 1); + if (($hasDate || $hasTime) && ($#{ $hash->{CHANGED} } != $offset)) { + # only add ts if there is a event to + $hash->{CHANGETIME}->[$#{ $hash->{CHANGED} }] = $ts; + } + readingsEndUpdate($hash,1); + } +} + + +#------------------------------------------------------------------------------------------------------ +# update a single 'value' +#------------------------------------------------------------------------------------------------------ +sub WS980_handleSingleValuesUpdate($$$) +{ + my ($hash, $valueType, $buf) = @_; + my $name = $hash->{NAME}; + + my $len = REQUESTS->{$valueType}{"value"}{"width"}; + if ($len eq "auto") { + my $value = unpack("C/A", $buf); + readingsSingleUpdate($hash, REQUESTS->{$valueType}{"value"}{"name"}, $value, 1); + } else { + WS980_error($hash, "cannot decode value in WS980_handleSingleValuesUpdate: " . $valueType); + } +} + +#------------------------------------------------------------------------------------------------------ +# * if disabled -> ... +#------------------------------------------------------------------------------------------------------ +sub WS980_isDisabled($) +{ + my ($hash) = @_; + return AttrVal($hash->{NAME}, "disable", "0") eq "1" || + IsDisabled($hash->{NAME}); +} + +#------------------------------------------------------------------------------------------------------ +# creates the binary request buffer for $valueType from %{REQUESTS()} +#------------------------------------------------------------------------------------------------------ +sub WS980_createRequest($$) +{ + my ($hash, $valueType) = @_; + my $name = $hash->{NAME}; + + if (!defined REQUESTS->{$valueType}) { + WS980_error($hash, "WS980_createRequest failed to create request: no config for $valueType"); + return undef; + } + + if (defined REQUESTS->{$valueType}{"req"}) { + return REQUESTS->{$valueType}{"req"}; + } + + return WS980_createRequestRaw( + defined(REQUESTS->{$valueType}{"type"}) ? REQUESTS->{$valueType}{"type"} : undef, + defined(REQUESTS->{$valueType}{"subtype"}) ? REQUESTS->{$valueType}{"subtype"} : undef, + defined(REQUESTS->{$valueType}{"data"}) ? REQUESTS->{$valueType}{"data"} : undef + ); +} + +#------------------------------------------------------------------------------------------------------ +# creates the binary request buffer for $type, $subtype and $data +#------------------------------------------------------------------------------------------------------ +sub WS980_createRequestRaw($;$$) +{ + my ($type, $subtype, $data) = @_; + + $type = "" if (!defined($type)); + $subtype = "" if (!defined($subtype)); + $data = "" if (!defined($data)); + + if ($type eq "\x0b") { + # ffff 0b LLLL XX C1 C2 + my $cmd = $subtype . $data; + my $c1 = WS980_calculateChecksum($cmd); + + my $len = 1 + 2 + length($cmd) + 1 + 1; # $type + LLLL + $cmd + $c1 + $c2 + my $req = $type . pack("n", $len) . $cmd . pack("C", $c1); + my $c2 = WS980_calculateChecksum($req); + return "\xff\xff" . $req . pack("C", $c2); + } + elsif ($type eq "\x12") { + # ffff 12 LLLL C2 + my $len = 1 + 2 + 1; # $type + LLLL + $c2 + my $req = $type . pack("n", $len); + my $c2 = WS980_calculateChecksum($req); + return "\xff\xff" . $req . pack("C", $c2); + } + elsif ($type eq "\x50") { + # ffff 50 LL C2 + my $len = 1 + 1 + 1; # $type + LL + $c2 + my $req = $type . pack("C", $len); + my $c2 = WS980_calculateChecksum($req); + return "\xff\xff" . $req . pack("C", $c2); + } + + return undef; +} + + +#------------------------------------------------------------------------------------------------------ +# parses the reply from the WS980 +#------------------------------------------------------------------------------------------------------ +sub WS980_handleReply($$) +{ + my ($hash, $buf) = @_; + my $name = $hash->{NAME}; + + # remove leading 'ffff' + if ($buf =~ /^\xff\xff/) { + $buf = substr($buf, 2); + } else { + WS980_error($hash, "msg did not start with ffff"); + return (undef, undef); + } + + my $typeStr = ""; + my $type = substr($buf, 0, 1); + + if ($type eq "\x0b") { + # 0b has a checksum and a 2 byte length field + if (!WS980_checkChecksum($buf)) { + WS980_error($hash, "first checksum did not match"); + return (undef, undef); + } + + # remove $type + $buf = substr($buf, 1); + + # check and remove the 2 byte length-field + my $len = unpack("n", $buf); + if ($len != length($buf) + 1) { # incl $type + WS980_error($hash, "length did not match: " . sprintf("%02x vs %02x", $len, length($buf)+1)); + return (undef, undef); + } + $buf = substr($buf, 2); + + # remove the first checksum + $buf = substr($buf, 0, -1); + + # check the second checksum + if (!WS980_checkChecksum($buf)) { + WS980_error($hash, "second checksum did not match"); + return (undef, undef); + } + + # remove the second checksum + $buf = substr($buf, 0, -1); + + # the next byte encodes the subtype (04, 05, 06, ...) + my $subType = substr($buf, 0, 1); + $typeStr = WS980_findConfigKey($type, $subType); + $buf = substr($buf, 1); + } + elsif ($type eq "\x12") { # autodiscovery + # 12 has a checksum and a 2 byte length field + if (!WS980_checkChecksum($buf)) { + WS980_error($hash, "first checksum did not match"); + return (undef, undef); + } + + # remove $type + $buf = substr($buf, 1); + + # check and remove the 2 byte length-field + my $len = unpack("n", $buf); + if ($len != length($buf) + 1 + 2) { # incl $type + FFFF + WS980_error($hash, "length did not match: " . sprintf("%02x vs %02x", $len, length($buf)+3)); + return (undef, undef); + } + $buf = substr($buf, 2); + } + elsif ($type eq "\x50") { # firmware + # remove '50' + $buf = substr($buf, 1); + + $typeStr = WS980_findConfigKey($type); + + # check and remove length-field + my $len = unpack("C", $buf); + if ($len != length($buf) + 1) { + # 50 is missing the checksum, so the length check fails + # WS980_error($hash, "length did not match: " . sprintf("%02x vs %02x", $len, length($buf)+3)); + # return undef; + } + $buf = substr($buf, 1); + } + + return ($typeStr, $buf); +} + +#------------------------------------------------------------------------------------------------------ +# returns the configKey for the given type and subtype (0b, 04 -> 'current') +#------------------------------------------------------------------------------------------------------ +sub WS980_findConfigKey($;$) +{ + my ($type, $subType) = @_; # bytearrays + + foreach my $key (keys %{REQUESTS()}) { + if (defined(REQUESTS->{$key}{"type"}) && REQUESTS->{$key}{"type"} eq $type) { + if (!defined($subType) && !defined(REQUESTS->{$key}{"subtype"})) { + return $key; + } + if (defined(REQUESTS->{$key}{"subtype"}) && REQUESTS->{$key}{"subtype"} eq $subType) { + return $key; + } + } + } + return undef; +} + + +#------------------------------------------------------------------------------------------------------ +# opens a connection to IP:PORT +#------------------------------------------------------------------------------------------------------ +sub WS980_Open($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + my $ip = $hash->{IP}; + my $port = $hash->{PORT}; + my $timeout = 0.25; + + return 1 if ($hash->{CD}); + + Log3 $name, 3, "WS980 ($name) - Creating socket connection to $ip:$port"; + + my $socket = new IO::Socket::INET( + PeerAddr => $ip, + PeerPort => $port, + Proto => 'tcp', + Timeout => $timeout, + ); + + if (!$socket) { + $hash->{ConnectionState} = 'disconnected'; + WS980_error($hash, "Couldn't connect to $ip:$port: " . $@); + return 0; + } + + # set receive timeout to 500msecs second (format is: secs, microsecs) + if (!$socket->setsockopt(SOL_SOCKET, SO_RCVTIMEO, pack('l!l!', 0, 500*1000))) { + WS980_error($hash, "Could not set SO_RCVTIMEO on socket"); + return 0; + } + + $hash->{FD} = $socket->fileno(); + $hash->{CD} = $socket; # sysread / close won't work on fileno + $selectlist{$name} = $hash; + + $hash->{ConnectionState} = 'connected'; + + Log3 $name, 2, "WS980 ($name) - Socket Connected"; + return 1; +} + + +#------------------------------------------------------------------------------------------------------ +# ReadFn +#------------------------------------------------------------------------------------------------------ +sub WS980_ReadFn($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + Log3 $name, 4, "WS980 ($name) - ReadFn started"; + + my $rawbuf; + my $len = sysread($hash->{CD}, $rawbuf, 10240); + + Log3 $name, 5, "WS980 ($name) - received reply: " . WS980_hexDump($rawbuf); + + $hash->{helper}{requestInProgress} = 0; + + if (!defined($len) or !$len or $len < 1 ) { + WS980_Close($hash); + return; + } + + my ($typeStr, $buf) = WS980_handleReply($hash, $rawbuf); + $typeStr = "" if (!defined($typeStr)); + + if (AttrVal($name, "showRawBuffer", "0") eq "1") { + readingsSingleUpdate($hash, "rawBuffer_" . $typeStr, WS980_hexDump($rawbuf), 1); + } else { + CommandDeleteReading(undef, "$name rawBuffer.*"); + } + + if ($typeStr ne "" && defined($buf)) { + if ($typeStr eq "firmware") { + WS980_handleSingleValuesUpdate($hash, $typeStr, $buf); + } else { + WS980_handleMultiValuesUpdate($hash, $typeStr, $buf); + } + + WS980_doPostUpdate($hash, $typeStr); + } + else + { + Log3 $name, 3, "WS980 ($name) - looks like the reply could not be decoded, skipping"; + } + + WS980_writeNextActiveRequest($hash); +} + +#------------------------------------------------------------------------------------------------------ +# called just after updating readings +#------------------------------------------------------------------------------------------------------ +sub WS980_doPostUpdate($$) +{ + my ($hash, $typeStr) = @_; + my $name = $hash->{NAME}; + + if ($typeStr eq "current") { + WS980_updateState($hash); + WS980_updateRain24h($hash); + WS980_updateRelPressure($hash); + WS980_updateEvents($hash); + } +} + + +#------------------------------------------------------------------------------------------------------ +# updates the state-reading +#------------------------------------------------------------------------------------------------------ +sub WS980_updateState($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + my $val = 'T: ' . ReadingsNum($name, "temperature", 0.0) . AttrVal($name, UNIT_CONVERSIONS->{"°C"}{"attr"}, "°C") . " " + . 'H: ' . ReadingsNum($name, "humidity", 0.0) . '% ' + . 'W: ' . ReadingsNum($name, "wind", 0.0) . AttrVal($name, UNIT_CONVERSIONS->{"m/s"}{"attr"}, "m/s") . " " + . 'P: ' . ReadingsNum($name, "pressureAbs", 0.0) . AttrVal($name, UNIT_CONVERSIONS->{"hPa"}{"attr"}, "hPa") . " "; + + readingsSingleUpdate($hash, "state", $val, 1); +} + + +#------------------------------------------------------------------------------------------------------ +# calculates and updates the rain24h-reading +#------------------------------------------------------------------------------------------------------ +sub WS980_updateRain24h($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + my $interval = 3600; + + my $lastTS = ReadingsNum($name, ".rain24h_lastTS", 0); + my $curTS = int(gettimeofday() / $interval); + return if ($lastTS == $curTS); + + Log3 $name, 3, "WS980 ($name) - updating rain24h ..."; + readingsSingleUpdate($hash, ".rain24h_lastTS", $curTS, 1); + + my $curRainTotal = ReadingsNum($name, "rainTotal", -1); + return if ($curRainTotal == -1); + + my @values = split(/[|]/, ReadingsVal($name, ".rain24h_hourly", "")); + push(@values, $curRainTotal); + + my $count = scalar(@values); + if ($count > 24) { + my $lastRainTotal = shift(@values); + readingsSingleUpdate($hash, "rain24h", sprintf("%.1f", $curRainTotal - $lastRainTotal), 1); + } else { + my $lastRainTotal = $values[0]; + readingsSingleUpdate($hash, "rain24h", sprintf("(%.1f in %dh)", $curRainTotal - $lastRainTotal, $count), 1); + } + + readingsSingleUpdate($hash, ".rain24h_hourly", join('|', @values), 1); +} + +#------------------------------------------------------------------------------------------------------ +# calculates and update the relative pressure +#------------------------------------------------------------------------------------------------------ +sub WS980_updateRelPressure($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + my $altitude = 0; + if (defined($attr{$name}{"altitude"})){ + $altitude = $attr{$name}{"altitude"}; + } elsif (defined($attr{"global"}{"altitude"})) { + $altitude = $attr{"global"}{"altitude"}; + } + + my $relPressure = WS980_calculateRelPressure_QFF( + ReadingsVal($name, "temperature", 0.0), + ReadingsVal($name, "pressureAbs", 0.0), + $altitude, + ReadingsVal($name, "humidity", 0.0)); + + readingsSingleUpdate($hash, "pressureRel_calculated", sprintf("%.1f", $relPressure), 1); +} + +#------------------------------------------------------------------------------------------------------ +# https://www.symcon.de/forum/threads/6480-Relativen-Luftdruck-aus-absoluten-Luftdruck-errechnen +# QNH: Luftdruckangabe auf Meereshöhe nach einer Standardatmosphäre (nach ICAO) reduziert (Flughäfen, +# CWOP-Stationen, APRS) nach http://dk0te.ba-ravensburg.de/cgi-bin/navi?m=WX_BAROMETER +#------------------------------------------------------------------------------------------------------ +sub WS980_calculateRelPressure_QNH($$$) +{ + my ($Temperature, $AirPressureAbsolute, $Altitude) = @_; + + my $g_n = 9.80665; # Erdbeschleunigung (m/s^2) + my $gam = 0.0065; # Temperaturabnahme in K pro geopotentiellen Metern (K/gpm) + my $R = 287.06; # Gaskonstante für trockene Luft (R = R_0 / M) + my $M = 0.0289644; # Molare Masse trockener Luft (J/kgK) + my $R_0 = 8.314472; # allgemeine Gaskonstante (J/molK) + my $T_0 = 273.15; # Umrechnung von °C in K + + my $p = $AirPressureAbsolute * ((($gam * $Altitude + $Temperature + $T_0) / ($Temperature + $T_0)) ** ($g_n / ($R * $gam))); + return $p; +} + +#------------------------------------------------------------------------------------------------------ +# https://www.symcon.de/forum/threads/6480-Relativen-Luftdruck-aus-absoluten-Luftdruck-errechnen +# QFF: Luftdruckangabe auf Meereshöhe umgerechnet (DWD) +# nach http://dk0te.ba-ravensburg.de/cgi-bin/navi?m=WX_BAROMETER +#------------------------------------------------------------------------------------------------------ +sub WS980_calculateRelPressure_QFF($$$$) +{ + my ($Temperature, $AirPressureAbsolute, $Altitude, $Humidity) = @_; + + my $g_n = 9.80665; # Erdbeschleunigung (m/s^2) + my $gam = 0.0065; # Temperaturabnahme in K pro geopotentiellen Metern (K/gpm) + my $R = 287.06; # Gaskonstante für trockene Luft (R = R_0 / M) + my $M = 0.0289644; # Molare Masse trockener Luft (J/kgK) + my $R_0 = 8.314472; # allgemeine Gaskonstante (J/molK) + my $T_0 = 273.15; # Umrechnung von °C in K + my $C = 0.11; # DWD-Beiwert für die Berücksichtigung der Luftfeuchte + + my $E_0 = 6.11213; # (hPa) + my $f_rel = $Humidity / 100; # relative Luftfeuchte (0-1.0) + # momentaner Stationsdampfdruck (hPa) + my $e_d = $f_rel * $E_0 * exp((17.5043 * $Temperature) / (241.2 + $Temperature)); + + my $p = $AirPressureAbsolute * exp(($g_n * $Altitude) / ($R * ($Temperature + $T_0 + $C * $e_d + (($gam * $Altitude) / 2)))); + return $p; +} + +#------------------------------------------------------------------------------------------------------ +# converts the 'event'-attribute into an internal structure +#------------------------------------------------------------------------------------------------------ +sub WS980_parseEventsAttr($$) +{ + my ($hash, $attrVal) = @_; + my $name = $hash->{NAME}; + + my %oldEventsConfig; + if ($hash->{helper}{eventsConfig}) { + %oldEventsConfig = %{$hash->{helper}{eventsConfig}} + } + + # compact the input to be able to parse it right + $attrVal =~ s/\n/|/g; # newline -> | + $attrVal =~ s/\s//g; # " " -> "" + $attrVal =~ s/\|+/|/g; # || -> | + + Log3 $name, 3, "WS980 ($name) - WS980_parseEventsAttr for $attrVal"; + + # parse attribute + my %eventsConfig; + my @cfgs = split("[|]", $attrVal); + foreach my $cfg (@cfgs) { + my ($event, $readingAndLimit, $hysterese) = split(/[,:]/, $cfg); # dusk:brightness<20,20 + my ($srcReading, $type, $limit) = split(/\b/, $readingAndLimit); # brightness<20 + + my $eventReading = "is" . uc(substr($event, 0, 1)) . substr($event, 1); # isDusk + $eventsConfig{$eventReading}{"src"} = $srcReading; + $eventsConfig{$eventReading}{"type"} = $type; + $eventsConfig{$eventReading}{"limit"} = $limit; + $eventsConfig{$eventReading}{"hyst"} = int($hysterese); + + Log3 $name, 3, "WS980 ($name) - adding event-configuration for $eventReading: $srcReading, $type, $limit, $hysterese"; + } + + # remember config in $hash->{helper} + $hash->{helper}{eventsConfig} = \%eventsConfig; + + # delete removed events + foreach my $oldReading (keys %oldEventsConfig) { + if (!defined($eventsConfig{$oldReading})) { + Log3 $name, 3, "WS980 ($name) - removing event-configuration for $oldReading"; + CommandDeleteReading( undef, "$name ". $oldReading); + CommandDeleteReading( undef, "$name ".".".$oldReading."_hyst"); + } + } + + # initialize new events if init was done already + if ($init_done) { + WS980_updateEvents($hash) + } +} + +#------------------------------------------------------------------------------------------------------ +# updates all events from readings +#------------------------------------------------------------------------------------------------------ +sub WS980_updateEvents($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + Log3 $name, 4, "WS980 ($name) - WS980_updateEvents"; + if (!$hash->{helper}{eventsConfig}) { + return + } + + my %beConfig = %{$hash->{helper}{eventsConfig}}; + + # handle events + readingsBeginUpdate($hash); + foreach my $readingName (keys %beConfig) + { + my $hystReadingName = "." . $readingName . "_hyst"; + my $src = $beConfig{$readingName}{"src"}; # brightness + my $type = $beConfig{$readingName}{"type"}; # <|> + my $limit = $beConfig{$readingName}{"limit"}; # 5000 + my $hyst = $beConfig{$readingName}{"hyst"}; # 100 + + my $prevState = ReadingsNum($name, $readingName, -1); # 0, 1 + my $prevHystState = ReadingsNum($name, $hystReadingName, -1); # 0, 1 + + my $srcValue = ReadingsNum($name, $src, -1); # 23540 + + if ($type eq "<") { + if ($prevState == -1) { + Log3 $name, 3, "WS980 ($name) - adding event $readingName"; + readingsBulkUpdate($hash, $readingName, $srcValue <= $limit ? "1" : "0", 1); + readingsBulkUpdate($hash, $hystReadingName, "0", 0); + } else { + if ($srcValue <= maxNum(0, $limit - $hyst)) { + readingsBulkUpdate($hash, $readingName, "1", 1); + readingsBulkUpdate($hash, $hystReadingName, "0", 0); + } elsif ($srcValue <= $limit) { + if ($prevState == 0 && $prevHystState == 0) { + readingsBulkUpdate($hash, $readingName, "1", 1); + readingsBulkUpdate($hash, $hystReadingName, "1", 0); + } + } elsif ($srcValue <= $limit + $hyst) { + if ($prevState == 1 && $prevHystState == 0) { + readingsBulkUpdate($hash, $readingName, "0", 1); + readingsBulkUpdate($hash, $hystReadingName, "1", 0); + } + } else { + readingsBulkUpdate($hash, $readingName, "0", 1); + readingsBulkUpdate($hash, $hystReadingName, "0", 0); + } + } + } + elsif ($type eq ">") { + if ($prevState == -1) { + Log3 $name, 3, "WS980 ($name) - adding event $readingName"; + readingsBulkUpdate($hash, $readingName, $srcValue >= $limit ? "1" : "0", 1); + readingsBulkUpdate($hash, $hystReadingName, "0", 0); + } else { + if ($srcValue >= $limit + $hyst) { + readingsBulkUpdate($hash, $readingName, "1", 1); + readingsBulkUpdate($hash, $hystReadingName, "0", 01); + } elsif ($srcValue >= $limit) { + if ($prevState == 0 && $prevHystState == 0) { + readingsBulkUpdate($hash, $readingName, "1", 1); + readingsBulkUpdate($hash, $hystReadingName, "1", 0); + } + } elsif ($srcValue >= maxNum(0, $limit - $hyst)) { + if ($prevState == 1 && $prevHystState == 0) { + readingsBulkUpdate($hash, $readingName, "0", 1); + readingsBulkUpdate($hash, $hystReadingName, "1", 0); + } + } else { + readingsBulkUpdate($hash, $readingName, "0", 1); + readingsBulkUpdate($hash, $hystReadingName, "0", 0); + } + } + } + else { + # ERROR + } + } + readingsEndUpdate($hash, 1); +} + +#------------------------------------------------------------------------------------------------------ +# WriteFn +#------------------------------------------------------------------------------------------------------ +sub WS980_WriteFn($$) +{ + my ($hash, $buf) = @_; + my $name = $hash->{NAME}; + + Log3 $name, 4, "WS980 ($name) - WriteFn called"; + + return Log3 $name, 3, "WS980 ($name) - socket not connected" unless($hash->{CD}); + + Log3 $name, 5, "WS980 ($name) - sending " . WS980_hexDump($buf); + my $bytes = syswrite($hash->{CD}, $buf); + + # success? + if (defined($bytes) && $bytes == length($buf)) { + $hash->{helper}{requestInProgress} = 1; + Log3 $name, 5, "WS980 ($name) - sent $bytes bytes"; + } else { + my $err = "Wrote incomplete data"; + if (!defined ($bytes)) { + $err = $!; + } + WS980_error($hash, "error sending data: " . $err); + WS980_Close($hash); + } + return undef; +} + + +#------------------------------------------------------------------------------------------------------ +# close +#------------------------------------------------------------------------------------------------------ +sub WS980_Close($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + return if( !$hash->{CD} ); + + close($hash->{CD}) if($hash->{CD}); + delete($hash->{FD}); + delete($hash->{CD}); + delete($selectlist{$name}); + + $hash->{ConnectionState} = 'disconnected'; + + Log3 $name, 1, "WS980 ($name) - Socket Disconnected"; +} + + +#------------------------------------------------------------------------------------------------------ +# updates lastError-Reading and logs the message +#------------------------------------------------------------------------------------------------------ +sub WS980_error($$) +{ + my ($hash, $msg) = @_; + my $name = $hash->{NAME}; + + readingsSingleUpdate($hash, "lastError", $msg, 1); + Log3 $name, 1, "WS980 ($name) - ERROR: $msg"; +} + + +#------------------------------------------------------------------------------------------------------ +# used to automatically extract attributes from UNIT_CONVERSIONS for AttrList +#------------------------------------------------------------------------------------------------------ +sub WS980_extractAttrsFromUnits() +{ + my $retval; + foreach my $unit (keys %{UNIT_CONVERSIONS()}) { + my $attr = UNIT_CONVERSIONS->{$unit}{"attr"} . ":"; + my @units = keys %{UNIT_CONVERSIONS->{$unit}{"fnc"}}; + $retval .= $attr . join(",", @units) . " "; + } + return $retval; +} + + +#------------------------------------------------------------------------------------------------------ +# converts a binary input to hex-string '\x23\xff' -> "23ff" +#------------------------------------------------------------------------------------------------------ +sub WS980_binToHex($) +{ + my ($bin) = @_; + + my @array = split('', $bin); + + my $hex = ""; + foreach (@array) { + $hex .= sprintf("%02x", ord($_)); + } + return $hex; +} + +#------------------------------------------------------------------------------------------------------ +# returns a readable representation of the already hex formated input like "ffff0b005004..." +#------------------------------------------------------------------------------------------------------ +sub WS980_hexDump($) +{ + my ($buf) = @_; + + my @retval; + my @array = unpack("C*", $buf); + for (my $i = 0; $i < scalar(@array); $i++) { + push (@retval, sprintf('%02x', $array[$i])); + } + return "[" . join(" ", @retval) . "]"; +} + +#------------------------------------------------------------------------------------------------------ +# calculates the checksum of $buf and returns it +# The checksum is the sum of each byte & 0xff +#------------------------------------------------------------------------------------------------------ +sub WS980_calculateChecksum($) +{ + my ($buf) = @_; + + return unpack('%16C*', $buf); +} + +#------------------------------------------------------------------------------------------------------ +# calculates the checksum of $buf[0..-2] and compares it to $buf[-1..-0] +#------------------------------------------------------------------------------------------------------ +sub WS980_checkChecksum($) +{ + my ($buf) = @_; + + my $actual = WS980_calculateChecksum(substr($buf, -1)); # exclude the checksum + my $expected = ord(substr($buf, -1)); + return $actual == $expected; +} + +1; + + +=pod +=item device +=item summary Module to request weather data form WS980WiFi weather stations +=item summary_DE Modul zum Abfragen von Wetterdaten aus WS980WiFi-Wetterstationen + +=begin html + + +

WS980

+ +=end html + +# =begin html_DE +# +# +# =end html_DE + +# Ende der Commandref +=cut diff --git a/fhem/MAINTAINER.txt b/fhem/MAINTAINER.txt index e27bd17d2..ee1160792 100644 --- a/fhem/MAINTAINER.txt +++ b/fhem/MAINTAINER.txt @@ -77,6 +77,7 @@ FHEM/10_pilight_ctrl.pm risiko Sonstige Systeme FHEM/10_RESIDENTS.pm loredo Automatisierung FHEM/10_SOMFY.pm viegener Sonstige Systeme FHEM/10_UNIRoll.pm C_Herrmann SlowRF +FHEM/10_WS980.pm choenig Unterstuetzende Dienste/Wettermodule FHEM/10_ZWave.pm rudolfkoenig ZWave FHEM/11_FHT.pm rudolfkoenig SlowRF FHEM/11_FHT8V.pm rudolfkoenig SlowRF