diff --git a/app/CLAUDE.md b/app/CLAUDE.md index 060f26367..342f92315 100644 --- a/app/CLAUDE.md +++ b/app/CLAUDE.md @@ -531,13 +531,12 @@ The `POST /api/contacts/{key}/telemetry` endpoint fetches status, neighbors, and ### Request Flow 1. Verify contact exists and is a repeater (type=2) -2. Sync contacts from radio with `ensure_contacts()` -3. Remove and re-add contact with flood mode (clears stale auth state) -4. Send login with password -5. Request status with retries (3 attempts, 10s timeout) -6. Fetch neighbors with `fetch_all_neighbours()` (handles pagination) -7. Fetch ACL with `req_acl_sync()` -8. Resolve pubkey prefixes to contact names from database +2. Add contact to radio with stored path data (from advertisements) +3. Send login with password +4. Request status with retries (3 attempts, 10s timeout) +5. Fetch neighbors with `fetch_all_neighbours()` (handles pagination) +6. Fetch ACL with `req_acl_sync()` +7. Resolve pubkey prefixes to contact names from database ### ACL Permission Levels @@ -626,7 +625,14 @@ if txt_type == 1: ### Helper Function `prepare_repeater_connection()` handles the login dance: -1. Sync contacts from radio -2. Remove contact if exists (clears stale auth) -3. Re-add with flood mode (`out_path_len=-1`) -4. Send login with password +1. Add contact to radio with stored path from DB (`out_path`, `out_path_len`) +2. Send login with password +3. Wait for key exchange to complete + +### Contact Path Tracking + +When advertisements are received, path data is extracted and stored: +- `last_path`: Hex string of routing path bytes +- `last_path_len`: Number of hops (-1=flood/unknown, 0=direct, >0=hops through repeaters) + +**Shortest path selection**: When receiving echoed advertisements within 60 seconds, the shortest path is kept. This ensures we use the most efficient route when multiple paths exist. diff --git a/app/packet_processor.py b/app/packet_processor.py index f69affa74..6e84f61e7 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -309,17 +309,41 @@ async def _process_advertisement( return # Extract path info from packet - path_len = packet_info.path_length - path_hex = packet_info.path.hex() if packet_info.path else "" + new_path_len = packet_info.path_length + new_path_hex = packet_info.path.hex() if packet_info.path else "" + + # Try to find existing contact + existing = await ContactRepository.get_by_key(advert.public_key) + + # Determine which path to use: keep shorter path if heard recently (within 60s) + # This handles advertisement echoes through different routes + PATH_FRESHNESS_SECONDS = 60 + use_existing_path = False + + if existing and existing.last_seen: + path_age = timestamp - existing.last_seen + existing_path_len = existing.last_path_len if existing.last_path_len >= 0 else float('inf') + + # Keep existing path if it's fresh and shorter (or equal) + if path_age <= PATH_FRESHNESS_SECONDS and existing_path_len <= new_path_len: + use_existing_path = True + logger.debug( + "Keeping existing shorter path for %s (existing=%d, new=%d, age=%ds)", + advert.public_key[:12], existing_path_len, new_path_len, path_age + ) + + if use_existing_path: + path_len = existing.last_path_len + path_hex = existing.last_path or "" + else: + path_len = new_path_len + path_hex = new_path_hex logger.debug( "Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, path_len=%d)", advert.public_key[:12], advert.name, advert.device_role, advert.lat, advert.lon, path_len ) - # Try to find existing contact - existing = await ContactRepository.get_by_key(advert.public_key) - # Use device_role from advertisement for contact type (1=Chat, 2=Repeater, 3=Room, 4=Sensor) # Use advert.timestamp for last_advert (sender's timestamp), receive timestamp for last_seen contact_type = advert.device_role if advert.device_role > 0 else (existing.type if existing else 0) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index b8231f649..bea1bdf1a 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -83,6 +83,13 @@ const [unreadCounts, setUnreadCounts] = useState>({}); 2. **REST API** fetches initial data and handles user actions 3. **Components** receive state as props, call handlers to trigger changes +### Conversation Header + +For contacts, the header shows path information alongside "Last heard": +- `(Last heard: 10:30 AM, direct)` - Direct neighbor (path_len=0) +- `(Last heard: 10:30 AM, 2 hops)` - Routed through repeaters (path_len>0) +- `(Last heard: 10:30 AM, flood)` - No known path (path_len=-1) + ## WebSocket (`useWebSocket.ts`) The `useWebSocket` hook manages real-time connection: @@ -186,6 +193,9 @@ interface Contact { name: string | null; type: number; // 0=unknown, 1=client, 2=repeater, 3=room on_radio: boolean; + last_path_len: number; // -1=flood, 0=direct, >0=hops through repeaters + last_path: string | null; // Hex routing path + last_seen: number | null; // Unix timestamp // ... } diff --git a/tests/test_packet_pipeline.py b/tests/test_packet_pipeline.py index a5104b359..7625513de 100644 --- a/tests/test_packet_pipeline.py +++ b/tests/test_packet_pipeline.py @@ -254,6 +254,76 @@ class TestAdvertisementPipeline: # Empty path stored as None or "" assert contact.last_path in (None, "") + @pytest.mark.asyncio + async def test_advertisement_keeps_shorter_path_within_window(self, test_db, captured_broadcasts): + """When receiving echoed advertisements, keep the shortest path within 60s window.""" + from app.packet_processor import _process_advertisement + from app.decoder import parse_packet + + # Create a contact with a longer path (path_len=3) + test_pubkey = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + await ContactRepository.upsert({ + "public_key": test_pubkey, + "name": "TestNode", + "type": 1, + "last_seen": 1000, + "last_path_len": 3, + "last_path": "aabbcc", # 3 bytes = 3 hops + }) + + # Simulate receiving a shorter path (path_len=1) within 60s + # We'll call _process_advertisement directly with mock packet_info + from unittest.mock import MagicMock + from app.decoder import PacketInfo, RouteType, PayloadType, ParsedAdvertisement + + broadcasts, mock_broadcast = captured_broadcasts + + # Mock packet_info with shorter path + short_packet_info = MagicMock() + short_packet_info.path_length = 1 + short_packet_info.path = bytes.fromhex("aa") + short_packet_info.payload = b"" # Will be parsed by parse_advertisement + + # Mock parse_advertisement to return our test contact + with patch("app.packet_processor.broadcast_event", mock_broadcast): + with patch("app.packet_processor.parse_advertisement") as mock_parse: + mock_parse.return_value = ParsedAdvertisement( + public_key=test_pubkey, + name="TestNode", + timestamp=1050, + lat=None, + lon=None, + device_role=1, + ) + # Process at timestamp 1050 (within 60s of last_seen=1000) + await _process_advertisement(b"", timestamp=1050, packet_info=short_packet_info) + + # Verify the shorter path was stored + contact = await ContactRepository.get_by_key(test_pubkey) + assert contact.last_path_len == 1 # Updated to shorter path + + # Now simulate receiving a longer path (path_len=5) - should keep the shorter one + long_packet_info = MagicMock() + long_packet_info.path_length = 5 + long_packet_info.path = bytes.fromhex("aabbccddee") + + with patch("app.packet_processor.broadcast_event", mock_broadcast): + with patch("app.packet_processor.parse_advertisement") as mock_parse: + mock_parse.return_value = ParsedAdvertisement( + public_key=test_pubkey, + name="TestNode", + timestamp=1055, + lat=None, + lon=None, + device_role=1, + ) + # Process at timestamp 1055 (within 60s of last update) + await _process_advertisement(b"", timestamp=1055, packet_info=long_packet_info) + + # Verify the shorter path was kept + contact = await ContactRepository.get_by_key(test_pubkey) + assert contact.last_path_len == 1 # Still the shorter path + class TestAckPipeline: """Test ACK flow: outgoing message → ACK received → broadcast update."""