diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 824e20b..746be02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python: ["3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout uses: actions/checkout@v2 diff --git a/.isort.cfg b/.isort.cfg index 1bf93d8..8525d85 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,2 +1,3 @@ [settings] known_first_party=enapter +profile=black diff --git a/Makefile b/Makefile index 34a0591..1168b5c 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,9 @@ lint-pyflakes: .PHONY: lint-mypy lint-mypy: - pipenv run mypy enapter + pipenv run mypy setup.py + pipenv run mypy tests + pipenv run mypy src/enapter .PHONY: test test: run-unit-tests run-integration-tests @@ -69,7 +71,7 @@ bump-version: ifndef V $(error V is not defined) endif - sed -E -i 's/__version__ = "[0-9]+.[0-9]+.[0-9]+"/__version__ = "$(V)"/g' enapter/__init__.py + sed -E -i 's/__version__ = "[0-9]+.[0-9]+.[0-9]+"/__version__ = "$(V)"/g' src/enapter/__init__.py grep -E --files-with-matches --recursive 'enapter==[0-9]+.[0-9]+.[0-9]+' examples \ | xargs -n 1 sed -E -i 's/enapter==[0-9]+.[0-9]+.[0-9]+/enapter==$(V)/g' diff --git a/Pipfile b/Pipfile index 0145ddc..f799422 100644 --- a/Pipfile +++ b/Pipfile @@ -18,3 +18,5 @@ pytest-asyncio = "*" pytest-cov = "*" setuptools = "*" twine = "*" +types-docker = "*" +types-setuptools = "*" diff --git a/README.md b/README.md index 3fc438e..f81ab3c 100644 --- a/README.md +++ b/README.md @@ -6,89 +6,48 @@ Enapter software development kit for Python. +## Features + +- [Standalone + Devices](https://v3.developers.enapter.com/docs/standalone/introduction) + framework. +- [MQTT + API](https://v3.developers.enapter.com/reference/device_integration/mqtt_api/) + client. +- [HTTP API](https://v3.developers.enapter.com/reference/http/intro) client. + ## Installation -This project uses [semantic versioning](https://semver.org/). +> [!IMPORTANT] +> Make sure you are using Python 3.11+. -The API is still under development and may change at any time. It is -recommended to pin the version during installation. +> [!WARNING] +> The API is still under development and may change at any time. It is +> recommended to pin the version during installation. -Latest from PyPI: +From PyPI: ```bash -pip install enapter==0.11.3 +pip install enapter==0.12.0 ``` ## Usage -Checkout [examples](examples). - -## Implementing your own VUCM - -### Device Telemetry and Properties - -Every method of `enapter.vucm.Device` subclass decorated with -`enapter.vucm.device_task` decorator is considered a _device task_. When such a -device is started, all of its tasks are started as well. Device tasks are -started in random order and are being executed concurrently in the background. -If a device task returns or raises an exception, device routine is terminated. -A typical use of the task is to run a periodic job to send device telemetry and -properties. - -In order to send telemetry and properties define two corresponding device -tasks. It is advised (but is not obligatory) to send telemetry every **1 -second** and to send properties every **10 seconds**. - -Examples: - -- [wttr-in](examples/vucm/wttr-in) - -### Device Command Handlers - -Every method of `enapter.vucm.Device` subclass decorated with -`enapter.vucm.device_command` is considered a _device command handler_. Device -command handlers receive the same arguments as described in device Blueprint -manifest and can optionally return a payload as `enapter.types.JSON`. - -In order to handle device commands define corresponding device command -handlers. - -Examples: +Check out examples: -- [zhimi-fan-za5](examples/vucm/zhimi-fan-za5) +- [Standalone Devices](examples/standalone) +- [MQTT API](examples/mqtt) +- [HTTP API](examples/http) -### Device Alerts +They provide a good overview of available features and should give you enough +power to get started. -Device alerts are stored in `self.alerts`. It is a usual Python `set`, so you -can add an alert using `alerts.add`, remove an alert `alerts.remove` and clear -alerts using `alerts.clear`. +> [!TIP] +> Don't hesitate to peek into the source code - it is supposed to be easy to +> follow. -Alerts are sent only as part of telemetry, so in order to report device alert, -use `send_telemetry` with any payload. +## Help -## Running your own VUCM via Docker - -A simple Dockerfile can be: - -``` -FROM python:3.10-alpine3.16 - -WORKDIR /app - -RUN python -m venv .venv -COPY requirements.txt requirements.txt -RUN .venv/bin/pip install -r requirements.txt - -COPY script.py script.py - -CMD [".venv/bin/python", "script.py"] -``` - -:information_source: If you are using [Enapter -Gateway](https://handbook.enapter.com/software/gateway_software/) and running -Linux, you should connect your containers to `host` network -:information_source:: - -```bash -docker run --network host ... -``` +If you feel lost or confused, reach us in +[Discord](https://discord.com/invite/TCaEZs3qpe) or just [file a +bug](https://github.com/Enapter/python-sdk/issues/new). We'd be glad to help. diff --git a/enapter/__init__.py b/enapter/__init__.py deleted file mode 100644 index c0f4ae6..0000000 --- a/enapter/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -__version__ = "0.11.4" - -from . import async_, log, mdns, mqtt, types, vucm - -__all__ = [ - "__version__", - "async_", - "log", - "mdns", - "mqtt", - "types", - "vucm", -] diff --git a/enapter/async_/routine.py b/enapter/async_/routine.py deleted file mode 100644 index 4540596..0000000 --- a/enapter/async_/routine.py +++ /dev/null @@ -1,70 +0,0 @@ -import abc -import asyncio -import contextlib - - -class Routine(abc.ABC): - @abc.abstractmethod - async def _run(self) -> None: - raise NotImplementedError # pragma: no cover - - async def __aenter__(self): - await self.start() - return self - - async def __aexit__(self, *_) -> None: - await self.stop() - - def task(self) -> asyncio.Task: - return self._task - - async def start(self, cancel_parent_task_on_exception: bool = True) -> None: - self._started = asyncio.Event() - self._stack = contextlib.AsyncExitStack() - - self._parent_task = asyncio.current_task() - self._cancel_parent_task_on_exception = cancel_parent_task_on_exception - - self._task = asyncio.create_task(self.__run()) - wait_started_task = asyncio.create_task(self._started.wait()) - - done, _ = await asyncio.wait( - {self._task, wait_started_task}, - return_when=asyncio.FIRST_COMPLETED, - ) - - if wait_started_task not in done: - wait_started_task.cancel() - try: - await wait_started_task - except asyncio.CancelledError: - pass - - if self._task in done: - self._task.result() - - async def stop(self) -> None: - self.cancel() - await self.join() - - def cancel(self) -> None: - self._task.cancel() - - async def join(self) -> None: - if self._task.done(): - self._task.result() - else: - await self._task - - async def __run(self) -> None: - try: - await self._run() - except asyncio.CancelledError: - pass - except: - if self._started.is_set() and self._cancel_parent_task_on_exception: - assert self._parent_task is not None - self._parent_task.cancel() - raise - finally: - await self._stack.aclose() diff --git a/enapter/mqtt/api/__init__.py b/enapter/mqtt/api/__init__.py deleted file mode 100644 index ef3169d..0000000 --- a/enapter/mqtt/api/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from .command import CommandRequest, CommandResponse, CommandState -from .device_channel import DeviceChannel -from .log_severity import LogSeverity - -__all__ = [ - "CommandRequest", - "CommandResponse", - "CommandState", - "DeviceChannel", - "LogSeverity", -] diff --git a/enapter/mqtt/api/command.py b/enapter/mqtt/api/command.py deleted file mode 100644 index f9a7de2..0000000 --- a/enapter/mqtt/api/command.py +++ /dev/null @@ -1,51 +0,0 @@ -import enum -import json -from typing import Any, Dict, Optional, Union - - -class CommandState(enum.Enum): - COMPLETED = "completed" - ERROR = "error" - - -class CommandRequest: - @classmethod - def unmarshal_json(cls, data: Union[str, bytes]) -> "CommandRequest": - req = json.loads(data) - return cls(id_=req["id"], name=req["name"], args=req.get("arguments")) - - def __init__(self, id_: str, name: str, args: Optional[Dict[str, Any]] = None): - self.id = id_ - self.name = name - - if args is None: - args = {} - self.args = args - - def new_response(self, *args, **kwargs) -> "CommandResponse": - return CommandResponse(self.id, *args, **kwargs) - - -class CommandResponse: - def __init__( - self, - id_: str, - state: Union[str, CommandState], - payload: Optional[Union[Dict[str, Any], str]] = None, - ) -> None: - self.id = id_ - - if not isinstance(state, CommandState): - state = CommandState(state) - self.state = state - - if not isinstance(payload, dict): - payload = {"message": payload} - self.payload = payload - - def json(self) -> Dict[str, Any]: - json_object: Dict[str, Any] = {"id": self.id, "state": self.state.value} - if self.payload is not None: - json_object["payload"] = self.payload - - return json_object diff --git a/enapter/mqtt/api/device_channel.py b/enapter/mqtt/api/device_channel.py deleted file mode 100644 index 3c6fd27..0000000 --- a/enapter/mqtt/api/device_channel.py +++ /dev/null @@ -1,84 +0,0 @@ -import json -import logging -import time -from typing import Any, AsyncContextManager, AsyncGenerator, Dict - -import aiomqtt # type: ignore - -import enapter - -from ..client import Client -from .command import CommandRequest, CommandResponse -from .log_severity import LogSeverity - -LOGGER = logging.getLogger(__name__) - - -class DeviceChannel: - def __init__(self, client: Client, hardware_id: str, channel_id: str) -> None: - self._client = client - self._logger = self._new_logger(hardware_id, channel_id) - self._hardware_id = hardware_id - self._channel_id = channel_id - - @property - def hardware_id(self) -> str: - return self._hardware_id - - @property - def channel_id(self) -> str: - return self._channel_id - - @staticmethod - def _new_logger(hardware_id, channel_id) -> logging.LoggerAdapter: - extra = {"hardware_id": hardware_id, "channel_id": channel_id} - return logging.LoggerAdapter(LOGGER, extra=extra) - - @enapter.async_.generator - async def subscribe_to_command_requests( - self, - ) -> AsyncGenerator[CommandRequest, None]: - async with self._subscribe("v1/command/requests") as messages: - async for msg in messages: - assert isinstance(msg.payload, str) or isinstance(msg.payload, bytes) - yield CommandRequest.unmarshal_json(msg.payload) - - async def publish_command_response(self, resp: CommandResponse) -> None: - await self._publish_json("v1/command/responses", resp.json()) - - async def publish_telemetry(self, telemetry: Dict[str, Any], **kwargs) -> None: - await self._publish_json("v1/telemetry", telemetry, **kwargs) - - async def publish_properties(self, properties: Dict[str, Any], **kwargs) -> None: - await self._publish_json("v1/register", properties, **kwargs) - - async def publish_logs( - self, msg: str, severity: LogSeverity, persist: bool = False, **kwargs - ) -> None: - logs = { - "message": msg, - "severity": severity.value, - "persist": persist, - } - await self._publish_json("v3/logs", logs, **kwargs) - - def _subscribe( - self, path: str - ) -> AsyncContextManager[AsyncGenerator[aiomqtt.Message, None]]: - topic = f"v1/to/{self._hardware_id}/{self._channel_id}/{path}" - return self._client.subscribe(topic) - - async def _publish_json( - self, path: str, json_object: Dict[str, Any], **kwargs - ) -> None: - if "timestamp" not in json_object: - json_object["timestamp"] = int(time.time()) - payload = json.dumps(json_object) - await self._publish(path, payload, **kwargs) - - async def _publish(self, path: str, payload: str, **kwargs) -> None: - topic = f"v1/from/{self._hardware_id}/{self._channel_id}/{path}" - try: - await self._client.publish(topic, payload, **kwargs) - except Exception as e: - self._logger.error("failed to publish %s: %r", path, e) diff --git a/enapter/mqtt/config.py b/enapter/mqtt/config.py deleted file mode 100644 index d9eede1..0000000 --- a/enapter/mqtt/config.py +++ /dev/null @@ -1,69 +0,0 @@ -import os -from typing import MutableMapping, Optional - - -class TLSConfig: - - @classmethod - def from_env( - cls, prefix: str = "ENAPTER_", env: MutableMapping[str, str] = os.environ - ) -> Optional["TLSConfig"]: - secret_key = env.get(prefix + "MQTT_TLS_SECRET_KEY") - cert = env.get(prefix + "MQTT_TLS_CERT") - ca_cert = env.get(prefix + "MQTT_TLS_CA_CERT") - - nothing_defined = {secret_key, cert, ca_cert} == {None} - if nothing_defined: - return None - - if secret_key is None: - raise KeyError(prefix + "MQTT_TLS_SECRET_KEY") - if cert is None: - raise KeyError(prefix + "MQTT_TLS_CERT") - if ca_cert is None: - raise KeyError(prefix + "MQTT_TLS_CA_CERT") - - def pem(value: str) -> str: - return value.replace("\\n", "\n") - - return cls(secret_key=pem(secret_key), cert=pem(cert), ca_cert=pem(ca_cert)) - - def __init__(self, secret_key: str, cert: str, ca_cert: str) -> None: - self.secret_key = secret_key - self.cert = cert - self.ca_cert = ca_cert - - -class Config: - @classmethod - def from_env( - cls, prefix: str = "ENAPTER_", env: MutableMapping[str, str] = os.environ - ) -> "Config": - return cls( - host=env[prefix + "MQTT_HOST"], - port=int(env[prefix + "MQTT_PORT"]), - user=env.get(prefix + "MQTT_USER", default=None), - password=env.get(prefix + "MQTT_PASSWORD", default=None), - tls=TLSConfig.from_env(prefix=prefix, env=env), - ) - - def __init__( - self, - host: str, - port: int, - user: Optional[str] = None, - password: Optional[str] = None, - tls: Optional[TLSConfig] = None, - ) -> None: - self.host = host - self.port = port - self.user = user - self.password = password - self.tls = tls - - def __repr__(self) -> str: - return "mqtt.Config(host=%r, port=%r, tls=%r)" % ( - self.host, - self.port, - self.tls is not None, - ) diff --git a/enapter/types.py b/enapter/types.py deleted file mode 100644 index 5cb5485..0000000 --- a/enapter/types.py +++ /dev/null @@ -1,3 +0,0 @@ -from typing import Dict, List, Union - -JSON = Union[str, int, float, None, bool, List["JSON"], Dict[str, "JSON"]] diff --git a/enapter/vucm/__init__.py b/enapter/vucm/__init__.py deleted file mode 100644 index 60b47ca..0000000 --- a/enapter/vucm/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from .app import App, run -from .config import Config -from .device import Device, device_command, device_task -from .ucm import UCM - -__all__ = [ - "App", - "Config", - "Device", - "device_command", - "device_task", - "UCM", - "run", -] diff --git a/enapter/vucm/app.py b/enapter/vucm/app.py deleted file mode 100644 index 99098e5..0000000 --- a/enapter/vucm/app.py +++ /dev/null @@ -1,60 +0,0 @@ -import asyncio -from typing import Optional, Protocol - -import enapter - -from .config import Config -from .device import Device -from .ucm import UCM - - -class DeviceFactory(Protocol): - - def __call__(self, channel: enapter.mqtt.api.DeviceChannel, **kwargs) -> Device: - pass - - -async def run( - device_factory: DeviceFactory, config_prefix: Optional[str] = None -) -> None: - enapter.log.configure(level=enapter.log.LEVEL or "info") - - config = Config.from_env(prefix=config_prefix) - - async with App(config=config, device_factory=device_factory) as app: - await app.join() - - -class App(enapter.async_.Routine): - def __init__(self, config: Config, device_factory: DeviceFactory) -> None: - self._config = config - self._device_factory = device_factory - - async def _run(self) -> None: - tasks = set() - - mqtt_client = await self._stack.enter_async_context( - enapter.mqtt.Client(config=self._config.mqtt) - ) - tasks.add(mqtt_client.task()) - - if self._config.start_ucm: - ucm = await self._stack.enter_async_context( - UCM(mqtt_client=mqtt_client, hardware_id=self._config.hardware_id) - ) - tasks.add(ucm.task()) - - device = await self._stack.enter_async_context( - self._device_factory( - channel=enapter.mqtt.api.DeviceChannel( - client=mqtt_client, - hardware_id=self._config.hardware_id, - channel_id=self._config.channel_id, - ) - ) - ) - tasks.add(device.task()) - - self._started.set() - - await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) diff --git a/enapter/vucm/config.py b/enapter/vucm/config.py deleted file mode 100644 index 2726bb9..0000000 --- a/enapter/vucm/config.py +++ /dev/null @@ -1,72 +0,0 @@ -import base64 -import json -import os -from typing import MutableMapping, Optional - -import enapter - - -class Config: - @classmethod - def from_env( - cls, prefix: Optional[str] = None, env: MutableMapping[str, str] = os.environ - ) -> "Config": - if prefix is None: - prefix = "ENAPTER_VUCM_" - try: - blob = env[prefix + "BLOB"] - except KeyError: - pass - else: - config = cls.from_blob(blob) - try: - config.channel_id = env[prefix + "CHANNEL_ID"] - except KeyError: - pass - return config - - hardware_id = env[prefix + "HARDWARE_ID"] - channel_id = env[prefix + "CHANNEL_ID"] - - mqtt = enapter.mqtt.Config.from_env(prefix=prefix, env=env) - - start_ucm = env.get(prefix + "START_UCM", "1") != "0" - - return cls( - hardware_id=hardware_id, - channel_id=channel_id, - mqtt=mqtt, - start_ucm=start_ucm, - ) - - @classmethod - def from_blob(cls, blob: str) -> "Config": - payload = json.loads(base64.b64decode(blob)) - - mqtt = enapter.mqtt.Config( - host=payload["mqtt_host"], - port=int(payload["mqtt_port"]), - tls=enapter.mqtt.TLSConfig( - ca_cert=payload["mqtt_ca"], - cert=payload["mqtt_cert"], - secret_key=payload["mqtt_private_key"], - ), - ) - - return cls( - hardware_id=payload["ucm_id"], - channel_id=payload["channel_id"], - mqtt=mqtt, - ) - - def __init__( - self, - hardware_id: str, - channel_id: str, - mqtt: enapter.mqtt.Config, - start_ucm: bool = True, - ) -> None: - self.hardware_id = hardware_id - self.channel_id = channel_id - self.mqtt = mqtt - self.start_ucm = start_ucm diff --git a/enapter/vucm/device.py b/enapter/vucm/device.py deleted file mode 100644 index 2aa39bf..0000000 --- a/enapter/vucm/device.py +++ /dev/null @@ -1,147 +0,0 @@ -import asyncio -import concurrent -import functools -import traceback -from typing import Any, Callable, Coroutine, Dict, Optional, Set, Tuple - -import enapter - -from .logger import Logger - -DEVICE_TASK_MARK = "_enapter_vucm_device_task" -DEVICE_COMMAND_MARK = "_enapter_vucm_device_command" - -DeviceTaskFunc = Callable[[Any], Coroutine] -DeviceCommandFunc = Callable[..., Coroutine] - - -def device_task(func: DeviceTaskFunc) -> DeviceTaskFunc: - setattr(func, DEVICE_TASK_MARK, True) - return func - - -def device_command(func: DeviceCommandFunc) -> DeviceTaskFunc: - setattr(func, DEVICE_COMMAND_MARK, True) - return func - - -def is_device_task(func: DeviceTaskFunc) -> bool: - return getattr(func, DEVICE_TASK_MARK, False) is True - - -def is_device_command(func: DeviceCommandFunc) -> bool: - return getattr(func, DEVICE_COMMAND_MARK, False) is True - - -class Device(enapter.async_.Routine): - def __init__( - self, channel: enapter.mqtt.api.DeviceChannel, thread_pool_workers: int = 1 - ) -> None: - self.__channel = channel - - self.__tasks = {} - for name in dir(self): - obj = getattr(self, name) - if is_device_task(obj): - self.__tasks[name] = obj - - self.__commands = {} - for name in dir(self): - obj = getattr(self, name) - if is_device_command(obj): - self.__commands[name] = obj - - self.__thread_pool_executor = concurrent.futures.ThreadPoolExecutor( - max_workers=thread_pool_workers - ) - - self.log = Logger(channel=channel) - self.alerts: Set[str] = set() - - async def send_telemetry( - self, telemetry: Optional[Dict[str, enapter.types.JSON]] = None - ) -> None: - if telemetry is None: - telemetry = {} - else: - telemetry = telemetry.copy() - - telemetry.setdefault("alerts", list(self.alerts)) - - await self.__channel.publish_telemetry(telemetry) - - async def send_properties( - self, properties: Optional[Dict[str, enapter.types.JSON]] = None - ) -> None: - if properties is None: - properties = {} - else: - properties = properties.copy() - - await self.__channel.publish_properties(properties) - - async def run_in_thread(self, func, *args, **kwargs) -> Any: - loop = asyncio.get_running_loop() - return await loop.run_in_executor( - self.__thread_pool_executor, functools.partial(func, *args, **kwargs) - ) - - async def _run(self) -> None: - self._stack.enter_context(self.__thread_pool_executor) - - tasks = set() - - for name, func in self.__tasks.items(): - tasks.add(asyncio.create_task(func(), name=name)) - - tasks.add( - asyncio.create_task( - self.__process_command_requests(), name="command_requests_processor" - ) - ) - - self._started.set() - - try: - await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - except asyncio.CancelledError: - pass - - finally: - for task in tasks: - task.cancel() - self._stack.push_async_callback(self.__wait_task, task) - - async def __wait_task(self, task) -> None: - try: - await task - except asyncio.CancelledError: - pass - except Exception as e: - try: - await self.log.error(f"device task {task.get_name()!r} failed: {e!r}") - except: - pass - raise - - async def __process_command_requests(self) -> None: - async with self.__channel.subscribe_to_command_requests() as reqs: - async for req in reqs: - state, payload = await self.__execute_command(req) - resp = req.new_response(state, payload) - await self.__channel.publish_command_response(resp) - - async def __execute_command( - self, req - ) -> Tuple[enapter.mqtt.api.CommandState, enapter.types.JSON]: - try: - cmd = self.__commands[req.name] - except KeyError: - return enapter.mqtt.api.CommandState.ERROR, {"reason": "unknown command"} - - try: - return enapter.mqtt.api.CommandState.COMPLETED, await cmd(**req.args) - except: - return enapter.mqtt.api.CommandState.ERROR, { - "traceback": traceback.format_exc() - } diff --git a/enapter/vucm/logger.py b/enapter/vucm/logger.py deleted file mode 100644 index b1af474..0000000 --- a/enapter/vucm/logger.py +++ /dev/null @@ -1,45 +0,0 @@ -import logging - -import enapter - -LOGGER = logging.getLogger(__name__) - - -class Logger: - def __init__(self, channel) -> None: - self._channel = channel - self._logger = self._new_logger(channel.hardware_id, channel.channel_id) - - @staticmethod - def _new_logger(hardware_id, channel_id) -> logging.LoggerAdapter: - extra = {"hardware_id": hardware_id, "channel_id": channel_id} - return logging.LoggerAdapter(LOGGER, extra=extra) - - async def debug(self, msg: str, persist: bool = False) -> None: - self._logger.debug(msg) - await self.log( - msg, severity=enapter.mqtt.api.LogSeverity.DEBUG, persist=persist - ) - - async def info(self, msg: str, persist: bool = False) -> None: - self._logger.info(msg) - await self.log(msg, severity=enapter.mqtt.api.LogSeverity.INFO, persist=persist) - - async def warning(self, msg: str, persist: bool = False) -> None: - self._logger.warning(msg) - await self.log( - msg, severity=enapter.mqtt.api.LogSeverity.WARNING, persist=persist - ) - - async def error(self, msg: str, persist: bool = False) -> None: - self._logger.error(msg) - await self.log( - msg, severity=enapter.mqtt.api.LogSeverity.ERROR, persist=persist - ) - - async def log( - self, msg: str, severity: enapter.mqtt.api.LogSeverity, persist: bool = False - ) -> None: - await self._channel.publish_logs(msg=msg, severity=severity, persist=persist) - - __call__ = log diff --git a/enapter/vucm/ucm.py b/enapter/vucm/ucm.py deleted file mode 100644 index 9099e27..0000000 --- a/enapter/vucm/ucm.py +++ /dev/null @@ -1,36 +0,0 @@ -import asyncio - -import enapter - -from .device import Device, device_command, device_task - - -class UCM(Device): - def __init__(self, mqtt_client, hardware_id) -> None: - super().__init__( - channel=enapter.mqtt.api.DeviceChannel( - client=mqtt_client, hardware_id=hardware_id, channel_id="ucm" - ) - ) - - @device_command - async def reboot(self) -> None: - await asyncio.sleep(0) - raise NotImplementedError - - @device_command - async def upload_lua_script(self, url, sha1, payload=None) -> None: - await asyncio.sleep(0) - raise NotImplementedError - - @device_task - async def telemetry_publisher(self) -> None: - while True: - await self.send_telemetry() - await asyncio.sleep(1) - - @device_task - async def properties_publisher(self) -> None: - while True: - await self.send_properties({"virtual": True, "lua_api_ver": 1}) - await asyncio.sleep(10) diff --git a/examples/http/get_device_by_id.py b/examples/http/get_device_by_id.py new file mode 100644 index 0000000..7124918 --- /dev/null +++ b/examples/http/get_device_by_id.py @@ -0,0 +1,16 @@ +import asyncio +import os + +import enapter + + +async def main(): + config = enapter.http.api.Config.from_env() + device_id = os.environ["DEVICE_ID"] + async with enapter.http.api.Client(config=config) as client: + device = await client.devices.get(device_id) + print(device) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/mqtt/pub_sub.py b/examples/mqtt/pub_sub.py index ef6cadd..f0cd111 100644 --- a/examples/mqtt/pub_sub.py +++ b/examples/mqtt/pub_sub.py @@ -28,4 +28,7 @@ async def publisher(client: enapter.mqtt.Client) -> None: if __name__ == "__main__": - asyncio.run(main()) + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/examples/mqtt/rl6_sim.py b/examples/mqtt/rl6_sim.py deleted file mode 100644 index 4705b27..0000000 --- a/examples/mqtt/rl6_sim.py +++ /dev/null @@ -1,181 +0,0 @@ -import asyncio -import json -import os -import time -from typing import Any, Dict - -import enapter - - -async def main() -> None: - hardware_id = os.environ["HARDWARE_ID"] - channel_id = os.environ["CHANNEL_ID"] - mqtt_config = enapter.mqtt.Config( - host=os.environ["MQTT_HOST"], - port=int(os.environ["MQTT_PORT"]), - tls=enapter.mqtt.TLSConfig( - secret_key=os.environ["MQTT_TLS_SECRET_KEY"], - cert=os.environ["MQTT_TLS_CERT"], - ca_cert=os.environ["MQTT_TLS_CA_CERT"], - ), - ) - async with enapter.mqtt.Client(config=mqtt_config) as client: - async with asyncio.TaskGroup() as tg: - tg.create_task(command_handler(client, hardware_id, channel_id)) - tg.create_task(telemetry_publisher(client, hardware_id, channel_id)) - tg.create_task(properties_publisher(client, hardware_id, channel_id)) - # NOTE: The following two tasks are necessary only when connecting - # to Cloud v2. - tg.create_task(ucm_properties_publisher(client, hardware_id)) - tg.create_task(ucm_telemetry_publisher(client, hardware_id)) - - -async def command_handler( - client: enapter.mqtt.Client, hardware_id: str, channel_id: str -) -> None: - async with client.subscribe( - f"v1/to/{hardware_id}/{channel_id}/v1/command/requests" - ) as messages: - async for msg in messages: - request = json.loads(msg.payload) - match request["name"]: - case "enable_load": - response = handle_enable_load_command(request) - case "disable_load": - response = handle_disable_load_command(request) - case _: - response = handle_unknown_command(request) - try: - await client.publish( - topic=f"v1/from/{hardware_id}/{channel_id}/v1/command/responses", - payload=json.dumps(response), - ) - except enapter.mqtt.Error as e: - print("failed to publish command response: " + str(e)) - - -LOADS = { - "r1": False, - "r2": False, - "r3": False, - "r4": False, - "r5": False, - "r6": False, -} - - -def handle_enable_load_command(request: Dict[str, Any]) -> Dict[str, Any]: - arguments = request.get("arguments", {}) - load = arguments.get("load") - if load not in LOADS: - return { - "id": request["id"], - "state": "error", - "payload": {"reason": "load invalid or missing"}, - } - LOADS[load] = True - return { - "id": request["id"], - "state": "completed", - "payload": {}, - } - - -def handle_disable_load_command(request: Dict[str, Any]) -> Dict[str, Any]: - args = request.get("args", {}) - load = args.get("load") - if load not in LOADS: - return { - "id": request["id"], - "state": "error", - "payload": {"reason": "load invalid or missing"}, - } - LOADS[load] = False - return { - "id": request["id"], - "state": "completed", - "payload": {}, - } - - -def handle_unknown_command(request: Dict[str, Any]) -> Dict[str, Any]: - return { - "id": request["id"], - "state": "error", - "payload": {"reason": "command unknown"}, - } - - -async def telemetry_publisher( - client: enapter.mqtt.Client, hardware_id: str, channel_id: str -) -> None: - while True: - try: - telemetry = { - "timestamp": int(time.time()), - **LOADS, - } - await client.publish( - topic=f"v1/from/{hardware_id}/{channel_id}/v1/telemetry", - payload=json.dumps(telemetry), - ) - except enapter.mqtt.Error as e: - print("failed to publish telemetry: " + str(e)) - await asyncio.sleep(1) - - -async def properties_publisher( - client: enapter.mqtt.Client, hardware_id: str, channel_id: str -) -> None: - while True: - try: - properties = { - "timestamp": int(time.time()), - } - await client.publish( - topic=f"v1/from/{hardware_id}/{channel_id}/v1/properties", - payload=json.dumps(properties), - ) - except enapter.mqtt.Error as e: - print("failed to publish properties: " + str(e)) - await asyncio.sleep(10) - - -async def ucm_telemetry_publisher( - client: enapter.mqtt.Client, hardware_id: str -) -> None: - while True: - try: - telemetry = { - "timestamp": int(time.time()), - } - await client.publish( - topic=f"v1/from/{hardware_id}/ucm/v1/telemetry", - payload=json.dumps(telemetry), - ) - except enapter.mqtt.Error as e: - print("failed to publish ucm telemetry: " + str(e)) - await asyncio.sleep(1) - - -async def ucm_properties_publisher( - client: enapter.mqtt.Client, hardware_id: str -) -> None: - while True: - try: - properties = { - "timestamp": int(time.time()), - "virtual": True, - "lua_api_ver": 1, - } - await client.publish( - topic=f"v1/from/{hardware_id}/ucm/v1/register", - payload=json.dumps(properties), - ) - except enapter.mqtt.Error as e: - print("failed to publish ucm properties: " + str(e)) - await asyncio.sleep(10) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/standalone/README.md b/examples/standalone/README.md new file mode 100644 index 0000000..bd8a1e4 --- /dev/null +++ b/examples/standalone/README.md @@ -0,0 +1,126 @@ +# Standalone + +## Basic Implementation + +The most straightforward way to implement your own Standalone Device is this: + +1. Subclass `enapter.standalone.Device`. +2. Override `async def run(self) -> None` method to send telemetry and properties. +3. Pass an instance of your device to `enapter.standalone.run`. + +Here's a basic example: + +```python +# my_device.py + +import asyncio +import enapter + +async def main(): + await enapter.standalone.run(MyDevice()) + +class MyDevice(enapter.standalone.Device): + async def run(self): + while True: + await self.send_telemetry({}) + await self.send_properties({"model": "0.0.1"}) + await asyncio.sleep(10) + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Handling Commands + +`enapter.standalone.Device` dispatches incoming command execution requests to +corresponding methods on your subclass. + +If this command is defined in the manifest: + +```yaml +configure_connection: + display_name: Configure Connection + group: connection + arguments: + ip_address: + display_name: IP Address + type: string + required: true + token: + display_name: Bearer Authentication Token + type: string + required: false +``` + +This is the signature you would create in your device class to implement a +command handler: + +```python +async def cmd_configure_connection(ip_address: str, token: str | None = None): ... +``` + +By default `cmd_` prefix is used to search the command handler. + +## Synchronous Code + +Blocking (CPU-bound) code should not be called directly. For example, if a +function performs a CPU-intensive calculation for 1 second, all concurrent +`asyncio` Tasks and IO operations would be delayed by 1 second. + +Instead, use `asyncio.to_thread`: + +```python +await asyncio.to_thread(blocking_call()) +``` + +## Communication Config + +> [!NOTE] +> The following instruction works only for v3 sites. If you have a v1 site, +> follow [this +> tutorial](https://developers.enapter.com/docs/tutorial/software-ucms/standalone) +> to generate your communication config. + +Generate a communication config using [Enapter +CLI](https://github.com/Enapter/enapter-cli): + +```bash +enapter3 device communication-config generate --device-id "$YOUR_DEVICE_ID" --protocol MQTTS | jq .config | base64 --wrap=0 +``` + + + +## Running + +Now you can use the communication config to run your device: + +```bash +export ENAPTER_STANDALONE_COMMUNICATION_CONFIG="$YOUR_COMMUNICATION_CONFIG" +python my_device.py +``` + +## Running In Docker + +Here's an example of a simple `Dockerfile`: + +```Dockerfile +FROM python:3.13-alpine + +WORKDIR /app + +RUN python -m venv .venv +COPY requirements.txt requirements.txt +RUN .venv/bin/pip install -r requirements.txt + +COPY script.py script.py + +CMD [".venv/bin/python", "script.py"] +``` + +> [!WARNING] +> If you are using Enapter Gateway and running Linux, you should connect your +> containers to the `host` network to make mDNS resolution work: +> +> ```bash +> docker run --network host ... +> ``` diff --git a/examples/vucm/snmp-eaton-ups/Dockerfile b/examples/standalone/mi-fan-1c/Dockerfile similarity index 88% rename from examples/vucm/snmp-eaton-ups/Dockerfile rename to examples/standalone/mi-fan-1c/Dockerfile index d4e42b0..57c8991 100644 --- a/examples/vucm/snmp-eaton-ups/Dockerfile +++ b/examples/standalone/mi-fan-1c/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-alpine3.16 +FROM python:3.13-alpine WORKDIR /app diff --git a/examples/vucm/zhimi-fan-za5/docker_run.sh b/examples/standalone/mi-fan-1c/docker_run.sh similarity index 62% rename from examples/vucm/zhimi-fan-za5/docker_run.sh rename to examples/standalone/mi-fan-1c/docker_run.sh index afbc898..0c8ee7b 100755 --- a/examples/vucm/zhimi-fan-za5/docker_run.sh +++ b/examples/standalone/mi-fan-1c/docker_run.sh @@ -4,14 +4,14 @@ set -euo pipefail IFS=$'\n\t' SCRIPT_DIR="$(realpath "$(dirname "$0")")" -IMAGE_TAG="${IMAGE_TAG:-"enapter-vucm-examples/$(basename "$SCRIPT_DIR"):latest"}" +IMAGE_TAG="${IMAGE_TAG:-"enapter-standalone-examples/$(basename "$SCRIPT_DIR"):latest"}" docker build --tag "$IMAGE_TAG" "$SCRIPT_DIR" docker run --rm -it \ --network host \ -e ENAPTER_LOG_LEVEL="${ENAPTER_LOG_LEVEL:-info}" \ - -e ENAPTER_VUCM_BLOB="$ENAPTER_VUCM_BLOB" \ + -e ENAPTER_STANDALONE_COMMUNICATION_CONFIG="$ENAPTER_STANDALONE_COMMUNICATION_CONFIG" \ -e MIIO_IP="$MIIO_IP" \ -e MIIO_TOKEN="$MIIO_TOKEN" \ "$IMAGE_TAG" diff --git a/examples/standalone/mi-fan-1c/manifest.yml b/examples/standalone/mi-fan-1c/manifest.yml new file mode 100644 index 0000000..5d2b81e --- /dev/null +++ b/examples/standalone/mi-fan-1c/manifest.yml @@ -0,0 +1,64 @@ +blueprint_spec: "device/1.0" + +display_name: "Mi Smart Standing Fan 1C" + +communication_module: + product: ENP-VIRTUAL + +command_groups: + commands: + display_name: Commands + +properties: {} + +telemetry: + "on": + display_name: "On" + type: boolean + mode: + display_name: Mode + type: string + enum: + - normal + - nature + buzzer: + display_name: Buzzer + type: boolean + speed: + display_name: Speed + type: integer + enum: [1, 2, 3] + +commands: + power: + display_name: Power + group: commands + arguments: + "on": + display_name: "On" + type: boolean + mode: + display_name: Mode + group: commands + arguments: + mode: + display_name: Mode + type: string + enum: + - normal + - nature + buzzer: + display_name: Buzzer + group: commands + arguments: + "on": + display_name: "On" + type: boolean + speed: + display_name: Speed + group: commands + arguments: + speed: + display_name: Speed + type: integer + enum: [1, 2, 3] diff --git a/examples/vucm/zhimi-fan-za5/requirements.txt b/examples/standalone/mi-fan-1c/requirements.txt similarity index 55% rename from examples/vucm/zhimi-fan-za5/requirements.txt rename to examples/standalone/mi-fan-1c/requirements.txt index 7fb6769..b849b89 100644 --- a/examples/vucm/zhimi-fan-za5/requirements.txt +++ b/examples/standalone/mi-fan-1c/requirements.txt @@ -1,2 +1,2 @@ -enapter==0.11.4 +enapter==0.12.0 python-miio==0.5.12 diff --git a/examples/standalone/mi-fan-1c/script.py b/examples/standalone/mi-fan-1c/script.py new file mode 100644 index 0000000..a53391f --- /dev/null +++ b/examples/standalone/mi-fan-1c/script.py @@ -0,0 +1,52 @@ +import asyncio +import os + +import miio + +import enapter + + +async def main(): + await enapter.standalone.run( + Fan1C(ip=os.environ["MIIO_IP"], token=os.environ["MIIO_TOKEN"]) + ) + + +class Fan1C(enapter.standalone.Device): + + def __init__(self, ip, token): + super().__init__() + self.fan = miio.Fan1C(ip=ip, token=token) + + async def run(self): + while True: + status = await asyncio.to_thread(self.fan.status) + await self.send_telemetry( + { + "on": status.is_on, + "mode": status.mode.value, + "buzzer": status.buzzer, + "speed": status.speed, + }, + ) + await asyncio.sleep(1) + + async def cmd_power(self, on: bool = False): + return await asyncio.to_thread(self.fan.on if on else self.fan.off) + + async def cmd_mode(self, mode: str): + miio_mode = miio.fan_common.OperationMode(mode) + return await asyncio.to_thread(self.fan.set_mode, miio_mode) + + async def cmd_buzzer(self, on: bool = False): + return await asyncio.to_thread(self.fan.set_buzzer, on) + + async def cmd_speed(self, speed: int): + return await asyncio.to_thread(self.fan.set_speed, speed) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/examples/vucm/psutil-battery/Dockerfile b/examples/standalone/psutil-battery/Dockerfile similarity index 89% rename from examples/vucm/psutil-battery/Dockerfile rename to examples/standalone/psutil-battery/Dockerfile index 968c365..2afbc6e 100644 --- a/examples/vucm/psutil-battery/Dockerfile +++ b/examples/standalone/psutil-battery/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-alpine3.16 +FROM python:3.13-alpine WORKDIR /app diff --git a/examples/vucm/rl6-simulator/docker_run.sh b/examples/standalone/psutil-battery/docker_run.sh similarity index 57% rename from examples/vucm/rl6-simulator/docker_run.sh rename to examples/standalone/psutil-battery/docker_run.sh index 8f7686a..d2443b8 100755 --- a/examples/vucm/rl6-simulator/docker_run.sh +++ b/examples/standalone/psutil-battery/docker_run.sh @@ -4,13 +4,12 @@ set -euo pipefail IFS=$'\n\t' SCRIPT_DIR="$(realpath "$(dirname "$0")")" -IMAGE_TAG="${IMAGE_TAG:-"enapter-vucm-examples/$(basename "$SCRIPT_DIR"):latest"}" +IMAGE_TAG="${IMAGE_TAG:-"enapter-standalone-examples/$(basename "$SCRIPT_DIR"):latest"}" docker build --tag "$IMAGE_TAG" "$SCRIPT_DIR" docker run --rm -it \ --network host \ -e ENAPTER_LOG_LEVEL="${ENAPTER_LOG_LEVEL:-info}" \ - -e ENAPTER_VUCM_BLOB="$ENAPTER_VUCM_BLOB" \ - -e ENAPTER_VUCM_CHANNEL_ID="rl6" \ + -e ENAPTER_STANDALONE_COMMUNICATION_CONFIG="$ENAPTER_STANDALONE_COMMUNICATION_CONFIG" \ "$IMAGE_TAG" diff --git a/examples/vucm/psutil-battery/manifest.yml b/examples/standalone/psutil-battery/manifest.yml similarity index 100% rename from examples/vucm/psutil-battery/manifest.yml rename to examples/standalone/psutil-battery/manifest.yml diff --git a/examples/standalone/psutil-battery/requirements.txt b/examples/standalone/psutil-battery/requirements.txt new file mode 100644 index 0000000..f0534b5 --- /dev/null +++ b/examples/standalone/psutil-battery/requirements.txt @@ -0,0 +1,2 @@ +enapter==0.12.0 +psutil==7.1.2 diff --git a/examples/standalone/psutil-battery/script.py b/examples/standalone/psutil-battery/script.py new file mode 100644 index 0000000..ee5bfed --- /dev/null +++ b/examples/standalone/psutil-battery/script.py @@ -0,0 +1,44 @@ +import asyncio + +import psutil + +import enapter + + +async def main(): + await enapter.standalone.run(PSUtilBattery()) + + +class PSUtilBattery(enapter.standalone.Device): + + async def run(self): + while True: + properties, telemetry = await self.gather_data() + await self.send_properties(properties) + await self.send_telemetry(telemetry) + await asyncio.sleep(10) + + async def gather_data(self): + try: + battery = await asyncio.to_thread(psutil.sensors_battery) + except Exception as e: + await self.logger.error(f"failed to gather data: {e}") + return {}, {"alerts": ["gather_data_error"]} + + if battery is None: + return {"battery_installed": False}, {} + + return {"battery_installed": True}, { + "charge_percent": battery.percent, + "power_plugged": battery.power_plugged, + "time_until_full_discharge": ( + battery.secsleft if not battery.power_plugged else None + ), + } + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/examples/vucm/zhimi-fan-za5/Dockerfile b/examples/standalone/rl6-simulator/Dockerfile similarity index 88% rename from examples/vucm/zhimi-fan-za5/Dockerfile rename to examples/standalone/rl6-simulator/Dockerfile index d4e42b0..57c8991 100644 --- a/examples/vucm/zhimi-fan-za5/Dockerfile +++ b/examples/standalone/rl6-simulator/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-alpine3.16 +FROM python:3.13-alpine WORKDIR /app diff --git a/examples/vucm/psutil-battery/docker_run.sh b/examples/standalone/rl6-simulator/docker_run.sh similarity index 57% rename from examples/vucm/psutil-battery/docker_run.sh rename to examples/standalone/rl6-simulator/docker_run.sh index 6db7a50..d2443b8 100755 --- a/examples/vucm/psutil-battery/docker_run.sh +++ b/examples/standalone/rl6-simulator/docker_run.sh @@ -4,12 +4,12 @@ set -euo pipefail IFS=$'\n\t' SCRIPT_DIR="$(realpath "$(dirname "$0")")" -IMAGE_TAG="${IMAGE_TAG:-"enapter-vucm-examples/$(basename "$SCRIPT_DIR"):latest"}" +IMAGE_TAG="${IMAGE_TAG:-"enapter-standalone-examples/$(basename "$SCRIPT_DIR"):latest"}" docker build --tag "$IMAGE_TAG" "$SCRIPT_DIR" docker run --rm -it \ --network host \ -e ENAPTER_LOG_LEVEL="${ENAPTER_LOG_LEVEL:-info}" \ - -e ENAPTER_VUCM_BLOB="$ENAPTER_VUCM_BLOB" \ + -e ENAPTER_STANDALONE_COMMUNICATION_CONFIG="$ENAPTER_STANDALONE_COMMUNICATION_CONFIG" \ "$IMAGE_TAG" diff --git a/examples/vucm/rl6-simulator/manifest.yml b/examples/standalone/rl6-simulator/manifest.yml similarity index 100% rename from examples/vucm/rl6-simulator/manifest.yml rename to examples/standalone/rl6-simulator/manifest.yml diff --git a/examples/standalone/rl6-simulator/requirements.txt b/examples/standalone/rl6-simulator/requirements.txt new file mode 100644 index 0000000..f6f33c8 --- /dev/null +++ b/examples/standalone/rl6-simulator/requirements.txt @@ -0,0 +1 @@ +enapter==0.12.0 diff --git a/examples/vucm/rl6-simulator/script.py b/examples/standalone/rl6-simulator/script.py similarity index 55% rename from examples/vucm/rl6-simulator/script.py rename to examples/standalone/rl6-simulator/script.py index c5a7aaa..3f5c2f6 100644 --- a/examples/vucm/rl6-simulator/script.py +++ b/examples/standalone/rl6-simulator/script.py @@ -4,12 +4,13 @@ async def main(): - await enapter.vucm.run(Rl6Simulator) + await enapter.standalone.run(Rl6Simulator()) -class Rl6Simulator(enapter.vucm.Device): - def __init__(self, **kwargs): - super().__init__(**kwargs) +class Rl6Simulator(enapter.standalone.Device): + + def __init__(self): + super().__init__() self.loads = { "r1": False, "r2": False, @@ -19,26 +20,30 @@ def __init__(self, **kwargs): "r6": False, } - @enapter.vucm.device_command - async def enable_load(self, load: str): - self.loads[load] = True + async def run(self): + async with asyncio.TaskGroup() as tg: + tg.create_task(self.properties_sender()) + tg.create_task(self.telemetry_sender()) - @enapter.vucm.device_command - async def disable_load(self, load: str): - self.loads[load] = False + async def properties_sender(self): + while True: + await self.send_properties({}) + await asyncio.sleep(10) - @enapter.vucm.device_task async def telemetry_sender(self): while True: await self.send_telemetry(self.loads) await asyncio.sleep(1) - @enapter.vucm.device_task - async def properties_sender(self): - while True: - await self.send_properties({}) - await asyncio.sleep(10) + async def cmd_enable_load(self, load: str): + self.loads[load] = True + + async def cmd_disable_load(self, load: str): + self.loads[load] = False if __name__ == "__main__": - asyncio.run(main()) + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/examples/vucm/wttr-in/Dockerfile b/examples/standalone/snmp-eaton-ups/Dockerfile similarity index 88% rename from examples/vucm/wttr-in/Dockerfile rename to examples/standalone/snmp-eaton-ups/Dockerfile index d4e42b0..57c8991 100644 --- a/examples/vucm/wttr-in/Dockerfile +++ b/examples/standalone/snmp-eaton-ups/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-alpine3.16 +FROM python:3.13-alpine WORKDIR /app diff --git a/examples/standalone/snmp-eaton-ups/README.md b/examples/standalone/snmp-eaton-ups/README.md new file mode 100644 index 0000000..1454314 --- /dev/null +++ b/examples/standalone/snmp-eaton-ups/README.md @@ -0,0 +1,166 @@ +# Eaton UPS Standalone Device (SNMP) + +This example describes the implementation of the Standalone Device concept +using the opensource [Enapter +python-sdk](https://github.com/Enapter/python-sdk) for monitoring Eaton UPS +using SNMP protocol. + +In order to use this standalone device you need to enable SNMPv1 protocol in +the Web Interface of your UPS and set unique community name for the read only +access. The default port for SNMP is 161 but also can be changed. + +As an example in this guide we will use the following dummy settings for +configuration: + +- UPS IP Address: 192.168.192.192 +- Community Name: public +- SNMP Port: 161 + +## Requirements + +It is recommended to run this standalone device using Docker and Docker +Compose. This will ensure that environment is correct. + +The UPS must be reachable from the computer where the Docker Container will be +running. You can check availability and settings with `snmpget` command on +Linux or Mac: + +```bash +user@pc snmp-eaton-ups % snmpget -v1 -c public 192.168.192.192:161 1.3.6.1.2.1.33.1.1.1.0 +SNMPv2-SMI::mib-2.33.1.1.1.0 = STRING: "EATON" +``` + +## Step 1. Create Standalone Device in Enapter Cloud + +Log in to the Enapter Cloud, navigate to the Site where you want to create a +Standalone Device and click on `Add new` button in the Standalone Device +section. + +After creating Standalone Device, you need to Generate and save Configuration +string also known as `ENAPTER_STANDALONE_COMMUNICATION_CONFIG` as well as save +UCM ID which will be needed for the next step. + +## Step 2. Upload Blueprint into the Cloud + +The general case [Enapter Blueprint](https://marketplace.enapter.com/about) +consists of two files - declaration in YAML format (manifest.yaml) and logic +written in Lua. Howerver for this case the logic is written in Python as Lua +implementation doesn't have SNMP integration. + +But for both cases we need to tell Enapter Cloud which telemetry we are going +to send and store and how to name it. + +The easiest way to do that - using [Enapter +CLI](https://github.com/Enapter/enapter-cli) to upload manifest.yaml into +Cloud. The other option is to use [Web +IDE](https://developers.enapter.com/docs/tutorial/uploading-blueprint). + +```bash +user@pc snmp-eaton-ups % enapter devices upload --blueprint-dir . --hardware-id REAL_UCM_ID +upload started with operation id 25721 +[#25721] 2023-07-20T16:27:33Z [INFO] Started uploading blueprint[id=dcb05efe-1618-4b01-877b-6105960690bc] on device[hardware_id=REAL_UCM_ID] +[#25721] 2023-07-20T16:27:33Z [INFO] Generating configuration for uploading +[#25721] 2023-07-20T16:27:33Z [INFO] Updating configuration in the cloud platform +[#25721] 2023-07-20T16:27:33Z [INFO] Updating configuration on the gateway +[#25721] 2023-07-20T16:27:35Z [INFO] Uploading blueprint finished successfully +Done! +``` + +## Step 3. Configuring Standalone Device + +Open `docker-compose.yaml` in any editor. + +Set environment variables according to your configuration settings. With dummy +settings your file will look like this: + +```yaml +version: "3" +services: + snmp-eaton-ups-ucm: + build: . + image: enapter-standalone-examples/snmp-eaton-ups:latest + environment: + ENAPTER_STANDALONE_COMMUNICATION_CONFIG: "PUT_YOUR_CONFIG_HERE" + ENAPTER_SNMP_HOST: "192.168.192.192" + ENAPTER_SNMP_PORT: "161" + ENAPTER_SNMP_COMMUNITY: "public" +``` + +## Step 4. Build Docker Image with Standalone Device + +> You can you can skip this step and go directly to Step 5. Docker Compose will +> automatically build your image before starting container. + +Build your Docker image by running `bash docker_build.sh` command in directory +with Standalone Device. + +```bash +user@pc snmp-eaton-ups % bash docker_build.sh +#0 building with "desktop-linux" instance using docker driver + +#1 [internal] load .dockerignore +#1 transferring context: 2B done +#1 DONE 0.0s + +#2 [internal] load build definition from Dockerfile +#2 transferring dockerfile: 281B done +#2 DONE 0.0s + +#3 [internal] load metadata for docker.io/library/python:3.10-alpine3.16 +#3 DONE 2.0s + +#4 [1/7] FROM docker.io/library/python:3.10-alpine3.16@sha256:afe68972cc00883d70b3760ee0ffbb7375cf09706c122dda7063ffe64c5be21b +#4 DONE 0.0s + +#5 [internal] load build context +#5 transferring context: 66B done +#5 DONE 0.0s + +#6 [3/7] RUN apk add build-base +#6 CACHED + +#7 [2/7] WORKDIR /app +#7 CACHED + +#8 [4/7] RUN python -m venv .venv +#8 CACHED + +#9 [5/7] COPY requirements.txt requirements.txt +#9 CACHED + +#10 [6/7] RUN .venv/bin/pip install -r requirements.txt +#10 CACHED + +#11 [7/7] COPY script.py script.py +#11 CACHED + +#12 exporting to image +#12 exporting layers done +#12 writing image sha256:92e1050debeabaff5837c6ca5bc26b0b966d09fc6f24e21b1d10cbb2f4d9aeec done +#12 naming to docker.io/enapter-standalone-examples/snmp-eaton-ups:latest done +#12 DONE 0.0s +``` + +Your `enapter-standalone-examples/snmp-eaton-ups` image is now built and you +can see it by running `docker images` command: + +```bash +user@pc snmp-eaton-ups % docker images enapter-standalone-examples/snmp-eaton-ups +REPOSITORY TAG IMAGE ID CREATED SIZE +enapter-standalone-examples/snmp-eaton-ups latest 92e1050debea 5 hours ago 285MB +``` + +## Step 5. Run your Standalone Device Docker Container + +Finally run your Standalone Device with `docker-compose up` command: + +```bash +user@pc snmp-eaton-ups % docker-compose up +[+] Running 1/0 + ✔ Container snmp-eaton-ups-snmp-eaton-ups-standalone-1 Created 0.0s +Attaching to snmp-eaton-ups-snmp-eaton-ups-standalone-1 +snmp-eaton-ups-snmp-eaton-ups-standalone-1 | {"time": "2023-07-20T15:50:01.570744", "level": "INFO", "name": "enapter.mqtt.client", "host": "10.1.1.47", "port": 8883, "message": "starting"} +snmp-eaton-ups-snmp-eaton-ups-standalone-1 | {"time": "2023-07-20T15:50:21.776037", "level": "INFO", "name": "enapter.mqtt.client", "host": "10.1.1.47", "port": 8883, "message": "client ready"} +``` + +On this step you can check that your Device is now Online in the Cloud. diff --git a/examples/vucm/snmp-eaton-ups/docker-compose.yaml b/examples/standalone/snmp-eaton-ups/docker-compose.yaml similarity index 56% rename from examples/vucm/snmp-eaton-ups/docker-compose.yaml rename to examples/standalone/snmp-eaton-ups/docker-compose.yaml index d323011..e7fb7b0 100644 --- a/examples/vucm/snmp-eaton-ups/docker-compose.yaml +++ b/examples/standalone/snmp-eaton-ups/docker-compose.yaml @@ -1,12 +1,12 @@ version: "3" services: - snmp-eaton-ups-ucm: + snmp-eaton-ups-standalone: build: . - image: enapter-vucm-examples/snmp-eaton-ups:latest + image: enapter-standalone-examples/snmp-eaton-ups:latest stop_signal: SIGINT restart: "no" environment: - ENAPTER_VUCM_BLOB: "REALENAPTERVUCMBLOBMUSTBEHERE=" + ENAPTER_STANDALONE_COMMUNICATION_CONFIG: "PUT_YOUR_CONFIG_HERE" ENAPTER_SNMP_HOST: "192.168.192.192" ENAPTER_SNMP_PORT: "161" ENAPTER_SNMP_COMMUNITY: "public" diff --git a/examples/vucm/snmp-eaton-ups/docker_build.sh b/examples/standalone/snmp-eaton-ups/docker_build.sh similarity index 62% rename from examples/vucm/snmp-eaton-ups/docker_build.sh rename to examples/standalone/snmp-eaton-ups/docker_build.sh index a9f7d29..594c43f 100755 --- a/examples/vucm/snmp-eaton-ups/docker_build.sh +++ b/examples/standalone/snmp-eaton-ups/docker_build.sh @@ -4,6 +4,6 @@ set -euo pipefail IFS=$'\n\t' SCRIPT_DIR="$(realpath "$(dirname "$0")")" -IMAGE_TAG="${IMAGE_TAG:-"enapter-vucm-examples/$(basename "$SCRIPT_DIR"):latest"}" +IMAGE_TAG="${IMAGE_TAG:-"enapter-standalone-examples/$(basename "$SCRIPT_DIR"):latest"}" docker build --progress=plain --tag "$IMAGE_TAG" "$SCRIPT_DIR" diff --git a/examples/vucm/snmp-eaton-ups/docker_run.sh b/examples/standalone/snmp-eaton-ups/docker_run.sh similarity index 67% rename from examples/vucm/snmp-eaton-ups/docker_run.sh rename to examples/standalone/snmp-eaton-ups/docker_run.sh index 0200929..8bd195a 100755 --- a/examples/vucm/snmp-eaton-ups/docker_run.sh +++ b/examples/standalone/snmp-eaton-ups/docker_run.sh @@ -4,14 +4,14 @@ set -euo pipefail IFS=$'\n\t' SCRIPT_DIR="$(realpath "$(dirname "$0")")" -IMAGE_TAG="${IMAGE_TAG:-"enapter-vucm-examples/$(basename "$SCRIPT_DIR"):latest"}" +IMAGE_TAG="${IMAGE_TAG:-"enapter-standalone-examples/$(basename "$SCRIPT_DIR"):latest"}" bash $SCRIPT_DIR/docker_build.sh docker run --rm -it \ --network host \ -e ENAPTER_LOG_LEVEL="${ENAPTER_LOG_LEVEL:-info}" \ - -e ENAPTER_VUCM_BLOB="$ENAPTER_VUCM_BLOB" \ + -e ENAPTER_STANDALONE_COMMUNICATION_CONFIG="$ENAPTER_STANDALONE_COMMUNICATION_CONFIG" \ -e ENAPTER_SNMP_HOST="$ENAPTER_SNMP_HOST" \ -e ENAPTER_SNMP_PORT="$ENAPTER_SNMP_PORT" \ -e ENAPTER_SNMP_COMMUNITY="$ENAPTER_SNMP_COMMUNITY" \ diff --git a/examples/vucm/snmp-eaton-ups/manifest.yml b/examples/standalone/snmp-eaton-ups/manifest.yml similarity index 100% rename from examples/vucm/snmp-eaton-ups/manifest.yml rename to examples/standalone/snmp-eaton-ups/manifest.yml diff --git a/examples/vucm/snmp-eaton-ups/requirements.txt b/examples/standalone/snmp-eaton-ups/requirements.txt similarity index 64% rename from examples/vucm/snmp-eaton-ups/requirements.txt rename to examples/standalone/snmp-eaton-ups/requirements.txt index 6afc9c5..5eefe23 100644 --- a/examples/vucm/snmp-eaton-ups/requirements.txt +++ b/examples/standalone/snmp-eaton-ups/requirements.txt @@ -1,3 +1,3 @@ -enapter==0.11.4 +enapter==0.12.0 pysnmp==4.4.12 pyasn1<=0.4.8 diff --git a/examples/vucm/snmp-eaton-ups/script.py b/examples/standalone/snmp-eaton-ups/script.py similarity index 89% rename from examples/vucm/snmp-eaton-ups/script.py rename to examples/standalone/snmp-eaton-ups/script.py index 70a4bba..be36703 100644 --- a/examples/vucm/snmp-eaton-ups/script.py +++ b/examples/standalone/snmp-eaton-ups/script.py @@ -1,5 +1,4 @@ import asyncio -import functools import os from pysnmp.entity.rfc3413.oneliner import cmdgen @@ -8,27 +7,30 @@ async def main(): - device_factory = functools.partial( - EatonUPS, + eaton_ups = EatonUPS( snmp_community=os.environ["ENAPTER_SNMP_COMMUNITY"], snmp_host=os.environ["ENAPTER_SNMP_HOST"], snmp_port=os.environ["ENAPTER_SNMP_PORT"], ) - await enapter.vucm.run(device_factory) + await enapter.standalone.run(eaton_ups) -class EatonUPS(enapter.vucm.Device): - def __init__(self, snmp_community, snmp_host, snmp_port, **kwargs): - super().__init__(**kwargs) +class EatonUPS(enapter.standalone.Device): + def __init__(self, snmp_community, snmp_host, snmp_port): + super().__init__() self.telemetry = {} self.properties = {} - self.cmd_gen = cmdgen.CommandGenerator() self.auth_data = cmdgen.CommunityData(snmp_community) self.transport_target = cmdgen.UdpTransportTarget((snmp_host, snmp_port)) - @enapter.vucm.device_task + async def run(self): + async with asyncio.TaskGroup() as tg: + tg.create_task(self.get_telemetry_data()) + tg.create_task(self.telemetry_sender()) + tg.create_task(self.properties_sender()) + async def get_telemetry_data(self): while True: temperature = await self.snmp_get("1.3.6.1.4.1.534.1.6.1.0") @@ -105,20 +107,18 @@ async def get_properties_data(self): await asyncio.sleep(60) - @enapter.vucm.device_task async def telemetry_sender(self): while True: await self.send_telemetry(self.telemetry) await asyncio.sleep(1) - @enapter.vucm.device_task async def properties_sender(self): while True: await self.send_properties(self.properties) await asyncio.sleep(10) async def snmp_get(self, oid): - result = await self.run_in_thread( + result = await asyncio.to_thread( self.cmd_gen.getCmd, self.auth_data, self.transport_target, @@ -147,4 +147,7 @@ async def snmp_get(self, oid): if __name__ == "__main__": - asyncio.run(main()) + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/examples/vucm/rl6-simulator/Dockerfile b/examples/standalone/wttr-in/Dockerfile similarity index 88% rename from examples/vucm/rl6-simulator/Dockerfile rename to examples/standalone/wttr-in/Dockerfile index d4e42b0..57c8991 100644 --- a/examples/vucm/rl6-simulator/Dockerfile +++ b/examples/standalone/wttr-in/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-alpine3.16 +FROM python:3.13-alpine WORKDIR /app diff --git a/examples/vucm/wttr-in/docker_run.sh b/examples/standalone/wttr-in/docker_run.sh similarity index 61% rename from examples/vucm/wttr-in/docker_run.sh rename to examples/standalone/wttr-in/docker_run.sh index e93a6ba..fec7d1b 100755 --- a/examples/vucm/wttr-in/docker_run.sh +++ b/examples/standalone/wttr-in/docker_run.sh @@ -4,13 +4,13 @@ set -euo pipefail IFS=$'\n\t' SCRIPT_DIR="$(realpath "$(dirname "$0")")" -IMAGE_TAG="${IMAGE_TAG:-"enapter-vucm-examples/$(basename "$SCRIPT_DIR"):latest"}" +IMAGE_TAG="${IMAGE_TAG:-"enapter-standalone-examples/$(basename "$SCRIPT_DIR"):latest"}" docker build --tag "$IMAGE_TAG" "$SCRIPT_DIR" docker run --rm -it \ --network host \ -e ENAPTER_LOG_LEVEL="${ENAPTER_LOG_LEVEL:-info}" \ - -e ENAPTER_VUCM_BLOB="$ENAPTER_VUCM_BLOB" \ + -e ENAPTER_STANDALONE_COMMUNICATION_CONFIG="$ENAPTER_STANDALONE_COMMUNICATION_CONFIG" \ -e WTTR_IN_LOCATION="$WTTR_IN_LOCATION" \ "$IMAGE_TAG" diff --git a/examples/vucm/wttr-in/manifest.yml b/examples/standalone/wttr-in/manifest.yml similarity index 100% rename from examples/vucm/wttr-in/manifest.yml rename to examples/standalone/wttr-in/manifest.yml diff --git a/examples/standalone/wttr-in/requirements.txt b/examples/standalone/wttr-in/requirements.txt new file mode 100644 index 0000000..2f315a3 --- /dev/null +++ b/examples/standalone/wttr-in/requirements.txt @@ -0,0 +1,2 @@ +enapter==0.12.0 +python-weather==2.1.0 diff --git a/examples/standalone/wttr-in/script.py b/examples/standalone/wttr-in/script.py new file mode 100644 index 0000000..50a578e --- /dev/null +++ b/examples/standalone/wttr-in/script.py @@ -0,0 +1,50 @@ +import asyncio +import os + +import python_weather + +import enapter + + +async def main(): + async with python_weather.Client() as client: + await enapter.standalone.run( + WttrIn(client=client, location=os.environ["WTTR_IN_LOCATION"]) + ) + + +class WttrIn(enapter.standalone.Device): + + def __init__(self, client, location): + super().__init__() + self.client = client + self.location = location + + async def run(self): + async with asyncio.TaskGroup() as tg: + tg.create_task(self.properties_sender()) + tg.create_task(self.telemetry_sender()) + + async def properties_sender(self): + while True: + await self.send_properties({"location": self.location}) + await asyncio.sleep(10) + + async def telemetry_sender(self): + while True: + try: + weather = await self.client.get(self.location) + await self.send_telemetry( + {"temperature": weather.temperature, "alerts": []} + ) + except Exception as e: + await self.log.error(f"failed to get weather: {e}") + await self.send_telemetry({"alerts": ["wttr_in_error"]}) + await asyncio.sleep(10) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/examples/standalone/zigbee2mqtt/Dockerfile b/examples/standalone/zigbee2mqtt/Dockerfile new file mode 100644 index 0000000..57c8991 --- /dev/null +++ b/examples/standalone/zigbee2mqtt/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.13-alpine + +WORKDIR /app + +RUN apk add build-base + +RUN python -m venv .venv +COPY requirements.txt requirements.txt +RUN .venv/bin/pip install -r requirements.txt + +COPY script.py script.py + +CMD [".venv/bin/python", "script.py"] diff --git a/examples/standalone/zigbee2mqtt/README.md b/examples/standalone/zigbee2mqtt/README.md new file mode 100644 index 0000000..96dc98d --- /dev/null +++ b/examples/standalone/zigbee2mqtt/README.md @@ -0,0 +1,156 @@ +# Zigbee Sensor (MQTT) + +This example describes the implementation of the Standalone Device concept +using the opensource [Enapter +python-sdk](https://github.com/Enapter/python-sdk) for monitoring Zigbee Sensor +via MQTT protocol (Zigbee2Mqtt). + +In order to use this Standalone Device you need to have +[Zigbee2MQTT](https://www.zigbee2mqtt.io/guide/installation/) and some MQTT +broker (for example [Mosquitto](https://mosquitto.org)) running. + +As an example in this guide we will use the following dummy settings for +configuration: + +- MQTT Broker Address: 192.168.192.190 +- MQTT Broker Port: 9883 +- MQTT User: mqtt_user +- MQTT Password: mqtt_password +- Device MQTT topic: zigbee2mqtt/MyDevice + +## Requirements + +It is recommended to run this Standalone Device using Docker and Docker +Compose. This will ensure that environment is correct. + +The MQTT broker must be reachable from the computer where the Docker Container +will be running. + +## Step 1. Create Standalone Device in Enapter Cloud + +Log in to the Enapter Cloud, navigate to the Site where you want to create +Standalone Device and click on `Add new` button in the Standalone Device +section. + +After creating Standalone Device, you need to Generate and save Configuration +string also known as `ENAPTER_STANDALONE_COMMUNICATION_CONFIG` as well as save +UCM ID which will be needed for the next step. + +## Step 2. Upload Blueprint into the Cloud + +The general case [Enapter Blueprint](https://marketplace.enapter.com/about) +consists of two files - declaration in YAML format (manifest.yaml) and logic +written in Lua. Howerver for this case the logic is written in Python. + +But for both cases we need to tell Enapter Cloud which telemetry we are going +to send and store and how to name it. + +The easiest way to do that - using [Enapter +CLI](https://github.com/Enapter/enapter-cli) to upload manifest.yaml into +Cloud. The other option is to use [Web +IDE](https://developers.enapter.com/docs/tutorial/uploading-blueprint). + +```bash +user@pc zigbee2mqtt % enapter devices upload --blueprint-dir . --hardware-id REAL_UCM_ID +upload started with operation id 25721 +[#25721] 2023-07-20T16:27:33Z [INFO] Started uploading blueprint[id=dcb05efe-1618-4b01-877b-6105960690bc] on device[hardware_id=REAL_UCM_ID] +[#25721] 2023-07-20T16:27:33Z [INFO] Generating configuration for uploading +[#25721] 2023-07-20T16:27:33Z [INFO] Updating configuration in the cloud platform +[#25721] 2023-07-20T16:27:33Z [INFO] Updating configuration on the gateway +[#25721] 2023-07-20T16:27:35Z [INFO] Uploading blueprint finished successfully +Done! +``` + +## Step 3. Configuring Standalone UCM + +Open `docker-compose.yaml` in any editor. + +Set environment variables according to your configuration settings. With dummy +settings your file will look like this: + +```yaml +version: "3" +services: + zigbee2mqtt-standalone: + build: . + image: enapter-standalone-examples/zigbee2mqtt:latest + environment: + - ENAPTER_STANDALONE_COMMUNICATION_CONFIG: "PUT_YOUR_CONFIG_HERE" + - ZIGBEE_MQTT_HOST: "192.168.192.190" + - ZIGBEE_MQTT_PORT: "9883" + - ZIGBEE_MQTT_USER: "mqtt_user" + - ZIGBEE_MQTT_PASSWORD: "mqtt_password" + - ZIGBEE_MQTT_TOPIC: "zigbee2mqtt/MyDevice" + - ZIGBEE_SENSOR_MANUFACTURER: "Device Manufacturer" + - ZIGBEE_SENSOR_MODEL: "Device Model" +``` + +## Step 4. Build Docker Image with Standalone Device + +> You can you can skip this step and go directly to Step 5. Docker Compose will +> automatically build your image before starting container. + +Build your Docker image by running `bash docker_build.sh` command in directory +with Standalone Device. + +```bash +user@pc zigbee2mqtt % bash docker_build.sh +#0 building with "desktop-linux" instance using docker driver + +#1 [internal] load .dockerignore +#1 transferring context: 2B done +#1 DONE 0.0s + +#2 [internal] load build definition from Dockerfile +#2 transferring dockerfile: 281B done +#2 DONE 0.0s + +#3 [internal] load metadata for docker.io/library/python:3.10-alpine3.16 +#3 DONE 2.0s + +#4 [1/7] FROM docker.io/library/python:3.10-alpine3.16@sha256:afe68972cc00883d70b3760ee0ffbb7375cf09706c122dda7063ffe64c5be21b +#4 DONE 0.0s + +#5 [internal] load build context +#5 transferring context: 66B done +#5 DONE 0.0s + +#6 [3/7] RUN apk add build-base +#6 CACHED + +#7 [2/7] WORKDIR /app +#7 CACHED + +#8 [4/7] RUN python -m venv .venv +#8 CACHED + +#9 [5/7] COPY requirements.txt requirements.txt +#9 CACHED + +#10 [6/7] RUN .venv/bin/pip install -r requirements.txt +#10 CACHED + +#11 [7/7] COPY script.py script.py +#11 CACHED + +#12 exporting to image +#12 exporting layers done +#12 writing image sha256:92e1050debeabaff5837c6ca5bc26b0b966d09fc6f24e21b1d10cbb2f4d9aeec done +#12 naming to docker.io/enapter-standalone-examples/zigbee2mqtt:latest done +#12 DONE 0.0s +``` + +Your `enapter-standalone-examples/zigbee2mqtt` image is now built and you can +see it by running `docker images` command: + +```bash +user@pc zigbee2mqtt % docker images enapter-standalone-examples/zigbee2mqtt +REPOSITORY TAG IMAGE ID CREATED SIZE +enapter-standalone-examples/zigbee2mqtt latest 92e1050debea 5 hours ago 285MB +``` + +## Step 5. Run your Standalone Device Docker Container + +Finally run your Standalone Device with `docker-compose up` command: + +On this step you can check that your Device is now Online in the Cloud. diff --git a/examples/vucm/zigbee2mqtt/docker-compose.yaml b/examples/standalone/zigbee2mqtt/docker-compose.yaml similarity index 70% rename from examples/vucm/zigbee2mqtt/docker-compose.yaml rename to examples/standalone/zigbee2mqtt/docker-compose.yaml index 2428b8f..5d58ce2 100644 --- a/examples/vucm/zigbee2mqtt/docker-compose.yaml +++ b/examples/standalone/zigbee2mqtt/docker-compose.yaml @@ -1,10 +1,10 @@ version: "3" services: - zigbee2mqtt-ucm: + zigbee2mqtt-standalone: build: . - image: enapter-vucm-examples/zigbee2mqtt:latest + image: enapter-standalone-examples/zigbee2mqtt:latest environment: - - ENAPTER_VUCM_BLOB: "REALENAPTERVUCMBLOBMUSTBEHERE=" + - ENAPTER_STANDALONE_COMMUNICATION_CONFIG: "PUT_YOUR_CONFIG_HERE" - ZIGBEE_MQTT_HOST: "192.168.192.190" - ZIGBEE_MQTT_PORT: "9883" - ZIGBEE_MQTT_USER: "mqtt_user" diff --git a/examples/vucm/zigbee2mqtt/docker_build.sh b/examples/standalone/zigbee2mqtt/docker_build.sh similarity index 62% rename from examples/vucm/zigbee2mqtt/docker_build.sh rename to examples/standalone/zigbee2mqtt/docker_build.sh index a9f7d29..594c43f 100755 --- a/examples/vucm/zigbee2mqtt/docker_build.sh +++ b/examples/standalone/zigbee2mqtt/docker_build.sh @@ -4,6 +4,6 @@ set -euo pipefail IFS=$'\n\t' SCRIPT_DIR="$(realpath "$(dirname "$0")")" -IMAGE_TAG="${IMAGE_TAG:-"enapter-vucm-examples/$(basename "$SCRIPT_DIR"):latest"}" +IMAGE_TAG="${IMAGE_TAG:-"enapter-standalone-examples/$(basename "$SCRIPT_DIR"):latest"}" docker build --progress=plain --tag "$IMAGE_TAG" "$SCRIPT_DIR" diff --git a/examples/vucm/zigbee2mqtt/docker_run.sh b/examples/standalone/zigbee2mqtt/docker_run.sh similarity index 76% rename from examples/vucm/zigbee2mqtt/docker_run.sh rename to examples/standalone/zigbee2mqtt/docker_run.sh index 6e01fd0..9762519 100755 --- a/examples/vucm/zigbee2mqtt/docker_run.sh +++ b/examples/standalone/zigbee2mqtt/docker_run.sh @@ -4,14 +4,14 @@ set -euo pipefail IFS=$'\n\t' SCRIPT_DIR="$(realpath "$(dirname "$0")")" -IMAGE_TAG="${IMAGE_TAG:-"enapter-vucm-examples/$(basename "$SCRIPT_DIR"):latest"}" +IMAGE_TAG="${IMAGE_TAG:-"enapter-standalone-examples/$(basename "$SCRIPT_DIR"):latest"}" bash $SCRIPT_DIR/docker_build.sh docker run --rm -it \ --network host \ -e ENAPTER_LOG_LEVEL="${ENAPTER_LOG_LEVEL:-info}" \ - -e ENAPTER_VUCM_BLOB="$ENAPTER_VUCM_BLOB" \ + -e ENAPTER_STANDALONE_COMMUNICATION_CONFIG="$ENAPTER_STANDALONE_COMMUNICATION_CONFIG" \ -e ZIGBEE_MQTT_HOST="$ZIGBEE_MQTT_HOST" \ -e ZIGBEE_MQTT_PORT="$ZIGBEE_MQTT_PORT" \ -e ZIGBEE_MQTT_USER="$ZIGBEE_MQTT_USER" \ diff --git a/examples/vucm/zigbee2mqtt/manifest.yml b/examples/standalone/zigbee2mqtt/manifest.yml similarity index 100% rename from examples/vucm/zigbee2mqtt/manifest.yml rename to examples/standalone/zigbee2mqtt/manifest.yml diff --git a/examples/standalone/zigbee2mqtt/requirements.txt b/examples/standalone/zigbee2mqtt/requirements.txt new file mode 100644 index 0000000..f6f33c8 --- /dev/null +++ b/examples/standalone/zigbee2mqtt/requirements.txt @@ -0,0 +1 @@ +enapter==0.12.0 diff --git a/examples/vucm/zigbee2mqtt/script.py b/examples/standalone/zigbee2mqtt/script.py similarity index 68% rename from examples/vucm/zigbee2mqtt/script.py rename to examples/standalone/zigbee2mqtt/script.py index 8664290..9d7dc03 100644 --- a/examples/vucm/zigbee2mqtt/script.py +++ b/examples/standalone/zigbee2mqtt/script.py @@ -1,5 +1,4 @@ import asyncio -import functools import json import os @@ -9,38 +8,37 @@ async def main(): env_prefix = "ZIGBEE_" mqtt_client_config = enapter.mqtt.Config.from_env(prefix=env_prefix) - device_factory = functools.partial( - ZigbeeMqtt, + zigbee_mqtt = ZigbeeMqtt( mqtt_client_config=mqtt_client_config, mqtt_topic=os.environ[env_prefix + "MQTT_TOPIC"], sensor_manufacturer=os.environ[env_prefix + "SENSOR_MANUFACTURER"], sensor_model=os.environ[env_prefix + "SENSOR_MODEL"], ) - await enapter.vucm.run(device_factory) + await enapter.standalone.run(zigbee_mqtt) -class ZigbeeMqtt(enapter.vucm.Device): +class ZigbeeMqtt(enapter.standalone.Device): + def __init__( - self, - mqtt_client_config, - mqtt_topic, - sensor_manufacturer, - sensor_model, - **kwargs, + self, mqtt_client_config, mqtt_topic, sensor_manufacturer, sensor_model ): - super().__init__(**kwargs) - + super().__init__() self.telemetry = {} - self.mqtt_client_config = mqtt_client_config self.mqtt_topic = mqtt_topic - self.sensor_manufacturer = sensor_manufacturer self.sensor_model = sensor_model - @enapter.vucm.device_task - async def consume(self): - async with enapter.mqtt.Client(self.mqtt_client_config) as client: + async def run(self): + async with asyncio.TaskGroup() as tg: + tg.create_task(self.consumer(tg)) + tg.create_task(self.telemetry_sender()) + tg.create_task(self.properties_sender()) + + async def consumer(self, tg): + async with enapter.mqtt.Client( + self.mqtt_client_config, task_group=tg + ) as client: async with client.subscribe(self.mqtt_topic) as messages: async for msg in messages: try: @@ -48,13 +46,11 @@ async def consume(self): except json.JSONDecodeError as e: await self.log.error(f"failed to decode json payload: {e}") - @enapter.vucm.device_task async def telemetry_sender(self): while True: await self.send_telemetry(self.telemetry) await asyncio.sleep(1) - @enapter.vucm.device_task async def properties_sender(self): while True: await self.send_properties( @@ -67,4 +63,7 @@ async def properties_sender(self): if __name__ == "__main__": - asyncio.run(main()) + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/examples/vucm/psutil-battery/requirements.txt b/examples/vucm/psutil-battery/requirements.txt deleted file mode 100644 index 5aeb18b..0000000 --- a/examples/vucm/psutil-battery/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -enapter==0.11.4 -psutil==5.9.4 diff --git a/examples/vucm/psutil-battery/script.py b/examples/vucm/psutil-battery/script.py deleted file mode 100644 index 4881062..0000000 --- a/examples/vucm/psutil-battery/script.py +++ /dev/null @@ -1,46 +0,0 @@ -import asyncio -from typing import Tuple - -import psutil - -import enapter - - -async def main(): - await enapter.vucm.run(PSUtilBattery) - - -class PSUtilBattery(enapter.vucm.Device): - @enapter.vucm.device_task - async def data_sender(self): - while True: - telemetry, properties, delay = await self.gather_data() - await self.send_telemetry(telemetry) - await self.send_properties(properties) - await asyncio.sleep(delay) - - async def gather_data(self) -> Tuple[enapter.types.JSON, enapter.types.JSON, int]: - try: - battery = psutil.sensors_battery() - except Exception as e: - await self.log.error(f"failed to gather data: {e}") - self.alerts.add("gather_data_error") - return None, None, 10 - self.alerts.clear() - - telemetry = None - properties = {"battery_installed": battery is not None} - - if battery is not None: - telemetry = { - "charge_percent": battery.percent, - "power_plugged": battery.power_plugged, - } - if not battery.power_plugged: - telemetry["time_until_full_discharge"] = battery.secsleft - - return telemetry, properties, 5 - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/vucm/rl6-simulator/requirements.txt b/examples/vucm/rl6-simulator/requirements.txt deleted file mode 100644 index 5acec81..0000000 --- a/examples/vucm/rl6-simulator/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -enapter==0.11.4 diff --git a/examples/vucm/snmp-eaton-ups/README.md b/examples/vucm/snmp-eaton-ups/README.md deleted file mode 100644 index 4271427..0000000 --- a/examples/vucm/snmp-eaton-ups/README.md +++ /dev/null @@ -1,147 +0,0 @@ -# Eaton UPS Standalone UCM (SNMP) - -This example describes the implementation of the [Standalone UCM](https://handbook.enapter.com/software/virtual_ucm/) concept using the opensource [Enapter python-sdk](https://github.com/Enapter/python-sdk) for monitoring Eaton UPS using SNMP protocol. - -In order to use this UCM you need to enable SNMPv1 protocol in the Web Interface of your UPS and set unique community name for the read only access. The default port for SNMP is 161 but also can be changed. - -As an example in this guide we will use the following dummy settings for configuration: - -UPS IP Address: 192.168.192.192 - -Community Name: public - -SNMP Port: 161 - -## Requirements - -It is recommended to run this UCM using Docker and Docker Compose. This will ensure that environment is correct. - -The UPS must be reachable from the computer where the Docker Container will be running. You can check availability and settings with `snmpget` command on Linux or Mac: - -```bash -user@pc snmp-eaton-ups % snmpget -v1 -c public 192.168.192.192:161 1.3.6.1.2.1.33.1.1.1.0 -SNMPv2-SMI::mib-2.33.1.1.1.0 = STRING: "EATON" -``` - -## Step 1. Create Standalone UCM in Enapter Cloud - -Log in to the Enapter Cloud, navigate to the Site where you want to create Standalone UCM and click on `Add new` button in the Standalone Device section. - -After creating Standalone UCM, you need to Generate and save Configuration string also known as ENAPTER_VUCM_BLOB as well as save UCM ID which will be needed for the next step - -More information you can find on [this page](https://developers.enapter.com/docs/tutorial/software-ucms/standalone). - -## Step 2. Upload Blueprint into the Cloud - -The general case [Enapter Blueprint](https://marketplace.enapter.com/about) consists of two files - declaration in YAML format (manifest.yaml) and logic written in Lua. Howerver for this case the logic is written in Python as Lua implementation doesn't have SNMP integration. - -But for both cases we need to tell Enapter Cloud which telemetry we are going to send and store and how to name it. - -The easiest way to do that - using [Enapter CLI](https://github.com/Enapter/enapter-cli) to upload manifest.yaml into Cloud. The other option is to use [Web IDE](https://developers.enapter.com/docs/tutorial/uploading-blueprint). - -```bash -user@pc snmp-eaton-ups % enapter devices upload --blueprint-dir . --hardware-id REAL_UCM_ID -upload started with operation id 25721 -[#25721] 2023-07-20T16:27:33Z [INFO] Started uploading blueprint[id=dcb05efe-1618-4b01-877b-6105960690bc] on device[hardware_id=REAL_UCM_ID] -[#25721] 2023-07-20T16:27:33Z [INFO] Generating configuration for uploading -[#25721] 2023-07-20T16:27:33Z [INFO] Updating configuration in the cloud platform -[#25721] 2023-07-20T16:27:33Z [INFO] Updating configuration on the gateway -[#25721] 2023-07-20T16:27:35Z [INFO] Uploading blueprint finished successfully -Done! -``` - -## Step 3. Configuring Standalone UCM - -Open `docker-compose.yaml` in any editor. - -Set environment variables according to your configuration settings. With dummy settings your file will look like this: - -```yaml -version: "3" -services: - snmp-eaton-ups-ucm: - build: . - image: enapter-vucm-examples/snmp-eaton-ups:latest - environment: - ENAPTER_VUCM_BLOB: "REALENAPTERVUCMBLOBMUSTBEHERE=" - ENAPTER_SNMP_HOST: "192.168.192.192" - ENAPTER_SNMP_PORT: "161" - ENAPTER_SNMP_COMMUNITY: "public" -``` - -## Step 4. Build Docker Image with Standalone UCM - -> You can you can skip this step and go directly to th Step 5. -> Docker Compose will automatically build your image before starting container. - -Build your Docker image by running `bash docker_build.sh` command in directory with UCM. - -```bash -user@pc snmp-eaton-ups % bash docker_build.sh -#0 building with "desktop-linux" instance using docker driver - -#1 [internal] load .dockerignore -#1 transferring context: 2B done -#1 DONE 0.0s - -#2 [internal] load build definition from Dockerfile -#2 transferring dockerfile: 281B done -#2 DONE 0.0s - -#3 [internal] load metadata for docker.io/library/python:3.10-alpine3.16 -#3 DONE 2.0s - -#4 [1/7] FROM docker.io/library/python:3.10-alpine3.16@sha256:afe68972cc00883d70b3760ee0ffbb7375cf09706c122dda7063ffe64c5be21b -#4 DONE 0.0s - -#5 [internal] load build context -#5 transferring context: 66B done -#5 DONE 0.0s - -#6 [3/7] RUN apk add build-base -#6 CACHED - -#7 [2/7] WORKDIR /app -#7 CACHED - -#8 [4/7] RUN python -m venv .venv -#8 CACHED - -#9 [5/7] COPY requirements.txt requirements.txt -#9 CACHED - -#10 [6/7] RUN .venv/bin/pip install -r requirements.txt -#10 CACHED - -#11 [7/7] COPY script.py script.py -#11 CACHED - -#12 exporting to image -#12 exporting layers done -#12 writing image sha256:92e1050debeabaff5837c6ca5bc26b0b966d09fc6f24e21b1d10cbb2f4d9aeec done -#12 naming to docker.io/enapter-vucm-examples/snmp-eaton-ups:latest done -#12 DONE 0.0s -``` - -Your `enapter-vucm-examples/snmp-eaton-ups` image is now built and you can see it by running `docker images` command: - -```bash -user@pc snmp-eaton-ups % docker images enapter-vucm-examples/snmp-eaton-ups -REPOSITORY TAG IMAGE ID CREATED SIZE -enapter-vucm-examples/snmp-eaton-ups latest 92e1050debea 5 hours ago 285MB -``` - -## Step 5. Run your Standalone UCM Docker Container - -Finally run your Standalone UCM with `docker-compose up` command: - -```bash -user@pc snmp-eaton-ups % docker-compose up -[+] Running 1/0 - ✔ Container snmp-eaton-ups-snmp-eaton-ups-ucm-1 Created 0.0s -Attaching to snmp-eaton-ups-snmp-eaton-ups-ucm-1 -snmp-eaton-ups-snmp-eaton-ups-ucm-1 | {"time": "2023-07-20T15:50:01.570744", "level": "INFO", "name": "enapter.mqtt.client", "host": "10.1.1.47", "port": 8883, "message": "starting"} -snmp-eaton-ups-snmp-eaton-ups-ucm-1 | {"time": "2023-07-20T15:50:21.776037", "level": "INFO", "name": "enapter.mqtt.client", "host": "10.1.1.47", "port": 8883, "message": "client ready"} -``` - -On this step you can check that your UCM is now Online in the Cloud. diff --git a/examples/vucm/wttr-in/requirements.txt b/examples/vucm/wttr-in/requirements.txt deleted file mode 100644 index 5fbe5b6..0000000 --- a/examples/vucm/wttr-in/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -enapter==0.11.4 -python-weather==0.4.2 diff --git a/examples/vucm/wttr-in/script.py b/examples/vucm/wttr-in/script.py deleted file mode 100644 index 8b830bf..0000000 --- a/examples/vucm/wttr-in/script.py +++ /dev/null @@ -1,54 +0,0 @@ -import asyncio -import functools -import os - -import python_weather - -import enapter - - -async def main(): - async with python_weather.Client() as client: - device_factory = functools.partial( - WttrIn, - client=client, - location=os.environ["WTTR_IN_LOCATION"], - ) - await enapter.vucm.run(device_factory) - - -class WttrIn(enapter.vucm.Device): - def __init__(self, client, location, **kwargs): - super().__init__(**kwargs) - self.client = client - self.location = location - - @enapter.vucm.device_task - async def properties_sender(self): - while True: - await self.send_properties( - { - "location": self.location, - } - ) - await asyncio.sleep(10) - - @enapter.vucm.device_task - async def telemetry_sender(self): - while True: - try: - weather = await self.client.get(self.location) - await self.send_telemetry( - { - "temperature": weather.current.temperature, - } - ) - self.alerts.clear() - except Exception as e: - self.alerts.add("wttr_in_error") - await self.log.error(f"failed to get weather: {e}") - await asyncio.sleep(1) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/vucm/zhimi-fan-za5/manifest.yml b/examples/vucm/zhimi-fan-za5/manifest.yml deleted file mode 100644 index 328d1ec..0000000 --- a/examples/vucm/zhimi-fan-za5/manifest.yml +++ /dev/null @@ -1,71 +0,0 @@ -blueprint_spec: "device/1.0" - -display_name: "Smartmi Standing Fan 3" - -communication_module: - product: ENP-VIRTUAL - -command_groups: - commands: - display_name: Commands - -properties: {} - -telemetry: - humidity: - display_name: Humidity - type: integer - enum: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100] - temperature: - display_name: Temperature - type: float - "on": - display_name: "On" - type: boolean - mode: - display_name: Mode - type: string - enum: - - normal - - nature - buzzer: - display_name: Buzzer - type: boolean - speed: - display_name: Speed - type: integer - enum: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100] - -commands: - power: - display_name: Power - group: commands - arguments: - "on": - display_name: "On" - type: boolean - mode: - display_name: Mode - group: commands - arguments: - mode: - display_name: Mode - type: string - enum: - - normal - - nature - buzzer: - display_name: Buzzer - group: commands - arguments: - "on": - display_name: "On" - type: boolean - speed: - display_name: Speed - group: commands - arguments: - speed: - display_name: Speed - type: integer - enum: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100] diff --git a/examples/vucm/zhimi-fan-za5/script.py b/examples/vucm/zhimi-fan-za5/script.py deleted file mode 100644 index 54ab746..0000000 --- a/examples/vucm/zhimi-fan-za5/script.py +++ /dev/null @@ -1,59 +0,0 @@ -import asyncio -import functools -import os - -import miio - -import enapter - - -async def main(): - device_factory = functools.partial( - ZhimiFanZA5, - ip=os.environ["MIIO_IP"], - token=os.environ["MIIO_TOKEN"], - ) - await enapter.vucm.run(device_factory) - - -class ZhimiFanZA5(enapter.vucm.Device): - def __init__(self, ip, token, **kwargs): - super().__init__(**kwargs) - self.fan = miio.FanZA5(ip=ip, token=token) - - @enapter.vucm.device_command - async def power(self, on: bool = False): - return await self.run_in_thread(self.fan.on if on else self.fan.off) - - @enapter.vucm.device_command - async def mode(self, mode: str): - miio_mode = miio.fan_common.OperationMode(mode) - return await self.run_in_thread(self.fan.set_mode, miio_mode) - - @enapter.vucm.device_command - async def buzzer(self, on: bool = False): - return await self.run_in_thread(self.fan.set_buzzer, on) - - @enapter.vucm.device_command - async def speed(self, speed: int): - return await self.run_in_thread(self.fan.set_speed, speed) - - @enapter.vucm.device_task - async def telemetry_sender(self): - while True: - status = await self.run_in_thread(self.fan.status) - await self.send_telemetry( - { - "humidity": status.humidity, - "temperature": status.temperature, - "on": status.is_on, - "mode": status.mode.value, - "buzzer": status.buzzer, - "speed": status.speed, - } - ) - await asyncio.sleep(1) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/vucm/zigbee2mqtt/Dockerfile b/examples/vucm/zigbee2mqtt/Dockerfile deleted file mode 100644 index d4e42b0..0000000 --- a/examples/vucm/zigbee2mqtt/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM python:3.10-alpine3.16 - -WORKDIR /app - -RUN apk add build-base - -RUN python -m venv .venv -COPY requirements.txt requirements.txt -RUN .venv/bin/pip install -r requirements.txt - -COPY script.py script.py - -CMD [".venv/bin/python", "script.py"] diff --git a/examples/vucm/zigbee2mqtt/README.md b/examples/vucm/zigbee2mqtt/README.md deleted file mode 100644 index 0a15552..0000000 --- a/examples/vucm/zigbee2mqtt/README.md +++ /dev/null @@ -1,141 +0,0 @@ -# Zigbee Sensor (MQTT) - -This example describes the implementation of the [Standalone UCM](https://handbook.enapter.com/software/virtual_ucm/) concept using the opensource [Enapter python-sdk](https://github.com/Enapter/python-sdk) for monitoring Zigbee Sensor via MQTT protocol (Zigbee2Mqtt). - -In order to use this UCM you need to have [Zigbee2MQTT](https://www.zigbee2mqtt.io/guide/installation/) and some MQTT broker (for example [Mosquitto](https://mosquitto.org)) running. - -As an example in this guide we will use the following dummy settings for configuration: - -MQTT Broker Address: 192.168.192.190 - -MQTT Broker Port: 9883 - -MQTT User: mqtt_user - -MQTT Password: mqtt_password - -Device MQTT topic: zigbee2mqtt/MyDevice - -## Requirements - -It is recommended to run this UCM using Docker and Docker Compose. This will ensure that environment is correct. - -The MQTT broker must be reachable from the computer where the Docker Container will be running. - -## Step 1. Create Standalone UCM in Enapter Cloud - -Log in to the Enapter Cloud, navigate to the Site where you want to create Standalone UCM and click on `Add new` button in the Standalone Device section. - -After creating Standalone UCM, you need to Generate and save Configuration string also known as ENAPTER_VUCM_BLOB as well as save UCM ID which will be needed for the next step - -More information you can find on [this page](https://developers.enapter.com/docs/tutorial/software-ucms/standalone). - -## Step 2. Upload Blueprint into the Cloud - -The general case [Enapter Blueprint](https://marketplace.enapter.com/about) consists of two files - declaration in YAML format (manifest.yaml) and logic written in Lua. Howerver for this case the logic is written in Python as Lua implementation doesn't have SNMP integration. - -But for both cases we need to tell Enapter Cloud which telemetry we are going to send and store and how to name it. - -The easiest way to do that - using [Enapter CLI](https://github.com/Enapter/enapter-cli) to upload manifest.yaml into Cloud. The other option is to use [Web IDE](https://developers.enapter.com/docs/tutorial/uploading-blueprint). - -```bash -user@pc zigbee2mqtt % enapter devices upload --blueprint-dir . --hardware-id REAL_UCM_ID -upload started with operation id 25721 -[#25721] 2023-07-20T16:27:33Z [INFO] Started uploading blueprint[id=dcb05efe-1618-4b01-877b-6105960690bc] on device[hardware_id=REAL_UCM_ID] -[#25721] 2023-07-20T16:27:33Z [INFO] Generating configuration for uploading -[#25721] 2023-07-20T16:27:33Z [INFO] Updating configuration in the cloud platform -[#25721] 2023-07-20T16:27:33Z [INFO] Updating configuration on the gateway -[#25721] 2023-07-20T16:27:35Z [INFO] Uploading blueprint finished successfully -Done! -``` - -## Step 3. Configuring Standalone UCM - -Open `docker-compose.yaml` in any editor. - -Set environment variables according to your configuration settings. With dummy settings your file will look like this: - -```yaml -version: "3" -services: - zigbee2mqtt-ucm: - build: . - image: enapter-vucm-examples/zigbee2mqtt:latest - environment: - - ENAPTER_VUCM_BLOB: "REALENAPTERVUCMBLOBMUSTBEHERE=" - - ZIGBEE_MQTT_HOST: "192.168.192.190" - - ZIGBEE_MQTT_PORT: "9883" - - ZIGBEE_MQTT_USER: "mqtt_user" - - ZIGBEE_MQTT_PASSWORD: "mqtt_password" - - ZIGBEE_MQTT_TOPIC: "zigbee2mqtt/MyDevice" - - ZIGBEE_SENSOR_MANUFACTURER: "Device Manufacturer" - - ZIGBEE_SENSOR_MODEL: "Device Model" -``` - -## Step 4. Build Docker Image with Standalone UCM - -> You can you can skip this step and go directly to th Step 5. -> Docker Compose will automatically build your image before starting container. - -Build your Docker image by running `bash docker_build.sh` command in directory with UCM. - -```bash -user@pc zigbee2mqtt % bash docker_build.sh -#0 building with "desktop-linux" instance using docker driver - -#1 [internal] load .dockerignore -#1 transferring context: 2B done -#1 DONE 0.0s - -#2 [internal] load build definition from Dockerfile -#2 transferring dockerfile: 281B done -#2 DONE 0.0s - -#3 [internal] load metadata for docker.io/library/python:3.10-alpine3.16 -#3 DONE 2.0s - -#4 [1/7] FROM docker.io/library/python:3.10-alpine3.16@sha256:afe68972cc00883d70b3760ee0ffbb7375cf09706c122dda7063ffe64c5be21b -#4 DONE 0.0s - -#5 [internal] load build context -#5 transferring context: 66B done -#5 DONE 0.0s - -#6 [3/7] RUN apk add build-base -#6 CACHED - -#7 [2/7] WORKDIR /app -#7 CACHED - -#8 [4/7] RUN python -m venv .venv -#8 CACHED - -#9 [5/7] COPY requirements.txt requirements.txt -#9 CACHED - -#10 [6/7] RUN .venv/bin/pip install -r requirements.txt -#10 CACHED - -#11 [7/7] COPY script.py script.py -#11 CACHED - -#12 exporting to image -#12 exporting layers done -#12 writing image sha256:92e1050debeabaff5837c6ca5bc26b0b966d09fc6f24e21b1d10cbb2f4d9aeec done -#12 naming to docker.io/enapter-vucm-examples/zigbee2mqtt:latest done -#12 DONE 0.0s -``` - -Your `enapter-vucm-examples/zigbee2mqtt` image is now built and you can see it by running `docker images` command: - -```bash -user@pc zigbee2mqtt % docker images enapter-vucm-examples/zigbee2mqtt -REPOSITORY TAG IMAGE ID CREATED SIZE -enapter-vucm-examples/zigbee2mqtt latest 92e1050debea 5 hours ago 285MB -``` - -## Step 5. Run your Standalone UCM Docker Container - -Finally run your Standalone UCM with `docker-compose up` command: - -On this step you can check that your UCM is now Online in the Cloud. diff --git a/examples/vucm/zigbee2mqtt/requirements.txt b/examples/vucm/zigbee2mqtt/requirements.txt deleted file mode 100644 index 5acec81..0000000 --- a/examples/vucm/zigbee2mqtt/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -enapter==0.11.4 diff --git a/setup.py b/setup.py index f7fde5d..06a7bf6 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,15 @@ import setuptools -def main(): +def main() -> None: setuptools.setup( name="enapter", version=read_version(), long_description=read_file("README.md"), long_description_content_type="text/markdown", description="Enapter Python SDK", - packages=setuptools.find_packages(), - include_package_data=True, + packages=setuptools.find_packages("src"), + package_dir={"": "src"}, url="https://github.com/Enapter/python-sdk", author="Roman Novatorov", author_email="rnovatorov@enapter.com", @@ -17,18 +17,19 @@ def main(): "aiomqtt==2.4.*", "dnspython==2.8.*", "json-log-formatter==1.1.*", + "httpx==0.28.*", ], ) -def read_version(): - with open("enapter/__init__.py") as f: - local_scope = {} +def read_version() -> str: + with open("src/enapter/__init__.py") as f: + local_scope: dict = {} exec(f.readline(), {}, local_scope) return local_scope["__version__"] -def read_file(name): +def read_file(name) -> str: with open(name) as f: return f.read() diff --git a/src/enapter/__init__.py b/src/enapter/__init__.py new file mode 100644 index 0000000..6483dc3 --- /dev/null +++ b/src/enapter/__init__.py @@ -0,0 +1,13 @@ +__version__ = "0.12.0" + +from . import async_, log, mdns, mqtt, http, standalone # isort: skip + +__all__ = [ + "__version__", + "async_", + "log", + "mdns", + "mqtt", + "http", + "standalone", +] diff --git a/enapter/async_/__init__.py b/src/enapter/async_/__init__.py similarity index 57% rename from enapter/async_/__init__.py rename to src/enapter/async_/__init__.py index 9fc5f1f..4b1a79c 100644 --- a/enapter/async_/__init__.py +++ b/src/enapter/async_/__init__.py @@ -1,7 +1,4 @@ from .generator import generator from .routine import Routine -__all__ = [ - "generator", - "Routine", -] +__all__ = ["generator", "Routine"] diff --git a/enapter/async_/generator.py b/src/enapter/async_/generator.py similarity index 100% rename from enapter/async_/generator.py rename to src/enapter/async_/generator.py diff --git a/src/enapter/async_/routine.py b/src/enapter/async_/routine.py new file mode 100644 index 0000000..ff5e9e6 --- /dev/null +++ b/src/enapter/async_/routine.py @@ -0,0 +1,37 @@ +import abc +import asyncio +import contextlib +from typing import Self + + +class Routine(abc.ABC): + + def __init__(self, task_group: asyncio.TaskGroup | None) -> None: + self._task_group = task_group + self._task: asyncio.Task | None = None + + @abc.abstractmethod + async def _run(self) -> None: + pass + + async def __aenter__(self) -> Self: + await self.start() + return self + + async def __aexit__(self, *_) -> None: + await self.stop() + + async def start(self) -> None: + if self._task is not None: + raise RuntimeError("already started") + if self._task_group is None: + self._task = asyncio.create_task(self._run()) + else: + self._task = self._task_group.create_task(self._run()) + + async def stop(self) -> None: + if self._task is None: + raise RuntimeError("not started yet") + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task diff --git a/src/enapter/http/__init__.py b/src/enapter/http/__init__.py new file mode 100644 index 0000000..b8a107a --- /dev/null +++ b/src/enapter/http/__init__.py @@ -0,0 +1,3 @@ +from . import api + +__all__ = ["api"] diff --git a/src/enapter/http/api/__init__.py b/src/enapter/http/api/__init__.py new file mode 100644 index 0000000..9495ad2 --- /dev/null +++ b/src/enapter/http/api/__init__.py @@ -0,0 +1,5 @@ +from . import devices +from .client import Client +from .config import Config + +__all__ = ["Client", "Config", "devices"] diff --git a/src/enapter/http/api/client.py b/src/enapter/http/api/client.py new file mode 100644 index 0000000..72d156b --- /dev/null +++ b/src/enapter/http/api/client.py @@ -0,0 +1,31 @@ +from typing import Self + +import httpx + +from enapter.http.api import devices + +from .config import Config + + +class Client: + + def __init__(self, config: Config) -> None: + self._config = config + self._client = self._new_client() + + def _new_client(self) -> httpx.AsyncClient: + return httpx.AsyncClient( + headers={"X-Enapter-Auth-Token": self._config.token}, + base_url=self._config.base_url, + ) + + async def __aenter__(self) -> Self: + await self._client.__aenter__() + return self + + async def __aexit__(self, *exc) -> None: + await self._client.__aexit__(*exc) + + @property + def devices(self) -> devices.Client: + return devices.Client(client=self._client) diff --git a/src/enapter/http/api/config.py b/src/enapter/http/api/config.py new file mode 100644 index 0000000..6fa5439 --- /dev/null +++ b/src/enapter/http/api/config.py @@ -0,0 +1,23 @@ +import os +from typing import MutableMapping, Self + + +class Config: + + @classmethod + def from_env( + cls, env: MutableMapping[str, str] = os.environ, namespace: str = "ENAPTER_" + ) -> Self: + prefix = namespace + "HTTP_API_" + return cls( + token=env[prefix + "TOKEN"], + base_url=env.get(prefix + "BASE_URL"), + ) + + def __init__(self, token: str, base_url: str | None = None) -> None: + if not token: + raise ValueError("token is missing") + self.token = token + if base_url is None: + base_url = "https://api.enapter.com" + self.base_url = base_url diff --git a/src/enapter/http/api/devices/__init__.py b/src/enapter/http/api/devices/__init__.py new file mode 100644 index 0000000..7316787 --- /dev/null +++ b/src/enapter/http/api/devices/__init__.py @@ -0,0 +1,21 @@ +from .authorized_role import AuthorizedRole +from .client import Client +from .communication_config import CommunicationConfig +from .device import Device +from .device_type import DeviceType +from .mqtt_credentials import MQTTCredentials +from .mqtt_protocol import MQTTProtocol +from .mqtts_credentials import MQTTSCredentials +from .time_sync_protocol import TimeSyncProtocol + +__all__ = [ + "AuthorizedRole", + "Client", + "CommunicationConfig", + "Device", + "DeviceType", + "MQTTCredentials", + "MQTTProtocol", + "MQTTSCredentials", + "TimeSyncProtocol", +] diff --git a/src/enapter/http/api/devices/authorized_role.py b/src/enapter/http/api/devices/authorized_role.py new file mode 100644 index 0000000..400c4a1 --- /dev/null +++ b/src/enapter/http/api/devices/authorized_role.py @@ -0,0 +1,11 @@ +import enum + + +class AuthorizedRole(enum.Enum): + + READONLY = "READONLY" + USER = "USER" + OWNER = "OWNER" + INSTALLER = "INSTALLER" + SYSTEM = "SYSTEM" + VENDOR = "VENDOR" diff --git a/src/enapter/http/api/devices/client.py b/src/enapter/http/api/devices/client.py new file mode 100644 index 0000000..bc262fb --- /dev/null +++ b/src/enapter/http/api/devices/client.py @@ -0,0 +1,25 @@ +import httpx + +from .communication_config import CommunicationConfig +from .device import Device +from .mqtt_protocol import MQTTProtocol + + +class Client: + + def __init__(self, client: httpx.AsyncClient) -> None: + self._client = client + + async def get(self, device_id: str) -> Device: + url = f"v3/devices/{device_id}" + response = await self._client.get(url) + response.raise_for_status() + return Device.from_dto(response.json()["device"]) + + async def generate_communication_config( + self, device_id: str, mqtt_protocol: MQTTProtocol + ) -> CommunicationConfig: + url = f"v3/devices/{device_id}/generate_config" + response = await self._client.post(url, json={"protocol": mqtt_protocol.value}) + response.raise_for_status() + return CommunicationConfig.from_dto(response.json()["config"]) diff --git a/src/enapter/http/api/devices/communication_config.py b/src/enapter/http/api/devices/communication_config.py new file mode 100644 index 0000000..670025c --- /dev/null +++ b/src/enapter/http/api/devices/communication_config.py @@ -0,0 +1,45 @@ +import dataclasses +from typing import Any, Self + +from .mqtt_credentials import MQTTCredentials +from .mqtt_protocol import MQTTProtocol +from .mqtts_credentials import MQTTSCredentials +from .time_sync_protocol import TimeSyncProtocol + + +@dataclasses.dataclass +class CommunicationConfig: + + mqtt_host: str + mqtt_port: int + mqtt_credentials: MQTTCredentials | MQTTSCredentials + mqtt_protocol: MQTTProtocol + time_sync_protocol: TimeSyncProtocol + time_sync_host: str + time_sync_port: int + hardware_id: str + channel_id: str + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + mqtt_protocol = MQTTProtocol(dto["mqtt_protocol"]) + mqtt_credentials: MQTTCredentials | MQTTSCredentials | None = None + match mqtt_protocol: + case MQTTProtocol.MQTT: + mqtt_credentials = MQTTCredentials.from_dto(dto["mqtt_credentials"]) + case MQTTProtocol.MQTTS: + mqtt_credentials = MQTTSCredentials.from_dto(dto["mqtt_credentials"]) + case _: + raise NotImplementedError(mqtt_protocol) + assert mqtt_credentials is not None + return cls( + mqtt_host=dto["mqtt_host"], + mqtt_port=int(dto["mqtt_port"]), + mqtt_credentials=mqtt_credentials, + mqtt_protocol=mqtt_protocol, + time_sync_protocol=TimeSyncProtocol(dto["time_sync_protocol"].upper()), + time_sync_host=dto["time_sync_host"], + time_sync_port=int(dto["time_sync_port"]), + hardware_id=dto["hardware_id"], + channel_id=dto["channel_id"], + ) diff --git a/src/enapter/http/api/devices/device.py b/src/enapter/http/api/devices/device.py new file mode 100644 index 0000000..95558f6 --- /dev/null +++ b/src/enapter/http/api/devices/device.py @@ -0,0 +1,32 @@ +import dataclasses +import datetime +from typing import Any, Self + +from .authorized_role import AuthorizedRole +from .device_type import DeviceType + + +@dataclasses.dataclass +class Device: + + id: str + blueprint_id: str + name: str + site_id: str + updated_at: datetime.datetime + slug: str + type: DeviceType + authorized_role: AuthorizedRole + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + return cls( + id=dto["id"], + blueprint_id=dto["blueprint_id"], + name=dto["name"], + site_id=dto["site_id"], + updated_at=datetime.datetime.fromisoformat(dto["updated_at"]), + slug=dto["slug"], + type=DeviceType(dto["type"]), + authorized_role=AuthorizedRole(dto["authorized_role"]), + ) diff --git a/src/enapter/http/api/devices/device_type.py b/src/enapter/http/api/devices/device_type.py new file mode 100644 index 0000000..25b4abf --- /dev/null +++ b/src/enapter/http/api/devices/device_type.py @@ -0,0 +1,10 @@ +import enum + + +class DeviceType(enum.Enum): + + LUA = "LUA" + VIRTUAL_UCM = "VIRTUAL_UCM" + HARDWARE_UCM = "HARDWARE_UCM" + STANDALONE = "STANDALONE" + GATEWAY = "GATEWAY" diff --git a/src/enapter/http/api/devices/mqtt_credentials.py b/src/enapter/http/api/devices/mqtt_credentials.py new file mode 100644 index 0000000..9241143 --- /dev/null +++ b/src/enapter/http/api/devices/mqtt_credentials.py @@ -0,0 +1,13 @@ +import dataclasses +from typing import Any, Self + + +@dataclasses.dataclass +class MQTTCredentials: + + username: str + password: str + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + return cls(username=dto["username"], password=dto["password"]) diff --git a/src/enapter/http/api/devices/mqtt_protocol.py b/src/enapter/http/api/devices/mqtt_protocol.py new file mode 100644 index 0000000..be24242 --- /dev/null +++ b/src/enapter/http/api/devices/mqtt_protocol.py @@ -0,0 +1,7 @@ +import enum + + +class MQTTProtocol(enum.Enum): + + MQTT = "MQTT" + MQTTS = "MQTTS" diff --git a/src/enapter/http/api/devices/mqtts_credentials.py b/src/enapter/http/api/devices/mqtts_credentials.py new file mode 100644 index 0000000..af56d82 --- /dev/null +++ b/src/enapter/http/api/devices/mqtts_credentials.py @@ -0,0 +1,18 @@ +import dataclasses +from typing import Any, Self + + +@dataclasses.dataclass +class MQTTSCredentials: + + private_key: str + certificate: str + ca_chain: str + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + return cls( + private_key=dto["private_key"], + certificate=dto["certificate"], + ca_chain=dto["ca_chain"], + ) diff --git a/src/enapter/http/api/devices/time_sync_protocol.py b/src/enapter/http/api/devices/time_sync_protocol.py new file mode 100644 index 0000000..47eef88 --- /dev/null +++ b/src/enapter/http/api/devices/time_sync_protocol.py @@ -0,0 +1,6 @@ +import enum + + +class TimeSyncProtocol(enum.Enum): + + NTP = "NTP" diff --git a/enapter/log/__init__.py b/src/enapter/log/__init__.py similarity index 100% rename from enapter/log/__init__.py rename to src/enapter/log/__init__.py diff --git a/enapter/log/json_formatter.py b/src/enapter/log/json_formatter.py similarity index 76% rename from enapter/log/json_formatter.py rename to src/enapter/log/json_formatter.py index 46b351a..a58b6db 100644 --- a/enapter/log/json_formatter.py +++ b/src/enapter/log/json_formatter.py @@ -1,17 +1,15 @@ import datetime import logging -from typing import Any, Dict +from typing import Any import json_log_formatter # type: ignore class JSONFormatter(json_log_formatter.JSONFormatter): + def json_record( - self, - message: str, - extra: Dict[str, Any], - record: logging.LogRecord, - ) -> Dict[str, Any]: + self, message: str, extra: dict[str, Any], record: logging.LogRecord + ) -> dict[str, Any]: try: del extra["taskName"] except KeyError: @@ -34,5 +32,5 @@ def json_record( return json_record - def mutate_json_record(self, json_record: Dict[str, Any]) -> Dict[str, Any]: + def mutate_json_record(self, json_record: dict[str, Any]) -> dict[str, Any]: return json_record diff --git a/enapter/mdns/__init__.py b/src/enapter/mdns/__init__.py similarity index 100% rename from enapter/mdns/__init__.py rename to src/enapter/mdns/__init__.py diff --git a/enapter/mdns/resolver.py b/src/enapter/mdns/resolver.py similarity index 99% rename from enapter/mdns/resolver.py rename to src/enapter/mdns/resolver.py index 30e3488..a4f5090 100644 --- a/enapter/mdns/resolver.py +++ b/src/enapter/mdns/resolver.py @@ -6,6 +6,7 @@ class Resolver: + def __init__(self) -> None: self._logger = LOGGER self._dns_resolver = self._new_dns_resolver() diff --git a/enapter/mqtt/__init__.py b/src/enapter/mqtt/__init__.py similarity index 69% rename from enapter/mqtt/__init__.py rename to src/enapter/mqtt/__init__.py index d1ab9b1..d2fc950 100644 --- a/enapter/mqtt/__init__.py +++ b/src/enapter/mqtt/__init__.py @@ -1,12 +1,15 @@ -from . import api from .client import Client from .config import Config, TLSConfig from .errors import Error +from .message import Message + +from . import api # isort: skip __all__ = [ "Client", "Config", "Error", + "Message", "TLSConfig", "api", ] diff --git a/src/enapter/mqtt/api/__init__.py b/src/enapter/mqtt/api/__init__.py new file mode 100644 index 0000000..5ec9f6d --- /dev/null +++ b/src/enapter/mqtt/api/__init__.py @@ -0,0 +1,21 @@ +from .command_request import CommandRequest +from .command_response import CommandResponse +from .command_state import CommandState +from .device_channel import DeviceChannel +from .log import Log +from .log_severity import LogSeverity +from .message import Message +from .properties import Properties +from .telemetry import Telemetry + +__all__ = [ + "CommandRequest", + "CommandResponse", + "DeviceChannel", + "CommandState", + "Log", + "LogSeverity", + "Message", + "Properties", + "Telemetry", +] diff --git a/src/enapter/mqtt/api/command_request.py b/src/enapter/mqtt/api/command_request.py new file mode 100644 index 0000000..aaf4a13 --- /dev/null +++ b/src/enapter/mqtt/api/command_request.py @@ -0,0 +1,38 @@ +import dataclasses +from typing import Any, Self + +from .command_response import CommandResponse +from .command_state import CommandState +from .message import Message + + +@dataclasses.dataclass +class CommandRequest(Message): + + id: str + name: str + arguments: dict[str, Any] + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + return cls( + id=dto["id"], + name=dto["name"], + arguments=dto.get("arguments", {}), + ) + + def to_dto(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "arguments": self.arguments, + } + + def new_response( + self, state: CommandState, payload: dict[str, Any] + ) -> CommandResponse: + return CommandResponse( + id=self.id, + state=state, + payload=payload, + ) diff --git a/src/enapter/mqtt/api/command_response.py b/src/enapter/mqtt/api/command_response.py new file mode 100644 index 0000000..7f62960 --- /dev/null +++ b/src/enapter/mqtt/api/command_response.py @@ -0,0 +1,28 @@ +import dataclasses +from typing import Any, Self + +from .command_state import CommandState +from .message import Message + + +@dataclasses.dataclass +class CommandResponse(Message): + + id: str + state: CommandState + payload: dict[str, Any] + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + return cls( + id=dto["id"], + state=CommandState(dto["state"]), + payload=dto.get("payload", {}), + ) + + def to_dto(self) -> dict[str, Any]: + return { + "id": self.id, + "state": self.state.value, + "payload": self.payload, + } diff --git a/src/enapter/mqtt/api/command_state.py b/src/enapter/mqtt/api/command_state.py new file mode 100644 index 0000000..2ec56ef --- /dev/null +++ b/src/enapter/mqtt/api/command_state.py @@ -0,0 +1,8 @@ +import enum + + +class CommandState(enum.Enum): + + COMPLETED = "completed" + ERROR = "error" + LOG = "log" diff --git a/src/enapter/mqtt/api/device_channel.py b/src/enapter/mqtt/api/device_channel.py new file mode 100644 index 0000000..8e2feb6 --- /dev/null +++ b/src/enapter/mqtt/api/device_channel.py @@ -0,0 +1,68 @@ +import logging +from typing import AsyncContextManager, AsyncGenerator + +from enapter import async_, mqtt + +from .command_request import CommandRequest +from .command_response import CommandResponse +from .log import Log +from .properties import Properties +from .telemetry import Telemetry + +LOGGER = logging.getLogger(__name__) + + +class DeviceChannel: + + def __init__(self, client: mqtt.Client, hardware_id: str, channel_id: str) -> None: + self._client = client + self._logger = self._new_logger(hardware_id, channel_id) + self._hardware_id = hardware_id + self._channel_id = channel_id + + @property + def hardware_id(self) -> str: + return self._hardware_id + + @property + def channel_id(self) -> str: + return self._channel_id + + @staticmethod + def _new_logger(hardware_id: str, channel_id: str) -> logging.LoggerAdapter: + extra = {"hardware_id": hardware_id, "channel_id": channel_id} + return logging.LoggerAdapter(LOGGER, extra=extra) + + @async_.generator + async def subscribe_to_command_requests( + self, + ) -> AsyncGenerator[CommandRequest, None]: + async with self._subscribe("v1/command/requests") as messages: + async for msg in messages: + assert isinstance(msg.payload, str) or isinstance(msg.payload, bytes) + yield CommandRequest.from_json(msg.payload) + + async def publish_command_response(self, response: CommandResponse) -> None: + await self._publish("v1/command/responses", response.to_json()) + + async def publish_telemetry(self, telemetry: Telemetry, **kwargs) -> None: + await self._publish("v1/telemetry", telemetry.to_json(), **kwargs) + + async def publish_properties(self, properties: Properties, **kwargs) -> None: + await self._publish("v1/register", properties.to_json(), **kwargs) + + async def publish_log(self, log: Log, **kwargs) -> None: + await self._publish("v3/logs", log.to_json(), **kwargs) + + def _subscribe( + self, path: str + ) -> AsyncContextManager[AsyncGenerator[mqtt.Message, None]]: + topic = f"v1/to/{self._hardware_id}/{self._channel_id}/{path}" + return self._client.subscribe(topic) + + async def _publish(self, path: str, payload: str, **kwargs) -> None: + topic = f"v1/from/{self._hardware_id}/{self._channel_id}/{path}" + try: + await self._client.publish(topic, payload, **kwargs) + except Exception as e: + self._logger.error("failed to publish %s: %r", path, e) diff --git a/src/enapter/mqtt/api/log.py b/src/enapter/mqtt/api/log.py new file mode 100644 index 0000000..7a9eff7 --- /dev/null +++ b/src/enapter/mqtt/api/log.py @@ -0,0 +1,31 @@ +import dataclasses +from typing import Any, Self + +from .log_severity import LogSeverity +from .message import Message + + +@dataclasses.dataclass +class Log(Message): + + timestamp: int + message: str + severity: LogSeverity + persist: bool + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + return cls( + timestamp=dto["timestamp"], + message=dto["message"], + severity=LogSeverity(dto["severity"]), + persist=dto["persist"], + ) + + def to_dto(self) -> dict[str, Any]: + return { + "timestamp": self.timestamp, + "message": self.message, + "severity": self.severity.value, + "persist": self.persist, + } diff --git a/enapter/mqtt/api/log_severity.py b/src/enapter/mqtt/api/log_severity.py similarity index 99% rename from enapter/mqtt/api/log_severity.py rename to src/enapter/mqtt/api/log_severity.py index 04743c1..ff554de 100644 --- a/enapter/mqtt/api/log_severity.py +++ b/src/enapter/mqtt/api/log_severity.py @@ -2,6 +2,7 @@ class LogSeverity(enum.Enum): + DEBUG = "debug" INFO = "info" WARNING = "warning" diff --git a/src/enapter/mqtt/api/message.py b/src/enapter/mqtt/api/message.py new file mode 100644 index 0000000..b55085c --- /dev/null +++ b/src/enapter/mqtt/api/message.py @@ -0,0 +1,24 @@ +import abc +import json +from typing import Any, Self + + +class Message(abc.ABC): + + @classmethod + @abc.abstractmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + pass + + @abc.abstractmethod + def to_dto(self) -> dict[str, Any]: + pass + + @classmethod + def from_json(cls, data: str | bytes) -> Self: + dto = json.loads(data) + return cls.from_dto(dto) + + def to_json(self) -> str: + dto = self.to_dto() + return json.dumps(dto) diff --git a/src/enapter/mqtt/api/properties.py b/src/enapter/mqtt/api/properties.py new file mode 100644 index 0000000..64fa10b --- /dev/null +++ b/src/enapter/mqtt/api/properties.py @@ -0,0 +1,24 @@ +import dataclasses +from typing import Any, Self + +from .message import Message + + +@dataclasses.dataclass +class Properties(Message): + + timestamp: int + values: dict[str, Any] = dataclasses.field(default_factory=dict) + + def __post_init__(self) -> None: + if "timestamp" in self.values: + raise ValueError("`timestamp` is reserved") + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + timestamp = dto["timestamp"] + values = {k: v for k, v in dto.items() if k != "timestamp"} + return cls(timestamp=timestamp, values=values) + + def to_dto(self) -> dict[str, Any]: + return {"timestamp": self.timestamp, **self.values} diff --git a/src/enapter/mqtt/api/telemetry.py b/src/enapter/mqtt/api/telemetry.py new file mode 100644 index 0000000..de3c5e9 --- /dev/null +++ b/src/enapter/mqtt/api/telemetry.py @@ -0,0 +1,28 @@ +import dataclasses +from typing import Any, Self + +from .message import Message + + +@dataclasses.dataclass +class Telemetry(Message): + + timestamp: int + alerts: list[str] | None = None + values: dict[str, Any] = dataclasses.field(default_factory=dict) + + def __post_init__(self) -> None: + if "timestamp" in self.values: + raise ValueError("`timestamp` is reserved") + if "alerts" in self.values: + raise ValueError("`alerts` is reserved") + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + dto = dto.copy() + timestamp = dto.pop("timestamp") + alerts = dto.pop("alerts", None) + return cls(timestamp=timestamp, alerts=alerts, values=dto) + + def to_dto(self) -> dict[str, Any]: + return {"timestamp": self.timestamp, "alerts": self.alerts, **self.values} diff --git a/enapter/mqtt/client.py b/src/enapter/mqtt/client.py similarity index 72% rename from enapter/mqtt/client.py rename to src/enapter/mqtt/client.py index 77c8e69..ff16d83 100644 --- a/enapter/mqtt/client.py +++ b/src/enapter/mqtt/client.py @@ -3,24 +3,29 @@ import logging import ssl import tempfile -from typing import AsyncGenerator, Optional +from typing import AsyncGenerator import aiomqtt # type: ignore -import enapter +from enapter import async_, mdns from .config import Config +from .message import Message LOGGER = logging.getLogger(__name__) -class Client(enapter.async_.Routine): - def __init__(self, config: Config) -> None: +class Client(async_.Routine): + + def __init__( + self, config: Config, task_group: asyncio.TaskGroup | None = None + ) -> None: + super().__init__(task_group=task_group) self._logger = self._new_logger(config) self._config = config - self._mdns_resolver = enapter.mdns.Resolver() + self._mdns_resolver = mdns.Resolver() self._tls_context = self._new_tls_context(config) - self._publisher: Optional[aiomqtt.Client] = None + self._publisher: aiomqtt.Client | None = None self._publisher_connected = asyncio.Event() @staticmethod @@ -36,8 +41,8 @@ async def publish(self, *args, **kwargs) -> None: assert self._publisher is not None await self._publisher.publish(*args, **kwargs) - @enapter.async_.generator - async def subscribe(self, *topics: str) -> AsyncGenerator[aiomqtt.Message, None]: + @async_.generator + async def subscribe(self, *topics: str) -> AsyncGenerator[Message, None]: while True: try: async with self._connect() as subscriber: @@ -53,7 +58,6 @@ async def subscribe(self, *topics: str) -> AsyncGenerator[aiomqtt.Message, None] async def _run(self) -> None: self._logger.info("starting") - self._started.set() while True: try: async with self._connect() as publisher: @@ -69,12 +73,11 @@ async def _run(self) -> None: finally: self._publisher_connected.clear() self._publisher = None - self._logger.info("publisher disconnected") @contextlib.asynccontextmanager async def _connect(self) -> AsyncGenerator[aiomqtt.Client, None]: host = await self._maybe_resolve_mdns(self._config.host) - async with aiomqtt.Client( + async with _new_aiomqtt_client( hostname=host, port=self._config.port, username=self._config.user, @@ -85,7 +88,7 @@ async def _connect(self) -> AsyncGenerator[aiomqtt.Client, None]: yield client @staticmethod - def _new_tls_context(config: Config) -> Optional[ssl.SSLContext]: + def _new_tls_context(config: Config) -> ssl.SSLContext | None: if config.tls is None: return None @@ -119,3 +122,28 @@ async def _maybe_resolve_mdns(self, host: str) -> str: self._logger.error("failed to resolve mDNS host %r: %s", host, e) retry_interval = 5 await asyncio.sleep(retry_interval) + + +@contextlib.asynccontextmanager +async def _new_aiomqtt_client(*args, **kwargs) -> AsyncGenerator[aiomqtt.Client, None]: + """ + Creates `aiomqtt.Client` shielding `__aenter__` from cancellation. + + See: + - https://github.com/empicano/aiomqtt/issues/377 + """ + client = aiomqtt.Client(*args, **kwargs) + setup_task = asyncio.create_task(client.__aenter__()) + try: + await asyncio.shield(setup_task) + except asyncio.CancelledError as e: + await setup_task + await client.__aexit__(type(e), e, e.__traceback__) + raise + try: + yield client + except BaseException as e: + await client.__aexit__(type(e), e, e.__traceback__) + raise + else: + await client.__aexit__(None, None, None) diff --git a/src/enapter/mqtt/config.py b/src/enapter/mqtt/config.py new file mode 100644 index 0000000..5fdc4d5 --- /dev/null +++ b/src/enapter/mqtt/config.py @@ -0,0 +1,77 @@ +import os +from typing import MutableMapping, Self + + +class TLSConfig: + + @classmethod + def from_env( + cls, env: MutableMapping[str, str] = os.environ, namespace: str = "ENAPTER_" + ) -> Self | None: + prefix = namespace + "MQTT_TLS_" + + secret_key = env.get(prefix + "SECRET_KEY") + cert = env.get(prefix + "CERT") + ca_cert = env.get(prefix + "CA_CERT") + + nothing_defined = {secret_key, cert, ca_cert} == {None} + if nothing_defined: + return None + + if secret_key is None: + raise KeyError(prefix + "SECRET_KEY") + if cert is None: + raise KeyError(prefix + "CERT") + if ca_cert is None: + raise KeyError(prefix + "CA_CERT") + + def pem(value: str) -> str: + return value.replace("\\n", "\n") + + return cls(secret_key=pem(secret_key), cert=pem(cert), ca_cert=pem(ca_cert)) + + def __init__(self, secret_key: str, cert: str, ca_cert: str) -> None: + self.secret_key = secret_key + self.cert = cert + self.ca_cert = ca_cert + + +class Config: + + @classmethod + def from_env( + cls, env: MutableMapping[str, str] = os.environ, namespace: str = "ENAPTER_" + ) -> Self: + prefix = namespace + "MQTT_" + return cls( + host=env[prefix + "HOST"], + port=int(env[prefix + "PORT"]), + user=env.get(prefix + "USER", default=None), + password=env.get(prefix + "PASSWORD", default=None), + tls_config=TLSConfig.from_env(env, namespace=namespace), + ) + + def __init__( + self, + host: str, + port: int, + user: str | None = None, + password: str | None = None, + tls_config: TLSConfig | None = None, + ) -> None: + self.host = host + self.port = port + self.user = user + self.password = password + self.tls_config = tls_config + + @property + def tls(self) -> TLSConfig | None: + return self.tls_config + + def __repr__(self) -> str: + return "mqtt.Config(host=%r, port=%r, tls=%r)" % ( + self.host, + self.port, + self.tls is not None, + ) diff --git a/enapter/mqtt/errors.py b/src/enapter/mqtt/errors.py similarity index 100% rename from enapter/mqtt/errors.py rename to src/enapter/mqtt/errors.py diff --git a/src/enapter/mqtt/message.py b/src/enapter/mqtt/message.py new file mode 100644 index 0000000..c796060 --- /dev/null +++ b/src/enapter/mqtt/message.py @@ -0,0 +1,3 @@ +import aiomqtt + +Message = aiomqtt.Message diff --git a/src/enapter/py.typed b/src/enapter/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/enapter/standalone/__init__.py b/src/enapter/standalone/__init__.py new file mode 100644 index 0000000..4bfb2bb --- /dev/null +++ b/src/enapter/standalone/__init__.py @@ -0,0 +1,25 @@ +from .config import Config +from .device import Device +from .device_protocol import ( + CommandArgs, + CommandResult, + DeviceProtocol, + Log, + Properties, + Telemetry, +) +from .logger import Logger +from .run import run + +__all__ = [ + "CommandArgs", + "CommandResult", + "Config", + "Device", + "DeviceProtocol", + "Log", + "Logger", + "Properties", + "Telemetry", + "run", +] diff --git a/src/enapter/standalone/config.py b/src/enapter/standalone/config.py new file mode 100644 index 0000000..baa7301 --- /dev/null +++ b/src/enapter/standalone/config.py @@ -0,0 +1,218 @@ +import base64 +import dataclasses +import enum +import json +import logging +import os +from typing import Any, MutableMapping, Self + +from enapter import mqtt + +LOGGER = logging.getLogger(__name__) + + +@dataclasses.dataclass +class Config: + + communication_config: "CommunicationConfig" + + @property + def communication(self) -> "CommunicationConfig": + return self.communication_config + + @classmethod + def from_env( + cls, env: MutableMapping[str, str] = os.environ, namespace: str = "ENAPTER_" + ) -> Self: + communication_config = CommunicationConfig.from_env(env, namespace=namespace) + return cls(communication_config=communication_config) + + +@dataclasses.dataclass +class CommunicationConfig: + + mqtt_config: mqtt.Config + hardware_id: str + channel_id: str + ucm_needed: bool + + @property + def mqtt(self) -> mqtt.Config: + return self.mqtt_config + + @classmethod + def from_env( + cls, env: MutableMapping[str, str] = os.environ, namespace: str = "ENAPTER_" + ) -> Self: + prefix = namespace + "STANDALONE_COMMUNICATION_" + try: + blob = env[namespace + "VUCM_BLOB"] + LOGGER.warn( + "`%s` is deprecated and will be removed soon. Please use `%s`.", + namespace + "VUCM_BLOB", + prefix + "CONFIG", + ) + except KeyError: + blob = env[prefix + "CONFIG"] + config = cls.from_blob(blob) + override_mqtt_host = env.get(prefix + "OVERRIDE_MQTT_HOST") + if override_mqtt_host is not None: + config.mqtt.host = override_mqtt_host + return config + + @classmethod + def from_blob(cls, blob: str) -> Self: + dto = json.loads(base64.b64decode(blob)) + if "ucm_id" in dto: + config_v1 = CommunicationConfigV1.from_dto(dto) + return cls.from_config_v1(config_v1) + else: + config_v3 = CommunicationConfigV3.from_dto(dto) + return cls.from_config_v3(config_v3) + + @classmethod + def from_config_v1(cls, config: "CommunicationConfigV1") -> Self: + mqtt_config = mqtt.Config( + host=config.mqtt_host, + port=config.mqtt_port, + tls_config=mqtt.TLSConfig( + secret_key=config.mqtt_private_key, + cert=config.mqtt_cert, + ca_cert=config.mqtt_ca, + ), + ) + return cls( + mqtt_config=mqtt_config, + hardware_id=config.ucm_id, + channel_id=config.channel_id, + ucm_needed=True, + ) + + @classmethod + def from_config_v3(cls, config: "CommunicationConfigV3") -> Self: + mqtt_config: mqtt.Config | None = None + match config.mqtt_protocol: + case CommunicationConfigV3.MQTTProtocol.MQTT: + assert isinstance( + config.mqtt_credentials, CommunicationConfigV3.MQTTCredentials + ) + mqtt_config = mqtt.Config( + host=config.mqtt_host, + port=config.mqtt_port, + user=config.mqtt_credentials.username, + password=config.mqtt_credentials.password, + ) + case CommunicationConfigV3.MQTTProtocol.MQTTS: + assert isinstance( + config.mqtt_credentials, CommunicationConfigV3.MQTTSCredentials + ) + mqtt_config = mqtt.Config( + host=config.mqtt_host, + port=config.mqtt_port, + tls_config=mqtt.TLSConfig( + secret_key=config.mqtt_credentials.private_key, + cert=config.mqtt_credentials.certificate, + ca_cert=config.mqtt_credentials.ca_chain, + ), + ) + case _: + raise NotImplementedError(config.mqtt_protocol) + assert mqtt_config is not None + return cls( + mqtt_config=mqtt_config, + hardware_id=config.hardware_id, + channel_id=config.channel_id, + ucm_needed=False, + ) + + +@dataclasses.dataclass +class CommunicationConfigV1: + + mqtt_host: str + mqtt_port: int + mqtt_ca: str + mqtt_cert: str + mqtt_private_key: str + ucm_id: str + channel_id: str + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + return cls( + mqtt_host=dto["mqtt_host"], + mqtt_port=int(dto["mqtt_port"]), + mqtt_ca=dto["mqtt_ca"], + mqtt_cert=dto["mqtt_cert"], + mqtt_private_key=dto["mqtt_private_key"], + ucm_id=dto["ucm_id"], + channel_id=dto["channel_id"], + ) + + +@dataclasses.dataclass +class CommunicationConfigV3: + + class MQTTProtocol(enum.Enum): + + MQTT = "MQTT" + MQTTS = "MQTTS" + + @dataclasses.dataclass + class MQTTCredentials: + + username: str + password: str + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + return cls(username=dto["username"], password=dto["password"]) + + @dataclasses.dataclass + class MQTTSCredentials: + + private_key: str + certificate: str + ca_chain: str + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + return cls( + private_key=dto["private_key"], + certificate=dto["certificate"], + ca_chain=dto["ca_chain"], + ) + + mqtt_host: str + mqtt_port: int + mqtt_protocol: MQTTProtocol + mqtt_credentials: MQTTCredentials | MQTTSCredentials + hardware_id: str + channel_id: str + + @classmethod + def from_dto(cls, dto: dict[str, Any]) -> Self: + mqtt_protocol = cls.MQTTProtocol(dto["mqtt_protocol"]) + mqtt_credentials: ( + CommunicationConfigV3.MQTTCredentials + | CommunicationConfigV3.MQTTSCredentials + | None + ) = None + match mqtt_protocol: + case cls.MQTTProtocol.MQTT: + mqtt_credentials = cls.MQTTCredentials.from_dto(dto["mqtt_credentials"]) + case cls.MQTTProtocol.MQTTS: + mqtt_credentials = cls.MQTTSCredentials.from_dto( + dto["mqtt_credentials"] + ) + case _: + raise NotImplementedError(mqtt_protocol) + assert mqtt_credentials is not None + return cls( + mqtt_host=dto["mqtt_host"], + mqtt_port=int(dto["mqtt_port"]), + mqtt_credentials=mqtt_credentials, + mqtt_protocol=mqtt_protocol, + hardware_id=dto["hardware_id"], + channel_id=dto["channel_id"], + ) diff --git a/src/enapter/standalone/device.py b/src/enapter/standalone/device.py new file mode 100644 index 0000000..195273f --- /dev/null +++ b/src/enapter/standalone/device.py @@ -0,0 +1,59 @@ +import abc +import asyncio +from typing import AsyncGenerator + +from .device_protocol import CommandArgs, CommandResult, Log, Properties, Telemetry +from .logger import Logger + + +class Device(abc.ABC): + + def __init__( + self, + properties_queue_size: int = 1, + telemetry_queue_size: int = 1, + log_queue_size: int = 1, + command_prefix: str = "cmd_", + ) -> None: + self._properties_queue: asyncio.Queue[Properties] = asyncio.Queue( + properties_queue_size + ) + self._telemetry_queue: asyncio.Queue[Telemetry] = asyncio.Queue( + telemetry_queue_size + ) + self._log_queue: asyncio.Queue[Log] = asyncio.Queue(log_queue_size) + self._logger = Logger(self._log_queue) + self._command_prefix = command_prefix + + @abc.abstractmethod + async def run(self) -> None: + pass + + @property + def logger(self) -> Logger: + return self._logger + + async def send_properties(self, properties: Properties) -> None: + await self._properties_queue.put(properties.copy()) + + async def send_telemetry(self, telemetry: Telemetry) -> None: + await self._telemetry_queue.put(telemetry.copy()) + + async def stream_properties(self) -> AsyncGenerator[Properties, None]: + while True: + yield await self._properties_queue.get() + + async def stream_telemetry(self) -> AsyncGenerator[Telemetry, None]: + while True: + yield await self._telemetry_queue.get() + + async def stream_logs(self) -> AsyncGenerator[Log, None]: + while True: + yield await self._log_queue.get() + + async def execute_command(self, name: str, args: CommandArgs) -> CommandResult: + try: + command = getattr(self, self._command_prefix + name) + except AttributeError: + raise NotImplementedError() from None + return {"result": await command(**args)} diff --git a/src/enapter/standalone/device_driver.py b/src/enapter/standalone/device_driver.py new file mode 100644 index 0000000..1c42848 --- /dev/null +++ b/src/enapter/standalone/device_driver.py @@ -0,0 +1,98 @@ +import asyncio +import contextlib +import time +import traceback + +from enapter import async_, mqtt + +from .device_protocol import DeviceProtocol + + +class DeviceDriver(async_.Routine): + + def __init__( + self, + device_channel: mqtt.api.DeviceChannel, + device: DeviceProtocol, + task_group: asyncio.TaskGroup | None, + ) -> None: + super().__init__(task_group=task_group) + self._device_channel = device_channel + self._device = device + + async def _run(self) -> None: + async with asyncio.TaskGroup() as tg: + tg.create_task(self._device.run()) + tg.create_task(self._stream_properties()) + tg.create_task(self._stream_telemetry()) + tg.create_task(self._stream_logs()) + tg.create_task(self._execute_commands()) + + async def _stream_properties(self) -> None: + async with contextlib.aclosing(self._device.stream_properties()) as iterator: + async for properties in iterator: + properties = properties.copy() + timestamp = properties.pop("timestamp", int(time.time())) + await self._device_channel.publish_properties( + properties=mqtt.api.Properties( + timestamp=timestamp, values=properties + ) + ) + + async def _stream_telemetry(self) -> None: + async with contextlib.aclosing(self._device.stream_telemetry()) as iterator: + async for telemetry in iterator: + telemetry = telemetry.copy() + timestamp = telemetry.pop("timestamp", int(time.time())) + alerts = telemetry.pop("alerts", None) + await self._device_channel.publish_telemetry( + telemetry=mqtt.api.Telemetry( + timestamp=timestamp, alerts=alerts, values=telemetry + ) + ) + + async def _stream_logs(self) -> None: + async with contextlib.aclosing(self._device.stream_logs()) as iterator: + async for log in iterator: + await self._device_channel.publish_log( + log=mqtt.api.Log( + timestamp=int(time.time()), + severity=mqtt.api.LogSeverity(log.severity), + message=log.message, + persist=log.persist, + ) + ) + + async def _execute_commands(self) -> None: + async with asyncio.TaskGroup() as tg: + async with self._device_channel.subscribe_to_command_requests() as requests: + async for request in requests: + tg.create_task(self._execute_command(request)) + + async def _execute_command(self, request: mqtt.api.CommandRequest) -> None: + await self._device_channel.publish_command_response( + request.new_response( + mqtt.api.CommandState.LOG, {"message": "Executing command..."} + ) + ) + try: + payload = await self._device.execute_command( + request.name, request.arguments + ) + except NotImplementedError: + await self._device_channel.publish_command_response( + request.new_response( + mqtt.api.CommandState.ERROR, + {"message": "Command handler not implemented."}, + ) + ) + except Exception: + await self._device_channel.publish_command_response( + request.new_response( + mqtt.api.CommandState.ERROR, {"message": traceback.format_exc()} + ) + ) + else: + await self._device_channel.publish_command_response( + request.new_response(mqtt.api.CommandState.COMPLETED, payload) + ) diff --git a/src/enapter/standalone/device_protocol.py b/src/enapter/standalone/device_protocol.py new file mode 100644 index 0000000..8c25b28 --- /dev/null +++ b/src/enapter/standalone/device_protocol.py @@ -0,0 +1,33 @@ +import dataclasses +from typing import Any, AsyncGenerator, Literal, Protocol, TypeAlias + +Properties: TypeAlias = dict[str, Any] +Telemetry: TypeAlias = dict[str, Any] +CommandArgs: TypeAlias = dict[str, Any] +CommandResult: TypeAlias = dict[str, Any] + + +@dataclasses.dataclass +class Log: + + severity: Literal["debug", "info", "warning", "error"] + message: str + persist: bool + + +class DeviceProtocol(Protocol): + + async def run(self) -> None: + pass + + async def stream_properties(self) -> AsyncGenerator[Properties, None]: + yield {} + + async def stream_telemetry(self) -> AsyncGenerator[Telemetry, None]: + yield {} + + async def stream_logs(self) -> AsyncGenerator[Log, None]: + yield Log("debug", "", False) + + async def execute_command(self, name: str, args: CommandArgs) -> CommandResult: + pass diff --git a/src/enapter/standalone/logger.py b/src/enapter/standalone/logger.py new file mode 100644 index 0000000..6c179cf --- /dev/null +++ b/src/enapter/standalone/logger.py @@ -0,0 +1,21 @@ +import asyncio + +from .device_protocol import Log + + +class Logger: + + def __init__(self, queue: asyncio.Queue[Log]) -> None: + self._queue = queue + + async def debug(self, msg: str, persist: bool = False) -> None: + await self._queue.put(Log("debug", msg, persist)) + + async def info(self, msg: str, persist: bool = False) -> None: + await self._queue.put(Log("info", msg, persist)) + + async def warning(self, msg: str, persist: bool = False) -> None: + await self._queue.put(Log("warning", msg, persist)) + + async def error(self, msg: str, persist: bool = False) -> None: + await self._queue.put(Log("error", msg, persist)) diff --git a/src/enapter/standalone/run.py b/src/enapter/standalone/run.py new file mode 100644 index 0000000..4f93a32 --- /dev/null +++ b/src/enapter/standalone/run.py @@ -0,0 +1,43 @@ +import asyncio +import contextlib + +from enapter import log, mqtt + +from .config import Config +from .device_driver import DeviceDriver +from .device_protocol import DeviceProtocol +from .ucm import UCM + + +async def run(device: DeviceProtocol) -> None: + log.configure(level=log.LEVEL or "info") + config = Config.from_env() + async with contextlib.AsyncExitStack() as stack: + task_group = await stack.enter_async_context(asyncio.TaskGroup()) + mqtt_client = await stack.enter_async_context( + mqtt.Client(config=config.communication.mqtt, task_group=task_group) + ) + _ = await stack.enter_async_context( + DeviceDriver( + device_channel=mqtt.api.DeviceChannel( + client=mqtt_client, + hardware_id=config.communication.hardware_id, + channel_id=config.communication.channel_id, + ), + device=device, + task_group=task_group, + ) + ) + if config.communication.ucm_needed: + _ = await stack.enter_async_context( + DeviceDriver( + device_channel=mqtt.api.DeviceChannel( + client=mqtt_client, + hardware_id=config.communication.hardware_id, + channel_id="ucm", + ), + device=UCM(), + task_group=task_group, + ) + ) + await asyncio.Event().wait() diff --git a/src/enapter/standalone/ucm.py b/src/enapter/standalone/ucm.py new file mode 100644 index 0000000..91c0b76 --- /dev/null +++ b/src/enapter/standalone/ucm.py @@ -0,0 +1,28 @@ +import asyncio + +from .device import Device +from .device_protocol import CommandResult + + +class UCM(Device): + + async def run(self) -> None: + async with asyncio.TaskGroup() as tg: + tg.create_task(self.properties_sender()) + tg.create_task(self.telemetry_sender()) + + async def properties_sender(self) -> None: + while True: + await self.send_properties({"virtual": True, "lua_api_ver": 1}) + await asyncio.sleep(30) + + async def telemetry_sender(self) -> None: + while True: + await self.send_telemetry({}) + await asyncio.sleep(1) + + async def cmd_reboot(self, *args, **kwargs) -> CommandResult: + raise NotImplementedError + + async def cmd_upload_lua_script(self, *args, **kwargs) -> CommandResult: + raise NotImplementedError diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 800f058..89da5aa 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,3 +1,4 @@ +import asyncio import os import socket @@ -18,8 +19,9 @@ async def fixture_enapter_mqtt_client(mosquitto_container): host=ports[0]["HostIp"], port=int(ports[0]["HostPort"]), ) - async with enapter.mqtt.Client(config) as mqtt_client: - yield mqtt_client + async with asyncio.TaskGroup() as tg: + async with enapter.mqtt.Client(config, task_group=tg) as mqtt_client: + yield mqtt_client @pytest.fixture(scope="session", name="mosquitto_container") diff --git a/tests/integration/test_mqtt.py b/tests/integration/test_mqtt.py index ba1ea6c..5819a87 100644 --- a/tests/integration/test_mqtt.py +++ b/tests/integration/test_mqtt.py @@ -1,97 +1,91 @@ import asyncio -import contextlib import time -import aiomqtt - import enapter class TestClient: + async def test_sanity(self, enapter_mqtt_client): - async with contextlib.AsyncExitStack() as stack: - heartbit_sender = await stack.enter_async_context( - HeartbitSender(enapter_mqtt_client) - ) - messages = await stack.enter_async_context( - enapter_mqtt_client.subscribe(heartbit_sender.topic) - ) - msg = await messages.__anext__() - assert int(msg.payload) <= time.time() + async with asyncio.TaskGroup() as tg: + async with HeartbitSender(tg, enapter_mqtt_client) as heartbit_sender: + async with enapter_mqtt_client.subscribe( + heartbit_sender.topic + ) as messages: + msg = await messages.__anext__() + assert int(msg.payload) <= time.time() async def test_consume_after_another_subscriber_left(self, enapter_mqtt_client): - async with contextlib.AsyncExitStack() as stack: - heartbit_sender = await stack.enter_async_context( - HeartbitSender(enapter_mqtt_client) - ) - async with enapter_mqtt_client.subscribe( - heartbit_sender.topic - ) as messages_1: - msg = await messages_1.__anext__() - assert int(msg.payload) <= time.time() + async with asyncio.TaskGroup() as tg: + async with HeartbitSender(tg, enapter_mqtt_client) as heartbit_sender: async with enapter_mqtt_client.subscribe( heartbit_sender.topic - ) as messages_2: - msg = await messages_2.__anext__() + ) as messages_1: + msg = await messages_1.__anext__() + assert int(msg.payload) <= time.time() + async with enapter_mqtt_client.subscribe( + heartbit_sender.topic + ) as messages_2: + msg = await messages_2.__anext__() + assert int(msg.payload) <= time.time() + msg = await messages_1.__anext__() assert int(msg.payload) <= time.time() - msg = await messages_1.__anext__() - assert int(msg.payload) <= time.time() async def test_two_subscriptions(self, enapter_mqtt_client): - async with contextlib.AsyncExitStack() as stack: - heartbit_sender = await stack.enter_async_context( - HeartbitSender(enapter_mqtt_client) - ) - for i in range(2): + async with asyncio.TaskGroup() as tg: + async with HeartbitSender(tg, enapter_mqtt_client) as heartbit_sender: + for i in range(2): + async with enapter_mqtt_client.subscribe( + heartbit_sender.topic + ) as messages: + msg = await messages.__anext__() + assert int(msg.payload) <= time.time() + + async def test_two_subscribers(self, enapter_mqtt_client): + async with asyncio.TaskGroup() as tg: + async with HeartbitSender(tg, enapter_mqtt_client) as heartbit_sender: + async with enapter_mqtt_client.subscribe( + heartbit_sender.topic + ) as messages_1: + async with enapter_mqtt_client.subscribe( + heartbit_sender.topic + ) as messages_2: + for messages in [messages_1, messages_2]: + msg = await messages.__anext__() + assert int(msg.payload) <= time.time() + + async def test_broker_restart(self, mosquitto_container, enapter_mqtt_client): + async with asyncio.TaskGroup() as tg: + async with HeartbitSender(tg, enapter_mqtt_client) as heartbit_sender: async with enapter_mqtt_client.subscribe( heartbit_sender.topic ) as messages: msg = await messages.__anext__() assert int(msg.payload) <= time.time() - - async def test_two_subscribers(self, enapter_mqtt_client): - async with contextlib.AsyncExitStack() as stack: - heartbit_sender = await stack.enter_async_context( - HeartbitSender(enapter_mqtt_client) - ) - messages_1 = await stack.enter_async_context( - enapter_mqtt_client.subscribe(heartbit_sender.topic) - ) - messages_2 = await stack.enter_async_context( - enapter_mqtt_client.subscribe(heartbit_sender.topic) - ) - for messages in [messages_1, messages_2]: - msg = await messages.__anext__() - assert int(msg.payload) <= time.time() - - async def test_broker_restart(self, mosquitto_container, enapter_mqtt_client): - async with contextlib.AsyncExitStack() as stack: - heartbit_sender = await stack.enter_async_context( - HeartbitSender(enapter_mqtt_client) - ) - messages = await stack.enter_async_context( - enapter_mqtt_client.subscribe(heartbit_sender.topic) - ) - msg = await messages.__anext__() - assert int(msg.payload) <= time.time() - mosquitto_container.restart() - msg = await messages.__anext__() - assert int(msg.payload) <= time.time() + mosquitto_container.restart() + msg = await messages.__anext__() + assert int(msg.payload) <= time.time() class HeartbitSender(enapter.async_.Routine): - def __init__(self, enapter_mqtt_client, topic="heartbits", interval=0.5): + + def __init__( + self, + task_group: asyncio.TaskGroup, + enapter_mqtt_client, + topic="heartbits", + interval=0.5, + ): + super().__init__(task_group=task_group) self.enapter_mqtt_client = enapter_mqtt_client self.topic = topic self.interval = interval async def _run(self): - self._started.set() - while True: payload = str(int(time.time())) try: await self.enapter_mqtt_client.publish(self.topic, payload) - except aiomqtt.MqttError as e: + except enapter.mqtt.Error as e: print(f"failed to publish heartbit: {e}") await asyncio.sleep(self.interval) diff --git a/tests/unit/test_async.py b/tests/unit/test_async.py index 9b16fbb..8458d25 100644 --- a/tests/unit/test_async.py +++ b/tests/unit/test_async.py @@ -1,11 +1,10 @@ -import asyncio - import pytest import enapter class TestGenerator: + async def test_aclose(self): @enapter.async_.generator async def agen(): @@ -19,134 +18,3 @@ async def agen(): with pytest.raises(StopAsyncIteration): await g.__anext__() - - -class TestRoutine: - async def test_run_not_implemented(self): - class R(enapter.async_.Routine): - pass - - with pytest.raises(TypeError): - R() - - async def test_context_manager(self): - done = asyncio.Event() - - class R(enapter.async_.Routine): - async def _run(self): - self._started.set() - await asyncio.sleep(0) - done.set() - - async with R(): - await asyncio.sleep(0) - - await asyncio.wait_for(done.wait(), 1) - - async def test_task_getter(self): - can_exit = asyncio.Event() - - class R(enapter.async_.Routine): - async def _run(self): - self._started.set() - await asyncio.wait_for(can_exit.wait(), 1) - - async with R() as r: - assert not r.task().done() - can_exit.set() - - assert r.task().done() - - async def test_started_fine(self): - done = asyncio.Event() - - class R(enapter.async_.Routine): - async def _run(self): - await asyncio.sleep(0) - self._started.set() - await asyncio.sleep(0) - done.set() - await asyncio.sleep(10) - - r = R() - await r.start(cancel_parent_task_on_exception=False) - try: - await asyncio.wait_for(done.wait(), 1) - finally: - await r.stop() - - async def test_finished_after_started(self): - class R(enapter.async_.Routine): - async def _run(self): - self._started.set() - await asyncio.sleep(0) - - r = R() - await r.start(cancel_parent_task_on_exception=False) - await asyncio.wait_for(r.join(), 1) - - async def test_finished_before_started(self): - class R(enapter.async_.Routine): - async def _run(self): - await asyncio.sleep(0) - - r = R() - await r.start(cancel_parent_task_on_exception=False) - await asyncio.wait_for(r.join(), 1) - - async def test_failed_before_started(self): - class R(enapter.async_.Routine): - async def _run(self): - await asyncio.sleep(0) - raise RuntimeError() - - r = R() - with pytest.raises(RuntimeError): - await r.start(cancel_parent_task_on_exception=False) - - async def test_failed_after_started(self): - can_fail = asyncio.Event() - - class R(enapter.async_.Routine): - async def _run(self): - self._started.set() - await asyncio.wait_for(can_fail.wait(), 1) - raise RuntimeError() - - r = R() - await r.start(cancel_parent_task_on_exception=False) - can_fail.set() - with pytest.raises(RuntimeError): - await r.join() - - async def test_cancel_parent_task_on_exception_after_started(self): - can_fail = asyncio.Event() - - class R(enapter.async_.Routine): - async def _run(self): - self._started.set() - await asyncio.wait_for(can_fail.wait(), 1) - raise RuntimeError() - - r = R() - - async def parent(): - await r.start() - can_fail.set() - with pytest.raises(asyncio.CancelledError): - await asyncio.wait_for(asyncio.sleep(2), 1) - - parent_task = asyncio.create_task(parent()) - await parent_task - - with pytest.raises(RuntimeError): - await r.join() - - async def test_do_not_cancel_parent_task_on_exception_before_started(self): - class R(enapter.async_.Routine): - async def _run(self): - raise RuntimeError() - - r = R() - with pytest.raises(RuntimeError): - await r.start() diff --git a/tests/unit/test_mqtt/test_api.py b/tests/unit/test_mqtt/test_api.py index 3e8ad48..00628e5 100644 --- a/tests/unit/test_mqtt/test_api.py +++ b/tests/unit/test_mqtt/test_api.py @@ -12,26 +12,12 @@ async def test_publish_telemetry(self, fake): device_channel = enapter.mqtt.api.DeviceChannel( client=mock_client, hardware_id=hardware_id, channel_id=channel_id ) - await device_channel.publish_telemetry({"timestamp": timestamp}) - mock_client.publish.assert_called_once_with( - f"v1/from/{hardware_id}/{channel_id}/v1/telemetry", - '{"timestamp": ' + str(timestamp) + "}", - ) - - async def test_publish_telemetry_without_timestamp(self, fake): - hardware_id = fake.hardware_id() - channel_id = fake.channel_id() - timestamp = fake.timestamp() - mock_client = mock.Mock() - device_channel = enapter.mqtt.api.DeviceChannel( - client=mock_client, hardware_id=hardware_id, channel_id=channel_id + await device_channel.publish_telemetry( + enapter.mqtt.api.Telemetry(timestamp=timestamp) ) - with mock.patch("time.time") as mock_time: - mock_time.return_value = timestamp - await device_channel.publish_telemetry({}) mock_client.publish.assert_called_once_with( f"v1/from/{hardware_id}/{channel_id}/v1/telemetry", - '{"timestamp": ' + str(timestamp) + "}", + '{"timestamp": ' + str(timestamp) + ', "alerts": null}', ) async def test_publish_properties(self, fake): @@ -42,23 +28,9 @@ async def test_publish_properties(self, fake): device_channel = enapter.mqtt.api.DeviceChannel( client=mock_client, hardware_id=hardware_id, channel_id=channel_id ) - await device_channel.publish_properties({"timestamp": timestamp}) - mock_client.publish.assert_called_once_with( - f"v1/from/{hardware_id}/{channel_id}/v1/register", - '{"timestamp": ' + str(timestamp) + "}", - ) - - async def test_publish_properties_without_timestamp(self, fake): - hardware_id = fake.hardware_id() - channel_id = fake.channel_id() - timestamp = fake.timestamp() - mock_client = mock.Mock() - device_channel = enapter.mqtt.api.DeviceChannel( - client=mock_client, hardware_id=hardware_id, channel_id=channel_id + await device_channel.publish_properties( + enapter.mqtt.api.Properties(timestamp=timestamp) ) - with mock.patch("time.time") as mock_time: - mock_time.return_value = timestamp - await device_channel.publish_properties({}) mock_client.publish.assert_called_once_with( f"v1/from/{hardware_id}/{channel_id}/v1/register", '{"timestamp": ' + str(timestamp) + "}", diff --git a/tests/unit/test_vucm.py b/tests/unit/test_vucm.py deleted file mode 100644 index aee4929..0000000 --- a/tests/unit/test_vucm.py +++ /dev/null @@ -1,118 +0,0 @@ -import asyncio -import contextlib - -import enapter - - -class TestDevice: - async def test_run_in_thread(self, fake): - async with enapter.vucm.Device(channel=MockChannel(fake)) as device: - assert not device._Device__thread_pool_executor._shutdown - assert await device.run_in_thread(lambda: 42) == 42 - assert device._Device__thread_pool_executor._shutdown - - async def test_task_marks(self, fake): - class MyDevice(enapter.vucm.Device): - async def task_foo(self): - pass - - @enapter.vucm.device_task - async def task_bar(self): - pass - - @enapter.vucm.device_task - async def baz(self): - pass - - @enapter.vucm.device_task - async def goo(self): - pass - - async with MyDevice(channel=MockChannel(fake)) as device: - tasks = device._Device__tasks - assert len(tasks) == 3 - assert "task_foo" not in tasks - assert "task_bar" in tasks - assert "baz" in tasks - assert "goo" in tasks - - async def test_command_marks(self, fake): - class MyDevice(enapter.vucm.Device): - async def cmd_foo(self): - pass - - async def cmd_foo2(self, a, b, c): - pass - - @enapter.vucm.device_command - async def cmd_bar(self): - pass - - @enapter.vucm.device_command - async def cmd_bar2(self, a, b, c): - pass - - @enapter.vucm.device_command - async def baz(self): - pass - - @enapter.vucm.device_command - async def baz2(self, a, b, c): - pass - - @enapter.vucm.device_command - async def goo(self): - pass - - async with MyDevice(channel=MockChannel(fake)) as device: - commands = device._Device__commands - assert len(commands) == 5 - assert "cmd_foo" not in commands - assert "cmd_foo2" not in commands - assert "cmd_bar" in commands - assert "cmd_bar2" in commands - assert "baz" in commands - assert "baz2" in commands - assert "goo" in commands - - async def test_task_and_commands_marks(self, fake): - class MyDevice(enapter.vucm.Device): - @enapter.vucm.device_task - async def foo_task(self): - pass - - @enapter.vucm.device_task - async def bar_task(self): - pass - - @enapter.vucm.device_command - async def foo_command(self): - pass - - @enapter.vucm.device_command - async def bar_command(self): - pass - - async with MyDevice(channel=MockChannel(fake)) as device: - tasks = device._Device__tasks - assert len(tasks) == 2 - assert "foo_task" in tasks - assert "bar_task" in tasks - assert "foo_command" not in tasks - assert "bar_command" not in tasks - commands = device._Device__commands - assert "foo_task" not in commands - assert "bar_task" not in commands - assert "foo_command" in commands - assert "bar_command" in commands - - -class MockChannel: - def __init__(self, fake): - self.hardware_id = fake.hardware_id() - self.channel_id = fake.channel_id() - - @contextlib.asynccontextmanager - async def subscribe_to_command_requests(self, *args, **kwargs): - await asyncio.Event().wait() - yield