From e373a7eb9e69b75017a5e1e54853d83a1a55dc07 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 22 Jul 2026 22:59:34 -0700 Subject: [PATCH 1/2] Validate freeze protection temperature against device feature limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set_freeze_protection_temperature documented a 35-45°F valid range but never enforced it, unlike set_dhw_temperature which validates against device feature limits. Now validates temperature against features.freeze_protection_temp_min/_max before sending, raising RangeValidationError out of range and DeviceCapabilityError if device features aren't available yet. Fixes #112 --- src/nwp500/mqtt/control.py | 23 ++++++++++++++ tests/test_protocol_correctness.py | 50 ++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/nwp500/mqtt/control.py b/src/nwp500/mqtt/control.py index 79d82ff3..7d15cc9c 100644 --- a/src/nwp500/mqtt/control.py +++ b/src/nwp500/mqtt/control.py @@ -831,7 +831,30 @@ async def set_freeze_protection_temperature( Returns: Publish packet ID + + Raises: + DeviceCapabilityError: If device features are not available and + 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_protocol_correctness.py b/tests/test_protocol_correctness.py index 10065d48..31d07075 100644 --- a/tests/test_protocol_correctness.py +++ b/tests/test_protocol_correctness.py @@ -299,6 +299,56 @@ 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_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_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() + + class TestErrorCodeChangeEvents: """error_detected must fire when the error code changes.""" From 3caebbf6207467cfd7577b1be2559064a34faa95 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Thu, 23 Jul 2026 08:20:55 -0700 Subject: [PATCH 2/2] Gate freeze protection temperature on freeze_protection_use capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #116 (Copilot): - Range validation alone let the command through to devices that report freeze_protection_use=False; add the capability to _CAPABILITY_MAP and decorate set_freeze_protection_temperature with @requires_capability("freeze_protection_use"), consistent with every other controllable feature. - The docstring hard-coded a fixed 35-45°F range even though validation is against the device's own reported limits; reworded to describe device-specific validation instead. --- src/nwp500/device_capabilities.py | 1 + src/nwp500/mqtt/control.py | 13 +++++++++---- tests/test_device_capabilities.py | 17 ++++++++++++++++- tests/test_protocol_correctness.py | 26 ++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/nwp500/device_capabilities.py b/src/nwp500/device_capabilities.py index 088a8204..f28d500f 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 7d15cc9c..3eb8d7f9 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,15 +827,19 @@ 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 device features are not available and - the temperature range cannot be validated. + 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. """ diff --git a/tests/test_device_capabilities.py b/tests/test_device_capabilities.py index 3cf88931..e0c3830c 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 31d07075..4420d0b4 100644 --- a/tests/test_protocol_correctness.py +++ b/tests/test_protocol_correctness.py @@ -309,6 +309,7 @@ async def test_rejects_out_of_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, ) @@ -325,6 +326,7 @@ 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, ) @@ -348,6 +350,30 @@ async def test_raises_capability_error_when_features_unavailable( ) 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."""