Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/meshcore_console/core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class EventType(StrEnum):

# EventService events (mesh.* naming)
MESH_CONTACT_NEW = "mesh.contact.new"
MESH_NODE_DISCOVERED = "mesh.network.node_discovered"
MESH_CHANNEL_MESSAGE_NEW = "mesh.channel.message.new"
MESH_MESSAGE_NEW = "mesh.message.new"

Expand Down
85 changes: 72 additions & 13 deletions src/meshcore_console/meshcore/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ def _process_event_for_peers(self, event: MeshEventDict) -> None:
EventType.CONTACT_RECEIVED,
EventType.ADVERT_RECEIVED,
EventType.MESH_CONTACT_NEW,
EventType.MESH_NODE_DISCOVERED,
):
self._process_advert_event(data)

Expand All @@ -536,7 +537,7 @@ def _process_advert_event(self, data: MeshEventDict) -> None:
peer_name = repair_utf8(str(peer_name))

peer_id = data.get("sender_id") or data.get("peer_id")
public_key = data.get("sender_pubkey")
public_key = data.get("sender_pubkey") or data.get("public_key")

# Skip our own advert reflected back through a repeater.
self_pubkey = self.get_self_public_key()
Expand All @@ -551,9 +552,14 @@ def _process_advert_event(self, data: MeshEventDict) -> None:
rssi: int | None = int(rssi_raw) if rssi_raw is not None else None
snr: float | None = float(snr_raw) if snr_raw is not None else None

