vnode 0.1.3__tar.gz → 0.1.5__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vnode
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Virtual Meshtastic node using mudp transport and meshdb storage.
5
5
  License-Expression: GPL-3.0-only
6
6
  License-File: LICENSE
@@ -82,14 +82,23 @@ node = VirtualNode("node.json")
82
82
  packet_id = node.send_text("!1234abcd", "hello")
83
83
  ```
84
84
 
85
+ The runtime also mirrors the common Meshtastic Python pubsub and method surface:
86
+
87
+ - topics: `meshtastic.connection.established`, `meshtastic.connection.lost`, `meshtastic.receive`, `meshtastic.receive.text`, `meshtastic.receive.position`, `meshtastic.receive.user`, `meshtastic.receive.data.<PORTNUM>`, `meshtastic.node.updated`, `meshtastic.log.line`
88
+ - methods: `receive()`, `unreceive()`, `close()`, `sendText()`, `sendData()`, `sendPosition()`, `getMyNodeInfo()`, `getMyUser()`, `getLongName()`
89
+
90
+ That means code shaped like the Meshtastic Python library can often run on `VirtualNode` with minimal changes.
91
+
85
92
  ## Examples
86
93
 
87
94
  Use these as small runnable references for common tasks:
88
95
 
89
96
  - `examples/autoresponder.py`: DM-only reply bot for inbound direct text messages
90
- - `examples/listen_packets.py`: packet logger for multicast traffic and decoded text
97
+ - `examples/listen_packets.py`: packet logger using the mirrored `meshtastic.receive` callback shape
91
98
  - `examples/send_dm.py`: minimal one-shot direct-message sender
92
- - `examples/library_embed.py`: minimal application-style embedding example using `VirtualNode` directly
99
+ - `examples/library_embed.py`: minimal application-style embedding example using both vnode and Meshtastic-style APIs
100
+ - `examples/meshtastic_compat.py`: minimal Meshtastic-style example using `meshtastic.*` topics and `sendText()`
101
+ - `examples/serial_or_vnode.py`: try a real serial node first, then fall back to `vnode` when no device is attached
93
102
  - `examples/watch_reliability.py`: watcher for ACK, NAK, retry, and retransmit-failure events
94
103
 
95
104
  ```bash
@@ -97,6 +106,8 @@ Use these as small runnable references for common tasks:
97
106
  .venv/bin/python examples/listen_packets.py
98
107
  .venv/bin/python examples/send_dm.py --to '!1234abcd' --message 'hello'
99
108
  .venv/bin/python examples/library_embed.py
109
+ .venv/bin/python examples/meshtastic_compat.py
110
+ .venv/bin/python examples/serial_or_vnode.py
100
111
  .venv/bin/python examples/watch_reliability.py --to '!1234abcd' --message 'hello'
101
112
  ```
102
113
 
@@ -58,14 +58,23 @@ node = VirtualNode("node.json")
58
58
  packet_id = node.send_text("!1234abcd", "hello")
59
59
  ```
60
60
 
61
+ The runtime also mirrors the common Meshtastic Python pubsub and method surface:
62
+
63
+ - topics: `meshtastic.connection.established`, `meshtastic.connection.lost`, `meshtastic.receive`, `meshtastic.receive.text`, `meshtastic.receive.position`, `meshtastic.receive.user`, `meshtastic.receive.data.<PORTNUM>`, `meshtastic.node.updated`, `meshtastic.log.line`
64
+ - methods: `receive()`, `unreceive()`, `close()`, `sendText()`, `sendData()`, `sendPosition()`, `getMyNodeInfo()`, `getMyUser()`, `getLongName()`
65
+
66
+ That means code shaped like the Meshtastic Python library can often run on `VirtualNode` with minimal changes.
67
+
61
68
  ## Examples
62
69
 
63
70
  Use these as small runnable references for common tasks:
64
71
 
65
72
  - `examples/autoresponder.py`: DM-only reply bot for inbound direct text messages
66
- - `examples/listen_packets.py`: packet logger for multicast traffic and decoded text
73
+ - `examples/listen_packets.py`: packet logger using the mirrored `meshtastic.receive` callback shape
67
74
  - `examples/send_dm.py`: minimal one-shot direct-message sender
68
- - `examples/library_embed.py`: minimal application-style embedding example using `VirtualNode` directly
75
+ - `examples/library_embed.py`: minimal application-style embedding example using both vnode and Meshtastic-style APIs
76
+ - `examples/meshtastic_compat.py`: minimal Meshtastic-style example using `meshtastic.*` topics and `sendText()`
77
+ - `examples/serial_or_vnode.py`: try a real serial node first, then fall back to `vnode` when no device is attached
69
78
  - `examples/watch_reliability.py`: watcher for ACK, NAK, retry, and retransmit-failure events
70
79
 
71
80
  ```bash
@@ -73,6 +82,8 @@ Use these as small runnable references for common tasks:
73
82
  .venv/bin/python examples/listen_packets.py
74
83
  .venv/bin/python examples/send_dm.py --to '!1234abcd' --message 'hello'
75
84
  .venv/bin/python examples/library_embed.py
85
+ .venv/bin/python examples/meshtastic_compat.py
86
+ .venv/bin/python examples/serial_or_vnode.py
76
87
  .venv/bin/python examples/watch_reliability.py --to '!1234abcd' --message 'hello'
77
88
  ```
78
89
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "vnode"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  description = "Virtual Meshtastic node using mudp transport and meshdb storage."
5
5
  authors = [
6
6
  {name = "Ben Lipsey",email = "ben@pdxlocations.com"}
@@ -138,12 +138,27 @@ class NodeConfig:
138
138
  if value not in (0, 0xFFFFFFFF):
139
139
  return f"!{value:08x}"
140
140
 
141
+ @staticmethod
142
+ def _generated_names(node_id: str) -> Dict[str, str]:
143
+ suffix = str(node_id).strip().lower()[-4:]
144
+ return {
145
+ "long_name": f"Meshtastic {suffix}",
146
+ "short_name": suffix,
147
+ }
148
+
141
149
  @classmethod
142
150
  def _populate_generated_defaults(cls, payload: Dict[str, Any]) -> bool:
143
151
  changed = False
144
152
  if not str(payload.get("node_id", "")).strip():
145
153
  payload["node_id"] = cls._generate_node_id()
146
154
  changed = True
155
+ generated_names = cls._generated_names(str(payload["node_id"]))
156
+ if not str(payload.get("long_name", "")).strip():
157
+ payload["long_name"] = generated_names["long_name"]
158
+ changed = True
159
+ if not str(payload.get("short_name", "")).strip():
160
+ payload["short_name"] = generated_names["short_name"]
161
+ changed = True
147
162
  return changed
148
163
 
149
164
  def save(self, path: Union[str, Path]) -> None:
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "node_id": "",
3
- "long_name": "Virtual Meshtastic Node",
4
- "short_name": "VND",
3
+ "long_name": "",
4
+ "short_name": "",
5
5
  "hw_model": "ANDROID_SIM",
6
6
  "role": "CLIENT",
7
7
  "is_licensed": false,
@@ -5,11 +5,13 @@ import sqlite3
5
5
  import threading
6
6
  import time
7
7
  from pathlib import Path
8
- from typing import Any, Dict, Optional, Union
8
+ from types import SimpleNamespace
9
+ from typing import Any, Callable, Dict, Optional, Union
9
10
 
10
11
  import meshdb
12
+ from google.protobuf.json_format import MessageToDict
11
13
  from google.protobuf.message import DecodeError
12
- from meshtastic import BROADCAST_NUM, config_pb2, mesh_pb2, portnums_pb2
14
+ from meshtastic import BROADCAST_ADDR, BROADCAST_NUM, config_pb2, mesh_pb2, portnums_pb2
13
15
  from mudp import UDPPacketStream
14
16
  from mudp.encryption import encrypt_packet as mudp_encrypt_packet
15
17
  from mudp.reliability import is_ack, is_nak, publish_ack, register_pending_ack, send_ack
@@ -62,6 +64,7 @@ def resolve_role(value: Union[str, int]) -> int:
62
64
 
63
65
 
64
66
  class VirtualNode:
67
+ RECEIVE_TOPIC = "mesh.rx.packet"
65
68
  PACKET_TOPIC = "mesh.rx.unique_packet"
66
69
  DUPLICATE_TOPIC = "mesh.rx.duplicate"
67
70
 
@@ -81,6 +84,11 @@ class VirtualNode:
81
84
  self._last_nodeinfo_sent_monotonic = 0.0
82
85
  self._last_position_reply_monotonic = 0.0
83
86
  self._last_nodeinfo_seen: Dict[int, int] = {}
87
+ self._response_handlers: Dict[int, tuple[Callable[[Dict[str, Any]], Any], bool]] = {}
88
+ self.nodesByNum: Dict[int, Dict[str, Any]] = {}
89
+ self.nodes: Dict[str, Dict[str, Any]] = {}
90
+ self.myInfo = SimpleNamespace(my_node_num=self.node_num)
91
+ self._connected = False
84
92
 
85
93
  self._ensure_security_keys()
86
94
  self._write_public_key_file()
@@ -121,6 +129,7 @@ class VirtualNode:
121
129
  is_licensed=int(self.config.is_licensed),
122
130
  public_key=self._public_key_b64,
123
131
  )
