mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-06 01:42:11 +02:00
Use advert position if we don't have a from-repeater-stats lat/lon
This commit is contained in:
@@ -8,6 +8,7 @@ import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { isValidLocation } from '../utils/pathUtils';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
|
||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||
@@ -60,6 +61,8 @@ export function RepeaterDashboard({
|
||||
onDeleteContact,
|
||||
}: RepeaterDashboardProps) {
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
||||
const hasAdvertLocation = isValidLocation(contact?.lat ?? null, contact?.lon ?? null);
|
||||
const {
|
||||
loggedIn,
|
||||
loginLoading,
|
||||
@@ -77,9 +80,8 @@ export function RepeaterDashboard({
|
||||
sendFloodAdvert,
|
||||
rebootRepeater,
|
||||
syncClock,
|
||||
} = useRepeaterDashboard(conversation);
|
||||
} = useRepeaterDashboard(conversation, { hasAdvertLocation });
|
||||
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id);
|
||||
const isFav = isFavorite(favorites, 'contact', conversation.id);
|
||||
|
||||
// Loading all panes indicator
|
||||
@@ -261,6 +263,7 @@ export function RepeaterDashboard({
|
||||
state={paneStates.neighbors}
|
||||
onRefresh={() => refreshPane('neighbors')}
|
||||
disabled={anyLoading}
|
||||
repeaterContact={contact}
|
||||
contacts={contacts}
|
||||
nodeInfo={paneData.nodeInfo}
|
||||
nodeInfoState={paneStates.nodeInfo}
|
||||
|
||||
@@ -19,6 +19,7 @@ export function NeighborsPane({
|
||||
state,
|
||||
onRefresh,
|
||||
disabled,
|
||||
repeaterContact,
|
||||
contacts,
|
||||
nodeInfo,
|
||||
nodeInfoState,
|
||||
@@ -28,11 +29,15 @@ export function NeighborsPane({
|
||||
state: PaneState;
|
||||
onRefresh: () => void;
|
||||
disabled?: boolean;
|
||||
repeaterContact: Contact | null;
|
||||
contacts: Contact[];
|
||||
nodeInfo: RepeaterNodeInfoResponse | null;
|
||||
nodeInfoState: PaneState;
|
||||
repeaterName: string | null;
|
||||
}) {
|
||||
const advertLat = repeaterContact?.lat ?? null;
|
||||
const advertLon = repeaterContact?.lon ?? null;
|
||||
|
||||
const radioLat = useMemo(() => {
|
||||
const parsed = nodeInfo?.lat != null ? parseFloat(nodeInfo.lat) : null;
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
@@ -43,11 +48,26 @@ export function NeighborsPane({
|
||||
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);
|
||||
const positionSource = useMemo(() => {
|
||||
if (isValidLocation(radioLat, radioLon)) {
|
||||
return { lat: radioLat, lon: radioLon, source: 'reported' as const };
|
||||
}
|
||||
if (isValidLocation(advertLat, advertLon)) {
|
||||
return { lat: advertLat, lon: advertLon, source: 'advert' as const };
|
||||
}
|
||||
return { lat: null, lon: null, source: null };
|
||||
}, [advertLat, advertLon, radioLat, radioLon]);
|
||||
|
||||
const radioName = nodeInfo?.name || repeaterContact?.name || repeaterName;
|
||||
const hasValidRepeaterGps = positionSource.source !== null;
|
||||
const headerNote =
|
||||
positionSource.source === 'reported'
|
||||
? 'Using repeater-reported position'
|
||||
: positionSource.source === 'advert'
|
||||
? 'Using advert position'
|
||||
: nodeInfoState.loading
|
||||
? 'Waiting for repeater position'
|
||||
: 'No repeater position available';
|
||||
|
||||
// Resolve contact data for each neighbor in a single pass — used for
|
||||
// coords (mini-map), distances (table column), and sorted display order.
|
||||
@@ -71,7 +91,7 @@ export function NeighborsPane({
|
||||
|
||||
let dist: string | null = null;
|
||||
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
|
||||
const distKm = calculateDistance(radioLat, radioLon, nLat, nLon);
|
||||
const distKm = calculateDistance(positionSource.lat, positionSource.lon, nLat, nLon);
|
||||
if (distKm != null) {
|
||||
dist = formatDistance(distKm);
|
||||
anyDist = true;
|
||||
@@ -91,11 +111,12 @@ export function NeighborsPane({
|
||||
sorted: enriched,
|
||||
hasDistances: anyDist,
|
||||
};
|
||||
}, [contacts, data, hasValidRepeaterGps, radioLat, radioLon]);
|
||||
}, [contacts, data, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
|
||||
|
||||
return (
|
||||
<RepeaterPane
|
||||
title="Neighbors"
|
||||
headerNote={headerNote}
|
||||
state={state}
|
||||
onRefresh={onRefresh}
|
||||
disabled={disabled}
|
||||
@@ -153,18 +174,17 @@ export function NeighborsPane({
|
||||
<NeighborsMiniMap
|
||||
key={neighborsWithCoords.map((n) => n.pubkey_prefix).join(',')}
|
||||
neighbors={neighborsWithCoords}
|
||||
radioLat={radioLat}
|
||||
radioLon={radioLon}
|
||||
radioLat={positionSource.lat}
|
||||
radioLon={positionSource.lon}
|
||||
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.
|
||||
Map and distance data are unavailable until this repeater has a valid position from
|
||||
either its advert or a Node Info fetch.
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</RepeaterPane>
|
||||
|
||||
@@ -109,6 +109,7 @@ function formatFetchedTime(fetchedAt: number): string {
|
||||
|
||||
export function RepeaterPane({
|
||||
title,
|
||||
headerNote,
|
||||
state,
|
||||
onRefresh,
|
||||
disabled,
|
||||
@@ -117,6 +118,7 @@ export function RepeaterPane({
|
||||
contentClassName,
|
||||
}: {
|
||||
title: string;
|
||||
headerNote?: ReactNode;
|
||||
state: PaneState;
|
||||
onRefresh?: () => void;
|
||||
disabled?: boolean;
|
||||
@@ -131,6 +133,7 @@ export function RepeaterPane({
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{headerNote && <p className="text-[11px] text-muted-foreground">{headerNote}</p>}
|
||||
{fetchedAt && (
|
||||
<p
|
||||
className="text-[11px] text-muted-foreground"
|
||||
|
||||
@@ -188,8 +188,13 @@ export interface UseRepeaterDashboardResult {
|
||||
syncClock: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface UseRepeaterDashboardOptions {
|
||||
hasAdvertLocation?: boolean;
|
||||
}
|
||||
|
||||
export function useRepeaterDashboard(
|
||||
activeConversation: Conversation | null
|
||||
activeConversation: Conversation | null,
|
||||
options: UseRepeaterDashboardOptions = {}
|
||||
): UseRepeaterDashboardResult {
|
||||
const conversationId =
|
||||
activeConversation && activeConversation.type === 'contact' ? activeConversation.id : null;
|
||||
@@ -301,7 +306,7 @@ export function useRepeaterDashboard(
|
||||
if (!publicKey) return;
|
||||
const conversationId = publicKey;
|
||||
|
||||
if (pane === 'neighbors') {
|
||||
if (pane === 'neighbors' && !options.hasAdvertLocation) {
|
||||
const nodeInfoState = paneStatesRef.current.nodeInfo;
|
||||
const nodeInfoData = paneDataRef.current.nodeInfo;
|
||||
const needsNodeInfoPrefetch =
|
||||
@@ -385,7 +390,7 @@ export function useRepeaterDashboard(
|
||||
}
|
||||
}
|
||||
},
|
||||
[getPublicKey]
|
||||
[getPublicKey, options.hasAdvertLocation]
|
||||
);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
|
||||
@@ -265,13 +265,14 @@ describe('RepeaterDashboard', () => {
|
||||
|
||||
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.'
|
||||
'Map and distance data are unavailable until this repeater has a valid position from either its advert or a Node Info fetch.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('No repeater position available')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Dist')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows neighbor distance when repeater radio settings include valid coords', () => {
|
||||
it('shows neighbor distance when repeater node info includes valid coords', () => {
|
||||
mockHook.loggedIn = true;
|
||||
mockHook.paneData.neighbors = {
|
||||
neighbors: [
|
||||
@@ -324,13 +325,69 @@ describe('RepeaterDashboard', () => {
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={contactsWithNeighbor} />);
|
||||
|
||||
expect(screen.getByText('Dist')).toBeInTheDocument();
|
||||
expect(screen.getByText('Using repeater-reported position')).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.'
|
||||
'Map and distance data are unavailable until this repeater has a valid position from either its advert or a Node Info fetch.'
|
||||
)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses advert coords for neighbor distance when node info is unavailable', () => {
|
||||
mockHook.loggedIn = true;
|
||||
mockHook.paneData.neighbors = {
|
||||
neighbors: [
|
||||
{ pubkey_prefix: 'bbbbbbbbbbbb', name: 'Neighbor', snr: 7.2, last_heard_seconds: 9 },
|
||||
],
|
||||
};
|
||||
mockHook.paneData.nodeInfo = null;
|
||||
mockHook.paneStates.neighbors = {
|
||||
loading: false,
|
||||
attempt: 1,
|
||||
error: null,
|
||||
fetched_at: Date.now(),
|
||||
};
|
||||
mockHook.paneStates.nodeInfo = {
|
||||
loading: false,
|
||||
attempt: 0,
|
||||
error: null,
|
||||
fetched_at: null,
|
||||
};
|
||||
|
||||
const contactsWithAdvertAndNeighbor = [
|
||||
{
|
||||
...contacts[0],
|
||||
lat: -31.95,
|
||||
lon: 115.86,
|
||||
},
|
||||
{
|
||||
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={contactsWithAdvertAndNeighbor} />);
|
||||
|
||||
expect(screen.getByText('Dist')).toBeInTheDocument();
|
||||
expect(screen.getByText('Using advert position')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fetching state with attempt counter', () => {
|
||||
mockHook.loggedIn = true;
|
||||
mockHook.paneStates.status = { loading: true, attempt: 2, error: null };
|
||||
|
||||
@@ -408,6 +408,22 @@ describe('useRepeaterDashboard', () => {
|
||||
expect(mockApi.repeaterNeighbors).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('refreshing neighbors skips node info prefetch when advert location already exists', async () => {
|
||||
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useRepeaterDashboard(repeaterConversation, { hasAdvertLocation: true })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshPane('neighbors');
|
||||
});
|
||||
|
||||
expect(mockApi.repeaterNodeInfo).not.toHaveBeenCalled();
|
||||
expect(mockApi.repeaterNeighbors).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.paneData.neighbors).toEqual({ neighbors: [] });
|
||||
});
|
||||
|
||||
it('restores dashboard state when navigating away and back to the same repeater', async () => {
|
||||
const statusData = { battery_volts: 4.2 };
|
||||
mockApi.repeaterLogin.mockResolvedValueOnce({
|
||||
|
||||
Reference in New Issue
Block a user