Skip to content

fix(gateway): bound the reserve target to what the inverter accepts - #5170

Merged
springfall2008 merged 1 commit into
mainfrom
fix/gateway-reserve-soc-max
Sep 20, 2026
Merged

springfall2008 merged 1 commit into
mainfrom
fix/gateway-reserve-soc-max

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

Pairs with predbat-gateway #349, which fixes predbat-gateway#346. Neither half works alone.

The bug

Some GivEnergy inverters refuse a battery reserve of 100% — Gen2, Gen3 and AIO are the ones our own docs/inverter-setup.md documents, advising inverter_reserve_max: 98. On the gateway route nothing knew that: the firmware reported no ceiling, and GATEWAY_ATTRIBUTE_TABLE hardcodes "max": 100, so reserve_device_bounds() had nothing to clamp with.

Every hold path writes min(soc_percent + 1, 100), so a battery sitting at 100% asks for a reserve of 100. The write is accepted on the wire, the register keeps its old value, and write_and_poll_value reads the mismatch back as a failed write — ten retries, 2 s apart, every engine cycle, then record_status(..., had_errors=True).

A customer saw "Warn: Inverter 0 write to reserve failed" from before 14:21 until about 17:40 on 17 Sep 2026, with Trying to write 100 to reserve didn't complete got 4.0 in the log and the register reading back 4%. The battery held correctly the whole time — the gateway's own plan entry does the holding — so this was a false error that cost a support ticket and an escalation, and it cleared only when the hold ended.

The fix

The gateway firmware now reports the ceiling per inverter (98 on the GivEnergy register path, 100 elsewhere). This PR publishes it as the reserve entity's max attribute in place of the hardcoded 100:

reserve_attributes = dict(GATEWAY_ATTRIBUTE_TABLE.get("reserve_soc", {}))
reserve_soc_max = getattr(control, "reserve_soc_max", 0)
reserve_attributes["max"] = reserve_soc_max if 1 <= reserve_soc_max <= 100 else 100

adjust_reserve() already honours that attribute through reserve_device_bounds() (GH#4826), and set_arg("reserve", reserve_entities) points it at exactly these entities — so a full-battery hold on GivEnergy now targets 98 and the write lands first time. No new config, and nothing changes for an inverter that accepts 100.

0 means "not reported" — gateway firmware predating the field — and is read as 100, as is anything outside 1-100. The table entry is copied per inverter, so one inverter's ceiling cannot leak into another's.

Holding a full battery at 98 rather than 100 changes nothing in practice: the gateway's plan entry is what holds the battery, and the reserve is a floor beneath it.

Schema

ControlStatus.reserve_soc_max (field 11), mirroring the firmware copy of gateway_status.proto — the two must stay in sync for wire compatibility. Appended, so both directions stay compatible, verified by round-trip across the old and new generated bindings:

  • new firmware -> old PredBat: parses cleanly, unknown field ignored
  • old firmware -> new PredBat: parses, reserve_soc_max defaults to 0

gateway_status_pb2.py was regenerated with grpcio-tools and the repo's black hook, keeping the 7.34.1 gencode header the file ships so the protobuf runtime floor doesn't move for users. That is safe to assert rather than assume: regenerating the unchanged schema with this toolchain reproduces the checked-in file byte-for-byte apart from that header, so the body here is what protoc 34.1 emits. The diff is the serialized descriptor plus the shifted _serialized_start/_end offsets — same shape as #4495.

Tests

Four new cases in TestInjectEntities (apps/predbat/tests/test_gateway.py):

Test Guards
test_reserve_soc_max_published_as_the_entity_max 98 from the firmware reaches the entity's max, and the rest of the table entry survives — fails without this change
test_reserve_soc_max_zero_falls_back_to_100 older firmware's 0 doesn't cap the reserve at zero
test_reserve_soc_max_out_of_range_falls_back_to_100 a nonsense ceiling is ignored
test_reserve_soc_max_does_not_mutate_the_shared_table the per-inverter override is applied to a copy

run_gateway_tests() passes in full (280 cases). black, ruff (the hook's rule set) and cspell are clean on the changed files; interrogate sits where it already did (78.3% -> 78.5% on these two files — this change adds docstrings, it doesn't remove any).

🤖 Generated with Claude Code

Some GivEnergy inverters refuse a battery reserve of 100% (Gen2, Gen3 and AIO
are the documented ones — our own inverter-setup docs advise
inverter_reserve_max: 98). Nothing on the gateway route knew that: the firmware
reported no ceiling and GATEWAY_ATTRIBUTE_TABLE hardcodes "max": 100, so
reserve_device_bounds() had nothing to clamp with.

Every hold path writes min(soc_percent + 1, 100), so a battery sitting at 100%
asks for a reserve of 100. The write is accepted on the wire, the register keeps
its old value, and write_and_poll_value reads the mismatch back as a failed
write — ten retries, 2s apart, every engine cycle, then
record_status(..., had_errors=True). A customer saw "Warn: Inverter 0 write to
reserve failed" from before 14:21 until about 17:40 on 17 Sep 2026, with
"Trying to write 100 to reserve didn't complete got 4.0" in the log. The battery
held correctly throughout — the gateway's own plan entry does the holding — so
this was a false error that cost a support ticket and an escalation.

Gateway firmware now reports the ceiling per inverter (predbat-gateway#346,
PR #349). This publishes it as the reserve entity's "max" attribute in place of
the hardcoded 100, which adjust_reserve() already honours through
reserve_device_bounds() (GH#4826). A full-battery hold on GivEnergy therefore
targets 98 and the write lands first time.

- ControlStatus.reserve_soc_max (field 11), mirroring the firmware copy of
  gateway_status.proto; the two must stay in sync for wire compatibility.
- 0 means "not reported" — older gateway firmware — and is read as 100, as is
  anything outside 1-100. The table entry is copied per inverter, so one
  inverter's ceiling cannot become another's.

Appended field, so both directions stay compatible — verified by round-trip
across the old and new generated bindings:
  new firmware -> old PredBat: parses cleanly, unknown field ignored
  old firmware -> new PredBat: parses, reserve_soc_max defaults to 0

Regenerated with grpcio-tools and the repo's black hook, keeping the 7.34.1
gencode header the file ships so the protobuf runtime floor does not move:
against the unchanged schema the two generators produce a byte-identical body,
so the result is what protoc 34.1 emits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 20, 2026 11:49

Copilot AI 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.

Copilot review overview

🟢 Approval recommended

The schema, runtime handling, fallback behavior, and per-inverter isolation are consistently implemented and covered by tests.

Review effort: Lite
Findings: None

What changed in this PR

Updates the gateway protobuf and entity publication so PredBat respects each inverter’s reported reserve ceiling.

Changes:

  • Adds reserve_soc_max to the gateway schema and generated bindings.
  • Publishes a validated per-inverter reserve max attribute.
  • Adds regression tests for valid, missing, invalid, and isolated bounds.
File Description
apps/​predbat/​gateway.py Publishes per-inverter reserve limits.
apps/​predbat/​gateway_status.proto Defines the new protobuf field.
apps/​predbat/​gateway_status_pb2.py Regenerated protobuf bindings.
apps/​predbat/​tests/​test_gateway.py Tests reserve limit publication and fallback behavior.
Files not reviewed (1)
  • apps/predbat/gateway_status_pb2.py: Generated file

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@springfall2008
springfall2008 merged commit 5df7fb9 into main Sep 20, 2026
3 checks passed
@springfall2008
springfall2008 deleted the fix/gateway-reserve-soc-max branch September 20, 2026 12:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants