Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
671 lines
20 KiB
JavaScript
671 lines
20 KiB
JavaScript
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const YMODEM = Object.freeze({
|
|
SOH: 0x01,
|
|
STX: 0x02,
|
|
EOT: 0x04,
|
|
ACK: 0x06,
|
|
NAK: 0x15,
|
|
CAN: 0x18,
|
|
CRC16: 0x43,
|
|
BACKSPACE: 0x08,
|
|
PACKET_SIZE_128: 128,
|
|
PACKET_SIZE_1024: 1024,
|
|
});
|
|
|
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
const DEFAULT_RETRY_LIMIT = 10;
|
|
const YMODEM_CANCEL_SEQUENCE = Buffer.from([
|
|
YMODEM.CAN,
|
|
YMODEM.CAN,
|
|
YMODEM.CAN,
|
|
YMODEM.CAN,
|
|
YMODEM.CAN,
|
|
YMODEM.BACKSPACE,
|
|
YMODEM.BACKSPACE,
|
|
YMODEM.BACKSPACE,
|
|
YMODEM.BACKSPACE,
|
|
YMODEM.BACKSPACE,
|
|
]);
|
|
|
|
class YmodemTransferError extends Error {
|
|
constructor(message, code = "YMODEM_TRANSFER_ERROR") {
|
|
super(message);
|
|
this.name = "YmodemTransferError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function crc16Xmodem(buffer, start = 0, length = buffer.length - start) {
|
|
let crc = 0;
|
|
for (let i = start; i < start + length; i += 1) {
|
|
let code = ((crc >>> 8) ^ buffer[i]) & 0xff;
|
|
code ^= code >>> 4;
|
|
crc = ((crc << 8) ^ (code << 12) ^ (code << 5) ^ code) & 0xffff;
|
|
}
|
|
return crc;
|
|
}
|
|
|
|
function createPacket(header, blockNumber, payload) {
|
|
const size = payload.length;
|
|
const packet = Buffer.alloc(3 + size + 2, 0x00);
|
|
packet[0] = header;
|
|
packet[1] = blockNumber & 0xff;
|
|
packet[2] = 0xff - packet[1];
|
|
payload.copy(packet, 3);
|
|
packet.writeUInt16BE(crc16Xmodem(packet, 3, size), packet.length - 2);
|
|
return packet;
|
|
}
|
|
|
|
function createYmodemFileInfoPacket({ filename, size, mtime = 0, mode = 0o100644 }) {
|
|
const baseName = path.basename(filename || "file");
|
|
const metadata = `${baseName}\0${size} ${Math.trunc(mtime).toString(8)} ${mode.toString(8)}`;
|
|
const metadataBytes = Buffer.from(metadata, "utf8");
|
|
const packetSize = metadataBytes.length >= YMODEM.PACKET_SIZE_128
|
|
? YMODEM.PACKET_SIZE_1024
|
|
: YMODEM.PACKET_SIZE_128;
|
|
const payload = Buffer.alloc(packetSize, 0x00);
|
|
metadataBytes.copy(payload, 0, 0, Math.min(metadataBytes.length, packetSize));
|
|
return createPacket(
|
|
packetSize === YMODEM.PACKET_SIZE_1024 ? YMODEM.STX : YMODEM.SOH,
|
|
0,
|
|
payload,
|
|
);
|
|
}
|
|
|
|
function createYmodemDataPackets(buffer) {
|
|
if (!Buffer.isBuffer(buffer)) {
|
|
buffer = Buffer.from(buffer);
|
|
}
|
|
const packets = [];
|
|
let blockNumber = 1;
|
|
for (let offset = 0; offset < buffer.length; offset += YMODEM.PACKET_SIZE_1024) {
|
|
const payload = Buffer.alloc(YMODEM.PACKET_SIZE_1024, 0x1a);
|
|
buffer.copy(payload, 0, offset, Math.min(offset + YMODEM.PACKET_SIZE_1024, buffer.length));
|
|
packets.push(createPacket(YMODEM.STX, blockNumber, payload));
|
|
blockNumber = (blockNumber + 1) & 0xff;
|
|
}
|
|
return packets;
|
|
}
|
|
|
|
function createYmodemEndSessionPacket() {
|
|
return createPacket(YMODEM.SOH, 0, Buffer.alloc(YMODEM.PACKET_SIZE_128, 0x00));
|
|
}
|
|
|
|
function sanitizeYmodemFilename(filename) {
|
|
const normalized = String(filename || "").replace(/\\/g, "/");
|
|
const baseName = path.basename(normalized).replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").trim();
|
|
if (!baseName || baseName === "." || baseName === "..") {
|
|
return "ymodem-received.bin";
|
|
}
|
|
return baseName;
|
|
}
|
|
|
|
function parseYmodemFileInfoPayload(payload) {
|
|
const separatorIndex = payload.indexOf(0x00);
|
|
const rawName = (separatorIndex >= 0 ? payload.subarray(0, separatorIndex) : payload).toString("utf8");
|
|
if (!rawName) return null;
|
|
|
|
const metadata = separatorIndex >= 0
|
|
? payload.subarray(separatorIndex + 1).toString("ascii").replace(/\0.*$/u, "").trim()
|
|
: "";
|
|
const [sizeText] = metadata.split(/\s+/u);
|
|
if (!/^\d+$/u.test(sizeText || "")) {
|
|
throw new YmodemTransferError("YMODEM file header has an invalid size", "YMODEM_INVALID_SIZE");
|
|
}
|
|
const parsedSize = Number.parseInt(sizeText, 10);
|
|
|
|
return {
|
|
fileName: sanitizeYmodemFilename(rawName),
|
|
totalBytes: Number.isFinite(parsedSize) && parsedSize >= 0 ? parsedSize : 0,
|
|
};
|
|
}
|
|
|
|
async function resolveUniqueDestinationPath(destinationDir, fileName) {
|
|
const parsed = path.parse(fileName);
|
|
for (let attempt = 0; attempt < 10_000; attempt += 1) {
|
|
const candidateName = attempt === 0
|
|
? fileName
|
|
: `${parsed.name} (${attempt})${parsed.ext}`;
|
|
const candidatePath = path.join(destinationDir, candidateName);
|
|
try {
|
|
await fs.promises.access(candidatePath);
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return candidatePath;
|
|
throw error;
|
|
}
|
|
}
|
|
throw new YmodemTransferError("Could not choose a destination file name", "YMODEM_DESTINATION_EXISTS");
|
|
}
|
|
|
|
function writeAndDrain(serialPort, buffer) {
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const done = (err) => {
|
|
if (settled) return;
|
|
if (err) {
|
|
settled = true;
|
|
reject(err);
|
|
return;
|
|
}
|
|
if (typeof serialPort.drain !== "function") {
|
|
settled = true;
|
|
resolve();
|
|
return;
|
|
}
|
|
serialPort.drain((drainErr) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
drainErr ? reject(drainErr) : resolve();
|
|
});
|
|
};
|
|
|
|
try {
|
|
serialPort.write(buffer, done);
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
function createSerialByteReader(serialPort, abortSignal) {
|
|
const queue = [];
|
|
const waiters = [];
|
|
|
|
const rejectAll = (error) => {
|
|
while (waiters.length) {
|
|
const waiter = waiters.shift();
|
|
clearTimeout(waiter.timer);
|
|
waiter.reject(error);
|
|
}
|
|
};
|
|
|
|
const onData = (chunk) => {
|
|
for (const byte of Buffer.from(chunk)) {
|
|
if (waiters.length) {
|
|
const waiter = waiters.shift();
|
|
clearTimeout(waiter.timer);
|
|
waiter.resolve(byte);
|
|
} else {
|
|
queue.push(byte);
|
|
}
|
|
}
|
|
};
|
|
|
|
const onAbort = () => {
|
|
rejectAll(new YmodemTransferError("YMODEM transfer cancelled", "YMODEM_CANCELLED"));
|
|
};
|
|
const onError = (error) => {
|
|
rejectAll(error instanceof Error ? error : new Error(String(error)));
|
|
};
|
|
const onClose = () => {
|
|
rejectAll(new YmodemTransferError("Serial port closed during YMODEM transfer", "YMODEM_SERIAL_CLOSED"));
|
|
};
|
|
|
|
serialPort.on("data", onData);
|
|
serialPort.on?.("error", onError);
|
|
serialPort.on?.("close", onClose);
|
|
abortSignal?.addEventListener?.("abort", onAbort, { once: true });
|
|
|
|
return {
|
|
unreadByte(byte) {
|
|
queue.unshift(byte);
|
|
},
|
|
readByte(timeoutMs) {
|
|
if (abortSignal?.aborted) {
|
|
return Promise.reject(new YmodemTransferError("YMODEM transfer cancelled", "YMODEM_CANCELLED"));
|
|
}
|
|
if (queue.length) {
|
|
return Promise.resolve(queue.shift());
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
const index = waiters.findIndex((waiter) => waiter.timer === timer);
|
|
if (index >= 0) waiters.splice(index, 1);
|
|
reject(new YmodemTransferError("Timed out waiting for YMODEM receiver", "YMODEM_TIMEOUT"));
|
|
}, timeoutMs);
|
|
waiters.push({ resolve, reject, timer });
|
|
});
|
|
},
|
|
cleanup() {
|
|
serialPort.off("data", onData);
|
|
serialPort.off?.("error", onError);
|
|
serialPort.off?.("close", onClose);
|
|
abortSignal?.removeEventListener?.("abort", onAbort);
|
|
rejectAll(new YmodemTransferError("YMODEM reader closed", "YMODEM_CLOSED"));
|
|
},
|
|
};
|
|
}
|
|
|
|
function sendYmodemCancel(serialPort) {
|
|
if (!serialPort || typeof serialPort.write !== "function") return Promise.resolve();
|
|
return writeAndDrain(serialPort, YMODEM_CANCEL_SEQUENCE).catch(() => {});
|
|
}
|
|
|
|
async function readExpected(reader, expected, timeoutMs, label) {
|
|
for (;;) {
|
|
const byte = await reader.readByte(timeoutMs);
|
|
if (byte === YMODEM.CAN) {
|
|
const next = await reader.readByte(1_000).catch(() => null);
|
|
if (next === YMODEM.CAN) {
|
|
throw new YmodemTransferError("YMODEM transfer cancelled by receiver", "YMODEM_REMOTE_CANCELLED");
|
|
}
|
|
if (next !== null) {
|
|
reader.unreadByte?.(next);
|
|
}
|
|
continue;
|
|
}
|
|
if (expected.includes(byte)) return byte;
|
|
if (byte === YMODEM.CRC16 && expected.includes(YMODEM.CRC16)) return byte;
|
|
if (byte === YMODEM.NAK && expected.includes(YMODEM.NAK)) return byte;
|
|
if (byte === YMODEM.ACK && expected.includes(YMODEM.ACK)) return byte;
|
|
if (byte === YMODEM.CRC16 || byte === YMODEM.ACK || byte === YMODEM.NAK) continue;
|
|
throw new YmodemTransferError(`Unexpected YMODEM byte while waiting for ${label}: 0x${byte.toString(16)}`);
|
|
}
|
|
}
|
|
|
|
async function sendPacketWithRetry({ serialPort, reader, packet, timeoutMs, retryLimit, onProgress }) {
|
|
for (let attempt = 0; attempt < retryLimit; attempt += 1) {
|
|
await writeAndDrain(serialPort, packet);
|
|
onProgress?.();
|
|
const response = await readExpected(reader, [YMODEM.ACK, YMODEM.NAK], timeoutMs, "packet ACK");
|
|
if (response === YMODEM.ACK) return;
|
|
}
|
|
throw new YmodemTransferError("YMODEM receiver rejected the packet too many times", "YMODEM_RETRY_LIMIT");
|
|
}
|
|
|
|
async function readYmodemFrame(reader, timeoutMs) {
|
|
const header = await reader.readByte(timeoutMs);
|
|
if (header === YMODEM.CAN) {
|
|
const next = await reader.readByte(1_000).catch(() => null);
|
|
if (next === YMODEM.CAN) {
|
|
throw new YmodemTransferError("YMODEM transfer cancelled by sender", "YMODEM_REMOTE_CANCELLED");
|
|
}
|
|
if (next !== null) {
|
|
reader.unreadByte?.(next);
|
|
}
|
|
return readYmodemFrame(reader, timeoutMs);
|
|
}
|
|
if (header === YMODEM.EOT) {
|
|
return { type: "eot" };
|
|
}
|
|
if (header !== YMODEM.SOH && header !== YMODEM.STX) {
|
|
throw new YmodemTransferError(`Unexpected YMODEM packet header: 0x${header.toString(16)}`);
|
|
}
|
|
|
|
const payloadSize = header === YMODEM.SOH ? YMODEM.PACKET_SIZE_128 : YMODEM.PACKET_SIZE_1024;
|
|
const blockNumber = await reader.readByte(timeoutMs);
|
|
const blockComplement = await reader.readByte(timeoutMs);
|
|
const payload = Buffer.alloc(payloadSize);
|
|
for (let i = 0; i < payloadSize; i += 1) {
|
|
payload[i] = await reader.readByte(timeoutMs);
|
|
}
|
|
const sentCrc = ((await reader.readByte(timeoutMs)) << 8) | await reader.readByte(timeoutMs);
|
|
const expectedCrc = crc16Xmodem(payload);
|
|
const valid = blockComplement === (0xff - blockNumber) && sentCrc === expectedCrc;
|
|
|
|
return {
|
|
type: "packet",
|
|
blockNumber,
|
|
payload,
|
|
valid,
|
|
};
|
|
}
|
|
|
|
async function readYmodemPacketWithRetry({
|
|
serialPort,
|
|
reader,
|
|
expectedBlockNumber,
|
|
requestByte,
|
|
timeoutMs,
|
|
retryLimit,
|
|
label,
|
|
}) {
|
|
for (let attempt = 0; attempt < retryLimit; attempt += 1) {
|
|
if (requestByte !== undefined) {
|
|
await writeAndDrain(serialPort, Buffer.from([requestByte]));
|
|
}
|
|
|
|
let frame;
|
|
try {
|
|
frame = await readYmodemFrame(reader, timeoutMs);
|
|
} catch (error) {
|
|
if (error?.code === "YMODEM_TIMEOUT" && attempt < retryLimit - 1) {
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (frame.type === "eot") {
|
|
return frame;
|
|
}
|
|
if (frame.valid && frame.blockNumber === expectedBlockNumber) {
|
|
return frame;
|
|
}
|
|
if (frame.valid && frame.blockNumber === ((expectedBlockNumber - 1) & 0xff)) {
|
|
await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK]));
|
|
continue;
|
|
}
|
|
await writeAndDrain(serialPort, Buffer.from([YMODEM.NAK]));
|
|
}
|
|
|
|
throw new YmodemTransferError(`YMODEM sender did not provide a valid ${label}`, "YMODEM_RETRY_LIMIT");
|
|
}
|
|
|
|
async function receiveYmodemFileData({
|
|
serialPort,
|
|
reader,
|
|
fileHandle,
|
|
totalBytes,
|
|
timeoutMs,
|
|
retryLimit,
|
|
onProgress,
|
|
}) {
|
|
let expectedBlockNumber = 1;
|
|
let writtenBytes = 0;
|
|
let rejectedPackets = 0;
|
|
|
|
for (;;) {
|
|
let frame;
|
|
try {
|
|
frame = await readYmodemFrame(reader, timeoutMs);
|
|
} catch (error) {
|
|
if (error?.code === "YMODEM_TIMEOUT" && rejectedPackets < retryLimit) {
|
|
rejectedPackets += 1;
|
|
await writeAndDrain(serialPort, Buffer.from([YMODEM.NAK]));
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (frame.type === "eot") {
|
|
await writeAndDrain(serialPort, Buffer.from([YMODEM.NAK]));
|
|
const secondEot = await readYmodemFrame(reader, timeoutMs);
|
|
if (secondEot.type !== "eot") {
|
|
throw new YmodemTransferError("YMODEM sender did not confirm end of file", "YMODEM_EOT_EXPECTED");
|
|
}
|
|
await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK]));
|
|
if (writtenBytes !== totalBytes) {
|
|
throw new YmodemTransferError(
|
|
`YMODEM received incomplete file (${writtenBytes}/${totalBytes} bytes)`,
|
|
"YMODEM_INCOMPLETE_FILE",
|
|
);
|
|
}
|
|
return writtenBytes;
|
|
}
|
|
|
|
if (!frame.valid) {
|
|
rejectedPackets += 1;
|
|
if (rejectedPackets > retryLimit) {
|
|
throw new YmodemTransferError("YMODEM sender sent too many invalid packets", "YMODEM_RETRY_LIMIT");
|
|
}
|
|
await writeAndDrain(serialPort, Buffer.from([YMODEM.NAK]));
|
|
continue;
|
|
}
|
|
|
|
if (frame.blockNumber === ((expectedBlockNumber - 1) & 0xff)) {
|
|
await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK]));
|
|
continue;
|
|
}
|
|
if (frame.blockNumber !== expectedBlockNumber) {
|
|
rejectedPackets += 1;
|
|
if (rejectedPackets > retryLimit) {
|
|
throw new YmodemTransferError("YMODEM sender sent packets out of order", "YMODEM_RETRY_LIMIT");
|
|
}
|
|
await writeAndDrain(serialPort, Buffer.from([YMODEM.NAK]));
|
|
continue;
|
|
}
|
|
|
|
const remainingBytes = Math.max(0, totalBytes - writtenBytes);
|
|
const bytesToWrite = Math.min(frame.payload.length, remainingBytes);
|
|
if (bytesToWrite > 0) {
|
|
await fileHandle.write(frame.payload, 0, bytesToWrite);
|
|
writtenBytes += bytesToWrite;
|
|
}
|
|
|
|
rejectedPackets = 0;
|
|
expectedBlockNumber = (expectedBlockNumber + 1) & 0xff;
|
|
await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK]));
|
|
onProgress?.({ transferredBytes: writtenBytes, totalBytes, stage: "data" });
|
|
}
|
|
}
|
|
|
|
async function receiveYmodemFiles(serialPort, {
|
|
destinationDir,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
retryLimit = DEFAULT_RETRY_LIMIT,
|
|
abortSignal,
|
|
onProgress,
|
|
} = {}) {
|
|
if (!serialPort) {
|
|
throw new YmodemTransferError("Serial session is not available", "YMODEM_NO_SERIAL");
|
|
}
|
|
if (!destinationDir || typeof destinationDir !== "string") {
|
|
throw new YmodemTransferError("No destination directory selected", "YMODEM_NO_DESTINATION");
|
|
}
|
|
|
|
const resolvedDestinationDir = path.resolve(destinationDir);
|
|
let destinationStat;
|
|
try {
|
|
destinationStat = await fs.promises.stat(resolvedDestinationDir);
|
|
} catch (error) {
|
|
throw new YmodemTransferError("Selected destination is not a directory", "YMODEM_DESTINATION_NOT_DIRECTORY");
|
|
}
|
|
if (!destinationStat.isDirectory()) {
|
|
throw new YmodemTransferError("Selected destination is not a directory", "YMODEM_DESTINATION_NOT_DIRECTORY");
|
|
}
|
|
|
|
const reader = createSerialByteReader(serialPort, abortSignal);
|
|
const files = [];
|
|
|
|
try {
|
|
for (;;) {
|
|
const headerFrame = await readYmodemPacketWithRetry({
|
|
serialPort,
|
|
reader,
|
|
expectedBlockNumber: 0,
|
|
requestByte: YMODEM.CRC16,
|
|
timeoutMs,
|
|
retryLimit,
|
|
label: "file header",
|
|
});
|
|
|
|
if (headerFrame.type === "eot") {
|
|
await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK]));
|
|
continue;
|
|
}
|
|
|
|
const fileInfo = parseYmodemFileInfoPayload(headerFrame.payload);
|
|
await writeAndDrain(serialPort, Buffer.from([YMODEM.ACK]));
|
|
if (!fileInfo) {
|
|
break;
|
|
}
|
|
|
|
await writeAndDrain(serialPort, Buffer.from([YMODEM.CRC16]));
|
|
onProgress?.({ transferredBytes: 0, totalBytes: fileInfo.totalBytes, stage: "header" });
|
|
|
|
const filePath = await resolveUniqueDestinationPath(resolvedDestinationDir, fileInfo.fileName);
|
|
let fileHandle;
|
|
let closeNeeded = false;
|
|
let createdFile = false;
|
|
try {
|
|
fileHandle = await fs.promises.open(filePath, "wx");
|
|
closeNeeded = true;
|
|
createdFile = true;
|
|
const writtenBytes = await receiveYmodemFileData({
|
|
serialPort,
|
|
reader,
|
|
fileHandle,
|
|
totalBytes: fileInfo.totalBytes,
|
|
timeoutMs,
|
|
retryLimit,
|
|
onProgress,
|
|
});
|
|
await fileHandle.close();
|
|
closeNeeded = false;
|
|
|
|
files.push({
|
|
fileName: path.basename(filePath),
|
|
filePath,
|
|
totalBytes: fileInfo.totalBytes,
|
|
writtenBytes,
|
|
});
|
|
} catch (error) {
|
|
if (closeNeeded) {
|
|
await fileHandle.close().catch(() => {});
|
|
}
|
|
if (createdFile) {
|
|
await fs.promises.rm(filePath, { force: true }).catch(() => {});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const totalBytes = files.reduce((sum, file) => sum + file.totalBytes, 0);
|
|
const writtenBytes = files.reduce((sum, file) => sum + file.writtenBytes, 0);
|
|
return {
|
|
files,
|
|
fileCount: files.length,
|
|
totalBytes,
|
|
writtenBytes,
|
|
fileName: files[0]?.fileName,
|
|
filePath: files[0]?.filePath,
|
|
};
|
|
} finally {
|
|
reader.cleanup();
|
|
}
|
|
}
|
|
|
|
async function sendYmodemBuffer(serialPort, {
|
|
filename,
|
|
buffer,
|
|
mtime = 0,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
retryLimit = DEFAULT_RETRY_LIMIT,
|
|
abortSignal,
|
|
onProgress,
|
|
} = {}) {
|
|
if (!serialPort) {
|
|
throw new YmodemTransferError("Serial session is not available", "YMODEM_NO_SERIAL");
|
|
}
|
|
const fileBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || []);
|
|
const dataPackets = createYmodemDataPackets(fileBuffer);
|
|
const packets = [
|
|
createYmodemFileInfoPacket({ filename, size: fileBuffer.length, mtime }),
|
|
...dataPackets,
|
|
];
|
|
const reader = createSerialByteReader(serialPort, abortSignal);
|
|
let packetsSent = 0;
|
|
|
|
try {
|
|
await readExpected(reader, [YMODEM.CRC16], timeoutMs, "initial receiver request");
|
|
await sendPacketWithRetry({
|
|
serialPort,
|
|
reader,
|
|
packet: packets[0],
|
|
timeoutMs,
|
|
retryLimit,
|
|
onProgress: () => {
|
|
packetsSent += 1;
|
|
onProgress?.({ transferredBytes: 0, totalBytes: fileBuffer.length, stage: "header" });
|
|
},
|
|
});
|
|
|
|
for (let attempt = 0; attempt < retryLimit; attempt += 1) {
|
|
const response = await readExpected(reader, [YMODEM.CRC16, YMODEM.NAK], timeoutMs, "data receiver request");
|
|
if (response === YMODEM.CRC16) break;
|
|
await sendPacketWithRetry({
|
|
serialPort,
|
|
reader,
|
|
packet: packets[0],
|
|
timeoutMs,
|
|
retryLimit,
|
|
onProgress: () => {
|
|
packetsSent += 1;
|
|
onProgress?.({ transferredBytes: 0, totalBytes: fileBuffer.length, stage: "header" });
|
|
},
|
|
});
|
|
if (attempt === retryLimit - 1) {
|
|
throw new YmodemTransferError("YMODEM receiver did not request data after header", "YMODEM_RETRY_LIMIT");
|
|
}
|
|
}
|
|
|
|
for (let i = 1; i < packets.length; i += 1) {
|
|
await sendPacketWithRetry({
|
|
serialPort,
|
|
reader,
|
|
packet: packets[i],
|
|
timeoutMs,
|
|
retryLimit,
|
|
onProgress: () => {
|
|
packetsSent += 1;
|
|
onProgress?.({
|
|
transferredBytes: Math.min(i * YMODEM.PACKET_SIZE_1024, fileBuffer.length),
|
|
totalBytes: fileBuffer.length,
|
|
stage: "data",
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
await writeAndDrain(serialPort, Buffer.from([YMODEM.EOT]));
|
|
const eotResponse = await readExpected(reader, [YMODEM.NAK, YMODEM.ACK], timeoutMs, "first EOT response");
|
|
if (eotResponse === YMODEM.NAK) {
|
|
await writeAndDrain(serialPort, Buffer.from([YMODEM.EOT]));
|
|
await readExpected(reader, [YMODEM.ACK], timeoutMs, "second EOT ACK");
|
|
}
|
|
|
|
await readExpected(reader, [YMODEM.CRC16], timeoutMs, "end session request");
|
|
await sendPacketWithRetry({
|
|
serialPort,
|
|
reader,
|
|
packet: createYmodemEndSessionPacket(),
|
|
timeoutMs,
|
|
retryLimit,
|
|
});
|
|
|
|
onProgress?.({
|
|
transferredBytes: fileBuffer.length,
|
|
totalBytes: fileBuffer.length,
|
|
stage: "complete",
|
|
});
|
|
|
|
return {
|
|
fileName: path.basename(filename || "file"),
|
|
totalBytes: fileBuffer.length,
|
|
writtenBytes: fileBuffer.length,
|
|
packetsSent,
|
|
};
|
|
} finally {
|
|
reader.cleanup();
|
|
}
|
|
}
|
|
|
|
async function sendYmodemFile(serialPort, filePath, options = {}) {
|
|
const stat = await fs.promises.stat(filePath);
|
|
if (!stat.isFile()) {
|
|
throw new YmodemTransferError("Selected path is not a file", "YMODEM_NOT_FILE");
|
|
}
|
|
const buffer = await fs.promises.readFile(filePath);
|
|
return sendYmodemBuffer(serialPort, {
|
|
...options,
|
|
filename: filePath,
|
|
buffer,
|
|
mtime: Math.floor(stat.mtimeMs / 1000),
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
YMODEM,
|
|
YmodemTransferError,
|
|
crc16Xmodem,
|
|
createYmodemFileInfoPacket,
|
|
createYmodemDataPackets,
|
|
createYmodemEndSessionPacket,
|
|
receiveYmodemFiles,
|
|
sendYmodemCancel,
|
|
sendYmodemBuffer,
|
|
sendYmodemFile,
|
|
};
|