vnode 0.1.6__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.6
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).
@@ -58,7 +72,6 @@ broadcasts.
58
72
  ## Run
59
73
 
60
74
  ```bash
61
- .venv/bin/pip install -e .
62
75
  .venv/bin/python -m vnode --vnode-file node.json run
63
76
  ```
64
77
 
@@ -84,6 +97,15 @@ node = VirtualNode("node.json")
84
97
  packet_id = node.send_text("!1234abcd", "hello")
85
98
  ```
86
99
 
100
+ Supported `VirtualNode` calls:
101
+
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()`
108
+
87
109
  ## Examples
88
110
 
89
111
  Use these as small runnable references for common tasks:
@@ -91,6 +113,7 @@ Use these as small runnable references for common tasks:
91
113
  - `examples/autoresponder.py`: DM-only reply bot for inbound direct text messages
92
114
  - `examples/basic_subscriptions.py`: minimal `node.receive()` subscription example
93
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`
94
117
  - `examples/serial_or_vnode.py`: try a serial Meshtastic node first, then fall back to `VirtualNode`
95
118
  - `examples/send_dm.py`: minimal one-shot direct-message sender
96
119
  - `examples/library_embed.py`: minimal application-style embedding example using `VirtualNode` directly
@@ -100,6 +123,7 @@ Use these as small runnable references for common tasks:
100
123
  .venv/bin/python examples/autoresponder.py
101
124
  .venv/bin/python examples/basic_subscriptions.py
102
125
  .venv/bin/python examples/listen_packets.py
126
+ .venv/bin/python examples/mesh_interface_compat.py
103
127
  .venv/bin/python examples/serial_or_vnode.py
104
128
  .venv/bin/python examples/send_dm.py --to '!1234abcd' --message 'hello'
105
129
  .venv/bin/python examples/library_embed.py
@@ -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).
@@ -34,7 +48,6 @@ broadcasts.
34
48
  ## Run
35
49
 
36
50
  ```bash
37
- .venv/bin/pip install -e .
38
51
  .venv/bin/python -m vnode --vnode-file node.json run
39
52
  ```
40
53
 
@@ -60,6 +73,15 @@ node = VirtualNode("node.json")
60
73
  packet_id = node.send_text("!1234abcd", "hello")
61
74
  ```
62
75
 
76
+ Supported `VirtualNode` calls:
77
+
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()`
84
+
63
85
  ## Examples
64
86
 
65
87
  Use these as small runnable references for common tasks:
@@ -67,6 +89,7 @@ Use these as small runnable references for common tasks:
67
89
  - `examples/autoresponder.py`: DM-only reply bot for inbound direct text messages
68
90
  - `examples/basic_subscriptions.py`: minimal `node.receive()` subscription example
69
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`
70
93
  - `examples/serial_or_vnode.py`: try a serial Meshtastic node first, then fall back to `VirtualNode`
71
94
  - `examples/send_dm.py`: minimal one-shot direct-message sender
72
95
  - `examples/library_embed.py`: minimal application-style embedding example using `VirtualNode` directly
@@ -76,6 +99,7 @@ Use these as small runnable references for common tasks:
76
99
  .venv/bin/python examples/autoresponder.py
77
100
  .venv/bin/python examples/basic_subscriptions.py
78
101
  .venv/bin/python examples/listen_packets.py
102
+ .venv/bin/python examples/mesh_interface_compat.py
79
103
  .venv/bin/python examples/serial_or_vnode.py
80
104
  .venv/bin/python examples/send_dm.py --to '!1234abcd' --message 'hello'
81
105
  .venv/bin/python examples/library_embed.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "vnode"
3
- version = "0.1.6"
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"}
@@ -7,9 +7,10 @@ import time
7
7
  from pathlib import Path
8
8
  from typing import Any, Dict, Optional, Union
9
9
 
10
+ from google.protobuf.json_format import MessageToDict
10
11
  import meshdb
11
12
  from google.protobuf.message import DecodeError
12
- from meshtastic import 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
13
14
  from mudp import UDPPacketStream
14
15
  from mudp.encryption import encrypt_packet as mudp_encrypt_packet
15
16
  from mudp.reliability import is_ack, is_nak, publish_ack, register_pending_ack, send_ack
@@ -82,6 +83,7 @@ class VirtualNode:
82
83
  self._last_nodeinfo_sent_monotonic = 0.0
83
84
  self._last_position_reply_monotonic = 0.0
84
85
  self._last_nodeinfo_seen: Dict[int, int] = {}
86
+ self._response_handlers: Dict[int, Dict[str, Any]] = {}
85
87
 
86
88
  self._ensure_security_keys()
87
89
  self._write_public_key_file()
@@ -142,6 +144,9 @@ class VirtualNode:
142
144
  return
143
145
  pub.subscribe(self._handle_raw_packet, "mesh.rx.packet")
144
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")
145
150
  self.stream = UDPPacketStream(
146
151
  self.config.udp.mcast_group,
147
152
  int(self.config.udp.mcast_port),
@@ -171,6 +176,15 @@ class VirtualNode:
171
176
  pub.unsubscribe(self._handle_unique_packet, self.PACKET_TOPIC)
172
177
  except KeyError:
173
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
174
188
  if self._broadcast_thread and self._broadcast_thread.is_alive():
175
189
  self._broadcast_thread.join(timeout=2.0)
176
190
 
@@ -191,6 +205,161 @@ class VirtualNode:
191
205
  except KeyError:
192
206
  pass
193
207
 
208
+ def close(self) -> None:
209
+ self.stop()
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
+
238
+ def sendText(
239
+ self,
240
+ text: str,
241
+ destinationId: Union[int, str] = BROADCAST_ADDR,
242
+ wantAck: bool = False,
243
+ wantResponse: bool = False,
244
+ onResponse=None,
245
+ channelIndex: int = 0,
246
+ portNum: int = portnums_pb2.PortNum.TEXT_MESSAGE_APP,
247
+ replyId: Optional[int] = None,
248
+ hopLimit: Optional[int] = None,
249
+ ):
250
+ return self.sendData(
251
+ text.encode("utf-8"),
252
+ destinationId=destinationId,
253
+ portNum=portNum,
254
+ wantAck=wantAck,
255
+ wantResponse=wantResponse,
256
+ onResponse=onResponse,
257
+ channelIndex=channelIndex,
258
+ hopLimit=hopLimit,
259
+ replyId=replyId,
260
+ )
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
+
282
+ def sendData(
283
+ self,
284
+ data,
285
+ destinationId: Union[int, str] = BROADCAST_ADDR,
286
+ portNum: int = portnums_pb2.PortNum.PRIVATE_APP,
287
+ wantAck: bool = False,
288
+ wantResponse: bool = False,
289
+ onResponse=None,
290
+ onResponseAckPermitted: bool = False,
291
+ channelIndex: int = 0,
292
+ hopLimit: Optional[int] = None,
293
+ pkiEncrypted: Optional[bool] = False,
294
+ publicKey: Optional[bytes] = None,
295
+ priority: int = mesh_pb2.MeshPacket.Priority.RELIABLE,
296
+ replyId: Optional[int] = None,
297
+ ):
298
+ if getattr(data, "SerializeToString", None):
299
+ data = data.SerializeToString()
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)
317
+ if replyId is not None:
318
+ data_message.reply_id = int(replyId)
319
+
320
+ packet = self._send_data_packet(
321
+ data_message,
322
+ destination=int(destination),
323
+ force_pki=force_pki,
324
+ hop_limit=hopLimit,
325
+ want_ack=wantAck,
326
+ priority=int(priority),
327
+ )
328
+ if onResponse is not None:
329
+ self._response_handlers[int(packet.id)] = {
330
+ "callback": onResponse,
331
+ "ack_permitted": bool(onResponseAckPermitted) or getattr(onResponse, "__name__", "") == "onAckNak",
332
+ }
333
+ return packet
334
+
335
+ def sendPosition(
336
+ self,
337
+ latitude: float = 0.0,
338
+ longitude: float = 0.0,
339
+ altitude: int = 0,
340
+ destinationId: Union[int, str] = BROADCAST_ADDR,
341
+ wantAck: bool = False,
342
+ wantResponse: bool = False,
343
+ channelIndex: int = 0,
344
+ hopLimit: Optional[int] = None,
345
+ ):
346
+ position = mesh_pb2.Position()
347
+ if latitude != 0.0:
348
+ position.latitude_i = int(latitude * 1e7)
349
+ if longitude != 0.0:
350
+ position.longitude_i = int(longitude * 1e7)
351
+ if altitude != 0:
352
+ position.altitude = int(altitude)
353
+ return self.sendData(
354
+ position,
355
+ destinationId=destinationId,
356
+ portNum=portnums_pb2.PortNum.POSITION_APP,
357
+ wantAck=wantAck,
358
+ wantResponse=wantResponse,
359
+ channelIndex=channelIndex,
360
+ hopLimit=hopLimit,
361
+ )
362
+
194
363
  def connect_send_socket(self) -> None:
