Massive frontend overhaul for settings menu and channel addition.

This commit is contained in:
Jack Kingsman
2026-01-17 18:17:11 -08:00
parent bbe2bba5b6
commit 9652cb3277
21 changed files with 1499 additions and 1069 deletions
+9
View File
@@ -499,6 +499,15 @@ class RawPacketRepository:
row = await cursor.fetchone()
return row["count"] if row else 0
@staticmethod
async def get_oldest_undecrypted() -> int | None:
"""Get timestamp of oldest undecrypted packet, or None if none exist."""
cursor = await db.conn.execute(
"SELECT MIN(timestamp) as oldest FROM raw_packets WHERE message_id IS NULL"
)
row = await cursor.fetchone()
return row["oldest"] if row and row["oldest"] is not None else None
@staticmethod
async def get_all_undecrypted() -> list[tuple[int, bytes, int]]:
"""Get all undecrypted packets as (id, data, timestamp) tuples."""
+10
View File
@@ -5,6 +5,7 @@ from pydantic import BaseModel
from app.config import settings
from app.radio import radio_manager
from app.repository import RawPacketRepository
router = APIRouter(tags=["health"])
@@ -14,6 +15,7 @@ class HealthResponse(BaseModel):
radio_connected: bool
serial_port: str | None
database_size_mb: float
oldest_undecrypted_timestamp: int | None
@router.get("/health", response_model=HealthResponse)
@@ -27,9 +29,17 @@ async def healthcheck() -> HealthResponse:
except OSError:
pass
# Get oldest undecrypted packet info (gracefully handle if DB not connected)
oldest_ts = None
try:
oldest_ts = await RawPacketRepository.get_oldest_undecrypted()
except RuntimeError:
pass # Database not connected
return HealthResponse(
status="ok" if radio_manager.is_connected else "degraded",
radio_connected=radio_manager.is_connected,
serial_port=radio_manager.port,
database_size_mb=db_size_mb,
oldest_undecrypted_timestamp=oldest_ts,
)
+9 -1
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.config import settings
from app.radio import radio_manager
from app.repository import ChannelRepository, ContactRepository
from app.repository import ChannelRepository, ContactRepository, RawPacketRepository
from app.websocket import ws_manager
logger = logging.getLogger(__name__)
@@ -29,11 +29,19 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
except OSError:
pass
# Get oldest undecrypted packet info
oldest_ts = None
try:
oldest_ts = await RawPacketRepository.get_oldest_undecrypted()
except RuntimeError:
pass # Database not connected
health_data = {
"status": "ok" if radio_manager.is_connected else "degraded",
"radio_connected": radio_manager.is_connected,
"serial_port": radio_manager.port,
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,
}
await ws_manager.send_personal(websocket, "health", health_data)
+21 -10
View File
@@ -88,22 +88,33 @@ def broadcast_error(message: str, details: str | None = None) -> None:
def broadcast_health(radio_connected: bool, serial_port: str | None = None) -> None:
"""Broadcast health status change to all connected clients."""
# Get database file size in MB
db_size_mb = 0.0
try:
db_size_bytes = os.path.getsize(settings.database_path)
db_size_mb = round(db_size_bytes / (1024 * 1024), 2)
except OSError:
pass
from app.repository import RawPacketRepository
asyncio.create_task(
ws_manager.broadcast(
async def _broadcast():
# Get database file size in MB
db_size_mb = 0.0
try:
db_size_bytes = os.path.getsize(settings.database_path)
db_size_mb = round(db_size_bytes / (1024 * 1024), 2)
except OSError:
pass
# Get oldest undecrypted packet info
oldest_ts = None
try:
oldest_ts = await RawPacketRepository.get_oldest_undecrypted()
except RuntimeError:
pass # Database not connected
await ws_manager.broadcast(
"health",
{
"status": "ok" if radio_connected else "degraded",
"radio_connected": radio_connected,
"serial_port": serial_port,
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,
},
)
)
asyncio.create_task(_broadcast())
+1 -2
View File
@@ -45,8 +45,7 @@ frontend/
│ │ ├── MapView.tsx # Leaflet map showing node locations
│ │ ├── CrackerPanel.tsx # WebGPU channel key cracker (lazy-loads wordlist)
│ │ ├── NewMessageModal.tsx
│ │ ── ConfigModal.tsx # Radio config + app settings
│ │ └── MaintenanceModal.tsx # Database maintenance (packet cleanup)
│ │ ── SettingsModal.tsx # Unified settings: radio config, identity, serial, database, advertise
│ └── test/
│ ├── setup.ts # Test setup (jsdom, matchers)
│ ├── messageParser.test.ts
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
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
+2 -2
View File
@@ -13,8 +13,8 @@
<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-E6l20oLD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C5j7uJOC.css">
<script type="module" crossorigin src="/assets/index-CGSJJsxM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D2NVJvYv.css">
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="MCTerm" />
<meta name="theme-color" content="#0a0a0a" />
+33 -39
View File
@@ -12,8 +12,7 @@ import { Sidebar } from './components/Sidebar';
import { MessageList } from './components/MessageList';
import { MessageInput, type MessageInputHandle } from './components/MessageInput';
import { NewMessageModal } from './components/NewMessageModal';
import { ConfigModal } from './components/ConfigModal';
import { MaintenanceModal } from './components/MaintenanceModal';
import { SettingsModal } from './components/SettingsModal';
import { RawPacketList } from './components/RawPacketList';
import { MapView } from './components/MapView';
import { CrackerPanel } from './components/CrackerPanel';
@@ -54,8 +53,7 @@ export function App() {
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
const [showNewMessage, setShowNewMessage] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [showMaintenance, setShowMaintenance] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [undecryptedCount, setUndecryptedCount] = useState(0);
const [showCracker, setShowCracker] = useState(false);
@@ -564,9 +562,7 @@ export function App() {
<StatusBar
health={health}
config={config}
onConfigClick={() => setShowConfig(true)}
onMaintenanceClick={() => setShowMaintenance(true)}
onAdvertise={handleAdvertise}
onSettingsClick={() => setShowSettings(true)}
onMenuClick={() => setSidebarOpen(true)}
/>
@@ -617,30 +613,32 @@ export function App() {
</span>
<span className="font-normal text-sm text-muted-foreground font-mono truncate">
{activeConversation.id}
{activeConversation.type === 'contact' &&
(() => {
const contact = contacts.find(
(c) => c.public_key === activeConversation.id
);
if (!contact) return null;
const parts: string[] = [];
if (contact.last_seen) {
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
}
if (contact.last_path_len === -1) {
parts.push('flood');
} else if (contact.last_path_len === 0) {
parts.push('direct');
} else if (contact.last_path_len > 0) {
parts.push(
`${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`
);
}
return parts.length > 0 ? (
<span className="ml-2 font-sans">({parts.join(', ')})</span>
) : null;
})()}
</span>
{activeConversation.type === 'contact' &&
(() => {
const contact = contacts.find(
(c) => c.public_key === activeConversation.id
);
if (!contact) return null;
const parts: string[] = [];
if (contact.last_seen) {
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
}
if (contact.last_path_len === -1) {
parts.push('flood');
} else if (contact.last_path_len === 0) {
parts.push('direct');
} else if (contact.last_path_len > 0) {
parts.push(
`${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`
);
}
return parts.length > 0 ? (
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
({parts.join(', ')})
</span>
) : null;
})()}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Favorite button */}
@@ -779,21 +777,17 @@ export function App() {
onCreateHashtagChannel={handleCreateHashtagChannel}
/>
<ConfigModal
open={showConfig}
<SettingsModal
open={showSettings}
config={config}
health={health}
appSettings={appSettings}
onClose={() => setShowConfig(false)}
onClose={() => setShowSettings(false)}
onSave={handleSaveConfig}
onSaveAppSettings={handleSaveAppSettings}
onSetPrivateKey={handleSetPrivateKey}
onReboot={handleReboot}
/>
<MaintenanceModal
open={showMaintenance}
health={health}
onClose={() => setShowMaintenance(false)}
onAdvertise={handleAdvertise}
onHealthRefresh={async () => {
const data = await api.getHealth();
setHealth(data);
-314
View File
@@ -1,314 +0,0 @@
import { useState, useEffect } from 'react';
import type { AppSettings, AppSettingsUpdate, RadioConfig, RadioConfigUpdate } from '../types';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Button } from './ui/button';
import { Separator } from './ui/separator';
import { Alert, AlertDescription } from './ui/alert';
interface ConfigModalProps {
open: boolean;
config: RadioConfig | null;
appSettings: AppSettings | null;
onClose: () => void;
onSave: (update: RadioConfigUpdate) => Promise<void>;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onSetPrivateKey: (key: string) => Promise<void>;
onReboot: () => Promise<void>;
}
export function ConfigModal({
open,
config,
appSettings,
onClose,
onSave,
onSaveAppSettings,
onSetPrivateKey,
onReboot,
}: ConfigModalProps) {
const [name, setName] = useState('');
const [lat, setLat] = useState('');
const [lon, setLon] = useState('');
const [txPower, setTxPower] = useState('');
const [freq, setFreq] = useState('');
const [bw, setBw] = useState('');
const [sf, setSf] = useState('');
const [cr, setCr] = useState('');
const [privateKey, setPrivateKey] = useState('');
const [maxRadioContacts, setMaxRadioContacts] = useState('');
const [loading, setLoading] = useState(false);
const [rebooting, setRebooting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (config) {
setName(config.name);
setLat(String(config.lat));
setLon(String(config.lon));
setTxPower(String(config.tx_power));
setFreq(String(config.radio.freq));
setBw(String(config.radio.bw));
setSf(String(config.radio.sf));
setCr(String(config.radio.cr));
}
}, [config]);
useEffect(() => {
if (appSettings) {
setMaxRadioContacts(String(appSettings.max_radio_contacts));
}
}, [appSettings]);
const handleSave = async () => {
setError('');
setLoading(true);
try {
const update: RadioConfigUpdate = {
name,
lat: parseFloat(lat),
lon: parseFloat(lon),
tx_power: parseInt(txPower, 10),
radio: {
freq: parseFloat(freq),
bw: parseFloat(bw),
sf: parseInt(sf, 10),
cr: parseInt(cr, 10),
},
};
await onSave(update);
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings?.max_radio_contacts) {
await onSaveAppSettings({ max_radio_contacts: newMaxRadioContacts });
}
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setLoading(false);
}
};
const handleSetPrivateKey = async () => {
if (!privateKey.trim()) {
setError('Private key is required');
return;
}
setError('');
setLoading(true);
try {
await onSetPrivateKey(privateKey.trim());
setPrivateKey('');
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to set private key');
} finally {
setLoading(false);
}
};
const handleReboot = async () => {
if (
!confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.')
) {
return;
}
setError('');
setRebooting(true);
try {
await onReboot();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reboot radio');
} finally {
setRebooting(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Radio Configuration</DialogTitle>
</DialogHeader>
{!config ? (
<div className="py-8 text-center text-muted-foreground">Loading configuration...</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="lat">Latitude</Label>
<Input
id="lat"
type="number"
step="any"
value={lat}
onChange={(e) => setLat(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lon">Longitude</Label>
<Input
id="lon"
type="number"
step="any"
value={lon}
onChange={(e) => setLon(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="freq">Frequency (MHz)</Label>
<Input
id="freq"
type="number"
step="any"
value={freq}
onChange={(e) => setFreq(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bw">Bandwidth (kHz)</Label>
<Input
id="bw"
type="number"
step="any"
value={bw}
onChange={(e) => setBw(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sf">Spreading Factor</Label>
<Input
id="sf"
type="number"
min="7"
max="12"
value={sf}
onChange={(e) => setSf(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cr">Coding Rate</Label>
<Input
id="cr"
type="number"
min="1"
max="4"
value={cr}
onChange={(e) => setCr(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tx-power">TX Power (dBm)</Label>
<Input
id="tx-power"
type="number"
value={txPower}
onChange={(e) => setTxPower(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-tx">Max TX Power</Label>
<Input id="max-tx" type="number" value={config.max_tx_power} disabled />
</div>
</div>
<Separator className="my-4" />
<div className="space-y-2">
<Label htmlFor="max-contacts">Max Contacts on Radio</Label>
<Input
id="max-contacts"
type="number"
min="1"
max="1000"
value={maxRadioContacts}
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Recent non-repeater contacts loaded to radio for DM auto-ACK (1-1000)
</p>
</div>
<Separator className="my-4" />
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<div className="flex gap-2">
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
className="flex-1"
/>
<Button onClick={handleSetPrivateKey} disabled={loading || !privateKey.trim()}>
Set
</Button>
</div>
</div>
<Separator className="my-4" />
<div className="space-y-3">
<Label>Reboot Radio</Label>
<Alert variant="warning">
<AlertDescription>
Some configuration changes (like name) require a radio reboot to take effect. The
connection will temporarily drop and automatically reconnect.
</AlertDescription>
</Alert>
<Button
variant="outline"
onClick={handleReboot}
disabled={rebooting || loading}
className="border-yellow-500/50 text-yellow-200 hover:bg-yellow-500/10"
>
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
</Button>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || !config}>
{loading ? 'Saving...' : 'Save Config'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,97 +0,0 @@
import { useState } from 'react';
import type { HealthStatus } from '../types';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Button } from './ui/button';
import { toast } from './ui/sonner';
import { api } from '../api';
interface MaintenanceModalProps {
open: boolean;
health: HealthStatus | null;
onClose: () => void;
onHealthRefresh: () => Promise<void>;
}
export function MaintenanceModal({
open,
health,
onClose,
onHealthRefresh,
}: MaintenanceModalProps) {
const [retentionDays, setRetentionDays] = useState('14');
const [cleaning, setCleaning] = useState(false);
const handleCleanup = async () => {
const days = parseInt(retentionDays, 10);
if (isNaN(days) || days < 1) {
toast.error('Invalid retention days', {
description: 'Retention days must be at least 1',
});
return;
}
setCleaning(true);
try {
const result = await api.runMaintenance(days);
toast.success('Database cleanup complete', {
description: `Deleted ${result.packets_deleted} old packet${result.packets_deleted === 1 ? '' : 's'}`,
});
// Refresh health to get updated database size
await onHealthRefresh();
} catch (err) {
console.error('Failed to run maintenance:', err);
toast.error('Database cleanup failed', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setCleaning(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Database Maintenance</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Current database size:{' '}
<span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
</p>
<div className="space-y-3">
<Label>Cleanup Old Packets</Label>
<p className="text-xs text-muted-foreground">
Delete undecrypted packets older than the specified days. This helps manage storage
for packets that couldn't be decrypted (unknown channel keys).
</p>
<div className="flex gap-2 items-end">
<div className="space-y-1">
<Label htmlFor="retention-days" className="text-xs">
Days to retain
</Label>
<Input
id="retention-days"
type="number"
min="1"
max="365"
value={retentionDays}
onChange={(e) => setRetentionDays(e.target.value)}
className="w-20"
/>
</div>
<Button variant="outline" onClick={handleCleanup} disabled={cleaning}>
{cleaning ? 'Cleaning...' : 'Cleanup'}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+48 -15
View File
@@ -1,7 +1,14 @@
import { useState, useRef } from 'react';
import type { Contact, Conversation } from '../types';
import { getContactDisplayName } from '../utils/pubkey';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from './ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
import { Input } from './ui/input';
import { Label } from './ui/label';
@@ -33,7 +40,8 @@ export function NewMessageModal({
}: NewMessageModalProps) {
const [tab, setTab] = useState<Tab>('existing');
const [name, setName] = useState('');
const [key, setKey] = useState('');
const [contactKey, setContactKey] = useState('');
const [roomKey, setRoomKey] = useState('');
const [tryHistorical, setTryHistorical] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
@@ -45,22 +53,22 @@ export function NewMessageModal({
try {
if (tab === 'new-contact') {
if (!name.trim() || !key.trim()) {
if (!name.trim() || !contactKey.trim()) {
setError('Name and public key are required');
return;
}
await onCreateContact(name.trim(), key.trim(), tryHistorical);
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
onSelectConversation({
type: 'contact',
id: key.trim(),
id: contactKey.trim(),
name: name.trim(),
});
} else if (tab === 'new-room') {
if (!name.trim() || !key.trim()) {
if (!name.trim() || !roomKey.trim()) {
setError('Room name and key are required');
return;
}
await onCreateChannel(name.trim(), key.trim(), tryHistorical);
await onCreateChannel(name.trim(), roomKey.trim(), tryHistorical);
} else if (tab === 'hashtag') {
const channelName = name.trim();
const validationError = validateHashtagName(channelName);
@@ -116,6 +124,12 @@ export function NewMessageModal({
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
<DialogDescription className="sr-only">
{tab === 'existing' && 'Select an existing contact to start a conversation'}
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
{tab === 'new-room' && 'Create a private room with a shared encryption key'}
{tab === 'hashtag' && 'Join a public hashtag channel'}
</DialogDescription>
</DialogHeader>
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="w-full">
@@ -165,8 +179,8 @@ export function NewMessageModal({
<Label htmlFor="contact-key">Public Key</Label>
<Input
id="contact-key"
value={key}
onChange={(e) => setKey(e.target.value)}
value={contactKey}
onChange={(e) => setContactKey(e.target.value)}
placeholder="64-character hex public key"
/>
</div>
@@ -184,12 +198,31 @@ export function NewMessageModal({
</div>
<div className="space-y-2">
<Label htmlFor="room-key">Room Key</Label>
<Input
id="room-key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="Pre-shared key (hex)"
/>
<div className="flex gap-2">
<Input
id="room-key"
value={roomKey}
onChange={(e) => setRoomKey(e.target.value)}
placeholder="Pre-shared key (hex)"
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
setRoomKey(hex);
}}
title="Generate random key"
>
🎲
</Button>
</div>
</div>
</TabsContent>
+660
View File
@@ -0,0 +1,660 @@
import { useState, useEffect, useMemo } from 'react';
import type {
AppSettings,
AppSettingsUpdate,
HealthStatus,
RadioConfig,
RadioConfigUpdate,
} from '../types';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Button } from './ui/button';
import { Separator } from './ui/separator';
import { Alert, AlertDescription } from './ui/alert';
import { toast } from './ui/sonner';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
// Radio presets for common configurations
interface RadioPreset {
name: string;
freq: number;
bw: number;
sf: number;
cr: number;
}
const RADIO_PRESETS: RadioPreset[] = [
{ name: 'USA/Canada', freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
{ name: 'Australia', freq: 915.8, bw: 250, sf: 10, cr: 5 },
{ name: 'Australia (narrow)', freq: 916.575, bw: 62.5, sf: 7, cr: 8 },
{ name: 'Australia SA, WA', freq: 923.125, bw: 62.5, sf: 8, cr: 8 },
{ name: 'Australia QLD', freq: 923.125, bw: 62.5, sf: 8, cr: 5 },
{ name: 'New Zealand', freq: 917.375, bw: 250, sf: 11, cr: 5 },
{ name: 'New Zealand (narrow)', freq: 917.375, bw: 62.5, sf: 7, cr: 5 },
{ name: 'EU/UK/Switzerland Long Range', freq: 869.525, bw: 250, sf: 11, cr: 5 },
{ name: 'EU/UK/Switzerland Medium Range', freq: 869.525, bw: 250, sf: 10, cr: 5 },
{ name: 'EU/UK/Switzerland Narrow', freq: 869.618, bw: 62.5, sf: 8, cr: 8 },
{ name: 'Czech Republic (Narrow)', freq: 869.432, bw: 62.5, sf: 7, cr: 5 },
{ name: 'EU 433MHz Long Range', freq: 433.65, bw: 250, sf: 11, cr: 5 },
{ name: 'Portugal 433MHz', freq: 433.375, bw: 62.5, sf: 9, cr: 6 },
{ name: 'Portugal 868MHz', freq: 869.618, bw: 62.5, sf: 7, cr: 6 },
{ name: 'Vietnam', freq: 920.25, bw: 250, sf: 11, cr: 5 },
];
interface SettingsModalProps {
open: boolean;
config: RadioConfig | null;
health: HealthStatus | null;
appSettings: AppSettings | null;
onClose: () => void;
onSave: (update: RadioConfigUpdate) => Promise<void>;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onSetPrivateKey: (key: string) => Promise<void>;
onReboot: () => Promise<void>;
onAdvertise: () => Promise<void>;
onHealthRefresh: () => Promise<void>;
}
export function SettingsModal({
open,
config,
health,
appSettings,
onClose,
onSave,
onSaveAppSettings,
onSetPrivateKey,
onReboot,
onAdvertise,
onHealthRefresh,
}: SettingsModalProps) {
// Tab state
type SettingsTab = 'radio' | 'identity' | 'serial' | 'database' | 'advertise';
const [activeTab, setActiveTab] = useState<SettingsTab>('radio');
// Radio config state
const [name, setName] = useState('');
const [lat, setLat] = useState('');
const [lon, setLon] = useState('');
const [txPower, setTxPower] = useState('');
const [freq, setFreq] = useState('');
const [bw, setBw] = useState('');
const [sf, setSf] = useState('');
const [cr, setCr] = useState('');
const [privateKey, setPrivateKey] = useState('');
const [maxRadioContacts, setMaxRadioContacts] = useState('');
// Loading states
const [loading, setLoading] = useState(false);
const [rebooting, setRebooting] = useState(false);
const [advertising, setAdvertising] = useState(false);
const [gettingLocation, setGettingLocation] = useState(false);
const [error, setError] = useState('');
// Database maintenance state
const [retentionDays, setRetentionDays] = useState('14');
const [cleaning, setCleaning] = useState(false);
useEffect(() => {
if (config) {
setName(config.name);
setLat(String(config.lat));
setLon(String(config.lon));
setTxPower(String(config.tx_power));
setFreq(String(config.radio.freq));
setBw(String(config.radio.bw));
setSf(String(config.radio.sf));
setCr(String(config.radio.cr));
}
}, [config]);
useEffect(() => {
if (appSettings) {
setMaxRadioContacts(String(appSettings.max_radio_contacts));
}
}, [appSettings]);
// Detect current preset from form values
const currentPreset = useMemo(() => {
const freqNum = parseFloat(freq);
const bwNum = parseFloat(bw);
const sfNum = parseInt(sf, 10);
const crNum = parseInt(cr, 10);
for (const preset of RADIO_PRESETS) {
if (
preset.freq === freqNum &&
preset.bw === bwNum &&
preset.sf === sfNum &&
preset.cr === crNum
) {
return preset.name;
}
}
return 'custom';
}, [freq, bw, sf, cr]);
const handlePresetChange = (presetName: string) => {
if (presetName === 'custom') return;
const preset = RADIO_PRESETS.find((p) => p.name === presetName);
if (preset) {
setFreq(String(preset.freq));
setBw(String(preset.bw));
setSf(String(preset.sf));
setCr(String(preset.cr));
}
};
const handleGetLocation = () => {
if (!navigator.geolocation) {
toast.error('Geolocation not supported', {
description: 'Your browser does not support geolocation',
});
return;
}
setGettingLocation(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setLat(position.coords.latitude.toFixed(6));
setLon(position.coords.longitude.toFixed(6));
setGettingLocation(false);
toast.success('Location updated');
},
(err) => {
setGettingLocation(false);
toast.error('Failed to get location', {
description: err.message,
});
},
{ enableHighAccuracy: true, timeout: 10000 }
);
};
const handleSaveRadioConfig = async () => {
setError('');
setLoading(true);
try {
const update: RadioConfigUpdate = {
lat: parseFloat(lat),
lon: parseFloat(lon),
tx_power: parseInt(txPower, 10),
radio: {
freq: parseFloat(freq),
bw: parseFloat(bw),
sf: parseInt(sf, 10),
cr: parseInt(cr, 10),
},
};
await onSave(update);
toast.success('Radio config saved');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setLoading(false);
}
};
const handleSaveIdentity = async () => {
setError('');
setLoading(true);
try {
const update: RadioConfigUpdate = { name };
await onSave(update);
toast.success('Identity saved');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setLoading(false);
}
};
const handleSaveSerial = async () => {
setError('');
setLoading(true);
try {
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings?.max_radio_contacts) {
await onSaveAppSettings({ max_radio_contacts: newMaxRadioContacts });
}
toast.success('Serial settings saved');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setLoading(false);
}
};
const handleSetPrivateKey = async () => {
if (!privateKey.trim()) {
setError('Private key is required');
return;
}
setError('');
setLoading(true);
try {
await onSetPrivateKey(privateKey.trim());
setPrivateKey('');
toast.success('Private key set');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to set private key');
} finally {
setLoading(false);
}
};
const handleReboot = async () => {
if (
!confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.')
) {
return;
}
setError('');
setRebooting(true);
try {
await onReboot();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reboot radio');
} finally {
setRebooting(false);
}
};
const handleAdvertise = async () => {
setAdvertising(true);
try {
await onAdvertise();
} finally {
setAdvertising(false);
}
};
const handleCleanup = async () => {
const days = parseInt(retentionDays, 10);
if (isNaN(days) || days < 1) {
toast.error('Invalid retention days', {
description: 'Retention days must be at least 1',
});
return;
}
setCleaning(true);
try {
const result = await api.runMaintenance(days);
toast.success('Database cleanup complete', {
description: `Deleted ${result.packets_deleted} old packet${result.packets_deleted === 1 ? '' : 's'}`,
});
await onHealthRefresh();
} catch (err) {
console.error('Failed to run maintenance:', err);
toast.error('Database cleanup failed', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setCleaning(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Radio & Settings</DialogTitle>
<DialogDescription className="sr-only">
{activeTab === 'radio' && 'Configure radio frequency, power, and location settings'}
{activeTab === 'identity' && 'Manage radio name, public key, and private key'}
{activeTab === 'serial' && 'View serial port connection and configure contact sync'}
{activeTab === 'database' && 'View database statistics and clean up old packets'}
{activeTab === 'advertise' && 'Send a flood advertisement to announce your presence'}
</DialogDescription>
</DialogHeader>
{!config ? (
<div className="py-8 text-center text-muted-foreground">Loading configuration...</div>
) : (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as SettingsTab)}
className="w-full"
>
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="radio">Radio</TabsTrigger>
<TabsTrigger value="identity">Identity</TabsTrigger>
<TabsTrigger value="serial">Serial</TabsTrigger>
<TabsTrigger value="database">Database</TabsTrigger>
<TabsTrigger value="advertise">Advertise</TabsTrigger>
</TabsList>
{/* Radio Config Tab */}
<TabsContent value="radio" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="preset">Preset</Label>
<select
id="preset"
value={currentPreset}
onChange={(e) => handlePresetChange(e.target.value)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="custom">Custom</option>
{RADIO_PRESETS.map((preset) => (
<option key={preset.name} value={preset.name}>
{preset.name}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="freq">Frequency (MHz)</Label>
<Input
id="freq"
type="number"
step="any"
value={freq}
onChange={(e) => setFreq(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bw">Bandwidth (kHz)</Label>
<Input
id="bw"
type="number"
step="any"
value={bw}
onChange={(e) => setBw(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sf">Spreading Factor</Label>
<Input
id="sf"
type="number"
min="7"
max="12"
value={sf}
onChange={(e) => setSf(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cr">Coding Rate</Label>
<Input
id="cr"
type="number"
min="5"
max="8"
value={cr}
onChange={(e) => setCr(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tx-power">TX Power (dBm)</Label>
<Input
id="tx-power"
type="number"
value={txPower}
onChange={(e) => setTxPower(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-tx">Max TX Power</Label>
<Input id="max-tx" type="number" value={config.max_tx_power} disabled />
</div>
</div>
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Location</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGetLocation}
disabled={gettingLocation}
>
{gettingLocation ? 'Getting...' : '📍 Use My Location'}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="lat" className="text-xs text-muted-foreground">
Latitude
</Label>
<Input
id="lat"
type="number"
step="any"
value={lat}
onChange={(e) => setLat(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lon" className="text-xs text-muted-foreground">
Longitude
</Label>
<Input
id="lon"
type="number"
step="any"
value={lon}
onChange={(e) => setLon(e.target.value)}
/>
</div>
</div>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<Button onClick={handleSaveRadioConfig} disabled={loading} className="w-full">
{loading ? 'Saving...' : 'Save Radio Config'}
</Button>
</TabsContent>
{/* Identity Tab */}
<TabsContent value="identity" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input
id="public-key"
value={config.public_key}
disabled
className="font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="name">Radio Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<Button onClick={handleSaveIdentity} disabled={loading} className="w-full">
{loading ? 'Saving...' : 'Set Name'}
</Button>
<Separator />
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
/>
<Button
onClick={handleSetPrivateKey}
disabled={loading || !privateKey.trim()}
className="w-full"
>
Set Private Key
</Button>
</div>
<Separator />
<Alert variant="warning">
<AlertDescription>
Changes to name or private key require a radio reboot to take effect.
</AlertDescription>
</Alert>
<Button
variant="outline"
onClick={handleReboot}
disabled={rebooting || loading}
className="w-full border-yellow-500/50 text-yellow-200 hover:bg-yellow-500/10"
>
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
</Button>
{error && <div className="text-sm text-destructive">{error}</div>}
</TabsContent>
{/* Serial Tab */}
<TabsContent value="serial" className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Serial Port</Label>
{health?.serial_port ? (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<code className="px-2 py-1 bg-muted rounded text-foreground text-sm">
{health.serial_port}
</code>
</div>
) : (
<div className="flex items-center gap-2 text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-gray-500" />
<span>Not connected</span>
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="max-contacts">Max Contacts on Radio</Label>
<Input
id="max-contacts"
type="number"
min="1"
max="1000"
value={maxRadioContacts}
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Recent non-repeater contacts loaded to radio for DM auto-ACK (1-1000)
</p>
</div>
<Button onClick={handleSaveSerial} disabled={loading} className="w-full">
{loading ? 'Saving...' : 'Save Settings'}
</Button>
{error && <div className="text-sm text-destructive">{error}</div>}
</TabsContent>
{/* Database Tab */}
<TabsContent value="database" className="space-y-4 mt-4">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Database size</span>
<span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
</div>
{health?.oldest_undecrypted_timestamp ? (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="font-medium">
{formatTime(health.oldest_undecrypted_timestamp)}
<span className="text-muted-foreground ml-1">
(
{Math.floor(
(Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400
)}{' '}
days old)
</span>
</span>
</div>
) : (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="text-muted-foreground">None</span>
</div>
)}
</div>
<Separator />
<div className="space-y-3">
<Label>Cleanup Old Packets</Label>
<p className="text-xs text-muted-foreground">
Delete undecrypted packets older than the specified days. This helps manage
storage for packets that couldn't be decrypted (unknown channel keys).
</p>
<div className="flex gap-2 items-end">
<div className="space-y-1">
<Label htmlFor="retention-days" className="text-xs">
Days to retain
</Label>
<Input
id="retention-days"
type="number"
min="1"
max="365"
value={retentionDays}
onChange={(e) => setRetentionDays(e.target.value)}
className="w-24"
/>
</div>
<Button variant="outline" onClick={handleCleanup} disabled={cleaning}>
{cleaning ? 'Cleaning...' : 'Cleanup'}
</Button>
</div>
</div>
</TabsContent>
{/* Advertise Tab */}
<TabsContent value="advertise" className="space-y-4 mt-4">
<div className="text-center py-8">
<p className="text-muted-foreground mb-6">
Send a flood advertisement to announce your presence on the mesh network.
</p>
<Button
size="lg"
onClick={handleAdvertise}
disabled={advertising || !health?.radio_connected}
className="bg-green-600 hover:bg-green-700 text-white px-12 py-6 text-lg"
>
{advertising ? 'Sending...' : 'Send Advertisement'}
</Button>
{!health?.radio_connected && (
<p className="text-sm text-destructive mt-4">Radio not connected</p>
)}
</div>
</TabsContent>
</Tabs>
)}
</DialogContent>
</Dialog>
);
}
+9 -45
View File
@@ -7,20 +7,11 @@ import { toast } from './ui/sonner';
interface StatusBarProps {
health: HealthStatus | null;
config: RadioConfig | null;
onConfigClick: () => void;
onMaintenanceClick: () => void;
onAdvertise: () => void;
onSettingsClick: () => void;
onMenuClick?: () => void;
}
export function StatusBar({
health,
config,
onConfigClick,
onMaintenanceClick,
onAdvertise,
onMenuClick,
}: StatusBarProps) {
export function StatusBar({ health, config, onSettingsClick, onMenuClick }: StatusBarProps) {
const connected = health?.radio_connected ?? false;
const [reconnecting, setReconnecting] = useState(false);
@@ -62,21 +53,10 @@ export function StatusBar({
</span>
</div>
{health?.serial_port && (
<div className="hidden xl:flex items-center gap-1 text-[#888]">
Port: <span className="text-[#e0e0e0]">{health.serial_port}</span>
</div>
)}
{config && (
<>
<div className="hidden lg:flex items-center gap-1 text-[#888]">
<span className="text-[#e0e0e0]">{config.name || 'Unnamed'}</span>
</div>
<div className="hidden xl:flex items-center gap-1 text-[#888]">
{config.radio.freq} MHz/SF{config.radio.sf}/CR{config.radio.cr}/{config.tx_power}dBm
</div>
</>
<div className="hidden lg:flex items-center gap-1 text-[#888]">
<span className="text-[#e0e0e0]">{config.name || 'Unnamed'}</span>
</div>
)}
{/* Spacer to push buttons right on mobile */}
@@ -92,29 +72,13 @@ export function StatusBar({
</button>
)}
<button
onClick={onAdvertise}
disabled={!connected}
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444] disabled:bg-[#333] disabled:text-[#666] disabled:cursor-not-allowed"
>
Advertise
</button>
<button
onClick={onMaintenanceClick}
className="px-2 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444]"
title="Database Maintenance"
>
<span role="img" aria-label="Settings">
&#9881;&#65039;
</span>
</button>
<button
onClick={onConfigClick}
onClick={onSettingsClick}
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444]"
>
<span role="img" aria-label="Radio">
&#128251;
<span role="img" aria-label="Settings">
&#128295;
</span>{' '}
Config
Radio & Config
</button>
</div>
);
+152
View File
@@ -0,0 +1,152 @@
import { describe, it, expect } from 'vitest';
// Radio presets - duplicated from SettingsModal for testing
// In a real app, these would be in a shared constants file
interface RadioPreset {
name: string;
freq: number;
bw: number;
sf: number;
cr: number;
}
const RADIO_PRESETS: RadioPreset[] = [
{ name: 'USA/Canada', freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
{ name: 'Australia', freq: 915.8, bw: 250, sf: 10, cr: 5 },
{ name: 'Australia (narrow)', freq: 916.575, bw: 62.5, sf: 7, cr: 8 },
{ name: 'Australia SA, WA', freq: 923.125, bw: 62.5, sf: 8, cr: 8 },
{ name: 'Australia QLD', freq: 923.125, bw: 62.5, sf: 8, cr: 5 },
{ name: 'New Zealand', freq: 917.375, bw: 250, sf: 11, cr: 5 },
{ name: 'New Zealand (narrow)', freq: 917.375, bw: 62.5, sf: 7, cr: 5 },
{ name: 'EU/UK/Switzerland Long Range', freq: 869.525, bw: 250, sf: 11, cr: 5 },
{ name: 'EU/UK/Switzerland Medium Range', freq: 869.525, bw: 250, sf: 10, cr: 5 },
{ name: 'EU/UK/Switzerland Narrow', freq: 869.618, bw: 62.5, sf: 8, cr: 8 },
{ name: 'Czech Republic (Narrow)', freq: 869.432, bw: 62.5, sf: 7, cr: 5 },
{ name: 'EU 433MHz Long Range', freq: 433.65, bw: 250, sf: 11, cr: 5 },
{ name: 'Portugal 433MHz', freq: 433.375, bw: 62.5, sf: 9, cr: 6 },
{ name: 'Portugal 868MHz', freq: 869.618, bw: 62.5, sf: 7, cr: 6 },
{ name: 'Vietnam', freq: 920.25, bw: 250, sf: 11, cr: 5 },
];
// Preset detection function - matches the logic in SettingsModal
function detectPreset(freq: number, bw: number, sf: number, cr: number): string {
for (const preset of RADIO_PRESETS) {
if (preset.freq === freq && preset.bw === bw && preset.sf === sf && preset.cr === cr) {
return preset.name;
}
}
return 'custom';
}
// Find preset by name
function findPreset(name: string): RadioPreset | undefined {
return RADIO_PRESETS.find((p) => p.name === name);
}
describe('Radio Presets', () => {
describe('detectPreset', () => {
it('detects USA/Canada preset', () => {
expect(detectPreset(910.525, 62.5, 7, 5)).toBe('USA/Canada');
});
it('detects Australia preset', () => {
expect(detectPreset(915.8, 250, 10, 5)).toBe('Australia');
});
it('detects Australia (narrow) preset', () => {
expect(detectPreset(916.575, 62.5, 7, 8)).toBe('Australia (narrow)');
});
it('detects EU/UK/Switzerland Long Range preset', () => {
expect(detectPreset(869.525, 250, 11, 5)).toBe('EU/UK/Switzerland Long Range');
});
it('detects EU/UK/Switzerland Medium Range preset', () => {
expect(detectPreset(869.525, 250, 10, 5)).toBe('EU/UK/Switzerland Medium Range');
});
it('detects EU/UK/Switzerland Narrow preset', () => {
expect(detectPreset(869.618, 62.5, 8, 8)).toBe('EU/UK/Switzerland Narrow');
});
it('returns custom for non-matching values', () => {
expect(detectPreset(900, 250, 10, 5)).toBe('custom');
});
it('returns custom when one value differs', () => {
// Same as USA/Canada but with different SF
expect(detectPreset(910.525, 62.5, 8, 5)).toBe('custom');
});
it('returns custom when bandwidth differs', () => {
// Same as USA/Canada but with different BW
expect(detectPreset(910.525, 125, 7, 5)).toBe('custom');
});
it('returns custom when coding rate differs', () => {
// Same as USA/Canada but with different CR
expect(detectPreset(910.525, 62.5, 7, 6)).toBe('custom');
});
});
describe('findPreset', () => {
it('finds preset by exact name', () => {
const preset = findPreset('USA/Canada');
expect(preset).toBeDefined();
expect(preset?.freq).toBe(910.525);
expect(preset?.bw).toBe(62.5);
expect(preset?.sf).toBe(7);
expect(preset?.cr).toBe(5);
});
it('returns undefined for unknown preset', () => {
expect(findPreset('Unknown Preset')).toBeUndefined();
});
it('returns undefined for custom', () => {
expect(findPreset('custom')).toBeUndefined();
});
});
describe('preset round-trip', () => {
it('all presets can be detected after being applied', () => {
for (const preset of RADIO_PRESETS) {
const detected = detectPreset(preset.freq, preset.bw, preset.sf, preset.cr);
expect(detected).toBe(preset.name);
}
});
});
describe('preset values are valid LoRa parameters', () => {
it('all frequencies are in valid ISM bands', () => {
for (const preset of RADIO_PRESETS) {
// 433 MHz: 433.05-434.79, EU 868: 863-870, US/AU/NZ/VN 900: 902-928
const valid433 = preset.freq >= 433 && preset.freq <= 435;
const validEU = preset.freq >= 863 && preset.freq <= 870;
const valid900 = preset.freq >= 902 && preset.freq <= 928;
expect(valid433 || validEU || valid900).toBe(true);
}
});
it('all spreading factors are valid (7-12)', () => {
for (const preset of RADIO_PRESETS) {
expect(preset.sf).toBeGreaterThanOrEqual(7);
expect(preset.sf).toBeLessThanOrEqual(12);
}
});
it('all coding rates are valid (5-8 for 4/5 to 4/8)', () => {
for (const preset of RADIO_PRESETS) {
expect(preset.cr).toBeGreaterThanOrEqual(5);
expect(preset.cr).toBeLessThanOrEqual(8);
}
});
it('all bandwidths are standard LoRa values', () => {
const validBandwidths = [7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500];
for (const preset of RADIO_PRESETS) {
expect(validBandwidths).toContain(preset.bw);
}
});
});
});
+1
View File
@@ -42,6 +42,7 @@ export interface HealthStatus {
radio_connected: boolean;
serial_port: string | null;
database_size_mb: number;
oldest_undecrypted_timestamp: number | null;
}
export interface MaintenanceResult {