diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index 47ea116e..1577d19a 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -447,9 +447,16 @@ async def unregister_event_listener(self) -> None: self.event_listener_id = None @retry_on_auth_error - async def get_current_execution(self, exec_id: str) -> Execution: - """Get a currently running execution by its exec_id.""" + async def get_current_execution(self, exec_id: str) -> Execution | None: + """Get a currently running execution by its exec_id. + + Returns None if the execution does not exist. + """ response = await self._get(f"exec/current/{exec_id}") + + if not response or not isinstance(response, dict): + return None + return Execution(**decamelize(response)) @retry_on_auth_error diff --git a/pyoverkiz/exceptions.py b/pyoverkiz/exceptions.py index 77df2062..54dd98a1 100644 --- a/pyoverkiz/exceptions.py +++ b/pyoverkiz/exceptions.py @@ -97,6 +97,14 @@ class UnknownUserError(BaseOverkizError): """Raised when an unknown user is provided.""" +class NoSuchDeviceError(BaseOverkizError): + """Raised when the requested device does not exist.""" + + +class NoSuchActionGroupError(BaseOverkizError): + """Raised when the requested action group does not exist.""" + + class UnknownObjectError(BaseOverkizError): """Raised when an unknown object is provided.""" diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index b25fca50..2f4339db 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -805,8 +805,11 @@ class Execution: id: str description: str owner: str = field(repr=obfuscate_email) - state: str + state: ExecutionState action_group: ActionGroup + start_time: int | None = None + execution_type: ExecutionType | None = None + execution_sub_type: ExecutionSubType | None = None def __init__( self, @@ -815,14 +818,22 @@ def __init__( owner: str, state: str, action_group: dict[str, Any], + start_time: int | None = None, + execution_type: str | None = None, + execution_sub_type: str | None = None, **_: Any, ): """Initialize Execution object from API fields.""" self.id = id self.description = description self.owner = owner - self.state = state + self.state = ExecutionState(state) self.action_group = ActionGroup(**action_group) + self.start_time = start_time + self.execution_type = ExecutionType(execution_type) if execution_type else None + self.execution_sub_type = ( + ExecutionSubType(execution_sub_type) if execution_sub_type else None + ) @define(init=False, kw_only=True) @@ -858,7 +869,7 @@ class ActionGroup: is composed of one or more commands to be executed on that device. """ - id: str = field(repr=obfuscate_id) + id: str | None = field(default=None, repr=obfuscate_id) creation_time: int | None = None last_update_time: int | None = None label: str = field(repr=obfuscate_string) @@ -869,7 +880,7 @@ class ActionGroup: notification_text: str | None = None notification_title: str | None = None actions: list[Action] - oid: str = field(repr=obfuscate_id) + oid: str | None = field(default=None, repr=obfuscate_id) def __init__( self, @@ -888,10 +899,7 @@ def __init__( **_: Any, ) -> None: """Initialize ActionGroup from API data and convert nested actions.""" - if oid is None and id is None: - raise ValueError("Either 'oid' or 'id' must be provided") - - self.id = cast(str, oid or id) + self.id = oid or id self.creation_time = creation_time self.last_update_time = last_update_time self.label = ( @@ -904,7 +912,7 @@ def __init__( self.notification_text = notification_text self.notification_title = notification_title self.actions = [Action(**action) for action in actions] - self.oid = cast(str, oid or id) + self.oid = oid or id @define(init=False, kw_only=True) diff --git a/pyoverkiz/response_handler.py b/pyoverkiz/response_handler.py index 97e2582b..3210087b 100644 --- a/pyoverkiz/response_handler.py +++ b/pyoverkiz/response_handler.py @@ -22,6 +22,8 @@ MissingAPIKeyError, MissingAuthorizationTokenError, NoRegisteredEventListenerError, + NoSuchActionGroupError, + NoSuchDeviceError, NoSuchResourceError, NoSuchTokenError, NotAuthenticatedError, @@ -46,6 +48,8 @@ ("INVALID_FIELD_VALUE", None, ActionGroupSetupNotFoundError), ("INVALID_API_CALL", None, NoSuchResourceError), ("EXEC_QUEUE_FULL", None, ExecutionQueueFullError), + ("NO_SUCH_DEVICE", None, NoSuchDeviceError), + ("NO_SUCH_ACTION_GROUP", None, NoSuchActionGroupError), # --- errorCode + message substring --- ("AUTHENTICATION_ERROR", "Too many requests", TooManyRequestsError), ("AUTHENTICATION_ERROR", "Bad credentials", BadCredentialsError), diff --git a/tests/fixtures/endpoints/device-states.json b/tests/fixtures/endpoints/device-states.json new file mode 100644 index 00000000..7967a36f --- /dev/null +++ b/tests/fixtures/endpoints/device-states.json @@ -0,0 +1,17 @@ +[ + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:ClosureState", + "type": 1, + "value": 0 + }, + { + "name": "core:OpenClosedState", + "type": 3, + "value": "open" + } +] diff --git a/tests/fixtures/endpoints/events-register.json b/tests/fixtures/endpoints/events-register.json new file mode 100644 index 00000000..c9570251 --- /dev/null +++ b/tests/fixtures/endpoints/events-register.json @@ -0,0 +1 @@ +{"id": "a70f6d96-0a19-0483-72d9-ac5f6bd7da26"} diff --git a/tests/fixtures/endpoints/exec-apply.json b/tests/fixtures/endpoints/exec-apply.json new file mode 100644 index 00000000..7d2340b4 --- /dev/null +++ b/tests/fixtures/endpoints/exec-apply.json @@ -0,0 +1 @@ +{"execId": "ee7a5676-c68f-43a3-956d-6f5efc745954"} diff --git a/tests/fixtures/endpoints/exec-current-empty-list.json b/tests/fixtures/endpoints/exec-current-empty-list.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/tests/fixtures/endpoints/exec-current-empty-list.json @@ -0,0 +1 @@ +[] diff --git a/tests/fixtures/endpoints/exec-current-empty-object.json b/tests/fixtures/endpoints/exec-current-empty-object.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tests/fixtures/endpoints/exec-current-empty-object.json @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/endpoints/exec-schedule.json b/tests/fixtures/endpoints/exec-schedule.json new file mode 100644 index 00000000..fa7e8c3f --- /dev/null +++ b/tests/fixtures/endpoints/exec-schedule.json @@ -0,0 +1 @@ +{"triggerId": "abc12345-def6-7890-abcd-ef1234567890"} diff --git a/tests/fixtures/endpoints/history-executions.json b/tests/fixtures/endpoints/history-executions.json new file mode 100644 index 00000000..fe1cf113 --- /dev/null +++ b/tests/fixtures/endpoints/history-executions.json @@ -0,0 +1,52 @@ +[ + { + "id": "699dd967-0a19-0481-7a62-99b990a2feb8", + "eventTime": 1767003511145, + "owner": "email@email.nl", + "source": "modem", + "endTime": 1767003514000, + "effectiveStartTime": 1767003511500, + "duration": 2855, + "label": "close - RTS Roller Shutter", + "type": "ACTUATOR", + "state": "COMPLETED", + "failureType": "NO_FAILURE", + "commands": [ + { + "deviceURL": "rts://2025-8464-6867/16756006", + "command": "close", + "parameters": [], + "rank": 0, + "dynamic": false, + "state": "COMPLETED", + "failureType": "NO_FAILURE" + } + ], + "executionType": "Immediate execution", + "executionSubType": "MANUAL_CONTROL" + }, + { + "id": "b2c3d4e5-f6a7-8901-bcde-f23456789012", + "eventTime": 1767002400000, + "owner": "email@email.nl", + "source": "modem", + "duration": 0, + "label": "open - RTS Roller Shutter", + "type": "ACTUATOR", + "state": "FAILED", + "failureType": "CMDCANCELLED", + "commands": [ + { + "deviceURL": "rts://2025-8464-6867/16756006", + "command": "open", + "parameters": [], + "rank": 0, + "dynamic": false, + "state": "FAILED", + "failureType": "CMDCANCELLED" + } + ], + "executionType": "Immediate execution", + "executionSubType": "MANUAL_CONTROL" + } +] diff --git a/tests/fixtures/endpoints/setup-places.json b/tests/fixtures/endpoints/setup-places.json new file mode 100644 index 00000000..910d740e --- /dev/null +++ b/tests/fixtures/endpoints/setup-places.json @@ -0,0 +1,25 @@ +{ + "creationTime": 1650000000000, + "lastUpdateTime": 1767003511145, + "label": "My House", + "type": 0, + "oid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "subPlaces": [ + { + "creationTime": 1650000100000, + "lastUpdateTime": 1767003511145, + "label": "Living Room", + "type": 1, + "oid": "11111111-2222-3333-4444-555555555555", + "subPlaces": [] + }, + { + "creationTime": 1650000200000, + "lastUpdateTime": null, + "label": "Bedroom", + "type": 1, + "oid": "66666666-7777-8888-9999-aaaaaaaaaaaa", + "subPlaces": [] + } + ] +} diff --git a/tests/fixtures/exceptions/cloud/action-group-setup-not-found.json b/tests/fixtures/exceptions/cloud/action-group-setup-not-found.json new file mode 100644 index 00000000..26fd3f50 --- /dev/null +++ b/tests/fixtures/exceptions/cloud/action-group-setup-not-found.json @@ -0,0 +1 @@ +{"errorCode":"INVALID_FIELD_VALUE","error":"Unable to determine action group setup (no setup for gateway #0000-0000-0000)"} diff --git a/tests/fixtures/exceptions/cloud/no-such-action-group.json b/tests/fixtures/exceptions/cloud/no-such-action-group.json new file mode 100644 index 00000000..7e62bb93 --- /dev/null +++ b/tests/fixtures/exceptions/cloud/no-such-action-group.json @@ -0,0 +1 @@ +{"errorCode":"NO_SUCH_ACTION_GROUP","error":"No such action group : 00000000-0000-0000-0000-000000000000"} diff --git a/tests/fixtures/exceptions/cloud/no-such-controllable.json b/tests/fixtures/exceptions/cloud/no-such-controllable.json new file mode 100644 index 00000000..865dee17 --- /dev/null +++ b/tests/fixtures/exceptions/cloud/no-such-controllable.json @@ -0,0 +1 @@ +{"errorCode":"NO_SUCH_RESSOURCE","error":"No such controllable : io:NonExistentDeviceControllable"} diff --git a/tests/fixtures/exceptions/cloud/no-such-ui-profile.json b/tests/fixtures/exceptions/cloud/no-such-ui-profile.json new file mode 100644 index 00000000..1885868c --- /dev/null +++ b/tests/fixtures/exceptions/cloud/no-such-ui-profile.json @@ -0,0 +1 @@ +{"errorCode":"NO_SUCH_RESSOURCE","error":"No such core UI profile or form-factor : NonExistentProfile"} diff --git a/tests/fixtures/exceptions/cloud/resource-access-denied-device-setup-mismatch.json b/tests/fixtures/exceptions/cloud/resource-access-denied-device-setup-mismatch.json new file mode 100644 index 00000000..64c941d1 --- /dev/null +++ b/tests/fixtures/exceptions/cloud/resource-access-denied-device-setup-mismatch.json @@ -0,0 +1 @@ +{"errorCode":"RESOURCE_ACCESS_DENIED","error":"Security exception : Device setup mismatch"} diff --git a/tests/fixtures/exceptions/cloud/resource-access-denied-gateway-not-in-setup.json b/tests/fixtures/exceptions/cloud/resource-access-denied-gateway-not-in-setup.json new file mode 100644 index 00000000..d376e3c3 --- /dev/null +++ b/tests/fixtures/exceptions/cloud/resource-access-denied-gateway-not-in-setup.json @@ -0,0 +1 @@ +{"errorCode":"RESOURCE_ACCESS_DENIED","error":"Security exception : Gateway #0000-0000-0000 does not belong to setup 15eaf55a-8af9-483b-ae4a-ffd4254fd762"} diff --git a/tests/fixtures/exec/current-single.json b/tests/fixtures/exec/current-single.json new file mode 100644 index 00000000..ac59f605 --- /dev/null +++ b/tests/fixtures/exec/current-single.json @@ -0,0 +1,26 @@ +{ + "startTime": 1767003511145, + "owner": "email@email.nl", + "actionGroup": { + "label": "Execution via Home Assistant", + "shortcut": false, + "notificationTypeMask": 0, + "notificationCondition": "NEVER", + "actions": [ + { + "deviceURL": "rts://1234-5678-1234/12345678", + "commands": [ + { + "type": 1, + "name": "close" + } + ] + } + ] + }, + "description": "Execution : Execution via Home Assistant", + "id": "699dd967-0a19-0481-7a62-99b990a2feb8", + "state": "TRANSMITTED", + "executionType": "Immediate execution", + "executionSubType": "MANUAL_CONTROL" +} diff --git a/tests/test_client.py b/tests/test_client.py index d0740a14..17bbbb9b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -19,8 +19,23 @@ UsernamePasswordCredentials, ) from pyoverkiz.client import OverkizClient -from pyoverkiz.enums import APIType, DataType, Server -from pyoverkiz.models import Option +from pyoverkiz.enums import ( + APIType, + DataType, + ExecutionState, + ExecutionSubType, + ExecutionType, + Server, +) +from pyoverkiz.models import ( + Action, + Command, + Execution, + HistoryExecution, + Option, + Place, + State, +) from pyoverkiz.response_handler import check_response from pyoverkiz.utils import create_local_server_config @@ -460,6 +475,36 @@ async def test_get_diagnostic_data_without_masking(self, client: OverkizClient): exceptions.ResourceAccessDeniedError, 400, ), + ( + "cloud/resource-access-denied-device-setup-mismatch.json", + exceptions.ResourceAccessDeniedError, + 400, + ), + ( + "cloud/resource-access-denied-gateway-not-in-setup.json", + exceptions.ResourceAccessDeniedError, + 400, + ), + ( + "cloud/no-such-action-group.json", + exceptions.NoSuchActionGroupError, + 404, + ), + ( + "cloud/action-group-setup-not-found.json", + exceptions.ActionGroupSetupNotFoundError, + 400, + ), + ( + "cloud/no-such-controllable.json", + exceptions.OverkizError, + 400, + ), + ( + "cloud/no-such-ui-profile.json", + exceptions.OverkizError, + 400, + ), ( "local/400-bad-parameters.json", exceptions.OverkizError, @@ -503,7 +548,7 @@ async def test_get_diagnostic_data_without_masking(self, client: OverkizClient): ), ( "local/400-no-such-device.json", - exceptions.OverkizError, + exceptions.NoSuchDeviceError, 400, ), ( @@ -667,6 +712,31 @@ async def test_get_setup_option( else: assert isinstance(option, instance) + @pytest.mark.parametrize( + "fixture_name", + [ + "exec-current-empty-object.json", + "exec-current-empty-list.json", + ], + ) + @pytest.mark.asyncio + async def test_get_current_execution_returns_none_for_empty_response( + self, + client: OverkizClient, + fixture_name: str, + ): + """Cloud returns {} and local returns [] for non-existent exec_ids.""" + with (CURRENT_DIR / "fixtures" / "endpoints" / fixture_name).open( + encoding="utf-8", + ) as f: + resp = MockResponse(f.read()) + + with patch.object(aiohttp.ClientSession, "get", return_value=resp): + result = await client.get_current_execution( + "00000000-0000-0000-0000-000000000000" + ) + assert result is None + @pytest.mark.parametrize( ("fixture_name", "scenario_count"), [ @@ -706,6 +776,372 @@ async def test_get_action_groups( for command in action.commands: assert command.name + @pytest.mark.asyncio + async def test_get_current_execution_returns_execution(self, client: OverkizClient): + """Verify a running execution is parsed into an Execution model.""" + with (CURRENT_DIR / "fixtures" / "exec" / "current-single.json").open( + encoding="utf-8", + ) as f: + resp = MockResponse(f.read()) + + with patch.object(aiohttp.ClientSession, "get", return_value=resp): + result = await client.get_current_execution( + "699dd967-0a19-0481-7a62-99b990a2feb8" + ) + assert isinstance(result, Execution) + assert result.id == "699dd967-0a19-0481-7a62-99b990a2feb8" + assert result.state == ExecutionState.TRANSMITTED + assert result.start_time == 1767003511145 + assert result.execution_type == ExecutionType.IMMEDIATE_EXECUTION + assert result.execution_sub_type == ExecutionSubType.MANUAL_CONTROL + assert result.action_group.oid is None + assert ( + result.action_group.actions[0].device_url + == "rts://1234-5678-1234/12345678" + ) + + @pytest.mark.asyncio + async def test_get_current_executions(self, client: OverkizClient): + """Verify parsing a list of running executions with RTS device commands.""" + with (CURRENT_DIR / "fixtures" / "exec" / "current-tahoma-switch.json").open( + encoding="utf-8", + ) as f: + resp = MockResponse(f.read()) + + with patch.object(aiohttp.ClientSession, "get", return_value=resp): + executions = await client.get_current_executions() + assert len(executions) == 1 + assert isinstance(executions[0], Execution) + assert executions[0].state == ExecutionState.TRANSMITTED + assert len(executions[0].action_group.actions) == 2 + assert executions[0].action_group.actions[0].commands[0].name == "close" + assert executions[0].action_group.actions[1].commands[0].name == "identify" + + @pytest.mark.asyncio + async def test_get_execution_history(self, client: OverkizClient): + """Verify execution history parsing including completed and failed executions.""" + with (CURRENT_DIR / "fixtures" / "endpoints" / "history-executions.json").open( + encoding="utf-8", + ) as f: + resp = MockResponse(f.read()) + + with patch.object(aiohttp.ClientSession, "get", return_value=resp): + history = await client.get_execution_history() + assert len(history) == 2 + + completed = history[0] + assert isinstance(completed, HistoryExecution) + assert completed.state.value == "COMPLETED" + assert completed.failure_type == "NO_FAILURE" + assert completed.commands[0].command == "close" + assert completed.commands[0].device_url == "rts://2025-8464-6867/16756006" + + failed = history[1] + assert failed.state.value == "FAILED" + assert failed.failure_type == "CMDCANCELLED" + assert failed.commands[0].command == "open" + + @pytest.mark.asyncio + async def test_get_state(self, client: OverkizClient): + """Verify device state retrieval and parsing.""" + with (CURRENT_DIR / "fixtures" / "endpoints" / "device-states.json").open( + encoding="utf-8", + ) as f: + resp = MockResponse(f.read()) + + with patch.object(aiohttp.ClientSession, "get", return_value=resp): + states = await client.get_state("io://1234-5678-1234/12345678") + assert len(states) == 3 + assert all(isinstance(s, State) for s in states) + assert states[0].name == "core:StatusState" + assert states[0].value == "available" + assert states[1].name == "core:ClosureState" + assert states[1].value == 0 + assert states[2].name == "core:OpenClosedState" + assert states[2].value == "open" + + @pytest.mark.asyncio + async def test_get_places(self, client: OverkizClient): + """Verify hierarchical place structure is parsed recursively.""" + with (CURRENT_DIR / "fixtures" / "endpoints" / "setup-places.json").open( + encoding="utf-8", + ) as f: + resp = MockResponse(f.read()) + + with patch.object(aiohttp.ClientSession, "get", return_value=resp): + places = await client.get_places() + assert isinstance(places, Place) + assert places.label == "My House" + assert len(places.sub_places) == 2 + assert places.sub_places[0].label == "Living Room" + assert places.sub_places[1].label == "Bedroom" + assert places.sub_places[1].last_update_time is None + + @pytest.mark.asyncio + async def test_execute_action_group_rts_close(self, client: OverkizClient): + """Verify executing a close command on an RTS cover.""" + action = Action( + "rts://2025-8464-6867/16756006", + [Command(name="close", parameters=None, type=1)], + ) + resp = MockResponse('{"execId": "ee7a5676-c68f-43a3-956d-6f5efc745954"}') + + with patch.object(aiohttp.ClientSession, "post") as mock_post: + mock_post.return_value = resp + exec_id = await client.execute_action_group([action]) + + assert exec_id == "ee7a5676-c68f-43a3-956d-6f5efc745954" + _, kwargs = mock_post.call_args + sent_json = kwargs.get("json") + assert ( + sent_json["actions"][0]["deviceURL"] == "rts://2025-8464-6867/16756006" + ) + assert sent_json["actions"][0]["commands"][0]["name"] == "close" + + @pytest.mark.asyncio + async def test_execute_action_group_multiple_rts_devices( + self, client: OverkizClient + ): + """Verify executing commands on multiple RTS devices in a single action group.""" + actions = [ + Action( + "rts://2025-8464-6867/16756006", + [Command(name="close", parameters=None, type=1)], + ), + Action( + "rts://2025-8464-6867/16756007", + [Command(name="open", parameters=None, type=1)], + ), + ] + resp = MockResponse('{"execId": "aaa-bbb-ccc"}') + + with patch.object(aiohttp.ClientSession, "post") as mock_post: + mock_post.return_value = resp + exec_id = await client.execute_action_group(actions) + + assert exec_id == "aaa-bbb-ccc" + _, kwargs = mock_post.call_args + sent_json = kwargs.get("json") + assert len(sent_json["actions"]) == 2 + assert sent_json["actions"][0]["commands"][0]["name"] == "close" + assert sent_json["actions"][1]["commands"][0]["name"] == "open" + + @pytest.mark.asyncio + async def test_execute_persisted_action_group(self, client: OverkizClient): + """Verify executing a persisted action group by OID.""" + resp = MockResponse('{"execId": "ee7a5676-c68f-43a3-956d-6f5efc745954"}') + + with patch.object(aiohttp.ClientSession, "post", return_value=resp): + exec_id = await client.execute_persisted_action_group( + "12345678-abcd-efgh-ijkl-123456789012" + ) + assert exec_id == "ee7a5676-c68f-43a3-956d-6f5efc745954" + + @pytest.mark.asyncio + async def test_schedule_persisted_action_group(self, client: OverkizClient): + """Verify scheduling a persisted action group.""" + with (CURRENT_DIR / "fixtures" / "endpoints" / "exec-schedule.json").open( + encoding="utf-8", + ) as f: + resp = MockResponse(f.read()) + + with patch.object(aiohttp.ClientSession, "post", return_value=resp): + trigger_id = await client.schedule_persisted_action_group( + "12345678-abcd-efgh-ijkl-123456789012", 1767003511145 + ) + assert trigger_id == "abc12345-def6-7890-abcd-ef1234567890" + + @pytest.mark.asyncio + async def test_cancel_execution(self, client: OverkizClient): + """Verify cancel_execution sends DELETE and does not raise on 204.""" + resp = MockResponse("", status=204) + + with patch.object(aiohttp.ClientSession, "delete", return_value=resp): + await client.cancel_execution("699dd967-0a19-0481-7a62-99b990a2feb8") + + @pytest.mark.asyncio + async def test_register_event_listener(self, client: OverkizClient): + """Verify event listener registration returns and stores the listener ID.""" + with (CURRENT_DIR / "fixtures" / "endpoints" / "events-register.json").open( + encoding="utf-8", + ) as f: + resp = MockResponse(f.read()) + + with patch.object(aiohttp.ClientSession, "post", return_value=resp): + listener_id = await client.register_event_listener() + assert listener_id == "a70f6d96-0a19-0483-72d9-ac5f6bd7da26" + assert client.event_listener_id == listener_id + + @pytest.mark.asyncio + async def test_refresh_states(self, client: OverkizClient): + """Verify refresh_states sends POST and does not raise on 204.""" + resp = MockResponse("", status=204) + + with patch.object(aiohttp.ClientSession, "post", return_value=resp): + await client.refresh_states() + + @pytest.mark.asyncio + async def test_refresh_device_states(self, client: OverkizClient): + """Verify refresh_device_states sends POST for a specific device.""" + resp = MockResponse("", status=204) + + with patch.object(aiohttp.ClientSession, "post", return_value=resp): + await client.refresh_device_states("rts://2025-8464-6867/16756006") + + # --- Local API specific tests --- + # The local gateway (KizOs) behaves differently from the cloud API + # in several cases. These tests verify the client raises proper errors + # instead of crashing when called via the local API. + + @pytest.mark.asyncio + async def test_local_get_current_execution_empty_list( + self, local_client: OverkizClient + ): + """Local gateway returns [] for non-existent exec_id (cloud returns {}).""" + resp = MockResponse("[]") + + with patch.object(aiohttp.ClientSession, "get", return_value=resp): + result = await local_client.get_current_execution( + "00000000-0000-0000-0000-000000000000" + ) + assert result is None + + @pytest.mark.asyncio + async def test_local_get_state_no_such_device(self, local_client: OverkizClient): + """Local gateway raises NoSuchDeviceError for unknown device URLs.""" + resp = MockResponse( + '{"error":"No such device : \\"io://0000-0000-0000/12345678\\"","errorCode":"NO_SUCH_DEVICE"}', + status=400, + ) + + with ( + patch.object(aiohttp.ClientSession, "get", return_value=resp), + pytest.raises(exceptions.NoSuchDeviceError), + ): + await local_client.get_state("io://0000-0000-0000/12345678") + + @pytest.mark.asyncio + async def test_local_get_device_definition_no_such_device( + self, local_client: OverkizClient + ): + """Local gateway raises NoSuchDeviceError for unknown device definition lookups.""" + resp = MockResponse( + '{"error":"No such device : \\"io://0000-0000-0000/12345678\\"","errorCode":"NO_SUCH_DEVICE"}', + status=400, + ) + + with ( + patch.object(aiohttp.ClientSession, "get", return_value=resp), + pytest.raises(exceptions.NoSuchDeviceError), + ): + await local_client.get_device_definition("io://0000-0000-0000/12345678") + + @pytest.mark.asyncio + async def test_local_get_setup_option_unknown_object( + self, local_client: OverkizClient + ): + """Local gateway raises UnknownObjectError for non-existent options (cloud returns {}).""" + resp = MockResponse( + '{"error":"Unknown object.","errorCode":"UNSPECIFIED_ERROR"}', + status=400, + ) + + with ( + patch.object(aiohttp.ClientSession, "get", return_value=resp), + pytest.raises(exceptions.UnknownObjectError), + ): + await local_client.get_setup_option("nonExistentOption") + + @pytest.mark.asyncio + async def test_local_refresh_device_states_unknown_object( + self, local_client: OverkizClient + ): + """Local gateway raises UnknownObjectError for unknown device refresh.""" + resp = MockResponse( + '{"error":"Unknown object.","errorCode":"UNSPECIFIED_ERROR"}', + status=400, + ) + + with ( + patch.object(aiohttp.ClientSession, "post", return_value=resp), + pytest.raises(exceptions.UnknownObjectError), + ): + await local_client.refresh_device_states("io://0000-0000-0000/12345678") + + @pytest.mark.asyncio + async def test_local_get_reference_controllable_unknown_object( + self, local_client: OverkizClient + ): + """Local gateway raises UnknownObjectError for unknown controllable names.""" + resp = MockResponse( + '{"error":"Unknown object.","errorCode":"UNSPECIFIED_ERROR"}', + status=400, + ) + + with ( + patch.object(aiohttp.ClientSession, "get", return_value=resp), + pytest.raises(exceptions.UnknownObjectError), + ): + await local_client.get_reference_controllable("io:NonExistentControllable") + + @pytest.mark.asyncio + async def test_local_cancel_execution_succeeds_on_unknown_id( + self, local_client: OverkizClient + ): + """Local gateway returns 200 with [] for cancel on unknown exec_id (idempotent).""" + resp = MockResponse("[]", status=200) + + with patch.object(aiohttp.ClientSession, "delete", return_value=resp): + await local_client.cancel_execution("00000000-0000-0000-0000-000000000000") + + @pytest.mark.asyncio + async def test_local_execute_action_group_rts_close( + self, local_client: OverkizClient + ): + """Verify executing an RTS command via the local API.""" + action = Action( + "rts://2025-8464-6867/16756006", + [Command(name="close")], + ) + resp = MockResponse('{"execId": "45e52d27-3c08-4fd5-87f2-03d650b67f4b"}') + + with patch.object(aiohttp.ClientSession, "post") as mock_post: + mock_post.return_value = resp + exec_id = await local_client.execute_action_group([action]) + + assert exec_id == "45e52d27-3c08-4fd5-87f2-03d650b67f4b" + + @pytest.mark.asyncio + async def test_local_no_registered_event_listener( + self, local_client: OverkizClient + ): + """Local gateway raises NoRegisteredEventListenerError for unregistered fetch.""" + resp = MockResponse( + '{"error":"\\"No registered event listener.\\"","errorCode":"UNSPECIFIED_ERROR"}', + status=400, + ) + + with pytest.raises(exceptions.NoRegisteredEventListenerError): + await check_response(resp) + + @pytest.mark.asyncio + async def test_local_schedule_persisted_action_group_unknown_object( + self, local_client: OverkizClient + ): + """Local gateway raises UnknownObjectError when scheduling a non-existent action group.""" + resp = MockResponse( + '{"error":"Unknown object.","errorCode":"UNSPECIFIED_ERROR"}', + status=400, + ) + + with ( + patch.object(aiohttp.ClientSession, "post", return_value=resp), + pytest.raises(exceptions.UnknownObjectError), + ): + await local_client.schedule_persisted_action_group( + "00000000-0000-0000-0000-000000000000", 9999999999 + ) + class MockResponse: """Simple stand-in for aiohttp responses used in tests."""