Skip to content

Solplanet AI dongle: add battery control via modbushttp plugin - #31908

Draft
mi-ch-a wants to merge 15 commits into
evcc-io:masterfrom
mi-ch-a:modbushttp-solplanet
Draft

Solplanet AI dongle: add battery control via modbushttp plugin#31908
mi-ch-a wants to merge 15 commits into
evcc-io:masterfrom
mi-ch-a:modbushttp-solplanet

Conversation

@mi-ch-a

@mi-ch-a mi-ch-a commented Jul 18, 2026

Copy link
Copy Markdown

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 and
POSTed as JSON, and the dongle relays it to the inverter over its internal UART.

This is the same tunnel #31480 outlined battery control for, but left commented out pending
further verification. The read-only parts of that groundwork were since merged separately in
#31732 as solplanet-ai-dongle. This PR adds battery control (normal/hold/charge) on
top 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 http plugin with fixed, pre-computed Modbus-RTU frames
(hex-encoded, CRC16-checked) sent as the request body - the same approach #31480 had already
sketched 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 holdcharge mode - both were dropped from an earlier draft of this PR to
keep the change minimal and in line with the original #31480 approach.

Register mapping

Per the official AISWEI Modbus profile (MB001_ASW GEN-Modbus-en_V2.1.4), address conversion
per the vendor's own convention (strip the leading 4, subtract 1):

Register (vendor ADDR) Address used Purpose
41104 1103 Run mode (2=self-use, 4=customer defined)
41153 1152 Charge/discharge power, S16 W (+=discharge, -=charge)

batterymode cases map to evcc's api.BatteryMode enum:

  • 1 (normal): mode=2 (self-use)
  • 2 (hold): mode=4 (customer defined), setpoint=0 W
  • 3 (charge): mode=4, setpoint=-5000 W (fixed)

timeout: 20s

Not present in the original #31480 draft. The dongle is a weak, cloud-first ESP32: typical
fdbg.cgi responses complete in ~5 s, but testing (Talend API Tester, and repeated runs
against 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 a
20 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 batterymode cases were tested against real hardware (Solplanet ASW-H hybrid
inverter, AI dongle V1) via a running evcc instance with --log trace, triggered through the
existing "grid charging" / "prevent discharge" dashboard controls:

  • normal → charge: mode/setpoint frames sent and acknowledged ({"dat":"ok",...}),
    confirmed by battery power dropping to -5000 W
  • charge → normal: mode reset to self-use, confirmed by battery power returning to
    normal self-use behavior
  • hold: setpoint forced to 0 W, confirmed by charge power: 0W while a test charger was
    active

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.

andig and others added 12 commits July 5, 2026 11:50
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.
@github-actions github-actions Bot added devices Specific device support enhancement New feature or request labels Jul 18, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread templates/definition/meter/solplanet-ai-dongle.yaml
Comment thread plugin/modbushttp.go Outdated
Comment on lines +362 to +371
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])
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
}
  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.

Comment thread templates/definition/meter/solplanet-ai-dongle.yaml Outdated
Comment thread templates/definition/meter/solplanet-ai-dongle.yaml Outdated
@andig
andig marked this pull request as draft July 18, 2026 13:52
Comment thread plugin/modbushttp.go Outdated
mi-ch-a and others added 3 commits July 18, 2026 22:57
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.
@andig
andig marked this pull request as ready for review July 26, 2026 14:50

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Das ist alles technisches Detail. Bitte auf "Ladeleistung der Batteriesteuerung ist auf xyz fest limitiert" einkürzen.

@andig
andig marked this pull request as draft July 26, 2026 14:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

devices Specific device support enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants