Download app.js from HelloSun/esp32emu: direct link, hf CLI and curl.
- Browser
- Download file 15.7 kB
-
https://huggingface.co/spaces/HelloSun/esp32emu/resolve/main/app.js
- Command line
-
hf download hf://spaces/HelloSun/esp32emu/app.js
-
curl -L -o app.js https://huggingface.co/spaces/HelloSun/esp32emu/resolve/main/app.js
15.7 kB
| import { ASSETS } from './assets.js'; | |
| const $ = (id) => document.getElementById(id); | |
| const worker = new Worker(new URL('./js/worker.js', import.meta.url), { type: 'module' }); | |
| let ready = false, running = false, paused = false; | |
| let files = []; // {name, data:Uint8Array, addr:number} | |
| let curExample = null; | |
| // ---------- helpers ---------- | |
| async function gunzipB64(b64) { | |
| const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); | |
| const ds = new DecompressionStream('gzip'); | |
| const stream = new Blob([bin]).stream().pipeThrough(ds); | |
| return new Uint8Array(await new Response(stream).arrayBuffer()); | |
| } | |
| const hex = (n) => '0x' + n.toString(16).toUpperCase(); | |
| function setStat(t) { $('stat').textContent = t; } | |
| // ---------- boot ---------- | |
| async function init() { | |
| setStat('載入 ROM…'); | |
| const [pro, app, wasm] = await Promise.all([gunzipB64(ASSETS.rom_pro), gunzipB64(ASSETS.rom_app), gunzipB64(ASSETS.wasm)]); | |
| worker.postMessage({ type: 'roms', pro: pro.buffer, app: app.buffer, wasm: wasm.buffer }, [pro.buffer, app.buffer, wasm.buffer]); | |
| const sel = $('example'); | |
| for (const ex of ASSETS.examples) { const o = document.createElement('option'); o.value = ex.id; o.textContent = ex.title; sel.appendChild(o); } | |
| buildKbd(); buildGpio(); buildBoard(); | |
| } | |
| worker.onmessage = (e) => { | |
| const m = e.data; | |
| switch (m.type) { | |
| case 'ready': ready = true; setStat('就緒 — 按「載入並執行」'); if (!window.__noAuto) runExample($('example').value || ASSETS.examples[0].id); break; | |
| case 'booted': running = true; setStat('執行中…'); $('btnPause').textContent = '⏸ 暫停'; paused = false; break; | |
| case 'frame': drawFrame(m); break; | |
| case 'uart': if (m.n === 0) appendSerial(m.data); break; | |
| case 'status': onStatus(m); break; | |
| case 'gpio': updateGpio(m); break; | |
| case 'flash': { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([m.data])); a.download = 'esp32-flash.bin'; a.click(); break; } | |
| case 'error': setStat('錯誤: ' + m.text); break; | |
| } | |
| }; | |
| function buildFlash(parts, sizeMB) { | |
| const size = sizeMB * 1024 * 1024; | |
| const img = new Uint8Array(size).fill(0xFF); | |
| for (const p of parts) { | |
| if (p.addr + p.data.length > size) throw new Error(p.name + ' 超出 flash 大小'); | |
| img.set(p.data, p.addr); | |
| } | |
| return img; | |
| } | |
| function startBoot(flash, psram) { | |
| $('serial').textContent = ''; | |
| worker.postMessage({ type: 'boot', flash: flash.buffer, psram, cpi: 1 }, [flash.buffer]); | |
| $('noSignal').style.display = 'flex'; | |
| } | |
| async function runExample(id) { | |
| const ex = ASSETS.examples.find((x) => x.id === id); if (!ex) return; | |
| curExample = ex; setStat('解壓縮 ' + ex.title + '…'); | |
| const flash = await gunzipB64(ex.flash); | |
| $('chkPsram').checked = !!ex.psram; | |
| $('selFlash').value = String(Math.max(2, Math.min(16, flash.length / 1048576))); | |
| files = [{ name: ex.id + '.flash.bin', data: flash.slice(), addr: 0, fromExample: true }]; renderFiles(); | |
| startBoot(flash, !!ex.psram); | |
| } | |
| $('btnRun').onclick = () => { if (ready) runExample($('example').value); }; | |
| $('example').onchange = () => { if (ready) runExample($('example').value); }; | |
| $('btnReset').onclick = () => worker.postMessage({ type: 'reset' }); | |
| $('btnPause').onclick = () => { paused = !paused; worker.postMessage({ type: 'pause', value: paused }); $('btnPause').textContent = paused ? '▶ 繼續' : '⏸ 暫停'; }; | |
| $('chkTurbo').onchange = (e) => worker.postMessage({ type: 'turbo', value: e.target.checked }); | |
| $('selSpeed').onchange = (e) => worker.postMessage({ type: 'speed', value: parseFloat(e.target.value) }); | |
| $('btnSave').onclick = () => worker.postMessage({ type: 'getFlash' }); | |
| $('btnFiles').onclick = () => { $('filesBox').open = true; $('fileIn').click(); }; | |
| // ---------- flash file table ---------- | |
| function guessAddr(name, data) { | |
| const n = name.toLowerCase(); | |
| if (data.length >= 0x100000 && (data.length & (data.length - 1)) === 0) return 0; | |
| if (n.includes('bootloader')) return 0x1000; | |
| if (n.includes('partition')) return 0x8000; | |
| if (n.includes('boot_app0') || n.includes('otadata')) return 0xE000; | |
| if (data[0] === 0xE9) return 0x10000; | |
| if (n.endsWith('.img') || n.includes('disk') || n.includes('fat')) return 0x200000; | |
| return 0x10000; | |
| } | |
| function renderFiles() { | |
| const tb = $('fileTbl').querySelector('tbody'); tb.innerHTML = ''; | |
| files.forEach((f, i) => { | |
| const tr = document.createElement('tr'); | |
| tr.innerHTML = `<td>${f.name}</td><td><input value="${hex(f.addr)}"></td><td>${f.data.length}</td><td><button>✕</button></td>`; | |
| tr.querySelector('input').onchange = (e) => { const v = parseInt(e.target.value, 16) || parseInt(e.target.value, 10) || 0; f.addr = v; e.target.value = hex(v); }; | |
| tr.querySelector('button').onclick = () => { files.splice(i, 1); renderFiles(); }; | |
| tb.appendChild(tr); | |
| }); | |
| } | |
| async function addFiles(list) { | |
| $('filesBox').open = true; | |
| for (const file of list) { | |
| const data = new Uint8Array(await file.arrayBuffer()); | |
| files = files.filter((f) => !f.fromExample); | |
| files.push({ name: file.name, data, addr: guessAddr(file.name, data) }); | |
| } | |
| // single app image without boot files -> add defaults | |
| if (!files.some((f) => f.addr === 0) && !files.some((f) => f.addr === 0x1000)) addDefaults(); | |
| renderFiles(); | |
| } | |
| async function addDefaults() { | |
| const defs = [['bootloader.bin', ASSETS.bootloader, 0x1000], ['partitions.bin', ASSETS.partitions, 0x8000], ['boot_app0.bin', ASSETS.boot_app0, 0xE000]]; | |
| for (const [name, b64, addr] of defs) if (!files.some((f) => f.addr === addr)) files.push({ name, data: await gunzipB64(b64), addr }); | |
| renderFiles(); | |
| } | |
| $('fileIn').onchange = (e) => { addFiles(e.target.files); e.target.value = ''; }; | |
| $('btnAddDefaults').onclick = addDefaults; | |
| $('btnBoot').onclick = () => { | |
| try { | |
| const mb = parseInt($('selFlash').value); | |
| const full = files.find((f) => f.addr === 0 && f.data.length >= 0x100000); | |
| const flash = buildFlash(files, full ? Math.max(mb, Math.ceil(full.data.length / 1048576)) : mb); | |
| startBoot(flash, $('chkPsram').checked); | |
| } catch (err) { setStat('錯誤: ' + err.message); } | |
| }; | |
| document.addEventListener('dragover', (e) => e.preventDefault()); | |
| document.addEventListener('drop', (e) => { e.preventDefault(); if (e.dataTransfer.files.length) { $('filesBox').open = true; addFiles(e.dataTransfer.files); } }); | |
| // ---------- display ---------- | |
| const canvas = $('screen'), ctx = canvas.getContext('2d'); | |
| let lastW = 0, lastH = 0, frames = 0; | |
| let pendingFrame = null, raf = 0; | |
| function drawFrame(m) { | |
| if (pendingFrame) worker.postMessage({ type: 'frameAck' }); // dropped frame | |
| pendingFrame = m; | |
| if (!raf) raf = requestAnimationFrame(paint); | |
| } | |
| function paint() { | |
| raf = 0; const m = pendingFrame; if (!m) return; pendingFrame = null; | |
| if (m.w !== lastW || m.h !== lastH) { canvas.width = m.w; canvas.height = m.h; lastW = m.w; lastH = m.h; $('vgaInfo').textContent = `VGA ${m.w}×${m.h}`; } | |
| ctx.putImageData(new ImageData(new Uint8ClampedArray(m.data.buffer), m.w, m.h), 0, 0); | |
| $('noSignal').style.display = 'none'; | |
| frames++; | |
| worker.postMessage({ type: 'frameAck' }); | |
| } | |
| $('chkStretch').onchange = (e) => canvas.classList.toggle('native', !e.target.checked); | |
| $('btnFull').onclick = () => { const w = $('screenWrap'); if (document.fullscreenElement) document.exitFullscreen(); else w.requestFullscreen?.(); }; | |
| // ---------- serial ---------- | |
| const dec = new TextDecoder('utf-8'); | |
| function appendSerial(bytes) { | |
| const el = $('serial'); const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 8; | |
| el.textContent += dec.decode(bytes, { stream: true }).replace(/\r/g, ''); | |
| if (el.textContent.length > 200000) el.textContent = el.textContent.slice(-100000); | |
| if (atEnd) el.scrollTop = el.scrollHeight; | |
| } | |
| $('serialIn').onkeydown = (e) => { if (e.key === 'Enter') { const t = e.target.value + '\n'; worker.postMessage({ type: 'uartRx', n: 0, data: new TextEncoder().encode(t) }); e.target.value = ''; } }; | |
| $('btnSerialClr').onclick = () => { $('serial').textContent = ''; }; | |
| // ---------- status ---------- | |
| function onStatus(m) { | |
| const s = m.virtMs / 1000; | |
| setStat(`虛擬時間 ${s.toFixed(1)}s · CPU ${m.cpuMHz.toFixed(0)}MHz · ${m.mips.toFixed(0)} MIPS · 速度 ${(m.ratio * 100).toFixed(0)}% · ${m.engine === 'wasm' ? 'WASM' : 'JS'} 核心`); | |
| } | |
| // ---------- GPIO panel ---------- | |
| const ROLE = {}; | |
| [[22, 'R1'], [21, 'R0'], [19, 'G1'], [18, 'G0'], [5, 'B1'], [4, 'B0'], [23, 'HS'], [15, 'VS'], [33, 'KCLK'], [32, 'KDAT'], [26, 'MCLK'], [27, 'MDAT'], [25, 'AUD'], [13, 'SDCS'], [12, 'MOSI'], [2, 'MISO'], [14, 'SCK'], [1, 'TX0'], [3, 'RX0']].forEach(([p, r]) => { ROLE[p] = r; }); | |
| function buildGpio() { | |
| const g = $('gpioGrid'); g.innerHTML = ''; | |
| for (let i = 0; i < 40; i++) { if ([20, 24, 28, 29, 30, 31].includes(i)) continue; const d = document.createElement('div'); d.className = 'pin'; d.id = 'pin' + i; d.textContent = i + (ROLE[i] ? '\n' + ROLE[i] : ''); d.style.whiteSpace = 'pre'; d.title = 'GPIO' + i + (ROLE[i] ? ' (' + ROLE[i] + ')' : ''); g.appendChild(d); } | |
| } | |
| function buildBoard() { | |
| const svg = $('board'); const NS = 'http://www.w3.org/2000/svg'; | |
| const add = (tag, attrs, text) => { const e = document.createElementNS(NS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); if (text) e.textContent = text; svg.appendChild(e); return e; }; | |
| add('rect', { x: 4, y: 4, width: 352, height: 142, rx: 8, fill: '#0f2a1a', stroke: '#2f7a4c' }); | |
| add('rect', { x: 130, y: 34, width: 100, height: 72, rx: 4, fill: '#14202c', stroke: '#6aa' }); | |
| add('text', { x: 180, y: 66, 'text-anchor': 'middle', fill: '#9cd', 'font-size': 11 }, 'ESP32'); | |
| add('text', { x: 180, y: 80, 'text-anchor': 'middle', fill: '#789', 'font-size': 8 }, 'WROVER · 4MB PSRAM'); | |
| const pins = (title, x, y, list) => { | |
| add('text', { x, y: y - 6, fill: '#7fd', 'font-size': 8 }, title); | |
| list.forEach(([g, name], i) => { | |
| add('circle', { id: 'ind' + g, cx: x + 4, cy: y + 7 + i * 12, r: 3.5, fill: '#234', stroke: '#6a8' }); | |
| add('text', { x: x + 11, y: y + 10 + i * 12, fill: '#bde', 'font-size': 8 }, name + ' G' + g); | |
| }); | |
| }; | |
| pins('VGA (DB15)', 12, 22, [[22, 'R1'], [21, 'R0'], [19, 'G1'], [18, 'G0'], [5, 'B1'], [4, 'B0'], [23, 'HS'], [15, 'VS']]); | |
| pins('PS/2 KBD', 250, 22, [[33, 'CLK'], [32, 'DATA']]); | |
| pins('PS/2 MOUSE', 250, 58, [[26, 'CLK'], [27, 'DATA']]); | |
| pins('SD (SPI)', 250, 96, [[13, 'CS'], [12, 'MOSI'], [2, 'MISO'], [14, 'SCK']]); | |
| pins('AUDIO', 135, 118, [[25, 'DAC']]); | |
| pins('UART0', 135, 20, []); | |
| } | |
| function updateGpio(m) { | |
| for (const g of [22, 21, 19, 18, 5, 4, 23, 15, 33, 32, 26, 27, 13, 12, 2, 14, 25]) { const e = document.getElementById('ind' + g); if (e) e.setAttribute('fill', m.lv[g] ? (m.drv[g] >= 0 ? '#3ddc84' : '#2a9d5c') : '#234'); } | |
| for (let i = 0; i < 40; i++) { const d = $('pin' + i); if (!d) continue; d.classList.toggle('hi', !!m.lv[i]); d.classList.toggle('drv', m.drv[i] >= 0); } | |
| } | |
| // ---------- virtual PS/2 keyboard ---------- | |
| const KB = [ | |
| [['Esc', 'Escape'], ['F1', 'F1'], ['F2', 'F2'], ['F3', 'F3'], ['F4', 'F4'], ['F5', 'F5'], ['F6', 'F6'], ['F7', 'F7'], ['F8', 'F8'], ['F9', 'F9'], ['F10', 'F10'], ['F11', 'F11'], ['F12', 'F12']], | |
| [['`', 'Backquote'], ['1', 'Digit1'], ['2', 'Digit2'], ['3', 'Digit3'], ['4', 'Digit4'], ['5', 'Digit5'], ['6', 'Digit6'], ['7', 'Digit7'], ['8', 'Digit8'], ['9', 'Digit9'], ['0', 'Digit0'], ['-', 'Minus'], ['=', 'Equal'], ['⌫', 'Backspace', 2]], | |
| [['Tab', 'Tab', 1.5], ['Q', 'KeyQ'], ['W', 'KeyW'], ['E', 'KeyE'], ['R', 'KeyR'], ['T', 'KeyT'], ['Y', 'KeyY'], ['U', 'KeyU'], ['I', 'KeyI'], ['O', 'KeyO'], ['P', 'KeyP'], ['[', 'BracketLeft'], [']', 'BracketRight'], ['\\', 'Backslash', 1.5]], | |
| [['Caps', 'CapsLock', 1.8], ['A', 'KeyA'], ['S', 'KeyS'], ['D', 'KeyD'], ['F', 'KeyF'], ['G', 'KeyG'], ['H', 'KeyH'], ['J', 'KeyJ'], ['K', 'KeyK'], ['L', 'KeyL'], [';', 'Semicolon'], ["'", 'Quote'], ['Enter', 'Enter', 2.2]], | |
| [['Shift', 'ShiftLeft', 2.3, 1], ['Z', 'KeyZ'], ['X', 'KeyX'], ['C', 'KeyC'], ['V', 'KeyV'], ['B', 'KeyB'], ['N', 'KeyN'], ['M', 'KeyM'], [',', 'Comma'], ['.', 'Period'], ['/', 'Slash'], ['Shift', 'ShiftRight', 2.7, 1]], | |
| [['Ctrl', 'ControlLeft', 1.6, 1], ['Alt', 'AltLeft', 1.6, 1], ['Space', 'Space', 7], ['Alt', 'AltRight', 1.4, 1], ['Ctrl', 'ControlRight', 1.6, 1], ['←', 'ArrowLeft'], ['↑', 'ArrowUp'], ['↓', 'ArrowDown'], ['→', 'ArrowRight']], | |
| [['Ins', 'Insert'], ['Del', 'Delete'], ['Home', 'Home'], ['End', 'End'], ['PgUp', 'PageUp'], ['PgDn', 'PageDown'], ['Num', 'NumLock'], ['Scr', 'ScrollLock'], ['PrtSc', 'PrintScreen']], | |
| ]; | |
| const latched = new Set(); | |
| function sendKey(code, down) { worker.postMessage({ type: 'key', code, down }); } | |
| function tap(code) { sendKey(code, true); setTimeout(() => sendKey(code, false), 55); } | |
| function buildKbd() { | |
| const root = $('vkbd'); root.innerHTML = ''; | |
| for (const row of KB) { | |
| const r = document.createElement('div'); r.className = 'krow'; | |
| for (const [label, code, w = 1, mod] of row) { | |
| const b = document.createElement('button'); b.className = 'key' + (mod ? ' mod' : ''); b.textContent = label; b.style.flexGrow = w; b.dataset.code = code; | |
| const press = (e) => { | |
| e.preventDefault(); | |
| if (mod) { | |
| if (latched.has(code)) { latched.delete(code); b.classList.remove('latched'); sendKey(code, false); } else { latched.add(code); b.classList.add('latched'); sendKey(code, true); } | |
| } else { | |
| sendKey(code, true); b.classList.add('down'); | |
| const up = () => { sendKey(code, false); b.classList.remove('down'); for (const c of [...latched]) { latched.delete(c); sendKey(c, false); } root.querySelectorAll('.latched').forEach((x) => x.classList.remove('latched')); window.removeEventListener('pointerup', up); b.removeEventListener('pointerleave', up); }; | |
| window.addEventListener('pointerup', up); b.addEventListener('pointerleave', up); | |
| } | |
| }; | |
| b.addEventListener('pointerdown', press); | |
| r.appendChild(b); | |
| } | |
| root.appendChild(r); | |
| } | |
| } | |
| // ---------- physical keyboard capture ---------- | |
| let capture = false; | |
| const wrap = $('screenWrap'); | |
| function setCapture(v) { capture = v; wrap.classList.toggle('focus', v); if (v) wrap.focus(); } | |
| wrap.addEventListener('click', () => setCapture(true)); | |
| document.addEventListener('keydown', (e) => { | |
| if (!capture) return; | |
| if (e.target && /INPUT|SELECT|TEXTAREA/.test(e.target.tagName)) return; | |
| if (e.code === 'Escape' && e.shiftKey) { setCapture(false); return; } | |
| e.preventDefault(); if (!e.repeat) sendKey(e.code, true); else sendKey(e.code, true); | |
| }, true); | |
| document.addEventListener('keyup', (e) => { if (!capture) return; if (e.target && /INPUT|SELECT|TEXTAREA/.test(e.target.tagName)) return; e.preventDefault(); sendKey(e.code, false); }, true); | |
| document.addEventListener('click', (e) => { if (!wrap.contains(e.target) && !$('vkbd').contains(e.target)) setCapture(false); }, true); | |
| // ---------- PS/2 mouse over the screen (hold button + move) ---------- | |
| let mdown = 0, lastMx = 0, lastMy = 0; | |
| canvas.addEventListener('pointerdown', (e) => { mdown = e.buttons; lastMx = e.clientX; lastMy = e.clientY; }); | |
| canvas.addEventListener('pointermove', (e) => { | |
| if (!e.buttons && !mdown) return; | |
| const sx = (canvas.width / canvas.getBoundingClientRect().width), sy = (canvas.height / canvas.getBoundingClientRect().height); | |
| worker.postMessage({ type: 'mouse', dx: Math.round((e.clientX - lastMx) * sx), dy: -Math.round((e.clientY - lastMy) * sy), buttons: e.buttons }); | |
| lastMx = e.clientX; lastMy = e.clientY; | |
| }); | |
| window.addEventListener('pointerup', () => { if (mdown) { mdown = 0; worker.postMessage({ type: 'mouse', dx: 0, dy: 0, buttons: 0 }); } }); | |
| init(); | |