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_DESCRIPTORS.map((d) => ( + + + + + + ))} + +
RadioManufacturerConnection
+ {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 = () => { )} + {isError && onChangePort && ( + + )} {isError && onRetry && ( )}

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) => ( - +
+ {Array.from(groupedOptions.entries()).map(([group, opts]) => ( +
+ {group && ( +

+ {group} +

+ )} +
+ {opts.map((opt) => ( + + ))} +
+
))}
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); + }); +});