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/packet.py CHANGED
@@ -1,13 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
- from dataclasses import dataclass, asdict
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
- return dt.astimezone(timezone.utc).isoformat()
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
- createdAt: Optional[str] = None
20
- expiresAt: Optional[str] = None
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
- table: Any
35
+ time: str
36
+ columns: Any
26
37
  dataGroup: Optional[str] = None
27
- createdAt: Optional[str] = None
28
- expiresAt: Optional[str] = None
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: int = 1
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
- payload = DataPayload(
42
- value=value,
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
- return {"version": UnsPacket.version, "message": {"data": asdict(payload)}}
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(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)}}
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()
@@ -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)
@@ -6,5 +6,34 @@ poetry install
6
6
  poetry run python src/main.py
7
7
  ```
8
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
+
9
38
  ## Config
10
- Edit `config.json` with your MQTT host/auth.
39
+ Edit `config.json` with your MQTT host/auth (TS-style nested infra/uns structure).
@@ -8,10 +8,17 @@ package-mode = false
8
8
  [tool.poetry.dependencies]
9
9
  python = "^3.10"
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"
11
14
 
12
15
  [tool.poetry.group.dev.dependencies]
13
16
  pytest = "^8.3.3"
14
17
 
18
+ [tool.poetry.scripts]
19
+ dev = "uns_kit.cli:main"
20
+ run = "uns_kit.cli:main"
21
+
15
22
  [build-system]
16
23
  requires = ["poetry-core"]
17
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())