Skip to content

FUG-107: auto hardware discovery — qwiic sensor drivers in the effects VM - #96

Open
issuefleet[bot] wants to merge 15 commits into
mainfrom
agent/fug-107-auto-hardware-discovery
Open

issuefleet[bot] wants to merge 15 commits into
mainfrom
agent/fug-107-auto-hardware-discovery

Conversation

@issuefleet

@issuefleet issuefleet Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Plug any qwiic/I2C sensor into a splanc device and the model can enumerate the
bus, identify the part, write a driver for it, and install it — the driver runs
in the effects VM and exports sensor values as uniforms that effects
reference. Implements the FUG-107 vision end-to-end.

Design: a driver is just an effect with a different entry point

Rather than add a second interpreter, a sensor driver is ordinary .fxb
bytecode
run by the same fx_vm as effects — same sandbox, same per-invocation
instruction budget, same host/wasm/device parity. The differences:

  • a void poll() entry instead of shade() (a program is an effect XOR a
    driver);
  • export <type> name "unit"; declarations — the values poll() writes;
  • I2C intrinsics i2c_write / i2c_read8 / i2c_read16.

On the device each poll's exports are copied into the active effect's uniform
of the same name/width
— the exact set_uniforms slot path a slider uses — so
a driver and an effect authored independently line up automatically. In the VM
the intrinsics call an I2cBus trait: the firmware backs it with the ESP-IDF
qwiic master, the host/browser back it with a mock register map, so drivers are
authored and unit-tested with no hardware.

The pipeline (bottom-up, one commit per layer)

  1. Driver VM (firmware/fx_vm, fx_compiler) — I2cBus trait + I2cWrite/
    I2cRead opcodes threaded through run(); .fxb v2 header adds
    poll_entry (v1 still parses); Vm::run_poll / Vm::export; the compiler
    gains export decls + exports manifest, a poll() entry, and the
    i2c_* builtins. (Also fixed two latent bugs the driver AI would trip on:
    the disassembler truncated at min/max/mix, and string literals mangled
    non-ASCII units.)
  2. Protocol — ScanI2c → I2cScanResult{addresses}, SubmitDriver{fxb, poll_interval_ms, bindings, activate} → DriverState, RemoveDriver.
  3. Firmware runtime — qwiic bus brought up in main.cpp (scan probes
    0x08–0x77); a second fx_vm runs the driver's poll() off the render loop
    (lm_drv_poll), applying exports → effect uniforms each poll.
  4. Web + AI — a qwiic sensor database keyed by I2C address (identifyScan),
    export→uniform bind-by-name (computeBindings), scanI2c/submitDriver
    client methods, and two AI tools (scan_bus, set_driver) + a driver-dialect
    system prompt wired into the editor's chat when a device is connected. The
    model can scan the bus, identify the part, write a driver from its register
    map, compile it, bind it, and install it — iterating on the first poll result.

EFFECTS.md documents the whole driver stack.

Verification

  • VM/compiler: host unit tests compile+run drivers against a mock I2C bus
    (read→exports, config write, bus-error -1/0, effect/driver exclusivity,
    v2 back-compat).
  • Protocol: conformance_test + phone_client_frames_test green.
  • Firmware dispatch: ffi_test drives scan_i2c → submit_driver → poll → remove_driver end-to-end on the host.
  • Firmware image: bazel build -c opt //firmware/player_app:esp32c6 links
    clean.
  • Web: full app type-checks; sensorDb/driverBindings unit-tested; 67
    web tests green.

Hardware-pending

Real-sensor bring-up is the one step I couldn't run — there's no qwiic module on
the HITL bench. The qwiic pins in main.cpp (default SDA=6/SCL=7 @100 kHz) are
build-time overridable and will likely need tuning to the actual board's qwiic
connector. The export→uniform bridge matches by name+width; the AI is instructed
to export the type the target uniform expects (client-side type matching would
need the scalar type threaded into the uniform manifest — a small follow-up).

🤖 Generated with Claude Code


Update (HITL fix): the first HITL run failed on the BLE-provision → wss TLS
handshake lane. Root-caused to boot-time footprint — the firmware paid for I2C at
boot (driver-install heap + a 4 KB static buffer), eating into the mbedtls
handshake headroom the arena sizing is tuned around. Fixed: I2C init is now lazy
(first sensor use, not boot) and the driver buffer is 1 KB, so FUG-107 adds ~1 KB
.bss and zero boot-time heap on a sensor-less device. (Couldn't reproduce on a
rig from the fleet worktree — it's logged out of Tailscale — so this leans on the
root-cause analysis + the CI re-run.)

