Make repeater neighbor display need a GPS fix to show map + distance, and fetch before display. Closes #58.

This commit is contained in:
Jack Kingsman
2026-03-12 16:15:03 -07:00
parent 07934093e6
commit 07fd88a4d6
15 changed files with 516 additions and 102 deletions

View File

@@ -318,7 +318,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
| POST | `/api/contacts/{public_key}/repeater/neighbors` | Fetch repeater neighbors |
| POST | `/api/contacts/{public_key}/repeater/acl` | Fetch repeater ACL |
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch radio settings via CLI |
| POST | `/api/contacts/{public_key}/repeater/node-info` | Fetch repeater name, location, and clock via CLI |
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch repeater radio config via CLI |
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |

View File

@@ -176,6 +176,7 @@ app/
- `POST /contacts/{public_key}/repeater/lpp-telemetry`
- `POST /contacts/{public_key}/repeater/neighbors`
- `POST /contacts/{public_key}/repeater/acl`
- `POST /contacts/{public_key}/repeater/node-info`
- `POST /contacts/{public_key}/repeater/radio-settings`
- `POST /contacts/{public_key}/repeater/advert-intervals`
- `POST /contacts/{public_key}/repeater/owner-info`

View File

@@ -428,8 +428,17 @@ class RepeaterStatusResponse(BaseModel):
full_events: int = Field(description="Full event queue count")
class RepeaterNodeInfoResponse(BaseModel):
"""Identity/location info from a repeater (small CLI batch)."""
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 RepeaterRadioSettingsResponse(BaseModel):
"""Radio settings from a repeater (batch CLI get commands)."""
"""Radio settings from a repeater (radio/config CLI batch)."""
firmware_version: str | None = Field(default=None, description="Firmware version string")
radio: str | None = Field(default=None, description="Radio settings (freq,bw,sf,cr)")
@@ -437,10 +446,6 @@ class RepeaterRadioSettingsResponse(BaseModel):
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):

View File