195
364
  if getattr(conn, "socket", None) is None:
196
365
  conn.setup_multicast(self.config.udp.mcast_group, int(self.config.udp.mcast_port))
@@ -390,13 +559,38 @@ class VirtualNode:
390
559
  hop_limit: Optional[int] = None,
391
560
  hop_start: Optional[int] = None,
392
561
  want_ack: bool = False,
562
+ priority: Optional[int] = None,
393
563
  ) -> int:
564
+ packet = self._send_data_packet(
565
+ data,
566
+ destination=destination,
567
+ force_pki=force_pki,
568
+ hop_limit=hop_limit,
569
+ hop_start=hop_start,
570
+ want_ack=want_ack,
571
+ priority=priority,
572
+ )
573
+ return packet.id
574
+
575
+ def _send_data_packet(
576
+ self,
577
+ data: mesh_pb2.Data,
578
+ *,
579
+ destination: int,
580
+ force_pki: bool,
581
+ hop_limit: Optional[int] = None,
582
+ hop_start: Optional[int] = None,
583
+ want_ack: bool = False,
584
+ priority: Optional[int] = None,
585
+ ) -> mesh_pb2.MeshPacket:
394
586
  self.connect_send_socket()
395
587
  packet = mesh_pb2.MeshPacket()
396
588
  packet.id = self._next_packet_id()
397
589
  setattr(packet, "from", self.node_num)
398
590
  packet.to = int(destination)
399
591
  packet.want_ack = bool(want_ack)
592
+ if priority is not None:
593
+ packet.priority = int(priority)
400
594
  resolved_hop_limit = int(self.config.hop_limit if hop_limit is None else hop_limit)
401
595
  resolved_hop_start = int(resolved_hop_limit if hop_start is None else hop_start)
402
596
  if resolved_hop_start < resolved_hop_limit:
@@ -429,7 +623,7 @@ class VirtualNode:
429
623
  register_pending_ack(packet, raw_packet)
430
624
  conn.sendto(raw_packet, (conn.host, conn.port))
431
625
  self._persist_outbound_packet(packet, data)
432
- return packet.id
626
+ return packet
433
627
 
434
628
  def _handle_raw_packet(self, packet: mesh_pb2.MeshPacket, addr: Any = None) -> None:
435
629
  del addr
@@ -594,10 +788,7 @@ class VirtualNode:
594
788
  )
595
789
 
596
790
  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
791
+ packet_dict = self._mesh_interface_packet_dict(packet)
601
792
 
602
793
  topic = self.RECEIVE_TOPIC
603
794
  decoded = packet_dict.get("decoded")
@@ -605,23 +796,94 @@ class VirtualNode:
605
796
  if isinstance(decoded, dict):
606
797
  raw_portnum = decoded.get("portnum")
607
798
  if raw_portnum is not None:
608
- try:
609
- portnum = int(raw_portnum)
610
- except (TypeError, ValueError):
611
- portnum = None
799
+ portnum = raw_portnum
612
800
 
613
801
  if portnum is not None:
614
- handler = protocols.get(portnum)
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
615
807
  if handler is not None:
616
808
  topic = f"{self.RECEIVE_TOPIC}.{handler.name}"
617
809
  else:
618
- try:
619
- topic = f"{self.RECEIVE_TOPIC}.data.{portnums_pb2.PortNum.Name(portnum)}"
620
- except ValueError:
810
+ if isinstance(portnum, str):
811
+ topic = f"{self.RECEIVE_TOPIC}.data.{portnum}"
812
+ else:
621
813
  topic = f"{self.RECEIVE_TOPIC}.data.{portnum}"
622
814
 
623
815
  pub.sendMessage(topic, packet=packet_dict, interface=self)
624
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
+
625
887
  def _broadcast_loop(self) -> None:
626
888
  nodeinfo_interval = int(self.config.broadcasts.nodeinfo_interval_seconds)
627
889
  position_interval = int(self.config.position.position_interval_seconds)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes