Compare commits
20 Commits
92dd27ed9b
...
4493b6d732
| Author | SHA1 | Date |
|---|---|---|
|
|
4493b6d732 | |
|
|
4e93301626 | |
|
|
a8df9ac08d | |
|
|
47b11d085f | |
|
|
109017ed6f | |
|
|
1b1c11ccb9 | |
|
|
d01c81b48f | |
|
|
402fade928 | |
|
|
e0e95f80c0 | |
|
|
107e67bea4 | |
|
|
8d5d05dff0 | |
|
|
17d34a33e9 | |
|
|
1601c0ad38 | |
|
|
da91312ca9 | |
|
|
1076aa3848 | |
|
|
0e2f8c4828 | |
|
|
f7fbf57095 | |
|
|
0e21d6cb99 | |
|
|
9b572bb9d3 | |
|
|
235b5e170a |
|
|
@ -1,19 +1,27 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue';
|
import { ref } from 'vue';
|
||||||
import {
|
import {
|
||||||
type AppImageInfo,
|
type AppImageInfo,
|
||||||
SPI_FLASH_MODE_NAMES, SPI_FLASH_SPEED_NAMES, SPI_FLASH_SIZE_NAMES,
|
SPI_FLASH_MODE_NAMES, SPI_FLASH_SPEED_NAMES, SPI_FLASH_SIZE_NAMES,
|
||||||
parseAppImage,
|
parseAppImage,
|
||||||
} from '../../lib/app-image';
|
} from '../../lib/app-image';
|
||||||
|
import { computeFieldRanges, type FieldGroup } from '../../lib/app-image/ranges';
|
||||||
|
import HexDump from './HexDump.vue';
|
||||||
|
import HexFieldPanel from './HexFieldPanel.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isDark?: boolean;
|
isDark?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const imageInfo = ref<AppImageInfo | null>(null);
|
const imageInfo = ref<AppImageInfo | null>(null);
|
||||||
|
const rawData = ref<Uint8Array | null>(null);
|
||||||
|
const showHex = ref(false);
|
||||||
const statusMessage = ref('');
|
const statusMessage = ref('');
|
||||||
const statusType = ref<'success' | 'error' | 'info'>('info');
|
const statusType = ref<'success' | 'error' | 'info'>('info');
|
||||||
const fileName = ref('');
|
const fileName = ref('');
|
||||||
|
const fieldGroups = ref<FieldGroup[]>([]);
|
||||||
|
const activeRange = ref<{ start: number; end: number } | null>(null);
|
||||||
|
const hexDumpRef = ref<InstanceType<typeof HexDump> | null>(null);
|
||||||
|
|
||||||
let statusTimer: ReturnType<typeof setTimeout> | null = null;
|
let statusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
|
@ -40,6 +48,27 @@ function formatSha256(data: Uint8Array): string {
|
||||||
return Array.from(data).map(b => b.toString(16).padStart(2, '0')).join('');
|
return Array.from(data).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onByteHover(offset: number | null) {
|
||||||
|
if (offset === null) { activeRange.value = null; return; }
|
||||||
|
// Pass 1: named field match (checked across all groups first)
|
||||||
|
for (const g of fieldGroups.value) {
|
||||||
|
const f = g.fields.find(f => offset >= f.start && offset < f.end);
|
||||||
|
if (f) { activeRange.value = { start: f.start, end: f.end }; return; }
|
||||||
|
}
|
||||||
|
// Pass 2: data-only group (segment data blobs)
|
||||||
|
for (const g of fieldGroups.value) {
|
||||||
|
if (g.fields.length === 0 && offset >= g.start && offset < g.end) {
|
||||||
|
activeRange.value = { start: g.start, end: g.end }; return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeRange.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFieldSelect(range: { start: number; end: number }) {
|
||||||
|
activeRange.value = range;
|
||||||
|
hexDumpRef.value?.scrollTo(range.start);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleOpenFile(file: File): Promise<false> {
|
async function handleOpenFile(file: File): Promise<false> {
|
||||||
try {
|
try {
|
||||||
const buffer = await file.arrayBuffer();
|
const buffer = await file.arrayBuffer();
|
||||||
|
|
@ -49,6 +78,10 @@ async function handleOpenFile(file: File): Promise<false> {
|
||||||
throw new Error('ELF 格式不支持。请使用 esptool.py elf2image 将其转换为 .bin 文件');
|
throw new Error('ELF 格式不支持。请使用 esptool.py elf2image 将其转换为 .bin 文件');
|
||||||
}
|
}
|
||||||
imageInfo.value = parseAppImage(data);
|
imageInfo.value = parseAppImage(data);
|
||||||
|
rawData.value = data;
|
||||||
|
fieldGroups.value = computeFieldRanges(data, imageInfo.value);
|
||||||
|
activeRange.value = null;
|
||||||
|
showHex.value = false;
|
||||||
fileName.value = file.name;
|
fileName.value = file.name;
|
||||||
showStatus(`已加载 ${file.name} (${data.byteLength} 字节)`, 'success');
|
showStatus(`已加载 ${file.name} (${data.byteLength} 字节)`, 'success');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -110,9 +143,16 @@ async function handleOpenFile(file: File): Promise<false> {
|
||||||
<el-descriptions-item label="Flash大小">{{ SPI_FLASH_SIZE_NAMES[imageInfo.header.spiSize] ?? formatHex(imageInfo.header.spiSize) }}</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="段数">{{ imageInfo.header.segmentCount }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="WP引脚">{{ formatHex(imageInfo.extendedHeader.wpPin) }}</el-descriptions-item>
|
<el-descriptions-item label="WP引脚">{{ formatHex(imageInfo.extendedHeader.wpPin) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="SPI引脚驱动">{{ imageInfo.extendedHeader.spiPinDrv.map(formatHex).join(' / ') }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="最小芯片版本">{{ imageInfo.extendedHeader.minChipRevFull / 100 }}</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.maxChipRevFull === 0xFFFF ? '不限' : imageInfo.extendedHeader.maxChipRevFull / 100 }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="附加哈希">{{ imageInfo.extendedHeader.hashAppended ? '是' : '否' }}</el-descriptions-item>
|
<el-descriptions-item label="附加哈希" :span="imageInfo.extendedHeader.hashAppended ? 2 : 1">
|
||||||
|
{{ imageInfo.extendedHeader.hashAppended ? '是' : '否' }}
|
||||||
|
<el-text v-if="imageInfo.extendedHeader.hashAppended && rawData"
|
||||||
|
size="small" class="font-mono" style="margin-left:8px">
|
||||||
|
{{ formatSha256(rawData.slice(-32)) }}
|
||||||
|
</el-text>
|
||||||
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
<!-- Segments -->
|
<!-- Segments -->
|
||||||
|
|
@ -128,6 +168,29 @@ async function handleOpenFile(file: File): Promise<false> {
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<!-- Hex dump -->
|
||||||
|
<div class="mt-3">
|
||||||
|
<el-button size="small" @click="showHex = !showHex">
|
||||||
|
{{ showHex ? '隐藏原始字节' : '查看原始字节' }}
|
||||||
|
</el-button>
|
||||||
|
<template v-if="showHex && rawData">
|
||||||
|
<HexFieldPanel
|
||||||
|
:groups="fieldGroups"
|
||||||
|
:activeRange="activeRange"
|
||||||
|
class="mt-2"
|
||||||
|
@select="onFieldSelect"
|
||||||
|
/>
|
||||||
|
<HexDump
|
||||||
|
ref="hexDumpRef"
|
||||||
|
:data="rawData"
|
||||||
|
:height="400"
|
||||||
|
:highlight="activeRange"
|
||||||
|
class="mt-1"
|
||||||
|
@byte-hover="onByteHover"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-empty v-else description="请打开一个ESP32固件文件 (.bin)" />
|
<el-empty v-else description="请打开一个ESP32固件文件 (.bin)" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
data: Uint8Array
|
||||||
|
height?: number
|
||||||
|
highlight?: { start: number; end: number } | null
|
||||||
|
}>(), { height: 400, highlight: null })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'byte-hover': [offset: number | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const BYTES = 16
|
||||||
|
const ROW_H = 20
|
||||||
|
|
||||||
|
const wrapEl = ref<HTMLElement | null>(null)
|
||||||
|
const scrollTop = ref(0)
|
||||||
|
|
||||||
|
const totalRows = computed(() => Math.ceil(props.data.length / BYTES))
|
||||||
|
const firstRow = computed(() => Math.floor(scrollTop.value / ROW_H))
|
||||||
|
const lastRow = computed(() => Math.min(firstRow.value + Math.ceil(props.height / ROW_H) + 4, totalRows.value))
|
||||||
|
|
||||||
|
const rows = computed(() => {
|
||||||
|
const out = []
|
||||||
|
for (let r = firstRow.value; r < lastRow.value; r++) {
|
||||||
|
const off = r * BYTES
|
||||||
|
const slice = props.data.subarray(off, off + BYTES)
|
||||||
|
const hex = Array.from(slice).map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
const asc = Array.from(slice).map(b => (b >= 0x20 && b < 0x7f) ? String.fromCharCode(b) : '.')
|
||||||
|
out.push({ off, hex, asc })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
function isHi(offset: number): boolean {
|
||||||
|
return props.highlight != null
|
||||||
|
&& offset >= props.highlight.start
|
||||||
|
&& offset < props.highlight.end
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll(e: Event) {
|
||||||
|
scrollTop.value = (e.target as HTMLElement).scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
scrollTo(offset: number) {
|
||||||
|
const row = Math.floor(offset / BYTES)
|
||||||
|
wrapEl.value?.scrollTo({ top: row * ROW_H, behavior: 'smooth' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="wrapEl" class="hd-wrap" :style="{ height: height + 'px' }" @scroll="onScroll">
|
||||||
|
<div :style="{ height: totalRows * ROW_H + 'px', position: 'relative' }">
|
||||||
|
<div :style="{ transform: `translateY(${firstRow * ROW_H}px)` }">
|
||||||
|
<div v-for="row in rows" :key="row.off" class="hd-row">
|
||||||
|
<span class="hd-off">{{ row.off.toString(16).padStart(8, '0') }}</span>
|
||||||
|
|
||||||
|
<span class="hd-hex">
|
||||||
|
<span
|
||||||
|
v-for="(h, bi) in row.hex"
|
||||||
|
:key="bi"
|
||||||
|
:class="['hd-byte', { 'hd-byte-hi': isHi(row.off + bi) }, bi === 8 ? 'hd-byte-gap' : '']"
|
||||||
|
@mouseenter="emit('byte-hover', row.off + bi)"
|
||||||
|
@mouseleave="emit('byte-hover', null)"
|
||||||
|
>{{ h }}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="hd-asc">|<span
|
||||||
|
v-for="(a, bi) in row.asc"
|
||||||
|
:key="bi"
|
||||||
|
:class="['hd-asc-char', { 'hd-byte-hi': isHi(row.off + bi) }]"
|
||||||
|
@mouseenter="emit('byte-hover', row.off + bi)"
|
||||||
|
@mouseleave="emit('byte-hover', null)"
|
||||||
|
>{{ a }}</span>|</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hd-wrap {
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
}
|
||||||
|
.hd-row {
|
||||||
|
display: flex;
|
||||||
|
height: 20px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
.hd-row:hover {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
.hd-off {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
min-width: 6em;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.hd-hex {
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
.hd-byte {
|
||||||
|
display: inline-block;
|
||||||
|
width: 3ch;
|
||||||
|
cursor: default;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
/* Extra left margin to create the double-space gap between the two groups of 8 */
|
||||||
|
.hd-byte-gap {
|
||||||
|
margin-left: 1ch;
|
||||||
|
}
|
||||||
|
.hd-byte-hi {
|
||||||
|
background: var(--el-color-primary-light-7);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
.hd-asc {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
.hd-asc-char {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1ch;
|
||||||
|
cursor: default;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { FieldGroup } from '../../lib/app-image/ranges'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
groups: FieldGroup[]
|
||||||
|
activeRange: { start: number; end: number } | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [range: { start: number; end: number }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const collapsed = ref<Set<number>>(new Set())
|
||||||
|
|
||||||
|
function toggle(i: number) {
|
||||||
|
if (collapsed.value.has(i)) collapsed.value.delete(i)
|
||||||
|
else collapsed.value.add(i)
|
||||||
|
// trigger reactivity
|
||||||
|
collapsed.value = new Set(collapsed.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGroupActive(g: FieldGroup): boolean {
|
||||||
|
if (!props.activeRange) return false
|
||||||
|
return props.activeRange.start >= g.start && props.activeRange.end <= g.end
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFieldActive(start: number, end: number): boolean {
|
||||||
|
if (!props.activeRange) return false
|
||||||
|
return props.activeRange.start === start && props.activeRange.end === end
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectGroup(g: FieldGroup) {
|
||||||
|
emit('select', { start: g.start, end: g.end })
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectField(start: number, end: number) {
|
||||||
|
emit('select', { start, end })
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtRange(start: number, end: number): string {
|
||||||
|
if (end - start === 1) return start.toString(16).padStart(8, '0')
|
||||||
|
return `${start.toString(16).padStart(8, '0')}–${(end - 1).toString(16).padStart(8, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="hfp-wrap">
|
||||||
|
<!-- Group list -->
|
||||||
|
<div class="hfp-groups">
|
||||||
|
<div v-for="(g, gi) in groups" :key="gi" class="hfp-group">
|
||||||
|
<!-- Group header row -->
|
||||||
|
<div
|
||||||
|
class="hfp-group-hdr"
|
||||||
|
:class="{ 'hfp-row-active': isGroupActive(g) && g.fields.length === 0 }"
|
||||||
|
@click="g.fields.length ? toggle(gi) : selectGroup(g)"
|
||||||
|
>
|
||||||
|
<span class="hfp-arrow" v-if="g.fields.length">{{ collapsed.has(gi) ? '▶' : '▼' }}</span>
|
||||||
|
<span class="hfp-arrow" v-else>◇</span>
|
||||||
|
<span class="hfp-group-label">{{ g.label }}</span>
|
||||||
|
<span class="hfp-mono hfp-range">{{ fmtRange(g.start, g.end) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Field rows -->
|
||||||
|
<template v-if="!collapsed.has(gi) && g.fields.length">
|
||||||
|
<div
|
||||||
|
v-for="(f, fi) in g.fields"
|
||||||
|
:key="fi"
|
||||||
|
class="hfp-field-row"
|
||||||
|
:class="{ 'hfp-row-active': isFieldActive(f.start, f.end) }"
|
||||||
|
@click="selectField(f.start, f.end)"
|
||||||
|
>
|
||||||
|
<span class="hfp-field-label">{{ f.label }}</span>
|
||||||
|
<span class="hfp-mono hfp-range">{{ fmtRange(f.start, f.end) }}</span>
|
||||||
|
<span class="hfp-mono hfp-value">{{ f.value }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hfp-wrap {
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hfp-groups {
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hfp-group-hdr {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
.hfp-group-hdr:hover {
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hfp-field-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 1px 8px 1px 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||||
|
}
|
||||||
|
.hfp-field-row:hover {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hfp-row-active,
|
||||||
|
.hfp-row-active:hover {
|
||||||
|
background: var(--el-color-primary-light-8) !important;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hfp-arrow {
|
||||||
|
width: 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hfp-group-label,
|
||||||
|
.hfp-field-label {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hfp-mono {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hfp-range {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
min-width: 9em;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hfp-value {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
min-width: 8em;
|
||||||
|
max-width: 18em;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -50,6 +50,7 @@ const props = defineProps({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
isDark: Boolean,
|
isDark: Boolean,
|
||||||
|
disableFirmwareSelect: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -485,11 +486,11 @@ async function reset() {
|
||||||
<div v-show="serialSupported">
|
<div v-show="serialSupported">
|
||||||
<el-tabs>
|
<el-tabs>
|
||||||
<el-tab-pane label="烧录" :disabled="consoleStarted">
|
<el-tab-pane label="烧录" :disabled="consoleStarted">
|
||||||
<el-alert v-if="imageOptions.length === 0" type="warning" class="mb-4" show-icon :closable="false">
|
<el-alert v-if="imageOptions.length === 0 && !disableFirmwareSelect" type="warning" class="mb-4" show-icon :closable="false">
|
||||||
未配置固件选项,无法烧录。
|
未配置固件选项,无法烧录。
|
||||||
</el-alert>
|
</el-alert>
|
||||||
<el-form label-width="auto">
|
<el-form label-width="auto">
|
||||||
<el-form-item v-if="imageOptions.length > 1" label="固件">
|
<el-form-item v-if="imageOptions.length > 1 && !disableFirmwareSelect" label="固件">
|
||||||
<client-only>
|
<client-only>
|
||||||
<el-select
|
<el-select
|
||||||
v-model="imageSelect"
|
v-model="imageSelect"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="props.visible"
|
||||||
|
title="编辑二进制数据"
|
||||||
|
width="min(640px, 92vw)"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@update:model-value="$emit('update:visible', $event)"
|
||||||
|
>
|
||||||
|
<!-- toolbar -->
|
||||||
|
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span class="text-sm text-gray-500">{{ byteCount }} 字节</span>
|
||||||
|
<el-button-group class="ml-2">
|
||||||
|
<el-button size="small" :type="activeTab === 'edit' ? 'primary' : ''" @click="activeTab = 'edit'">编辑</el-button>
|
||||||
|
<el-button size="small" :type="activeTab === 'preview' ? 'primary' : ''" @click="activeTab = 'preview'">预览</el-button>
|
||||||
|
</el-button-group>
|
||||||
|
<span class="flex-1" />
|
||||||
|
<el-button size="small" :icon="Upload" @click="importInput?.click()">导入文件</el-button>
|
||||||
|
<el-button size="small" :icon="Download" @click="exportFile">导出文件</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- edit tab: offset + textarea -->
|
||||||
|
<div v-if="activeTab === 'edit'" class="hex-editor-wrapper">
|
||||||
|
<div ref="offsetPanel" class="hex-offset-panel" :style="{ height: editorHeight + 'px' }" aria-hidden="true">
|
||||||
|
<div v-for="(_, i) in lineCount" :key="i" class="hex-offset-row">
|
||||||
|
{{ (i * 16).toString(16).padStart(8, '0') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="hexText"
|
||||||
|
class="blob-hex-textarea"
|
||||||
|
:style="{ height: editorHeight + 'px', overflowY: 'auto' }"
|
||||||
|
wrap="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="输入十六进制字节 (如: de ad be ef 01 02) 支持空格、逗号、0x前缀分隔"
|
||||||
|
@input="onHexInput"
|
||||||
|
@scroll="syncOffsetScroll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- preview tab: HexDump -->
|
||||||
|
<HexDump v-else :data="currentBytes" :height="editorHeight" />
|
||||||
|
|
||||||
|
<div v-if="parseError" class="text-red-500 text-xs mt-1">{{ parseError }}</div>
|
||||||
|
|
||||||
|
<input ref="importInput" type="file" accept="*/*" style="display:none" @change="onImport" />
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="$emit('update:visible', false)">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!!parseError" @click="confirm">确认</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, withDefaults } from 'vue';
|
||||||
|
import { Upload, Download } from '@element-plus/icons-vue';
|
||||||
|
import HexDump from '../app-image-viewer/HexDump.vue';
|
||||||
|
import { parseHexString } from '../../lib/nvs';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: Uint8Array;
|
||||||
|
visible: boolean;
|
||||||
|
entryKey?: string;
|
||||||
|
}>(), { entryKey: 'blob' });
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: Uint8Array];
|
||||||
|
'update:visible': [value: boolean];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const importInput = ref<HTMLInputElement>();
|
||||||
|
const offsetPanel = ref<HTMLDivElement>();
|
||||||
|
const hexText = ref('');
|
||||||
|
const parseError = ref<string | null>(null);
|
||||||
|
const activeTab = ref<'edit' | 'preview'>('edit');
|
||||||
|
|
||||||
|
// ── Formatting ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatHex(data: Uint8Array): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = 0; i < data.length; i += 16) {
|
||||||
|
const chunk = data.subarray(i, i + 16);
|
||||||
|
lines.push(Array.from(chunk).map(b => b.toString(16).padStart(2, '0')).join(' '));
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parsing ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseHex(text: string): Uint8Array | null {
|
||||||
|
const result = parseHexString(text);
|
||||||
|
if ('error' in result) {
|
||||||
|
parseError.value = result.error;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
parseError.value = null;
|
||||||
|
return result.bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBytes = computed<Uint8Array>(() => parseHex(hexText.value) ?? new Uint8Array(0));
|
||||||
|
const byteCount = computed(() => currentBytes.value.length);
|
||||||
|
|
||||||
|
/** Number of non-empty lines in the textarea (= number of 16-byte offset rows to show) */
|
||||||
|
const lineCount = computed(() => {
|
||||||
|
const lines = hexText.value.split('\n');
|
||||||
|
const count = (lines.length > 0 && lines[lines.length - 1].trim() === '')
|
||||||
|
? lines.length - 1
|
||||||
|
: lines.length;
|
||||||
|
return Math.max(1, count);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Editor/preview height in px — uses max of textarea line count and hex-dump row count */
|
||||||
|
const editorHeight = computed(() => {
|
||||||
|
const textRows = lineCount.value + 1;
|
||||||
|
const byteRows = Math.ceil(currentBytes.value.length / 16) + 1;
|
||||||
|
return Math.min(20, Math.max(textRows, byteRows)) * 20 + 16;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sync with prop ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
watch(() => props.visible, (v) => {
|
||||||
|
if (v) {
|
||||||
|
hexText.value = formatHex(props.modelValue as Uint8Array);
|
||||||
|
parseError.value = null;
|
||||||
|
activeTab.value = 'edit';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function onHexInput() {
|
||||||
|
parseHex(hexText.value); // side-effect: sets parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncOffsetScroll(e: Event) {
|
||||||
|
if (offsetPanel.value)
|
||||||
|
offsetPanel.value.scrollTop = (e.target as HTMLTextAreaElement).scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
const bytes = parseHex(hexText.value);
|
||||||
|
if (bytes === null) return;
|
||||||
|
emit('update:modelValue', bytes);
|
||||||
|
emit('update:visible', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onImport(e: Event) {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
(e.target as HTMLInputElement).value = '';
|
||||||
|
if (!file) return;
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
hexText.value = formatHex(bytes);
|
||||||
|
parseError.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportFile() {
|
||||||
|
const bytes = parseHex(hexText.value);
|
||||||
|
if (!bytes) return;
|
||||||
|
const url = URL.createObjectURL(new Blob([bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer]));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = props.entryKey + '.bin';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hex-editor-wrapper {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hex-editor-wrapper:focus-within {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
.hex-offset-panel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-right: 1px solid var(--el-border-color-lighter);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
padding: 8px 6px;
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.hex-offset-row {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.blob-hex-textarea {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.blob-hex-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,5 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import { Delete, Plus, FolderOpened, Download, ArrowDown, Top, Bottom } from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
type PartitionTable, type PartitionEntry,
|
type PartitionTable, type PartitionEntry,
|
||||||
PartitionType, PartitionFlags,
|
PartitionType, PartitionFlags,
|
||||||
|
|
@ -20,8 +22,8 @@ const props = defineProps<{
|
||||||
// ── Core state ─────────────────────────────────────────────────────
|
// ── Core state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const table = ref<PartitionTable>({ entries: [], md5Valid: false });
|
const table = ref<PartitionTable>({ entries: [], md5Valid: false });
|
||||||
const statusMessage = ref('');
|
|
||||||
const statusType = ref<'success' | 'error' | 'info'>('info');
|
const openInput = ref<HTMLInputElement>();
|
||||||
|
|
||||||
// Add dialog
|
// Add dialog
|
||||||
const showAddDialog = ref(false);
|
const showAddDialog = ref(false);
|
||||||
|
|
@ -51,9 +53,7 @@ function getSubtypeOptionsForType(type: PartitionType) {
|
||||||
// ── Helpers ────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function showStatus(msg: string, type: 'success' | 'error' | 'info' = 'info') {
|
function showStatus(msg: string, type: 'success' | 'error' | 'info' = 'info') {
|
||||||
statusMessage.value = msg;
|
ElMessage({ message: msg, type, duration: 4000, showClose: true });
|
||||||
statusType.value = type;
|
|
||||||
setTimeout(() => { statusMessage.value = ''; }, 4000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHex(val: number): string {
|
function formatHex(val: number): string {
|
||||||
|
|
@ -200,7 +200,44 @@ function handleClear() {
|
||||||
|
|
||||||
// ── File I/O ───────────────────────────────────────────────────────
|
// ── File I/O ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleOpenBinary(file: File): Promise<false> {
|
async function detectFileType(file: File): Promise<'bin' | 'csv' | null> {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||||
|
if (ext === 'bin') return 'bin';
|
||||||
|
if (ext === 'csv') return 'csv';
|
||||||
|
// Fallback: magic byte for partition table binary is 0xAA 0x50.
|
||||||
|
if (file.size >= 2) {
|
||||||
|
const header = new Uint8Array(await file.slice(0, 2).arrayBuffer());
|
||||||
|
if (header[0] === 0xAA && header[1] === 0x50) return 'bin';
|
||||||
|
}
|
||||||
|
// Ambiguous: ask user
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`无法确定 "${file.name}" 的格式。请选择打开方式:`,
|
||||||
|
'选择格式',
|
||||||
|
{ confirmButtonText: '二进制 (BIN)', cancelButtonText: 'CSV 文本', distinguishCancelAndClose: true },
|
||||||
|
);
|
||||||
|
return 'bin';
|
||||||
|
} catch (action) {
|
||||||
|
if (action === 'cancel') return 'csv';
|
||||||
|
return null; // dialog dismissed (Esc / X) → abort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOpenChange(e: Event) {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
(e.target as HTMLInputElement).value = '';
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const type = await detectFileType(file);
|
||||||
|
if (!type) return; // user dismissed
|
||||||
|
if (type === 'bin') {
|
||||||
|
await handleOpenBinary(file);
|
||||||
|
} else {
|
||||||
|
await handleOpenCsv(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpenBinary(file: File) {
|
||||||
try {
|
try {
|
||||||
const buffer = await file.arrayBuffer();
|
const buffer = await file.arrayBuffer();
|
||||||
table.value = parseBinary(new Uint8Array(buffer));
|
table.value = parseBinary(new Uint8Array(buffer));
|
||||||
|
|
@ -213,7 +250,6 @@ async function handleOpenBinary(file: File): Promise<false> {
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showStatus(`加载失败: ${e.message}`, 'error');
|
showStatus(`加载失败: ${e.message}`, 'error');
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleExportBinary() {
|
function handleExportBinary() {
|
||||||
|
|
@ -232,7 +268,7 @@ function handleExportBinary() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleOpenCsv(file: File): Promise<false> {
|
async function handleOpenCsv(file: File) {
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
@ -246,7 +282,6 @@ async function handleOpenCsv(file: File): Promise<false> {
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showStatus(`加载失败: ${e.message}`, 'error');
|
showStatus(`加载失败: ${e.message}`, 'error');
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleExportCsv() {
|
function handleExportCsv() {
|
||||||
|
|
@ -261,53 +296,65 @@ function handleExportCsv() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="partition-table-editor-container">
|
||||||
<!-- Status message -->
|
<!-- Hidden file input -->
|
||||||
<transition name="el-fade-in">
|
<input ref="openInput" type="file" accept=".bin,.csv" style="display:none" @change="onOpenChange" />
|
||||||
<el-alert
|
|
||||||
v-if="statusMessage"
|
|
||||||
:title="statusMessage"
|
|
||||||
:type="statusType"
|
|
||||||
show-icon
|
|
||||||
closable
|
|
||||||
class="mb-3"
|
|
||||||
@close="statusMessage = ''"
|
|
||||||
/>
|
|
||||||
</transition>
|
|
||||||
|
|
||||||
<!-- ── Toolbar ── -->
|
<!-- ── Main Action Toolbar ── -->
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-3">
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||||
<el-button type="primary" @click="showAddDialog = true">添加分区</el-button>
|
<div class="flex items-center gap-3">
|
||||||
<el-button type="danger" plain @click="handleClear">清空</el-button>
|
<el-button type="danger" plain :icon="Delete" @click="handleClear">清空数据</el-button>
|
||||||
<el-divider direction="vertical" />
|
<el-button type="primary" plain :icon="Plus" @click="showAddDialog = true">添加分区</el-button>
|
||||||
<el-text size="small">{{ table.entries.length }} 个分区</el-text>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<!-- Import / Export -->
|
||||||
|
<el-button type="primary" plain :icon="FolderOpened" @click="openInput?.click()">打开(覆盖)</el-button>
|
||||||
|
|
||||||
|
<el-dropdown trigger="click">
|
||||||
|
<el-button type="primary" :icon="Download">
|
||||||
|
导出 <el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item @click="handleExportBinary">导出为 BIN</el-dropdown-item>
|
||||||
|
<el-dropdown-item @click="handleExportCsv">导出为 CSV</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Data Table Card ── -->
|
||||||
|
<el-card shadow="never" class="nvs-table-card" :body-style="{ padding: '0px' }">
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 bg-gray-50 border-b nvs-card-header">
|
||||||
|
<span class="text-[14px] font-semibold text-gray-700">分区列表 <span class="font-normal text-gray-500 ml-1">({{ table.entries.length }} 条)</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Data table ── -->
|
|
||||||
<el-table
|
<el-table
|
||||||
:data="table.entries"
|
:data="table.entries"
|
||||||
border
|
|
||||||
stripe
|
stripe
|
||||||
size="small"
|
size="default"
|
||||||
empty-text="暂无分区,请添加或导入"
|
empty-text="暂无分区,请添加或导入"
|
||||||
max-height="500"
|
max-height="600"
|
||||||
|
class="w-full"
|
||||||
>
|
>
|
||||||
<el-table-column label="名称" width="160">
|
<el-table-column label="名称" width="160">
|
||||||
<template #default="{ row, $index }">
|
<template #default="{ row, $index }">
|
||||||
<el-input
|
<el-input
|
||||||
:model-value="row.name"
|
:model-value="row.name"
|
||||||
size="small"
|
class="nvs-seamless-input"
|
||||||
:maxlength="15"
|
:maxlength="15"
|
||||||
@change="(val: string) => handleUpdateName($index, val)"
|
@change="(val: string) => handleUpdateName($index, val)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="类型" width="110">
|
<el-table-column label="类型" width="120">
|
||||||
<template #default="{ row, $index }">
|
<template #default="{ row, $index }">
|
||||||
<el-select
|
<el-select
|
||||||
:model-value="row.type"
|
:model-value="row.type"
|
||||||
size="small"
|
class="nvs-seamless-select"
|
||||||
@change="(val: PartitionType) => handleUpdateType($index, val)"
|
@change="(val: PartitionType) => handleUpdateType($index, val)"
|
||||||
>
|
>
|
||||||
<el-option label="app" :value="PartitionType.APP" />
|
<el-option label="app" :value="PartitionType.APP" />
|
||||||
|
|
@ -316,11 +363,11 @@ function handleExportCsv() {
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="子类型" width="140">
|
<el-table-column label="子类型" width="150">
|
||||||
<template #default="{ row, $index }">
|
<template #default="{ row, $index }">
|
||||||
<el-select
|
<el-select
|
||||||
:model-value="row.subtype"
|
:model-value="row.subtype"
|
||||||
size="small"
|
class="nvs-seamless-select"
|
||||||
@change="(val: number) => handleUpdateSubtype($index, val)"
|
@change="(val: number) => handleUpdateSubtype($index, val)"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
|
|
@ -333,31 +380,31 @@ function handleExportCsv() {
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="偏移量" width="140">
|
<el-table-column label="偏移量" width="160">
|
||||||
<template #default="{ row, $index }">
|
<template #default="{ row, $index }">
|
||||||
<el-input
|
<el-input
|
||||||
:model-value="formatHex(row.offset)"
|
:model-value="formatHex(row.offset)"
|
||||||
size="small"
|
class="nvs-seamless-input"
|
||||||
@change="(val: string) => handleUpdateOffset($index, val)"
|
@change="(val: string) => handleUpdateOffset($index, val)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="大小" width="140">
|
<el-table-column label="大小" width="180">
|
||||||
<template #default="{ row, $index }">
|
<template #default="{ row, $index }">
|
||||||
<el-input
|
<el-input
|
||||||
:model-value="formatHex(row.size)"
|
:model-value="formatHex(row.size)"
|
||||||
size="small"
|
class="nvs-seamless-input text-mono"
|
||||||
@change="(val: string) => handleUpdateSize($index, val)"
|
@change="(val: string) => handleUpdateSize($index, val)"
|
||||||
>
|
>
|
||||||
<template #append>
|
<template #suffix>
|
||||||
<span class="text-xs">{{ formatSize(row.size) }}</span>
|
<span class="text-xs text-gray-500 mr-1">{{ formatSize(row.size) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="加密" width="70" align="center">
|
<el-table-column label="加密" width="80" align="center">
|
||||||
<template #default="{ row, $index }">
|
<template #default="{ row, $index }">
|
||||||
<el-checkbox
|
<el-checkbox
|
||||||
:model-value="(row.flags & 0x01) !== 0"
|
:model-value="(row.flags & 0x01) !== 0"
|
||||||
|
|
@ -366,42 +413,21 @@ function handleExportCsv() {
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="操作" width="130" fixed="right">
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
<template #default="{ $index }">
|
<template #default="{ $index }">
|
||||||
<el-button size="small" text @click="handleMoveUp($index)" :disabled="$index === 0">上</el-button>
|
<div class="flex items-center">
|
||||||
<el-button size="small" text @click="handleMoveDown($index)" :disabled="$index === table.entries.length - 1">下</el-button>
|
<el-button type="primary" link size="small" :icon="Top" @click="handleMoveUp($index)" :disabled="$index === 0" title="上移" />
|
||||||
|
<el-button type="primary" link size="small" :icon="Bottom" @click="handleMoveDown($index)" :disabled="$index === table.entries.length - 1" title="下移" />
|
||||||
<el-popconfirm title="确定删除?" @confirm="handleDeleteEntry($index)">
|
<el-popconfirm title="确定删除?" @confirm="handleDeleteEntry($index)">
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<el-button size="small" text type="danger">删除</el-button>
|
<el-button type="danger" link size="small" :icon="Delete" title="删除" />
|
||||||
</template>
|
</template>
|
||||||
</el-popconfirm>
|
</el-popconfirm>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
</el-card>
|
||||||
<!-- ── 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 ── -->
|
<!-- ── Add partition dialog ── -->
|
||||||
<el-dialog v-model="showAddDialog" title="添加分区" width="450px">
|
<el-dialog v-model="showAddDialog" title="添加分区" width="450px">
|
||||||
|
|
@ -455,3 +481,53 @@ function handleExportCsv() {
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nvs-table-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--vp-c-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvs-card-header {
|
||||||
|
background-color: var(--vp-c-bg-soft);
|
||||||
|
border-bottom: 1px solid var(--vp-c-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Seamless Inputs for Table */
|
||||||
|
.nvs-seamless-input :deep(.el-input__wrapper),
|
||||||
|
.nvs-seamless-select :deep(.el-input__wrapper) {
|
||||||
|
box-shadow: none !important;
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nvs-seamless-input :deep(.el-input__wrapper:hover),
|
||||||
|
.nvs-seamless-select :deep(.el-input__wrapper:hover),
|
||||||
|
.nvs-seamless-input :deep(.el-input__wrapper.is-focus),
|
||||||
|
.nvs-seamless-select :deep(.el-input__wrapper.is-focus) {
|
||||||
|
box-shadow: 0 0 0 1px var(--el-color-primary) inset !important;
|
||||||
|
background-color: var(--vp-c-bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides and utility classes mapping */
|
||||||
|
.bg-gray-50 {
|
||||||
|
background-color: var(--vp-c-bg-soft);
|
||||||
|
}
|
||||||
|
.border-b {
|
||||||
|
border-bottom: 1px solid var(--vp-c-divider);
|
||||||
|
}
|
||||||
|
.text-gray-500 {
|
||||||
|
color: var(--vp-c-text-2);
|
||||||
|
}
|
||||||
|
.text-gray-700 {
|
||||||
|
color: var(--vp-c-text-1);
|
||||||
|
}
|
||||||
|
.border-gray-100 {
|
||||||
|
border-color: var(--vp-c-divider);
|
||||||
|
}
|
||||||
|
.text-mono :deep(.el-input__inner) {
|
||||||
|
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -18,3 +18,7 @@ export {
|
||||||
|
|
||||||
// Parser
|
// Parser
|
||||||
export { parseAppImage } from './parser';
|
export { parseAppImage } from './parser';
|
||||||
|
|
||||||
|
// Ranges (field ↔ hex highlighting)
|
||||||
|
export { computeFieldRanges } from './ranges';
|
||||||
|
export type { FieldDef, FieldGroup } from './ranges';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { readU32 } from '../shared/binary-reader';
|
||||||
|
import {
|
||||||
|
IMAGE_HEADER_SIZE, EXTENDED_HEADER_SIZE, SEGMENT_HEADER_SIZE,
|
||||||
|
APP_DESC_MAGIC, APP_DESC_SIZE,
|
||||||
|
} from './constants';
|
||||||
|
import {
|
||||||
|
SPI_FLASH_MODE_NAMES, SPI_FLASH_SPEED_NAMES, SPI_FLASH_SIZE_NAMES,
|
||||||
|
} from './types';
|
||||||
|
import type { AppImageInfo } from './types';
|
||||||
|
|
||||||
|
export interface FieldDef {
|
||||||
|
label: string;
|
||||||
|
start: number;
|
||||||
|
end: number; // exclusive
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldGroup {
|
||||||
|
label: string;
|
||||||
|
start: number;
|
||||||
|
end: number; // exclusive
|
||||||
|
fields: FieldDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function h(n: number, pad = 2): string {
|
||||||
|
return '0x' + n.toString(16).toUpperCase().padStart(pad, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeFieldRanges(data: Uint8Array, info: AppImageInfo): FieldGroup[] {
|
||||||
|
const groups: FieldGroup[] = [];
|
||||||
|
|
||||||
|
// ── Image Header (bytes 0–7) ──
|
||||||
|
const spiSpeedSize = data[3];
|
||||||
|
groups.push({
|
||||||
|
label: '镜像头 (Image Header)',
|
||||||
|
start: 0,
|
||||||
|
end: IMAGE_HEADER_SIZE,
|
||||||
|
fields: [
|
||||||
|
{ label: 'magic', start: 0, end: 1, value: h(info.header.magic) },
|
||||||
|
{ label: 'segmentCount', start: 1, end: 2, value: String(info.header.segmentCount) },
|
||||||
|
{ label: 'spiMode', start: 2, end: 3, value: SPI_FLASH_MODE_NAMES[info.header.spiMode] ?? h(info.header.spiMode) },
|
||||||
|
{
|
||||||
|
label: 'spiSpeed / spiSize',
|
||||||
|
start: 3,
|
||||||
|
end: 4,
|
||||||
|
value: `${SPI_FLASH_SPEED_NAMES[info.header.spiSpeed] ?? h(spiSpeedSize & 0x0F)} / ${SPI_FLASH_SIZE_NAMES[info.header.spiSize] ?? h((spiSpeedSize >> 4) & 0x0F)}`,
|
||||||
|
},
|
||||||
|
{ label: 'entryPoint', start: 4, end: 8, value: h(info.header.entryPoint, 8) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Extended Header (bytes 8–23) ──
|
||||||
|
const extOff = IMAGE_HEADER_SIZE;
|
||||||
|
groups.push({
|
||||||
|
label: '扩展头 (Extended Header)',
|
||||||
|
start: extOff,
|
||||||
|
end: extOff + EXTENDED_HEADER_SIZE,
|
||||||
|
fields: [
|
||||||
|
{ label: 'wpPin', start: extOff, end: extOff + 1, value: h(info.extendedHeader.wpPin) },
|
||||||
|
{ label: 'spiPinDrv[0]', start: extOff + 1, end: extOff + 2, value: h(info.extendedHeader.spiPinDrv[0]) },
|
||||||
|
{ label: 'spiPinDrv[1]', start: extOff + 2, end: extOff + 3, value: h(info.extendedHeader.spiPinDrv[1]) },
|
||||||
|
{ label: 'spiPinDrv[2]', start: extOff + 3, end: extOff + 4, value: h(info.extendedHeader.spiPinDrv[2]) },
|
||||||
|
{ label: 'chipId', start: extOff + 4, end: extOff + 6, value: `${h(info.extendedHeader.chipId, 4)} (${info.chipName})` },
|
||||||
|
{ label: 'minChipRev', start: extOff + 6, end: extOff + 7, value: h(info.extendedHeader.minChipRev) },
|
||||||
|
{ label: 'minChipRevFull',start: extOff + 7, end: extOff + 9, value: String(info.extendedHeader.minChipRevFull / 100) },
|
||||||
|
{ label: 'maxChipRevFull',start: extOff + 9, end: extOff + 11, value: info.extendedHeader.maxChipRevFull === 0xFFFF ? '不限' : String(info.extendedHeader.maxChipRevFull / 100) },
|
||||||
|
{ label: 'reserved', start: extOff + 11, end: extOff + 15, value: '(reserved)' },
|
||||||
|
{ label: 'hashAppended', start: extOff + 15, end: extOff + 16, value: info.extendedHeader.hashAppended ? '是' : '否' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Segments ──
|
||||||
|
let segOff = IMAGE_HEADER_SIZE + EXTENDED_HEADER_SIZE;
|
||||||
|
for (let i = 0; i < info.segments.length; i++) {
|
||||||
|
const seg = info.segments[i];
|
||||||
|
const hdrEnd = segOff + SEGMENT_HEADER_SIZE;
|
||||||
|
const dataEnd = hdrEnd + seg.dataLen;
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
label: `段 ${i} 头 (Segment ${i} Header)`,
|
||||||
|
start: segOff,
|
||||||
|
end: hdrEnd,
|
||||||
|
fields: [
|
||||||
|
{ label: 'loadAddr', start: segOff, end: segOff + 4, value: h(seg.loadAddr, 8) },
|
||||||
|
{ label: 'dataLen', start: segOff + 4, end: hdrEnd, value: `${seg.dataLen} 字节` },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
label: `段 ${i} 数据 (Segment ${i} Data)`,
|
||||||
|
start: hdrEnd,
|
||||||
|
end: dataEnd,
|
||||||
|
fields: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
segOff = dataEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App Description — walk segments to find the offset ──
|
||||||
|
if (info.appDescription) {
|
||||||
|
let appDescOff: number | null = null;
|
||||||
|
let scanOff = IMAGE_HEADER_SIZE + EXTENDED_HEADER_SIZE;
|
||||||
|
for (const seg of info.segments) {
|
||||||
|
const dataOff = scanOff + SEGMENT_HEADER_SIZE;
|
||||||
|
if (dataOff + 4 <= data.length && readU32(data, dataOff) === APP_DESC_MAGIC) {
|
||||||
|
appDescOff = dataOff;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
scanOff = dataOff + seg.dataLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appDescOff !== null) {
|
||||||
|
const o = appDescOff;
|
||||||
|
const d = info.appDescription;
|
||||||
|
groups.push({
|
||||||
|
label: '应用信息 (App Description)',
|
||||||
|
start: o,
|
||||||
|
end: o + APP_DESC_SIZE,
|
||||||
|
fields: [
|
||||||
|
{ label: 'magicWord', start: o, end: o + 4, value: h(d.magicWord, 8) },
|
||||||
|
{ label: 'secureVersion', start: o + 4, end: o + 8, value: String(d.secureVersion) },
|
||||||
|
{ label: 'version', start: o + 16, end: o + 48, value: d.version },
|
||||||
|
{ label: 'projectName', start: o + 48, end: o + 80, value: d.projectName },
|
||||||
|
{ label: 'compileTime', start: o + 80, end: o + 96, value: d.compileTime },
|
||||||
|
{ label: 'compileDate', start: o + 96, end: o + 112, value: d.compileDate },
|
||||||
|
{ label: 'idfVersion', start: o + 112, end: o + 144, value: d.idfVersion },
|
||||||
|
{ label: 'appElfSha256', start: o + 144, end: o + 176, value: Array.from(d.appElfSha256).map(b => b.toString(16).padStart(2, '0')).join('') },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Appended SHA256 ──
|
||||||
|
if (info.extendedHeader.hashAppended && data.length >= 32) {
|
||||||
|
groups.push({
|
||||||
|
label: '附加 SHA256 (Appended Hash)',
|
||||||
|
start: data.length - 32,
|
||||||
|
end: data.length,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'sha256',
|
||||||
|
start: data.length - 32,
|
||||||
|
end: data.length,
|
||||||
|
value: Array.from(data.slice(-32)).map(b => b.toString(16).padStart(2, '0')).join(''),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,8 @@ export type {
|
||||||
NvsEncoding,
|
NvsEncoding,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
export type { NormalizeResult, ValidationError } from './nvs-partition';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
NvsType,
|
NvsType,
|
||||||
NvsVersion,
|
NvsVersion,
|
||||||
|
|
@ -53,4 +55,8 @@ export {
|
||||||
validatePartition,
|
validatePartition,
|
||||||
sortEntries,
|
sortEntries,
|
||||||
generateEntryId,
|
generateEntryId,
|
||||||
|
normalizePartition,
|
||||||
|
reconcileBlobTypes,
|
||||||
|
checkBlobCompatibility,
|
||||||
|
parseHexString,
|
||||||
} from './nvs-partition';
|
} from './nvs-partition';
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
readU8, readU16, readU32, readI8, readI16, readI32, readU64, readI64,
|
readU8, readU16, readU32, readI8, readI16, readI32, readU64, readI64,
|
||||||
readNullTermString,
|
readNullTermString,
|
||||||
} from '../shared/binary-reader';
|
} from '../shared/binary-reader';
|
||||||
import { generateEntryId } from './nvs-partition';
|
import { generateEntryId, reconcileBlobTypes } from './nvs-partition';
|
||||||
|
|
||||||
// ── Entry state bitmap ─────────────────────────────────────────────
|
// ── Entry state bitmap ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -368,7 +368,7 @@ export function parseBinary(data: Uint8Array): NvsPartition {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entries: Array.from(entryMap.values()),
|
entries: reconcileBlobTypes(Array.from(entryMap.values()), detectedVersion),
|
||||||
namespaces,
|
namespaces,
|
||||||
version: detectedVersion,
|
version: detectedVersion,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,13 @@ export function serializeBinary(partition: NvsPartition, targetSize: number): Ui
|
||||||
|
|
||||||
// ── Step 1: Assign namespace indices ──
|
// ── Step 1: Assign namespace indices ──
|
||||||
|
|
||||||
|
// Derive used namespaces from entries (ignores orphaned namespaces)
|
||||||
|
const usedNs = new Set(partition.entries.map(e => e.namespace));
|
||||||
|
const namespaces = partition.namespaces.filter(ns => usedNs.has(ns));
|
||||||
|
|
||||||
const nsToIndex = new Map<string, number>();
|
const nsToIndex = new Map<string, number>();
|
||||||
let nextNsIdx = 1;
|
let nextNsIdx = 1;
|
||||||
for (const ns of partition.namespaces) {
|
for (const ns of namespaces) {
|
||||||
nsToIndex.set(ns, nextNsIdx++);
|
nsToIndex.set(ns, nextNsIdx++);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { NvsPartition, NvsEntry, NvsEncoding } from './types';
|
import type { NvsPartition, NvsEntry, NvsEncoding } from './types';
|
||||||
import { NvsType, NvsVersion, ENCODING_TO_TYPE } from './types';
|
import { NvsType, NvsVersion, ENCODING_TO_TYPE } from './types';
|
||||||
import { generateEntryId } from './nvs-partition';
|
import { generateEntryId, reconcileBlobTypes } from './nvs-partition';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a line respecting quoted fields.
|
* Parse a line respecting quoted fields.
|
||||||
|
|
@ -73,9 +73,12 @@ function parseBigIntValue(str: string): bigint {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decode hex string (e.g. "48656c6c6f") to Uint8Array */
|
/** Decode hex string (e.g. "48656c6c6f") to Uint8Array. Throws on non-hex characters. */
|
||||||
function hexToBytes(hex: string): Uint8Array {
|
function hexToBytes(hex: string): Uint8Array {
|
||||||
hex = hex.replace(/\s/g, '');
|
hex = hex.replace(/\s/g, '');
|
||||||
|
if (hex.length > 0 && !/^[0-9a-fA-F]+$/.test(hex)) {
|
||||||
|
throw new Error('Invalid hex string: contains non-hex characters');
|
||||||
|
}
|
||||||
const bytes = new Uint8Array(hex.length / 2);
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||||
|
|
@ -144,6 +147,7 @@ export function parseCsv(text: string): NvsPartition {
|
||||||
const entries: NvsEntry[] = [];
|
const entries: NvsEntry[] = [];
|
||||||
const namespaces: string[] = [];
|
const namespaces: string[] = [];
|
||||||
let currentNamespace = '';
|
let currentNamespace = '';
|
||||||
|
let inferredVersion = NvsVersion.V2;
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i].trim();
|
const line = lines[i].trim();
|
||||||
|
|
@ -157,7 +161,8 @@ export function parseCsv(text: string): NvsPartition {
|
||||||
|
|
||||||
const key = fields[0];
|
const key = fields[0];
|
||||||
const type = fields[1];
|
const type = fields[1];
|
||||||
const encoding = (fields[2] || '').toLowerCase() as NvsEncoding | '';
|
const rawEncoding = (fields[2] || '').toLowerCase();
|
||||||
|
const encoding = rawEncoding as NvsEncoding | '';
|
||||||
const value = fields[3] || '';
|
const value = fields[3] || '';
|
||||||
|
|
||||||
if (type === 'namespace') {
|
if (type === 'namespace') {
|
||||||
|
|
@ -180,6 +185,33 @@ export function parseCsv(text: string): NvsPartition {
|
||||||
throw new Error(`Line ${i + 1}: key "${key}" missing encoding`);
|
throw new Error(`Line ${i + 1}: key "${key}" missing encoding`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- nvs_partition_gen.py-compatible encodings (not in NvsEncoding) ---
|
||||||
|
if (rawEncoding === 'hex2bin') {
|
||||||
|
const hexClean = value.replace(/\s/g, '');
|
||||||
|
if (hexClean.length % 2 !== 0) throw new Error(`Line ${i + 1}: hex2bin value must have even number of hex chars`);
|
||||||
|
entries.push({
|
||||||
|
id: generateEntryId(),
|
||||||
|
namespace: currentNamespace,
|
||||||
|
key,
|
||||||
|
type: NvsType.BLOB_DATA,
|
||||||
|
value: hexClean.length === 0 ? new Uint8Array(0) : hexToBytes(hexClean),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawEncoding === 'base64') {
|
||||||
|
const decoded = tryBase64Decode(value);
|
||||||
|
if (!decoded) throw new Error(`Line ${i + 1}: invalid base64 value for key "${key}"`);
|
||||||
|
entries.push({
|
||||||
|
id: generateEntryId(),
|
||||||
|
namespace: currentNamespace,
|
||||||
|
key,
|
||||||
|
type: NvsType.BLOB_DATA,
|
||||||
|
value: decoded,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// --- end nvs_partition_gen.py encodings ---
|
||||||
|
|
||||||
const nvsType = ENCODING_TO_TYPE[encoding as NvsEncoding];
|
const nvsType = ENCODING_TO_TYPE[encoding as NvsEncoding];
|
||||||
if (nvsType === undefined) {
|
if (nvsType === undefined) {
|
||||||
throw new Error(`Line ${i + 1}: unknown encoding "${encoding}"`);
|
throw new Error(`Line ${i + 1}: unknown encoding "${encoding}"`);
|
||||||
|
|
@ -200,6 +232,7 @@ export function parseCsv(text: string): NvsPartition {
|
||||||
break;
|
break;
|
||||||
case 'blob':
|
case 'blob':
|
||||||
case 'binary': {
|
case 'binary': {
|
||||||
|
if (encoding === 'blob') inferredVersion = NvsVersion.V1;
|
||||||
if (type === 'file') {
|
if (type === 'file') {
|
||||||
// In browser context, file paths can't be resolved.
|
// In browser context, file paths can't be resolved.
|
||||||
// Store an empty Uint8Array — the UI should handle file picking.
|
// Store an empty Uint8Array — the UI should handle file picking.
|
||||||
|
|
@ -229,5 +262,5 @@ export function parseCsv(text: string): NvsPartition {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { entries, namespaces, version: NvsVersion.V2 };
|
return { entries: reconcileBlobTypes(entries, inferredVersion), namespaces, version: inferredVersion };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { NvsPartition } from './types';
|
import type { NvsPartition } from './types';
|
||||||
import { NvsType, TYPE_TO_ENCODING, isPrimitiveType } from './types';
|
import { NvsType, NvsVersion, TYPE_TO_ENCODING, isPrimitiveType } from './types';
|
||||||
|
|
||||||
/** Convert Uint8Array to hex string */
|
/** Convert Uint8Array to hex string */
|
||||||
function bytesToHex(data: Uint8Array): string {
|
function bytesToHex(data: Uint8Array): string {
|
||||||
|
|
@ -25,9 +25,13 @@ function escapeCsvField(value: string): string {
|
||||||
export function serializeCsv(partition: NvsPartition): string {
|
export function serializeCsv(partition: NvsPartition): string {
|
||||||
const lines: string[] = ['key,type,encoding,value'];
|
const lines: string[] = ['key,type,encoding,value'];
|
||||||
|
|
||||||
|
// Derive used namespaces from entries (ignores orphaned namespaces)
|
||||||
|
const usedNs = new Set(partition.entries.map(e => e.namespace));
|
||||||
|
const namespaces = partition.namespaces.filter(ns => usedNs.has(ns));
|
||||||
|
|
||||||
// Group entries by namespace
|
// Group entries by namespace
|
||||||
const grouped = new Map<string, typeof partition.entries>();
|
const grouped = new Map<string, typeof partition.entries>();
|
||||||
for (const ns of partition.namespaces) {
|
for (const ns of namespaces) {
|
||||||
grouped.set(ns, []);
|
grouped.set(ns, []);
|
||||||
}
|
}
|
||||||
for (const entry of partition.entries) {
|
for (const entry of partition.entries) {
|
||||||
|
|
@ -52,8 +56,14 @@ export function serializeCsv(partition: NvsPartition): string {
|
||||||
} else if (entry.type === NvsType.SZ) {
|
} else if (entry.type === NvsType.SZ) {
|
||||||
valueStr = escapeCsvField(entry.value as string);
|
valueStr = escapeCsvField(entry.value as string);
|
||||||
} else {
|
} else {
|
||||||
// BLOB / BLOB_DATA — hex encode
|
// BLOB / BLOB_DATA — version-aware encoding
|
||||||
valueStr = bytesToHex(entry.value as Uint8Array);
|
const hex = bytesToHex(entry.value as Uint8Array);
|
||||||
|
// V1 uses 'blob' (monolithic single entry in binary).
|
||||||
|
// V2 uses 'hex2bin' (chunked BLOB_DATA + BLOB_IDX); nvs_partition_gen.py
|
||||||
|
// rejects 'blob' for V2 and would produce a V1 binary from it.
|
||||||
|
const csvEncoding = partition.version === NvsVersion.V1 ? 'blob' : 'hex2bin';
|
||||||
|
lines.push(`${escapeCsvField(entry.key)},data,${csvEncoding},${hex}`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(`${escapeCsvField(entry.key)},data,${encoding},${valueStr}`);
|
lines.push(`${escapeCsvField(entry.key)},data,${encoding},${valueStr}`);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,15 @@ import type { NvsEntry, NvsPartition, NvsFlashStats } from './types';
|
||||||
import { NvsType, NvsVersion, isPrimitiveType } from './types';
|
import { NvsType, NvsVersion, isPrimitiveType } from './types';
|
||||||
import { ENTRIES_PER_PAGE, ENTRY_SIZE, PAGE_SIZE, MAX_KEY_LENGTH, MAX_NAMESPACES, MAX_STRING_LENGTH, MAX_BLOB_SIZE_V1, MAX_BLOB_SIZE_V2 } from './constants';
|
import { ENTRIES_PER_PAGE, ENTRY_SIZE, PAGE_SIZE, MAX_KEY_LENGTH, MAX_NAMESPACES, MAX_STRING_LENGTH, MAX_BLOB_SIZE_V1, MAX_BLOB_SIZE_V2 } from './constants';
|
||||||
|
|
||||||
|
/** Result of normalizing a raw deserialized partition. */
|
||||||
|
export interface NormalizeResult {
|
||||||
|
partition: NvsPartition;
|
||||||
|
/** Entries that were completely unsalvageable and removed. */
|
||||||
|
dropped: number;
|
||||||
|
/** Entries whose numeric values were clamped to fit the type range. */
|
||||||
|
clamped: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Generate a random unique ID for client-side entry tracking */
|
/** Generate a random unique ID for client-side entry tracking */
|
||||||
export function generateEntryId(): string {
|
export function generateEntryId(): string {
|
||||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||||
|
|
@ -46,14 +55,13 @@ export function updateEntry(
|
||||||
const entries = partition.entries.map(e =>
|
const entries = partition.entries.map(e =>
|
||||||
e.id === entryId ? { ...e, ...updates } : e,
|
e.id === entryId ? { ...e, ...updates } : e,
|
||||||
);
|
);
|
||||||
// If namespace changed, ensure it's in the list
|
// If namespace changed, add it. Intentionally does NOT remove the old namespace:
|
||||||
|
// partition.namespaces doubles as a UI dropdown convenience list; orphaned entries
|
||||||
|
// are silently filtered out at serialization/validation/restore boundaries.
|
||||||
let namespaces = partition.namespaces;
|
let namespaces = partition.namespaces;
|
||||||
if (updates.namespace && !namespaces.includes(updates.namespace)) {
|
if (updates.namespace && !namespaces.includes(updates.namespace)) {
|
||||||
namespaces = [...namespaces, updates.namespace];
|
namespaces = [...namespaces, updates.namespace];
|
||||||
}
|
}
|
||||||
// Clean up unused namespaces
|
|
||||||
const usedNs = new Set(entries.map(e => e.namespace));
|
|
||||||
namespaces = namespaces.filter(ns => usedNs.has(ns));
|
|
||||||
return { ...partition, entries, namespaces };
|
return { ...partition, entries, namespaces };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,7 +110,7 @@ export function mergePartitions(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...target, entries, namespaces };
|
return { ...target, entries: reconcileBlobTypes(entries, target.version), namespaces };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Calculate the entry span for a single NvsEntry */
|
/** Calculate the entry span for a single NvsEntry */
|
||||||
|
|
@ -144,8 +152,11 @@ export function calculateFlashStats(
|
||||||
const maxEntries = usablePages * ENTRIES_PER_PAGE;
|
const maxEntries = usablePages * ENTRIES_PER_PAGE;
|
||||||
|
|
||||||
// Build a flat list of entry spans (namespace defs + data entries)
|
// Build a flat list of entry spans (namespace defs + data entries)
|
||||||
|
// Derive used namespaces from entries (ignores orphaned namespaces)
|
||||||
|
const usedNs = new Set(partition.entries.map(e => e.namespace));
|
||||||
|
const activeNs = partition.namespaces.filter(ns => usedNs.has(ns));
|
||||||
const spans: number[] = [];
|
const spans: number[] = [];
|
||||||
for (const _ns of partition.namespaces) spans.push(1);
|
for (const _ns of activeNs) spans.push(1);
|
||||||
for (const entry of partition.entries) spans.push(entrySpan(entry, partition.version));
|
for (const entry of partition.entries) spans.push(entrySpan(entry, partition.version));
|
||||||
|
|
||||||
// Simulate page-packing to count actual slot consumption (including fragmentation waste).
|
// Simulate page-packing to count actual slot consumption (including fragmentation waste).
|
||||||
|
|
@ -177,32 +188,50 @@ export function calculateFlashStats(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate partition data. Returns array of error messages (empty = valid). */
|
/** Structured validation error with optional entry ID for precise highlighting. */
|
||||||
export function validatePartition(partition: NvsPartition): string[] {
|
export interface ValidationError {
|
||||||
const errors: string[] = [];
|
message: string;
|
||||||
|
/** Entry ID that caused the error, undefined for partition-level errors. */
|
||||||
|
entryId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
if (partition.namespaces.length > MAX_NAMESPACES) {
|
/** Validate partition data. Returns array of validation errors (empty = valid). */
|
||||||
errors.push(`Namespace count exceeds limit ${MAX_NAMESPACES}`);
|
export function validatePartition(partition: NvsPartition): ValidationError[] {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
|
||||||
|
// Derive active namespaces from entries (ignore orphaned namespaces left by updateEntry)
|
||||||
|
const usedNs = new Set(partition.entries.map(e => e.namespace));
|
||||||
|
const activeNs = partition.namespaces.filter(ns => usedNs.has(ns));
|
||||||
|
|
||||||
|
if (activeNs.length > MAX_NAMESPACES) {
|
||||||
|
errors.push({ message: `Namespace count exceeds limit ${MAX_NAMESPACES}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const ns of partition.namespaces) {
|
for (const ns of activeNs) {
|
||||||
if (ns.length === 0) {
|
if (ns.length === 0) {
|
||||||
errors.push('Namespace name cannot be empty');
|
errors.push({ message: 'Namespace name cannot be empty' });
|
||||||
}
|
}
|
||||||
if (ns.length > MAX_KEY_LENGTH) {
|
if (ns.length > MAX_KEY_LENGTH) {
|
||||||
errors.push(`Namespace "${ns}" exceeds ${MAX_KEY_LENGTH} characters`);
|
errors.push({ message: `Namespace "${ns}" exceeds ${MAX_KEY_LENGTH} characters` });
|
||||||
|
}
|
||||||
|
if ([...ns].some(c => c.charCodeAt(0) > 0xFF)) {
|
||||||
|
errors.push({ message: `Namespace "${ns}" contains non-Latin-1 characters (binary format only supports 8-bit characters)` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of partition.entries) {
|
for (const entry of partition.entries) {
|
||||||
|
const eid = entry.id;
|
||||||
if (entry.key.length === 0) {
|
if (entry.key.length === 0) {
|
||||||
errors.push(`Empty key in namespace "${entry.namespace}"`);
|
errors.push({ message: `Empty key in namespace "${entry.namespace}"`, entryId: eid });
|
||||||
}
|
}
|
||||||
if (entry.key.length > MAX_KEY_LENGTH) {
|
if (entry.key.length > MAX_KEY_LENGTH) {
|
||||||
errors.push(`Key "${entry.key}" exceeds ${MAX_KEY_LENGTH} characters`);
|
errors.push({ message: `Key "${entry.key}" exceeds ${MAX_KEY_LENGTH} characters`, entryId: eid });
|
||||||
|
}
|
||||||
|
if ([...entry.key].some(c => c.charCodeAt(0) > 0xFF)) {
|
||||||
|
errors.push({ message: `Key "${entry.key}" contains non-Latin-1 characters`, entryId: eid });
|
||||||
}
|
}
|
||||||
if (!partition.namespaces.includes(entry.namespace)) {
|
if (!partition.namespaces.includes(entry.namespace)) {
|
||||||
errors.push(`Key "${entry.key}" references unregistered namespace "${entry.namespace}"`);
|
errors.push({ message: `Key "${entry.key}" references unregistered namespace "${entry.namespace}"`, entryId: eid });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate value ranges for primitive types
|
// Validate value ranges for primitive types
|
||||||
|
|
@ -210,21 +239,21 @@ export function validatePartition(partition: NvsPartition): string[] {
|
||||||
if (typeof entry.value === 'number') {
|
if (typeof entry.value === 'number') {
|
||||||
const v = entry.value;
|
const v = entry.value;
|
||||||
switch (entry.type) {
|
switch (entry.type) {
|
||||||
case NvsType.U8: if (v < 0 || v > 0xFF) errors.push(`"${entry.key}" U8 value out of range`); break;
|
case NvsType.U8: if (v < 0 || v > 0xFF) errors.push({ message: `"${entry.key}" U8 value out of range`, entryId: eid }); break;
|
||||||
case NvsType.I8: if (v < -128 || v > 127) errors.push(`"${entry.key}" I8 value out of range`); break;
|
case NvsType.I8: if (v < -128 || v > 127) errors.push({ message: `"${entry.key}" I8 value out of range`, entryId: eid }); break;
|
||||||
case NvsType.U16: if (v < 0 || v > 0xFFFF) errors.push(`"${entry.key}" U16 value out of range`); break;
|
case NvsType.U16: if (v < 0 || v > 0xFFFF) errors.push({ message: `"${entry.key}" U16 value out of range`, entryId: eid }); break;
|
||||||
case NvsType.I16: if (v < -32768 || v > 32767) errors.push(`"${entry.key}" I16 value out of range`); break;
|
case NvsType.I16: if (v < -32768 || v > 32767) errors.push({ message: `"${entry.key}" I16 value out of range`, entryId: eid }); break;
|
||||||
case NvsType.U32: if (v < 0 || v > 0xFFFFFFFF) errors.push(`"${entry.key}" U32 value out of range`); break;
|
case NvsType.U32: if (v < 0 || v > 0xFFFFFFFF) errors.push({ message: `"${entry.key}" U32 value out of range`, entryId: eid }); break;
|
||||||
case NvsType.I32: if (v < -2147483648 || v > 2147483647) errors.push(`"${entry.key}" I32 value out of range`); break;
|
case NvsType.I32: if (v < -2147483648 || v > 2147483647) errors.push({ message: `"${entry.key}" I32 value out of range`, entryId: eid }); break;
|
||||||
}
|
}
|
||||||
} else if (typeof entry.value === 'bigint') {
|
} else if (typeof entry.value === 'bigint') {
|
||||||
const v = entry.value;
|
const v = entry.value;
|
||||||
switch (entry.type) {
|
switch (entry.type) {
|
||||||
case NvsType.U64:
|
case NvsType.U64:
|
||||||
if (v < 0n || v > 0xFFFFFFFFFFFFFFFFn) errors.push(`"${entry.key}" U64 value out of range`);
|
if (v < 0n || v > 0xFFFFFFFFFFFFFFFFn) errors.push({ message: `"${entry.key}" U64 value out of range`, entryId: eid });
|
||||||
break;
|
break;
|
||||||
case NvsType.I64:
|
case NvsType.I64:
|
||||||
if (v < -9223372036854775808n || v > 9223372036854775807n) errors.push(`"${entry.key}" I64 value out of range`);
|
if (v < -9223372036854775808n || v > 9223372036854775807n) errors.push({ message: `"${entry.key}" I64 value out of range`, entryId: eid });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -234,33 +263,47 @@ export function validatePartition(partition: NvsPartition): string[] {
|
||||||
if (entry.type === NvsType.SZ && typeof entry.value === 'string') {
|
if (entry.type === NvsType.SZ && typeof entry.value === 'string') {
|
||||||
const byteLen = new TextEncoder().encode(entry.value).length;
|
const byteLen = new TextEncoder().encode(entry.value).length;
|
||||||
if (byteLen >= MAX_STRING_LENGTH) {
|
if (byteLen >= MAX_STRING_LENGTH) {
|
||||||
errors.push(`"${entry.key}" string length ${byteLen} bytes exceeds limit ${MAX_STRING_LENGTH - 1}`);
|
errors.push({ message: `"${entry.key}" string length ${byteLen} bytes exceeds limit ${MAX_STRING_LENGTH - 1}`, entryId: eid });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate blob size
|
// Validate blob size
|
||||||
// NvsType.BLOB uses the legacy V1 single-page format regardless of partition version,
|
|
||||||
// so it is always capped at MAX_BLOB_SIZE_V1.
|
|
||||||
// NvsType.BLOB_DATA uses the V2 chunked format and is capped at MAX_BLOB_SIZE_V2.
|
|
||||||
if (entry.type === NvsType.BLOB && entry.value instanceof Uint8Array) {
|
if (entry.type === NvsType.BLOB && entry.value instanceof Uint8Array) {
|
||||||
if (entry.value.length > MAX_BLOB_SIZE_V1) {
|
if (entry.value.length > MAX_BLOB_SIZE_V1) {
|
||||||
errors.push(`"${entry.key}" BLOB ${entry.value.length} bytes exceeds limit ${MAX_BLOB_SIZE_V1}`);
|
errors.push({ message: `"${entry.key}" BLOB ${entry.value.length} bytes exceeds limit ${MAX_BLOB_SIZE_V1}`, entryId: eid });
|
||||||
}
|
}
|
||||||
} else if (entry.type === NvsType.BLOB_DATA && entry.value instanceof Uint8Array) {
|
} else if (entry.type === NvsType.BLOB_DATA && entry.value instanceof Uint8Array) {
|
||||||
if (entry.value.length > MAX_BLOB_SIZE_V2) {
|
if (entry.value.length > MAX_BLOB_SIZE_V2) {
|
||||||
errors.push(`"${entry.key}" BLOB ${entry.value.length} bytes exceeds V2 limit ${MAX_BLOB_SIZE_V2}`);
|
errors.push({ message: `"${entry.key}" BLOB ${entry.value.length} bytes exceeds V2 limit ${MAX_BLOB_SIZE_V2}`, entryId: eid });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entry.type === NvsType.BLOB_IDX) {
|
||||||
|
errors.push({ message: `"${entry.key}" has internal-only type BLOB_IDX`, entryId: eid });
|
||||||
|
}
|
||||||
|
if (entry.type === NvsType.BLOB_DATA && partition.version === NvsVersion.V1) {
|
||||||
|
errors.push({ message: `"${entry.key}" has V2-only type BLOB_DATA in a V1 partition`, entryId: eid });
|
||||||
|
}
|
||||||
|
if (entry.type === NvsType.BLOB && partition.version === NvsVersion.V2) {
|
||||||
|
errors.push({ message: `"${entry.key}" has V1-only type BLOB in a V2 partition`, entryId: eid });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate (namespace, key) pairs
|
// Check for duplicate (namespace, key) pairs
|
||||||
const seen = new Set<string>();
|
const seen = new Map<string, string>(); // composite key → first entry ID
|
||||||
|
const alreadyFlagged = new Set<string>(); // first-entry IDs already given one error
|
||||||
for (const entry of partition.entries) {
|
for (const entry of partition.entries) {
|
||||||
const k = `${entry.namespace}::${entry.key}`;
|
const k = `${entry.namespace}::${entry.key}`;
|
||||||
if (seen.has(k)) {
|
if (seen.has(k)) {
|
||||||
errors.push(`Duplicate key: ${entry.namespace}/${entry.key}`);
|
errors.push({ message: `Duplicate key: ${entry.namespace}/${entry.key}`, entryId: entry.id });
|
||||||
|
const firstId = seen.get(k)!;
|
||||||
|
if (!alreadyFlagged.has(firstId)) {
|
||||||
|
errors.push({ message: `Duplicate key: ${entry.namespace}/${entry.key}`, entryId: firstId });
|
||||||
|
alreadyFlagged.add(firstId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
seen.set(k, entry.id);
|
||||||
}
|
}
|
||||||
seen.add(k);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
|
|
@ -274,3 +317,189 @@ export function sortEntries(partition: NvsPartition): NvsPartition {
|
||||||
});
|
});
|
||||||
return { ...partition, entries };
|
return { ...partition, entries };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce BLOB/BLOB_DATA types to match partition version.
|
||||||
|
* V1 partitions use monolithic BLOB (0x41); V2 partitions use chunked BLOB_DATA (0x42).
|
||||||
|
* Must be called at every import boundary (JSON, localStorage, binary parser).
|
||||||
|
*/
|
||||||
|
export function reconcileBlobTypes(entries: NvsEntry[], version: NvsVersion): NvsEntry[] {
|
||||||
|
return entries.map(e => {
|
||||||
|
if (version === NvsVersion.V1 && e.type === NvsType.BLOB_DATA) return { ...e, type: NvsType.BLOB };
|
||||||
|
if (version === NvsVersion.V2 && e.type === NvsType.BLOB) return { ...e, type: NvsType.BLOB_DATA };
|
||||||
|
return e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize and validate a raw deserialized object into a well-formed NvsPartition.
|
||||||
|
* Single gate for all deserialization paths (localStorage restore + JSON import/merge).
|
||||||
|
* Never throws. Regenerates missing/duplicate ids. Strips NUL bytes from keys and namespaces.
|
||||||
|
* Returns metadata about dropped and clamped entries for UI warnings.
|
||||||
|
*/
|
||||||
|
export function normalizePartition(raw: unknown): NormalizeResult {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||||
|
return { partition: createEmptyPartition(), dropped: 0, clamped: 0 };
|
||||||
|
}
|
||||||
|
const obj = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
const VALID_VERSIONS = new Set<number>([NvsVersion.V1, NvsVersion.V2]);
|
||||||
|
const version: NvsVersion =
|
||||||
|
typeof obj.version === 'number' && VALID_VERSIONS.has(obj.version)
|
||||||
|
? (obj.version as NvsVersion)
|
||||||
|
: NvsVersion.V2;
|
||||||
|
|
||||||
|
// BLOB_IDX (0x48) is synthesized internally by the serializer; it is never a valid
|
||||||
|
// user entry. All other NvsType values are acceptable user input.
|
||||||
|
const VALID_TYPES = new Set<number>([
|
||||||
|
NvsType.U8, NvsType.I8, NvsType.U16, NvsType.I16,
|
||||||
|
NvsType.U32, NvsType.I32, NvsType.U64, NvsType.I64,
|
||||||
|
NvsType.SZ, NvsType.BLOB, NvsType.BLOB_DATA,
|
||||||
|
]);
|
||||||
|
const rawEntries = Array.isArray(obj.entries) ? obj.entries : [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const entries: NvsEntry[] = [];
|
||||||
|
let dropped = 0;
|
||||||
|
let clamped = 0;
|
||||||
|
|
||||||
|
for (const re of rawEntries) {
|
||||||
|
if (!re || typeof re !== 'object' || Array.isArray(re)) { dropped++; continue; }
|
||||||
|
const r = re as Record<string, unknown>;
|
||||||
|
if (typeof r.type !== 'number' || !VALID_TYPES.has(r.type)) { dropped++; continue; }
|
||||||
|
const type = r.type as NvsType;
|
||||||
|
const namespace = typeof r.namespace === 'string' ? r.namespace.replace(/\0/g, '') : '';
|
||||||
|
if (typeof r.key !== 'string') { dropped++; continue; }
|
||||||
|
const key = r.key.replace(/\0/g, '');
|
||||||
|
if (key.length === 0) { dropped++; continue; }
|
||||||
|
const result = _normalizeEntryValue(type, r.value);
|
||||||
|
if (result === null) { dropped++; continue; }
|
||||||
|
if (result.clamped) clamped++;
|
||||||
|
let id = typeof r.id === 'string' && r.id.length > 0 ? r.id : '';
|
||||||
|
if (!id || seenIds.has(id)) id = generateEntryId();
|
||||||
|
seenIds.add(id);
|
||||||
|
entries.push({ id, namespace, key, type, value: result.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconciledEntries = reconcileBlobTypes(entries, version);
|
||||||
|
|
||||||
|
// Rebuild namespaces: preserve stored order, deduplicate, add missing, drop unused
|
||||||
|
const rawNs = Array.isArray(obj.namespaces) ? obj.namespaces : [];
|
||||||
|
const orderedNs = (rawNs.filter((n): n is string => typeof n === 'string'))
|
||||||
|
.reduce<string[]>((acc, n) => { if (!acc.includes(n)) acc.push(n); return acc; }, []);
|
||||||
|
for (const e of reconciledEntries) {
|
||||||
|
if (e.namespace && !orderedNs.includes(e.namespace)) orderedNs.push(e.namespace);
|
||||||
|
}
|
||||||
|
const usedNs = new Set(reconciledEntries.map(e => e.namespace));
|
||||||
|
const namespaces = orderedNs.filter(n => usedNs.has(n));
|
||||||
|
|
||||||
|
return { partition: { entries: reconciledEntries, namespaces, version }, dropped, clamped };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns normalized value for type, or null if unsalvageable. `clamped` is true if the value was modified. */
|
||||||
|
function _normalizeEntryValue(type: NvsType, raw: unknown): { value: NvsEntry['value']; clamped: boolean } | null {
|
||||||
|
// U64/I64 MUST come before isPrimitiveType() check — isPrimitiveType includes them
|
||||||
|
// but they require BigInt to avoid Number() precision loss above 2^53.
|
||||||
|
if (type === NvsType.U64 || type === NvsType.I64) {
|
||||||
|
if (typeof raw === 'bigint') return _clampBigInt(type, raw);
|
||||||
|
if (typeof raw === 'number') return _clampBigInt(type, BigInt(Math.trunc(raw)));
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
try { return _clampBigInt(type, BigInt(raw)); } catch { return null; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (isPrimitiveType(type)) {
|
||||||
|
let n: number;
|
||||||
|
if (typeof raw === 'number') n = raw;
|
||||||
|
else if (typeof raw === 'string') { n = Number(raw); if (Number.isNaN(n)) return null; }
|
||||||
|
else return null;
|
||||||
|
return _clampPrimitive(type, Math.trunc(n));
|
||||||
|
}
|
||||||
|
if (type === NvsType.SZ) return typeof raw === 'string' ? { value: raw, clamped: false } : null;
|
||||||
|
// BLOB / BLOB_DATA / BLOB_IDX — already revived by partitionFromJson reviver
|
||||||
|
if (raw instanceof Uint8Array) return { value: raw, clamped: false };
|
||||||
|
return null; // malformed/missing blob payload — drop the entry
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clampPrimitive(type: NvsType, n: number): { value: number; clamped: boolean } {
|
||||||
|
let v: number;
|
||||||
|
switch (type) {
|
||||||
|
case NvsType.U8: v = Math.max(0, Math.min(0xFF, n)); break;
|
||||||
|
case NvsType.I8: v = Math.max(-128, Math.min(127, n)); break;
|
||||||
|
case NvsType.U16: v = Math.max(0, Math.min(0xFFFF, n)); break;
|
||||||
|
case NvsType.I16: v = Math.max(-32768, Math.min(32767, n)); break;
|
||||||
|
case NvsType.U32: v = Math.max(0, Math.min(0xFFFFFFFF, n)); break;
|
||||||
|
case NvsType.I32: v = Math.max(-2147483648, Math.min(2147483647, n)); break;
|
||||||
|
default: v = n;
|
||||||
|
}
|
||||||
|
return { value: v, clamped: v !== n };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clampBigInt(type: NvsType, v: bigint): { value: bigint; clamped: boolean } {
|
||||||
|
let r: bigint;
|
||||||
|
if (type === NvsType.U64) {
|
||||||
|
r = v < 0n ? 0n : v > 0xFFFFFFFFFFFFFFFFn ? 0xFFFFFFFFFFFFFFFFn : v;
|
||||||
|
} else {
|
||||||
|
// I64
|
||||||
|
r = v < -9223372036854775808n ? -9223372036854775808n
|
||||||
|
: v > 9223372036854775807n ? 9223372036854775807n : v;
|
||||||
|
}
|
||||||
|
return { value: r, clamped: r !== v };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check blob entries against the target version's size limit.
|
||||||
|
* Returns human-readable warnings for each oversized blob.
|
||||||
|
*/
|
||||||
|
export function checkBlobCompatibility(
|
||||||
|
entries: NvsEntry[],
|
||||||
|
targetVersion: NvsVersion,
|
||||||
|
): string[] {
|
||||||
|
const limit = targetVersion === NvsVersion.V1 ? MAX_BLOB_SIZE_V1 : MAX_BLOB_SIZE_V2;
|
||||||
|
const warnings: string[] = [];
|
||||||
|
for (const e of entries) {
|
||||||
|
if ((e.type === NvsType.BLOB || e.type === NvsType.BLOB_DATA) &&
|
||||||
|
e.value instanceof Uint8Array && e.value.length > limit) {
|
||||||
|
warnings.push(`"${e.key}" (${e.value.length}B) 超出限制 ${limit}B`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a hex byte string. Accepts:
|
||||||
|
* - "de ad be ef" (space-separated)
|
||||||
|
* - "deadbeef" (continuous)
|
||||||
|
* - "0xDE 0xAD" (0x-prefixed, comma/space separated)
|
||||||
|
* Rejects input containing non-hex content (letters from identifiers, brackets, etc.).
|
||||||
|
*/
|
||||||
|
export function parseHexString(text: string): { bytes: Uint8Array } | { error: string } {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return { bytes: new Uint8Array(0) };
|
||||||
|
|
||||||
|
// 0x-prefixed mode: each whitespace/comma-separated token is one byte (0x0–0xFF)
|
||||||
|
if (/0[xX]/.test(trimmed)) {
|
||||||
|
const tokens = trimmed.split(/[\s,]+/).filter(t => t.length > 0);
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (!/^0[xX][0-9a-fA-F]{1,2}$/.test(token)) {
|
||||||
|
return { error: `无效的字节: "${token}"(0x格式每个字节为 0x0–0xFF)` };
|
||||||
|
}
|
||||||
|
bytes.push(parseInt(token.slice(2), 16));
|
||||||
|
}
|
||||||
|
return { bytes: new Uint8Array(bytes) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw hex mode: strip separators, parse as 2-char pairs
|
||||||
|
const cleaned = trimmed.replace(/[\s,]+/g, '');
|
||||||
|
if (!/^[0-9a-fA-F]+$/.test(cleaned)) {
|
||||||
|
return { error: '包含非十六进制字符' };
|
||||||
|
}
|
||||||
|
if (cleaned.length % 2 !== 0) {
|
||||||
|
return { error: '字节数不完整(剩余半字节)' };
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(cleaned.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
bytes[i] = parseInt(cleaned.substring(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
return { bytes };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { PartitionEntry, PartitionTable } from './types';
|
import type { PartitionEntry, PartitionTable } from './types';
|
||||||
|
|
||||||
export interface PartitionValidationError {
|
export interface PartitionValidationError {
|
||||||
type: 'overlap' | 'alignment' | 'duplicate_name';
|
type: 'overlap' | 'alignment' | 'duplicate_name' | 'invalid_encoding';
|
||||||
message: string;
|
message: string;
|
||||||
entryA?: PartitionEntry;
|
entryA?: PartitionEntry;
|
||||||
entryB?: PartitionEntry;
|
entryB?: PartitionEntry;
|
||||||
|
|
@ -13,6 +13,17 @@ export function validateTable(table: PartitionTable): PartitionValidationError[]
|
||||||
const errors: PartitionValidationError[] = [];
|
const errors: PartitionValidationError[] = [];
|
||||||
const entries = table.entries;
|
const entries = table.entries;
|
||||||
|
|
||||||
|
// Encoding validation — partition names are stored as null-terminated Latin-1 byte strings
|
||||||
|
for (const entry of entries) {
|
||||||
|
if ([...entry.name].some(c => c.charCodeAt(0) > 0xFF)) {
|
||||||
|
errors.push({
|
||||||
|
type: 'invalid_encoding',
|
||||||
|
message: `Partition name "${entry.name}" contains non-Latin-1 characters (binary format only supports 8-bit characters)`,
|
||||||
|
entryA: entry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Duplicate name detection
|
// Duplicate name detection
|
||||||
const names = new Map<string, PartitionEntry>();
|
const names = new Map<string, PartitionEntry>();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,12 @@ export function readI64(buf: Uint8Array, off: number): bigint {
|
||||||
return v > 0x7FFFFFFFFFFFFFFFn ? v - 0x10000000000000000n : v;
|
return v > 0x7FFFFFFFFFFFFFFFn ? v - 0x10000000000000000n : v;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read null-terminated ASCII string of max `maxLen` bytes */
|
/** Read null-terminated byte string of max `maxLen` bytes, preserving all byte values 0x01–0xFF */
|
||||||
export function readNullTermString(buf: Uint8Array, off: number, maxLen: number): string {
|
export function readNullTermString(buf: Uint8Array, off: number, maxLen: number): string {
|
||||||
let end = off;
|
let result = '';
|
||||||
while (end < off + maxLen && buf[end] !== 0) end++;
|
for (let i = off; i < off + maxLen; i++) {
|
||||||
const decoder = new TextDecoder('ascii');
|
if (buf[i] === 0) break;
|
||||||
return decoder.decode(buf.subarray(off, end));
|
result += String.fromCharCode(buf[i]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,11 @@ export function writeI64(buf: Uint8Array, off: number, val: bigint) {
|
||||||
writeU64(buf, off, u);
|
writeU64(buf, off, u);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write null-terminated ASCII string padded to `fieldSize` bytes */
|
/** Write null-terminated byte string padded to `fieldSize` bytes, preserving all byte values 0x01–0xFF */
|
||||||
export function writeNullTermString(buf: Uint8Array, off: number, str: string, fieldSize: number) {
|
export function writeNullTermString(buf: Uint8Array, off: number, str: string, fieldSize: number) {
|
||||||
buf.fill(0, off, off + fieldSize);
|
buf.fill(0, off, off + fieldSize);
|
||||||
const encoder = new TextEncoder();
|
const len = Math.min(str.length, fieldSize - 1);
|
||||||
const strBytes = encoder.encode(str);
|
for (let i = 0; i < len; i++) {
|
||||||
buf.set(strBytes.subarray(0, fieldSize - 1), off); // leave room for null
|
buf[off + i] = str.charCodeAt(i) & 0xFF;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* Pre-computed CRC32 lookup table using reflected polynomial 0xEDB88320.
|
* Pre-computed CRC32 lookup table using reflected polynomial 0xEDB88320.
|
||||||
* Compatible with zlib.crc32() used by ESP-IDF.
|
* Compatible with esp_rom_crc32_le(0xFFFFFFFF, ...) used by ESP-IDF NVS/OTA.
|
||||||
*/
|
*/
|
||||||
const CRC32_TABLE: Uint32Array = (() => {
|
const CRC32_TABLE: Uint32Array = (() => {
|
||||||
const table = new Uint32Array(256);
|
const table = new Uint32Array(256);
|
||||||
|
|
@ -16,7 +16,7 @@ const CRC32_TABLE: Uint32Array = (() => {
|
||||||
|
|
||||||
/** Compute CRC32 of a Uint8Array. Returns unsigned 32-bit integer. */
|
/** Compute CRC32 of a Uint8Array. Returns unsigned 32-bit integer. */
|
||||||
export function crc32(data: Uint8Array): number {
|
export function crc32(data: Uint8Array): number {
|
||||||
let crc = 0xFFFFFFFF;
|
let crc = 0x00000000;
|
||||||
for (let i = 0; i < data.length; i++) {
|
for (let i = 0; i < data.length; i++) {
|
||||||
crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ data[i]) & 0xFF];
|
crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ data[i]) & 0xFF];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue