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.
- enapter/__init__.py +4 -4
- enapter/async_/__init__.py +1 -4
- enapter/async_/generator.py +5 -2
- enapter/async_/routine.py +21 -53
- enapter/http/__init__.py +3 -0
- enapter/http/api/__init__.py +5 -0
- enapter/http/api/client.py +31 -0
- enapter/http/api/config.py +23 -0
- enapter/http/api/devices/__init__.py +21 -0
- enapter/http/api/devices/authorized_role.py +11 -0
- enapter/http/api/devices/client.py +25 -0
- enapter/http/api/devices/communication_config.py +45 -0
- enapter/http/api/devices/device.py +32 -0
- enapter/http/api/devices/device_type.py +10 -0
- enapter/http/api/devices/mqtt_credentials.py +13 -0
- enapter/http/api/devices/mqtt_protocol.py +7 -0
- enapter/http/api/devices/mqtts_credentials.py +18 -0
- enapter/http/api/devices/time_sync_protocol.py +6 -0
- enapter/log/json_formatter.py +14 -4
- enapter/mdns/resolver.py +14 -11
- enapter/mqtt/__init__.py +5 -12
- enapter/mqtt/api/__init__.py +6 -0
- enapter/mqtt/api/client.py +62 -0
- enapter/mqtt/api/config.py +77 -0
- enapter/mqtt/api/device/__init__.py +21 -0
- enapter/mqtt/api/device/channel.py +56 -0
- enapter/mqtt/api/device/command_request.py +38 -0
- enapter/mqtt/api/device/command_response.py +28 -0
- enapter/mqtt/api/device/command_state.py +8 -0
- enapter/mqtt/api/device/log.py +31 -0
- enapter/mqtt/api/device/log_severity.py +9 -0
- enapter/mqtt/api/device/message.py +24 -0
- enapter/mqtt/api/device/properties.py +24 -0
- enapter/mqtt/api/device/telemetry.py +28 -0
- enapter/mqtt/client.py +88 -119
- enapter/mqtt/errors.py +3 -0
- enapter/mqtt/message.py +3 -0
- enapter/standalone/__init__.py +25 -0
- enapter/standalone/config.py +218 -0
- enapter/standalone/device.py +59 -0
- enapter/standalone/device_protocol.py +33 -0
- enapter/standalone/logger.py +21 -0
- enapter/standalone/mqtt_adapter.py +134 -0
- enapter/standalone/run.py +39 -0
- enapter/standalone/ucm.py +28 -0
- enapter-0.12.1.dist-info/METADATA +74 -0
- enapter-0.12.1.dist-info/RECORD +52 -0
- {enapter-0.6.3.dist-info → enapter-0.12.1.dist-info}/WHEEL +1 -1
- {enapter-0.6.3.dist-info → enapter-0.12.1.dist-info}/top_level.txt +0 -1
- enapter/mqtt/command.py +0 -45
- enapter/mqtt/config.py +0 -48
- enapter/mqtt/device_channel.py +0 -83
- enapter/types.py +0 -3
- enapter/vucm/__init__.py +0 -12
- enapter/vucm/app.py +0 -48
- enapter/vucm/config.py +0 -59
- enapter/vucm/device.py +0 -92
- enapter/vucm/logger.py +0 -39
- enapter/vucm/ucm.py +0 -30
- enapter-0.6.3.dist-info/METADATA +0 -111
- enapter-0.6.3.dist-info/RECORD +0 -27
- tests/test_async.py +0 -152
- tests/test_log.py +0 -69
- /tests/__init__.py → /enapter/py.typed +0 -0
enapter/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
__version__ = "0.
|
|
1
|
+
__version__ = "0.12.1"
|
|
2
2
|
|
|
3
|
-
from . import async_, log, mdns, mqtt,
|
|
3
|
+
from . import async_, log, mdns, mqtt, http, standalone # isort: skip
|
|
4
4
|
|
|
5
5
|
__all__ = [
|
|
6
6
|
"__version__",
|
|
@@ -8,6 +8,6 @@ __all__ = [
|
|
|
8
8
|
"log",
|
|
9
9
|
"mdns",
|
|
10
10
|
"mqtt",
|
|
11
|
-
"
|
|
12
|
-
"
|
|
11
|
+
"http",
|
|
12
|
+
"standalone",
|
|
13
13
|
]
|
enapter/async_/__init__.py
CHANGED
enapter/async_/generator.py
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import contextlib
|
|
2
2
|
import functools
|
|
3
|
+
from typing import AsyncContextManager, AsyncGenerator, Callable
|
|
3
4
|
|
|
4
5
|
|
|
5
|
-
def generator(
|
|
6
|
+
def generator(
|
|
7
|
+
func: Callable[..., AsyncGenerator],
|
|
8
|
+
) -> Callable[..., AsyncContextManager[AsyncGenerator]]:
|
|
6
9
|
@functools.wraps(func)
|
|
7
10
|
@contextlib.asynccontextmanager
|
|
8
|
-
async def wrapper(*args, **kwargs):
|
|
11
|
+
async def wrapper(*args, **kwargs) -> AsyncGenerator[AsyncGenerator, None]:
|
|
9
12
|
gen = func(*args, **kwargs)
|
|
10
13
|
try:
|
|
11
14
|
yield gen
|
enapter/async_/routine.py
CHANGED
|
@@ -1,69 +1,37 @@
|
|
|
1
1
|
import abc
|
|
2
2
|
import asyncio
|
|
3
3
|
import contextlib
|
|
4
|
+
from typing import Self
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
class Routine(abc.ABC):
|
|
8
|
+
|
|
9
|
+
def __init__(self, task_group: asyncio.TaskGroup | None = None) -> None:
|
|
10
|
+
self._task_group = task_group
|
|
11
|
+
self._task: asyncio.Task | None = None
|
|
12
|
+
|
|
7
13
|
@abc.abstractmethod
|
|
8
|
-
async def _run(self):
|
|
9
|
-
|
|
14
|
+
async def _run(self) -> None:
|
|
15
|
+
pass # pragma: no cover
|
|
10
16
|
|
|
11
|
-
async def __aenter__(self):
|
|
17
|
+
async def __aenter__(self) -> Self:
|
|
12
18
|
await self.start()
|
|
13
19
|
return self
|
|
14
20
|
|
|
15
|
-
async def __aexit__(self, *_):
|
|
21
|
+
async def __aexit__(self, *_) -> None:
|
|
16
22
|
await self.stop()
|
|
17
23
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
self._parent_task = asyncio.current_task()
|
|
26
|
-
self._cancel_parent_task_on_exception = cancel_parent_task_on_exception
|
|
27
|
-
|
|
28
|
-
self._task = asyncio.create_task(self.__run())
|
|
29
|
-
wait_started_task = asyncio.create_task(self._started.wait())
|
|
30
|
-
|
|
31
|
-
done, _ = await asyncio.wait(
|
|
32
|
-
{self._task, wait_started_task},
|
|
33
|
-
return_when=asyncio.FIRST_COMPLETED,
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
if wait_started_task not in done:
|
|
37
|
-
wait_started_task.cancel()
|
|
38
|
-
try:
|
|
39
|
-
await wait_started_task
|
|
40
|
-
except asyncio.CancelledError:
|
|
41
|
-
pass
|
|
42
|
-
|
|
43
|
-
if self._task in done:
|
|
44
|
-
self._task.result()
|
|
45
|
-
|
|
46
|
-
async def stop(self):
|
|
47
|
-
self.cancel()
|
|
48
|
-
await self.join()
|
|
24
|
+
async def start(self) -> None:
|
|
25
|
+
if self._task is not None:
|
|
26
|
+
raise RuntimeError("already started")
|
|
27
|
+
if self._task_group is None:
|
|
28
|
+
self._task = asyncio.create_task(self._run())
|
|
29
|
+
else:
|
|
30
|
+
self._task = self._task_group.create_task(self._run())
|
|
49
31
|
|
|
50
|
-
def
|
|
32
|
+
async def stop(self) -> None:
|
|
33
|
+
if self._task is None:
|
|
34
|
+
raise RuntimeError("not started yet")
|
|
51
35
|
self._task.cancel()
|
|
52
|
-
|
|
53
|
-
async def join(self):
|
|
54
|
-
if self._task.done():
|
|
55
|
-
self._task.result()
|
|
56
|
-
else:
|
|
36
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
57
37
|
await self._task
|
|
58
|
-
|
|
59
|
-
async def __run(self):
|
|
60
|
-
try:
|
|
61
|
-
await self._run()
|
|
62
|
-
except asyncio.CancelledError:
|
|
63
|
-
pass
|
|
64
|
-
except:
|
|
65
|
-
if self._started.is_set() and self._cancel_parent_task_on_exception:
|
|
66
|
-
self._parent_task.cancel()
|
|
67
|
-
raise
|
|
68
|
-
finally:
|
|
69
|
-
await self._stack.aclose()
|
enapter/http/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Self
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from enapter.http.api import devices
|
|
6
|
+
|
|
7
|
+
from .config import Config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Client:
|
|
11
|
+
|
|
12
|
+
def __init__(self, config: Config) -> None:
|
|
13
|
+
self._config = config
|
|
14
|
+
self._client = self._new_client()
|
|
15
|
+
|
|
16
|
+
def _new_client(self) -> httpx.AsyncClient:
|
|
17
|
+
return httpx.AsyncClient(
|
|
18
|
+
headers={"X-Enapter-Auth-Token": self._config.token},
|
|
19
|
+
base_url=self._config.base_url,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
async def __aenter__(self) -> Self:
|
|
23
|
+
await self._client.__aenter__()
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
async def __aexit__(self, *exc) -> None:
|
|
27
|
+
await self._client.__aexit__(*exc)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def devices(self) -> devices.Client:
|
|
31
|
+
return devices.Client(client=self._client)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import MutableMapping, Self
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Config:
|
|
6
|
+
|
|
7
|
+
@classmethod
|
|
8
|
+
def from_env(
|
|
9
|
+
cls, env: MutableMapping[str, str] = os.environ, namespace: str = "ENAPTER_"
|
|
10
|
+
) -> Self:
|
|
11
|
+
prefix = namespace + "HTTP_API_"
|
|
12
|
+
return cls(
|
|
13
|
+
token=env[prefix + "TOKEN"],
|
|
14
|
+
base_url=env.get(prefix + "BASE_URL"),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def __init__(self, token: str, base_url: str | None = None) -> None:
|
|
18
|
+
if not token:
|
|
19
|
+
raise ValueError("token is missing")
|
|
20
|
+
self.token = token
|
|
21
|
+
if base_url is None:
|
|
22
|
+
base_url = "https://api.enapter.com"
|
|
23
|
+
self.base_url = base_url
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .authorized_role import AuthorizedRole
|
|
2
|
+
from .client import Client
|
|
3
|
+
from .communication_config import CommunicationConfig
|
|
4
|
+
from .device import Device
|
|
5
|
+
from .device_type import DeviceType
|
|
6
|
+
from .mqtt_credentials import MQTTCredentials
|
|
7
|
+
from .mqtt_protocol import MQTTProtocol
|
|
8
|
+
from .mqtts_credentials import MQTTSCredentials
|
|
9
|
+
from .time_sync_protocol import TimeSyncProtocol
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AuthorizedRole",
|
|
13
|
+
"Client",
|
|
14
|
+
"CommunicationConfig",
|
|
15
|
+
"Device",
|
|
16
|
+
"DeviceType",
|
|
17
|
+
"MQTTCredentials",
|
|
18
|
+
"MQTTProtocol",
|
|
19
|
+
"MQTTSCredentials",
|
|
20
|
+
"TimeSyncProtocol",
|
|
21
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from .communication_config import CommunicationConfig
|
|
4
|
+
from .device import Device
|
|
5
|
+
from .mqtt_protocol import MQTTProtocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Client:
|
|
9
|
+
|
|
10
|
+
def __init__(self, client: httpx.AsyncClient) -> None:
|
|
11
|
+
self._client = client
|
|
12
|
+
|
|
13
|
+
async def get(self, device_id: str) -> Device:
|
|
14
|
+
url = f"v3/devices/{device_id}"
|
|
15
|
+
response = await self._client.get(url)
|
|
16
|
+
response.raise_for_status()
|
|
17
|
+
return Device.from_dto(response.json()["device"])
|
|
18
|
+
|
|
19
|
+
async def generate_communication_config(
|
|
20
|
+
self, device_id: str, mqtt_protocol: MQTTProtocol
|
|
21
|
+
) -> CommunicationConfig:
|
|
22
|
+
url = f"v3/devices/{device_id}/generate_config"
|
|
23
|
+
response = await self._client.post(url, json={"protocol": mqtt_protocol.value})
|
|
24
|
+
response.raise_for_status()
|
|
25
|
+
return CommunicationConfig.from_dto(response.json()["config"])
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from typing import Any, Self
|
|
3
|
+
|
|
4
|
+
from .mqtt_credentials import MQTTCredentials
|
|
5
|
+
from .mqtt_protocol import MQTTProtocol
|
|
6
|
+
from .mqtts_credentials import MQTTSCredentials
|
|
7
|
+
from .time_sync_protocol import TimeSyncProtocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclasses.dataclass
|
|
11
|
+
class CommunicationConfig:
|
|
12
|
+
|
|
13
|
+
mqtt_host: str
|
|
14
|
+
mqtt_port: int
|
|
15
|
+
mqtt_credentials: MQTTCredentials | MQTTSCredentials
|
|
16
|
+
mqtt_protocol: MQTTProtocol
|
|
17
|
+
time_sync_protocol: TimeSyncProtocol
|
|
18
|
+
time_sync_host: str
|
|
19
|
+
time_sync_port: int
|
|
20
|
+
hardware_id: str
|
|
21
|
+
channel_id: str
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_dto(cls, dto: dict[str, Any]) -> Self:
|
|
25
|
+
mqtt_protocol = MQTTProtocol(dto["mqtt_protocol"])
|
|
26
|
+
mqtt_credentials: MQTTCredentials | MQTTSCredentials | None = None
|
|
27
|
+
match mqtt_protocol:
|
|
28
|
+
case MQTTProtocol.MQTT:
|
|
29
|
+
mqtt_credentials = MQTTCredentials.from_dto(dto["mqtt_credentials"])
|
|
30
|
+
case MQTTProtocol.MQTTS:
|
|
31
|
+
mqtt_credentials = MQTTSCredentials.from_dto(dto["mqtt_credentials"])
|
|
32
|
+
case _:
|
|
33
|
+
raise NotImplementedError(mqtt_protocol)
|
|
34
|
+
assert mqtt_credentials is not None
|
|
35
|
+
return cls(
|
|
36
|
+
mqtt_host=dto["mqtt_host"],
|
|
37
|
+
mqtt_port=int(dto["mqtt_port"]),
|
|
38
|
+
mqtt_credentials=mqtt_credentials,
|
|
39
|
+
mqtt_protocol=mqtt_protocol,
|
|
40
|
+
time_sync_protocol=TimeSyncProtocol(dto["time_sync_protocol"].upper()),
|
|
41
|
+
time_sync_host=dto["time_sync_host"],
|
|
42
|
+
time_sync_port=int(dto["time_sync_port"]),
|
|
43
|
+
hardware_id=dto["hardware_id"],
|
|
44
|
+
channel_id=dto["channel_id"],
|
|
45
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import datetime
|
|
3
|
+
from typing import Any, Self
|
|
4
|
+
|
|
5
|
+
from .authorized_role import AuthorizedRole
|
|
6
|
+
from .device_type import DeviceType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclasses.dataclass
|
|
10
|
+
class Device:
|
|
11
|
+
|
|
12
|
+
id: str
|
|
13
|
+
blueprint_id: str
|
|
14
|
+
name: str
|
|
15
|
+
site_id: str
|
|
16
|
+
updated_at: datetime.datetime
|
|
17
|
+
slug: str
|
|
18
|
+
type: DeviceType
|
|
19
|
+
authorized_role: AuthorizedRole
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_dto(cls, dto: dict[str, Any]) -> Self:
|
|
23
|
+
return cls(
|
|
24
|
+
id=dto["id"],
|
|
25
|
+
blueprint_id=dto["blueprint_id"],
|
|
26
|
+
name=dto["name"],
|
|
27
|
+
site_id=dto["site_id"],
|
|
28
|
+
updated_at=datetime.datetime.fromisoformat(dto["updated_at"]),
|
|
29
|
+
slug=dto["slug"],
|
|
30
|
+
type=DeviceType(dto["type"]),
|
|
31
|
+
authorized_role=AuthorizedRole(dto["authorized_role"]),
|
|
32
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from typing import Any, Self
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclasses.dataclass
|
|
6
|
+
class MQTTCredentials:
|
|
7
|
+
|
|
8
|
+
username: str
|
|
9
|
+
password: str
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def from_dto(cls, dto: dict[str, Any]) -> Self:
|
|
13
|
+
return cls(username=dto["username"], password=dto["password"])
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from typing import Any, Self
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclasses.dataclass
|
|
6
|
+
class MQTTSCredentials:
|
|
7
|
+
|
|
8
|
+
private_key: str
|
|
9
|
+
certificate: str
|
|
10
|
+
ca_chain: str
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def from_dto(cls, dto: dict[str, Any]) -> Self:
|
|
14
|
+
return cls(
|
|
15
|
+
private_key=dto["private_key"],
|
|
16
|
+
certificate=dto["certificate"],
|
|
17
|
+
ca_chain=dto["ca_chain"],
|
|
18
|
+
)
|
enapter/log/json_formatter.py
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import datetime
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any
|
|
2
4
|
|
|
3
|
-
import json_log_formatter
|
|
5
|
+
import json_log_formatter # type: ignore
|
|
4
6
|
|
|
5
7
|
|
|
6
8
|
class JSONFormatter(json_log_formatter.JSONFormatter):
|
|
7
|
-
|
|
9
|
+
|
|
10
|
+
def json_record(
|
|
11
|
+
self, message: str, extra: dict[str, Any], record: logging.LogRecord
|
|
12
|
+
) -> dict[str, Any]:
|
|
13
|
+
try:
|
|
14
|
+
del extra["taskName"]
|
|
15
|
+
except KeyError:
|
|
16
|
+
pass
|
|
17
|
+
|
|
8
18
|
json_record = {
|
|
9
|
-
"time": datetime.datetime.
|
|
19
|
+
"time": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
10
20
|
"level": record.levelname[:4],
|
|
11
21
|
"name": record.name,
|
|
12
22
|
**extra,
|
|
@@ -22,5 +32,5 @@ class JSONFormatter(json_log_formatter.JSONFormatter):
|
|
|
22
32
|
|
|
23
33
|
return json_record
|
|
24
34
|
|
|
25
|
-
def mutate_json_record(self, json_record):
|
|
35
|
+
def mutate_json_record(self, json_record: dict[str, Any]) -> dict[str, Any]:
|
|
26
36
|
return json_record
|
enapter/mdns/resolver.py
CHANGED
|
@@ -1,39 +1,42 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
|
-
import dns.asyncresolver
|
|
3
|
+
import dns.asyncresolver # type: ignore
|
|
4
4
|
|
|
5
5
|
LOGGER = logging.getLogger(__name__)
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class Resolver:
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
def __init__(self) -> None:
|
|
10
11
|
self._logger = LOGGER
|
|
11
12
|
self._dns_resolver = self._new_dns_resolver()
|
|
12
13
|
self._mdns_resolver = self._new_mdns_resolver()
|
|
13
14
|
|
|
14
|
-
async def resolve(self, host):
|
|
15
|
+
async def resolve(self, host: str) -> str:
|
|
15
16
|
# TODO: Resolve concurrently.
|
|
16
17
|
try:
|
|
17
|
-
|
|
18
|
+
ip = await self._resolve(self._dns_resolver, host)
|
|
19
|
+
self._logger.debug("%r resolved using DNS: %r", host, ip)
|
|
20
|
+
return ip
|
|
18
21
|
except Exception as e:
|
|
19
22
|
self._logger.debug(
|
|
20
|
-
"failed to resolve %r using DNS
|
|
21
|
-
host,
|
|
22
|
-
e,
|
|
23
|
+
"switching to mDNS: failed to resolve %r using DNS: %r", host, e
|
|
23
24
|
)
|
|
24
|
-
|
|
25
|
+
ip = await self._resolve(self._mdns_resolver, host)
|
|
26
|
+
self._logger.info("%r resolved using mDNS: %r", host, ip)
|
|
27
|
+
return ip
|
|
25
28
|
|
|
26
|
-
async def _resolve(self, resolver, host):
|
|
29
|
+
async def _resolve(self, resolver: dns.asyncresolver.Resolver, host: str) -> str:
|
|
27
30
|
answer = await resolver.resolve(host, "A")
|
|
28
31
|
if not answer:
|
|
29
32
|
raise ValueError(f"empty answer received: {host}")
|
|
30
33
|
|
|
31
34
|
return answer[0].address
|
|
32
35
|
|
|
33
|
-
def _new_dns_resolver(self):
|
|
36
|
+
def _new_dns_resolver(self) -> dns.asyncresolver.Resolver:
|
|
34
37
|
return dns.asyncresolver.Resolver(configure=True)
|
|
35
38
|
|
|
36
|
-
def _new_mdns_resolver(self):
|
|
39
|
+
def _new_mdns_resolver(self) -> dns.asyncresolver.Resolver:
|
|
37
40
|
r = dns.asyncresolver.Resolver(configure=False)
|
|
38
41
|
r.nameservers = ["224.0.0.251"]
|
|
39
42
|
r.port = 5353
|
enapter/mqtt/__init__.py
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
1
|
from .client import Client
|
|
2
|
-
from .
|
|
3
|
-
from .
|
|
4
|
-
from .device_channel import DeviceChannel, DeviceLogSeverity
|
|
2
|
+
from .errors import Error
|
|
3
|
+
from .message import Message
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"CommandResponse",
|
|
10
|
-
"CommandState",
|
|
11
|
-
"Config",
|
|
12
|
-
"DeviceChannel",
|
|
13
|
-
"DeviceLogSeverity",
|
|
14
|
-
]
|
|
5
|
+
from . import api # isort: skip
|
|
6
|
+
|
|
7
|
+
__all__ = ["Client", "Error", "Message", "api"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import ssl
|
|
4
|
+
import tempfile
|
|
5
|
+
from typing import Self
|
|
6
|
+
|
|
7
|
+
from enapter import mqtt
|
|
8
|
+
from enapter.mqtt.api import device
|
|
9
|
+
|
|
10
|
+
from .config import Config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Client:
|
|
14
|
+
|
|
15
|
+
def __init__(self, config: Config, task_group: asyncio.TaskGroup | None) -> None:
|
|
16
|
+
self._config = config
|
|
17
|
+
self._client = self._new_client(task_group=task_group)
|
|
18
|
+
|
|
19
|
+
async def __aenter__(self) -> Self:
|
|
20
|
+
await self._client.__aenter__()
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
async def __aexit__(self, *exc) -> None:
|
|
24
|
+
await self._client.__aexit__(*exc)
|
|
25
|
+
|
|
26
|
+
def device_channel(self, hardware_id: str, channel_id: str) -> device.Channel:
|
|
27
|
+
return device.Channel(
|
|
28
|
+
client=self._client, hardware_id=hardware_id, channel_id=channel_id
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def _new_client(self, task_group: asyncio.TaskGroup | None) -> mqtt.Client:
|
|
32
|
+
return mqtt.Client(
|
|
33
|
+
hostname=self._config.host,
|
|
34
|
+
port=self._config.port,
|
|
35
|
+
username=self._config.user,
|
|
36
|
+
password=self._config.password,
|
|
37
|
+
tls_context=self._new_tls_context(),
|
|
38
|
+
task_group=task_group,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def _new_tls_context(self) -> ssl.SSLContext | None:
|
|
42
|
+
if self._config.tls is None:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
46
|
+
|
|
47
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
48
|
+
ctx.check_hostname = False
|
|
49
|
+
ctx.load_verify_locations(None, None, self._config.tls.ca_cert)
|
|
50
|
+
|
|
51
|
+
with contextlib.ExitStack() as stack:
|
|
52
|
+
certfile = stack.enter_context(tempfile.NamedTemporaryFile())
|
|
53
|
+
certfile.write(self._config.tls.cert.encode())
|
|
54
|
+
certfile.flush()
|
|
55
|
+
|
|
56
|
+
keyfile = stack.enter_context(tempfile.NamedTemporaryFile())
|
|
57
|
+
keyfile.write(self._config.tls.secret_key.encode())
|
|
58
|
+
keyfile.flush()
|
|
59
|
+
|
|
60
|
+
ctx.load_cert_chain(certfile.name, keyfile=keyfile.name)
|
|
61
|
+
|
|
62
|
+
return ctx
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import MutableMapping, Self
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TLSConfig:
|
|
6
|
+
|
|
7
|
+
@classmethod
|
|
8
|
+
def from_env(
|
|
9
|
+
cls, env: MutableMapping[str, str] = os.environ, namespace: str = "ENAPTER_"
|
|
10
|
+
) -> Self | None:
|
|
11
|
+
prefix = namespace + "MQTT_API_TLS_"
|
|
12
|
+
|
|
13
|
+
secret_key = env.get(prefix + "SECRET_KEY")
|
|
14
|
+
cert = env.get(prefix + "CERT")
|
|
15
|
+
ca_cert = env.get(prefix + "CA_CERT")
|
|
16
|
+
|
|
17
|
+
nothing_defined = {secret_key, cert, ca_cert} == {None}
|
|
18
|
+
if nothing_defined:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
if secret_key is None:
|
|
22
|
+
raise KeyError(prefix + "SECRET_KEY")
|
|
23
|
+
if cert is None:
|
|
24
|
+
raise KeyError(prefix + "CERT")
|
|
25
|
+
if ca_cert is None:
|
|
26
|
+
raise KeyError(prefix + "CA_CERT")
|
|
27
|
+
|
|
28
|
+
def pem(value: str) -> str:
|
|
29
|
+
return value.replace("\\n", "\n")
|
|
30
|
+
|
|
31
|
+
return cls(secret_key=pem(secret_key), cert=pem(cert), ca_cert=pem(ca_cert))
|
|
32
|
+
|
|
33
|
+
def __init__(self, secret_key: str, cert: str, ca_cert: str) -> None:
|
|
34
|
+
self.secret_key = secret_key
|
|
35
|
+
self.cert = cert
|
|
36
|
+
self.ca_cert = ca_cert
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Config:
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_env(
|
|
43
|
+
cls, env: MutableMapping[str, str] = os.environ, namespace: str = "ENAPTER_"
|
|
44
|
+
) -> Self:
|
|
45
|
+
prefix = namespace + "MQTT_API_"
|
|
46
|
+
return cls(
|
|
47
|
+
host=env[prefix + "HOST"],
|
|
48
|
+
port=int(env[prefix + "PORT"]),
|
|
49
|
+
user=env.get(prefix + "USER", default=None),
|
|
50
|
+
password=env.get(prefix + "PASSWORD", default=None),
|
|
51
|
+
tls_config=TLSConfig.from_env(env, namespace=namespace),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
host: str,
|
|
57
|
+
port: int,
|
|
58
|
+
user: str | None = None,
|
|
59
|
+
password: str | None = None,
|
|
60
|
+
tls_config: TLSConfig | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self.host = host
|
|
63
|
+
self.port = port
|
|
64
|
+
self.user = user
|
|
65
|
+
self.password = password
|
|
66
|
+
self.tls_config = tls_config
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def tls(self) -> TLSConfig | None:
|
|
70
|
+
return self.tls_config
|
|
71
|
+
|
|
72
|
+
def __repr__(self) -> str:
|
|
73
|
+
return "mqtt.api.Config(host=%r, port=%r, tls=%r)" % (
|
|
74
|
+
self.host,
|
|
75
|
+
self.port,
|
|
76
|
+
self.tls is not None,
|
|
77
|
+
)
|