stm32f4-emu 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +273 -0
- package/index.mjs +39 -0
- package/mcp/server.mjs +235 -0
- package/package.json +83 -0
- package/site/app.js +654 -0
- package/site/components.js +146 -0
- package/site/emulator.js +885 -0
- package/site/firmware.js +37 -0
- package/site/index.html +385 -0
- package/site/loaders.js +133 -0
- package/site/netsim.js +212 -0
- package/site/vendor/package.json +15 -0
- package/site/vendor/stm32_periph_wasm.d.ts +400 -0
- package/site/vendor/stm32_periph_wasm.js +944 -0
- package/site/vendor/stm32_periph_wasm_bg.wasm +0 -0
- package/site/vendor/stm32_periph_wasm_bg.wasm.d.ts +68 -0
- package/site/vendor/stm32f407.svd +41374 -0
- package/site/vendor/unicorn_arm.cjs +0 -0
- package/site/vendor/unicorn_arm.js +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# STM32F4 Emulator
|
|
2
|
+
|
|
3
|
+
[](https://github.com/danish9661/stm32F4-emulator/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/danish9661/stm32F4-emulator/actions/workflows/pages.yml)
|
|
5
|
+
|
|
6
|
+
An STM32F407 microcontroller emulator that runs real Cortex-M4 firmware. It
|
|
7
|
+
combines a **Unicorn CPU core** (QEMU-derived, compiled to WASM) with a
|
|
8
|
+
**Rust peripheral model** (RCC, USART, GPIO, DMA, ETH, TIM, NVIC, ...) also
|
|
9
|
+
compiled to WASM — so the whole machine runs headless in **Node.js or a
|
|
10
|
+
browser tab**, with no SDL, no native deps, no hardware.
|
|
11
|
+
|
|
12
|
+
It ships three real networking firmwares (`eth_http`, `eth_dhcp`, `eth_test`)
|
|
13
|
+
that do DHCP + TCP + HTTP against a simulated (or a real gVisor-backed)
|
|
14
|
+
network, a browser demo, and a publishable npm package.
|
|
15
|
+
|
|
16
|
+
## Live demo
|
|
17
|
+
|
|
18
|
+
The browser demo deploys to GitHub Pages:
|
|
19
|
+
|
|
20
|
+
**https://danish9661.github.io/stm32F4-emulator/**
|
|
21
|
+
|
|
22
|
+
A single console page that starts **idle** — nothing runs until you pick a
|
|
23
|
+
firmware: a preset dropdown with 31 bundled binaries (network demos, a
|
|
24
|
+
bare-metal LED blinker, peripheral/crypto/UART/SPI test binaries), custom
|
|
25
|
+
firmware upload (`.bin`, Intel `.hex`, `.elf` — with loadable RAM segments
|
|
26
|
+
and symbols — plus `.map` for a symbol table), Run/Stop/Reset, a **gateway
|
|
27
|
+
URL field** to connect a real network stack (openhw-gw + gVisor) with a
|
|
28
|
+
scripted network (netsim) as fallback, a live UART terminal (with **UART
|
|
29
|
+
RX input** — type into the console and the firmware reads it; newline
|
|
30
|
+
characters excluded per HTML spec, see AGENTS.md §11), GPIO pin readout
|
|
31
|
+
for banks A–E, and key peripheral registers. Interrupt-driven firmware
|
|
32
|
+
(`rx_interrupt_test`, `rx_crypto_test`) is serviced by an opt-in guest-IRQ
|
|
33
|
+
pump; polling firmware (the ETH demos) never uses it. For automation, a
|
|
34
|
+
preset can auto-boot via the URL: `?fw=eth_http`, `?fw=blinky`, `?fw=crypto_test`, …
|
|
35
|
+
|
|
36
|
+
## DOOM (in the browser)
|
|
37
|
+
|
|
38
|
+
**[`site/doom.html`](site/doom.html)** runs DOOM 1 shareware
|
|
39
|
+
(doomgeneric, ported to the emulated F407) at ~25 FPS in a
|
|
40
|
+
headless-Chrome-verified browser page — playable, but below DOOM's native
|
|
41
|
+
35 fps: at ~918k guest instructions per rendered frame, 35 fps would need
|
|
42
|
+
~32 MIPS and the Unicorn WASM core tops out near 20-24 (details and the
|
|
43
|
+
measurements in AGENTS.md §16). Because the guest mixes one frame of audio
|
|
44
|
+
per rendered frame, sound below 35 fps plays slightly slow and pitched-down
|
|
45
|
+
rather than breaking up — the worklet rate-matches instead of inserting
|
|
46
|
+
gaps, and the stats line reports it (`audio 0.72x`). The page: 320×200
|
|
47
|
+
CMAP256 framebuffer,
|
|
48
|
+
WASD + arrows + Ctrl/Space/Shift + F-keys, I2S audio out (mixer →
|
|
49
|
+
AudioWorklet at 11025 Hz), and a realtime lock that paces the guest to
|
|
50
|
+
wall time. **Save/load works**: the firmware stages savegames to an
|
|
51
|
+
EXTRAM region (2 slots × 256 KB at 0xC0080000) and `doom.js` mirrors
|
|
52
|
+
them to `localStorage['doom-save-N']`, so F6 (quick-save), F2 (save
|
|
53
|
+
menu), F9 (quick-load, 'y' to confirm) and F3 (load menu) survive page
|
|
54
|
+
reloads.
|
|
55
|
+
|
|
56
|
+
- Controls: move W/S/A/D + arrows · strafe Shift · fire Ctrl · use
|
|
57
|
+
Space · menu Enter/Esc · save F2/F6 · load F3/F9 · F1/F10/F11/F12.
|
|
58
|
+
- Boot → menu → gameplay verified end-to-end by
|
|
59
|
+
`node site/test_doom.mjs` (boot markers, menu navigation to E1M1,
|
|
60
|
+
palette + framebuffer, W-move + turn, audio, **save → `SAVE ok slot=0`
|
|
61
|
+
→ load handshake**).
|
|
62
|
+
- `site/doom1.wad` is the 4.2 MB shareware WAD; the firmware never reads
|
|
63
|
+
it from flash — the driver loads it into 8 MB of `extra_mem`
|
|
64
|
+
(0xB8000000).
|
|
65
|
+
|
|
66
|
+
## Quickstart
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# browser demo locally
|
|
70
|
+
npm run serve # then open http://127.0.0.1:8123
|
|
71
|
+
|
|
72
|
+
# node end-to-end flow test (boot -> DHCP -> TCP -> HTTP, 2 rounds)
|
|
73
|
+
npm test # == node site/test_flow.mjs
|
|
74
|
+
|
|
75
|
+
# gateway-backed run: firmware talks to a REAL network stack (gVisor)
|
|
76
|
+
cd stm32-periph-wasm/pkg
|
|
77
|
+
node cli.mjs ../../eth_http/eth_http.bin 10000000 \
|
|
78
|
+
--gateway --config=../../eth_http/config.yaml
|
|
79
|
+
# (requires an HTTP server at 127.0.0.1:8092; see AGENTS.md §10)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Use it as a library (npm package)
|
|
83
|
+
|
|
84
|
+
`npm pack` produces `stm32f4-emu` — the full emulator as a library,
|
|
85
|
+
with all WASM assets, the SVD register map, and the firmware binaries
|
|
86
|
+
bundled:
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
import { createSTM32F407, createNetSim, FIRMWARES } from 'stm32f4-emu';
|
|
90
|
+
|
|
91
|
+
const netsim = createNetSim(); // canned DHCP/TCP/HTTP peer
|
|
92
|
+
const emu = await createSTM32F407({
|
|
93
|
+
firmware: FIRMWARES.eth_http.bytes, // any STM32F4 firmware blob
|
|
94
|
+
onTx: (frame) => {
|
|
95
|
+
for (const reply of netsim.onTx(frame)) emu.injectFrame(reply);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
emu.step(100000); // run up to 100k instructions
|
|
100
|
+
const uart = emu.drainUart(); // collect UART output
|
|
101
|
+
emu.injectFrame(packetBytes); // inject an Ethernet frame
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
See `site/test_flow.mjs` and the exports in `index.mjs` for the full API
|
|
105
|
+
(`decodeFirmware`, `createEmulator`, `createNetSim` re-exports).
|
|
106
|
+
|
|
107
|
+
Attach virtual hardware (LEDs, buttons, PWM readers, analog sensors,
|
|
108
|
+
custom SPI/I2C devices) to pins and buses with the component API —
|
|
109
|
+
`emu.pin()`/`emu.watchPin()`/`emu.setAdcChannel()` plus the
|
|
110
|
+
`LED`/`Button`/`Pwm`/`Potentiometer`/`I2cRegisterDevice` components, or
|
|
111
|
+
`ext_devices.spiDevices`/`i2cDevices` for your own bus protocol. See
|
|
112
|
+
[docs/components.md](docs/components.md).
|
|
113
|
+
|
|
114
|
+
## Drive it from an AI agent (MCP)
|
|
115
|
+
|
|
116
|
+
`mcp/server.mjs` exposes the emulator over the Model Context Protocol —
|
|
117
|
+
boot firmware, step, read UART, poke pins, inject ADC values, and inspect
|
|
118
|
+
registers as tools an MCP client (Claude Code, Claude Desktop) can call:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npm install && npm run mcp
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The MCP SDK is an *optional peer dependency*, so installing this package
|
|
125
|
+
as a library stays dependency-free; only MCP users need
|
|
126
|
+
`npm i @modelcontextprotocol/sdk zod`. See [docs/mcp.md](docs/mcp.md) for
|
|
127
|
+
the tool reference and client config.
|
|
128
|
+
|
|
129
|
+
## Firmwares
|
|
130
|
+
|
|
131
|
+
| Firmware | What it does | Success marker |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| `eth_http/` | DHCP + TCP client + HTTP GET + prints the response | `TCP connected`, `=== HTTP <len>b ===` |
|
|
134
|
+
| `eth_dhcp/` | Loops DHCP Discover/Offer/Request/Ack | `DHCP SUCCESS` |
|
|
135
|
+
| `eth_test/` | Raw ETH TX/RX self-test | `ETH Test: done` |
|
|
136
|
+
| `blinky/` | **No ethernet** — LED blinker on GPIOA PA5 + UART tick counter | `tick N LED=ON/OFF` |
|
|
137
|
+
| `doom/` | **DOOM 1 shareware** (doomgeneric F407 port, browser page `site/doom.html`) | `node site/test_doom.mjs` (boot + menu + gameplay + save/load) |
|
|
138
|
+
|
|
139
|
+
Plus 17 more test binaries (`crypto_test`, `hal_test`, `timer_test`,
|
|
140
|
+
`periph_test`, `echo_test`, `blink_serial`, `rx_interrupt_test`,
|
|
141
|
+
`spi_tft_test`, …) from the `*_test/` directories — all boot headless and
|
|
142
|
+
print a banner over UART (probe: `node site/probe_firmwares.mjs`).
|
|
143
|
+
|
|
144
|
+
All are bare-metal (no RTOS), built with the Arduino core's
|
|
145
|
+
arm-none-eabi-gcc, and driven purely through memory-mapped registers —
|
|
146
|
+
the same firmware binaries run on the emulator and on real silicon. That
|
|
147
|
+
equivalence is for **logic**, not **timing**: timers/ADC/RNG/RTC/watchdogs
|
|
148
|
+
are instruction-count driven rather than wall-clock driven, so firmware
|
|
149
|
+
relying on real-time behavior (PWM frequency matching real hardware,
|
|
150
|
+
watchdog timeouts close to spec) will diverge — see docs/progress-and-future.md#known-limitations.
|
|
151
|
+
|
|
152
|
+
## Architecture
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
firmware .bin ──► Unicorn WASM CPU ──► memory hooks
|
|
156
|
+
│
|
|
157
|
+
periph_read/write ──► Rust peripheral model (WASM)
|
|
158
|
+
RCC USART GPIO DMA ETH TIM NVIC
|
|
159
|
+
│
|
|
160
|
+
UART out / ETH TX frames ──► driver (cli.mjs / site/emulator.js)
|
|
161
|
+
│
|
|
162
|
+
RX frames injected (netsim, or real gVisor gateway)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
- **CPU**: Unicorn 2.1.4 compiled to WASM executes Thumb-2 code; every
|
|
166
|
+
read/write to a hooked MMIO range is routed into the Rust model, which
|
|
167
|
+
answers by writing the modeled register value back into guest memory.
|
|
168
|
+
- **Peripherals**: a `wasm-bindgen` crate (`stm32-periph-wasm/`); registers
|
|
169
|
+
and bit fields come from the vendor SVD (`monox/stm32f407.svd`).
|
|
170
|
+
- **Ethernet**: TX is captured from the DMA descriptors; RX frames are
|
|
171
|
+
injected into the RX ring and the firmware's `eth_irq_flag` (SRAM) drives
|
|
172
|
+
polling — no interrupts required. Optionally, a Go gateway
|
|
173
|
+
(`openhw-local-gateway/`) with a gVisor network stack makes the firmware
|
|
174
|
+
talk to a real network: `node cli.mjs <fw.bin> <inst> --gateway`.
|
|
175
|
+
- **Browser build** (`site/`): same modules as ESM; `site/emulator.js` is an
|
|
176
|
+
import-free universal factory; `site/netsim.js` is a canned network peer;
|
|
177
|
+
`site/loaders.js` parses Intel HEX / ELF32 / linker-map files. The demo
|
|
178
|
+
page's gateway mode uses the exact same protocol as the Node CLI:
|
|
179
|
+
raw Ethernet frames over a WebSocket (`/api/network-gateway`), `RESET`
|
|
180
|
+
control message on reboot.
|
|
181
|
+
|
|
182
|
+
## Repository layout
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
├── site/ Single-page console + universal emulator factory
|
|
186
|
+
│ ├── index.html, app.js Console UI (UART, presets, loaders, gateway, GPIO)
|
|
187
|
+
│ ├── doom.html, doom.js DOOM page (gameplay + save/load via localStorage)
|
|
188
|
+
│ ├── emulator.js Import-free emulator factory (Node + browser)
|
|
189
|
+
│ ├── components.js Virtual components (LED/Button/Pwm/Potentiometer/...)
|
|
190
|
+
│ ├── netsim.js Canned DHCP/TCP/HTTP network peer (fallback)
|
|
191
|
+
│ ├── loaders.js Intel HEX / ELF32 / linker-map parsers
|
|
192
|
+
│ ├── test_flow.mjs Node E2E flow test (npm test)
|
|
193
|
+
│ ├── test_blinky.mjs Node blinky GPIO test (npm test)
|
|
194
|
+
│ ├── test_rx_interrupt.mjs Node UART-interrupt test (npm test)
|
|
195
|
+
│ ├── test_component_*.mjs Component-API tests, one firmware each (npm test)
|
|
196
|
+
│ ├── test_doom.mjs Node DOOM boot/menu/gameplay/save test
|
|
197
|
+
│ └── vendor/ Browser WASM build, SVD, Unicorn
|
|
198
|
+
├── index.mjs, package.json npm package entry (stm32f4-emu)
|
|
199
|
+
├── mcp/ MCP server (drive the emulator from an AI agent)
|
|
200
|
+
├── .github/workflows/ CI (Linux/Windows/macOS test matrix) + Pages deploy
|
|
201
|
+
├── tools/make_firmware.mjs Regenerates site/firmware.js from eth_*/.bin
|
|
202
|
+
├── stm32-periph-wasm/ Rust peripheral model (WASM build + pkg/)
|
|
203
|
+
├── eth_http/ eth_dhcp/ eth_test/ Sample network firmwares + configs
|
|
204
|
+
├── doom/ DOOM 1 port (doomgeneric f407 target + WAD path)
|
|
205
|
+
├── openhw-local-gateway/ Go gateway (gVisor network stack)
|
|
206
|
+
├── scripts/verify_ethernet.sh Regression runner for all three firmwares
|
|
207
|
+
├── src/, monox/, saturn/ Native SDL emulator (upstream heritage)
|
|
208
|
+
└── AGENTS.md Full architecture, build steps, runbook
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Building from source
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
# Rust peripheral model (Node target; browser target goes to site/vendor/)
|
|
215
|
+
cd stm32-periph-wasm && wasm-pack build --release --target nodejs
|
|
216
|
+
|
|
217
|
+
# firmware (bare-metal Makefiles; toolchain from the Arduino core)
|
|
218
|
+
TOOLCHAIN="$HOME/.arduino15/packages/STMicroelectronics/tools/xpack-arm-none-eabi-gcc/14.2.1-1.1/bin/arm-none-eabi-" \
|
|
219
|
+
make -C eth_http # also eth_dhcp, eth_test
|
|
220
|
+
|
|
221
|
+
# native SDL emulator (upstream heritage, not the headless path)
|
|
222
|
+
cd stm32-emulator-main && cargo build --release
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`wasm-pack` writes `site/vendor/.gitignore` containing `*` after a browser
|
|
226
|
+
rebuild — delete it so the vendor assets stay tracked/committed.
|
|
227
|
+
|
|
228
|
+
## Testing
|
|
229
|
+
|
|
230
|
+
- `npm test` — flow test (`site/test_flow.mjs`) + blinky test
|
|
231
|
+
(`site/test_blinky.mjs`) + interrupt-UART test (`site/test_rx_interrupt.mjs`)
|
|
232
|
+
+ component-API tests (`site/test_component_{led,button,pwm,i2cregfile}.mjs`,
|
|
233
|
+
each against real firmware — LED/blinky, Button/exti_test, Pwm/buzzer_test,
|
|
234
|
+
I2cRegisterDevice/rtc_test), exit 0 = all PASS. Each test file boots
|
|
235
|
+
exactly one firmware in its own `node` process — `createEmulator()`
|
|
236
|
+
instances aren't safe to reuse across different firmware in the same
|
|
237
|
+
process (see docs/components.md). The same suite runs in CI on
|
|
238
|
+
`ubuntu-latest`/`windows-latest`/`macos-latest`
|
|
239
|
+
([.github/workflows/ci.yml](.github/workflows/ci.yml)) on every push.
|
|
240
|
+
- `node site/test_doom.mjs` — DOOM boot → menu → E1M1 gameplay + save/load
|
|
241
|
+
(see the DOOM section above).
|
|
242
|
+
- `scripts/verify_ethernet.sh [max_inst]` — runs all three firmwares through
|
|
243
|
+
the gateway, asserts the success markers and 0 `TCP fail`.
|
|
244
|
+
- Soak-tested: 200M-instruction gateway runs with 1000+ consecutive TCP
|
|
245
|
+
rounds, 0 failures (details in AGENTS.md §10).
|
|
246
|
+
|
|
247
|
+
## Documentation
|
|
248
|
+
|
|
249
|
+
- [docs/architecture.md](docs/architecture.md) — how the emulator is put
|
|
250
|
+
together (CPU, peripheral model, drivers, ETH flow, interrupts).
|
|
251
|
+
- [docs/peripherals.md](docs/peripherals.md) — all 33 peripherals and the
|
|
252
|
+
level each is implemented to, plus external devices and known gaps.
|
|
253
|
+
- [docs/usage.md](docs/usage.md) — CLI, browser, and npm-library usage,
|
|
254
|
+
config files, env vars, building.
|
|
255
|
+
- [docs/components.md](docs/components.md) — attach virtual LEDs, buttons,
|
|
256
|
+
PWM/analog sensors, and custom SPI/I2C devices to pins/buses
|
|
257
|
+
(rp2040js-style component API).
|
|
258
|
+
- [docs/mcp.md](docs/mcp.md) — the MCP server: drive the emulator as tools
|
|
259
|
+
from Claude Code / Claude Desktop or any MCP client.
|
|
260
|
+
- [docs/benchmarks.md](docs/benchmarks.md) — throughput numbers, soak
|
|
261
|
+
results, tunables.
|
|
262
|
+
- [docs/progress-and-future.md](docs/progress-and-future.md) — status,
|
|
263
|
+
known limitations, roadmap.
|
|
264
|
+
|
|
265
|
+
## License
|
|
266
|
+
|
|
267
|
+
GPL-3.0-only. See [LICENSE](LICENSE).
|
|
268
|
+
|
|
269
|
+
*This repository is a fork/continuation of
|
|
270
|
+
[nviennot/stm32-emulator](https://github.com/nviennot/stm32-emulator), which
|
|
271
|
+
emulated 3D-printer firmwares (Elegoo Saturn, Anycubic Mono X) in a native
|
|
272
|
+
SDL app. The WASM headless emulator, network firmwares, browser demo, and npm
|
|
273
|
+
package are new work built on that base.*
|
package/index.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// stm32f4-emu — Node API entry.
|
|
2
|
+
// Wraps the browser/Node-universal emulator.js with the bundled assets
|
|
3
|
+
// (SVD, wasm bindings, Unicorn) so Node consumers get a one-call setup.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import * as bindings from './site/vendor/stm32_periph_wasm.js';
|
|
7
|
+
import { createEmulator } from './site/emulator.js';
|
|
8
|
+
import { createNetSim } from './site/netsim.js';
|
|
9
|
+
import { FIRMWARES } from './site/firmware.js';
|
|
10
|
+
import { LED, Button, Pwm, I2cRegisterDevice, Potentiometer } from './site/components.js';
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const unicornFactory = require('./site/vendor/unicorn_arm.cjs');
|
|
14
|
+
|
|
15
|
+
const svdXml = readFileSync(new URL('./site/vendor/stm32f407.svd', import.meta.url), 'utf8');
|
|
16
|
+
const wasmBytes = new Uint8Array(readFileSync(new URL('./site/vendor/stm32_periph_wasm_bg.wasm', import.meta.url)));
|
|
17
|
+
|
|
18
|
+
// Decode a base64-encoded firmware from FIRMWARES.
|
|
19
|
+
export function decodeFirmware(key) {
|
|
20
|
+
const fw = FIRMWARES[key];
|
|
21
|
+
if (!fw) throw new Error(`unknown firmware '${key}' (have: ${Object.keys(FIRMWARES).join(', ')})`);
|
|
22
|
+
const bin = atob(fw.bytes);
|
|
23
|
+
const out = new Uint8Array(bin.length);
|
|
24
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Convenience: create an emulator with the bundled STM32F407 assets.
|
|
29
|
+
// await createSTM32F407({ firmware }) // firmware: Uint8Array
|
|
30
|
+
// await createSTM32F407({ firmware: 'eth_http' }) // or a FIRMWARES key
|
|
31
|
+
// All extra options pass through to createEmulator().
|
|
32
|
+
export async function createSTM32F407(opts = {}) {
|
|
33
|
+
const { firmware } = opts;
|
|
34
|
+
const bin = typeof firmware === 'string' ? decodeFirmware(firmware) : firmware;
|
|
35
|
+
if (!bin) throw new Error('createSTM32F407 requires `firmware` (Uint8Array or a FIRMWARES key)');
|
|
36
|
+
return createEmulator({ ...opts, firmware: bin, bindings, unicorn: unicornFactory, svdXml, wasmInit: wasmBytes });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { createEmulator, createNetSim, FIRMWARES, bindings, unicornFactory, svdXml, LED, Button, Pwm, I2cRegisterDevice, Potentiometer };
|
package/mcp/server.mjs
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// MCP server exposing the STM32F407 emulator as tools an MCP client
|
|
3
|
+
// (Claude Code / Claude Desktop / any MCP host) can drive: load firmware,
|
|
4
|
+
// step execution, read/write UART, poke and watch GPIO pins, inject ADC
|
|
5
|
+
// values, and inspect CPU registers.
|
|
6
|
+
//
|
|
7
|
+
// One active emulator session at a time — load_firmware/reset replaces it.
|
|
8
|
+
// Sequential instances are supported (see docs/components.md), but there is
|
|
9
|
+
// exactly one active system per process and creating a new one detaches the
|
|
10
|
+
// old, so we close the previous instance before creating the next.
|
|
11
|
+
import { createSTM32F407, FIRMWARES, LED, Button, Pwm, Potentiometer } from '../index.mjs';
|
|
12
|
+
|
|
13
|
+
// The MCP SDK and zod are OPTIONAL peer dependencies: the emulator library
|
|
14
|
+
// itself has no runtime dependencies, and only this server needs them, so
|
|
15
|
+
// they aren't forced on consumers who just want to run firmware. Loaded
|
|
16
|
+
// dynamically to turn a missing install into an actionable message rather
|
|
17
|
+
// than a raw ERR_MODULE_NOT_FOUND stack.
|
|
18
|
+
let McpServer, StdioServerTransport, z;
|
|
19
|
+
try {
|
|
20
|
+
({ McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js'));
|
|
21
|
+
({ StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js'));
|
|
22
|
+
({ z } = await import('zod'));
|
|
23
|
+
} catch (e) {
|
|
24
|
+
process.stderr.write(
|
|
25
|
+
'stm32f4-mcp: missing optional dependencies.\n\n' +
|
|
26
|
+
' npm install @modelcontextprotocol/sdk zod\n\n' +
|
|
27
|
+
'They are optional peer dependencies so that using this package as an\n' +
|
|
28
|
+
'emulator library stays dependency-free. See docs/mcp.md.\n' +
|
|
29
|
+
`\nOriginal error: ${e.message}\n`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let session = null; // { emu, firmware, components: Map }
|
|
34
|
+
let componentSeq = 0;
|
|
35
|
+
|
|
36
|
+
const text = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s, null, 2) }] });
|
|
37
|
+
const err = (s) => ({ content: [{ type: 'text', text: s }], isError: true });
|
|
38
|
+
|
|
39
|
+
function requireSession() {
|
|
40
|
+
if (!session) throw new Error('no firmware loaded — call load_firmware first');
|
|
41
|
+
return session;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const server = new McpServer({ name: 'stm32f4-emu', version: '0.1.0' });
|
|
45
|
+
|
|
46
|
+
server.registerTool('list_firmwares', {
|
|
47
|
+
description: 'List the firmware images bundled with the emulator that load_firmware accepts.',
|
|
48
|
+
inputSchema: {},
|
|
49
|
+
}, async () => text(Object.keys(FIRMWARES)));
|
|
50
|
+
|
|
51
|
+
server.registerTool('load_firmware', {
|
|
52
|
+
description: 'Boot a bundled firmware image, replacing any active session. Use list_firmwares for valid names.',
|
|
53
|
+
inputSchema: {
|
|
54
|
+
firmware: z.string().describe('a firmware key from list_firmwares, e.g. "blinky"'),
|
|
55
|
+
enable_irqs: z.boolean().optional().describe('run guest IRQ handlers between batches (needed for interrupt-driven firmware; must stay off for the ETH demos)'),
|
|
56
|
+
},
|
|
57
|
+
}, async ({ firmware, enable_irqs = false }) => {
|
|
58
|
+
if (!FIRMWARES[firmware]) return err(`unknown firmware '${firmware}' (have: ${Object.keys(FIRMWARES).join(', ')})`);
|
|
59
|
+
const replaced = session !== null;
|
|
60
|
+
if (session) { try { session.emu.close(); } catch {} }
|
|
61
|
+
const emu = await createSTM32F407({ firmware, enable_irqs });
|
|
62
|
+
session = { emu, firmware, components: new Map() };
|
|
63
|
+
componentSeq = 0;
|
|
64
|
+
return text({
|
|
65
|
+
loaded: firmware,
|
|
66
|
+
enable_irqs,
|
|
67
|
+
note: replaced
|
|
68
|
+
? 'Replaced a previous session. Peripheral state is process-global in the wasm model, so switching firmware in one process can misbehave (see docs/components.md) — restart this server for a fully clean boot.'
|
|
69
|
+
: undefined,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
server.registerTool('step', {
|
|
74
|
+
description: 'Run the CPU for up to N instructions. Returns the program counter, total instruction count, and whether the machine stopped.',
|
|
75
|
+
inputSchema: { instructions: z.number().int().positive().default(100000) },
|
|
76
|
+
}, async ({ instructions }) => {
|
|
77
|
+
const { emu } = requireSession();
|
|
78
|
+
const r = emu.step(instructions);
|
|
79
|
+
return text({ pc: '0x' + (r.pc >>> 0).toString(16), instCount: r.instCount, stopped: r.stopped });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
server.registerTool('read_uart', {
|
|
83
|
+
description: 'Drain and return any UART output the firmware has printed since the last call.',
|
|
84
|
+
inputSchema: {},
|
|
85
|
+
}, async () => text(requireSession().emu.drainUart() || '(no output)'));
|
|
86
|
+
|
|
87
|
+
server.registerTool('send_uart', {
|
|
88
|
+
description: 'Send text to the firmware over UART RX (as if typed into a serial console).',
|
|
89
|
+
inputSchema: { text: z.string() },
|
|
90
|
+
}, async ({ text: s }) => {
|
|
91
|
+
const { emu } = requireSession();
|
|
92
|
+
emu.sendUart([...s].map((c) => c.charCodeAt(0)));
|
|
93
|
+
return text(`sent ${s.length} byte(s)`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
server.registerTool('read_pin', {
|
|
97
|
+
description: 'Read a GPIO pin: the level the guest is driving out, and the input level it sees.',
|
|
98
|
+
inputSchema: {
|
|
99
|
+
port: z.string().describe('port letter, e.g. "A"'),
|
|
100
|
+
pin: z.number().int().min(0).max(15),
|
|
101
|
+
},
|
|
102
|
+
}, async ({ port, pin }) => {
|
|
103
|
+
const p = requireSession().emu.pin(port, pin);
|
|
104
|
+
return text({ port, pin, output: p.read(), input: p.readInput() });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
server.registerTool('write_pin', {
|
|
108
|
+
description: 'Drive a GPIO input level into the guest (as an external device or button would).',
|
|
109
|
+
inputSchema: {
|
|
110
|
+
port: z.string().describe('port letter, e.g. "A"'),
|
|
111
|
+
pin: z.number().int().min(0).max(15),
|
|
112
|
+
level: z.boolean(),
|
|
113
|
+
},
|
|
114
|
+
}, async ({ port, pin, level }) => {
|
|
115
|
+
requireSession().emu.pin(port, pin).write(level);
|
|
116
|
+
return text(`P${port.toUpperCase()}${pin} <- ${level ? 'high' : 'low'}`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
server.registerTool('set_adc_channel', {
|
|
120
|
+
description: 'Force an ADC channel to a 12-bit value (0-4095), simulating an analog sensor. Omit `value` to clear the override.',
|
|
121
|
+
inputSchema: {
|
|
122
|
+
peripheral: z.string().default('ADC1'),
|
|
123
|
+
channel: z.number().int().min(0).max(18),
|
|
124
|
+
value: z.number().int().min(0).max(4095).optional(),
|
|
125
|
+
},
|
|
126
|
+
}, async ({ peripheral, channel, value }) => {
|
|
127
|
+
const { emu } = requireSession();
|
|
128
|
+
if (value === undefined) {
|
|
129
|
+
emu.clearAdcChannel(peripheral, channel);
|
|
130
|
+
return text(`${peripheral} ch${channel} override cleared`);
|
|
131
|
+
}
|
|
132
|
+
emu.setAdcChannel(peripheral, channel, value);
|
|
133
|
+
return text(`${peripheral} ch${channel} <- ${value}`);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
server.registerTool('read_registers', {
|
|
137
|
+
description: 'Read the ARM CPU registers (R0-R12, SP, LR, PC, XPSR) as hex.',
|
|
138
|
+
inputSchema: {},
|
|
139
|
+
}, async () => {
|
|
140
|
+
const r = requireSession().emu.getRegisters();
|
|
141
|
+
return text(Object.fromEntries(Object.entries(r).map(([k, v]) => [k, '0x' + (v >>> 0).toString(16)])));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
server.registerTool('read_memory', {
|
|
145
|
+
description: 'Read a 32-bit word from guest memory (flash, SRAM, or a peripheral register).',
|
|
146
|
+
inputSchema: { address: z.number().int().describe('absolute address, e.g. 0x40020014 for GPIOA_ODR') },
|
|
147
|
+
}, async ({ address }) => {
|
|
148
|
+
const v = requireSession().emu.read32(address);
|
|
149
|
+
return text({ address: '0x' + (address >>> 0).toString(16), value: '0x' + (v >>> 0).toString(16), decimal: v });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
server.registerTool('attach_component', {
|
|
153
|
+
description: 'Attach a virtual component to the running machine and return its id for read_component. Types: led (port+pin), button (port+pin), pwm (timer+channel), potentiometer (peripheral+channel).',
|
|
154
|
+
inputSchema: {
|
|
155
|
+
type: z.enum(['led', 'button', 'pwm', 'potentiometer']),
|
|
156
|
+
port: z.string().optional().describe('led/button: port letter, e.g. "A"'),
|
|
157
|
+
pin: z.number().int().min(0).max(15).optional().describe('led/button: pin number'),
|
|
158
|
+
timer: z.string().optional().describe('pwm: timer name, e.g. "TIM2"'),
|
|
159
|
+
channel: z.number().int().optional().describe('pwm: channel 1-4; potentiometer: ADC channel'),
|
|
160
|
+
peripheral: z.string().optional().describe('potentiometer: ADC peripheral, default ADC1'),
|
|
161
|
+
activeLow: z.boolean().optional(),
|
|
162
|
+
},
|
|
163
|
+
}, async (a) => {
|
|
164
|
+
const { emu, components } = requireSession();
|
|
165
|
+
const id = `${a.type}${++componentSeq}`;
|
|
166
|
+
let comp;
|
|
167
|
+
switch (a.type) {
|
|
168
|
+
case 'led':
|
|
169
|
+
if (a.port === undefined || a.pin === undefined) return err('led requires port and pin');
|
|
170
|
+
comp = new LED(emu, a.port, a.pin, { activeLow: a.activeLow ?? false });
|
|
171
|
+
break;
|
|
172
|
+
case 'button':
|
|
173
|
+
if (a.port === undefined || a.pin === undefined) return err('button requires port and pin');
|
|
174
|
+
comp = new Button(emu, a.port, a.pin, { activeLow: a.activeLow ?? true });
|
|
175
|
+
break;
|
|
176
|
+
case 'pwm':
|
|
177
|
+
if (!a.timer) return err('pwm requires timer');
|
|
178
|
+
comp = new Pwm(emu, a.timer, a.channel ?? 1);
|
|
179
|
+
break;
|
|
180
|
+
case 'potentiometer':
|
|
181
|
+
if (a.channel === undefined) return err('potentiometer requires channel');
|
|
182
|
+
comp = new Potentiometer(emu, a.peripheral ?? 'ADC1', a.channel);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
components.set(id, { type: a.type, comp });
|
|
186
|
+
return text({ id, type: a.type });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
server.registerTool('read_component', {
|
|
190
|
+
description: 'Read the current state of an attached component (led: on/off, pwm: freq/duty, potentiometer: value).',
|
|
191
|
+
inputSchema: { id: z.string() },
|
|
192
|
+
}, async ({ id }) => {
|
|
193
|
+
const entry = requireSession().components.get(id);
|
|
194
|
+
if (!entry) return err(`no component '${id}'`);
|
|
195
|
+
const { type, comp } = entry;
|
|
196
|
+
if (type === 'led') return text({ id, type, on: comp.value });
|
|
197
|
+
if (type === 'pwm') return text({ id, type, freq: comp.freq, duty: comp.duty });
|
|
198
|
+
if (type === 'potentiometer') return text({ id, type, value: comp.value });
|
|
199
|
+
return text({ id, type, note: 'buttons are write-only; use control_component to press/release' });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
server.registerTool('control_component', {
|
|
203
|
+
description: 'Act on an attached component: press/release a button, or set a potentiometer value.',
|
|
204
|
+
inputSchema: {
|
|
205
|
+
id: z.string(),
|
|
206
|
+
action: z.enum(['press', 'release', 'set']),
|
|
207
|
+
value: z.number().optional().describe('required for action=set on a potentiometer'),
|
|
208
|
+
},
|
|
209
|
+
}, async ({ id, action, value }) => {
|
|
210
|
+
const entry = requireSession().components.get(id);
|
|
211
|
+
if (!entry) return err(`no component '${id}'`);
|
|
212
|
+
const { type, comp } = entry;
|
|
213
|
+
if (action === 'set') {
|
|
214
|
+
if (type !== 'potentiometer') return err(`'set' only applies to a potentiometer, not ${type}`);
|
|
215
|
+
if (value === undefined) return err("action 'set' requires value");
|
|
216
|
+
comp.value = value;
|
|
217
|
+
return text({ id, value: comp.value });
|
|
218
|
+
}
|
|
219
|
+
if (type !== 'button') return err(`'${action}' only applies to a button, not ${type}`);
|
|
220
|
+
action === 'press' ? comp.press() : comp.release();
|
|
221
|
+
return text({ id, action });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
server.registerTool('reset', {
|
|
225
|
+
description: 'Close the active emulator session and clear all attached components.',
|
|
226
|
+
inputSchema: {},
|
|
227
|
+
}, async () => {
|
|
228
|
+
if (!session) return text('no active session');
|
|
229
|
+
try { session.emu.close(); } catch {}
|
|
230
|
+
const was = session.firmware;
|
|
231
|
+
session = null;
|
|
232
|
+
return text(`closed session (${was})`);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await server.connect(new StdioServerTransport());
|
package/package.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stm32f4-emu",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "STM32F407 emulator: Unicorn (WASM) CPU + Rust peripheral model. Runs real Cortex-M4 firmware in Node or the browser.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.mjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"stm32f4-mcp": "./mcp/server.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./index.mjs",
|
|
12
|
+
"./components": "./site/components.js",
|
|
13
|
+
"./emulator": "./site/emulator.js",
|
|
14
|
+
"./netsim": "./site/netsim.js",
|
|
15
|
+
"./firmwares": "./site/firmware.js",
|
|
16
|
+
"./loaders": "./site/loaders.js",
|
|
17
|
+
"./vendor": "./site/vendor/stm32_periph_wasm.js",
|
|
18
|
+
"./vendor/unicorn": "./site/vendor/unicorn_arm.cjs",
|
|
19
|
+
"./site": "./site/index.html"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"index.mjs",
|
|
23
|
+
"README.md",
|
|
24
|
+
"site/emulator.js",
|
|
25
|
+
"site/components.js",
|
|
26
|
+
"mcp/server.mjs",
|
|
27
|
+
"site/netsim.js",
|
|
28
|
+
"site/firmware.js",
|
|
29
|
+
"site/loaders.js",
|
|
30
|
+
"site/index.html",
|
|
31
|
+
"site/app.js",
|
|
32
|
+
"site/vendor/"
|
|
33
|
+
],
|
|
34
|
+
"keywords": [
|
|
35
|
+
"stm32",
|
|
36
|
+
"stm32f407",
|
|
37
|
+
"emulator",
|
|
38
|
+
"arm",
|
|
39
|
+
"cortex-m4",
|
|
40
|
+
"unicorn",
|
|
41
|
+
"wasm",
|
|
42
|
+
"embedded",
|
|
43
|
+
"firmware"
|
|
44
|
+
],
|
|
45
|
+
"license": "GPL-3.0-only",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/danish9661/stm32F4-emulator.git"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://danish9661.github.io/stm32F4-emulator/",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/danish9661/stm32F4-emulator/issues"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=20"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"test": "node site/test_flow.mjs && node site/test_blinky.mjs && node site/test_rx_interrupt.mjs && node site/test_component_led.mjs && node site/test_component_button.mjs && node site/test_component_pwm.mjs && node site/test_component_i2cregfile.mjs && node site/test_component_adc.mjs && node site/test_multi_instance.mjs && node site/test_fsmc_dcmi.mjs && node site/test_fsmc.mjs && node site/test_dcmi.mjs",
|
|
59
|
+
"test:flow": "node site/test_flow.mjs",
|
|
60
|
+
"test:blinky": "node site/test_blinky.mjs",
|
|
61
|
+
"test:rx": "node site/test_rx_interrupt.mjs",
|
|
62
|
+
"test:multi": "node site/test_multi_instance.mjs",
|
|
63
|
+
"test:fsmc": "node site/test_fsmc.mjs && node site/test_dcmi.mjs && node site/test_fsmc_dcmi.mjs",
|
|
64
|
+
"test:components": "node site/test_component_led.mjs && node site/test_component_button.mjs && node site/test_component_pwm.mjs && node site/test_component_i2cregfile.mjs && node site/test_component_adc.mjs",
|
|
65
|
+
"test:mcp": "node mcp/test_mcp.mjs",
|
|
66
|
+
"mcp": "node mcp/server.mjs",
|
|
67
|
+
"firmwares": "node tools/make_firmware.mjs",
|
|
68
|
+
"serve": "python3 -m http.server 8123 --directory site",
|
|
69
|
+
"prepack": "node tools/make_firmware.mjs"
|
|
70
|
+
},
|
|
71
|
+
"peerDependencies": {
|
|
72
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
73
|
+
"zod": "^3.25 || ^4.0"
|
|
74
|
+
},
|
|
75
|
+
"peerDependenciesMeta": {
|
|
76
|
+
"@modelcontextprotocol/sdk": { "optional": true },
|
|
77
|
+
"zod": { "optional": true }
|
|
78
|
+
},
|
|
79
|
+
"devDependencies": {
|
|
80
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
81
|
+
"zod": "^3.25 || ^4.0"
|
|
82
|
+
}
|
|
83
|
+
}
|