sync: merge submodule feature work (HexDump viewer, hex highlighting)
Merges development done in the website submodule: - feat: display SPI pin drive strength and custom app desc raw bytes - feat: add hex dump viewer, show appended SHA256, remove custom app desc - feat: add field-range map for hex <-> field highlighting - feat: Wireshark-style hex/field bidirectional highlighting - feat: add disableFirmwareSelect prop to EspFlasher - fix(crc32): use correct init value for ESP-IDF ROM CRC32
This commit is contained in:
commit
17d34a33e9
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -44,6 +52,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();
|
||||||
|
|
@ -53,6 +82,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) {
|
||||||
|
|
@ -117,7 +150,13 @@ async function handleOpenFile(file: File): Promise<false> {
|
||||||
<el-descriptions-item label="SPI引脚驱动">{{ imageInfo.extendedHeader.spiPinDrv.map(formatHex).join(' / ') }}</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 -->
|
||||||
|
|
@ -134,13 +173,28 @@ async function handleOpenFile(file: File): Promise<false> {
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<!-- Custom App Description (raw bytes) -->
|
<!-- Hex dump -->
|
||||||
<template v-if="imageInfo.customDescRawBytes && !imageInfo.customDescRawBytes.every(b => b === 0)">
|
<div class="mt-3">
|
||||||
<el-text tag="b" class="block mb-2">自定义应用描述(偏移 288 B,原始字节)</el-text>
|
<el-button size="small" @click="showHex = !showHex">
|
||||||
<el-text size="small" class="font-mono break-all">
|
{{ showHex ? '隐藏原始字节' : '查看原始字节' }}
|
||||||
{{ formatHexDump(imageInfo.customDescRawBytes) }}
|
</el-button>
|
||||||
</el-text>
|
<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>
|
</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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue