enapter 0.6.3__py3-none-any.whl → 0.12.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. enapter/__init__.py +4 -4
  2. enapter/async_/__init__.py +1 -4
  3. enapter/async_/generator.py +5 -2
  4. enapter/async_/routine.py +21 -53
  5. enapter/http/__init__.py +3 -0
  6. enapter/http/api/__init__.py +5 -0
  7. enapter/http/api/client.py +31 -0
  8. enapter/http/api/config.py +23 -0
  9. enapter/http/api/devices/__init__.py +21 -0
  10. enapter/http/api/devices/authorized_role.py +11 -0
  11. enapter/http/api/devices/client.py +25 -0
  12. enapter/http/api/devices/communication_config.py +45 -0
  13. enapter/http/api/devices/device.py +32 -0
  14. enapter/http/api/devices/device_type.py +10 -0
  15. enapter/http/api/devices/mqtt_credentials.py +13 -0
  16. enapter/http/api/devices/mqtt_protocol.py +7 -0
  17. enapter/http/api/devices/mqtts_credentials.py +18 -0
  18. enapter/http/api/devices/time_sync_protocol.py +6 -0
  19. enapter/log/json_formatter.py +14 -4
  20. enapter/mdns/resolver.py +14 -11
  21. enapter/mqtt/__init__.py +5 -12
  22. enapter/mqtt/api/__init__.py +6 -0
  23. enapter/mqtt/api/client.py +62 -0
  24. enapter/mqtt/api/config.py +77 -0
  25. enapter/mqtt/api/device/__init__.py +21 -0
  26. enapter/mqtt/api/device/channel.py +56 -0
  27. enapter/mqtt/api/device/command_request.py +38 -0
  28. enapter/mqtt/api/device/command_response.py +28 -0
  29. enapter/mqtt/api/device/command_state.py +8 -0
  30. enapter/mqtt/api/device/log.py +31 -0
  31. enapter/mqtt/api/device/log_severity.py +9 -0
  32. enapter/mqtt/api/device/message.py +24 -0
  33. enapter/mqtt/api/device/properties.py +24 -0
  34. enapter/mqtt/api/device/telemetry.py +28 -0
  35. enapter/mqtt/client.py +88 -119
  36. enapter/mqtt/errors.py +3 -0
  37. enapter/mqtt/message.py +3 -0
  38. enapter/standalone/__init__.py +25 -0
  39. enapter/standalone/config.py +218 -0
  40. enapter/standalone/device.py +59 -0
  41. enapter/standalone/device_protocol.py +33 -0
  42. enapter/standalone/logger.py +21 -0
  43. enapter/standalone/mqtt_adapter.py +134 -0
  44. enapter/standalone/run.py +39 -0
  45. enapter/standalone/ucm.py +28 -0
  46. enapter-0.12.1.dist-info/METADATA +74 -0
  47. enapter-0.12.1.dist-info/RECORD +52 -0
  48. {enapter-0.6.3.dist-info → enapter-0.12.1.dist-info}/WHEEL +1 -1
  49. {enapter-0.6.3.dist-info → enapter-0.12.1.dist-info}/top_level.txt +0 -1
  50. enapter/mqtt/command.py +0 -45
  51. enapter/mqtt/config.py +0 -48
  52. enapter/mqtt/device_channel.py +0 -83
  53. enapter/types.py +0 -3
  54. enapter/vucm/__init__.py +0 -12
  55. enapter/vucm/app.py +0 -48
  56. enapter/vucm/config.py +0 -59
  57. enapter/vucm/device.py +0 -92
  58. enapter/vucm/logger.py +0 -39
  59. enapter/vucm/ucm.py +0 -30
  60. enapter-0.6.3.dist-info/METADATA +0 -111
  61. enapter-0.6.3.dist-info/RECORD +0 -27
  62. tests/test_async.py +0 -152
  63. tests/test_log.py +0 -69
  64. /tests/__init__.py → /enapter/py.typed +0 -0
