import { useState, useEffect, useMemo } from 'react'; import { MapPinned } from 'lucide-react'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; import { Button } from '../ui/button'; import { Separator } from '../ui/separator'; import { toast } from '../ui/sonner'; import { Checkbox } from '../ui/checkbox'; import { RADIO_PRESETS } from '../../utils/radioPresets'; import { stripRegionScopePrefix } from '../../utils/regionScope'; import type { AppSettings, AppSettingsUpdate, HealthStatus, RadioAdvertMode, RadioConfig, RadioConfigUpdate, RadioDiscoveryResponse, RadioDiscoveryTarget, } from '../../types'; export function SettingsRadioSection({ config, health, appSettings, pageMode, onSave, onSaveAppSettings, onSetPrivateKey, onReboot, onDisconnect, onReconnect, onAdvertise, meshDiscovery, meshDiscoveryLoadingTarget, onDiscoverMesh, onClose, className, }: { config: RadioConfig; health: HealthStatus | null; appSettings: AppSettings; pageMode: boolean; onSave: (update: RadioConfigUpdate) => Promise; onSaveAppSettings: (update: AppSettingsUpdate) => Promise; onSetPrivateKey: (key: string) => Promise; onReboot: () => Promise; onDisconnect: () => Promise; onReconnect: () => Promise; onAdvertise: (mode: RadioAdvertMode) => Promise; meshDiscovery: RadioDiscoveryResponse | null; meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null; onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise; onClose: () => void; className?: string; }) { // 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 [pathHashMode, setPathHashMode] = useState('0'); const [advertLocationSource, setAdvertLocationSource] = useState<'off' | 'current'>('current'); const [multiAcksEnabled, setMultiAcksEnabled] = useState(false); const [gettingLocation, setGettingLocation] = useState(false); const [busy, setBusy] = useState(false); const [rebooting, setRebooting] = useState(false); const [error, setError] = useState(null); // Identity state const [privateKey, setPrivateKey] = useState(''); const [identityBusy, setIdentityBusy] = useState(false); const [identityRebooting, setIdentityRebooting] = useState(false); const [identityError, setIdentityError] = useState(null); // Flood & advert control state const [advertIntervalHours, setAdvertIntervalHours] = useState('0'); const [floodScope, setFloodScope] = useState(''); const [maxRadioContacts, setMaxRadioContacts] = useState(''); const [floodBusy, setFloodBusy] = useState(false); const [floodError, setFloodError] = useState(null); // Advertise state const [advertisingMode, setAdvertisingMode] = useState(null); const [discoverError, setDiscoverError] = useState(null); const [connectionBusy, setConnectionBusy] = useState(false); useEffect(() => { 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)); setPathHashMode(String(config.path_hash_mode)); setAdvertLocationSource(config.advert_location_source ?? 'current'); setMultiAcksEnabled(config.multi_acks_enabled ?? false); }, [config]); useEffect(() => { setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600))); setFloodScope(stripRegionScopePrefix(appSettings.flood_scope)); setMaxRadioContacts(String(appSettings.max_radio_contacts)); }, [appSettings]); 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 buildUpdate = (): RadioConfigUpdate | null => { const parsedLat = parseFloat(lat); const parsedLon = parseFloat(lon); const parsedTxPower = parseInt(txPower, 10); const parsedFreq = parseFloat(freq); const parsedBw = parseFloat(bw); const parsedSf = parseInt(sf, 10); const parsedCr = parseInt(cr, 10); if ( [parsedLat, parsedLon, parsedTxPower, parsedFreq, parsedBw, parsedSf, parsedCr].some((v) => isNaN(v) ) ) { setError('All numeric fields must have valid values'); return null; } const parsedPathHashMode = parseInt(pathHashMode, 10); return { name, lat: parsedLat, lon: parsedLon, tx_power: parsedTxPower, ...(advertLocationSource !== (config.advert_location_source ?? 'current') ? { advert_location_source: advertLocationSource } : {}), ...(multiAcksEnabled !== (config.multi_acks_enabled ?? false) ? { multi_acks_enabled: multiAcksEnabled } : {}), radio: { freq: parsedFreq, bw: parsedBw, sf: parsedSf, cr: parsedCr, }, ...(config.path_hash_mode_supported && !isNaN(parsedPathHashMode) && parsedPathHashMode !== config.path_hash_mode ? { path_hash_mode: parsedPathHashMode } : {}), }; }; const handleSave = async () => { setError(null); const update = buildUpdate(); if (!update) return; setBusy(true); try { await onSave(update); toast.success('Radio config saved'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save'); } finally { setBusy(false); } }; const handleSaveAndReboot = async () => { setError(null); const update = buildUpdate(); if (!update) return; setBusy(true); try { await onSave(update); toast.success('Radio config saved, rebooting...'); setRebooting(true); await onReboot(); if (!pageMode) { onClose(); } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save'); } finally { setRebooting(false); setBusy(false); } }; const handleSetPrivateKey = async () => { if (!privateKey.trim()) { setIdentityError('Private key is required'); return; } setIdentityError(null); setIdentityBusy(true); try { await onSetPrivateKey(privateKey.trim()); setPrivateKey(''); toast.success('Private key set, rebooting...'); setIdentityRebooting(true); await onReboot(); if (!pageMode) { onClose(); } } catch (err) { setIdentityError(err instanceof Error ? err.message : 'Failed to set private key'); } finally { setIdentityRebooting(false); setIdentityBusy(false); } }; const handleSaveFloodSettings = async () => { setFloodError(null); setFloodBusy(true); try { const update: AppSettingsUpdate = {}; const hours = parseInt(advertIntervalHours, 10); const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600; if (newAdvertInterval !== appSettings.advert_interval) { update.advert_interval = newAdvertInterval; } if (floodScope !== stripRegionScopePrefix(appSettings.flood_scope)) { update.flood_scope = floodScope; } const newMaxRadioContacts = parseInt(maxRadioContacts, 10); if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings.max_radio_contacts) { update.max_radio_contacts = newMaxRadioContacts; } if (Object.keys(update).length > 0) { await onSaveAppSettings(update); } toast.success('Settings saved'); } catch (err) { setFloodError(err instanceof Error ? err.message : 'Failed to save'); } finally { setFloodBusy(false); } }; const handleAdvertise = async (mode: RadioAdvertMode) => { setAdvertisingMode(mode); try { await onAdvertise(mode); } finally { setAdvertisingMode(null); } }; const handleDiscover = async (target: RadioDiscoveryTarget) => { setDiscoverError(null); try { await onDiscoverMesh(target); } catch (err) { setDiscoverError(err instanceof Error ? err.message : 'Failed to run mesh discovery'); } }; const radioState = health?.radio_state ?? (health?.radio_initializing ? 'initializing' : 'disconnected'); const connectionActionLabel = radioState === 'paused' ? 'Reconnect' : radioState === 'connected' || radioState === 'initializing' ? 'Disconnect' : 'Stop Trying'; const connectionStatusLabel = radioState === 'connected' ? health?.connection_info || 'Connected' : radioState === 'initializing' ? `Initializing ${health?.connection_info || 'radio'}` : radioState === 'connecting' ? `Attempting to connect${health?.connection_info ? ` to ${health.connection_info}` : ''}` : radioState === 'paused' ? `Connection paused${health?.connection_info ? ` (${health.connection_info})` : ''}` : 'Not connected'; const deviceInfoLabel = useMemo(() => { const info = health?.radio_device_info; if (!info) { return null; } const model = info.model?.trim() || null; const firmwareParts = [info.firmware_build?.trim(), info.firmware_version?.trim()].filter( (value): value is string => Boolean(value) ); const capacityParts = [ typeof info.max_contacts === 'number' ? `${info.max_contacts} contacts` : null, typeof info.max_channels === 'number' ? `${info.max_channels} channels` : null, ].filter((value): value is string => value !== null); if (!model && firmwareParts.length === 0 && capacityParts.length === 0) { return null; } let label = model ?? 'Radio'; if (firmwareParts.length > 0) { label += ` running ${firmwareParts.join('/')}`; } if (capacityParts.length > 0) { label += ` (max: ${capacityParts.join(', ')})`; } return label; }, [health?.radio_device_info]); const handleConnectionAction = async () => { setConnectionBusy(true); try { if (radioState === 'paused') { await onReconnect(); toast.success('Reconnect requested'); } else { await onDisconnect(); toast.success('Radio connection paused'); } } catch (err) { toast.error('Failed to change radio connection state', { description: err instanceof Error ? err.message : 'Check radio connection and try again', }); } finally { setConnectionBusy(false); } }; return (
{/* ── Connection ── */}
{connectionStatusLabel}
{deviceInfoLabel &&

{deviceInfoLabel}

}

Disconnect pauses automatic reconnect attempts so another device can use the radio.

{/* ── Identity ── */}
setName(e.target.value)} />
setPrivateKey(e.target.value)} placeholder="64-character hex private key" />
{identityError && (
{identityError}
)} {/* ── Radio Parameters ── */}
setFreq(e.target.value)} />
setBw(e.target.value)} />
setSf(e.target.value)} />
setCr(e.target.value)} />
setTxPower(e.target.value)} />
{config.path_hash_mode_supported && (

Compatibility Warning

ALL nodes along a message's route — your radio, every repeater, and the recipient — must be running firmware that supports the selected mode. Messages sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.

)} {/* ── Location ── */}
setLat(e.target.value)} />
setLon(e.target.value)} />

