diff --git a/fhem/contrib/APsystemsEZ1/74_APsystemsEZ1.pm b/fhem/contrib/APsystemsEZ1/74_APsystemsEZ1.pm new file mode 100644 index 000000000..7c228d0de --- /dev/null +++ b/fhem/contrib/APsystemsEZ1/74_APsystemsEZ1.pm @@ -0,0 +1,671 @@ +############################################################################### +# +# $Id$ +# +# This script 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 +# 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 FHEM::APsystemsEZ1; +my $cvsid = '$Id$'; +use strict; +use warnings; +use POSIX; +use GPUtils qw(:all); +use Time::HiRes qw(gettimeofday); +use Time::Local; +my $EMPTY = q{}; +my $missingModul = $EMPTY; +## no critic (ProhibitConditionalUseStatements) +eval { use Readonly; 1 } or $missingModul .= 'Readonly '; + +Readonly my $APIPORT => '8050'; +Readonly my $SPACE => q{ }; +Readonly $EMPTY => q{}; +Readonly my $MAIN_INTERVAL => 30; +Readonly my $LONG_INTERVAL => 600; +Readonly my $YEARSEC => 31536000; + +eval { use JSON; 1 } or $missingModul .= 'JSON '; +## use critic +require HttpUtils; + +BEGIN { + GP_Import( + qw( + AttrVal + CommandAttr + CommandDeleteReading + FmtDateTime + FW_ME + SVG_FwFn + getKeyValue + InternalTimer + InternalVal + IsDisabled + Log3 + Log + attr + defs + devspec2array + deviceEvents + init_done + minNum + maxNum + modules + readingFnAttributes + readingsBeginUpdate + readingsBulkUpdate + readingsBulkUpdateIfChanged + readingsDelete + readingsEndUpdate + ReadingsNum + readingsSingleUpdate + ReadingsVal + RemoveInternalTimer + setKeyValue + setNotifyDev + ) + ); +} + +GP_Export( + qw( + Initialize + ) +); + +############################################################## +sub Initialize() { + my ($hash) = @_; + + $hash->{DefFn} = \&Define; + $hash->{UndefFn} = \&Undefine; + $hash->{DeleteFn} = \&Delete; + $hash->{SetFn} = \&Set; + $hash->{FW_detailFn} = \&FW_detailFn; + $hash->{AttrFn} = \&Attr; + $hash->{AttrList} = + 'activeArea ' . + 'disable:1,0 ' . + 'disabledForIntervals ' . + 'SVG_PlotsToShow:textField-long ' . + $::readingFnAttributes; + + return; +} + +######################### +sub Define{ + my ( $hash, $def ) = @_; + my @val = split( "[ \t]+", $def ); + my $name = $val[0]; + my $type = $val[1]; + my $iam = "$type $name Define:"; + my $ip = ''; + my $tod = gettimeofday(); + + return "$iam Cannot define $type device. Perl modul $missingModul is missing." if ( $missingModul ); + + return "$iam too few parameters: define $type " if( @val < 3 ); + + $ip = $val[2]; + + %$hash = (%$hash, + helper => { + cmds => { + getOutputData => { + lasttime => 0, + interval => $MAIN_INTERVAL * 10, + readings => { + p1 => 'line1_currentPower', + e1 => 'line1_todayEnergy', + te1 => 'line1_lifeTimeEnergy', + p2 => 'line2_currentPower', + e2 => 'line2_todayEnergy', + te2 => 'line2_lifeTimeEnergy' + }, + }, + getDeviceInfo => { + lasttime => 0, + interval => $YEARSEC, + readings => { + deviceId => 'inverter_Id', + devVer => 'inverter_Version', + ssid => 'inverter_SSID', + ipAddr => 'inverter_IpAddress', + minPower => 'inverter_MinPower', + maxPower => 'inverter_MaxPower' + }, + }, + getMaxPower => { + lasttime => 0, + interval => $YEARSEC, + readings => { + maxPower => 'inverter_ActiveMaxPower' + }, + }, + setMaxPower => { + lasttime => 0, + interval => $YEARSEC, + quantity => 'p', + readings => { + maxPower => 'inverter_ActiveMaxPower' + }, + }, + getAlarm => { + lasttime => 0, + interval => $YEARSEC, + readings => { + og => 'alarm_OffGrid', + isce1 => 'alarm1_DCShortCircuitError', + isce2 => 'alarm2_DCShortCircuitError', + oe => 'alarm_OutputError' + }, + }, + getOnOff => { + lasttime => 0, + interval => $LONG_INTERVAL, + readings => { + status => 'inverter_Status' + }, + }, + setOnOff => { + lasttime => 0, + interval => $YEARSEC, + quantity => 'status', + readings => { + status => 'inverter_Status' + }, + }, + }, + timeout_ => 2, + retry_count => 0, + retry_max => 6, + call_delay => 3, + callStack => [ + 'getOutputData', + 'getDeviceInfo', + 'getMaxPower', + 'getAlarm', + 'getOnOff' + ], + url => "http://${ip}:${APIPORT}/", + inverter => { + start => $tod, + stop => $tod, + duration => 0 + } + } + ); + +# $attr{$name}{disabledForIntervals} = '{sunset_abs("HORIZON=0")}-24 00-{sunrise_abs("HORIZON=0")}' if( !defined( $attr{$name}{disabledForIntervals} ) ); + $attr{$name}{stateFormat} = 'Power' if( !defined( $attr{$name}{stateFormat} ) ); + $attr{$name}{room} = 'APsystemsEZ1' if( !defined( $attr{$name}{room} ) ); + $attr{$name}{icon} = 'inverter' if( !defined( $attr{$name}{icon} ) ); + ( $hash->{VERSION} ) = $cvsid =~ /\.pm (.*)Z/; + + readingsSingleUpdate( $hash, '.associatedWith', $attr{$name}{SVG_PlotsToShow}, 0 ) if ( defined $attr{$name}{SVG_PlotsToShow} ); + + RemoveInternalTimer($hash); + InternalTimer( gettimeofday() + 2, \&callAPI, $hash, 1); + + readingsSingleUpdate( $hash, 'state', 'defined', 1 ); + + return; + +} + +######################### +sub FW_detailFn { ## no critic (ProhibitExcessComplexity [complexity core maintenance]) + my ($FW_wname, $name, $room, $pageHash) = @_; # pageHash is set for summaryFn. + my $hash = $defs{$name}; + my $type = $hash->{TYPE}; + my $iam = "$type $name FW_detailFn:"; + return if ( !AttrVal( $name, 'SVG_PlotsToShow', 0 ) || AttrVal( $name, 'disable', 0 ) || !$init_done || !$FW_ME ); + + my @plots = split( " ", AttrVal( $name, 'SVG_PlotsToShow', 0 ) ); + my $ret = "
"; + + for my $plot ( @plots ) { + + $ret .= ''; + + } + + $ret .= '
' . SVG_FwFn( $FW_wname, $plot, "", {} ) . '
'; + return $ret; + +} + +######################### +sub callAPI { + my ( $hash, $update ) = @_; + my $name = $hash->{NAME}; + my $type = $hash->{TYPE}; + my $iam = "$type $name callAPI:"; + my @states = ('undefined', 'disabled', 'temporarily disabled', 'inactive' ); + my $tod = gettimeofday(); + + if( IsDisabled( $name ) ) { + + readingsSingleUpdate( $hash, 'state', $states[ IsDisabled( $name ) ], 1 ) if ( ReadingsVal( $name, 'state', '' ) !~ /disabled|inactive/ ); + RemoveInternalTimer( $hash ); + InternalTimer( $tod + $MAIN_INTERVAL, \&callAPI, $hash, 0 ); + return; + + } + + if ( scalar @{ $hash->{helper}{callStack} } == 0 ) { + + my @cmds = qw( getDeviceInfo getMaxPower getAlarm getOnOff getOutputData ); + + for ( @cmds ) { + + push @{ $hash->{helper}{callStack} }, $_ if ( ( $hash->{helper}{cmds}{$_}{lasttime} + $hash->{helper}{cmds}{$_}{interval} ) < $tod ); + + } + + if ( scalar @{ $hash->{helper}{callStack} } == 0 ) { + + RemoveInternalTimer( $hash, \&callAPI ); + InternalTimer( gettimeofday() + $MAIN_INTERVAL, \&callAPI, $hash, 0 ); + return; + + } + + } + + if ( !$update && $::init_done ) { + + readingsSingleUpdate( $hash, 'state', 'initialized', 1 ) if ( $hash->{READINGS}{state}{VAL} != /initialized|connected/ ); + my $url = $hash->{helper}{url}; + my $timeout = $hash->{helper}{timeout_api}; + my $command = $hash->{helper}{callStack}[0]; +# Log3 $name, 1, join(" | ", @{ $hash->{helper}{callStack} } ) . "\n$url$command"; + + ::HttpUtils_NonblockingGet( { + url => $url . $command, + timeout => $timeout, + hash => $hash, + method => 'GET', + callback => \&APIresponse, + t_begin => $tod + } ); + + } else { + + RemoveInternalTimer( $hash, \&callAPI ); + InternalTimer( gettimeofday() + $MAIN_INTERVAL, \&callAPI, $hash, 0 ); + + } + + return; + +} + +######################### +sub APIresponse { + my ($param, $err, $data) = @_; + my $hash = $param->{hash}; + my $name = $hash->{NAME}; + my $type = $hash->{TYPE}; + my $statuscode = $param->{code} // ''; + my $call_delay = $hash->{helper}{call_delay}; + my $iam = "$type $name APIresponse:"; + my $tod = gettimeofday(); + my $duration = sprintf( "%.2f", ( $tod - $param->{t_begin} ) ); + + Log3 $name, 4, "$iam response time ". $duration . ' s'; + Log3 $name, 4, "$iam \$statuscode >$statuscode< \$err >$err< \$param->url $param->{url}\n\$data >$data<\n"; + + if ( !$err && $statuscode == 200 && $data ) { + + my $result = eval { decode_json( $data ) }; + if ($@) { + + Log3 $name, 2, "$iam JSON error [ $@ ]"; + readingsSingleUpdate( $hash, 'state', 'error JSON', 1 ); + + } else { + + if ( $hash->{READINGS}{state}{VAL} ne 'connected' ) { + + $hash->{helper}{inverter}{start} = $tod; + readingsSingleUpdate( $hash, 'state', "connected", 1 ); + + } + + $hash->{helper}{inverter}{duration} = $tod - $hash->{helper}{inverter}{start}; + + my $cmd = $hash->{helper}{callStack}[0]; + $cmd = $1 if ( $cmd =~ /(.*)\?/ ); + $hash->{helper}->{response}{$cmd} = $result; + + if ( $result->{message} eq 'SUCCESS' ) { + + $hash->{helper}{cmds}{$cmd}{lasttime} = $tod; + + readingsBeginUpdate($hash); + + for my $ky ( keys %{ $result->{data} } ) { + + if ( "$cmd$ky" =~ /getOutputDatap(1|2)|MaxPower/ ) { + + readingsBulkUpdateIfChanged( $hash, $hash->{helper}{cmds}{$cmd}{readings}{$ky}, $result->{data}{$ky} ); + + } elsif ( "$cmd" =~ /OnOff/ ) { + + readingsBulkUpdateIfChanged( $hash, $hash->{helper}{cmds}{$cmd}{readings}{$ky}, ( $result->{data}{$ky} ? 'off' : 'on' ) ); + + } elsif ( "$cmd" =~ /Alarm/ ) { + + readingsBulkUpdateIfChanged( $hash, $hash->{helper}{cmds}{$cmd}{readings}{$ky}, ( $result->{data}{$ky} ? 'alarm' : 'normal' ) ); + + } elsif ( "$cmd$ky" =~ /getOutputDatae(1|2)/ ) { + + readingsBulkUpdateIfChanged( $hash, $hash->{helper}{cmds}{$cmd}{readings}{$ky}, sprintf( "%.1f", $result->{data}{$ky} ) ); + + } elsif ( "$cmd$ky" =~ /getOutputDatate(1|2)/ ) { + + readingsBulkUpdate( $hash, $hash->{helper}{cmds}{$cmd}{readings}{$ky}, sprintf( "%.1f", $result->{data}{$ky} ), 0 ); + + } else { + + readingsBulkUpdate( $hash, $hash->{helper}{cmds}{$cmd}{readings}{$ky}, $result->{data}{$ky}, 0 ); + + } + + } + + if ( $hash->{helper}{callStack}[0] eq 'getOutputData' ) { + + readingsBulkUpdate( $hash, 'inverter_OnlineTime', int( $hash->{helper}{inverter}{duration} /3600 ) , 0 ); + readingsBulkUpdateIfChanged( $hash, 'Power', $result->{data}{p1} + $result->{data}{p2} ); + readingsBulkUpdateIfChanged( $hash, 'Energy', sprintf( "%.1f", $result->{data}{e1} + $result->{data}{e2} ) ); + readingsBulkUpdateIfChanged( $hash, 'LifeTimeEnergy', sprintf( "%.1f", $result->{data}{te1} + $result->{data}{te2} ) ); + readingsBulkUpdate( $hash, 'PowerDensity', int( ( $result->{data}{p1} + $result->{data}{p2} ) / AttrVal( $name, 'activeArea', 4.10592 ) ), 0 ) if ( AttrVal( $name, 'activeArea', 0 ) ); + + } + + readingsEndUpdate($hash, 1); + + shift @{ $hash->{helper}{callStack} }; + + if ( scalar @{ $hash->{helper}{callStack} } ) { + + RemoveInternalTimer( $hash, \&calAPI ); + InternalTimer( $tod + $call_delay, \&callAPI, $hash, 0 ); + return; + + } + + RemoveInternalTimer( $hash, \&calAPI ); + InternalTimer( $tod + $MAIN_INTERVAL, \&callAPI, $hash, 0 ); + return; + + } + + $hash->{helper}{retry_count}++; + + if ( $hash->{helper}{retry_count} > $hash->{helper}{retry_max} - 1 ) { + + shift @{ $hash->{helper}{callStack} }; + $hash->{helper}{retry_count} = 0; + + if ( scalar @{ $hash->{helper}{callStack} } ) { + + RemoveInternalTimer( $hash, \&calAPI ); + InternalTimer( $tod + $call_delay, \&callAPI, $hash, 0 ); + return; + + } + + RemoveInternalTimer( $hash, \&calAPI ); + InternalTimer( $tod + ( $result->{data}{p1} + $result->{data}{p2} < 1 ? $LONG_INTERVAL : $MAIN_INTERVAL ), \&callAPI, $hash, 0 ); + return; + + } + + } + + } elsif ( !$statuscode && !$data && $err =~ /\(113\)$|timed out/ ) { + + if ( $hash->{READINGS}{state}{VAL} ne 'disconnected' ) { + + $hash->{helper}{inverter}{stop} = $tod; + $hash->{helper}{inverter}{duration} = $tod - $hash->{helper}{inverter}{start}; + readingsSingleUpdate( $hash, 'state', "disconnected", 1 ); + + } + + RemoveInternalTimer( $hash, \&callAPI ); + InternalTimer( $tod + $LONG_INTERVAL, \&callAPI, $hash, 0 ); + return; + + } + + readingsSingleUpdate( $hash, 'state', "error", 1 ); + Log3 $name, 1, "$iam \$statuscode >$statuscode< \$err >$err< \$param->url $param->{url}\n\$data >$data<\n"; + + $hash->{helper}{retry_count}++; + + if ( $hash->{helper}{retry_count} > $hash->{helper}{retry_max} ) { + + CommandAttr( $hash, "$name disable 1" ); + $hash->{helper}{retry_count} = 0; + + } + + RemoveInternalTimer( $hash, \&callAPI ); + InternalTimer( $tod + $MAIN_INTERVAL, \&callAPI, $hash, 0 ); + my $txt = AttrVal( $name, 'disable', $EMPTY ) ? "$iam: Device is disabled now." : "$iam failed, retry in $MAIN_INTERVAL seconds."; + Log3 $name, 1, $txt; + return; + +} + +######################### +sub Set { + my ($hash,@val) = @_; + my $type = $hash->{TYPE}; + my $name = $hash->{NAME}; + my $iam = "$type $name Set:"; + + return "$iam: needs at least one argument" if ( @val < 2 ); + return "Unknown argument, $iam is disabled, choose one of none:noArg" if ( IsDisabled( $name ) ); + + my ($pname,$setName,$setVal,$setVal2,$setVal3) = @val; + + Log3 $name, 4, "$iam called with $setName"; + + my $minpow = ReadingsNum( $name, 'minPower', 30 ); + my $maxpow = ReadingsNum( $name, 'maxPower', 800 ); + $setVal = 0 if ( defined( $setVal ) && $setVal eq 'on' ); + $setVal = 1 if ( defined( $setVal ) && $setVal eq 'off' ); + + if ( $setName eq 'setOnOff' && ( $setVal == 0 || $setVal == 1 ) || $setName eq 'setMaxPower' && $setVal >= $minpow && $setVal <= $maxpow ) { + + my $cmd = $setName . '?' . $hash->{helper}{cmds}{$setName}{quantity} . '=' . $setVal; + unshift @{ $hash->{helper}{callStack} }, $cmd; +# Log3 $name, 1, "$iam called with $cmd | ".join(" | ", @{ $hash->{helper}{callStack} } ); + return; + + } elsif ( $setName eq 'getUpdate') { + + my @cmds = qw( getDeviceInfo getMaxPower getAlarm getOnOff getOutputData ); + push @{ $hash->{helper}{callStack} }, @cmds; + return; + + } + my $ret = ' setMaxPower:selectnumbers,' . $minpow . ',10,' . $maxpow . ',0,lin setOnOff:on,off getUpdate:noArg '; + return "Unknown argument $setName, choose one of".$ret; + +} + +######################### +sub Undefine { + + my ( $hash, $arg ) = @_; + my $name = $hash->{NAME}; + my $type = $hash->{TYPE}; + + RemoveInternalTimer( $hash ); + readingsSingleUpdate( $hash, 'state', 'undefined', 1 ); + return; +} + +########################## +sub Delete { + + my ( $hash, $arg ) = @_; + my $name = $hash->{NAME}; + my $type = $hash->{TYPE}; + my $iam ="$type $name Delete: "; + Log3( $name, 5, "$iam called" ); + + return; +} + +########################## +sub Attr { + + my ( $cmd, $name, $attrName, $attrVal ) = @_; + my $hash = $defs{$name}; + my $type = $hash->{TYPE}; + my $iam = "$type $name Attr:"; + ########## + if ( $attrName eq 'disable' ) { + + if( $cmd eq "set" and $attrVal eq "1" ) { + + readingsSingleUpdate( $hash,'state','disabled',1); + Log3 $name, 3, "$iam $cmd $attrName disabled"; + + } elsif( $cmd eq "del" or $cmd eq 'set' and !$attrVal ) { + + RemoveInternalTimer( $hash, \&callAPI); + InternalTimer( gettimeofday() + 1, \&callAPI, $hash, 0 ); + Log3 $name, 3, "$iam $cmd $attrName enabled"; + + } + + return; + + ########## + } elsif ( $attrName eq 'SVG_PlotsToShow' ) { + + readingsSingleUpdate( $hash, '.associatedWith', $attrVal, 0 ) if ( $cmd eq 'set' && $attrVal ); + delete $hash->{READINGS}{'.associatedWith'} if ( $cmd eq 'del' ); + return; + + } + + return; + +} +############################################################## + + +1; + +__END__ + +=pod + +=item helper +=item summary Serve the APsystems EZ1 inverter API +=item summary_DE Bedient die API des APsystems EZ1 Wechselrichters + +=begin html + + +

APsystemsEZ1

+ + +=end html diff --git a/fhem/contrib/APsystemsEZ1/readme.txt b/fhem/contrib/APsystemsEZ1/readme.txt new file mode 100644 index 000000000..d6203f5ed --- /dev/null +++ b/fhem/contrib/APsystemsEZ1/readme.txt @@ -0,0 +1,2 @@ +Das Modul kann über die FHEM Befehlszeile geladen werden mit: +{ Svn_GetFile('contrib/APsystemsEZ1/74_APsystemsEZ1.pm', 'FHEM/74_APsystemsEZ1.pm') }