Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions src/components/layout/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(() => getMigrationTargetModels()[0] ?? 'DM-32UV');
Expand Down Expand Up @@ -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<void>((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);
Expand All @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -567,6 +614,26 @@ export const Toolbar: React.FC = () => {
cancelLabel="Cancel"
variant="default"
/>
<ConfirmModal
isOpen={cloneInstructionsOpen}
onClose={() => setCloneInstructionsOpen(false)}
onConfirm={handleCloneInstructionsConfirm}
title="Prepare radio for clone mode"
message={caps?.cloneModeInstructions?.read ?? ''}
confirmLabel="Continue"
cancelLabel="Cancel"
variant="default"
/>
<ConfirmModal
isOpen={cloneStartOpen}
onClose={handleCloneStartCancel}
onConfirm={handleCloneStartConfirm}
title="Start sending from radio"
message={caps?.cloneModeInstructions?.readStart ?? ''}
confirmLabel="Continue"
cancelLabel="Cancel"
variant="default"
/>
<ConfirmModal
isOpen={alertOpen}
onClose={closeAlert}
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/useRadioConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function useRadioConnection() {

const readFromRadio = useCallback(async (
onProgress?: (progress: number, message: string, step?: string) => void,
{ forcePortSelection = true }: { forcePortSelection?: boolean } = {}
{ forcePortSelection = true, onConnected }: { forcePortSelection?: boolean; onConnected?: () => Promise<void> | void } = {}
) => {
setIsConnecting(true);
setError(null);
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
42 changes: 42 additions & 0 deletions src/radios/ft70/capabilities.ts
Original file line number Diff line number Diff line change
@@ -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,
};
114 changes: 114 additions & 0 deletions src/radios/ft70/connection.ts
Original file line number Diff line number Diff line change
@@ -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<FT70SerialPort> {
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<void> {
await super.openPort(port);
await this.delay(300);
this.buf = new Uint8Array(0);
}

/** Close reader/writer and port. */
async close(): Promise<void> {
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<Uint8Array> {
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<void> {
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);
}
}
}
Loading
Loading