mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-09 10:13:02 +02:00
Add repeater telemetry reading
This commit is contained in:
+170
-4
@@ -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}...`
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user