5 Commits

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

View File

@@ -307,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`, 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 |
| 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 |
| 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

@@ -169,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`, 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`
- `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
- `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

View File

@@ -58,15 +58,6 @@ 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
@@ -507,13 +498,6 @@ 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)
@@ -529,7 +513,6 @@ 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,
)

View File

@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
from meshcore import EventType
from app.models import CONTACT_TYPE_ROOM, Contact, ContactUpsert
from app.models import Contact, ContactUpsert
from app.packet_processor import process_raw_packet
from app.repository import (
ContactRepository,
@@ -17,7 +17,6 @@ from app.services.contact_reconciliation import (
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.websocket import broadcast_event
@@ -88,23 +87,6 @@ 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", ""),
@@ -113,9 +95,9 @@ async def on_contact_message(event: "Event") -> None:
path=path,
path_len=path_len,
txt_type=txt_type,
signature=signature,
sender_name=sender_name,
sender_key=sender_key,
signature=payload.get("signature"),
sender_name=context.sender_name,
sender_key=context.sender_key,
broadcast_fn=broadcast_event,
update_last_contacted_key=context.contact.public_key.lower() if context.contact else None,
)

View File

@@ -65,7 +65,6 @@ 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:
@@ -79,7 +78,6 @@ 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, get_fanout_message_text
from app.fanout.base import FanoutModule
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 = get_fanout_message_text(data)
text = data.get("text", "")
sender_name = data.get("sender_name") or "Unknown"
via = ""

View File

@@ -33,30 +33,3 @@ 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": _get_client_version(),
"client": _CLIENT_ID,
}
if email:
payload["email"] = email
@@ -260,10 +260,8 @@ def _build_radio_info() -> str:
def _get_client_version() -> str:
"""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}"
"""Return the app version string for community MQTT payloads."""
return get_app_build_info().version
class CommunityMqttPublisher(BaseMqttPublisher):

View File

@@ -32,7 +32,6 @@ from app.routers import (
radio,
read_state,
repeaters,
rooms,
settings,
statistics,
ws,
@@ -135,7 +134,6 @@ 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

@@ -231,7 +231,6 @@ class ContactRoutingOverrideRequest(BaseModel):
# Contact type constants
CONTACT_TYPE_REPEATER = 2
CONTACT_TYPE_ROOM = 3
class ContactAdvertPath(BaseModel):

View File

@@ -78,7 +78,6 @@ 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
@@ -235,9 +234,6 @@ 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,10 +81,6 @@ 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):
@@ -103,10 +99,6 @@ 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):
@@ -230,7 +222,6 @@ 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,5 +1,6 @@
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from fastapi import APIRouter, HTTPException
@@ -27,14 +28,6 @@ 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:
@@ -50,11 +43,39 @@ 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:
return extract_response_text(event)
"""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_repeater_response(
@@ -62,6 +83,21 @@ 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:
@@ -69,12 +105,13 @@ async def _fetch_repeater_response(
result = await mc.commands.get_msg(timeout=2.0)
except asyncio.TimeoutError:
continue
except Exception as exc:
logger.debug("get_msg() exception: %s", exc)
except Exception as e:
logger.debug("get_msg() exception: %s", e)
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
@@ -88,6 +125,8 @@ 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,
@@ -97,6 +136,7 @@ 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"),
@@ -110,13 +150,87 @@ async def _fetch_repeater_response(
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
return await prepare_authenticated_contact_connection(
mc,
contact,
password,
label="repeater",
response_timeout=REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
"""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},
)
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:
@@ -289,7 +403,43 @@ async def _batch_cli_fetch(
operation_name: str,
commands: list[tuple[str, str]],
) -> dict[str, str | None]:
return await batch_cli_fetch(contact, operation_name, commands)
"""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
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
@@ -374,13 +524,72 @@ 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 or room server."""
"""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
"""
require_connected()
# Get contact from database
contact = await _resolve_contact_or_404(public_key)
require_server_capable_contact(contact)
return await send_contact_cli_command(
contact,
request.command,
operation_name="send_repeater_command",
)
_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,
)

View File

@@ -1,145 +0,0 @@
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

@@ -1,317 +0,0 @@
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

@@ -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_TYPE_ROOM, Contact, ContactUpsert, Message
from app.models import CONTACT_TYPE_REPEATER, Contact, ContactUpsert, Message
from app.repository import (
AmbiguousPublicKeyPrefixError,
ContactRepository,
@@ -106,35 +106,6 @@ 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,
@@ -266,19 +237,8 @@ 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:
@@ -289,17 +249,7 @@ async def ingest_decrypted_direct_message(
)
return None
if not outgoing:
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
sender_name = contact.name
received = received_at or int(time.time())
message = await _store_direct_message(
@@ -311,10 +261,10 @@ async def ingest_decrypted_direct_message(
path=path,
path_len=path_len,
outgoing=outgoing,
txt_type=decrypted.txt_type,
signature=signature,
txt_type=0,
signature=None,
sender_name=sender_name,
sender_key=sender_key,
sender_key=conversation_key if not outgoing else None,
realtime=realtime,
broadcast_fn=broadcast_fn,
update_last_contacted_key=conversation_key,

View File

@@ -44,13 +44,6 @@ 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,12 +245,10 @@ 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.
@@ -425,9 +423,9 @@ PYTHONPATH=. uv run pytest tests/ -v
## Errata & Known Non-Issues
### Contacts use mention styling for unread DMs
### Contacts rollup uses mention styling for unread DMs
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.
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.
### RawPacketList always scrolls to bottom

View File

@@ -14,11 +14,9 @@ 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';
@@ -83,7 +81,6 @@ export function App() {
toggleConversationNotifications,
notifyIncomingMessage,
} = useBrowserNotifications();
const { rawPacketStatsSession, recordRawPacketObservation } = useRawPacketStatsSession();
const {
showNewMessage,
showSettings,
@@ -92,12 +89,10 @@ export function App() {
showCracker,
crackerRunning,
localLabel,
distanceUnit,
setSettingsSection,
setSidebarOpen,
setCrackerRunning,
setLocalLabel,
setDistanceUnit,
handleCloseSettingsView,
handleToggleSettingsView,
handleOpenNewMessage,
@@ -336,7 +331,6 @@ export function App() {
removeConversationMessages,
receiveMessageAck,
notifyIncomingMessage,
recordRawPacketObservation,
});
const handleVisibilityPolicyChanged = useCallback(() => {
clearConversationMessages();
@@ -419,7 +413,6 @@ export function App() {
contacts,
channels,
rawPackets,
rawPacketStatsSession,
config,
health,
favorites,
@@ -567,31 +560,29 @@ export function App() {
setContactsLoaded,
]);
return (
<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>
<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}
/>
);
}

View File

@@ -383,21 +383,4 @@ 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,7 +19,6 @@ import type {
PathDiscoveryResponse,
RadioConfig,
} from '../types';
import { CONTACT_TYPE_ROOM } from '../types';
interface ChatHeaderProps {
conversation: Conversation;
@@ -85,7 +84,6 @@ 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;
@@ -232,7 +230,7 @@ export function ChatHeader({
</span>
</span>
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
{conversation.type === 'contact' && !activeContactIsRoomServer && (
{conversation.type === 'contact' && (
<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)}
@@ -247,7 +245,7 @@ export function ChatHeader({
<Route className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</button>
)}
{conversation.type === 'contact' && !activeContactIsRoomServer && (
{conversation.type === 'contact' && (
<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}
@@ -262,7 +260,7 @@ export function ChatHeader({
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
</button>
)}
{notificationsSupported && !activeContactIsRoomServer && (
{notificationsSupported && (
<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

@@ -24,7 +24,6 @@ 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,
@@ -83,7 +82,6 @@ export function ContactInfoPane({
onToggleBlockedKey,
onToggleBlockedName,
}: ContactInfoPaneProps) {
const { distanceUnit } = useDistanceUnit();
const isNameOnly = contactKey?.startsWith('name:') ?? false;
const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null;
@@ -317,7 +315,7 @@ export function ContactInfoPane({
<InfoItem label="Last Contacted" value={formatTime(contact.last_contacted)} />
)}
{distFromUs !== null && (
<InfoItem label="Distance" value={formatDistance(distFromUs, distanceUnit)} />
<InfoItem label="Distance" value={formatDistance(distFromUs)} />
)}
{effectiveRoute && (
<InfoItem

View File

@@ -11,7 +11,6 @@ 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 {
@@ -25,7 +24,6 @@ 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);
@@ -76,7 +74,7 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
>
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
</span>
{distFromUs !== null && ` (${formatDistance(distFromUs, distanceUnit)})`}
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
</span>
);
}

View File

@@ -1,10 +1,9 @@
import { lazy, Suspense, useEffect, useMemo, useState, type Ref } from 'react';
import { lazy, Suspense, useMemo, type Ref } from 'react';
import { ChatHeader } from './ChatHeader';
import { MessageInput, type MessageInputHandle } from './MessageInput';
import { MessageList } from './MessageList';
import { RawPacketFeedView } from './RawPacketFeedView';
import { RoomServerPanel } from './RoomServerPanel';
import { RawPacketList } from './RawPacketList';
import type {
Channel,
Contact,
@@ -16,8 +15,7 @@ import type {
RawPacket,
RadioConfig,
} from '../types';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { isPrefixOnlyContact, isUnknownFullKeyContact } from '../utils/pubkey';
const RepeaterDashboard = lazy(() =>
@@ -33,7 +31,6 @@ interface ConversationPaneProps {
contacts: Contact[];
channels: Channel[];
rawPackets: RawPacket[];
rawPacketStatsSession: RawPacketStatsSessionState;
config: RadioConfig | null;
health: HealthStatus | null;
notificationsSupported: boolean;
@@ -98,7 +95,6 @@ export function ConversationPane({
contacts,
channels,
rawPackets,
rawPacketStatsSession,
config,
health,
notificationsSupported,
@@ -132,7 +128,6 @@ 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);
@@ -142,10 +137,6 @@ 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;
@@ -187,12 +178,14 @@ export function ConversationPane({
if (activeConversation.type === 'raw') {
return (
<RawPacketFeedView
packets={rawPackets}
rawPacketStatsSession={rawPacketStatsSession}
contacts={contacts}
channels={channels}
/>
<>
<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>
</>
);
}
@@ -224,8 +217,6 @@ export function ConversationPane({
);
}
const showRoomChat = !activeContactIsRoom || roomAuthenticated;
return (
<>
<ChatHeader
@@ -253,40 +244,35 @@ export function ConversationPane({
{activeConversation.type === 'contact' && isUnknownFullKeyActiveContact && (
<ContactResolutionBanner variant="unknown-full-key" />
)}
{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) ? (
<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 : (
<MessageInput
ref={messageInputRef}
onSend={onSendMessage}
@@ -299,7 +285,7 @@ export function ConversationPane({
: `Message ${activeConversation.name}...`
}
/>
) : null}
)}
</>
);
}

View File

@@ -9,7 +9,7 @@ import {
type ReactNode,
} from 'react';
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
import { getDirectContactRoute } from '../utils/pathUtils';
@@ -500,33 +500,6 @@ 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 {
@@ -611,8 +584,6 @@ 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}`;
@@ -641,24 +612,18 @@ 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 || (isRoomServer && msg.type === 'PRIV')
? { sender: null, content: msg.text }
: parseSenderFromText(msg.text);
const directSenderName =
msg.type === 'PRIV' && isRoomServer ? msg.sender_name || null : null;
const { sender, content } = isRepeater
? { sender: null, content: msg.text }
: parseSenderFromText(msg.text);
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'
: directSenderName ||
(isRoomServer && msg.sender_key ? msg.sender_key.slice(0, 8) : null) ||
contact?.name ||
: contact?.name ||
channelSenderName ||
(isCorruptChannelMessage
? CORRUPT_SENDER_LABEL
@@ -671,22 +636,15 @@ 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,
directSenderName || channelSenderName,
isCorruptChannelMessage
);
const currentSenderKey = getSenderKey(msg, channelSenderName, isCorruptChannelMessage);
const prevMsg = sortedMessages[index - 1];
const prevParsedSender = prevMsg ? parseSenderFromText(prevMsg.text).sender : null;
const prevSenderKey = prevMsg
? getSenderKey(
prevMsg,
prevMsg.type === 'PRIV' &&
getContact(prevMsg.conversation_key)?.type === CONTACT_TYPE_ROOM
? prevMsg.sender_name
: prevMsg.type === 'CHAN'
? prevMsg.sender_name || prevParsedSender
: prevParsedSender,
prevMsg.type === 'CHAN'
? prevMsg.sender_name || prevParsedSender
: prevParsedSender,
isCorruptUnnamedChannelMessage(prevMsg, prevParsedSender)
)
: null;
@@ -700,14 +658,9 @@ export function MessageList({
let avatarVariant: 'default' | 'corrupt' = 'default';
if (!msg.outgoing) {
if (msg.type === 'PRIV' && 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;
}
// DM: use conversation_key (sender's public key)
avatarName = contact?.name || null;
avatarKey = msg.conversation_key;
} else if (isCorruptChannelMessage) {
avatarName = CORRUPT_SENDER_LABEL;
avatarKey = `corrupt:${msg.id}`;
@@ -772,12 +725,7 @@ 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' || (msg.type === 'PRIV' && isRoomServer)
)
}
onClick={() => onOpenContactInfo(avatarKey, msg.type === 'CHAN')}
>
<ContactAvatar
name={avatarName}
@@ -832,7 +780,7 @@ export function MessageList({
onClick={() =>
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
senderInfo: getSenderInfo(msg, contact, sender),
})
}
/>
@@ -858,7 +806,7 @@ export function MessageList({
onClick={() =>
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
senderInfo: getSenderInfo(msg, contact, sender),
})
}
/>

View File

@@ -14,8 +14,6 @@ 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 }))
@@ -46,7 +44,6 @@ 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;
@@ -123,8 +120,7 @@ export function PathModal({
resolvedPaths[0].resolved.sender.lon,
resolvedPaths[0].resolved.receiver.lat,
resolvedPaths[0].resolved.receiver.lon
)!,
distanceUnit
)!
)}
</span>
</div>
@@ -175,11 +171,7 @@ export function PathModal({
</Suspense>
</div>
)}
<PathVisualization
resolved={pathData.resolved}
senderInfo={senderInfo}
distanceUnit={distanceUnit}
/>
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
</div>
);
})}
@@ -235,10 +227,9 @@ export function PathModal({
interface PathVisualizationProps {
resolved: ResolvedPath;
senderInfo: SenderInfo;
distanceUnit: DistanceUnit;
}
function PathVisualization({ resolved, senderInfo, distanceUnit }: PathVisualizationProps) {
function PathVisualization({ resolved, senderInfo }: 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 => {
@@ -273,7 +264,6 @@ function PathVisualization({ resolved, senderInfo, distanceUnit }: PathVisualiza
name={resolved.sender.name}
prefix={resolved.sender.prefix}
distance={null}
distanceUnit={distanceUnit}
isFirst
lat={resolved.sender.lat}
lon={resolved.sender.lon}
@@ -287,7 +277,6 @@ function PathVisualization({ resolved, senderInfo, distanceUnit }: PathVisualiza
hop={hop}
hopNumber={index + 1}
prevLocation={getPrevLocation(index)}
distanceUnit={distanceUnit}
/>
))}
@@ -297,7 +286,6 @@ function PathVisualization({ resolved, senderInfo, distanceUnit }: PathVisualiza
name={resolved.receiver.name}
prefix={resolved.receiver.prefix}
distance={calculateReceiverDistance(resolved)}
distanceUnit={distanceUnit}
isLast
lat={resolved.receiver.lat}
lon={resolved.receiver.lon}
@@ -312,7 +300,7 @@ function PathVisualization({ resolved, senderInfo, distanceUnit }: PathVisualiza
</span>
<span className="text-sm font-medium">
{resolved.hasGaps ? '>' : ''}
{formatDistance(resolved.totalDistances[0], distanceUnit)}
{formatDistance(resolved.totalDistances[0])}
</span>
</div>
)}
@@ -325,7 +313,6 @@ interface PathNodeProps {
name: string;
prefix: string;
distance: number | null;
distanceUnit: DistanceUnit;
isFirst?: boolean;
isLast?: boolean;
/** Optional coordinates for map link */
@@ -340,7 +327,6 @@ function PathNode({
name,
prefix,
distance,
distanceUnit,
isFirst,
isLast,
lat,
@@ -367,9 +353,7 @@ function PathNode({
<div className="font-medium truncate">
{name}
{distance !== null && (
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(distance, distanceUnit)}
</span>
<span className="text-xs text-muted-foreground ml-1">- {formatDistance(distance)}</span>
)}
{hasLocation && <CoordinateLink lat={lat!} lon={lon!} publicKey={publicKey!} />}
</div>
@@ -382,10 +366,9 @@ interface HopNodeProps {
hop: PathHop;
hopNumber: number;
prevLocation: { lat: number | null; lon: number | null } | null;
distanceUnit: DistanceUnit;
}
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
const isAmbiguous = hop.matches.length > 1;
const isUnknown = hop.matches.length === 0;
@@ -434,7 +417,7 @@ function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
{contact.name || contact.public_key.slice(0, 12)}
{dist !== null && (
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(dist, distanceUnit)}
- {formatDistance(dist)}
</span>
)}
{hasLocation && (
@@ -453,7 +436,7 @@ function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: 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, distanceUnit)}
- {formatDistance(hop.distanceFromPrev)}
</span>
)}
{isValidLocation(hop.matches[0].lat, hop.matches[0].lon) && (

View File

@@ -1,644 +0,0 @@
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

@@ -1,548 +0,0 @@
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,13 +1,11 @@
import { useEffect, useRef, useMemo } from 'react';
import type { Channel, RawPacket } from '../types';
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
import type { 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 {
@@ -26,6 +24,132 @@ 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) {
@@ -58,17 +182,16 @@ function getRouteTypeLabel(routeType: string): string {
}
}
export function RawPacketList({ packets, channels, onPacketClick }: RawPacketListProps) {
export function RawPacketList({ packets }: 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, decoderOptions),
decoded: decodePacketSummary(packet.data, packet.decrypted_info),
}));
}, [decoderOptions, packets]);
}, [packets]);
// Sort packets by timestamp ascending (oldest first)
const sortedPackets = useMemo(
@@ -95,78 +218,54 @@ export function RawPacketList({ packets, channels, onPacketClick }: RawPacketLis
className="h-full overflow-y-auto p-4 flex flex-col gap-2 [contain:layout_paint]"
ref={listRef}
>
{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>
{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>
{/* 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>
{/* Encryption status */}
{!packet.decrypted && (
<>
<span aria-hidden="true">🔒</span>
<span className="sr-only">Encrypted</span>
</>
)}
{/* 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>
</>
);
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}
{/* Summary */}
<span
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
>
{cardContent}
</button>
);
}
{decoded.summary}
</span>
return (
<div key={getRawPacketObservationKey(packet)} className={className}>
{cardContent}
{/* 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>
)}
{/* 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>
</div>
))}
</div>
);
}

View File

@@ -5,7 +5,6 @@ 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';
@@ -82,18 +81,8 @@ 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);
@@ -232,12 +221,8 @@ export function RepeaterDashboard({
repeaterName={conversation.name}
loading={loginLoading}
error={loginError}
password={password}
onPasswordChange={setPassword}
rememberPassword={rememberPassword}
onRememberPasswordChange={setRememberPassword}
onLogin={handleRepeaterLogin}
onLoginAsGuest={handleRepeaterGuestLogin}
onLogin={login}
onLoginAsGuest={loginAsGuest}
/>
) : (
<div className="space-y-4">

View File

@@ -1,39 +1,24 @@
import { useCallback, type FormEvent } from 'react';
import { useState, 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();
@@ -48,7 +33,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">{description}</p>
<p className="text-sm text-muted-foreground">Log in to access repeater dashboard</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
@@ -60,34 +45,13 @@ export function RepeaterLogin({
data-1p-ignore="true"
data-bwignore="true"
value={password}
onChange={(e) => onPasswordChange(e.target.value)}
placeholder={passwordPlaceholder}
onChange={(e) => setPassword(e.target.value)}
placeholder="Repeater password..."
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}
@@ -96,7 +60,7 @@ export function RepeaterLogin({
<div className="flex flex-col gap-2">
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Logging in...' : loginLabel}
{loading ? 'Logging in...' : 'Login with Password'}
</Button>
<Button
type="button"
@@ -105,7 +69,7 @@ export function RepeaterLogin({
className="w-full"
onClick={onLoginAsGuest}
>
{guestLabel}
Login as Guest / ACLs
</Button>
</div>
</form>

View File

@@ -1,312 +0,0 @@
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,7 +13,6 @@ import {
X,
} from 'lucide-react';
import {
CONTACT_TYPE_ROOM,
CONTACT_TYPE_REPEATER,
type Contact,
type Channel,
@@ -58,7 +57,6 @@ type CollapseState = {
favorites: boolean;
channels: boolean;
contacts: boolean;
rooms: boolean;
repeaters: boolean;
};
@@ -69,7 +67,6 @@ const DEFAULT_COLLAPSE_STATE: CollapseState = {
favorites: false,
channels: false,
contacts: false,
rooms: false,
repeaters: false,
};
@@ -83,7 +80,6 @@ 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 {
@@ -161,7 +157,6 @@ 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);
@@ -319,61 +314,16 @@ 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 && c.type !== CONTACT_TYPE_ROOM
),
uniqueContacts.filter((c) => c.type !== CONTACT_TYPE_REPEATER),
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(
@@ -408,17 +358,6 @@ 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
@@ -439,7 +378,6 @@ export function Sidebar({
favorites: favoritesCollapsed,
channels: channelsCollapsed,
contacts: contactsCollapsed,
rooms: roomsCollapsed,
repeaters: repeatersCollapsed,
};
}
@@ -449,14 +387,12 @@ export function Sidebar({
favoritesCollapsed ||
channelsCollapsed ||
contactsCollapsed ||
roomsCollapsed ||
repeatersCollapsed
) {
setToolsCollapsed(false);
setFavoritesCollapsed(false);
setChannelsCollapsed(false);
setContactsCollapsed(false);
setRoomsCollapsed(false);
setRepeatersCollapsed(false);
}
return;
@@ -469,7 +405,6 @@ export function Sidebar({
setFavoritesCollapsed(prev.favorites);
setChannelsCollapsed(prev.channels);
setContactsCollapsed(prev.contacts);
setRoomsCollapsed(prev.rooms);
setRepeatersCollapsed(prev.repeaters);
}
}, [
@@ -478,7 +413,6 @@ export function Sidebar({
favoritesCollapsed,
channelsCollapsed,
contactsCollapsed,
roomsCollapsed,
repeatersCollapsed,
]);
@@ -490,7 +424,6 @@ export function Sidebar({
favorites: favoritesCollapsed,
channels: channelsCollapsed,
contacts: contactsCollapsed,
rooms: roomsCollapsed,
repeaters: repeatersCollapsed,
};
@@ -505,56 +438,62 @@ export function Sidebar({
favoritesCollapsed,
channelsCollapsed,
contactsCollapsed,
roomsCollapsed,
repeatersCollapsed,
]);
// Separate favorites from regular items, and build combined favorites list
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 { 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 items: FavoriteItem[] = [
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
];
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);
});
return {
favoriteItems: sortFavoriteItemsByOrder(items, sectionSortOrders.favorites),
nonFavoriteChannels: nonFavChannels,
nonFavoriteContacts: nonFavContacts,
nonFavoriteRooms: nonFavRooms,
nonFavoriteRepeaters: nonFavRepeaters,
};
}, [
filteredChannels,
filteredNonRepeaterContacts,
filteredRooms,
filteredRepeaters,
favorites,
sectionSortOrders.favorites,
sortFavoriteItemsByOrder,
]);
return {
favoriteItems: items,
nonFavoriteChannels: nonFavChannels,
nonFavoriteContacts: nonFavContacts,
nonFavoriteRepeaters: nonFavRepeaters,
};
}, [
filteredChannels,
filteredNonRepeaterContacts,
filteredRepeaters,
favorites,
getContactRecentTime,
getLastMessageTime,
]);
const buildChannelRow = (channel: Channel, keyPrefix: string): ConversationRow => ({
key: `${keyPrefix}-${channel.key}`,
@@ -578,65 +517,57 @@ export function Sidebar({
contact,
});
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'
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>
)}
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}
/>
{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>
)}
<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>
);
};
</span>
</div>
);
const renderSidebarActionRow = ({
key,
@@ -682,13 +613,11 @@ 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);
@@ -904,7 +833,7 @@ export function Sidebar({
'Favorites',
favoritesCollapsed,
() => setFavoritesCollapsed((prev) => !prev),
'favorites',
null,
favoritesUnreadCount,
favoritesHasMention
)}
@@ -945,21 +874,6 @@ 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 && (
<>
@@ -977,7 +891,6 @@ export function Sidebar({
{/* Empty state */}
{nonFavoriteContacts.length === 0 &&
nonFavoriteRooms.length === 0 &&
nonFavoriteChannels.length === 0 &&
nonFavoriteRepeaters.length === 0 &&
favoriteItems.length === 0 && (

View File

@@ -2,7 +2,6 @@ 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,
@@ -36,7 +35,6 @@ export function NeighborsPane({
nodeInfoState: PaneState;
repeaterName: string | null;
}) {
const { distanceUnit } = useDistanceUnit();
const advertLat = repeaterContact?.lat ?? null;
const advertLon = repeaterContact?.lon ?? null;
@@ -95,7 +93,7 @@ export function NeighborsPane({
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
const distKm = calculateDistance(positionSource.lat, positionSource.lon, nLat, nLon);
if (distKm != null) {
dist = formatDistance(distKm, distanceUnit);
dist = formatDistance(distKm);
anyDist = true;
}
}
@@ -113,7 +111,7 @@ export function NeighborsPane({
sorted: enriched,
hasDistances: anyDist,
};
}, [contacts, data, distanceUnit, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
}, [contacts, data, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
return (
<RepeaterPane

View File

@@ -11,12 +11,6 @@ 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,
@@ -25,7 +19,6 @@ export function SettingsLocalSection({
onLocalLabelChange?: (label: LocalLabel) => void;
className?: string;
}) {
const { distanceUnit, setDistanceUnit } = useDistanceUnit();
const [reopenLastConversation, setReopenLastConversation] = useState(
getReopenLastConversationEnabled
);
@@ -89,31 +82,6 @@ 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,7 +5,6 @@ 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 {
@@ -65,7 +64,6 @@ 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);
@@ -100,7 +98,6 @@ 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(() => {
@@ -193,9 +190,6 @@ 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,
@@ -585,24 +579,6 @@ 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

@@ -1,31 +0,0 @@
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,4 +10,3 @@ export { useRealtimeAppState } from './useRealtimeAppState';
export { useConversationActions } from './useConversationActions';
export { useConversationNavigation } from './useConversationNavigation';
export { useBrowserNotifications } from './useBrowserNotifications';
export { useRawPacketStatsSession } from './useRawPacketStatsSession';

View File

@@ -1,7 +1,6 @@
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';
@@ -13,12 +12,10 @@ 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;
@@ -37,7 +34,6 @@ 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(() => {
@@ -91,12 +87,10 @@ export function useAppShell(): UseAppShellResult {
showCracker,
crackerRunning,
localLabel,
distanceUnit,
setSettingsSection,
setSidebarOpen,
setCrackerRunning,
setLocalLabel,
setDistanceUnit,
handleCloseSettingsView,
handleToggleSettingsView,
handleOpenNewMessage,

View File

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

View File

@@ -1,79 +0,0 @@
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

@@ -3,7 +3,6 @@ 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 {
@@ -171,38 +170,6 @@ 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' };

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { getContactAvatar } from '../utils/contactAvatar';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
describe('getContactAvatar', () => {
it('returns complete avatar info', () => {
@@ -30,13 +30,6 @@ 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

@@ -1,5 +1,5 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConversationPane } from '../components/ConversationPane';
@@ -12,7 +12,6 @@ import type {
Message,
RadioConfig,
} from '../types';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
const mocks = vi.hoisted(() => ({
messageList: vi.fn(() => <div data-testid="message-list" />),
@@ -41,21 +40,6 @@ 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" />,
}));
@@ -111,20 +95,12 @@ 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,
@@ -231,54 +207,6 @@ 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

View File

@@ -1,32 +0,0 @@
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

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MessageList } from '../components/MessageList';
import { CONTACT_TYPE_ROOM, type Contact, type Message } from '../types';
import type { Message } from '../types';
const scrollIntoViewMock = vi.fn();
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
@@ -81,46 +81,6 @@ 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

@@ -685,39 +685,22 @@ describe('isValidLocation', () => {
});
describe('formatDistance', () => {
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 under 1km in meters', () => {
expect(formatDistance(0.5)).toBe('500m');
expect(formatDistance(0.123)).toBe('123m');
expect(formatDistance(0.9999)).toBe('1000m');
});
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('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 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))
);
it('rounds meters to nearest integer', () => {
expect(formatDistance(0.4567)).toBe('457m');
expect(formatDistance(0.001)).toBe('1m');
});
});

View File

@@ -1,51 +0,0 @@
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

@@ -1,355 +0,0 @@
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

@@ -1,28 +0,0 @@
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 { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { RawPacketList } from '../components/RawPacketList';
import type { RawPacket } from '../types';
@@ -23,17 +23,5 @@ 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

@@ -1,137 +0,0 @@
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

@@ -7,10 +7,6 @@ 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(),
};
@@ -30,43 +26,18 @@ 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} 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' } });
fireEvent.change(input, { target: { value: ' secret ' } });
fireEvent.submit(screen.getByText('Login with Password').closest('form')!);
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();
expect(defaultProps.onLogin).toHaveBeenCalledWith('secret');
});
it('calls onLoginAsGuest when guest button clicked', () => {

View File

@@ -1,71 +0,0 @@
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

@@ -1,5 +1,5 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SettingsModal } from '../components/SettingsModal';
import type {
@@ -19,7 +19,6 @@ 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),
@@ -37,7 +36,6 @@ const baseConfig: RadioConfig = {
path_hash_mode: 0,
path_hash_mode_supported: false,
advert_location_source: 'current',
multi_acks_enabled: false,
};
const baseHealth: HealthStatus = {
@@ -178,10 +176,6 @@ function openDatabaseSection() {
}
describe('SettingsModal', () => {
beforeEach(() => {
vi.spyOn(api, 'getFanoutConfigs').mockResolvedValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
@@ -333,18 +327,6 @@ 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();
@@ -382,21 +364,17 @@ 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', async () => {
it('does not clip the fanout add-integration menu in external desktop mode', () => {
renderModal({
externalSidebarNav: true,
desktopSection: 'fanout',
});
const addIntegrationButton = await screen.findByRole('button', { name: 'Add Integration' });
const addIntegrationButton = screen.getByRole('button', { name: 'Add Integration' });
const wrapperSection = addIntegrationButton.closest('section');
expect(wrapperSection).not.toHaveClass('overflow-hidden');
});
@@ -449,35 +427,30 @@ describe('SettingsModal', () => {
expect(screen.getByText('Save failed')).toBeInTheDocument();
});
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();
});
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 () => {})}
/>
);
expect(api.getFanoutConfigs).toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
expect(screen.queryByText('Save failed')).not.toBeInTheDocument();
});
@@ -536,18 +509,6 @@ 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,9 +1 @@
import '@testing-library/jest-dom';
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserver;

View File

@@ -2,13 +2,7 @@ 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,
CONTACT_TYPE_ROOM,
type Channel,
type Contact,
type Favorite,
} from '../types';
import { CONTACT_TYPE_REPEATER, type Channel, type Contact, type Favorite } from '../types';
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
@@ -57,19 +51,16 @@ 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,
};
@@ -78,7 +69,7 @@ function renderSidebar(overrides?: {
const view = render(
<Sidebar
contacts={[alice, board, relay]}
contacts={[alice, relay]}
channels={channels}
activeConversation={null}
onSelectConversation={vi.fn()}
@@ -96,7 +87,7 @@ function renderSidebar(overrides?: {
/>
);
return { ...view, flightChannel, opsChannel, aliceName, roomName };
return { ...view, flightChannel, opsChannel, aliceName };
}
function getSectionHeaderContainer(title: string): HTMLElement {
@@ -117,7 +108,6 @@ 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();
});
@@ -139,7 +129,7 @@ describe('Sidebar section summaries', () => {
);
});
it('turns contact row badges red while the contacts rollup remains red', () => {
it('keeps contact row badges normal while the contacts rollup is always red', () => {
const { aliceName } = renderSidebar();
expect(within(getSectionHeaderContainer('Contacts')).getByText('3')).toHaveClass(
@@ -150,54 +140,21 @@ 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, roomName } = renderSidebar();
const { opsChannel, aliceName } = 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' } });
@@ -212,22 +169,19 @@ 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, roomName, unmount } = renderSidebar();
const { opsChannel, aliceName, 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();
@@ -235,7 +189,6 @@ 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', () => {
@@ -312,12 +265,6 @@ 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,
});
@@ -326,7 +273,7 @@ describe('Sidebar section summaries', () => {
});
const props = {
contacts: [zed, amy, zebraRoom, alphaRoom, relayZulu, relayAlpha],
contacts: [zed, amy, relayZulu, relayAlpha],
channels: [publicChannel, zebraChannel, alphaChannel],
activeConversation: null,
onSelectConversation: vi.fn(),
@@ -335,7 +282,6 @@ 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: {},
@@ -358,76 +304,27 @@ 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(['Amy', 'Zed']);
expect(getRoomsOrder()).toEqual(['Alpha Room', 'Zebra Room']);
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
unmount();
render(<Sidebar {...props} />);
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
expect(getRoomsOrder()).toEqual(['Alpha Room', 'Zebra Room']);
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
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 });
@@ -545,90 +442,4 @@ 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

@@ -217,9 +217,7 @@ describe('useConversationMessages conversation switch', () => {
// Switch to conv B while older-messages fetch is still pending
mockGetMessages.mockResolvedValueOnce([createMessage({ id: 999, conversation_key: 'conv_b' })]);
await act(async () => {
rerender({ conv: convB });
});
rerender({ conv: convB });
// loadingOlder must reset immediately — no phantom spinner in conv B
await waitFor(() => expect(result.current.loadingOlder).toBe(false));
@@ -228,13 +226,12 @@ 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
await act(async () => {
olderDeferred.resolve([
createMessage({ id: 500, conversation_key: 'conv_a', text: 'stale-old' }),
]);
await Promise.resolve();
});
olderDeferred.resolve([
createMessage({ id: 500, conversation_key: 'conv_a', text: 'stale-old' }),
]);
// 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

@@ -1,77 +0,0 @@
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

@@ -16,7 +16,6 @@ export interface RadioConfig {
path_hash_mode: number;
path_hash_mode_supported: boolean;
advert_location_source?: 'off' | 'current';
multi_acks_enabled?: boolean;
}
export interface RadioConfigUpdate {
@@ -27,7 +26,6 @@ export interface RadioConfigUpdate {
radio?: RadioSettings;
path_hash_mode?: number;
advert_location_source?: 'off' | 'current';
multi_acks_enabled?: boolean;
}
export type RadioDiscoveryTarget = 'repeaters' | 'sensors' | 'all';
@@ -352,7 +350,6 @@ 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,24 +3,18 @@
*
* 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) and room servers (type=3) always show a fixed glyph.
* Repeaters (type=2) always show 🛜 with a gray background.
*/
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
// Fixed contact-type avatar styling
// Repeater 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;
@@ -109,7 +103,7 @@ function getAvatarColor(publicKey: string): {
/**
* Get all avatar properties for a contact.
* Repeaters and room servers always get a special fixed avatar.
* Repeaters (type=2) always get a special gray avatar with 🛜.
*/
export function getContactAvatar(
name: string | null,
@@ -120,12 +114,10 @@ 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 = 'favorites' | 'channels' | 'contacts' | 'rooms' | 'repeaters';
export type SidebarSortableSection = 'channels' | 'contacts' | 'repeaters';
export type SidebarSectionSortOrders = Record<SidebarSortableSection, SortOrder>;
// In-memory cache of last message times (loaded from server on init)
@@ -113,10 +113,8 @@ export function buildSidebarSectionSortOrders(
defaultOrder: SortOrder = 'recent'
): SidebarSectionSortOrders {
return {
favorites: defaultOrder,
channels: defaultOrder,
contacts: defaultOrder,
rooms: defaultOrder,
repeaters: defaultOrder,
};
}
@@ -131,10 +129,8 @@ 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

@@ -1,32 +0,0 @@
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,5 +1,4 @@
import type { Contact, ContactRoute, RadioConfig, MessagePath } from '../types';
import type { DistanceUnit } from './distanceUnits';
import { CONTACT_TYPE_REPEATER } from '../types';
const MAX_PATH_BYTES = 64;
@@ -344,35 +343,13 @@ export function isValidLocation(lat: number | null, lon: number | null): boolean
}
/**
* Format distance in human-readable form using the selected display unit.
* Format distance in human-readable form (m or km)
*/
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`;
export function formatDistance(km: number): string {
if (km < 1) {
return `${Math.round(km * 1000)}m`;
}
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`;
return `${km.toFixed(1)}km`;
}
/**

View File

@@ -1,388 +0,0 @@
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

@@ -1,531 +0,0 @@
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.3.1",
"meshcore==2.2.29",
"aiomqtt>=2.0",
"apprise>=1.9.7",
"boto3>=1.38.0",

View File

@@ -12,14 +12,10 @@ 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")
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"
)
echo -e "${YELLOW}=== Frontend Docker CI Matrix ===${NC}"
echo -e "${BLUE}Repo:${NC} $SCRIPT_DIR"
echo
run_combo() {
local node_version="$1"
@@ -40,33 +36,9 @@ 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=()
@@ -78,25 +50,21 @@ cleanup() {
trap cleanup EXIT
echo -e "${YELLOW}=== Frontend Docker CI Matrix ===${NC}"
echo -e "${BLUE}Repo:${NC} $SCRIPT_DIR"
echo
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"
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"
echo -e "${BLUE}Starting:${NC} ${label}"
(
echo -e "${YELLOW}=== ${label} ===${NC}"
run_combo "$node_version" "$npm_version"
) >"$log_file" 2>&1 &
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")
JOB_PIDS+=("$!")
JOB_LABELS+=("$label")
JOB_LOGS+=("$log_file")
done
done
echo

View File

@@ -108,24 +108,20 @@ class TestJwtGeneration:
def test_payload_contains_required_fields(self):
private_key, public_key = _make_test_keys()
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
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"] == f"{_CLIENT_ID}/1.2.3-abcdef"
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"] == _CLIENT_ID
assert "email" not in payload # omitted when empty
def test_payload_includes_email_when_provided(self):
private_key, public_key = _make_test_keys()
@@ -826,10 +822,7 @@ 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-abcdef",
),
patch("app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm 2.4.0"),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
await pub._on_connected_async(settings)
@@ -849,7 +842,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-abcdef"
assert payload["client_version"] == "RemoteTerm 2.4.0"
assert payload["stats"] == {"battery_mv": 4200}
def test_lwt_and_online_share_same_topic(self):
@@ -909,8 +902,7 @@ 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/0.0.0-unknown",
"app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm unknown"
),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
@@ -1223,21 +1215,18 @@ class TestBuildRadioInfo:
class TestGetClientVersion:
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 == "RemoteTerm/1.2.3-abcdef"
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_falls_back_to_unknown_hash_when_commit_missing(self):
"""Should keep the canonical shape even when the commit hash is unavailable."""
def test_returns_version_from_build_helper(self):
"""Should use the shared backend build-info helper."""
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"
assert result == "1.2.3"
class TestPublishStatus:
@@ -1266,10 +1255,7 @@ 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-abcdef",
),
patch("app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm 2.4.0"),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
await pub._publish_status(settings)
@@ -1282,7 +1268,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-abcdef"
assert payload["client_version"] == "RemoteTerm 2.4.0"
assert payload["stats"] == stats
@pytest.mark.asyncio
@@ -1307,8 +1293,7 @@ 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/0.0.0-unknown",
"app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm unknown"
),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
@@ -1341,8 +1326,7 @@ 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/0.0.0-unknown",
"app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm unknown"
),
patch.object(pub, "publish", new_callable=AsyncMock),
):

View File

@@ -898,33 +898,6 @@ class TestDirectMessageDecryption:
assert result is None
def test_decrypt_signed_room_post_extracts_author_prefix(self):
"""TXT_TYPE_SIGNED_PLAIN room posts expose the 4-byte author prefix separately."""
shared_secret = bytes(range(32))
timestamp = 1_700_000_000
flags = (2 << 2) | 1
author_prefix = bytes.fromhex("aabbccdd")
plaintext = (
timestamp.to_bytes(4, "little")
+ bytes([flags])
+ author_prefix
+ b"hello room"
+ b"\x00"
)
padded = plaintext + (b"\x00" * ((16 - (len(plaintext) % 16)) % 16))
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
ciphertext = cipher.encrypt(padded)
mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
payload = bytes.fromhex("1020") + mac + ciphertext
result = decrypt_direct_message(payload, shared_secret)
assert result is not None
assert result.txt_type == 2
assert result.attempt == 1
assert result.signed_sender_prefix == "aabbccdd"
assert result.message == "hello room"
class TestTryDecryptDM:
"""Test full packet decryption for direct messages."""

View File

@@ -429,121 +429,6 @@ class TestContactMessageCLIFiltering:
assert event_type == "message_acked"
assert set(payload.keys()) == EXPECTED_ACK_KEYS
@pytest.mark.asyncio
async def test_room_server_message_uses_author_prefix_for_sender_metadata(self, test_db):
from app.event_handlers import on_contact_message
room_key = "ab" * 32
author_key = "12345678" + ("cd" * 28)
await ContactRepository.upsert(
{
"public_key": room_key,
"name": "Ops Board",
"type": 3,
"flags": 0,
"direct_path": None,
"direct_path_len": -1,
"direct_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,
"last_seen": None,
"on_radio": False,
"last_contacted": None,
"first_seen": None,
}
)
await ContactRepository.upsert(
{
"public_key": author_key,
"name": "Alice",
"type": 1,
"flags": 0,
"direct_path": None,
"direct_path_len": -1,
"direct_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,
"last_seen": None,
"on_radio": False,
"last_contacted": None,
"first_seen": None,
}
)
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
class MockEvent:
payload = {
"pubkey_prefix": room_key[:12],
"text": "hello room",
"txt_type": 2,
"signature": author_key[:8],
"sender_timestamp": 1700000000,
}
await on_contact_message(MockEvent())
event_type, payload = mock_broadcast.call_args_list[-1][0]
assert event_type == "message"
assert payload["conversation_key"] == room_key
assert payload["sender_name"] == "Alice"
assert payload["sender_key"] == author_key
assert payload["signature"] == author_key[:8]
@pytest.mark.asyncio
async def test_room_server_message_does_not_create_placeholder_contact_for_unknown_author(
self, test_db
):
from app.event_handlers import on_contact_message
room_key = "ab" * 32
author_prefix = "12345678"
await ContactRepository.upsert(
{
"public_key": room_key,
"name": "Ops Board",
"type": 3,
"flags": 0,
"direct_path": None,
"direct_path_len": -1,
"direct_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,
"last_seen": None,
"on_radio": False,
"last_contacted": None,
"first_seen": None,
}
)
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
class MockEvent:
payload = {
"pubkey_prefix": room_key[:12],
"text": "hello room",
"txt_type": 2,
"signature": author_prefix,
"sender_timestamp": 1700000000,
}
await on_contact_message(MockEvent())
message = (await MessageRepository.get_all(msg_type="PRIV", conversation_key=room_key))[
0
]
assert message.sender_name is None
assert message.sender_key == author_prefix
assert await ContactRepository.get_by_key(author_prefix) is None
assert len(mock_broadcast.call_args_list) == 1
event_type, payload = mock_broadcast.call_args_list[0][0]
assert event_type == "message"
assert payload["sender_key"] == author_prefix
@pytest.mark.asyncio
async def test_missing_txt_type_defaults_to_normal(self, test_db):
"""Messages without txt_type field are treated as normal (not filtered)."""

View File

@@ -781,20 +781,6 @@ class TestAppriseFormatBody:
)
assert body == "**#general:** Bob: hi"
def test_channel_format_strips_stored_sender_prefix(self):
from app.fanout.apprise_mod import _format_body
body = _format_body(
{
"type": "CHAN",
"text": "Bob: hi",
"sender_name": "Bob",
"channel_name": "#general",
},
include_path=False,
)
assert body == "**#general:** Bob: hi"
def test_dm_with_path(self):
from app.fanout.apprise_mod import _format_body
@@ -902,34 +888,6 @@ class TestAppriseNormalizeDiscordUrl:
result = _normalize_discord_url("https://discord.com/api/webhooks/123/abc")
assert "avatar=no" in result
class TestFanoutMessageText:
def test_channel_text_strips_matching_sender_prefix(self):
from app.fanout.base import get_fanout_message_text
text = get_fanout_message_text(
{
"type": "CHAN",
"text": "Alice: hello world",
"sender_name": "Alice",
}
)
assert text == "hello world"
def test_channel_text_keeps_nonmatching_prefix(self):
from app.fanout.base import get_fanout_message_text
text = get_fanout_message_text(
{
"type": "CHAN",
"text": "Alice: hello world",
"sender_name": "Bob",
}
)
assert text == "Alice: hello world"
def test_non_discord_unchanged(self):
from app.fanout.apprise_mod import _normalize_discord_url

View File

@@ -1388,56 +1388,6 @@ class TestRepeaterMessageFiltering:
)
assert len(messages) == 0
@pytest.mark.asyncio
async def test_room_cli_response_not_stored(self, test_db, captured_broadcasts):
"""Room-server CLI responses should not be stored in chat history."""
from app.decoder import DecryptedDirectMessage
from app.models import CONTACT_TYPE_ROOM
from app.packet_processor import create_dm_message_from_decrypted
from app.repository import ContactRepository, MessageRepository, RawPacketRepository
room_pub = "c3d4e5f6cb0a6fb9816ca956ff22dd7f12e2e5adbbf5e233bd8232774d6cffee"
await ContactRepository.upsert(
{
"public_key": room_pub,
"name": "Test Room",
"type": CONTACT_TYPE_ROOM,
"flags": 0,
"on_radio": False,
}
)
packet_id, _ = await RawPacketRepository.create(b"\x09\x00test", 1700000000)
decrypted = DecryptedDirectMessage(
timestamp=1700000000,
flags=(1 << 2),
message="> status ok",
dest_hash="fa",
src_hash="c3",
)
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
msg_id = await create_dm_message_from_decrypted(
packet_id=packet_id,
decrypted=decrypted,
their_public_key=room_pub,
our_public_key=self.OUR_PUB,
received_at=1700000001,
outgoing=False,
)
assert msg_id is None
assert len(broadcasts) == 0
messages = await MessageRepository.get_all(
msg_type="PRIV", conversation_key=room_pub.lower(), limit=10
)
assert len(messages) == 0
@pytest.mark.asyncio
async def test_client_message_still_stored(self, test_db, captured_broadcasts):
"""Messages from normal clients should still be stored."""

View File

@@ -33,7 +33,6 @@ def _mock_meshcore_with_info():
mc.commands.set_radio = AsyncMock()
mc.commands.set_path_hash_mode = AsyncMock(return_value=_radio_result())
mc.commands.set_advert_loc_policy = AsyncMock(return_value=_radio_result())
mc.commands.set_multi_acks = AsyncMock(return_value=_radio_result())
mc.commands.send_appstart = AsyncMock()
mc.commands.import_private_key = AsyncMock(return_value=_radio_result())
return mc
@@ -85,39 +84,6 @@ class TestApplyRadioConfigUpdate:
mc.commands.set_advert_loc_policy.assert_awaited_once_with(1)
mc.commands.send_appstart.assert_awaited_once()
@pytest.mark.asyncio
async def test_updates_multi_acks_enabled(self):
mc = _mock_meshcore_with_info()
await apply_radio_config_update(
mc,
RadioConfigUpdate(multi_acks_enabled=True),
path_hash_mode_supported=True,
set_path_hash_mode=MagicMock(),
sync_radio_time_fn=AsyncMock(),
)
mc.commands.set_multi_acks.assert_awaited_once_with(1)
mc.commands.send_appstart.assert_awaited_once()
@pytest.mark.asyncio
async def test_raises_when_radio_rejects_multi_acks(self):
mc = _mock_meshcore_with_info()
mc.commands.set_multi_acks = AsyncMock(
return_value=_radio_result(EventType.ERROR, {"error": "nope"})
)
with pytest.raises(RadioCommandRejectedError):
await apply_radio_config_update(
mc,
RadioConfigUpdate(multi_acks_enabled=False),
path_hash_mode_supported=True,
set_path_hash_mode=MagicMock(),
sync_radio_time_fn=AsyncMock(),
)
mc.commands.send_appstart.assert_not_awaited()
@pytest.mark.asyncio
async def test_raises_when_radio_rejects_advert_location_source(self):
mc = _mock_meshcore_with_info()

View File

@@ -75,7 +75,6 @@ def _mock_meshcore_with_info():
"radio_sf": 7,
"radio_cr": 5,
"adv_loc_policy": 2,
"multi_acks": 0,
}
mc.commands = MagicMock()
mc.commands.set_name = AsyncMock()
@@ -83,7 +82,6 @@ def _mock_meshcore_with_info():
mc.commands.set_tx_power = AsyncMock()
mc.commands.set_radio = AsyncMock()
mc.commands.set_advert_loc_policy = AsyncMock(return_value=_radio_result())
mc.commands.set_multi_acks = AsyncMock(return_value=_radio_result())
mc.commands.send_appstart = AsyncMock()
mc.commands.import_private_key = AsyncMock(return_value=_radio_result())
mc.commands.send_node_discover_req = AsyncMock(return_value=_radio_result())
@@ -106,17 +104,6 @@ class TestGetRadioConfig:
assert response.radio.freq == 910.525
assert response.radio.cr == 5
assert response.advert_location_source == "current"
assert response.multi_acks_enabled is False
@pytest.mark.asyncio
async def test_maps_multi_acks_to_response(self):
mc = _mock_meshcore_with_info()
mc.self_info["multi_acks"] = 1
with patch("app.routers.radio.require_connected", return_value=mc):
response = await get_radio_config()
assert response.multi_acks_enabled is True
@pytest.mark.asyncio
async def test_maps_any_nonzero_advert_location_policy_to_current(self):
@@ -185,7 +172,6 @@ class TestUpdateRadioConfig:
path_hash_mode=0,
path_hash_mode_supported=False,
advert_location_source="current",
multi_acks_enabled=False,
)
with (
@@ -201,36 +187,6 @@ class TestUpdateRadioConfig:
mc.commands.set_advert_loc_policy.assert_awaited_once_with(1)
assert result == expected
@pytest.mark.asyncio
async def test_updates_multi_acks_enabled(self):
mc = _mock_meshcore_with_info()
expected = RadioConfigResponse(
public_key="aa" * 32,
name="NodeA",
lat=10.0,
lon=20.0,
tx_power=17,
max_tx_power=22,
radio=RadioSettings(freq=910.525, bw=62.5, sf=7, cr=5),
path_hash_mode=0,
path_hash_mode_supported=False,
advert_location_source="current",
multi_acks_enabled=True,
)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock),
patch(
"app.routers.radio.get_radio_config", new_callable=AsyncMock, return_value=expected
),
):
result = await update_radio_config(RadioConfigUpdate(multi_acks_enabled=True))
mc.commands.set_multi_acks.assert_awaited_once_with(1)
assert result == expected
def test_model_rejects_negative_path_hash_mode(self):
with pytest.raises(ValidationError):
RadioConfigUpdate(path_hash_mode=-1)

View File

@@ -1,187 +0,0 @@
"""Tests for room-server contact routes."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from meshcore import EventType
from app.models import CommandRequest, RepeaterLoginRequest
from app.radio import radio_manager
from app.repository import ContactRepository
from app.routers.repeaters import send_repeater_command
from app.routers.rooms import room_acl, room_login, room_status
ROOM_KEY = "cc" * 32
AUTHOR_KEY = "12345678" + ("dd" * 28)
@pytest.fixture(autouse=True)
def _reset_radio_state():
prev = radio_manager._meshcore
prev_lock = radio_manager._operation_lock
yield
radio_manager._meshcore = prev
radio_manager._operation_lock = prev_lock
def _radio_result(event_type=EventType.OK, payload=None):
result = MagicMock()
result.type = event_type
result.payload = payload or {}
return result
def _mock_mc():
mc = MagicMock()
mc.commands = MagicMock()
mc.commands.send_login = AsyncMock(return_value=_radio_result(EventType.MSG_SENT))
mc.commands.req_status_sync = AsyncMock()
mc.commands.req_acl_sync = AsyncMock()
mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK))
mc.commands.get_msg = AsyncMock()
mc.commands.add_contact = AsyncMock(return_value=_radio_result(EventType.OK))
mc.subscribe = MagicMock(return_value=MagicMock(unsubscribe=MagicMock()))
mc.stop_auto_message_fetching = AsyncMock()
mc.start_auto_message_fetching = AsyncMock()
return mc
async def _insert_contact(public_key: str, name: str, contact_type: int):
await ContactRepository.upsert(
{
"public_key": public_key,
"name": name,
"type": contact_type,
"flags": 0,
"direct_path": None,
"direct_path_len": -1,
"direct_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,
"last_seen": None,
"on_radio": False,
"last_contacted": None,
"first_seen": None,
}
)
class TestRoomLogin:
@pytest.mark.asyncio
async def test_room_login_success(self, test_db):
mc = _mock_mc()
await _insert_contact(ROOM_KEY, name="Room Server", contact_type=3)
subscriptions: dict[EventType, tuple[object, object]] = {}
def _subscribe(event_type, callback, attribute_filters=None):
subscriptions[event_type] = (callback, attribute_filters)
return MagicMock(unsubscribe=MagicMock())
async def _send_login(*args, **kwargs):
callback, _filters = subscriptions[EventType.LOGIN_SUCCESS]
callback(_radio_result(EventType.LOGIN_SUCCESS, {"pubkey_prefix": ROOM_KEY[:12]}))
return _radio_result(EventType.MSG_SENT)
mc.subscribe = MagicMock(side_effect=_subscribe)
mc.commands.send_login = AsyncMock(side_effect=_send_login)
with (
patch("app.routers.rooms.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
response = await room_login(ROOM_KEY, RepeaterLoginRequest(password="hello"))
assert response.status == "ok"
assert response.authenticated is True
@pytest.mark.asyncio
async def test_room_login_rejects_non_room(self, test_db):
mc = _mock_mc()
await _insert_contact(ROOM_KEY, name="Client", contact_type=1)
with (
patch("app.routers.rooms.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
with pytest.raises(HTTPException) as exc:
await room_login(ROOM_KEY, RepeaterLoginRequest(password="hello"))
assert exc.value.status_code == 400
class TestRoomStatus:
@pytest.mark.asyncio
async def test_room_status_maps_fields(self, test_db):
mc = _mock_mc()
await _insert_contact(ROOM_KEY, name="Room Server", contact_type=3)
mc.commands.req_status_sync = AsyncMock(
return_value={
"bat": 4025,
"tx_queue_len": 1,
"noise_floor": -118,
"last_rssi": -82,
"last_snr": 6.0,
"nb_recv": 80,
"nb_sent": 40,
"airtime": 120,
"rx_airtime": 240,
"uptime": 600,
"sent_flood": 5,
"sent_direct": 35,
"recv_flood": 7,
"recv_direct": 73,
"flood_dups": 2,
"direct_dups": 1,
"full_evts": 0,
}
)
with (
patch("app.routers.rooms.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
response = await room_status(ROOM_KEY)
assert response.battery_volts == 4.025
assert response.packets_received == 80
assert response.recv_direct == 73
@pytest.mark.asyncio
async def test_room_acl_maps_entries(self, test_db):
mc = _mock_mc()
await _insert_contact(ROOM_KEY, name="Room Server", contact_type=3)
await _insert_contact(AUTHOR_KEY, name="Author", contact_type=1)
mc.commands.req_acl_sync = AsyncMock(return_value=[{"key": AUTHOR_KEY[:12], "perm": 3}])
with (
patch("app.routers.rooms.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
response = await room_acl(ROOM_KEY)
assert len(response.acl) == 1
assert response.acl[0].name == "Author"
assert response.acl[0].permission_name == "Admin"
class TestRoomCommandReuse:
@pytest.mark.asyncio
async def test_generic_command_route_accepts_room_servers(self, test_db):
mc = _mock_mc()
await _insert_contact(ROOM_KEY, name="Room Server", contact_type=3)
mc.commands.get_msg = AsyncMock(
return_value=_radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": ROOM_KEY[:12], "text": "> ok", "txt_type": 1},
)
)
with (
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
response = await send_repeater_command(ROOM_KEY, CommandRequest(command="ver"))
assert response.response == "ok"

View File

@@ -4,12 +4,9 @@ Verifies that the primary RF packet entry point correctly extracts hex payload,
SNR, and RSSI from MeshCore events and passes them to process_raw_packet.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, patch
import pytest
from meshcore import EventType
from meshcore.packets import PacketType
from meshcore.reader import MessageReader
class TestOnRxLogData:
@@ -93,42 +90,3 @@ class TestOnRxLogData:
with pytest.raises(ValueError):
await on_rx_log_data(MockEvent())
@pytest.mark.asyncio
async def test_real_meshcore_reader_forwards_3byte_log_data_to_handler(self):
"""The meshcore reader emits usable RX_LOG_DATA for 3-byte-hop packets."""
from app.event_handlers import on_rx_log_data
payload_hex = "15833fa002860ccae0eed9ca78b9ab0775d477c1f6490a398bf4edc75240"
dispatcher = MagicMock()
dispatcher.dispatch = AsyncMock()
reader = MessageReader(dispatcher)
frame = bytes(
[
PacketType.LOG_DATA.value,
int(7.5 * 4),
(-85) & 0xFF,
]
) + bytes.fromhex(payload_hex)
await reader.handle_rx(bytearray(frame))
dispatcher.dispatch.assert_awaited_once()
event = dispatcher.dispatch.await_args.args[0]
assert event.type == EventType.RX_LOG_DATA
assert event.payload["payload"] == payload_hex.lower()
assert event.payload["path_hash_size"] == 3
assert event.payload["path_len"] == 3
assert event.payload["path"] == "3fa002860ccae0eed9"
assert event.payload["snr"] == 7.5
assert event.payload["rssi"] == -85
with patch("app.event_handlers.process_raw_packet", new_callable=AsyncMock) as mock_process:
await on_rx_log_data(event)
mock_process.assert_called_once_with(
raw_bytes=bytes.fromhex(payload_hex),
snr=7.5,
rssi=-85,
)

8
uv.lock generated
View File

@@ -534,7 +534,7 @@ wheels = [
[[package]]
name = "meshcore"
version = "2.3.1"
version = "2.2.29"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bleak" },
@@ -542,9 +542,9 @@ dependencies = [
{ name = "pycryptodome" },
{ name = "pyserial-asyncio-fast" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/a8/79f84f32cad056358b1e31dbb343d7f986f78fd93021dbbde306a9b4d36e/meshcore-2.3.1.tar.gz", hash = "sha256:07bd2267cb84a335b915ea6dab1601ae7ae13cad5923793e66b2356c3e351e24", size = 69503 }
sdist = { url = "https://files.pythonhosted.org/packages/a8/16/ecee71bcd3fa5b9fe3cf16cb0f354e57c1661adbd8f0429c6782b6a5b1b7/meshcore-2.2.29.tar.gz", hash = "sha256:ae6339f51e6d1a518d493d3a95ef8c015fe17ea571cdca6f1d9ed2c26455a56b", size = 68651 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/df/66d615298b717c2c6471592e2b96117f391ae3c99f477d7f424449897bf0/meshcore-2.3.1-py3-none-any.whl", hash = "sha256:59bb8b66fd9e3261dbdb0e69fc038d4606bfd4ad1a260bbdd8659066e4bf12d2", size = 53084 },
{ url = "https://files.pythonhosted.org/packages/d5/9d/47dfb1b8d96558b2594f03e8a193548255f5a63209cb9bad4dfb85c7dff2/meshcore-2.2.29-py3-none-any.whl", hash = "sha256:154a4d65a585fc2b0a70dc0ee93d3c6bb32fde134fc5e7c7bc7741fbea8bf37a", size = 52218 },
]
[[package]]
@@ -1142,7 +1142,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" },
{ name = "meshcore", specifier = "==2.3.1" },
{ name = "meshcore", specifier = "==2.2.29" },
{ name = "pycryptodome", specifier = ">=3.20.0" },
{ name = "pydantic-settings", specifier = ">=2.0.0" },
{ name = "pynacl", specifier = ">=1.5.0" },