vnode 0.1.2__py3-none-any.whl

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.
vnode/runtime.py ADDED
@@ -0,0 +1,516 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ import threading
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any, Optional, Union
8
+
9
+ import meshdb
10
+ from google.protobuf.message import DecodeError
11
+ from meshtastic import BROADCAST_NUM, config_pb2, mesh_pb2, portnums_pb2
12
+ from mudp import UDPPacketStream
13
+ from mudp.encryption import encrypt_packet as mudp_encrypt_packet
14
+ from mudp.reliability import is_ack, is_nak, publish_ack, register_pending_ack, send_ack
15
+ from mudp.singleton import conn, node as mudp_node
16
+ from pubsub import pub
17
+
18
+ from .config import NodeConfig
19
+ from .crypto import b64_decode, b64_encode, decrypt_dm, derive_public_key, encrypt_dm, generate_keypair
20
+
21
+ PKI_DISALLOWED_PORTNUMS = {
22
+ portnums_pb2.PortNum.TRACEROUTE_APP,
23
+ portnums_pb2.PortNum.NODEINFO_APP,
24
+ portnums_pb2.PortNum.ROUTING_APP,
25
+ portnums_pb2.PortNum.POSITION_APP,
26
+ }
27
+ TEXT_PORTNUMS = {
28
+ portnums_pb2.PortNum.TEXT_MESSAGE_APP,
29
+ portnums_pb2.PortNum.TEXT_MESSAGE_COMPRESSED_APP,
30
+ }
31
+
32
+
33
+ def parse_node_id(node_id: Union[str, int]) -> int:
34
+ if isinstance(node_id, int):
35
+ return node_id
36
+ text = str(node_id).strip()
37
+ if text.startswith("!"):
38
+ text = text[1:]
39
+ return int(text, 16)
40
+
41
+
42
+ def resolve_hw_model(value: Union[str, int]) -> int:
43
+ if isinstance(value, int):
44
+ return value
45
+ return mesh_pb2.HardwareModel.Value(str(value))
46
+
47
+
48
+ def resolve_role(value: Union[str, int]) -> int:
49
+ if isinstance(value, int):
50
+ return value
51
+ return config_pb2.Config.DeviceConfig.Role.Value(str(value))
52
+
53
+
54
+ class VirtualNode:
55
+ PACKET_TOPIC = "mesh.rx.unique_packet"
56
+ DUPLICATE_TOPIC = "mesh.rx.duplicate"
57
+
58
+ def __init__(self, config_path: Union[str, Path] = "node.json") -> None:
59
+ self.config_path = Path(config_path).resolve()
60
+ self.base_dir = self.config_path.parent
61
+ self.public_key_path = self.config_path.with_suffix(".public.key")
62
+ self.config = NodeConfig.load(self.config_path)
63
+ self.node_num = parse_node_id(self.config.node_id)
64
+ self.meshdb_path = str((self.base_dir / self.config.meshdb.path).resolve())
65
+ self.stream: Optional[UDPPacketStream] = None
66
+ self._stop = threading.Event()
67
+ self._broadcast_thread: Optional[threading.Thread] = None
68
+ self._message_id = random.getrandbits(32)
69
+ self._public_key_b64 = ""
70
+
71
+ self._ensure_security_keys()
72
+ self._write_public_key_file()
73
+ self._seed_owner_record()
74
+ self._configure_mudp_globals()
75
+
76
+ def _ensure_security_keys(self) -> None:
77
+ private_key = self.config.security.private_key.strip()
78
+ changed = False
79
+
80
+ if private_key:
81
+ self._public_key_b64 = b64_encode(derive_public_key(b64_decode(private_key)))
82
+ else:
83
+ public_bytes, private_bytes = generate_keypair()
84
+ self._public_key_b64 = b64_encode(public_bytes)
85
+ private_key = b64_encode(private_bytes)
86
+ changed = True
87
+
88
+ if changed:
89
+ self.config.security.private_key = private_key
90
+ self.config.security.public_key = ""
91
+ self.config.save(self.config_path)
92
+
93
+ def _write_public_key_file(self) -> None:
94
+ if not self._public_key_b64:
95
+ return
96
+ self.public_key_path.write_text(f"{self._public_key_b64}\n", encoding="utf-8")
97
+
98
+ def _seed_owner_record(self) -> None:
99
+ Path(self.meshdb_path).mkdir(parents=True, exist_ok=True)
100
+ meshdb.set_default_db_path(self.meshdb_path)
101
+ meshdb.NodeDB(self.node_num, self.meshdb_path).upsert(
102
+ node_num=self.node_num,
103
+ long_name=self.config.long_name,
104
+ short_name=self.config.short_name,
105
+ hw_model=str(resolve_hw_model(self.config.hw_model)),
106
+ role=str(self.config.role),
107
+ is_licensed=int(self.config.is_licensed),
108
+ public_key=self._public_key_b64,
109
+ )
110
+
111
+ def _configure_mudp_globals(self) -> None:
112
+ mudp_node.node_id = self.config.node_id
113
+ mudp_node.long_name = self.config.long_name
114
+ mudp_node.short_name = self.config.short_name
115
+ mudp_node.hw_model = resolve_hw_model(self.config.hw_model)
116
+ mudp_node.role = self.config.role
117
+ mudp_node.public_key = (
118
+ b64_decode(self._public_key_b64)
119
+ if self._public_key_b64
120
+ else b""
121
+ )
122
+ mudp_node.channel = self.config.channel.name
123
+ mudp_node.key = self.config.channel.psk
124
+
125
+ def start(self) -> None:
126
+ if self.stream is not None:
127
+ return
128
+ pub.subscribe(self._handle_raw_packet, "mesh.rx.packet")
129
+ pub.subscribe(self._handle_unique_packet, self.PACKET_TOPIC)
130
+ self.stream = UDPPacketStream(
131
+ self.config.udp.mcast_group,
132
+ int(self.config.udp.mcast_port),
133
+ key=self.config.channel.psk,
134
+ parse_payload=False,
135
+ )
136
+ self.stream.start()
137
+ if self.config.broadcasts.send_startup_nodeinfo:
138
+ self.send_nodeinfo()
139
+ self._broadcast_thread = threading.Thread(
140
+ target=self._broadcast_loop,
141
+ name="vnode-nodeinfo-broadcast",
142
+ daemon=True,
143
+ )
144
+ self._broadcast_thread.start()
145
+
146
+ def stop(self) -> None:
147
+ self._stop.set()
148
+ if self.stream is not None:
149
+ self.stream.stop()
150
+ self.stream = None
151
+ try:
152
+ pub.unsubscribe(self._handle_raw_packet, "mesh.rx.packet")
153
+ except KeyError:
154
+ pass
155
+ try:
156
+ pub.unsubscribe(self._handle_unique_packet, self.PACKET_TOPIC)
157
+ except KeyError:
158
+ pass
159
+ if self._broadcast_thread and self._broadcast_thread.is_alive():
160
+ self._broadcast_thread.join(timeout=2.0)
161
+
162
+ def run_forever(self) -> None:
163
+ self.start()
164
+ try:
165
+ while not self._stop.wait(1.0):
166
+ pass
167
+ finally:
168
+ self.stop()
169
+
170
+ def connect_send_socket(self) -> None:
171
+ if getattr(conn, "socket", None) is None:
172
+ conn.setup_multicast(self.config.udp.mcast_group, int(self.config.udp.mcast_port))
173
+
174
+ def send_nodeinfo(self, destination: int = BROADCAST_NUM) -> int:
175
+ user = mesh_pb2.User(
176
+ id=self.config.node_id,
177
+ long_name=self.config.long_name,
178
+ short_name=self.config.short_name,
179
+ hw_model=resolve_hw_model(self.config.hw_model),
180
+ )
181
+ user.role = resolve_role(self.config.role)
182
+ if self._public_key_b64:
183
+ user.public_key = b64_decode(self._public_key_b64)
184
+
185
+ data = mesh_pb2.Data()
186
+ data.portnum = portnums_pb2.PortNum.NODEINFO_APP
187
+ data.payload = user.SerializeToString()
188
+ data.source = self.node_num
189
+ data.dest = int(destination)
190
+ return self._send_data(data, destination=int(destination), force_pki=False)
191
+
192
+ def send_position(
193
+ self,
194
+ destination: int = BROADCAST_NUM,
195
+ *,
196
+ latitude: Optional[float] = None,
197
+ longitude: Optional[float] = None,
198
+ altitude: Optional[int] = None,
199
+ ) -> int:
200
+ if not self.config.position.enabled:
201
+ raise ValueError("Position sending is disabled in node.json")
202
+ resolved_latitude = self.config.position.latitude if latitude is None else latitude
203
+ resolved_longitude = self.config.position.longitude if longitude is None else longitude
204
+ resolved_altitude = self.config.position.altitude if altitude is None else altitude
205
+ if resolved_latitude is None or resolved_longitude is None:
206
+ raise ValueError("Position latitude and longitude must be configured before sending position")
207
+
208
+ position = mesh_pb2.Position(
209
+ latitude_i=int(float(resolved_latitude) * 1e7),
210
+ longitude_i=int(float(resolved_longitude) * 1e7),
211
+ time=int(time.time()),
212
+ )
213
+ if resolved_altitude is not None:
214
+ position.altitude = int(resolved_altitude)
215
+ data = mesh_pb2.Data()
216
+ data.portnum = portnums_pb2.PortNum.POSITION_APP
217
+ data.payload = position.SerializeToString()
218
+ data.source = self.node_num
219
+ data.dest = int(destination)
220
+ return self._send_data(data, destination=int(destination), force_pki=False)
221
+
222
+ def send_text(
223
+ self,
224
+ destination: Union[str, int],
225
+ message: str,
226
+ pki_mode: str = "auto",
227
+ *,
228
+ reply_id: Optional[int] = None,
229
+ emoji: bool = False,
230
+ hop_limit: Optional[int] = None,
231
+ hop_start: Optional[int] = None,
232
+ want_ack: Optional[bool] = None,
233
+ ) -> int:
234
+ destination_num = self._resolve_destination(destination)
235
+ data = mesh_pb2.Data()
236
+ data.portnum = portnums_pb2.PortNum.TEXT_MESSAGE_APP
237
+ data.payload = message.encode("utf-8")
238
+ data.source = self.node_num
239
+ data.dest = destination_num
240
+ if reply_id is not None:
241
+ data.reply_id = int(reply_id)
242
+ if emoji:
243
+ data.emoji = 1
244
+
245
+ use_pki = self._should_use_pki(destination_num, data.portnum, pki_mode)
246
+ return self._send_data(
247
+ data,
248
+ destination=destination_num,
249
+ force_pki=use_pki,
250
+ hop_limit=hop_limit,
251
+ hop_start=hop_start,
252
+ want_ack=self._default_want_ack(destination_num) if want_ack is None else bool(want_ack),
253
+ )
254
+
255
+ def send_reply(
256
+ self,
257
+ destination: Union[str, int],
258
+ message: str,
259
+ *,
260
+ reply_id: int,
261
+ emoji: bool = False,
262
+ pki_mode: str = "auto",
263
+ hop_limit: Optional[int] = None,
264
+ hop_start: Optional[int] = None,
265
+ want_ack: Optional[bool] = None,
266
+ ) -> int:
267
+ return self.send_text(
268
+ destination,
269
+ message,
270
+ pki_mode=pki_mode,
271
+ reply_id=reply_id,
272
+ emoji=emoji,
273
+ hop_limit=hop_limit,
274
+ hop_start=hop_start,
275
+ want_ack=want_ack,
276
+ )
277
+
278
+ def is_direct_message_for_me(self, packet: mesh_pb2.MeshPacket) -> bool:
279
+ return bool(
280
+ int(getattr(packet, "to", BROADCAST_NUM)) == self.node_num
281
+ and getattr(packet, "from", None) not in (None, self.node_num)
282
+ )
283
+
284
+ def is_text_message(self, packet: mesh_pb2.MeshPacket) -> bool:
285
+ return bool(packet.HasField("decoded") and packet.decoded.portnum in TEXT_PORTNUMS)
286
+
287
+ def get_text_message(self, packet: mesh_pb2.MeshPacket) -> Optional[str]:
288
+ if not self.is_text_message(packet):
289
+ return None
290
+ return packet.decoded.payload.decode("utf-8", "ignore")
291
+
292
+ def reply_to_packet(
293
+ self,
294
+ packet: mesh_pb2.MeshPacket,
295
+ message: str,
296
+ *,
297
+ emoji: bool = False,
298
+ pki_mode: str = "auto",
299
+ want_ack: Optional[bool] = None,
300
+ ) -> int:
301
+ sender_id = getattr(packet, "from", None)
302
+ if sender_id is None:
303
+ raise ValueError("Packet does not have a sender")
304
+
305
+ inbound_hop_limit = packet.hop_limit or self.config.hop_limit or 3
306
+ inbound_hop_start = packet.hop_start or inbound_hop_limit
307
+ return self.send_reply(
308
+ int(sender_id),
309
+ message,
310
+ reply_id=int(getattr(packet, "id")),
311
+ emoji=emoji,
312
+ pki_mode=pki_mode,
313
+ hop_limit=inbound_hop_limit,
314
+ hop_start=inbound_hop_start,
315
+ want_ack=want_ack,
316
+ )
317
+
318
+ def _send_data(
319
+ self,
320
+ data: mesh_pb2.Data,
321
+ *,
322
+ destination: int,
323
+ force_pki: bool,
324
+ hop_limit: Optional[int] = None,
325
+ hop_start: Optional[int] = None,
326
+ want_ack: bool = False,
327
+ ) -> int:
328
+ self.connect_send_socket()
329
+ packet = mesh_pb2.MeshPacket()
330
+ packet.id = self._next_packet_id()
331
+ setattr(packet, "from", self.node_num)
332
+ packet.to = int(destination)
333
+ packet.want_ack = bool(want_ack)
334
+ resolved_hop_limit = int(self.config.hop_limit if hop_limit is None else hop_limit)
335
+ resolved_hop_start = int(resolved_hop_limit if hop_start is None else hop_start)
336
+ if resolved_hop_start < resolved_hop_limit:
337
+ resolved_hop_start = resolved_hop_limit
338
+ packet.hop_limit = resolved_hop_limit
339
+ packet.hop_start = resolved_hop_start
340
+
341
+ if force_pki:
342
+ remote_public_key = self._lookup_public_key(destination)
343
+ if remote_public_key is None:
344
+ raise ValueError(f"Destination {destination} does not have a stored public key in meshdb")
345
+ packet.channel = 0
346
+ packet.pki_encrypted = True
347
+ packet.encrypted = encrypt_dm(
348
+ sender_private_key=b64_decode(self.config.security.private_key),
349
+ receiver_public_key=remote_public_key,
350
+ packet_id=packet.id,
351
+ from_node=self.node_num,
352
+ plaintext=data.SerializeToString(),
353
+ )
354
+ else:
355
+ packet.encrypted = mudp_encrypt_packet(
356
+ self.config.channel.name,
357
+ self.config.channel.psk,
358
+ packet,
359
+ data,
360
+ )
361
+
362
+ raw_packet = packet.SerializeToString()
363
+ register_pending_ack(packet, raw_packet)
364
+ conn.sendto(raw_packet, (conn.host, conn.port))
365
+ self._persist_outbound_packet(packet, data)
366
+ return packet.id
367
+
368
+ def _handle_raw_packet(self, packet: mesh_pb2.MeshPacket, addr: Any = None) -> None:
369
+ del addr
370
+ if not getattr(packet, "rx_time", 0):
371
+ packet.rx_time = int(time.time())
372
+
373
+ if not packet.HasField("decoded"):
374
+ self._try_decode_pki(packet)
375
+ self._maybe_send_ack(packet)
376
+
377
+ def _handle_unique_packet(self, packet: mesh_pb2.MeshPacket, addr: Any = None) -> None:
378
+ del addr
379
+ if not getattr(packet, "rx_time", 0):
380
+ packet.rx_time = int(time.time())
381
+ if not packet.HasField("decoded"):
382
+ self._try_decode_pki(packet)
383
+ if getattr(packet, "from", None) == self.node_num:
384
+ return
385
+
386
+ self._persist_packet(packet)
387
+
388
+ def _try_decode_pki(self, packet: mesh_pb2.MeshPacket) -> bool:
389
+ if packet.channel != 0 or packet.to != self.node_num or not packet.encrypted:
390
+ return False
391
+ sender_public_key = self._lookup_public_key(getattr(packet, "from"))
392
+ if sender_public_key is None:
393
+ return False
394
+ try:
395
+ decrypted = decrypt_dm(
396
+ receiver_private_key=b64_decode(self.config.security.private_key),
397
+ sender_public_key=sender_public_key,
398
+ packet_id=packet.id,
399
+ from_node=getattr(packet, "from"),
400
+ payload=bytes(packet.encrypted),
401
+ )
402
+ decoded = mesh_pb2.Data()
403
+ decoded.ParseFromString(decrypted)
404
+ except (ValueError, DecodeError):
405
+ return False
406
+
407
+ packet.decoded.CopyFrom(decoded)
408
+ packet.pki_encrypted = True
409
+ packet.public_key = sender_public_key
410
+ return True
411
+
412
+ def _maybe_send_ack(self, packet: mesh_pb2.MeshPacket) -> None:
413
+ if not packet.HasField("decoded"):
414
+ return
415
+ if int(getattr(packet, "to", BROADCAST_NUM)) != self.node_num:
416
+ return
417
+ if not getattr(packet, "want_ack", False):
418
+ return
419
+ if is_ack(packet) or is_nak(packet):
420
+ return
421
+ ack_packet = send_ack(packet)
422
+ publish_ack(ack_packet)
423
+
424
+ def _persist_outbound_packet(self, packet: mesh_pb2.MeshPacket, data: mesh_pb2.Data) -> None:
425
+ stored_packet = mesh_pb2.MeshPacket()
426
+ stored_packet.CopyFrom(packet)
427
+ stored_packet.rx_time = int(time.time())
428
+ stored_packet.transport_mechanism = mesh_pb2.MeshPacket.TransportMechanism.TRANSPORT_MULTICAST_UDP
429
+ stored_packet.decoded.CopyFrom(data)
430
+ self._persist_packet(stored_packet)
431
+
432
+ def _persist_packet(self, packet: mesh_pb2.MeshPacket) -> None:
433
+ normalized = meshdb.normalize_packet(packet, "udp")
434
+ meshdb.handle_packet(
435
+ normalized,
436
+ node_database_number=self.node_num,
437
+ db_path=self.meshdb_path,
438
+ )
439
+
440
+ def _broadcast_loop(self) -> None:
441
+ nodeinfo_interval = int(self.config.broadcasts.nodeinfo_interval_seconds)
442
+ position_interval = int(self.config.position.position_interval_seconds)
443
+ have_position = (
444
+ self.config.position.enabled
445
+ and self.config.position.latitude is not None
446
+ and self.config.position.longitude is not None
447
+ )
448
+ if nodeinfo_interval <= 0 and (position_interval <= 0 or not have_position):
449
+ return
450
+ next_nodeinfo = time.monotonic() + max(nodeinfo_interval, 0) if nodeinfo_interval > 0 else None
451
+ next_position = time.monotonic() + max(position_interval, 0) if position_interval > 0 and have_position else None
452
+
453
+ while not self._stop.is_set():
454
+ due_times = [due for due in (next_nodeinfo, next_position) if due is not None]
455
+ if not due_times:
456
+ return
457
+ wait_seconds = max(min(due_times) - time.monotonic(), 0.0)
458
+ if self._stop.wait(wait_seconds):
459
+ return
460
+
461
+ now = time.monotonic()
462
+ if next_nodeinfo is not None and now >= next_nodeinfo:
463
+ self.send_nodeinfo()
464
+ next_nodeinfo = now + nodeinfo_interval
465
+ if next_position is not None and now >= next_position:
466
+ self.send_position()
467
+ next_position = now + position_interval
468
+
469
+ def _resolve_destination(self, destination: Union[str, int]) -> int:
470
+ if isinstance(destination, int):
471
+ return destination
472
+ text = str(destination).strip()
473
+ if text.startswith("!"):
474
+ return parse_node_id(text)
475
+ resolved = meshdb.get_node_num(text, owner_node_num=self.node_num, db_path=self.meshdb_path)
476
+ if isinstance(resolved, list):
477
+ raise ValueError(f"Destination '{destination}' is ambiguous: {resolved}")
478
+ if resolved is None:
479
+ raise ValueError(f"Unknown destination '{destination}'")
480
+ return int(resolved)
481
+
482
+ def _lookup_public_key(self, node_num: int) -> Optional[bytes]:
483
+ if int(node_num) == self.node_num:
484
+ key = self._public_key_b64
485
+ return b64_decode(key) if key else None
486
+ row = meshdb.get_nodeinfo(int(node_num), owner_node_num=self.node_num, db_path=self.meshdb_path)
487
+ if not isinstance(row, dict):
488
+ return None
489
+ public_key = str(row.get("public_key", "")).strip()
490
+ if not public_key:
491
+ return None
492
+ return b64_decode(public_key)
493
+
494
+ def _should_use_pki(self, destination: int, portnum: int, pki_mode: str) -> bool:
495
+ mode = pki_mode.strip().lower()
496
+ if mode not in {"auto", "on", "off"}:
497
+ raise ValueError("pki_mode must be one of: auto, on, off")
498
+ if mode == "off":
499
+ return False
500
+ if destination == BROADCAST_NUM or portnum in PKI_DISALLOWED_PORTNUMS:
501
+ if mode == "on":
502
+ raise ValueError("Meshtastic PKI is only valid for direct messages on supported portnums")
503
+ return False
504
+ if mode == "on":
505
+ return True
506
+ return (
507
+ not self.config.is_licensed
508
+ and self._lookup_public_key(destination) is not None
509
+ )
510
+
511
+ def _default_want_ack(self, destination: int) -> bool:
512
+ return int(destination) != BROADCAST_NUM
513
+
514
+ def _next_packet_id(self) -> int:
515
+ self._message_id = ((self._message_id + 1) % 1024) | (random.getrandbits(22) << 10)
516
+ return self._message_id
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: vnode
3
+ Version: 0.1.2
4
+ Summary: Virtual Meshtastic node using mudp transport and meshdb storage.
5
+ License-Expression: GPL-3.0-only
6
+ License-File: LICENSE
7
+ Author: Ben Lipsey
8
+ Author-email: ben@pdxlocations.com
9
+ Requires-Python: >=3.9,<3.15
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Dist: cryptography (>=43,<45)
18
+ Requires-Dist: meshdb (>=0.1.9)
19
+ Requires-Dist: meshtastic (>=2.7,<3.0)
20
+ Requires-Dist: mudp (>=1.1.0)
21
+ Project-URL: Homepage, https://github.com/pdxlocations/vnode
22
+ Project-URL: Issues, https://github.com/pdxlocations/vnode/issues
23
+ Description-Content-Type: text/markdown
24
+
25
+ # vnode
26
+
27
+ Virtual Meshtastic node runtime built around:
28
+
29
+ - `mudp` for multicast UDP send/receive
30
+ - `meshdb` for packet and node persistence
31
+ - Meshtastic-style PKI DM encryption using X25519 + SHA-256 + AES-CCM
32
+
33
+ ## Config
34
+
35
+ Edit [node.json](node.json).
36
+
37
+ If `node.json` does not exist yet, the runtime will create it automatically from
38
+ [example-node.json](example-node.json).
39
+
40
+ If the template leaves `node_id` blank, the runtime will generate and persist a random
41
+ Meshtastic-style node ID when it creates or first loads `node.json`.
42
+
43
+ The runtime will generate and persist a PKI private key into `node.json` on first run if
44
+ the private key is blank.
45
+
46
+ The derived public key is written automatically to `node.public.key` next to
47
+ `node.json`. That file is generated by the runtime and should not need manual edits.
48
+
49
+ The `position` section in `node.json` controls periodic position broadcasts. Set
50
+ `position.enabled` to `true`, then set `position.latitude` and `position.longitude`, and adjust
51
+ `position.position_interval_seconds` to control how often the node sends `POSITION_APP`
52
+ packets. You can also set `position.altitude` if you want altitude included in the payload.
53
+ Leave `position.enabled` false or leave latitude/longitude as `null` to disable position
54
+ broadcasts.
55
+
56
+ ## Run
57
+
58
+ ```bash
59
+ .venv/bin/pip install -e .
60
+ .venv/bin/python -m vnode --vnode-file node.json run
61
+ ```
62
+
63
+ ## Send a DM
64
+
65
+ ```bash
66
+ .venv/bin/python -m vnode --vnode-file node.json send-text --to '!1234abcd' --message 'hello'
67
+ ```
68
+
69
+ `send-text` uses PKI automatically for direct messages when the destination node has a
70
+ stored public key in `meshdb`. Otherwise it falls back to channel encryption.
71
+ `--config` is still accepted as a compatibility alias for `--vnode-file`.
72
+
73
+ ## Library
74
+
75
+ The installable package lives under `vnode/vnode`, and the public library surface is
76
+ exported from `vnode` directly:
77
+
78
+ ```python
79
+ from vnode import NodeConfig, VirtualNode, generate_keypair
80
+
81
+ node = VirtualNode("node.json")
82
+ packet_id = node.send_text("!1234abcd", "hello")
83
+ ```
84
+
85
+ ## Examples
86
+
87
+ Use these as small runnable references for common tasks:
88
+
89
+ - `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
91
+ - `examples/send_dm.py`: minimal one-shot direct-message sender
92
+ - `examples/library_embed.py`: minimal application-style embedding example using `VirtualNode` directly
93
+ - `examples/watch_reliability.py`: watcher for ACK, NAK, retry, and retransmit-failure events
94
+
95
+ ```bash
96
+ .venv/bin/python examples/autoresponder.py
97
+ .venv/bin/python examples/listen_packets.py
98
+ .venv/bin/python examples/send_dm.py --to '!1234abcd' --message 'hello'
99
+ .venv/bin/python examples/library_embed.py
100
+ .venv/bin/python examples/watch_reliability.py --to '!1234abcd' --message 'hello'
101
+ ```
102
+
103
+ See [examples/README.md](examples/README.md) for the full list.
104
+
105
+ ## License
106
+
107
+ GPL-3.0-only. See [`LICENSE`](LICENSE).
108
+
109
+ Meshtastic® is a registered trademark of Meshtastic LLC. Meshtastic software components are released under various licenses, see GitHub for details. No warranty is provided - use at your own risk.
110
+
@@ -0,0 +1,12 @@
1
+ vnode/__init__.py,sha256=Y6duTTw8kmq5m5gJZRiCwJZmkJJhEUquqvQbx5IP1rw,769
2
+ vnode/__main__.py,sha256=PSQ4rpL0dG6f-qH4N7H-gD9igQkdHzH4yVZDcW8lfZo,80
3
+ vnode/cli.py,sha256=vyH4XvjfLBI6UGpEOhaUzMZqhHzBYYT1DlC5x-K_i-U,1964
4
+ vnode/config.py,sha256=_IA1ZRjqm3cpa7eyx-gCGR22rPqeiqoQdwDlet5PNlo,5394
5
+ vnode/crypto.py,sha256=Oow5_HJHYJGQwdVnVZXlnqT3ljFUW2JJAyo0n4ApS6g,3270
6
+ vnode/example-node.json,sha256=MFyapY-6EnPWgKAIDmdU9fKx091d9p_BwY8re4pKiUA,635
7
+ vnode/runtime.py,sha256=Aivftp7Im19qwJtonqBV0J9qNpqgORuRZTDjJZ1zmik,19683
8
+ vnode-0.1.2.dist-info/METADATA,sha256=8q9dqYd6on6O9M3ENoHN1eq06w604lfDBtBwCTNq6BY,4054
9
+ vnode-0.1.2.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
10
+ vnode-0.1.2.dist-info/entry_points.txt,sha256=o22zxgJ9GOe_cvNiuV_oxkz9wi5oUm8NtM937qk2RKQ,40
11
+ vnode-0.1.2.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
12
+ vnode-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.3.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ vnode=vnode.cli:main
3
+