Skip to content

fix: persist HA-driven gateway settings via retained /set commands#441

Merged
nanomad merged 4 commits intodevelopfrom
fix/persist-ha-driven-settings
May 9, 2026
Merged

fix: persist HA-driven gateway settings via retained /set commands#441
nanomad merged 4 commits intodevelopfrom
fix/persist-ha-driven-settings

Conversation

@nanomad
Copy link
Copy Markdown
Contributor

@nanomad nanomad commented May 9, 2026

Summary

  • Adds retain: true to the HA discovery payloads of the six writable entities whose values are user-set and should survive a gateway restart: the four refresh_period_* numbers, refresh_mode, and totalBatteryCapacity. HA now publishes their /set commands with retain=true, so the broker keeps the user's last intent.
  • On reconnect, the existing /set subscription delivers the retained command, the existing dispatch path runs unchanged, and configure_missing()'s already-correct sentinel guards (-1 / None) make the config defaults no-op.
  • Plumbs the gmqtt retain flag from __on_message through the command path so RefreshModeCommand can drop a retained one-shot mode (force / charging_detection) — otherwise a single-shot poll would loop on every restart. OFF and PERIODIC replay normally.
  • Suppresses clear_command (publisher.clear_topic) on retained replays so the dispatcher does not erase the user's intent from the broker.
  • API-backed values (SOC target, charge current limit, charging schedule, battery heating schedule) remain retain=false — the SAIC API is the source of truth on first read.

Unblocks #440 (fix/ha-number-optimistic-state): once persistence works, the paused PR can rebase and ship optimistic: false without regressing user settings.

First-deploy caveat (in CHANGELOG): existing retained STATE topics on the broker are not retained /set commands, so persistence kicks in only after the user next changes a setting from HA.

Test plan

  • pytest — all 179 tests pass (10 new).
  • pre-commit run --all-files — ruff + ruff-format + mypy clean.
  • New tests cover: retained FORCE / CHARGING_DETECTION dropped; retained PERIODIC / OFF applied; non-retained FORCE still applied (regression); retained battery capacity replays to vehicle.custom_battery_capacity; retained clear_command skipped; configure_missing preserves seeded retained values; the six entities have retain=true in HA discovery while sample non-retained entities do not.
  • Manual: with the gateway running in k8s, change a refresh period from HA, restart the pod, confirm value persists. Set refresh_mode = force from HA, observe one poll, restart pod, confirm FORCE does NOT replay (log: "Dropping retained one-shot refresh mode force").

nanomad added 4 commits May 9, 2026 17:33
Adds retain=true to HA discovery for the four refresh_period_* numbers,
refresh_mode, and totalBatteryCapacity, so the broker keeps the user's
last `/set` value across gateway restarts. Plumbs the gmqtt retain flag
through the command-dispatch path and drops a retained one-shot refresh
mode (force, charging_detection) so a single-shot poll does not loop on
every restart. Also suppresses clear_command on retained replays so the
broker does not erase the user's intent.

Unblocks PR #440 (fix/ha-number-optimistic-state).
A retained /set arriving for an unknown VIN means we lost the user's
intent — surface it at WARN so it shows up in default log views.
Drops RefreshMode.OFF from INVALID_STARTUP_REFRESH_MODES and changes the
constructor default for refresh_mode from OFF to PERIODIC. Before, OFF
was in the INVALID set because OFF was the constructor default and a
gateway booting in OFF would never poll. Now that retained `/set off` is
replayed on reconnect (via the parent commit), OFF is a legitimate
persistent user choice and must be preserved by configure_missing.

FORCE / CHARGING_DETECTION remain in INVALID_STARTUP_REFRESH_MODES as a
belt-and-braces guard alongside the primary drop in RefreshModeCommand.

Also addresses two review nits:
- refresh_mode.handle empty-payload guard now mirrors the parent's
  `not self.supports_empty_payload` clause for contract parity
- drops a redundant `if _properties` defensive check; gmqtt always
  populates the properties dict with the retain flag
Adds an opt-in CommandHandlerBase.is_replayable_when_retained() classmethod
(default False) and gates the dispatcher: any retained `/set` for a handler
that hasn't opted in is dropped with a WARN log before reaching the handler.

Defense-in-depth against non-HA producers (node-RED, custom scripts,
mosquitto_pub) that may mistakenly publish action-bearing commands with
retain=true. Without this guard such a stale retained command (e.g. a
retained `charging/set true`) would re-fire the SAIC API call on every
gateway restart.

Opted in: RefreshMode, the four RefreshPeriod_*, and TotalBatteryCapacity
— exactly the six entities whose HA discovery payload also declares
retain=true. Single source of truth lives next to the handler logic.
@nanomad nanomad merged commit a4239e3 into develop May 9, 2026
6 checks passed
nanomad added a commit that referenced this pull request May 9, 2026
HA MQTT Number / Select / Text discovery defaults to optimistic mode,
so HA treats its locally cached value as authoritative even when the
gateway publishes a different state on state_topic — the trigger for
target SoC resetting on restart (#375). PR #441 narrowed the symptom
for the six retained entities by replaying the user's last /set on
reconnect; this PR closes the underlying optimistic-mode gap.

Discovery: add `optimistic: false` to _publish_number, _publish_select,
and _publish_text. Switches already had it.

Dispatcher: with optimistic: false the slider waits for state_topic
to confirm before updating, which on a sleeping car can take minutes
for SAIC-API-backed values. Add three optional hooks on
CommandHandlerBase (echo_state_topic / capture_current_state /
echo_payload) and an eager-echo + rollback path in the dispatcher:
echo the requested value to state_topic on receipt, roll back on
SAIC failure. Skipped on retained replays — no user is watching the
slider on startup.

Only DrivetrainSoCTargetCommand opts in: it's the one writable entity
that is API-backed (so #441's retained-replay path doesn't cover it)
and slow enough that the slider would otherwise freeze.

Closes #375
nanomad added a commit that referenced this pull request May 9, 2026
HA MQTT Number / Select / Text discovery defaults to optimistic mode,
so HA treats its locally cached value as authoritative even when the
gateway publishes a different state on state_topic — the trigger for
target SoC resetting on restart (#375). PR #441 narrowed the symptom
for the six retained entities by replaying the user's last /set on
reconnect; this PR closes the underlying optimistic-mode gap.

Discovery: add `optimistic: false` to _publish_number, _publish_select,
and _publish_text. Switches already had it.

Dispatcher: with optimistic: false the slider waits for state_topic
to confirm before updating, which on a sleeping car can take minutes
for SAIC-API-backed values. Add three optional hooks on
CommandHandlerBase (echo_state_topic / capture_current_state /
echo_payload) and an eager-echo + rollback path in the dispatcher:
echo the requested value to state_topic on receipt, roll back on
SAIC failure. Skipped on retained replays — no user is watching the
slider on startup.

Only DrivetrainSoCTargetCommand opts in: it's the one writable entity
that is API-backed (so #441's retained-replay path doesn't cover it)
and slow enough that the slider would otherwise freeze.

Closes #375
nanomad added a commit that referenced this pull request May 9, 2026
HA MQTT Number / Select / Text discovery defaults to optimistic mode,
so HA treats its locally cached value as authoritative even when the
gateway publishes a different state on state_topic — the trigger for
target SoC resetting on restart (#375). PR #441 narrowed the symptom
for the six retained entities by replaying the user's last /set on
reconnect; this PR closes the underlying optimistic-mode gap.

Discovery: add `optimistic: false` to _publish_number, _publish_select,
and _publish_text. Switches already had it.

Dispatcher: with optimistic: false the slider waits for state_topic
to confirm before updating, which on a sleeping car can take minutes
for SAIC-API-backed values. Add three optional hooks on
CommandHandlerBase (echo_state_topic / capture_current_state /
echo_payload) and an eager-echo + rollback path in the dispatcher:
echo the requested value to state_topic on receipt, roll back on
SAIC failure. Skipped on retained replays — no user is watching the
slider on startup.

Only DrivetrainSoCTargetCommand opts in: it's the one writable entity
that is API-backed (so #441's retained-replay path doesn't cover it)
and slow enough that the slider would otherwise freeze.

Closes #375
nanomad added a commit that referenced this pull request May 9, 2026
HA MQTT Number / Select / Text discovery defaults to optimistic mode,
so HA treats its locally cached value as authoritative even when the
gateway publishes a different state on state_topic — the trigger for
target SoC resetting on restart (#375). PR #441 narrowed the symptom
for the six retained entities by replaying the user's last /set on
reconnect; this PR closes the underlying optimistic-mode gap.

Discovery: add `optimistic: false` to _publish_number, _publish_select,
and _publish_text. Switches already had it.

Dispatcher: with optimistic: false the slider waits for state_topic
to confirm before updating, which on a sleeping car can take minutes
for SAIC-API-backed values. Add three optional hooks on
CommandHandlerBase (echo_state_topic / capture_current_state /
echo_payload) and an eager-echo + rollback path in the dispatcher:
echo the requested value to state_topic on receipt, roll back on
SAIC failure. Skipped on retained replays — no user is watching the
slider on startup.

Only DrivetrainSoCTargetCommand opts in: it's the one writable entity
that is API-backed (so #441's retained-replay path doesn't cover it)
and slow enough that the slider would otherwise freeze.

Closes #375
nanomad added a commit that referenced this pull request May 9, 2026
HA MQTT Number / Select / Text discovery defaults to optimistic mode,
so HA treats its locally cached value as authoritative even when the
gateway publishes a different state on state_topic — the trigger for
target SoC resetting on restart (#375). PR #441 narrowed the symptom
for the six retained entities by replaying the user's last /set on
reconnect; this PR closes the underlying optimistic-mode gap.

Discovery: add `optimistic: false` to _publish_number, _publish_select,
and _publish_text. Switches already had it.

Dispatcher: with optimistic: false the slider waits for state_topic
to confirm before updating, which on a sleeping car can take minutes
for SAIC-API-backed values. Add three optional hooks on
CommandHandlerBase (echo_state_topic / capture_current_state /
echo_payload) and an eager-echo + rollback path in the dispatcher:
echo the requested value to state_topic on receipt, roll back on
SAIC failure. Skipped on retained replays — no user is watching the
slider on startup.

Only DrivetrainSoCTargetCommand opts in: it's the one writable entity
that is API-backed (so #441's retained-replay path doesn't cover it)
and slow enough that the slider would otherwise freeze.

Closes #375
nanomad added a commit that referenced this pull request May 9, 2026
HA MQTT Number / Select / Text discovery defaults to optimistic mode,
so HA treats its locally cached value as authoritative even when the
gateway publishes a different state on state_topic — the trigger for
target SoC resetting on restart (#375). PR #441 narrowed the symptom
for the six retained entities by replaying the user's last /set on
reconnect; this PR closes the underlying optimistic-mode gap.

Discovery: add `optimistic: false` to _publish_number, _publish_select,
and _publish_text. Switches already had it.

Dispatcher: with optimistic: false the slider waits for state_topic
to confirm before updating, which on a sleeping car can take minutes
for SAIC-API-backed values. Add three optional hooks on
CommandHandlerBase (echo_state_topic / capture_current_state /
echo_payload) and an eager-echo + rollback path in the dispatcher:
echo the requested value to state_topic on receipt, roll back on
SAIC failure. Skipped on retained replays — no user is watching the
slider on startup.

Only DrivetrainSoCTargetCommand opts in: it's the one writable entity
that is API-backed (so #441's retained-replay path doesn't cover it)
and slow enough that the slider would otherwise freeze.

Closes #375
nanomad added a commit that referenced this pull request May 9, 2026
HA MQTT Number / Select / Text discovery defaults to optimistic mode,
so HA treats its locally cached value as authoritative even when the
gateway publishes a different state on state_topic — the trigger for
target SoC resetting on restart (#375). PR #441 narrowed the symptom
for the six retained entities by replaying the user's last /set on
reconnect; this PR closes the underlying optimistic-mode gap.

Discovery: add `optimistic: false` to _publish_number, _publish_select,
and _publish_text. Switches already had it.

Dispatcher: with optimistic: false the slider waits for state_topic
to confirm before updating, which on a sleeping car can take minutes
for SAIC-API-backed values. Add three optional hooks on
CommandHandlerBase (echo_state_topic / capture_current_state /
echo_payload) and an eager-echo + rollback path in the dispatcher:
echo the requested value to state_topic on receipt, roll back on
SAIC failure. Skipped on retained replays — no user is watching the
slider on startup.

Only DrivetrainSoCTargetCommand opts in: it's the one writable entity
that is API-backed (so #441's retained-replay path doesn't cover it)
and slow enough that the slider would otherwise freeze.

Closes #375
nanomad added a commit that referenced this pull request May 9, 2026
HA MQTT Number / Select / Text discovery defaults to optimistic mode,
so HA treats its locally cached value as authoritative even when the
gateway publishes a different state on state_topic — the trigger for
target SoC resetting on restart (#375). PR #441 narrowed the symptom
for the six retained entities by replaying the user's last /set on
reconnect; this PR closes the underlying optimistic-mode gap.

Discovery: add `optimistic: false` to _publish_number, _publish_select,
and _publish_text. Switches already had it.

Dispatcher: with optimistic: false the slider waits for state_topic
to confirm before updating, which on a sleeping car can take minutes
for SAIC-API-backed values. Add three optional hooks on
CommandHandlerBase (echo_state_topic / capture_current_state /
echo_payload) and an eager-echo + rollback path in the dispatcher:
echo the requested value to state_topic on receipt, roll back on
SAIC failure. Skipped on retained replays — no user is watching the
slider on startup.

Only DrivetrainSoCTargetCommand opts in: it's the one writable entity
that is API-backed (so #441's retained-replay path doesn't cover it)
and slow enough that the slider would otherwise freeze.

Closes #375
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.

1 participant