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 e4aceaf..4dcb735 100644 --- a/src/enapter/cli/http/api/command.py +++ b/src/enapter/cli/http/api/command.py @@ -2,6 +2,8 @@ from enapter import cli +from .blueprint_command import BlueprintCommand +from .command_command import CommandCommand from .device_command import DeviceCommand from .site_command import SiteCommand @@ -13,8 +15,10 @@ 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 [ + BlueprintCommand, + CommandCommand, DeviceCommand, SiteCommand, ]: @@ -22,7 +26,11 @@ def register(parent: cli.Subparsers) -> None: @staticmethod async def run(args: argparse.Namespace) -> None: - match args.http_api_command: + 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/cli/http/api/device_command.py b/src/enapter/cli/http/api/device_command.py index 9742109..4daed9f 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 @@ -21,16 +20,13 @@ 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, DeviceDeleteCommand, DeviceGenerateCommunicationConfigCommand, DeviceGetCommand, - DeviceGetManifestCommand, DeviceListCommand, DeviceUpdateCommand, ]: @@ -38,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": @@ -49,8 +45,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_command.py b/src/enapter/cli/http/api/device_get_command.py index fb9519b..d993f22 100644 --- a/src/enapter/cli/http/api/device_get_command.py +++ b/src/enapter/cli/http/api/device_get_command.py @@ -14,9 +14,39 @@ 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", + ) + 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( + "-u", + "--communication", + action="store_true", + help="Expand device communication 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, + expand_properties=args.properties, + expand_connectivity=args.connectivity, + expand_communication=args.communication, + ) print(json.dumps(device.to_dto())) 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/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/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) diff --git a/src/enapter/http/api/__init__.py b/src/enapter/http/api/__init__.py index 791abca..864a834 100644 --- a/src/enapter/http/api/__init__.py +++ b/src/enapter/http/api/__init__.py @@ -2,6 +2,16 @@ from .config import Config from .errors import Error, MultiError, check_error -from . import devices, sites # isort: skip +from . import devices, sites, commands, blueprints # isort: skip -__all__ = ["Client", "Config", "devices", "sites", "Error", "MultiError", "check_error"] +__all__ = [ + "Client", + "Config", + "devices", + "blueprints", + "sites", + "commands", + "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..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 devices, sites +from enapter.http.api import blueprints, commands, devices, sites from .config import Config @@ -37,3 +37,11 @@ def devices(self) -> devices.Client: @property 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(), + } diff --git a/src/enapter/http/api/devices/__init__.py b/src/enapter/http/api/devices/__init__.py index 7316787..012ad66 100644 --- a/src/enapter/http/api/devices/__init__.py +++ b/src/enapter/http/api/devices/__init__.py @@ -2,6 +2,8 @@ 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 from .mqtt_protocol import MQTTProtocol @@ -14,6 +16,10 @@ "CommunicationConfig", "Device", "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 eff426c..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 @@ -21,26 +21,47 @@ 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, + expand_properties: bool = False, + expand_connectivity: bool = False, + expand_communication: bool = False, + ) -> Device: url = f"v3/devices/{device_id}" - response = await self._client.get(url) + 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) + response = await self._client.get(url, params={"expand": expand_string}) 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]: + 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() diff --git a/src/enapter/http/api/devices/device.py b/src/enapter/http/api/devices/device.py index e9bc860..801c3c1 100644 --- a/src/enapter/http/api/devices/device.py +++ b/src/enapter/http/api/devices/device.py @@ -3,6 +3,8 @@ 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 @@ -17,6 +19,10 @@ class Device: slug: str type: DeviceType authorized_role: AuthorizedRole + 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: @@ -29,6 +35,18 @@ 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"), + properties=dto.get("properties"), + connectivity=( + DeviceConnectivity.from_dto(dto["connectivity"]) + 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]: @@ -41,4 +59,12 @@ def to_dto(self) -> dict[str, Any]: "slug": self.slug, "type": self.type.value, "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 + ), + "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, + } 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}