Skip to content

Hardware Dev Kit

magmacrunchmedia edited this page Aug 24, 2026 · 2 revisions

Hardware Dev Kit

Added in 0.4.0.

You should not need the hardware to build for the hardware. This page covers the four pieces that make that true: a bus-level simulator, a live test bench, background polling, and session record/replay.

All of it runs the real I2C stack — the protocol handshake, MagmaHub, ControllerState parsing, the input adapters. Nothing here is a stub that pretends to be a hub.

For the hardware interface itself — the memory map, button bits, I2CBus semantics — see Magma Hub and I2C.

The simulator

SimBus implements the smbus2 surface that I2CBus calls, and is injected through the backend= parameter added in 0.4.0:

from texastoast import simulated_hub
from texastoast.i2c.protocol import BTN_A, BTN_UP

hub, sim = simulated_hub()      # a real MagmaHub on a real I2CBus over a SimBus

sim.press(BTN_A | BTN_UP)
state = hub.poll()[0]           # real protocol, real parsing, no wires
state.a, state.up               # (True, True)
hub.connected                   # True

simulated_hub(num_controllers=1, address=0x08) is the one-line wiring. To build it by hand, or to simulate several hubs at once:

from texastoast import I2CBus, MagmaHub, SimBus

sim = SimBus({0x08: 2, 0x0A: 1})    # {address: controller count}
bus = I2CBus(backend=sim)           # is_mock is False — this is a real bus
hub = MagmaHub(0x08, bus, num_controllers=2)

Why the fake sits at the bus level

A fake MagmaHub would have been less code. It would also have been worthless as a test, because it bypasses exactly what needs testing: the write-[start_addr, [num_bytes]]-then-read handshake, the connected any-controller-answered logic, and the byte-to-ControllerState decode.

Faking one layer lower means every test exercises that code for real. The 0.3.0 connected bug — where a multi-controller hub let the last controller's result overwrite the others — could not have been caught by a hub-level fake, by construction.

The simulator is deliberately strict about the protocol for the same reason:

sim.read_i2c_block_data(0x08, 0x00, 2)   # OSError — no select-write first

The Pico firmware refuses that read, so SimBus refuses it too. A lenient sim that answered any read would keep passing even if MagmaHub stopped sending the select-write.

Simulating failure

Real buses misbehave, and the interesting code paths are the ones that handle it. Each of these is a one-liner:

sim.fail_next_reads(3)      # the next 3 reads raise OSError — a loose wire
sim.set_read_delay(0.05)    # 50 ms per read — a slow or contended bus
sim.disconnect_hub(0x08)    # reads raise until reconnect_hub() — a hotplug
sim.reconnect_hub(0x08)

Failures surface where you would expect them:

sim.fail_next_reads(1)
hub.poll()
hub.stats.error_count       # 1
hub.connected               # False

Driving the sim from the keyboard

KeyboardHubDriver binds the engine's standard key map and writes the resulting button byte into the simulator once per frame, so a game can run "controller" input on a laptop:

from texastoast import KeyboardHubDriver, MagmaHubInput, simulated_hub

hub, sim = simulated_hub()
driver = KeyboardHubDriver(game.root, sim)
pad = MagmaHubInput(hub)

def update(dt):
    driver.apply()               # keyboard → simulated controller bytes
    state = pad.poll()           # read back through the full stack
    player.move(state.dx, state.dy, dt, tilemap)

The test bench

texastoast-bench              # scan for hubs; simulator mode if none answer
texastoast-bench --sim        # force the simulator
texastoast-bench --bus 1 --addr 0x08 --controllers 2
texastoast-bench --record session.ttrec

A live diagnostic window: eight button indicators and the raw btn:0x.. joy:0x.. bytes per controller, a joystick crosshair, a per-hub connection dot, poll latency (min/avg/max and jitter, in milliseconds) and a read-error count with a per-second rate.

Open it while probing wiring or bringing up firmware. A flaky solder joint shows as a climbing error rate rather than a game that just feels wrong.

The bench always reads through a HubPoller, so the window never freezes on a stalled bus — a dead hub shows as a red dot and rising errors while the UI stays responsive.

With no hub found it drops into simulator mode and the keyboard drives controller 0, which makes it a working demonstration of the I2C stack on any machine.

