452 lines
14 KiB
Markdown
452 lines
14 KiB
Markdown
# Custom Chips — Build & Test guide
|
|
|
|
Toolchain setup, compilation pipeline, and testing — both the in-app
|
|
"Compile" button and the local sandbox suite (70+ regression tests).
|
|
|
|
---
|
|
|
|
## Table of contents
|
|
|
|
- [Toolchain at a glance](#toolchain-at-a-glance)
|
|
- [Installing WASI-SDK](#installing-wasi-sdk)
|
|
- [Compiling a single chip](#compiling-a-single-chip)
|
|
- [Compiling via the backend API](#compiling-via-the-backend-api)
|
|
- [The sandbox test suite](#the-sandbox-test-suite)
|
|
- [Multi-board validation suite](#multi-board-validation-suite)
|
|
- [Writing tests for your own chip](#writing-tests-for-your-own-chip)
|
|
- [Troubleshooting](#troubleshooting)
|
|
|
|
---
|
|
|
|
## Toolchain at a glance
|
|
|
|
The whole compilation pipeline is **two open-source tools** plus our header:
|
|
|
|
| Component | What it does | License |
|
|
|---|---|---|
|
|
| **clang** (from WASI-SDK) | Compiles C → WebAssembly | Apache 2.0 |
|
|
| **wasi-libc** (from WASI-SDK) | C standard library (`malloc`, `printf`, `memset`, …) | MIT |
|
|
| **`velxio-chip.h`** | The Velxio chip API header | MIT (Velxio) |
|
|
|
|
No third-party simulator code is involved. The Velxio backend Docker image
|
|
bundles WASI-SDK at `/opt/wasi-sdk` automatically; for local dev you install
|
|
it once.
|
|
|
|
---
|
|
|
|
## Installing WASI-SDK
|
|
|
|
WASI-SDK is a single tarball with everything needed: clang, wasi-libc,
|
|
runtime libraries.
|
|
|
|
Releases: <https://github.com/WebAssembly/wasi-sdk/releases>
|
|
|
|
### Linux / macOS
|
|
|
|
```bash
|
|
cd /opt
|
|
sudo curl -L https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-22/wasi-sdk-22.0-linux.tar.gz \
|
|
| sudo tar -xz
|
|
sudo mv wasi-sdk-22.0 wasi-sdk
|
|
echo 'export WASI_SDK=/opt/wasi-sdk' >> ~/.bashrc
|
|
export WASI_SDK=/opt/wasi-sdk
|
|
```
|
|
|
|
### Windows (PowerShell)
|
|
|
|
```powershell
|
|
$ErrorActionPreference = 'Stop'
|
|
$tmp = "$env:TEMP\wasi-sdk.tar.gz"
|
|
Invoke-WebRequest `
|
|
https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-22/wasi-sdk-22.0-mingw.tar.gz `
|
|
-OutFile $tmp
|
|
tar -xzf $tmp -C C:\
|
|
Move-Item C:\wasi-sdk-22.0 C:\wasi-sdk
|
|
[Environment]::SetEnvironmentVariable('WASI_SDK', 'C:\wasi-sdk', 'User')
|
|
$env:WASI_SDK = 'C:\wasi-sdk'
|
|
```
|
|
|
|
Reopen your terminal so the env var sticks, then verify:
|
|
|
|
```bash
|
|
$WASI_SDK/bin/clang --version
|
|
# clang version 22.x ... Target: wasm32-unknown-wasip1
|
|
```
|
|
|
|
If your installed version reports a different default target, pass
|
|
`--target=wasm32-unknown-wasip1` explicitly (the compile script already does).
|
|
|
|
### Verify the backend has it
|
|
|
|
When the backend is running:
|
|
|
|
```bash
|
|
curl http://localhost:8001/api/compile-chip/status
|
|
# {"available":true,"wasi_sdk":"/opt/wasi-sdk","sdk_include":"/app/sdk"}
|
|
```
|
|
|
|
If `available: false`, set `WASI_SDK` and restart uvicorn, or rebuild the
|
|
Docker image.
|
|
|
|
---
|
|
|
|
## Compiling a single chip
|
|
|
|
The exact command Velxio uses internally:
|
|
|
|
```bash
|
|
$WASI_SDK/bin/clang \
|
|
--target=wasm32-unknown-wasip1 \
|
|
-O2 \
|
|
-nostartfiles \
|
|
-Wl,--import-memory \
|
|
-Wl,--export-table \
|
|
-Wl,--no-entry \
|
|
-Wl,--export=chip_setup \
|
|
-Wl,--allow-undefined \
|
|
-I /path/to/sdk/include \
|
|
mychip.c -o mychip.wasm
|
|
```
|
|
|
|
### Each flag explained
|
|
|
|
| Flag | Why |
|
|
|---|---|
|
|
| `--target=wasm32-unknown-wasip1` | Backend = WASM, libc = WASI preview-1 |
|
|
| `-O2` | Reasonable optimisation; `-Os` is fine too if you want smaller |
|
|
| `-nostartfiles` | No `crt0` — the chip has no `main()` |
|
|
| `-Wl,--import-memory` | The host provides the WASM linear memory |
|
|
| `-Wl,--export-table` | Exposes the function table so the host can invoke C function pointers (used for I2C / SPI / pin-watch callbacks) |
|
|
| `-Wl,--no-entry` | No `_start` function, only exports |
|
|
| `-Wl,--export=chip_setup` | Guarantees `chip_setup` survives DCE if linker is aggressive |
|
|
| `-Wl,--allow-undefined` | Treat unresolved `vx_*` symbols as WASM imports (the host provides them) |
|
|
|
|
### Convenience scripts
|
|
|
|
The sandbox ships ready-to-use scripts:
|
|
|
|
```bash
|
|
# Linux/macOS
|
|
bash test/test_custom_chips/scripts/compile-chip.sh \
|
|
mychip.c fixtures/mychip.wasm
|
|
|
|
# Windows
|
|
.\test\test_custom_chips\scripts\compile-chip.ps1 `
|
|
mychip.c fixtures\mychip.wasm
|
|
```
|
|
|
|
Both auto-discover `WASI_SDK` from common install paths.
|
|
|
|
---
|
|
|
|
## Compiling via the backend API
|
|
|
|
When the Custom Chip designer in the UI says "Compile", it `POST`s to:
|
|
|
|
```
|
|
POST /api/compile-chip/
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"source": "<full chip.c text>",
|
|
"chip_json": "<full chip.json text — currently ignored, future use>"
|
|
}
|
|
```
|
|
|
|
Response:
|
|
|
|
```json
|
|
{
|
|
"success": true,
|
|
"wasm_base64": "AGFzbQEAAAAB...",
|
|
"stdout": "",
|
|
"stderr": "",
|
|
"error": null,
|
|
"byte_size": 63287
|
|
}
|
|
```
|
|
|
|
On compile errors `success` is `false` and `stderr` contains the clang
|
|
output. The endpoint never returns 5xx — it surfaces compiler errors as
|
|
data so the UI can show them.
|
|
|
|
You can also hit the endpoint manually:
|
|
|
|
```bash
|
|
curl -X POST http://localhost:8001/api/compile-chip/ \
|
|
-H 'Content-Type: application/json' \
|
|
-d "$(jq -Rn --rawfile s mychip.c '{source: $s}')"
|
|
```
|
|
|
|
A status endpoint reports availability:
|
|
|
|
```bash
|
|
curl http://localhost:8001/api/compile-chip/status
|
|
```
|
|
|
|
---
|
|
|
|
## The sandbox test suite
|
|
|
|
Velxio ships a Node.js sandbox at
|
|
[`test/test_custom_chips/`](../../test/test_custom_chips/) that mirrors the
|
|
production runtime. It uses the **same** chip API, the **same** I2C bus
|
|
manager, and the **same** `avr8js` instance the browser uses — all the
|
|
chips work identically.
|
|
|
|
### Quick start
|
|
|
|
```bash
|
|
cd test/test_custom_chips
|
|
npm install
|
|
|
|
# Build all 11 example chip fixtures (.wasm) — needs WASI-SDK
|
|
bash scripts/compile-all.sh
|
|
|
|
# Run the test suite
|
|
npm test
|
|
```
|
|
|
|
Expected output:
|
|
|
|
```
|
|
Test Files 26 passed (26)
|
|
Tests 70 passed (70)
|
|
```
|
|
|
|
### What it tests
|
|
|
|
| Layer | Tests |
|
|
|---|---|
|
|
| `PinManager` mirror | Edge dispatch, PWM, analog, unsubscribe (5 tests) |
|
|
| `I2CBus` mirror | TWI event handler, address routing, NACK (3 tests) |
|
|
| `AVRHarness` | `avr8js` boots a real `blink.hex` (2 tests) |
|
|
| `ChipRuntime` host imports | Surface check, missing-import error reporting (4 tests) |
|
|
| Single chip behavior | Each of the 11 example chips (3-5 tests each) |
|
|
| Multi-chip | Chained logic, two EEPROMs, mixed I2C+UART (4 tests) |
|
|
| AVR + chip integration | `avr8js` blink hex driving a real chip (3 tests) |
|
|
| Full E2E with `Wire.h` | Compiled Arduino sketch + 24C01 EEPROM (1 test) |
|
|
| API extras | OUTPUT_LOW/HIGH, pin_watch_stop, DAC (4 tests) |
|
|
|
|
### Running just one suite
|
|
|
|
```bash
|
|
npm run test:js # JS-only (no WASM needed) — 14 tests
|
|
npm run test:e2e # Tests requiring compiled .wasm — 7 tests
|
|
```
|
|
|
|
---
|
|
|
|
## Multi-board validation suite
|
|
|
|
The pytest suite at
|
|
[`test/test_custom_chips_boards/`](../../test/test_custom_chips_boards/)
|
|
exercises the **backend** services that custom chips depend on, across all
|
|
supported board families.
|
|
|
|
### Pre-requisites
|
|
|
|
- Backend running. By default the suite expects `http://127.0.0.1:8765`.
|
|
Override with `VELXIO_BACKEND_URL`.
|
|
- For ESP32 tests: the lcgamboa `libqemu-xtensa.{dll,so}` must be at
|
|
`backend/app/services/`. The Docker image bundles it; for local dev
|
|
you can `docker cp` it from a running container.
|
|
|
|
### Running
|
|
|
|
```bash
|
|
cd /path/to/velxio
|
|
VELXIO_BACKEND_URL=http://127.0.0.1:8765 \
|
|
pytest test/test_custom_chips_boards/ -v
|
|
```
|
|
|
|
Expected: 24 tests pass.
|
|
|
|
| Test file | Validates |
|
|
|---|---|
|
|
| `test_compile_endpoint.py` | All 11 chips compile via `/api/compile-chip` (14 tests) |
|
|
| `test_multi_board_sketch_compile.py` | Blink sketch builds for AVR/RP2040/ESP32 (5 tests) |
|
|
| `test_esp32_gpio_bridge.py` | ESP32 QEMU GPIO + serial round-trip via WS (2 tests) |
|
|
| `test_esp32_chip_i2c.py` | ESP32 sketch ↔ chip 24C01 EEPROM (Wire round-trip via backend WASM runtime) |
|
|
| `test_esp32_chip_uart.py` | ESP32 sketch ↔ chip ROT13 (Serial.write/read via backend WASM runtime) |
|
|
| `test_esp32_chip_spi.py` | ESP32 sketch ↔ chip 74HC595 (SPI byte + RCLK pin_watch + 8 GPIO outputs) |
|
|
|
|
The ESP32 tests skip cleanly if `libqemu-xtensa` is missing.
|
|
|
|
For pure-runtime tests (no QEMU, no WebSocket, just the Python WASM runtime in
|
|
isolation) see [`test/test_chip_backend_runtime/test_wasm_runtime.py`](../../test/test_chip_backend_runtime/test_wasm_runtime.py)
|
|
— 11 tests covering GPIO, I2C, UART, SPI, pin_watch and timers. Run with
|
|
`pytest test/test_chip_backend_runtime/`.
|
|
|
|
---
|
|
|
|
## Writing tests for your own chip
|
|
|
|
Inside the sandbox, drop a test file under
|
|
`test/test_custom_chips/test/chips/your_chip.test.js`:
|
|
|
|
```js
|
|
import { describe, it, expect } from 'vitest';
|
|
import { ChipInstance } from '../../src/ChipRuntime.js';
|
|
import { PinManager } from '../../src/PinManager.js';
|
|
import { loadChipWasm, chipWasmExists } from '../helpers.js';
|
|
|
|
const skip = !chipWasmExists('mychip');
|
|
|
|
describe('My chip', () => {
|
|
it.skipIf(skip)('does the thing', async () => {
|
|
const pm = new PinManager();
|
|
const chip = await ChipInstance.create({
|
|
wasm: loadChipWasm('mychip'),
|
|
pinManager: pm,
|
|
wires: new Map([['IN', 2], ['OUT', 3]]),
|
|
});
|
|
chip.start();
|
|
|
|
pm.triggerPinChange(2, true);
|
|
expect(pm.getPinState(3)).toBe(false);
|
|
|
|
chip.dispose();
|
|
});
|
|
});
|
|
```
|
|
|
|
For an I2C chip, instantiate an `I2CBus`:
|
|
|
|
```js
|
|
import { I2CBus } from '../../src/I2CBus.js';
|
|
import { makeFakeTwi, i2cWrite, i2cRead } from '../helpers.js';
|
|
|
|
const twi = makeFakeTwi();
|
|
const bus = new I2CBus(twi);
|
|
const chip = await ChipInstance.create({
|
|
wasm: loadChipWasm('mychip'),
|
|
pinManager: pm,
|
|
i2cBus: bus,
|
|
wires: new Map([['SDA', 18], ['SCL', 19]]),
|
|
});
|
|
chip.start();
|
|
|
|
i2cWrite(bus, twi, 0x50, [0x00, 0xAA]); // pointer=0, data=0xAA
|
|
i2cWrite(bus, twi, 0x50, [0x00]); // reset pointer
|
|
expect(i2cRead(bus, twi, 0x50, 1)).toEqual([0xAA]);
|
|
```
|
|
|
|
For a SPI chip, use `SPIBus`:
|
|
|
|
```js
|
|
import { SPIBus } from '../../src/SPIBus.js';
|
|
|
|
const spi = new SPIBus();
|
|
const chip = await ChipInstance.create({
|
|
wasm: loadChipWasm('mychip'),
|
|
pinManager: pm,
|
|
spiBus: spi,
|
|
wires: new Map([['SCK', 13], ['MOSI', 11], ['MISO', 12], ['CS', 10]]),
|
|
});
|
|
chip.start();
|
|
|
|
pm.triggerPinChange(10, false); // CS LOW
|
|
const responses = spi.transferBytes([0x01, 0xA0, 0x00]);
|
|
pm.triggerPinChange(10, true); // CS HIGH
|
|
expect(responses[2]).toBe(/* expected */);
|
|
```
|
|
|
|
For full E2E with a real Arduino sketch, see
|
|
[`test/test_custom_chips/test/e2e/07_chip_eeprom_avr_e2e.test.js`](../../test/test_custom_chips/test/e2e/07_chip_eeprom_avr_e2e.test.js)
|
|
— it loads a compiled `.hex` of `Wire.h` code into `avr8js` and runs the
|
|
chip alongside.
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Compile errors
|
|
|
|
**`undefined symbol: vx_pin_register`**
|
|
You forgot `-Wl,--allow-undefined`. The compile scripts already include
|
|
this — if you're invoking clang manually, add the flag.
|
|
|
|
**`#include "velxio-chip.h" — file not found`**
|
|
You missed `-I path/to/sdk/include`. The header lives at
|
|
`test/test_custom_chips/sdk/include/velxio-chip.h` (sandbox) or
|
|
`backend/sdk/velxio-chip.h` (backend).
|
|
|
|
**`error: "vx_i2c_config must be 64 bytes"` (static assert)**
|
|
Your local header is out of sync with the runtime. Re-pull the latest
|
|
`velxio-chip.h` from `backend/sdk/`.
|
|
|
|
**`argument '--target=wasm32-wasi' is deprecated`**
|
|
You're using the old target name. The compile scripts use
|
|
`wasm32-unknown-wasip1` (the modern equivalent). Update your invocation.
|
|
|
|
### Runtime errors
|
|
|
|
**Chip console: `Chip WASM imports missing in host: env.foo_bar`**
|
|
Your chip uses an extern that the runtime doesn't provide. Either:
|
|
- The function name is wrong (typo of a `vx_*` function).
|
|
- You added a custom `extern` thinking the host would provide it. The
|
|
host only provides the functions in `velxio-chip.h`.
|
|
|
|
**Chip seems to do nothing on canvas**
|
|
- Is the chip wired? Open the chip and check pins are connected to the
|
|
Arduino.
|
|
- Is the simulation running?
|
|
- Did `chip_setup` complete? Look at `[chip:<id>]` lines in the browser
|
|
console — `vx_log` and `printf` output land there.
|
|
|
|
**Chip works in the sandbox but not in the browser**
|
|
- Did you click "Compile" + "Save & Place" after editing the C? The
|
|
`wasm_base64` is embedded into the component's properties at save time.
|
|
- For AVR / RP2040, the chip runs in the browser. For ESP32, the chip runs in
|
|
the **backend Python process** — see
|
|
[`custom-chips-esp32-backend-runtime.md`](./custom-chips-esp32-backend-runtime.md)
|
|
for the architecture. If logs and pin updates don't appear, check the
|
|
worker stderr in the backend logs (search for `[custom-chip]`).
|
|
- Check the [board support matrix](../../test/autosearch/07_multi_board_support.md)
|
|
for protocol coverage per board.
|
|
|
|
### ESP32 specific
|
|
|
|
On ESP32 the chip's `.wasm` is loaded inside the same Python process that
|
|
hosts QEMU (`backend/app/services/wasm_chip_runtime.py` via wasmtime). All
|
|
peripheral callbacks fire **synchronously** in the QEMU thread:
|
|
|
|
| Chip API | ESP32 hook | Notes |
|
|
|---|---|---|
|
|
| `vx_pin_write` | `qemu_picsimlab_set_pin(gpio + 1, value)` | Drives GPIO input |
|
|
| `vx_pin_read` | Cached `_pin_state[gpio]` from `_on_pin_change` | Live |
|
|
| `vx_pin_watch` | Dispatched from `_on_pin_change` (edge filtered) | Sync, lock held |
|
|
| `vx_i2c_attach` | Registered as `_i2c_slaves[addr]` | `_on_i2c_event` |
|
|
| `vx_uart_attach` / `vx_uart_write` | `_on_uart_tx` ↔ `qemu_picsimlab_uart_receive` | UART0 only |
|
|
| `vx_spi_attach` | Dispatched from `_on_spi_event` (op = `data << 8`) | Re-arm pattern supported |
|
|
| `vx_timer_*` | Dedicated scheduler thread that takes the IO-thread lock | Wakes on soonest deadline |
|
|
|
|
`chip_log` and `chip_error` events flow back over the WebSocket as a
|
|
telemetry channel. Stubs (`vx_pin_dac_write`, framebuffer) emit `chip_warning`
|
|
instead of crashing.
|
|
|
|
**ESP32 QEMU exits immediately, "unsupported machine type: esp32"**
|
|
The upstream `qemu-system-xtensa` binary doesn't include the ESP32 machine
|
|
model. Velxio uses the **lcgamboa fork** as a shared library
|
|
(`libqemu-xtensa.dll/.so`). The Docker image bundles it; for native dev,
|
|
copy it from a running container:
|
|
|
|
```bash
|
|
docker cp velxio-dev:/app/app/services/libqemu-xtensa.dll \
|
|
backend/app/services/
|
|
```
|
|
|
|
Then restart uvicorn so it picks up `LIB_PATH`.
|
|
|
|
### File-size sanity
|
|
|
|
A typical chip is **40-80 KB** of WASM (most of that is wasi-libc's
|
|
`malloc`/`printf` family). If yours is dramatically larger, check whether
|
|
you're pulling in `<math.h>` or `<stdio.h>` features you don't need.
|
|
Replacing `printf` with `vx_log` shaves about 15 KB.
|
|
|
|
If yours is smaller than 1 KB, something probably went wrong — the host
|
|
expects a real WASM module with the magic bytes `\x00asm`. Run
|
|
`xxd mychip.wasm | head -2` to confirm the magic.
|