vnode 0.1.5__tar.gz → 0.1.7__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.5
3
+ Version: 0.1.7
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
@@ -30,6 +30,20 @@ Virtual Meshtastic node runtime built around:
30
30
  - `meshdb` for packet and node persistence
31
31
  - Meshtastic-style PKI DM encryption using X25519 + SHA-256 + AES-CCM
32
32
 
33
+ ## Install
34
+
35
+ Install from PyPI:
36
+
37
+ ```bash
38
+ pip install vnode
39
+ ```
40
+
41
+ If you are working from this repository instead:
42
+
43
+ ```bash
44
+ .venv/bin/pip install -e .
45
+ ```
46
+
33
47
  ## Config
34
48
 
35
49
  Edit [node.json](node.json).
@@ -39,6 +53,8 @@ If `node.json` does not exist yet, the runtime will create it automatically from
39
53
 
40
54
  If the template leaves `node_id` blank, the runtime will generate and persist a random
41
55
  Meshtastic-style node ID when it creates or first loads `node.json`.
56
+ If `long_name` or `short_name` are blank, the runtime will also generate them from the
57
+ last 4 hex characters of the node ID, for example `Meshtastic 691c` and `691c`.
42
58
 
43
59
  The runtime will generate and persist a PKI private key into `node.json` on first run if
44
60
  the private key is blank.
@@ -56,7 +72,6 @@ broadcasts.
56
72
  ## Run
57
73
 
58
74
  ```bash
59
- .venv/bin/pip install -e .
60
75
  .venv/bin/python -m vnode --vnode-file node.json run
61
76
  ```
62
77
 
@@ -82,32 +97,36 @@ node = VirtualNode("node.json")
82
97
  packet_id = node.send_text("!1234abcd", "hello")
83
98
  ```
84
99
 
85
- The runtime also mirrors the common Meshtastic Python pubsub and method surface:
100
+ Supported `VirtualNode` calls:
86
101
 
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.
102
+ - Lifecycle: `start()`, `stop()`, `close()`, `run_forever()`
103
+ - Subscription: `receive(callback)`, `unreceive(callback)`
104
+ - Node identity: `getLongName()`, `getShortName()`, `getPublicKey()`, `getMyUser()`, `getMyNodeInfo()`
105
+ - Native vnode sends: `send_text()`, `send_reply()`, `send_nodeinfo()`, `send_position()`
106
+ - MeshInterface-compatible sends: `sendText()`, `sendAlert()`, `sendData()`, `sendPosition()`
107
+ - Packet helpers: `is_direct_message_for_me()`, `is_text_message()`, `get_text_message()`, `reply_to_packet()`
91
108
 
92
109
  ## Examples
93
110
 
94
111
  Use these as small runnable references for common tasks:
95
112
 
96
113
  - `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
114
+ - `examples/basic_subscriptions.py`: minimal `node.receive()` subscription example
115
+ - `examples/listen_packets.py`: packet logger for multicast traffic and decoded text
116
+ - `examples/mesh_interface_compat.py`: examples of MeshInterface-style compatibility calls on `VirtualNode`
117
+ - `examples/serial_or_vnode.py`: try a serial Meshtastic node first, then fall back to `VirtualNode`
98
118
  - `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
119
+ - `examples/library_embed.py`: minimal application-style embedding example using `VirtualNode` directly
102
120
  - `examples/watch_reliability.py`: watcher for ACK, NAK, retry, and retransmit-failure events
103
121
 
104
122
  ```bash
105
123
  .venv/bin/python examples/autoresponder.py
124
+ .venv/bin/python examples/basic_subscriptions.py
106
125
  .venv/bin/python examples/listen_packets.py
126
+ .venv/bin/python examples/mesh_interface_compat.py
127
+ .venv/bin/python examples/serial_or_vnode.py
107
128
  .venv/bin/python examples/send_dm.py --to '!1234abcd' --message 'hello'
108
129
  .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
130
  .venv/bin/python examples/watch_reliability.py --to '!1234abcd' --message 'hello'
