uns-kit 0.0.4__py3-none-any.whl → 0.0.5__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.
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ from enum import Enum
6
+ from typing import Any, Dict, Optional
7
+
8
+ from .client import UnsMqttClient
9
+ from .packet import UnsPacket, isoformat
10
+ from .proxy import UnsProxy
11
+ from .topic_builder import TopicBuilder
12
+
13
+
14
+ class MessageMode(str, Enum):
15
+ RAW = "raw"
16
+ DELTA = "delta"
17
+ BOTH = "both"
18
+
19
+
20
+ @dataclass
21
+ class LastValueEntry:
22
+ value: Any
23
+ uom: Optional[str]
24
+ timestamp: datetime
25
+
26
+
27
+ class UnsMqttProxy(UnsProxy):
28
+ def __init__(
29
+ self,
30
+ host: str,
31
+ *,
32
+ process_name: str,
33
+ instance_name: str,
34
+ package_name: str = "uns-kit",
35
+ package_version: str = "0.0.1",
36
+ port: Optional[int] = None,
37
+ username: Optional[str] = None,
38
+ password: Optional[str] = None,
39
+ tls: bool = False,
40
+ client_id: Optional[str] = None,
41
+ keepalive: int = 60,
42
+ clean_session: bool = True,
43
+ ) -> None:
44
+ self.topic_builder = TopicBuilder(package_name, package_version, process_name)
45
+ self.instance_status_topic = self.topic_builder.instance_status_topic(instance_name)
46
+ self.client = UnsMqttClient(
47
+ host,
48
+ port=port,
49
+ username=username,
50
+ password=password,
51
+ tls=tls,
52
+ client_id=client_id,
53
+ keepalive=keepalive,
54
+ clean_session=clean_session,
55
+ topic_builder=self.topic_builder,
56
+ instance_name=instance_name,
57
+ publisher_active=True,
58
+ subscriber_active=True,
59
+ )
60
+ super().__init__(self.client, self.instance_status_topic, instance_name)
61
+ self._last_values: Dict[str, LastValueEntry] = {}
62
+ self._sequence_ids: Dict[str, int] = {}
63
+
64
+ async def connect(self) -> None:
65
+ await self.client.connect()
66
+ await self.start()
67
+
68
+ async def close(self) -> None:
69
+ await self.stop()
70
+ await self.client.close()
71
+
72
+ async def publish_message(self, topic: str, payload: str | bytes) -> None:
73
+ await self.client.publish_raw(topic, payload)
74
+
75
+ async def publish_packet(self, topic: str, packet: Dict[str, Any]) -> None:
76
+ await self.client.publish_packet(topic, packet)
77
+
78
+ async def publish_mqtt_message(self, mqtt_message: Dict[str, Any], mode: MessageMode = MessageMode.RAW) -> None:
79
+ attrs = mqtt_message.get("attributes")
80
+ if attrs is None:
81
+ raise ValueError("mqtt_message must include attributes")
82
+ if not isinstance(attrs, list):
83
+ attrs = [attrs]
84
+
85
+ base_topic = mqtt_message.get("topic", "")
86
+ asset = mqtt_message.get("asset")
87
+ asset_description = mqtt_message.get("assetDescription")
88
+ object_type = mqtt_message.get("objectType")
89
+ object_type_description = mqtt_message.get("objectTypeDescription")
90
+ object_id = mqtt_message.get("objectId")
91
+
92
+ for attr in attrs:
93
+ attribute = attr.get("attribute")
94
+ if attribute is None:
95
+ raise ValueError("attribute is required")
96
+ description = attr.get("description")
97
+ tags = attr.get("tags")
98
+ attribute_needs_persistence = attr.get("attributeNeedsPersistence")
99
+
100
+ message = attr.get("message")
101
+ if message is None:
102
+ if "data" in attr:
103
+ message = {
104
+ "data": attr["data"],
105
+ **({"createdAt": attr["createdAt"]} if attr.get("createdAt") else {}),
106
+ **({"expiresAt": attr["expiresAt"]} if attr.get("expiresAt") else {}),
107
+ }
108
+ elif "table" in attr:
109
+ message = {
110
+ "table": attr["table"],
111
+ **({"createdAt": attr["createdAt"]} if attr.get("createdAt") else {}),
112
+ **({"expiresAt": attr["expiresAt"]} if attr.get("expiresAt") else {}),
113
+ }
114
+ else:
115
+ raise ValueError("Attribute entry must include exactly one of data/table/message")
116
+
117
+ packet = UnsPacket.from_message(message)
118
+
119
+ msg = {
120
+ "topic": base_topic,
121
+ "asset": asset,
122
+ "assetDescription": asset_description,
123
+ "objectType": object_type,
124
+ "objectTypeDescription": object_type_description,
125
+ "objectId": object_id,
126
+ "attribute": attribute,
127
+ "description": description or attribute,
128
+ "tags": tags,
129
+ "attributeNeedsPersistence": attribute_needs_persistence,
130
+ "packet": packet,
131
+ }
132
+
133
+ if mode == MessageMode.RAW:
134
+ await self._process_and_publish(msg, value_is_cumulative=False)
135
+ elif mode == MessageMode.DELTA:
136
+ delta_msg = dict(msg)
137
+ delta_msg["attribute"] = f"{attribute}-delta"
138
+ delta_msg["description"] = f"{msg['description']} (delta)"
139
+ await self._process_and_publish(delta_msg, value_is_cumulative=True)
140
+ elif mode == MessageMode.BOTH:
141
+ await self._process_and_publish(msg, value_is_cumulative=False)
142
+ delta_msg = dict(msg)
143
+ delta_msg["attribute"] = f"{attribute}-delta"
144
+ delta_msg["description"] = f"{msg['description']} (delta)"
145
+ await self._process_and_publish(delta_msg, value_is_cumulative=True)
146
+
147
+ def _resolve_object_identity(self, msg: Dict[str, Any]) -> None:
148
+ topic = msg.get("topic", "")
149
+ provided_type = msg.get("objectType")
150
+ provided_id = msg.get("objectId")
151
+ provided_asset = msg.get("asset")
152
+
153
+ parts = [p for p in topic.split("/") if p]
154
+ parsed_type = parts[-2] if len(parts) >= 2 else None
155
+ parsed_id = parts[-1] if len(parts) >= 1 else None
156
+ parsed_asset = parts[-3] if len(parts) >= 3 else None
157
+
158
+ msg["objectType"] = provided_type or parsed_type
159
+ msg["objectId"] = provided_id or parsed_id or "main"
160
+ msg["asset"] = provided_asset or parsed_asset
161
+
162
+ def _normalize_topic(self, topic: str) -> str:
163
+ return topic if topic.endswith("/") else f"{topic}/"
164
+
165
+ async def _process_and_publish(self, msg: Dict[str, Any], *, value_is_cumulative: bool) -> None:
166
+ self._resolve_object_identity(msg)
167
+ base_topic = self._normalize_topic(msg.get("topic", ""))
168
+
169
+ packet = msg["packet"]
170
+ message = packet.get("message", {})
171
+ data = message.get("data")
172
+ table = message.get("table")
173
+
174
+ attribute_type = "Data" if data is not None else "Table" if table is not None else None
175
+ data_group = ""
176
+ if isinstance(data, dict):
177
+ data_group = data.get("dataGroup") or ""
178
+ if isinstance(table, dict):
179
+ data_group = table.get("dataGroup") or ""
180
+
181
+ await self.register_unique_topic(
182
+ {
183
+ "timestamp": isoformat(datetime.now(timezone.utc)),
184
+ "topic": base_topic,
185
+ "asset": msg.get("asset"),
186
+ "assetDescription": msg.get("assetDescription"),
187
+ "objectType": msg.get("objectType"),
188
+ "objectTypeDescription": msg.get("objectTypeDescription"),
189
+ "objectId": msg.get("objectId"),
190
+ "attribute": msg.get("attribute"),
191
+ "attributeType": attribute_type,
192
+ "description": msg.get("description"),
193
+ "tags": msg.get("tags"),
194
+ "attributeNeedsPersistence": msg.get("attributeNeedsPersistence"),
195
+ "dataGroup": data_group,
196
+ }
197
+ )
198
+
199
+ publish_topic = (
200
+ f"{base_topic}"
201
+ f"{msg.get('asset') + '/' if msg.get('asset') else ''}"
202
+ f"{msg.get('objectType') + '/' if msg.get('objectType') else ''}"
203
+ f"{msg.get('objectId') + '/' if msg.get('objectId') else ''}"
204
+ f"{msg.get('attribute')}"
205
+ )
206
+
207
+ seq_id = self._sequence_ids.get(base_topic, 0)
208
+ self._sequence_ids[base_topic] = seq_id + 1
209
+ packet["sequenceId"] = seq_id
210
+
211
+ if isinstance(data, dict):
212
+ time_value = data.get("time")
213
+ if not time_value:
214
+ time_value = UnsPacket.data(value=0)["message"]["data"]["time"]
215
+ data["time"] = time_value
216
+ current_time = datetime.fromisoformat(time_value.replace("Z", "+00:00"))
217
+ new_value = data.get("value")
218
+ new_uom = data.get("uom")
219
+ last = self._last_values.get(publish_topic)
220
+ if last:
221
+ interval_ms = int((current_time - last.timestamp).total_seconds() * 1000)
222
+ packet["interval"] = interval_ms
223
+ if value_is_cumulative and isinstance(new_value, (int, float)) and isinstance(last.value, (int, float)):
224
+ delta = new_value - last.value
225
+ data["value"] = delta
226
+ data["time"] = isoformat(current_time)
227
+ self._last_values[publish_topic] = LastValueEntry(new_value, new_uom, current_time)
228
+ await self.client.publish_raw(publish_topic, UnsPacket.to_json(packet))
229
+ else:
230
+ self._last_values[publish_topic] = LastValueEntry(new_value, new_uom, current_time)
231
+ # For delta mode with no previous value, skip to avoid bogus delta; otherwise publish.
232
+ if not value_is_cumulative:
233
+ await self.client.publish_raw(publish_topic, UnsPacket.to_json(packet))
234
+ elif isinstance(table, dict):
235
+ await self.client.publish_raw(publish_topic, UnsPacket.to_json(packet))
236
+ else:
237
+ raise ValueError("packet.message must include data or table")
uns_kit/version.py ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ try: # pragma: no cover - best-effort metadata fetch
4
+ from importlib.metadata import version
5
+
6
+ __version__ = version("uns-kit")
7
+ except Exception: # fallback for editable/local
8
+ __version__ = "0.0.0"
9
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uns-kit
3
- Version: 0.0.4
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,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; will message is retained on `<statusTopic>alive`.
83
- - Uptime is published every 10 seconds on `<statusTopic>uptime`.
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
 
@@ -0,0 +1,19 @@
1
+ uns_kit/__init__.py,sha256=oLhYBOJkoYXBJGN7VDtJpMXAb1v5qFVphun5QgFwthw,564
2
+ uns_kit/cli.py,sha256=USq7bHl6sL75s--Y_nV2j7v1JScZWehZp92GyqYgjyw,5552
3
+ uns_kit/client.py,sha256=GjlGOTPuaxeLlgkfcJWpxMId6nLeZNsZTNBfOYAe8YY,14892
4
+ uns_kit/config.py,sha256=K9FIvFB_zk59I_9XW5PA7sDRJOMl-5-3VCHbpyLgxyM,2899
5
+ uns_kit/packet.py,sha256=M-JJ0--FVe-iFPGnBUFynx_0iKB9F3NFwvETJ7yKibc,5874
6
+ uns_kit/proxy.py,sha256=H1Afs71rdb7DTHYp_8qeoM8uN5vOvAzYK50u9JszXQQ,2338
7
+ uns_kit/proxy_process.py,sha256=If8HXd_YAf1xYjzy8BA73Jo0DRh0-3WpHHIoVcIbS-Q,3906
8
+ uns_kit/status_monitor.py,sha256=8XXBaUHh8SW7gX4d3EtChnEkgQ7rnQ14tEHb0jDB25I,3022
9
+ uns_kit/templates/default/README.md,sha256=V8HXk3MxL9Aq21lm-zt8WOK7n3pDBI4CMrvtRYOPPK4,1213
10
+ uns_kit/templates/default/pyproject.toml,sha256=JT2gYvmofwBuS7FI7U0i_5U3TzyiXrwU6w-bz-cTvKU,540
11
+ uns_kit/templates/default/src/data_example.py,sha256=8D1CyGVsbeKzZV79l8zaX-hqz_gxrA_ps2TA-GwzLQI,4381
12
+ uns_kit/templates/default/src/load_test.py,sha256=cmQ3FzakBisLJU5TTNVpHpfdnIoA32c2pN7dnEwlfgo,3359
13
+ uns_kit/topic_builder.py,sha256=UYC2SS9ptHopeyQ3ud1HEg1IHr5RJ7X2DEkfZC1CM5Y,1938
14
+ uns_kit/uns_mqtt_proxy.py,sha256=QzG0E42r7n33Z4Ri70-DxTlVoM-wj0_wAtnbMyaet08,9831
15
+ uns_kit/version.py,sha256=vmk-z_o25XOVgX5lSUrECllqau8NL7B_pkKsSmwhmX0,247
16
+ uns_kit-0.0.5.dist-info/METADATA,sha256=VZ2JjJ_7twYxRtrSEURUXfPKsdSiqhGKuVIH08Zrg-M,3306
17
+ uns_kit-0.0.5.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
18
+ uns_kit-0.0.5.dist-info/entry_points.txt,sha256=sLvTioiJQfGczUD-ODVx2xwwtHcGKjlIOn8t_Lt87Pg,47
19
+ uns_kit-0.0.5.dist-info/RECORD,,
@@ -1,27 +0,0 @@
1
- import asyncio
2
- import json
3
- from pathlib import Path
4
- from uns_kit import UnsMqttClient, TopicBuilder, UnsPacket
5
-
6
-
7
- async def main():
8
- cfg = json.loads(Path("config.json").read_text())
9
- tb = TopicBuilder(cfg["packageName"], cfg["packageVersion"], cfg["processName"])
10
- client = UnsMqttClient(
11
- cfg["host"],
12
- port=cfg.get("port"),
13
- username=cfg.get("username") or None,
14
- password=cfg.get("password") or None,
15
- tls=cfg.get("tls", False),
16
- client_id=cfg.get("clientId"),
17
- topic_builder=tb,
18
- reconnect_interval=1,
19
- )
20
- await client.connect()
21
- await client.publish_packet("raw/data/", UnsPacket.data(value=1, uom="count"))
22
- async for msg in client.resilient_messages("uns-infra/#"):
23
- print(msg.topic, msg.payload.decode())
24
-
25
-
26
- if __name__ == "__main__":
27
- asyncio.run(main())
@@ -1,13 +0,0 @@
1
- uns_kit/__init__.py,sha256=-4R72u-uzg2ib2Z1SFQiSX5KSNYY6oMqVvGoVPI3eQs,290
2
- uns_kit/cli.py,sha256=USq7bHl6sL75s--Y_nV2j7v1JScZWehZp92GyqYgjyw,5552
3
- uns_kit/client.py,sha256=bFXAKmuR06mSlO8tsAoeB7jv9RQgw1a_AIbXQNpNFTw,5815
4
- uns_kit/config.py,sha256=mRMiuxGV6CEIBDVZUJQqYIeGxKzfBhlXCcGNXnVtBj4,869
5
- uns_kit/packet.py,sha256=qd6BrCv_JE8fxh-iaJBAVhUZvY28lhiDr9F1At8CXjw,1675
6
- uns_kit/templates/default/README.md,sha256=IQ1zvCSzigE3foKBLkDv9UZnZEScuS6V19oCSCJPvXw,149
7
- uns_kit/templates/default/pyproject.toml,sha256=dxSghe6RiljsXfz4Gt_YXB1g-P_W2jiYUJk6ug4TytE,362
8
- uns_kit/templates/default/src/main.py,sha256=0mxq9Oa00UwqlEiJIBnlDiulBPECJ4YSojqjv6tonTk,850
9
- uns_kit/topic_builder.py,sha256=JGYdYynRWWSwzXaCaRLjap0-du13eSc6qe1V0GrD_ak,1492
10
- uns_kit-0.0.4.dist-info/METADATA,sha256=CjegZQzS1ZMvRyMEXB7p21RaejS7cidggXYvKgZ_QMs,2736
11
- uns_kit-0.0.4.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
12
- uns_kit-0.0.4.dist-info/entry_points.txt,sha256=sLvTioiJQfGczUD-ODVx2xwwtHcGKjlIOn8t_Lt87Pg,47
13
- uns_kit-0.0.4.dist-info/RECORD,,