diff --git a/AGENTS.md b/AGENTS.md index d4a0e22..bea6153 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/app/AGENTS.md b/app/AGENTS.md index 022bbd7..4ed5c20 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -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` diff --git a/app/models.py b/app/models.py index 804b0ed..a8f1cea 100644 --- a/app/models.py +++ b/app/models.py @@ -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): diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index 7817446..2cbd506 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -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) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 569359d..b516403 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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`. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8b1a678..13caa93 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -25,6 +25,7 @@ import type { RepeaterLoginResponse, RepeaterLppTelemetryResponse, RepeaterNeighborsResponse, + RepeaterNodeInfoResponse, RepeaterOwnerInfoResponse, RepeaterRadioSettingsResponse, RepeaterStatusResponse, @@ -352,6 +353,10 @@ export const api = { fetchJson(`/contacts/${publicKey}/repeater/neighbors`, { method: 'POST', }), + repeaterNodeInfo: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/repeater/node-info`, { + method: 'POST', + }), repeaterAcl: (publicKey: string) => fetchJson(`/contacts/${publicKey}/repeater/acl`, { method: 'POST', diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index cb65517..a70620e 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -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({ /> ) : (
- {/* Top row: Telemetry + Radio Settings | Neighbors (with expanding map) */} + {/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */}
+ refreshPane('nodeInfo')} + disabled={anyLoading} + />
- refreshPane('neighbors')} - disabled={anyLoading} - contacts={contacts} - radioLat={radioLat} - radioLon={radioLon} - radioName={radioName} - /> +
+ refreshPane('neighbors')} + disabled={anyLoading} + contacts={contacts} + nodeInfo={paneData.nodeInfo} + nodeInfoState={paneStates.nodeInfo} + repeaterName={conversation.name} + /> +
{/* Remaining panes: ACL | Owner Info + Actions */} diff --git a/frontend/src/components/repeater/RepeaterNeighborsPane.tsx b/frontend/src/components/repeater/RepeaterNeighborsPane.tsx index 3d51732..000e690 100644 --- a/frontend/src/components/repeater/RepeaterNeighborsPane.tsx +++ b/frontend/src/components/repeater/RepeaterNeighborsPane.tsx @@ -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 (
- {(neighborsWithCoords.length > 0 || isValidLocation(radioLat, radioLon)) && ( + {hasValidRepeaterGps && (neighborsWithCoords.length > 0 || hasValidRepeaterGps) ? ( @@ -136,7 +158,13 @@ export function NeighborsPane({ radioName={radioName} /> - )} + ) : showGpsUnavailableMessage ? ( +
+ 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. +
+ ) : null} )} diff --git a/frontend/src/components/repeater/RepeaterNodeInfoPane.tsx b/frontend/src/components/repeater/RepeaterNodeInfoPane.tsx new file mode 100644 index 0000000..7c5e63c --- /dev/null +++ b/frontend/src/components/repeater/RepeaterNodeInfoPane.tsx @@ -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 ( + + {!data ? ( + + ) : ( +
+ + +
+ Clock (UTC) + + {data.clock_utc ?? '—'} + {clockDrift && ( + + (drift: {clockDrift.text}) + + )} + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/repeater/RepeaterRadioSettingsPane.tsx b/frontend/src/components/repeater/RepeaterRadioSettingsPane.tsx index 2dbe9dc..7a14bee 100644 --- a/frontend/src/components/repeater/RepeaterRadioSettingsPane.tsx +++ b/frontend/src/components/repeater/RepeaterRadioSettingsPane.tsx @@ -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 ( @@ -44,36 +68,14 @@ export function RadioSettingsPane({ ) : (
- + {formattedRadio.display}} + /> - - - - -
- Clock (UTC) - - {data.clock_utc ?? '—'} - {clockDrift && ( - - (drift: {clockDrift.text}) - - )} - -
)} {/* Advert Intervals sub-section */} diff --git a/frontend/src/hooks/useRepeaterDashboard.ts b/frontend/src/hooks/useRepeaterDashboard.ts index 99ceeb8..d9df745 100644 --- a/frontend/src/hooks/useRepeaterDashboard.ts +++ b/frontend/src/hooks/useRepeaterDashboard.ts @@ -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 { 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 { 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): Record { 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>( cachedState?.paneStates ?? createInitialPaneStates ); + const paneDataRef = useRef(cachedState?.paneData ?? createInitialPaneData()); + const paneStatesRef = useRef>( + cachedState?.paneStates ?? createInitialPaneStates() + ); const [consoleHistory, setConsoleHistory] = useState( 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', diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index 5cb5b1f..8b64b0a 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -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(); 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(); + + 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(); + + 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(); + + 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 = { diff --git a/frontend/src/test/useRepeaterDashboard.test.ts b/frontend/src/test/useRepeaterDashboard.test.ts index 008e523..d703cbd 100644 --- a/frontend/src/test/useRepeaterDashboard.test.ts +++ b/frontend/src/test/useRepeaterDashboard.test.ts @@ -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' }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 82f13dd..280ced5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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' diff --git a/tests/test_repeater_routes.py b/tests/test_repeater_routes.py index 5238b1f..b33e5c3 100644 --- a/tests/test_repeater_routes.py +++ b/tests/test_repeater_routes.py @@ -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):