From 3dc1cd9753e842ee5380f0fa56fd87a8ec10ecdd Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 22 Jul 2026 23:01:30 -0700 Subject: [PATCH 1/2] Gate demand response commands on dr_setting_use capability enable_demand_response/disable_demand_response were dispatched unconditionally even though the dr_setting_use capability flag exists on DeviceFeature. Every other controllable feature follows flag + _CAPABILITY_MAP entry + @requires_capability, so DR was the one gap. Add "dr_setting_use" to _CAPABILITY_MAP and decorate both methods. Fixes #114 --- src/nwp500/device_capabilities.py | 1 + src/nwp500/mqtt/control.py | 2 ++ tests/test_device_capabilities.py | 17 ++++++++++++- tests/test_protocol_correctness.py | 40 ++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/nwp500/device_capabilities.py b/src/nwp500/device_capabilities.py index 088a8204..3839602d 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), } @classmethod diff --git a/src/nwp500/mqtt/control.py b/src/nwp500/mqtt/control.py index 79d82ff3..2e943cde 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 3cf88931..90b5b0b4 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 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["dr_setting_use"] is True + assert len(controls) == 9 + + 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_register_capability(self) -> None: """Test custom capability registration.""" diff --git a/tests/test_protocol_correctness.py b/tests/test_protocol_correctness.py index 10065d48..9d8e572d 100644 --- a/tests/test_protocol_correctness.py +++ b/tests/test_protocol_correctness.py @@ -299,6 +299,46 @@ 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() + + class TestErrorCodeChangeEvents: """error_detected must fire when the error code changes.""" From ca1cf6e4540cf83abfe506009281cb309a897418 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Thu, 23 Jul 2026 08:22:47 -0700 Subject: [PATCH 2/2] Add missing disable_demand_response allowed-when-supported test Review feedback on #117 (Copilot): the PR description claimed both enable_demand_response and disable_demand_response were verified allowed-when-supported, but only enable_demand_response had that assertion. Add the matching test for disable_demand_response so a regression that gates one command but not the other would be caught. --- tests/test_protocol_correctness.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_protocol_correctness.py b/tests/test_protocol_correctness.py index 9d8e572d..cb8180f3 100644 --- a/tests/test_protocol_correctness.py +++ b/tests/test_protocol_correctness.py @@ -338,6 +338,16 @@ async def test_enable_allowed_when_supported(self, mock_device): 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 TestErrorCodeChangeEvents: """error_detected must fire when the error code changes."""