Zendure HEMS Modul

Begonnen von Mitch, 25 Februar 2026, 12:43:16

Vorheriges Thema - Nächstes Thema

Mitch

Hallo Zusammen,

ich hatte mit einem SolarFlow 800 Plus + D0 Zähler angefangen und alles über MQTT ausgelesen.
Soweit,so gut.

Bin dann auf HEMS (Home Energy Management System) umgestiegen, wodurch MQTT für den SF800P deaktiviert wurde.
Habe jetzt auch noch einen SolarFlow 800 mit dabei, deswegen habe ich mir ein Modul bauen lassen.

Ich stelle es hier gerne ohne Gewähr zur Verfügung.

define Gerätename ZendureLocal IP Seriennummer
FHEM im Proxmox Container

point3r

Hi,

ich versuche seit zwei Monaten genau dieses Problem zu lösen - mit dem Modul funktioniert es einwandfrei, vielen Dank!

Und wer (wie ich) nach der Umrechnung zur Temperatur sucht: https://github.com/Zendure/zenSDK/blob/main/docs/en_properties.md

Grüße!

Mitch

Gerne.
Anbei eine neue Version inkl. der Umrechnung.

FHEM im Proxmox Container

cotecmania

Hi,

runtergeladen, Fhem neu gestartet, definiert ... läuft, d.h. Werte kommen

Echt klasse !

Danke !!!
FHEM auf Debian 13 in Proxmox VM
MAX!/HM/Sonoff-Thermostate, HM-Rolladenschalter, Shellys aller Art, LevelJet-Ölstandsmessung, KM271, IPCAM, TAB13" FTUI3

rabehd

Ich habe ein Hyper 2000 im HEMS. Wenn ich das Modul ausprobiere, dann erhalte ich "Connection refused (111)".
MQTT geht bei mir aber weiterhin, auch mit HEMS.
Auch funktionierende Lösungen kann man hinterfragen.

Mitch

Weil der wohl noch keinen lokalen Zugang bietet. Aber wenn MQTT, passt es doch
FHEM im Proxmox Container

cotecmania

#6
Wie gesagt, bei mir läufts mit einem 2400AC gut.

Gibts für das HEMS Protokoll irgendwo 'ne Doku von Zendure ?

z.B. der Wert p_remainOutTime funktioniert, solange der Wert unter x h liegt, die Grenze weiss ich noch nicht

Aktuell sagt Fhem 255 aber Home Assistant berichtet aktuell 14h und 32m
Gestern abend hats gepasst und war bei 2:30 h gleich bei beiden Systemen

@Mitch
Von "wem" hast Du das programmieren lassen ? KI ? Und anhand welcher Doku ?

Gruss
Joe

PS: Das Setzen des minSOC funktioniert auch nicht
Du darfst diesen Dateianhang nicht ansehen.
Wenn ich den auf 9% setze bleibt er trotzdem bei 11%
Allerdings werden die 11% als 110 in Fhem angezeigt (p_minSoc 110)
FHEM auf Debian 13 in Proxmox VM
MAX!/HM/Sonoff-Thermostate, HM-Rolladenschalter, Shellys aller Art, LevelJet-Ölstandsmessung, KM271, IPCAM, TAB13" FTUI3

cotecmania

Nach weiterer Recherche kann man mit der URL : http://ip_zendure_xxx/properties/report die ganzen Werte als JSON anfragen
Diese Werte werden von Mitch's Modul ausgelesen und in FHem angezeigt.
Hier steht auch der SOCmin (z.B. 11% bei mir) mit "110" genauso drin.
Komische Formatierung, deren Beschreibung mich interessieren würde.
Bei besserer Kenntnis der Formatierung könnte ich bzw. man die anpassen.
FHEM auf Debian 13 in Proxmox VM
MAX!/HM/Sonoff-Thermostate, HM-Rolladenschalter, Shellys aller Art, LevelJet-Ölstandsmessung, KM271, IPCAM, TAB13" FTUI3

Mitch

#8
Ich habe ChatGPT genutzt. Die Doku stammt teilweise von Github (https://github.com/Zendure/zenSDK/blob/main/docs/en_properties.md), leider sind aber nicht alle Werte dokumentiert.

Hier mal ein Update:
package main;

use strict;
use warnings;
use HttpUtils;
use JSON qw(decode_json encode_json);
use Time::HiRes qw(gettimeofday);

sub ZendureLocal_Initialize($);
sub ZendureLocal_Define($$);
sub ZendureLocal_Undef($$);
sub ZendureLocal_Get($$@);
sub ZendureLocal_Set($$@);
sub ZendureLocal_Poll($);
sub ZendureLocal_DoRequest($$$$$);
sub ZendureLocal_ParsePropertiesReport($$);
sub ZendureLocal_FormatMinutes($);
sub ZendureLocal_MapState($);

sub ZendureLocal_Initialize($) {
  my ($hash) = @_;

  $hash->{DefFn}   = "ZendureLocal_Define";
  $hash->{UndefFn} = "ZendureLocal_Undef";
  $hash->{GetFn}   = "ZendureLocal_Get";
  $hash->{SetFn}   = "ZendureLocal_Set";

  $hash->{AttrList} = join(" ", qw(
    interval
    timeout
    disable:0,1
    readingPrefix
    room
  )) . " " . $readingFnAttributes;

  return;
}

sub ZendureLocal_Define($$) {
  my ($hash, $def) = @_;
  my @a = split("[ \t]+", $def);

  return "Usage: define <name> ZendureLocal <ip> <sn>" if (@a != 4);

  my ($name, undef, $ip, $sn) = @a;

  $hash->{NAME} = $name;
  $hash->{IP}   = $ip;
  $hash->{SN}   = $sn;

  $attr{$name}{interval}      //= 10;
  $attr{$name}{timeout}       //= 5;
  $attr{$name}{readingPrefix} //= "";

  readingsSingleUpdate($hash, "state", "defined", 1);

  ZendureLocal_Poll($hash);
  return undef;
}

sub ZendureLocal_Undef($$) {
  my ($hash, $arg) = @_;
  RemoveInternalTimer($hash);
  return undef;
}

sub ZendureLocal_Get($$@) {
  my ($hash, $name, @a) = @_;
  return "get $name needs an argument" if (@a < 1);

  my $cmd = $a[0];

  if ($cmd eq "properties") {
    ZendureLocal_DoRequest($hash, "GET", "/properties/report", undef, sub {
      my ($h, $ok, $msg) = @_;
      readingsSingleUpdate($h, "state", $ok ? "ok" : "error", 1);
      readingsSingleUpdate($h, "lastError", $msg, 1) if (!$ok && defined $msg && $msg ne "");
    });
    return "OK (request started)";
  }

  if ($cmd eq "rpc") {
    return "Usage: get $name rpc <method>" if (@a < 2);
    my $method = $a[1];

    my $body = {
      sn     => $hash->{SN},
      method => $method,
      params => {}
    };

    ZendureLocal_DoRequest($hash, "POST", "/rpc", $body, sub {
      my ($h, $ok, $msg) = @_;
      readingsSingleUpdate($h, "state", $ok ? "ok" : "error", 1);
      readingsSingleUpdate($h, "lastRpc", $msg // "", 1);
    });

    return "OK (request started)";
  }

  return "Unknown argument $cmd, choose one of properties rpc";
}

sub ZendureLocal_Set($$@) {
  my ($hash, $name, @a) = @_;

  my $choices = "outputLimit acMode minSoc smartMode property pollNow";
  return "set $name needs an argument, choose one of $choices" if (@a < 1);

  my $cmd = $a[0];

  if ($cmd eq "?") {
    return "choose one of $choices";
  }

  if ($cmd eq "pollNow") {
    ZendureLocal_Poll($hash);
    return "OK";
  }

  my $send_write = sub {
    my ($key, $val) = @_;

    my $val_json = ($val =~ /^-?\d+(?:\.\d+)?$/) ? 0 + $val : $val;

    my $body = {
      sn => $hash->{SN},
      properties => { $key => $val_json }
    };

    ZendureLocal_DoRequest($hash, "POST", "/properties/write", $body, sub {
      my ($h, $ok, $msg) = @_;
      readingsSingleUpdate($h, "state", $ok ? "ok" : "error", 1);
      readingsSingleUpdate($h, "lastSet", "$key=$val", 1) if $ok;
      readingsSingleUpdate($h, "lastError", $msg, 1) if (!$ok && defined $msg && $msg ne "");
    });

    return "OK (request started)";
  };

  if ($cmd eq "property") {
    return "Usage: set $name property <key> <value>" if (@a < 3);
    my ($key, $val) = ($a[1], $a[2]);
    return $send_write->($key, $val);
  }

  if ($cmd =~ /^(outputLimit|acMode|minSoc|smartMode)$/) {
    return "Usage: set $name $cmd <value>" if (@a < 2);
    my $val = $a[1];
    return $send_write->($cmd, $val);
  }

  return "Unknown argument $cmd, choose one of $choices";
}

sub ZendureLocal_Poll($) {
  my ($hash) = @_;
  my $name = $hash->{NAME};

  RemoveInternalTimer($hash);
  return if AttrVal($name, "disable", 0);

  ZendureLocal_DoRequest($hash, "GET", "/properties/report", undef, sub {
    my ($h, $ok, $msg) = @_;
    readingsSingleUpdate($h, "state", $ok ? "ok" : "error", 1);
    readingsSingleUpdate($h, "lastError", $msg, 1) if (!$ok && defined $msg && $msg ne "");
  });

  my $interval = int(AttrVal($name, "interval", 10));
  $interval = 10 if ($interval < 2);

  InternalTimer(gettimeofday() + $interval, "ZendureLocal_Poll", $hash, 0);
}

sub ZendureLocal_DoRequest($$$$$) {
  my ($hash, $method, $path, $body, $cb) = @_;

  my $name    = $hash->{NAME};
  my $ip      = $hash->{IP};
  my $timeout = int(AttrVal($name, "timeout", 5));
  my $url     = "http://$ip$path";

  my $data;
  my $header = {};

  if (defined $body) {
    $data = encode_json($body);
    $header->{"Content-Type"} = "application/json";
  }

  my $param = {
    url      => $url,
    method   => $method,
    timeout  => $timeout,
    header   => $header,
    data     => $data,
    hash     => $hash,
    callback => sub {
      my ($p, $err, $resp) = @_;
      my $h = $p->{hash};

      if (defined $err && $err ne "") {
        $cb->($h, 0, $err);
        return;
      }

      if (!defined $resp || $resp eq "") {
        $cb->($h, 0, "$url: empty answer received");
        return;
      }

      my $json;
      if (ref($resp)) {
        $json = $resp;
      } else {
        eval { $json = decode_json($resp); 1; } or do {
          $cb->($h, 0, "JSON parse failed");
          return;
        };
      }

      if ($path eq "/properties/report") {
        ZendureLocal_ParsePropertiesReport($h, $json);
      } else {
        readingsBeginUpdate($h);
        readingsBulkUpdate($h, "lastResponse", ref($resp) ? encode_json($resp) : $resp);
        readingsEndUpdate($h, 1);
      }

      $cb->($h, 1, undef);
    }
  };

  HttpUtils_NonblockingGet($param);
}

sub ZendureLocal_FormatMinutes($) {
  my ($m) = @_;
  return "" if (!defined $m || $m eq "" || $m !~ /^-?\d+$/);

  my $h = int($m / 60);
  my $min = $m % 60;
  return sprintf("%dh %02dm", $h, $min);
}

sub ZendureLocal_MapState($) {
  my ($v) = @_;
  return "" if (!defined $v || $v eq "");

  my %map = (
    0 => "standby",
    1 => "charging",
    2 => "discharging",
  );

  return exists $map{$v} ? $map{$v} : $v;
}

sub ZendureLocal_ParsePropertiesReport($$) {
  my ($hash, $json) = @_;
  my $name   = $hash->{NAME};
  my $prefix = AttrVal($name, "readingPrefix", "");

  if (!ref($json)) {
    my $tmp;
    eval { $tmp = decode_json($json); 1; };
    $json = $tmp if ($tmp && ref($tmp));
  }

  readingsBeginUpdate($hash);

  if (ref($json) eq "HASH") {
    foreach my $k (sort keys %{$json}) {
      next if ($k eq "properties" || $k eq "packData");
      my $v = $json->{$k};
      next if !defined $v;
      readingsBulkUpdate($hash, $prefix.$k, ref($v) ? encode_json($v) : $v);
    }
  }

  if (exists $json->{properties}) {
    my $p = $json->{properties};

    if (!ref($p)) {
      my $tmp;
      eval { $tmp = decode_json($p); 1; };
      $p = $tmp if ($tmp && ref($tmp));
    }

    if (ref($p) eq "HASH") {
      foreach my $k (sort keys %{$p}) {
        my $v = $p->{$k};
        next if !defined $v;

        my $rname = $prefix . "p_" . $k;
        readingsBulkUpdate($hash, $rname, ref($v) ? encode_json($v) : $v);

        if ($k eq "hyperTmp" && $v =~ /^\d+$/) {
          my $c = ($v - 2731) / 10.0;
          readingsBulkUpdate($hash, $prefix . "p_hyperTmp_C", sprintf("%.1f", $c));
        }

        if (($k eq "minSoc" || $k eq "socSet") && $v =~ /^\d+$/) {
          my $pct = $v / 10.0;
          readingsBulkUpdate($hash, $prefix . "p_" . $k . "_pct", sprintf("%.1f", $pct));
        }

        if ($k eq "packState") {
          readingsBulkUpdate($hash, $prefix . "p_packState_txt", ZendureLocal_MapState($v));
        }

        if ($k eq "acMode") {
          readingsBulkUpdate($hash, $prefix . "p_acMode_txt", ZendureLocal_MapState($v));
        }

        if ($k eq "remainOutTime" && $v =~ /^\d+$/) {
          readingsBulkUpdate($hash, $prefix . "p_remainOutTime_hm", ZendureLocal_FormatMinutes($v));
        }
      }
    } else {
      readingsBulkUpdate($hash, $prefix . "properties", ref($json->{properties}) ? encode_json($json->{properties}) : $json->{properties});
    }
  }

  if (exists $json->{packData}) {
    my $arr = $json->{packData};

    if (!ref($arr)) {
      my $tmp;
      eval { $tmp = decode_json($arr); 1; };
      $arr = $tmp if ($tmp && ref($tmp) eq "ARRAY");
    }

    if (ref($arr) eq "ARRAY") {
      for (my $i = 0; $i < @$arr; $i++) {
        next if ref($arr->[$i]) ne "HASH";
        my $bd = $arr->[$i];

        foreach my $k (sort keys %{$bd}) {
          my $v = $bd->{$k};
          next if !defined $v;

          my $rname = $prefix . "bat${i}_" . $k;
          readingsBulkUpdate($hash, $rname, ref($v) ? encode_json($v) : $v);

          if ($k eq "maxTemp" && $v =~ /^\d+$/) {
            my $c = ($v - 2731) / 10.0;
            readingsBulkUpdate($hash, $prefix . "bat${i}_maxTemp_C", sprintf("%.1f", $c));
          }

          if ($k eq "batcur" && $v =~ /^\d+$/) {
            my $raw = int($v) & 0xFFFF;
            my $signed = ($raw & 0x8000) ? ($raw - 0x10000) : $raw;
            my $a = $signed / 10.0;
            readingsBulkUpdate($hash, $prefix . "bat${i}_batcur_A", sprintf("%.1f", $a));
          }
        }
      }

      readingsBulkUpdate($hash, $prefix . "bat_count", scalar(@$arr));
    } else {
      readingsBulkUpdate($hash, $prefix . "packData", ref($json->{packData}) ? encode_json($json->{packData}) : $json->{packData});
    }
  }

  readingsBulkUpdate($hash, $prefix . "lastUpdate", gettimeofday());
  readingsEndUpdate($hash, 1);
}

1;

=pod
=item device
=item summary Zendure SolarFlow Local API (HTTP) reader/writer
=begin html

<a name="ZendureLocal"></a>
<h3>ZendureLocal</h3>
<ul>
  <li><b>Define</b><br>
    <code>define &lt;name&gt; ZendureLocal &lt;ip&gt; &lt;sn&gt;</code>
  </li>
  <li><b>Attributes</b><br>
    <ul>
      <li>interval (default 10)</li>
      <li>timeout (default 5)</li>
      <li>disable (0/1)</li>
      <li>readingPrefix (default empty)</li>
      <li>room</li>
    </ul>
  </li>
  <li><b>Get</b><br>
    <code>get &lt;name&gt; properties</code><br>
    <code>get &lt;name&gt; rpc &lt;method&gt;</code>
  </li>
  <li><b>Set</b><br>
    <code>set &lt;name&gt; outputLimit &lt;W&gt;</code><br>
    <code>set &lt;name&gt; acMode &lt;num&gt;</code><br>
    <code>set &lt;name&gt; minSoc &lt;num&gt;</code><br>
    <code>set &lt;name&gt; smartMode &lt;0|1&gt;</code><br>
    <code>set &lt;name&gt; property &lt;key&gt; &lt;value&gt;</code><br>
    <code>set &lt;name&gt; pollNow</code>
  </li>
</ul>

=end html
=cut
FHEM im Proxmox Container

cotecmania

#9
Ok.
Du hast u.a. die SOCs in Prozent umgerechnet und als neue Readings hinzugefuegt. Ebenso die Remaining time.

Ich habe mir mal erlaubt eine Versionierung incl. Reading einzufuegen ... siehe Anhang

Das setzen des min/max SOC funktioniert aber immer noch nicht.
Weist Du wie die URL dazu aussehen sollte ? Dann könnte man mal einen Direktaufruf testen ...
Dann könnte man die min/max SOCs mit Fhem automatisch je nach Jahreszeit anpassen ...
FHEM auf Debian 13 in Proxmox VM
MAX!/HM/Sonoff-Thermostate, HM-Rolladenschalter, Shellys aller Art, LevelJet-Ölstandsmessung, KM271, IPCAM, TAB13" FTUI3