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/README.md ADDED
@@ -0,0 +1,273 @@
1
+ # STM32F4 Emulator
2
+
3
+ [![CI](https://github.com/danish9661/stm32F4-emulator/actions/workflows/ci.yml/badge.svg)](https://github.com/danish9661/stm32F4-emulator/actions/workflows/ci.yml)
4
+ [![Pages](https://github.com/danish9661/stm32F4-emulator/actions/workflows/pages.yml/badge.svg)](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
+ }