Skip to content

Commit 43e1565

Browse files
committed
feat: Enhance vnode with unique packet handling and update examples for clarity
1 parent b3b3354 commit 43e1565

6 files changed

Lines changed: 61 additions & 13 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ Supported `VirtualNode` calls:
8282
- MeshInterface-compatible sends: `sendText()`, `sendAlert()`, `sendData()`, `sendPosition()`
8383
- Packet helpers: `is_direct_message_for_me()`, `is_text_message()`, `get_text_message()`, `reply_to_packet()`
8484

85+
`receive(callback)` is deduplicated. Repeated multicast copies stay on `mudp`'s wire-level
86+
topics, while `vnode` publishes `meshtastic.receive` from `mesh.rx.unique_packet` so
87+
application callbacks run once per logical packet.
88+
8589
## Examples
8690

8791
Use these as small runnable references for common tasks:
@@ -93,7 +97,7 @@ Use these as small runnable references for common tasks:
9397
- `examples/serial_or_vnode.py`: try a serial Meshtastic node first, then fall back to `VirtualNode`
9498
- `examples/send_dm.py`: minimal one-shot direct-message sender
9599
- `examples/library_embed.py`: minimal application-style embedding example using `VirtualNode` directly
96-
- `examples/watch_reliability.py`: watcher for ACK, NAK, retry, and retransmit-failure events
100+
- `examples/watch_reliability.py`: watcher for unique ACK, NAK, retry, and retransmit-failure events
97101

98102
```bash
99103
.venv/bin/python examples/autoresponder.py

examples/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ It skips duplicates, ignores replies, and alternates between an emoji and plain-
1616

1717
Shows the minimal `node.receive(callback)` / `node.unreceive(callback)` pattern using
1818
Meshtastic-style `(packet, interface)` callbacks. Edit `VNODE_FILE` in the script if you
19-
want to use a config path other than `node.json`.
19+
want to use a config path other than `node.json`. The callback is fed from `mudp`'s
20+
unique-packet topic, so duplicates are already suppressed before app code sees them.
2021

2122
```bash
2223
.venv/bin/python examples/basic_subscriptions.py
@@ -72,8 +73,9 @@ callback shape.
7273

7374
## Watch ACK and retry events
7475

75-
Subscribes to `mudp` reliability events and prints ACK, NAK, retry, and max-retransmit updates.
76-
Optionally sends a startup DM so you can watch the full reliability lifecycle immediately.
76+
Subscribes to `mudp` reliability events and prints unique ACK, NAK, retry, and
77+
max-retransmit updates. Optionally sends a startup DM so you can watch the full
78+
reliability lifecycle immediately.
7779

