From 3d51669d2c1d8f816f8894b8e69265939acfcabe Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Thu, 2 Oct 2025 09:10:02 +0200 Subject: [PATCH 01/43] move `DeviceChannel` from `mqtt.api` to `mqtt` --- enapter/mqtt/__init__.py | 2 ++ enapter/mqtt/api/__init__.py | 2 -- enapter/mqtt/{api => }/device_channel.py | 15 +++++++-------- enapter/vucm/app.py | 4 ++-- enapter/vucm/device.py | 2 +- enapter/vucm/ucm.py | 2 +- .../unit/{test_mqtt/test_api.py => test_mqtt.py} | 8 ++++---- tests/unit/test_mqtt/__init__.py | 0 8 files changed, 17 insertions(+), 18 deletions(-) rename enapter/mqtt/{api => }/device_channel.py (84%) rename tests/unit/{test_mqtt/test_api.py => test_mqtt.py} (91%) delete mode 100644 tests/unit/test_mqtt/__init__.py diff --git a/enapter/mqtt/__init__.py b/enapter/mqtt/__init__.py index d1ab9b1..4bf288d 100644 --- a/enapter/mqtt/__init__.py +++ b/enapter/mqtt/__init__.py @@ -1,11 +1,13 @@ from . import api from .client import Client from .config import Config, TLSConfig +from .device_channel import DeviceChannel from .errors import Error __all__ = [ "Client", "Config", + "DeviceChannel", "Error", "TLSConfig", "api", diff --git a/enapter/mqtt/api/__init__.py b/enapter/mqtt/api/__init__.py index ef3169d..2be623a 100644 --- a/enapter/mqtt/api/__init__.py +++ b/enapter/mqtt/api/__init__.py @@ -1,11 +1,9 @@ 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/device_channel.py b/enapter/mqtt/device_channel.py similarity index 84% rename from enapter/mqtt/api/device_channel.py rename to enapter/mqtt/device_channel.py index 3c6fd27..c7de3e9 100644 --- a/enapter/mqtt/api/device_channel.py +++ b/enapter/mqtt/device_channel.py @@ -7,9 +7,8 @@ import enapter -from ..client import Client -from .command import CommandRequest, CommandResponse -from .log_severity import LogSeverity +from . import api +from .client import Client LOGGER = logging.getLogger(__name__) @@ -30,20 +29,20 @@ def channel_id(self) -> str: return self._channel_id @staticmethod - def _new_logger(hardware_id, channel_id) -> logging.LoggerAdapter: + 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) @enapter.async_.generator async def subscribe_to_command_requests( self, - ) -> AsyncGenerator[CommandRequest, None]: + ) -> AsyncGenerator[api.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) + yield api.CommandRequest.unmarshal_json(msg.payload) - async def publish_command_response(self, resp: CommandResponse) -> None: + async def publish_command_response(self, resp: api.CommandResponse) -> None: await self._publish_json("v1/command/responses", resp.json()) async def publish_telemetry(self, telemetry: Dict[str, Any], **kwargs) -> None: @@ -53,7 +52,7 @@ 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 + self, msg: str, severity: api.LogSeverity, persist: bool = False, **kwargs ) -> None: logs = { "message": msg, diff --git a/enapter/vucm/app.py b/enapter/vucm/app.py index 99098e5..8706423 100644 --- a/enapter/vucm/app.py +++ b/enapter/vucm/app.py @@ -10,7 +10,7 @@ class DeviceFactory(Protocol): - def __call__(self, channel: enapter.mqtt.api.DeviceChannel, **kwargs) -> Device: + def __call__(self, channel: enapter.mqtt.DeviceChannel, **kwargs) -> Device: pass @@ -46,7 +46,7 @@ async def _run(self) -> None: device = await self._stack.enter_async_context( self._device_factory( - channel=enapter.mqtt.api.DeviceChannel( + channel=enapter.mqtt.DeviceChannel( client=mqtt_client, hardware_id=self._config.hardware_id, channel_id=self._config.channel_id, diff --git a/enapter/vucm/device.py b/enapter/vucm/device.py index 2aa39bf..20fe783 100644 --- a/enapter/vucm/device.py +++ b/enapter/vucm/device.py @@ -35,7 +35,7 @@ def is_device_command(func: DeviceCommandFunc) -> bool: class Device(enapter.async_.Routine): def __init__( - self, channel: enapter.mqtt.api.DeviceChannel, thread_pool_workers: int = 1 + self, channel: enapter.mqtt.DeviceChannel, thread_pool_workers: int = 1 ) -> None: self.__channel = channel diff --git a/enapter/vucm/ucm.py b/enapter/vucm/ucm.py index 9099e27..3d08ac0 100644 --- a/enapter/vucm/ucm.py +++ b/enapter/vucm/ucm.py @@ -8,7 +8,7 @@ class UCM(Device): def __init__(self, mqtt_client, hardware_id) -> None: super().__init__( - channel=enapter.mqtt.api.DeviceChannel( + channel=enapter.mqtt.DeviceChannel( client=mqtt_client, hardware_id=hardware_id, channel_id="ucm" ) ) diff --git a/tests/unit/test_mqtt/test_api.py b/tests/unit/test_mqtt.py similarity index 91% rename from tests/unit/test_mqtt/test_api.py rename to tests/unit/test_mqtt.py index 3e8ad48..196dd6b 100644 --- a/tests/unit/test_mqtt/test_api.py +++ b/tests/unit/test_mqtt.py @@ -9,7 +9,7 @@ async def test_publish_telemetry(self, fake): channel_id = fake.channel_id() timestamp = fake.timestamp() mock_client = mock.Mock() - device_channel = enapter.mqtt.api.DeviceChannel( + device_channel = enapter.mqtt.DeviceChannel( client=mock_client, hardware_id=hardware_id, channel_id=channel_id ) await device_channel.publish_telemetry({"timestamp": timestamp}) @@ -23,7 +23,7 @@ async def test_publish_telemetry_without_timestamp(self, fake): channel_id = fake.channel_id() timestamp = fake.timestamp() mock_client = mock.Mock() - device_channel = enapter.mqtt.api.DeviceChannel( + device_channel = enapter.mqtt.DeviceChannel( client=mock_client, hardware_id=hardware_id, channel_id=channel_id ) with mock.patch("time.time") as mock_time: @@ -39,7 +39,7 @@ async def test_publish_properties(self, fake): channel_id = fake.channel_id() timestamp = fake.timestamp() mock_client = mock.Mock() - device_channel = enapter.mqtt.api.DeviceChannel( + device_channel = enapter.mqtt.DeviceChannel( client=mock_client, hardware_id=hardware_id, channel_id=channel_id ) await device_channel.publish_properties({"timestamp": timestamp}) @@ -53,7 +53,7 @@ async def test_publish_properties_without_timestamp(self, fake): channel_id = fake.channel_id() timestamp = fake.timestamp() mock_client = mock.Mock() - device_channel = enapter.mqtt.api.DeviceChannel( + device_channel = enapter.mqtt.DeviceChannel( client=mock_client, hardware_id=hardware_id, channel_id=channel_id ) with mock.patch("time.time") as mock_time: diff --git a/tests/unit/test_mqtt/__init__.py b/tests/unit/test_mqtt/__init__.py deleted file mode 100644 index e69de29..0000000 From 7a254c853d9226d573700a153e20215991318a0e Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Thu, 2 Oct 2025 14:34:14 +0200 Subject: [PATCH 02/43] mqtt: export `Message` --- enapter/mqtt/__init__.py | 2 ++ enapter/mqtt/client.py | 3 ++- enapter/mqtt/device_channel.py | 5 ++--- enapter/mqtt/message.py | 3 +++ 4 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 enapter/mqtt/message.py diff --git a/enapter/mqtt/__init__.py b/enapter/mqtt/__init__.py index 4bf288d..d711365 100644 --- a/enapter/mqtt/__init__.py +++ b/enapter/mqtt/__init__.py @@ -3,12 +3,14 @@ from .config import Config, TLSConfig from .device_channel import DeviceChannel from .errors import Error +from .message import Message __all__ = [ "Client", "Config", "DeviceChannel", "Error", + "Message", "TLSConfig", "api", ] diff --git a/enapter/mqtt/client.py b/enapter/mqtt/client.py index 77c8e69..005bc7b 100644 --- a/enapter/mqtt/client.py +++ b/enapter/mqtt/client.py @@ -10,6 +10,7 @@ import enapter from .config import Config +from .message import Message LOGGER = logging.getLogger(__name__) @@ -37,7 +38,7 @@ async def publish(self, *args, **kwargs) -> None: await self._publisher.publish(*args, **kwargs) @enapter.async_.generator - async def subscribe(self, *topics: str) -> AsyncGenerator[aiomqtt.Message, None]: + async def subscribe(self, *topics: str) -> AsyncGenerator[Message, None]: while True: try: async with self._connect() as subscriber: diff --git a/enapter/mqtt/device_channel.py b/enapter/mqtt/device_channel.py index c7de3e9..434136c 100644 --- a/enapter/mqtt/device_channel.py +++ b/enapter/mqtt/device_channel.py @@ -3,12 +3,11 @@ import time from typing import Any, AsyncContextManager, AsyncGenerator, Dict -import aiomqtt # type: ignore - import enapter from . import api from .client import Client +from .message import Message LOGGER = logging.getLogger(__name__) @@ -63,7 +62,7 @@ async def publish_logs( def _subscribe( self, path: str - ) -> AsyncContextManager[AsyncGenerator[aiomqtt.Message, None]]: + ) -> AsyncContextManager[AsyncGenerator[Message, None]]: topic = f"v1/to/{self._hardware_id}/{self._channel_id}/{path}" return self._client.subscribe(topic) diff --git a/enapter/mqtt/message.py b/enapter/mqtt/message.py new file mode 100644 index 0000000..c796060 --- /dev/null +++ b/enapter/mqtt/message.py @@ -0,0 +1,3 @@ +import aiomqtt + +Message = aiomqtt.Message From b880fdea8879701ac05818aff2069f78f2c32126 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Thu, 2 Oct 2025 18:04:21 +0200 Subject: [PATCH 03/43] mqtt: define api more explicitly --- enapter/mqtt/api/__init__.py | 11 +++++- enapter/mqtt/api/command.py | 51 ------------------------ enapter/mqtt/api/commands.py | 66 ++++++++++++++++++++++++++++++++ enapter/mqtt/api/log_severity.py | 8 ---- enapter/mqtt/api/logs.py | 39 +++++++++++++++++++ enapter/mqtt/api/message.py | 24 ++++++++++++ enapter/mqtt/api/properties.py | 24 ++++++++++++ enapter/mqtt/api/telemetry.py | 28 ++++++++++++++ enapter/mqtt/device_channel.py | 37 +++++------------- enapter/vucm/device.py | 35 +++++++---------- enapter/vucm/logger.py | 41 +++++++++++++------- tests/unit/test_mqtt.py | 36 ++--------------- 12 files changed, 245 insertions(+), 155 deletions(-) delete mode 100644 enapter/mqtt/api/command.py create mode 100644 enapter/mqtt/api/commands.py delete mode 100644 enapter/mqtt/api/log_severity.py create mode 100644 enapter/mqtt/api/logs.py create mode 100644 enapter/mqtt/api/message.py create mode 100644 enapter/mqtt/api/properties.py create mode 100644 enapter/mqtt/api/telemetry.py diff --git a/enapter/mqtt/api/__init__.py b/enapter/mqtt/api/__init__.py index 2be623a..b7fd645 100644 --- a/enapter/mqtt/api/__init__.py +++ b/enapter/mqtt/api/__init__.py @@ -1,9 +1,16 @@ -from .command import CommandRequest, CommandResponse, CommandState -from .log_severity import LogSeverity +from .commands import CommandRequest, CommandResponse, CommandState +from .logs import Log, LogSeverity +from .message import Message +from .properties import Properties +from .telemetry import Telemetry __all__ = [ "CommandRequest", "CommandResponse", "CommandState", + "Log", "LogSeverity", + "Message", + "Properties", + "Telemetry", ] 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/commands.py b/enapter/mqtt/api/commands.py new file mode 100644 index 0000000..291e342 --- /dev/null +++ b/enapter/mqtt/api/commands.py @@ -0,0 +1,66 @@ +import dataclasses +import enum +from typing import Any, Dict + +from .message import Message + + +class CommandState(enum.Enum): + + COMPLETED = "completed" + ERROR = "error" + + +@dataclasses.dataclass +class CommandRequest(Message): + + id: str + name: str + arguments: Dict[str, Any] + + @classmethod + def from_dto(cls, dto: Dict[str, Any]) -> "CommandRequest": + 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, + ) + + +@dataclasses.dataclass +class CommandResponse(Message): + + id: str + state: CommandState + payload: Dict[str, Any] + + @classmethod + def from_dto(cls, dto: Dict[str, Any]) -> "CommandResponse": + 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/enapter/mqtt/api/log_severity.py b/enapter/mqtt/api/log_severity.py deleted file mode 100644 index 04743c1..0000000 --- a/enapter/mqtt/api/log_severity.py +++ /dev/null @@ -1,8 +0,0 @@ -import enum - - -class LogSeverity(enum.Enum): - DEBUG = "debug" - INFO = "info" - WARNING = "warning" - ERROR = "error" diff --git a/enapter/mqtt/api/logs.py b/enapter/mqtt/api/logs.py new file mode 100644 index 0000000..4c271c4 --- /dev/null +++ b/enapter/mqtt/api/logs.py @@ -0,0 +1,39 @@ +import dataclasses +import enum +from typing import Any, Dict + +from .message import Message + + +class LogSeverity(enum.Enum): + + DEBUG = "debug" + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +@dataclasses.dataclass +class Log(Message): + + timestamp: int + message: str + severity: LogSeverity + persist: bool + + @classmethod + def from_dto(cls, dto: Dict[str, Any]) -> "Log": + 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/message.py b/enapter/mqtt/api/message.py new file mode 100644 index 0000000..7d90c2d --- /dev/null +++ b/enapter/mqtt/api/message.py @@ -0,0 +1,24 @@ +import abc +import json +from typing import Any, Dict, Union + + +class Message(abc.ABC): + + @classmethod + @abc.abstractmethod + def from_dto(cls, dto: Dict[str, Any]): + pass + + @abc.abstractmethod + def to_dto(self) -> Dict[str, Any]: + pass + + @classmethod + def from_json(cls, data: Union[str, bytes]): + 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/enapter/mqtt/api/properties.py b/enapter/mqtt/api/properties.py new file mode 100644 index 0000000..4d60bde --- /dev/null +++ b/enapter/mqtt/api/properties.py @@ -0,0 +1,24 @@ +import dataclasses +from typing import Any, Dict + +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]) -> "Properties": + 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/enapter/mqtt/api/telemetry.py b/enapter/mqtt/api/telemetry.py new file mode 100644 index 0000000..ecad791 --- /dev/null +++ b/enapter/mqtt/api/telemetry.py @@ -0,0 +1,28 @@ +import dataclasses +from typing import Any, Dict, List, Optional + +from .message import Message + + +@dataclasses.dataclass +class Telemetry(Message): + + timestamp: int + alerts: Optional[List[str]] = 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]) -> "Telemetry": + 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/device_channel.py b/enapter/mqtt/device_channel.py index 434136c..75a54c7 100644 --- a/enapter/mqtt/device_channel.py +++ b/enapter/mqtt/device_channel.py @@ -1,7 +1,5 @@ -import json import logging -import time -from typing import Any, AsyncContextManager, AsyncGenerator, Dict +from typing import AsyncContextManager, AsyncGenerator import enapter @@ -39,26 +37,19 @@ async def subscribe_to_command_requests( 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 api.CommandRequest.unmarshal_json(msg.payload) + yield api.CommandRequest.from_json(msg.payload) - async def publish_command_response(self, resp: api.CommandResponse) -> None: - await self._publish_json("v1/command/responses", resp.json()) + async def publish_command_response(self, response: api.CommandResponse) -> None: + await self._publish("v1/command/responses", response.to_json()) - async def publish_telemetry(self, telemetry: Dict[str, Any], **kwargs) -> None: - await self._publish_json("v1/telemetry", telemetry, **kwargs) + async def publish_telemetry(self, telemetry: api.Telemetry, **kwargs) -> None: + await self._publish("v1/telemetry", telemetry.to_json(), **kwargs) - async def publish_properties(self, properties: Dict[str, Any], **kwargs) -> None: - await self._publish_json("v1/register", properties, **kwargs) + async def publish_properties(self, properties: api.Properties, **kwargs) -> None: + await self._publish("v1/register", properties.to_json(), **kwargs) - async def publish_logs( - self, msg: str, severity: api.LogSeverity, persist: bool = False, **kwargs - ) -> None: - logs = { - "message": msg, - "severity": severity.value, - "persist": persist, - } - await self._publish_json("v3/logs", logs, **kwargs) + async def publish_log(self, log: api.Log, **kwargs) -> None: + await self._publish("v3/logs", log.to_json(), **kwargs) def _subscribe( self, path: str @@ -66,14 +57,6 @@ def _subscribe( 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: diff --git a/enapter/vucm/device.py b/enapter/vucm/device.py index 20fe783..50c5f78 100644 --- a/enapter/vucm/device.py +++ b/enapter/vucm/device.py @@ -1,6 +1,7 @@ import asyncio import concurrent import functools +import time import traceback from typing import Any, Callable, Coroutine, Dict, Optional, Set, Tuple @@ -58,26 +59,18 @@ def __init__( 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)) - + async def send_telemetry(self, values: Optional[Dict[str, Any]] = None) -> None: + values = values.copy() if values is not None else {} + timestamp = values.pop("timestamp", int(time.time())) + telemetry = enapter.mqtt.api.Telemetry( + timestamp=timestamp, alerts=list(self.alerts), values=values + ) 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() - + async def send_properties(self, values: Optional[Dict[str, Any]] = None) -> None: + values = values.copy() if values is not None else {} + timestamp = values.pop("timestamp", int(time.time())) + properties = enapter.mqtt.api.Properties(timestamp=timestamp, values=values) await self.__channel.publish_properties(properties) async def run_in_thread(self, func, *args, **kwargs) -> Any: @@ -132,15 +125,15 @@ async def __process_command_requests(self) -> None: await self.__channel.publish_command_response(resp) async def __execute_command( - self, req - ) -> Tuple[enapter.mqtt.api.CommandState, enapter.types.JSON]: + self, req: enapter.mqtt.api.CommandRequest + ) -> Tuple[enapter.mqtt.api.CommandState, Dict[str, Any]]: 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) + return enapter.mqtt.api.CommandState.COMPLETED, await cmd(**req.arguments) except: return enapter.mqtt.api.CommandState.ERROR, { "traceback": traceback.format_exc() diff --git a/enapter/vucm/logger.py b/enapter/vucm/logger.py index b1af474..a990b62 100644 --- a/enapter/vucm/logger.py +++ b/enapter/vucm/logger.py @@ -1,4 +1,6 @@ import logging +import time +from typing import Optional import enapter @@ -6,7 +8,8 @@ class Logger: - def __init__(self, channel) -> None: + + def __init__(self, channel: enapter.mqtt.DeviceChannel) -> None: self._channel = channel self._logger = self._new_logger(channel.hardware_id, channel.channel_id) @@ -15,31 +18,41 @@ 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: + async def debug(self, msg: str, persist: Optional[bool] = None) -> None: self._logger.debug(msg) - await self.log( + await self._log( msg, severity=enapter.mqtt.api.LogSeverity.DEBUG, persist=persist ) - async def info(self, msg: str, persist: bool = False) -> None: + async def info(self, msg: str, persist: Optional[bool] = None) -> None: self._logger.info(msg) - await self.log(msg, severity=enapter.mqtt.api.LogSeverity.INFO, persist=persist) + await self._log( + msg, severity=enapter.mqtt.api.LogSeverity.INFO, persist=persist + ) - async def warning(self, msg: str, persist: bool = False) -> None: + async def warning(self, msg: str, persist: Optional[bool] = None) -> None: self._logger.warning(msg) - await self.log( + await self._log( msg, severity=enapter.mqtt.api.LogSeverity.WARNING, persist=persist ) - async def error(self, msg: str, persist: bool = False) -> None: + async def error(self, msg: str, persist: Optional[bool] = None) -> None: self._logger.error(msg) - await self.log( + 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 + async def _log( + self, + msg: str, + severity: enapter.mqtt.api.LogSeverity, + persist: Optional[bool] = None, ) -> None: - await self._channel.publish_logs(msg=msg, severity=severity, persist=persist) - - __call__ = log + await self._channel.publish_log( + enapter.mqtt.api.Log( + message=msg, + severity=severity, + persist=persist if persist is not None else False, + timestamp=int(time.time()), + ) + ) diff --git a/tests/unit/test_mqtt.py b/tests/unit/test_mqtt.py index 196dd6b..dc9cde4 100644 --- a/tests/unit/test_mqtt.py +++ b/tests/unit/test_mqtt.py @@ -12,23 +12,9 @@ async def test_publish_telemetry(self, fake): device_channel = enapter.mqtt.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.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) + "}", @@ -42,23 +28,9 @@ async def test_publish_properties(self, fake): device_channel = enapter.mqtt.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.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) + "}", From 23bb68d22ea46916a0b9392da9e5dd4943bf5ea7 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Fri, 17 Oct 2025 12:28:18 +0200 Subject: [PATCH 04/43] drop `types` module --- enapter/__init__.py | 3 +-- enapter/types.py | 3 --- examples/vucm/psutil-battery/script.py | 10 +++++----- 3 files changed, 6 insertions(+), 10 deletions(-) delete mode 100644 enapter/types.py diff --git a/enapter/__init__.py b/enapter/__init__.py index c0f4ae6..49d10c3 100644 --- a/enapter/__init__.py +++ b/enapter/__init__.py @@ -1,6 +1,6 @@ __version__ = "0.11.4" -from . import async_, log, mdns, mqtt, types, vucm +from . import async_, log, mdns, mqtt, vucm __all__ = [ "__version__", @@ -8,6 +8,5 @@ "log", "mdns", "mqtt", - "types", "vucm", ] 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/examples/vucm/psutil-battery/script.py b/examples/vucm/psutil-battery/script.py index 4881062..cc3f794 100644 --- a/examples/vucm/psutil-battery/script.py +++ b/examples/vucm/psutil-battery/script.py @@ -1,5 +1,5 @@ import asyncio -from typing import Tuple +from typing import Any, Dict, Tuple import psutil @@ -19,17 +19,17 @@ async def data_sender(self): await self.send_properties(properties) await asyncio.sleep(delay) - async def gather_data(self) -> Tuple[enapter.types.JSON, enapter.types.JSON, int]: + async def gather_data(self) -> Tuple[Dict[str, Any], Dict[str, Any], 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 + return {}, {}, 10 self.alerts.clear() - telemetry = None - properties = {"battery_installed": battery is not None} + telemetry: Dict[str, Any] = {} + properties: Dict[str, Any] = {"battery_installed": battery is not None} if battery is not None: telemetry = { From 9c2792480fbac2120cd5bbf22e5dfd3d90e76b89 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Fri, 17 Oct 2025 14:42:01 +0200 Subject: [PATCH 05/43] mqtt: move `DeviceChannel` back to `api` --- enapter/mqtt/__init__.py | 6 ++--- enapter/mqtt/api/__init__.py | 2 ++ enapter/mqtt/{ => api}/device_channel.py | 27 ++++++++++--------- enapter/vucm/app.py | 2 +- enapter/vucm/device.py | 2 +- enapter/vucm/logger.py | 2 +- tests/unit/test_mqtt/__init__.py | 0 .../{test_mqtt.py => test_mqtt/test_api.py} | 4 +-- 8 files changed, 24 insertions(+), 21 deletions(-) rename enapter/mqtt/{ => api}/device_channel.py (69%) create mode 100644 tests/unit/test_mqtt/__init__.py rename tests/unit/{test_mqtt.py => test_mqtt/test_api.py} (91%) diff --git a/enapter/mqtt/__init__.py b/enapter/mqtt/__init__.py index d711365..9a2d1e8 100644 --- a/enapter/mqtt/__init__.py +++ b/enapter/mqtt/__init__.py @@ -1,14 +1,14 @@ -from . import api from .client import Client from .config import Config, TLSConfig -from .device_channel import DeviceChannel from .errors import Error from .message import Message +# isort: split +from . import api + __all__ = [ "Client", "Config", - "DeviceChannel", "Error", "Message", "TLSConfig", diff --git a/enapter/mqtt/api/__init__.py b/enapter/mqtt/api/__init__.py index b7fd645..95b48d7 100644 --- a/enapter/mqtt/api/__init__.py +++ b/enapter/mqtt/api/__init__.py @@ -1,4 +1,5 @@ from .commands import CommandRequest, CommandResponse, CommandState +from .device_channel import DeviceChannel from .logs import Log, LogSeverity from .message import Message from .properties import Properties @@ -7,6 +8,7 @@ __all__ = [ "CommandRequest", "CommandResponse", + "DeviceChannel", "CommandState", "Log", "LogSeverity", diff --git a/enapter/mqtt/device_channel.py b/enapter/mqtt/api/device_channel.py similarity index 69% rename from enapter/mqtt/device_channel.py rename to enapter/mqtt/api/device_channel.py index 75a54c7..705c29c 100644 --- a/enapter/mqtt/device_channel.py +++ b/enapter/mqtt/api/device_channel.py @@ -1,17 +1,18 @@ import logging from typing import AsyncContextManager, AsyncGenerator -import enapter +from enapter import async_, mqtt -from . import api -from .client import Client -from .message import Message +from .commands import CommandRequest, CommandResponse +from .logs import Log +from .properties import Properties +from .telemetry import Telemetry LOGGER = logging.getLogger(__name__) class DeviceChannel: - def __init__(self, client: Client, hardware_id: str, channel_id: str) -> None: + 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 @@ -30,30 +31,30 @@ 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) - @enapter.async_.generator + @async_.generator async def subscribe_to_command_requests( self, - ) -> AsyncGenerator[api.CommandRequest, None]: + ) -> 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 api.CommandRequest.from_json(msg.payload) + yield CommandRequest.from_json(msg.payload) - async def publish_command_response(self, response: api.CommandResponse) -> None: + async def publish_command_response(self, response: CommandResponse) -> None: await self._publish("v1/command/responses", response.to_json()) - async def publish_telemetry(self, telemetry: api.Telemetry, **kwargs) -> None: + async def publish_telemetry(self, telemetry: Telemetry, **kwargs) -> None: await self._publish("v1/telemetry", telemetry.to_json(), **kwargs) - async def publish_properties(self, properties: api.Properties, **kwargs) -> None: + async def publish_properties(self, properties: Properties, **kwargs) -> None: await self._publish("v1/register", properties.to_json(), **kwargs) - async def publish_log(self, log: api.Log, **kwargs) -> None: + 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[Message, None]]: + ) -> AsyncContextManager[AsyncGenerator[mqtt.Message, None]]: topic = f"v1/to/{self._hardware_id}/{self._channel_id}/{path}" return self._client.subscribe(topic) diff --git a/enapter/vucm/app.py b/enapter/vucm/app.py index 8706423..c59fd0a 100644 --- a/enapter/vucm/app.py +++ b/enapter/vucm/app.py @@ -10,7 +10,7 @@ class DeviceFactory(Protocol): - def __call__(self, channel: enapter.mqtt.DeviceChannel, **kwargs) -> Device: + def __call__(self, channel: enapter.mqtt.api.DeviceChannel, **kwargs) -> Device: pass diff --git a/enapter/vucm/device.py b/enapter/vucm/device.py index 50c5f78..bb67f0f 100644 --- a/enapter/vucm/device.py +++ b/enapter/vucm/device.py @@ -36,7 +36,7 @@ def is_device_command(func: DeviceCommandFunc) -> bool: class Device(enapter.async_.Routine): def __init__( - self, channel: enapter.mqtt.DeviceChannel, thread_pool_workers: int = 1 + self, channel: enapter.mqtt.api.DeviceChannel, thread_pool_workers: int = 1 ) -> None: self.__channel = channel diff --git a/enapter/vucm/logger.py b/enapter/vucm/logger.py index a990b62..436083e 100644 --- a/enapter/vucm/logger.py +++ b/enapter/vucm/logger.py @@ -9,7 +9,7 @@ class Logger: - def __init__(self, channel: enapter.mqtt.DeviceChannel) -> None: + def __init__(self, channel: enapter.mqtt.api.DeviceChannel) -> None: self._channel = channel self._logger = self._new_logger(channel.hardware_id, channel.channel_id) diff --git a/tests/unit/test_mqtt/__init__.py b/tests/unit/test_mqtt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_mqtt.py b/tests/unit/test_mqtt/test_api.py similarity index 91% rename from tests/unit/test_mqtt.py rename to tests/unit/test_mqtt/test_api.py index dc9cde4..1562b1e 100644 --- a/tests/unit/test_mqtt.py +++ b/tests/unit/test_mqtt/test_api.py @@ -9,7 +9,7 @@ async def test_publish_telemetry(self, fake): channel_id = fake.channel_id() timestamp = fake.timestamp() mock_client = mock.Mock() - device_channel = enapter.mqtt.DeviceChannel( + device_channel = enapter.mqtt.api.DeviceChannel( client=mock_client, hardware_id=hardware_id, channel_id=channel_id ) await device_channel.publish_telemetry( @@ -25,7 +25,7 @@ async def test_publish_properties(self, fake): channel_id = fake.channel_id() timestamp = fake.timestamp() mock_client = mock.Mock() - device_channel = enapter.mqtt.DeviceChannel( + device_channel = enapter.mqtt.api.DeviceChannel( client=mock_client, hardware_id=hardware_id, channel_id=channel_id ) await device_channel.publish_properties( From 507563d880b3db372d609b6c305a31e7de9ffeea Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Fri, 17 Oct 2025 15:28:58 +0200 Subject: [PATCH 06/43] use `src` dir for package layout --- Makefile | 2 +- setup.py | 6 ++--- {enapter => src/enapter}/__init__.py | 2 +- {enapter => src/enapter}/async_/__init__.py | 0 {enapter => src/enapter}/async_/generator.py | 0 {enapter => src/enapter}/async_/routine.py | 0 {enapter => src/enapter}/log/__init__.py | 0 .../enapter}/log/json_formatter.py | 0 {enapter => src/enapter}/mdns/__init__.py | 0 {enapter => src/enapter}/mdns/resolver.py | 0 {enapter => src/enapter}/mqtt/__init__.py | 3 +-- {enapter => src/enapter}/mqtt/api/__init__.py | 0 {enapter => src/enapter}/mqtt/api/commands.py | 0 .../enapter}/mqtt/api/device_channel.py | 0 {enapter => src/enapter}/mqtt/api/logs.py | 0 {enapter => src/enapter}/mqtt/api/message.py | 0 .../enapter}/mqtt/api/properties.py | 0 .../enapter}/mqtt/api/telemetry.py | 0 {enapter => src/enapter}/mqtt/client.py | 8 +++---- {enapter => src/enapter}/mqtt/config.py | 0 {enapter => src/enapter}/mqtt/errors.py | 0 {enapter => src/enapter}/mqtt/message.py | 0 {enapter => src/enapter}/vucm/__init__.py | 0 {enapter => src/enapter}/vucm/app.py | 12 +++++----- {enapter => src/enapter}/vucm/config.py | 0 {enapter => src/enapter}/vucm/device.py | 22 ++++++++--------- {enapter => src/enapter}/vucm/logger.py | 24 +++++++------------ {enapter => src/enapter}/vucm/ucm.py | 4 ++-- 28 files changed, 36 insertions(+), 47 deletions(-) rename {enapter => src/enapter}/__init__.py (66%) rename {enapter => src/enapter}/async_/__init__.py (100%) rename {enapter => src/enapter}/async_/generator.py (100%) rename {enapter => src/enapter}/async_/routine.py (100%) rename {enapter => src/enapter}/log/__init__.py (100%) rename {enapter => src/enapter}/log/json_formatter.py (100%) rename {enapter => src/enapter}/mdns/__init__.py (100%) rename {enapter => src/enapter}/mdns/resolver.py (100%) rename {enapter => src/enapter}/mqtt/__init__.py (86%) rename {enapter => src/enapter}/mqtt/api/__init__.py (100%) rename {enapter => src/enapter}/mqtt/api/commands.py (100%) rename {enapter => src/enapter}/mqtt/api/device_channel.py (100%) rename {enapter => src/enapter}/mqtt/api/logs.py (100%) rename {enapter => src/enapter}/mqtt/api/message.py (100%) rename {enapter => src/enapter}/mqtt/api/properties.py (100%) rename {enapter => src/enapter}/mqtt/api/telemetry.py (100%) rename {enapter => src/enapter}/mqtt/client.py (96%) rename {enapter => src/enapter}/mqtt/config.py (100%) rename {enapter => src/enapter}/mqtt/errors.py (100%) rename {enapter => src/enapter}/mqtt/message.py (100%) rename {enapter => src/enapter}/vucm/__init__.py (100%) rename {enapter => src/enapter}/vucm/app.py (82%) rename {enapter => src/enapter}/vucm/config.py (100%) rename {enapter => src/enapter}/vucm/device.py (85%) rename {enapter => src/enapter}/vucm/logger.py (66%) rename {enapter => src/enapter}/vucm/ucm.py (93%) diff --git a/Makefile b/Makefile index 34a0591..fbc1889 100644 --- a/Makefile +++ b/Makefile @@ -69,7 +69,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/setup.py b/setup.py index f7fde5d..23253be 100644 --- a/setup.py +++ b/setup.py @@ -8,8 +8,8 @@ def main(): 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", @@ -22,7 +22,7 @@ def main(): def read_version(): - with open("enapter/__init__.py") as f: + with open("src/enapter/__init__.py") as f: local_scope = {} exec(f.readline(), {}, local_scope) return local_scope["__version__"] diff --git a/enapter/__init__.py b/src/enapter/__init__.py similarity index 66% rename from enapter/__init__.py rename to src/enapter/__init__.py index 49d10c3..ec8bbd5 100644 --- a/enapter/__init__.py +++ b/src/enapter/__init__.py @@ -1,6 +1,6 @@ __version__ = "0.11.4" -from . import async_, log, mdns, mqtt, vucm +from . import async_, log, mdns, mqtt, vucm # isort: skip __all__ = [ "__version__", diff --git a/enapter/async_/__init__.py b/src/enapter/async_/__init__.py similarity index 100% rename from enapter/async_/__init__.py rename to src/enapter/async_/__init__.py 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/enapter/async_/routine.py b/src/enapter/async_/routine.py similarity index 100% rename from enapter/async_/routine.py rename to src/enapter/async_/routine.py 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 100% rename from enapter/log/json_formatter.py rename to src/enapter/log/json_formatter.py 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 100% rename from enapter/mdns/resolver.py rename to src/enapter/mdns/resolver.py diff --git a/enapter/mqtt/__init__.py b/src/enapter/mqtt/__init__.py similarity index 86% rename from enapter/mqtt/__init__.py rename to src/enapter/mqtt/__init__.py index 9a2d1e8..d2fc950 100644 --- a/enapter/mqtt/__init__.py +++ b/src/enapter/mqtt/__init__.py @@ -3,8 +3,7 @@ from .errors import Error from .message import Message -# isort: split -from . import api +from . import api # isort: skip __all__ = [ "Client", diff --git a/enapter/mqtt/api/__init__.py b/src/enapter/mqtt/api/__init__.py similarity index 100% rename from enapter/mqtt/api/__init__.py rename to src/enapter/mqtt/api/__init__.py diff --git a/enapter/mqtt/api/commands.py b/src/enapter/mqtt/api/commands.py similarity index 100% rename from enapter/mqtt/api/commands.py rename to src/enapter/mqtt/api/commands.py diff --git a/enapter/mqtt/api/device_channel.py b/src/enapter/mqtt/api/device_channel.py similarity index 100% rename from enapter/mqtt/api/device_channel.py rename to src/enapter/mqtt/api/device_channel.py diff --git a/enapter/mqtt/api/logs.py b/src/enapter/mqtt/api/logs.py similarity index 100% rename from enapter/mqtt/api/logs.py rename to src/enapter/mqtt/api/logs.py diff --git a/enapter/mqtt/api/message.py b/src/enapter/mqtt/api/message.py similarity index 100% rename from enapter/mqtt/api/message.py rename to src/enapter/mqtt/api/message.py diff --git a/enapter/mqtt/api/properties.py b/src/enapter/mqtt/api/properties.py similarity index 100% rename from enapter/mqtt/api/properties.py rename to src/enapter/mqtt/api/properties.py diff --git a/enapter/mqtt/api/telemetry.py b/src/enapter/mqtt/api/telemetry.py similarity index 100% rename from enapter/mqtt/api/telemetry.py rename to src/enapter/mqtt/api/telemetry.py diff --git a/enapter/mqtt/client.py b/src/enapter/mqtt/client.py similarity index 96% rename from enapter/mqtt/client.py rename to src/enapter/mqtt/client.py index 005bc7b..49405a1 100644 --- a/enapter/mqtt/client.py +++ b/src/enapter/mqtt/client.py @@ -7,7 +7,7 @@ import aiomqtt # type: ignore -import enapter +from enapter import async_, mdns from .config import Config from .message import Message @@ -15,11 +15,11 @@ LOGGER = logging.getLogger(__name__) -class Client(enapter.async_.Routine): +class Client(async_.Routine): def __init__(self, config: Config) -> None: 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_connected = asyncio.Event() @@ -37,7 +37,7 @@ async def publish(self, *args, **kwargs) -> None: assert self._publisher is not None await self._publisher.publish(*args, **kwargs) - @enapter.async_.generator + @async_.generator async def subscribe(self, *topics: str) -> AsyncGenerator[Message, None]: while True: try: diff --git a/enapter/mqtt/config.py b/src/enapter/mqtt/config.py similarity index 100% rename from enapter/mqtt/config.py rename to src/enapter/mqtt/config.py 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/enapter/mqtt/message.py b/src/enapter/mqtt/message.py similarity index 100% rename from enapter/mqtt/message.py rename to src/enapter/mqtt/message.py diff --git a/enapter/vucm/__init__.py b/src/enapter/vucm/__init__.py similarity index 100% rename from enapter/vucm/__init__.py rename to src/enapter/vucm/__init__.py diff --git a/enapter/vucm/app.py b/src/enapter/vucm/app.py similarity index 82% rename from enapter/vucm/app.py rename to src/enapter/vucm/app.py index c59fd0a..66eda47 100644 --- a/enapter/vucm/app.py +++ b/src/enapter/vucm/app.py @@ -1,7 +1,7 @@ import asyncio from typing import Optional, Protocol -import enapter +from enapter import async_, log, mqtt from .config import Config from .device import Device @@ -10,14 +10,14 @@ class DeviceFactory(Protocol): - def __call__(self, channel: enapter.mqtt.api.DeviceChannel, **kwargs) -> Device: + def __call__(self, channel: 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") + log.configure(level=log.LEVEL or "info") config = Config.from_env(prefix=config_prefix) @@ -25,7 +25,7 @@ async def run( await app.join() -class App(enapter.async_.Routine): +class App(async_.Routine): def __init__(self, config: Config, device_factory: DeviceFactory) -> None: self._config = config self._device_factory = device_factory @@ -34,7 +34,7 @@ async def _run(self) -> None: tasks = set() mqtt_client = await self._stack.enter_async_context( - enapter.mqtt.Client(config=self._config.mqtt) + mqtt.Client(config=self._config.mqtt) ) tasks.add(mqtt_client.task()) @@ -46,7 +46,7 @@ async def _run(self) -> None: device = await self._stack.enter_async_context( self._device_factory( - channel=enapter.mqtt.DeviceChannel( + channel=mqtt.api.DeviceChannel( client=mqtt_client, hardware_id=self._config.hardware_id, channel_id=self._config.channel_id, diff --git a/enapter/vucm/config.py b/src/enapter/vucm/config.py similarity index 100% rename from enapter/vucm/config.py rename to src/enapter/vucm/config.py diff --git a/enapter/vucm/device.py b/src/enapter/vucm/device.py similarity index 85% rename from enapter/vucm/device.py rename to src/enapter/vucm/device.py index bb67f0f..1605840 100644 --- a/enapter/vucm/device.py +++ b/src/enapter/vucm/device.py @@ -5,7 +5,7 @@ import traceback from typing import Any, Callable, Coroutine, Dict, Optional, Set, Tuple -import enapter +from enapter import async_, mqtt from .logger import Logger @@ -34,9 +34,9 @@ def is_device_command(func: DeviceCommandFunc) -> bool: return getattr(func, DEVICE_COMMAND_MARK, False) is True -class Device(enapter.async_.Routine): +class Device(async_.Routine): def __init__( - self, channel: enapter.mqtt.api.DeviceChannel, thread_pool_workers: int = 1 + self, channel: mqtt.api.DeviceChannel, thread_pool_workers: int = 1 ) -> None: self.__channel = channel @@ -62,7 +62,7 @@ def __init__( async def send_telemetry(self, values: Optional[Dict[str, Any]] = None) -> None: values = values.copy() if values is not None else {} timestamp = values.pop("timestamp", int(time.time())) - telemetry = enapter.mqtt.api.Telemetry( + telemetry = mqtt.api.Telemetry( timestamp=timestamp, alerts=list(self.alerts), values=values ) await self.__channel.publish_telemetry(telemetry) @@ -70,7 +70,7 @@ async def send_telemetry(self, values: Optional[Dict[str, Any]] = None) -> None: async def send_properties(self, values: Optional[Dict[str, Any]] = None) -> None: values = values.copy() if values is not None else {} timestamp = values.pop("timestamp", int(time.time())) - properties = enapter.mqtt.api.Properties(timestamp=timestamp, values=values) + properties = mqtt.api.Properties(timestamp=timestamp, values=values) await self.__channel.publish_properties(properties) async def run_in_thread(self, func, *args, **kwargs) -> Any: @@ -125,16 +125,14 @@ async def __process_command_requests(self) -> None: await self.__channel.publish_command_response(resp) async def __execute_command( - self, req: enapter.mqtt.api.CommandRequest - ) -> Tuple[enapter.mqtt.api.CommandState, Dict[str, Any]]: + self, req: mqtt.api.CommandRequest + ) -> Tuple[mqtt.api.CommandState, Dict[str, Any]]: try: cmd = self.__commands[req.name] except KeyError: - return enapter.mqtt.api.CommandState.ERROR, {"reason": "unknown command"} + return mqtt.api.CommandState.ERROR, {"reason": "unknown command"} try: - return enapter.mqtt.api.CommandState.COMPLETED, await cmd(**req.arguments) + return mqtt.api.CommandState.COMPLETED, await cmd(**req.arguments) except: - return enapter.mqtt.api.CommandState.ERROR, { - "traceback": traceback.format_exc() - } + return mqtt.api.CommandState.ERROR, {"traceback": traceback.format_exc()} diff --git a/enapter/vucm/logger.py b/src/enapter/vucm/logger.py similarity index 66% rename from enapter/vucm/logger.py rename to src/enapter/vucm/logger.py index 436083e..ac6d40c 100644 --- a/enapter/vucm/logger.py +++ b/src/enapter/vucm/logger.py @@ -2,14 +2,14 @@ import time from typing import Optional -import enapter +from enapter import mqtt LOGGER = logging.getLogger(__name__) class Logger: - def __init__(self, channel: enapter.mqtt.api.DeviceChannel) -> None: + def __init__(self, channel: mqtt.api.DeviceChannel) -> None: self._channel = channel self._logger = self._new_logger(channel.hardware_id, channel.channel_id) @@ -20,36 +20,28 @@ def _new_logger(hardware_id, channel_id) -> logging.LoggerAdapter: async def debug(self, msg: str, persist: Optional[bool] = None) -> None: self._logger.debug(msg) - await self._log( - msg, severity=enapter.mqtt.api.LogSeverity.DEBUG, persist=persist - ) + await self._log(msg, severity=mqtt.api.LogSeverity.DEBUG, persist=persist) async def info(self, msg: str, persist: Optional[bool] = None) -> None: self._logger.info(msg) - await self._log( - msg, severity=enapter.mqtt.api.LogSeverity.INFO, persist=persist - ) + await self._log(msg, severity=mqtt.api.LogSeverity.INFO, persist=persist) async def warning(self, msg: str, persist: Optional[bool] = None) -> None: self._logger.warning(msg) - await self._log( - msg, severity=enapter.mqtt.api.LogSeverity.WARNING, persist=persist - ) + await self._log(msg, severity=mqtt.api.LogSeverity.WARNING, persist=persist) async def error(self, msg: str, persist: Optional[bool] = None) -> None: self._logger.error(msg) - await self._log( - msg, severity=enapter.mqtt.api.LogSeverity.ERROR, persist=persist - ) + await self._log(msg, severity=mqtt.api.LogSeverity.ERROR, persist=persist) async def _log( self, msg: str, - severity: enapter.mqtt.api.LogSeverity, + severity: mqtt.api.LogSeverity, persist: Optional[bool] = None, ) -> None: await self._channel.publish_log( - enapter.mqtt.api.Log( + mqtt.api.Log( message=msg, severity=severity, persist=persist if persist is not None else False, diff --git a/enapter/vucm/ucm.py b/src/enapter/vucm/ucm.py similarity index 93% rename from enapter/vucm/ucm.py rename to src/enapter/vucm/ucm.py index 3d08ac0..1905df0 100644 --- a/enapter/vucm/ucm.py +++ b/src/enapter/vucm/ucm.py @@ -1,6 +1,6 @@ import asyncio -import enapter +from enapter import mqtt from .device import Device, device_command, device_task @@ -8,7 +8,7 @@ class UCM(Device): def __init__(self, mqtt_client, hardware_id) -> None: super().__init__( - channel=enapter.mqtt.DeviceChannel( + channel=mqtt.api.DeviceChannel( client=mqtt_client, hardware_id=hardware_id, channel_id="ucm" ) ) From da1deae89846b824ac5f6d64c8ae03c9c7e06113 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 20 Oct 2025 16:52:28 +0200 Subject: [PATCH 07/43] mqtt: api: CommandState: add `LOG` --- src/enapter/mqtt/api/commands.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/enapter/mqtt/api/commands.py b/src/enapter/mqtt/api/commands.py index 291e342..eefca24 100644 --- a/src/enapter/mqtt/api/commands.py +++ b/src/enapter/mqtt/api/commands.py @@ -9,6 +9,7 @@ class CommandState(enum.Enum): COMPLETED = "completed" ERROR = "error" + LOG = "log" @dataclasses.dataclass From 07a5a2242f7832fefb869cc23bf8d919a7e023d9 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 20 Oct 2025 19:57:26 +0200 Subject: [PATCH 08/43] drop `vucm` package and tests --- src/enapter/__init__.py | 3 +- src/enapter/vucm/__init__.py | 14 ---- src/enapter/vucm/app.py | 60 --------------- src/enapter/vucm/config.py | 72 ------------------ src/enapter/vucm/device.py | 138 ----------------------------------- src/enapter/vucm/logger.py | 50 ------------- src/enapter/vucm/ucm.py | 36 --------- tests/unit/test_vucm.py | 118 ------------------------------ 8 files changed, 1 insertion(+), 490 deletions(-) delete mode 100644 src/enapter/vucm/__init__.py delete mode 100644 src/enapter/vucm/app.py delete mode 100644 src/enapter/vucm/config.py delete mode 100644 src/enapter/vucm/device.py delete mode 100644 src/enapter/vucm/logger.py delete mode 100644 src/enapter/vucm/ucm.py delete mode 100644 tests/unit/test_vucm.py diff --git a/src/enapter/__init__.py b/src/enapter/__init__.py index ec8bbd5..c03b6b4 100644 --- a/src/enapter/__init__.py +++ b/src/enapter/__init__.py @@ -1,6 +1,6 @@ __version__ = "0.11.4" -from . import async_, log, mdns, mqtt, vucm # isort: skip +from . import async_, log, mdns, mqtt # isort: skip __all__ = [ "__version__", @@ -8,5 +8,4 @@ "log", "mdns", "mqtt", - "vucm", ] diff --git a/src/enapter/vucm/__init__.py b/src/enapter/vucm/__init__.py deleted file mode 100644 index 60b47ca..0000000 --- a/src/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/src/enapter/vucm/app.py b/src/enapter/vucm/app.py deleted file mode 100644 index 66eda47..0000000 --- a/src/enapter/vucm/app.py +++ /dev/null @@ -1,60 +0,0 @@ -import asyncio -from typing import Optional, Protocol - -from enapter import async_, log, mqtt - -from .config import Config -from .device import Device -from .ucm import UCM - - -class DeviceFactory(Protocol): - - def __call__(self, channel: mqtt.api.DeviceChannel, **kwargs) -> Device: - pass - - -async def run( - device_factory: DeviceFactory, config_prefix: Optional[str] = None -) -> None: - log.configure(level=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(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( - 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=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/src/enapter/vucm/config.py b/src/enapter/vucm/config.py deleted file mode 100644 index 2726bb9..0000000 --- a/src/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/src/enapter/vucm/device.py b/src/enapter/vucm/device.py deleted file mode 100644 index 1605840..0000000 --- a/src/enapter/vucm/device.py +++ /dev/null @@ -1,138 +0,0 @@ -import asyncio -import concurrent -import functools -import time -import traceback -from typing import Any, Callable, Coroutine, Dict, Optional, Set, Tuple - -from enapter import async_, mqtt - -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(async_.Routine): - def __init__( - self, channel: 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, values: Optional[Dict[str, Any]] = None) -> None: - values = values.copy() if values is not None else {} - timestamp = values.pop("timestamp", int(time.time())) - telemetry = mqtt.api.Telemetry( - timestamp=timestamp, alerts=list(self.alerts), values=values - ) - await self.__channel.publish_telemetry(telemetry) - - async def send_properties(self, values: Optional[Dict[str, Any]] = None) -> None: - values = values.copy() if values is not None else {} - timestamp = values.pop("timestamp", int(time.time())) - properties = mqtt.api.Properties(timestamp=timestamp, values=values) - 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: mqtt.api.CommandRequest - ) -> Tuple[mqtt.api.CommandState, Dict[str, Any]]: - try: - cmd = self.__commands[req.name] - except KeyError: - return mqtt.api.CommandState.ERROR, {"reason": "unknown command"} - - try: - return mqtt.api.CommandState.COMPLETED, await cmd(**req.arguments) - except: - return mqtt.api.CommandState.ERROR, {"traceback": traceback.format_exc()} diff --git a/src/enapter/vucm/logger.py b/src/enapter/vucm/logger.py deleted file mode 100644 index ac6d40c..0000000 --- a/src/enapter/vucm/logger.py +++ /dev/null @@ -1,50 +0,0 @@ -import logging -import time -from typing import Optional - -from enapter import mqtt - -LOGGER = logging.getLogger(__name__) - - -class Logger: - - def __init__(self, channel: mqtt.api.DeviceChannel) -> 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: Optional[bool] = None) -> None: - self._logger.debug(msg) - await self._log(msg, severity=mqtt.api.LogSeverity.DEBUG, persist=persist) - - async def info(self, msg: str, persist: Optional[bool] = None) -> None: - self._logger.info(msg) - await self._log(msg, severity=mqtt.api.LogSeverity.INFO, persist=persist) - - async def warning(self, msg: str, persist: Optional[bool] = None) -> None: - self._logger.warning(msg) - await self._log(msg, severity=mqtt.api.LogSeverity.WARNING, persist=persist) - - async def error(self, msg: str, persist: Optional[bool] = None) -> None: - self._logger.error(msg) - await self._log(msg, severity=mqtt.api.LogSeverity.ERROR, persist=persist) - - async def _log( - self, - msg: str, - severity: mqtt.api.LogSeverity, - persist: Optional[bool] = None, - ) -> None: - await self._channel.publish_log( - mqtt.api.Log( - message=msg, - severity=severity, - persist=persist if persist is not None else False, - timestamp=int(time.time()), - ) - ) diff --git a/src/enapter/vucm/ucm.py b/src/enapter/vucm/ucm.py deleted file mode 100644 index 1905df0..0000000 --- a/src/enapter/vucm/ucm.py +++ /dev/null @@ -1,36 +0,0 @@ -import asyncio - -from enapter import mqtt - -from .device import Device, device_command, device_task - - -class UCM(Device): - def __init__(self, mqtt_client, hardware_id) -> None: - super().__init__( - channel=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/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 From 6ffa23c341f578d4499ccbd063177ce8a7749d3f Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Tue, 21 Oct 2025 10:36:57 +0200 Subject: [PATCH 09/43] add `standalone` package --- src/enapter/__init__.py | 3 +- src/enapter/standalone/__init__.py | 13 ++++ src/enapter/standalone/app.py | 52 ++++++++++++++++ src/enapter/standalone/config.py | 73 ++++++++++++++++++++++ src/enapter/standalone/device.py | 20 ++++++ src/enapter/standalone/device_driver.py | 81 +++++++++++++++++++++++++ src/enapter/standalone/ucm.py | 34 +++++++++++ 7 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 src/enapter/standalone/__init__.py create mode 100644 src/enapter/standalone/app.py create mode 100644 src/enapter/standalone/config.py create mode 100644 src/enapter/standalone/device.py create mode 100644 src/enapter/standalone/device_driver.py create mode 100644 src/enapter/standalone/ucm.py diff --git a/src/enapter/__init__.py b/src/enapter/__init__.py index c03b6b4..cb601e4 100644 --- a/src/enapter/__init__.py +++ b/src/enapter/__init__.py @@ -1,6 +1,6 @@ __version__ = "0.11.4" -from . import async_, log, mdns, mqtt # isort: skip +from . import async_, log, mdns, mqtt, standalone # isort: skip __all__ = [ "__version__", @@ -8,4 +8,5 @@ "log", "mdns", "mqtt", + "standalone", ] diff --git a/src/enapter/standalone/__init__.py b/src/enapter/standalone/__init__.py new file mode 100644 index 0000000..b8bba5c --- /dev/null +++ b/src/enapter/standalone/__init__.py @@ -0,0 +1,13 @@ +from .app import App, run +from .config import Config +from .device import Device + +__all__ = [ + "App", + "Config", + "Device", + "device_command", + "device_task", + "UCM", + "run", +] diff --git a/src/enapter/standalone/app.py b/src/enapter/standalone/app.py new file mode 100644 index 0000000..c0c50a6 --- /dev/null +++ b/src/enapter/standalone/app.py @@ -0,0 +1,52 @@ +import asyncio +import contextlib +from typing import Optional + +from enapter import async_, log, mqtt + +from .config import Config +from .device import Device +from .device_driver import DeviceDriver +from .ucm import UCM + + +async def run(device: Device, config_prefix: Optional[str] = None) -> None: + log.configure(level=log.LEVEL or "info") + + config = Config.from_env(prefix=config_prefix) + + async with App(config=config, device=device) as app: + await app.join() + + +class App(async_.Routine): + + def __init__(self, config: Config, device: Device) -> None: + self._config = config + self._device = device + + async def _run(self) -> None: + async with contextlib.AsyncExitStack() as stack: + mqtt_client = await stack.enter_async_context( + mqtt.Client(config=self._config.mqtt) + ) + device_channel = mqtt.api.DeviceChannel( + client=mqtt_client, + hardware_id=self._config.hardware_id, + channel_id=self._config.channel_id, + ) + _ = await stack.enter_async_context( + DeviceDriver(device_channel=device_channel, device=self._device) + ) + if self._config.start_ucm: + ucm_channel = mqtt.api.DeviceChannel( + client=mqtt_client, + hardware_id=self._config.hardware_id, + channel_id="ucm", + ) + _ = await stack.enter_async_context( + DeviceDriver(device_channel=ucm_channel, device=UCM()) + ) + self._started.set() + while True: + await asyncio.sleep(1) diff --git a/src/enapter/standalone/config.py b/src/enapter/standalone/config.py new file mode 100644 index 0000000..483d18f --- /dev/null +++ b/src/enapter/standalone/config.py @@ -0,0 +1,73 @@ +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/src/enapter/standalone/device.py b/src/enapter/standalone/device.py new file mode 100644 index 0000000..3b9468f --- /dev/null +++ b/src/enapter/standalone/device.py @@ -0,0 +1,20 @@ +from typing import Any, AsyncGenerator, Dict, Optional, Protocol, TypeAlias + +Properties: TypeAlias = Dict[str, Any] +Telemetry: TypeAlias = Dict[str, Any] +CommandArgs: TypeAlias = Dict[str, Any] +CommandResult: TypeAlias = Dict[str, Any] + + +class Device(Protocol): + + async def send_properties(self) -> AsyncGenerator[Properties]: + yield {} + + async def send_telemetry(self) -> AsyncGenerator[Telemetry]: + yield {} + + async def execute_command( + self, name: str, args: CommandArgs + ) -> Optional[CommandResult]: + pass diff --git a/src/enapter/standalone/device_driver.py b/src/enapter/standalone/device_driver.py new file mode 100644 index 0000000..2d29df9 --- /dev/null +++ b/src/enapter/standalone/device_driver.py @@ -0,0 +1,81 @@ +import asyncio +import contextlib +import time +import traceback + +from enapter import async_, mqtt + +from .device import Device + + +class DeviceDriver(async_.Routine): + + def __init__(self, device_channel: mqtt.api.DeviceChannel, device: Device) -> None: + self._device_channel = device_channel + self._device = device + + async def _run(self) -> None: + async with asyncio.TaskGroup() as tg: + tg.create_task(self._send_properties()) + tg.create_task(self._send_telemetry()) + tg.create_task(self._execute_commands()) + self._started.set() + + async def _send_properties(self) -> None: + async with contextlib.aclosing(self._device.send_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 _send_telemetry(self) -> None: + async with contextlib.aclosing(self._device.send_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 _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: + if payload is None: + payload = {} + await self._device_channel.publish_command_response( + request.new_response(mqtt.api.CommandState.COMPLETED, payload) + ) diff --git a/src/enapter/standalone/ucm.py b/src/enapter/standalone/ucm.py new file mode 100644 index 0000000..be13509 --- /dev/null +++ b/src/enapter/standalone/ucm.py @@ -0,0 +1,34 @@ +import asyncio +from typing import AsyncGenerator + +from .device import CommandArgs, CommandResult, Properties, Telemetry + + +class UCM: + + async def execute_command(self, name: str, args: CommandArgs) -> CommandResult: + match name: + case "reboot": + return await self.reboot() + case "upload_lua_script": + return await self.upload_lua_script(**args) + case _: + raise NotImplementedError + + async def reboot(self) -> CommandResult: + raise NotImplementedError + + async def upload_lua_script( + self, url: str, sha1: str, payload=None + ) -> CommandResult: + raise NotImplementedError + + async def send_telemetry(self) -> AsyncGenerator[Telemetry]: + while True: + yield {} + await asyncio.sleep(1) + + async def send_properties(self) -> AsyncGenerator[Properties]: + while True: + yield {"virtual": True, "lua_api_ver": 1} + await asyncio.sleep(30) From 60043d9f363470d8a6c02f2e19a629703a27cd90 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Tue, 21 Oct 2025 13:23:16 +0200 Subject: [PATCH 10/43] mqtt: api: define one class per file --- src/enapter/mqtt/api/__init__.py | 7 ++-- .../api/{commands.py => command_request.py} | 35 ++----------------- src/enapter/mqtt/api/command_response.py | 28 +++++++++++++++ src/enapter/mqtt/api/command_state.py | 8 +++++ src/enapter/mqtt/api/device_channel.py | 6 ++-- src/enapter/mqtt/api/{logs.py => log.py} | 10 +----- src/enapter/mqtt/api/log_severity.py | 9 +++++ 7 files changed, 58 insertions(+), 45 deletions(-) rename src/enapter/mqtt/api/{commands.py => command_request.py} (54%) create mode 100644 src/enapter/mqtt/api/command_response.py create mode 100644 src/enapter/mqtt/api/command_state.py rename src/enapter/mqtt/api/{logs.py => log.py} (84%) create mode 100644 src/enapter/mqtt/api/log_severity.py diff --git a/src/enapter/mqtt/api/__init__.py b/src/enapter/mqtt/api/__init__.py index 95b48d7..5ec9f6d 100644 --- a/src/enapter/mqtt/api/__init__.py +++ b/src/enapter/mqtt/api/__init__.py @@ -1,6 +1,9 @@ -from .commands import CommandRequest, CommandResponse, CommandState +from .command_request import CommandRequest +from .command_response import CommandResponse +from .command_state import CommandState from .device_channel import DeviceChannel -from .logs import Log, LogSeverity +from .log import Log +from .log_severity import LogSeverity from .message import Message from .properties import Properties from .telemetry import Telemetry diff --git a/src/enapter/mqtt/api/commands.py b/src/enapter/mqtt/api/command_request.py similarity index 54% rename from src/enapter/mqtt/api/commands.py rename to src/enapter/mqtt/api/command_request.py index eefca24..be90b5c 100644 --- a/src/enapter/mqtt/api/commands.py +++ b/src/enapter/mqtt/api/command_request.py @@ -1,17 +1,11 @@ import dataclasses -import enum from typing import Any, Dict +from .command_response import CommandResponse +from .command_state import CommandState from .message import Message -class CommandState(enum.Enum): - - COMPLETED = "completed" - ERROR = "error" - LOG = "log" - - @dataclasses.dataclass class CommandRequest(Message): @@ -36,32 +30,9 @@ def to_dto(self) -> Dict[str, Any]: def new_response( self, state: CommandState, payload: Dict[str, Any] - ) -> "CommandResponse": + ) -> CommandResponse: return CommandResponse( id=self.id, state=state, payload=payload, ) - - -@dataclasses.dataclass -class CommandResponse(Message): - - id: str - state: CommandState - payload: Dict[str, Any] - - @classmethod - def from_dto(cls, dto: Dict[str, Any]) -> "CommandResponse": - 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_response.py b/src/enapter/mqtt/api/command_response.py new file mode 100644 index 0000000..49022ec --- /dev/null +++ b/src/enapter/mqtt/api/command_response.py @@ -0,0 +1,28 @@ +import dataclasses +from typing import Any, Dict + +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]) -> "CommandResponse": + 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 index 705c29c..8e2feb6 100644 --- a/src/enapter/mqtt/api/device_channel.py +++ b/src/enapter/mqtt/api/device_channel.py @@ -3,8 +3,9 @@ from enapter import async_, mqtt -from .commands import CommandRequest, CommandResponse -from .logs import Log +from .command_request import CommandRequest +from .command_response import CommandResponse +from .log import Log from .properties import Properties from .telemetry import Telemetry @@ -12,6 +13,7 @@ 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) diff --git a/src/enapter/mqtt/api/logs.py b/src/enapter/mqtt/api/log.py similarity index 84% rename from src/enapter/mqtt/api/logs.py rename to src/enapter/mqtt/api/log.py index 4c271c4..180aca3 100644 --- a/src/enapter/mqtt/api/logs.py +++ b/src/enapter/mqtt/api/log.py @@ -1,18 +1,10 @@ import dataclasses -import enum from typing import Any, Dict +from .log_severity import LogSeverity from .message import Message -class LogSeverity(enum.Enum): - - DEBUG = "debug" - INFO = "info" - WARNING = "warning" - ERROR = "error" - - @dataclasses.dataclass class Log(Message): diff --git a/src/enapter/mqtt/api/log_severity.py b/src/enapter/mqtt/api/log_severity.py new file mode 100644 index 0000000..ff554de --- /dev/null +++ b/src/enapter/mqtt/api/log_severity.py @@ -0,0 +1,9 @@ +import enum + + +class LogSeverity(enum.Enum): + + DEBUG = "debug" + INFO = "info" + WARNING = "warning" + ERROR = "error" From 0ef188f5fe4dc1e24f1cf3442482e9f84d5c53c8 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Wed, 22 Oct 2025 10:34:52 +0200 Subject: [PATCH 11/43] standalone: rework device into abc to allow for convenient logging --- src/enapter/standalone/__init__.py | 13 ++++++++---- src/enapter/standalone/device.py | 27 +++++++++++++++++++------ src/enapter/standalone/device_driver.py | 15 ++++++++++++-- src/enapter/standalone/logger.py | 26 ++++++++++++++++++++++++ src/enapter/standalone/ucm.py | 13 ++---------- 5 files changed, 71 insertions(+), 23 deletions(-) create mode 100644 src/enapter/standalone/logger.py diff --git a/src/enapter/standalone/__init__.py b/src/enapter/standalone/__init__.py index b8bba5c..eb50821 100644 --- a/src/enapter/standalone/__init__.py +++ b/src/enapter/standalone/__init__.py @@ -1,13 +1,18 @@ from .app import App, run from .config import Config -from .device import Device +from .device import (CommandArgs, CommandResult, Device, Log, Properties, + Telemetry) +from .logger import Logger __all__ = [ "App", + "CommandArgs", + "CommandResult", "Config", "Device", - "device_command", - "device_task", - "UCM", + "Log", + "Logger", + "Properties", + "Telemetry", "run", ] diff --git a/src/enapter/standalone/device.py b/src/enapter/standalone/device.py index 3b9468f..574d3ff 100644 --- a/src/enapter/standalone/device.py +++ b/src/enapter/standalone/device.py @@ -1,4 +1,7 @@ -from typing import Any, AsyncGenerator, Dict, Optional, Protocol, TypeAlias +import abc +from typing import Any, AsyncGenerator, Dict, TypeAlias + +from .logger import Log, Logger Properties: TypeAlias = Dict[str, Any] Telemetry: TypeAlias = Dict[str, Any] @@ -6,15 +9,27 @@ CommandResult: TypeAlias = Dict[str, Any] -class Device(Protocol): +class Device(abc.ABC): + + def __init__(self) -> None: + self.logger = Logger() + @abc.abstractmethod async def send_properties(self) -> AsyncGenerator[Properties]: yield {} + @abc.abstractmethod async def send_telemetry(self) -> AsyncGenerator[Telemetry]: yield {} - async def execute_command( - self, name: str, args: CommandArgs - ) -> Optional[CommandResult]: - pass + async def send_logs(self) -> AsyncGenerator[Log]: + while True: + yield await self.logger.queue.get() + + async def execute_command(self, name: str, args: CommandArgs) -> CommandResult: + try: + command = getattr(self, name) + except AttributeError: + raise NotImplementedError() from None + result = await command(**args) + return result if result is not None else {} diff --git a/src/enapter/standalone/device_driver.py b/src/enapter/standalone/device_driver.py index 2d29df9..7f2de29 100644 --- a/src/enapter/standalone/device_driver.py +++ b/src/enapter/standalone/device_driver.py @@ -18,6 +18,7 @@ async def _run(self) -> None: async with asyncio.TaskGroup() as tg: tg.create_task(self._send_properties()) tg.create_task(self._send_telemetry()) + tg.create_task(self._send_logs()) tg.create_task(self._execute_commands()) self._started.set() @@ -44,6 +45,18 @@ async def _send_telemetry(self) -> None: ) ) + async def _send_logs(self) -> None: + async with contextlib.aclosing(self._device.send_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[0]), + message=log[1], + persist=log[2], + ) + ) + async def _execute_commands(self) -> None: async with asyncio.TaskGroup() as tg: async with self._device_channel.subscribe_to_command_requests() as requests: @@ -74,8 +87,6 @@ async def _execute_command(self, request: mqtt.api.CommandRequest) -> None: ) ) else: - if payload is None: - payload = {} await self._device_channel.publish_command_response( request.new_response(mqtt.api.CommandState.COMPLETED, payload) ) diff --git a/src/enapter/standalone/logger.py b/src/enapter/standalone/logger.py new file mode 100644 index 0000000..30be267 --- /dev/null +++ b/src/enapter/standalone/logger.py @@ -0,0 +1,26 @@ +import asyncio +from typing import Literal, Tuple, TypeAlias + +Log: TypeAlias = Tuple[Literal["debug", "info", "warning", "error"], str, bool] + + +class Logger: + + def __init__(self) -> None: + self._queue: asyncio.Queue[Log] = asyncio.Queue(1) + + async def debug(self, msg: str, persist: bool = False) -> None: + await self._queue.put(("debug", msg, persist)) + + async def info(self, msg: str, persist: bool = False) -> None: + await self._queue.put(("info", msg, persist)) + + async def warning(self, msg: str, persist: bool = False) -> None: + await self._queue.put(("warning", msg, persist)) + + async def error(self, msg: str, persist: bool = False) -> None: + await self._queue.put(("error", msg, persist)) + + @property + def queue(self) -> asyncio.Queue[Log]: + return self._queue diff --git a/src/enapter/standalone/ucm.py b/src/enapter/standalone/ucm.py index be13509..a420711 100644 --- a/src/enapter/standalone/ucm.py +++ b/src/enapter/standalone/ucm.py @@ -1,19 +1,10 @@ import asyncio from typing import AsyncGenerator -from .device import CommandArgs, CommandResult, Properties, Telemetry +from .device import CommandResult, Device, Properties, Telemetry -class UCM: - - async def execute_command(self, name: str, args: CommandArgs) -> CommandResult: - match name: - case "reboot": - return await self.reboot() - case "upload_lua_script": - return await self.upload_lua_script(**args) - case _: - raise NotImplementedError +class UCM(Device): async def reboot(self) -> CommandResult: raise NotImplementedError From ca83c445d5e78b20ff5f989e198b17fd1cbdb336 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Wed, 22 Oct 2025 12:20:44 +0200 Subject: [PATCH 12/43] drop `async_.Routine` in favor of `asyncio.TaskGroup` --- src/enapter/async_/__init__.py | 6 +- src/enapter/async_/routine.py | 70 ------------- src/enapter/mqtt/client.py | 10 +- src/enapter/standalone/app.py | 56 +++++----- src/enapter/standalone/device_driver.py | 13 ++- tests/integration/conftest.py | 5 +- tests/integration/test_mqtt.py | 90 ++++++++-------- tests/unit/test_async.py | 134 +----------------------- 8 files changed, 93 insertions(+), 291 deletions(-) delete mode 100644 src/enapter/async_/routine.py diff --git a/src/enapter/async_/__init__.py b/src/enapter/async_/__init__.py index 9fc5f1f..af11f51 100644 --- a/src/enapter/async_/__init__.py +++ b/src/enapter/async_/__init__.py @@ -1,7 +1,3 @@ from .generator import generator -from .routine import Routine -__all__ = [ - "generator", - "Routine", -] +__all__ = ["generator"] diff --git a/src/enapter/async_/routine.py b/src/enapter/async_/routine.py deleted file mode 100644 index 4540596..0000000 --- a/src/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/src/enapter/mqtt/client.py b/src/enapter/mqtt/client.py index 49405a1..de78eb8 100644 --- a/src/enapter/mqtt/client.py +++ b/src/enapter/mqtt/client.py @@ -15,14 +15,19 @@ LOGGER = logging.getLogger(__name__) -class Client(async_.Routine): - def __init__(self, config: Config) -> None: +class Client: + + def __init__(self, task_group: asyncio.TaskGroup, config: Config) -> None: self._logger = self._new_logger(config) self._config = config self._mdns_resolver = mdns.Resolver() self._tls_context = self._new_tls_context(config) self._publisher: Optional[aiomqtt.Client] = None self._publisher_connected = asyncio.Event() + self._task = task_group.create_task(self._run()) + + def cancel(self) -> None: + self._task.cancel() @staticmethod def _new_logger(config: Config) -> logging.LoggerAdapter: @@ -54,7 +59,6 @@ async def subscribe(self, *topics: str) -> AsyncGenerator[Message, None]: async def _run(self) -> None: self._logger.info("starting") - self._started.set() while True: try: async with self._connect() as publisher: diff --git a/src/enapter/standalone/app.py b/src/enapter/standalone/app.py index c0c50a6..2504549 100644 --- a/src/enapter/standalone/app.py +++ b/src/enapter/standalone/app.py @@ -1,8 +1,7 @@ import asyncio -import contextlib from typing import Optional -from enapter import async_, log, mqtt +from enapter import log, mqtt from .config import Config from .device import Device @@ -15,38 +14,41 @@ async def run(device: Device, config_prefix: Optional[str] = None) -> None: config = Config.from_env(prefix=config_prefix) - async with App(config=config, device=device) as app: - await app.join() + try: + async with asyncio.TaskGroup() as tg: + _ = App(task_group=tg, config=config, device=device) + except asyncio.CancelledError: + pass -class App(async_.Routine): +class App: - def __init__(self, config: Config, device: Device) -> None: + def __init__( + self, task_group: asyncio.TaskGroup, config: Config, device: Device + ) -> None: self._config = config self._device = device + self._task = task_group.create_task(self._run()) async def _run(self) -> None: - async with contextlib.AsyncExitStack() as stack: - mqtt_client = await stack.enter_async_context( - mqtt.Client(config=self._config.mqtt) - ) - device_channel = mqtt.api.DeviceChannel( - client=mqtt_client, - hardware_id=self._config.hardware_id, - channel_id=self._config.channel_id, - ) - _ = await stack.enter_async_context( - DeviceDriver(device_channel=device_channel, device=self._device) - ) - if self._config.start_ucm: - ucm_channel = mqtt.api.DeviceChannel( + async with asyncio.TaskGroup() as tg: + mqtt_client = mqtt.Client(task_group=tg, config=self._config.mqtt) + _ = DeviceDriver( + task_group=tg, + device_channel=mqtt.api.DeviceChannel( client=mqtt_client, hardware_id=self._config.hardware_id, - channel_id="ucm", - ) - _ = await stack.enter_async_context( - DeviceDriver(device_channel=ucm_channel, device=UCM()) + channel_id=self._config.channel_id, + ), + device=self._device, + ) + if self._config.start_ucm: + _ = DeviceDriver( + task_group=tg, + device_channel=mqtt.api.DeviceChannel( + client=mqtt_client, + hardware_id=self._config.hardware_id, + channel_id="ucm", + ), + device=UCM(), ) - self._started.set() - while True: - await asyncio.sleep(1) diff --git a/src/enapter/standalone/device_driver.py b/src/enapter/standalone/device_driver.py index 7f2de29..71eb91f 100644 --- a/src/enapter/standalone/device_driver.py +++ b/src/enapter/standalone/device_driver.py @@ -3,16 +3,22 @@ import time import traceback -from enapter import async_, mqtt +from enapter import mqtt from .device import Device -class DeviceDriver(async_.Routine): +class DeviceDriver: - def __init__(self, device_channel: mqtt.api.DeviceChannel, device: Device) -> None: + def __init__( + self, + task_group: asyncio.TaskGroup, + device_channel: mqtt.api.DeviceChannel, + device: Device, + ) -> None: self._device_channel = device_channel self._device = device + self._task = task_group.create_task(self._run()) async def _run(self) -> None: async with asyncio.TaskGroup() as tg: @@ -20,7 +26,6 @@ async def _run(self) -> None: tg.create_task(self._send_telemetry()) tg.create_task(self._send_logs()) tg.create_task(self._execute_commands()) - self._started.set() async def _send_properties(self) -> None: async with contextlib.aclosing(self._device.send_properties()) as iterator: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 800f058..8bf710b 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,3 +1,4 @@ +import asyncio import os import socket @@ -18,8 +19,10 @@ 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: + async with asyncio.TaskGroup() as tg: + mqtt_client = enapter.mqtt.Client(tg, config) yield mqtt_client + mqtt_client.cancel() @pytest.fixture(scope="session", name="mosquitto_container") diff --git a/tests/integration/test_mqtt.py b/tests/integration/test_mqtt.py index ba1ea6c..1a5d992 100644 --- a/tests/integration/test_mqtt.py +++ b/tests/integration/test_mqtt.py @@ -1,29 +1,22 @@ 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: + heartbit_sender = HeartbitSender(tg, enapter_mqtt_client) + async with enapter_mqtt_client.subscribe(heartbit_sender.topic) as messages: + msg = await messages.__anext__() + assert int(msg.payload) <= time.time() + heartbit_sender.cancel() 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 asyncio.TaskGroup() as tg: + heartbit_sender = HeartbitSender(tg, enapter_mqtt_client) async with enapter_mqtt_client.subscribe( heartbit_sender.topic ) as messages_1: @@ -36,58 +29,59 @@ async def test_consume_after_another_subscriber_left(self, enapter_mqtt_client): assert int(msg.payload) <= time.time() msg = await messages_1.__anext__() assert int(msg.payload) <= time.time() + heartbit_sender.cancel() 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) - ) + async with asyncio.TaskGroup() as tg: + heartbit_sender = HeartbitSender(tg, enapter_mqtt_client) 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() + heartbit_sender.cancel() 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]: + async with asyncio.TaskGroup() as tg: + heartbit_sender = HeartbitSender(tg, enapter_mqtt_client) + 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() + heartbit_sender.cancel() + + async def test_broker_restart(self, mosquitto_container, enapter_mqtt_client): + async with asyncio.TaskGroup() as tg: + heartbit_sender = HeartbitSender(tg, enapter_mqtt_client) + async with enapter_mqtt_client.subscribe(heartbit_sender.topic) as messages: + msg = await messages.__anext__() + assert int(msg.payload) <= time.time() + mosquitto_container.restart() msg = await messages.__anext__() assert int(msg.payload) <= time.time() + heartbit_sender.cancel() - 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() +class HeartbitSender: -class HeartbitSender(enapter.async_.Routine): - def __init__(self, enapter_mqtt_client, topic="heartbits", interval=0.5): + def __init__( + self, task_group, enapter_mqtt_client, topic="heartbits", interval=0.5 + ): self.enapter_mqtt_client = enapter_mqtt_client self.topic = topic self.interval = interval + self._task = task_group.create_task(self._run()) - async def _run(self): - self._started.set() + def cancel(self): + self._task.cancel() + async def _run(self): while True: payload = str(int(time.time())) try: 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() From b1dbf3c3ae9d9666a2709cd0831b36d6e7aa5ca8 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Thu, 23 Oct 2025 10:57:24 +0200 Subject: [PATCH 13/43] mqtt: fix handling of cancellation during client connection --- src/enapter/mqtt/client.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/enapter/mqtt/client.py b/src/enapter/mqtt/client.py index de78eb8..439b309 100644 --- a/src/enapter/mqtt/client.py +++ b/src/enapter/mqtt/client.py @@ -79,7 +79,7 @@ async def _run(self) -> None: @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, @@ -124,3 +124,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) From 8238eabb02a6d8bd58bb0e49bed9db2ad120646c Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Thu, 23 Oct 2025 11:32:44 +0200 Subject: [PATCH 14/43] setup: add `httpx` to dependencies --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 23253be..f465173 100644 --- a/setup.py +++ b/setup.py @@ -17,6 +17,7 @@ def main(): "aiomqtt==2.4.*", "dnspython==2.8.*", "json-log-formatter==1.1.*", + "httpx==0.28.*", ], ) From 5805efe76c93e2d33456e1e8804a47c459a18436 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Thu, 23 Oct 2025 17:06:26 +0200 Subject: [PATCH 15/43] add basic http api support --- src/enapter/__init__.py | 3 +- src/enapter/http/__init__.py | 3 ++ src/enapter/http/api/__init__.py | 5 +++ src/enapter/http/api/client.py | 29 ++++++++++++ src/enapter/http/api/config.py | 21 +++++++++ src/enapter/http/api/devices/__init__.py | 21 +++++++++ .../http/api/devices/authorized_role.py | 11 +++++ src/enapter/http/api/devices/client.py | 25 +++++++++++ .../http/api/devices/communication_config.py | 45 +++++++++++++++++++ src/enapter/http/api/devices/device.py | 32 +++++++++++++ src/enapter/http/api/devices/device_type.py | 10 +++++ .../http/api/devices/mqtt_credentials.py | 13 ++++++ src/enapter/http/api/devices/mqtt_protocol.py | 7 +++ .../http/api/devices/mqtts_credentials.py | 18 ++++++++ .../http/api/devices/time_sync_protocol.py | 6 +++ 15 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 src/enapter/http/__init__.py create mode 100644 src/enapter/http/api/__init__.py create mode 100644 src/enapter/http/api/client.py create mode 100644 src/enapter/http/api/config.py create mode 100644 src/enapter/http/api/devices/__init__.py create mode 100644 src/enapter/http/api/devices/authorized_role.py create mode 100644 src/enapter/http/api/devices/client.py create mode 100644 src/enapter/http/api/devices/communication_config.py create mode 100644 src/enapter/http/api/devices/device.py create mode 100644 src/enapter/http/api/devices/device_type.py create mode 100644 src/enapter/http/api/devices/mqtt_credentials.py create mode 100644 src/enapter/http/api/devices/mqtt_protocol.py create mode 100644 src/enapter/http/api/devices/mqtts_credentials.py create mode 100644 src/enapter/http/api/devices/time_sync_protocol.py diff --git a/src/enapter/__init__.py b/src/enapter/__init__.py index cb601e4..c3c6f95 100644 --- a/src/enapter/__init__.py +++ b/src/enapter/__init__.py @@ -1,6 +1,6 @@ __version__ = "0.11.4" -from . import async_, log, mdns, mqtt, standalone # isort: skip +from . import async_, log, mdns, mqtt, http, standalone # isort: skip __all__ = [ "__version__", @@ -8,5 +8,6 @@ "log", "mdns", "mqtt", + "http", "standalone", ] 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..05e94b3 --- /dev/null +++ b/src/enapter/http/api/client.py @@ -0,0 +1,29 @@ +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) -> "Client": + 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..e623897 --- /dev/null +++ b/src/enapter/http/api/config.py @@ -0,0 +1,21 @@ +import os +from typing import MutableMapping, Optional + + +class Config: + + @classmethod + def from_env( + cls, + prefix: str = "ENAPTER_HTTP_API_", + env: MutableMapping[str, str] = os.environ, + ) -> "Config": + return cls(token=env[prefix + "TOKEN"], base_url=env.get(prefix + "BASE_URL")) + + def __init__(self, token: str, base_url: Optional[str] = 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..9496469 --- /dev/null +++ b/src/enapter/http/api/devices/communication_config.py @@ -0,0 +1,45 @@ +import dataclasses +from typing import Any, Dict + +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]) -> "CommunicationConfig": + 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=dto["time_sync_protocol"], + 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..7ec4fb7 --- /dev/null +++ b/src/enapter/http/api/devices/device.py @@ -0,0 +1,32 @@ +import dataclasses +import datetime +from typing import Any, Dict + +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]) -> "Device": + 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..298a34a --- /dev/null +++ b/src/enapter/http/api/devices/mqtt_credentials.py @@ -0,0 +1,13 @@ +import dataclasses +from typing import Any, Dict + + +@dataclasses.dataclass +class MQTTCredentials: + + username: str + password: str + + @classmethod + def from_dto(cls, dto: Dict[str, Any]) -> "MQTTCredentials": + 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..8a5320f --- /dev/null +++ b/src/enapter/http/api/devices/mqtts_credentials.py @@ -0,0 +1,18 @@ +import dataclasses +from typing import Any, Dict + + +@dataclasses.dataclass +class MQTTSCredentials: + + private_key: str + certificate: str + ca_chain: str + + @classmethod + def from_dto(cls, dto: Dict[str, Any]) -> "MQTTSCredentials": + 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..1c45a1b --- /dev/null +++ b/src/enapter/http/api/devices/time_sync_protocol.py @@ -0,0 +1,6 @@ +import enum + + +class TimeSyncProtocol(enum.Enum): + + HTTP = "HTTP" From e545670637953f6536b6049ac372a94d95d2b9f6 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Fri, 24 Oct 2025 13:57:34 +0200 Subject: [PATCH 16/43] standalone: device: allow configuring `cmd_prefix` --- src/enapter/standalone/device.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/enapter/standalone/device.py b/src/enapter/standalone/device.py index 574d3ff..374b686 100644 --- a/src/enapter/standalone/device.py +++ b/src/enapter/standalone/device.py @@ -11,8 +11,9 @@ class Device(abc.ABC): - def __init__(self) -> None: + def __init__(self, command_prefix: str = "") -> None: self.logger = Logger() + self.__command_prefix = command_prefix @abc.abstractmethod async def send_properties(self) -> AsyncGenerator[Properties]: @@ -28,7 +29,7 @@ async def send_logs(self) -> AsyncGenerator[Log]: async def execute_command(self, name: str, args: CommandArgs) -> CommandResult: try: - command = getattr(self, name) + command = getattr(self, self.__command_prefix + name) except AttributeError: raise NotImplementedError() from None result = await command(**args) From dd5c48873960edd4352956d02e2bda517e25f68a Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 27 Oct 2025 18:53:09 +0100 Subject: [PATCH 17/43] config: normalize working with env vars --- src/enapter/http/api/config.py | 8 ++++++-- src/enapter/mqtt/config.py | 30 +++++++++++++++++------------- src/enapter/standalone/app.py | 5 ++--- src/enapter/standalone/config.py | 22 +++++++++++----------- 4 files changed, 36 insertions(+), 29 deletions(-) diff --git a/src/enapter/http/api/config.py b/src/enapter/http/api/config.py index e623897..55d2cbf 100644 --- a/src/enapter/http/api/config.py +++ b/src/enapter/http/api/config.py @@ -7,10 +7,14 @@ class Config: @classmethod def from_env( cls, - prefix: str = "ENAPTER_HTTP_API_", env: MutableMapping[str, str] = os.environ, + namespace: str = "ENAPTER_", ) -> "Config": - return cls(token=env[prefix + "TOKEN"], base_url=env.get(prefix + "BASE_URL")) + prefix = namespace + "HTTP_API_" + return cls( + token=env[prefix + "TOKEN"], + base_url=env.get(prefix + "BASE_URL"), + ) def __init__(self, token: str, base_url: Optional[str] = None) -> None: if not token: diff --git a/src/enapter/mqtt/config.py b/src/enapter/mqtt/config.py index d9eede1..b1d66e7 100644 --- a/src/enapter/mqtt/config.py +++ b/src/enapter/mqtt/config.py @@ -6,22 +6,24 @@ class TLSConfig: @classmethod def from_env( - cls, prefix: str = "ENAPTER_", env: MutableMapping[str, str] = os.environ + cls, env: MutableMapping[str, str] = os.environ, namespace: str = "ENAPTER_" ) -> 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") + 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 + "MQTT_TLS_SECRET_KEY") + raise KeyError(prefix + "SECRET_KEY") if cert is None: - raise KeyError(prefix + "MQTT_TLS_CERT") + raise KeyError(prefix + "CERT") if ca_cert is None: - raise KeyError(prefix + "MQTT_TLS_CA_CERT") + raise KeyError(prefix + "CA_CERT") def pem(value: str) -> str: return value.replace("\\n", "\n") @@ -35,16 +37,18 @@ def __init__(self, secret_key: str, cert: str, ca_cert: str) -> None: class Config: + @classmethod def from_env( - cls, prefix: str = "ENAPTER_", env: MutableMapping[str, str] = os.environ + cls, env: MutableMapping[str, str] = os.environ, namespace: str = "ENAPTER_" ) -> "Config": + prefix = namespace + "MQTT_" 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), + host=env[prefix + "HOST"], + port=int(env[prefix + "PORT"]), + user=env.get(prefix + "USER", default=None), + password=env.get(prefix + "PASSWORD", default=None), + tls=TLSConfig.from_env(env, namespace=namespace), ) def __init__( diff --git a/src/enapter/standalone/app.py b/src/enapter/standalone/app.py index 2504549..8bcf81f 100644 --- a/src/enapter/standalone/app.py +++ b/src/enapter/standalone/app.py @@ -1,5 +1,4 @@ import asyncio -from typing import Optional from enapter import log, mqtt @@ -9,10 +8,10 @@ from .ucm import UCM -async def run(device: Device, config_prefix: Optional[str] = None) -> None: +async def run(device: Device) -> None: log.configure(level=log.LEVEL or "info") - config = Config.from_env(prefix=config_prefix) + config = Config.from_env() try: async with asyncio.TaskGroup() as tg: diff --git a/src/enapter/standalone/config.py b/src/enapter/standalone/config.py index 483d18f..6f62a1d 100644 --- a/src/enapter/standalone/config.py +++ b/src/enapter/standalone/config.py @@ -1,19 +1,19 @@ import base64 import json import os -from typing import MutableMapping, Optional +from typing import MutableMapping -import enapter +from enapter import mqtt class Config: @classmethod def from_env( - cls, prefix: Optional[str] = None, env: MutableMapping[str, str] = os.environ + cls, env: MutableMapping[str, str] = os.environ, namespace: str = "ENAPTER_" ) -> "Config": - if prefix is None: - prefix = "ENAPTER_VUCM_" + prefix = namespace + "STANDALONE_" + try: blob = env[prefix + "BLOB"] except KeyError: @@ -29,14 +29,14 @@ def from_env( hardware_id = env[prefix + "HARDWARE_ID"] channel_id = env[prefix + "CHANNEL_ID"] - mqtt = enapter.mqtt.Config.from_env(prefix=prefix, env=env) + mqtt_config = mqtt.Config.from_env(env, namespace=namespace) start_ucm = env.get(prefix + "START_UCM", "1") != "0" return cls( hardware_id=hardware_id, channel_id=channel_id, - mqtt=mqtt, + mqtt=mqtt_config, start_ucm=start_ucm, ) @@ -44,10 +44,10 @@ def from_env( def from_blob(cls, blob: str) -> "Config": payload = json.loads(base64.b64decode(blob)) - mqtt = enapter.mqtt.Config( + mqtt_config = mqtt.Config( host=payload["mqtt_host"], port=int(payload["mqtt_port"]), - tls=enapter.mqtt.TLSConfig( + tls=mqtt.TLSConfig( ca_cert=payload["mqtt_ca"], cert=payload["mqtt_cert"], secret_key=payload["mqtt_private_key"], @@ -57,14 +57,14 @@ def from_blob(cls, blob: str) -> "Config": return cls( hardware_id=payload["ucm_id"], channel_id=payload["channel_id"], - mqtt=mqtt, + mqtt=mqtt_config, ) def __init__( self, hardware_id: str, channel_id: str, - mqtt: enapter.mqtt.Config, + mqtt: mqtt.Config, start_ucm: bool = True, ) -> None: self.hardware_id = hardware_id From b97450984cce2f27e71926fa63cd4b91f66fdd70 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 27 Oct 2025 18:54:12 +0100 Subject: [PATCH 18/43] lint: isort: set profile to black --- .isort.cfg | 1 + src/enapter/standalone/__init__.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) 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/src/enapter/standalone/__init__.py b/src/enapter/standalone/__init__.py index eb50821..f719977 100644 --- a/src/enapter/standalone/__init__.py +++ b/src/enapter/standalone/__init__.py @@ -1,7 +1,6 @@ from .app import App, run from .config import Config -from .device import (CommandArgs, CommandResult, Device, Log, Properties, - Telemetry) +from .device import CommandArgs, CommandResult, Device, Log, Properties, Telemetry from .logger import Logger __all__ = [ From 9795669fd3d2c20125bb622059d1c5613fc9505b Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Tue, 28 Oct 2025 11:13:16 +0100 Subject: [PATCH 19/43] mqtt: client: do not log when publisher disconnected --- src/enapter/mqtt/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/enapter/mqtt/client.py b/src/enapter/mqtt/client.py index 439b309..53c804c 100644 --- a/src/enapter/mqtt/client.py +++ b/src/enapter/mqtt/client.py @@ -74,7 +74,6 @@ 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]: From 7c00cccc61c79c4b3b1b2e1fc93e49292e3129c7 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Tue, 28 Oct 2025 14:44:05 +0100 Subject: [PATCH 20/43] http: api: devices: fix time sync protocol parsing --- src/enapter/http/api/devices/communication_config.py | 2 +- src/enapter/http/api/devices/time_sync_protocol.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/enapter/http/api/devices/communication_config.py b/src/enapter/http/api/devices/communication_config.py index 9496469..0b83684 100644 --- a/src/enapter/http/api/devices/communication_config.py +++ b/src/enapter/http/api/devices/communication_config.py @@ -37,7 +37,7 @@ def from_dto(cls, dto: Dict[str, Any]) -> "CommunicationConfig": mqtt_port=int(dto["mqtt_port"]), mqtt_credentials=mqtt_credentials, mqtt_protocol=mqtt_protocol, - time_sync_protocol=dto["time_sync_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"], diff --git a/src/enapter/http/api/devices/time_sync_protocol.py b/src/enapter/http/api/devices/time_sync_protocol.py index 1c45a1b..47eef88 100644 --- a/src/enapter/http/api/devices/time_sync_protocol.py +++ b/src/enapter/http/api/devices/time_sync_protocol.py @@ -3,4 +3,4 @@ class TimeSyncProtocol(enum.Enum): - HTTP = "HTTP" + NTP = "NTP" From 6f0820d07895667920d9ad366d297bc6da66a3ae Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Tue, 28 Oct 2025 18:45:02 +0100 Subject: [PATCH 21/43] standalone: rework configuration to support api v3 --- src/enapter/mqtt/config.py | 10 +- src/enapter/standalone/app.py | 12 +- src/enapter/standalone/config.py | 222 ++++++++++++++++++++++++------- 3 files changed, 190 insertions(+), 54 deletions(-) diff --git a/src/enapter/mqtt/config.py b/src/enapter/mqtt/config.py index b1d66e7..ff0c9ec 100644 --- a/src/enapter/mqtt/config.py +++ b/src/enapter/mqtt/config.py @@ -48,7 +48,7 @@ def from_env( port=int(env[prefix + "PORT"]), user=env.get(prefix + "USER", default=None), password=env.get(prefix + "PASSWORD", default=None), - tls=TLSConfig.from_env(env, namespace=namespace), + tls_config=TLSConfig.from_env(env, namespace=namespace), ) def __init__( @@ -57,13 +57,17 @@ def __init__( port: int, user: Optional[str] = None, password: Optional[str] = None, - tls: Optional[TLSConfig] = None, + tls_config: Optional[TLSConfig] = None, ) -> None: self.host = host self.port = port self.user = user self.password = password - self.tls = tls + 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)" % ( diff --git a/src/enapter/standalone/app.py b/src/enapter/standalone/app.py index 8bcf81f..d1d421b 100644 --- a/src/enapter/standalone/app.py +++ b/src/enapter/standalone/app.py @@ -31,22 +31,24 @@ def __init__( async def _run(self) -> None: async with asyncio.TaskGroup() as tg: - mqtt_client = mqtt.Client(task_group=tg, config=self._config.mqtt) + mqtt_client = mqtt.Client( + task_group=tg, config=self._config.communication.mqtt + ) _ = DeviceDriver( task_group=tg, device_channel=mqtt.api.DeviceChannel( client=mqtt_client, - hardware_id=self._config.hardware_id, - channel_id=self._config.channel_id, + hardware_id=self._config.communication.hardware_id, + channel_id=self._config.communication.channel_id, ), device=self._device, ) - if self._config.start_ucm: + if self._config.communication.ucm_needed: _ = DeviceDriver( task_group=tg, device_channel=mqtt.api.DeviceChannel( client=mqtt_client, - hardware_id=self._config.hardware_id, + hardware_id=self._config.communication.hardware_id, channel_id="ucm", ), device=UCM(), diff --git a/src/enapter/standalone/config.py b/src/enapter/standalone/config.py index 6f62a1d..b3ba093 100644 --- a/src/enapter/standalone/config.py +++ b/src/enapter/standalone/config.py @@ -1,73 +1,203 @@ import base64 +import dataclasses +import enum import json import os -from typing import MutableMapping +from typing import Any, Dict, MutableMapping, Self from enapter import mqtt +@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_" - ) -> "Config": - prefix = namespace + "STANDALONE_" + ) -> Self: + communication_config = CommunicationConfig.from_env(env, namespace=namespace) + return cls(communication_config=communication_config) - 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"] +@dataclasses.dataclass +class CommunicationConfig: - mqtt_config = mqtt.Config.from_env(env, namespace=namespace) + mqtt_config: mqtt.Config + hardware_id: str + channel_id: str + ucm_needed: bool - start_ucm = env.get(prefix + "START_UCM", "1") != "0" + @property + def mqtt(self) -> mqtt.Config: + return self.mqtt_config - return cls( - hardware_id=hardware_id, - channel_id=channel_id, - mqtt=mqtt_config, - start_ucm=start_ucm, - ) + @classmethod + def from_env( + cls, env: MutableMapping[str, str] = os.environ, namespace: str = "ENAPTER_" + ) -> Self: + prefix = namespace + "STANDALONE_COMMUNICATION_" + blob = env[prefix + "CONFIG"] + return cls.from_blob(blob) @classmethod - def from_blob(cls, blob: str) -> "Config": - payload = json.loads(base64.b64decode(blob)) + 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=payload["mqtt_host"], - port=int(payload["mqtt_port"]), - tls=mqtt.TLSConfig( - ca_cert=payload["mqtt_ca"], - cert=payload["mqtt_cert"], - secret_key=payload["mqtt_private_key"], + 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( - hardware_id=payload["ucm_id"], - channel_id=payload["channel_id"], - mqtt=mqtt_config, + 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"], ) - def __init__( - self, - hardware_id: str, - channel_id: str, - mqtt: 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 + +@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"], + ) From 7e813f18e2d682f93a87ca15b79d94852b3cc36a Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Tue, 28 Oct 2025 18:57:08 +0100 Subject: [PATCH 22/43] typing: modernize annotations --- Makefile | 2 +- src/enapter/http/api/client.py | 4 +++- src/enapter/http/api/config.py | 10 ++++------ .../http/api/devices/communication_config.py | 4 ++-- src/enapter/http/api/devices/device.py | 4 ++-- src/enapter/http/api/devices/mqtt_credentials.py | 4 ++-- .../http/api/devices/mqtts_credentials.py | 4 ++-- src/enapter/log/json_formatter.py | 12 +++++------- src/enapter/mdns/resolver.py | 1 + src/enapter/mqtt/api/command_request.py | 10 +++++----- src/enapter/mqtt/api/command_response.py | 8 ++++---- src/enapter/mqtt/api/log.py | 6 +++--- src/enapter/mqtt/api/message.py | 8 ++++---- src/enapter/mqtt/api/properties.py | 8 ++++---- src/enapter/mqtt/api/telemetry.py | 10 +++++----- src/enapter/mqtt/client.py | 6 +++--- src/enapter/mqtt/config.py | 12 ++++++------ src/enapter/standalone/config.py | 10 +++++----- src/enapter/standalone/device.py | 16 ++++++++-------- src/enapter/standalone/logger.py | 4 ++-- src/enapter/standalone/ucm.py | 4 ++-- 21 files changed, 73 insertions(+), 74 deletions(-) diff --git a/Makefile b/Makefile index fbc1889..851dcb0 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ lint-pyflakes: .PHONY: lint-mypy lint-mypy: - pipenv run mypy enapter + pipenv run mypy src/enapter .PHONY: test test: run-unit-tests run-integration-tests diff --git a/src/enapter/http/api/client.py b/src/enapter/http/api/client.py index 05e94b3..72d156b 100644 --- a/src/enapter/http/api/client.py +++ b/src/enapter/http/api/client.py @@ -1,3 +1,5 @@ +from typing import Self + import httpx from enapter.http.api import devices @@ -17,7 +19,7 @@ def _new_client(self) -> httpx.AsyncClient: base_url=self._config.base_url, ) - async def __aenter__(self) -> "Client": + async def __aenter__(self) -> Self: await self._client.__aenter__() return self diff --git a/src/enapter/http/api/config.py b/src/enapter/http/api/config.py index 55d2cbf..6fa5439 100644 --- a/src/enapter/http/api/config.py +++ b/src/enapter/http/api/config.py @@ -1,22 +1,20 @@ import os -from typing import MutableMapping, Optional +from typing import MutableMapping, Self class Config: @classmethod def from_env( - cls, - env: MutableMapping[str, str] = os.environ, - namespace: str = "ENAPTER_", - ) -> "Config": + 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: Optional[str] = None) -> None: + def __init__(self, token: str, base_url: str | None = None) -> None: if not token: raise ValueError("token is missing") self.token = token diff --git a/src/enapter/http/api/devices/communication_config.py b/src/enapter/http/api/devices/communication_config.py index 0b83684..670025c 100644 --- a/src/enapter/http/api/devices/communication_config.py +++ b/src/enapter/http/api/devices/communication_config.py @@ -1,5 +1,5 @@ import dataclasses -from typing import Any, Dict +from typing import Any, Self from .mqtt_credentials import MQTTCredentials from .mqtt_protocol import MQTTProtocol @@ -21,7 +21,7 @@ class CommunicationConfig: channel_id: str @classmethod - def from_dto(cls, dto: Dict[str, Any]) -> "CommunicationConfig": + def from_dto(cls, dto: dict[str, Any]) -> Self: mqtt_protocol = MQTTProtocol(dto["mqtt_protocol"]) mqtt_credentials: MQTTCredentials | MQTTSCredentials | None = None match mqtt_protocol: diff --git a/src/enapter/http/api/devices/device.py b/src/enapter/http/api/devices/device.py index 7ec4fb7..95558f6 100644 --- a/src/enapter/http/api/devices/device.py +++ b/src/enapter/http/api/devices/device.py @@ -1,6 +1,6 @@ import dataclasses import datetime -from typing import Any, Dict +from typing import Any, Self from .authorized_role import AuthorizedRole from .device_type import DeviceType @@ -19,7 +19,7 @@ class Device: authorized_role: AuthorizedRole @classmethod - def from_dto(cls, dto: Dict[str, Any]) -> "Device": + def from_dto(cls, dto: dict[str, Any]) -> Self: return cls( id=dto["id"], blueprint_id=dto["blueprint_id"], diff --git a/src/enapter/http/api/devices/mqtt_credentials.py b/src/enapter/http/api/devices/mqtt_credentials.py index 298a34a..9241143 100644 --- a/src/enapter/http/api/devices/mqtt_credentials.py +++ b/src/enapter/http/api/devices/mqtt_credentials.py @@ -1,5 +1,5 @@ import dataclasses -from typing import Any, Dict +from typing import Any, Self @dataclasses.dataclass @@ -9,5 +9,5 @@ class MQTTCredentials: password: str @classmethod - def from_dto(cls, dto: Dict[str, Any]) -> "MQTTCredentials": + 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/mqtts_credentials.py b/src/enapter/http/api/devices/mqtts_credentials.py index 8a5320f..af56d82 100644 --- a/src/enapter/http/api/devices/mqtts_credentials.py +++ b/src/enapter/http/api/devices/mqtts_credentials.py @@ -1,5 +1,5 @@ import dataclasses -from typing import Any, Dict +from typing import Any, Self @dataclasses.dataclass @@ -10,7 +10,7 @@ class MQTTSCredentials: ca_chain: str @classmethod - def from_dto(cls, dto: Dict[str, Any]) -> "MQTTSCredentials": + def from_dto(cls, dto: dict[str, Any]) -> Self: return cls( private_key=dto["private_key"], certificate=dto["certificate"], diff --git a/src/enapter/log/json_formatter.py b/src/enapter/log/json_formatter.py index 46b351a..a58b6db 100644 --- a/src/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/src/enapter/mdns/resolver.py b/src/enapter/mdns/resolver.py index 30e3488..a4f5090 100644 --- a/src/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/src/enapter/mqtt/api/command_request.py b/src/enapter/mqtt/api/command_request.py index be90b5c..aaf4a13 100644 --- a/src/enapter/mqtt/api/command_request.py +++ b/src/enapter/mqtt/api/command_request.py @@ -1,5 +1,5 @@ import dataclasses -from typing import Any, Dict +from typing import Any, Self from .command_response import CommandResponse from .command_state import CommandState @@ -11,17 +11,17 @@ class CommandRequest(Message): id: str name: str - arguments: Dict[str, Any] + arguments: dict[str, Any] @classmethod - def from_dto(cls, dto: Dict[str, Any]) -> "CommandRequest": + 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]: + def to_dto(self) -> dict[str, Any]: return { "id": self.id, "name": self.name, @@ -29,7 +29,7 @@ def to_dto(self) -> Dict[str, Any]: } def new_response( - self, state: CommandState, payload: Dict[str, Any] + self, state: CommandState, payload: dict[str, Any] ) -> CommandResponse: return CommandResponse( id=self.id, diff --git a/src/enapter/mqtt/api/command_response.py b/src/enapter/mqtt/api/command_response.py index 49022ec..7f62960 100644 --- a/src/enapter/mqtt/api/command_response.py +++ b/src/enapter/mqtt/api/command_response.py @@ -1,5 +1,5 @@ import dataclasses -from typing import Any, Dict +from typing import Any, Self from .command_state import CommandState from .message import Message @@ -10,17 +10,17 @@ class CommandResponse(Message): id: str state: CommandState - payload: Dict[str, Any] + payload: dict[str, Any] @classmethod - def from_dto(cls, dto: Dict[str, Any]) -> "CommandResponse": + 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]: + def to_dto(self) -> dict[str, Any]: return { "id": self.id, "state": self.state.value, diff --git a/src/enapter/mqtt/api/log.py b/src/enapter/mqtt/api/log.py index 180aca3..7a9eff7 100644 --- a/src/enapter/mqtt/api/log.py +++ b/src/enapter/mqtt/api/log.py @@ -1,5 +1,5 @@ import dataclasses -from typing import Any, Dict +from typing import Any, Self from .log_severity import LogSeverity from .message import Message @@ -14,7 +14,7 @@ class Log(Message): persist: bool @classmethod - def from_dto(cls, dto: Dict[str, Any]) -> "Log": + def from_dto(cls, dto: dict[str, Any]) -> Self: return cls( timestamp=dto["timestamp"], message=dto["message"], @@ -22,7 +22,7 @@ def from_dto(cls, dto: Dict[str, Any]) -> "Log": persist=dto["persist"], ) - def to_dto(self) -> Dict[str, Any]: + def to_dto(self) -> dict[str, Any]: return { "timestamp": self.timestamp, "message": self.message, diff --git a/src/enapter/mqtt/api/message.py b/src/enapter/mqtt/api/message.py index 7d90c2d..b55085c 100644 --- a/src/enapter/mqtt/api/message.py +++ b/src/enapter/mqtt/api/message.py @@ -1,21 +1,21 @@ import abc import json -from typing import Any, Dict, Union +from typing import Any, Self class Message(abc.ABC): @classmethod @abc.abstractmethod - def from_dto(cls, dto: Dict[str, Any]): + def from_dto(cls, dto: dict[str, Any]) -> Self: pass @abc.abstractmethod - def to_dto(self) -> Dict[str, Any]: + def to_dto(self) -> dict[str, Any]: pass @classmethod - def from_json(cls, data: Union[str, bytes]): + def from_json(cls, data: str | bytes) -> Self: dto = json.loads(data) return cls.from_dto(dto) diff --git a/src/enapter/mqtt/api/properties.py b/src/enapter/mqtt/api/properties.py index 4d60bde..64fa10b 100644 --- a/src/enapter/mqtt/api/properties.py +++ b/src/enapter/mqtt/api/properties.py @@ -1,5 +1,5 @@ import dataclasses -from typing import Any, Dict +from typing import Any, Self from .message import Message @@ -8,17 +8,17 @@ class Properties(Message): timestamp: int - values: Dict[str, Any] = dataclasses.field(default_factory=dict) + 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]) -> "Properties": + 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]: + 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 index ecad791..de3c5e9 100644 --- a/src/enapter/mqtt/api/telemetry.py +++ b/src/enapter/mqtt/api/telemetry.py @@ -1,5 +1,5 @@ import dataclasses -from typing import Any, Dict, List, Optional +from typing import Any, Self from .message import Message @@ -8,8 +8,8 @@ class Telemetry(Message): timestamp: int - alerts: Optional[List[str]] = None - values: Dict[str, Any] = dataclasses.field(default_factory=dict) + alerts: list[str] | None = None + values: dict[str, Any] = dataclasses.field(default_factory=dict) def __post_init__(self) -> None: if "timestamp" in self.values: @@ -18,11 +18,11 @@ def __post_init__(self) -> None: raise ValueError("`alerts` is reserved") @classmethod - def from_dto(cls, dto: Dict[str, Any]) -> "Telemetry": + 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]: + def to_dto(self) -> dict[str, Any]: return {"timestamp": self.timestamp, "alerts": self.alerts, **self.values} diff --git a/src/enapter/mqtt/client.py b/src/enapter/mqtt/client.py index 53c804c..cae0e49 100644 --- a/src/enapter/mqtt/client.py +++ b/src/enapter/mqtt/client.py @@ -3,7 +3,7 @@ import logging import ssl import tempfile -from typing import AsyncGenerator, Optional +from typing import AsyncGenerator import aiomqtt # type: ignore @@ -22,7 +22,7 @@ def __init__(self, task_group: asyncio.TaskGroup, config: Config) -> None: self._config = config 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() self._task = task_group.create_task(self._run()) @@ -89,7 +89,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 diff --git a/src/enapter/mqtt/config.py b/src/enapter/mqtt/config.py index ff0c9ec..5fdc4d5 100644 --- a/src/enapter/mqtt/config.py +++ b/src/enapter/mqtt/config.py @@ -1,5 +1,5 @@ import os -from typing import MutableMapping, Optional +from typing import MutableMapping, Self class TLSConfig: @@ -7,7 +7,7 @@ class TLSConfig: @classmethod def from_env( cls, env: MutableMapping[str, str] = os.environ, namespace: str = "ENAPTER_" - ) -> Optional["TLSConfig"]: + ) -> Self | None: prefix = namespace + "MQTT_TLS_" secret_key = env.get(prefix + "SECRET_KEY") @@ -41,7 +41,7 @@ class Config: @classmethod def from_env( cls, env: MutableMapping[str, str] = os.environ, namespace: str = "ENAPTER_" - ) -> "Config": + ) -> Self: prefix = namespace + "MQTT_" return cls( host=env[prefix + "HOST"], @@ -55,9 +55,9 @@ def __init__( self, host: str, port: int, - user: Optional[str] = None, - password: Optional[str] = None, - tls_config: Optional[TLSConfig] = None, + user: str | None = None, + password: str | None = None, + tls_config: TLSConfig | None = None, ) -> None: self.host = host self.port = port diff --git a/src/enapter/standalone/config.py b/src/enapter/standalone/config.py index b3ba093..323647b 100644 --- a/src/enapter/standalone/config.py +++ b/src/enapter/standalone/config.py @@ -3,7 +3,7 @@ import enum import json import os -from typing import Any, Dict, MutableMapping, Self +from typing import Any, MutableMapping, Self from enapter import mqtt @@ -123,7 +123,7 @@ class CommunicationConfigV1: channel_id: str @classmethod - def from_dto(cls, dto: Dict[str, Any]) -> Self: + def from_dto(cls, dto: dict[str, Any]) -> Self: return cls( mqtt_host=dto["mqtt_host"], mqtt_port=int(dto["mqtt_port"]), @@ -150,7 +150,7 @@ class MQTTCredentials: password: str @classmethod - def from_dto(cls, dto: Dict[str, Any]) -> Self: + def from_dto(cls, dto: dict[str, Any]) -> Self: return cls(username=dto["username"], password=dto["password"]) @dataclasses.dataclass @@ -161,7 +161,7 @@ class MQTTSCredentials: ca_chain: str @classmethod - def from_dto(cls, dto: Dict[str, Any]) -> Self: + def from_dto(cls, dto: dict[str, Any]) -> Self: return cls( private_key=dto["private_key"], certificate=dto["certificate"], @@ -176,7 +176,7 @@ def from_dto(cls, dto: Dict[str, Any]) -> Self: channel_id: str @classmethod - def from_dto(cls, dto: Dict[str, Any]) -> Self: + def from_dto(cls, dto: dict[str, Any]) -> Self: mqtt_protocol = cls.MQTTProtocol(dto["mqtt_protocol"]) mqtt_credentials: ( CommunicationConfigV3.MQTTCredentials diff --git a/src/enapter/standalone/device.py b/src/enapter/standalone/device.py index 374b686..05642ab 100644 --- a/src/enapter/standalone/device.py +++ b/src/enapter/standalone/device.py @@ -1,12 +1,12 @@ import abc -from typing import Any, AsyncGenerator, Dict, TypeAlias +from typing import Any, AsyncGenerator, TypeAlias from .logger import Log, Logger -Properties: TypeAlias = Dict[str, Any] -Telemetry: TypeAlias = Dict[str, Any] -CommandArgs: TypeAlias = Dict[str, Any] -CommandResult: TypeAlias = Dict[str, Any] +Properties: TypeAlias = dict[str, Any] +Telemetry: TypeAlias = dict[str, Any] +CommandArgs: TypeAlias = dict[str, Any] +CommandResult: TypeAlias = dict[str, Any] class Device(abc.ABC): @@ -16,14 +16,14 @@ def __init__(self, command_prefix: str = "") -> None: self.__command_prefix = command_prefix @abc.abstractmethod - async def send_properties(self) -> AsyncGenerator[Properties]: + async def send_properties(self) -> AsyncGenerator[Properties, None]: yield {} @abc.abstractmethod - async def send_telemetry(self) -> AsyncGenerator[Telemetry]: + async def send_telemetry(self) -> AsyncGenerator[Telemetry, None]: yield {} - async def send_logs(self) -> AsyncGenerator[Log]: + async def send_logs(self) -> AsyncGenerator[Log, None]: while True: yield await self.logger.queue.get() diff --git a/src/enapter/standalone/logger.py b/src/enapter/standalone/logger.py index 30be267..62cbc52 100644 --- a/src/enapter/standalone/logger.py +++ b/src/enapter/standalone/logger.py @@ -1,7 +1,7 @@ import asyncio -from typing import Literal, Tuple, TypeAlias +from typing import Literal, TypeAlias -Log: TypeAlias = Tuple[Literal["debug", "info", "warning", "error"], str, bool] +Log: TypeAlias = tuple[Literal["debug", "info", "warning", "error"], str, bool] class Logger: diff --git a/src/enapter/standalone/ucm.py b/src/enapter/standalone/ucm.py index a420711..b01452b 100644 --- a/src/enapter/standalone/ucm.py +++ b/src/enapter/standalone/ucm.py @@ -14,12 +14,12 @@ async def upload_lua_script( ) -> CommandResult: raise NotImplementedError - async def send_telemetry(self) -> AsyncGenerator[Telemetry]: + async def send_telemetry(self) -> AsyncGenerator[Telemetry, None]: while True: yield {} await asyncio.sleep(1) - async def send_properties(self) -> AsyncGenerator[Properties]: + async def send_properties(self) -> AsyncGenerator[Properties, None]: while True: yield {"virtual": True, "lua_api_ver": 1} await asyncio.sleep(30) From f62eb3fdcedae42632847db583b747c3d5e3c27a Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Wed, 29 Oct 2025 09:05:25 +0100 Subject: [PATCH 23/43] drop support for python < 3.11 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 8987966b534cf71debc8d6284d92be6bf89134b3 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Wed, 29 Oct 2025 14:05:10 +0100 Subject: [PATCH 24/43] examples: rename `vucm` into `standalone` --- examples/{vucm => standalone}/psutil-battery/Dockerfile | 0 examples/{vucm => standalone}/psutil-battery/docker_run.sh | 0 examples/{vucm => standalone}/psutil-battery/manifest.yml | 0 examples/{vucm => standalone}/psutil-battery/requirements.txt | 0 examples/{vucm => standalone}/psutil-battery/script.py | 0 examples/{vucm => standalone}/rl6-simulator/Dockerfile | 0 examples/{vucm => standalone}/rl6-simulator/docker_run.sh | 0 examples/{vucm => standalone}/rl6-simulator/manifest.yml | 0 examples/{vucm => standalone}/rl6-simulator/requirements.txt | 0 examples/{vucm => standalone}/rl6-simulator/script.py | 0 examples/{vucm => standalone}/snmp-eaton-ups/Dockerfile | 0 examples/{vucm => standalone}/snmp-eaton-ups/README.md | 0 examples/{vucm => standalone}/snmp-eaton-ups/docker-compose.yaml | 0 examples/{vucm => standalone}/snmp-eaton-ups/docker_build.sh | 0 examples/{vucm => standalone}/snmp-eaton-ups/docker_run.sh | 0 examples/{vucm => standalone}/snmp-eaton-ups/manifest.yml | 0 examples/{vucm => standalone}/snmp-eaton-ups/requirements.txt | 0 examples/{vucm => standalone}/snmp-eaton-ups/script.py | 0 examples/{vucm => standalone}/wttr-in/Dockerfile | 0 examples/{vucm => standalone}/wttr-in/docker_run.sh | 0 examples/{vucm => standalone}/wttr-in/manifest.yml | 0 examples/{vucm => standalone}/wttr-in/requirements.txt | 0 examples/{vucm => standalone}/wttr-in/script.py | 0 examples/{vucm => standalone}/zhimi-fan-za5/Dockerfile | 0 examples/{vucm => standalone}/zhimi-fan-za5/docker_run.sh | 0 examples/{vucm => standalone}/zhimi-fan-za5/manifest.yml | 0 examples/{vucm => standalone}/zhimi-fan-za5/requirements.txt | 0 examples/{vucm => standalone}/zhimi-fan-za5/script.py | 0 examples/{vucm => standalone}/zigbee2mqtt/Dockerfile | 0 examples/{vucm => standalone}/zigbee2mqtt/README.md | 0 examples/{vucm => standalone}/zigbee2mqtt/docker-compose.yaml | 0 examples/{vucm => standalone}/zigbee2mqtt/docker_build.sh | 0 examples/{vucm => standalone}/zigbee2mqtt/docker_run.sh | 0 examples/{vucm => standalone}/zigbee2mqtt/manifest.yml | 0 examples/{vucm => standalone}/zigbee2mqtt/requirements.txt | 0 examples/{vucm => standalone}/zigbee2mqtt/script.py | 0 36 files changed, 0 insertions(+), 0 deletions(-) rename examples/{vucm => standalone}/psutil-battery/Dockerfile (100%) rename examples/{vucm => standalone}/psutil-battery/docker_run.sh (100%) rename examples/{vucm => standalone}/psutil-battery/manifest.yml (100%) rename examples/{vucm => standalone}/psutil-battery/requirements.txt (100%) rename examples/{vucm => standalone}/psutil-battery/script.py (100%) rename examples/{vucm => standalone}/rl6-simulator/Dockerfile (100%) rename examples/{vucm => standalone}/rl6-simulator/docker_run.sh (100%) rename examples/{vucm => standalone}/rl6-simulator/manifest.yml (100%) rename examples/{vucm => standalone}/rl6-simulator/requirements.txt (100%) rename examples/{vucm => standalone}/rl6-simulator/script.py (100%) rename examples/{vucm => standalone}/snmp-eaton-ups/Dockerfile (100%) rename examples/{vucm => standalone}/snmp-eaton-ups/README.md (100%) rename examples/{vucm => standalone}/snmp-eaton-ups/docker-compose.yaml (100%) rename examples/{vucm => standalone}/snmp-eaton-ups/docker_build.sh (100%) rename examples/{vucm => standalone}/snmp-eaton-ups/docker_run.sh (100%) rename examples/{vucm => standalone}/snmp-eaton-ups/manifest.yml (100%) rename examples/{vucm => standalone}/snmp-eaton-ups/requirements.txt (100%) rename examples/{vucm => standalone}/snmp-eaton-ups/script.py (100%) rename examples/{vucm => standalone}/wttr-in/Dockerfile (100%) rename examples/{vucm => standalone}/wttr-in/docker_run.sh (100%) rename examples/{vucm => standalone}/wttr-in/manifest.yml (100%) rename examples/{vucm => standalone}/wttr-in/requirements.txt (100%) rename examples/{vucm => standalone}/wttr-in/script.py (100%) rename examples/{vucm => standalone}/zhimi-fan-za5/Dockerfile (100%) rename examples/{vucm => standalone}/zhimi-fan-za5/docker_run.sh (100%) rename examples/{vucm => standalone}/zhimi-fan-za5/manifest.yml (100%) rename examples/{vucm => standalone}/zhimi-fan-za5/requirements.txt (100%) rename examples/{vucm => standalone}/zhimi-fan-za5/script.py (100%) rename examples/{vucm => standalone}/zigbee2mqtt/Dockerfile (100%) rename examples/{vucm => standalone}/zigbee2mqtt/README.md (100%) rename examples/{vucm => standalone}/zigbee2mqtt/docker-compose.yaml (100%) rename examples/{vucm => standalone}/zigbee2mqtt/docker_build.sh (100%) rename examples/{vucm => standalone}/zigbee2mqtt/docker_run.sh (100%) rename examples/{vucm => standalone}/zigbee2mqtt/manifest.yml (100%) rename examples/{vucm => standalone}/zigbee2mqtt/requirements.txt (100%) rename examples/{vucm => standalone}/zigbee2mqtt/script.py (100%) diff --git a/examples/vucm/psutil-battery/Dockerfile b/examples/standalone/psutil-battery/Dockerfile similarity index 100% rename from examples/vucm/psutil-battery/Dockerfile rename to examples/standalone/psutil-battery/Dockerfile diff --git a/examples/vucm/psutil-battery/docker_run.sh b/examples/standalone/psutil-battery/docker_run.sh similarity index 100% rename from examples/vucm/psutil-battery/docker_run.sh rename to examples/standalone/psutil-battery/docker_run.sh 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/vucm/psutil-battery/requirements.txt b/examples/standalone/psutil-battery/requirements.txt similarity index 100% rename from examples/vucm/psutil-battery/requirements.txt rename to examples/standalone/psutil-battery/requirements.txt diff --git a/examples/vucm/psutil-battery/script.py b/examples/standalone/psutil-battery/script.py similarity index 100% rename from examples/vucm/psutil-battery/script.py rename to examples/standalone/psutil-battery/script.py diff --git a/examples/vucm/rl6-simulator/Dockerfile b/examples/standalone/rl6-simulator/Dockerfile similarity index 100% rename from examples/vucm/rl6-simulator/Dockerfile rename to examples/standalone/rl6-simulator/Dockerfile diff --git a/examples/vucm/rl6-simulator/docker_run.sh b/examples/standalone/rl6-simulator/docker_run.sh similarity index 100% rename from examples/vucm/rl6-simulator/docker_run.sh rename to examples/standalone/rl6-simulator/docker_run.sh 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/vucm/rl6-simulator/requirements.txt b/examples/standalone/rl6-simulator/requirements.txt similarity index 100% rename from examples/vucm/rl6-simulator/requirements.txt rename to examples/standalone/rl6-simulator/requirements.txt diff --git a/examples/vucm/rl6-simulator/script.py b/examples/standalone/rl6-simulator/script.py similarity index 100% rename from examples/vucm/rl6-simulator/script.py rename to examples/standalone/rl6-simulator/script.py diff --git a/examples/vucm/snmp-eaton-ups/Dockerfile b/examples/standalone/snmp-eaton-ups/Dockerfile similarity index 100% rename from examples/vucm/snmp-eaton-ups/Dockerfile rename to examples/standalone/snmp-eaton-ups/Dockerfile diff --git a/examples/vucm/snmp-eaton-ups/README.md b/examples/standalone/snmp-eaton-ups/README.md similarity index 100% rename from examples/vucm/snmp-eaton-ups/README.md rename to examples/standalone/snmp-eaton-ups/README.md diff --git a/examples/vucm/snmp-eaton-ups/docker-compose.yaml b/examples/standalone/snmp-eaton-ups/docker-compose.yaml similarity index 100% rename from examples/vucm/snmp-eaton-ups/docker-compose.yaml rename to examples/standalone/snmp-eaton-ups/docker-compose.yaml diff --git a/examples/vucm/snmp-eaton-ups/docker_build.sh b/examples/standalone/snmp-eaton-ups/docker_build.sh similarity index 100% rename from examples/vucm/snmp-eaton-ups/docker_build.sh rename to examples/standalone/snmp-eaton-ups/docker_build.sh diff --git a/examples/vucm/snmp-eaton-ups/docker_run.sh b/examples/standalone/snmp-eaton-ups/docker_run.sh similarity index 100% rename from examples/vucm/snmp-eaton-ups/docker_run.sh rename to examples/standalone/snmp-eaton-ups/docker_run.sh 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 100% rename from examples/vucm/snmp-eaton-ups/requirements.txt rename to examples/standalone/snmp-eaton-ups/requirements.txt diff --git a/examples/vucm/snmp-eaton-ups/script.py b/examples/standalone/snmp-eaton-ups/script.py similarity index 100% rename from examples/vucm/snmp-eaton-ups/script.py rename to examples/standalone/snmp-eaton-ups/script.py diff --git a/examples/vucm/wttr-in/Dockerfile b/examples/standalone/wttr-in/Dockerfile similarity index 100% rename from examples/vucm/wttr-in/Dockerfile rename to examples/standalone/wttr-in/Dockerfile diff --git a/examples/vucm/wttr-in/docker_run.sh b/examples/standalone/wttr-in/docker_run.sh similarity index 100% rename from examples/vucm/wttr-in/docker_run.sh rename to examples/standalone/wttr-in/docker_run.sh 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/vucm/wttr-in/requirements.txt b/examples/standalone/wttr-in/requirements.txt similarity index 100% rename from examples/vucm/wttr-in/requirements.txt rename to examples/standalone/wttr-in/requirements.txt diff --git a/examples/vucm/wttr-in/script.py b/examples/standalone/wttr-in/script.py similarity index 100% rename from examples/vucm/wttr-in/script.py rename to examples/standalone/wttr-in/script.py diff --git a/examples/vucm/zhimi-fan-za5/Dockerfile b/examples/standalone/zhimi-fan-za5/Dockerfile similarity index 100% rename from examples/vucm/zhimi-fan-za5/Dockerfile rename to examples/standalone/zhimi-fan-za5/Dockerfile diff --git a/examples/vucm/zhimi-fan-za5/docker_run.sh b/examples/standalone/zhimi-fan-za5/docker_run.sh similarity index 100% rename from examples/vucm/zhimi-fan-za5/docker_run.sh rename to examples/standalone/zhimi-fan-za5/docker_run.sh diff --git a/examples/vucm/zhimi-fan-za5/manifest.yml b/examples/standalone/zhimi-fan-za5/manifest.yml similarity index 100% rename from examples/vucm/zhimi-fan-za5/manifest.yml rename to examples/standalone/zhimi-fan-za5/manifest.yml diff --git a/examples/vucm/zhimi-fan-za5/requirements.txt b/examples/standalone/zhimi-fan-za5/requirements.txt similarity index 100% rename from examples/vucm/zhimi-fan-za5/requirements.txt rename to examples/standalone/zhimi-fan-za5/requirements.txt diff --git a/examples/vucm/zhimi-fan-za5/script.py b/examples/standalone/zhimi-fan-za5/script.py similarity index 100% rename from examples/vucm/zhimi-fan-za5/script.py rename to examples/standalone/zhimi-fan-za5/script.py diff --git a/examples/vucm/zigbee2mqtt/Dockerfile b/examples/standalone/zigbee2mqtt/Dockerfile similarity index 100% rename from examples/vucm/zigbee2mqtt/Dockerfile rename to examples/standalone/zigbee2mqtt/Dockerfile diff --git a/examples/vucm/zigbee2mqtt/README.md b/examples/standalone/zigbee2mqtt/README.md similarity index 100% rename from examples/vucm/zigbee2mqtt/README.md rename to examples/standalone/zigbee2mqtt/README.md diff --git a/examples/vucm/zigbee2mqtt/docker-compose.yaml b/examples/standalone/zigbee2mqtt/docker-compose.yaml similarity index 100% rename from examples/vucm/zigbee2mqtt/docker-compose.yaml rename to examples/standalone/zigbee2mqtt/docker-compose.yaml diff --git a/examples/vucm/zigbee2mqtt/docker_build.sh b/examples/standalone/zigbee2mqtt/docker_build.sh similarity index 100% rename from examples/vucm/zigbee2mqtt/docker_build.sh rename to examples/standalone/zigbee2mqtt/docker_build.sh diff --git a/examples/vucm/zigbee2mqtt/docker_run.sh b/examples/standalone/zigbee2mqtt/docker_run.sh similarity index 100% rename from examples/vucm/zigbee2mqtt/docker_run.sh rename to examples/standalone/zigbee2mqtt/docker_run.sh 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/vucm/zigbee2mqtt/requirements.txt b/examples/standalone/zigbee2mqtt/requirements.txt similarity index 100% rename from examples/vucm/zigbee2mqtt/requirements.txt rename to examples/standalone/zigbee2mqtt/requirements.txt diff --git a/examples/vucm/zigbee2mqtt/script.py b/examples/standalone/zigbee2mqtt/script.py similarity index 100% rename from examples/vucm/zigbee2mqtt/script.py rename to examples/standalone/zigbee2mqtt/script.py From b052a39ecbd30fc3dd00095aacc0a65f4586bd43 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Thu, 30 Oct 2025 16:49:38 +0100 Subject: [PATCH 25/43] standalone: split device protocol and base class --- src/enapter/standalone/__init__.py | 11 ++++- src/enapter/standalone/device.py | 60 ++++++++++++++++------- src/enapter/standalone/device_driver.py | 29 +++++------ src/enapter/standalone/device_protocol.py | 33 +++++++++++++ src/enapter/standalone/logger.py | 19 +++---- src/enapter/standalone/ucm.py | 26 ++++------ 6 files changed, 117 insertions(+), 61 deletions(-) create mode 100644 src/enapter/standalone/device_protocol.py diff --git a/src/enapter/standalone/__init__.py b/src/enapter/standalone/__init__.py index f719977..d93fbf8 100644 --- a/src/enapter/standalone/__init__.py +++ b/src/enapter/standalone/__init__.py @@ -1,6 +1,14 @@ from .app import App, run from .config import Config -from .device import CommandArgs, CommandResult, Device, Log, Properties, Telemetry +from .device import Device +from .device_protocol import ( + CommandArgs, + CommandResult, + DeviceProtocol, + Log, + Properties, + Telemetry, +) from .logger import Logger __all__ = [ @@ -9,6 +17,7 @@ "CommandResult", "Config", "Device", + "DeviceProtocol", "Log", "Logger", "Properties", diff --git a/src/enapter/standalone/device.py b/src/enapter/standalone/device.py index 05642ab..fe6407a 100644 --- a/src/enapter/standalone/device.py +++ b/src/enapter/standalone/device.py @@ -1,35 +1,59 @@ import abc -from typing import Any, AsyncGenerator, TypeAlias +import asyncio +from typing import AsyncGenerator -from .logger import Log, Logger - -Properties: TypeAlias = dict[str, Any] -Telemetry: TypeAlias = dict[str, Any] -CommandArgs: TypeAlias = dict[str, Any] -CommandResult: TypeAlias = dict[str, Any] +from .device_protocol import CommandArgs, CommandResult, Log, Properties, Telemetry +from .logger import Logger class Device(abc.ABC): - def __init__(self, command_prefix: str = "") -> None: - self.logger = Logger() - self.__command_prefix = command_prefix + 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 send_properties(self) -> AsyncGenerator[Properties, None]: - yield {} + async def run(self) -> None: + pass - @abc.abstractmethod - async def send_telemetry(self) -> AsyncGenerator[Telemetry, None]: - yield {} + @property + async 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 send_logs(self) -> AsyncGenerator[Log, None]: + async def stream_logs(self) -> AsyncGenerator[Log, None]: while True: - yield await self.logger.queue.get() + yield await self._log_queue.get() async def execute_command(self, name: str, args: CommandArgs) -> CommandResult: try: - command = getattr(self, self.__command_prefix + name) + command = getattr(self, self._command_prefix + name) except AttributeError: raise NotImplementedError() from None result = await command(**args) diff --git a/src/enapter/standalone/device_driver.py b/src/enapter/standalone/device_driver.py index 71eb91f..643615c 100644 --- a/src/enapter/standalone/device_driver.py +++ b/src/enapter/standalone/device_driver.py @@ -5,7 +5,7 @@ from enapter import mqtt -from .device import Device +from .device_protocol import DeviceProtocol class DeviceDriver: @@ -14,7 +14,7 @@ def __init__( self, task_group: asyncio.TaskGroup, device_channel: mqtt.api.DeviceChannel, - device: Device, + device: DeviceProtocol, ) -> None: self._device_channel = device_channel self._device = device @@ -22,13 +22,14 @@ def __init__( async def _run(self) -> None: async with asyncio.TaskGroup() as tg: - tg.create_task(self._send_properties()) - tg.create_task(self._send_telemetry()) - tg.create_task(self._send_logs()) + 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 _send_properties(self) -> None: - async with contextlib.aclosing(self._device.send_properties()) as iterator: + 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())) @@ -38,8 +39,8 @@ async def _send_properties(self) -> None: ) ) - async def _send_telemetry(self) -> None: - async with contextlib.aclosing(self._device.send_telemetry()) as iterator: + 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())) @@ -50,15 +51,15 @@ async def _send_telemetry(self) -> None: ) ) - async def _send_logs(self) -> None: - async with contextlib.aclosing(self._device.send_logs()) as iterator: + 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[0]), - message=log[1], - persist=log[2], + severity=mqtt.api.LogSeverity(log.severity), + message=log.message, + persist=log.persist, ) ) 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 index 62cbc52..6c179cf 100644 --- a/src/enapter/standalone/logger.py +++ b/src/enapter/standalone/logger.py @@ -1,26 +1,21 @@ import asyncio -from typing import Literal, TypeAlias -Log: TypeAlias = tuple[Literal["debug", "info", "warning", "error"], str, bool] +from .device_protocol import Log class Logger: - def __init__(self) -> None: - self._queue: asyncio.Queue[Log] = asyncio.Queue(1) + 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(("debug", msg, persist)) + await self._queue.put(Log("debug", msg, persist)) async def info(self, msg: str, persist: bool = False) -> None: - await self._queue.put(("info", msg, persist)) + await self._queue.put(Log("info", msg, persist)) async def warning(self, msg: str, persist: bool = False) -> None: - await self._queue.put(("warning", msg, persist)) + await self._queue.put(Log("warning", msg, persist)) async def error(self, msg: str, persist: bool = False) -> None: - await self._queue.put(("error", msg, persist)) - - @property - def queue(self) -> asyncio.Queue[Log]: - return self._queue + await self._queue.put(Log("error", msg, persist)) diff --git a/src/enapter/standalone/ucm.py b/src/enapter/standalone/ucm.py index b01452b..bc2ae23 100644 --- a/src/enapter/standalone/ucm.py +++ b/src/enapter/standalone/ucm.py @@ -1,25 +1,19 @@ import asyncio -from typing import AsyncGenerator -from .device import CommandResult, Device, Properties, Telemetry +from .device import Device +from .device_protocol import CommandResult class UCM(Device): - async def reboot(self) -> CommandResult: - raise NotImplementedError + async def run(self) -> None: + while True: + await self.send_properties({"virtual": True, "lua_api_ver": 1}) + await self.send_telemetry({}) + await asyncio.sleep(30) - async def upload_lua_script( - self, url: str, sha1: str, payload=None - ) -> CommandResult: + async def cmd_reboot(self, *args, **kwargs) -> CommandResult: raise NotImplementedError - async def send_telemetry(self) -> AsyncGenerator[Telemetry, None]: - while True: - yield {} - await asyncio.sleep(1) - - async def send_properties(self) -> AsyncGenerator[Properties, None]: - while True: - yield {"virtual": True, "lua_api_ver": 1} - await asyncio.sleep(30) + async def cmd_upload_lua_script(self, *args, **kwargs) -> CommandResult: + raise NotImplementedError From 0d0a6ed6c618ed5d3aa44899d627495be8bade79 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Thu, 30 Oct 2025 16:48:10 +0100 Subject: [PATCH 26/43] examples: standalone: fix psutil-battery --- examples/standalone/psutil-battery/Dockerfile | 2 +- .../standalone/psutil-battery/docker_run.sh | 4 +- .../psutil-battery/requirements.txt | 2 +- examples/standalone/psutil-battery/script.py | 49 +++++++++---------- 4 files changed, 26 insertions(+), 31 deletions(-) diff --git a/examples/standalone/psutil-battery/Dockerfile b/examples/standalone/psutil-battery/Dockerfile index 968c365..2afbc6e 100644 --- a/examples/standalone/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/standalone/psutil-battery/docker_run.sh b/examples/standalone/psutil-battery/docker_run.sh index 6db7a50..d2443b8 100755 --- a/examples/standalone/psutil-battery/docker_run.sh +++ b/examples/standalone/psutil-battery/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/standalone/psutil-battery/requirements.txt b/examples/standalone/psutil-battery/requirements.txt index 5aeb18b..21b9af1 100644 --- a/examples/standalone/psutil-battery/requirements.txt +++ b/examples/standalone/psutil-battery/requirements.txt @@ -1,2 +1,2 @@ enapter==0.11.4 -psutil==5.9.4 +psutil==7.1.2 diff --git a/examples/standalone/psutil-battery/script.py b/examples/standalone/psutil-battery/script.py index cc3f794..0fbcbb4 100644 --- a/examples/standalone/psutil-battery/script.py +++ b/examples/standalone/psutil-battery/script.py @@ -1,5 +1,4 @@ import asyncio -from typing import Any, Dict, Tuple import psutil @@ -7,39 +6,35 @@ async def main(): - await enapter.vucm.run(PSUtilBattery) + await enapter.standalone.run(PSUtilBattery()) -class PSUtilBattery(enapter.vucm.Device): - @enapter.vucm.device_task - async def data_sender(self): +class PSUtilBattery(enapter.standalone.Device): + + async def run(self): while True: - telemetry, properties, delay = await self.gather_data() - await self.send_telemetry(telemetry) + properties, telemetry = await self.gather_data() await self.send_properties(properties) - await asyncio.sleep(delay) + await self.send_telemetry(telemetry) + await asyncio.sleep(10) - async def gather_data(self) -> Tuple[Dict[str, Any], Dict[str, Any], int]: + async def gather_data(self): try: - battery = psutil.sensors_battery() + battery = await asyncio.to_thread(psutil.sensors_battery) except Exception as e: - await self.log.error(f"failed to gather data: {e}") - self.alerts.add("gather_data_error") - return {}, {}, 10 - self.alerts.clear() - - telemetry: Dict[str, Any] = {} - properties: Dict[str, Any] = {"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 + 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__": From c0fef1744a3f2333612a00e3099216dfaede40cb Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Thu, 30 Oct 2025 16:48:34 +0100 Subject: [PATCH 27/43] examples: standalone: fix rl6-simulator --- examples/standalone/rl6-simulator/Dockerfile | 2 +- .../standalone/rl6-simulator/docker_run.sh | 5 ++- examples/standalone/rl6-simulator/script.py | 34 ++++++++++--------- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/examples/standalone/rl6-simulator/Dockerfile b/examples/standalone/rl6-simulator/Dockerfile index d4e42b0..57c8991 100644 --- a/examples/standalone/rl6-simulator/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/standalone/rl6-simulator/docker_run.sh b/examples/standalone/rl6-simulator/docker_run.sh index 8f7686a..d2443b8 100755 --- a/examples/standalone/rl6-simulator/docker_run.sh +++ b/examples/standalone/rl6-simulator/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/standalone/rl6-simulator/script.py b/examples/standalone/rl6-simulator/script.py index c5a7aaa..3789501 100644 --- a/examples/standalone/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,25 +20,26 @@ 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__": From a4fa27dc851a8da0e7a60d180b2ef862b5444668 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Thu, 30 Oct 2025 16:48:51 +0100 Subject: [PATCH 28/43] examples: standalone: fix wttr-in --- examples/standalone/wttr-in/Dockerfile | 2 +- examples/standalone/wttr-in/docker_run.sh | 2 +- examples/standalone/wttr-in/requirements.txt | 2 +- examples/standalone/wttr-in/script.py | 39 ++++++++------------ 4 files changed, 18 insertions(+), 27 deletions(-) diff --git a/examples/standalone/wttr-in/Dockerfile b/examples/standalone/wttr-in/Dockerfile index d4e42b0..57c8991 100644 --- a/examples/standalone/wttr-in/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/standalone/wttr-in/docker_run.sh b/examples/standalone/wttr-in/docker_run.sh index e93a6ba..a03687d 100755 --- a/examples/standalone/wttr-in/docker_run.sh +++ b/examples/standalone/wttr-in/docker_run.sh @@ -4,7 +4,7 @@ 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" diff --git a/examples/standalone/wttr-in/requirements.txt b/examples/standalone/wttr-in/requirements.txt index 5fbe5b6..3440339 100644 --- a/examples/standalone/wttr-in/requirements.txt +++ b/examples/standalone/wttr-in/requirements.txt @@ -1,2 +1,2 @@ enapter==0.11.4 -python-weather==0.4.2 +python-weather==2.1.0 diff --git a/examples/standalone/wttr-in/script.py b/examples/standalone/wttr-in/script.py index 8b830bf..c6ee79c 100644 --- a/examples/standalone/wttr-in/script.py +++ b/examples/standalone/wttr-in/script.py @@ -1,5 +1,4 @@ import asyncio -import functools import os import python_weather @@ -9,45 +8,37 @@ 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.standalone.run( + 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) +class WttrIn(enapter.standalone.Device): + + def __init__(self, client, location): + super().__init__() self.client = client self.location = location - @enapter.vucm.device_task + 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 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() + await self.send_telemetry({"temperature": weather.temperature}) except Exception as e: - self.alerts.add("wttr_in_error") await self.log.error(f"failed to get weather: {e}") - await asyncio.sleep(1) + await self.send_telemetry({"alerts": ["wttr_in_error"]}) + await asyncio.sleep(10) if __name__ == "__main__": From acc9f1525c5e095a9ffe0f789a28d1b200e0b1cf Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Thu, 30 Oct 2025 19:17:11 +0100 Subject: [PATCH 29/43] examples: standalone: fix smart fan --- .../{zhimi-fan-za5 => mi-fan-1c}/Dockerfile | 2 +- .../docker_run.sh | 4 +- examples/standalone/mi-fan-1c/manifest.yml | 64 +++++++++++++++++ .../requirements.txt | 0 examples/standalone/mi-fan-1c/script.py | 49 +++++++++++++ .../standalone/zhimi-fan-za5/manifest.yml | 71 ------------------- examples/standalone/zhimi-fan-za5/script.py | 59 --------------- 7 files changed, 116 insertions(+), 133 deletions(-) rename examples/standalone/{zhimi-fan-za5 => mi-fan-1c}/Dockerfile (88%) rename examples/standalone/{zhimi-fan-za5 => mi-fan-1c}/docker_run.sh (62%) create mode 100644 examples/standalone/mi-fan-1c/manifest.yml rename examples/standalone/{zhimi-fan-za5 => mi-fan-1c}/requirements.txt (100%) create mode 100644 examples/standalone/mi-fan-1c/script.py delete mode 100644 examples/standalone/zhimi-fan-za5/manifest.yml delete mode 100644 examples/standalone/zhimi-fan-za5/script.py diff --git a/examples/standalone/zhimi-fan-za5/Dockerfile b/examples/standalone/mi-fan-1c/Dockerfile similarity index 88% rename from examples/standalone/zhimi-fan-za5/Dockerfile rename to examples/standalone/mi-fan-1c/Dockerfile index d4e42b0..57c8991 100644 --- a/examples/standalone/zhimi-fan-za5/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/standalone/zhimi-fan-za5/docker_run.sh b/examples/standalone/mi-fan-1c/docker_run.sh similarity index 62% rename from examples/standalone/zhimi-fan-za5/docker_run.sh rename to examples/standalone/mi-fan-1c/docker_run.sh index afbc898..0c8ee7b 100755 --- a/examples/standalone/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/standalone/zhimi-fan-za5/requirements.txt b/examples/standalone/mi-fan-1c/requirements.txt similarity index 100% rename from examples/standalone/zhimi-fan-za5/requirements.txt rename to examples/standalone/mi-fan-1c/requirements.txt diff --git a/examples/standalone/mi-fan-1c/script.py b/examples/standalone/mi-fan-1c/script.py new file mode 100644 index 0000000..d7971d2 --- /dev/null +++ b/examples/standalone/mi-fan-1c/script.py @@ -0,0 +1,49 @@ +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__": + asyncio.run(main()) diff --git a/examples/standalone/zhimi-fan-za5/manifest.yml b/examples/standalone/zhimi-fan-za5/manifest.yml deleted file mode 100644 index 328d1ec..0000000 --- a/examples/standalone/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/standalone/zhimi-fan-za5/script.py b/examples/standalone/zhimi-fan-za5/script.py deleted file mode 100644 index 54ab746..0000000 --- a/examples/standalone/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()) From f33eec115822dffa1ca64409b13596f72d5929cf Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Thu, 30 Oct 2025 19:17:46 +0100 Subject: [PATCH 30/43] standalone: device: wrap command result in object --- src/enapter/standalone/device.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/enapter/standalone/device.py b/src/enapter/standalone/device.py index fe6407a..ae23a36 100644 --- a/src/enapter/standalone/device.py +++ b/src/enapter/standalone/device.py @@ -56,5 +56,4 @@ async def execute_command(self, name: str, args: CommandArgs) -> CommandResult: command = getattr(self, self._command_prefix + name) except AttributeError: raise NotImplementedError() from None - result = await command(**args) - return result if result is not None else {} + return {"result": await command(**args)} From 21055f97e5f9ec6c642cc8340e82b7f094b0ed2f Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Thu, 30 Oct 2025 19:23:22 +0100 Subject: [PATCH 31/43] tests: unit: mqtt: fix missing alerts null field --- tests/unit/test_mqtt/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_mqtt/test_api.py b/tests/unit/test_mqtt/test_api.py index 1562b1e..00628e5 100644 --- a/tests/unit/test_mqtt/test_api.py +++ b/tests/unit/test_mqtt/test_api.py @@ -17,7 +17,7 @@ async def test_publish_telemetry(self, fake): ) 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): From 82b87bb4c04cc8a1f875e567c6b5069f432a0b5b Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 3 Nov 2025 09:40:58 +0100 Subject: [PATCH 32/43] standalone: ucm: use different intervals for sending properties and telemetry --- src/enapter/standalone/ucm.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/enapter/standalone/ucm.py b/src/enapter/standalone/ucm.py index bc2ae23..91c0b76 100644 --- a/src/enapter/standalone/ucm.py +++ b/src/enapter/standalone/ucm.py @@ -7,11 +7,20 @@ 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 self.send_telemetry({}) 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 From 70f7d2e869422ccbd59d93b4f02034dee33aba89 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 3 Nov 2025 14:38:11 +0100 Subject: [PATCH 33/43] standalone: app: propagate `CancelledError` --- examples/standalone/mi-fan-1c/script.py | 5 ++++- examples/standalone/psutil-battery/script.py | 5 ++++- examples/standalone/rl6-simulator/script.py | 5 ++++- examples/standalone/wttr-in/script.py | 5 ++++- src/enapter/standalone/app.py | 9 ++------- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/examples/standalone/mi-fan-1c/script.py b/examples/standalone/mi-fan-1c/script.py index d7971d2..a53391f 100644 --- a/examples/standalone/mi-fan-1c/script.py +++ b/examples/standalone/mi-fan-1c/script.py @@ -46,4 +46,7 @@ async def cmd_speed(self, speed: int): if __name__ == "__main__": - asyncio.run(main()) + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/examples/standalone/psutil-battery/script.py b/examples/standalone/psutil-battery/script.py index 0fbcbb4..ee5bfed 100644 --- a/examples/standalone/psutil-battery/script.py +++ b/examples/standalone/psutil-battery/script.py @@ -38,4 +38,7 @@ async def gather_data(self): if __name__ == "__main__": - asyncio.run(main()) + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/examples/standalone/rl6-simulator/script.py b/examples/standalone/rl6-simulator/script.py index 3789501..3f5c2f6 100644 --- a/examples/standalone/rl6-simulator/script.py +++ b/examples/standalone/rl6-simulator/script.py @@ -43,4 +43,7 @@ async def cmd_disable_load(self, load: str): if __name__ == "__main__": - asyncio.run(main()) + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/examples/standalone/wttr-in/script.py b/examples/standalone/wttr-in/script.py index c6ee79c..e77b8ac 100644 --- a/examples/standalone/wttr-in/script.py +++ b/examples/standalone/wttr-in/script.py @@ -42,4 +42,7 @@ async def telemetry_sender(self): if __name__ == "__main__": - asyncio.run(main()) + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/src/enapter/standalone/app.py b/src/enapter/standalone/app.py index d1d421b..87b31bb 100644 --- a/src/enapter/standalone/app.py +++ b/src/enapter/standalone/app.py @@ -10,14 +10,9 @@ async def run(device: Device) -> None: log.configure(level=log.LEVEL or "info") - config = Config.from_env() - - try: - async with asyncio.TaskGroup() as tg: - _ = App(task_group=tg, config=config, device=device) - except asyncio.CancelledError: - pass + async with asyncio.TaskGroup() as tg: + _ = App(task_group=tg, config=config, device=device) class App: From 07dfd727501164b98b4c6ca682befb1bf186c363 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 3 Nov 2025 15:07:00 +0100 Subject: [PATCH 34/43] examples: standalone: try to fix snmp-eaton-ups --- examples/standalone/snmp-eaton-ups/Dockerfile | 2 +- examples/standalone/snmp-eaton-ups/README.md | 97 +++++++++++-------- .../snmp-eaton-ups/docker-compose.yaml | 6 +- .../standalone/snmp-eaton-ups/docker_build.sh | 2 +- .../standalone/snmp-eaton-ups/docker_run.sh | 2 +- examples/standalone/snmp-eaton-ups/script.py | 29 +++--- 6 files changed, 80 insertions(+), 58 deletions(-) diff --git a/examples/standalone/snmp-eaton-ups/Dockerfile b/examples/standalone/snmp-eaton-ups/Dockerfile index d4e42b0..57c8991 100644 --- a/examples/standalone/snmp-eaton-ups/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 index 4271427..1454314 100644 --- a/examples/standalone/snmp-eaton-ups/README.md +++ b/examples/standalone/snmp-eaton-ups/README.md @@ -1,43 +1,59 @@ -# Eaton UPS Standalone UCM (SNMP) +# Eaton UPS Standalone Device (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. +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 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. +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: +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 +- 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. +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: +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. +## Step 1. Create Standalone Device in Enapter Cloud -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 +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. -More information you can find on [this page](https://developers.enapter.com/docs/tutorial/software-ucms/standalone). +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. +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. +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). +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 @@ -50,31 +66,33 @@ upload started with operation id 25721 Done! ``` -## Step 3. Configuring Standalone UCM +## 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: +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 + image: enapter-standalone-examples/snmp-eaton-ups:latest 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" ``` -## Step 4. Build Docker Image with Standalone UCM +## Step 4. Build Docker Image with Standalone Device -> You can you can skip this step and go directly to th Step 5. -> Docker Compose will automatically build your image before starting container. +> 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 UCM. +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 @@ -119,29 +137,30 @@ user@pc snmp-eaton-ups % bash docker_build.sh #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 naming to docker.io/enapter-standalone-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: +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-vucm-examples/snmp-eaton-ups -REPOSITORY TAG IMAGE ID CREATED SIZE -enapter-vucm-examples/snmp-eaton-ups latest 92e1050debea 5 hours ago 285MB +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 UCM Docker Container +## Step 5. Run your Standalone Device Docker Container -Finally run your Standalone UCM with `docker-compose up` command: +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-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"} + ✔ 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 UCM is now Online in the Cloud. +On this step you can check that your Device is now Online in the Cloud. diff --git a/examples/standalone/snmp-eaton-ups/docker-compose.yaml b/examples/standalone/snmp-eaton-ups/docker-compose.yaml index d323011..e7fb7b0 100644 --- a/examples/standalone/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/standalone/snmp-eaton-ups/docker_build.sh b/examples/standalone/snmp-eaton-ups/docker_build.sh index a9f7d29..594c43f 100755 --- a/examples/standalone/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/standalone/snmp-eaton-ups/docker_run.sh b/examples/standalone/snmp-eaton-ups/docker_run.sh index 0200929..eef44ba 100755 --- a/examples/standalone/snmp-eaton-ups/docker_run.sh +++ b/examples/standalone/snmp-eaton-ups/docker_run.sh @@ -4,7 +4,7 @@ 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 diff --git a/examples/standalone/snmp-eaton-ups/script.py b/examples/standalone/snmp-eaton-ups/script.py index 70a4bba..be36703 100644 --- a/examples/standalone/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 From a5b1a35981e55c642774be55880d3d723e4c7d97 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 3 Nov 2025 15:07:16 +0100 Subject: [PATCH 35/43] examples: standalone: try to fix zigbee2mqtt --- examples/standalone/zigbee2mqtt/Dockerfile | 2 +- examples/standalone/zigbee2mqtt/README.md | 91 +++++++++++-------- .../zigbee2mqtt/docker-compose.yaml | 6 +- .../standalone/zigbee2mqtt/docker_build.sh | 2 +- examples/standalone/zigbee2mqtt/docker_run.sh | 4 +- examples/standalone/zigbee2mqtt/script.py | 51 +++++------ 6 files changed, 84 insertions(+), 72 deletions(-) diff --git a/examples/standalone/zigbee2mqtt/Dockerfile b/examples/standalone/zigbee2mqtt/Dockerfile index d4e42b0..57c8991 100644 --- a/examples/standalone/zigbee2mqtt/Dockerfile +++ b/examples/standalone/zigbee2mqtt/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-alpine3.16 +FROM python:3.13-alpine WORKDIR /app diff --git a/examples/standalone/zigbee2mqtt/README.md b/examples/standalone/zigbee2mqtt/README.md index 0a15552..96dc98d 100644 --- a/examples/standalone/zigbee2mqtt/README.md +++ b/examples/standalone/zigbee2mqtt/README.md @@ -1,42 +1,54 @@ # 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). +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 UCM you need to have [Zigbee2MQTT](https://www.zigbee2mqtt.io/guide/installation/) and some MQTT broker (for example [Mosquitto](https://mosquitto.org)) running. +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: +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 +- 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. +It is recommended to run this Standalone Device using Docker and Docker +Compose. This will ensure that environment is correct. -## Step 1. Create Standalone UCM in Enapter Cloud +The MQTT broker must be reachable from the computer where the Docker Container +will be running. -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. +## Step 1. Create Standalone Device in Enapter Cloud -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 +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. -More information you can find on [this page](https://developers.enapter.com/docs/tutorial/software-ucms/standalone). +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. +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. +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). +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 @@ -53,16 +65,17 @@ Done! Open `docker-compose.yaml` in any editor. -Set environment variables according to your configuration settings. With dummy settings your file will look like this: +Set environment variables according to your configuration settings. With dummy +settings your file will look like this: ```yaml 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" @@ -72,12 +85,13 @@ services: - ZIGBEE_SENSOR_MODEL: "Device Model" ``` -## Step 4. Build Docker Image with Standalone UCM +## Step 4. Build Docker Image with Standalone Device -> You can you can skip this step and go directly to th Step 5. -> Docker Compose will automatically build your image before starting container. +> 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 UCM. +Build your Docker image by running `bash docker_build.sh` command in directory +with Standalone Device. ```bash user@pc zigbee2mqtt % bash docker_build.sh @@ -122,20 +136,21 @@ user@pc zigbee2mqtt % bash docker_build.sh #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 naming to docker.io/enapter-standalone-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: +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-vucm-examples/zigbee2mqtt -REPOSITORY TAG IMAGE ID CREATED SIZE -enapter-vucm-examples/zigbee2mqtt latest 92e1050debea 5 hours ago 285MB +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 UCM Docker Container +## Step 5. Run your Standalone Device Docker Container -Finally run your Standalone UCM with `docker-compose up` command: +Finally run your Standalone Device with `docker-compose up` command: -On this step you can check that your UCM is now Online in the Cloud. +On this step you can check that your Device is now Online in the Cloud. diff --git a/examples/standalone/zigbee2mqtt/docker-compose.yaml b/examples/standalone/zigbee2mqtt/docker-compose.yaml index 2428b8f..5d58ce2 100644 --- a/examples/standalone/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/standalone/zigbee2mqtt/docker_build.sh b/examples/standalone/zigbee2mqtt/docker_build.sh index a9f7d29..594c43f 100755 --- a/examples/standalone/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/standalone/zigbee2mqtt/docker_run.sh b/examples/standalone/zigbee2mqtt/docker_run.sh index 6e01fd0..9762519 100755 --- a/examples/standalone/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/standalone/zigbee2mqtt/script.py b/examples/standalone/zigbee2mqtt/script.py index 8664290..219d62f 100644 --- a/examples/standalone/zigbee2mqtt/script.py +++ b/examples/standalone/zigbee2mqtt/script.py @@ -1,5 +1,4 @@ import asyncio -import functools import json import os @@ -9,52 +8,47 @@ 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 with client.subscribe(self.mqtt_topic) as messages: - async for msg in messages: - try: - self.telemetry = json.loads(msg.payload) - except json.JSONDecodeError as e: - await self.log.error(f"failed to decode json payload: {e}") + 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): + client = enapter.mqtt.Client(tg, self.mqtt_client_config) + async with client.subscribe(self.mqtt_topic) as messages: + async for msg in messages: + try: + self.telemetry = json.loads(msg.payload) + 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 +61,7 @@ async def properties_sender(self): if __name__ == "__main__": - asyncio.run(main()) + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass From 58d7bdb3ab1fb1021b31331b4e3b792b21bd1844 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 3 Nov 2025 16:23:38 +0100 Subject: [PATCH 36/43] examples: mqtt: drop rl6sim --- examples/mqtt/rl6_sim.py | 181 --------------------------------------- 1 file changed, 181 deletions(-) delete mode 100644 examples/mqtt/rl6_sim.py 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()) From c5f1e830a4e234964dc735c1b6ab23fa29839309 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 3 Nov 2025 16:26:24 +0100 Subject: [PATCH 37/43] examples: mqtt: fix pub_sub --- examples/mqtt/pub_sub.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/examples/mqtt/pub_sub.py b/examples/mqtt/pub_sub.py index ef6cadd..214193d 100644 --- a/examples/mqtt/pub_sub.py +++ b/examples/mqtt/pub_sub.py @@ -6,10 +6,10 @@ async def main() -> None: config = enapter.mqtt.Config(host="127.0.0.1", port=1883) - async with enapter.mqtt.Client(config=config) as client: - async with asyncio.TaskGroup() as tg: - tg.create_task(subscriber(client)) - tg.create_task(publisher(client)) + async with asyncio.TaskGroup() as tg: + client = enapter.mqtt.Client(tg, config=config) + tg.create_task(subscriber(client)) + tg.create_task(publisher(client)) async def subscriber(client: enapter.mqtt.Client) -> None: @@ -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 From 725759d17c2b78dcf5a5ec22aec187d3b85edee8 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 3 Nov 2025 17:57:15 +0100 Subject: [PATCH 38/43] standalone: communication config: allow overriding MQTT host --- src/enapter/standalone/config.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/enapter/standalone/config.py b/src/enapter/standalone/config.py index 323647b..e455548 100644 --- a/src/enapter/standalone/config.py +++ b/src/enapter/standalone/config.py @@ -43,7 +43,11 @@ def from_env( ) -> Self: prefix = namespace + "STANDALONE_COMMUNICATION_" blob = env[prefix + "CONFIG"] - return cls.from_blob(blob) + 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: From d6c7fbeeb8fde39393d04087d01745293f36b8c5 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Tue, 4 Nov 2025 11:31:32 +0100 Subject: [PATCH 39/43] standalone: run: accept `DeviceProtocol` instead of `Device` --- src/enapter/standalone/app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/enapter/standalone/app.py b/src/enapter/standalone/app.py index 87b31bb..c4f874f 100644 --- a/src/enapter/standalone/app.py +++ b/src/enapter/standalone/app.py @@ -3,12 +3,12 @@ from enapter import log, mqtt from .config import Config -from .device import Device from .device_driver import DeviceDriver +from .device_protocol import DeviceProtocol from .ucm import UCM -async def run(device: Device) -> None: +async def run(device: DeviceProtocol) -> None: log.configure(level=log.LEVEL or "info") config = Config.from_env() async with asyncio.TaskGroup() as tg: @@ -18,7 +18,7 @@ async def run(device: Device) -> None: class App: def __init__( - self, task_group: asyncio.TaskGroup, config: Config, device: Device + self, task_group: asyncio.TaskGroup, config: Config, device: DeviceProtocol ) -> None: self._config = config self._device = device From 885d22a1ff6c86ce8b58b813e627385b719b4546 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Tue, 4 Nov 2025 13:09:04 +0100 Subject: [PATCH 40/43] bump version to 0.12.0 --- examples/standalone/mi-fan-1c/requirements.txt | 2 +- examples/standalone/psutil-battery/requirements.txt | 2 +- examples/standalone/rl6-simulator/requirements.txt | 2 +- examples/standalone/snmp-eaton-ups/requirements.txt | 2 +- examples/standalone/wttr-in/requirements.txt | 2 +- examples/standalone/zigbee2mqtt/requirements.txt | 2 +- src/enapter/__init__.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/standalone/mi-fan-1c/requirements.txt b/examples/standalone/mi-fan-1c/requirements.txt index 7fb6769..b849b89 100644 --- a/examples/standalone/mi-fan-1c/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/psutil-battery/requirements.txt b/examples/standalone/psutil-battery/requirements.txt index 21b9af1..f0534b5 100644 --- a/examples/standalone/psutil-battery/requirements.txt +++ b/examples/standalone/psutil-battery/requirements.txt @@ -1,2 +1,2 @@ -enapter==0.11.4 +enapter==0.12.0 psutil==7.1.2 diff --git a/examples/standalone/rl6-simulator/requirements.txt b/examples/standalone/rl6-simulator/requirements.txt index 5acec81..f6f33c8 100644 --- a/examples/standalone/rl6-simulator/requirements.txt +++ b/examples/standalone/rl6-simulator/requirements.txt @@ -1 +1 @@ -enapter==0.11.4 +enapter==0.12.0 diff --git a/examples/standalone/snmp-eaton-ups/requirements.txt b/examples/standalone/snmp-eaton-ups/requirements.txt index 6afc9c5..5eefe23 100644 --- a/examples/standalone/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/standalone/wttr-in/requirements.txt b/examples/standalone/wttr-in/requirements.txt index 3440339..2f315a3 100644 --- a/examples/standalone/wttr-in/requirements.txt +++ b/examples/standalone/wttr-in/requirements.txt @@ -1,2 +1,2 @@ -enapter==0.11.4 +enapter==0.12.0 python-weather==2.1.0 diff --git a/examples/standalone/zigbee2mqtt/requirements.txt b/examples/standalone/zigbee2mqtt/requirements.txt index 5acec81..f6f33c8 100644 --- a/examples/standalone/zigbee2mqtt/requirements.txt +++ b/examples/standalone/zigbee2mqtt/requirements.txt @@ -1 +1 @@ -enapter==0.11.4 +enapter==0.12.0 diff --git a/src/enapter/__init__.py b/src/enapter/__init__.py index c3c6f95..6483dc3 100644 --- a/src/enapter/__init__.py +++ b/src/enapter/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.11.4" +__version__ = "0.12.0" from . import async_, log, mdns, mqtt, http, standalone # isort: skip From 77a1fc584cfb96783fe65b9df71dd0f2544c2d13 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Tue, 4 Nov 2025 13:10:17 +0100 Subject: [PATCH 41/43] setup: add type annotations --- setup.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index f465173..06a7bf6 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ import setuptools -def main(): +def main() -> None: setuptools.setup( name="enapter", version=read_version(), @@ -22,14 +22,14 @@ def main(): ) -def read_version(): +def read_version() -> str: with open("src/enapter/__init__.py") as f: - local_scope = {} + 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() From 5f7362813a84375cc1bfe06568c7cfe304590bc1 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Tue, 4 Nov 2025 13:13:13 +0100 Subject: [PATCH 42/43] add `py.typed` marker file --- src/enapter/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/enapter/py.typed diff --git a/src/enapter/py.typed b/src/enapter/py.typed new file mode 100644 index 0000000..e69de29 From 3df39b0a3d496ce3d4d63d08b3a48e946007adbf Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Tue, 4 Nov 2025 13:27:52 +0100 Subject: [PATCH 43/43] lint: mypy: check setup and tests --- Makefile | 2 ++ Pipfile | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Makefile b/Makefile index 851dcb0..1168b5c 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,8 @@ lint-pyflakes: .PHONY: lint-mypy lint-mypy: + pipenv run mypy setup.py + pipenv run mypy tests pipenv run mypy src/enapter .PHONY: test 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 = "*"