Skip to content

One AVR emulator on every platform, I2C reads, and eight sensor parts - #5

Merged
burhankhanzada merged 11 commits into
mainfrom
feat/web-sim-worker
Sep 30, 2026
Merged

burhankhanzada merged 11 commits into
mainfrom
feat/web-sim-worker

Conversation

@burhankhanzada

Copy link
Copy Markdown
Member

What and why

Everything on feat/web-sim-worker since main, in four groups.

Depends on PinBench/avr8_dart#1 (the app pins its 183c19a) and PinBench/pdl#2 (the pinbench_pdl submodule points at 59951b8). Merge those first, with merge commits.

1. I²C devices can answer reads (43ad684)

Every read on the bus used to return 0xFF, so no I²C sensor could be modelled. A part now serves a register bank and keeps it current with setRegisters. The bus answers the sketch mid-frame: pointer first, auto-incrementing, and writes are stored so they read back. An address nothing serves is still NACKed.

2. The same emulator on desktop and web (6b915c4, 90009d3, b956a37, 8cbfeea, 9748796)

  • Desktop ran about 30% fast. AVRBridge.tick(cycles) counted instructions, not cycles, so delay(1000) lasted about 0.77 s. avr_tick_test pins the fix.
  • The web ran avr8js from a CDN, so every emulator feature was written twice. With avr8_dart 0.2.0, dart2wasm runs as fast as dart2js (4.70× vs 4.56× real time over five templates, identical final state). The Wasm app now calls AVRBridge directly, like desktop. web/avr_bridge.js, its build and test scripts, the CI step and window.AVR8 are gone.
  • avr8_dart and ngspice_dart are fetched from their new home in the PinBench org.

3. Engine fixes the sensors needed (2851d5c, 6286fad)

  • Sensors are powered before setup(). Parts used to run after the CPU in every frame. So on the first frame setup() probed the bus before any device had served its registers, and Adafruit_MPU6050.begin() and friends reported "chip not found" for good. Parts now get one pass before the first instruction. That pass treats the analog model as unsolved, since nothing has been solved yet.
  • .pdl STATE starts fresh every run, as PDL.md promises. It used to resume from the last run's final values left on the canvas; a PIR, for example, was still holding. Those values are also no longer written into saved .cdl files.
  • Floats print again. avr8_dart set the overflow flag on every CP, so Serial.print(1.5) printed inf in every sketch. Fixed there (0.2.0: decode opcodes once, move to the PinBench org, fix CP's overflow flag avr8_dart#1); this pins the fix.

4. Eight new parts (a196059, a75cf6b)

Part How
BH1750 light sensor I²C LOGIC. ADDR selects 0x23/0x5C
MPU-6050 accelerometer + gyro I²C LOGIC. Boots asleep, clears its own reset bit, scales to the sketch's range. AD0 selects 0x68/0x69
DS1307 real-time clock I²C LOGIC. Keeps simulated time; starts at the host's time or unset. Needs 5 V
AHT20 temperature + humidity I²C LOGIC. Stands in for the DHT, which needs a part that can drive a pin
TMP36, LM35 voltage source, read with analogRead
Capacitive soil moisture voltage source, inverted and offset like the real module
9V battery voltage source

An I²C sensor answers only when its VCC and GND reach the board and SDA/SCL reach A4/A5. So a missing supply or swapped SDA/SCL fails the way it does on a desk.

How it was verified

  • dart analyze --fatal-infos: clean
  • dart format: no changes in the files this PR touches
  • flutter test: 453 app tests pass (7 skipped); pinbench_parts 137, pinbench_sim 80
  • Tried it in the running app. Not done for the new parts: they were checked headless and their artwork rendered to PNG, but not placed and run in the app.

New tests:

  • i2c_sensors_test.dart: each sensor's registers, frame by frame, against a register bus.
  • sensor_sketches_test.dart: a sketch built on Adafruit MPU6050, BH1750, RTClib and Adafruit AHTX0, plus two analogReads (test/fixtures/sensors/), runs on the emulator against the placed parts wired to the Uno. Every library initialises and every reading comes back exact.
  • Regressions for the power-on pass, fresh STATE and the .cdl writer. Each fails without its fix.

If you changed a painter

No painters changed. The new parts are .pdl + SVG. pdl_bundled_parts_test checks that every pin is on the lattice and that every part loads with zero diagnostics.

Notes for the reviewer

These engine gaps, found while doing this and recorded in the handbook's architecture README, kept some parts out of this PR:

  • The Uno's 5V/3.3V pins aren't SPICE sources. So resistive sensors fed from 5V (FSR, tilt, reed) wouldn't respond; runtime alter on resistors is also rejected, as already known.
  • Digital inputs only see a closed path to GND and ignore driven voltages. Touch and IR modules, and the existing PIR, read HIGH regardless.
  • A voltage-source part with its GND unwired leaves a floating subcircuit, and nothing guards the solver against that.

The roadmap's next parts (HC-SR04, DHT, RGB LED, relay, transistor) need multi-element parts or parts that drive a pin first.

🤖 Generated with Claude Code

burhankhanzada and others added 11 commits September 30, 2026 02:13
Every read on the bus used to return 0xFF, so no I2C sensor could be
modelled: a part could only receive what the sketch wrote. A read has
to be answered mid-frame, while the CPU clocks it, and part logic only
runs between frames. So a part now publishes registers and the bus
answers from them, which is how I2C sensors are built:

- context.i2c.serve(address, size:, pointerBytes:) declares a bank and
  claims the address. The first pointerBytes of a write set the
  register pointer (MSB first); reads return the register under it and
  advance, wrapping. pointerBytes: 0 is for devices with nothing to
  select, like a PCF8574, whose reads start at 0.
- context.i2c.setRegisters(address, offset, bytes) publishes readings.
- Bytes the sketch writes after the pointer are stored, so a register
  reads back what the sketch set, and still arrive through drain().
- Serving the same shape again keeps registers and pointer, so a part
  can declare itself every frame.

The register logic lives in I2cRecorder (native) and is mirrored in
web/avr_bridge.js, so a read never crosses into Dart on either
platform. An address nothing serves still reads 0xFF and is NACKed.

Tested at three levels: I2cRecorder unit tests for each rule; the same
scenarios run against the JS copy in Node (not checked in); and a real
Wire sketch (test/fixtures/i2c_read), compiled with arduino-cli, that
reads one register, six in a row after a repeated start, reads back a
write, and is refused at an absent address. PDL.md's context.i2c
section documents the API.

Also drops the empty `()` the Dart 3.13 migration left on PartI2cApi
and I2cRecorder, the last two.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AVRBridge.tick(cycles) looped `cycles` times over avrInstruction, so it
ran that many instructions. Most AVR instructions take one cycle but
branches, calls and memory access take two to four, so a frame asked
for 256,000 cycles ran about 345,000 and sketches ran roughly 30% fast
on desktop: delay(1000) lasted about 0.77 s. The web bridge already
looped until cpu.cycles reached the target; native now does the same.

avr_tick_test pins it: each frame advances the requested cycles, give
or take the last instruction. It fails on the old loop (frame 0 ran
345,030 cycles).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The web used avr8js from the unpkg CDN, so the desktop and the web ran two
different emulators and every feature was written twice: the I2C reads in
web/avr_bridge.js had to mirror I2cRecorder by hand. Now web/avr_bridge.js
is the native AVRBridge itself, compiled by dart2js and exported as
window.AVR8 (web_bridge/avr_bridge_main.dart), so both platforms run the
same code and the CDN dependency is gone.

dart2js rather than calling the bridge from the app: the app ships as
WebAssembly, and dart2wasm runs this loop at about half the speed (2.1x real
time against 4.4x on the OLED firmware).

To match avr8js's speed, tick() reads its statics into locals (a lazily
initialised static costs a check on every access in dart2js) and samples
PWM duty from the port registers instead of 14 pinState calls; the masks
agree with pinState on every template (4.7M samples, 0 differences).
Measured warm in Chrome 154: 2.71x real time against avr8js's 2.56x.

The bridge no-ops before a sketch is loaded, as the JS one did, and no
longer logs from tick (sim_log pulls in Flutter-side code).

