mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-12 03:33:03 +02:00
Always used shortest path in advert burst
This commit is contained in:
+17
-11
@@ -531,13 +531,12 @@ The `POST /api/contacts/{key}/telemetry` endpoint fetches status, neighbors, and
|
|||||||
### Request Flow
|
### Request Flow
|
||||||
|
|
||||||
1. Verify contact exists and is a repeater (type=2)
|
1. Verify contact exists and is a repeater (type=2)
|
||||||
2. Sync contacts from radio with `ensure_contacts()`
|
2. Add contact to radio with stored path data (from advertisements)
|
||||||
3. Remove and re-add contact with flood mode (clears stale auth state)
|
3. Send login with password
|
||||||
4. Send login with password
|
4. Request status with retries (3 attempts, 10s timeout)
|
||||||
5. Request status with retries (3 attempts, 10s timeout)
|
5. Fetch neighbors with `fetch_all_neighbours()` (handles pagination)
|
||||||
6. Fetch neighbors with `fetch_all_neighbours()` (handles pagination)
|
6. Fetch ACL with `req_acl_sync()`
|
||||||
7. Fetch ACL with `req_acl_sync()`
|
7. Resolve pubkey prefixes to contact names from database
|
||||||
8. Resolve pubkey prefixes to contact names from database
|
|
||||||
|
|
||||||
### ACL Permission Levels
|
### ACL Permission Levels
|
||||||
|
|
||||||
@@ -626,7 +625,14 @@ if txt_type == 1:
|
|||||||
### Helper Function
|
### Helper Function
|
||||||
|
|
||||||
`prepare_repeater_connection()` handles the login dance:
|
`prepare_repeater_connection()` handles the login dance:
|
||||||
1. Sync contacts from radio
|
1. Add contact to radio with stored path from DB (`out_path`, `out_path_len`)
|
||||||
2. Remove contact if exists (clears stale auth)
|
2. Send login with password
|
||||||
3. Re-add with flood mode (`out_path_len=-1`)
|
3. Wait for key exchange to complete
|
||||||
4. Send login with password
|
|
||||||
|
### 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.
|
||||||
|
|||||||
+29
-5
@@ -309,17 +309,41 @@ async def _process_advertisement(
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Extract path info from packet
|
# Extract path info from packet
|
||||||
path_len = packet_info.path_length
|
new_path_len = packet_info.path_length
|
||||||
path_hex = packet_info.path.hex() if packet_info.path else ""
|
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(
|
logger.debug(
|
||||||
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, path_len=%d)",
|
"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
|
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 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
|
# 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)
|
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
|
2. **REST API** fetches initial data and handles user actions
|
||||||
3. **Components** receive state as props, call handlers to trigger changes
|
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`)
|
## WebSocket (`useWebSocket.ts`)
|
||||||
|
|
||||||
The `useWebSocket` hook manages real-time connection:
|
The `useWebSocket` hook manages real-time connection:
|
||||||
@@ -186,6 +193,9 @@ interface Contact {
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
type: number; // 0=unknown, 1=client, 2=repeater, 3=room
|
type: number; // 0=unknown, 1=client, 2=repeater, 3=room
|
||||||
on_radio: boolean;
|
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 ""
|
# Empty path stored as None or ""
|
||||||
assert contact.last_path in (None, "")
|
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:
|
class TestAckPipeline:
|
||||||
"""Test ACK flow: outgoing message → ACK received → broadcast update."""
|
"""Test ACK flow: outgoing message → ACK received → broadcast update."""
|
||||||
|
|||||||
Reference in New Issue
Block a user