Skip to content

Magma Hub and I2C

magmacrunchmedia edited this page Aug 29, 2026 · 3 revisions

Magma Hub and I2C

texastoast can read controller input from an I2C device instead of the keyboard. This is optional in every sense: the dependency is optional, the hardware is optional, and a game written against it runs unchanged on a laptop.

The Magma Hub hardware is still in development. This page documents the software interface — what the engine expects and how it behaves without a device attached.

Writing Pico firmware? See Magma Hub Firmware Spec for the exact I2C protocol, memory map, button bitmasks, and read handshake that the Pico must implement.

Developing without hardware? Use the simulator, not mock mode — see Hardware Dev Kit. It runs everything on this page for real, against simulated controllers.

Install

pip install "texastoast[hardware]"

That pulls in smbus2, which only works on Linux. Without it, everything below still imports and runs — in mock mode.

Mock mode

I2CBus falls back to mock mode when smbus2 is missing, the bus device does not exist, or permissions deny it. Mock mode is not a simulator: reads report failure rather than inventing data.

from texastoast import I2CBus

bus = I2CBus(1)
bus.is_mock          # True on a machine with no I2C
bus.read_byte_data(0x08, 0x00)        # None
bus.read_i2c_block_data(0x08, 0, 2)   # None

None rather than 0x00 is the important part. A zero byte is a valid controller report meaning "nothing pressed"; if a failed read returned zeros too, nothing downstream could tell whether hardware was present.

This was a real bug in 0.1.x: fabricated zeros made MagmaHub.connected report True with nothing attached, CompositeInput latched onto the phantom hub, and keyboard input stopped reaching the game. See Migrating to 0.2.0.

I2CBus

bus = I2CBus(bus_number=1)

bus.read_byte_data(address, register)              # int, or None
bus.read_i2c_block_data(address, register, length) # list[int], or None
bus.write_byte_data(address, register, value)
bus.write_i2c_block_data(address, register, data)
bus.probe(address)                                  # bool — one read
bus.scan()                                          # responding addresses
bus.close()

with I2CBus(1) as bus:
    ...

probe() (0.4.0) checks a single address. Prefer it over scan(), which walks every address from 0x03 to 0x77 — 117 blocking reads, fine for diagnostics and much too slow for a game's startup path.

I2CBus(backend=...) (0.4.0) accepts any object with the smbus2 surface, which is how the simulator is injected. A bus with an injected backend is a real bus: is_mock is False and reads flow through normally.

Reads never raise: an OSError from the kernel is logged at debug level and turned into None. Turn on logging to see them:

import logging
logging.getLogger("texastoast.i2c").setLevel(logging.DEBUG)

MagmaHub

One hub, addressing one or more controllers:

from texastoast import MagmaHub, I2CBus

bus = I2CBus(1)
hub = MagmaHub(address=0x08, bus=bus, num_controllers=2)

states = hub.poll()             # list[ControllerState]
hub.get_controller(0)
hub.connected
hub.stats                       # HubStats — 0.4.0

poll() rate-limits itself to poll_interval (default 0.016 s ≈ 60 Hz) and returns the cached states in between, so calling it every frame is fine. Since 0.4.0 it builds a fresh list each time and swaps it in with one assignment, rather than mutating the previous list in place — that is what makes it safe to read from another thread. Treat the returned list as read-only.

connected is True only while reads are actually succeeding. It starts False, becomes True after a successful poll, and returns to False as soon as reads start failing. With several controllers it is True if any of them answered.

stats reports poll and error counts plus rolling latency figures; see Hardware Dev Kit.

Reading the hub from a background thread, so a slow bus never costs a frame, is HubPoller.

Discovery

hubs = MagmaHub.scan_buses(bus_numbers=[1], num_controllers=1)

Probes each candidate address and returns a MagmaHub for every one that answers. Candidates default to DEFAULT_HUB_ADDRESSES (0x08, 0x09, 0x0A, 0x0B), so this is four reads per bus — before 0.4.0 it swept the whole address range instead. Buses with no hub are closed rather than left open. On a machine without I2C this returns an empty list.

MagmaHub.scan_buses(buses=[my_bus])          # scan buses you already built

To keep discovery off the main thread entirely, use scan_buses_async.

ControllerState

The raw two bytes per controller, decoded:

cs = hub.get_controller(0)

cs.buttons, cs.joystick    # raw bytes
cs.up, cs.down, cs.left, cs.right
cs.a, cs.b, cs.start, cs.select
cs.direction()             # -> (dx, dy)

Buttons are one active-high bit each, in texastoast.i2c.protocol:

Bit Button
0b00000001 up
0b00000010 down
0b00000100 left
0b00001000 right
0b00010000 a
0b00100000 b
0b01000000 start
0b10000000 select

Each controller occupies CONTROLLER_SIZE (2) bytes: buttons at BUTTONS_ADDR (0x00), joystick at JOYSTICK_ADDR (0x01). Controller n starts at n * CONTROLLER_SIZE.

Using it in a game

MagmaHubInput wraps a hub in the same interface as KeyboardInput, and CompositeInput picks whichever is live:

from texastoast import KeyboardInput, MagmaHub, MagmaHubInput, CompositeInput

keyboard = KeyboardInput(game.root)

hubs = MagmaHub.scan_buses(bus_numbers=[1])
hub_input = MagmaHubInput(hubs[0]) if hubs else None

controls = CompositeInput(keyboard, hub_input)

def update(dt):
    state = controls.poll()          # same InputState either way
    player.move(state.dx, state.dy, dt, tilemap)

Write the game against controls and it works on a desk with a keyboard and on a Pi with a controller, with no branching. See examples/magma_hub_demo.py.

Bringing up hardware

When you have a physical hub to test, texastoast-bench shows live button state, raw bytes, connection status, poll latency and error rates:

texastoast-bench

See Hardware Dev Kit for the bench, and Testing on the Pi for the bring-up checklist.

Clone this wiki locally