vnode 0.1.4__tar.gz → 0.1.6__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.4
3
+ Version: 0.1.6
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
@@ -39,6 +39,8 @@ If `node.json` does not exist yet, the runtime will create it automatically from
39
39
 
40
40
  If the template leaves `node_id` blank, the runtime will generate and persist a random
41
41
  Meshtastic-style node ID when it creates or first loads `node.json`.
42
+ If `long_name` or `short_name` are blank, the runtime will also generate them from the
43
+ last 4 hex characters of the node ID, for example `Meshtastic 691c` and `691c`.
42
44
 
43
45
  The runtime will generate and persist a PKI private key into `node.json` on first run if
44
46
  the private key is blank.
@@ -82,32 +84,25 @@ node = VirtualNode("node.json")
82
84
  packet_id = node.send_text("!1234abcd", "hello")
83
85
  ```
84
86
 
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
-
92
87
  ## Examples
93
88
 
94
89
  Use these as small runnable references for common tasks:
95
90
 
96
91
  - `examples/autoresponder.py`: DM-only reply bot for inbound direct text messages
97
- - `examples/listen_packets.py`: packet logger using the mirrored `meshtastic.receive` callback shape
92
+ - `examples/basic_subscriptions.py`: minimal `node.receive()` subscription example
93
+ - `examples/listen_packets.py`: packet logger for multicast traffic and decoded text
94
+ - `examples/serial_or_vnode.py`: try a serial Meshtastic node first, then fall back to `VirtualNode`
98
95
  - `examples/send_dm.py`: minimal one-shot direct-message sender
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
96
+ - `examples/library_embed.py`: minimal application-style embedding example using `VirtualNode` directly
102
97
  - `examples/watch_reliability.py`: watcher for ACK, NAK, retry, and retransmit-failure events
103
98
 
104
99
  ```bash
105
100
  .venv/bin/python examples/autoresponder.py
101
+ .venv/bin/python examples/basic_subscriptions.py
106
102
  .venv/bin/python examples/listen_packets.py
103
+ .venv/bin/python examples/serial_or_vnode.py
107
104
  .venv/bin/python examples/send_dm.py --to '!1234abcd' --message 'hello'
108
105
  .venv/bin/python examples/library_embed.py
109
- .venv/bin/python examples/meshtastic_compat.py
110
- .venv/bin/python examples/serial_or_vnode.py
111
106
  .venv/bin/python examples/watch_reliability.py --to '!1234abcd' --message 'hello'
112
107
  ```
113
108
 
@@ -15,6 +15,8 @@ If `node.json` does not exist yet, the runtime will create it automatically from
15
15
 
16
16
  If the template leaves `node_id` blank, the runtime will generate and persist a random
17
17
  Meshtastic-style node ID when it creates or first loads `node.json`.
18
+ If `long_name` or `short_name` are blank, the runtime will also generate them from the
19
+ last 4 hex characters of the node ID, for example `Meshtastic 691c` and `691c`.
18
20
 
19
21
  The runtime will generate and persist a PKI private key into `node.json` on first run if
20
22
  the private key is blank.
@@ -58,32 +60,25 @@ node = VirtualNode("node.json")
58
60
  packet_id = node.send_text("!1234abcd", "hello")
59
61
  ```
60
62
 
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
-
68
63
  ## Examples
69
64
 
70
65
  Use these as small runnable references for common tasks:
71
66
 
72
67
  - `examples/autoresponder.py`: DM-only reply bot for inbound direct text messages
73
- - `examples/listen_packets.py`: packet logger using the mirrored `meshtastic.receive` callback shape
68
+ - `examples/basic_subscriptions.py`: minimal `node.receive()` subscription example
69
+ - `examples/listen_packets.py`: packet logger for multicast traffic and decoded text
70
+ - `examples/serial_or_vnode.py`: try a serial Meshtastic node first, then fall back to `VirtualNode`
74
71
  - `examples/send_dm.py`: minimal one-shot direct-message sender
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
72
+ - `examples/library_embed.py`: minimal application-style embedding example using `VirtualNode` directly
78
73
  - `examples/watch_reliability.py`: watcher for ACK, NAK, retry, and retransmit-failure events
79
74
 
80
75
  ```bash
81
76
  .venv/bin/python examples/autoresponder.py
77
+ .venv/bin/python examples/basic_subscriptions.py
82
78
  .venv/bin/python examples/listen_packets.py
79
+ .venv/bin/python examples/serial_or_vnode.py
83
80
  .venv/bin/python examples/send_dm.py --to '!1234abcd' --message 'hello'
84
81
  .venv/bin/python examples/library_embed.py
85
- .venv/bin/python examples/meshtastic_compat.py
86
- .venv/bin/python examples/serial_or_vnode.py
87
82
  .venv/bin/python examples/watch_reliability.py --to '!1234abcd' --message 'hello'
88
83
  ```