Companion-radio firmware does not distinguish between saved coordinates and live GPS here. When enabled, adverts include the node's current location state. That may be the last coordinates you set from RemoteTerm or live GPS coordinates if the node itself is already updating them. RemoteTerm cannot enable GPS on the node through the interface library.

{error && (
{error}
)}

Some settings may require a reboot to take effect on some radios.

{/* ── Messaging ── */}
setMultiAcksEnabled(checked === true)} className="mt-0.5" />

When enabled, the radio sends one extra direct ACK transmission before the normal ACK for received direct messages. This is a firmware-level receive behavior, not a RemoteTerm retry setting.

onSaveAppSettings({ auto_resend_channel: checked === true }) } className="mt-0.5" />

When enabled, outgoing channel messages that receive no echo within 2 seconds are automatically resent once (byte-perfect, within the 30-second dedup window). Repeaters that already heard the original will ignore the duplicate. This functionality will NOT create double-sent/duplicate messages.

setFloodScope(e.target.value)} placeholder="MyRegion" />

Tag outgoing flood messages with a region name (e.g. MyRegion). Repeaters configured for that region can forward the traffic, while repeaters configured to deny other regions may drop it. Leave empty to disable.

setMaxRadioContacts(e.target.value)} />

Configured radio contact capacity. Favorites reload first, then background maintenance refills to about 80% of this value and offloads once occupancy reaches about 95%.