Update (merge): merged origin/main (FUG-121 native OSC input landed). The
overlap was in the same firmware files but disjoint code regions — resolved one
conflict in tests/ffi.rs (both features append host assertions; kept both). The
.fxb finish() now carries both my v2 poll_entry header and FUG-121's
embedded uniform manifest. Re-verified post-merge: all host rust tests, the
esp32c6 image, web type-check + tests, and both wasm bundles build/pass.

Update (test-lane fix): the post-merge test/test-macos failure was a link
error, not a test assertion — FUG-121's transport_bench (a host binary linking
player_ffi_host, which bazel test //... builds) hit undefined reference to lm_i2c_scan because ffi.rs now imports the qwiic hooks. Added the same no-op
stubs the host ffi_test uses. Verified bazel build is green across
//firmware/... //fx_compiler/... //tools/... //web/... //solver/... //shared/....

Update (v2 .fxb web parsers): fixed two browser-side .fxb parsers that
hardcoded the v1 18-byte header and so broke on this PR's v2 .fxb — parseFxb
(perf estimator) rejected version != 1, and parseFxbTextures read the buffer
table at the wrong offset. Both now handle v2 (20-byte header); a new
fxbV2.test.ts pins them. These are runtime-only paths (node tests don't emit
real .fxb), which is why they surfaced late. All .fxb consumers (Rust VM +
disassembler, both web parsers) now agree on the v2 layout.

Update (merge 2 — FUG-122): rebased onto main again after FUG-122 (FX-VM
native datatypes + framing) landed — a large overlapping change to the VM
opcodes, run(), the .fxb writer, and the compiler. Resolved: appended
FUG-107's I2cWrite/I2cRead opcodes AFTER FUG-122's new fixed/int ops so their
discriminants are unchanged (I2C mirror consts renumbered 102/103); threaded the
I2cBus param through FUG-122's rewritten run() (resident stack/locals/
call_stack + 4-tuple return) including run_poll; kept both features' compile
tests and extended FUG-122's opcode-classifier test to cover the I2C ops. The
.fxb finish() now composes v2 poll_entry (FUG-107) + embedded manifest
(FUG-121) + the new datatype flags (FUG-122). Re-verified on the merged tree:
all host rust tests, the esp32c6 image, web type-check + 65 unit tests, and
both freshness gates (fx_vm_perf_doc_freshness, codegen_freshness) pass.

