DHDRL commited on
Commit
910f8ae
·
verified ·
1 Parent(s): 79b2e3a

Update node_transport.py

Browse files
Files changed (1) hide show
  1. 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()