From a27584476b63d6970fd1dcef8887cef08071d055 Mon Sep 17 00:00:00 2001 From: Bartlomiej Kobus Date: Wed, 12 Aug 2026 14:24:45 +0200 Subject: [PATCH] fix: handle sensor state and thermoBox safety off --- blebox_uniapi/box_types.py | 30 +++++++++- blebox_uniapi/climate.py | 45 ++++++++++++--- blebox_uniapi/sensor.py | 109 +++++++++++++++++++++++++++++------- tests/test_climate.py | 40 +++++++++++++- tests/test_sensor.py | 110 +++++++++++++++++++++++++++++++++++++ 5 files changed, 305 insertions(+), 29 deletions(-) diff --git a/blebox_uniapi/box_types.py b/blebox_uniapi/box_types.py index fde922d..305a7b1 100644 --- a/blebox_uniapi/box_types.py +++ b/blebox_uniapi/box_types.py @@ -191,6 +191,7 @@ def get_latest_api_level(product_type: str) -> Union[dict, int]: "minimum": "thermo.minimumTemp", "maximum": "thermo.maximumTemp", "temperature": lambda x: f"sensors[?id == `{x}`]|[0]|value", + "temperature.state": lambda x: f"sensors[?id == `{x}`]|[0]|state", "state": "thermo.state", "mode": "thermo.mode", "safetySensorId": "thermo.safetyTempSensor.sensorId", @@ -203,6 +204,7 @@ def get_latest_api_level(product_type: str) -> Union[dict, int]: "temperature", { "temperature": lambda x: f"sensors[?id == `{x}`]|[0]|value", + "temperature.state": lambda x: f"sensors[?id == `{x}`]|[0]|state", }, ] ], @@ -530,7 +532,7 @@ def get_latest_api_level(product_type: str) -> Union[dict, int]: { "temperature": "tempSensor.sensors[?id == `0`]|[0]|value", "trend": "tempSensor.sensors[?id == `0`]|[0]|trend", - "state": "tempSensor.sensors[?id == `0`]|[0]|state", + "temperature.state": "tempSensor.sensors[?id == `0`]|[0]|state", "elapsed": "tempSensor.sensors[?id == `0`]|[0]|elapsedTimeS", }, ] @@ -682,7 +684,9 @@ def get_latest_api_level(product_type: str) -> Union[dict, int]: "multiSensor", { "temperature": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", + "temperature.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|state", "wind": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", + "wind.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|state", }, ] ], @@ -704,8 +708,11 @@ def get_latest_api_level(product_type: str) -> Union[dict, int]: "multiSensor", { "temperature": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", + "temperature.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|state", "wind": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", + "wind.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|state", "humidity": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", + "humidity.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|state", }, ] ], @@ -727,9 +734,13 @@ def get_latest_api_level(product_type: str) -> Union[dict, int]: "multiSensor", { "illuminance": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", + "illuminance.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|state", "temperature": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", + "temperature.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|state", "wind": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'wind']|[0]|value", + "wind.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'wind']|[0]|state", "humidity": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", + "humidity.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|state", }, ] ], @@ -751,22 +762,39 @@ def get_latest_api_level(product_type: str) -> Union[dict, int]: "multiSensor", { "frequency": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'frequency']|[0]|value", + "frequency.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'frequency']|[0]|state", "current": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'current']|[0]|value", + "current.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'current']|[0]|state", "voltage": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'voltage']|[0]|value", + "voltage.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'voltage']|[0]|state", "apparentPower": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'apparentPower']|[0]|value", + "apparentPower.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'apparentPower']|[0]|state", "activePower": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'activePower']|[0]|value", + "activePower.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'activePower']|[0]|state", "reactivePower": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'reactivePower']|[0]|value", + "reactivePower.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'reactivePower']|[0]|state", "reverseActiveEnergy": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'reverseActiveEnergy']|[0]|value", + "reverseActiveEnergy.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'reverseActiveEnergy']|[0]|state", "forwardActiveEnergy": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'forwardActiveEnergy']|[0]|value", + "forwardActiveEnergy.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'forwardActiveEnergy']|[0]|state", "forwardReactiveEnergy": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'forwardReactiveEnergy']|[0]|value", + "forwardReactiveEnergy.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'forwardReactiveEnergy']|[0]|state", "reverseReactiveEnergy": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'reverseReactiveEnergy']|[0]|value", + "reverseReactiveEnergy.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'reverseReactiveEnergy']|[0]|state", "illuminance": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'illuminance']|[0]|value", + "illuminance.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'illuminance']|[0]|state", "temperature": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'temperature']|[0]|value", + "temperature.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'temperature']|[0]|state", "wind": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'wind']|[0]|value", + "wind.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'wind']|[0]|state", "humidity": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'humidity']|[0]|value", + "humidity.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'humidity']|[0]|state", "openStatus": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'openStatus']|[0]|value", + "openStatus.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'openStatus']|[0]|state", "co2": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'co2']|[0]|value", + "co2.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'co2']|[0]|state", "co2Definition": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'co2Definition']|[0]|value", + "co2Definition.state": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'co2Definition']|[0]|state", }, ] ], diff --git a/blebox_uniapi/climate.py b/blebox_uniapi/climate.py index 6bae5be..16fbc7a 100644 --- a/blebox_uniapi/climate.py +++ b/blebox_uniapi/climate.py @@ -1,9 +1,19 @@ +from enum import IntEnum from .sensor import Temperature from typing import Optional, Any, Union from .feature import Feature from blebox_uniapi.jfollow import follow +class BleboxThermoState(IntEnum): + """Possible states of a thermo regulator, as reported by the device.""" + + OFF = 0 + ON = 1 + BOOST = 3 + SAFETY_OFF = 4 + + class Climate(Temperature): _is_on: Optional[bool] _desired: Union[float, int, None] @@ -90,12 +100,18 @@ def _read_is_on(self) -> Optional[bool]: raw = self.raw_value("state") if raw is not None: # no reading alias = self._alias - return product.expect_int(alias, raw, 3, 0) in ( - 1, - 3, - ) # 1: On, 3: Boost(thermoBox max temp of mode) + return product.expect_int(alias, raw, max(BleboxThermoState), 0) in ( + BleboxThermoState.ON, + BleboxThermoState.BOOST, + ) return None + def _read_safety_off(self) -> bool: + """Return True when the safety system forced the output off.""" + if self._product.last_data is None: + return False + return self.raw_value("state") == BleboxThermoState.SAFETY_OFF + def _read_operating_state(self) -> Optional[int]: """Return current operating state""" if self._product.last_data is not None: @@ -107,13 +123,13 @@ def _read_operating_state(self) -> Optional[int]: return None def _read_is_heating(self) -> Optional[bool]: - if not self._product.last_data: + if not self._product.last_data or self.current is None or self.desired is None: return None return self.is_on and (self.current < self.desired) def _read_is_cooling(self) -> Optional[bool]: - if not self._product.last_data: + if not self._product.last_data or self.current is None or self.desired is None: return None return self.is_on and (self.current > self.desired) @@ -131,7 +147,22 @@ def _read_mode(self) -> Optional[int]: def after_update(self) -> None: self._is_on = self._read_is_on() self._desired = self._read_temperature("desired") - self._current = self._read_temperature("temperature") + + state = self._read_state("temperature") + if self._state_is_error(state): + self._error = True + self._current = None + else: + self._error = False + if self._state_is_initializing(state): + self._current = None + else: + self._current = self._read_temperature("temperature") + + if self._read_safety_off(): + self._error = True + + self._native_value = self._current self._is_heating = self._read_is_heating() self._is_cooling = self._read_is_cooling() self._havc_action = self._read_operating_state() diff --git a/blebox_uniapi/sensor.py b/blebox_uniapi/sensor.py index 7f979d3..3d9044b 100644 --- a/blebox_uniapi/sensor.py +++ b/blebox_uniapi/sensor.py @@ -1,5 +1,5 @@ import datetime -import numbers +from enum import IntEnum from functools import partial from .feature import Feature @@ -67,10 +67,9 @@ def many_from_config(cls, product, box_type_config, extended_state): # note: methods for sensor readings are provided as template # functions (lambdas) in the box config. We need to "materialize" # them to make sure they are properly indexed by sensor ID - materialized_methods = { - **methods, - device_class: methods[device_class](sensor_id), - } + materialized_methods = Feature.resolve_access_method_paths( + methods, sensor_id + ) feature = constructor( product=product, @@ -97,12 +96,24 @@ def many_from_config(cls, product, box_type_config, extended_state): return [] +class BleboxSensorState(IntEnum): + """Possible states of a sensor reading, not all valid for every device type.""" + + IDLE = 0 + INITIALIZING = 1 + ACTIVE = 2 + ERROR = 3 + ABOVE_RANGE = 4 + BELOW_RANGE = 5 + + class BaseSensor(Feature): _unit: str _device_class: str - _native_value: Union[float, int, str] + _native_value: Optional[Union[float, int, str]] = None _sensor_type: Optional[str] _sensor_id: Optional[int] + _error: bool = False def __init__( self, @@ -130,6 +141,26 @@ def device_class(self) -> str: def native_value(self): return self._native_value + @property + def is_error(self) -> bool: + return self._error + + def _read_state(self, name: str) -> Optional[int]: + if self._product.last_data is None: + return None + raw = self.raw_value(f"{name}.state") + if not isinstance(raw, (int, float)): + return None + return int(raw) + + @staticmethod + def _state_is_error(state: Optional[int]) -> bool: + return state == BleboxSensorState.ERROR + + @staticmethod + def _state_is_initializing(state: Optional[int]) -> bool: + return state == BleboxSensorState.INITIALIZING + @property def sensor_id(self): return self._sensor_id @@ -201,9 +232,22 @@ def after_update(self): if product.last_data is None: return + state = self._read_state(self._device_class) + if self._state_is_error(state): + self._error = True + self._native_value = None + return + + self._error = False + + if self._state_is_initializing(state): + self._native_value = None + return + raw = self.raw_value(self._device_class) - if not isinstance(raw, numbers.Number): - raw = float("nan") + if not isinstance(raw, (int, float)): + self._native_value = None + return native = raw / self._scale if self._precision: @@ -234,7 +278,7 @@ def _read_period_of_measurement(self) -> int: @SensorFactory.register("temperature") class Temperature(BaseSensor): - _current: Union[float, int, None] + _current: Optional[Union[float, int]] def __init__( self, @@ -249,21 +293,35 @@ def __init__( self._device_class = "temperature" @property - def current(self) -> Union[float, int, None]: + def current(self) -> Optional[Union[float, int]]: return self._current - def _read_temperature(self, field: str) -> Union[float, int, None]: + def _read_temperature(self, field: str) -> Optional[Union[float, int]]: product = self._product if product.last_data is not None: raw = self.raw_value(field) - if raw is not None: - alias = self._alias - return round(product.expect_int(alias, raw, 12500, -5500) / 100.0, 1) + if isinstance(raw, (int, float)): + return round(raw / 100.0, 1) return None def after_update(self) -> None: - self._current = self._read_temperature("temperature") - self._native_value = self._read_temperature("temperature") + state = self._read_state("temperature") + if self._state_is_error(state): + self._error = True + self._current = None + self._native_value = None + return + + self._error = False + + if self._state_is_initializing(state): + self._current = None + self._native_value = None + return + + current = self._read_temperature("temperature") + self._current = current + self._native_value = current @SensorFactory.register("airSensor") @@ -282,14 +340,25 @@ def __init__( self._unit = "concentration_of_mp" self._device_class = alias - def _pm_value(self, name: str) -> Optional[int]: + def _pm_value(self, name: str) -> Optional[Union[int, float]]: product = self._product if product.last_data is not None: raw = self.raw_value(name) - if raw is not None: - alias = self._alias - return product.expect_int(alias, raw, 3000, 0) + if isinstance(raw, (int, float)): + return raw return None def after_update(self) -> None: + state = self._read_state(self.device_class) + if self._state_is_error(state): + self._error = True + self._native_value = None + return + + self._error = False + + if self._state_is_initializing(state): + self._native_value = None + return + self._native_value = self._pm_value(f"{self.device_class}.value") diff --git a/tests/test_climate.py b/tests/test_climate.py index 3a4d70b..7fadd79 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -39,7 +39,6 @@ class BleBoxClimateEntity(CommonEntity, ClimateDevice): def __init__(self, feature): super().__init__(feature) ClimateDevice.__init__(self) - pass """Representation of a BleBox climate feature.""" @@ -294,6 +293,45 @@ async def test_thermo_init(self, aioclient_mock): entity = (await self.async_entities(aioclient_mock))[0] assert entity.device_info["name"] == "My ThermoBox" + async def test_thermo_sensor_error_state(self, aioclient_mock): + """A broken probe (state=3, out-of-range value) must not prevent setup.""" + self.DEVICE_INFO = self.DEVICE_INFO_THERMO + self.DEVICE_EXTENDED_INFO = jmerge( + self.DEVICE_EXTENDED_INFO_THERMO, + '{ "sensors": [ { "value": 18349, "state": 3 } ] }', + ) + await self.allow_get_info(aioclient_mock) + entity = (await self.async_entities(aioclient_mock))[0] + + assert entity.current_temperature is None + assert entity._feature.is_error is True + + async def test_thermo_sensor_initializing_state(self, aioclient_mock): + """state=1 (measurement in progress) must not surface a value or an error.""" + self.DEVICE_INFO = self.DEVICE_INFO_THERMO + self.DEVICE_EXTENDED_INFO = jmerge( + self.DEVICE_EXTENDED_INFO_THERMO, + '{ "sensors": [ { "value": 18349, "state": 1 } ] }', + ) + await self.allow_get_info(aioclient_mock) + entity = (await self.async_entities(aioclient_mock))[0] + + assert entity.current_temperature is None + assert entity._feature.is_error is False + + async def test_thermo_safety_off_state(self, aioclient_mock): + """state=4 (output blocked by safety system) must not prevent setup.""" + self.DEVICE_INFO = self.DEVICE_INFO_THERMO + self.DEVICE_EXTENDED_INFO = jmerge( + self.DEVICE_EXTENDED_INFO_THERMO, + '{ "thermo": { "state": 4 } }', + ) + await self.allow_get_info(aioclient_mock) + entity = (await self.async_entities(aioclient_mock))[0] + + assert entity._feature.is_on is False + assert entity._feature.is_error is True + async def test_device_info(self, aioclient_mock): await self.allow_get_info(aioclient_mock, self.DEVICE_INFO) entity = (await self.async_entities(aioclient_mock))[0] diff --git a/tests/test_sensor.py b/tests/test_sensor.py index b83dab8..a63cab2 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -283,6 +283,73 @@ async def test_multisensor_update(self, aioclient_mock): assert entity.native_value == 1.2 + STATE_SENSOR_ERROR = json.loads( + """ + { + "tempSensor": { + "sensors": [ + { + "type": "temperature", + "id": 0, + "value": 18349, + "trend": 3, + "state": 3, + "elapsedTimeS": 0 + } + ] + } + } + """ + ) + + async def test_update_with_sensor_error_state_ignores_out_of_range_value( + self, aioclient_mock + ): + """Regression test: a broken probe must not prevent device setup.""" + + entity = await self.updated(aioclient_mock, self.STATE_SENSOR_ERROR) + + assert entity.native_value is None + assert entity._feature.is_error is True + + async def test_valid_state_trusts_out_of_nominal_range_value(self, aioclient_mock): + """state == 2 means the value is valid, even outside the nominal range.""" + + state = jmerge( + self.STATE_DEFAULT, + '{ "tempSensor": { "sensors": [ { "value": 18349, "state": 2 } ] } }', + ) + + entity = await self.updated(aioclient_mock, state) + + assert entity._feature.is_error is False + assert entity.native_value == 183.5 + + async def test_initializing_state_reports_unknown_not_error(self, aioclient_mock): + """state == 1 (measurement in progress) must not surface a value or an error.""" + + state = jmerge( + self.STATE_DEFAULT, + '{ "tempSensor": { "sensors": [ { "value": 18349, "state": 1 } ] } }', + ) + + entity = await self.updated(aioclient_mock, state) + + assert entity._feature.is_error is False + assert entity.native_value is None + + async def test_update_recovers_after_sensor_error_clears(self, aioclient_mock): + """Entity value is restored once the sensor reports a valid state again.""" + + entity = await self.updated(aioclient_mock, self.STATE_SENSOR_ERROR) + assert entity._feature.is_error is True + + product = entity._feature.product + product._update_last_data(self.STATE_DEFAULT) + + assert entity._feature.is_error is False + assert entity.native_value == 25.2 + class TestMultiSensorEnergy(DefaultBoxTest): """Tests for multiSensor energy/current sensors (apiLevel 20230606).""" @@ -351,6 +418,49 @@ async def test_sensor_value(self, aioclient_mock, index, unit, value): assert entity._feature.unit == unit assert entity.native_value == value + async def test_sensor_error_state_reports_error(self, aioclient_mock): + """state == 3 is the only value that marks the sensor as errored.""" + + state_json = jmerge( + self.STATE_DEFAULT, + '{ "multiSensor": { "sensors": [ { "state": 3 } ] } }', + ) + + entity = await self.updated(aioclient_mock, state_json, index=0) + + assert entity.native_value is None + assert entity._feature.is_error is True + + async def test_sensor_initializing_state_reports_unknown_not_error( + self, aioclient_mock + ): + """state == 1 (measurement in progress) must not surface a value or an error.""" + + state_json = jmerge( + self.STATE_DEFAULT, + '{ "multiSensor": { "sensors": [ { "state": 1 } ] } }', + ) + + entity = await self.updated(aioclient_mock, state_json, index=0) + + assert entity._feature.is_error is False + assert entity.native_value is None + + @pytest.mark.parametrize("state", [0, 2, 4, 5]) + async def test_sensor_valid_state_reports_value(self, aioclient_mock, state): + """States other than 1 (init) and 3 (error) trust the reported value, + including 4/5 (out of range), where value is the measurement limit.""" + + state_json = jmerge( + self.STATE_DEFAULT, + f'{{ "multiSensor": {{ "sensors": [ {{ "state": {state} }} ] }} }}', + ) + + entity = await self.updated(aioclient_mock, state_json, index=0) + + assert entity._feature.is_error is False + assert entity.native_value == 1500 + class TestAirSensor(DefaultBoxTest): """Tests for sensors representing BleBox airSensor."""