4 Commits

Author SHA1 Message Date
Jack Kingsman
7151cf3846 Be much, much clearer about room server ops. Closes #78. 2026-03-27 13:01:34 -07:00
Jack Kingsman
6e5256acce Be more flexible about radio offload. Closes #118. 2026-03-27 12:49:01 -07:00
Jack Kingsman
7d27567ae9 Merge pull request #109 from jkingsman/fix-room-server-ordering
Order room server messages by sender timestamp, not packet-receipt time
2026-03-27 10:18:21 -07:00
jkingsman
95c874e643 Order room server messages by sender timestamp, not arrival-at-our-radio timestamp 2026-03-24 15:55:28 -07:00
16 changed files with 835 additions and 152 deletions

View File

@@ -17,6 +17,7 @@ from app.frontend_static import (
)
from app.radio import RadioDisconnectedError
from app.radio_sync import (
stop_background_contact_reconciliation,
stop_message_polling,
stop_periodic_advert,
stop_periodic_sync,
@@ -95,6 +96,7 @@ async def lifespan(app: FastAPI):
pass
await fanout_manager.stop_all()
await radio_manager.stop_connection_monitor()
await stop_background_contact_reconciliation()
await stop_message_polling()
await stop_periodic_advert()
await stop_periodic_sync()

View File

@@ -548,11 +548,14 @@ class RadioManager:
async def disconnect(self) -> None:
"""Disconnect from the radio."""
from app.radio_sync import stop_background_contact_reconciliation
clear_keys()
self._reset_reconnect_error_broadcasts()
if self._meshcore is None:
return
await stop_background_contact_reconciliation()
await self._acquire_operation_lock("disconnect", blocking=True)
try:
mc = self._meshcore

View File

@@ -166,6 +166,9 @@ async def pause_polling():
# Background task handle
_sync_task: asyncio.Task | None = None
# Startup/background contact reconciliation task handle
_contact_reconcile_task: asyncio.Task | None = None
# Periodic maintenance check interval in seconds (5 minutes)
SYNC_INTERVAL = 300
@@ -266,30 +269,7 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
remove_result = await mc.commands.remove_contact(contact_data)
if remove_result.type == EventType.OK:
removed += 1
# LIBRARY INTERNAL FIXUP: The MeshCore library's
# commands.remove_contact() sends the remove command over
# the wire but does NOT update the library's in-memory
# contact cache (mc._contacts). This is a gap in the
# library — there's no public API to clear a single
# contact from the cache, and the library only refreshes
# it on a full get_contacts() call.
#
# Why this matters: sync_recent_contacts_to_radio() uses
# mc.get_contact_by_key_prefix() to check whether a
# contact is already loaded on the radio. That method
# searches mc._contacts. If we don't evict the removed
# contact from the cache here, get_contact_by_key_prefix()
# will still find it and skip the add_contact() call —
# meaning contacts never get loaded back onto the radio
# after offload. The result: no DM ACKs, degraded routing
# for potentially minutes until the next periodic sync
# refreshes the cache from the (now-empty) radio.
#
# We access mc._contacts directly because the library
# exposes it as a read-only property (mc.contacts) with
# no removal API. The dict is keyed by public_key string.
mc._contacts.pop(public_key, None)
_evict_removed_contact_from_library_cache(mc, public_key)
else:
logger.warning(
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
@@ -461,28 +441,28 @@ async def ensure_default_channels() -> None:
async def sync_and_offload_all(mc: MeshCore) -> dict:
"""Sync and offload both contacts and channels, then ensure defaults exist."""
"""Run fast startup sync, then background contact reconcile."""
logger.info("Starting full radio sync and offload")
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
# cycle so old rows stop claiming radio residency we do not actively track.
await ContactRepository.clear_on_radio_except([])
contacts_result = await sync_and_offload_contacts(mc)
contacts_result = await sync_contacts_from_radio(mc)
channels_result = await sync_and_offload_channels(mc)
# Ensure default channels exist
await ensure_default_channels()
# Reload favorites plus a working-set fill back onto the radio immediately.
# Pass mc directly since the caller already holds the radio operation lock
# (asyncio.Lock is not reentrant).
reload_result = await sync_recent_contacts_to_radio(force=True, mc=mc)
start_background_contact_reconciliation(
initial_radio_contacts=contacts_result.get("radio_contacts", {}),
expected_mc=mc,
)
return {
"contacts": contacts_result,
"channels": channels_result,
"reloaded": reload_result,
"contact_reconcile_started": True,
}
@@ -1036,6 +1016,270 @@ async def stop_periodic_sync():
# Throttling for contact sync to radio
_last_contact_sync: float = 0.0
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
CONTACT_RECONCILE_BATCH_SIZE = 2
CONTACT_RECONCILE_YIELD_SECONDS = 0.05
def _evict_removed_contact_from_library_cache(mc: MeshCore, public_key: str) -> None:
"""Keep the library's contact cache consistent after a successful removal."""
# LIBRARY INTERNAL FIXUP: The MeshCore library's remove_contact() sends the
# remove command over the wire but does NOT update the library's in-memory
# contact cache (mc._contacts). This is a gap in the library — there's no
# public API to clear a single contact from the cache, and the library only
# refreshes it on a full get_contacts() call.
#
# Why this matters: contact sync and targeted ensure/load paths use
# mc.get_contact_by_key_prefix() to check whether a contact is already
# loaded on the radio. That method searches mc._contacts. If we don't evict
# the removed contact from the cache here, later syncs will still find it
# and skip add_contact() calls, leaving the radio without the contact even
# though the app thinks it is resident.
mc._contacts.pop(public_key, None)
def _normalize_radio_contacts_payload(contacts: dict | None) -> dict[str, dict]:
"""Return radio contacts keyed by normalized lowercase full public key."""
normalized: dict[str, dict] = {}
for public_key, contact_data in (contacts or {}).items():
normalized[str(public_key).lower()] = contact_data
return normalized
async def sync_contacts_from_radio(mc: MeshCore) -> dict:
"""Pull contacts from the radio and persist them to the database without removing them."""
synced = 0
try:
result = await mc.commands.get_contacts()
if result is None or result.type == EventType.ERROR:
logger.error(
"Failed to get contacts from radio: %s. "
"If you see this repeatedly, the radio may be visible on the "
"serial/TCP/BLE port but not responding to commands. Check for "
"another process with the serial port open (other RemoteTerm "
"instances, serial monitors, etc.), verify the firmware is "
"up-to-date and in client mode (not repeater), or try a "
"power cycle.",
result,
)
return {"synced": 0, "radio_contacts": {}, "error": str(result)}
contacts = _normalize_radio_contacts_payload(result.payload)
logger.info("Found %d contacts on radio", len(contacts))
for public_key, contact_data in contacts.items():
await ContactRepository.upsert(
ContactUpsert.from_radio_dict(public_key, contact_data, on_radio=False)
)
asyncio.create_task(
_reconcile_contact_messages_background(
public_key,
contact_data.get("adv_name"),
)
)
synced += 1
logger.info("Synced %d contacts from radio snapshot", synced)
return {"synced": synced, "radio_contacts": contacts}
except Exception as e:
logger.error("Error during contact snapshot sync: %s", e)
return {"synced": synced, "radio_contacts": {}, "error": str(e)}
async def _reconcile_radio_contacts_in_background(
*,
initial_radio_contacts: dict[str, dict],
expected_mc: MeshCore,
) -> None:
"""Converge radio contacts toward the desired favorites+recents working set."""
radio_contacts = dict(initial_radio_contacts)
removed = 0
loaded = 0
failed = 0
try:
while True:
if not radio_manager.is_connected or radio_manager.meshcore is not expected_mc:
logger.info("Stopping background contact reconcile: radio transport changed")
break
selected_contacts = await get_contacts_selected_for_radio_sync()
desired_contacts = {
contact.public_key.lower(): contact
for contact in selected_contacts
if len(contact.public_key) >= 64
}
removable_keys = [key for key in radio_contacts if key not in desired_contacts]
missing_contacts = [
contact for key, contact in desired_contacts.items() if key not in radio_contacts
]
if not removable_keys and not missing_contacts:
logger.info(
"Background contact reconcile complete: %d contacts on radio working set",
len(radio_contacts),
)
break
progressed = False
try:
async with radio_manager.radio_operation(
"background_contact_reconcile",
blocking=False,
) as mc:
if mc is not expected_mc:
logger.info(
"Stopping background contact reconcile: radio transport changed"
)
break
budget = CONTACT_RECONCILE_BATCH_SIZE
selected_contacts = await get_contacts_selected_for_radio_sync()
desired_contacts = {
contact.public_key.lower(): contact
for contact in selected_contacts
if len(contact.public_key) >= 64
}
for public_key in list(radio_contacts):
if budget <= 0:
break
if public_key in desired_contacts:
continue
remove_payload = (
mc.get_contact_by_key_prefix(public_key[:12])
or radio_contacts.get(public_key)
or {"public_key": public_key}
)
try:
remove_result = await mc.commands.remove_contact(remove_payload)
except Exception as exc:
failed += 1
budget -= 1
logger.warning(
"Error removing contact %s during background reconcile: %s",
public_key[:12],
exc,
)
continue
budget -= 1
if remove_result.type == EventType.OK:
radio_contacts.pop(public_key, None)
_evict_removed_contact_from_library_cache(mc, public_key)
removed += 1
progressed = True
else:
failed += 1
logger.warning(
"Failed to remove contact %s during background reconcile: %s",
public_key[:12],
remove_result.payload,
)
if budget > 0:
for public_key, contact in desired_contacts.items():
if budget <= 0:
break
if public_key in radio_contacts:
continue
if mc.get_contact_by_key_prefix(public_key[:12]):
radio_contacts[public_key] = {"public_key": public_key}
continue
try:
add_payload = contact.to_radio_dict()
add_result = await mc.commands.add_contact(add_payload)
except Exception as exc:
failed += 1
budget -= 1
logger.warning(
"Error adding contact %s during background reconcile: %s",
public_key[:12],
exc,
exc_info=True,
)
continue
budget -= 1
if add_result.type == EventType.OK:
radio_contacts[public_key] = add_payload
loaded += 1
progressed = True
else:
failed += 1
reason = add_result.payload
hint = ""
if reason is None:
hint = (
" (no response from radio — if this repeats, check for "
"serial port contention from another process or try a "
"power cycle)"
)
logger.warning(
"Failed to add contact %s during background reconcile: %s%s",
public_key[:12],
reason,
hint,
)
except RadioOperationBusyError:
logger.debug("Background contact reconcile yielding: radio busy")
await asyncio.sleep(CONTACT_RECONCILE_YIELD_SECONDS)
if not progressed:
continue
except asyncio.CancelledError:
logger.info("Background contact reconcile task cancelled")
raise
except Exception as exc:
logger.error("Background contact reconcile failed: %s", exc, exc_info=True)
finally:
if removed > 0 or loaded > 0 or failed > 0:
logger.info(
"Background contact reconcile summary: removed %d, loaded %d, failed %d",
removed,
loaded,
failed,
)
def start_background_contact_reconciliation(
*,
initial_radio_contacts: dict[str, dict],
expected_mc: MeshCore,
) -> None:
"""Start or replace the background contact reconcile task for the current radio."""
global _contact_reconcile_task
if _contact_reconcile_task is not None and not _contact_reconcile_task.done():
_contact_reconcile_task.cancel()
_contact_reconcile_task = asyncio.create_task(
_reconcile_radio_contacts_in_background(
initial_radio_contacts=initial_radio_contacts,
expected_mc=expected_mc,
)
)
logger.info(
"Started background contact reconcile for %d radio contact(s)",
len(initial_radio_contacts),
)
async def stop_background_contact_reconciliation() -> None:
"""Stop the background contact reconcile task."""
global _contact_reconcile_task
if _contact_reconcile_task and not _contact_reconcile_task.done():
_contact_reconcile_task.cancel()
try:
await _contact_reconcile_task
except asyncio.CancelledError:
pass
_contact_reconcile_task = None
async def get_contacts_selected_for_radio_sync() -> list[Contact]:

View File

@@ -62,7 +62,7 @@ def _login_rejected_message(label: str) -> str:
def _login_send_failed_message(label: str) -> str:
return (
f"The login request could not be sent to the {label}. "
f"The control panel is still available, but authenticated actions may fail until a login succeeds."
f"You're free to attempt interaction; try logging in again if authenticated actions fail."
)
@@ -70,7 +70,7 @@ def _login_timeout_message(label: str) -> str:
return (
f"No login confirmation was heard from the {label}. "
"That can mean the password was wrong or the reply was missed in transit. "
"The control panel is still available; try logging in again if authenticated actions fail."
"You're free to attempt interaction; try logging in again if authenticated actions fail."
)

View File

@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef, useState } from 'react';
import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
import { api } from './api';
import { takePrefetchOrFetch } from './prefetch';
import { useWebSocket } from './useWebSocket';
@@ -24,6 +24,7 @@ import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
import { messageContainsMention } from './utils/messageParser';
import { getStateKey } from './utils/conversationState';
import type { Conversation, Message, RawPacket } from './types';
import { CONTACT_TYPE_ROOM } from './types';
interface ChannelUnreadMarker {
channelId: string;
@@ -251,6 +252,21 @@ export function App() {
} = useConversationMessages(activeConversation, targetMessageId);
removeConversationMessagesRef.current = removeConversationMessages;
// Room servers replay stored history as a burst of DMs, all arriving with similar received_at
// but spanning a wide range of sender_timestamps. Sort by sender_timestamp for room contacts
// so the display reflects the original send order rather than our radio's receipt order.
const activeContactIsRoom =
activeConversation?.type === 'contact' &&
contacts.find((c) => c.public_key === activeConversation.id)?.type === CONTACT_TYPE_ROOM;
const sortedMessages = useMemo(() => {
if (!activeContactIsRoom || messages.length === 0) return messages;
return [...messages].sort((a, b) => {
const aTs = a.sender_timestamp ?? a.received_at;
const bTs = b.sender_timestamp ?? b.received_at;
return aTs !== bTs ? aTs - bTs : a.id - b.id;
});
}, [activeContactIsRoom, messages]);
const {
unreadCounts,
mentions,
@@ -427,7 +443,7 @@ export function App() {
config,
health,
favorites,
messages,
messages: sortedMessages,
messagesLoading,
loadingOlder,
hasOlderMessages,

View File

@@ -5,6 +5,7 @@ import { Button } from './ui/button';
import { Bell, Route, Star, Trash2 } from 'lucide-react';
import { DirectTraceIcon } from './DirectTraceIcon';
import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { isFavorite } from '../utils/favorites';
@@ -69,6 +70,7 @@ export function RepeaterDashboard({
loggedIn,
loginLoading,
loginError,
lastLoginAttempt,
paneData,
paneStates,
consoleHistory,
@@ -249,6 +251,14 @@ export function RepeaterDashboard({
/>
) : (
<div className="space-y-4">
<ServerLoginStatusBanner
attempt={lastLoginAttempt}
loading={loginLoading}
canRetryPassword={password.trim().length > 0}
onRetryPassword={() => handleRepeaterLogin(password)}
onRetryBlank={handleRepeaterGuestLogin}
blankRetryLabel="Retry Existing-Access Login"
/>
{/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:items-stretch">
<div className="flex flex-col gap-4">

View File

@@ -16,7 +16,13 @@ import { AclPane } from './repeater/RepeaterAclPane';
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { ConsolePane } from './repeater/RepeaterConsolePane';
import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
import {
buildServerLoginAttemptFromError,
buildServerLoginAttemptFromResponse,
type ServerLoginAttemptState,
} from '../utils/serverLoginState';
interface RoomServerPanelProps {
contact: Contact;
@@ -61,6 +67,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [authenticated, setAuthenticated] = useState(false);
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [paneData, setPaneData] = useState<RoomPaneData>({
status: null,
@@ -75,6 +82,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
setLoginLoading(false);
setLoginError(null);
setAuthenticated(false);
setLastLoginAttempt(null);
setAdvancedOpen(false);
setPaneData({
status: null,
@@ -129,26 +137,32 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
);
const performLogin = useCallback(
async (password: string) => {
async (nextPassword: string, method: 'password' | 'blank') => {
if (loginLoading) return;
setLoginLoading(true);
setLoginError(null);
try {
const result = await api.roomLogin(contact.public_key, password);
const result = await api.roomLogin(contact.public_key, nextPassword);
setLastLoginAttempt(buildServerLoginAttemptFromResponse(method, result, 'room server'));
setAuthenticated(true);
if (result.authenticated) {
toast.success('Room login confirmed');
toast.success('Login confirmed by the room server.');
} else {
toast.warning('Room login not confirmed', {
description: result.message ?? 'Room login was not confirmed',
toast.warning("Couldn't confirm room login", {
description:
result.message ??
'No confirmation came back from the room server. You can still open tools and try again.',
});
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setLastLoginAttempt(buildServerLoginAttemptFromError(method, message, 'room server'));
setAuthenticated(true);
setLoginError(message);
toast.error('Room login failed', { description: message });
toast.error('Room login request failed', {
description: `${message}. You can still open tools and retry the login from here.`,
});
} finally {
setLoginLoading(false);
}
@@ -157,15 +171,15 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
);
const handleLogin = useCallback(
async (password: string) => {
await performLogin(password);
persistAfterLogin(password);
async (nextPassword: string) => {
await performLogin(nextPassword, 'password');
persistAfterLogin(nextPassword);
},
[performLogin, persistAfterLogin]
);
const handleLoginAsGuest = useCallback(async () => {
await performLogin('');
await performLogin('', 'blank');
persistAfterLogin('');
}, [performLogin, persistAfterLogin]);
@@ -207,6 +221,8 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
);
const panelTitle = useMemo(() => contact.name || contact.public_key.slice(0, 12), [contact]);
const showLoginFailureState =
lastLoginAttempt !== null && lastLoginAttempt.outcome !== 'confirmed';
if (!authenticated) {
return (
@@ -236,7 +252,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
onLoginAsGuest={handleLoginAsGuest}
description="Log in with the room password or use ACL/guest access to enter this room server"
passwordPlaceholder="Room server password..."
guestLabel="Login with ACL / Guest"
guestLabel="Login with Existing Access / Guest"
/>
</div>
</div>
@@ -245,15 +261,52 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
return (
<section className="border-b border-border bg-muted/20 px-4 py-3">
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAdvancedOpen((prev) => !prev)}
>
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
</Button>
<div className="space-y-3">
{showLoginFailureState ? (
<ServerLoginStatusBanner
attempt={lastLoginAttempt}
loading={loginLoading}
canRetryPassword={password.trim().length > 0}
onRetryPassword={() => handleLogin(password)}
onRetryBlank={handleLoginAsGuest}
blankRetryLabel="Retry Existing-Access Login"
showRetryActions={false}
/>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2">
{showLoginFailureState ? (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void handleLogin(password)}
disabled={loginLoading || password.trim().length === 0}
>
Retry Password Login
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleLoginAsGuest}
disabled={loginLoading}
>
Retry Existing-Access Login
</Button>
</div>
) : (
<div />
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAdvancedOpen((prev) => !prev)}
>
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
</Button>
</div>
</div>
<Sheet open={advancedOpen} onOpenChange={setAdvancedOpen}>
<SheetContent side="right" className="w-full sm:max-w-4xl p-0 flex flex-col">
@@ -269,15 +322,6 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
<h2 className="truncate text-base font-semibold">Room Server Tools</h2>
<p className="text-sm text-muted-foreground">{panelTitle}</p>
</div>
<Button
type="button"
variant="outline"
onClick={handleLoginAsGuest}
disabled={loginLoading}
className="self-start sm:self-auto"
>
Refresh ACL Login
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">

View File

@@ -0,0 +1,76 @@
import { Button } from './ui/button';
import type { ServerLoginAttemptState } from '../utils/serverLoginState';
import { getServerLoginAttemptTone } from '../utils/serverLoginState';
import { cn } from '../lib/utils';
interface ServerLoginStatusBannerProps {
attempt: ServerLoginAttemptState | null;
loading: boolean;
canRetryPassword: boolean;
onRetryPassword: () => Promise<void> | void;
onRetryBlank: () => Promise<void> | void;
passwordRetryLabel?: string;
blankRetryLabel?: string;
showRetryActions?: boolean;
}
export function ServerLoginStatusBanner({
attempt,
loading,
canRetryPassword,
onRetryPassword,
onRetryBlank,
passwordRetryLabel = 'Retry Password Login',
blankRetryLabel = 'Retry Existing-Access Login',
showRetryActions = true,
}: ServerLoginStatusBannerProps) {
if (attempt?.outcome === 'confirmed') {
return null;
}
const tone = getServerLoginAttemptTone(attempt);
const shouldShowActions = showRetryActions;
const toneClassName =
tone === 'success'
? 'border-success/30 bg-success/10 text-success'
: tone === 'warning'
? 'border-warning/30 bg-warning/10 text-warning'
: tone === 'destructive'
? 'border-destructive/30 bg-destructive/10 text-destructive'
: 'border-border bg-muted/40 text-foreground';
return (
<div className={cn('rounded-md border px-4 py-3', toneClassName)}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium">
{attempt?.summary ?? 'No server login attempt has been recorded in this view yet.'}
</p>
{attempt?.details && <p className="text-xs opacity-90">{attempt.details}</p>}
</div>
{shouldShowActions ? (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void onRetryPassword()}
disabled={loading || !canRetryPassword}
>
{passwordRetryLabel}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void onRetryBlank()}
disabled={loading}
>
{blankRetryLabel}
</Button>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -2,12 +2,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
type ServerLoginKind = 'repeater' | 'room';
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
type StoredPassword = {
password: string;
};
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
const inMemoryPasswords = new Map<string, StoredPassword>();
function getStorageKey(kind: ServerLoginKind, publicKey: string): string {
return `${STORAGE_KEY_PREFIX}:${kind}:${publicKey}`;
}
@@ -33,37 +34,46 @@ export function useRememberedServerPassword(kind: ServerLoginKind, publicKey: st
useEffect(() => {
const stored = loadStoredPassword(kind, publicKey);
if (!stored) {
setPassword('');
if (stored) {
setPassword(stored.password);
setRememberPassword(true);
return;
}
const inMemoryStored = inMemoryPasswords.get(storageKey);
if (inMemoryStored) {
setPassword(inMemoryStored.password);
setRememberPassword(false);
return;
}
setPassword(stored.password);
setRememberPassword(true);
}, [kind, publicKey]);
setPassword('');
setRememberPassword(false);
}, [kind, publicKey, storageKey]);
const persistAfterLogin = useCallback(
(submittedPassword: string) => {
const trimmedPassword = submittedPassword.trim();
if (!trimmedPassword) {
return;
}
inMemoryPasswords.set(storageKey, { password: trimmedPassword });
if (!rememberPassword) {
try {
localStorage.removeItem(storageKey);
} catch {
// localStorage may be unavailable
}
setPassword('');
return;
} else {
try {
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
} catch {
// localStorage may be unavailable
}
}
const trimmedPassword = submittedPassword.trim();
if (!trimmedPassword) {
return;
}
try {
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
} catch {
// localStorage may be unavailable
}
setPassword(trimmedPassword);
},
[rememberPassword, storageKey]

View File

@@ -15,6 +15,11 @@ import type {
RepeaterLppTelemetryResponse,
CommandResponse,
} from '../types';
import {
buildServerLoginAttemptFromError,
buildServerLoginAttemptFromResponse,
type ServerLoginAttemptState,
} from '../utils/serverLoginState';
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;
@@ -41,6 +46,7 @@ interface PaneData {
interface RepeaterDashboardCacheEntry {
loggedIn: boolean;
loginError: string | null;
lastLoginAttempt: ServerLoginAttemptState | null;
paneData: PaneData;
paneStates: Record<PaneName, PaneState>;
consoleHistory: ConsoleEntry[];
@@ -119,6 +125,7 @@ function getCachedState(publicKey: string | null): RepeaterDashboardCacheEntry |
return {
loggedIn: cached.loggedIn,
loginError: cached.loginError,
lastLoginAttempt: cached.lastLoginAttempt,
paneData: clonePaneData(cached.paneData),
paneStates: normalizePaneStates(cached.paneStates),
consoleHistory: cloneConsoleHistory(cached.consoleHistory),
@@ -130,6 +137,7 @@ function cacheState(publicKey: string, entry: RepeaterDashboardCacheEntry) {
repeaterDashboardCache.set(publicKey, {
loggedIn: entry.loggedIn,
loginError: entry.loginError,
lastLoginAttempt: entry.lastLoginAttempt,
paneData: clonePaneData(entry.paneData),
paneStates: normalizePaneStates(entry.paneStates),
consoleHistory: cloneConsoleHistory(entry.consoleHistory),
@@ -173,6 +181,7 @@ export interface UseRepeaterDashboardResult {
loggedIn: boolean;
loginLoading: boolean;
loginError: string | null;
lastLoginAttempt: ServerLoginAttemptState | null;
paneData: PaneData;
paneStates: Record<PaneName, PaneState>;
consoleHistory: ConsoleEntry[];
@@ -203,6 +212,9 @@ export function useRepeaterDashboard(
const [loggedIn, setLoggedIn] = useState(cachedState?.loggedIn ?? false);
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(cachedState?.loginError ?? null);
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(
cachedState?.lastLoginAttempt ?? null
);
const [paneData, setPaneData] = useState<PaneData>(
cachedState?.paneData ?? createInitialPaneData
@@ -243,11 +255,20 @@ export function useRepeaterDashboard(
cacheState(conversationId, {
loggedIn,
loginError,
lastLoginAttempt,
paneData,
paneStates,
consoleHistory,
});
}, [consoleHistory, conversationId, loggedIn, loginError, paneData, paneStates]);
}, [
consoleHistory,
conversationId,
loggedIn,
loginError,
lastLoginAttempt,
paneData,
paneStates,
]);
useEffect(() => {
paneDataRef.current = paneData;
@@ -267,12 +288,14 @@ export function useRepeaterDashboard(
const publicKey = getPublicKey();
if (!publicKey) return;
const conversationId = publicKey;
const method = password.trim().length > 0 ? 'password' : 'blank';
setLoginLoading(true);
setLoginError(null);
try {
const result = await api.repeaterLogin(publicKey, password);
if (activeIdRef.current !== conversationId) return;
setLastLoginAttempt(buildServerLoginAttemptFromResponse(method, result, 'repeater'));
setLoggedIn(true);
if (!result.authenticated) {
const msg = result.message ?? 'Repeater login was not confirmed';
@@ -282,6 +305,7 @@ export function useRepeaterDashboard(
} catch (err) {
if (activeIdRef.current !== conversationId) return;
const msg = err instanceof Error ? err.message : 'Login failed';
setLastLoginAttempt(buildServerLoginAttemptFromError(method, msg, 'repeater'));
setLoggedIn(true);
setLoginError(msg);
toast.error('Login request failed', {
@@ -475,6 +499,7 @@ export function useRepeaterDashboard(
loggedIn,
loginLoading,
loginError,
lastLoginAttempt,
paneData,
paneStates,
consoleHistory,

View File

@@ -11,6 +11,7 @@ const mockHook: {
loggedIn: false,
loginLoading: false,
loginError: null,
lastLoginAttempt: null,
paneData: {
status: null,
nodeInfo: null,

View File

@@ -56,22 +56,84 @@ describe('RoomServerPanel', () => {
status: 'timeout',
authenticated: false,
message:
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
"No login confirmation was heard from the room server. You're free to try sending messages; try logging in again if authenticated actions fail.",
});
const onAuthenticatedChange = vi.fn();
render(<RoomServerPanel contact={roomContact} onAuthenticatedChange={onAuthenticatedChange} />);
fireEvent.click(screen.getByText('Login with ACL / Guest'));
fireEvent.click(screen.getByText('Login with Existing Access / Guest'));
await waitFor(() => {
expect(screen.getByText('Show Tools')).toBeInTheDocument();
});
expect(screen.getByText('Show Tools')).toBeInTheDocument();
expect(mockToast.warning).toHaveBeenCalledWith('Room login not confirmed', {
expect(screen.getByText('Retry Existing-Access Login')).toBeInTheDocument();
expect(mockToast.warning).toHaveBeenCalledWith("Couldn't confirm room login", {
description:
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
"No login confirmation was heard from the room server. You're free to try sending messages; try logging in again if authenticated actions fail.",
});
expect(onAuthenticatedChange).toHaveBeenLastCalledWith(true);
});
it('retains the last password for one-click retry after unlocking the panel', async () => {
mockApi.roomLogin
.mockResolvedValueOnce({
status: 'timeout',
authenticated: false,
message: 'No reply heard',
})
.mockResolvedValueOnce({
status: 'ok',
authenticated: true,
message: null,
});
render(<RoomServerPanel contact={roomContact} />);
fireEvent.change(screen.getByLabelText('Repeater password'), {
target: { value: 'secret-room-password' },
});
fireEvent.click(screen.getByText('Login with Password'));
await waitFor(() => {
expect(screen.getByText('Retry Password Login')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Retry Password Login'));
await waitFor(() => {
expect(mockApi.roomLogin).toHaveBeenNthCalledWith(
1,
roomContact.public_key,
'secret-room-password'
);
expect(mockApi.roomLogin).toHaveBeenNthCalledWith(
2,
roomContact.public_key,
'secret-room-password'
);
});
});
it('shows only a success toast after a confirmed login', async () => {
mockApi.roomLogin.mockResolvedValueOnce({
status: 'ok',
authenticated: true,
message: null,
});
render(<RoomServerPanel contact={roomContact} />);
fireEvent.click(screen.getByText('Login with Existing Access / Guest'));
await waitFor(() => {
expect(screen.getByText('Show Tools')).toBeInTheDocument();
});
expect(screen.queryByText('Login confirmed by the room server.')).not.toBeInTheDocument();
expect(screen.queryByText('Retry Password Login')).not.toBeInTheDocument();
expect(screen.queryByText('Retry Existing-Access Login')).not.toBeInTheDocument();
expect(mockToast.success).toHaveBeenCalledWith('Login confirmed by the room server.');
});
});

View File

@@ -8,70 +8,24 @@ describe('useRememberedServerPassword', () => {
localStorage.clear();
});
it('loads remembered passwords from localStorage', () => {
localStorage.setItem(
'remoteterm-server-password:repeater:abc123',
JSON.stringify({ password: 'stored-secret' })
it('restores the last in-memory password when local remember is disabled', () => {
const { result, unmount } = renderHook(() =>
useRememberedServerPassword('room', 'aa'.repeat(32))
);
const { result } = renderHook(() => useRememberedServerPassword('repeater', 'abc123'));
expect(result.current.password).toBe('stored-secret');
expect(result.current.rememberPassword).toBe(true);
});
it('stores passwords after login when remember is enabled', () => {
const { result } = renderHook(() => useRememberedServerPassword('room', 'room-key'));
act(() => {
result.current.setRememberPassword(true);
result.current.setPassword('room-secret');
result.current.persistAfterLogin('room-secret');
});
act(() => {
result.current.persistAfterLogin(' hello ');
});
expect(result.current.password).toBe('room-secret');
unmount();
expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe(
JSON.stringify({ password: 'hello' })
);
expect(result.current.password).toBe('hello');
});
it('clears stored passwords when login is done with remember disabled', () => {
localStorage.setItem(
'remoteterm-server-password:repeater:abc123',
JSON.stringify({ password: 'stored-secret' })
const { result: remounted } = renderHook(() =>
useRememberedServerPassword('room', 'aa'.repeat(32))
);
const { result } = renderHook(() => useRememberedServerPassword('repeater', 'abc123'));
act(() => {
result.current.setRememberPassword(false);
});
act(() => {
result.current.persistAfterLogin('new-secret');
});
expect(localStorage.getItem('remoteterm-server-password:repeater:abc123')).toBeNull();
expect(result.current.password).toBe('');
});
it('preserves remembered passwords on guest login when remember stays enabled', () => {
localStorage.setItem(
'remoteterm-server-password:room:room-key',
JSON.stringify({ password: 'stored-secret' })
);
const { result } = renderHook(() => useRememberedServerPassword('room', 'room-key'));
act(() => {
result.current.persistAfterLogin('');
});
expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe(
JSON.stringify({ password: 'stored-secret' })
);
expect(result.current.password).toBe('stored-secret');
expect(remounted.current.password).toBe('room-secret');
expect(remounted.current.rememberPassword).toBe(false);
});
});

View File

@@ -74,6 +74,8 @@ describe('useRepeaterDashboard', () => {
expect(result.current.loggedIn).toBe(true);
expect(result.current.loginError).toBe(null);
expect(result.current.lastLoginAttempt?.heardBack).toBe(true);
expect(result.current.lastLoginAttempt?.outcome).toBe('confirmed');
expect(mockApi.repeaterLogin).toHaveBeenCalledWith(REPEATER_KEY, 'secret');
});
@@ -92,6 +94,8 @@ describe('useRepeaterDashboard', () => {
expect(result.current.loggedIn).toBe(true);
expect(result.current.loginError).toBe('Auth failed');
expect(result.current.lastLoginAttempt?.heardBack).toBe(true);
expect(result.current.lastLoginAttempt?.outcome).toBe('not_confirmed');
expect(mockToast.error).toHaveBeenCalledWith('Login not confirmed', {
description: 'Auth failed',
});
@@ -125,6 +129,8 @@ describe('useRepeaterDashboard', () => {
expect(result.current.loggedIn).toBe(true);
expect(result.current.loginError).toBe('Network error');
expect(result.current.lastLoginAttempt?.heardBack).toBe(false);
expect(result.current.lastLoginAttempt?.outcome).toBe('request_failed');
expect(mockToast.error).toHaveBeenCalledWith('Login request failed', {
description:
'Network error. The dashboard is still available, but repeater operations may fail until a login succeeds.',

View File

@@ -0,0 +1,107 @@
import type { RepeaterLoginResponse } from '../types';
export type ServerLoginMethod = 'password' | 'blank';
export type ServerLoginAttemptState =
| {
method: ServerLoginMethod;
outcome: 'confirmed';
summary: string;
details: string | null;
heardBack: true;
at: number;
}
| {
method: ServerLoginMethod;
outcome: 'not_confirmed';
summary: string;
details: string | null;
heardBack: boolean;
at: number;
}
| {
method: ServerLoginMethod;
outcome: 'request_failed';
summary: string;
details: string | null;
heardBack: false;
at: number;
};
export function getServerLoginMethodLabel(
method: ServerLoginMethod,
blankLabel = 'existing-access'
): string {
return method === 'password' ? 'password' : blankLabel;
}
export function getServerLoginAttemptTone(
attempt: ServerLoginAttemptState | null
): 'success' | 'warning' | 'destructive' | 'muted' {
if (!attempt) return 'muted';
if (attempt.outcome === 'confirmed') return 'success';
if (attempt.outcome === 'not_confirmed') return 'warning';
return 'destructive';
}
export function buildServerLoginAttemptFromResponse(
method: ServerLoginMethod,
result: RepeaterLoginResponse,
entityLabel: string
): ServerLoginAttemptState {
const methodLabel = getServerLoginMethodLabel(method);
const at = Date.now();
const target = `the ${entityLabel}`;
if (result.authenticated) {
return {
method,
outcome: 'confirmed',
summary: `Login confirmed by ${target}.`,
details: null,
heardBack: true,
at,
};
}
if (result.status === 'timeout') {
return {
method,
outcome: 'not_confirmed',
summary: `We couldn't confirm the login.`,
details:
result.message ??
`No confirmation came back from ${target} after the ${methodLabel} login attempt.`,
heardBack: false,
at,
};
}
return {
method,
outcome: 'not_confirmed',
summary: `Login was not confirmed.`,
details:
result.message ??
`${target} responded, but did not confirm the ${methodLabel} login attempt.`,
heardBack: true,
at,
};
}
export function buildServerLoginAttemptFromError(
method: ServerLoginMethod,
message: string,
entityLabel: string
): ServerLoginAttemptState {
const methodLabel = getServerLoginMethodLabel(method);
const target = `the ${entityLabel}`;
return {
method,
outcome: 'request_failed',
summary: `We couldn't send the login request.`,
details: `${target} never acknowledged the ${methodLabel} login attempt. ${message}`,
heardBack: false,
at: Date.now(),
};
}

View File

@@ -5,12 +5,14 @@ contact/channel sync operations, and default channel management.
"""
import asyncio
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from meshcore import EventType
from meshcore.events import Event
import app.radio_sync as radio_sync
from app.models import Favorite
from app.radio import RadioManager, radio_manager
from app.radio_sync import (
@@ -36,8 +38,6 @@ from app.repository import (
@pytest.fixture(autouse=True)
def reset_sync_state():
"""Reset polling pause state, sync timestamp, and radio_manager before/after each test."""
import app.radio_sync as radio_sync
prev_mc = radio_manager._meshcore
prev_lock = radio_manager._operation_lock
prev_max_channels = radio_manager.max_channels
@@ -45,12 +45,20 @@ def reset_sync_state():
prev_slot_by_key = radio_manager._channel_slot_by_key.copy()
prev_key_by_slot = radio_manager._channel_key_by_slot.copy()
prev_pending_channel_key_by_slot = radio_manager._pending_message_channel_key_by_slot.copy()
prev_contact_reconcile_task = radio_sync._contact_reconcile_task
radio_sync._polling_pause_count = 0
radio_sync._last_contact_sync = 0.0
yield
if (
radio_sync._contact_reconcile_task is not None
and radio_sync._contact_reconcile_task is not prev_contact_reconcile_task
and not radio_sync._contact_reconcile_task.done()
):
radio_sync._contact_reconcile_task.cancel()
radio_sync._polling_pause_count = 0
radio_sync._last_contact_sync = 0.0
radio_sync._contact_reconcile_task = prev_contact_reconcile_task
radio_manager._meshcore = prev_mc
radio_manager._operation_lock = prev_lock
radio_manager.max_channels = prev_max_channels
@@ -433,7 +441,7 @@ class TestSyncAndOffloadAll:
"""Test session-local contact radio residency reset behavior."""
@pytest.mark.asyncio
async def test_clears_stale_contact_on_radio_flags_before_reload(self, test_db):
async def test_clears_stale_contact_on_radio_flags_before_background_reconcile(self, test_db):
await _insert_contact(KEY_A, "Alice", on_radio=True)
await _insert_contact(KEY_B, "Bob", on_radio=True)
@@ -441,8 +449,8 @@ class TestSyncAndOffloadAll:
with (
patch(
"app.radio_sync.sync_and_offload_contacts",
new=AsyncMock(return_value={"synced": 0, "removed": 0}),
"app.radio_sync.sync_contacts_from_radio",
new=AsyncMock(return_value={"synced": 0, "radio_contacts": {}}),
),
patch(
"app.radio_sync.sync_and_offload_channels",
@@ -450,8 +458,7 @@ class TestSyncAndOffloadAll:
),
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
patch(
"app.radio_sync.sync_recent_contacts_to_radio",
new=AsyncMock(return_value={"loaded": 0, "already_on_radio": 0, "failed": 0}),
"app.radio_sync.start_background_contact_reconciliation",
),
):
await sync_and_offload_all(mock_mc)
@@ -461,6 +468,30 @@ class TestSyncAndOffloadAll:
assert alice is not None and alice.on_radio is False
assert bob is not None and bob.on_radio is False
@pytest.mark.asyncio
async def test_starts_background_contact_reconcile_with_radio_snapshot(self, test_db):
mock_mc = MagicMock()
radio_contacts = {KEY_A: {"public_key": KEY_A}}
with (
patch(
"app.radio_sync.sync_contacts_from_radio",
new=AsyncMock(return_value={"synced": 1, "radio_contacts": radio_contacts}),
),
patch(
"app.radio_sync.sync_and_offload_channels",
new=AsyncMock(return_value={"synced": 0, "cleared": 0}),
),
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
patch("app.radio_sync.start_background_contact_reconciliation") as mock_start,
):
result = await sync_and_offload_all(mock_mc)
mock_start.assert_called_once_with(
initial_radio_contacts=radio_contacts, expected_mc=mock_mc
)
assert result["contact_reconcile_started"] is True
@pytest.mark.asyncio
async def test_advert_fill_skips_repeaters(self, test_db):
"""Recent advert fallback only considers non-repeaters."""
@@ -1036,6 +1067,98 @@ class TestSyncAndOffloadContacts:
assert KEY_A in mock_mc._contacts
class TestBackgroundContactReconcile:
"""Test the yielding background contact reconcile loop."""
@pytest.mark.asyncio
async def test_rechecks_desired_set_before_deleting_contact(self, test_db):
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
alice = await ContactRepository.get_by_key(KEY_A)
bob = await ContactRepository.get_by_key(KEY_B)
assert alice is not None
assert bob is not None
mock_mc = MagicMock()
mock_mc.is_connected = True
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
mock_mc.commands.remove_contact = AsyncMock(return_value=MagicMock(type=EventType.OK))
mock_mc.commands.add_contact = AsyncMock(return_value=MagicMock(type=EventType.OK))
radio_manager._meshcore = mock_mc
@asynccontextmanager
async def _radio_operation(*args, **kwargs):
del args, kwargs
yield mock_mc
with (
patch.object(
radio_sync.radio_manager,
"radio_operation",
side_effect=lambda *args, **kwargs: _radio_operation(*args, **kwargs),
),
patch(
"app.radio_sync.get_contacts_selected_for_radio_sync",
side_effect=[[bob], [alice, bob], [alice, bob]],
),
patch("app.radio_sync.asyncio.sleep", new=AsyncMock()),
):
await radio_sync._reconcile_radio_contacts_in_background(
initial_radio_contacts={KEY_A: {"public_key": KEY_A}},
expected_mc=mock_mc,
)
mock_mc.commands.remove_contact.assert_not_called()
mock_mc.commands.add_contact.assert_awaited_once()
payload = mock_mc.commands.add_contact.call_args.args[0]
assert payload["public_key"] == KEY_B
@pytest.mark.asyncio
async def test_yields_radio_lock_every_two_contact_operations(self, test_db):
await _insert_contact(KEY_A, "Alice", last_contacted=3000)
await _insert_contact(KEY_B, "Bob", last_contacted=2000)
extra_key = "cc" * 32
await _insert_contact(extra_key, "Carol", last_contacted=1000)
mock_mc = MagicMock()
mock_mc.is_connected = True
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
mock_mc.commands.remove_contact = AsyncMock(return_value=MagicMock(type=EventType.OK))
mock_mc.commands.add_contact = AsyncMock()
radio_manager._meshcore = mock_mc
acquire_count = 0
@asynccontextmanager
async def _radio_operation(*args, **kwargs):
del args, kwargs
nonlocal acquire_count
acquire_count += 1
yield mock_mc
with (
patch.object(
radio_sync.radio_manager,
"radio_operation",
side_effect=lambda *args, **kwargs: _radio_operation(*args, **kwargs),
),
patch("app.radio_sync.get_contacts_selected_for_radio_sync", return_value=[]),
patch("app.radio_sync.asyncio.sleep", new=AsyncMock()),
):
await radio_sync._reconcile_radio_contacts_in_background(
initial_radio_contacts={
KEY_A: {"public_key": KEY_A},
KEY_B: {"public_key": KEY_B},
extra_key: {"public_key": extra_key},
},
expected_mc=mock_mc,
)
assert acquire_count == 2
assert mock_mc.commands.remove_contact.await_count == 3
mock_mc.commands.add_contact.assert_not_called()
class TestSyncAndOffloadChannels:
"""Test sync_and_offload_channels: pull channels from radio, save to DB, clear from radio."""