feat(partition-table): add initial partition table library and Vue editor

This commit is contained in:
kerms 2026-02-22 13:54:39 +01:00
parent b23a7e5c8a
commit 34eb123f5e
Signed by: kerms
GPG Key ID: 5432C10DDCF8DAD5
11 changed files with 1179 additions and 0 deletions

View File

@ -0,0 +1,457 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import {
type PartitionTable, type PartitionEntry,
PartitionType, PartitionFlags,
TYPE_NAMES, NAME_TO_TYPE,
getSubtypeName, subtypeFromName,
AppSubtype, DataSubtype,
APP_SUBTYPE_NAMES, DATA_SUBTYPE_NAMES,
parseBinary, serializeBinary,
parseCsv, serializeCsv,
validateTable,
NAME_FIELD_SIZE,
} from '../../lib/partition-table';
const props = defineProps<{
isDark?: boolean;
}>();
// Core state
const table = ref<PartitionTable>({ entries: [], md5Valid: false });
const statusMessage = ref('');
const statusType = ref<'success' | 'error' | 'info'>('info');
// Add dialog
const showAddDialog = ref(false);
const newEntry = ref<PartitionEntry>({
name: '',
type: PartitionType.DATA,
subtype: DataSubtype.NVS,
offset: 0,
size: 0x1000,
flags: PartitionFlags.NONE,
});
// Computed
const subtypeOptions = computed(() => {
return newEntry.value.type === PartitionType.APP
? Object.entries(APP_SUBTYPE_NAMES)
: Object.entries(DATA_SUBTYPE_NAMES);
});
function getSubtypeOptionsForType(type: PartitionType) {
return type === PartitionType.APP
? Object.entries(APP_SUBTYPE_NAMES)
: Object.entries(DATA_SUBTYPE_NAMES);
}
// Helpers
function showStatus(msg: string, type: 'success' | 'error' | 'info' = 'info') {
statusMessage.value = msg;
statusType.value = type;
setTimeout(() => { statusMessage.value = ''; }, 4000);
}
function formatHex(val: number): string {
return '0x' + val.toString(16).toUpperCase();
}
function formatSize(size: number): string {
if (size >= 1024 * 1024 && size % (1024 * 1024) === 0) {
return `${size / (1024 * 1024)} MB`;
}
if (size >= 1024 && size % 1024 === 0) {
return `${size / 1024} KB`;
}
return `${size} B`;
}
function parseHexOrDec(str: string): number {
str = str.trim();
if (str.startsWith('0x') || str.startsWith('0X')) {
if (!/^0x[0-9a-f]+$/i.test(str)) throw new Error(`"${str}"`);
const v = parseInt(str, 16);
if (isNaN(v) || v < 0 || v > 0xFFFF_FFFF) throw new Error(`"${str}"`);
return v;
}
// Support K/M suffixes
const match = str.match(/^(\d+(?:\.\d+)?)\s*([KkMm])?$/);
if (match) {
const num = parseFloat(match[1]);
const unit = (match[2] || '').toUpperCase();
let result: number;
if (unit === 'K') result = Math.floor(num * 1024);
else if (unit === 'M') result = Math.floor(num * 1024 * 1024);
else result = Math.floor(num);
if (result > 0xFFFF_FFFF) throw new Error(`"${str}" (超出 32 位范围)`);
return result;
}
throw new Error(`"${str}"`);
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
// Actions
function handleAddEntry() {
if (!newEntry.value.name) return;
table.value = {
...table.value,
entries: [...table.value.entries, { ...newEntry.value }],
};
showAddDialog.value = false;
newEntry.value = {
name: '',
type: PartitionType.DATA,
subtype: DataSubtype.NVS,
offset: 0,
size: 0x1000,
flags: PartitionFlags.NONE,
};
showStatus('已添加分区', 'success');
}
function handleDeleteEntry(index: number) {
const entries = [...table.value.entries];
entries.splice(index, 1);
table.value = { ...table.value, entries };
}
function handleMoveUp(index: number) {
if (index <= 0) return;
const entries = [...table.value.entries];
[entries[index - 1], entries[index]] = [entries[index], entries[index - 1]];
table.value = { ...table.value, entries };
}
function handleMoveDown(index: number) {
if (index >= table.value.entries.length - 1) return;
const entries = [...table.value.entries];
[entries[index], entries[index + 1]] = [entries[index + 1], entries[index]];
table.value = { ...table.value, entries };
}
function handleUpdateName(index: number, val: string) {
const entries = [...table.value.entries];
entries[index] = { ...entries[index], name: val.substring(0, NAME_FIELD_SIZE - 1) };
table.value = { ...table.value, entries };
}
function handleUpdateType(index: number, val: PartitionType) {
const entries = [...table.value.entries];
// Reset subtype when type changes
const defaultSubtype = val === PartitionType.APP ? AppSubtype.FACTORY : DataSubtype.NVS;
entries[index] = { ...entries[index], type: val, subtype: defaultSubtype };
table.value = { ...table.value, entries };
}
function handleUpdateSubtype(index: number, val: number) {
const entries = [...table.value.entries];
entries[index] = { ...entries[index], subtype: val };
table.value = { ...table.value, entries };
}
function handleUpdateOffset(index: number, val: string) {
try {
const entries = [...table.value.entries];
entries[index] = { ...entries[index], offset: parseHexOrDec(val) };
table.value = { ...table.value, entries };
} catch (e: any) {
showStatus(`无效的偏移量: ${e.message}`, 'error');
}
}
function handleUpdateSize(index: number, val: string) {
try {
const entries = [...table.value.entries];
entries[index] = { ...entries[index], size: parseHexOrDec(val) };
table.value = { ...table.value, entries };
} catch (e: any) {
showStatus(`无效的大小: ${e.message}`, 'error');
}
}
function handleUpdateFlags(index: number, encrypted: boolean) {
const entries = [...table.value.entries];
const cur = entries[index].flags;
entries[index] = { ...entries[index], flags: encrypted
? cur | PartitionFlags.ENCRYPTED
: cur & ~PartitionFlags.ENCRYPTED };
table.value = { ...table.value, entries };
}
function handleClear() {
table.value = { entries: [], md5Valid: false };
showStatus('已清空分区表', 'info');
}
// File I/O
async function handleOpenBinary(file: File): Promise<false> {
try {
const buffer = await file.arrayBuffer();
table.value = parseBinary(new Uint8Array(buffer));
const md5Status = table.value.md5Valid ? '有效' : '无效/缺失';
const corruptedNote = table.value.corrupted ? ', 检测到二进制损坏' : '';
showStatus(
`已加载 ${file.name} (${table.value.entries.length} 分区, MD5: ${md5Status}${corruptedNote})`,
table.value.corrupted ? 'error' : 'success',
);
} catch (e: any) {
showStatus(`加载失败: ${e.message}`, 'error');
}
return false;
}
function handleExportBinary() {
const errors = validateTable(table.value);
if (errors.length > 0) {
const suffix = errors.length > 1 ? `${errors.length} 个问题` : '';
showStatus(`导出取消: ${errors[0].message}${suffix}`, 'error');
return;
}
try {
const data = serializeBinary(table.value);
downloadBlob(new Blob([data]), 'partitions.bin');
showStatus('已导出 partitions.bin', 'success');
} catch (e: any) {
showStatus(`导出失败: ${e.message}`, 'error');
}
}
async function handleOpenCsv(file: File): Promise<false> {
try {
const text = await file.text();
const warnings: string[] = [];
table.value = parseCsv(text, (line, msg) => warnings.push(`${line}: ${msg}`));
if (warnings.length > 0) {
const preview = warnings.slice(0, 2).join('; ') + (warnings.length > 2 ? '…' : '');
showStatus(`已加载 ${file.name} (${table.value.entries.length} 分区,${warnings.length} 个警告: ${preview})`, 'error');
} else {
showStatus(`已加载 ${file.name} (${table.value.entries.length} 分区)`, 'success');
}
} catch (e: any) {
showStatus(`加载失败: ${e.message}`, 'error');
}
return false;
}
function handleExportCsv() {
try {
const text = serializeCsv(table.value);
downloadBlob(new Blob([text], { type: 'text/csv;charset=utf-8' }), 'partitions.csv');
showStatus('已导出 partitions.csv', 'success');
} catch (e: any) {
showStatus(`导出失败: ${e.message}`, 'error');
}
}
</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>
<!-- Toolbar -->
<div class="flex flex-wrap items-center gap-2 mb-3">
<el-button type="primary" @click="showAddDialog = true">添加分区</el-button>
<el-button type="danger" plain @click="handleClear">清空</el-button>
<el-divider direction="vertical" />
<el-text size="small">{{ table.entries.length }} 个分区</el-text>
</div>
<!-- Data table -->
<el-table
:data="table.entries"
border
stripe
size="small"
empty-text="暂无分区,请添加或导入"
max-height="500"
>
<el-table-column label="名称" width="160">
<template #default="{ row, $index }">
<el-input
:model-value="row.name"
size="small"
:maxlength="15"
@change="(val: string) => handleUpdateName($index, val)"
/>
</template>
</el-table-column>
<el-table-column label="类型" width="110">
<template #default="{ row, $index }">
<el-select
:model-value="row.type"
size="small"
@change="(val: PartitionType) => handleUpdateType($index, val)"
>
<el-option label="app" :value="PartitionType.APP" />
<el-option label="data" :value="PartitionType.DATA" />
</el-select>
</template>
</el-table-column>
<el-table-column label="子类型" width="140">
<template #default="{ row, $index }">
<el-select
:model-value="row.subtype"
size="small"
@change="(val: number) => handleUpdateSubtype($index, val)"
>
<el-option
v-for="[val, name] in getSubtypeOptionsForType(row.type)"
:key="val"
:label="name"
:value="Number(val)"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="偏移量" width="140">
<template #default="{ row, $index }">
<el-input
:model-value="formatHex(row.offset)"
size="small"
@change="(val: string) => handleUpdateOffset($index, val)"
/>
</template>
</el-table-column>
<el-table-column label="大小" width="140">
<template #default="{ row, $index }">
<el-input
:model-value="formatHex(row.size)"
size="small"
@change="(val: string) => handleUpdateSize($index, val)"
>
<template #append>
<span class="text-xs">{{ formatSize(row.size) }}</span>
</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="加密" width="70" align="center">
<template #default="{ row, $index }">
<el-checkbox
:model-value="(row.flags & 0x01) !== 0"
@change="(val: boolean) => handleUpdateFlags($index, val)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ $index }">
<el-button size="small" text @click="handleMoveUp($index)" :disabled="$index === 0"></el-button>
<el-button size="small" text @click="handleMoveDown($index)" :disabled="$index === table.entries.length - 1"></el-button>
<el-popconfirm title="确定删除?" @confirm="handleDeleteEntry($index)">
<template #reference>
<el-button size="small" text type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- Import/Export section -->
<el-divider />
<div class="flex flex-wrap gap-4">
<div>
<el-text tag="b" class="block mb-2">二进制文件 (.bin)</el-text>
<div class="flex flex-wrap gap-2">
<el-upload :before-upload="handleOpenBinary" :show-file-list="false" accept=".bin">
<el-button>打开</el-button>
</el-upload>
<el-button type="primary" @click="handleExportBinary">导出</el-button>
</div>
</div>
<div>
<el-text tag="b" class="block mb-2">CSV文件 (.csv)</el-text>
<div class="flex flex-wrap gap-2">
<el-upload :before-upload="handleOpenCsv" :show-file-list="false" accept=".csv">
<el-button>打开</el-button>
</el-upload>
<el-button type="primary" @click="handleExportCsv">导出</el-button>
</div>
</div>
</div>
<!-- Add partition dialog -->
<el-dialog v-model="showAddDialog" title="添加分区" width="450px">
<el-form label-width="80px">
<el-form-item label="名称">
<el-input v-model="newEntry.name" :maxlength="15" placeholder="partition name" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="newEntry.type">
<el-option label="app" :value="PartitionType.APP" />
<el-option label="data" :value="PartitionType.DATA" />
</el-select>
</el-form-item>
<el-form-item label="子类型">
<el-select v-model="newEntry.subtype">
<el-option
v-for="[val, name] in subtypeOptions"
:key="val"
:label="name"
:value="Number(val)"
/>
</el-select>
</el-form-item>
<el-form-item label="偏移量">
<el-input
:model-value="formatHex(newEntry.offset)"
@change="(val: string) => { try { newEntry.offset = parseHexOrDec(val) } catch { showStatus('无效的偏移量', 'error') } }"
placeholder="0x9000"
/>
</el-form-item>
<el-form-item label="大小">
<el-input
:model-value="formatHex(newEntry.size)"
@change="(val: string) => { try { newEntry.size = parseHexOrDec(val) } catch { showStatus('无效的大小', 'error') } }"
placeholder="0x6000 or 24K"
/>
</el-form-item>
<el-form-item label="加密">
<el-checkbox
:model-value="(newEntry.flags & PartitionFlags.ENCRYPTED) !== 0"
@change="(val: boolean) => newEntry.flags = val
? newEntry.flags | PartitionFlags.ENCRYPTED
: newEntry.flags & ~PartitionFlags.ENCRYPTED"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="handleAddEntry" :disabled="!newEntry.name">添加</el-button>
</template>
</el-dialog>
</div>
</template>

4
lib/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from './shared';
export * as nvs from './nvs';
export * as partitionTable from './partition-table';
export * as appImage from './app-image';

View File

@ -0,0 +1,20 @@
/** Each partition entry is 32 bytes */
export const ENTRY_SIZE = 32;
/** Magic bytes marking a valid entry (little-endian 0xAA50) */
export const ENTRY_MAGIC = 0x50AA;
/** MD5 checksum marker (little-endian 0xEBEB) */
export const MD5_MAGIC = 0xEBEB;
/** Default partition table offset in flash */
export const DEFAULT_TABLE_OFFSET = 0x8000;
/** Maximum number of partition entries */
export const MAX_ENTRIES = 95;
/** Name field size in bytes */
export const NAME_FIELD_SIZE = 16;
/** Partition table maximum binary size (3KB) */
export const TABLE_MAX_SIZE = 0xC00;

View File

@ -0,0 +1,144 @@
import { PartitionEntry, PartitionFlags, PartitionTable, PartitionType, NAME_TO_TYPE, subtypeFromName } from './types';
const U32_MAX = 0xFFFF_FFFF;
/** Parse partition flags: handles numeric values, single names, and combined forms like "encrypted readonly" */
function parseFlags(str: string): number {
if (!str) return 0;
const normalized = str.trim().toLowerCase();
// Numeric values (hex or decimal) take priority
if (/^0x[\da-f]+$/i.test(normalized) || /^\d+$/.test(normalized)) {
return parseSize(str);
}
// Split on whitespace, pipe, or comma to support combined flags
let result = 0;
for (const part of normalized.split(/[\s|,]+/).filter(Boolean)) {
if (part === 'encrypted') result |= PartitionFlags.ENCRYPTED;
else if (part === 'readonly') result |= PartitionFlags.READONLY;
else throw new Error(`未知标志: "${part}"`);
}
return result;
}
/** Parse a size string like "0x6000", "1M", "32K", "4096" */
function parseSize(str: string): number {
str = str.trim();
if (!str) return 0;
if (str.startsWith('0x') || str.startsWith('0X')) {
if (!/^0x[0-9a-f]+$/i.test(str)) throw new Error(`无效的大小/偏移值: "${str}"`);
const v = parseInt(str, 16);
if (isNaN(v) || v < 0 || v > U32_MAX) throw new Error(`无效的大小/偏移值: "${str}"`);
return v;
}
const match = str.match(/^(\d+(?:\.\d+)?)\s*([KkMm])?$/);
if (match) {
const num = parseFloat(match[1]);
const unit = (match[2] || '').toUpperCase();
let result: number;
if (unit === 'K') result = Math.floor(num * 1024);
else if (unit === 'M') result = Math.floor(num * 1024 * 1024);
else result = Math.floor(num);
if (result > U32_MAX) throw new Error(`无效的大小/偏移值: "${str}" (超出 32 位范围)`);
return result;
}
throw new Error(`无效的大小/偏移值: "${str}"`);
}
/**
* Split one CSV line into trimmed fields, respecting RFC-4180 quoting and
* treating an unquoted '#' as the start of an inline comment (discards rest).
*/
function splitCsvLine(line: string): string[] {
const fields: string[] = [];
let current = '';
let inQuotes = false;
let i = 0;
while (i < line.length) {
const ch = line[i];
if (inQuotes) {
if (ch === '"' && line[i + 1] === '"') { // escaped quote ""
current += '"';
i += 2;
} else if (ch === '"') {
inQuotes = false;
i++;
} else {
current += ch;
i++;
}
} else {
if (ch === '"') {
inQuotes = true;
i++;
} else if (ch === ',') {
fields.push(current.trim());
current = '';
i++;
} else if (ch === '#') {
break; // inline comment — discard rest of line
} else {
current += ch;
i++;
}
}
}
if (inQuotes) throw new Error('引号未闭合');
fields.push(current.trim());
return fields;
}
/**
* Parse ESP-IDF partition table CSV format.
*
* Format:
* # Name, Type, SubType, Offset, Size, Flags
* nvs, data, nvs, 0x9000, 0x6000,
* phy_init, data, phy, 0xf000, 0x1000,
* factory, app, factory, 0x10000, 1M,
*/
export function parseCsv(text: string, onWarning?: (line: number, message: string) => void): PartitionTable {
const lines = text.split(/\r?\n/);
const entries: PartitionEntry[] = [];
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
const line = lines[lineIdx].trim();
if (!line || line.startsWith('#')) continue;
try {
const fields = splitCsvLine(line);
if (fields.length < 5) {
onWarning?.(lineIdx + 1, `字段数量不足 (需要 5实际 ${fields.length}): "${line}"`);
continue;
}
// Skip header line
if (fields[0].toLowerCase() === 'name' && fields[1].toLowerCase() === 'type') continue;
const name = fields[0];
const typeName = fields[1].toLowerCase();
const subtypeName = fields[2];
const offsetStr = fields[3];
const sizeStr = fields[4];
const flagsStr = fields[5] || '';
const type = NAME_TO_TYPE[typeName];
if (type === undefined) {
onWarning?.(lineIdx + 1, `未知分区类型: "${typeName}"`);
continue;
}
const subtype = subtypeFromName(type, subtypeName);
const offset = parseSize(offsetStr);
const size = parseSize(sizeStr);
const flags = parseFlags(flagsStr);
entries.push({ name, type, subtype, offset, size, flags });
} catch (e) {
onWarning?.(lineIdx + 1, `解析失败: ${(e as Error).message}`);
}
}
return { entries, md5Valid: false };
}

