diff --git a/AGENTS.md b/AGENTS.md index 03489ea..cd7980b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -278,9 +278,17 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | POST | `/api/contacts/{key}/add-to-radio` | Push contact to radio | | POST | `/api/contacts/{key}/remove-from-radio` | Remove contact from radio | | POST | `/api/contacts/{key}/mark-read` | Mark contact conversation as read | -| POST | `/api/contacts/{key}/telemetry` | Request telemetry from repeater | | POST | `/api/contacts/{key}/command` | Send CLI command to repeater | | POST | `/api/contacts/{key}/trace` | Trace route to contact | +| POST | `/api/contacts/{key}/repeater/login` | Log in to a repeater | +| POST | `/api/contacts/{key}/repeater/status` | Fetch repeater status telemetry | +| POST | `/api/contacts/{key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data | +| POST | `/api/contacts/{key}/repeater/neighbors` | Fetch repeater neighbors | +| POST | `/api/contacts/{key}/repeater/acl` | Fetch repeater ACL | +| POST | `/api/contacts/{key}/repeater/radio-settings` | Fetch radio settings via CLI | +| POST | `/api/contacts/{key}/repeater/advert-intervals` | Fetch advert intervals | +| POST | `/api/contacts/{key}/repeater/owner-info` | Fetch owner info | +| POST | `/api/contacts/{key}/repeater/clock` | Fetch repeater clock | | GET | `/api/channels` | List channels | | GET | `/api/channels/{key}` | Get channel by key | | POST | `/api/channels` | Create channel | diff --git a/app/AGENTS.md b/app/AGENTS.md index 125a4d7..bf9e269 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -124,9 +124,17 @@ app/ - `POST /contacts/{public_key}/add-to-radio` - `POST /contacts/{public_key}/remove-from-radio` - `POST /contacts/{public_key}/mark-read` -- `POST /contacts/{public_key}/telemetry` - `POST /contacts/{public_key}/command` - `POST /contacts/{public_key}/trace` +- `POST /contacts/{public_key}/repeater/login` +- `POST /contacts/{public_key}/repeater/status` +- `POST /contacts/{public_key}/repeater/lpp-telemetry` +- `POST /contacts/{public_key}/repeater/neighbors` +- `POST /contacts/{public_key}/repeater/acl` +- `POST /contacts/{public_key}/repeater/radio-settings` +- `POST /contacts/{public_key}/repeater/advert-intervals` +- `POST /contacts/{public_key}/repeater/owner-info` +- `POST /contacts/{public_key}/repeater/clock` ### Channels - `GET /channels` @@ -237,7 +245,7 @@ tests/ ├── test_radio_operation.py # radio_operation() context manager ├── test_radio_router.py # Radio router endpoints ├── test_radio_sync.py # Polling, sync, advertisement -├── test_repeater_routes.py # Repeater command/telemetry/trace +├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints ├── test_repository.py # Data access layer ├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends ├── test_settings_router.py # Settings endpoints, advert validation diff --git a/app/models.py b/app/models.py index 72c8534..0b712fe 100644 --- a/app/models.py +++ b/app/models.py @@ -211,12 +211,93 @@ class SendChannelMessageRequest(SendMessageRequest): channel_key: str = Field(description="Channel key (32-char hex)") -class TelemetryRequest(BaseModel): +class RepeaterLoginRequest(BaseModel): + """Request to log in to a repeater.""" + password: str = Field( - default="", description="Repeater password (empty string for no password)" + default="", description="Repeater password (empty string for guest login)" ) +class RepeaterLoginResponse(BaseModel): + """Response from repeater login.""" + + status: str = Field(description="Login result status") + + +class RepeaterStatusResponse(BaseModel): + """Status telemetry from a repeater (single attempt, no retries).""" + + battery_volts: float = Field(description="Battery voltage in volts") + tx_queue_len: int = Field(description="Transmit queue length") + noise_floor_dbm: int = Field(description="Noise floor in dBm") + last_rssi_dbm: int = Field(description="Last RSSI in dBm") + last_snr_db: float = Field(description="Last SNR in dB") + packets_received: int = Field(description="Total packets received") + packets_sent: int = Field(description="Total packets sent") + airtime_seconds: int = Field(description="TX airtime in seconds") + rx_airtime_seconds: int = Field(description="RX airtime in seconds") + uptime_seconds: int = Field(description="Uptime in seconds") + sent_flood: int = Field(description="Flood packets sent") + sent_direct: int = Field(description="Direct packets sent") + recv_flood: int = Field(description="Flood packets received") + recv_direct: int = Field(description="Direct packets received") + flood_dups: int = Field(description="Duplicate flood packets") + direct_dups: int = Field(description="Duplicate direct packets") + full_events: int = Field(description="Full event queue count") + + +class RepeaterRadioSettingsResponse(BaseModel): + """Radio settings from a repeater (batch CLI get commands).""" + + firmware_version: str | None = Field(default=None, description="Firmware version string") + radio: str | None = Field(default=None, description="Radio settings (freq,bw,sf,cr)") + tx_power: str | None = Field(default=None, description="TX power in dBm") + airtime_factor: str | None = Field(default=None, description="Airtime factor") + repeat_enabled: str | None = Field(default=None, description="Repeat mode enabled") + flood_max: str | None = Field(default=None, description="Max flood hops") + name: str | None = Field(default=None, description="Repeater name") + lat: str | None = Field(default=None, description="Latitude") + lon: str | None = Field(default=None, description="Longitude") + clock_utc: str | None = Field(default=None, description="Repeater clock in UTC") + + +class RepeaterAdvertIntervalsResponse(BaseModel): + """Advertisement intervals from a repeater.""" + + advert_interval: str | None = Field(default=None, description="Local advert interval") + flood_advert_interval: str | None = Field(default=None, description="Flood advert interval") + + +class RepeaterOwnerInfoResponse(BaseModel): + """Owner info and guest password from a repeater.""" + + owner_info: str | None = Field(default=None, description="Owner info string") + guest_password: str | None = Field(default=None, description="Guest password") + + +class RepeaterClockResponse(BaseModel): + """Clock output from a repeater.""" + + clock_output: str | None = Field(default=None, description="Output from 'clock' command") + + +class LppSensor(BaseModel): + """A single CayenneLPP sensor reading from req_telemetry_sync.""" + + channel: int = Field(description="LPP channel number") + type_name: str = Field(description="Sensor type name (e.g. temperature, humidity)") + value: float | dict = Field( + description="Scalar value or dict for multi-value sensors (GPS, accel)" + ) + + +class RepeaterLppTelemetryResponse(BaseModel): + """CayenneLPP sensor telemetry from a repeater.""" + + sensors: list[LppSensor] = Field(default_factory=list, description="List of sensor readings") + + class NeighborInfo(BaseModel): """Information about a neighbor seen by a repeater.""" @@ -237,34 +318,18 @@ class AclEntry(BaseModel): permission_name: str = Field(description="Human-readable permission name") -class TelemetryResponse(BaseModel): - """Telemetry data from a repeater, formatted for human readability.""" +class RepeaterNeighborsResponse(BaseModel): + """Neighbors list from a repeater.""" - pubkey_prefix: str = Field(description="12-char public key prefix") - battery_volts: float = Field(description="Battery voltage in volts") - tx_queue_len: int = Field(description="Transmit queue length") - noise_floor_dbm: int = Field(description="Noise floor in dBm") - last_rssi_dbm: int = Field(description="Last RSSI in dBm") - last_snr_db: float = Field(description="Last SNR in dB") - packets_received: int = Field(description="Total packets received") - packets_sent: int = Field(description="Total packets sent") - airtime_seconds: int = Field(description="TX airtime in seconds") - rx_airtime_seconds: int = Field(description="RX airtime in seconds") - uptime_seconds: int = Field(description="Uptime in seconds") - sent_flood: int = Field(description="Flood packets sent") - sent_direct: int = Field(description="Direct packets sent") - recv_flood: int = Field(description="Flood packets received") - recv_direct: int = Field(description="Direct packets received") - flood_dups: int = Field(description="Duplicate flood packets") - direct_dups: int = Field(description="Duplicate direct packets") - full_events: int = Field(description="Full event queue count") neighbors: list[NeighborInfo] = Field( default_factory=list, description="List of neighbors seen by repeater" ) + + +class RepeaterAclResponse(BaseModel): + """ACL list from a repeater.""" + acl: list[AclEntry] = Field(default_factory=list, description="Access control list") - clock_output: str | None = Field( - default=None, description="Output from 'clock' command (or error message)" - ) class TraceResponse(BaseModel): diff --git a/app/routers/contacts.py b/app/routers/contacts.py index 3692bc6..b14b277 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -19,10 +19,19 @@ from app.models import ( ContactAdvertPathSummary, ContactDetail, CreateContactRequest, + LppSensor, NearestRepeater, NeighborInfo, - TelemetryRequest, - TelemetryResponse, + RepeaterAclResponse, + RepeaterAdvertIntervalsResponse, + RepeaterClockResponse, + RepeaterLoginRequest, + RepeaterLoginResponse, + RepeaterLppTelemetryResponse, + RepeaterNeighborsResponse, + RepeaterOwnerInfoResponse, + RepeaterRadioSettingsResponse, + RepeaterStatusResponse, TraceResponse, ) from app.packet_processor import start_historical_dm_decryption @@ -63,6 +72,14 @@ def _monotonic() -> float: return time.monotonic() +def _extract_response_text(event) -> str: + """Extract text from a CLI response event, stripping the firmware '> ' prefix.""" + text = event.payload.get("text", str(event.payload)) + if text.startswith("> "): + text = text[2:] + return text + + async def _fetch_repeater_response( mc, target_pubkey_prefix: str, @@ -167,17 +184,7 @@ async def prepare_repeater_connection(mc, contact: Contact, password: str) -> No """ # 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]) - add_result = await mc.commands.add_contact(contact.to_radio_dict()) - if add_result is not None and add_result.type == EventType.ERROR: - logger.warning( - "Failed to add repeater %s to radio: %s — continuing anyway", - contact.public_key[:12], - add_result.payload, - ) - broadcast_error( - "Failed to add repeater contact to radio, attempting to continue", - str(add_result.payload), - ) + await _ensure_on_radio(mc, contact) # Send login with password logger.info("Sending login to repeater %s", contact.public_key[:12]) @@ -191,6 +198,305 @@ async def prepare_repeater_connection(mc, contact: Contact, password: str) -> No await asyncio.sleep(REPEATER_OP_DELAY_SECONDS) +def _require_repeater(contact: Contact) -> None: + """Raise 400 if contact is not a repeater.""" + if contact.type != CONTACT_TYPE_REPEATER: + raise HTTPException( + status_code=400, + detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})", + ) + + +async def _ensure_on_radio(mc, contact: Contact) -> None: + """Add a contact to the radio for routing, raising 500 on failure.""" + add_result = await mc.commands.add_contact(contact.to_radio_dict()) + if add_result is not None and add_result.type == EventType.ERROR: + raise HTTPException( + status_code=500, detail=f"Failed to add contact to radio: {add_result.payload}" + ) + + +# --------------------------------------------------------------------------- +# Granular repeater endpoints — one attempt, no server-side retries. +# Frontend manages retry logic for better UX control. +# --------------------------------------------------------------------------- + + +@router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse) +async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse: + """Log in to a repeater. Adds contact to radio, sends login, waits for key exchange.""" + require_connected() + contact = await _resolve_contact_or_404(public_key) + _require_repeater(contact) + + async with radio_manager.radio_operation( + "repeater_login", + pause_polling=True, + suspend_auto_fetch=True, + ) as mc: + await prepare_repeater_connection(mc, contact, request.password) + + return RepeaterLoginResponse(status="ok") + + +@router.post("/{public_key}/repeater/status", response_model=RepeaterStatusResponse) +async def repeater_status(public_key: str) -> RepeaterStatusResponse: + """Fetch status telemetry from a repeater (single attempt, 10s timeout).""" + require_connected() + contact = await _resolve_contact_or_404(public_key) + _require_repeater(contact) + + async with radio_manager.radio_operation( + "repeater_status", pause_polling=True, suspend_auto_fetch=True + ) as mc: + # Ensure contact is on radio for routing + 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 repeater") + + 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}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse) +async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse: + """Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout).""" + require_connected() + contact = await _resolve_contact_or_404(public_key) + _require_repeater(contact) + + async with radio_manager.radio_operation( + "repeater_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 repeater") + + sensors: list[LppSensor] = [] + for entry in telemetry: + channel = entry.get("channel", 0) + type_name = str(entry.get("type", "unknown")) + value = entry.get("value", 0) + sensors.append(LppSensor(channel=channel, type_name=type_name, value=value)) + + return RepeaterLppTelemetryResponse(sensors=sensors) + + +@router.post("/{public_key}/repeater/neighbors", response_model=RepeaterNeighborsResponse) +async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse: + """Fetch neighbors from a repeater (single attempt, 10s timeout).""" + require_connected() + contact = await _resolve_contact_or_404(public_key) + _require_repeater(contact) + + async with radio_manager.radio_operation( + "repeater_neighbors", pause_polling=True, suspend_auto_fetch=True + ) as mc: + # Ensure contact is on radio for routing + await _ensure_on_radio(mc, contact) + + neighbors_data = await mc.commands.fetch_all_neighbours( + contact.public_key, timeout=10, min_timeout=5 + ) + + neighbors: list[NeighborInfo] = [] + if neighbors_data and "neighbours" in neighbors_data: + for n in neighbors_data["neighbours"]: + pubkey_prefix = n.get("pubkey", "") + resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix) + neighbors.append( + NeighborInfo( + pubkey_prefix=pubkey_prefix, + name=resolved_contact.name if resolved_contact else None, + snr=n.get("snr", 0.0), + last_heard_seconds=n.get("secs_ago", 0), + ) + ) + + return RepeaterNeighborsResponse(neighbors=neighbors) + + +@router.post("/{public_key}/repeater/acl", response_model=RepeaterAclResponse) +async def repeater_acl(public_key: str) -> RepeaterAclResponse: + """Fetch ACL from a repeater (single attempt, 10s timeout).""" + require_connected() + contact = await _resolve_contact_or_404(public_key) + _require_repeater(contact) + + async with radio_manager.radio_operation( + "repeater_acl", pause_polling=True, suspend_auto_fetch=True + ) as mc: + # Ensure contact is on radio for routing + await _ensure_on_radio(mc, contact) + + acl_data = await mc.commands.req_acl_sync(contact.public_key, timeout=10, min_timeout=5) + + acl_entries: list[AclEntry] = [] + if acl_data and isinstance(acl_data, list): + 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) + + +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 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/radio-settings", response_model=RepeaterRadioSettingsResponse) +async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse: + """Fetch radio settings from a repeater via batch CLI commands.""" + require_connected() + contact = await _resolve_contact_or_404(public_key) + _require_repeater(contact) + + results = await _batch_cli_fetch( + contact, + "repeater_radio_settings", + [ + ("ver", "firmware_version"), + ("get radio", "radio"), + ("get tx", "tx_power"), + ("get af", "airtime_factor"), + ("get repeat", "repeat_enabled"), + ("get flood.max", "flood_max"), + ("get name", "name"), + ("get lat", "lat"), + ("get lon", "lon"), + ("clock", "clock_utc"), + ], + ) + return RepeaterRadioSettingsResponse(**results) + + +@router.post( + "/{public_key}/repeater/advert-intervals", response_model=RepeaterAdvertIntervalsResponse +) +async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsResponse: + """Fetch advertisement intervals from a repeater via CLI commands.""" + require_connected() + contact = await _resolve_contact_or_404(public_key) + _require_repeater(contact) + + results = await _batch_cli_fetch( + contact, + "repeater_advert_intervals", + [ + ("get advert.interval", "advert_interval"), + ("get flood.advert.interval", "flood_advert_interval"), + ], + ) + return RepeaterAdvertIntervalsResponse(**results) + + +@router.post("/{public_key}/repeater/owner-info", response_model=RepeaterOwnerInfoResponse) +async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse: + """Fetch owner info and guest password from a repeater via CLI commands.""" + require_connected() + contact = await _resolve_contact_or_404(public_key) + _require_repeater(contact) + + results = await _batch_cli_fetch( + contact, + "repeater_owner_info", + [ + ("get owner.info", "owner_info"), + ("get guest.password", "guest_password"), + ], + ) + return RepeaterOwnerInfoResponse(**results) + + +@router.post("/{public_key}/repeater/clock", response_model=RepeaterClockResponse) +async def repeater_clock(public_key: str) -> RepeaterClockResponse: + """Fetch clock output from a repeater.""" + require_connected() + contact = await _resolve_contact_or_404(public_key) + _require_repeater(contact) + + results = await _batch_cli_fetch( + contact, + "repeater_clock", + [("clock", "clock_output")], + ) + return RepeaterClockResponse(**results) + + @router.get("", response_model=list[Contact]) async def list_contacts( limit: int = Query(default=100, ge=1, le=1000), @@ -501,168 +807,13 @@ async def delete_contact(public_key: str) -> dict: return {"status": "ok"} -@router.post("/{public_key}/telemetry", response_model=TelemetryResponse) -async def request_telemetry(public_key: str, request: TelemetryRequest) -> TelemetryResponse: - """Request telemetry from a repeater. - - The contact must be a repeater (type=2). If not on the radio, it will be added. - Uses login + status request with retry logic. - """ - require_connected() - - # Get contact from database - contact = await _resolve_contact_or_404(public_key) - - # Verify it's a repeater - if contact.type != CONTACT_TYPE_REPEATER: - raise HTTPException( - status_code=400, - detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})", - ) - - async with radio_manager.radio_operation( - "request_telemetry", - pause_polling=True, - suspend_auto_fetch=True, - ) as mc: - # Prepare connection (add/remove dance + login) - await prepare_repeater_connection(mc, contact, request.password) - - # Request status with retries - logger.info("Requesting status from repeater %s", contact.public_key[:12]) - status = None - for attempt in range(1, 4): - logger.debug("Status request attempt %d/3", attempt) - status = await mc.commands.req_status_sync( - contact.public_key, timeout=10, min_timeout=5 - ) - if status: - break - logger.debug("Status request timeout, retrying...") - - if not status: - raise HTTPException( - status_code=504, detail="No response from repeater after 3 attempts" - ) - - logger.info("Received telemetry from %s: %s", contact.public_key[:12], status) - - # Fetch neighbors (fetch_all_neighbours handles pagination) - logger.info("Fetching neighbors from repeater %s", contact.public_key[:12]) - neighbors_data = None - for attempt in range(1, 4): - logger.debug("Neighbors request attempt %d/3", attempt) - neighbors_data = await mc.commands.fetch_all_neighbours( - contact.public_key, timeout=10, min_timeout=5 - ) - if neighbors_data: - break - logger.debug("Neighbors request timeout, retrying...") - - # Process neighbors - resolve pubkey prefixes to contact names - neighbors: list[NeighborInfo] = [] - if neighbors_data and "neighbours" in neighbors_data: - logger.info("Received %d neighbors", len(neighbors_data["neighbours"])) - for n in neighbors_data["neighbours"]: - pubkey_prefix = n.get("pubkey", "") - # Try to resolve to a contact name from our database - resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix) - neighbors.append( - NeighborInfo( - pubkey_prefix=pubkey_prefix, - name=resolved_contact.name if resolved_contact else None, - snr=n.get("snr", 0.0), - last_heard_seconds=n.get("secs_ago", 0), - ) - ) - - # Fetch ACL - logger.info("Fetching ACL from repeater %s", contact.public_key[:12]) - acl_data = None - for attempt in range(1, 4): - logger.debug("ACL request attempt %d/3", attempt) - acl_data = await mc.commands.req_acl_sync(contact.public_key, timeout=10, min_timeout=5) - if acl_data: - break - logger.debug("ACL request timeout, retrying...") - - # Process ACL - resolve pubkey prefixes to contact names - acl_entries: list[AclEntry] = [] - if acl_data and isinstance(acl_data, list): - logger.info("Received %d ACL entries", len(acl_data)) - for entry in acl_data: - pubkey_prefix = entry.get("key", "") - perm = entry.get("perm", 0) - # Try to resolve to a contact name from our database - 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})"), - ) - ) - - # Fetch clock output (up to 2 attempts) - logger.info("Fetching clock from repeater %s", contact.public_key[:12]) - clock_output: str | None = None - for attempt in range(1, 3): - logger.debug("Clock request attempt %d/2", attempt) - send_result = await mc.commands.send_cmd(contact.public_key, "clock") - if send_result.type == EventType.ERROR: - logger.debug("Clock command send error: %s", send_result.payload) - continue - - response_event = await _fetch_repeater_response( - mc, contact.public_key[:12], timeout=10.0 - ) - if response_event is None: - logger.debug("Clock request timeout, retrying...") - continue - - clock_output = response_event.payload.get("text", "") - logger.info("Received clock output: %s", clock_output) - break - - if clock_output is None: - clock_output = "Unable to fetch `clock` output (repeater did not respond)" - - # Convert raw telemetry to response format - # bat is in mV, convert to V (e.g., 3775 -> 3.775) - - return TelemetryResponse( - pubkey_prefix=status.get("pubkey_pre", contact.public_key[:12]), - 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), - neighbors=neighbors, - acl=acl_entries, - clock_output=clock_output, - ) - - @router.post("/{public_key}/command", response_model=CommandResponse) async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse: """Send a CLI command to a repeater. The contact must be a repeater (type=2). The user must have already logged in - via the telemetry endpoint. This endpoint ensures the contact is on the radio - before sending commands (the repeater remembers ACL permissions after login). + 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 @@ -678,13 +829,7 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com # Get contact from database contact = await _resolve_contact_or_404(public_key) - - # Verify it's a repeater - if contact.type != CONTACT_TYPE_REPEATER: - raise HTTPException( - status_code=400, - detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})", - ) + _require_repeater(contact) async with radio_manager.radio_operation( "send_repeater_command", @@ -693,17 +838,8 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com ) 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]) - add_result = await mc.commands.add_contact(contact.to_radio_dict()) - if add_result is not None and add_result.type == EventType.ERROR: - logger.warning( - "Failed to add repeater %s to radio: %s — continuing anyway", - contact.public_key[:12], - add_result.payload, - ) - broadcast_error( - "Failed to add repeater contact to radio, attempting to continue", - str(add_result.payload), - ) + 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) @@ -730,7 +866,7 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com ) # CONTACT_MSG_RECV payloads use sender_timestamp in meshcore. - response_text = response_event.payload.get("text", str(response_event.payload)) + response_text = _extract_response_text(response_event) sender_timestamp = response_event.payload.get( "sender_timestamp", response_event.payload.get("timestamp"), @@ -763,18 +899,8 @@ async def request_trace(public_key: str) -> TraceResponse: # Trace does not need auto-fetch suspension: response arrives as TRACE_DATA # from the reader loop, not via get_msg(). async with radio_manager.radio_operation("request_trace", pause_polling=True) as mc: - # Ensure contact is on radio so the trace can reach them (non-fatal) - add_result = await mc.commands.add_contact(contact.to_radio_dict()) - if add_result is not None and add_result.type == EventType.ERROR: - logger.warning( - "Failed to add contact %s to radio for trace: %s — continuing anyway", - contact.public_key[:12], - add_result.payload, - ) - broadcast_error( - "Failed to add contact to radio for trace, attempting to continue", - str(add_result.payload), - ) + # Ensure contact is on radio so the trace can reach them + await _ensure_on_radio(mc, contact) logger.info( "Sending trace to %s (tag=%d, hash=%s)", contact.public_key[:12], tag, contact_hash diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index adaee42..b2cbd10 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -33,8 +33,7 @@ frontend/src/ │ ├── index.ts # Central re-export of all hooks │ ├── useConversationMessages.ts # Fetch, pagination, dedup, ACK buffering │ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps -│ ├── useRepeaterMode.ts # Repeater login/command workflow -│ ├── useAirtimeTracking.ts # Repeater airtime stats polling +│ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries) │ ├── useRadioControl.ts # Radio health/config state, reconnection │ ├── useAppSettings.ts # Settings, favorites, preferences migration │ ├── useConversationRouter.ts # URL hash → active conversation routing @@ -68,6 +67,8 @@ frontend/src/ │ ├── BotCodeEditor.tsx │ ├── ContactAvatar.tsx │ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths) +│ ├── RepeaterDashboard.tsx # Repeater pane-based dashboard (telemetry, neighbors, ACL, etc.) +│ ├── RepeaterLogin.tsx # Repeater login form (password + guest) │ └── ui/ # shadcn/ui primitives ├── types/ │ └── d3-force-3d.d.ts # Type declarations for d3-force-3d @@ -75,7 +76,6 @@ frontend/src/ ├── setup.ts ├── fixtures/websocket_events.json ├── api.test.ts - ├── useAirtimeTracking.test.ts ├── appFavorites.test.tsx ├── appStartupHash.test.tsx ├── contactAvatar.test.ts @@ -85,6 +85,7 @@ frontend/src/ ├── pathUtils.test.ts ├── radioPresets.test.ts ├── rawPacketIdentity.test.ts + ├── repeaterDashboard.test.tsx ├── repeaterMode.test.ts ├── settingsModal.test.tsx ├── sidebar.test.tsx @@ -92,7 +93,7 @@ frontend/src/ ├── urlHash.test.ts ├── useConversationMessages.test.ts ├── useConversationMessages.race.test.ts - ├── useRepeaterMode.test.ts + ├── useRepeaterDashboard.test.ts ├── useWebSocket.dispatch.test.ts └── useWebSocket.lifecycle.test.ts ``` @@ -108,12 +109,7 @@ frontend/src/ - `useConversationRouter`: URL hash → active conversation routing - `useConversationMessages`: fetch, pagination, dedup/update helpers - `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps -- `useRepeaterMode`: repeater login/command workflow -- `useAirtimeTracking`: repeater airtime stats polling - -### Local message IDs - -`useRepeaterMode` and `useAirtimeTracking` each have a `createLocalMessage` that generates ephemeral (non-persisted) message IDs via `-Date.now()`. Both hooks write to the same `setMessages`, so a same-millisecond call would produce duplicate IDs. In practice this requires a repeater command response and an airtime poll to land in the exact same ms — staggeringly unlikely and cosmetic-only (React duplicate key warning). Not worth fixing. +- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions) ### Initial load + realtime @@ -204,13 +200,19 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid - `last_advert_time` - `bots` -## Repeater Mode +## Repeater Dashboard -For repeater contacts (`type=2`): -1. Telemetry/login phase (`POST /api/contacts/{key}/telemetry`) -2. Command phase (`POST /api/contacts/{key}/command`) +For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput). -CLI responses are rendered as local-only messages (not persisted to DB). +**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`. + +**Dashboard panes** (after login): Telemetry, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info, Clock — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. "Load All" fetches all panes serially (parallel would queue behind the radio lock). + +**Actions pane**: Send Advert, Sync Clock, Reboot — all send CLI commands via `POST /api/contacts/{key}/command`. + +**Console pane**: Full CLI access via the same command endpoint. History is ephemeral (not persisted to DB). + +All state is managed by `useRepeaterDashboard` hook. State resets on conversation change. ## Styling diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 99ec2ee..876f84e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,7 +12,6 @@ import { api } from './api'; import { takePrefetch } from './prefetch'; import { useWebSocket } from './useWebSocket'; import { - useRepeaterMode, useUnreadCounts, useConversationMessages, getMessageContentKey, @@ -35,8 +34,12 @@ import { } from './components/settingsConstants'; import { RawPacketList } from './components/RawPacketList'; import { ContactInfoPane } from './components/ContactInfoPane'; +import { CONTACT_TYPE_REPEATER } from './types'; // Lazy-load heavy components to reduce initial bundle +const RepeaterDashboard = lazy(() => + import('./components/RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard })) +); const MapView = lazy(() => import('./components/MapView').then((m) => ({ default: m.MapView }))); const VisualizerView = lazy(() => import('./components/VisualizerView').then((m) => ({ default: m.VisualizerView })) @@ -167,7 +170,6 @@ export function App() { messagesLoading, loadingOlder, hasOlderMessages, - setMessages, fetchOlderMessages, addMessageIfNew, updateMessageAck, @@ -182,12 +184,12 @@ export function App() { trackNewMessage, } = useUnreadCounts(channels, contacts, activeConversation); - const { - repeaterLoggedIn, - activeContactIsRepeater, - handleTelemetryRequest, - handleRepeaterCommand, - } = useRepeaterMode(activeConversation, contacts, setMessages, activeConversationRef); + // Determine if active contact is a repeater (used for routing to dashboard) + const activeContactIsRepeater = useMemo(() => { + if (!activeConversation || activeConversation.type !== 'contact') return false; + const contact = contacts.find((c) => c.public_key === activeConversation.id); + return contact?.type === CONTACT_TYPE_REPEATER; + }, [activeConversation, contacts]); // WebSocket handlers - memoized to prevent reconnection loops const wsHandlers = useMemo( @@ -562,6 +564,27 @@ export function App() { + ) : activeContactIsRepeater ? ( + + Loading dashboard... + + } + > + + ) : ( <> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 203a1aa..4f69e28 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -15,8 +15,15 @@ import type { MigratePreferencesResponse, RadioConfig, RadioConfigUpdate, + RepeaterAclResponse, + RepeaterAdvertIntervalsResponse, + RepeaterLoginResponse, + RepeaterLppTelemetryResponse, + RepeaterNeighborsResponse, + RepeaterOwnerInfoResponse, + RepeaterRadioSettingsResponse, + RepeaterStatusResponse, StatisticsResponse, - TelemetryResponse, TraceResponse, UnreadCounts, } from './types'; @@ -118,11 +125,6 @@ export const api = { fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, { method: 'POST', }), - requestTelemetry: (publicKey: string, password: string) => - fetchJson(`/contacts/${publicKey}/telemetry`, { - method: 'POST', - body: JSON.stringify({ password }), - }), sendRepeaterCommand: (publicKey: string, command: string) => fetchJson(`/contacts/${publicKey}/command`, { method: 'POST', @@ -240,4 +242,39 @@ export const api = { // Statistics getStatistics: () => fetchJson('/statistics'), + + // Granular repeater endpoints + repeaterLogin: (publicKey: string, password: string) => + fetchJson(`/contacts/${publicKey}/repeater/login`, { + method: 'POST', + body: JSON.stringify({ password }), + }), + repeaterStatus: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/repeater/status`, { + method: 'POST', + }), + repeaterNeighbors: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/repeater/neighbors`, { + method: 'POST', + }), + repeaterAcl: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/repeater/acl`, { + method: 'POST', + }), + repeaterRadioSettings: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/repeater/radio-settings`, { + method: 'POST', + }), + repeaterAdvertIntervals: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/repeater/advert-intervals`, { + method: 'POST', + }), + repeaterOwnerInfo: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/repeater/owner-info`, { + method: 'POST', + }), + repeaterLppTelemetry: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/repeater/lpp-telemetry`, { + method: 'POST', + }), }; diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index 2d83b78..9a28017 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -33,8 +33,6 @@ interface MessageInputProps { onSend: (text: string) => Promise; disabled: boolean; placeholder?: string; - /** When true, input becomes password field for repeater telemetry */ - isRepeaterMode?: boolean; /** Conversation type for character limit calculation */ conversationType?: 'contact' | 'channel' | 'raw'; /** Sender name (radio name) for channel message limit calculation */ @@ -48,7 +46,7 @@ export interface MessageInputHandle { } export const MessageInput = forwardRef(function MessageInput( - { onSend, disabled, placeholder, isRepeaterMode, conversationType, senderName }, + { onSend, disabled, placeholder, conversationType, senderName }, ref ) { const [text, setText] = useState(''); @@ -112,45 +110,25 @@ export const MessageInput = forwardRef(fu async (e: FormEvent) => { e.preventDefault(); const trimmed = text.trim(); + if (!trimmed || sending || disabled) return; - // For repeater mode, empty password means guest login - if (isRepeaterMode) { - if (sending || disabled) return; - setSending(true); - try { - await onSend(trimmed); - setText(''); - } catch (err) { - console.error('Failed to request telemetry:', err); - toast.error('Failed to request telemetry', { - description: err instanceof Error ? err.message : 'Check radio connection', - }); - return; - } finally { - setSending(false); - } - // Refocus after React re-enables the input (now in CLI command mode) - setTimeout(() => inputRef.current?.focus(), 0); - } else { - if (!trimmed || sending || disabled) return; - setSending(true); - try { - await onSend(trimmed); - setText(''); - } catch (err) { - console.error('Failed to send message:', err); - toast.error('Failed to send message', { - description: err instanceof Error ? err.message : 'Check radio connection', - }); - return; - } finally { - setSending(false); - } - // Refocus after React re-enables the input - setTimeout(() => inputRef.current?.focus(), 0); + setSending(true); + try { + await onSend(trimmed); + setText(''); + } catch (err) { + console.error('Failed to send message:', err); + toast.error('Failed to send message', { + description: err instanceof Error ? err.message : 'Check radio connection', + }); + return; + } finally { + setSending(false); } + // Refocus after React re-enables the input + setTimeout(() => inputRef.current?.focus(), 0); }, - [text, sending, disabled, onSend, isRepeaterMode] + [text, sending, disabled, onSend] ); const handleKeyDown = useCallback( @@ -163,12 +141,11 @@ export const MessageInput = forwardRef(fu [handleSubmit] ); - // For repeater mode, always allow submit (empty = guest login) - const canSubmit = isRepeaterMode ? true : text.trim().length > 0; + const canSubmit = text.trim().length > 0; - // Show counter for messages (not repeater mode or raw). + // Show counter for messages (not raw). // Desktop: always visible. Mobile: only show count after 100 characters. - const showCharCounter = !isRepeaterMode && limits !== null; + const showCharCounter = limits !== null; const showMobileCounterValue = text.length > 100; return ( @@ -180,7 +157,7 @@ export const MessageInput = forwardRef(fu
(fu value={text} onChange={(e) => setText(e.target.value)} onKeyDown={handleKeyDown} - placeholder={ - placeholder || - (isRepeaterMode ? 'Enter password for admin login...' : 'Type a message...') - } + placeholder={placeholder || 'Type a message...'} disabled={disabled || sending} className="flex-1 min-w-0" /> @@ -201,15 +175,7 @@ export const MessageInput = forwardRef(fu disabled={disabled || sending || !canSubmit} className="flex-shrink-0" > - {sending - ? isRepeaterMode - ? 'Logging in...' - : 'Sending...' - : isRepeaterMode - ? text.trim() - ? 'Log in with password' - : 'Log in as guest/use repeater ACLs' - : 'Send'} + {sending ? 'Sending...' : 'Send'}
{showCharCounter && ( diff --git a/frontend/src/components/NeighborsMiniMap.tsx b/frontend/src/components/NeighborsMiniMap.tsx new file mode 100644 index 0000000..e878845 --- /dev/null +++ b/frontend/src/components/NeighborsMiniMap.tsx @@ -0,0 +1,98 @@ +import { MapContainer, TileLayer, CircleMarker, Popup, Polyline } from 'react-leaflet'; +import 'leaflet/dist/leaflet.css'; + +interface Neighbor { + lat: number | null; + lon: number | null; + name: string | null; + pubkey_prefix: string; + snr: number; +} + +interface Props { + neighbors: Neighbor[]; + radioLat?: number | null; + radioLon?: number | null; + radioName?: string | null; +} + +export function NeighborsMiniMap({ neighbors, radioLat, radioLon, radioName }: Props) { + const valid = neighbors.filter( + (n): n is Neighbor & { lat: number; lon: number } => n.lat != null && n.lon != null + ); + + const hasRadio = radioLat != null && radioLon != null && !(radioLat === 0 && radioLon === 0); + + if (valid.length === 0 && !hasRadio) return null; + + // Center on radio if available, otherwise first neighbor + const center: [number, number] = hasRadio ? [radioLat!, radioLon!] : [valid[0].lat, valid[0].lon]; + + return ( +
+ + + {/* Dotted lines from radio to each neighbor */} + {hasRadio && + valid.map((n, i) => ( + + ))} + {/* Radio node (bright blue) */} + {hasRadio && ( + + + {radioName || 'Our Radio'} + + + )} + {/* Neighbor nodes (SNR-colored) */} + {valid.map((n, i) => ( + = 6 ? '#22c55e' : n.snr >= 0 ? '#eab308' : '#ef4444', + fillOpacity: 0.8, + weight: 1, + }} + > + + {n.name || n.pubkey_prefix} + + + ))} + +
+ ); +} diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx new file mode 100644 index 0000000..8a85afa --- /dev/null +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -0,0 +1,971 @@ +import { + useState, + useCallback, + useRef, + useEffect, + useMemo, + type FormEvent, + lazy, + Suspense, +} from 'react'; +import { toast } from './ui/sonner'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { Separator } from './ui/separator'; +import { RepeaterLogin } from './RepeaterLogin'; +import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard'; +import { formatTime } from '../utils/messageParser'; +import { isFavorite } from '../utils/favorites'; +import { cn } from '@/lib/utils'; +import type { + Contact, + Conversation, + Favorite, + LppSensor, + PaneState, + RepeaterStatusResponse, + RepeaterNeighborsResponse, + RepeaterAclResponse, + RepeaterRadioSettingsResponse, + RepeaterAdvertIntervalsResponse, + RepeaterOwnerInfoResponse, + RepeaterLppTelemetryResponse, + NeighborInfo, +} from '../types'; +import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils'; + +// Lazy-load the entire mini-map file so react-leaflet imports are bundled together +// and MapContainer only mounts once (avoids "already initialized" crash). +const NeighborsMiniMap = lazy(() => + import('./NeighborsMiniMap').then((m) => ({ default: m.NeighborsMiniMap })) +); + +// --- Shared Icons --- + +function RefreshIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +// --- Utility --- + +export function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const mins = Math.floor((seconds % 3600) / 60); + if (days > 0) { + if (hours > 0 && mins > 0) return `${days}d${hours}h${mins}m`; + if (hours > 0) return `${days}d${hours}h`; + if (mins > 0) return `${days}d${mins}m`; + return `${days}d`; + } + if (hours > 0) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`; + return `${mins}m`; +} + +// --- Generic Pane Wrapper --- + +function RepeaterPane({ + title, + state, + onRefresh, + disabled, + children, + className, + contentClassName, +}: { + title: string; + state: PaneState; + onRefresh?: () => void; + disabled?: boolean; + children: React.ReactNode; + className?: string; + contentClassName?: string; +}) { + return ( +
+
+

{title}

+ {onRefresh && ( + + )} +
+ {state.error && ( +
+ {state.error} +
+ )} +
+ {state.loading ? ( +

+ Fetching{state.attempt > 1 ? ` (attempt ${state.attempt}/${3})` : ''}... +

+ ) : ( + children + )} +
+
+ ); +} + +function NotFetched() { + return

<not fetched>

; +} + +function KvRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +// --- Individual Panes --- + +function TelemetryPane({ + data, + state, + onRefresh, + disabled, +}: { + data: RepeaterStatusResponse | null; + state: PaneState; + onRefresh: () => void; + disabled?: boolean; +}) { + return ( + + {!data ? ( + + ) : ( +
+ + + + + + + + + + + + + + + + +
+ )} +
+ ); +} + +function NeighborsPane({ + data, + state, + onRefresh, + disabled, + contacts, + radioLat, + radioLon, + radioName, +}: { + data: RepeaterNeighborsResponse | null; + state: PaneState; + onRefresh: () => void; + disabled?: boolean; + contacts: Contact[]; + radioLat: number | null; + radioLon: number | null; + radioName: string | null; +}) { + // Resolve contact data for each neighbor in a single pass — used for + // coords (mini-map), distances (table column), and sorted display order. + const { neighborsWithCoords, sorted, hasDistances } = useMemo(() => { + if (!data) { + return { + neighborsWithCoords: [] as Array, + sorted: [] as Array, + hasDistances: false, + }; + } + + const withCoords: Array = []; + const enriched: Array = []; + let anyDist = false; + + for (const n of data.neighbors) { + const contact = contacts.find((c) => c.public_key.startsWith(n.pubkey_prefix)); + const nLat = contact?.lat ?? null; + const nLon = contact?.lon ?? null; + + let dist: string | null = null; + if (isValidLocation(radioLat, radioLon) && isValidLocation(nLat, nLon)) { + const distKm = calculateDistance(radioLat, radioLon, nLat, nLon); + if (distKm != null) { + dist = formatDistance(distKm); + anyDist = true; + } + } + enriched.push({ ...n, distance: dist }); + + if (isValidLocation(nLat, nLon)) { + withCoords.push({ ...n, lat: nLat, lon: nLon }); + } + } + + enriched.sort((a, b) => b.snr - a.snr); + + return { + neighborsWithCoords: withCoords, + sorted: enriched, + hasDistances: anyDist, + }; + }, [data, contacts, radioLat, radioLon]); + + return ( + + {!data ? ( + + ) : sorted.length === 0 ? ( +

No neighbors reported

+ ) : ( +
+
+ + + + + + {hasDistances && } + + + + + {sorted.map((n, i) => { + const dist = n.distance; + const snrStr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1); + const snrColor = + n.snr >= 6 ? 'text-green-500' : n.snr >= 0 ? 'text-yellow-500' : 'text-red-500'; + return ( + + + + {hasDistances && ( + + )} + + + ); + })} + +
NameSNRDistLast Heard
{n.name || n.pubkey_prefix}{snrStr} dB + {dist ?? '—'} + + {formatDuration(n.last_heard_seconds)} ago +
+
+ {(neighborsWithCoords.length > 0 || isValidLocation(radioLat, radioLon)) && ( + + Loading map... +
+ } + > + n.pubkey_prefix).join(',')} + neighbors={neighborsWithCoords} + radioLat={radioLat} + radioLon={radioLon} + radioName={radioName} + /> + + )} + + )} +
+ ); +} + +function AclPane({ + data, + state, + onRefresh, + disabled, +}: { + data: RepeaterAclResponse | null; + state: PaneState; + onRefresh: () => void; + disabled?: boolean; +}) { + const permColor: Record = { + 0: 'bg-muted text-muted-foreground', + 1: 'bg-blue-500/10 text-blue-500', + 2: 'bg-green-500/10 text-green-500', + 3: 'bg-amber-500/10 text-amber-500', + }; + + return ( + + {!data ? ( + + ) : data.acl.length === 0 ? ( +

No ACL entries

+ ) : ( + + + + + + + + + {data.acl.map((entry, i) => ( + + + + + ))} + +
NamePermission
{entry.name || entry.pubkey_prefix} + + {entry.permission_name} + +
+ )} +
+ ); +} + +export function formatClockDrift(clockUtc: string): { text: string; isLarge: boolean } { + // Firmware format: "HH:MM - D/M/YYYY UTC" or "HH:MM:SS - D/M/YYYY UTC" + // Also handle ISO-like: "YYYY-MM-DD HH:MM:SS" + let parsed: Date; + const fwMatch = clockUtc.match( + /^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*-\s*(\d{1,2})\/(\d{1,2})\/(\d{4})/ + ); + if (fwMatch) { + const [, hh, mm, ss, dd, mo, yyyy] = fwMatch; + parsed = new Date(Date.UTC(+yyyy, +mo - 1, +dd, +hh, +mm, +(ss ?? 0))); + } else { + parsed = new Date( + clockUtc.replace(' ', 'T') + (clockUtc.includes('Z') || clockUtc.includes('UTC') ? '' : 'Z') + ); + } + if (isNaN(parsed.getTime())) return { text: '(invalid)', isLarge: false }; + + const driftMs = Math.abs(Date.now() - parsed.getTime()); + const driftSec = Math.floor(driftMs / 1000); + + if (driftSec >= 86400) return { text: '>24 hours!', isLarge: true }; + + const h = Math.floor(driftSec / 3600); + const m = Math.floor((driftSec % 3600) / 60); + const s = driftSec % 60; + + const parts: string[] = []; + if (h > 0) parts.push(`${h}h`); + if (m > 0) parts.push(`${m}m`); + parts.push(`${s}s`); + + return { text: parts.join(''), isLarge: false }; +} + +function RadioSettingsPane({ + data, + state, + onRefresh, + disabled, + advertData, + advertState, + onRefreshAdvert, +}: { + data: RepeaterRadioSettingsResponse | null; + state: PaneState; + onRefresh: () => void; + disabled?: boolean; + advertData: RepeaterAdvertIntervalsResponse | null; + advertState: PaneState; + onRefreshAdvert: () => void; +}) { + const clockDrift = useMemo(() => { + if (!data?.clock_utc) return null; + return formatClockDrift(data.clock_utc); + }, [data?.clock_utc]); + + return ( + + {!data ? ( + + ) : ( +
+ + + + + + + + + + +
+ Clock (UTC) + + {data.clock_utc ?? '—'} + {clockDrift && ( + + (drift: {clockDrift.text}) + + )} + +
+
+ )} + {/* Advert Intervals sub-section */} + +
+ Advert Intervals + +
+ {advertState.error &&

{advertState.error}

} + {advertState.loading ? ( +

+ Fetching{advertState.attempt > 1 ? ` (attempt ${advertState.attempt}/3)` : ''}... +

+ ) : !advertData ? ( + + ) : ( +
+ + +
+ )} +
+ ); +} + +function formatAdvertInterval(val: string | null): string { + if (val == null) return '—'; + const trimmed = val.trim(); + if (trimmed === '0') return ''; + return `${trimmed}h`; +} + +const LPP_UNIT_MAP: Record = { + temperature: '°C', + humidity: '%', + barometer: 'hPa', + voltage: 'V', + current: 'mA', + luminosity: 'lux', + altitude: 'm', + power: 'W', + distance: 'mm', + energy: 'kWh', + direction: '°', + concentration: 'ppm', + colour: '', +}; + +function formatLppLabel(typeName: string): string { + return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' '); +} + +function LppSensorRow({ sensor }: { sensor: LppSensor }) { + const label = formatLppLabel(sensor.type_name); + + if (typeof sensor.value === 'object' && sensor.value !== null) { + // Multi-value sensor (GPS, accelerometer, etc.) + return ( +
+ {label} +
+ {Object.entries(sensor.value).map(([k, v]) => ( + + ))} +
+
+ ); + } + + const unit = LPP_UNIT_MAP[sensor.type_name] ?? ''; + const formatted = + typeof sensor.value === 'number' + ? `${sensor.value % 1 === 0 ? sensor.value : sensor.value.toFixed(2)}${unit ? ` ${unit}` : ''}` + : String(sensor.value); + + return ; +} + +function LppTelemetryPane({ + data, + state, + onRefresh, + disabled, +}: { + data: RepeaterLppTelemetryResponse | null; + state: PaneState; + onRefresh: () => void; + disabled?: boolean; +}) { + return ( + + {!data ? ( + + ) : data.sensors.length === 0 ? ( +

No sensor data available

+ ) : ( +
+ {data.sensors.map((sensor, i) => ( + + ))} +
+ )} +
+ ); +} + +function OwnerInfoPane({ + data, + state, + onRefresh, + disabled, +}: { + data: RepeaterOwnerInfoResponse | null; + state: PaneState; + onRefresh: () => void; + disabled?: boolean; +}) { + return ( + + {!data ? ( + + ) : ( +
+ + +
+ )} +
+ ); +} + +function ActionsPane({ + onSendAdvert, + onSyncClock, + onReboot, + consoleLoading, +}: { + onSendAdvert: () => void; + onSyncClock: () => void; + onReboot: () => void; + consoleLoading: boolean; +}) { + const [confirmReboot, setConfirmReboot] = useState(false); + + const handleReboot = useCallback(() => { + if (!confirmReboot) { + setConfirmReboot(true); + return; + } + setConfirmReboot(false); + onReboot(); + }, [confirmReboot, onReboot]); + + // Reset confirmation after 3 seconds + useEffect(() => { + if (!confirmReboot) return; + const timer = setTimeout(() => setConfirmReboot(false), 3000); + return () => clearTimeout(timer); + }, [confirmReboot]); + + return ( +
+
+

Actions

+
+
+ + + +
+
+ ); +} + +function ConsolePane({ + history, + loading, + onSend, +}: { + history: Array<{ command: string; response: string; timestamp: number; outgoing: boolean }>; + loading: boolean; + onSend: (command: string) => Promise; +}) { + const [input, setInput] = useState(''); + const outputRef = useRef(null); + + // Auto-scroll to bottom on new entries + useEffect(() => { + if (outputRef.current) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }, [history]); + + const handleSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + const trimmed = input.trim(); + if (!trimmed || loading) return; + setInput(''); + await onSend(trimmed); + }, + [input, loading, onSend] + ); + + return ( +
+
+

Console

+
+
+ {history.length === 0 && ( +

Type a CLI command below...

+ )} + {history.map((entry, i) => + entry.outgoing ? ( +
+ > {entry.command} +
+ ) : ( +
+ {entry.response} +
+ ) + )} + {loading &&
...
} +
+
+ setInput(e.target.value)} + placeholder="CLI command..." + disabled={loading} + className="flex-1 font-mono text-sm" + /> + +
+
+ ); +} + +// --- Main Dashboard --- + +interface RepeaterDashboardProps { + conversation: Conversation; + contacts: Contact[]; + favorites: Favorite[]; + radioLat: number | null; + radioLon: number | null; + radioName: string | null; + onTrace: () => void; + onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; + onDeleteContact: (publicKey: string) => void; +} + +export function RepeaterDashboard({ + conversation, + contacts, + favorites, + radioLat, + radioLon, + radioName, + onTrace, + onToggleFavorite, + onDeleteContact, +}: RepeaterDashboardProps) { + const { + loggedIn, + loginLoading, + loginError, + paneData, + paneStates, + consoleHistory, + consoleLoading, + login, + loginAsGuest, + refreshPane, + loadAll, + sendConsoleCommand, + sendAdvert, + rebootRepeater, + syncClock, + } = useRepeaterDashboard(conversation); + + const contact = contacts.find((c) => c.public_key === conversation.id); + const isFav = isFavorite(favorites, 'contact', conversation.id); + + // Loading all panes indicator + const anyLoading = Object.values(paneStates).some((s) => s.loading); + + return ( +
+ {/* Header */} +
+ + {conversation.name} + { + navigator.clipboard.writeText(conversation.id); + toast.success('Contact key copied!'); + }} + title="Click to copy" + > + {conversation.id} + + {contact?.last_seen && ( + + (Last heard: {formatTime(contact.last_seen)}) + + )} + +
+ {loggedIn && ( + + )} + + + +
+
+ + {/* Body */} +
+ {!loggedIn ? ( + + ) : ( +
+ {/* Top row: Telemetry + Radio Settings | Neighbors (with expanding map) */} +
+
+ refreshPane('status')} + disabled={anyLoading} + /> + refreshPane('radioSettings')} + disabled={anyLoading} + advertData={paneData.advertIntervals} + advertState={paneStates.advertIntervals} + onRefreshAdvert={() => refreshPane('advertIntervals')} + /> + refreshPane('lppTelemetry')} + disabled={anyLoading} + /> +
+ refreshPane('neighbors')} + disabled={anyLoading} + contacts={contacts} + radioLat={radioLat} + radioLon={radioLon} + radioName={radioName} + /> +
+ + {/* Remaining panes: ACL | Owner Info + Actions */} +
+ refreshPane('acl')} + disabled={anyLoading} + /> +
+ refreshPane('ownerInfo')} + disabled={anyLoading} + /> + +
+
+ + {/* Console — full width */} + +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/RepeaterLogin.tsx b/frontend/src/components/RepeaterLogin.tsx new file mode 100644 index 0000000..d1ad725 --- /dev/null +++ b/frontend/src/components/RepeaterLogin.tsx @@ -0,0 +1,74 @@ +import { useState, useCallback, type FormEvent } from 'react'; +import { Input } from './ui/input'; +import { Button } from './ui/button'; + +interface RepeaterLoginProps { + repeaterName: string; + loading: boolean; + error: string | null; + onLogin: (password: string) => Promise; + onLoginAsGuest: () => Promise; +} + +export function RepeaterLogin({ + repeaterName, + loading, + error, + onLogin, + onLoginAsGuest, +}: RepeaterLoginProps) { + const [password, setPassword] = useState(''); + + const handleSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + if (loading) return; + await onLogin(password.trim()); + }, + [password, loading, onLogin] + ); + + return ( +
+
+
+

{repeaterName}

+

Log in to access repeater dashboard

+
+ +
+ setPassword(e.target.value)} + placeholder="Repeater password..." + disabled={loading} + autoFocus + /> + + {error &&

{error}

} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index d34f9af..7ff0db7 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1,4 +1,3 @@ -export { useRepeaterMode } from './useRepeaterMode'; export { useUnreadCounts } from './useUnreadCounts'; export { useConversationMessages, getMessageContentKey } from './useConversationMessages'; export { useRadioControl } from './useRadioControl'; diff --git a/frontend/src/hooks/useAirtimeTracking.ts b/frontend/src/hooks/useAirtimeTracking.ts deleted file mode 100644 index c33e708..0000000 --- a/frontend/src/hooks/useAirtimeTracking.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** - * Airtime/duty cycle tracking for repeaters. - * - * When "dutycycle_start" command is issued, this captures baseline telemetry - * and polls every 5 minutes to display rolling airtime/duty cycle statistics. - */ - -import { useRef, useCallback, useEffect } from 'react'; -import { api } from '../api'; -import type { Message, TelemetryResponse } from '../types'; - -// Baseline telemetry snapshot for airtime tracking -interface AirtimeBaseline { - startTime: number; // epoch seconds - uptime: number; - txAirtime: number; - rxAirtime: number; - sentFlood: number; - sentDirect: number; - recvFlood: number; - recvDirect: number; - conversationId: string; -} - -// Polling interval: 5 minutes -const AIRTIME_POLL_INTERVAL_MS = 5 * 60 * 1000; - -// Format duration in XhXmXs format -function formatAirtimeDuration(seconds: number): string { - const hours = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - return `${hours}h${mins}m${secs}s`; -} - -// Get emoji indicator for TX duty cycle percentage -function getTxDutyCycleEmoji(pct: number): string { - if (pct <= 5) return '✅'; - if (pct <= 10) return '🟢'; - if (pct <= 25) return '🟡'; - if (pct <= 50) return '🔴'; - return '🚨'; -} - -// Format airtime statistics comparing current telemetry to baseline -function formatAirtimeStats(baseline: AirtimeBaseline, current: TelemetryResponse): string { - const now = Math.floor(Date.now() / 1000); - const wallDuration = now - baseline.startTime; - - // Compute deltas - const deltaUptime = current.uptime_seconds - baseline.uptime; - const deltaTxAirtime = current.airtime_seconds - baseline.txAirtime; - const deltaRxAirtime = current.rx_airtime_seconds - baseline.rxAirtime; - const deltaSentFlood = current.sent_flood - baseline.sentFlood; - const deltaSentDirect = current.sent_direct - baseline.sentDirect; - const deltaRecvFlood = current.recv_flood - baseline.recvFlood; - const deltaRecvDirect = current.recv_direct - baseline.recvDirect; - - // Calculate airtime percentages - const txPct = deltaUptime > 0 ? (deltaTxAirtime / deltaUptime) * 100 : 0; - const rxPct = deltaUptime > 0 ? (deltaRxAirtime / deltaUptime) * 100 : 0; - - // Estimate flood/direct airtime breakdown based on packet proportions - const totalSent = deltaSentFlood + deltaSentDirect; - const totalRecv = deltaRecvFlood + deltaRecvDirect; - - const txFloodPct = totalSent > 0 ? txPct * (deltaSentFlood / totalSent) : 0; - const txDirectPct = totalSent > 0 ? txPct * (deltaSentDirect / totalSent) : 0; - const rxFloodPct = totalRecv > 0 ? rxPct * (deltaRecvFlood / totalRecv) : 0; - const rxDirectPct = totalRecv > 0 ? rxPct * (deltaRecvDirect / totalRecv) : 0; - - const txEmoji = getTxDutyCycleEmoji(txPct); - const idlePct = Math.max(0, 100 - txPct - rxPct); - - const lines = [ - `Airtime/Duty Cycle Statistics`, - `Duration: ${formatAirtimeDuration(wallDuration)} (uptime delta: ${formatAirtimeDuration(deltaUptime)})`, - ``, - `${txEmoji} TX Airtime: ${txPct.toFixed(3)}% (${totalSent.toLocaleString()} pkts)`, - ` Flood: ${txFloodPct.toFixed(3)}% (${deltaSentFlood.toLocaleString()} pkts)`, - ` Direct: ${txDirectPct.toFixed(3)}% (${deltaSentDirect.toLocaleString()} pkts)`, - ``, - `RX Airtime: ${rxPct.toFixed(3)}% (${totalRecv.toLocaleString()} pkts)`, - ` Flood: ${rxFloodPct.toFixed(3)}% (${deltaRecvFlood.toLocaleString()} pkts)`, - ` Direct: ${rxDirectPct.toFixed(3)}% (${deltaRecvDirect.toLocaleString()} pkts)`, - ``, - `Idle: ${idlePct.toFixed(3)}%`, - ]; - - return lines.join('\n'); -} - -// Create a local message object (not persisted to database) -function createLocalMessage(conversationKey: string, text: string, outgoing: boolean): Message { - const now = Math.floor(Date.now() / 1000); - return { - id: -Date.now(), - type: 'PRIV', - conversation_key: conversationKey, - text, - sender_timestamp: now, - received_at: now, - paths: null, - txt_type: 0, - signature: null, - outgoing, - acked: 1, - }; -} - -interface UseAirtimeTrackingResult { - /** Returns true if this was an airtime command that was handled */ - handleAirtimeCommand: (command: string, conversationId: string) => Promise; - /** Stop any active airtime tracking */ - stopTracking: () => void; -} - -export function useAirtimeTracking( - setMessages: React.Dispatch> -): UseAirtimeTrackingResult { - const baselineRef = useRef(null); - const intervalRef = useRef(null); - - // Stop tracking and clear interval - const stopTracking = useCallback(() => { - if (intervalRef.current !== null) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - baselineRef.current = null; - }, []); - - // Poll for airtime stats with one retry on failure - const pollAirtimeStats = useCallback(async () => { - const baseline = baselineRef.current; - if (!baseline) return; - - let telemetry: TelemetryResponse | null = null; - let lastError: Error | null = null; - - // Try up to 2 times (initial + 1 retry) - for (let attempt = 0; attempt < 2; attempt++) { - try { - telemetry = await api.requestTelemetry(baseline.conversationId, ''); - break; // Success, exit loop - } catch (err) { - lastError = err instanceof Error ? err : new Error('Unknown error'); - // Wait a moment before retry - if (attempt === 0) { - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - } - } - - // If tracking was stopped while the request was in-flight (e.g. conversation - // switch called stopTracking), discard the stale response. - if (!baselineRef.current) return; - - if (telemetry) { - const statsMessage = createLocalMessage( - baseline.conversationId, - formatAirtimeStats(baseline, telemetry), - false - ); - setMessages((prev) => [...prev, statsMessage]); - } else { - const errorMessage = createLocalMessage( - baseline.conversationId, - `Duty cycle poll failed after retry: ${lastError?.message ?? 'Unknown error'}`, - false - ); - setMessages((prev) => [...prev, errorMessage]); - } - }, [setMessages]); - - // Handle airtime commands - const handleAirtimeCommand = useCallback( - async (command: string, conversationId: string): Promise => { - const cmd = command.trim().toLowerCase(); - - if (cmd === 'dutycycle_start') { - // Stop any existing tracking - stopTracking(); - - // Fetch initial telemetry with one retry - let telemetry: TelemetryResponse | null = null; - let lastError: Error | null = null; - - for (let attempt = 0; attempt < 2; attempt++) { - try { - telemetry = await api.requestTelemetry(conversationId, ''); - break; - } catch (err) { - lastError = err instanceof Error ? err : new Error('Unknown error'); - if (attempt === 0) { - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - } - } - - if (!telemetry) { - const errorMessage = createLocalMessage( - conversationId, - `Failed to start duty cycle tracking after retry: ${lastError?.message ?? 'Unknown error'}`, - false - ); - setMessages((prev) => [...prev, errorMessage]); - return true; - } - - // Store baseline - const now = Math.floor(Date.now() / 1000); - baselineRef.current = { - startTime: now, - uptime: telemetry.uptime_seconds, - txAirtime: telemetry.airtime_seconds, - rxAirtime: telemetry.rx_airtime_seconds, - sentFlood: telemetry.sent_flood, - sentDirect: telemetry.sent_direct, - recvFlood: telemetry.recv_flood, - recvDirect: telemetry.recv_direct, - conversationId, - }; - - // Add start message - const startMessage = createLocalMessage( - conversationId, - `Airtime/duty cycle statistics gathering begins at ${now}. Logs will follow every 5 minutes. To stop, run dutycycle_stop or navigate away from this conversation.`, - false - ); - setMessages((prev) => [...prev, startMessage]); - - // Start polling interval - intervalRef.current = window.setInterval(pollAirtimeStats, AIRTIME_POLL_INTERVAL_MS); - - return true; - } - - if (cmd === 'dutycycle_stop') { - if (baselineRef.current && baselineRef.current.conversationId === conversationId) { - // Do one final poll before stopping - await pollAirtimeStats(); - - stopTracking(); - - const stopMessage = createLocalMessage( - conversationId, - 'Airtime/duty cycle statistics gathering stopped.', - false - ); - setMessages((prev) => [...prev, stopMessage]); - } else { - const notRunningMessage = createLocalMessage( - conversationId, - 'Duty cycle tracking is not active.', - false - ); - setMessages((prev) => [...prev, notRunningMessage]); - } - return true; - } - - return false; // Not an airtime command - }, - [setMessages, stopTracking, pollAirtimeStats] - ); - - // Cleanup on unmount - useEffect(() => { - return () => { - if (intervalRef.current !== null) { - clearInterval(intervalRef.current); - } - }; - }, []); - - return { - handleAirtimeCommand, - stopTracking, - }; -} diff --git a/frontend/src/hooks/useRepeaterDashboard.ts b/frontend/src/hooks/useRepeaterDashboard.ts new file mode 100644 index 0000000..6bae0ef --- /dev/null +++ b/frontend/src/hooks/useRepeaterDashboard.ts @@ -0,0 +1,298 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { api } from '../api'; +import { toast } from '../components/ui/sonner'; +import type { + Conversation, + PaneName, + PaneState, + RepeaterStatusResponse, + RepeaterNeighborsResponse, + RepeaterAclResponse, + RepeaterRadioSettingsResponse, + RepeaterAdvertIntervalsResponse, + RepeaterOwnerInfoResponse, + RepeaterLppTelemetryResponse, + CommandResponse, +} from '../types'; + +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 2000; + +interface ConsoleEntry { + command: string; + response: string; + timestamp: number; + outgoing: boolean; +} + +interface PaneData { + status: RepeaterStatusResponse | null; + neighbors: RepeaterNeighborsResponse | null; + acl: RepeaterAclResponse | null; + radioSettings: RepeaterRadioSettingsResponse | null; + advertIntervals: RepeaterAdvertIntervalsResponse | null; + ownerInfo: RepeaterOwnerInfoResponse | null; + lppTelemetry: RepeaterLppTelemetryResponse | null; +} + +const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null }; + +function createInitialPaneStates(): Record { + return { + status: { ...INITIAL_PANE_STATE }, + neighbors: { ...INITIAL_PANE_STATE }, + acl: { ...INITIAL_PANE_STATE }, + radioSettings: { ...INITIAL_PANE_STATE }, + advertIntervals: { ...INITIAL_PANE_STATE }, + ownerInfo: { ...INITIAL_PANE_STATE }, + lppTelemetry: { ...INITIAL_PANE_STATE }, + }; +} + +function createInitialPaneData(): PaneData { + return { + status: null, + neighbors: null, + acl: null, + radioSettings: null, + advertIntervals: null, + ownerInfo: null, + lppTelemetry: null, + }; +} + +// Maps pane name to the API call +function fetchPaneData(publicKey: string, pane: PaneName) { + switch (pane) { + case 'status': + return api.repeaterStatus(publicKey); + case 'neighbors': + return api.repeaterNeighbors(publicKey); + case 'acl': + return api.repeaterAcl(publicKey); + case 'radioSettings': + return api.repeaterRadioSettings(publicKey); + case 'advertIntervals': + return api.repeaterAdvertIntervals(publicKey); + case 'ownerInfo': + return api.repeaterOwnerInfo(publicKey); + case 'lppTelemetry': + return api.repeaterLppTelemetry(publicKey); + } +} + +export interface UseRepeaterDashboardResult { + loggedIn: boolean; + loginLoading: boolean; + loginError: string | null; + paneData: PaneData; + paneStates: Record; + consoleHistory: ConsoleEntry[]; + consoleLoading: boolean; + login: (password: string) => Promise; + loginAsGuest: () => Promise; + refreshPane: (pane: PaneName) => Promise; + loadAll: () => Promise; + sendConsoleCommand: (command: string) => Promise; + sendAdvert: () => Promise; + rebootRepeater: () => Promise; + syncClock: () => Promise; +} + +export function useRepeaterDashboard( + activeConversation: Conversation | null +): UseRepeaterDashboardResult { + const [loggedIn, setLoggedIn] = useState(false); + const [loginLoading, setLoginLoading] = useState(false); + const [loginError, setLoginError] = useState(null); + + const [paneData, setPaneData] = useState(createInitialPaneData); + const [paneStates, setPaneStates] = + useState>(createInitialPaneStates); + + const [consoleHistory, setConsoleHistory] = useState([]); + const [consoleLoading, setConsoleLoading] = useState(false); + + // Track which conversation we're operating on to avoid stale updates after + // unmount. Initialised from activeConversation because the parent renders + // , so this hook only ever sees one conversation. + const activeIdRef = useRef(activeConversation?.id ?? null); + + // Guard against setting state after unmount (retry timers firing late) + const mountedRef = useRef(true); + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const getPublicKey = useCallback((): string | null => { + if (!activeConversation || activeConversation.type !== 'contact') return null; + return activeConversation.id; + }, [activeConversation]); + + const login = useCallback( + async (password: string) => { + const publicKey = getPublicKey(); + if (!publicKey) return; + const conversationId = publicKey; + + setLoginLoading(true); + setLoginError(null); + try { + await api.repeaterLogin(publicKey, password); + if (activeIdRef.current !== conversationId) return; + setLoggedIn(true); + } catch (err) { + if (activeIdRef.current !== conversationId) return; + const msg = err instanceof Error ? err.message : 'Login failed'; + setLoginError(msg); + } finally { + if (activeIdRef.current === conversationId) { + setLoginLoading(false); + } + } + }, + [getPublicKey] + ); + + const loginAsGuest = useCallback(async () => { + await login(''); + }, [login]); + + const refreshPane = useCallback( + async (pane: PaneName) => { + const publicKey = getPublicKey(); + if (!publicKey) return; + const conversationId = publicKey; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + if (!mountedRef.current || activeIdRef.current !== conversationId) return; + + setPaneStates((prev) => ({ + ...prev, + [pane]: { loading: true, attempt, error: null }, + })); + + try { + const data = await fetchPaneData(publicKey, pane); + if (!mountedRef.current || activeIdRef.current !== conversationId) return; + + setPaneData((prev) => ({ ...prev, [pane]: data })); + setPaneStates((prev) => ({ + ...prev, + [pane]: { loading: false, attempt, error: null }, + })); + return; // Success + } catch (err) { + if (!mountedRef.current || activeIdRef.current !== conversationId) return; + + const msg = err instanceof Error ? err.message : 'Request failed'; + + if (attempt === MAX_RETRIES) { + setPaneStates((prev) => ({ + ...prev, + [pane]: { loading: false, attempt, error: msg }, + })); + toast.error(`Failed to fetch ${pane}`, { description: msg }); + } else { + // Wait before retrying + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); + } + } + } + }, + [getPublicKey] + ); + + const loadAll = useCallback(async () => { + const panes: PaneName[] = [ + 'status', + 'neighbors', + 'acl', + 'radioSettings', + 'advertIntervals', + 'ownerInfo', + 'lppTelemetry', + ]; + // Serial execution — parallel calls just queue behind the radio lock anyway + for (const pane of panes) { + await refreshPane(pane); + } + }, [refreshPane]); + + const sendConsoleCommand = useCallback( + async (command: string) => { + const publicKey = getPublicKey(); + if (!publicKey) return; + const conversationId = publicKey; + + const now = Math.floor(Date.now() / 1000); + + // Add outgoing command entry + setConsoleHistory((prev) => [ + ...prev, + { command, response: '', timestamp: now, outgoing: true }, + ]); + + setConsoleLoading(true); + try { + const result: CommandResponse = await api.sendRepeaterCommand(publicKey, command); + if (activeIdRef.current !== conversationId) return; + + setConsoleHistory((prev) => [ + ...prev, + { + command, + response: result.response, + timestamp: result.sender_timestamp ?? now, + outgoing: false, + }, + ]); + } catch (err) { + if (activeIdRef.current !== conversationId) return; + const msg = err instanceof Error ? err.message : 'Command failed'; + setConsoleHistory((prev) => [ + ...prev, + { command, response: `Error: ${msg}`, timestamp: now, outgoing: false }, + ]); + } finally { + if (activeIdRef.current === conversationId) { + setConsoleLoading(false); + } + } + }, + [getPublicKey] + ); + + const sendAdvert = useCallback(async () => { + await sendConsoleCommand('advert'); + }, [sendConsoleCommand]); + + const rebootRepeater = useCallback(async () => { + await sendConsoleCommand('reboot'); + }, [sendConsoleCommand]); + + const syncClock = useCallback(async () => { + const epoch = Math.floor(Date.now() / 1000); + await sendConsoleCommand(`clock ${epoch}`); + }, [sendConsoleCommand]); + + return { + loggedIn, + loginLoading, + loginError, + paneData, + paneStates, + consoleHistory, + consoleLoading, + login, + loginAsGuest, + refreshPane, + loadAll, + sendConsoleCommand, + sendAdvert, + rebootRepeater, + syncClock, + }; +} diff --git a/frontend/src/hooks/useRepeaterMode.ts b/frontend/src/hooks/useRepeaterMode.ts deleted file mode 100644 index 0e8c3ca..0000000 --- a/frontend/src/hooks/useRepeaterMode.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { useState, useCallback, useMemo, useEffect, type RefObject } from 'react'; -import { api } from '../api'; -import type { - Contact, - Conversation, - Message, - TelemetryResponse, - NeighborInfo, - AclEntry, -} from '../types'; -import { CONTACT_TYPE_REPEATER } from '../types'; -import { useAirtimeTracking } from './useAirtimeTracking'; - -// Format seconds into human-readable duration (e.g., 1d17h2m, 1h5m, 3m) -function formatDuration(seconds: number): string { - if (seconds < 60) return `${seconds}s`; - - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - const mins = Math.floor((seconds % 3600) / 60); - - if (days > 0) { - if (hours > 0 && mins > 0) return `${days}d${hours}h${mins}m`; - if (hours > 0) return `${days}d${hours}h`; - if (mins > 0) return `${days}d${mins}m`; - return `${days}d`; - } - if (hours > 0) { - return mins > 0 ? `${hours}h${mins}m` : `${hours}h`; - } - return `${mins}m`; -} - -// Format telemetry response as human-readable text -function formatTelemetry(telemetry: TelemetryResponse): string { - const lines = [ - `Telemetry`, - `Battery Voltage: ${telemetry.battery_volts.toFixed(3)}V`, - `Uptime: ${formatDuration(telemetry.uptime_seconds)}`, - ...(telemetry.clock_output ? [`Clock: ${telemetry.clock_output}`] : []), - `TX Airtime: ${formatDuration(telemetry.airtime_seconds)}`, - `RX Airtime: ${formatDuration(telemetry.rx_airtime_seconds)}`, - '', - `Noise Floor: ${telemetry.noise_floor_dbm} dBm`, - `Last RSSI: ${telemetry.last_rssi_dbm} dBm`, - `Last SNR: ${telemetry.last_snr_db.toFixed(1)} dB`, - '', - `Packets: ${telemetry.packets_received.toLocaleString()} rx / ${telemetry.packets_sent.toLocaleString()} tx`, - `Flood: ${telemetry.recv_flood.toLocaleString()} rx / ${telemetry.sent_flood.toLocaleString()} tx`, - `Direct: ${telemetry.recv_direct.toLocaleString()} rx / ${telemetry.sent_direct.toLocaleString()} tx`, - `Duplicates: ${telemetry.flood_dups.toLocaleString()} flood / ${telemetry.direct_dups.toLocaleString()} direct`, - '', - `TX Queue: ${telemetry.tx_queue_len}`, - `Debug Flags: ${telemetry.full_events}`, - ]; - return lines.join('\n'); -} - -// Format neighbors list as human-readable text -function formatNeighbors(neighbors: NeighborInfo[]): string { - if (neighbors.length === 0) { - return 'Neighbors\nNo neighbors reported'; - } - // Sort by SNR descending (highest first) - const sorted = [...neighbors].sort((a, b) => b.snr - a.snr); - const lines = [`Neighbors (${sorted.length})`]; - for (const n of sorted) { - const name = n.name || n.pubkey_prefix; - const snr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1); - lines.push(`${name}, ${snr} dB [${formatDuration(n.last_heard_seconds)} ago]`); - } - return lines.join('\n'); -} - -// Format ACL list as human-readable text -function formatAcl(acl: AclEntry[]): string { - if (acl.length === 0) { - return 'ACL\nNo ACL entries'; - } - const lines = [`ACL (${acl.length})`]; - for (const entry of acl) { - const name = entry.name || entry.pubkey_prefix; - lines.push(`${name}: ${entry.permission_name}`); - } - return lines.join('\n'); -} - -// Create a local message object (not persisted to database) -function createLocalMessage( - conversationKey: string, - text: string, - outgoing: boolean, - idOffset = 0 -): Message { - const now = Math.floor(Date.now() / 1000); - return { - id: -Date.now() - idOffset, - type: 'PRIV', - conversation_key: conversationKey, - text, - sender_timestamp: now, - received_at: now, - paths: null, - txt_type: 0, - signature: null, - outgoing, - acked: 1, - }; -} - -interface UseRepeaterModeResult { - repeaterLoggedIn: boolean; - activeContactIsRepeater: boolean; - handleTelemetryRequest: (password: string) => Promise; - handleRepeaterCommand: (command: string) => Promise; -} - -export function useRepeaterMode( - activeConversation: Conversation | null, - contacts: Contact[], - setMessages: React.Dispatch>, - activeConversationRef: RefObject -): UseRepeaterModeResult { - const [repeaterLoggedIn, setRepeaterLoggedIn] = useState(false); - const { handleAirtimeCommand, stopTracking } = useAirtimeTracking(setMessages); - - // Reset login state and stop airtime tracking when conversation changes - useEffect(() => { - setRepeaterLoggedIn(false); - stopTracking(); - }, [activeConversation?.id, stopTracking]); - - // Check if active conversation is a repeater - const activeContactIsRepeater = useMemo(() => { - if (!activeConversation || activeConversation.type !== 'contact') return false; - const contact = contacts.find((c) => c.public_key === activeConversation.id); - return contact?.type === CONTACT_TYPE_REPEATER; - }, [activeConversation, contacts]); - - // Request telemetry from a repeater - const handleTelemetryRequest = useCallback( - async (password: string) => { - if (!activeConversation || activeConversation.type !== 'contact') return; - if (!activeContactIsRepeater) return; - - const conversationId = activeConversation.id; - - try { - const telemetry = await api.requestTelemetry(conversationId, password); - - // User may have switched conversations during the await - if (activeConversationRef.current?.id !== conversationId) return; - - // Create local messages to display the telemetry (not persisted to database) - const telemetryMessage = createLocalMessage( - conversationId, - formatTelemetry(telemetry), - false, - 0 - ); - - const neighborsMessage = createLocalMessage( - conversationId, - formatNeighbors(telemetry.neighbors), - false, - 1 - ); - - const aclMessage = createLocalMessage(conversationId, formatAcl(telemetry.acl), false, 2); - - // Add all messages to the list - setMessages((prev) => [...prev, telemetryMessage, neighborsMessage, aclMessage]); - - // Mark as logged in for CLI command mode - setRepeaterLoggedIn(true); - } catch (err) { - if (activeConversationRef.current?.id !== conversationId) return; - const errorMessage = createLocalMessage( - conversationId, - `Telemetry request failed: ${err instanceof Error ? err.message : 'Unknown error'}`, - false, - 0 - ); - setMessages((prev) => [...prev, errorMessage]); - } - }, - [activeConversation, activeContactIsRepeater, setMessages, activeConversationRef] - ); - - // Send CLI command to a repeater (after logged in) - const handleRepeaterCommand = useCallback( - async (command: string) => { - if (!activeConversation || activeConversation.type !== 'contact') return; - if (!activeContactIsRepeater || !repeaterLoggedIn) return; - - const conversationId = activeConversation.id; - - // Check for special airtime commands first (handled locally) - const handled = await handleAirtimeCommand(command, conversationId); - if (handled) return; - - // Show the command as an outgoing message - const commandMessage = createLocalMessage(conversationId, `> ${command}`, true, 0); - setMessages((prev) => [...prev, commandMessage]); - - try { - const response = await api.sendRepeaterCommand(conversationId, command); - - // User may have switched conversations during the await - if (activeConversationRef.current?.id !== conversationId) return; - - // Use the actual timestamp from the repeater if available - const responseMessage = createLocalMessage(conversationId, response.response, false, 1); - if (response.sender_timestamp) { - responseMessage.sender_timestamp = response.sender_timestamp; - } - - setMessages((prev) => [...prev, responseMessage]); - } catch (err) { - if (activeConversationRef.current?.id !== conversationId) return; - const errorMessage = createLocalMessage( - conversationId, - `Command failed: ${err instanceof Error ? err.message : 'Unknown error'}`, - false, - 1 - ); - setMessages((prev) => [...prev, errorMessage]); - } - }, - [ - activeConversation, - activeContactIsRepeater, - repeaterLoggedIn, - setMessages, - handleAirtimeCommand, - activeConversationRef, - ] - ); - - return { - repeaterLoggedIn, - activeContactIsRepeater, - handleTelemetryRequest, - handleRepeaterCommand, - }; -} diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index 6f4c071..bcf9767 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -39,8 +39,6 @@ const mocks = vi.hoisted(() => ({ incrementUnread: vi.fn(), markAllRead: vi.fn(), trackNewMessage: vi.fn(), - handleTelemetryRequest: vi.fn(), - handleRepeaterCommand: vi.fn(), }, })); @@ -75,12 +73,6 @@ vi.mock('../hooks', async (importOriginal) => { markAllRead: mocks.hookFns.markAllRead, trackNewMessage: mocks.hookFns.trackNewMessage, }), - useRepeaterMode: () => ({ - repeaterLoggedIn: false, - activeContactIsRepeater: false, - handleTelemetryRequest: mocks.hookFns.handleTelemetryRequest, - handleRepeaterCommand: mocks.hookFns.handleRepeaterCommand, - }), getMessageContentKey: () => 'content-key', }; }); diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index adf9ebb..857062d 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -44,12 +44,6 @@ vi.mock('../hooks', async (importOriginal) => { markAllRead: vi.fn(), trackNewMessage: vi.fn(), }), - useRepeaterMode: () => ({ - repeaterLoggedIn: false, - activeContactIsRepeater: false, - handleTelemetryRequest: vi.fn(), - handleRepeaterCommand: vi.fn(), - }), getMessageContentKey: () => 'content-key', }; }); diff --git a/frontend/src/test/localLabel.test.ts b/frontend/src/test/localLabel.test.ts new file mode 100644 index 0000000..28a4691 --- /dev/null +++ b/frontend/src/test/localLabel.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { getLocalLabel, setLocalLabel, getContrastTextColor } from '../utils/localLabel'; + +describe('localLabel utilities', () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe('getLocalLabel', () => { + it('returns default when nothing stored', () => { + const label = getLocalLabel(); + expect(label.text).toBe(''); + expect(label.color).toBe('#062d60'); + }); + + it('returns stored label', () => { + localStorage.setItem( + 'remoteterm-local-label', + JSON.stringify({ text: 'Dev', color: '#ff0000' }) + ); + const label = getLocalLabel(); + expect(label.text).toBe('Dev'); + expect(label.color).toBe('#ff0000'); + }); + + it('handles corrupted JSON gracefully', () => { + localStorage.setItem('remoteterm-local-label', '{bad json'); + const label = getLocalLabel(); + expect(label.text).toBe(''); + expect(label.color).toBe('#062d60'); + }); + + it('handles partial stored data', () => { + localStorage.setItem('remoteterm-local-label', JSON.stringify({ text: 'Hi' })); + const label = getLocalLabel(); + expect(label.text).toBe('Hi'); + expect(label.color).toBe('#062d60'); // falls back to default color + }); + + it('handles non-string values in stored data', () => { + localStorage.setItem('remoteterm-local-label', JSON.stringify({ text: 123, color: true })); + const label = getLocalLabel(); + expect(label.text).toBe(''); // non-string falls back + expect(label.color).toBe('#062d60'); + }); + }); + + describe('setLocalLabel', () => { + it('stores label to localStorage', () => { + setLocalLabel('Test', '#00ff00'); + const raw = localStorage.getItem('remoteterm-local-label'); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + expect(parsed.text).toBe('Test'); + expect(parsed.color).toBe('#00ff00'); + }); + }); + + describe('getContrastTextColor', () => { + it('returns white for dark colors', () => { + expect(getContrastTextColor('#000000')).toBe('white'); + expect(getContrastTextColor('#062d60')).toBe('white'); + expect(getContrastTextColor('#333333')).toBe('white'); + }); + + it('returns black for light colors', () => { + expect(getContrastTextColor('#ffffff')).toBe('black'); + expect(getContrastTextColor('#ffff00')).toBe('black'); + expect(getContrastTextColor('#00ff00')).toBe('black'); + }); + + it('handles hex with # prefix', () => { + expect(getContrastTextColor('#000000')).toBe('white'); + }); + + it('handles hex without # prefix', () => { + expect(getContrastTextColor('000000')).toBe('white'); + expect(getContrastTextColor('ffffff')).toBe('black'); + }); + }); +}); diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx new file mode 100644 index 0000000..caccf87 --- /dev/null +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -0,0 +1,270 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { RepeaterDashboard } from '../components/RepeaterDashboard'; +import type { UseRepeaterDashboardResult } from '../hooks/useRepeaterDashboard'; +import type { Contact, Conversation, Favorite } from '../types'; + +// Mock the hook — typed as mutable version of the return type +const mockHook: { + -readonly [K in keyof UseRepeaterDashboardResult]: UseRepeaterDashboardResult[K]; +} = { + loggedIn: false, + loginLoading: false, + loginError: null, + paneData: { + status: null, + neighbors: null, + acl: null, + radioSettings: null, + advertIntervals: null, + ownerInfo: null, + + lppTelemetry: null, + }, + paneStates: { + status: { loading: false, attempt: 0, error: null }, + neighbors: { loading: false, attempt: 0, error: null }, + acl: { loading: false, attempt: 0, error: null }, + radioSettings: { loading: false, attempt: 0, error: null }, + advertIntervals: { loading: false, attempt: 0, error: null }, + ownerInfo: { loading: false, attempt: 0, error: null }, + + lppTelemetry: { loading: false, attempt: 0, error: null }, + }, + consoleHistory: [], + consoleLoading: false, + login: vi.fn(), + loginAsGuest: vi.fn(), + refreshPane: vi.fn(), + loadAll: vi.fn(), + sendConsoleCommand: vi.fn(), + sendAdvert: vi.fn(), + rebootRepeater: vi.fn(), + syncClock: vi.fn(), +}; + +vi.mock('../hooks/useRepeaterDashboard', () => ({ + useRepeaterDashboard: () => mockHook, +})); + +// Mock sonner toast +vi.mock('../components/ui/sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + }, +})); + +// Mock leaflet imports (not needed in test) +vi.mock('react-leaflet', () => ({ + MapContainer: () => null, + TileLayer: () => null, + CircleMarker: () => null, + Popup: () => null, +})); + +const REPEATER_KEY = 'aa'.repeat(32); + +const conversation: Conversation = { + type: 'contact', + id: REPEATER_KEY, + name: 'TestRepeater', +}; + +const contacts: Contact[] = [ + { + public_key: REPEATER_KEY, + name: 'TestRepeater', + type: 2, + flags: 0, + last_path: null, + last_path_len: -1, + last_advert: null, + lat: null, + lon: null, + last_seen: null, + on_radio: false, + last_contacted: null, + last_read_at: null, + }, +]; + +const favorites: Favorite[] = []; + +const defaultProps = { + conversation, + contacts, + favorites, + radioLat: null, + radioLon: null, + radioName: null, + onTrace: vi.fn(), + onToggleFavorite: vi.fn(), + onDeleteContact: vi.fn(), +}; + +describe('RepeaterDashboard', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset mock hook state + mockHook.loggedIn = false; + mockHook.loginLoading = false; + mockHook.loginError = null; + mockHook.paneData = { + status: null, + neighbors: null, + acl: null, + radioSettings: null, + advertIntervals: null, + ownerInfo: null, + + lppTelemetry: null, + }; + mockHook.paneStates = { + status: { loading: false, attempt: 0, error: null }, + neighbors: { loading: false, attempt: 0, error: null }, + acl: { loading: false, attempt: 0, error: null }, + radioSettings: { loading: false, attempt: 0, error: null }, + advertIntervals: { loading: false, attempt: 0, error: null }, + ownerInfo: { loading: false, attempt: 0, error: null }, + + lppTelemetry: { loading: false, attempt: 0, error: null }, + }; + mockHook.consoleHistory = []; + mockHook.consoleLoading = false; + }); + + it('renders login form when not logged in', () => { + render(); + + expect(screen.getByText('Login with Password')).toBeInTheDocument(); + expect(screen.getByText('Login as Guest / ACLs')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Repeater password...')).toBeInTheDocument(); + expect(screen.getByText('Log in to access repeater dashboard')).toBeInTheDocument(); + }); + + it('renders dashboard panes when logged in', () => { + mockHook.loggedIn = true; + + render(); + + expect(screen.getByText('Telemetry')).toBeInTheDocument(); + expect(screen.getByText('Neighbors')).toBeInTheDocument(); + expect(screen.getByText('ACL')).toBeInTheDocument(); + expect(screen.getByText('Radio Settings')).toBeInTheDocument(); + expect(screen.getByText('Advert Intervals')).toBeInTheDocument(); // sub-section inside Radio Settings + expect(screen.getByText('LPP Sensors')).toBeInTheDocument(); + expect(screen.getByText('Owner Info')).toBeInTheDocument(); + expect(screen.getByText('Actions')).toBeInTheDocument(); + expect(screen.getByText('Console')).toBeInTheDocument(); + }); + + it('shows not fetched placeholder for empty panes', () => { + mockHook.loggedIn = true; + + render(); + + // All panes should show since data is null + const notFetched = screen.getAllByText(''); + expect(notFetched.length).toBeGreaterThanOrEqual(7); // At least 7 data panes (incl. LPP Sensors) + }); + + it('shows Load All button when logged in', () => { + mockHook.loggedIn = true; + + render(); + + expect(screen.getByText('Load All')).toBeInTheDocument(); + }); + + it('calls loadAll when Load All button is clicked', () => { + mockHook.loggedIn = true; + + render(); + + fireEvent.click(screen.getByText('Load All')); + expect(mockHook.loadAll).toHaveBeenCalledTimes(1); + }); + + it('shows login error when present', () => { + mockHook.loginError = 'Invalid password'; + + render(); + + expect(screen.getByText('Invalid password')).toBeInTheDocument(); + }); + + it('shows pane error when fetch fails', () => { + mockHook.loggedIn = true; + mockHook.paneStates.status = { loading: false, attempt: 3, error: 'Timeout' }; + + render(); + + expect(screen.getByText('Timeout')).toBeInTheDocument(); + }); + + it('shows fetching state with attempt counter', () => { + mockHook.loggedIn = true; + mockHook.paneStates.status = { loading: true, attempt: 2, error: null }; + + render(); + + expect(screen.getByText('Fetching (attempt 2/3)...')).toBeInTheDocument(); + }); + + it('renders telemetry data when available', () => { + mockHook.loggedIn = true; + mockHook.paneData.status = { + battery_volts: 4.2, + tx_queue_len: 0, + noise_floor_dbm: -120, + last_rssi_dbm: -85, + last_snr_db: 7.5, + packets_received: 100, + packets_sent: 50, + airtime_seconds: 600, + rx_airtime_seconds: 1200, + uptime_seconds: 86400, + sent_flood: 10, + sent_direct: 40, + recv_flood: 30, + recv_direct: 70, + flood_dups: 1, + direct_dups: 0, + full_events: 0, + }; + + render(); + + expect(screen.getByText('4.200V')).toBeInTheDocument(); + expect(screen.getByText('-120 dBm')).toBeInTheDocument(); + expect(screen.getByText('7.5 dB')).toBeInTheDocument(); + }); + + it('renders action buttons', () => { + mockHook.loggedIn = true; + + render(); + + expect(screen.getByText('Send Advert')).toBeInTheDocument(); + expect(screen.getByText('Sync Clock')).toBeInTheDocument(); + expect(screen.getByText('Reboot')).toBeInTheDocument(); + }); + + it('calls onTrace when trace button clicked', () => { + render(); + + // The trace button has title "Direct Trace" + fireEvent.click(screen.getByTitle('Direct Trace')); + expect(defaultProps.onTrace).toHaveBeenCalledTimes(1); + }); + + it('console shows placeholder when empty', () => { + mockHook.loggedIn = true; + + render(); + + expect(screen.getByText('Type a CLI command below...')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/repeaterFormatters.test.ts b/frontend/src/test/repeaterFormatters.test.ts new file mode 100644 index 0000000..b12e82f --- /dev/null +++ b/frontend/src/test/repeaterFormatters.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { formatDuration, formatClockDrift } from '../components/RepeaterDashboard'; + +describe('formatDuration', () => { + it('formats seconds only', () => { + expect(formatDuration(0)).toBe('0s'); + expect(formatDuration(30)).toBe('30s'); + expect(formatDuration(59)).toBe('59s'); + }); + + it('formats minutes only', () => { + expect(formatDuration(60)).toBe('1m'); + expect(formatDuration(300)).toBe('5m'); + expect(formatDuration(3540)).toBe('59m'); + }); + + it('formats hours and minutes', () => { + expect(formatDuration(3600)).toBe('1h'); + expect(formatDuration(3660)).toBe('1h1m'); + expect(formatDuration(7200)).toBe('2h'); + expect(formatDuration(7260)).toBe('2h1m'); + }); + + it('formats days', () => { + expect(formatDuration(86400)).toBe('1d'); + expect(formatDuration(86400 + 3600)).toBe('1d1h'); + expect(formatDuration(86400 + 60)).toBe('1d1m'); + expect(formatDuration(86400 + 3600 + 60)).toBe('1d1h1m'); + expect(formatDuration(172800)).toBe('2d'); + }); + + it('formats multi-day durations', () => { + expect(formatDuration(3 * 86400 + 12 * 3600 + 30 * 60)).toBe('3d12h30m'); + }); +}); + +describe('formatClockDrift', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('parses firmware format HH:MM - D/M/YYYY UTC', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-09T12:30:00Z')); + + const result = formatClockDrift('12:30 - 9/1/2025 UTC'); + expect(result.isLarge).toBe(false); + expect(result.text).toBe('0s'); + }); + + it('parses firmware format with seconds HH:MM:SS - D/M/YYYY', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-06-15T08:00:00Z')); + + const result = formatClockDrift('08:00:00 - 15/6/2025 UTC'); + expect(result.isLarge).toBe(false); + expect(result.text).toBe('0s'); + }); + + it('reports large drift (>24h)', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-11T12:30:00Z')); + + const result = formatClockDrift('12:30 - 9/1/2025 UTC'); + expect(result.isLarge).toBe(true); + expect(result.text).toBe('>24 hours!'); + }); + + it('handles invalid date strings', () => { + const result = formatClockDrift('not a date'); + expect(result.text).toBe('(invalid)'); + expect(result.isLarge).toBe(false); + }); + + it('formats multi-unit drift', () => { + vi.useFakeTimers(); + // 1h30m5s drift + vi.setSystemTime(new Date('2025-01-09T14:00:05Z')); + + const result = formatClockDrift('12:30 - 9/1/2025 UTC'); + expect(result.isLarge).toBe(false); + expect(result.text).toBe('1h30m5s'); + }); + + it('formats minutes and seconds drift', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-09T12:35:10Z')); + + const result = formatClockDrift('12:30 - 9/1/2025 UTC'); + expect(result.isLarge).toBe(false); + expect(result.text).toBe('5m10s'); + }); +}); diff --git a/frontend/src/test/repeaterLogin.test.tsx b/frontend/src/test/repeaterLogin.test.tsx new file mode 100644 index 0000000..7401ef0 --- /dev/null +++ b/frontend/src/test/repeaterLogin.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { RepeaterLogin } from '../components/RepeaterLogin'; + +describe('RepeaterLogin', () => { + const defaultProps = { + repeaterName: 'TestRepeater', + loading: false, + error: null as string | null, + onLogin: vi.fn(), + onLoginAsGuest: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders repeater name and description', () => { + render(); + + expect(screen.getByText('TestRepeater')).toBeInTheDocument(); + expect(screen.getByText('Log in to access repeater dashboard')).toBeInTheDocument(); + }); + + it('renders password input and buttons', () => { + render(); + + expect(screen.getByPlaceholderText('Repeater 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(); + + const input = screen.getByPlaceholderText('Repeater password...'); + fireEvent.change(input, { target: { value: ' secret ' } }); + fireEvent.submit(screen.getByText('Login with Password').closest('form')!); + + expect(defaultProps.onLogin).toHaveBeenCalledWith('secret'); + }); + + it('calls onLoginAsGuest when guest button clicked', () => { + render(); + + fireEvent.click(screen.getByText('Login as Guest / ACLs')); + expect(defaultProps.onLoginAsGuest).toHaveBeenCalledTimes(1); + }); + + it('disables inputs when loading', () => { + render(); + + expect(screen.getByPlaceholderText('Repeater password...')).toBeDisabled(); + expect(screen.getByText('Logging in...')).toBeDisabled(); + expect(screen.getByText('Login as Guest / ACLs')).toBeDisabled(); + }); + + it('shows loading text on submit button', () => { + render(); + + expect(screen.getByText('Logging in...')).toBeInTheDocument(); + expect(screen.queryByText('Login with Password')).not.toBeInTheDocument(); + }); + + it('displays error message when present', () => { + render(); + + expect(screen.getByText('Invalid password')).toBeInTheDocument(); + }); + + it('does not call onLogin when loading', () => { + render(); + + fireEvent.submit(screen.getByText('Logging in...').closest('form')!); + expect(defaultProps.onLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/test/repeaterMode.test.ts b/frontend/src/test/repeaterMessageParsing.test.ts similarity index 100% rename from frontend/src/test/repeaterMode.test.ts rename to frontend/src/test/repeaterMessageParsing.test.ts diff --git a/frontend/src/test/useAirtimeTracking.test.ts b/frontend/src/test/useAirtimeTracking.test.ts deleted file mode 100644 index f518f02..0000000 --- a/frontend/src/test/useAirtimeTracking.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { useAirtimeTracking } from '../hooks/useAirtimeTracking'; -import type { Message, TelemetryResponse } from '../types'; - -function createTelemetry(overrides: Partial = {}): TelemetryResponse { - return { - pubkey_prefix: 'AABB', - battery_volts: 3.7, - tx_queue_len: 0, - noise_floor_dbm: -120, - last_rssi_dbm: -80, - last_snr_db: 10, - packets_received: 100, - packets_sent: 50, - airtime_seconds: 10, - rx_airtime_seconds: 5, - uptime_seconds: 3600, - sent_flood: 30, - sent_direct: 20, - recv_flood: 60, - recv_direct: 40, - flood_dups: 5, - direct_dups: 2, - full_events: 0, - clock_output: null, - neighbors: [], - acl: [], - ...overrides, - }; -} - -function createDeferred() { - let resolve: (value: T | PromiseLike) => void = () => {}; - const promise = new Promise((res) => { - resolve = res; - }); - return { promise, resolve }; -} - -const mockRequestTelemetry = vi.fn<(...args: unknown[]) => Promise>(); - -vi.mock('../api', () => ({ - api: { - requestTelemetry: (...args: unknown[]) => mockRequestTelemetry(...args), - }, -})); - -describe('useAirtimeTracking stale poll guard', () => { - beforeEach(() => { - mockRequestTelemetry.mockReset(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('discards poll response when tracking was stopped during in-flight request', async () => { - const setMessages = vi.fn>>(); - - // Initial telemetry for dutycycle_start succeeds immediately - mockRequestTelemetry.mockResolvedValueOnce(createTelemetry()); - - const { result } = renderHook(() => useAirtimeTracking(setMessages)); - - // Start tracking - await act(async () => { - await result.current.handleAirtimeCommand('dutycycle_start', 'repeater_a'); - }); - - // setMessages was called with the start message - const startCallCount = setMessages.mock.calls.length; - expect(startCallCount).toBeGreaterThanOrEqual(1); - - // Set up a deferred telemetry response for the poll - const deferred = createDeferred(); - mockRequestTelemetry.mockReturnValueOnce(deferred.promise); - - // Advance timer to trigger the 5-minute poll - act(() => { - vi.advanceTimersByTime(5 * 60 * 1000); - }); - - // Poll is now in-flight. Stop tracking (simulates conversation switch). - act(() => { - result.current.stopTracking(); - }); - - // Resolve the stale telemetry response - await act(async () => { - deferred.resolve(createTelemetry({ uptime_seconds: 7200 })); - }); - - // setMessages should NOT have been called with the stale poll result - // Only the start message calls should exist - expect(setMessages.mock.calls.length).toBe(startCallCount); - }); - - it('appends poll result when tracking is still active', async () => { - const setMessages = vi.fn>>(); - - // Initial telemetry for dutycycle_start - mockRequestTelemetry.mockResolvedValueOnce(createTelemetry()); - - const { result } = renderHook(() => useAirtimeTracking(setMessages)); - - await act(async () => { - await result.current.handleAirtimeCommand('dutycycle_start', 'repeater_a'); - }); - - const startCallCount = setMessages.mock.calls.length; - - // Set up poll response - mockRequestTelemetry.mockResolvedValueOnce(createTelemetry({ uptime_seconds: 7200 })); - - // Advance timer to trigger the 5-minute poll - await act(async () => { - vi.advanceTimersByTime(5 * 60 * 1000); - }); - - // setMessages SHOULD have been called with the poll result - expect(setMessages.mock.calls.length).toBeGreaterThan(startCallCount); - }); -}); diff --git a/frontend/src/test/useRepeaterDashboard.test.ts b/frontend/src/test/useRepeaterDashboard.test.ts new file mode 100644 index 0000000..7ec4836 --- /dev/null +++ b/frontend/src/test/useRepeaterDashboard.test.ts @@ -0,0 +1,289 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard'; +import type { Conversation } from '../types'; + +// Mock the api module +vi.mock('../api', () => ({ + api: { + repeaterLogin: vi.fn(), + repeaterStatus: vi.fn(), + repeaterNeighbors: vi.fn(), + repeaterAcl: vi.fn(), + repeaterRadioSettings: vi.fn(), + repeaterAdvertIntervals: vi.fn(), + repeaterOwnerInfo: vi.fn(), + repeaterLppTelemetry: vi.fn(), + sendRepeaterCommand: vi.fn(), + }, +})); + +// Mock sonner toast +vi.mock('../components/ui/sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + }, +})); + +// Get mock reference — cast to Record for type-safe mock method access +const { api: _rawApi } = await import('../api'); +const mockApi = _rawApi as unknown as Record; + +const REPEATER_KEY = 'aa'.repeat(32); + +const repeaterConversation: Conversation = { + type: 'contact', + id: REPEATER_KEY, + name: 'TestRepeater', +}; + +describe('useRepeaterDashboard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('starts with logged out state', () => { + const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation)); + expect(result.current.loggedIn).toBe(false); + expect(result.current.loginLoading).toBe(false); + expect(result.current.loginError).toBe(null); + }); + + it('login sets loggedIn on success', async () => { + mockApi.repeaterLogin.mockResolvedValueOnce({ status: 'ok' }); + + const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation)); + + await act(async () => { + await result.current.login('secret'); + }); + + expect(result.current.loggedIn).toBe(true); + expect(result.current.loginError).toBe(null); + expect(mockApi.repeaterLogin).toHaveBeenCalledWith(REPEATER_KEY, 'secret'); + }); + + it('login sets error on failure', async () => { + mockApi.repeaterLogin.mockRejectedValueOnce(new Error('Auth failed')); + + const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation)); + + await act(async () => { + await result.current.login('bad'); + }); + + expect(result.current.loggedIn).toBe(false); + expect(result.current.loginError).toBe('Auth failed'); + }); + + it('loginAsGuest calls login with empty password', async () => { + mockApi.repeaterLogin.mockResolvedValueOnce({ status: 'ok' }); + + const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation)); + + await act(async () => { + await result.current.loginAsGuest(); + }); + + expect(mockApi.repeaterLogin).toHaveBeenCalledWith(REPEATER_KEY, ''); + expect(result.current.loggedIn).toBe(true); + }); + + it('refreshPane stores data on success', async () => { + const statusData = { + battery_volts: 4.2, + tx_queue_len: 0, + noise_floor_dbm: -120, + last_rssi_dbm: -85, + last_snr_db: 7.5, + packets_received: 100, + packets_sent: 50, + airtime_seconds: 600, + rx_airtime_seconds: 1200, + uptime_seconds: 86400, + sent_flood: 10, + sent_direct: 40, + recv_flood: 30, + recv_direct: 70, + flood_dups: 1, + direct_dups: 0, + full_events: 0, + }; + mockApi.repeaterStatus.mockResolvedValueOnce(statusData); + + const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation)); + + await act(async () => { + await result.current.refreshPane('status'); + }); + + expect(result.current.paneData.status).toEqual(statusData); + expect(result.current.paneStates.status.loading).toBe(false); + expect(result.current.paneStates.status.error).toBe(null); + }); + + it('refreshPane retries up to 3 times', async () => { + mockApi.repeaterStatus.mockRejectedValueOnce(new Error('fail1')); + mockApi.repeaterStatus.mockRejectedValueOnce(new Error('fail2')); + mockApi.repeaterStatus.mockRejectedValueOnce(new Error('fail3')); + + const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation)); + + await act(async () => { + await result.current.refreshPane('status'); + }); + + expect(mockApi.repeaterStatus).toHaveBeenCalledTimes(3); + expect(result.current.paneStates.status.error).toBe('fail3'); + expect(result.current.paneData.status).toBe(null); + }); + + it('refreshPane succeeds on second attempt', async () => { + const statusData = { battery_volts: 3.7 }; + mockApi.repeaterStatus.mockRejectedValueOnce(new Error('fail1')); + mockApi.repeaterStatus.mockResolvedValueOnce(statusData); + + const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation)); + + await act(async () => { + await result.current.refreshPane('status'); + }); + + expect(mockApi.repeaterStatus).toHaveBeenCalledTimes(2); + expect(result.current.paneData.status).toEqual(statusData); + expect(result.current.paneStates.status.error).toBe(null); + }); + + it('sendConsoleCommand adds entries to console history', async () => { + mockApi.sendRepeaterCommand.mockResolvedValueOnce({ + command: 'ver', + response: 'v2.1.0', + sender_timestamp: 1000, + }); + + const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation)); + + await act(async () => { + await result.current.sendConsoleCommand('ver'); + }); + + expect(result.current.consoleHistory).toHaveLength(2); + expect(result.current.consoleHistory[0].outgoing).toBe(true); + expect(result.current.consoleHistory[0].command).toBe('ver'); + expect(result.current.consoleHistory[1].outgoing).toBe(false); + expect(result.current.consoleHistory[1].response).toBe('v2.1.0'); + }); + + it('sendConsoleCommand adds error entry on failure', async () => { + mockApi.sendRepeaterCommand.mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation)); + + await act(async () => { + await result.current.sendConsoleCommand('ver'); + }); + + expect(result.current.consoleHistory).toHaveLength(2); + expect(result.current.consoleHistory[0].outgoing).toBe(true); + expect(result.current.consoleHistory[0].command).toBe('ver'); + expect(result.current.consoleHistory[1].outgoing).toBe(false); + expect(result.current.consoleHistory[1].response).toBe('Error: Network error'); + expect(result.current.consoleLoading).toBe(false); + }); + + it('sendAdvert sends "advert" command', async () => { + mockApi.sendRepeaterCommand.mockResolvedValueOnce({ + command: 'advert', + response: 'ok', + sender_timestamp: 1000, + }); + + const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation)); + + await act(async () => { + await result.current.sendAdvert(); + }); + + expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'advert'); + }); + + it('rebootRepeater sends "reboot" command', async () => { + mockApi.sendRepeaterCommand.mockResolvedValueOnce({ + command: 'reboot', + response: 'ok', + sender_timestamp: 1000, + }); + + const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation)); + + await act(async () => { + await result.current.rebootRepeater(); + }); + + expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'reboot'); + }); + + it('syncClock sends "clock " command', async () => { + const fakeNow = 1700000000000; + vi.spyOn(Date, 'now').mockReturnValue(fakeNow); + + mockApi.sendRepeaterCommand.mockResolvedValueOnce({ + command: 'clock 1700000000', + response: 'ok', + sender_timestamp: 1000, + }); + + const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation)); + + await act(async () => { + await result.current.syncClock(); + }); + + expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'clock 1700000000'); + + vi.restoreAllMocks(); + }); + + it('loadAll calls refreshPane for all panes serially', async () => { + mockApi.repeaterStatus.mockResolvedValueOnce({ battery_volts: 4.0 }); + mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] }); + mockApi.repeaterAcl.mockResolvedValueOnce({ acl: [] }); + mockApi.repeaterRadioSettings.mockResolvedValueOnce({ + firmware_version: 'v1.0', + radio: null, + tx_power: null, + airtime_factor: null, + repeat_enabled: null, + flood_max: null, + name: null, + lat: null, + lon: null, + clock_utc: null, + }); + mockApi.repeaterAdvertIntervals.mockResolvedValueOnce({ + advert_interval: null, + flood_advert_interval: null, + }); + mockApi.repeaterOwnerInfo.mockResolvedValueOnce({ + owner_info: null, + guest_password: null, + }); + mockApi.repeaterLppTelemetry.mockResolvedValueOnce({ sensors: [] }); + + const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation)); + + await act(async () => { + await result.current.loadAll(); + }); + + expect(mockApi.repeaterStatus).toHaveBeenCalledTimes(1); + expect(mockApi.repeaterNeighbors).toHaveBeenCalledTimes(1); + expect(mockApi.repeaterAcl).toHaveBeenCalledTimes(1); + expect(mockApi.repeaterRadioSettings).toHaveBeenCalledTimes(1); + expect(mockApi.repeaterAdvertIntervals).toHaveBeenCalledTimes(1); + expect(mockApi.repeaterOwnerInfo).toHaveBeenCalledTimes(1); + expect(mockApi.repeaterLppTelemetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 740ec1e..d6fe960 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -218,8 +218,19 @@ export interface AclEntry { permission_name: string; } -export interface TelemetryResponse { - pubkey_prefix: string; +export interface CommandResponse { + command: string; + response: string; + sender_timestamp: number | null; +} + +// --- Granular repeater endpoint types --- + +export interface RepeaterLoginResponse { + status: string; +} + +export interface RepeaterStatusResponse { battery_volts: number; tx_queue_len: number; noise_floor_dbm: number; @@ -237,15 +248,62 @@ export interface TelemetryResponse { flood_dups: number; direct_dups: number; full_events: number; - neighbors: NeighborInfo[]; - acl: AclEntry[]; - clock_output: string | null; } -export interface CommandResponse { - command: string; - response: string; - sender_timestamp: number | null; +export interface RepeaterNeighborsResponse { + neighbors: NeighborInfo[]; +} + +export interface RepeaterAclResponse { + acl: AclEntry[]; +} + +export interface RepeaterRadioSettingsResponse { + firmware_version: string | null; + radio: string | null; + tx_power: string | null; + airtime_factor: string | null; + repeat_enabled: string | null; + flood_max: string | null; + name: string | null; + lat: string | null; + lon: string | null; + clock_utc: string | null; +} + +export interface RepeaterAdvertIntervalsResponse { + advert_interval: string | null; + flood_advert_interval: string | null; +} + +export interface RepeaterOwnerInfoResponse { + owner_info: string | null; + guest_password: string | null; +} + +export interface LppSensor { + channel: number; + type_name: string; + value: number | Record; +} + +export interface RepeaterLppTelemetryResponse { + sensors: LppSensor[]; +} + +export type PaneName = + | 'status' + | 'neighbors' + | 'acl' + | 'radioSettings' + | 'advertIntervals' + | 'ownerInfo' + | 'lppTelemetry'; + +export interface PaneState { + loading: boolean; + attempt: number; + error: string | null; } export interface TraceResponse { diff --git a/tests/test_repeater_routes.py b/tests/test_repeater_routes.py index fa26b98..0a27a35 100644 --- a/tests/test_repeater_routes.py +++ b/tests/test_repeater_routes.py @@ -7,13 +7,21 @@ from fastapi import HTTPException from meshcore import EventType from app.database import Database -from app.models import CommandRequest, TelemetryRequest +from app.models import CommandRequest, Contact, RepeaterLoginRequest from app.radio import radio_manager from app.repository import ContactRepository from app.routers.contacts import ( + _batch_cli_fetch, _fetch_repeater_response, - prepare_repeater_connection, - request_telemetry, + repeater_acl, + repeater_advert_intervals, + repeater_clock, + repeater_login, + repeater_lpp_telemetry, + repeater_neighbors, + repeater_owner_info, + repeater_radio_settings, + repeater_status, request_trace, send_repeater_command, ) @@ -86,6 +94,7 @@ def _mock_mc(): mc.commands.req_status_sync = AsyncMock() mc.commands.fetch_all_neighbours = AsyncMock() mc.commands.req_acl_sync = AsyncMock() + mc.commands.req_telemetry_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)) @@ -271,303 +280,6 @@ class TestFetchRepeaterResponse: assert mc.commands.get_msg.await_count == 21 -class TestTelemetryRoute: - @pytest.mark.asyncio - async def test_returns_404_when_contact_missing(self, test_db): - mc = _mock_mc() - with ( - patch("app.routers.contacts.require_connected", return_value=mc), - patch.object(radio_manager, "_meshcore", mc), - ): - with pytest.raises(HTTPException) as exc: - await request_telemetry(KEY_A, TelemetryRequest(password="pw")) - - assert exc.value.status_code == 404 - - @pytest.mark.asyncio - async def test_returns_400_for_non_repeater_contact(self, test_db): - mc = _mock_mc() - await _insert_contact(KEY_A, name="Client", contact_type=1) - - with ( - patch("app.routers.contacts.require_connected", return_value=mc), - patch.object(radio_manager, "_meshcore", mc), - ): - with pytest.raises(HTTPException) as exc: - await request_telemetry(KEY_A, TelemetryRequest(password="pw")) - - assert exc.value.status_code == 400 - assert "not a repeater" in exc.value.detail.lower() - - @pytest.mark.asyncio - async def test_status_retry_timeout_returns_504(self, test_db): - mc = _mock_mc() - await _insert_contact(KEY_A, name="Repeater", contact_type=2) - mc.commands.req_status_sync = AsyncMock(side_effect=[None, None, None]) - - with ( - patch("app.routers.contacts.require_connected", return_value=mc), - patch.object(radio_manager, "_meshcore", mc), - patch( - "app.routers.contacts.prepare_repeater_connection", - new_callable=AsyncMock, - ) as mock_prepare, - ): - with pytest.raises(HTTPException) as exc: - await request_telemetry(KEY_A, TelemetryRequest(password="pw")) - - assert exc.value.status_code == 504 - assert mc.commands.req_status_sync.await_count == 3 - mock_prepare.assert_awaited_once() - - @pytest.mark.asyncio - async def test_clock_timeout_uses_fallback_message_and_restores_auto_fetch(self, test_db): - mc = _mock_mc() - await _insert_contact(KEY_A, name="Repeater", contact_type=2) - mc.commands.req_status_sync = AsyncMock( - return_value={ - "pubkey_pre": "aaaaaaaaaaaa", - "bat": 3775, - "uptime": 1234, - } - ) - mc.commands.fetch_all_neighbours = AsyncMock( - return_value={"neighbours": [{"pubkey": "abc123def456", "snr": 9.0, "secs_ago": 5}]} - ) - mc.commands.req_acl_sync = AsyncMock(return_value=[{"key": "def456abc123", "perm": 2}]) - mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK)) - # Clock fetch uses _fetch_repeater_response which calls get_msg() directly. - # Return NO_MORE_MSGS to simulate no clock response. - mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS)) - - # Clock is attempted twice, each with timeout=10.0. Provide enough ticks - # for the deadline to expire on each attempt. - clock_ticks = [] - for base in (0.0, 100.0): - clock_ticks.extend([base, base + 5.0, base + 11.0]) - - with ( - patch("app.routers.contacts.require_connected", return_value=mc), - patch.object(radio_manager, "_meshcore", mc), - patch( - "app.routers.contacts.prepare_repeater_connection", - new_callable=AsyncMock, - ) as mock_prepare, - patch(_MONOTONIC, side_effect=clock_ticks), - patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock), - ): - response = await request_telemetry(KEY_A, TelemetryRequest(password="pw")) - - assert response.pubkey_prefix == "aaaaaaaaaaaa" - assert response.battery_volts == 3.775 - assert response.clock_output is not None - assert "unable to fetch `clock` output" in response.clock_output.lower() - mock_prepare.assert_awaited_once() - mc.stop_auto_message_fetching.assert_awaited_once() - mc.start_auto_message_fetching.assert_awaited_once() - - @pytest.mark.asyncio - async def test_full_success_with_neighbors_acl_and_clock(self, test_db): - """Full telemetry success: status, neighbors (name-resolved), ACL (with perm names), clock.""" - mc = _mock_mc() - # Insert the repeater itself - await _insert_contact(KEY_A, name="Repeater", contact_type=2) - # Insert a known neighbor so name resolution works - neighbor_key = "bb" * 32 - await _insert_contact(neighbor_key, name="NeighborNode", contact_type=1) - - mc.commands.req_status_sync = AsyncMock( - return_value={ - "pubkey_pre": KEY_A[:12], - "bat": 4200, - "uptime": 86400, - "tx_queue_len": 2, - "noise_floor": -120, - "last_rssi": -85, - "last_snr": 7.5, - "nb_recv": 1000, - "nb_sent": 500, - "airtime": 3600, - "rx_airtime": 7200, - "sent_flood": 100, - "sent_direct": 400, - "recv_flood": 300, - "recv_direct": 700, - "flood_dups": 10, - "direct_dups": 5, - "full_evts": 0, - } - ) - mc.commands.fetch_all_neighbours = AsyncMock( - return_value={ - "neighbours": [ - {"pubkey": neighbor_key[:12], "snr": 9.0, "secs_ago": 5}, - {"pubkey": "cccccccccccc", "snr": 3.0, "secs_ago": 120}, - ] - } - ) - mc.commands.req_acl_sync = AsyncMock( - return_value=[ - {"key": neighbor_key[:12], "perm": 3}, - {"key": "dddddddddddd", "perm": 0}, - ] - ) - mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK)) - mc.commands.get_msg = AsyncMock( - return_value=_radio_result( - EventType.CONTACT_MSG_RECV, - { - "pubkey_prefix": KEY_A[:12], - "text": "2026-02-23 12:00:00 UTC", - "txt_type": 1, - }, - ) - ) - - with ( - patch("app.routers.contacts.require_connected", return_value=mc), - patch.object(radio_manager, "_meshcore", mc), - patch( - "app.routers.contacts.prepare_repeater_connection", - new_callable=AsyncMock, - ), - patch(_MONOTONIC, side_effect=_advancing_clock()), - ): - response = await request_telemetry(KEY_A, TelemetryRequest(password="pw")) - - # Status fields - assert response.pubkey_prefix == KEY_A[:12] - assert response.battery_volts == 4.2 - assert response.uptime_seconds == 86400 - assert response.packets_received == 1000 - assert response.packets_sent == 500 - assert response.noise_floor_dbm == -120 - assert response.last_rssi_dbm == -85 - assert response.last_snr_db == 7.5 - - # Neighbors — first resolved by name, second unknown - assert len(response.neighbors) == 2 - assert response.neighbors[0].name == "NeighborNode" - assert response.neighbors[0].snr == 9.0 - assert response.neighbors[1].name is None - assert response.neighbors[1].last_heard_seconds == 120 - - # ACL — first resolved, permission names mapped - assert len(response.acl) == 2 - assert response.acl[0].name == "NeighborNode" - assert response.acl[0].permission_name == "Admin" - assert response.acl[1].name is None - assert response.acl[1].permission_name == "Guest" - - # Clock - assert response.clock_output == "2026-02-23 12:00:00 UTC" - - @pytest.mark.asyncio - async def test_empty_neighbors_and_acl(self, test_db): - """Telemetry with empty neighbor list and ACL still succeeds.""" - mc = _mock_mc() - await _insert_contact(KEY_A, name="Repeater", contact_type=2) - - mc.commands.req_status_sync = AsyncMock( - return_value={"pubkey_pre": KEY_A[:12], "bat": 3700, "uptime": 100} - ) - mc.commands.fetch_all_neighbours = AsyncMock(return_value={"neighbours": []}) - mc.commands.req_acl_sync = AsyncMock(return_value=[]) - mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK)) - mc.commands.get_msg = AsyncMock( - return_value=_radio_result( - EventType.CONTACT_MSG_RECV, - {"pubkey_prefix": KEY_A[:12], "text": "12:00", "txt_type": 1}, - ) - ) - - with ( - patch("app.routers.contacts.require_connected", return_value=mc), - patch.object(radio_manager, "_meshcore", mc), - patch( - "app.routers.contacts.prepare_repeater_connection", - new_callable=AsyncMock, - ), - patch(_MONOTONIC, side_effect=_advancing_clock()), - ): - response = await request_telemetry(KEY_A, TelemetryRequest(password="pw")) - - assert response.battery_volts == 3.7 - assert response.neighbors == [] - assert response.acl == [] - assert response.clock_output == "12:00" - - -class TestAddContactNonFatal: - """add_contact failure should warn and continue, not abort the operation.""" - - @pytest.mark.asyncio - async def test_prepare_repeater_connection_continues_on_add_contact_error(self, test_db): - mc = _mock_mc() - await _insert_contact(KEY_A, name="Repeater", contact_type=2) - mc.commands.add_contact = AsyncMock( - return_value=_radio_result(EventType.ERROR, {"reason": "no_event_received"}) - ) - mc.commands.send_login = AsyncMock(return_value=_radio_result(EventType.OK)) - contact = await ContactRepository.get_by_key(KEY_A) - - with patch("app.routers.contacts.broadcast_error") as mock_broadcast: - await prepare_repeater_connection(mc, contact, "pw") - - # Login was still attempted despite add_contact failure - mc.commands.send_login.assert_awaited_once() - mock_broadcast.assert_called_once() - assert "attempting to continue" in mock_broadcast.call_args[0][0].lower() - - @pytest.mark.asyncio - async def test_command_continues_on_add_contact_error(self, test_db): - mc = _mock_mc() - await _insert_contact(KEY_A, name="Repeater", contact_type=2) - mc.commands.add_contact = AsyncMock( - return_value=_radio_result(EventType.ERROR, {"reason": "no_event_received"}) - ) - mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK)) - mc.commands.get_msg = AsyncMock( - return_value=_radio_result( - EventType.CONTACT_MSG_RECV, - {"pubkey_prefix": KEY_A[:12], "text": "ver 1.0", "txt_type": 1}, - ) - ) - - with ( - patch("app.routers.contacts.require_connected", return_value=mc), - patch.object(radio_manager, "_meshcore", mc), - patch("app.routers.contacts.broadcast_error") as mock_broadcast, - patch(_MONOTONIC, side_effect=_advancing_clock()), - ): - response = await send_repeater_command(KEY_A, CommandRequest(command="ver")) - - assert response.response == "ver 1.0" - mock_broadcast.assert_called_once() - - @pytest.mark.asyncio - async def test_trace_continues_on_add_contact_error(self, test_db): - mc = _mock_mc() - await _insert_contact(KEY_A, name="Client", contact_type=1) - mc.commands.add_contact = AsyncMock( - return_value=_radio_result(EventType.ERROR, {"reason": "no_event_received"}) - ) - mc.commands.send_trace = AsyncMock(return_value=_radio_result(EventType.OK)) - mc.wait_for_event = AsyncMock( - return_value=MagicMock(payload={"path": [{"snr": 5.5}], "path_len": 1}) - ) - - with ( - patch("app.routers.contacts.require_connected", return_value=mc), - patch.object(radio_manager, "_meshcore", mc), - patch("app.routers.contacts.random.randint", return_value=1234), - patch("app.routers.contacts.broadcast_error") as mock_broadcast, - ): - response = await request_trace(KEY_A) - - assert response.remote_snr == 5.5 - assert response.path_len == 1 - mock_broadcast.assert_called_once() class TestRepeaterCommandRoute: @@ -637,6 +349,32 @@ class TestRepeaterCommandRoute: assert response.response == "firmware: v1.2.3" assert response.sender_timestamp == 1700000000 + @pytest.mark.asyncio + async def test_response_strips_firmware_prompt_prefix(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK)) + mc.commands.get_msg = AsyncMock( + return_value=_radio_result( + EventType.CONTACT_MSG_RECV, + { + "pubkey_prefix": KEY_A[:12], + "text": "> firmware: v1.2.3", + "sender_timestamp": 1700000000, + "txt_type": 1, + }, + ) + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch(_MONOTONIC, side_effect=_advancing_clock()), + ): + response = await send_repeater_command(KEY_A, CommandRequest(command="ver")) + + assert response.response == "firmware: v1.2.3" + @pytest.mark.asyncio async def test_success_falls_back_to_legacy_timestamp_field(self, test_db): mc = _mock_mc() @@ -798,3 +536,717 @@ class TestTraceRoute: assert response.remote_snr == 5.5 assert response.local_snr == 3.2 assert response.path_len == 2 + + +# --------------------------------------------------------------------------- +# Tests for new granular repeater endpoints +# --------------------------------------------------------------------------- + + +class TestRepeaterLogin: + @pytest.mark.asyncio + async def test_success(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch( + "app.routers.contacts.prepare_repeater_connection", + new_callable=AsyncMock, + ) as mock_prepare, + ): + response = await repeater_login(KEY_A, RepeaterLoginRequest(password="secret")) + + assert response.status == "ok" + mock_prepare.assert_awaited_once() + + @pytest.mark.asyncio + async def test_404_missing_contact(self, test_db): + mc = _mock_mc() + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + with pytest.raises(HTTPException) as exc: + await repeater_login(KEY_A, RepeaterLoginRequest(password="pw")) + assert exc.value.status_code == 404 + + @pytest.mark.asyncio + async def test_400_not_repeater(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Client", contact_type=1) + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + with pytest.raises(HTTPException) as exc: + await repeater_login(KEY_A, RepeaterLoginRequest(password="pw")) + assert exc.value.status_code == 400 + assert "not a repeater" in exc.value.detail.lower() + + @pytest.mark.asyncio + async def test_login_error_raises(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + + async def _prepare_fail(*args, **kwargs): + raise HTTPException(status_code=401, detail="Login failed") + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch("app.routers.contacts.prepare_repeater_connection", side_effect=_prepare_fail), + ): + with pytest.raises(HTTPException) as exc: + await repeater_login(KEY_A, RepeaterLoginRequest(password="bad")) + assert exc.value.status_code == 401 + + +class TestRepeaterStatus: + @pytest.mark.asyncio + async def test_success_with_field_mapping(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.req_status_sync = AsyncMock( + return_value={ + "bat": 4200, + "tx_queue_len": 2, + "noise_floor": -120, + "last_rssi": -85, + "last_snr": 7.5, + "nb_recv": 1000, + "nb_sent": 500, + "airtime": 3600, + "rx_airtime": 7200, + "uptime": 86400, + "sent_flood": 100, + "sent_direct": 400, + "recv_flood": 300, + "recv_direct": 700, + "flood_dups": 10, + "direct_dups": 5, + "full_evts": 0, + } + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + response = await repeater_status(KEY_A) + + assert response.battery_volts == 4.2 + assert response.tx_queue_len == 2 + assert response.noise_floor_dbm == -120 + assert response.last_rssi_dbm == -85 + assert response.last_snr_db == 7.5 + assert response.packets_received == 1000 + assert response.packets_sent == 500 + assert response.uptime_seconds == 86400 + assert response.sent_flood == 100 + assert response.recv_direct == 700 + + @pytest.mark.asyncio + async def test_504_on_timeout(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.req_status_sync = AsyncMock(return_value=None) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + with pytest.raises(HTTPException) as exc: + await repeater_status(KEY_A) + assert exc.value.status_code == 504 + + @pytest.mark.asyncio + async def test_400_not_repeater(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Client", contact_type=1) + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + with pytest.raises(HTTPException) as exc: + await repeater_status(KEY_A) + assert exc.value.status_code == 400 + + +class TestRepeaterLppTelemetry: + @pytest.mark.asyncio + async def test_success_with_sensors(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.req_telemetry_sync = AsyncMock( + return_value=[ + {"channel": 0, "type": "temperature", "value": 24.5}, + {"channel": 1, "type": "humidity", "value": 62.0}, + { + "channel": 2, + "type": "gps", + "value": {"latitude": 37.7, "longitude": -122.4, "altitude": 15.0}, + }, + ] + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + response = await repeater_lpp_telemetry(KEY_A) + + assert len(response.sensors) == 3 + assert response.sensors[0].channel == 0 + assert response.sensors[0].type_name == "temperature" + assert response.sensors[0].value == 24.5 + assert response.sensors[1].type_name == "humidity" + assert response.sensors[1].value == 62.0 + assert response.sensors[2].type_name == "gps" + assert isinstance(response.sensors[2].value, dict) + assert response.sensors[2].value["latitude"] == 37.7 + + @pytest.mark.asyncio + async def test_empty_sensors(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.req_telemetry_sync = AsyncMock(return_value=[]) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + response = await repeater_lpp_telemetry(KEY_A) + + assert response.sensors == [] + + @pytest.mark.asyncio + async def test_504_on_timeout(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.req_telemetry_sync = AsyncMock(return_value=None) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + with pytest.raises(HTTPException) as exc: + await repeater_lpp_telemetry(KEY_A) + assert exc.value.status_code == 504 + + @pytest.mark.asyncio + async def test_400_not_repeater(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Client", contact_type=1) + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + with pytest.raises(HTTPException) as exc: + await repeater_lpp_telemetry(KEY_A) + assert exc.value.status_code == 400 + + +class TestRepeaterNeighbors: + @pytest.mark.asyncio + async def test_success_with_name_resolution(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + neighbor_key = "bb" * 32 + await _insert_contact(neighbor_key, name="NeighborNode", contact_type=1) + + mc.commands.fetch_all_neighbours = AsyncMock( + return_value={ + "neighbours": [ + {"pubkey": neighbor_key[:12], "snr": 9.0, "secs_ago": 5}, + {"pubkey": "cccccccccccc", "snr": 3.0, "secs_ago": 120}, + ] + } + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + response = await repeater_neighbors(KEY_A) + + assert len(response.neighbors) == 2 + assert response.neighbors[0].name == "NeighborNode" + assert response.neighbors[0].snr == 9.0 + assert response.neighbors[1].name is None + assert response.neighbors[1].last_heard_seconds == 120 + + @pytest.mark.asyncio + async def test_empty_neighbors(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.fetch_all_neighbours = AsyncMock(return_value={"neighbours": []}) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + response = await repeater_neighbors(KEY_A) + + assert response.neighbors == [] + + @pytest.mark.asyncio + async def test_timeout_returns_empty(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.fetch_all_neighbours = AsyncMock(return_value=None) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + response = await repeater_neighbors(KEY_A) + + assert response.neighbors == [] + + +class TestRepeaterAcl: + @pytest.mark.asyncio + async def test_success_with_permission_mapping(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + neighbor_key = "bb" * 32 + await _insert_contact(neighbor_key, name="Admin User", contact_type=1) + + mc.commands.req_acl_sync = AsyncMock( + return_value=[ + {"key": neighbor_key[:12], "perm": 3}, + {"key": "dddddddddddd", "perm": 0}, + ] + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + response = await repeater_acl(KEY_A) + + assert len(response.acl) == 2 + assert response.acl[0].name == "Admin User" + assert response.acl[0].permission_name == "Admin" + assert response.acl[1].name is None + assert response.acl[1].permission_name == "Guest" + + @pytest.mark.asyncio + async def test_empty_acl(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.req_acl_sync = AsyncMock(return_value=[]) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + response = await repeater_acl(KEY_A) + + assert response.acl == [] + + @pytest.mark.asyncio + async def test_timeout_returns_empty(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.req_acl_sync = AsyncMock(return_value=None) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + response = await repeater_acl(KEY_A) + + assert response.acl == [] + + +class TestRepeaterRadioSettings: + @pytest.mark.asyncio + async def test_full_success(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + + # Build responses for all 10 commands + responses = [ + "v2.1.0", # ver + "915.0,250,7,5", # get radio + "20", # get tx + "0", # get af + "1", # get repeat + "3", # get flood.max + "MyRepeater", # get name + "40.7128", # get lat + "-74.0060", # get lon + "2025-02-25 14:30:00", # clock + ] + get_msg_results = [ + _radio_result( + EventType.CONTACT_MSG_RECV, + {"pubkey_prefix": KEY_A[:12], "text": text, "txt_type": 1}, + ) + for text in responses + ] + mc.commands.get_msg = AsyncMock(side_effect=get_msg_results) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch(_MONOTONIC, side_effect=_advancing_clock()), + ): + response = await repeater_radio_settings(KEY_A) + + assert response.firmware_version == "v2.1.0" + assert response.radio == "915.0,250,7,5" + assert response.tx_power == "20" + assert response.airtime_factor == "0" + assert response.repeat_enabled == "1" + assert response.flood_max == "3" + assert response.name == "MyRepeater" + assert response.lat == "40.7128" + assert response.lon == "-74.0060" + assert response.clock_utc == "2025-02-25 14:30:00" + + @pytest.mark.asyncio + async def test_partial_failure(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + + # First command succeeds, rest timeout + first_response = _radio_result( + EventType.CONTACT_MSG_RECV, + {"pubkey_prefix": KEY_A[:12], "text": "v2.0.0", "txt_type": 1}, + ) + no_msgs = _radio_result(EventType.NO_MORE_MSGS) + mc.commands.get_msg = AsyncMock(side_effect=[first_response] + [no_msgs] * 50) + + # Provide clock ticks: first command succeeds quickly, others expire + clock_ticks = [0.0, 0.1] # First fetch succeeds + for i in range(9): + base = 100.0 * (i + 1) + clock_ticks.extend([base, base + 5.0, base + 11.0]) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch(_MONOTONIC, side_effect=clock_ticks), + patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock), + ): + response = await repeater_radio_settings(KEY_A) + + assert response.firmware_version == "v2.0.0" + assert response.radio is None + assert response.tx_power is None + + @pytest.mark.asyncio + async def test_400_not_repeater(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Client", contact_type=1) + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + with pytest.raises(HTTPException) as exc: + await repeater_radio_settings(KEY_A) + assert exc.value.status_code == 400 + + +class TestRepeaterAdvertIntervals: + @pytest.mark.asyncio + async def test_success(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + + responses = [ + _radio_result( + EventType.CONTACT_MSG_RECV, + {"pubkey_prefix": KEY_A[:12], "text": "30", "txt_type": 1}, + ), + _radio_result( + EventType.CONTACT_MSG_RECV, + {"pubkey_prefix": KEY_A[:12], "text": "120", "txt_type": 1}, + ), + ] + mc.commands.get_msg = AsyncMock(side_effect=responses) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch(_MONOTONIC, side_effect=_advancing_clock()), + ): + response = await repeater_advert_intervals(KEY_A) + + assert response.advert_interval == "30" + assert response.flood_advert_interval == "120" + + @pytest.mark.asyncio + async def test_timeout_returns_none_fields(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS)) + + clock_ticks = [] + for i in range(2): + base = 100.0 * i + clock_ticks.extend([base, base + 5.0, base + 11.0]) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch(_MONOTONIC, side_effect=clock_ticks), + patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock), + ): + response = await repeater_advert_intervals(KEY_A) + + assert response.advert_interval is None + assert response.flood_advert_interval is None + + +class TestRepeaterOwnerInfo: + @pytest.mark.asyncio + async def test_success(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + + responses = [ + _radio_result( + EventType.CONTACT_MSG_RECV, + { + "pubkey_prefix": KEY_A[:12], + "text": "John Doe - Contact: john@example.com", + "txt_type": 1, + }, + ), + _radio_result( + EventType.CONTACT_MSG_RECV, + {"pubkey_prefix": KEY_A[:12], "text": "guestpw123", "txt_type": 1}, + ), + ] + mc.commands.get_msg = AsyncMock(side_effect=responses) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch(_MONOTONIC, side_effect=_advancing_clock()), + ): + response = await repeater_owner_info(KEY_A) + + assert response.owner_info == "John Doe - Contact: john@example.com" + assert response.guest_password == "guestpw123" + + @pytest.mark.asyncio + async def test_timeout_returns_none_fields(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS)) + + clock_ticks = [] + for i in range(2): + base = 100.0 * i + clock_ticks.extend([base, base + 5.0, base + 11.0]) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch(_MONOTONIC, side_effect=clock_ticks), + patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock), + ): + response = await repeater_owner_info(KEY_A) + + assert response.owner_info is None + assert response.guest_password is None + + +class TestRepeaterClock: + @pytest.mark.asyncio + async def test_success(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + + mc.commands.get_msg = AsyncMock( + return_value=_radio_result( + EventType.CONTACT_MSG_RECV, + {"pubkey_prefix": KEY_A[:12], "text": "2026-02-25 12:00:00 UTC", "txt_type": 1}, + ) + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch(_MONOTONIC, side_effect=_advancing_clock()), + ): + response = await repeater_clock(KEY_A) + + assert response.clock_output == "2026-02-25 12:00:00 UTC" + + @pytest.mark.asyncio + async def test_timeout_returns_none(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS)) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch(_MONOTONIC, side_effect=[0.0, 5.0, 11.0]), + patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock), + ): + response = await repeater_clock(KEY_A) + + assert response.clock_output is None + + +def _make_contact( + public_key: str = KEY_A, name: str = "Repeater", contact_type: int = 2 +) -> Contact: + """Create a Contact model instance for testing.""" + return Contact(public_key=public_key, name=name, type=contact_type) + + +class TestBatchCliFetch: + """Tests for the _batch_cli_fetch helper.""" + + @pytest.mark.asyncio + async def test_add_contact_error_raises_500(self): + mc = _mock_mc() + mc.commands.add_contact = AsyncMock( + return_value=_radio_result(EventType.ERROR, {"err": "radio busy"}) + ) + + contact = _make_contact() + + with patch.object(radio_manager, "_meshcore", mc): + with pytest.raises(HTTPException) as exc: + await _batch_cli_fetch(contact, "test_op", [("ver", "firmware_version")]) + + assert exc.value.status_code == 500 + assert "Failed to add contact to radio" in exc.value.detail + + @pytest.mark.asyncio + async def test_send_cmd_error_skips_field(self): + mc = _mock_mc() + mc.commands.add_contact = AsyncMock(return_value=_radio_result(EventType.OK)) + + # First command fails, second succeeds + mc.commands.send_cmd = AsyncMock( + side_effect=[ + _radio_result(EventType.ERROR, {"err": "bad cmd"}), + _radio_result(EventType.OK), + ] + ) + mc.commands.get_msg = AsyncMock( + return_value=_radio_result( + EventType.CONTACT_MSG_RECV, + {"pubkey_prefix": KEY_A[:12], "text": "result2", "txt_type": 1}, + ) + ) + + contact = _make_contact() + + with ( + patch.object(radio_manager, "_meshcore", mc), + patch(_MONOTONIC, side_effect=_advancing_clock()), + patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock), + ): + results = await _batch_cli_fetch( + contact, "test_op", [("bad_cmd", "field_a"), ("good_cmd", "field_b")] + ) + + assert results["field_a"] is None # skipped due to send error + assert results["field_b"] == "result2" + + @pytest.mark.asyncio + async def test_no_response_leaves_field_none(self): + mc = _mock_mc() + mc.commands.add_contact = AsyncMock(return_value=_radio_result(EventType.OK)) + mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK)) + mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS)) + + contact = _make_contact() + + with ( + patch.object(radio_manager, "_meshcore", mc), + patch(_MONOTONIC, side_effect=[0.0, 5.0, 11.0]), + patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock), + ): + results = await _batch_cli_fetch(contact, "test_op", [("clock", "clock_output")]) + + assert results["clock_output"] is None + + +class TestRepeaterAddContactError: + """Test that repeater endpoints raise 500 when add_contact fails.""" + + @pytest.mark.asyncio + async def test_status_add_contact_error(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.add_contact = AsyncMock( + return_value=_radio_result(EventType.ERROR, {"err": "radio busy"}) + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + with pytest.raises(HTTPException) as exc: + await repeater_status(KEY_A) + + assert exc.value.status_code == 500 + assert "Failed to add contact to radio" in exc.value.detail + + @pytest.mark.asyncio + async def test_lpp_telemetry_add_contact_error(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.add_contact = AsyncMock( + return_value=_radio_result(EventType.ERROR, {"err": "radio busy"}) + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + with pytest.raises(HTTPException) as exc: + await repeater_lpp_telemetry(KEY_A) + + assert exc.value.status_code == 500 + assert "Failed to add contact to radio" in exc.value.detail + + @pytest.mark.asyncio + async def test_neighbors_add_contact_error(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.add_contact = AsyncMock( + return_value=_radio_result(EventType.ERROR, {"err": "radio busy"}) + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + with pytest.raises(HTTPException) as exc: + await repeater_neighbors(KEY_A) + + assert exc.value.status_code == 500 + assert "Failed to add contact to radio" in exc.value.detail + + @pytest.mark.asyncio + async def test_acl_add_contact_error(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.add_contact = AsyncMock( + return_value=_radio_result(EventType.ERROR, {"err": "radio busy"}) + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + ): + with pytest.raises(HTTPException) as exc: + await repeater_acl(KEY_A) + + assert exc.value.status_code == 500 + assert "Failed to add contact to radio" in exc.value.detail