neues Modul (contribute) 74_APsystemsEZ1.pm für den Wechselrichter APsystems EZ1

Begonnen von Ellert, 05 Mai 2025, 18:17:02

Vorheriges Thema - Nächstes Thema

Ellert

Es gibt ein Modul für den Wechselrichter EZ1 von APsystems im SVN unter contrib/APsystemsEZ1/74_APsystemsEZ1.pm

Das Modul kann über die FHEM Befehlszeile mit { Svn_GetFile('contrib/APsystemsEZ1/74_APsystemsEZ1.pm', 'FHEM/74_APsystemsEZ1.pm') } geladen werden.

Danach muss mit reload 74_APsystemsEZ1.pm das Modul geladen werden, bevor ein Gerät definiert werden kann.

define <name> APsystemsEZ1 <IP des Wechselrichters>
Der Wechselrichter muss mit der EasyPower App in den lokalen Modus geschaltet werden.

87insane

Danke, bisher hatte ich das über httpmod laufen.

Energy ggf. umbenennen zu TodayEnergy? Das wäre es gleich zu LifeTimeEnergy
Hinzu kann ich kein eigenes devStateIcon einfügen. Man sieht es nur in der FHEM Raumübersicht aber nicht innerhalb des Gerätes. Ist da irgendeine Art Sperre drin?

Danke und Gruß,
Kai

Ellert

Da Modul hat eine eigene Detailfunktion mit der man SVG Plots anzeigen kann, die blendet wohl das Icon aus.
Das Reading werde ich gelegentlich umbenenen.

Aekschn

Hallo Ellert,

vielen Dank das du dich den APsystems Wechselrichtern angenommen hast. Wäre es mit vertretbarem Aufand möglich den EZHI

(https://file.apsystemsema.com:8083/apsystems/apeasypower/resource/APsystems%20EZHI%20Local%20API%20User%20Manual.pdf)
 
in das Modul zu integrieren?

Die APIs sind sich ähnlich aber nicht gleich.

Vielen Dank vorab!
Florian

Ellert

Zitat von: Aekschn am 09 Juli 2025, 14:24:30Wäre es mit vertretbarem Aufand möglich den EZHI

Ich habe keine Ahnung, wie groß der Aufwand für Dich ist. Aber Du könntest das Modul als Grundlage nehmen und es anpassen.

Aekschn

Hi,

ich habe ich mal daran gemacht komme aber nicht weiter und weiß nicht so recht wo es hängt. Das Modul geht auf Error und im Log steht nichts was weiter hilft.Könntest du mal drüber gucken ob dir was ins Auge sticht?

Danke vorab!


package FHEM::EZHI;
my $cvsid = '$Id: 74_EZHI.pm 29928 2025-07-10 08:22:04Z $';
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        => '80';
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
          FW_ME
          SVG_FwFn
          getKeyValue
          InternalTimer
          InternalVal
          IsDisabled
          Log3
          Log
          attr
          defs
          init_done
          readingFnAttributes
          readingsBeginUpdate
          readingsBulkUpdate
          readingsBulkUpdateIfChanged
          readingsDelete
          readingsEndUpdate
          ReadingsNum
          readingsSingleUpdate
          ReadingsVal
          RemoveInternalTimer
          )
    );
}

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}     =
                          '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 <NAME> $type <email>" if( @val < 3 );

  $ip = $val[2];

  %$hash = (%$hash,
    helper => {
      cmds                      => {
        getOutputData           => {
          lasttime              => 0,
          interval              => $MAIN_INTERVAL * 10,
          readings              => {
            batS                => 'battery_Status',
            batSoc              => 'battery_state_of_charge',
            pvP                 => 'pv_input_power',
            batP                => 'battery_power',
            ogP                 => 'on-grid_power',
            ofgP                => 'off-grid_power'
          },
        },
        getDeviceInfo           => {
          lasttime              => 0,
          interval              => $YEARSEC,
          readings              => {
            deviceId            => 'inverter_Id',
            type                => 'inverter_type',
            ssid                => 'inverter_SSID',
            ip                  => 'inverter_IpAddress'           
          },
        },
        getPower             => {
          lasttime              => 0,
          interval              => $YEARSEC,
          readings              => {
            Power               => 'on-grid_power-setting'
          },
        },
        setPower             => {
          lasttime              => 0,
          interval              => $YEARSEC,
          quantity              => 'p',
          readings              => {
            Power               => 'on-grid_power-setting'
          },
      },
  },
      timeout_                  => 2,
      retry_count               => 0,
      retry_max                 => 6,
      call_delay                => 3,
      callStack                 => [
                                  'getOutputData',
                                  'getDeviceInfo',
                                  'getPower'
        ],
      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} = 'APsystemsEZHI' 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 = "<div id='${type}_${name}_plots' ><table>";

  for my $plot ( @plots ) {

    $ret .= '<tr><td>' . SVG_FwFn( $FW_wname, $plot, "", {} ) . '</td></tr>';

  }

  $ret .= '</table></div>';
  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 getPower 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)|Power/ ) {

              readingsBulkUpdateIfChanged( $hash, $hash->{helper}{cmds}{$cmd}{readings}{$ky}, $result->{data}{$ky} );

            } 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}{batS} < 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', -1200 );
  my $maxpow = ReadingsNum( $name, 'maxPower', 1200 );
  $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 'setPower' && $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 getPower getOutputData );
    push @{ $hash->{helper}{callStack} }, @cmds;
    return;

  }
  my $ret = ' setPower: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;

  ##########
  } elsif ( $attrName eq 'intervalMultiplier' ) {

    if( $cmd eq 'set' ) {

      my @ivals = split( /\R/, $attrVal );

      for my $ival (@ivals) {

        if ( my ($cm, $val) = $ival =~ /^(getDeviceInfo|getPower|getOutputData)=(\d+)$/ ) {

          $hash->{helper}{cmds}{$cm}{interval} = $MAIN_INTERVAL * $val;

        } else {

          Log3 $name, 1, "$iam $cmd $attrName wrong syntax for $ival, default is used.";

        }

      }

    } elsif( $cmd eq 'del' ) {

      my @ivals = qw( getDeviceInfo=1051200 getPower=1051200 getOutputData=10 );

    for my $ival ( @ivals ) {

        if ( my ($cm, $val) = $ival =~ /^(getDeviceInfo|getPower|getOutputData)=(\d+)$/ ) {

          $hash->{helper}{cmds}{$cm}{interval} = $MAIN_INTERVAL * $val;

        }

      }

    }

    Log3 $name, 3, "$iam $cmd $attrName to default interval multiplier.";

    return;

  }

  return;

}
1;

Aekschn

Ich habe das Problem gefunden:
Der EZHI akzeptiert keine Anfragen mit HTTP-Version 1.0, ich habe das auf 1.1 geändert und das Modul läuft :)

Schönes Wochenende!