mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
5 Commits
room_serve
...
better-pat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b98198c60 | ||
|
|
29a76cef96 | ||
|
|
0768b59bcc | ||
|
|
2c6ab31202 | ||
|
|
7895671309 |
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -231,7 +231,6 @@ class ContactRoutingOverrideRequest(BaseModel):
|
||||
|
||||
# Contact type constants
|
||||
CONTACT_TYPE_REPEATER = 2
|
||||
CONTACT_TYPE_ROOM = 3
|
||||
|
||||
|
||||
class ContactAdvertPath(BaseModel):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -10,4 +10,3 @@ export { useRealtimeAppState } from './useRealtimeAppState';
|
||||
export { useConversationActions } from './useConversationActions';
|
||||
export { useConversationNavigation } from './useConversationNavigation';
|
||||
export { useBrowserNotifications } from './useBrowserNotifications';
|
||||
export { useRawPacketStatsSession } from './useRawPacketStatsSession';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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' };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -1,9 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = ResizeObserver;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
8
uv.lock
generated
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user