mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-06 08:43:36 +02:00
Repeater UI overhaul
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
export { useRepeaterMode } from './useRepeaterMode';
|
||||
export { useUnreadCounts } from './useUnreadCounts';
|
||||
export { useConversationMessages, getMessageContentKey } from './useConversationMessages';
|
||||
export { useRadioControl } from './useRadioControl';
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
/**
|
||||
* Airtime/duty cycle tracking for repeaters.
|
||||
*
|
||||
* When "dutycycle_start" command is issued, this captures baseline telemetry
|
||||
* and polls every 5 minutes to display rolling airtime/duty cycle statistics.
|
||||
*/
|
||||
|
||||
import { useRef, useCallback, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
import type { Message, TelemetryResponse } from '../types';
|
||||
|
||||
// Baseline telemetry snapshot for airtime tracking
|
||||
interface AirtimeBaseline {
|
||||
startTime: number; // epoch seconds
|
||||
uptime: number;
|
||||
txAirtime: number;
|
||||
rxAirtime: number;
|
||||
sentFlood: number;
|
||||
sentDirect: number;
|
||||
recvFlood: number;
|
||||
recvDirect: number;
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
// Polling interval: 5 minutes
|
||||
const AIRTIME_POLL_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
// Format duration in XhXmXs format
|
||||
function formatAirtimeDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${hours}h${mins}m${secs}s`;
|
||||
}
|
||||
|
||||
// Get emoji indicator for TX duty cycle percentage
|
||||
function getTxDutyCycleEmoji(pct: number): string {
|
||||
if (pct <= 5) return '✅';
|
||||
if (pct <= 10) return '🟢';
|
||||
if (pct <= 25) return '🟡';
|
||||
if (pct <= 50) return '🔴';
|
||||
return '🚨';
|
||||
}
|
||||
|
||||
// Format airtime statistics comparing current telemetry to baseline
|
||||
function formatAirtimeStats(baseline: AirtimeBaseline, current: TelemetryResponse): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const wallDuration = now - baseline.startTime;
|
||||
|
||||
// Compute deltas
|
||||
const deltaUptime = current.uptime_seconds - baseline.uptime;
|
||||
const deltaTxAirtime = current.airtime_seconds - baseline.txAirtime;
|
||||
const deltaRxAirtime = current.rx_airtime_seconds - baseline.rxAirtime;
|
||||
const deltaSentFlood = current.sent_flood - baseline.sentFlood;
|
||||
const deltaSentDirect = current.sent_direct - baseline.sentDirect;
|
||||
const deltaRecvFlood = current.recv_flood - baseline.recvFlood;
|
||||
const deltaRecvDirect = current.recv_direct - baseline.recvDirect;
|
||||
|
||||
// Calculate airtime percentages
|
||||
const txPct = deltaUptime > 0 ? (deltaTxAirtime / deltaUptime) * 100 : 0;
|
||||
const rxPct = deltaUptime > 0 ? (deltaRxAirtime / deltaUptime) * 100 : 0;
|
||||
|
||||
// Estimate flood/direct airtime breakdown based on packet proportions
|
||||
const totalSent = deltaSentFlood + deltaSentDirect;
|
||||
const totalRecv = deltaRecvFlood + deltaRecvDirect;
|
||||
|
||||
const txFloodPct = totalSent > 0 ? txPct * (deltaSentFlood / totalSent) : 0;
|
||||
const txDirectPct = totalSent > 0 ? txPct * (deltaSentDirect / totalSent) : 0;
|
||||
const rxFloodPct = totalRecv > 0 ? rxPct * (deltaRecvFlood / totalRecv) : 0;
|
||||
const rxDirectPct = totalRecv > 0 ? rxPct * (deltaRecvDirect / totalRecv) : 0;
|
||||
|
||||
const txEmoji = getTxDutyCycleEmoji(txPct);
|
||||
const idlePct = Math.max(0, 100 - txPct - rxPct);
|
||||
|
||||
const lines = [
|
||||
`Airtime/Duty Cycle Statistics`,
|
||||
`Duration: ${formatAirtimeDuration(wallDuration)} (uptime delta: ${formatAirtimeDuration(deltaUptime)})`,
|
||||
``,
|
||||
`${txEmoji} TX Airtime: ${txPct.toFixed(3)}% (${totalSent.toLocaleString()} pkts)`,
|
||||
` Flood: ${txFloodPct.toFixed(3)}% (${deltaSentFlood.toLocaleString()} pkts)`,
|
||||
` Direct: ${txDirectPct.toFixed(3)}% (${deltaSentDirect.toLocaleString()} pkts)`,
|
||||
``,
|
||||
`RX Airtime: ${rxPct.toFixed(3)}% (${totalRecv.toLocaleString()} pkts)`,
|
||||
` Flood: ${rxFloodPct.toFixed(3)}% (${deltaRecvFlood.toLocaleString()} pkts)`,
|
||||
` Direct: ${rxDirectPct.toFixed(3)}% (${deltaRecvDirect.toLocaleString()} pkts)`,
|
||||
``,
|
||||
`Idle: ${idlePct.toFixed(3)}%`,
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Create a local message object (not persisted to database)
|
||||
function createLocalMessage(conversationKey: string, text: string, outgoing: boolean): Message {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
id: -Date.now(),
|
||||
type: 'PRIV',
|
||||
conversation_key: conversationKey,
|
||||
text,
|
||||
sender_timestamp: now,
|
||||
received_at: now,
|
||||
paths: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing,
|
||||
acked: 1,
|
||||
};
|
||||
}
|
||||
|
||||
interface UseAirtimeTrackingResult {
|
||||
/** Returns true if this was an airtime command that was handled */
|
||||
handleAirtimeCommand: (command: string, conversationId: string) => Promise<boolean>;
|
||||
/** Stop any active airtime tracking */
|
||||
stopTracking: () => void;
|
||||
}
|
||||
|
||||
export function useAirtimeTracking(
|
||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>
|
||||
): UseAirtimeTrackingResult {
|
||||
const baselineRef = useRef<AirtimeBaseline | null>(null);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
// Stop tracking and clear interval
|
||||
const stopTracking = useCallback(() => {
|
||||
if (intervalRef.current !== null) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
baselineRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Poll for airtime stats with one retry on failure
|
||||
const pollAirtimeStats = useCallback(async () => {
|
||||
const baseline = baselineRef.current;
|
||||
if (!baseline) return;
|
||||
|
||||
let telemetry: TelemetryResponse | null = null;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
// Try up to 2 times (initial + 1 retry)
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
telemetry = await api.requestTelemetry(baseline.conversationId, '');
|
||||
break; // Success, exit loop
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error('Unknown error');
|
||||
// Wait a moment before retry
|
||||
if (attempt === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If tracking was stopped while the request was in-flight (e.g. conversation
|
||||
// switch called stopTracking), discard the stale response.
|
||||
if (!baselineRef.current) return;
|
||||
|
||||
if (telemetry) {
|
||||
const statsMessage = createLocalMessage(
|
||||
baseline.conversationId,
|
||||
formatAirtimeStats(baseline, telemetry),
|
||||
false
|
||||
);
|
||||
setMessages((prev) => [...prev, statsMessage]);
|
||||
} else {
|
||||
const errorMessage = createLocalMessage(
|
||||
baseline.conversationId,
|
||||
`Duty cycle poll failed after retry: ${lastError?.message ?? 'Unknown error'}`,
|
||||
false
|
||||
);
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
}
|
||||
}, [setMessages]);
|
||||
|
||||
// Handle airtime commands
|
||||
const handleAirtimeCommand = useCallback(
|
||||
async (command: string, conversationId: string): Promise<boolean> => {
|
||||
const cmd = command.trim().toLowerCase();
|
||||
|
||||
if (cmd === 'dutycycle_start') {
|
||||
// Stop any existing tracking
|
||||
stopTracking();
|
||||
|
||||
// Fetch initial telemetry with one retry
|
||||
let telemetry: TelemetryResponse | null = null;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
telemetry = await api.requestTelemetry(conversationId, '');
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error('Unknown error');
|
||||
if (attempt === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!telemetry) {
|
||||
const errorMessage = createLocalMessage(
|
||||
conversationId,
|
||||
`Failed to start duty cycle tracking after retry: ${lastError?.message ?? 'Unknown error'}`,
|
||||
false
|
||||
);
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Store baseline
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
baselineRef.current = {
|
||||
startTime: now,
|
||||
uptime: telemetry.uptime_seconds,
|
||||
txAirtime: telemetry.airtime_seconds,
|
||||
rxAirtime: telemetry.rx_airtime_seconds,
|
||||
sentFlood: telemetry.sent_flood,
|
||||
sentDirect: telemetry.sent_direct,
|
||||
recvFlood: telemetry.recv_flood,
|
||||
recvDirect: telemetry.recv_direct,
|
||||
conversationId,
|
||||
};
|
||||
|
||||
// Add start message
|
||||
const startMessage = createLocalMessage(
|
||||
conversationId,
|
||||
`Airtime/duty cycle statistics gathering begins at ${now}. Logs will follow every 5 minutes. To stop, run dutycycle_stop or navigate away from this conversation.`,
|
||||
false
|
||||
);
|
||||
setMessages((prev) => [...prev, startMessage]);
|
||||
|
||||
// Start polling interval
|
||||
intervalRef.current = window.setInterval(pollAirtimeStats, AIRTIME_POLL_INTERVAL_MS);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cmd === 'dutycycle_stop') {
|
||||
if (baselineRef.current && baselineRef.current.conversationId === conversationId) {
|
||||
// Do one final poll before stopping
|
||||
await pollAirtimeStats();
|
||||
|
||||
stopTracking();
|
||||
|
||||
const stopMessage = createLocalMessage(
|
||||
conversationId,
|
||||
'Airtime/duty cycle statistics gathering stopped.',
|
||||
false
|
||||
);
|
||||
setMessages((prev) => [...prev, stopMessage]);
|
||||
} else {
|
||||
const notRunningMessage = createLocalMessage(
|
||||
conversationId,
|
||||
'Duty cycle tracking is not active.',
|
||||
false
|
||||
);
|
||||
setMessages((prev) => [...prev, notRunningMessage]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false; // Not an airtime command
|
||||
},
|
||||
[setMessages, stopTracking, pollAirtimeStats]
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current !== null) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
handleAirtimeCommand,
|
||||
stopTracking,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import type {
|
||||
Conversation,
|
||||
PaneName,
|
||||
PaneState,
|
||||
RepeaterStatusResponse,
|
||||
RepeaterNeighborsResponse,
|
||||
RepeaterAclResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterAdvertIntervalsResponse,
|
||||
RepeaterOwnerInfoResponse,
|
||||
RepeaterLppTelemetryResponse,
|
||||
CommandResponse,
|
||||
} from '../types';
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
|
||||
interface ConsoleEntry {
|
||||
command: string;
|
||||
response: string;
|
||||
timestamp: number;
|
||||
outgoing: boolean;
|
||||
}
|
||||
|
||||
interface PaneData {
|
||||
status: RepeaterStatusResponse | null;
|
||||
neighbors: RepeaterNeighborsResponse | null;
|
||||
acl: RepeaterAclResponse | null;
|
||||
radioSettings: RepeaterRadioSettingsResponse | null;
|
||||
advertIntervals: RepeaterAdvertIntervalsResponse | null;
|
||||
ownerInfo: RepeaterOwnerInfoResponse | null;
|
||||
lppTelemetry: RepeaterLppTelemetryResponse | null;
|
||||
}
|
||||
|
||||
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null };
|
||||
|
||||
function createInitialPaneStates(): Record<PaneName, PaneState> {
|
||||
return {
|
||||
status: { ...INITIAL_PANE_STATE },
|
||||
neighbors: { ...INITIAL_PANE_STATE },
|
||||
acl: { ...INITIAL_PANE_STATE },
|
||||
radioSettings: { ...INITIAL_PANE_STATE },
|
||||
advertIntervals: { ...INITIAL_PANE_STATE },
|
||||
ownerInfo: { ...INITIAL_PANE_STATE },
|
||||
lppTelemetry: { ...INITIAL_PANE_STATE },
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialPaneData(): PaneData {
|
||||
return {
|
||||
status: null,
|
||||
neighbors: null,
|
||||
acl: null,
|
||||
radioSettings: null,
|
||||
advertIntervals: null,
|
||||
ownerInfo: null,
|
||||
lppTelemetry: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Maps pane name to the API call
|
||||
function fetchPaneData(publicKey: string, pane: PaneName) {
|
||||
switch (pane) {
|
||||
case 'status':
|
||||
return api.repeaterStatus(publicKey);
|
||||
case 'neighbors':
|
||||
return api.repeaterNeighbors(publicKey);
|
||||
case 'acl':
|
||||
return api.repeaterAcl(publicKey);
|
||||
case 'radioSettings':
|
||||
return api.repeaterRadioSettings(publicKey);
|
||||
case 'advertIntervals':
|
||||
return api.repeaterAdvertIntervals(publicKey);
|
||||
case 'ownerInfo':
|
||||
return api.repeaterOwnerInfo(publicKey);
|
||||
case 'lppTelemetry':
|
||||
return api.repeaterLppTelemetry(publicKey);
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseRepeaterDashboardResult {
|
||||
loggedIn: boolean;
|
||||
loginLoading: boolean;
|
||||
loginError: string | null;
|
||||
paneData: PaneData;
|
||||
paneStates: Record<PaneName, PaneState>;
|
||||
consoleHistory: ConsoleEntry[];
|
||||
consoleLoading: boolean;
|
||||
login: (password: string) => Promise<void>;
|
||||
loginAsGuest: () => Promise<void>;
|
||||
refreshPane: (pane: PaneName) => Promise<void>;
|
||||
loadAll: () => Promise<void>;
|
||||
sendConsoleCommand: (command: string) => Promise<void>;
|
||||
sendAdvert: () => Promise<void>;
|
||||
rebootRepeater: () => Promise<void>;
|
||||
syncClock: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useRepeaterDashboard(
|
||||
activeConversation: Conversation | null
|
||||
): UseRepeaterDashboardResult {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const [paneData, setPaneData] = useState<PaneData>(createInitialPaneData);
|
||||
const [paneStates, setPaneStates] =
|
||||
useState<Record<PaneName, PaneState>>(createInitialPaneStates);
|
||||
|
||||
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
|
||||
const [consoleLoading, setConsoleLoading] = useState(false);
|
||||
|
||||
// Track which conversation we're operating on to avoid stale updates after
|
||||
// unmount. Initialised from activeConversation because the parent renders
|
||||
// <RepeaterDashboard key={id}>, so this hook only ever sees one conversation.
|
||||
const activeIdRef = useRef(activeConversation?.id ?? null);
|
||||
|
||||
// Guard against setting state after unmount (retry timers firing late)
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getPublicKey = useCallback((): string | null => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return null;
|
||||
return activeConversation.id;
|
||||
}, [activeConversation]);
|
||||
|
||||
const login = useCallback(
|
||||
async (password: string) => {
|
||||
const publicKey = getPublicKey();
|
||||
if (!publicKey) return;
|
||||
const conversationId = publicKey;
|
||||
|
||||
setLoginLoading(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
await api.repeaterLogin(publicKey, password);
|
||||
if (activeIdRef.current !== conversationId) return;
|
||||
setLoggedIn(true);
|
||||
} catch (err) {
|
||||
if (activeIdRef.current !== conversationId) return;
|
||||
const msg = err instanceof Error ? err.message : 'Login failed';
|
||||
setLoginError(msg);
|
||||
} finally {
|
||||
if (activeIdRef.current === conversationId) {
|
||||
setLoginLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[getPublicKey]
|
||||
);
|
||||
|
||||
const loginAsGuest = useCallback(async () => {
|
||||
await login('');
|
||||
}, [login]);
|
||||
|
||||
const refreshPane = useCallback(
|
||||
async (pane: PaneName) => {
|
||||
const publicKey = getPublicKey();
|
||||
if (!publicKey) return;
|
||||
const conversationId = publicKey;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
|
||||
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: true, attempt, error: null },
|
||||
}));
|
||||
|
||||
try {
|
||||
const data = await fetchPaneData(publicKey, pane);
|
||||
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
|
||||
|
||||
setPaneData((prev) => ({ ...prev, [pane]: data }));
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: false, attempt, error: null },
|
||||
}));
|
||||
return; // Success
|
||||
} catch (err) {
|
||||
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
|
||||
|
||||
const msg = err instanceof Error ? err.message : 'Request failed';
|
||||
|
||||
if (attempt === MAX_RETRIES) {
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: false, attempt, error: msg },
|
||||
}));
|
||||
toast.error(`Failed to fetch ${pane}`, { description: msg });
|
||||
} else {
|
||||
// Wait before retrying
|
||||
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[getPublicKey]
|
||||
);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
const panes: PaneName[] = [
|
||||
'status',
|
||||
'neighbors',
|
||||
'acl',
|
||||
'radioSettings',
|
||||
'advertIntervals',
|
||||
'ownerInfo',
|
||||
'lppTelemetry',
|
||||
];
|
||||
// Serial execution — parallel calls just queue behind the radio lock anyway
|
||||
for (const pane of panes) {
|
||||
await refreshPane(pane);
|
||||
}
|
||||
}, [refreshPane]);
|
||||
|
||||
const sendConsoleCommand = useCallback(
|
||||
async (command: string) => {
|
||||
const publicKey = getPublicKey();
|
||||
if (!publicKey) return;
|
||||
const conversationId = publicKey;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Add outgoing command entry
|
||||
setConsoleHistory((prev) => [
|
||||
...prev,
|
||||
{ command, response: '', timestamp: now, outgoing: true },
|
||||
]);
|
||||
|
||||
setConsoleLoading(true);
|
||||
try {
|
||||
const result: CommandResponse = await api.sendRepeaterCommand(publicKey, command);
|
||||
if (activeIdRef.current !== conversationId) return;
|
||||
|
||||
setConsoleHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
command,
|
||||
response: result.response,
|
||||
timestamp: result.sender_timestamp ?? now,
|
||||
outgoing: false,
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
if (activeIdRef.current !== conversationId) return;
|
||||
const msg = err instanceof Error ? err.message : 'Command failed';
|
||||
setConsoleHistory((prev) => [
|
||||
...prev,
|
||||
{ command, response: `Error: ${msg}`, timestamp: now, outgoing: false },
|
||||
]);
|
||||
} finally {
|
||||
if (activeIdRef.current === conversationId) {
|
||||
setConsoleLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[getPublicKey]
|
||||
);
|
||||
|
||||
const sendAdvert = useCallback(async () => {
|
||||
await sendConsoleCommand('advert');
|
||||
}, [sendConsoleCommand]);
|
||||
|
||||
const rebootRepeater = useCallback(async () => {
|
||||
await sendConsoleCommand('reboot');
|
||||
}, [sendConsoleCommand]);
|
||||
|
||||
const syncClock = useCallback(async () => {
|
||||
const epoch = Math.floor(Date.now() / 1000);
|
||||
await sendConsoleCommand(`clock ${epoch}`);
|
||||
}, [sendConsoleCommand]);
|
||||
|
||||
return {
|
||||
loggedIn,
|
||||
loginLoading,
|
||||
loginError,
|
||||
paneData,
|
||||
paneStates,
|
||||
consoleHistory,
|
||||
consoleLoading,
|
||||
login,
|
||||
loginAsGuest,
|
||||
refreshPane,
|
||||
loadAll,
|
||||
sendConsoleCommand,
|
||||
sendAdvert,
|
||||
rebootRepeater,
|
||||
syncClock,
|
||||
};
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { useState, useCallback, useMemo, useEffect, type RefObject } from 'react';
|
||||
import { api } from '../api';
|
||||
import type {
|
||||
Contact,
|
||||
Conversation,
|
||||
Message,
|
||||
TelemetryResponse,
|
||||
NeighborInfo,
|
||||
AclEntry,
|
||||
} from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { useAirtimeTracking } from './useAirtimeTracking';
|
||||
|
||||
// Format seconds into human-readable duration (e.g., 1d17h2m, 1h5m, 3m)
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) {
|
||||
if (hours > 0 && mins > 0) return `${days}d${hours}h${mins}m`;
|
||||
if (hours > 0) return `${days}d${hours}h`;
|
||||
if (mins > 0) return `${days}d${mins}m`;
|
||||
return `${days}d`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
// Format telemetry response as human-readable text
|
||||
function formatTelemetry(telemetry: TelemetryResponse): string {
|
||||
const lines = [
|
||||
`Telemetry`,
|
||||
`Battery Voltage: ${telemetry.battery_volts.toFixed(3)}V`,
|
||||
`Uptime: ${formatDuration(telemetry.uptime_seconds)}`,
|
||||
...(telemetry.clock_output ? [`Clock: ${telemetry.clock_output}`] : []),
|
||||
`TX Airtime: ${formatDuration(telemetry.airtime_seconds)}`,
|
||||
`RX Airtime: ${formatDuration(telemetry.rx_airtime_seconds)}`,
|
||||
'',
|
||||
`Noise Floor: ${telemetry.noise_floor_dbm} dBm`,
|
||||
`Last RSSI: ${telemetry.last_rssi_dbm} dBm`,
|
||||
`Last SNR: ${telemetry.last_snr_db.toFixed(1)} dB`,
|
||||
'',
|
||||
`Packets: ${telemetry.packets_received.toLocaleString()} rx / ${telemetry.packets_sent.toLocaleString()} tx`,
|
||||
`Flood: ${telemetry.recv_flood.toLocaleString()} rx / ${telemetry.sent_flood.toLocaleString()} tx`,
|
||||
`Direct: ${telemetry.recv_direct.toLocaleString()} rx / ${telemetry.sent_direct.toLocaleString()} tx`,
|
||||
`Duplicates: ${telemetry.flood_dups.toLocaleString()} flood / ${telemetry.direct_dups.toLocaleString()} direct`,
|
||||
'',
|
||||
`TX Queue: ${telemetry.tx_queue_len}`,
|
||||
`Debug Flags: ${telemetry.full_events}`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Format neighbors list as human-readable text
|
||||
function formatNeighbors(neighbors: NeighborInfo[]): string {
|
||||
if (neighbors.length === 0) {
|
||||
return 'Neighbors\nNo neighbors reported';
|
||||
}
|
||||
// Sort by SNR descending (highest first)
|
||||
const sorted = [...neighbors].sort((a, b) => b.snr - a.snr);
|
||||
const lines = [`Neighbors (${sorted.length})`];
|
||||
for (const n of sorted) {
|
||||
const name = n.name || n.pubkey_prefix;
|
||||
const snr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1);
|
||||
lines.push(`${name}, ${snr} dB [${formatDuration(n.last_heard_seconds)} ago]`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Format ACL list as human-readable text
|
||||
function formatAcl(acl: AclEntry[]): string {
|
||||
if (acl.length === 0) {
|
||||
return 'ACL\nNo ACL entries';
|
||||
}
|
||||
const lines = [`ACL (${acl.length})`];
|
||||
for (const entry of acl) {
|
||||
const name = entry.name || entry.pubkey_prefix;
|
||||
lines.push(`${name}: ${entry.permission_name}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Create a local message object (not persisted to database)
|
||||
function createLocalMessage(
|
||||
conversationKey: string,
|
||||
text: string,
|
||||
outgoing: boolean,
|
||||
idOffset = 0
|
||||
): Message {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
id: -Date.now() - idOffset,
|
||||
type: 'PRIV',
|
||||
conversation_key: conversationKey,
|
||||
text,
|
||||
sender_timestamp: now,
|
||||
received_at: now,
|
||||
paths: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing,
|
||||
acked: 1,
|
||||
};
|
||||
}
|
||||
|
||||
interface UseRepeaterModeResult {
|
||||
repeaterLoggedIn: boolean;
|
||||
activeContactIsRepeater: boolean;
|
||||
handleTelemetryRequest: (password: string) => Promise<void>;
|
||||
handleRepeaterCommand: (command: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useRepeaterMode(
|
||||
activeConversation: Conversation | null,
|
||||
contacts: Contact[],
|
||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>,
|
||||
activeConversationRef: RefObject<Conversation | null>
|
||||
): UseRepeaterModeResult {
|
||||
const [repeaterLoggedIn, setRepeaterLoggedIn] = useState(false);
|
||||
const { handleAirtimeCommand, stopTracking } = useAirtimeTracking(setMessages);
|
||||
|
||||
// Reset login state and stop airtime tracking when conversation changes
|
||||
useEffect(() => {
|
||||
setRepeaterLoggedIn(false);
|
||||
stopTracking();
|
||||
}, [activeConversation?.id, stopTracking]);
|
||||
|
||||
// Check if active conversation is a repeater
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return false;
|
||||
const contact = contacts.find((c) => c.public_key === activeConversation.id);
|
||||
return contact?.type === CONTACT_TYPE_REPEATER;
|
||||
}, [activeConversation, contacts]);
|
||||
|
||||
// Request telemetry from a repeater
|
||||
const handleTelemetryRequest = useCallback(
|
||||
async (password: string) => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return;
|
||||
if (!activeContactIsRepeater) return;
|
||||
|
||||
const conversationId = activeConversation.id;
|
||||
|
||||
try {
|
||||
const telemetry = await api.requestTelemetry(conversationId, password);
|
||||
|
||||
// User may have switched conversations during the await
|
||||
if (activeConversationRef.current?.id !== conversationId) return;
|
||||
|
||||
// Create local messages to display the telemetry (not persisted to database)
|
||||
const telemetryMessage = createLocalMessage(
|
||||
conversationId,
|
||||
formatTelemetry(telemetry),
|
||||
false,
|
||||
0
|
||||
);
|
||||
|
||||
const neighborsMessage = createLocalMessage(
|
||||
conversationId,
|
||||
formatNeighbors(telemetry.neighbors),
|
||||
false,
|
||||
1
|
||||
);
|
||||
|
||||
const aclMessage = createLocalMessage(conversationId, formatAcl(telemetry.acl), false, 2);
|
||||
|
||||
// Add all messages to the list
|
||||
setMessages((prev) => [...prev, telemetryMessage, neighborsMessage, aclMessage]);
|
||||
|
||||
// Mark as logged in for CLI command mode
|
||||
setRepeaterLoggedIn(true);
|
||||
} catch (err) {
|
||||
if (activeConversationRef.current?.id !== conversationId) return;
|
||||
const errorMessage = createLocalMessage(
|
||||
conversationId,
|
||||
`Telemetry request failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
||||
false,
|
||||
0
|
||||
);
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
}
|
||||
},
|
||||
[activeConversation, activeContactIsRepeater, setMessages, activeConversationRef]
|
||||
);
|
||||
|
||||
// Send CLI command to a repeater (after logged in)
|
||||
const handleRepeaterCommand = useCallback(
|
||||
async (command: string) => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return;
|
||||
if (!activeContactIsRepeater || !repeaterLoggedIn) return;
|
||||
|
||||
const conversationId = activeConversation.id;
|
||||
|
||||
// Check for special airtime commands first (handled locally)
|
||||
const handled = await handleAirtimeCommand(command, conversationId);
|
||||
if (handled) return;
|
||||
|
||||
// Show the command as an outgoing message
|
||||
const commandMessage = createLocalMessage(conversationId, `> ${command}`, true, 0);
|
||||
setMessages((prev) => [...prev, commandMessage]);
|
||||
|
||||
try {
|
||||
const response = await api.sendRepeaterCommand(conversationId, command);
|
||||
|
||||
// User may have switched conversations during the await
|
||||
if (activeConversationRef.current?.id !== conversationId) return;
|
||||
|
||||
// Use the actual timestamp from the repeater if available
|
||||
const responseMessage = createLocalMessage(conversationId, response.response, false, 1);
|
||||
if (response.sender_timestamp) {
|
||||
responseMessage.sender_timestamp = response.sender_timestamp;
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, responseMessage]);
|
||||
} catch (err) {
|
||||
if (activeConversationRef.current?.id !== conversationId) return;
|
||||
const errorMessage = createLocalMessage(
|
||||
conversationId,
|
||||
`Command failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
||||
false,
|
||||
1
|
||||
);
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeConversation,
|
||||
activeContactIsRepeater,
|
||||
repeaterLoggedIn,
|
||||
setMessages,
|
||||
handleAirtimeCommand,
|
||||
activeConversationRef,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
repeaterLoggedIn,
|
||||
activeContactIsRepeater,
|
||||
handleTelemetryRequest,
|
||||
handleRepeaterCommand,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user