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/__init__.py +40 -0
- vnode/__main__.py +5 -0
- vnode/cli.py +64 -0
- vnode/config.py +161 -0
- vnode/crypto.py +103 -0
- vnode/example-node.json +34 -0
- vnode/runtime.py +516 -0
- vnode-0.1.2.dist-info/METADATA +110 -0
- vnode-0.1.2.dist-info/RECORD +12 -0
- vnode-0.1.2.dist-info/WHEEL +4 -0
- vnode-0.1.2.dist-info/entry_points.txt +3 -0
- vnode-0.1.2.dist-info/licenses/LICENSE +674 -0
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,,
|