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
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!
Gerne.
Anbei eine neue Version inkl. der Umrechnung.
Hi,
runtergeladen, Fhem neu gestartet, definiert ... läuft, d.h. Werte kommen
Echt klasse !
Danke !!!
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.
Weil der wohl noch keinen lokalen Zugang bietet. Aber wenn MQTT, passt es doch
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
2026-03-29_11h20_42.png
Wenn ich den auf 9% setze bleibt er trotzdem bei 11%
Allerdings werden die 11% als 110 in Fhem angezeigt (p_minSoc 110)
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.
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 <name> ZendureLocal <ip> <sn></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 <name> properties</code><br>
<code>get <name> rpc <method></code>
</li>
<li><b>Set</b><br>
<code>set <name> outputLimit <W></code><br>
<code>set <name> acMode <num></code><br>
<code>set <name> minSoc <num></code><br>
<code>set <name> smartMode <0|1></code><br>
<code>set <name> property <key> <value></code><br>
<code>set <name> pollNow</code>
</li>
</ul>
=end html
=cut
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 ...