uns-kit 0.0.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uns-kit
3
- Version: 0.0.3
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
- - Infra status topics (`alive`, `uptime`) with MQTT will.
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 UnsMqttClient, TopicBuilder, UnsPacket
46
+ from uns_kit import UnsConfig, UnsPacket, UnsProxyProcess
46
47
 
47
48
  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()
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 client.publish_packet("raw/data/", UnsPacket.data(value=1, uom="count"))
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 client.close()
59
+ await mqtt.close()
60
+ await process.stop()
59
61
 
60
62
  asyncio.run(main())
61
63
  ```
@@ -69,9 +71,32 @@ 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.
75
+
76
+ ### Create a new project
77
+ ```bash
78
+ uns-kit-py create my-uns-py-app
79
+ cd my-uns-py-app
80
+ poetry install
81
+ poetry run python src/main.py
82
+ ```
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.
72
90
 
73
91
  ## 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}`.
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.
77
102
 
@@ -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"
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
  ]
@@ -2,6 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import json
5
+ import os
6
+ import shutil
7
+ import importlib.resources
5
8
  from pathlib import Path
6
9
  from typing import Optional
7
10
 
@@ -120,6 +123,22 @@ async def _run_subscribe(
120
123
  print(f"{msg.topic} <binary {len(msg.payload)} bytes>")
121
124
 
122
125
 
126
+ @cli.command("create", help="Create a new UNS Python app (default template).")
127
+ @click.argument("dest")
128
+ def create(dest: str):
129
+ template_root = importlib.resources.files("uns_kit").joinpath("templates/default")
130
+ dest_path = os.path.abspath(dest)
131
+ if os.path.exists(dest_path):
132
+ raise click.ClickException(f"Destination already exists: {dest_path}")
133
+ shutil.copytree(template_root, dest_path)
134
+ click.echo(f"Created UNS Python app at {dest_path}")
135
+ click.echo("Next steps:")
136
+ click.echo(f" 1) cd {dest_path}")
137
+ click.echo(" 2) poetry install")
138
+ click.echo(" 3) poetry run python src/main.py")
139
+ click.echo(" 4) Edit config.json with your MQTT host/credentials")
140
+
141
+
123
142
  @cli.command("write-config", help="Write a minimal config.json scaffold.")
124
143
  @click.option("--path", default="config.json", show_default=True)
125
144
  def write_config(path: str):
@@ -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