7880
```bash
7981
.venv/bin/python examples/watch_reliability.py --vnode-file node.json --to '!1234abcd' --message 'hello'

examples/watch_reliability.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ def main() -> int:
7272
node.start()
7373

7474
# These pubsub topics come from mudp, not the example itself.
75-
# They let app code observe ACK resolution and retry behavior without reimplementing transport logic.
75+
# ACK/NAK are emitted once per logical packet on mudp's unique-only routing topics.
76+
# Use mesh.rx.packet / mesh.rx.duplicate instead if you need every wire observation.
7677
pub.subscribe(on_ack, "mesh.rx.ack")
7778
pub.subscribe(on_nak, "mesh.rx.nak")
7879
pub.subscribe(on_retry, "mesh.tx.retry")

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "vnode"
3-
version = "0.1.7"
3+
version = "0.1.8"
44
description = "Virtual Meshtastic node using mudp transport and meshdb storage."
55
authors = [
66
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
@@ -12,7 +12,7 @@ dependencies = [
1212
"cryptography>=43,<45",
1313
"meshdb>=0.1.9",
1414
"meshtastic>=2.7,<3.0",
15-
"mudp>=1.1.0",
15+
"mudp>=1.1.5",
1616
]
1717

1818
[project.urls]

vnode/tests/test_crypto.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,44 @@ def on_receive(packet, interface) -> None:
866866
self.assertEqual(packet["decoded"]["text"], "hello")
867867
self.assertIs(packet["raw"], inbound)
868868

869+
def test_receive_callback_runs_once_when_duplicate_wire_copy_is_not_unique(self) -> None:
870+
with tempfile.TemporaryDirectory() as temp_dir:
871+
root = Path(temp_dir)
872+
_public_key, private_key = generate_keypair()
873+
config_path = self._write_temp_config(root, b64_encode(private_key))
874+
node = VirtualNode(config_path)
875+
876+
received: list[tuple[dict[str, object], object]] = []
877+
878+
def on_receive(packet, interface) -> None:
879+
received.append((packet, interface))
880+
881+
node.receive(on_receive)
882+
try:
883+
inbound = mesh_pb2.MeshPacket()
884+
inbound.id = 5152
885+
setattr(inbound, "from", int("12345678", 16))
886+
inbound.to = node.node_num
887+
inbound.channel = 0
888+
inbound.hop_limit = 3
889+
inbound.hop_start = 3
890+
inbound.decoded.portnum = portnums_pb2.PortNum.TEXT_MESSAGE_APP
891+
inbound.decoded.payload = b"hello once"
892+
893+
node._handle_raw_packet(inbound)
894+
node._handle_unique_packet(inbound)
895+
896+
duplicate = mesh_pb2.MeshPacket()
897+
duplicate.CopyFrom(inbound)
898+
node._handle_raw_packet(duplicate)
899+
finally:
900+
node.unreceive(on_receive)
901+
902+
self.assertEqual(len(received), 1)
903+
packet, interface = received[0]
904+
self.assertIs(interface, node)
905+
self.assertEqual(packet["decoded"]["text"], "hello once")
906+
869907
def test_sendText_compat_returns_meshpacket(self) -> None:
870908
with tempfile.TemporaryDirectory() as temp_dir:
871909
root = Path(temp_dir)

vnode/vnode/runtime.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ def resolve_role(value: Union[str, int]) -> int:
6363

6464

6565
class VirtualNode:
66+
RAW_PACKET_TOPIC = "mesh.rx.packet"
6667
PACKET_TOPIC = "mesh.rx.unique_packet"
68+
ACK_TOPIC = "mesh.rx.ack"
69+
NAK_TOPIC = "mesh.rx.nak"
6770
DUPLICATE_TOPIC = "mesh.rx.duplicate"
6871
RECEIVE_TOPIC = "meshtastic.receive"
6972

@@ -142,11 +145,11 @@ def _configure_mudp_globals(self) -> None:
142145
def start(self) -> None:
143146
if self.stream is not None:
144147
return
145-
pub.subscribe(self._handle_raw_packet, "mesh.rx.packet")
148+
pub.subscribe(self._handle_raw_packet, self.RAW_PACKET_TOPIC)
146149
pub.subscribe(self._handle_unique_packet, self.PACKET_TOPIC)
147150
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")
151+
pub.subscribe(self._handle_compat_ack, self.ACK_TOPIC)
152+
pub.subscribe(self._handle_compat_nak, self.NAK_TOPIC)
150153
self.stream = UDPPacketStream(
151154
self.config.udp.mcast_group,
152155
int(self.config.udp.mcast_port),
@@ -169,7 +172,7 @@ def stop(self) -> None:
169172
self.stream.stop()
170173
self.stream = None
171174
try:
172-
pub.unsubscribe(self._handle_raw_packet, "mesh.rx.packet")
175+
pub.unsubscribe(self._handle_raw_packet, self.RAW_PACKET_TOPIC)
173176
except KeyError:
174177
pass
175178
try:
@@ -178,8 +181,8 @@ def stop(self) -> None:
178181
pass
179182
for topic, listener in (
180183
(self.RECEIVE_TOPIC, self._handle_compat_response_packet),
181-
("mesh.rx.ack", self._handle_compat_ack),
182-
("mesh.rx.nak", self._handle_compat_nak),
184+
(self.ACK_TOPIC, self._handle_compat_ack),
185+
(self.NAK_TOPIC, self._handle_compat_nak),
183186
):
184187
try:
185188
pub.unsubscribe(listener, topic)

0 commit comments

Comments
 (0)