# Extract GPS coordinates from ADVERT
advert_lat_raw = data.get("advert_lat")
advert_lon_raw = data.get("advert_lon")
# Extract GPS coordinates from ADVERT (packet events use advert_lat/lon,
# NODE_DISCOVERED events use lat/lon).
advert_lat_raw = (
data.get("advert_lat") if data.get("advert_lat") is not None else data.get("lat")
)
advert_lon_raw = (
data.get("advert_lon") if data.get("advert_lon") is not None else data.get("lon")
)
advert_lat: float | None = float(advert_lat_raw) if advert_lat_raw is not None else None
advert_lon: float | None = float(advert_lon_raw) if advert_lon_raw is not None else None
has_location = (
Expand All @@ -566,9 +572,27 @@ def _process_advert_event(self, data: MeshEventDict) -> None:

# Determine repeater status from advert_type (lower nibble of ADVERT flags byte).
# ADV_TYPE_REPEATER = 2 per pyMC_core.
advert_type = data.get("advert_type")
advert_type = data.get("advert_type") or data.get("contact_type")
is_repeater = int(advert_type) == 2 if advert_type is not None else False

# Extract raw routing path so the ContactBook Contact gets
# out_path/out_path_len for direct routing. NODE_DISCOVERED events
# carry inbound_path (bytes) + path_len_encoded directly; for "packet"
# events reconstruct from the decoded path_hops list. Only derive
# path when the event actually carries routing data ("path_hops" key
# present) — identity-only events like mesh.contact.new must not
# overwrite a previously learned route.
inbound_path: bytes | None = data.get("inbound_path") # type: ignore[assignment]
path_len_encoded: int | None = data.get("path_len_encoded") # type: ignore[assignment]
if path_len_encoded is None and public_key and "path_hops" in data:
if not path_hops:
inbound_path = b""
path_len_encoded = 0
Comment on lines +588 to +590
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve existing route when advert has no path data

When path_len_encoded is missing, this branch unconditionally sets path_len_encoded = 0 for any contact with a public key and no path_hops. In mesh.contact.new/similar events that carry identity but no routing info, _sync_contact_to_book() will overwrite a previously learned direct route with flood routing (out_path_len=0), so repeater responses can fail again until another routed advert arrives.

Useful? React with 👍 / 👎.

else:
hash_size = len(path_hops[0]) // 2 or 1
inbound_path = bytes.fromhex("".join(path_hops))
path_len_encoded = ((hash_size - 1) << 6) | len(path_hops)

if peer_name in self._peers:
self._update_existing_peer(
peer_name,
Expand All @@ -581,6 +605,8 @@ def _process_advert_event(self, data: MeshEventDict) -> None:
advert_lat,
advert_lon,
is_repeater,
inbound_path=inbound_path,
path_len_encoded=path_len_encoded,
)
else:
self._create_new_peer(
Expand All @@ -595,6 +621,8 @@ def _process_advert_event(self, data: MeshEventDict) -> None:
has_location,
advert_lat,
advert_lon,
inbound_path=inbound_path,
path_len_encoded=path_len_encoded,
)

def _update_existing_peer(
Expand All @@ -609,6 +637,8 @@ def _update_existing_peer(
advert_lat: float | None,
advert_lon: float | None,
is_repeater: bool = False,
inbound_path: bytes | None = None,
path_len_encoded: int | None = None,
) -> None:
"""Update an existing peer with new advert data."""
existing = self._peers[peer_name]
Expand All @@ -620,7 +650,9 @@ def _update_existing_peer(
existing.is_repeater = is_repeater
if public_key:
existing.public_key = public_key
self._sync_contact_to_book(peer_name, public_key)
self._sync_contact_to_book(
peer_name, public_key, inbound_path=inbound_path, path_len_encoded=path_len_encoded
)
if has_location and advert_lat is not None and advert_lon is not None:
existing.latitude = advert_lat
existing.longitude = advert_lon
Expand All @@ -640,6 +672,8 @@ def _create_new_peer(
has_location: bool,
advert_lat: float | None,
advert_lon: float | None,
inbound_path: bytes | None = None,
path_len_encoded: int | None = None,
) -> None:
"""Create a new peer from advert data."""
peer = Peer(
Expand All @@ -659,7 +693,9 @@ def _create_new_peer(
self._peers[peer_name] = peer
self._peer_store.add_or_update(peer)
if public_key:
self._sync_contact_to_book(peer_name, public_key)
self._sync_contact_to_book(
peer_name, public_key, inbound_path=inbound_path, path_len_encoded=path_len_encoded
)

def _process_message_event(self, data: MeshEventDict, event_type: str = "") -> None:
"""Process an incoming message event."""
Expand Down Expand Up @@ -959,13 +995,36 @@ def _seed_contact_book(self) -> None:
"""Populate the session's contact book with known peers that have public keys."""
book = self._session.contact_book
for peer in self._peers.values():
if peer.public_key:
book.add_contact({"name": peer.display_name, "public_key": peer.public_key})

def _sync_contact_to_book(self, name: str, public_key: str) -> None:
if not peer.public_key:
continue
book.add_contact({"name": peer.display_name, "public_key": peer.public_key})
contact = book.get_by_name(peer.display_name)
if contact is not None and peer.last_path is not None:
if not peer.last_path:
contact.out_path = b""
contact.out_path_len = 0
else:
hash_size = len(peer.last_path[0]) // 2 or 1
contact.out_path = bytes.fromhex("".join(peer.last_path))
contact.out_path_len = ((hash_size - 1) << 6) | len(peer.last_path)

def _sync_contact_to_book(
self,
name: str,
public_key: str,
inbound_path: bytes | None = None,
path_len_encoded: int | None = None,
) -> None:
"""Add or update a single contact in the session's contact book."""
if self._connected:
self._session.contact_book.add_contact({"name": name, "public_key": public_key})
if not self._connected:
return
book = self._session.contact_book
book.add_contact({"name": name, "public_key": public_key})
if path_len_encoded is not None:
contact = book.get_by_name(name)
if contact is not None:
contact.out_path = inbound_path or b""
contact.out_path_len = path_len_encoded

def set_event_notify(self, notify_fn: Callable[[], None]) -> None:
self._event_notify = notify_fn
Expand Down
9 changes: 6 additions & 3 deletions src/meshcore_console/meshcore/contact_book.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ class Contact:

name: str
public_key: str # 64-char hex string
out_path: list | None = None # Routing path set by pyMC_core on advert receipt
out_path: bytes | None = None
out_path_len: int = -1 # -1 = unknown → flood; 0 = direct; >0 = encoded hop count


class ContactBook:
Expand Down Expand Up @@ -62,9 +63,11 @@ def add_contact(self, data: dict[str, str] | Contact) -> None:
entry = Contact(name=data.get("name", ""), public_key=data.get("public_key", ""))
if not entry.name or not entry.public_key:
return
# Update existing or append
for i, existing in enumerate(self.contacts):
if existing.name == entry.name:
self.contacts[i] = entry
existing.public_key = entry.public_key
if entry.out_path is not None:
existing.out_path = entry.out_path
existing.out_path_len = entry.out_path_len
return
self.contacts.append(entry)
31 changes: 20 additions & 11 deletions tests/unit/test_contact_book.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,20 @@ def test_contact_has_out_path_default() -> None:
"""Contact.out_path defaults to None so pyMC_core can read it before an advert arrives."""
contact = Contact(name="Alice", public_key="ab" * 32)
assert contact.out_path is None
assert contact.out_path_len == -1


def test_contact_allows_dynamic_attributes() -> None:
"""pyMC_core sets dynamic attributes (e.g. out_path) on contacts during advert processing.
"""pyMC_core sets dynamic attributes on contacts during advert processing.

Contact must NOT use slots=True or pyMC_core will crash with AttributeError.
"""
contact = Contact(name="Alice", public_key="ab" * 32)

# Simulate pyMC_core setting out_path after processing an advert
contact.out_path = [0xA2, 0xB3]
assert contact.out_path == [0xA2, 0xB3]
contact.out_path = b"\xa2\xb3"
contact.out_path_len = 2
assert contact.out_path == b"\xa2\xb3"
assert contact.out_path_len == 2

# pyMC_core may also set other dynamic attributes we don't declare
contact.last_rssi = -72 # type: ignore[attr-defined]
Expand All @@ -36,20 +38,27 @@ def test_contact_book_add_and_lookup() -> None:


def test_contact_book_update_preserves_out_path() -> None:
"""Updating a contact via add_contact should not lose pyMC_core-set attributes."""
"""Updating a contact via add_contact preserves existing path data."""
book = ContactBook()
book.add_contact({"name": "Alice", "public_key": "ab" * 32})

# Simulate pyMC_core setting out_path on the contact
contact = book.get_by_name("Alice")
assert contact is not None
contact.out_path = [0xA2]
contact.out_path = b"\xa2"
contact.out_path_len = 1

# Re-adding the same contact (e.g. from a new advert) replaces the entry
# Re-adding with a dict (no path info) preserves existing path
book.add_contact({"name": "Alice", "public_key": "cd" * 32})
updated = book.get_by_name("Alice")
assert updated is not None
assert updated.public_key == "cd" * 32
# out_path resets because it's a new Contact object — this is expected;
# pyMC_core will re-set it on the next advert
assert updated.out_path is None
assert updated.out_path == b"\xa2"
assert updated.out_path_len == 1

# Re-adding with a Contact that has explicit path overwrites
new_contact = Contact(name="Alice", public_key="ef" * 32, out_path=b"", out_path_len=0)
book.add_contact(new_contact)
updated2 = book.get_by_name("Alice")
assert updated2 is not None
assert updated2.out_path == b""
assert updated2.out_path_len == 0
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading