From 8bc1b1f318e62e58e1c82250ba8a2c4fe84cd282 Mon Sep 17 00:00:00 2001 From: Filip Lajszczak Date: Mon, 27 Apr 2026 11:42:28 +0000 Subject: [PATCH] mqtt: polish published state values --- src/batcontrol/mqtt_api.py | 9 ++-- tests/batcontrol/test_mqtt_api.py | 75 ++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py index aa1c1ef..324d0dc 100644 --- a/src/batcontrol/mqtt_api.py +++ b/src/batcontrol/mqtt_api.py @@ -139,8 +139,7 @@ def on_connect(self, client, userdata, flags, rc): # Handle reconnect case for topic in self.callbacks: logger.debug('Subscribing topic: %s', topic) - for topic in self.callbacks: - client.subscribe(topic) + client.subscribe(topic) def wait_ready(self) -> bool: """ Wait for MQTT connection to be ready""" @@ -310,7 +309,7 @@ def publish_SOC(self, soc: float) -> None: # pylint: disable=invalid-name /SOC """ if self.client.is_connected(): - self.client.publish(self.base_topic + '/SOC', f'{int(soc):03}') + self.client.publish(self._topic('SOC'), f'{soc:.2f}') def publish_stored_energy_capacity(self, stored_energy: float) -> None: """ Publish the stored energy capacity in Wh to MQTT @@ -453,7 +452,7 @@ def publish_discharge_blocked(self, discharge_blocked: bool) -> None: self.client.publish( self.base_topic + '/discharge_blocked', - str(discharge_blocked)) + str(discharge_blocked).lower()) def publish_production_offset(self, production_offset: float) -> None: """ Publish the production offset percentage to MQTT @@ -670,7 +669,7 @@ def send_mqtt_discovery_messages(self) -> None: None, self.base_topic + "/discharge_blocked", - value_template="{% if value | lower == 'True' %}blocked{% else %}not blocked{% endif %}") + value_template="{% if value | lower == 'true' %}blocked{% else %}not blocked{% endif %}") self.publish_mqtt_discovery_message( "Reserved Energy Capacity", diff --git a/tests/batcontrol/test_mqtt_api.py b/tests/batcontrol/test_mqtt_api.py index 91b3355..2a22af4 100644 --- a/tests/batcontrol/test_mqtt_api.py +++ b/tests/batcontrol/test_mqtt_api.py @@ -1,5 +1,5 @@ """Tests for MqttApi._handle_message, focusing on bytes payload decoding.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call from batcontrol.mqtt_api import MqttApi @@ -41,6 +41,20 @@ def _make_discovery_stub(): return api +def _make_publish_stub(): + """Return a minimal stub for publish helper tests.""" + api = MagicMock(spec=MqttApi) + api.base_topic = 'batcontrol' + api.client = MagicMock() + api.client.is_connected.return_value = True + api._topic = MqttApi._topic.__get__(api, MqttApi) + api.publish_SOC = MqttApi.publish_SOC.__get__(api, MqttApi) + api.publish_discharge_blocked = ( + MqttApi.publish_discharge_blocked.__get__(api, MqttApi) + ) + return api + + class TestHandleMessageBytesDecoding: """_handle_message must decode bytes payloads before calling convert.""" @@ -105,6 +119,49 @@ def test_str_convert_with_plain_string(self): assert received == ['true'] +class TestReconnectSubscriptions: + """Reconnect handling should subscribe to each callback topic once.""" + + def test_on_connect_subscribes_each_callback_topic_once(self): + api = _make_handler_stub() + api.base_topic = 'batcontrol' + api.auto_discover_enable = False + api.callbacks = { + 'batcontrol/mode/set': {}, + 'batcontrol/charge_rate/set': {}, + } + api.on_connect = MqttApi.on_connect.__get__(api, MqttApi) + client = MagicMock() + + api.on_connect(client, None, None, 0) + + assert client.subscribe.call_args_list == [ + call('batcontrol/mode/set'), + call('batcontrol/charge_rate/set'), + ] + + +class TestPublishedState: + """Published state payloads should preserve precision and parse cleanly.""" + + def test_publish_soc_uses_decimal_precision(self): + api = _make_publish_stub() + + api.publish_SOC(87.65) + + api.client.publish.assert_called_once_with('batcontrol/SOC', '87.65') + + def test_publish_discharge_blocked_uses_lowercase_boolean(self): + api = _make_publish_stub() + + api.publish_discharge_blocked(True) + + api.client.publish.assert_called_once_with( + 'batcontrol/discharge_blocked', + 'true', + ) + + class TestModeDiscovery: """Mode discovery should expose the full externally supported mode model.""" @@ -169,6 +226,22 @@ def test_discovery_includes_api_override_active_binary_sensor(self): for call in api.publish_mqtt_discovery_message.call_args_list ) + def test_discharge_blocked_discovery_accepts_lowercase_true(self): + api = _make_discovery_stub() + + api.send_mqtt_discovery_messages() + + assert any( + call.args[:3] == ( + 'Discharge Blocked', + 'batcontrol_discharge_blocked', + 'sensor', + ) + and call.args[5] == 'batcontrol/discharge_blocked' + and "value | lower == 'true'" in call.kwargs['value_template'] + for call in api.publish_mqtt_discovery_message.call_args_list + ) + class TestPeakShavingEnabledApi: """Regression test: peak_shaving/enabled must correctly parse the bytes payload."""