@@ -0,0 +1,21 @@
1
+ from .channel import Channel
2
+ from .command_request import CommandRequest
3
+ from .command_response import CommandResponse
4
+ from .command_state import CommandState
5
+ from .log import Log
6
+ from .log_severity import LogSeverity
7
+ from .message import Message
8
+ from .properties import Properties
9
+ from .telemetry import Telemetry
10
+
11
+ __all__ = [
12
+ "CommandRequest",
13
+ "CommandResponse",
14
+ "Channel",
15
+ "CommandState",
16
+ "Log",
17
+ "LogSeverity",
18
+ "Message",
19
+ "Properties",
20
+ "Telemetry",
21
+ ]
@@ -0,0 +1,56 @@
1
+ from typing import AsyncContextManager, AsyncGenerator
2
+
3
+ from enapter import async_, mqtt
4
+
5
+ from .command_request import CommandRequest
6
+ from .command_response import CommandResponse
7
+ from .log import Log
8
+ from .properties import Properties
9
+ from .telemetry import Telemetry
10
+
11
+
12
+ class Channel:
13
+
14
+ def __init__(self, client: mqtt.Client, hardware_id: str, channel_id: str) -> None:
15
+ self._client = client
16
+ self._hardware_id = hardware_id
17
+ self._channel_id = channel_id
18
+
19
+ @property
20
+ def hardware_id(self) -> str:
21
+ return self._hardware_id
22
+
23
+ @property
24
+ def channel_id(self) -> str:
25
+ return self._channel_id
26
+
27
+ @async_.generator
28
+ async def subscribe_to_command_requests(
29
+ self,
30
+ ) -> AsyncGenerator[CommandRequest, None]:
31
+ async with self._subscribe("v1/command/requests") as messages:
32
+ async for msg in messages:
33
+ assert isinstance(msg.payload, str) or isinstance(msg.payload, bytes)
34
+ yield CommandRequest.from_json(msg.payload)
35
+
36
+ async def publish_command_response(self, response: CommandResponse) -> None:
37
+ await self._publish("v1/command/responses", response.to_json())
38
+
39
+ async def publish_telemetry(self, telemetry: Telemetry, **kwargs) -> None:
40
+ await self._publish("v1/telemetry", telemetry.to_json(), **kwargs)
41
+
42
+ async def publish_properties(self, properties: Properties, **kwargs) -> None:
43
+ await self._publish("v1/register", properties.to_json(), **kwargs)
44
+
45
+ async def publish_log(self, log: Log, **kwargs) -> None:
46
+ await self._publish("v3/logs", log.to_json(), **kwargs)
47
+
48
+ def _subscribe(
49
+ self, path: str
50
+ ) -> AsyncContextManager[AsyncGenerator[mqtt.Message, None]]:
51
+ topic = f"v1/to/{self._hardware_id}/{self._channel_id}/{path}"
52
+ return self._client.subscribe(topic)
53
+
54
+ async def _publish(self, path: str, payload: str, **kwargs) -> None:
55
+ topic = f"v1/from/{self._hardware_id}/{self._channel_id}/{path}"
56
+ await self._client.publish(topic, payload, **kwargs)
@@ -0,0 +1,38 @@
1
+ import dataclasses
2
+ from typing import Any, Self
3
+
4
+ from .command_response import CommandResponse
5
+ from .command_state import CommandState
6
+ from .message import Message
7
+
8
+
9
+ @dataclasses.dataclass
10
+ class CommandRequest(Message):
11
+
12
+ id: str
13
+ name: str
14
+ arguments: dict[str, Any]
15
+
16
+ @classmethod
17
+ def from_dto(cls, dto: dict[str, Any]) -> Self:
18
+ return cls(
19
+ id=dto["id"],
20
+ name=dto["name"],
21
+ arguments=dto.get("arguments", {}),
22
+ )
23
+
24
+ def to_dto(self) -> dict[str, Any]:
25
+ return {
26
+ "id": self.id,
27
+ "name": self.name,
28
+ "arguments": self.arguments,
29
+ }
30
+
31
+ def new_response(
32
+ self, state: CommandState, payload: dict[str, Any]
33
+ ) -> CommandResponse:
34
+ return CommandResponse(
35
+ id=self.id,
36
+ state=state,
37
+ payload=payload,
38
+ )
@@ -0,0 +1,28 @@
1
+ import dataclasses
2
+ from typing import Any, Self
3
+
4
+ from .command_state import CommandState
5
+ from .message import Message
6
+
7
+
8
+ @dataclasses.dataclass
9
+ class CommandResponse(Message):
10
+
11
+ id: str
12
+ state: CommandState
13
+ payload: dict[str, Any]
14
+
15
+ @classmethod
16
+ def from_dto(cls, dto: dict[str, Any]) -> Self:
17
+ return cls(
18
+ id=dto["id"],
19
+ state=CommandState(dto["state"]),
20
+ payload=dto.get("payload", {}),
21
+ )
22
+
23
+ def to_dto(self) -> dict[str, Any]:
24
+ return {
25
+ "id": self.id,
26
+ "state": self.state.value,
27
+ "payload": self.payload,
28
+ }
@@ -0,0 +1,8 @@
1
+ import enum
2
+
3
+
4
+ class CommandState(enum.Enum):
5
+
6
+ COMPLETED = "completed"
7
+ ERROR = "error"
8
+ LOG = "log"
@@ -0,0 +1,31 @@
1
+ import dataclasses
2
+ from typing import Any, Self
3
+
4
+ from .log_severity import LogSeverity
5
+ from .message import Message
6
+
7
+
8
+ @dataclasses.dataclass
9
+ class Log(Message):
10
+
11
+ timestamp: int
12
+ message: str
13
+ severity: LogSeverity
14
+ persist: bool
15
+
16
+ @classmethod
17
+ def from_dto(cls, dto: dict[str, Any]) -> Self:
18
+ return cls(
19
+ timestamp=dto["timestamp"],
20
+ message=dto["message"],
21
+ severity=LogSeverity(dto["severity"]),
22
+ persist=dto["persist"],
23
+ )
24
+
25
+ def to_dto(self) -> dict[str, Any]:
26
+ return {
27
+ "timestamp": self.timestamp,
28
+ "message": self.message,
29
+ "severity": self.severity.value,
30
+ "persist": self.persist,
31
+ }
@@ -0,0 +1,9 @@
1
+ import enum
2
+
3
+
4
+ class LogSeverity(enum.Enum):
5
+
6
+ DEBUG = "debug"
7
+ INFO = "info"
8
+ WARNING = "warning"
9
+ ERROR = "error"
@@ -0,0 +1,24 @@
1
+ import abc
2
+ import json
3
+ from typing import Any, Self
4
+
5
+
6
+ class Message(abc.ABC):
7
+
8
+ @classmethod
9
+ @abc.abstractmethod
10
+ def from_dto(cls, dto: dict[str, Any]) -> Self:
11
+ pass
12
+
13
+ @abc.abstractmethod
14
+ def to_dto(self) -> dict[str, Any]:
15
+ pass
16
+
17
+ @classmethod
18
+ def from_json(cls, data: str | bytes) -> Self:
19
+ dto = json.loads(data)
20
+ return cls.from_dto(dto)
21
+
22
+ def to_json(self) -> str:
23
+ dto = self.to_dto()
24
+ return json.dumps(dto)
@@ -0,0 +1,24 @@
1
+ import dataclasses
2
+ from typing import Any, Self
3
+
4
+ from .message import Message
5
+
6
+
7
+ @dataclasses.dataclass
8
+ class Properties(Message):
9
+
10
+ timestamp: int
11
+ values: dict[str, Any] = dataclasses.field(default_factory=dict)
12
+
13
+ def __post_init__(self) -> None:
14
+ if "timestamp" in self.values:
15
+ raise ValueError("`timestamp` is reserved")
16
+
17
+ @classmethod
18
+ def from_dto(cls, dto: dict[str, Any]) -> Self:
19
+ timestamp = dto["timestamp"]
20
+ values = {k: v for k, v in dto.items() if k != "timestamp"}
21
+ return cls(timestamp=timestamp, values=values)
22
+
23
+ def to_dto(self) -> dict[str, Any]:
24
+ return {"timestamp": self.timestamp, **self.values}
@@ -0,0 +1,28 @@
1
+ import dataclasses
2
+ from typing import Any, Self
3
+
4
+ from .message import Message
5
+
6
+
7
+ @dataclasses.dataclass
8
+ class Telemetry(Message):
9
+
10
+ timestamp: int
11
+ alerts: list[str] | None = None
12
+ values: dict[str, Any] = dataclasses.field(default_factory=dict)
13
+
14
+ def __post_init__(self) -> None:
15
+ if "timestamp" in self.values:
16
+ raise ValueError("`timestamp` is reserved")
17
+ if "alerts" in self.values:
18
+ raise ValueError("`alerts` is reserved")
19
+
20
+ @classmethod
21
+ def from_dto(cls, dto: dict[str, Any]) -> Self:
22
+ dto = dto.copy()
23
+ timestamp = dto.pop("timestamp")
24
+ alerts = dto.pop("alerts", None)
25
+ return cls(timestamp=timestamp, alerts=alerts, values=dto)
26
+
27
+ def to_dto(self) -> dict[str, Any]:
28
+ return {"timestamp": self.timestamp, "alerts": self.alerts, **self.values}
enapter/mqtt/client.py CHANGED
@@ -2,157 +2,126 @@ import asyncio
2
2
  import contextlib
