Reinforcement Learning
stable-baselines3
deep-reinforcement-learning
agricultural-ai
weather-modelling
curriculum-learning
edge-ai
Instructions to use DHDRL/monsoon-rl with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- stable-baselines3
How to use DHDRL/monsoon-rl with stable-baselines3:
from huggingface_sb3 import load_from_hub checkpoint = load_from_hub( repo_id="DHDRL/monsoon-rl", filename="{MODEL FILENAME}.zip", ) - Notebooks
- Google Colab
- Kaggle
Update node_transport.py
Browse files- node_transport.py +1 -131
node_transport.py
CHANGED
|
@@ -2,34 +2,6 @@
|
|
| 2 |
node_transport.py
|
| 3 |
=================
|
| 4 |
Transport-neutral node bus for edge fleet coordination.
|
| 5 |
-
|
| 6 |
-
Semantics (all backends):
|
| 7 |
-
- Latest-value, non-consuming reads
|
| 8 |
-
- Soft TTL (STATE_TTL_SEC); recv returns None when stale
|
| 9 |
-
- No pickle / no eval — versioned binary or JSON only
|
| 10 |
-
|
| 11 |
-
Wire envelope (both hidden state and product alerts):
|
| 12 |
-
[version:u8][flags:u8][payload...]
|
| 13 |
-
flags bit0 = zlib-compressed payload
|
| 14 |
-
|
| 15 |
-
Hidden inner payload (unchanged for torch side):
|
| 16 |
-
[n_layers:u32][batch:u32][hidden:u32][float32 data...] little-endian
|
| 17 |
-
|
| 18 |
-
Alert inner payload:
|
| 19 |
-
UTF-8 JSON object (see alert_to_bytes / bytes_to_alert)
|
| 20 |
-
|
| 21 |
-
NodeTransport protocol (Genius-safe abstraction):
|
| 22 |
-
send/recv_hidden_state, send/recv_alert, clear
|
| 23 |
-
|
| 24 |
-
MQTT is one backend (hub-and-spoke). A future libp2p / SuperGenius backend
|
| 25 |
-
implements the same protocol without callers knowing topic names or brokers.
|
| 26 |
-
|
| 27 |
-
Steps covered here:
|
| 28 |
-
1. Product alerts on the protocol (not MQTT-only helpers)
|
| 29 |
-
2. MQTT hardening: TLS, auth, LWT + birth presence, reconnect
|
| 30 |
-
3. MQTT 5 when available (message expiry, user properties); 3.1.1 fallback
|
| 31 |
-
4. Schema version + optional zlib on all wire payloads
|
| 32 |
-
5. Multi-broker list (try in order) — topology as config, not hard-coded sites
|
| 33 |
"""
|
| 34 |
|
| 35 |
from __future__ import annotations
|
|
@@ -64,11 +36,7 @@ STATE_TTL_SEC = 300 # soft TTL for latest-value reads
|
|
| 64 |
WIRE_VERSION = 1
|
| 65 |
FLAG_COMPRESSED = 0x01
|
| 66 |
|
| 67 |
-
# MQTT topic layout is an implementation detail of MQTTTransport only.
|
| 68 |
DEFAULT_TOPIC_ROOT = "weather"
|
| 69 |
-
# weather/hidden/{zone_id}
|
| 70 |
-
# weather/alert/{zone_id}
|
| 71 |
-
# weather/presence/{node_id}
|
| 72 |
|
| 73 |
|
| 74 |
# ---------------------------------------------------------------------------
|
|
@@ -76,7 +44,6 @@ DEFAULT_TOPIC_ROOT = "weather"
|
|
| 76 |
# ---------------------------------------------------------------------------
|
| 77 |
|
| 78 |
def pack_wire(payload: bytes, *, compress: bool = False) -> bytes:
|
| 79 |
-
"""Prefix payload with version + flags. Optional zlib on payload only."""
|
| 80 |
flags = 0
|
| 81 |
body = payload
|
| 82 |
if compress and len(payload) > 64:
|
|
@@ -86,15 +53,9 @@ def pack_wire(payload: bytes, *, compress: bool = False) -> bytes:
|
|
| 86 |
|
| 87 |
|
| 88 |
def unpack_wire(data: bytes) -> bytes:
|
| 89 |
-
"""
|
| 90 |
-
Strip version envelope. Accepts:
|
| 91 |
-
- versioned: [ver][flags][payload]
|
| 92 |
-
- legacy hidden: raw [u32][u32][u32][floats...] (no version byte)
|
| 93 |
-
"""
|
| 94 |
if len(data) < 2:
|
| 95 |
raise ValueError(f"wire payload too short: {len(data)}")
|
| 96 |
|
| 97 |
-
# Legacy hidden: first uint32 is n_layers in 1..8 and total length matches
|
| 98 |
if _looks_like_legacy_hidden(data):
|
| 99 |
return data
|
| 100 |
|
|
@@ -125,15 +86,6 @@ def _looks_like_legacy_hidden(data: bytes) -> bool:
|
|
| 125 |
# ---------------------------------------------------------------------------
|
| 126 |
|
| 127 |
def hidden_to_bytes(hidden_tensor) -> bytes:
|
| 128 |
-
"""
|
| 129 |
-
Serialise a GRU hidden state tensor to inner payload bytes.
|
| 130 |
-
|
| 131 |
-
Format:
|
| 132 |
-
[n_layers: uint32][batch_size: uint32][hidden_size: uint32][float32 data...]
|
| 133 |
-
Always little-endian, always float32.
|
| 134 |
-
Call pack_wire(...) before sending on the bus if compression/versioning needed;
|
| 135 |
-
LocalTransport and MQTTTransport pack automatically on send.
|
| 136 |
-
"""
|
| 137 |
import torch
|
| 138 |
|
| 139 |
h = hidden_tensor.detach().cpu().float()
|
|
@@ -144,10 +96,6 @@ def hidden_to_bytes(hidden_tensor) -> bytes:
|
|
| 144 |
|
| 145 |
|
| 146 |
def bytes_to_hidden(data: bytes):
|
| 147 |
-
"""
|
| 148 |
-
Safe deserialisation with strict validation before any ML import.
|
| 149 |
-
Accepts wire envelope or legacy/inner payload.
|
| 150 |
-
"""
|
| 151 |
if _looks_like_legacy_hidden(data):
|
| 152 |
inner = data
|
| 153 |
else:
|
|
@@ -188,11 +136,6 @@ def bytes_to_hidden(data: bytes):
|
|
| 188 |
|
| 189 |
@dataclass
|
| 190 |
class ProductAlert:
|
| 191 |
-
"""Client/product-facing alert snapshot for the node bus.
|
| 192 |
-
|
| 193 |
-
Mirrors env terminate info + scorer freeze fields. Transport-agnostic:
|
| 194 |
-
any NodeTransport backend can carry the same bytes.
|
| 195 |
-
"""
|
| 196 |
zone_id: str
|
| 197 |
product_actionable: bool
|
| 198 |
elevated: bool
|
|
@@ -212,7 +155,6 @@ class ProductAlert:
|
|
| 212 |
|
| 213 |
|
| 214 |
def alert_to_bytes(alert: ProductAlert) -> bytes:
|
| 215 |
-
"""JSON inner payload for product alerts (UTF-8)."""
|
| 216 |
raw = json.dumps(alert.to_dict(), separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
| 217 |
if len(raw) > MAX_ALERT_BYTES:
|
| 218 |
raise ValueError(f"alert payload exceeds MAX_ALERT_BYTES ({len(raw)})")
|
|
@@ -220,12 +162,10 @@ def alert_to_bytes(alert: ProductAlert) -> bytes:
|
|
| 220 |
|
| 221 |
|
| 222 |
def bytes_to_alert(data: bytes) -> ProductAlert:
|
| 223 |
-
"""Parse alert from wire or inner JSON bytes."""
|
| 224 |
try:
|
| 225 |
inner = unpack_wire(data)
|
| 226 |
except ValueError:
|
| 227 |
inner = data
|
| 228 |
-
# If unpack produced garbage for pure JSON, try raw
|
| 229 |
try:
|
| 230 |
obj = json.loads(inner.decode("utf-8"))
|
| 231 |
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
@@ -266,7 +206,6 @@ def product_alert_from_risk(
|
|
| 266 |
trigger: str = "",
|
| 267 |
**extra: Any,
|
| 268 |
) -> ProductAlert:
|
| 269 |
-
"""Helper for env / edge callers building an alert from scorer/env info."""
|
| 270 |
return ProductAlert(
|
| 271 |
zone_id=zone_id,
|
| 272 |
product_actionable=product_actionable,
|
|
@@ -287,12 +226,6 @@ def product_alert_from_risk(
|
|
| 287 |
|
| 288 |
@runtime_checkable
|
| 289 |
class NodeTransport(Protocol):
|
| 290 |
-
"""Minimal interface every transport must implement.
|
| 291 |
-
|
| 292 |
-
Hidden state and product alerts are peer channels with identical
|
| 293 |
-
latest-value / TTL semantics. Topic names, brokers, and P2P routes
|
| 294 |
-
must not leak into callers.
|
| 295 |
-
"""
|
| 296 |
|
| 297 |
async def send_hidden_state(self, zone_id: str, hidden: bytes) -> None:
|
| 298 |
...
|
|
@@ -315,14 +248,6 @@ class NodeTransport(Protocol):
|
|
| 315 |
# ---------------------------------------------------------------------------
|
| 316 |
|
| 317 |
class LocalTransport:
|
| 318 |
-
"""
|
| 319 |
-
In-process deterministic transport.
|
| 320 |
-
|
| 321 |
-
Semantics:
|
| 322 |
-
- Latest-value (NOT consuming)
|
| 323 |
-
- TTL enforced on recv
|
| 324 |
-
- Auto wire-envelope on send when payload is bare inner
|
| 325 |
-
"""
|
| 326 |
|
| 327 |
def __init__(self, *, compress: bool = False, ttl_sec: float = STATE_TTL_SEC) -> None:
|
| 328 |
self._hidden: Dict[str, Tuple[bytes, float]] = {}
|
|
@@ -331,7 +256,6 @@ class LocalTransport:
|
|
| 331 |
self._ttl = float(ttl_sec)
|
| 332 |
|
| 333 |
def _pack(self, payload: bytes) -> bytes:
|
| 334 |
-
# If already versioned, leave as-is
|
| 335 |
if len(payload) >= 2 and payload[0] == WIRE_VERSION and not _looks_like_legacy_hidden(payload):
|
| 336 |
return payload
|
| 337 |
return pack_wire(payload, compress=self._compress)
|
|
@@ -380,23 +304,6 @@ class LocalTransport:
|
|
| 380 |
# ---------------------------------------------------------------------------
|
| 381 |
|
| 382 |
class MQTTTransport:
|
| 383 |
-
"""
|
| 384 |
-
MQTT backend for NodeTransport (hub-and-spoke).
|
| 385 |
-
|
| 386 |
-
Guarantees:
|
| 387 |
-
- Same latest-value / non-consuming / TTL semantics as LocalTransport
|
| 388 |
-
- Thread-safe receive buffers
|
| 389 |
-
- Fallback to LocalTransport when broker unavailable
|
| 390 |
-
- TLS + username/password when configured
|
| 391 |
-
- LWT + retained birth on presence topic
|
| 392 |
-
- MQTT 5 message expiry when broker/protocol supports it; 3.1.1 fallback
|
| 393 |
-
- Multi-broker: try hosts in order (step 5 topology as config)
|
| 394 |
-
|
| 395 |
-
Topic layout (implementation detail — not part of NodeTransport):
|
| 396 |
-
{topic_root}/hidden/{zone_id}
|
| 397 |
-
{topic_root}/alert/{zone_id}
|
| 398 |
-
{topic_root}/presence/{node_id}
|
| 399 |
-
"""
|
| 400 |
|
| 401 |
def __init__(
|
| 402 |
self,
|
|
@@ -405,21 +312,16 @@ class MQTTTransport:
|
|
| 405 |
username: Optional[str] = None,
|
| 406 |
password: Optional[str] = None,
|
| 407 |
topic_root: str = DEFAULT_TOPIC_ROOT,
|
| 408 |
-
# legacy alias
|
| 409 |
topic_prefix: Optional[str] = None,
|
| 410 |
*,
|
| 411 |
-
# step 5: try multiple brokers in order
|
| 412 |
brokers: Optional[Sequence[Tuple[str, int]]] = None,
|
| 413 |
-
# step 2: TLS
|
| 414 |
tls: bool = False,
|
| 415 |
tls_ca_certs: Optional[str] = None,
|
| 416 |
tls_certfile: Optional[str] = None,
|
| 417 |
tls_keyfile: Optional[str] = None,
|
| 418 |
tls_insecure: bool = False,
|
| 419 |
-
# identity / presence
|
| 420 |
client_id: Optional[str] = None,
|
| 421 |
node_id: str = "node-0",
|
| 422 |
-
# behaviour
|
| 423 |
compress: bool = False,
|
| 424 |
ttl_sec: float = STATE_TTL_SEC,
|
| 425 |
keepalive: int = 60,
|
|
@@ -434,9 +336,7 @@ class MQTTTransport:
|
|
| 434 |
self.username = username
|
| 435 |
self.password = password
|
| 436 |
self.topic_root = topic_root.rstrip("/")
|
| 437 |
-
# backward compat: old topic_prefix meant hidden prefix
|
| 438 |
if topic_prefix is not None:
|
| 439 |
-
# if caller passed weather/hidden, derive root
|
| 440 |
parts = topic_prefix.rstrip("/").split("/")
|
| 441 |
if parts and parts[-1] == "hidden":
|
| 442 |
self.topic_root = "/".join(parts[:-1]) or DEFAULT_TOPIC_ROOT
|
|
@@ -516,7 +416,6 @@ class MQTTTransport:
|
|
| 516 |
if self.client_id:
|
| 517 |
client_kwargs["client_id"] = self.client_id
|
| 518 |
|
| 519 |
-
# Prefer MQTT 5 when requested and available
|
| 520 |
self._mqttv5_active = False
|
| 521 |
if self.use_mqttv5 and hasattr(mqtt, "MQTTv5"):
|
| 522 |
protocol = mqtt.MQTTv5
|
|
@@ -524,7 +423,6 @@ class MQTTTransport:
|
|
| 524 |
|
| 525 |
client_kwargs["protocol"] = protocol
|
| 526 |
if callback_api is not None:
|
| 527 |
-
# paho 2.x
|
| 528 |
try:
|
| 529 |
client = mqtt.Client(
|
| 530 |
callback_api_version=callback_api.VERSION2,
|
|
@@ -550,7 +448,6 @@ class MQTTTransport:
|
|
| 550 |
if self.tls_insecure:
|
| 551 |
client.tls_insecure_set(True)
|
| 552 |
|
| 553 |
-
# LWT: retained offline presence
|
| 554 |
lwt_payload = json.dumps(
|
| 555 |
{"node_id": self.node_id, "status": "offline", "ts": time.time()},
|
| 556 |
separators=(",", ":"),
|
|
@@ -566,7 +463,6 @@ class MQTTTransport:
|
|
| 566 |
client.on_disconnect = self._on_disconnect
|
| 567 |
client.on_message = self._on_message
|
| 568 |
|
| 569 |
-
# Automatic reconnect (paho)
|
| 570 |
try:
|
| 571 |
client.reconnect_delay_set(min_delay=1, max_delay=30)
|
| 572 |
except Exception:
|
|
@@ -578,7 +474,6 @@ class MQTTTransport:
|
|
| 578 |
client.loop_start()
|
| 579 |
|
| 580 |
self._client = client
|
| 581 |
-
# Brief wait for on_connect — non-blocking best-effort
|
| 582 |
for _ in range(20):
|
| 583 |
if self._connected:
|
| 584 |
break
|
|
@@ -588,7 +483,6 @@ class MQTTTransport:
|
|
| 588 |
"MQTTTransport: connect attempted %s:%s mqttv5=%s connected=%s",
|
| 589 |
host, port, self._mqttv5_active, self._connected,
|
| 590 |
)
|
| 591 |
-
# Consider success if loop started; on_connect may race
|
| 592 |
return True
|
| 593 |
|
| 594 |
def _teardown_client(self) -> None:
|
|
@@ -602,7 +496,6 @@ class MQTTTransport:
|
|
| 602 |
self._connected = False
|
| 603 |
|
| 604 |
def _on_connect(self, client, userdata, flags, reason_code, properties=None) -> None:
|
| 605 |
-
# Compatible with both paho 1.x (rc int) and 2.x VERSION2 (reason_code)
|
| 606 |
rc = reason_code
|
| 607 |
if hasattr(reason_code, "value"):
|
| 608 |
rc = reason_code.value
|
|
@@ -615,7 +508,6 @@ class MQTTTransport:
|
|
| 615 |
if not self._connected:
|
| 616 |
return
|
| 617 |
|
| 618 |
-
# Retained birth / online presence
|
| 619 |
birth = json.dumps(
|
| 620 |
{"node_id": self.node_id, "status": "online", "ts": time.time()},
|
| 621 |
separators=(",", ":"),
|
|
@@ -630,7 +522,6 @@ class MQTTTransport:
|
|
| 630 |
except Exception as e:
|
| 631 |
logger.warning("MQTTTransport: birth publish failed: %s", e)
|
| 632 |
|
| 633 |
-
# Re-subscribe after reconnect
|
| 634 |
try:
|
| 635 |
client.subscribe(f"{self.topic_root}/hidden/#", qos=self.qos)
|
| 636 |
client.subscribe(f"{self.topic_root}/alert/#", qos=self.qos)
|
|
@@ -638,7 +529,6 @@ class MQTTTransport:
|
|
| 638 |
logger.warning("MQTTTransport: resubscribe failed: %s", e)
|
| 639 |
|
| 640 |
def _on_disconnect(self, client, userdata, flags, reason_code=None, properties=None) -> None:
|
| 641 |
-
# paho 1.x: (client, userdata, rc); 2.x VERSION2 adds flags/properties
|
| 642 |
self._connected = False
|
| 643 |
logger.warning("MQTTTransport: disconnected reason=%s", reason_code)
|
| 644 |
|
|
@@ -688,9 +578,7 @@ class MQTTTransport:
|
|
| 688 |
from paho.mqtt.packettypes import PacketTypes
|
| 689 |
|
| 690 |
props = Properties(PacketTypes.PUBLISH)
|
| 691 |
-
# Message expiry (seconds) — MQTT 5 equivalent of soft TTL
|
| 692 |
props.MessageExpiryInterval = int(self._ttl)
|
| 693 |
-
# User properties for schema / debugging (not required by receivers)
|
| 694 |
try:
|
| 695 |
props.UserProperty = [("wire_v", str(WIRE_VERSION))]
|
| 696 |
except Exception:
|
|
@@ -726,7 +614,6 @@ class MQTTTransport:
|
|
| 726 |
with self._lock:
|
| 727 |
item = self._hidden_rx.get(zone_id)
|
| 728 |
if item is None:
|
| 729 |
-
# also check fallback (local writes while disconnected)
|
| 730 |
return await self._fallback.recv_hidden_state(zone_id)
|
| 731 |
|
| 732 |
data, ts = item
|
|
@@ -774,7 +661,6 @@ class MQTTTransport:
|
|
| 774 |
|
| 775 |
async def disconnect(self) -> None:
|
| 776 |
if self._client is not None:
|
| 777 |
-
# Best-effort offline presence before disconnect
|
| 778 |
try:
|
| 779 |
offline = json.dumps(
|
| 780 |
{"node_id": self.node_id, "status": "offline", "ts": time.time()},
|
|
@@ -800,14 +686,6 @@ def create_node_transport(
|
|
| 800 |
use_mqtt: bool = False,
|
| 801 |
**mqtt_kwargs: Any,
|
| 802 |
) -> NodeTransport:
|
| 803 |
-
"""
|
| 804 |
-
Public factory.
|
| 805 |
-
|
| 806 |
-
Usage:
|
| 807 |
-
training → create_node_transport()
|
| 808 |
-
edge → create_node_transport(use_mqtt=True, broker="...", tls=True, ...)
|
| 809 |
-
multi → create_node_transport(use_mqtt=True, brokers=[("a", 8883), ("b", 8883)], tls=True)
|
| 810 |
-
"""
|
| 811 |
if use_mqtt:
|
| 812 |
return MQTTTransport(**mqtt_kwargs)
|
| 813 |
return LocalTransport(
|
|
@@ -826,7 +704,6 @@ def _self_test() -> None:
|
|
| 826 |
async def _run() -> None:
|
| 827 |
print("node_transport.py self-test")
|
| 828 |
|
| 829 |
-
# --- wire pack/unpack ---
|
| 830 |
raw = b"hello-alert-payload"
|
| 831 |
wire = pack_wire(raw, compress=False)
|
| 832 |
assert unpack_wire(wire) == raw
|
|
@@ -834,8 +711,6 @@ def _self_test() -> None:
|
|
| 834 |
assert unpack_wire(wire_c) == raw * 20
|
| 835 |
print(" wire envelope OK")
|
| 836 |
|
| 837 |
-
# --- hidden round-trip (no torch required if we skip tensor path) ---
|
| 838 |
-
# Build a synthetic inner payload
|
| 839 |
n_layers, batch, hidden = 1, 1, 8
|
| 840 |
inner = struct.pack("<III", n_layers, batch, hidden) + (b"\x00\x00\x00\x00" * (n_layers * batch * hidden))
|
| 841 |
assert _looks_like_legacy_hidden(inner)
|
|
@@ -843,7 +718,6 @@ def _self_test() -> None:
|
|
| 843 |
assert unpack_wire(w) == inner
|
| 844 |
print(" hidden wire OK")
|
| 845 |
|
| 846 |
-
# --- alert serialisation ---
|
| 847 |
alert = product_alert_from_risk(
|
| 848 |
"karawang_rice",
|
| 849 |
alert_level="warning",
|
|
@@ -862,7 +736,6 @@ def _self_test() -> None:
|
|
| 862 |
assert abs(alert2.drought_risk - 0.42) < 1e-6
|
| 863 |
print(" alert serialisation OK")
|
| 864 |
|
| 865 |
-
# --- LocalTransport protocol ---
|
| 866 |
tr = LocalTransport()
|
| 867 |
await tr.send_hidden_state("z1", inner)
|
| 868 |
got = await tr.recv_hidden_state("z1")
|
|
@@ -880,9 +753,7 @@ def _self_test() -> None:
|
|
| 880 |
assert await tr.recv_alert("z1") is None
|
| 881 |
print(" LocalTransport protocol OK")
|
| 882 |
|
| 883 |
-
# --- MQTTTransport falls back without broker ---
|
| 884 |
mqtt_tr = MQTTTransport(broker="127.0.0.1", port=1, node_id="test-node")
|
| 885 |
-
# do not connect — should use fallback
|
| 886 |
await mqtt_tr.send_alert("z2", ab)
|
| 887 |
got_b = await mqtt_tr.recv_alert("z2")
|
| 888 |
assert got_b is not None
|
|
@@ -891,7 +762,6 @@ def _self_test() -> None:
|
|
| 891 |
assert await mqtt_tr.recv_hidden_state("z2") is not None
|
| 892 |
print(" MQTTTransport offline fallback OK")
|
| 893 |
|
| 894 |
-
# --- factory ---
|
| 895 |
t0 = create_node_transport()
|
| 896 |
assert isinstance(t0, LocalTransport)
|
| 897 |
t1 = create_node_transport(use_mqtt=True, broker="localhost")
|
|
@@ -905,4 +775,4 @@ def _self_test() -> None:
|
|
| 905 |
|
| 906 |
if __name__ == "__main__":
|
| 907 |
logging.basicConfig(level=logging.INFO)
|
| 908 |
-
_self_test()
|
|
|
|
| 2 |
node_transport.py
|
| 3 |
=================
|
| 4 |
Transport-neutral node bus for edge fleet coordination.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
|
|
|
| 36 |
WIRE_VERSION = 1
|
| 37 |
FLAG_COMPRESSED = 0x01
|
| 38 |
|
|
|
|
| 39 |
DEFAULT_TOPIC_ROOT = "weather"
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
# ---------------------------------------------------------------------------
|
|
|
|
| 44 |
# ---------------------------------------------------------------------------
|
| 45 |
|
| 46 |
def pack_wire(payload: bytes, *, compress: bool = False) -> bytes:
|
|
|
|
| 47 |
flags = 0
|
| 48 |
body = payload
|
| 49 |
if compress and len(payload) > 64:
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
def unpack_wire(data: bytes) -> bytes:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
if len(data) < 2:
|
| 57 |
raise ValueError(f"wire payload too short: {len(data)}")
|
| 58 |
|
|
|
|
| 59 |
if _looks_like_legacy_hidden(data):
|
| 60 |
return data
|
| 61 |
|
|
|
|
| 86 |
# ---------------------------------------------------------------------------
|
| 87 |
|
| 88 |
def hidden_to_bytes(hidden_tensor) -> bytes:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
import torch
|
| 90 |
|
| 91 |
h = hidden_tensor.detach().cpu().float()
|
|
|
|
| 96 |
|
| 97 |
|
| 98 |
def bytes_to_hidden(data: bytes):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
if _looks_like_legacy_hidden(data):
|
| 100 |
inner = data
|
| 101 |
else:
|
|
|
|
| 136 |
|
| 137 |
@dataclass
|
| 138 |
class ProductAlert:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
zone_id: str
|
| 140 |
product_actionable: bool
|
| 141 |
elevated: bool
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
def alert_to_bytes(alert: ProductAlert) -> bytes:
|
|
|
|
| 158 |
raw = json.dumps(alert.to_dict(), separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
| 159 |
if len(raw) > MAX_ALERT_BYTES:
|
| 160 |
raise ValueError(f"alert payload exceeds MAX_ALERT_BYTES ({len(raw)})")
|
|
|
|
| 162 |
|
| 163 |
|
| 164 |
def bytes_to_alert(data: bytes) -> ProductAlert:
|
|
|
|
| 165 |
try:
|
| 166 |
inner = unpack_wire(data)
|
| 167 |
except ValueError:
|
| 168 |
inner = data
|
|
|
|
| 169 |
try:
|
| 170 |
obj = json.loads(inner.decode("utf-8"))
|
| 171 |
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
|
|
| 206 |
trigger: str = "",
|
| 207 |
**extra: Any,
|
| 208 |
) -> ProductAlert:
|
|
|
|
| 209 |
return ProductAlert(
|
| 210 |
zone_id=zone_id,
|
| 211 |
product_actionable=product_actionable,
|
|
|
|
| 226 |
|
| 227 |
@runtime_checkable
|
| 228 |
class NodeTransport(Protocol):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
async def send_hidden_state(self, zone_id: str, hidden: bytes) -> None:
|
| 231 |
...
|
|
|
|
| 248 |
# ---------------------------------------------------------------------------
|
| 249 |
|
| 250 |
class LocalTransport:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
def __init__(self, *, compress: bool = False, ttl_sec: float = STATE_TTL_SEC) -> None:
|
| 253 |
self._hidden: Dict[str, Tuple[bytes, float]] = {}
|
|
|
|
| 256 |
self._ttl = float(ttl_sec)
|
| 257 |
|
| 258 |
def _pack(self, payload: bytes) -> bytes:
|
|
|
|
| 259 |
if len(payload) >= 2 and payload[0] == WIRE_VERSION and not _looks_like_legacy_hidden(payload):
|
| 260 |
return payload
|
| 261 |
return pack_wire(payload, compress=self._compress)
|
|
|
|
| 304 |
# ---------------------------------------------------------------------------
|
| 305 |
|
| 306 |
class MQTTTransport:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
def __init__(
|
| 309 |
self,
|
|
|
|
| 312 |
username: Optional[str] = None,
|
| 313 |
password: Optional[str] = None,
|
| 314 |
topic_root: str = DEFAULT_TOPIC_ROOT,
|
|
|
|
| 315 |
topic_prefix: Optional[str] = None,
|
| 316 |
*,
|
|
|
|
| 317 |
brokers: Optional[Sequence[Tuple[str, int]]] = None,
|
|
|
|
| 318 |
tls: bool = False,
|
| 319 |
tls_ca_certs: Optional[str] = None,
|
| 320 |
tls_certfile: Optional[str] = None,
|
| 321 |
tls_keyfile: Optional[str] = None,
|
| 322 |
tls_insecure: bool = False,
|
|
|
|
| 323 |
client_id: Optional[str] = None,
|
| 324 |
node_id: str = "node-0",
|
|
|
|
| 325 |
compress: bool = False,
|
| 326 |
ttl_sec: float = STATE_TTL_SEC,
|
| 327 |
keepalive: int = 60,
|
|
|
|
| 336 |
self.username = username
|
| 337 |
self.password = password
|
| 338 |
self.topic_root = topic_root.rstrip("/")
|
|
|
|
| 339 |
if topic_prefix is not None:
|
|
|
|
| 340 |
parts = topic_prefix.rstrip("/").split("/")
|
| 341 |
if parts and parts[-1] == "hidden":
|
| 342 |
self.topic_root = "/".join(parts[:-1]) or DEFAULT_TOPIC_ROOT
|
|
|
|
| 416 |
if self.client_id:
|
| 417 |
client_kwargs["client_id"] = self.client_id
|
| 418 |
|
|
|
|
| 419 |
self._mqttv5_active = False
|
| 420 |
if self.use_mqttv5 and hasattr(mqtt, "MQTTv5"):
|
| 421 |
protocol = mqtt.MQTTv5
|
|
|
|
| 423 |
|
| 424 |
client_kwargs["protocol"] = protocol
|
| 425 |
if callback_api is not None:
|
|
|
|
| 426 |
try:
|
| 427 |
client = mqtt.Client(
|
| 428 |
callback_api_version=callback_api.VERSION2,
|
|
|
|
| 448 |
if self.tls_insecure:
|
| 449 |
client.tls_insecure_set(True)
|
| 450 |
|
|
|
|
| 451 |
lwt_payload = json.dumps(
|
| 452 |
{"node_id": self.node_id, "status": "offline", "ts": time.time()},
|
| 453 |
separators=(",", ":"),
|
|
|
|
| 463 |
client.on_disconnect = self._on_disconnect
|
| 464 |
client.on_message = self._on_message
|
| 465 |
|
|
|
|
| 466 |
try:
|
| 467 |
client.reconnect_delay_set(min_delay=1, max_delay=30)
|
| 468 |
except Exception:
|
|
|
|
| 474 |
client.loop_start()
|
| 475 |
|
| 476 |
self._client = client
|
|
|
|
| 477 |
for _ in range(20):
|
| 478 |
if self._connected:
|
| 479 |
break
|
|
|
|
| 483 |
"MQTTTransport: connect attempted %s:%s mqttv5=%s connected=%s",
|
| 484 |
host, port, self._mqttv5_active, self._connected,
|
| 485 |
)
|
|
|
|
| 486 |
return True
|
| 487 |
|
| 488 |
def _teardown_client(self) -> None:
|
|
|
|
| 496 |
self._connected = False
|
| 497 |
|
| 498 |
def _on_connect(self, client, userdata, flags, reason_code, properties=None) -> None:
|
|
|
|
| 499 |
rc = reason_code
|
| 500 |
if hasattr(reason_code, "value"):
|
| 501 |
rc = reason_code.value
|
|
|
|
| 508 |
if not self._connected:
|
| 509 |
return
|
| 510 |
|
|
|
|
| 511 |
birth = json.dumps(
|
| 512 |
{"node_id": self.node_id, "status": "online", "ts": time.time()},
|
| 513 |
separators=(",", ":"),
|
|
|
|
| 522 |
except Exception as e:
|
| 523 |
logger.warning("MQTTTransport: birth publish failed: %s", e)
|
| 524 |
|
|
|
|
| 525 |
try:
|
| 526 |
client.subscribe(f"{self.topic_root}/hidden/#", qos=self.qos)
|
| 527 |
client.subscribe(f"{self.topic_root}/alert/#", qos=self.qos)
|
|
|
|
| 529 |
logger.warning("MQTTTransport: resubscribe failed: %s", e)
|
| 530 |
|
| 531 |
def _on_disconnect(self, client, userdata, flags, reason_code=None, properties=None) -> None:
|
|
|
|
| 532 |
self._connected = False
|
| 533 |
logger.warning("MQTTTransport: disconnected reason=%s", reason_code)
|
| 534 |
|
|
|
|
| 578 |
from paho.mqtt.packettypes import PacketTypes
|
| 579 |
|
| 580 |
props = Properties(PacketTypes.PUBLISH)
|
|
|
|
| 581 |
props.MessageExpiryInterval = int(self._ttl)
|
|
|
|
| 582 |
try:
|
| 583 |
props.UserProperty = [("wire_v", str(WIRE_VERSION))]
|
| 584 |
except Exception:
|
|
|
|
| 614 |
with self._lock:
|
| 615 |
item = self._hidden_rx.get(zone_id)
|
| 616 |
if item is None:
|
|
|
|
| 617 |
return await self._fallback.recv_hidden_state(zone_id)
|
| 618 |
|
| 619 |
data, ts = item
|
|
|
|
| 661 |
|
| 662 |
async def disconnect(self) -> None:
|
| 663 |
if self._client is not None:
|
|
|
|
| 664 |
try:
|
| 665 |
offline = json.dumps(
|
| 666 |
{"node_id": self.node_id, "status": "offline", "ts": time.time()},
|
|
|
|
| 686 |
use_mqtt: bool = False,
|
| 687 |
**mqtt_kwargs: Any,
|
| 688 |
) -> NodeTransport:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 689 |
if use_mqtt:
|
| 690 |
return MQTTTransport(**mqtt_kwargs)
|
| 691 |
return LocalTransport(
|
|
|
|
| 704 |
async def _run() -> None:
|
| 705 |
print("node_transport.py self-test")
|
| 706 |
|
|
|
|
| 707 |
raw = b"hello-alert-payload"
|
| 708 |
wire = pack_wire(raw, compress=False)
|
| 709 |
assert unpack_wire(wire) == raw
|
|
|
|
| 711 |
assert unpack_wire(wire_c) == raw * 20
|
| 712 |
print(" wire envelope OK")
|
| 713 |
|
|
|
|
|
|
|
| 714 |
n_layers, batch, hidden = 1, 1, 8
|
| 715 |
inner = struct.pack("<III", n_layers, batch, hidden) + (b"\x00\x00\x00\x00" * (n_layers * batch * hidden))
|
| 716 |
assert _looks_like_legacy_hidden(inner)
|
|
|
|
| 718 |
assert unpack_wire(w) == inner
|
| 719 |
print(" hidden wire OK")
|
| 720 |
|
|
|
|
| 721 |
alert = product_alert_from_risk(
|
| 722 |
"karawang_rice",
|
| 723 |
alert_level="warning",
|
|
|
|
| 736 |
assert abs(alert2.drought_risk - 0.42) < 1e-6
|
| 737 |
print(" alert serialisation OK")
|
| 738 |
|
|
|
|
| 739 |
tr = LocalTransport()
|
| 740 |
await tr.send_hidden_state("z1", inner)
|
| 741 |
got = await tr.recv_hidden_state("z1")
|
|
|
|
| 753 |
assert await tr.recv_alert("z1") is None
|
| 754 |
print(" LocalTransport protocol OK")
|
| 755 |
|
|
|
|
| 756 |
mqtt_tr = MQTTTransport(broker="127.0.0.1", port=1, node_id="test-node")
|
|
|
|
| 757 |
await mqtt_tr.send_alert("z2", ab)
|
| 758 |
got_b = await mqtt_tr.recv_alert("z2")
|
| 759 |
assert got_b is not None
|
|
|
|
| 762 |
assert await mqtt_tr.recv_hidden_state("z2") is not None
|
| 763 |
print(" MQTTTransport offline fallback OK")
|
| 764 |
|
|
|
|
| 765 |
t0 = create_node_transport()
|
| 766 |
assert isinstance(t0, LocalTransport)
|
| 767 |
t1 = create_node_transport(use_mqtt=True, broker="localhost")
|
|
|
|
| 775 |
|
| 776 |
if __name__ == "__main__":
|
| 777 |
logging.basicConfig(level=logging.INFO)
|
| 778 |
+
_self_test()
|