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
1 change: 1 addition & 0 deletions src/nwp500/device_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 30 additions & 2 deletions src/nwp500/mqtt/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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."
),
)

Comment on lines +846 to +855

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 3caebbf. Added freeze_protection_use to _CAPABILITY_MAP and decorated the method with @requires_capability("freeze_protection_use"), matching the pattern used everywhere else. Added a regression test (test_blocked_when_device_lacks_freeze_protection).

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]
Expand Down
17 changes: 16 additions & 1 deletion tests/test_device_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down
76 changes: 76 additions & 0 deletions tests/test_protocol_correctness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading