Skip to content

Derive charging state from the wallbox instead of assuming it - #74

Merged
BorisBrock merged 3 commits into
BorisBrock:mainfrom
sadilek:fix/charging-state-from-hardware
Aug 4, 2026
Merged

BorisBrock merged 3 commits into
BorisBrock:mainfrom
sadilek:fix/charging-state-from-hardware

Conversation

@sadilek

@sadilek sadilek commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Register 261 holds the charging current limit: 0 A blocks charging, 6-16 A permits it. It keeps its value when this bridge restarts. Two defects let the bridge's RAM state drift away from it, and the result was a reported state of "charging enabled" while the wallbox delivered nothing, which the Home Assistant switch could not correct.

Init() never read register 261. It configured 262, 258 and 257, so mChargingEnabled started at the header default of true regardless of what the wallbox was doing. If charging had been disabled when the bridge restarted -- a PV surplus controller pausing overnight, then an OTA update or a power blip -- the flag claimed enabled while the register held 0 A. Init() now reads the register and seeds both the flag and the requested limit from it. If that read fails the old assumption remains, but the mismatch is no longer permanent, since any later enable or disable rewrites the register.

GetChargingCurrentLimit() overwrote the setpoint. It is telemetry, called from the publish rotation and from the Modbus TCP server, but it assigned its reading to the same member that held the desired limit. A single read of 0 A destroyed the value to be applied next, so enabling afterwards restored 0 A. Intent and observation now live in separate members, and the getter only touches the observed one.

Because intent now survives a disable, mPreviousChargingCurrentLimitA has nothing left to do and is removed. SetChargingEnabled writes the requested limit or 0 on every call rather than only on a transition, since a request that matches our flag must still reach a wallbox that may disagree.

Requests are clamped to the 0 or 6-16 A the wallbox accepts, which also removes an undefined float-to-uint16_t conversion for negative payloads. Writing 0 to stop charging still works, so Daheimladen clients are unaffected.

Two deliberate behaviour changes:

  • After a client stops charging by writing 0 A rather than by disabling, enabling now starts charging at the configured default, where the old transition gate made it a no-op. It does not remember a pre-zero limit: "8 A, 0 A, enable" gives the default, "8 A, disable, enable" gives 8 A.
  • GetChargingCurrentLimit() returns 0 A rather than a stale 16 A before the first successful read.

DummyWallbox keeps the previous logic, since its register is RAM that dies with the process and this class of desync cannot occur there. Happy to port it if you would rather the two implementations match.

Tested on a Heidelberg Energy Control with an ESP32. The sequence that used to fail -- set 8 A, disable, wait for telemetry to read 0 A repeatedly, enable -- now restores 8 A, verified by reading register 261 over Modbus TCP rather than over MQTT, since the firmware only publishes that value within 6-16 A.

sadilek and others added 3 commits August 4, 2026 09:19
Register 261 holds the charging current limit: 0 A blocks charging, 6-16 A
permits it. It keeps its value when this bridge restarts. Two defects let the
bridge's RAM state drift away from it, and the result was a reported state of
"charging enabled" while the wallbox delivered nothing, which the Home Assistant
switch could not correct.

Init() never read register 261. It configured 262, 258 and 257, so
mChargingEnabled started at the header default of true regardless of what the
wallbox was doing. If charging had been disabled when the bridge restarted -- a
PV surplus controller pausing overnight, then an OTA update or a power blip --
the flag claimed enabled while the register held 0 A. Init() now reads the
register and seeds both the flag and the requested limit from it. If that read
fails the old assumption remains, but the mismatch is no longer permanent, since
any later enable or disable rewrites the register.

GetChargingCurrentLimit() overwrote the setpoint. It is telemetry, called from
the publish rotation and from the Modbus TCP server, but it assigned its reading
to the same member that held the desired limit. A single read of 0 A destroyed
the value to be applied next, so enabling afterwards restored 0 A. Intent and
observation now live in separate members, and the getter only touches the
observed one.

Because intent now survives a disable, mPreviousChargingCurrentLimitA has
nothing left to do and is removed. SetChargingEnabled writes the requested limit
or 0 on every call rather than only on a transition, since a request that
matches our flag must still reach a wallbox that may disagree.

Requests are clamped to the 0 or 6-16 A the wallbox accepts, which also removes
an undefined float-to-uint16_t conversion for negative payloads. Writing 0 to
stop charging still works, so Daheimladen clients are unaffected.

Two deliberate behaviour changes:

- After a client stops charging by writing 0 A rather than by disabling,
  enabling now starts charging at the configured default, where the old
  transition gate made it a no-op. It does not remember a pre-zero limit:
  "8 A, 0 A, enable" gives the default, "8 A, disable, enable" gives 8 A.
- GetChargingCurrentLimit() returns 0 A rather than a stale 16 A before the
  first successful read.

DummyWallbox keeps the previous logic, since its register is RAM that dies with
the process and this class of desync cannot occur there. Happy to port it if you
would rather the two implementations match.

Tested on a Heidelberg Energy Control with an ESP32. The sequence that used to
fail -- set 8 A, disable, wait for telemetry to read 0 A repeatedly, enable --
now restores 8 A, verified by reading register 261 over Modbus TCP rather than
over MQTT, since the firmware only publishes that value within 6-16 A.
@BorisBrock
BorisBrock merged commit d36510d into BorisBrock:main Aug 4, 2026
3 checks passed
@sadilek
sadilek deleted the fix/charging-state-from-hardware branch August 4, 2026 09:15
sadilek added a commit to sadilek/HeidelBridge that referenced this pull request Aug 4, 2026
…rock#75, BorisBrock#76) into kupa5

Our three fixes are now upstream, so the fork no longer carries them. Boris made
two edits when merging: ClampToWallboxRange and WriteCurrentLimitRegister moved
from a file-local anonymous namespace to private members of HeidelbergWallbox,
and InitialChargingCurrentLimitA is now defined as MaxChargingCurrentA instead
of repeating 16.0f.

Conflicts resolved in favour of upstream throughout, except for two items that
stay fork-specific in MQTTManager.cpp:

- The Enable Charging unique_id keeps its underscore. Upstream still has
  "%control_enable_charging"; reverting ours would orphan the existing entity in
  Home Assistant and break the automation that references it by entity id.
- The diagnostic echo to {DeviceName}/internal/last_command is kept. It is not
  upstream and is deliberately not proposed, since it adds public topic surface.

Also adopted from upstream: the discovery publish retry (publish() can silently
return 0 when the TCP send buffer is full) and the Last Will plus
availability_topic on every entity, which together complement the retained
discovery from BorisBrock#73.
sadilek added a commit to sadilek/HeidelBridge that referenced this pull request Aug 4, 2026
All three fixes are upstream now, so the "candidates for a PR" framing was
stale. Replaced with a short history keyed to BorisBrock#73/BorisBrock#74/BorisBrock#75, plus Boris's edits
when merging: the two helpers became private members, and
InitialChargingCurrentLimitA is defined as MaxChargingCurrentA.

Added an explicit list of what kupa5 still diverges on, so a future upstream
merge does not discard it: the Enable Charging unique_id underscore (reverting
would orphan the Home Assistant entity the automation references) and the
diagnostic echo topic.
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.

3 participants