89
84
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "vnode"
3
- version = "0.1.4"
3
+ version = "0.1.6"
4
4
  description = "Virtual Meshtastic node using mudp transport and meshdb storage."
5
5
  authors = [
6
6
  {name = "Ben Lipsey",email = "ben@pdxlocations.com"}
@@ -5,13 +5,11 @@ import sqlite3
5
5
  import threading
6
6
  import time
7
7
  from pathlib import Path
8
- from types import SimpleNamespace
9
- from typing import Any, Callable, Dict, Optional, Union
8
+ from typing import Any, Dict, Optional, Union
10
9
 
11
10
  import meshdb
12
- from google.protobuf.json_format import MessageToDict
13
11
  from google.protobuf.message import DecodeError
14
- from meshtastic import BROADCAST_ADDR, BROADCAST_NUM, config_pb2, mesh_pb2, portnums_pb2
12
+ from meshtastic import BROADCAST_NUM, config_pb2, mesh_pb2, portnums_pb2
15
13
  from mudp import UDPPacketStream
16
14
  from mudp.encryption import encrypt_packet as mudp_encrypt_packet
17
15
  from mudp.reliability import is_ack, is_nak, publish_ack, register_pending_ack, send_ack
@@ -64,9 +62,9 @@ def resolve_role(value: Union[str, int]) -> int:
64
62
 
65
63
 
66
64
  class VirtualNode:
67
- RECEIVE_TOPIC = "mesh.rx.packet"
68
65
  PACKET_TOPIC = "mesh.rx.unique_packet"
69
66
  DUPLICATE_TOPIC = "mesh.rx.duplicate"
67
+ RECEIVE_TOPIC = "meshtastic.receive"
70
68
 
71
69
  def __init__(self, config_path: Union[str, Path] = "node.json") -> None:
72
70
  self.config_path = Path(config_path).resolve()
@@ -84,11 +82,6 @@ class VirtualNode:
84
82
  self._last_nodeinfo_sent_monotonic = 0.0
85
83
  self._last_position_reply_monotonic = 0.0
86
84
  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
92
85
 
93
86
  self._ensure_security_keys()
94
87
  self._write_public_key_file()
@@ -129,7 +122,6 @@ class VirtualNode:
129
122
  is_licensed=int(self.config.is_licensed),
130
123
  public_key=self._public_key_b64,
131
124
  )
132
- self._upsert_local_node_cache()
133
125
 
134
126
  def _configure_mudp_globals(self) -> None:
135
127
  mudp_node.node_id = self.config.node_id
@@ -157,10 +149,6 @@ class VirtualNode:
157
149
  parse_payload=False,
158
150
  )
159
151
  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])
164
152
  if self.config.broadcasts.send_startup_nodeinfo:
165
153
  self.send_nodeinfo()
166
154
  self._broadcast_thread = threading.Thread(
@@ -172,8 +160,6 @@ class VirtualNode:
172
160
 
173
161
  def stop(self) -> None:
174
162
  self._stop.set()
175
- was_connected = self._connected
176
- self._connected = False
177
163
  if self.stream is not None:
178
164
  self.stream.stop()
179
165
  self.stream = None
@@ -187,9 +173,6 @@ class VirtualNode:
187
173
  pass
188
174
  if self._broadcast_thread and self._broadcast_thread.is_alive():
189
175
  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)
193
176
 
194
177
  def run_forever(self) -> None:
195
178
  self.start()
@@ -199,133 +182,15 @@ class VirtualNode:
199
182
  finally:
200
183
  self.stop()
201
184
 
202
- def receive(self, callback: Callable[..., Any]) -> None:
203
- pub.subscribe(callback, "meshtastic.receive")
185
+ def receive(self, callback) -> None:
186
+ pub.subscribe(callback, self.RECEIVE_TOPIC)
204
187
 
205
- def unreceive(self, callback: Callable[..., Any]) -> None:
188
+ def unreceive(self, callback) -> None:
206
189
  try:
207
- pub.unsubscribe(callback, "meshtastic.receive")
190
+ pub.unsubscribe(callback, self.RECEIVE_TOPIC)
208
191
  except KeyError:
209
192
  pass
210
193
 
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
-
329
194
  def connect_send_socket(self) -> None:
330
195
  if getattr(conn, "socket", None) is None:
331
196
  conn.setup_multicast(self.config.udp.mcast_group, int(self.config.udp.mcast_port))
@@ -526,36 +391,12 @@ class VirtualNode:
526
391
  hop_start: Optional[int] = None,
527
392
  want_ack: bool = False,
528
393
  ) -> 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:
551
394
  self.connect_send_socket()
552
395
  packet = mesh_pb2.MeshPacket()
553
396
  packet.id = self._next_packet_id()
554
397
  setattr(packet, "from", self.node_num)
555
398
  packet.to = int(destination)
556
399
  packet.want_ack = bool(want_ack)