@@ -21,6 +21,7 @@ from app.models import (
RepeaterLoginResponse,
RepeaterLppTelemetryResponse,
RepeaterNeighborsResponse,
RepeaterNodeInfoResponse,
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
@@ -373,9 +374,29 @@ async def _batch_cli_fetch(
return results
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
"""Fetch repeater identity/location info via a small CLI batch."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
results = await _batch_cli_fetch(
contact,
"repeater_node_info",
[
("get name", "name"),
("get lat", "lat"),
("get lon", "lon"),
("clock", "clock_utc"),
],
)
return RepeaterNodeInfoResponse(**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."""
"""Fetch radio settings from a repeater via radio/config CLI commands."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
@@ -390,10 +411,6 @@ async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsRespo
("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)

View File

@@ -122,7 +122,8 @@ frontend/src/
│ │ ├── RepeaterTelemetryPane.tsx # Battery, airtime, packet counts
│ │ ├── RepeaterNeighborsPane.tsx # Neighbor table + lazy mini-map
│ │ ├── RepeaterAclPane.tsx # Permission table
│ │ ├── RepeaterRadioSettingsPane.tsx # Radio settings + advert intervals
│ │ ├── RepeaterNodeInfoPane.tsx # Repeater name, coords, clock drift
│ │ ├── RepeaterRadioSettingsPane.tsx # Radio config + advert intervals
│ │ ├── RepeaterLppTelemetryPane.tsx # CayenneLPP sensor data
│ │ ├── RepeaterOwnerInfoPane.tsx # Owner info + guest password
│ │ ├── RepeaterActionsPane.tsx # Send Advert, Sync Clock, Reboot
@@ -359,7 +360,7 @@ For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashbo
**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 — 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).
**Dashboard panes** (after login): Telemetry, Node Info, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. `Neighbors` depends on the smaller `node-info` fetch for repeater GPS, not the heavier radio-settings batch. "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`.

View File

@@ -25,6 +25,7 @@ import type {
RepeaterLoginResponse,
RepeaterLppTelemetryResponse,
RepeaterNeighborsResponse,
RepeaterNodeInfoResponse,
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
@@ -352,6 +353,10 @@ export const api = {
fetchJson<RepeaterNeighborsResponse>(`/contacts/${publicKey}/repeater/neighbors`, {
method: 'POST',
}),
repeaterNodeInfo: (publicKey: string) =>
fetchJson<RepeaterNodeInfoResponse>(`/contacts/${publicKey}/repeater/node-info`, {
method: 'POST',
}),
repeaterAcl: (publicKey: string) =>
fetchJson<RepeaterAclResponse>(`/contacts/${publicKey}/repeater/acl`, {
method: 'POST',

View File

@@ -11,6 +11,7 @@ import type { Contact, Conversation, Favorite } from '../types';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
import { AclPane } from './repeater/RepeaterAclPane';
import { NodeInfoPane } from './repeater/RepeaterNodeInfoPane';
import { RadioSettingsPane } from './repeater/RepeaterRadioSettingsPane';
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
@@ -47,7 +48,7 @@ export function RepeaterDashboard({
notificationsPermission,
radioLat,
radioLon,
radioName,
radioName: _radioName,
onTrace,
onToggleNotifications,
onToggleFavorite,
@@ -197,9 +198,15 @@ export function RepeaterDashboard({
/>
) : (
<div className="space-y-4">
{/* Top row: Telemetry + Radio Settings | Neighbors (with expanding map) */}
{/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-4">
<NodeInfoPane
data={paneData.nodeInfo}
state={paneStates.nodeInfo}
onRefresh={() => refreshPane('nodeInfo')}
disabled={anyLoading}
/>
<TelemetryPane
data={paneData.status}
state={paneStates.status}
@@ -222,16 +229,18 @@ export function RepeaterDashboard({
disabled={anyLoading}
/>
</div>
<NeighborsPane
data={paneData.neighbors}
state={paneStates.neighbors}
onRefresh={() => refreshPane('neighbors')}
disabled={anyLoading}
contacts={contacts}
radioLat={radioLat}
radioLon={radioLon}
radioName={radioName}
/>
<div className="flex flex-col gap-4">
<NeighborsPane
data={paneData.neighbors}
state={paneStates.neighbors}
onRefresh={() => refreshPane('neighbors')}
disabled={anyLoading}
contacts={contacts}
nodeInfo={paneData.nodeInfo}
nodeInfoState={paneStates.nodeInfo}
repeaterName={conversation.name}
/>
</div>
</div>
{/* Remaining panes: ACL | Owner Info + Actions */}

View File

@@ -2,7 +2,13 @@ import { useMemo, lazy, Suspense } from 'react';
import { cn } from '@/lib/utils';
import { RepeaterPane, NotFetched, formatDuration } from './repeaterPaneShared';
import { isValidLocation, calculateDistance, formatDistance } from '../../utils/pathUtils';
import type { Contact, RepeaterNeighborsResponse, PaneState, NeighborInfo } from '../../types';
import type {
Contact,
RepeaterNeighborsResponse,
PaneState,
NeighborInfo,
RepeaterNodeInfoResponse,
} from '../../types';
const NeighborsMiniMap = lazy(() =>
import('../NeighborsMiniMap').then((m) => ({ default: m.NeighborsMiniMap }))
@@ -14,19 +20,35 @@ export function NeighborsPane({
onRefresh,
disabled,
contacts,
radioLat,
radioLon,
radioName,
nodeInfo,
nodeInfoState,
repeaterName,
}: {
data: RepeaterNeighborsResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
contacts: Contact[];
radioLat: number | null;
radioLon: number | null;
radioName: string | null;
nodeInfo: RepeaterNodeInfoResponse | null;
nodeInfoState: PaneState;
repeaterName: string | null;
}) {
const radioLat = useMemo(() => {
const parsed = nodeInfo?.lat != null ? parseFloat(nodeInfo.lat) : null;
return Number.isFinite(parsed) ? parsed : null;
}, [nodeInfo?.lat]);
const radioLon = useMemo(() => {
const parsed = nodeInfo?.lon != null ? parseFloat(nodeInfo.lon) : null;
return Number.isFinite(parsed) ? parsed : null;
}, [nodeInfo?.lon]);
const radioName = nodeInfo?.name || repeaterName;
const hasValidRepeaterGps = isValidLocation(radioLat, radioLon);
const showGpsUnavailableMessage =
!hasValidRepeaterGps &&
(nodeInfoState.error !== null || nodeInfoState.fetched_at != null || nodeInfo !== 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(() => {
@@ -48,7 +70,7 @@ export function NeighborsPane({
const nLon = contact?.lon ?? null;
let dist: string | null = null;
if (isValidLocation(radioLat, radioLon) && isValidLocation(nLat, nLon)) {
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
const distKm = calculateDistance(radioLat, radioLon, nLat, nLon);
if (distKm != null) {
dist = formatDistance(distKm);
@@ -69,7 +91,7 @@ export function NeighborsPane({
sorted: enriched,
hasDistances: anyDist,
};
}, [data, contacts, radioLat, radioLon]);
}, [contacts, data, hasValidRepeaterGps, radioLat, radioLon]);
return (
<RepeaterPane
@@ -120,7 +142,7 @@ export function NeighborsPane({
</tbody>
</table>
</div>
{(neighborsWithCoords.length > 0 || isValidLocation(radioLat, radioLon)) && (
{hasValidRepeaterGps && (neighborsWithCoords.length > 0 || hasValidRepeaterGps) ? (
<Suspense
fallback={
<div className="h-48 flex items-center justify-center text-xs text-muted-foreground">
@@ -136,7 +158,13 @@ export function NeighborsPane({
radioName={radioName}
/>
</Suspense>
)}
) : showGpsUnavailableMessage ? (
<div className="rounded border border-border/70 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
GPS info failed to fetch; map and distance data not available. This may be due to
missing or zero-zero GPS data on the repeater, or due to transient fetch failure. Try
refreshing.
</div>
) : null}
</div>
)}
</RepeaterPane>

View File

@@ -0,0 +1,55 @@
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { RepeaterPane, NotFetched, KvRow, formatClockDrift } from './repeaterPaneShared';
import type { RepeaterNodeInfoResponse, PaneState } from '../../types';
export function NodeInfoPane({
data,
state,
onRefresh,
disabled,
}: {
data: RepeaterNodeInfoResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
}) {
const clockDrift = useMemo(() => {
if (!data?.clock_utc) return null;
return formatClockDrift(data.clock_utc);
}, [data?.clock_utc]);
return (
<RepeaterPane title="Node Info" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : (
<div>
<KvRow label="Name" value={data.name ?? '—'} />
<KvRow
label="Lat / Lon"
value={
data.lat != null || data.lon != null ? `${data.lat ?? '—'}, ${data.lon ?? '—'}` : '—'
}
/>
<div className="flex justify-between text-sm py-0.5">
<span className="text-muted-foreground">Clock (UTC)</span>
<span>
{data.clock_utc ?? '—'}
{clockDrift && (
<span
className={cn(
'ml-2 text-xs',
clockDrift.isLarge ? 'text-destructive' : 'text-muted-foreground'
)}
>
(drift: {clockDrift.text})
</span>
)}
</span>
</div>
</div>
)}
</RepeaterPane>
);
}

View File

@@ -1,4 +1,3 @@
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { Separator } from '../ui/separator';
import {
@@ -6,7 +5,6 @@ import {
RefreshIcon,
NotFetched,
KvRow,
formatClockDrift,
formatAdvertInterval,
} from './repeaterPaneShared';
import type {
@@ -15,6 +13,35 @@ import type {
PaneState,
} from '../../types';
function formatRadioTuple(radio: string | null): { display: string; raw: string | null } {
if (radio == null) {
return { display: '—', raw: null };
}
const trimmed = radio.trim();
const parts = trimmed.split(',').map((part) => part.trim());
if (parts.length !== 4) {
return { display: trimmed || '—', raw: trimmed || null };
}
const [freqRaw, bwRaw, sfRaw, crRaw] = parts;
const freq = Number.parseFloat(freqRaw);
const bw = Number.parseFloat(bwRaw);
const sf = Number.parseInt(sfRaw, 10);
const cr = Number.parseInt(crRaw, 10);
if (![freq, bw, sf, cr].every(Number.isFinite)) {
return { display: trimmed || '—', raw: trimmed || null };
}
const formattedFreq = Number(freq.toFixed(3)).toString();
const formattedBw = Number(bw.toFixed(3)).toString();
return {
display: `${formattedFreq} MHz, BW ${formattedBw} kHz, SF${sf}, CR${cr}`,
raw: trimmed,
};
}
export function RadioSettingsPane({
data,
state,
@@ -32,10 +59,7 @@ export function RadioSettingsPane({
advertState: PaneState;
onRefreshAdvert: () => void;
}) {
const clockDrift = useMemo(() => {
if (!data?.clock_utc) return null;
return formatClockDrift(data.clock_utc);
}, [data?.clock_utc]);
const formattedRadio = formatRadioTuple(data?.radio ?? null);
return (
<RepeaterPane title="Radio Settings" state={state} onRefresh={onRefresh} disabled={disabled}>
@@ -44,36 +68,14 @@ export function RadioSettingsPane({
) : (
<div>
<KvRow label="Firmware" value={data.firmware_version ?? '—'} />
<KvRow label="Radio" value={data.radio ?? '—'} />
<KvRow
label="Radio"
value={<span title={formattedRadio.raw ?? undefined}>{formattedRadio.display}</span>}
/>
<KvRow label="TX Power" value={data.tx_power != null ? `${data.tx_power} dBm` : '—'} />
<KvRow label="Airtime Factor" value={data.airtime_factor ?? '—'} />
<KvRow label="Repeat Mode" value={data.repeat_enabled ?? '—'} />
<KvRow label="Max Flood Hops" value={data.flood_max ?? '—'} />
<Separator className="my-1" />
<KvRow label="Name" value={data.name ?? '—'} />
<KvRow
label="Lat / Lon"
value={
data.lat != null || data.lon != null ? `${data.lat ?? '—'}, ${data.lon ?? '—'}` : '—'
}
/>
<Separator className="my-1" />
<div className="flex justify-between text-sm py-0.5">
<span className="text-muted-foreground">Clock (UTC)</span>
<span>
{data.clock_utc ?? '—'}
{clockDrift && (
<span
className={cn(
'ml-2 text-xs',
clockDrift.isLarge ? 'text-destructive' : 'text-muted-foreground'
)}
>
(drift: {clockDrift.text})
</span>
)}
</span>
</div>
</div>
)}
{/* Advert Intervals sub-section */}

View File

@@ -8,6 +8,7 @@ import type {
RepeaterStatusResponse,
RepeaterNeighborsResponse,
RepeaterAclResponse,
RepeaterNodeInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterAdvertIntervalsResponse,
RepeaterOwnerInfoResponse,
@@ -28,6 +29,7 @@ interface ConsoleEntry {
interface PaneData {
status: RepeaterStatusResponse | null;
nodeInfo: RepeaterNodeInfoResponse | null;
neighbors: RepeaterNeighborsResponse | null;
acl: RepeaterAclResponse | null;
radioSettings: RepeaterRadioSettingsResponse | null;
@@ -49,6 +51,7 @@ const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null,
function createInitialPaneStates(): Record<PaneName, PaneState> {
return {
status: { ...INITIAL_PANE_STATE },
nodeInfo: { ...INITIAL_PANE_STATE },
neighbors: { ...INITIAL_PANE_STATE },
acl: { ...INITIAL_PANE_STATE },
radioSettings: { ...INITIAL_PANE_STATE },
@@ -61,6 +64,7 @@ function createInitialPaneStates(): Record<PaneName, PaneState> {
function createInitialPaneData(): PaneData {
return {
status: null,
nodeInfo: null,
neighbors: null,
acl: null,
radioSettings: null,
@@ -79,6 +83,7 @@ function clonePaneData(data: PaneData): PaneData {
function normalizePaneStates(paneStates: Record<PaneName, PaneState>): Record<PaneName, PaneState> {
return {
status: { ...paneStates.status, loading: false },
nodeInfo: { ...paneStates.nodeInfo, loading: false },
neighbors: { ...paneStates.neighbors, loading: false },
acl: { ...paneStates.acl, loading: false },
radioSettings: { ...paneStates.radioSettings, loading: false },
@@ -136,6 +141,8 @@ function fetchPaneData(publicKey: string, pane: PaneName) {
switch (pane) {
case 'status':
return api.repeaterStatus(publicKey);
case 'nodeInfo':
return api.repeaterNodeInfo(publicKey);
case 'neighbors':
return api.repeaterNeighbors(publicKey);
case 'acl':
@@ -187,6 +194,10 @@ export function useRepeaterDashboard(
const [paneStates, setPaneStates] = useState<Record<PaneName, PaneState>>(
cachedState?.paneStates ?? createInitialPaneStates
);
const paneDataRef = useRef<PaneData>(cachedState?.paneData ?? createInitialPaneData());
const paneStatesRef = useRef<Record<PaneName, PaneState>>(
cachedState?.paneStates ?? createInitialPaneStates()
);
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>(
cachedState?.consoleHistory ?? []
@@ -222,6 +233,14 @@ export function useRepeaterDashboard(
});
}, [consoleHistory, conversationId, loggedIn, loginError, paneData, paneStates]);
useEffect(() => {
paneDataRef.current = paneData;
}, [paneData]);
useEffect(() => {
paneStatesRef.current = paneStates;
}, [paneStates]);
const getPublicKey = useCallback((): string | null => {
if (!activeConversation || activeConversation.type !== 'contact') return null;
return activeConversation.id;
@@ -262,27 +281,60 @@ export function useRepeaterDashboard(
if (!publicKey) return;
const conversationId = publicKey;
if (pane === 'neighbors') {
const nodeInfoState = paneStatesRef.current.nodeInfo;
const nodeInfoData = paneDataRef.current.nodeInfo;
const needsNodeInfoPrefetch =
nodeInfoState.error !== null ||
(nodeInfoState.fetched_at == null && nodeInfoData == null);
if (needsNodeInfoPrefetch) {
await refreshPane('nodeInfo');
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
}
}
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
const loadingState = {
loading: true,
attempt,
error: null,
fetched_at: paneStatesRef.current[pane].fetched_at ?? null,
};
paneStatesRef.current = {
...paneStatesRef.current,
[pane]: loadingState,
};
setPaneStates((prev) => ({
...prev,
[pane]: {
loading: true,
attempt,
error: null,
fetched_at: prev[pane].fetched_at ?? null,
},
[pane]: loadingState,
}));
try {
const data = await fetchPaneData(publicKey, pane);
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
paneDataRef.current = {
...paneDataRef.current,
[pane]: data,
};
const successState = {
loading: false,
attempt,
error: null,
fetched_at: Date.now(),
};
paneStatesRef.current = {
...paneStatesRef.current,
[pane]: successState,
};
setPaneData((prev) => ({ ...prev, [pane]: data }));
setPaneStates((prev) => ({
...prev,
[pane]: { loading: false, attempt, error: null, fetched_at: Date.now() },
[pane]: successState,
}));
return; // Success
} catch (err) {
@@ -291,14 +343,19 @@ export function useRepeaterDashboard(
const msg = err instanceof Error ? err.message : 'Request failed';
if (attempt === MAX_RETRIES) {
const errorState = {
loading: false,
attempt,
error: msg,
fetched_at: paneStatesRef.current[pane].fetched_at ?? null,
};
paneStatesRef.current = {
...paneStatesRef.current,
[pane]: errorState,
};
setPaneStates((prev) => ({
...prev,
[pane]: {
loading: false,
attempt,
error: msg,
fetched_at: prev[pane].fetched_at ?? null,
},
[pane]: errorState,
}));
toast.error(`Failed to fetch ${pane}`, { description: msg });
} else {
@@ -314,9 +371,10 @@ export function useRepeaterDashboard(
const loadAll = useCallback(async () => {
const panes: PaneName[] = [
'status',
'nodeInfo',
'neighbors',
'acl',
'radioSettings',
'acl',
'advertIntervals',
'ownerInfo',
'lppTelemetry',

View File

@@ -13,6 +13,7 @@ const mockHook: {
loginError: null,
paneData: {
status: null,
nodeInfo: null,
neighbors: null,
acl: null,
radioSettings: null,
@@ -23,6 +24,7 @@ const mockHook: {
},
paneStates: {
status: { loading: false, attempt: 0, error: null },
nodeInfo: { 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 },
@@ -63,6 +65,7 @@ vi.mock('react-leaflet', () => ({
TileLayer: () => null,
CircleMarker: () => null,
Popup: () => null,
Polyline: () => null,
}));
const REPEATER_KEY = 'aa'.repeat(32);
@@ -120,6 +123,7 @@ describe('RepeaterDashboard', () => {
mockHook.loginError = null;
mockHook.paneData = {
status: null,
nodeInfo: null,
neighbors: null,
acl: null,
radioSettings: null,
@@ -130,6 +134,7 @@ describe('RepeaterDashboard', () => {
};
mockHook.paneStates = {
status: { loading: false, attempt: 0, error: null },
nodeInfo: { 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 },
@@ -157,6 +162,7 @@ describe('RepeaterDashboard', () => {
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Telemetry')).toBeInTheDocument();
expect(screen.getByText('Node Info')).toBeInTheDocument();
expect(screen.getByText('Neighbors')).toBeInTheDocument();
expect(screen.getByText('ACL')).toBeInTheDocument();
expect(screen.getByText('Radio Settings')).toBeInTheDocument();
@@ -226,6 +232,102 @@ describe('RepeaterDashboard', () => {
expect(screen.getByText('Timeout')).toBeInTheDocument();
});
it('shows GPS unavailable message for neighbors when repeater coords are missing', () => {
mockHook.loggedIn = true;
mockHook.paneData.neighbors = {
neighbors: [
{ pubkey_prefix: 'bbbbbbbbbbbb', name: 'Neighbor', snr: 7.2, last_heard_seconds: 9 },
],
};
mockHook.paneData.nodeInfo = {
name: 'TestRepeater',
lat: '0',
lon: '0',
clock_utc: null,
};
mockHook.paneStates.neighbors = {
loading: false,
attempt: 1,
error: null,
fetched_at: Date.now(),
};
mockHook.paneStates.nodeInfo = {
loading: false,
attempt: 1,
error: null,
fetched_at: Date.now(),
};
render(<RepeaterDashboard {...defaultProps} />);
expect(
screen.getByText(
'GPS info failed to fetch; map and distance data not available. This may be due to missing or zero-zero GPS data on the repeater, or due to transient fetch failure. Try refreshing.'
)
).toBeInTheDocument();
expect(screen.queryByText('Dist')).not.toBeInTheDocument();
});
it('shows neighbor distance when repeater radio settings include valid coords', () => {
mockHook.loggedIn = true;
mockHook.paneData.neighbors = {
neighbors: [
{ pubkey_prefix: 'bbbbbbbbbbbb', name: 'Neighbor', snr: 7.2, last_heard_seconds: 9 },
],
};
mockHook.paneData.nodeInfo = {
name: 'TestRepeater',
lat: '-31.9500',
lon: '115.8600',
clock_utc: null,
};
mockHook.paneStates.neighbors = {
loading: false,
attempt: 1,
error: null,
fetched_at: Date.now(),
};
mockHook.paneStates.nodeInfo = {
loading: false,
attempt: 1,
error: null,
fetched_at: Date.now(),
};
const contactsWithNeighbor = [
...contacts,
{
public_key: 'bbbbbbbbbbbb0000000000000000000000000000000000000000000000000000',
name: 'Neighbor',
type: 1,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,
last_advert: null,
lat: -31.94,
lon: 115.87,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
];
render(<RepeaterDashboard {...defaultProps} contacts={contactsWithNeighbor} />);
expect(screen.getByText('Dist')).toBeInTheDocument();
expect(
screen.queryByText(
'GPS info failed to fetch; map and distance data not available. This may be due to missing or zero-zero GPS data on the repeater, or due to transient fetch failure. Try refreshing.'
)
).not.toBeInTheDocument();
});
it('shows fetching state with attempt counter', () => {
mockHook.loggedIn = true;
mockHook.paneStates.status = { loading: true, attempt: 2, error: null };
@@ -264,6 +366,24 @@ describe('RepeaterDashboard', () => {
expect(screen.getByText('7.5 dB')).toBeInTheDocument();
});
it('formats the radio tuple and preserves the raw tuple in a tooltip', () => {
mockHook.loggedIn = true;
mockHook.paneData.radioSettings = {
firmware_version: 'v1.0',
radio: '910.5250244,62.5,7,5',
tx_power: '20',
airtime_factor: '0',
repeat_enabled: '1',
flood_max: '3',
};
render(<RepeaterDashboard {...defaultProps} />);
const formatted = screen.getByText('910.525 MHz, BW 62.5 kHz, SF7, CR5');
expect(formatted).toBeInTheDocument();
expect(formatted).toHaveAttribute('title', '910.5250244,62.5,7,5');
});
it('shows fetched time and relative age when pane data has been loaded', () => {
mockHook.loggedIn = true;
mockHook.paneStates.status = {

View File

@@ -12,6 +12,7 @@ vi.mock('../api', () => ({
api: {
repeaterLogin: vi.fn(),
repeaterStatus: vi.fn(),
repeaterNodeInfo: vi.fn(),
repeaterNeighbors: vi.fn(),
repeaterAcl: vi.fn(),
repeaterRadioSettings: vi.fn(),
@@ -284,8 +285,12 @@ describe('useRepeaterDashboard', () => {
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.repeaterNodeInfo.mockResolvedValueOnce({
name: null,
lat: null,
lon: null,
clock_utc: null,
});
mockApi.repeaterRadioSettings.mockResolvedValueOnce({
firmware_version: 'v1.0',
radio: null,
@@ -293,11 +298,9 @@ describe('useRepeaterDashboard', () => {
airtime_factor: null,
repeat_enabled: null,
flood_max: null,
name: null,
lat: null,
lon: null,
clock_utc: null,
});
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
mockApi.repeaterAcl.mockResolvedValueOnce({ acl: [] });
mockApi.repeaterAdvertIntervals.mockResolvedValueOnce({
advert_interval: null,
flood_advert_interval: null,
@@ -315,6 +318,7 @@ describe('useRepeaterDashboard', () => {
});
expect(mockApi.repeaterStatus).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterNodeInfo).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterNeighbors).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterAcl).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterRadioSettings).toHaveBeenCalledTimes(1);
@@ -323,6 +327,53 @@ describe('useRepeaterDashboard', () => {
expect(mockApi.repeaterLppTelemetry).toHaveBeenCalledTimes(1);
});
it('refreshing neighbors fetches node info first', async () => {
mockApi.repeaterNodeInfo.mockResolvedValueOnce({
name: 'Repeater',
lat: '-31.9523',
lon: '115.8613',
clock_utc: null,
});
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.refreshPane('neighbors');
});
expect(mockApi.repeaterNodeInfo).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterNeighbors).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterNodeInfo.mock.invocationCallOrder[0]).toBeLessThan(
mockApi.repeaterNeighbors.mock.invocationCallOrder[0]
);
expect(result.current.paneData.nodeInfo?.lat).toBe('-31.9523');
expect(result.current.paneData.neighbors).toEqual({ neighbors: [] });
});
it('refreshing neighbors reuses already-fetched node info', async () => {
mockApi.repeaterNodeInfo.mockResolvedValueOnce({
name: 'Repeater',
lat: '-31.9523',
lon: '115.8613',
clock_utc: null,
});
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.refreshPane('neighbors');
});
await act(async () => {
await result.current.refreshPane('neighbors');
});
expect(mockApi.repeaterNodeInfo).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterNeighbors).toHaveBeenCalledTimes(2);
});
it('restores dashboard state when navigating away and back to the same repeater', async () => {
const statusData = { battery_volts: 4.2 };
mockApi.repeaterLogin.mockResolvedValueOnce({ status: 'ok' });

View File

@@ -356,6 +356,13 @@ export interface RepeaterAclResponse {
acl: AclEntry[];
}
export interface RepeaterNodeInfoResponse {
name: string | null;
lat: string | null;
lon: string | null;
clock_utc: string | null;
}
export interface RepeaterRadioSettingsResponse {
firmware_version: string | null;
radio: string | null;
@@ -363,10 +370,6 @@ export interface RepeaterRadioSettingsResponse {
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 {
@@ -391,6 +394,7 @@ export interface RepeaterLppTelemetryResponse {
export type PaneName =
| 'status'
| 'nodeInfo'
| 'neighbors'
| 'acl'
| 'radioSettings'

View File

@@ -18,6 +18,7 @@ from app.routers.repeaters import (
repeater_login,
repeater_lpp_telemetry,
repeater_neighbors,
repeater_node_info,
repeater_owner_info,
repeater_radio_settings,
repeater_status,
@@ -848,7 +849,7 @@ class TestRepeaterRadioSettings:
mc = _mock_mc()
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
# Build responses for all 10 commands
# Build responses for all 6 commands
responses = [
"v2.1.0", # ver
"915.0,250,7,5", # get radio
@@ -856,10 +857,6 @@ class TestRepeaterRadioSettings:
"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(
@@ -883,10 +880,6 @@ class TestRepeaterRadioSettings:
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):
@@ -903,7 +896,7 @@ class TestRepeaterRadioSettings:
# Provide clock ticks: first command succeeds quickly, others expire
clock_ticks = [0.0, 0.1] # First fetch succeeds
for i in range(9):
for i in range(5):
base = 100.0 * (i + 1)
clock_ticks.extend([base, base + 5.0, base + 11.0])
@@ -932,6 +925,70 @@ class TestRepeaterRadioSettings:
assert exc.value.status_code == 400
class TestRepeaterNodeInfo:
@pytest.mark.asyncio
async def test_full_success(self, test_db):
mc = _mock_mc()
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
responses = [
"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.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=_advancing_clock()),
):
response = await repeater_node_info(KEY_A)
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_response = _radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": KEY_A[:12], "text": "MyRepeater", "txt_type": 1},
)
no_msgs = _radio_result(EventType.NO_MORE_MSGS)
mc.commands.get_msg = AsyncMock(side_effect=[first_response] + [no_msgs] * 50)
clock_ticks = [0.0, 0.1]
for i in range(3):
base = 100.0 * (i + 1)
clock_ticks.extend([base, base + 5.0, base + 11.0])
with (
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=clock_ticks),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
):
response = await repeater_node_info(KEY_A)
assert response.name == "MyRepeater"
assert response.lat is None
assert response.lon is None
assert response.clock_utc is None
class TestRepeaterAdvertIntervals:
@pytest.mark.asyncio
async def test_success(self, test_db):