forked from iarv/Remote-Terminal-for-MeshCore
Always used shortest path in advert burst
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
// ...
|
||||
}
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user