-
Notifications
You must be signed in to change notification settings - Fork 0
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,
I2CBussemantics — see Magma Hub and I2C.
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 # Truesimulated_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)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 firstThe 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.
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 # FalseKeyboardHubDriver 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)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.ttrecA 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'stools/directory, becausetools/ships only in the sdist — a Pi that installed from a wheel would not have it. From a checkout you can also runpython tools/controller_bench.py.
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 instantlyHubPoller 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_intervalthrottle 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])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 windowHubStats is a frozen dataclass built fresh on each access, so a reader on
another thread always gets one coherent snapshot.
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.
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 changesOmit the path to keep events in memory and save() them later.
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.durationManual 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.
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 → InputStateThis 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.
The simulator covers the logic; it cannot tell you the wiring is right. Before tagging a release that touches the hardware layer:
- Enable I2C (
sudo raspi-config), wire the hub, and confirmi2cdetect -y 1shows it at0x08–0x0b. -
pip install "texastoast[hardware]", runtexastoast-bench, and check that every button lights, the joystick crosshair tracks, latency is steady, and the error rate sits at 0/s. - Record a session with
--recordand replay it — keep the file as part of the regression corpus. - 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.
texastoast · PyPI · Apache-2.0