diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index 711093c..69a7986 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -50,6 +50,11 @@ export const Toolbar: React.FC = () => { const [lastOperationMode, setLastOperationMode] = useState<'read' | 'write' | null>(null); const [writeWarningOpen, setWriteWarningOpen] = useState(false); const [writeWarningMessage, setWriteWarningMessage] = useState(''); + const [cloneInstructionsOpen, setCloneInstructionsOpen] = useState(false); + const [pendingReadForceSelection, setPendingReadForceSelection] = useState(true); + const [cloneStartOpen, setCloneStartOpen] = useState(false); + const cloneStartResolveRef = useRef<(() => void) | null>(null); + const cloneStartRejectRef = useRef<((err: Error) => void) | null>(null); const { alertOpen, alertMessage, alertTitle, showAlert, closeAlert } = useAlert(); const [convertModalOpen, setConvertModalOpen] = useState(false); const [convertTargetModel, setConvertTargetModel] = useState(() => getMigrationTargetModels()[0] ?? 'DM-32UV'); @@ -261,7 +266,31 @@ export const Toolbar: React.FC = () => { await exportCodeplug(buildCodeplugData()); }; - const handleRead = async (forcePortSelection = true) => { + // For radios that need a manual button-press to start sending (e.g. FT-70D): the radio + // streams immediately with no handshake, so we must already be connected and listening + // before asking the user to press it — otherwise the transmission can finish before + // anyone is reading. Resolves once the user confirms the "press it now" modal. + const waitForCloneStart = () => new Promise((resolve, reject) => { + cloneStartResolveRef.current = resolve; + cloneStartRejectRef.current = reject; + setCloneStartOpen(true); + }); + + const handleCloneStartConfirm = () => { + setCloneStartOpen(false); + cloneStartResolveRef.current?.(); + cloneStartResolveRef.current = null; + cloneStartRejectRef.current = null; + }; + + const handleCloneStartCancel = () => { + setCloneStartOpen(false); + cloneStartRejectRef.current?.(new Error('Read cancelled by user.')); + cloneStartResolveRef.current = null; + cloneStartRejectRef.current = null; + }; + + const doRead = async (forcePortSelection = true) => { window.focus(); try { setConnectionError(null); @@ -270,13 +299,14 @@ export const Toolbar: React.FC = () => { setProgressMessage('Selecting port...'); setCurrentStep('Selecting port'); + const onConnected = caps?.cloneModeInstructions ? waitForCloneStart : undefined; await readFromRadio((progress, message, step) => { setProgress(progress); setProgressMessage(message); if (step) { setCurrentStep(step); } - }, { forcePortSelection }); + }, { forcePortSelection, onConnected }); setConnectionError(null); setLastOperationMode(null); @@ -295,6 +325,20 @@ export const Toolbar: React.FC = () => { } }; + const handleRead = (forcePortSelection = true) => { + if (caps?.cloneModeInstructions) { + setPendingReadForceSelection(forcePortSelection); + setCloneInstructionsOpen(true); + return; + } + doRead(forcePortSelection); + }; + + const handleCloneInstructionsConfirm = () => { + setCloneInstructionsOpen(false); + doRead(pendingReadForceSelection); + }; + const handleRetry = () => { if (lastOperationMode === 'write') { handleWrite(); @@ -401,6 +445,9 @@ export const Toolbar: React.FC = () => { }); message = '⚠️ Codeplug check\n\n' + validationLines.join('\n\n') + '\n\n' + message; } + if (caps?.cloneModeInstructions) { + message = caps.cloneModeInstructions.write + '\n\n' + message; + } setWriteWarningMessage(message); setWriteWarningOpen(true); }; @@ -567,6 +614,26 @@ export const Toolbar: React.FC = () => { cancelLabel="Cancel" variant="default" /> + setCloneInstructionsOpen(false)} + onConfirm={handleCloneInstructionsConfirm} + title="Prepare radio for clone mode" + message={caps?.cloneModeInstructions?.read ?? ''} + confirmLabel="Continue" + cancelLabel="Cancel" + variant="default" + /> + void, - { forcePortSelection = true }: { forcePortSelection?: boolean } = {} + { forcePortSelection = true, onConnected }: { forcePortSelection?: boolean; onConnected?: () => Promise | void } = {} ) => { setIsConnecting(true); setError(null); @@ -274,6 +274,7 @@ export function useRadioConnection() { : 'Reconnecting to radio...', steps[0]); await protocol.connect({ forcePortSelection, ...(transport != null && { transport }) }); + if (onConnected) await onConnected(); await performRead(protocol); } catch (err) { @@ -290,6 +291,7 @@ export function useRadioConnection() { protocol = createProtocolForModel(effectiveModel ?? '') ?? createDefaultProtocol(); protocol.onProgress = (progress, message) => onProgress?.(progress, message); await protocol.connect(); + if (onConnected) await onConnected(); await performRead(protocol); return; } catch (retryErr) { diff --git a/src/radios/ft70/capabilities.ts b/src/radios/ft70/capabilities.ts new file mode 100644 index 0000000..8d037eb --- /dev/null +++ b/src/radios/ft70/capabilities.ts @@ -0,0 +1,42 @@ +import type { RadioCapabilities } from '../../types/radioCapabilities'; + +/** + * The FT-70D has no software-triggerable clone mode — the user must arm it + * manually on the radio before each Read/Write. Steps from CHIRP's + * ft70.py get_prompts() (pre_download / pre_upload). + */ +const FT70_CLONE_MODE_INSTRUCTIONS = { + read: + 'FT-70D Clone Mode — Read (radio → computer)\n\n' + + '1. Turn the radio on and connect the USB clone cable to the DATA terminal.\n' + + '2. Unclip the battery, then press and hold [AMS] + the power key while clipping the battery back in. "ADMS" appears on the display.\n' + + '3. Click Continue below to connect (you may be asked to select the serial port). Don\'t press [BAND] yet — the radio sends immediately on that key press with no handshake, so it has to happen after the connection is already open and listening.', + readStart: + 'Connected and listening for the radio.\n\n' + + 'Press the [BAND] key on the radio now to start sending, then click Continue.', + write: + 'FT-70D Clone Mode — Write (computer → radio)\n\n' + + '1. Turn the radio on and connect the USB clone cable to the DATA terminal.\n' + + '2. Unclip the battery, then press and hold [AMS] + the power key while clipping the battery back in. "ADMS" appears on the display.\n' + + '3. Press the [MODE] key on the radio — the display shows "-WAIT-" — then click Continue below.', +}; + +/** FT-70D / FT-70DR / FT-70DE: dual-band VHF+UHF, C4FM digital voice (decoded as analog-only here). */ +export const FT70_CAPS: RadioCapabilities = { + cloneModeInstructions: FT70_CLONE_MODE_INSTRUCTIONS, + bandLimits: { + vhfMin: 136, + vhfMax: 174, + uhfMin: 400, + uhfMax: 480, + }, + writeValidations: { channelsMustBeInZones: false }, + maxChannels: 900, + supportsZones: false, // FT-70 has 24 banks (chirp FT70Bank); not yet mapped to NeonPlug zones + supportsScanLists: false, + supportsContacts: false, + analogOnly: true, + supportsBle: false, + preferredTransport: 'serial', + supportsBulkRead: false, +}; diff --git a/src/radios/ft70/connection.ts b/src/radios/ft70/connection.ts new file mode 100644 index 0000000..8f777a1 --- /dev/null +++ b/src/radios/ft70/connection.ts @@ -0,0 +1,114 @@ +/** + * Web Serial connection for the Yaesu FT-70D using the generic Yaesu clone + * protocol (chirp/drivers/yaesu_clone.py), NOT the SCU-35 PROGRAM/QX + * handshake used by the FT-65/FT-4 family. + * + * There is no software-side "enter clone mode" command — the user manually + * puts the radio into ADMS clone mode (hold AMS + power while clipping in + * the battery, then press BAND to send from radio / MODE to receive into + * radio) before clicking Read/Write in the app. Once in that mode the radio + * just streams (or expects) two clone blocks: + * 1. a 10-byte ID block ("AH51G" + 5 more bytes), ACK'd by the host + * 2. the remaining 65217-byte data block, streamed continuously + */ + +import { FT70_BAUD_RATE, FT70_RX_BUFFER_SIZE, FT70_ID_BLOCK_SIZE, FT70_DATA_BLOCK_SIZE, FT70_CHUNK_SIZE } from './constants'; +import { BaseSerialConnection, type SerialLikePort } from '../shared/BaseSerialConnection'; +import { requestSerialPort } from '../shared/serialPort'; + +const ACK = 0x06; +const READ_TIMEOUT_MS = 60_000; +const ACK_TIMEOUT_MS = 8_000; +const WRITE_CHUNK_DELAY_MS = 30; + +export type FT70SerialPort = SerialLikePort; + +/** Request a Web Serial port and open it at 38400 baud with a clone-stream-sized buffer. */ +export async function openFT70Port(forceSelection = false): Promise { + return requestSerialPort(FT70_BAUD_RATE, forceSelection, FT70_RX_BUFFER_SIZE); +} + +export class FT70Connection extends BaseSerialConnection { + /** Open the port and set up reader/writer. */ + async open(port: FT70SerialPort): Promise { + await super.openPort(port); + await this.delay(300); + this.buf = new Uint8Array(0); + } + + /** Close reader/writer and port. */ + async close(): Promise { + await super.closeStreams(); + } + + /** + * Read the full memory image from the radio (must already be in clone + * send mode). Reports progress via onProgress(0-100). + */ + async readImage(onProgress?: (pct: number, message: string) => void): Promise { + const image = new Uint8Array(FT70_ID_BLOCK_SIZE + FT70_DATA_BLOCK_SIZE); + + // ID block: some cables echo a single ACK byte ahead of the real data — chew it if present. + let idBlock = await this.readExact(FT70_ID_BLOCK_SIZE, ACK_TIMEOUT_MS); + if (idBlock[0] === ACK) { + const extra = await this.readExact(1, ACK_TIMEOUT_MS); + idBlock = new Uint8Array([...idBlock.slice(1), extra[0]]); + } + image.set(idBlock, 0); + + // Tell the radio to continue with the data block. + await this.write(new Uint8Array([ACK])); + + // Echoing cables (TX/RX OR'd together) reflect the ACK we just sent ahead + // of the radio's data. Strip it exactly like CHIRP's _chunk_read does — + // without this the whole image shifts by one byte and the read times out + // waiting for the last byte. + let firstByte = await this.readExact(1, READ_TIMEOUT_MS); + if (firstByte[0] === ACK) { + firstByte = await this.readExact(1, READ_TIMEOUT_MS); + } + image[FT70_ID_BLOCK_SIZE] = firstByte[0]; + + let received = 1; + while (received < FT70_DATA_BLOCK_SIZE) { + const step = Math.min(FT70_CHUNK_SIZE, FT70_DATA_BLOCK_SIZE - received); + const chunk = await this.readExact(step, READ_TIMEOUT_MS); + image.set(chunk, FT70_ID_BLOCK_SIZE + received); + received += step; + onProgress?.(Math.round((received / FT70_DATA_BLOCK_SIZE) * 100), `Reading ${received}/${FT70_DATA_BLOCK_SIZE} bytes`); + } + + return image; + } + + /** + * Write the full memory image to the radio (must already be in clone + * receive mode). Reports progress via onProgress(0-100). + */ + async writeImage(image: Uint8Array, onProgress?: (pct: number, message: string) => void): Promise { + if (image.length !== FT70_ID_BLOCK_SIZE + FT70_DATA_BLOCK_SIZE) { + throw new Error(`FT-70 image must be ${FT70_ID_BLOCK_SIZE + FT70_DATA_BLOCK_SIZE} bytes`); + } + + // ID block: write, then expect either a bare ACK, or (echoing cable) the + // 10-byte echo of what we just sent followed by the real ACK. + await this.write(image.subarray(0, FT70_ID_BLOCK_SIZE)); + const first = await this.readExact(1, ACK_TIMEOUT_MS); + if (first[0] !== ACK) { + const rest = await this.readExact(FT70_ID_BLOCK_SIZE, ACK_TIMEOUT_MS); + if (rest[rest.length - 1] !== ACK) { + throw new Error('Radio did not acknowledge ID block'); + } + } + + // Data block: stream in paced chunks, no per-chunk ack. + let sent = 0; + while (sent < FT70_DATA_BLOCK_SIZE) { + const step = Math.min(FT70_CHUNK_SIZE, FT70_DATA_BLOCK_SIZE - sent); + await this.write(image.subarray(FT70_ID_BLOCK_SIZE + sent, FT70_ID_BLOCK_SIZE + sent + step)); + sent += step; + onProgress?.(Math.round((sent / FT70_DATA_BLOCK_SIZE) * 100), `Writing ${sent}/${FT70_DATA_BLOCK_SIZE} bytes`); + await this.delay(WRITE_CHUNK_DELAY_MS); + } + } +} diff --git a/src/radios/ft70/constants.ts b/src/radios/ft70/constants.ts new file mode 100644 index 0000000..7f0249e --- /dev/null +++ b/src/radios/ft70/constants.ts @@ -0,0 +1,108 @@ +/** + * Constants for the Yaesu FT-70D (ADMS-10 / generic Yaesu clone protocol). + * Layout derived from CHIRP chirp/drivers/ft70.py. + * + * Unlike the FT-65/FT-4 family (SCU-35, PROGRAM/QX handshake), the FT-70 uses + * Yaesu's generic block-clone protocol (see chirp/drivers/yaesu_clone.py): + * the radio is put into clone mode manually by the user (AMS+power, then + * BAND for download / MODE for upload); software just streams an ID block + * followed by the full memory image. + */ + +export const FT70_BAUD_RATE = 38400; + +/** + * WebSerial receive buffer. Must hold the radio's entire one-shot clone stream: + * the radio starts sending the instant the user presses [BAND] — before they + * click Continue in the app — and Chrome's default buffer is only 255 bytes. + */ +export const FT70_RX_BUFFER_SIZE = 256 * 1024; + +/** Image is split into two clone blocks: a 10-byte ID block, then the rest. */ +export const FT70_ID_BLOCK_SIZE = 10; +export const FT70_DATA_BLOCK_SIZE = 65217; +export const FT70_MEM_SIZE = FT70_ID_BLOCK_SIZE + FT70_DATA_BLOCK_SIZE; // 65227 bytes + +/** Chunk size used when streaming the (large) data block, for pacing + progress. */ +export const FT70_CHUNK_SIZE = 1024; + +/** First 5 bytes of the ID block — radio model identifier. */ +export const FT70_MODEL_ID = 'AH51G'; + +export const FT70_MAX_CHANNELS = 900; +export const FT70_CHANNEL_SIZE = 32; // bytes per memory entry + +/** Memory region offsets (absolute, within the full 65227-byte image). */ +export const FT70_ADDR_FLAGS = 0x280a; // flag[900], 1 byte/channel +export const FT70_ADDR_CHANNELS = 0x2d4a; // memory[900], 32 bytes/channel +export const FT70_ADDR_MYCALL = 0xced0; // callsign[10] + u16 charset +export const FT70_ADDR_CHECKSUM = 0xfeca; // u8, sum of bytes [0x0000, 0xfec9] mod 256 + +/** Settings block offsets. */ +export const FT70_ADDR_OPENING_MESSAGE = 0x047e; // unknown1(1) flag(1) unknown2(2) message[6] +export const FT70_ADDR_SQUELCH = 0x049a; // u8: unknown:4, squelch:4 +export const FT70_ADDR_FIRST_SETTINGS = 0x04ba; // 6 bytes +export const FT70_ADDR_BEEP_SETTINGS = 0x04c0; // 2 bytes +export const FT70_ADDR_SCAN_SETTINGS = 0x04ce; // 47 bytes +export const FT70_ADDR_SCAN_SETTINGS_2 = 0x06b6; // 1 byte: unknown:3, volume:5 +export const FT70_ADDR_DIGITAL_SETTINGS_MORE = 0xcf30; // 8 bytes +export const FT70_ADDR_DIGITAL_SETTINGS = 0xcf7c; // 10 bytes + +/** Channel memory[] field offsets (within the 32-byte slot). */ +export const MEM = { + FLAGS1: 0, // display_tag:1, unknown0:1, deviation:1, clock_shift:1, unknown1:4 + MODE_DUPLEX: 1, // mode:2, duplex:2, tune_step:4 + FREQ: 2, // bbcd[3], kHz + FLAGS2: 5, // power:2, unknown2:1, ams:1, tone_mode:4 + CHARSETBITS: 6, // [2] + LABEL: 8, // char[6] + // unknown7[10] at 14..23 + OFFSET: 24, // bbcd[3], kHz + TONE: 27, // unknown:2, tone:6 + DCS: 28, // unknown:1, dcs:7 + // unknown9 at 29 + FLAGS3: 30, // ams_on_dn_vw_fm:2, unknown8_3:1, unknown8_4:1, smeter:4 + FLAGS4: 31, // unknown10:2, att:1, auto_step:1, auto_mode:1, unknown11:2, bell:1 +} as const; + +/** mode field values (memory.mode, 2 bits). */ +export const FT70_MODES = ['FM', 'AM', 'NFM'] as const; + +/** duplex field values (memory.duplex, 2 bits). */ +export const FT70_DUPLEX = ['', '-', '+', 'split'] as const; + +/** tune_step field values, in kHz (0 = "auto"). */ +export const FT70_STEPS: readonly number[] = [5, 6.25, 0, 10, 12.5, 15, 20, 25, 50, 100]; + +/** tone_mode field values (memory.tone_mode, 4 bits). */ +export const FT70_TMODES = ['', 'Tone', 'TSQL', 'DTCS'] as const; + +/** + * CTCSS tone table: index -> Hz (0 = off). Matches CHIRP's chirp_common.TONES. + * Same table as the FT-65; kept local so ft70 has no cross-radio import. + */ +export const FT70_CTCSS_TONES: readonly number[] = [ + 67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5, 94.8, 97.4, + 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3, 131.8, 136.5, + 141.3, 146.2, 151.4, 156.7, 159.8, 162.2, 165.5, 167.9, 171.3, 173.8, + 177.3, 179.9, 183.5, 186.2, 189.9, 192.8, 196.6, 199.5, 203.5, 206.5, + 210.7, 218.1, 225.7, 229.1, 233.6, 241.8, 250.3, 254.1, +]; + +/** + * DCS code table: index -> code number. Matches CHIRP's chirp_common.DTCS_CODES. + */ +export const FT70_DCS_CODES: readonly number[] = [ + 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, +]; + +/** power field values (memory.power, 2 bits): 3=Hi, 2=Mid, 1=Low. */ +export const FT70_POWER_LEVELS: Record = { + 3: 'High', 2: 'Medium', 1: 'Low', +}; diff --git a/src/radios/ft70/descriptor.ts b/src/radios/ft70/descriptor.ts new file mode 100644 index 0000000..9ac5288 --- /dev/null +++ b/src/radios/ft70/descriptor.ts @@ -0,0 +1,18 @@ +/** + * RadioDescriptor for the Yaesu FT-70D (ADMS-10 clone cable, generic Yaesu block protocol). + */ +import type { RadioDescriptor } from '../types'; +import { FT70Protocol } from './protocol'; +import { FT70_CAPS } from './capabilities'; +import { FT70_SETTINGS_PROFILE } from './settingsProfile'; + +export const FT70_DESCRIPTOR: RadioDescriptor = { + modelIds: ['FT-70', 'FT-70D', 'FT-70DR', 'FT-70DE'], + label: 'FT-70D', + icon: '📻', + group: 'Yaesu', + supportsBle: false, + protocolFactory: () => new FT70Protocol(), + capabilities: FT70_CAPS, + settingsProfile: FT70_SETTINGS_PROFILE, +}; diff --git a/src/radios/ft70/protocol.ts b/src/radios/ft70/protocol.ts new file mode 100644 index 0000000..c5aaf8b --- /dev/null +++ b/src/radios/ft70/protocol.ts @@ -0,0 +1,134 @@ +/** + * FT70Protocol: RadioProtocol for the Yaesu FT-70D. + * Analog-only (C4FM digital decoded as FM here), 900 channels, serial via + * the radio's USB programming cable using the generic Yaesu clone protocol. + * + * Unlike FT-65 (which enters/exits clone mode per operation via PROGRAM/END), + * the FT-70 expects the user to manually arm clone mode on the radio before + * each Read/Write; the connection just streams the ID block + full image. + */ + +import type { RadioInfo } from '../../types/radio'; +import type { Channel, RadioSettings } from '../../models'; +import type { Ft70Settings } from '../../types/ft70Settings'; +import { BaseAnalogProtocol } from '../shared/BaseProtocols'; +import { FT70Connection, openFT70Port, type FT70SerialPort } from './connection'; +import { FT70_MEM_SIZE, FT70_MODEL_ID } from './constants'; +import { parseAllChannels, encodeChannel, clearChannelRegions, applyChecksum } from './structures'; +import { parseFt70Settings, writeFt70Settings } from './settingsFormat'; + +export class FT70Protocol extends BaseAnalogProtocol { + /** Settings are buffered into the memory image and uploaded by writeChannels — + * the connection hook must call writeRadioSettings before writeChannels. */ + readonly bufferedSettingsWrite = true; + + private conn: FT70Connection | null = null; + private port: FT70SerialPort | null = null; + private cachedImage: Uint8Array | null = null; + private pendingSettings: Ft70Settings | null = null; + + getMemoryImage(): Uint8Array | null { + return this.cachedImage; + } + + setMemoryImage(image: Uint8Array): void { + this.cachedImage = new Uint8Array(image); + } + + async connect( + portOrOptions?: string | { forcePortSelection?: boolean; transport?: string } + ): Promise { + const opts = typeof portOrOptions === 'object' ? portOrOptions : {}; + const forceSelection = opts.forcePortSelection ?? false; + + this.port = await openFT70Port(forceSelection); + const conn = new FT70Connection(); + await conn.open(this.port); + this.conn = conn; + } + + async disconnect(): Promise { + this.cachedImage = null; + this.pendingSettings = null; + if (this.conn) { + await this.conn.close(); + this.conn = null; + } + this.port = null; + } + + isConnected(): boolean { + return this.conn !== null; + } + + async getRadioInfo(): Promise { + return { + model: 'FT-70D', + firmware: '', + buildDate: '', + memoryLayout: { configStart: 0x0000, configEnd: FT70_MEM_SIZE - 1 }, + }; + } + + async readChannels(): Promise { + if (!this.conn) throw new Error('Not connected'); + + const image = await this.conn.readImage((pct, msg) => this.onProgress?.(pct, msg)); + + const idStr = String.fromCharCode(...image.subarray(0, FT70_MODEL_ID.length)); + if (idStr !== FT70_MODEL_ID) { + throw new Error( + `Radio ID mismatch. Expected "${FT70_MODEL_ID}", got "${idStr}". Wrong model selected, or radio not in clone-send mode?` + ); + } + + this.cachedImage = image; + return parseAllChannels(image); + } + + async writeChannels(channels: Channel[]): Promise { + if (!this.conn) throw new Error('Not connected'); + if (!this.cachedImage) { + // The write streams the full memory image; without a read image every + // non-channel byte (settings, APRS, GM, banks, DTMF) would be written as zero. + throw new Error('Read the radio first. Writing needs the memory image from a read to preserve radio settings.'); + } + + // Start from the cached read image so non-channel regions are preserved + const image = new Uint8Array(FT70_MEM_SIZE); + image.set(this.cachedImage); + + if (this.pendingSettings) { + writeFt70Settings(image, this.pendingSettings); + this.pendingSettings = null; + } + + clearChannelRegions(image); + for (const ch of channels) { + if (ch.number >= 1 && ch.number <= 900) { + encodeChannel(image, ch); + } + } + + applyChecksum(image); + + await this.conn.writeImage(image, (pct, msg) => this.onProgress?.(pct, msg)); + + // The uploaded image is the radio's new state — it becomes the cache baseline + // for subsequent writes in this session. + this.cachedImage = image; + } + + override async readRadioSettings(): Promise { + if (!this.cachedImage) return null; + const radioSpecific = parseFt70Settings(this.cachedImage); + if (!radioSpecific) return null; + return { radioSpecific } as unknown as RadioSettings; + } + + override async writeRadioSettings(settings: RadioSettings): Promise { + const radioSpecific = settings.radioSpecific as Ft70Settings | undefined; + if (!radioSpecific) return; + this.pendingSettings = radioSpecific; + } +} diff --git a/src/radios/ft70/settingsFormat.ts b/src/radios/ft70/settingsFormat.ts new file mode 100644 index 0000000..ea019e4 --- /dev/null +++ b/src/radios/ft70/settingsFormat.ts @@ -0,0 +1,211 @@ +/** + * Parse/encode the FT-70D settings blocks. Layout from CHIRP chirp/drivers/ft70.py. + * Byte offsets are absolute (within the full 65227-byte image). + */ +import type { Ft70Settings } from '../../types/ft70Settings'; +import { + FT70_ADDR_OPENING_MESSAGE, FT70_ADDR_SQUELCH, FT70_ADDR_FIRST_SETTINGS, + FT70_ADDR_BEEP_SETTINGS, FT70_ADDR_SCAN_SETTINGS, FT70_ADDR_SCAN_SETTINGS_2, + FT70_ADDR_MYCALL, FT70_ADDR_DIGITAL_SETTINGS_MORE, FT70_ADDR_DIGITAL_SETTINGS, + FT70_MEM_SIZE, +} from './constants'; + +// Relative offsets within scan_settings (base FT70_ADDR_SCAN_SETTINGS). +const SS = { + LCD_DIMMER: 0, DTMF_DELAY: 1, LAMP: 6, LOCK: 7, MIC_GAIN: 9, DW_INTERVAL: 11, + PTT_DELAY: 12, RX_SAVE: 13, SCAN_RESTART: 14, TOT: 22, + BYTE_A: 26, // vfo_mode:1 unknown:1 scan_lamp:1 unknown:1 ars:1 dtmf_speed:1 unknown:1 dtmf_mode:1 + BYTE_B: 27, // busy_led:1 unknown:1 unknown:1 bclo:1 beep_edge:1 unknown:1 unknown:1 unknown:1 + BYTE_C: 28, // unknown x5 password:1 home_rev:1 moni:1 + BYTE_D: 29, // gm_interval:4 unknown:4 + BYTE_E: 31, // unknown x4 home_vfo:1 unknown:1 unknown:1 dw_rt:1 +} as const; + +// Relative offsets within first_settings (base FT70_ADDR_FIRST_SETTINGS). +const FS = { SCAN_RESUME: 0, DW_RESUME_INTERVAL: 1, APO: 3, GM_RING: 4 } as const; + +function clamp(v: number, max: number): number { + return Math.min(Math.max(0, v), max); +} + +function decodeAscii(s: Uint8Array, off: number, len: number): string { + let out = ''; + for (let i = 0; i < len; i++) { + const b = s[off + i]; + if (b === 0xff || b === 0x00) break; + out += String.fromCharCode(b); + } + return out.trimEnd(); +} + +function encodeAscii(s: Uint8Array, off: number, len: number, value: string): void { + s.fill(0xff, off, off + len); + const capped = value.slice(0, len); + for (let i = 0; i < capped.length; i++) s[off + i] = capped.charCodeAt(i) & 0xff; +} + +export function parseFt70Settings(image: Uint8Array): Ft70Settings | null { + if (image.length < FT70_MEM_SIZE) return null; + + const openingMsg = image; + const squelch = image[FT70_ADDR_SQUELCH] & 0x0f; + const first = image; + const beep = image; + const scan = image; + const scan2 = image; + const digMore = image; + const dig = image; + + const byteA = scan[FT70_ADDR_SCAN_SETTINGS + SS.BYTE_A]; + const byteB = scan[FT70_ADDR_SCAN_SETTINGS + SS.BYTE_B]; + const byteC = scan[FT70_ADDR_SCAN_SETTINGS + SS.BYTE_C]; + const byteD = scan[FT70_ADDR_SCAN_SETTINGS + SS.BYTE_D]; + const byteE = scan[FT70_ADDR_SCAN_SETTINGS + SS.BYTE_E]; + + const rawDigitalPopup = digMore[FT70_ADDR_DIGITAL_SETTINGS_MORE + 7]; + + return { + openingMessageMode: clamp(openingMsg[FT70_ADDR_OPENING_MESSAGE + 1], 2), + openingMessageText: decodeAscii(openingMsg, FT70_ADDR_OPENING_MESSAGE + 4, 6), + lcdDimmer: clamp(scan[FT70_ADDR_SCAN_SETTINGS + SS.LCD_DIMMER], 5), + + squelch, + volume: scan2[FT70_ADDR_SCAN_SETTINGS_2] & 0x1f, + + apo: clamp(first[FT70_ADDR_FIRST_SETTINGS + FS.APO] & 0x1f, 24), + rxSave: clamp(scan[FT70_ADDR_SCAN_SETTINGS + SS.RX_SAVE], 36), + + scanResume: clamp(first[FT70_ADDR_FIRST_SETTINGS + FS.SCAN_RESUME] & 0x1f, 18), + scanRestart: clamp(scan[FT70_ADDR_SCAN_SETTINGS + SS.SCAN_RESTART], 27), + scanLamp: ((byteA >> 5) & 1) !== 0, + ars: ((byteA >> 3) & 1) !== 0, + + dwResumeInterval: clamp(first[FT70_ADDR_FIRST_SETTINGS + FS.DW_RESUME_INTERVAL] & 0x1f, 18), + dwInterval: clamp(scan[FT70_ADDR_SCAN_SETTINGS + SS.DW_INTERVAL], 27), + dwRt: (byteE & 1) !== 0, + homeVfo: ((byteE >> 3) & 1) !== 0, + + beepLevel: clamp(beep[FT70_ADDR_BEEP_SETTINGS] & 0x07, 6), + beepSelect: clamp(beep[FT70_ADDR_BEEP_SETTINGS + 1] & 0x03, 2), + beepEdge: ((byteB >> 3) & 1) !== 0, + + lock: clamp(scan[FT70_ADDR_SCAN_SETTINGS + SS.LOCK], 6), + lamp: clamp(scan[FT70_ADDR_SCAN_SETTINGS + SS.LAMP], 10), + homeRev: ((byteC >> 1) & 1) !== 0, + moni: (byteC & 1) !== 0, + + bclo: ((byteB >> 4) & 1) !== 0, + busyLed: ((byteB >> 7) & 1) !== 0, + micGain: clamp(scan[FT70_ADDR_SCAN_SETTINGS + SS.MIC_GAIN], 8), + pttDelay: clamp(scan[FT70_ADDR_SCAN_SETTINGS + SS.PTT_DELAY], 4), + tot: clamp(scan[FT70_ADDR_SCAN_SETTINGS + SS.TOT], 20), + vfoMode: ((byteA >> 7) & 1) !== 0, + + dtmfMode: (byteA & 1) !== 0, + dtmfDelay: clamp(scan[FT70_ADDR_SCAN_SETTINGS + SS.DTMF_DELAY], 4), + dtmfSpeed: ((byteA >> 2) & 1) !== 0, + + gmRing: clamp(first[FT70_ADDR_FIRST_SETTINGS + FS.GM_RING] & 0x03, 2), + gmInterval: clamp((byteD >> 4) & 0x0f, 2), + + myCall: decodeAscii(image, FT70_ADDR_MYCALL, 10), + amsTxMode: clamp(dig[FT70_ADDR_DIGITAL_SETTINGS] & 0x03, 2), + standbyBeep: (dig[FT70_ADDR_DIGITAL_SETTINGS + 2] & 1) !== 0, + rxDgId: clamp(dig[FT70_ADDR_DIGITAL_SETTINGS + 6], 99), + txDgId: clamp(dig[FT70_ADDR_DIGITAL_SETTINGS + 7], 99), + vwMode: (dig[FT70_ADDR_DIGITAL_SETTINGS + 8] & 1) !== 0, + digitalPopup: rawDigitalPopup === 0 ? 0 : clamp(rawDigitalPopup - 9, 9), + }; +} + +export function writeFt70Settings(image: Uint8Array, settings: Partial): void { + if (image.length < FT70_MEM_SIZE) return; + + const setBit = (addr: number, bit: number, value: boolean) => { + if (value) image[addr] |= (1 << bit); + else image[addr] &= ~(1 << bit); + }; + + if (settings.openingMessageMode != null) image[FT70_ADDR_OPENING_MESSAGE + 1] = clamp(settings.openingMessageMode, 2); + if (settings.openingMessageText != null) encodeAscii(image, FT70_ADDR_OPENING_MESSAGE + 4, 6, settings.openingMessageText); + if (settings.lcdDimmer != null) image[FT70_ADDR_SCAN_SETTINGS + SS.LCD_DIMMER] = clamp(settings.lcdDimmer, 5); + + if (settings.squelch != null) { + image[FT70_ADDR_SQUELCH] = (image[FT70_ADDR_SQUELCH] & 0xf0) | clamp(settings.squelch, 15); + } + if (settings.volume != null) { + const addr = FT70_ADDR_SCAN_SETTINGS_2; + image[addr] = (image[addr] & 0xe0) | clamp(settings.volume, 31); + } + + if (settings.apo != null) { + const addr = FT70_ADDR_FIRST_SETTINGS + FS.APO; + image[addr] = (image[addr] & 0xe0) | clamp(settings.apo, 24); + } + if (settings.rxSave != null) image[FT70_ADDR_SCAN_SETTINGS + SS.RX_SAVE] = clamp(settings.rxSave, 36); + + if (settings.scanResume != null) { + const addr = FT70_ADDR_FIRST_SETTINGS + FS.SCAN_RESUME; + image[addr] = (image[addr] & 0xe0) | clamp(settings.scanResume, 18); + } + if (settings.scanRestart != null) image[FT70_ADDR_SCAN_SETTINGS + SS.SCAN_RESTART] = clamp(settings.scanRestart, 27); + if (settings.scanLamp != null) setBit(FT70_ADDR_SCAN_SETTINGS + SS.BYTE_A, 5, settings.scanLamp); + if (settings.ars != null) setBit(FT70_ADDR_SCAN_SETTINGS + SS.BYTE_A, 3, settings.ars); + + if (settings.dwResumeInterval != null) { + const addr = FT70_ADDR_FIRST_SETTINGS + FS.DW_RESUME_INTERVAL; + image[addr] = (image[addr] & 0xe0) | clamp(settings.dwResumeInterval, 18); + } + if (settings.dwInterval != null) image[FT70_ADDR_SCAN_SETTINGS + SS.DW_INTERVAL] = clamp(settings.dwInterval, 27); + if (settings.dwRt != null) setBit(FT70_ADDR_SCAN_SETTINGS + SS.BYTE_E, 0, settings.dwRt); + if (settings.homeVfo != null) setBit(FT70_ADDR_SCAN_SETTINGS + SS.BYTE_E, 3, settings.homeVfo); + + if (settings.beepLevel != null) { + const addr = FT70_ADDR_BEEP_SETTINGS; + image[addr] = (image[addr] & 0xf8) | clamp(settings.beepLevel, 6); + } + if (settings.beepSelect != null) { + const addr = FT70_ADDR_BEEP_SETTINGS + 1; + image[addr] = (image[addr] & 0xfc) | clamp(settings.beepSelect, 2); + } + if (settings.beepEdge != null) setBit(FT70_ADDR_SCAN_SETTINGS + SS.BYTE_B, 3, settings.beepEdge); + + if (settings.lock != null) image[FT70_ADDR_SCAN_SETTINGS + SS.LOCK] = clamp(settings.lock, 6); + if (settings.lamp != null) image[FT70_ADDR_SCAN_SETTINGS + SS.LAMP] = clamp(settings.lamp, 10); + if (settings.homeRev != null) setBit(FT70_ADDR_SCAN_SETTINGS + SS.BYTE_C, 1, settings.homeRev); + if (settings.moni != null) setBit(FT70_ADDR_SCAN_SETTINGS + SS.BYTE_C, 0, settings.moni); + + if (settings.bclo != null) setBit(FT70_ADDR_SCAN_SETTINGS + SS.BYTE_B, 4, settings.bclo); + if (settings.busyLed != null) setBit(FT70_ADDR_SCAN_SETTINGS + SS.BYTE_B, 7, settings.busyLed); + if (settings.micGain != null) image[FT70_ADDR_SCAN_SETTINGS + SS.MIC_GAIN] = clamp(settings.micGain, 8); + if (settings.pttDelay != null) image[FT70_ADDR_SCAN_SETTINGS + SS.PTT_DELAY] = clamp(settings.pttDelay, 4); + if (settings.tot != null) image[FT70_ADDR_SCAN_SETTINGS + SS.TOT] = clamp(settings.tot, 20); + if (settings.vfoMode != null) setBit(FT70_ADDR_SCAN_SETTINGS + SS.BYTE_A, 7, settings.vfoMode); + + if (settings.dtmfMode != null) setBit(FT70_ADDR_SCAN_SETTINGS + SS.BYTE_A, 0, settings.dtmfMode); + if (settings.dtmfDelay != null) image[FT70_ADDR_SCAN_SETTINGS + SS.DTMF_DELAY] = clamp(settings.dtmfDelay, 4); + if (settings.dtmfSpeed != null) setBit(FT70_ADDR_SCAN_SETTINGS + SS.BYTE_A, 2, settings.dtmfSpeed); + + if (settings.gmRing != null) { + const addr = FT70_ADDR_FIRST_SETTINGS + FS.GM_RING; + image[addr] = (image[addr] & 0xfc) | clamp(settings.gmRing, 2); + } + if (settings.gmInterval != null) { + const addr = FT70_ADDR_SCAN_SETTINGS + SS.BYTE_D; + image[addr] = (image[addr] & 0x0f) | (clamp(settings.gmInterval, 2) << 4); + } + + if (settings.myCall != null) encodeAscii(image, FT70_ADDR_MYCALL, 10, settings.myCall.toUpperCase()); + if (settings.amsTxMode != null) { + const addr = FT70_ADDR_DIGITAL_SETTINGS; + image[addr] = (image[addr] & 0xfc) | clamp(settings.amsTxMode, 2); + } + if (settings.standbyBeep != null) setBit(FT70_ADDR_DIGITAL_SETTINGS + 2, 0, settings.standbyBeep); + if (settings.rxDgId != null) image[FT70_ADDR_DIGITAL_SETTINGS + 6] = clamp(settings.rxDgId, 99); + if (settings.txDgId != null) image[FT70_ADDR_DIGITAL_SETTINGS + 7] = clamp(settings.txDgId, 99); + if (settings.vwMode != null) setBit(FT70_ADDR_DIGITAL_SETTINGS + 8, 0, settings.vwMode); + if (settings.digitalPopup != null) { + const idx = clamp(settings.digitalPopup, 9); + image[FT70_ADDR_DIGITAL_SETTINGS_MORE + 7] = idx === 0 ? 0 : idx + 9; + } +} diff --git a/src/radios/ft70/settingsProfile.ts b/src/radios/ft70/settingsProfile.ts new file mode 100644 index 0000000..d4cb478 --- /dev/null +++ b/src/radios/ft70/settingsProfile.ts @@ -0,0 +1,140 @@ +/** + * Settings profile for the Yaesu FT-70D. + */ +import type { SettingsProfile } from '../../types/settingsProfile'; + +function opt(values: string[]) { + return values.map((label, i) => ({ value: i, label })); +} + +const APO_OPTIONS = opt(['Off', ...Array.from({ length: 24 }, (_, i) => `${((i + 1) * 0.5).toFixed(1)}h`)]); +const TOT_OPTIONS = opt(['Off', ...Array.from({ length: 20 }, (_, i) => `${((i + 1) * 0.5).toFixed(1)} min`)]); +const RESUME_OPTIONS = opt([ + ...Array.from({ length: 17 }, (_, i) => `${(2.0 + i * 0.5).toFixed(1)}s`), + 'Busy', 'Hold', +]); +const RESTART_OPTIONS = opt([ + ...Array.from({ length: 9 }, (_, i) => `${(0.1 + i * 0.1).toFixed(1)}s`), + ...Array.from({ length: 19 }, (_, i) => `${(1.0 + i * 0.5).toFixed(1)}s`), +]); +const RX_SAVE_OPTIONS = opt([ + 'Off', '0.2s', '.3s', '.4s', '.5s', '.6s', '.7s', '.8s', '.9s', '1.0s', '1.5s', + '2.0s', '2.5s', '3.0s', '3.5s', '4.0s', '4.5s', '5.0s', '5.5s', '6.0s', '6.5s', '7.0s', + '7.5s', '8.0s', '8.5s', '9.0s', '10.0s', '15s', '20s', '25s', '30s', '35s', '40s', '45s', '50s', '55s', '60s', +]); +const LAMP_OPTIONS = opt([...Array.from({ length: 9 }, (_, i) => `Key ${i + 2} sec`), 'Continuous', 'Off']); +const LCD_DIMMER_OPTIONS = opt(Array.from({ length: 6 }, (_, i) => `Level ${i + 1}`)); +const MIC_GAIN_OPTIONS = opt(Array.from({ length: 9 }, (_, i) => `Level ${i + 1}`)); +const VOLUME_OPTIONS = opt(Array.from({ length: 32 }, (_, i) => `Level ${i}`)); +const SQUELCH_OPTIONS = opt(Array.from({ length: 16 }, (_, i) => `Level ${i}`)); +const BEEP_LEVEL_OPTIONS = opt(Array.from({ length: 7 }, (_, i) => `Level ${i + 1}`)); +const BEEP_SELECT_OPTIONS = opt(['Key + Scan', 'Key', 'Off']); +const LOCK_OPTIONS = opt(['Key', 'Dial', 'Key + Dial', 'PTT', 'Key + PTT', 'Dial + PTT', 'All']); +const DTMF_DELAY_OPTIONS = opt(['50 ms', '250 ms', '450 ms', '750 ms', '1000 ms']); +const PTT_DELAY_OPTIONS = opt(['Off', '20 ms', '50 ms', '100 ms', '200 ms']); +const GM_RING_OPTIONS = opt(['Off', 'In Range', 'Always']); +const GM_INTERVAL_OPTIONS = opt(['Long', 'Normal', 'Off']); +const AMS_TX_MODE_OPTIONS = opt(['Auto', 'Digital', 'FM']); +const DG_ID_OPTIONS = opt(Array.from({ length: 100 }, (_, i) => `${i}`)); +const DIG_POPUP_OPTIONS = opt(['Off', '2 sec', '4 sec', '6 sec', '8 sec', '10 sec', '20 sec', '30 sec', '60 sec', 'Continuous']); +const OPENING_MESSAGE_OPTIONS = opt(['Off', 'DC', 'Message']); + +export const FT70_SETTINGS_PROFILE: SettingsProfile = { + radioType: 'FT-70', + sections: [ + { + id: 'basic', + title: 'Basic', + fields: [ + { key: 'radioSpecific.squelch', label: 'Squelch', type: 'select', options: SQUELCH_OPTIONS }, + { key: 'radioSpecific.volume', label: 'Volume', type: 'select', options: VOLUME_OPTIONS }, + { key: 'radioSpecific.apo', label: 'Auto Power Off', type: 'select', options: APO_OPTIONS }, + { key: 'radioSpecific.tot', label: 'Time-Out Timer', type: 'select', options: TOT_OPTIONS }, + { key: 'radioSpecific.rxSave', label: 'RX Battery Save', type: 'select', options: RX_SAVE_OPTIONS }, + { key: 'radioSpecific.bclo', label: 'Busy Channel Lockout', type: 'checkbox' }, + { key: 'radioSpecific.vfoMode', label: 'VFO Mode: Band-limited', type: 'checkbox' }, + ], + }, + { + id: 'audio', + title: 'Audio & Beep', + fields: [ + { key: 'radioSpecific.beepSelect', label: 'Beep', type: 'select', options: BEEP_SELECT_OPTIONS }, + { key: 'radioSpecific.beepLevel', label: 'Beep Level', type: 'select', options: BEEP_LEVEL_OPTIONS }, + { key: 'radioSpecific.beepEdge', label: 'Beep at Band Edge', type: 'checkbox' }, + { key: 'radioSpecific.micGain', label: 'Mic Gain', type: 'select', options: MIC_GAIN_OPTIONS }, + ], + }, + { + id: 'display', + title: 'Display & Indicators', + fields: [ + { key: 'radioSpecific.lcdDimmer', label: 'LCD Dimmer', type: 'select', options: LCD_DIMMER_OPTIONS }, + { key: 'radioSpecific.lamp', label: 'Lamp / Backlight', type: 'select', options: LAMP_OPTIONS }, + { key: 'radioSpecific.busyLed', label: 'Busy LED', type: 'checkbox' }, + { key: 'radioSpecific.lock', label: 'Lock Mode', type: 'select', options: LOCK_OPTIONS }, + { key: 'radioSpecific.openingMessageMode', label: 'Opening Message Mode', type: 'select', options: OPENING_MESSAGE_OPTIONS }, + { key: 'radioSpecific.openingMessageText', label: 'Opening Message Text', type: 'text', maxLength: 6 }, + ], + }, + { + id: 'scan', + title: 'Scan', + fields: [ + { key: 'radioSpecific.scanResume', label: 'Scan Resume', type: 'select', options: RESUME_OPTIONS }, + { key: 'radioSpecific.scanRestart', label: 'Scan Restart Time', type: 'select', options: RESTART_OPTIONS }, + { key: 'radioSpecific.scanLamp', label: 'Scan Lamp', type: 'checkbox' }, + ], + }, + { + id: 'dualwatch', + title: 'Dual Watch', + fields: [ + { key: 'radioSpecific.dwResumeInterval', label: 'Dual Watch Resume', type: 'select', options: RESUME_OPTIONS }, + { key: 'radioSpecific.dwInterval', label: 'Dual Watch Interval', type: 'select', options: RESTART_OPTIONS }, + { key: 'radioSpecific.dwRt', label: 'Priority Channel Revert', type: 'checkbox' }, + { key: 'radioSpecific.homeVfo', label: 'Home -> VFO', type: 'checkbox' }, + { key: 'radioSpecific.homeRev', label: 'HOME/REV Key = Home', type: 'checkbox' }, + { key: 'radioSpecific.moni', label: 'MONI/T-CALL = Tone Call', type: 'checkbox' }, + { key: 'radioSpecific.ars', label: 'Automatic Repeater Shift', type: 'checkbox' }, + ], + }, + { + id: 'ptt', + title: 'PTT', + fields: [ + { key: 'radioSpecific.pttDelay', label: 'PTT Delay', type: 'select', options: PTT_DELAY_OPTIONS }, + ], + }, + { + id: 'dtmf', + title: 'DTMF', + fields: [ + { key: 'radioSpecific.dtmfMode', label: 'DTMF Mode: Auto', type: 'checkbox' }, + { key: 'radioSpecific.dtmfDelay', label: 'DTMF Delay', type: 'select', options: DTMF_DELAY_OPTIONS }, + { key: 'radioSpecific.dtmfSpeed', label: 'DTMF Speed: 100ms', type: 'checkbox' }, + ], + }, + { + id: 'digital', + title: 'Digital (C4FM)', + fields: [ + { key: 'radioSpecific.myCall', label: 'MYCALL', type: 'text', maxLength: 10 }, + { key: 'radioSpecific.amsTxMode', label: 'AMS TX Mode', type: 'select', options: AMS_TX_MODE_OPTIONS }, + { key: 'radioSpecific.standbyBeep', label: 'Standby Beep', type: 'checkbox' }, + { key: 'radioSpecific.vwMode', label: 'VW Mode', type: 'checkbox' }, + { key: 'radioSpecific.rxDgId', label: 'RX DG-ID', type: 'select', options: DG_ID_OPTIONS }, + { key: 'radioSpecific.txDgId', label: 'TX DG-ID', type: 'select', options: DG_ID_OPTIONS }, + { key: 'radioSpecific.digitalPopup', label: 'Digital Popup Time', type: 'select', options: DIG_POPUP_OPTIONS }, + ], + }, + { + id: 'gm', + title: 'Group Monitor', + fields: [ + { key: 'radioSpecific.gmRing', label: 'GM Ring', type: 'select', options: GM_RING_OPTIONS }, + { key: 'radioSpecific.gmInterval', label: 'GM Interval', type: 'select', options: GM_INTERVAL_OPTIONS }, + ], + }, + ], +}; diff --git a/src/radios/ft70/structures.ts b/src/radios/ft70/structures.ts new file mode 100644 index 0000000..1cc63e3 --- /dev/null +++ b/src/radios/ft70/structures.ts @@ -0,0 +1,240 @@ +/** + * Pure parse/encode functions for the FT-70D memory image. + * Layout from CHIRP chirp/drivers/ft70.py MEM_FORMAT. + */ + +import type { Channel, CTCSSDCS } from '../../models/Channel'; +import { + FT70_MAX_CHANNELS, FT70_CHANNEL_SIZE, FT70_ADDR_FLAGS, FT70_ADDR_CHANNELS, + FT70_ADDR_CHECKSUM, MEM, FT70_DUPLEX, + FT70_CTCSS_TONES, FT70_DCS_CODES, FT70_POWER_LEVELS, +} from './constants'; +import { createDefaultChannel } from '../../utils/channelHelpers'; + +// --------------------------------------------------------------------------- +// 3-byte big-endian BCD frequency codec (radio stores frequencies in kHz) +// --------------------------------------------------------------------------- + +/** Decode 3-byte big-endian BCD (6 decimal digits) to kHz. */ +export function decodeBCDkHz(bytes: Uint8Array, offset = 0): number { + let val = 0; + for (let i = 0; i < 3; i++) { + const b = bytes[offset + i]; + val = val * 100 + (b >> 4) * 10 + (b & 0xf); + } + return val; +} + +/** Encode a kHz value to 3-byte big-endian BCD (6 decimal digits). */ +export function encodeBCDkHz(khz: number, out: Uint8Array, offset = 0): void { + let val = Math.round(khz); + for (let i = 2; i >= 0; i--) { + const lo = val % 10; val = Math.floor(val / 10); + const hi = val % 10; val = Math.floor(val / 10); + out[offset + i] = (hi << 4) | lo; + } +} + +// --------------------------------------------------------------------------- +// Checksum: sum of bytes [0x0000, 0xFEC9] mod 256, stored at 0xFECA. +// --------------------------------------------------------------------------- + +export function computeChecksum(image: Uint8Array): number { + let sum = 0; + for (let i = 0; i <= 0xfec9; i++) sum += image[i]; + return sum & 0xff; +} + +export function applyChecksum(image: Uint8Array): void { + image[FT70_ADDR_CHECKSUM] = computeChecksum(image); +} + +// --------------------------------------------------------------------------- +// flag[] bitfield (1 byte/channel): nosubvfo:1, unknown:3, pskip:1, skip:1, used:1, valid:1 +// --------------------------------------------------------------------------- + +function flagOffset(idx: number): number { + return FT70_ADDR_FLAGS + idx; +} + +function isValid(byte: number): boolean { return (byte & 0x01) !== 0; } +function isUsed(byte: number): boolean { return (byte & 0x02) !== 0; } +function isSkip(byte: number): boolean { return (byte & 0x04) !== 0; } +function isPskip(byte: number): boolean { return (byte & 0x08) !== 0; } + +function isNoSubVfo(rxFrequencyMhz: number): boolean { + const hz = rxFrequencyMhz * 1_000_000; + return hz < 30_000_000 || (hz > 88_000_000 && hz < 108_000_000) || hz > 580_000_000; +} + +// --------------------------------------------------------------------------- +// Name codec: 6-byte ASCII label, 0xFF padded +// --------------------------------------------------------------------------- + +const LABEL_LEN = 6; + +function decodeLabel(image: Uint8Array, slotBase: number): string { + let name = ''; + for (let i = 0; i < LABEL_LEN; i++) { + const b = image[slotBase + MEM.LABEL + i]; + if (b === 0xff) break; + name += String.fromCharCode(b); + } + return name.trimEnd(); +} + +function encodeLabel(image: Uint8Array, slotBase: number, name: string): void { + image.fill(0xff, slotBase + MEM.LABEL, slotBase + MEM.LABEL + LABEL_LEN); + const capped = name.slice(0, LABEL_LEN); + for (let i = 0; i < capped.length; i++) { + image[slotBase + MEM.LABEL + i] = capped.charCodeAt(i) & 0xff; + } +} + +// --------------------------------------------------------------------------- +// Tone / DCS — FT-70 has a single tone+dcs pair per channel (no independent +// TX/RX codes); tone_mode selects which side(s) actually use it: +// 0 '' no tone +// 1 Tone TX-encode only +// 2 TSQL TX-encode + RX-decode, same CTCSS tone +// 3 DTCS TX+RX, same DCS code (no polarity support on this radio) +// --------------------------------------------------------------------------- + +function decodeTone(toneMode: number, toneIdx: number, dcsIdx: number): { rx: CTCSSDCS; tx: CTCSSDCS } { + switch (toneMode) { + case 1: { + const hz = FT70_CTCSS_TONES[toneIdx]; + return { tx: hz != null ? { type: 'CTCSS', value: hz } : { type: 'None' }, rx: { type: 'None' } }; + } + case 2: { + const hz = FT70_CTCSS_TONES[toneIdx]; + const tone: CTCSSDCS = hz != null ? { type: 'CTCSS', value: hz } : { type: 'None' }; + return { tx: tone, rx: tone }; + } + case 3: { + const code = FT70_DCS_CODES[dcsIdx]; + const dcs: CTCSSDCS = code != null ? { type: 'DCS', value: code, polarity: 'N' } : { type: 'None' }; + return { tx: dcs, rx: dcs }; + } + default: + return { tx: { type: 'None' }, rx: { type: 'None' } }; + } +} + +/** Returns { toneMode, toneIdx, dcsIdx }. Best effort when tx/rx tones differ (hardware has only one code). */ +function encodeTone(tx: CTCSSDCS, rx: CTCSSDCS): { toneMode: number; toneIdx: number; dcsIdx: number } { + const hasTx = tx.type !== 'None'; + const hasRx = rx.type !== 'None'; + + if (tx.type === 'DCS' || rx.type === 'DCS') { + const code = (tx.type === 'DCS' ? tx.value : rx.value) ?? 0; + const dcsIdx = FT70_DCS_CODES.findIndex((c) => c === code); + return { toneMode: 3, toneIdx: 0, dcsIdx: dcsIdx >= 0 ? dcsIdx : 0 }; + } + + const ctcss = hasTx ? tx : hasRx ? rx : null; + if (!ctcss || ctcss.value == null) return { toneMode: 0, toneIdx: 0, dcsIdx: 0 }; + const toneIdx = FT70_CTCSS_TONES.findIndex((t) => Math.abs(t - ctcss.value!) < 0.05); + const idx = toneIdx >= 0 ? toneIdx : 0; + // TSQL when both sides carry a tone (or only RX does — hardware has no RX-only mode). + const toneMode = hasTx && !hasRx ? 1 : 2; + return { toneMode, toneIdx: idx, dcsIdx: 0 }; +} + +// --------------------------------------------------------------------------- +// Channel parse / encode +// --------------------------------------------------------------------------- + +export function parseChannel(image: Uint8Array, idx: number): Channel | null { + const flag = image[flagOffset(idx)]; + if (!isValid(flag) || !isUsed(flag)) return null; + + const slotBase = FT70_ADDR_CHANNELS + idx * FT70_CHANNEL_SIZE; + const s = image; + + const flags1 = s[slotBase + MEM.FLAGS1]; + const deviation = (flags1 >> 5) & 1; // 0 = wide(FM), 1 = narrow(NFM) + + const modeDuplex = s[slotBase + MEM.MODE_DUPLEX]; + const duplexIdx = (modeDuplex >> 4) & 0x3; + + const rxKhz = decodeBCDkHz(s, slotBase + MEM.FREQ); + const offsetKhz = decodeBCDkHz(s, slotBase + MEM.OFFSET); + const rxMhz = rxKhz / 1000; + + let txMhz: number; + switch (FT70_DUPLEX[duplexIdx]) { + case '+': txMhz = rxMhz + offsetKhz / 1000; break; + case '-': txMhz = rxMhz - offsetKhz / 1000; break; + case 'split': txMhz = offsetKhz / 1000; break; + default: txMhz = rxMhz; + } + + const flags2 = s[slotBase + MEM.FLAGS2]; + const power = (flags2 >> 6) & 0x3; + const toneMode = flags2 & 0xf; + + const toneIdx = s[slotBase + MEM.TONE] & 0x3f; + const dcsIdx = s[slotBase + MEM.DCS] & 0x7f; + const { tx: txCtcssDcs, rx: rxCtcssDcs } = decodeTone(toneMode, toneIdx, dcsIdx); + + return createDefaultChannel({ + number: idx + 1, + name: decodeLabel(image, slotBase), + rxFrequency: rxMhz, + txFrequency: txMhz, + mode: 'Analog', + bandwidth: deviation ? '12.5kHz' : '25kHz', + power: FT70_POWER_LEVELS[power] ?? 'High', + rxCtcssDcs, + txCtcssDcs, + scanAdd: !(isSkip(flag) || isPskip(flag)), + }); +} + +/** Write one channel back into the memory image. Caller must clear regions first for deletions to take. */ +export function encodeChannel(image: Uint8Array, ch: Channel): void { + const idx = ch.number - 1; + const slotBase = FT70_ADDR_CHANNELS + idx * FT70_CHANNEL_SIZE; + + image.fill(0x00, slotBase, slotBase + FT70_CHANNEL_SIZE); + + const diffKhz = Math.round((ch.txFrequency - ch.rxFrequency) * 1000); + const duplexIdx = Math.abs(diffKhz) < 1 ? 0 : diffKhz > 0 ? 2 : 1; // '', '+', '-' + + encodeBCDkHz(Math.round(ch.rxFrequency * 1000), image, slotBase + MEM.FREQ); + encodeBCDkHz(Math.abs(diffKhz), image, slotBase + MEM.OFFSET); + + image[slotBase + MEM.MODE_DUPLEX] = (duplexIdx & 0x3) << 4; // mode=FM(0), tune_step=0(auto) + + const deviation = ch.bandwidth === '12.5kHz' ? 1 : 0; + image[slotBase + MEM.FLAGS1] = (1 << 7) | (deviation << 5); // display_tag=1 (show name) + + const powerMap: Record = { Low: 1, Medium: 2, High: 3 }; + const { toneMode, toneIdx, dcsIdx } = encodeTone(ch.txCtcssDcs, ch.rxCtcssDcs); + image[slotBase + MEM.FLAGS2] = ((powerMap[ch.power] ?? 3) << 6) | (toneMode & 0xf); + image[slotBase + MEM.TONE] = toneIdx & 0x3f; + image[slotBase + MEM.DCS] = dcsIdx & 0x7f; + + encodeLabel(image, slotBase, ch.name); + + const flag = (isNoSubVfo(ch.rxFrequency) ? 0x80 : 0) | 0x02 /* used */ | 0x01 /* valid */ + | (ch.scanAdd === false ? 0x04 /* skip */ : 0); + image[flagOffset(idx)] = flag; +} + +/** Zero out flag[] and memory[] regions before re-encoding, so deleted channels don't leave ghost entries. */ +export function clearChannelRegions(image: Uint8Array): void { + image.fill(0x00, FT70_ADDR_FLAGS, FT70_ADDR_FLAGS + FT70_MAX_CHANNELS); + image.fill(0x00, FT70_ADDR_CHANNELS, FT70_ADDR_CHANNELS + FT70_MAX_CHANNELS * FT70_CHANNEL_SIZE); +} + +/** Parse all 900 channel slots from a full memory image. */ +export function parseAllChannels(image: Uint8Array): Channel[] { + const channels: Channel[] = []; + for (let i = 0; i < FT70_MAX_CHANNELS; i++) { + const ch = parseChannel(image, i); + if (ch) channels.push(ch); + } + return channels; +} diff --git a/src/radios/index.ts b/src/radios/index.ts index a9f9ad2..f85f07d 100644 --- a/src/radios/index.ts +++ b/src/radios/index.ts @@ -7,6 +7,7 @@ import type { RadioDescriptor } from './types'; import { DM32UV_DESCRIPTOR } from './dm32uv/descriptor'; import { UV5RMINI_DESCRIPTOR } from './uv5rmini/descriptor'; import { FT65_DESCRIPTOR, FT4_DESCRIPTOR, FT25R_DESCRIPTOR } from './ft65/descriptor'; +import { FT70_DESCRIPTOR } from './ft70/descriptor'; export type ProtocolFactory = () => RadioProtocol; @@ -17,6 +18,7 @@ export const RADIO_DESCRIPTORS: readonly RadioDescriptor[] = [ FT65_DESCRIPTOR, FT4_DESCRIPTOR, FT25R_DESCRIPTOR, + FT70_DESCRIPTOR, ]; /** Backward compatibility: same radio, multiple model IDs. */ diff --git a/src/radios/shared/BaseSerialConnection.ts b/src/radios/shared/BaseSerialConnection.ts index 54151af..5c2731d 100644 --- a/src/radios/shared/BaseSerialConnection.ts +++ b/src/radios/shared/BaseSerialConnection.ts @@ -7,7 +7,7 @@ export interface SerialLikePort { readonly readable: ReadableStream | null; readonly writable: WritableStream | null; - open(options: { baudRate: number }): Promise; + open(options: { baudRate: number; bufferSize?: number }): Promise; close(): Promise; } diff --git a/src/radios/shared/serialPort.ts b/src/radios/shared/serialPort.ts index 919e37b..b403b90 100644 --- a/src/radios/shared/serialPort.ts +++ b/src/radios/shared/serialPort.ts @@ -1,13 +1,19 @@ import type { SerialLikePort } from './BaseSerialConnection'; /** - * Request or reuse a Web Serial port and open it at the given baud rate. + * Request a Web Serial port and open it with the given parameters. * Shared by all serial radios; each radio's connection file wraps this * with a named function that supplies its own baud rate constant. + * + * `bufferSize` sets Chrome's receive buffer (default is only 255 bytes). + * Radios that stream unsolicited data the moment the user presses a key on + * the radio (FT-70 clone mode) need one large enough to hold everything that + * arrives before the app starts reading. */ export async function requestSerialPort( baudRate: number, - forceSelection = false + forceSelection = false, + bufferSize?: number ): Promise { if (!('serial' in navigator)) throw new Error('Web Serial API not supported. Use Chrome/Edge.'); const nav = (navigator as any).serial; @@ -15,16 +21,17 @@ export async function requestSerialPort( ? await nav.requestPort() : ((await nav.getPorts())[0] ?? (await nav.requestPort())); - // The port may still be open from a previous operation (e.g. a read that - // errored mid-transfer). Reuse it if its streams are free; calling open() - // on an already-open port throws InvalidStateError. + // The port may still be open from a previous operation — possibly at a + // different baud rate or buffer size (e.g. an FT-65 session at 9600 before + // an FT-70 read at 38400). Close and reopen so this radio's parameters + // actually apply; locked streams mean another operation still owns it. if (port.readable && port.writable) { if (port.readable.locked || port.writable.locked) { throw new Error('Serial port is busy from a previous operation. Reconnect the cable or reload the page.'); } - return port; + await port.close(); } - await port.open({ baudRate }); + await port.open({ baudRate, ...(bufferSize != null && { bufferSize }) }); return port; } diff --git a/src/types/ft70Settings.ts b/src/types/ft70Settings.ts new file mode 100644 index 0000000..fea68ea --- /dev/null +++ b/src/types/ft70Settings.ts @@ -0,0 +1,68 @@ +/** + * FT-70D settings (stored in RadioSettings.radioSpecific). Select fields use 0-based index. + * Layout from CHIRP chirp/drivers/ft70.py. Banks, DTMF memories, and programmable keys + * (prog_key1/prog_key2) are not yet implemented — see ADDING_A_RADIO.md gotchas. + */ +export interface Ft70Settings { + // Display / opening message + openingMessageMode: number; // 0=Off, 1=DC, 2=Message + openingMessageText: string; // up to 6 chars + lcdDimmer: number; // 0-5 + + // Squelch / volume + squelch: number; // 0-15 + volume: number; // 0-31 + + // Power management + apo: number; // 0=Off, 1-24 = 0.5h to 12h (0.5h steps) + rxSave: number; // 0-36 (Off, 0.2s..60s — see _RX_SAVE table) + + // Scan + scanResume: number; // 0-18 (2.0s..10.0s step 0.5s, then Busy, Hold) + scanRestart: number; // 0-27 (0.1s..2.0s step 0.1s, then 2.0s..10.0s step 0.5s) + scanLamp: boolean; + ars: boolean; // Automatic Repeater Shift + + // Dual watch + dwResumeInterval: number; // 0-18, same table as scanResume + dwInterval: number; // 0-27, same table as scanRestart + dwRt: boolean; // priority channel revert during dual watch + homeVfo: boolean; // transfer VFO to home channel + + // Beep + beepLevel: number; // 0-6 + beepSelect: number; // 0=Key+Scan, 1=Key, 2=Off + beepEdge: boolean; + + // Lock / keys + lock: number; // 0=Key,1=Dial,2=Key+Dial,3=PTT,4=Key+PTT,5=Dial+PTT,6=All + lamp: number; // 0-10 (2-10 sec, Continuous, Off) + homeRev: boolean; + moni: boolean; // false=Monitor, true=Tone-Call + + // Misc radio behavior + bclo: boolean; // Busy Channel Lockout + busyLed: boolean; + micGain: number; // 0-8 + pttDelay: number; // 0-4 + tot: number; // 0-20 (Off, 0.5min steps) + vfoMode: boolean; // false=All, true=Band + + // DTMF + dtmfMode: boolean; // false=Manual, true=Auto + dtmfDelay: number; // 0-4 + dtmfSpeed: boolean; // false=50ms, true=100ms + + // Group Monitor (C4FM) + gmRing: number; // 0=Off, 1=In Range, 2=Always + gmInterval: number; // 0=Long, 1=Normal, 2=Off + + // Digital (C4FM) + myCall: string; // up to 10 chars + amsTxMode: number; // 0=Auto, 1=Digital, 2=FM + standbyBeep: boolean; + rxDgId: number; // 0-99 + txDgId: number; // 0-99 + vwMode: boolean; + digitalPopup: number; // 0-9 (Off, 2s..60s, Continuous) +} diff --git a/src/types/radioCapabilities.ts b/src/types/radioCapabilities.ts index 9f6412c..54e5ae9 100644 --- a/src/types/radioCapabilities.ts +++ b/src/types/radioCapabilities.ts @@ -91,4 +91,23 @@ export interface RadioCapabilities { supportsQuickMessages?: boolean; /** If true, radio has Analog Emergency Systems (DM-32UV only). */ supportsAnalogEmergency?: boolean; + /** + * Manual button-press steps the user must perform on the radio itself before a + * Read/Write can proceed (e.g. FT-70D: software can't trigger clone mode over + * the cable, the user has to arm it on the radio). When set, the UI shows a + * confirmation modal with this text before starting the corresponding operation. + */ + cloneModeInstructions?: { + /** Shown before connecting — arm clone mode, then click Continue to open the port. */ + read: string; + /** + * Shown after the port is open and actively listening, instructing the user to + * trigger the radio's send. Must come after the port is open: the radio starts + * streaming the instant the button is pressed, with no handshake to wait for the + * host, so if this were shown before connecting the radio's one-shot transmission + * could finish before anyone is listening. + */ + readStart: string; + write: string; + }; } diff --git a/tests/unit/ft70Connection.test.ts b/tests/unit/ft70Connection.test.ts new file mode 100644 index 0000000..a029da2 --- /dev/null +++ b/tests/unit/ft70Connection.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { FT70Connection } from '../../src/radios/ft70/connection'; +import type { SerialLikePort } from '../../src/radios/shared/BaseSerialConnection'; +import { + FT70_ID_BLOCK_SIZE, FT70_DATA_BLOCK_SIZE, FT70_MEM_SIZE, FT70_MODEL_ID, +} from '../../src/radios/ft70/constants'; + +const ACK = 0x06; + +function concat(...arrays: Uint8Array[]): Uint8Array { + const out = new Uint8Array(arrays.reduce((n, a) => n + a.length, 0)); + let off = 0; + for (const a of arrays) { out.set(a, off); off += a.length; } + return out; +} + +const ID_BLOCK = (() => { + const id = new Uint8Array(FT70_ID_BLOCK_SIZE); + id.set(new TextEncoder().encode(FT70_MODEL_ID)); + return id; +})(); + +const DATA_BLOCK = (() => { + const data = new Uint8Array(FT70_DATA_BLOCK_SIZE); + // Deterministic pattern; data[0] = 1 so it can't be mistaken for an echoed ACK. + for (let i = 0; i < data.length; i++) data[i] = (i * 7 + 1) & 0xff; + return data; +})(); + +/** Fake Web Serial port pre-loaded with everything the radio will send. */ +function makePort(rxData: Uint8Array): { port: SerialLikePort; written: number[] } { + const written: number[] = []; + const port: SerialLikePort = { + readable: new ReadableStream({ + start(controller) { + controller.enqueue(rxData); + controller.close(); + }, + }), + writable: new WritableStream({ + write(chunk) { written.push(...chunk); }, + }), + open: async () => {}, + close: async () => {}, + }; + return { port, written }; +} + +async function readImageFrom(rxData: Uint8Array) { + const { port, written } = makePort(rxData); + const conn = new FT70Connection(); + await conn.open(port); + const image = await conn.readImage(); + return { image, written }; +} + +describe('FT70Connection.readImage', () => { + it('reads a clean stream from a non-echoing cable', async () => { + const { image, written } = await readImageFrom(concat(ID_BLOCK, DATA_BLOCK)); + + expect(image.length).toBe(FT70_MEM_SIZE); + expect(image.slice(0, FT70_ID_BLOCK_SIZE)).toEqual(ID_BLOCK); + expect(image.slice(FT70_ID_BLOCK_SIZE)).toEqual(DATA_BLOCK); + expect(written).toEqual([ACK]); // one host ACK after the ID block + }); + + it('strips the echoed host ACK ahead of the data block (echoing cable)', async () => { + // TX/RX OR'd cables reflect the ACK the host sends after the ID block. + const { image } = await readImageFrom(concat(ID_BLOCK, Uint8Array.of(ACK), DATA_BLOCK)); + + expect(image.slice(0, FT70_ID_BLOCK_SIZE)).toEqual(ID_BLOCK); + expect(image.slice(FT70_ID_BLOCK_SIZE)).toEqual(DATA_BLOCK); // not shifted by one + }); + + it('tolerates a stray ACK ahead of the ID block', async () => { + const { image } = await readImageFrom(concat(Uint8Array.of(ACK), ID_BLOCK, DATA_BLOCK)); + + expect(image.slice(0, FT70_ID_BLOCK_SIZE)).toEqual(ID_BLOCK); + expect(image.slice(FT70_ID_BLOCK_SIZE)).toEqual(DATA_BLOCK); + }); +}); diff --git a/tests/unit/ft70Protocol.test.ts b/tests/unit/ft70Protocol.test.ts new file mode 100644 index 0000000..2d139dd --- /dev/null +++ b/tests/unit/ft70Protocol.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { FT70Protocol } from '../../src/radios/ft70/protocol'; + +// The connection hook relies on this surface to keep FT-70 writes safe: +// bufferedSettingsWrite makes it stage settings BEFORE writeChannels, and +// get/setMemoryImage carry the read image across protocol instances so a +// write doesn't zero the radio's non-channel memory (settings, APRS, GM, banks). +describe('FT70Protocol memory image handling', () => { + it('declares bufferedSettingsWrite so the hook stages settings before writeChannels', () => { + expect(new FT70Protocol().bufferedSettingsWrite).toBe(true); + }); + + it('has no memory image before a read', () => { + expect(new FT70Protocol().getMemoryImage()).toBeNull(); + }); + + it('setMemoryImage stores a defensive copy retrievable via getMemoryImage', () => { + const proto = new FT70Protocol(); + const image = new Uint8Array([1, 2, 3, 4]); + proto.setMemoryImage(image); + + const cached = proto.getMemoryImage(); + expect(cached).toEqual(image); + expect(cached).not.toBe(image); // mutation of the caller's array must not affect the cache + + image[0] = 99; + expect(proto.getMemoryImage()![0]).toBe(1); + }); + + it('refuses to write channels without a memory image (would zero radio settings)', async () => { + const proto = new FT70Protocol(); + // Bypass the connection guard to reach the image guard. + (proto as unknown as { conn: object }).conn = {}; + await expect(proto.writeChannels([])).rejects.toThrow(/Read the radio first/); + }); +}); diff --git a/tests/unit/ft70SettingsFormat.test.ts b/tests/unit/ft70SettingsFormat.test.ts new file mode 100644 index 0000000..6a2f42e --- /dev/null +++ b/tests/unit/ft70SettingsFormat.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect } from 'vitest'; +import { parseFt70Settings, writeFt70Settings } from '../../src/radios/ft70/settingsFormat'; +import { + FT70_MEM_SIZE, FT70_ADDR_SQUELCH, FT70_ADDR_FIRST_SETTINGS, FT70_ADDR_BEEP_SETTINGS, + FT70_ADDR_SCAN_SETTINGS, FT70_ADDR_SCAN_SETTINGS_2, FT70_ADDR_MYCALL, + FT70_ADDR_DIGITAL_SETTINGS, FT70_ADDR_DIGITAL_SETTINGS_MORE, FT70_ADDR_OPENING_MESSAGE, +} from '../../src/radios/ft70/constants'; + +function makeImage(): Uint8Array { + return new Uint8Array(FT70_MEM_SIZE); +} + +describe('parseFt70Settings', () => { + it('returns null when image is too small', () => { + expect(parseFt70Settings(new Uint8Array(0))).toBeNull(); + expect(parseFt70Settings(new Uint8Array(100))).toBeNull(); + }); + + it('parses all-zero block to safe defaults', () => { + const s = parseFt70Settings(makeImage())!; + expect(s.squelch).toBe(0); + expect(s.volume).toBe(0); + expect(s.apo).toBe(0); + expect(s.bclo).toBe(false); + expect(s.myCall).toBe(''); + expect(s.digitalPopup).toBe(0); + }); + + it('parses squelch from low nibble without disturbing high nibble', () => { + const img = makeImage(); + img[FT70_ADDR_SQUELCH] = 0xf0 | 7; + expect(parseFt70Settings(img)!.squelch).toBe(7); + }); + + it('parses volume from scan_settings_2 low 5 bits', () => { + const img = makeImage(); + img[FT70_ADDR_SCAN_SETTINGS_2] = 0xe0 | 17; + expect(parseFt70Settings(img)!.volume).toBe(17); + }); + + it('parses apo from first_settings low 5 bits', () => { + const img = makeImage(); + img[FT70_ADDR_FIRST_SETTINGS + 3] = 0xe0 | 12; + expect(parseFt70Settings(img)!.apo).toBe(12); + }); + + it('parses beep_level / beep_select', () => { + const img = makeImage(); + img[FT70_ADDR_BEEP_SETTINGS] = 5; // beep_level + img[FT70_ADDR_BEEP_SETTINGS + 1] = 2; // beep_select + const s = parseFt70Settings(img)!; + expect(s.beepLevel).toBe(5); + expect(s.beepSelect).toBe(2); + }); + + it('parses scan_settings byte-A bitfield (vfo_mode..dtmf_mode)', () => { + const img = makeImage(); + // vfo_mode(bit7)=1, scan_lamp(bit5)=1, ars(bit3)=1, dtmf_speed(bit2)=1, dtmf_mode(bit0)=1 + img[FT70_ADDR_SCAN_SETTINGS + 26] = 0b1010_1101; + const s = parseFt70Settings(img)!; + expect(s.vfoMode).toBe(true); + expect(s.scanLamp).toBe(true); + expect(s.ars).toBe(true); + expect(s.dtmfSpeed).toBe(true); + expect(s.dtmfMode).toBe(true); + }); + + it('parses scan_settings byte-B bitfield (busy_led, bclo, beep_edge)', () => { + const img = makeImage(); + img[FT70_ADDR_SCAN_SETTINGS + 27] = 0b1001_1000; // busy_led=1, bclo=1, beep_edge=1 + const s = parseFt70Settings(img)!; + expect(s.busyLed).toBe(true); + expect(s.bclo).toBe(true); + expect(s.beepEdge).toBe(true); + }); + + it('parses MYCALL stopping at 0xFF padding', () => { + const img = makeImage(); + const cs = 'KK7DS'; + for (let i = 0; i < cs.length; i++) img[FT70_ADDR_MYCALL + i] = cs.charCodeAt(i); + expect(parseFt70Settings(img)!.myCall).toBe('KK7DS'); + }); + + it('parses digital popup special mapping (0 = Off, else raw-9)', () => { + const img = makeImage(); + expect(parseFt70Settings(img)!.digitalPopup).toBe(0); + img[FT70_ADDR_DIGITAL_SETTINGS_MORE + 7] = 0x12; // 18 -> index 9 ("Continuous") + expect(parseFt70Settings(img)!.digitalPopup).toBe(9); + }); + + it('parses digital_settings ams_tx_mode / standby_beep / dg_id / vw_mode', () => { + const img = makeImage(); + img[FT70_ADDR_DIGITAL_SETTINGS] = 2; // ams_tx_mode + img[FT70_ADDR_DIGITAL_SETTINGS + 2] = 1; // standby_beep + img[FT70_ADDR_DIGITAL_SETTINGS + 6] = 42; // rx_dg_id + img[FT70_ADDR_DIGITAL_SETTINGS + 7] = 24; // tx_dg_id + img[FT70_ADDR_DIGITAL_SETTINGS + 8] = 1; // vw_mode + const s = parseFt70Settings(img)!; + expect(s.amsTxMode).toBe(2); + expect(s.standbyBeep).toBe(true); + expect(s.rxDgId).toBe(42); + expect(s.txDgId).toBe(24); + expect(s.vwMode).toBe(true); + }); + + it('clamps out-of-range values to valid maximums', () => { + const img = makeImage(); + img[FT70_ADDR_SCAN_SETTINGS + 13] = 255; // rxSave max 36 + img[FT70_ADDR_FIRST_SETTINGS + 3] = 255 & 0x1f; // apo masked to 5 bits anyway (max 31 -> clamp 24) + const s = parseFt70Settings(img)!; + expect(s.rxSave).toBe(36); + expect(s.apo).toBe(24); + }); +}); + +describe('writeFt70Settings', () => { + it('no-ops silently on an undersized image', () => { + expect(() => writeFt70Settings(new Uint8Array(0), { squelch: 5 })).not.toThrow(); + }); + + it('writes squelch without disturbing the high nibble', () => { + const img = makeImage(); + img[FT70_ADDR_SQUELCH] = 0xa0; + writeFt70Settings(img, { squelch: 9 }); + expect(img[FT70_ADDR_SQUELCH]).toBe(0xa9); + }); + + it('writes volume into low 5 bits, preserving unknown bits', () => { + const img = makeImage(); + img[FT70_ADDR_SCAN_SETTINGS_2] = 0xc0; + writeFt70Settings(img, { volume: 31 }); + expect(img[FT70_ADDR_SCAN_SETTINGS_2]).toBe(0xc0 | 31); + }); + + it('writes boolean bitfield fields without disturbing siblings', () => { + const img = makeImage(); + writeFt70Settings(img, { vfoMode: true, scanLamp: true }); + expect(img[FT70_ADDR_SCAN_SETTINGS + 26]).toBe(0b1010_0000); + writeFt70Settings(img, { vfoMode: false }); + expect(img[FT70_ADDR_SCAN_SETTINGS + 26]).toBe(0b0010_0000); // scanLamp untouched + }); + + it('writes MYCALL 0xFF-padded to 10 bytes, uppercased', () => { + const img = makeImage(); + writeFt70Settings(img, { myCall: 'kk7ds' }); + expect(parseFt70Settings(img)!.myCall).toBe('KK7DS'); + expect(img[FT70_ADDR_MYCALL + 5]).toBe(0xff); + }); + + it('writes digitalPopup using the 0/raw-9 mapping', () => { + const img = makeImage(); + writeFt70Settings(img, { digitalPopup: 0 }); + expect(img[FT70_ADDR_DIGITAL_SETTINGS_MORE + 7]).toBe(0); + writeFt70Settings(img, { digitalPopup: 9 }); + expect(img[FT70_ADDR_DIGITAL_SETTINGS_MORE + 7]).toBe(18); + }); + + it('writes opening message text and mode', () => { + const img = makeImage(); + writeFt70Settings(img, { openingMessageMode: 2, openingMessageText: 'HELLO' }); + const s = parseFt70Settings(img)!; + expect(s.openingMessageMode).toBe(2); + expect(s.openingMessageText).toBe('HELLO'); + expect(img[FT70_ADDR_OPENING_MESSAGE + 4 + 5]).toBe(0xff); // padded + }); + + it('does not modify bytes for unspecified fields', () => { + const img = makeImage(); + img[FT70_ADDR_SCAN_SETTINGS + 9] = 0x42; // mic_gain byte + writeFt70Settings(img, { squelch: 3 }); + expect(img[FT70_ADDR_SCAN_SETTINGS + 9]).toBe(0x42); + }); + + it('clamps written values to valid ranges', () => { + const img = makeImage(); + writeFt70Settings(img, { apo: 99, tot: 99, rxSave: 99 }); + expect(img[FT70_ADDR_FIRST_SETTINGS + 3] & 0x1f).toBe(24); + expect(img[FT70_ADDR_SCAN_SETTINGS + 22]).toBe(20); + expect(img[FT70_ADDR_SCAN_SETTINGS + 13]).toBe(36); + }); +}); + +describe('round-trip', () => { + it('parse -> write -> parse yields identical result', () => { + const img = makeImage(); + img[FT70_ADDR_SQUELCH] = 5; + img[FT70_ADDR_SCAN_SETTINGS_2] = 12; + img[FT70_ADDR_FIRST_SETTINGS + 3] = 8; // apo + img[FT70_ADDR_SCAN_SETTINGS + 26] = 0b1010_1101; // byte A + img[FT70_ADDR_SCAN_SETTINGS + 27] = 0b1001_1000; // byte B + const cs = 'N0CALL'; + for (let i = 0; i < cs.length; i++) img[FT70_ADDR_MYCALL + i] = cs.charCodeAt(i); + img[FT70_ADDR_DIGITAL_SETTINGS_MORE + 7] = 0x0a; // digital popup raw + + const parsed1 = parseFt70Settings(img)!; + const img2 = makeImage(); + writeFt70Settings(img2, parsed1); + const parsed2 = parseFt70Settings(img2)!; + expect(parsed2).toEqual(parsed1); + }); +}); diff --git a/tests/unit/ft70Structures.test.ts b/tests/unit/ft70Structures.test.ts new file mode 100644 index 0000000..a95dd0a --- /dev/null +++ b/tests/unit/ft70Structures.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from 'vitest'; +import { + decodeBCDkHz, encodeBCDkHz, computeChecksum, applyChecksum, + parseChannel, encodeChannel, parseAllChannels, clearChannelRegions, +} from '../../src/radios/ft70/structures'; +import { FT70_MEM_SIZE, FT70_ADDR_CHECKSUM, FT70_ADDR_FLAGS, FT70_ADDR_CHANNELS } from '../../src/radios/ft70/constants'; +import { createDefaultChannel } from '../../src/utils/channelHelpers'; + +function makeImage(): Uint8Array { + return new Uint8Array(FT70_MEM_SIZE); +} + +describe('BCD kHz codec', () => { + it('round-trips a typical VHF frequency', () => { + const buf = new Uint8Array(3); + encodeBCDkHz(146520, buf); + expect(decodeBCDkHz(buf)).toBe(146520); + expect([...buf]).toEqual([0x14, 0x65, 0x20]); + }); + + it('round-trips a UHF frequency', () => { + const buf = new Uint8Array(3); + encodeBCDkHz(438650, buf); + expect(decodeBCDkHz(buf)).toBe(438650); + }); + + it('round-trips zero', () => { + const buf = new Uint8Array(3); + encodeBCDkHz(0, buf); + expect(decodeBCDkHz(buf)).toBe(0); + }); +}); + +describe('checksum', () => { + it('computes sum of bytes [0, 0xFEC9] mod 256', () => { + const img = makeImage(); + img[0] = 0x10; + img[1] = 0x20; + img[0xfec9] = 0x05; + img[0xfeca] = 0xff; // outside sum range, must not affect checksum + expect(computeChecksum(img)).toBe(0x35); + }); + + it('applyChecksum writes the computed value at 0xFECA', () => { + const img = makeImage(); + img[5] = 7; + applyChecksum(img); + expect(img[FT70_ADDR_CHECKSUM]).toBe(computeChecksum(img)); + }); +}); + +describe('parseChannel', () => { + it('returns null for an unprogrammed (invalid) slot', () => { + const img = makeImage(); + expect(parseChannel(img, 0)).toBeNull(); + }); + + it('returns null when used bit is clear even if valid', () => { + const img = makeImage(); + img[FT70_ADDR_FLAGS] = 0x01; // valid=1, used=0 + expect(parseChannel(img, 0)).toBeNull(); + }); +}); + +describe('encodeChannel / parseChannel round trip', () => { + it('round-trips a simplex channel', () => { + const img = makeImage(); + const ch = createDefaultChannel({ + number: 1, + name: 'SIMPLEX', + rxFrequency: 146.52, + txFrequency: 146.52, + bandwidth: '25kHz', + power: 'High', + }); + encodeChannel(img, ch); + const parsed = parseChannel(img, 0)!; + expect(parsed).not.toBeNull(); + expect(parsed.name).toBe('SIMPLE'); // 6-char label + expect(parsed.rxFrequency).toBeCloseTo(146.52, 3); + expect(parsed.txFrequency).toBeCloseTo(146.52, 3); + expect(parsed.power).toBe('High'); + expect(parsed.bandwidth).toBe('25kHz'); + }); + + it('round-trips a positive-shift repeater channel', () => { + const img = makeImage(); + const ch = createDefaultChannel({ + number: 5, + name: 'RPTR', + rxFrequency: 146.94, + txFrequency: 146.34, + bandwidth: '12.5kHz', + power: 'Low', + }); + encodeChannel(img, ch); + const parsed = parseChannel(img, 4)!; + expect(parsed.rxFrequency).toBeCloseTo(146.94, 3); + expect(parsed.txFrequency).toBeCloseTo(146.34, 3); + expect(parsed.power).toBe('Low'); + expect(parsed.bandwidth).toBe('12.5kHz'); + }); + + it('round-trips CTCSS TSQL tone (same tx/rx)', () => { + const img = makeImage(); + const ch = createDefaultChannel({ + number: 1, + rxFrequency: 146.52, + txFrequency: 146.52, + rxCtcssDcs: { type: 'CTCSS', value: 100.0 }, + txCtcssDcs: { type: 'CTCSS', value: 100.0 }, + }); + encodeChannel(img, ch); + const parsed = parseChannel(img, 0)!; + expect(parsed.txCtcssDcs).toEqual({ type: 'CTCSS', value: 100.0 }); + expect(parsed.rxCtcssDcs).toEqual({ type: 'CTCSS', value: 100.0 }); + }); + + it('round-trips TX-only CTCSS tone', () => { + const img = makeImage(); + const ch = createDefaultChannel({ + number: 1, + rxFrequency: 146.52, + txFrequency: 146.52, + rxCtcssDcs: { type: 'None' }, + txCtcssDcs: { type: 'CTCSS', value: 88.5 }, + }); + encodeChannel(img, ch); + const parsed = parseChannel(img, 0)!; + expect(parsed.txCtcssDcs).toEqual({ type: 'CTCSS', value: 88.5 }); + expect(parsed.rxCtcssDcs).toEqual({ type: 'None' }); + }); + + it('round-trips DCS code (shared tx/rx)', () => { + const img = makeImage(); + const ch = createDefaultChannel({ + number: 1, + rxFrequency: 146.52, + txFrequency: 146.52, + rxCtcssDcs: { type: 'DCS', value: 23, polarity: 'N' }, + txCtcssDcs: { type: 'DCS', value: 23, polarity: 'N' }, + }); + encodeChannel(img, ch); + const parsed = parseChannel(img, 0)!; + expect(parsed.txCtcssDcs).toEqual({ type: 'DCS', value: 23, polarity: 'N' }); + expect(parsed.rxCtcssDcs).toEqual({ type: 'DCS', value: 23, polarity: 'N' }); + }); + + it('marks scan-skipped channels via scanAdd', () => { + const img = makeImage(); + const ch = createDefaultChannel({ number: 1, rxFrequency: 146.52, txFrequency: 146.52, scanAdd: false }); + encodeChannel(img, ch); + expect(parseChannel(img, 0)!.scanAdd).toBe(false); + }); +}); + +describe('parseAllChannels / clearChannelRegions', () => { + it('parses multiple programmed channels and skips empty slots', () => { + const img = makeImage(); + encodeChannel(img, createDefaultChannel({ number: 1, rxFrequency: 146.52, txFrequency: 146.52 })); + encodeChannel(img, createDefaultChannel({ number: 3, rxFrequency: 446.0, txFrequency: 446.0 })); + const channels = parseAllChannels(img); + expect(channels.map((c) => c.number)).toEqual([1, 3]); + }); + + it('clearChannelRegions wipes flags and channel data', () => { + const img = makeImage(); + encodeChannel(img, createDefaultChannel({ number: 1, rxFrequency: 146.52, txFrequency: 146.52 })); + expect(parseAllChannels(img)).toHaveLength(1); + clearChannelRegions(img); + expect(parseAllChannels(img)).toHaveLength(0); + expect(img[FT70_ADDR_FLAGS]).toBe(0); + expect(img[FT70_ADDR_CHANNELS]).toBe(0); + }); +});