diff --git a/src/nwp500/device_capabilities.py b/src/nwp500/device_capabilities.py index 088a820..f28d500 100644 --- a/src/nwp500/device_capabilities.py +++ b/src/nwp500/device_capabilities.py @@ -45,6 +45,7 @@ class MqttDeviceCapabilityChecker: "anti_legionella_setting_use": lambda f: bool( f.anti_legionella_setting_use ), + "freeze_protection_use": lambda f: bool(f.freeze_protection_use), } @classmethod diff --git a/src/nwp500/mqtt/control.py b/src/nwp500/mqtt/control.py index 79d82ff..3eb8d7f 100644 --- a/src/nwp500/mqtt/control.py +++ b/src/nwp500/mqtt/control.py @@ -814,6 +814,7 @@ async def reset_wifi(self, device: Device) -> int: device, CommandCode.WIFI_RESET, "wifi-reset" ) + @requires_capability("freeze_protection_use") async def set_freeze_protection_temperature( self, device: Device, temperature: float ) -> int: @@ -826,12 +827,39 @@ async def set_freeze_protection_temperature( Args: device: Device to configure temperature: Activation temperature in the user's preferred unit - (°C if unit system is metric, °F otherwise). - Valid range: 35–45°F (1.7–7.2°C). + (°C if unit system is metric, °F otherwise). Validated + against the device's reported + ``freeze_protection_temp_min``/``freeze_protection_temp_max`` + feature limits (typically around 35-45°F / 1.7-7.2°C, but + this can vary by device). Returns: Publish packet ID + + Raises: + DeviceCapabilityError: If the device does not support freeze + protection, or its features are not available so the + temperature range cannot be validated. + RangeValidationError: If temperature is outside the device's + supported freeze protection range. """ + features = await self._get_device_features(device) + if features is None: + raise DeviceCapabilityError( + "freeze_protection_use", + ( + "Device features not available. " + "Unable to validate temperature range." + ), + ) + + self._validate_range( + "temperature", + temperature, + features.freeze_protection_temp_min, + features.freeze_protection_temp_max, + ) + raw = preferred_to_half_celsius(temperature) return await self._mode_command( device, CommandCode.FREZ_TEMP, "frez-temp", [raw] diff --git a/tests/test_device_capabilities.py b/tests/test_device_capabilities.py index 3cf8893..e0c3830 100644 --- a/tests/test_device_capabilities.py +++ b/tests/test_device_capabilities.py @@ -89,6 +89,7 @@ def test_get_available_controls(self) -> None: mock_feature.recirculation_use = True mock_feature.recirc_reservation_use = False mock_feature.anti_legionella_setting_use = True + mock_feature.freeze_protection_use = True controls = MqttDeviceCapabilityChecker.get_available_controls( mock_feature @@ -102,7 +103,21 @@ def test_get_available_controls(self) -> None: assert controls["recirculation_use"] is True assert controls["recirc_reservation_use"] is False assert controls["anti_legionella_setting_use"] is True - assert len(controls) == 8 + assert controls["freeze_protection_use"] is True + assert len(controls) == 9 + + def test_freeze_protection_use_capability(self) -> None: + """freeze_protection_use must be a registered capability.""" + mock_feature = Mock() + mock_feature.freeze_protection_use = True + assert MqttDeviceCapabilityChecker.supports( + "freeze_protection_use", mock_feature + ) + + mock_feature.freeze_protection_use = False + assert not MqttDeviceCapabilityChecker.supports( + "freeze_protection_use", mock_feature + ) def test_register_capability(self) -> None: """Test custom capability registration.""" diff --git a/tests/test_protocol_correctness.py b/tests/test_protocol_correctness.py index 10065d4..4420d0b 100644 --- a/tests/test_protocol_correctness.py +++ b/tests/test_protocol_correctness.py @@ -299,6 +299,82 @@ def test_defaults_decode_to_documented_range(self, device_status_dict): assert status.freeze_protection_temp_max == pytest.approx(50, abs=1) +class TestFreezeProtectionTemperatureValidation: + """set_freeze_protection_temperature must validate against device limits.""" + + @pytest.mark.asyncio + async def test_rejects_out_of_range_temperature(self, mock_device): + from nwp500.exceptions import RangeValidationError + + controller, publish = _make_controller() + controller._get_device_features = AsyncMock( + return_value=MagicMock( + freeze_protection_use=True, + freeze_protection_temp_min=35.0, + freeze_protection_temp_max=45.0, + ) + ) + + with pytest.raises(RangeValidationError): + await controller.set_freeze_protection_temperature( + mock_device, 60.0 + ) + publish.assert_not_awaited() + + @pytest.mark.asyncio + async def test_accepts_in_range_temperature(self, mock_device): + controller, publish = _make_controller() + controller._get_device_features = AsyncMock( + return_value=MagicMock( + freeze_protection_use=True, + freeze_protection_temp_min=35.0, + freeze_protection_temp_max=45.0, + ) + ) + + await controller.set_freeze_protection_temperature(mock_device, 40.0) + publish.assert_awaited_once() + + @pytest.mark.asyncio + async def test_raises_capability_error_when_features_unavailable( + self, mock_device + ): + from nwp500.exceptions import DeviceCapabilityError + + controller, publish = _make_controller() + controller._get_device_features = AsyncMock(return_value=None) + + with pytest.raises(DeviceCapabilityError): + await controller.set_freeze_protection_temperature( + mock_device, 40.0 + ) + publish.assert_not_awaited() + + @pytest.mark.asyncio + async def test_blocked_when_device_lacks_freeze_protection( + self, mock_device + ): + """Regression: a device that doesn't support freeze protection at + all (freeze_protection_use=False) must not receive the command, + even if the requested temperature would otherwise be in-range.""" + from nwp500.exceptions import DeviceCapabilityError + + controller, publish = _make_controller() + controller._get_device_features = AsyncMock( + return_value=MagicMock( + freeze_protection_use=False, + freeze_protection_temp_min=35.0, + freeze_protection_temp_max=45.0, + ) + ) + + with pytest.raises(DeviceCapabilityError): + await controller.set_freeze_protection_temperature( + mock_device, 40.0 + ) + publish.assert_not_awaited() + + class TestErrorCodeChangeEvents: """error_detected must fire when the error code changes."""