From 8320bf7ab2d6cc12ace3d84a08d42fdbb65f2e98 Mon Sep 17 00:00:00 2001 From: kerms Date: Sun, 22 Feb 2026 12:37:30 +0100 Subject: [PATCH] feat(app-image): add ESP firmware image parser and viewer with robust upload handling - add app image parser/types/constants for ESP image header, extended header, segments, and app description - add shared binary read/write utilities and CRC32 helper - add AppImageViewer component to inspect firmware metadata in UI - improve upload UX: accept .bin only and show explicit error for ELF input - prevent status alert timer race by clearing previous timeout before setting a new one - ignore .claude in .gitignore --- .gitignore | 2 + .../app-image-viewer/AppImageViewer.vue | 144 ++++++++++++++++++ lib/app-image/constants.ts | 32 ++++ lib/app-image/index.ts | 20 +++ lib/app-image/parser.ts | 104 +++++++++++++ lib/app-image/types.ts | 93 +++++++++++ lib/shared/binary-reader.ts | 46 ++++++ lib/shared/binary-writer.ts | 50 ++++++ lib/shared/crc32.ts | 24 +++ lib/shared/index.ts | 3 + 10 files changed, 518 insertions(+) create mode 100644 .gitignore create mode 100644 components/app-image-viewer/AppImageViewer.vue create mode 100644 lib/app-image/constants.ts create mode 100644 lib/app-image/index.ts create mode 100644 lib/app-image/parser.ts create mode 100644 lib/app-image/types.ts create mode 100644 lib/shared/binary-reader.ts create mode 100644 lib/shared/binary-writer.ts create mode 100644 lib/shared/crc32.ts create mode 100644 lib/shared/index.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5034043 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# AI +/.claude \ No newline at end of file diff --git a/components/app-image-viewer/AppImageViewer.vue b/components/app-image-viewer/AppImageViewer.vue new file mode 100644 index 0000000..30c9735 --- /dev/null +++ b/components/app-image-viewer/AppImageViewer.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/lib/app-image/constants.ts b/lib/app-image/constants.ts new file mode 100644 index 0000000..95126a1 --- /dev/null +++ b/lib/app-image/constants.ts @@ -0,0 +1,32 @@ +/** Magic byte at start of image header */ +export const IMAGE_MAGIC = 0xE9; + +/** Image header size in bytes (magic, segment_count, spi_mode, spi_speed_size, entry_point, padding) */ +export const IMAGE_HEADER_SIZE = 8; + +/** Extended header size in bytes */ +export const EXTENDED_HEADER_SIZE = 16; + +/** Segment header size in bytes */ +export const SEGMENT_HEADER_SIZE = 8; + +/** Magic word for esp_app_desc_t */ +export const APP_DESC_MAGIC = 0xABCD5432; + +/** Size of esp_app_desc_t structure */ +export const APP_DESC_SIZE = 256; + +/** Chip ID to human-readable name */ +export const CHIP_ID_NAMES: Record = { + 0x0000: 'ESP32', + 0x0002: 'ESP32-S2', + 0x0005: 'ESP32-C3', + 0x0009: 'ESP32-S3', + 0x000C: 'ESP32-C2', + 0x000D: 'ESP32-C6', + 0x0010: 'ESP32-H2', + 0x0012: 'ESP32-P4', + 0x0011: 'ESP32-C61', + 0x0014: 'ESP32-C5', + 0x0016: 'ESP32-H4', +}; diff --git a/lib/app-image/index.ts b/lib/app-image/index.ts new file mode 100644 index 0000000..8f44b64 --- /dev/null +++ b/lib/app-image/index.ts @@ -0,0 +1,20 @@ +// Types +export type { + ImageHeader, ExtendedHeader, SegmentHeader, + AppDescription, AppImageInfo, +} from './types'; + +export { + SpiFlashMode, SpiFlashSpeed, SpiFlashSize, + SPI_FLASH_MODE_NAMES, SPI_FLASH_SPEED_NAMES, SPI_FLASH_SIZE_NAMES, +} from './types'; + +// Constants +export { + IMAGE_MAGIC, IMAGE_HEADER_SIZE, EXTENDED_HEADER_SIZE, + SEGMENT_HEADER_SIZE, APP_DESC_MAGIC, APP_DESC_SIZE, + CHIP_ID_NAMES, +} from './constants'; + +// Parser +export { parseAppImage } from './parser'; diff --git a/lib/app-image/parser.ts b/lib/app-image/parser.ts new file mode 100644 index 0000000..88a1a1f --- /dev/null +++ b/lib/app-image/parser.ts @@ -0,0 +1,104 @@ +import { readU8, readU16, readU32, readNullTermString } from '../shared/binary-reader'; +import { + AppImageInfo, ImageHeader, ExtendedHeader, SegmentHeader, AppDescription, +} from './types'; +import { + IMAGE_MAGIC, IMAGE_HEADER_SIZE, EXTENDED_HEADER_SIZE, + SEGMENT_HEADER_SIZE, APP_DESC_MAGIC, APP_DESC_SIZE, CHIP_ID_NAMES, +} from './constants'; + +/** + * Parse an ESP32 app image binary. + * Read-only parser — extracts headers, segment info, and app description. + * + * @param data Raw binary data of the app image + */ +export function parseAppImage(data: Uint8Array): AppImageInfo { + if (data.length < IMAGE_HEADER_SIZE + EXTENDED_HEADER_SIZE) { + throw new Error(`数据太短: ${data.length} 字节 (最少需要 ${IMAGE_HEADER_SIZE + EXTENDED_HEADER_SIZE} 字节)`); + } + + // ── Image Header (8 bytes: magic + segments + spi_mode + spi_speed_size + entry_addr) ── + const magic = readU8(data, 0); + if (magic !== IMAGE_MAGIC) { + throw new Error(`无效的魔数: 0x${magic.toString(16)} (应为 0xE9)`); + } + + const segmentCount = readU8(data, 1); + const spiModeRaw = readU8(data, 2); + const spiSpeedSize = readU8(data, 3); + const entryPoint = readU32(data, 4); + + const header: ImageHeader = { + magic, + segmentCount, + spiMode: spiModeRaw, + spiSpeed: spiSpeedSize & 0x0F, + spiSize: (spiSpeedSize >> 4) & 0x0F, + entryPoint, + }; + + // ── Extended Header (16 bytes at offset 8) ── + const extOff = IMAGE_HEADER_SIZE; + const wpPin = readU8(data, extOff); // +0 = offset 8 + const chipId = readU16(data, extOff + 4); // +4 = offset 12 + const minChipRev = readU8(data, extOff + 6); // +6 = offset 14 + const minChipRevFull = readU16(data, extOff + 7); // +7 = offset 15 (packed) + const maxChipRevFull = readU16(data, extOff + 9); // +9 = offset 17 (packed) + const hashAppended = readU8(data, extOff + 15) === 1; // +15 = offset 23 + + const extendedHeader: ExtendedHeader = { + wpPin, + spiPinDrv: [readU8(data, extOff + 1), readU8(data, extOff + 2), readU8(data, extOff + 3)], + chipId, + minChipRev, + minChipRevFull, + maxChipRevFull, + hashAppended, + }; + + // ── Segment Headers ── + const segments: SegmentHeader[] = []; + // Track each segment's data start offset internally (not exposed in public API) + const segDataOffsets: number[] = []; + let segOff = IMAGE_HEADER_SIZE + EXTENDED_HEADER_SIZE; + + for (let i = 0; i < segmentCount && segOff + SEGMENT_HEADER_SIZE <= data.length; i++) { + const loadAddr = readU32(data, segOff); + const dataLen = readU32(data, segOff + 4); + const dataOffset = segOff + SEGMENT_HEADER_SIZE; + if (dataOffset + dataLen > data.length) break; // truncated image + segments.push({ loadAddr, dataLen }); + segDataOffsets.push(dataOffset); + segOff = dataOffset + dataLen; // advance past segment data + } + + // ── App Description — scan all segments for APP_DESC_MAGIC ── + let appDescription: AppDescription | null = null; + for (const off of segDataOffsets) { + if (off + 4 > data.length) continue; + const descMagic = readU32(data, off); + if (descMagic === APP_DESC_MAGIC && off + APP_DESC_SIZE <= data.length) { + appDescription = { + magicWord: descMagic, + secureVersion: readU32(data, off + 4), + version: readNullTermString(data, off + 16, 32), + projectName: readNullTermString(data, off + 48, 32), + compileTime: readNullTermString(data, off + 80, 16), + compileDate: readNullTermString(data, off + 96, 16), + idfVersion: readNullTermString(data, off + 112, 32), + appElfSha256: new Uint8Array(data.subarray(off + 144, off + 176)), + }; + break; + } + } + + return { + header, + extendedHeader, + segments, + appDescription, + valid: segments.length === segmentCount, // false if image was truncated mid-segment + chipName: CHIP_ID_NAMES[chipId] ?? `Unknown (0x${chipId.toString(16)})`, + }; +} diff --git a/lib/app-image/types.ts b/lib/app-image/types.ts new file mode 100644 index 0000000..274cc9d --- /dev/null +++ b/lib/app-image/types.ts @@ -0,0 +1,93 @@ +export enum SpiFlashMode { + QIO = 0, + QOUT = 1, + DIO = 2, + DOUT = 3, +} + +export enum SpiFlashSpeed { + SPEED_40M = 0x0, + SPEED_26M = 0x1, + SPEED_20M = 0x2, + SPEED_80M = 0xF, +} + +export enum SpiFlashSize { + SIZE_1MB = 0x0, + SIZE_2MB = 0x1, + SIZE_4MB = 0x2, + SIZE_8MB = 0x3, + SIZE_16MB = 0x4, + SIZE_32MB = 0x5, + SIZE_64MB = 0x6, + SIZE_128MB = 0x7, +} + +export interface ImageHeader { + magic: number; + segmentCount: number; + spiMode: SpiFlashMode; + spiSpeed: SpiFlashSpeed; + spiSize: SpiFlashSize; + entryPoint: number; +} + +export interface ExtendedHeader { + wpPin: number; + spiPinDrv: [number, number, number]; + chipId: number; + minChipRev: number; + minChipRevFull: number; + maxChipRevFull: number; + hashAppended: boolean; +} + +export interface SegmentHeader { + loadAddr: number; + dataLen: number; +} + +export interface AppDescription { + magicWord: number; + secureVersion: number; + version: string; + projectName: string; + compileTime: string; + compileDate: string; + idfVersion: string; + appElfSha256: Uint8Array; +} + +export interface AppImageInfo { + header: ImageHeader; + extendedHeader: ExtendedHeader; + segments: SegmentHeader[]; + appDescription: AppDescription | null; + valid: boolean; + chipName: string; +} + +export const SPI_FLASH_MODE_NAMES: Record = { + [SpiFlashMode.QIO]: 'QIO', + [SpiFlashMode.QOUT]: 'QOUT', + [SpiFlashMode.DIO]: 'DIO', + [SpiFlashMode.DOUT]: 'DOUT', +}; + +export const SPI_FLASH_SPEED_NAMES: Record = { + [SpiFlashSpeed.SPEED_40M]: '40MHz', + [SpiFlashSpeed.SPEED_26M]: '26MHz', + [SpiFlashSpeed.SPEED_20M]: '20MHz', + [SpiFlashSpeed.SPEED_80M]: '80MHz', +}; + +export const SPI_FLASH_SIZE_NAMES: Record = { + [SpiFlashSize.SIZE_1MB]: '1MB', + [SpiFlashSize.SIZE_2MB]: '2MB', + [SpiFlashSize.SIZE_4MB]: '4MB', + [SpiFlashSize.SIZE_8MB]: '8MB', + [SpiFlashSize.SIZE_16MB]: '16MB', + [SpiFlashSize.SIZE_32MB]: '32MB', + [SpiFlashSize.SIZE_64MB]: '64MB', + [SpiFlashSize.SIZE_128MB]: '128MB', +}; diff --git a/lib/shared/binary-reader.ts b/lib/shared/binary-reader.ts new file mode 100644 index 0000000..6c3c90e --- /dev/null +++ b/lib/shared/binary-reader.ts @@ -0,0 +1,46 @@ +// ── Little-endian read helpers ───────────────────────────────────── + +export function readU8(buf: Uint8Array, off: number): number { + return buf[off]; +} + +export function readU16(buf: Uint8Array, off: number): number { + return buf[off] | (buf[off + 1] << 8); +} + +export function readU32(buf: Uint8Array, off: number): number { + return (buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24)) >>> 0; +} + +export function readI8(buf: Uint8Array, off: number): number { + const v = buf[off]; + return v > 127 ? v - 256 : v; +} + +export function readI16(buf: Uint8Array, off: number): number { + const v = readU16(buf, off); + return v > 32767 ? v - 65536 : v; +} + +export function readI32(buf: Uint8Array, off: number): number { + return buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24); +} + +export function readU64(buf: Uint8Array, off: number): bigint { + const lo = BigInt(readU32(buf, off)); + const hi = BigInt(readU32(buf, off + 4)); + return (hi << 32n) | lo; +} + +export function readI64(buf: Uint8Array, off: number): bigint { + const v = readU64(buf, off); + return v > 0x7FFFFFFFFFFFFFFFn ? v - 0x10000000000000000n : v; +} + +/** Read null-terminated ASCII string of max `maxLen` bytes */ +export function readNullTermString(buf: Uint8Array, off: number, maxLen: number): string { + let end = off; + while (end < off + maxLen && buf[end] !== 0) end++; + const decoder = new TextDecoder('ascii'); + return decoder.decode(buf.subarray(off, end)); +} diff --git a/lib/shared/binary-writer.ts b/lib/shared/binary-writer.ts new file mode 100644 index 0000000..a586dd9 --- /dev/null +++ b/lib/shared/binary-writer.ts @@ -0,0 +1,50 @@ +// ── Little-endian write helpers ──────────────────────────────────── + +export function writeU8(buf: Uint8Array, off: number, val: number) { + buf[off] = val & 0xFF; +} + +export function writeU16(buf: Uint8Array, off: number, val: number) { + buf[off] = val & 0xFF; + buf[off + 1] = (val >> 8) & 0xFF; +} + +export function writeU32(buf: Uint8Array, off: number, val: number) { + buf[off] = val & 0xFF; + buf[off + 1] = (val >> 8) & 0xFF; + buf[off + 2] = (val >> 16) & 0xFF; + buf[off + 3] = (val >> 24) & 0xFF; +} + +export function writeI8(buf: Uint8Array, off: number, val: number) { + buf[off] = val < 0 ? val + 256 : val; +} + +export function writeI16(buf: Uint8Array, off: number, val: number) { + const u = val < 0 ? val + 65536 : val; + writeU16(buf, off, u); +} + +export function writeI32(buf: Uint8Array, off: number, val: number) { + writeU32(buf, off, val < 0 ? val + 0x100000000 : val); +} + +export function writeU64(buf: Uint8Array, off: number, val: bigint) { + const lo = Number(val & 0xFFFFFFFFn); + const hi = Number((val >> 32n) & 0xFFFFFFFFn); + writeU32(buf, off, lo); + writeU32(buf, off + 4, hi); +} + +export function writeI64(buf: Uint8Array, off: number, val: bigint) { + const u = val < 0n ? val + 0x10000000000000000n : val; + writeU64(buf, off, u); +} + +/** Write null-terminated ASCII string padded to `fieldSize` bytes */ +export function writeNullTermString(buf: Uint8Array, off: number, str: string, fieldSize: number) { + buf.fill(0, off, off + fieldSize); + const encoder = new TextEncoder(); + const strBytes = encoder.encode(str); + buf.set(strBytes.subarray(0, fieldSize - 1), off); // leave room for null +} diff --git a/lib/shared/crc32.ts b/lib/shared/crc32.ts new file mode 100644 index 0000000..95da4f9 --- /dev/null +++ b/lib/shared/crc32.ts @@ -0,0 +1,24 @@ +/** + * Pre-computed CRC32 lookup table using reflected polynomial 0xEDB88320. + * Compatible with zlib.crc32() used by ESP-IDF. + */ +const CRC32_TABLE: Uint32Array = (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let crc = i; + for (let j = 0; j < 8; j++) { + crc = (crc & 1) ? ((crc >>> 1) ^ 0xEDB88320) : (crc >>> 1); + } + table[i] = crc; + } + return table; +})(); + +/** Compute CRC32 of a Uint8Array. Returns unsigned 32-bit integer. */ +export function crc32(data: Uint8Array): number { + let crc = 0xFFFFFFFF; + for (let i = 0; i < data.length; i++) { + crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ data[i]) & 0xFF]; + } + return (crc ^ 0xFFFFFFFF) >>> 0; +} diff --git a/lib/shared/index.ts b/lib/shared/index.ts new file mode 100644 index 0000000..ffe1733 --- /dev/null +++ b/lib/shared/index.ts @@ -0,0 +1,3 @@ +export * from './binary-reader'; +export * from './binary-writer'; +export { crc32 } from './crc32';