uns-kit 0.0.2__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 ADDED
@@ -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
+ ]
uns_kit/cli.py ADDED
@@ -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()
uns_kit/client.py ADDED
@@ -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
uns_kit/config.py ADDED
@@ -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)
uns_kit/packet.py ADDED
@@ -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]) + "/"
@@ -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
+
@@ -0,0 +1,10 @@
1
+ uns_kit/__init__.py,sha256=-4R72u-uzg2ib2Z1SFQiSX5KSNYY6oMqVvGoVPI3eQs,290
2
+ uns_kit/cli.py,sha256=BVqLPNegmbEasBr_k2Qt4S_A3fQuP8tn5d5Z_bO5t5w,4800
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/topic_builder.py,sha256=JGYdYynRWWSwzXaCaRLjap0-du13eSc6qe1V0GrD_ak,1492
7
+ uns_kit-0.0.2.dist-info/METADATA,sha256=ZrSVdIbe--Lv_HKNHiIpjMRGCkyRlb1tFe9g9eejWHU,2545
8
+ uns_kit-0.0.2.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
9
+ uns_kit-0.0.2.dist-info/entry_points.txt,sha256=KJlvQiJW_UWZQT0V4LH9BbXjnYaXunjhlWoobOtlthw,44
10
+ uns_kit-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.3.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ uns-kit=uns_kit.cli:main
3
+