22 Commits

Author SHA1 Message Date
Jack Kingsman
cee7103ec6 Don't log on missed login ack and don't make standalone contacts for repeater users 2026-03-19 20:26:10 -07:00
Jack Kingsman
d05312c157 Add password-remember + warning on save 2026-03-19 20:10:59 -07:00
Jack Kingsman
5b166c4b66 Add room server 2026-03-19 19:22:40 -07:00
Jack Kingsman
dbe2915635 Use metric by default 2026-03-19 17:56:04 -07:00
Jack Kingsman
2337d7b592 Remove Apprise duplicate names. Closes #88. 2026-03-19 17:44:51 -07:00
Jack Kingsman
62080424bb Multi-ack. Closes #81. 2026-03-19 17:30:34 -07:00
Jack Kingsman
1ae76848fe Improve test coverage 2026-03-19 17:19:35 -07:00
Jack Kingsman
45ed430580 Allow favorites to be sorted. Closes #91. 2026-03-19 17:05:34 -07:00
Jack Kingsman
5f8ce16855 Fix spacing around byte display on packet detail. Closes #93. 2026-03-19 16:58:27 -07:00
Jack Kingsman
b79249c4a0 Add more realistic hop stats display 2026-03-19 16:49:06 -07:00
Jack Kingsman
85d1a940dc Update meshcore for three byte path failures 2026-03-19 09:57:06 -07:00
Jack Kingsman
b85d451e26 Add packet feed clickable packet inspection. Closes #75 again. 2026-03-19 09:49:14 -07:00
Jack Kingsman
41a297c944 GIVE ME SMOOTS. Closes #87. 2026-03-18 22:43:34 -07:00
Jack Kingsman
41d64d86d4 Expand docker version testing coverage. Closes #84. 2026-03-18 22:09:44 -07:00
Jack Kingsman
bd336e3ee2 Add fancy metrics view for packet feed. Closes #75. 2026-03-18 22:01:10 -07:00
Jack Kingsman
cf585cdf87 Unread DMs are always red. Closes #86. 2026-03-18 21:05:40 -07:00
Jack Kingsman
417a583696 Use proper version formatting. Closes #70. 2026-03-18 20:50:56 -07:00
Jack Kingsman
541dba6a75 Fix migration to not import historical advert path 2026-03-19 03:45:51 +00:00
Jack Kingsman
720b8be64f Add e2e test 2026-03-19 03:45:51 +00:00
Jack Kingsman
2b5083e889 Doc updates 2026-03-19 03:45:51 +00:00
Jack Kingsman
5975006cf7 Dupe code cleanup 2026-03-19 03:45:51 +00:00
Jack Kingsman
69e09378f5 Pass 1 on PATH integration 2026-03-19 03:45:51 +00:00
117 changed files with 7393 additions and 1218 deletions

View File

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

View File

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

View File

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

View File

@@ -58,6 +58,28 @@ class DecryptedDirectMessage:
message: str
dest_hash: str # First byte of destination pubkey as hex
src_hash: str # First byte of sender pubkey as hex
signed_sender_prefix: str | None = None
@property
def txt_type(self) -> int:
return self.flags >> 2
@property
def attempt(self) -> int:
return self.flags & 0x03
@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
@@ -485,6 +507,13 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
# Extract message text (UTF-8, null-padded)
message_bytes = decrypted[5:]
signed_sender_prefix: str | None = None
txt_type = flags >> 2
if txt_type == 2:
if len(message_bytes) < 4:
return None
signed_sender_prefix = message_bytes[:4].hex()
message_bytes = message_bytes[4:]
try:
message_text = message_bytes.decode("utf-8")
# Truncate at first null terminator (consistent with channel message handling)
@@ -500,6 +529,7 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
message=message_text,
dest_hash=dest_hash,
src_hash=src_hash,
signed_sender_prefix=signed_sender_prefix,
)
@@ -563,3 +593,88 @@ def try_decrypt_dm(
return None
return decrypt_direct_message(packet_info.payload, shared_secret)
def decrypt_path_payload(payload: bytes, shared_secret: bytes) -> DecryptedPathPayload | None:
"""Decrypt a PATH payload using the ECDH shared secret."""
if len(payload) < 4:
return None
dest_hash = format(payload[0], "02x")
src_hash = format(payload[1], "02x")
mac = payload[2:4]
ciphertext = payload[4:]
if len(ciphertext) == 0 or len(ciphertext) % 16 != 0:
return None
calculated_mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
if calculated_mac != mac:
return None
try:
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
decrypted = cipher.decrypt(ciphertext)
except Exception as e:
logger.debug("AES decryption failed for PATH payload: %s", e)
return None
if len(decrypted) < 2:
return None
from app.path_utils import decode_path_byte
packed_len = decrypted[0]
try:
returned_path_len, hash_size = decode_path_byte(packed_len)
except ValueError:
return None
path_byte_len = returned_path_len * hash_size
if len(decrypted) < 1 + path_byte_len + 1:
return None
offset = 1
returned_path = decrypted[offset : offset + path_byte_len]
offset += path_byte_len
extra_type = decrypted[offset] & 0x0F
offset += 1
extra = decrypted[offset:]
return DecryptedPathPayload(
dest_hash=dest_hash,
src_hash=src_hash,
returned_path=returned_path,
returned_path_len=returned_path_len,
returned_path_hash_mode=hash_size - 1,
extra_type=extra_type,
extra=extra,
)
def try_decrypt_path(
raw_packet: bytes,
our_private_key: bytes,
their_public_key: bytes,
our_public_key: bytes,
) -> DecryptedPathPayload | None:
"""Try to decrypt a raw packet as a PATH packet."""
packet_info = parse_packet(raw_packet)
if packet_info is None or packet_info.payload_type != PayloadType.PATH:
return None
if len(packet_info.payload) < 4:
return None
dest_hash = packet_info.payload[0]
src_hash = packet_info.payload[1]
if dest_hash != our_public_key[0] or src_hash != their_public_key[0]:
return None
try:
shared_secret = derive_shared_secret(our_private_key, their_public_key)
except Exception as e:
logger.debug("Failed to derive shared secret for PATH payload: %s", e)
return None
return decrypt_path_payload(packet_info.payload, shared_secret)

View File

@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
from meshcore import EventType
from app.models import Contact, ContactUpsert
from app.models import CONTACT_TYPE_ROOM, Contact, ContactUpsert
from app.packet_processor import process_raw_packet
from app.repository import (
ContactRepository,
@@ -14,11 +14,12 @@ 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_direct_message_sender_metadata,
resolve_fallback_direct_message_context,
)
from app.services.messages import increment_ack_and_broadcast
from app.websocket import broadcast_event
if TYPE_CHECKING:
@@ -87,6 +88,23 @@ async def on_contact_message(event: "Event") -> None:
sender_timestamp = ts if ts is not None else received_at
path = payload.get("path")
path_len = payload.get("path_len")
sender_name = context.sender_name
sender_key = context.sender_key
signature = payload.get("signature")
if (
context.contact is not None
and context.contact.type == CONTACT_TYPE_ROOM
and txt_type == 2
and isinstance(signature, str)
and signature
):
sender_name, sender_key = await resolve_direct_message_sender_metadata(
sender_public_key=signature,
received_at=received_at,
broadcast_fn=broadcast_event,
contact_repository=ContactRepository,
log=logger,
)
message = await ingest_fallback_direct_message(
conversation_key=context.conversation_key,
text=payload.get("text", ""),
@@ -95,9 +113,9 @@ async def on_contact_message(event: "Event") -> None:
path=path,
path_len=path_len,
txt_type=txt_type,
signature=payload.get("signature"),
sender_name=context.sender_name,
sender_key=context.sender_key,
signature=signature,
sender_name=sender_name,
sender_key=sender_key,
broadcast_fn=broadcast_event,
update_last_contacted_key=context.contact.public_key.lower() if context.contact else None,
)
@@ -197,11 +215,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 +287,10 @@ async def on_ack(event: "Event") -> None:
return
logger.debug("Received ACK with code %s", ack_code)
cleanup_expired_acks()
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
if message_id is not None:
dm_ack_tracker.clear_pending_acks_for_message(message_id)
logger.info("ACK received for message %d", message_id)
# DM ACKs don't carry path data, so paths is intentionally omitted.
# The frontend's mergePendingAck handles the missing field correctly,
# preserving any previously known paths.
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_event)
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
if matched:
logger.info("ACK received for code %s", ack_code)
else:
dm_ack_tracker.buffer_unmatched_ack(ack_code)
logger.debug("ACK code %s does not match any pending messages", ack_code)

View File

@@ -65,6 +65,7 @@ Wraps bot code execution via `app/fanout/bot_exec.py`. Config blob:
- `code` — Python bot function source code
- Executes in a thread pool with timeout and semaphore concurrency control
- Rate-limits outgoing messages for repeater compatibility
- Channel `message_text` passed to bot code is normalized for human readability by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender.
### webhook (webhook.py)
HTTP webhook delivery. Config blob:
@@ -78,6 +79,7 @@ Push notifications via Apprise library. Config blob:
- `urls` — newline-separated Apprise notification service URLs
- `preserve_identity` — suppress Discord webhook name/avatar override
- `include_path` — include routing path in notification body
- Channel notifications normalize stored message text by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender so alerts do not duplicate the name.
### sqs (sqs.py)
Amazon SQS delivery. Config blob:

View File

@@ -6,7 +6,7 @@ import asyncio
import logging
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from app.fanout.base import FanoutModule
from app.fanout.base import FanoutModule, get_fanout_message_text
from app.path_utils import split_path_hex
logger = logging.getLogger(__name__)
@@ -39,7 +39,7 @@ def _normalize_discord_url(url: str) -> str:
def _format_body(data: dict, *, include_path: bool) -> str:
"""Build a human-readable notification body from message data."""
msg_type = data.get("type", "")
text = data.get("text", "")
text = get_fanout_message_text(data)
sender_name = data.get("sender_name") or "Unknown"
via = ""

View File

@@ -33,3 +33,30 @@ class FanoutModule:
def status(self) -> str:
"""Return 'connected', 'disconnected', or 'error'."""
raise NotImplementedError
def get_fanout_message_text(data: dict) -> str:
"""Return the best human-readable message body for fanout consumers.
Channel messages are stored with the rendered sender label embedded in the
text (for example ``"Alice: hello"``). Human-facing integrations that also
carry ``sender_name`` should strip that duplicated prefix when it matches
the payload sender exactly.
"""
text = data.get("text", "")
if not isinstance(text, str):
return ""
if data.get("type") != "CHAN":
return text
sender_name = data.get("sender_name")
if not isinstance(sender_name, str) or not sender_name:
return text
prefix, separator, remainder = text.partition(": ")
if separator and prefix == sender_name:
return remainder
return text

View File

@@ -115,7 +115,7 @@ def _generate_jwt_token(
"exp": now + _TOKEN_LIFETIME,
"aud": audience,
"owner": pubkey_hex,
"client": _CLIENT_ID,
"client": _get_client_version(),
}
if email:
payload["email"] = email
@@ -260,8 +260,10 @@ def _build_radio_info() -> str:
def _get_client_version() -> str:
"""Return the app version string for community MQTT payloads."""
return get_app_build_info().version
"""Return the canonical client/version identifier for community MQTT."""
build = get_app_build_info()
commit_hash = build.commit_hash or "unknown"
return f"{_CLIENT_ID}/{build.version}-{commit_hash}"
class CommunityMqttPublisher(BaseMqttPublisher):

View File

@@ -32,6 +32,7 @@ from app.routers import (
radio,
read_state,
repeaters,
rooms,
settings,
statistics,
ws,
@@ -134,6 +135,7 @@ app.include_router(fanout.router, prefix="/api")
app.include_router(radio.router, prefix="/api")
app.include_router(contacts.router, prefix="/api")
app.include_router(repeaters.router, prefix="/api")
app.include_router(rooms.router, prefix="/api")
app.include_router(channels.router, prefix="/api")
app.include_router(messages.router, prefix="/api")
app.include_router(packets.router, prefix="/api")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -78,6 +78,7 @@ class DebugChannelAudit(BaseModel):
class DebugRadioProbe(BaseModel):
performed: bool
errors: list[str] = Field(default_factory=list)
multi_acks_enabled: bool | None = None
self_info: dict[str, Any] | None = None
device_info: dict[str, Any] | None = None
stats_core: dict[str, Any] | None = None
@@ -234,6 +235,9 @@ async def _probe_radio() -> DebugRadioProbe:
return DebugRadioProbe(
performed=True,
errors=errors,
multi_acks_enabled=bool(mc.self_info.get("multi_acks", 0))
if mc.self_info is not None
else None,
self_info=dict(mc.self_info or {}),
device_info=device_info,
stats_core=stats_core,

View File

@@ -81,6 +81,10 @@ class RadioConfigResponse(BaseModel):
default="current",
description="Whether adverts include the node's current location state",
)
multi_acks_enabled: bool = Field(
default=False,
description="Whether the radio sends an extra direct ACK transmission",
)
class RadioConfigUpdate(BaseModel):
@@ -99,6 +103,10 @@ class RadioConfigUpdate(BaseModel):
default=None,
description="Whether adverts include the node's current location state",
)
multi_acks_enabled: bool | None = Field(
default=None,
description="Whether the radio sends an extra direct ACK transmission",
)
class PrivateKeyUpdate(BaseModel):
@@ -222,6 +230,7 @@ async def get_radio_config() -> RadioConfigResponse:
path_hash_mode=radio_manager.path_hash_mode,
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
advert_location_source=advert_location_source,
multi_acks_enabled=bool(info.get("multi_acks", 0)),
)

View File

@@ -1,6 +1,5 @@
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from fastapi import APIRouter, HTTPException
@@ -28,6 +27,14 @@ from app.models import (
)
from app.repository import ContactRepository
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
from app.routers.server_control import (
_monotonic,
batch_cli_fetch,
extract_response_text,
prepare_authenticated_contact_connection,
require_server_capable_contact,
send_contact_cli_command,
)
from app.services.radio_runtime import radio_runtime as radio_manager
if TYPE_CHECKING:
@@ -43,39 +50,11 @@ ACL_PERMISSION_NAMES = {
3: "Admin",
}
router = APIRouter(prefix="/contacts", tags=["repeaters"])
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
REPEATER_LOGIN_REJECTED_MESSAGE = (
"The repeater replied but did not confirm this login. "
"Existing access may still allow some repeater operations, but admin actions may fail."
)
REPEATER_LOGIN_SEND_FAILED_MESSAGE = (
"The login request could not be sent to the repeater. "
"The dashboard is still available, but repeater operations may fail until a login succeeds."
)
REPEATER_LOGIN_TIMEOUT_MESSAGE = (
"No login confirmation was heard from the repeater. "
"On current repeater firmware, that can mean the password was wrong, "
"blank-password login was not allowed by the ACL, or the reply was missed in transit. "
"The dashboard is still available; try logging in again if admin actions fail."
)
def _monotonic() -> float:
"""Wrapper around time.monotonic() for testability.
Patching time.monotonic directly breaks the asyncio event loop which also
uses it. This indirection allows tests to control the clock safely.
"""
return time.monotonic()
def _extract_response_text(event) -> str:
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
text = event.payload.get("text", str(event.payload))
if text.startswith("> "):
text = text[2:]
return text
return extract_response_text(event)
async def _fetch_repeater_response(
@@ -83,21 +62,6 @@ async def _fetch_repeater_response(
target_pubkey_prefix: str,
timeout: float = 20.0,
) -> "Event | None":
"""Fetch a CLI response from a specific repeater via a validated get_msg() loop.
Calls get_msg() repeatedly until a matching CLI response (txt_type=1) from the
target repeater arrives or the wall-clock deadline expires. Unrelated messages
are safe to skip — meshcore's event dispatcher already delivers them to the
normal subscription handlers (on_contact_message, etc.) when get_msg() returns.
Args:
mc: MeshCore instance
target_pubkey_prefix: 12-char hex prefix of the repeater's public key
timeout: Wall-clock seconds before giving up
Returns:
The matching Event, or None if no response arrived before the deadline.
"""
deadline = _monotonic() + timeout
while _monotonic() < deadline:
@@ -105,13 +69,12 @@ async def _fetch_repeater_response(
result = await mc.commands.get_msg(timeout=2.0)
except asyncio.TimeoutError:
continue
except Exception as e:
logger.debug("get_msg() exception: %s", e)
except Exception as exc:
logger.debug("get_msg() exception: %s", exc)
await asyncio.sleep(1.0)
continue
if result.type == EventType.NO_MORE_MSGS:
# No messages queued yet — wait and retry
await asyncio.sleep(1.0)
continue
@@ -125,8 +88,6 @@ async def _fetch_repeater_response(
txt_type = result.payload.get("txt_type", 0)
if msg_prefix == target_pubkey_prefix and txt_type == 1:
return result
# Not our target — already dispatched to subscribers by meshcore,
# so just continue draining the queue.
logger.debug(
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
msg_prefix,
@@ -136,7 +97,6 @@ async def _fetch_repeater_response(
continue
if result.type == EventType.CHANNEL_MSG_RECV:
# Already dispatched to subscribers by meshcore; skip.
logger.debug(
"Skipping channel message (channel_idx=%s) during repeater fetch",
result.payload.get("channel_idx"),
@@ -150,87 +110,13 @@ async def _fetch_repeater_response(
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
"""Prepare connection to a repeater by adding to radio and attempting login.
Args:
mc: MeshCore instance
contact: The repeater contact
password: Password for login (empty string for no password)
"""
pubkey_prefix = contact.public_key[:12].lower()
loop = asyncio.get_running_loop()
login_future = loop.create_future()
def _resolve_login(event_type: EventType, message: str | None = None) -> None:
if login_future.done():
return
login_future.set_result(
RepeaterLoginResponse(
status="ok" if event_type == EventType.LOGIN_SUCCESS else "error",
authenticated=event_type == EventType.LOGIN_SUCCESS,
message=message,
)
)
success_subscription = mc.subscribe(
EventType.LOGIN_SUCCESS,
lambda _event: _resolve_login(EventType.LOGIN_SUCCESS),
attribute_filters={"pubkey_prefix": pubkey_prefix},
return await prepare_authenticated_contact_connection(
mc,
contact,
password,
label="repeater",
response_timeout=REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
)
failed_subscription = mc.subscribe(
EventType.LOGIN_FAILED,
lambda _event: _resolve_login(
EventType.LOGIN_FAILED,
REPEATER_LOGIN_REJECTED_MESSAGE,
),
attribute_filters={"pubkey_prefix": pubkey_prefix},
)
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
try:
logger.info("Adding repeater %s to radio", contact.public_key[:12])
await _ensure_on_radio(mc, contact)
logger.info("Sending login to repeater %s", contact.public_key[:12])
login_result = await mc.commands.send_login(contact.public_key, password)
if login_result.type == EventType.ERROR:
return RepeaterLoginResponse(
status="error",
authenticated=False,
message=f"{REPEATER_LOGIN_SEND_FAILED_MESSAGE} ({login_result.payload})",
)
try:
return await asyncio.wait_for(
login_future,
timeout=REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.warning(
"No login response from repeater %s within %.1fs",
contact.public_key[:12],
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
)
return RepeaterLoginResponse(
status="timeout",
authenticated=False,
message=REPEATER_LOGIN_TIMEOUT_MESSAGE,
)
except HTTPException as exc:
logger.warning(
"Repeater login setup failed for %s: %s",
contact.public_key[:12],
exc.detail,
)
return RepeaterLoginResponse(
status="error",
authenticated=False,
message=f"{REPEATER_LOGIN_SEND_FAILED_MESSAGE} ({exc.detail})",
)
finally:
success_subscription.unsubscribe()
failed_subscription.unsubscribe()
def _require_repeater(contact: Contact) -> None:
@@ -403,43 +289,7 @@ async def _batch_cli_fetch(
operation_name: str,
commands: list[tuple[str, str]],
) -> dict[str, str | None]:
"""Send a batch of CLI commands to a repeater and collect responses.
Opens a radio operation with polling paused and auto-fetch suspended (since
we call get_msg() directly via _fetch_repeater_response), adds the contact
to the radio for routing, then sends each command sequentially with a 1-second
gap between them.
Returns a dict mapping field names to response strings (or None on timeout).
"""
results: dict[str, str | None] = {field: None for _, field in commands}
async with radio_manager.radio_operation(
operation_name,
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0)
for i, (cmd, field) in enumerate(commands):
if i > 0:
await asyncio.sleep(1.0)
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
if send_result.type == EventType.ERROR:
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
continue
response_event = await _fetch_repeater_response(
mc, contact.public_key[:12], timeout=10.0
)
if response_event is not None:
results[field] = _extract_response_text(response_event)
else:
logger.warning("No response for command '%s' (%s)", cmd, field)
return results
return await batch_cli_fetch(contact, operation_name, commands)
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
@@ -524,72 +374,13 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
@router.post("/{public_key}/command", response_model=CommandResponse)
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
"""Send a CLI command to a repeater.
The contact must be a repeater (type=2). The user must have already logged in
via the repeater/login endpoint. This endpoint ensures the contact is on the
radio before sending commands (the repeater remembers ACL permissions after login).
Common commands:
- get name, set name <value>
- get tx, set tx <dbm>
- get radio, set radio <freq,bw,sf,cr>
- tempradio <freq,bw,sf,cr,minutes>
- setperm <pubkey> <permission> (0=guest, 1=read-only, 2=read-write, 3=admin)
- clock, clock sync, time <epoch_seconds>
- reboot
- ver
"""
"""Send a CLI command to a repeater or room server."""
require_connected()
# Get contact from database
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
async with radio_manager.radio_operation(
"send_repeater_command",
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
logger.info("Adding repeater %s to radio", contact.public_key[:12])
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0)
# Send the command
logger.info("Sending command to repeater %s: %s", contact.public_key[:12], request.command)
send_result = await mc.commands.send_cmd(contact.public_key, request.command)
if send_result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to send command: {send_result.payload}"
)
# Wait for response using validated fetch loop
response_event = await _fetch_repeater_response(mc, contact.public_key[:12])
if response_event is None:
logger.warning(
"No response from repeater %s for command: %s",
contact.public_key[:12],
request.command,
)
return CommandResponse(
command=request.command,
response="(no response - command may have been processed)",
)
# CONTACT_MSG_RECV payloads use sender_timestamp in meshcore.
response_text = _extract_response_text(response_event)
sender_timestamp = response_event.payload.get(
"sender_timestamp",
response_event.payload.get("timestamp"),
)
logger.info("Received response from %s: %s", contact.public_key[:12], response_text)
return CommandResponse(
command=request.command,
response=response_text,
sender_timestamp=sender_timestamp,
)
require_server_capable_contact(contact)
return await send_contact_cli_command(
contact,
request.command,
operation_name="send_repeater_command",
)

145
app/routers/rooms.py Normal file
View File

@@ -0,0 +1,145 @@
from fastapi import APIRouter, HTTPException
from app.dependencies import require_connected
from app.models import (
CONTACT_TYPE_ROOM,
AclEntry,
LppSensor,
RepeaterAclResponse,
RepeaterLoginRequest,
RepeaterLoginResponse,
RepeaterLppTelemetryResponse,
RepeaterStatusResponse,
)
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
from app.routers.server_control import (
prepare_authenticated_contact_connection,
require_server_capable_contact,
)
from app.services.radio_runtime import radio_runtime as radio_manager
router = APIRouter(prefix="/contacts", tags=["rooms"])
def _require_room(contact) -> None:
require_server_capable_contact(contact, allowed_types=(CONTACT_TYPE_ROOM,))
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
"""Attempt room-server login and report whether auth was confirmed."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_room(contact)
async with radio_manager.radio_operation(
"room_login",
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
return await prepare_authenticated_contact_connection(
mc,
contact,
request.password,
label="room server",
)
@router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse)
async def room_status(public_key: str) -> RepeaterStatusResponse:
"""Fetch status telemetry from a room server."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_room(contact)
async with radio_manager.radio_operation(
"room_status", pause_polling=True, suspend_auto_fetch=True
) as mc:
await _ensure_on_radio(mc, contact)
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
if status is None:
raise HTTPException(status_code=504, detail="No status response from room server")
return RepeaterStatusResponse(
battery_volts=status.get("bat", 0) / 1000.0,
tx_queue_len=status.get("tx_queue_len", 0),
noise_floor_dbm=status.get("noise_floor", 0),
last_rssi_dbm=status.get("last_rssi", 0),
last_snr_db=status.get("last_snr", 0.0),
packets_received=status.get("nb_recv", 0),
packets_sent=status.get("nb_sent", 0),
airtime_seconds=status.get("airtime", 0),
rx_airtime_seconds=status.get("rx_airtime", 0),
uptime_seconds=status.get("uptime", 0),
sent_flood=status.get("sent_flood", 0),
sent_direct=status.get("sent_direct", 0),
recv_flood=status.get("recv_flood", 0),
recv_direct=status.get("recv_direct", 0),
flood_dups=status.get("flood_dups", 0),
direct_dups=status.get("direct_dups", 0),
full_events=status.get("full_evts", 0),
)
@router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
"""Fetch CayenneLPP telemetry from a room server."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_room(contact)
async with radio_manager.radio_operation(
"room_lpp_telemetry", pause_polling=True, suspend_auto_fetch=True
) as mc:
await _ensure_on_radio(mc, contact)
telemetry = await mc.commands.req_telemetry_sync(
contact.public_key, timeout=10, min_timeout=5
)
if telemetry is None:
raise HTTPException(status_code=504, detail="No telemetry response from room server")
sensors = [
LppSensor(
channel=entry.get("channel", 0),
type_name=str(entry.get("type", "unknown")),
value=entry.get("value", 0),
)
for entry in telemetry
]
return RepeaterLppTelemetryResponse(sensors=sensors)
@router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse)
async def room_acl(public_key: str) -> RepeaterAclResponse:
"""Fetch ACL entries from a room server."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_room(contact)
async with radio_manager.radio_operation(
"room_acl", pause_polling=True, suspend_auto_fetch=True
) as mc:
await _ensure_on_radio(mc, contact)
acl_data = await mc.commands.req_acl_sync(contact.public_key, timeout=10, min_timeout=5)
acl_entries = []
if acl_data and isinstance(acl_data, list):
from app.repository import ContactRepository
from app.routers.repeaters import ACL_PERMISSION_NAMES
for entry in acl_data:
pubkey_prefix = entry.get("key", "")
perm = entry.get("perm", 0)
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
acl_entries.append(
AclEntry(
pubkey_prefix=pubkey_prefix,
name=resolved_contact.name if resolved_contact else None,
permission=perm,
permission_name=ACL_PERMISSION_NAMES.get(perm, f"Unknown({perm})"),
)
)
return RepeaterAclResponse(acl=acl_entries)

View File

@@ -0,0 +1,317 @@
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from fastapi import HTTPException
from meshcore import EventType
from app.models import (
CONTACT_TYPE_REPEATER,
CONTACT_TYPE_ROOM,
CommandResponse,
Contact,
RepeaterLoginResponse,
)
from app.routers.contacts import _ensure_on_radio
from app.services.radio_runtime import radio_runtime as radio_manager
if TYPE_CHECKING:
from meshcore.events import Event
logger = logging.getLogger(__name__)
SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
def _monotonic() -> float:
"""Wrapper around time.monotonic() for testability."""
return time.monotonic()
def get_server_contact_label(contact: Contact) -> str:
"""Return a user-facing label for server-capable contacts."""
if contact.type == CONTACT_TYPE_REPEATER:
return "repeater"
if contact.type == CONTACT_TYPE_ROOM:
return "room server"
return "server"
def require_server_capable_contact(
contact: Contact,
*,
allowed_types: tuple[int, ...] = (CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM),
) -> None:
"""Raise 400 if the contact does not support server control/login features."""
if contact.type not in allowed_types:
expected = ", ".join(str(value) for value in allowed_types)
raise HTTPException(
status_code=400,
detail=f"Contact is not a supported server contact (type={contact.type}, expected one of {expected})",
)
def _login_rejected_message(label: str) -> str:
return (
f"The {label} replied but did not confirm this login. "
f"Existing access may still allow some {label} operations, but privileged actions may fail."
)
def _login_send_failed_message(label: str) -> str:
return (
f"The login request could not be sent to the {label}. "
f"The control panel is still available, but authenticated actions may fail until a login succeeds."
)
def _login_timeout_message(label: str) -> str:
return (
f"No login confirmation was heard from the {label}. "
"That can mean the password was wrong or the reply was missed in transit. "
"The control panel is still available; try logging in again if authenticated actions fail."
)
def extract_response_text(event) -> str:
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
text = event.payload.get("text", str(event.payload))
if text.startswith("> "):
text = text[2:]
return text
async def fetch_contact_cli_response(
mc,
target_pubkey_prefix: str,
timeout: float = 20.0,
) -> "Event | None":
"""Fetch a CLI response from a specific contact via a validated get_msg() loop."""
deadline = _monotonic() + timeout
while _monotonic() < deadline:
try:
result = await mc.commands.get_msg(timeout=2.0)
except asyncio.TimeoutError:
continue
except Exception as exc:
logger.debug("get_msg() exception: %s", exc)
await asyncio.sleep(1.0)
continue
if result.type == EventType.NO_MORE_MSGS:
await asyncio.sleep(1.0)
continue
if result.type == EventType.ERROR:
logger.debug("get_msg() error: %s", result.payload)
await asyncio.sleep(1.0)
continue
if result.type == EventType.CONTACT_MSG_RECV:
msg_prefix = result.payload.get("pubkey_prefix", "")
txt_type = result.payload.get("txt_type", 0)
if msg_prefix == target_pubkey_prefix and txt_type == 1:
return result
logger.debug(
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
msg_prefix,
txt_type,
target_pubkey_prefix,
)
continue
if result.type == EventType.CHANNEL_MSG_RECV:
logger.debug(
"Skipping channel message (channel_idx=%s) during CLI fetch",
result.payload.get("channel_idx"),
)
continue
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
logger.warning("No CLI response from contact %s within %.1fs", target_pubkey_prefix, timeout)
return None
async def prepare_authenticated_contact_connection(
mc,
contact: Contact,
password: str,
*,
label: str | None = None,
response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
) -> RepeaterLoginResponse:
"""Prepare connection to a server-capable contact by adding it to the radio and logging in."""
pubkey_prefix = contact.public_key[:12].lower()
contact_label = label or get_server_contact_label(contact)
loop = asyncio.get_running_loop()
login_future = loop.create_future()
def _resolve_login(event_type: EventType, message: str | None = None) -> None:
if login_future.done():
return
login_future.set_result(
RepeaterLoginResponse(
status="ok" if event_type == EventType.LOGIN_SUCCESS else "error",
authenticated=event_type == EventType.LOGIN_SUCCESS,
message=message,
)
)
success_subscription = mc.subscribe(
EventType.LOGIN_SUCCESS,
lambda _event: _resolve_login(EventType.LOGIN_SUCCESS),
attribute_filters={"pubkey_prefix": pubkey_prefix},
)
failed_subscription = mc.subscribe(
EventType.LOGIN_FAILED,
lambda _event: _resolve_login(
EventType.LOGIN_FAILED,
_login_rejected_message(contact_label),
),
attribute_filters={"pubkey_prefix": pubkey_prefix},
)
try:
logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12])
await _ensure_on_radio(mc, contact)
logger.info("Sending login to %s %s", contact_label, contact.public_key[:12])
login_result = await mc.commands.send_login(contact.public_key, password)
if login_result.type == EventType.ERROR:
return RepeaterLoginResponse(
status="error",
authenticated=False,
message=f"{_login_send_failed_message(contact_label)} ({login_result.payload})",
)
try:
return await asyncio.wait_for(
login_future,
timeout=response_timeout,
)
except asyncio.TimeoutError:
logger.warning(
"No login response from %s %s within %.1fs",
contact_label,
contact.public_key[:12],
response_timeout,
)
return RepeaterLoginResponse(
status="timeout",
authenticated=False,
message=_login_timeout_message(contact_label),
)
except HTTPException as exc:
logger.warning(
"%s login setup failed for %s: %s",
contact_label.capitalize(),
contact.public_key[:12],
exc.detail,
)
return RepeaterLoginResponse(
status="error",
authenticated=False,
message=f"{_login_send_failed_message(contact_label)} ({exc.detail})",
)
finally:
success_subscription.unsubscribe()
failed_subscription.unsubscribe()
async def batch_cli_fetch(
contact: Contact,
operation_name: str,
commands: list[tuple[str, str]],
) -> dict[str, str | None]:
"""Send a batch of CLI commands to a server-capable contact and collect responses."""
results: dict[str, str | None] = {field: None for _, field in commands}
async with radio_manager.radio_operation(
operation_name,
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0)
for index, (cmd, field) in enumerate(commands):
if index > 0:
await asyncio.sleep(1.0)
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
if send_result.type == EventType.ERROR:
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
continue
response_event = await fetch_contact_cli_response(
mc, contact.public_key[:12], timeout=10.0
)
if response_event is not None:
results[field] = extract_response_text(response_event)
else:
logger.warning("No response for command '%s' (%s)", cmd, field)
return results
async def send_contact_cli_command(
contact: Contact,
command: str,
*,
operation_name: str,
) -> CommandResponse:
"""Send a CLI command to a server-capable contact and return the text response."""
label = get_server_contact_label(contact)
async with radio_manager.radio_operation(
operation_name,
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
logger.info("Adding %s %s to radio", label, contact.public_key[:12])
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0)
logger.info("Sending command to %s %s: %s", label, contact.public_key[:12], command)
send_result = await mc.commands.send_cmd(contact.public_key, command)
if send_result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to send command: {send_result.payload}"
)
response_event = await fetch_contact_cli_response(mc, contact.public_key[:12])
if response_event is None:
logger.warning(
"No response from %s %s for command: %s",
label,
contact.public_key[:12],
command,
)
return CommandResponse(
command=command,
response="(no response - command may have been processed)",
)
response_text = extract_response_text(response_event)
sender_timestamp = response_event.payload.get(
"sender_timestamp",
response_event.payload.get("timestamp"),
)
logger.info(
"Received response from %s %s: %s",
label,
contact.public_key[:12],
response_text,
)
return CommandResponse(
command=command,
response=response_text,
sender_timestamp=sender_timestamp,
)

View File

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

View File

@@ -5,7 +5,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from app.models import CONTACT_TYPE_REPEATER, Contact, ContactUpsert, Message
from app.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message
from app.repository import (
AmbiguousPublicKeyPrefixError,
ContactRepository,
@@ -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)
@@ -107,6 +106,35 @@ async def resolve_fallback_direct_message_context(
)
async def resolve_direct_message_sender_metadata(
*,
sender_public_key: str,
received_at: int,
broadcast_fn: BroadcastFn,
contact_repository=ContactRepository,
log: logging.Logger | None = None,
) -> tuple[str | None, str | None]:
"""Resolve sender attribution for direct-message variants such as room-server posts."""
normalized_sender = sender_public_key.lower()
try:
contact = await contact_repository.get_by_key_or_prefix(normalized_sender)
except AmbiguousPublicKeyPrefixError:
(log or logger).warning(
"Sender prefix '%s' is ambiguous; preserving prefix-only attribution",
sender_public_key,
)
contact = None
if contact is not None:
await claim_prefix_messages_for_contact(
public_key=contact.public_key.lower(), log=log or logger
)
return contact.name, contact.public_key.lower()
return None, normalized_sender or None
async def _store_direct_message(
*,
packet_id: int | None,
@@ -238,8 +266,19 @@ async def ingest_decrypted_direct_message(
contact_repository=ContactRepository,
) -> Message | None:
conversation_key = their_public_key.lower()
if not outgoing and decrypted.txt_type == 1:
logger.debug(
"Skipping CLI response from %s (txt_type=1): %s",
conversation_key[:12],
(decrypted.message or "")[:50],
)
return None
contact = await contact_repository.get_by_key(conversation_key)
sender_name: str | None = None
sender_key: str | None = conversation_key if not outgoing else None
signature: str | None = None
if contact is not None:
conversation_key, skip_storage = await _prepare_resolved_contact(contact, log=logger)
if skip_storage:
@@ -250,7 +289,17 @@ async def ingest_decrypted_direct_message(
)
return None
if not outgoing:
sender_name = contact.name
if contact.type == CONTACT_TYPE_ROOM and decrypted.signed_sender_prefix:
sender_name, sender_key = await resolve_direct_message_sender_metadata(
sender_public_key=decrypted.signed_sender_prefix,
received_at=received_at or int(time.time()),
broadcast_fn=broadcast_fn,
contact_repository=contact_repository,
log=logger,
)
signature = decrypted.signed_sender_prefix
else:
sender_name = contact.name
received = received_at or int(time.time())
message = await _store_direct_message(
@@ -262,10 +311,10 @@ async def ingest_decrypted_direct_message(
path=path,
path_len=path_len,
outgoing=outgoing,
txt_type=0,
signature=None,
txt_type=decrypted.txt_type,
signature=signature,
sender_name=sender_name,
sender_key=conversation_key if not outgoing else None,
sender_key=sender_key,
realtime=realtime,
broadcast_fn=broadcast_fn,
update_last_contacted_key=conversation_key,

View File

@@ -44,6 +44,13 @@ async def apply_radio_config_update(
f"Failed to set advert location policy: {result.payload}"
)
if update.multi_acks_enabled is not None:
multi_acks = 1 if update.multi_acks_enabled else 0
logger.info("Setting multi ACKs to %d", multi_acks)
result = await mc.commands.set_multi_acks(multi_acks)
if result is not None and result.type == EventType.ERROR:
raise RadioCommandRejectedError(f"Failed to set multi ACKs: {result.payload}")
if update.name is not None:
logger.info("Setting radio name to %s", update.name)
await mc.commands.set_name(update.name)

View File

@@ -245,10 +245,12 @@ High-level state is delegated to hooks:
- `id`: backend storage row identity (payload-level dedup)
- `observation_id`: realtime per-arrival identity (session fidelity)
- Packet feed/visualizer render keys and dedup logic should use `observation_id` (fallback to `id` only for older payloads).
- The dedicated raw packet feed view now includes a frontend-only stats drawer. It tracks a separate lightweight per-observation session history for charts/rankings, so its windows are not limited by the visible packet list cap. Coverage messaging should stay honest when detailed in-memory stats history has been trimmed or the selected window predates the current browser session.
### Radio settings behavior
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
- `SettingsRadioSection.tsx` also exposes `multi_acks_enabled` as a checkbox for the radio's extra direct-ACK transmission behavior.
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
- The advert action is mode-aware: the radio settings section exposes both flood and zero-hop manual advert buttons, both routed through the same `onAdvertise(mode)` seam.
- Mesh discovery in the radio section is limited to node classes that currently answer discovery control-data requests in firmware: repeaters and sensors.
@@ -340,9 +342,10 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
- Name history ("Also Known As") — shown only when the contact has used multiple names
- Message stats: DM count, channel message count
- Most active rooms (clickable → navigate to channel)
- Route details from the canonical backend surface (`effective_route`, `effective_route_source`, `direct_route`, `route_override`)
- Advert observation rate
- Nearest repeaters (resolved from first-hop path prefixes)
- Recent advert paths
- Recent advert paths (informational only; not part of DM route selection)
State: `useConversationNavigation` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
@@ -422,9 +425,9 @@ PYTHONPATH=. uv run pytest tests/ -v
## Errata & Known Non-Issues
### Contacts rollup uses mention styling for unread DMs
### Contacts use mention styling for unread DMs
This is intentional. In the sidebar section headers, unread direct messages are treated as mention-equivalent, so the Contacts rollup uses the highlighted mention-style badge for any unread DM. Row-level mention detection remains separate; this note is only about the section summary styling.
This is intentional. In the sidebar, unread direct messages for actual contact conversations are treated as mention-equivalent for badge styling. That means both the Contacts section header and contact unread badges themselves use the highlighted mention-style colors for unread DMs, including when those contacts appear in Favorites. Repeaters do not inherit this rule, and channel badges still use mention styling only for real `@[name]` mentions.
### RawPacketList always scrolls to bottom

View File

@@ -14,9 +14,11 @@ import {
useConversationNavigation,
useRealtimeAppState,
useBrowserNotifications,
useRawPacketStatsSession,
} from './hooks';
import { AppShell } from './components/AppShell';
import type { MessageInputHandle } from './components/MessageInput';
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
import { messageContainsMention } from './utils/messageParser';
import { getStateKey } from './utils/conversationState';
import type { Conversation, Message, RawPacket } from './types';
@@ -81,6 +83,7 @@ export function App() {
toggleConversationNotifications,
notifyIncomingMessage,
} = useBrowserNotifications();
const { rawPacketStatsSession, recordRawPacketObservation } = useRawPacketStatsSession();
const {
showNewMessage,
showSettings,
@@ -89,10 +92,12 @@ export function App() {
showCracker,
crackerRunning,
localLabel,
distanceUnit,
setSettingsSection,
setSidebarOpen,
setCrackerRunning,
setLocalLabel,
setDistanceUnit,
handleCloseSettingsView,
handleToggleSettingsView,
handleOpenNewMessage,
@@ -331,6 +336,7 @@ export function App() {
removeConversationMessages,
receiveMessageAck,
notifyIncomingMessage,
recordRawPacketObservation,
});
const handleVisibilityPolicyChanged = useCallback(() => {
clearConversationMessages();
@@ -413,6 +419,7 @@ export function App() {
contacts,
channels,
rawPackets,
rawPacketStatsSession,
config,
health,
favorites,
@@ -560,29 +567,31 @@ export function App() {
setContactsLoaded,
]);
return (
<AppShell
localLabel={localLabel}
showNewMessage={showNewMessage}
showSettings={showSettings}
settingsSection={settingsSection}
sidebarOpen={sidebarOpen}
showCracker={showCracker}
onSettingsSectionChange={setSettingsSection}
onSidebarOpenChange={setSidebarOpen}
onCrackerRunningChange={setCrackerRunning}
onToggleSettingsView={handleToggleSettingsView}
onCloseSettingsView={handleCloseSettingsView}
onCloseNewMessage={handleCloseNewMessage}
onLocalLabelChange={setLocalLabel}
statusProps={statusProps}
sidebarProps={sidebarProps}
conversationPaneProps={conversationPaneProps}
searchProps={searchProps}
settingsProps={settingsProps}
crackerProps={crackerProps}
newMessageModalProps={newMessageModalProps}
contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps}
/>
<DistanceUnitProvider distanceUnit={distanceUnit} setDistanceUnit={setDistanceUnit}>
<AppShell
localLabel={localLabel}
showNewMessage={showNewMessage}
showSettings={showSettings}
settingsSection={settingsSection}
sidebarOpen={sidebarOpen}
showCracker={showCracker}
onSettingsSectionChange={setSettingsSection}
onSidebarOpenChange={setSidebarOpen}
onCrackerRunningChange={setCrackerRunning}
onToggleSettingsView={handleToggleSettingsView}
onCloseSettingsView={handleCloseSettingsView}
onCloseNewMessage={handleCloseNewMessage}
onLocalLabelChange={setLocalLabel}
statusProps={statusProps}
sidebarProps={sidebarProps}
conversationPaneProps={conversationPaneProps}
searchProps={searchProps}
settingsProps={settingsProps}
crackerProps={crackerProps}
newMessageModalProps={newMessageModalProps}
contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps}
/>
</DistanceUnitProvider>
);
}

View File

@@ -383,4 +383,21 @@ export const api = {
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
method: 'POST',
}),
roomLogin: (publicKey: string, password: string) =>
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
method: 'POST',
body: JSON.stringify({ password }),
}),
roomStatus: (publicKey: string) =>
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/room/status`, {
method: 'POST',
}),
roomAcl: (publicKey: string) =>
fetchJson<RepeaterAclResponse>(`/contacts/${publicKey}/room/acl`, {
method: 'POST',
}),
roomLppTelemetry: (publicKey: string) =>
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/room/lpp-telemetry`, {
method: 'POST',
}),
};

View File

@@ -19,6 +19,7 @@ import type {
PathDiscoveryResponse,
RadioConfig,
} from '../types';
import { CONTACT_TYPE_ROOM } from '../types';
interface ChatHeaderProps {
conversation: Conversation;
@@ -84,6 +85,7 @@ export function ChatHeader({
conversation.type === 'contact'
? contacts.find((contact) => contact.public_key === conversation.id)
: null;
const activeContactIsRoomServer = activeContact?.type === CONTACT_TYPE_ROOM;
const activeContactIsPrefixOnly = activeContact
? isPrefixOnlyContact(activeContact.public_key)
: false;
@@ -230,7 +232,7 @@ export function ChatHeader({
</span>
</span>
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
{conversation.type === 'contact' && (
{conversation.type === 'contact' && !activeContactIsRoomServer && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setPathDiscoveryOpen(true)}
@@ -245,7 +247,7 @@ export function ChatHeader({
<Route className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</button>
)}
{conversation.type === 'contact' && (
{conversation.type === 'contact' && !activeContactIsRoomServer && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onTrace}
@@ -260,7 +262,7 @@ export function ChatHeader({
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
</button>
)}
{notificationsSupported && (
{notificationsSupported && !activeContactIsRoomServer && (
<button
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onToggleNotifications}

View File

@@ -12,6 +12,7 @@ import {
calculateDistance,
formatDistance,
formatRouteLabel,
getDirectContactRoute,
getEffectiveContactRoute,
hasRoutingOverride,
parsePathHops,
@@ -23,6 +24,7 @@ import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
import type {
Contact,
ContactActiveRoom,
@@ -81,6 +83,7 @@ export function ContactInfoPane({
onToggleBlockedKey,
onToggleBlockedName,
}: ContactInfoPaneProps) {
const { distanceUnit } = useDistanceUnit();
const isNameOnly = contactKey?.startsWith('name:') ?? false;
const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null;
@@ -134,11 +137,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 &&
@@ -313,7 +317,7 @@ export function ContactInfoPane({
<InfoItem label="Last Contacted" value={formatTime(contact.last_contacted)} />
)}
{distFromUs !== null && (
<InfoItem label="Distance" value={formatDistance(distFromUs)} />
<InfoItem label="Distance" value={formatDistance(distFromUs, distanceUnit)} />
)}
{effectiveRoute && (
<InfoItem
@@ -330,7 +334,7 @@ export function ContactInfoPane({
}
/>
)}
{contact && hasRoutingOverride(contact) && learnedRouteLabel && (
{hasRoutingOverride(contact) && learnedRouteLabel && (
<InfoItem label="Learned Route" value={learnedRouteLabel} />
)}
{pathHashModeLabel && <InfoItem label="Hop Width" value={pathHashModeLabel} />}

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import {
import { getMapFocusHash } from '../utils/urlHash';
import { handleKeyboardActivate } from '../utils/a11y';
import type { Contact } from '../types';
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
import { ContactRoutingOverrideModal } from './ContactRoutingOverrideModal';
interface ContactStatusInfoProps {
@@ -24,6 +25,7 @@ interface ContactStatusInfoProps {
* shared between ChatHeader and RepeaterDashboard.
*/
export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfoProps) {
const { distanceUnit } = useDistanceUnit();
const [routingModalOpen, setRoutingModalOpen] = useState(false);
const parts: ReactNode[] = [];
const effectiveRoute = getEffectiveContactRoute(contact);
@@ -74,7 +76,7 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
>
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
</span>
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
{distFromUs !== null && ` (${formatDistance(distFromUs, distanceUnit)})`}
</span>
);
}

View File

@@ -1,9 +1,10 @@
import { lazy, Suspense, useMemo, type Ref } from 'react';
import { lazy, Suspense, useEffect, useMemo, useState, type Ref } from 'react';
import { ChatHeader } from './ChatHeader';
import { MessageInput, type MessageInputHandle } from './MessageInput';
import { MessageList } from './MessageList';
import { RawPacketList } from './RawPacketList';
import { RawPacketFeedView } from './RawPacketFeedView';
import { RoomServerPanel } from './RoomServerPanel';
import type {
Channel,
Contact,
@@ -15,7 +16,8 @@ import type {
RawPacket,
RadioConfig,
} from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
import { isPrefixOnlyContact, isUnknownFullKeyContact } from '../utils/pubkey';
const RepeaterDashboard = lazy(() =>
@@ -31,6 +33,7 @@ interface ConversationPaneProps {
contacts: Contact[];
channels: Channel[];
rawPackets: RawPacket[];
rawPacketStatsSession: RawPacketStatsSessionState;
config: RadioConfig | null;
health: HealthStatus | null;
notificationsSupported: boolean;
@@ -95,6 +98,7 @@ export function ConversationPane({
contacts,
channels,
rawPackets,
rawPacketStatsSession,
config,
health,
notificationsSupported,
@@ -128,6 +132,7 @@ export function ConversationPane({
onSendMessage,
onToggleNotifications,
}: ConversationPaneProps) {
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
const activeContactIsRepeater = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return false;
const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id);
@@ -137,6 +142,10 @@ export function ConversationPane({
if (!activeConversation || activeConversation.type !== 'contact') return null;
return contacts.find((candidate) => candidate.public_key === activeConversation.id) ?? null;
}, [activeConversation, contacts]);
const activeContactIsRoom = activeContact?.type === CONTACT_TYPE_ROOM;
useEffect(() => {
setRoomAuthenticated(false);
}, [activeConversation?.id]);
const isPrefixOnlyActiveContact = activeContact
? isPrefixOnlyContact(activeContact.public_key)
: false;
@@ -178,14 +187,12 @@ export function ConversationPane({
if (activeConversation.type === 'raw') {
return (
<>
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
Raw Packet Feed
</h2>
<div className="flex-1 overflow-hidden">
<RawPacketList packets={rawPackets} />
</div>
</>
<RawPacketFeedView
packets={rawPackets}
rawPacketStatsSession={rawPacketStatsSession}
contacts={contacts}
channels={channels}
/>
);
}
@@ -217,6 +224,8 @@ export function ConversationPane({
);
}
const showRoomChat = !activeContactIsRoom || roomAuthenticated;
return (
<>
<ChatHeader
@@ -244,35 +253,40 @@ export function ConversationPane({
{activeConversation.type === 'contact' && isUnknownFullKeyActiveContact && (
<ContactResolutionBanner variant="unknown-full-key" />
)}
<MessageList
key={activeConversation.id}
messages={messages}
contacts={contacts}
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
unreadMarkerLastReadAt={
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
}
onDismissUnreadMarker={
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
}
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
onLoadOlder={onLoadOlder}
onResendChannelMessage={
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
}
radioName={config?.name}
config={config}
onOpenContactInfo={onOpenContactInfo}
targetMessageId={targetMessageId}
onTargetReached={onTargetReached}
hasNewerMessages={hasNewerMessages}
loadingNewer={loadingNewer}
onLoadNewer={onLoadNewer}
onJumpToBottom={onJumpToBottom}
/>
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact ? null : (
{activeContactIsRoom && activeContact && (
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
)}
{showRoomChat && (
<MessageList
key={activeConversation.id}
messages={messages}
contacts={contacts}
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
unreadMarkerLastReadAt={
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
}
onDismissUnreadMarker={
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
}
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
onLoadOlder={onLoadOlder}
onResendChannelMessage={
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
}
radioName={config?.name}
config={config}
onOpenContactInfo={onOpenContactInfo}
targetMessageId={targetMessageId}
onTargetReached={onTargetReached}
hasNewerMessages={hasNewerMessages}
loadingNewer={loadingNewer}
onLoadNewer={onLoadNewer}
onJumpToBottom={onJumpToBottom}
/>
)}
{showRoomChat && !(activeConversation.type === 'contact' && isPrefixOnlyActiveContact) ? (
<MessageInput
ref={messageInputRef}
onSend={onSendMessage}
@@ -285,7 +299,7 @@ export function ConversationPane({
: `Message ${activeConversation.name}...`
}
/>
)}
) : null}
</>
);
}

View File

@@ -9,9 +9,10 @@ import {
type ReactNode,
} from 'react';
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } 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';
@@ -499,13 +500,41 @@ export function MessageList({
contact: Contact | null,
parsedSender: string | null
): SenderInfo => {
if (
msg.type === 'PRIV' &&
contact?.type === CONTACT_TYPE_ROOM &&
(msg.sender_key || msg.sender_name)
) {
const authorContact =
(msg.sender_key
? contacts.find((candidate) => candidate.public_key === msg.sender_key)
: null) || (msg.sender_name ? getContactByName(msg.sender_name) : null);
if (authorContact) {
const directRoute = getDirectContactRoute(authorContact);
return {
name: authorContact.name || msg.sender_name || authorContact.public_key.slice(0, 12),
publicKeyOrPrefix: authorContact.public_key,
lat: authorContact.lat,
lon: authorContact.lon,
pathHashMode: directRoute?.path_hash_mode ?? null,
};
}
return {
name: msg.sender_name || msg.sender_key || 'Unknown',
publicKeyOrPrefix: msg.sender_key || '',
lat: null,
lon: null,
pathHashMode: null,
};
}
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 +544,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 +568,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,
};
}
}
@@ -580,6 +611,8 @@ export function MessageList({
isCorruptChannelMessage: boolean
): string => {
if (msg.outgoing) return '__outgoing__';
if (msg.type === 'PRIV' && msg.sender_key) return `key:${msg.sender_key}`;
if (msg.type === 'PRIV' && senderName) return `name:${senderName}`;
if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key;
if (msg.sender_key) return `key:${msg.sender_key}`;
if (senderName) return `name:${senderName}`;
@@ -608,18 +641,24 @@ export function MessageList({
// For DMs, look up contact; for channel messages, use parsed sender
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
const isRoomServer = contact?.type === CONTACT_TYPE_ROOM;
// Skip sender parsing for repeater messages (CLI responses often have colons)
const { sender, content } = isRepeater
? { sender: null, content: msg.text }
: parseSenderFromText(msg.text);
const { sender, content } =
isRepeater || (isRoomServer && msg.type === 'PRIV')
? { sender: null, content: msg.text }
: parseSenderFromText(msg.text);
const directSenderName =
msg.type === 'PRIV' && isRoomServer ? msg.sender_name || null : null;
const channelSenderName = msg.type === 'CHAN' ? msg.sender_name || sender : null;
const channelSenderContact =
msg.type === 'CHAN' && channelSenderName ? getContactByName(channelSenderName) : null;
const isCorruptChannelMessage = isCorruptUnnamedChannelMessage(msg, sender);
const displaySender = msg.outgoing
? 'You'
: contact?.name ||
: directSenderName ||
(isRoomServer && msg.sender_key ? msg.sender_key.slice(0, 8) : null) ||
contact?.name ||
channelSenderName ||
(isCorruptChannelMessage
? CORRUPT_SENDER_LABEL
@@ -632,15 +671,22 @@ export function MessageList({
displaySender !== CORRUPT_SENDER_LABEL;
// Determine if we should show avatar (first message in a chunk from same sender)
const currentSenderKey = getSenderKey(msg, channelSenderName, isCorruptChannelMessage);
const currentSenderKey = getSenderKey(
msg,
directSenderName || channelSenderName,
isCorruptChannelMessage
);
const prevMsg = sortedMessages[index - 1];
const prevParsedSender = prevMsg ? parseSenderFromText(prevMsg.text).sender : null;
const prevSenderKey = prevMsg
? getSenderKey(
prevMsg,
prevMsg.type === 'CHAN'
? prevMsg.sender_name || prevParsedSender
: prevParsedSender,
prevMsg.type === 'PRIV' &&
getContact(prevMsg.conversation_key)?.type === CONTACT_TYPE_ROOM
? prevMsg.sender_name
: prevMsg.type === 'CHAN'
? prevMsg.sender_name || prevParsedSender
: prevParsedSender,
isCorruptUnnamedChannelMessage(prevMsg, prevParsedSender)
)
: null;
@@ -654,9 +700,14 @@ export function MessageList({
let avatarVariant: 'default' | 'corrupt' = 'default';
if (!msg.outgoing) {
if (msg.type === 'PRIV' && msg.conversation_key) {
// DM: use conversation_key (sender's public key)
avatarName = contact?.name || null;
avatarKey = msg.conversation_key;
if (isRoomServer) {
avatarName = directSenderName;
avatarKey =
msg.sender_key || (avatarName ? `name:${avatarName}` : msg.conversation_key);
} else {
avatarName = contact?.name || null;
avatarKey = msg.conversation_key;
}
} else if (isCorruptChannelMessage) {
avatarName = CORRUPT_SENDER_LABEL;
avatarKey = `corrupt:${msg.id}`;
@@ -721,7 +772,12 @@ export function MessageList({
type="button"
className="avatar-action-button rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={avatarActionLabel}
onClick={() => onOpenContactInfo(avatarKey, msg.type === 'CHAN')}
onClick={() =>
onOpenContactInfo(
avatarKey,
msg.type === 'CHAN' || (msg.type === 'PRIV' && isRoomServer)
)
}
>
<ContactAvatar
name={avatarName}
@@ -776,7 +832,7 @@ export function MessageList({
onClick={() =>
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, sender),
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
})
}
/>
@@ -802,7 +858,7 @@ export function MessageList({
onClick={() =>
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, sender),
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
})
}
/>

View File

@@ -14,6 +14,8 @@ import {
} from '../utils/pathUtils';
import { formatTime } from '../utils/messageParser';
import { getMapFocusHash } from '../utils/urlHash';
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
import type { DistanceUnit } from '../utils/distanceUnits';
const PathRouteMap = lazy(() =>
import('./PathRouteMap').then((m) => ({ default: m.PathRouteMap }))
@@ -44,6 +46,7 @@ export function PathModal({
isResendable,
onResend,
}: PathModalProps) {
const { distanceUnit } = useDistanceUnit();
const [expandedMaps, setExpandedMaps] = useState<Set<number>>(new Set());
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
const hasPaths = paths.length > 0;
@@ -120,7 +123,8 @@ export function PathModal({
resolvedPaths[0].resolved.sender.lon,
resolvedPaths[0].resolved.receiver.lat,
resolvedPaths[0].resolved.receiver.lon
)!
)!,
distanceUnit
)}
</span>
</div>
@@ -171,7 +175,11 @@ export function PathModal({
</Suspense>
</div>
)}
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
<PathVisualization
resolved={pathData.resolved}
senderInfo={senderInfo}
distanceUnit={distanceUnit}
/>
</div>
);
})}
@@ -227,9 +235,10 @@ export function PathModal({
interface PathVisualizationProps {
resolved: ResolvedPath;
senderInfo: SenderInfo;
distanceUnit: DistanceUnit;
}
function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
function PathVisualization({ resolved, senderInfo, distanceUnit }: PathVisualizationProps) {
// Track previous location for each hop to calculate distances
// Returns null if previous hop was ambiguous or has invalid location
const getPrevLocation = (hopIndex: number): { lat: number | null; lon: number | null } | null => {
@@ -264,6 +273,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
name={resolved.sender.name}
prefix={resolved.sender.prefix}
distance={null}
distanceUnit={distanceUnit}
isFirst
lat={resolved.sender.lat}
lon={resolved.sender.lon}
@@ -277,6 +287,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
hop={hop}
hopNumber={index + 1}
prevLocation={getPrevLocation(index)}
distanceUnit={distanceUnit}
/>
))}
@@ -286,6 +297,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
name={resolved.receiver.name}
prefix={resolved.receiver.prefix}
distance={calculateReceiverDistance(resolved)}
distanceUnit={distanceUnit}
isLast
lat={resolved.receiver.lat}
lon={resolved.receiver.lon}
@@ -300,7 +312,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
</span>
<span className="text-sm font-medium">
{resolved.hasGaps ? '>' : ''}
{formatDistance(resolved.totalDistances[0])}
{formatDistance(resolved.totalDistances[0], distanceUnit)}
</span>
</div>
)}
@@ -313,6 +325,7 @@ interface PathNodeProps {
name: string;
prefix: string;
distance: number | null;
distanceUnit: DistanceUnit;
isFirst?: boolean;
isLast?: boolean;
/** Optional coordinates for map link */
@@ -327,6 +340,7 @@ function PathNode({
name,
prefix,
distance,
distanceUnit,
isFirst,
isLast,
lat,
@@ -353,7 +367,9 @@ function PathNode({
<div className="font-medium truncate">
{name}
{distance !== null && (
<span className="text-xs text-muted-foreground ml-1">- {formatDistance(distance)}</span>
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(distance, distanceUnit)}
</span>
)}
{hasLocation && <CoordinateLink lat={lat!} lon={lon!} publicKey={publicKey!} />}
</div>
@@ -366,9 +382,10 @@ interface HopNodeProps {
hop: PathHop;
hopNumber: number;
prevLocation: { lat: number | null; lon: number | null } | null;
distanceUnit: DistanceUnit;
}
function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
const isAmbiguous = hop.matches.length > 1;
const isUnknown = hop.matches.length === 0;
@@ -417,7 +434,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
{contact.name || contact.public_key.slice(0, 12)}
{dist !== null && (
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(dist)}
- {formatDistance(dist, distanceUnit)}
</span>
)}
{hasLocation && (
@@ -436,7 +453,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
{hop.matches[0].name || hop.matches[0].public_key.slice(0, 12)}
{hop.distanceFromPrev !== null && (
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(hop.distanceFromPrev)}
- {formatDistance(hop.distanceFromPrev, distanceUnit)}
</span>
)}
{isValidLocation(hop.matches[0].lat, hop.matches[0].lon) && (

View File

@@ -0,0 +1,644 @@
import { useMemo, useState } from 'react';
import { ChannelCrypto, PayloadType } from '@michaelhart/meshcore-decoder';
import type { Channel, RawPacket } from '../types';
import { cn } from '@/lib/utils';
import {
createDecoderOptions,
inspectRawPacketWithOptions,
type PacketByteField,
} from '../utils/rawPacketInspector';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
interface RawPacketDetailModalProps {
packet: RawPacket | null;
channels: Channel[];
onClose: () => void;
}
interface FieldPaletteEntry {
box: string;
boxActive: string;
hex: string;
hexActive: string;
}
interface GroupTextResolutionCandidate {
key: string;
name: string;
hash: string;
}
const FIELD_PALETTE: FieldPaletteEntry[] = [
{
box: 'border-sky-500/30 bg-sky-500/10',
boxActive: 'border-sky-600 bg-sky-500/20 shadow-sm shadow-sky-500/20',
hex: 'bg-sky-500/20 ring-1 ring-inset ring-sky-500/35',
hexActive: 'bg-sky-500/40 ring-1 ring-inset ring-sky-600/70',
},
{
box: 'border-emerald-500/30 bg-emerald-500/10',
boxActive: 'border-emerald-600 bg-emerald-500/20 shadow-sm shadow-emerald-500/20',
hex: 'bg-emerald-500/20 ring-1 ring-inset ring-emerald-500/35',
hexActive: 'bg-emerald-500/40 ring-1 ring-inset ring-emerald-600/70',
},
{
box: 'border-amber-500/30 bg-amber-500/10',
boxActive: 'border-amber-600 bg-amber-500/20 shadow-sm shadow-amber-500/20',
hex: 'bg-amber-500/20 ring-1 ring-inset ring-amber-500/35',
hexActive: 'bg-amber-500/40 ring-1 ring-inset ring-amber-600/70',
},
{
box: 'border-rose-500/30 bg-rose-500/10',
boxActive: 'border-rose-600 bg-rose-500/20 shadow-sm shadow-rose-500/20',
hex: 'bg-rose-500/20 ring-1 ring-inset ring-rose-500/35',
hexActive: 'bg-rose-500/40 ring-1 ring-inset ring-rose-600/70',
},
{
box: 'border-violet-500/30 bg-violet-500/10',
boxActive: 'border-violet-600 bg-violet-500/20 shadow-sm shadow-violet-500/20',
hex: 'bg-violet-500/20 ring-1 ring-inset ring-violet-500/35',
hexActive: 'bg-violet-500/40 ring-1 ring-inset ring-violet-600/70',
},
{
box: 'border-cyan-500/30 bg-cyan-500/10',
boxActive: 'border-cyan-600 bg-cyan-500/20 shadow-sm shadow-cyan-500/20',
hex: 'bg-cyan-500/20 ring-1 ring-inset ring-cyan-500/35',
hexActive: 'bg-cyan-500/40 ring-1 ring-inset ring-cyan-600/70',
},
{
box: 'border-lime-500/30 bg-lime-500/10',
boxActive: 'border-lime-600 bg-lime-500/20 shadow-sm shadow-lime-500/20',
hex: 'bg-lime-500/20 ring-1 ring-inset ring-lime-500/35',
hexActive: 'bg-lime-500/40 ring-1 ring-inset ring-lime-600/70',
},
{
box: 'border-fuchsia-500/30 bg-fuchsia-500/10',
boxActive: 'border-fuchsia-600 bg-fuchsia-500/20 shadow-sm shadow-fuchsia-500/20',
hex: 'bg-fuchsia-500/20 ring-1 ring-inset ring-fuchsia-500/35',
hexActive: 'bg-fuchsia-500/40 ring-1 ring-inset ring-fuchsia-600/70',
},
];
function formatTimestamp(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleString([], {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function formatSignal(packet: RawPacket): string {
const parts: string[] = [];
if (packet.rssi !== null) {
parts.push(`${packet.rssi} dBm RSSI`);
}
if (packet.snr !== null) {
parts.push(`${packet.snr.toFixed(1)} dB SNR`);
}
return parts.length > 0 ? parts.join(' · ') : 'No signal sample';
}
function formatByteRange(field: PacketByteField): string {
if (field.absoluteStartByte === field.absoluteEndByte) {
return `Byte ${field.absoluteStartByte}`;
}
return `Bytes ${field.absoluteStartByte}-${field.absoluteEndByte}`;
}
function formatPathMode(hashSize: number | undefined, hopCount: number): string {
if (hopCount === 0) {
return 'No path hops';
}
if (!hashSize) {
return `${hopCount} hop${hopCount === 1 ? '' : 's'}`;
}
return `${hopCount} hop${hopCount === 1 ? '' : 's'} · ${hashSize} byte hash${hashSize === 1 ? '' : 'es'}`;
}
function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResolutionCandidate[] {
return channels.map((channel) => ({
key: channel.key,
name: channel.name,
hash: ChannelCrypto.calculateChannelHash(channel.key).toUpperCase(),
}));
}
function resolveGroupTextRoomName(
payload: {
channelHash?: string;
cipherMac?: string;
ciphertext?: string;
decrypted?: { message?: string };
},
candidates: GroupTextResolutionCandidate[]
): string | null {
if (!payload.channelHash) {
return null;
}
const hashMatches = candidates.filter(
(candidate) => candidate.hash === payload.channelHash?.toUpperCase()
);
if (hashMatches.length === 1) {
return hashMatches[0].name;
}
if (
hashMatches.length <= 1 ||
!payload.cipherMac ||
!payload.ciphertext ||
!payload.decrypted?.message
) {
return null;
}
const decryptMatches = hashMatches.filter(
(candidate) =>
ChannelCrypto.decryptGroupTextMessage(payload.ciphertext!, payload.cipherMac!, candidate.key)
.success
);
return decryptMatches.length === 1 ? decryptMatches[0].name : null;
}
function packetShowsDecryptedState(
packet: RawPacket,
inspection: ReturnType<typeof inspectRawPacketWithOptions>
): boolean {
const payload = inspection.decoded?.payload.decoded as { decrypted?: unknown } | null | undefined;
return packet.decrypted || Boolean(packet.decrypted_info) || Boolean(payload?.decrypted);
}
function getPacketContext(
packet: RawPacket,
inspection: ReturnType<typeof inspectRawPacketWithOptions>,
groupTextCandidates: GroupTextResolutionCandidate[]
) {
const fallbackSender = packet.decrypted_info?.sender ?? null;
const fallbackRoom = packet.decrypted_info?.channel_name ?? null;
if (!inspection.decoded?.payload.decoded) {
if (!fallbackSender && !fallbackRoom) {
return null;
}
return {
title: fallbackRoom ? 'Room' : 'Context',
primary: fallbackRoom ?? 'Sender metadata available',
secondary: fallbackSender ? `Sender: ${fallbackSender}` : null,
};
}
if (inspection.decoded.payloadType === PayloadType.GroupText) {
const payload = inspection.decoded.payload.decoded as {
channelHash?: string;
cipherMac?: string;
ciphertext?: string;
decrypted?: { sender?: string; message?: string };
};
const roomName = fallbackRoom ?? resolveGroupTextRoomName(payload, groupTextCandidates);
return {
title: roomName ? 'Room' : 'Channel',
primary:
roomName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
secondary: payload.decrypted?.sender
? `Sender: ${payload.decrypted.sender}`
: fallbackSender
? `Sender: ${fallbackSender}`
: null,
};
}
if (fallbackSender) {
return {
title: 'Context',
primary: fallbackSender,
secondary: null,
};
}
return null;
}
function buildDisplayFields(inspection: ReturnType<typeof inspectRawPacketWithOptions>) {
return [
...inspection.packetFields.filter((field) => field.name !== 'Payload'),
...inspection.payloadFields,
];
}
function buildFieldColorMap(fields: PacketByteField[]) {
return new Map(
fields.map((field, index) => [field.id, FIELD_PALETTE[index % FIELD_PALETTE.length]])
);
}
function buildByteOwners(totalBytes: number, fields: PacketByteField[]) {
const owners = new Array<string | null>(totalBytes).fill(null);
for (const field of fields) {
for (let index = field.absoluteStartByte; index <= field.absoluteEndByte; index += 1) {
if (index >= 0 && index < owners.length) {
owners[index] = field.id;
}
}
}
return owners;
}
function buildByteRuns(bytes: string[], owners: Array<string | null>) {
const runs: Array<{ fieldId: string | null; text: string }> = [];
for (let index = 0; index < bytes.length; index += 1) {
const fieldId = owners[index];
const lastRun = runs[runs.length - 1];
if (lastRun && lastRun.fieldId === fieldId) {
lastRun.text += ` ${bytes[index]}`;
continue;
}
runs.push({
fieldId,
text: bytes[index],
});
}
return runs;
}
function CompactMetaCard({
label,
primary,
secondary,
}: {
label: string;
primary: string;
secondary?: string | null;
}) {
return (
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
<div className="mt-1 text-sm font-medium leading-tight text-foreground">{primary}</div>
{secondary ? (
<div className="mt-1 text-xs leading-tight text-muted-foreground">{secondary}</div>
) : null}
</div>
);
}
function FullPacketHex({
packetHex,
fields,
colorMap,
hoveredFieldId,
onHoverField,
}: {
packetHex: string;
fields: PacketByteField[];
colorMap: Map<string, FieldPaletteEntry>;
hoveredFieldId: string | null;
onHoverField: (fieldId: string | null) => void;
}) {
const normalized = packetHex.toUpperCase();
const bytes = useMemo(() => normalized.match(/.{1,2}/g) ?? [], [normalized]);
const byteOwners = useMemo(() => buildByteOwners(bytes.length, fields), [bytes.length, fields]);
const byteRuns = useMemo(() => buildByteRuns(bytes, byteOwners), [byteOwners, bytes]);
return (
<div className="font-mono text-[15px] leading-7 text-foreground">
{byteRuns.map((run, index) => {
const fieldId = run.fieldId;
const palette = fieldId ? colorMap.get(fieldId) : null;
const active = fieldId !== null && hoveredFieldId === fieldId;
return (
<span key={`${fieldId ?? 'plain'}-${index}`}>
<span
onMouseEnter={() => onHoverField(fieldId)}
onMouseLeave={() => onHoverField(null)}
className={cn(
'inline rounded-sm px-0.5 py-0.5 transition-colors',
palette ? (active ? palette.hexActive : palette.hex) : ''
)}
>
{run.text}
</span>
{index < byteRuns.length - 1 ? ' ' : ''}
</span>
);
})}
</div>
);
}
function renderFieldValue(field: PacketByteField) {
if (field.name !== 'Path Data') {
return field.value.toUpperCase();
}
const parts = field.value
.toUpperCase()
.split(' → ')
.filter((part) => part.length > 0);
if (parts.length <= 1) {
return field.value.toUpperCase();
}
return (
<span className="inline-flex flex-wrap justify-start gap-x-1 sm:justify-end">
{parts.map((part, index) => {
const isLast = index === parts.length - 1;
return (
<span key={`${field.id}-${part}-${index}`} className="whitespace-nowrap">
{isLast ? part : `${part}`}
</span>
);
})}
</span>
);
}
function FieldBox({
field,
palette,
active,
onHoverField,
}: {
field: PacketByteField;
palette: FieldPaletteEntry;
active: boolean;
onHoverField: (fieldId: string | null) => void;
}) {
return (
<div
onMouseEnter={() => onHoverField(field.id)}
onMouseLeave={() => onHoverField(null)}
className={cn(
'rounded-lg border p-2.5 transition-colors',
active ? palette.boxActive : palette.box
)}
>
<div className="flex flex-col items-start gap-2 sm:flex-row sm:justify-between">
<div className="min-w-0">
<div className="text-base font-semibold leading-tight text-foreground">{field.name}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">{formatByteRange(field)}</div>
</div>
<div
className={cn(
'w-full font-mono text-sm leading-5 text-foreground sm:max-w-[14rem] sm:text-right',
field.name === 'Path Data' ? 'break-normal' : 'break-all'
)}
>
{renderFieldValue(field)}
</div>
</div>
<div className="mt-2 whitespace-pre-wrap text-sm leading-5 text-foreground">
{field.description}
</div>
{field.decryptedMessage ? (
<div className="mt-2 rounded border border-border/50 bg-background/40 p-2">
<div className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
{field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'}
</div>
<PlaintextContent text={field.decryptedMessage} />
</div>
) : null}
{field.headerBreakdown ? (
<div className="mt-2 space-y-1.5">
<div className="font-mono text-xs tracking-[0.16em] text-muted-foreground">
{field.headerBreakdown.fullBinary}
</div>
{field.headerBreakdown.fields.map((part) => (
<div
key={`${field.id}-${part.bits}-${part.field}`}
className="rounded border border-border/50 bg-background/40 p-2"
>
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-sm font-medium leading-tight text-foreground">
{part.field}
</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">Bits {part.bits}</div>
</div>
<div className="text-right">
<div className="font-mono text-sm text-foreground">{part.binary}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">{part.value}</div>
</div>
</div>
</div>
))}
</div>
) : null}
</div>
);
}
function PlaintextContent({ text }: { text: string }) {
const lines = text.split('\n');
return (
<div className="mt-1 space-y-1 text-sm leading-5 text-foreground">
{lines.map((line, index) => {
const separatorIndex = line.indexOf(': ');
if (separatorIndex === -1) {
return (
<div key={`${line}-${index}`} className="font-mono">
{line}
</div>
);
}
const label = line.slice(0, separatorIndex + 1);
const value = line.slice(separatorIndex + 2);
return (
<div key={`${line}-${index}`}>
<span>{label} </span>
<span className="font-mono">{value}</span>
</div>
);
})}
</div>
);
}
function FieldSection({
title,
fields,
colorMap,
hoveredFieldId,
onHoverField,
}: {
title: string;
fields: PacketByteField[];
colorMap: Map<string, FieldPaletteEntry>;
hoveredFieldId: string | null;
onHoverField: (fieldId: string | null) => void;
}) {
return (
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
<div className="mb-2 text-sm font-semibold text-foreground">{title}</div>
{fields.length === 0 ? (
<div className="text-sm text-muted-foreground">No decoded fields available.</div>
) : (
<div className="grid gap-2">
{fields.map((field) => (
<FieldBox
key={field.id}
field={field}
palette={colorMap.get(field.id) ?? FIELD_PALETTE[0]}
active={hoveredFieldId === field.id}
onHoverField={onHoverField}
/>
))}
</div>
)}
</section>
);
}
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
const groupTextCandidates = useMemo(
() => buildGroupTextResolutionCandidates(channels),
[channels]
);
const inspection = useMemo(
() => (packet ? inspectRawPacketWithOptions(packet, decoderOptions) : null),
[decoderOptions, packet]
);
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
const packetDisplayFields = useMemo(
() => (inspection ? inspection.packetFields.filter((field) => field.name !== 'Payload') : []),
[inspection]
);
const fullPacketFields = useMemo(
() => (inspection ? buildDisplayFields(inspection) : []),
[inspection]
);
const colorMap = useMemo(() => buildFieldColorMap(fullPacketFields), [fullPacketFields]);
const packetContext = useMemo(
() => (packet && inspection ? getPacketContext(packet, inspection, groupTextCandidates) : null),
[groupTextCandidates, inspection, packet]
);
const packetIsDecrypted = useMemo(
() => (packet && inspection ? packetShowsDecryptedState(packet, inspection) : false),
[inspection, packet]
);
if (!packet || !inspection) {
return null;
}
return (
<Dialog open={packet !== null} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-border px-5 py-3">
<DialogTitle>Packet Details</DialogTitle>
<DialogDescription className="sr-only">
Detailed byte and field breakdown for the selected raw packet.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
Summary
</div>
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
{inspection.summary.summary}
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{formatTimestamp(packet.timestamp)}
</div>
</div>
{packetContext ? (
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
{packetContext.title}
</div>
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
{packetContext.primary}
</div>
{packetContext.secondary ? (
<div className="mt-1 text-xs leading-tight text-muted-foreground">
{packetContext.secondary}
</div>
) : null}
</div>
) : null}
</section>
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
<CompactMetaCard
label="Packet"
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
/>
<CompactMetaCard
label="Transport"
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
/>
<CompactMetaCard
label="Signal"
primary={formatSignal(packet)}
secondary={packetContext ? null : undefined}
/>
</section>
</div>
{inspection.validationErrors.length > 0 ? (
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
<div className="text-sm font-semibold text-foreground">Validation notes</div>
<div className="mt-1.5 space-y-1 text-sm text-foreground">
{inspection.validationErrors.map((error) => (
<div key={error}>{error}</div>
))}
</div>
</div>
) : null}
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
<div className="mt-2.5">
<FullPacketHex
packetHex={packet.data}
fields={fullPacketFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
</div>
</div>
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
<FieldSection
title="Packet fields"
fields={packetDisplayFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
<FieldSection
title="Payload fields"
fields={inspection.payloadFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,548 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { RawPacketList } from './RawPacketList';
import { RawPacketDetailModal } from './RawPacketDetailModal';
import type { Channel, Contact, RawPacket } from '../types';
import {
RAW_PACKET_STATS_WINDOWS,
buildRawPacketStatsSnapshot,
type NeighborStat,
type PacketTimelineBin,
type RankedPacketStat,
type RawPacketStatsSessionState,
type RawPacketStatsWindow,
} from '../utils/rawPacketStats';
import { getContactDisplayName } from '../utils/pubkey';
import { cn } from '@/lib/utils';
interface RawPacketFeedViewProps {
packets: RawPacket[];
rawPacketStatsSession: RawPacketStatsSessionState;
contacts: Contact[];
channels: Channel[];
}
const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
'1m': '1 min',
'5m': '5 min',
'10m': '10 min',
'30m': '30 min',
session: 'Session',
};
const TIMELINE_COLORS = [
'bg-sky-500/80',
'bg-emerald-500/80',
'bg-amber-500/80',
'bg-rose-500/80',
'bg-violet-500/80',
];
function formatTimestamp(timestampMs: number): string {
return new Date(timestampMs).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function formatDuration(seconds: number): string {
if (seconds < 60) {
return `${Math.max(1, Math.round(seconds))} sec`;
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainder = Math.round(seconds % 60);
return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`;
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.round((seconds % 3600) / 60);
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
}
function formatRate(value: number): string {
if (value >= 100) return value.toFixed(0);
if (value >= 10) return value.toFixed(1);
return value.toFixed(2);
}
function formatPercent(value: number): string {
return `${Math.round(value * 100)}%`;
}
function formatRssi(value: number | null): string {
return value === null ? '-' : `${Math.round(value)} dBm`;
}
function resolveContactLabel(sourceKey: string | null, contacts: Contact[]): string | null {
if (!sourceKey || sourceKey.startsWith('name:')) {
return null;
}
const normalizedSourceKey = sourceKey.toLowerCase();
const matches = contacts.filter((contact) =>
contact.public_key.toLowerCase().startsWith(normalizedSourceKey)
);
if (matches.length !== 1) {
return null;
}
const contact = matches[0];
return getContactDisplayName(contact.name, contact.public_key, contact.last_advert);
}
function resolveNeighbor(item: NeighborStat, contacts: Contact[]): NeighborStat {
return {
...item,
label: resolveContactLabel(item.key, contacts) ?? item.label,
};
}
function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]): boolean {
if (item.key.startsWith('name:')) {
return true;
}
return resolveContactLabel(item.key, contacts) !== null;
}
function formatStrongestPacketDetail(
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
contacts: Contact[]
): string | undefined {
if (!stats.strongestPacketPayloadType) {
return undefined;
}
const resolvedLabel =
resolveContactLabel(stats.strongestPacketSourceKey, contacts) ??
stats.strongestPacketSourceLabel;
if (resolvedLabel) {
return `${resolvedLabel} · ${stats.strongestPacketPayloadType}`;
}
if (stats.strongestPacketPayloadType === 'GroupText') {
return '<unknown sender> · GroupText';
}
return stats.strongestPacketPayloadType;
}
function getCoverageMessage(
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
session: RawPacketStatsSessionState
): { tone: 'default' | 'warning'; message: string } {
if (session.trimmedObservationCount > 0 && stats.window === 'session') {
return {
tone: 'warning',
message: `Detailed session history was trimmed after ${session.totalObservedPackets.toLocaleString()} observations.`,
};
}
if (!stats.windowFullyCovered) {
return {
tone: 'warning',
message: `This window is only covered for ${formatDuration(stats.coverageSeconds)} of frontend-collected history.`,
};
}
return {
tone: 'default',
message: `Tracking ${session.observations.length.toLocaleString()} detailed observations from this browser session.`,
};
}
function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) {
return (
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/80 p-3">
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-foreground">{value}</div>
{detail ? <div className="mt-1 text-xs text-muted-foreground">{detail}</div> : null}
</div>
);
}
function RankedBars({
title,
items,
emptyLabel,
formatter,
}: {
title: string;
items: RankedPacketStat[];
emptyLabel: string;
formatter?: (item: RankedPacketStat) => string;
}) {
const maxCount = Math.max(...items.map((item) => item.count), 1);
return (
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{items.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
) : (
<div className="mt-3 space-y-2">
{items.map((item) => (
<div key={item.label}>
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
<span className="truncate text-foreground">{item.label}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatter
? formatter(item)
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary/80"
style={{ width: `${(item.count / maxCount) * 100}%` }}
/>
</div>
</div>
))}
</div>
)}
</section>
);
}
function NeighborList({
title,
items,
emptyLabel,
mode,
contacts,
}: {
title: string;
items: NeighborStat[];
emptyLabel: string;
mode: 'heard' | 'signal' | 'recent';
contacts: Contact[];
}) {
return (
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{items.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
) : (
<div className="mt-3 space-y-2">
{items.map((item) => (
<div
key={item.key}
className="flex items-center justify-between gap-3 rounded-md bg-background/70 px-2 py-1.5"
>
<div className="min-w-0">
<div className="truncate text-sm text-foreground">{item.label}</div>
<div className="text-xs text-muted-foreground">
{mode === 'heard'
? `${item.count.toLocaleString()} packets`
: mode === 'signal'
? `${formatRssi(item.bestRssi)} best`
: `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
</div>
{!isNeighborIdentityResolvable(item, contacts) ? (
<div className="text-[11px] text-warning">Identity not resolvable</div>
) : null}
</div>
{mode !== 'signal' ? (
<div className="shrink-0 text-xs tabular-nums text-muted-foreground">
{mode === 'recent' ? formatRssi(item.bestRssi) : formatRssi(item.bestRssi)}
</div>
) : null}
</div>
))}
</div>
)}
</section>
);
}
function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
const maxTotal = Math.max(...bins.map((bin) => bin.total), 1);
const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice(
0,
TIMELINE_COLORS.length
);
return (
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
{typeOrder.map((type, index) => (
<span key={type} className="inline-flex items-center gap-1">
<span className={cn('h-2 w-2 rounded-full', TIMELINE_COLORS[index])} />
<span>{type}</span>
</span>
))}
</div>
</div>
<div className="mt-3 flex items-start gap-1">
{bins.map((bin, index) => (
<div
key={`${bin.label}-${index}`}
className="flex min-w-0 flex-1 flex-col items-center gap-1"
>
<div className="flex h-24 w-full items-end overflow-hidden rounded-sm bg-muted/60">
<div className="flex h-full w-full flex-col justify-end">
{typeOrder.map((type, index) => {
const count = bin.countsByType[type] ?? 0;
if (count === 0) return null;
return (
<div
key={type}
className={cn('w-full', TIMELINE_COLORS[index])}
style={{
height: `${(count / maxTotal) * 100}%`,
}}
title={`${bin.label}: ${type} ${count.toLocaleString()}`}
/>
);
})}
</div>
</div>
<div className="text-[10px] text-muted-foreground">{bin.label}</div>
</div>
))}
</div>
</section>
);
}
export function RawPacketFeedView({
packets,
rawPacketStatsSession,
contacts,
channels,
}: RawPacketFeedViewProps) {
const [statsOpen, setStatsOpen] = useState(() =>
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia('(min-width: 768px)').matches
: false
);
const [selectedWindow, setSelectedWindow] = useState<RawPacketStatsWindow>('10m');
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
useEffect(() => {
const interval = window.setInterval(() => {
setNowSec(Math.floor(Date.now() / 1000));
}, 30000);
return () => window.clearInterval(interval);
}, []);
useEffect(() => {
setNowSec(Math.floor(Date.now() / 1000));
}, [packets, rawPacketStatsSession]);
const stats = useMemo(
() => buildRawPacketStatsSnapshot(rawPacketStatsSession, selectedWindow, nowSec),
[nowSec, rawPacketStatsSession, selectedWindow]
);
const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession);
const strongestPacketDetail = useMemo(
() => formatStrongestPacketDetail(stats, contacts),
[contacts, stats]
);
const strongestNeighbors = useMemo(
() => stats.strongestNeighbors.map((item) => resolveNeighbor(item, contacts)),
[contacts, stats.strongestNeighbors]
);
const mostActiveNeighbors = useMemo(
() => stats.mostActiveNeighbors.map((item) => resolveNeighbor(item, contacts)),
[contacts, stats.mostActiveNeighbors]
);
const newestNeighbors = useMemo(
() => stats.newestNeighbors.map((item) => resolveNeighbor(item, contacts)),
[contacts, stats.newestNeighbors]
);
return (
<>
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
<div>
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
<p className="text-xs text-muted-foreground">
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
</p>
</div>
<button
type="button"
onClick={() => setStatsOpen((current) => !current)}
aria-expanded={statsOpen}
className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
{statsOpen ? 'Hide Stats' : 'Show Stats'}
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<div className={cn('min-h-0 min-w-0 flex-1', statsOpen && 'md:border-r md:border-border')}>
<RawPacketList packets={packets} channels={channels} onPacketClick={setSelectedPacket} />
</div>
<aside
className={cn(
'shrink-0 overflow-hidden border-t border-border transition-all duration-300 md:border-l md:border-t-0',
statsOpen
? 'max-h-[42rem] md:max-h-none md:w-1/2 md:min-w-[30rem]'
: 'max-h-0 md:w-0 md:min-w-0 border-transparent'
)}
>
{statsOpen ? (
<div className="h-full overflow-y-auto bg-background p-4 [contain:layout_paint]">
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">
Coverage
</div>
<div
className={cn(
'mt-1 text-sm',
coverageMessage.tone === 'warning'
? 'text-warning'
: 'text-muted-foreground'
)}
>
{coverageMessage.message}
</div>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<span className="text-muted-foreground">Window</span>
<select
value={selectedWindow}
onChange={(event) =>
setSelectedWindow(event.target.value as RawPacketStatsWindow)
}
className="rounded-md border border-input bg-background px-2 py-1 text-sm"
aria-label="Stats window"
>
{RAW_PACKET_STATS_WINDOWS.map((option) => (
<option key={option} value={option}>
{WINDOW_LABELS[option]}
</option>
))}
</select>
</label>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{stats.packetCount.toLocaleString()} packets in{' '}
{WINDOW_LABELS[selectedWindow].toLowerCase()} window
{' · '}
{rawPacketStatsSession.totalObservedPackets.toLocaleString()} observed this
session
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-3">
<StatTile
label="Packets / min"
value={formatRate(stats.packetsPerMinute)}
detail={`${stats.packetCount.toLocaleString()} total in window`}
/>
<StatTile
label="Unique Sources"
value={stats.uniqueSources.toLocaleString()}
detail="Distinct identified senders"
/>
<StatTile
label="Decrypt Rate"
value={formatPercent(stats.decryptRate)}
detail={`${stats.decryptedCount.toLocaleString()} decrypted / ${stats.undecryptedCount.toLocaleString()} locked`}
/>
<StatTile
label="Path Diversity"
value={stats.distinctPaths.toLocaleString()}
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
/>
<StatTile
label="Best RSSI"
value={formatRssi(stats.bestRssi)}
detail={strongestPacketDetail ?? 'No signal sample in window'}
/>
<StatTile
label="Median RSSI"
value={formatRssi(stats.medianRssi)}
detail={
stats.averageRssi === null
? 'No signal sample in window'
: `Average ${formatRssi(stats.averageRssi)}`
}
/>
</div>
<div className="mt-4">
<TimelineChart bins={stats.timeline} />
</div>
<div className="md:columns-2 md:gap-4">
<RankedBars
title="Packet Types"
items={stats.payloadBreakdown}
emptyLabel="No packets in this window yet."
/>
<RankedBars
title="Route Mix"
items={stats.routeBreakdown}
emptyLabel="No packets in this window yet."
/>
<RankedBars
title="Hop Profile"
items={stats.hopProfile}
emptyLabel="No packets in this window yet."
/>
<RankedBars
title="Hop Byte Width"
items={stats.hopByteWidthProfile}
emptyLabel="No packets in this window yet."
/>
<RankedBars
title="Signal Distribution"
items={stats.rssiBuckets}
emptyLabel="No RSSI samples in this window yet."
/>
<NeighborList
title="Most-Heard Neighbors"
items={mostActiveNeighbors}
emptyLabel="No sender identities resolved in this window yet."
mode="heard"
contacts={contacts}
/>
<NeighborList
title="Strongest Recent Neighbors"
items={strongestNeighbors}
emptyLabel="No RSSI-tagged neighbors in this window yet."
mode="signal"
contacts={contacts}
/>
<NeighborList
title="Newest Heard Neighbors"
items={newestNeighbors}
emptyLabel="No newly identified neighbors in this window yet."
mode="recent"
contacts={contacts}
/>
</div>
</div>
) : null}
</aside>
</div>
<RawPacketDetailModal
packet={selectedPacket}
channels={channels}
onClose={() => setSelectedPacket(null)}
/>
</>
);
}

View File

@@ -1,11 +1,13 @@
import { useEffect, useRef, useMemo } from 'react';
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
import type { RawPacket } from '../types';
import type { Channel, RawPacket } from '../types';
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
import { createDecoderOptions, decodePacketSummary } from '../utils/rawPacketInspector';
import { cn } from '@/lib/utils';
interface RawPacketListProps {
packets: RawPacket[];
channels?: Channel[];
onPacketClick?: (packet: RawPacket) => void;
}
function formatTime(timestamp: number): string {
@@ -24,132 +26,6 @@ function formatSignalInfo(packet: RawPacket): string {
return parts.join(' | ');
}
// Decrypted info from the packet (validated by backend)
interface DecryptedInfo {
channel_name: string | null;
sender: string | null;
}
// Decode a packet and generate a human-readable summary
// Uses backend's decrypted_info when available (validated), falls back to decoder
function decodePacketSummary(
hexData: string,
decryptedInfo: DecryptedInfo | null
): {
summary: string;
routeType: string;
details?: string;
} {
try {
const decoded = MeshCoreDecoder.decode(hexData);
if (!decoded.isValid) {
return { summary: 'Invalid packet', routeType: 'Unknown' };
}
const routeType = Utils.getRouteTypeName(decoded.routeType);
const payloadTypeName = Utils.getPayloadTypeName(decoded.payloadType);
const tracePayload =
decoded.payloadType === PayloadType.Trace && decoded.payload.decoded
? (decoded.payload.decoded as { pathHashes?: string[] })
: null;
const pathTokens = tracePayload?.pathHashes || decoded.path || [];
// Build path string if available
const pathStr = pathTokens.length > 0 ? ` via ${pathTokens.join('-')}` : '';
// Generate summary based on payload type
let summary = payloadTypeName;
let details: string | undefined;
switch (decoded.payloadType) {
case PayloadType.TextMessage: {
const payload = decoded.payload.decoded as {
destinationHash?: string;
sourceHash?: string;
} | null;
if (payload?.sourceHash && payload?.destinationHash) {
summary = `DM from ${payload.sourceHash} to ${payload.destinationHash}${pathStr}`;
} else {
summary = `DM${pathStr}`;
}
break;
}
case PayloadType.GroupText: {
const payload = decoded.payload.decoded as {
channelHash?: string;
} | null;
// Use backend's validated decrypted_info when available
if (decryptedInfo?.channel_name) {
if (decryptedInfo.sender) {
summary = `GT from ${decryptedInfo.sender} in ${decryptedInfo.channel_name}${pathStr}`;
} else {
summary = `GT in ${decryptedInfo.channel_name}${pathStr}`;
}
} else if (payload?.channelHash) {
// Fallback to showing channel hash when not decrypted
summary = `GT ch:${payload.channelHash}${pathStr}`;
} else {
summary = `GroupText${pathStr}`;
}
break;
}
case PayloadType.Advert: {
const payload = decoded.payload.decoded as {
publicKey?: string;
appData?: { name?: string; deviceRole?: number };
} | null;
if (payload?.appData?.name) {
const role =
payload.appData.deviceRole !== undefined
? Utils.getDeviceRoleName(payload.appData.deviceRole)
: '';
summary = `Advert: ${payload.appData.name}${role ? ` (${role})` : ''}${pathStr}`;
} else if (payload?.publicKey) {
summary = `Advert: ${payload.publicKey.slice(0, 8)}...${pathStr}`;
} else {
summary = `Advert${pathStr}`;
}
break;
}
case PayloadType.Ack: {
summary = `ACK${pathStr}`;
break;
}
case PayloadType.Request: {
summary = `Request${pathStr}`;
break;
}
case PayloadType.Response: {
summary = `Response${pathStr}`;
break;
}
case PayloadType.Trace: {
summary = `Trace${pathStr}`;
break;
}
case PayloadType.Path: {
summary = `Path${pathStr}`;
break;
}
default:
summary = `${payloadTypeName}${pathStr}`;
}
return { summary, routeType, details };
} catch {
return { summary: 'Decode error', routeType: 'Unknown' };
}
}
// Get route type badge color
function getRouteTypeColor(routeType: string): string {
switch (routeType) {
@@ -182,16 +58,17 @@ function getRouteTypeLabel(routeType: string): string {
}
}
export function RawPacketList({ packets }: RawPacketListProps) {
export function RawPacketList({ packets, channels, onPacketClick }: RawPacketListProps) {
const listRef = useRef<HTMLDivElement>(null);
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
// Decode all packets (memoized to avoid re-decoding on every render)
const decodedPackets = useMemo(() => {
return packets.map((packet) => ({
packet,
decoded: decodePacketSummary(packet.data, packet.decrypted_info),
decoded: decodePacketSummary(packet, decoderOptions),
}));
}, [packets]);
}, [decoderOptions, packets]);
// Sort packets by timestamp ascending (oldest first)
const sortedPackets = useMemo(
@@ -218,54 +95,78 @@ export function RawPacketList({ packets }: RawPacketListProps) {
className="h-full overflow-y-auto p-4 flex flex-col gap-2 [contain:layout_paint]"
ref={listRef}
>
{sortedPackets.map(({ packet, decoded }) => (
<div
key={getRawPacketObservationKey(packet)}
className="py-2 px-3 bg-card rounded-md border border-border/50"
>
<div className="flex items-center gap-2">
{/* Route type badge */}
<span
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
title={decoded.routeType}
>
{getRouteTypeLabel(decoded.routeType)}
</span>
{sortedPackets.map(({ packet, decoded }) => {
const cardContent = (
<>
<div className="flex items-center gap-2">
{/* Route type badge */}
<span
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
title={decoded.routeType}
>
{getRouteTypeLabel(decoded.routeType)}
</span>
{/* Encryption status */}
{!packet.decrypted && (
<>
<span aria-hidden="true">🔒</span>
<span className="sr-only">Encrypted</span>
</>
{/* Encryption status */}
{!packet.decrypted && (
<>
<span aria-hidden="true">🔒</span>
<span className="sr-only">Encrypted</span>
</>
)}
{/* Summary */}
<span
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
>
{decoded.summary}
</span>
{/* Time */}
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
{formatTime(packet.timestamp)}
</span>
</div>
{/* Signal info */}
{(packet.snr !== null || packet.rssi !== null) && (
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
{formatSignalInfo(packet)}
</div>
)}
{/* Summary */}
<span
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
>
{decoded.summary}
</span>
{/* Time */}
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
{formatTime(packet.timestamp)}
</span>
</div>
{/* Signal info */}
{(packet.snr !== null || packet.rssi !== null) && (
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
{formatSignalInfo(packet)}
{/* Raw hex data (always visible) */}
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
{packet.data.toUpperCase()}
</div>
)}
</>
);
{/* Raw hex data (always visible) */}
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
{packet.data.toUpperCase()}
const className = cn(
'rounded-md border border-border/50 bg-card px-3 py-2 text-left',
onPacketClick &&
'cursor-pointer transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
);
if (onPacketClick) {
return (
<button
key={getRawPacketObservationKey(packet)}
type="button"
onClick={() => onPacketClick(packet)}
className={className}
>
{cardContent}
</button>
);
}
return (
<div key={getRawPacketObservationKey(packet)} className={className}>
{cardContent}
</div>
</div>
))}
);
})}
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { Button } from './ui/button';
import { Bell, Route, Star, Trash2 } from 'lucide-react';
import { DirectTraceIcon } from './DirectTraceIcon';
import { RepeaterLogin } from './RepeaterLogin';
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
@@ -81,8 +82,18 @@ export function RepeaterDashboard({
rebootRepeater,
syncClock,
} = useRepeaterDashboard(conversation, { hasAdvertLocation });
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
useRememberedServerPassword('repeater', conversation.id);
const isFav = isFavorite(favorites, 'contact', conversation.id);
const handleRepeaterLogin = async (nextPassword: string) => {
await login(nextPassword);
persistAfterLogin(nextPassword);
};
const handleRepeaterGuestLogin = async () => {
await loginAsGuest();
persistAfterLogin('');
};
// Loading all panes indicator
const anyLoading = Object.values(paneStates).some((s) => s.loading);
@@ -221,8 +232,12 @@ export function RepeaterDashboard({
repeaterName={conversation.name}
loading={loginLoading}
error={loginError}
onLogin={login}
onLoginAsGuest={loginAsGuest}
password={password}
onPasswordChange={setPassword}
rememberPassword={rememberPassword}
onRememberPasswordChange={setRememberPassword}
onLogin={handleRepeaterLogin}
onLoginAsGuest={handleRepeaterGuestLogin}
/>
) : (
<div className="space-y-4">

View File

@@ -1,24 +1,39 @@
import { useState, useCallback, type FormEvent } from 'react';
import { useCallback, type FormEvent } from 'react';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { Checkbox } from './ui/checkbox';
interface RepeaterLoginProps {
repeaterName: string;
loading: boolean;
error: string | null;
password: string;
onPasswordChange: (password: string) => void;
rememberPassword: boolean;
onRememberPasswordChange: (checked: boolean) => void;
onLogin: (password: string) => Promise<void>;
onLoginAsGuest: () => Promise<void>;
description?: string;
passwordPlaceholder?: string;
loginLabel?: string;
guestLabel?: string;
}
export function RepeaterLogin({
repeaterName,
loading,
error,
password,
onPasswordChange,
rememberPassword,
onRememberPasswordChange,
onLogin,
onLoginAsGuest,
description = 'Log in to access repeater dashboard',
passwordPlaceholder = 'Repeater password...',
loginLabel = 'Login with Password',
guestLabel = 'Login as Guest / ACLs',
}: RepeaterLoginProps) {
const [password, setPassword] = useState('');
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
@@ -33,7 +48,7 @@ export function RepeaterLogin({
<div className="w-full max-w-sm space-y-6">
<div className="text-center space-y-1">
<h2 className="text-lg font-semibold">{repeaterName}</h2>
<p className="text-sm text-muted-foreground">Log in to access repeater dashboard</p>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
@@ -45,13 +60,34 @@ export function RepeaterLogin({
data-1p-ignore="true"
data-bwignore="true"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Repeater password..."
onChange={(e) => onPasswordChange(e.target.value)}
placeholder={passwordPlaceholder}
aria-label="Repeater password"
disabled={loading}
autoFocus
/>
<label
htmlFor="remember-server-password"
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<Checkbox
id="remember-server-password"
checked={rememberPassword}
disabled={loading}
onCheckedChange={(checked) => onRememberPasswordChange(checked === true)}
/>
<span>Remember password</span>
</label>
{rememberPassword && (
<p className="text-xs text-muted-foreground">
Passwords are stored unencrypted in local browser storage for this domain. It is
highly recommended to login via ACLs after your first successful login; saving the
password is not recommended.
</p>
)}
{error && (
<p className="text-sm text-destructive text-center" role="alert">
{error}
@@ -60,7 +96,7 @@ export function RepeaterLogin({
<div className="flex flex-col gap-2">
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Logging in...' : 'Login with Password'}
{loading ? 'Logging in...' : loginLabel}
</Button>
<Button
type="button"
@@ -69,7 +105,7 @@ export function RepeaterLogin({
className="w-full"
onClick={onLoginAsGuest}
>
Login as Guest / ACLs
{guestLabel}
</Button>
</div>
</form>

View File

@@ -0,0 +1,312 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../api';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import type {
Contact,
PaneState,
RepeaterAclResponse,
RepeaterLppTelemetryResponse,
RepeaterStatusResponse,
} from '../types';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { AclPane } from './repeater/RepeaterAclPane';
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { ConsolePane } from './repeater/RepeaterConsolePane';
import { RepeaterLogin } from './RepeaterLogin';
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
interface RoomServerPanelProps {
contact: Contact;
onAuthenticatedChange?: (authenticated: boolean) => void;
}
type RoomPaneKey = 'status' | 'acl' | 'lppTelemetry';
type RoomPaneData = {
status: RepeaterStatusResponse | null;
acl: RepeaterAclResponse | null;
lppTelemetry: RepeaterLppTelemetryResponse | null;
};
type RoomPaneStates = Record<RoomPaneKey, PaneState>;
type ConsoleEntry = {
command: string;
response: string;
timestamp: number;
outgoing: boolean;
};
const INITIAL_PANE_STATE: PaneState = {
loading: false,
attempt: 0,
error: null,
fetched_at: null,
};
function createInitialPaneStates(): RoomPaneStates {
return {
status: { ...INITIAL_PANE_STATE },
acl: { ...INITIAL_PANE_STATE },
lppTelemetry: { ...INITIAL_PANE_STATE },
};
}
export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPanelProps) {
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
useRememberedServerPassword('room', contact.public_key);
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [loginMessage, setLoginMessage] = useState<string | null>(null);
const [authenticated, setAuthenticated] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [paneData, setPaneData] = useState<RoomPaneData>({
status: null,
acl: null,
lppTelemetry: null,
});
const [paneStates, setPaneStates] = useState<RoomPaneStates>(createInitialPaneStates);
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
const [consoleLoading, setConsoleLoading] = useState(false);
useEffect(() => {
setLoginLoading(false);
setLoginError(null);
setLoginMessage(null);
setAuthenticated(false);
setAdvancedOpen(false);
setPaneData({
status: null,
acl: null,
lppTelemetry: null,
});
setPaneStates(createInitialPaneStates());
setConsoleHistory([]);
setConsoleLoading(false);
}, [contact.public_key]);
useEffect(() => {
onAuthenticatedChange?.(authenticated);
}, [authenticated, onAuthenticatedChange]);
const refreshPane = useCallback(
async <K extends RoomPaneKey>(pane: K, loader: () => Promise<RoomPaneData[K]>) => {
setPaneStates((prev) => ({
...prev,
[pane]: {
...prev[pane],
loading: true,
attempt: prev[pane].attempt + 1,
error: null,
},
}));
try {
const data = await loader();
setPaneData((prev) => ({ ...prev, [pane]: data }));
setPaneStates((prev) => ({
...prev,
[pane]: {
loading: false,
attempt: prev[pane].attempt,
error: null,
fetched_at: Date.now(),
},
}));
} catch (err) {
setPaneStates((prev) => ({
...prev,
[pane]: {
...prev[pane],
loading: false,
error: err instanceof Error ? err.message : 'Unknown error',
},
}));
}
},
[]
);
const performLogin = useCallback(
async (password: string) => {
if (loginLoading) return;
setLoginLoading(true);
setLoginError(null);
setLoginMessage(null);
try {
const result = await api.roomLogin(contact.public_key, password);
setAuthenticated(true);
setLoginMessage(
result.message ??
(result.authenticated
? 'Login confirmed. You can now send room messages and open admin tools.'
: 'Login request sent, but authentication was not confirmed.')
);
if (result.authenticated) {
toast.success('Room login confirmed');
} else {
toast(result.message ?? 'Room login was not confirmed');
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setAuthenticated(true);
setLoginError(message);
toast.error('Room login failed', { description: message });
} finally {
setLoginLoading(false);
}
},
[contact.public_key, loginLoading]
);
const handleLogin = useCallback(
async (password: string) => {
await performLogin(password);
persistAfterLogin(password);
},
[performLogin, persistAfterLogin]
);
const handleLoginAsGuest = useCallback(async () => {
await performLogin('');
persistAfterLogin('');
}, [performLogin, persistAfterLogin]);
const handleConsoleCommand = useCallback(
async (command: string) => {
setConsoleLoading(true);
const timestamp = Date.now();
setConsoleHistory((prev) => [
...prev,
{ command, response: command, timestamp, outgoing: true },
]);
try {
const response = await api.sendRepeaterCommand(contact.public_key, command);
setConsoleHistory((prev) => [
...prev,
{
command,
response: response.response,
timestamp: Date.now(),
outgoing: false,
},
]);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setConsoleHistory((prev) => [
...prev,
{
command,
response: `(error) ${message}`,
timestamp: Date.now(),
outgoing: false,
},
]);
} finally {
setConsoleLoading(false);
}
},
[contact.public_key]
);
const panelTitle = useMemo(() => contact.name || contact.public_key.slice(0, 12), [contact]);
if (!authenticated) {
return (
<div className="flex-1 overflow-y-auto p-4">
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
<div className="rounded-md border border-warning/30 bg-warning/10 px-4 py-3 text-sm text-warning">
Room server access is experimental and in public alpha. Please report any issues on{' '}
<a
href="https://github.com/jkingsman/Remote-Terminal-for-MeshCore/issues"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-2 hover:text-warning/80"
>
GitHub
</a>
.
</div>
<RepeaterLogin
repeaterName={panelTitle}
loading={loginLoading}
error={loginError}
password={password}
onPasswordChange={setPassword}
rememberPassword={rememberPassword}
onRememberPasswordChange={setRememberPassword}
onLogin={handleLogin}
onLoginAsGuest={handleLoginAsGuest}
description="Log in with the room password or use ACL/guest access to enter this room server"
passwordPlaceholder="Room server password..."
guestLabel="Login with ACL / Guest"
/>
</div>
</div>
);
}
return (
<section className="border-b border-border bg-muted/20 px-4 py-3">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">Room Server Controls</div>
<p className="text-xs text-muted-foreground">
Room access is active. Use the chat history and message box below to participate, and
open admin tools when needed.
</p>
{loginMessage && <p className="text-xs text-muted-foreground">{loginMessage}</p>}
</div>
<div className="flex w-full flex-col gap-2 sm:flex-row lg:w-auto">
<Button
type="button"
variant="outline"
onClick={handleLoginAsGuest}
disabled={loginLoading}
>
Refresh ACL Login
</Button>
<Button
type="button"
variant="outline"
onClick={() => setAdvancedOpen((prev) => !prev)}
>
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
</Button>
</div>
</div>
{advancedOpen && (
<div className="grid gap-3 xl:grid-cols-2">
<TelemetryPane
data={paneData.status}
state={paneStates.status}
onRefresh={() => refreshPane('status', () => api.roomStatus(contact.public_key))}
/>
<AclPane
data={paneData.acl}
state={paneStates.acl}
onRefresh={() => refreshPane('acl', () => api.roomAcl(contact.public_key))}
/>
<LppTelemetryPane
data={paneData.lppTelemetry}
state={paneStates.lppTelemetry}
onRefresh={() =>
refreshPane('lppTelemetry', () => api.roomLppTelemetry(contact.public_key))
}
/>
<ConsolePane
history={consoleHistory}
loading={consoleLoading}
onSend={handleConsoleCommand}
/>
</div>
)}
</div>
</section>
);
}

View File

@@ -13,6 +13,7 @@ import {
X,
} from 'lucide-react';
import {
CONTACT_TYPE_ROOM,
CONTACT_TYPE_REPEATER,
type Contact,
type Channel,
@@ -57,6 +58,7 @@ type CollapseState = {
favorites: boolean;
channels: boolean;
contacts: boolean;
rooms: boolean;
repeaters: boolean;
};
@@ -67,6 +69,7 @@ const DEFAULT_COLLAPSE_STATE: CollapseState = {
favorites: false,
channels: false,
contacts: false,
rooms: false,
repeaters: false,
};
@@ -80,6 +83,7 @@ function loadCollapsedState(): CollapseState {
favorites: parsed.favorites ?? DEFAULT_COLLAPSE_STATE.favorites,
channels: parsed.channels ?? DEFAULT_COLLAPSE_STATE.channels,
contacts: parsed.contacts ?? DEFAULT_COLLAPSE_STATE.contacts,
rooms: parsed.rooms ?? DEFAULT_COLLAPSE_STATE.rooms,
repeaters: parsed.repeaters ?? DEFAULT_COLLAPSE_STATE.repeaters,
};
} catch {
@@ -157,6 +161,7 @@ export function Sidebar({
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
const [channelsCollapsed, setChannelsCollapsed] = useState(initialCollapsedState.channels);
const [contactsCollapsed, setContactsCollapsed] = useState(initialCollapsedState.contacts);
const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms);
const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters);
const collapseSnapshotRef = useRef<CollapseState | null>(null);
const sectionSortSourceRef = useRef(initialSectionSortState.source);
@@ -314,16 +319,61 @@ export function Sidebar({
[getContactHeardTime]
);
const getFavoriteItemName = useCallback(
(item: FavoriteItem) =>
item.type === 'channel'
? item.channel.name
: getContactDisplayName(
item.contact.name,
item.contact.public_key,
item.contact.last_advert
),
[]
);
const sortFavoriteItemsByOrder = useCallback(
(items: FavoriteItem[], order: SortOrder) =>
[...items].sort((a, b) => {
if (order === 'recent') {
const timeA =
a.type === 'channel'
? getLastMessageTime('channel', a.channel.key)
: getContactRecentTime(a.contact);
const timeB =
b.type === 'channel'
? getLastMessageTime('channel', b.channel.key)
: getContactRecentTime(b.contact);
if (timeA && timeB) return timeB - timeA;
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
}
return getFavoriteItemName(a).localeCompare(getFavoriteItemName(b));
}),
[getContactRecentTime, getFavoriteItemName, getLastMessageTime]
);
// Split non-repeater contacts and repeater contacts into separate sorted lists
const sortedNonRepeaterContacts = useMemo(
() =>
sortContactsByOrder(
uniqueContacts.filter((c) => c.type !== CONTACT_TYPE_REPEATER),
uniqueContacts.filter(
(c) => c.type !== CONTACT_TYPE_REPEATER && c.type !== CONTACT_TYPE_ROOM
),
sectionSortOrders.contacts
),
[uniqueContacts, sectionSortOrders.contacts, sortContactsByOrder]
);
const sortedRooms = useMemo(
() =>
sortContactsByOrder(
uniqueContacts.filter((c) => c.type === CONTACT_TYPE_ROOM),
sectionSortOrders.rooms
),
[uniqueContacts, sectionSortOrders.rooms, sortContactsByOrder]
);
const sortedRepeaters = useMemo(
() =>
sortRepeatersByOrder(
@@ -358,6 +408,17 @@ export function Sidebar({
[sortedNonRepeaterContacts, query]
);
const filteredRooms = useMemo(
() =>
query
? sortedRooms.filter(
(c) =>
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: sortedRooms,
[sortedRooms, query]
);
const filteredRepeaters = useMemo(
() =>
query
@@ -378,6 +439,7 @@ export function Sidebar({
favorites: favoritesCollapsed,
channels: channelsCollapsed,
contacts: contactsCollapsed,
rooms: roomsCollapsed,
repeaters: repeatersCollapsed,
};
}
@@ -387,12 +449,14 @@ export function Sidebar({
favoritesCollapsed ||
channelsCollapsed ||
contactsCollapsed ||
roomsCollapsed ||
repeatersCollapsed
) {
setToolsCollapsed(false);
setFavoritesCollapsed(false);
setChannelsCollapsed(false);
setContactsCollapsed(false);
setRoomsCollapsed(false);
setRepeatersCollapsed(false);
}
return;
@@ -405,6 +469,7 @@ export function Sidebar({
setFavoritesCollapsed(prev.favorites);
setChannelsCollapsed(prev.channels);
setContactsCollapsed(prev.contacts);
setRoomsCollapsed(prev.rooms);
setRepeatersCollapsed(prev.repeaters);
}
}, [
@@ -413,6 +478,7 @@ export function Sidebar({
favoritesCollapsed,
channelsCollapsed,
contactsCollapsed,
roomsCollapsed,
repeatersCollapsed,
]);
@@ -424,6 +490,7 @@ export function Sidebar({
favorites: favoritesCollapsed,
channels: channelsCollapsed,
contacts: contactsCollapsed,
rooms: roomsCollapsed,
repeaters: repeatersCollapsed,
};
@@ -438,62 +505,56 @@ export function Sidebar({
favoritesCollapsed,
channelsCollapsed,
contactsCollapsed,
roomsCollapsed,
repeatersCollapsed,
]);
// Separate favorites from regular items, and build combined favorites list
const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts, nonFavoriteRepeaters } =
useMemo(() => {
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
const favContacts = [...filteredNonRepeaterContacts, ...filteredRepeaters].filter((c) =>
isFavorite(favorites, 'contact', c.public_key)
);
const nonFavChannels = filteredChannels.filter(
(c) => !isFavorite(favorites, 'channel', c.key)
);
const nonFavContacts = filteredNonRepeaterContacts.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
const nonFavRepeaters = filteredRepeaters.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
const {
favoriteItems,
nonFavoriteChannels,
nonFavoriteContacts,
nonFavoriteRooms,
nonFavoriteRepeaters,
} = useMemo(() => {
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
const favContacts = [
...filteredNonRepeaterContacts,
...filteredRooms,
...filteredRepeaters,
].filter((c) => isFavorite(favorites, 'contact', c.public_key));
const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key));
const nonFavContacts = filteredNonRepeaterContacts.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
const nonFavRooms = filteredRooms.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
const nonFavRepeaters = filteredRepeaters.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
const items: FavoriteItem[] = [
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
].sort((a, b) => {
const timeA =
a.type === 'channel'
? getLastMessageTime('channel', a.channel.key)
: getContactRecentTime(a.contact);
const timeB =
b.type === 'channel'
? getLastMessageTime('channel', b.channel.key)
: getContactRecentTime(b.contact);
if (timeA && timeB) return timeB - timeA;
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
const nameA =
a.type === 'channel' ? a.channel.name : a.contact.name || a.contact.public_key;
const nameB =
b.type === 'channel' ? b.channel.name : b.contact.name || b.contact.public_key;
return nameA.localeCompare(nameB);
});
const items: FavoriteItem[] = [
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
];
return {
favoriteItems: items,
nonFavoriteChannels: nonFavChannels,
nonFavoriteContacts: nonFavContacts,
nonFavoriteRepeaters: nonFavRepeaters,
};
}, [
filteredChannels,
filteredNonRepeaterContacts,
filteredRepeaters,
favorites,
getContactRecentTime,
getLastMessageTime,
]);
return {
favoriteItems: sortFavoriteItemsByOrder(items, sectionSortOrders.favorites),
nonFavoriteChannels: nonFavChannels,
nonFavoriteContacts: nonFavContacts,
nonFavoriteRooms: nonFavRooms,
nonFavoriteRepeaters: nonFavRepeaters,
};
}, [
filteredChannels,
filteredNonRepeaterContacts,
filteredRooms,
filteredRepeaters,
favorites,
sectionSortOrders.favorites,
sortFavoriteItemsByOrder,
]);
const buildChannelRow = (channel: Channel, keyPrefix: string): ConversationRow => ({
key: `${keyPrefix}-${channel.key}`,
@@ -517,57 +578,65 @@ export function Sidebar({
contact,
});
const renderConversationRow = (row: ConversationRow) => (
<div
key={row.key}
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive(row.type, row.id) && 'bg-accent border-l-primary',
row.unreadCount > 0 && '[&_.name]:font-semibold [&_.name]:text-foreground'
)}
role="button"
tabIndex={0}
aria-current={isActive(row.type, row.id) ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: row.type,
id: row.id,
name: row.name,
})
}
>
{row.type === 'contact' && row.contact && (
<ContactAvatar
name={row.contact.name}
publicKey={row.contact.public_key}
size={24}
contactType={row.contact.type}
/>
)}
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
<span className="ml-auto flex items-center gap-1">
{row.notificationsEnabled && (
<span aria-label="Notifications enabled" title="Notifications enabled">
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
</span>
const renderConversationRow = (row: ConversationRow) => {
const highlightUnread =
row.isMention ||
(row.type === 'contact' &&
row.contact?.type !== CONTACT_TYPE_REPEATER &&
row.unreadCount > 0);
return (
<div
key={row.key}
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive(row.type, row.id) && 'bg-accent border-l-primary',
row.unreadCount > 0 && '[&_.name]:font-semibold [&_.name]:text-foreground'
)}
{row.unreadCount > 0 && (
<span
className={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
row.isMention
? 'bg-badge-mention text-badge-mention-foreground'
: 'bg-badge-unread/90 text-badge-unread-foreground'
)}
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
>
{row.unreadCount}
</span>
role="button"
tabIndex={0}
aria-current={isActive(row.type, row.id) ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: row.type,
id: row.id,
name: row.name,
})
}
>
{row.type === 'contact' && row.contact && (
<ContactAvatar
name={row.contact.name}
publicKey={row.contact.public_key}
size={24}
contactType={row.contact.type}
/>
)}
</span>
</div>
);
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
<span className="ml-auto flex items-center gap-1">
{row.notificationsEnabled && (
<span aria-label="Notifications enabled" title="Notifications enabled">
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
</span>
)}
{row.unreadCount > 0 && (
<span
className={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
highlightUnread
? 'bg-badge-mention text-badge-mention-foreground'
: 'bg-badge-unread/90 text-badge-unread-foreground'
)}
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
>
{row.unreadCount}
</span>
)}
</span>
</div>
);
};
const renderSidebarActionRow = ({
key,
@@ -613,11 +682,13 @@ export function Sidebar({
);
const channelRows = nonFavoriteChannels.map((channel) => buildChannelRow(channel, 'chan'));
const contactRows = nonFavoriteContacts.map((contact) => buildContactRow(contact, 'contact'));
const roomRows = nonFavoriteRooms.map((contact) => buildContactRow(contact, 'room'));
const repeaterRows = nonFavoriteRepeaters.map((contact) => buildContactRow(contact, 'repeater'));
const favoritesUnreadCount = getSectionUnreadCount(favoriteRows);
const channelsUnreadCount = getSectionUnreadCount(channelRows);
const contactsUnreadCount = getSectionUnreadCount(contactRows);
const roomsUnreadCount = getSectionUnreadCount(roomRows);
const repeatersUnreadCount = getSectionUnreadCount(repeaterRows);
const favoritesHasMention = sectionHasMention(favoriteRows);
const channelsHasMention = sectionHasMention(channelRows);
@@ -833,7 +904,7 @@ export function Sidebar({
'Favorites',
favoritesCollapsed,
() => setFavoritesCollapsed((prev) => !prev),
null,
'favorites',
favoritesUnreadCount,
favoritesHasMention
)}
@@ -874,6 +945,21 @@ export function Sidebar({
</>
)}
{/* Room Servers */}
{nonFavoriteRooms.length > 0 && (
<>
{renderSectionHeader(
'Room Servers',
roomsCollapsed,
() => setRoomsCollapsed((prev) => !prev),
'rooms',
roomsUnreadCount,
roomsUnreadCount > 0
)}
{(isSearching || !roomsCollapsed) && roomRows.map((row) => renderConversationRow(row))}
</>
)}
{/* Repeaters */}
{nonFavoriteRepeaters.length > 0 && (
<>
@@ -891,6 +977,7 @@ export function Sidebar({
{/* Empty state */}
{nonFavoriteContacts.length === 0 &&
nonFavoriteRooms.length === 0 &&
nonFavoriteChannels.length === 0 &&
nonFavoriteRepeaters.length === 0 &&
favoriteItems.length === 0 && (

View File

@@ -2,6 +2,7 @@ import { useMemo, lazy, Suspense } from 'react';
import { cn } from '@/lib/utils';
import { RepeaterPane, NotFetched, formatDuration } from './repeaterPaneShared';
import { isValidLocation, calculateDistance, formatDistance } from '../../utils/pathUtils';
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
import type {
Contact,
RepeaterNeighborsResponse,
@@ -35,6 +36,7 @@ export function NeighborsPane({
nodeInfoState: PaneState;
repeaterName: string | null;
}) {
const { distanceUnit } = useDistanceUnit();
const advertLat = repeaterContact?.lat ?? null;
const advertLon = repeaterContact?.lon ?? null;
@@ -93,7 +95,7 @@ export function NeighborsPane({
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
const distKm = calculateDistance(positionSource.lat, positionSource.lon, nLat, nLon);
if (distKm != null) {
dist = formatDistance(distKm);
dist = formatDistance(distKm, distanceUnit);
anyDist = true;
}
}
@@ -111,7 +113,7 @@ export function NeighborsPane({
sorted: enriched,
hasDistances: anyDist,
};
}, [contacts, data, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
}, [contacts, data, distanceUnit, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
return (
<RepeaterPane

View File

@@ -11,6 +11,12 @@ import {
} from '../../utils/lastViewedConversation';
import { ThemeSelector } from './ThemeSelector';
import { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel';
import {
DISTANCE_UNIT_LABELS,
DISTANCE_UNITS,
setSavedDistanceUnit,
} from '../../utils/distanceUnits';
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
export function SettingsLocalSection({
onLocalLabelChange,
@@ -19,6 +25,7 @@ export function SettingsLocalSection({
onLocalLabelChange?: (label: LocalLabel) => void;
className?: string;
}) {
const { distanceUnit, setDistanceUnit } = useDistanceUnit();
const [reopenLastConversation, setReopenLastConversation] = useState(
getReopenLastConversationEnabled
);
@@ -82,6 +89,31 @@ export function SettingsLocalSection({
<Separator />
<div className="space-y-3">
<Label htmlFor="distance-units">Distance Units</Label>
<select
id="distance-units"
value={distanceUnit}
onChange={(event) => {
const nextUnit = event.target.value as (typeof DISTANCE_UNITS)[number];
setSavedDistanceUnit(nextUnit);
setDistanceUnit(nextUnit);
}}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{DISTANCE_UNITS.map((unit) => (
<option key={unit} value={unit}>
{DISTANCE_UNIT_LABELS[unit]}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
Controls how distances are shown throughout the app.
</p>
</div>
<Separator />
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"

View File

@@ -5,6 +5,7 @@ import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { Checkbox } from '../ui/checkbox';
import { RADIO_PRESETS } from '../../utils/radioPresets';
import { stripRegionScopePrefix } from '../../utils/regionScope';
import type {
@@ -64,6 +65,7 @@ export function SettingsRadioSection({
const [cr, setCr] = useState('');
const [pathHashMode, setPathHashMode] = useState('0');
const [advertLocationSource, setAdvertLocationSource] = useState<'off' | 'current'>('current');
const [multiAcksEnabled, setMultiAcksEnabled] = useState(false);
const [gettingLocation, setGettingLocation] = useState(false);
const [busy, setBusy] = useState(false);
const [rebooting, setRebooting] = useState(false);
@@ -98,6 +100,7 @@ export function SettingsRadioSection({
setCr(String(config.radio.cr));
setPathHashMode(String(config.path_hash_mode));
setAdvertLocationSource(config.advert_location_source ?? 'current');
setMultiAcksEnabled(config.multi_acks_enabled ?? false);
}, [config]);
useEffect(() => {
@@ -190,6 +193,9 @@ export function SettingsRadioSection({
...(advertLocationSource !== (config.advert_location_source ?? 'current')
? { advert_location_source: advertLocationSource }
: {}),
...(multiAcksEnabled !== (config.multi_acks_enabled ?? false)
? { multi_acks_enabled: multiAcksEnabled }
: {}),
radio: {
freq: parsedFreq,
bw: parsedBw,
@@ -579,6 +585,24 @@ export function SettingsRadioSection({
library.
</p>
</div>
<div className="space-y-2">
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="multi-acks-enabled"
checked={multiAcksEnabled}
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
<p className="text-xs text-muted-foreground">
When enabled, the radio sends one extra direct ACK transmission before the normal
ACK for received direct messages. This is a firmware-level receive behavior, not a
RemoteTerm retry setting.
</p>
</div>
</div>
</div>
</div>
{config.path_hash_mode_supported && (

View File

@@ -0,0 +1,31 @@
import { createContext, useContext, type ReactNode } from 'react';
import type { DistanceUnit } from '../utils/distanceUnits';
interface DistanceUnitContextValue {
distanceUnit: DistanceUnit;
setDistanceUnit: (unit: DistanceUnit) => void;
}
const noop = () => {};
const DistanceUnitContext = createContext<DistanceUnitContextValue>({
distanceUnit: 'metric',
setDistanceUnit: noop,
});
export function DistanceUnitProvider({
distanceUnit,
setDistanceUnit,
children,
}: DistanceUnitContextValue & { children: ReactNode }) {
return (
<DistanceUnitContext.Provider value={{ distanceUnit, setDistanceUnit }}>
{children}
</DistanceUnitContext.Provider>
);
}
export function useDistanceUnit() {
return useContext(DistanceUnitContext);
}

View File

@@ -10,3 +10,4 @@ export { useRealtimeAppState } from './useRealtimeAppState';
export { useConversationActions } from './useConversationActions';
export { useConversationNavigation } from './useConversationNavigation';
export { useBrowserNotifications } from './useBrowserNotifications';
export { useRawPacketStatsSession } from './useRawPacketStatsSession';

View File

@@ -1,6 +1,7 @@
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
import { getLocalLabel, type LocalLabel } from '../utils/localLabel';
import { getSavedDistanceUnit, type DistanceUnit } from '../utils/distanceUnits';
import type { SettingsSection } from '../components/settings/settingsConstants';
import { parseHashSettingsSection, updateSettingsHash } from '../utils/urlHash';
@@ -12,10 +13,12 @@ interface UseAppShellResult {
showCracker: boolean;
crackerRunning: boolean;
localLabel: LocalLabel;
distanceUnit: DistanceUnit;
setSettingsSection: (section: SettingsSection) => void;
setSidebarOpen: (open: boolean) => void;
setCrackerRunning: (running: boolean) => void;
setLocalLabel: (label: LocalLabel) => void;
setDistanceUnit: (unit: DistanceUnit) => void;
handleCloseSettingsView: () => void;
handleToggleSettingsView: () => void;
handleOpenNewMessage: () => void;
@@ -34,6 +37,7 @@ export function useAppShell(): UseAppShellResult {
const [showCracker, setShowCracker] = useState(false);
const [crackerRunning, setCrackerRunning] = useState(false);
const [localLabel, setLocalLabel] = useState(getLocalLabel);
const [distanceUnit, setDistanceUnit] = useState(getSavedDistanceUnit);
const previousHashRef = useRef('');
useEffect(() => {
@@ -87,10 +91,12 @@ export function useAppShell(): UseAppShellResult {
showCracker,
crackerRunning,
localLabel,
distanceUnit,
setSettingsSection,
setSidebarOpen,
setCrackerRunning,
setLocalLabel,
setDistanceUnit,
handleCloseSettingsView,
handleToggleSettingsView,
handleOpenNewMessage,

View File

@@ -0,0 +1,52 @@
import { useCallback, useState } from 'react';
import type { RawPacket } from '../types';
import {
MAX_RAW_PACKET_STATS_OBSERVATIONS,
summarizeRawPacketForStats,
type RawPacketStatsSessionState,
} from '../utils/rawPacketStats';
export function useRawPacketStatsSession() {
const [session, setSession] = useState<RawPacketStatsSessionState>(() => ({
sessionStartedAt: Date.now(),
totalObservedPackets: 0,
trimmedObservationCount: 0,
observations: [],
}));
const recordRawPacketObservation = useCallback((packet: RawPacket) => {
setSession((prev) => {
const observation = summarizeRawPacketForStats(packet);
if (
prev.observations.some(
(candidate) => candidate.observationKey === observation.observationKey
)
) {
return prev;
}
const observations = [...prev.observations, observation];
if (observations.length <= MAX_RAW_PACKET_STATS_OBSERVATIONS) {
return {
...prev,
totalObservedPackets: prev.totalObservedPackets + 1,
observations,
};
}
const overflow = observations.length - MAX_RAW_PACKET_STATS_OBSERVATIONS;
return {
...prev,
totalObservedPackets: prev.totalObservedPackets + 1,
trimmedObservationCount: prev.trimmedObservationCount + overflow,
observations: observations.slice(overflow),
};
});
}, []);
return {
rawPacketStatsSession: session,
recordRawPacketObservation,
};
}

View File

@@ -50,6 +50,7 @@ interface UseRealtimeAppStateArgs {
removeConversationMessages: (conversationId: string) => void;
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
notifyIncomingMessage?: (msg: Message) => void;
recordRawPacketObservation?: (packet: RawPacket) => void;
maxRawPackets?: number;
}
@@ -97,6 +98,7 @@ export function useRealtimeAppState({
removeConversationMessages,
receiveMessageAck,
notifyIncomingMessage,
recordRawPacketObservation,
maxRawPackets = 500,
}: UseRealtimeAppStateArgs): UseWebSocketOptions {
const mergeChannelIntoList = useCallback(
@@ -241,6 +243,7 @@ export function useRealtimeAppState({
}
},
onRawPacket: (packet: RawPacket) => {
recordRawPacketObservation?.(packet);
setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets));
},
onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => {
@@ -261,6 +264,7 @@ export function useRealtimeAppState({
pendingDeleteFallbackRef,
prevHealthRef,
recordMessageEvent,
recordRawPacketObservation,
receiveMessageAck,
observeMessage,
refreshUnreads,

View File

@@ -0,0 +1,79 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
type ServerLoginKind = 'repeater' | 'room';
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
type StoredPassword = {
password: string;
};
function getStorageKey(kind: ServerLoginKind, publicKey: string): string {
return `${STORAGE_KEY_PREFIX}:${kind}:${publicKey}`;
}
function loadStoredPassword(kind: ServerLoginKind, publicKey: string): StoredPassword | null {
try {
const raw = localStorage.getItem(getStorageKey(kind, publicKey));
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<StoredPassword>;
if (typeof parsed.password !== 'string' || parsed.password.length === 0) {
return null;
}
return { password: parsed.password };
} catch {
return null;
}
}
export function useRememberedServerPassword(kind: ServerLoginKind, publicKey: string) {
const storageKey = useMemo(() => getStorageKey(kind, publicKey), [kind, publicKey]);
const [password, setPassword] = useState('');
const [rememberPassword, setRememberPassword] = useState(false);
useEffect(() => {
const stored = loadStoredPassword(kind, publicKey);
if (!stored) {
setPassword('');
setRememberPassword(false);
return;
}
setPassword(stored.password);
setRememberPassword(true);
}, [kind, publicKey]);
const persistAfterLogin = useCallback(
(submittedPassword: string) => {
if (!rememberPassword) {
try {
localStorage.removeItem(storageKey);
} catch {
// localStorage may be unavailable
}
setPassword('');
return;
}
const trimmedPassword = submittedPassword.trim();
if (!trimmedPassword) {
return;
}
try {
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
} catch {
// localStorage may be unavailable
}
setPassword(trimmedPassword);
},
[rememberPassword, storageKey]
);
return {
password,
setPassword,
rememberPassword,
setRememberPassword,
persistAfterLogin,
};
}

View File

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

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { ChatHeader } from '../components/ChatHeader';
import type { Channel, Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
import { CONTACT_TYPE_ROOM } from '../types';
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
@@ -170,6 +171,38 @@ describe('ChatHeader key visibility', () => {
expect(onToggleNotifications).toHaveBeenCalledTimes(1);
});
it('hides trace and notification controls for room-server contacts', () => {
const pubKey = '41'.repeat(32);
const contact: Contact = {
public_key: pubKey,
name: 'Ops Board',
type: CONTACT_TYPE_ROOM,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Ops Board' };
render(
<ChatHeader {...baseProps} conversation={conversation} channels={[]} contacts={[contact]} />
);
expect(screen.queryByRole('button', { name: 'Path Discovery' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Direct Trace' })).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Enable notifications for this conversation' })
).not.toBeInTheDocument();
});
it('hides the delete button for the canonical Public channel', () => {
const channel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public', false);
const conversation: Conversation = { type: 'channel', id: PUBLIC_CHANNEL_KEY, name: 'Public' };
@@ -196,9 +229,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 +275,9 @@ describe('ChatHeader key visibility', () => {
name: 'Alice',
type: 1,
flags: 0,
last_path: 'AA',
last_path_len: 1,
out_path_hash_mode: 0,
direct_path: 'AA',
direct_path_len: 1,
direct_path_hash_mode: 0,
route_override_path: 'BBDD',
route_override_len: 2,
route_override_hash_mode: 0,

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { getContactAvatar } from '../utils/contactAvatar';
import { CONTACT_TYPE_REPEATER } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
describe('getContactAvatar', () => {
it('returns complete avatar info', () => {
@@ -30,6 +30,13 @@ describe('getContactAvatar', () => {
expect(avatar1.background).toBe(avatar2.background);
});
it('returns room avatar for type=3', () => {
const avatar = getContactAvatar('Ops Board', 'abc123def456', CONTACT_TYPE_ROOM);
expect(avatar.text).toBe('🛖');
expect(avatar.background).toBe('#6b4f2a');
expect(avatar.textColor).toBe('#ffffff');
});
it('non-repeater types use normal avatar', () => {
const avatar0 = getContactAvatar('John', 'abc123', 0);
const avatar1 = getContactAvatar('John', 'abc123', 1);

View File

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

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConversationPane } from '../components/ConversationPane';
@@ -12,6 +12,7 @@ import type {
Message,
RadioConfig,
} from '../types';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
const mocks = vi.hoisted(() => ({
messageList: vi.fn(() => <div data-testid="message-list" />),
@@ -40,6 +41,21 @@ vi.mock('../components/RepeaterDashboard', () => ({
RepeaterDashboard: () => <div data-testid="repeater-dashboard" />,
}));
vi.mock('../components/RoomServerPanel', () => ({
RoomServerPanel: ({
onAuthenticatedChange,
}: {
onAuthenticatedChange?: (value: boolean) => void;
}) => (
<div>
<div data-testid="room-server-panel" />
<button type="button" onClick={() => onAuthenticatedChange?.(true)}>
Authenticate room
</button>
</div>
),
}));
vi.mock('../components/MapView', () => ({
MapView: () => <div data-testid="map-view" />,
}));
@@ -95,12 +111,20 @@ const message: Message = {
sender_name: null,
};
const rawPacketStatsSession: RawPacketStatsSessionState = {
sessionStartedAt: 1_700_000_000_000,
totalObservedPackets: 0,
trimmedObservationCount: 0,
observations: [],
};
function createProps(overrides: Partial<React.ComponentProps<typeof ConversationPane>> = {}) {
return {
activeConversation: null as Conversation | null,
contacts: [] as Contact[],
channels: [channel],
rawPackets: [],
rawPacketStatsSession,
config,
health,
notificationsSupported: true,
@@ -166,9 +190,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,
@@ -207,6 +231,54 @@ describe('ConversationPane', () => {
});
});
it('gates room chat behind room login controls until authenticated', async () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'contact',
id: 'cc'.repeat(32),
name: 'Ops Board',
},
contacts: [
{
public_key: 'cc'.repeat(32),
name: 'Ops Board',
type: 3,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
],
})}
/>
);
await waitFor(() => {
expect(screen.getByTestId('room-server-panel')).toBeInTheDocument();
expect(screen.getByTestId('chat-header')).toBeInTheDocument();
});
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
expect(screen.queryByTestId('message-input')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Authenticate room' }));
await waitFor(() => {
expect(screen.getByTestId('message-list')).toBeInTheDocument();
expect(screen.getByTestId('message-input')).toBeInTheDocument();
});
});
it('passes unread marker props to MessageList only for channel conversations', async () => {
render(
<ConversationPane
@@ -268,9 +340,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 +376,9 @@ describe('ConversationPane', () => {
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,

View File

@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
DISTANCE_UNIT_KEY,
getSavedDistanceUnit,
setSavedDistanceUnit,
} from '../utils/distanceUnits';
describe('distanceUnits utilities', () => {
beforeEach(() => {
localStorage.clear();
});
it('defaults to metric when unset', () => {
expect(getSavedDistanceUnit()).toBe('metric');
});
it('returns the stored unit when valid', () => {
localStorage.setItem(DISTANCE_UNIT_KEY, 'metric');
expect(getSavedDistanceUnit()).toBe('metric');
});
it('falls back to metric for invalid stored values', () => {
localStorage.setItem(DISTANCE_UNIT_KEY, 'parsecs');
expect(getSavedDistanceUnit()).toBe('metric');
});
it('stores the selected distance unit', () => {
setSavedDistanceUnit('smoots');
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
});
});

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MessageList } from '../components/MessageList';
import type { Message } from '../types';
import { CONTACT_TYPE_ROOM, type Contact, type Message } from '../types';
const scrollIntoViewMock = vi.fn();
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
@@ -81,6 +81,46 @@ describe('MessageList channel sender rendering', () => {
expect(screen.getByText('A')).toBeInTheDocument();
});
it('renders room-server DM messages using stored sender attribution instead of the room contact', () => {
const roomContact: Contact = {
public_key: 'ab'.repeat(32),
name: 'Ops Board',
type: CONTACT_TYPE_ROOM,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
render(
<MessageList
messages={[
createMessage({
type: 'PRIV',
conversation_key: roomContact.public_key,
text: 'status update: ready',
sender_name: 'Alice',
sender_key: '12'.repeat(32),
}),
]}
contacts={[roomContact]}
loading={false}
/>
);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.queryByText('Ops Board')).not.toBeInTheDocument();
expect(screen.getByText('status update: ready')).toBeInTheDocument();
});
it('gives clickable sender avatars an accessible label', () => {
render(
<MessageList

View File

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

View File

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

View File

@@ -22,8 +22,9 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
name: 'Test Contact',
type: CONTACT_TYPE_REPEATER,
flags: 0,
last_path: null,
last_path_len: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
@@ -33,7 +34,6 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
last_read_at: null,
first_seen: null,
...overrides,
out_path_hash_mode: overrides.out_path_hash_mode ?? 0,
};
}
@@ -139,9 +139,9 @@ describe('contact routing helpers', () => {
it('prefers routing override over learned route', () => {
const effective = getEffectiveContactRoute(
createContact({
last_path: 'AABB',
last_path_len: 1,
out_path_hash_mode: 0,
direct_path: 'AABB',
direct_path_len: 1,
direct_path_hash_mode: 0,
route_override_path: 'AE92F13E',
route_override_len: 2,
route_override_hash_mode: 1,
@@ -685,22 +685,39 @@ describe('isValidLocation', () => {
});
describe('formatDistance', () => {
it('formats distances under 1km in meters', () => {
expect(formatDistance(0.5)).toBe('500m');
expect(formatDistance(0.123)).toBe('123m');
expect(formatDistance(0.9999)).toBe('1000m');
const formatInteger = (value: number) => value.toLocaleString();
const formatOneDecimal = (value: number) =>
value.toLocaleString(undefined, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
it('defaults to imperial formatting', () => {
expect(formatDistance(0.01)).toBe(`${formatInteger(33)}ft`);
expect(formatDistance(0.5)).toBe(`${formatOneDecimal(0.5 * 0.621371)}mi`);
expect(formatDistance(1)).toBe(`${formatOneDecimal(0.621371)}mi`);
});
it('formats distances at or above 1km with one decimal', () => {
expect(formatDistance(1)).toBe('1.0km');
expect(formatDistance(1.5)).toBe('1.5km');
expect(formatDistance(12.34)).toBe('12.3km');
expect(formatDistance(100)).toBe('100.0km');
it('formats metric distances in meters and kilometers', () => {
expect(formatDistance(0.5, 'metric')).toBe(`${formatInteger(500)}m`);
expect(formatDistance(0.123, 'metric')).toBe(`${formatInteger(123)}m`);
expect(formatDistance(0.9999, 'metric')).toBe(`${formatInteger(1000)}m`);
expect(formatDistance(1, 'metric')).toBe(`${formatOneDecimal(1)}km`);
expect(formatDistance(12.34, 'metric')).toBe(`${formatOneDecimal(12.34)}km`);
});
it('rounds meters to nearest integer', () => {
expect(formatDistance(0.4567)).toBe('457m');
expect(formatDistance(0.001)).toBe('1m');
it('formats smoot distances using 1.7018 meters per smoot', () => {
expect(formatDistance(0.0017018, 'smoots')).toBe(`${formatOneDecimal(1)} smoot`);
expect(formatDistance(0.001, 'smoots')).toBe(`${formatOneDecimal(0.6)} smoots`);
expect(formatDistance(1, 'smoots')).toBe(`${formatInteger(588)} smoots`);
});
it('applies locale separators to large values', () => {
expect(formatDistance(1.234, 'metric')).toBe(`${formatOneDecimal(1.234)}km`);
expect(formatDistance(1234, 'metric')).toBe(`${formatOneDecimal(1234)}km`);
expect(formatDistance(2.1, 'smoots')).toContain(
formatInteger(Math.round((2.1 * 1000) / 1.7018))
);
});
});

View File

@@ -0,0 +1,51 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { RawPacketDetailModal } from '../components/RawPacketDetailModal';
import type { Channel, RawPacket } from '../types';
const BOT_CHANNEL: Channel = {
key: 'eb50a1bcb3e4e5d7bf69a57c9dada211',
name: '#bot',
is_hashtag: true,
on_radio: false,
last_read_at: null,
};
const BOT_PACKET: RawPacket = {
id: 1,
observation_id: 10,
timestamp: 1_700_000_000,
data: '15833fa002860ccae0eed9ca78b9ab0775d477c1f6490a398bf4edc75240',
decrypted: false,
payload_type: 'GroupText',
rssi: -72,
snr: 5.5,
decrypted_info: null,
};
describe('RawPacketDetailModal', () => {
it('renders path hops as nowrap arrow-delimited groups and links hover state to the full packet hex', () => {
render(<RawPacketDetailModal packet={BOT_PACKET} channels={[BOT_CHANNEL]} onClose={vi.fn()} />);
const pathDescription = screen.getByText(
'Historical route taken (3-byte hashes added as packet floods through network)'
);
const pathFieldBox = pathDescription.closest('[class*="rounded-lg"]');
expect(pathFieldBox).not.toBeNull();
const pathField = within(pathFieldBox as HTMLElement);
expect(pathField.getByText('3FA002 →')).toHaveClass('whitespace-nowrap');
expect(pathField.getByText('860CCA →')).toHaveClass('whitespace-nowrap');
expect(pathField.getByText('E0EED9')).toHaveClass('whitespace-nowrap');
const pathRun = screen.getByText('3F A0 02 86 0C CA E0 EE D9');
const idleClassName = pathRun.className;
fireEvent.mouseEnter(pathFieldBox as HTMLElement);
expect(pathRun.className).not.toBe(idleClassName);
fireEvent.mouseLeave(pathFieldBox as HTMLElement);
expect(pathRun.className).toBe(idleClassName);
});
});

View File

@@ -0,0 +1,355 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RawPacketFeedView } from '../components/RawPacketFeedView';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
import type { Channel, Contact, RawPacket } from '../types';
const GROUP_TEXT_PACKET_HEX =
'1500E69C7A89DD0AF6A2D69F5823B88F9720731E4B887C56932BF889255D8D926D99195927144323A42DD8A158F878B518B8304DF55E80501C7D02A9FFD578D3518283156BBA257BF8413E80A237393B2E4149BBBC864371140A9BBC4E23EB9BF203EF0D029214B3E3AAC3C0295690ACDB89A28619E7E5F22C83E16073AD679D25FA904D07E5ACF1DB5A7C77D7E1719FB9AE5BF55541EE0D7F59ED890E12CF0FEED6700818';
const TEST_CHANNEL: Channel = {
key: '7ABA109EDCF304A84433CB71D0F3AB73',
name: '#six77',
is_hashtag: true,
on_radio: false,
last_read_at: null,
};
const COLLIDING_TEST_CHANNEL: Channel = {
...TEST_CHANNEL,
name: '#collision',
};
function createSession(
overrides: Partial<RawPacketStatsSessionState> = {}
): RawPacketStatsSessionState {
return {
sessionStartedAt: 1_700_000_000_000,
totalObservedPackets: 3,
trimmedObservationCount: 0,
observations: [
{
observationKey: 'obs-1',
timestamp: 1_700_000_000,
payloadType: 'Advert',
routeType: 'Flood',
decrypted: false,
rssi: -70,
snr: 6,
sourceKey: 'AA11',
sourceLabel: 'AA11',
pathTokenCount: 1,
pathSignature: '01',
},
{
observationKey: 'obs-2',
timestamp: 1_700_000_030,
payloadType: 'TextMessage',
routeType: 'Direct',
decrypted: true,
rssi: -66,
snr: 7,
sourceKey: 'BB22',
sourceLabel: 'BB22',
pathTokenCount: 0,
pathSignature: null,
},
{
observationKey: 'obs-3',
timestamp: 1_700_000_050,
payloadType: 'Ack',
routeType: 'Direct',
decrypted: true,
rssi: -80,
snr: 4,
sourceKey: 'BB22',
sourceLabel: 'BB22',
pathTokenCount: 0,
pathSignature: null,
},
],
...overrides,
};
}
function createContact(overrides: Partial<Contact> = {}): Contact {
return {
public_key: 'aa11bb22cc33' + '0'.repeat(52),
name: 'Alpha',
type: 1,
flags: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
last_advert: 1_700_000_000,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
...overrides,
};
}
function renderView({
packets = [],
contacts = [],
channels = [],
rawPacketStatsSession = createSession(),
}: {
packets?: RawPacket[];
contacts?: Contact[];
channels?: Channel[];
rawPacketStatsSession?: RawPacketStatsSessionState;
} = {}) {
return render(
<RawPacketFeedView
packets={packets}
rawPacketStatsSession={rawPacketStatsSession}
contacts={contacts}
channels={channels}
/>
);
}
describe('RawPacketFeedView', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('opens a stats drawer with window controls and grouped summaries', () => {
renderView();
expect(screen.getByText('Raw Packet Feed')).toBeInTheDocument();
expect(screen.queryByText('Packet Types')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
expect(screen.getByLabelText('Stats window')).toBeInTheDocument();
expect(screen.getByText('Packet Types')).toBeInTheDocument();
expect(screen.getByText('Hop Byte Width')).toBeInTheDocument();
expect(screen.getByText('Most-Heard Neighbors')).toBeInTheDocument();
expect(screen.getByText('Traffic Timeline')).toBeInTheDocument();
});
it('shows stats by default on desktop', () => {
vi.stubGlobal(
'matchMedia',
vi.fn().mockImplementation((query: string) => ({
matches: query === '(min-width: 768px)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
);
renderView();
expect(screen.getByText('Packet Types')).toBeInTheDocument();
expect(screen.getByText('Hop Byte Width')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /hide stats/i })).toBeInTheDocument();
});
it('refreshes coverage when packet or session props update without counter deltas', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:30Z'));
const initialPackets: RawPacket[] = [];
const nextPackets: RawPacket[] = [
{
id: 1,
timestamp: 1_704_067_255,
data: '00',
decrypted: false,
payload_type: 'Unknown',
rssi: null,
snr: null,
observation_id: 1,
decrypted_info: null,
},
];
const initialSession = createSession({
sessionStartedAt: Date.parse('2024-01-01T00:00:00Z'),
totalObservedPackets: 10,
trimmedObservationCount: 1,
observations: [
{
observationKey: 'obs-1',
timestamp: 1_704_067_220,
payloadType: 'Advert',
routeType: 'Flood',
decrypted: false,
rssi: -70,
snr: 6,
sourceKey: 'AA11',
sourceLabel: 'AA11',
pathTokenCount: 1,
pathSignature: '01',
},
],
});
const { rerender } = renderView({
packets: initialPackets,
rawPacketStatsSession: initialSession,
contacts: [],
});
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: '1m' } });
expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument();
vi.setSystemTime(new Date('2024-01-01T00:01:10Z'));
rerender(
<RawPacketFeedView
packets={nextPackets}
rawPacketStatsSession={initialSession}
contacts={[]}
channels={[]}
/>
);
expect(screen.getByText(/only covered for 50 sec/i)).toBeInTheDocument();
vi.setSystemTime(new Date('2024-01-01T00:01:30Z'));
const nextSession = {
...initialSession,
sessionStartedAt: Date.parse('2024-01-01T00:01:00Z'),
observations: [
{
...initialSession.observations[0],
timestamp: 1_704_067_280,
},
],
};
rerender(
<RawPacketFeedView
packets={nextPackets}
rawPacketStatsSession={nextSession}
contacts={[]}
channels={[]}
/>
);
expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument();
vi.useRealTimers();
});
it('resolves neighbor labels from matching contacts when identity is available', () => {
renderView({
rawPacketStatsSession: createSession({
totalObservedPackets: 1,
observations: [
{
observationKey: 'obs-1',
timestamp: 1_700_000_000,
payloadType: 'Advert',
routeType: 'Flood',
decrypted: false,
rssi: -70,
snr: 6,
sourceKey: 'AA11BB22CC33',
sourceLabel: 'AA11BB22CC33',
pathTokenCount: 1,
pathSignature: '01',
},
],
}),
contacts: [createContact()],
});
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0);
});
it('marks unresolved neighbor identities explicitly', () => {
renderView({
rawPacketStatsSession: createSession({
totalObservedPackets: 1,
observations: [
{
observationKey: 'obs-1',
timestamp: 1_700_000_000,
payloadType: 'Advert',
routeType: 'Flood',
decrypted: false,
rssi: -70,
snr: 6,
sourceKey: 'DEADBEEF1234',
sourceLabel: 'DEADBEEF1234',
pathTokenCount: 1,
pathSignature: '01',
},
],
}),
contacts: [],
});
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
expect(screen.getAllByText('Identity not resolvable').length).toBeGreaterThan(0);
});
it('opens a packet detail modal from the raw feed and decrypts room messages when a key is loaded', () => {
renderView({
packets: [
{
id: 1,
observation_id: 10,
timestamp: 1_700_000_000,
data: GROUP_TEXT_PACKET_HEX,
decrypted: false,
payload_type: 'GroupText',
rssi: -72,
snr: 5.5,
decrypted_info: null,
},
],
channels: [TEST_CHANNEL],
});
fireEvent.click(screen.getByRole('button', { name: /gt from flightless/i }));
expect(screen.getByText('Packet Details')).toBeInTheDocument();
expect(screen.getByText('Payload fields')).toBeInTheDocument();
expect(screen.getByText('Full packet hex')).toBeInTheDocument();
expect(screen.getByText('#six77')).toBeInTheDocument();
expect(screen.getByText(/bytes · decrypted/i)).toBeInTheDocument();
expect(screen.getAllByText(/sender: flightless/i).length).toBeGreaterThan(0);
expect(
screen.getByText(/hello there; this hashtag room is essentially public/i)
).toBeInTheDocument();
});
it('does not guess a room name when multiple loaded channels collide on the group hash', () => {
renderView({
packets: [
{
id: 1,
observation_id: 10,
timestamp: 1_700_000_000,
data: GROUP_TEXT_PACKET_HEX,
decrypted: false,
payload_type: 'GroupText',
rssi: -72,
snr: 5.5,
decrypted_info: null,
},
],
channels: [TEST_CHANNEL, COLLIDING_TEST_CHANNEL],
});
fireEvent.click(screen.getByRole('button', { name: /gt from flightless/i }));
expect(screen.getByText(/channel hash e6/i)).toBeInTheDocument();
expect(screen.queryByText('#six77')).not.toBeInTheDocument();
expect(screen.queryByText('#collision')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { PayloadType } from '@michaelhart/meshcore-decoder';
import { describeCiphertextStructure, formatHexByHop } from '../utils/rawPacketInspector';
describe('rawPacketInspector helpers', () => {
it('formats path hex as hop-delimited groups', () => {
expect(formatHexByHop('A1B2C3D4E5F6', 2)).toBe('A1B2 → C3D4 → E5F6');
expect(formatHexByHop('AABBCC', 1)).toBe('AA → BB → CC');
});
it('leaves non-hop-aligned hex unchanged', () => {
expect(formatHexByHop('A1B2C3', 2)).toBe('A1B2C3');
expect(formatHexByHop('A1B2', null)).toBe('A1B2');
});
it('describes undecryptable ciphertext with multiline bullets', () => {
expect(describeCiphertextStructure(PayloadType.GroupText, 9, 'fallback')).toContain(
'\n• Timestamp (4 bytes)'
);
expect(describeCiphertextStructure(PayloadType.GroupText, 9, 'fallback')).toContain(
'\n• Flags (1 byte)'
);
expect(describeCiphertextStructure(PayloadType.TextMessage, 12, 'fallback')).toContain(
'\n• Message (remaining bytes)'
);
});
});

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { RawPacketList } from '../components/RawPacketList';
import type { RawPacket } from '../types';
@@ -23,5 +23,17 @@ describe('RawPacketList', () => {
render(<RawPacketList packets={[createPacket()]} />);
expect(screen.getByText('TF')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('makes packet cards clickable only when an inspector handler is provided', () => {
const packet = createPacket({ id: 9, observation_id: 22 });
const onPacketClick = vi.fn();
render(<RawPacketList packets={[packet]} onPacketClick={onPacketClick} />);
fireEvent.click(screen.getByRole('button'));
expect(onPacketClick).toHaveBeenCalledWith(packet);
});
});

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest';
import {
buildRawPacketStatsSnapshot,
type RawPacketStatsSessionState,
} from '../utils/rawPacketStats';
function createSession(
overrides: Partial<RawPacketStatsSessionState> = {}
): RawPacketStatsSessionState {
return {
sessionStartedAt: 700_000,
totalObservedPackets: 4,
trimmedObservationCount: 0,
observations: [
{
observationKey: 'obs-1',
timestamp: 850,
payloadType: 'Advert',
routeType: 'Flood',
decrypted: false,
rssi: -68,
snr: 7,
sourceKey: 'AA11',
sourceLabel: 'AA11',
pathTokenCount: 2,
pathSignature: '01>02',
hopByteWidth: 1,
},
{
observationKey: 'obs-2',
timestamp: 910,
payloadType: 'TextMessage',
routeType: 'Direct',
decrypted: true,
rssi: -74,
snr: 5,
sourceKey: 'BB22',
sourceLabel: 'BB22',
pathTokenCount: 0,
pathSignature: null,
hopByteWidth: null,
},
{
observationKey: 'obs-3',
timestamp: 960,
payloadType: 'Advert',
routeType: 'Flood',
decrypted: false,
rssi: -64,
snr: 8,
sourceKey: 'AA11',
sourceLabel: 'AA11',
pathTokenCount: 1,
pathSignature: '02',
hopByteWidth: 2,
},
{
observationKey: 'obs-4',
timestamp: 990,
payloadType: 'Ack',
routeType: 'Direct',
decrypted: true,
rssi: -88,
snr: 3,
sourceKey: null,
sourceLabel: null,
pathTokenCount: 0,
pathSignature: null,
hopByteWidth: null,
},
],
...overrides,
};
}
describe('buildRawPacketStatsSnapshot', () => {
it('computes counts, rankings, and rolling-window coverage from session observations', () => {
const stats = buildRawPacketStatsSnapshot(createSession(), '5m', 1_000);
expect(stats.packetCount).toBe(4);
expect(stats.uniqueSources).toBe(2);
expect(stats.pathBearingCount).toBe(2);
expect(stats.payloadBreakdown.slice(0, 3).map((item) => item.label)).toEqual([
'Advert',
'Ack',
'TextMessage',
]);
expect(stats.payloadBreakdown).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: 'GroupText', count: 0 }),
expect.objectContaining({ label: 'Control', count: 0 }),
])
);
expect(stats.hopProfile.map((item) => item.label)).toEqual([
'0',
'1',
'2-5',
'6-10',
'11-15',
'16+',
]);
expect(stats.hopProfile).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: '0', count: 2 }),
expect.objectContaining({ label: '1', count: 1 }),
expect.objectContaining({ label: '2-5', count: 1 }),
expect.objectContaining({ label: '6-10', count: 0 }),
expect.objectContaining({ label: '11-15', count: 0 }),
expect.objectContaining({ label: '16+', count: 0 }),
])
);
expect(stats.hopByteWidthProfile).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: 'No path', count: 2 }),
expect.objectContaining({ label: '1 byte / hop', count: 1 }),
expect.objectContaining({ label: '2 bytes / hop', count: 1 }),
])
);
expect(stats.strongestNeighbors[0]).toMatchObject({ label: 'AA11', bestRssi: -64 });
expect(stats.mostActiveNeighbors[0]).toMatchObject({ label: 'AA11', count: 2 });
expect(stats.windowFullyCovered).toBe(true);
});
it('flags incomplete session coverage when detailed history has been trimmed', () => {
const stats = buildRawPacketStatsSnapshot(
createSession({
trimmedObservationCount: 25,
}),
'session',
1_000
);
expect(stats.windowFullyCovered).toBe(false);
expect(stats.packetCount).toBe(4);
});
});

View File

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

View File

@@ -7,6 +7,10 @@ describe('RepeaterLogin', () => {
repeaterName: 'TestRepeater',
loading: false,
error: null as string | null,
password: '',
onPasswordChange: vi.fn(),
rememberPassword: false,
onRememberPasswordChange: vi.fn(),
onLogin: vi.fn(),
onLoginAsGuest: vi.fn(),
};
@@ -26,20 +30,45 @@ describe('RepeaterLogin', () => {
render(<RepeaterLogin {...defaultProps} />);
expect(screen.getByPlaceholderText('Repeater password...')).toBeInTheDocument();
expect(screen.getByText('Remember password')).toBeInTheDocument();
expect(screen.getByText('Login with Password')).toBeInTheDocument();
expect(screen.getByText('Login as Guest / ACLs')).toBeInTheDocument();
});
it('calls onLogin with trimmed password on submit', () => {
render(<RepeaterLogin {...defaultProps} />);
const input = screen.getByPlaceholderText('Repeater password...');
fireEvent.change(input, { target: { value: ' secret ' } });
render(<RepeaterLogin {...defaultProps} password=" secret " />);
fireEvent.submit(screen.getByText('Login with Password').closest('form')!);
expect(defaultProps.onLogin).toHaveBeenCalledWith('secret');
});
it('propagates password changes', () => {
render(<RepeaterLogin {...defaultProps} />);
const input = screen.getByPlaceholderText('Repeater password...');
fireEvent.change(input, { target: { value: 'new secret' } });
expect(defaultProps.onPasswordChange).toHaveBeenCalledWith('new secret');
});
it('toggles remember password checkbox', () => {
render(<RepeaterLogin {...defaultProps} />);
fireEvent.click(screen.getByLabelText('Remember password'));
expect(defaultProps.onRememberPasswordChange).toHaveBeenCalledWith(true);
});
it('shows storage warning when remember password is enabled', () => {
render(<RepeaterLogin {...defaultProps} rememberPassword={true} />);
expect(
screen.getByText(
/Passwords are stored unencrypted in local browser storage for this domain\./
)
).toBeInTheDocument();
});
it('calls onLoginAsGuest when guest button clicked', () => {
render(<RepeaterLogin {...defaultProps} />);

View File

@@ -0,0 +1,71 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { RoomServerPanel } from '../components/RoomServerPanel';
import type { Contact } from '../types';
vi.mock('../api', () => ({
api: {
roomLogin: vi.fn(),
roomStatus: vi.fn(),
roomAcl: vi.fn(),
roomLppTelemetry: vi.fn(),
sendRepeaterCommand: vi.fn(),
},
}));
vi.mock('../components/ui/sonner', () => ({
toast: Object.assign(vi.fn(), {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
}),
}));
const { api: _rawApi } = await import('../api');
const mockApi = _rawApi as unknown as Record<string, Mock>;
const roomContact: Contact = {
public_key: 'aa'.repeat(32),
name: 'Ops Board',
type: 3,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
describe('RoomServerPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it('keeps room controls available when login is not confirmed', async () => {
mockApi.roomLogin.mockResolvedValueOnce({
status: 'timeout',
authenticated: false,
message:
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
});
const onAuthenticatedChange = vi.fn();
render(<RoomServerPanel contact={roomContact} onAuthenticatedChange={onAuthenticatedChange} />);
fireEvent.click(screen.getByText('Login with ACL / Guest'));
await waitFor(() => {
expect(screen.getByText('Room Server Controls')).toBeInTheDocument();
});
expect(screen.getByText(/control panel is still available/i)).toBeInTheDocument();
expect(onAuthenticatedChange).toHaveBeenLastCalledWith(true);
});
});

View File

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

View File

@@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SettingsModal } from '../components/SettingsModal';
import type {
@@ -19,6 +19,7 @@ import {
REOPEN_LAST_CONVERSATION_KEY,
} from '../utils/lastViewedConversation';
import { api } from '../api';
import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits';
const baseConfig: RadioConfig = {
public_key: 'aa'.repeat(32),
@@ -36,6 +37,7 @@ const baseConfig: RadioConfig = {
path_hash_mode: 0,
path_hash_mode_supported: false,
advert_location_source: 'current',
multi_acks_enabled: false,
};
const baseHealth: HealthStatus = {
@@ -176,6 +178,10 @@ function openDatabaseSection() {
}
describe('SettingsModal', () => {
beforeEach(() => {
vi.spyOn(api, 'getFanoutConfigs').mockResolvedValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
@@ -327,6 +333,18 @@ describe('SettingsModal', () => {
});
});
it('saves multi-acks through radio config save', async () => {
const { onSave } = renderModal();
openRadioSection();
fireEvent.click(screen.getByLabelText('Extra Direct ACK Transmission'));
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ multi_acks_enabled: true }));
});
});
it('saves changed max contacts value through onSaveAppSettings', async () => {
const { onSaveAppSettings } = renderModal();
openRadioSection();
@@ -364,17 +382,21 @@ describe('SettingsModal', () => {
desktopSection: 'fanout',
});
await waitFor(() => {
expect(api.getFanoutConfigs).toHaveBeenCalled();
});
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Local Configuration/i })).not.toBeInTheDocument();
expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument();
});
it('does not clip the fanout add-integration menu in external desktop mode', () => {
it('does not clip the fanout add-integration menu in external desktop mode', async () => {
renderModal({
externalSidebarNav: true,
desktopSection: 'fanout',
});
const addIntegrationButton = screen.getByRole('button', { name: 'Add Integration' });
const addIntegrationButton = await screen.findByRole('button', { name: 'Add Integration' });
const wrapperSection = addIntegrationButton.closest('section');
expect(wrapperSection).not.toHaveClass('overflow-hidden');
});
@@ -427,30 +449,35 @@ describe('SettingsModal', () => {
expect(screen.getByText('Save failed')).toBeInTheDocument();
});
view.rerender(
<SettingsModal
open
externalSidebarNav
desktopSection="fanout"
config={baseConfig}
health={baseHealth}
appSettings={baseSettings}
onClose={vi.fn()}
onSave={vi.fn(async () => {})}
onSaveAppSettings={onSaveAppSettings}
onSetPrivateKey={vi.fn(async () => {})}
onReboot={vi.fn(async () => {})}
onDisconnect={vi.fn(async () => {})}
onReconnect={vi.fn(async () => {})}
onAdvertise={vi.fn(async () => {})}
meshDiscovery={null}
meshDiscoveryLoadingTarget={null}
onDiscoverMesh={vi.fn(async () => {})}
onHealthRefresh={vi.fn(async () => {})}
onRefreshAppSettings={vi.fn(async () => {})}
/>
);
await act(async () => {
view.rerender(
<SettingsModal
open
externalSidebarNav
desktopSection="fanout"
config={baseConfig}
health={baseHealth}
appSettings={baseSettings}
onClose={vi.fn()}
onSave={vi.fn(async () => {})}
onSaveAppSettings={onSaveAppSettings}
onSetPrivateKey={vi.fn(async () => {})}
onReboot={vi.fn(async () => {})}
onDisconnect={vi.fn(async () => {})}
onReconnect={vi.fn(async () => {})}
onAdvertise={vi.fn(async () => {})}
meshDiscovery={null}
meshDiscoveryLoadingTarget={null}
onDiscoverMesh={vi.fn(async () => {})}
onHealthRefresh={vi.fn(async () => {})}
onRefreshAppSettings={vi.fn(async () => {})}
/>
);
await Promise.resolve();
});
expect(api.getFanoutConfigs).toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
expect(screen.queryByText('Save failed')).not.toBeInTheDocument();
});
@@ -509,6 +536,18 @@ describe('SettingsModal', () => {
expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toBeNull();
});
it('defaults distance units to metric and stores local changes', () => {
renderModal();
openLocalSection();
const select = screen.getByLabelText('Distance Units');
expect(select).toHaveValue('metric');
fireEvent.change(select, { target: { value: 'smoots' } });
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
});
it('purges decrypted raw packets via maintenance endpoint action', async () => {
const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({
packets_deleted: 12,

View File

@@ -1 +1,9 @@
import '@testing-library/jest-dom';
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserver;

View File

@@ -2,7 +2,13 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Sidebar } from '../components/Sidebar';
import { CONTACT_TYPE_REPEATER, type Channel, type Contact, type Favorite } from '../types';
import {
CONTACT_TYPE_REPEATER,
CONTACT_TYPE_ROOM,
type Channel,
type Contact,
type Favorite,
} from '../types';
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
@@ -27,9 +33,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,
@@ -51,16 +57,19 @@ function renderSidebar(overrides?: {
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
}) {
const aliceName = 'Alice';
const roomName = 'Ops Board';
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
const flightChannel = makeChannel('BB'.repeat(16), '#flight');
const opsChannel = makeChannel('CC'.repeat(16), '#ops');
const alice = makeContact('11'.repeat(32), aliceName);
const board = makeContact('33'.repeat(32), roomName, CONTACT_TYPE_ROOM);
const relay = makeContact('22'.repeat(32), 'Relay', CONTACT_TYPE_REPEATER);
const unreadCounts = overrides?.unreadCounts ?? {
[getStateKey('channel', flightChannel.key)]: 2,
[getStateKey('channel', opsChannel.key)]: 1,
[getStateKey('contact', alice.public_key)]: 3,
[getStateKey('contact', board.public_key)]: 5,
[getStateKey('contact', relay.public_key)]: 4,
};
@@ -69,7 +78,7 @@ function renderSidebar(overrides?: {
const view = render(
<Sidebar
contacts={[alice, relay]}
contacts={[alice, board, relay]}
channels={channels}
activeConversation={null}
onSelectConversation={vi.fn()}
@@ -87,7 +96,7 @@ function renderSidebar(overrides?: {
/>
);
return { ...view, flightChannel, opsChannel, aliceName };
return { ...view, flightChannel, opsChannel, aliceName, roomName };
}
function getSectionHeaderContainer(title: string): HTMLElement {
@@ -108,6 +117,7 @@ describe('Sidebar section summaries', () => {
expect(within(getSectionHeaderContainer('Favorites')).getByText('2')).toBeInTheDocument();
expect(within(getSectionHeaderContainer('Channels')).getByText('1')).toBeInTheDocument();
expect(within(getSectionHeaderContainer('Contacts')).getByText('3')).toBeInTheDocument();
expect(within(getSectionHeaderContainer('Room Servers')).getByText('5')).toBeInTheDocument();
expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument();
});
@@ -129,7 +139,7 @@ describe('Sidebar section summaries', () => {
);
});
it('keeps contact row badges normal while the contacts rollup is always red', () => {
it('turns contact row badges red while the contacts rollup remains red', () => {
const { aliceName } = renderSidebar();
expect(within(getSectionHeaderContainer('Contacts')).getByText('3')).toHaveClass(
@@ -140,21 +150,54 @@ describe('Sidebar section summaries', () => {
const aliceRow = screen.getByText(aliceName).closest('div');
if (!aliceRow) throw new Error('Missing Alice row');
expect(within(aliceRow).getByText('3')).toHaveClass(
'bg-badge-mention',
'text-badge-mention-foreground'
);
});
it('turns favorite contact row badges red', () => {
const { aliceName } = renderSidebar({
favorites: [{ type: 'contact', id: '11'.repeat(32) }],
});
const aliceRow = screen.getByText(aliceName).closest('div');
if (!aliceRow) throw new Error('Missing Alice row');
expect(within(aliceRow).getByText('3')).toHaveClass(
'bg-badge-mention',
'text-badge-mention-foreground'
);
});
it('keeps repeater row badges neutral', () => {
renderSidebar();
const relayRow = screen.getByText('Relay').closest('div');
if (!relayRow) throw new Error('Missing Relay row');
expect(within(relayRow).getByText('4')).toHaveClass(
'bg-badge-unread/90',
'text-badge-unread-foreground'
);
});
it('renders room servers in their own section', () => {
const { roomName } = renderSidebar();
expect(screen.getByRole('button', { name: 'Room Servers' })).toBeInTheDocument();
expect(screen.getByText(roomName)).toBeInTheDocument();
});
it('expands collapsed sections during search and restores collapse state after clearing search', async () => {
const { opsChannel, aliceName } = renderSidebar();
const { opsChannel, aliceName, roomName } = renderSidebar();
fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
fireEvent.click(screen.getByRole('button', { name: 'Channels' }));
fireEvent.click(screen.getByRole('button', { name: 'Contacts' }));
fireEvent.click(screen.getByRole('button', { name: 'Room Servers' }));
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
const search = screen.getByLabelText('Search conversations');
fireEvent.change(search, { target: { value: 'alice' } });
@@ -169,19 +212,22 @@ describe('Sidebar section summaries', () => {
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
});
});
it('persists collapsed section state across unmount and remount', () => {
const { opsChannel, aliceName, unmount } = renderSidebar();
const { opsChannel, aliceName, roomName, unmount } = renderSidebar();
fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
fireEvent.click(screen.getByRole('button', { name: 'Channels' }));
fireEvent.click(screen.getByRole('button', { name: 'Contacts' }));
fireEvent.click(screen.getByRole('button', { name: 'Room Servers' }));
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
unmount();
renderSidebar();
@@ -189,6 +235,7 @@ describe('Sidebar section summaries', () => {
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
});
it('renders same-name channels when keys differ and allows selecting both', () => {
@@ -265,6 +312,12 @@ describe('Sidebar section summaries', () => {
const alphaChannel = makeChannel('CC'.repeat(16), '#alpha');
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
const amy = makeContact('22'.repeat(32), 'Amy');
const zebraRoom = makeContact('55'.repeat(32), 'Zebra Room', CONTACT_TYPE_ROOM, {
last_seen: 100,
});
const alphaRoom = makeContact('66'.repeat(32), 'Alpha Room', CONTACT_TYPE_ROOM, {
last_advert: 300,
});
const relayZulu = makeContact('33'.repeat(32), 'Zulu Relay', CONTACT_TYPE_REPEATER, {
last_seen: 100,
});
@@ -273,7 +326,7 @@ describe('Sidebar section summaries', () => {
});
const props = {
contacts: [zed, amy, relayZulu, relayAlpha],
contacts: [zed, amy, zebraRoom, alphaRoom, relayZulu, relayAlpha],
channels: [publicChannel, zebraChannel, alphaChannel],
activeConversation: null,
onSelectConversation: vi.fn(),
@@ -282,6 +335,7 @@ describe('Sidebar section summaries', () => {
[getStateKey('channel', zebraChannel.key)]: 300,
[getStateKey('channel', alphaChannel.key)]: 100,
[getStateKey('contact', zed.public_key)]: 200,
[getStateKey('contact', zebraRoom.public_key)]: 350,
},
unreadCounts: {},
mentions: {},
@@ -304,27 +358,76 @@ describe('Sidebar section summaries', () => {
.getAllByText(/Relay$/)
.map((node) => node.textContent)
.filter((text): text is string => Boolean(text));
const getRoomsOrder = () =>
screen
.getAllByText(/Room$/)
.map((node) => node.textContent)
.filter((text): text is string => Boolean(text));
const { unmount } = render(<Sidebar {...props} />);
expect(getChannelsOrder()).toEqual(['#zebra', '#alpha']);
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
expect(getRoomsOrder()).toEqual(['Zebra Room', 'Alpha Room']);
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
fireEvent.click(screen.getByRole('button', { name: 'Sort Channels alphabetically' }));
fireEvent.click(screen.getByRole('button', { name: 'Sort Contacts alphabetically' }));
fireEvent.click(screen.getByRole('button', { name: 'Sort Room Servers alphabetically' }));
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
expect(getRoomsOrder()).toEqual(['Alpha Room', 'Zebra Room']);
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
unmount();
render(<Sidebar {...props} />);
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
expect(getRoomsOrder()).toEqual(['Alpha Room', 'Zebra Room']);
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
});
it('sorts room servers like contacts by DM recency first, then advert recency', () => {
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const dmRecentRoom = makeContact('77'.repeat(32), 'DM Recent Room', CONTACT_TYPE_ROOM, {
last_advert: 100,
});
const advertOnlyRoom = makeContact('88'.repeat(32), 'Advert Only Room', CONTACT_TYPE_ROOM, {
last_seen: 300,
});
const noRecencyRoom = makeContact('99'.repeat(32), 'No Recency Room', CONTACT_TYPE_ROOM);
render(
<Sidebar
contacts={[noRecencyRoom, advertOnlyRoom, dmRecentRoom]}
channels={[publicChannel]}
activeConversation={null}
onSelectConversation={vi.fn()}
onNewMessage={vi.fn()}
lastMessageTimes={{
[getStateKey('contact', dmRecentRoom.public_key)]: 400,
}}
unreadCounts={{}}
mentions={{}}
showCracker={false}
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
legacySortOrder="recent"
/>
);
const roomRows = screen
.getAllByText(/Room$/)
.map((node) => node.textContent)
.filter((text): text is string => Boolean(text));
expect(roomRows).toEqual(['DM Recent Room', 'Advert Only Room', 'No Recency Room']);
});
it('sorts contacts by DM recency first, then advert recency, then no-recency at the bottom', () => {
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const dmRecent = makeContact('11'.repeat(32), 'DM Recent', 1, { last_advert: 100 });
@@ -442,4 +545,90 @@ describe('Sidebar section summaries', () => {
name: 'Public',
});
});
it('sorts favorites independently and persists the favorites sort preference', () => {
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
const amy = makeContact('22'.repeat(32), 'Amy');
const props = {
contacts: [zed, amy],
channels: [publicChannel],
activeConversation: null,
onSelectConversation: vi.fn(),
onNewMessage: vi.fn(),
lastMessageTimes: {
[getStateKey('contact', zed.public_key)]: 200,
},
unreadCounts: {},
mentions: {},
showCracker: false,
crackerRunning: false,
onToggleCracker: vi.fn(),
onMarkAllRead: vi.fn(),
favorites: [
{ type: 'contact', id: zed.public_key },
{ type: 'contact', id: amy.public_key },
] satisfies Favorite[],
legacySortOrder: 'recent' as const,
};
const getFavoritesOrder = () =>
screen
.getAllByText(/^(Amy|Zed)$/)
.map((node) => node.textContent)
.filter((text): text is string => Boolean(text));
const { unmount } = render(<Sidebar {...props} />);
expect(getFavoritesOrder()).toEqual(['Zed', 'Amy']);
fireEvent.click(screen.getByRole('button', { name: 'Sort Favorites alphabetically' }));
expect(getFavoritesOrder()).toEqual(['Amy', 'Zed']);
unmount();
render(<Sidebar {...props} />);
expect(getFavoritesOrder()).toEqual(['Amy', 'Zed']);
});
it('seeds favorites sort from the legacy global sort order when section prefs are missing', () => {
localStorage.setItem('remoteterm-sortOrder', 'alpha');
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
const amy = makeContact('22'.repeat(32), 'Amy');
render(
<Sidebar
contacts={[zed, amy]}
channels={[publicChannel]}
activeConversation={null}
onSelectConversation={vi.fn()}
onNewMessage={vi.fn()}
lastMessageTimes={{
[getStateKey('contact', zed.public_key)]: 200,
}}
unreadCounts={{}}
mentions={{}}
showCracker={false}
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[
{ type: 'contact', id: zed.public_key },
{ type: 'contact', id: amy.public_key },
]}
/>
);
const favoriteRows = screen
.getAllByText(/^(Amy|Zed)$/)
.map((node) => node.textContent)
.filter((text): text is string => Boolean(text));
expect(favoriteRows).toEqual(['Amy', 'Zed']);
expect(screen.getByRole('button', { name: 'Sort Favorites by recent' })).toBeInTheDocument();
});
});

View File

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

View File

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

View File

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

View File

@@ -217,7 +217,9 @@ describe('useConversationMessages conversation switch', () => {
// Switch to conv B while older-messages fetch is still pending
mockGetMessages.mockResolvedValueOnce([createMessage({ id: 999, conversation_key: 'conv_b' })]);
rerender({ conv: convB });
await act(async () => {
rerender({ conv: convB });
});
// loadingOlder must reset immediately — no phantom spinner in conv B
await waitFor(() => expect(result.current.loadingOlder).toBe(false));
@@ -226,12 +228,13 @@ describe('useConversationMessages conversation switch', () => {
expect(result.current.messages[0].conversation_key).toBe('conv_b');
// Resolve the stale older-messages fetch — should not affect conv B's state
olderDeferred.resolve([
createMessage({ id: 500, conversation_key: 'conv_a', text: 'stale-old' }),
]);
await act(async () => {
olderDeferred.resolve([
createMessage({ id: 500, conversation_key: 'conv_a', text: 'stale-old' }),
]);
await Promise.resolve();
});
// Give the stale response time to be processed (it should be discarded)
await new Promise((r) => setTimeout(r, 50));
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].conversation_key).toBe('conv_b');
});

View File

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

View File

@@ -0,0 +1,77 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
describe('useRememberedServerPassword', () => {
beforeEach(() => {
localStorage.clear();
});
it('loads remembered passwords from localStorage', () => {
localStorage.setItem(
'remoteterm-server-password:repeater:abc123',
JSON.stringify({ password: 'stored-secret' })
);
const { result } = renderHook(() => useRememberedServerPassword('repeater', 'abc123'));
expect(result.current.password).toBe('stored-secret');
expect(result.current.rememberPassword).toBe(true);
});
it('stores passwords after login when remember is enabled', () => {
const { result } = renderHook(() => useRememberedServerPassword('room', 'room-key'));
act(() => {
result.current.setRememberPassword(true);
});
act(() => {
result.current.persistAfterLogin(' hello ');
});
expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe(
JSON.stringify({ password: 'hello' })
);
expect(result.current.password).toBe('hello');
});
it('clears stored passwords when login is done with remember disabled', () => {
localStorage.setItem(
'remoteterm-server-password:repeater:abc123',
JSON.stringify({ password: 'stored-secret' })
);
const { result } = renderHook(() => useRememberedServerPassword('repeater', 'abc123'));
act(() => {
result.current.setRememberPassword(false);
});
act(() => {
result.current.persistAfterLogin('new-secret');
});
expect(localStorage.getItem('remoteterm-server-password:repeater:abc123')).toBeNull();
expect(result.current.password).toBe('');
});
it('preserves remembered passwords on guest login when remember stays enabled', () => {
localStorage.setItem(
'remoteterm-server-password:room:room-key',
JSON.stringify({ password: 'stored-secret' })
);
const { result } = renderHook(() => useRememberedServerPassword('room', 'room-key'));
act(() => {
result.current.persistAfterLogin('');
});
expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe(
JSON.stringify({ password: 'stored-secret' })
);
expect(result.current.password).toBe('stored-secret');
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ export interface RadioConfig {
path_hash_mode: number;
path_hash_mode_supported: boolean;
advert_location_source?: 'off' | 'current';
multi_acks_enabled?: boolean;
}
export interface RadioConfigUpdate {
@@ -26,6 +27,7 @@ export interface RadioConfigUpdate {
radio?: RadioSettings;
path_hash_mode?: number;
advert_location_source?: 'off' | 'current';
multi_acks_enabled?: boolean;
}
export type RadioDiscoveryTarget = 'repeaters' | 'sensors' | 'all';
@@ -99,12 +101,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 +122,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;
@@ -339,6 +352,7 @@ export interface MigratePreferencesResponse {
/** Contact type constants */
export const CONTACT_TYPE_REPEATER = 2;
export const CONTACT_TYPE_ROOM = 3;
export interface NeighborInfo {
pubkey_prefix: string;

View File

@@ -3,18 +3,24 @@
*
* Uses the contact's public key to generate a consistent background color,
* and extracts initials or emoji from the name for display.
* Repeaters (type=2) always show 🛜 with a gray background.
* Repeaters (type=2) and room servers (type=3) always show a fixed glyph.
*/
import { CONTACT_TYPE_REPEATER } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
// Repeater avatar styling
// Fixed contact-type avatar styling
const REPEATER_AVATAR = {
text: '🛜',
background: '#444444',
textColor: '#ffffff',
};
const ROOM_AVATAR = {
text: '🛖',
background: '#6b4f2a',
textColor: '#ffffff',
};
// DJB2 hash function for strings
export function hashString(str: string): number {
let hash = 0;
@@ -103,7 +109,7 @@ function getAvatarColor(publicKey: string): {
/**
* Get all avatar properties for a contact.
* Repeaters (type=2) always get a special gray avatar with 🛜.
* Repeaters and room servers always get a special fixed avatar.
*/
export function getContactAvatar(
name: string | null,
@@ -114,10 +120,12 @@ export function getContactAvatar(
background: string;
textColor: string;
} {
// Repeaters always get the repeater avatar
if (contactType === CONTACT_TYPE_REPEATER) {
return REPEATER_AVATAR;
}
if (contactType === CONTACT_TYPE_ROOM) {
return ROOM_AVATAR;
}
const text = getAvatarText(name, publicKey);
const colors = getAvatarColor(publicKey);

View File

@@ -15,7 +15,7 @@ const SIDEBAR_SECTION_SORT_ORDERS_KEY = 'remoteterm-sidebar-section-sort-orders'
export type ConversationTimes = Record<string, number>;
export type SortOrder = 'recent' | 'alpha';
export type SidebarSortableSection = 'channels' | 'contacts' | 'repeaters';
export type SidebarSortableSection = 'favorites' | 'channels' | 'contacts' | 'rooms' | 'repeaters';
export type SidebarSectionSortOrders = Record<SidebarSortableSection, SortOrder>;
// In-memory cache of last message times (loaded from server on init)
@@ -113,8 +113,10 @@ export function buildSidebarSectionSortOrders(
defaultOrder: SortOrder = 'recent'
): SidebarSectionSortOrders {
return {
favorites: defaultOrder,
channels: defaultOrder,
contacts: defaultOrder,
rooms: defaultOrder,
repeaters: defaultOrder,
};
}
@@ -129,8 +131,10 @@ export function loadLocalStorageSidebarSectionSortOrders(): SidebarSectionSortOr
const parsed = JSON.parse(stored) as Partial<SidebarSectionSortOrders>;
return {
favorites: parsed.favorites === 'alpha' ? 'alpha' : 'recent',
channels: parsed.channels === 'alpha' ? 'alpha' : 'recent',
contacts: parsed.contacts === 'alpha' ? 'alpha' : 'recent',
rooms: parsed.rooms === 'alpha' ? 'alpha' : 'recent',
repeaters: parsed.repeaters === 'alpha' ? 'alpha' : 'recent',
};
} catch {

View File

@@ -0,0 +1,32 @@
export const DISTANCE_UNIT_KEY = 'remoteterm-distance-unit';
export const DISTANCE_UNITS = ['imperial', 'metric', 'smoots'] as const;
export type DistanceUnit = (typeof DISTANCE_UNITS)[number];
export const DISTANCE_UNIT_LABELS: Record<DistanceUnit, string> = {
imperial: 'Imperial',
metric: 'Metric',
smoots: 'Smoots',
};
function isDistanceUnit(value: unknown): value is DistanceUnit {
return typeof value === 'string' && DISTANCE_UNITS.includes(value as DistanceUnit);
}
export function getSavedDistanceUnit(): DistanceUnit {
try {
const raw = localStorage.getItem(DISTANCE_UNIT_KEY);
return isDistanceUnit(raw) ? raw : 'metric';
} catch {
return 'metric';
}
}
export function setSavedDistanceUnit(unit: DistanceUnit): void {
try {
localStorage.setItem(DISTANCE_UNIT_KEY, unit);
} catch {
// localStorage may be unavailable
}
}

View File

@@ -1,4 +1,5 @@
import type { Contact, RadioConfig, MessagePath } from '../types';
import type { Contact, ContactRoute, RadioConfig, MessagePath } from '../types';
import type { DistanceUnit } from './distanceUnits';
import { CONTACT_TYPE_REPEATER } from '../types';
const MAX_PATH_BYTES = 64;
@@ -37,6 +38,7 @@ export interface EffectiveContactRoute {
pathLen: number;
pathHashMode: number;
forced: boolean;
source: 'override' | 'direct' | 'flood';
}
function normalizePathHashMode(mode: number | null | undefined): number | null {
@@ -114,29 +116,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 +210,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(',');
}
@@ -284,13 +344,35 @@ export function isValidLocation(lat: number | null, lon: number | null): boolean
}
/**
* Format distance in human-readable form (m or km)
* Format distance in human-readable form using the selected display unit.
*/
export function formatDistance(km: number): string {
if (km < 1) {
return `${Math.round(km * 1000)}m`;
export function formatDistance(km: number, unit: DistanceUnit = 'imperial'): string {
const formatInteger = (value: number) => value.toLocaleString();
const formatOneDecimal = (value: number) =>
value.toLocaleString(undefined, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
if (unit === 'metric') {
if (km < 1) {
return `${formatInteger(Math.round(km * 1000))}m`;
}
return `${formatOneDecimal(km)}km`;
}
return `${km.toFixed(1)}km`;
if (unit === 'smoots') {
const smoots = (km * 1000) / 1.7018;
const rounded = smoots < 10 ? Number(smoots.toFixed(1)) : Math.round(smoots);
const display = smoots < 10 ? formatOneDecimal(rounded) : formatInteger(rounded);
return `${display} ${rounded === 1 ? 'smoot' : 'smoots'}`;
}
const miles = km * 0.621371;
if (miles < 0.1) {
return `${formatInteger(Math.round(km * 3280.839895))}ft`;
}
return `${formatOneDecimal(miles)}mi`;
}
/**

View File

@@ -0,0 +1,388 @@
import {
MeshCoreDecoder,
PayloadType,
Utils,
type DecodedPacket,
type DecryptionOptions,
type HeaderBreakdown,
type PacketStructure,
} from '@michaelhart/meshcore-decoder';
import type { Channel, RawPacket } from '../types';
export interface RawPacketSummary {
summary: string;
routeType: string;
details?: string;
}
export interface PacketByteField {
id: string;
scope: 'packet' | 'payload';
name: string;
description: string;
value: string;
decryptedMessage?: string;
startByte: number;
endByte: number;
absoluteStartByte: number;
absoluteEndByte: number;
headerBreakdown?: HeaderBreakdown;
}
export interface RawPacketInspection {
decoded: DecodedPacket | null;
structure: PacketStructure | null;
routeTypeName: string;
payloadTypeName: string;
payloadVersionName: string;
pathTokens: string[];
summary: RawPacketSummary;
validationErrors: string[];
packetFields: PacketByteField[];
payloadFields: PacketByteField[];
}
export function formatHexByHop(hex: string, hashSize: number | null | undefined): string {
const normalized = hex.trim().toUpperCase();
if (!normalized || !hashSize || hashSize < 1) {
return normalized;
}
const charsPerHop = hashSize * 2;
if (normalized.length <= charsPerHop || normalized.length % charsPerHop !== 0) {
return normalized;
}
const hops = normalized.match(new RegExp(`.{1,${charsPerHop}}`, 'g'));
return hops && hops.length > 1 ? hops.join(' → ') : normalized;
}
export function describeCiphertextStructure(
payloadType: PayloadType,
byteLength: number,
fallbackDescription: string
): string {
switch (payloadType) {
case PayloadType.GroupText:
return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure:
• Timestamp (4 bytes) - send time as unix timestamp
• Flags (1 byte) - room-message flags byte
• Message (remaining bytes) - UTF-8 room message text`;
case PayloadType.TextMessage:
return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure:
• Timestamp (4 bytes) - send time as unix timestamp
• Message (remaining bytes) - UTF-8 direct message text`;
case PayloadType.Response:
return `Encrypted response data (${byteLength} bytes). Contains encrypted plaintext with this structure:
• Tag (4 bytes) - request/response correlation tag
• Content (remaining bytes) - response body`;
default:
return fallbackDescription;
}
}
function getPathTokens(decoded: DecodedPacket): string[] {
const tracePayload =
decoded.payloadType === PayloadType.Trace && decoded.payload.decoded
? (decoded.payload.decoded as { pathHashes?: string[] })
: null;
return tracePayload?.pathHashes || decoded.path || [];
}
function formatUnixTimestamp(timestamp: number): string {
return `${timestamp} (${new Date(timestamp * 1000).toLocaleString()})`;
}
function createPacketField(
scope: 'packet' | 'payload',
id: string,
field: {
name: string;
description: string;
value: string;
decryptedMessage?: string;
startByte: number;
endByte: number;
headerBreakdown?: HeaderBreakdown;
},
absoluteOffset: number
): PacketByteField {
return {
id,
scope,
name: field.name,
description: field.description,
value: field.value,
decryptedMessage: field.decryptedMessage,
startByte: field.startByte,
endByte: field.endByte,
absoluteStartByte: absoluteOffset + field.startByte,
absoluteEndByte: absoluteOffset + field.endByte,
headerBreakdown: field.headerBreakdown,
};
}
export function createDecoderOptions(
channels: Channel[] | null | undefined
): DecryptionOptions | undefined {
const channelSecrets =
channels
?.map((channel) => channel.key?.trim())
.filter((key): key is string => Boolean(key && key.length > 0)) ?? [];
if (channelSecrets.length === 0) {
return undefined;
}
return {
keyStore: MeshCoreDecoder.createKeyStore({ channelSecrets }),
attemptDecryption: true,
};
}
function safeValidate(hexData: string): string[] {
try {
const validation = MeshCoreDecoder.validate(hexData);
return validation.errors ?? [];
} catch (error) {
return [error instanceof Error ? error.message : 'Packet validation failed'];
}
}
export function decodePacketSummary(
packet: RawPacket,
decoderOptions?: DecryptionOptions
): RawPacketSummary {
try {
const decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
if (!decoded.isValid) {
return { summary: 'Invalid packet', routeType: 'Unknown' };
}
const routeType = Utils.getRouteTypeName(decoded.routeType);
const payloadTypeName = Utils.getPayloadTypeName(decoded.payloadType);
const pathTokens = getPathTokens(decoded);
const pathStr = pathTokens.length > 0 ? ` via ${pathTokens.join(', ')}` : '';
let summary = payloadTypeName;
let details: string | undefined;
switch (decoded.payloadType) {
case PayloadType.TextMessage: {
const payload = decoded.payload.decoded as {
destinationHash?: string;
sourceHash?: string;
} | null;
if (payload?.sourceHash && payload?.destinationHash) {
summary = `DM from ${payload.sourceHash} to ${payload.destinationHash}${pathStr}`;
} else {
summary = `DM${pathStr}`;
}
break;
}
case PayloadType.GroupText: {
const payload = decoded.payload.decoded as {
channelHash?: string;
decrypted?: { sender?: string; message?: string };
} | null;
if (packet.decrypted_info?.channel_name) {
if (packet.decrypted_info.sender) {
summary = `GT from ${packet.decrypted_info.sender} in ${packet.decrypted_info.channel_name}${pathStr}`;
} else {
summary = `GT in ${packet.decrypted_info.channel_name}${pathStr}`;
}
} else if (payload?.decrypted?.sender) {
summary = `GT from ${payload.decrypted.sender}${pathStr}`;
} else if (payload?.decrypted?.message) {
summary = `GT decrypted${pathStr}`;
} else if (payload?.channelHash) {
summary = `GT ch:${payload.channelHash}${pathStr}`;
} else {
summary = `GroupText${pathStr}`;
}
break;
}
case PayloadType.Advert: {
const payload = decoded.payload.decoded as {
publicKey?: string;
appData?: { name?: string; deviceRole?: number };
} | null;
if (payload?.appData?.name) {
const role =
payload.appData.deviceRole !== undefined
? Utils.getDeviceRoleName(payload.appData.deviceRole)
: '';
summary = `Advert: ${payload.appData.name}${role ? ` (${role})` : ''}${pathStr}`;
} else if (payload?.publicKey) {
summary = `Advert: ${payload.publicKey.slice(0, 8)}...${pathStr}`;
} else {
summary = `Advert${pathStr}`;
}
break;
}
case PayloadType.Ack:
summary = `ACK${pathStr}`;
break;
case PayloadType.Request:
summary = `Request${pathStr}`;
break;
case PayloadType.Response:
summary = `Response${pathStr}`;
break;
case PayloadType.Trace:
summary = `Trace${pathStr}`;
break;
case PayloadType.Path:
summary = `Path${pathStr}`;
break;
default:
summary = `${payloadTypeName}${pathStr}`;
break;
}
return { summary, routeType, details };
} catch {
return { summary: 'Decode error', routeType: 'Unknown' };
}
}
export function inspectRawPacket(packet: RawPacket): RawPacketInspection {
return inspectRawPacketWithOptions(packet);
}
export function inspectRawPacketWithOptions(
packet: RawPacket,
decoderOptions?: DecryptionOptions
): RawPacketInspection {
const summary = decodePacketSummary(packet, decoderOptions);
const validationErrors = safeValidate(packet.data);
let decoded: DecodedPacket | null = null;
let structure: PacketStructure | null = null;
try {
decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
} catch {
decoded = null;
}
try {
structure = MeshCoreDecoder.analyzeStructure(packet.data, decoderOptions);
} catch {
structure = null;
}
const routeTypeName = decoded?.isValid
? Utils.getRouteTypeName(decoded.routeType)
: summary.routeType;
const payloadTypeName = decoded?.isValid
? Utils.getPayloadTypeName(decoded.payloadType)
: packet.payload_type;
const payloadVersionName = decoded?.isValid
? Utils.getPayloadVersionName(decoded.payloadVersion)
: 'Unknown';
const pathTokens = decoded?.isValid ? getPathTokens(decoded) : [];
const packetFields =
structure?.segments
.map((segment, index) => createPacketField('packet', `packet-${index}`, segment, 0))
.map((field) => {
if (field.name !== 'Path Data') {
return field;
}
const hashSize =
decoded?.pathHashSize ??
(decoded?.pathLength && decoded.pathLength > 0
? Math.max(1, field.value.length / 2 / decoded.pathLength)
: null);
return {
...field,
value: formatHexByHop(field.value, hashSize),
};
}) ?? [];
const payloadFields =
structure == null
? []
: (structure.payload.segments.length > 0
? structure.payload.segments
: structure.payload.hex.length > 0
? [
{
name: 'Payload Bytes',
description:
'Field-level payload breakdown is not available for this packet type.',
startByte: 0,
endByte: Math.max(0, structure.payload.hex.length / 2 - 1),
value: structure.payload.hex,
},
]
: []
).map((segment, index) =>
createPacketField('payload', `payload-${index}`, segment, structure.payload.startByte)
);
const enrichedPayloadFields =
decoded?.isValid && decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded
? payloadFields.map((field) => {
if (field.name !== 'Ciphertext') {
return field;
}
const payload = decoded.payload.decoded as {
decrypted?: { timestamp?: number; flags?: number; sender?: string; message?: string };
};
if (!payload.decrypted?.message) {
return field;
}
const detailLines = [
payload.decrypted.timestamp != null
? `Timestamp: ${formatUnixTimestamp(payload.decrypted.timestamp)}`
: null,
payload.decrypted.flags != null
? `Flags: 0x${payload.decrypted.flags.toString(16).padStart(2, '0')}`
: null,
payload.decrypted.sender ? `Sender: ${payload.decrypted.sender}` : null,
`Message: ${payload.decrypted.message}`,
].filter((line): line is string => line !== null);
return {
...field,
description: describeCiphertextStructure(
decoded.payloadType,
field.endByte - field.startByte + 1,
field.description
),
decryptedMessage: detailLines.join('\n'),
};
})
: payloadFields.map((field) => {
if (!decoded?.isValid || field.name !== 'Ciphertext') {
return field;
}
return {
...field,
description: describeCiphertextStructure(
decoded.payloadType,
field.endByte - field.startByte + 1,
field.description
),
};
});
return {
decoded,
structure,
routeTypeName,
payloadTypeName,
payloadVersionName,
pathTokens,
summary,
validationErrors:
validationErrors.length > 0
? validationErrors
: (decoded?.errors ?? (decoded || structure ? [] : ['Unable to decode packet'])),
packetFields,
payloadFields: enrichedPayloadFields,
};
}

View File

@@ -0,0 +1,531 @@
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
import type { RawPacket } from '../types';
import { getRawPacketObservationKey } from './rawPacketIdentity';
export const RAW_PACKET_STATS_WINDOWS = ['1m', '5m', '10m', '30m', 'session'] as const;
export type RawPacketStatsWindow = (typeof RAW_PACKET_STATS_WINDOWS)[number];
export const RAW_PACKET_STATS_WINDOW_SECONDS: Record<
Exclude<RawPacketStatsWindow, 'session'>,
number
> = {
'1m': 60,
'5m': 5 * 60,
'10m': 10 * 60,
'30m': 30 * 60,
};
export const MAX_RAW_PACKET_STATS_OBSERVATIONS = 20000;
const KNOWN_PAYLOAD_TYPES = [
'Advert',
'GroupText',
'TextMessage',
'Ack',
'Request',
'Response',
'Trace',
'Path',
'Control',
'Unknown',
] as const;
const KNOWN_ROUTE_TYPES = [
'Flood',
'Direct',
'TransportFlood',
'TransportDirect',
'Unknown',
] as const;
export interface RawPacketStatsObservation {
observationKey: string;
timestamp: number;
payloadType: string;
routeType: string;
decrypted: boolean;
rssi: number | null;
snr: number | null;
sourceKey: string | null;
sourceLabel: string | null;
pathTokenCount: number;
pathSignature: string | null;
hopByteWidth?: number | null;
}
export interface RawPacketStatsSessionState {
sessionStartedAt: number;
totalObservedPackets: number;
trimmedObservationCount: number;
observations: RawPacketStatsObservation[];
}
export interface RankedPacketStat {
label: string;
count: number;
share: number;
}
export interface NeighborStat {
key: string;
label: string;
count: number;
bestRssi: number | null;
lastSeen: number;
}
export interface PacketTimelineBin {
label: string;
total: number;
countsByType: Record<string, number>;
}
export interface RawPacketStatsSnapshot {
window: RawPacketStatsWindow;
nowSec: number;
packets: RawPacketStatsObservation[];
packetCount: number;
packetsPerMinute: number;
uniqueSources: number;
decryptedCount: number;
undecryptedCount: number;
decryptRate: number;
pathBearingCount: number;
pathBearingRate: number;
distinctPaths: number;
payloadBreakdown: RankedPacketStat[];
routeBreakdown: RankedPacketStat[];
topPacketTypes: RankedPacketStat[];
hopProfile: RankedPacketStat[];
hopByteWidthProfile: RankedPacketStat[];
strongestNeighbors: NeighborStat[];
mostActiveNeighbors: NeighborStat[];
newestNeighbors: NeighborStat[];
averageRssi: number | null;
medianRssi: number | null;
bestRssi: number | null;
rssiBuckets: RankedPacketStat[];
strongestPacketSourceKey: string | null;
strongestPacketSourceLabel: string | null;
strongestPacketPayloadType: string | null;
coverageSeconds: number;
windowFullyCovered: boolean;
oldestStoredTimestamp: number | null;
timeline: PacketTimelineBin[];
}
function toSourceLabel(sourceKey: string): string {
if (sourceKey.startsWith('name:')) {
return sourceKey.slice(5);
}
return sourceKey.slice(0, 12).toUpperCase();
}
function getPathTokens(decoded: ReturnType<typeof MeshCoreDecoder.decode>): string[] {
const tracePayload =
decoded.payloadType === PayloadType.Trace && decoded.payload.decoded
? (decoded.payload.decoded as { pathHashes?: string[] })
: null;
return tracePayload?.pathHashes || decoded.path || [];
}
function getSourceInfo(
packet: RawPacket,
decoded: ReturnType<typeof MeshCoreDecoder.decode>
): Pick<RawPacketStatsObservation, 'sourceKey' | 'sourceLabel'> {
if (!decoded.isValid || !decoded.payload.decoded) {
const fallbackContactKey = packet.decrypted_info?.contact_key?.toUpperCase() ?? null;
if (fallbackContactKey) {
return {
sourceKey: fallbackContactKey,
sourceLabel: packet.decrypted_info?.sender || toSourceLabel(fallbackContactKey),
};
}
if (packet.decrypted_info?.sender) {
return {
sourceKey: `name:${packet.decrypted_info.sender.toLowerCase()}`,
sourceLabel: packet.decrypted_info.sender,
};
}
return { sourceKey: null, sourceLabel: null };
}
switch (decoded.payloadType) {
case PayloadType.Advert: {
const publicKey = (decoded.payload.decoded as { publicKey?: string }).publicKey;
if (!publicKey) return { sourceKey: null, sourceLabel: null };
return {
sourceKey: publicKey.toUpperCase(),
sourceLabel: publicKey.slice(0, 12).toUpperCase(),
};
}
case PayloadType.TextMessage:
case PayloadType.Request:
case PayloadType.Response: {
const sourceHash = (decoded.payload.decoded as { sourceHash?: string }).sourceHash;
if (!sourceHash) return { sourceKey: null, sourceLabel: null };
return {
sourceKey: sourceHash.toUpperCase(),
sourceLabel: sourceHash.toUpperCase(),
};
}
case PayloadType.GroupText: {
const contactKey = packet.decrypted_info?.contact_key?.toUpperCase() ?? null;
if (contactKey) {
return {
sourceKey: contactKey,
sourceLabel: packet.decrypted_info?.sender || toSourceLabel(contactKey),
};
}
if (packet.decrypted_info?.sender) {
return {
sourceKey: `name:${packet.decrypted_info.sender.toLowerCase()}`,
sourceLabel: packet.decrypted_info.sender,
};
}
return { sourceKey: null, sourceLabel: null };
}
case PayloadType.AnonRequest: {
const senderPublicKey = (decoded.payload.decoded as { senderPublicKey?: string })
.senderPublicKey;
if (!senderPublicKey) return { sourceKey: null, sourceLabel: null };
return {
sourceKey: senderPublicKey.toUpperCase(),
sourceLabel: senderPublicKey.slice(0, 12).toUpperCase(),
};
}
default: {
const fallbackContactKey = packet.decrypted_info?.contact_key?.toUpperCase() ?? null;
if (fallbackContactKey) {
return {
sourceKey: fallbackContactKey,
sourceLabel: packet.decrypted_info?.sender || toSourceLabel(fallbackContactKey),
};
}
return { sourceKey: null, sourceLabel: null };
}
}
}
export function summarizeRawPacketForStats(packet: RawPacket): RawPacketStatsObservation {
try {
const decoded = MeshCoreDecoder.decode(packet.data);
const pathTokens = decoded.isValid ? getPathTokens(decoded) : [];
const payloadType = decoded.isValid
? Utils.getPayloadTypeName(decoded.payloadType)
: packet.payload_type;
const routeType = decoded.isValid ? Utils.getRouteTypeName(decoded.routeType) : 'Unknown';
const sourceInfo = getSourceInfo(packet, decoded);
return {
observationKey: getRawPacketObservationKey(packet),
timestamp: packet.timestamp,
payloadType,
routeType,
decrypted: packet.decrypted,
rssi: packet.rssi,
snr: packet.snr,
sourceKey: sourceInfo.sourceKey,
sourceLabel: sourceInfo.sourceLabel,
pathTokenCount: pathTokens.length,
pathSignature: pathTokens.length > 0 ? pathTokens.join('>') : null,
hopByteWidth: pathTokens.length > 0 ? (decoded.pathHashSize ?? 1) : null,
};
} catch {
return {
observationKey: getRawPacketObservationKey(packet),
timestamp: packet.timestamp,
payloadType: packet.payload_type,
routeType: 'Unknown',
decrypted: packet.decrypted,
rssi: packet.rssi,
snr: packet.snr,
sourceKey: null,
sourceLabel: null,
pathTokenCount: 0,
pathSignature: null,
hopByteWidth: null,
};
}
}
function inferHopByteWidth(packet: RawPacketStatsObservation): number | null {
if (packet.pathTokenCount <= 0) {
return null;
}
if (packet.hopByteWidth && packet.hopByteWidth > 0) {
return packet.hopByteWidth;
}
const firstToken = packet.pathSignature?.split('>')[0] ?? null;
if (!firstToken || firstToken.length % 2 !== 0) {
return null;
}
const inferred = firstToken.length / 2;
return inferred >= 1 && inferred <= 3 ? inferred : null;
}
function share(count: number, total: number): number {
if (total <= 0) return 0;
return count / total;
}
function createCountsMap(labels: readonly string[]): Map<string, number> {
return new Map(labels.map((label) => [label, 0]));
}
function rankedBreakdown(counts: Map<string, number>, total: number): RankedPacketStat[] {
return Array.from(counts.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([label, count]) => ({ label, count, share: share(count, total) }));
}
function orderedBreakdown(counts: Map<string, number>, total: number): RankedPacketStat[] {
return Array.from(counts.entries()).map(([label, count]) => ({
label,
count,
share: share(count, total),
}));
}
function median(values: number[]): number | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) {
return sorted[mid];
}
return (sorted[mid - 1] + sorted[mid]) / 2;
}
function formatTimelineLabel(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
}
function getHopProfileBucket(pathTokenCount: number): string {
if (pathTokenCount <= 0) {
return '0';
}
if (pathTokenCount === 1) {
return '1';
}
if (pathTokenCount <= 5) {
return '2-5';
}
if (pathTokenCount <= 10) {
return '6-10';
}
if (pathTokenCount <= 15) {
return '11-15';
}
return '16+';
}
export function buildRawPacketStatsSnapshot(
session: RawPacketStatsSessionState,
window: RawPacketStatsWindow,
nowSec: number = Math.floor(Date.now() / 1000)
): RawPacketStatsSnapshot {
const sessionStartedSec = Math.floor(session.sessionStartedAt / 1000);
const windowSeconds = window === 'session' ? null : RAW_PACKET_STATS_WINDOW_SECONDS[window];
const windowStart = windowSeconds === null ? sessionStartedSec : nowSec - windowSeconds;
const packets = session.observations.filter((packet) => packet.timestamp >= windowStart);
const packetCount = packets.length;
const uniqueSources = new Set(packets.map((packet) => packet.sourceKey).filter(Boolean)).size;
const decryptedCount = packets.filter((packet) => packet.decrypted).length;
const undecryptedCount = packetCount - decryptedCount;
const pathBearingCount = packets.filter((packet) => packet.pathTokenCount > 0).length;
const distinctPaths = new Set(
packets.map((packet) => packet.pathSignature).filter((value): value is string => Boolean(value))
).size;
const effectiveCoverageSeconds =
windowSeconds ?? Math.max(1, nowSec - Math.min(sessionStartedSec, nowSec));
const packetsPerMinute = packetCount / Math.max(effectiveCoverageSeconds / 60, 1 / 60);
const payloadCounts = createCountsMap(KNOWN_PAYLOAD_TYPES);
const routeCounts = createCountsMap(KNOWN_ROUTE_TYPES);
const hopCounts = new Map<string, number>([
['0', 0],
['1', 0],
['2-5', 0],
['6-10', 0],
['11-15', 0],
['16+', 0],
]);
const hopByteWidthCounts = new Map<string, number>([
['No path', 0],
['1 byte / hop', 0],
['2 bytes / hop', 0],
['3 bytes / hop', 0],
['Unknown width', 0],
]);
const neighborMap = new Map<string, NeighborStat>();
const rssiValues: number[] = [];
const rssiBucketCounts = new Map<string, number>([
['Strong (>-70 dBm)', 0],
['Okay (-70 to -85 dBm)', 0],
['Weak (<-85 dBm)', 0],
]);
let strongestPacket: RawPacketStatsObservation | null = null;
for (const packet of packets) {
payloadCounts.set(packet.payloadType, (payloadCounts.get(packet.payloadType) ?? 0) + 1);
routeCounts.set(packet.routeType, (routeCounts.get(packet.routeType) ?? 0) + 1);
const hopProfileBucket = getHopProfileBucket(packet.pathTokenCount);
hopCounts.set(hopProfileBucket, (hopCounts.get(hopProfileBucket) ?? 0) + 1);
const hopByteWidth = inferHopByteWidth(packet);
if (packet.pathTokenCount <= 0) {
hopByteWidthCounts.set('No path', (hopByteWidthCounts.get('No path') ?? 0) + 1);
} else if (hopByteWidth === 1) {
hopByteWidthCounts.set('1 byte / hop', (hopByteWidthCounts.get('1 byte / hop') ?? 0) + 1);
} else if (hopByteWidth === 2) {
hopByteWidthCounts.set('2 bytes / hop', (hopByteWidthCounts.get('2 bytes / hop') ?? 0) + 1);
} else if (hopByteWidth === 3) {
hopByteWidthCounts.set('3 bytes / hop', (hopByteWidthCounts.get('3 bytes / hop') ?? 0) + 1);
} else {
hopByteWidthCounts.set('Unknown width', (hopByteWidthCounts.get('Unknown width') ?? 0) + 1);
}
if (packet.sourceKey && packet.sourceLabel) {
const existing = neighborMap.get(packet.sourceKey);
if (!existing) {
neighborMap.set(packet.sourceKey, {
key: packet.sourceKey,
label: packet.sourceLabel,
count: 1,
bestRssi: packet.rssi,
lastSeen: packet.timestamp,
});
} else {
existing.count += 1;
existing.lastSeen = Math.max(existing.lastSeen, packet.timestamp);
if (
packet.rssi !== null &&
(existing.bestRssi === null || packet.rssi > existing.bestRssi)
) {
existing.bestRssi = packet.rssi;
}
}
}
if (packet.rssi !== null) {
rssiValues.push(packet.rssi);
if (packet.rssi > -70) {
rssiBucketCounts.set(
'Strong (>-70 dBm)',
(rssiBucketCounts.get('Strong (>-70 dBm)') ?? 0) + 1
);
} else if (packet.rssi >= -85) {
rssiBucketCounts.set(
'Okay (-70 to -85 dBm)',
(rssiBucketCounts.get('Okay (-70 to -85 dBm)') ?? 0) + 1
);
} else {
rssiBucketCounts.set('Weak (<-85 dBm)', (rssiBucketCounts.get('Weak (<-85 dBm)') ?? 0) + 1);
}
if (!strongestPacket || strongestPacket.rssi === null || packet.rssi > strongestPacket.rssi) {
strongestPacket = packet;
}
}
}
const averageRssi =
rssiValues.length > 0
? rssiValues.reduce((sum, value) => sum + value, 0) / rssiValues.length
: null;
const bestRssi = rssiValues.length > 0 ? Math.max(...rssiValues) : null;
const medianRssi = median(rssiValues);
const neighbors = Array.from(neighborMap.values());
const strongestNeighbors = [...neighbors]
.filter((neighbor) => neighbor.bestRssi !== null)
.sort(
(a, b) =>
(b.bestRssi ?? Number.NEGATIVE_INFINITY) - (a.bestRssi ?? Number.NEGATIVE_INFINITY) ||
b.count - a.count ||
a.label.localeCompare(b.label)
)
.slice(0, 5);
const mostActiveNeighbors = [...neighbors]
.sort((a, b) => b.count - a.count || b.lastSeen - a.lastSeen || a.label.localeCompare(b.label))
.slice(0, 5);
const newestNeighbors = [...neighbors]
.sort((a, b) => b.lastSeen - a.lastSeen || b.count - a.count || a.label.localeCompare(b.label))
.slice(0, 5);
const oldestStoredTimestamp = session.observations[0]?.timestamp ?? null;
const detailedCoverageStart =
session.trimmedObservationCount > 0 ? (oldestStoredTimestamp ?? nowSec) : sessionStartedSec;
const windowFullyCovered =
window === 'session'
? session.trimmedObservationCount === 0
: detailedCoverageStart <= windowStart;
const coverageStart = Math.max(windowStart, detailedCoverageStart);
const coverageSeconds =
window === 'session'
? Math.max(1, nowSec - detailedCoverageStart)
: Math.max(1, nowSec - coverageStart);
const timelineSpanSeconds = Math.max(
windowSeconds ?? Math.max(60, nowSec - sessionStartedSec),
60
);
const timelineBinCount = 10;
const binWidth = Math.max(1, timelineSpanSeconds / timelineBinCount);
const timeline = Array.from({ length: timelineBinCount }, (_, index) => {
const start = Math.floor(windowStart + index * binWidth);
return {
label: formatTimelineLabel(start),
total: 0,
countsByType: {} as Record<string, number>,
};
});
for (const packet of packets) {
const rawIndex = Math.floor((packet.timestamp - windowStart) / binWidth);
const index = Math.max(0, Math.min(timelineBinCount - 1, rawIndex));
const bin = timeline[index];
bin.total += 1;
bin.countsByType[packet.payloadType] = (bin.countsByType[packet.payloadType] ?? 0) + 1;
}
return {
window,
nowSec,
packets,
packetCount,
packetsPerMinute,
uniqueSources,
decryptedCount,
undecryptedCount,
decryptRate: share(decryptedCount, packetCount),
pathBearingCount,
pathBearingRate: share(pathBearingCount, packetCount),
distinctPaths,
payloadBreakdown: rankedBreakdown(payloadCounts, packetCount),
routeBreakdown: rankedBreakdown(routeCounts, packetCount),
topPacketTypes: rankedBreakdown(payloadCounts, packetCount).slice(0, 5),
hopProfile: orderedBreakdown(hopCounts, packetCount),
hopByteWidthProfile: rankedBreakdown(hopByteWidthCounts, packetCount),
strongestNeighbors,
mostActiveNeighbors,
newestNeighbors,
averageRssi,
medianRssi,
bestRssi,
rssiBuckets: rankedBreakdown(rssiBucketCounts, rssiValues.length),
strongestPacketSourceKey: strongestPacket?.sourceKey ?? null,
strongestPacketSourceLabel: strongestPacket?.sourceLabel ?? null,
strongestPacketPayloadType: strongestPacket?.payloadType ?? null,
coverageSeconds,
windowFullyCovered,
oldestStoredTimestamp,
timeline,
};
}

View File

@@ -12,7 +12,7 @@ dependencies = [
"httpx>=0.28.1",
"pycryptodome>=3.20.0",
"pynacl>=1.5.0",
"meshcore==2.2.29",
"meshcore==2.3.1",
"aiomqtt>=2.0",
"apprise>=1.9.7",
"boto3>=1.38.0",

View File

@@ -12,10 +12,14 @@ SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
NODE_VERSIONS=("20" "22" "24")
# Use explicit npm patch versions so resolver regressions are caught.
NPM_VERSIONS=("9.1.1" "9.9.4" "10.9.5" "11.6.2")
echo -e "${YELLOW}=== Frontend Docker CI Matrix ===${NC}"
echo -e "${BLUE}Repo:${NC} $SCRIPT_DIR"
echo
EXTRA_CASES=(
"18|9.1.1"
"20|8.19.4"
"18|8.19.4"
"24|11.12.0"
"25|11.6.2"
"25|11.12.0"
)
run_combo() {
local node_version="$1"
@@ -36,9 +40,33 @@ run_combo() {
npm ci
npm run build
"
}
declare -a TEST_CASES=()
declare -A SEEN_CASES=()
add_case() {
local node_version="$1"
local npm_version="$2"
local key="${node_version}|${npm_version}"
if [[ -n "${SEEN_CASES[$key]:-}" ]]; then
return
fi
SEEN_CASES["$key"]=1
TEST_CASES+=("$key")
}
for node_version in "${NODE_VERSIONS[@]}"; do
for npm_version in "${NPM_VERSIONS[@]}"; do
add_case "$node_version" "$npm_version"
done
done
for case_spec in "${EXTRA_CASES[@]}"; do
IFS='|' read -r node_version npm_version <<<"$case_spec"
add_case "$node_version" "$npm_version"
done
TMP_DIR="$(mktemp -d)"
declare -a JOB_PIDS=()
declare -a JOB_LABELS=()
@@ -50,21 +78,25 @@ cleanup() {
trap cleanup EXIT
for node_version in "${NODE_VERSIONS[@]}"; do
for npm_version in "${NPM_VERSIONS[@]}"; do
label="Node ${node_version} / npm ${npm_version}"
log_file="$TMP_DIR/node-${node_version}-npm-${npm_version}.log"
echo -e "${YELLOW}=== Frontend Docker CI Matrix ===${NC}"
echo -e "${BLUE}Repo:${NC} $SCRIPT_DIR"
echo
echo -e "${BLUE}Starting:${NC} ${label}"
(
echo -e "${YELLOW}=== ${label} ===${NC}"
run_combo "$node_version" "$npm_version"
) >"$log_file" 2>&1 &
for case_spec in "${TEST_CASES[@]}"; do
IFS='|' read -r node_version npm_version <<<"$case_spec"
label="Node ${node_version} / npm ${npm_version}"
safe_npm="${npm_version//./-}"
log_file="$TMP_DIR/node-${node_version}-npm-${safe_npm}.log"
JOB_PIDS+=("$!")
JOB_LABELS+=("$label")
JOB_LOGS+=("$log_file")
done
echo -e "${BLUE}Starting:${NC} ${label}"
(
echo -e "${YELLOW}=== ${label} ===${NC}"
run_combo "$node_version" "$npm_version"
) >"$log_file" 2>&1 &
JOB_PIDS+=("$!")
JOB_LABELS+=("$label")
JOB_LOGS+=("$log_file")
done
echo

View File

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

View File

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

View File

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

View File

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

View File

@@ -108,20 +108,24 @@ class TestJwtGeneration:
def test_payload_contains_required_fields(self):
private_key, public_key = _make_test_keys()
token = _generate_jwt_token(private_key, public_key)
payload_b64 = token.split(".")[1]
import base64
with patch(
"app.fanout.community_mqtt.get_app_build_info",
return_value=SimpleNamespace(version="1.2.3", commit_hash="abcdef"),
):
token = _generate_jwt_token(private_key, public_key)
payload_b64 = token.split(".")[1]
import base64
padded = payload_b64 + "=" * (4 - len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded))
assert payload["publicKey"] == public_key.hex().upper()
assert "iat" in payload
assert "exp" in payload
assert payload["exp"] - payload["iat"] == 86400
assert payload["aud"] == _DEFAULT_BROKER
assert payload["owner"] == public_key.hex().upper()
assert payload["client"] == _CLIENT_ID
assert "email" not in payload # omitted when empty
padded = payload_b64 + "=" * (4 - len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded))
assert payload["publicKey"] == public_key.hex().upper()
assert "iat" in payload
assert "exp" in payload
assert payload["exp"] - payload["iat"] == 86400
assert payload["aud"] == _DEFAULT_BROKER
assert payload["owner"] == public_key.hex().upper()
assert payload["client"] == f"{_CLIENT_ID}/1.2.3-abcdef"
assert "email" not in payload # omitted when empty
def test_payload_includes_email_when_provided(self):
private_key, public_key = _make_test_keys()
@@ -822,7 +826,10 @@ class TestLwtAndStatusPublish:
pub, "_fetch_stats", new_callable=AsyncMock, return_value={"battery_mv": 4200}
),
patch("app.fanout.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
patch("app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm 2.4.0"),
patch(
"app.fanout.community_mqtt._get_client_version",
return_value="RemoteTerm/2.4.0-abcdef",
),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
await pub._on_connected_async(settings)
@@ -842,7 +849,7 @@ class TestLwtAndStatusPublish:
assert payload["model"] == "T-Deck"
assert payload["firmware_version"] == "v2.2.2 (Build: 2025-01-15)"
assert payload["radio"] == "915.0,250.0,10,8"
assert payload["client_version"] == "RemoteTerm 2.4.0"
assert payload["client_version"] == "RemoteTerm/2.4.0-abcdef"
assert payload["stats"] == {"battery_mv": 4200}
def test_lwt_and_online_share_same_topic(self):
@@ -902,7 +909,8 @@ class TestLwtAndStatusPublish:
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch(
"app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm unknown"
"app.fanout.community_mqtt._get_client_version",
return_value="RemoteTerm/0.0.0-unknown",
),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
@@ -1215,18 +1223,21 @@ class TestBuildRadioInfo:
class TestGetClientVersion:
def test_returns_plain_version(self):
"""Should return a bare version string with no product prefix."""
result = _get_client_version()
assert result
assert "RemoteTerm" not in result
def test_returns_version_from_build_helper(self):
"""Should use the shared backend build-info helper."""
def test_returns_canonical_client_identifier(self):
"""Should return the canonical client/version/hash identifier."""
with patch("app.fanout.community_mqtt.get_app_build_info") as mock_build_info:
mock_build_info.return_value.version = "1.2.3"
mock_build_info.return_value.commit_hash = "abcdef"
result = _get_client_version()
assert result == "1.2.3"
assert result == "RemoteTerm/1.2.3-abcdef"
def test_falls_back_to_unknown_hash_when_commit_missing(self):
"""Should keep the canonical shape even when the commit hash is unavailable."""
with patch("app.fanout.community_mqtt.get_app_build_info") as mock_build_info:
mock_build_info.return_value.version = "1.2.3"
mock_build_info.return_value.commit_hash = None
result = _get_client_version()
assert result == "RemoteTerm/1.2.3-unknown"
class TestPublishStatus:
@@ -1255,7 +1266,10 @@ class TestPublishStatus:
),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=stats),
patch("app.fanout.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
patch("app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm 2.4.0"),
patch(
"app.fanout.community_mqtt._get_client_version",
return_value="RemoteTerm/2.4.0-abcdef",
),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
await pub._publish_status(settings)
@@ -1268,7 +1282,7 @@ class TestPublishStatus:
assert payload["model"] == "T-Deck"
assert payload["firmware_version"] == "v2.2.2 (Build: 2025-01-15)"
assert payload["radio"] == "915.0,250.0,10,8"
assert payload["client_version"] == "RemoteTerm 2.4.0"
assert payload["client_version"] == "RemoteTerm/2.4.0-abcdef"
assert payload["stats"] == stats
@pytest.mark.asyncio
@@ -1293,7 +1307,8 @@ class TestPublishStatus:
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch(
"app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm unknown"
"app.fanout.community_mqtt._get_client_version",
return_value="RemoteTerm/0.0.0-unknown",
),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
@@ -1326,7 +1341,8 @@ class TestPublishStatus:
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch(
"app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm unknown"
"app.fanout.community_mqtt._get_client_version",
return_value="RemoteTerm/0.0.0-unknown",
),
patch.object(pub, "publish", new_callable=AsyncMock),
):

Some files were not shown because too many files have changed in this diff Show More