tools/build_avr_bridge.sh builds it against the app's committed lockfile;
CI checks the committed file is current and runs tools/test_avr_bridge.mjs
against it (I2C reads, cycle-accurate ticks, blink timing, real-time speed).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The web called a separate JavaScript build of the emulator (a dart2js
build of AVRBridge, before that avr8js) because dart2wasm ran avr8_dart
0.1.0 at half dart2js's speed. That was the package, not the compiler:
avr8_dart 0.2.0 removes the late fields and the per-instruction decode
chain, and dart2wasm now runs it as fast as dart2js (4.70x against 4.56x
real time over five templates, identical final state), steady from the
first frame.

So the web app, which already ships as WebAssembly, calls AVRBridge
directly, like desktop. Gone: web/avr_bridge.js, its build and test
scripts, the CI step, the window.AVR8 facade and the conditional export;
avr_interop_io.dart becomes avr_interop.dart, the one implementation.
The bridge now drops the CR of CRLF itself, which only the web facade
did, so both platforms print the same lines.

avr8_dart 0.2.0 is not on pub.dev yet, so it is pinned to its commit on
GitHub (00d14d2, branch perf/hot-path) in the app and in pinbench_sim.

Checked: analysis, 453 app tests and every package suite, a Wasm web
build, and Blink running in Chrome with no avr_bridge.js or window.AVR8.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The repository moved from burhankhanzada/avr8_dart to PinBench/avr8_dart.
Pinned to db48705, the perf/hot-path head: 0.2.0 plus its metadata
pointing at the new home. Still not on pub.dev.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The repository moved from burhankhanzada/ngspice_dart to
PinBench/ngspice_dart; same commit (1061e5f, its main).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three things a sensor part needs from the engine:

- Parts ran after the CPU in every frame, so on the first frame setup()
  probed the bus before any I2C device had served its registers. The
  address was NACKed and Adafruit_MPU6050.begin() and friends report
  "chip not found" for good. Parts now get one pass before the first
  instruction, with the analog model treated as unsolved, since nothing
  has been solved yet.
- The evaluator seeded a .pdl part's STATE from the node's properties,
  which still hold where the last run ended. A new run resumed from there
  (a PIR still holding) although PDL.md says STATE is recreated every run.
  Declared STATE keys are now left out of what seeds it.
- The same STATE was written into the saved .cdl, dirtying the file after
  every run. The writer now skips declared STATE like the runtime flags.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every CP set V, so signed branches after a compare went the wrong way and
Serial.print() of any float printed "inf".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- BH1750 light, MPU-6050 accelerometer and gyro, DS1307 real-time clock
  and AHT20 temperature and humidity: LOGIC parts that serve the
  registers a sketch reads (logic/i2c_sensors.dart). Each answers only
  when powered and wired to A4/A5; address jumpers (ADDR, AD0) are read
  from the wiring. The MPU-6050 boots asleep and clears its own reset
  bit; the DS1307 keeps simulated time and can start unset; the AHT20
  stands in for the DHT, which needs a part that can drive a pin.
- TMP36, LM35 and a capacitive soil probe: voltage sources read by
  analogRead. A 9 V battery.

Tested per part against a register bus, and end to end: a sketch built on
Adafruit MPU6050, BH1750, RTClib and Adafruit AHTX0, plus two analogReads,
runs on the emulator against the placed parts (test/fixtures/sensors).

Also points the pinbench_pdl submodule at the doc listing them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Both PRs were squash-merged (merge commits are disabled on those repos),
so the branch commits pinned here would be orphaned once their branches
were deleted. Same content: avr8_dart 183c19a/ac16ee0 -> 70f4415 (#1),
pdl 59951b8 -> 714d715 (#2).

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

Copy link
Copy Markdown
Member Author

PinBench/avr8_dart#1 and PinBench/pdl#2 are merged. Both repos only allow squash merges, so c8eb0af re-pins to the merged main commits (avr8_dart 70f4415, pdl 714d715). The content is identical to the branch commits; pinbench_sim tests pass on it, 80/80 including the sensor sketch. This PR no longer depends on anything unmerged.

@burhankhanzada
burhankhanzada merged commit 9a3ace7 into main Sep 30, 2026
5 of 7 checks passed
@burhankhanzada
burhankhanzada deleted the feat/web-sim-worker branch September 30, 2026 13:08
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 30, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant