From c489fa6912cd4d4336d84652abd3044352c28e7b Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 23 Feb 2026 11:33:12 +0100 Subject: [PATCH 1/4] Add total energy parameter to sensor and enhance value conversion for embedded units --- src/bsblan/constants.py | 1 + src/bsblan/models.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) 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): From 56fdb75b56de72d0a7a49c63eb062001297cb5ff Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 23 Feb 2026 11:33:20 +0100 Subject: [PATCH 2/4] Add tests for energy value extraction and sensor total energy verification --- tests/fixtures/sensor.json | 11 ++++++ tests/test_entity_info.py | 70 ++++++++++++++++++++++++++++++++++++++ tests/test_sensor.py | 8 +++++ 3 files changed, 89 insertions(+) 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 From 58d4d03bfe97bfe3db29ab1d2f84cd25d00f755b Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 23 Feb 2026 11:33:27 +0100 Subject: [PATCH 3/4] Add fetch_param.py to retrieve and print BSB-LAN parameters from the API --- examples/fetch_param.py | 67 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 examples/fetch_param.py diff --git a/examples/fetch_param.py b/examples/fetch_param.py new file mode 100644 index 00000000..abae0d47 --- /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=env.get("passkey") or None, + username=env.get("username") or None, + password=env.get("password") or 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() From fbeb938e772e7680cd44c38f8da7a215d6a0c33c Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 23 Feb 2026 11:37:39 +0100 Subject: [PATCH 4/4] Refactor configuration parameter handling to ensure proper type conversion for passkey, username, and password. --- examples/fetch_param.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/fetch_param.py b/examples/fetch_param.py index abae0d47..4053822d 100644 --- a/examples/fetch_param.py +++ b/examples/fetch_param.py @@ -34,9 +34,9 @@ async def fetch_parameters(param_ids: list[str]) -> None: config = BSBLANConfig( host=host, port=port, - passkey=env.get("passkey") or None, - username=env.get("username") or None, - password=env.get("password") or None, + 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)