132
+ self._upsert_local_node_cache()
124
133
 
125
134
  def _configure_mudp_globals(self) -> None:
126
135
  mudp_node.node_id = self.config.node_id
@@ -148,6 +157,10 @@ class VirtualNode:
148
157
  parse_payload=False,
149
158
  )
150
159
  self.stream.start()
160
+ self._connected = True
161
+ self._publish_log_line(f"virtual node connected: {self.config.node_id}")
162
+ pub.sendMessage("meshtastic.connection.established", interface=self)
163
+ self._publish_node_updated(self.nodesByNum[self.node_num])
151
164
  if self.config.broadcasts.send_startup_nodeinfo:
152
165
  self.send_nodeinfo()
153
166
  self._broadcast_thread = threading.Thread(
@@ -159,6 +172,8 @@ class VirtualNode:
159
172
 
160
173
  def stop(self) -> None:
161
174
  self._stop.set()
175
+ was_connected = self._connected
176
+ self._connected = False
162
177
  if self.stream is not None:
163
178
  self.stream.stop()
164
179
  self.stream = None
@@ -172,6 +187,9 @@ class VirtualNode:
172
187
  pass
173
188
  if self._broadcast_thread and self._broadcast_thread.is_alive():
174
189
  self._broadcast_thread.join(timeout=2.0)
190
+ if was_connected:
191
+ self._publish_log_line(f"virtual node disconnected: {self.config.node_id}")
192
+ pub.sendMessage("meshtastic.connection.lost", interface=self)
175
193
 
176
194
  def run_forever(self) -> None:
177
195
  self.start()
@@ -181,6 +199,133 @@ class VirtualNode:
181
199
  finally:
182
200
  self.stop()
183
201
 
202
+ def receive(self, callback: Callable[..., Any]) -> None:
203
+ pub.subscribe(callback, "meshtastic.receive")
204
+
205
+ def unreceive(self, callback: Callable[..., Any]) -> None:
206
+ try:
207
+ pub.unsubscribe(callback, "meshtastic.receive")
208
+ except KeyError:
209
+ pass
210
+
211
+ def close(self) -> None:
212
+ self.stop()
213
+
214
+ def sendText(
215
+ self,
216
+ text: str,
217
+ destinationId: Union[int, str] = BROADCAST_ADDR,
218
+ wantAck: bool = False,
219
+ wantResponse: bool = False,
220
+ onResponse: Optional[Callable[[dict], Any]] = None,
221
+ channelIndex: int = 0,
222
+ portNum: int = portnums_pb2.PortNum.TEXT_MESSAGE_APP,
223
+ replyId: Optional[int] = None,
224
+ hopLimit: Optional[int] = None,
225
+ ) -> mesh_pb2.MeshPacket:
226
+ return self.sendData(
227
+ text.encode("utf-8"),
228
+ destinationId=destinationId,
229
+ portNum=portNum,
230
+ wantAck=wantAck,
231
+ wantResponse=wantResponse,
232
+ onResponse=onResponse,
233
+ channelIndex=channelIndex,
234
+ hopLimit=hopLimit,
235
+ replyId=replyId,
236
+ )
237
+
238
+ def sendData(
239
+ self,
240
+ data: Any,
241
+ destinationId: Union[int, str] = BROADCAST_ADDR,
242
+ portNum: int = portnums_pb2.PortNum.PRIVATE_APP,
243
+ wantAck: bool = False,
244
+ wantResponse: bool = False,
245
+ onResponse: Optional[Callable[[dict], Any]] = None,
246
+ onResponseAckPermitted: bool = False,
247
+ channelIndex: int = 0,
248
+ hopLimit: Optional[int] = None,
249
+ pkiEncrypted: bool = False,
250
+ publicKey: Optional[bytes] = None,
251
+ priority: int = mesh_pb2.MeshPacket.Priority.RELIABLE,
252
+ replyId: Optional[int] = None,
253
+ ) -> mesh_pb2.MeshPacket:
254
+ if channelIndex != 0:
255
+ raise ValueError("vnode currently supports only channelIndex=0")
256
+ if getattr(data, "SerializeToString", None):
257
+ data = data.SerializeToString()
258
+ payload = bytes(data)
259
+ destination_num = self._resolve_destination(destinationId)
260
+
261
+ decoded = mesh_pb2.Data()
262
+ decoded.portnum = int(portNum)
263
+ decoded.payload = payload
264
+ decoded.source = self.node_num
265
+ decoded.dest = destination_num
266
+ decoded.want_response = bool(wantResponse)
267
+ if replyId is not None:
268
+ decoded.reply_id = int(replyId)
269
+
270
+ packet = self._send_packet(
271
+ decoded,
272
+ destination=destination_num,
273
+ force_pki=bool(pkiEncrypted),
274
+ hop_limit=hopLimit,
275
+ hop_start=None,
276
+ want_ack=bool(wantAck),
277
+ priority=priority,
278
+ remote_public_key=publicKey,
279
+ )
280
+ if onResponse is not None:
281
+ self._response_handlers[int(packet.id)] = (onResponse, bool(onResponseAckPermitted))
282
+ return self._packet_for_return(packet, decoded)
283
+
284
+ def sendPosition(
285
+ self,
286
+ latitude: float = 0.0,
287
+ longitude: float = 0.0,
288
+ altitude: int = 0,
289
+ destinationId: Union[int, str] = BROADCAST_ADDR,
290
+ wantAck: bool = False,
291
+ wantResponse: bool = False,
292
+ channelIndex: int = 0,
293
+ hopLimit: Optional[int] = None,
294
+ ) -> mesh_pb2.MeshPacket:
295
+ position = mesh_pb2.Position()
296
+ if latitude != 0.0:
297
+ position.latitude_i = int(latitude / 1e-7)
298
+ if longitude != 0.0:
299
+ position.longitude_i = int(longitude / 1e-7)
300
+ if altitude != 0:
301
+ position.altitude = int(altitude)
302
+ return self.sendData(
303
+ position,
304
+ destinationId=destinationId,
305
+ portNum=portnums_pb2.PortNum.POSITION_APP,
306
+ wantAck=wantAck,
307
+ wantResponse=wantResponse,
308
+ channelIndex=channelIndex,
309
+ hopLimit=hopLimit,
310
+ )
311
+
312
+ def getMyNodeInfo(self) -> Optional[Dict[str, Any]]:
313
+ return self.nodesByNum.get(self.node_num)
314
+
315
+ def getMyUser(self) -> Optional[Dict[str, Any]]:
316
+ node_info = self.getMyNodeInfo()
317
+ if node_info is None:
318
+ return None
319
+ user = node_info.get("user")
320
+ return user if isinstance(user, dict) else None
321
+
322
+ def getLongName(self) -> Optional[str]:
323
+ user = self.getMyUser()
324
+ if user is None:
325
+ return None
326
+ value = user.get("longName")
327
+ return str(value) if value is not None else None
328
+
184
329
  def connect_send_socket(self) -> None:
185
330
  if getattr(conn, "socket", None) is None:
186
331
  conn.setup_multicast(self.config.udp.mcast_group, int(self.config.udp.mcast_port))
@@ -381,12 +526,36 @@ class VirtualNode:
381
526
  hop_start: Optional[int] = None,
382
527
  want_ack: bool = False,
383
528
  ) -> int:
