Skip to content
4 changes: 4 additions & 0 deletions src/bsblan/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,6 +29,8 @@

__all__ = [
"BSBLAN",
"UNIT_DEVICE_CLASS_MAP",
"UNIT_STATE_CLASS_MAP",
"BSBLANAuthError",
"BSBLANConfig",
"BSBLANConnectionError",
Expand Down
71 changes: 71 additions & 0 deletions src/bsblan/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = {
Expand Down
49 changes: 48 additions & 1 deletion src/bsblan/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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").

"""

Expand All @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down
220 changes: 220 additions & 0 deletions tests/test_entity_info_ha.py
Original file line number Diff line number Diff line change
@@ -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
Loading