3
3
  import logging
4
4
  import ssl
5
- import tempfile
5
+ from typing import AsyncGenerator
6
6
 
7
- import asyncio_mqtt
7
+ import aiomqtt # type: ignore
8
8
 
9
- from .. import async_, mdns
10
- from .device_channel import DeviceChannel
9
+ from enapter import async_, mdns
10
+
11
+ from .message import Message
11
12
 
12
13
  LOGGER = logging.getLogger(__name__)
13
14
 
14
15
 
15
16
  class Client(async_.Routine):
16
- def __init__(self, config):
17
- self._logger = self._new_logger(config)
18
- self._config = config
19
- self._mdns_resolver = mdns.Resolver()
20
- self._tls_context = self._new_tls_context(config)
21
- self._lock = asyncio.Lock()
22
- self._client = None
23
- self._client_ready = asyncio.Event()
24
-
25
- @staticmethod
26
- def _new_logger(config):
27
- extra = {"host": config.host, "port": config.port}
28
- return logging.LoggerAdapter(LOGGER, extra=extra)
29
-
30
- def config(self):
31
- return self._config
32
-
33
- def device_channel(self, hardware_id, channel_id):
34
- return DeviceChannel(
35
- client=self, hardware_id=hardware_id, channel_id=channel_id
36
- )
37
17
 
