5 Commits

Author SHA1 Message Date
Jack Kingsman
5b98198c60 Fix migration to not import historical advert path 2026-03-18 20:41:19 -07:00
Jack Kingsman
29a76cef96 Add e2e test 2026-03-18 20:15:56 -07:00
Jack Kingsman
0768b59bcc Doc updates 2026-03-18 19:59:32 -07:00
Jack Kingsman
2c6ab31202 Dupe code cleanup 2026-03-18 19:59:32 -07:00
Jack Kingsman
7895671309 Pass 1 on PATH integration 2026-03-18 19:59:31 -07:00
57 changed files with 1572 additions and 458 deletions

View File

@@ -138,8 +138,12 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
- `path_hash_mode` values are `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
- `GET /api/radio/config` exposes both the current `path_hash_mode` and `path_hash_mode_supported`.
- `PATCH /api/radio/config` may update `path_hash_mode` only when the connected firmware supports it.
- Contacts persist `out_path_hash_mode` separately from `last_path` so contact sync and DM send paths can round-trip correctly even when hop bytes are ambiguous.
- Contacts may also persist an explicit routing override (`route_override_*`). When set, radio-bound operations use the override instead of the learned `last_path*`, but learned paths still keep updating from adverts.
- Contact routing now uses canonical route fields: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, plus optional `route_override_*`.
- The contact/API surface also exposes backend-computed `effective_route`, `effective_route_source`, `direct_route`, and `route_override` so send logic and UI do not reimplement precedence rules independently.
- Legacy `last_path`, `last_path_len`, and `out_path_hash_mode` are no longer part of the contact model or API contract.
- Route precedence for direct-message sends is: explicit override, then learned direct route, then flood.
- The learned direct route is sourced from radio contact sync (`out_path`) and PATH/path-discovery updates, matching how firmware updates `ContactInfo.out_path`.
- Advertisement paths are informational only. They are retained in `contact_advert_paths` for the contact pane and visualizer, but they are not used as DM send routes.
- `path_len` in API payloads is always hop count, not byte count. The actual path byte length is `hop_count * hash_size`.
## Data Flow
@@ -159,12 +163,21 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
4. Message stored in database with `outgoing=true`
5. For direct messages: ACK tracked; for channel: repeat detection
Direct-message send behavior intentionally mirrors the firmware/library `send_msg_with_retry(...)` flow:
- We push the contact's effective route to the radio via `add_contact(...)` before sending.
- Non-final attempts use the effective route (`override > direct > flood`).
- Retry timing follows the radio's `suggested_timeout`.
- The final retry is sent as flood by resetting the path on the radio first, even if an override or direct route exists.
- Path math is always hop-count based; hop bytes are interpreted using the stored `path_hash_mode`.
### ACK and Repeat Detection
**Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
Outgoing DMs send once immediately, then may retry up to 2 more times in the background if still unacked. Retry timing follows the radio's `suggested_timeout` from `PACKET_MSG_SENT`, and the final retry is sent as flood even when a routing override is configured. DM ACK state is terminal on first ACK: sibling retry ACK codes are cleared so one DM should not accumulate multiple delivery confirmations from different retry attempts.
ACKs are not a contact-route source. They drive message delivery state and may appear in analytics/detail surfaces, but they do not update `direct_path*` or otherwise influence route selection for future sends.
**Channel messages**: Flood messages echo back through repeaters. Repeats are identified by the database UNIQUE constraint on `(type, conversation_key, text, sender_timestamp)` — when an INSERT hits a duplicate, `_handle_duplicate_message()` in `packet_processor.py` adds the new path and, for outgoing messages only, increments the ack count. Incoming repeats add path data but do not change the ack count. There is no timestamp-windowed matching; deduplication is exact-match only.
This message-layer echo/path handling is independent of raw-packet storage deduplication.

View File

@@ -104,8 +104,10 @@ app/
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same room reuse the loaded slot; new rooms fill free slots up to the discovered channel capacity, then evict the least recently used cached room.
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
- Contacts persist `out_path_hash_mode` in the database so contact sync and outbound DM routing reuse the exact stored mode instead of inferring from path bytes.
- Contacts may also persist `route_override_path`, `route_override_len`, and `route_override_hash_mode`. `Contact.to_radio_dict()` gives these override fields precedence over learned `last_path*`, while advert processing still updates the learned route for telemetry/fallback.
- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes.
- Direct-route sources are limited to radio contact sync (`out_path`) and PATH/path-discovery updates. This mirrors firmware `onContactPathRecv(...)`, which replaces `ContactInfo.out_path` when a new returned path is heard.
- `route_override_path`, `route_override_len`, and `route_override_hash_mode` take precedence over the learned direct route for radio-bound sends.
- Advertisement paths are stored only in `contact_advert_paths` for analytics/visualization. They are not part of `Contact.to_radio_dict()` or DM route selection.
- `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths.
### Read/unread state
@@ -120,8 +122,10 @@ app/
- DM ACK tracking is an in-memory pending/buffered map in `services/dm_ack_tracker.py`, with periodic expiry from `radio_sync.py`.
- Outgoing DMs send once inline, store/broadcast immediately after the first successful `MSG_SENT`, then may retry up to 2 more times in the background if still unacked.
- DM retry timing follows the firmware-provided `suggested_timeout` from `PACKET_MSG_SENT`; do not replace it with a fixed app timeout unless you intentionally want more aggressive duplicate-prone retries.
- The final DM retry is intentionally sent as flood via `reset_path(...)`, even when a routing override exists.
- Direct-message send behavior is intended to emulate `meshcore_py.commands.send_msg_with_retry(...)`: stage the effective contact route on the radio, send, wait for ACK, and on the final retry force flood via `reset_path(...)`.
- Non-final DM attempts use the contact's effective route (`override > direct > flood`). The final retry is intentionally sent as flood even when a routing override exists.
- DM ACK state is terminal on first ACK. Retry attempts may register multiple expected ACK codes for the same message, but sibling pending codes are cleared once one ACK wins so a DM should not accrue multiple delivery confirmations from retries.
- ACKs are delivery state, not routing state. Bundled ACKs inside PATH packets still satisfy pending DM sends, but ACK history does not feed contact route learning.
### Echo/repeat dedup
@@ -258,7 +262,7 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
## Data Model Notes
Main tables:
- `contacts` (includes `first_seen` for contact age tracking and `out_path_hash_mode` for route round-tripping)
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
- `channels`
Includes optional `flood_scope_override` for channel-specific regional sends.
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
@@ -267,6 +271,13 @@ Main tables:
- `contact_name_history` (tracks name changes over time)
- `app_settings`
Contact route state is canonicalized on the backend:
- stored route inputs: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, `direct_path_updated_at`, plus optional `route_override_*`
- computed route surface: `effective_route`, `effective_route_source`, `direct_route`, `route_override`
- removed legacy names: `last_path`, `last_path_len`, `out_path_hash_mode`
Frontend and send paths should consume the canonical route surface rather than reconstructing precedence from raw fields.
Repository writes should prefer typed models such as `ContactUpsert` over ad hoc dict payloads when adding or updating schema-coupled data.
`max_radio_contacts` is the configured radio contact capacity baseline. Favorites reload first, the app refills non-favorite working-set contacts to about 80% of that capacity, and periodic offload triggers once occupancy reaches about 95%.

View File

@@ -13,9 +13,10 @@ CREATE TABLE IF NOT EXISTS contacts (
name TEXT,
type INTEGER DEFAULT 0,
flags INTEGER DEFAULT 0,
last_path TEXT,
last_path_len INTEGER DEFAULT -1,
out_path_hash_mode INTEGER DEFAULT 0,
direct_path TEXT,
direct_path_len INTEGER,
direct_path_hash_mode INTEGER,
direct_path_updated_at INTEGER,
route_override_path TEXT,
route_override_len INTEGER,
route_override_hash_mode INTEGER,
@@ -25,7 +26,8 @@ CREATE TABLE IF NOT EXISTS contacts (
last_seen INTEGER,
on_radio INTEGER DEFAULT 0,
last_contacted INTEGER,
first_seen INTEGER
first_seen INTEGER,
last_read_at INTEGER
);
CREATE TABLE IF NOT EXISTS channels (
@@ -33,7 +35,8 @@ CREATE TABLE IF NOT EXISTS channels (
name TEXT NOT NULL,
is_hashtag INTEGER DEFAULT 0,
on_radio INTEGER DEFAULT 0,
flood_scope_override TEXT
flood_scope_override TEXT,
last_read_at INTEGER
);
CREATE TABLE IF NOT EXISTS messages (

View File

@@ -60,6 +60,19 @@ class DecryptedDirectMessage:
src_hash: str # First byte of sender pubkey as hex
@dataclass
class DecryptedPathPayload:
"""Result of decrypting a PATH payload."""
dest_hash: str
src_hash: str
returned_path: bytes
returned_path_len: int
returned_path_hash_mode: int
extra_type: int
extra: bytes
@dataclass
class ParsedAdvertisement:
"""Result of parsing an advertisement packet."""
@@ -563,3 +576,88 @@ def try_decrypt_dm(
return None
return decrypt_direct_message(packet_info.payload, shared_secret)
def decrypt_path_payload(payload: bytes, shared_secret: bytes) -> DecryptedPathPayload | None:
"""Decrypt a PATH payload using the ECDH shared secret."""
if len(payload) < 4:
return None
dest_hash = format(payload[0], "02x")
src_hash = format(payload[1], "02x")
mac = payload[2:4]
ciphertext = payload[4:]
if len(ciphertext) == 0 or len(ciphertext) % 16 != 0:
return None
calculated_mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
if calculated_mac != mac:
return None
try:
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
decrypted = cipher.decrypt(ciphertext)
except Exception as e:
logger.debug("AES decryption failed for PATH payload: %s", e)
return None
if len(decrypted) < 2:
return None
from app.path_utils import decode_path_byte
packed_len = decrypted[0]
try:
returned_path_len, hash_size = decode_path_byte(packed_len)
except ValueError:
return None
path_byte_len = returned_path_len * hash_size
if len(decrypted) < 1 + path_byte_len + 1:
return None
offset = 1
returned_path = decrypted[offset : offset + path_byte_len]
offset += path_byte_len
extra_type = decrypted[offset] & 0x0F
offset += 1
extra = decrypted[offset:]
return DecryptedPathPayload(
dest_hash=dest_hash,
src_hash=src_hash,
returned_path=returned_path,
returned_path_len=returned_path_len,
returned_path_hash_mode=hash_size - 1,
extra_type=extra_type,
extra=extra,
)
def try_decrypt_path(
raw_packet: bytes,
our_private_key: bytes,
their_public_key: bytes,
our_public_key: bytes,
) -> DecryptedPathPayload | None:
"""Try to decrypt a raw packet as a PATH packet."""
packet_info = parse_packet(raw_packet)
if packet_info is None or packet_info.payload_type != PayloadType.PATH:
return None
if len(packet_info.payload) < 4:
return None
dest_hash = packet_info.payload[0]
src_hash = packet_info.payload[1]
if dest_hash != our_public_key[0] or src_hash != their_public_key[0]:
return None
try:
shared_secret = derive_shared_secret(our_private_key, their_public_key)
except Exception as e:
logger.debug("Failed to derive shared secret for PATH payload: %s", e)
return None
return decrypt_path_payload(packet_info.payload, shared_secret)

View File

@@ -14,11 +14,11 @@ from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile,
)
from app.services.dm_ack_apply import apply_dm_ack_code
from app.services.dm_ingest import (
ingest_fallback_direct_message,
resolve_fallback_direct_message_context,
)
from app.services.messages import increment_ack_and_broadcast
from app.websocket import broadcast_event
if TYPE_CHECKING:
@@ -197,11 +197,12 @@ async def on_path_update(event: "Event") -> None:
)
normalized_path_hash_mode = None
await ContactRepository.update_path(
await ContactRepository.update_direct_path(
contact.public_key,
str(path),
normalized_path_len,
normalized_path_hash_mode,
updated_at=int(time.time()),
)
@@ -268,19 +269,10 @@ async def on_ack(event: "Event") -> None:
return
logger.debug("Received ACK with code %s", ack_code)
cleanup_expired_acks()
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
if message_id is not None:
dm_ack_tracker.clear_pending_acks_for_message(message_id)
logger.info("ACK received for message %d", message_id)
# DM ACKs don't carry path data, so paths is intentionally omitted.
# The frontend's mergePendingAck handles the missing field correctly,
# preserving any previously known paths.
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_event)
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
if matched:
logger.info("ACK received for code %s", ack_code)
else:
dm_ack_tracker.buffer_unmatched_ack(ack_code)
logger.debug("ACK code %s does not match any pending messages", ack_code)

View File

@@ -346,6 +346,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 44)
applied += 1
# Migration 45: Replace legacy contact route columns with direct-route columns
if version < 45:
logger.info("Applying migration 45: rebuild contacts direct-route columns")
await _migrate_045_rebuild_contacts_direct_route_columns(conn)
await set_version(conn, 45)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -2635,3 +2642,134 @@ async def _migrate_044_dedupe_incoming_direct_messages(conn: aiosqlite.Connectio
WHERE type = 'PRIV' AND outgoing = 0"""
)
await conn.commit()
async def _migrate_045_rebuild_contacts_direct_route_columns(conn: aiosqlite.Connection) -> None:
"""Replace legacy contact route columns with canonical direct-route columns."""
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'"
)
if await cursor.fetchone() is None:
await conn.commit()
return
cursor = await conn.execute("PRAGMA table_info(contacts)")
columns = {row[1] for row in await cursor.fetchall()}
target_columns = {
"public_key",
"name",
"type",
"flags",
"direct_path",
"direct_path_len",
"direct_path_hash_mode",
"direct_path_updated_at",
"route_override_path",
"route_override_len",
"route_override_hash_mode",
"last_advert",
"lat",
"lon",
"last_seen",
"on_radio",
"last_contacted",
"first_seen",
"last_read_at",
}
if (
target_columns.issubset(columns)
and "last_path" not in columns
and "out_path_hash_mode" not in columns
):
await conn.commit()
return
await conn.execute(
"""
CREATE TABLE contacts_new (
public_key TEXT PRIMARY KEY,
name TEXT,
type INTEGER DEFAULT 0,
flags INTEGER DEFAULT 0,
direct_path TEXT,
direct_path_len INTEGER,
direct_path_hash_mode INTEGER,
direct_path_updated_at INTEGER,
route_override_path TEXT,
route_override_len INTEGER,
route_override_hash_mode INTEGER,
last_advert INTEGER,
lat REAL,
lon REAL,
last_seen INTEGER,
on_radio INTEGER DEFAULT 0,
last_contacted INTEGER,
first_seen INTEGER,
last_read_at INTEGER
)
"""
)
select_expr = {
"public_key": "public_key",
"name": "NULL",
"type": "0",
"flags": "0",
"direct_path": "NULL",
"direct_path_len": "NULL",
"direct_path_hash_mode": "NULL",
"direct_path_updated_at": "NULL",
"route_override_path": "NULL",
"route_override_len": "NULL",
"route_override_hash_mode": "NULL",
"last_advert": "NULL",
"lat": "NULL",
"lon": "NULL",
"last_seen": "NULL",
"on_radio": "0",
"last_contacted": "NULL",
"first_seen": "NULL",
"last_read_at": "NULL",
}
for name in ("name", "type", "flags"):
if name in columns:
select_expr[name] = name
if "direct_path" in columns:
select_expr["direct_path"] = "direct_path"
if "direct_path_len" in columns:
select_expr["direct_path_len"] = "direct_path_len"
if "direct_path_hash_mode" in columns:
select_expr["direct_path_hash_mode"] = "direct_path_hash_mode"
for name in (
"route_override_path",
"route_override_len",
"route_override_hash_mode",
"last_advert",
"lat",
"lon",
"last_seen",
"on_radio",
"last_contacted",
"first_seen",
"last_read_at",
):
if name in columns:
select_expr[name] = name
ordered_columns = list(select_expr.keys())
await conn.execute(
f"""
INSERT INTO contacts_new ({", ".join(ordered_columns)})
SELECT {", ".join(select_expr[name] for name in ordered_columns)}
FROM contacts
"""
)
await conn.execute("DROP TABLE contacts")
await conn.execute("ALTER TABLE contacts_new RENAME TO contacts")
await conn.commit()

View File

@@ -2,7 +2,17 @@ from typing import Literal
from pydantic import BaseModel, Field
from app.path_utils import normalize_contact_route
from app.path_utils import normalize_contact_route, normalize_route_override
class ContactRoute(BaseModel):
"""A normalized contact route."""
path: str = Field(description="Hex-encoded path bytes (empty string for direct/flood)")
path_len: int = Field(description="Hop count (-1=flood, 0=direct, >0=explicit route)")
path_hash_mode: int = Field(
description="Path hash mode (-1=flood, 0=1-byte, 1=2-byte, 2=3-byte hop identifiers)"
)
class ContactUpsert(BaseModel):
@@ -12,9 +22,10 @@ class ContactUpsert(BaseModel):
name: str | None = None
type: int = 0
flags: int = 0
last_path: str | None = None
last_path_len: int = -1
out_path_hash_mode: int | None = None
direct_path: str | None = None
direct_path_len: int | None = None
direct_path_hash_mode: int | None = None
direct_path_updated_at: int | None = None
route_override_path: str | None = None
route_override_len: int | None = None
route_override_hash_mode: int | None = None
@@ -40,7 +51,7 @@ class ContactUpsert(BaseModel):
cls, public_key: str, radio_data: dict, on_radio: bool = False
) -> "ContactUpsert":
"""Convert radio contact data to the contact-row write shape."""
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
radio_data.get("out_path"),
radio_data.get("out_path_len", -1),
radio_data.get(
@@ -53,9 +64,9 @@ class ContactUpsert(BaseModel):
name=radio_data.get("adv_name"),
type=radio_data.get("type", 0),
flags=radio_data.get("flags", 0),
last_path=last_path,
last_path_len=last_path_len,
out_path_hash_mode=out_path_hash_mode,
direct_path=direct_path,
direct_path_len=direct_path_len,
direct_path_hash_mode=direct_path_hash_mode,
lat=radio_data.get("adv_lat"),
lon=radio_data.get("adv_lon"),
last_advert=radio_data.get("last_advert"),
@@ -68,9 +79,10 @@ class Contact(BaseModel):
name: str | None = None
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor
flags: int = 0
last_path: str | None = None
last_path_len: int = -1
out_path_hash_mode: int = 0
direct_path: str | None = None
direct_path_len: int = -1
direct_path_hash_mode: int = -1
direct_path_updated_at: int | None = None
route_override_path: str | None = None
route_override_len: int | None = None
route_override_hash_mode: int | None = None
@@ -82,38 +94,99 @@ class Contact(BaseModel):
last_contacted: int | None = None # Last time we sent/received a message
last_read_at: int | None = None # Server-side read state tracking
first_seen: int | None = None
effective_route: ContactRoute | None = None
effective_route_source: Literal["override", "direct", "flood"] = "flood"
direct_route: ContactRoute | None = None
route_override: ContactRoute | None = None
def model_post_init(self, __context) -> None:
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
self.direct_path,
self.direct_path_len,
self.direct_path_hash_mode,
)
self.direct_path = direct_path or None
self.direct_path_len = direct_path_len
self.direct_path_hash_mode = direct_path_hash_mode
route_override_path, route_override_len, route_override_hash_mode = (
normalize_route_override(
self.route_override_path,
self.route_override_len,
self.route_override_hash_mode,
)
)
self.route_override_path = route_override_path or None
self.route_override_len = route_override_len
self.route_override_hash_mode = route_override_hash_mode
if (
route_override_path is not None
and route_override_len is not None
and route_override_hash_mode is not None
):
self.route_override = ContactRoute(
path=route_override_path,
path_len=route_override_len,
path_hash_mode=route_override_hash_mode,
)
else:
self.route_override = None
if direct_path_len >= 0:
self.direct_route = ContactRoute(
path=direct_path,
path_len=direct_path_len,
path_hash_mode=direct_path_hash_mode,
)
else:
self.direct_route = None
path, path_len, path_hash_mode = self.effective_route_tuple()
if self.has_route_override():
self.effective_route_source = "override"
elif self.direct_route is not None:
self.effective_route_source = "direct"
else:
self.effective_route_source = "flood"
self.effective_route = ContactRoute(
path=path,
path_len=path_len,
path_hash_mode=path_hash_mode,
)
def has_route_override(self) -> bool:
return self.route_override_len is not None
def effective_route(self) -> tuple[str, int, int]:
def effective_route_tuple(self) -> tuple[str, int, int]:
if self.has_route_override():
return normalize_contact_route(
self.route_override_path,
self.route_override_len,
self.route_override_hash_mode,
)
return normalize_contact_route(
self.last_path,
self.last_path_len,
self.out_path_hash_mode,
)
if self.direct_path_len >= 0:
return normalize_contact_route(
self.direct_path,
self.direct_path_len,
self.direct_path_hash_mode,
)
return "", -1, -1
def to_radio_dict(self) -> dict:
"""Convert to the dict format expected by meshcore radio commands.
The radio API uses different field names (adv_name, out_path, etc.)
than our database schema (name, last_path, etc.).
than our database schema (name, direct_path, etc.).
"""
last_path, last_path_len, out_path_hash_mode = self.effective_route()
effective_path, effective_path_len, effective_path_hash_mode = self.effective_route_tuple()
return {
"public_key": self.public_key,
"adv_name": self.name or "",
"type": self.type,
"flags": self.flags,
"out_path": last_path,
"out_path_len": last_path_len,
"out_path_hash_mode": out_path_hash_mode,
"out_path": effective_path,
"out_path_len": effective_path_len,
"out_path_hash_mode": effective_path_hash_mode,
"adv_lat": self.lat if self.lat is not None else 0.0,
"adv_lon": self.lon if self.lon is not None else 0.0,
"last_advert": self.last_advert if self.last_advert is not None else 0,
@@ -149,7 +222,7 @@ class ContactRoutingOverrideRequest(BaseModel):
route: str = Field(
description=(
"Blank clears the override and resets learned routing to flood, "
"Blank clears the override, "
'"-1" forces flood, "0" forces direct, and explicit routes are '
"comma-separated 1/2/3-byte hop hex values"
)

View File

@@ -26,6 +26,7 @@ from app.decoder import (
parse_packet,
try_decrypt_dm,
try_decrypt_packet_with_channel_key,
try_decrypt_path,
)
from app.keystore import get_private_key, get_public_key, has_private_key
from app.models import (
@@ -44,6 +45,7 @@ from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile,
)
from app.services.dm_ack_apply import apply_dm_ack_code
from app.services.messages import (
create_dm_message_from_decrypted as _create_dm_message_from_decrypted,
)
@@ -318,8 +320,7 @@ async def process_raw_packet(
elif payload_type == PayloadType.ADVERT:
# Process all advert arrivals (even payload-hash duplicates) so the
# path-freshness logic in _process_advertisement can pick the shortest
# path heard within the freshness window.
# advert-history table retains recent path observations.
await _process_advertisement(raw_bytes, ts, packet_info)
elif payload_type == PayloadType.TEXT_MESSAGE:
@@ -328,6 +329,9 @@ async def process_raw_packet(
if decrypt_result:
result.update(decrypt_result)
elif payload_type == PayloadType.PATH:
await _process_path_packet(raw_bytes, ts, packet_info)
# Always broadcast raw packet for the packet feed UI (even duplicates)
# This enables the frontend cracker to see all incoming packets in real-time
broadcast_payload = RawPacketBroadcast(
@@ -430,51 +434,20 @@ async def _process_advertisement(
logger.debug("Failed to parse advertisement payload")
return
# Extract path info from packet
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.lower())
# 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_advert:
path_age = timestamp - existing.last_advert
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:
assert existing is not None # Guaranteed by the conditions that set use_existing_path
path_len = existing.last_path_len if existing.last_path_len is not None else -1
path_hex = existing.last_path or ""
out_path_hash_mode = existing.out_path_hash_mode
else:
path_len = new_path_len
path_hex = new_path_hex
out_path_hash_mode = packet_info.path_hash_size - 1
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, advert_path_len=%d)",
advert.public_key[:12],
advert.name,
advert.device_role,
advert.lat,
advert.lon,
path_len,
new_path_len,
)
# Use device_role from advertisement for contact type (1=Chat, 2=Repeater, 3=Room, 4=Sensor).
@@ -501,9 +474,6 @@ async def _process_advertisement(
lon=advert.lon,
last_advert=timestamp,
last_seen=timestamp,
last_path=path_hex,
last_path_len=path_len,
out_path_hash_mode=out_path_hash_mode,
first_seen=timestamp, # COALESCE in upsert preserves existing value
)
@@ -667,3 +637,90 @@ async def _process_direct_message(
# Couldn't decrypt with any known contact
logger.debug("Could not decrypt DM with any of %d candidate contacts", len(candidate_contacts))
return None
async def _process_path_packet(
raw_bytes: bytes,
timestamp: int,
packet_info: PacketInfo | None,
) -> None:
"""Process a PATH packet and update the learned direct route."""
if not has_private_key():
return
private_key = get_private_key()
our_public_key = get_public_key()
if private_key is None or our_public_key is None:
return
if packet_info is None:
packet_info = parse_packet(raw_bytes)
if packet_info is None or packet_info.payload is None or len(packet_info.payload) < 4:
return
dest_hash = format(packet_info.payload[0], "02x").lower()
src_hash = format(packet_info.payload[1], "02x").lower()
our_first_byte = format(our_public_key[0], "02x").lower()
if dest_hash != our_first_byte:
return
candidate_contacts = await ContactRepository.get_by_pubkey_first_byte(src_hash)
if not candidate_contacts:
logger.debug("No contacts found matching hash %s for PATH decryption", src_hash)
return
for contact in candidate_contacts:
if len(contact.public_key) != 64:
continue
try:
contact_public_key = bytes.fromhex(contact.public_key)
except ValueError:
continue
result = try_decrypt_path(
raw_packet=raw_bytes,
our_private_key=private_key,
their_public_key=contact_public_key,
our_public_key=our_public_key,
)
if result is None:
continue
await ContactRepository.update_direct_path(
contact.public_key,
result.returned_path.hex(),
result.returned_path_len,
result.returned_path_hash_mode,
updated_at=timestamp,
)
if result.extra_type == PayloadType.ACK and len(result.extra) >= 4:
ack_code = result.extra[:4].hex()
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
if matched:
logger.info(
"Applied bundled PATH ACK for %s via contact %s",
ack_code,
contact.public_key[:12],
)
else:
logger.debug(
"Buffered bundled PATH ACK %s via contact %s",
ack_code,
contact.public_key[:12],
)
elif result.extra_type == PayloadType.RESPONSE and len(result.extra) > 0:
logger.debug(
"Observed bundled PATH RESPONSE from %s (%d bytes)",
contact.public_key[:12],
len(result.extra),
)
refreshed_contact = await ContactRepository.get_by_key(contact.public_key)
if refreshed_contact is not None:
broadcast_event("contact", refreshed_contact.model_dump())
return
logger.debug(
"Could not decrypt PATH packet with any of %d candidate contacts", len(candidate_contacts)
)

View File

@@ -153,12 +153,12 @@ def first_hop_hex(path_hex: str, hop_count: int) -> str | None:
def normalize_contact_route(
path_hex: str | None,
path_len: int | None,
out_path_hash_mode: int | None,
path_hash_mode: int | None,
) -> tuple[str, int, int]:
"""Normalize stored contact route fields.
Handles legacy/bad rows where the packed wire path byte was stored directly
in `last_path_len` (sometimes as a signed byte, e.g. `-125` for `0x83`).
in the hop-count column (sometimes as a signed byte, e.g. `-125` for `0x83`).
Returns `(path_hex, hop_count, hash_mode)`.
"""
normalized_path = path_hex or ""
@@ -169,7 +169,7 @@ def normalize_contact_route(
normalized_len = -1
try:
normalized_mode = int(out_path_hash_mode) if out_path_hash_mode is not None else None
normalized_mode = int(path_hash_mode) if path_hash_mode is not None else None
except (TypeError, ValueError):
normalized_mode = None
@@ -207,7 +207,7 @@ def normalize_contact_route(
def normalize_route_override(
path_hex: str | None,
path_len: int | None,
out_path_hash_mode: int | None,
path_hash_mode: int | None,
) -> tuple[str | None, int | None, int | None]:
"""Normalize optional route-override fields while preserving the unset state."""
if path_len is None:
@@ -216,7 +216,7 @@ def normalize_route_override(
normalized_path, normalized_len, normalized_mode = normalize_contact_route(
path_hex,
path_len,
out_path_hash_mode,
path_hash_mode,
)
return normalized_path, normalized_len, normalized_mode

View File

@@ -46,9 +46,10 @@ def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
return {
"type": contact.type,
"flags": contact.flags,
"last_path": contact.last_path,
"last_path_len": contact.last_path_len,
"out_path_hash_mode": contact.out_path_hash_mode,
"direct_path": contact.direct_path,
"direct_path_len": contact.direct_path_len,
"direct_path_hash_mode": contact.direct_path_hash_mode,
"direct_path_updated_at": contact.direct_path_updated_at,
"route_override_path": contact.route_override_path,
"route_override_len": contact.route_override_len,
"route_override_hash_mode": contact.route_override_hash_mode,

View File

@@ -36,11 +36,20 @@ class ContactRepository:
@staticmethod
async def upsert(contact: ContactUpsert | Contact | Mapping[str, Any]) -> None:
contact_row = ContactRepository._coerce_contact_upsert(contact)
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
contact_row.last_path,
contact_row.last_path_len,
contact_row.out_path_hash_mode,
)
if (
contact_row.direct_path is None
and contact_row.direct_path_len is None
and contact_row.direct_path_hash_mode is None
):
direct_path = None
direct_path_len = None
direct_path_hash_mode = None
else:
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
contact_row.direct_path,
contact_row.direct_path_len,
contact_row.direct_path_hash_mode,
)
route_override_path, route_override_len, route_override_hash_mode = (
normalize_route_override(
contact_row.route_override_path,
@@ -51,20 +60,25 @@ class ContactRepository:
await db.conn.execute(
"""
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
out_path_hash_mode,
INSERT INTO contacts (public_key, name, type, flags, direct_path, direct_path_len,
direct_path_hash_mode, direct_path_updated_at,
route_override_path, route_override_len,
route_override_hash_mode,
last_advert, lat, lon, last_seen,
on_radio, last_contacted, first_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(public_key) DO UPDATE SET
name = COALESCE(excluded.name, contacts.name),
type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END,
flags = excluded.flags,
last_path = COALESCE(excluded.last_path, contacts.last_path),
last_path_len = excluded.last_path_len,
out_path_hash_mode = excluded.out_path_hash_mode,
direct_path = COALESCE(excluded.direct_path, contacts.direct_path),
direct_path_len = COALESCE(excluded.direct_path_len, contacts.direct_path_len),
direct_path_hash_mode = COALESCE(
excluded.direct_path_hash_mode, contacts.direct_path_hash_mode
),
direct_path_updated_at = COALESCE(
excluded.direct_path_updated_at, contacts.direct_path_updated_at
),
route_override_path = COALESCE(
excluded.route_override_path, contacts.route_override_path
),
@@ -87,9 +101,10 @@ class ContactRepository:
contact_row.name,
contact_row.type,
contact_row.flags,
last_path,
last_path_len,
out_path_hash_mode,
direct_path,
direct_path_len,
direct_path_hash_mode,
contact_row.direct_path_updated_at,
route_override_path,
route_override_len,
route_override_hash_mode,
@@ -107,12 +122,12 @@ class ContactRepository:
@staticmethod
def _row_to_contact(row) -> Contact:
"""Convert a database row to a Contact model."""
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
row["last_path"],
row["last_path_len"],
row["out_path_hash_mode"],
)
available_columns = set(row.keys())
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
row["direct_path"] if "direct_path" in available_columns else None,
row["direct_path_len"] if "direct_path_len" in available_columns else None,
row["direct_path_hash_mode"] if "direct_path_hash_mode" in available_columns else None,
)
route_override_path = (
row["route_override_path"] if "route_override_path" in available_columns else None
)
@@ -136,9 +151,14 @@ class ContactRepository:
name=row["name"],
type=row["type"],
flags=row["flags"],
last_path=last_path,
last_path_len=last_path_len,
out_path_hash_mode=out_path_hash_mode,
direct_path=direct_path,
direct_path_len=direct_path_len,
direct_path_hash_mode=direct_path_hash_mode,
direct_path_updated_at=(
row["direct_path_updated_at"]
if "direct_path_updated_at" in available_columns
else None
),
route_override_path=route_override_path,
route_override_len=route_override_len,
route_override_hash_mode=route_override_hash_mode,
@@ -286,26 +306,30 @@ class ContactRepository:
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def update_path(
async def update_direct_path(
public_key: str,
path: str,
path_len: int,
out_path_hash_mode: int | None = None,
path_hash_mode: int | None = None,
updated_at: int | None = None,
) -> None:
normalized_path, normalized_path_len, normalized_hash_mode = normalize_contact_route(
path,
path_len,
out_path_hash_mode,
path_hash_mode,
)
ts = updated_at if updated_at is not None else int(time.time())
await db.conn.execute(
"""UPDATE contacts SET last_path = ?, last_path_len = ?,
out_path_hash_mode = COALESCE(?, out_path_hash_mode),
"""UPDATE contacts SET direct_path = ?, direct_path_len = ?,
direct_path_hash_mode = COALESCE(?, direct_path_hash_mode),
direct_path_updated_at = ?,
last_seen = ? WHERE public_key = ?""",
(
normalized_path,
normalized_path_len,
normalized_hash_mode,
int(time.time()),
ts,
ts,
public_key.lower(),
),
)
@@ -316,12 +340,12 @@ class ContactRepository:
public_key: str,
path: str | None,
path_len: int | None,
out_path_hash_mode: int | None = None,
path_hash_mode: int | None = None,
) -> None:
normalized_path, normalized_len, normalized_hash_mode = normalize_route_override(
path,
path_len,
out_path_hash_mode,
path_hash_mode,
)
await db.conn.execute(
"""

View File

@@ -304,7 +304,6 @@ async def create_contact(
contact_upsert = ContactUpsert(
public_key=lower_key,
name=request.name,
out_path_hash_mode=-1,
on_radio=False,
)
await ContactRepository.upsert(contact_upsert)
@@ -474,7 +473,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
return_len = int(payload.get("in_path_len") or 0)
return_mode = _path_hash_mode_from_hop_width(payload.get("in_path_hash_len"))
await ContactRepository.update_path(
await ContactRepository.update_direct_path(
contact.public_key,
forward_path,
forward_len,
@@ -524,9 +523,8 @@ async def set_contact_routing_override(
route_text = request.route.strip()
if route_text == "":
await ContactRepository.clear_routing_override(contact.public_key)
await ContactRepository.update_path(contact.public_key, "", -1, -1)
logger.info(
"Cleared routing override and reset learned path to flood for %s",
"Cleared routing override for %s",
contact.public_key[:12],
)
elif route_text == "-1":

View File

@@ -0,0 +1,26 @@
"""Shared direct-message ACK application logic."""
from collections.abc import Callable
from typing import Any
from app.services import dm_ack_tracker
from app.services.messages import increment_ack_and_broadcast
BroadcastFn = Callable[..., Any]
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
"""Apply a DM ACK code using the shared pending/buffered state machine.
Returns True when the ACK matched a pending message, False when it was buffered.
"""
dm_ack_tracker.cleanup_expired_acks()
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
if message_id is None:
dm_ack_tracker.buffer_unmatched_ack(ack_code)
return False
dm_ack_tracker.clear_pending_acks_for_message(message_id)
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_fn)
return True

View File

@@ -92,7 +92,6 @@ async def resolve_fallback_direct_message_context(
last_contacted=received_at,
first_seen=received_at,
on_radio=False,
out_path_hash_mode=-1,
)
await contact_repository.upsert(placeholder_upsert)
contact = await contact_repository.get_by_key(normalized_sender)

View File

@@ -340,9 +340,10 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
- Name history ("Also Known As") — shown only when the contact has used multiple names
- Message stats: DM count, channel message count
- Most active rooms (clickable → navigate to channel)
- Route details from the canonical backend surface (`effective_route`, `effective_route_source`, `direct_route`, `route_override`)
- Advert observation rate
- Nearest repeaters (resolved from first-hop path prefixes)
- Recent advert paths
- Recent advert paths (informational only; not part of DM route selection)
State: `useConversationNavigation` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot.

View File

@@ -12,6 +12,7 @@ import {
calculateDistance,
formatDistance,
formatRouteLabel,
getDirectContactRoute,
getEffectiveContactRoute,
hasRoutingOverride,
parsePathHops,
@@ -134,11 +135,12 @@ export function ContactInfoPane({
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
: null;
const effectiveRoute = contact ? getEffectiveContactRoute(contact) : null;
const directRoute = contact ? getDirectContactRoute(contact) : null;
const pathHashModeLabel =
effectiveRoute && effectiveRoute.pathLen >= 0
? formatPathHashMode(effectiveRoute.pathHashMode)
: null;
const learnedRouteLabel = contact ? formatRouteLabel(contact.last_path_len, true) : null;
const learnedRouteLabel = directRoute ? formatRouteLabel(directRoute.path_len, true) : null;
const isPrefixOnlyResolvedContact = contact ? isPrefixOnlyContact(contact.public_key) : false;
const isUnknownFullKeyResolvedContact =
contact !== null &&
@@ -330,7 +332,7 @@ export function ContactInfoPane({
}
/>
)}
{contact && hasRoutingOverride(contact) && learnedRouteLabel && (
{hasRoutingOverride(contact) && learnedRouteLabel && (
<InfoItem label="Learned Route" value={learnedRouteLabel} />
)}
{pathHashModeLabel && <InfoItem label="Hop Width" value={pathHashModeLabel} />}

View File

@@ -4,6 +4,7 @@ import type { Contact, PathDiscoveryResponse, PathDiscoveryRoute } from '../type
import {
findContactsByPrefix,
formatRouteLabel,
getDirectContactRoute,
getEffectiveContactRoute,
hasRoutingOverride,
parsePathHops,
@@ -99,16 +100,17 @@ export function ContactPathDiscoveryModal({
const [result, setResult] = useState<PathDiscoveryResponse | null>(null);
const effectiveRoute = useMemo(() => getEffectiveContactRoute(contact), [contact]);
const directRoute = useMemo(() => getDirectContactRoute(contact), [contact]);
const hasForcedRoute = hasRoutingOverride(contact);
const learnedRouteSummary = useMemo(() => {
if (contact.last_path_len === -1) {
if (!directRoute) {
return 'Flood';
}
const hops = parsePathHops(contact.last_path, contact.last_path_len);
const hops = parsePathHops(directRoute.path, directRoute.path_len);
return hops.length > 0
? `${formatRouteLabel(contact.last_path_len, true)} (${hops.join(' -> ')})`
: formatRouteLabel(contact.last_path_len, true);
}, [contact.last_path, contact.last_path_len]);
? `${formatRouteLabel(directRoute.path_len, true)} (${hops.join(' -> ')})`
: formatRouteLabel(directRoute.path_len, true);
}, [directRoute]);
const forcedRouteSummary = useMemo(() => {
if (!hasForcedRoute) {
return null;

View File

@@ -5,6 +5,7 @@ import type { Contact } from '../types';
import {
formatRouteLabel,
formatRoutingOverrideInput,
getDirectContactRoute,
hasRoutingOverride,
} from '../utils/pathUtils';
import { Button } from './ui/button';
@@ -28,7 +29,7 @@ interface ContactRoutingOverrideModalProps {
}
function summarizeLearnedRoute(contact: Contact): string {
return formatRouteLabel(contact.last_path_len, true);
return formatRouteLabel(getDirectContactRoute(contact)?.path_len ?? -1, true);
}
function summarizeForcedRoute(contact: Contact): string | null {

View File

@@ -12,6 +12,7 @@ import type { Contact, Message, MessagePath, RadioConfig } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
import { getDirectContactRoute } from '../utils/pathUtils';
import { ContactAvatar } from './ContactAvatar';
import { PathModal } from './PathModal';
import { handleKeyboardActivate } from '../utils/a11y';
@@ -500,12 +501,13 @@ export function MessageList({
parsedSender: string | null
): SenderInfo => {
if (msg.type === 'PRIV' && contact) {
const directRoute = getDirectContactRoute(contact);
return {
name: contact.name || contact.public_key.slice(0, 12),
publicKeyOrPrefix: contact.public_key,
lat: contact.lat,
lon: contact.lon,
pathHashMode: contact.out_path_hash_mode,
pathHashMode: directRoute?.path_hash_mode ?? null,
};
}
if (msg.type === 'CHAN') {
@@ -515,12 +517,13 @@ export function MessageList({
? contacts.find((candidate) => candidate.public_key === msg.sender_key)
: null) || (senderName ? getContactByName(senderName) : null);
if (senderContact) {
const directRoute = getDirectContactRoute(senderContact);
return {
name: senderContact.name || senderName || senderContact.public_key.slice(0, 12),
publicKeyOrPrefix: senderContact.public_key,
lat: senderContact.lat,
lon: senderContact.lon,
pathHashMode: senderContact.out_path_hash_mode,
pathHashMode: directRoute?.path_hash_mode ?? null,
};
}
if (senderName || msg.sender_key) {
@@ -538,12 +541,13 @@ export function MessageList({
if (parsedSender) {
const senderContact = getContactByName(parsedSender);
if (senderContact) {
const directRoute = getDirectContactRoute(senderContact);
return {
name: parsedSender,
publicKeyOrPrefix: senderContact.public_key,
lat: senderContact.lat,
lon: senderContact.lon,
pathHashMode: senderContact.out_path_hash_mode,
pathHashMode: directRoute?.path_hash_mode ?? null,
};
}
}

View File

@@ -293,8 +293,8 @@ describe('App startup hash resolution', () => {
name: 'Alice',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
direct_path: null,
direct_path_len: -1,
last_advert: null,
lat: null,
lon: null,

View File

@@ -196,9 +196,9 @@ describe('ChatHeader key visibility', () => {
name: 'Alice',
type: 1,
flags: 0,
last_path: 'AA',
last_path_len: 1,
out_path_hash_mode: 0,
direct_path: 'AA',
direct_path_len: 1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -242,9 +242,9 @@ describe('ChatHeader key visibility', () => {
name: 'Alice',
type: 1,
flags: 0,
last_path: 'AA',
last_path_len: 1,
out_path_hash_mode: 0,
direct_path: 'AA',
direct_path_len: 1,
direct_path_hash_mode: 0,
route_override_path: 'BBDD',
route_override_len: 2,
route_override_hash_mode: 0,

View File

@@ -39,9 +39,9 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
name: 'Alice',
type: 1,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -104,7 +104,7 @@ describe('ContactInfoPane', () => {
});
it('shows hop width when contact has a stored path hash mode', async () => {
const contact = createContact({ out_path_hash_mode: 1 });
const contact = createContact({ direct_path_hash_mode: 1, direct_path_len: 1 });
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
@@ -117,7 +117,7 @@ describe('ContactInfoPane', () => {
});
it('does not show hop width for flood-routed contacts', async () => {
const contact = createContact({ last_path_len: -1, out_path_hash_mode: -1 });
const contact = createContact({ direct_path_len: -1, direct_path_hash_mode: -1 });
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
@@ -131,8 +131,8 @@ describe('ContactInfoPane', () => {
it('shows forced routing override and learned route separately', async () => {
const contact = createContact({
last_path_len: 1,
out_path_hash_mode: 0,
direct_path_len: 1,
direct_path_hash_mode: 0,
route_override_path: 'ae92f13e',
route_override_len: 2,
route_override_hash_mode: 1,

View File

@@ -166,9 +166,9 @@ describe('ConversationPane', () => {
name: 'Repeater',
type: 2,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -268,9 +268,9 @@ describe('ConversationPane', () => {
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
@@ -304,9 +304,9 @@ describe('ConversationPane', () => {
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,

View File

@@ -274,8 +274,8 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
name: 'TestNode',
type: 1,
flags: 0,
last_path: null,
last_path_len: 0,
direct_path: null,
direct_path_len: 0,
last_advert: null,
lat: null,
lon: null,
@@ -285,7 +285,7 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
last_read_at: null,
first_seen: null,
...overrides,
out_path_hash_mode: overrides.out_path_hash_mode ?? 0,
direct_path_hash_mode: overrides.direct_path_hash_mode ?? 0,
};
}

View File

@@ -29,9 +29,9 @@ describe('MapView', () => {
name: 'Mystery Node',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,
@@ -63,9 +63,9 @@ describe('MapView', () => {
name: 'Almost Stale',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,

View File

@@ -23,9 +23,9 @@ const mockContact: Contact = {
name: 'Alice',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,

View File

@@ -51,9 +51,9 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
name,
type,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,

View File

@@ -22,8 +22,9 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
name: 'Test Contact',
type: CONTACT_TYPE_REPEATER,
flags: 0,
last_path: null,
last_path_len: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
@@ -33,7 +34,6 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
last_read_at: null,
first_seen: null,
...overrides,
out_path_hash_mode: overrides.out_path_hash_mode ?? 0,
};
}
@@ -139,9 +139,9 @@ describe('contact routing helpers', () => {
it('prefers routing override over learned route', () => {
const effective = getEffectiveContactRoute(
createContact({
last_path: 'AABB',
last_path_len: 1,
out_path_hash_mode: 0,
direct_path: 'AABB',
direct_path_len: 1,
direct_path_hash_mode: 0,
route_override_path: 'AE92F13E',
route_override_len: 2,
route_override_hash_mode: 1,

View File

@@ -82,9 +82,9 @@ const contacts: Contact[] = [
name: 'TestRepeater',
type: 2,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -305,9 +305,9 @@ describe('RepeaterDashboard', () => {
name: 'Neighbor',
type: 1,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,
@@ -365,9 +365,9 @@ describe('RepeaterDashboard', () => {
name: 'Neighbor',
type: 1,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,
@@ -520,15 +520,15 @@ describe('RepeaterDashboard', () => {
});
describe('path type display and reset', () => {
it('shows flood when last_path_len is -1', () => {
it('shows flood when direct_path_len is -1', () => {
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('flood')).toBeInTheDocument();
});
it('shows direct when last_path_len is 0', () => {
it('shows direct when direct_path_len is 0', () => {
const directContacts: Contact[] = [
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
{ ...contacts[0], direct_path_len: 0, last_seen: 1700000000 },
];
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
@@ -536,9 +536,9 @@ describe('RepeaterDashboard', () => {
expect(screen.getByText('direct')).toBeInTheDocument();
});
it('shows N hops when last_path_len > 0', () => {
it('shows N hops when direct_path_len > 0', () => {
const hoppedContacts: Contact[] = [
{ ...contacts[0], last_path_len: 3, last_seen: 1700000000 },
{ ...contacts[0], direct_path_len: 3, last_seen: 1700000000 },
];
render(<RepeaterDashboard {...defaultProps} contacts={hoppedContacts} />);
@@ -548,7 +548,7 @@ describe('RepeaterDashboard', () => {
it('shows 1 hop (singular) for single hop', () => {
const oneHopContacts: Contact[] = [
{ ...contacts[0], last_path_len: 1, last_seen: 1700000000 },
{ ...contacts[0], direct_path_len: 1, last_seen: 1700000000 },
];
render(<RepeaterDashboard {...defaultProps} contacts={oneHopContacts} />);
@@ -558,7 +558,7 @@ describe('RepeaterDashboard', () => {
it('direct path is clickable, underlined, and marked as editable', () => {
const directContacts: Contact[] = [
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
{ ...contacts[0], direct_path_len: 0, last_seen: 1700000000 },
];
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
@@ -573,7 +573,7 @@ describe('RepeaterDashboard', () => {
const forcedContacts: Contact[] = [
{
...contacts[0],
last_path_len: 1,
direct_path_len: 1,
last_seen: 1700000000,
route_override_path: 'ae92f13e',
route_override_len: 2,
@@ -589,7 +589,7 @@ describe('RepeaterDashboard', () => {
it('clicking direct path opens modal and can force direct routing', async () => {
const directContacts: Contact[] = [
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
{ ...contacts[0], direct_path_len: 0, last_seen: 1700000000 },
];
const { api } = await import('../api');
@@ -613,7 +613,7 @@ describe('RepeaterDashboard', () => {
it('closing the routing override modal does not call the API', async () => {
const directContacts: Contact[] = [
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
{ ...contacts[0], direct_path_len: 0, last_seen: 1700000000 },
];
const { api } = await import('../api');

View File

@@ -231,9 +231,9 @@ describe('SearchView', () => {
name: 'Bob',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,

View File

@@ -27,9 +27,9 @@ function makeContact(
name,
type,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,

View File

@@ -225,9 +225,9 @@ describe('resolveContactFromHashToken', () => {
name: 'Alice',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -242,9 +242,9 @@ describe('resolveContactFromHashToken', () => {
name: 'Alice',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -259,9 +259,9 @@ describe('resolveContactFromHashToken', () => {
name: null,
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,

View File

@@ -42,9 +42,9 @@ function makeContact(suffix: string): Contact {
name: `Contact-${suffix}`,
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,

View File

@@ -200,9 +200,9 @@ describe('useConversationActions', () => {
name: 'Alice',
type: 1,
flags: 0,
last_path: 'AABB',
last_path_len: 2,
out_path_hash_mode: 0,
direct_path: 'AABB',
direct_path_len: 2,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,

View File

@@ -100,9 +100,9 @@ describe('useRealtimeAppState', () => {
name: 'Bob',
type: 1,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -142,9 +142,9 @@ describe('useRealtimeAppState', () => {
name: 'Bob',
type: 1,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -232,9 +232,9 @@ describe('useRealtimeAppState', () => {
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,

View File

@@ -44,9 +44,9 @@ function makeContact(pubkey: string): Contact {
name: `Contact-${pubkey.slice(0, 6)}`,
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,

View File

@@ -44,9 +44,9 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
name,
type,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,

View File

@@ -112,9 +112,9 @@ describe('useWebSocket dispatch', () => {
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,

View File

@@ -25,9 +25,9 @@ describe('wsEvents', () => {
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
@@ -50,9 +50,9 @@ describe('wsEvents', () => {
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,

View File

@@ -99,12 +99,17 @@ export interface Contact {
name: string | null;
type: number;
flags: number;
last_path: string | null;
last_path_len: number;
out_path_hash_mode: number;
direct_path: string | null;
direct_path_len: number;
direct_path_hash_mode: number;
direct_path_updated_at?: number | null;
route_override_path?: string | null;
route_override_len?: number | null;
route_override_hash_mode?: number | null;
effective_route?: ContactRoute | null;
effective_route_source?: 'override' | 'direct' | 'flood';
direct_route?: ContactRoute | null;
route_override?: ContactRoute | null;
last_advert: number | null;
lat: number | null;
lon: number | null;
@@ -115,6 +120,12 @@ export interface Contact {
first_seen: number | null;
}
export interface ContactRoute {
path: string;
path_len: number;
path_hash_mode: number;
}
export interface ContactAdvertPath {
path: string;
path_len: number;

View File

@@ -1,4 +1,4 @@
import type { Contact, RadioConfig, MessagePath } from '../types';
import type { Contact, ContactRoute, RadioConfig, MessagePath } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
const MAX_PATH_BYTES = 64;
@@ -37,6 +37,7 @@ export interface EffectiveContactRoute {
pathLen: number;
pathHashMode: number;
forced: boolean;
source: 'override' | 'direct' | 'flood';
}
function normalizePathHashMode(mode: number | null | undefined): number | null {
@@ -114,29 +115,86 @@ export function parsePathHops(path: string | null | undefined, hopCount?: number
}
export function hasRoutingOverride(contact: Contact): boolean {
return contact.route_override_len !== null && contact.route_override_len !== undefined;
return (
(contact.route_override !== null && contact.route_override !== undefined) ||
(contact.route_override_len !== null && contact.route_override_len !== undefined)
);
}
export function getDirectContactRoute(contact: Contact): ContactRoute | null {
if (contact.direct_route) {
return contact.direct_route;
}
if (contact.direct_path_len < 0) {
return null;
}
return {
path: contact.direct_path ?? '',
path_len: contact.direct_path_len,
path_hash_mode:
normalizePathHashMode(contact.direct_path_hash_mode) ??
inferPathHashMode(contact.direct_path, contact.direct_path_len) ??
0,
};
}
function getRouteOverride(contact: Contact): ContactRoute | null {
if (contact.route_override) {
return contact.route_override;
}
if (!hasRoutingOverride(contact)) {
return null;
}
const pathLen = contact.route_override_len ?? -1;
let pathHashMode = normalizePathHashMode(contact.route_override_hash_mode);
if (pathLen === -1) {
pathHashMode = -1;
} else if (pathHashMode == null) {
pathHashMode = inferPathHashMode(contact.route_override_path, pathLen) ?? 0;
}
return {
path: contact.route_override_path ?? '',
path_len: pathLen,
path_hash_mode: pathHashMode,
};
}
export function getEffectiveContactRoute(contact: Contact): EffectiveContactRoute {
const forced = hasRoutingOverride(contact);
const pathLen = forced ? (contact.route_override_len ?? -1) : contact.last_path_len;
const path = forced ? (contact.route_override_path ?? '') : (contact.last_path ?? '');
const route = contact.effective_route;
if (route) {
return {
path: route.path || null,
pathLen: route.path_len,
pathHashMode: route.path_hash_mode,
forced: contact.effective_route_source === 'override',
source: contact.effective_route_source ?? 'flood',
};
}
let pathHashMode = forced
? (contact.route_override_hash_mode ?? null)
: (contact.out_path_hash_mode ?? null);
const directRoute = getDirectContactRoute(contact);
const overrideRoute = getRouteOverride(contact);
const resolvedRoute = overrideRoute ?? directRoute;
const source = overrideRoute ? 'override' : directRoute ? 'direct' : 'flood';
const pathLen = resolvedRoute?.path_len ?? -1;
let pathHashMode = resolvedRoute?.path_hash_mode ?? null;
if (pathLen === -1) {
pathHashMode = -1;
} else if (pathHashMode == null || pathHashMode < 0 || pathHashMode > 2) {
pathHashMode = inferPathHashMode(path, pathLen) ?? 0;
pathHashMode = inferPathHashMode(resolvedRoute?.path, pathLen) ?? 0;
}
return {
path: path || null,
path: resolvedRoute?.path || null,
pathLen,
pathHashMode,
forced,
forced: source === 'override',
source,
};
}
@@ -151,16 +209,17 @@ export function formatRouteLabel(pathLen: number, capitalize: boolean = false):
}
export function formatRoutingOverrideInput(contact: Contact): string {
if (!hasRoutingOverride(contact)) {
const routeOverride = getRouteOverride(contact);
if (!routeOverride) {
return '';
}
if (contact.route_override_len === -1) {
if (routeOverride.path_len === -1) {
return '-1';
}
if (contact.route_override_len === 0) {
if (routeOverride.path_len === 0) {
return '0';
}
return parsePathHops(contact.route_override_path, contact.route_override_len)
return parsePathHops(routeOverride.path, routeOverride.path_len)
.map((hop) => hop.toLowerCase())
.join(',');
}

View File

@@ -43,6 +43,8 @@ export interface RadioConfig {
cr: number;
}
export type RadioAdvertMode = 'flood' | 'zero_hop';
export function getRadioConfig(): Promise<RadioConfig> {
return fetchJson('/radio/config');
}
@@ -58,6 +60,13 @@ export function rebootRadio(): Promise<{ status: string; message: string }> {
return fetchJson('/radio/reboot', { method: 'POST' });
}
export function sendAdvertisement(mode: RadioAdvertMode = 'flood'): Promise<{ status: string }> {
return fetchJson('/radio/advertise', {
method: 'POST',
body: JSON.stringify({ mode }),
});
}
// --- Channels ---
export interface Channel {
@@ -90,9 +99,9 @@ export interface Contact {
name: string | null;
type: number;
flags: number;
last_path: string | null;
last_path_len: number;
out_path_hash_mode: number;
direct_path: string | null;
direct_path_len: number;
direct_path_hash_mode: number;
route_override_path?: string | null;
route_override_len?: number | null;
route_override_hash_mode?: number | null;
@@ -128,6 +137,22 @@ export function deleteContact(publicKey: string): Promise<{ status: string }> {
return fetchJson(`/contacts/${publicKey}`, { method: 'DELETE' });
}
export async function getContactByKey(publicKey: string): Promise<Contact | undefined> {
const normalized = publicKey.toLowerCase();
const contacts = await getContacts(500, 0);
return contacts.find((contact) => contact.public_key.toLowerCase() === normalized);
}
export function setContactRoutingOverride(
publicKey: string,
route: string
): Promise<{ status: string; public_key: string }> {
return fetchJson(`/contacts/${publicKey}/routing-override`, {
method: 'POST',
body: JSON.stringify({ route }),
});
}
// --- Messages ---
export interface MessagePath {

View File

@@ -0,0 +1,135 @@
import { test, expect } from '@playwright/test';
import {
createContact,
deleteContact,
getContactByKey,
getMessages,
setContactRoutingOverride,
} from '../helpers/api';
const DEV_ONLY_ENV = 'MESHCORE_ENABLE_DEV_FLIGHTLESS_ROUTE_E2E';
const FLIGHTLESS_NAME = 'FlightlessDt🥝';
const FLIGHTLESS_PUBLIC_KEY =
'ae92577bae6c269a1da3c87b5333e1bdb007e372b66e94204b9f92a6b52a62b1';
const DEVELOPER_ONLY_NOTICE =
`Developer-only hardware test. This scenario assumes ${FLIGHTLESS_NAME} ` +
`(${FLIGHTLESS_PUBLIC_KEY.slice(0, 12)}...) is a nearby reachable node for the author's test radio. ` +
`Set ${DEV_ONLY_ENV}=1 to run it intentionally.`;
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
test.describe('Developer-only direct-route learning for FlightlessDt🥝', () => {
test('zero-hop adverts then DM ACK learns a direct route', { tag: '@developer-only' }, async ({
page,
}, testInfo) => {
testInfo.annotations.push({ type: 'notice', description: DEVELOPER_ONLY_NOTICE });
if (process.env[DEV_ONLY_ENV] !== '1') {
test.skip(true, DEVELOPER_ONLY_NOTICE);
}
test.setTimeout(180_000);
console.warn(`[developer-only e2e] ${DEVELOPER_ONLY_NOTICE}`);
try {
await deleteContact(FLIGHTLESS_PUBLIC_KEY);
} catch {
// Best-effort reset; the contact may not exist yet in the temp E2E DB.
}
await createContact(FLIGHTLESS_PUBLIC_KEY, FLIGHTLESS_NAME);
await setContactRoutingOverride(FLIGHTLESS_PUBLIC_KEY, '');
await expect
.poll(
async () => {
const contact = await getContactByKey(FLIGHTLESS_PUBLIC_KEY);
return contact?.direct_path_len ?? null;
},
{
timeout: 10_000,
message: 'Waiting for recreated FlightlessDt contact to start in flood mode',
}
)
.toBe(-1);
await page.goto('/#settings/radio');
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
const zeroHopButton = page.getByRole('button', { name: 'Send Zero-Hop Advertisement' });
await expect(zeroHopButton).toBeVisible();
await zeroHopButton.click();
await expect(page.getByText('Zero-hop advertisement sent')).toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(5_000);
await zeroHopButton.click();
await expect(page.getByText('Zero-hop advertisement sent')).toBeVisible({ timeout: 15_000 });
await page.getByRole('button', { name: /Back to Chat/i }).click();
await expect(page.getByRole('button', { name: /Back to Chat/i })).toBeHidden({
timeout: 15_000,
});
const searchInput = page.getByLabel('Search conversations');
await searchInput.fill(FLIGHTLESS_PUBLIC_KEY.slice(0, 12));
await expect(page.getByText(FLIGHTLESS_NAME, { exact: true })).toBeVisible({
timeout: 15_000,
});
await page.getByText(FLIGHTLESS_NAME, { exact: true }).click();
await expect
.poll(() => page.url(), {
timeout: 15_000,
message: 'Waiting for FlightlessDt conversation route to load',
})
.toContain(`#contact/${encodeURIComponent(FLIGHTLESS_PUBLIC_KEY)}`);
await expect(
page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(FLIGHTLESS_NAME)}`, 'i'))
).toBeVisible({ timeout: 15_000 });
const text = `dev-flightless-direct-${Date.now()}`;
const input = page.getByPlaceholder(/message/i);
await input.fill(text);
await page.getByRole('button', { name: 'Send', exact: true }).click();
await expect(page.getByText(text)).toBeVisible({ timeout: 15_000 });
await expect
.poll(
async () => {
const messages = await getMessages({
type: 'PRIV',
conversation_key: FLIGHTLESS_PUBLIC_KEY,
limit: 25,
});
const match = messages.find((message) => message.outgoing && message.text === text);
return match?.acked ?? 0;
},
{
timeout: 90_000,
message: 'Waiting for FlightlessDt DM ACK',
}
)
.toBeGreaterThan(0);
await expect
.poll(
async () => {
const contact = await getContactByKey(FLIGHTLESS_PUBLIC_KEY);
return contact?.direct_path_len ?? null;
},
{
timeout: 90_000,
message: 'Waiting for FlightlessDt route to update from flood to direct',
}
)
.toBe(0);
const learnedContact = await getContactByKey(FLIGHTLESS_PUBLIC_KEY);
expect(learnedContact?.direct_path ?? '').toBe('');
await page.locator('[title="View contact info"]').click();
await expect(page.getByLabel('Contact Info')).toBeVisible({ timeout: 15_000 });
});
});

View File

@@ -49,8 +49,9 @@ async def _insert_contact(public_key, name="Alice"):
"name": name,
"type": 0,
"flags": 0,
"last_path": None,
"last_path_len": -1,
"direct_path": None,
"direct_path_len": -1,
"direct_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,

View File

@@ -62,8 +62,9 @@ async def _insert_contact(public_key, name="Alice", **overrides):
"name": name,
"type": 0,
"flags": 0,
"last_path": None,
"last_path_len": -1,
"direct_path": None,
"direct_path_len": -1,
"direct_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,

View File

@@ -44,8 +44,9 @@ async def _insert_contact(public_key=KEY_A, name="Alice", on_radio=False, **over
"name": name,
"type": 0,
"flags": 0,
"last_path": None,
"last_path_len": -1,
"direct_path": None,
"direct_path_len": -1,
"direct_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,
@@ -307,9 +308,9 @@ class TestPathDiscovery:
updated = await ContactRepository.get_by_key(KEY_A)
assert updated is not None
assert updated.last_path == "11223344"
assert updated.last_path_len == 2
assert updated.out_path_hash_mode == 1
assert updated.direct_path == "11223344"
assert updated.direct_path_len == 2
assert updated.direct_path_hash_mode == 1
mc.commands.add_contact.assert_awaited()
mock_broadcast.assert_called_once_with("contact", updated.model_dump())
@@ -527,7 +528,7 @@ class TestRoutingOverride:
@pytest.mark.asyncio
async def test_set_explicit_routing_override(self, test_db, client):
await _insert_contact(KEY_A, last_path="11", last_path_len=1, out_path_hash_mode=0)
await _insert_contact(KEY_A, direct_path="11", direct_path_len=1, direct_path_hash_mode=0)
with (
patch("app.routers.contacts.radio_manager") as mock_rm,
@@ -542,8 +543,8 @@ class TestRoutingOverride:
assert response.status_code == 200
contact = await ContactRepository.get_by_key(KEY_A)
assert contact is not None
assert contact.last_path == "11"
assert contact.last_path_len == 1
assert contact.direct_path == "11"
assert contact.direct_path_len == 1
assert contact.route_override_path == "ae92f13e"
assert contact.route_override_len == 2
assert contact.route_override_hash_mode == 1
@@ -554,9 +555,9 @@ class TestRoutingOverride:
await _insert_contact(
KEY_A,
on_radio=True,
last_path="11",
last_path_len=1,
out_path_hash_mode=0,
direct_path="11",
direct_path_len=1,
direct_path_hash_mode=0,
)
mock_mc = MagicMock()
@@ -584,16 +585,17 @@ class TestRoutingOverride:
contact = await ContactRepository.get_by_key(KEY_A)
assert contact is not None
assert contact.route_override_len == -1
assert contact.last_path == "11"
assert contact.last_path_len == 1
assert contact.direct_path == "11"
assert contact.direct_path_len == 1
@pytest.mark.asyncio
async def test_blank_route_clears_override_and_resets_learned_path(self, test_db, client):
async def test_blank_route_clears_override_and_preserves_learned_path(self, test_db, client):
await _insert_contact(
KEY_A,
last_path="11",
last_path_len=1,
out_path_hash_mode=0,
direct_path="11",
direct_path_len=1,
direct_path_hash_mode=0,
direct_path_updated_at=1700000000,
route_override_path="ae92f13e",
route_override_len=2,
route_override_hash_mode=1,
@@ -613,9 +615,10 @@ class TestRoutingOverride:
contact = await ContactRepository.get_by_key(KEY_A)
assert contact is not None
assert contact.route_override_len is None
assert contact.last_path == ""
assert contact.last_path_len == -1
assert contact.out_path_hash_mode == -1
assert contact.direct_path == "11"
assert contact.direct_path_len == 1
assert contact.direct_path_hash_mode == 0
assert contact.direct_path_updated_at == 1700000000
@pytest.mark.asyncio
async def test_rejects_invalid_explicit_route(self, test_db, client):

View File

@@ -16,12 +16,14 @@ from app.decoder import (
_clamp_scalar,
decrypt_direct_message,
decrypt_group_text,
decrypt_path_payload,
derive_public_key,
derive_shared_secret,
extract_payload,
parse_packet,
try_decrypt_dm,
try_decrypt_packet_with_channel_key,
try_decrypt_path,
)
@@ -298,6 +300,181 @@ class TestGroupTextDecryption:
assert result is None
class TestPathDecryption:
"""Test PATH payload decryption against the firmware wire format."""
WORKED_PATH_PACKET = bytes.fromhex("22007EDE577469F4134F9B00EDD57EB4353A1B5999B7")
WORKED_PATH_SENDER_PRIV = bytes.fromhex(
"489E11DCC0A5E037E65C90D2327AA11A42EAFE0C9F68DEBE82B0F71C88C0874B"
"CC291D9B2B98A54F5C1426B7AB8156B0D684EAA4EBA755AC614A9FD32B74C308"
)
WORKED_PATH_DEST_PUB = bytes.fromhex(
"7e23132922070404863fe855248ce414b64012c891342c1fc7ee5bd3d51ea405"
)
@staticmethod
def _create_encrypted_path_payload(
*,
shared_secret: bytes,
dest_hash: int,
src_hash: int,
packed_path_len: int,
path_bytes: bytes,
extra_type: int,
extra: bytes,
) -> bytes:
plaintext = bytes([packed_path_len]) + path_bytes + bytes([extra_type]) + extra
pad_len = (16 - len(plaintext) % 16) % 16
if pad_len == 0:
pad_len = 16
plaintext += bytes(pad_len)
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
ciphertext = cipher.encrypt(plaintext)
mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
return bytes([dest_hash, src_hash]) + mac + ciphertext
def test_decrypt_path_payload_matches_firmware_layout(self):
"""PATH packets are dest/src hashes plus MAC+ciphertext; decrypted data is path+extra."""
shared_secret = bytes(range(32))
payload = self._create_encrypted_path_payload(
shared_secret=shared_secret,
dest_hash=0xAE,
src_hash=0x11,
packed_path_len=0x42, # mode 1 (2-byte hops), 2 hops
path_bytes=bytes.fromhex("aabbccdd"),
extra_type=PayloadType.ACK,
extra=bytes.fromhex("01020304"),
)
result = decrypt_path_payload(payload, shared_secret)
assert result is not None
assert result.dest_hash == "ae"
assert result.src_hash == "11"
assert result.returned_path == bytes.fromhex("aabbccdd")
assert result.returned_path_len == 2
assert result.returned_path_hash_mode == 1
assert result.extra_type == PayloadType.ACK
assert result.extra[:4] == bytes.fromhex("01020304")
def test_decrypt_path_payload_rejects_corrupted_mac(self):
"""PATH payloads with a bad MAC must be rejected."""
shared_secret = bytes(range(32))
payload = self._create_encrypted_path_payload(
shared_secret=shared_secret,
dest_hash=0xAE,
src_hash=0x11,
packed_path_len=0x00,
path_bytes=b"",
extra_type=PayloadType.RESPONSE,
extra=b"\x99\x88",
)
corrupted = payload[:2] + bytes([payload[2] ^ 0xFF, payload[3]]) + payload[4:]
result = decrypt_path_payload(corrupted, shared_secret)
assert result is None
def test_decrypt_worked_path_packet_fixture(self):
"""Worked PATH sample from the design doc decrypts as a direct route."""
packet = parse_packet(self.WORKED_PATH_PACKET)
assert packet is not None
assert packet.payload_type == PayloadType.PATH
shared_secret = derive_shared_secret(
self.WORKED_PATH_SENDER_PRIV, self.WORKED_PATH_DEST_PUB
)
result = decrypt_path_payload(packet.payload, shared_secret)
assert result is not None
assert result.dest_hash == "7e"
assert result.src_hash == "de"
assert result.returned_path == b""
assert result.returned_path_len == 0
assert result.returned_path_hash_mode == 0
assert result.extra_type == 0x0F
class TestTryDecryptPath:
"""Test the full PATH decryption wrapper."""
OUR_PRIV = bytes.fromhex(
"58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B"
"77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1"
)
THEIR_PUB = bytes.fromhex("a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7")
@classmethod
def _make_path_packet(
cls,
*,
packed_path_len: int,
path_bytes: bytes,
extra_type: int,
extra: bytes,
) -> bytes:
shared_secret = derive_shared_secret(cls.OUR_PRIV, cls.THEIR_PUB)
plaintext = bytes([packed_path_len]) + path_bytes + bytes([extra_type]) + extra
pad_len = (16 - len(plaintext) % 16) % 16
if pad_len == 0:
pad_len = 16
plaintext += bytes(pad_len)
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
ciphertext = cipher.encrypt(plaintext)
mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
our_public = derive_public_key(cls.OUR_PRIV)
return (
bytes([(PayloadType.PATH << 2) | RouteType.DIRECT, 0x00])
+ bytes([our_public[0], cls.THEIR_PUB[0]])
+ mac
+ ciphertext
)
def test_try_decrypt_path_decrypts_full_packet(self):
"""try_decrypt_path validates hashes, derives ECDH, and returns the route."""
raw_packet = self._make_path_packet(
packed_path_len=0x42,
path_bytes=bytes.fromhex("aabbccdd"),
extra_type=PayloadType.ACK,
extra=bytes.fromhex("01020304"),
)
result = try_decrypt_path(
raw_packet=raw_packet,
our_private_key=self.OUR_PRIV,
their_public_key=self.THEIR_PUB,
our_public_key=derive_public_key(self.OUR_PRIV),
)
assert result is not None
assert result.returned_path == bytes.fromhex("aabbccdd")
assert result.returned_path_len == 2
assert result.returned_path_hash_mode == 1
assert result.extra_type == PayloadType.ACK
assert result.extra[:4] == bytes.fromhex("01020304")
def test_try_decrypt_path_rejects_hash_mismatch(self):
"""Packets addressed to another destination are rejected before decryption."""
raw_packet = self._make_path_packet(
packed_path_len=0x00,
path_bytes=b"",
extra_type=PayloadType.RESPONSE,
extra=b"\xaa",
)
wrong_our_public = bytes.fromhex("ff") + derive_public_key(self.OUR_PRIV)[1:]
result = try_decrypt_path(
raw_packet=raw_packet,
our_private_key=self.OUR_PRIV,
their_public_key=self.THEIR_PUB,
our_public_key=wrong_our_public,
)
assert result is None
class TestTryDecryptPacket:
"""Test the full packet decryption pipeline."""

View File

@@ -559,7 +559,6 @@ class TestDualPathDedup:
"last_contacted": SENDER_TIMESTAMP,
"first_seen": SENDER_TIMESTAMP,
"on_radio": False,
"out_path_hash_mode": 0,
}
)
@@ -631,7 +630,6 @@ class TestDualPathDedup:
"last_contacted": SENDER_TIMESTAMP,
"first_seen": SENDER_TIMESTAMP,
"on_radio": False,
"out_path_hash_mode": 0,
}
)
@@ -706,7 +704,6 @@ class TestDualPathDedup:
"last_contacted": SENDER_TIMESTAMP,
"first_seen": SENDER_TIMESTAMP,
"on_radio": False,
"out_path_hash_mode": 0,
}
)

View File

@@ -850,9 +850,9 @@ class TestOnPathUpdate:
# Verify path was updated in DB
contact = await ContactRepository.get_by_key("aa" * 32)
assert contact is not None
assert contact.last_path == "0102"
assert contact.last_path_len == 2
assert contact.out_path_hash_mode == 0
assert contact.direct_path == "0102"
assert contact.direct_path_len == 2
assert contact.direct_path_hash_mode == 0
@pytest.mark.asyncio
async def test_updates_path_hash_mode_when_present(self, test_db):
@@ -880,9 +880,9 @@ class TestOnPathUpdate:
contact = await ContactRepository.get_by_key("ab" * 32)
assert contact is not None
assert contact.last_path == "aa00bb00"
assert contact.last_path_len == 2
assert contact.out_path_hash_mode == 1
assert contact.direct_path == "aa00bb00"
assert contact.direct_path_len == 2
assert contact.direct_path_hash_mode == 1
@pytest.mark.asyncio
async def test_does_nothing_when_contact_not_found(self, test_db):
@@ -924,8 +924,8 @@ class TestOnPathUpdate:
contact = await ContactRepository.get_by_key("bb" * 32)
assert contact is not None
assert contact.last_path == "0a0b"
assert contact.last_path_len == 2
assert contact.direct_path == "0a0b"
assert contact.direct_path_len == 2
@pytest.mark.asyncio
async def test_missing_path_fields_does_not_modify_contact(self, test_db):
@@ -940,7 +940,7 @@ class TestOnPathUpdate:
"flags": 0,
}
)
await ContactRepository.update_path("dd" * 32, "beef", 2)
await ContactRepository.update_direct_path("dd" * 32, "beef", 2)
class MockEvent:
payload = {"public_key": "dd" * 32}
@@ -949,8 +949,8 @@ class TestOnPathUpdate:
contact = await ContactRepository.get_by_key("dd" * 32)
assert contact is not None
assert contact.last_path == "beef"
assert contact.last_path_len == 2
assert contact.direct_path == "beef"
assert contact.direct_path_len == 2
@pytest.mark.asyncio
async def test_missing_identity_fields_noop(self, test_db):
@@ -965,7 +965,7 @@ class TestOnPathUpdate:
"flags": 0,
}
)
await ContactRepository.update_path("ee" * 32, "abcd", 2)
await ContactRepository.update_direct_path("ee" * 32, "abcd", 2)
class MockEvent:
payload = {}
@@ -974,8 +974,8 @@ class TestOnPathUpdate:
contact = await ContactRepository.get_by_key("ee" * 32)
assert contact is not None
assert contact.last_path == "abcd"
assert contact.last_path_len == 2
assert contact.direct_path == "abcd"
assert contact.direct_path_len == 2
class TestOnNewContact:

View File

@@ -1201,8 +1201,8 @@ class TestMigration039:
"""Test migration 039: persist contacts.out_path_hash_mode."""
@pytest.mark.asyncio
async def test_adds_column_and_backfills_legacy_rows(self):
"""Pre-039 contacts get flood=-1 and legacy routed paths=0."""
async def test_legacy_advert_paths_do_not_become_direct_routes_after_upgrade(self):
"""Pre-045 advert-derived last_path data is dropped from active direct-route columns."""
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
try:
@@ -1247,29 +1247,31 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 6
assert await get_version(conn) == 44
assert applied == 7
assert await get_version(conn) == 45
cursor = await conn.execute(
"""
SELECT public_key, last_path_len, out_path_hash_mode
SELECT public_key, direct_path, direct_path_len, direct_path_hash_mode
FROM contacts
ORDER BY public_key
"""
)
rows = await cursor.fetchall()
assert rows[0]["public_key"] == "aa" * 32
assert rows[0]["last_path_len"] == -1
assert rows[0]["out_path_hash_mode"] == -1
assert rows[0]["direct_path"] is None
assert rows[0]["direct_path_len"] is None
assert rows[0]["direct_path_hash_mode"] is None
assert rows[1]["public_key"] == "bb" * 32
assert rows[1]["last_path_len"] == 1
assert rows[1]["out_path_hash_mode"] == 0
assert rows[1]["direct_path"] is None
assert rows[1]["direct_path_len"] is None
assert rows[1]["direct_path_hash_mode"] is None
finally:
await conn.close()
@pytest.mark.asyncio
async def test_existing_valid_modes_are_preserved_when_column_already_exists(self):
"""Migration does not clobber post-upgrade multibyte rows."""
async def test_legacy_out_path_hash_mode_is_not_promoted_into_direct_routes(self):
"""Pre-045 out_path_hash_mode does not make advert paths become active direct routes."""
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
try:
@@ -1317,12 +1319,12 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 6
assert await get_version(conn) == 44
assert applied == 7
assert await get_version(conn) == 45
cursor = await conn.execute(
"""
SELECT public_key, out_path_hash_mode
SELECT public_key, direct_path, direct_path_len, direct_path_hash_mode
FROM contacts
WHERE public_key IN (?, ?)
ORDER BY public_key
@@ -1331,9 +1333,75 @@ class TestMigration039:
)
rows = await cursor.fetchall()
assert rows[0]["public_key"] == "cc" * 32
assert rows[0]["out_path_hash_mode"] == 1
assert rows[0]["direct_path"] is None
assert rows[0]["direct_path_len"] is None
assert rows[0]["direct_path_hash_mode"] is None
assert rows[1]["public_key"] == "dd" * 32
assert rows[1]["out_path_hash_mode"] == -1
assert rows[1]["direct_path"] is None
assert rows[1]["direct_path_len"] is None
assert rows[1]["direct_path_hash_mode"] is None
finally:
await conn.close()
@pytest.mark.asyncio
async def test_existing_direct_route_columns_are_preserved(self):
"""Already-migrated databases keep canonical direct-route data intact."""
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
try:
await set_version(conn, 44)
await conn.execute("""
CREATE TABLE contacts (
public_key TEXT PRIMARY KEY,
name TEXT,
type INTEGER DEFAULT 0,
flags INTEGER DEFAULT 0,
direct_path TEXT,
direct_path_len INTEGER,
direct_path_hash_mode INTEGER,
direct_path_updated_at INTEGER,
route_override_path TEXT,
route_override_len INTEGER,
route_override_hash_mode INTEGER,
last_advert INTEGER,
lat REAL,
lon REAL,
last_seen INTEGER,
on_radio INTEGER DEFAULT 0,
last_contacted INTEGER,
first_seen INTEGER,
last_read_at INTEGER
)
""")
await conn.execute(
"""
INSERT INTO contacts (
public_key, name, direct_path, direct_path_len, direct_path_hash_mode,
direct_path_updated_at, last_seen
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("ee" * 32, "Direct", "aa00bb00", 2, 1, 123456, 123457),
)
await conn.commit()
applied = await run_migrations(conn)
assert applied == 1
assert await get_version(conn) == 45
cursor = await conn.execute(
"""
SELECT direct_path, direct_path_len, direct_path_hash_mode, direct_path_updated_at
FROM contacts
WHERE public_key = ?
""",
("ee" * 32,),
)
row = await cursor.fetchone()
assert row["direct_path"] == "aa00bb00"
assert row["direct_path_len"] == 2
assert row["direct_path_hash_mode"] == 1
assert row["direct_path_updated_at"] == 123456
finally:
await conn.close()
@@ -1371,8 +1439,8 @@ class TestMigration040:
applied = await run_migrations(conn)
assert applied == 5
assert await get_version(conn) == 44
assert applied == 6
assert await get_version(conn) == 45
await conn.execute(
"""
@@ -1433,8 +1501,8 @@ class TestMigration041:
applied = await run_migrations(conn)
assert applied == 4
assert await get_version(conn) == 44
assert applied == 5
assert await get_version(conn) == 45
await conn.execute(
"""
@@ -1486,8 +1554,8 @@ class TestMigration042:
applied = await run_migrations(conn)
assert applied == 3
assert await get_version(conn) == 44
assert applied == 4
assert await get_version(conn) == 45
await conn.execute(
"""

View File

@@ -12,10 +12,20 @@ from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from Crypto.Cipher import AES
from app.decoder import DecryptedDirectMessage, PacketInfo, ParsedAdvertisement, PayloadType
from app.decoder import (
DecryptedDirectMessage,
PacketInfo,
ParsedAdvertisement,
PayloadType,
RouteType,
derive_public_key,
derive_shared_secret,
)
from app.repository import (
ChannelRepository,
ContactAdvertPathRepository,
ContactRepository,
MessageRepository,
RawPacketRepository,
@@ -27,6 +37,43 @@ with open(FIXTURES_PATH) as f:
FIXTURES = json.load(f)
PATH_TEST_OUR_PRIV = bytes.fromhex(
"58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B"
"77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1"
)
PATH_TEST_CONTACT_PUB = bytes.fromhex(
"a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7"
)
PATH_TEST_OUR_PUB = derive_public_key(PATH_TEST_OUR_PRIV)
def _build_path_packet(
*,
packed_path_len: int,
path_bytes: bytes,
extra_type: int,
extra: bytes,
route_type: RouteType = RouteType.DIRECT,
) -> bytes:
shared_secret = derive_shared_secret(PATH_TEST_OUR_PRIV, PATH_TEST_CONTACT_PUB)
plaintext = bytes([packed_path_len]) + path_bytes + bytes([extra_type]) + extra
pad_len = (16 - len(plaintext) % 16) % 16
if pad_len == 0:
pad_len = 16
plaintext += bytes(pad_len)
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
ciphertext = cipher.encrypt(plaintext)
import hmac
from hashlib import sha256
mac = hmac.new(shared_secret, ciphertext, sha256).digest()[:2]
header = bytes([(PayloadType.PATH << 2) | route_type, 0x00])
payload = bytes([PATH_TEST_OUR_PUB[0], PATH_TEST_CONTACT_PUB[0]]) + mac + ciphertext
return header + payload
class TestChannelMessagePipeline:
"""Test channel message flow: packet → decrypt → store → broadcast."""
@@ -169,10 +216,13 @@ class TestAdvertisementPipeline:
assert contact.lon is not None
assert abs(contact.lat - expected["lat"]) < 0.001
assert abs(contact.lon - expected["lon"]) < 0.001
# This advertisement has path_len=6 (6 hops through repeaters)
assert contact.last_path_len == 6
assert contact.last_path is not None
assert len(contact.last_path) == 12 # 6 bytes = 12 hex chars
assert contact.direct_path_len == -1
assert contact.direct_path in (None, "")
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
assert len(advert_paths) == 1
assert advert_paths[0].path_len == 6
assert len(advert_paths[0].path) == 12 # 6 bytes = 12 hex chars
# Verify WebSocket broadcast
contact_broadcasts = [b for b in broadcasts if b["type"] == "contact"]
@@ -182,7 +232,8 @@ class TestAdvertisementPipeline:
assert broadcast["data"]["public_key"] == expected["public_key"]
assert broadcast["data"]["name"] == expected["name"]
assert broadcast["data"]["type"] == expected["type"]
assert broadcast["data"]["last_path_len"] == 6
assert broadcast["data"]["direct_path_len"] == -1
assert "last_path_len" not in broadcast["data"]
@pytest.mark.asyncio
async def test_advertisement_updates_existing_contact(self, test_db, captured_broadcasts):
@@ -216,10 +267,11 @@ class TestAdvertisementPipeline:
assert contact.type == expected["type"] # Type updated
assert contact.lat is not None # GPS added
assert contact.lon is not None
# This advertisement has path_len=0 (direct neighbor)
assert contact.last_path_len == 0
# Empty path stored as None or ""
assert contact.last_path in (None, "")
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
assert len(advert_paths) == 1
assert advert_paths[0].path_len == 0
assert advert_paths[0].path == ""
@pytest.mark.asyncio
async def test_advertisement_triggers_historical_decrypt_for_new_contact(
@@ -278,42 +330,38 @@ class TestAdvertisementPipeline:
assert mock_start.await_count == 0
@pytest.mark.asyncio
async def test_advertisement_keeps_shorter_path_within_window(
async def test_advertisement_records_recent_unique_paths_without_changing_direct_route(
self, test_db, captured_broadcasts
):
"""When receiving echoed advertisements, keep the shortest path within 60s window."""
"""Advertisement paths are informational and do not replace the stored direct route."""
from app.packet_processor import _process_advertisement
# Create a contact with a longer path (path_len=3)
test_pubkey = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
await ContactRepository.upsert(
{
"public_key": test_pubkey,
"name": "TestNode",
"type": 1,
"direct_path": "aabbcc",
"direct_path_len": 3,
"direct_path_hash_mode": 0,
"last_advert": 1000,
"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 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.path_hash_size = 1
short_packet_info.payload = b"" # Will be parsed by parse_advertisement
short_packet_info.payload = b""
# 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(
@@ -324,18 +372,13 @@ class TestAdvertisementPipeline:
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")
long_packet_info.path_hash_size = 1
long_packet_info.payload = b""
with patch("app.packet_processor.broadcast_event", mock_broadcast):
with patch("app.packet_processor.parse_advertisement") as mock_parse:
@@ -347,35 +390,33 @@ class TestAdvertisementPipeline:
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
assert contact is not None
assert contact.direct_path == "aabbcc"
assert contact.direct_path_len == 3
assert contact.direct_path_hash_mode == 0
assert contact.direct_path_updated_at is None
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey)
assert [(path.path, path.path_len) for path in advert_paths] == [
("aabbccddee", 5),
("aa", 1),
]
@pytest.mark.asyncio
async def test_advertisement_path_freshness_uses_receive_time_not_sender_clock(
self, test_db, captured_broadcasts
):
"""Sender clock skew should not keep an old advert path artificially fresh."""
"""Advert history timestamps use receive time instead of sender clock."""
from unittest.mock import MagicMock
from app.decoder import ParsedAdvertisement
from app.packet_processor import _process_advertisement
test_pubkey = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
await ContactRepository.upsert(
{
"public_key": test_pubkey,
"name": "TestNode",
"type": 1,
"last_advert": 1000,
"last_seen": 1055, # Simulates later non-advert activity
"last_path_len": 1,
"last_path": "aa",
}
)
await ContactRepository.upsert({"public_key": test_pubkey, "name": "TestNode", "type": 1})
broadcasts, mock_broadcast = captured_broadcasts
@@ -419,19 +460,19 @@ class TestAdvertisementPipeline:
contact = await ContactRepository.get_by_key(test_pubkey)
assert contact is not None
assert contact.last_path_len == 3
assert contact.last_path == "aabbcc"
assert contact.last_advert == 1200
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey)
assert [(path.path, path.last_seen) for path in advert_paths] == [
("aabbcc", 1200),
("aa", 1070),
]
@pytest.mark.asyncio
async def test_advertisement_default_path_len_treated_as_infinity(
async def test_advertisement_records_path_history_when_no_direct_route_exists(
self, test_db, captured_broadcasts
):
"""Contact with last_path_len=-1 (unset) is treated as infinite length.
Any new advertisement should replace the default -1 path since
the code converts -1 to float('inf') for comparison.
"""
"""Advertisement path history is still recorded when no direct route exists."""
from app.packet_processor import _process_advertisement
test_pubkey = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
@@ -441,8 +482,6 @@ class TestAdvertisementPipeline:
"name": "TestNode",
"type": 1,
"last_seen": 1000,
"last_path_len": -1, # Default unset value
"last_path": None,
}
)
@@ -465,23 +504,21 @@ class TestAdvertisementPipeline:
lon=None,
device_role=1,
)
# Process within 60s window (last_seen=1000, now=1050)
await _process_advertisement(b"", timestamp=1050, packet_info=packet_info)
# Since -1 is treated as infinity, the new path (len=3) should replace it
contact = await ContactRepository.get_by_key(test_pubkey)
assert contact.last_path_len == 3
assert contact.last_path == "aabbcc"
assert contact is not None
assert contact.direct_path_len == -1
assert contact.direct_path in (None, "")
assert contact.direct_path_updated_at is None
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey)
assert len(advert_paths) == 1
assert advert_paths[0].path == "aabbcc"
assert advert_paths[0].path_len == 3
@pytest.mark.asyncio
async def test_advertisement_replaces_stale_path_outside_window(
self, test_db, captured_broadcasts
):
"""When existing path is stale (>60s), a new longer path should replace it.
In a mesh network, a stale short path may no longer be valid (node moved, repeater
went offline). Accepting the new longer path ensures we have a working route.
"""
async def test_advertisement_adds_new_unique_history_path(self, test_db, captured_broadcasts):
"""A new advertisement path is added to history even when an older path already exists."""
from app.packet_processor import _process_advertisement
test_pubkey = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
@@ -491,10 +528,9 @@ class TestAdvertisementPipeline:
"name": "TestNode",
"type": 1,
"last_seen": 1000,
"last_path_len": 1, # Short path
"last_path": "aa",
}
)
await ContactAdvertPathRepository.record_observation(test_pubkey, "aa", 1000, hop_count=1)
from unittest.mock import MagicMock
@@ -521,10 +557,158 @@ class TestAdvertisementPipeline:
)
await _process_advertisement(b"", timestamp=1061, packet_info=long_packet_info)
# Verify the longer path replaced the stale shorter one
contact = await ContactRepository.get_by_key(test_pubkey)
assert contact.last_path_len == 4
assert contact.last_path == "aabbccdd"
assert contact is not None
assert contact.direct_path_len == -1
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey)
assert [(path.path, path.path_len) for path in advert_paths] == [
("aabbccdd", 4),
("aa", 1),
]
class TestPathPacketPipeline:
"""Test PATH packet learning and bundled ACK handling."""
@pytest.mark.asyncio
async def test_process_raw_path_packet_updates_direct_route(self, test_db, captured_broadcasts):
"""A decryptable PATH packet updates the contact's learned direct route."""
from app.packet_processor import process_raw_packet
timestamp = 1700000200
raw_packet = _build_path_packet(
packed_path_len=0x42,
path_bytes=bytes.fromhex("aabbccdd"),
extra_type=PayloadType.RESPONSE,
extra=b"\x11\x22",
)
await ContactRepository.upsert(
{
"public_key": PATH_TEST_CONTACT_PUB.hex(),
"name": "PathPeer",
"type": 1,
}
)
broadcasts, mock_broadcast = captured_broadcasts
with (
patch("app.packet_processor.broadcast_event", mock_broadcast),
patch("app.packet_processor.has_private_key", return_value=True),
patch("app.packet_processor.get_private_key", return_value=PATH_TEST_OUR_PRIV),
patch("app.packet_processor.get_public_key", return_value=PATH_TEST_OUR_PUB),
):
result = await process_raw_packet(raw_packet, timestamp=timestamp)
assert result["payload_type"] == "PATH"
contact = await ContactRepository.get_by_key(PATH_TEST_CONTACT_PUB.hex())
assert contact is not None
assert contact.direct_path == "aabbccdd"
assert contact.direct_path_len == 2
assert contact.direct_path_hash_mode == 1
assert contact.direct_path_updated_at == timestamp
contact_broadcasts = [b for b in broadcasts if b["type"] == "contact"]
assert len(contact_broadcasts) == 1
assert contact_broadcasts[0]["data"]["effective_route_source"] == "direct"
@pytest.mark.asyncio
async def test_bundled_path_ack_marks_message_acked(self, test_db, captured_broadcasts):
"""Bundled ACKs inside PATH packets satisfy the pending DM ACK tracker."""
from app.packet_processor import process_raw_packet
from app.services import dm_ack_tracker
raw_packet = _build_path_packet(
packed_path_len=0x00,
path_bytes=b"",
extra_type=PayloadType.ACK,
extra=bytes.fromhex("01020304feedface"),
)
await ContactRepository.upsert(
{
"public_key": PATH_TEST_CONTACT_PUB.hex(),
"name": "AckPeer",
"type": 1,
}
)
message_id = await MessageRepository.create(
msg_type="PRIV",
text="waiting for ack",
conversation_key=PATH_TEST_CONTACT_PUB.hex(),
sender_timestamp=1700000000,
received_at=1700000000,
outgoing=True,
)
prev_pending = dm_ack_tracker._pending_acks.copy()
prev_buffered = dm_ack_tracker._buffered_acks.copy()
dm_ack_tracker._pending_acks.clear()
dm_ack_tracker._buffered_acks.clear()
dm_ack_tracker.track_pending_ack("01020304", message_id, 30000)
dm_ack_tracker.track_pending_ack("05060708", message_id, 30000)
broadcasts, mock_broadcast = captured_broadcasts
try:
with (
patch("app.packet_processor.broadcast_event", mock_broadcast),
patch("app.packet_processor.has_private_key", return_value=True),
patch("app.packet_processor.get_private_key", return_value=PATH_TEST_OUR_PRIV),
patch("app.packet_processor.get_public_key", return_value=PATH_TEST_OUR_PUB),
):
await process_raw_packet(raw_packet, timestamp=1700000300)
finally:
pending_after = dm_ack_tracker._pending_acks.copy()
dm_ack_tracker._pending_acks.clear()
dm_ack_tracker._pending_acks.update(prev_pending)
dm_ack_tracker._buffered_acks.clear()
dm_ack_tracker._buffered_acks.update(prev_buffered)
messages = await MessageRepository.get_all(
msg_type="PRIV",
conversation_key=PATH_TEST_CONTACT_PUB.hex(),
limit=10,
)
assert len(messages) == 1
assert messages[0].acked == 1
assert "01020304" not in pending_after
assert "05060708" not in pending_after
ack_broadcasts = [b for b in broadcasts if b["type"] == "message_acked"]
assert len(ack_broadcasts) == 1
assert ack_broadcasts[0]["data"] == {"message_id": message_id, "ack_count": 1}
@pytest.mark.asyncio
async def test_prefix_only_contacts_are_skipped_for_path_decryption(self, test_db):
"""Prefix-only contacts are not treated as valid ECDH peers for PATH packets."""
from app.packet_processor import _process_path_packet
raw_packet = _build_path_packet(
packed_path_len=0x00,
path_bytes=b"",
extra_type=PayloadType.RESPONSE,
extra=b"\x01",
)
await ContactRepository.upsert(
{
"public_key": PATH_TEST_CONTACT_PUB.hex()[:12],
"name": "PrefixOnly",
"type": 1,
}
)
with (
patch("app.packet_processor.has_private_key", return_value=True),
patch("app.packet_processor.get_private_key", return_value=PATH_TEST_OUR_PRIV),
patch("app.packet_processor.get_public_key", return_value=PATH_TEST_OUR_PUB),
patch(
"app.packet_processor.try_decrypt_path",
side_effect=AssertionError("prefix-only contacts should be skipped"),
),
):
await _process_path_packet(raw_packet, 1700000400, None)
class TestAckPipeline:
@@ -1694,8 +1878,12 @@ class TestProcessRawPacketIntegration:
contact = await ContactRepository.get_by_key(test_pubkey)
assert contact is not None
assert contact.last_path_len == 1 # Shorter path won
assert contact.last_path == "dd"
assert contact.direct_path_len == -1
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey)
assert [(path.path, path.path_len) for path in advert_paths] == [
("dd", 1),
("aabbcc", 3),
]
@pytest.mark.asyncio
async def test_dispatches_text_message(self, test_db, captured_broadcasts):

View File

@@ -200,16 +200,16 @@ class TestParseExplicitHopRoute:
class TestContactToRadioDictHashMode:
"""Test that Contact.to_radio_dict() preserves the stored out_path_hash_mode."""
"""Test that Contact.to_radio_dict() preserves the stored direct-route hash mode."""
def test_preserves_1byte_mode(self):
from app.models import Contact
c = Contact(
public_key="aa" * 32,
last_path="1a2b3c",
last_path_len=3,
out_path_hash_mode=0,
direct_path="1a2b3c",
direct_path_len=3,
direct_path_hash_mode=0,
)
d = c.to_radio_dict()
assert d["out_path_hash_mode"] == 0
@@ -219,9 +219,9 @@ class TestContactToRadioDictHashMode:
c = Contact(
public_key="bb" * 32,
last_path="1a2b3c4d",
last_path_len=2,
out_path_hash_mode=1,
direct_path="1a2b3c4d",
direct_path_len=2,
direct_path_hash_mode=1,
)
d = c.to_radio_dict()
assert d["out_path_hash_mode"] == 1
@@ -231,9 +231,9 @@ class TestContactToRadioDictHashMode:
c = Contact(
public_key="cc" * 32,
last_path="1a2b3c4d5e6f",
last_path_len=2,
out_path_hash_mode=2,
direct_path="1a2b3c4d5e6f",
direct_path_len=2,
direct_path_hash_mode=2,
)
d = c.to_radio_dict()
assert d["out_path_hash_mode"] == 2
@@ -243,9 +243,9 @@ class TestContactToRadioDictHashMode:
c = Contact(
public_key="dd" * 32,
last_path=None,
last_path_len=-1,
out_path_hash_mode=-1,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
)
d = c.to_radio_dict()
assert d["out_path_hash_mode"] == -1
@@ -255,9 +255,9 @@ class TestContactToRadioDictHashMode:
c = Contact(
public_key="ee" * 32,
last_path="aa00bb00",
last_path_len=2,
out_path_hash_mode=1,
direct_path="aa00bb00",
direct_path_len=2,
direct_path_hash_mode=1,
)
d = c.to_radio_dict()
assert d["out_path_hash_mode"] == 1
@@ -267,9 +267,9 @@ class TestContactToRadioDictHashMode:
c = Contact(
public_key="ff" * 32,
last_path="3f3f69de1c7b7e7662",
last_path_len=-125,
out_path_hash_mode=2,
direct_path="3f3f69de1c7b7e7662",
direct_path_len=-125,
direct_path_hash_mode=2,
)
d = c.to_radio_dict()
assert d["out_path"] == "3f3f69de1c7b7e7662"
@@ -281,9 +281,9 @@ class TestContactToRadioDictHashMode:
c = Contact(
public_key="11" * 32,
last_path="aabb",
last_path_len=1,
out_path_hash_mode=0,
direct_path="aabb",
direct_path_len=1,
direct_path_hash_mode=0,
route_override_path="cc00dd00",
route_override_len=2,
route_override_hash_mode=1,
@@ -309,7 +309,9 @@ class TestContactFromRadioDictHashMode:
"out_path_hash_mode": 1,
},
)
assert d["out_path_hash_mode"] == 1
assert d["direct_path"] == "aa00bb00"
assert d["direct_path_len"] == 2
assert d["direct_path_hash_mode"] == 1
def test_flood_falls_back_to_minus_one(self):
from app.models import Contact
@@ -322,4 +324,6 @@ class TestContactFromRadioDictHashMode:
"out_path_len": -1,
},
)
assert d["out_path_hash_mode"] == -1
assert d["direct_path"] == ""
assert d["direct_path_len"] == -1
assert d["direct_path_hash_mode"] == -1

View File

@@ -356,9 +356,9 @@ class TestDiscoverMesh:
name=None,
type=2,
flags=0,
last_path=None,
last_path_len=-1,
out_path_hash_mode=0,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
last_advert=None,
lat=None,
lon=None,
@@ -418,9 +418,9 @@ class TestDiscoverMesh:
name="Known",
type=4,
flags=0,
last_path=None,
last_path_len=-1,
out_path_hash_mode=0,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
last_advert=None,
lat=None,
lon=None,

View File

@@ -71,9 +71,9 @@ async def _insert_contact(
contact_type=0,
last_contacted=None,
last_advert=None,
last_path=None,
last_path_len=-1,
out_path_hash_mode=0,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
):
"""Insert a contact into the test database."""
await ContactRepository.upsert(
@@ -82,9 +82,9 @@ async def _insert_contact(
"name": name,
"type": contact_type,
"flags": 0,
"last_path": last_path,
"last_path_len": last_path_len,
"out_path_hash_mode": out_path_hash_mode,
"direct_path": direct_path,
"direct_path_len": direct_path_len,
"direct_path_hash_mode": direct_path_hash_mode,
"last_advert": last_advert,
"lat": None,
"lon": None,
@@ -597,9 +597,9 @@ class TestSyncAndOffloadAll:
KEY_A,
"Alice",
last_contacted=2000,
last_path="aa00bb00",
last_path_len=2,
out_path_hash_mode=1,
direct_path="aa00bb00",
direct_path_len=2,
direct_path_hash_mode=1,
)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
@@ -626,9 +626,9 @@ class TestSyncAndOffloadAll:
KEY_A,
"Alice",
last_contacted=2000,
last_path="3f3f69de1c7b7e7662",
last_path_len=-125,
out_path_hash_mode=2,
direct_path="3f3f69de1c7b7e7662",
direct_path_len=-125,
direct_path_hash_mode=2,
)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])

View File

@@ -58,8 +58,9 @@ async def _insert_contact(public_key: str, name: str = "Node", contact_type: int
"name": name,
"type": contact_type,
"flags": 0,
"last_path": None,
"last_path_len": -1,
"direct_path": None,
"direct_path_len": -1,
"direct_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,

View File

@@ -750,7 +750,7 @@ class TestContactRepositoryUpsertContracts:
name="Bob",
type=2,
on_radio=True,
out_path_hash_mode=-1,
direct_path_hash_mode=-1,
)
)

View File

@@ -83,8 +83,9 @@ async def _insert_contact(public_key, name="Alice", **overrides):
"name": name,
"type": 0,
"flags": 0,
"last_path": None,
"last_path_len": -1,
"direct_path": None,
"direct_path_len": -1,
"direct_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,
@@ -152,16 +153,16 @@ class TestOutgoingDMBroadcast:
assert "ambiguous" in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_send_dm_preserves_stored_out_path_hash_mode(self, test_db):
async def test_send_dm_preserves_stored_direct_path_hash_mode(self, test_db):
"""Direct-message send pushes the persisted path hash mode back to the radio."""
mc = _make_mc()
pub_key = "cd" * 32
await _insert_contact(
pub_key,
"Alice",
last_path="aa00bb00",
last_path_len=2,
out_path_hash_mode=1,
direct_path="aa00bb00",
direct_path_len=2,
direct_path_hash_mode=1,
)
with (
@@ -185,9 +186,9 @@ class TestOutgoingDMBroadcast:
await _insert_contact(
pub_key,
"Alice",
last_path="aabb",
last_path_len=1,
out_path_hash_mode=0,
direct_path="aabb",
direct_path_len=1,
direct_path_hash_mode=0,
route_override_path="cc00dd00",
route_override_len=2,
route_override_hash_mode=1,