The bench lives in the installed package (texastoast.devtools.bench), not in the repository's tools/ directory, because tools/ ships only in the sdist — a Pi that installed from a wheel would not have it. From a checkout you can also run python tools/controller_bench.py.

Background polling

I2C reads block the calling thread. On a healthy bus a poll is well under a millisecond, but a loose wire turns it into a visible frame hitch. HubPoller moves the traffic to a daemon thread:

from texastoast import HubPoller, MagmaHubInput

poller = HubPoller(hub).start()
game.on_close(poller.stop)          # you wire the teardown

pad = MagmaHubInput(poller)         # poll() now returns instantly

HubPoller duck-types the hub's read surface — poll, get_controller, connected, stats, address, num_controllers — so everything downstream takes it without knowing the difference. It is a hub whose poll() never touches the bus.

The handoff is a single assignment of an immutable tuple, which is atomic under the GIL, so neither side takes a lock.

Use one poller per hub, or poll the hub directly — never both. Two callers fight the hub's own poll_interval throttle and each sees a fraction of the updates.

Discovery can go off-thread too:

from texastoast import scan_buses_async

def on_found(hubs):
    # called from the scan thread — marshal back before touching tkinter
    root.after(0, lambda: attach(hubs))

scan_buses_async(on_found, bus_numbers=[1])

Polling health

stats = hub.stats           # or poller.stats

stats.poll_count
stats.error_count           # failed reads, cumulative
stats.last_poll_duration    # seconds
stats.min_duration, stats.max_duration, stats.avg_duration
stats.jitter                # max - min, over a rolling 120-poll window

HubStats is a frozen dataclass built fresh on each access, so a reader on another thread always gets one coherent snapshot.

Recording and replay

A .ttrec file is delta-encoded JSON Lines: a header, then one line per change in controller state.

{"format": "ttrec", "version": 1, "created": "...", "source": "MagmaHubInput"}
{"t": 1.234, "buttons": 8}
{"t": 1.401, "buttons": 24, "joystick": 128}

t is seconds since the recording started; an omitted key means unchanged. An idle session records nothing but the header, and because the format is append-only, a session that crashes still leaves a readable file.

Buttons are stored as the I2C protocol bitmask, not as engine field names. That is what lets one recording replay two different ways.

Recording

InputRecorder wraps any input source transparently — put it around a keyboard, a hub, or a whole CompositeInput:

from texastoast import InputRecorder

recorder = InputRecorder(controls, "session.ttrec")
recorder.start()
game.on_close(recorder.stop)

def update(dt):
    state = recorder.poll()      # delegates to the wrapped source, records changes

Omit the path to keep events in memory and save() them later.

Replay through the engine

ReplayInput is itself an input source, with two mutually exclusive clocks:

from texastoast import ReplayInput

replay = ReplayInput("session.ttrec")

replay.start()          # wall clock: poll() returns the state at now - t0
# ── or ──
replay.advance(dt)      # manual clock: deterministic, never reads the real time
replay.seek(2.5)

replay.poll()           # -> InputState
replay.finished, replay.duration

Manual mode is the one to use in tests: the same advance() calls always produce the same state sequence. Pass loop=True to wrap instead of holding the final state.

Replay through the hardware stack

The same file can be pushed back in as raw bytes, so the protocol, MagmaHub and MagmaHubInput all replay it:

hub, sim = simulated_hub()
driver = sim.play_recording("session.ttrec")
pad = MagmaHubInput(hub)

def update(dt):
    driver.advance(dt)      # recorded bytes → SimBus
    state = pad.poll()      # → I2CBus → MagmaHub → InputState

This is the firmware regression story: record a session against real hardware with texastoast-bench --record, and it becomes a test that runs on any machine with no hub attached.

Testing on the Pi

The simulator covers the logic; it cannot tell you the wiring is right. Before tagging a release that touches the hardware layer:

  1. Enable I2C (sudo raspi-config), wire the hub, and confirm i2cdetect -y 1 shows it at 0x080x0b.
  2. pip install "texastoast[hardware]", run texastoast-bench, and check that every button lights, the joystick crosshair tracks, latency is steady, and the error rate sits at 0/s.
  3. Record a session with --record and replay it — keep the file as part of the regression corpus.
  4. Run examples/magma_hub_demo.py; unplug the hub mid-game and confirm control falls back to the keyboard.

CI runs everything else on every push, with no I2C and no display.

Clone this wiki locally