vnode 0.1.3__tar.gz → 0.1.5__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.
- {vnode-0.1.3 → vnode-0.1.5}/PKG-INFO +14 -3
- {vnode-0.1.3 → vnode-0.1.5}/README.md +13 -2
- {vnode-0.1.3 → vnode-0.1.5}/pyproject.toml +1 -1
- {vnode-0.1.3 → vnode-0.1.5}/vnode/vnode/config.py +15 -0
- {vnode-0.1.3 → vnode-0.1.5}/vnode/vnode/example-node.json +2 -2
- {vnode-0.1.3 → vnode-0.1.5}/vnode/vnode/runtime.py +363 -10
- {vnode-0.1.3 → vnode-0.1.5}/LICENSE +0 -0
- {vnode-0.1.3 → vnode-0.1.5}/vnode/vnode/__init__.py +0 -0
- {vnode-0.1.3 → vnode-0.1.5}/vnode/vnode/__main__.py +0 -0
- {vnode-0.1.3 → vnode-0.1.5}/vnode/vnode/cli.py +0 -0
- {vnode-0.1.3 → vnode-0.1.5}/vnode/vnode/crypto.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vnode
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
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
|
|
@@ -82,14 +82,23 @@ node = VirtualNode("node.json")
|
|
|
82
82
|
packet_id = node.send_text("!1234abcd", "hello")
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
The runtime also mirrors the common Meshtastic Python pubsub and method surface:
|
|
86
|
+
|
|
87
|
+
- topics: `meshtastic.connection.established`, `meshtastic.connection.lost`, `meshtastic.receive`, `meshtastic.receive.text`, `meshtastic.receive.position`, `meshtastic.receive.user`, `meshtastic.receive.data.<PORTNUM>`, `meshtastic.node.updated`, `meshtastic.log.line`
|
|
88
|
+
- methods: `receive()`, `unreceive()`, `close()`, `sendText()`, `sendData()`, `sendPosition()`, `getMyNodeInfo()`, `getMyUser()`, `getLongName()`
|
|
89
|
+
|
|
90
|
+
That means code shaped like the Meshtastic Python library can often run on `VirtualNode` with minimal changes.
|
|
91
|
+
|
|
85
92
|
## Examples
|
|
86
93
|
|
|
87
94
|
Use these as small runnable references for common tasks:
|
|
88
95
|
|
|
89
96
|
- `examples/autoresponder.py`: DM-only reply bot for inbound direct text messages
|
|
90
|
-
- `examples/listen_packets.py`: packet logger
|
|
97
|
+
- `examples/listen_packets.py`: packet logger using the mirrored `meshtastic.receive` callback shape
|
|
91
98
|
- `examples/send_dm.py`: minimal one-shot direct-message sender
|
|
92
|
-
- `examples/library_embed.py`: minimal application-style embedding example using
|
|
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
|
|
93
102
|
- `examples/watch_reliability.py`: watcher for ACK, NAK, retry, and retransmit-failure events
|
|
94
103
|
|
|
95
104
|
```bash
|
|
@@ -97,6 +106,8 @@ Use these as small runnable references for common tasks:
|
|
|
97
106
|
.venv/bin/python examples/listen_packets.py
|
|
98
107
|
.venv/bin/python examples/send_dm.py --to '!1234abcd' --message 'hello'
|
|
99
108
|
.venv/bin/python examples/library_embed.py
|
|
109
|
+
.venv/bin/python examples/meshtastic_compat.py
|
|
110
|
+
.venv/bin/python examples/serial_or_vnode.py
|
|
100
111
|
.venv/bin/python examples/watch_reliability.py --to '!1234abcd' --message 'hello'
|
|
101
112
|
```
|
|
102
113
|
|
|
@@ -58,14 +58,23 @@ node = VirtualNode("node.json")
|
|
|
58
58
|
packet_id = node.send_text("!1234abcd", "hello")
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
The runtime also mirrors the common Meshtastic Python pubsub and method surface:
|
|
62
|
+
|
|
63
|
+
- topics: `meshtastic.connection.established`, `meshtastic.connection.lost`, `meshtastic.receive`, `meshtastic.receive.text`, `meshtastic.receive.position`, `meshtastic.receive.user`, `meshtastic.receive.data.<PORTNUM>`, `meshtastic.node.updated`, `meshtastic.log.line`
|
|
64
|
+
- methods: `receive()`, `unreceive()`, `close()`, `sendText()`, `sendData()`, `sendPosition()`, `getMyNodeInfo()`, `getMyUser()`, `getLongName()`
|
|
65
|
+
|
|
66
|
+
That means code shaped like the Meshtastic Python library can often run on `VirtualNode` with minimal changes.
|
|
67
|
+
|
|
61
68
|
## Examples
|
|
62
69
|
|
|
63
70
|
Use these as small runnable references for common tasks:
|
|
64
71
|
|
|
65
72
|
- `examples/autoresponder.py`: DM-only reply bot for inbound direct text messages
|
|
66
|
-
- `examples/listen_packets.py`: packet logger
|
|
73
|
+
- `examples/listen_packets.py`: packet logger using the mirrored `meshtastic.receive` callback shape
|
|
67
74
|
- `examples/send_dm.py`: minimal one-shot direct-message sender
|
|
68
|
-
- `examples/library_embed.py`: minimal application-style embedding example using
|
|
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
|
|
69
78
|
- `examples/watch_reliability.py`: watcher for ACK, NAK, retry, and retransmit-failure events
|
|
70
79
|
|
|
71
80
|
```bash
|
|
@@ -73,6 +82,8 @@ Use these as small runnable references for common tasks:
|
|
|
73
82
|
.venv/bin/python examples/listen_packets.py
|
|
74
83
|
.venv/bin/python examples/send_dm.py --to '!1234abcd' --message 'hello'
|
|
75
84
|
.venv/bin/python examples/library_embed.py
|
|
85
|
+
.venv/bin/python examples/meshtastic_compat.py
|
|
86
|
+
.venv/bin/python examples/serial_or_vnode.py
|
|
76
87
|
.venv/bin/python examples/watch_reliability.py --to '!1234abcd' --message 'hello'
|
|
77
88
|
```
|
|
78
89
|
|
|
@@ -138,12 +138,27 @@ class NodeConfig:
|
|
|
138
138
|
if value not in (0, 0xFFFFFFFF):
|
|
139
139
|
return f"!{value:08x}"
|
|
140
140
|
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _generated_names(node_id: str) -> Dict[str, str]:
|
|
143
|
+
suffix = str(node_id).strip().lower()[-4:]
|
|
144
|
+
return {
|
|
145
|
+
"long_name": f"Meshtastic {suffix}",
|
|
146
|
+
"short_name": suffix,
|
|
147
|
+
}
|
|
148
|
+
|
|
141
149
|
@classmethod
|
|
142
150
|
def _populate_generated_defaults(cls, payload: Dict[str, Any]) -> bool:
|
|
143
151
|
changed = False
|
|
144
152
|
if not str(payload.get("node_id", "")).strip():
|
|
145
153
|
payload["node_id"] = cls._generate_node_id()
|
|
146
154
|
changed = True
|
|
155
|
+
generated_names = cls._generated_names(str(payload["node_id"]))
|
|
156
|
+
if not str(payload.get("long_name", "")).strip():
|
|
157
|
+
payload["long_name"] = generated_names["long_name"]
|
|
158
|
+
changed = True
|
|
159
|
+
if not str(payload.get("short_name", "")).strip():
|
|
160
|
+
payload["short_name"] = generated_names["short_name"]
|
|
161
|
+
changed = True
|
|
147
162
|
return changed
|
|
148
163
|
|
|
149
164
|
def save(self, path: Union[str, Path]) -> None:
|
|
@@ -5,11 +5,13 @@ import sqlite3
|
|
|
5
5
|
import threading
|
|
6
6
|
import time
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
from typing import Any, Callable, Dict, Optional, Union
|
|
9
10
|
|
|
10
11
|
import meshdb
|
|
12
|
+
from google.protobuf.json_format import MessageToDict
|
|
11
13
|
from google.protobuf.message import DecodeError
|
|
12
|
-
from meshtastic import BROADCAST_NUM, config_pb2, mesh_pb2, portnums_pb2
|
|
14
|
+
from meshtastic import BROADCAST_ADDR, BROADCAST_NUM, config_pb2, mesh_pb2, portnums_pb2
|
|
13
15
|
from mudp import UDPPacketStream
|
|
14
16
|
from mudp.encryption import encrypt_packet as mudp_encrypt_packet
|
|
15
17
|
from mudp.reliability import is_ack, is_nak, publish_ack, register_pending_ack, send_ack
|
|
@@ -62,6 +64,7 @@ def resolve_role(value: Union[str, int]) -> int:
|
|
|
62
64
|
|
|
63
65
|
|
|
64
66
|
class VirtualNode:
|
|
67
|
+
RECEIVE_TOPIC = "mesh.rx.packet"
|
|
65
68
|
PACKET_TOPIC = "mesh.rx.unique_packet"
|
|
66
69
|
DUPLICATE_TOPIC = "mesh.rx.duplicate"
|
|
67
70
|
|
|
@@ -81,6 +84,11 @@ class VirtualNode:
|
|
|
81
84
|
self._last_nodeinfo_sent_monotonic = 0.0
|
|
82
85
|
self._last_position_reply_monotonic = 0.0
|
|
83
86
|
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
|
|
84
92
|
|
|
85
93
|
self._ensure_security_keys()
|
|
86
94
|
self._write_public_key_file()
|
|
@@ -121,6 +129,7 @@ class VirtualNode:
|
|
|
121
129
|
is_licensed=int(self.config.is_licensed),
|
|
122
130
|
public_key=self._public_key_b64,
|
|
123
131
|
)
|
|
132
|
+
self._upsert_local_node_cache()
|
|
124
133
|
|
|
125
134
|
def _configure_mudp_globals(self) -> None:
|
|
126
135
|
mudp_node.node_id = self.config.node_id
|
|
@@ -148,6 +157,10 @@ class VirtualNode:
|
|
|
148
157
|
parse_payload=False,
|
|
149
158
|
)
|
|
150
159
|
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])
|
|
151
164
|
if self.config.broadcasts.send_startup_nodeinfo:
|
|
152
165
|
self.send_nodeinfo()
|
|
153
166
|
self._broadcast_thread = threading.Thread(
|
|
@@ -159,6 +172,8 @@ class VirtualNode:
|
|
|
159
172
|
|
|
160
173
|
def stop(self) -> None:
|
|
161
174
|
self._stop.set()
|
|
175
|
+
was_connected = self._connected
|
|
176
|
+
self._connected = False
|
|
162
177
|
if self.stream is not None:
|
|
163
178
|
self.stream.stop()
|
|
164
179
|
self.stream = None
|
|
@@ -172,6 +187,9 @@ class VirtualNode:
|
|
|
172
187
|
pass
|
|
173
188
|
if self._broadcast_thread and self._broadcast_thread.is_alive():
|
|
174
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)
|
|
175
193
|
|
|
176
194
|
def run_forever(self) -> None:
|
|
177
195
|
self.start()
|
|
@@ -181,6 +199,133 @@ class VirtualNode:
|
|
|
181
199
|
finally:
|
|
182
200
|
self.stop()
|
|
183
201
|
|
|
202
|
+
def receive(self, callback: Callable[..., Any]) -> None:
|
|
203
|
+
pub.subscribe(callback, "meshtastic.receive")
|
|
204
|
+
|
|
205
|
+
def unreceive(self, callback: Callable[..., Any]) -> None:
|
|
206
|
+
try:
|
|
207
|
+
pub.unsubscribe(callback, "meshtastic.receive")
|
|
208
|
+
except KeyError:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
def close(self) -> None:
|
|
212
|
+
self.stop()
|
|
213
|
+
|
|
214
|
+
def sendText(
|
|
215
|
+
self,
|
|
216
|
+
text: str,
|
|
217
|
+
destinationId: Union[int, str] = BROADCAST_ADDR,
|
|
218
|
+
wantAck: bool = False,
|
|
219
|
+
wantResponse: bool = False,
|
|
220
|
+
onResponse: Optional[Callable[[dict], Any]] = None,
|
|
221
|
+
channelIndex: int = 0,
|
|
222
|
+
portNum: int = portnums_pb2.PortNum.TEXT_MESSAGE_APP,
|
|
223
|
+
replyId: Optional[int] = None,
|
|
224
|
+
hopLimit: Optional[int] = None,
|
|
225
|
+
) -> mesh_pb2.MeshPacket:
|
|
226
|
+
return self.sendData(
|
|
227
|
+
text.encode("utf-8"),
|
|
228
|
+
destinationId=destinationId,
|
|
229
|
+
portNum=portNum,
|
|
230
|
+
wantAck=wantAck,
|
|
231
|
+
wantResponse=wantResponse,
|
|
232
|
+
onResponse=onResponse,
|
|
233
|
+
channelIndex=channelIndex,
|
|
234
|
+
hopLimit=hopLimit,
|
|
235
|
+
replyId=replyId,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def sendData(
|
|
239
|
+
self,
|
|
240
|
+
data: Any,
|
|
241
|
+
destinationId: Union[int, str] = BROADCAST_ADDR,
|
|
242
|
+
portNum: int = portnums_pb2.PortNum.PRIVATE_APP,
|
|
243
|
+
wantAck: bool = False,
|
|
244
|
+
wantResponse: bool = False,
|
|
245
|
+
onResponse: Optional[Callable[[dict], Any]] = None,
|
|
246
|
+
onResponseAckPermitted: bool = False,
|
|
247
|
+
channelIndex: int = 0,
|
|
248
|
+
hopLimit: Optional[int] = None,
|
|
249
|
+
pkiEncrypted: bool = False,
|
|
250
|
+
publicKey: Optional[bytes] = None,
|
|
251
|
+
priority: int = mesh_pb2.MeshPacket.Priority.RELIABLE,
|
|
252
|
+
replyId: Optional[int] = None,
|
|
253
|
+
) -> mesh_pb2.MeshPacket:
|
|
254
|
+
if channelIndex != 0:
|
|
255
|
+
raise ValueError("vnode currently supports only channelIndex=0")
|
|
256
|
+
if getattr(data, "SerializeToString", None):
|
|
257
|
+
data = data.SerializeToString()
|
|
258
|
+
payload = bytes(data)
|
|
259
|
+
destination_num = self._resolve_destination(destinationId)
|
|
260
|
+
|
|
261
|
+
decoded = mesh_pb2.Data()
|
|
262
|
+
decoded.portnum = int(portNum)
|
|
263
|
+
decoded.payload = payload
|
|
264
|
+
decoded.source = self.node_num
|
|
265
|
+
decoded.dest = destination_num
|
|
266
|
+
decoded.want_response = bool(wantResponse)
|
|
267
|
+
if replyId is not None:
|
|
268
|
+
decoded.reply_id = int(replyId)
|
|
269
|
+
|
|
270
|
+
packet = self._send_packet(
|
|
271
|
+
decoded,
|
|
272
|
+
destination=destination_num,
|
|
273
|
+
force_pki=bool(pkiEncrypted),
|
|
274
|
+
hop_limit=hopLimit,
|
|
275
|
+
hop_start=None,
|
|
276
|
+
want_ack=bool(wantAck),
|
|
277
|
+
priority=priority,
|
|
278
|
+
remote_public_key=publicKey,
|
|
279
|
+
)
|
|
280
|
+
if onResponse is not None:
|
|
281
|
+
self._response_handlers[int(packet.id)] = (onResponse, bool(onResponseAckPermitted))
|
|
282
|
+
return self._packet_for_return(packet, decoded)
|
|
283
|
+
|
|
284
|
+
def sendPosition(
|
|
285
|
+
self,
|
|
286
|
+
latitude: float = 0.0,
|
|
287
|
+
longitude: float = 0.0,
|
|
288
|
+
altitude: int = 0,
|
|
289
|
+
destinationId: Union[int, str] = BROADCAST_ADDR,
|
|
290
|
+
wantAck: bool = False,
|
|
291
|
+
wantResponse: bool = False,
|
|
292
|
+
channelIndex: int = 0,
|
|
293
|
+
hopLimit: Optional[int] = None,
|
|
294
|
+
) -> mesh_pb2.MeshPacket:
|
|
295
|
+
position = mesh_pb2.Position()
|
|
296
|
+
if latitude != 0.0:
|
|
297
|
+
position.latitude_i = int(latitude / 1e-7)
|
|
298
|
+
if longitude != 0.0:
|
|
299
|
+
position.longitude_i = int(longitude / 1e-7)
|
|
300
|
+
if altitude != 0:
|
|
301
|
+
position.altitude = int(altitude)
|
|
302
|
+
return self.sendData(
|
|
303
|
+
position,
|
|
304
|
+
destinationId=destinationId,
|
|
305
|
+
portNum=portnums_pb2.PortNum.POSITION_APP,
|
|
306
|
+
wantAck=wantAck,
|
|
307
|
+
wantResponse=wantResponse,
|
|
308
|
+
channelIndex=channelIndex,
|
|
309
|
+
hopLimit=hopLimit,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def getMyNodeInfo(self) -> Optional[Dict[str, Any]]:
|
|
313
|
+
return self.nodesByNum.get(self.node_num)
|
|
314
|
+
|
|
315
|
+
def getMyUser(self) -> Optional[Dict[str, Any]]:
|
|
316
|
+
node_info = self.getMyNodeInfo()
|
|
317
|
+
if node_info is None:
|
|
318
|
+
return None
|
|
319
|
+
user = node_info.get("user")
|
|
320
|
+
return user if isinstance(user, dict) else None
|
|
321
|
+
|
|
322
|
+
def getLongName(self) -> Optional[str]:
|
|
323
|
+
user = self.getMyUser()
|
|
324
|
+
if user is None:
|
|
325
|
+
return None
|
|
326
|
+
value = user.get("longName")
|
|
327
|
+
return str(value) if value is not None else None
|
|
328
|
+
|
|
184
329
|
def connect_send_socket(self) -> None:
|
|
185
330
|
if getattr(conn, "socket", None) is None:
|
|
186
331
|
conn.setup_multicast(self.config.udp.mcast_group, int(self.config.udp.mcast_port))
|
|
@@ -381,12 +526,36 @@ class VirtualNode:
|
|
|
381
526
|
hop_start: Optional[int] = None,
|
|
382
527
|
want_ack: bool = False,
|
|
383
528
|
) -> int:
|
|
529
|
+
packet = self._send_packet(
|
|
530
|
+
data,
|
|
531
|
+
destination=destination,
|
|
532
|
+
force_pki=force_pki,
|
|
533
|
+
hop_limit=hop_limit,
|
|
534
|
+
hop_start=hop_start,
|
|
535
|
+
want_ack=want_ack,
|
|
536
|
+
)
|
|
537
|
+
return packet.id
|
|
538
|
+
|
|
539
|
+
def _send_packet(
|
|
540
|
+
self,
|
|
541
|
+
data: mesh_pb2.Data,
|
|
542
|
+
*,
|
|
543
|
+
destination: int,
|
|
544
|
+
force_pki: bool,
|
|
545
|
+
hop_limit: Optional[int] = None,
|
|
546
|
+
hop_start: Optional[int] = None,
|
|
547
|
+
want_ack: bool = False,
|
|
548
|
+
priority: Optional[int] = None,
|
|
549
|
+
remote_public_key: Optional[bytes] = None,
|
|
550
|
+
) -> mesh_pb2.MeshPacket:
|
|
384
551
|
self.connect_send_socket()
|
|
385
552
|
packet = mesh_pb2.MeshPacket()
|
|
386
553
|
packet.id = self._next_packet_id()
|
|
387
554
|
setattr(packet, "from", self.node_num)
|
|
388
555
|
packet.to = int(destination)
|
|
389
556
|
packet.want_ack = bool(want_ack)
|
|
557
|
+
if priority is not None:
|
|
558
|
+
packet.priority = int(priority)
|
|
390
559
|
resolved_hop_limit = int(self.config.hop_limit if hop_limit is None else hop_limit)
|
|
391
560
|
resolved_hop_start = int(resolved_hop_limit if hop_start is None else hop_start)
|
|
392
561
|
if resolved_hop_start < resolved_hop_limit:
|
|
@@ -395,14 +564,15 @@ class VirtualNode:
|
|
|
395
564
|
packet.hop_start = resolved_hop_start
|
|
396
565
|
|
|
397
566
|
if force_pki:
|
|
398
|
-
|
|
399
|
-
if
|
|
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:
|
|
400
569
|
raise ValueError(f"Destination {destination} does not have a stored public key in meshdb")
|
|
401
570
|
packet.channel = 0
|
|
402
571
|
packet.pki_encrypted = True
|
|
572
|
+
packet.public_key = key_bytes
|
|
403
573
|
packet.encrypted = encrypt_dm(
|
|
404
574
|
sender_private_key=b64_decode(self.config.security.private_key),
|
|
405
|
-
receiver_public_key=
|
|
575
|
+
receiver_public_key=key_bytes,
|
|
406
576
|
packet_id=packet.id,
|
|
407
577
|
from_node=self.node_num,
|
|
408
578
|
plaintext=data.SerializeToString(),
|
|
@@ -419,20 +589,21 @@ class VirtualNode:
|
|
|
419
589
|
register_pending_ack(packet, raw_packet)
|
|
420
590
|
conn.sendto(raw_packet, (conn.host, conn.port))
|
|
421
591
|
self._persist_outbound_packet(packet, data)
|
|
422
|
-
return packet
|
|
592
|
+
return packet
|
|
423
593
|
|
|
424
|
-
def _handle_raw_packet(self, packet: mesh_pb2.MeshPacket,
|
|
425
|
-
del
|
|
594
|
+
def _handle_raw_packet(self, packet: mesh_pb2.MeshPacket, **kwargs: Any) -> None:
|
|
595
|
+
del kwargs
|
|
426
596
|
if not getattr(packet, "rx_time", 0):
|
|
427
597
|
packet.rx_time = int(time.time())
|
|
428
598
|
|
|
429
599
|
if not packet.HasField("decoded"):
|
|
430
600
|
self._try_decode_pki(packet)
|
|
601
|
+
self._publish_meshtastic_receive(packet)
|
|
431
602
|
self._maybe_send_ack(packet)
|
|
432
603
|
self._maybe_send_response(packet)
|
|
433
604
|
|
|
434
|
-
def _handle_unique_packet(self, packet: mesh_pb2.MeshPacket,
|
|
435
|
-
del
|
|
605
|
+
def _handle_unique_packet(self, packet: mesh_pb2.MeshPacket, **kwargs: Any) -> None:
|
|
606
|
+
del kwargs
|
|
436
607
|
if not getattr(packet, "rx_time", 0):
|
|
437
608
|
packet.rx_time = int(time.time())
|
|
438
609
|
if not packet.HasField("decoded"):
|
|
@@ -441,6 +612,186 @@ class VirtualNode:
|
|
|
441
612
|
return
|
|
442
613
|
|
|
443
614
|
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)
|
|
444
795
|
|
|
445
796
|
def _try_decode_pki(self, packet: mesh_pb2.MeshPacket) -> bool:
|
|
446
797
|
if packet.channel != 0 or packet.to != self.node_num or not packet.encrypted:
|
|
@@ -615,6 +966,8 @@ class VirtualNode:
|
|
|
615
966
|
if isinstance(destination, int):
|
|
616
967
|
return destination
|
|
617
968
|
text = str(destination).strip()
|
|
969
|
+
if text == BROADCAST_ADDR:
|
|
970
|
+
return BROADCAST_NUM
|
|
618
971
|
if text.startswith("!"):
|
|
619
972
|
return parse_node_id(text)
|
|
620
973
|
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
|