View File

@ -0,0 +1,40 @@
import { PartitionTable, TYPE_NAMES, getSubtypeName } from './types';
/** Format a number as hex with 0x prefix */
function toHex(val: number): string {
return '0x' + val.toString(16);
}
/** Format size with human-readable suffix if aligned */
function formatSize(size: number): string {
if (size >= 1024 * 1024 && size % (1024 * 1024) === 0) {
return `${size / (1024 * 1024)}M`;
}
if (size >= 1024 && size % 1024 === 0) {
return `${size / 1024}K`;
}
return toHex(size);
}
/**
* Serialize a PartitionTable to ESP-IDF CSV format.
*
* Output:
* # Name, Type, SubType, Offset, Size, Flags
* nvs, data, nvs, 0x9000, 0x6000,
*/
export function serializeCsv(table: PartitionTable): string {
const lines: string[] = ['# Name, Type, SubType, Offset, Size, Flags'];
for (const entry of table.entries) {
const typeName = TYPE_NAMES[entry.type] ?? `0x${entry.type.toString(16).padStart(2, '0')}`;
const subtypeName = getSubtypeName(entry.type, entry.subtype);
const offset = toHex(entry.offset);
const size = formatSize(entry.size);
const flags = entry.flags ? toHex(entry.flags) : '';
lines.push(`${entry.name}, ${typeName}, ${subtypeName}, ${offset}, ${size}, ${flags}`);
}
return lines.join('\n') + '\n';
}

View File

@ -0,0 +1,26 @@
// Types and interfaces
export type { PartitionEntry, PartitionTable } from './types';
export {
PartitionType, AppSubtype, DataSubtype, PartitionFlags,
TYPE_NAMES, APP_SUBTYPE_NAMES, DATA_SUBTYPE_NAMES,
NAME_TO_TYPE,
getSubtypeName, subtypeFromName,
} from './types';
// Constants
export {
ENTRY_SIZE, ENTRY_MAGIC, MD5_MAGIC,
DEFAULT_TABLE_OFFSET, MAX_ENTRIES, NAME_FIELD_SIZE, TABLE_MAX_SIZE,
} from './constants';
// Binary operations
export { parseBinary } from './parser';
export { serializeBinary } from './serializer';
// CSV operations
export { parseCsv } from './csv-parser';
export { serializeCsv } from './csv-serializer';
// Validation
export { validateTable, type PartitionValidationError } from './validator';

153
lib/partition-table/md5.ts Normal file
View File

@ -0,0 +1,153 @@
/**
* Lightweight MD5 implementation for partition table checksum validation.
* Web Crypto API does not support MD5, so we need a pure JS implementation.
*/
function md5cycle(x: number[], k: number[]) {
let a = x[0], b = x[1], c = x[2], d = x[3];
a = ff(a, b, c, d, k[0], 7, -680876936);
d = ff(d, a, b, c, k[1], 12, -389564586);
c = ff(c, d, a, b, k[2], 17, 606105819);
b = ff(b, c, d, a, k[3], 22, -1044525330);
a = ff(a, b, c, d, k[4], 7, -176418897);
d = ff(d, a, b, c, k[5], 12, 1200080426);
c = ff(c, d, a, b, k[6], 17, -1473231341);
b = ff(b, c, d, a, k[7], 22, -45705983);
a = ff(a, b, c, d, k[8], 7, 1770035416);
d = ff(d, a, b, c, k[9], 12, -1958414417);
c = ff(c, d, a, b, k[10], 17, -42063);
b = ff(b, c, d, a, k[11], 22, -1990404162);
a = ff(a, b, c, d, k[12], 7, 1804603682);
d = ff(d, a, b, c, k[13], 12, -40341101);
c = ff(c, d, a, b, k[14], 17, -1502002290);
b = ff(b, c, d, a, k[15], 22, 1236535329);
a = gg(a, b, c, d, k[1], 5, -165796510);
d = gg(d, a, b, c, k[6], 9, -1069501632);
c = gg(c, d, a, b, k[11], 14, 643717713);
b = gg(b, c, d, a, k[0], 20, -373897302);
a = gg(a, b, c, d, k[5], 5, -701558691);
d = gg(d, a, b, c, k[10], 9, 38016083);
c = gg(c, d, a, b, k[15], 14, -660478335);
b = gg(b, c, d, a, k[4], 20, -405537848);
a = gg(a, b, c, d, k[9], 5, 568446438);
d = gg(d, a, b, c, k[14], 9, -1019803690);
c = gg(c, d, a, b, k[3], 14, -187363961);
b = gg(b, c, d, a, k[8], 20, 1163531501);
a = gg(a, b, c, d, k[13], 5, -1444681467);
d = gg(d, a, b, c, k[2], 9, -51403784);
c = gg(c, d, a, b, k[7], 14, 1735328473);
b = gg(b, c, d, a, k[12], 20, -1926607734);
a = hh(a, b, c, d, k[5], 4, -378558);
d = hh(d, a, b, c, k[8], 11, -2022574463);
c = hh(c, d, a, b, k[11], 16, 1839030562);
b = hh(b, c, d, a, k[14], 23, -35309556);
a = hh(a, b, c, d, k[1], 4, -1530992060);
d = hh(d, a, b, c, k[4], 11, 1272893353);
c = hh(c, d, a, b, k[7], 16, -155497632);
b = hh(b, c, d, a, k[10], 23, -1094730640);
a = hh(a, b, c, d, k[13], 4, 681279174);
d = hh(d, a, b, c, k[0], 11, -358537222);
c = hh(c, d, a, b, k[3], 16, -722521979);
b = hh(b, c, d, a, k[6], 23, 76029189);
a = hh(a, b, c, d, k[9], 4, -640364487);
d = hh(d, a, b, c, k[12], 11, -421815835);
c = hh(c, d, a, b, k[15], 16, 530742520);
b = hh(b, c, d, a, k[2], 23, -995338651);
a = ii(a, b, c, d, k[0], 6, -198630844);
d = ii(d, a, b, c, k[7], 10, 1126891415);
c = ii(c, d, a, b, k[14], 15, -1416354905);
b = ii(b, c, d, a, k[5], 21, -57434055);
a = ii(a, b, c, d, k[12], 6, 1700485571);
d = ii(d, a, b, c, k[3], 10, -1894986606);
c = ii(c, d, a, b, k[10], 15, -1051523);
b = ii(b, c, d, a, k[1], 21, -2054922799);
a = ii(a, b, c, d, k[8], 6, 1873313359);
d = ii(d, a, b, c, k[15], 10, -30611744);
c = ii(c, d, a, b, k[6], 15, -1560198380);
b = ii(b, c, d, a, k[13], 21, 1309151649);
a = ii(a, b, c, d, k[4], 6, -145523070);
d = ii(d, a, b, c, k[11], 10, -1120210379);
c = ii(c, d, a, b, k[2], 15, 718787259);
b = ii(b, c, d, a, k[9], 21, -343485551);
x[0] = add32(a, x[0]);
x[1] = add32(b, x[1]);
x[2] = add32(c, x[2]);
x[3] = add32(d, x[3]);
}
function cmn(q: number, a: number, b: number, x: number, s: number, t: number): number {
a = add32(add32(a, q), add32(x, t));
return add32((a << s) | (a >>> (32 - s)), b);
}
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn((b & c) | ((~b) & d), a, b, x, s, t);
}
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn((b & d) | (c & (~d)), a, b, x, s, t);
}
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn(b ^ c ^ d, a, b, x, s, t);
}
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn(c ^ (b | (~d)), a, b, x, s, t);
}
function add32(a: number, b: number): number {
return (a + b) & 0xFFFFFFFF;
}
/** Compute MD5 hash of a Uint8Array. Returns 16-byte Uint8Array. */
export function md5(data: Uint8Array): Uint8Array {
const n = data.length;
const state = [1732584193, -271733879, -1732584194, 271733878];
let i: number;
// Pre-processing: pad message
// Message length in bits = n * 8
// Append 0x80, then zeros, then 64-bit length
const padLen = ((56 - (n + 1) % 64) + 64) % 64;
const totalLen = n + 1 + padLen + 8;
const buf = new Uint8Array(totalLen);
buf.set(data);
buf[n] = 0x80;
// Write length in bits as 64-bit LE
const bitLen = n * 8;
buf[totalLen - 8] = bitLen & 0xFF;
buf[totalLen - 7] = (bitLen >> 8) & 0xFF;
buf[totalLen - 6] = (bitLen >> 16) & 0xFF;
buf[totalLen - 5] = (bitLen >> 24) & 0xFF;
// Upper 32 bits of bit length (for messages < 512MB, these are 0)
buf[totalLen - 4] = 0;
buf[totalLen - 3] = 0;
buf[totalLen - 2] = 0;
buf[totalLen - 1] = 0;
// Process each 64-byte block
for (i = 0; i < totalLen; i += 64) {
const k = new Array<number>(16);
for (let j = 0; j < 16; j++) {
const off = i + j * 4;
k[j] = buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24);
}
md5cycle(state, k);
}
// Convert state to bytes (LE)
const result = new Uint8Array(16);
for (let j = 0; j < 4; j++) {
result[j * 4] = state[j] & 0xFF;
result[j * 4 + 1] = (state[j] >> 8) & 0xFF;
result[j * 4 + 2] = (state[j] >> 16) & 0xFF;
result[j * 4 + 3] = (state[j] >> 24) & 0xFF;
}
return result;
}

View File

@ -0,0 +1,73 @@
import { readU16, readU32, readNullTermString } from '../shared/binary-reader';
import { PartitionEntry, PartitionTable, PartitionType } from './types';
import { ENTRY_SIZE, ENTRY_MAGIC, MD5_MAGIC, NAME_FIELD_SIZE, MAX_ENTRIES } from './constants';
import { md5 } from './md5';
/**
* Parse ESP32 partition table binary.
* @param data Raw binary data (the partition table region, typically 3KB at offset 0x8000)
*/
export function parseBinary(data: Uint8Array): PartitionTable {
const entries: PartitionEntry[] = [];
let md5Valid = false;
let corrupted = false;
let offset = 0;
for (let i = 0; i < MAX_ENTRIES && offset + ENTRY_SIZE <= data.length; i++) {
const magic = readU16(data, offset);
if (magic === 0xFFFF) {
// Empty entry — end of table (erased flash)
break;
}
if (magic === MD5_MAGIC) {
// MD5 checksum entry
// Bytes [16..31] contain the stored MD5 hash of all preceding bytes
const storedMd5 = data.subarray(offset + 16, offset + 32);
const computedMd5 = md5(data.subarray(0, offset));
md5Valid = storedMd5.length === 16 && computedMd5.length === 16 &&
storedMd5.every((v, j) => v === computedMd5[j]);
break;
}
if (magic !== ENTRY_MAGIC) {
// Unknown magic — binary is corrupted
corrupted = true;
break;
}
// Parse 32-byte entry:
// [0..1] magic (already read)
// [2] type
// [3] subtype
// [4..7] offset (LE uint32)
// [8..11] size (LE uint32)
// [12..27] name (null-terminated, 16 bytes)
// [28..31] flags (LE uint32)
const type = data[offset + 2] as PartitionType;
const subtype = data[offset + 3];
const partOffset = readU32(data, offset + 4);
const size = readU32(data, offset + 8);
const name = readNullTermString(data, offset + 12, NAME_FIELD_SIZE);
const flags = readU32(data, offset + 28);
entries.push({ name, type, subtype, offset: partOffset, size, flags });
offset += ENTRY_SIZE;
}
// When the table is exactly full (MAX_ENTRIES entries), the loop exits on
// i >= MAX_ENTRIES without ever seeing the MD5 slot. Check it explicitly.
if (!md5Valid && offset + ENTRY_SIZE <= data.length) {
const magic = readU16(data, offset);
if (magic === MD5_MAGIC) {
const storedMd5 = data.subarray(offset + 16, offset + 32);
const computedMd5 = md5(data.subarray(0, offset));
md5Valid = storedMd5.length === 16 && computedMd5.length === 16 &&
storedMd5.every((v, j) => v === computedMd5[j]);
}
}
return { entries, md5Valid, ...(corrupted ? { corrupted } : {}) };
}

