mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-06-30 23:11:13 +02:00
247 lines
8.3 KiB
TypeScript
247 lines
8.3 KiB
TypeScript
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,
|
|
};
|
|
}
|