529
+ packet = self._send_packet(
530
+ data,
531
+ destination=destination,
532
+ force_pki=force_pki,
533
+ hop_limit=hop_limit,
534
+ hop_start=hop_start,
535
+ want_ack=want_ack,
536
+ )
537
+ return packet.id
538
+
539
+ def _send_packet(
540
+ self,
541
+ data: mesh_pb2.Data,
542
+ *,
543
+ destination: int,
544
+ force_pki: bool,
545
+ hop_limit: Optional[int] = None,
546
+ hop_start: Optional[int] = None,
547
+ want_ack: bool = False,
548
+ priority: Optional[int] = None,
549
+ remote_public_key: Optional[bytes] = None,
550
+ ) -> mesh_pb2.MeshPacket:
384
551
  self.connect_send_socket()
385
552
  packet = mesh_pb2.MeshPacket()
386
553
  packet.id = self._next_packet_id()
387
554
  setattr(packet, "from", self.node_num)
388
555
  packet.to = int(destination)
389
556
  packet.want_ack = bool(want_ack)
557
+ if priority is not None:
558
+ packet.priority = int(priority)
390
559
  resolved_hop_limit = int(self.config.hop_limit if hop_limit is None else hop_limit)
391
560
  resolved_hop_start = int(resolved_hop_limit if hop_start is None else hop_start)
