Add repeater telemetry reading

This commit is contained in:
Jack Kingsman
2026-01-09 21:17:55 -08:00
parent 2a6aeebabe
commit e401a32049
12 changed files with 1129 additions and 554 deletions
+170 -4
View File
@@ -22,6 +22,7 @@ import {
import { pubkeysMatch, getContactDisplayName } from './utils/pubkey';
import { cn } from '@/lib/utils';
import type {
AclEntry,
AppSettings,
AppSettingsUpdate,
Contact,
@@ -29,13 +30,90 @@ import type {
Conversation,
HealthStatus,
Message,
NeighborInfo,
RawPacket,
RadioConfig,
RadioConfigUpdate,
TelemetryResponse,
} from './types';
import { CONTACT_TYPE_REPEATER } from './types';
const MAX_RAW_PACKETS = 500; // Limit stored packets to prevent memory issues
// 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
// Note: Avoid "Word: " pattern at line start - it triggers sender extraction in MessageList
function formatTelemetry(telemetry: TelemetryResponse): string {
const lines = [
`[Telemetry]`,
`Battery Voltage: ${telemetry.battery_volts.toFixed(3)}V`,
`Uptime: ${formatDuration(telemetry.uptime_seconds)}`,
`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');
}
// Generate a key for deduplicating messages by content
function getMessageContentKey(msg: Message): string {
return `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
@@ -512,6 +590,13 @@ export function App() {
fetchMessages(true);
}, [fetchMessages]);
// 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]);
// Send message handler
const handleSendMessage = useCallback(
async (text: string) => {
@@ -528,6 +613,84 @@ export function App() {
[activeConversation, fetchMessages]
);
// Request telemetry from a repeater
const handleTelemetryRequest = useCallback(
async (password: string) => {
if (!activeConversation || activeConversation.type !== 'contact') return;
if (!activeContactIsRepeater) return;
try {
const telemetry = await api.requestTelemetry(activeConversation.id, password);
const now = Math.floor(Date.now() / 1000);
// Create a local message to display the telemetry (not persisted to database)
const telemetryMessage: Message = {
id: -Date.now(), // Negative ID to avoid collision with real messages
type: 'PRIV',
conversation_key: activeConversation.id,
text: formatTelemetry(telemetry),
sender_timestamp: now,
received_at: now,
path_len: null,
txt_type: 0,
signature: null,
outgoing: false, // Show as incoming (from the repeater)
acked: true, // Mark as acked since it's a response
};
// Create a second message for neighbors
const neighborsMessage: Message = {
id: -Date.now() - 1, // Different ID
type: 'PRIV',
conversation_key: activeConversation.id,
text: formatNeighbors(telemetry.neighbors),
sender_timestamp: now,
received_at: now,
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: true,
};
// Create a third message for ACL
const aclMessage: Message = {
id: -Date.now() - 2, // Different ID
type: 'PRIV',
conversation_key: activeConversation.id,
text: formatAcl(telemetry.acl),
sender_timestamp: now,
received_at: now,
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: true,
};
// Add all messages to the list
setMessages((prev) => [...prev, telemetryMessage, neighborsMessage, aclMessage]);
} catch (err) {
// Show error as a local message
const errorMessage: Message = {
id: -Date.now(),
type: 'PRIV',
conversation_key: activeConversation.id,
text: `Telemetry request failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
sender_timestamp: Math.floor(Date.now() / 1000),
received_at: Math.floor(Date.now() / 1000),
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: true,
};
setMessages((prev) => [...prev, errorMessage]);
}
},
[activeConversation, activeContactIsRepeater]
);
// Config save handler
const handleSaveConfig = useCallback(async (update: RadioConfigUpdate) => {
await api.updateRadioConfig(update);
@@ -820,12 +983,15 @@ export function App() {
/>
<MessageInput
ref={messageInputRef}
onSend={handleSendMessage}
onSend={activeContactIsRepeater ? handleTelemetryRequest : handleSendMessage}
disabled={!health?.radio_connected}
isRepeaterMode={activeContactIsRepeater}
placeholder={
health?.radio_connected
? `Message ${activeConversation.name}...`
: 'Radio not connected'
!health?.radio_connected
? 'Radio not connected'
: activeContactIsRepeater
? `Enter password for ${activeConversation.name} (or . for none)...`
: `Message ${activeConversation.name}...`
}
/>
</>
+6
View File
@@ -7,6 +7,7 @@ import type {
Message,
RadioConfig,
RadioConfigUpdate,
TelemetryResponse,
} from './types';
const API_BASE = '/api';
@@ -80,6 +81,11 @@ export const api = {
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE',
}),
requestTelemetry: (publicKey: string, password: string) =>
fetchJson<TelemetryResponse>(`/contacts/${publicKey}/telemetry`, {
method: 'POST',
body: JSON.stringify({ password }),
}),
// Channels
getChannels: () => fetchJson<Channel[]>('/channels'),
+40 -15
View File
@@ -6,6 +6,8 @@ interface MessageInputProps {
onSend: (text: string) => Promise<void>;
disabled: boolean;
placeholder?: string;
/** When true, input becomes password field for repeater telemetry */
isRepeaterMode?: boolean;
}
export interface MessageInputHandle {
@@ -13,7 +15,7 @@ export interface MessageInputHandle {
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
function MessageInput({ onSend, disabled, placeholder }, ref) {
function MessageInput({ onSend, disabled, placeholder, isRepeaterMode }, ref) {
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
@@ -30,19 +32,35 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
async (e: FormEvent) => {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed || sending || disabled) return;
setSending(true);
try {
await onSend(trimmed);
setText('');
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setSending(false);
// For repeater mode, allow empty password via "."
if (isRepeaterMode) {
if (sending || disabled) return;
// "." means empty password
const password = trimmed === '.' ? '' : trimmed;
setSending(true);
try {
await onSend(password);
setText('');
} catch (err) {
console.error('Failed to request telemetry:', err);
} finally {
setSending(false);
}
} else {
if (!trimmed || sending || disabled) return;
setSending(true);
try {
await onSend(trimmed);
setText('');
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setSending(false);
}
}
},
[text, sending, disabled, onSend]
[text, sending, disabled, onSend, isRepeaterMode]
);
const handleKeyDown = useCallback(
@@ -55,20 +73,27 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
[handleSubmit]
);
// For repeater mode, enable submit if there's text OR if it's just "." for empty password
const canSubmit = isRepeaterMode
? text.trim().length > 0 || text === '.'
: text.trim().length > 0;
return (
<form className="px-4 py-3 border-t border-border flex gap-2" onSubmit={handleSubmit}>
<Input
ref={inputRef}
type="text"
type={isRepeaterMode ? 'password' : 'text'}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder || 'Type a message...'}
placeholder={placeholder || (isRepeaterMode ? 'Enter password (or . for none)...' : 'Type a message...')}
disabled={disabled || sending}
className="flex-1"
/>
<Button type="submit" disabled={disabled || sending || !text.trim()}>
{sending ? 'Sending...' : 'Send'}
<Button type="submit" disabled={disabled || sending || !canSubmit}>
{sending
? (isRepeaterMode ? 'Fetching...' : 'Sending...')
: (isRepeaterMode ? 'Fetch' : 'Send')}
</Button>
</form>
);
+40
View File
@@ -109,3 +109,43 @@ export interface AppSettings {
export interface AppSettingsUpdate {
max_radio_contacts?: number;
}
/** Contact type constant for repeaters */
export const CONTACT_TYPE_REPEATER = 2;
export interface NeighborInfo {
pubkey_prefix: string;
name: string | null;
snr: number;
last_heard_seconds: number;
}
export interface AclEntry {
pubkey_prefix: string;
name: string | null;
permission: number;
permission_name: string;
}
export interface TelemetryResponse {
pubkey_prefix: string;
battery_volts: number;
tx_queue_len: number;
noise_floor_dbm: number;
last_rssi_dbm: number;
last_snr_db: number;
packets_received: number;
packets_sent: number;
airtime_seconds: number;
rx_airtime_seconds: number;
uptime_seconds: number;
sent_flood: number;
sent_direct: number;
recv_flood: number;
recv_direct: number;
flood_dups: number;
direct_dups: number;
full_events: number;
neighbors: NeighborInfo[];
acl: AclEntry[];
}