uns-kit 0.0.3__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.
- uns_kit/__init__.py +9 -0
- uns_kit/cli.py +19 -0
- 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 +39 -0
- uns_kit/templates/default/pyproject.toml +24 -0
- uns_kit/templates/default/src/data_example.py +110 -0
- uns_kit/templates/default/src/load_test.py +103 -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.3.dist-info → uns_kit-0.0.5.dist-info}/METADATA +38 -13
- uns_kit-0.0.5.dist-info/RECORD +19 -0
- uns_kit-0.0.3.dist-info/RECORD +0 -10
- {uns_kit-0.0.3.dist-info → uns_kit-0.0.5.dist-info}/WHEEL +0 -0
- {uns_kit-0.0.3.dist-info → uns_kit-0.0.5.dist-info}/entry_points.txt +0 -0
uns_kit/packet.py
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from dataclasses import dataclass
|
|
3
|
+
from dataclasses import dataclass
|
|
4
4
|
from datetime import datetime, timezone
|
|
5
5
|
from typing import Any, Dict, Optional
|
|
6
6
|
import json
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def isoformat(dt: datetime) -> str:
|
|
10
|
-
|
|
10
|
+
if dt.tzinfo is None:
|
|
11
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
12
|
+
dt = dt.astimezone(timezone.utc)
|
|
13
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
11
14
|
|
|
12
15
|
|
|
13
16
|
@dataclass
|
|
@@ -16,40 +19,138 @@ class DataPayload:
|
|
|
16
19
|
uom: Optional[str] = None
|
|
17
20
|
time: Optional[str] = None
|
|
18
21
|
dataGroup: Optional[str] = None
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
intervalStart: Optional[str | int] = None
|
|
23
|
+
intervalEnd: Optional[str | int] = None
|
|
24
|
+
windowStart: Optional[str | int] = None
|
|
25
|
+
windowEnd: Optional[str | int] = None
|
|
26
|
+
eventId: Optional[str] = None
|
|
27
|
+
deleted: Optional[bool] = None
|
|
28
|
+
deletedAt: Optional[str | int] = None
|
|
29
|
+
lastSeen: Optional[str | int] = None
|
|
30
|
+
foreignEventKey: Optional[str] = None
|
|
21
31
|
|
|
22
32
|
|
|
23
33
|
@dataclass
|
|
24
34
|
class TablePayload:
|
|
25
|
-
|
|
35
|
+
time: str
|
|
36
|
+
columns: Any
|
|
26
37
|
dataGroup: Optional[str] = None
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
intervalStart: Optional[str | int] = None
|
|
39
|
+
intervalEnd: Optional[str | int] = None
|
|
40
|
+
windowStart: Optional[str | int] = None
|
|
41
|
+
windowEnd: Optional[str | int] = None
|
|
42
|
+
eventId: Optional[str] = None
|
|
43
|
+
deleted: Optional[bool] = None
|
|
44
|
+
deletedAt: Optional[str | int] = None
|
|
45
|
+
lastSeen: Optional[str | int] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _value_type(value: Any) -> str:
|
|
49
|
+
if value is None:
|
|
50
|
+
return "null"
|
|
51
|
+
if isinstance(value, bool):
|
|
52
|
+
return "boolean"
|
|
53
|
+
if isinstance(value, (int, float)):
|
|
54
|
+
return "number"
|
|
55
|
+
if isinstance(value, str):
|
|
56
|
+
return "string"
|
|
57
|
+
return "object"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalize_payload_fields(payload: Dict[str, Any], extra: Dict[str, Any]) -> None:
|
|
61
|
+
mapping = {
|
|
62
|
+
"data_group": "dataGroup",
|
|
63
|
+
"interval_start": "intervalStart",
|
|
64
|
+
"interval_end": "intervalEnd",
|
|
65
|
+
"window_start": "windowStart",
|
|
66
|
+
"window_end": "windowEnd",
|
|
67
|
+
"event_id": "eventId",
|
|
68
|
+
"deleted_at": "deletedAt",
|
|
69
|
+
"last_seen": "lastSeen",
|
|
70
|
+
"foreign_event_key": "foreignEventKey",
|
|
71
|
+
}
|
|
72
|
+
for key, value in extra.items():
|
|
73
|
+
target = mapping.get(key, key)
|
|
74
|
+
payload[target] = value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _prune_none(payload: Dict[str, Any]) -> None:
|
|
78
|
+
for key in list(payload.keys()):
|
|
79
|
+
if payload[key] is None:
|
|
80
|
+
payload.pop(key, None)
|
|
29
81
|
|
|
30
82
|
|
|
31
83
|
class UnsPacket:
|
|
32
|
-
version:
|
|
84
|
+
version: str = "1.3.0"
|
|
33
85
|
|
|
34
86
|
@staticmethod
|
|
35
87
|
def data(
|
|
36
88
|
value: Any,
|
|
37
89
|
uom: Optional[str] = None,
|
|
38
|
-
time: Optional[datetime] = None,
|
|
90
|
+
time: Optional[datetime | str] = None,
|
|
39
91
|
data_group: Optional[str] = None,
|
|
92
|
+
created_at: Optional[datetime | str] = None,
|
|
93
|
+
expires_at: Optional[datetime | str] = None,
|
|
94
|
+
**extra: Any,
|
|
40
95
|
) -> Dict[str, Any]:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
uom=uom,
|
|
44
|
-
time=isoformat(time or datetime.utcnow()),
|
|
45
|
-
dataGroup=data_group,
|
|
96
|
+
resolved_time = (
|
|
97
|
+
time if isinstance(time, str) else isoformat(time or datetime.now(timezone.utc))
|
|
46
98
|
)
|
|
47
|
-
|
|
99
|
+
payload = {
|
|
100
|
+
"value": value,
|
|
101
|
+
"uom": uom,
|
|
102
|
+
"time": resolved_time,
|
|
103
|
+
"dataGroup": data_group,
|
|
104
|
+
"valueType": _value_type(value),
|
|
105
|
+
}
|
|
106
|
+
_normalize_payload_fields(payload, extra)
|
|
107
|
+
_prune_none(payload)
|
|
108
|
+
message: Dict[str, Any] = {"data": payload}
|
|
109
|
+
if created_at is not None:
|
|
110
|
+
message["createdAt"] = created_at if isinstance(created_at, str) else isoformat(created_at)
|
|
111
|
+
if expires_at is not None:
|
|
112
|
+
message["expiresAt"] = expires_at if isinstance(expires_at, str) else isoformat(expires_at)
|
|
113
|
+
return {"version": UnsPacket.version, "message": message}
|
|
48
114
|
|
|
49
115
|
@staticmethod
|
|
50
|
-
def table(
|
|
51
|
-
|
|
52
|
-
|
|
116
|
+
def table(
|
|
117
|
+
table: Optional[Dict[str, Any]] = None,
|
|
118
|
+
*,
|
|
119
|
+
columns: Optional[Any] = None,
|
|
120
|
+
time: Optional[datetime | str] = None,
|
|
121
|
+
data_group: Optional[str] = None,
|
|
122
|
+
created_at: Optional[datetime | str] = None,
|
|
123
|
+
expires_at: Optional[datetime | str] = None,
|
|
124
|
+
**extra: Any,
|
|
125
|
+
) -> Dict[str, Any]:
|
|
126
|
+
if table is None:
|
|
127
|
+
if columns is None:
|
|
128
|
+
raise ValueError("table() requires either table dict or columns list")
|
|
129
|
+
resolved_time = (
|
|
130
|
+
time if isinstance(time, str) else isoformat(time or datetime.now(timezone.utc))
|
|
131
|
+
)
|
|
132
|
+
payload = {
|
|
133
|
+
"time": resolved_time,
|
|
134
|
+
"columns": columns,
|
|
135
|
+
"dataGroup": data_group,
|
|
136
|
+
}
|
|
137
|
+
else:
|
|
138
|
+
payload = dict(table)
|
|
139
|
+
if "time" not in payload:
|
|
140
|
+
payload["time"] = (
|
|
141
|
+
time if isinstance(time, str) else isoformat(time or datetime.now(timezone.utc))
|
|
142
|
+
)
|
|
143
|
+
if data_group is not None and "dataGroup" not in payload:
|
|
144
|
+
payload["dataGroup"] = data_group
|
|
145
|
+
|
|
146
|
+
_normalize_payload_fields(payload, extra)
|
|
147
|
+
_prune_none(payload)
|
|
148
|
+
message: Dict[str, Any] = {"table": payload}
|
|
149
|
+
if created_at is not None:
|
|
150
|
+
message["createdAt"] = created_at if isinstance(created_at, str) else isoformat(created_at)
|
|
151
|
+
if expires_at is not None:
|
|
152
|
+
message["expiresAt"] = expires_at if isinstance(expires_at, str) else isoformat(expires_at)
|
|
153
|
+
return {"version": UnsPacket.version, "message": message}
|
|
53
154
|
|
|
54
155
|
@staticmethod
|
|
55
156
|
def to_json(packet: Dict[str, Any]) -> str:
|
|
@@ -61,3 +162,14 @@ class UnsPacket:
|
|
|
61
162
|
return json.loads(packet_str)
|
|
62
163
|
except Exception:
|
|
63
164
|
return None
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def from_message(message: Dict[str, Any]) -> Dict[str, Any]:
|
|
168
|
+
msg = dict(message)
|
|
169
|
+
data = msg.get("data")
|
|
170
|
+
if isinstance(data, dict):
|
|
171
|
+
if "valueType" not in data and "value" in data:
|
|
172
|
+
data = dict(data)
|
|
173
|
+
data["valueType"] = _value_type(data.get("value"))
|
|
174
|
+
msg["data"] = data
|
|
175
|
+
return {"version": UnsPacket.version, "message": msg}
|
uns_kit/proxy.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
from .client import UnsMqttClient
|
|
9
|
+
from .packet import isoformat
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UnsProxy:
|
|
13
|
+
"""
|
|
14
|
+
Base proxy that tracks produced topics and periodically publishes the registry.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, client: UnsMqttClient, instance_status_topic: str, instance_name: str) -> None:
|
|
18
|
+
self._client = client
|
|
19
|
+
self._instance_status_topic = instance_status_topic
|
|
20
|
+
self._instance_name = instance_name
|
|
21
|
+
self._produced_topics: Dict[str, Dict[str, Any]] = {}
|
|
22
|
+
self._publish_task: Optional[asyncio.Task] = None
|
|
23
|
+
self._running = False
|
|
24
|
+
|
|
25
|
+
async def start(self) -> None:
|
|
26
|
+
if self._running:
|
|
27
|
+
return
|
|
28
|
+
self._running = True
|
|
29
|
+
self._publish_task = asyncio.create_task(self._publish_loop())
|
|
30
|
+
|
|
31
|
+
async def stop(self) -> None:
|
|
32
|
+
self._running = False
|
|
33
|
+
if self._publish_task and not self._publish_task.done():
|
|
34
|
+
self._publish_task.cancel()
|
|
35
|
+
try:
|
|
36
|
+
await self._publish_task
|
|
37
|
+
except asyncio.CancelledError:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
async def _publish_loop(self) -> None:
|
|
41
|
+
while self._running:
|
|
42
|
+
await self._emit_produced_topics()
|
|
43
|
+
await asyncio.sleep(60)
|
|
44
|
+
|
|
45
|
+
async def _emit_produced_topics(self) -> None:
|
|
46
|
+
if not self._produced_topics:
|
|
47
|
+
return
|
|
48
|
+
payload = json.dumps(list(self._produced_topics.values()), separators=(",", ":"))
|
|
49
|
+
await self._client.publish_raw(
|
|
50
|
+
f"{self._instance_status_topic}topics",
|
|
51
|
+
payload,
|
|
52
|
+
retain=True,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async def register_unique_topic(self, topic_object: Dict[str, Any]) -> None:
|
|
56
|
+
asset = topic_object.get("asset") or ""
|
|
57
|
+
object_type = topic_object.get("objectType") or ""
|
|
58
|
+
object_id = topic_object.get("objectId") or ""
|
|
59
|
+
attribute = topic_object.get("attribute") or ""
|
|
60
|
+
full_topic = f"{topic_object.get('topic', '')}{asset}/{object_type}/{object_id}/{attribute}"
|
|
61
|
+
if full_topic not in self._produced_topics:
|
|
62
|
+
topic_object.setdefault("timestamp", isoformat(datetime.now(timezone.utc)))
|
|
63
|
+
self._produced_topics[full_topic] = topic_object
|
|
64
|
+
await self._emit_produced_topics()
|
uns_kit/proxy_process.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from .client import UnsMqttClient
|
|
8
|
+
from .config import UnsConfig
|
|
9
|
+
from .status_monitor import StatusMonitor
|
|
10
|
+
from .topic_builder import TopicBuilder
|
|
11
|
+
from .uns_mqtt_proxy import UnsMqttProxy
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UnsProxyProcess:
|
|
15
|
+
"""
|
|
16
|
+
Minimal Python equivalent of the TS UnsProxyProcess.
|
|
17
|
+
Manages process-level status publishing and creates instance proxies.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, host: str, config: UnsConfig, process_name: Optional[str] = None, activate_delay_s: float = 10.0) -> None:
|
|
21
|
+
self.config = config
|
|
22
|
+
self.process_name = process_name or config.process_name
|
|
23
|
+
self.process_id = uuid.uuid4().hex
|
|
24
|
+
self.active = False
|
|
25
|
+
self._activate_delay_s = activate_delay_s
|
|
26
|
+
self.topic_builder = TopicBuilder(config.package_name, config.package_version, self.process_name)
|
|
27
|
+
if config.client_id:
|
|
28
|
+
process_client_id = f"{config.client_id}-{self.process_id}"
|
|
29
|
+
else:
|
|
30
|
+
process_client_id = f"{self.process_name}-{self.process_id}"
|
|
31
|
+
self._client = UnsMqttClient(
|
|
32
|
+
host,
|
|
33
|
+
port=config.port,
|
|
34
|
+
username=config.username or None,
|
|
35
|
+
password=config.password or None,
|
|
36
|
+
tls=config.tls,
|
|
37
|
+
client_id=process_client_id,
|
|
38
|
+
keepalive=config.keepalive,
|
|
39
|
+
clean_session=config.clean_session,
|
|
40
|
+
topic_builder=self.topic_builder,
|
|
41
|
+
enable_status=False,
|
|
42
|
+
)
|
|
43
|
+
self._status_monitor = StatusMonitor(self._client, self.topic_builder, lambda: self.active)
|
|
44
|
+
self._proxies: List[UnsMqttProxy] = []
|
|
45
|
+
self._activate_task: Optional[asyncio.Task] = None
|
|
46
|
+
|
|
47
|
+
async def start(self) -> None:
|
|
48
|
+
await self._client.connect()
|
|
49
|
+
await self._status_monitor.start()
|
|
50
|
+
if self._activate_task is None or self._activate_task.done():
|
|
51
|
+
self._activate_task = asyncio.create_task(self._activate_after_delay())
|
|
52
|
+
|
|
53
|
+
async def stop(self) -> None:
|
|
54
|
+
if self._activate_task and not self._activate_task.done():
|
|
55
|
+
self._activate_task.cancel()
|
|
56
|
+
try:
|
|
57
|
+
await self._activate_task
|
|
58
|
+
except asyncio.CancelledError:
|
|
59
|
+
pass
|
|
60
|
+
await self._status_monitor.stop()
|
|
61
|
+
await self._client.close()
|
|
62
|
+
|
|
63
|
+
def set_active(self, active: bool) -> None:
|
|
64
|
+
self.active = active
|
|
65
|
+
|
|
66
|
+
async def _activate_after_delay(self) -> None:
|
|
67
|
+
await asyncio.sleep(self._activate_delay_s)
|
|
68
|
+
if not self.active:
|
|
69
|
+
self.active = True
|
|
70
|
+
|
|
71
|
+
async def create_mqtt_proxy(
|
|
72
|
+
self,
|
|
73
|
+
instance_name: str,
|
|
74
|
+
*,
|
|
75
|
+
host: Optional[str] = None,
|
|
76
|
+
port: Optional[int] = None,
|
|
77
|
+
username: Optional[str] = None,
|
|
78
|
+
password: Optional[str] = None,
|
|
79
|
+
tls: Optional[bool] = None,
|
|
80
|
+
client_id: Optional[str] = None,
|
|
81
|
+
) -> UnsMqttProxy:
|
|
82
|
+
if client_id:
|
|
83
|
+
resolved_client_id = client_id
|
|
84
|
+
elif self.config.client_id:
|
|
85
|
+
resolved_client_id = f"{self.config.client_id}-{instance_name}-{self.process_id}"
|
|
86
|
+
else:
|
|
87
|
+
resolved_client_id = f"{self.process_name}-{instance_name}-{self.process_id}"
|
|
88
|
+
proxy = UnsMqttProxy(
|
|
89
|
+
host or self.config.host,
|
|
90
|
+
process_name=self.process_name,
|
|
91
|
+
instance_name=instance_name,
|
|
92
|
+
package_name=self.config.package_name,
|
|
93
|
+
package_version=self.config.package_version,
|
|
94
|
+
port=port if port is not None else self.config.port,
|
|
95
|
+
username=username if username is not None else self.config.username,
|
|
96
|
+
password=password if password is not None else self.config.password,
|
|
97
|
+
tls=tls if tls is not None else self.config.tls,
|
|
98
|
+
client_id=resolved_client_id,
|
|
99
|
+
)
|
|
100
|
+
await proxy.connect()
|
|
101
|
+
self._proxies.append(proxy)
|
|
102
|
+
return proxy
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import tracemalloc
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
|
|
8
|
+
from .client import UnsMqttClient
|
|
9
|
+
from .packet import UnsPacket
|
|
10
|
+
from .topic_builder import TopicBuilder
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StatusMonitor:
|
|
14
|
+
"""
|
|
15
|
+
Periodically publishes process-level status topics:
|
|
16
|
+
- active
|
|
17
|
+
- heap-used
|
|
18
|
+
- heap-total
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
client: UnsMqttClient,
|
|
24
|
+
topic_builder: TopicBuilder,
|
|
25
|
+
active_supplier: Callable[[], bool],
|
|
26
|
+
interval_s: float = 10.0,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._client = client
|
|
29
|
+
self._topic_builder = topic_builder
|
|
30
|
+
self._active_supplier = active_supplier
|
|
31
|
+
self._interval_s = interval_s
|
|
32
|
+
self._memory_task: Optional[asyncio.Task] = None
|
|
33
|
+
self._status_task: Optional[asyncio.Task] = None
|
|
34
|
+
self._running = False
|
|
35
|
+
|
|
36
|
+
async def start(self) -> None:
|
|
37
|
+
if self._running:
|
|
38
|
+
return
|
|
39
|
+
if not tracemalloc.is_tracing():
|
|
40
|
+
tracemalloc.start()
|
|
41
|
+
self._running = True
|
|
42
|
+
self._memory_task = asyncio.create_task(self._publish_memory_loop())
|
|
43
|
+
self._status_task = asyncio.create_task(self._publish_active_loop())
|
|
44
|
+
|
|
45
|
+
async def stop(self) -> None:
|
|
46
|
+
self._running = False
|
|
47
|
+
for task in (self._memory_task, self._status_task):
|
|
48
|
+
if task and not task.done():
|
|
49
|
+
task.cancel()
|
|
50
|
+
try:
|
|
51
|
+
await task
|
|
52
|
+
except asyncio.CancelledError:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
async def _publish_memory_loop(self) -> None:
|
|
56
|
+
topic_base = self._topic_builder.process_status_topic
|
|
57
|
+
heap_used_topic = f"{topic_base}heap-used"
|
|
58
|
+
heap_total_topic = f"{topic_base}heap-total"
|
|
59
|
+
while self._running:
|
|
60
|
+
current, peak = tracemalloc.get_traced_memory()
|
|
61
|
+
time = datetime.now(timezone.utc)
|
|
62
|
+
heap_used = round(current / 1048576)
|
|
63
|
+
heap_total = round(peak / 1048576)
|
|
64
|
+
heap_used_packet = UnsPacket.data(
|
|
65
|
+
value=heap_used,
|
|
66
|
+
uom="MB",
|
|
67
|
+
time=time,
|
|
68
|
+
)
|
|
69
|
+
heap_total_packet = UnsPacket.data(
|
|
70
|
+
value=heap_total,
|
|
71
|
+
uom="MB",
|
|
72
|
+
time=time,
|
|
73
|
+
)
|
|
74
|
+
await self._client.publish_raw(heap_used_topic, UnsPacket.to_json(heap_used_packet))
|
|
75
|
+
await self._client.publish_raw(heap_total_topic, UnsPacket.to_json(heap_total_packet))
|
|
76
|
+
await asyncio.sleep(self._interval_s)
|
|
77
|
+
|
|
78
|
+
async def _publish_active_loop(self) -> None:
|
|
79
|
+
topic = self._topic_builder.active_topic()
|
|
80
|
+
while self._running:
|
|
81
|
+
time = datetime.now(timezone.utc)
|
|
82
|
+
active_packet = UnsPacket.data(
|
|
83
|
+
value=1 if self._active_supplier() else 0,
|
|
84
|
+
uom="bit",
|
|
85
|
+
time=time,
|
|
86
|
+
)
|
|
87
|
+
await self._client.publish_raw(topic, UnsPacket.to_json(active_packet))
|
|
88
|
+
await asyncio.sleep(self._interval_s)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# UNS Python App Template
|
|
2
|
+
|
|
3
|
+
## Setup
|
|
4
|
+
```bash
|
|
5
|
+
poetry install
|
|
6
|
+
poetry run python src/main.py
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Data example
|
|
10
|
+
```bash
|
|
11
|
+
poetry run python src/data_example.py
|
|
12
|
+
```
|
|
13
|
+
Requires an upstream publisher sending `raw/data` payloads like `count,timestamp,value`.
|
|
14
|
+
|
|
15
|
+
If you are developing inside the uns-kit monorepo, install the local package first:
|
|
16
|
+
```bash
|
|
17
|
+
poetry add -e ../packages/uns-py
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Alternatively, use the script entry point:
|
|
21
|
+
```bash
|
|
22
|
+
poetry run run publish --host localhost:1883 --topic raw/data/ --value 1
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Load test
|
|
26
|
+
```bash
|
|
27
|
+
poetry run python src/load_test.py
|
|
28
|
+
```
|
|
29
|
+
The script will prompt for confirmation, iterations, delay, and topic.
|
|
30
|
+
Tip: if you run multiple clients at once, avoid reusing the same MQTT clientId.
|
|
31
|
+
|
|
32
|
+
## Status topics
|
|
33
|
+
The default `main.py` uses `UnsProxyProcess` + `UnsMqttProxy`, which publish:
|
|
34
|
+
- process status (`.../active`, `.../heap-used`, `.../heap-total`) every 10s
|
|
35
|
+
- instance status (`.../<instance>/alive`, `.../<instance>/uptime`, `.../<instance>/t-publisher-active`, `.../<instance>/t-subscriber-active`) every 10s
|
|
36
|
+
- transformation stats (`.../<instance>/published-message-*`, `.../<instance>/subscribed-message-*`) every 60s
|
|
37
|
+
|
|
38
|
+
## Config
|
|
39
|
+
Edit `config.json` with your MQTT host/auth.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "uns-py-app"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Example UNS Python application"
|
|
5
|
+
authors = ["Your Name <you@example.com>"]
|
|
6
|
+
package-mode = false
|
|
7
|
+
|
|
8
|
+
[tool.poetry.dependencies]
|
|
9
|
+
python = "^3.10"
|
|
10
|
+
uns-kit = "*"
|
|
11
|
+
# Pin MQTT libs to versions compatible with asyncio-mqtt 0.16.x
|
|
12
|
+
paho-mqtt = "<2"
|
|
13
|
+
asyncio-mqtt = "0.16.2"
|
|
14
|
+
|
|
15
|
+
[tool.poetry.group.dev.dependencies]
|
|
16
|
+
pytest = "^8.3.3"
|
|
17
|
+
|
|
18
|
+
[tool.poetry.scripts]
|
|
19
|
+
dev = "uns_kit.cli:main"
|
|
20
|
+
run = "uns_kit.cli:main"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["poetry-core"]
|
|
24
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from uns_kit import UnsConfig, UnsMqttClient, UnsProxyProcess
|
|
6
|
+
from uns_kit.packet import isoformat
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def main() -> None:
|
|
10
|
+
cfg = UnsConfig.load(Path("config.json"))
|
|
11
|
+
process = UnsProxyProcess(cfg.host, cfg)
|
|
12
|
+
await process.start()
|
|
13
|
+
print(f"[data-example] process client connected to {cfg.host}:{cfg.port or 1883}")
|
|
14
|
+
|
|
15
|
+
out = await process.create_mqtt_proxy("py-output")
|
|
16
|
+
out.client.publisher_active = True
|
|
17
|
+
print(f"[data-example] output proxy connected to {cfg.host}:{cfg.port or 1883}")
|
|
18
|
+
|
|
19
|
+
inp = UnsMqttClient(
|
|
20
|
+
cfg.host,
|
|
21
|
+
port=cfg.port,
|
|
22
|
+
username=cfg.username or None,
|
|
23
|
+
password=cfg.password or None,
|
|
24
|
+
tls=cfg.tls,
|
|
25
|
+
client_id=f"{cfg.process_name}-data-example-in",
|
|
26
|
+
topic_builder=cfg.topic_builder(),
|
|
27
|
+
instance_name="py-input",
|
|
28
|
+
subscriber_active=True,
|
|
29
|
+
)
|
|
30
|
+
await inp.connect()
|
|
31
|
+
print(f"[data-example] input client connected to {cfg.host}:{cfg.port or 1883}, subscribing to raw/#")
|
|
32
|
+
|
|
33
|
+
topic = "enterprise/site/area/line/"
|
|
34
|
+
asset = "asset"
|
|
35
|
+
asset_description = "Sample asset"
|
|
36
|
+
object_type = "energy-resource"
|
|
37
|
+
object_id = "main"
|
|
38
|
+
data_group = "sensor"
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
async with inp.messages("raw/#") as messages:
|
|
42
|
+
await out.client.publish_raw("raw/data", "", retain=True)
|
|
43
|
+
print("[data-example] cleared retained raw/data; subscribed to 1 topic (raw/#); waiting for incoming raw/data...")
|
|
44
|
+
|
|
45
|
+
async for msg in messages:
|
|
46
|
+
payload = msg.payload.decode(errors="replace") if msg.payload else ""
|
|
47
|
+
if str(msg.topic) != "raw/data":
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
parts = payload.split(",")
|
|
51
|
+
if len(parts) < 3:
|
|
52
|
+
print(f"[data-example] skip malformed raw/data: {payload}")
|
|
53
|
+
continue
|
|
54
|
+
try:
|
|
55
|
+
number_value = float(parts[0])
|
|
56
|
+
event_time_ms = int(parts[1])
|
|
57
|
+
sensor_value = float(parts[2])
|
|
58
|
+
except ValueError:
|
|
59
|
+
print(f"[data-example] parse error, skip: {payload}")
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
event_time = datetime.fromtimestamp(event_time_ms / 1000, tz=timezone.utc)
|
|
63
|
+
interval_start = isoformat(event_time - timedelta(seconds=1))
|
|
64
|
+
interval_end = isoformat(event_time)
|
|
65
|
+
time = isoformat(event_time)
|
|
66
|
+
|
|
67
|
+
await out.publish_mqtt_message(
|
|
68
|
+
{
|
|
69
|
+
"topic": topic,
|
|
70
|
+
"asset": asset,
|
|
71
|
+
"assetDescription": asset_description,
|
|
72
|
+
"objectType": object_type,
|
|
73
|
+
"objectId": object_id,
|
|
74
|
+
"attributes": [
|
|
75
|
+
{
|
|
76
|
+
"attribute": "current",
|
|
77
|
+
"description": "Simulated current sensor value",
|
|
78
|
+
"data": {
|
|
79
|
+
"dataGroup": data_group,
|
|
80
|
+
"time": time,
|
|
81
|
+
"intervalStart": interval_start,
|
|
82
|
+
"intervalEnd": interval_end,
|
|
83
|
+
"value": number_value,
|
|
84
|
+
"uom": "A",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"attribute": "voltage",
|
|
89
|
+
"description": "Simulated voltage sensor value",
|
|
90
|
+
"data": {
|
|
91
|
+
"dataGroup": data_group,
|
|
92
|
+
"time": time,
|
|
93
|
+
"intervalStart": interval_start,
|
|
94
|
+
"intervalEnd": interval_end,
|
|
95
|
+
"value": sensor_value,
|
|
96
|
+
"uom": "V",
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
print(f"[data-example] published transform at {time}")
|
|
103
|
+
finally:
|
|
104
|
+
await inp.close()
|
|
105
|
+
await out.close()
|
|
106
|
+
await process.stop()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
asyncio.run(main())
|