Skip to content

fix(gateway): never enable discharge/export after a failed window write - #5532

Merged
springfall2008 merged 6 commits into
mainfrom
fix/hub-discharge-window-safety
Oct 14, 2026
Merged

springfall2008 merged 6 commits into
mainfrom
fix/hub-discharge-window-safety

Conversation

@mgazza

@mgazza mgazza commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Bug

For a direct Hub (gateway) battery, adjust_force_export writes the discharge start and end as two separate set_discharge_slot commands, then turns on scheduled_discharge_enable and calls adjust_inverter_mode (export mode) regardless of whether those endpoint writes were applied. schedule_write_ok only withheld the later commit record.

When the Hub accepts the start but refuses the end, it is left enabled on a stale window. In the reproduction below that window is 02:55–00:01, which wraps for about 21 hours, and export mode is active.

Reproduction

This runs the actual Inverter.adjust_force_export, GatewayMQTT command path and ACK cache. The transport is synthetic: it accepts the start and refuses any set_discharge_slot carrying end (modbus_write_failed).

before (main 40b9ce2) after
wire {start:255}, {end:301} (refused), set_discharge_enable true one {start:255,end:301} (refused)
read-back 02:55:00 – 00:01:00 00:00:00 – 00:01:00 (unchanged)
discharge enable on off
export mode called yes no
commit pending yes yes (next cycle retries)

Fix

This mirrors the charge-window fix in #5531.

  • GatewayMQTT.update_discharge_window sends both endpoints in one full-serial set_discharge_slot. It refuses read-only mode, non-slot1 or unpaired entities, mismatched serials, coordinators and unparsable times. True only means the command was published.
  • GatewayMQTT.discharge_window_uses_staging picks the coordinator path synchronously, before the async bridge. A swallowed exception in the run_async thread therefore returns None, which counts as a publish failure. It never sends the caller to the legacy individual writes.
  • Inverter.write_and_poll_gateway_discharge_window runs when both endpoint entities are owned by gateway.
    • Mixed ownership is an error, not a fallback.
    • Both endpoint read-backs (applied ACK or telemetry) must match before it returns success.
    • It records both endpoints in the control ledger, or clears them on failure, and uses one write-backoff key for the pair.
  • In adjust_force_export, any failure returns before the discharge target, the enable write, export mode and the commit, and leaves export_window commit pending. Coordinators (GATEWAY_COORDINATOR_TYPES), other providers, the disable path and midnight windows keep their existing sequence.

Tests

apps/predbat/tests/test_gateway_discharge_window.py adds 15 tests, registered in run_gateway_tests. They cover:

  • the refused endpoint, plus refused, partial, staged, timeout and exception outcomes, all failing closed with the ledger cleared
  • the pair applied before enable, with the full serial
  • a retry after refusal recovering
  • read-only, ownership and serial refusals
  • an async-bridge exception that does not fall back
  • the coordinator and non-Hub sequence preserved, including a coordinator whose end entity is not gateway-owned (staging is detected before ownership checks)
  • disable and the midnight window
  • the success ledger and backoff key
  • the write/moved counters
  • an armed schedule turned off when its move is refused, left running when the move is only unconfirmed (timeout, partial or staged ACK), and kept enabled when the move applies
  • a refusal that ends the attempt at once (one poll, no re-send inside the ack window)

Results:

  • unit_test.py -t gateway: PASSED (434 OK). -t inverter_write_poll and -t inverter_multi: PASSED.
  • On unfixed main, 7 of the 9 new tests fail. The two that pass there are the preservation tests (coordinator/non-Hub, disable/midnight).
  • -t inverter stops on the same pre-existing adjust_target50 write_and_poll_switch unpack error locally, with and without this change. The adjust_force_export* cases before it pass.
  • Pre-commit (ruff, black, cspell) passes.

Notes

  • This conflicts textually with fix(gateway): apply direct battery charge windows as paired slots #5531 (adjacent hunks in gateway.py, inverter.py and the run_gateway_tests import). The resolution is to keep both.
  • Fail-safe for an armed schedule: if export is already enabled and the Hub explicitly refuses the exact paired window, scheduled export is turned off and the mode returns to Eco (except in read-only mode). Previously the old window kept exporting. Test: 02:00–04:00 enabled, plan 02:30–03:00, refused → enable off, adjust_inverter_mode(False). An unconfirmed write (slow or missing ACK, telemetry lag) may have applied, so the running export is left alone and the pending commit retries. After a refusal, execute.py still reports "Exporting" for that cycle.
  • An independent adversarial review found nothing blocking. Mutation testing caught 11 of 13 mutations. Fixed from that review: the coordinator ordering (529e6f8), and in 57b7b4a the armed-export fail-safe, refusal no longer polled to timeout, the unreachable serial check removed, counter tests added, and the async exception path documented. The coordinator ordering also applies to fix(gateway): apply direct battery charge windows as paired slots #5531.
  • MQTT transport, ACKs and inverter state are synthetic in these tests. No physical hardware validation has been done.

🤖 Generated with Claude Code

https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf

mgazza and others added 6 commits October 13, 2026 08:09
For a direct Hub battery, adjust_force_export wrote the discharge start
and end as two separate set_discharge_slot commands, then enabled the
scheduled discharge and switched to export mode regardless of whether
the endpoint writes were applied. schedule_write_ok only withheld the
later commit record. When the end write was refused after the start was
accepted, the Hub was left enabled with a stale wrapping window
(e.g. 02:55-00:01) and actively exporting.

Send both endpoints in one full-serial set_discharge_slot for direct Hub
batteries and verify both endpoint read-backs before continuing. Any
refusal, staged or partial ACK, timeout, publication error or swallowed
async-bridge exception returns before the enable write, export mode and
commit, leaving the commit pending for the next cycle to retry. Both
endpoints are tracked in the control ledger and share one backoff key.
Read-only, entity ownership and serial checks refuse before publishing.
Coordinators keep their staged separate-endpoint sequence; other
providers, disable and midnight windows are unchanged.

Mirrors the charge-window fix in #5531.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf
…hecks

A coordinator whose discharge end entity was not (yet) owned by the
gateway was refused with an ownership error instead of keeping its
staged separate-endpoint sequence. Identify coordinators first, as that
decision only needs the entity names and discovery record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf
…o re-poll of a refusal

- When the paired window write fails while scheduled export is already
  enabled, turn the schedule off and return to Eco instead of leaving
  the previous window exporting on a plan that has moved away from it
  (skipped in read-only mode).
- A refusal of the exact paired command ends the attempt immediately:
  the poll stops when it arrives, and a retry inside the ack window is
  not published (_send_control would suppress it anyway), so a refused
  write no longer holds the engine for the full poll timeout per retry.
- Drop the unreachable end-serial comparison (the end entity name is
  derived from the start, so it carries the same serial) and name the
  test case after what it exercises.
- Document that an exception inside a running event loop reaches the
  caller as None via run_async's thread, not as an exception.
- Test the write/moved counters for the paired path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf
The fail-safe disable applies only to a failed window write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf
…edule

The fail-safe disable fired on any unverified window write, including a
slow but successful one (ACK outside the poll, older firmware without
an applied block, telemetry not yet landed). That stopped a correctly
re-windowed export for a cycle and toggled enable/mode. Take the
disable path only when the Hub refused the exact paired command; an
unconfirmed write leaves the running export alone and keeps the commit
pending for the next cycle. Document that execute.py still reports
"Exporting" for a cycle whose window was refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf
Resolve the run_gateway_tests conflict with #5496 by registering both
TestGatewayDischargeWindow and TestGatewayDisplayOnly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@springfall2008
springfall2008 merged commit c24750e into main Oct 14, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/hub-discharge-window-safety branch October 14, 2026 07:06
springfall2008 pushed a commit that referenced this pull request Oct 14, 2026
Resolve the run_gateway_tests conflict with #5496 and #5532 by registering
TestGatewayChargeWindow, TestGatewayDischargeWindow and TestGatewayDisplayOnly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
springfall2008 pushed a commit that referenced this pull request Oct 14, 2026
A coordinator whose charge end entity was not (yet) owned by the gateway
was refused with an ownership error, so its charge window was never
written, instead of keeping its staged separate-endpoint sequence.
Identify coordinators first, as that decision only needs the entity names
and discovery record. Same ordering as the discharge side in #5532.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
springfall2008 pushed a commit that referenced this pull request Oct 14, 2026
)

* fix(gateway): send direct battery charge windows as paired slots

* fix(gateway): separate coordinator staging from async publication failure

* fix(gateway): detect coordinator staging before charge ownership checks

A coordinator whose charge end entity was not (yet) owned by the gateway
was refused with an ownership error, so its charge window was never
written, instead of keeping its staged separate-endpoint sequence.
Identify coordinators first, as that decision only needs the entity names
and discovery record. Same ordering as the discharge side in #5532.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: CI <ci@example.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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