From 6924747b7e60fd3d9b0632757d4f18e6fe0cb751 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 10 May 2026 16:28:30 +0200 Subject: [PATCH 1/2] fix: republish Total Battery Capacity after HA number update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HA "Total Battery Capacity" number and sensor share the same MQTT state topic. The `_set` handler used to mutate the in-memory override on `VehicleInfo.custom_battery_capacity` and return `RESULT_DO_NOTHING`, relying on the next charge-status poll to refresh the shared sensor topic. As a result the HA number widget displayed the user's retained `/set` value while the sensor (and any UI binding to it) remained stuck on the previous value — typically the per-model default published from `rvs_charge_status.get_actual_battery_capacity`. Republish `vehicle.real_battery_capacity` to the state topic from the handler itself so the sensor reflects the change immediately, without forcing an extra vehicle poll. Reading through `real_battery_capacity` keeps the `payload == 0` "clear override" path consistent: it falls back to the per-model default instead of publishing `0`. The kWh-derived sensors (`SoC_kWh`, `Last Charge SoC kWh`, the two `Power Usage` sensors) still use the previous correction factor until the next charge-status update, same staleness window as before. --- CHANGELOG.md | 8 ++++++++ .../drivetrain/drivetrain_total_battery_capacity.py | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e960207..e5d1fa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,14 @@ persistent. Existing retained STATE values on the broker are not converted into retained `/set` commands. +* Republish the effective Total Battery Capacity to its state topic right after + the user updates the HA number. The `_set` handler used to only mutate the + in-memory override and rely on the next vehicle poll to refresh the shared + sensor topic, leaving the HA sensor stuck on the previous (often hardcoded + per-model default) value while the number widget already showed the new + setting. A payload of `0` re-publishes the per-model default via + `real_battery_capacity`. + ## 0.11.0 ### Added diff --git a/src/handlers/command/drivetrain/drivetrain_total_battery_capacity.py b/src/handlers/command/drivetrain/drivetrain_total_battery_capacity.py index 849f16a..1c0ea43 100644 --- a/src/handlers/command/drivetrain/drivetrain_total_battery_capacity.py +++ b/src/handlers/command/drivetrain/drivetrain_total_battery_capacity.py @@ -29,5 +29,18 @@ async def handle_typed_payload(self, payload: float) -> CommandProcessingResult: LOG.info("Setting Total Battery Capacity to %f", payload) self.vehicle_state.update_battery_capacity(payload) + # The HA number and sensor entities share the same state topic. + # Republish the effective capacity locally so the sensor reflects + # the change immediately instead of waiting for the next vehicle poll + # (payload of 0 falls back to the per-model default in real_battery_capacity). + effective_capacity = self.vehicle_state.vehicle.real_battery_capacity + if effective_capacity is not None and effective_capacity > 0: + self.publisher.publish_float( + self.vehicle_state.get_topic( + mqtt_topics.DRIVETRAIN_TOTAL_BATTERY_CAPACITY + ), + effective_capacity, + ) + # No need to force a refresh return RESULT_DO_NOTHING From ea5802affc1577eb7955202aa7c4e9e80378150b Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 10 May 2026 16:29:56 +0200 Subject: [PATCH 2/2] test: cover the Total Battery Capacity republish path Stub `vehicle.real_battery_capacity` on the mock so the existing retained- replay test exercises the new state-topic publish, and add coverage for the two interesting branches: * payload `0` clears the override and republishes the per-model default * unknown model (`real_battery_capacity is None`) skips the publish --- tests/handlers/test_vehicle_command.py | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/handlers/test_vehicle_command.py b/tests/handlers/test_vehicle_command.py index d900983..4815b5e 100644 --- a/tests/handlers/test_vehicle_command.py +++ b/tests/handlers/test_vehicle_command.py @@ -254,6 +254,9 @@ async def test_payload_structure(self) -> None: f"{VEHICLE_PREFIX}/{mqtt_topics.DRIVETRAIN_TOTAL_BATTERY_CAPACITY}" f"/{mqtt_topics.RESULT_SUFFIX}" ) +TOTAL_BATTERY_CAPACITY_STATE_TOPIC = ( + f"{VEHICLE_PREFIX}/{mqtt_topics.DRIVETRAIN_TOTAL_BATTERY_CAPACITY}" +) class TestRetainedReplay(unittest.IsolatedAsyncioTestCase): @@ -328,14 +331,44 @@ async def test_non_retained_force_still_applied(self) -> None: async def test_retained_battery_capacity_replays_to_vehicle_info(self) -> None: handler, pub = _build() vehicle_state = cast("MagicMock", handler.vehicle_state) + vehicle_state.vehicle.real_battery_capacity = 50.0 await handler.handle_mqtt_command( topic=TOTAL_BATTERY_CAPACITY_SET_TOPIC, payload="50.0", retained=True ) vehicle_state.update_battery_capacity.assert_called_once_with(50.0) + pub.publish_float.assert_any_call(TOTAL_BATTERY_CAPACITY_STATE_TOPIC, 50.0) pub.publish_str.assert_any_call(TOTAL_BATTERY_CAPACITY_RESULT_TOPIC, "Success") + async def test_battery_capacity_zero_payload_publishes_model_default(self) -> None: + """Payload `0` clears the override; the per-model default is republished.""" + handler, pub = _build() + vehicle_state = cast("MagicMock", handler.vehicle_state) + # update_battery_capacity(0) clears the override; real_battery_capacity then + # falls back to the per-model default (e.g. 64 kWh for an MG4 NMC). + vehicle_state.vehicle.real_battery_capacity = 64.0 + + await handler.handle_mqtt_command( + topic=TOTAL_BATTERY_CAPACITY_SET_TOPIC, payload="0", retained=False + ) + + vehicle_state.update_battery_capacity.assert_called_once_with(0.0) + pub.publish_float.assert_any_call(TOTAL_BATTERY_CAPACITY_STATE_TOPIC, 64.0) + + async def test_battery_capacity_skips_publish_when_no_default(self) -> None: + """When `real_battery_capacity` returns None (unknown model), skip the publish.""" + handler, pub = _build() + vehicle_state = cast("MagicMock", handler.vehicle_state) + vehicle_state.vehicle.real_battery_capacity = None + + await handler.handle_mqtt_command( + topic=TOTAL_BATTERY_CAPACITY_SET_TOPIC, payload="0", retained=False + ) + + vehicle_state.update_battery_capacity.assert_called_once_with(0.0) + pub.publish_float.assert_not_called() + async def test_retained_action_command_dropped_at_dispatcher(self) -> None: """Retained `/set` for an action-bearing command is dropped at the dispatcher.