Add airtime tracking

This commit is contained in:
Jack Kingsman
2026-01-17 22:58:14 -08:00
parent 7c778885f6
commit f9a10e4b6a
6 changed files with 339 additions and 48 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,7 +13,7 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-Djay-rUY.js"></script>
<script type="module" crossorigin src="/assets/index-BszmRhBR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BpFnqQF8.css">
</head>
<body>
+277
View File
@@ -0,0 +1,277 @@
/**
* 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,
path_len: null,
txt_type: 0,
signature: null,
outgoing,
acked: 1,
};
}
export 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 (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,
};
}
+16 -3
View File
@@ -9,6 +9,7 @@ import type {
AclEntry,
} from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { useAirtimeTracking } from './useAirtimeTracking';
// Format seconds into human-readable duration (e.g., 1d17h2m, 1h5m, 3m)
export function formatDuration(seconds: number): string {
@@ -119,11 +120,13 @@ export function useRepeaterMode(
setMessages: React.Dispatch<React.SetStateAction<Message[]>>
): UseRepeaterModeResult {
const [repeaterLoggedIn, setRepeaterLoggedIn] = useState(false);
const { handleAirtimeCommand, stopTracking } = useAirtimeTracking(setMessages);
// Reset login state when conversation changes
// Reset login state and stop airtime tracking when conversation changes
useEffect(() => {
setRepeaterLoggedIn(false);
}, [activeConversation?.id]);
stopTracking();
}, [activeConversation?.id, stopTracking]);
// Check if active conversation is a repeater
const activeContactIsRepeater = useMemo(() => {
@@ -187,6 +190,10 @@ export function useRepeaterMode(
if (!activeConversation || activeConversation.type !== 'contact') return;
if (!activeContactIsRepeater || !repeaterLoggedIn) return;
// Check for special airtime commands first (handled locally)
const handled = await handleAirtimeCommand(command, activeConversation.id);
if (handled) return;
// Show the command as an outgoing message
const commandMessage = createLocalMessage(activeConversation.id, `> ${command}`, true, 0);
setMessages((prev) => [...prev, commandMessage]);
@@ -216,7 +223,13 @@ export function useRepeaterMode(
setMessages((prev) => [...prev, errorMessage]);
}
},
[activeConversation, activeContactIsRepeater, repeaterLoggedIn, setMessages]
[
activeConversation,
activeContactIsRepeater,
repeaterLoggedIn,
setMessages,
handleAirtimeCommand,
]
);
return {