Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions examples/fetch_param.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions src/bsblan/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = {
Expand Down
17 changes: 17 additions & 0 deletions src/bsblan/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -321,13 +322,28 @@ 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 ``<number> <known-unit>``, 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
data_type = data.get("dataType", data.get("data_type", 0))
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
Expand Down Expand Up @@ -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):
Expand Down
11 changes: 11 additions & 0 deletions tests/fixtures/sensor.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,16 @@
"dataType": 0,
"readonly": 1,
"unit": "&deg;C"
},
"3113": {
"name": "Energy brought in ",
"dataType_name": "STRING",
"dataType_family": "STRN",
"error": 0,
"value": "7968 kWh",
"desc": "",
"dataType": 7,
"readwrite": 1,
"unit": ""
}
}
70 changes: 70 additions & 0 deletions tests/test_entity_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == ""
8 changes: 8 additions & 0 deletions tests/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "&deg;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
Loading