diff --git a/AGENTS.md b/AGENTS.md
index 72812dc..0fa0dbd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -138,8 +138,10 @@ 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_*`.
+- 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 +161,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.
diff --git a/app/AGENTS.md b/app/AGENTS.md
index eb741c5..9cef9b2 100644
--- a/app/AGENTS.md
+++ b/app/AGENTS.md
@@ -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)
diff --git a/app/database.py b/app/database.py
index dbc7719..e4cb912 100644
--- a/app/database.py
+++ b/app/database.py
@@ -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 (
diff --git a/app/decoder.py b/app/decoder.py
index e0352ae..c34f5d4 100644
--- a/app/decoder.py
+++ b/app/decoder.py
@@ -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)
diff --git a/app/event_handlers.py b/app/event_handlers.py
index dafa9f5..269de32 100644
--- a/app/event_handlers.py
+++ b/app/event_handlers.py
@@ -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)
diff --git a/app/migrations.py b/app/migrations.py
index ef81561..75eb816 100644
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -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,140 @@ 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"
+ elif "last_path" in columns:
+ select_expr["direct_path"] = "last_path"
+
+ if "direct_path_len" in columns:
+ select_expr["direct_path_len"] = "direct_path_len"
+ elif "last_path_len" in columns:
+ select_expr["direct_path_len"] = "last_path_len"
+
+ if "direct_path_hash_mode" in columns:
+ select_expr["direct_path_hash_mode"] = "direct_path_hash_mode"
+ elif "out_path_hash_mode" in columns:
+ select_expr["direct_path_hash_mode"] = "out_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()
diff --git a/app/models.py b/app/models.py
index 98595f9..65460a9 100644
--- a/app/models.py
+++ b/app/models.py
@@ -1,8 +1,19 @@
-from typing import Literal
+from collections.abc import Mapping
+from typing import Any, Literal
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, model_validator
-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 +23,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
@@ -26,6 +38,20 @@ class ContactUpsert(BaseModel):
last_contacted: int | None = None
first_seen: int | None = None
+ @model_validator(mode="before")
+ @classmethod
+ def _translate_legacy_route_fields(cls, data: Any) -> Any:
+ if not isinstance(data, Mapping):
+ return data
+ translated = dict(data)
+ if "direct_path" not in translated and "last_path" in translated:
+ translated["direct_path"] = translated.get("last_path")
+ if "direct_path_len" not in translated and "last_path_len" in translated:
+ translated["direct_path_len"] = translated.get("last_path_len")
+ if "direct_path_hash_mode" not in translated and "out_path_hash_mode" in translated:
+ translated["direct_path_hash_mode"] = translated.get("out_path_hash_mode")
+ return translated
+
@classmethod
def from_contact(cls, contact: "Contact", **changes) -> "ContactUpsert":
return cls.model_validate(
@@ -40,7 +66,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 +79,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 +94,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 +109,125 @@ 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
+
+ @model_validator(mode="before")
+ @classmethod
+ def _translate_legacy_route_fields(cls, data: Any) -> Any:
+ if not isinstance(data, Mapping):
+ return data
+ translated = dict(data)
+ if "direct_path" not in translated and "last_path" in translated:
+ translated["direct_path"] = translated.get("last_path")
+ if "direct_path_len" not in translated and "last_path_len" in translated:
+ translated["direct_path_len"] = translated.get("last_path_len")
+ if "direct_path_hash_mode" not in translated and "out_path_hash_mode" in translated:
+ translated["direct_path_hash_mode"] = translated.get("out_path_hash_mode")
+ return translated
+
+ 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]:
+ @property
+ def last_path(self) -> str | None:
+ return self.direct_path
+
+ @property
+ def last_path_len(self) -> int:
+ return self.direct_path_len
+
+ @property
+ def out_path_hash_mode(self) -> int:
+ return self.direct_path_hash_mode
+
+ 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 +263,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"
)
diff --git a/app/packet_processor.py b/app/packet_processor.py
index 2a39850..12ac466 100644
--- a/app/packet_processor.py
+++ b/app/packet_processor.py
@@ -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)
+ )
diff --git a/app/path_utils.py b/app/path_utils.py
index 0234311..6a33faa 100644
--- a/app/path_utils.py
+++ b/app/path_utils.py
@@ -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
diff --git a/app/radio_sync.py b/app/radio_sync.py
index 9d82499..9ad6c1f 100644
--- a/app/radio_sync.py
+++ b/app/radio_sync.py
@@ -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,
diff --git a/app/repository/contacts.py b/app/repository/contacts.py
index 1f40e84..ed72a98 100644
--- a/app/repository/contacts.py
+++ b/app/repository/contacts.py
@@ -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,42 +306,63 @@ 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(),
),
)
await db.conn.commit()
+ @staticmethod
+ async def update_path(
+ public_key: str,
+ path: str,
+ path_len: int,
+ path_hash_mode: int | None = None,
+ updated_at: int | None = None,
+ ) -> None:
+ """Compatibility shim for legacy callers/tests."""
+ await ContactRepository.update_direct_path(
+ public_key,
+ path,
+ path_len,
+ path_hash_mode,
+ updated_at=updated_at,
+ )
+
@staticmethod
async def set_routing_override(
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(
"""
diff --git a/app/routers/contacts.py b/app/routers/contacts.py
index 837ebc1..a4289df 100644
--- a/app/routers/contacts.py
+++ b/app/routers/contacts.py
@@ -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":
diff --git a/app/services/dm_ack_apply.py b/app/services/dm_ack_apply.py
new file mode 100644
index 0000000..66368dc
--- /dev/null
+++ b/app/services/dm_ack_apply.py
@@ -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
diff --git a/app/services/dm_ingest.py b/app/services/dm_ingest.py
index 240f748..c1bf42e 100644
--- a/app/services/dm_ingest.py
+++ b/app/services/dm_ingest.py
@@ -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)
diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx
index 6e8a496..db327f5 100644
--- a/frontend/src/components/ContactInfoPane.tsx
+++ b/frontend/src/components/ContactInfoPane.tsx
@@ -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 && (
)}
{pathHashModeLabel && }
diff --git a/frontend/src/components/ContactPathDiscoveryModal.tsx b/frontend/src/components/ContactPathDiscoveryModal.tsx
index 06f8001..f2fa70a 100644
--- a/frontend/src/components/ContactPathDiscoveryModal.tsx
+++ b/frontend/src/components/ContactPathDiscoveryModal.tsx
@@ -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(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;
diff --git a/frontend/src/components/ContactRoutingOverrideModal.tsx b/frontend/src/components/ContactRoutingOverrideModal.tsx
index c728311..fd1badc 100644
--- a/frontend/src/components/ContactRoutingOverrideModal.tsx
+++ b/frontend/src/components/ContactRoutingOverrideModal.tsx
@@ -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 {
diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx
index 092547c..b18b41f 100644
--- a/frontend/src/components/MessageList.tsx
+++ b/frontend/src/components/MessageList.tsx
@@ -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,
};
}
}
diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx
index fa23a4b..8b9f7cd 100644
--- a/frontend/src/test/appStartupHash.test.tsx
+++ b/frontend/src/test/appStartupHash.test.tsx
@@ -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,
diff --git a/frontend/src/test/chatHeaderKeyVisibility.test.tsx b/frontend/src/test/chatHeaderKeyVisibility.test.tsx
index e527fb0..29be711 100644
--- a/frontend/src/test/chatHeaderKeyVisibility.test.tsx
+++ b/frontend/src/test/chatHeaderKeyVisibility.test.tsx
@@ -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,
diff --git a/frontend/src/test/contactInfoPane.test.tsx b/frontend/src/test/contactInfoPane.test.tsx
index 8fefb03..615daa9 100644
--- a/frontend/src/test/contactInfoPane.test.tsx
+++ b/frontend/src/test/contactInfoPane.test.tsx
@@ -39,9 +39,9 @@ function createContact(overrides: Partial = {}): 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();
@@ -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();
@@ -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,
diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx
index ddc3b43..09222da 100644
--- a/frontend/src/test/conversationPane.test.tsx
+++ b/frontend/src/test/conversationPane.test.tsx
@@ -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,
diff --git a/frontend/src/test/integration.test.ts b/frontend/src/test/integration.test.ts
index 00eb948..e29796b 100644
--- a/frontend/src/test/integration.test.ts
+++ b/frontend/src/test/integration.test.ts
@@ -274,8 +274,8 @@ function makeContact(overrides: Partial = {}): 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 {
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,
};
}
diff --git a/frontend/src/test/mapView.test.tsx b/frontend/src/test/mapView.test.tsx
index effd766..26d5f6f 100644
--- a/frontend/src/test/mapView.test.tsx
+++ b/frontend/src/test/mapView.test.tsx
@@ -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,
diff --git a/frontend/src/test/newMessageModal.test.tsx b/frontend/src/test/newMessageModal.test.tsx
index 9984710..ad242cf 100644
--- a/frontend/src/test/newMessageModal.test.tsx
+++ b/frontend/src/test/newMessageModal.test.tsx
@@ -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,
diff --git a/frontend/src/test/packetNetworkGraph.test.ts b/frontend/src/test/packetNetworkGraph.test.ts
index cb6825d..4b9206a 100644
--- a/frontend/src/test/packetNetworkGraph.test.ts
+++ b/frontend/src/test/packetNetworkGraph.test.ts
@@ -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,
diff --git a/frontend/src/test/pathUtils.test.ts b/frontend/src/test/pathUtils.test.ts
index 79dfdc4..9113692 100644
--- a/frontend/src/test/pathUtils.test.ts
+++ b/frontend/src/test/pathUtils.test.ts
@@ -22,8 +22,9 @@ function createContact(overrides: Partial = {}): 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 {
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,
diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx
index 46bbd92..1d3e21d 100644
--- a/frontend/src/test/repeaterDashboard.test.tsx
+++ b/frontend/src/test/repeaterDashboard.test.tsx
@@ -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();
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();
@@ -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();
@@ -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();
@@ -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();
@@ -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');
diff --git a/frontend/src/test/searchView.test.tsx b/frontend/src/test/searchView.test.tsx
index 540dede..b4e62d4 100644
--- a/frontend/src/test/searchView.test.tsx
+++ b/frontend/src/test/searchView.test.tsx
@@ -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,
diff --git a/frontend/src/test/sidebar.test.tsx b/frontend/src/test/sidebar.test.tsx
index 5a1fa1f..e70f63b 100644
--- a/frontend/src/test/sidebar.test.tsx
+++ b/frontend/src/test/sidebar.test.tsx
@@ -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,
diff --git a/frontend/src/test/urlHash.test.ts b/frontend/src/test/urlHash.test.ts
index 54f1a71..8e3cd1f 100644
--- a/frontend/src/test/urlHash.test.ts
+++ b/frontend/src/test/urlHash.test.ts
@@ -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,
diff --git a/frontend/src/test/useContactsAndChannels.test.ts b/frontend/src/test/useContactsAndChannels.test.ts
index d12c1ac..8f4d0d8 100644
--- a/frontend/src/test/useContactsAndChannels.test.ts
+++ b/frontend/src/test/useContactsAndChannels.test.ts
@@ -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,
diff --git a/frontend/src/test/useConversationActions.test.ts b/frontend/src/test/useConversationActions.test.ts
index 1734580..3da22ce 100644
--- a/frontend/src/test/useConversationActions.test.ts
+++ b/frontend/src/test/useConversationActions.test.ts
@@ -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,
diff --git a/frontend/src/test/useRealtimeAppState.test.ts b/frontend/src/test/useRealtimeAppState.test.ts
index c184d98..877fa89 100644
--- a/frontend/src/test/useRealtimeAppState.test.ts
+++ b/frontend/src/test/useRealtimeAppState.test.ts
@@ -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,
diff --git a/frontend/src/test/useUnreadCounts.test.ts b/frontend/src/test/useUnreadCounts.test.ts
index a7151fd..82bfb87 100644
--- a/frontend/src/test/useUnreadCounts.test.ts
+++ b/frontend/src/test/useUnreadCounts.test.ts
@@ -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,
diff --git a/frontend/src/test/useVisualizerData3D.test.ts b/frontend/src/test/useVisualizerData3D.test.ts
index 2f5d94c..74adda0 100644
--- a/frontend/src/test/useVisualizerData3D.test.ts
+++ b/frontend/src/test/useVisualizerData3D.test.ts
@@ -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,
diff --git a/frontend/src/test/useWebSocket.dispatch.test.ts b/frontend/src/test/useWebSocket.dispatch.test.ts
index 99d3b8c..eb83f49 100644
--- a/frontend/src/test/useWebSocket.dispatch.test.ts
+++ b/frontend/src/test/useWebSocket.dispatch.test.ts
@@ -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,
diff --git a/frontend/src/test/wsEvents.test.ts b/frontend/src/test/wsEvents.test.ts
index 8faef36..20ec2f1 100644
--- a/frontend/src/test/wsEvents.test.ts
+++ b/frontend/src/test/wsEvents.test.ts
@@ -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,
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 2445d52..4e549a5 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -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;
diff --git a/frontend/src/utils/pathUtils.ts b/frontend/src/utils/pathUtils.ts
index e041252..c98577b 100644
--- a/frontend/src/utils/pathUtils.ts
+++ b/frontend/src/utils/pathUtils.ts
@@ -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(',');
}
diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts
index f5319a6..a00cb88 100644
--- a/tests/e2e/helpers/api.ts
+++ b/tests/e2e/helpers/api.ts
@@ -90,9 +90,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;
diff --git a/tests/test_contacts_router.py b/tests/test_contacts_router.py
index 3eb77d2..e5d9ac6 100644
--- a/tests/test_contacts_router.py
+++ b/tests/test_contacts_router.py
@@ -588,12 +588,13 @@ class TestRoutingOverride:
assert contact.last_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 +614,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):
diff --git a/tests/test_decoder.py b/tests/test_decoder.py
index b7cca75..40fcb23 100644
--- a/tests/test_decoder.py
+++ b/tests/test_decoder.py
@@ -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."""
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index 88b1b30..d320c6c 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -1247,23 +1247,25 @@ 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"] == ""
+ assert rows[0]["direct_path_len"] == -1
+ assert rows[0]["direct_path_hash_mode"] == -1
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"] == "1122"
+ assert rows[1]["direct_path_len"] == 1
+ assert rows[1]["direct_path_hash_mode"] == 0
finally:
await conn.close()
@@ -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_hash_mode
FROM contacts
WHERE public_key IN (?, ?)
ORDER BY public_key
@@ -1331,9 +1333,9 @@ 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_hash_mode"] == 1
assert rows[1]["public_key"] == "dd" * 32
- assert rows[1]["out_path_hash_mode"] == -1
+ assert rows[1]["direct_path_hash_mode"] == -1
finally:
await conn.close()
@@ -1371,8 +1373,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 +1435,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 +1488,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(
"""
diff --git a/tests/test_packet_pipeline.py b/tests/test_packet_pipeline.py
index 4e9360f..d23ee2b 100644
--- a/tests/test_packet_pipeline.py
+++ b/tests/test_packet_pipeline.py
@@ -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.last_path_len == -1
+ 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 == 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.last_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.last_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):
diff --git a/tests/test_path_utils.py b/tests/test_path_utils.py
index b127c44..d57cd43 100644
--- a/tests/test_path_utils.py
+++ b/tests/test_path_utils.py
@@ -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
diff --git a/tests/test_radio_sync.py b/tests/test_radio_sync.py
index 8e34a46..3f10009 100644
--- a/tests/test_radio_sync.py
+++ b/tests/test_radio_sync.py
@@ -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)])
diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py
index dbb5500..41a8737 100644
--- a/tests/test_send_messages.py
+++ b/tests/test_send_messages.py
@@ -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,