From 511e0e40a07c140c43614ed74b4c590a4e5e78a0 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 11 Feb 2026 11:05:52 +0100 Subject: [PATCH 1/7] feat: add HA-compatible device and state class mappings for BSB-LAN units --- src/bsblan/constants.py | 69 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/bsblan/constants.py b/src/bsblan/constants.py index bc3336ca..af9fcd46 100644 --- a/src/bsblan/constants.py +++ b/src/bsblan/constants.py @@ -415,6 +415,75 @@ def get_hvac_action_category(status_code: int) -> HVACActionCategory: # Handle both ASCII and Unicode degree symbols TEMPERATURE_UNITS = {"°C", "°F", "°C", "°F", "°C", "°F"} +# HA-compatible device class mapping from BSB-LAN units +# Maps unit strings (including HTML-encoded variants) to HA SensorDeviceClass values +# See: https://developers.home-assistant.io/docs/core/entity/sensor/#available-device-classes +UNIT_DEVICE_CLASS_MAP: Final[dict[str, str]] = { + # Temperature + "°C": "temperature", + "°F": "temperature", + "°C": "temperature", + "°F": "temperature", + "°C": "temperature", + "°F": "temperature", + # Energy + "kWh": "energy", + "MWh": "energy", + "Wh": "energy", + # Power + "kW": "power", + "W": "power", + # Pressure + "bar": "pressure", + "Pa": "pressure", + "hPa": "pressure", + # Voltage + "V": "voltage", + # Current + "A": "current", + "mA": "current", + # Frequency + "Hz": "frequency", + # Volume flow rate + "l/min": "volume_flow_rate", + "l/h": "volume_flow_rate", + # Duration + "h": "duration", + "min": "duration", + "s": "duration", + # Percentage + "%": "power_factor", +} + +# HA-compatible state class mapping from BSB-LAN units +# Maps unit strings to HA SensorStateClass values +# See: https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes +UNIT_STATE_CLASS_MAP: Final[dict[str, str]] = { + # Energy counters are always total_increasing + "kWh": "total_increasing", + "MWh": "total_increasing", + "Wh": "total_increasing", + # All other numeric measurements + "°C": "measurement", + "°F": "measurement", + "°C": "measurement", + "°F": "measurement", + "°C": "measurement", + "°F": "measurement", + "kW": "measurement", + "W": "measurement", + "bar": "measurement", + "Pa": "measurement", + "hPa": "measurement", + "V": "measurement", + "A": "measurement", + "mA": "measurement", + "Hz": "measurement", + "l/min": "measurement", + "l/h": "measurement", + "%": "measurement", +} + # Hot Water Parameter Groups # Essential parameters for frequent monitoring HOT_WATER_ESSENTIAL_PARAMS: Final[set[str]] = { From 4f52b1e48367675b89778bd4d457daf9d151b8fd Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 11 Feb 2026 11:06:01 +0100 Subject: [PATCH 2/7] feat: enhance EntityInfo with device and state class suggestions for Home Assistant --- src/bsblan/__init__.py | 4 ++++ src/bsblan/models.py | 42 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/bsblan/__init__.py b/src/bsblan/__init__.py index 86b3e833..cf9542ba 100644 --- a/src/bsblan/__init__.py +++ b/src/bsblan/__init__.py @@ -2,6 +2,8 @@ from .bsblan import BSBLAN, BSBLANConfig from .constants import ( + UNIT_DEVICE_CLASS_MAP, + UNIT_STATE_CLASS_MAP, HeatingCircuitStatus, HVACActionCategory, get_hvac_action_category, @@ -27,6 +29,8 @@ __all__ = [ "BSBLAN", + "UNIT_DEVICE_CLASS_MAP", + "UNIT_STATE_CLASS_MAP", "BSBLANAuthError", "BSBLANConfig", "BSBLANConnectionError", diff --git a/src/bsblan/models.py b/src/bsblan/models.py index f93ad399..4e055108 100644 --- a/src/bsblan/models.py +++ b/src/bsblan/models.py @@ -11,7 +11,11 @@ from mashumaro.mixins.json import DataClassJSONMixin -from bsblan.constants import TEMPERATURE_UNITS +from bsblan.constants import ( + TEMPERATURE_UNITS, + UNIT_DEVICE_CLASS_MAP, + UNIT_STATE_CLASS_MAP, +) # Maximum number of time slots per day supported by BSB-LAN MAX_TIME_SLOTS_PER_DAY: Final[int] = 3 @@ -225,6 +229,8 @@ class EntityInfo(DataClassJSONMixin): readonly: Whether the value is read-only. readwrite: Whether the value is read-write. precision: Optional precision for numeric values. + data_type_name: BSB-LAN data type name (e.g., "TEMP", "ENUM"). + data_type_family: BSB-LAN data type family (e.g., "VALS", "ENUM"). """ @@ -237,6 +243,8 @@ class EntityInfo(DataClassJSONMixin): readonly: int = field(default=0) readwrite: int = field(default=0) precision: float | None = field(default=None) + data_type_name: str = field(default="", metadata={"alias": "dataType_name"}) + data_type_family: str = field(default="", metadata={"alias": "dataType_family"}) def __post_init__(self) -> None: """Convert values based on data_type after initialization.""" @@ -312,6 +320,38 @@ def enum_description(self) -> str | None: """ return self.desc if self.data_type == DataType.ENUM else None + @property + def suggested_device_class(self) -> str | None: + """Suggest HA SensorDeviceClass based on unit and data type. + + This maps BSB-LAN units to Home Assistant sensor device classes, + enabling automatic entity configuration in HA integrations. + + Returns: + str | None: The suggested HA device class (e.g., "temperature", + "energy", "power"), or None if no mapping exists. + + """ + return UNIT_DEVICE_CLASS_MAP.get(self.unit) + + @property + def suggested_state_class(self) -> str | None: + """Suggest HA SensorStateClass based on unit. + + This maps BSB-LAN units to Home Assistant sensor state classes, + which determine how the data is tracked (measurement vs + total_increasing). + + Energy counters (kWh, MWh, Wh) are mapped to "total_increasing", + while other numeric measurements use "measurement". + + Returns: + str | None: The suggested HA state class (e.g., "measurement", + "total_increasing"), or None if no mapping exists. + + """ + return UNIT_STATE_CLASS_MAP.get(self.unit) + @dataclass class SetHotWaterParam: From 501644124c5de1bd8d7b94e41d41b9125a89ac21 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 11 Feb 2026 11:06:07 +0100 Subject: [PATCH 3/7] feat: add comprehensive tests for suggested device and state classes in EntityInfo --- tests/test_entity_info_ha.py | 420 +++++++++++++++++++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 tests/test_entity_info_ha.py diff --git a/tests/test_entity_info_ha.py b/tests/test_entity_info_ha.py new file mode 100644 index 00000000..42d4b38a --- /dev/null +++ b/tests/test_entity_info_ha.py @@ -0,0 +1,420 @@ +"""Tests for EntityInfo HA integration properties (device class, state class).""" + +from bsblan.models import DataType, EntityInfo + + +def test_suggested_device_class_temperature_celsius() -> None: + """Test suggested_device_class for °C unit.""" + entity = EntityInfo( + name="Current Temperature", + value="22.5", + unit="°C", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == "temperature" + + +def test_suggested_device_class_temperature_fahrenheit() -> None: + """Test suggested_device_class for °F unit.""" + entity = EntityInfo( + name="Current Temperature", + value="72.5", + unit="°F", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == "temperature" + + +def test_suggested_device_class_temperature_html_encoded() -> None: + """Test suggested_device_class for HTML-encoded degree symbol.""" + entity = EntityInfo( + name="Outside Temperature", + value="7.6", + unit="°C", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == "temperature" + + +def test_suggested_device_class_temperature_numeric_encoded() -> None: + """Test suggested_device_class for numeric HTML-encoded degree symbol.""" + entity = EntityInfo( + name="Room Temperature", + value="18.2", + unit="°C", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == "temperature" + + +def test_suggested_device_class_energy_kwh() -> None: + """Test suggested_device_class for kWh unit (energy counter).""" + entity = EntityInfo( + name="Energie utilisée", + value="7538", + unit="kWh", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == "energy" + + +def test_suggested_device_class_energy_wh() -> None: + """Test suggested_device_class for Wh unit.""" + entity = EntityInfo( + name="Energy", + value="100", + unit="Wh", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == "energy" + + +def test_suggested_device_class_energy_mwh() -> None: + """Test suggested_device_class for MWh unit.""" + entity = EntityInfo( + name="Energy Total", + value="7.5", + unit="MWh", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == "energy" + + +def test_suggested_device_class_power_kw() -> None: + """Test suggested_device_class for kW unit.""" + entity = EntityInfo( + name="Power", + value="3.5", + unit="kW", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == "power" + + +def test_suggested_device_class_power_w() -> None: + """Test suggested_device_class for W unit.""" + entity = EntityInfo( + name="Power", + value="350", + unit="W", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == "power" + + +def test_suggested_device_class_pressure_bar() -> None: + """Test suggested_device_class for bar unit.""" + entity = EntityInfo( + name="Pressure", + value="1.5", + unit="bar", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == "pressure" + + +def test_suggested_device_class_voltage() -> None: + """Test suggested_device_class for V unit.""" + entity = EntityInfo( + name="Voltage", + value="230", + unit="V", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == "voltage" + + +def test_suggested_device_class_current() -> None: + """Test suggested_device_class for A unit.""" + entity = EntityInfo( + name="Current", + value="5.2", + unit="A", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == "current" + + +def test_suggested_device_class_frequency() -> None: + """Test suggested_device_class for Hz unit.""" + entity = EntityInfo( + name="Frequency", + value="50", + unit="Hz", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == "frequency" + + +def test_suggested_device_class_volume_flow_rate() -> None: + """Test suggested_device_class for l/min unit.""" + entity = EntityInfo( + name="Flow Rate", + value="12", + unit="l/min", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == "volume_flow_rate" + + +def test_suggested_device_class_duration() -> None: + """Test suggested_device_class for h unit.""" + entity = EntityInfo( + name="Runtime", + value="1500", + unit="h", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == "duration" + + +def test_suggested_device_class_percentage() -> None: + """Test suggested_device_class for % unit.""" + entity = EntityInfo( + name="COP", + value="0.94", + unit="%", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == "power_factor" + + +def test_suggested_device_class_none_for_enum() -> None: + """Test suggested_device_class returns None for ENUM with no unit.""" + entity = EntityInfo( + name="Operating mode", + value="3", + unit="", + desc="Comfort", + data_type=DataType.ENUM, + ) + assert entity.suggested_device_class is None + + +def test_suggested_device_class_none_for_unknown_unit() -> None: + """Test suggested_device_class returns None for unknown units.""" + entity = EntityInfo( + name="Unknown", + value="42", + unit="bbl", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class is None + + +def test_suggested_state_class_energy_total_increasing() -> None: + """Test suggested_state_class for energy counters is total_increasing.""" + entity = EntityInfo( + name="Energie utilisée", + value="7538", + unit="kWh", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_state_class == "total_increasing" + + +def test_suggested_state_class_energy_mwh_total_increasing() -> None: + """Test suggested_state_class for MWh is total_increasing.""" + entity = EntityInfo( + name="Energy Total", + value="7.5", + unit="MWh", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_state_class == "total_increasing" + + +def test_suggested_state_class_energy_wh_total_increasing() -> None: + """Test suggested_state_class for Wh is total_increasing.""" + entity = EntityInfo( + name="Energy", + value="100", + unit="Wh", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_state_class == "total_increasing" + + +def test_suggested_state_class_temperature_measurement() -> None: + """Test suggested_state_class for temperature is measurement.""" + entity = EntityInfo( + name="Temperature", + value="22.5", + unit="°C", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_state_class == "measurement" + + +def test_suggested_state_class_power_measurement() -> None: + """Test suggested_state_class for power is measurement.""" + entity = EntityInfo( + name="Power", + value="3.5", + unit="kW", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_state_class == "measurement" + + +def test_suggested_state_class_pressure_measurement() -> None: + """Test suggested_state_class for pressure is measurement.""" + entity = EntityInfo( + name="Pressure", + value="1.5", + unit="bar", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_state_class == "measurement" + + +def test_suggested_state_class_none_for_enum() -> None: + """Test suggested_state_class returns None for ENUM with no unit.""" + entity = EntityInfo( + name="Operating mode", + value="3", + unit="", + desc="Comfort", + data_type=DataType.ENUM, + ) + assert entity.suggested_state_class is None + + +def test_suggested_state_class_none_for_unknown_unit() -> None: + """Test suggested_state_class returns None for unknown units.""" + entity = EntityInfo( + name="Unknown", + value="42", + unit="bbl", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_state_class is None + + +def test_data_type_name_default() -> None: + """Test data_type_name defaults to empty string.""" + entity = EntityInfo( + name="Test", + value="42", + unit="kWh", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.data_type_name == "" + + +def test_data_type_family_default() -> None: + """Test data_type_family defaults to empty string.""" + entity = EntityInfo( + name="Test", + value="42", + unit="kWh", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.data_type_family == "" + + +def test_data_type_name_from_json() -> None: + """Test data_type_name populated from JSON response.""" + entity = EntityInfo.from_dict( + { + "name": "Comfort setpoint", + "dataType_name": "TEMP", + "dataType_family": "VALS", + "error": 0, + "value": "18.0", + "desc": "", + "precision": 0.1, + "dataType": 0, + "readonly": 0, + "readwrite": 0, + "unit": "°C", + } + ) + assert entity.data_type_name == "TEMP" + assert entity.data_type_family == "VALS" + + +def test_data_type_name_from_json_enum() -> None: + """Test data_type_name for ENUM type from JSON response.""" + entity = EntityInfo.from_dict( + { + "name": "Operating mode", + "dataType_name": "ENUM", + "dataType_family": "ENUM", + "error": 0, + "value": "3", + "desc": "Comfort", + "dataType": 1, + "readonly": 0, + "readwrite": 0, + "unit": "", + } + ) + assert entity.data_type_name == "ENUM" + assert entity.data_type_family == "ENUM" + + +def test_backwards_compat_no_data_type_name_in_json() -> None: + """Test EntityInfo works when JSON has no dataType_name/family.""" + entity = EntityInfo.from_dict( + { + "name": "Outside temp", + "error": 0, + "value": "7.6", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C", + } + ) + assert entity.data_type_name == "" + assert entity.data_type_family == "" + assert entity.suggested_device_class == "temperature" + assert entity.suggested_state_class == "measurement" + + +def test_backwards_compat_existing_fields_unchanged() -> None: + """Test that existing fields still work identically.""" + entity = EntityInfo( + name="Test Temp", + value="22.5", + unit="°C", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + # Existing behavior: value converted to float for temperature + assert entity.value == 22.5 + assert entity.name == "Test Temp" + assert entity.unit == "°C" + assert entity.data_type == DataType.PLAIN_NUMBER + assert entity.error == 0 + assert entity.readonly == 0 + assert entity.readwrite == 0 + assert entity.precision is None + assert entity.enum_description is None From 81100efa52c097691376af481f46ba9acb2c1b4f Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 11 Feb 2026 11:19:16 +0100 Subject: [PATCH 4/7] feat: refactor tests for suggested device and state classes in EntityInfo --- tests/test_entity_info_ha.py | 440 +++++++++-------------------------- 1 file changed, 114 insertions(+), 326 deletions(-) diff --git a/tests/test_entity_info_ha.py b/tests/test_entity_info_ha.py index 42d4b38a..ffc02132 100644 --- a/tests/test_entity_info_ha.py +++ b/tests/test_entity_info_ha.py @@ -1,334 +1,133 @@ """Tests for EntityInfo HA integration properties (device class, state class).""" -from bsblan.models import DataType, EntityInfo - - -def test_suggested_device_class_temperature_celsius() -> None: - """Test suggested_device_class for °C unit.""" - entity = EntityInfo( - name="Current Temperature", - value="22.5", - unit="°C", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_device_class == "temperature" - - -def test_suggested_device_class_temperature_fahrenheit() -> None: - """Test suggested_device_class for °F unit.""" - entity = EntityInfo( - name="Current Temperature", - value="72.5", - unit="°F", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_device_class == "temperature" - - -def test_suggested_device_class_temperature_html_encoded() -> None: - """Test suggested_device_class for HTML-encoded degree symbol.""" - entity = EntityInfo( - name="Outside Temperature", - value="7.6", - unit="°C", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_device_class == "temperature" - - -def test_suggested_device_class_temperature_numeric_encoded() -> None: - """Test suggested_device_class for numeric HTML-encoded degree symbol.""" - entity = EntityInfo( - name="Room Temperature", - value="18.2", - unit="°C", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_device_class == "temperature" - - -def test_suggested_device_class_energy_kwh() -> None: - """Test suggested_device_class for kWh unit (energy counter).""" - entity = EntityInfo( - name="Energie utilisée", - value="7538", - unit="kWh", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_device_class == "energy" - - -def test_suggested_device_class_energy_wh() -> None: - """Test suggested_device_class for Wh unit.""" - entity = EntityInfo( - name="Energy", - value="100", - unit="Wh", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_device_class == "energy" - - -def test_suggested_device_class_energy_mwh() -> None: - """Test suggested_device_class for MWh unit.""" - entity = EntityInfo( - name="Energy Total", - value="7.5", - unit="MWh", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_device_class == "energy" - - -def test_suggested_device_class_power_kw() -> None: - """Test suggested_device_class for kW unit.""" - entity = EntityInfo( - name="Power", - value="3.5", - unit="kW", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_device_class == "power" - - -def test_suggested_device_class_power_w() -> None: - """Test suggested_device_class for W unit.""" - entity = EntityInfo( - name="Power", - value="350", - unit="W", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_device_class == "power" - - -def test_suggested_device_class_pressure_bar() -> None: - """Test suggested_device_class for bar unit.""" - entity = EntityInfo( - name="Pressure", - value="1.5", - unit="bar", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_device_class == "pressure" - - -def test_suggested_device_class_voltage() -> None: - """Test suggested_device_class for V unit.""" - entity = EntityInfo( - name="Voltage", - value="230", - unit="V", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_device_class == "voltage" - - -def test_suggested_device_class_current() -> None: - """Test suggested_device_class for A unit.""" - entity = EntityInfo( - name="Current", - value="5.2", - unit="A", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_device_class == "current" - - -def test_suggested_device_class_frequency() -> None: - """Test suggested_device_class for Hz unit.""" - entity = EntityInfo( - name="Frequency", - value="50", - unit="Hz", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_device_class == "frequency" - - -def test_suggested_device_class_volume_flow_rate() -> None: - """Test suggested_device_class for l/min unit.""" - entity = EntityInfo( - name="Flow Rate", - value="12", - unit="l/min", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_device_class == "volume_flow_rate" - - -def test_suggested_device_class_duration() -> None: - """Test suggested_device_class for h unit.""" - entity = EntityInfo( - name="Runtime", - value="1500", - unit="h", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_device_class == "duration" +import pytest +from bsblan.models import DataType, EntityInfo -def test_suggested_device_class_percentage() -> None: - """Test suggested_device_class for % unit.""" +# -- suggested_device_class ------------------------------------------------ + + +@pytest.mark.parametrize( + ("unit", "expected"), + [ + ("°C", "temperature"), + ("°F", "temperature"), + ("°C", "temperature"), + ("°F", "temperature"), + ("°C", "temperature"), + ("°F", "temperature"), + ("kWh", "energy"), + ("Wh", "energy"), + ("MWh", "energy"), + ("kW", "power"), + ("W", "power"), + ("bar", "pressure"), + ("Pa", "pressure"), + ("hPa", "pressure"), + ("V", "voltage"), + ("A", "current"), + ("mA", "current"), + ("Hz", "frequency"), + ("l/min", "volume_flow_rate"), + ("l/h", "volume_flow_rate"), + ("h", "duration"), + ("min", "duration"), + ("s", "duration"), + ("%", "power_factor"), + ], +) +def test_suggested_device_class(unit: str, expected: str) -> None: + """Test suggested_device_class maps unit to correct HA device class.""" entity = EntityInfo( - name="COP", - value="0.94", - unit="%", + name="Test", + value="42", + unit=unit, desc="", data_type=DataType.PLAIN_NUMBER, ) - assert entity.suggested_device_class == "power_factor" - - -def test_suggested_device_class_none_for_enum() -> None: - """Test suggested_device_class returns None for ENUM with no unit.""" - entity = EntityInfo( - name="Operating mode", - value="3", - unit="", - desc="Comfort", - data_type=DataType.ENUM, - ) - assert entity.suggested_device_class is None + assert entity.suggested_device_class == expected -def test_suggested_device_class_none_for_unknown_unit() -> None: - """Test suggested_device_class returns None for unknown units.""" +@pytest.mark.parametrize( + ("unit", "data_type"), + [ + ("", DataType.ENUM), + ("bbl", DataType.PLAIN_NUMBER), + ("unknown", DataType.PLAIN_NUMBER), + ], +) +def test_suggested_device_class_none(unit: str, data_type: int) -> None: + """Test suggested_device_class returns None for unmapped units.""" entity = EntityInfo( - name="Unknown", + name="Test", value="42", - unit="bbl", + unit=unit, desc="", - data_type=DataType.PLAIN_NUMBER, + data_type=data_type, ) assert entity.suggested_device_class is None -def test_suggested_state_class_energy_total_increasing() -> None: - """Test suggested_state_class for energy counters is total_increasing.""" - entity = EntityInfo( - name="Energie utilisée", - value="7538", - unit="kWh", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_state_class == "total_increasing" - - -def test_suggested_state_class_energy_mwh_total_increasing() -> None: - """Test suggested_state_class for MWh is total_increasing.""" - entity = EntityInfo( - name="Energy Total", - value="7.5", - unit="MWh", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_state_class == "total_increasing" - - -def test_suggested_state_class_energy_wh_total_increasing() -> None: - """Test suggested_state_class for Wh is total_increasing.""" - entity = EntityInfo( - name="Energy", - value="100", - unit="Wh", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_state_class == "total_increasing" - - -def test_suggested_state_class_temperature_measurement() -> None: - """Test suggested_state_class for temperature is measurement.""" +# -- suggested_state_class ------------------------------------------------- + + +@pytest.mark.parametrize( + ("unit", "expected"), + [ + ("kWh", "total_increasing"), + ("MWh", "total_increasing"), + ("Wh", "total_increasing"), + ("°C", "measurement"), + ("°F", "measurement"), + ("°C", "measurement"), + ("°C", "measurement"), + ("kW", "measurement"), + ("W", "measurement"), + ("bar", "measurement"), + ("Pa", "measurement"), + ("V", "measurement"), + ("A", "measurement"), + ("Hz", "measurement"), + ("l/min", "measurement"), + ("%", "measurement"), + ], +) +def test_suggested_state_class(unit: str, expected: str) -> None: + """Test suggested_state_class maps unit to correct HA state class.""" entity = EntityInfo( - name="Temperature", - value="22.5", - unit="°C", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_state_class == "measurement" - - -def test_suggested_state_class_power_measurement() -> None: - """Test suggested_state_class for power is measurement.""" - entity = EntityInfo( - name="Power", - value="3.5", - unit="kW", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.suggested_state_class == "measurement" - - -def test_suggested_state_class_pressure_measurement() -> None: - """Test suggested_state_class for pressure is measurement.""" - entity = EntityInfo( - name="Pressure", - value="1.5", - unit="bar", + name="Test", + value="42", + unit=unit, desc="", data_type=DataType.PLAIN_NUMBER, ) - assert entity.suggested_state_class == "measurement" - - -def test_suggested_state_class_none_for_enum() -> None: - """Test suggested_state_class returns None for ENUM with no unit.""" - entity = EntityInfo( - name="Operating mode", - value="3", - unit="", - desc="Comfort", - data_type=DataType.ENUM, - ) - assert entity.suggested_state_class is None + assert entity.suggested_state_class == expected -def test_suggested_state_class_none_for_unknown_unit() -> None: - """Test suggested_state_class returns None for unknown units.""" +@pytest.mark.parametrize( + ("unit", "data_type"), + [ + ("", DataType.ENUM), + ("bbl", DataType.PLAIN_NUMBER), + ], +) +def test_suggested_state_class_none(unit: str, data_type: int) -> None: + """Test suggested_state_class returns None for unmapped units.""" entity = EntityInfo( - name="Unknown", + name="Test", value="42", - unit="bbl", + unit=unit, desc="", - data_type=DataType.PLAIN_NUMBER, + data_type=data_type, ) assert entity.suggested_state_class is None -def test_data_type_name_default() -> None: - """Test data_type_name defaults to empty string.""" - entity = EntityInfo( - name="Test", - value="42", - unit="kWh", - desc="", - data_type=DataType.PLAIN_NUMBER, - ) - assert entity.data_type_name == "" +# -- dataType_name / dataType_family fields -------------------------------- -def test_data_type_family_default() -> None: - """Test data_type_family defaults to empty string.""" +def test_data_type_name_and_family_default() -> None: + """Test data_type_name and data_type_family default to empty string.""" entity = EntityInfo( name="Test", value="42", @@ -336,48 +135,37 @@ def test_data_type_family_default() -> None: desc="", data_type=DataType.PLAIN_NUMBER, ) + assert entity.data_type_name == "" assert entity.data_type_family == "" -def test_data_type_name_from_json() -> None: - """Test data_type_name populated from JSON response.""" +@pytest.mark.parametrize( + ("type_name", "type_family"), + [ + ("TEMP", "VALS"), + ("ENUM", "ENUM"), + ], +) +def test_data_type_name_from_json(type_name: str, type_family: str) -> None: + """Test data_type_name/family populated from JSON response.""" entity = EntityInfo.from_dict( { - "name": "Comfort setpoint", - "dataType_name": "TEMP", - "dataType_family": "VALS", + "name": "Test", + "dataType_name": type_name, + "dataType_family": type_family, "error": 0, "value": "18.0", "desc": "", - "precision": 0.1, "dataType": 0, "readonly": 0, - "readwrite": 0, "unit": "°C", } ) - assert entity.data_type_name == "TEMP" - assert entity.data_type_family == "VALS" + assert entity.data_type_name == type_name + assert entity.data_type_family == type_family -def test_data_type_name_from_json_enum() -> None: - """Test data_type_name for ENUM type from JSON response.""" - entity = EntityInfo.from_dict( - { - "name": "Operating mode", - "dataType_name": "ENUM", - "dataType_family": "ENUM", - "error": 0, - "value": "3", - "desc": "Comfort", - "dataType": 1, - "readonly": 0, - "readwrite": 0, - "unit": "", - } - ) - assert entity.data_type_name == "ENUM" - assert entity.data_type_family == "ENUM" +# -- Backwards compatibility ----------------------------------------------- def test_backwards_compat_no_data_type_name_in_json() -> None: From 88c4ba430f0181fb8cb9aaa7fd10f5da91ddd848 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 11 Feb 2026 11:26:35 +0100 Subject: [PATCH 5/7] feat: enhance unit mappings and device class suggestions for non-numeric types in EntityInfo --- src/bsblan/constants.py | 3 +++ src/bsblan/models.py | 23 +++++++++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/bsblan/constants.py b/src/bsblan/constants.py index af9fcd46..355ffa0e 100644 --- a/src/bsblan/constants.py +++ b/src/bsblan/constants.py @@ -482,6 +482,9 @@ def get_hvac_action_category(status_code: int) -> HVACActionCategory: "l/min": "measurement", "l/h": "measurement", "%": "measurement", + "h": "measurement", + "min": "measurement", + "s": "measurement", } # Hot Water Parameter Groups diff --git a/src/bsblan/models.py b/src/bsblan/models.py index 4e055108..548b58a7 100644 --- a/src/bsblan/models.py +++ b/src/bsblan/models.py @@ -324,32 +324,39 @@ def enum_description(self) -> str | None: def suggested_device_class(self) -> str | None: """Suggest HA SensorDeviceClass based on unit and data type. - This maps BSB-LAN units to Home Assistant sensor device classes, - enabling automatic entity configuration in HA integrations. + Only PLAIN_NUMBER data types are considered sensor-like values. + Returns None for ENUM, TIME, WEEKDAY, STRING, and other + non-numeric types even if they carry a unit. Returns: str | None: The suggested HA device class (e.g., "temperature", - "energy", "power"), or None if no mapping exists. + "energy", "power"), or None if no mapping exists or the + data type is not numeric. """ + if self.data_type != DataType.PLAIN_NUMBER: + return None return UNIT_DEVICE_CLASS_MAP.get(self.unit) @property def suggested_state_class(self) -> str | None: - """Suggest HA SensorStateClass based on unit. + """Suggest HA SensorStateClass based on unit and data type. - This maps BSB-LAN units to Home Assistant sensor state classes, - which determine how the data is tracked (measurement vs - total_increasing). + Only PLAIN_NUMBER data types are considered sensor-like values. + Returns None for ENUM, TIME, WEEKDAY, STRING, and other + non-numeric types even if they carry a unit. Energy counters (kWh, MWh, Wh) are mapped to "total_increasing", while other numeric measurements use "measurement". Returns: str | None: The suggested HA state class (e.g., "measurement", - "total_increasing"), or None if no mapping exists. + "total_increasing"), or None if the data type is not + numeric or no mapping exists. """ + if self.data_type != DataType.PLAIN_NUMBER: + return None return UNIT_STATE_CLASS_MAP.get(self.unit) From c7c418519ad0e87a0f9cb170dbe64cef55d075bc Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 11 Feb 2026 11:26:40 +0100 Subject: [PATCH 6/7] feat: enhance tests for suggested device and state classes to include non-numeric types --- tests/test_entity_info_ha.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/test_entity_info_ha.py b/tests/test_entity_info_ha.py index ffc02132..90a9767b 100644 --- a/tests/test_entity_info_ha.py +++ b/tests/test_entity_info_ha.py @@ -54,10 +54,15 @@ def test_suggested_device_class(unit: str, expected: str) -> None: ("", DataType.ENUM), ("bbl", DataType.PLAIN_NUMBER), ("unknown", DataType.PLAIN_NUMBER), + ("°C", DataType.ENUM), + ("kWh", DataType.ENUM), + ("°C", DataType.TIME), + ("°C", DataType.WEEKDAY), + ("°C", DataType.STRING), ], ) def test_suggested_device_class_none(unit: str, data_type: int) -> None: - """Test suggested_device_class returns None for unmapped units.""" + """Test suggested_device_class returns None for unmapped or non-numeric types.""" entity = EntityInfo( name="Test", value="42", @@ -90,6 +95,9 @@ def test_suggested_device_class_none(unit: str, data_type: int) -> None: ("Hz", "measurement"), ("l/min", "measurement"), ("%", "measurement"), + ("h", "measurement"), + ("min", "measurement"), + ("s", "measurement"), ], ) def test_suggested_state_class(unit: str, expected: str) -> None: @@ -109,10 +117,14 @@ def test_suggested_state_class(unit: str, expected: str) -> None: [ ("", DataType.ENUM), ("bbl", DataType.PLAIN_NUMBER), + ("kWh", DataType.ENUM), + ("°C", DataType.TIME), + ("°C", DataType.WEEKDAY), + ("°C", DataType.STRING), ], ) def test_suggested_state_class_none(unit: str, data_type: int) -> None: - """Test suggested_state_class returns None for unmapped units.""" + """Test suggested_state_class returns None for unmapped or non-numeric types.""" entity = EntityInfo( name="Test", value="42", From 3c530fc299388da5f5c6802127bab01a91776fe9 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 11 Feb 2026 11:28:18 +0100 Subject: [PATCH 7/7] refactor: improve comments for HA-compatible device and state class mappings --- src/bsblan/constants.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/bsblan/constants.py b/src/bsblan/constants.py index 355ffa0e..9929c5f5 100644 --- a/src/bsblan/constants.py +++ b/src/bsblan/constants.py @@ -416,8 +416,8 @@ def get_hvac_action_category(status_code: int) -> HVACActionCategory: TEMPERATURE_UNITS = {"°C", "°F", "°C", "°F", "°C", "°F"} # HA-compatible device class mapping from BSB-LAN units -# Maps unit strings (including HTML-encoded variants) to HA SensorDeviceClass values -# See: https://developers.home-assistant.io/docs/core/entity/sensor/#available-device-classes +# Maps unit strings (incl. HTML-encoded variants) +# to HA SensorDeviceClass values UNIT_DEVICE_CLASS_MAP: Final[dict[str, str]] = { # Temperature "°C": "temperature", @@ -457,7 +457,6 @@ def get_hvac_action_category(status_code: int) -> HVACActionCategory: # HA-compatible state class mapping from BSB-LAN units # Maps unit strings to HA SensorStateClass values -# See: https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes UNIT_STATE_CLASS_MAP: Final[dict[str, str]] = { # Energy counters are always total_increasing "kWh": "total_increasing",