diff --git a/README.md b/README.md index efd2add1..81d43e57 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,40 @@ if __name__ == "__main__": asyncio.run(main()) ``` +### Reading Custom Parameters + +You can also read individual parameters directly by their ID or name for more flexibility: + +```python +import asyncio +from bsblan import BSBLAN, BSBLANConfig + +async def main(): + config = BSBLANConfig(host="10.0.2.60") + + async with BSBLAN(config) as bsblan: + await bsblan.initialize() + + # Read a single parameter by ID + temp = await bsblan.read_parameter("8740") + if temp: + print(f"Temperature: {temp.value} {temp.unit}") + + # Read a single parameter by name + hvac_mode = await bsblan.read_parameter_by_name("hvac_mode") + if hvac_mode: + print(f"HVAC Mode: {hvac_mode.desc}") + +asyncio.run(main()) +``` + +This is useful when you want to: +- Fetch only specific parameters you need +- Work with custom or non-standard parameters +- Build custom sensor implementations + +See `examples/custom_parameters.py` for more detailed examples. + ## Changelog & Releases This repository keeps a change log using [GitHub's releases][releases] diff --git a/examples/custom_parameters.py b/examples/custom_parameters.py new file mode 100644 index 00000000..3ac04275 --- /dev/null +++ b/examples/custom_parameters.py @@ -0,0 +1,81 @@ +"""Example demonstrating custom sensor/parameter reading. + +This example shows how to use the new read_parameter() and +read_parameter_by_name() methods to fetch individual custom parameters. +""" + +import asyncio +import os + +from bsblan import BSBLAN, BSBLANConfig + + +async def main() -> None: + """Demonstrate custom parameter reading.""" + # Initialize the client from environment variables or defaults + config = BSBLANConfig( + host=os.getenv("BSBLAN_HOST", "192.168.1.100"), + username=os.getenv("BSBLAN_USER"), + password=os.getenv("BSBLAN_PASS"), + ) + + async with BSBLAN(config) as client: + # Initialize the API + await client.initialize() + + print("=" * 60) + print("Reading Custom Parameters") + print("=" * 60) + + # Example 1: Read a single parameter by ID + print("\n1. Reading parameter by ID (8740 - current temperature)") + temp = await client.read_parameter("8740") + if temp: + print(f" Value: {temp.value} {temp.unit}") + print(f" Name: {temp.name}") + print(f" Description: {temp.desc}") + else: + print(" Parameter not found or not supported") + + # Example 2: Read a single parameter by name + print("\n2. Reading parameter by name (current_temperature)") + temp = await client.read_parameter_by_name("current_temperature") + if temp: + print(f" Value: {temp.value} {temp.unit}") + print(f" Data Type: {temp.data_type}") + else: + print(" Parameter not found or not supported") + + # Example 3: Read HVAC mode (ENUM type) + print("\n3. Reading HVAC mode (700 - operating mode)") + hvac_mode = await client.read_parameter("700") + if hvac_mode: + print(f" Value: {hvac_mode.value}") + print(f" Description: {hvac_mode.desc}") + print(f" Unit: {hvac_mode.unit}") + else: + print(" Parameter not found or not supported") + + # Example 4: Read multiple custom parameters + print("\n4. Reading multiple custom parameters") + param_ids = ["8740", "8700", "700"] # temperature, outside temp, mode + for param_id in param_ids: + param = await client.read_parameter(param_id) + if param: + print(f" {param_id}: {param.value} {param.unit} ({param.name})") + + # Example 5: Check if parameter exists before using it + print("\n5. Safe parameter reading with existence check") + outside_temp = await client.read_parameter_by_name("outside_temperature") + if outside_temp and outside_temp.value != "---": + print(f" Outside temperature: {outside_temp.value} {outside_temp.unit}") + else: + print(" Outside temperature sensor not available") + + print("\n" + "=" * 60) + print("Examples completed!") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/bsblan/bsblan.py b/src/bsblan/bsblan.py index f3ba0103..dd735519 100644 --- a/src/bsblan/bsblan.py +++ b/src/bsblan/bsblan.py @@ -36,6 +36,8 @@ NO_PARAMETER_NAMES_ERROR_MSG, NO_SCHEDULE_ERROR_MSG, NO_STATE_ERROR_MSG, + PARAMETER_ID_EMPTY_ERROR_MSG, + PARAMETER_NAME_EMPTY_ERROR_MSG, PARAMETER_NAMES_NOT_RESOLVED_ERROR_MSG, SESSION_NOT_INITIALIZED_ERROR_MSG, SETTABLE_HOT_WATER_PARAMS, @@ -1547,3 +1549,80 @@ async def read_parameters_by_name( for param_id, entity_info in id_results.items() if param_id in id_to_name } + + async def read_parameter( + self, + parameter_id: str, + ) -> EntityInfo | None: + """Read a single parameter by its BSB-LAN parameter ID. + + This is a convenience method for reading a single custom parameter. + Returns the parameter data as an EntityInfo object with correct type + and format, or None if the parameter is not found or invalid. + + Example: + # Fetch only current temperature + temp = await client.read_parameter("8740") + if temp: + print(f"Temperature: {temp.value} {temp.unit}") + + Args: + parameter_id: The BSB-LAN parameter ID to fetch (e.g., "700"). + + Returns: + EntityInfo | None: The parameter data as EntityInfo object, + or None if not found or invalid. + + Raises: + BSBLANError: If parameter_id is empty or request fails. + + """ + if not parameter_id: + raise BSBLANError(PARAMETER_ID_EMPTY_ERROR_MSG) + + result = await self.read_parameters([parameter_id]) + return result.get(parameter_id) + + async def read_parameter_by_name( + self, + parameter_name: str, + ) -> EntityInfo | None: + """Read a single parameter by its name. + + This is a convenience method for reading a single custom parameter + by its name. Returns the parameter data as an EntityInfo object with + correct type and format, or None if the parameter is not found or invalid. + + Example: + # Fetch only current temperature by name + temp = await client.read_parameter_by_name("current_temperature") + if temp: + print(f"Temperature: {temp.value} {temp.unit}") + + Args: + parameter_name: The parameter name to fetch + (e.g., "current_temperature"). + + Returns: + EntityInfo | None: The parameter data as EntityInfo object, + or None if not found or invalid. + + Raises: + BSBLANError: If parameter_name is empty, the client is not initialized, + or the parameter name cannot be resolved. + + """ + if not parameter_name: + raise BSBLANError(PARAMETER_NAME_EMPTY_ERROR_MSG) + + if not self._api_data: + raise BSBLANError(API_DATA_NOT_INITIALIZED_ERROR_MSG) + + # Resolve name to ID + param_id = self.get_parameter_id(parameter_name) + + if param_id is None: + msg = f"{PARAMETER_NAMES_NOT_RESOLVED_ERROR_MSG}: {parameter_name}" + raise BSBLANError(msg) + + return await self.read_parameter(param_id) diff --git a/src/bsblan/constants.py b/src/bsblan/constants.py index bc3336ca..90bfc88f 100644 --- a/src/bsblan/constants.py +++ b/src/bsblan/constants.py @@ -402,6 +402,8 @@ def get_hvac_action_category(status_code: int) -> HVACActionCategory: # Error messages for low-level parameter access NO_PARAMETER_IDS_ERROR_MSG: Final[str] = "No parameter IDs provided" NO_PARAMETER_NAMES_ERROR_MSG: Final[str] = "No parameter names provided" +PARAMETER_ID_EMPTY_ERROR_MSG: Final[str] = "Parameter ID cannot be empty" +PARAMETER_NAME_EMPTY_ERROR_MSG: Final[str] = "Parameter name cannot be empty" PARAMETER_NAMES_NOT_RESOLVED_ERROR_MSG: Final[str] = ( "Could not resolve any parameter names" ) diff --git a/tests/test_custom_sensors.py b/tests/test_custom_sensors.py new file mode 100644 index 00000000..f8748188 --- /dev/null +++ b/tests/test_custom_sensors.py @@ -0,0 +1,205 @@ +"""Tests for custom sensor/parameter reading methods.""" + +from typing import Any +from unittest.mock import AsyncMock + +import aiohttp +import pytest + +from bsblan import BSBLAN, BSBLANConfig, BSBLANError, EntityInfo +from bsblan.constants import API_V3 + + +@pytest.mark.asyncio +async def test_read_parameter_by_id( + mock_bsblan: BSBLAN, + monkeypatch: Any, +) -> None: + """Test reading a single parameter by its ID.""" + # Arrange: mock response with parameter data + mock_response = { + "8740": { + "name": "Raumtemperatur 1", + "value": "21.5", + "unit": "°C", + "desc": "", + "dataType": 0, + }, + } + request_mock = AsyncMock(return_value=mock_response) + monkeypatch.setattr(mock_bsblan, "_request", request_mock) + + # Act + result = await mock_bsblan.read_parameter("8740") + + # Assert + assert result is not None + assert isinstance(result, EntityInfo) + assert result.value == 21.5 # Converted to float + assert result.unit == "°C" + assert result.name == "Raumtemperatur 1" + request_mock.assert_awaited_once_with(params={"Parameter": "8740"}) + + +@pytest.mark.asyncio +async def test_read_parameter_not_found( + mock_bsblan: BSBLAN, + monkeypatch: Any, +) -> None: + """Test that missing parameter returns None.""" + # Arrange: response without the requested parameter + mock_response: dict[str, Any] = {} + monkeypatch.setattr(mock_bsblan, "_request", AsyncMock(return_value=mock_response)) + + # Act + result = await mock_bsblan.read_parameter("9999") + + # Assert + assert result is None + + +@pytest.mark.asyncio +async def test_read_parameter_empty_id() -> None: + """Test that empty parameter ID raises error.""" + async with aiohttp.ClientSession() as session: + config = BSBLANConfig(host="example.com") + bsblan = BSBLAN(config, session=session) + + with pytest.raises(BSBLANError, match="Parameter ID cannot be empty"): + await bsblan.read_parameter("") + + +@pytest.mark.asyncio +async def test_read_parameter_invalid_data( + mock_bsblan: BSBLAN, + monkeypatch: Any, +) -> None: + """Test that parameter with invalid data returns None.""" + # Arrange: response with None value + mock_response = { + "8740": None, + } + monkeypatch.setattr(mock_bsblan, "_request", AsyncMock(return_value=mock_response)) + + # Act + result = await mock_bsblan.read_parameter("8740") + + # Assert + assert result is None + + +@pytest.mark.asyncio +async def test_read_parameter_enum_type( + mock_bsblan: BSBLAN, + monkeypatch: Any, +) -> None: + """Test reading a parameter with ENUM data type.""" + # Arrange: mock response with ENUM parameter + mock_response = { + "8000": { + "name": "Status Heizkreis 1", + "value": "114", + "unit": "", + "desc": "Heating Comfort", + "dataType": 1, + }, + } + request_mock = AsyncMock(return_value=mock_response) + monkeypatch.setattr(mock_bsblan, "_request", request_mock) + + # Act + result = await mock_bsblan.read_parameter("8000") + + # Assert + assert result is not None + assert result.value == 114 # ENUM type, converted to int + assert result.desc == "Heating Comfort" + assert result.data_type == 1 + + +@pytest.mark.asyncio +async def test_read_parameter_by_name_found(monkeypatch: Any) -> None: + """Test reading a single parameter by its name.""" + async with aiohttp.ClientSession() as session: + config = BSBLANConfig(host="example.com") + bsblan = BSBLAN(config, session=session) + monkeypatch.setattr(bsblan, "_api_data", API_V3) + + # Mock response + mock_response = { + "8740": { + "name": "Raumtemperatur 1", + "value": "21.5", + "unit": "°C", + "desc": "", + "dataType": 0, + }, + } + monkeypatch.setattr(bsblan, "_request", AsyncMock(return_value=mock_response)) + + # Act + result = await bsblan.read_parameter_by_name("current_temperature") + + # Assert + assert result is not None + assert isinstance(result, EntityInfo) + assert result.value == 21.5 + assert result.unit == "°C" + + +@pytest.mark.asyncio +async def test_read_parameter_by_name_not_found(monkeypatch: Any) -> None: + """Test reading parameter by unknown name raises error.""" + async with aiohttp.ClientSession() as session: + config = BSBLANConfig(host="example.com") + bsblan = BSBLAN(config, session=session) + monkeypatch.setattr(bsblan, "_api_data", API_V3) + + # Act & Assert + with pytest.raises( + BSBLANError, + match="Could not resolve any parameter names: nonexistent_param", + ): + await bsblan.read_parameter_by_name("nonexistent_param") + + +@pytest.mark.asyncio +async def test_read_parameter_by_name_empty_name() -> None: + """Test that empty parameter name raises error.""" + async with aiohttp.ClientSession() as session: + config = BSBLANConfig(host="example.com") + bsblan = BSBLAN(config, session=session) + + with pytest.raises(BSBLANError, match="Parameter name cannot be empty"): + await bsblan.read_parameter_by_name("") + + +@pytest.mark.asyncio +async def test_read_parameter_by_name_no_api_data() -> None: + """Test error when API data not initialized.""" + async with aiohttp.ClientSession() as session: + config = BSBLANConfig(host="example.com") + bsblan = BSBLAN(config, session=session) + # _api_data is None by default + + with pytest.raises(BSBLANError, match="API data not initialized"): + await bsblan.read_parameter_by_name("current_temperature") + + +@pytest.mark.asyncio +async def test_read_parameter_by_name_not_in_device(monkeypatch: Any) -> None: + """Test that parameter not found in device returns None.""" + async with aiohttp.ClientSession() as session: + config = BSBLANConfig(host="example.com") + bsblan = BSBLAN(config, session=session) + monkeypatch.setattr(bsblan, "_api_data", API_V3) + + # Mock response without the requested parameter + mock_response: dict[str, Any] = {} + monkeypatch.setattr(bsblan, "_request", AsyncMock(return_value=mock_response)) + + # Act + result = await bsblan.read_parameter_by_name("current_temperature") + + # Assert + assert result is None