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/__init__.py +9 -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 +29 -0
- uns_kit/templates/default/pyproject.toml +7 -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.4.dist-info → uns_kit-0.0.5.dist-info}/METADATA +30 -13
- uns_kit-0.0.5.dist-info/RECORD +19 -0
- uns_kit/templates/default/src/main.py +0 -27
- uns_kit-0.0.4.dist-info/RECORD +0 -13
- {uns_kit-0.0.4.dist-info → uns_kit-0.0.5.dist-info}/WHEEL +0 -0
- {uns_kit-0.0.4.dist-info → uns_kit-0.0.5.dist-info}/entry_points.txt +0 -0
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)
|
|
@@ -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("/")
|