diff --git a/examples/control.py b/examples/control.py index 69e63802..7d584d41 100644 --- a/examples/control.py +++ b/examples/control.py @@ -16,7 +16,12 @@ async def print_state(state: State) -> None: - """Print the current state of the BSBLan device.""" + """Print the current state of the BSBLan device. + + Args: + state (State): The current state of the BSBLan device. + + """ print(f"HVAC Action: {state.hvac_action.desc}") print(f"HVAC Mode: {state.hvac_mode.desc}") print(f"Current Temperature: {state.current_temperature.value}") @@ -24,26 +29,49 @@ async def print_state(state: State) -> None: async def print_sensor(sensor: Sensor) -> None: - """Print sensor information from the BSBLan device.""" + """Print sensor information from the BSBLan device. + + Args: + sensor (Sensor): The sensor information from the BSBLan device. + + """ print(f"Outside Temperature: {sensor.outside_temperature.value}") async def print_device_info(device: Device, info: Info) -> None: - """Print device and general information.""" + """Print device and general information. + + Args: + device (Device): The device information from the BSBLan device. + info (Info): The general information from the BSBLan device. + + """ print(f"Device Name: {device.name}") print(f"Version: {device.version}") print(f"Device Identification: {info.device_identification.value}") async def print_static_state(static_state: StaticState) -> None: - """Print static state information.""" + """Print static state information. + + Args: + static_state (StaticState): The static state information + from the BSBLan device. + + """ print(f"Min Temperature: {static_state.min_temp.value}") print(f"Max Temperature: {static_state.max_temp.value}") print(f"Min Temperature Unit: {static_state.min_temp.unit}") async def print_hot_water_state(hot_water_state: HotWaterState) -> None: - """Print hot water state information.""" + """Print hot water state information. + + Args: + hot_water_state (HotWaterState): The hot water state information + from the BSBLan device. + + """ print("\nHot Water State:") print(f"Operating Mode: {hot_water_state.operating_mode.desc}") print(f"Nominal Setpoint: {hot_water_state.nominal_setpoint.value}") diff --git a/src/bsblan/bsblan.py b/src/bsblan/bsblan.py index 8c7768a6..636b166e 100644 --- a/src/bsblan/bsblan.py +++ b/src/bsblan/bsblan.py @@ -71,7 +71,12 @@ class BSBLAN: _initialized: bool = False async def __aenter__(self) -> Self: - """Enter the context manager.""" + """Enter the context manager. + + Returns: + Self: The initialized BSBLAN instance. + + """ if self.session is None: self.session = aiohttp.ClientSession() self._close_session = True @@ -79,7 +84,12 @@ async def __aenter__(self) -> Self: return self async def __aexit__(self, *args: object) -> None: - """Exit the context manager.""" + """Exit the context manager. + + Args: + *args: Variable length argument list. + + """ if self._close_session and self.session: await self.session.close() @@ -100,7 +110,13 @@ async def _fetch_firmware_version(self) -> None: self._set_api_version() def _set_api_version(self) -> None: - """Set the API version based on the firmware version.""" + """Set the API version based on the firmware version. + + Raises: + BSBLANError: If the firmware version is not set. + BSBLANVersionError: If the firmware version is not supported. + + """ if not self._firmware_version: raise BSBLANError(FIRMWARE_VERSION_ERROR_MSG) @@ -126,7 +142,15 @@ async def _initialize_temperature_range(self) -> None: ) async def _initialize_api_data(self) -> APIConfig: - """Initialize and cache the API data.""" + """Initialize and cache the API data. + + Returns: + APIConfig: The API configuration data. + + Raises: + BSBLANError: If the API version or data is not initialized. + + """ if self._api_data is None: if self._api_version is None: raise BSBLANError(API_VERSION_ERROR_MSG) @@ -143,7 +167,23 @@ async def _request( data: dict[str, object] | None = None, params: Mapping[str, str | int] | str | None = None, ) -> dict[str, Any]: - """Handle a request to a BSBLAN device.""" + """Handle a request to a BSBLAN device. + + Args: + method (str): The HTTP method to use for the request. + base_path (str): The base path for the URL. + data (dict[str, object] | None): The data to send in the request body. + params (Mapping[str, str | int] | str | None): The query parameters + to include in the request. + + Returns: + dict[str, Any]: The JSON response from the BSBLAN device. + + Raises: + BSBLANConnectionError: If there is a connection error. + BSBLANError: If there is an error with the request. + + """ if self.session is None: raise BSBLANError(SESSION_NOT_INITIALIZED_ERROR_MSG) url = self._build_url(base_path) @@ -170,7 +210,15 @@ async def _request( raise BSBLANError(str(e)) from e def _build_url(self, base_path: str) -> URL: - """Build the URL for the request.""" + """Build the URL for the request. + + Args: + base_path (str): The base path for the URL. + + Returns: + URL: The constructed URL. + + """ if self.config.passkey: base_path = f"/{self.config.passkey}{base_path}" return URL.build( @@ -181,30 +229,63 @@ def _build_url(self, base_path: str) -> URL: ) def _get_auth(self) -> BasicAuth | None: - """Get the authentication for the request.""" + """Get the authentication for the request. + + Returns: + BasicAuth | None: The authentication object or None if no authentication + is required. + + """ if self.config.username and self.config.password: return BasicAuth(self.config.username, self.config.password) return None def _get_headers(self) -> dict[str, str]: - """Get the headers for the request.""" + """Get the headers for the request. + + Returns: + dict[str, str]: The headers for the request. + + """ return { "User-Agent": f"PythonBSBLAN/{self._firmware_version}", "Accept": "application/json, */*", } def _validate_single_parameter(self, *params: Any, error_msg: str) -> None: - """Validate that exactly one parameter is provided.""" + """Validate that exactly one parameter is provided. + + Args: + *params: Variable length argument list of parameters to validate. + error_msg (str): The error message to raise if validation fails. + + Raises: + BSBLANError: If the validation fails. + + """ if sum(param is not None for param in params) != 1: raise BSBLANError(error_msg) async def _get_parameters(self, params: dict[Any, Any]) -> dict[Any, Any]: - """Get the parameters info from BSBLAN device.""" + """Get the parameters info from BSBLAN device. + + Args: + params (dict[Any, Any]): The parameters to get info for. + + Returns: + dict[Any, Any]: The parameters info from the BSBLAN device. + + """ string_params = ",".join(map(str, params)) return {"string_par": string_params, "list": list(params.values())} async def state(self) -> State: - """Get the current state from BSBLAN device.""" + """Get the current state from BSBLAN device. + + Returns: + State: The current state of the BSBLAN device. + + """ api_data = await self._initialize_api_data() params = await self._get_parameters(api_data["heating"]) data = await self._request(params={"Parameter": params["string_par"]}) @@ -213,7 +294,12 @@ async def state(self) -> State: return State.from_dict(data) async def sensor(self) -> Sensor: - """Get the sensor information from BSBLAN device.""" + """Get the sensor information from BSBLAN device. + + Returns: + Sensor: The sensor information from the BSBLAN device. + + """ api_data = await self._initialize_api_data() params = await self._get_parameters(api_data["sensor"]) data = await self._request(params={"Parameter": params["string_par"]}) @@ -221,7 +307,12 @@ async def sensor(self) -> Sensor: return Sensor.from_dict(data) async def static_values(self) -> StaticState: - """Get the static information from BSBLAN device.""" + """Get the static information from BSBLAN device. + + Returns: + StaticState: The static information from the BSBLAN device. + + """ api_data = await self._initialize_api_data() params = await self._get_parameters(api_data["staticValues"]) data = await self._request(params={"Parameter": params["string_par"]}) @@ -229,12 +320,22 @@ async def static_values(self) -> StaticState: return StaticState.from_dict(data) async def device(self) -> Device: - """Get BSBLAN device info.""" + """Get BSBLAN device info. + + Returns: + Device: The BSBLAN device information. + + """ device_info = await self._request(base_path="/JI") return Device.from_dict(device_info) async def info(self) -> Info: - """Get information about the current heating system config.""" + """Get information about the current heating system config. + + Returns: + Info: The information about the current heating system config. + + """ api_data = await self._initialize_api_data() params = await self._get_parameters(api_data["device"]) data = await self._request(params={"Parameter": params["string_par"]}) @@ -246,7 +347,13 @@ async def thermostat( target_temperature: str | None = None, hvac_mode: str | None = None, ) -> None: - """Change the state of the thermostat through BSB-Lan.""" + """Change the state of the thermostat through BSB-Lan. + + Args: + target_temperature (str | None): The target temperature to set. + hvac_mode (str | None): The HVAC mode to set. + + """ await self._initialize_temperature_range() self._validate_single_parameter( @@ -263,11 +370,22 @@ def _prepare_thermostat_state( target_temperature: str | None, hvac_mode: str | None, ) -> dict[str, Any]: - """Prepare the thermostat state for setting.""" + """Prepare the thermostat state for setting. + + Args: + target_temperature (str | None): The target temperature to set. + hvac_mode (str | None): The HVAC mode to set. + + Returns: + dict[str, Any]: The prepared state for the thermostat. + + """ state: dict[str, Any] = {} if target_temperature is not None: self._validate_target_temperature(target_temperature) - state.update({"Parameter": "710", "Value": target_temperature, "Type": "1"}) + state.update( + {"Parameter": "710", "Value": target_temperature, "Type": "1"}, + ) if hvac_mode is not None: self._validate_hvac_mode(hvac_mode) state.update( @@ -280,7 +398,16 @@ def _prepare_thermostat_state( return state def _validate_target_temperature(self, target_temperature: str) -> None: - """Validate the target temperature.""" + """Validate the target temperature. + + Args: + target_temperature (str): The target temperature to validate. + + Raises: + BSBLANError: If the temperature range is not initialized. + BSBLANInvalidParameterError: If the target temperature is invalid. + + """ if self._min_temp is None or self._max_temp is None: raise BSBLANError(TEMPERATURE_RANGE_ERROR_MSG) @@ -292,17 +419,35 @@ def _validate_target_temperature(self, target_temperature: str) -> None: raise BSBLANInvalidParameterError(target_temperature) from err def _validate_hvac_mode(self, hvac_mode: str) -> None: - """Validate the HVAC mode.""" + """Validate the HVAC mode. + + Args: + hvac_mode (str): The HVAC mode to validate. + + Raises: + BSBLANInvalidParameterError: If the HVAC mode is invalid. + + """ if hvac_mode not in HVAC_MODE_DICT_REVERSE: raise BSBLANInvalidParameterError(hvac_mode) async def _set_thermostat_state(self, state: dict[str, Any]) -> None: - """Set the thermostat state.""" + """Set the thermostat state. + + Args: + state (dict[str, Any]): The state to set for the thermostat. + + """ response = await self._request(base_path="/JS", data=state) logger.debug("Response for setting: %s", response) async def hot_water_state(self) -> HotWaterState: - """Get the current hot water state from BSBLAN device.""" + """Get the current hot water state from BSBLAN device. + + Returns: + HotWaterState: The current hot water state. + + """ api_data = await self._initialize_api_data() params = await self._get_parameters(api_data["hot_water"]) data = await self._request(params={"Parameter": params["string_par"]}) @@ -311,20 +456,23 @@ async def hot_water_state(self) -> HotWaterState: async def set_hot_water( self, - operating_mode: str | None = None, nominal_setpoint: float | None = None, reduced_setpoint: float | None = None, ) -> None: - """Change the state of the hot water system through BSB-Lan.""" + """Change the state of the hot water system through BSB-Lan. + + Args: + nominal_setpoint (float | None): The nominal setpoint temperature to set. + reduced_setpoint (float | None): The reduced setpoint temperature to set. + + """ self._validate_single_parameter( - operating_mode, nominal_setpoint, reduced_setpoint, error_msg=MULTI_PARAMETER_ERROR_MSG, ) state = self._prepare_hot_water_state( - operating_mode, nominal_setpoint, reduced_setpoint, ) @@ -332,16 +480,23 @@ async def set_hot_water( def _prepare_hot_water_state( self, - operating_mode: str | None, nominal_setpoint: float | None, reduced_setpoint: float | None, ) -> dict[str, Any]: - """Prepare the hot water state for setting.""" + """Prepare the hot water state for setting. + + Args: + nominal_setpoint (float | None): The nominal setpoint temperature to set. + reduced_setpoint (float | None): The reduced setpoint temperature to set. + + Returns: + dict[str, Any]: The prepared state for the hot water. + + Raises: + BSBLANError: If no state is provided. + + """ state: dict[str, Any] = {} - if operating_mode is not None: - state.update( - {"Parameter": "1600", "EnumValue": operating_mode, "Type": "1"}, - ) if nominal_setpoint is not None: state.update( {"Parameter": "1610", "Value": str(nominal_setpoint), "Type": "1"}, @@ -355,6 +510,11 @@ def _prepare_hot_water_state( return state async def _set_hot_water_state(self, state: dict[str, Any]) -> None: - """Set the hot water state.""" + """Set the hot water state. + + Args: + state (dict[str, Any]): The state to set for the hot water. + + """ response = await self._request(base_path="/JS", data=state) logger.debug("Response for setting: %s", response) diff --git a/src/bsblan/constants.py b/src/bsblan/constants.py index 71f9bafb..04b86311 100644 --- a/src/bsblan/constants.py +++ b/src/bsblan/constants.py @@ -75,11 +75,14 @@ class APIConfig(TypedDict): "hot_water": { "1600": "operating_mode", "1610": "nominal_setpoint", + "1614": "nominal_setpoint_max", "1612": "reduced_setpoint", "1620": "release", "1640": "legionella_function", "1645": "legionella_setpoint", "1641": "legionella_periodically", + "8830": "dhw_actual_value_top_temperature", + "8820": "state_dhw_pump", }, } diff --git a/src/bsblan/exceptions.py b/src/bsblan/exceptions.py index 6e43e1cc..248b9e6e 100644 --- a/src/bsblan/exceptions.py +++ b/src/bsblan/exceptions.py @@ -9,7 +9,12 @@ class BSBLANError(Exception): message: str = "Unexpected response from the BSBLAN device." def __init__(self, message: str | None = None) -> None: - """Initialize a new instance of the BSBLANError class.""" + """Initialize a new instance of the BSBLANError class. + + Args: + message: Optional custom error message. + + """ if message is not None: self.message = message super().__init__(self.message) @@ -22,7 +27,12 @@ class BSBLANConnectionError(BSBLANError): message_error: str = "Error occurred while connecting to BSBLAN device." def __init__(self, response: str | None = None) -> None: - """Initialize a new instance of the BSBLANConnectionError class.""" + """Initialize a new instance of the BSBLANConnectionError class. + + Args: + response: Optional response message. + + """ self.response = response super().__init__(self.message) @@ -37,6 +47,11 @@ class BSBLANInvalidParameterError(BSBLANError): """Raised when an invalid parameter is provided.""" def __init__(self, parameter: str) -> None: - """Initialize a new instance of the BSBLANInvalidParameterError class.""" + """Initialize a new instance of the BSBLANInvalidParameterError class. + + Args: + parameter: The invalid parameter that caused the error. + + """ self.message = f"Invalid values provided: {parameter}" super().__init__(self.message) diff --git a/src/bsblan/models.py b/src/bsblan/models.py index 27c8449c..867d1865 100644 --- a/src/bsblan/models.py +++ b/src/bsblan/models.py @@ -93,11 +93,14 @@ class HotWaterState(DataClassJSONMixin): operating_mode: EntityInfo nominal_setpoint: EntityInfo + nominal_setpoint_max: EntityInfo # 1614 reduced_setpoint: EntityInfo release: EntityInfo legionella_function: EntityInfo legionella_setpoint: EntityInfo legionella_periodically: EntityInfo + dhw_actual_value_top_temperature: EntityInfo # 8830 + state_dhw_pump: EntityInfo # 8820 @dataclass diff --git a/tests/fixtures/hot_water_state.json b/tests/fixtures/hot_water_state.json index dd583f06..0a63c967 100644 --- a/tests/fixtures/hot_water_state.json +++ b/tests/fixtures/hot_water_state.json @@ -24,6 +24,19 @@ "readwrite": 0, "unit": "°C" }, + "1614": { + "name": "Nominal setpoint Max", + "dataType_name": "TEMP", + "dataType_family": "VALS", + "error": 0, + "value": "65.0", + "desc": "", + "precision": 0.1, + "dataType": 0, + "readonly": 0, + "readwrite": 0, + "unit": "°C" + }, "1612": { "name": "Reduced setpoint", "dataType_name": "TEMP", @@ -85,5 +98,30 @@ "readonly": 0, "readwrite": 0, "unit": "" + }, + "8830": { + "name": "DHW actual value top temperature", + "dataType_name": "TEMP", + "dataType_family": "VALS", + "error": 0, + "value": "50.0", + "desc": "", + "precision": 0.1, + "dataType": 0, + "readonly": 1, + "readwrite": 0, + "unit": "°C" + }, + "8820": { + "name": "State DHW pump", + "dataType_name": "ENUM", + "dataType_family": "ENUM", + "error": 0, + "value": "0", + "desc": "Off", + "dataType": 1, + "readonly": 1, + "readwrite": 0, + "unit": "" } } diff --git a/tests/test_hotwater_state.py b/tests/test_hotwater_state.py index a484e7cf..6974972c 100644 --- a/tests/test_hotwater_state.py +++ b/tests/test_hotwater_state.py @@ -54,15 +54,18 @@ async def test_hot_water_state( initialize_api_data_mock = AsyncMock() get_parameters_mock = AsyncMock( return_value={ - "string_par": "1600,1610,1612,1620,1640,1645,1641", + "string_par": "1600,1610,1614,1612,1620,1640,1645,1641,8830,8820", "list": [ "operating_mode", "nominal_setpoint", + "nominal_setpoint_max", "reduced_setpoint", "release", "legionella_function", "legionella_setpoint", "legionella_periodically", + "dhw_actual_value_top_temperature", + "state_dhw_pump", ], }, ) @@ -78,17 +81,20 @@ async def test_hot_water_state( # Assertions assert isinstance(hot_water_state, HotWaterState) - assert hot_water_state.operating_mode.value == "3" # Example value - assert hot_water_state.nominal_setpoint.value == "60.0" # Example value - assert hot_water_state.reduced_setpoint.value == "40.0" # Example value - assert hot_water_state.release.value == "1.0.0" # Example value - assert hot_water_state.legionella_function.value == "1" # Example value - assert hot_water_state.legionella_setpoint.value == "70.0" # Example value - assert hot_water_state.legionella_periodically.value == "1" # Example value + assert hot_water_state.operating_mode.value == "3" + assert hot_water_state.nominal_setpoint.value == "60.0" + assert hot_water_state.nominal_setpoint_max.value == "65.0" + assert hot_water_state.reduced_setpoint.value == "40.0" + assert hot_water_state.release.value == "1.0.0" + assert hot_water_state.legionella_function.value == "1" + assert hot_water_state.legionella_setpoint.value == "70.0" + assert hot_water_state.legionella_periodically.value == "1" + assert hot_water_state.dhw_actual_value_top_temperature.value == "50.0" + assert hot_water_state.state_dhw_pump.desc == "Off" # Verify method calls initialize_api_data_mock.assert_called_once() get_parameters_mock.assert_called_once() request_mock.assert_called_once_with( - params={"Parameter": "1600,1610,1612,1620,1640,1645,1641"}, + params={"Parameter": "1600,1610,1614,1612,1620,1640,1645,1641,8830,8820"}, ) diff --git a/tests/test_set_hotwater.py b/tests/test_set_hotwater.py index ca1d04df..a34a4ee3 100644 --- a/tests/test_set_hotwater.py +++ b/tests/test_set_hotwater.py @@ -15,20 +15,14 @@ @pytest.mark.asyncio async def test_set_hot_water(mock_bsblan: BSBLAN) -> None: - """Test setting BSBLAN hot water state.""" - # Test setting operating_mode - await mock_bsblan.set_hot_water(operating_mode="3") - assert isinstance(mock_bsblan._request, AsyncMock) # Type check - mock_bsblan._request.assert_awaited_with( - base_path="/JS", - data={ - "Parameter": "1600", - "EnumValue": "3", - "Type": "1", - }, - ) + """Test setting BSBLAN hot water state. + + Args: + mock_bsblan (BSBLAN): The mock BSBLAN instance. + """ # Test setting nominal_setpoint + assert isinstance(mock_bsblan._request, AsyncMock) await mock_bsblan.set_hot_water(nominal_setpoint=60.0) mock_bsblan._request.assert_awaited_with( base_path="/JS", @@ -52,27 +46,19 @@ async def test_set_hot_water(mock_bsblan: BSBLAN) -> None: # Test setting multiple parameters (should raise an error) with pytest.raises(BSBLANError, match=MULTI_PARAMETER_ERROR_MSG): - await mock_bsblan.set_hot_water(operating_mode="3", nominal_setpoint=60.0) + await mock_bsblan.set_hot_water(nominal_setpoint=60.0, reduced_setpoint=40.0) @pytest.mark.asyncio async def test_prepare_hot_water_state(mock_bsblan: BSBLAN) -> None: - """Test preparing hot water state.""" - # Test preparing operating_mode - state = mock_bsblan._prepare_hot_water_state( - operating_mode="3", - nominal_setpoint=None, - reduced_setpoint=None, - ) - assert state == { - "Parameter": "1600", - "EnumValue": "3", - "Type": "1", - } + """Test preparing hot water state. + + Args: + mock_bsblan (BSBLAN): The mock BSBLAN instance. + """ # Test preparing nominal_setpoint state = mock_bsblan._prepare_hot_water_state( - operating_mode=None, nominal_setpoint=60.0, reduced_setpoint=None, ) @@ -84,7 +70,6 @@ async def test_prepare_hot_water_state(mock_bsblan: BSBLAN) -> None: # Test preparing reduced_setpoint state = mock_bsblan._prepare_hot_water_state( - operating_mode=None, nominal_setpoint=None, reduced_setpoint=40.0, ) @@ -97,7 +82,6 @@ async def test_prepare_hot_water_state(mock_bsblan: BSBLAN) -> None: # Test preparing no parameters (should raise an error) with pytest.raises(BSBLANError, match=NO_STATE_ERROR_MSG): mock_bsblan._prepare_hot_water_state( - operating_mode=None, nominal_setpoint=None, reduced_setpoint=None, ) @@ -107,7 +91,12 @@ async def test_prepare_hot_water_state(mock_bsblan: BSBLAN) -> None: async def test_set_hot_water_state( mock_bsblan: BSBLAN, ) -> None: - """Test setting hot water state.""" + """Test setting hot water state. + + Args: + mock_bsblan (BSBLAN): The mock BSBLAN instance. + + """ state = { "Parameter": "1600", "EnumValue": "3",