38
- async def publish(self, *args, **kwargs):
39
- client = None
40
-
41
- while True:
42
- await self._client_ready.wait()
43
- async with self._lock:
44
- if self._client_ready.is_set():
45
- client = self._client
46
- break
18
+ def __init__(
19
+ self,
20
+ hostname: str,
21
+ port: int = 1883,
22
+ *,
23
+ username: str | None = None,
24
+ password: str | None = None,
25
+ tls_context: ssl.SSLContext | None = None,
26
+ task_group: asyncio.TaskGroup | None = None
27
+ ) -> None:
28
+ super().__init__(task_group=task_group)
29
+ self._logger = logging.LoggerAdapter(
30
+ LOGGER, extra={"hostname": hostname, "port": port}
31
+ )
32
+ self._hostname = hostname
33
+ self._port = port
34
+ self._username = username
35
+ self._password = password
36
+ self._tls_context = tls_context
37
+ self._mdns_resolver = mdns.Resolver()
38
+ self._publisher: aiomqtt.Client | None = None
39
+ self._publisher_connected = asyncio.Event()
47
40
 
48
- await client.publish(*args, **kwargs)
41
+ async def publish(self, *args, **kwargs) -> None:
42
+ await self._publisher_connected.wait()
43
+ assert self._publisher is not None
44
+ await self._publisher.publish(*args, **kwargs)
49
45
 
