Always used shortest path in advert burst

This commit is contained in:
Jack Kingsman
2026-01-14 16:10:29 -08:00
parent e272be88ca
commit 92e7cd24e6
4 changed files with 126 additions and 16 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -83,6 +83,13 @@ const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
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
// ...
}

View File

@@ -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."""