uns-kit 0.0.4__tar.gz → 0.0.5__tar.gz
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.
- {uns_kit-0.0.4 → uns_kit-0.0.5}/PKG-INFO +30 -13
- uns_kit-0.0.5/README.md +81 -0
- {uns_kit-0.0.4 → uns_kit-0.0.5}/pyproject.toml +2 -1
- {uns_kit-0.0.4 → uns_kit-0.0.5}/src/uns_kit/__init__.py +9 -0
- uns_kit-0.0.5/src/uns_kit/client.py +349 -0
- uns_kit-0.0.5/src/uns_kit/config.py +90 -0
- uns_kit-0.0.5/src/uns_kit/packet.py +175 -0
- uns_kit-0.0.5/src/uns_kit/proxy.py +64 -0
- uns_kit-0.0.5/src/uns_kit/proxy_process.py +102 -0
- uns_kit-0.0.5/src/uns_kit/status_monitor.py +88 -0
- uns_kit-0.0.5/src/uns_kit/templates/default/README.md +39 -0
- {uns_kit-0.0.4 → uns_kit-0.0.5}/src/uns_kit/templates/default/pyproject.toml +7 -0
- uns_kit-0.0.5/src/uns_kit/templates/default/src/data_example.py +110 -0
- uns_kit-0.0.5/src/uns_kit/templates/default/src/load_test.py +103 -0
- {uns_kit-0.0.4 → uns_kit-0.0.5}/src/uns_kit/topic_builder.py +10 -0
- uns_kit-0.0.5/src/uns_kit/uns_mqtt_proxy.py +237 -0
- uns_kit-0.0.5/src/uns_kit/version.py +9 -0
- uns_kit-0.0.4/README.md +0 -65
- uns_kit-0.0.4/src/uns_kit/client.py +0 -158
- uns_kit-0.0.4/src/uns_kit/config.py +0 -32
- uns_kit-0.0.4/src/uns_kit/packet.py +0 -63
- uns_kit-0.0.4/src/uns_kit/templates/default/README.md +0 -10
- uns_kit-0.0.4/src/uns_kit/templates/default/src/main.py +0 -27
- {uns_kit-0.0.4 → uns_kit-0.0.5}/src/uns_kit/cli.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: uns-kit
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.5
|
|
4
4
|
Summary: Lightweight Python UNS MQTT client (pub/sub + infra topics)
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Aljoša Vister
|
|
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.14
|
|
16
16
|
Requires-Dist: asyncio-mqtt (>=0.16.1,<0.17.0)
|
|
17
17
|
Requires-Dist: click (>=8.1.7,<9.0.0)
|
|
18
|
+
Requires-Dist: paho-mqtt (<2)
|
|
18
19
|
Description-Content-Type: text/markdown
|
|
19
20
|
|
|
20
21
|
# uns-kit (Python)
|
|
@@ -22,8 +23,8 @@ Description-Content-Type: text/markdown
|
|
|
22
23
|
Lightweight UNS MQTT client for Python. Provides:
|
|
23
24
|
- Topic builder compatible with UNS infra topics (`uns-infra/<package>/<version>/<process>/`).
|
|
24
25
|
- Async publish/subscribe via MQTT v5 (using `asyncio-mqtt`).
|
|
25
|
-
-
|
|
26
|
-
- Minimal UNS packet builder/parser (data/table).
|
|
26
|
+
- Process + instance status topics (active/heap/uptime/alive + stats).
|
|
27
|
+
- Minimal UNS packet builder/parser (data/table) aligned with TS core.
|
|
27
28
|
|
|
28
29
|
## Install (editable)
|
|
29
30
|
```bash
|
|
@@ -42,20 +43,21 @@ poetry run uns-kit-py write-config --path config.json
|
|
|
42
43
|
## Quick start
|
|
43
44
|
```python
|
|
44
45
|
import asyncio
|
|
45
|
-
from uns_kit import
|
|
46
|
+
from uns_kit import UnsConfig, UnsPacket, UnsProxyProcess
|
|
46
47
|
|
|
47
48
|
async def main():
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
await
|
|
49
|
+
process = UnsProxyProcess("mqtt-broker", config=UnsConfig(host="mqtt-broker"))
|
|
50
|
+
await process.start()
|
|
51
|
+
mqtt = await process.create_mqtt_proxy("py")
|
|
51
52
|
|
|
52
53
|
# Subscribe
|
|
53
|
-
async with client.messages("uns-infra/#") as messages:
|
|
54
|
-
await
|
|
54
|
+
async with mqtt.client.messages("uns-infra/#") as messages:
|
|
55
|
+
await mqtt.publish_packet("raw/data/", UnsPacket.data(value=1, uom="count"))
|
|
55
56
|
msg = await messages.__anext__()
|
|
56
57
|
print(msg.topic, msg.payload.decode())
|
|
57
58
|
|
|
58
|
-
await
|
|
59
|
+
await mqtt.close()
|
|
60
|
+
await process.stop()
|
|
59
61
|
|
|
60
62
|
asyncio.run(main())
|
|
61
63
|
```
|
|
@@ -69,6 +71,7 @@ async for msg in client.resilient_messages("uns-infra/#"):
|
|
|
69
71
|
### Examples
|
|
70
72
|
- `examples/publish.py` — publish 5 data packets.
|
|
71
73
|
- `examples/subscribe.py` — resilient subscription with auto-reconnect.
|
|
74
|
+
- `examples/load_test.py` — interactive publish burst.
|
|
72
75
|
|
|
73
76
|
### Create a new project
|
|
74
77
|
```bash
|
|
@@ -78,8 +81,22 @@ poetry install
|
|
|
78
81
|
poetry run python src/main.py
|
|
79
82
|
```
|
|
80
83
|
|
|
84
|
+
### Create a sandbox app in this repo
|
|
85
|
+
From the monorepo root:
|
|
86
|
+
```bash
|
|
87
|
+
pnpm run py:sandbox
|
|
88
|
+
```
|
|
89
|
+
This creates `sandbox-app-py/` using the default Python template.
|
|
90
|
+
|
|
81
91
|
## Notes
|
|
82
|
-
- Default QoS is 0
|
|
83
|
-
-
|
|
84
|
-
- Packet shape mirrors the TypeScript core: `{"version":1,"message":{"data":{...}},"sequenceId":0}`.
|
|
92
|
+
- Default QoS is 0.
|
|
93
|
+
- Instance status topics are published every 10 seconds; stats every 60 seconds.
|
|
94
|
+
- Packet shape mirrors the TypeScript core: `{"version":"1.3.0","message":{"data":{...}},"sequenceId":0}`.
|
|
95
|
+
|
|
96
|
+
## TODO (parity with TS core)
|
|
97
|
+
- Handover manager (cross-version active detection + handover_* messages).
|
|
98
|
+
- Publish throttling/queue.
|
|
99
|
+
- Status parity (publisher/subscriber active flags everywhere, richer metrics).
|
|
100
|
+
- API endpoints registry (to mirror @uns-kit/api produced endpoints).
|
|
101
|
+
- Optional: dictionary/measurement helpers + CLI wrapper.
|
|
85
102
|
|
uns_kit-0.0.5/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# uns-kit (Python)
|
|
2
|
+
|
|
3
|
+
Lightweight UNS MQTT client for Python. Provides:
|
|
4
|
+
- Topic builder compatible with UNS infra topics (`uns-infra/<package>/<version>/<process>/`).
|
|
5
|
+
- Async publish/subscribe via MQTT v5 (using `asyncio-mqtt`).
|
|
6
|
+
- Process + instance status topics (active/heap/uptime/alive + stats).
|
|
7
|
+
- Minimal UNS packet builder/parser (data/table) aligned with TS core.
|
|
8
|
+
|
|
9
|
+
## Install (editable)
|
|
10
|
+
```bash
|
|
11
|
+
cd packages/uns-py
|
|
12
|
+
poetry install
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## CLI
|
|
16
|
+
After `poetry install`, an `uns-kit-py` command is available (renamed to avoid clashing with the Node CLI):
|
|
17
|
+
```bash
|
|
18
|
+
poetry run uns-kit-py publish --host localhost:1883 --topic raw/data/ --value 1
|
|
19
|
+
poetry run uns-kit-py subscribe --host localhost:1883 --topic 'uns-infra/#'
|
|
20
|
+
poetry run uns-kit-py write-config --path config.json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
```python
|
|
25
|
+
import asyncio
|
|
26
|
+
from uns_kit import UnsConfig, UnsPacket, UnsProxyProcess
|
|
27
|
+
|
|
28
|
+
async def main():
|
|
29
|
+
process = UnsProxyProcess("mqtt-broker", config=UnsConfig(host="mqtt-broker"))
|
|
30
|
+
await process.start()
|
|
31
|
+
mqtt = await process.create_mqtt_proxy("py")
|
|
32
|
+
|
|
33
|
+
# Subscribe
|
|
34
|
+
async with mqtt.client.messages("uns-infra/#") as messages:
|
|
35
|
+
await mqtt.publish_packet("raw/data/", UnsPacket.data(value=1, uom="count"))
|
|
36
|
+
msg = await messages.__anext__()
|
|
37
|
+
print(msg.topic, msg.payload.decode())
|
|
38
|
+
|
|
39
|
+
await mqtt.close()
|
|
40
|
+
await process.stop()
|
|
41
|
+
|
|
42
|
+
asyncio.run(main())
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Resilient subscriber
|
|
46
|
+
```python
|
|
47
|
+
async for msg in client.resilient_messages("uns-infra/#"):
|
|
48
|
+
print(msg.topic, msg.payload.decode())
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Examples
|
|
52
|
+
- `examples/publish.py` — publish 5 data packets.
|
|
53
|
+
- `examples/subscribe.py` — resilient subscription with auto-reconnect.
|
|
54
|
+
- `examples/load_test.py` — interactive publish burst.
|
|
55
|
+
|
|
56
|
+
### Create a new project
|
|
57
|
+
```bash
|
|
58
|
+
uns-kit-py create my-uns-py-app
|
|
59
|
+
cd my-uns-py-app
|
|
60
|
+
poetry install
|
|
61
|
+
poetry run python src/main.py
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Create a sandbox app in this repo
|
|
65
|
+
From the monorepo root:
|
|
66
|
+
```bash
|
|
67
|
+
pnpm run py:sandbox
|
|
68
|
+
```
|
|
69
|
+
This creates `sandbox-app-py/` using the default Python template.
|
|
70
|
+
|
|
71
|
+
## Notes
|
|
72
|
+
- Default QoS is 0.
|
|
73
|
+
- Instance status topics are published every 10 seconds; stats every 60 seconds.
|
|
74
|
+
- Packet shape mirrors the TypeScript core: `{"version":"1.3.0","message":{"data":{...}},"sequenceId":0}`.
|
|
75
|
+
|
|
76
|
+
## TODO (parity with TS core)
|
|
77
|
+
- Handover manager (cross-version active detection + handover_* messages).
|
|
78
|
+
- Publish throttling/queue.
|
|
79
|
+
- Status parity (publisher/subscriber active flags everywhere, richer metrics).
|
|
80
|
+
- API endpoints registry (to mirror @uns-kit/api produced endpoints).
|
|
81
|
+
- Optional: dictionary/measurement helpers + CLI wrapper.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "uns-kit"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.5"
|
|
4
4
|
description = "Lightweight Python UNS MQTT client (pub/sub + infra topics)"
|
|
5
5
|
authors = ["Aljoša Vister <aljosa.vister@gmail.com>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -11,6 +11,7 @@ packages = [{ include = "uns_kit", from = "src" }]
|
|
|
11
11
|
python = "^3.10"
|
|
12
12
|
asyncio-mqtt = "^0.16.1"
|
|
13
13
|
click = "^8.1.7"
|
|
14
|
+
paho-mqtt = "<2"
|
|
14
15
|
|
|
15
16
|
[tool.poetry.group.dev.dependencies]
|
|
16
17
|
pytest = "^8.3.3"
|
|
@@ -2,6 +2,10 @@ from .topic_builder import TopicBuilder
|
|
|
2
2
|
from .packet import UnsPacket, DataPayload, TablePayload
|
|
3
3
|
from .client import UnsMqttClient
|
|
4
4
|
from .config import UnsConfig
|
|
5
|
+
from .status_monitor import StatusMonitor
|
|
6
|
+
from .uns_mqtt_proxy import UnsMqttProxy, MessageMode
|
|
7
|
+
from .proxy_process import UnsProxyProcess
|
|
8
|
+
from .version import __version__
|
|
5
9
|
|
|
6
10
|
__all__ = [
|
|
7
11
|
"TopicBuilder",
|
|
@@ -10,4 +14,9 @@ __all__ = [
|
|
|
10
14
|
"TablePayload",
|
|
11
15
|
"UnsMqttClient",
|
|
12
16
|
"UnsConfig",
|
|
17
|
+
"StatusMonitor",
|
|
18
|
+
"UnsMqttProxy",
|
|
19
|
+
"MessageMode",
|
|
20
|
+
"UnsProxyProcess",
|
|
21
|
+
"__version__",
|
|
13
22
|
]
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import uuid
|
|
8
|
+
import socket
|
|
9
|
+
from typing import AsyncIterator, List, Optional
|
|
10
|
+
|
|
11
|
+
from asyncio_mqtt import Client, MqttError, Message, Will, Topic
|
|
12
|
+
from paho.mqtt.client import MQTTv5, MQTTv311
|
|
13
|
+
|
|
14
|
+
from .packet import UnsPacket
|
|
15
|
+
from .topic_builder import TopicBuilder
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnsMqttClient:
|
|
19
|
+
_exception_handler_installed = False
|
|
20
|
+
_exception_handler_loop_id: int | None = None
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
host: str,
|
|
25
|
+
*,
|
|
26
|
+
topic_builder: TopicBuilder,
|
|
27
|
+
port: Optional[int] = None,
|
|
28
|
+
username: Optional[str] = None,
|
|
29
|
+
password: Optional[str] = None,
|
|
30
|
+
client_id: Optional[str] = None,
|
|
31
|
+
tls: bool = False,
|
|
32
|
+
keepalive: int = 60,
|
|
33
|
+
clean_session: bool = True,
|
|
34
|
+
reconnect_interval: float = 2.0,
|
|
35
|
+
max_reconnect_interval: float = 30.0,
|
|
36
|
+
instance_name: Optional[str] = None,
|
|
37
|
+
publisher_active: Optional[bool] = None,
|
|
38
|
+
subscriber_active: Optional[bool] = None,
|
|
39
|
+
stats_interval: float = 60.0,
|
|
40
|
+
enable_status: bool = True,
|
|
41
|
+
):
|
|
42
|
+
self.host = host
|
|
43
|
+
self.port = port
|
|
44
|
+
self.username = username if username not in ("", None) else None
|
|
45
|
+
self.password = password if password not in ("", None) else None
|
|
46
|
+
if client_id:
|
|
47
|
+
self.client_id = client_id
|
|
48
|
+
else:
|
|
49
|
+
base = topic_builder.process_name
|
|
50
|
+
if instance_name:
|
|
51
|
+
base = f"{base}-{instance_name}"
|
|
52
|
+
self.client_id = f"{base}-{uuid.uuid4().hex[:8]}"
|
|
53
|
+
self.tls = tls
|
|
54
|
+
self.keepalive = keepalive
|
|
55
|
+
self.clean_session = clean_session
|
|
56
|
+
self.reconnect_interval = reconnect_interval
|
|
57
|
+
self.max_reconnect_interval = max_reconnect_interval
|
|
58
|
+
self.topic_builder = topic_builder
|
|
59
|
+
self.instance_name = instance_name
|
|
60
|
+
self.publisher_active = publisher_active
|
|
61
|
+
self.subscriber_active = subscriber_active
|
|
62
|
+
self.stats_interval = stats_interval
|
|
63
|
+
self.enable_status = enable_status
|
|
64
|
+
self._client: Optional[Client] = None
|
|
65
|
+
self._status_task: Optional[asyncio.Task] = None
|
|
66
|
+
self._stats_task: Optional[asyncio.Task] = None
|
|
67
|
+
self._connected = asyncio.Event()
|
|
68
|
+
self._closing = False
|
|
69
|
+
self._connect_lock = asyncio.Lock()
|
|
70
|
+
self._published_message_count = 0
|
|
71
|
+
self._published_message_bytes = 0
|
|
72
|
+
self._subscribed_message_count = 0
|
|
73
|
+
self._subscribed_message_bytes = 0
|
|
74
|
+
|
|
75
|
+
if self.instance_name:
|
|
76
|
+
self.status_topic = self.topic_builder.instance_status_topic(self.instance_name)
|
|
77
|
+
else:
|
|
78
|
+
self.status_topic = self.topic_builder.process_status_topic
|
|
79
|
+
|
|
80
|
+
def _install_exception_handler(self) -> None:
|
|
81
|
+
loop = asyncio.get_running_loop()
|
|
82
|
+
loop_id = id(loop)
|
|
83
|
+
if UnsMqttClient._exception_handler_installed and UnsMqttClient._exception_handler_loop_id == loop_id:
|
|
84
|
+
return
|
|
85
|
+
original = loop.get_exception_handler()
|
|
86
|
+
|
|
87
|
+
def handler(loop: asyncio.AbstractEventLoop, context: dict) -> None:
|
|
88
|
+
exc = context.get("exception")
|
|
89
|
+
message = context.get("message", "")
|
|
90
|
+
if isinstance(exc, MqttError) and "Unexpected disconnect" in str(exc):
|
|
91
|
+
# Swallow noisy disconnect futures; reconnect logic handles it.
|
|
92
|
+
return
|
|
93
|
+
if isinstance(exc, MqttError) and "Future exception was never retrieved" in message:
|
|
94
|
+
return
|
|
95
|
+
if original:
|
|
96
|
+
original(loop, context)
|
|
97
|
+
else:
|
|
98
|
+
loop.default_exception_handler(context)
|
|
99
|
+
|
|
100
|
+
loop.set_exception_handler(handler)
|
|
101
|
+
UnsMqttClient._exception_handler_installed = True
|
|
102
|
+
UnsMqttClient._exception_handler_loop_id = loop_id
|
|
103
|
+
|
|
104
|
+
async def _connect_once(self) -> None:
|
|
105
|
+
await self._probe_socket()
|
|
106
|
+
will = Will(
|
|
107
|
+
topic=f"{self.status_topic}alive",
|
|
108
|
+
payload=b"",
|
|
109
|
+
qos=0,
|
|
110
|
+
retain=True,
|
|
111
|
+
)
|
|
112
|
+
base_kwargs = {
|
|
113
|
+
"hostname": self.host,
|
|
114
|
+
"port": self.port,
|
|
115
|
+
"username": self.username,
|
|
116
|
+
"password": self.password,
|
|
117
|
+
"client_id": self.client_id,
|
|
118
|
+
"keepalive": self.keepalive,
|
|
119
|
+
"will": will,
|
|
120
|
+
}
|
|
121
|
+
if self.tls:
|
|
122
|
+
try:
|
|
123
|
+
import ssl
|
|
124
|
+
base_kwargs["tls_context"] = ssl.create_default_context()
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
last_error: Exception | None = None
|
|
129
|
+
# Prefer MQTT 3.1.1 for widest broker compatibility (some brokers
|
|
130
|
+
# allow connect with MQTTv5 but then drop subscriptions unexpectedly).
|
|
131
|
+
for protocol in (MQTTv311, MQTTv5):
|
|
132
|
+
kwargs = dict(base_kwargs)
|
|
133
|
+
kwargs["protocol"] = protocol
|
|
134
|
+
if protocol == MQTTv5:
|
|
135
|
+
kwargs["clean_start"] = self.clean_session
|
|
136
|
+
kwargs.pop("clean_session", None)
|
|
137
|
+
else:
|
|
138
|
+
kwargs["clean_session"] = self.clean_session
|
|
139
|
+
kwargs.pop("clean_start", None)
|
|
140
|
+
try:
|
|
141
|
+
try:
|
|
142
|
+
client = Client(**kwargs)
|
|
143
|
+
except TypeError:
|
|
144
|
+
kwargs.pop("tls_context", None)
|
|
145
|
+
client = Client(**kwargs)
|
|
146
|
+
await client.connect()
|
|
147
|
+
self._client = client
|
|
148
|
+
self._connected.set()
|
|
149
|
+
return
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
last_error = exc
|
|
152
|
+
continue
|
|
153
|
+
if last_error:
|
|
154
|
+
raise last_error
|
|
155
|
+
|
|
156
|
+
async def _probe_socket(self) -> None:
|
|
157
|
+
try:
|
|
158
|
+
loop = asyncio.get_event_loop()
|
|
159
|
+
await loop.run_in_executor(
|
|
160
|
+
None,
|
|
161
|
+
lambda: socket.create_connection((self.host, self.port or 1883), timeout=2),
|
|
162
|
+
)
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
raise MqttError(f"Cannot reach MQTT broker at {self.host}:{self.port or 1883}") from exc
|
|
165
|
+
|
|
166
|
+
async def _ensure_connected(self) -> None:
|
|
167
|
+
if self._connected.is_set():
|
|
168
|
+
return
|
|
169
|
+
async with self._connect_lock:
|
|
170
|
+
if self._connected.is_set():
|
|
171
|
+
return
|
|
172
|
+
self._install_exception_handler()
|
|
173
|
+
backoff = self.reconnect_interval
|
|
174
|
+
while not self._closing:
|
|
175
|
+
try:
|
|
176
|
+
await self._connect_once()
|
|
177
|
+
if self.enable_status:
|
|
178
|
+
if not self._status_task or self._status_task.done():
|
|
179
|
+
self._status_task = asyncio.create_task(self._publish_status_loop())
|
|
180
|
+
self._status_task.add_done_callback(self._handle_task_error)
|
|
181
|
+
if not self._stats_task or self._stats_task.done():
|
|
182
|
+
self._stats_task = asyncio.create_task(self._publish_stats_loop())
|
|
183
|
+
self._stats_task.add_done_callback(self._handle_task_error)
|
|
184
|
+
return
|
|
185
|
+
except MqttError:
|
|
186
|
+
await asyncio.sleep(backoff)
|
|
187
|
+
backoff = min(backoff * 2, self.max_reconnect_interval)
|
|
188
|
+
|
|
189
|
+
def _handle_task_error(self, task: asyncio.Task) -> None:
|
|
190
|
+
try:
|
|
191
|
+
task.exception()
|
|
192
|
+
except asyncio.CancelledError:
|
|
193
|
+
return
|
|
194
|
+
except Exception:
|
|
195
|
+
self._connected.clear()
|
|
196
|
+
|
|
197
|
+
async def connect(self) -> None:
|
|
198
|
+
await self._ensure_connected()
|
|
199
|
+
|
|
200
|
+
async def close(self) -> None:
|
|
201
|
+
self._closing = True
|
|
202
|
+
if self._status_task:
|
|
203
|
+
self._status_task.cancel()
|
|
204
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
205
|
+
await self._status_task
|
|
206
|
+
if self._stats_task:
|
|
207
|
+
self._stats_task.cancel()
|
|
208
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
209
|
+
await self._stats_task
|
|
210
|
+
if self._client:
|
|
211
|
+
with contextlib.suppress(Exception):
|
|
212
|
+
await self._client.disconnect()
|
|
213
|
+
self._connected.clear()
|
|
214
|
+
|
|
215
|
+
async def publish_raw(self, topic: str, payload: str | bytes, *, qos: int = 0, retain: bool = False) -> None:
|
|
216
|
+
await self._ensure_connected()
|
|
217
|
+
for attempt in range(2):
|
|
218
|
+
try:
|
|
219
|
+
assert self._client
|
|
220
|
+
payload_bytes = payload.encode() if isinstance(payload, str) else payload
|
|
221
|
+
self._published_message_count += 1
|
|
222
|
+
self._published_message_bytes += len(payload_bytes)
|
|
223
|
+
await self._client.publish(topic, payload, qos=qos, retain=retain)
|
|
224
|
+
return
|
|
225
|
+
except MqttError:
|
|
226
|
+
self._connected.clear()
|
|
227
|
+
if attempt == 0:
|
|
228
|
+
await self._ensure_connected()
|
|
229
|
+
else:
|
|
230
|
+
raise
|
|
231
|
+
|
|
232
|
+
async def publish_packet(self, topic: str, packet: dict, *, qos: int = 0, retain: bool = False) -> None:
|
|
233
|
+
payload = UnsPacket.to_json(packet)
|
|
234
|
+
await self.publish_raw(topic, payload, qos=qos, retain=retain)
|
|
235
|
+
|
|
236
|
+
@asynccontextmanager
|
|
237
|
+
async def messages(self, topics: str | List[str]) -> AsyncIterator[AsyncIterator[Message]]:
|
|
238
|
+
await self._ensure_connected()
|
|
239
|
+
assert self._client
|
|
240
|
+
async with self._client.messages() as messages:
|
|
241
|
+
try:
|
|
242
|
+
if isinstance(topics, str):
|
|
243
|
+
await self._client.subscribe(topics)
|
|
244
|
+
else:
|
|
245
|
+
for t in topics:
|
|
246
|
+
await self._client.subscribe(t)
|
|
247
|
+
except MqttError:
|
|
248
|
+
self._connected.clear()
|
|
249
|
+
raise
|
|
250
|
+
|
|
251
|
+
async def wrapped() -> AsyncIterator[Message]:
|
|
252
|
+
try:
|
|
253
|
+
async for msg in messages:
|
|
254
|
+
self._subscribed_message_count += 1
|
|
255
|
+
self._subscribed_message_bytes += len(msg.payload or b"")
|
|
256
|
+
yield msg
|
|
257
|
+
except MqttError:
|
|
258
|
+
self._connected.clear()
|
|
259
|
+
return
|
|
260
|
+
except asyncio.CancelledError:
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
yield wrapped()
|
|
264
|
+
|
|
265
|
+
async def resilient_messages(self, topics: str | List[str]) -> AsyncIterator[Message]:
|
|
266
|
+
"""
|
|
267
|
+
Async generator that keeps the subscription alive across disconnects.
|
|
268
|
+
"""
|
|
269
|
+
while not self._closing:
|
|
270
|
+
await self._ensure_connected()
|
|
271
|
+
try:
|
|
272
|
+
async with self.messages(topics) as msgs:
|
|
273
|
+
async for msg in msgs:
|
|
274
|
+
yield msg
|
|
275
|
+
except MqttError:
|
|
276
|
+
self._connected.clear()
|
|
277
|
+
await asyncio.sleep(self.reconnect_interval)
|
|
278
|
+
continue
|
|
279
|
+
except Exception:
|
|
280
|
+
self._connected.clear()
|
|
281
|
+
await asyncio.sleep(self.reconnect_interval)
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
async def _publish_status_loop(self) -> None:
|
|
285
|
+
uptime_topic = f"{self.status_topic}uptime"
|
|
286
|
+
alive_topic = f"{self.status_topic}alive"
|
|
287
|
+
publisher_topic = f"{self.status_topic}t-publisher-active"
|
|
288
|
+
subscriber_topic = f"{self.status_topic}t-subscriber-active"
|
|
289
|
+
start = asyncio.get_event_loop().time()
|
|
290
|
+
try:
|
|
291
|
+
while not self._closing:
|
|
292
|
+
try:
|
|
293
|
+
now = asyncio.get_event_loop().time()
|
|
294
|
+
uptime_minutes = int((now - start) / 60)
|
|
295
|
+
time = datetime.now(timezone.utc)
|
|
296
|
+
alive_packet = UnsPacket.data(value=1, uom="bit", time=time)
|
|
297
|
+
uptime_packet = UnsPacket.data(value=uptime_minutes, uom="min", time=time)
|
|
298
|
+
await self.publish_raw(alive_topic, UnsPacket.to_json(alive_packet), qos=0, retain=False)
|
|
299
|
+
await self.publish_raw(uptime_topic, UnsPacket.to_json(uptime_packet), qos=0, retain=False)
|
|
300
|
+
if self.publisher_active is not None:
|
|
301
|
+
publisher_packet = UnsPacket.data(value=1 if self.publisher_active else 0, uom="bit", time=time)
|
|
302
|
+
await self.publish_raw(publisher_topic, UnsPacket.to_json(publisher_packet), qos=0, retain=False)
|
|
303
|
+
if self.subscriber_active is not None:
|
|
304
|
+
subscriber_packet = UnsPacket.data(value=1 if self.subscriber_active else 0, uom="bit", time=time)
|
|
305
|
+
await self.publish_raw(subscriber_topic, UnsPacket.to_json(subscriber_packet), qos=0, retain=False)
|
|
306
|
+
except MqttError:
|
|
307
|
+
self._connected.clear()
|
|
308
|
+
except Exception:
|
|
309
|
+
self._connected.clear()
|
|
310
|
+
await asyncio.sleep(10)
|
|
311
|
+
except asyncio.CancelledError:
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
async def _publish_stats_loop(self) -> None:
|
|
315
|
+
published_count_topic = f"{self.status_topic}published-message-count"
|
|
316
|
+
published_bytes_topic = f"{self.status_topic}published-message-bytes"
|
|
317
|
+
subscribed_count_topic = f"{self.status_topic}subscribed-message-count"
|
|
318
|
+
subscribed_bytes_topic = f"{self.status_topic}subscribed-message-bytes"
|
|
319
|
+
try:
|
|
320
|
+
while not self._closing:
|
|
321
|
+
try:
|
|
322
|
+
time = datetime.now(timezone.utc)
|
|
323
|
+
published_count_packet = UnsPacket.data(value=self._published_message_count, time=time)
|
|
324
|
+
published_bytes_packet = UnsPacket.data(
|
|
325
|
+
value=round(self._published_message_bytes / 1024),
|
|
326
|
+
uom="kB",
|
|
327
|
+
time=time,
|
|
328
|
+
)
|
|
329
|
+
subscribed_count_packet = UnsPacket.data(value=self._subscribed_message_count, time=time)
|
|
330
|
+
subscribed_bytes_packet = UnsPacket.data(
|
|
331
|
+
value=round(self._subscribed_message_bytes / 1024),
|
|
332
|
+
uom="kB",
|
|
333
|
+
time=time,
|
|
334
|
+
)
|
|
335
|
+
await self.publish_raw(published_count_topic, UnsPacket.to_json(published_count_packet), qos=0, retain=False)
|
|
336
|
+
await self.publish_raw(published_bytes_topic, UnsPacket.to_json(published_bytes_packet), qos=0, retain=False)
|
|
337
|
+
await self.publish_raw(subscribed_count_topic, UnsPacket.to_json(subscribed_count_packet), qos=0, retain=False)
|
|
338
|
+
await self.publish_raw(subscribed_bytes_topic, UnsPacket.to_json(subscribed_bytes_packet), qos=0, retain=False)
|
|
339
|
+
self._published_message_count = 0
|
|
340
|
+
self._published_message_bytes = 0
|
|
341
|
+
self._subscribed_message_count = 0
|
|
342
|
+
self._subscribed_message_bytes = 0
|
|
343
|
+
except MqttError:
|
|
344
|
+
self._connected.clear()
|
|
345
|
+
except Exception:
|
|
346
|
+
self._connected.clear()
|
|
347
|
+
await asyncio.sleep(self.stats_interval)
|
|
348
|
+
except asyncio.CancelledError:
|
|
349
|
+
pass
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from .topic_builder import TopicBuilder
|
|
9
|
+
from .version import __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class UnsConfig:
|
|
14
|
+
# MQTT infra (matches TS nested config.infra)
|
|
15
|
+
infra_host: str
|
|
16
|
+
infra_port: Optional[int] = None
|
|
17
|
+
infra_username: Optional[str] = None
|
|
18
|
+
infra_password: Optional[str] = None
|
|
19
|
+
infra_client_id: Optional[str] = None
|
|
20
|
+
infra_tls: bool = False
|
|
21
|
+
keepalive: int = 60
|
|
22
|
+
clean_session: bool = True
|
|
23
|
+
mqtt_sub_to_topics: Optional[List[str]] = None
|
|
24
|
+
package_name: str = "uns-kit"
|
|
25
|
+
package_version: str = __version__
|
|
26
|
+
process_name: str = "uns-process"
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def load(path: Path) -> "UnsConfig":
|
|
30
|
+
data = json.loads(path.read_text())
|
|
31
|
+
|
|
32
|
+
if "infra" not in data or "uns" not in data:
|
|
33
|
+
raise ValueError("config.json must include 'infra' and 'uns' sections (TS-style config)")
|
|
34
|
+
|
|
35
|
+
infra = data.get("infra", {}) or {}
|
|
36
|
+
uns_section = data.get("uns", {}) or {}
|
|
37
|
+
|
|
38
|
+
proto = (infra.get("protocol") or "").lower()
|
|
39
|
+
tls = proto in ("mqtts", "ssl", "wss")
|
|
40
|
+
|
|
41
|
+
host = infra.get("host") or (infra.get("hosts") or [None])[0]
|
|
42
|
+
if not host:
|
|
43
|
+
raise ValueError("infra.host (or hosts[0]) is required")
|
|
44
|
+
|
|
45
|
+
return UnsConfig(
|
|
46
|
+
infra_host=host,
|
|
47
|
+
infra_port=infra.get("port"),
|
|
48
|
+
infra_username=_none_if_empty(infra.get("username")),
|
|
49
|
+
infra_password=_none_if_empty(infra.get("password")),
|
|
50
|
+
infra_client_id=_none_if_empty(infra.get("clientId")),
|
|
51
|
+
infra_tls=infra.get("tls") if infra.get("tls") is not None else tls,
|
|
52
|
+
mqtt_sub_to_topics=infra.get("mqttSubToTopics"),
|
|
53
|
+
keepalive=infra.get("keepalive", 60),
|
|
54
|
+
clean_session=infra.get("clean", True),
|
|
55
|
+
package_name=uns_section.get("packageName", "uns-kit"),
|
|
56
|
+
package_version=uns_section.get("packageVersion") or __version__,
|
|
57
|
+
process_name=uns_section.get("processName", "uns-process"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def topic_builder(self) -> TopicBuilder:
|
|
61
|
+
return TopicBuilder(self.package_name, self.package_version, self.process_name)
|
|
62
|
+
|
|
63
|
+
# Convenience accessors for MQTT client creation
|
|
64
|
+
@property
|
|
65
|
+
def host(self) -> str:
|
|
66
|
+
return self.infra_host
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def port(self) -> Optional[int]:
|
|
70
|
+
return self.infra_port
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def username(self) -> Optional[str]:
|
|
74
|
+
return self.infra_username
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def password(self) -> Optional[str]:
|
|
78
|
+
return self.infra_password
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def client_id(self) -> Optional[str]:
|
|
82
|
+
return self.infra_client_id
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def tls(self) -> bool:
|
|
86
|
+
return self.infra_tls
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _none_if_empty(value: Optional[str]) -> Optional[str]:
|
|
90
|
+
return None if value == "" else value
|