Metadata-Version: 2.4
Name: testomatic-io
Version: 0.1.1
Summary: Hardware abstraction layer for the I/O features of the Testomatic chassis and test modules
Author-email: Jonathan Oxer <jon@oxer.com.au>
Maintainer-email: Jonathan Oxer <jon@oxer.com.au>
License: MIT
Project-URL: Homepage, https://github.com/SuperHouse/testomatic-io
Project-URL: bugs, https://github.com/SuperHouse/testomatic-io/issues
Classifier: Topic :: Software Development :: Embedded Systems
Classifier: Topic :: System :: Hardware :: Hardware Drivers
Classifier: License :: OSI Approved :: MIT License
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: adafruit-blinka
Requires-Dist: rpi-lgpio
Requires-Dist: gpiod>=2.0.2
Requires-Dist: ad5593r>=0.3.0
Requires-Dist: mcp23008>=0.1.0
Requires-Dist: tca9548a>=0.1.0
Requires-Dist: adafruit-circuitpython-ina260
Requires-Dist: adafruit-circuitpython-24lc32
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Dynamic: license-file

# testomatic-io

A Python hardware abstraction layer for the I/O features of the 
[Testomatic](https://testomatic.io/) PCB test system chassis and its test 
modules.

## Overview

The Testomatic hardware is two distinct physical devices:

- **Chassis** — the fixed enclosure: IOMOD I/O expander modules (behind a
  TCA9548A I2C multiplexer), the 3.3V/5V/12V power rail relays and INA260
  sensors, the IOMOD interrupt line, the external "test start" button, the
  piezo beeper, and the chassis's own identity EEPROM.
- **Test Module** — a swappable shim that plugs into the chassis to
  interface with a specific Device Under Test. Today that's just its
  identity EEPROM; more Test-Module-specific hardware and drivers will be
  added over time, varying per Test Module type.

`testomatic_io.Chassis` and `testomatic_io.TestModule` are the two top-level
entry points, each grouping its subsystems into sub-namespaces:

```python
from testomatic_io import Chassis, TestModule

chassis = Chassis()
chassis.init()

test_module = TestModule()
test_module.init()

chassis.iomod.digital_write('C', 4, True)
chassis.power.rail_3v3(True)
chassis.power.read_3v3()          # PowerReading(voltage, current, power)
chassis.interrupts.is_asserted()
chassis.button.pressed()
chassis.beeper.beep(0.1)
chassis.hat_eeprom.read(0, 32)

test_module.eeprom.read(0, 32)
```

## Features

- **IOMODs**: multiple ADC/DAC/GPIO expander modules behind a TCA9548A
  multiplexer, with the expander chip on each module identified
  automatically by its I2C address — no manual configuration needed
- **Power rails**: turn the 3.3V/5V/12V rails to the Device Under Test on or
  off, and measure voltage/current/power on each via INA260 sensors
- **IOMOD interrupts**: read the shared interrupt line that all IOMODs OR
  onto
- **Button**: read the external "test start" button
- **Beeper**: drive the piezo beeper
- **EEPROMs**: read/write the chassis identity EEPROM and the Test Module
  identity EEPROM (both CAT24C32, on I2C bus 0)

Not yet implemented: thermal camera capture (planned, MLX90640 over serial),
and Test-Module-specific drivers beyond the identity EEPROM.

## Supported IOMOD Expander Chips

- **AD5593R** — 8-channel ADC/DAC/GPIO, I2C address `0x10`
- **MCP23008** — 8-channel digital GPIO only (no ADC/DAC), I2C address `0x20`

Support for additional chips (e.g. Serial Wombat) can be added by
writing a driver — see [testomatic_io/iomod/drivers/](testomatic_io/iomod/drivers/)
for the driver interface and the AD5593R driver as a reference implementation.

## Hardware Requirements

- Raspberry Pi (or other Blinka-supported board)
- TCA9548A I2C multiplexer, on I2C bus 1
- One or more IOMOD modules (connected via the multiplexer), each fitted
  with a supported expander chip
- 3× INA260 power monitors on I2C bus 1 (not behind the multiplexer):
  3.3V rail at `0x42`, 5V rail at `0x41`, 12V rail at `0x40`
- 2× CAT24C32 I2C EEPROMs on I2C bus 0: chassis identity at `0x50`,
  Test Module identity at `0x51`
- See [testomatic_io/pinout.py](testomatic_io/pinout.py) for the full GPIO
  pin map (power rail relays, button, beeper, IOMOD interrupt line)

### Enabling I2C bus 0

I2C bus 0 (the `ID_SD`/`ID_SC` pins, GPIO0/GPIO1) is disabled by default —
it's reserved for HAT ID EEPROM detection and isn't brought up by the normal
`raspi-config` "Enable I2C" option, which only enables bus 1. Without it,
`open_i2c_bus0()` (used by `Chassis.hat_eeprom` and `TestModule.eeprom`)
fails with a misleading error like:

```
ValueError: No Hardware I2C on (scl,sda)=(1, 0)
Valid I2C ports: ((1, 3, 2), (0, 1, 0), (10, 45, 44)).
Make sure I2C is enabled.
```

`(0, 1, 0)` in that list looks like a match for the requested `(1, 0)`, which
is what makes this misleading — the pins *are* the right ones for bus 0.
Blinka's `busio.I2C` matches the pin pair first, then tries to actually open
that bus; if the bus isn't enabled at the OS level, opening it raises
internally and Blinka silently falls through to try the next candidate
before eventually raising this "no hardware I2C" error, without indicating
that a pin match was in fact found.

To fix it, enable the `i2c_vc` device tree overlay and reboot:

```bash
echo "dtparam=i2c_vc=on" | sudo tee -a /boot/firmware/config.txt
sudo reboot
```

(On older Raspberry Pi OS releases the file is `/boot/config.txt` instead of
`/boot/firmware/config.txt`.) After rebooting, `ls /dev/i2c-*` should list
the new bus.

## Dependencies

```bash
pip install -r requirements.txt
```

or for local development:

```bash
pip install -e .
```

`import testomatic_io` requires actual Raspberry Pi (or other
Blinka-supported) hardware — `board`/Adafruit-Blinka does platform detection
at import time and raises `NotImplementedError` on unsupported platforms
(e.g. a Mac dev machine). When working on this repo without hardware, verify
logic by stubbing `board`, `tca9548a`, `gpiod`, and `busio` with
fakes before importing `testomatic_io`, rather than trying to run it
directly.

`rpi-lgpio` (declared above, alongside `gpiod`) is what lets `import board`
succeed at all on a Raspberry Pi: Blinka's own board/pin detection needs
`RPi.GPIO` importable even though this package only asks Blinka for I2C.
Before this was a declared dependency, installing on a fresh Raspberry Pi OS
(Bookworm or later) failed several layers deep in `adafruit_blinka` with
`ModuleNotFoundError: No module named 'RPi'` — a real GPIO backend was
missing, but nothing in the error said so. `rpi-lgpio` fixes this by
providing the same `RPi.GPIO` import name on top of the modern `gpiochip`
character-device API, so it works unmodified across old and new Raspberry Pi
OS releases. If you ever see that error anyway (e.g. an editable install from
before this was added), install it directly:

```bash
pip install rpi-lgpio
```

## Tests

```bash
pip install -e ".[dev]"
pytest
```

The test suite stubs the hardware packages above (see `tests/conftest.py`),
so it runs on any machine — no Raspberry Pi required. It covers driver
logic, GPIO active-high/low behaviour, and present/absent handling for the
EEPROMs and power sensors; it does not verify real electrical behaviour,
which still needs a manual pass against actual hardware.

## Quick Start

```python
from testomatic_io import Chassis

chassis = Chassis()
chassis.init()

# Scan for available IOMODs
modules = chassis.iomod.scan_modules()
print(f"Available modules: {modules}")

# Write HIGH to pin 4 on module D
chassis.iomod.digital_write('D', 4, True)

# Read from pin 1 on module C
value = chassis.iomod.digital_read('C', 1)

# Read analog voltage from pin 3 on module B
voltage = chassis.iomod.read_voltage('B', 3)

# Turn on the 3.3V rail and read it back
chassis.power.rail_3v3(True)
reading = chassis.power.read_3v3()
print(f"3.3V rail: {reading.voltage:.3f}V, {reading.current:.1f}mA, {reading.power:.0f}mW")
```

Modules can be referenced either by letter (`'A'`-`'H'`, preferred) or by their
underlying numeric channel (`0`-`7`) on the TCA9548A multiplexer.

## API Reference

### Chassis

```python
chassis.init(i2c_bus=None, i2c_bus0=None)
```
Initialize every chassis subsystem. `i2c_bus` is bus 1 (IOMODs + power
sensors), defaulting to `board.I2C()`. `i2c_bus0` is bus 0 (the identity
EEPROM bus) — pass the same object to `TestModule.init()` if using both
together, so they share one bus 0 connection.

#### `chassis.iomod` — IOMODs

```python
chassis.iomod.scan_modules()
```
Scan for available modules and return a list of working module letters
(`'A'`-`'H'`). Each module's expander chip is identified automatically by
its I2C address.

```python
chassis.iomod.select_module(module_id)
```
Validate that a module is present and ready for use.

```python
chassis.iomod.digital_write(module_id, pin, value)
chassis.iomod.digital_read(module_id, pin)
chassis.iomod.toggle(module_id, pin)
```
Digital I/O: write/read HIGH/LOW, or toggle an output pin.

```python
chassis.iomod.analog_read(module_id, pin, average=1)
chassis.iomod.analog_write(module_id, pin, value)
chassis.iomod.read_voltage(module_id, pin, average=1)
```
Analog I/O: raw ADC value (0-4095), raw DAC value (0-4095), or voltage in
volts (chip-dependent).

```python
chassis.iomod.pin_mode(module_id, pin, mode)
```
Configure pin mode: `testomatic_io.INPUT`, `OUTPUT`, `ADC`, or `DAC`.

```python
chassis.iomod.set_vref(module_id, activate=True)
chassis.iomod.get_vref(module_id)
chassis.iomod.get_dac_range(module_id)
chassis.iomod.set_dac_range(module_id, range=2)
chassis.iomod.set_ldac_mode(module_id, mode)
```
Voltage reference and DAC range management (chip-dependent).

```python
chassis.iomod.reset(module_id)
```
Reset the specified module.

#### `chassis.power` — power rails

```python
chassis.power.rail_3v3(on)
chassis.power.rail_5v(on)
chassis.power.rail_12v(on)
```
Turn a power rail relay on or off.

```python
chassis.power.rail_3v3_enabled()
chassis.power.rail_5v_enabled()
chassis.power.rail_12v_enabled()
```
Read back whether a rail relay is currently on.

```python
chassis.power.read_3v3()
chassis.power.read_5v()
chassis.power.read_12v()
```
Read a rail's INA260 sensor. Returns a `PowerReading(voltage, current, power)`
namedtuple — voltage in V, current in mA, power in mW. Raises `RuntimeError`
if that rail's sensor wasn't detected on the bus.

```python
chassis.power.read_3v3_available()
chassis.power.read_5v_available()
chassis.power.read_12v_available()
```
Check whether a rail's INA260 sensor was detected on the bus, before calling
`read_*`.

#### `chassis.interrupts`

```python
chassis.interrupts.is_asserted()
```
True if any IOMOD has a pending interrupt (the shared line is driven low).
Doesn't identify which module — poll `chassis.iomod`'s modules to find it.

#### `chassis.button`

```python
chassis.button.pressed()
```
True while the external button is held down.

#### `chassis.beeper`

```python
chassis.beeper.on()
chassis.beeper.off()
chassis.beeper.beep(duration_s=0.1)
```
Drive the piezo beeper.

#### `chassis.hat_eeprom`

```python
chassis.hat_eeprom.present            # False if no EEPROM responded on the bus
chassis.hat_eeprom.read(address, length)
chassis.hat_eeprom.write(address, data)
```
Read/write the chassis identity EEPROM (CAT24C32 at `0x50`, I2C bus 0). If
no chip responds at that address, `present` is `False` and `read`/`write`
raise `RuntimeError` instead of the underlying I2C probe crashing
`Chassis.init()`.

### TestModule

```python
test_module.init(i2c_bus0=None)
```
Initialize the Test Module. `i2c_bus0` is bus 0 — pass the same object as
`Chassis.init()`'s if using both together.

```python
test_module.eeprom.present            # False if no Test Module (or no EEPROM) is present
test_module.eeprom.read(address, length)
test_module.eeprom.write(address, data)
```
Read/write the Test Module identity EEPROM (CAT24C32 at `0x51`, I2C bus 0).

## Constants

- `testomatic_io.HIGH` / `LOW` — digital states (1 / 0)
- `testomatic_io.INPUT` / `OUTPUT` / `ADC` / `DAC` — IOMOD pin modes

## Adding a New IOMOD Expander Driver

To support a new I/O expander chip:

1. Create a driver module in `testomatic_io/iomod/drivers/` that subclasses
   `ExpanderDriver` (see `testomatic_io/iomod/drivers/base.py` for the
   interface and `testomatic_io/iomod/drivers/ad5593r.py` for a reference
   implementation).
2. Implement `probe(i2c_adapter, address)` as a classmethod that returns
   `True` when the driver recognises a chip at that address — matching on
   address alone is fine for chips with a fixed or narrow address range;
   chips that could share an address with another supported device should
   read an identifying register instead.
3. Implement the operations the chip actually supports (`pin_mode`,
   `digital_write`, `digital_read`, `toggle`, and where applicable
   `analog_read`, `analog_write`, `read_voltage`, `set_vref`, `get_vref`,
   `get_dac_range`, `set_dac_range`, `set_ldac_mode`, `reset`). Operations a
   chip doesn't support can be left unimplemented — the base class raises
   `NotImplementedError` for them.
4. Register the class in `DRIVERS` in `testomatic_io/iomod/drivers/__init__.py`.

Once registered, modules using that chip are detected and used automatically
— no changes to `IOModManager` itself are needed.

## Running Examples

Examples live in [`examples/`](examples/) in the repo and are included in
the source distribution (sdist), but are **not** installed into a venv's
`site-packages` — they're demo scripts to read and run from a checkout, not
importable library code. Since this package normally needs a real Testomatic
chassis and is installed with `pip install -e .` from a git clone (see
[Dependencies](#dependencies)), the `examples/` directory is already right
there alongside the code; there's nothing extra to install.

### Basic Example
```bash
python examples/testomatic_io_example.py
```

### Interactive Mode
```bash
python examples/testomatic_io_example.py interactive
```

The interactive mode provides a command-line interface for testing
individual functions.

## Error Handling

- **Invalid module**: Must be a letter A-H or number 0-7
- **Invalid pin number**: Must be 0-7
- **Invalid pin mode**: Must be INPUT, OUTPUT, ADC, or DAC
- **No supported expander found**: No registered driver recognised a chip on
  that module's I2C channel
- **Module not found**: Module not responding on I2C bus
- **Fixed I2C device not found**: EEPROMs (`Cat24C32`) and power sensors
  (`PowerSensors`, INA260) trap the construction-time probe failure instead
  of raising (see `testomatic_io/i2c_probe.py`) — check `.present` /
  `present_3v3` etc. (or `chassis.power.read_3v3_available()`) before
  reading, which otherwise raise `RuntimeError` if called while absent
- **I2C errors**: Communication failures
- **Unsupported operation**: A chip-dependent operation (e.g. analog I/O)
  not supported by the expander on that module

## Notes

- IOMOD modules are referenced by letter A-H (preferred) or by their
  underlying numeric channel 0-7 (TCA9548A has 8 channels)
- IOMOD pin numbers range from 0-7 (matching the AD5593R's 8 I/O pins; other
  supported chips may differ)
- ADC/DAC values are 12-bit (0-4095) on the AD5593R
- Voltage reference is typically 2.5V or 5V depending on configuration
- Not every expander chip supports every operation — GPIO-only chips will
  raise `NotImplementedError` for analog/voltage-reference calls
- `Chassis` and `TestModule` are independent top-level classes, not nested,
  since they represent two separate physical devices with independent
  lifecycles (a Test Module can be swapped without reinitializing the
  chassis)
