uns-kit 0.0.4__py3-none-any.whl → 0.0.6__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.
- uns_kit/__init__.py +9 -0
- uns_kit/cli.py +16 -9
- uns_kit/client.py +230 -39
- uns_kit/config.py +66 -8
- uns_kit/packet.py +130 -18
- uns_kit/proxy.py +64 -0
- uns_kit/proxy_process.py +102 -0
- uns_kit/status_monitor.py +88 -0
- uns_kit/templates/default/README.md +30 -1
- uns_kit/templates/default/pyproject.toml +7 -0
- uns_kit/templates/default/src/data_example.py +110 -0
- uns_kit/templates/default/src/load_test.py +101 -0
- uns_kit/topic_builder.py +10 -0
- uns_kit/uns_mqtt_proxy.py +237 -0
- uns_kit/version.py +9 -0
- {uns_kit-0.0.4.dist-info → uns_kit-0.0.6.dist-info}/METADATA +30 -13
- uns_kit-0.0.6.dist-info/RECORD +19 -0
- uns_kit/templates/default/src/main.py +0 -27
- uns_kit-0.0.4.dist-info/RECORD +0 -13
- {uns_kit-0.0.4.dist-info → uns_kit-0.0.6.dist-info}/WHEEL +0 -0
- {uns_kit-0.0.4.dist-info → uns_kit-0.0.6.dist-info}/entry_points.txt +0 -0
uns_kit/__init__.py
CHANGED
|
@@ -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
|
]
|
uns_kit/cli.py
CHANGED
|
@@ -143,15 +143,22 @@ def create(dest: str):
|
|
|
143
143
|
@click.option("--path", default="config.json", show_default=True)
|
|
144
144
|
def write_config(path: str):
|
|
145
145
|
data = {
|
|
146
|
-
"
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
146
|
+
"infra": {
|
|
147
|
+
"host": "localhost",
|
|
148
|
+
"port": 1883,
|
|
149
|
+
"username": "",
|
|
150
|
+
"password": "",
|
|
151
|
+
"tls": False,
|
|
152
|
+
"clientId": "uns-py",
|
|
153
|
+
"mqttSubToTopics": [],
|
|
154
|
+
"keepalive": 60,
|
|
155
|
+
"clean": True
|
|
156
|
+
},
|
|
157
|
+
"uns": {
|
|
158
|
+
"packageName": "uns-kit",
|
|
159
|
+
"packageVersion": "0.0.1",
|
|
160
|
+
"processName": "uns-process"
|
|
161
|
+
}
|
|
155
162
|
}
|
|
156
163
|
Path(path).write_text(json.dumps(data, indent=2))
|
|
157
164
|
click.echo(f"Wrote {path}")
|
uns_kit/client.py
CHANGED
|
@@ -3,15 +3,22 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import contextlib
|
|
5
5
|
from contextlib import asynccontextmanager
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import uuid
|
|
8
|
+
import socket
|
|
6
9
|
from typing import AsyncIterator, List, Optional
|
|
7
10
|
|
|
8
|
-
from asyncio_mqtt import Client, MqttError, Message
|
|
11
|
+
from asyncio_mqtt import Client, MqttError, Message, Will, Topic
|
|
12
|
+
from paho.mqtt.client import MQTTv5, MQTTv311
|
|
9
13
|
|
|
10
14
|
from .packet import UnsPacket
|
|
11
15
|
from .topic_builder import TopicBuilder
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
class UnsMqttClient:
|
|
19
|
+
_exception_handler_installed = False
|
|
20
|
+
_exception_handler_loop_id: int | None = None
|
|
21
|
+
|
|
15
22
|
def __init__(
|
|
16
23
|
self,
|
|
17
24
|
host: str,
|
|
@@ -26,45 +33,135 @@ class UnsMqttClient:
|
|
|
26
33
|
clean_session: bool = True,
|
|
27
34
|
reconnect_interval: float = 2.0,
|
|
28
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,
|
|
29
41
|
):
|
|
30
42
|
self.host = host
|
|
31
43
|
self.port = port
|
|
32
|
-
self.username = username
|
|
33
|
-
self.password = password
|
|
34
|
-
|
|
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]}"
|
|
35
53
|
self.tls = tls
|
|
36
54
|
self.keepalive = keepalive
|
|
37
55
|
self.clean_session = clean_session
|
|
38
56
|
self.reconnect_interval = reconnect_interval
|
|
39
57
|
self.max_reconnect_interval = max_reconnect_interval
|
|
40
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
|
|
41
64
|
self._client: Optional[Client] = None
|
|
42
65
|
self._status_task: Optional[asyncio.Task] = None
|
|
66
|
+
self._stats_task: Optional[asyncio.Task] = None
|
|
43
67
|
self._connected = asyncio.Event()
|
|
44
68
|
self._closing = False
|
|
45
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
|
|
46
103
|
|
|
47
104
|
async def _connect_once(self) -> None:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
|
|
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,
|
|
105
|
+
await self._probe_socket()
|
|
106
|
+
will = Will(
|
|
107
|
+
topic=f"{self.status_topic}alive",
|
|
108
|
+
payload=b"",
|
|
109
|
+
qos=0,
|
|
110
|
+
retain=True,
|
|
64
111
|
)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
68
165
|
|
|
69
166
|
async def _ensure_connected(self) -> None:
|
|
70
167
|
if self._connected.is_set():
|
|
@@ -72,17 +169,31 @@ class UnsMqttClient:
|
|
|
72
169
|
async with self._connect_lock:
|
|
73
170
|
if self._connected.is_set():
|
|
74
171
|
return
|
|
172
|
+
self._install_exception_handler()
|
|
75
173
|
backoff = self.reconnect_interval
|
|
76
174
|
while not self._closing:
|
|
77
175
|
try:
|
|
78
176
|
await self._connect_once()
|
|
79
|
-
if
|
|
80
|
-
self._status_task
|
|
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)
|
|
81
184
|
return
|
|
82
185
|
except MqttError:
|
|
83
186
|
await asyncio.sleep(backoff)
|
|
84
187
|
backoff = min(backoff * 2, self.max_reconnect_interval)
|
|
85
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
|
+
|
|
86
197
|
async def connect(self) -> None:
|
|
87
198
|
await self._ensure_connected()
|
|
88
199
|
|
|
@@ -92,6 +203,10 @@ class UnsMqttClient:
|
|
|
92
203
|
self._status_task.cancel()
|
|
93
204
|
with contextlib.suppress(asyncio.CancelledError):
|
|
94
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
|
|
95
210
|
if self._client:
|
|
96
211
|
with contextlib.suppress(Exception):
|
|
97
212
|
await self._client.disconnect()
|
|
@@ -102,6 +217,9 @@ class UnsMqttClient:
|
|
|
102
217
|
for attempt in range(2):
|
|
103
218
|
try:
|
|
104
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)
|
|
105
223
|
await self._client.publish(topic, payload, qos=qos, retain=retain)
|
|
106
224
|
return
|
|
107
225
|
except MqttError:
|
|
@@ -119,14 +237,30 @@ class UnsMqttClient:
|
|
|
119
237
|
async def messages(self, topics: str | List[str]) -> AsyncIterator[AsyncIterator[Message]]:
|
|
120
238
|
await self._ensure_connected()
|
|
121
239
|
assert self._client
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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()
|
|
130
264
|
|
|
131
265
|
async def resilient_messages(self, topics: str | List[str]) -> AsyncIterator[Message]:
|
|
132
266
|
"""
|
|
@@ -142,17 +276,74 @@ class UnsMqttClient:
|
|
|
142
276
|
self._connected.clear()
|
|
143
277
|
await asyncio.sleep(self.reconnect_interval)
|
|
144
278
|
continue
|
|
279
|
+
except Exception:
|
|
280
|
+
self._connected.clear()
|
|
281
|
+
await asyncio.sleep(self.reconnect_interval)
|
|
282
|
+
continue
|
|
145
283
|
|
|
146
284
|
async def _publish_status_loop(self) -> None:
|
|
147
|
-
uptime_topic = f"{self.
|
|
148
|
-
alive_topic = f"{self.
|
|
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"
|
|
149
289
|
start = asyncio.get_event_loop().time()
|
|
150
290
|
try:
|
|
151
291
|
while not self._closing:
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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()
|
|
156
310
|
await asyncio.sleep(10)
|
|
157
311
|
except asyncio.CancelledError:
|
|
158
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
|
uns_kit/config.py
CHANGED
|
@@ -6,27 +6,85 @@ from pathlib import Path
|
|
|
6
6
|
from typing import List, Optional
|
|
7
7
|
|
|
8
8
|
from .topic_builder import TopicBuilder
|
|
9
|
+
from .version import __version__
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
@dataclass
|
|
12
13
|
class UnsConfig:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
19
21
|
keepalive: int = 60
|
|
20
22
|
clean_session: bool = True
|
|
21
23
|
mqtt_sub_to_topics: Optional[List[str]] = None
|
|
22
24
|
package_name: str = "uns-kit"
|
|
23
|
-
package_version: str =
|
|
25
|
+
package_version: str = __version__
|
|
24
26
|
process_name: str = "uns-process"
|
|
25
27
|
|
|
26
28
|
@staticmethod
|
|
27
29
|
def load(path: Path) -> "UnsConfig":
|
|
28
30
|
data = json.loads(path.read_text())
|
|
29
|
-
|
|
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
|
+
)
|
|
30
59
|
|
|
31
60
|
def topic_builder(self) -> TopicBuilder:
|
|
32
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
|