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.
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
39
  Edit `config.json` with your MQTT host/auth.
@@ -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())
@@ -0,0 +1,103 @@
1
+ import asyncio
2
+ import math
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ from uns_kit import UnsConfig, UnsMqttClient, TopicBuilder
7
+
8
+
9
+ def load_config() -> UnsConfig:
10
+ cfg_path = Path("config.json")
11
+ if cfg_path.exists():
12
+ return UnsConfig.load(cfg_path)
13
+ return UnsConfig(host="localhost")
14
+
15
+
16
+ def simulate_sensor_value(step: int) -> float:
17
+ base_value = 42.0
18
+ fast_cycle = math.sin(step / 5.0) * 3.0
19
+ slow_cycle = math.sin(step / 25.0) * 6.0
20
+ ripple = math.sin(step / 2.0 + math.pi / 4.0) * 0.5
21
+ value = base_value + fast_cycle + slow_cycle + ripple
22
+ return round(value, 2)
23
+
24
+
25
+ def prompt_bool(prompt: str, default_yes: bool = True) -> bool:
26
+ suffix = " (Y/n) " if default_yes else " (y/N) "
27
+ answer = input(prompt + suffix).strip().lower()
28
+ if not answer:
29
+ return default_yes
30
+ return answer in ("y", "yes")
31
+
32
+
33
+ def prompt_int(prompt: str, default: int, suffix: str = "") -> int:
34
+ answer = input(f"{prompt} (default {default}{suffix}) ").strip()
35
+ try:
36
+ return int(answer) if answer else default
37
+ except ValueError:
38
+ return default
39
+
40
+
41
+ def prompt_topic(prompt: str, default: str) -> str:
42
+ answer = input(f"{prompt} (default {default}) ").strip()
43
+ return answer or default
44
+
45
+
46
+ def log_info(message: str) -> None:
47
+ timestamp = datetime.now().isoformat(timespec="seconds")
48
+ print(f"{timestamp} [INFO] {message}")
49
+
50
+
51
+ async def main() -> None:
52
+ cfg = load_config()
53
+ host = f"{cfg.host}:{cfg.port}" if cfg.port else cfg.host
54
+
55
+ if not prompt_bool(f"Would you like to continue with load-test on {host}?"):
56
+ print("Load test aborted.")
57
+ return
58
+
59
+ count = prompt_int("How many iterations should be run?", 100, "")
60
+ delay_ms = prompt_int("What should be the delay between intervals in milliseconds?", 0, " ms")
61
+ topic = prompt_topic("Topic to publish to", "raw/data")
62
+ retain = prompt_bool("Retain each published message so late subscribers see the last value?", True)
63
+ delay_s = delay_ms / 1000.0
64
+
65
+ log_info(f"Starting load test with {count} messages and {delay_ms} ms delay...")
66
+
67
+ # Use a dedicated identity for load testing to avoid clashing with app processes.
68
+ tb = TopicBuilder(package_name="uns-loadtest", package_version="0.0.0", process_name="load-tester")
69
+ client = UnsMqttClient(
70
+ cfg.host,
71
+ port=cfg.port,
72
+ username=cfg.username or None,
73
+ password=cfg.password or None,
74
+ tls=cfg.tls,
75
+ client_id="uns-loadtest-client",
76
+ topic_builder=tb,
77
+ instance_name="py-load-test",
78
+ reconnect_interval=1,
79
+ )
80
+ await client.connect()
81
+
82
+ start = asyncio.get_event_loop().time()
83
+ for i in range(count):
84
+ now = asyncio.get_event_loop().time()
85
+ sensor_value = simulate_sensor_value(i)
86
+ raw_payload = f"{i},{int(now * 1000)},{sensor_value}"
87
+ await client.publish_raw(topic, raw_payload, retain=retain)
88
+ if delay_s:
89
+ await asyncio.sleep(delay_s)
90
+
91
+ log_info("Sleeping for 50ms.")
92
+ await asyncio.sleep(0.05)
93
+ end = asyncio.get_event_loop().time()
94
+ duration = end - start
95
+ rate = count / duration if duration > 0 else 0
96
+ log_info(f"Load test completed in {duration:.2f} seconds.")
97
+ log_info(f"Message rate: {rate:.2f} msg/s.")
98
+
99
+ await client.close()
100
+
101
+
102
+ if __name__ == "__main__":
103
+ asyncio.run(main())
uns_kit/topic_builder.py CHANGED
@@ -33,6 +33,16 @@ class TopicBuilder:
33
33
  def handover_topic(self) -> str:
34
34
  return f"{self._base}handover"
35
35
 
36
+ def wildcard_active_topic(self) -> str:
37
+ parts = self._base.strip("/").split("/")
38
+ if len(parts) < 2:
39
+ raise ValueError("processStatusTopic must follow 'uns-infra/<package>/<version>/<process>/'")
40
+ return "/".join(parts[:2]) + "/+/+/active"
41
+
42
+ def instance_status_topic(self, instance_name: str) -> str:
43
+ sanitized = self.sanitize_topic_part(instance_name)
44
+ return f"{self._base}{sanitized}/"
45
+
36
46
  @staticmethod
37
47
  def extract_base_topic(full_topic: str) -> str:
38
48
  parts = full_topic.split("/")