diff --git a/examples/fetch_param.py b/examples/fetch_param.py new file mode 100644 index 00000000..4053822d --- /dev/null +++ b/examples/fetch_param.py @@ -0,0 +1,67 @@ +"""Fetch one or more BSB-LAN parameters and print the raw API response. + +Usage: + export BSBLAN_HOST=10.0.2.60 + export BSBLAN_PASSKEY=your_passkey # if needed + + # Single parameter + cd examples && python fetch_param.py 3113 + + # Multiple parameters + cd examples && python fetch_param.py 3113 8700 8740 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json + +from bsblan import BSBLAN, BSBLANConfig +from discovery import get_bsblan_host, get_config_from_env + + +async def fetch_parameters(param_ids: list[str]) -> None: + """Fetch and print raw API response for the given parameter IDs. + + Args: + param_ids: List of BSB-LAN parameter IDs to fetch. + + """ + host, port = await get_bsblan_host() + env = get_config_from_env() + + config = BSBLANConfig( + host=host, + port=port, + passkey=str(env["passkey"]) if env.get("passkey") else None, + username=str(env["username"]) if env.get("username") else None, + password=str(env["password"]) if env.get("password") else None, + ) + + params_string = ",".join(param_ids) + + async with BSBLAN(config) as client: + result = await client._request( # noqa: SLF001 + params={"Parameter": params_string}, + ) + print(f"Raw API response for parameter(s) {params_string}:") + print(json.dumps(result, indent=2, ensure_ascii=False)) + + +def main() -> None: + """Parse arguments and run the fetch.""" + parser = argparse.ArgumentParser( + description="Fetch BSB-LAN parameters and print raw JSON response.", + ) + parser.add_argument( + "params", + nargs="+", + help="One or more BSB-LAN parameter IDs (e.g. 3113 8700)", + ) + args = parser.parse_args() + asyncio.run(fetch_parameters(args.params)) + + +if __name__ == "__main__": + main() diff --git a/src/bsblan/constants.py b/src/bsblan/constants.py index 9929c5f5..c0a90be8 100644 --- a/src/bsblan/constants.py +++ b/src/bsblan/constants.py @@ -41,6 +41,7 @@ class APIConfig(TypedDict): BASE_SENSOR_PARAMS: Final[dict[str, str]] = { "8700": "outside_temperature", "8740": "current_temperature", + "3113": "total_energy", } BASE_HOT_WATER_PARAMS: Final[dict[str, str]] = { diff --git a/src/bsblan/models.py b/src/bsblan/models.py index d58dcc0b..ac6ca63c 100644 --- a/src/bsblan/models.py +++ b/src/bsblan/models.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import re from dataclasses import dataclass, field from datetime import time from enum import IntEnum @@ -321,6 +322,11 @@ def convert_raw_value(cls, data: dict[str, Any]) -> dict[str, Any]: BSB-LAN always sends values as strings. This validator converts them to the correct Python type (float, int, time) before pydantic's type checking runs. + + Some STRING-type parameters (e.g. "7968 kWh") embed a numeric + value with a unit suffix. When the ``unit`` field is empty and + the value matches `` ``, the numeric part is + extracted and the ``unit`` field is populated automatically. """ raw_value = data.get("value") # Resolve data_type from either alias or field name @@ -328,6 +334,16 @@ def convert_raw_value(cls, data: dict[str, Any]) -> dict[str, Any]: unit = data.get("unit", "") data["value"] = _convert_bsblan_value(raw_value, data_type, unit) + + # Handle STRING values with embedded units (e.g. "7968 kWh") + converted = data["value"] + if isinstance(converted, str) and not unit and data_type == DataType.STRING: + match = re.match(r"^(\d+(?:\.\d+)?)\s+(\S+)$", converted) + if match and match.group(2) in UNIT_DEVICE_CLASS_MAP: + num_str = match.group(1) + data["value"] = float(num_str) if "." in num_str else int(num_str) + data["unit"] = match.group(2) + return data @property @@ -452,6 +468,7 @@ class Sensor(BaseModel): outside_temperature: EntityInfo[float] | None = None current_temperature: EntityInfo[float] | None = None + total_energy: EntityInfo[int] | None = None class HotWaterState(BaseModel): diff --git a/tests/fixtures/sensor.json b/tests/fixtures/sensor.json index 04d5782a..ed9fd7cb 100644 --- a/tests/fixtures/sensor.json +++ b/tests/fixtures/sensor.json @@ -16,5 +16,16 @@ "dataType": 0, "readonly": 1, "unit": "°C" + }, + "3113": { + "name": "Energy brought in ", + "dataType_name": "STRING", + "dataType_family": "STRN", + "error": 0, + "value": "7968 kWh", + "desc": "", + "dataType": 7, + "readwrite": 1, + "unit": "" } } diff --git a/tests/test_entity_info.py b/tests/test_entity_info.py index c7f623bd..e535630e 100644 --- a/tests/test_entity_info.py +++ b/tests/test_entity_info.py @@ -152,3 +152,73 @@ def test_entity_info_enum_description() -> None: # The enum_description should return None assert non_enum_entity.enum_description is None + + +def test_entity_info_string_with_embedded_unit_kwh() -> None: + """Test STRING value with embedded kWh unit is extracted to int.""" + entity = EntityInfo( + name="Energy brought in", + value="7968 kWh", + unit="", + desc="", + data_type=DataType.STRING, + ) + + assert entity.value == 7968 + assert entity.unit == "kWh" + + +def test_entity_info_string_with_embedded_unit_float() -> None: + """Test STRING value with embedded unit and decimal is extracted to float.""" + entity = EntityInfo( + name="Power value", + value="3.5 kW", + unit="", + desc="", + data_type=DataType.STRING, + ) + + assert entity.value == 3.5 + assert entity.unit == "kW" + + +def test_entity_info_string_with_unknown_unit_kept_as_string() -> None: + """Test STRING value with unknown unit remains as string.""" + entity = EntityInfo( + name="Unknown unit", + value="42 foobar", + unit="", + desc="", + data_type=DataType.STRING, + ) + + assert entity.value == "42 foobar" + assert entity.unit == "" + + +def test_entity_info_string_with_existing_unit_not_overwritten() -> None: + """Test STRING value is not parsed when unit field is already set.""" + entity = EntityInfo( + name="Already has unit", + value="7968 kWh", + unit="something", + desc="", + data_type=DataType.STRING, + ) + + assert entity.value == "7968 kWh" + assert entity.unit == "something" + + +def test_entity_info_string_plain_text_not_parsed() -> None: + """Test regular STRING value without number-unit pattern is kept as-is.""" + entity = EntityInfo( + name="Firmware version", + value="1.0.38-20200730", + unit="", + desc="", + data_type=DataType.STRING, + ) + + assert entity.value == "1.0.38-20200730" + assert entity.unit == "" diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 850b4ebe..53dea21f 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -105,3 +105,11 @@ async def test_sensor( assert sensor.current_temperature.value == expected_current_temp["value"] assert sensor.current_temperature.value == 18.2 assert sensor.current_temperature.unit == "°C" + + # Verify total_energy (only present in full response) + if "3113" in sensor_response: + assert sensor.total_energy is not None + assert sensor.total_energy.value == 7968 + assert sensor.total_energy.unit == "kWh" + else: + assert sensor.total_energy is None