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
),
"dr_setting_use": lambda f: bool(f.dr_setting_use),
"freeze_protection_use": lambda f: bool(f.freeze_protection_use),
}

Expand Down
2 changes: 2 additions & 0 deletions src/nwp500/mqtt/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,10 +641,12 @@ async def signal_app_connection(self, device: Device) -> int:

return await self._publish(topic, message)

@requires_capability("dr_setting_use")
async def enable_demand_response(self, device: Device) -> int:
"""Enable utility demand response participation."""
return await self._mode_command(device, CommandCode.DR_ON, "dr-on")

@requires_capability("dr_setting_use")
async def disable_demand_response(self, device: Device) -> int:
"""Disable utility demand response participation."""
return await self._mode_command(device, CommandCode.DR_OFF, "dr-off")
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.dr_setting_use = True
mock_feature.freeze_protection_use = True

controls = MqttDeviceCapabilityChecker.get_available_controls(
Expand All @@ -103,8 +104,22 @@ 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 controls["dr_setting_use"] is True
assert controls["freeze_protection_use"] is True
assert len(controls) == 9
assert len(controls) == 10

def test_dr_setting_use_capability(self) -> None:
"""dr_setting_use must be a registered capability (issue #114)."""
mock_feature = Mock()
mock_feature.dr_setting_use = True
assert MqttDeviceCapabilityChecker.supports(
"dr_setting_use", mock_feature
)

mock_feature.dr_setting_use = False
assert not MqttDeviceCapabilityChecker.supports(
"dr_setting_use", mock_feature
)

def test_freeze_protection_use_capability(self) -> None:
"""freeze_protection_use must be a registered capability."""
Expand Down
50 changes: 50 additions & 0 deletions tests/test_protocol_correctness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 TestDemandResponseCapabilityGate:
"""enable/disable_demand_response must be gated on dr_setting_use."""

@pytest.mark.asyncio
async def test_enable_blocked_when_unsupported(self, mock_device):
from nwp500.exceptions import DeviceCapabilityError

controller, publish = _make_controller()
controller._get_device_features = AsyncMock(
return_value=MagicMock(dr_setting_use=False)
)

with pytest.raises(DeviceCapabilityError):
await controller.enable_demand_response(mock_device)
publish.assert_not_awaited()

@pytest.mark.asyncio
async def test_disable_blocked_when_unsupported(self, mock_device):
from nwp500.exceptions import DeviceCapabilityError

controller, publish = _make_controller()
controller._get_device_features = AsyncMock(
return_value=MagicMock(dr_setting_use=False)
)

with pytest.raises(DeviceCapabilityError):
await controller.disable_demand_response(mock_device)
publish.assert_not_awaited()

@pytest.mark.asyncio
async def test_enable_allowed_when_supported(self, mock_device):
controller, publish = _make_controller()
controller._get_device_features = AsyncMock(
return_value=MagicMock(dr_setting_use=True)
)

await controller.enable_demand_response(mock_device)
publish.assert_awaited_once()

@pytest.mark.asyncio
async def test_disable_allowed_when_supported(self, mock_device):
controller, publish = _make_controller()
controller._get_device_features = AsyncMock(
return_value=MagicMock(dr_setting_use=True)
)

await controller.disable_demand_response(mock_device)
publish.assert_awaited_once()


class TestFreezeProtectionTemperatureValidation:
"""set_freeze_protection_temperature must validate against device limits."""

Expand Down
Loading