mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Make repeater neighbor display need a GPS fix to show map + distance, and fetch before display. Closes #58.
This commit is contained in:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
55
frontend/src/components/repeater/RepeaterNodeInfoPane.tsx
Normal file
55
frontend/src/components/repeater/RepeaterNodeInfoPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user