fix(esp-flasher): enforce imageOptions.value uniqueness via prop validator

Replace comment-only contract with a Vue prop validator that catches duplicate
.value keys at dev time. Validator is stripped from production builds.
Add inline comment to demo/tsconfig.json documenting the 8 node_modules type
errors (vueuse Bluetooth API, element-plus JSX/slots/icons) that require
skipLibCheck: true until those packages are updated.
This commit is contained in:
kerms 2026-02-22 16:08:04 +01:00
parent 34eb123f5e
commit 577b845afc
Signed by: kerms
GPG Key ID: 5432C10DDCF8DAD5
23 changed files with 3320 additions and 93 deletions

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
# AI
/.claude
node_modules/
dist/

View File

@ -1,26 +1,28 @@
# Yunsi Toolbox Vue
A collection of web-based tools.
A collection of web-based tools with pure TypeScript parsing libraries and Vue 3 components.
## Components
## Structure
### [ESP Flasher](./esp-flasher/)
A browser-based firmware flasher for ESP32 microcontrollers using Web Serial.
- `lib/` - Pure TypeScript libraries (no Vue dependency)
- `shared/` - Binary read/write helpers, CRC32
- `nvs/` - NVS partition parser, serializer, CSV support
- `partition-table/` - ESP32 partition table parser/editor
- `app-image/` - ESP32 app image header reader
- `components/` - Vue 3 components (depend on lib/)
- `nvs-editor/` - NVS partition editor UI
- `esp-flasher/` - Browser-based ESP32 flasher (Web Serial)
- `partition-table-editor/` - Partition table editor UI
- `app-image-viewer/` - App image info viewer
## Installation
## Usage
This is intended to be used as a git submodule in Vue 3 projects.
```bash
git submodule add <repository-url> src/components/yunsi-toolbox
git submodule add <repository-url> src/components/yunsi-toolbox-vue
```
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
### Third Party Licenses
This project includes code from the following third-party projects:
- **esptools-js**: Licensed under the Apache License 2.0. Copyright (c) 2024 Espressif Systems (Shanghai) CO LTD.

View File

@ -1,20 +1,6 @@
<script lang="ts">
interface Navigator {
serial: {
// Define the methods and properties you need from the Web Serial API
// For example:
requestPort: (options?: SerialPortRequestOptions) => Promise<SerialPort>;
getPorts: () => Promise<SerialPort[]>;
// Add other properties and methods as needed
};
}
</script>
<script setup lang="ts">
import 'xterm/css/xterm.css';
import {onBeforeMount, onMounted, reactive, ref, watch} from "vue";
import {computed, onBeforeMount, onMounted, reactive, ref, watch, type PropType} from "vue";
import {ESPLoader, type FlashOptions, type IEspLoaderTerminal, type LoaderOptions, Transport} from "./lib_esptools-js";
import CryptoJS from "crypto-js";
@ -22,19 +8,90 @@ const terminalContainer = ref();
let terminal: any;
let fitAddon: any;
const terminalConfig = {
theme: {
background: '#4b4b4b', // dark gray background
foreground: '#c5c8c6', // light gray text
cursor: '#f0c674', // yellow cursor
// You can also set specific ANSI colors if needed
const terminalDarkTheme = {
background: '#4b4b4b',
foreground: '#c5c8c6',
cursor: '#f0c674',
black: '#1d1f21',
red: '#cc6666',
convertEol: true,
// ...and so on for other colors
}
}
const terminalLightTheme = {
background: '#f5f5f5',
foreground: '#333333',
cursor: '#555555',
}
// Props
/** `value` is the unique selection key — enforced by the prop validator. */
type ImageOption = {
value: string;
link: string;
target: string;
};
const props = defineProps({
imageOptions: {
type: Array as PropType<ImageOption[]>,
required: true as const,
validator(opts: ImageOption[]) {
const values = opts.map(o => o.value);
const hasDupes = values.length !== new Set(values).size;
if (hasDupes) {
console.warn(
'[EspFlasher] imageOptions contains duplicate .value keys — ' +
'each option must have a unique value. Selection behaviour is undefined.'
);
}
return !hasDupes;
},
},
isDark: Boolean,
});
// State
const chip = ref("");
const chip_type = ref("");
const programBaud = ref("115200");
const programBaudOption = [
{text: '115200', value: '115200'},
{text: '230400', value: '230400'},
{text: '460800', value: '460800'},
{text: '921600', value: '921600'},
]
const connectedBaud = ref("")
const programConnected = ref(false)
const serialSupported = ref(false);
// selectedValue: stable string key that survives array replacements.
// imageSelect: writable computed getter always returns the live object from
// the current props.imageOptions; no deep watcher or manual reconciliation needed.
// el-select matches options by valueKey="value" (string), not by reference,
// so a fresh object reference from the getter is fine.
const selectedValue = ref<string | null>(props.imageOptions[0]?.value ?? null);
const imageSelect = computed<ImageOption | null>({
get() {
const opts = props.imageOptions;
if (opts.length === 0) return null;
return opts.find(o => o.value === selectedValue.value) ?? opts[0];
},
set(opt: ImageOption | null) {
selectedValue.value = opt?.value ?? null;
},
});
watch(() => props.isDark, (val) => {
if (terminal) {
terminal.options.theme = val ? terminalDarkTheme : terminalLightTheme;
}
});
// Lifecycle
const notSupportedMsg = "您的浏览器不支持虚拟串口请使用电脑版Chrome或者Edge。"
onBeforeMount(() => {
@ -52,7 +109,7 @@ onMounted(async () => {
const { Terminal } = await import('xterm');
const { FitAddon } = await import('xterm-addon-fit');
fitAddon = new FitAddon();
terminal = new Terminal(terminalConfig);
terminal = new Terminal({ theme: props.isDark ? terminalDarkTheme : terminalLightTheme });
terminal.loadAddon(fitAddon);
// Initialize the terminal
@ -71,37 +128,6 @@ onMounted(async () => {
}
});
const chip = ref("");
const chip_type = ref("");
const programBaud = ref("115200");
const programBaudOption = [
{text: '115200', value: '115200'},
{text: '230400', value: '230400'},
{text: '460800', value: '460800'},
{text: '921600', value: '921600'},
]
const connectedBaud = ref("")
const programConnected = ref(false)
const serialSupported = ref(false);
type ImageOption = {
value: string;
link: string;
target: string;
};
const props = defineProps<{
imageOptions: ImageOption[];
isDark?: boolean;
}>();
watch(() => props.isDark, (value) => {
// Handle dark mode change if needed for xterm
});
const imageSelect = ref(props.imageOptions[0]);
let transport: Transport | null;
let esploader: ESPLoader;
@ -216,6 +242,7 @@ const binaryLoadStatus = reactive({
});
function updateProgress(loaded: number, total: number) {
if (!Number.isFinite(total) || total <= 0) return;
binaryLoadStatus.progress = Math.round((loaded / total) * 100);
}
@ -233,14 +260,13 @@ async function loadBinaryFile(imageLink: string) {
}
const contentLength = response.headers.get('content-length');
if (!contentLength) {
throw new Error('Content-Length header is missing');
}
const total = parseInt(contentLength, 10);
const total = contentLength ? parseInt(contentLength, 10) : NaN;
let loaded = 0;
// Stream response body
if (!response.body) {
throw new Error('Response body is null');
}
const reader = response.body.getReader();
let chunks = []; // to store chunks of data
let receivedLength = 0; // received that many bytes at the moment
@ -279,6 +305,10 @@ async function loadBinaryFile(imageLink: string) {
}
async function programFlash() {
if (!imageSelect.value) {
alert('请先选择固件');
return;
}
const fileArray: IBinImage[] = [];
if (chip_type.value != imageSelect.value.target) {
@ -368,22 +398,10 @@ async function handleFileChange(e: Event) {
const target = e.target as HTMLInputElement
const files = target.files
const fileArray: IBinImage[] = [];
const blob = await loadBinaryFile(imageSelect.value.link);
if (blob) {
let data = arrayBufferToBinaryString(await blob.arrayBuffer());
console.log(blob.size, data);
}
if (files && files.length > 0) {
const file = files[0];
let data: string = arrayBufferToBinaryString(await file.arrayBuffer());
fileArray.push({
data: data,
address: 0x0,
})
const data: string = arrayBufferToBinaryString(await file.arrayBuffer());
fileArray.push({ data, address: 0x0 });
console.log(file, data);
}
}
@ -467,6 +485,9 @@ async function reset() {
</el-alert>
<el-tabs>
<el-tab-pane label="烧录" :disabled="consoleStarted">
<el-alert v-if="imageOptions.length === 0" type="warning" class="mb-4" show-icon :closable="false">
未配置固件选项无法烧录
</el-alert>
<el-alert type="info" class="mb-4" show-icon>
若无法连接请先让ESP32进入下载模式再尝试连接按住BOOT按一下RESET松开BOOT
</el-alert>
@ -506,7 +527,7 @@ async function reset() {
<el-button v-show="programConnected" @click="programFlash" type="primary">烧录</el-button>
<el-button v-show="programConnected" @click="programErase" type="danger">全片擦除</el-button>
<el-button v-show="programConnected" @click="programDisconnect" type="info">断开连接</el-button>
<el-link :href="imageSelect.link" :underline="false" class="ml-2">
<el-link v-if="imageSelect" :href="imageSelect.link" :underline="false" class="ml-2">
<el-button type="primary">保存固件到本地</el-button>
</el-link>
</el-form-item>

39
components/esp-flasher/web-serial.d.ts vendored Normal file
View File

@ -0,0 +1,39 @@
// Module shims for vendored/untyped packages used by esp-flasher
declare module 'crypto-js'
// Global ambient Web Serial API types consumed by lib_esptools-js/webserial.d.ts and EspFlasher.vue.
// This file must have no imports/exports so declarations are ambient (global scope).
// This file has no imports/exports so all declarations are ambient (global).
// navigator.serial is not yet part of TypeScript's lib.dom.d.ts.
type ParityType = 'none' | 'even' | 'odd'
type FlowControlType = 'none' | 'hardware'
interface SerialPortInfo {
usbVendorId?: number
usbProductId?: number
}
interface SerialPortFilter {
usbVendorId?: number
usbProductId?: number
}
interface SerialPortRequestOptions {
filters?: SerialPortFilter[]
}
interface SerialPort extends EventTarget {
open(options: { baudRate: number; [k: string]: unknown }): Promise<void>
close(): Promise<void>
readonly readable: ReadableStream<Uint8Array> | null
readonly writable: WritableStream<Uint8Array> | null
getInfo(): SerialPortInfo
}
interface Navigator {
readonly serial: {
requestPort(options?: SerialPortRequestOptions): Promise<SerialPort>
getPorts(): Promise<SerialPort[]>
}
}

View File

@ -273,7 +273,7 @@ function handleExportBinary() {
return;
}
const data = serializeBinary(partition.value, targetSize.value);
downloadBlob(new Blob([data]), 'nvs.bin');
downloadBlob(new Blob([data as Uint8Array<ArrayBuffer>]), 'nvs.bin');
showStatus('已导出 nvs.bin', 'success');
} catch (e: any) {
showStatus(`导出失败: ${e.message}`, 'error');

View File

@ -225,7 +225,7 @@ function handleExportBinary() {
}
try {
const data = serializeBinary(table.value);
downloadBlob(new Blob([data]), 'partitions.bin');
downloadBlob(new Blob([data as Uint8Array<ArrayBuffer>]), 'partitions.bin');
showStatus('已导出 partitions.bin', 'success');
} catch (e: any) {
showStatus(`导出失败: ${e.message}`, 'error');

12
demo/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yunsi Toolbox Demo</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2731
demo/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
demo/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "yunsi-toolbox-vue-demo",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"element-plus": "^2.6.3",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.0",
"vite": "^5.0.0",
"vue-tsc": "^2.0.0"
}
}

6
demo/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

36
demo/src/App.vue Normal file
View File

@ -0,0 +1,36 @@
<script setup lang="ts">
import { ref, computed, provide, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const isDark = ref(false)
provide('isDark', isDark)
watch(isDark, (val) => {
document.documentElement.classList.toggle('dark', val)
}, { immediate: true })
const isHome = computed(() => route.path === '/')
</script>
<template>
<div :class="{ dark: isDark }" class="app-shell">
<header class="app-header">
<div class="app-header-inner">
<div class="app-brand" @click="router.push('/')">
<span class="app-brand-name">Yunsi Toolbox</span>
<span class="app-brand-sub">ESP32 Developer Tools</span>
</div>
<div class="app-header-actions">
<el-button v-if="!isHome" text size="small" @click="router.push('/')"> Home</el-button>
<span class="dark-label">Dark</span>
<el-switch v-model="isDark" />
</div>
</div>
</header>
<main class="app-main">
<router-view />
</main>
</div>
</template>

9
demo/src/main.ts Normal file
View File

@ -0,0 +1,9 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import router from './router'
import App from './App.vue'
import './style.css'
createApp(App).use(ElementPlus).use(router).mount('#app')

View File

@ -0,0 +1,22 @@
<script lang="ts">
import type { ToolMeta } from '../tools'
export const toolMeta: ToolMeta = {
path: '/app-image',
name: 'App Image Viewer',
desc: 'Inspect ESP32 app image headers, segments and app description',
icon: 'IMG',
color: '#E6A23C',
order: 3,
}
</script>
<script setup lang="ts">
import { inject, ref, type Ref } from 'vue'
import AppImageViewer from '@yunsi/components/app-image-viewer/AppImageViewer.vue'
const isDark = inject<Ref<boolean>>('isDark', ref(false))
</script>
<template>
<AppImageViewer :is-dark="isDark" />
</template>

View File

@ -0,0 +1,23 @@
<script lang="ts">
import type { ToolMeta } from '../tools'
export const toolMeta: ToolMeta = {
path: '/esp-flasher',
name: 'ESP Flasher',
desc: 'Flash firmware over USB via Web Serial — Chrome/Edge desktop',
icon: '⚡',
color: '#F56C6C',
order: 4,
}
</script>
<script setup lang="ts">
import { inject, ref, type Ref } from 'vue'
import EspFlasher from '@yunsi/components/esp-flasher/EspFlasher.vue'
const isDark = inject<Ref<boolean>>('isDark', ref(false))
const imageOptions: { value: string; link: string; target: string }[] = []
</script>
<template>
<EspFlasher :image-options="imageOptions" :is-dark="isDark" />
</template>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { tools } from '../tools'
const router = useRouter()
</script>
<template>
<div class="home-page">
<div class="home-intro">
<h1 class="home-title">ESP32 Developer Tools</h1>
<p class="home-subtitle">Select a tool to get started</p>
</div>
<div class="tools-grid">
<div
v-for="tool in tools"
:key="tool.meta.path"
class="tool-card"
@click="router.push(tool.meta.path)"
>
<div class="tool-icon" :style="{ background: tool.meta.color }">
{{ tool.meta.icon }}
</div>
<div class="tool-info">
<h3>{{ tool.meta.name }}</h3>
<p>{{ tool.meta.desc }}</p>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,22 @@
<script lang="ts">
import type { ToolMeta } from '../tools'
export const toolMeta: ToolMeta = {
path: '/nvs',
name: 'NVS Editor',
desc: 'Create and edit ESP-IDF NVS binary partitions and CSV files',
icon: 'NVS',
color: '#409EFF',
order: 1,
}
</script>
<script setup lang="ts">
import { inject, ref, type Ref } from 'vue'
import NvsEditor from '@yunsi/components/nvs-editor/NvsEditor.vue'
const isDark = inject<Ref<boolean>>('isDark', ref(false))
</script>
<template>
<NvsEditor :is-dark="isDark" />
</template>

View File

@ -0,0 +1,22 @@
<script lang="ts">
import type { ToolMeta } from '../tools'
export const toolMeta: ToolMeta = {
path: '/partition-table',
name: 'Partition Table',
desc: 'Edit ESP32 flash partition tables — import/export binary and CSV',
icon: 'PT',
color: '#67C23A',
order: 2,
}
</script>
<script setup lang="ts">
import { inject, ref, type Ref } from 'vue'
import PartitionTableEditor from '@yunsi/components/partition-table-editor/PartitionTableEditor.vue'
const isDark = inject<Ref<boolean>>('isDark', ref(false))
</script>
<template>
<PartitionTableEditor :is-dark="isDark" />
</template>

11
demo/src/router.ts Normal file
View File

@ -0,0 +1,11 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import HomePage from './pages/HomePage.vue'
import { tools } from './tools'
export default createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', component: HomePage },
...tools.map(t => ({ path: t.meta.path, component: t.component })),
],
})

147
demo/src/style.css Normal file
View File

@ -0,0 +1,147 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── App shell ─────────────────────────────────────────────────── */
.app-shell {
min-height: 100vh;
background: var(--el-bg-color-page);
color: var(--el-text-color-primary);
}
.app-header {
background: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color-light);
position: sticky;
top: 0;
z-index: 100;
}
.app-header-inner {
max-width: 1200px;
margin: 0 auto;
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.app-brand {
display: flex;
align-items: baseline;
gap: 10px;
cursor: pointer;
}
.app-brand-name {
font-size: 1.125rem;
font-weight: 700;
color: var(--el-text-color-primary);
}
.app-brand-sub {
font-size: 0.8125rem;
color: var(--el-text-color-secondary);
}
.app-header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.dark-label {
font-size: 0.8125rem;
color: var(--el-text-color-secondary);
}
.app-main {
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px;
}
/* ── Home page ──────────────────────────────────────────────────── */
.home-page {
display: flex;
flex-direction: column;
gap: 32px;
}
.home-intro {
text-align: center;
}
.home-title {
font-size: 1.75rem;
font-weight: 700;
margin: 0 0 8px 0;
color: var(--el-text-color-primary);
}
.home-subtitle {
font-size: 1rem;
color: var(--el-text-color-secondary);
margin: 0;
}
/* ── Tool cards ──────────────────────────────────────────────────── */
.tools-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
@media (max-width: 640px) {
.tools-grid {
grid-template-columns: 1fr;
}
}
.tool-card {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 12px;
padding: 28px 24px;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
display: flex;
align-items: flex-start;
gap: 20px;
}
.tool-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.tool-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9375rem;
font-weight: 700;
color: #fff;
flex-shrink: 0;
letter-spacing: -0.5px;
}
.tool-info h3 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 6px 0;
color: var(--el-text-color-primary);
}
.tool-info p {
font-size: 0.8125rem;
color: var(--el-text-color-secondary);
margin: 0;
line-height: 1.5;
}

27
demo/src/tools.ts Normal file
View File

@ -0,0 +1,27 @@
import type { Component } from 'vue'
export interface ToolMeta {
path: string
name: string
desc: string
icon: string
color: string
order: number
}
interface PageModule {
default: Component
toolMeta?: ToolMeta
}
const modules = import.meta.glob<PageModule>('./pages/*.vue', { eager: true })
export interface Tool {
meta: ToolMeta
component: Component
}
export const tools: Tool[] = Object.values(modules)
.filter((m): m is PageModule & { toolMeta: ToolMeta } => m.toolMeta !== undefined)
.map(m => ({ meta: m.toolMeta, component: m.default }))
.sort((a, b) => a.meta.order - b.meta.order)

11
demo/tailwind.config.js Normal file
View File

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./src/**/*.{vue,ts,html}',
'../components/**/*.vue',
],
theme: {
extend: {},
},
plugins: [],
}

17
demo/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true, // required: @vueuse/core (Bluetooth API types) and element-plus (JSX namespace, __VLS_Slots, icons-vue) have .d.ts incompatible with TS 5.4+. All 8 suppressed errors are inside node_modules; project source is fully clean. Remove only after upgrading those packages.
"jsx": "preserve",
"lib": ["ESNext", "DOM"],
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@yunsi/*": ["../*"]
}
},
"include": ["src/**/*", "../lib/**/*", "../components/**/*"]
}

12
demo/vite.config.ts Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@yunsi': resolve(__dirname, '..'),
},
},
})