diff --git a/src/nwp500/device_capabilities.py b/src/nwp500/device_capabilities.py index f28d500..e0312b7 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 ), + "dr_setting_use": lambda f: bool(f.dr_setting_use), "freeze_protection_use": lambda f: bool(f.freeze_protection_use), } diff --git a/src/nwp500/mqtt/control.py b/src/nwp500/mqtt/control.py index 3eb8d7f..65b3c93 100644 --- a/src/nwp500/mqtt/control.py +++ b/src/nwp500/mqtt/control.py @@ -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") diff --git a/tests/test_device_capabilities.py b/tests/test_device_capabilities.py index e0c3830..9ed09ae 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.dr_setting_use = True mock_feature.freeze_protection_use = True controls = MqttDeviceCapabilityChecker.get_available_controls( @@ -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.""" diff --git a/tests/test_protocol_correctness.py b/tests/test_protocol_correctness.py index 4420d0b..1718d05 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 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."""