50
46
  @async_.generator
51
- async def subscribe(self, topic):
47
+ async def subscribe(self, *topics: str) -> AsyncGenerator[Message, None]:
52
48
  while True:
53
- client = None
54
-
55
- while True:
56
- await self._client_ready.wait()
57
- async with self._lock:
58
- if self._client_ready.is_set():
59
- client = self._client
60
- break
61
-
62
49
  try:
63
- async with client.filtered_messages(topic) as messages:
64
- await client.subscribe(topic)
65
- async for msg in messages:
50
+ async with self._connect() as subscriber:
51
+ for topic in topics:
52
+ await subscriber.subscribe(topic)
53
+ self._logger.info("subscriber [%s] connected", ",".join(topics))
54
+ async for msg in subscriber.messages:
66
55
  yield msg
67
-
68
- except asyncio_mqtt.MqttError as e:
56
+ except aiomqtt.MqttError as e:
69
57
  self._logger.error(e)
70
58
  retry_interval = 5
71
59
  await asyncio.sleep(retry_interval)
72
60
 
73
- async def _run(self):
61
+ async def _run(self) -> None:
74
62
  self._logger.info("starting")
75
-
76
- self._started.set()
77
-
78
63
  while True:
79
64
  try:
80
- async with self._connect() as client:
81
- async with self._lock:
82
- self._client = client
83
- self._client_ready.set()
84
- self._logger.info("client ready")
85
-
86
- async with client.unfiltered_messages() as messages:
87
- async for message in messages:
88
- self._logger.warn(
89
- "received unfiltered message: %s", message.topic
90
- )
91
- except asyncio_mqtt.MqttError as e:
65
+ async with self._connect() as publisher:
66
+ self._logger.info("publisher connected")
67
+ self._publisher = publisher
68
+ self._publisher_connected.set()
69
+ async for msg in publisher.messages:
70
+ pass
71
+ except aiomqtt.MqttError as e:
92
72
  self._logger.error(e)
93
73
  retry_interval = 5
94
74
  await asyncio.sleep(retry_interval)
95
75
  finally:
96
- async with self._lock:
97
- self._client_ready.clear()
98
- self._client = None
99
- self._logger.info("client not ready")
76
+ self._publisher_connected.clear()
77
+ self._publisher = None
100
78
 
101
79
  @contextlib.asynccontextmanager