112
131
  ```
113
132
 
@@ -6,6 +6,20 @@ Virtual Meshtastic node runtime built around:
6
6
  - `meshdb` for packet and node persistence
7
7
  - Meshtastic-style PKI DM encryption using X25519 + SHA-256 + AES-CCM
8
8
 
9
+ ## Install
10
+
11
+ Install from PyPI:
12
+
13
+ ```bash
14
+ pip install vnode
15
+ ```
16
+
17
+ If you are working from this repository instead:
18
+
19
+ ```bash
20
+ .venv/bin/pip install -e .
21
+ ```
22
+
9
23
  ## Config
10
24
 
11
25
  Edit [node.json](node.json).
@@ -15,6 +29,8 @@ If `node.json` does not exist yet, the runtime will create it automatically from
15
29
 
16
30
  If the template leaves `node_id` blank, the runtime will generate and persist a random
17
31
  Meshtastic-style node ID when it creates or first loads `node.json`.
32
+ If `long_name` or `short_name` are blank, the runtime will also generate them from the
33
+ last 4 hex characters of the node ID, for example `Meshtastic 691c` and `691c`.
18
34
 
19
35
  The runtime will generate and persist a PKI private key into `node.json` on first run if
20
36
  the private key is blank.
@@ -32,7 +48,6 @@ broadcasts.
32
48
  ## Run
33
49
 
34
50
  ```bash
35
- .venv/bin/pip install -e .
36
51
  .venv/bin/python -m vnode --vnode-file node.json run
37
52
  ```
38
53
 
@@ -58,32 +73,36 @@ node = VirtualNode("node.json")
58
73
  packet_id = node.send_text("!1234abcd", "hello")
59
74
  ```
60
75
 
61
- The runtime also mirrors the common Meshtastic Python pubsub and method surface:
76
+ Supported `VirtualNode` calls:
62
77
 
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.
78
+ - Lifecycle: `start()`, `stop()`, `close()`, `run_forever()`
79
+ - Subscription: `receive(callback)`, `unreceive(callback)`
80
+ - Node identity: `getLongName()`, `getShortName()`, `getPublicKey()`, `getMyUser()`, `getMyNodeInfo()`
81
+ - Native vnode sends: `send_text()`, `send_reply()`, `send_nodeinfo()`, `send_position()`
82
+ - MeshInterface-compatible sends: `sendText()`, `sendAlert()`, `sendData()`, `sendPosition()`
83
+ - Packet helpers: `is_direct_message_for_me()`, `is_text_message()`, `get_text_message()`, `reply_to_packet()`
67
84
 
68
85
  ## Examples
69
86
 
70
87
  Use these as small runnable references for common tasks:
71
88
 
72
89
  - `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
90
+ - `examples/basic_subscriptions.py`: minimal `node.receive()` subscription example
91
+ - `examples/listen_packets.py`: packet logger for multicast traffic and decoded text
92
+ - `examples/mesh_interface_compat.py`: examples of MeshInterface-style compatibility calls on `VirtualNode`
93
+ - `examples/serial_or_vnode.py`: try a serial Meshtastic node first, then fall back to `VirtualNode`
74
94
  - `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
95
+ - `examples/library_embed.py`: minimal application-style embedding example using `VirtualNode` directly
78
96
  - `examples/watch_reliability.py`: watcher for ACK, NAK, retry, and retransmit-failure events
79
97
 
80
98
  ```bash
81
99
  .venv/bin/python examples/autoresponder.py
100
+ .venv/bin/python examples/basic_subscriptions.py
82
101
  .venv/bin/python examples/listen_packets.py
102
+ .venv/bin/python examples/mesh_interface_compat.py
103
+ .venv/bin/python examples/serial_or_vnode.py
83
104
  .venv/bin/python examples/send_dm.py --to '!1234abcd' --message 'hello'
84
105
  .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
106
  .venv/bin/python examples/watch_reliability.py --to '!1234abcd' --message 'hello'
88
107
  ```
89
108
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "vnode"
3
- version = "0.1.5"
3
+ version = "0.1.7"
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,12 @@ 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
- import meshdb
12
10
  from google.protobuf.json_format import MessageToDict
11
+ import meshdb
13
12
  from google.protobuf.message import DecodeError