{health?.radio_device_info?.max_contacts != null && Number(maxRadioContacts) > health.radio_device_info.max_contacts && (

Your radio reports a hardware limit of {health.radio_device_info.max_contacts}{' '} contacts. The effective cap will be limited to what the radio supports.

)}
{floodError && (
{floodError}
)} {/* ── Advertising & Discovery ── */}
setAdvertIntervalHours(e.target.value)} className="w-28" /> hours (0 = off)

How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour. Recommended: 24 hours or higher.

Flood adverts propagate through repeaters. Zero-hop adverts are local-only and use less airtime.

{!health?.radio_connected && (

Radio not connected

)}

Discover nearby node types that currently respond to mesh discovery requests: repeaters and sensors.

{[ { target: 'repeaters', label: 'Discover Repeaters' }, { target: 'sensors', label: 'Discover Sensors' }, { target: 'all', label: 'Discover Both' }, ].map(({ target, label }) => ( ))}
{!health?.radio_connected && (

Radio not connected

)} {discoverError && (

{discoverError}

)} {meshDiscovery && (

Last sweep: {meshDiscovery.results.length} node {meshDiscovery.results.length === 1 ? '' : 's'}

{meshDiscovery.duration_seconds.toFixed(0)}s listen window

{meshDiscovery.results.length === 0 ? (

No supported nodes responded during the last discovery sweep.

) : (
{meshDiscovery.results.map((result) => (
{result.name ?? {result.node_type}} heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
{result.name && (

{result.node_type}

)}

{result.public_key}

Heard here: {result.local_snr ?? 'n/a'} dB SNR / {result.local_rssi ?? 'n/a'}{' '} dBm RSSI. Remote heard us: {result.remote_snr ?? 'n/a'} dB SNR.

))}
)}
)}
); }