From 18277abaa4b0ee69b9e173d454f3ce6f24f298c9 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 5 Jul 2026 11:50:06 +0200 Subject: [PATCH 01/11] Meter: Add Solplanet (AISWEI) ASW-H hybrid inverter template fixes #17680 Register map from the vendor's AISWEI Modbus documentation posted in the discussion. Covers grid (CT/smart meter block), PV, and battery (power/SOC) monitoring over Modbus RS485/TCP. Battery charge/discharge control is deliberately left out: the vendor documents the registers (41152/41153) but not the power sign convention, and community reports suggest the network dongle doesn't expose native Modbus TCP, so both need field verification before a follow-up adds control. --- .../definition/meter/solplanet-hybrid.yaml | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 templates/definition/meter/solplanet-hybrid.yaml diff --git a/templates/definition/meter/solplanet-hybrid.yaml b/templates/definition/meter/solplanet-hybrid.yaml new file mode 100644 index 00000000000..9062b153710 --- /dev/null +++ b/templates/definition/meter/solplanet-hybrid.yaml @@ -0,0 +1,191 @@ +template: solplanet-hybrid +products: + - brand: Solplanet + description: + en: ASW-H Hybrid Inverter + de: ASW-H Hybrid-Wechselrichter +requirements: + description: + en: | + Register map from the vendor's [AISWEI Modbus documentation](https://github.com/evcc-io/evcc/discussions/17680) (Solplanet inverters are built by AISWEI). + + **Attention**: users report that the network dongle does not expose Modbus TCP directly (connection refused/reset), only the undocumented HTTP/JSON API on port 8484. Use an RS485 connection (directly or via a serial-to-TCP/USB adapter) unless your dongle's firmware has since added native Modbus TCP support. + + Battery charge/discharge control is not implemented yet: the vendor documents the required registers, but the exact power sign convention (charge vs. discharge) is not stated and needs field verification on real hardware before it can be added safely. + de: | + Registerkarte aus der [AISWEI Modbus-Dokumentation](https://github.com/evcc-io/evcc/discussions/17680) des Herstellers (Solplanet-Wechselrichter werden von AISWEI gefertigt). + + **Achtung**: Nutzer berichten, dass der Netzwerk-Dongle kein Modbus TCP anbietet (Verbindung wird abgelehnt/zurückgesetzt), sondern nur die undokumentierte HTTP/JSON-API auf Port 8484. Nutze eine RS485-Verbindung (direkt oder über einen Seriell-zu-TCP/USB-Adapter), außer die Firmware deines Dongles unterstützt inzwischen natives Modbus TCP. + + Eine aktive Batteriesteuerung (Laden/Halten) ist noch nicht umgesetzt: der Hersteller dokumentiert die nötigen Register, aber die genaue Vorzeichenkonvention (Laden vs. Entladen) ist nicht dokumentiert und muss erst an echter Hardware verifiziert werden. + evcc: ["skiptest"] +params: + - name: usage + choice: ["grid", "pv", "battery"] + - name: modbus + choice: ["rs485", "tcpip"] + - name: timeout + default: 15s + - name: capacity + usages: ["battery"] +render: | + type: custom + {{- if eq .usage "grid" }} + power: + source: modbus + {{- include "modbus" . | indent 2 }} + timeout: {{ .timeout }} + connectdelay: 1s + register: + address: 35016 # Total system power (S32, W) + type: holding + decode: int32 + block: # 35001-35021 + register: 35001 + count: 21 + energy: + source: modbus + {{- include "modbus" . | indent 2 }} + timeout: {{ .timeout }} + register: + address: 35018 # Import Wh since last reset (U32, 0.1 kWh) + type: holding + decode: uint32 + block: # 35001-35021 + register: 35001 + count: 21 + scale: 0.1 + returnenergy: + source: modbus + {{- include "modbus" . | indent 2 }} + timeout: {{ .timeout }} + register: + address: 35020 # Export Wh since last reset (U32, 0.1 kWh) + type: holding + decode: uint32 + block: # 35001-35021 + register: 35001 + count: 21 + scale: 0.1 + currents: + - source: modbus + {{- include "modbus" . | indent 2 }} + timeout: {{ .timeout }} + register: + address: 35005 # Phase 1 current (U16, 0.1 A) + type: holding + decode: uint16 + block: # 35001-35021 + register: 35001 + count: 21 + scale: 0.1 + - source: modbus + {{- include "modbus" . | indent 2 }} + timeout: {{ .timeout }} + register: + address: 35006 # Phase 2 current (U16, 0.1 A) + type: holding + decode: uint16 + block: # 35001-35021 + register: 35001 + count: 21 + scale: 0.1 + - source: modbus + {{- include "modbus" . | indent 2 }} + timeout: {{ .timeout }} + register: + address: 35007 # Phase 3 current (U16, 0.1 A) + type: holding + decode: uint16 + block: # 35001-35021 + register: 35001 + count: 21 + scale: 0.1 + voltages: + - source: modbus + {{- include "modbus" . | indent 2 }} + timeout: {{ .timeout }} + register: + address: 35001 # Phase 1 line to neutral volts (U16, 0.1 V) + type: holding + decode: uint16 + block: # 35001-35021 + register: 35001 + count: 21 + scale: 0.1 + - source: modbus + {{- include "modbus" . | indent 2 }} + timeout: {{ .timeout }} + register: + address: 35002 # Phase 2 line to neutral volts (U16, 0.1 V) + type: holding + decode: uint16 + block: # 35001-35021 + register: 35001 + count: 21 + scale: 0.1 + - source: modbus + {{- include "modbus" . | indent 2 }} + timeout: {{ .timeout }} + register: + address: 35003 # Phase 3 line to neutral volts (U16, 0.1 V) + type: holding + decode: uint16 + block: # 35001-35021 + register: 35001 + count: 21 + scale: 0.1 + {{- end }} + {{- if eq .usage "pv" }} + power: + source: modbus + {{- include "modbus" . | indent 2 }} + timeout: {{ .timeout }} + connectdelay: 1s + register: + address: 31601 # PV total power (U32, W) + type: holding + decode: uint32 + block: # 31601-31606 + register: 31601 + count: 6 + energy: + source: modbus + {{- include "modbus" . | indent 2 }} + timeout: {{ .timeout }} + register: + address: 31605 # PV E-Total (U32, 0.1 kWh) + type: holding + decode: uint32 + block: # 31601-31606 + register: 31601 + count: 6 + scale: 0.1 + {{- end }} + {{- if eq .usage "battery" }} + power: + source: modbus + {{- include "modbus" . | indent 2 }} + timeout: {{ .timeout }} + connectdelay: 1s + register: + address: 31619 # BMS1 battery power (S32, W, sign convention unverified) + type: holding + decode: int32 + block: # 31617-31622 + register: 31617 + count: 6 + soc: + source: modbus + {{- include "modbus" . | indent 2 }} + timeout: {{ .timeout }} + register: + address: 31622 # BMS1 battery SOC (U16, 0.01 %) + type: holding + decode: uint16 + block: # 31617-31622 + register: 31617 + count: 6 + scale: 0.01 + capacity: {{ .capacity }} # kWh, no capacity register documented + {{- end }} From 8eaafe7bb5c1f08ede39c294f3c3bd5302ea455e Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 5 Jul 2026 22:19:53 +0200 Subject: [PATCH 02/11] Apply suggestion from @andig --- templates/definition/meter/solplanet-hybrid.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/definition/meter/solplanet-hybrid.yaml b/templates/definition/meter/solplanet-hybrid.yaml index 9062b153710..43852b9ee88 100644 --- a/templates/definition/meter/solplanet-hybrid.yaml +++ b/templates/definition/meter/solplanet-hybrid.yaml @@ -23,7 +23,7 @@ params: - name: usage choice: ["grid", "pv", "battery"] - name: modbus - choice: ["rs485", "tcpip"] + choice: ["rs485"] - name: timeout default: 15s - name: capacity From ceb2aa0e739d7ea2af2eb77b25cb7abd5433fed6 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 5 Jul 2026 22:29:43 +0200 Subject: [PATCH 03/11] solplanet-hybrid: add battery charge/discharge control --- .../definition/meter/solplanet-hybrid.yaml | 97 ++++++++++++++----- 1 file changed, 75 insertions(+), 22 deletions(-) diff --git a/templates/definition/meter/solplanet-hybrid.yaml b/templates/definition/meter/solplanet-hybrid.yaml index 43852b9ee88..90a2b08c58b 100644 --- a/templates/definition/meter/solplanet-hybrid.yaml +++ b/templates/definition/meter/solplanet-hybrid.yaml @@ -7,34 +7,41 @@ products: requirements: description: en: | - Register map from the vendor's [AISWEI Modbus documentation](https://github.com/evcc-io/evcc/discussions/17680) (Solplanet inverters are built by AISWEI). + Based on the vendor's [AISWEI Modbus documentation](https://github.com/evcc-io/evcc/discussions/17680) (Solplanet inverters are built by AISWEI). **Attention**: users report that the network dongle does not expose Modbus TCP directly (connection refused/reset), only the undocumented HTTP/JSON API on port 8484. Use an RS485 connection (directly or via a serial-to-TCP/USB adapter) unless your dongle's firmware has since added native Modbus TCP support. - Battery charge/discharge control is not implemented yet: the vendor documents the required registers, but the exact power sign convention (charge vs. discharge) is not stated and needs field verification on real hardware before it can be added safely. + Battery charge/discharge control is supported and has been confirmed working against real hardware ([source](https://github.com/evcc-io/evcc/pull/31480#issuecomment-4887359149)). de: | - Registerkarte aus der [AISWEI Modbus-Dokumentation](https://github.com/evcc-io/evcc/discussions/17680) des Herstellers (Solplanet-Wechselrichter werden von AISWEI gefertigt). + Basierend auf der [AISWEI Modbus-Dokumentation](https://github.com/evcc-io/evcc/discussions/17680) des Herstellers (Solplanet-Wechselrichter werden von AISWEI gefertigt). **Achtung**: Nutzer berichten, dass der Netzwerk-Dongle kein Modbus TCP anbietet (Verbindung wird abgelehnt/zurückgesetzt), sondern nur die undokumentierte HTTP/JSON-API auf Port 8484. Nutze eine RS485-Verbindung (direkt oder über einen Seriell-zu-TCP/USB-Adapter), außer die Firmware deines Dongles unterstützt inzwischen natives Modbus TCP. - Eine aktive Batteriesteuerung (Laden/Halten) ist noch nicht umgesetzt: der Hersteller dokumentiert die nötigen Register, aber die genaue Vorzeichenkonvention (Laden vs. Entladen) ist nicht dokumentiert und muss erst an echter Hardware verifiziert werden. + Die Batterie-Lade-/Entladesteuerung wird unterstützt und wurde an echter Hardware verifiziert ([Quelle](https://github.com/evcc-io/evcc/pull/31480#issuecomment-4887359149)). evcc: ["skiptest"] +capabilities: ["battery-control"] params: - name: usage choice: ["grid", "pv", "battery"] - name: modbus choice: ["rs485"] - - name: timeout - default: 15s - - name: capacity - usages: ["battery"] + - preset: battery-params + - name: maxchargepower + required: true + example: 5000 + - name: maxdischargepower + required: true + example: 5000 + - name: minsoc + default: 10 + - name: maxsoc + default: 100 render: | type: custom {{- if eq .usage "grid" }} power: source: modbus {{- include "modbus" . | indent 2 }} - timeout: {{ .timeout }} connectdelay: 1s register: address: 35016 # Total system power (S32, W) @@ -46,7 +53,6 @@ render: | energy: source: modbus {{- include "modbus" . | indent 2 }} - timeout: {{ .timeout }} register: address: 35018 # Import Wh since last reset (U32, 0.1 kWh) type: holding @@ -58,7 +64,6 @@ render: | returnenergy: source: modbus {{- include "modbus" . | indent 2 }} - timeout: {{ .timeout }} register: address: 35020 # Export Wh since last reset (U32, 0.1 kWh) type: holding @@ -70,7 +75,6 @@ render: | currents: - source: modbus {{- include "modbus" . | indent 2 }} - timeout: {{ .timeout }} register: address: 35005 # Phase 1 current (U16, 0.1 A) type: holding @@ -81,7 +85,6 @@ render: | scale: 0.1 - source: modbus {{- include "modbus" . | indent 2 }} - timeout: {{ .timeout }} register: address: 35006 # Phase 2 current (U16, 0.1 A) type: holding @@ -92,7 +95,6 @@ render: | scale: 0.1 - source: modbus {{- include "modbus" . | indent 2 }} - timeout: {{ .timeout }} register: address: 35007 # Phase 3 current (U16, 0.1 A) type: holding @@ -104,7 +106,6 @@ render: | voltages: - source: modbus {{- include "modbus" . | indent 2 }} - timeout: {{ .timeout }} register: address: 35001 # Phase 1 line to neutral volts (U16, 0.1 V) type: holding @@ -115,7 +116,6 @@ render: | scale: 0.1 - source: modbus {{- include "modbus" . | indent 2 }} - timeout: {{ .timeout }} register: address: 35002 # Phase 2 line to neutral volts (U16, 0.1 V) type: holding @@ -126,7 +126,6 @@ render: | scale: 0.1 - source: modbus {{- include "modbus" . | indent 2 }} - timeout: {{ .timeout }} register: address: 35003 # Phase 3 line to neutral volts (U16, 0.1 V) type: holding @@ -140,7 +139,6 @@ render: | power: source: modbus {{- include "modbus" . | indent 2 }} - timeout: {{ .timeout }} connectdelay: 1s register: address: 31601 # PV total power (U32, W) @@ -152,7 +150,6 @@ render: | energy: source: modbus {{- include "modbus" . | indent 2 }} - timeout: {{ .timeout }} register: address: 31605 # PV E-Total (U32, 0.1 kWh) type: holding @@ -166,7 +163,6 @@ render: | power: source: modbus {{- include "modbus" . | indent 2 }} - timeout: {{ .timeout }} connectdelay: 1s register: address: 31619 # BMS1 battery power (S32, W, sign convention unverified) @@ -178,7 +174,6 @@ render: | soc: source: modbus {{- include "modbus" . | indent 2 }} - timeout: {{ .timeout }} register: address: 31622 # BMS1 battery SOC (U16, 0.01 %) type: holding @@ -187,5 +182,63 @@ render: | register: 31617 count: 6 scale: 0.01 - capacity: {{ .capacity }} # kWh, no capacity register documented + batterymode: + source: switch + switch: + - case: 1 # normal + set: + source: const + value: 2 # normal operation + set: + source: modbus + {{- include "modbus" . | indent 8 }} + register: + address: 1103 # battery power control mode + type: writesingle + decode: uint16 + - case: 2 # hold + set: + source: sequence + set: + - source: const + value: 4 # manual power control + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 1103 # battery power control mode + type: writesingle + decode: uint16 + - source: const + value: 0 # setpoint 0 W + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 1152 # battery power setpoint, W, positive discharge/negative charge + type: writesingle + decode: int16 + - case: 3 # charge + set: + source: sequence + set: + - source: const + value: 4 # manual power control + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 1103 # battery power control mode + type: writesingle + decode: uint16 + - source: const + value: -{{ .maxchargepower }} # negative setpoint charges + set: + source: modbus + {{- include "modbus" . | indent 10 }} + register: + address: 1152 # battery power setpoint, W, positive discharge/negative charge + type: writesingle + decode: int16 + {{- include "battery-params" . }} {{- end }} From 67fdaa9f064e4041c70405e0343548a938e2ec53 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 6 Jul 2026 23:03:56 +0200 Subject: [PATCH 04/11] solplanet-hybrid-http: add fdbg.cgi HTTP transport as alternative to Modbus Adds a second template for the AI LAN/WiFi dongle, which only exposes an undocumented HTTP/JSON API (getdevdata.cgi, fdbg.cgi) instead of Modbus TCP/RTU. Battery control tunnels raw Modbus RTU frames over that endpoint, as reported working in discussion #17680. Charge power is fixed at 5000 W since the frames are precomputed including CRC16. Cross-links both templates so users pick the right one for their dongle/connection. --- .../meter/solplanet-hybrid-http.yaml | 103 ++++++++++++++++++ .../definition/meter/solplanet-hybrid.yaml | 4 +- 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 templates/definition/meter/solplanet-hybrid-http.yaml diff --git a/templates/definition/meter/solplanet-hybrid-http.yaml b/templates/definition/meter/solplanet-hybrid-http.yaml new file mode 100644 index 00000000000..13a65ee4b23 --- /dev/null +++ b/templates/definition/meter/solplanet-hybrid-http.yaml @@ -0,0 +1,103 @@ +template: solplanet-hybrid-http +products: + - brand: Solplanet + description: + en: ASW-H Hybrid Inverter (AI dongle, HTTP) + de: ASW-H Hybrid-Wechselrichter (AI-Dongle, HTTP) +requirements: + description: + en: | + For the AI LAN/WiFi dongle (V1, port 8484), which does not expose Modbus TCP/RTU directly. Reads use the dongle's undocumented `getdevdata.cgi` JSON endpoint, battery control tunnels raw Modbus-RTU frames through the equally undocumented `fdbg.cgi` endpoint ([background and more testers in discussion #17680](https://github.com/evcc-io/evcc/discussions/17680)). + + If your dongle exposes real Modbus (RS485, or TCP on a newer firmware), use the [solplanet-hybrid](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid.yaml) template instead - it supports a configurable charge power. + + **Charging here is fixed at 5000 W.** The fdbg.cgi tunnel takes precomputed Modbus RTU frames including a CRC16 checksum; evcc has no way to compute one for an arbitrary configured wattage, so only the 5000 W frame confirmed working in the discussion is included. + de: | + Für den AI LAN/WiFi-Dongle (V1, Port 8484), der kein Modbus TCP/RTU anbietet. Messwerte kommen über die undokumentierte `getdevdata.cgi`-JSON-Schnittstelle des Dongles, die Batteriesteuerung nutzt einen Tunnel für rohe Modbus-RTU-Frames über die ebenfalls undokumentierte `fdbg.cgi`-Schnittstelle ([Hintergrund und weitere Tester in Diskussion #17680](https://github.com/evcc-io/evcc/discussions/17680)). + + Falls dein Dongle echtes Modbus anbietet (RS485 oder TCP bei neuerer Firmware), nutze stattdessen das [solplanet-hybrid](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid.yaml)-Template - dort ist die Ladeleistung konfigurierbar. + + **Das Laden ist hier auf 5000 W fest eingestellt.** Der fdbg.cgi-Tunnel benötigt vorab berechnete Modbus-RTU-Frames inklusive CRC16-Prüfsumme; evcc kann diese nicht für eine beliebig konfigurierte Leistung berechnen, deshalb ist nur der in der Diskussion bestätigte 5000-W-Frame hinterlegt. + evcc: ["skiptest"] +capabilities: ["battery-control"] +params: + - name: usage + choice: ["grid", "pv", "battery"] + - name: host + - name: sn + description: + de: Seriennummer + en: Serial number + required: true + help: + de: Seriennummer des Wechselrichters, zu finden in der Solplanet-App oder auf der Konfigurationsseite des Dongles + en: Inverter serial number, found in the Solplanet app or on the dongle's configuration page + - preset: battery-params +render: | + type: custom + {{- if eq .usage "grid" }} + power: + source: http + uri: http://{{ .host }}:8484/getdevdata.cgi?device=3&sn={{ .sn }} + jq: .pac # grid power, W + {{- end }} + {{- if eq .usage "pv" }} + power: + source: http + uri: http://{{ .host }}:8484/getdevdata.cgi?device=4&sn={{ .sn }} + jq: .ppv # PV power, W + {{- end }} + {{- if eq .usage "battery" }} + power: + source: http + uri: http://{{ .host }}:8484/getdevdata.cgi?device=4&sn={{ .sn }} + jq: .pb # battery power, W + soc: + source: http + uri: http://{{ .host }}:8484/getdevdata.cgi?device=4&sn={{ .sn }} + jq: .soc # battery SOC, % + batterymode: + source: switch + switch: + - case: 1 # normal + set: + source: http + uri: http://{{ .host }}:8484/fdbg.cgi + method: POST + headers: + Content-Type: application/json + body: '{"data":"0306044f0002390e"}' # write reg 1103 (mode) = 2 (normal) + - case: 2 # hold: custom mode + 0 W + set: + source: sequence + set: + - source: http + uri: http://{{ .host }}:8484/fdbg.cgi + method: POST + headers: + Content-Type: application/json + body: '{"data":"0306044f0004b90c"}' # write reg 1103 (mode) = 4 (custom) + - source: http + uri: http://{{ .host }}:8484/fdbg.cgi + method: POST + headers: + Content-Type: application/json + body: '{"data":"03060480000088f0"}' # write reg 1152 (setpoint) = 0 W + - case: 3 # charge: custom mode + fixed 5000 W (see requirements) + set: + source: sequence + set: + - source: http + uri: http://{{ .host }}:8484/fdbg.cgi + method: POST + headers: + Content-Type: application/json + body: '{"data":"0306044f0004b90c"}' # write reg 1103 (mode) = 4 (custom) + - source: http + uri: http://{{ .host }}:8484/fdbg.cgi + method: POST + headers: + Content-Type: application/json + body: '{"data":"03060480ec78c412"}' # write reg 1152 (setpoint) = -5000 W + {{- include "battery-params" . }} + {{- end }} diff --git a/templates/definition/meter/solplanet-hybrid.yaml b/templates/definition/meter/solplanet-hybrid.yaml index 90a2b08c58b..912e704f0c4 100644 --- a/templates/definition/meter/solplanet-hybrid.yaml +++ b/templates/definition/meter/solplanet-hybrid.yaml @@ -9,13 +9,13 @@ requirements: en: | Based on the vendor's [AISWEI Modbus documentation](https://github.com/evcc-io/evcc/discussions/17680) (Solplanet inverters are built by AISWEI). - **Attention**: users report that the network dongle does not expose Modbus TCP directly (connection refused/reset), only the undocumented HTTP/JSON API on port 8484. Use an RS485 connection (directly or via a serial-to-TCP/USB adapter) unless your dongle's firmware has since added native Modbus TCP support. + **Attention**: users report that the network dongle does not expose Modbus TCP directly (connection refused/reset), only the undocumented HTTP/JSON API on port 8484. Use an RS485 connection (directly or via a serial-to-TCP/USB adapter) unless your dongle's firmware has since added native Modbus TCP support. If you only have that dongle, use the [solplanet-hybrid-http](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid-http.yaml) template instead. Battery charge/discharge control is supported and has been confirmed working against real hardware ([source](https://github.com/evcc-io/evcc/pull/31480#issuecomment-4887359149)). de: | Basierend auf der [AISWEI Modbus-Dokumentation](https://github.com/evcc-io/evcc/discussions/17680) des Herstellers (Solplanet-Wechselrichter werden von AISWEI gefertigt). - **Achtung**: Nutzer berichten, dass der Netzwerk-Dongle kein Modbus TCP anbietet (Verbindung wird abgelehnt/zurückgesetzt), sondern nur die undokumentierte HTTP/JSON-API auf Port 8484. Nutze eine RS485-Verbindung (direkt oder über einen Seriell-zu-TCP/USB-Adapter), außer die Firmware deines Dongles unterstützt inzwischen natives Modbus TCP. + **Achtung**: Nutzer berichten, dass der Netzwerk-Dongle kein Modbus TCP anbietet (Verbindung wird abgelehnt/zurückgesetzt), sondern nur die undokumentierte HTTP/JSON-API auf Port 8484. Nutze eine RS485-Verbindung (direkt oder über einen Seriell-zu-TCP/USB-Adapter), außer die Firmware deines Dongles unterstützt inzwischen natives Modbus TCP. Falls du nur diesen Dongle hast, nutze stattdessen das [solplanet-hybrid-http](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid-http.yaml)-Template. Die Batterie-Lade-/Entladesteuerung wird unterstützt und wurde an echter Hardware verifiziert ([Quelle](https://github.com/evcc-io/evcc/pull/31480#issuecomment-4887359149)). evcc: ["skiptest"] From f1354b1640f54de3a4548cbfe9ea3072afdeff90 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 7 Jul 2026 09:29:05 +0200 Subject: [PATCH 05/11] solplanet-hybrid-http: disable batterymode pending further verification --- .../meter/solplanet-hybrid-http.yaml | 87 ++++++++++--------- 1 file changed, 44 insertions(+), 43 deletions(-) diff --git a/templates/definition/meter/solplanet-hybrid-http.yaml b/templates/definition/meter/solplanet-hybrid-http.yaml index 13a65ee4b23..0aeb5e4bec2 100644 --- a/templates/definition/meter/solplanet-hybrid-http.yaml +++ b/templates/definition/meter/solplanet-hybrid-http.yaml @@ -56,48 +56,49 @@ render: | source: http uri: http://{{ .host }}:8484/getdevdata.cgi?device=4&sn={{ .sn }} jq: .soc # battery SOC, % - batterymode: - source: switch - switch: - - case: 1 # normal - set: - source: http - uri: http://{{ .host }}:8484/fdbg.cgi - method: POST - headers: - Content-Type: application/json - body: '{"data":"0306044f0002390e"}' # write reg 1103 (mode) = 2 (normal) - - case: 2 # hold: custom mode + 0 W - set: - source: sequence - set: - - source: http - uri: http://{{ .host }}:8484/fdbg.cgi - method: POST - headers: - Content-Type: application/json - body: '{"data":"0306044f0004b90c"}' # write reg 1103 (mode) = 4 (custom) - - source: http - uri: http://{{ .host }}:8484/fdbg.cgi - method: POST - headers: - Content-Type: application/json - body: '{"data":"03060480000088f0"}' # write reg 1152 (setpoint) = 0 W - - case: 3 # charge: custom mode + fixed 5000 W (see requirements) - set: - source: sequence - set: - - source: http - uri: http://{{ .host }}:8484/fdbg.cgi - method: POST - headers: - Content-Type: application/json - body: '{"data":"0306044f0004b90c"}' # write reg 1103 (mode) = 4 (custom) - - source: http - uri: http://{{ .host }}:8484/fdbg.cgi - method: POST - headers: - Content-Type: application/json - body: '{"data":"03060480ec78c412"}' # write reg 1152 (setpoint) = -5000 W + # batterymode disabled pending further verification - see discussion #17680 + # batterymode: + # source: switch + # switch: + # - case: 1 # normal + # set: + # source: http + # uri: http://{{ .host }}:8484/fdbg.cgi + # method: POST + # headers: + # Content-Type: application/json + # body: '{"data":"0306044f0002390e"}' # write reg 1103 (mode) = 2 (normal) + # - case: 2 # hold: custom mode + 0 W + # set: + # source: sequence + # set: + # - source: http + # uri: http://{{ .host }}:8484/fdbg.cgi + # method: POST + # headers: + # Content-Type: application/json + # body: '{"data":"0306044f0004b90c"}' # write reg 1103 (mode) = 4 (custom) + # - source: http + # uri: http://{{ .host }}:8484/fdbg.cgi + # method: POST + # headers: + # Content-Type: application/json + # body: '{"data":"03060480000088f0"}' # write reg 1152 (setpoint) = 0 W + # - case: 3 # charge: custom mode + fixed 5000 W (see requirements) + # set: + # source: sequence + # set: + # - source: http + # uri: http://{{ .host }}:8484/fdbg.cgi + # method: POST + # headers: + # Content-Type: application/json + # body: '{"data":"0306044f0004b90c"}' # write reg 1103 (mode) = 4 (custom) + # - source: http + # uri: http://{{ .host }}:8484/fdbg.cgi + # method: POST + # headers: + # Content-Type: application/json + # body: '{"data":"03060480ec78c412"}' # write reg 1152 (setpoint) = -5000 W {{- include "battery-params" . }} {{- end }} From f20b7c8643bf38a9f0db48806f2482cb78e6d5e9 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 7 Jul 2026 10:10:09 +0200 Subject: [PATCH 06/11] solplanet-hybrid: matter-of-fact wording, rename sn to serial --- .../meter/solplanet-hybrid-http.yaml | 20 +++++++------------ .../definition/meter/solplanet-hybrid.yaml | 4 ++-- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/templates/definition/meter/solplanet-hybrid-http.yaml b/templates/definition/meter/solplanet-hybrid-http.yaml index 0aeb5e4bec2..707b66b9f59 100644 --- a/templates/definition/meter/solplanet-hybrid-http.yaml +++ b/templates/definition/meter/solplanet-hybrid-http.yaml @@ -9,13 +9,13 @@ requirements: en: | For the AI LAN/WiFi dongle (V1, port 8484), which does not expose Modbus TCP/RTU directly. Reads use the dongle's undocumented `getdevdata.cgi` JSON endpoint, battery control tunnels raw Modbus-RTU frames through the equally undocumented `fdbg.cgi` endpoint ([background and more testers in discussion #17680](https://github.com/evcc-io/evcc/discussions/17680)). - If your dongle exposes real Modbus (RS485, or TCP on a newer firmware), use the [solplanet-hybrid](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid.yaml) template instead - it supports a configurable charge power. + If the dongle exposes real Modbus (RS485, or TCP on a newer firmware), use the [solplanet-hybrid](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid.yaml) template instead - it supports a configurable charge power. **Charging here is fixed at 5000 W.** The fdbg.cgi tunnel takes precomputed Modbus RTU frames including a CRC16 checksum; evcc has no way to compute one for an arbitrary configured wattage, so only the 5000 W frame confirmed working in the discussion is included. de: | Für den AI LAN/WiFi-Dongle (V1, Port 8484), der kein Modbus TCP/RTU anbietet. Messwerte kommen über die undokumentierte `getdevdata.cgi`-JSON-Schnittstelle des Dongles, die Batteriesteuerung nutzt einen Tunnel für rohe Modbus-RTU-Frames über die ebenfalls undokumentierte `fdbg.cgi`-Schnittstelle ([Hintergrund und weitere Tester in Diskussion #17680](https://github.com/evcc-io/evcc/discussions/17680)). - Falls dein Dongle echtes Modbus anbietet (RS485 oder TCP bei neuerer Firmware), nutze stattdessen das [solplanet-hybrid](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid.yaml)-Template - dort ist die Ladeleistung konfigurierbar. + Falls der Dongle echtes Modbus anbietet (RS485 oder TCP bei neuerer Firmware), stattdessen das [solplanet-hybrid](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid.yaml)-Template verwenden - dort ist die Ladeleistung konfigurierbar. **Das Laden ist hier auf 5000 W fest eingestellt.** Der fdbg.cgi-Tunnel benötigt vorab berechnete Modbus-RTU-Frames inklusive CRC16-Prüfsumme; evcc kann diese nicht für eine beliebig konfigurierte Leistung berechnen, deshalb ist nur der in der Diskussion bestätigte 5000-W-Frame hinterlegt. evcc: ["skiptest"] @@ -24,37 +24,31 @@ params: - name: usage choice: ["grid", "pv", "battery"] - name: host - - name: sn - description: - de: Seriennummer - en: Serial number + - name: serial required: true - help: - de: Seriennummer des Wechselrichters, zu finden in der Solplanet-App oder auf der Konfigurationsseite des Dongles - en: Inverter serial number, found in the Solplanet app or on the dongle's configuration page - preset: battery-params render: | type: custom {{- if eq .usage "grid" }} power: source: http - uri: http://{{ .host }}:8484/getdevdata.cgi?device=3&sn={{ .sn }} + uri: http://{{ .host }}:8484/getdevdata.cgi?device=3&sn={{ .serial }} jq: .pac # grid power, W {{- end }} {{- if eq .usage "pv" }} power: source: http - uri: http://{{ .host }}:8484/getdevdata.cgi?device=4&sn={{ .sn }} + uri: http://{{ .host }}:8484/getdevdata.cgi?device=4&sn={{ .serial }} jq: .ppv # PV power, W {{- end }} {{- if eq .usage "battery" }} power: source: http - uri: http://{{ .host }}:8484/getdevdata.cgi?device=4&sn={{ .sn }} + uri: http://{{ .host }}:8484/getdevdata.cgi?device=4&sn={{ .serial }} jq: .pb # battery power, W soc: source: http - uri: http://{{ .host }}:8484/getdevdata.cgi?device=4&sn={{ .sn }} + uri: http://{{ .host }}:8484/getdevdata.cgi?device=4&sn={{ .serial }} jq: .soc # battery SOC, % # batterymode disabled pending further verification - see discussion #17680 # batterymode: diff --git a/templates/definition/meter/solplanet-hybrid.yaml b/templates/definition/meter/solplanet-hybrid.yaml index 912e704f0c4..d20641e99d5 100644 --- a/templates/definition/meter/solplanet-hybrid.yaml +++ b/templates/definition/meter/solplanet-hybrid.yaml @@ -9,13 +9,13 @@ requirements: en: | Based on the vendor's [AISWEI Modbus documentation](https://github.com/evcc-io/evcc/discussions/17680) (Solplanet inverters are built by AISWEI). - **Attention**: users report that the network dongle does not expose Modbus TCP directly (connection refused/reset), only the undocumented HTTP/JSON API on port 8484. Use an RS485 connection (directly or via a serial-to-TCP/USB adapter) unless your dongle's firmware has since added native Modbus TCP support. If you only have that dongle, use the [solplanet-hybrid-http](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid-http.yaml) template instead. + **Attention**: users report that the network dongle does not expose Modbus TCP directly (connection refused/reset), only the undocumented HTTP/JSON API on port 8484. Use an RS485 connection (directly or via a serial-to-TCP/USB adapter) unless the dongle's firmware has since added native Modbus TCP support. If only that dongle is used, use the [solplanet-hybrid-http](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid-http.yaml) template instead. Battery charge/discharge control is supported and has been confirmed working against real hardware ([source](https://github.com/evcc-io/evcc/pull/31480#issuecomment-4887359149)). de: | Basierend auf der [AISWEI Modbus-Dokumentation](https://github.com/evcc-io/evcc/discussions/17680) des Herstellers (Solplanet-Wechselrichter werden von AISWEI gefertigt). - **Achtung**: Nutzer berichten, dass der Netzwerk-Dongle kein Modbus TCP anbietet (Verbindung wird abgelehnt/zurückgesetzt), sondern nur die undokumentierte HTTP/JSON-API auf Port 8484. Nutze eine RS485-Verbindung (direkt oder über einen Seriell-zu-TCP/USB-Adapter), außer die Firmware deines Dongles unterstützt inzwischen natives Modbus TCP. Falls du nur diesen Dongle hast, nutze stattdessen das [solplanet-hybrid-http](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid-http.yaml)-Template. + **Achtung**: Nutzer berichten, dass der Netzwerk-Dongle kein Modbus TCP anbietet (Verbindung wird abgelehnt/zurückgesetzt), sondern nur die undokumentierte HTTP/JSON-API auf Port 8484. Eine RS485-Verbindung verwenden (direkt oder über einen Seriell-zu-TCP/USB-Adapter), außer die Firmware des Dongles unterstützt inzwischen natives Modbus TCP. Falls nur dieser Dongle verwendet wird, stattdessen das [solplanet-hybrid-http](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid-http.yaml)-Template verwenden. Die Batterie-Lade-/Entladesteuerung wird unterstützt und wurde an echter Hardware verifiziert ([Quelle](https://github.com/evcc-io/evcc/pull/31480#issuecomment-4887359149)). evcc: ["skiptest"] From 5c1c037ea671e1e62207d6111c3cb5810839d237 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 7 Jul 2026 10:12:07 +0200 Subject: [PATCH 07/11] Update templates/definition/meter/solplanet-hybrid-http.yaml --- templates/definition/meter/solplanet-hybrid-http.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/templates/definition/meter/solplanet-hybrid-http.yaml b/templates/definition/meter/solplanet-hybrid-http.yaml index 707b66b9f59..ecd16d64082 100644 --- a/templates/definition/meter/solplanet-hybrid-http.yaml +++ b/templates/definition/meter/solplanet-hybrid-http.yaml @@ -16,8 +16,6 @@ requirements: Für den AI LAN/WiFi-Dongle (V1, Port 8484), der kein Modbus TCP/RTU anbietet. Messwerte kommen über die undokumentierte `getdevdata.cgi`-JSON-Schnittstelle des Dongles, die Batteriesteuerung nutzt einen Tunnel für rohe Modbus-RTU-Frames über die ebenfalls undokumentierte `fdbg.cgi`-Schnittstelle ([Hintergrund und weitere Tester in Diskussion #17680](https://github.com/evcc-io/evcc/discussions/17680)). Falls der Dongle echtes Modbus anbietet (RS485 oder TCP bei neuerer Firmware), stattdessen das [solplanet-hybrid](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid.yaml)-Template verwenden - dort ist die Ladeleistung konfigurierbar. - - **Das Laden ist hier auf 5000 W fest eingestellt.** Der fdbg.cgi-Tunnel benötigt vorab berechnete Modbus-RTU-Frames inklusive CRC16-Prüfsumme; evcc kann diese nicht für eine beliebig konfigurierte Leistung berechnen, deshalb ist nur der in der Diskussion bestätigte 5000-W-Frame hinterlegt. evcc: ["skiptest"] capabilities: ["battery-control"] params: From 6381065636f58d5f5cc1f13445bbe8cd57da4fb1 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 7 Jul 2026 10:12:14 +0200 Subject: [PATCH 08/11] Update templates/definition/meter/solplanet-hybrid-http.yaml --- templates/definition/meter/solplanet-hybrid-http.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/templates/definition/meter/solplanet-hybrid-http.yaml b/templates/definition/meter/solplanet-hybrid-http.yaml index ecd16d64082..b5edf0f4456 100644 --- a/templates/definition/meter/solplanet-hybrid-http.yaml +++ b/templates/definition/meter/solplanet-hybrid-http.yaml @@ -10,8 +10,6 @@ requirements: For the AI LAN/WiFi dongle (V1, port 8484), which does not expose Modbus TCP/RTU directly. Reads use the dongle's undocumented `getdevdata.cgi` JSON endpoint, battery control tunnels raw Modbus-RTU frames through the equally undocumented `fdbg.cgi` endpoint ([background and more testers in discussion #17680](https://github.com/evcc-io/evcc/discussions/17680)). If the dongle exposes real Modbus (RS485, or TCP on a newer firmware), use the [solplanet-hybrid](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid.yaml) template instead - it supports a configurable charge power. - - **Charging here is fixed at 5000 W.** The fdbg.cgi tunnel takes precomputed Modbus RTU frames including a CRC16 checksum; evcc has no way to compute one for an arbitrary configured wattage, so only the 5000 W frame confirmed working in the discussion is included. de: | Für den AI LAN/WiFi-Dongle (V1, Port 8484), der kein Modbus TCP/RTU anbietet. Messwerte kommen über die undokumentierte `getdevdata.cgi`-JSON-Schnittstelle des Dongles, die Batteriesteuerung nutzt einen Tunnel für rohe Modbus-RTU-Frames über die ebenfalls undokumentierte `fdbg.cgi`-Schnittstelle ([Hintergrund und weitere Tester in Diskussion #17680](https://github.com/evcc-io/evcc/discussions/17680)). From 4ea12b59327b6ebfe4153c4ad3ba652a97331351 Mon Sep 17 00:00:00 2001 From: Micha Date: Sat, 18 Jul 2026 12:15:06 +0000 Subject: [PATCH 09/11] Add modbushttp plugin and battery control for Solplanet AI dongle --- plugin/modbushttp.go | 453 ++++++++++++++++++ .../definition/meter/solplanet-ai-dongle.yaml | 135 +++++- .../meter/solplanet-hybrid-http.yaml | 94 ---- .../definition/meter/solplanet-hybrid.yaml | 244 ---------- 4 files changed, 587 insertions(+), 339 deletions(-) create mode 100644 plugin/modbushttp.go delete mode 100644 templates/definition/meter/solplanet-hybrid-http.yaml delete mode 100644 templates/definition/meter/solplanet-hybrid.yaml diff --git a/plugin/modbushttp.go b/plugin/modbushttp.go new file mode 100644 index 00000000000..eea50894cfc --- /dev/null +++ b/plugin/modbushttp.go @@ -0,0 +1,453 @@ +package plugin + +// modbushttp: generic transport for devices that only expose Modbus-RTU +// tunneled through a vendor-specific HTTP/JSON endpoint (instead of real +// Modbus TCP/RTU). Originally built for the Solplanet/AISWEI "AI dongle" +// (fdbg.cgi on port 8484), but the framing/CRC/HTTP logic is generic and +// should work for any device using the same "raw RTU frame as hex, POSTed +// as JSON" pattern. +// +// Unlike a fixed set of pre-computed hex commands, this plugin computes the +// Modbus RTU frame (address, function code, register, value, CRC16) at +// runtime, so it supports arbitrary values - which is what makes variable +// charge/discharge power control possible. + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sync" + "time" + + "github.com/evcc-io/evcc/util" +) + +// modbus function codes we support +const ( + fcReadHoldingRegisters byte = 0x03 + fcWriteSingleRegister byte = 0x06 +) + +// decode/encode types for the register value +type registerType string + +const ( + typeUint16 registerType = "uint16" + typeInt16 registerType = "int16" +) + +// Register describes a single Modbus holding register on the device. +type Register struct { + Address uint16 `mapstructure:"address"` // plain register address, e.g. 1152 (NOT the 4xxxx Modicon notation) + Decode registerType `mapstructure:"decode"` // uint16 (default) or int16 + Scale float64 `mapstructure:"scale"` // applied before encoding / after decoding, default 1 +} + +// ModbusHttp is the modbushttp plugin. +type ModbusHttp struct { + *http.Client + log *util.Logger + uri string // e.g. http://192.168.1.50:8484/fdbg.cgi + host string // scheme+host+port, used as rate-limit key + slaveID byte + register Register + timeout time.Duration // overall budget for FloatGetter/FloatSetter, incl. retries + minInterval time.Duration +} + +func init() { + registry.AddCtx("modbushttp", NewModbusHttpFromConfig) +} + +// modbusHttpConfig is the raw YAML/mapstructure configuration. +type modbusHttpConfig struct { + URI string `mapstructure:"uri"` // http://:/fdbg.cgi + ID uint8 `mapstructure:"id"` // Modbus slave id, evcc convention default 1 + Register Register `mapstructure:"register"` + RequestTimeout time.Duration `mapstructure:"requestTimeout"` // per single HTTP request, default 5s + Timeout time.Duration `mapstructure:"timeout"` // overall budget incl. retries, default 30s + MinInterval time.Duration `mapstructure:"minInterval"` // minimum spacing between requests to the same dongle, default 2s +} + +// NewModbusHttpFromConfig creates a modbushttp plugin. +func NewModbusHttpFromConfig(ctx context.Context, other map[string]any) (Plugin, error) { + var cc modbusHttpConfig + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if cc.URI == "" { + return nil, fmt.Errorf("modbushttp: uri is required") + } + if cc.ID == 0 { + cc.ID = 1 + } + if cc.Register.Scale == 0 { + cc.Register.Scale = 1 + } + if cc.RequestTimeout == 0 { + cc.RequestTimeout = 20 * time.Second // observed real-world responses take up to ~11s; give more headroom to reduce retry frequency + } + if cc.Timeout == 0 { + cc.Timeout = 50 * time.Second // covers: request (up to 20s) + short pause (3s) + retry (up to 20s) + margin + } + if cc.MinInterval == 0 { + cc.MinInterval = 5 * time.Second // community reports: this dongle needs ~5s between any requests + } + + log := util.ContextLoggerWithDefault(ctx, util.NewLogger("modbushttp")) + + m := &ModbusHttp{ + Client: &http.Client{Timeout: cc.RequestTimeout}, + log: log, + uri: cc.URI, + host: hostKey(cc.URI), + slaveID: cc.ID, + register: cc.Register, + timeout: cc.Timeout, + minInterval: cc.MinInterval, + } + + return m, nil +} + +// --- rate limiting --- +// +// The Solplanet AI dongle (and likely similar ESP32-based dongles using the +// same fdbg.cgi pattern) is fragile under concurrent/rapid requests: multiple +// field reports describe the dongle becoming unresponsive for several +// minutes after being hit with requests in short succession - both via +// fdbg.cgi and the getdevdata.cgi JSON endpoint used for reads elsewhere in +// the template. Since evcc may run several modbushttp instances against the +// same dongle (e.g. one for the mode register, one for the setpoint +// register, invoked back-to-back from a "sequence" plugin), we serialize and +// throttle requests per host so the effective request rate to a given dongle +// never exceeds one request per minInterval, regardless of how many +// registers/instances are configured against it. + +var dongleGate = struct { + mu sync.Mutex + last map[string]time.Time +}{last: make(map[string]time.Time)} + +// throttle blocks until at least minInterval has passed since the last +// request to the same host, then reserves the current slot. host should be +// the scheme+host+port part of the URI (e.g. "http://192.168.1.50:8484") so +// that all registers/instances talking to the same physical dongle share the +// same budget. +func throttle(ctx context.Context, host string, minInterval time.Duration) error { + if minInterval <= 0 { + return nil + } + + for { + dongleGate.mu.Lock() + last, ok := dongleGate.last[host] + now := time.Now() + wait := time.Duration(0) + if ok { + if elapsed := now.Sub(last); elapsed < minInterval { + wait = minInterval - elapsed + } + } + if wait == 0 { + dongleGate.last[host] = now + dongleGate.mu.Unlock() + return nil + } + dongleGate.mu.Unlock() + + select { + case <-time.After(wait): + // retry - another goroutine may have taken the slot meanwhile + case <-ctx.Done(): + return ctx.Err() + } + } +} + +// hostKey extracts scheme+host+port from a full URI, e.g. +// "http://192.168.1.50:8484/fdbg.cgi" -> "http://192.168.1.50:8484" +func hostKey(rawURI string) string { + u, err := url.Parse(rawURI) + if err != nil { + return rawURI // fall back to full URI - still correct, just less shared + } + return u.Scheme + "://" + u.Host +} + +func crc16modbus(data []byte) uint16 { + var crc uint16 = 0xFFFF + for _, b := range data { + crc ^= uint16(b) + for i := 0; i < 8; i++ { + if crc&1 != 0 { + crc = (crc >> 1) ^ 0xA001 + } else { + crc >>= 1 + } + } + } + return crc +} + +// frame builds a full Modbus RTU frame (incl. CRC) as raw bytes. +func (m *ModbusHttp) frame(fc byte, address uint16, value uint16) []byte { + buf := make([]byte, 0, 8) + buf = append(buf, m.slaveID, fc) + buf = append(buf, byte(address>>8), byte(address&0xff)) + buf = append(buf, byte(value>>8), byte(value&0xff)) + + crc := crc16modbus(buf) + // CRC is transmitted low byte first + buf = append(buf, byte(crc&0xff), byte(crc>>8)) + + return buf +} + +// encode converts a float64 evcc value into the raw uint16 register payload, +// respecting sign (S16) and scale. +func encodeValue(v float64, scale float64, decode registerType) uint16 { + scaled := v / scale + if decode == typeInt16 { + return uint16(int16(scaled)) + } + return uint16(scaled) +} + +// decodeValue converts a raw uint16 register payload back into a float64, +// respecting sign (S16) and scale. +func decodeValue(raw uint16, scale float64, decode registerType) float64 { + if decode == typeInt16 { + return float64(int16(raw)) * scale + } + return float64(raw) * scale +} + +// dongleRequest is the JSON body the fdbg.cgi endpoint expects. +type dongleRequest struct { + Data string `json:"data"` +} + +// dongleResponse is the JSON body the fdbg.cgi endpoint returns. +// The exact shape of the response envelope varies by dongle firmware; +// adjust the field name / unwrapping below once verified against real +// hardware. What matters is that .data (or similar) contains the hex +// string of the raw Modbus RTU response frame, mirroring the request. +type dongleResponse struct { + Data string `json:"data"` +} + +// post sends a raw modbus RTU frame to the dongle and returns the raw +// response frame bytes. Respects the per-host rate limit (see throttle) +// before actually issuing the HTTP request. +// +// The dongle is a weak, cloud-first ESP32. Manual testing (Talend API +// Tester) shows that even a single, uncontended request can legitimately +// take up to ~11s to complete - this is normal for this hardware, not a +// sign of overload. requestTimeout is set generously above that observed +// ceiling so we don't cut off responses that are simply slow rather than +// lost. If a request still fails within that generous budget, a short +// pause before the single retry is enough to let a brief collision with +// other traffic clear, without adding excessive extra wait time on top of +// the already-generous per-request timeout. +const ( + maxRetries = 2 + retryBaseDelay = 3 * time.Second +) + +func (m *ModbusHttp) post(ctx context.Context, frame []byte) ([]byte, error) { + var lastErr error + + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + m.log.TRACE.Printf("retry %d/%d after %v (previous error: %v)", attempt+1, maxRetries, retryBaseDelay, lastErr) + select { + case <-time.After(retryBaseDelay): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + raw, err := m.doPost(ctx, frame) + if err == nil { + return raw, nil + } + lastErr = err + } + + return nil, fmt.Errorf("modbushttp: giving up after %d attempts: %w", maxRetries, lastErr) +} + +// doPost performs a single request/response round trip, without retries. +func (m *ModbusHttp) doPost(ctx context.Context, frame []byte) ([]byte, error) { + if err := throttle(ctx, m.host, m.minInterval); err != nil { + return nil, fmt.Errorf("modbushttp: rate limit wait: %w", err) + } + + reqHex := hex.EncodeToString(frame) + m.log.TRACE.Printf("send %s", reqHex) + + reqBody, err := json.Marshal(dongleRequest{Data: reqHex}) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.uri, bytes.NewReader(reqBody)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := m.Client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("modbushttp: unexpected status: %d", resp.StatusCode) + } + + var dr dongleResponse + if err := json.NewDecoder(resp.Body).Decode(&dr); err != nil { + return nil, err + } + + m.log.TRACE.Printf("recv %s", dr.Data) + + raw, err := hex.DecodeString(dr.Data) + if err != nil { + return nil, fmt.Errorf("modbushttp: invalid hex response: %w", err) + } + + return raw, nil +} + +// modbus exception codes, see Modbus Application Protocol spec §7 +var modbusExceptions = map[byte]string{ + 0x01: "illegal function", + 0x02: "illegal data address", + 0x03: "illegal data value", + 0x04: "server device failure", + 0x05: "acknowledge", + 0x06: "server device busy", + 0x0a: "gateway path unavailable", + 0x0b: "gateway target device failed to respond", +} + +// checkException returns a descriptive error if the response frame's function +// code has the exception bit (0x80) set, e.g. because a register address is +// invalid or the device rejected the write (wrong mode, out of range, ...). +func checkException(raw []byte) error { + if len(raw) < 3 { + return fmt.Errorf("modbushttp: response too short") + } + if raw[1]&0x80 == 0 { + return nil + } + name, ok := modbusExceptions[raw[2]] + if !ok { + name = fmt.Sprintf("unknown (0x%02x)", raw[2]) + } + return fmt.Errorf("modbushttp: modbus exception: %s", name) +} + +// parseReadResponse extracts the register value from a read-holding-registers +// RTU response frame: [id][fc][bytecount][data...][crc lo][crc hi] +func parseReadResponse(raw []byte) (uint16, error) { + if err := checkException(raw); err != nil { + return 0, err + } + if len(raw) < 5 { + return 0, fmt.Errorf("modbushttp: response too short") + } + if raw[1] != fcReadHoldingRegisters { + return 0, fmt.Errorf("modbushttp: unexpected function code 0x%02x", raw[1]) + } + byteCount := int(raw[2]) + if byteCount < 2 || len(raw) < 3+byteCount { + return 0, fmt.Errorf("modbushttp: malformed response") + } + return uint16(raw[3])<<8 | uint16(raw[4]), nil +} + +var _ FloatGetter = (*ModbusHttp)(nil) + +// FloatGetter implements the plugin read interface. +func (m *ModbusHttp) FloatGetter() (func() (float64, error), error) { + return func() (float64, error) { + ctx, cancel := context.WithTimeout(context.Background(), m.timeout) + defer cancel() + + f := m.frame(fcReadHoldingRegisters, m.register.Address, 1) // read 1 register + raw, err := m.post(ctx, f) + if err != nil { + return 0, err + } + + val, err := parseReadResponse(raw) + if err != nil { + return 0, err + } + + return decodeValue(val, m.register.Scale, m.register.Decode), nil + }, nil +} + +var _ IntGetter = (*ModbusHttp)(nil) + +// IntGetter wraps FloatGetter, mirroring the pattern in plugin/modbus.go. +func (m *ModbusHttp) IntGetter() (func() (int64, error), error) { + g, err := m.FloatGetter() + if err != nil { + return nil, err + } + + return func() (int64, error) { + f, err := g() + return int64(f), err + }, nil +} + +var _ FloatSetter = (*ModbusHttp)(nil) + +// FloatSetter implements the plugin write interface. +// This is what makes variable charge/discharge power possible: the value +// is encoded into the RTU frame and CRC computed fresh on every call, +// instead of relying on pre-baked hex strings for a handful of fixed values. +func (m *ModbusHttp) FloatSetter(param string) (func(float64) error, error) { + return func(v float64) error { + ctx, cancel := context.WithTimeout(context.Background(), m.timeout) + defer cancel() + + payload := encodeValue(v, m.register.Scale, m.register.Decode) + f := m.frame(fcWriteSingleRegister, m.register.Address, payload) + + raw, err := m.post(ctx, f) + if err != nil { + return err + } + return checkException(raw) + }, nil +} + +var _ IntSetter = (*ModbusHttp)(nil) + +// IntSetter implements the plugin write interface for integer values, e.g. +// enum-like registers such as the battery run mode (2=self-use, 4=custom). +// Mirrors the pattern in plugin/modbus.go (IntSetter wraps FloatSetter). +func (m *ModbusHttp) IntSetter(param string) (func(int64) error, error) { + set, err := m.FloatSetter(param) + if err != nil { + return nil, err + } + + return func(v int64) error { + return set(float64(v)) + }, nil +} diff --git a/templates/definition/meter/solplanet-ai-dongle.yaml b/templates/definition/meter/solplanet-ai-dongle.yaml index 979dbb046a4..a71ac0ca028 100644 --- a/templates/definition/meter/solplanet-ai-dongle.yaml +++ b/templates/definition/meter/solplanet-ai-dongle.yaml @@ -8,9 +8,22 @@ requirements: de: | Nutzt die lokale HTTP-Schnittstelle des AI-Dongle (z.B. BA121T-30) statt Modbus TCP. Die Seriennummer (isn) des Wechselrichters kann über `http://:8484/getdev.cgi?device=2` ausgelesen werden. + + Die Batteriesteuerung (normal/hold/charge/holdcharge) tunnelt rohe Modbus-RTU-Frames über die + ebenfalls undokumentierte `fdbg.cgi`-Schnittstelle mittels des `modbushttp`-Plugins + ([Hintergrund und weitere Tester in Diskussion #17680](https://github.com/evcc-io/evcc/discussions/17680)). + Ladeleistung und SOC-Grenzen sind über maxchargepower/minsoc/maxsoc konfigurierbar und werden zur + Laufzeit berechnet und CRC-geprüft statt fest kodierter Befehle zu nutzen. en: | Uses the AI-Dongle local HTTP API (e.g. BA121T-30) instead of Modbus TCP. The inverter serial number (isn) can be read via `http://:8484/getdev.cgi?device=2`. + + Battery control (normal/hold/charge/holdcharge) tunnels raw Modbus-RTU frames through the + equally undocumented `fdbg.cgi` endpoint using the `modbushttp` plugin + ([background and more testers in discussion #17680](https://github.com/evcc-io/evcc/discussions/17680)). + Charge power and SOC limits are configurable via maxchargepower/minsoc/maxsoc, computed and + CRC-checked at runtime rather than relying on pre-baked commands. +capabilities: ["battery-control"] params: - name: usage choice: ["grid", "pv", "battery"] @@ -20,7 +33,25 @@ params: en: Inverter serial number (isn) de: Seriennummer des Wechselrichters (isn) required: true + - name: slaveid + description: + en: Modbus slave ID + de: Modbus-Slave-ID + help: + en: Usually 3 for Solplanet/AISWEI hybrid inverters + de: Bei Solplanet/AISWEI-Hybrid-Wechselrichtern meist 3 + default: 3 + advanced: true - preset: battery-params + - name: maxchargepower + description: + en: Max. charge power + de: Max. Ladeleistung + help: + en: Used in watts when forcing a battery charge + de: In Watt, genutzt beim erzwungenen Laden + required: true + example: 5000 render: | type: custom {{- if eq .usage "grid" }} @@ -44,5 +75,107 @@ render: | source: http uri: http://{{ .host }}:8484/getdevdata.cgi?device=4&sn={{ .serial }} jq: .soc + batterymode: + source: switch + switch: + - case: 1 # normal / self-use + set: + source: sequence + set: + - source: const + value: 2 # 2 = self-use + set: + source: modbushttp + uri: http://{{ .host }}:8484/fdbg.cgi + id: {{ .slaveid }} + register: + address: 1103 # battery run mode + decode: uint16 + - source: const + value: {{ .maxsoc }} # reset charging SOC upper limit, undoes any prior holdcharge + set: + source: modbushttp + uri: http://{{ .host }}:8484/fdbg.cgi + id: {{ .slaveid }} + register: + address: 1153 # charging SOC upper limit, % + decode: uint16 + scale: 0.01 + - case: 2 # hold: custom mode + 0 W setpoint + set: + source: sequence + set: + - source: const + value: 4 # 4 = customer defined / manual power control + set: + source: modbushttp + uri: http://{{ .host }}:8484/fdbg.cgi + id: {{ .slaveid }} + register: + address: 1103 # battery run mode + decode: uint16 + - source: const + value: 0 + set: + source: modbushttp + uri: http://{{ .host }}:8484/fdbg.cgi + id: {{ .slaveid }} + register: + address: 1152 # battery power setpoint, W, positive=discharge/negative=charge + decode: int16 + - source: const + value: {{ .maxsoc }} # reset charging SOC upper limit, undoes any prior holdcharge + set: + source: modbushttp + uri: http://{{ .host }}:8484/fdbg.cgi + id: {{ .slaveid }} + register: + address: 1153 + decode: uint16 + scale: 0.01 + - case: 3 # charge at configured maxchargepower + set: + source: sequence + set: + - source: const + value: 4 + set: + source: modbushttp + uri: http://{{ .host }}:8484/fdbg.cgi + id: {{ .slaveid }} + register: + address: 1103 + decode: uint16 + - source: const + value: -{{ .maxchargepower }} # negative setpoint charges + set: + source: modbushttp + uri: http://{{ .host }}:8484/fdbg.cgi + id: {{ .slaveid }} + register: + address: 1152 + decode: int16 + - source: const + value: {{ .maxsoc }} # reset charging SOC upper limit, undoes any prior holdcharge + set: + source: modbushttp + uri: http://{{ .host }}:8484/fdbg.cgi + id: {{ .slaveid }} + register: + address: 1153 + decode: uint16 + scale: 0.01 + - case: 4 # holdcharge: cap charging at user-configured minsoc, discharge stays free + set: + source: const + value: {{ .minsoc }} + set: + source: modbushttp + uri: http://{{ .host }}:8484/fdbg.cgi + id: {{ .slaveid }} + register: + address: 1153 # charging SOC upper limit, % + decode: uint16 + scale: 0.01 {{- include "battery-params" . }} - {{- end }} + {{- end }} \ No newline at end of file diff --git a/templates/definition/meter/solplanet-hybrid-http.yaml b/templates/definition/meter/solplanet-hybrid-http.yaml deleted file mode 100644 index b5edf0f4456..00000000000 --- a/templates/definition/meter/solplanet-hybrid-http.yaml +++ /dev/null @@ -1,94 +0,0 @@ -template: solplanet-hybrid-http -products: - - brand: Solplanet - description: - en: ASW-H Hybrid Inverter (AI dongle, HTTP) - de: ASW-H Hybrid-Wechselrichter (AI-Dongle, HTTP) -requirements: - description: - en: | - For the AI LAN/WiFi dongle (V1, port 8484), which does not expose Modbus TCP/RTU directly. Reads use the dongle's undocumented `getdevdata.cgi` JSON endpoint, battery control tunnels raw Modbus-RTU frames through the equally undocumented `fdbg.cgi` endpoint ([background and more testers in discussion #17680](https://github.com/evcc-io/evcc/discussions/17680)). - - If the dongle exposes real Modbus (RS485, or TCP on a newer firmware), use the [solplanet-hybrid](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid.yaml) template instead - it supports a configurable charge power. - de: | - Für den AI LAN/WiFi-Dongle (V1, Port 8484), der kein Modbus TCP/RTU anbietet. Messwerte kommen über die undokumentierte `getdevdata.cgi`-JSON-Schnittstelle des Dongles, die Batteriesteuerung nutzt einen Tunnel für rohe Modbus-RTU-Frames über die ebenfalls undokumentierte `fdbg.cgi`-Schnittstelle ([Hintergrund und weitere Tester in Diskussion #17680](https://github.com/evcc-io/evcc/discussions/17680)). - - Falls der Dongle echtes Modbus anbietet (RS485 oder TCP bei neuerer Firmware), stattdessen das [solplanet-hybrid](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid.yaml)-Template verwenden - dort ist die Ladeleistung konfigurierbar. - evcc: ["skiptest"] -capabilities: ["battery-control"] -params: - - name: usage - choice: ["grid", "pv", "battery"] - - name: host - - name: serial - required: true - - preset: battery-params -render: | - type: custom - {{- if eq .usage "grid" }} - power: - source: http - uri: http://{{ .host }}:8484/getdevdata.cgi?device=3&sn={{ .serial }} - jq: .pac # grid power, W - {{- end }} - {{- if eq .usage "pv" }} - power: - source: http - uri: http://{{ .host }}:8484/getdevdata.cgi?device=4&sn={{ .serial }} - jq: .ppv # PV power, W - {{- end }} - {{- if eq .usage "battery" }} - power: - source: http - uri: http://{{ .host }}:8484/getdevdata.cgi?device=4&sn={{ .serial }} - jq: .pb # battery power, W - soc: - source: http - uri: http://{{ .host }}:8484/getdevdata.cgi?device=4&sn={{ .serial }} - jq: .soc # battery SOC, % - # batterymode disabled pending further verification - see discussion #17680 - # batterymode: - # source: switch - # switch: - # - case: 1 # normal - # set: - # source: http - # uri: http://{{ .host }}:8484/fdbg.cgi - # method: POST - # headers: - # Content-Type: application/json - # body: '{"data":"0306044f0002390e"}' # write reg 1103 (mode) = 2 (normal) - # - case: 2 # hold: custom mode + 0 W - # set: - # source: sequence - # set: - # - source: http - # uri: http://{{ .host }}:8484/fdbg.cgi - # method: POST - # headers: - # Content-Type: application/json - # body: '{"data":"0306044f0004b90c"}' # write reg 1103 (mode) = 4 (custom) - # - source: http - # uri: http://{{ .host }}:8484/fdbg.cgi - # method: POST - # headers: - # Content-Type: application/json - # body: '{"data":"03060480000088f0"}' # write reg 1152 (setpoint) = 0 W - # - case: 3 # charge: custom mode + fixed 5000 W (see requirements) - # set: - # source: sequence - # set: - # - source: http - # uri: http://{{ .host }}:8484/fdbg.cgi - # method: POST - # headers: - # Content-Type: application/json - # body: '{"data":"0306044f0004b90c"}' # write reg 1103 (mode) = 4 (custom) - # - source: http - # uri: http://{{ .host }}:8484/fdbg.cgi - # method: POST - # headers: - # Content-Type: application/json - # body: '{"data":"03060480ec78c412"}' # write reg 1152 (setpoint) = -5000 W - {{- include "battery-params" . }} - {{- end }} diff --git a/templates/definition/meter/solplanet-hybrid.yaml b/templates/definition/meter/solplanet-hybrid.yaml deleted file mode 100644 index d20641e99d5..00000000000 --- a/templates/definition/meter/solplanet-hybrid.yaml +++ /dev/null @@ -1,244 +0,0 @@ -template: solplanet-hybrid -products: - - brand: Solplanet - description: - en: ASW-H Hybrid Inverter - de: ASW-H Hybrid-Wechselrichter -requirements: - description: - en: | - Based on the vendor's [AISWEI Modbus documentation](https://github.com/evcc-io/evcc/discussions/17680) (Solplanet inverters are built by AISWEI). - - **Attention**: users report that the network dongle does not expose Modbus TCP directly (connection refused/reset), only the undocumented HTTP/JSON API on port 8484. Use an RS485 connection (directly or via a serial-to-TCP/USB adapter) unless the dongle's firmware has since added native Modbus TCP support. If only that dongle is used, use the [solplanet-hybrid-http](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid-http.yaml) template instead. - - Battery charge/discharge control is supported and has been confirmed working against real hardware ([source](https://github.com/evcc-io/evcc/pull/31480#issuecomment-4887359149)). - de: | - Basierend auf der [AISWEI Modbus-Dokumentation](https://github.com/evcc-io/evcc/discussions/17680) des Herstellers (Solplanet-Wechselrichter werden von AISWEI gefertigt). - - **Achtung**: Nutzer berichten, dass der Netzwerk-Dongle kein Modbus TCP anbietet (Verbindung wird abgelehnt/zurückgesetzt), sondern nur die undokumentierte HTTP/JSON-API auf Port 8484. Eine RS485-Verbindung verwenden (direkt oder über einen Seriell-zu-TCP/USB-Adapter), außer die Firmware des Dongles unterstützt inzwischen natives Modbus TCP. Falls nur dieser Dongle verwendet wird, stattdessen das [solplanet-hybrid-http](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/solplanet-hybrid-http.yaml)-Template verwenden. - - Die Batterie-Lade-/Entladesteuerung wird unterstützt und wurde an echter Hardware verifiziert ([Quelle](https://github.com/evcc-io/evcc/pull/31480#issuecomment-4887359149)). - evcc: ["skiptest"] -capabilities: ["battery-control"] -params: - - name: usage - choice: ["grid", "pv", "battery"] - - name: modbus - choice: ["rs485"] - - preset: battery-params - - name: maxchargepower - required: true - example: 5000 - - name: maxdischargepower - required: true - example: 5000 - - name: minsoc - default: 10 - - name: maxsoc - default: 100 -render: | - type: custom - {{- if eq .usage "grid" }} - power: - source: modbus - {{- include "modbus" . | indent 2 }} - connectdelay: 1s - register: - address: 35016 # Total system power (S32, W) - type: holding - decode: int32 - block: # 35001-35021 - register: 35001 - count: 21 - energy: - source: modbus - {{- include "modbus" . | indent 2 }} - register: - address: 35018 # Import Wh since last reset (U32, 0.1 kWh) - type: holding - decode: uint32 - block: # 35001-35021 - register: 35001 - count: 21 - scale: 0.1 - returnenergy: - source: modbus - {{- include "modbus" . | indent 2 }} - register: - address: 35020 # Export Wh since last reset (U32, 0.1 kWh) - type: holding - decode: uint32 - block: # 35001-35021 - register: 35001 - count: 21 - scale: 0.1 - currents: - - source: modbus - {{- include "modbus" . | indent 2 }} - register: - address: 35005 # Phase 1 current (U16, 0.1 A) - type: holding - decode: uint16 - block: # 35001-35021 - register: 35001 - count: 21 - scale: 0.1 - - source: modbus - {{- include "modbus" . | indent 2 }} - register: - address: 35006 # Phase 2 current (U16, 0.1 A) - type: holding - decode: uint16 - block: # 35001-35021 - register: 35001 - count: 21 - scale: 0.1 - - source: modbus - {{- include "modbus" . | indent 2 }} - register: - address: 35007 # Phase 3 current (U16, 0.1 A) - type: holding - decode: uint16 - block: # 35001-35021 - register: 35001 - count: 21 - scale: 0.1 - voltages: - - source: modbus - {{- include "modbus" . | indent 2 }} - register: - address: 35001 # Phase 1 line to neutral volts (U16, 0.1 V) - type: holding - decode: uint16 - block: # 35001-35021 - register: 35001 - count: 21 - scale: 0.1 - - source: modbus - {{- include "modbus" . | indent 2 }} - register: - address: 35002 # Phase 2 line to neutral volts (U16, 0.1 V) - type: holding - decode: uint16 - block: # 35001-35021 - register: 35001 - count: 21 - scale: 0.1 - - source: modbus - {{- include "modbus" . | indent 2 }} - register: - address: 35003 # Phase 3 line to neutral volts (U16, 0.1 V) - type: holding - decode: uint16 - block: # 35001-35021 - register: 35001 - count: 21 - scale: 0.1 - {{- end }} - {{- if eq .usage "pv" }} - power: - source: modbus - {{- include "modbus" . | indent 2 }} - connectdelay: 1s - register: - address: 31601 # PV total power (U32, W) - type: holding - decode: uint32 - block: # 31601-31606 - register: 31601 - count: 6 - energy: - source: modbus - {{- include "modbus" . | indent 2 }} - register: - address: 31605 # PV E-Total (U32, 0.1 kWh) - type: holding - decode: uint32 - block: # 31601-31606 - register: 31601 - count: 6 - scale: 0.1 - {{- end }} - {{- if eq .usage "battery" }} - power: - source: modbus - {{- include "modbus" . | indent 2 }} - connectdelay: 1s - register: - address: 31619 # BMS1 battery power (S32, W, sign convention unverified) - type: holding - decode: int32 - block: # 31617-31622 - register: 31617 - count: 6 - soc: - source: modbus - {{- include "modbus" . | indent 2 }} - register: - address: 31622 # BMS1 battery SOC (U16, 0.01 %) - type: holding - decode: uint16 - block: # 31617-31622 - register: 31617 - count: 6 - scale: 0.01 - batterymode: - source: switch - switch: - - case: 1 # normal - set: - source: const - value: 2 # normal operation - set: - source: modbus - {{- include "modbus" . | indent 8 }} - register: - address: 1103 # battery power control mode - type: writesingle - decode: uint16 - - case: 2 # hold - set: - source: sequence - set: - - source: const - value: 4 # manual power control - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 1103 # battery power control mode - type: writesingle - decode: uint16 - - source: const - value: 0 # setpoint 0 W - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 1152 # battery power setpoint, W, positive discharge/negative charge - type: writesingle - decode: int16 - - case: 3 # charge - set: - source: sequence - set: - - source: const - value: 4 # manual power control - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 1103 # battery power control mode - type: writesingle - decode: uint16 - - source: const - value: -{{ .maxchargepower }} # negative setpoint charges - set: - source: modbus - {{- include "modbus" . | indent 10 }} - register: - address: 1152 # battery power setpoint, W, positive discharge/negative charge - type: writesingle - decode: int16 - {{- include "battery-params" . }} - {{- end }} From a5307aaa6877e609d90ed80a1217980a8c1c64f0 Mon Sep 17 00:00:00 2001 From: mi-ch-a Date: Sat, 18 Jul 2026 22:57:32 +0200 Subject: [PATCH 10/11] Update templates/definition/meter/solplanet-ai-dongle.yaml Co-authored-by: andig --- templates/definition/meter/solplanet-ai-dongle.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/templates/definition/meter/solplanet-ai-dongle.yaml b/templates/definition/meter/solplanet-ai-dongle.yaml index a71ac0ca028..cefd22e96d3 100644 --- a/templates/definition/meter/solplanet-ai-dongle.yaml +++ b/templates/definition/meter/solplanet-ai-dongle.yaml @@ -37,9 +37,6 @@ params: description: en: Modbus slave ID de: Modbus-Slave-ID - help: - en: Usually 3 for Solplanet/AISWEI hybrid inverters - de: Bei Solplanet/AISWEI-Hybrid-Wechselrichtern meist 3 default: 3 advanced: true - preset: battery-params From 4a9e167be0b2b85795aa698231bbf90ce89d529d Mon Sep 17 00:00:00 2001 From: Micha Date: Mon, 20 Jul 2026 10:12:30 +0000 Subject: [PATCH 11/11] Simplify: use fixed pre-computed hex commands via http plugin instead of modbushttp plugin, per andig's feedback Added explicit timeout: 20s to each http source (not in the original #31480 draft) - real hardware testing showed the dongle's fdbg.cgi responses can take up to ~11s, which risks spurious timeouts with a shorter default. --- plugin/modbushttp.go | 453 ------------------ .../definition/meter/solplanet-ai-dongle.yaml | 159 ++---- 2 files changed, 44 insertions(+), 568 deletions(-) delete mode 100644 plugin/modbushttp.go diff --git a/plugin/modbushttp.go b/plugin/modbushttp.go deleted file mode 100644 index eea50894cfc..00000000000 --- a/plugin/modbushttp.go +++ /dev/null @@ -1,453 +0,0 @@ -package plugin - -// modbushttp: generic transport for devices that only expose Modbus-RTU -// tunneled through a vendor-specific HTTP/JSON endpoint (instead of real -// Modbus TCP/RTU). Originally built for the Solplanet/AISWEI "AI dongle" -// (fdbg.cgi on port 8484), but the framing/CRC/HTTP logic is generic and -// should work for any device using the same "raw RTU frame as hex, POSTed -// as JSON" pattern. -// -// Unlike a fixed set of pre-computed hex commands, this plugin computes the -// Modbus RTU frame (address, function code, register, value, CRC16) at -// runtime, so it supports arbitrary values - which is what makes variable -// charge/discharge power control possible. - -import ( - "bytes" - "context" - "encoding/hex" - "encoding/json" - "fmt" - "net/http" - "net/url" - "sync" - "time" - - "github.com/evcc-io/evcc/util" -) - -// modbus function codes we support -const ( - fcReadHoldingRegisters byte = 0x03 - fcWriteSingleRegister byte = 0x06 -) - -// decode/encode types for the register value -type registerType string - -const ( - typeUint16 registerType = "uint16" - typeInt16 registerType = "int16" -) - -// Register describes a single Modbus holding register on the device. -type Register struct { - Address uint16 `mapstructure:"address"` // plain register address, e.g. 1152 (NOT the 4xxxx Modicon notation) - Decode registerType `mapstructure:"decode"` // uint16 (default) or int16 - Scale float64 `mapstructure:"scale"` // applied before encoding / after decoding, default 1 -} - -// ModbusHttp is the modbushttp plugin. -type ModbusHttp struct { - *http.Client - log *util.Logger - uri string // e.g. http://192.168.1.50:8484/fdbg.cgi - host string // scheme+host+port, used as rate-limit key - slaveID byte - register Register - timeout time.Duration // overall budget for FloatGetter/FloatSetter, incl. retries - minInterval time.Duration -} - -func init() { - registry.AddCtx("modbushttp", NewModbusHttpFromConfig) -} - -// modbusHttpConfig is the raw YAML/mapstructure configuration. -type modbusHttpConfig struct { - URI string `mapstructure:"uri"` // http://:/fdbg.cgi - ID uint8 `mapstructure:"id"` // Modbus slave id, evcc convention default 1 - Register Register `mapstructure:"register"` - RequestTimeout time.Duration `mapstructure:"requestTimeout"` // per single HTTP request, default 5s - Timeout time.Duration `mapstructure:"timeout"` // overall budget incl. retries, default 30s - MinInterval time.Duration `mapstructure:"minInterval"` // minimum spacing between requests to the same dongle, default 2s -} - -// NewModbusHttpFromConfig creates a modbushttp plugin. -func NewModbusHttpFromConfig(ctx context.Context, other map[string]any) (Plugin, error) { - var cc modbusHttpConfig - if err := util.DecodeOther(other, &cc); err != nil { - return nil, err - } - - if cc.URI == "" { - return nil, fmt.Errorf("modbushttp: uri is required") - } - if cc.ID == 0 { - cc.ID = 1 - } - if cc.Register.Scale == 0 { - cc.Register.Scale = 1 - } - if cc.RequestTimeout == 0 { - cc.RequestTimeout = 20 * time.Second // observed real-world responses take up to ~11s; give more headroom to reduce retry frequency - } - if cc.Timeout == 0 { - cc.Timeout = 50 * time.Second // covers: request (up to 20s) + short pause (3s) + retry (up to 20s) + margin - } - if cc.MinInterval == 0 { - cc.MinInterval = 5 * time.Second // community reports: this dongle needs ~5s between any requests - } - - log := util.ContextLoggerWithDefault(ctx, util.NewLogger("modbushttp")) - - m := &ModbusHttp{ - Client: &http.Client{Timeout: cc.RequestTimeout}, - log: log, - uri: cc.URI, - host: hostKey(cc.URI), - slaveID: cc.ID, - register: cc.Register, - timeout: cc.Timeout, - minInterval: cc.MinInterval, - } - - return m, nil -} - -// --- rate limiting --- -// -// The Solplanet AI dongle (and likely similar ESP32-based dongles using the -// same fdbg.cgi pattern) is fragile under concurrent/rapid requests: multiple -// field reports describe the dongle becoming unresponsive for several -// minutes after being hit with requests in short succession - both via -// fdbg.cgi and the getdevdata.cgi JSON endpoint used for reads elsewhere in -// the template. Since evcc may run several modbushttp instances against the -// same dongle (e.g. one for the mode register, one for the setpoint -// register, invoked back-to-back from a "sequence" plugin), we serialize and -// throttle requests per host so the effective request rate to a given dongle -// never exceeds one request per minInterval, regardless of how many -// registers/instances are configured against it. - -var dongleGate = struct { - mu sync.Mutex - last map[string]time.Time -}{last: make(map[string]time.Time)} - -// throttle blocks until at least minInterval has passed since the last -// request to the same host, then reserves the current slot. host should be -// the scheme+host+port part of the URI (e.g. "http://192.168.1.50:8484") so -// that all registers/instances talking to the same physical dongle share the -// same budget. -func throttle(ctx context.Context, host string, minInterval time.Duration) error { - if minInterval <= 0 { - return nil - } - - for { - dongleGate.mu.Lock() - last, ok := dongleGate.last[host] - now := time.Now() - wait := time.Duration(0) - if ok { - if elapsed := now.Sub(last); elapsed < minInterval { - wait = minInterval - elapsed - } - } - if wait == 0 { - dongleGate.last[host] = now - dongleGate.mu.Unlock() - return nil - } - dongleGate.mu.Unlock() - - select { - case <-time.After(wait): - // retry - another goroutine may have taken the slot meanwhile - case <-ctx.Done(): - return ctx.Err() - } - } -} - -// hostKey extracts scheme+host+port from a full URI, e.g. -// "http://192.168.1.50:8484/fdbg.cgi" -> "http://192.168.1.50:8484" -func hostKey(rawURI string) string { - u, err := url.Parse(rawURI) - if err != nil { - return rawURI // fall back to full URI - still correct, just less shared - } - return u.Scheme + "://" + u.Host -} - -func crc16modbus(data []byte) uint16 { - var crc uint16 = 0xFFFF - for _, b := range data { - crc ^= uint16(b) - for i := 0; i < 8; i++ { - if crc&1 != 0 { - crc = (crc >> 1) ^ 0xA001 - } else { - crc >>= 1 - } - } - } - return crc -} - -// frame builds a full Modbus RTU frame (incl. CRC) as raw bytes. -func (m *ModbusHttp) frame(fc byte, address uint16, value uint16) []byte { - buf := make([]byte, 0, 8) - buf = append(buf, m.slaveID, fc) - buf = append(buf, byte(address>>8), byte(address&0xff)) - buf = append(buf, byte(value>>8), byte(value&0xff)) - - crc := crc16modbus(buf) - // CRC is transmitted low byte first - buf = append(buf, byte(crc&0xff), byte(crc>>8)) - - return buf -} - -// encode converts a float64 evcc value into the raw uint16 register payload, -// respecting sign (S16) and scale. -func encodeValue(v float64, scale float64, decode registerType) uint16 { - scaled := v / scale - if decode == typeInt16 { - return uint16(int16(scaled)) - } - return uint16(scaled) -} - -// decodeValue converts a raw uint16 register payload back into a float64, -// respecting sign (S16) and scale. -func decodeValue(raw uint16, scale float64, decode registerType) float64 { - if decode == typeInt16 { - return float64(int16(raw)) * scale - } - return float64(raw) * scale -} - -// dongleRequest is the JSON body the fdbg.cgi endpoint expects. -type dongleRequest struct { - Data string `json:"data"` -} - -// dongleResponse is the JSON body the fdbg.cgi endpoint returns. -// The exact shape of the response envelope varies by dongle firmware; -// adjust the field name / unwrapping below once verified against real -// hardware. What matters is that .data (or similar) contains the hex -// string of the raw Modbus RTU response frame, mirroring the request. -type dongleResponse struct { - Data string `json:"data"` -} - -// post sends a raw modbus RTU frame to the dongle and returns the raw -// response frame bytes. Respects the per-host rate limit (see throttle) -// before actually issuing the HTTP request. -// -// The dongle is a weak, cloud-first ESP32. Manual testing (Talend API -// Tester) shows that even a single, uncontended request can legitimately -// take up to ~11s to complete - this is normal for this hardware, not a -// sign of overload. requestTimeout is set generously above that observed -// ceiling so we don't cut off responses that are simply slow rather than -// lost. If a request still fails within that generous budget, a short -// pause before the single retry is enough to let a brief collision with -// other traffic clear, without adding excessive extra wait time on top of -// the already-generous per-request timeout. -const ( - maxRetries = 2 - retryBaseDelay = 3 * time.Second -) - -func (m *ModbusHttp) post(ctx context.Context, frame []byte) ([]byte, error) { - var lastErr error - - for attempt := 0; attempt < maxRetries; attempt++ { - if attempt > 0 { - m.log.TRACE.Printf("retry %d/%d after %v (previous error: %v)", attempt+1, maxRetries, retryBaseDelay, lastErr) - select { - case <-time.After(retryBaseDelay): - case <-ctx.Done(): - return nil, ctx.Err() - } - } - - raw, err := m.doPost(ctx, frame) - if err == nil { - return raw, nil - } - lastErr = err - } - - return nil, fmt.Errorf("modbushttp: giving up after %d attempts: %w", maxRetries, lastErr) -} - -// doPost performs a single request/response round trip, without retries. -func (m *ModbusHttp) doPost(ctx context.Context, frame []byte) ([]byte, error) { - if err := throttle(ctx, m.host, m.minInterval); err != nil { - return nil, fmt.Errorf("modbushttp: rate limit wait: %w", err) - } - - reqHex := hex.EncodeToString(frame) - m.log.TRACE.Printf("send %s", reqHex) - - reqBody, err := json.Marshal(dongleRequest{Data: reqHex}) - if err != nil { - return nil, err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.uri, bytes.NewReader(reqBody)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - - resp, err := m.Client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("modbushttp: unexpected status: %d", resp.StatusCode) - } - - var dr dongleResponse - if err := json.NewDecoder(resp.Body).Decode(&dr); err != nil { - return nil, err - } - - m.log.TRACE.Printf("recv %s", dr.Data) - - raw, err := hex.DecodeString(dr.Data) - if err != nil { - return nil, fmt.Errorf("modbushttp: invalid hex response: %w", err) - } - - return raw, nil -} - -// modbus exception codes, see Modbus Application Protocol spec §7 -var modbusExceptions = map[byte]string{ - 0x01: "illegal function", - 0x02: "illegal data address", - 0x03: "illegal data value", - 0x04: "server device failure", - 0x05: "acknowledge", - 0x06: "server device busy", - 0x0a: "gateway path unavailable", - 0x0b: "gateway target device failed to respond", -} - -// checkException returns a descriptive error if the response frame's function -// code has the exception bit (0x80) set, e.g. because a register address is -// invalid or the device rejected the write (wrong mode, out of range, ...). -func checkException(raw []byte) error { - if len(raw) < 3 { - return fmt.Errorf("modbushttp: response too short") - } - if raw[1]&0x80 == 0 { - return nil - } - name, ok := modbusExceptions[raw[2]] - if !ok { - name = fmt.Sprintf("unknown (0x%02x)", raw[2]) - } - return fmt.Errorf("modbushttp: modbus exception: %s", name) -} - -// parseReadResponse extracts the register value from a read-holding-registers -// RTU response frame: [id][fc][bytecount][data...][crc lo][crc hi] -func parseReadResponse(raw []byte) (uint16, error) { - if err := checkException(raw); err != nil { - return 0, err - } - if len(raw) < 5 { - return 0, fmt.Errorf("modbushttp: response too short") - } - if raw[1] != fcReadHoldingRegisters { - return 0, fmt.Errorf("modbushttp: unexpected function code 0x%02x", raw[1]) - } - byteCount := int(raw[2]) - if byteCount < 2 || len(raw) < 3+byteCount { - return 0, fmt.Errorf("modbushttp: malformed response") - } - return uint16(raw[3])<<8 | uint16(raw[4]), nil -} - -var _ FloatGetter = (*ModbusHttp)(nil) - -// FloatGetter implements the plugin read interface. -func (m *ModbusHttp) FloatGetter() (func() (float64, error), error) { - return func() (float64, error) { - ctx, cancel := context.WithTimeout(context.Background(), m.timeout) - defer cancel() - - f := m.frame(fcReadHoldingRegisters, m.register.Address, 1) // read 1 register - raw, err := m.post(ctx, f) - if err != nil { - return 0, err - } - - val, err := parseReadResponse(raw) - if err != nil { - return 0, err - } - - return decodeValue(val, m.register.Scale, m.register.Decode), nil - }, nil -} - -var _ IntGetter = (*ModbusHttp)(nil) - -// IntGetter wraps FloatGetter, mirroring the pattern in plugin/modbus.go. -func (m *ModbusHttp) IntGetter() (func() (int64, error), error) { - g, err := m.FloatGetter() - if err != nil { - return nil, err - } - - return func() (int64, error) { - f, err := g() - return int64(f), err - }, nil -} - -var _ FloatSetter = (*ModbusHttp)(nil) - -// FloatSetter implements the plugin write interface. -// This is what makes variable charge/discharge power possible: the value -// is encoded into the RTU frame and CRC computed fresh on every call, -// instead of relying on pre-baked hex strings for a handful of fixed values. -func (m *ModbusHttp) FloatSetter(param string) (func(float64) error, error) { - return func(v float64) error { - ctx, cancel := context.WithTimeout(context.Background(), m.timeout) - defer cancel() - - payload := encodeValue(v, m.register.Scale, m.register.Decode) - f := m.frame(fcWriteSingleRegister, m.register.Address, payload) - - raw, err := m.post(ctx, f) - if err != nil { - return err - } - return checkException(raw) - }, nil -} - -var _ IntSetter = (*ModbusHttp)(nil) - -// IntSetter implements the plugin write interface for integer values, e.g. -// enum-like registers such as the battery run mode (2=self-use, 4=custom). -// Mirrors the pattern in plugin/modbus.go (IntSetter wraps FloatSetter). -func (m *ModbusHttp) IntSetter(param string) (func(int64) error, error) { - set, err := m.FloatSetter(param) - if err != nil { - return nil, err - } - - return func(v int64) error { - return set(float64(v)) - }, nil -} diff --git a/templates/definition/meter/solplanet-ai-dongle.yaml b/templates/definition/meter/solplanet-ai-dongle.yaml index cefd22e96d3..b16ccb7b3d4 100644 --- a/templates/definition/meter/solplanet-ai-dongle.yaml +++ b/templates/definition/meter/solplanet-ai-dongle.yaml @@ -9,20 +9,18 @@ requirements: Nutzt die lokale HTTP-Schnittstelle des AI-Dongle (z.B. BA121T-30) statt Modbus TCP. Die Seriennummer (isn) des Wechselrichters kann über `http://:8484/getdev.cgi?device=2` ausgelesen werden. - Die Batteriesteuerung (normal/hold/charge/holdcharge) tunnelt rohe Modbus-RTU-Frames über die - ebenfalls undokumentierte `fdbg.cgi`-Schnittstelle mittels des `modbushttp`-Plugins + Die Batteriesteuerung (normal/hold/charge) nutzt fest vorberechnete Modbus-RTU-Befehle, + die über die ebenfalls undokumentierte `fdbg.cgi`-Schnittstelle gesendet werden ([Hintergrund und weitere Tester in Diskussion #17680](https://github.com/evcc-io/evcc/discussions/17680)). - Ladeleistung und SOC-Grenzen sind über maxchargepower/minsoc/maxsoc konfigurierbar und werden zur - Laufzeit berechnet und CRC-geprüft statt fest kodierter Befehle zu nutzen. + Da die Befehle fest kodiert sind, ist die Ladeleistung auf 5000 W fixiert. en: | Uses the AI-Dongle local HTTP API (e.g. BA121T-30) instead of Modbus TCP. The inverter serial number (isn) can be read via `http://:8484/getdev.cgi?device=2`. - Battery control (normal/hold/charge/holdcharge) tunnels raw Modbus-RTU frames through the - equally undocumented `fdbg.cgi` endpoint using the `modbushttp` plugin + Battery control (normal/hold/charge) uses fixed, pre-computed Modbus-RTU commands sent + through the equally undocumented `fdbg.cgi` endpoint ([background and more testers in discussion #17680](https://github.com/evcc-io/evcc/discussions/17680)). - Charge power and SOC limits are configurable via maxchargepower/minsoc/maxsoc, computed and - CRC-checked at runtime rather than relying on pre-baked commands. + Because the commands are fixed, charge power is pinned to 5000 W. capabilities: ["battery-control"] params: - name: usage @@ -33,22 +31,7 @@ params: en: Inverter serial number (isn) de: Seriennummer des Wechselrichters (isn) required: true - - name: slaveid - description: - en: Modbus slave ID - de: Modbus-Slave-ID - default: 3 - advanced: true - preset: battery-params - - name: maxchargepower - description: - en: Max. charge power - de: Max. Ladeleistung - help: - en: Used in watts when forcing a battery charge - de: In Watt, genutzt beim erzwungenen Laden - required: true - example: 5000 render: | type: custom {{- if eq .usage "grid" }} @@ -75,104 +58,50 @@ render: | batterymode: source: switch switch: - - case: 1 # normal / self-use + - case: 1 # normal set: - source: sequence - set: - - source: const - value: 2 # 2 = self-use - set: - source: modbushttp - uri: http://{{ .host }}:8484/fdbg.cgi - id: {{ .slaveid }} - register: - address: 1103 # battery run mode - decode: uint16 - - source: const - value: {{ .maxsoc }} # reset charging SOC upper limit, undoes any prior holdcharge - set: - source: modbushttp - uri: http://{{ .host }}:8484/fdbg.cgi - id: {{ .slaveid }} - register: - address: 1153 # charging SOC upper limit, % - decode: uint16 - scale: 0.01 - - case: 2 # hold: custom mode + 0 W setpoint + source: http + uri: http://{{ .host }}:8484/fdbg.cgi + method: POST + timeout: 20s + headers: + Content-Type: application/json + body: '{"data":"0306044f0002390e"}' # write reg 1103 (mode) = 2 (normal) + - case: 2 # hold: custom mode + 0 W set: source: sequence set: - - source: const - value: 4 # 4 = customer defined / manual power control - set: - source: modbushttp - uri: http://{{ .host }}:8484/fdbg.cgi - id: {{ .slaveid }} - register: - address: 1103 # battery run mode - decode: uint16 - - source: const - value: 0 - set: - source: modbushttp - uri: http://{{ .host }}:8484/fdbg.cgi - id: {{ .slaveid }} - register: - address: 1152 # battery power setpoint, W, positive=discharge/negative=charge - decode: int16 - - source: const - value: {{ .maxsoc }} # reset charging SOC upper limit, undoes any prior holdcharge - set: - source: modbushttp - uri: http://{{ .host }}:8484/fdbg.cgi - id: {{ .slaveid }} - register: - address: 1153 - decode: uint16 - scale: 0.01 - - case: 3 # charge at configured maxchargepower + - source: http + uri: http://{{ .host }}:8484/fdbg.cgi + method: POST + timeout: 20s + headers: + Content-Type: application/json + body: '{"data":"0306044f0004b90c"}' # write reg 1103 (mode) = 4 (custom) + - source: http + uri: http://{{ .host }}:8484/fdbg.cgi + method: POST + timeout: 20s + headers: + Content-Type: application/json + body: '{"data":"03060480000088f0"}' # write reg 1152 (setpoint) = 0 W + - case: 3 # charge: custom mode + fixed 5000 W (see requirements) set: source: sequence set: - - source: const - value: 4 - set: - source: modbushttp - uri: http://{{ .host }}:8484/fdbg.cgi - id: {{ .slaveid }} - register: - address: 1103 - decode: uint16 - - source: const - value: -{{ .maxchargepower }} # negative setpoint charges - set: - source: modbushttp - uri: http://{{ .host }}:8484/fdbg.cgi - id: {{ .slaveid }} - register: - address: 1152 - decode: int16 - - source: const - value: {{ .maxsoc }} # reset charging SOC upper limit, undoes any prior holdcharge - set: - source: modbushttp - uri: http://{{ .host }}:8484/fdbg.cgi - id: {{ .slaveid }} - register: - address: 1153 - decode: uint16 - scale: 0.01 - - case: 4 # holdcharge: cap charging at user-configured minsoc, discharge stays free - set: - source: const - value: {{ .minsoc }} - set: - source: modbushttp - uri: http://{{ .host }}:8484/fdbg.cgi - id: {{ .slaveid }} - register: - address: 1153 # charging SOC upper limit, % - decode: uint16 - scale: 0.01 + - source: http + uri: http://{{ .host }}:8484/fdbg.cgi + method: POST + timeout: 20s + headers: + Content-Type: application/json + body: '{"data":"0306044f0004b90c"}' # write reg 1103 (mode) = 4 (custom) + - source: http + uri: http://{{ .host }}:8484/fdbg.cgi + method: POST + timeout: 20s + headers: + Content-Type: application/json + body: '{"data":"03060480ec78c412"}' # write reg 1152 (setpoint) = -5000 W {{- include "battery-params" . }} {{- end }} \ No newline at end of file