From bee5b761a7ee4fe131892709013dcd6643f627a1 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 1 Dec 2025 16:52:31 +0100 Subject: [PATCH 1/9] http: api: implement expand device manifest --- src/enapter/cli/http/api/device_get_command.py | 8 +++++++- src/enapter/http/api/devices/client.py | 6 ++++-- src/enapter/http/api/devices/device.py | 3 +++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/enapter/cli/http/api/device_get_command.py b/src/enapter/cli/http/api/device_get_command.py index fb9519b..6c148e1 100644 --- a/src/enapter/cli/http/api/device_get_command.py +++ b/src/enapter/cli/http/api/device_get_command.py @@ -14,9 +14,15 @@ def register(parent: cli.Subparsers) -> None: parser.add_argument( "id", type=str, help="ID or slug of the device to get information about" ) + parser.add_argument( + "-m", + "--manifest", + action="store_true", + help="Expand device manifest information", + ) @staticmethod async def run(args: argparse.Namespace) -> None: async with http.api.Client(http.api.Config.from_env()) as client: - device = await client.devices.get(args.id) + device = await client.devices.get(args.id, expand_manifest=args.manifest) print(json.dumps(device.to_dto())) diff --git a/src/enapter/http/api/devices/client.py b/src/enapter/http/api/devices/client.py index eff426c..ad276e2 100644 --- a/src/enapter/http/api/devices/client.py +++ b/src/enapter/http/api/devices/client.py @@ -21,9 +21,11 @@ async def create_standalone(self, name: str, site_id: str | None = None) -> Devi api.check_error(response) return await self.get(device_id=response.json()["device_id"]) - async def get(self, device_id: str) -> Device: + async def get(self, device_id: str, expand_manifest: bool = False) -> Device: url = f"v3/devices/{device_id}" - response = await self._client.get(url) + expand = {"manifest": expand_manifest} + params = {"expand": ",".join(k for k, v in expand.items() if v)} + response = await self._client.get(url, params=params) api.check_error(response) return Device.from_dto(response.json()["device"]) diff --git a/src/enapter/http/api/devices/device.py b/src/enapter/http/api/devices/device.py index e9bc860..807c967 100644 --- a/src/enapter/http/api/devices/device.py +++ b/src/enapter/http/api/devices/device.py @@ -17,6 +17,7 @@ class Device: slug: str type: DeviceType authorized_role: AuthorizedRole + manifest: dict[str, Any] | None = None @classmethod def from_dto(cls, dto: dict[str, Any]) -> Self: @@ -29,6 +30,7 @@ def from_dto(cls, dto: dict[str, Any]) -> Self: slug=dto["slug"], type=DeviceType(dto["type"]), authorized_role=AuthorizedRole(dto["authorized_role"]), + manifest=dto.get("manifest"), ) def to_dto(self) -> dict[str, Any]: @@ -41,4 +43,5 @@ def to_dto(self) -> dict[str, Any]: "slug": self.slug, "type": self.type.value, "authorized_role": self.authorized_role.value, + "manifest": self.manifest, } From b7a3bc582bcff6dc642cee7813f8320bcd995b5b Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 1 Dec 2025 16:53:18 +0100 Subject: [PATCH 2/9] http: api: drop get device manifest --- src/enapter/cli/http/api/device_command.py | 4 ---- .../http/api/device_get_manifest_command.py | 20 ------------------- src/enapter/http/api/devices/client.py | 6 ------ 3 files changed, 30 deletions(-) delete mode 100644 src/enapter/cli/http/api/device_get_manifest_command.py diff --git a/src/enapter/cli/http/api/device_command.py b/src/enapter/cli/http/api/device_command.py index 9742109..5780621 100644 --- a/src/enapter/cli/http/api/device_command.py +++ b/src/enapter/cli/http/api/device_command.py @@ -9,7 +9,6 @@ DeviceGenerateCommunicationConfigCommand, ) from .device_get_command import DeviceGetCommand -from .device_get_manifest_command import DeviceGetManifestCommand from .device_list_command import DeviceListCommand from .device_update_command import DeviceUpdateCommand @@ -30,7 +29,6 @@ def register(parent: cli.Subparsers) -> None: DeviceDeleteCommand, DeviceGenerateCommunicationConfigCommand, DeviceGetCommand, - DeviceGetManifestCommand, DeviceListCommand, DeviceUpdateCommand, ]: @@ -49,8 +47,6 @@ async def run(args: argparse.Namespace) -> None: await DeviceGenerateCommunicationConfigCommand.run(args) case "get": await DeviceGetCommand.run(args) - case "get-manifest": - await DeviceGetManifestCommand.run(args) case "list": await DeviceListCommand.run(args) case "update": diff --git a/src/enapter/cli/http/api/device_get_manifest_command.py b/src/enapter/cli/http/api/device_get_manifest_command.py deleted file mode 100644 index 6b8089e..0000000 --- a/src/enapter/cli/http/api/device_get_manifest_command.py +++ /dev/null @@ -1,20 +0,0 @@ -import argparse -import json - -from enapter import cli, http - - -class DeviceGetManifestCommand(cli.Command): - - @staticmethod - def register(parent: cli.Subparsers) -> None: - parser = parent.add_parser( - "get-manifest", formatter_class=argparse.ArgumentDefaultsHelpFormatter - ) - parser.add_argument("id", help="ID or slug of the device") - - @staticmethod - async def run(args: argparse.Namespace) -> None: - async with http.api.Client(http.api.Config.from_env()) as client: - manifest = await client.devices.get_manifest(args.id) - print(json.dumps(manifest)) diff --git a/src/enapter/http/api/devices/client.py b/src/enapter/http/api/devices/client.py index ad276e2..007b39e 100644 --- a/src/enapter/http/api/devices/client.py +++ b/src/enapter/http/api/devices/client.py @@ -29,12 +29,6 @@ async def get(self, device_id: str, expand_manifest: bool = False) -> Device: api.check_error(response) return Device.from_dto(response.json()["device"]) - async def get_manifest(self, device_id: str) -> dict[str, Any]: - url = f"v3/devices/{device_id}/manifest" - response = await self._client.get(url) - api.check_error(response) - return response.json()["manifest"] - @async_.generator async def list(self) -> AsyncGenerator[Device, None]: url = "v3/devices" From c0dbb041586cb2ae5b46452f61d3f8da4644e252 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 1 Dec 2025 16:55:43 +0100 Subject: [PATCH 3/9] http: api: implement expand device properties --- src/enapter/cli/http/api/device_get_command.py | 12 +++++++++++- src/enapter/http/api/devices/client.py | 9 +++++++-- src/enapter/http/api/devices/device.py | 3 +++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/enapter/cli/http/api/device_get_command.py b/src/enapter/cli/http/api/device_get_command.py index 6c148e1..9ed015b 100644 --- a/src/enapter/cli/http/api/device_get_command.py +++ b/src/enapter/cli/http/api/device_get_command.py @@ -20,9 +20,19 @@ def register(parent: cli.Subparsers) -> None: action="store_true", help="Expand device manifest information", ) + parser.add_argument( + "-p", + "--properties", + action="store_true", + help="Expand device properties information", + ) @staticmethod async def run(args: argparse.Namespace) -> None: async with http.api.Client(http.api.Config.from_env()) as client: - device = await client.devices.get(args.id, expand_manifest=args.manifest) + device = await client.devices.get( + args.id, + expand_manifest=args.manifest, + expand_properties=args.properties, + ) print(json.dumps(device.to_dto())) diff --git a/src/enapter/http/api/devices/client.py b/src/enapter/http/api/devices/client.py index 007b39e..570056e 100644 --- a/src/enapter/http/api/devices/client.py +++ b/src/enapter/http/api/devices/client.py @@ -21,9 +21,14 @@ async def create_standalone(self, name: str, site_id: str | None = None) -> Devi api.check_error(response) return await self.get(device_id=response.json()["device_id"]) - async def get(self, device_id: str, expand_manifest: bool = False) -> Device: + async def get( + self, + device_id: str, + expand_manifest: bool = False, + expand_properties: bool = False, + ) -> Device: url = f"v3/devices/{device_id}" - expand = {"manifest": expand_manifest} + expand = {"manifest": expand_manifest, "properties": expand_properties} params = {"expand": ",".join(k for k, v in expand.items() if v)} response = await self._client.get(url, params=params) api.check_error(response) diff --git a/src/enapter/http/api/devices/device.py b/src/enapter/http/api/devices/device.py index 807c967..68663d3 100644 --- a/src/enapter/http/api/devices/device.py +++ b/src/enapter/http/api/devices/device.py @@ -18,6 +18,7 @@ class Device: type: DeviceType authorized_role: AuthorizedRole manifest: dict[str, Any] | None = None + properties: dict[str, Any] | None = None @classmethod def from_dto(cls, dto: dict[str, Any]) -> Self: @@ -31,6 +32,7 @@ def from_dto(cls, dto: dict[str, Any]) -> Self: type=DeviceType(dto["type"]), authorized_role=AuthorizedRole(dto["authorized_role"]), manifest=dto.get("manifest"), + properties=dto.get("properties"), ) def to_dto(self) -> dict[str, Any]: @@ -44,4 +46,5 @@ def to_dto(self) -> dict[str, Any]: "type": self.type.value, "authorized_role": self.authorized_role.value, "manifest": self.manifest, + "properties": self.properties, } From 66414ebcb0bc3cae9eae575d7904f4949417df7b Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 1 Dec 2025 17:04:50 +0100 Subject: [PATCH 4/9] http: api: implement expand device connectivity --- .../cli/http/api/device_get_command.py | 7 ++++++ src/enapter/http/api/devices/__init__.py | 3 +++ src/enapter/http/api/devices/client.py | 7 +++++- src/enapter/http/api/devices/device.py | 10 +++++++++ .../http/api/devices/device_connectivity.py | 22 +++++++++++++++++++ 5 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/enapter/http/api/devices/device_connectivity.py diff --git a/src/enapter/cli/http/api/device_get_command.py b/src/enapter/cli/http/api/device_get_command.py index 9ed015b..c2fb30b 100644 --- a/src/enapter/cli/http/api/device_get_command.py +++ b/src/enapter/cli/http/api/device_get_command.py @@ -26,6 +26,12 @@ def register(parent: cli.Subparsers) -> None: action="store_true", help="Expand device properties information", ) + parser.add_argument( + "-c", + "--connectivity", + action="store_true", + help="Expand device connectivity information", + ) @staticmethod async def run(args: argparse.Namespace) -> None: @@ -34,5 +40,6 @@ async def run(args: argparse.Namespace) -> None: args.id, expand_manifest=args.manifest, expand_properties=args.properties, + expand_connectivity=args.connectivity, ) print(json.dumps(device.to_dto())) diff --git a/src/enapter/http/api/devices/__init__.py b/src/enapter/http/api/devices/__init__.py index 7316787..a6e120d 100644 --- a/src/enapter/http/api/devices/__init__.py +++ b/src/enapter/http/api/devices/__init__.py @@ -2,6 +2,7 @@ from .client import Client from .communication_config import CommunicationConfig from .device import Device +from .device_connectivity import DeviceConnectivity, DeviceConnectivityStatus from .device_type import DeviceType from .mqtt_credentials import MQTTCredentials from .mqtt_protocol import MQTTProtocol @@ -14,6 +15,8 @@ "CommunicationConfig", "Device", "DeviceType", + "DeviceConnectivity", + "DeviceConnectivityStatus", "MQTTCredentials", "MQTTProtocol", "MQTTSCredentials", diff --git a/src/enapter/http/api/devices/client.py b/src/enapter/http/api/devices/client.py index 570056e..99404a1 100644 --- a/src/enapter/http/api/devices/client.py +++ b/src/enapter/http/api/devices/client.py @@ -26,9 +26,14 @@ async def get( device_id: str, expand_manifest: bool = False, expand_properties: bool = False, + expand_connectivity: bool = False, ) -> Device: url = f"v3/devices/{device_id}" - expand = {"manifest": expand_manifest, "properties": expand_properties} + expand = { + "manifest": expand_manifest, + "properties": expand_properties, + "connectivity": expand_connectivity, + } params = {"expand": ",".join(k for k, v in expand.items() if v)} response = await self._client.get(url, params=params) api.check_error(response) diff --git a/src/enapter/http/api/devices/device.py b/src/enapter/http/api/devices/device.py index 68663d3..2997dc4 100644 --- a/src/enapter/http/api/devices/device.py +++ b/src/enapter/http/api/devices/device.py @@ -3,6 +3,7 @@ from typing import Any, Self from .authorized_role import AuthorizedRole +from .device_connectivity import DeviceConnectivity from .device_type import DeviceType @@ -19,6 +20,7 @@ class Device: authorized_role: AuthorizedRole manifest: dict[str, Any] | None = None properties: dict[str, Any] | None = None + connectivity: DeviceConnectivity | None = None @classmethod def from_dto(cls, dto: dict[str, Any]) -> Self: @@ -33,6 +35,11 @@ def from_dto(cls, dto: dict[str, Any]) -> Self: authorized_role=AuthorizedRole(dto["authorized_role"]), manifest=dto.get("manifest"), properties=dto.get("properties"), + connectivity=( + DeviceConnectivity.from_dto(dto["connectivity"]) + if dto.get("connectivity") is not None + else None + ), ) def to_dto(self) -> dict[str, Any]: @@ -47,4 +54,7 @@ def to_dto(self) -> dict[str, Any]: "authorized_role": self.authorized_role.value, "manifest": self.manifest, "properties": self.properties, + "connectivity": ( + self.connectivity.to_dto() if self.connectivity is not None else None + ), } diff --git a/src/enapter/http/api/devices/device_connectivity.py b/src/enapter/http/api/devices/device_connectivity.py new file mode 100644 index 0000000..8254a6c --- /dev/null +++ b/src/enapter/http/api/devices/device_connectivity.py @@ -0,0 +1,22 @@ +import dataclasses +import enum + + +class DeviceConnectivityStatus(enum.Enum): + + UNKNOWN = "UNKNOWN" + ONLINE = "ONLINE" + OFFLINE = "OFFLINE" + + +@dataclasses.dataclass +class DeviceConnectivity: + + status: DeviceConnectivityStatus + + @classmethod + def from_dto(cls, dto: dict[str, str]) -> "DeviceConnectivity": + return cls(status=DeviceConnectivityStatus(dto.get("status", "UNKNOWN"))) + + def to_dto(self) -> dict[str, str]: + return {"status": self.status.value} From f9cc124e4f49d288ec59d3dec4c9f812fd674cea Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 1 Dec 2025 17:13:07 +0100 Subject: [PATCH 5/9] http: api: implement expand device communication info --- .../cli/http/api/device_get_command.py | 7 ++++ src/enapter/http/api/devices/__init__.py | 3 ++ src/enapter/http/api/devices/client.py | 2 + src/enapter/http/api/devices/device.py | 10 +++++ .../http/api/devices/device_communication.py | 39 +++++++++++++++++++ 5 files changed, 61 insertions(+) create mode 100644 src/enapter/http/api/devices/device_communication.py diff --git a/src/enapter/cli/http/api/device_get_command.py b/src/enapter/cli/http/api/device_get_command.py index c2fb30b..d993f22 100644 --- a/src/enapter/cli/http/api/device_get_command.py +++ b/src/enapter/cli/http/api/device_get_command.py @@ -32,6 +32,12 @@ def register(parent: cli.Subparsers) -> None: action="store_true", help="Expand device connectivity information", ) + parser.add_argument( + "-u", + "--communication", + action="store_true", + help="Expand device communication information", + ) @staticmethod async def run(args: argparse.Namespace) -> None: @@ -41,5 +47,6 @@ async def run(args: argparse.Namespace) -> None: expand_manifest=args.manifest, expand_properties=args.properties, expand_connectivity=args.connectivity, + expand_communication=args.communication, ) print(json.dumps(device.to_dto())) diff --git a/src/enapter/http/api/devices/__init__.py b/src/enapter/http/api/devices/__init__.py index a6e120d..012ad66 100644 --- a/src/enapter/http/api/devices/__init__.py +++ b/src/enapter/http/api/devices/__init__.py @@ -2,6 +2,7 @@ from .client import Client from .communication_config import CommunicationConfig from .device import Device +from .device_communication import DeviceCommunication, DeviceCommunicationType from .device_connectivity import DeviceConnectivity, DeviceConnectivityStatus from .device_type import DeviceType from .mqtt_credentials import MQTTCredentials @@ -17,6 +18,8 @@ "DeviceType", "DeviceConnectivity", "DeviceConnectivityStatus", + "DeviceCommunication", + "DeviceCommunicationType", "MQTTCredentials", "MQTTProtocol", "MQTTSCredentials", diff --git a/src/enapter/http/api/devices/client.py b/src/enapter/http/api/devices/client.py index 99404a1..a2159d6 100644 --- a/src/enapter/http/api/devices/client.py +++ b/src/enapter/http/api/devices/client.py @@ -27,12 +27,14 @@ async def get( expand_manifest: bool = False, expand_properties: bool = False, expand_connectivity: bool = False, + expand_communication: bool = False, ) -> Device: url = f"v3/devices/{device_id}" expand = { "manifest": expand_manifest, "properties": expand_properties, "connectivity": expand_connectivity, + "communication": expand_communication, } params = {"expand": ",".join(k for k, v in expand.items() if v)} response = await self._client.get(url, params=params) diff --git a/src/enapter/http/api/devices/device.py b/src/enapter/http/api/devices/device.py index 2997dc4..801c3c1 100644 --- a/src/enapter/http/api/devices/device.py +++ b/src/enapter/http/api/devices/device.py @@ -3,6 +3,7 @@ from typing import Any, Self from .authorized_role import AuthorizedRole +from .device_communication import DeviceCommunication from .device_connectivity import DeviceConnectivity from .device_type import DeviceType @@ -21,6 +22,7 @@ class Device: manifest: dict[str, Any] | None = None properties: dict[str, Any] | None = None connectivity: DeviceConnectivity | None = None + communication: DeviceCommunication | None = None @classmethod def from_dto(cls, dto: dict[str, Any]) -> Self: @@ -40,6 +42,11 @@ def from_dto(cls, dto: dict[str, Any]) -> Self: if dto.get("connectivity") is not None else None ), + communication=( + DeviceCommunication.from_dto(dto["communication"]) + if dto.get("communication") is not None + else None + ), ) def to_dto(self) -> dict[str, Any]: @@ -57,4 +64,7 @@ def to_dto(self) -> dict[str, Any]: "connectivity": ( self.connectivity.to_dto() if self.connectivity is not None else None ), + "communication": ( + self.communication.to_dto() if self.communication is not None else None + ), } diff --git a/src/enapter/http/api/devices/device_communication.py b/src/enapter/http/api/devices/device_communication.py new file mode 100644 index 0000000..31c4536 --- /dev/null +++ b/src/enapter/http/api/devices/device_communication.py @@ -0,0 +1,39 @@ +import dataclasses +import enum +from typing import Any + + +class DeviceCommunicationType(str, enum.Enum): + + MQTT_V1_PLAINTEXT = "MQTT_V1_PLAINTEXT" + MQTT_V1_TLS = "MQTT_V1_TLS" + MQTT_V1_LOCALHOST = "MQTT_V1_LOCALHOST" + UCM_LUA = "UCM_LUA" + UCM_EMBEDDED = "UCM_EMBEDDED" + LINK = "LINK" + + +@dataclasses.dataclass +class DeviceCommunication: + + type: DeviceCommunicationType + upstream_id: str | None + hardware_id: str | None + channel_id: str | None + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> "DeviceCommunication": + return cls( + type=DeviceCommunicationType(dto["type"]), + upstream_id=dto.get("upstream_id"), + hardware_id=dto.get("hardware_id"), + channel_id=dto.get("channel_id"), + ) + + def to_dto(self) -> dict[str, Any]: + return { + "type": self.type.value, + "upstream_id": self.upstream_id, + "hardware_id": self.hardware_id, + "channel_id": self.channel_id, + } From 703f26ffb5b8c130b024faa40e8cb376bf98817b Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 1 Dec 2025 17:25:43 +0100 Subject: [PATCH 6/9] http: api: add expand to list devices --- .../cli/http/api/device_list_command.py | 30 ++++++++++++++++++- src/enapter/http/api/devices/client.py | 23 ++++++++++---- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/enapter/cli/http/api/device_list_command.py b/src/enapter/cli/http/api/device_list_command.py index 7367fb6..8b468ac 100644 --- a/src/enapter/cli/http/api/device_list_command.py +++ b/src/enapter/cli/http/api/device_list_command.py @@ -18,13 +18,41 @@ def register(parent: cli.Subparsers) -> None: help="Maximum number of devices to list", default=-1, ) + parser.add_argument( + "-m", + "--manifest", + action="store_true", + help="Expand device manifest information", + ) + parser.add_argument( + "-p", + "--properties", + action="store_true", + help="Expand device properties information", + ) + parser.add_argument( + "-c", + "--connectivity", + action="store_true", + help="Expand device connectivity information", + ) + parser.add_argument( + "--communication", + action="store_true", + help="Expand device communication information", + ) @staticmethod async def run(args: argparse.Namespace) -> None: if args.limit == 0: return async with http.api.Client(http.api.Config.from_env()) as client: - async with client.devices.list() as stream: + async with client.devices.list( + expand_manifest=args.manifest, + expand_properties=args.properties, + expand_connectivity=args.connectivity, + expand_communication=args.communication, + ) as stream: count = 0 async for device in stream: print(json.dumps(device.to_dto())) diff --git a/src/enapter/http/api/devices/client.py b/src/enapter/http/api/devices/client.py index a2159d6..b940dd3 100644 --- a/src/enapter/http/api/devices/client.py +++ b/src/enapter/http/api/devices/client.py @@ -1,4 +1,4 @@ -from typing import Any, AsyncGenerator +from typing import AsyncGenerator import httpx @@ -36,19 +36,32 @@ async def get( "connectivity": expand_connectivity, "communication": expand_communication, } - params = {"expand": ",".join(k for k, v in expand.items() if v)} - response = await self._client.get(url, params=params) + expand_string = ",".join(k for k, v in expand.items() if v) + response = await self._client.get(url, params={"expand": expand_string}) api.check_error(response) return Device.from_dto(response.json()["device"]) @async_.generator - async def list(self) -> AsyncGenerator[Device, None]: + async def list( + self, + expand_manifest: bool = False, + expand_properties: bool = False, + expand_connectivity: bool = False, + expand_communication: bool = False, + ) -> AsyncGenerator[Device, None]: url = "v3/devices" + expand = { + "manifest": expand_manifest, + "properties": expand_properties, + "connectivity": expand_connectivity, + "communication": expand_communication, + } + expand_string = ",".join(k for k, v in expand.items() if v) limit = 50 offset = 0 while True: response = await self._client.get( - url, params={"limit": limit, "offset": offset} + url, params={"expand": expand_string, "limit": limit, "offset": offset} ) api.check_error(response) payload = response.json() From f7bb16e909a3196a4e0a69c662f06703f0dd5758 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Wed, 3 Dec 2025 14:44:55 +0100 Subject: [PATCH 7/9] cli: http: simplify subcommand names --- src/enapter/cli/http/api/command.py | 4 ++-- src/enapter/cli/http/api/device_command.py | 6 ++---- src/enapter/cli/http/api/site_command.py | 6 +++--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/enapter/cli/http/api/command.py b/src/enapter/cli/http/api/command.py index e4aceaf..fa30ff9 100644 --- a/src/enapter/cli/http/api/command.py +++ b/src/enapter/cli/http/api/command.py @@ -13,7 +13,7 @@ def register(parent: cli.Subparsers) -> None: parser = parent.add_parser( "api", formatter_class=argparse.ArgumentDefaultsHelpFormatter ) - subparsers = parser.add_subparsers(dest="http_api_command", required=True) + subparsers = parser.add_subparsers(dest="api_command", required=True) for command in [ DeviceCommand, SiteCommand, @@ -22,7 +22,7 @@ def register(parent: cli.Subparsers) -> None: @staticmethod async def run(args: argparse.Namespace) -> None: - match args.http_api_command: + match args.api_command: case "device": await DeviceCommand.run(args) case "site": diff --git a/src/enapter/cli/http/api/device_command.py b/src/enapter/cli/http/api/device_command.py index 5780621..4daed9f 100644 --- a/src/enapter/cli/http/api/device_command.py +++ b/src/enapter/cli/http/api/device_command.py @@ -20,9 +20,7 @@ def register(parent: cli.Subparsers) -> None: parser = parent.add_parser( "device", formatter_class=argparse.ArgumentDefaultsHelpFormatter ) - subparsers = parser.add_subparsers( - dest="http_api_device_command", required=True - ) + subparsers = parser.add_subparsers(dest="device_command", required=True) for command in [ DeviceAssignBlueprintCommand, DeviceCreateStandaloneCommand, @@ -36,7 +34,7 @@ def register(parent: cli.Subparsers) -> None: @staticmethod async def run(args: argparse.Namespace) -> None: - match args.http_api_device_command: + match args.device_command: case "assign-blueprint": await DeviceAssignBlueprintCommand.run(args) case "create-standalone": diff --git a/src/enapter/cli/http/api/site_command.py b/src/enapter/cli/http/api/site_command.py index 8fadd1c..a6d440e 100644 --- a/src/enapter/cli/http/api/site_command.py +++ b/src/enapter/cli/http/api/site_command.py @@ -16,7 +16,7 @@ def register(parent: cli.Subparsers) -> None: parser = parent.add_parser( "site", formatter_class=argparse.ArgumentDefaultsHelpFormatter ) - subparsers = parser.add_subparsers(dest="http_api_site_command", required=True) + subparsers = parser.add_subparsers(dest="site_command", required=True) for command in [ SiteCreateCommand, SiteDeleteCommand, @@ -28,7 +28,7 @@ def register(parent: cli.Subparsers) -> None: @staticmethod async def run(args: argparse.Namespace) -> None: - match args.http_api_site_command: + match args.site_command: case "create": await SiteCreateCommand.run(args) case "delete": @@ -40,4 +40,4 @@ async def run(args: argparse.Namespace) -> None: case "update": await SiteUpdateCommand.run(args) case _: - raise NotImplementedError(args.device_command) + raise NotImplementedError(args.site_command) From 12be0d5286553e333c3ab6a858df173fda9ceaf4 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Wed, 3 Dec 2025 16:30:56 +0100 Subject: [PATCH 8/9] http: api: implement basic blueprints support --- src/enapter/cli/http/api/blueprint_command.py | 31 ++++++++++++++ .../http/api/blueprint_download_command.py | 36 ++++++++++++++++ .../cli/http/api/blueprint_upload_command.py | 29 +++++++++++++ src/enapter/cli/http/api/command.py | 4 ++ src/enapter/http/api/__init__.py | 13 +++++- src/enapter/http/api/blueprints/__init__.py | 8 ++++ src/enapter/http/api/blueprints/blueprint.py | 30 ++++++++++++++ src/enapter/http/api/blueprints/client.py | 41 +++++++++++++++++++ src/enapter/http/api/client.py | 6 ++- 9 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 src/enapter/cli/http/api/blueprint_command.py create mode 100644 src/enapter/cli/http/api/blueprint_download_command.py create mode 100644 src/enapter/cli/http/api/blueprint_upload_command.py create mode 100644 src/enapter/http/api/blueprints/__init__.py create mode 100644 src/enapter/http/api/blueprints/blueprint.py create mode 100644 src/enapter/http/api/blueprints/client.py diff --git a/src/enapter/cli/http/api/blueprint_command.py b/src/enapter/cli/http/api/blueprint_command.py new file mode 100644 index 0000000..fc2c3ac --- /dev/null +++ b/src/enapter/cli/http/api/blueprint_command.py @@ -0,0 +1,31 @@ +import argparse + +from enapter import cli + +from .blueprint_download_command import BlueprintDownloadCommand +from .blueprint_upload_command import BlueprintUploadCommand + + +class BlueprintCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "blueprint", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + subparsers = parser.add_subparsers(dest="blueprint_command", required=True) + for command in [ + BlueprintDownloadCommand, + BlueprintUploadCommand, + ]: + command.register(subparsers) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + match args.blueprint_command: + case "download": + await BlueprintDownloadCommand.run(args) + case "upload": + await BlueprintUploadCommand.run(args) + case _: + raise NotImplementedError(args.command_command) diff --git a/src/enapter/cli/http/api/blueprint_download_command.py b/src/enapter/cli/http/api/blueprint_download_command.py new file mode 100644 index 0000000..7856cf6 --- /dev/null +++ b/src/enapter/cli/http/api/blueprint_download_command.py @@ -0,0 +1,36 @@ +import argparse +import logging +import pathlib + +from enapter import cli, http + +LOGGER = logging.getLogger(__name__) + + +class BlueprintDownloadCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "download", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument("id", help="ID of the blueprint to download") + parser.add_argument( + "-o", "--output", type=pathlib.Path, help="Output file path", required=True + ) + parser.add_argument( + "-v", + "--view", + choices=["original", "compiled"], + default="original", + help="Blueprint view type", + ) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + async with http.api.Client(http.api.Config.from_env()) as client: + content = await client.blueprints.download( + args.id, view=http.api.blueprints.BlueprintView(args.view.upper()) + ) + with open(args.output, "wb") as f: + f.write(content) diff --git a/src/enapter/cli/http/api/blueprint_upload_command.py b/src/enapter/cli/http/api/blueprint_upload_command.py new file mode 100644 index 0000000..289478e --- /dev/null +++ b/src/enapter/cli/http/api/blueprint_upload_command.py @@ -0,0 +1,29 @@ +import argparse +import json +import logging +import pathlib + +from enapter import cli, http + +LOGGER = logging.getLogger(__name__) + + +class BlueprintUploadCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "upload", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "path", type=pathlib.Path, help="Path to a directory or a zip file" + ) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + async with http.api.Client(http.api.Config.from_env()) as client: + if args.path.is_dir(): + blueprint = await client.blueprints.upload_directory(args.path) + else: + blueprint = await client.blueprints.upload_file(args.path) + print(json.dumps(blueprint.to_dto())) diff --git a/src/enapter/cli/http/api/command.py b/src/enapter/cli/http/api/command.py index fa30ff9..b59ac9a 100644 --- a/src/enapter/cli/http/api/command.py +++ b/src/enapter/cli/http/api/command.py @@ -2,6 +2,7 @@ from enapter import cli +from .blueprint_command import BlueprintCommand from .device_command import DeviceCommand from .site_command import SiteCommand @@ -15,6 +16,7 @@ def register(parent: cli.Subparsers) -> None: ) subparsers = parser.add_subparsers(dest="api_command", required=True) for command in [ + BlueprintCommand, DeviceCommand, SiteCommand, ]: @@ -23,6 +25,8 @@ def register(parent: cli.Subparsers) -> None: @staticmethod async def run(args: argparse.Namespace) -> None: match args.api_command: + case "blueprint": + await BlueprintCommand.run(args) case "device": await DeviceCommand.run(args) case "site": diff --git a/src/enapter/http/api/__init__.py b/src/enapter/http/api/__init__.py index 791abca..e65ed85 100644 --- a/src/enapter/http/api/__init__.py +++ b/src/enapter/http/api/__init__.py @@ -2,6 +2,15 @@ from .config import Config from .errors import Error, MultiError, check_error -from . import devices, sites # isort: skip +from . import devices, sites, blueprints # isort: skip -__all__ = ["Client", "Config", "devices", "sites", "Error", "MultiError", "check_error"] +__all__ = [ + "Client", + "Config", + "devices", + "blueprints", + "sites", + "Error", + "MultiError", + "check_error", +] diff --git a/src/enapter/http/api/blueprints/__init__.py b/src/enapter/http/api/blueprints/__init__.py new file mode 100644 index 0000000..fa5147b --- /dev/null +++ b/src/enapter/http/api/blueprints/__init__.py @@ -0,0 +1,8 @@ +from .blueprint import Blueprint, BlueprintView +from .client import Client + +__all__ = [ + "Client", + "Blueprint", + "BlueprintView", +] diff --git a/src/enapter/http/api/blueprints/blueprint.py b/src/enapter/http/api/blueprints/blueprint.py new file mode 100644 index 0000000..7546cc7 --- /dev/null +++ b/src/enapter/http/api/blueprints/blueprint.py @@ -0,0 +1,30 @@ +import dataclasses +import datetime +import enum +from typing import Any, Self + + +class BlueprintView(enum.Enum): + + ORIGINAL = "ORIGINAL" + COMPILED = "COMPILED" + + +@dataclasses.dataclass +class Blueprint: + + id: str + created_at: datetime.datetime + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + return cls( + id=dto["id"], + created_at=datetime.datetime.fromisoformat(dto["created_at"]), + ) + + def to_dto(self) -> dict[str, Any]: + return { + "id": self.id, + "created_at": self.created_at.isoformat(), + } diff --git a/src/enapter/http/api/blueprints/client.py b/src/enapter/http/api/blueprints/client.py new file mode 100644 index 0000000..cbcae14 --- /dev/null +++ b/src/enapter/http/api/blueprints/client.py @@ -0,0 +1,41 @@ +import io +import pathlib +import zipfile + +import httpx + +from enapter.http import api + +from .blueprint import Blueprint, BlueprintView + + +class Client: + + def __init__(self, client: httpx.AsyncClient) -> None: + self._client = client + + async def upload_file(self, path: pathlib.Path) -> Blueprint: + with path.open("rb") as file: + data = file.read() + return await self.upload(data) + + async def upload_directory(self, path: pathlib.Path) -> Blueprint: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: + for file_path in path.rglob("*"): + zip_file.write(file_path, arcname=file_path.relative_to(path)) + return await self.upload(buffer.getvalue()) + + async def upload(self, data: bytes) -> Blueprint: + url = "v3/blueprints/upload" + response = await self._client.post(url, content=data) + api.check_error(response) + return Blueprint.from_dto(response.json()["blueprint"]) + + async def download( + self, blueprint_id: str, view: BlueprintView = BlueprintView.ORIGINAL + ) -> bytes: + url = f"v3/blueprints/{blueprint_id}/zip" + response = await self._client.get(url, params={"view": view.value}) + api.check_error(response) + return response.content diff --git a/src/enapter/http/api/client.py b/src/enapter/http/api/client.py index 53d8005..27cf981 100644 --- a/src/enapter/http/api/client.py +++ b/src/enapter/http/api/client.py @@ -2,7 +2,7 @@ import httpx -from enapter.http.api import devices, sites +from enapter.http.api import blueprints, devices, sites from .config import Config @@ -37,3 +37,7 @@ def devices(self) -> devices.Client: @property def sites(self) -> sites.Client: return sites.Client(client=self._client) + + @property + def blueprints(self) -> blueprints.Client: + return blueprints.Client(client=self._client) From e8a8d069620976029d8f7d325b93edab7c2e57e4 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Thu, 4 Dec 2025 09:32:33 +0100 Subject: [PATCH 9/9] http: api: implement commands --- src/enapter/cli/http/api/command.py | 4 + src/enapter/cli/http/api/command_arguments.py | 11 +++ src/enapter/cli/http/api/command_command.py | 39 +++++++++ .../api/command_create_execution_command.py | 39 +++++++++ .../cli/http/api/command_execute_command.py | 48 +++++++++++ .../http/api/command_get_execution_command.py | 41 ++++++++++ .../api/command_list_executions_command.py | 49 ++++++++++++ src/enapter/http/api/__init__.py | 3 +- src/enapter/http/api/client.py | 6 +- src/enapter/http/api/commands/__init__.py | 14 ++++ src/enapter/http/api/commands/client.py | 79 +++++++++++++++++++ src/enapter/http/api/commands/execution.py | 64 +++++++++++++++ src/enapter/http/api/commands/request.py | 25 ++++++ src/enapter/http/api/commands/response.py | 35 ++++++++ 14 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 src/enapter/cli/http/api/command_arguments.py create mode 100644 src/enapter/cli/http/api/command_command.py create mode 100644 src/enapter/cli/http/api/command_create_execution_command.py create mode 100644 src/enapter/cli/http/api/command_execute_command.py create mode 100644 src/enapter/cli/http/api/command_get_execution_command.py create mode 100644 src/enapter/cli/http/api/command_list_executions_command.py create mode 100644 src/enapter/http/api/commands/__init__.py create mode 100644 src/enapter/http/api/commands/client.py create mode 100644 src/enapter/http/api/commands/execution.py create mode 100644 src/enapter/http/api/commands/request.py create mode 100644 src/enapter/http/api/commands/response.py diff --git a/src/enapter/cli/http/api/command.py b/src/enapter/cli/http/api/command.py index b59ac9a..4dcb735 100644 --- a/src/enapter/cli/http/api/command.py +++ b/src/enapter/cli/http/api/command.py @@ -3,6 +3,7 @@ from enapter import cli from .blueprint_command import BlueprintCommand +from .command_command import CommandCommand from .device_command import DeviceCommand from .site_command import SiteCommand @@ -17,6 +18,7 @@ def register(parent: cli.Subparsers) -> None: subparsers = parser.add_subparsers(dest="api_command", required=True) for command in [ BlueprintCommand, + CommandCommand, DeviceCommand, SiteCommand, ]: @@ -27,6 +29,8 @@ async def run(args: argparse.Namespace) -> None: match args.api_command: case "blueprint": await BlueprintCommand.run(args) + case "command": + await CommandCommand.run(args) case "device": await DeviceCommand.run(args) case "site": diff --git a/src/enapter/cli/http/api/command_arguments.py b/src/enapter/cli/http/api/command_arguments.py new file mode 100644 index 0000000..1ab3c8c --- /dev/null +++ b/src/enapter/cli/http/api/command_arguments.py @@ -0,0 +1,11 @@ +import argparse +import json + + +def parse_command_arguments(arguments_string: str | None) -> dict: + if arguments_string is None: + return {} + try: + return json.loads(arguments_string) + except json.JSONDecodeError as e: + raise argparse.ArgumentTypeError(f"Decode JSON: {e.msg}") diff --git a/src/enapter/cli/http/api/command_command.py b/src/enapter/cli/http/api/command_command.py new file mode 100644 index 0000000..dfd9d63 --- /dev/null +++ b/src/enapter/cli/http/api/command_command.py @@ -0,0 +1,39 @@ +import argparse + +from enapter import cli + +from .command_create_execution_command import CommandCreateExecutionCommand +from .command_execute_command import CommandExecuteCommand +from .command_get_execution_command import CommandGetExecutionCommand +from .command_list_executions_command import CommandListExecutionsCommand + + +class CommandCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "command", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + subparsers = parser.add_subparsers(dest="command_command", required=True) + for command in [ + CommandCreateExecutionCommand, + CommandExecuteCommand, + CommandGetExecutionCommand, + CommandListExecutionsCommand, + ]: + command.register(subparsers) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + match args.command_command: + case "create-execution": + await CommandCreateExecutionCommand.run(args) + case "execute": + await CommandExecuteCommand.run(args) + case "get-execution": + await CommandGetExecutionCommand.run(args) + case "list-executions": + await CommandListExecutionsCommand.run(args) + case _: + raise NotImplementedError(args.command_command) diff --git a/src/enapter/cli/http/api/command_create_execution_command.py b/src/enapter/cli/http/api/command_create_execution_command.py new file mode 100644 index 0000000..01e8f09 --- /dev/null +++ b/src/enapter/cli/http/api/command_create_execution_command.py @@ -0,0 +1,39 @@ +import argparse +import json +import logging + +from enapter import cli, http + +from .command_arguments import parse_command_arguments + +LOGGER = logging.getLogger(__name__) + + +class CommandCreateExecutionCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "create-execution", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "-d", + "--device-id", + required=True, + help="ID or slug of the device to execute the command on", + ) + parser.add_argument( + "-a", + "--arguments", + type=parse_command_arguments, + help="JSON string of arguments to pass to the command", + ) + parser.add_argument("name", help="Name of the command to execute") + + @staticmethod + async def run(args: argparse.Namespace) -> None: + async with http.api.Client(http.api.Config.from_env()) as client: + execution = await client.commands.create_execution( + device_id=args.device_id, name=args.name, arguments=args.arguments + ) + print(json.dumps(execution.to_dto())) diff --git a/src/enapter/cli/http/api/command_execute_command.py b/src/enapter/cli/http/api/command_execute_command.py new file mode 100644 index 0000000..b423eb3 --- /dev/null +++ b/src/enapter/cli/http/api/command_execute_command.py @@ -0,0 +1,48 @@ +import argparse +import json +import logging + +from enapter import cli, http + +from .command_arguments import parse_command_arguments + +LOGGER = logging.getLogger(__name__) + + +class CommandExecuteCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "execute", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "-d", + "--device-id", + required=True, + help="ID or slug of the device to execute the command on", + ) + parser.add_argument( + "-a", + "--arguments", + type=parse_command_arguments, + help="JSON string of arguments to pass to the command", + ) + parser.add_argument( + "-l", + "--log", + action="store_true", + help="Expand command execution log in the output", + ) + parser.add_argument("name", help="Name of the command to execute") + + @staticmethod + async def run(args: argparse.Namespace) -> None: + async with http.api.Client(http.api.Config.from_env()) as client: + execution = await client.commands.execute( + device_id=args.device_id, + name=args.name, + arguments=args.arguments, + expand_log=args.log, + ) + print(json.dumps(execution.to_dto())) diff --git a/src/enapter/cli/http/api/command_get_execution_command.py b/src/enapter/cli/http/api/command_get_execution_command.py new file mode 100644 index 0000000..186a3a6 --- /dev/null +++ b/src/enapter/cli/http/api/command_get_execution_command.py @@ -0,0 +1,41 @@ +import argparse +import json +import logging + +from enapter import cli, http + +LOGGER = logging.getLogger(__name__) + + +class CommandGetExecutionCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "get-execution", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "-d", + "--device-id", + required=True, + help="ID or slug of the device to get the command execution of", + ) + parser.add_argument( + "-l", + "--log", + action="store_true", + help="Expand command execution log in the output", + ) + parser.add_argument( + "execution_id", help="ID of the command execution to retrieve" + ) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + async with http.api.Client(http.api.Config.from_env()) as client: + execution = await client.commands.get_execution( + device_id=args.device_id, + execution_id=args.execution_id, + expand_log=args.log, + ) + print(json.dumps(execution.to_dto())) diff --git a/src/enapter/cli/http/api/command_list_executions_command.py b/src/enapter/cli/http/api/command_list_executions_command.py new file mode 100644 index 0000000..b6bcfef --- /dev/null +++ b/src/enapter/cli/http/api/command_list_executions_command.py @@ -0,0 +1,49 @@ +import argparse +import json + +from enapter import cli, http + + +class CommandListExecutionsCommand(cli.Command): + + @staticmethod + def register(parent: cli.Subparsers) -> None: + parser = parent.add_parser( + "list-executions", formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "-l", + "--limit", + type=int, + help="Maximum number of command executions to list", + default=-1, + ) + parser.add_argument( + "-o", + "--order", + choices=["created_at_asc", "created_at_desc"], + help="Order of the listed command executions", + default="created_at_asc", + ) + parser.add_argument( + "-d", + "--device-id", + help="ID or slug of the device to list command executions for", + required=True, + ) + + @staticmethod + async def run(args: argparse.Namespace) -> None: + if args.limit == 0: + return + async with http.api.Client(http.api.Config.from_env()) as client: + async with client.commands.list_executions( + device_id=args.device_id, + order=http.api.commands.ListExecutionsOrder(args.order.upper()), + ) as stream: + count = 0 + async for device in stream: + print(json.dumps(device.to_dto())) + count += 1 + if args.limit > 0 and count == args.limit: + break diff --git a/src/enapter/http/api/__init__.py b/src/enapter/http/api/__init__.py index e65ed85..864a834 100644 --- a/src/enapter/http/api/__init__.py +++ b/src/enapter/http/api/__init__.py @@ -2,7 +2,7 @@ from .config import Config from .errors import Error, MultiError, check_error -from . import devices, sites, blueprints # isort: skip +from . import devices, sites, commands, blueprints # isort: skip __all__ = [ "Client", @@ -10,6 +10,7 @@ "devices", "blueprints", "sites", + "commands", "Error", "MultiError", "check_error", diff --git a/src/enapter/http/api/client.py b/src/enapter/http/api/client.py index 27cf981..19ce81e 100644 --- a/src/enapter/http/api/client.py +++ b/src/enapter/http/api/client.py @@ -2,7 +2,7 @@ import httpx -from enapter.http.api import blueprints, devices, sites +from enapter.http.api import blueprints, commands, devices, sites from .config import Config @@ -38,6 +38,10 @@ def devices(self) -> devices.Client: def sites(self) -> sites.Client: return sites.Client(client=self._client) + @property + def commands(self) -> commands.Client: + return commands.Client(client=self._client) + @property def blueprints(self) -> blueprints.Client: return blueprints.Client(client=self._client) diff --git a/src/enapter/http/api/commands/__init__.py b/src/enapter/http/api/commands/__init__.py new file mode 100644 index 0000000..dfee149 --- /dev/null +++ b/src/enapter/http/api/commands/__init__.py @@ -0,0 +1,14 @@ +from .client import Client +from .execution import Execution, ExecutionState, ListExecutionsOrder +from .request import Request +from .response import Response, ResponseState + +__all__ = [ + "Client", + "Execution", + "ExecutionState", + "ListExecutionsOrder", + "Request", + "Response", + "ResponseState", +] diff --git a/src/enapter/http/api/commands/client.py b/src/enapter/http/api/commands/client.py new file mode 100644 index 0000000..0b2026c --- /dev/null +++ b/src/enapter/http/api/commands/client.py @@ -0,0 +1,79 @@ +from typing import AsyncGenerator + +import httpx + +from enapter import async_ +from enapter.http import api + +from .execution import Execution, ListExecutionsOrder + + +class Client: + + def __init__(self, client: httpx.AsyncClient) -> None: + self._client = client + + async def get_execution( + self, device_id: str, execution_id: str, expand_log: bool = False + ) -> Execution: + url = f"v3/devices/{device_id}/command_executions/{execution_id}" + expand = {"log": expand_log} + expand_string = ",".join(k for k, v in expand.items() if v) + response = await self._client.get(url, params={"expand": expand_string}) + api.check_error(response) + return Execution.from_dto(response.json()["execution"]) + + @async_.generator + async def list_executions( + self, + device_id: str, + order: ListExecutionsOrder = ListExecutionsOrder.CREATED_AT_ASC, + ) -> AsyncGenerator[Execution, None]: + url = f"v3/devices/{device_id}/command_executions" + limit = 50 + offset = 0 + while True: + response = await self._client.get( + url, params={"order": order.value, "limit": limit, "offset": offset} + ) + api.check_error(response) + payload = response.json() + if not payload["executions"]: + return + for dto in payload["executions"]: + yield Execution.from_dto(dto) + offset += limit + + async def execute( + self, + device_id: str, + name: str, + arguments: dict | None = None, + expand_log: bool = False, + ) -> Execution: + url = f"v3/devices/{device_id}/execute_command" + expand = {"log": expand_log} + expand_string = ",".join(k for k, v in expand.items() if v) + if arguments is None: + arguments = {} + response = await self._client.post( + url, + params={"expand": expand_string}, + json={"name": name, "arguments": arguments}, + ) + api.check_error(response) + return Execution.from_dto(response.json()["execution"]) + + async def create_execution( + self, device_id: str, name: str, arguments: dict | None = None + ) -> Execution: + url = f"v3/devices/{device_id}/command_executions" + if arguments is None: + arguments = {} + response = await self._client.post( + url, json={"name": name, "arguments": arguments} + ) + api.check_error(response) + return await self.get_execution( + device_id=device_id, execution_id=response.json()["execution_id"] + ) diff --git a/src/enapter/http/api/commands/execution.py b/src/enapter/http/api/commands/execution.py new file mode 100644 index 0000000..e27bd43 --- /dev/null +++ b/src/enapter/http/api/commands/execution.py @@ -0,0 +1,64 @@ +import dataclasses +import datetime +import enum +from typing import Any, Self + +from .request import Request +from .response import Response + + +class ListExecutionsOrder(enum.Enum): + + CREATED_AT_ASC = "CREATED_AT_ASC" + CREATED_AT_DESC = "CREATED_AT_DESC" + + +class ExecutionState(enum.Enum): + + NEW = "NEW" + IN_PROGRESS = "IN_PROGRESS" + SUCCESS = "SUCCESS" + ERROR = "ERROR" + TIMEOUT = "TIMEOUT" + + +@dataclasses.dataclass +class Execution: + + id: str + state: ExecutionState + created_at: datetime.datetime + request: Request + response: Response | None + log: list[Response] | None + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + return cls( + id=dto["id"], + state=ExecutionState(dto["state"]), + created_at=datetime.datetime.fromisoformat(dto["created_at"]), + request=Request.from_dto(dto["request"]), + response=( + Response.from_dto(dto["response"]) + if dto.get("response") is not None + else None + ), + log=( + [Response.from_dto(item) for item in dto["log"]] + if dto.get("log") is not None + else None + ), + ) + + def to_dto(self) -> dict[str, Any]: + return { + "id": self.id, + "state": self.state.value, + "created_at": self.created_at.isoformat(), + "request": self.request.to_dto(), + "response": self.response.to_dto() if self.response is not None else None, + "log": ( + [item.to_dto() for item in self.log] if self.log is not None else None + ), + } diff --git a/src/enapter/http/api/commands/request.py b/src/enapter/http/api/commands/request.py new file mode 100644 index 0000000..2a8ac64 --- /dev/null +++ b/src/enapter/http/api/commands/request.py @@ -0,0 +1,25 @@ +import dataclasses +from typing import Any, Self + + +@dataclasses.dataclass +class Request: + + name: str + arguments: dict[str, Any] + manifest_name: str | None = None + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + return cls( + name=dto["name"], + arguments=dto["arguments"], + manifest_name=dto.get("manifest_name"), + ) + + def to_dto(self) -> dict[str, Any]: + return { + "name": self.name, + "arguments": self.arguments, + "manifest_name": self.manifest_name, + } diff --git a/src/enapter/http/api/commands/response.py b/src/enapter/http/api/commands/response.py new file mode 100644 index 0000000..bc4aafa --- /dev/null +++ b/src/enapter/http/api/commands/response.py @@ -0,0 +1,35 @@ +import dataclasses +import datetime +import enum +from typing import Any, Self + + +class ResponseState(enum.Enum): + + STARTED = "STARTED" + IN_PROGRESS = "IN_PROGRESS" + SUCCEEDED = "SUCCEEDED" + ERROR = "ERROR" + + +@dataclasses.dataclass +class Response: + + state: ResponseState + payload: dict[str, Any] + received_at: datetime.datetime + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + return cls( + state=ResponseState(dto["state"]), + payload=dto.get("payload", {}), + received_at=datetime.datetime.fromisoformat(dto["received_at"]), + ) + + def to_dto(self) -> dict[str, Any]: + return { + "state": self.state.value, + "payload": self.payload, + "received_at": self.received_at.isoformat(), + }