halyn 0.2.0__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.
@@ -0,0 +1,60 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ Browser Driver — Chrome/Chromium via CDP.
5
+ """
6
+ from __future__ import annotations
7
+ import json
8
+ import subprocess
9
+ from typing import Any
10
+ from nrp import NRPDriver, ShieldRule, ShieldType
11
+
12
+ class BrowserDriver(NRPDriver):
13
+ def __init__(self, cdp_url: str = "http://localhost:9222") -> None:
14
+ self.cdp_url = cdp_url
15
+
16
+ @property
17
+ def kind(self) -> str: return "browser"
18
+
19
+ @property
20
+ def capabilities(self) -> list[str]:
21
+ return ["navigate", "screenshot", "click", "type", "read_page", "tabs", "execute_js"]
22
+
23
+ async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
24
+ import aiohttp
25
+ async with aiohttp.ClientSession() as s:
26
+ async with s.get(f"{self.cdp_url}/json") as r:
27
+ tabs = await r.json()
28
+ return {"tabs": [{"title": t.get("title",""), "url": t.get("url","")} for t in tabs[:10]]}
29
+
30
+ async def act(self, command: str, args: dict[str, Any]) -> Any:
31
+ if command == "navigate":
32
+ return await self._cdp_cmd("Page.navigate", {"url": args["url"]})
33
+ elif command == "screenshot":
34
+ data = await self._cdp_cmd("Page.captureScreenshot", {})
35
+ return {"base64_length": len(data.get("data",""))}
36
+ elif command == "execute_js":
37
+ return await self._cdp_cmd("Runtime.evaluate", {"expression": args["code"]})
38
+ raise ValueError(f"Unknown browser command: {command}")
39
+
40
+ def shield_rules(self) -> list[ShieldRule]:
41
+ return [
42
+ ShieldRule("no_file_urls", ShieldType.PATTERN, "file://", description="Block local file access"),
43
+ ShieldRule("no_data_exfil", ShieldType.PATTERN, "document.cookie", description="Block cookie theft"),
44
+ ]
45
+
46
+ async def _cdp_cmd(self, method: str, params: dict) -> dict:
47
+ import aiohttp
48
+ async with aiohttp.ClientSession() as s:
49
+ async with s.get(f"{self.cdp_url}/json") as r:
50
+ tabs = await r.json()
51
+ if not tabs:
52
+ return {"error": "no tabs"}
53
+ ws_url = tabs[0].get("webSocketDebuggerUrl", "")
54
+ if not ws_url:
55
+ return {"error": "no websocket"}
56
+ async with s.ws_connect(ws_url) as ws:
57
+ await ws.send_json({"id": 1, "method": method, "params": params})
58
+ resp = await ws.receive_json()
59
+ return resp.get("result", {})
60
+
halyn/drivers/dds.py ADDED
@@ -0,0 +1,156 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ DDS meta-driver — Data Distribution Service (pub/sub real-time).
5
+
6
+ Covers: ROS2 (via rmw), autonomous vehicles, military systems,
7
+ real-time telemetry, high-frequency sensor fusion.
8
+ DDS operates at UDP layer with QoS guarantees.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ import time
16
+ from typing import Any
17
+
18
+ from nrp import (
19
+ NRPDriver, NRPManifest, NRPId,
20
+ ChannelSpec, ActionSpec, ShieldSpec, ShieldRule, ShieldType,
21
+ )
22
+
23
+ log = logging.getLogger("halyn.drivers.dds")
24
+
25
+ try:
26
+ import rclpy
27
+ from rclpy.node import Node as ROS2Node
28
+ HAS_ROS2 = True
29
+ except ImportError:
30
+ HAS_ROS2 = False
31
+
32
+
33
+ class DDSDriver(NRPDriver):
34
+ """
35
+ DDS pub/sub driver with optional ROS2 bridge.
36
+
37
+ Two modes:
38
+ - ros2: uses rclpy for full ROS2 interop
39
+ - raw: direct DDS via cyclonedds-python
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ domain_id: int = 0,
45
+ topics_sub: list[str] | None = None,
46
+ topics_pub: list[str] | None = None,
47
+ qos_depth: int = 10,
48
+ mode: str = "ros2",
49
+ ) -> None:
50
+ super().__init__()
51
+ self.domain_id = domain_id
52
+ self.topics_sub = topics_sub or []
53
+ self.topics_pub = topics_pub or []
54
+ self.qos_depth = qos_depth
55
+ self.mode = mode
56
+ self._state: dict[str, Any] = {}
57
+ self._msg_counts: dict[str, int] = {}
58
+ self._node = None
59
+ self._subs: list[Any] = []
60
+ self._pubs: dict[str, Any] = {}
61
+
62
+ def manifest(self) -> NRPManifest:
63
+ channels = [
64
+ ChannelSpec("active_topics", "json", description="Subscribed topics and message counts"),
65
+ ]
66
+ for topic in self.topics_sub:
67
+ channels.append(ChannelSpec(
68
+ topic.replace("/", "_").strip("_"),
69
+ "json", rate="on_change",
70
+ description=f"Latest message from {topic}",
71
+ ))
72
+
73
+ actions = []
74
+ for topic in self.topics_pub:
75
+ safe_name = topic.replace("/", "_").strip("_")
76
+ actions.append(ActionSpec(
77
+ f"pub_{safe_name}",
78
+ {"message": "json — message payload"},
79
+ f"Publish to {topic}",
80
+ ))
81
+ actions.append(ActionSpec("list_topics", {}, "Enumerate discovered DDS topics"))
82
+
83
+ return NRPManifest(
84
+ nrp_id=self._nrp_id or NRPId.create("local", "dds", f"domain-{self.domain_id}"),
85
+ manufacturer="DDS",
86
+ model=f"domain={self.domain_id} mode={self.mode}",
87
+ observe=channels,
88
+ act=actions,
89
+ shield=[ShieldSpec("pub_rate", "limit", 1000, "msg/s")],
90
+ )
91
+
92
+ async def connect(self) -> bool:
93
+ if self.mode == "ros2":
94
+ return self._connect_ros2()
95
+ log.warning("dds: raw DDS mode requires cyclonedds (not yet bundled)")
96
+ return False
97
+
98
+ async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
99
+ state: dict[str, Any] = {"active_topics": dict(self._msg_counts)}
100
+ if channels:
101
+ for ch in channels:
102
+ if ch in self._state:
103
+ state[ch] = self._state[ch]
104
+ else:
105
+ state.update(self._state)
106
+ return state
107
+
108
+ async def act(self, command: str, args: dict[str, Any]) -> Any:
109
+ if command == "list_topics" and self._node and HAS_ROS2:
110
+ return {
111
+ "publishers": self._node.get_publisher_names_and_types_by_node(
112
+ self._node.get_name(), self._node.get_namespace()),
113
+ "subscribers": self._node.get_subscriber_names_and_types_by_node(
114
+ self._node.get_name(), self._node.get_namespace()),
115
+ }
116
+ for topic in self.topics_pub:
117
+ safe = topic.replace("/", "_").strip("_")
118
+ if command == f"pub_{safe}":
119
+ return self._publish(topic, args.get("message", {}))
120
+ return {"error": f"unknown: {command}"}
121
+
122
+ def shield_rules(self) -> list[ShieldRule]:
123
+ return [ShieldRule("pub_rate", ShieldType.LIMIT, 1000)]
124
+
125
+ async def disconnect(self) -> None:
126
+ if self._node and HAS_ROS2:
127
+ self._node.destroy_node()
128
+ self._node = None
129
+
130
+ def _connect_ros2(self) -> bool:
131
+ if not HAS_ROS2:
132
+ log.warning("dds: rclpy not installed")
133
+ return False
134
+ try:
135
+ if not rclpy.ok():
136
+ rclpy.init()
137
+ self._node = ROS2Node("halyn_dds_bridge", namespace=f"halyn")
138
+ log.info("dds.ros2_connected domain=%d topics=%d",
139
+ self.domain_id, len(self.topics_sub))
140
+ return True
141
+ except Exception as e:
142
+ log.error("dds.ros2_failed: %s", e)
143
+ return False
144
+
145
+ def _publish(self, topic: str, message: Any) -> dict[str, Any]:
146
+ pub = self._pubs.get(topic)
147
+ if not pub:
148
+ return {"error": f"no publisher for {topic}"}
149
+ try:
150
+ from std_msgs.msg import String
151
+ msg = String()
152
+ msg.data = message if isinstance(message, str) else str(message)
153
+ pub.publish(msg)
154
+ return {"published": topic, "size": len(msg.data)}
155
+ except Exception as e:
156
+ return {"error": str(e)}
@@ -0,0 +1,62 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ Docker Driver — Containers, images, volumes.
5
+ """
6
+ from __future__ import annotations
7
+ import json
8
+ import subprocess
9
+ from typing import Any
10
+ from nrp import NRPDriver, ShieldRule, ShieldType
11
+
12
+ class DockerDriver(NRPDriver):
13
+ def __init__(self, host: str = "unix:///var/run/docker.sock") -> None:
14
+ self.host = host
15
+
16
+ @property
17
+ def kind(self) -> str: return "docker"
18
+
19
+ @property
20
+ def capabilities(self) -> list[str]:
21
+ return ["run", "stop", "restart", "logs", "exec", "images", "volumes"]
22
+
23
+ async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
24
+ ps = self._cmd("docker ps --format '{{.Names}}\t{{.Status}}\t{{.Image}}'")
25
+ containers = []
26
+ for line in ps.strip().splitlines():
27
+ parts = line.split("\t")
28
+ if parts:
29
+ containers.append({"name": parts[0], "status": parts[1] if len(parts)>1 else "",
30
+ "image": parts[2] if len(parts)>2 else ""})
31
+ stats = self._cmd("docker system df --format '{{.Type}}\t{{.Size}}'")
32
+ return {"containers": containers, "disk": stats.strip()}
33
+
34
+ async def act(self, command: str, args: dict[str, Any]) -> Any:
35
+ name = args.get("name", args.get("container", ""))
36
+ if command == "run":
37
+ image = args["image"]
38
+ flags = args.get("flags", "-d")
39
+ return self._cmd(f"docker run {flags} --name {name} {image}")
40
+ elif command == "stop":
41
+ return self._cmd(f"docker stop {name}")
42
+ elif command == "restart":
43
+ return self._cmd(f"docker restart {name}")
44
+ elif command == "logs":
45
+ n = args.get("lines", 50)
46
+ return self._cmd(f"docker logs --tail {n} {name}")
47
+ elif command == "exec":
48
+ cmd = args.get("command", "echo ok")
49
+ return self._cmd(f"docker exec {name} {cmd}")
50
+ raise ValueError(f"Unknown docker command: {command}")
51
+
52
+ def shield_rules(self) -> list[ShieldRule]:
53
+ return [
54
+ ShieldRule("no_privileged", ShieldType.PATTERN, "--privileged", description="Block privileged containers"),
55
+ ShieldRule("no_host_network", ShieldType.PATTERN, "--network host", description="Block host networking"),
56
+ ShieldRule("confirm_stop", ShieldType.CONFIRM, "stop", description="Confirm before stopping"),
57
+ ]
58
+
59
+ def _cmd(self, cmd: str, timeout: int = 15) -> str:
60
+ r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
61
+ return r.stdout
62
+
@@ -0,0 +1,259 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ HTTP Auto-Introspection Driver — The universal API connector.
5
+
6
+ Auto-introspecting HTTP driver.
7
+
8
+ Reads OpenAPI 3.x or GraphQL introspection schemas
9
+ and generates NRP manifests from discovered endpoints.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json as json_mod
15
+ import logging
16
+ from typing import Any
17
+ from urllib.parse import urljoin
18
+
19
+ from nrp import NRPDriver, ShieldRule, ShieldType
20
+
21
+ log = logging.getLogger("halyn.drivers.http_auto")
22
+
23
+ try:
24
+ import aiohttp
25
+ HAS_AIOHTTP = True
26
+ except ImportError:
27
+ HAS_AIOHTTP = False
28
+
29
+
30
+ class HTTPAutoDriver(NRPDriver):
31
+ """
32
+ Auto-introspecting HTTP/REST/GraphQL driver.
33
+
34
+ Give it a base URL. It discovers the API automatically.
35
+
36
+ Supports:
37
+ - OpenAPI 3.x (reads /openapi.json, /swagger.json, /docs)
38
+ - GraphQL (reads /graphql introspection)
39
+ - REST (manual endpoint registration)
40
+ - Webhook callbacks
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ base_url: str,
46
+ auth_header: str = "",
47
+ auth_token: str = "",
48
+ openapi_path: str = "",
49
+ timeout: int = 30,
50
+ ) -> None:
51
+ super().__init__()
52
+ self.base_url = base_url.rstrip("/")
53
+ self.auth_header = auth_header or "Authorization"
54
+ self.auth_token = auth_token
55
+ self.openapi_path = openapi_path
56
+ self.timeout = timeout
57
+ self._spec: dict[str, Any] = {}
58
+ self._endpoints: list[dict[str, Any]] = []
59
+
60
+ def manifest(self):
61
+ from nrp import NRPManifest, ChannelSpec, ActionSpec, ShieldSpec
62
+ nrp_id = self._nrp_id
63
+ observe_channels = [
64
+ ChannelSpec("health", "string", description="API health status"),
65
+ ChannelSpec("endpoints", "json", description="Available API endpoints"),
66
+ ]
67
+ actions = []
68
+
69
+ for ep in self._endpoints:
70
+ method = ep.get("method", "GET").upper()
71
+ path = ep.get("path", "")
72
+ desc = ep.get("description", f"{method} {path}")
73
+ args: dict[str, str] = {}
74
+ for param in ep.get("parameters", []):
75
+ pname = param.get("name", "")
76
+ ptype = param.get("type", "string")
77
+ args[pname] = f"{ptype} — {param.get('description', '')}"
78
+ if method in ("POST", "PUT", "PATCH"):
79
+ args["body"] = "json — request body"
80
+
81
+ dangerous = method in ("DELETE", "PUT", "PATCH", "POST")
82
+ action_name = ep.get("operationId", f"{method.lower()}_{path.replace('/', '_').strip('_')}")
83
+ actions.append(ActionSpec(
84
+ name=action_name, args=args, description=desc, dangerous=dangerous,
85
+ ))
86
+
87
+ return NRPManifest(
88
+ nrp_id=nrp_id,
89
+ manufacturer="HTTP API",
90
+ model=self._spec.get("info", {}).get("title", self.base_url),
91
+ firmware=self._spec.get("info", {}).get("version", ""),
92
+ observe=observe_channels,
93
+ act=actions,
94
+ shield=[
95
+ ShieldSpec("rate_limit", "limit", 100, "req/min", "API rate limit"),
96
+ ],
97
+ )
98
+
99
+ async def connect(self) -> bool:
100
+ """Try to discover the API spec automatically."""
101
+ if not HAS_AIOHTTP:
102
+ log.warning("http_auto: aiohttp not installed, using manual mode")
103
+ return True
104
+
105
+ spec_paths = [
106
+ self.openapi_path,
107
+ "/openapi.json", "/swagger.json",
108
+ "/api/openapi.json", "/v1/openapi.json",
109
+ "/docs/openapi.json", "/.well-known/openapi.json",
110
+ ]
111
+ headers = self._headers()
112
+
113
+ async with aiohttp.ClientSession() as session:
114
+ for path in spec_paths:
115
+ if not path:
116
+ continue
117
+ url = urljoin(self.base_url + "/", path.lstrip("/"))
118
+ try:
119
+ async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as resp:
120
+ if resp.status == 200:
121
+ ct = resp.content_type or ""
122
+ if "json" in ct or "yaml" in ct:
123
+ self._spec = await resp.json()
124
+ self._parse_openapi()
125
+ log.info("http_auto.spec_found url=%s endpoints=%d",
126
+ url, len(self._endpoints))
127
+ return True
128
+ except Exception as exc:
129
+ log.debug("http_auto.probe_failed url=%s error=%s", url, exc)
130
+
131
+ # Try GraphQL introspection
132
+ try:
133
+ gql_url = urljoin(self.base_url + "/", "graphql")
134
+ introspection = {"query": "{ __schema { types { name } } }"}
135
+ async with session.post(gql_url, json=introspection, headers=headers,
136
+ timeout=aiohttp.ClientTimeout(total=10)) as resp:
137
+ if resp.status == 200:
138
+ data = await resp.json()
139
+ if "data" in data and "__schema" in data["data"]:
140
+ self._spec = {"graphql": True, "schema": data["data"]["__schema"]}
141
+ self._endpoints = [{"method": "POST", "path": "/graphql",
142
+ "operationId": "graphql_query",
143
+ "description": "Execute GraphQL query",
144
+ "parameters": [{"name": "query", "type": "string"}]}]
145
+ log.info("http_auto.graphql_found url=%s", gql_url)
146
+ return True
147
+ except Exception:
148
+ pass
149
+
150
+ log.info("http_auto.no_spec_found url=%s (manual mode)", self.base_url)
151
+ return True
152
+
153
+ async def observe(self, channels=None):
154
+ channels = channels or ["health", "endpoints"]
155
+ state: dict[str, Any] = {}
156
+
157
+ if "health" in channels:
158
+ if HAS_AIOHTTP:
159
+ try:
160
+ async with aiohttp.ClientSession() as session:
161
+ async with session.get(
162
+ self.base_url, headers=self._headers(),
163
+ timeout=aiohttp.ClientTimeout(total=5)
164
+ ) as resp:
165
+ state["health"] = f"status={resp.status}"
166
+ except Exception as exc:
167
+ state["health"] = f"error: {str(exc)[:100]}"
168
+ else:
169
+ state["health"] = "unknown (aiohttp not installed)"
170
+
171
+ if "endpoints" in channels:
172
+ state["endpoints"] = [
173
+ {"method": ep.get("method"), "path": ep.get("path"),
174
+ "name": ep.get("operationId", "")}
175
+ for ep in self._endpoints[:50]
176
+ ]
177
+
178
+ return state
179
+
180
+ async def act(self, command: str, args: dict[str, Any]) -> Any:
181
+ if not HAS_AIOHTTP:
182
+ return {"error": "aiohttp not installed"}
183
+
184
+ ep = next((e for e in self._endpoints if e.get("operationId") == command), None)
185
+ if not ep:
186
+ return {"error": f"Unknown endpoint: {command}"}
187
+
188
+ method = ep.get("method", "GET").upper()
189
+ path = ep.get("path", "/")
190
+
191
+ # Substitute path parameters
192
+ for param in ep.get("parameters", []):
193
+ pname = param.get("name", "")
194
+ if pname in args and f"{{{pname}}}" in path:
195
+ path = path.replace(f"{{{pname}}}", str(args.pop(pname)))
196
+
197
+ url = urljoin(self.base_url + "/", path.lstrip("/"))
198
+ body = args.pop("body", None)
199
+ query_params = {k: v for k, v in args.items() if v is not None}
200
+
201
+ headers = self._headers()
202
+ if body:
203
+ headers["Content-Type"] = "application/json"
204
+
205
+ async with aiohttp.ClientSession() as session:
206
+ async with session.request(
207
+ method, url, headers=headers, params=query_params or None,
208
+ json=body if body else None,
209
+ timeout=aiohttp.ClientTimeout(total=self.timeout),
210
+ ) as resp:
211
+ ct = resp.content_type or ""
212
+ if "json" in ct:
213
+ data = await resp.json()
214
+ else:
215
+ data = await resp.text()
216
+ return {"status": resp.status, "data": data}
217
+
218
+ def shield_rules(self):
219
+ return [ShieldRule("rate_limit", ShieldType.LIMIT, 100)]
220
+
221
+ def add_endpoint(self, method: str, path: str, operation_id: str = "",
222
+ description: str = "", parameters: list[dict] | None = None) -> None:
223
+ """Manually register an endpoint (when no spec is available)."""
224
+ self._endpoints.append({
225
+ "method": method.upper(), "path": path,
226
+ "operationId": operation_id or f"{method.lower()}_{path.replace('/', '_').strip('_')}",
227
+ "description": description,
228
+ "parameters": parameters or [],
229
+ })
230
+
231
+ def _parse_openapi(self) -> None:
232
+ """Parse OpenAPI 3.x spec into endpoints."""
233
+ paths = self._spec.get("paths", {})
234
+ for path, methods in paths.items():
235
+ for method, details in methods.items():
236
+ if method.upper() not in ("GET", "POST", "PUT", "DELETE", "PATCH"):
237
+ continue
238
+ params = []
239
+ for p in details.get("parameters", []):
240
+ params.append({
241
+ "name": p.get("name", ""),
242
+ "type": p.get("schema", {}).get("type", "string"),
243
+ "description": p.get("description", ""),
244
+ "required": p.get("required", False),
245
+ })
246
+ self._endpoints.append({
247
+ "method": method.upper(),
248
+ "path": path,
249
+ "operationId": details.get("operationId", ""),
250
+ "description": details.get("summary", details.get("description", "")),
251
+ "parameters": params,
252
+ })
253
+
254
+ def _headers(self) -> dict[str, str]:
255
+ headers: dict[str, str] = {"Accept": "application/json"}
256
+ if self.auth_token:
257
+ headers[self.auth_header] = self.auth_token
258
+ return headers
259
+
halyn/drivers/mqtt.py ADDED
@@ -0,0 +1,93 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ MQTT Driver — IoT sensors and actuators.
5
+
6
+ Connects to any MQTT broker. Observes topics. Publishes commands.
7
+ Covers: temperature sensors, smart switches, irrigation, weather stations,
8
+ industrial sensors, home automation, agricultural monitoring.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import time
15
+ from typing import Any
16
+
17
+ from nrp import NRPDriver, ShieldRule, ShieldType
18
+
19
+
20
+ class MQTTDriver(NRPDriver):
21
+ """Control IoT devices via MQTT."""
22
+
23
+ def __init__(self, broker: str = "localhost", port: int = 1883,
24
+ topics: list[str] | None = None) -> None:
25
+ self.broker = broker
26
+ self.port = port
27
+ self.topics = topics or []
28
+ self._client: Any = None
29
+ self._last_messages: dict[str, Any] = {}
30
+
31
+ @property
32
+ def kind(self) -> str:
33
+ return "mqtt"
34
+
35
+ @property
36
+ def capabilities(self) -> list[str]:
37
+ return ["publish", "subscribe", "read_topic", "set_value"]
38
+
39
+ async def connect(self) -> bool:
40
+ try:
41
+ import paho.mqtt.client as mqtt
42
+ self._client = mqtt.Client()
43
+ self._client.on_message = self._on_message
44
+ self._client.connect(self.broker, self.port, 60)
45
+ for topic in self.topics:
46
+ self._client.subscribe(topic)
47
+ self._client.loop_start()
48
+ return True
49
+ except Exception:
50
+ return False
51
+
52
+ async def disconnect(self) -> None:
53
+ if self._client:
54
+ self._client.loop_stop()
55
+ self._client.disconnect()
56
+
57
+ async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
58
+ if channels:
59
+ return {ch: self._last_messages.get(ch, None) for ch in channels}
60
+ return dict(self._last_messages)
61
+
62
+ async def act(self, command: str, args: dict[str, Any]) -> Any:
63
+ if not self._client:
64
+ raise RuntimeError("Not connected to MQTT broker")
65
+ topic = args.get("topic", "")
66
+ payload = args.get("payload", args.get("value", ""))
67
+ if isinstance(payload, (dict, list)):
68
+ payload = json.dumps(payload)
69
+ self._client.publish(topic, str(payload))
70
+ return {"published": topic, "payload": str(payload)[:200]}
71
+
72
+ def shield_rules(self) -> list[ShieldRule]:
73
+ return [
74
+ ShieldRule("read_only_sensors", ShieldType.PATTERN, "sensor/*",
75
+ description="Sensor topics are read-only"),
76
+ ShieldRule("max_publish_rate", ShieldType.LIMIT, 10, unit="msg/s",
77
+ description="Max 10 messages per second"),
78
+ ]
79
+
80
+ def _on_message(self, client: Any, userdata: Any, msg: Any) -> None:
81
+ try:
82
+ payload = msg.payload.decode()
83
+ try:
84
+ payload = json.loads(payload)
85
+ except (json.JSONDecodeError, ValueError):
86
+ pass
87
+ self._last_messages[msg.topic] = {
88
+ "value": payload,
89
+ "timestamp": time.time(),
90
+ }
91
+ except Exception:
92
+ pass
93
+