Update (merge 3 — FUG-125): rebased onto main again after FUG-125 (FX
engine optimizer + VM superinstructions + on-device RV32 JIT) landed — another
large overlapping change. Resolved: FUG-107's I2cWrite/I2cRead re-appended at
discriminants 106/107 (after FUG-125's 102–105) across all coupled sites — the
Op enum, from_u8, the run() dispatch, the fx_vm_op mirror, the
disassembler, opt::op_len, and fx_jit::op_len (exhaustive, so it had to be
sized); merged FUG-125's JIT-table run() params with the I2cBus param
(run_poll passes both). The bytecode optimizer now repoints poll_entry
too (threaded through optimize/decode/encode/Prog + the reachability
root), so a driver's poll() is optimized + relocated exactly like an effect —
verified by the compile→optimize→run driver tests. fx_jit correctly declines
to JIT the I2C ops (I/O, not in the straight-line integer subset). Re-verified on
the merged tree: all host rust tests + transport_bench, the esp32c6 image,
web type-check + 68 unit tests, and both freshness gates pass.

Closes-Linear: FUG-107 (https://linear.app/fughilli/issue/FUG-107/auto-hardware-discovery)

Claude Agent and others added 6 commits August 15, 2026 07:53
A sensor driver is ordinary .fxb bytecode with a poll() entry point that
talks to a qwiic module over a new I2cBus trait and writes readings into
`export` state slots. Same VM, sandboxing and perf model as effects.

fx_vm:
- I2cBus trait (register-oriented; firmware backs it with ESP-IDF I2C,
  host/browser with a mock register map).
- I2cWrite / I2cRead opcodes, threaded through run() as Option<&mut dyn
  I2cBus> (None for shade/update — inert there).
- .fxb v2 header adds poll_entry (v1 still parses, poll = NO_ENTRY).
- Vm::run_poll() runs poll() with a bus; Vm::export() reads results back.

fx_compiler:
- `export <type> name "unit";` decls (state-backed) + exports manifest.
- `poll()` recognized as a void entry; a program is an effect (shade) XOR
  a driver (poll), never both; export requires a driver.
- i2c_write / i2c_read8 / i2c_read16 builtins.
- v2 .fxb emission + disassembler poll label; filled in the missing
  ABS_I..MIX_FIX disasm arms (were truncating disasm) + I2C ops.
- Lexer: decode string literals as UTF-8 (units like "°C" no longer mangled).

Host-tested end-to-end against a mock I2C bus: read into exports, write
config reg, bus-error (-1/0), effect/driver exclusivity, v2 back-compat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire contract for auto hardware discovery:
- ScanI2c -> I2cScanResult{addresses}: enumerate the qwiic bus.
- SubmitDriver{fxb, poll_interval_ms, bindings[], activate} -> DriverState:
  upload a compiled driver; DriverBinding maps a driver export slot to an
  active-effect uniform slot.
- RemoveDriver -> DriverState.

New ClientMessage arms 32-34, ServerMessage arms 19-20. micropb caps:
I2cScanResult.addresses=128 (0x08..0x77 space), SubmitDriver.bindings
hand-walked on firmware (cap 1) like SubmitEffect. Player core replies
"unsupported" (the fx/ffi layer intercepts these); exhaustive-match arms
added in player and the phone_client_frames conformance test. TS bindings
regenerated. conformance_test + phone_client_frames_test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ffi.rs:
- ARM 32/33/34 dispatch: handle_scan_i2c, handle_submit_driver (hand-walks
  fxb/interval/bindings/activate like submit_effect), handle_remove_driver.
- A second fx_vm instance (DRV_VM) runs the driver's poll() off the render
  loop via lm_drv_poll(now_ms) — self-paced to poll_interval_ms — backed by
  an I2cBus that calls the C++ qwiic hooks. Each poll copies exports into the
  active effect's uniform slots per the app-supplied bindings (the same path
  set_uniforms uses). lm_drv_running() gates the call.

main.cpp: bring up the qwiic I2C bus (driver/i2c.h, legacy master API) on
overridable pins (default SDA=6/SCL=7 @100khz); implement lm_i2c_write /
lm_i2c_read (register-pointer + repeated-start) / lm_i2c_scan (probe
0x08..0x77); i2c_init() in setup(); lm_drv_poll() in loop() under
player_mutex.

player_ffi.h: driver + I2C hook prototypes.

Build-verified: `bazel build -c opt //firmware/player_app:esp32c6` links
clean. Real-sensor bring-up is hardware-pending (no qwiic module on the
bench).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The authoring side of auto hardware discovery:
- hardware/sensorDb.ts: a curated qwiic module DB keyed by I2C address, with
  identifyScan() to turn a bus scan into per-address candidate lists (unit
  tested). Address collisions surface all matches for the user to disambiguate.
- hardware/driverBindings.ts: computeBindings() matches a driver's exports to
  the effect's uniforms by name+width → the submit_driver bindings (tested).
- net/client.ts: scanI2c / submitDriver / removeDriver request methods.
- net/proto.ts: the three new client arms + two server arms + flat shapes.
- fx compiler wasm now returns the export manifest (fx/preview FxExport,
  compile-worker plumbing).
- effects/ai: DRIVER_TOOLS (scan_bus, set_driver) + driver-system-prompt.ts
  teaching the model the driver dialect (poll/export/i2c_*, bind-by-name), wired
  into the editor's AI chat when a device is connected — the model can scan the
  bus, identify the part, write a driver, compile it, bind it, and install it.

Host ffi_test extended to drive scan_i2c → submit_driver → poll → remove_driver
end-to-end; SubmitDriver.fxb host micropb cap sized like UploadChunk.payload
(firmware hand-walks it). Full web app type-checks; 67 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New "Sensor drivers (auto hardware discovery)" section: the poll()/export/
i2c_* driver dialect, the export→uniform bind-by-name bridge, the I2cBus seam
(IDF on device, mock on host/browser), the .fxb v2 header, the scan/submit
wire path, and the editor AI's scan_bus/set_driver tools.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://fughilli.github.io/splanc/pr-preview/pr-96/

Built to branch gh-pages at 2026-08-19 23:36 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

…oom)

The HITL lane (BLE-provision → wss TLS handshake) is heap/.bss-sensitive on the
C6 — the arena was trimmed specifically because that headroom gates the mbedtls
record-buffer alloc. The initial firmware paid for I2C at boot (driver-install
heap + a 4 KB static driver buffer), eating into that headroom.

- I2C bus init is now LAZY (i2c_ensure, on first scan/driver use) instead of in
  setup(), so a device with no sensor in use pays no boot-time heap/.bss for it.
- DRV_BYTES 4 KB → 1 KB (a driver .fxb is a few hundred bytes; submit_driver
  rejects larger). Net boot-time .bss added by FUG-107 is now ~1 KB.

Behavior is unchanged when no driver is loaded (lm_drv_poll is gated on
lm_drv_running). Firmware rebuilds clean; host ffi_test still exercises
scan→submit→poll→remove. Could not reproduce on a rig from this worktree (the
container is logged out of Tailscale); relying on the CI HITL re-run to confirm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rdware-discovery