392
561
  if resolved_hop_start < resolved_hop_limit:
@@ -395,14 +564,15 @@ class VirtualNode:
395
564
  packet.hop_start = resolved_hop_start
396
565
 
397
566
  if force_pki:
398
- remote_public_key = self._lookup_public_key(destination)
399
- if remote_public_key is None:
567
+ key_bytes = remote_public_key if remote_public_key is not None else self._lookup_public_key(destination)
568
+ if key_bytes is None:
400
569
  raise ValueError(f"Destination {destination} does not have a stored public key in meshdb")
401
570
  packet.channel = 0
402
571
  packet.pki_encrypted = True
572
+ packet.public_key = key_bytes
403
573
  packet.encrypted = encrypt_dm(
404
574
  sender_private_key=b64_decode(self.config.security.private_key),
405
- receiver_public_key=remote_public_key,
575
+ receiver_public_key=key_bytes,
406
576
  packet_id=packet.id,
407
577
  from_node=self.node_num,
408
578
  plaintext=data.SerializeToString(),
@@ -419,20 +589,21 @@ class VirtualNode:
419
589
  register_pending_ack(packet, raw_packet)
420
590
  conn.sendto(raw_packet, (conn.host, conn.port))
421
591
  self._persist_outbound_packet(packet, data)
422
- return packet.id
592
+ return packet
423
593
 
424
- def _handle_raw_packet(self, packet: mesh_pb2.MeshPacket, addr: Any = None) -> None:
425
- del addr
594
+ def _handle_raw_packet(self, packet: mesh_pb2.MeshPacket, **kwargs: Any) -> None:
595
+ del kwargs
426
596
  if not getattr(packet, "rx_time", 0):
427
597
  packet.rx_time = int(time.time())
428
598
 
429
599
  if not packet.HasField("decoded"):
430
600
  self._try_decode_pki(packet)
601
+ self._publish_meshtastic_receive(packet)
431
602
  self._maybe_send_ack(packet)
432
603
  self._maybe_send_response(packet)
433
604
 
434
- def _handle_unique_packet(self, packet: mesh_pb2.MeshPacket, addr: Any = None) -> None:
435
- del addr
605
+ def _handle_unique_packet(self, packet: mesh_pb2.MeshPacket, **kwargs: Any) -> None:
606
+ del kwargs
436
607
  if not getattr(packet, "rx_time", 0):
437
608
  packet.rx_time = int(time.time())
438
609
  if not packet.HasField("decoded"):
@@ -441,6 +612,186 @@ class VirtualNode:
441
612
  return
442
613
 
443
614
  self._persist_packet(packet)
615
+ self._update_node_cache_from_packet(packet)
616
+
617
+ def _packet_to_receive_dict(self, packet: mesh_pb2.MeshPacket) -> Dict[str, Any]:
618
+ as_dict = MessageToDict(packet)
619
+ as_dict["raw"] = packet
620
+
621
+ if "from" not in as_dict:
622
+ as_dict["from"] = 0
623
+ if "to" not in as_dict:
624
+ as_dict["to"] = 0
625
+
626
+ as_dict["fromId"] = self._node_num_to_id(int(as_dict["from"]), is_dest=False)
627
+ as_dict["toId"] = self._node_num_to_id(int(as_dict["to"]), is_dest=True)
628
+
629
+ if "decoded" in as_dict and packet.HasField("decoded"):
630
+ as_dict["decoded"]["payload"] = packet.decoded.payload
631
+ if packet.decoded.portnum in TEXT_PORTNUMS:
632
+ as_dict["decoded"]["text"] = packet.decoded.payload.decode("utf-8", "ignore")
633
+ elif packet.decoded.portnum == portnums_pb2.PortNum.POSITION_APP:
634
+ position = mesh_pb2.Position()
635
+ try:
636
+ position.ParseFromString(packet.decoded.payload)
637
+ position_dict = MessageToDict(position)
638
+ position_dict["raw"] = position
639
+ as_dict["decoded"]["position"] = position_dict
640
+ except DecodeError:
641
+ pass
642
+ elif packet.decoded.portnum == portnums_pb2.PortNum.NODEINFO_APP:
643
+ user = mesh_pb2.User()
644
+ try:
645
+ user.ParseFromString(packet.decoded.payload)
646
+ user_dict = MessageToDict(user)
647
+ user_dict["raw"] = user
648
+ as_dict["decoded"]["user"] = user_dict
649
+ except DecodeError:
650
+ pass
651
+
652
+ return as_dict
653
+
654
+ def _node_num_to_id(self, node_num: int, *, is_dest: bool) -> Optional[str]:
655
+ if node_num == BROADCAST_NUM:
656
+ return "^all" if is_dest else "Unknown"
657
+ if node_num == self.node_num:
658
+ return self.config.node_id
659
+
660
+ return f"!{int(node_num):08x}"
661
+
662
+ def _packet_for_return(self, packet: mesh_pb2.MeshPacket, data: mesh_pb2.Data) -> mesh_pb2.MeshPacket:
663
+ returned = mesh_pb2.MeshPacket()
664
+ returned.CopyFrom(packet)
665
+ returned.decoded.CopyFrom(data)
666
+ return returned
667
+
668
+ def _publish_meshtastic_receive(self, packet: mesh_pb2.MeshPacket) -> None:
669
+ if int(getattr(packet, "from", 0) or 0) == self.node_num:
670
+ return
671
+ packet_dict = self._packet_to_receive_dict(packet)
672
+ self._dispatch_response_handler(packet, packet_dict)
673
+ topic = "meshtastic.receive"
674
+ if packet.HasField("decoded"):
675
+ portnum_name = str(packet_dict.get("decoded", {}).get("portnum", "UNKNOWN_APP"))
676
+ topic = f"meshtastic.receive.data.{portnum_name}"
677
+ if packet.decoded.portnum in TEXT_PORTNUMS:
678
+ topic = "meshtastic.receive.text"
679
+ elif packet.decoded.portnum == portnums_pb2.PortNum.POSITION_APP:
680
+ topic = "meshtastic.receive.position"
681
+ elif packet.decoded.portnum == portnums_pb2.PortNum.NODEINFO_APP:
682
+ topic = "meshtastic.receive.user"
683
+ pub.sendMessage(topic, packet=packet_dict, interface=self)
684
+
685
+ def _dispatch_response_handler(self, packet: mesh_pb2.MeshPacket, packet_dict: Dict[str, Any]) -> None:
686
+ decoded = packet_dict.get("decoded")
687
+ if not isinstance(decoded, dict):
688
+ return
689
+ request_id = decoded.get("requestId")
690
+ if request_id is None:
691
+ return
692
+ try:
693
+ request_id_num = int(request_id)
694
+ except (TypeError, ValueError):
695
+ return
696
+ handler = self._response_handlers.get(request_id_num)
697
+ if handler is None:
698
+ return
699
+ callback, ack_permitted = handler
700
+ if is_ack(packet) and not ack_permitted and getattr(callback, "__name__", "") != "onAckNak":
701
+ return
702
+ self._response_handlers.pop(request_id_num, None)
703
+ callback(packet_dict)
704
+
705
+ def _upsert_local_node_cache(self) -> None:
706
+ node = self._default_node_dict(self.node_num)
707
+ node["user"].update(
708
+ {
709
+ "id": self.config.node_id,
710
+ "longName": self.config.long_name,
711
+ "shortName": self.config.short_name,
712
+ "hwModel": str(self.config.hw_model),
713
+ "role": str(self.config.role),
714
+ "publicKey": self._public_key_b64,
715
+ "isLicensed": bool(self.config.is_licensed),
716
+ }
717
+ )
718
+ if (
719
+ self.config.position.enabled
720
+ and self.config.position.latitude is not None
721
+ and self.config.position.longitude is not None
722
+ ):
723
+ node["position"] = {
724
+ "latitudeI": int(float(self.config.position.latitude) * 1e7),
725
+ "longitudeI": int(float(self.config.position.longitude) * 1e7),
726
+ }
727
+ if self.config.position.altitude is not None:
728
+ node["position"]["altitude"] = int(self.config.position.altitude)
729
+ self.nodesByNum[self.node_num] = node
730
+ self.nodes[self.config.node_id] = node
731
+
732
+ def _default_node_dict(self, node_num: int) -> Dict[str, Any]:
733
+ node_id = f"!{int(node_num):08x}"
734
+ return {
735
+ "num": int(node_num),
736
+ "user": {
737
+ "id": node_id,
738
+ "longName": f"Meshtastic {node_id[-4:]}",
739
+ "shortName": node_id[-4:],
740
+ "hwModel": "UNSET",
741
+ },
742
+ }
743
+
744
+ def _update_node_cache_from_packet(self, packet: mesh_pb2.MeshPacket) -> None:
745
+ sender = int(getattr(packet, "from", 0) or 0)
746
+ if sender == 0:
747
+ return
748
+ packet_dict = self._packet_to_receive_dict(packet)
749
+ node = dict(self.nodesByNum.get(sender, self._default_node_dict(sender)))
750
+ changed = sender not in self.nodesByNum
751
+
752
+ rx_time = packet_dict.get("rxTime")
753
+ if rx_time is not None and node.get("lastHeard") != rx_time:
754
+ node["lastHeard"] = rx_time
755
+ changed = True
756
+ snr = packet_dict.get("rxSnr")
757
+ if snr is not None and node.get("snr") != snr:
758
+ node["snr"] = snr
759
+ changed = True
760
+
761
+ decoded = packet_dict.get("decoded")
762
+ if isinstance(decoded, dict):
763
+ user = decoded.get("user")
764
+ if isinstance(user, dict):
765
+ clean_user = dict(user)
766
+ clean_user.pop("raw", None)
767
+ existing_user = node.get("user", {})
768
+ merged_user = dict(existing_user) if isinstance(existing_user, dict) else {}
769
+ merged_user.update(clean_user)
770
+ if merged_user != existing_user:
771
+ node["user"] = merged_user
772
+ changed = True
773
+ position = decoded.get("position")
774
+ if isinstance(position, dict):
775
+ clean_position = dict(position)
776
+ clean_position.pop("raw", None)
777
+ if clean_position != node.get("position"):
778
+ node["position"] = clean_position
779
+ changed = True
780
+
781
+ self.nodesByNum[sender] = node
782
+ user = node.get("user")
783
+ if isinstance(user, dict):
784
+ node_id = user.get("id")
785
+ if node_id:
786
+ self.nodes[str(node_id)] = node
787
+ if changed:
788
+ self._publish_node_updated(node)
789
+
790
+ def _publish_node_updated(self, node: Dict[str, Any]) -> None:
791
+ pub.sendMessage("meshtastic.node.updated", node=node, interface=self)
792
+
793
+ def _publish_log_line(self, line: str) -> None:
794
+ pub.sendMessage("meshtastic.log.line", line=str(line), interface=self)
444
795
 
445
796
  def _try_decode_pki(self, packet: mesh_pb2.MeshPacket) -> bool:
446
797
  if packet.channel != 0 or packet.to != self.node_num or not packet.encrypted:
@@ -615,6 +966,8 @@ class VirtualNode:
615
966
  if isinstance(destination, int):
616
967
  return destination
617
968
  text = str(destination).strip()
969
+ if text == BROADCAST_ADDR:
970
+ return BROADCAST_NUM
618
971
  if text.startswith("!"):
619
972
  return parse_node_id(text)
620
973
  resolved = meshdb.get_node_num(text, owner_node_num=self.node_num, db_path=self.meshdb_path)
File without changes
File without changes
File without changes
File without changes
File without changes