feat(partition-table): add initial partition table library and Vue editor
This commit is contained in:
parent
b23a7e5c8a
commit
34eb123f5e
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from './shared';
|
||||
export * as nvs from './nvs';
|
||||
export * as partitionTable from './partition-table';
|
||||
export * as appImage from './app-image';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 } : {}) };
|
||||
}
|
||||
|
|
@ -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}" 不是有效的字节值 (0–255): ${val}`);
|
||||
}
|
||||
|
||||
function assertU32(val: number, field: string): void {
|
||||
if (!Number.isInteger(val) || val < 0 || val > U32_MAX)
|
||||
throw new Error(`"${field}" 不是有效的 32 位无符号整数 (0–0xFFFFFFFF): ${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;
|
||||
}
|
||||
|
|
@ -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}"`);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue