diff --git a/src/meshcore_console/core/enums.py b/src/meshcore_console/core/enums.py index c10f840..8ce3881 100644 --- a/src/meshcore_console/core/enums.py +++ b/src/meshcore_console/core/enums.py @@ -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" diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 500a8c7..ccbb3a7 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -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) @@ -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() @@ -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 = ( @@ -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 + 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, @@ -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( @@ -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( @@ -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] @@ -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 @@ -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( @@ -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.""" @@ -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 diff --git a/src/meshcore_console/meshcore/contact_book.py b/src/meshcore_console/meshcore/contact_book.py index e435840..dc96575 100644 --- a/src/meshcore_console/meshcore/contact_book.py +++ b/src/meshcore_console/meshcore/contact_book.py @@ -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: @@ -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) diff --git a/tests/unit/test_contact_book.py b/tests/unit/test_contact_book.py index 5f0f0f8..bf8b5b1 100644 --- a/tests/unit/test_contact_book.py +++ b/tests/unit/test_contact_book.py @@ -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] @@ -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 diff --git a/uv.lock b/uv.lock index de0ae92..b600edf 100644 --- a/uv.lock +++ b/uv.lock @@ -530,7 +530,7 @@ wheels = [ [[package]] name = "meshcore-uconsole" -version = "1.10.0" +version = "1.11.0" source = { editable = "." } dependencies = [ { name = "gpsdclient" },