diff --git a/src/demetriek/__init__.py b/src/demetriek/__init__.py index 414603ce..0220452f 100644 --- a/src/demetriek/__init__.py +++ b/src/demetriek/__init__.py @@ -20,8 +20,10 @@ LaMetricConnectionError, LaMetricConnectionTimeoutError, LaMetricError, + LaMetricUnsupportedError, ) from .models import ( + Application, Audio, Bluetooth, Chart, @@ -37,11 +39,13 @@ Sound, SoundURL, User, + Widget, Wifi, ) __all__ = [ "AlarmSound", + "Application", "Audio", "Bluetooth", "BrightnessMode", @@ -60,6 +64,7 @@ "LaMetricConnectionTimeoutError", "LaMetricDevice", "LaMetricError", + "LaMetricUnsupportedError", "Model", "Notification", "NotificationIconType", @@ -72,6 +77,7 @@ "Sound", "SoundURL", "User", + "Widget", "Wifi", "WifiMode", ] diff --git a/src/demetriek/device.py b/src/demetriek/device.py index ac6f8db0..3de6487d 100644 --- a/src/demetriek/device.py +++ b/src/demetriek/device.py @@ -18,13 +18,16 @@ LaMetricConnectionError, LaMetricConnectionTimeoutError, LaMetricError, + LaMetricUnsupportedError, ) from .models import ( + Application, Audio, Bluetooth, Device, Display, Notification, + Widget, Wifi, ) @@ -73,6 +76,8 @@ async def _request( Raises: ------ + LaMetricUnsupportedError: If the requested endpoint is not supported + by the current API version of the LaMetric device. LaMetricAuthenticationError: If the API key is invalid. LaMetricConnectionError: An error occurred while communication with the LaMetric device. @@ -117,6 +122,12 @@ async def _request( if exception.status in [401, 403]: msg = f"Authentication to the LaMetric device at {self.host} failed" raise LaMetricAuthenticationError(msg) from exception + if exception.status == 404: + msg = ( + f"The requested endpoint {uri} is not supported in the current" + f" API version by the LaMetric device at {self.host}." + ) + raise LaMetricUnsupportedError(msg) from exception msg = ( f"Error occurred while connecting to the LaMetric device at {self.host}" ) @@ -264,20 +275,153 @@ async def wifi(self) -> Wifi: data.update(ip=data.get("ipv4"), rssi=data.get("signal_strength")) return Wifi.from_dict(data) + async def apps(self) -> dict[str, Application] | None: + """Get installed apps on LaMetric device. + + Note: This feature is only available on API v2.1.0+ + Devices with OS version < 2.1.0 may not support this. + + Returns + ------- + A dictionary of Application objects keyed by package name, + or None if the device doesn't support apps API. + + """ + try: + response = await self._request("/api/v2/device/apps") + return { + pkg: Application.from_dict(app_data) + for pkg, app_data in response.items() + } + except LaMetricUnsupportedError: + return None + + async def app(self, *, package: str) -> Application | None: + """Get details of a specific app. + + Args: + ---- + package: The package name of the app (e.g., 'com.lametric.clock'). + + Returns: + ------- + An Application object with the app details. + + """ + try: + response = await self._request(f"/api/v2/device/apps/{package}") + return Application.from_dict(response) + except LaMetricUnsupportedError: + return None + + async def widget(self, *, package: str, widget_id: str) -> Widget | None: + """Get details of a specific widget. + + Args: + ---- + package: The package name of the app (e.g., 'com.lametric.clock'). + widget_id: The widget ID. + + Returns: + ------- + A Widget object with the widget details. + + """ + try: + response = await self._request( + f"/api/v2/device/apps/{package}/widgets/{widget_id}" + ) + return Widget.from_dict(response) + except LaMetricUnsupportedError: + return None + async def app_next(self) -> None: - """Switch to the next app on LaMetric Time. + """Switch to the next app on LaMetric device. - App order is controlled by the user via LaMetric Time app. + App order is controlled by the user via LaMetric mobile app. """ await self._request("/api/v2/device/apps/next", method=hdrs.METH_PUT) async def app_previous(self) -> None: - """Switch to the next app on LaMetric Time. + """Switch to the previous app on LaMetric device. - App order is controlled by the user via LaMetric Time app. + App order is controlled by the user via LaMetric mobile app. """ await self._request("/api/v2/device/apps/prev", method=hdrs.METH_PUT) + async def app_activate(self, *, package: str, widget_id: str) -> None: + """Activate a specific app widget on LaMetric device. + + Args: + ---- + package: The package name of the app (e.g., 'com.lametric.clock'). + widget_id: The widget ID to activate. + + """ + await self._request( + f"/api/v2/device/apps/{package}/widgets/{widget_id}/activate", + method=hdrs.METH_PUT, + ) + + # pylint: disable=too-many-arguments + async def widget_action( + self, + *, + package: str, + widget_id: str, + action_id: str, + parameters: dict[str, Any] | None = None, + activate: bool = True, + ) -> None: + """Interact with a running widget by triggering an action. + + Args: + ---- + package: The package name of the app (e.g., 'com.lametric.clock'). + widget_id: The widget ID. + action_id: The action to trigger (e.g., 'button.press', 'clock.alarm'). + parameters: Optional parameters for the action. + activate: Whether to activate the widget when performing the action. + + """ + payload = { + "id": action_id, + "params": parameters or {}, + "activate": activate, + } + await self._request( + f"/api/v2/device/apps/{package}/widgets/{widget_id}/actions", + method=hdrs.METH_POST, + data=payload, + ) + + async def widget_update( + self, + *, + package: str, + widget_id: str, + settings: dict[str, Any], + ) -> Widget: + """Update widget settings. + + Args: + ---- + package: The package name of the app (e.g., 'com.lametric.clock'). + widget_id: The widget ID. + settings: The settings to update. + + Returns: + ------- + A Widget object with the updated widget details. + + """ + response = await self._request( + f"/api/v2/device/apps/{package}/widgets/{widget_id}", + method=hdrs.METH_PUT, + data=settings, + ) + return Widget.from_dict(response) + async def notify( self, *, diff --git a/src/demetriek/exceptions.py b/src/demetriek/exceptions.py index 1362b433..951c146e 100644 --- a/src/demetriek/exceptions.py +++ b/src/demetriek/exceptions.py @@ -15,3 +15,7 @@ class LaMetricAuthenticationError(LaMetricError): class LaMetricConnectionTimeoutError(LaMetricConnectionError): """LaMetric connection Timeout exception.""" + + +class LaMetricUnsupportedError(LaMetricError): + """LaMetric API feature not supported on this device version.""" diff --git a/src/demetriek/models.py b/src/demetriek/models.py index b86258a1..03868fb7 100644 --- a/src/demetriek/models.py +++ b/src/demetriek/models.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from datetime import datetime from ipaddress import IPv4Address +from typing import Any from awesomeversion import AwesomeVersion from mashumaro import field_options @@ -39,21 +40,21 @@ class Audio(DataClassORJSONMixin): """Object holding the audio state of an LaMetric device.""" available: bool = True - volume: int | None - volume_limit: Range | None - volume_range: Range | None + volume: int | None = None + volume_limit: Range | None = None + volume_range: Range | None = None @dataclass(kw_only=True) class Bluetooth(DataClassORJSONMixin): """Object holding the Bluetooth state of an LaMetric device.""" - active: bool - address: str - available: bool - discoverable: bool - name: str - pairable: bool + active: bool | None = None + address: str | None = None + available: bool = False + discoverable: bool | None = None + name: str | None = None + pairable: bool | None = None @dataclass(kw_only=True) @@ -267,3 +268,27 @@ class CloudDevice(DataClassORJSONMixin): ssid: str = field(metadata=field_options(alias="wifi_ssid")) state: DeviceState updated_at: datetime + + +@dataclass(kw_only=True) +class Widget(DataClassORJSONMixin): + """Object holding LaMetric Widget information.""" + + index: int + package: str + settings: dict[str, Any] | None = None + visible: bool | None = None + + +@dataclass(kw_only=True) +class Application(DataClassORJSONMixin): + """Object holding LaMetric Application information.""" + + actions: dict[str, dict[str, Any]] | None = None + package: str + title: str | None = None + triggers: dict[str, dict[str, Any]] | None = None + vendor: str + version: str + version_code: str + widgets: dict[str, Widget] | None = None diff --git a/tests/__init__.py b/tests/__init__.py index bf2c61b4..ef876ad2 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,4 @@ -"""Asynchronous Python client for LaMetric TIME devices.""" +"""Asynchronous Python client for LaMetric devices.""" from pathlib import Path diff --git a/tests/__snapshots__/test_app.ambr b/tests/__snapshots__/test_app.ambr new file mode 100644 index 00000000..2750299a --- /dev/null +++ b/tests/__snapshots__/test_app.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_app + dict({ + 'actions': dict({ + 'clock.alarm': dict({ + 'id': 'clock.alarm', + 'title': 'Alarm', + 'type': 'native', + }), + 'clock.clockface': dict({ + 'id': 'clock.clockface', + 'title': 'Clock Face', + 'type': 'native', + }), + }), + 'package': 'com.lametric.clock', + 'title': 'Clock', + 'triggers': None, + 'vendor': 'LaMetric', + 'version': '1.0.17', + 'version_code': '23', + 'widgets': dict({ + '08b8eac21074f8f7e5a29f2855ba8060': dict({ + 'index': 0, + 'package': 'com.lametric.clock', + 'settings': None, + 'visible': True, + }), + }), + }) +# --- diff --git a/tests/__snapshots__/test_apps.ambr b/tests/__snapshots__/test_apps.ambr new file mode 100644 index 00000000..1c3d5bac --- /dev/null +++ b/tests/__snapshots__/test_apps.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_apps + dict({ + 'com.lametric.clock': dict({ + 'actions': dict({ + 'clock.alarm': dict({ + 'id': 'clock.alarm', + 'title': 'Alarm', + 'type': 'native', + }), + 'clock.clockface': dict({ + 'id': 'clock.clockface', + 'params': dict({ + 'type': dict({ + 'type': 'string', + 'values': list([ + 'weather', + 'page_a_day', + 'custom', + 'none', + ]), + }), + }), + 'title': 'Clock Face', + 'type': 'native', + }), + }), + 'package': 'com.lametric.clock', + 'title': 'Clock', + 'triggers': None, + 'vendor': 'LaMetric', + 'version': '1.0.17', + 'version_code': '23', + 'widgets': dict({ + '08b8eac21074f8f7e5a29f2855ba8060': dict({ + 'index': 0, + 'package': 'com.lametric.clock', + 'settings': None, + 'visible': True, + }), + }), + }), + 'com.lametric.radio': dict({ + 'actions': None, + 'package': 'com.lametric.radio', + 'title': 'Radio', + 'triggers': None, + 'vendor': 'LaMetric', + 'version': '1.0.2', + 'version_code': '8', + 'widgets': dict({ + '8a5d6001db1f4040b0eeb50e63f3e8af': dict({ + 'index': 1, + 'package': 'com.lametric.radio', + 'settings': None, + 'visible': True, + }), + }), + }), + }) +# --- diff --git a/tests/__snapshots__/test_widget.ambr b/tests/__snapshots__/test_widget.ambr new file mode 100644 index 00000000..b546008a --- /dev/null +++ b/tests/__snapshots__/test_widget.ambr @@ -0,0 +1,23 @@ +# serializer version: 1 +# name: test_widget + dict({ + 'index': 0, + 'package': 'com.lametric.clock', + 'settings': dict({ + 'date_format': 'DD.MM.YYYY', + 'time_format': 'h:mm:ss', + }), + 'visible': True, + }) +# --- +# name: test_widget_update + dict({ + 'index': 0, + 'package': 'com.lametric.clock', + 'settings': dict({ + 'date_format': 'MM/DD/YYYY', + 'time_format': 'HH:mm:ss', + }), + 'visible': True, + }) +# --- diff --git a/tests/fixtures/app.json b/tests/fixtures/app.json new file mode 100644 index 00000000..5d3ef60e --- /dev/null +++ b/tests/fixtures/app.json @@ -0,0 +1,26 @@ +{ + "package": "com.lametric.clock", + "vendor": "LaMetric", + "version": "1.0.17", + "version_code": "23", + "title": "Clock", + "actions": { + "clock.alarm": { + "id": "clock.alarm", + "title": "Alarm", + "type": "native" + }, + "clock.clockface": { + "id": "clock.clockface", + "title": "Clock Face", + "type": "native" + } + }, + "widgets": { + "08b8eac21074f8f7e5a29f2855ba8060": { + "index": 0, + "package": "com.lametric.clock", + "visible": true + } + } +} diff --git a/tests/fixtures/app_activate.json b/tests/fixtures/app_activate.json new file mode 100644 index 00000000..bbb4bad7 --- /dev/null +++ b/tests/fixtures/app_activate.json @@ -0,0 +1,6 @@ +{ + "success": { + "data": {}, + "path": "/api/v2/device/apps/com.lametric.clock/widgets/08b8eac21074f8f7e5a29f2855ba8060/activate" + } +} diff --git a/tests/fixtures/apps.json b/tests/fixtures/apps.json new file mode 100644 index 00000000..b00a73e9 --- /dev/null +++ b/tests/fixtures/apps.json @@ -0,0 +1,48 @@ +{ + "com.lametric.clock": { + "package": "com.lametric.clock", + "vendor": "LaMetric", + "version": "1.0.17", + "version_code": "23", + "title": "Clock", + "actions": { + "clock.alarm": { + "id": "clock.alarm", + "title": "Alarm", + "type": "native" + }, + "clock.clockface": { + "id": "clock.clockface", + "title": "Clock Face", + "type": "native", + "params": { + "type": { + "type": "string", + "values": ["weather", "page_a_day", "custom", "none"] + } + } + } + }, + "widgets": { + "08b8eac21074f8f7e5a29f2855ba8060": { + "index": 0, + "package": "com.lametric.clock", + "visible": true + } + } + }, + "com.lametric.radio": { + "package": "com.lametric.radio", + "vendor": "LaMetric", + "version": "1.0.2", + "version_code": "8", + "title": "Radio", + "widgets": { + "8a5d6001db1f4040b0eeb50e63f3e8af": { + "index": 1, + "package": "com.lametric.radio", + "visible": true + } + } + } +} diff --git a/tests/fixtures/widget.json b/tests/fixtures/widget.json new file mode 100644 index 00000000..6fa81a1d --- /dev/null +++ b/tests/fixtures/widget.json @@ -0,0 +1,9 @@ +{ + "index": 0, + "package": "com.lametric.clock", + "visible": true, + "settings": { + "time_format": "h:mm:ss", + "date_format": "DD.MM.YYYY" + } +} diff --git a/tests/fixtures/widget_action.json b/tests/fixtures/widget_action.json new file mode 100644 index 00000000..b9162a4e --- /dev/null +++ b/tests/fixtures/widget_action.json @@ -0,0 +1,6 @@ +{ + "success": { + "data": {}, + "path": "/api/v2/device/apps/com.lametric.clock/widgets/08b8eac21074f8f7e5a29f2855ba8060/actions" + } +} diff --git a/tests/fixtures/widget_update.json b/tests/fixtures/widget_update.json new file mode 100644 index 00000000..d5bee0b5 --- /dev/null +++ b/tests/fixtures/widget_update.json @@ -0,0 +1,9 @@ +{ + "index": 0, + "package": "com.lametric.clock", + "visible": true, + "settings": { + "time_format": "HH:mm:ss", + "date_format": "MM/DD/YYYY" + } +} diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 00000000..63087a0d --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,174 @@ +"""Asynchronous Python client for LaMetric devices.""" + +from dataclasses import asdict + +import aiohttp +import pytest +from aresponses import ResponsesMockServer +from syrupy.assertion import SnapshotAssertion + +from demetriek import LaMetricDevice +from demetriek.exceptions import LaMetricUnsupportedError + +from . import load_fixture + + +async def test_app( + aresponses: ResponsesMockServer, snapshot: SnapshotAssertion +) -> None: + """Test getting specific app details.""" + aresponses.add( + "127.0.0.2:4343", + "/api/v2/device/apps/com.lametric.clock", + "GET", + aresponses.Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixture("app.json"), + ), + ) + async with aiohttp.ClientSession() as session: + demetriek = LaMetricDevice(host="127.0.0.2", api_key="abc", session=session) + app = await demetriek.app(package="com.lametric.clock") + + assert app is not None + assert app.title == "Clock" + assert app.package == "com.lametric.clock" + assert app.vendor == "LaMetric" + assert asdict(app) == snapshot + + +async def test_app_unsupported(aresponses: ResponsesMockServer) -> None: + """Test app API not supported (404 response).""" + aresponses.add( + "127.0.0.2:4343", + "/api/v2/device/apps/com.lametric.clock", + "GET", + aresponses.Response( + status=404, + headers={"Content-Type": "application/json"}, + text='{"errors": [{"message": "Not found"}]}', + ), + ) + async with aiohttp.ClientSession() as session: + demetriek = LaMetricDevice(host="127.0.0.2", api_key="abc", session=session) + app = await demetriek.app(package="com.lametric.clock") + + # Should return None for unsupported API + assert app is None + + +async def test_app_next(aresponses: ResponsesMockServer) -> None: + """Test switching to the next app.""" + aresponses.add( + "127.0.0.2:4343", + "/api/v2/device/apps/next", + "PUT", + aresponses.Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixture("apps_next.json"), + ), + ) + async with aiohttp.ClientSession() as session: + demetriek = LaMetricDevice(host="127.0.0.2", api_key="abc", session=session) + await demetriek.app_next() + + +async def test_app_next_unsupported(aresponses: ResponsesMockServer) -> None: + """Test app_next not supported (404 response).""" + aresponses.add( + "127.0.0.2:4343", + "/api/v2/device/apps/next", + "PUT", + aresponses.Response( + status=404, + headers={"Content-Type": "application/json"}, + text='{"errors": [{"message": "Not found"}]}', + ), + ) + async with aiohttp.ClientSession() as session: + demetriek = LaMetricDevice(host="127.0.0.2", api_key="abc", session=session) + + # Write operations should raise exception + with pytest.raises(LaMetricUnsupportedError): + await demetriek.app_next() + + +async def test_app_previous(aresponses: ResponsesMockServer) -> None: + """Test switching to the previous app.""" + aresponses.add( + "127.0.0.2:4343", + "/api/v2/device/apps/prev", + "PUT", + aresponses.Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixture("apps_prev.json"), + ), + ) + async with aiohttp.ClientSession() as session: + demetriek = LaMetricDevice(host="127.0.0.2", api_key="abc", session=session) + await demetriek.app_previous() + + +async def test_app_previous_unsupported(aresponses: ResponsesMockServer) -> None: + """Test app_previous not supported (404 response).""" + aresponses.add( + "127.0.0.2:4343", + "/api/v2/device/apps/prev", + "PUT", + aresponses.Response( + status=404, + headers={"Content-Type": "application/json"}, + text='{"errors": [{"message": "Not found"}]}', + ), + ) + async with aiohttp.ClientSession() as session: + demetriek = LaMetricDevice(host="127.0.0.2", api_key="abc", session=session) + + # Write operations should raise exception + with pytest.raises(LaMetricUnsupportedError): + await demetriek.app_previous() + + +async def test_app_activate(aresponses: ResponsesMockServer) -> None: + """Test activating an app.""" + aresponses.add( + "127.0.0.2:4343", + "/api/v2/device/apps/com.lametric.clock/widgets/08b8eac21074f8f7e5a29f2855ba8060/activate", + "PUT", + aresponses.Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixture("app_activate.json"), + ), + ) + async with aiohttp.ClientSession() as session: + demetriek = LaMetricDevice(host="127.0.0.2", api_key="abc", session=session) + await demetriek.app_activate( + package="com.lametric.clock", widget_id="08b8eac21074f8f7e5a29f2855ba8060" + ) + + +async def test_app_activate_unsupported(aresponses: ResponsesMockServer) -> None: + """Test app_activate not supported (404 response).""" + aresponses.add( + "127.0.0.2:4343", + "/api/v2/device/apps/com.lametric.clock/widgets/08b8eac21074f8f7e5a29f2855ba8060/activate", + "PUT", + aresponses.Response( + status=404, + headers={"Content-Type": "application/json"}, + text='{"errors": [{"message": "Not found"}]}', + ), + ) + async with aiohttp.ClientSession() as session: + demetriek = LaMetricDevice(host="127.0.0.2", api_key="abc", session=session) + + # Write operations should raise exception + with pytest.raises(LaMetricUnsupportedError): + await demetriek.app_activate( + package="com.lametric.clock", + widget_id="08b8eac21074f8f7e5a29f2855ba8060", + ) diff --git a/tests/test_apps.py b/tests/test_apps.py index 7b37f1c7..e8903f58 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -1,43 +1,66 @@ -"""Asynchronous Python client for LaMetric TIME devices.""" +"""Asynchronous Python client for LaMetric devices.""" + +from dataclasses import asdict -# pylint: disable=protected-access import aiohttp from aresponses import ResponsesMockServer +from syrupy.assertion import SnapshotAssertion from demetriek import LaMetricDevice from . import load_fixture -async def test_app_next(aresponses: ResponsesMockServer) -> None: - """Test switching to the next app.""" +async def test_apps( + aresponses: ResponsesMockServer, snapshot: SnapshotAssertion +) -> None: + """Test getting all apps.""" aresponses.add( "127.0.0.2:4343", - "/api/v2/device/apps/next", - "PUT", + "/api/v2/device/apps", + "GET", aresponses.Response( status=200, headers={"Content-Type": "application/json"}, - text=load_fixture("apps_next.json"), + text=load_fixture("apps.json"), ), ) async with aiohttp.ClientSession() as session: demetriek = LaMetricDevice(host="127.0.0.2", api_key="abc", session=session) - await demetriek.app_next() + apps = await demetriek.apps() + + assert apps is not None + assert len(apps) == 2 + assert "com.lametric.clock" in apps + assert "com.lametric.radio" in apps + + clock_app = apps["com.lametric.clock"] + assert clock_app.title == "Clock" + assert clock_app.vendor == "LaMetric" + assert clock_app.version == "1.0.17" + assert clock_app.widgets is not None + assert len(clock_app.widgets) == 1 + # Verify snapshot + apps_dict = {pkg: asdict(app) for pkg, app in apps.items()} + assert apps_dict == snapshot -async def test_app_previous(aresponses: ResponsesMockServer) -> None: - """Test switching to the previous app.""" + +async def test_apps_unsupported(aresponses: ResponsesMockServer) -> None: + """Test apps API not supported (404 response).""" aresponses.add( "127.0.0.2:4343", - "/api/v2/device/apps/prev", - "PUT", + "/api/v2/device/apps", + "GET", aresponses.Response( - status=200, + status=404, headers={"Content-Type": "application/json"}, - text=load_fixture("apps_prev.json"), + text='{"errors": [{"message": "Not found"}]}', ), ) async with aiohttp.ClientSession() as session: demetriek = LaMetricDevice(host="127.0.0.2", api_key="abc", session=session) - await demetriek.app_previous() + apps = await demetriek.apps() + + # Should return None for unsupported API + assert apps is None diff --git a/tests/test_widget.py b/tests/test_widget.py new file mode 100644 index 00000000..a8f4c8ea --- /dev/null +++ b/tests/test_widget.py @@ -0,0 +1,162 @@ +"""Asynchronous Python client for LaMetric devices.""" + +from dataclasses import asdict + +import aiohttp +import pytest +from aresponses import ResponsesMockServer +from syrupy.assertion import SnapshotAssertion + +from demetriek import LaMetricDevice +from demetriek.exceptions import LaMetricUnsupportedError + +from . import load_fixture + + +async def test_widget( + aresponses: ResponsesMockServer, snapshot: SnapshotAssertion +) -> None: + """Test getting widget details.""" + aresponses.add( + "127.0.0.2:4343", + "/api/v2/device/apps/com.lametric.clock/widgets/08b8eac21074f8f7e5a29f2855ba8060", + "GET", + aresponses.Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixture("widget.json"), + ), + ) + async with aiohttp.ClientSession() as session: + demetriek = LaMetricDevice(host="127.0.0.2", api_key="abc", session=session) + widget = await demetriek.widget( + package="com.lametric.clock", widget_id="08b8eac21074f8f7e5a29f2855ba8060" + ) + + assert widget is not None + assert widget.package == "com.lametric.clock" + assert widget.index == 0 + assert widget.visible is True + assert asdict(widget) == snapshot + + +async def test_widget_unsupported(aresponses: ResponsesMockServer) -> None: + """Test widget API not supported (404 response).""" + aresponses.add( + "127.0.0.2:4343", + "/api/v2/device/apps/com.lametric.clock/widgets/08b8eac21074f8f7e5a29f2855ba8060", + "GET", + aresponses.Response( + status=404, + headers={"Content-Type": "application/json"}, + text='{"errors": [{"message": "Not found"}]}', + ), + ) + async with aiohttp.ClientSession() as session: + demetriek = LaMetricDevice(host="127.0.0.2", api_key="abc", session=session) + widget = await demetriek.widget( + package="com.lametric.clock", widget_id="08b8eac21074f8f7e5a29f2855ba8060" + ) + + # Should return None for unsupported API + assert widget is None + + +async def test_widget_action(aresponses: ResponsesMockServer) -> None: + """Test triggering a widget action.""" + aresponses.add( + "127.0.0.2:4343", + "/api/v2/device/apps/com.lametric.clock/widgets/08b8eac21074f8f7e5a29f2855ba8060/actions", + "POST", + aresponses.Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixture("widget_action.json"), + ), + ) + async with aiohttp.ClientSession() as session: + demetriek = LaMetricDevice(host="127.0.0.2", api_key="abc", session=session) + await demetriek.widget_action( + package="com.lametric.clock", + widget_id="08b8eac21074f8f7e5a29f2855ba8060", + action_id="clock.clockface", + parameters={"type": "weather"}, + activate=True, + ) + + +async def test_widget_action_unsupported(aresponses: ResponsesMockServer) -> None: + """Test widget_action not supported (404 response).""" + aresponses.add( + "127.0.0.2:4343", + "/api/v2/device/apps/com.lametric.clock/widgets/08b8eac21074f8f7e5a29f2855ba8060/actions", + "POST", + aresponses.Response( + status=404, + headers={"Content-Type": "application/json"}, + text='{"errors": [{"message": "Not found"}]}', + ), + ) + async with aiohttp.ClientSession() as session: + demetriek = LaMetricDevice(host="127.0.0.2", api_key="abc", session=session) + + # Write operations should raise exception + with pytest.raises(LaMetricUnsupportedError): + await demetriek.widget_action( + package="com.lametric.clock", + widget_id="08b8eac21074f8f7e5a29f2855ba8060", + action_id="clock.clockface", + parameters={"type": "weather"}, + ) + + +async def test_widget_update( + aresponses: ResponsesMockServer, snapshot: SnapshotAssertion +) -> None: + """Test updating widget settings.""" + aresponses.add( + "127.0.0.2:4343", + "/api/v2/device/apps/com.lametric.clock/widgets/08b8eac21074f8f7e5a29f2855ba8060", + "PUT", + aresponses.Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixture("widget_update.json"), + ), + ) + async with aiohttp.ClientSession() as session: + demetriek = LaMetricDevice(host="127.0.0.2", api_key="abc", session=session) + widget = await demetriek.widget_update( + package="com.lametric.clock", + widget_id="08b8eac21074f8f7e5a29f2855ba8060", + settings={"time_format": "HH:mm:ss", "date_format": "MM/DD/YYYY"}, + ) + + assert widget.package == "com.lametric.clock" + assert widget.settings is not None + assert widget.settings["time_format"] == "HH:mm:ss" + assert asdict(widget) == snapshot + + +async def test_widget_update_unsupported(aresponses: ResponsesMockServer) -> None: + """Test widget_update not supported (404 response).""" + aresponses.add( + "127.0.0.2:4343", + "/api/v2/device/apps/com.lametric.clock/widgets/08b8eac21074f8f7e5a29f2855ba8060", + "PUT", + aresponses.Response( + status=404, + headers={"Content-Type": "application/json"}, + text='{"errors": [{"message": "Not found"}]}', + ), + ) + async with aiohttp.ClientSession() as session: + demetriek = LaMetricDevice(host="127.0.0.2", api_key="abc", session=session) + + # Write operations should raise exception + with pytest.raises(LaMetricUnsupportedError): + await demetriek.widget_update( + package="com.lametric.clock", + widget_id="08b8eac21074f8f7e5a29f2855ba8060", + settings={"time_format": "HH:mm:ss"}, + )