############################################################################# # $Id$ # fhem Modul für Impulszähler auf Basis von Arduino mit ArduCounter Sketch # # This file is part of fhem. # # Fhem 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. # # Fhem 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. # # You should have received a copy of the GNU General Public License # along with fhem. If not, see . # ############################################################################## # # # ideas / todo: # - use DoClose / SetStates -> Utils? # - check integration of device none and write tests # # - DevIo_IsOpen instead of checking fd # - "static" ports that do not count but report every change or an analog value to Fhem # - check reply from device after sending a command # - rename existing readings if new name is specified in attr # - max time for interpolation as attribute # - detect level thresholds automatically for analog input, track drift # - timeMissed # # # My house water meter: # 36.4? pulses per liter # # one big tap open = 9l / min -> 0,5 qm / h # Max 5qm / h theoretical max load # = 83l/min = 1,6 l / sec => 50 pulses / sec = 50 Hz freq. # => minimal duration 20 ms, sampling at 5ms is fine # package ArduCounter; use strict; use warnings; use GPUtils qw(:all); use Time::HiRes qw(gettimeofday time); use DevIo; use FHEM::HTTPMOD::Utils qw(:all); use Exporter ('import'); our @EXPORT_OK = qw(); our %EXPORT_TAGS = (all => [@EXPORT_OK]); BEGIN { GP_Import( qw( fhem CommandAttr CommandDeleteAttr addToDevAttrList AttrVal ReadingsVal ReadingsTimestamp readingsSingleUpdate readingsBeginUpdate readingsBulkUpdate readingsEndUpdate readingsDelete InternalVal makeReadingName Log3 RemoveInternalTimer InternalTimer deviceEvents EvalSpecials AnalyzePerlCommand CheckRegexp IsDisabled devspec2array FmtTime gettimeofday FmtDateTime GetTimeSpec fhemTimeLocal time_str2num min max minNum maxNum abstime2rel defInfo trim ltrim rtrim UntoggleDirect UntoggleIndirect IsInt fhemNc round sortTopicNum Svn_GetFile WriteFile DevIo_OpenDev DevIo_SimpleWrite DevIo_SimpleRead DevIo_CloseDev DevIo_Disconnected SetExtensions HttpUtils_NonblockingGet featurelevel defs modules attr init_done )); GP_Export( qw( Initialize )); }; my $Module_version = '8.00 - 21.10.2021'; my %SetHash = ( 'disable' => '', 'enable' => '', 'raw' => '', 'reset' => '', 'resetWifi' => '', 'flash' => '', 'saveConfig' => '', 'clearLevels' => '', 'counter' => '', 'clearCounters' => '', 'clearHistory' => '', 'reconnect' => '' ); my %GetHash = ( 'info' => '', 'history' => '', 'levels' => '' ); my %AnalogPinMap = ( 'NANO' => { 'A0' => 14, 'A1' => 15, 'A2' => 16, 'A3' => 17, 'A4' => 18, 'A5' => 19, 'A6' => 20, 'A7' => 21 }, 'ESP8266' => { 'A0' => 17 }, 'ESP32' => { 'A0' => 36 }, 'T-Display' => { 'A0' => 36 } ); my %rAnalogPinMap; ######################################################################### # FHEM module intitialisation # defines the functions to be called from FHEM sub Initialize { my $hash = shift; $hash->{ReadFn} = \&ArduCounter::ReadFn; $hash->{ReadyFn} = \&ArduCounter::ReadyFn; $hash->{DefFn} = \&ArduCounter::DefineFn; $hash->{UndefFn} = \&ArduCounter::UndefFn; $hash->{GetFn} = \&ArduCounter::GetFn; $hash->{SetFn} = \&ArduCounter::SetFn; $hash->{AttrFn} = \&ArduCounter::AttrFn; $hash->{NotifyFn} = \&ArduCounter::NotifyFn; $hash->{AttrList} = 'board:UNO,NANO,ESP8266,ESP32,T-Display ' . 'pin[AD]?[0-9]+ ' . # configuration of pins -> sent to device 'interval ' . # configuration of intervals -> sent to device 'factor ' . # legacy (should be removed, use pulsesPerKwh instead) 'pulsesPerKWh ' . # old 'pulsesPerUnit ' . 'flowUnitTime ' . # time for which the flow / consumtion is calculated. Defaults to 3600 seconds (one hour) 'devVerbose:0,5,10,20,30,40,50 ' . # old configuration of verbose level of board -> sent to device 'enableHistory:0,1 ' . # history creation on device 'enableSerialEcho:0,1,2 ' . # serial echo of output via TCP from device 'enablePinDebug:0,1 ' . # show pin state changes from device 'enableAnalogDebug:0,1,2,3 ' . # show analog levels 'enableDevTime:0,1 ' . # device will send its time so drift can be detected 'analogThresholds ' . # legacy (should be removed, add to pin attributes instead) 'readingNameCount[AD]?[0-9]+ ' . # raw count for this running period 'readingNamePower[AD]?[0-9]+ ' . 'readingNameLongCount[AD]?[0-9]+ ' . # long term count 'readingNameInterpolatedCount[AD]?[0-9]+ ' . # long term count including interpolation for offline times 'readingNameCalcCount[AD]?[0-9]+ ' . # new to be implemented by using factor for the counter as well 'readingFactor[AD]?[0-9]+ ' . 'readingPulsesPerKWh[AD]?[0-9]+ ' . 'readingPulsesPerUnit[AD]?[0-9]+ ' . 'readingFlowUnitTime[AD]?[0-9]+ ' . # time for which the flow / consumtion is calculated. Defaults to 3600 seconds (one hour) 'readingStartTime[AD]?[0-9]+ ' . 'verboseReadings[AD]?[0-9]+ ' . 'runTime[AD]?[0-9]+ ' . # keep runTime for this pin 'runTimeIgnore[AD]?[0-9]+ ' . # ignore runTime for this pin while specified devices switched on 'flashCommand ' . 'helloSendDelay ' . 'helloWaitTime ' . 'configDelay ' . # how many seconds to wait before sending config after reboot of board 'keepAliveDelay ' . 'keepAliveTimeout ' . 'keepAliveRetries ' . 'nextOpenDelay ' . 'silentReconnect:0,1 ' . 'openTimeout ' . 'maxHist ' . 'deviceDisplay ' . 'logFilter ' . 'disable:0,1 ' . 'do_not_notify:1,0 ' . $main::readingFnAttributes; # initialize rAnalogPinMap for each board and pin foreach my $board (keys %AnalogPinMap) { foreach my $pinName (keys %{$AnalogPinMap{$board}}) { my $pin = $AnalogPinMap{$board}{$pinName}; $rAnalogPinMap{$board}{$pin} = $pinName; #Log3 undef, 3, "ArduCounter: initialize rAalogPinMap $board - $pin - $pinName"; } } return; } ########################################################################## # Define command sub DefineFn { my $hash = shift; # reference to the Fhem device hash my $def = shift; # definition string my @a = split( /[ \t]+/, $def ); # the above string split at space or tab return 'wrong syntax: define ArduCounter devicename@speed or ipAdr:port' if ( @a < 3 ); DevIo_CloseDev($hash); my $name = $a[0]; my $dev = $a[2]; if ($dev =~ m/^[Nn]one$/) { # none # for testing } elsif ($dev =~ m/^(.+):([0-9]+)$/) { # tcp conection with explicit port $hash->{TCP} = 1; } elsif ($dev =~ m/^(\d+\.\d+\.\d+\.\d+)(?:\:([0-9]+))?$/) { $hash->{TCP} = 1; $dev .= ':80' if (!$2); # ip adr with optional port } else { # serial connection if ($dev !~ /.+@([0-9]+)/) { $dev .= '@115200'; # add new default serial speed } else { Log3 $name, 3, "$name: Warning: connection speed $1 is not the default for the latest ArduCounter firmware" if ($1 != 115200); } } $hash->{DeviceName} = $dev; $hash->{VersionModule} = $Module_version; $hash->{NOTIFYDEV} = "global"; # NotifyFn nur aufrufen wenn global events (INITIALIZED) $hash->{STATE} = "disconnected"; delete $hash->{Initialized}; # device might not be initialized - wait for hello / setup before cmds Log3 $name, 3, "$name: defined with $dev, Module version $Module_version"; # do open in notify after init_done or after a new defined device (also after init_done) return; } ######################################################################### # undefine command when device is deleted sub UndefFn { my $hash = shift; DevIo_CloseDev($hash); return; } ##################################################### # remove timers, call DevIo_Disconnected # to set state and add to readyFnList sub SetDisconnected { my $hash = shift; my $name = $hash->{NAME}; RemoveInternalTimer ("alive:$name"); # no timeout if waiting for keepalive response RemoveInternalTimer ("keepAlive:$name"); # don't send keepalive messages anymore RemoveInternalTimer ("sendHello:$name"); DevIo_Disconnected($hash); # close, add to readyFnList so _Ready is called to reopen return; } ##################################################### # open callback sub OpenCallback { my $hash = shift; my $msg = shift; my $name = $hash->{NAME}; my $now = gettimeofday(); if ($msg) { Log3 $name, 5, "$name: Open callback: $msg" if ($msg); } delete $hash->{BUSY_OPENDEV}; if ($hash->{FD}) { Log3 $name, 5, "$name: DoOpen succeeded in callback"; my $hdl = AttrVal($name, "helloSendDelay", 4); # send hello if device doesn't say "Started" withing $hdl seconds RemoveInternalTimer ("sendHello:$name"); InternalTimer($now+$hdl, "ArduCounter::AskForHello", "sendHello:$name", 0); if ($hash->{TCP}) { # send first keepalive immediately to turn on tcp mode in device KeepAlive("keepAlive:$name"); } } else { # no file descriptor after open #Log3 $name, 5, "$name: DoOpen failed - open callback called from DevIO without FD"; } return; } ########################################################################## # Open Device # called from Notify after init_done or when a new device is defined later, # or from Ready as reopen, # from attr when disable is removed / set to 0, # from set reconnect, reset or after flash, # from delayed_open when a tcp connection was closed with "already busy" # # normally an open also resets the counter board, unless its hardware is modified # to continue when opened. # sub DoOpen { my $hash = shift; my $reopen = shift // 0; my $name = $hash->{NAME}; my $now = gettimeofday(); my $caller = FhemCaller(); if ($hash->{DeviceName} eq 'none') { Log3 $name, 5, "$name: open called from $caller, device is defined with none" if ($caller ne 'Ready'); SetStates($hash, 'opened'); return; } if ($hash->{BUSY_OPENDEV}) { # still waiting for callback to last open if ($hash->{LASTOPEN} && $now > $hash->{LASTOPEN} + (AttrVal($name, "openTimeout", 3) * 2) && $now > $hash->{LASTOPEN} + 15) { Log3 $name, 5, "$name: _Open - still waiting for open callback, timeout is over twice - this should never happen"; Log3 $name, 5, "$name: _Open - stop waiting and reset the flag."; $hash->{BUSY_OPENDEV} = 0; } else { # no timeout yet Log3 $name, 5, "$name: _Open - still waiting for open callback"; return; } } if (!$reopen) { # not called from _Ready DevIo_CloseDev($hash); delete $hash->{NEXT_OPEN}; delete $hash->{DevIoJustClosed}; } Log3 $name, 4, "$name: trying to open connection to $hash->{DeviceName}" if (!$reopen); $hash->{BUSY_OPENDEV} = 1; $hash->{LASTOPEN} = $now; $hash->{nextOpenDelay} = AttrVal($name, "nextOpenDelay", 60); $hash->{devioLoglevel} = (AttrVal($name, "silentReconnect", 0) ? 4 : 3); $hash->{TIMEOUT} = AttrVal($name, "openTimeout", 3); $hash->{buffer} = ""; # clear Buffer for reception DevIo_OpenDev($hash, $reopen, 0, \&OpenCallback); delete $hash->{TIMEOUT}; if ($hash->{FD}) { Log3 $name, 5, "$name: DoOpen succeeded immediately" if (!$reopen); } else { Log3 $name, 5, "$name: DoOpen waiting for callback" if (!$reopen); } return; } ################################################## # close connection # $hash is physical or both (connection over TCP) sub DoClose { my ($hash, $noState, $noDelete) = @_; my $name = $hash->{NAME}; Log3 $name, 5, "$name: Close called from " . FhemCaller() . ($noState || $noDelete ? ' with ' : '') . ($noState ? 'noState' : '') . # set state? ($noState && $noDelete ? ' and ' : '') . ($noDelete ? 'noDelete' : ''); # command delete on connection device? delete $hash->{LASTOPEN}; # reset so next open will actually call OpenDev if ($hash->{DeviceName} eq 'none') { Log3 $name, 4, "$name: Simulate closing connection to none"; } else { Log3 $name, 4, "$name: Close connection with DevIo_CloseDev"; # close even if it was not open yet but on ready list (need to remove entry from readylist) DevIo_CloseDev($hash); } SetStates($hash, 'disconnected') if (!$noState); return; } ################################################################# # set state Reading and STATE internal # call instead of setting STATE directly and when inactive / disconnected sub SetStates { my $hash = shift; my $state = shift; my $name = $hash->{NAME}; my $newState = $state; Log3 $name, 5, "$name: SetState called from " . FhemCaller() . " with $state sets state and STATE to $newState"; $hash->{STATE} = $newState; return if ($newState eq ReadingsVal($name, 'state', '')); readingsSingleUpdate($hash, 'state', $newState, 1); return; } ######################################################################### sub ReadyFn { my $hash = shift; my $name = $hash->{NAME}; if($hash->{STATE} eq "disconnected") { RemoveInternalTimer ("alive:$name"); # no timeout if waiting for keepalive response RemoveInternalTimer ("keepAlive:$name"); # don't send keepalive messages anymore delete $hash->{Initialized}; # when reconnecting wait for setup / hello before further action if (IsDisabled($name)) { Log3 $name, 3, "$name: _Ready: $name is disabled - don't try to reconnect"; DevIo_CloseDev($hash); # close, remove from readyfnlist so _ready is not called again return; } DoOpen($hash, 1); # reopen, don't call DevIoClose before reopening return; # a return value triggers direct read for win } # This is relevant for windows/USB only my $po = $hash->{USBDev}; if ($po) { my ($BlockingFlags, $InBytes, $OutBytes, $ErrorFlags) = $po->status; return ($InBytes>0); # tell fhem.pl to read when we return } return; } ####################################################### # called from InternalTimer # if TCP connection is busy or after firmware flash sub DelayedOpen { my $param = shift; my (undef,$name) = split(/:/,$param); my $hash = $defs{$name}; Log3 $name, 4, "$name: try to reopen connection after delay"; RemoveInternalTimer ("delayedopen:$name"); delete $hash->{DevIoJustClosed}; # otherwise open returns without doing anything this time and we are not on the readyFnList ... DoOpen($hash, 1); # reopen return; } ######################################################## # Notify for INITIALIZED or Modified # -> Open connection to device sub NotifyFn { my ($hash, $source) = @_; return if($source->{NAME} ne "global"); my $events = deviceEvents($source, 1); return if(!$events); my $name = $hash->{NAME}; # Log3 $name, 5, "$name: Notify called for source $source->{NAME} with events: @{$events}"; return if (!grep {m/^INITIALIZED|REREADCFG|(MODIFIED $name)$|(DEFINED $name)$/} @{$events}); # DEFINED is not triggered if init is not done. if (IsDisabled($name)) { Log3 $name, 3, "$name: Notify / Init: device is disabled"; return; } Log3 $name, 3, "$name: Notify called with events: @{$events}, " . "open device and set timer to send hello to device"; DoOpen($hash); return; } ###################################### # wrapper for DevIo write sub DoWrite { my $hash = shift; my $line = shift; my $name = $hash->{NAME}; if (!IsOpen($hash)) { Log3 $name, 5, "$name: Write: device is disconnected, dropping line to write"; return 0; } if (IsDisabled($name)) { Log3 $name, 5, "$name: Write called but device is disabled, dropping line to send"; return 0; } #Log3 $name, 5, "$name: Write: $line"; # devio will already log the write #DevIo_SimpleWrite($hash, "\n", 2); if ($hash->{DeviceName} eq 'none') { Log3 $name, 4, "$name: Simulate sending to none: $line"; } else { DevIo_SimpleWrite($hash, "$line.", 2); } return 1; } ######################################################################################## # return the internal pin number for an analog pin name name like A1 # $hash->{Board} is set in parseHello and potentially overwritten by Attribut board # called from Attr and ConfigureDevice to translate analog pin specifications to numbers # in all cases Board and AllowedPins have been received with hello before sub InternalPinNumber { my $hash = shift; my $pinName = shift; my $name = $hash->{NAME}; my $board = $hash->{Board}; my $pin; if (!$board) { # if board is not known, try to guess it # maybe no hello received yet and no Board-attr set (should never be the case) my @boardOptions = keys %AnalogPinMap; my $count = 0; foreach my $candidate (@boardOptions) { if ($AnalogPinMap{$candidate}{$pinName}) { $board = $candidate; $count++; } } if ($count > 1) { Log3 $name, 3, "$name: PinNumber called from " . FhemCaller() . " can not determine internal pin number for $pinName," . " board type is not known (yet) and attribute Board is also not set"; } elsif (!$count) { Log3 $name, 3, "$name: PinNumber called from " . FhemCaller() . " can not determine internal pin number for $pinName." . " No known board seems to support it"; } else { Log3 $name, 3, "$name: PinNumber called from " . FhemCaller() . " does not know what kind of board is used. " . " Guessing $board ..."; } } $pin = $AnalogPinMap{$board}{$pinName} if ($board); if ($pin) { Log3 $name, 5, "$name: PinNumber called from " . FhemCaller() . " returns $pin for $pinName"; } else { Log3 $name, 5, "$name: PinNumber called from " . FhemCaller() . " returns unknown for $pinName"; } return $pin # might be undef } ###################################################### # return the the pin as it is used in a pin attr # e.g. D2 or A1 for a passed pin number sub PinName { my $hash = shift; my $pin = shift; my $name = $hash->{NAME}; my $pinName = $pin; # start assuming that attrs are set as pinX if (!AttrVal($name, "pin$pinName", 0)) { # if not if (AttrVal($name, "pinD$pin", 0)) { # is the pin defined as pinDX? $pinName = "D$pin"; #Log3 $name, 5, "$name: using attrs with pin name D$pin"; } elsif ($hash->{Board}) { my $aPin = $rAnalogPinMap{$hash->{Board}}{$pin}; if ($aPin) { # or pinAX? $pinName = "$aPin"; #Log3 $name, 5, "$name: using attrs with pin name $pinName instead of $pin or D$pin (Board $hash->{Board})"; } } } return $pinName; } ##################################################### # return the first attr in the list that is defined sub AttrValFromList { my ($hash, $default, $a1, $a2, $a3, $a4) = @_; my $name = $hash->{NAME}; return AttrVal($name, $a1, undef) if (defined (AttrVal($name, $a1, undef))); #Log3 $name, 5, "$name: AAV (" . FhemCaller() . ") $a1 not there"; return AttrVal($name, $a2, undef) if (defined ($a2) && defined (AttrVal($name, $a2, undef))); #Log3 $name, 5, "$name: AAV (" . FhemCaller() . ") $a2 not there"; return AttrVal($name, $a3, undef) if (defined ($a3) && defined (AttrVal($name, $a3, undef))); #Log3 $name, 5, "$name: AAV (" . FhemCaller() . ") $a3 not there"; return AttrVal($name, $a4, undef) if (defined ($a4) && defined (AttrVal($name, $a4, undef))); #Log3 $name, 5, "$name: AAV (" . FhemCaller() . ") $a4 not there"; return $default; } ###################################################### # return a meaningful name (the relevant reading name) # for passed pin number # called from functions that handle device output with pin number sub LogPinDesc { my $hash = shift; my $pin = shift; my $pinName = PinName ($hash, $pin); return AttrValFromList($hash, "pin$pin", "readingNameCount$pinName", "readingNameCount$pin", "readingNamePower$pinName", "readingNamePower$pin"); } ###################################################### # send 'a' command to the device to configure a pin # called from attr (pin attribute) and configureDevice # with a pinName sub ConfigurePin { my ($hash, $pinArg, $aVal) = @_; my $name = $hash->{NAME}; my $opt; if ($aVal !~ /^(rising|falling)[ \,\;]*(pullup)?[ \,\;]*(min +)?(\d+)?(?:[ \,\;]*(?:analog )out *(\d+)(?:[ \,\;]*threshold *(\d+)[ \,\;]+(\d+)))?/) { Log3 $name, 3, "$name: ConfigurePin got invalid config for $pinArg: $aVal"; return "Invalid config for pin $pinArg: $aVal"; } my ($edge, $pullup, $minText, $min, $aout, $t1, $t2) = ($1, $2, $3, $4, $5, $6, $7); if (!$hash->{Initialized}) { # no hello received yet Log3 $name, 5, "$name: pin validation and communication postponed until device is initialized"; return; # accept value but don't send it to the device yet. } my ($pin, $pinName) = ParsePin($hash, $pinArg); return "illegal pin $pinArg" if (!defined($pin)); # parsePin logs error if wrong pin spec if ($edge eq 'rising') {$opt = "3"} # pulse level rising or falling elsif ($edge eq 'falling') {$opt = "2"} $opt .= ($pullup ? ",1" : ",0"); # pullup $opt .= ($min ? ",$min" : ",2"); # min length, default is 2 if ($hash->{VersionFirmware} && $hash->{VersionFirmware} > "4.00") { if (defined($aout)) { $opt .= ",$aout" } # analog out pin if (defined($t2)) { $opt .= ",$t1,$t2" } # analog thresholds } else { Log3 $name, 3, "$name: ConfigurePin sends old syntax to outdated firmware ($hash->{VersionFirmware})"; } Log3 $name, 5, "$name: ConfigurePin creates command ${pin},${opt}a"; DoWrite($hash, "${pin},${opt}a"); # initialized is already checked above return; } ###################################################### # send 'i' command to the device to configure a pin sub ConfigureIntervals { my $hash = shift; my $aVal = shift; my $name = $hash->{NAME}; my $cmd; if (!defined($aVal)) { $aVal = AttrVal($name, "interval", ""); if (!$aVal) { Log3 $name, 4, "$name: attr interval not set"; return; } } if ($aVal !~ /^(\d+)[\s\,](\d+)[\s\,]?(\d+)?[\s\,]?(\d+)?([\s\,](\d+)[\s\,]+(\d+))?/) { Log3 $name, 3, "$name: Invalid interval specification $aVal"; return "Invalid interval specification $aVal"; } my ($min, $max, $sml, $cnt, $ain, $asm) = ($1, $2, $3, $4, $5, $6, $7); if ($min < 1 || $min > 3600 || $max < $min || $max > 3600) { Log3 $name, 3, "$name: Invalid value in interval specification $aVal"; return "Invalid Value $aVal"; } if (!$hash->{Initialized}) { Log3 $name, 5, "$name: communication postponed until device is initialized"; return; } $sml = 0 if (!$sml); $cnt = 0 if (!$cnt); $ain = 50 if (!$ain); $asm = 4 if (!$asm); if ($hash->{VersionFirmware} && $hash->{VersionFirmware} > "4.00") { $cmd = "${min},${max},${sml},${cnt},${ain},${asm}i"; } else { # old firmware $cmd = "${min},${max},${sml},${cnt}i"; } Log3 $name, 5, "$name: ConfigureIntervals creates command $cmd"; DoWrite($hash, $cmd); return; } ###################################################### # send 'a' command to the device to configure a pin sub ConfigureVerboseLevels { my ($hash, $eHist, $eSerial, $pinDebug, $aDebug, $eTime) = @_; my $name = $hash->{NAME}; my $err; if (defined($eHist)) { if ($eHist !~ /^[01]$/) { $err = "illegal value for enableHistory: $eHist, only 0 and 1 allowed"; } } else { $eHist = AttrVal($name, "enableHistory", 0); } if (defined($eSerial)) { if ($eSerial !~ /^[012]$/) { $err = "illegal value enableSerialEcho: $eSerial, only 0,1 and 2 allowed"; } } else { $eSerial = AttrVal($name, "enableSerialEcho", 0); } if (defined($pinDebug)) { if ($pinDebug !~ /^[01]$/) { $err = "illegal value enablePinDebug: $pinDebug, only 0 and 1 allowed"; } } else { $pinDebug = AttrVal($name, "enablePinDebug", 0); } if (defined($aDebug)) { if ($aDebug !~ /^[0123]$/) { $err = "illegal value enable AnalogDebug: $aDebug, only 0-3 allowed"; } } else { $aDebug = AttrVal($name, "enableAnalogDebug", 0); } if (defined($eTime)) { if ($eTime !~ /^[01]$/) { $err = "illegal value enableDevTime: $eTime, only 0 and 1 allowed"; } } else { $eTime = AttrVal($name, "enableDevTime", 0); } if ($err) { Log3 $name, 3, "$name: $err"; return $err; } if (!$hash->{Initialized}) { # no hello received yet Log3 $name, 5, "$name: pin validation and communication postponed until device is initialized"; return; # accept value but don't send it to the device yet. } my $cmd = "${eHist},${eSerial},${pinDebug},${aDebug},${eTime}v"; Log3 $name, 5, "$name: ConfigureVerboseLevels creates command $cmd"; DoWrite($hash, $cmd); return; } ###################################################### # encode string as int sequence # used in communication with device sub IntString { my ($inStr) = @_; my $byteNum = 0; my $val = 0; my $outStr; foreach my $char (split (//, $inStr)) { if ($byteNum) { $val = ord($char) * 256 + $val; # second char -> add as high byte $outStr .= ",$val"; $byteNum = 0; } else { $val = ord($char); # first char $byteNum++; } } if ($byteNum) { # low order byte has been set, high byte is still zero $outStr .= ",$val"; # but not added to outstr yet } else { # high byte is used as well, $outStr .= ",0"; # add training zero if val is not already zero } return $outStr; } ###################################################################### # send 'p' command to the device to configure # a tft display connected to the device # called from configureDevice which handles hello message from device # and from attr deviceDisplay with $aVal sub ConfigureDisplay { my ($hash, $aVal) = @_; my ($pinArg, $pin, $pinName, $ppu, $fDiv, $unit, $fut, $funit); my $name = $hash->{NAME}; if (!defined($aVal)) { $aVal = AttrVal($name, "deviceDisplay", ""); } if ($aVal =~ /^([AD\d]+)(?:[\s\,]+([^\s\,]+)(?:[\s\,]+([^\s\,]+)))\s*$/) { ($pinArg, $unit, $funit) = ($1, $2, $3); if (!$hash->{Initialized}) { # no hello received yet Log3 $name, 5, "$name: pin validation and communication postponed until device is initialized"; return; # accept value but don't send it to the device yet. } ($pin, $pinName) = ParsePin($hash, $pinArg); return "illegal pin $pinArg" if (!defined($pin)); # parsePin logs error if wrong pin spec $ppu = AttrValFromList($hash, 0, "readingPulsesPerUnit$pinName", "readingPulsesPerUnit$pin"); $ppu = AttrValFromList($hash, 0, "readingPulsesPerKWh$pinName", "readingPulsesPerKWh$pin") if (!$ppu); $ppu = AttrValFromList($hash, 1, "pulsesPerUnit", "pulsesPerKWh") if (!$ppu); $fut = AttrValFromList($hash, 60, "readingFlowUnitTime$pinName", "readingFlowUnitTime$pin"); Log3 $name, 5, "$name: ConfigureDisplay pin $pin / $pinName, ppu $ppu, fut $fut"; if ($ppu =~ /(\.\d)/) { $fDiv = 10 ** (length($1)-1); $ppu = int($ppu * $fDiv); } else { $fDiv = 1; } } else { Log3 $name, 3, "$name: Invalid device display configuration $aVal"; return "Invalid device display configuration $aVal"; } Log3 $name, 5, "$name: ConfigureDisplay $pin, $ppu, $fDiv, $unit, $fut, $funit"; if (!$hash->{Initialized}) { # no hello received yet Log3 $name, 5, "$name: pin validation and communication postponed until device is initialized"; return; # accept value but don't send it to the device yet. } my $cmd = "$pin,$ppu,$fDiv" . IntString($unit) . ",$fut" . IntString($funit) . "u"; Log3 $name, 5, "$name: ConfigureDisplay creates command $cmd"; DoWrite($hash, $cmd); return; } ####################################################### # called from InternalTimer # if relevant attr is changed sub DelayedConfigureDisplay { my $param = shift; my (undef,$name) = split(/:/,$param); my $hash = $defs{$name}; Log3 $name, 5, "$name: call configureDisplay after delay"; RemoveInternalTimer ("delayedcdisp:$name"); ConfigureDisplay($hash); return; } ####################################### # Aufruf aus InternalTimer # send "h" to ask for "Hello" since device didn't say "Started" so far - maybe it's still counting ... # called with timer from _openCB, _Ready and if count is read in _Parse but no hello was received sub AskForHello { my $param = shift; my (undef,$name) = split(/:/,$param); my $hash = $defs{$name}; Log3 $name, 5, "$name: ArduCounter $Module_version sending h(ello) to device to ask for firmware version"; return if (!DoWrite( $hash, "h")); my $now = gettimeofday(); my $hwt = AttrVal($name, "helloWaitTime", 2); RemoveInternalTimer ("hwait:$name"); InternalTimer($now+$hwt, "ArduCounter::HelloTimeout", "hwait:$name", 0); $hash->{WaitForHello} = 1; return; } ####################################### # Aufruf aus InternalTimer sub HelloTimeout { my $param = shift; my (undef,$name) = split(/:/,$param); my $hash = $defs{$name}; delete $hash->{WaitForHello}; RemoveInternalTimer ("hwait:$name"); if ($hash->{DeviceName} !~ m/^(.+):([0-9]+)$/) { # not TCP if (!$hash->{OpenRetries}) { $hash->{OpenRetries} = 1; } else { $hash->{OpenRetries}++; if ($hash->{OpenRetries}++ > 4) { Log3 $name, 3, "$name: device didn't reply to h(ello). Is the right sketch flashed? Is serial speed set to 38400 or 115200 for firmware >4.0?"; return; } } Log3 $name, 5, "$name: HelloTimeout: DeviceName in hash is $hash->{DeviceName}"; if ($hash->{DeviceName} !~ /(.+)@([0-9]+)(.*)/) { # no serial speed specified $hash->{DeviceName} .= '@38400'; # should not happen (added during define) Log3 $name, 3, "$name: device didn't reply to h(ello). No serial speed set. Is the right sketch flashed? Trying again with \@38400"; } else { if ($2 == 38400) { $hash->{DeviceName} = "${1}\@115200${3}"; # now try 115200 if 38400 before Log3 $name, 3, "$name: device didn't reply to h(ello). Is the right sketch flashed? Serial speed was $2. Trying again with \@115200"; } else { $hash->{DeviceName} = "${1}\@38400${3}"; # now try 38400 Log3 $name, 3, "$name: device didn't reply to h(ello). Is the right sketch flashed? Serial speed was $2. Trying again with \@38400"; } } Log3 $name, 5, "$name: HelloTimeout: DeviceName in hash is set to $hash->{DeviceName}"; DoOpen($hash); # try again } return; } ############################################ # Aufruf aus Open / Ready und InternalTimer # send "1k" to ask for "alive" sub KeepAlive { my $param = shift; my (undef,$name) = split(/:/,$param); my $hash = $defs{$name}; my $now = gettimeofday(); if (IsDisabled($name)) { return; } my $kdl = AttrVal($name, "keepAliveDelay", 10); # next keepalive as timer my $kto = AttrVal($name, "keepAliveTimeout", 2); # timeout waiting for response Log3 $name, 5, "$name: sending k(eepAlive) to device" if (AttrVal($name, "logFilter", "N") =~ "N"); DoWrite( $hash, "1,${kdl}k"); RemoveInternalTimer ("alive:$name"); InternalTimer($now+$kto, "ArduCounter::AliveTimeout", "alive:$name", 0); #Log3 $name, 5, "$name: keepAlive timeout timer set $kto"; if ($hash->{TCP}) { RemoveInternalTimer ("keepAlive:$name"); InternalTimer($now+$kdl, "ArduCounter::KeepAlive", "keepAlive:$name", 0); # next keepalive #Log3 $name, 5, "$name: keepAlive timer for next message set in $kdl"; } return; } ####################################### # Aufruf aus InternalTimer sub AliveTimeout { my $param = shift; my (undef,$name) = split(/:/,$param); my $hash = $defs{$name}; #Log3 $name, 5, "$name: AliveTimeout called"; $hash->{KeepAliveRetries} = 0 if (!$hash->{KeepAliveRetries}); if (++$hash->{KeepAliveRetries} > AttrVal($name, "keepAliveRetries", 2)) { Log3 $name, 3, "$name: device didn't reply to k(eeepAlive), no retries left, setting device to disconnected"; SetDisconnected($hash); # set to Disconnected but let _Ready try to Reopen delete $hash->{KeepAliveRetries}; } else { Log3 $name, 3, "$name: device didn't reply to k(eeepAlive), count=$hash->{KeepAliveRetries}"; } return; } ########################################################################## # Send config commands after Board reported it is ready or still counting # called when parsing hello message from device sub ConfigureDevice { my $param = shift; my (undef,$name) = split(/:/,$param); my $hash = $defs{$name}; # todo: check if device got disconnected in the meantime! my @runningPins = sort grep {/[\d]/} keys %{$hash->{runningCfg}}; #Log3 $name, 5, "$name: ConfigureDevice: pins in running config: @runningPins"; my @attrPins = sort grep {/pin([dDaA])?[\d]/} keys %{$attr{$name}}; #Log3 $name, 5, "$name: ConfigureDevice: pins from attrs: @attrPins"; #Log3 $name, 5, "$name: ConfigureDevice: check for pins without attr in list: @runningPins"; my %cPins; # get all pins from running config in a hash to find out if one is not defined on fhem side for (my $i = 0; $i < @runningPins; $i++) { $cPins{$runningPins[$i]} = 1; #Log3 $name, 3, "$name: ConfigureDevice remember pin $runningPins[$i]"; } Log3 $name, 5, "$name: ConfigureDevice: send config"; while (my ($aName, $val) = each(%{$attr{$name}})) { if ($aName =~ /^pin([DA])?([\d+]+)/) { # for each pin attr my $type = ($1 ? $1 : ''); my $aPinNum = $2; # if not overwritten for analog pins, we have already a number my $pinName = $type.$aPinNum; $aPinNum = InternalPinNumber($hash, $pinName) if ($type && $type eq 'A'); # if this is an analog pin specification translate it if ($aPinNum) { ConfigurePin($hash, $pinName, $val); delete $cPins{$aPinNum}; # this pin from running config has an attr } else { Log3 $name, 3, "$name: ConfigureDevice can not send pin config for $aName, internal pin number can not be determined"; } } } if (%cPins) { # remaining pins in running config without attrs my $pins = join ",", keys %cPins; Log3 $name, 5, "$name: ConfigureDevice: pins in running config without attribute in Fhem: $pins"; foreach my $pin (keys %cPins) { Log3 $name, 5, "$name: ConfigureDevice: removing pin $pin"; DoWrite($hash, "${pin}d"); } } else { Log3 $name, 5, "$name: ConfigureDevice: no pins in running config without attribute in Fhem"; } ConfigureIntervals($hash); ConfigureVerboseLevels($hash); ConfigureDisplay($hash) if ($hash->{Board} =~ /Display/); DoWrite( $hash, "s"); # get new running config return; } ######################################################################### # Attr command sub AttrFn { my ($cmd,$name,$aName,$aVal) = @_; # $cmd can be "del" or "set" # $name is device name # aName and aVal are Attribute name and value my $hash = $defs{$name}; my $modHash = $modules{$hash->{TYPE}}; Log3 $name, 5, "$name: Attr called with @_"; if ($cmd eq "set") { if ($aName =~ /^pin([DA]?\d+)/) { # pin attribute -> add a pin my $pinName = $1; return ConfigurePin($hash, $pinName, $aVal); } elsif ($aName eq "devVerbose") { my $text = "devVerbose has been replaced by " . 'enableHistory:0,1 ' . # history creation on device 'enableSerialEcho:0,1,2 ' . # serial echo of output via TCP from device 'enablePinDebug:0,1 ' . # show pin state changes from device 'enableAnalogDebug:0,1,2,3 ' . # show analog levels 'enableDevTime:0,1 ' . # device will send its time so drift can be detected " please adapt you attribute configuration"; Log3 $name, 3, "$name: $text"; return $text; } elsif ($aName eq "enableHistory") { return ConfigureVerboseLevels($hash, $aVal); } elsif ($aName eq "enableSerialEcho") { return ConfigureVerboseLevels($hash, undef, $aVal); } elsif ($aName eq "enablePinDebug") { return ConfigureVerboseLevels($hash, undef, undef, $aVal); } elsif ($aName eq "enableAnalogDebug") { return ConfigureVerboseLevels($hash, undef, undef, undef, $aVal); } elsif ($aName eq "enableDevTime") { return ConfigureVerboseLevels($hash, undef, undef, undef, undef, $aVal); } elsif ($aName eq "analogThresholds") { my $text = "analogThresholds has been removed. Thresholds are now part of the pin attribute. Please update your configuration"; Log3 $name, 3, "$name: $text"; if ($aVal =~ /^(\d+) (\d+)\s*$/) { my $min = $1; my $max = $2; if ($min < 1 || $min > 1023 || $max < $min || $max > 1023) { Log3 $name, 3, "$name: Invalid value in attr $name $aName $aVal"; return "Invalid Value $aVal"; } if ($hash->{Initialized}) { DoWrite($hash, "${min},${max}t"); } else { Log3 $name, 5, "$name: communication postponed until device is initialized"; } } else { Log3 $name, 3, "$name: Invalid value in attr $name $aName $aVal"; return "Invalid Value $aVal"; } } elsif ($aName eq "interval") { return ConfigureIntervals($hash, $aVal); } elsif ($aName eq "board") { $hash->{Board} = $aVal; } elsif ($aName eq "factor") { # log notice to remove this / replace if ($aVal =~ '^(\d+)$') { } else { Log3 $name, 3, "$name: Invalid value in attr $name $aName $aVal"; return "Invalid Value $aVal"; } } elsif ($aName eq "keepAliveDelay") { if ($aVal =~ '^(\d+)$') { if ($aVal > 3600) { Log3 $name, 3, "$name: value too big in attr $name $aName $aVal"; return "Value too big: $aVal"; } } else { Log3 $name, 3, "$name: Invalid value in attr $name $aName $aVal"; return "Invalid Value $aVal"; } } elsif ($aName eq 'disable') { if ($aVal) { Log3 $name, 5, "$name: disable attribute set"; SetDisconnected($hash); # set to disconnected and remove timers DevIo_CloseDev($hash); # really close and remove from readyFnList again return; } else { Log3 $name, 3, "$name: disable attribute cleared"; DoOpen($hash) if ($init_done); # only if fhem is initialized } } elsif ($aName eq 'deviceDisplay') { ConfigureDisplay($hash, $aVal); } elsif ($aName =~ /pulsesPer/ || $aName =~ /[Ff]lowUnitTime/) { my $now = gettimeofday(); RemoveInternalTimer ("delayedcdisp:$name"); InternalTimer($now, "ArduCounter::DelayedConfigureDisplay", "delayedcdisp:$name", 0); } elsif ($aName =~ /^verboseReadings([DA]?(\d+))/) { my $arg = $1; if (!$hash->{Initialized}) { # no hello received yet return; # accept value for now. } my ($pin, $pinName) = ParsePin($hash, $arg); return "illegal pin $arg" if (!defined($pin)); # parsePin logs error if wrong pin spec if ($aVal eq "0") { UserReadingsDelete($hash, "lastMsg", $pin); UserReadingsDelete($hash, "pinHistory", $pin); } elsif ($aVal eq "-1") { readingsDelete($hash, 'pin' . $pin); readingsDelete($hash, 'pin' . $pinName); readingsDelete($hash, 'long' . $pin); readingsDelete($hash, 'long' . $pinName); readingsDelete($hash, 'countDiff' . $pin); readingsDelete($hash, 'countDiff' . $pinName); readingsDelete($hash, 'timeDiff' . $pin); readingsDelete($hash, 'timeDiff' . $pinName); readingsDelete($hash, 'reject' . $pin); readingsDelete($hash, 'reject' . $pinName); readingsDelete($hash, 'interpolatedLong' . $pin); readingsDelete($hash, 'interpolatedLong' . $pinName); } } ManageUserAttr($hash, $aName); } elsif ($cmd eq "del") { if ($aName =~ 'pin(.*)') { my $arg = $1; if (!$hash->{Initialized}) { # no hello received yet return; # accept value for now. } my ($pin, $pinName) = ParsePin($hash, $arg); if (defined($pin)) { DoWrite( $hash, "${pin}d"); } } elsif ($aName eq 'disable') { Log3 $name, 3, "$name: disable attribute removed"; DoOpen($hash) if ($init_done); # if fhem is initialized } } return; } ######################################################################### # flash a device via serial or OTA with external commands sub DoFlash { my ($hash, @args) = @_; my $name = $hash->{NAME}; my $log = ""; my @deviceName = split(/@/, $hash->{DeviceName}); my $port = $deviceName[0]; my $firmwareFolder = "./FHEM/firmware/"; my $logFile = AttrVal("global", "logdir", "./log") . "/ArduCounterFlash.log"; my $ip; if ($port =~ /(\d+\.\d+\.\d+\.\d+):(\d+)/) { $ip = $1; $port = $1; } my $hexFile = shift @args; my $netPort = 0; my $flashCommand = AttrVal($name, "flashCommand", ""); if ($hash->{Board} =~ /ESP8266/ ) { $hexFile = "ArduCounter-ESP8266.bin" if (!$hexFile); $netPort = 8266; if (!$flashCommand ) { if ($hash->{TCP}) { $flashCommand = 'espota.py -i[IP] -p [NETPORT] -f [BINFILE] >[LOGFILE] 2>&1'; } else { $flashCommand = 'esptool.py --chip esp8266 --port [PORT] --baud 115200 write_flash 0x0 [BINFILE] >[LOGFILE] 2>&1'; } } } elsif ($hash->{Board} =~ /ESP32/ || $hash->{Board} =~ /T-Display/ ) { $netPort = 3232; if ($hash->{Board} =~ /T-Display/ ) { $hexFile = "ArduCounter-ESP32T.bin" if (!$hexFile); } else { $hexFile = "ArduCounter-ESP32.bin" if (!$hexFile); } if (!$flashCommand ) { if ($hash->{TCP}) { $flashCommand = 'espota.py -i[IP] -p [NETPORT] -f [BINFILE] 2>[LOGFILE]'; # https://github.com/esp8266/Arduino/blob/master/tools/espota.py } else { $flashCommand = 'esptool.py --chip esp32 --port [PORT] --baud 460800 --before default_reset --after hard_reset write_flash -z ' . '--flash_mode dio --flash_freq 40m --flash_size detect ' . '0x1000 FHEM/firmware/ArduCounter-ESP32-bootloader_dio_40m.bin ' . '0x8000 FHEM/firmware/ArduCounter-ESP32-partitions.bin ' . '0xe000 FHEM/firmware/ArduCounter-ESP32-boot_app0.bin ' . '0x10000 [BINFILE] >[LOGFILE] 2>&1'; # to install do apt-get install python3, python3-pip # and then pip3 install esptool } } } elsif ($hash->{Board} =~ /NANO/ ) { $hexFile = "ArduCounter-NANO.hex" if (!$hexFile); $flashCommand = 'avrdude -p atmega328P -b 57600 -c arduino -P [PORT] -D -U flash:w:[HEXFILE] 2>[LOGFILE]' if (!$flashCommand); } elsif ($hash->{Board} =~ /UNO/ ) { $hexFile = "ArduCounter-NANO.hex" if (!$hexFile); $flashCommand = 'avrdude -p atmega328P -c arduino -P [PORT] -D -U flash:w:[HEXFILE] 2>[LOGFILE]' if (!$flashCommand); } else { if (!$hash->{Board}) { return "Flashing not possible if board type is unknown and no filename given. Try setting the board attribute (ESP8266, ESP32 or NANO)."; } else { return "Flashing $hash->{Board} not supported or board attribute wrong (should be ESP8266, ESP32 or NANO)"; } } $hexFile = $firmwareFolder . $hexFile; return "The file '$hexFile' does not exist" if(!-e $hexFile); Log3 $name, 3, "$name: Flashing device at $port with $hexFile. See $logFile for details"; $log .= "flashing device as ArduCounter for $name\n"; $log .= "firmware file: $hexFile\n"; $log .= "port: $port\n"; $log .= "log file: $logFile\n"; if($flashCommand) { if (-e $logFile) { unlink $logFile; } SetDisconnected($hash); DevIo_CloseDev($hash); $log .= "$name closed\n"; $flashCommand =~ s/\Q[PORT]\E/$port/g; $flashCommand =~ s/\Q[IP]\E/$ip/g; $flashCommand =~ s/\Q[HEXFILE]\E/$hexFile/g; $flashCommand =~ s/\Q[BINFILE]\E/$hexFile/g; $flashCommand =~ s/\Q[LOGFILE]\E/$logFile/g; $flashCommand =~ s/\Q[NETPORT]\E/$netPort/g; $log .= "command: $flashCommand\n\n"; `$flashCommand`; local $/=undef; if (-e $logFile) { open my $FILE, '<', $logFile; my $logText = <$FILE>; close $FILE; $log .= "--- flash command ---------------------------------------------------------------------------------\n"; $log .= $logText; $log .= "--- flash command ---------------------------------------------------------------------------------\n\n"; } else { $log .= "WARNING: flash command created no log file\n\n"; } delete $hash->{Initialized}; my $now = gettimeofday(); my $delay = 5; # wait 5 seconds to give device time for reboot Log3 $name, 4, "$name: DoFlash set internal timer to call open"; RemoveInternalTimer ("delayedopen:$name"); InternalTimer($now+$delay, "ArduCounter::DelayedOpen", "delayedopen:$name", 0); $log .= "$name internal timer set to call open.\n"; } else { return "Flashing not possible if flash command is not set for this board and connection"; } return $log; } ##################################################### # parse pin input and return pin number and pin name sub ParsePin { my ($hash, $arg) = @_; my $name = $hash->{NAME}; if ($arg !~ /^([DA]?)(\d+)/) { Log3 $name, 3, "$name: parseTime got invalid pin spec $arg"; return; } my $pinType = $1; my $pin = $2; $pin = InternalPinNumber($hash, $pinType.$pin) if ($pinType eq 'A'); my $pinName = PinName ($hash, $pin); # if board did send allowed pins, check here if ($hash->{allowedPins}) { # list of allowed pins received with hello my %pins = map { $_ => 1 } split (/,/, $hash->{allowedPins}); if ($init_done && %pins && !$pins{$pin}) { Log3 $name, 3, "$name: Invalid / disallowed pin in specification $arg"; return; } } return ($pin, $pinName); } ######################################################################### # clears all counter readings for a specified pin number # called from set with a pin number sub ClearPinCounters { my ($hash, $pin) = @_; DoWrite($hash, "${pin}c"); my ($err, $msg) = ReadAnswer($hash, '(cleared \d+)|(Error:)'); UserReadingsDelete($hash, 'pin', $pin); # internal device counter pinX UserReadingsDelete($hash, 'long', $pin); # long counter longX UserReadingsDelete($hash, 'interpolated', $pin); # interpolated long counter interpolatedLongX UserReadingsDelete($hash, 'calcCounter', $pin); # calculated counter calcCounterX UserReadingsDelete($hash, 'calcCounter_i', $pin); # calculated counter - ignored units calcCounterX_i UserReadingsDelete($hash, 'power', $pin); # power reading powerX UserReadingsDelete($hash, 'seq', $pin); # sequence seqX UserReadingsDelete($hash, 'reject', $pin); # rejected pulsesin last reportng period rejectX UserReadingsDelete($hash, 'timeDiff', $pin); # time difference of last reporting period timeDiffX UserReadingsDelete($hash, 'countDiff', $pin); # count difference of last reporting period countDiffX UserReadingsDelete($hash, 'lastMsg', $pin); # last message from device UserReadingsDelete($hash, "runTime", $pin); UserReadingsDelete($hash, "runTimeIgnore", $pin); UserReadingsDelete($hash, ".switchOnTime", $pin); UserReadingsDelete($hash, ".lastCheckIgnoreTime", $pin); return; } ######################################################################### # SET command sub SetFn { my @setValArr = @_; # remainder is set values my $hash = shift @setValArr; # reference to Fhem device hash my $name = shift @setValArr; # Fhem device name my $setName = shift @setValArr; # name of the set option my $setVal = join(' ', @setValArr); # set values as one string return "\"set $name\" needs at least an argument" if (!$setName); if(!defined($SetHash{$setName})) { my @cList = keys %SetHash; return "Unknown argument $setName, choose one of " . join(" ", @cList); } if ($setName eq "disable") { Log3 $name, 4, "$name: set disable called"; CommandAttr(undef, "$name disable 1"); return; } elsif ($setName eq "enable") { Log3 $name, 4, "$name: set enable called"; CommandAttr(undef, "$name disable 0"); return; } elsif ($setName eq "reconnect") { Log3 $name, 4, "$name: set reconnect called"; DevIo_CloseDev($hash); delete $hash->{OpenRetries}; DoOpen($hash); return; } elsif ($setName eq "clearLevels") { delete $hash->{analogLevels}; return; } elsif ($setName eq "clearHistory") { # remove history delete $hash->{History}; delete $hash->{HistoryPin}; delete $hash->{LastHistSeq}; delete $hash->{HistIdx}; return; } elsif ($setName eq "clearCounters") { # clear counters for a specific pin my ($pin, $pinName) = ParsePin($hash, $setVal); return "illegal pin $setVal" if (!defined($pin)); # parsePin logs error if wrong pin spec Log3 $name, 4, "$name: Set $setName $setVal called - removing all readings for pin $pinName, internal $pin"; ClearPinCounters($hash, $pin); } elsif ($setName eq "counter") { # set counters for a specific pin if ($setVal =~ /([AD]?\d+)[\s\,]+([\d\.]+)/) { my $val = $2; my ($pin, $pinName) = ParsePin($hash, $1); return "illegal pin $setVal" if (!defined($pin)); # parsePin logs error if wrong pin spec Log3 $name, 4, "$name: Set $setName $setVal called - setting calcCounter for pin $pinName"; readingsBeginUpdate($hash); UserBulkUpdate($hash, 'calcCounter', $pin, $val); readingsEndUpdate($hash,1); } else { return "wrong syntax, use set counters pin value"; } } elsif ($setName eq "flash") { return DoFlash($hash, @setValArr); } if(!IsOpen($hash)) { Log3 $name, 4, "$name: Set $setName $setVal called but device is disconnected"; return "Set called but device is disconnected"; } if (IsDisabled($name)) { Log3 $name, 4, "$name: set $setName $setVal called but device is disabled"; return; } if ($setName eq "raw") { Log3 $name, 4, "$name: set raw $setVal called"; DoWrite($hash, "$setVal"); } elsif ($setName eq "saveConfig") { Log3 $name, 4, "$name: set saveConfig called"; DoWrite($hash, "e"); } elsif ($setName eq "reset") { Log3 $name, 4, "$name: set reset called"; if (DoWrite($hash, "r")) { delete $hash->{Initialized}; } DevIo_CloseDev($hash); DoOpen($hash); return "sent (r)eset command to device - waiting for its setup message"; } elsif ($setName eq "resetWifi") { Log3 $name, 4, "$name: set resetWifi called"; DoWrite($hash, "w"); return "sent (w) command to device"; } return; } ######################################################################### # GET command sub GetFn { my @getValArr = @_; # rest is optional values my $hash = shift @getValArr; # reference to device hash my $name = shift @getValArr; # device name my $getName = shift @getValArr; # get option name my $getVal = join(' ', @getValArr); # optional value after get name return "\"get $name\" needs at least one argument" if (!$getName); if(!defined($GetHash{$getName})) { my @cList = keys %GetHash; return "Unknown argument $getName, choose one of " . join(" ", @cList); } if ($getName eq "levels") { my $msg = ""; foreach my $level (sort {$a <=> $b} keys %{$hash->{analogLevels}}) { $msg .= "$level: $hash->{analogLevels}{$level}\n"; } return "observed levels from analog input:\n$msg\n"; } if(!IsOpen($hash)) { Log3 $name, 4, "$name: Get called but device is disconnected"; return ("Get called but device is disconnected", undef); } if (IsDisabled($name)) { Log3 $name, 4, "$name: get called but device is disabled"; return; } if ($getName eq "info") { Log3 $name, 5, "$name: Sending info command to device"; DoWrite( $hash, "s"); my ($err, $msg) = ReadAnswer($hash, 'Next report in.*seconds'); return ($err ? $err : $msg); } elsif ($getName eq "history") { Log3 $name, 5, "$name: get history"; $hash->{HistIdx} = 0 if (!defined($hash->{HistIdx})); my $idx = $hash->{HistIdx}; # HistIdx points to the next slot to be overwritten my $ret = ""; my $count = 0; my $histLine; while ($count < AttrVal($name, "maxHist", 1000)) { if (defined ($hash->{History}[$idx])) { if (!$getVal || !$hash->{HistoryPin} || $hash->{HistoryPin}[$idx] eq $getVal) { $ret .= $hash->{History}[$idx] . "\n"; } } $idx++; $count++; $idx = 0 if ($idx > AttrVal($name, "maxHist", 1000)); } if (!AttrVal($name, "enableHistory", 0)) { $ret = "Make sure that enableHistory is set to 1 to get pin history data\n" . $ret; } return ($ret ? $ret : "no history data so far"); } return; } ########################################### # calculate and log drift of device time # called from parse_hello with T and B line, # from parse with new N line # and parse report with only N sub ParseTime { my ($hash, $line, $now) = @_; my $name = $hash->{NAME}; if ($line !~ /^[NT](\d+),(\d+) *(?:B(\d+),(\d+))?/) { Log3 $name, 4, "$name: probably wrong firmware version - cannot parse line $line"; return; } my $mNow = $1; my $mNowW = $2; my $mBoot = $3; my $mBootW = $4; my $deviceNowSecs = ($mNow/1000) + ((0xFFFFFFFF / 1000) * $mNowW); #Log3 $name, 5, "$name: Device Time $deviceNowSecs"; if (defined($mBoot)) { my $deviceBootSecs = ($mBoot/1000) + ((0xFFFFFFFF / 1000) * $mBootW); my $bootTime = $now - ($deviceNowSecs - $deviceBootSecs); $hash->{deviceBooted} = $bootTime; # for estimation of missed pulses up to now } if (defined ($hash->{'.DeTOff'}) && $hash->{'.LastDeT'}) { if ($deviceNowSecs >= $hash->{'.LastDeT'}) { $hash->{'.Drift2'} = ($now - $hash->{'.DeTOff'}) - $deviceNowSecs; } else { $hash->{'.DeTOff'} = $now - $deviceNowSecs; Log3 $name, 4, "$name: device did reset (now $deviceNowSecs, before $hash->{'.LastDeT'})." . " New offset is $hash->{'.DeTOff'}"; } } else { $hash->{'.DeTOff'} = $now - $deviceNowSecs; $hash->{'.Drift2'} = 0; $hash->{'.DriftStart'} = $now; Log3 $name, 5, "$name: Initialize device clock offset to $hash->{'.DeTOff'}"; } $hash->{'.LastDeT'} = $deviceNowSecs; my $drTime = ($now - $hash->{'.DriftStart'}); Log3 $name, 5, "$name: Device Time $deviceNowSecs" . ", Offset " . sprintf("%.3f", $hash->{'.DeTOff'}/1000) . ", Drift " . sprintf("%.3f", $hash->{'.Drift2'}) . "s in " . sprintf("%.3f", $drTime) . "s" . ($drTime > 0 ? ", " . sprintf("%.2f", $hash->{'.Drift2'} / $drTime * 100) . "%" : ""); return; } sub ParseAvailablePins { my ($hash, $line) = @_; my $name = $hash->{NAME}; Log3 $name, 5, "$name: Device sent available pins $line"; # now enrich $line with $rAnalogPinMap{$hash->{Board}}{$pin} if ($line && $hash->{Board}) { my $newAllowed; my $first = 1; foreach my $pin (split (/,/, $line)) { $newAllowed .= ($first ? '' : ','); # separate by , if not empty anymore $newAllowed .= $pin; if ($rAnalogPinMap{$hash->{Board}}{$pin}) { $newAllowed .= ",$rAnalogPinMap{$hash->{Board}}{$pin}"; } $first = 0; } $hash->{allowedPins} = $newAllowed; } return; } #################################################### # Hello is sent after reconnect or restart # check firmware version, set device boot time hash # set timer to configure device sub ParseHello { my ($hash, $line, $now) = @_; my $name = $hash->{NAME}; # current versions send Time and avaliable pins as separate lines, not here if ($line !~ /^ArduCounter V([\d\.]+) on ([^\ ]+) ?(.*) compiled (.*) (?:Started|Hello)(, pins ([0-9\,]+) available)? ?(T(\d+),(\d+) B(\d+),(\d+))?/) { Log3 $name, 4, "$name: probably wrong firmware version - cannot parse line $line"; return; } $hash->{VersionFirmware} = ($1 ? $1 : ''); $hash->{Board} = ($2 ? $2 : 'unknown'); $hash->{BoardDet} = ($3 ? $3 : ''); $hash->{SketchCompile} = ($4 ? $4 : 'unknown'); my $allowedPins = $6; my $dTime = $7; my $boardAttr = AttrVal($name, 'board', ''); if ($hash->{Board} && $boardAttr && ($hash->{Board} ne $boardAttr)) { Log3 $name, 5, "attribute board is set to $boardAttr and is overwriting board $hash->{Board} reported by device"; $hash->{Board} = $boardAttr; } if (!$hash->{VersionFirmware} || $hash->{VersionFirmware} < "2.36") { $hash->{VersionFirmware} .= " - not compatible with this Module version - please flash new sketch"; Log3 $name, 3, "$name: device reported outdated Arducounter Firmware ($hash->{VersionFirmware}) - please update!"; delete $hash->{Initialized}; } else { if ($hash->{VersionFirmware} < "4.00") { Log3 $name, 3, "$name: device sent hello with outdated Arducounter Firmware ($hash->{VersionFirmware}) - please update!"; } else { Log3 $name, 5, "$name: device sent hello: $line"; } $hash->{Initialized} = 1; # device has finished its boot and reported version my $cft = AttrVal($name, "configDelay", 1); # wait for device to send cfg before reconf. RemoveInternalTimer ("cmpCfg:$name"); InternalTimer($now+$cft, "ArduCounter::ConfigureDevice", "cmpCfg:$name", 0); ParseTime($hash, $dTime, $now) if ($dTime); ParseAvailablePins($hash, $allowedPins) if ($allowedPins); } delete $hash->{runningCfg}; # new config will be sent now delete $hash->{WaitForHello}; delete $hash->{OpenRetries}; # remove old history - sequences won't fit anymore for future history messages delete $hash->{History}; delete $hash->{HistoryPin}; delete $hash->{LastHistSeq}; delete $hash->{HistIdx}; RemoveInternalTimer ("hwait:$name"); # dont wait for hello reply if already sent RemoveInternalTimer ("sendHello:$name"); # Hello not needed anymore if not sent yet return; } ######################################################################################### # return the name of the reading for a passed internal name # like 'long' or 'calcCounter' and its pin Number # depending on verboseReadings and readingName attributes # called with a base name and a pin number sub UserReadingName { my ($hash, $rBaseName, $pin) = @_; my $name = $hash->{NAME}; my $pinName = PinName ($hash, $pin); my $verbose = AttrValFromList($hash, 0, "verboseReadings$pinName", "verboseReadings$pin"); if ($verbose !~ /\-?[0-9]+/) { Log3 $name, 3, "illegal setting for verboseReadings: $verbose"; $verbose = 0; } if ($rBaseName eq 'pin') { my $default = ($verbose >= 0 ? "pin$pinName" : ".pin$pinName"); # hidden if verboseReadings < 0 return AttrValFromList($hash, $default, "readingNameCount$pinName", "readingNameCount$pin"); } elsif ($rBaseName eq 'long') { my $default = ($verbose >= 0 ? "long$pinName" : ".long$pinName"); # hidden if verboseReadings < 0 return AttrValFromList($hash, $default, "readingNameLongCount$pinName", "readingNameLongCount$pin"); } elsif ($rBaseName eq 'interpolated') { my $default = ($verbose >= 0 ? "interpolatedLong$pinName" : ""); # no reading if verboseReadings < 0 return AttrValFromList($hash, $default, "readingNameInterpolatedCount$pinName", "readingNameInterpolatedCount$pin"); } elsif ($rBaseName eq 'calcCounter') { return AttrValFromList($hash, "calcCounter$pinName", "readingNameCalcCount$pinName", "readingNameCalcCount$pin"); } elsif ($rBaseName eq 'calcCounter_i') { return AttrValFromList($hash, "calcCounter$pinName" . "_i", "readingNameCalcCount$pinName" . "_i", "readingNameCalcCount$pin" . "_i"); } elsif ($rBaseName eq 'timeDiff') { return ($verbose >= 0 ? "timeDiff$pinName" : ".timeDiff$pinName"); # hidden if verboseReadings < 0 } elsif ($rBaseName eq 'countDiff') { return ($verbose >= 0 ? "countDiff$pinName" : ".countDiff$pinName"); # hidden if verboseReadings < 0 } elsif ($rBaseName eq 'reject') { return ($verbose >= 0 ? "reject$pinName" : ".reject$pinName"); # hidden if verboseReadings < 0 } elsif ($rBaseName eq 'power') { return AttrValFromList($hash, "power$pinName", "readingNamePower$pinName", "readingNamePower$pin"); } elsif ($rBaseName eq 'lastMsg') { return ($verbose > 0 ? "lastMsg$pinName" : ""); # no reading if verboseReadings < 1 } elsif ($rBaseName eq 'pinHistory') { return ($verbose > 0 ? "pinHistory$pinName" : ""); # no reading if verboseReadings < 1 } elsif ($rBaseName eq 'seq') { return ($verbose > 0 ? ".seq$pinName" : ""); # always hidden } else { return $rBaseName . $pinName; } return; } ######################################################################### # return the value of the reading # with a passed internal name and a pin number # depending on verboseReadings and readingName attributes sub UserReadingsVal { my ($name, $rBaseName, $pin, $default) = @_; my $hash = $defs{$name}; $default = 0 if (!defined($default)); my $rName = UserReadingName($hash, $rBaseName, $pin); return $default if (!$rBaseName); return ReadingsVal($name, $rName, $default); } ######################################################################### # return the value of the reading # depending on verboseReadings and readingName attributes # only called from HandleCounters with a base name and a pin number sub UserReadingsTimestamp { my ($name, $rBaseName, $pin) = @_; my $hash = $defs{$name}; my $rName = UserReadingName($hash, $rBaseName, $pin); return 0 if (!$rBaseName); return ReadingsTimestamp($name, $rName, 0); } ######################################################################### # bulk update readings # depending on verboseReadings and readingName attributes # called from functions that handle device reports # with a base name and a pin number, value and optional time sub UserBulkUpdate { my ($hash, $rBaseName, $pin, $value, $sTime) = @_; my $name = $hash->{NAME}; my $rName = UserReadingName($hash, $rBaseName, $pin); if (!$rName) { #Log3 $name, 5, "UserBulkUpdate - suppress reading $rBaseName for pin $pin"; return; } if (defined($sTime)) { my $fSdTim = FmtTime($sTime); # only time formatted for logging my $fSTime = FmtDateTime($sTime); # date time formatted for reading Log3 $name, 5, "ReadingsUpdate - readingStartTime specified: setting timestamp to $fSdTim"; my $chIdx = 0; $hash->{".updateTime"} = $sTime; $hash->{".updateTimestamp"} = $fSTime; readingsBulkUpdate($hash, $rName, $value); $hash->{CHANGETIME}[$chIdx++] = $fSTime; # Intervall start readingsEndUpdate($hash, 1); # end of special block readingsBeginUpdate($hash); # start regular update block } else { readingsBulkUpdate($hash, $rName, $value); } return; } ######################################################################### # delete readings # depending on verboseReadings and readingName attributes # called with a pin number sub UserReadingsDelete { my ($hash, $rBaseName, $pin) = @_; my $name = $hash->{NAME}; my $rName = UserReadingName($hash, $rBaseName, $pin); readingsDelete($hash, $rName); return; } ######################################################################### sub HandleCounters { my ($hash, $pin, $seq, $count, $time, $diff, $rDiff, $now, $ppu) = @_; my $name = $hash->{NAME}; my $pinName = PinName ($hash, $pin); my $lName = LogPinDesc($hash, $pin); my $pLog = "$name: pin $pinName ($lName)"; # to be used as start of log lines my $longCount = UserReadingsVal($name, 'long', $pin); # alter long count Wert my $intpCount = UserReadingsVal($name, 'interpolated', $pin); # alter interpolated count Wert my $lastCount = UserReadingsVal($name, 'pin', $pin); my $cCounter = UserReadingsVal($name, 'calcCounter', $pin); # calculated counter my $iSum = UserReadingsVal($name, 'calcCounter_i', $pin); # ignored sum my $lastSeq = UserReadingsVal($name, 'seq', $pin); my $intrCount = 0; # interpolated count to be added my $lastCountTS = UserReadingsTimestamp ($name, 'pin', $pin); # last time long count reading was set as string my $lastCountTNum = time_str2num($lastCountTS); # time as number my $fBootTim; my $deviceBooted; if ($hash->{deviceBooted} && $lastCountTS && $hash->{deviceBooted} > $lastCountTNum) { $deviceBooted = 1; # first report for this pin after a restart $fBootTim = FmtTime($hash->{deviceBooted}) ; # time device booted } # without old readings, interpolation makes no sense anyway my $countStart = $count - $rDiff; # count at start of this reported interval $countStart = 0 if ($countStart < 0); my $timeGap = ($now - $time/1000 - $lastCountTNum); # time between last report and start of currently reported interval $timeGap = 0 if ($timeGap < 0 || !$lastCountTS); my $seqGap = $seq - ($lastSeq + 1); # gap of reporting sequences if any $seqGap = 0 if (!$lastCountTS); # readings didn't exist yet if ($seqGap < 0) { # new sequence number is smaller than last $seqGap %= 256; # correct seq gap Log3 $name, 5, "$pLog sequence wrapped from $lastSeq to $seq, set seqGap to $seqGap" if (!$deviceBooted); } my $pulseGap = $countStart - $lastCount; # gap of missed pulses if any $pulseGap = 0 if (!$lastCountTS); # readings didn't exist yet if ($deviceBooted) { # first report for this pin after a restart -> do interpolation # interpolate for period between last report before boot and boot time. Log3 $name, 5, "$pLog device restarted at $fBootTim, last reported at " . FmtTime($lastCountTNum) . " " . "count changed from $lastCount to $count, sequence from $lastSeq to $seq"; $seqGap = $seq - 1; # $seq should be 1 after restart $pulseGap = $countStart; # we missed everything up to the count at start of the reported interval my $lastInterval = UserReadingsVal ($name, "timeDiff", $pin); # time diff of last interval (old reading) my $lastCDiff = UserReadingsVal ($name, "countDiff", $pin); # count diff of last interval (old reading) my $offlTime = sprintf ("%.2f", $hash->{deviceBooted} - $lastCountTNum); # estimated offline time (last report in readings until boot) if ($lastInterval && ($offlTime > 0) && ($offlTime < 12*60*60)) { # offline > 0 and < 12h my $lastRatio = $lastCDiff / $lastInterval; my $curRatio = $diff / $time; my $intRatio = 1000 * ($lastRatio + $curRatio) / 2; $intrCount = int(($offlTime * $intRatio)+0.5); Log3 $name, 3, "$pLog interpolating for $offlTime secs until boot, $intrCount estimated pulses (before $lastCDiff in $lastInterval ms, now $diff in $time ms, avg ratio $intRatio p/s)"; } else { Log3 $name, 4, "$pLog interpolation of missed pulses for pin $pinName ($lName) not possible - no valid historic data."; } } else { if ($pulseGap < 0) { # pulseGap < 0 abd not booted should not happen Log3 $name, 3, "$pLog seems to have missed $seqGap reports in $timeGap seconds. " . "Last reported sequence was $lastSeq, now $seq. " . "Device count before was $lastCount, now $count with rDiff $rDiff " . "but pulseGap is $pulseGap. this is probably wrong and should not happen. Setting pulseGap to 0." if (!$deviceBooted); $pulseGap = 0; } } Log3 $name, 3, "$pLog missed $seqGap reports in $timeGap seconds. Last reported sequence was $lastSeq, " . "now $seq. Device count before was $lastCount, now $count with rDiff $rDiff. " . "Adding $pulseGap to long count and intpolated count readings" if ($pulseGap > 0); Log3 $name, 5, "$pLog adding rDiff $rDiff to long count $longCount and interpolated count $intpCount" if ($rDiff); Log3 $name, 5, "$pLog adding interpolated $intrCount to interpolated count $intpCount" if ($intrCount); $intpCount += ($rDiff + $pulseGap + $intrCount); $longCount += ($rDiff + $pulseGap); if ($ppu) { $cCounter += ($rDiff + $pulseGap + $intrCount) / $ppu; # add to calculated counter $iSum += $intrCount / $ppu; # sum of interpolation kWh } UserBulkUpdate($hash, 'pin', $pin, $count); UserBulkUpdate($hash, 'long', $pin, $longCount); UserBulkUpdate($hash, 'interpolated', $pin, $intpCount); UserBulkUpdate($hash, 'calcCounter', $pin, $cCounter) if ($ppu); UserBulkUpdate($hash, 'calcCounter_i', $pin, $iSum) if ($ppu); UserBulkUpdate($hash, 'seq', $pin, $seq); return; } ######################################################################### sub HandleRunTime { my ($hash, $pinName, $pin, $lastPower, $power) = @_; my $name = $hash->{NAME}; my $now = int(gettimeofday()); # just work with seconds here #Log3 $name, 5, "$name: HandleRunTime: power is $power"; if ($power <= 0) { readingsDelete($hash, "runTime$pinName"); readingsDelete($hash, "runTimeIgnore$pinName"); readingsDelete($hash, ".switchOnTime$pinName"); readingsDelete($hash, ".lastCheckIgnoreTime$pinName"); return; } my $soTime = ReadingsVal($name, ".switchOnTime$pinName", 0); # start time when power was >0 for the first time since it is >0 if (!$soTime || !$lastPower) { $soTime = $now; readingsBulkUpdate($hash, ".switchOnTime$pinName", $now); # save when consumption started readingsDelete($hash, "runTime$pinName"); Log3 $name, 5, "$name: HandleRunTime: start from zero consumption - reset runtime and update .switchOnTime"; } # check if an ignore device is on so runtime is not added currently my $doIgnore = 0; my $ignoreSpec = AttrValFromList($hash, "", "runTimeIgnore$pinName", "runTimeIgnore$pin"); my @devices = devspec2array($ignoreSpec); #Log3 $name, 5, "$name: HandleRunTime: devices list is @devices"; DEVICELOOP: foreach my $d (@devices) { my $state = (ReadingsVal($d, "state", "")); #Log3 $name, 5, "$name: HandleRunTime: check $d with state $state"; if ($state =~ /1|on|open|BI/) { $doIgnore = 1; Log3 $name, 5, "$name: HandleRunTime: ignoreDevice $d is $state"; last DEVICELOOP; } } my $iTime = ReadingsVal($name, "runTimeIgnore$pinName", 0); # time to ignore accumulated if ($doIgnore) { # ignore device is on my $siTime = ReadingsVal($name, ".lastCheckIgnoreTime$pinName",0); # last time we saw ignore device on if ($siTime) { my $iAddTime = $now - $siTime; # add to ignore time Log3 $name, 5, "$name: HandleRunTime: addiere $iAddTime auf ignoreTime $iTime"; $iTime += $iAddTime; readingsBulkUpdate($hash, "runTimeIgnore$pinName", $iTime); # remember time to ignore } #Log3 $name, 5, "$name: HandleRunTime: setze .lastCheckIgnoreTime auf now"; readingsBulkUpdate($hash, ".lastCheckIgnoreTime$pinName", $now); # last time we saw ignore device on } else { Log3 $name, 5, "$name: HandleRunTime: no ignoreDevice is on, lösche .lastCheckIgnoreTime"; readingsDelete($hash, ".lastCheckIgnoreTime$pinName"); # no ignore device is on -> remove marker for last time on } my $rTime = int($now - $soTime); # time since water was switched on my $newRunTime = $rTime - $iTime; # time since switch on minus ignore time Log3 $name, 5, "$name: HandleRunTime: runTime is now: $rTime - $iTime = $newRunTime"; readingsBulkUpdate($hash, "runTime$pinName", $newRunTime); # set new runtime reading return; } ######################################################################### sub ParseReport { my ($hash, $line) = @_; my $name = $hash->{NAME}; my $now = gettimeofday(); if ($line =~ /^R(\d+) *C(\d+) *D(\d+) *[\/R](\d+) *T(\d+) *(N\d+,\d+)? *X(\d+)(?: *S(\d+))?(?: *A(\d+))?/) { # new count is beeing reported my ($pin, $count, $diff, $rDiff, $time, $dTime, $reject, $seq, $avgLen) = ($1, $2, $3, $4, $5, $6, $7, $8, $9); my $pinName = PinName($hash, $pin); my $power; # first try with pinName, then pin Number, then generic fallback for all pins my $factor = AttrValFromList($hash, 1000, "readingFactor$pinName", "readingFactor$pin", "factor"); my $ppu = AttrValFromList($hash, 0, "readingPulsesPerKWh$pinName", "readingPulsesPerKWh$pin", "pulsesPerKWh"); $ppu = AttrValFromList($hash, $ppu, "readingPulsesPerUnit$pin", "readingPulsesPerUnit$pinName", "pulsesPerUnit"); my $fut = AttrValFromList($hash, 3600, "readingFlowUnitTime$pin", "readingFlowUnitTime$pinName", "flowUnitTime"); my $doRTime = AttrValFromList($hash, 0, "runTime$pinName", "runTime$pin"); my $doSTime = AttrValFromList($hash, 0, "readingStartTime$pinName", "readingStartTime$pin"); my $lName = LogPinDesc($hash, $pin); my $pLog = "$name: pin $pinName ($lName)"; # start of log lines my $sTime = $now - $time/1000; # start of interval (~first pulse) in secs (float) my $fSdTim = FmtTime($sTime); # only time formatted for logging my $fEdTim = FmtTime($now); # end of Interval - only time formatted for logging ParseTime($hash, $dTime, $now) if (defined($dTime)); # parse device time (old firmware, now line starting with N) if (!$time || !$factor) { Log3 $name, 3, "$pLog skip line because time or factor is 0: $line"; return; } if ($ppu) { $power = ($diff/$time) * (1000 * $fut / $ppu); # new calculation with pulses or rounds per unit (kWh) } else { $power = ($diff/$time) / 1000 * $fut * $factor; # old calculation with a factor that is hard to understand } my $powerFmt = sprintf ("%.3f", $power); Log3 $name, 4, "$pLog Cnt $count " . "(diff $diff/$rDiff) in " . sprintf("%.3f", $time/1000) . "s" . " from $fSdTim until $fEdTim, seq $seq" . ((defined($reject) && $reject ne "") ? ", Rej $reject" : "") . (defined($avgLen) ? ", Avg ${avgLen}ms" : "") . (defined($ppu) ? ", PPU ${ppu}" : "") . (defined($fut) ? ", FUT ${fut}s" : "") . ", result $powerFmt"; my $lastPower = UserReadingsVal($name, 'power', $pin); # alter Power Wert readingsBeginUpdate($hash); UserBulkUpdate($hash, 'power', $pin, $powerFmt, ($doSTime ? $sTime : undef)); #Log3 $name, 5, "$pLog last power $lastPower, power $power"; HandleRunTime($hash, $pinName, $pin, $lastPower, $powerFmt) if ($doRTime); if (defined($reject) && $reject ne "") { my $rejCount = ReadingsVal($name, "reject$pinName", 0); # alter reject count Wert UserBulkUpdate($hash, 'reject', $pin, $reject + $rejCount); } UserBulkUpdate($hash, 'timeDiff', $pin, $time); # used internally for interpolation UserBulkUpdate($hash, 'countDiff', $pin, $diff); # used internally for interpolation UserBulkUpdate($hash, 'lastMsg', $pin, $line); HandleCounters($hash, $pin, $seq, $count, $time, $diff, $rDiff, $now, $ppu); readingsEndUpdate($hash, 1); if (!$hash->{Initialized}) { # device sent count but no hello after reconnect Log3 $name, 3, "$name: device is still counting"; if (!$hash->{WaitForHello}) { # if hello not already sent, send it now AskForHello("direct:$name"); } RemoveInternalTimer ("sendHello:$name"); # don't send hello again } } return; } sub HandleHistory { my ($hash, $now, $pinName, $hist) = @_; my $name = $hash->{NAME}; my @hList = split(/, /, $hist); Log3 $name, 5, "$name: HandleHistory " . ($hash->{CL} ? "client $hash->{CL}{NAME}" : "no CL"); foreach my $he (@hList) { if ($he) { if ($he =~ /(\d+)[s\,]([\d\-]+)[\/\:](\d+)\@([01])(?:\/(\d+))?(.)/) { my ($seq, $time, $len, $level, $alvl, $act) = ($1, $2, $3, $4, $5, $6); my $fTime = FmtDateTime($now + ($time/1000)); my $action =""; if ($act eq "C") {$action = "pulse counted"} elsif ($act eq "G") {$action = "gap"} elsif ($act eq "R") {$action = "short pulse reject"} elsif ($act eq "X") {$action = "gap continued after ignored spike"} elsif ($act eq "P") {$action = "pulse continued after ignored drop"} my $histLine = "Seq " . sprintf ("%6s", $seq) . ' ' . $fTime . " Pin $pinName " . sprintf ("%7s", sprintf("%.3f", $len/1000)) . " seconds at $level" . (defined($alvl) ? " (analog $alvl)" : "") . " -> $action"; Log3 $name, 5, "$name: HandleHistory $histLine ($he)"; $hash->{LastHistSeq} = $seq -1 if (!defined($hash->{LastHistSeq})); $hash->{HistIdx} = 0 if (!defined($hash->{HistIdx})); if ($seq > $hash->{LastHistSeq} || $seq < ($hash->{LastHistSeq} - 10000)) { # probably wrap $hash->{History}[$hash->{HistIdx}] = $histLine; $hash->{HistoryPin}[$hash->{HistIdx}] = $pinName; $hash->{LastHistSeq} = $seq; $hash->{HistIdx}++; } $hash->{HistIdx} = 0 if ($hash->{HistIdx} > AttrVal($name, "maxHist", 1000)); } else { Log3 $name, 5, "$name: HandleHistory - no match for $he"; } } } return; } ######################################################################### sub Parse { my ($hash) = @_; my $name = $hash->{NAME}; my $retStr = ""; my @lines = split /\n/, $hash->{buffer}; my $now = gettimeofday(); foreach my $line (@lines) { $line =~ s/[\x0A\x0D]//g; #Log3 $name, 5, "$name: Parse line: #" . $line . "#"; if ($line =~ /^ArduCounter V([\d\.]+).*(Started|Hello)/) { # setup / hello message ParseHello($hash, $line, $now); } elsif ($line =~ /^(?:A|a|alive|Alive) *(?:(?:R|RSSI) *([\-\d]+))? *$/) { # alive response my $rssi = $1; Log3 $name, 5, "$name: device sent alive response: $line" if (AttrVal($name, "logFilter", "N") =~ "N"); RemoveInternalTimer ("alive:$name"); delete $hash->{KeepAliveRetries}; readingsSingleUpdate($hash, "RSSI", $rssi, 1) if ($rssi); } elsif ($line =~ /^R([\d]+)(.*)/) { # report counters ParseReport($hash, $line); $retStr .= ($retStr ? "\n" : "") . "report for pin $1: $2"; } elsif ($line =~ /^C([0-9\,]+)/) { # available pins ParseAvailablePins($hash, $1); $retStr .= ($retStr ? "\n" : "") . "available pins: $1"; } elsif ($line =~ /^H([\d]+) (.+)/) { # pin pulse history as separate line my $pin = $1; my $hist = $2; my $pinName = PinName($hash, $pin); HandleHistory($hash, $now, $pinName, $hist); if (AttrValFromList($hash, 0, "verboseReadings$pinName", "verboseReadings$pin") eq "1") { readingsSingleUpdate($hash, "pinHistory$pinName", $hist, 1); } } elsif ($line =~ /^I(.*)/) { # interval config report after show/hello $retStr .= ($retStr ? "\n" : "") . "interval config: $1"; $hash->{runningCfg}{I} = $1; # save for later compare $hash->{runningCfg}{I} =~ s/\s+$//; # remove spaces at end Log3 $name, 4, "$name: device sent interval config $hash->{runningCfg}{I}"; } elsif ($line =~ /^U(.*)/) { # unit display config $retStr .= ($retStr ? "\n" : "") . "display unit config: $1"; $hash->{runningCfg}{U} = $1; # save for later compare $hash->{runningCfg}{U} =~ s/\s+$//; # remove spaces at end Log3 $name, 4, "$name: device sent unit display config $hash->{runningCfg}{U}"; } elsif ($line =~ /^V(.*)/) { # devVerbose $retStr .= ($retStr ? "\n" : "") . "verbose config: $1"; $hash->{runningCfg}{V} = $1; # save for later compare $hash->{runningCfg}{V} =~ s/\s+$//; # remove spaces at end Log3 $name, 4, "$name: device sent devVerbose $hash->{runningCfg}{V}"; } elsif ($line =~ /^P(\d+) *(f|falling|r|rising|-) *(p|pullup)? *(?:m|min)? *(\d+) *(?:(?:analog)? *(?:o|out|out-pin)? *(\d+) *(?:(?:t|thresholds) *(\d+) *[\/\, ] *(\d+))?)?(?:, R\d+.*)?/) { # pin configuration at device my $p = ($3 ? $3 : "nop"); $hash->{runningCfg}{$1} = $line; $retStr .= ($retStr ? "\n" : "") . "pin $1 config: $2 $p min length $4 " . ($5 ? "analog out $5 thresholds $6/$7" : ""); Log3 $name, 4, "$name: device sent config for pin $1: $line"; } elsif ($line =~ /^N(.*)/) { # device time and boot time, track drift ParseTime($hash, $line, $now); $retStr .= ($retStr ? "\n" : "") . "device time $1"; Log3 $name, 4, "$name: device sent time info: $line"; } elsif ($line =~ /conn.* busy/) { my $now = gettimeofday(); my $delay = AttrVal($name, "nextOpenDelay", 60); Log3 $name, 4, "$name: _Parse: primary tcp connection seems busy - delay next open"; SetDisconnected($hash); # set to disconnected (state), remove timers DevIo_CloseDev($hash); # close, remove from readyfnlist so _ready is not called again RemoveInternalTimer ("delayedopen:$name"); InternalTimer($now+$delay, "ArduCounter::DelayedOpen", "delayedopen:$name", 0); # todo: the level reports should be recorded separately per pin } elsif ($line =~ /^L\d+: *([\d]+) ?, ?([\d]+) ?, ?-> *([\d]+)/) { # analog level difference reported with details if ($hash->{analogLevels}{$3}) { $hash->{analogLevels}{$3}++; } else { $hash->{analogLevels}{$3} = 1; } } elsif ($line =~ /^L\d+: *([\d]+)/) { # analog level difference reported if ($hash->{analogLevels}{$1}) { $hash->{analogLevels}{$1}++; } else { $hash->{analogLevels}{$1} = 1; } } elsif ($line =~ /^Error:/) { # Error message from device $retStr .= ($retStr ? "\n" : "") . $line; Log3 $name, 3, "$name: device: $line"; } elsif ($line =~ /^M (.*)/) { # other Message from device $retStr .= ($retStr ? "\n" : "") . $1; Log3 $name, 3, "$name: device: $1"; } elsif ($line =~ /^D (.*)/) { # debug / info Message from device $retStr .= ($retStr ? "\n" : "") . $1; Log3 $name, 4, "$name: device: $1"; } elsif ($line =~ /^[\s\n]*$/) { # blank line - ignore } else { Log3 $name, 3, "$name: unparseable message from device: $line"; } } $hash->{buffer} = ""; return $retStr; } ######################################################################### # called from the global loop, when the select for hash->{FD} reports data sub ReadFn { my $hash = shift; my $name = $hash->{NAME}; my ($pin, $count, $diff, $power, $time, $reject, $msg); my $buf; if ($hash->{DeviceName} eq 'none') { # simulate receiving if ($hash->{TestInput}) { $buf = $hash->{TestInput}; delete $hash->{TestInput}; } } else { # read from serial device $buf = DevIo_SimpleRead($hash); return if (!defined($buf) ); } $hash->{buffer} .= $buf; my $end = chop $buf; #Log3 $name, 5, "$name: Read: current buffer content: " . $hash->{buffer}; # did we already get a full frame? return if ($end ne "\n"); Parse($hash); return; } ##################################### # Called from get / set to get a direct answer # called with logical device hash sub ReadAnswer { my ($hash, $expect) = @_; my $name = $hash->{NAME}; my $rin = ''; my $msgBuf = ''; my $to = AttrVal($name, "timeout", 2); my $buf; Log3 $name, 5, "$name: ReadAnswer called"; for(;;) { if ($hash->{DeviceName} eq 'none') { # simulate receiving $buf = $hash->{TestInput}; delete $hash->{TestInput}; } elsif($^O =~ m/Win/ && $hash->{USBDev}) { $hash->{USBDev}->read_const_time($to*1000); # set timeout (ms) $buf = $hash->{USBDev}->read(999); if(length($buf) == 0) { Log3 $name, 3, "$name: Timeout in ReadAnswer"; return ("Timeout reading answer", undef) } } else { if(!$hash->{FD}) { Log3 $name, 3, "$name: Device lost in ReadAnswer"; return ("Device lost when reading answer", undef); } vec($rin, $hash->{FD}, 1) = 1; # setze entsprechendes Bit in rin my $nfound = select($rin, undef, undef, $to); if($nfound < 0) { next if ($! == EAGAIN() || $! == EINTR() || $! == 0); my $err = $!; SetDisconnected($hash); # set to disconnected, remove timers, let _ready try to reopen Log3 $name, 3, "$name: ReadAnswer error: $err"; return("ReadAnswer error: $err", undef); } if($nfound == 0) { Log3 $name, 3, "$name: Timeout2 in ReadAnswer"; return ("Timeout reading answer", undef); } $buf = DevIo_SimpleRead($hash); if(!defined($buf)) { Log3 $name, 3, "$name: ReadAnswer got no data"; return ("No data", undef); } } if($buf) { #Log3 $name, 5, "$name: ReadAnswer got: $buf"; $hash->{buffer} .= $buf; } my $end = chop $buf; #Log3 $name, 5, "$name: Current buffer content: " . $hash->{buffer}; next if ($end ne "\n"); $msgBuf .= "\n" if ($msgBuf); $msgBuf .= Parse($hash); #Log3 $name, 5, "$name: ReadAnswer msgBuf: " . $msgBuf; if ($msgBuf =~ $expect) { Log3 $name, 5, "$name: ReadAnswer matched $expect"; return (undef, $msgBuf); } } return ("no Data", undef); } 1; =pod =item device =item summary Module for energy / water meters on arduino / ESP8266 / ESP32 =item summary_DE Modul für Strom / Wasserzähler mit Arduino, ESP8266 oder ESP32 =begin html

ArduCounter

=end html =cut