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/constants.py b/src/bsblan/constants.py index bc3336ca..9929c5f5 100644 --- a/src/bsblan/constants.py +++ b/src/bsblan/constants.py @@ -415,6 +415,77 @@ 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 (incl. HTML-encoded variants) +# to HA SensorDeviceClass values +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 +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", + "h": "measurement", + "min": "measurement", + "s": "measurement", +} + # Hot Water Parameter Groups # Essential parameters for frequent monitoring HOT_WATER_ESSENTIAL_PARAMS: Final[set[str]] = { diff --git a/src/bsblan/models.py b/src/bsblan/models.py index f93ad399..548b58a7 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,45 @@ 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. + + 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 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 and data type. + + 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 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) + @dataclass class SetHotWaterParam: diff --git a/tests/test_entity_info_ha.py b/tests/test_entity_info_ha.py new file mode 100644 index 00000000..90a9767b --- /dev/null +++ b/tests/test_entity_info_ha.py @@ -0,0 +1,220 @@ +"""Tests for EntityInfo HA integration properties (device class, state class).""" + +import pytest + +from bsblan.models import DataType, EntityInfo + +# -- 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="Test", + value="42", + unit=unit, + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_device_class == expected + + +@pytest.mark.parametrize( + ("unit", "data_type"), + [ + ("", 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 or non-numeric types.""" + entity = EntityInfo( + name="Test", + value="42", + unit=unit, + desc="", + data_type=data_type, + ) + assert entity.suggested_device_class is None + + +# -- 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"), + ("h", "measurement"), + ("min", "measurement"), + ("s", "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="Test", + value="42", + unit=unit, + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.suggested_state_class == expected + + +@pytest.mark.parametrize( + ("unit", "data_type"), + [ + ("", 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 or non-numeric types.""" + entity = EntityInfo( + name="Test", + value="42", + unit=unit, + desc="", + data_type=data_type, + ) + assert entity.suggested_state_class is None + + +# -- dataType_name / dataType_family fields -------------------------------- + + +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", + unit="kWh", + desc="", + data_type=DataType.PLAIN_NUMBER, + ) + assert entity.data_type_name == "" + assert entity.data_type_family == "" + + +@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": "Test", + "dataType_name": type_name, + "dataType_family": type_family, + "error": 0, + "value": "18.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C", + } + ) + assert entity.data_type_name == type_name + assert entity.data_type_family == type_family + + +# -- Backwards compatibility ----------------------------------------------- + + +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