fix: persist HA-driven gateway settings via retained /set commands#441
Merged
fix: persist HA-driven gateway settings via retained /set commands#441
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
retain: trueto the HA discovery payloads of the six writable entities whose values are user-set and should survive a gateway restart: the fourrefresh_period_*numbers,refresh_mode, andtotalBatteryCapacity. HA now publishes their/setcommands withretain=true, so the broker keeps the user's last intent./setsubscription delivers the retained command, the existing dispatch path runs unchanged, andconfigure_missing()'s already-correct sentinel guards (-1/None) make the config defaults no-op.retainflag from__on_messagethrough the command path soRefreshModeCommandcan drop a retained one-shot mode (force/charging_detection) — otherwise a single-shot poll would loop on every restart.OFFandPERIODICreplay normally.clear_command(publisher.clear_topic) on retained replays so the dispatcher does not erase the user's intent from the broker.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 shipoptimistic: falsewithout regressing user settings.First-deploy caveat (in CHANGELOG): existing retained STATE topics on the broker are not retained
/setcommands, 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.vehicle.custom_battery_capacity; retainedclear_commandskipped;configure_missingpreserves seeded retained values; the six entities haveretain=truein HA discovery while sample non-retained entities do not.refresh_mode = forcefrom HA, observe one poll, restart pod, confirm FORCE does NOT replay (log: "Dropping retained one-shot refresh mode force").