uns-kit 0.0.2__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.2/PKG-INFO +77 -0
- uns_kit-0.0.2/README.md +57 -0
- uns_kit-0.0.2/pyproject.toml +26 -0
- uns_kit-0.0.2/src/uns_kit/__init__.py +13 -0
- uns_kit-0.0.2/src/uns_kit/cli.py +146 -0
- uns_kit-0.0.2/src/uns_kit/client.py +158 -0
- uns_kit-0.0.2/src/uns_kit/config.py +32 -0
- uns_kit-0.0.2/src/uns_kit/packet.py +63 -0
- uns_kit-0.0.2/src/uns_kit/topic_builder.py +41 -0
uns_kit-0.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uns-kit
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Lightweight Python UNS MQTT client (pub/sub + infra topics)
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Aljoša Vister
|
|
7
|
+
Author-email: aljosa.vister@gmail.com
|
|
8
|
+
Requires-Python: >=3.10,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Dist: asyncio-mqtt (>=0.16.1,<0.17.0)
|
|
17
|
+
Requires-Dist: click (>=8.1.7,<9.0.0)
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# uns-kit (Python)
|
|
21
|
+
|
|
22
|
+
Lightweight UNS MQTT client for Python. Provides:
|
|
23
|
+
- Topic builder compatible with UNS infra topics (`uns-infra/<package>/<version>/<process>/`).
|
|
24
|
+
- Async publish/subscribe via MQTT v5 (using `asyncio-mqtt`).
|
|
25
|
+
- Infra status topics (`alive`, `uptime`) with MQTT will.
|
|
26
|
+
- Minimal UNS packet builder/parser (data/table).
|
|
27
|
+
|
|
28
|
+
## Install (editable)
|
|
29
|
+
```bash
|
|
30
|
+
cd packages/uns-py
|
|
31
|
+
poetry install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## CLI
|
|
35
|
+
After `poetry install`, a `uns-kit` command is available:
|
|
36
|
+
```bash
|
|
37
|
+
poetry run uns-kit publish --host localhost:1883 --topic raw/data/ --value 1
|
|
38
|
+
poetry run uns-kit subscribe --host localhost:1883 --topic 'uns-infra/#'
|
|
39
|
+
poetry run uns-kit write-config --path config.json
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
```python
|
|
44
|
+
import asyncio
|
|
45
|
+
from uns_kit import UnsMqttClient, TopicBuilder, UnsPacket
|
|
46
|
+
|
|
47
|
+
async def main():
|
|
48
|
+
tb = TopicBuilder(package_name="uns-kit", package_version="0.0.1", process_name="py-demo")
|
|
49
|
+
client = UnsMqttClient(host="mqtt-broker", topic_builder=tb, reconnect_interval=1)
|
|
50
|
+
await client.connect()
|
|
51
|
+
|
|
52
|
+
# Subscribe
|
|
53
|
+
async with client.messages("uns-infra/#") as messages:
|
|
54
|
+
await client.publish_packet("raw/data/", UnsPacket.data(value=1, uom="count"))
|
|
55
|
+
msg = await messages.__anext__()
|
|
56
|
+
print(msg.topic, msg.payload.decode())
|
|
57
|
+
|
|
58
|
+
await client.close()
|
|
59
|
+
|
|
60
|
+
asyncio.run(main())
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Resilient subscriber
|
|
64
|
+
```python
|
|
65
|
+
async for msg in client.resilient_messages("uns-infra/#"):
|
|
66
|
+
print(msg.topic, msg.payload.decode())
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Examples
|
|
70
|
+
- `examples/publish.py` — publish 5 data packets.
|
|
71
|
+
- `examples/subscribe.py` — resilient subscription with auto-reconnect.
|
|
72
|
+
|
|
73
|
+
## Notes
|
|
74
|
+
- Default QoS is 0; will message is retained on `<statusTopic>alive`.
|
|
75
|
+
- Uptime is published every 10 seconds on `<statusTopic>uptime`.
|
|
76
|
+
- Packet shape mirrors the TypeScript core: `{"version":1,"message":{"data":{...}},"sequenceId":0}`.
|
|
77
|
+
|
uns_kit-0.0.2/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
- Infra status topics (`alive`, `uptime`) with MQTT will.
|
|
7
|
+
- Minimal UNS packet builder/parser (data/table).
|
|
8
|
+
|
|
9
|
+
## Install (editable)
|
|
10
|
+
```bash
|
|
11
|
+
cd packages/uns-py
|
|
12
|
+
poetry install
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## CLI
|
|
16
|
+
After `poetry install`, a `uns-kit` command is available:
|
|
17
|
+
```bash
|
|
18
|
+
poetry run uns-kit publish --host localhost:1883 --topic raw/data/ --value 1
|
|
19
|
+
poetry run uns-kit subscribe --host localhost:1883 --topic 'uns-infra/#'
|
|
20
|
+
poetry run uns-kit write-config --path config.json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
```python
|
|
25
|
+
import asyncio
|
|
26
|
+
from uns_kit import UnsMqttClient, TopicBuilder, UnsPacket
|
|
27
|
+
|
|
28
|
+
async def main():
|
|
29
|
+
tb = TopicBuilder(package_name="uns-kit", package_version="0.0.1", process_name="py-demo")
|
|
30
|
+
client = UnsMqttClient(host="mqtt-broker", topic_builder=tb, reconnect_interval=1)
|
|
31
|
+
await client.connect()
|
|
32
|
+
|
|
33
|
+
# Subscribe
|
|
34
|
+
async with client.messages("uns-infra/#") as messages:
|
|
35
|
+
await client.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 client.close()
|
|
40
|
+
|
|
41
|
+
asyncio.run(main())
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Resilient subscriber
|
|
45
|
+
```python
|
|
46
|
+
async for msg in client.resilient_messages("uns-infra/#"):
|
|
47
|
+
print(msg.topic, msg.payload.decode())
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Examples
|
|
51
|
+
- `examples/publish.py` — publish 5 data packets.
|
|
52
|
+
- `examples/subscribe.py` — resilient subscription with auto-reconnect.
|
|
53
|
+
|
|
54
|
+
## Notes
|
|
55
|
+
- Default QoS is 0; will message is retained on `<statusTopic>alive`.
|
|
56
|
+
- Uptime is published every 10 seconds on `<statusTopic>uptime`.
|
|
57
|
+
- Packet shape mirrors the TypeScript core: `{"version":1,"message":{"data":{...}},"sequenceId":0}`.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "uns-kit"
|
|
3
|
+
version = "0.0.2"
|
|
4
|
+
description = "Lightweight Python UNS MQTT client (pub/sub + infra topics)"
|
|
5
|
+
authors = ["Aljoša Vister <aljosa.vister@gmail.com>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
packages = [{ include = "uns_kit", from = "src" }]
|
|
9
|
+
|
|
10
|
+
[tool.poetry.dependencies]
|
|
11
|
+
python = "^3.10"
|
|
12
|
+
asyncio-mqtt = "^0.16.1"
|
|
13
|
+
click = "^8.1.7"
|
|
14
|
+
|
|
15
|
+
[tool.poetry.group.dev.dependencies]
|
|
16
|
+
pytest = "^8.3.3"
|
|
17
|
+
pytest-asyncio = "^0.24.0"
|
|
18
|
+
ruff = "^0.6.9"
|
|
19
|
+
mypy = "^1.11.2"
|
|
20
|
+
|
|
21
|
+
[tool.poetry.scripts]
|
|
22
|
+
uns-kit = "uns_kit.cli:main"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["poetry-core"]
|
|
26
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .topic_builder import TopicBuilder
|
|
2
|
+
from .packet import UnsPacket, DataPayload, TablePayload
|
|
3
|
+
from .client import UnsMqttClient
|
|
4
|
+
from .config import UnsConfig
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"TopicBuilder",
|
|
8
|
+
"UnsPacket",
|
|
9
|
+
"DataPayload",
|
|
10
|
+
"TablePayload",
|
|
11
|
+
"UnsMqttClient",
|
|
12
|
+
"UnsConfig",
|
|
13
|
+
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from .client import UnsMqttClient
|
|
11
|
+
from .packet import UnsPacket
|
|
12
|
+
from .topic_builder import TopicBuilder
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def common_options(func):
|
|
16
|
+
func = click.option("--host", required=True, help="MQTT host (hostname or host:port)")(func)
|
|
17
|
+
func = click.option("--port", type=int, help="MQTT port override")(func)
|
|
18
|
+
func = click.option("--username", help="MQTT username")(func)
|
|
19
|
+
func = click.option("--password", help="MQTT password")(func)
|
|
20
|
+
func = click.option("--tls/--no-tls", default=False, show_default=True, help="Enable TLS")(func)
|
|
21
|
+
func = click.option("--client-id", help="MQTT clientId")(func)
|
|
22
|
+
func = click.option("--package-name", default="uns-kit", show_default=True, help="Package name for infra topics")(func)
|
|
23
|
+
func = click.option("--package-version", default="0.0.1", show_default=True, help="Package version for infra topics")(func)
|
|
24
|
+
func = click.option("--process-name", default="uns-process", show_default=True, help="Process name for infra topics")(func)
|
|
25
|
+
func = click.option("--reconnect-interval", default=1.0, show_default=True, type=float, help="Reconnect backoff start (s)")(func)
|
|
26
|
+
return func
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@click.group(help="Lightweight UNS MQTT helper (Python).")
|
|
30
|
+
def cli():
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@cli.command("publish", help="Publish a UNS data packet to a topic.")
|
|
35
|
+
@common_options
|
|
36
|
+
@click.option("--topic", required=True, help="MQTT topic (e.g. raw/data/)")
|
|
37
|
+
@click.option("--value", required=False, help="Value to send (stringified if not JSON).")
|
|
38
|
+
@click.option("--uom", default=None, help="Unit of measure.")
|
|
39
|
+
@click.option("--json", "json_value", default=None, help="JSON string to use as value.")
|
|
40
|
+
@click.option("--qos", type=int, default=0, show_default=True)
|
|
41
|
+
@click.option("--retain/--no-retain", default=False, show_default=True)
|
|
42
|
+
def publish_cmd(**opts):
|
|
43
|
+
asyncio.run(_run_publish(**opts))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _run_publish(
|
|
47
|
+
host: str,
|
|
48
|
+
port: Optional[int],
|
|
49
|
+
username: Optional[str],
|
|
50
|
+
password: Optional[str],
|
|
51
|
+
tls: bool,
|
|
52
|
+
client_id: Optional[str],
|
|
53
|
+
package_name: str,
|
|
54
|
+
package_version: str,
|
|
55
|
+
process_name: str,
|
|
56
|
+
reconnect_interval: float,
|
|
57
|
+
topic: str,
|
|
58
|
+
value: Optional[str],
|
|
59
|
+
uom: Optional[str],
|
|
60
|
+
json_value: Optional[str],
|
|
61
|
+
qos: int,
|
|
62
|
+
retain: bool,
|
|
63
|
+
):
|
|
64
|
+
tb = TopicBuilder(package_name, package_version, process_name)
|
|
65
|
+
client = UnsMqttClient(
|
|
66
|
+
host=host.split(":")[0],
|
|
67
|
+
port=int(host.split(":")[1]) if ":" in host and port is None else port,
|
|
68
|
+
username=username,
|
|
69
|
+
password=password,
|
|
70
|
+
tls=tls,
|
|
71
|
+
client_id=client_id,
|
|
72
|
+
topic_builder=tb,
|
|
73
|
+
reconnect_interval=reconnect_interval,
|
|
74
|
+
)
|
|
75
|
+
await client.connect()
|
|
76
|
+
|
|
77
|
+
payload_obj = json.loads(json_value) if json_value is not None else value
|
|
78
|
+
packet = UnsPacket.data(value=payload_obj, uom=uom)
|
|
79
|
+
await client.publish_packet(topic, packet, qos=qos, retain=retain)
|
|
80
|
+
await client.close()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@cli.command("subscribe", help="Subscribe to one or more topics (resilient).")
|
|
84
|
+
@common_options
|
|
85
|
+
@click.option("--topic", "topic_filter", required=True, help="Topic filter (e.g. uns-infra/#)")
|
|
86
|
+
def subscribe_cmd(**opts):
|
|
87
|
+
asyncio.run(_run_subscribe(**opts))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _run_subscribe(
|
|
91
|
+
host: str,
|
|
92
|
+
port: Optional[int],
|
|
93
|
+
username: Optional[str],
|
|
94
|
+
password: Optional[str],
|
|
95
|
+
tls: bool,
|
|
96
|
+
client_id: Optional[str],
|
|
97
|
+
package_name: str,
|
|
98
|
+
package_version: str,
|
|
99
|
+
process_name: str,
|
|
100
|
+
reconnect_interval: float,
|
|
101
|
+
topic_filter: str,
|
|
102
|
+
):
|
|
103
|
+
tb = TopicBuilder(package_name, package_version, process_name)
|
|
104
|
+
client = UnsMqttClient(
|
|
105
|
+
host=host.split(":")[0],
|
|
106
|
+
port=int(host.split(":")[1]) if ":" in host and port is None else port,
|
|
107
|
+
username=username,
|
|
108
|
+
password=password,
|
|
109
|
+
tls=tls,
|
|
110
|
+
client_id=client_id,
|
|
111
|
+
topic_builder=tb,
|
|
112
|
+
reconnect_interval=reconnect_interval,
|
|
113
|
+
)
|
|
114
|
+
await client.connect()
|
|
115
|
+
|
|
116
|
+
async for msg in client.resilient_messages(topic_filter):
|
|
117
|
+
try:
|
|
118
|
+
print(f"{msg.topic} {msg.payload.decode()}")
|
|
119
|
+
except Exception:
|
|
120
|
+
print(f"{msg.topic} <binary {len(msg.payload)} bytes>")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@cli.command("write-config", help="Write a minimal config.json scaffold.")
|
|
124
|
+
@click.option("--path", default="config.json", show_default=True)
|
|
125
|
+
def write_config(path: str):
|
|
126
|
+
data = {
|
|
127
|
+
"host": "localhost",
|
|
128
|
+
"port": 1883,
|
|
129
|
+
"username": "",
|
|
130
|
+
"password": "",
|
|
131
|
+
"tls": False,
|
|
132
|
+
"clientId": "uns-py",
|
|
133
|
+
"packageName": "uns-kit",
|
|
134
|
+
"packageVersion": "0.0.1",
|
|
135
|
+
"processName": "uns-process",
|
|
136
|
+
}
|
|
137
|
+
Path(path).write_text(json.dumps(data, indent=2))
|
|
138
|
+
click.echo(f"Wrote {path}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main():
|
|
142
|
+
cli()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
main()
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from typing import AsyncIterator, List, Optional
|
|
7
|
+
|
|
8
|
+
from asyncio_mqtt import Client, MqttError, Message
|
|
9
|
+
|
|
10
|
+
from .packet import UnsPacket
|
|
11
|
+
from .topic_builder import TopicBuilder
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UnsMqttClient:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
host: str,
|
|
18
|
+
*,
|
|
19
|
+
topic_builder: TopicBuilder,
|
|
20
|
+
port: Optional[int] = None,
|
|
21
|
+
username: Optional[str] = None,
|
|
22
|
+
password: Optional[str] = None,
|
|
23
|
+
client_id: Optional[str] = None,
|
|
24
|
+
tls: bool = False,
|
|
25
|
+
keepalive: int = 60,
|
|
26
|
+
clean_session: bool = True,
|
|
27
|
+
reconnect_interval: float = 2.0,
|
|
28
|
+
max_reconnect_interval: float = 30.0,
|
|
29
|
+
):
|
|
30
|
+
self.host = host
|
|
31
|
+
self.port = port
|
|
32
|
+
self.username = username
|
|
33
|
+
self.password = password
|
|
34
|
+
self.client_id = client_id
|
|
35
|
+
self.tls = tls
|
|
36
|
+
self.keepalive = keepalive
|
|
37
|
+
self.clean_session = clean_session
|
|
38
|
+
self.reconnect_interval = reconnect_interval
|
|
39
|
+
self.max_reconnect_interval = max_reconnect_interval
|
|
40
|
+
self.topic_builder = topic_builder
|
|
41
|
+
self._client: Optional[Client] = None
|
|
42
|
+
self._status_task: Optional[asyncio.Task] = None
|
|
43
|
+
self._connected = asyncio.Event()
|
|
44
|
+
self._closing = False
|
|
45
|
+
self._connect_lock = asyncio.Lock()
|
|
46
|
+
|
|
47
|
+
async def _connect_once(self) -> None:
|
|
48
|
+
will = {
|
|
49
|
+
"topic": f"{self.topic_builder.process_status_topic}alive",
|
|
50
|
+
"payload": b"",
|
|
51
|
+
"qos": 0,
|
|
52
|
+
"retain": True,
|
|
53
|
+
}
|
|
54
|
+
client = Client(
|
|
55
|
+
hostname=self.host,
|
|
56
|
+
port=self.port,
|
|
57
|
+
username=self.username,
|
|
58
|
+
password=self.password,
|
|
59
|
+
client_id=self.client_id,
|
|
60
|
+
keepalive=self.keepalive,
|
|
61
|
+
clean_session=self.clean_session,
|
|
62
|
+
tls=self.tls,
|
|
63
|
+
will=will,
|
|
64
|
+
)
|
|
65
|
+
await client.connect()
|
|
66
|
+
self._client = client
|
|
67
|
+
self._connected.set()
|
|
68
|
+
|
|
69
|
+
async def _ensure_connected(self) -> None:
|
|
70
|
+
if self._connected.is_set():
|
|
71
|
+
return
|
|
72
|
+
async with self._connect_lock:
|
|
73
|
+
if self._connected.is_set():
|
|
74
|
+
return
|
|
75
|
+
backoff = self.reconnect_interval
|
|
76
|
+
while not self._closing:
|
|
77
|
+
try:
|
|
78
|
+
await self._connect_once()
|
|
79
|
+
if not self._status_task or self._status_task.done():
|
|
80
|
+
self._status_task = asyncio.create_task(self._publish_status_loop())
|
|
81
|
+
return
|
|
82
|
+
except MqttError:
|
|
83
|
+
await asyncio.sleep(backoff)
|
|
84
|
+
backoff = min(backoff * 2, self.max_reconnect_interval)
|
|
85
|
+
|
|
86
|
+
async def connect(self) -> None:
|
|
87
|
+
await self._ensure_connected()
|
|
88
|
+
|
|
89
|
+
async def close(self) -> None:
|
|
90
|
+
self._closing = True
|
|
91
|
+
if self._status_task:
|
|
92
|
+
self._status_task.cancel()
|
|
93
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
94
|
+
await self._status_task
|
|
95
|
+
if self._client:
|
|
96
|
+
with contextlib.suppress(Exception):
|
|
97
|
+
await self._client.disconnect()
|
|
98
|
+
self._connected.clear()
|
|
99
|
+
|
|
100
|
+
async def publish_raw(self, topic: str, payload: str | bytes, *, qos: int = 0, retain: bool = False) -> None:
|
|
101
|
+
await self._ensure_connected()
|
|
102
|
+
for attempt in range(2):
|
|
103
|
+
try:
|
|
104
|
+
assert self._client
|
|
105
|
+
await self._client.publish(topic, payload, qos=qos, retain=retain)
|
|
106
|
+
return
|
|
107
|
+
except MqttError:
|
|
108
|
+
self._connected.clear()
|
|
109
|
+
if attempt == 0:
|
|
110
|
+
await self._ensure_connected()
|
|
111
|
+
else:
|
|
112
|
+
raise
|
|
113
|
+
|
|
114
|
+
async def publish_packet(self, topic: str, packet: dict, *, qos: int = 0, retain: bool = False) -> None:
|
|
115
|
+
payload = UnsPacket.to_json(packet)
|
|
116
|
+
await self.publish_raw(topic, payload, qos=qos, retain=retain)
|
|
117
|
+
|
|
118
|
+
@asynccontextmanager
|
|
119
|
+
async def messages(self, topics: str | List[str]) -> AsyncIterator[AsyncIterator[Message]]:
|
|
120
|
+
await self._ensure_connected()
|
|
121
|
+
assert self._client
|
|
122
|
+
manager = self._client.filtered_messages(topics) if isinstance(topics, str) else self._client.unfiltered_messages()
|
|
123
|
+
async with manager as messages:
|
|
124
|
+
if isinstance(topics, str):
|
|
125
|
+
await self._client.subscribe(topics)
|
|
126
|
+
else:
|
|
127
|
+
for t in topics:
|
|
128
|
+
await self._client.subscribe(t)
|
|
129
|
+
yield messages
|
|
130
|
+
|
|
131
|
+
async def resilient_messages(self, topics: str | List[str]) -> AsyncIterator[Message]:
|
|
132
|
+
"""
|
|
133
|
+
Async generator that keeps the subscription alive across disconnects.
|
|
134
|
+
"""
|
|
135
|
+
while not self._closing:
|
|
136
|
+
await self._ensure_connected()
|
|
137
|
+
try:
|
|
138
|
+
async with self.messages(topics) as msgs:
|
|
139
|
+
async for msg in msgs:
|
|
140
|
+
yield msg
|
|
141
|
+
except MqttError:
|
|
142
|
+
self._connected.clear()
|
|
143
|
+
await asyncio.sleep(self.reconnect_interval)
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
async def _publish_status_loop(self) -> None:
|
|
147
|
+
uptime_topic = f"{self.topic_builder.process_status_topic}uptime"
|
|
148
|
+
alive_topic = f"{self.topic_builder.process_status_topic}alive"
|
|
149
|
+
start = asyncio.get_event_loop().time()
|
|
150
|
+
try:
|
|
151
|
+
while not self._closing:
|
|
152
|
+
now = asyncio.get_event_loop().time()
|
|
153
|
+
uptime_minutes = int((now - start) / 60)
|
|
154
|
+
await self.publish_raw(alive_topic, b"", qos=0, retain=True)
|
|
155
|
+
await self.publish_raw(uptime_topic, str(uptime_minutes).encode(), qos=0, retain=True)
|
|
156
|
+
await asyncio.sleep(10)
|
|
157
|
+
except asyncio.CancelledError:
|
|
158
|
+
pass
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class UnsConfig:
|
|
13
|
+
host: str
|
|
14
|
+
port: Optional[int] = None
|
|
15
|
+
username: Optional[str] = None
|
|
16
|
+
password: Optional[str] = None
|
|
17
|
+
client_id: Optional[str] = None
|
|
18
|
+
tls: bool = False
|
|
19
|
+
keepalive: int = 60
|
|
20
|
+
clean_session: bool = True
|
|
21
|
+
mqtt_sub_to_topics: Optional[List[str]] = None
|
|
22
|
+
package_name: str = "uns-kit"
|
|
23
|
+
package_version: str = "0.0.1"
|
|
24
|
+
process_name: str = "uns-process"
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def load(path: Path) -> "UnsConfig":
|
|
28
|
+
data = json.loads(path.read_text())
|
|
29
|
+
return UnsConfig(**data)
|
|
30
|
+
|
|
31
|
+
def topic_builder(self) -> TopicBuilder:
|
|
32
|
+
return TopicBuilder(self.package_name, self.package_version, self.process_name)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, asdict
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def isoformat(dt: datetime) -> str:
|
|
10
|
+
return dt.astimezone(timezone.utc).isoformat()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class DataPayload:
|
|
15
|
+
value: Any
|
|
16
|
+
uom: Optional[str] = None
|
|
17
|
+
time: Optional[str] = None
|
|
18
|
+
dataGroup: Optional[str] = None
|
|
19
|
+
createdAt: Optional[str] = None
|
|
20
|
+
expiresAt: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class TablePayload:
|
|
25
|
+
table: Any
|
|
26
|
+
dataGroup: Optional[str] = None
|
|
27
|
+
createdAt: Optional[str] = None
|
|
28
|
+
expiresAt: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnsPacket:
|
|
32
|
+
version: int = 1
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def data(
|
|
36
|
+
value: Any,
|
|
37
|
+
uom: Optional[str] = None,
|
|
38
|
+
time: Optional[datetime] = None,
|
|
39
|
+
data_group: Optional[str] = None,
|
|
40
|
+
) -> Dict[str, Any]:
|
|
41
|
+
payload = DataPayload(
|
|
42
|
+
value=value,
|
|
43
|
+
uom=uom,
|
|
44
|
+
time=isoformat(time or datetime.utcnow()),
|
|
45
|
+
dataGroup=data_group,
|
|
46
|
+
)
|
|
47
|
+
return {"version": UnsPacket.version, "message": {"data": asdict(payload)}}
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def table(table: Any, data_group: Optional[str] = None) -> Dict[str, Any]:
|
|
51
|
+
payload = TablePayload(table=table, dataGroup=data_group)
|
|
52
|
+
return {"version": UnsPacket.version, "message": {"table": asdict(payload)}}
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def to_json(packet: Dict[str, Any]) -> str:
|
|
56
|
+
return json.dumps(packet, separators=(",", ":"))
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def parse(packet_str: str) -> Optional[Dict[str, Any]]:
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(packet_str)
|
|
62
|
+
except Exception:
|
|
63
|
+
return None
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TopicBuilder:
|
|
7
|
+
"""
|
|
8
|
+
Mirrors the TypeScript MqttTopicBuilder for infra topics.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, package_name: str, package_version: str, process_name: str):
|
|
12
|
+
self.package_name = self.sanitize_topic_part(package_name)
|
|
13
|
+
self.package_version = self.sanitize_topic_part(package_version)
|
|
14
|
+
self.process_name = self.sanitize_topic_part(process_name)
|
|
15
|
+
self._base = f"uns-infra/{self.package_name}/{self.package_version}/{self.process_name}/"
|
|
16
|
+
if not re.match(r"^uns-infra/[^/]+/[^/]+/[^/]+/$", self._base):
|
|
17
|
+
raise ValueError("processStatusTopic must follow 'uns-infra/<package>/<version>/<process>/'")
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def sanitize_topic_part(name: str) -> str:
|
|
21
|
+
sanitized = re.sub(r"[^a-zA-Z0-9_-]+", "-", name)
|
|
22
|
+
sanitized = re.sub(r"-{2,}", "-", sanitized)
|
|
23
|
+
sanitized = sanitized.strip("-")
|
|
24
|
+
return sanitized or "uns-process"
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def process_status_topic(self) -> str:
|
|
28
|
+
return self._base
|
|
29
|
+
|
|
30
|
+
def active_topic(self) -> str:
|
|
31
|
+
return f"{self._base}active"
|
|
32
|
+
|
|
33
|
+
def handover_topic(self) -> str:
|
|
34
|
+
return f"{self._base}handover"
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def extract_base_topic(full_topic: str) -> str:
|
|
38
|
+
parts = full_topic.split("/")
|
|
39
|
+
if len(parts) < 4:
|
|
40
|
+
raise ValueError("Invalid topic format. Expected 'uns-infra/<package>/<version>/<process>/'.")
|
|
41
|
+
return "/".join(parts[:4]) + "/"
|