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
This commit is contained in:
parent
0f107073bd
commit
8320bf7ab2
|
|
@ -0,0 +1,2 @@
|
||||||
|
# AI
|
||||||
|
/.claude
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
type AppImageInfo,
|
||||||
|
SPI_FLASH_MODE_NAMES, SPI_FLASH_SPEED_NAMES, SPI_FLASH_SIZE_NAMES,
|
||||||
|
parseAppImage,
|
||||||
|
} from '../../lib/app-image';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isDark?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const imageInfo = ref<AppImageInfo | null>(null);
|
||||||
|
const statusMessage = ref('');
|
||||||
|
const statusType = ref<'success' | 'error' | 'info'>('info');
|
||||||
|
const fileName = ref('');
|
||||||
|
|
||||||
|
let statusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function showStatus(msg: string, type: 'success' | 'error' | 'info' = 'info') {
|
||||||
|
if (statusTimer !== null) {
|
||||||
|
clearTimeout(statusTimer);
|
||||||
|
statusTimer = null;
|
||||||
|
}
|
||||||
|
statusMessage.value = msg;
|
||||||
|
statusType.value = type;
|
||||||
|
statusTimer = setTimeout(() => {
|
||||||
|
statusMessage.value = '';
|
||||||
|
statusTimer = null;
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHex(val: number): string {
|
||||||
|
return '0x' + val.toString(16).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSha256(data: Uint8Array): string {
|
||||||
|
// Check if all zeros (not computed)
|
||||||
|
if (data.every(b => b === 0)) return '(未计算)';
|
||||||
|
return Array.from(data).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpenFile(file: File): Promise<false> {
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const data = new Uint8Array(buffer);
|
||||||
|
if (data.length >= 4 &&
|
||||||
|
data[0] === 0x7F && data[1] === 0x45 && data[2] === 0x4C && data[3] === 0x46) {
|
||||||
|
throw new Error('ELF 格式不支持。请使用 esptool.py elf2image 将其转换为 .bin 文件');
|
||||||
|
}
|
||||||
|
imageInfo.value = parseAppImage(data);
|
||||||
|
fileName.value = file.name;
|
||||||
|
showStatus(`已加载 ${file.name} (${data.byteLength} 字节)`, 'success');
|
||||||
|
} catch (e: any) {
|
||||||
|
imageInfo.value = null;
|
||||||
|
showStatus(`加载失败: ${e.message}`, 'error');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Status message -->
|
||||||
|
<transition name="el-fade-in">
|
||||||
|
<el-alert
|
||||||
|
v-if="statusMessage"
|
||||||
|
:title="statusMessage"
|
||||||
|
:type="statusType"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
class="mb-3"
|
||||||
|
@close="statusMessage = ''"
|
||||||
|
/>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Upload -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<el-upload :before-upload="handleOpenFile" :show-file-list="false" accept=".bin">
|
||||||
|
<el-button type="primary">打开固件文件</el-button>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="imageInfo">
|
||||||
|
<!-- App Description -->
|
||||||
|
<template v-if="imageInfo.appDescription">
|
||||||
|
<el-text tag="b" class="block mb-2">应用信息</el-text>
|
||||||
|
<el-descriptions :column="2" border size="small" class="mb-4">
|
||||||
|
<el-descriptions-item label="项目名称">{{ imageInfo.appDescription.projectName }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="版本">{{ imageInfo.appDescription.version }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="IDF版本">{{ imageInfo.appDescription.idfVersion }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="安全版本">{{ imageInfo.appDescription.secureVersion }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="编译日期">{{ imageInfo.appDescription.compileDate }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="编译时间">{{ imageInfo.appDescription.compileTime }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="ELF SHA256" :span="2">
|
||||||
|
<el-text size="small" class="font-mono break-all">
|
||||||
|
{{ formatSha256(imageInfo.appDescription.appElfSha256) }}
|
||||||
|
</el-text>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Image Header -->
|
||||||
|
<el-text tag="b" class="block mb-2">镜像头</el-text>
|
||||||
|
<el-descriptions :column="2" border size="small" class="mb-4">
|
||||||
|
<el-descriptions-item label="芯片">{{ imageInfo.chipName }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="入口地址">{{ formatHex(imageInfo.header.entryPoint) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="SPI模式">{{ SPI_FLASH_MODE_NAMES[imageInfo.header.spiMode] ?? formatHex(imageInfo.header.spiMode) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="SPI速度">{{ SPI_FLASH_SPEED_NAMES[imageInfo.header.spiSpeed] ?? formatHex(imageInfo.header.spiSpeed) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="Flash大小">{{ SPI_FLASH_SIZE_NAMES[imageInfo.header.spiSize] ?? formatHex(imageInfo.header.spiSize) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="段数">{{ imageInfo.header.segmentCount }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="WP引脚">{{ formatHex(imageInfo.extendedHeader.wpPin) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最小芯片版本">{{ imageInfo.extendedHeader.minChipRevFull / 100 }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最大芯片版本">{{ imageInfo.extendedHeader.maxChipRevFull === 0xFFFF ? '不限' : imageInfo.extendedHeader.maxChipRevFull / 100 }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="附加哈希">{{ imageInfo.extendedHeader.hashAppended ? '是' : '否' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<!-- Segments -->
|
||||||
|
<el-text tag="b" class="block mb-2">段列表</el-text>
|
||||||
|
<el-table :data="imageInfo.segments" border stripe size="small" max-height="300">
|
||||||
|
<el-table-column label="#" width="50" type="index" />
|
||||||
|
<el-table-column label="加载地址" width="160">
|
||||||
|
<template #default="{ row }">{{ formatHex(row.loadAddr) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="数据大小">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.dataLen }} 字节 ({{ (row.dataLen / 1024).toFixed(1) }} KB)
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-empty v-else description="请打开一个ESP32固件文件 (.bin)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.font-mono {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
}
|
||||||
|
.break-all {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -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<number, string> = {
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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)})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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<number, string> = {
|
||||||
|
[SpiFlashMode.QIO]: 'QIO',
|
||||||
|
[SpiFlashMode.QOUT]: 'QOUT',
|
||||||
|
[SpiFlashMode.DIO]: 'DIO',
|
||||||
|
[SpiFlashMode.DOUT]: 'DOUT',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SPI_FLASH_SPEED_NAMES: Record<number, string> = {
|
||||||
|
[SpiFlashSpeed.SPEED_40M]: '40MHz',
|
||||||
|
[SpiFlashSpeed.SPEED_26M]: '26MHz',
|
||||||
|
[SpiFlashSpeed.SPEED_20M]: '20MHz',
|
||||||
|
[SpiFlashSpeed.SPEED_80M]: '80MHz',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SPI_FLASH_SIZE_NAMES: Record<number, string> = {
|
||||||
|
[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',
|
||||||
|
};
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './binary-reader';
|
||||||
|
export * from './binary-writer';
|
||||||
|
export { crc32 } from './crc32';
|
||||||
Loading…
Reference in New Issue