diff --git a/README.md b/README.md
index 4ace8d0..fe56657 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,16 @@
**A next-generation, web-based Channel Programming Software (CPS) for supported radios.**
-NeonPlug supports the Baofeng DM-32UV / DP570UV and UV5R-Mini, with more radios on the way. Program your radio directly from your browserβno software installation required. Connect via Web Serial (USB) or, where supported, Bluetooth Low Energy (BLE). A sleek, cyberpunk neon-themed UI puts channels, zones, scan lists, contacts, and settings at your fingertips.
+NeonPlug lets you program your radio directly from your browserβno software installation required. Connect via Web Serial (USB) or, where supported, Bluetooth Low Energy (BLE). A sleek, cyberpunk neon-themed UI puts channels, zones, scan lists, contacts, and settings at your fingertips.
+
+**Supported radios:**
+| Radio | Manufacturer | Bands | Connection |
+|---|---|---|---|
+| DM-32UV / DP570UV | Baofeng | VHF + UHF (DMR/Analog) | USB |
+| UV5R-Mini | Baofeng | VHF + UHF (Analog) | USB or BLE |
+| FT-65 / FT-65R / FT-65E | Yaesu | VHF + UHF (Analog) | USB (SCU-35) |
+| FT-4 / FT-4XR / FT-4XE / FT-4VR | Yaesu | VHF + UHF (Analog) | USB (SCU-35) |
+| FT-25R | Yaesu | VHF (Analog) | USB (SCU-35) |
**π Try it live:** [https://neonplug.app](https://neonplug.app) Β· **π₯ [Download offline version](https://neonplug.app)** (single-file, no install)
@@ -41,9 +50,9 @@ NeonPlug supports the Baofeng DM-32UV / DP570UV and UV5R-Mini, with more radios
The `.neonplug` file is a zipped JSON archive. You can unzip it to inspect the contents in a semi-human-readable way (e.g. `codeplug.json` inside the zip). Editing the JSON directly is not recommendedβuse NeonPlugβs import/export and in-app editing instead to avoid invalid data or corruption.
### π₯ Contact & Group Management
-- **Digital Contacts** - Manage DMR contacts with full talk group support
-- **RX Groups** - Create and organize receive groups
-- **Scan Lists** - Configure scan lists across zones
+- **Digital Contacts** - Manage DMR contacts with full talk group support (DM-32UV)
+- **RX Groups** - Create and organize receive groups (DM-32UV)
+- **Scan Lists** - Configure scan lists across zones (DM-32UV)
### π¨ Modern Interface
- **Cyberpunk Theme** - Eye-catching neon UI that's both beautiful and functional
@@ -58,7 +67,7 @@ Just visit **[neonplug.app](https://neonplug.app)** in a Chrome-based browser (C
**Requirements:**
- Chrome, Edge, Opera, or Brave browser (for Web Serial API support)
-- A supported radio (e.g. DM-32UV / DP570UV or UV5R-Mini) with USB cableβor BLE for radios that support it
+- A supported radio (see table above) with the appropriate USB cableβor BLE for the UV5R-Mini
### π₯ Offline mode
diff --git a/neonplug_banner.jpg b/neonplug_banner.jpg
index 4ec584e..24ec966 100644
Binary files a/neonplug_banner.jpg and b/neonplug_banner.jpg differ
diff --git a/src/components/about/AboutTab.tsx b/src/components/about/AboutTab.tsx
index 6377ea8..fc5de17 100644
--- a/src/components/about/AboutTab.tsx
+++ b/src/components/about/AboutTab.tsx
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useDebugStore } from '../../store/debugStore';
+import { RADIO_DESCRIPTORS } from '../../radios';
import { Card } from '../ui/Card';
import { SectionTitle } from '../ui/SectionTitle';
import { Button } from '../ui/Button';
@@ -23,7 +24,7 @@ export const AboutTab: React.FC = () => {
About NeonPlug
- Channel programming software. Supports: DM-32UV, DP570UV.
+ Online Digital CPS β program your radio directly from your browser.
@@ -101,14 +102,43 @@ npm run build:single
Project Information
- NeonPlug is a next-generation, web-based Channel Programming Software (CPS) for supported radios, including DM-32UV / DP570UV and UV5R-Mini. Built with a modern cyberpunk neon-themed UI, it provides an intuitive interface for managing channels, zones, scan lists, contacts, and radio settings.
+ NeonPlug is a next-generation, web-based Channel Programming Software (CPS). Built with a cyberpunk neon-themed UI, it provides an intuitive interface for managing channels, zones, scan lists, contacts, and radio settings β all from your browser, with no drivers or software to install.
- This software implements protocol support for each radio, enabling full read and write operations directly from your web browser via the Web Serial API andβwhere supportedβBluetooth Low Energy (BLE).
+ Each radio's full protocol is implemented natively, enabling read and write operations via the Web Serial API and β where supported β Bluetooth Low Energy (BLE).
+ {/* Supported Radios */}
+
+ Supported Radios
+
+
+
+
+ Radio
+ Manufacturer
+ Connection
+
+
+
+ {RADIO_DESCRIPTORS.map((d) => (
+
+
+ {d.icon} {d.modelIds.join(' / ')}
+
+ {d.group ?? 'β'}
+
+ {d.supportsBle ? 'USB or BLE' : 'USB'}
+
+
+ ))}
+
+
+
+
+
{/* Codeplug format */}
Codeplug format (.neonplug)
@@ -162,8 +192,9 @@ npm run build:single
- This project implements the DM-32UV protocol specification, which was reverse-engineered
- through analysis of serial port captures and the official CPS software.
+ Radio protocols were implemented through reverse engineering β serial port captures,
+ analysis of official CPS software, and reference to open-source projects including
+ CHIRP. The DM-32UV protocol specification is documented separately.
diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx
index e0dab61..711093c 100644
--- a/src/components/layout/Toolbar.tsx
+++ b/src/components/layout/Toolbar.tsx
@@ -261,7 +261,7 @@ export const Toolbar: React.FC = () => {
await exportCodeplug(buildCodeplugData());
};
- const handleRead = async () => {
+ const handleRead = async (forcePortSelection = true) => {
window.focus();
try {
setConnectionError(null);
@@ -269,15 +269,15 @@ export const Toolbar: React.FC = () => {
setProgress(0);
setProgressMessage('Selecting port...');
setCurrentStep('Selecting port');
-
+
await readFromRadio((progress, message, step) => {
setProgress(progress);
setProgressMessage(message);
if (step) {
setCurrentStep(step);
}
- });
-
+ }, { forcePortSelection });
+
setConnectionError(null);
setLastOperationMode(null);
const modelLabel = useRadioStore.getState().radioInfo?.model ?? effectiveModel ?? undefined;
@@ -289,8 +289,7 @@ export const Toolbar: React.FC = () => {
}, 2000);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
- const displayError = errorMessage;
- setConnectionError(displayError);
+ setConnectionError(errorMessage);
setProgress(0);
setProgressMessage('Connection failed');
}
@@ -300,10 +299,14 @@ export const Toolbar: React.FC = () => {
if (lastOperationMode === 'write') {
handleWrite();
} else {
- window.location.reload();
+ handleRead(false);
}
};
+ const handleChangePort = () => {
+ handleRead(true);
+ };
+
const handleCloseModal = () => {
setConnectionError(null);
setLastOperationMode(null);
@@ -494,7 +497,7 @@ export const Toolbar: React.FC = () => {
handleRead()}
disabled={isConnecting || !webSerialSupported}
className={`rounded-r-none border-r border-white border-opacity-20 ${!webSerialSupported ? 'opacity-50 cursor-not-allowed' : ''}`}
title={!webSerialSupported ? 'Web Serial API not supported. Please use Chrome, Edge, Opera, or Brave.' : 'Read codeplug from current radio type'}
@@ -550,6 +553,7 @@ export const Toolbar: React.FC = () => {
steps={isWriting ? writeChannelsSteps : readSteps}
error={connectionError}
onRetry={handleRetry}
+ onChangePort={!isWriting ? handleChangePort : undefined}
onClose={handleCloseModal}
mode={isWriting ? 'write' : 'read'}
/>
diff --git a/src/components/settings/SettingsTab.tsx b/src/components/settings/SettingsTab.tsx
index 3073a17..a2a27af 100644
--- a/src/components/settings/SettingsTab.tsx
+++ b/src/components/settings/SettingsTab.tsx
@@ -90,6 +90,8 @@ export const SettingsTab: React.FC = () => {
const [showFirmwareWarning, setShowFirmwareWarning] = useState(false);
const { caps, model: effectiveModel } = useRadioCapabilities();
+ const settingsProfile = getSettingsProfileForModel(effectiveModel);
+
const EXPECTED_FIRMWARE = 'DM32.01.L01.048';
const hasRealFirmware = !!(radioInfo?.firmware && radioInfo.firmware !== '-' && radioInfo.firmware.trim() !== '');
const isNewerFirmware = !!(hasRealFirmware && caps?.isFirmware049OrNewer?.(radioInfo!.firmware));
@@ -504,10 +506,7 @@ export const SettingsTab: React.FC = () => {
{/* Boot / Startup Image Section - only when profile declares bootImage feature */}
- {(() => {
- const profile = getSettingsProfileForModel(effectiveModel);
- return profile?.features?.includes('bootImage');
- })() && (
+ {settingsProfile?.features?.includes('bootImage') && (
Boot / Startup Image
@@ -632,7 +631,7 @@ export const SettingsTab: React.FC = () => {
{/* Radio Configuration - profile-driven */}
{(() => {
- const profile = getSettingsProfileForModel(effectiveModel);
+ const profile = settingsProfile;
if (!profile) {
return radioSettings ? (
@@ -665,8 +664,8 @@ export const SettingsTab: React.FC = () => {
);
})()}
- {/* One Key Operation (DM-32 only; UV5R-Mini uses uv5rMiniSettings) */}
- {radioSettings && (!radioSettings.uv5rMiniSettings || radioSettings.analogCall) && (
+ {/* One Key Operation - only when profile declares the feature */}
+ {radioSettings && settingsProfile?.features?.includes('oneKeyOperation') && (
One Key Operation
@@ -939,7 +938,7 @@ export const SettingsTab: React.FC = () => {
)}
{/* GPS & APRS Settings */}
- {radioSettings && !radioSettings.uv5rMiniSettings && (
+ {radioSettings && settingsProfile?.features?.includes('gpsAprs') && (
GPS & APRS
@@ -1204,7 +1203,7 @@ export const SettingsTab: React.FC = () => {
)}
)}
-
+ {caps?.supportsAnalogEmergency && }
void;
+ onChangePort?: () => void;
onClose?: () => void;
mode?: 'read' | 'write';
}
@@ -40,6 +41,7 @@ export const ReadProgressModal: React.FC = ({
steps,
error,
onRetry,
+ onChangePort,
onClose,
mode = 'read',
}) => {
@@ -222,12 +224,20 @@ export const ReadProgressModal: React.FC = ({
Close
)}
+ {isError && onChangePort && (
+
+ Change Port
+
+ )}
{isError && onRetry && (
- Retry Connection
+ Retry
)}
diff --git a/src/components/ui/StartupModal.tsx b/src/components/ui/StartupModal.tsx
index f06b910..ea3cc56 100644
--- a/src/components/ui/StartupModal.tsx
+++ b/src/components/ui/StartupModal.tsx
@@ -67,6 +67,17 @@ export const StartupModal: React.FC = ({
}, [isOpen]);
const options = useMemo(() => getRadioPickerOptions(), []);
+ // Group options by manufacturer; ungrouped radios go under a blank key
+ const groupedOptions = useMemo(() => {
+ const groups = new Map();
+ for (const opt of options) {
+ const key = opt.group ?? '';
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key)!.push(opt);
+ }
+ return groups;
+ }, [options]);
+
// Default to first radio if none selected
const effectiveSelected = selectedRadioModel ?? options[0]?.modelId ?? null;
const selectedOption = options.find(o => o.modelId === effectiveSelected);
@@ -108,20 +119,31 @@ export const StartupModal: React.FC = ({
Pick a radio
-
- {options.map((opt) => (
-
setSelectedRadioModel(opt.modelId)}
- className={`flex flex-col items-center justify-center p-4 rounded-lg border-2 transition-all ${
- effectiveSelected === opt.modelId
- ? 'border-neon-cyan bg-neon-cyan bg-opacity-10 shadow-glow-cyan'
- : 'border-cool-gray hover:border-neon-cyan hover:bg-opacity-5'
- }`}
- >
- {opt.label}
-
+
+ {Array.from(groupedOptions.entries()).map(([group, opts]) => (
+
+ {group && (
+
+ {group}
+
+ )}
+
+ {opts.map((opt) => (
+ setSelectedRadioModel(opt.modelId)}
+ className={`flex items-center justify-center px-3 py-2 rounded border-2 transition-all text-sm font-medium ${
+ effectiveSelected === opt.modelId
+ ? 'border-neon-cyan bg-neon-cyan bg-opacity-10 shadow-glow-cyan text-white'
+ : 'border-cool-gray hover:border-neon-cyan text-cool-gray hover:text-white'
+ }`}
+ >
+ {opt.label}
+
+ ))}
+
+
))}
diff --git a/src/hooks/useRadioConnection.ts b/src/hooks/useRadioConnection.ts
index 51f23ca..7914c44 100644
--- a/src/hooks/useRadioConnection.ts
+++ b/src/hooks/useRadioConnection.ts
@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import type { RadioProtocol } from '../types/radio';
import { createDefaultProtocol, createProtocolForModel } from '../radios';
+import { DM32UVProtocol } from '../radios/dm32uv/protocol';
import { getCapabilitiesForModel } from '../radios/capabilities';
import type { Contact } from '../models/Contact';
import { useRadioStore } from '../store/radioStore';
@@ -67,7 +68,8 @@ export function useRadioConnection() {
const { clearKeys: clearEncryptionKeys } = useEncryptionKeysStore();
const readFromRadio = useCallback(async (
- onProgress?: (progress: number, message: string, step?: string) => void
+ onProgress?: (progress: number, message: string, step?: string) => void,
+ { forcePortSelection = true }: { forcePortSelection?: boolean } = {}
) => {
setIsConnecting(true);
setError(null);
@@ -131,25 +133,30 @@ export function useRadioConnection() {
// on first connect, which would cause bulk read to be skipped if we used it here.
const caps = getCapabilitiesForModel(info.model ?? effectiveModel);
- if (caps?.supportsBulkRead && typeof (proto as any).bulkReadRequiredBlocks === 'function') {
+ // Narrow to DM32UVProtocol once; all DM32-specific calls go through this variable.
+ const dm32 = proto instanceof DM32UVProtocol ? proto : null;
+
+ if (caps?.supportsBulkRead && dm32) {
onProgress?.(15, 'Reading all memory blocks...', steps[3]);
- await (proto as any).bulkReadRequiredBlocks();
+ await dm32.bulkReadRequiredBlocks();
}
onProgress?.(20, 'Parsing channels...', steps[4]);
const channels = await proto.readChannels();
setChannels(channels);
- // Enrich radioInfo with firmware from cached image (UV5R-Mini path)
- if (typeof (proto as any).getFirmwareFromCache === 'function') {
- const fw = (proto as any).getFirmwareFromCache();
- if (fw) {
- const current = useRadioStore.getState().radioInfo;
- if (current) setRadioInfo({ ...current, firmware: fw });
- }
+
+ // Enrich radioInfo with firmware from cached image (UV5R-Mini and DM-32UV)
+ const fw = proto.getFirmwareFromCache?.();
+ if (fw) {
+ const current = useRadioStore.getState().radioInfo;
+ if (current) setRadioInfo({ ...current, firmware: fw });
+ }
+
+ if (dm32) {
+ setRawChannelData(dm32.rawChannelData);
+ setBlockMetadata(new Map(dm32.blockMetadata));
+ setBlockData(new Map(dm32.blockData));
}
- if ((proto as any).rawChannelData) setRawChannelData((proto as any).rawChannelData);
- if ((proto as any).allBlockMetadata) setBlockMetadata(new Map
((proto as any).allBlockMetadata));
- if ((proto as any).allBlockData) setBlockData(new Map((proto as any).allBlockData));
// Suppress per-item progress messages during config parsing; only surface the percentage.
const savedProgress = proto.onProgress;
@@ -159,46 +166,51 @@ export function useRadioConnection() {
onProgress?.(70, 'Parsing configuration from cache...', steps[5]);
- const zones = await proto.readZones();
- setZones(zones);
- if ((proto as any).rawZoneData) setRawZoneData((proto as any).rawZoneData);
+ if (caps?.supportsZones) {
+ const zones = await proto.readZones();
+ setZones(zones);
+ if (dm32) setRawZoneData(dm32.rawZoneData);
+ }
- const scanLists = await proto.readScanLists();
- setScanLists(scanLists);
- if ((proto as any).rawScanListData) setRawScanListData((proto as any).rawScanListData);
- if ((proto as any).blockData) setBlockData((proto as any).blockData);
+ if (caps?.supportsScanLists) {
+ const scanLists = await proto.readScanLists();
+ setScanLists(scanLists);
+ if (dm32) setRawScanListData(dm32.rawScanListData);
+ }
- try {
- const messages = await (proto as any).readQuickMessages();
- setMessages(messages);
- const rawMsgMap = new Map();
- for (const [i, raw] of (proto as any).rawMessageData.entries()) rawMsgMap.set(i, raw);
- setRawMessageData(rawMsgMap);
- } catch { console.warn('Could not read Quick Messages'); }
+ if (dm32) {
+ try {
+ const messages = await dm32.readQuickMessages();
+ setMessages(messages);
+ const rawMsgMap = new Map();
+ for (const [i, raw] of dm32.rawMessageData.entries()) rawMsgMap.set(i, raw);
+ setRawMessageData(rawMsgMap);
+ } catch { console.warn('Could not read Quick Messages'); }
- try {
- const radioIds = await proto.readDMRRadioIDs();
- setRadioIds(radioIds);
- const rawIdMap = new Map();
- for (const [i, raw] of (proto as any).rawDMRRadioIDData.entries()) rawIdMap.set(i, raw);
- setRawRadioIdData(rawIdMap);
- } catch { console.warn('Could not read DMR Radio IDs'); }
+ try {
+ const radioIds = await dm32.readDMRRadioIDs();
+ setRadioIds(radioIds);
+ const rawIdMap = new Map();
+ for (const [i, raw] of dm32.rawDMRRadioIDData.entries()) rawIdMap.set(i, raw);
+ setRawRadioIdData(rawIdMap);
+ } catch { console.warn('Could not read DMR Radio IDs'); }
- try {
- setCalibration(await (proto as any).readCalibration());
- } catch { console.warn('Could not read calibration data'); }
+ try {
+ setCalibration(await dm32.readCalibration());
+ } catch { console.warn('Could not read calibration data'); }
- try {
- const rxGroups = await (proto as any).readRXGroups();
- setRXGroups(rxGroups);
- const rawGroupMap = new Map();
- for (const [i, raw] of (proto as any).rawRXGroupData.entries()) rawGroupMap.set(i, raw);
- setRawGroupData(rawGroupMap);
- } catch { console.warn('Could not read RX Groups'); }
+ try {
+ const rxGroups = await dm32.readRXGroups();
+ setRXGroups(rxGroups);
+ const rawGroupMap = new Map();
+ for (const [i, raw] of dm32.rawRXGroupData.entries()) rawGroupMap.set(i, raw);
+ setRawGroupData(rawGroupMap);
+ } catch { console.warn('Could not read RX Groups'); }
- try {
- setQuickContacts(await (proto as any).readQuickContacts());
- } catch { console.warn('Could not read Talk Groups'); }
+ try {
+ setQuickContacts(await dm32.readQuickContacts());
+ } catch { console.warn('Could not read Talk Groups'); }
+ }
try {
onProgress?.(90, 'Reading configuration...', 'Reading configuration');
@@ -206,23 +218,23 @@ export function useRadioConnection() {
try {
const radioSettings = await proto.readRadioSettings();
if (radioSettings) setRadioSettings(radioSettings);
- if ((proto as any).rawRadioSettingsData) setRawRadioSettingsData((proto as any).rawRadioSettingsData);
+ if (dm32?.rawRadioSettingsData) setRawRadioSettingsData(dm32.rawRadioSettingsData);
} catch { console.warn('Could not read Radio Settings'); }
- try {
- const digitalEmergency = await (proto as any).readDigitalEmergencies();
- if (digitalEmergency) {
- setDigitalEmergencies(digitalEmergency.systems);
- setDigitalEmergencyConfig(digitalEmergency.config);
- }
- } catch { console.warn('Could not read Digital Emergency Systems'); }
-
- try {
- const analogEmergencies = await (proto as any).readAnalogEmergencies();
- if (analogEmergencies) setAnalogEmergencies(analogEmergencies);
- } catch { console.warn('Could not read Analog Emergency Systems'); }
-
- if ((proto as any).blockData) setBlockData((proto as any).blockData);
+ if (dm32) {
+ try {
+ const digitalEmergency = await dm32.readDigitalEmergencies();
+ if (digitalEmergency) {
+ setDigitalEmergencies(digitalEmergency.systems);
+ setDigitalEmergencyConfig(digitalEmergency.config);
+ }
+ } catch { console.warn('Could not read Digital Emergency Systems'); }
+
+ try {
+ const analogEmergencies = await dm32.readAnalogEmergencies();
+ if (analogEmergencies) setAnalogEmergencies(analogEmergencies);
+ } catch { console.warn('Could not read Analog Emergency Systems'); }
+ }
} catch { console.warn('Error reading configuration blocks'); }
proto.onProgress = savedProgress;
@@ -239,8 +251,12 @@ export function useRadioConnection() {
const transport = caps?.supportsBle
? (preferredTransport ?? caps?.preferredTransport ?? 'serial')
: undefined;
- onProgress?.(5, transport === 'ble' ? 'Select BLE device...' : 'Select serial port...', steps[0]);
- await protocol.connect({ forcePortSelection: true, ...(transport != null && { transport }) });
+ onProgress?.(5,
+ forcePortSelection
+ ? (transport === 'ble' ? 'Select BLE device...' : 'Select serial port...')
+ : 'Reconnecting to radio...',
+ steps[0]);
+ await protocol.connect({ forcePortSelection, ...(transport != null && { transport }) });
await performRead(protocol);
} catch (err) {
@@ -318,13 +334,12 @@ export function useRadioConnection() {
const contacts = await protocol.readContacts();
setContacts(contacts);
- // Store first contact block for debugging
- if ((protocol as any).rawContactBlockData) {
- setRawContactBlockData((protocol as any).rawContactBlockData, (protocol as any).rawContactBlockAddress || null);
+ const dm32 = protocol instanceof DM32UVProtocol ? protocol : null;
+ if (dm32?.rawContactBlockData) {
+ setRawContactBlockData(dm32.rawContactBlockData, dm32.rawContactBlockAddress);
}
- // Store all contact blocks for diagnostics
- if ((protocol as any).rawContactBlocks) {
- setRawContactBlocks((protocol as any).rawContactBlocks);
+ if (dm32?.rawContactBlocks) {
+ setRawContactBlocks(dm32.rawContactBlocks);
}
onProgress?.(100, `Successfully read ${contacts.length} contacts`);
@@ -365,7 +380,9 @@ export function useRadioConnection() {
setConnected(true);
}
onProgress?.(10, 'Reading boot image from radio...');
- const raw = await (protocol as any).readBootImage();
+ const dm32 = protocol instanceof DM32UVProtocol ? protocol : null;
+ if (!dm32) throw new Error('Boot image is only supported on DM-32UV');
+ const raw = await dm32.readBootImage();
setBootImageRaw(raw);
const parsed = parseBootImageHeader(raw);
setBootImageDescription(parsed.description || null);
@@ -408,7 +425,9 @@ export function useRadioConnection() {
setConnected(true);
}
onProgress?.(10, 'Writing boot image to radio...');
- await (protocol as any).writeBootImage(data);
+ const dm32 = protocol instanceof DM32UVProtocol ? protocol : null;
+ if (!dm32) throw new Error('Boot image is only supported on DM-32UV');
+ await dm32.writeBootImage(data);
setBootImageRaw(data);
const parsed = parseBootImageHeader(data);
setBootImageDescription(parsed.description || null);
@@ -537,16 +556,15 @@ export function useRadioConnection() {
// Use protocol for connected radio (write path)
protocol = createProtocolForModel(radioInfo?.model ?? '') ?? createDefaultProtocol();
-
+ const dm32 = protocol instanceof DM32UVProtocol ? protocol : null;
+
// Restore cache from store if available (DM-32 bulk read path)
- const storeState = useRadioStore.getState();
- const storeBlockData = storeState.blockData;
- const storeBlockMetadata = storeState.blockMetadata;
- if (typeof (protocol as any).restoreCacheFromStore === 'function') {
+ if (dm32) {
+ const storeState = useRadioStore.getState();
+ const storeBlockData = storeState.blockData;
+ const storeBlockMetadata = storeState.blockMetadata;
if (storeBlockData && storeBlockData.size > 0 && storeBlockMetadata && storeBlockMetadata.size > 0) {
- const dataCopy = new Map(storeBlockData);
- const metadataCopy = new Map(storeBlockMetadata);
- (protocol as any).restoreCacheFromStore(dataCopy, metadataCopy);
+ dm32.restoreCacheFromStore(new Map(storeBlockData), new Map(storeBlockMetadata));
} else {
console.warn('[Connection] Store cache is empty - will need to read all blocks from radio');
}
@@ -571,83 +589,63 @@ export function useRadioConnection() {
setRadioInfo(connectedRadioInfo);
setConnected(true);
- // Step 4: Write channels (and zones/scan lists for DM-32; UV5R-Mini uses writeChannels only)
- if (typeof (protocol as any).writeAllData === 'function') {
+ // Step 4: Write channels (and zones/scan lists for DM-32; analog radios use writeChannels only)
+ if (dm32) {
onProgress?.(20, 'Writing channels, zones, and scan lists to radio...', steps[4]);
- await (protocol as any).writeAllData(validChannels, filteredZones, filteredScanLists);
- } else if (typeof protocol.writeChannels === 'function') {
+ await dm32.writeAllData(validChannels, filteredZones, filteredScanLists);
+ } else {
onProgress?.(20, 'Writing channels to radio...', steps[4]);
await protocol.writeChannels(validChannels);
- } else {
- throw new Error('Protocol does not support writing channels');
}
- // Step 5: Write Talk Groups if they have been loaded (DM-32 only)
- if (typeof (protocol as any).writeQuickContacts === 'function') {
- const quickContactsStore = useQuickContactsStore.getState();
- const quickContacts = quickContactsStore.contacts;
+ if (dm32) {
+ // Step 5: Talk Groups
+ const quickContacts = useQuickContactsStore.getState().contacts;
if (quickContacts && quickContacts.length > 0) {
onProgress?.(90, `Writing ${quickContacts.length} talk group(s) to radio...`, steps[4]);
- await (protocol as any).writeQuickContacts(quickContacts);
+ await dm32.writeQuickContacts(quickContacts);
}
- }
- // Step 5.5: Write Quick Messages if they have been loaded (DM-32 only)
- if (typeof (protocol as any).writeQuickMessages === 'function') {
- const quickMessagesStore = useQuickMessagesStore.getState();
- const quickMessages = quickMessagesStore.messages;
+ // Step 5.5: Quick Messages
+ const quickMessages = useQuickMessagesStore.getState().messages;
if (quickMessages && quickMessages.length > 0) {
onProgress?.(92, `Writing ${quickMessages.length} quick message(s) to radio...`, steps[4]);
- await (protocol as any).writeQuickMessages(quickMessages);
+ await dm32.writeQuickMessages(quickMessages);
}
- }
- // Step 5.6: Write RX Groups if they have been loaded (DM-32 only)
- if (typeof (protocol as any).writeRXGroups === 'function') {
+ // Step 5.6: RX Groups
const rxGroupsStore = useRXGroupsStore.getState();
- const rxGroups = rxGroupsStore.groups;
- if (rxGroups && rxGroups.length > 0 && rxGroupsStore.groupsLoaded) {
- onProgress?.(93, `Writing ${rxGroups.length} RX group(s) to radio...`, steps[4]);
- await (protocol as any).writeRXGroups(rxGroups);
+ if (rxGroupsStore.groups.length > 0 && rxGroupsStore.groupsLoaded) {
+ onProgress?.(93, `Writing ${rxGroupsStore.groups.length} RX group(s) to radio...`, steps[4]);
+ await dm32.writeRXGroups(rxGroupsStore.groups);
}
- }
- // Step 5.7: Write DMR Radio IDs if they have been loaded (DM-32 only)
- const dmrRadioIDsStore = useDMRRadioIDsStore.getState();
- const dmrRadioIds = dmrRadioIDsStore.radioIds;
- if (dmrRadioIds && dmrRadioIds.length > 0) {
- onProgress?.(94, `Writing ${dmrRadioIds.length} DMR Radio ID(s) to radio...`, steps[4]);
- await protocol.writeDMRRadioIDs(dmrRadioIds);
- }
+ // Step 5.7: DMR Radio IDs
+ const dmrRadioIDsStore = useDMRRadioIDsStore.getState();
+ if (dmrRadioIDsStore.radioIds.length > 0) {
+ onProgress?.(94, `Writing ${dmrRadioIDsStore.radioIds.length} DMR Radio ID(s) to radio...`, steps[4]);
+ await dm32.writeDMRRadioIDs(dmrRadioIDsStore.radioIds);
+ }
- // Step 5.8: Write Encryption Keys if they have been loaded (DM-32 only)
- if (typeof (protocol as any).writeEncryptionKeys === 'function') {
+ // Step 5.8: Encryption Keys
const encryptionKeysStore = useEncryptionKeysStore.getState();
- const encryptionKeys = encryptionKeysStore.keys;
- if (encryptionKeys && encryptionKeys.length > 0 && encryptionKeysStore.keysLoaded) {
- onProgress?.(94, `Writing ${encryptionKeys.length} encryption key(s) to radio...`, steps[4]);
- await (protocol as any).writeEncryptionKeys(encryptionKeys);
+ if (encryptionKeysStore.keys.length > 0 && encryptionKeysStore.keysLoaded) {
+ onProgress?.(94, `Writing ${encryptionKeysStore.keys.length} encryption key(s) to radio...`, steps[4]);
+ await dm32.writeEncryptionKeys(encryptionKeysStore.keys);
}
- }
- // Step 5.9: Write Digital Emergency Systems if they have been loaded (DM-32 only)
- if (typeof (protocol as any).writeDigitalEmergencies === 'function') {
+ // Step 5.9: Digital Emergency Systems
const digitalEmergencyStore = useDigitalEmergencyStore.getState();
- const digitalEmergencySystems = digitalEmergencyStore.systems;
- const digitalEmergencyConfig = digitalEmergencyStore.config;
- if (digitalEmergencySystems.length > 0 && digitalEmergencyConfig) {
- onProgress?.(94, `Writing ${digitalEmergencySystems.length} digital emergency system(s) to radio...`, steps[4]);
- await (protocol as any).writeDigitalEmergencies(digitalEmergencySystems, digitalEmergencyConfig);
+ if (digitalEmergencyStore.systems.length > 0 && digitalEmergencyStore.config) {
+ onProgress?.(94, `Writing ${digitalEmergencyStore.systems.length} digital emergency system(s) to radio...`, steps[4]);
+ await dm32.writeDigitalEmergencies(digitalEmergencyStore.systems, digitalEmergencyStore.config);
}
- }
- // Step 5.10: Write Analog Emergency Systems if they have been loaded (DM-32 only)
- if (typeof (protocol as any).writeAnalogEmergencies === 'function') {
+ // Step 5.10: Analog Emergency Systems
const analogEmergencyStore = useAnalogEmergencyStore.getState();
- const analogEmergencySystems = analogEmergencyStore.systems;
- if (analogEmergencySystems.length > 0) {
- onProgress?.(94, `Writing ${analogEmergencySystems.length} analog emergency system(s) to radio...`, steps[4]);
- await (protocol as any).writeAnalogEmergencies(analogEmergencySystems);
+ if (analogEmergencyStore.systems.length > 0) {
+ onProgress?.(94, `Writing ${analogEmergencyStore.systems.length} analog emergency system(s) to radio...`, steps[4]);
+ await dm32.writeAnalogEmergencies(analogEmergencyStore.systems);
}
}
@@ -665,8 +663,10 @@ export function useRadioConnection() {
}
// Store write block data and zone comparison data for debug export (DM-32 only)
- if ((protocol as any).writeBlockData != null) setWriteBlockData((protocol as any).writeBlockData);
- if ((protocol as any).zoneComparisonData != null) setZoneComparisonData((protocol as any).zoneComparisonData);
+ if (dm32) {
+ setWriteBlockData(dm32.writeBlockData);
+ setZoneComparisonData(dm32.zoneComparisonData);
+ }
// Step 6: Disconnect
await protocol.disconnect();
diff --git a/src/models/RadioSettings.ts b/src/models/RadioSettings.ts
index b92668e..f2a9944 100644
--- a/src/models/RadioSettings.ts
+++ b/src/models/RadioSettings.ts
@@ -187,6 +187,6 @@ export interface RadioSettings {
vfoA: Channel; // Offset 0x276-0x2A5 (48 bytes) - VFO A Channel
vfoB: Channel; // Offset 0x2A6-0x2D5 (48 bytes) - VFO B Channel
- /** UV5R-Mini specific settings (when radio is UV5R-Mini). Select fields use 0-based index. */
- uv5rMiniSettings?: import('../types/uv5rMiniSettings').Uv5rMiniSettings;
+ /** Radio-specific settings bag. Parsed by each radio's settingsFormat.ts; rendered via its settingsProfile. */
+ radioSpecific?: Record;
}
diff --git a/src/radios/dm32uv/capabilities.ts b/src/radios/dm32uv/capabilities.ts
index b0dcef4..b6b197a 100644
--- a/src/radios/dm32uv/capabilities.ts
+++ b/src/radios/dm32uv/capabilities.ts
@@ -42,4 +42,5 @@ export const DM32UV_CAPABILITIES: RadioCapabilities = {
maxScanLists: LIMITS.SCAN_LISTS_MAX,
supportsBootImage: true,
supportsQuickMessages: true,
+ supportsAnalogEmergency: true,
};
diff --git a/src/radios/dm32uv/descriptor.ts b/src/radios/dm32uv/descriptor.ts
index e56ffd9..969159d 100644
--- a/src/radios/dm32uv/descriptor.ts
+++ b/src/radios/dm32uv/descriptor.ts
@@ -12,6 +12,7 @@ export const DM32UV_DESCRIPTOR: RadioDescriptor = {
modelIds: DM32_MODEL_IDS,
label: 'DM-32UV',
icon: 'π»',
+ group: 'Baofeng',
supportsBle: false,
protocolFactory: () => new DM32UVProtocol(),
capabilities: DM32UV_CAPABILITIES,
diff --git a/src/radios/dm32uv/protocol.ts b/src/radios/dm32uv/protocol.ts
index 4eb5c4d..f2ff6b6 100644
--- a/src/radios/dm32uv/protocol.ts
+++ b/src/radios/dm32uv/protocol.ts
@@ -16,7 +16,8 @@ import {
type MemoryBlock,
} from './memory';
import { parseChannel, parseZones, parseScanLists, parseContactEntry, encodeChannel, encodeZone, encodeScanList, encodeContactEntry, parseRadioSettings, encodeRadioSettings, encodeDigitalEmergencies, encodeAnalogEmergencies, encodeEncryptionKey, parseQuickMessages, parseDMRRadioIDs, encodeDMRRadioID, parseCalibration, parseRXGroups, parseQuickContacts, encodeQuickContacts, encodeQuickMessages, parseTxContactForChannel, encodeTxContactForChannel, encodeRXGroups } from './structures';
-import type { RadioProtocol, RadioInfo } from '../../types/radio';
+import type { RadioInfo, DM32Protocol } from '../../types/radio';
+import { BaseDigitalProtocol } from '../shared/BaseProtocols';
import type { Channel, Zone, Contact, RadioSettings, ScanList, DigitalEmergency, DigitalEmergencyConfig, AnalogEmergency, QuickTextMessage, DMRRadioID, Calibration, RXGroup, QuickContact, EncryptionKey } from '../../models';
import type { WebSerialPort, ProtocolDebugData } from './types';
import { METADATA, BLOCK_SIZE, OFFSET, VFRAME, CONNECTION, LIMITS } from './constants';
@@ -40,17 +41,10 @@ import { log } from '../../utils/protocolLogger';
* await protocol.disconnect();
* ```
*/
-export class DM32UVProtocol implements RadioProtocol {
+export class DM32UVProtocol extends BaseDigitalProtocol implements DM32Protocol {
private connection: DM32Connection | null = null;
private port: WebSerialPort | null = null;
private radioInfo: RadioInfo | null = null;
-
- /**
- * Progress callback for long-running operations
- * @param progress Progress percentage (0-100)
- * @param message Status message
- */
- public onProgress?: (progress: number, message: string) => void;
public rawChannelData: Map = new Map();
public rawZoneData: Map = new Map();
public rawContactBlockData: Uint8Array | null = null;
@@ -495,8 +489,8 @@ export class DM32UVProtocol implements RadioProtocol {
blockData: this.blockData,
writeBlockData: this.writeBlockData,
zoneComparisonData: this.zoneComparisonData,
- allBlockMetadata: (this as any).allBlockMetadata || new Map(),
- allBlockData: (this as any).allBlockData || new Map(),
+ allBlockMetadata: this.blockMetadata,
+ allBlockData: new Map(this.blockData),
cachedBlockData: this.cachedBlockData,
discoveredBlocks: this.discoveredBlocks,
};
@@ -560,8 +554,7 @@ export class DM32UVProtocol implements RadioProtocol {
type: block.type,
});
}
- (this as any).allBlockMetadata = blockMetadataMap;
- // Note: allBlockData will be set after all blocks are read (see end of bulkReadRequiredBlocks)
+ this.blockMetadata = blockMetadataMap;
// Step 2: Determine which blocks we need to read
const blocksToRead: MemoryBlock[] = [];
@@ -759,10 +752,7 @@ export class DM32UVProtocol implements RadioProtocol {
log.debug('All blocks are now in cache - parsing can proceed without additional radio reads', 'Protocol');
- // Update allBlockData after all blocks are read (for store persistence)
- // This is critical - the store needs this data for cache restoration during writes
- (this as any).allBlockData = new Map(this.blockData); // Create a new Map to ensure it's a copy
- log.info(`Set allBlockData with ${this.blockData.size} blocks for store persistence (allBlockMetadata has ${(this as any).allBlockMetadata?.size || 0} entries)`, 'Protocol');
+ log.info(`All blocks read: ${this.blockData.size} blocks, ${this.blockMetadata.size} metadata entries`, 'Protocol');
// Verify critical blocks are in allBlockData
const tx42Addr = this.discoveredBlocks.find(b => b.metadata === METADATA.TX_CONTACT_LOW)?.address;
diff --git a/src/radios/dm32uv/settingsProfile.ts b/src/radios/dm32uv/settingsProfile.ts
index 3aaf286..eb3d55b 100644
--- a/src/radios/dm32uv/settingsProfile.ts
+++ b/src/radios/dm32uv/settingsProfile.ts
@@ -6,7 +6,7 @@ import type { SettingsProfile } from '../../types/settingsProfile';
export const DM32UV_SETTINGS_PROFILE: SettingsProfile = {
radioType: 'DM-32UV',
- features: ['bootImage'],
+ features: ['bootImage', 'oneKeyOperation', 'gpsAprs'],
sections: [
{
id: 'powerOnDisplay',
diff --git a/src/radios/dm32uv/structures.ts b/src/radios/dm32uv/structures.ts
index 56ade63..3f8f913 100644
--- a/src/radios/dm32uv/structures.ts
+++ b/src/radios/dm32uv/structures.ts
@@ -2284,6 +2284,7 @@ export function parseQuickMessages(
const textBytes = messageBytes.slice(0, textEndOffset);
const text = new TextDecoder('ascii', { fatal: false })
.decode(textBytes)
+ .replace(/\x00/g, '') // strip null-byte padding (radio uses 0x00 before the 0xFF terminator)
.trim();
// Skip empty messages
diff --git a/src/radios/ft65/capabilities.ts b/src/radios/ft65/capabilities.ts
new file mode 100644
index 0000000..c8eb834
--- /dev/null
+++ b/src/radios/ft65/capabilities.ts
@@ -0,0 +1,31 @@
+import type { RadioCapabilities } from '../../types/radioCapabilities';
+
+const FT65_CAPS_BASE: RadioCapabilities = {
+ bandLimits: {
+ vhfMin: 136,
+ vhfMax: 174,
+ uhfMin: 400,
+ uhfMax: 480,
+ },
+ writeValidations: { channelsMustBeInZones: false },
+ maxChannels: 200,
+ supportsZones: false,
+ supportsScanLists: false,
+ supportsContacts: false,
+ analogOnly: true,
+ supportsBle: false,
+ preferredTransport: 'serial',
+ supportsBulkRead: false,
+};
+
+/** FT-65R / FT-65E: dual-band VHF+UHF. */
+export const FT65_CAPS_DUAL: RadioCapabilities = { ...FT65_CAPS_BASE };
+
+/** FT-4XR / FT-4XE: dual-band VHF+UHF. */
+export const FT4X_CAPS_DUAL: RadioCapabilities = { ...FT65_CAPS_BASE };
+
+/** FT-25R / FT-4VR: VHF-only. */
+export const FT_CAPS_VHF: RadioCapabilities = {
+ ...FT65_CAPS_BASE,
+ bandLimits: { vhfMin: 136, vhfMax: 174, uhfMin: 400, uhfMax: 480 },
+};
diff --git a/src/radios/ft65/connection.ts b/src/radios/ft65/connection.ts
new file mode 100644
index 0000000..6f76269
--- /dev/null
+++ b/src/radios/ft65/connection.ts
@@ -0,0 +1,157 @@
+/**
+ * Web Serial connection for the Yaesu SCU-35 cable (FT-65/FT-4/FT-25).
+ *
+ * Protocol: "two-wire" β TX and RX are OR'd on the cable, so every byte
+ * sent is echoed back before the radio's own response arrives. Every
+ * command exchange follows: send β read echo β read response β read ACK (0x06).
+ *
+ * Clone mode lifecycle (mirrors CHIRP do_download / do_upload):
+ * open() β open port, set up reader/writer
+ * enterCloneMode() β PROGRAM β QX, read ID (call before each read/write)
+ * readBlock() / writeBlock() ...
+ * sendEnd() β END β ACK (call after each read/write)
+ * close() β release port
+ */
+
+import { FT65_BAUD_RATE, FT65_BLOCK_SIZE } from './constants';
+import { BaseSerialConnection, type SerialLikePort } from '../shared/BaseSerialConnection';
+import { requestSerialPort } from '../shared/serialPort';
+
+const PROGRAM_CMD = new TextEncoder().encode('PROGRAM');
+const END_CMD = new TextEncoder().encode('END');
+const ACK = 0x06;
+const TIMEOUT_MS = 8000;
+const BLOCK_TIMEOUT_MS = 5000;
+
+export type FT65SerialPort = SerialLikePort;
+
+/** Request / reuse a Web Serial port and open it at 9600 baud. */
+export async function openFT65Port(forceSelection = false): Promise {
+ return requestSerialPort(FT65_BAUD_RATE, forceSelection);
+}
+
+export class FT65Connection extends BaseSerialConnection {
+ /** Valid radio ID prefixes β any match accepted. */
+ validIdPrefixes: string[] = [];
+
+ /** Open the port and set up reader/writer. Does NOT enter clone mode. */
+ async open(port: FT65SerialPort): Promise {
+ await super.openPort(port);
+ await this.delay(300);
+ this.buf = new Uint8Array(0);
+ }
+
+ /** Close reader/writer and port. Does NOT send END β call sendEnd() first. */
+ async close(): Promise {
+ await super.closeStreams();
+ }
+
+ /**
+ * Enter clone mode and read + validate the radio's ID string.
+ * Must be called before each readBlock / writeBlock session.
+ * Returns the raw ID string reported by the radio.
+ */
+ async enterCloneMode(): Promise {
+ // Send PROGRAM, expect "QX" response. Retry with END recovery if needed.
+ let entered = false;
+ for (let endTry = 0; endTry < 3 && !entered; endTry++) {
+ for (let i = 0; i < 3 && !entered; i++) {
+ try {
+ const resp = await this.sendcmd(PROGRAM_CMD, 2);
+ if (resp[0] === 0x51 && resp[1] === 0x58) { // 'Q','X'
+ entered = true;
+ }
+ } catch { /* retry */ }
+ }
+ if (!entered) {
+ try { await this.sendcmd(END_CMD, 0); } catch { /* ignore */ }
+ }
+ }
+ if (!entered) throw new Error('Could not enter clone mode. Check cable and radio power.');
+
+ // Read radio ID (variable length, terminated by ACK)
+ const idBytes = await this.sendcmd(new Uint8Array([0x02]), null);
+ const idStr = String.fromCharCode(...idBytes).replace(/\x00.*/, '').trim();
+
+ if (
+ this.validIdPrefixes.length > 0 &&
+ !this.validIdPrefixes.some((p) => idStr.startsWith(p))
+ ) {
+ throw new Error(
+ `Radio ID mismatch. Expected one of [${this.validIdPrefixes.join(', ')}], got "${idStr}". Wrong model selected?`
+ );
+ }
+ return idStr;
+ }
+
+ /** Send END to release the radio from clone mode. Call after every read/write session. */
+ async sendEnd(): Promise {
+ await this.sendcmd(END_CMD, 0);
+ }
+
+ /** Read one 16-byte block at byte address `addr`. */
+ async readBlock(addr: number): Promise {
+ const cmd = new Uint8Array(4);
+ cmd[0] = 0x52; // 'R'
+ cmd[1] = (addr >> 8) & 0xff;
+ cmd[2] = addr & 0xff;
+ cmd[3] = FT65_BLOCK_SIZE;
+
+ const response = await this.sendcmd(cmd, 21, BLOCK_TIMEOUT_MS);
+ if (response[0] !== 0x57) throw new Error(`Bad block response header at addr 0x${addr.toString(16)}`);
+ const checksum = (response.slice(1, 20).reduce((a, b) => a + b, 0)) & 0xff;
+ if (checksum !== response[20]) throw new Error(`Block checksum mismatch at 0x${addr.toString(16)}`);
+ return response.slice(4, 20);
+ }
+
+ /** Write one 16-byte block at byte address `addr`. */
+ async writeBlock(addr: number, data: Uint8Array): Promise {
+ if (data.length !== FT65_BLOCK_SIZE) throw new Error('Block must be 16 bytes');
+ const chkstr = new Uint8Array(19);
+ chkstr[0] = (addr >> 8) & 0xff;
+ chkstr[1] = addr & 0xff;
+ chkstr[2] = FT65_BLOCK_SIZE;
+ chkstr.set(data, 3);
+ const checksum = chkstr.reduce((a, b) => a + b, 0) & 0xff;
+ const msg = new Uint8Array(22);
+ msg[0] = 0x57; // 'W'
+ msg.set(chkstr, 1);
+ msg[20] = checksum;
+ msg[21] = ACK;
+ await this.sendcmd(msg, 0, BLOCK_TIMEOUT_MS);
+ }
+
+ // -------------------------------------------------------------------------
+
+ private async sendcmd(
+ cmd: Uint8Array,
+ responseLen: number | null,
+ timeoutMs = TIMEOUT_MS
+ ): Promise> {
+ this.buf = new Uint8Array(0);
+ await this.write(cmd);
+
+ // Strip echo
+ await this.readExact(cmd.length, timeoutMs);
+
+ if (responseLen === null) {
+ // Variable: read until ACK
+ const parts: number[] = [];
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const b = await this.readExact(1, timeoutMs);
+ if (b[0] === ACK) return new Uint8Array(parts);
+ parts.push(b[0]);
+ }
+ throw new Error('Timeout reading variable response');
+ }
+
+ let response: Uint8Array = new Uint8Array(0);
+ if (responseLen > 0) {
+ response = await this.readExact(responseLen, timeoutMs);
+ }
+ const ack = await this.readExact(1, timeoutMs);
+ if (ack[0] !== ACK) throw new Error(`Expected ACK 0x06, got 0x${ack[0].toString(16)}`);
+ return response;
+ }
+}
diff --git a/src/radios/ft65/constants.ts b/src/radios/ft65/constants.ts
new file mode 100644
index 0000000..85d1450
--- /dev/null
+++ b/src/radios/ft65/constants.ts
@@ -0,0 +1,98 @@
+/**
+ * Constants for the Yaesu FT-65 / FT-4 / FT-25 family (SCU-35 cable).
+ * Protocol derived from CHIRP chirp/drivers/ft4.py.
+ */
+
+export const FT65_BAUD_RATE = 9600;
+
+/** Total number of 16-byte blocks in memory image. */
+export const FT65_NUM_BLOCKS = 0x215;
+export const FT65_BLOCK_SIZE = 16;
+export const FT65_MEM_SIZE = FT65_NUM_BLOCKS * FT65_BLOCK_SIZE; // 8528 bytes
+
+export const FT65_MAX_CHANNELS = 200;
+export const FT65_CHANNEL_SIZE = 16; // bytes per channel slot
+
+/** Memory region offsets. */
+export const FT65_ADDR_CHANNELS = 0x0010; // channel slot memory[200]
+export const FT65_ADDR_ENABLE = 0x0E50; // enable bitmap (32 bytes, 1 bit/channel)
+export const FT65_ADDR_SCAN = 0x0E70; // scan bitmap (32 bytes, 1 bit/channel)
+export const FT65_ADDR_NAMES = 0x1000; // name array (8 bytes/entry, 220 entries)
+export const FT65_ADDR_TXFREQS = 0x1700; // TX freq array (4 bytes/entry, 220 entries)
+export const FT65_ADDR_SETTINGS = 0x2000; // misc settings (64 bytes)
+
+/** Channel slot field offsets (within the 16-byte slot). */
+export const SLOT = {
+ TX_PWR: 0, // u8: 0=lo, 1=med, 2=hi
+ FREQ: 1, // bbcd[4]: Hz/10, big-endian BCD
+ TX_CTCSS: 5, // u8: 0=off, 1-50 = CTCSS_TONES index
+ RX_CTCSS: 6,
+ TX_DCS: 7, // u8: 0=off, 1-104 = DCS_CODES index
+ RX_DCS: 8,
+ DUPLEX: 9, // u8 low 3 bits: 0=+, 2=-, 4=off/simplex, 5=auto, 6=split
+ OFFSET: 10, // ul16 little-endian: multiply by freq_offset_factor
+ TX_WIDTH: 12, // u8 bit 0: 0=wide(FM), 1=narrow(NFM)
+ STEP: 13,
+ SQL_TYPE: 14, // 0=off,1=r-tone,2=t-tone,3=tsql,4=rev tn,5=dcs,6=pager
+} as const;
+
+/** sql_type values. */
+export const SQL = { OFF: 0, R_TONE: 1, T_TONE: 2, TSQL: 3, REV_TN: 4, DCS: 5, PAGER: 6 } as const;
+
+/** duplex field values. */
+export const DUPLEX = { PLUS: 0, MINUS: 2, OFF: 4, AUTO: 5, SPLIT: 6 } as const;
+
+/**
+ * CTCSS tone table: index β Hz (0 = off).
+ * Matches CHIRP TONE_MAP; radio encodes as index+0 (0=off, 1=67.0, β¦).
+ */
+export const CTCSS_TONES: readonly (number | null)[] = [
+ null, 67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5,
+ 85.4, 88.5, 91.5, 94.8, 97.4, 100.0, 103.5,
+ 107.2, 110.9, 114.8, 118.8, 123.0, 127.3,
+ 131.8, 136.5, 141.3, 146.2, 151.4, 156.7,
+ 159.8, 162.2, 165.5, 167.9, 171.3, 173.8,
+ 177.3, 179.9, 183.5, 186.2, 189.9, 192.8,
+ 196.6, 199.5, 203.5, 206.5, 210.7, 218.1,
+ 225.7, 229.1, 233.6, 241.8, 250.3, 254.1,
+];
+
+/**
+ * DCS code table: index β code number (0 = off).
+ * Matches CHIRP DTCS_MAP.
+ */
+export const DCS_CODES: readonly (number | null)[] = [
+ null, 23, 25, 26, 31, 32, 36, 43, 47, 51, 53, 54,
+ 65, 71, 72, 73, 74, 114, 115, 116, 122, 125, 131,
+ 132, 134, 143, 145, 152, 155, 156, 162, 165, 172, 174,
+ 205, 212, 223, 225, 226, 243, 244, 245, 246, 251, 252,
+ 255, 261, 263, 265, 266, 271, 274, 306, 311, 315, 325,
+ 331, 332, 343, 346, 351, 356, 364, 365, 371, 411, 412,
+ 413, 423, 431, 432, 445, 446, 452, 454, 455, 462, 464,
+ 465, 466, 503, 506, 516, 523, 526, 532, 546, 565, 606,
+ 612, 624, 627, 631, 632, 654, 662, 664, 703, 712, 723,
+ 731, 732, 734, 743, 754,
+];
+
+/**
+ * Frequency offset scale factor per radio family.
+ * FT-65 uses 50 kHz steps; FT-4 uses 25 kHz steps.
+ */
+export const OFFSET_FACTOR_FT65 = 50_000; // Hz per offset unit
+export const OFFSET_FACTOR_FT4 = 25_000;
+
+/**
+ * Max displayable name characters per family.
+ * Physical slot is always 8 bytes; FT-4 front panel only shows 6.
+ */
+export const MAX_NAME_LEN_FT65 = 8;
+export const MAX_NAME_LEN_FT4 = 6;
+
+/**
+ * Radio id_str values (matched after stripping trailing null/variant byte).
+ * Used to validate the radio identity during connect.
+ */
+export const ID_PREFIX_FT65 = 'IH-420';
+export const ID_PREFIX_FT4X = 'IFT-35R';
+export const ID_PREFIX_FT4V = 'IFT-15R';
+export const ID_PREFIX_FT25 = 'IFT-25R';
diff --git a/src/radios/ft65/descriptor.ts b/src/radios/ft65/descriptor.ts
new file mode 100644
index 0000000..702cdb8
--- /dev/null
+++ b/src/radios/ft65/descriptor.ts
@@ -0,0 +1,53 @@
+/**
+ * RadioDescriptor entries for the Yaesu FT-65/FT-4/FT-25 family (SCU-35 cable).
+ *
+ * Three picker entries cover six hardware variants:
+ * FT-65 β FT-65R (US/Asia) + FT-65E (EU) β same PCB, same ID prefix
+ * FT-4 β FT-4XR, FT-4XE (dual-band) + FT-4VR (VHF-only)
+ * FT-25R β FT-25R (VHF-only, US/Asia)
+ */
+import type { RadioDescriptor } from '../types';
+import { FT65Protocol } from './protocol';
+import { FT65_CAPS_DUAL, FT_CAPS_VHF } from './capabilities';
+import {
+ ID_PREFIX_FT65, ID_PREFIX_FT4X, ID_PREFIX_FT4V, ID_PREFIX_FT25,
+ OFFSET_FACTOR_FT65, OFFSET_FACTOR_FT4,
+ MAX_NAME_LEN_FT65, MAX_NAME_LEN_FT4,
+} from './constants';
+import { FT65_SETTINGS_PROFILE, FT4_SETTINGS_PROFILE, FT25R_SETTINGS_PROFILE } from './settingsProfile';
+
+/** FT-65 β covers FT-65R and FT-65E (identical hardware). */
+export const FT65_DESCRIPTOR: RadioDescriptor = {
+ modelIds: ['FT-65', 'FT-65R', 'FT-65E'],
+ label: 'FT-65',
+ icon: 'π»',
+ group: 'Yaesu',
+ supportsBle: false,
+ protocolFactory: () => new FT65Protocol('FT-65', [ID_PREFIX_FT65], OFFSET_FACTOR_FT65, MAX_NAME_LEN_FT65),
+ capabilities: FT65_CAPS_DUAL,
+ settingsProfile: FT65_SETTINGS_PROFILE,
+};
+
+/** FT-4 β covers FT-4XR, FT-4XE (dual-band) and FT-4VR (VHF-only). */
+export const FT4_DESCRIPTOR: RadioDescriptor = {
+ modelIds: ['FT-4', 'FT-4XR', 'FT-4XE', 'FT-4VR'],
+ label: 'FT-4',
+ icon: 'π»',
+ group: 'Yaesu',
+ supportsBle: false,
+ protocolFactory: () => new FT65Protocol('FT-4', [ID_PREFIX_FT4X, ID_PREFIX_FT4V], OFFSET_FACTOR_FT4, MAX_NAME_LEN_FT4),
+ capabilities: FT65_CAPS_DUAL,
+ settingsProfile: FT4_SETTINGS_PROFILE,
+};
+
+/** FT-25R β VHF-only, US/Asia. */
+export const FT25R_DESCRIPTOR: RadioDescriptor = {
+ modelIds: ['FT-25R'],
+ label: 'FT-25R',
+ icon: 'π»',
+ group: 'Yaesu',
+ supportsBle: false,
+ protocolFactory: () => new FT65Protocol('FT-25R', [ID_PREFIX_FT25], OFFSET_FACTOR_FT65, MAX_NAME_LEN_FT65),
+ capabilities: FT_CAPS_VHF,
+ settingsProfile: FT25R_SETTINGS_PROFILE,
+};
diff --git a/src/radios/ft65/protocol.ts b/src/radios/ft65/protocol.ts
new file mode 100644
index 0000000..77addc6
--- /dev/null
+++ b/src/radios/ft65/protocol.ts
@@ -0,0 +1,151 @@
+/**
+ * FT65Protocol: RadioProtocol for the Yaesu FT-65/FT-4/FT-25 family.
+ * Analog-only, 200 channels, serial via SCU-35 cable.
+ *
+ * Clone mode is self-contained per operation (mirrors CHIRP do_download/do_upload):
+ * enterCloneMode() β blocks β sendEnd()
+ * The port stays open between operations; each read/write enters/exits independently.
+ */
+
+import type { RadioInfo } from '../../types/radio';
+import type { Channel, RadioSettings } from '../../models';
+import type { Ft65Settings } from '../../types/ft65Settings';
+import { BaseAnalogProtocol } from '../shared/BaseProtocols';
+import { FT65Connection, openFT65Port, type FT65SerialPort } from './connection';
+import { FT65_NUM_BLOCKS, FT65_BLOCK_SIZE, FT65_MEM_SIZE } from './constants';
+import { parseAllChannels, encodeChannel, clearChannelRegions } from './structures';
+import { parseFt65Settings, writeFt65Settings } from './settingsFormat';
+
+export class FT65Protocol extends BaseAnalogProtocol {
+ private conn: FT65Connection | null = null;
+ private port: FT65SerialPort | null = null;
+ private cachedImage: Uint8Array | null = null;
+ private pendingSettings: Ft65Settings | null = null;
+
+ constructor(
+ private readonly modelId: string,
+ private readonly idPrefixes: string[],
+ private readonly offsetFactor: number,
+ private readonly maxNameLen: number = 8,
+ ) {
+ super();
+ }
+
+ async connect(
+ portOrOptions?: string | { forcePortSelection?: boolean; transport?: string }
+ ): Promise {
+ const opts = typeof portOrOptions === 'object' ? portOrOptions : {};
+ const forceSelection = opts.forcePortSelection ?? false;
+
+ this.port = await openFT65Port(forceSelection);
+ const conn = new FT65Connection();
+ conn.validIdPrefixes = this.idPrefixes;
+ await conn.open(this.port);
+ this.conn = conn;
+ }
+
+ async disconnect(): Promise {
+ this.cachedImage = null;
+ this.pendingSettings = null;
+ if (this.conn) {
+ await this.conn.close();
+ this.conn = null;
+ }
+ this.port = null;
+ }
+
+ isConnected(): boolean {
+ return this.conn !== null;
+ }
+
+ async getRadioInfo(): Promise {
+ return {
+ model: this.modelId,
+ firmware: '',
+ buildDate: '',
+ memoryLayout: { configStart: 0x0000, configEnd: FT65_MEM_SIZE - 1 },
+ };
+ }
+
+ async readChannels(): Promise {
+ if (!this.conn) throw new Error('Not connected');
+
+ await this.conn.enterCloneMode();
+
+ const image = new Uint8Array(FT65_MEM_SIZE);
+ for (let block = 0; block < FT65_NUM_BLOCKS; block++) {
+ const addr = block * FT65_BLOCK_SIZE;
+ const data = await this.conn.readBlock(addr);
+ image.set(data, addr);
+
+ if (this.onProgress && block % 16 === 0) {
+ this.onProgress(
+ Math.round((block / FT65_NUM_BLOCKS) * 100),
+ `Reading block ${block + 1} of ${FT65_NUM_BLOCKS}`
+ );
+ }
+ }
+
+ await this.conn.sendEnd();
+
+ this.cachedImage = image;
+ return parseAllChannels(image, this.offsetFactor);
+ }
+
+ async writeChannels(channels: Channel[]): Promise {
+ if (!this.conn) throw new Error('Not connected');
+
+ const image = new Uint8Array(FT65_MEM_SIZE);
+ // Start from the cached read image so settings/DTMF/P-keys are preserved
+ if (this.cachedImage) {
+ image.set(this.cachedImage);
+ }
+
+ // Flush any pending settings changes into the image before writing
+ if (this.pendingSettings) {
+ writeFt65Settings(image, this.pendingSettings);
+ this.pendingSettings = null;
+ }
+
+ // Clear channel data regions so deleted channels don't leave ghost entries
+ clearChannelRegions(image);
+
+ for (const ch of channels) {
+ if (ch.number >= 1 && ch.number <= 200) {
+ encodeChannel(image, ch, this.offsetFactor, this.maxNameLen);
+ }
+ }
+
+ await this.conn.enterCloneMode();
+
+ // Skip block 0 (radio type ID β read-only)
+ const totalWritable = FT65_NUM_BLOCKS - 1;
+ for (let block = 1; block < FT65_NUM_BLOCKS; block++) {
+ const addr = block * FT65_BLOCK_SIZE;
+ await this.conn.writeBlock(addr, image.subarray(addr, addr + FT65_BLOCK_SIZE));
+
+ if (this.onProgress && block % 16 === 0) {
+ this.onProgress(
+ Math.round(((block - 1) / totalWritable) * 100),
+ `Writing block ${block} of ${totalWritable}`
+ );
+ }
+ }
+
+ await this.conn.sendEnd();
+ }
+
+ override async readRadioSettings(): Promise {
+ if (!this.cachedImage) return null;
+ const radioSpecific = parseFt65Settings(this.cachedImage);
+ if (!radioSpecific) return null;
+ return { radioSpecific } as unknown as RadioSettings;
+ }
+
+ override async writeRadioSettings(settings: RadioSettings): Promise {
+ const radioSpecific = settings.radioSpecific as Ft65Settings | undefined;
+ if (!radioSpecific) return;
+ // Buffer settings; writeChannels picks them up and writes everything in one clone session.
+ this.pendingSettings = radioSpecific;
+ }
+}
diff --git a/src/radios/ft65/settingsFormat.ts b/src/radios/ft65/settingsFormat.ts
new file mode 100644
index 0000000..014e412
--- /dev/null
+++ b/src/radios/ft65/settingsFormat.ts
@@ -0,0 +1,112 @@
+/**
+ * Parse/encode the 64-byte settings block at FT65_ADDR_SETTINGS (0x2000).
+ * Layout from CHIRP chirp/drivers/ft4.py `misc` struct.
+ */
+import type { Ft65Settings } from '../../types/ft65Settings';
+import { FT65_ADDR_SETTINGS } from './constants';
+
+const SETTINGS_SIZE = 0x40; // 64 bytes
+
+export function parseFt65Settings(image: Uint8Array): Ft65Settings | null {
+ const off = FT65_ADDR_SETTINGS;
+ if (off + SETTINGS_SIZE > image.length) return null;
+ const s = image.subarray(off, off + SETTINGS_SIZE);
+
+ // cw_id: 6 ASCII bytes at 0x07β0x0C, space-padded
+ let cwId = '';
+ for (let i = 0; i < 6; i++) {
+ const b = s[0x07 + i];
+ if (b === 0x00 || b === 0xff) break;
+ if (b !== 0x20) cwId += String.fromCharCode(b);
+ else if (cwId.length > 0) cwId += ' ';
+ }
+ cwId = cwId.trimEnd();
+
+ // passwd: 4 ASCII digit bytes at 0x31β0x34
+ let passwd = '';
+ for (let i = 0; i < 4; i++) {
+ const b = s[0x31 + i];
+ passwd += (b >= 0x30 && b <= 0x39) ? String.fromCharCode(b) : '0';
+ }
+
+ return {
+ apo: Math.min(s[0x00], 24),
+ artsBeep: Math.min(s[0x01], 2),
+ artsIntv: Math.min(s[0x02], 1),
+ battSave: Math.min(s[0x03], 5),
+ bclo: s[0x04] !== 0,
+ beep: Math.min(s[0x05], 2),
+ bell: Math.min(s[0x06], 5),
+ cwId,
+ useCwid: s[0x1E] !== 0,
+ compander: s[0x1F] !== 0,
+ dtmfMode: Math.min(s[0x10], 1),
+ dtmfDelay: Math.min(s[0x11], 4),
+ dtmfSpeed: Math.min(s[0x12], 1),
+ edgBeep: s[0x13] !== 0,
+ keyLock: Math.min(s[0x14], 2),
+ lamp: Math.min(s[0x15], 4),
+ txLed: s[0x16] !== 0,
+ bsyLed: s[0x17] !== 0,
+ moniTcall: Math.min(s[0x18], 4),
+ priRvt: s[0x19] !== 0,
+ scanResume: Math.min(s[0x1A], 2),
+ rfSquelch: Math.min(s[0x1B], 8),
+ scanLamp: s[0x1C] !== 0,
+ txSave: s[0x21] !== 0,
+ vfoSpl: s[0x22] !== 0,
+ vox: s[0x23] !== 0,
+ wfmRcv: s[0x24] !== 0,
+ wxAlert: s[0x26] !== 0,
+ tot: Math.min(s[0x27], 30),
+ usePasswd: s[0x30] !== 0,
+ passwd,
+ };
+}
+
+/** Write settings back into image. Only modifies bytes corresponding to known fields; unknown bytes are untouched. */
+export function writeFt65Settings(image: Uint8Array, settings: Partial): void {
+ const off = FT65_ADDR_SETTINGS;
+ if (off + SETTINGS_SIZE > image.length) return;
+ const s = image.subarray(off, off + SETTINGS_SIZE);
+
+ if (settings.apo != null) s[0x00] = Math.min(settings.apo, 24);
+ if (settings.artsBeep != null) s[0x01] = Math.min(settings.artsBeep, 2);
+ if (settings.artsIntv != null) s[0x02] = Math.min(settings.artsIntv, 1);
+ if (settings.battSave != null) s[0x03] = Math.min(settings.battSave, 5);
+ if (settings.bclo != null) s[0x04] = settings.bclo ? 1 : 0;
+ if (settings.beep != null) s[0x05] = Math.min(settings.beep, 2);
+ if (settings.bell != null) s[0x06] = Math.min(settings.bell, 5);
+ if (settings.cwId != null) {
+ // space-pad the 6-byte field; uppercase only
+ const clean = settings.cwId.slice(0, 6).toUpperCase();
+ s.fill(0x20, 0x07, 0x0D);
+ for (let i = 0; i < clean.length; i++) s[0x07 + i] = clean.charCodeAt(i) & 0xff;
+ }
+ if (settings.dtmfMode != null) s[0x10] = Math.min(settings.dtmfMode, 1);
+ if (settings.dtmfDelay != null) s[0x11] = Math.min(settings.dtmfDelay, 4);
+ if (settings.dtmfSpeed != null) s[0x12] = Math.min(settings.dtmfSpeed, 1);
+ if (settings.edgBeep != null) s[0x13] = settings.edgBeep ? 1 : 0;
+ if (settings.keyLock != null) s[0x14] = Math.min(settings.keyLock, 2);
+ if (settings.lamp != null) s[0x15] = Math.min(settings.lamp, 4);
+ if (settings.txLed != null) s[0x16] = settings.txLed ? 1 : 0;
+ if (settings.bsyLed != null) s[0x17] = settings.bsyLed ? 1 : 0;
+ if (settings.moniTcall != null) s[0x18] = Math.min(settings.moniTcall, 4);
+ if (settings.priRvt != null) s[0x19] = settings.priRvt ? 1 : 0;
+ if (settings.scanResume != null) s[0x1A] = Math.min(settings.scanResume, 2);
+ if (settings.rfSquelch != null) s[0x1B] = Math.min(settings.rfSquelch, 8);
+ if (settings.scanLamp != null) s[0x1C] = settings.scanLamp ? 1 : 0;
+ if (settings.useCwid != null) s[0x1E] = settings.useCwid ? 1 : 0;
+ if (settings.compander != null) s[0x1F] = settings.compander ? 1 : 0;
+ if (settings.txSave != null) s[0x21] = settings.txSave ? 1 : 0;
+ if (settings.vfoSpl != null) s[0x22] = settings.vfoSpl ? 1 : 0;
+ if (settings.vox != null) s[0x23] = settings.vox ? 1 : 0;
+ if (settings.wfmRcv != null) s[0x24] = settings.wfmRcv ? 1 : 0;
+ if (settings.wxAlert != null) s[0x26] = settings.wxAlert ? 1 : 0;
+ if (settings.tot != null) s[0x27] = Math.min(settings.tot, 30);
+ if (settings.usePasswd != null) s[0x30] = settings.usePasswd ? 1 : 0;
+ if (settings.passwd != null) {
+ const digits = settings.passwd.replace(/[^0-9]/g, '').padEnd(4, '0').slice(0, 4);
+ for (let i = 0; i < 4; i++) s[0x31 + i] = digits.charCodeAt(i);
+ }
+}
diff --git a/src/radios/ft65/settingsProfile.ts b/src/radios/ft65/settingsProfile.ts
new file mode 100644
index 0000000..4c65370
--- /dev/null
+++ b/src/radios/ft65/settingsProfile.ts
@@ -0,0 +1,132 @@
+/**
+ * Settings profiles for the FT-65/FT-4/FT-25R family.
+ * FT65_SETTINGS_PROFILE includes compander (FT-65/FT-25R have the hardware).
+ * FT4_SETTINGS_PROFILE omits it.
+ */
+import type { SettingsProfile } from '../../types/settingsProfile';
+
+function opt(values: string[]) {
+ return values.map((label, i) => ({ value: i, label }));
+}
+
+const APO_OPTIONS = opt(['Off', '0.5h', '1.0h', '1.5h', '2.0h', '2.5h', '3.0h', '3.5h', '4.0h', '4.5h', '5.0h', '5.5h', '6.0h', '6.5h', '7.0h', '7.5h', '8.0h', '8.5h', '9.0h', '9.5h', '10.0h', '10.5h', '11.0h', '11.5h', '12.0h']);
+const TOT_OPTIONS = opt(['Off', ...Array.from({ length: 30 }, (_, i) => `${i + 1} min`)]);
+const BATT_OPTIONS = opt(['Off', '200 ms', '300 ms', '500 ms', '1 s', '2 s']);
+const BEEP_OPTIONS = opt(['Key + Scan', 'Key', 'Off']);
+const BELL_OPTIONS = opt(['Off', '1T', '3T', '5T', '8T', 'Continuous']);
+const LAMP_OPTIONS = opt(['5 sec', '10 sec', '30 sec', 'Key', 'Off']);
+const SCAN_OPTIONS = opt(['Busy', 'Hold', 'Time']);
+const SQL_OPTIONS = opt(['Off', 'S-1', 'S-2', 'S-3', 'S-4', 'S-5', 'S-6', 'S-7', 'S-Full']);
+const ARTS_OPTIONS = opt(['Off', 'In Range', 'Always']);
+const ARTS_INTV = opt(['25 sec', '15 sec']);
+const KEY_LOCK_OPT = opt(['Key', 'PTT', 'Key + PTT']);
+const MONI_OPTIONS = opt(['Monitor', '1750 Hz', '2100 Hz', '1000 Hz', '1450 Hz']);
+const DTMF_MODE_OPT = opt(['Manual', 'Auto']);
+const DTMF_DLY_OPT = opt(['50 ms', '250 ms', '450 ms', '750 ms', '1000 ms']);
+const DTMF_SPD_OPT = opt(['50 ms', '100 ms']);
+
+function makeSections(includeCompander: boolean): SettingsProfile['sections'] {
+ return [
+ {
+ id: 'basic',
+ title: 'Basic',
+ fields: [
+ { key: 'radioSpecific.rfSquelch', label: 'RF Squelch', type: 'select', options: SQL_OPTIONS },
+ { key: 'radioSpecific.apo', label: 'Auto Power Off', type: 'select', options: APO_OPTIONS },
+ { key: 'radioSpecific.tot', label: 'Time-Out Timer', type: 'select', options: TOT_OPTIONS },
+ { key: 'radioSpecific.battSave', label: 'Battery Save', type: 'select', options: BATT_OPTIONS },
+ { key: 'radioSpecific.bclo', label: 'Busy Channel Lockout', type: 'checkbox' },
+ { key: 'radioSpecific.txSave', label: 'TX Save', type: 'checkbox' },
+ ],
+ },
+ {
+ id: 'audio',
+ title: 'Audio & Beep',
+ fields: [
+ { key: 'radioSpecific.beep', label: 'Beep', type: 'select', options: BEEP_OPTIONS },
+ { key: 'radioSpecific.bell', label: 'Bell Rings', type: 'select', options: BELL_OPTIONS },
+ { key: 'radioSpecific.edgBeep', label: 'Edge Beep', type: 'checkbox' },
+ ...(includeCompander ? [{ key: 'radioSpecific.compander', label: 'Compander', type: 'checkbox' as const }] : []),
+ ],
+ },
+ {
+ id: 'display',
+ title: 'Display & Indicators',
+ fields: [
+ { key: 'radioSpecific.lamp', label: 'Lamp', type: 'select', options: LAMP_OPTIONS },
+ { key: 'radioSpecific.txLed', label: 'TX LED', type: 'checkbox' },
+ { key: 'radioSpecific.bsyLed', label: 'Busy LED', type: 'checkbox' },
+ { key: 'radioSpecific.scanLamp', label: 'Scan Lamp', type: 'checkbox' },
+ ],
+ },
+ {
+ id: 'scan',
+ title: 'Scan',
+ fields: [
+ { key: 'radioSpecific.scanResume', label: 'Scan Resume', type: 'select', options: SCAN_OPTIONS },
+ { key: 'radioSpecific.priRvt', label: 'Priority Revert', type: 'checkbox' },
+ ],
+ },
+ {
+ id: 'ptt',
+ title: 'PTT & Monitor',
+ fields: [
+ { key: 'radioSpecific.moniTcall', label: 'Monitor / Tone', type: 'select', options: MONI_OPTIONS },
+ { key: 'radioSpecific.vox', label: 'VOX', type: 'checkbox' },
+ { key: 'radioSpecific.keyLock', label: 'Key Lock', type: 'select', options: KEY_LOCK_OPT },
+ ],
+ },
+ {
+ id: 'misc',
+ title: 'Misc',
+ fields: [
+ { key: 'radioSpecific.vfoSpl', label: 'VFO Split', type: 'checkbox' },
+ { key: 'radioSpecific.wfmRcv', label: 'WFM Receive', type: 'checkbox' },
+ { key: 'radioSpecific.wxAlert', label: 'WX Alert', type: 'checkbox' },
+ ],
+ },
+ {
+ id: 'arts',
+ title: 'ARTS',
+ fields: [
+ { key: 'radioSpecific.useCwid', label: 'CW ID Enable', type: 'checkbox' },
+ { key: 'radioSpecific.cwId', label: 'CW ID Callsign', type: 'text', maxLength: 6 },
+ { key: 'radioSpecific.artsBeep', label: 'ARTS Beep', type: 'select', options: ARTS_OPTIONS },
+ { key: 'radioSpecific.artsIntv', label: 'ARTS Interval', type: 'select', options: ARTS_INTV },
+ ],
+ },
+ {
+ id: 'dtmf',
+ title: 'DTMF',
+ fields: [
+ { key: 'radioSpecific.dtmfMode', label: 'DTMF Mode', type: 'select', options: DTMF_MODE_OPT },
+ { key: 'radioSpecific.dtmfDelay', label: 'DTMF Delay', type: 'select', options: DTMF_DLY_OPT },
+ { key: 'radioSpecific.dtmfSpeed', label: 'DTMF Speed', type: 'select', options: DTMF_SPD_OPT },
+ ],
+ },
+ {
+ id: 'security',
+ title: 'Security',
+ fields: [
+ { key: 'radioSpecific.usePasswd', label: 'Password Enable', type: 'checkbox' },
+ { key: 'radioSpecific.passwd', label: 'Password (4 digits)', type: 'text', maxLength: 4 },
+ ],
+ },
+ ];
+}
+
+export const FT65_SETTINGS_PROFILE: SettingsProfile = {
+ radioType: 'FT-65',
+ sections: makeSections(true),
+};
+
+export const FT4_SETTINGS_PROFILE: SettingsProfile = {
+ radioType: 'FT-4',
+ sections: makeSections(false),
+};
+
+// FT-25R shares the FT-65 profile (same hardware, VHF-only)
+export const FT25R_SETTINGS_PROFILE: SettingsProfile = {
+ radioType: 'FT-25R',
+ sections: makeSections(true),
+};
diff --git a/src/radios/ft65/structures.ts b/src/radios/ft65/structures.ts
new file mode 100644
index 0000000..7f044fe
--- /dev/null
+++ b/src/radios/ft65/structures.ts
@@ -0,0 +1,295 @@
+/**
+ * Pure parse/encode functions for the FT-65/FT-4/FT-25 memory image.
+ */
+
+import type { Channel, CTCSSDCS } from '../../models/Channel';
+import {
+ FT65_MAX_CHANNELS, FT65_CHANNEL_SIZE, FT65_ADDR_CHANNELS,
+ FT65_ADDR_ENABLE, FT65_ADDR_NAMES, FT65_ADDR_TXFREQS,
+ SLOT, SQL, DUPLEX,
+ CTCSS_TONES, DCS_CODES,
+} from './constants';
+import { createDefaultChannel } from '../../utils/channelHelpers';
+
+// ---------------------------------------------------------------------------
+// BCD frequency codec
+// ---------------------------------------------------------------------------
+
+/** Decode 4-byte big-endian BCD to MHz. Radio stores Hz/10. */
+export function decodeBCDFreq(bytes: Uint8Array, offset = 0): number {
+ let val = 0;
+ for (let i = 0; i < 4; i++) {
+ const b = bytes[offset + i];
+ val = val * 100 + ((b >> 4) * 10) + (b & 0xf);
+ }
+ return (val * 10) / 1_000_000; // Hz β MHz
+}
+
+/** Encode MHz frequency to 4-byte big-endian BCD (Hz/10). */
+export function encodeBCDFreq(mhz: number, out: Uint8Array, offset = 0): void {
+ let val = Math.round(mhz * 100_000); // val = Hz/10 as integer
+ for (let i = 3; i >= 0; i--) {
+ const lo = val % 10; val = Math.floor(val / 10);
+ const hi = val % 10; val = Math.floor(val / 10);
+ out[offset + i] = (hi << 4) | lo;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Enable bitmap
+// ---------------------------------------------------------------------------
+
+export function isChannelEnabled(image: Uint8Array, idx: number): boolean {
+ const byte = image[FT65_ADDR_ENABLE + (idx >> 3)];
+ return ((byte >> (idx & 7)) & 1) === 1;
+}
+
+export function setChannelEnabled(image: Uint8Array, idx: number, enabled: boolean): void {
+ const byteIdx = FT65_ADDR_ENABLE + (idx >> 3);
+ const bit = idx & 7;
+ if (enabled) {
+ image[byteIdx] |= (1 << bit);
+ } else {
+ image[byteIdx] &= ~(1 << bit);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Name codec
+// ---------------------------------------------------------------------------
+
+const NAME_SLOT_LEN = 8; // physical bytes per name slot (both FT-65 and FT-4)
+
+export function decodeName(image: Uint8Array, idx: number): string {
+ const base = FT65_ADDR_NAMES + idx * NAME_SLOT_LEN;
+ let name = '';
+ for (let i = 0; i < NAME_SLOT_LEN; i++) {
+ const b = image[base + i];
+ if (b === 0x00 || b === 0xff) break;
+ const c = b === 0x7f ? 0x20 : b; // 0x7F (programmed from VFO) β space
+ name += String.fromCharCode(c);
+ }
+ return name.trimEnd();
+}
+
+export function encodeName(image: Uint8Array, idx: number, name: string, maxLen = NAME_SLOT_LEN): void {
+ const base = FT65_ADDR_NAMES + idx * NAME_SLOT_LEN;
+ // Clear the full 8-byte slot first, then write up to maxLen chars
+ image.fill(0x00, base, base + NAME_SLOT_LEN);
+ const capped = name.slice(0, maxLen);
+ for (let i = 0; i < capped.length; i++) {
+ image[base + i] = capped.charCodeAt(i) & 0xff;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// CTCSS / DCS helpers
+// ---------------------------------------------------------------------------
+
+function decodeCTCSS(code: number): CTCSSDCS {
+ if (code === 0) return { type: 'None' };
+ const hz = CTCSS_TONES[code];
+ if (hz == null) return { type: 'None' };
+ return { type: 'CTCSS', value: hz };
+}
+
+function decodeDCS(code: number): CTCSSDCS {
+ if (code === 0) return { type: 'None' };
+ const n = DCS_CODES[code];
+ if (n == null) return { type: 'None' };
+ return { type: 'DCS', value: n, polarity: 'N' };
+}
+
+function encodeCTCSS(tone: CTCSSDCS): number {
+ if (tone.type !== 'CTCSS' || tone.value == null) return 0;
+ const idx = CTCSS_TONES.findIndex((t) => t != null && Math.abs(t - tone.value!) < 0.05);
+ return idx > 0 ? idx : 0;
+}
+
+function encodeDCS(tone: CTCSSDCS): number {
+ if (tone.type !== 'DCS' || tone.value == null) return 0;
+ const idx = DCS_CODES.findIndex((c) => c === tone.value);
+ return idx > 0 ? idx : 0;
+}
+
+// ---------------------------------------------------------------------------
+// Channel parse / encode
+// ---------------------------------------------------------------------------
+
+interface SlotInfo {
+ enabled: boolean;
+ slotBase: number;
+ name: string;
+ txFreqBase: number;
+}
+
+function readSlotInfo(image: Uint8Array, idx: number): SlotInfo {
+ return {
+ enabled: isChannelEnabled(image, idx),
+ slotBase: FT65_ADDR_CHANNELS + idx * FT65_CHANNEL_SIZE,
+ name: decodeName(image, idx),
+ txFreqBase: FT65_ADDR_TXFREQS + idx * 4,
+ };
+}
+
+/**
+ * Parse one channel from a full memory image.
+ * Returns null if the channel slot is disabled/empty.
+ */
+export function parseChannel(image: Uint8Array, idx: number, offsetFactor: number): Channel | null {
+ const { enabled, slotBase, name } = readSlotInfo(image, idx);
+ if (!enabled) return null;
+
+ const s = image;
+ const rxMhz = decodeBCDFreq(s, slotBase + SLOT.FREQ);
+
+ // Offset: little-endian uint16 Γ offsetFactor (Hz)
+ const offsetRaw = s[slotBase + SLOT.OFFSET] | (s[slotBase + SLOT.OFFSET + 1] << 8);
+ const offsetHz = offsetRaw * offsetFactor;
+ const offsetMhz = offsetHz / 1_000_000;
+
+ const duplexField = s[slotBase + SLOT.DUPLEX] & 0x7;
+ let txMhz: number;
+ if (duplexField === DUPLEX.SPLIT) {
+ txMhz = decodeBCDFreq(s, FT65_ADDR_TXFREQS + idx * 4);
+ } else if (duplexField === DUPLEX.PLUS || duplexField === DUPLEX.AUTO) {
+ txMhz = rxMhz + offsetMhz;
+ } else if (duplexField === DUPLEX.MINUS) {
+ txMhz = rxMhz - offsetMhz;
+ } else {
+ txMhz = rxMhz; // simplex
+ }
+
+ const sqlType = s[slotBase + SLOT.SQL_TYPE];
+ const txCtcssCode = s[slotBase + SLOT.TX_CTCSS];
+ const rxCtcssCode = s[slotBase + SLOT.RX_CTCSS];
+ const txDcsCode = s[slotBase + SLOT.TX_DCS];
+ const rxDcsCode = s[slotBase + SLOT.RX_DCS];
+
+ let txCtcssDcs: CTCSSDCS = { type: 'None' };
+ let rxCtcssDcs: CTCSSDCS = { type: 'None' };
+
+ switch (sqlType) {
+ case SQL.T_TONE:
+ case SQL.TSQL:
+ txCtcssDcs = decodeCTCSS(txCtcssCode);
+ rxCtcssDcs = decodeCTCSS(rxCtcssCode || txCtcssCode);
+ break;
+ case SQL.R_TONE:
+ rxCtcssDcs = decodeCTCSS(rxCtcssCode);
+ break;
+ case SQL.DCS:
+ txCtcssDcs = decodeDCS(txDcsCode);
+ rxCtcssDcs = decodeDCS(rxDcsCode || txDcsCode);
+ break;
+ case SQL.REV_TN:
+ rxCtcssDcs = { type: 'None' }; // reverse tone = squelch opens without tone
+ break;
+ }
+
+ const pwrMap: Channel['power'][] = ['Low', 'Medium', 'High'];
+ const bandwidth: Channel['bandwidth'] = (s[slotBase + SLOT.TX_WIDTH] & 1) ? '12.5kHz' : '25kHz';
+
+ return createDefaultChannel({
+ number: idx + 1,
+ name,
+ rxFrequency: rxMhz,
+ txFrequency: txMhz,
+ mode: 'Analog',
+ bandwidth,
+ power: pwrMap[s[slotBase + SLOT.TX_PWR]] ?? 'High',
+ rxCtcssDcs,
+ txCtcssDcs,
+ });
+}
+
+/**
+ * Write one channel back into the memory image.
+ * Caller must clear the channel regions first (see clearChannelRegions).
+ * maxNameLen: 8 for FT-65/FT-25, 6 for FT-4.
+ */
+export function encodeChannel(image: Uint8Array, ch: Channel, offsetFactor: number, maxNameLen = 8): void {
+ const idx = ch.number - 1;
+ const slotBase = FT65_ADDR_CHANNELS + idx * FT65_CHANNEL_SIZE;
+
+ // Clear slot (in case caller didn't pre-clear)
+ image.fill(0x00, slotBase, slotBase + FT65_CHANNEL_SIZE);
+
+ // Frequency (rx)
+ encodeBCDFreq(ch.rxFrequency, image, slotBase + SLOT.FREQ);
+
+ // Power
+ const pwrMap: Record = { Low: 0, Medium: 1, High: 2 };
+ image[slotBase + SLOT.TX_PWR] = pwrMap[ch.power] ?? 2;
+
+ // Bandwidth
+ image[slotBase + SLOT.TX_WIDTH] = ch.bandwidth === '12.5kHz' ? 1 : 0;
+
+ // Offset / duplex
+ const txMhz = ch.txFrequency;
+ const rxMhz = ch.rxFrequency;
+ const diffHz = Math.round((txMhz - rxMhz) * 1_000_000);
+ if (Math.abs(diffHz) < 100) {
+ image[slotBase + SLOT.DUPLEX] = DUPLEX.OFF;
+ } else {
+ const offsetRaw = Math.round(Math.abs(diffHz) / offsetFactor);
+ image[slotBase + SLOT.OFFSET] = offsetRaw & 0xff;
+ image[slotBase + SLOT.OFFSET + 1] = (offsetRaw >> 8) & 0xff;
+ image[slotBase + SLOT.DUPLEX] = diffHz > 0 ? DUPLEX.PLUS : DUPLEX.MINUS;
+ }
+
+ // CTCSS / DCS
+ const hasTxTone = ch.txCtcssDcs.type !== 'None';
+ const hasRxTone = ch.rxCtcssDcs.type !== 'None';
+
+ if (hasTxTone && hasRxTone) {
+ image[slotBase + SLOT.SQL_TYPE] = SQL.TSQL;
+ } else if (hasTxTone) {
+ image[slotBase + SLOT.SQL_TYPE] = SQL.T_TONE;
+ } else if (hasRxTone) {
+ image[slotBase + SLOT.SQL_TYPE] = SQL.R_TONE;
+ } else {
+ image[slotBase + SLOT.SQL_TYPE] = SQL.OFF;
+ }
+
+ if (ch.txCtcssDcs.type === 'CTCSS') {
+ image[slotBase + SLOT.TX_CTCSS] = encodeCTCSS(ch.txCtcssDcs);
+ } else if (ch.txCtcssDcs.type === 'DCS') {
+ image[slotBase + SLOT.TX_DCS] = encodeDCS(ch.txCtcssDcs);
+ }
+
+ if (ch.rxCtcssDcs.type === 'CTCSS') {
+ image[slotBase + SLOT.RX_CTCSS] = encodeCTCSS(ch.rxCtcssDcs);
+ } else if (ch.rxCtcssDcs.type === 'DCS') {
+ image[slotBase + SLOT.RX_DCS] = encodeDCS(ch.rxCtcssDcs);
+ }
+
+ // Name and enable bit
+ encodeName(image, idx, ch.name, maxNameLen);
+ setChannelEnabled(image, idx, true);
+}
+
+/**
+ * Zero out all channel-data regions before re-encoding.
+ * Must be called before the encodeChannel loop in writeChannels.
+ */
+export function clearChannelRegions(image: Uint8Array): void {
+ // Channel slots
+ image.fill(0x00, FT65_ADDR_CHANNELS, FT65_ADDR_CHANNELS + FT65_MAX_CHANNELS * FT65_CHANNEL_SIZE);
+ // Enable + scan bitmaps
+ image.fill(0x00, FT65_ADDR_ENABLE, FT65_ADDR_ENABLE + 64);
+ // Name slots (8 bytes each Γ 220 entries)
+ image.fill(0x00, FT65_ADDR_NAMES, FT65_ADDR_NAMES + 220 * 8);
+ // TX freq slots (4 bytes each Γ 220 entries)
+ image.fill(0x00, FT65_ADDR_TXFREQS, FT65_ADDR_TXFREQS + 220 * 4);
+}
+
+/** Parse all 200 channel slots from a full memory image. */
+export function parseAllChannels(image: Uint8Array, offsetFactor: number): Channel[] {
+ const channels: Channel[] = [];
+ for (let i = 0; i < FT65_MAX_CHANNELS; i++) {
+ const ch = parseChannel(image, i, offsetFactor);
+ if (ch) channels.push(ch);
+ }
+ return channels;
+}
diff --git a/src/radios/index.ts b/src/radios/index.ts
index fc3053e..a9f9ad2 100644
--- a/src/radios/index.ts
+++ b/src/radios/index.ts
@@ -6,6 +6,7 @@ import type { RadioProtocol } from '../types/radio';
import type { RadioDescriptor } from './types';
import { DM32UV_DESCRIPTOR } from './dm32uv/descriptor';
import { UV5RMINI_DESCRIPTOR } from './uv5rmini/descriptor';
+import { FT65_DESCRIPTOR, FT4_DESCRIPTOR, FT25R_DESCRIPTOR } from './ft65/descriptor';
export type ProtocolFactory = () => RadioProtocol;
@@ -13,6 +14,9 @@ export type ProtocolFactory = () => RadioProtocol;
export const RADIO_DESCRIPTORS: readonly RadioDescriptor[] = [
DM32UV_DESCRIPTOR,
UV5RMINI_DESCRIPTOR,
+ FT65_DESCRIPTOR,
+ FT4_DESCRIPTOR,
+ FT25R_DESCRIPTOR,
];
/** Backward compatibility: same radio, multiple model IDs. */
@@ -33,6 +37,7 @@ export interface RadioPickerOption {
modelId: string;
label: string;
icon: string;
+ group?: string;
supportsBle: boolean;
}
@@ -40,6 +45,7 @@ const RADIO_PICKER_OPTIONS: RadioPickerOption[] = RADIO_DESCRIPTORS.map((d) => (
modelId: d.modelIds[0],
label: d.label,
icon: d.icon,
+ group: d.group,
supportsBle: d.supportsBle,
}));
diff --git a/src/radios/shared/BaseProtocols.ts b/src/radios/shared/BaseProtocols.ts
new file mode 100644
index 0000000..0e125db
--- /dev/null
+++ b/src/radios/shared/BaseProtocols.ts
@@ -0,0 +1,44 @@
+/**
+ * Base classes for the radio protocol hierarchy.
+ *
+ * BaseAnalogProtocol β channels + settings only. Extend this for analog radios (FT-65, UV5R-Mini).
+ * BaseDigitalProtocol β marker subclass for digital radios (DM-32UV, etc.). Extend this for any
+ * radio that supports zones, contacts, scan lists, RX groups, encryption, etc.
+ *
+ * useRadioConnection.ts uses `instanceof BaseDigitalProtocol` to gate DMR-specific reads
+ * instead of inspecting capability flags or using `as any` casts.
+ */
+
+import type { RadioProtocol, RadioInfo } from '../../types/radio';
+import type { Channel, Zone, Contact, RadioSettings, ScanList, DMRRadioID } from '../../models';
+
+export abstract class BaseAnalogProtocol implements RadioProtocol {
+ public onProgress?: (progress: number, message: string) => void;
+
+ abstract connect(portOrOptions?: string | { forcePortSelection?: boolean; transport?: string }): Promise;
+ abstract disconnect(): Promise;
+ abstract isConnected(): boolean;
+ abstract getRadioInfo(): Promise;
+ abstract readChannels(): Promise;
+ abstract writeChannels(channels: Channel[]): Promise;
+
+ // No-op stubs satisfy RadioProtocol for digital features analog radios don't support.
+ async readZones(): Promise { return []; }
+ async writeZones(_zones: Zone[]): Promise {}
+ async readScanLists(): Promise { return []; }
+ async readDMRRadioIDs(): Promise { return []; }
+ async writeDMRRadioIDs(_ids: DMRRadioID[]): Promise {}
+ async readContacts(): Promise { return []; }
+ async writeContacts(_contacts: Contact[]): Promise {}
+ async readRadioSettings(): Promise { return null; }
+ async writeRadioSettings(_settings: RadioSettings, _options?: { changedFields?: string[] }): Promise {}
+}
+
+/**
+ * Marker base class for digital radios. Extend this instead of BaseAnalogProtocol when the
+ * radio supports zones, contacts, scan lists, RX groups, encryption keys, calibration, etc.
+ *
+ * The empty body is intentional β the only purpose right now is to allow
+ * `instanceof BaseDigitalProtocol` checks in useRadioConnection.ts.
+ */
+export abstract class BaseDigitalProtocol extends BaseAnalogProtocol {}
diff --git a/src/radios/shared/BaseSerialConnection.ts b/src/radios/shared/BaseSerialConnection.ts
new file mode 100644
index 0000000..54151af
--- /dev/null
+++ b/src/radios/shared/BaseSerialConnection.ts
@@ -0,0 +1,69 @@
+/**
+ * Shared Web Serial boilerplate: port open/close, reader/writer lifecycle,
+ * buffered readExact, write helper, and delay. Extended by each radio's
+ * connection class, which only needs to implement its framing protocol.
+ */
+
+export interface SerialLikePort {
+ readonly readable: ReadableStream | null;
+ readonly writable: WritableStream | null;
+ open(options: { baudRate: number }): Promise;
+ close(): Promise;
+}
+
+export abstract class BaseSerialConnection {
+ protected reader: ReadableStreamDefaultReader | null = null;
+ protected writer: WritableStreamDefaultWriter | null = null;
+ protected buf = new Uint8Array(0);
+ protected port: SerialLikePort | null = null;
+
+ protected async openPort(port: SerialLikePort): Promise {
+ this.port = port;
+ this.buf = new Uint8Array(0);
+ if (!port.readable || !port.writable) throw new Error('Port streams unavailable');
+ if (port.readable.locked || port.writable.locked) throw new Error('Port already in use');
+ this.reader = port.readable.getReader();
+ this.writer = port.writable.getWriter();
+ }
+
+ protected async closeStreams(): Promise {
+ try { await this.reader?.cancel(); } catch { /* ignore */ }
+ try { await this.writer?.close(); } catch { /* ignore */ }
+ if (this.port) {
+ try { await this.port.close(); } catch { /* ignore */ }
+ }
+ this.reader = null;
+ this.writer = null;
+ this.port = null;
+ }
+
+ protected async write(data: Uint8Array): Promise {
+ if (!this.writer) throw new Error('Not connected');
+ await this.writer.write(data);
+ }
+
+ protected async readExact(n: number, timeoutMs: number): Promise> {
+ const deadline = Date.now() + timeoutMs;
+ while (this.buf.length < n) {
+ if (Date.now() > deadline) {
+ throw new Error(`Timeout: needed ${n} bytes, have ${this.buf.length}`);
+ }
+ const { value, done } = await this.reader!.read();
+ if (done) throw new Error('Serial port closed unexpectedly');
+ if (value && value.length > 0) {
+ const next = new Uint8Array(this.buf.length + value.length);
+ next.set(this.buf);
+ next.set(value, this.buf.length);
+ this.buf = next;
+ }
+ if (this.buf.length < n) await this.delay(10);
+ }
+ const result = new Uint8Array(this.buf.slice(0, n));
+ this.buf = this.buf.length > n ? this.buf.slice(n) : new Uint8Array(0);
+ return result;
+ }
+
+ protected delay(ms: number): Promise {
+ return new Promise((r) => setTimeout(r, ms));
+ }
+}
diff --git a/src/radios/shared/serialPort.ts b/src/radios/shared/serialPort.ts
new file mode 100644
index 0000000..4fde23c
--- /dev/null
+++ b/src/radios/shared/serialPort.ts
@@ -0,0 +1,19 @@
+import type { SerialLikePort } from './BaseSerialConnection';
+
+/**
+ * Request or reuse a Web Serial port and open it at the given baud rate.
+ * Shared by all serial radios; each radio's connection file wraps this
+ * with a named function that supplies its own baud rate constant.
+ */
+export async function requestSerialPort(
+ baudRate: number,
+ forceSelection = false
+): Promise {
+ if (!('serial' in navigator)) throw new Error('Web Serial API not supported. Use Chrome/Edge.');
+ const nav = (navigator as any).serial;
+ const port: SerialLikePort = forceSelection
+ ? await nav.requestPort()
+ : ((await nav.getPorts())[0] ?? (await nav.requestPort()));
+ await port.open({ baudRate });
+ return port;
+}
diff --git a/src/radios/types.ts b/src/radios/types.ts
index 4e17091..52b09df 100644
--- a/src/radios/types.ts
+++ b/src/radios/types.ts
@@ -14,6 +14,8 @@ export interface RadioDescriptor {
label: string;
/** Icon (emoji or character) for picker. */
icon: string;
+ /** Manufacturer/family group for the picker UI (e.g. "Yaesu", "Baofeng"). */
+ group?: string;
/** Whether the radio supports BLE in addition to serial. */
supportsBle: boolean;
/** Factory that returns a new protocol instance. */
diff --git a/src/radios/uv5rmini/descriptor.ts b/src/radios/uv5rmini/descriptor.ts
index 556e3b4..e350cef 100644
--- a/src/radios/uv5rmini/descriptor.ts
+++ b/src/radios/uv5rmini/descriptor.ts
@@ -12,6 +12,7 @@ export const UV5RMINI_DESCRIPTOR: RadioDescriptor = {
modelIds: [UV5RMINI_MODEL_ID],
label: 'UV5R-Mini',
icon: 'π»',
+ group: 'Baofeng',
supportsBle: true,
protocolFactory: () => new UV5RMiniProtocol(),
capabilities: UV5RMINI_CAPABILITIES,
diff --git a/src/radios/uv5rmini/protocol.ts b/src/radios/uv5rmini/protocol.ts
index a52ed19..677c8d1 100644
--- a/src/radios/uv5rmini/protocol.ts
+++ b/src/radios/uv5rmini/protocol.ts
@@ -2,8 +2,10 @@
* UV5R-Mini protocol: implements RadioProtocol (Serial and BLE).
*/
-import type { RadioProtocol, RadioInfo } from '../../types/radio';
-import type { Channel, Zone, Contact, RadioSettings, ScanList, DMRRadioID } from '../../models';
+import type { RadioInfo } from '../../types/radio';
+import type { Channel, RadioSettings } from '../../models';
+import type { Uv5rMiniSettings } from '../../types/uv5rMiniSettings';
+import { BaseAnalogProtocol } from '../shared/BaseProtocols';
import { UV5RMiniSerialConnection, openUV5RMiniPort } from './serialConnection';
import { UV5RMiniBleConnection, requestUV5RMiniBleDevice } from './bleConnection';
import {
@@ -28,7 +30,7 @@ type ConnectionLike = {
disconnect(): Promise;
};
-export class UV5RMiniProtocol implements RadioProtocol {
+export class UV5RMiniProtocol extends BaseAnalogProtocol {
private connection: ConnectionLike | null = null;
private port: import('./serialConnection').UV5RMiniSerialPort | null = null;
/** Cached image from last readChannels (used by readRadioSettings and getFirmwareFromCache). */
@@ -48,7 +50,6 @@ export class UV5RMiniProtocol implements RadioProtocol {
}
return String.fromCharCode(...slice.subarray(0, end)).trim();
}
- public onProgress?: (progress: number, message: string) => void;
async connect(portOrOptions?: string | { forcePortSelection?: boolean; transport?: 'serial' | 'ble' }): Promise {
const options =
@@ -182,54 +183,23 @@ export class UV5RMiniProtocol implements RadioProtocol {
}
}
- async readZones(): Promise {
- return [];
- }
-
- async writeZones(_zones: Zone[]): Promise {
- // no-op
- }
-
- async readScanLists(): Promise {
- return [];
- }
-
- async readDMRRadioIDs(): Promise {
- return [];
- }
-
- async writeDMRRadioIDs(_ids: DMRRadioID[]): Promise {
- // no-op
- }
-
- async readContacts(): Promise {
- return [];
- }
-
- async writeContacts(_contacts: Contact[]): Promise {
- // no-op
- }
-
- async readRadioSettings(): Promise {
+ override async readRadioSettings(): Promise {
const image = this.cachedImage;
if (!image || image.length < 0x8080) return null;
-
- const uv5rMiniSettings = parseUv5rMiniSettings(image);
- if (!uv5rMiniSettings) return null;
-
- return { uv5rMiniSettings } as RadioSettings;
+ const radioSpecific = parseUv5rMiniSettings(image);
+ if (!radioSpecific) return null;
+ return { radioSpecific } as unknown as RadioSettings;
}
- async writeRadioSettings(settings: RadioSettings, _options?: { changedFields?: string[] }): Promise {
- const uv5rMiniSettings = settings.uv5rMiniSettings;
- if (!uv5rMiniSettings || !this.connection) return;
-
+ override async writeRadioSettings(settings: RadioSettings, _options?: { changedFields?: string[] }): Promise {
+ const radioSpecific = settings.radioSpecific as Uv5rMiniSettings | undefined;
+ if (!radioSpecific || !this.connection) return;
// Read current settings block from radio, merge our changes, write back
const block = await this.connection.readBlock(UV5RMINI_SETTINGS_OFFSET);
const image = new Uint8Array(UV5RMINI_SETTINGS_OFFSET + 64);
image.fill(0xff);
image.set(block, UV5RMINI_SETTINGS_OFFSET);
- writeUv5rMiniSettings(image, uv5rMiniSettings);
+ writeUv5rMiniSettings(image, radioSpecific);
await this.connection.writeBlock(UV5RMINI_SETTINGS_OFFSET, image.subarray(UV5RMINI_SETTINGS_OFFSET));
}
}
diff --git a/src/radios/uv5rmini/serialConnection.ts b/src/radios/uv5rmini/serialConnection.ts
index 62d3ad1..8338ac6 100644
--- a/src/radios/uv5rmini/serialConnection.ts
+++ b/src/radios/uv5rmini/serialConnection.ts
@@ -17,77 +17,41 @@ import {
buildBaofengWriteFrame,
parseBaofengReadResponse,
} from './baofengProtocol';
+import { BaseSerialConnection, type SerialLikePort } from '../shared/BaseSerialConnection';
+import { requestSerialPort } from '../shared/serialPort';
-export interface UV5RMiniSerialPort {
- readonly readable: ReadableStream | null;
- readonly writable: WritableStream | null;
- open(options: { baudRate: number }): Promise;
- close(): Promise;
-}
+export type UV5RMiniSerialPort = SerialLikePort;
const READ_TIMEOUT_MS = 6000;
const WRITE_ACK_TIMEOUT_MS = 400;
-export class UV5RMiniSerialConnection {
- private reader: ReadableStreamDefaultReader | null = null;
- private writer: WritableStreamDefaultWriter | null = null;
- private readBuffer = new Uint8Array(0);
- private port: UV5RMiniSerialPort | null = null;
-
+export class UV5RMiniSerialConnection extends BaseSerialConnection {
async connect(port: UV5RMiniSerialPort): Promise {
- this.port = port;
- this.readBuffer = new Uint8Array(0);
- if (!port.readable || !port.writable) {
- throw new Error('Port streams not available');
- }
- if (port.readable.locked || port.writable.locked) {
- throw new Error('Port already in use');
- }
- this.reader = port.readable.getReader();
- this.writer = port.writable.getWriter();
+ await super.openPort(port);
await this.delay(300);
- await this.clearBuffer();
+ this.buf = new Uint8Array(0);
await this.delay(200);
// Handshake: ident -> ACK
- await this.send(BAOFENG_IDENT);
+ await this.write(BAOFENG_IDENT);
await this.waitForByte(BAOFENG_ACK, 8000);
// Magics (read mode)
for (const { send, responseLen } of BAOFENG_MAGICS_READ) {
- await this.clearBuffer();
- await this.send(send);
- await this.readBytes(responseLen, 4000);
+ this.buf = new Uint8Array(0);
+ await this.write(send);
+ await this.readExact(responseLen, 4000);
}
}
async disconnect(): Promise {
- try {
- await this.reader?.cancel();
- } catch {
- /* ignore */
- }
- try {
- await this.writer?.close();
- } catch {
- /* ignore */
- }
- if (this.port) {
- try {
- await this.port.close();
- } catch {
- /* ignore */
- }
- }
- this.reader = null;
- this.writer = null;
- this.port = null;
+ await super.closeStreams();
}
/** Read one 64-byte block at address (returns decrypted payload). */
async readBlock(addr: number): Promise {
const frame = buildBaofengReadFrame(addr, BAOFENG_BLOCK_SIZE);
- await this.send(frame);
+ await this.write(frame);
const raw = await this.waitForReadResponse(READ_TIMEOUT_MS);
return parseBaofengReadResponse(raw);
}
@@ -95,74 +59,39 @@ export class UV5RMiniSerialConnection {
/** Write one 64-byte block at address (block is plain; we encrypt in buildBaofengWriteFrame). */
async writeBlock(addr: number, block: Uint8Array): Promise {
if (block.length !== BAOFENG_BLOCK_SIZE) throw new Error('Block must be 64 bytes');
- await this.clearBuffer();
+ this.buf = new Uint8Array(0);
const frame = buildBaofengWriteFrame(addr, block);
- await this.send(frame);
+ await this.write(frame);
await this.waitForByte(BAOFENG_ACK, WRITE_ACK_TIMEOUT_MS);
}
/** Switch to upload magics (call before writing multiple blocks). */
async handshakeUpload(): Promise {
- await this.clearBuffer();
- await this.send(BAOFENG_IDENT);
+ this.buf = new Uint8Array(0);
+ await this.write(BAOFENG_IDENT);
await this.waitForByte(BAOFENG_ACK, 8000);
for (const { send, responseLen } of BAOFENG_MAGICS_UPLOAD) {
- await this.clearBuffer();
- await this.send(send);
- await this.readBytes(responseLen, 4000);
+ this.buf = new Uint8Array(0);
+ await this.write(send);
+ await this.readExact(responseLen, 4000);
}
}
- private delay(ms: number): Promise {
- return new Promise((r) => setTimeout(r, ms));
- }
-
- private async send(data: Uint8Array): Promise {
- if (!this.writer) throw new Error('Not connected');
- await this.writer.write(data);
- }
-
- private async readBytes(n: number, timeoutMs: number): Promise {
- const deadline = Date.now() + timeoutMs;
- while (this.readBuffer.length < n) {
- if (Date.now() > deadline) {
- throw new Error(`Timeout waiting for ${n} bytes (got ${this.readBuffer.length})`);
- }
- const { value } = await this.reader!.read();
- if (value && value.length > 0) {
- const newLen = this.readBuffer.length + value.length;
- const next = new Uint8Array(newLen);
- next.set(this.readBuffer);
- next.set(value, this.readBuffer.length);
- this.readBuffer = next;
- }
- await this.delay(10);
- }
- const out = this.readBuffer.slice(0, n);
- this.readBuffer =
- this.readBuffer.length > n ? this.readBuffer.subarray(n) : new Uint8Array(0);
- return out;
- }
-
private async waitForByte(byte: number, timeoutMs: number): Promise {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
- for (let i = 0; i < this.readBuffer.length; i++) {
- if (this.readBuffer[i] === byte) {
- this.readBuffer =
- this.readBuffer.length > i + 1
- ? this.readBuffer.subarray(i + 1)
- : new Uint8Array(0);
+ for (let i = 0; i < this.buf.length; i++) {
+ if (this.buf[i] === byte) {
+ this.buf = this.buf.length > i + 1 ? this.buf.subarray(i + 1) : new Uint8Array(0);
return;
}
}
const { value } = await this.reader!.read();
if (value && value.length > 0) {
- const newLen = this.readBuffer.length + value.length;
- const next = new Uint8Array(newLen);
- next.set(this.readBuffer);
- next.set(value, this.readBuffer.length);
- this.readBuffer = next;
+ const next = new Uint8Array(this.buf.length + value.length);
+ next.set(this.buf);
+ next.set(value, this.buf.length);
+ this.buf = next;
}
await this.delay(20);
}
@@ -173,56 +102,32 @@ export class UV5RMiniSerialConnection {
private async waitForReadResponse(timeoutMs: number): Promise {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
- while (this.readBuffer.length > 0 && this.readBuffer[0] !== 0x52) {
- this.readBuffer = this.readBuffer.subarray(1);
+ while (this.buf.length > 0 && this.buf[0] !== 0x52) {
+ this.buf = this.buf.subarray(1);
}
- if (this.readBuffer.length >= BAOFENG_READ_RESPONSE_LEN) {
- const out = this.readBuffer.slice(0, BAOFENG_READ_RESPONSE_LEN);
- this.readBuffer =
- this.readBuffer.length > BAOFENG_READ_RESPONSE_LEN
- ? this.readBuffer.subarray(BAOFENG_READ_RESPONSE_LEN)
- : new Uint8Array(0);
+ if (this.buf.length >= BAOFENG_READ_RESPONSE_LEN) {
+ const out = this.buf.slice(0, BAOFENG_READ_RESPONSE_LEN);
+ this.buf = this.buf.length > BAOFENG_READ_RESPONSE_LEN
+ ? this.buf.subarray(BAOFENG_READ_RESPONSE_LEN)
+ : new Uint8Array(0);
return out;
}
const { value } = await this.reader!.read();
if (value && value.length > 0) {
- const newLen = this.readBuffer.length + value.length;
- const next = new Uint8Array(newLen);
- next.set(this.readBuffer);
- next.set(value, this.readBuffer.length);
- this.readBuffer = next;
+ const next = new Uint8Array(this.buf.length + value.length);
+ next.set(this.buf);
+ next.set(value, this.buf.length);
+ this.buf = next;
}
await this.delay(20);
}
throw new Error(
- `Timeout waiting for read response (68 bytes). Have ${this.readBuffer.length} bytes.`
+ `Timeout waiting for read response (68 bytes). Have ${this.buf.length} bytes.`
);
}
-
- private clearBuffer(): void {
- this.readBuffer = new Uint8Array(0);
- }
}
/** Request Web Serial port and open at UV5R-Mini baud rate. */
-export async function openUV5RMiniPort(
- forcePortSelection?: boolean
-): Promise {
- if (!('serial' in navigator)) {
- throw new Error('Web Serial API not supported. Please use Chrome/Edge.');
- }
- const nav = (navigator as any).serial;
- let port: UV5RMiniSerialPort;
- if (forcePortSelection) {
- port = await nav.requestPort();
- } else {
- const ports = await nav.getPorts();
- if (ports.length === 0) {
- port = await nav.requestPort();
- } else {
- port = ports[0];
- }
- }
- await port.open({ baudRate: UV5RMINI_BAUD_RATE });
- return port;
+export async function openUV5RMiniPort(forcePortSelection = false): Promise {
+ return requestSerialPort(UV5RMINI_BAUD_RATE, forcePortSelection);
}
diff --git a/src/radios/uv5rmini/settingsProfile.ts b/src/radios/uv5rmini/settingsProfile.ts
index 88c4b55..30c5dd7 100644
--- a/src/radios/uv5rmini/settingsProfile.ts
+++ b/src/radios/uv5rmini/settingsProfile.ts
@@ -14,79 +14,79 @@ export const UV5RMINI_SETTINGS_PROFILE: SettingsProfile = {
id: 'basic',
title: 'Basic',
fields: [
- { key: 'uv5rMiniSettings.squelch', label: 'Squelch', type: 'select', options: optionsFor(['Off', '1', '2', '3', '4', '5']) },
- { key: 'uv5rMiniSettings.savemode', label: 'Save mode', type: 'select', options: optionsFor(['Off', 'On']) },
- { key: 'uv5rMiniSettings.vox', label: 'VOX', type: 'select', options: optionsFor(['Off', '1', '2', '3', '4', '5', '6', '7', '8', '9']) },
- { key: 'uv5rMiniSettings.backlight', label: 'Backlight', type: 'select', options: optionsFor(['Always On', ...Array.from({ length: 4 }, (_, i) => `${5 + i * 5} sec`)]) },
- { key: 'uv5rMiniSettings.dualstandby', label: 'Dual watch', type: 'select', options: optionsFor(['Off', 'On']) },
- { key: 'uv5rMiniSettings.tot', label: 'Timeout timer', type: 'select', options: optionsFor(['Off', ...Array.from({ length: 12 }, (_, i) => `${15 + i * 15} sec`)]) },
- { key: 'uv5rMiniSettings.beep', label: 'Beep', type: 'select', options: optionsFor(['Off', 'On']) },
- { key: 'uv5rMiniSettings.voicesw', label: 'Enable voice', type: 'checkbox' },
- { key: 'uv5rMiniSettings.voice', label: 'Voice prompt', type: 'select', options: optionsFor(['English', 'Chinese']) },
+ { key: 'radioSpecific.squelch', label: 'Squelch', type: 'select', options: optionsFor(['Off', '1', '2', '3', '4', '5']) },
+ { key: 'radioSpecific.savemode', label: 'Save mode', type: 'select', options: optionsFor(['Off', 'On']) },
+ { key: 'radioSpecific.vox', label: 'VOX', type: 'select', options: optionsFor(['Off', '1', '2', '3', '4', '5', '6', '7', '8', '9']) },
+ { key: 'radioSpecific.backlight', label: 'Backlight', type: 'select', options: optionsFor(['Always On', ...Array.from({ length: 4 }, (_, i) => `${5 + i * 5} sec`)]) },
+ { key: 'radioSpecific.dualstandby', label: 'Dual watch', type: 'select', options: optionsFor(['Off', 'On']) },
+ { key: 'radioSpecific.tot', label: 'Timeout timer', type: 'select', options: optionsFor(['Off', ...Array.from({ length: 12 }, (_, i) => `${15 + i * 15} sec`)]) },
+ { key: 'radioSpecific.beep', label: 'Beep', type: 'select', options: optionsFor(['Off', 'On']) },
+ { key: 'radioSpecific.voicesw', label: 'Enable voice', type: 'checkbox' },
+ { key: 'radioSpecific.voice', label: 'Voice prompt', type: 'select', options: optionsFor(['English', 'Chinese']) },
],
},
{
id: 'display',
title: 'Display & Channel',
fields: [
- { key: 'uv5rMiniSettings.chadistype', label: 'Channel A display', type: 'select', options: optionsFor(['Name', 'Frequency', 'Channel Number']) },
- { key: 'uv5rMiniSettings.chbdistype', label: 'Channel B display', type: 'select', options: optionsFor(['Name', 'Frequency', 'Channel Number']) },
- { key: 'uv5rMiniSettings.chaworkmode', label: 'Channel A work mode', type: 'select', options: optionsFor(['Frequency', 'Channel']) },
- { key: 'uv5rMiniSettings.chbworkmode', label: 'Channel B work mode', type: 'select', options: optionsFor(['Frequency', 'Channel']) },
- { key: 'uv5rMiniSettings.powerondistype', label: 'Power on display', type: 'select', options: optionsFor(['LOGO', 'BATT voltage']) },
- { key: 'uv5rMiniSettings.aOrB', label: 'VFO selected', type: 'select', options: [{ value: 0, label: 'A' }, { value: 1, label: 'B' }] },
+ { key: 'radioSpecific.chadistype', label: 'Channel A display', type: 'select', options: optionsFor(['Name', 'Frequency', 'Channel Number']) },
+ { key: 'radioSpecific.chbdistype', label: 'Channel B display', type: 'select', options: optionsFor(['Name', 'Frequency', 'Channel Number']) },
+ { key: 'radioSpecific.chaworkmode', label: 'Channel A work mode', type: 'select', options: optionsFor(['Frequency', 'Channel']) },
+ { key: 'radioSpecific.chbworkmode', label: 'Channel B work mode', type: 'select', options: optionsFor(['Frequency', 'Channel']) },
+ { key: 'radioSpecific.powerondistype', label: 'Power on display', type: 'select', options: optionsFor(['LOGO', 'BATT voltage']) },
+ { key: 'radioSpecific.aOrB', label: 'VFO selected', type: 'select', options: [{ value: 0, label: 'A' }, { value: 1, label: 'B' }] },
],
},
{
id: 'ptt',
title: 'PTT & Roger',
fields: [
- { key: 'uv5rMiniSettings.pttid', label: 'PTT ID', type: 'select', options: optionsFor(['Off', 'BOT', 'EOT', 'Both']) },
- { key: 'uv5rMiniSettings.pttdly', label: 'Send ID delay', type: 'select', options: optionsFor(Array.from({ length: 30 }, (_, i) => `${100 + i * 100} ms`)) },
- { key: 'uv5rMiniSettings.roger', label: 'Roger', type: 'checkbox' },
- { key: 'uv5rMiniSettings.sidetone', label: 'Side tone', type: 'select', options: optionsFor(['Off', 'KB Side Tone', 'ANI Side Tone', 'KB + ANI Side Tone']) },
+ { key: 'radioSpecific.pttid', label: 'PTT ID', type: 'select', options: optionsFor(['Off', 'BOT', 'EOT', 'Both']) },
+ { key: 'radioSpecific.pttdly', label: 'Send ID delay', type: 'select', options: optionsFor(Array.from({ length: 30 }, (_, i) => `${100 + i * 100} ms`)) },
+ { key: 'radioSpecific.roger', label: 'Roger', type: 'checkbox' },
+ { key: 'radioSpecific.sidetone', label: 'Side tone', type: 'select', options: optionsFor(['Off', 'KB Side Tone', 'ANI Side Tone', 'KB + ANI Side Tone']) },
],
},
{
id: 'scan',
title: 'Scan & Squelch',
fields: [
- { key: 'uv5rMiniSettings.scanmode', label: 'Scan mode', type: 'select', options: optionsFor(['Time', 'Carrier', 'Search']) },
- { key: 'uv5rMiniSettings.ctsdcsscantype', label: 'QT save mode', type: 'select', options: optionsFor(['Both', 'RX', 'TX']) },
+ { key: 'radioSpecific.scanmode', label: 'Scan mode', type: 'select', options: optionsFor(['Time', 'Carrier', 'Search']) },
+ { key: 'radioSpecific.ctsdcsscantype', label: 'QT save mode', type: 'select', options: optionsFor(['Both', 'RX', 'TX']) },
],
},
{
id: 'alarm',
title: 'Alarm & Safety',
fields: [
- { key: 'uv5rMiniSettings.alarmmode', label: 'Alarm mode', type: 'select', options: optionsFor(['Local', 'Send Tone', 'Send Code']) },
- { key: 'uv5rMiniSettings.alarmtone', label: 'Sound alarm', type: 'checkbox' },
- { key: 'uv5rMiniSettings.totalarm', label: 'Timeout alarm', type: 'select', options: optionsFor(['Off', '1 sec', '2 sec', '3 sec', '4 sec', '5 sec', '6 sec', '7 sec', '8 sec', '9 sec', '10 sec']) },
+ { key: 'radioSpecific.alarmmode', label: 'Alarm mode', type: 'select', options: optionsFor(['Local', 'Send Tone', 'Send Code']) },
+ { key: 'radioSpecific.alarmtone', label: 'Sound alarm', type: 'checkbox' },
+ { key: 'radioSpecific.totalarm', label: 'Timeout alarm', type: 'select', options: optionsFor(['Off', '1 sec', '2 sec', '3 sec', '4 sec', '5 sec', '6 sec', '7 sec', '8 sec', '9 sec', '10 sec']) },
],
},
{
id: 'repeater',
title: 'Repeater',
fields: [
- { key: 'uv5rMiniSettings.tailclear', label: 'Tail clear', type: 'checkbox' },
- { key: 'uv5rMiniSettings.rpttailclear', label: 'Rpt tail clear', type: 'select', options: optionsFor(Array.from({ length: 11 }, (_, i) => `${i * 100} ms`)) },
- { key: 'uv5rMiniSettings.rpttaildet', label: 'Rpt tail delay', type: 'select', options: optionsFor(Array.from({ length: 11 }, (_, i) => `${i * 100} ms`)) },
+ { key: 'radioSpecific.tailclear', label: 'Tail clear', type: 'checkbox' },
+ { key: 'radioSpecific.rpttailclear', label: 'Rpt tail clear', type: 'select', options: optionsFor(Array.from({ length: 11 }, (_, i) => `${i * 100} ms`)) },
+ { key: 'radioSpecific.rpttaildet', label: 'Rpt tail delay', type: 'select', options: optionsFor(Array.from({ length: 11 }, (_, i) => `${i * 100} ms`)) },
],
},
{
id: 'vox',
title: 'VOX & Misc',
fields: [
- { key: 'uv5rMiniSettings.voxdlytime', label: 'VOX delay time', type: 'select', options: optionsFor(Array.from({ length: 16 }, (_, i) => `${500 + i * 100} ms`)) },
- { key: 'uv5rMiniSettings.voxsw', label: 'VOX switch', type: 'checkbox' },
- { key: 'uv5rMiniSettings.menuquittime', label: 'Menu quit timer', type: 'select', options: optionsFor([...Array.from({ length: 10 }, (_, i) => `${5 + i * 5} sec`), '60 sec']) },
- { key: 'uv5rMiniSettings.dispani', label: 'Display ANI', type: 'checkbox' },
- { key: 'uv5rMiniSettings.inputdtmf', label: 'Input DTMF', type: 'checkbox' },
- { key: 'uv5rMiniSettings.bcl', label: 'BCL', type: 'checkbox' },
- { key: 'uv5rMiniSettings.autolock', label: 'Key auto lock', type: 'checkbox' },
- { key: 'uv5rMiniSettings.keylock', label: 'Key lock', type: 'checkbox' },
- { key: 'uv5rMiniSettings.fmenable', label: 'Disable FM', type: 'checkbox' },
- { key: 'uv5rMiniSettings.hangup', label: 'Hang-up time', type: 'select', options: optionsFor(['3 s', '4 s', '5 s', '6 s', '7 s', '8 s', '9 s', '10 s']) },
+ { key: 'radioSpecific.voxdlytime', label: 'VOX delay time', type: 'select', options: optionsFor(Array.from({ length: 16 }, (_, i) => `${500 + i * 100} ms`)) },
+ { key: 'radioSpecific.voxsw', label: 'VOX switch', type: 'checkbox' },
+ { key: 'radioSpecific.menuquittime', label: 'Menu quit timer', type: 'select', options: optionsFor([...Array.from({ length: 10 }, (_, i) => `${5 + i * 5} sec`), '60 sec']) },
+ { key: 'radioSpecific.dispani', label: 'Display ANI', type: 'checkbox' },
+ { key: 'radioSpecific.inputdtmf', label: 'Input DTMF', type: 'checkbox' },
+ { key: 'radioSpecific.bcl', label: 'BCL', type: 'checkbox' },
+ { key: 'radioSpecific.autolock', label: 'Key auto lock', type: 'checkbox' },
+ { key: 'radioSpecific.keylock', label: 'Key lock', type: 'checkbox' },
+ { key: 'radioSpecific.fmenable', label: 'Disable FM', type: 'checkbox' },
+ { key: 'radioSpecific.hangup', label: 'Hang-up time', type: 'select', options: optionsFor(['3 s', '4 s', '5 s', '6 s', '7 s', '8 s', '9 s', '10 s']) },
],
},
],
diff --git a/src/types/ft65Settings.ts b/src/types/ft65Settings.ts
new file mode 100644
index 0000000..9f05c08
--- /dev/null
+++ b/src/types/ft65Settings.ts
@@ -0,0 +1,34 @@
+/** FT-65 / FT-4 / FT-25R settings (stored in RadioSettings.ft65Settings). Select fields use 0-based index. */
+export interface Ft65Settings {
+ apo: number; // 0=off, 1-24 = 0.5h to 12h
+ artsBeep: number; // 0=off, 1=inrange, 2=always
+ artsIntv: number; // 0=25sec, 1=15sec
+ battSave: number; // 0=off, 1-5 = 200/300/500/1s/2s
+ bclo: boolean;
+ beep: number; // 0=key+scan, 1=key, 2=off
+ bell: number; // 0=off, 1=1T, 2=3T, 3=5T, 4=8T, 5=continuous
+ cwId: string; // up to 6 chars A-Z 0-9 space
+ useCwid: boolean;
+ compander: boolean; // FT-65 / FT-25R only; byte present on FT-4 but no hardware effect
+ dtmfMode: number; // 0=manual, 1=auto
+ dtmfDelay: number; // 0-4 = 50/250/450/750/1000ms
+ dtmfSpeed: number; // 0=50ms, 1=100ms
+ edgBeep: boolean;
+ keyLock: number; // 0=key, 1=ptt, 2=key+ptt
+ lamp: number; // 0=5sec, 1=10sec, 2=30sec, 3=key, 4=off
+ txLed: boolean;
+ bsyLed: boolean;
+ moniTcall: number; // 0=mon, 1=1750Hz, 2=2100Hz, 3=1000Hz, 4=1450Hz
+ priRvt: boolean;
+ scanResume: number; // 0=busy, 1=hold, 2=time
+ rfSquelch: number; // 0=off, 1-7=S1-S7, 8=S-full
+ scanLamp: boolean;
+ txSave: boolean;
+ vfoSpl: boolean;
+ vox: boolean;
+ wfmRcv: boolean;
+ wxAlert: boolean;
+ tot: number; // 0=off, 1-30 = 1min to 30min
+ usePasswd: boolean;
+ passwd: string; // 4 ASCII digit string
+}
diff --git a/src/types/radio.ts b/src/types/radio.ts
index e52d6ef..d68266d 100644
--- a/src/types/radio.ts
+++ b/src/types/radio.ts
@@ -1,8 +1,108 @@
-import type { Channel, Zone, Contact, RadioSettings, ScanList, DMRRadioID } from '../models';
+import type {
+ Channel, Zone, Contact, RadioSettings, ScanList, DMRRadioID,
+ QuickTextMessage, Calibration, RXGroup, QuickContact, EncryptionKey,
+ DigitalEmergency, DigitalEmergencyConfig, AnalogEmergency,
+} from '../models';
// Re-export RadioSettings for use in stores
export type { RadioSettings } from '../models';
+/**
+ * Minimal interface shared by all radios: channels + settings.
+ * Analog radios (FT-65, UV5R-Mini) implement only this surface.
+ */
+export interface AnalogRadioProtocol {
+ connect(portOrOptions?: string | { forcePortSelection?: boolean; transport?: string }): Promise;
+ disconnect(): Promise;
+ isConnected(): boolean;
+ getRadioInfo(): Promise;
+ readChannels(): Promise;
+ writeChannels(channels: Channel[]): Promise;
+ readRadioSettings(): Promise;
+ writeRadioSettings(settings: RadioSettings, options?: { changedFields?: string[] }): Promise;
+ onProgress?: (progress: number, message: string) => void;
+ /** Extract firmware version from the cached clone image. UV5R-Mini and DM-32UV implement this. */
+ getFirmwareFromCache?(): string | null;
+}
+
+/**
+ * Full interface for digital radios: zones, contacts, scan lists, DMR IDs, etc.
+ * Digital radios (DM-32UV and future) implement this.
+ */
+export interface DigitalRadioProtocol extends AnalogRadioProtocol {
+ readZones(): Promise;
+ writeZones(zones: Zone[]): Promise;
+ readScanLists(): Promise;
+ readDMRRadioIDs(): Promise;
+ writeDMRRadioIDs(radioIds: DMRRadioID[]): Promise;
+ readContacts(): Promise;
+ writeContacts(contacts: Contact[]): Promise;
+}
+
+/**
+ * Full public API of the DM-32UV protocol, including DM32-specific operations
+ * not present in the base digital radio interface.
+ *
+ * DM32UVProtocol implements this. A future DM32-compatible radio should also
+ * implement this if it shares the same on-air structures.
+ */
+export interface DM32Protocol extends DigitalRadioProtocol {
+ // Bulk memory read (DM-32 reads all 4 KB blocks up front)
+ bulkReadRequiredBlocks(): Promise;
+
+ // Write-path cache restore (avoids re-reading from radio before write)
+ restoreCacheFromStore(
+ blockData: Map,
+ blockMetadata: Map
+ ): void;
+
+ // Boot image
+ readBootImage(): Promise;
+ writeBootImage(data: Uint8Array): Promise;
+
+ // Quick text messages
+ readQuickMessages(): Promise;
+ writeQuickMessages(messages: QuickTextMessage[]): Promise;
+
+ // Calibration (read-only)
+ readCalibration(): Promise;
+
+ // RX groups
+ readRXGroups(): Promise;
+ writeRXGroups(groups: RXGroup[]): Promise;
+
+ // Quick contacts (talk groups in the DM-32 sense)
+ readQuickContacts(): Promise;
+ writeQuickContacts(contacts: QuickContact[]): Promise;
+
+ // Single-session write for channels + zones + scan lists
+ writeAllData(channels: Channel[], zones: Zone[], scanLists: ScanList[]): Promise;
+
+ // Encryption keys
+ writeEncryptionKeys(keys: EncryptionKey[]): Promise;
+
+ // Emergency systems
+ readDigitalEmergencies(): Promise<{ systems: DigitalEmergency[]; config: DigitalEmergencyConfig } | null>;
+ writeDigitalEmergencies(systems: DigitalEmergency[], config: DigitalEmergencyConfig): Promise;
+ readAnalogEmergencies(): Promise;
+ writeAnalogEmergencies(systems: AnalogEmergency[]): Promise;
+
+ // Raw/debug data (set after each read for the Diagnostics tab)
+ rawChannelData: Map;
+ rawZoneData: Map;
+ rawContactBlockData: Uint8Array | null;
+ rawContactBlockAddress: number | null;
+ rawContactBlocks: Map;
+ rawScanListData: Map;
+ rawRadioSettingsData: Uint8Array | null;
+ rawMessageData: Map;
+ rawDMRRadioIDData: Map;
+ rawRXGroupData: Map;
+ blockData: Map;
+ blockMetadata: Map;
+ writeBlockData: Map;
+}
+
export interface RadioInfo {
model: string; // "DP570UV"
firmware: string; // "DM32.01.01.046"
@@ -27,38 +127,21 @@ export interface RadioInfo {
* linear addresses) and decode/encode are implementation details of each radio.
*/
export interface RadioProtocol {
- // Connection
- // port: legacy for protocols that take a path; options: e.g. { forcePortSelection } for Web Serial
connect(portOrOptions?: string | { forcePortSelection?: boolean }): Promise;
disconnect(): Promise;
isConnected(): boolean;
-
- // Radio Info
getRadioInfo(): Promise;
-
- // Channels
readChannels(): Promise;
writeChannels(channels: Channel[]): Promise;
-
- // Zones
readZones(): Promise;
writeZones(zones: Zone[]): Promise;
-
- // Scan Lists
readScanLists(): Promise;
-
- // DMR Radio IDs
readDMRRadioIDs(): Promise;
writeDMRRadioIDs(radioIds: DMRRadioID[]): Promise;
-
- // Contacts
readContacts(): Promise;
writeContacts(contacts: Contact[]): Promise;
-
- // Settings
readRadioSettings(): Promise;
writeRadioSettings(settings: RadioSettings, options?: { changedFields?: string[] }): Promise;
-
- // Progress callbacks
onProgress?: (progress: number, message: string) => void;
+ getFirmwareFromCache?(): string | null;
}
diff --git a/src/types/radioCapabilities.ts b/src/types/radioCapabilities.ts
index 640f1c9..9f6412c 100644
--- a/src/types/radioCapabilities.ts
+++ b/src/types/radioCapabilities.ts
@@ -89,4 +89,6 @@ export interface RadioCapabilities {
supportsBootImage?: boolean;
/** If true, protocol supports readQuickMessages. */
supportsQuickMessages?: boolean;
+ /** If true, radio has Analog Emergency Systems (DM-32UV only). */
+ supportsAnalogEmergency?: boolean;
}
diff --git a/tests/unit/ft65SettingsFormat.test.ts b/tests/unit/ft65SettingsFormat.test.ts
new file mode 100644
index 0000000..2662212
--- /dev/null
+++ b/tests/unit/ft65SettingsFormat.test.ts
@@ -0,0 +1,232 @@
+import { describe, it, expect } from 'vitest';
+import { parseFt65Settings, writeFt65Settings } from '../../src/radios/ft65/settingsFormat';
+import { FT65_ADDR_SETTINGS } from '../../src/radios/ft65/constants';
+
+const OFF = FT65_ADDR_SETTINGS; // 0x2000
+
+function makeImage(): Uint8Array {
+ return new Uint8Array(OFF + 64);
+}
+
+// ββ parseFt65Settings ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+describe('parseFt65Settings', () => {
+ it('returns null when image is too small', () => {
+ expect(parseFt65Settings(new Uint8Array(0))).toBeNull();
+ expect(parseFt65Settings(new Uint8Array(OFF + 10))).toBeNull();
+ });
+
+ it('parses all-zero block to safe defaults', () => {
+ const s = parseFt65Settings(makeImage())!;
+ expect(s.apo).toBe(0);
+ expect(s.beep).toBe(0);
+ expect(s.bclo).toBe(false);
+ expect(s.txLed).toBe(false);
+ expect(s.cwId).toBe('');
+ expect(s.passwd).toBe('0000');
+ expect(s.tot).toBe(0);
+ expect(s.usePasswd).toBe(false);
+ });
+
+ it('parses scalar settings at correct byte offsets', () => {
+ const img = makeImage();
+ img[OFF + 0x00] = 12; // apo
+ img[OFF + 0x03] = 3; // battSave
+ img[OFF + 0x05] = 2; // beep
+ img[OFF + 0x06] = 4; // bell
+ img[OFF + 0x14] = 1; // keyLock
+ img[OFF + 0x15] = 3; // lamp
+ img[OFF + 0x18] = 2; // moniTcall
+ img[OFF + 0x1A] = 1; // scanResume
+ img[OFF + 0x1B] = 5; // rfSquelch
+ img[OFF + 0x27] = 20; // tot
+ const s = parseFt65Settings(img)!;
+ expect(s.apo).toBe(12);
+ expect(s.battSave).toBe(3);
+ expect(s.beep).toBe(2);
+ expect(s.bell).toBe(4);
+ expect(s.keyLock).toBe(1);
+ expect(s.lamp).toBe(3);
+ expect(s.moniTcall).toBe(2);
+ expect(s.scanResume).toBe(1);
+ expect(s.rfSquelch).toBe(5);
+ expect(s.tot).toBe(20);
+ });
+
+ it('parses boolean flags correctly', () => {
+ const img = makeImage();
+ img[OFF + 0x04] = 1; // bclo
+ img[OFF + 0x13] = 1; // edgBeep
+ img[OFF + 0x16] = 1; // txLed
+ img[OFF + 0x17] = 1; // bsyLed
+ img[OFF + 0x19] = 1; // priRvt
+ img[OFF + 0x1C] = 1; // scanLamp
+ img[OFF + 0x1E] = 1; // useCwid
+ img[OFF + 0x1F] = 1; // compander
+ img[OFF + 0x21] = 1; // txSave
+ img[OFF + 0x22] = 1; // vfoSpl
+ img[OFF + 0x23] = 1; // vox
+ img[OFF + 0x24] = 1; // wfmRcv
+ img[OFF + 0x26] = 1; // wxAlert
+ img[OFF + 0x30] = 1; // usePasswd
+ const s = parseFt65Settings(img)!;
+ expect(s.bclo).toBe(true);
+ expect(s.edgBeep).toBe(true);
+ expect(s.txLed).toBe(true);
+ expect(s.bsyLed).toBe(true);
+ expect(s.priRvt).toBe(true);
+ expect(s.scanLamp).toBe(true);
+ expect(s.useCwid).toBe(true);
+ expect(s.compander).toBe(true);
+ expect(s.txSave).toBe(true);
+ expect(s.vfoSpl).toBe(true);
+ expect(s.vox).toBe(true);
+ expect(s.wfmRcv).toBe(true);
+ expect(s.wxAlert).toBe(true);
+ expect(s.usePasswd).toBe(true);
+ });
+
+ it('parses CW ID (6-byte ASCII, space-padded)', () => {
+ const img = makeImage();
+ // 'VE2XY ' β trailing space should be trimmed
+ img[OFF + 0x07] = 0x56; // 'V'
+ img[OFF + 0x08] = 0x45; // 'E'
+ img[OFF + 0x09] = 0x32; // '2'
+ img[OFF + 0x0A] = 0x58; // 'X'
+ img[OFF + 0x0B] = 0x59; // 'Y'
+ img[OFF + 0x0C] = 0x20; // ' ' (padding)
+ expect(parseFt65Settings(img)!.cwId).toBe('VE2XY');
+ });
+
+ it('stops CW ID parsing at null byte', () => {
+ const img = makeImage();
+ img[OFF + 0x07] = 0x41; // 'A'
+ img[OFF + 0x08] = 0x00; // null β terminates here
+ img[OFF + 0x09] = 0x42; // 'B' β should not appear
+ expect(parseFt65Settings(img)!.cwId).toBe('A');
+ });
+
+ it('parses password digits', () => {
+ const img = makeImage();
+ img[OFF + 0x31] = 0x39; // '9'
+ img[OFF + 0x32] = 0x38; // '8'
+ img[OFF + 0x33] = 0x37; // '7'
+ img[OFF + 0x34] = 0x36; // '6'
+ expect(parseFt65Settings(img)!.passwd).toBe('9876');
+ });
+
+ it('replaces non-digit password bytes with 0', () => {
+ const img = makeImage();
+ img[OFF + 0x31] = 0x41; // 'A' β '0'
+ img[OFF + 0x32] = 0x35; // '5' β ok
+ img[OFF + 0x33] = 0xFF; // invalid β '0'
+ img[OFF + 0x34] = 0x31; // '1' β ok
+ expect(parseFt65Settings(img)!.passwd).toBe('0501');
+ });
+
+ it('clamps out-of-range values to valid maximums', () => {
+ const img = makeImage();
+ img[OFF + 0x00] = 255; // apo max 24
+ img[OFF + 0x27] = 255; // tot max 30
+ img[OFF + 0x01] = 255; // artsBeep max 2
+ img[OFF + 0x1B] = 255; // rfSquelch max 8
+ const s = parseFt65Settings(img)!;
+ expect(s.apo).toBe(24);
+ expect(s.tot).toBe(30);
+ expect(s.artsBeep).toBe(2);
+ expect(s.rfSquelch).toBe(8);
+ });
+});
+
+// ββ writeFt65Settings ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+describe('writeFt65Settings', () => {
+ it('no-ops silently on an undersized image', () => {
+ expect(() => writeFt65Settings(new Uint8Array(0), { apo: 5 })).not.toThrow();
+ });
+
+ it('writes scalar fields to correct byte offsets', () => {
+ const img = makeImage();
+ writeFt65Settings(img, { apo: 8, tot: 15, beep: 1, rfSquelch: 3 });
+ expect(img[OFF + 0x00]).toBe(8);
+ expect(img[OFF + 0x27]).toBe(15);
+ expect(img[OFF + 0x05]).toBe(1);
+ expect(img[OFF + 0x1B]).toBe(3);
+ });
+
+ it('writes boolean fields as 0/1', () => {
+ const img = makeImage();
+ writeFt65Settings(img, { bclo: true, txLed: false, vox: true, scanLamp: false });
+ expect(img[OFF + 0x04]).toBe(1);
+ expect(img[OFF + 0x16]).toBe(0);
+ expect(img[OFF + 0x23]).toBe(1);
+ expect(img[OFF + 0x1C]).toBe(0);
+ });
+
+ it('writes CW ID space-padded to 6 bytes', () => {
+ const img = makeImage();
+ writeFt65Settings(img, { cwId: 'AB' });
+ expect(img[OFF + 0x07]).toBe(0x41); // 'A'
+ expect(img[OFF + 0x08]).toBe(0x42); // 'B'
+ expect(img[OFF + 0x09]).toBe(0x20); // space pad
+ expect(img[OFF + 0x0C]).toBe(0x20); // space pad
+ });
+
+ it('truncates CW ID beyond 6 chars', () => {
+ const img = makeImage();
+ writeFt65Settings(img, { cwId: 'ABCDEFGH' });
+ // Only first 6 bytes written
+ expect(img[OFF + 0x07]).toBe(0x41); // 'A'
+ expect(img[OFF + 0x0C]).toBe(0x46); // 'F' (6th char)
+ });
+
+ it('writes password digits', () => {
+ const img = makeImage();
+ writeFt65Settings(img, { passwd: '5678' });
+ expect(img[OFF + 0x31]).toBe(0x35); // '5'
+ expect(img[OFF + 0x32]).toBe(0x36); // '6'
+ expect(img[OFF + 0x33]).toBe(0x37); // '7'
+ expect(img[OFF + 0x34]).toBe(0x38); // '8'
+ });
+
+ it('does not modify bytes for unspecified fields', () => {
+ const img = makeImage();
+ img[OFF + 0x10] = 0x42; // dtmfMode byte
+ img[OFF + 0x15] = 0x77; // lamp byte
+ writeFt65Settings(img, { apo: 3 });
+ expect(img[OFF + 0x10]).toBe(0x42);
+ expect(img[OFF + 0x15]).toBe(0x77);
+ });
+
+ it('clamps written values to valid ranges', () => {
+ const img = makeImage();
+ writeFt65Settings(img, { apo: 99, tot: 99, rfSquelch: 99 });
+ expect(img[OFF + 0x00]).toBe(24);
+ expect(img[OFF + 0x27]).toBe(30);
+ expect(img[OFF + 0x1B]).toBe(8);
+ });
+});
+
+// ββ round-trip ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+describe('round-trip', () => {
+ it('parse β write β parse yields identical result', () => {
+ const img = makeImage();
+ img[OFF + 0x00] = 7; // apo
+ img[OFF + 0x05] = 1; // beep
+ img[OFF + 0x04] = 1; // bclo
+ img[OFF + 0x07] = 0x56; img[OFF + 0x08] = 0x45; img[OFF + 0x09] = 0x32; // cwId 'VE2'
+ img[OFF + 0x14] = 2; // keyLock
+ img[OFF + 0x16] = 1; // txLed
+ img[OFF + 0x23] = 1; // vox
+ img[OFF + 0x27] = 15; // tot
+ img[OFF + 0x30] = 1; // usePasswd
+ img[OFF + 0x31] = 0x39; img[OFF + 0x32] = 0x38; img[OFF + 0x33] = 0x37; img[OFF + 0x34] = 0x36;
+
+ const parsed1 = parseFt65Settings(img)!;
+ const img2 = makeImage();
+ writeFt65Settings(img2, parsed1);
+ const parsed2 = parseFt65Settings(img2)!;
+ expect(parsed2).toEqual(parsed1);
+ });
+});
diff --git a/tests/unit/uv5rMiniSettingsFormat.test.ts b/tests/unit/uv5rMiniSettingsFormat.test.ts
new file mode 100644
index 0000000..2a2a4e6
--- /dev/null
+++ b/tests/unit/uv5rMiniSettingsFormat.test.ts
@@ -0,0 +1,216 @@
+import { describe, it, expect } from 'vitest';
+import {
+ parseUv5rMiniSettings,
+ writeUv5rMiniSettings,
+ UV5RMINI_SETTINGS_OFFSET,
+} from '../../src/radios/uv5rmini/settingsFormat';
+
+const OFF = UV5RMINI_SETTINGS_OFFSET; // 0x8040
+
+function makeImage(): Uint8Array {
+ return new Uint8Array(OFF + 64);
+}
+
+// ββ parseUv5rMiniSettings βββββββββββββββββββββββββββββββββββββββββββββββββ
+
+describe('parseUv5rMiniSettings', () => {
+ it('returns null when image is too small', () => {
+ expect(parseUv5rMiniSettings(new Uint8Array(0))).toBeNull();
+ expect(parseUv5rMiniSettings(new Uint8Array(OFF + 10))).toBeNull();
+ });
+
+ it('parses all-zero block to safe defaults', () => {
+ const s = parseUv5rMiniSettings(makeImage())!;
+ expect(s.squelch).toBe(0);
+ expect(s.tot).toBe(0);
+ expect(s.beep).toBe(0);
+ expect(s.voicesw).toBe(false);
+ expect(s.roger).toBe(false);
+ expect(s.aOrB).toBe(0);
+ expect(s.chaworkmode).toBe(0);
+ expect(s.chbworkmode).toBe(0);
+ });
+
+ it('parses byte-mapped scalar fields at correct offsets', () => {
+ const img = makeImage();
+ img[OFF + 0] = 4; // squelch
+ img[OFF + 1] = 1; // savemode
+ img[OFF + 2] = 5; // vox
+ img[OFF + 3] = 2; // backlight
+ img[OFF + 4] = 1; // dualstandby
+ img[OFF + 5] = 8; // tot
+ img[OFF + 6] = 1; // beep
+ img[OFF + 8] = 1; // voice
+ img[OFF + 9] = 2; // sidetone
+ img[OFF + 10] = 1; // scanmode
+ img[OFF + 11] = 3; // pttid
+ img[OFF + 12] = 5; // pttdly
+ img[OFF + 13] = 2; // chadistype
+ img[OFF + 14] = 1; // chbdistype
+ img[OFF + 17] = 2; // alarmmode
+ img[OFF + 21] = 3; // rpttailclear
+ img[OFF + 22] = 4; // rpttaildet
+ img[OFF + 28] = 1; // powerondistype
+ img[OFF + 32] = 7; // voxdlytime
+ img[OFF + 33] = 5; // menuquittime
+ img[OFF + 40] = 6; // totalarm
+ img[OFF + 43] = 2; // ctsdcsscantype
+ img[OFF + 57] = 3; // hangup
+ const s = parseUv5rMiniSettings(img)!;
+ expect(s.squelch).toBe(4);
+ expect(s.savemode).toBe(1);
+ expect(s.vox).toBe(5);
+ expect(s.backlight).toBe(2);
+ expect(s.dualstandby).toBe(1);
+ expect(s.tot).toBe(8);
+ expect(s.beep).toBe(1);
+ expect(s.voice).toBe(1);
+ expect(s.sidetone).toBe(2);
+ expect(s.scanmode).toBe(1);
+ expect(s.pttid).toBe(3);
+ expect(s.pttdly).toBe(5);
+ expect(s.chadistype).toBe(2);
+ expect(s.chbdistype).toBe(1);
+ expect(s.alarmmode).toBe(2);
+ expect(s.rpttailclear).toBe(3);
+ expect(s.rpttaildet).toBe(4);
+ expect(s.powerondistype).toBe(1);
+ expect(s.voxdlytime).toBe(7);
+ expect(s.menuquittime).toBe(5);
+ expect(s.totalarm).toBe(6);
+ expect(s.ctsdcsscantype).toBe(2);
+ expect(s.hangup).toBe(3);
+ });
+
+ it('parses boolean flags', () => {
+ const img = makeImage();
+ img[OFF + 7] = 1; // voicesw
+ img[OFF + 15] = 1; // bcl
+ img[OFF + 16] = 1; // autolock
+ img[OFF + 18] = 1; // alarmtone
+ img[OFF + 20] = 1; // tailclear
+ img[OFF + 23] = 1; // roger
+ img[OFF + 25] = 1; // fmenable
+ img[OFF + 27] = 1; // keylock
+ img[OFF + 36] = 1; // dispani
+ img[OFF + 58] = 1; // voxsw
+ img[OFF + 61] = 1; // inputdtmf
+ const s = parseUv5rMiniSettings(img)!;
+ expect(s.voicesw).toBe(true);
+ expect(s.bcl).toBe(true);
+ expect(s.autolock).toBe(true);
+ expect(s.alarmtone).toBe(true);
+ expect(s.tailclear).toBe(true);
+ expect(s.roger).toBe(true);
+ expect(s.fmenable).toBe(true);
+ expect(s.keylock).toBe(true);
+ expect(s.dispani).toBe(true);
+ expect(s.voxsw).toBe(true);
+ expect(s.inputdtmf).toBe(true);
+ });
+
+ it('decodes chaworkmode / chbworkmode from packed nibbles', () => {
+ const img = makeImage();
+ img[OFF + 26] = (1 << 4) | 0; // chaworkmode=Channel(1), chbworkmode=Frequency(0)
+ const s = parseUv5rMiniSettings(img)!;
+ expect(s.chaworkmode).toBe(1);
+ expect(s.chbworkmode).toBe(0);
+
+ const img2 = makeImage();
+ img2[OFF + 26] = (0 << 4) | 1; // chaworkmode=Frequency(0), chbworkmode=Channel(1)
+ const s2 = parseUv5rMiniSettings(img2)!;
+ expect(s2.chaworkmode).toBe(0);
+ expect(s2.chbworkmode).toBe(1);
+ });
+
+ it('parses aOrB as 0 or 1', () => {
+ const img = makeImage();
+ img[OFF + 24] = 0;
+ expect(parseUv5rMiniSettings(img)!.aOrB).toBe(0);
+ img[OFF + 24] = 5; // any non-zero β 1
+ expect(parseUv5rMiniSettings(img)!.aOrB).toBe(1);
+ });
+
+ it('clamps out-of-range values', () => {
+ const img = makeImage();
+ img[OFF + 0] = 255; // squelch max 5
+ img[OFF + 5] = 255; // tot max 12
+ img[OFF + 11] = 255; // pttid max 3
+ const s = parseUv5rMiniSettings(img)!;
+ expect(s.squelch).toBe(5);
+ expect(s.tot).toBe(12);
+ expect(s.pttid).toBe(3);
+ });
+});
+
+// ββ writeUv5rMiniSettings βββββββββββββββββββββββββββββββββββββββββββββββββ
+
+describe('writeUv5rMiniSettings', () => {
+ it('no-ops silently on an undersized image', () => {
+ expect(() => writeUv5rMiniSettings(new Uint8Array(0), { squelch: 3 })).not.toThrow();
+ });
+
+ it('writes scalar fields to correct byte offsets', () => {
+ const img = makeImage();
+ writeUv5rMiniSettings(img, { squelch: 4, tot: 6, beep: 1, sidetone: 2 });
+ expect(img[OFF + 0]).toBe(4);
+ expect(img[OFF + 5]).toBe(6);
+ expect(img[OFF + 6]).toBe(1);
+ expect(img[OFF + 9]).toBe(2);
+ });
+
+ it('writes boolean fields as 0/1', () => {
+ const img = makeImage();
+ writeUv5rMiniSettings(img, { voicesw: true, roger: false, keylock: true, voxsw: false });
+ expect(img[OFF + 7]).toBe(1);
+ expect(img[OFF + 23]).toBe(0);
+ expect(img[OFF + 27]).toBe(1);
+ expect(img[OFF + 58]).toBe(0);
+ });
+
+ it('packs chaworkmode / chbworkmode into nibbles of byte 26', () => {
+ const img = makeImage();
+ writeUv5rMiniSettings(img, { chaworkmode: 1, chbworkmode: 0 });
+ expect(img[OFF + 26]).toBe((1 << 4) | 0);
+ });
+
+ it('preserves the other nibble when only one workmode is set', () => {
+ const img = makeImage();
+ img[OFF + 26] = (1 << 4) | 1; // both Channel
+ writeUv5rMiniSettings(img, { chaworkmode: 0 }); // change only A
+ expect(img[OFF + 26]).toBe((0 << 4) | 1); // B stays Channel
+ });
+
+ it('does not modify bytes for unspecified fields', () => {
+ const img = makeImage();
+ img[OFF + 10] = 0x55; // scanmode
+ img[OFF + 17] = 0x77; // alarmmode
+ writeUv5rMiniSettings(img, { squelch: 2 });
+ expect(img[OFF + 10]).toBe(0x55);
+ expect(img[OFF + 17]).toBe(0x77);
+ });
+});
+
+// ββ round-trip ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+describe('round-trip', () => {
+ it('parse β write β parse yields identical result', () => {
+ const img = makeImage();
+ img[OFF + 0] = 3; // squelch
+ img[OFF + 5] = 8; // tot
+ img[OFF + 6] = 1; // beep
+ img[OFF + 7] = 1; // voicesw
+ img[OFF + 9] = 2; // sidetone
+ img[OFF + 23] = 1; // roger
+ img[OFF + 24] = 0; // aOrB = A
+ img[OFF + 26] = (1 << 4) | 1; // both workmode=Channel
+ img[OFF + 27] = 1; // keylock
+ img[OFF + 57] = 4; // hangup
+
+ const parsed1 = parseUv5rMiniSettings(img)!;
+ const img2 = makeImage();
+ writeUv5rMiniSettings(img2, parsed1);
+ const parsed2 = parseUv5rMiniSettings(img2)!;
+ expect(parsed2).toEqual(parsed1);
+ });
+});