14
- from meshtastic import BROADCAST_ADDR, BROADCAST_NUM, config_pb2, mesh_pb2, portnums_pb2
13
+ from meshtastic import BROADCAST_ADDR, BROADCAST_NUM, LOCAL_ADDR, config_pb2, mesh_pb2, portnums_pb2, protocols
15
14
  from mudp import UDPPacketStream
16
15
  from mudp.encryption import encrypt_packet as mudp_encrypt_packet
17
16
  from mudp.reliability import is_ack, is_nak, publish_ack, register_pending_ack, send_ack
@@ -64,9 +63,9 @@ def resolve_role(value: Union[str, int]) -> int:
64
63
 
65
64
 
66
65
  class VirtualNode:
67
- RECEIVE_TOPIC = "mesh.rx.packet"
68
66
  PACKET_TOPIC = "mesh.rx.unique_packet"
69
67
  DUPLICATE_TOPIC = "mesh.rx.duplicate"
68
+ RECEIVE_TOPIC = "meshtastic.receive"
70
69
 
71
70
  def __init__(self, config_path: Union[str, Path] = "node.json") -> None:
72
71
  self.config_path = Path(config_path).resolve()
@@ -84,11 +83,7 @@ class VirtualNode:
84
83
  self._last_nodeinfo_sent_monotonic = 0.0
85
84
  self._last_position_reply_monotonic = 0.0
86
85
  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
86
+ self._response_handlers: Dict[int, Dict[str, Any]] = {}
92
87
 
93
88
  self._ensure_security_keys()
94
89
  self._write_public_key_file()
@@ -129,7 +124,6 @@ class VirtualNode:
129
124
  is_licensed=int(self.config.is_licensed),
130
125
  public_key=self._public_key_b64,
131
126
  )
132
- self._upsert_local_node_cache()
133
127
 
134
128
  def _configure_mudp_globals(self) -> None:
135
129
  mudp_node.node_id = self.config.node_id
@@ -150,6 +144,9 @@ class VirtualNode:
150
144
  return
151
145
  pub.subscribe(self._handle_raw_packet, "mesh.rx.packet")
152
146
  pub.subscribe(self._handle_unique_packet, self.PACKET_TOPIC)
147
+ pub.subscribe(self._handle_compat_response_packet, self.RECEIVE_TOPIC)
148
+ pub.subscribe(self._handle_compat_ack, "mesh.rx.ack")
149
+ pub.subscribe(self._handle_compat_nak, "mesh.rx.nak")
153
150
  self.stream = UDPPacketStream(
154
151
  self.config.udp.mcast_group,
155
152
  int(self.config.udp.mcast_port),
@@ -157,10 +154,6 @@ class VirtualNode:
157
154
  parse_payload=False,
158
155
  )
159
156
  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
157
  if self.config.broadcasts.send_startup_nodeinfo:
165
158
  self.send_nodeinfo()
166
159
  self._broadcast_thread = threading.Thread(
@@ -172,8 +165,6 @@ class VirtualNode:
172
165
 
173
166
  def stop(self) -> None:
174
167
  self._stop.set()
175
- was_connected = self._connected
176
- self._connected = False
177
168
  if self.stream is not None:
178
169
  self.stream.stop()
179
170
  self.stream = None
@@ -185,11 +176,17 @@ class VirtualNode:
185
176
  pub.unsubscribe(self._handle_unique_packet, self.PACKET_TOPIC)
186
177
  except KeyError:
187
178
  pass
179
+ for topic, listener in (
180
+ (self.RECEIVE_TOPIC, self._handle_compat_response_packet),
181
+ ("mesh.rx.ack", self._handle_compat_ack),
182
+ ("mesh.rx.nak", self._handle_compat_nak),
183
+ ):
184
+ try:
185
+ pub.unsubscribe(listener, topic)
186
+ except KeyError:
187
+ pass
188
188
  if self._broadcast_thread and self._broadcast_thread.is_alive():
189
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)
193
190
 
194
191
  def run_forever(self) -> None:
195
192
  self.start()
@@ -199,30 +196,57 @@ class VirtualNode:
199
196
  finally:
200
197
  self.stop()
201
198
 
202
- def receive(self, callback: Callable[..., Any]) -> None:
203
- pub.subscribe(callback, "meshtastic.receive")
199
+ def receive(self, callback) -> None:
200
+ pub.subscribe(callback, self.RECEIVE_TOPIC)
204
201
 
205
- def unreceive(self, callback: Callable[..., Any]) -> None:
202
+ def unreceive(self, callback) -> None:
206
203
  try:
207
- pub.unsubscribe(callback, "meshtastic.receive")
204
+ pub.unsubscribe(callback, self.RECEIVE_TOPIC)
208
205
  except KeyError:
209
206
  pass
210
207
 
211
208
  def close(self) -> None:
212
209
  self.stop()
213
210
 
211
+ def getLongName(self):
212
+ return self.config.long_name
213
+
214
+ def getShortName(self):
215
+ return self.config.short_name
216
+
217
+ def getPublicKey(self):
218
+ return b64_decode(self._public_key_b64) if self._public_key_b64 else None
219
+
220
+ def getMyUser(self):
221
+ user = mesh_pb2.User(
222
+ id=self.config.node_id,
223
+ long_name=self.config.long_name,
224
+ short_name=self.config.short_name,
225
+ hw_model=resolve_hw_model(self.config.hw_model),
226
+ )
227
+ user.role = resolve_role(self.config.role)
228
+ if self._public_key_b64:
229
+ user.public_key = b64_decode(self._public_key_b64)
230
+ return MessageToDict(user, preserving_proto_field_name=False, use_integers_for_enums=False)
231
+
232
+ def getMyNodeInfo(self):
233
+ return {
234
+ "num": self.node_num,
235
+ "user": self.getMyUser(),
236
+ }
237
+
214
238
  def sendText(
215
239
  self,
216
240
  text: str,
217
241
  destinationId: Union[int, str] = BROADCAST_ADDR,
218
242
  wantAck: bool = False,
219
243
  wantResponse: bool = False,
220
- onResponse: Optional[Callable[[dict], Any]] = None,
244
+ onResponse=None,
221
245
  channelIndex: int = 0,
222
246
  portNum: int = portnums_pb2.PortNum.TEXT_MESSAGE_APP,
223
247
  replyId: Optional[int] = None,
224
248
  hopLimit: Optional[int] = None,
225
- ) -> mesh_pb2.MeshPacket:
249
+ ):
226
250
  return self.sendData(
227
251
  text.encode("utf-8"),
228
252
  destinationId=destinationId,
@@ -235,51 +259,78 @@ class VirtualNode:
235
259
  replyId=replyId,
236
260
  )
237
261
 
262
+ def sendAlert(
263
+ self,
264
+ text: str,
265
+ destinationId: Union[int, str] = BROADCAST_ADDR,
266
+ onResponse=None,
267
+ channelIndex: int = 0,
268
+ hopLimit: Optional[int] = None,
269
+ ):
270
+ return self.sendData(
271
+ text.encode("utf-8"),
272
+ destinationId=destinationId,
273
+ portNum=portnums_pb2.PortNum.ALERT_APP,
274
+ wantAck=False,
275
+ wantResponse=False,
276
+ onResponse=onResponse,
277
+ channelIndex=channelIndex,
278
+ hopLimit=hopLimit,
279
+ priority=mesh_pb2.MeshPacket.Priority.ALERT,
280
+ )
281
+
238
282
  def sendData(
239
283
  self,
240
- data: Any,
284
+ data,
241
285
  destinationId: Union[int, str] = BROADCAST_ADDR,
242
286
  portNum: int = portnums_pb2.PortNum.PRIVATE_APP,
243
287
  wantAck: bool = False,
244
288
  wantResponse: bool = False,
245
- onResponse: Optional[Callable[[dict], Any]] = None,
289
+ onResponse=None,
246
290
  onResponseAckPermitted: bool = False,
247
291
  channelIndex: int = 0,
248
292
  hopLimit: Optional[int] = None,
249
- pkiEncrypted: bool = False,
293
+ pkiEncrypted: Optional[bool] = False,
250
294
  publicKey: Optional[bytes] = None,
251
295
  priority: int = mesh_pb2.MeshPacket.Priority.RELIABLE,
252
296
  replyId: Optional[int] = None,
253
- ) -> mesh_pb2.MeshPacket:
254
- if channelIndex != 0:
255
- raise ValueError("vnode currently supports only channelIndex=0")
297
+ ):
256
298
  if getattr(data, "SerializeToString", None):
257
299
  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)
300
+ if not isinstance(data, (bytes, bytearray)):
301
+ raise TypeError("data must be bytes or a protobuf with SerializeToString()")
302
+ if int(channelIndex) != 0:
303
+ raise NotImplementedError("Only channelIndex=0 is currently supported")
304
+ if publicKey is not None:
305
+ raise NotImplementedError("publicKey override is not currently supported")
306
+ if int(portNum) == portnums_pb2.PortNum.UNKNOWN_APP:
307
+ raise ValueError("A non-zero port number must be specified")
308
+
309
+ destination = self._resolve_destination_compat(destinationId)
310
+ force_pki = bool(pkiEncrypted)
311
+ data_message = mesh_pb2.Data()
312
+ data_message.portnum = int(portNum)
313
+ data_message.payload = bytes(data)
314
+ data_message.want_response = bool(wantResponse)
315
+ data_message.source = self.node_num
316
+ data_message.dest = int(destination)
267
317
  if replyId is not None:
268
- decoded.reply_id = int(replyId)
318
+ data_message.reply_id = int(replyId)
269
319
 
270
- packet = self._send_packet(
271
- decoded,
272
- destination=destination_num,
273
- force_pki=bool(pkiEncrypted),
320
+ packet = self._send_data_packet(
321
+ data_message,
322
+ destination=int(destination),
323
+ force_pki=force_pki,
274
324
  hop_limit=hopLimit,
275
- hop_start=None,
276
- want_ack=bool(wantAck),
277
- priority=priority,
278
- remote_public_key=publicKey,
325
+ want_ack=wantAck,
326
+ priority=int(priority),
279
327
  )
280
328
  if onResponse is not None:
281
- self._response_handlers[int(packet.id)] = (onResponse, bool(onResponseAckPermitted))
282
- return self._packet_for_return(packet, decoded)
329
+ self._response_handlers[int(packet.id)] = {
330
+ "callback": onResponse,
331
+ "ack_permitted": bool(onResponseAckPermitted) or getattr(onResponse, "__name__", "") == "onAckNak",
332
+ }
333
+ return packet
283
334
 
284
335
  def sendPosition(
285
336
  self,
@@ -291,12 +342,12 @@ class VirtualNode:
291
342
  wantResponse: bool = False,
292
343
  channelIndex: int = 0,
293
344
  hopLimit: Optional[int] = None,
294
- ) -> mesh_pb2.MeshPacket:
345
+ ):
295
346
  position = mesh_pb2.Position()
296
347
  if latitude != 0.0:
297
- position.latitude_i = int(latitude / 1e-7)
348
+ position.latitude_i = int(latitude * 1e7)
298
349
  if longitude != 0.0:
299
- position.longitude_i = int(longitude / 1e-7)
350
+ position.longitude_i = int(longitude * 1e7)
300
351
  if altitude != 0:
301
352
  position.altitude = int(altitude)
302
353
  return self.sendData(
@@ -309,23 +360,6 @@ class VirtualNode:
309
360
  hopLimit=hopLimit,
310
361
  )
311
362
 
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
363
  def connect_send_socket(self) -> None:
330
364
  if getattr(conn, "socket", None) is None:
331
365
  conn.setup_multicast(self.config.udp.mcast_group, int(self.config.udp.mcast_port))
@@ -525,18 +559,20 @@ class VirtualNode:
525
559
  hop_limit: Optional[int] = None,
526
560
  hop_start: Optional[int] = None,
527
561
  want_ack: bool = False,
562
+ priority: Optional[int] = None,
528
563
  ) -> int:
529
- packet = self._send_packet(
564
+ packet = self._send_data_packet(
530
565
  data,
531
566
  destination=destination,
532
567
  force_pki=force_pki,
533
568
  hop_limit=hop_limit,
534
569
  hop_start=hop_start,
535
570
  want_ack=want_ack,
571
+ priority=priority,
536
572
  )
537
573
  return packet.id
538
574
 
539
- def _send_packet(
575
+ def _send_data_packet(
540
576
  self,
541
577
  data: mesh_pb2.Data,
542
578
  *,
@@ -546,7 +582,6 @@ class VirtualNode:
546
582
  hop_start: Optional[int] = None,
547
583
  want_ack: bool = False,
548
584
  priority: Optional[int] = None,
549
- remote_public_key: Optional[bytes] = None,
550
585
  ) -> mesh_pb2.MeshPacket:
551
586
  self.connect_send_socket()
552
587
  packet = mesh_pb2.MeshPacket()
@@ -564,15 +599,14 @@ class VirtualNode:
564
599
  packet.hop_start = resolved_hop_start
565
600
 
566
601
  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:
602
+ remote_public_key = self._lookup_public_key(destination)
603
+ if remote_public_key is None:
569
604
  raise ValueError(f"Destination {destination} does not have a stored public key in meshdb")
570
605
  packet.channel = 0
571
606
  packet.pki_encrypted = True
572
- packet.public_key = key_bytes
573
607
  packet.encrypted = encrypt_dm(
574
608
  sender_private_key=b64_decode(self.config.security.private_key),
575
- receiver_public_key=key_bytes,
609
+ receiver_public_key=remote_public_key,
576
610
  packet_id=packet.id,
577
611
  from_node=self.node_num,
578
612
  plaintext=data.SerializeToString(),
@@ -591,19 +625,18 @@ class VirtualNode:
591
625
  self._persist_outbound_packet(packet, data)
592
626
  return packet
593
627
 
594
- def _handle_raw_packet(self, packet: mesh_pb2.MeshPacket, **kwargs: Any) -> None:
595
- del kwargs
628
+ def _handle_raw_packet(self, packet: mesh_pb2.MeshPacket, addr: Any = None) -> None:
629
+ del addr
596
630
  if not getattr(packet, "rx_time", 0):
597
631
  packet.rx_time = int(time.time())
598
632
 
599
633
  if not packet.HasField("decoded"):
600
634
  self._try_decode_pki(packet)
601
- self._publish_meshtastic_receive(packet)
602
635
  self._maybe_send_ack(packet)
603
636
  self._maybe_send_response(packet)
604
637
 
605
- def _handle_unique_packet(self, packet: mesh_pb2.MeshPacket, **kwargs: Any) -> None:
606
- del kwargs
638
+ def _handle_unique_packet(self, packet: mesh_pb2.MeshPacket, addr: Any = None) -> None:
639
+ del addr
607
640
  if not getattr(packet, "rx_time", 0):
608
641
  packet.rx_time = int(time.time())
609
642
  if not packet.HasField("decoded"):
@@ -612,186 +645,7 @@ class VirtualNode:
612
645
  return
613
646
 
614
647
  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)
648
+ self._publish_receive(packet)
795
649
 
796
650
  def _try_decode_pki(self, packet: mesh_pb2.MeshPacket) -> bool:
797
651
  if packet.channel != 0 or packet.to != self.node_num or not packet.encrypted:
@@ -933,6 +787,103 @@ class VirtualNode:
933
787
  db_path=self.meshdb_path,
934
788
  )
935
789
 
790
+ def _publish_receive(self, packet: mesh_pb2.MeshPacket) -> None:
791
+ packet_dict = self._mesh_interface_packet_dict(packet)
792
+
793
+ topic = self.RECEIVE_TOPIC
794
+ decoded = packet_dict.get("decoded")
795
+ portnum = None
796
+ if isinstance(decoded, dict):
797
+ raw_portnum = decoded.get("portnum")
798
+ if raw_portnum is not None:
799
+ portnum = raw_portnum
800
+
801
+ if portnum is not None:
802
+ try:
803
+ portnum_value = portnums_pb2.PortNum.Value(str(portnum))
804
+ except ValueError:
805
+ portnum_value = None
806
+ handler = protocols.get(portnum_value) if portnum_value is not None else None
807
+ if handler is not None:
808
+ topic = f"{self.RECEIVE_TOPIC}.{handler.name}"
809
+ else:
810
+ if isinstance(portnum, str):
811
+ topic = f"{self.RECEIVE_TOPIC}.data.{portnum}"
812
+ else:
813
+ topic = f"{self.RECEIVE_TOPIC}.data.{portnum}"
814
+
815
+ pub.sendMessage(topic, packet=packet_dict, interface=self)
816
+
817
+ def _mesh_interface_packet_dict(self, packet: mesh_pb2.MeshPacket) -> Dict[str, Any]:
818
+ packet_dict = meshdb.normalize_packet(packet, "udp")
819
+ packet_dict["raw"] = packet
820
+
821
+ if packet.HasField("decoded"):
822
+ decoded = packet_dict.setdefault("decoded", {})
823
+ decoded["payload"] = bytes(packet.decoded.payload)
824
+ try:
825
+ decoded["portnum"] = portnums_pb2.PortNum.Name(int(packet.decoded.portnum))
826
+ except ValueError:
827
+ decoded["portnum"] = int(packet.decoded.portnum)
828
+ if getattr(packet.decoded, "want_response", False):
829
+ decoded["wantResponse"] = True
830
+ if getattr(packet.decoded, "request_id", 0):
831
+ decoded["requestId"] = int(packet.decoded.request_id)
832
+ if getattr(packet.decoded, "reply_id", 0):
833
+ decoded["replyId"] = int(packet.decoded.reply_id)
834
+ if getattr(packet.decoded, "emoji", 0):
835
+ decoded["emoji"] = int(packet.decoded.emoji)
836
+
837
+ handler = protocols.get(int(packet.decoded.portnum))
838
+ if handler is not None and getattr(handler, "protobufFactory", None) is not None:
839
+ try:
840
+ pb = handler.protobufFactory()
841
+ pb.ParseFromString(bytes(packet.decoded.payload))
842
+ decoded[handler.name] = MessageToDict(
843
+ pb,
844
+ preserving_proto_field_name=False,
845
+ use_integers_for_enums=False,
846
+ )
847
+ except Exception:
848
+ pass
849
+ return packet_dict
850
+
851
+ def _resolve_destination_compat(self, destination: Union[int, str]) -> int:
852
+ if destination == BROADCAST_ADDR:
853
+ return BROADCAST_NUM
854
+ if destination == LOCAL_ADDR:
855
+ return self.node_num
856
+ return self._resolve_destination(destination)
857
+
858
+ def _handle_compat_response_packet(self, packet, interface=None) -> None:
859
+ if interface is not self:
860
+ return
861
+ raw_packet = packet.get("raw") if isinstance(packet, dict) else None
862
+ if not isinstance(raw_packet, mesh_pb2.MeshPacket) or not raw_packet.HasField("decoded"):
863
+ return
864
+ request_id = int(getattr(raw_packet.decoded, "request_id", 0))
865
+ if request_id <= 0 or is_ack(raw_packet) or is_nak(raw_packet):
866
+ return
867
+ handler = self._response_handlers.pop(request_id, None)
868
+ if handler is not None:
869
+ handler["callback"](packet)
870
+
871
+ def _handle_compat_ack(self, packet, routing=None, addr=None, pending=None) -> None:
872
+ del routing, addr, pending
873
+ request_id = int(getattr(packet.decoded, "request_id", 0)) if packet.HasField("decoded") else 0
874
+ handler = self._response_handlers.get(request_id)
875
+ if handler is None or not handler["ack_permitted"]:
876
+ return
877
+ self._response_handlers.pop(request_id, None)
878
+ handler["callback"](self._mesh_interface_packet_dict(packet))
879
+
880
+ def _handle_compat_nak(self, packet, routing=None, addr=None, pending=None) -> None:
881
+ del routing, addr, pending
882
+ request_id = int(getattr(packet.decoded, "request_id", 0)) if packet.HasField("decoded") else 0
883
+ handler = self._response_handlers.pop(request_id, None)
884
+ if handler is not None:
885
+ handler["callback"](self._mesh_interface_packet_dict(packet))
886
+
936
887
  def _broadcast_loop(self) -> None:
937
888
  nodeinfo_interval = int(self.config.broadcasts.nodeinfo_interval_seconds)
938
889
  position_interval = int(self.config.position.position_interval_seconds)
@@ -966,8 +917,6 @@ class VirtualNode:
966
917
  if isinstance(destination, int):
967
918
  return destination
968
919
  text = str(destination).strip()
969
- if text == BROADCAST_ADDR:
970
- return BROADCAST_NUM
971
920
  if text.startswith("!"):
972
921
  return parse_node_id(text)
973
922
  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