102
- async def _connect(self):
103
- host = await self._maybe_resolve_mdns(self._config.host)
104
-
105
- try:
106
- async with asyncio_mqtt.Client(
107
- hostname=host,
108
- port=self._config.port,
109
- username=self._config.user,
110
- password=self._config.password,
111
- logger=self._logger,
112
- tls_context=self._tls_context,
113
- ) as client:
114
- yield client
115
- except asyncio.CancelledError:
116
- # FIXME: A cancelled `asyncio_mqtt.Client.connect` leaks resources.
117
- raise
118
-
119
- @staticmethod
120
- def _new_tls_context(config):
121
- if not config.tls_enabled:
122
- return None
123
-
124
- ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
125
-
126
- ctx.verify_mode = ssl.CERT_REQUIRED
127
- ctx.check_hostname = False
128
- ctx.load_verify_locations(None, None, config.tls_ca_cert)
129
-
130
- with contextlib.ExitStack() as stack:
131
- certfile = stack.enter_context(tempfile.NamedTemporaryFile())
132
- certfile.write(config.tls_cert.encode())
133
- certfile.flush()
134
-
135
- keyfile = stack.enter_context(tempfile.NamedTemporaryFile())
136
- keyfile.write(config.tls_secret_key.encode())
137
- keyfile.flush()
138
-
139
- ctx.load_cert_chain(certfile.name, keyfile=keyfile.name)
140
-
141
- return ctx
142
-
143
- async def _maybe_resolve_mdns(self, host):
144
- if not host.endswith(".local"):
145
- return host
80
+ async def _connect(self) -> AsyncGenerator[aiomqtt.Client, None]:
81
+ hostname = await self._maybe_resolve_hostname()
82
+ async with _new_aiomqtt_client(
83
+ hostname=hostname,
84
+ port=self._port,
85
+ username=self._username,
86
+ password=self._password,
87
+ logger=LOGGER,
88
+ tls_context=self._tls_context,
89
+ ) as client:
90
+ yield client
91
+
92
+ async def _maybe_resolve_hostname(self) -> str:
93
+ if not self._hostname.endswith(".local"):
94
+ return self._hostname
146
95
 
147
96
  while True:
148
97
  try:
149
- ip = await self._mdns_resolver.resolve(host)
98
+ return await self._mdns_resolver.resolve(self._hostname)
150
99
  except Exception as e:
151
- self._logger.error("failed to resolve mDNS host %r: %s", host, e)
100
+ self._logger.error("failed to resolve mDNS host: %s", e)
152
101
  retry_interval = 5
153
102
  await asyncio.sleep(retry_interval)
154
- continue
155
103
 
156
- self._logger.info("mDNS host resolved: %s", ip)
157
104
 
158
- return ip
105
+ @contextlib.asynccontextmanager
106
+ async def _new_aiomqtt_client(*args, **kwargs) -> AsyncGenerator[aiomqtt.Client, None]:
107
+ """
108
+ Creates `aiomqtt.Client` shielding `__aenter__` from cancellation.
109
+
110
+ See:
111
+ - https://github.com/empicano/aiomqtt/issues/377
112
+ """
113
+ client = aiomqtt.Client(*args, **kwargs)
114
+ setup_task = asyncio.create_task(client.__aenter__())
115
+ try:
116
+ await asyncio.shield(setup_task)
117
+ except asyncio.CancelledError as e:
118
+ await setup_task
119
+ await client.__aexit__(type(e), e, e.__traceback__)
120
+ raise
121
+ try:
122
+ yield client
123
+ except BaseException as e:
124
+ await client.__aexit__(type(e), e, e.__traceback__)
125
+ raise
126
+ else:
127
+ await client.__aexit__(None, None, None)
enapter/mqtt/errors.py ADDED
@@ -0,0 +1,3 @@
1
+ import aiomqtt
2
+
3
+ Error = aiomqtt.MqttError
@@ -0,0 +1,3 @@
1
+ import aiomqtt
2
+
3
+ Message = aiomqtt.Message
@@ -0,0 +1,25 @@
1
+ from .config import Config
2
+ from .device import Device
3
+ from .device_protocol import (
4
+ CommandArgs,
5
+ CommandResult,
6
+ DeviceProtocol,
7
+ Log,
8
+ Properties,
9
+ Telemetry,
10
+ )
11
+ from .logger import Logger
12
+ from .run import run
13
+
14
+ __all__ = [
15
+ "CommandArgs",
16
+ "CommandResult",
17
+ "Config",
18
+ "Device",
19
+ "DeviceProtocol",
20
+ "Log",
21
+ "Logger",
22
+ "Properties",
23
+ "Telemetry",
24
+ "run",
25
+ ]