View File

@ -0,0 +1,56 @@
import { writeU16, writeU32, writeNullTermString } from '../shared/binary-writer';
import { PartitionTable } from './types';
import { ENTRY_SIZE, ENTRY_MAGIC, MD5_MAGIC, NAME_FIELD_SIZE, TABLE_MAX_SIZE } from './constants';
import { md5 } from './md5';
const U32_MAX = 0xFFFF_FFFF;
function assertU8(val: number, field: string): void {
if (!Number.isInteger(val) || val < 0 || val > 0xFF)
throw new Error(`"${field}" 不是有效的字节值 (0255): ${val}`);
}
function assertU32(val: number, field: string): void {
if (!Number.isInteger(val) || val < 0 || val > U32_MAX)
throw new Error(`"${field}" 不是有效的 32 位无符号整数 (00xFFFFFFFF): ${val}`);
}
/**
* Serialize a PartitionTable to ESP32 binary format.
* Returns a Uint8Array of TABLE_MAX_SIZE filled with 0xFF for empty space.
*/
export function serializeBinary(table: PartitionTable): Uint8Array {
const buf = new Uint8Array(TABLE_MAX_SIZE);
buf.fill(0xFF);
let offset = 0;
for (const entry of table.entries) {
if (offset + ENTRY_SIZE > TABLE_MAX_SIZE - ENTRY_SIZE) {
throw new Error(`分区表条目过多,超过最大容量 (最多 ${Math.floor((TABLE_MAX_SIZE - ENTRY_SIZE) / ENTRY_SIZE)} 条)`);
}
assertU8(entry.type, '类型');
assertU8(entry.subtype, '子类型');
assertU32(entry.offset, '偏移量');
assertU32(entry.size, '大小');
assertU32(entry.flags, '标志');
writeU16(buf, offset, ENTRY_MAGIC);
buf[offset + 2] = entry.type;
buf[offset + 3] = entry.subtype;
writeU32(buf, offset + 4, entry.offset);
writeU32(buf, offset + 8, entry.size);
writeNullTermString(buf, offset + 12, entry.name, NAME_FIELD_SIZE);
writeU32(buf, offset + 28, entry.flags);
offset += ENTRY_SIZE;
}
// Write MD5 checksum entry
// [0..1] = MD5_MAGIC, [2..15] = 0xFF padding, [16..31] = MD5 hash
writeU16(buf, offset, MD5_MAGIC);
// [2..15] already 0xFF from fill
const hash = md5(buf.subarray(0, offset));
buf.set(hash, offset + 16);
return buf;
}

View File

@ -0,0 +1,137 @@
/** Partition type (top-level: app or data) */
export enum PartitionType {
APP = 0x00,
DATA = 0x01,
}
/** App subtypes */
export enum AppSubtype {
FACTORY = 0x00,
OTA_0 = 0x10,
OTA_1 = 0x11,
OTA_2 = 0x12,
OTA_3 = 0x13,
OTA_4 = 0x14,
OTA_5 = 0x15,
OTA_6 = 0x16,
OTA_7 = 0x17,
OTA_8 = 0x18,
OTA_9 = 0x19,
OTA_10 = 0x1A,
OTA_11 = 0x1B,
OTA_12 = 0x1C,
OTA_13 = 0x1D,
OTA_14 = 0x1E,
OTA_15 = 0x1F,
TEST = 0x20,
}
/** Data subtypes */
export enum DataSubtype {
OTA = 0x00,
PHY = 0x01,
NVS = 0x02,
COREDUMP = 0x03,
NVS_KEYS = 0x04,
EFUSE_EM = 0x05,
UNDEFINED = 0x06,
FAT = 0x81,
SPIFFS = 0x82,
LITTLEFS = 0x83,
}
/** Partition flags */
export enum PartitionFlags {
NONE = 0x00,
ENCRYPTED = 0x01,
READONLY = 0x02,
}
/** A single parsed partition entry */
export interface PartitionEntry {
name: string;
type: PartitionType;
subtype: number;
offset: number;
size: number;
flags: number;
}
/** The complete partition table */
export interface PartitionTable {
entries: PartitionEntry[];
md5Valid: boolean;
/** True if an unexpected magic value was found mid-table (indicates binary corruption) */
corrupted?: boolean;
}
/** Human-readable type name map */
export const TYPE_NAMES: Record<number, string> = {
[PartitionType.APP]: 'app',
[PartitionType.DATA]: 'data',
};
/** Human-readable app subtype name map */
export const APP_SUBTYPE_NAMES: Record<number, string> = {
[AppSubtype.FACTORY]: 'factory',
[AppSubtype.TEST]: 'test',
};
// OTA_0..OTA_15
for (let i = 0; i <= 15; i++) {
APP_SUBTYPE_NAMES[0x10 + i] = `ota_${i}`;
}
/** Human-readable data subtype name map */
export const DATA_SUBTYPE_NAMES: Record<number, string> = {
[DataSubtype.OTA]: 'ota',
[DataSubtype.PHY]: 'phy',
[DataSubtype.NVS]: 'nvs',
[DataSubtype.COREDUMP]: 'coredump',
[DataSubtype.NVS_KEYS]: 'nvs_keys',
[DataSubtype.EFUSE_EM]: 'efuse',
[DataSubtype.UNDEFINED]: 'undefined',
[DataSubtype.FAT]: 'fat',
[DataSubtype.SPIFFS]: 'spiffs',
[DataSubtype.LITTLEFS]: 'littlefs',
};
/** Get human-readable subtype name */
export function getSubtypeName(type: PartitionType, subtype: number): string {
if (type === PartitionType.APP) {
return APP_SUBTYPE_NAMES[subtype] ?? `0x${subtype.toString(16).padStart(2, '0')}`;
}
return DATA_SUBTYPE_NAMES[subtype] ?? `0x${subtype.toString(16).padStart(2, '0')}`;
}
/** Reverse lookup: type name string → PartitionType */
export const NAME_TO_TYPE: Record<string, PartitionType> = {
'app': PartitionType.APP,
'data': PartitionType.DATA,
};
/** Reverse lookup: subtype name → number, keyed by parent type */
export function subtypeFromName(type: PartitionType, name: string): number {
const normalized = name.trim().toLowerCase();
if (type === PartitionType.APP) {
for (const [val, n] of Object.entries(APP_SUBTYPE_NAMES)) {
if (n === normalized) return Number(val);
}
} else {
for (const [val, n] of Object.entries(DATA_SUBTYPE_NAMES)) {
if (n === normalized) return Number(val);
}
}
// Try numeric parse (decimal or hex) — full-string, byte-range validated
if (normalized.startsWith('0x')) {
if (!/^0x[0-9a-f]+$/i.test(normalized)) throw new Error(`Unknown partition subtype: "${name}"`);
const v = parseInt(normalized, 16);
if (isNaN(v) || v < 0 || v > 255) throw new Error(`Subtype value out of byte range: "${name}"`);
return v;
} else {
if (!/^\d+$/.test(normalized)) throw new Error(`Unknown partition subtype: "${name}"`);
const v = parseInt(normalized, 10);
if (v > 255) throw new Error(`Subtype value out of byte range: "${name}"`);
return v;
}
throw new Error(`Unknown partition subtype: "${name}"`);
}

View File

@ -0,0 +1,69 @@
import { PartitionEntry, PartitionTable } from './types';
export interface PartitionValidationError {
type: 'overlap' | 'alignment' | 'duplicate_name';
message: string;
entryA?: PartitionEntry;
entryB?: PartitionEntry;
}
const SECTOR_SIZE = 0x1000; // 4KB
export function validateTable(table: PartitionTable): PartitionValidationError[] {
const errors: PartitionValidationError[] = [];
const entries = table.entries;
// Duplicate name detection
const names = new Map<string, PartitionEntry>();
for (const entry of entries) {
if (names.has(entry.name)) {
errors.push({
type: 'duplicate_name',
message: `分区名称重复: "${entry.name}"`,
entryA: names.get(entry.name),
entryB: entry,
});
} else {
names.set(entry.name, entry);
}
}
// Alignment validation
for (const entry of entries) {
if (entry.offset !== 0 && entry.offset % SECTOR_SIZE !== 0) {
errors.push({
type: 'alignment',
message: `"${entry.name}" 偏移 0x${entry.offset.toString(16)} 未对齐到 4KB 边界`,
entryA: entry,
});
}
if (entry.size !== 0 && entry.size % SECTOR_SIZE !== 0) {
errors.push({
type: 'alignment',
message: `"${entry.name}" 大小 0x${entry.size.toString(16)} 未对齐到 4KB 边界`,
entryA: entry,
});
}
}
// Overlap detection (skip entries with offset === 0 — they may be auto-assigned)
const nonZeroEntries = entries.filter(e => e.offset !== 0 && e.size !== 0);
for (let i = 0; i < nonZeroEntries.length; i++) {
const a = nonZeroEntries[i];
const aEnd = a.offset + a.size;
for (let j = i + 1; j < nonZeroEntries.length; j++) {
const b = nonZeroEntries[j];
const bEnd = b.offset + b.size;
if (a.offset < bEnd && b.offset < aEnd) {
errors.push({
type: 'overlap',
message: `"${a.name}" [0x${a.offset.toString(16)}..0x${aEnd.toString(16)}] 与 "${b.name}" [0x${b.offset.toString(16)}..0x${bEnd.toString(16)}] 重叠`,
entryA: a,
entryB: b,
});
}
}
}
return errors;
}