diff --git a/README.md b/README.md index 163fd7e..4ace8d0 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ [![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/j59TBHVD22) -**A modern, web-based Customer Programming Software (CPS) for the Baofeng DM-32UV radio.** +**A next-generation, web-based Channel Programming Software (CPS) for supported radios.** -Program your DMR radio directly from your browser—no software installation required. NeonPlug brings a sleek, cyberpunk-themed interface with powerful features for managing channels, contacts, zones, and more. +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. **🚀 Try it live:** [https://neonplug.app](https://neonplug.app) · **📥 [Download offline version](https://neonplug.app)** (single-file, no install) @@ -27,8 +27,8 @@ Program your DMR radio directly from your browser—no software installation req ## 🎯 Key Features ### 📻 Radio Management -- **Direct USB Connection** - Connect your DM-32UV via Web Serial API (no drivers needed) -- **Read & Write** - Full codeplug read/write support +- **Web Serial & BLE** - Connect via USB (Web Serial API, no drivers) or Bluetooth Low Energy where supported (e.g. UV5R-Mini) +- **Read & Write** - Full codeplug read/write support for each radio - **Live Validation** - Real-time frequency and configuration validation ### 📡 Channel Configuration @@ -58,7 +58,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) -- Baofeng DM-32UV radio with USB cable +- A supported radio (e.g. DM-32UV / DP570UV or UV5R-Mini) with USB cable—or BLE for radios that support it ### 📥 Offline mode diff --git a/src/App.tsx b/src/App.tsx index fba842f..a846086 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,7 +49,7 @@ function App() { const { setContacts: setQuickContacts } = useQuickContactsStore(); const { setGroups: setRXGroups } = useRXGroupsStore(); const { setKeys: setEncryptionKeys } = useEncryptionKeysStore(); - const { setRadioInfo } = useRadioStore(); + const { setRadioInfo, setPreferredTransport, showPickRadioModal, setShowPickRadioModal } = useRadioStore(); const { isConnecting, error: radioError } = useRadioConnection(); const fileInputRef = useRef(null); @@ -104,10 +104,12 @@ function App() { return () => observer.disconnect(); }, []); - const handleReadFromRadio = () => { - // Close startup modal - the Toolbar's handleRead will show the progress modal + const handleReadFromRadio = (transport?: 'serial' | 'ble') => { + if (transport != null) { + setPreferredTransport(transport); + } setShowStartupModal(false); - // Small delay to ensure modal closes, then trigger the read button + setShowPickRadioModal(false); setTimeout(() => { const readButton = document.querySelector('[data-action="read-from-radio"]') as HTMLButtonElement; if (readButton && !readButton.disabled) { @@ -118,6 +120,7 @@ function App() { const handleLoadFile = () => { setShowStartupModal(false); + setShowPickRadioModal(false); // Small delay to ensure modal closes before file dialog opens setTimeout(() => { fileInputRef.current?.click(); @@ -218,6 +221,7 @@ function App() { const handleDismissStartup = () => { setShowStartupModal(false); + setShowPickRadioModal(false); // Load sample data if user dismisses setChannels(sampleChannels); setContacts(sampleContacts); @@ -264,10 +268,11 @@ function App() { {renderTabContent()} setShowPickRadioModal(false) : undefined} /> Project Information

- NeonPlug is a web-based Channel Programming Software (CPS) - for supported radios (DM-32UV / DP570UV). 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) 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.

- This software implements the DM-32UV serial protocol specification, enabling full read and write - operations directly from your web browser using the Web Serial API. + 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).