# Conflicts:
#	firmware/player_app/tests/ffi.rs
FUG-121's transport_bench (host rust_binary linking player_ffi_host) landed in
the merge; it hits `undefined reference to lm_i2c_scan` because ffi.rs now
imports the qwiic hooks (defined by main.cpp on-device / stubbed in tests/ffi.rs).
`bazel test //...` builds every target, so this broke the test / test-macos
lanes even though no test asserts it. Add the same no-op stubs the host ffi_test
uses. Verified: `bazel build //firmware/... //fx_compiler/... //tools/... //web/...
//solver/... //shared/...` all green (the only player_ffi_host consumers are
ffi_test and transport_bench, both now stubbed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two browser-side .fxb parsers hardcoded the v1 18-byte header, so they broke on
the v2 .fxb this PR emits (20-byte header + FUG-121's now-embedded manifest):
- costModel.ts parseFxb() REJECTED version != 1 and sliced the code segment at
  offset 18 → the perf estimator (estimate_performance) threw on every v2 effect.
- fxbTextures.ts read the buffer/texture table at offset 18 → wrong texture
  dimensions, so set_texture would drop frames for effects with textures.
Both now read the version and use a 20-byte header for v2 (18 for v1). Runtime-
only paths (node tests don't emit real .fxb), so a new fxbV2.test.ts pins both
parsers against v1 and v2 (with a non-empty manifest, catching a header slip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude Agent and others added 3 commits August 16, 2026 03:37
…rdware-discovery

# Conflicts:
#	firmware/fx_vm/src/lib.rs
#	fx_compiler/src/lib.rs
#	fx_compiler/tests/compile.rs
…-FUG-122 merge)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-107-auto-hardware-discovery

Resolved conflicts in fx_vm/src/lib.rs, fx_compiler/src/lib.rs, and
fx_compiler/tests/compile.rs: appended FUG-107's I2C opcodes (I2cWrite/I2cRead)
AFTER FUG-122's new fixed/int ops so their discriminants are unchanged (I2C
mirror consts renumbered 102/103), threaded the I2cBus param through FUG-122's
new run() signature (resident stack/locals/call_stack, 4-tuple return) incl.
run_poll, and kept both features' compile tests. All host rust tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude Agent and others added 2 commits August 19, 2026 23:20
…into agent/fug-107-auto-hardware-discovery

Resolved conflicts in fx_vm/src/lib.rs, fx_vm/tests/vm.rs, ffi.rs, and
fx_compiler/src/lib.rs (rerere replayed most): appended FUG-107's I2cWrite/
I2cRead opcodes AFTER FUG-125's superinstructions + JitCall (discriminants
106/107; mirror consts + op_len/op_to_ir in fx_jit), threaded the I2cBus param
through FUG-122/125's run() incl. the JIT table, and merged the imports/
classifier arms.

FIX: FUG-125's bytecode optimizer (opt.rs) remapped only update/shade entries
and DCE'd from those roots — so it silently dropped every sensor driver's poll()
body and left poll_entry stale. Threaded poll through decode/encode/optimize +
the CFG-reachability roots so poll() survives + is repointed. Driver compile
tests (which run poll() against a mock I2C bus through the optimized .fxb) pass.

All host rust tests green (fx_vm/fx_compiler/fx_jit/ffi/osc/player/conformance).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…into agent/fug-107-auto-hardware-discovery

Resolved conflicts in fx_vm, fx_compiler, ffi.rs, and the op-classifier test:
- Opcodes: FUG-125 took discriminants 102-105 (TeeLocal/IncLocalI/BrCmpI/
  JitCall); FUG-107's I2cWrite/I2cRead re-appended at 106/107 (enum + fx_vm_op
  mirror + disassembler + opt::op_len + fx_jit::op_len, which is exhaustive).
- run(): merged FUG-125's JIT-table params (jit_blocks/len/consts) with the
  existing I2cBus param; run_poll passes both.
- Optimizer now repoints poll_entry too (opt::optimize/decode/encode/Prog +
  remove_unreachable root), so a driver's poll() is optimized + relocated
  correctly — verified by the compile+run driver tests.
- fx_jit op_to_ir/branch_target bail on I2C ops via their _ => None (I/O isn't
  JIT-able); op_len (exhaustive) sizes them.

All host rust tests green (fx_vm, fx_compiler incl. driver + optimizer, fx_jit,
ffi driver+OSC, osc, player, conformance) + transport_bench.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

This branch had an error being deployed

1 failed deployment
HITL — 150a4630 Deployed Aug 19, 2026 by issuefleet[bot] via hitl_tests #257
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants