Solplanet AI dongle: add battery control via modbushttp plugin - #31908
Solplanet AI dongle: add battery control via modbushttp plugin#31908mi-ch-a wants to merge 15 commits into
Conversation
fixes evcc-io#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.
…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 evcc-io#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.
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- The Modbus RTU responses are currently only minimally validated (length and exception bit); consider also verifying CRC and matching slave ID/function code to detect corrupted or mismatched responses before treating a write/read as successful.
- encodeValue/decodeValue implicitly truncate and wrap values (e.g. non-integer v/scale, or values outside int16/uint16 range) without any checks; adding range/rounding validation would help catch misconfigured registers (wrong scale/type) before sending invalid frames to the device.
- The documentation comments in modbusHttpConfig for RequestTimeout/Timeout/MinInterval still mention the old default values; updating those to reflect the actual defaults (20s/50s/5s) will keep future readers from being confused when tuning these parameters.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The Modbus RTU responses are currently only minimally validated (length and exception bit); consider also verifying CRC and matching slave ID/function code to detect corrupted or mismatched responses before treating a write/read as successful.
- encodeValue/decodeValue implicitly truncate and wrap values (e.g. non-integer v/scale, or values outside int16/uint16 range) without any checks; adding range/rounding validation would help catch misconfigured registers (wrong scale/type) before sending invalid frames to the device.
- The documentation comments in modbusHttpConfig for RequestTimeout/Timeout/MinInterval still mention the old default values; updating those to reflect the actual defaults (20s/50s/5s) will keep future readers from being confused when tuning these parameters.
## Individual Comments
### Comment 1
<location path="templates/definition/meter/solplanet-ai-dongle.yaml" line_range="45-55" />
<code_context>
+ 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
</code_context>
<issue_to_address>
**suggestion:** Make maxchargepower optional or scoped to battery usage to avoid forcing it for non-battery meters.
`maxchargepower` is currently `required: true` in the global `params`, but the template allows `usage: [
```suggestion
- 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
usage: ["battery"]
example: 5000
render: |
```
</issue_to_address>
### Comment 2
<location path="plugin/modbushttp.go" line_range="362-371" />
<code_context>
+
+// 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
+}
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Validate slave ID and CRC in read responses to harden protocol handling.
This only validates the function code and basic frame shape. It should also verify that the response unit ID matches the requested slave and that the CRC is correct, especially given the unreliable ESP32/transport context and the fact you’re already computing CRCs for requests.
Suggested implementation:
```golang
// 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, slaveID byte) (uint16, error) {
if err := checkException(raw); err != nil {
return 0, err
}
// Minimum RTU frame for a single-register read response:
// [id][fc][bytecount=2][data_hi][data_lo][crc_lo][crc_hi] => 7 bytes
if len(raw) < 7 {
return 0, fmt.Errorf("modbushttp: response too short")
}
// Validate unit/slave ID
if raw[0] != slaveID {
return 0, fmt.Errorf("modbushttp: unexpected slave id 0x%02x (expected 0x%02x)", raw[0], slaveID)
}
// Validate function code
if raw[1] != fcReadHoldingRegisters {
return 0, fmt.Errorf("modbushttp: unexpected function code 0x%02x", raw[1])
}
byteCount := int(raw[2])
if byteCount < 2 {
return 0, fmt.Errorf("modbushttp: malformed response: byte count %d", byteCount)
}
// Full frame must include: 3 header bytes + byteCount data bytes + 2 CRC bytes
if len(raw) < 3+byteCount+2 {
return 0, fmt.Errorf("modbushttp: malformed response: length %d, expected at least %d", len(raw), 3+byteCount+2)
}
// Verify CRC over all bytes except the last two CRC bytes.
frameWithoutCRC := raw[:len(raw)-2]
expectedCRC := computeCRC16RTU(frameWithoutCRC)
// CRC layout: [crc lo][crc hi]
gotCRC := uint16(raw[len(raw)-2]) | uint16(raw[len(raw)-1])<<8
if gotCRC != expectedCRC {
return 0, fmt.Errorf("modbushttp: invalid CRC (got 0x%04x, expected 0x%04x)", gotCRC, expectedCRC)
}
// Extract the first register value from the data payload.
dataOffset := 3
if byteCount < 2 {
return 0, fmt.Errorf("modbushttp: malformed response: not enough data for register")
}
value := uint16(raw[dataOffset])<<8 | uint16(raw[dataOffset+1])
return value, nil
}
// computeCRC16RTU computes the Modbus RTU CRC16 for the given payload.
// Polynomial: 0xA001, initial value: 0xFFFF, LSB-first as per Modbus spec.
func computeCRC16RTU(data []byte) uint16 {
const (
poly = 0xA001
initCRC = 0xFFFF
)
crc := uint16(initCRC)
for _, b := range data {
crc ^= uint16(b)
for i := 0; i < 8; i++ {
if crc&0x0001 != 0 {
crc = (crc >> 1) ^ poly
} else {
crc >>= 1
}
}
}
return crc
}
```
1. The signature of `parseReadResponse` has changed from `func parseReadResponse(raw []byte) (uint16, error)` to `func parseReadResponse(raw []byte, slaveID byte) (uint16, error)`. All call sites in `plugin/modbushttp.go` (and any other files) must be updated to pass the expected slave ID/unit ID.
2. If the codebase already has a shared CRC helper for Modbus RTU frames (e.g. a function used when building request frames), you should:
- Remove `computeCRC16RTU` from this file.
- Replace its usage with the existing helper to avoid duplicating CRC logic and to stay consistent with the rest of the codebase.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| 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]) | ||
| } |
There was a problem hiding this comment.
suggestion (bug_risk): Validate slave ID and CRC in read responses to harden protocol handling.
This only validates the function code and basic frame shape. It should also verify that the response unit ID matches the requested slave and that the CRC is correct, especially given the unreliable ESP32/transport context and the fact you’re already computing CRCs for requests.
Suggested implementation:
// 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, slaveID byte) (uint16, error) {
if err := checkException(raw); err != nil {
return 0, err
}
// Minimum RTU frame for a single-register read response:
// [id][fc][bytecount=2][data_hi][data_lo][crc_lo][crc_hi] => 7 bytes
if len(raw) < 7 {
return 0, fmt.Errorf("modbushttp: response too short")
}
// Validate unit/slave ID
if raw[0] != slaveID {
return 0, fmt.Errorf("modbushttp: unexpected slave id 0x%02x (expected 0x%02x)", raw[0], slaveID)
}
// Validate function code
if raw[1] != fcReadHoldingRegisters {
return 0, fmt.Errorf("modbushttp: unexpected function code 0x%02x", raw[1])
}
byteCount := int(raw[2])
if byteCount < 2 {
return 0, fmt.Errorf("modbushttp: malformed response: byte count %d", byteCount)
}
// Full frame must include: 3 header bytes + byteCount data bytes + 2 CRC bytes
if len(raw) < 3+byteCount+2 {
return 0, fmt.Errorf("modbushttp: malformed response: length %d, expected at least %d", len(raw), 3+byteCount+2)
}
// Verify CRC over all bytes except the last two CRC bytes.
frameWithoutCRC := raw[:len(raw)-2]
expectedCRC := computeCRC16RTU(frameWithoutCRC)
// CRC layout: [crc lo][crc hi]
gotCRC := uint16(raw[len(raw)-2]) | uint16(raw[len(raw)-1])<<8
if gotCRC != expectedCRC {
return 0, fmt.Errorf("modbushttp: invalid CRC (got 0x%04x, expected 0x%04x)", gotCRC, expectedCRC)
}
// Extract the first register value from the data payload.
dataOffset := 3
if byteCount < 2 {
return 0, fmt.Errorf("modbushttp: malformed response: not enough data for register")
}
value := uint16(raw[dataOffset])<<8 | uint16(raw[dataOffset+1])
return value, nil
}
// computeCRC16RTU computes the Modbus RTU CRC16 for the given payload.
// Polynomial: 0xA001, initial value: 0xFFFF, LSB-first as per Modbus spec.
func computeCRC16RTU(data []byte) uint16 {
const (
poly = 0xA001
initCRC = 0xFFFF
)
crc := uint16(initCRC)
for _, b := range data {
crc ^= uint16(b)
for i := 0; i < 8; i++ {
if crc&0x0001 != 0 {
crc = (crc >> 1) ^ poly
} else {
crc >>= 1
}
}
}
return crc
}- The signature of
parseReadResponsehas changed fromfunc parseReadResponse(raw []byte) (uint16, error)tofunc parseReadResponse(raw []byte, slaveID byte) (uint16, error). All call sites inplugin/modbushttp.go(and any other files) must be updated to pass the expected slave ID/unit ID. - If the codebase already has a shared CRC helper for Modbus RTU frames (e.g. a function used when building request frames), you should:
- Remove
computeCRC16RTUfrom this file. - Replace its usage with the existing helper to avoid duplicating CRC logic and to stay consistent with the rest of the codebase.
- Remove
Co-authored-by: andig <cpuidle@gmail.com>
… of modbushttp plugin, per andig's feedback Added explicit timeout: 20s to each http source (not in the original evcc-io#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.
There was a problem hiding this comment.
Hey - I've left some high level feedback:
- The HTTP request configuration for
fdbg.cgi(method, timeout, headers) is duplicated across multiple cases; consider factoring this into a reusable YAML anchor or template block to reduce repetition and ease future changes. - Since the charge power is hardcoded to 5000 W in the Modbus frame, you might want to expose this as a parameter or clearly constrain it via configuration to make future adjustments easier without touching the hex payloads.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The HTTP request configuration for `fdbg.cgi` (method, timeout, headers) is duplicated across multiple cases; consider factoring this into a reusable YAML anchor or template block to reduce repetition and ease future changes.
- Since the charge power is hardcoded to 5000 W in the Modbus frame, you might want to expose this as a parameter or clearly constrain it via configuration to make future adjustments easier without touching the hex payloads.Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| Nutzt die lokale HTTP-Schnittstelle des AI-Dongle (z.B. BA121T-30) statt Modbus TCP. | ||
| Die Seriennummer (isn) des Wechselrichters kann über `http://<host>:8484/getdev.cgi?device=2` ausgelesen werden. | ||
|
|
||
| Die Batteriesteuerung (normal/hold/charge) nutzt fest vorberechnete Modbus-RTU-Befehle, |
There was a problem hiding this comment.
Das ist alles technisches Detail. Bitte auf "Ladeleistung der Batteriesteuerung ist auf xyz fest limitiert" einkürzen.
Solplanet AI-Dongle: battery control (normal/hold/charge)
Problem
The Solplanet/AISWEI AI dongle (LAN/WiFi, e.g. on ASW-H hybrid inverters) exposes no native
Modbus TCP or RTU. The only way to reach the inverter's Modbus registers is an undocumented
HTTP-to-Modbus tunnel on
fdbg.cgi(port 8484): a raw Modbus-RTU frame is hex-encoded andPOSTed as JSON, and the dongle relays it to the inverter over its internal UART.
This is the same tunnel
#31480outlined battery control for, but left commented out pendingfurther verification. The read-only parts of that groundwork were since merged separately in
#31732assolplanet-ai-dongle. This PR adds battery control (normal/hold/charge) ontop of that template, reviving the approach from
#31480.Solution
Per discussion on this PR, this intentionally does not introduce a new vendor-specific
plugin. Instead it uses the existing
httpplugin with fixed, pre-computed Modbus-RTU frames(hex-encoded, CRC16-checked) sent as the request body - the same approach
#31480had alreadysketched out, just uncommented and completed.
Because the commands are fixed rather than computed at runtime, charge power is pinned to
5000 W and there's no
holdchargemode - both were dropped from an earlier draft of this PR tokeep the change minimal and in line with the original
#31480approach.Register mapping
Per the official AISWEI Modbus profile (
MB001_ASW GEN-Modbus-en_V2.1.4), address conversionper the vendor's own convention (strip the leading
4, subtract 1):+=discharge,-=charge)batterymodecases map to evcc'sapi.BatteryModeenum:timeout: 20sNot present in the original
#31480draft. The dongle is a weak, cloud-first ESP32: typicalfdbg.cgiresponses complete in ~5 s, but testing (Talend API Tester, and repeated runsagainst a live evcc instance over several days) showed individual requests occasionally taking
up to ~11 s, and at least one observed case around 15 s - still a normal response, not a
failure. The original
modbushttp-based iteration of this PR (see discussion above) used a20 s timeout specifically to comfortably cover that range without cutting off legitimate slow
responses; that value is carried over here for the same reason, even without the plugin's
retry logic.
Testing
All three
batterymodecases were tested against real hardware (Solplanet ASW-H hybridinverter, AI dongle V1) via a running evcc instance with
--log trace, triggered through theexisting "grid charging" / "prevent discharge" dashboard controls:
{"dat":"ok",...}),confirmed by
battery powerdropping to -5000 Wbattery powerreturning tonormal self-use behavior
charge power: 0Wwhile a test charger wasactive
All requests completed within ~5 s in this particular test run - well under the 20 s timeout,
which is set generously based on the slower response times (~11-15 s) observed in earlier
testing, rather than what's typical.
The hex frames themselves were additionally cross-checked byte-for-byte (CRC16/S16 encoding)
against hardware-confirmed frames from the AISWEI reverse-engineering community (mb_rtu.py
gist, zbigniewmotyka/home-assistant-solplanet HACS integration) using the same algorithm.