diff --git a/src/components/channels/ChannelEditModal.tsx b/src/components/channels/ChannelEditModal.tsx index b3a7c67..cc10613 100644 --- a/src/components/channels/ChannelEditModal.tsx +++ b/src/components/channels/ChannelEditModal.tsx @@ -54,6 +54,10 @@ interface ChannelEditModalProps { onSave: (channel: Channel) => void; /** Band limits from radio capabilities (getCapabilitiesForModel(radioInfo?.model)?.bandLimits). */ bandLimits?: RadioBandLimits | null; + /** Max channel number from capabilities (e.g. 999 for UV5R-Mini, 4000 for DM-32UV). */ + maxChannels?: number; + /** When true, hide Digital/Fixed Digital mode options (e.g. UV5R-Mini). */ + analogOnly?: boolean; rxGroups?: RXGroup[]; encryptionKeys?: EncryptionKey[]; talkGroups?: QuickContact[]; @@ -65,6 +69,8 @@ export const ChannelEditModal: React.FC = ({ channel, onSave, bandLimits = null, + maxChannels = 4000, + analogOnly = false, rxGroups = [], encryptionKeys = [], talkGroups = [], @@ -80,9 +86,13 @@ export const ChannelEditModal: React.FC = ({ } else if (channel.number === 4002) { updatedChannel.name = 'VFO B'; } + // For analog-only radios, ensure mode is analog (never Digital/Fixed Digital) + if (analogOnly && (channel.mode === 'Digital' || channel.mode === 'Fixed Digital')) { + updatedChannel.mode = 'Analog'; + } setEditedChannel(updatedChannel); setValidationErrors([]); - }, [channel]); + }, [channel, analogOnly]); const handleChange = (field: keyof Channel, value: any) => { setEditedChannel(prev => ({ ...prev, [field]: value })); @@ -90,7 +100,7 @@ export const ChannelEditModal: React.FC = ({ }; const handleSave = () => { - const errors = validateChannel(editedChannel, bandLimits); + const errors = validateChannel(editedChannel, bandLimits, maxChannels); if (errors.length > 0) { setValidationErrors(errors); return; @@ -234,9 +244,9 @@ export const ChannelEditModal: React.FC = ({ className="w-full bg-deep-gray border border-neon-cyan border-opacity-30 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-neon-cyan focus:shadow-glow-cyan" > - + {!analogOnly && } - + {!analogOnly && }

Communication mode for this channel

diff --git a/src/components/channels/ChannelsTab.tsx b/src/components/channels/ChannelsTab.tsx index 1106d64..2c22905 100644 --- a/src/components/channels/ChannelsTab.tsx +++ b/src/components/channels/ChannelsTab.tsx @@ -1,6 +1,8 @@ import React, { useState, useMemo, useCallback, useRef } from 'react'; import { useChannelsStore } from '../../store/channelsStore'; import { useRadioSettingsStore } from '../../store/radioSettingsStore'; +import { useEffectiveRadioModel } from '../../hooks/useEffectiveRadioModel'; +import { getCapabilitiesForModel } from '../../radios/capabilities'; import { ChannelsTable } from './ChannelsTable'; import { createDefaultChannel } from '../../utils/channelHelpers'; import { ConfirmModal } from '../ui/ConfirmModal'; @@ -11,6 +13,9 @@ const isVFOChannel = (n: number) => n === 4001 || n === 4002; export const ChannelsTab: React.FC = () => { const { channels, addChannel, deleteChannels } = useChannelsStore(); const { settings: radioSettings } = useRadioSettingsStore(); + const effectiveModel = useEffectiveRadioModel(); + const caps = getCapabilitiesForModel(effectiveModel); + const supportsVfoChannels = caps?.supportsVfoChannels === true; const [searchQuery, setSearchQuery] = useState(''); const [scrollToChannel, setScrollToChannel] = useState(null); const [selectedChannelNumbers, setSelectedChannelNumbers] = useState>(new Set()); @@ -64,8 +69,9 @@ export const ChannelsTab: React.FC = () => { const handleClearSelection = useCallback(() => setSelectedChannelNumbers(new Set()), []); - // Create VFO channels with channel numbers 4001 and 4002 + // VFO A/B as channels 4001/4002 — DM-32 only; UV5R-Mini and other radios do not have these in the channel list const vfoChannels = useMemo(() => { + if (!supportsVfoChannels) return []; const vfos: Channel[] = []; if (radioSettings?.vfoA) { vfos.push({ ...radioSettings.vfoA, number: 4001 }); // VFO A is channel 4001 @@ -74,11 +80,13 @@ export const ChannelsTab: React.FC = () => { vfos.push({ ...radioSettings.vfoB, number: 4002 }); // VFO B is channel 4002 } return vfos; - }, [radioSettings?.vfoA, radioSettings?.vfoB]); + }, [supportsVfoChannels, radioSettings?.vfoA, radioSettings?.vfoB]); const filteredChannels = useMemo(() => { - const allChannels = [...vfoChannels, ...channels]; - + // Exclude empty channels (rxFrequency 0 = unprogrammed slot) + const nonEmptyChannels = channels.filter(ch => ch.rxFrequency > 0); + const allChannels = [...vfoChannels, ...nonEmptyChannels]; + if (!searchQuery.trim()) { return allChannels; } diff --git a/src/components/channels/ChannelsTable.tsx b/src/components/channels/ChannelsTable.tsx index af851f9..52903cb 100644 --- a/src/components/channels/ChannelsTable.tsx +++ b/src/components/channels/ChannelsTable.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { useChannelsStore } from '../../store/channelsStore'; -import { useRadioStore } from '../../store/radioStore'; +import { useEffectiveRadioModel } from '../../hooks/useEffectiveRadioModel'; import { useRadioSettingsStore } from '../../store/radioSettingsStore'; import { useScanListsStore } from '../../store/scanListsStore'; import { getCapabilitiesForModel } from '../../radios/capabilities'; @@ -70,9 +70,12 @@ export const ChannelsTable: React.FC = ({ onSelectionChange, }) => { const { channels: channelsFromStore, updateChannel, deleteChannel, addChannel } = useChannelsStore(); - const { radioInfo } = useRadioStore(); + const effectiveModel = useEffectiveRadioModel(); const { settings: radioSettings, updateSettings } = useRadioSettingsStore(); - const bandLimits = getCapabilitiesForModel(radioInfo?.model)?.bandLimits ?? null; + const caps = getCapabilitiesForModel(effectiveModel); + const bandLimits = caps?.bandLimits ?? null; + const maxChannels = caps?.maxChannels ?? 4000; + const analogOnly = caps?.analogOnly === true; const { scanLists } = useScanListsStore(); const { groups: rxGroups } = useRXGroupsStore(); const { keys: encryptionKeys } = useEncryptionKeysStore(); @@ -277,7 +280,9 @@ export const ChannelsTable: React.FC = ({ RX Freq Copy TX Freq - Mode + {!analogOnly && ( + Mode + )} PWR BW {/* Common fields - work for both analog and digital */} @@ -305,16 +310,20 @@ export const ChannelsTable: React.FC = ({ Step Freq Sig Type PTT ID Type - {/* Digital-only fields */} - Color Code - RX Group - Slot - Enc - Enc ID - TDMA - SDC - Priv - TX DMR ID + {/* Digital-only fields - hidden for analog-only radios */} + {!analogOnly && ( + <> + Color Code + RX Group + Slot + Enc + Enc ID + TDMA + SDC + Priv + TX DMR ID + + )} {/* Common fields - work for both */} TG Actions @@ -405,25 +414,27 @@ export const ChannelsTable: React.FC = ({ /> )} - - - + {!analogOnly && ( + + + + )} + )} {/* Fixed Channels Section */} diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index 1d97f56..2364e2c 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -1,5 +1,6 @@ import React, { useMemo, useState } from 'react'; import { useRadioStore } from '../../store/radioStore'; +import { useEffectiveRadioModel } from '../../hooks/useEffectiveRadioModel'; import { Modal } from '../ui/Modal'; import { getCapabilitiesForModel } from '../../radios/capabilities'; @@ -7,13 +8,16 @@ const USER_GESTURE_MESSAGE = 'Unable to read from radio, please read from a radi export const StatusBar: React.FC = () => { const { radioInfo, connectionError, setConnectionError } = useRadioStore(); + const effectiveModel = useEffectiveRadioModel(); const [showFirmwareWarning, setShowFirmwareWarning] = useState(false); const showUserGestureInBar = connectionError?.includes('Please click the button directly') ?? false; - const caps = useMemo(() => getCapabilitiesForModel(radioInfo?.model), [radioInfo?.model]); + const caps = useMemo(() => getCapabilitiesForModel(effectiveModel), [effectiveModel]); + const hasRealFirmware = !!(radioInfo?.firmware && radioInfo.firmware !== '-' && radioInfo.firmware.trim() !== ''); const EXPECTED_FIRMWARE = 'DM32.01.L01.048'; - const isNewerFirmware = !!(radioInfo?.firmware && caps?.isFirmware049OrNewer?.(radioInfo.firmware)); - const needsFirmwareUpdate = radioInfo?.firmware && radioInfo.firmware !== EXPECTED_FIRMWARE && !isNewerFirmware; + const isNewerFirmware = !!(hasRealFirmware && caps?.isFirmware049OrNewer?.(radioInfo!.firmware)); + const needsFirmwareUpdate = hasRealFirmware && radioInfo!.firmware !== EXPECTED_FIRMWARE && !isNewerFirmware; + const deviceValue = (v: string | undefined) => (v && v.trim() && v !== '-' ? v : '-'); return ( <> @@ -28,7 +32,7 @@ export const StatusBar: React.FC = () => { |
Firmware: - {radioInfo.firmware} + {deviceValue(radioInfo.firmware)} {(needsFirmwareUpdate || isNewerFirmware) && ( )}
- {radioInfo.buildDate && ( - <> - | -
- Build: - {radioInfo.buildDate} -
- - )} + | +
+ Build: + {deviceValue(radioInfo.buildDate)} +
{radioInfo.dspVersion && ( <> | diff --git a/src/components/layout/TabNavigation.tsx b/src/components/layout/TabNavigation.tsx index 7e0e3d8..906e2a8 100644 --- a/src/components/layout/TabNavigation.tsx +++ b/src/components/layout/TabNavigation.tsx @@ -1,12 +1,14 @@ -import React from 'react'; +import React, { useMemo, useEffect } from 'react'; import { useDebugStore } from '../../store/debugStore'; +import { useEffectiveRadioModel } from '../../hooks/useEffectiveRadioModel'; +import { getCapabilitiesForModel } from '../../radios/capabilities'; interface TabNavigationProps { activeTab: string; onTabChange: (tab: string) => void; } -const tabs = [ +const ALL_TABS = [ { id: 'channels', label: 'Channels' }, { id: 'zones', label: 'Zones' }, { id: 'scanlists', label: 'Scan Lists' }, @@ -23,14 +25,31 @@ export const TabNavigation: React.FC = ({ onTabChange, }) => { const { debugMode } = useDebugStore(); + const effectiveModel = useEffectiveRadioModel(); + const caps = useMemo(() => getCapabilitiesForModel(effectiveModel), [effectiveModel]); + + const tabs = useMemo(() => { + return ALL_TABS.filter((tab) => { + if (tab.id === 'diagnostics' && !debugMode) return false; + if (tab.id === 'zones' && caps?.supportsZones === false) return false; + if (tab.id === 'scanlists' && caps?.supportsScanLists === false) return false; + if (tab.id === 'contacts' && caps?.supportsContacts === false) return false; + if (tab.id === 'digital' && caps?.analogOnly === true) return false; + return true; + }); + }, [debugMode, caps?.supportsZones, caps?.supportsScanLists, caps?.supportsContacts, caps?.analogOnly]); + + useEffect(() => { + const visibleIds = tabs.map((t) => t.id); + if (!visibleIds.includes(activeTab)) { + onTabChange('channels'); + } + }, [tabs, activeTab, onTabChange]); return (
- {tabs.map((tab) => { - const isHidden = tab.id === 'diagnostics' && !debugMode; - if (isHidden) return null; - return ( + {tabs.map((tab) => ( - ); - })} + ))}
); diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index 4f32791..606a059 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react'; +import React, { useRef, useState, useEffect } from 'react'; import { Button } from '../ui/Button'; import { useChannelsStore } from '../../store/channelsStore'; import { useZonesStore } from '../../store/zonesStore'; @@ -8,13 +8,16 @@ import { useRadioSettingsStore } from '../../store/radioSettingsStore'; import { useDigitalEmergencyStore } from '../../store/digitalEmergencyStore'; import { useAnalogEmergencyStore } from '../../store/analogEmergencyStore'; import { useRadioStore } from '../../store/radioStore'; +import { useEffectiveRadioModel } from '../../hooks/useEffectiveRadioModel'; import { useQuickMessagesStore } from '../../store/quickMessagesStore'; import { useDMRRadioIDsStore } from '../../store/dmrRadioIdsStore'; import { useQuickContactsStore } from '../../store/quickContactsStore'; import { useRXGroupsStore } from '../../store/rxGroupsStore'; import { useEncryptionKeysStore } from '../../store/encryptionKeysStore'; import { getCapabilitiesForModel } from '../../radios/capabilities'; +import { getRadioPickerOptions, getMigrationTargetModels } from '../../radios'; import { validateCodeplugForWrite } from '../../services/validation/codeplugValidator'; +import { migrateCodeplug, type MigrationLoss } from '../../services/codeplugMigration'; // Codeplug export/import are lazy loaded when needed import { useRadioConnection } from '../../hooks/useRadioConnection'; import { ReadProgressModal } from '../ui/ReadProgressModal'; @@ -29,7 +32,8 @@ export const Toolbar: React.FC = () => { const { settings: radioSettings, setSettings: setRadioSettings } = useRadioSettingsStore(); const { systems: digitalEmergencies, config: digitalEmergencyConfig, setSystems: setDigitalEmergencies, setConfig: setDigitalEmergencyConfig } = useDigitalEmergencyStore(); const { systems: analogEmergencies, setSystems: setAnalogEmergencies } = useAnalogEmergencyStore(); - const { radioInfo, setRadioInfo } = useRadioStore(); + const { radioInfo, setRadioInfo, setShowPickRadioModal, setSelectedRadioModel } = useRadioStore(); + const effectiveModel = useEffectiveRadioModel(); const { messages, setMessages } = useQuickMessagesStore(); const { radioIds: dmrRadioIds, setRadioIds } = useDMRRadioIDsStore(); const { contacts: quickContacts, setContacts: setQuickContacts } = useQuickContactsStore(); @@ -48,8 +52,91 @@ export const Toolbar: React.FC = () => { const [alertOpen, setAlertOpen] = useState(false); const [alertMessage, setAlertMessage] = useState(''); const [alertTitle, setAlertTitle] = useState('Notice'); + const [convertModalOpen, setConvertModalOpen] = useState(false); + const [convertTargetModel, setConvertTargetModel] = useState(() => getMigrationTargetModels()[0] ?? 'DM-32UV'); + const [readDropdownOpen, setReadDropdownOpen] = useState(false); + const readDropdownRef = useRef(null); const webSerialSupported = isWebSerialSupported(); + useEffect(() => { + if (!readDropdownOpen) return; + const close = (e: MouseEvent) => { + if (readDropdownRef.current?.contains(e.target as Node)) return; + setReadDropdownOpen(false); + }; + document.addEventListener('click', close); + return () => document.removeEventListener('click', close); + }, [readDropdownOpen]); + + const buildCodeplugData = () => ({ + channels, + zones, + scanLists, + contacts, + digitalEmergencies, + digitalEmergencyConfig, + analogEmergencies, + radioSettings, + radioInfo, + messages, + radioIds: dmrRadioIds, + quickContacts, + rxGroups, + encryptionKeys, + exportDate: new Date().toISOString(), + version: '1.0.0', + }); + + const formatMigrationLoss = (loss: MigrationLoss): string => { + const parts: string[] = []; + if (loss.channelsDropped > 0) parts.push(`${loss.channelsDropped} channel(s) removed`); + if (loss.zonesLost > 0) parts.push(`${loss.zonesLost} zone(s) removed`); + if (loss.scanListsLost > 0) parts.push(`${loss.scanListsLost} scan list(s) removed`); + if (loss.contactsLost > 0) parts.push(`${loss.contactsLost} contact(s) removed`); + if (loss.radioIdsLost > 0) parts.push(`${loss.radioIdsLost} DMR ID(s) removed`); + if (loss.digitalEmergenciesLost > 0) parts.push(`${loss.digitalEmergenciesLost} digital emergency(s) removed`); + if (loss.messagesLost > 0) parts.push(`${loss.messagesLost} quick message(s) removed`); + if (loss.quickContactsLost > 0) parts.push(`${loss.quickContactsLost} quick contact(s) removed`); + if (loss.rxGroupsLost > 0) parts.push(`${loss.rxGroupsLost} RX group(s) removed`); + if (loss.encryptionKeysLost > 0) parts.push(`${loss.encryptionKeysLost} encryption key(s) removed`); + if (loss.settingsCleared) parts.push('Radio settings cleared (do not map between radios)'); + return parts.length > 0 ? parts.join('. ') : 'No data removed.'; + }; + + const handleConvertReplace = async () => { + const data = buildCodeplugData(); + const { migrated, loss } = migrateCodeplug(data, convertTargetModel); + setChannels(migrated.channels); + setZones(migrated.zones); + setScanLists(migrated.scanLists); + setContacts(migrated.contacts); + setDigitalEmergencies(migrated.digitalEmergencies); + setDigitalEmergencyConfig(migrated.digitalEmergencyConfig ?? null); + setAnalogEmergencies(migrated.analogEmergencies); + setRadioSettings(migrated.radioSettings ?? null); + setRadioInfo(migrated.radioInfo ?? null); + setMessages(migrated.messages); + setRadioIds(migrated.radioIds); + setQuickContacts(migrated.quickContacts); + setRXGroups(migrated.rxGroups); + setEncryptionKeys(migrated.encryptionKeys); + setSelectedRadioModel(convertTargetModel); + setConvertModalOpen(false); + const targetLabel = getRadioPickerOptions().find((o) => o.modelId === convertTargetModel)?.label ?? convertTargetModel; + setAlertTitle('Convert'); + const lossText = formatMigrationLoss(loss); + setAlertMessage(`Codeplug converted for ${targetLabel}. ${lossText}`); + setAlertOpen(true); + }; + + const handleConvertDownload = async () => { + const data = buildCodeplugData(); + const { migrated } = migrateCodeplug(data, convertTargetModel); + const { exportCodeplug } = await import('../../services/codeplugExport'); + await exportCodeplug(migrated); + setConvertModalOpen(false); + }; + const handleImport = () => { fileInputRef.current?.click(); }; @@ -120,27 +207,8 @@ export const Toolbar: React.FC = () => { }; const handleExport = async () => { - const codeplugData = { - channels, - zones, - scanLists, - contacts, - digitalEmergencies, - digitalEmergencyConfig, - analogEmergencies, - radioSettings, - radioInfo, - messages, - radioIds: dmrRadioIds, - quickContacts, - rxGroups, - encryptionKeys, - exportDate: new Date().toISOString(), - version: '1.0.0', - }; - // Lazy load codeplug export when needed const { exportCodeplug } = await import('../../services/codeplugExport'); - await exportCodeplug(codeplugData); + await exportCodeplug(buildCodeplugData()); }; const handleRead = async () => { @@ -245,7 +313,7 @@ export const Toolbar: React.FC = () => { return; } // Run radio-specific validations only when model is known; combine with experimental warning in one modal - const caps = getCapabilitiesForModel(radioInfo?.model); + const caps = getCapabilitiesForModel(effectiveModel); const { warnings } = validateCodeplugForWrite(channels, zones, caps?.writeValidations, dmrRadioIds); let message = EXPERIMENTAL_WRITE_WARNING; if (warnings.length > 0) { @@ -298,6 +366,18 @@ export const Toolbar: React.FC = () => { />
+ {radioInfo && ( +
+ Model: + {radioInfo.model} + {radioInfo.firmware && ( + <> + Firmware: + {radioInfo.firmware} + + )} +
+ )}
@@ -317,18 +397,53 @@ export const Toolbar: React.FC = () => { > Export +
- +
+
+ + +
+ {readDropdownOpen && ( +
+ +
+ )} +
+ +
+ +
+
+ ); + })()} ); }; diff --git a/src/components/settings/SettingsTab.tsx b/src/components/settings/SettingsTab.tsx index 17abb10..749f068 100644 --- a/src/components/settings/SettingsTab.tsx +++ b/src/components/settings/SettingsTab.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import Cropper, { Area } from 'react-easy-crop'; import { useRadioStore } from '../../store/radioStore'; +import { useEffectiveRadioModel } from '../../hooks/useEffectiveRadioModel'; import { useRadioConnection } from '../../hooks/useRadioConnection'; import { parseBootImageHeader, rgb565ToImageData, imageDataToRgb565, buildBootImagePayload, BOOT_IMAGE } from '../../utils/bootImage'; import { useChannelsStore } from '../../store/channelsStore'; @@ -88,31 +89,37 @@ export const SettingsTab: React.FC = () => { const [showCalibration, setShowCalibration] = useState(false); const [showFirmwareWarning, setShowFirmwareWarning] = useState(false); - const caps = useMemo(() => getCapabilitiesForModel(radioInfo?.model), [radioInfo?.model]); + const effectiveModel = useEffectiveRadioModel(); + const caps = useMemo(() => getCapabilitiesForModel(effectiveModel), [effectiveModel]); const EXPECTED_FIRMWARE = 'DM32.01.L01.048'; - const isNewerFirmware = !!(radioInfo?.firmware && caps?.isFirmware049OrNewer?.(radioInfo.firmware)); - const needsFirmwareUpdate = radioInfo?.firmware && radioInfo.firmware !== EXPECTED_FIRMWARE && !isNewerFirmware; + const hasRealFirmware = !!(radioInfo?.firmware && radioInfo.firmware !== '-' && radioInfo.firmware.trim() !== ''); + const isNewerFirmware = !!(hasRealFirmware && caps?.isFirmware049OrNewer?.(radioInfo!.firmware)); + const needsFirmwareUpdate = hasRealFirmware && radioInfo!.firmware !== EXPECTED_FIRMWARE && !isNewerFirmware; + /** Display value for device info fields; show "-" when unknown (e.g. after convert). */ + const deviceValue = (v: string | undefined) => (v && v.trim() && v !== '-' ? v : '-'); - // Calculate usage statistics (exclude VFO channels) + // Usage statistics: totals from current radio capabilities (converted codeplug shows target radio limits) + const maxChannels = caps?.maxChannels ?? 4000; + const maxZones = caps?.maxZones ?? (caps?.supportsZones ? 250 : 0); + const maxContacts = caps?.supportsContacts ? (radioInfo?.maxContacts ?? 50000) : 0; const vfoCount = (radioSettings?.vfoA ? 1 : 0) + (radioSettings?.vfoB ? 1 : 0); const channelUsage = { used: channels.length - vfoCount, - total: 4000, - percent: Math.round(((channels.length - vfoCount) / 4000) * 100), + total: maxChannels, + percent: maxChannels > 0 ? Math.round(((channels.length - vfoCount) / maxChannels) * 100) : 0, }; const zoneUsage = { used: zones.length, - total: 250, // Max zones per spec - percent: Math.round((zones.length / 250) * 100), + total: maxZones, + percent: maxZones > 0 ? Math.round((zones.length / maxZones) * 100) : 0, }; - const contactCapacity = radioInfo?.maxContacts ?? 50000; const contactUsage = { used: contacts.length, - total: contactCapacity, - percent: contactsLoaded ? Math.round((contacts.length / contactCapacity) * 100) : 0, + total: maxContacts, + percent: maxContacts > 0 && contactsLoaded ? Math.round((contacts.length / maxContacts) * 100) : 0, loaded: contactsLoaded, }; @@ -387,7 +394,7 @@ export const SettingsTab: React.FC = () => {
Firmware
- {radioInfo.firmware} + {deviceValue(radioInfo.firmware)} {(needsFirmwareUpdate || isNewerFirmware) && (
- {radioInfo.buildDate && ( -
- Build Date -
{radioInfo.buildDate}
-
- )} - {radioInfo.dspVersion && ( -
- DSP Version -
{radioInfo.dspVersion}
-
- )} - {radioInfo.radioVersion && ( -
- Radio Version -
{radioInfo.radioVersion}
-
- )} - {radioInfo.codeplugVersion && ( -
- Codeplug Version -
{radioInfo.codeplugVersion}
-
- )} +
+ Build Date +
{deviceValue(radioInfo.buildDate)}
+
+
+ DSP Version +
{deviceValue(radioInfo.dspVersion)}
+
+
+ Radio Version +
{deviceValue(radioInfo.radioVersion)}
+
+
+ Codeplug Version +
{deviceValue(radioInfo.codeplugVersion)}
+
@@ -461,7 +460,8 @@ export const SettingsTab: React.FC = () => { /> - + + {caps?.supportsZones !== false && (
Zones @@ -476,12 +476,14 @@ export const SettingsTab: React.FC = () => { />
- + )} + + {caps?.supportsContacts !== false && (
CSV Contacts - {contactUsage.loaded + {contactUsage.loaded ? `${contactUsage.used} / ${contactUsage.total.toLocaleString()} (${contactUsage.percent}%)` : `unknown / ${contactUsage.total.toLocaleString()}` } @@ -496,6 +498,7 @@ export const SettingsTab: React.FC = () => {
)}
+ )} @@ -503,7 +506,7 @@ export const SettingsTab: React.FC = () => { {/* Boot / Startup Image Section - only when profile declares bootImage feature */} {(() => { - const profile = getSettingsProfileForModel(radioInfo?.model); + const profile = getSettingsProfileForModel(effectiveModel); return profile?.features?.includes('bootImage'); })() && ( @@ -630,7 +633,7 @@ export const SettingsTab: React.FC = () => { {/* Radio Configuration - profile-driven */} {(() => { - const profile = getSettingsProfileForModel(radioInfo?.model); + const profile = getSettingsProfileForModel(effectiveModel); if (!profile) { return radioSettings ? ( @@ -663,8 +666,8 @@ export const SettingsTab: React.FC = () => { ); })()} - {/* One Key Operation */} - {radioSettings && ( + {/* One Key Operation (DM-32 only; UV5R-Mini uses uv5rMiniSettings) */} + {radioSettings && (!radioSettings.uv5rMiniSettings || radioSettings.analogCall) && ( One Key Operation diff --git a/src/components/ui/StartupModal.tsx b/src/components/ui/StartupModal.tsx index 00c77dd..7b7b08d 100644 --- a/src/components/ui/StartupModal.tsx +++ b/src/components/ui/StartupModal.tsx @@ -1,7 +1,8 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { Button } from './Button'; import { ConfirmModal } from './ConfirmModal'; -import { DM32_MODEL_IDS } from '../../radios'; +import { getRadioPickerOptions } from '../../radios'; +import { useRadioStore } from '../../store/radioStore'; import { isWebSerialSupported, getSupportedBrowsers } from '../../utils/browserSupport'; import { downloadOfflineAsZip } from '../../utils/offlineDownload'; @@ -9,9 +10,11 @@ const OFFLINE_VERSION_URL = 'https://infamy.github.io/NeonPlug/'; interface StartupModalProps { isOpen: boolean; - onReadFromRadio: () => void; + onReadFromRadio: (transport?: 'serial' | 'ble') => void; onLoadFile: () => void; onDismiss?: () => void; + /** When set (e.g. opened from Toolbar "Change radio"), show a Cancel button to close without action. */ + onCancel?: () => void; } const OFFLINE_FALLBACK_MESSAGE = @@ -24,13 +27,35 @@ export const StartupModal: React.FC = ({ onReadFromRadio, onLoadFile, onDismiss, + onCancel, }) => { const [offlineFallbackOpen, setOfflineFallbackOpen] = useState(false); + const [transportChoiceOpen, setTransportChoiceOpen] = useState(false); + const { selectedRadioModel, setSelectedRadioModel } = useRadioStore(); + const options = useMemo(() => getRadioPickerOptions(), []); + + // Default to first radio if none selected + const effectiveSelected = selectedRadioModel ?? options[0]?.modelId ?? null; + const selectedOption = options.find(o => o.modelId === effectiveSelected); if (!isOpen) return null; const webSerialSupported = isWebSerialSupported(); const supportedBrowsers = getSupportedBrowsers(); + const showTransportChoice = selectedOption?.supportsBle === true; + + const handleReadClick = () => { + if (showTransportChoice) { + setTransportChoiceOpen(true); + } else { + onReadFromRadio(); + } + }; + + const handleTransportChoice = (transport: 'serial' | 'ble') => { + setTransportChoiceOpen(false); + onReadFromRadio(transport); + }; return (
= ({

NEONPLUG

Channel programming software

-

Supports: {DM32_MODEL_IDS.join(', ')}

-
-

- How would you like to get started? -

+

Pick a radio

+
+ {options.map((opt) => ( + + ))} +
+
{!webSerialSupported && (
@@ -68,13 +107,13 @@ export const StartupModal: React.FC = ({ )} + {onCancel && ( + + )}
+ + {transportChoiceOpen && ( +
+
+

Connect via

+
+ + +
+ +
+
+ )} + setOfflineFallbackOpen(false)} @@ -126,4 +205,3 @@ export const StartupModal: React.FC = ({
); }; - diff --git a/src/data/settingsProfiles/index.ts b/src/data/settingsProfiles/index.ts index c7258a0..0ef953a 100644 --- a/src/data/settingsProfiles/index.ts +++ b/src/data/settingsProfiles/index.ts @@ -1,13 +1,17 @@ import type { SettingsProfile } from '../../types/settingsProfile'; -import { DM32_MODEL_IDS } from '../../radios'; -import { DM32UV_SETTINGS_PROFILE } from '../../radios/dm32uv/settingsProfile'; +import { RADIO_DESCRIPTORS } from '../../radios'; -const PROFILE_REGISTRY: Record = Object.fromEntries( - DM32_MODEL_IDS.map(id => [id, DM32UV_SETTINGS_PROFILE]) -); +const PROFILE_REGISTRY: Record = {}; +for (const d of RADIO_DESCRIPTORS) { + if (d.settingsProfile) { + for (const id of d.modelIds) { + PROFILE_REGISTRY[id] = d.settingsProfile; + } + } +} /** - * Returns the settings profile for the given radio model, or null if unknown. + * Returns the settings profile for the given radio model, or null if unknown or no settings UI. */ export function getSettingsProfileForModel(model: string | null | undefined): SettingsProfile | null { if (!model || typeof model !== 'string') return null; diff --git a/src/hooks/useEffectiveRadioModel.ts b/src/hooks/useEffectiveRadioModel.ts new file mode 100644 index 0000000..751a728 --- /dev/null +++ b/src/hooks/useEffectiveRadioModel.ts @@ -0,0 +1,11 @@ +import { useRadioStore } from '../store/radioStore'; + +/** + * Returns the effective radio model for UI: device model when known (from read), + * otherwise the model selected in the pick-a-radio modal. + * Use this so tabs, settings, and Channel Wizard reflect the current/converted radio. + */ +export function useEffectiveRadioModel(): string | null { + const { radioInfo, selectedRadioModel } = useRadioStore(); + return radioInfo?.model ?? selectedRadioModel ?? null; +} diff --git a/src/hooks/useRadioConnection.ts b/src/hooks/useRadioConnection.ts index 7b24178..0e3a466 100644 --- a/src/hooks/useRadioConnection.ts +++ b/src/hooks/useRadioConnection.ts @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react'; import type { RadioProtocol } from '../types/radio'; -import { createDefaultProtocol } from '../radios'; +import { createDefaultProtocol, createProtocolForModel } from '../radios'; import { getCapabilitiesForModel } from '../radios/capabilities'; import type { Contact } from '../models/Contact'; import { useRadioStore } from '../store/radioStore'; @@ -16,6 +16,7 @@ import { useQuickContactsStore } from '../store/quickContactsStore'; import { useDMRRadioIDsStore } from '../store/dmrRadioIdsStore'; import { useCalibrationStore } from '../store/calibrationStore'; import { useRXGroupsStore } from '../store/rxGroupsStore'; +import { useEncryptionKeysStore } from '../store/encryptionKeysStore'; import type { Channel } from '../models/Channel'; import type { Zone } from '../models/Zone'; import type { ScanList } from '../models/ScanList'; @@ -50,7 +51,7 @@ export function useRadioConnection() { const [isConnecting, setIsConnecting] = useState(false); const [error, setError] = useState(null); - const { radioInfo, setConnected, setRadioInfo, setSettings, setRawRadioSettingsData, setRawContactBlockData, setRawContactBlocks, setBlockMetadata, setBlockData, setWriteBlockData, setZoneComparisonData, setBootImageRaw, setBootImageDescription, setConnectionError } = useRadioStore(); + const { selectedRadioModel, preferredTransport, radioInfo, setConnected, setRadioInfo, setSettings, setRawRadioSettingsData, setRawContactBlockData, setRawContactBlocks, setBlockMetadata, setBlockData, setWriteBlockData, setZoneComparisonData, setBootImageRaw, setBootImageDescription, setConnectionError } = useRadioStore(); const { setChannels, setRawChannelData } = useChannelsStore(); const { setZones, setRawZoneData } = useZonesStore(); const { setScanLists, setRawScanListData } = useScanListsStore(); @@ -63,6 +64,7 @@ export function useRadioConnection() { const { setRadioIds, setRawRadioIdData, setRadioIdsLoaded } = useDMRRadioIDsStore(); const { setCalibration, setCalibrationLoaded } = useCalibrationStore(); const { setGroups: setRXGroups, setRawGroupData, setGroupsLoaded } = useRXGroupsStore(); + const { clearKeys: clearEncryptionKeys } = useEncryptionKeysStore(); const readFromRadio = useCallback(async ( onProgress?: (progress: number, message: string, step?: string) => void @@ -93,6 +95,7 @@ export function useRadioConnection() { setRXGroups([]); setRawGroupData(new Map()); setGroupsLoaded(false); + clearEncryptionKeys(); setRadioSettings(null); setDigitalEmergencies([]); setDigitalEmergencyConfig(null); @@ -113,17 +116,28 @@ export function useRadioConnection() { const steps = READ_STEPS; try { - // Create protocol instance - protocol = createDefaultProtocol(); + // Create protocol for the radio selected in the pick-a-radio modal + protocol = createProtocolForModel(selectedRadioModel ?? '') ?? createDefaultProtocol(); // Set up progress callback that forwards to our callback protocol.onProgress = (progress, message) => { onProgress?.(progress, message); }; - // Step 1: Request serial port in same user gesture (so browser allows requestPort) - onProgress?.(5, 'Select serial port...', steps[0]); - await protocol.connect({ forcePortSelection: true }); + // Step 1: Request port (serial or BLE) in same user gesture + const caps = getCapabilitiesForModel(selectedRadioModel ?? null); + 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 }), + }); // Step 2: Get radio info onProgress?.(10, 'Reading radio information...', steps[2]); @@ -132,17 +146,25 @@ export function useRadioConnection() { setRadioInfo(radioInfo); setConnected(true); - // Step 4: Bulk read all required blocks upfront - // This will read all blocks and then disconnect from the radio - onProgress?.(15, 'Reading all memory blocks...', steps[3]); - await (protocol as any).bulkReadRequiredBlocks(); + // Step 4: Bulk read when capability says so (e.g. DM-32UV); otherwise protocol reads on demand + if (caps?.supportsBulkRead && typeof (protocol as any).bulkReadRequiredBlocks === 'function') { + onProgress?.(15, 'Reading all memory blocks...', steps[3]); + await (protocol as any).bulkReadRequiredBlocks(); + } - // Connection is now closed - all data is in cache - // All parsing happens from cached blocks, no connection needed - // Step 5: Process cached blocks to extract data (no connection needed) - onProgress?.(20, 'Parsing channels from cache...', steps[4]); + // Step 5: Parse channels (from cache after bulk read, or over connection) + onProgress?.(20, 'Parsing channels...', steps[4]); const channels = await protocol.readChannels(); setChannels(channels); + // Enrich radioInfo with firmware from cached image (UV5R-Mini; getRadioInfo may have missed it) + if (typeof (protocol as any).getFirmwareFromCache === 'function') { + const fw = (protocol as any).getFirmwareFromCache(); + if (fw) { + const store = useRadioStore.getState(); + const current = store.radioInfo; + if (current) setRadioInfo({ ...current, firmware: fw }); + } + } // Store raw channel data for debug export if ((protocol as any).rawChannelData) { setRawChannelData((protocol as any).rawChannelData); @@ -330,8 +352,8 @@ export function useRadioConnection() { // Retry the entire read operation with forced port selection try { onProgress?.(5, 'Retrying with port selection...', steps[0]); - // Create a new protocol instance to ensure clean state - protocol = createDefaultProtocol(); + // Create a new protocol instance to ensure clean state (same radio as initial read) + protocol = createProtocolForModel(selectedRadioModel ?? '') ?? createDefaultProtocol(); protocol.onProgress = (progress, message) => { onProgress?.(progress, message); }; @@ -346,8 +368,11 @@ export function useRadioConnection() { setRadioInfo(radioInfo); setConnected(true); - onProgress?.(15, 'Reading all memory blocks...', steps[3]); - await (protocol as any).bulkReadRequiredBlocks(); + const retryCaps = getCapabilitiesForModel(selectedRadioModel ?? null); + if (retryCaps?.supportsBulkRead && typeof (protocol as any).bulkReadRequiredBlocks === 'function') { + onProgress?.(15, 'Reading all memory blocks...', steps[3]); + await (protocol as any).bulkReadRequiredBlocks(); + } onProgress?.(20, 'Parsing channels from cache...', steps[4]); const channels = await protocol.readChannels(); @@ -522,7 +547,7 @@ export function useRadioConnection() { setIsConnecting(false); } } - }, [setConnected, setRadioInfo, setSettings, setRawRadioSettingsData, setChannels, setZones, setScanLists, setContacts, setContactsLoaded, setRawChannelData, setRawZoneData, setRawScanListData, setBlockMetadata, setBlockData, setRadioSettings, setDigitalEmergencies, setDigitalEmergencyConfig, setAnalogEmergencies, setMessages, setRawMessageData, setMessagesLoaded, setQuickContacts, setQuickContactsLoaded, setRadioIds, setRawRadioIdData, setRadioIdsLoaded, setCalibration, setCalibrationLoaded, setRXGroups, setRawGroupData, setGroupsLoaded, setConnectionError]); + }, [selectedRadioModel, preferredTransport, setConnected, setRadioInfo, setSettings, setRawRadioSettingsData, setChannels, setZones, setScanLists, setContacts, setContactsLoaded, setRawChannelData, setRawZoneData, setRawScanListData, setBlockMetadata, setBlockData, setRadioSettings, setDigitalEmergencies, setDigitalEmergencyConfig, setAnalogEmergencies, setMessages, setRawMessageData, setMessagesLoaded, setQuickContacts, setQuickContactsLoaded, setRadioIds, setRawRadioIdData, setRadioIdsLoaded, setCalibration, setCalibrationLoaded, setRXGroups, setRawGroupData, setGroupsLoaded, setConnectionError]); const readContacts = useCallback(async ( onProgress?: (progress: number, message: string) => void @@ -533,8 +558,8 @@ export function useRadioConnection() { let protocol: RadioProtocol | null = null; try { - // Create protocol instance - protocol = createDefaultProtocol(); + // Use protocol for connected radio (write/reconnect path) + protocol = createProtocolForModel(radioInfo?.model ?? '') ?? createDefaultProtocol(); // Set up progress callback protocol.onProgress = (progress, message) => { @@ -592,7 +617,7 @@ export function useRadioConnection() { setError(null); let protocol: RadioProtocol | null = null; try { - protocol = createDefaultProtocol(); + protocol = createProtocolForModel(radioInfo?.model ?? '') ?? createDefaultProtocol(); protocol.onProgress = (progress, message) => { onProgress?.(progress, message); }; @@ -635,7 +660,7 @@ export function useRadioConnection() { setError(null); let protocol: RadioProtocol | null = null; try { - protocol = createDefaultProtocol(); + protocol = createProtocolForModel(radioInfo?.model ?? '') ?? createDefaultProtocol(); protocol.onProgress = (progress, message) => { onProgress?.(progress, message); }; @@ -680,8 +705,8 @@ export function useRadioConnection() { let protocol: RadioProtocol | null = null; try { - // Create protocol instance - protocol = createDefaultProtocol(); + // Use protocol for connected radio (write path) + protocol = createProtocolForModel(radioInfo?.model ?? '') ?? createDefaultProtocol(); // Set up progress callback protocol.onProgress = (progress, message) => { @@ -744,8 +769,9 @@ export function useRadioConnection() { document.addEventListener('visibilitychange', onVisibilityChange); try { - // Filter channels to only include those with valid frequencies - const bandLimits = getCapabilitiesForModel(radioInfo?.model)?.bandLimits; + // Filter channels to only include those with valid frequencies (use effective model for capabilities) + const effectiveModel = radioInfo?.model ?? selectedRadioModel ?? null; + const bandLimits = getCapabilitiesForModel(effectiveModel)?.bandLimits; const validChannels = channels.filter(ch => isValidChannelFrequency(ch, bandLimits)); const filteredCount = channels.length - validChannels.length; @@ -774,22 +800,21 @@ export function useRadioConnection() { channels: scanList.channels.filter(chNum => validChannelNumbers.has(chNum)) })).filter(scanList => scanList.channels.length > 0); // Remove empty scan lists - // Create protocol instance - protocol = createDefaultProtocol(); + // Use protocol for connected radio (write path) + protocol = createProtocolForModel(radioInfo?.model ?? '') ?? createDefaultProtocol(); - // Restore cache from store if available (from previous read operation) - // Read directly from store state to avoid hook reactivity issues + // Restore cache from store if available (DM-32 bulk read path) const storeState = useRadioStore.getState(); const storeBlockData = storeState.blockData; const storeBlockMetadata = storeState.blockMetadata; - - if (storeBlockData && storeBlockData.size > 0 && storeBlockMetadata && storeBlockMetadata.size > 0) { - // Create new Maps to ensure we have proper copies - const dataCopy = new Map(storeBlockData); - const metadataCopy = new Map(storeBlockMetadata); - (protocol as any).restoreCacheFromStore(dataCopy, metadataCopy); - } else { - console.warn('[Connection] Store cache is empty - will need to read all blocks from radio'); + if (typeof (protocol as any).restoreCacheFromStore === 'function') { + 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); + } else { + console.warn('[Connection] Store cache is empty - will need to read all blocks from radio'); + } } // Set up progress callback that forwards to our callback @@ -811,35 +836,48 @@ export function useRadioConnection() { setRadioInfo(connectedRadioInfo); setConnected(true); - // Step 4: Write channels, zones, and scan lists (using filtered data) - onProgress?.(20, 'Writing channels, zones, and scan lists to radio...', steps[4]); - await (protocol as any).writeAllData(validChannels, filteredZones, filteredScanLists); - - // Step 5: Write Talk Groups if they have been loaded - const quickContactsStore = useQuickContactsStore.getState(); - const quickContacts = quickContactsStore.contacts; - if (quickContacts && quickContacts.length > 0) { - onProgress?.(90, `Writing ${quickContacts.length} talk group(s) to radio...`, steps[4]); - await (protocol as any).writeQuickContacts(quickContacts); + // Step 4: Write channels (and zones/scan lists for DM-32; UV5R-Mini uses writeChannels only) + if (typeof (protocol as any).writeAllData === 'function') { + 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') { + onProgress?.(20, 'Writing channels to radio...', steps[4]); + await protocol.writeChannels(validChannels); + } else { + throw new Error('Protocol does not support writing channels'); } - // Step 5.5: Write Quick Messages if they have been loaded - const quickMessagesStore = useQuickMessagesStore.getState(); - const quickMessages = quickMessagesStore.messages; - if (quickMessages && quickMessages.length > 0) { - onProgress?.(92, `Writing ${quickMessages.length} quick message(s) to radio...`, steps[4]); - await (protocol as any).writeQuickMessages(quickMessages); + // 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 (quickContacts && quickContacts.length > 0) { + onProgress?.(90, `Writing ${quickContacts.length} talk group(s) to radio...`, steps[4]); + await (protocol as any).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; + if (quickMessages && quickMessages.length > 0) { + onProgress?.(92, `Writing ${quickMessages.length} quick message(s) to radio...`, steps[4]); + await (protocol as any).writeQuickMessages(quickMessages); + } } - // Step 5.6: Write RX Groups if they have been loaded - 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); + // Step 5.6: Write RX Groups if they have been loaded (DM-32 only) + if (typeof (protocol as any).writeRXGroups === 'function') { + 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); + } } - // Step 5.7: Write DMR Radio IDs if they have been loaded + // 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) { @@ -847,33 +885,49 @@ export function useRadioConnection() { await protocol.writeDMRRadioIDs(dmrRadioIds); } - // Step 6: Write radio settings only if they have been modified + // Step 5.8: Write Encryption Keys if they have been loaded (DM-32 only) + if (typeof (protocol as any).writeEncryptionKeys === 'function') { + 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); + } + } + + // Step 6: Write radio settings only if they have been modified (UV5R-Mini and DM-32) const radioSettingsStore = useRadioSettingsStore.getState(); const radioSettings = radioSettingsStore.settings; const changedFields = radioSettingsStore.getChangedFields(); + const hasSettingsToWrite = radioSettings && changedFields.length > 0; - if (radioSettings && changedFields.length > 0) { + if (hasSettingsToWrite) { onProgress?.(95, `Writing ${changedFields.length} changed setting(s) to radio...`, steps[4]); await protocol.writeRadioSettings(radioSettings, { changedFields }); // Clear changes after successful write radioSettingsStore.clearChanges(); } - // Store write block data and zone comparison data for debug export - setWriteBlockData((protocol as any).writeBlockData); - setZoneComparisonData((protocol as any).zoneComparisonData); + // 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); // Step 6: Disconnect await protocol.disconnect(); + const summaryQuickContacts = useQuickContactsStore.getState().contacts; + const summaryQuickMessages = useQuickMessagesStore.getState().messages; + const summaryRxGroupsStore = useRXGroupsStore.getState(); + const summaryEncryptionKeysStore = useEncryptionKeysStore.getState(); const summary = [ validChannels.length > 0 ? `${validChannels.length} channels` : null, filteredZones.length > 0 ? `${filteredZones.length} zones` : null, filteredScanLists.length > 0 ? `${filteredScanLists.length} scan lists` : null, - quickContacts && quickContacts.length > 0 ? `${quickContacts.length} talk group(s)` : null, - quickMessages && quickMessages.length > 0 ? `${quickMessages.length} quick message(s)` : null, - rxGroups && rxGroups.length > 0 && rxGroupsStore.groupsLoaded ? `${rxGroups.length} RX group(s)` : null, - radioSettings && changedFields.length > 0 ? `${changedFields.length} setting(s)` : null, + summaryQuickContacts?.length ? `${summaryQuickContacts.length} talk group(s)` : null, + summaryQuickMessages?.length ? `${summaryQuickMessages.length} quick message(s)` : null, + summaryRxGroupsStore.groups?.length && summaryRxGroupsStore.groupsLoaded ? `${summaryRxGroupsStore.groups.length} RX group(s)` : null, + summaryEncryptionKeysStore.keys?.length && summaryEncryptionKeysStore.keysLoaded ? `${summaryEncryptionKeysStore.keys.length} encryption key(s)` : null, + hasSettingsToWrite ? `${changedFields.length} setting(s)` : null, ].filter(Boolean).join(', '); // Add warning if channels were filtered diff --git a/src/models/RadioSettings.ts b/src/models/RadioSettings.ts index 8e4c1fe..6666567 100644 --- a/src/models/RadioSettings.ts +++ b/src/models/RadioSettings.ts @@ -182,4 +182,7 @@ export interface RadioSettings { // VFO Channel Information 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; } diff --git a/src/radios/README.md b/src/radios/README.md index 8605712..a1b90b0 100644 --- a/src/radios/README.md +++ b/src/radios/README.md @@ -7,10 +7,26 @@ Each radio folder implements the `RadioProtocol` interface by reading its own me Raw layout (V-frames, blocks, linear addresses) and decoding are implementation details of each radio. The app only ever sees the standard types. +## Radio descriptor (single registration surface) + +Each radio is registered via a **descriptor** (see [types.ts](types.ts)). The descriptor holds: + +- `modelIds` — one or more model IDs (e.g. `['DM-32UV', 'DP570UV']` or `['UV5R-Mini']`) +- `label`, `icon`, `supportsBle` — for the pick-a-radio modal +- `protocolFactory` — function that returns a new `RadioProtocol` instance +- `capabilities` — limits and feature flags (maxChannels, supportsZones, supportsBulkRead, etc.); the UI and migration use these instead of importing a specific radio +- `settingsProfile` — optional; set to `null` when the radio has no settings UI + +The central [index.ts](index.ts) imports all descriptors and builds the protocol registry, picker options, and migration targets from them. [capabilities.ts](capabilities.ts) builds the capabilities registry from the same descriptors. [data/settingsProfiles/index.ts](../data/settingsProfiles/index.ts) builds the settings profile registry from the same descriptors. **You do not edit capabilities.ts or settingsProfiles/index.ts when adding a radio** — only add a descriptor and register it in the descriptor list in index.ts. + ## Adding a new radio 1. **Protocol**: Create a new folder under `radios/` and implement the `RadioProtocol` interface (see [types/radio.ts](../types/radio.ts)). Produce the standard codeplug types; raw layout stays inside the radio folder. -2. **Registry**: Register the protocol in [radios/index.ts](index.ts). If the radio has multiple model ids (e.g. marketing vs internal name), add them to a shared list and build the registry from it (see `DM32_MODEL_IDS`). -3. **Capabilities**: Add a capabilities object (parsers, limits, band limits, firmware helpers) and register it in [radios/capabilities.ts](capabilities.ts) via `getCapabilitiesForModel`. The UI uses capabilities instead of importing from a specific radio. -4. **Settings profile**: If the radio has a settings UI, add a profile and register it in [data/settingsProfiles/index.ts](../data/settingsProfiles/index.ts). The Settings tab resolves the profile by `radioInfo.model`. -5. **Extended protocol surface** (optional): The hook [useRadioConnection.ts](../hooks/useRadioConnection.ts) calls extra methods (e.g. bulk read, boot image, quick messages) via `(protocol as any)`. To support a radio that omits some of these, add optional methods or capability flags and guard calls in the hook so a new radio only implements what it supports. +2. **Capabilities**: In the same folder, add a capabilities object (see [dm32uv/capabilities.ts](dm32uv/capabilities.ts) or [uv5rmini/capabilities.ts](uv5rmini/capabilities.ts)). Set at least `maxChannels`, `supportsZones`, `supportsScanLists`, `analogOnly`; add `supportsBle`, `preferredTransport`, `supportsBulkRead`, `maxZones`, `maxScanLists` as needed. +3. **Descriptor**: In the same folder, add a descriptor file (e.g. `descriptor.ts`) that exports a `RadioDescriptor`: modelIds, label, icon, supportsBle, protocolFactory, capabilities, and optionally settingsProfile (or `null` if no settings UI). +4. **Register**: In [radios/index.ts](index.ts), import the descriptor and add it to the `RADIO_DESCRIPTORS` array. + +No edits are needed in useRadioConnection, TabNavigation, migration, or Channel Wizard for feature visibility — they all use capabilities and the effective radio model. Tabs, Settings usage stats, and conversion behavior are driven by the capabilities you set on the descriptor. + +5. **Settings profile** (optional): If the radio has a settings UI, add a profile in the radio folder and reference it from the descriptor (`settingsProfile: MyRadio_SETTINGS_PROFILE`). If the radio has no settings UI, set `settingsProfile: null` on the descriptor. +6. **Extended protocol surface** (optional): The hook [useRadioConnection.ts](../hooks/useRadioConnection.ts) calls optional methods (e.g. bulk read, boot image, quick messages) and uses capability flags (`supportsBulkRead`, `supportsBootImage`, `supportsQuickMessages`) where applicable. Implement only what the radio supports; the hook guards by capability. diff --git a/src/radios/capabilities.ts b/src/radios/capabilities.ts index 84b80f8..07bbf53 100644 --- a/src/radios/capabilities.ts +++ b/src/radios/capabilities.ts @@ -1,14 +1,16 @@ /** - * Registry of radio capabilities (parsers, limits) by model. - * UI resolves via getCapabilitiesForModel(radioInfo?.model) instead of importing from a specific radio. + * Capabilities registry built from radio descriptors. + * UI resolves via getCapabilitiesForModel(model); no per-radio imports here. */ import type { RadioCapabilities } from '../types/radioCapabilities'; -import { DM32_MODEL_IDS } from './index'; -import { DM32UV_CAPABILITIES } from './dm32uv/capabilities'; +import { RADIO_DESCRIPTORS } from './index'; -const CAPABILITIES_REGISTRY: Record = Object.fromEntries( - DM32_MODEL_IDS.map(id => [id, DM32UV_CAPABILITIES]) -); +const CAPABILITIES_REGISTRY: Record = {}; +for (const d of RADIO_DESCRIPTORS) { + for (const id of d.modelIds) { + CAPABILITIES_REGISTRY[id] = d.capabilities; + } +} export function getCapabilitiesForModel(model: string | null | undefined): RadioCapabilities | null { if (!model || typeof model !== 'string') return null; diff --git a/src/radios/dm32uv/capabilities.ts b/src/radios/dm32uv/capabilities.ts index d8cdf08..b0dcef4 100644 --- a/src/radios/dm32uv/capabilities.ts +++ b/src/radios/dm32uv/capabilities.ts @@ -32,4 +32,14 @@ export const DM32UV_CAPABILITIES: RadioCapabilities = { writeValidations: { channelsMustBeInZones: true, }, + maxChannels: 4000, + supportsVfoChannels: true, + supportsZones: true, + supportsScanLists: true, + analogOnly: false, + supportsBulkRead: true, + maxZones: LIMITS.ZONES_MAX, + maxScanLists: LIMITS.SCAN_LISTS_MAX, + supportsBootImage: true, + supportsQuickMessages: true, }; diff --git a/src/radios/dm32uv/constants.ts b/src/radios/dm32uv/constants.ts index 5b25e49..7300473 100644 --- a/src/radios/dm32uv/constants.ts +++ b/src/radios/dm32uv/constants.ts @@ -86,7 +86,7 @@ export const CONNECTION = { BAUD_RATE: 115200, INIT_DELAY: 400, // ms after port open (increased for DM32.01.01.049 and similar) CLEAR_BUFFER_DELAY: 200, // ms after clearing buffer - PSEARCH_READ_DELAY: 200, // ms after PSEARCH before reading response (radio needs time to reply) + PSEARCH_READ_DELAY: 500, // ms after PSEARCH before reading response (radio needs time to reply) REOPEN_DELAY: 400, // ms to wait after closing port before reopening (fresh handshake) BLOCK_READ_DELAY: 150, // ms between block reads (radio needs time after sending 4KB before next request) // Timeout values (in milliseconds) diff --git a/src/radios/dm32uv/descriptor.ts b/src/radios/dm32uv/descriptor.ts new file mode 100644 index 0000000..e56ffd9 --- /dev/null +++ b/src/radios/dm32uv/descriptor.ts @@ -0,0 +1,19 @@ +/** + * DM-32UV / DP570UV radio descriptor. Registered in radios/index.ts. + */ +import type { RadioDescriptor } from '../types'; +import { DM32UVProtocol } from './protocol'; +import { DM32UV_CAPABILITIES } from './capabilities'; +import { DM32UV_SETTINGS_PROFILE } from './settingsProfile'; + +export const DM32_MODEL_IDS = ['DM-32UV', 'DP570UV'] as const; + +export const DM32UV_DESCRIPTOR: RadioDescriptor = { + modelIds: DM32_MODEL_IDS, + label: 'DM-32UV', + icon: '📻', + supportsBle: false, + protocolFactory: () => new DM32UVProtocol(), + capabilities: DM32UV_CAPABILITIES, + settingsProfile: DM32UV_SETTINGS_PROFILE, +}; diff --git a/src/radios/dm32uv/memory.ts b/src/radios/dm32uv/memory.ts index b80be45..805fc28 100644 --- a/src/radios/dm32uv/memory.ts +++ b/src/radios/dm32uv/memory.ts @@ -240,4 +240,4 @@ export function storeRawData { + requireConnection(this.connection, this.radioInfo); + + // Discover blocks if not already discovered + if (this.discoveredBlocks.length === 0) { + if (!this.radioInfo) { + throw new Error('Radio info not available. Connect and read radio info first.'); + } + const blocks = await discoverMemoryBlocks( + this.connection!, + this.radioInfo!.memoryLayout!.configStart, + this.radioInfo!.memoryLayout!.configEnd, + (current, total) => { + const progress = Math.floor((current / total) * 100); + this.onProgress?.(progress, `Discovering blocks ${current}/${total}...`); + } + ); + this.discoveredBlocks = blocks; + } + + requireDiscoveredBlocks(this.discoveredBlocks); + + // Find block 0x10 (same block as digital emergencies) + const keyBlock = this.discoveredBlocks.find(b => b.metadata === METADATA.DIGITAL_EMERGENCY); + + if (!keyBlock) { + throw new Error('Encryption Keys block (metadata 0x10) not found'); + } + + this.onProgress?.(0, 'Writing Encryption Keys...'); + + // Start from cached block data to preserve other regions (digital emergencies, etc.) + const existingBlockData = this.getCachedBlockByAddress(keyBlock.address)?.data; + const blockData = new Uint8Array(0x1000); + if (existingBlockData && existingBlockData.length >= 0x1000) { + blockData.set(existingBlockData.slice(0, 0x1000)); + } else { + blockData.fill(0xFF); + } + + // Encode each encryption key into the block + for (const key of keys) { + encodeEncryptionKey(key, blockData); + } + + // Preserve metadata byte + blockData[0xFFF] = METADATA.DIGITAL_EMERGENCY; + + // Write the entire block + await this.connection!.writeMemory(keyBlock.address, blockData, METADATA.DIGITAL_EMERGENCY); + this.blockData.set(keyBlock.address, blockData); + + this.onProgress?.(100, 'Encryption Keys written'); + } + /** * Parse Analog Emergency Systems from cached blocks * Blocks must be read first via bulkReadRequiredBlocks() diff --git a/src/radios/dm32uv/structures.ts b/src/radios/dm32uv/structures.ts index 5674ffa..6e3ebe8 100644 --- a/src/radios/dm32uv/structures.ts +++ b/src/radios/dm32uv/structures.ts @@ -2448,10 +2448,15 @@ export function parseDigitalEmergencies(data: Uint8Array): { systems: DigitalEme * Encode Digital Emergency Systems to metadata 0x10 block format * Entry structure: 20 bytes (0x14) starting at offset 0x000 * Entry Calculation: entry_base = 0x000 + entry_num * 0x14 + * Preserves existingBlockData (e.g. encryption keys at 0x300) when provided. */ -export function encodeDigitalEmergencies(systems: DigitalEmergency[], _config: DigitalEmergencyConfig): Uint8Array { +export function encodeDigitalEmergencies(systems: DigitalEmergency[], _config: DigitalEmergencyConfig, existingBlockData?: Uint8Array): Uint8Array { const data = new Uint8Array(0x1000); // 4KB block - data.fill(0xFF); + if (existingBlockData && existingBlockData.length >= 0x1000) { + data.set(existingBlockData.slice(0, 0x1000)); + } else { + data.fill(0xFF); + } const initialOffset = 0x000; const entrySize = 0x14; // 20 bytes per entry diff --git a/src/radios/index.ts b/src/radios/index.ts index d800572..fc3053e 100644 --- a/src/radios/index.ts +++ b/src/radios/index.ts @@ -1,23 +1,56 @@ /** - * Protocol registry: maps model ids to protocol factories. - * Use createDefaultProtocol() or createProtocolForModel(model) so app code - * does not import a specific radio implementation. + * Protocol registry and picker options built from radio descriptors. + * Add a new radio by adding its descriptor to RADIO_DESCRIPTORS. */ import type { RadioProtocol } from '../types/radio'; -import { DM32UVProtocol } from './dm32uv/protocol'; +import type { RadioDescriptor } from './types'; +import { DM32UV_DESCRIPTOR } from './dm32uv/descriptor'; +import { UV5RMINI_DESCRIPTOR } from './uv5rmini/descriptor'; export type ProtocolFactory = () => RadioProtocol; -/** Same radio: DM-32UV (marketing), DP570UV (internal). */ -export const DM32_MODEL_IDS = ['DM-32UV', 'DP570UV'] as const; +/** All registered radios. Add new radios here. */ +export const RADIO_DESCRIPTORS: readonly RadioDescriptor[] = [ + DM32UV_DESCRIPTOR, + UV5RMINI_DESCRIPTOR, +]; -const PROTOCOL_REGISTRY: Record = Object.fromEntries( - DM32_MODEL_IDS.map(id => [id, () => new DM32UVProtocol()]) -); +/** Backward compatibility: same radio, multiple model IDs. */ +export const DM32_MODEL_IDS = DM32UV_DESCRIPTOR.modelIds as readonly ['DM-32UV', 'DP570UV']; + +/** Backward compatibility: UV5R-Mini model ID. */ +export { UV5RMINI_MODEL_ID } from './uv5rmini/descriptor'; + +const PROTOCOL_REGISTRY: Record = {}; +for (const d of RADIO_DESCRIPTORS) { + for (const id of d.modelIds) { + PROTOCOL_REGISTRY[id] = d.protocolFactory; + } +} + +/** Options for the "Pick a radio" modal: one entry per descriptor. */ +export interface RadioPickerOption { + modelId: string; + label: string; + icon: string; + supportsBle: boolean; +} + +const RADIO_PICKER_OPTIONS: RadioPickerOption[] = RADIO_DESCRIPTORS.map((d) => ({ + modelId: d.modelIds[0], + label: d.label, + icon: d.icon, + supportsBle: d.supportsBle, +})); + +export function getRadioPickerOptions(): RadioPickerOption[] { + return [...RADIO_PICKER_OPTIONS]; +} + +export function getMigrationTargetModels(): string[] { + return RADIO_PICKER_OPTIONS.map((o) => o.modelId); +} -/** - * Returns a new protocol instance for the given radio model, or null if unknown. - */ export function createProtocolForModel(model: string): RadioProtocol | null { const trimmed = model?.trim(); if (!trimmed) return null; @@ -25,10 +58,8 @@ export function createProtocolForModel(model: string): RadioProtocol | null { return factory ? factory() : null; } -/** - * Returns the default protocol instance (connect first, detect model later). - * Currently returns DM-32UV; when more radios exist, could be user-selected or first registered. - */ +/** Default protocol when no model is selected (first registered radio). */ export function createDefaultProtocol(): RadioProtocol { - return createProtocolForModel(DM32_MODEL_IDS[0]) ?? new DM32UVProtocol(); + const firstModel = RADIO_DESCRIPTORS[0]?.modelIds[0]; + return createProtocolForModel(firstModel ?? '') ?? DM32UV_DESCRIPTOR.protocolFactory(); } diff --git a/src/radios/types.ts b/src/radios/types.ts new file mode 100644 index 0000000..4e17091 --- /dev/null +++ b/src/radios/types.ts @@ -0,0 +1,25 @@ +/** + * Types for the central radio registry. Each radio folder exports a descriptor + * that is registered in radios/index.ts; protocol, picker, capabilities, and + * settings profile are all derived from descriptors. + */ +import type { RadioProtocol } from '../types/radio'; +import type { RadioCapabilities } from '../types/radioCapabilities'; +import type { SettingsProfile } from '../types/settingsProfile'; + +export interface RadioDescriptor { + /** One or more model IDs (e.g. ['DM-32UV', 'DP570UV'] or ['UV5R-Mini']). */ + modelIds: readonly string[]; + /** Display label in pick-a-radio modal. */ + label: string; + /** Icon (emoji or character) for picker. */ + icon: string; + /** Whether the radio supports BLE in addition to serial. */ + supportsBle: boolean; + /** Factory that returns a new protocol instance. */ + protocolFactory: () => RadioProtocol; + /** Capabilities for this radio (limits, feature flags, parsers). */ + capabilities: RadioCapabilities; + /** Settings profile for the Settings tab; null when radio has no settings UI. */ + settingsProfile?: SettingsProfile | null; +} diff --git a/src/radios/uv5rmini/baofengProtocol.ts b/src/radios/uv5rmini/baofengProtocol.ts new file mode 100644 index 0000000..5153cf5 --- /dev/null +++ b/src/radios/uv5rmini/baofengProtocol.ts @@ -0,0 +1,110 @@ +/** + * Baofeng UV5R-Mini read/write frames and decrypt (from chirp-baofeng-uv5rmini.js). + */ + +import { + BAOFENG_BLOCK_SIZE, + BAOFENG_READ_RESPONSE_LEN, +} from './constants'; + +/** Magics for read mode (3rd magic last byte 0x00). */ +export const BAOFENG_MAGICS_READ: Array<{ send: Uint8Array; responseLen: number }> = [ + { send: new Uint8Array([0x46]), responseLen: 16 }, + { send: new Uint8Array([0x4d]), responseLen: 15 }, + { + send: new Uint8Array([ + 0x53, 0x45, 0x4e, 0x44, 0x21, 0x05, 0x0d, 0x01, 0x01, 0x01, 0x04, 0x11, + 0x08, 0x05, 0x0d, 0x0d, 0x01, 0x11, 0x0f, 0x09, 0x12, 0x09, 0x10, 0x04, 0x00, + ]), + responseLen: 1, + }, +]; + +/** Magics for write/upload mode (3rd magic last byte 0x01). */ +export const BAOFENG_MAGICS_UPLOAD: Array<{ send: Uint8Array; responseLen: number }> = [ + { send: new Uint8Array([0x46]), responseLen: 16 }, + { send: new Uint8Array([0x4d]), responseLen: 15 }, + { + send: new Uint8Array([ + 0x53, 0x45, 0x4e, 0x44, 0x21, 0x05, 0x0d, 0x01, 0x01, 0x01, 0x04, 0x11, + 0x08, 0x05, 0x0d, 0x0d, 0x01, 0x11, 0x0f, 0x09, 0x12, 0x09, 0x10, 0x04, 0x01, + ]), + responseLen: 1, + }, +]; + +const TBL_ENCRYPT = [ + [0x42, 0x48, 0x54, 0x20], + [0x43, 0x4f, 0x20, 0x37], + [0x41, 0x20, 0x45, 0x53], + [0x20, 0x45, 0x49, 0x59], + [0x4d, 0x20, 0x50, 0x51], + [0x58, 0x4e, 0x20, 0x59], + [0x52, 0x56, 0x42, 0x20], + [0x20, 0x48, 0x51, 0x50], + [0x57, 0x20, 0x52, 0x43], + [0x4d, 0x53, 0x20, 0x4e], + [0x20, 0x53, 0x41, 0x54], + [0x4b, 0x20, 0x44, 0x48], + [0x5a, 0x4f, 0x20, 0x52], + [0x43, 0x20, 0x53, 0x4c], + [0x36, 0x52, 0x42, 0x20], + [0x20, 0x4a, 0x43, 0x47], + [0x50, 0x4e, 0x20, 0x56], + [0x4a, 0x20, 0x50, 0x4b], + [0x45, 0x4b, 0x20, 0x4c], + [0x49, 0x20, 0x4c, 0x5a], +]; + +/** Symmetric encrypt/decrypt (encrsym 1 = "CO 7"). */ +export function baofengDecrypt(symbolIndex: number, buffer: Uint8Array): Uint8Array { + const sym = TBL_ENCRYPT[symbolIndex] ?? TBL_ENCRYPT[0]; + const out = new Uint8Array(buffer.length); + for (let i = 0; i < buffer.length; i++) { + const b = buffer[i]; + const s = sym[i % 4]; + const xor = + s !== 32 && b !== 0 && b !== 255 && b !== s && b !== (s ^ 255); + out[i] = xor ? b ^ s : b; + } + return out; +} + +/** Build read frame: 0x52 + addr (2 BE) + length (1). */ +export function buildBaofengReadFrame(addr: number, length: number): Uint8Array { + const frame = new Uint8Array(4); + frame[0] = 0x52; + frame[1] = (addr >>> 8) & 0xff; + frame[2] = addr & 0xff; + frame[3] = length & 0xff; + return frame; +} + +/** Build write frame: 0x57 + addr (2 BE) + 0x40 + encrypted 64-byte block. */ +export function buildBaofengWriteFrame( + addr: number, + block: Uint8Array, + encrsym: number = 1 +): Uint8Array { + if (block.length !== BAOFENG_BLOCK_SIZE) throw new Error('Block must be 64 bytes'); + const payload = baofengDecrypt(encrsym, block); + const frame = new Uint8Array(4 + BAOFENG_BLOCK_SIZE); + frame[0] = 0x57; + frame[1] = (addr >>> 8) & 0xff; + frame[2] = addr & 0xff; + frame[3] = 0x40; + frame.set(payload, 4); + return frame; +} + +/** Parse read response (68 bytes): skip 4-byte header, return decrypted 64-byte block. */ +export function parseBaofengReadResponse( + raw68: Uint8Array, + useDecrypt: boolean = true, + encrsym: number = 1 +): Uint8Array { + if (raw68.length < BAOFENG_READ_RESPONSE_LEN) + throw new Error('Baofeng read response too short'); + const payload = raw68.subarray(4, 4 + BAOFENG_BLOCK_SIZE); + return useDecrypt ? baofengDecrypt(encrsym, payload) : payload.slice(0); +} diff --git a/src/radios/uv5rmini/bleConnection.ts b/src/radios/uv5rmini/bleConnection.ts new file mode 100644 index 0000000..48380c4 --- /dev/null +++ b/src/radios/uv5rmini/bleConnection.ts @@ -0,0 +1,219 @@ +/** + * UV5R-Mini BLE transport (HM-10 style: FFE0/FFE1). + * Chunked writes 20 bytes with 30ms delay (CHIRP #12251). + */ + +import { + BAOFENG_IDENT, + BAOFENG_ACK, + BAOFENG_BLOCK_SIZE, + BAOFENG_READ_RESPONSE_LEN, +} from './constants'; +import { + BAOFENG_MAGICS_READ, + BAOFENG_MAGICS_UPLOAD, + buildBaofengReadFrame, + buildBaofengWriteFrame, + parseBaofengReadResponse, +} from './baofengProtocol'; + +const FFE0_SERVICE = '0000ffe0-0000-1000-8000-00805f9b34fb'; +const FFE1_CHAR = '0000ffe1-0000-1000-8000-00805f9b34fb'; + +const CHUNK_SIZE = 20; +const CHUNK_DELAY_MS = 30; +const READ_TIMEOUT_MS = 6000; +const WRITE_ACK_TIMEOUT_MS = 400; + +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +interface BleGattServer { + connect(): Promise; + connected: boolean; + disconnect(): void; + getPrimaryService(uuid: string): Promise<{ getCharacteristic(uuid: string): Promise }>; +} + +interface BleChar { + startNotifications(): Promise; + addEventListener(type: string, listener: (ev: { target: { value: DataView } }) => void): void; + writeValue(value: ArrayBuffer | ArrayBufferView): Promise; +} + +export interface BluetoothDevice { + gatt: BleGattServer | null; +} + +export async function requestUV5RMiniBleDevice(): Promise { + if (!window.isSecureContext) { + throw new Error('Web Bluetooth requires HTTPS or localhost.'); + } + const nav = navigator as Navigator & { bluetooth?: { requestDevice(opts: unknown): Promise } }; + if (!nav.bluetooth) { + throw new Error('Web Bluetooth not supported. Use Chrome (desktop or Android).'); + } + const device = await nav.bluetooth.requestDevice({ + filters: [{ name: 'walkie-talkie' }], + optionalServices: [FFE0_SERVICE], + }); + return device; +} + +export async function connectUV5RMiniBle( + device: BluetoothDevice +): Promise<{ server: BleGattServer; char: BleChar }> { + const server = await device.gatt!.connect(); + const service = await server.getPrimaryService(FFE0_SERVICE); + const char = await service.getCharacteristic(FFE1_CHAR); + return { server, char }; +} + +/** + * BLE connection for UV5R-Mini: same handshake and read/write as serial, over FFE1. + */ +export class UV5RMiniBleConnection { + private char: BleChar | null = null; + private server: BleGattServer | null = null; + private rxBuffer = new Uint8Array(0); + private resolveWhenByte: { byte: number; resolve: (v: boolean) => void; t: ReturnType } | null = null; + + async connect(device: BluetoothDevice): Promise { + const { server, char } = await connectUV5RMiniBle(device); + this.server = server; + this.char = char; + this.rxBuffer = new Uint8Array(0); + await char.startNotifications(); + char.addEventListener('characteristicvaluechanged', (ev: { target: { value: DataView } }) => { + const value = ev.target.value; + const arr = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + this.appendRx(arr); + }); + await delay(200); + this.clearBuffer(); + + await this.send(BAOFENG_IDENT); + await this.waitForByte(BAOFENG_ACK, 8000); + for (const { send, responseLen } of BAOFENG_MAGICS_READ) { + this.clearBuffer(); + await this.send(send); + await this.readBytes(responseLen, 4000); + } + } + + async disconnect(): Promise { + if (this.server?.connected) { + this.server.disconnect(); + } + this.char = null; + this.server = null; + } + + async readBlock(addr: number): Promise { + const frame = buildBaofengReadFrame(addr, BAOFENG_BLOCK_SIZE); + await this.send(frame); + const raw = await this.waitForReadResponse(READ_TIMEOUT_MS); + return parseBaofengReadResponse(raw); + } + + async writeBlock(addr: number, block: Uint8Array): Promise { + if (block.length !== BAOFENG_BLOCK_SIZE) throw new Error('Block must be 64 bytes'); + this.clearBuffer(); + const frame = buildBaofengWriteFrame(addr, block); + await this.send(frame); + await this.waitForByte(BAOFENG_ACK, WRITE_ACK_TIMEOUT_MS); + } + + async handshakeUpload(): Promise { + this.clearBuffer(); + await this.send(BAOFENG_IDENT); + await this.waitForByte(BAOFENG_ACK, 8000); + for (const { send, responseLen } of BAOFENG_MAGICS_UPLOAD) { + this.clearBuffer(); + await this.send(send); + await this.readBytes(responseLen, 4000); + } + } + + private clearBuffer(): void { + this.rxBuffer = new Uint8Array(0); + } + + private appendRx(arr: Uint8Array): void { + const newLen = this.rxBuffer.length + arr.length; + const next = new Uint8Array(newLen); + next.set(this.rxBuffer); + next.set(arr, this.rxBuffer.length); + this.rxBuffer = next; + if (this.resolveWhenByte && this.rxBuffer.length > 0) { + for (let i = 0; i < this.rxBuffer.length; i++) { + if (this.rxBuffer[i] === this.resolveWhenByte.byte) { + const r = this.resolveWhenByte; + this.resolveWhenByte = null; + clearTimeout(r.t); + this.rxBuffer = this.rxBuffer.length > i + 1 ? this.rxBuffer.subarray(i + 1) : new Uint8Array(0); + r.resolve(true); + return; + } + } + } + } + + private async send(data: Uint8Array): Promise { + if (!this.char) throw new Error('Not connected'); + for (let i = 0; i < data.length; i += CHUNK_SIZE) { + const slice = data.subarray(i, Math.min(i + CHUNK_SIZE, data.length)); + await this.char.writeValue(new Uint8Array(slice)); + if (i + slice.length < data.length) await delay(CHUNK_DELAY_MS); + } + } + + private waitForByte(byte: number, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + for (let i = 0; i < this.rxBuffer.length; i++) { + if (this.rxBuffer[i] === byte) { + this.rxBuffer = this.rxBuffer.length > i + 1 ? this.rxBuffer.subarray(i + 1) : new Uint8Array(0); + resolve(); + return; + } + } + const t = setTimeout(() => { + if (this.resolveWhenByte) { + this.resolveWhenByte = null; + reject(new Error(`Timeout waiting for byte 0x${byte.toString(16)}`)); + } + }, timeoutMs); + this.resolveWhenByte = { byte, resolve: () => { clearTimeout(t); resolve(); }, t }; + }); + } + + private async readBytes(n: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (this.rxBuffer.length < n) { + if (Date.now() > deadline) { + throw new Error(`Timeout waiting for ${n} bytes (got ${this.rxBuffer.length})`); + } + await delay(30); + } + const out = this.rxBuffer.slice(0, n); + this.rxBuffer = this.rxBuffer.length > n ? this.rxBuffer.subarray(n) : new Uint8Array(0); + return out; + } + + private async waitForReadResponse(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + while (this.rxBuffer.length > 0 && this.rxBuffer[0] !== 0x52) { + this.rxBuffer = this.rxBuffer.subarray(1); + } + if (this.rxBuffer.length >= BAOFENG_READ_RESPONSE_LEN) { + const out = this.rxBuffer.slice(0, BAOFENG_READ_RESPONSE_LEN); + this.rxBuffer = this.rxBuffer.length > BAOFENG_READ_RESPONSE_LEN ? this.rxBuffer.subarray(BAOFENG_READ_RESPONSE_LEN) : new Uint8Array(0); + return out; + } + await delay(30); + } + throw new Error(`Timeout waiting for read response. Have ${this.rxBuffer.length} bytes.`); + } +} diff --git a/src/radios/uv5rmini/capabilities.ts b/src/radios/uv5rmini/capabilities.ts new file mode 100644 index 0000000..737daff --- /dev/null +++ b/src/radios/uv5rmini/capabilities.ts @@ -0,0 +1,21 @@ +/** + * UV5R-Mini capabilities: analog-only, 999 channels, no zones/scan lists. + */ + +import type { RadioCapabilities } from '../../types/radioCapabilities'; +import { DEFAULT_BAND_LIMITS } from '../../types/radioCapabilities'; + +export const UV5RMINI_CAPABILITIES: RadioCapabilities = { + bandLimits: DEFAULT_BAND_LIMITS, + writeValidations: { + channelsMustBeInZones: false, + }, + maxChannels: 999, + supportsZones: false, + supportsScanLists: false, + supportsContacts: false, + analogOnly: true, + supportsBle: true, + preferredTransport: 'serial', + supportsBulkRead: false, +}; diff --git a/src/radios/uv5rmini/channelFormat.ts b/src/radios/uv5rmini/channelFormat.ts new file mode 100644 index 0000000..0ef2fd8 --- /dev/null +++ b/src/radios/uv5rmini/channelFormat.ts @@ -0,0 +1,174 @@ +/** + * UV5R-Mini channel format: 32 bytes per channel, BCD freq, tone encode/decode. + * Ported from chirp-baofeng-uv5rmini.js. + */ + +import { + BAOFENG_CHANNEL_COUNT, + BAOFENG_CHANNEL_SIZE, +} from './constants'; + +const DTCS_CODES = Object.freeze( + [ + 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, 645, + ].sort((a, b) => a - b) +); + +export function decodeBcdFreq(bytes: Uint8Array): number { + const digits: number[] = []; + for (let i = 0; i < 4; i++) { + digits.push(bytes[i] & 0x0f, (bytes[i] >> 4) & 0x0f); + } + let val = 0; + let mult = 1; + for (let i = 0; i < 8; i++) { + val += digits[i] * mult; + mult *= 10; + } + return val * 10; // tens of Hz -> Hz +} + +export function encodeBcdFreq(hz: number): Uint8Array { + let val = Math.floor(hz / 10); + const digits: number[] = []; + for (let i = 0; i < 8; i++) { + digits.push(val % 10); + val = Math.floor(val / 10); + } + const out = new Uint8Array(4); + for (let i = 0; i < 4; i++) { + out[i] = (digits[i * 2 + 1] << 4) | digits[i * 2]; + } + return out; +} + +export function decodeTone(bytes: Uint8Array): { mode: string; str: string; value?: number } { + const val = bytes[0] | (bytes[1] << 8); + if (val === 0 || val === 0xffff) return { mode: '', str: '—' }; + if (val >= 0x258) return { mode: 'Tone', str: (val / 10).toFixed(1), value: val / 10 }; + const idx = val > 0x69 ? val - 0x6a : val - 1; + const code = DTCS_CODES[idx]; + return { mode: 'DTCS', str: code != null ? String(code) : String(val), value: code }; +} + +export function encodeTone(str: string): Uint8Array { + if (!str || str === '—' || str.trim() === '') return new Uint8Array([0, 0]); + const s = str.trim(); + const asNum = parseFloat(s); + if ( + s.includes('.') && + !Number.isNaN(asNum) && + asNum >= 0.25 && + asNum < 6553.6 + ) { + const val = Math.round(asNum * 10) >>> 0; + if (val <= 0xffff) return new Uint8Array([val & 0xff, (val >> 8) & 0xff]); + } + const code = parseInt(s, 10); + if (!Number.isNaN(code)) { + const idx = DTCS_CODES.indexOf(code); + if (idx >= 0) + return new Uint8Array([(idx + 1) & 0xff, ((idx + 1) >> 8) & 0xff]); + } + return new Uint8Array([0, 0]); +} + +export interface Uv5rMiniChannelRaw { + num: number; + empty: boolean; + rxFreqHz: number; + txFreqHz: number; + duplex: string; + rxtone: string; + txtone: string; + power: string; + mode: string; + name: string; + rawBytes: Uint8Array; +} + +/** Parse channel list from cloned image (first BAOFENG_CHANNEL_COUNT * 32 bytes). */ +export function parseChannelsFromImage(image: Uint8Array): Uv5rMiniChannelRaw[] { + const channels: Uv5rMiniChannelRaw[] = []; + for (let i = 0; i < BAOFENG_CHANNEL_COUNT; i++) { + const offset = i * BAOFENG_CHANNEL_SIZE; + if (offset + BAOFENG_CHANNEL_SIZE > image.length) break; + const raw = image.subarray(offset, offset + BAOFENG_CHANNEL_SIZE); + const empty = raw[0] === 0xff; + const rxFreqHz = empty ? 0 : decodeBcdFreq(raw.subarray(0, 4)); + let txFreqHz = 0; + const txAllFF = + raw[4] === 0xff && raw[5] === 0xff && raw[6] === 0xff && raw[7] === 0xff; + const txAllZero = raw[4] === 0 && raw[5] === 0 && raw[6] === 0 && raw[7] === 0; + const txFilled = !empty && !txAllFF && !txAllZero; + if (txFilled) txFreqHz = decodeBcdFreq(raw.subarray(4, 8)); + const rxtone = decodeTone(raw.subarray(8, 10)); + const txtone = decodeTone(raw.subarray(10, 12)); + let name = ''; + for (let j = 20; j < 32; j++) { + const c = raw[j]; + if (c === 0xff || c === 0x00) break; + name += String.fromCharCode(c < 32 ? 32 : c); + } + name = name.replace(/\s+$/, ''); + // UV5R-Mini uses inverted bit: 1 = Narrow (NFM), 0 = Wide (FM) + const wideBit = (raw[15] >> 6) & 1; + const lowpower = raw[14] & 0x03; + channels.push({ + num: i + 1, + empty, + rxFreqHz, + txFreqHz, + duplex: empty ? '' : !txFilled ? 'off' : rxFreqHz === txFreqHz ? '' : txFreqHz > rxFreqHz ? '+' : '-', + rxtone: rxtone.str, + txtone: txtone.str, + power: lowpower === 0 ? 'High' : 'Low', + mode: wideBit ? 'NFM' : 'FM', + name: name || '—', + rawBytes: raw.slice(0, BAOFENG_CHANNEL_SIZE), + }); + } + return channels; +} + +/** Write one raw channel into image at channelIndex (0-based). */ +export function writeChannelToImage( + image: Uint8Array, + channelIndex: number, + raw: Uv5rMiniChannelRaw +): void { + const offset = channelIndex * BAOFENG_CHANNEL_SIZE; + if (offset + BAOFENG_CHANNEL_SIZE > image.length) + throw new Error('Channel index out of range'); + const out = image.subarray(offset, offset + BAOFENG_CHANNEL_SIZE); + if (raw.empty) { + out.fill(0xff); + return; + } + out.set(raw.rawBytes); + out.set(encodeBcdFreq(raw.rxFreqHz), 0); + if (raw.duplex === 'off') { + for (let i = 4; i < 8; i++) out[i] = 0xff; + } else { + const txHz = raw.txFreqHz || raw.rxFreqHz; + out.set(encodeBcdFreq(txHz), 4); + } + out.set(encodeTone(raw.rxtone), 8); + out.set(encodeTone(raw.txtone), 10); + const nameStr = (raw.name || '').trim().slice(0, 12); + const nameBytes = new TextEncoder().encode(nameStr); + for (let i = 20; i < 32; i++) { + out[i] = i - 20 < nameBytes.length ? nameBytes[i - 20] : 0x00; + } + const lowpower = raw.power === 'Low' ? 1 : 0; + out[14] = (out[14] & 0xcc) | (lowpower & 3); + // UV5R-Mini uses inverted bit: 1 = Narrow (NFM), 0 = Wide (FM) + const wideBit = raw.mode === 'FM' ? 0 : 1; + out[15] = (out[15] & 0x82) | (wideBit << 6); +} diff --git a/src/radios/uv5rmini/channelMapping.ts b/src/radios/uv5rmini/channelMapping.ts new file mode 100644 index 0000000..e915ff8 --- /dev/null +++ b/src/radios/uv5rmini/channelMapping.ts @@ -0,0 +1,146 @@ +/** + * Map between UV5R-Mini raw channel format and NeonPlug Channel model. + */ + +import type { Channel, CTCSSDCS } from '../../models'; +import type { Uv5rMiniChannelRaw } from './channelFormat'; +import { encodeBcdFreq, encodeTone } from './channelFormat'; + +export const DEFAULT_CTCSSDCS: CTCSSDCS = { type: 'None' }; + +function toneStrToCtcssDcs(str: string): CTCSSDCS { + if (!str || str === '—' || str.trim() === '') return DEFAULT_CTCSSDCS; + const s = str.trim(); + const asNum = parseFloat(s); + if (!Number.isNaN(asNum) && s.includes('.')) { + return { type: 'CTCSS', value: asNum }; + } + const code = parseInt(s, 10); + if (!Number.isNaN(code) && Number.isInteger(code)) { + return { type: 'DCS', value: code }; + } + return DEFAULT_CTCSSDCS; +} + +function ctcssDcsToStr(c: CTCSSDCS): string { + if (c.type === 'None') return '—'; + if (c.type === 'CTCSS' && c.value != null) return c.value.toFixed(1); + if (c.type === 'DCS' && c.value != null) return String(c.value); + return '—'; +} + +/** Convert NeonPlug Channel to UV5R-Mini raw shape (for write). */ +export function channelToUv5rMiniRaw(ch: Channel): Uv5rMiniChannelRaw { + const rxHz = Math.round((ch.rxFrequency || 0) * 1e6); + const txHz = Math.round((ch.txFrequency ?? ch.rxFrequency ?? 0) * 1e6); + let duplex = ''; + if (rxHz > 0) { + if (txHz === 0 || txHz === rxHz) duplex = ''; + else duplex = txHz > rxHz ? '+' : '-'; + } + const rawBytes = new Uint8Array(32); + if (rxHz === 0) { + rawBytes.fill(0xff); + return { + num: ch.number, + empty: true, + rxFreqHz: 0, + txFreqHz: 0, + duplex: '', + rxtone: '—', + txtone: '—', + power: 'High', + mode: 'NFM', + name: '—', + rawBytes, + }; + } + rawBytes.fill(0); + rawBytes.set(encodeBcdFreq(rxHz), 0); + if (duplex !== 'off') { + rawBytes.set(encodeBcdFreq(txHz), 4); + } else { + rawBytes[4] = rawBytes[5] = rawBytes[6] = rawBytes[7] = 0xff; + } + rawBytes.set(encodeTone(ctcssDcsToStr(ch.rxCtcssDcs ?? DEFAULT_CTCSSDCS)), 8); + rawBytes.set(encodeTone(ctcssDcsToStr(ch.txCtcssDcs ?? DEFAULT_CTCSSDCS)), 10); + rawBytes[12] = 1; + rawBytes[13] = 0; + const lowpower = (ch.power ?? 'High') === 'Low' ? 1 : 0; + const wide = (ch.bandwidth ?? '12.5kHz') === '25kHz' ? 1 : 0; + rawBytes[14] = lowpower & 3; + rawBytes[15] = (wide << 6); + const nameStr = (ch.name?.trim() || '—').slice(0, 12); + const nameBytes = new TextEncoder().encode(nameStr); + for (let i = 20; i < 32; i++) { + rawBytes[i] = i - 20 < nameBytes.length ? nameBytes[i - 20] : 0x00; + } + return { + num: ch.number, + empty: false, + rxFreqHz: rxHz, + txFreqHz: duplex === 'off' ? 0 : txHz, + duplex, + rxtone: ctcssDcsToStr(ch.rxCtcssDcs ?? DEFAULT_CTCSSDCS), + txtone: ctcssDcsToStr(ch.txCtcssDcs ?? DEFAULT_CTCSSDCS), + power: ch.power ?? 'High', + mode: (ch.bandwidth ?? '12.5kHz') === '25kHz' ? 'FM' : 'NFM', + name: ch.name?.trim() || '—', + rawBytes, + }; +} + +/** Convert UV5R-Mini raw channel to NeonPlug Channel. */ +export function uv5rMiniRawToChannel(raw: Uv5rMiniChannelRaw): Channel { + const rxMhz = raw.rxFreqHz / 1e6; + const txMhz = raw.txFreqHz > 0 ? raw.txFreqHz / 1e6 : rxMhz; + return { + number: raw.num, + name: raw.name || '—', + rxFrequency: rxMhz, + txFrequency: txMhz, + mode: 'Analog', + forbidTx: false, + loneWorker: false, + bandwidth: raw.mode === 'FM' ? '25kHz' : '12.5kHz', + scanAdd: false, + scanListId: 0, + forbidTalkaround: false, + unknown1A_6_4: 0, + unknown1A_3: false, + aprsReceive: false, + emergencyIndicator: false, + emergencyAck: false, + emergencySystemId: 0, + digitalEmergencySystemId: 0, + power: raw.power === 'Low' ? 'Low' : 'High', + aprsReportMode: 'Off', + unknown1C_1_0: 0, + voxFunction: false, + scramble: false, + compander: false, + talkback: false, + unknown1D_3_0: 0, + squelchLevel: 0, + pttIdDisplay: false, + pttId: 0, + colorCode: 0, + rxCtcssDcs: toneStrToCtcssDcs(raw.rxtone), + txCtcssDcs: toneStrToCtcssDcs(raw.txtone), + unknown25_7_6: 0, + companderDup: false, + voxRelated: false, + unknown25_3_0: 0, + pttIdDisplay2: false, + rxSquelchMode: 'Carrier/CTC', + unknown26_3_1: 0, + unknown26_0: false, + stepFrequency: 0, + signalingType: 'None', + pttIdType: 'Off', + unknown29_3_2: 0, + unknown29_1_0: 0, + unknown2A: 0, + contactId: 0, + }; +} diff --git a/src/radios/uv5rmini/constants.ts b/src/radios/uv5rmini/constants.ts new file mode 100644 index 0000000..e7740c2 --- /dev/null +++ b/src/radios/uv5rmini/constants.ts @@ -0,0 +1,35 @@ +/** + * UV5R-Mini protocol constants (from CHIRP baofeng_uv17Pro / uv5minitest). + */ + +/** 16-byte ident magic (MSTRING_UV17PROGPS). */ +export const BAOFENG_IDENT = new TextEncoder().encode('PROGRAMCOLORPROU'); + +/** Expected ACK byte after ident. */ +export const BAOFENG_ACK = 0x06; + +/** Block size for read/write. */ +export const BAOFENG_BLOCK_SIZE = 0x40; + +/** Read response = 4-byte header + BLOCK_SIZE payload. */ +export const BAOFENG_READ_RESPONSE_LEN = 4 + BAOFENG_BLOCK_SIZE; + +/** Memory regions: [start addr, size]. Full clone. */ +export const BAOFENG_MEM_STARTS = [0x0000, 0x9000, 0xa000]; +export const BAOFENG_MEM_SIZES = [0x8040, 0x0040, 0x01c0]; +export const BAOFENG_MEM_TOTAL = 0x8240; + +/** Number of 64-byte blocks for full clone. */ +export const BAOFENG_CLONE_BLOCK_COUNT = BAOFENG_MEM_SIZES.reduce( + (s, n) => s + n / BAOFENG_BLOCK_SIZE, + 0 +); + +export const BAOFENG_CHANNEL_COUNT = 999; +export const BAOFENG_CHANNEL_SIZE = 32; + +/** Serial baud rate for UV5R-Mini (CHIRP default for Baofeng). */ +export const UV5RMINI_BAUD_RATE = 38400; + +/** Firmware version string offset in clone image (CHIRP baofeng_uv17Pro _fw_ver_start). */ +export const BAOFENG_FW_VER_OFFSET = 0x1ef0; diff --git a/src/radios/uv5rmini/descriptor.ts b/src/radios/uv5rmini/descriptor.ts new file mode 100644 index 0000000..556e3b4 --- /dev/null +++ b/src/radios/uv5rmini/descriptor.ts @@ -0,0 +1,19 @@ +/** + * UV5R-Mini radio descriptor. Registered in radios/index.ts. + */ +import type { RadioDescriptor } from '../types'; +import { UV5RMiniProtocol } from './protocol'; +import { UV5RMINI_CAPABILITIES } from './capabilities'; +import { UV5RMINI_SETTINGS_PROFILE } from './settingsProfile'; + +export const UV5RMINI_MODEL_ID = 'UV5R-Mini'; + +export const UV5RMINI_DESCRIPTOR: RadioDescriptor = { + modelIds: [UV5RMINI_MODEL_ID], + label: 'UV5R-Mini', + icon: '📻', + supportsBle: true, + protocolFactory: () => new UV5RMiniProtocol(), + capabilities: UV5RMINI_CAPABILITIES, + settingsProfile: UV5RMINI_SETTINGS_PROFILE, +}; diff --git a/src/radios/uv5rmini/protocol.ts b/src/radios/uv5rmini/protocol.ts new file mode 100644 index 0000000..a52ed19 --- /dev/null +++ b/src/radios/uv5rmini/protocol.ts @@ -0,0 +1,235 @@ +/** + * 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 { UV5RMiniSerialConnection, openUV5RMiniPort } from './serialConnection'; +import { UV5RMiniBleConnection, requestUV5RMiniBleDevice } from './bleConnection'; +import { + BAOFENG_MEM_STARTS, + BAOFENG_MEM_SIZES, + BAOFENG_BLOCK_SIZE, + BAOFENG_CLONE_BLOCK_COUNT, + BAOFENG_CHANNEL_COUNT, + BAOFENG_CHANNEL_SIZE, + BAOFENG_FW_VER_OFFSET, +} from './constants'; +import { parseChannelsFromImage, writeChannelToImage, type Uv5rMiniChannelRaw } from './channelFormat'; +import { uv5rMiniRawToChannel, channelToUv5rMiniRaw } from './channelMapping'; +import { parseUv5rMiniSettings, writeUv5rMiniSettings, UV5RMINI_SETTINGS_OFFSET } from './settingsFormat'; + +const UV5RMINI_MODEL = 'UV5R-Mini'; + +type ConnectionLike = { + readBlock(addr: number): Promise; + writeBlock(addr: number, block: Uint8Array): Promise; + handshakeUpload(): Promise; + disconnect(): Promise; +}; + +export class UV5RMiniProtocol implements RadioProtocol { + private connection: ConnectionLike | null = null; + private port: import('./serialConnection').UV5RMiniSerialPort | null = null; + /** Cached image from last readChannels (used by readRadioSettings and getFirmwareFromCache). */ + private cachedImage: Uint8Array | null = null; + + /** Parse firmware string from cached clone image (call after readChannels). Used to enrich radioInfo. */ + getFirmwareFromCache(): string { + const img = this.cachedImage; + if (!img || img.length <= BAOFENG_FW_VER_OFFSET) return ''; + const slice = img.subarray(BAOFENG_FW_VER_OFFSET); + let end = 0; + const maxLen = Math.min(24, slice.length); + while (end < maxLen) { + const b = slice[end]; + if (b === 0x00 || b === 0xff || b < 0x20 || b > 0x7e) break; + end++; + } + 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 = + typeof portOrOptions === 'object' && portOrOptions != null ? portOrOptions : {}; + const forcePortSelection = options.forcePortSelection ?? false; + const transport = options.transport ?? 'serial'; + + if (transport === 'ble') { + const device = await requestUV5RMiniBleDevice(); + const bleConn = new UV5RMiniBleConnection(); + await bleConn.connect(device); + this.connection = bleConn; + this.port = null; + } else { + this.port = await openUV5RMiniPort(forcePortSelection); + const serialConn = new UV5RMiniSerialConnection(); + await serialConn.connect(this.port); + this.connection = serialConn; + } + } + + async disconnect(): Promise { + this.cachedImage = null; + if (this.connection) { + await this.connection.disconnect(); + this.connection = null; + } + if (this.port) { + try { + await this.port.close(); + } catch { + /* ignore */ + } + this.port = null; + } + } + + isConnected(): boolean { + return this.connection != null; + } + + async getRadioInfo(): Promise { + let firmware = ''; + if (this.connection) { + try { + const blockAddr = Math.floor(BAOFENG_FW_VER_OFFSET / BAOFENG_BLOCK_SIZE) * BAOFENG_BLOCK_SIZE; + const block = await this.connection.readBlock(blockAddr); + const offsetInBlock = BAOFENG_FW_VER_OFFSET - blockAddr; + const slice = block.subarray(offsetInBlock); + let end = 0; + const maxLen = Math.min(24, slice.length); + while (end < maxLen) { + const b = slice[end]; + if (b === 0x00 || b === 0xff || b < 0x20 || b > 0x7e) break; + end++; + } + firmware = String.fromCharCode(...slice.subarray(0, end)).trim(); + } catch { + /* ignore; firmware remains empty */ + } + } + return { + model: UV5RMINI_MODEL, + firmware, + buildDate: '', + maxContacts: 0, + memoryLayout: { configStart: 0x0000, configEnd: 0x8240 - 1 }, + }; + } + + /** Full clone: read all blocks, build image, parse channels. */ + async readChannels(): Promise { + if (!this.connection) throw new Error('Not connected'); + const image = new Uint8Array(0x8240); + let offset = 0; + let blockIndex = 0; + for (let r = 0; r < BAOFENG_MEM_STARTS.length; r++) { + const start = BAOFENG_MEM_STARTS[r]; + const size = BAOFENG_MEM_SIZES[r]; + for (let i = 0; i < size; i += BAOFENG_BLOCK_SIZE) { + const addr = start + i; + const block = await this.connection.readBlock(addr); + image.set(block, offset); + offset += BAOFENG_BLOCK_SIZE; + blockIndex++; + if (this.onProgress && blockIndex % 20 === 0) { + this.onProgress( + (blockIndex / BAOFENG_CLONE_BLOCK_COUNT) * 100, + `Reading block ${blockIndex}/${BAOFENG_CLONE_BLOCK_COUNT}` + ); + } + } + } + this.cachedImage = image; + const channelRegion = image.subarray(0, BAOFENG_CHANNEL_COUNT * BAOFENG_CHANNEL_SIZE); + const rawChannels = parseChannelsFromImage(channelRegion); + // Exclude empty channels (raw[0] === 0xff) - UV5R-Mini has no channel counter in memory + return rawChannels + .filter((raw) => !raw.empty) + .map((raw) => uv5rMiniRawToChannel(raw)); + } + + /** Write channels: build image from channels, then write blocks (upload handshake first). */ + async writeChannels(channels: Channel[]): Promise { + if (!this.connection) throw new Error('Not connected'); + await this.connection.handshakeUpload(); + + const image = new Uint8Array(0x8240); + image.fill(0xff); + const rawList: Uv5rMiniChannelRaw[] = channels + .filter((c) => c.number >= 1 && c.number <= BAOFENG_CHANNEL_COUNT) + .slice(0, BAOFENG_CHANNEL_COUNT) + .map((c) => channelToUv5rMiniRaw(c)); + for (let i = 0; i < rawList.length; i++) { + const raw = rawList[i]; + const idx = raw.num - 1; + if (idx >= 0 && idx < BAOFENG_CHANNEL_COUNT) { + writeChannelToImage(image, idx, raw); + } + } + + let written = 0; + const totalBlocks = Math.ceil((BAOFENG_CHANNEL_COUNT * BAOFENG_CHANNEL_SIZE) / BAOFENG_BLOCK_SIZE); + for (let addr = 0; addr < BAOFENG_CHANNEL_COUNT * BAOFENG_CHANNEL_SIZE; addr += BAOFENG_BLOCK_SIZE) { + const block = image.subarray(addr, addr + BAOFENG_BLOCK_SIZE); + await this.connection.writeBlock(addr, block); + written++; + if (this.onProgress && written % 20 === 0) { + this.onProgress((written / totalBlocks) * 100, `Writing block ${written}/${totalBlocks}`); + } + } + } + + 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 { + const image = this.cachedImage; + if (!image || image.length < 0x8080) return null; + + const uv5rMiniSettings = parseUv5rMiniSettings(image); + if (!uv5rMiniSettings) return null; + + return { uv5rMiniSettings } as RadioSettings; + } + + async writeRadioSettings(settings: RadioSettings, _options?: { changedFields?: string[] }): Promise { + const uv5rMiniSettings = settings.uv5rMiniSettings; + if (!uv5rMiniSettings || !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); + 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 new file mode 100644 index 0000000..62d3ad1 --- /dev/null +++ b/src/radios/uv5rmini/serialConnection.ts @@ -0,0 +1,228 @@ +/** + * UV5R-Mini connection over Web Serial API. + * Handshake: ident (PROGRAMCOLORPROU) -> ACK 0x06, then magics. + */ + +import { + BAOFENG_IDENT, + BAOFENG_ACK, + BAOFENG_BLOCK_SIZE, + BAOFENG_READ_RESPONSE_LEN, + UV5RMINI_BAUD_RATE, +} from './constants'; +import { + BAOFENG_MAGICS_READ, + BAOFENG_MAGICS_UPLOAD, + buildBaofengReadFrame, + buildBaofengWriteFrame, + parseBaofengReadResponse, +} from './baofengProtocol'; + +export interface UV5RMiniSerialPort { + readonly readable: ReadableStream | null; + readonly writable: WritableStream | null; + open(options: { baudRate: number }): Promise; + close(): Promise; +} + +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; + + 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 this.delay(300); + await this.clearBuffer(); + await this.delay(200); + + // Handshake: ident -> ACK + await this.send(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); + } + } + + 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; + } + + /** 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); + const raw = await this.waitForReadResponse(READ_TIMEOUT_MS); + return parseBaofengReadResponse(raw); + } + + /** 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(); + const frame = buildBaofengWriteFrame(addr, block); + await this.send(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); + 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); + } + } + + 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); + 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; + } + await this.delay(20); + } + throw new Error(`Timeout waiting for byte 0x${byte.toString(16)}`); + } + + /** Drain until buffer starts with 0x52, then read 68 bytes. */ + 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); + } + 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); + 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; + } + await this.delay(20); + } + throw new Error( + `Timeout waiting for read response (68 bytes). Have ${this.readBuffer.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; +} diff --git a/src/radios/uv5rmini/settingsFormat.ts b/src/radios/uv5rmini/settingsFormat.ts new file mode 100644 index 0000000..6690da4 --- /dev/null +++ b/src/radios/uv5rmini/settingsFormat.ts @@ -0,0 +1,142 @@ +/** + * UV5R-Mini settings format (from CHIRP baofeng_uv17Pro / uv5minitest). + * Layout: 0x8000 VFO A, 0x8020 VFO B, 0x8040 settings (64 bytes), 0x8080 ANI, 0x80A0 PTT ID, 0x81E0 upcode, 0x8210 downcode. + */ + +/** Settings object offset in image (64 bytes). */ +export const UV5RMINI_SETTINGS_OFFSET = 0x8040; + +/** Option lists for settings (from uv5minitest BASIC_SETTINGS_SCHEMA). */ +const LIST_PTTID = ['Off', 'BOT', 'EOT', 'Both']; +const LIST_TIMEOUT = ['Off', ...Array.from({ length: 12 }, (_, i) => `${15 + i * 15} sec`)]; +const LIST_DUAL_WATCH = ['Off', 'On']; +const LIST_POWERON_DISPLAY = ['LOGO', 'BATT voltage']; +const LIST_VOICE = ['English', 'Chinese']; +const LIST_BACKLIGHT = ['Always On', ...Array.from({ length: 4 }, (_, i) => `${5 + i * 5} sec`)]; +const LIST_BEEP_MINI = ['Off', 'On']; +const LIST_MODE = ['Name', 'Frequency', 'Channel Number']; +const LIST_ID_DELAY = Array.from({ length: 30 }, (_, i) => `${100 + i * 100} ms`); +const LIST_QT_SAVEMODE = ['Both', 'RX', 'TX']; +const LIST_SCANMODE = ['Time', 'Carrier', 'Search']; +const LIST_ALARMMODE = ['Local', 'Send Tone', 'Send Code']; +const LIST_SIDE_TONE = ['Off', 'KB Side Tone', 'ANI Side Tone', 'KB + ANI Side Tone']; +const LIST_RPT_TAIL = Array.from({ length: 11 }, (_, i) => `${i * 100} ms`); +const LIST_VOX_DELAY = Array.from({ length: 16 }, (_, i) => `${500 + i * 100} ms`); +const LIST_VOX_LEVEL = ['Off', ...Array.from({ length: 9 }, (_, i) => String(i + 1))]; +const LIST_PW_SAVEMODE = ['Off', 'On']; +const LIST_TIMEOUT_ALARM = ['Off', ...Array.from({ length: 10 }, (_, i) => `${i + 1} sec`)]; +const LIST_MENU_QUIT = Array.from({ length: 11 }, (_, i) => (i < 10 ? `${5 + i * 5} sec` : '60 sec')); +const LIST_WORKMODE = ['Frequency', 'Channel']; +const LIST_HANGUPTIME = [3, 4, 5, 6, 7, 8, 9, 10].map((x) => `${x} s`); +const SQUELCH_LIST = ['Off', '1', '2', '3', '4', '5']; + +function clampIndex(maxLen: number, value: number | undefined): number { + if (value == null || value < 0) return 0; + return Math.min(value, maxLen - 1); +} + +import type { Uv5rMiniSettings } from '../../types/uv5rMiniSettings'; + +export type { Uv5rMiniSettings }; + +/** Parse UV5R-Mini settings from image at offset 0x8040 (64 bytes). */ +export function parseUv5rMiniSettings(image: Uint8Array): Uv5rMiniSettings | null { + const offset = UV5RMINI_SETTINGS_OFFSET; + if (offset + 64 > image.length) return null; + + const s = image.subarray(offset, offset + 64); + const chbworkmode = s[26] & 0x0f; + const chaworkmode = (s[26] >> 4) & 0x0f; + + return { + squelch: Math.min(s[0], SQUELCH_LIST.length - 1), + savemode: Math.min(s[1], LIST_PW_SAVEMODE.length - 1), + vox: Math.min(s[2], LIST_VOX_LEVEL.length - 1), + backlight: Math.min(s[3], LIST_BACKLIGHT.length - 1), + dualstandby: Math.min(s[4], LIST_DUAL_WATCH.length - 1), + tot: Math.min(s[5], LIST_TIMEOUT.length - 1), + beep: Math.min(s[6], LIST_BEEP_MINI.length - 1), + voicesw: !!s[7], + voice: Math.min(s[8], LIST_VOICE.length - 1), + sidetone: Math.min(s[9], LIST_SIDE_TONE.length - 1), + scanmode: Math.min(s[10], LIST_SCANMODE.length - 1), + pttid: Math.min(s[11], LIST_PTTID.length - 1), + pttdly: Math.min(s[12], LIST_ID_DELAY.length - 1), + chadistype: Math.min(s[13], LIST_MODE.length - 1), + chbdistype: Math.min(s[14], LIST_MODE.length - 1), + bcl: !!s[15], + autolock: !!s[16], + alarmmode: Math.min(s[17], LIST_ALARMMODE.length - 1), + alarmtone: !!s[18], + tailclear: !!s[20], + rpttailclear: Math.min(s[21], LIST_RPT_TAIL.length - 1), + rpttaildet: Math.min(s[22], LIST_RPT_TAIL.length - 1), + roger: !!s[23], + aOrB: (s[24] === 0 ? 0 : 1) as 0 | 1, + fmenable: !!s[25], + chaworkmode: Math.min(chaworkmode, LIST_WORKMODE.length - 1), + chbworkmode: Math.min(chbworkmode, LIST_WORKMODE.length - 1), + keylock: !!s[27], + powerondistype: Math.min(s[28], LIST_POWERON_DISPLAY.length - 1), + voxdlytime: Math.min(s[32], LIST_VOX_DELAY.length - 1), + menuquittime: Math.min(s[33], LIST_MENU_QUIT.length - 1), + dispani: !!s[36], + totalarm: Math.min(s[40], LIST_TIMEOUT_ALARM.length - 1), + ctsdcsscantype: Math.min(s[43], LIST_QT_SAVEMODE.length - 1), + hangup: Math.min(s[57], LIST_HANGUPTIME.length - 1), + voxsw: !!s[58], + inputdtmf: !!s[61], + }; +} + +/** Write UV5R-Mini settings back to image. Only writes fields present in settings; preserves other bytes. */ +export function writeUv5rMiniSettings( + image: Uint8Array, + settings: Partial +): void { + const offset = UV5RMINI_SETTINGS_OFFSET; + if (offset + 64 > image.length) return; + + const s = image.subarray(offset, offset + 64); + + if (settings.squelch != null) s[0] = clampIndex(SQUELCH_LIST.length, settings.squelch); + if (settings.savemode != null) s[1] = clampIndex(LIST_PW_SAVEMODE.length, settings.savemode); + if (settings.vox != null) s[2] = clampIndex(LIST_VOX_LEVEL.length, settings.vox); + if (settings.backlight != null) s[3] = clampIndex(LIST_BACKLIGHT.length, settings.backlight); + if (settings.dualstandby != null) s[4] = clampIndex(LIST_DUAL_WATCH.length, settings.dualstandby); + if (settings.tot != null) s[5] = clampIndex(LIST_TIMEOUT.length, settings.tot); + if (settings.beep != null) s[6] = clampIndex(LIST_BEEP_MINI.length, settings.beep); + if (settings.voicesw != null) s[7] = settings.voicesw ? 1 : 0; + if (settings.voice != null) s[8] = clampIndex(LIST_VOICE.length, settings.voice); + if (settings.sidetone != null) s[9] = clampIndex(LIST_SIDE_TONE.length, settings.sidetone); + if (settings.scanmode != null) s[10] = clampIndex(LIST_SCANMODE.length, settings.scanmode); + if (settings.pttid != null) s[11] = clampIndex(LIST_PTTID.length, settings.pttid); + if (settings.pttdly != null) s[12] = clampIndex(LIST_ID_DELAY.length, settings.pttdly); + if (settings.chadistype != null) s[13] = clampIndex(LIST_MODE.length, settings.chadistype); + if (settings.chbdistype != null) s[14] = clampIndex(LIST_MODE.length, settings.chbdistype); + if (settings.bcl != null) s[15] = settings.bcl ? 1 : 0; + if (settings.autolock != null) s[16] = settings.autolock ? 1 : 0; + if (settings.alarmmode != null) s[17] = clampIndex(LIST_ALARMMODE.length, settings.alarmmode); + if (settings.alarmtone != null) s[18] = settings.alarmtone ? 1 : 0; + if (settings.tailclear != null) s[20] = settings.tailclear ? 1 : 0; + if (settings.rpttailclear != null) s[21] = clampIndex(LIST_RPT_TAIL.length, settings.rpttailclear); + if (settings.rpttaildet != null) s[22] = clampIndex(LIST_RPT_TAIL.length, settings.rpttaildet); + if (settings.roger != null) s[23] = settings.roger ? 1 : 0; + if (settings.aOrB != null) s[24] = settings.aOrB; + if (settings.fmenable != null) s[25] = settings.fmenable ? 1 : 0; + if (settings.chaworkmode != null || settings.chbworkmode != null) { + const high = settings.chaworkmode != null ? clampIndex(LIST_WORKMODE.length, settings.chaworkmode) : (s[26] >> 4) & 0x0f; + const low = settings.chbworkmode != null ? clampIndex(LIST_WORKMODE.length, settings.chbworkmode) : s[26] & 0x0f; + s[26] = (high << 4) | low; + } + if (settings.keylock != null) s[27] = settings.keylock ? 1 : 0; + if (settings.powerondistype != null) s[28] = clampIndex(LIST_POWERON_DISPLAY.length, settings.powerondistype); + if (settings.voxdlytime != null) s[32] = clampIndex(LIST_VOX_DELAY.length, settings.voxdlytime); + if (settings.menuquittime != null) s[33] = clampIndex(LIST_MENU_QUIT.length, settings.menuquittime); + if (settings.dispani != null) s[36] = settings.dispani ? 1 : 0; + if (settings.totalarm != null) s[40] = clampIndex(LIST_TIMEOUT_ALARM.length, settings.totalarm); + if (settings.ctsdcsscantype != null) s[43] = clampIndex(LIST_QT_SAVEMODE.length, settings.ctsdcsscantype); + if (settings.hangup != null) s[57] = clampIndex(LIST_HANGUPTIME.length, settings.hangup); + if (settings.voxsw != null) s[58] = settings.voxsw ? 1 : 0; + if (settings.inputdtmf != null) s[61] = settings.inputdtmf ? 1 : 0; +} diff --git a/src/radios/uv5rmini/settingsProfile.ts b/src/radios/uv5rmini/settingsProfile.ts new file mode 100644 index 0000000..88c4b55 --- /dev/null +++ b/src/radios/uv5rmini/settingsProfile.ts @@ -0,0 +1,93 @@ +/** + * UV5R-Mini settings profile. Drives the Settings tab UI. + */ +import type { SettingsProfile } from '../../types/settingsProfile'; + +function optionsFor(values: string[]) { + return values.map((label, i) => ({ value: i, label })); +} + +export const UV5RMINI_SETTINGS_PROFILE: SettingsProfile = { + radioType: 'UV5R-Mini', + sections: [ + { + 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']) }, + ], + }, + { + 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' }] }, + ], + }, + { + 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']) }, + ], + }, + { + 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']) }, + ], + }, + { + 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']) }, + ], + }, + { + 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`)) }, + ], + }, + { + 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']) }, + ], + }, + ], +}; diff --git a/src/services/codeplugMigration.ts b/src/services/codeplugMigration.ts new file mode 100644 index 0000000..247ce84 --- /dev/null +++ b/src/services/codeplugMigration.ts @@ -0,0 +1,149 @@ +/** + * Codeplug migration: convert codeplug data for a target radio (e.g. UV5R-Mini). + * Drops or truncates data that the target doesn't support. + * Settings are always cleared (they do not map between radios). + */ + +import type { CodeplugData } from './codeplugExport'; +import { getCapabilitiesForModel } from '../radios/capabilities'; + +/** Placeholder when device version info is unknown (e.g. after convert from another radio). */ +const UNKNOWN_VERSION = '-'; + +/** Counts of what was removed or cleared during migration (for user warning). */ +export interface MigrationLoss { + channelsDropped: number; + zonesLost: number; + scanListsLost: number; + contactsLost: number; + radioIdsLost: number; + digitalEmergenciesLost: number; + messagesLost: number; + quickContactsLost: number; + rxGroupsLost: number; + encryptionKeysLost: number; + settingsCleared: boolean; +} + +export interface MigrationResult { + migrated: CodeplugData; + loss: MigrationLoss; +} + +/** + * Migrate codeplug to be valid for the given target radio model. + * Returns migrated data and a loss summary; does not mutate source. + * Radio settings are always cleared (they do not map between radios). + */ +export function migrateCodeplug(source: CodeplugData, targetModel: string): MigrationResult { + const caps = getCapabilitiesForModel(targetModel); + const maxChannels = caps?.maxChannels ?? 4000; + const supportsZones = caps?.supportsZones ?? true; + const supportsScanLists = caps?.supportsScanLists ?? true; + const analogOnly = caps?.analogOnly ?? false; + + // 1) Channels: drop digital if analogOnly, then truncate to maxChannels (keep by number, no renumbering) + let channels = source.channels; + if (analogOnly) { + channels = channels.filter( + (ch) => ch.mode !== 'Digital' && ch.mode !== 'Fixed Digital' + ); + } + const validChannelNumbers = new Set( + channels + .filter((ch) => ch.number >= 1 && ch.number <= maxChannels) + .map((ch) => ch.number) + ); + channels = channels.filter((ch) => validChannelNumbers.has(ch.number)); + + const maxZones = caps?.maxZones; + const maxScanLists = caps?.maxScanLists; + + // 2) Zones + let zones = source.zones; + if (!supportsZones) { + zones = []; + } else { + zones = zones + .map((z) => ({ + ...z, + channels: z.channels.filter((n) => validChannelNumbers.has(n)), + })) + .filter((z) => z.channels.length > 0); + if (maxZones != null && maxZones >= 0) { + zones = zones.slice(0, maxZones); + } + } + + // 3) Scan lists + let scanLists = source.scanLists; + if (!supportsScanLists) { + scanLists = []; + } else { + scanLists = source.scanLists + .map((s) => ({ + ...s, + channels: s.channels.filter((n) => validChannelNumbers.has(n)), + })) + .filter((s) => s.channels.length > 0); + if (maxScanLists != null && maxScanLists >= 0) { + scanLists = scanLists.slice(0, maxScanLists); + } + } + + // 4) Contacts, DMR IDs, digital, quick messages, RX groups, encryption: empty if analogOnly + const contacts = analogOnly ? [] : source.contacts; + const radioIds = analogOnly ? [] : source.radioIds; + const digitalEmergencies = analogOnly ? [] : source.digitalEmergencies; + const digitalEmergencyConfig = analogOnly ? null : source.digitalEmergencyConfig; + const messages = analogOnly ? [] : source.messages; + const quickContacts = analogOnly ? [] : source.quickContacts; + const rxGroups = analogOnly ? [] : source.rxGroups; + const encryptionKeys = analogOnly ? [] : source.encryptionKeys; + const analogEmergencies = source.analogEmergencies; + + // Loss summary (counts removed/cleared) + const loss: MigrationLoss = { + channelsDropped: source.channels.length - channels.length, + zonesLost: source.zones.length - zones.length, + scanListsLost: source.scanLists.length - scanLists.length, + contactsLost: analogOnly ? source.contacts.length : Math.max(0, source.contacts.length - contacts.length), + radioIdsLost: analogOnly ? (source.radioIds?.length ?? 0) : 0, + digitalEmergenciesLost: analogOnly ? (source.digitalEmergencies?.length ?? 0) : 0, + messagesLost: analogOnly ? (source.messages?.length ?? 0) : 0, + quickContactsLost: analogOnly ? (source.quickContacts?.length ?? 0) : 0, + rxGroupsLost: analogOnly ? (source.rxGroups?.length ?? 0) : 0, + encryptionKeysLost: analogOnly ? (source.encryptionKeys?.length ?? 0) : 0, + settingsCleared: !!source.radioSettings, + }; + + const migrated: CodeplugData = { + ...source, + channels, + zones, + scanLists, + contacts, + radioIds, + digitalEmergencies, + digitalEmergencyConfig, + messages, + quickContacts, + rxGroups, + encryptionKeys, + analogEmergencies, + radioSettings: null, // Settings do not map between radios; always cleared on convert + radioInfo: { + model: targetModel, + firmware: UNKNOWN_VERSION, + buildDate: UNKNOWN_VERSION, + dspVersion: UNKNOWN_VERSION, + radioVersion: UNKNOWN_VERSION, + codeplugVersion: UNKNOWN_VERSION, + // Do not carry over memoryLayout/vframes/maxContacts from source; they are device-specific. + }, + exportDate: new Date().toISOString(), + version: source.version, + }; + + return { migrated, loss }; +} diff --git a/src/services/validation/channelValidator.ts b/src/services/validation/channelValidator.ts index a8a5539..7399c2a 100644 --- a/src/services/validation/channelValidator.ts +++ b/src/services/validation/channelValidator.ts @@ -8,12 +8,17 @@ export interface ValidationError { message: string; } +/** Default max channel number when capabilities don't specify (e.g. DM-32UV). */ +const DEFAULT_MAX_CHANNELS = 4000; + /** - * Validate a channel. Band limits come from radio capabilities (getCapabilitiesForModel(radioInfo?.model)?.bandLimits). + * Validate a channel. Band limits and maxChannels come from radio capabilities + * (getCapabilitiesForModel(radioInfo?.model)). */ export function validateChannel( channel: Channel, - bandLimits?: RadioBandLimits | null + bandLimits?: RadioBandLimits | null, + maxChannels: number = DEFAULT_MAX_CHANNELS ): ValidationError[] { const errors: ValidationError[] = []; @@ -46,9 +51,9 @@ export function validateChannel( } } - // Channel number validation - if (channel.number < 1 || channel.number > 4000) { - errors.push({ field: 'number', message: 'Channel number must be between 1 and 4000' }); + // Channel number validation (uses maxChannels from capabilities, e.g. 999 for UV5R-Mini) + if (channel.number < 1 || channel.number > maxChannels) { + errors.push({ field: 'number', message: `Channel number must be between 1 and ${maxChannels}` }); } // DMR-specific validation (digital only) @@ -73,11 +78,12 @@ export function validateChannel( export function validateChannels( channels: Channel[], - bandLimits?: RadioBandLimits | null + bandLimits?: RadioBandLimits | null, + maxChannels: number = DEFAULT_MAX_CHANNELS ): Map { const errors = new Map(); channels.forEach((channel) => { - const channelErrors = validateChannel(channel, bandLimits); + const channelErrors = validateChannel(channel, bandLimits, maxChannels); if (channelErrors.length > 0) { errors.set(channel.number, channelErrors); } diff --git a/src/store/encryptionKeysStore.ts b/src/store/encryptionKeysStore.ts index 1dbad82..e3a0086 100644 --- a/src/store/encryptionKeysStore.ts +++ b/src/store/encryptionKeysStore.ts @@ -3,7 +3,10 @@ import type { EncryptionKey } from '../models/EncryptionKey'; interface EncryptionKeysState { keys: EncryptionKey[]; + keysLoaded: boolean; setKeys: (keys: EncryptionKey[]) => void; + setKeysLoaded: (loaded: boolean) => void; + clearKeys: () => void; updateKey: (entryNumber: number, updates: Partial) => void; addKey: (key: EncryptionKey) => void; deleteKey: (entryNumber: number) => void; @@ -11,7 +14,10 @@ interface EncryptionKeysState { export const useEncryptionKeysStore = create((set) => ({ keys: [], - setKeys: (keys) => set({ keys }), + keysLoaded: false, + setKeys: (keys) => set({ keys, keysLoaded: true }), + setKeysLoaded: (loaded) => set({ keysLoaded: loaded }), + clearKeys: () => set({ keys: [], keysLoaded: false }), updateKey: (entryNumber, updates) => set((state) => ({ keys: state.keys.map((k) => diff --git a/src/store/radioStore.ts b/src/store/radioStore.ts index 16e7174..c818d3a 100644 --- a/src/store/radioStore.ts +++ b/src/store/radioStore.ts @@ -24,6 +24,12 @@ type ZoneComparisonData = Array<{ }>; interface RadioState { + /** Model ID selected in the pick-a-radio modal for the next "Read from Radio" (e.g. DM-32UV). */ + selectedRadioModel: string | null; + /** When connecting to a radio that supports both (e.g. UV5R-Mini), use this transport. */ + preferredTransport: 'serial' | 'ble' | null; + /** When true, show the pick-a-radio modal (e.g. from Toolbar "Change radio"). */ + showPickRadioModal: boolean; isConnected: boolean; radioInfo: RadioInfo | null; settings: RadioSettings | null; @@ -51,9 +57,15 @@ interface RadioState { setBootImageRaw: (data: Uint8Array | null) => void; setBootImageDescription: (description: string | null) => void; setConnectionError: (error: string | null) => void; + setSelectedRadioModel: (model: string | null) => void; + setPreferredTransport: (transport: 'serial' | 'ble' | null) => void; + setShowPickRadioModal: (show: boolean) => void; } export const useRadioStore = create((set) => ({ + selectedRadioModel: null, + preferredTransport: null, + showPickRadioModal: false, isConnected: false, radioInfo: null, settings: null, @@ -81,5 +93,8 @@ export const useRadioStore = create((set) => ({ setBootImageRaw: (data) => set({ bootImageRaw: data }), setBootImageDescription: (description) => set({ bootImageDescription: description }), setConnectionError: (error) => set({ connectionError: error }), + setSelectedRadioModel: (model) => set({ selectedRadioModel: model }), + setPreferredTransport: (transport) => set({ preferredTransport: transport }), + setShowPickRadioModal: (show) => set({ showPickRadioModal: show }), })); diff --git a/src/types/radioCapabilities.ts b/src/types/radioCapabilities.ts index f84e2c2..640f1c9 100644 --- a/src/types/radioCapabilities.ts +++ b/src/types/radioCapabilities.ts @@ -63,4 +63,30 @@ export interface RadioCapabilities { isFirmware049OrNewer?: (firmware: string) => boolean; /** Validations to run before writing codeplug to this radio. Only run when model is known. */ writeValidations?: WriteValidations; + /** Max channel count (e.g. 999 for UV5R-Mini, 4000 for DM32). */ + maxChannels?: number; + /** If false, radio has no zones (e.g. UV5R-Mini). */ + supportsZones?: boolean; + /** If false, radio has no scan lists. */ + supportsScanLists?: boolean; + /** If false, radio has no CSV contacts / contact list (e.g. UV5R-Mini). */ + supportsContacts?: boolean; + /** If true, analog-only radio — no DMR/digital features. */ + analogOnly?: boolean; + /** If true, radio supports BLE in addition to serial (transport option in connect). */ + supportsBle?: boolean; + /** When radio supports both serial and BLE, default transport to offer (store can override). */ + preferredTransport?: 'serial' | 'ble'; + /** If true, hook calls bulkReadRequiredBlocks() before parsing channels (e.g. DM-32UV). */ + supportsBulkRead?: boolean; + /** If true, channel list includes VFO A/B as channels 4001/4002 (e.g. DM-32UV). Analog-only radios typically do not. */ + supportsVfoChannels?: boolean; + /** Max zone count when supportsZones is true (e.g. 250 for DM32). */ + maxZones?: number; + /** Max scan list count when supportsScanLists is true (e.g. 32 for DM32). */ + maxScanLists?: number; + /** If true, protocol supports readBootImage / writeBootImage. */ + supportsBootImage?: boolean; + /** If true, protocol supports readQuickMessages. */ + supportsQuickMessages?: boolean; } diff --git a/src/types/uv5rMiniSettings.ts b/src/types/uv5rMiniSettings.ts new file mode 100644 index 0000000..364c245 --- /dev/null +++ b/src/types/uv5rMiniSettings.ts @@ -0,0 +1,40 @@ +/** UV5R-Mini settings (stored in RadioSettings.uv5rMiniSettings). Select fields use 0-based index. */ +export interface Uv5rMiniSettings { + squelch: number; + savemode: number; + vox: number; + backlight: number; + dualstandby: number; + tot: number; + beep: number; + voicesw: boolean; + voice: number; + sidetone: number; + scanmode: number; + pttid: number; + pttdly: number; + chadistype: number; + chbdistype: number; + bcl: boolean; + autolock: boolean; + alarmmode: number; + alarmtone: boolean; + tailclear: boolean; + rpttailclear: number; + rpttaildet: number; + roger: boolean; + aOrB: 0 | 1; + fmenable: boolean; + chaworkmode: number; + chbworkmode: number; + keylock: boolean; + powerondistype: number; + voxdlytime: number; + menuquittime: number; + dispani: boolean; + totalarm: number; + ctsdcsscantype: number; + hangup: number; + voxsw: boolean; + inputdtmf: boolean; +}