557
- if priority is not None:
558
- packet.priority = int(priority)
559
400
  resolved_hop_limit = int(self.config.hop_limit if hop_limit is None else hop_limit)
560
401
  resolved_hop_start = int(resolved_hop_limit if hop_start is None else hop_start)
561
402
  if resolved_hop_start < resolved_hop_limit:
@@ -564,15 +405,14 @@ class VirtualNode:
564
405
  packet.hop_start = resolved_hop_start
565
406
 
566
407
  if force_pki:
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:
408
+ remote_public_key = self._lookup_public_key(destination)
409
+ if remote_public_key is None:
569
410
  raise ValueError(f"Destination {destination} does not have a stored public key in meshdb")
570
411
  packet.channel = 0
571
412
  packet.pki_encrypted = True
572
- packet.public_key = key_bytes
573
413
  packet.encrypted = encrypt_dm(
574
414
  sender_private_key=b64_decode(self.config.security.private_key),
575
- receiver_public_key=key_bytes,
415
+ receiver_public_key=remote_public_key,
576
416
  packet_id=packet.id,
577
417
  from_node=self.node_num,
578
418
  plaintext=data.SerializeToString(),
@@ -589,21 +429,20 @@ class VirtualNode:
589
429
  register_pending_ack(packet, raw_packet)
590
430
  conn.sendto(raw_packet, (conn.host, conn.port))
591
431
  self._persist_outbound_packet(packet, data)
592
- return packet
432
+ return packet.id
593
433
 
594
- def _handle_raw_packet(self, packet: mesh_pb2.MeshPacket, **kwargs: Any) -> None:
595
- del kwargs
434
+ def _handle_raw_packet(self, packet: mesh_pb2.MeshPacket, addr: Any = None) -> None:
435
+ del addr
596
436
  if not getattr(packet, "rx_time", 0):
597
437
  packet.rx_time = int(time.time())
598
438
 
599
439
  if not packet.HasField("decoded"):
600
440
  self._try_decode_pki(packet)
601
- self._publish_meshtastic_receive(packet)
602
441
  self._maybe_send_ack(packet)
603
442
  self._maybe_send_response(packet)
604
443
 
605
- def _handle_unique_packet(self, packet: mesh_pb2.MeshPacket, **kwargs: Any) -> None:
606
- del kwargs
444
+ def _handle_unique_packet(self, packet: mesh_pb2.MeshPacket, addr: Any = None) -> None:
445
+ del addr
607
446
  if not getattr(packet, "rx_time", 0):
608
447
  packet.rx_time = int(time.time())
609
448
  if not packet.HasField("decoded"):
@@ -612,186 +451,7 @@ class VirtualNode:
612
451
  return
613
452
 
614
453
  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)
454
+ self._publish_receive(packet)
795
455
 
796
456
  def _try_decode_pki(self, packet: mesh_pb2.MeshPacket) -> bool:
797
457
  if packet.channel != 0 or packet.to != self.node_num or not packet.encrypted:
@@ -933,6 +593,35 @@ class VirtualNode:
933
593
  db_path=self.meshdb_path,
934
594
  )
935
595
 
596
+ def _publish_receive(self, packet: mesh_pb2.MeshPacket) -> None:
597
+ from meshtastic import protocols
598
+
599
+ packet_dict = meshdb.normalize_packet(packet, "udp")
600
+ packet_dict["raw"] = packet
601
+
602
+ topic = self.RECEIVE_TOPIC
603
+ decoded = packet_dict.get("decoded")
604
+ portnum = None
605
+ if isinstance(decoded, dict):
606
+ raw_portnum = decoded.get("portnum")
607
+ if raw_portnum is not None:
608
+ try:
609
+ portnum = int(raw_portnum)
610
+ except (TypeError, ValueError):
611
+ portnum = None
612
+
613
+ if portnum is not None:
614
+ handler = protocols.get(portnum)
615
+ if handler is not None:
616
+ topic = f"{self.RECEIVE_TOPIC}.{handler.name}"
617
+ else:
618
+ try:
619
+ topic = f"{self.RECEIVE_TOPIC}.data.{portnums_pb2.PortNum.Name(portnum)}"
620
+ except ValueError:
621
+ topic = f"{self.RECEIVE_TOPIC}.data.{portnum}"
622
+
623
+ pub.sendMessage(topic, packet=packet_dict, interface=self)
624
+
936
625
  def _broadcast_loop(self) -> None:
937
626
  nodeinfo_interval = int(self.config.broadcasts.nodeinfo_interval_seconds)
938
627
  position_interval = int(self.config.position.position_interval_seconds)
@@ -966,8 +655,6 @@ class VirtualNode:
966
655
  if isinstance(destination, int):
967
656
  return destination
968
657
  text = str(destination).strip()
969
- if text == BROADCAST_ADDR:
970
- return BROADCAST_NUM
971
658
  if text.startswith("!"):
972
659
  return parse_node_id(text)
973
660
  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
File without changes