Metadata-Version: 2.4
Name: embeddedci
Version: 0.1.0
Summary: BenchPod pytest client for EmbeddedCI hardware-in-the-loop testing
Project-URL: Homepage, https://embeddedci.com
Project-URL: Repository, https://github.com/embeddedci-com/embeddedci-python
Project-URL: Issues, https://github.com/embeddedci-com/embeddedci-python/issues
Author: EmbeddedCI
License: Apache-2.0
License-File: LICENSE
Keywords: benchpod,embedded,hardware-in-the-loop,openocd,pytest,swd
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Pytest
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Testing
Requires-Python: >=3.9
Requires-Dist: pyserial>=3.5
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Description-Content-Type: text/markdown

# embeddedci (BenchPod pytest library)

A pytest-friendly Python client for an [EmbeddedCI](https://embeddedci.com)
**BenchPod** — the same device operations you do today with `benchpod-cli`,
available straight from your tests:

* connect to a BenchPod over **wifi/network or serial**
* power the target on/off
* **flash** firmware to the target and **assert ok / not ok**
* emulate an I2C sensor (BMP280) and decode the bus traffic

```python
from embeddedci import benchpod

with benchpod.BenchPod("192.168.1.213") as bp:   # or "/dev/ttyACM0", or "serial"
    bp.ping()
    bp.power_on(benchpod.INTERNAL)
    result = bp.flash(
        file="firmware.elf", target="target/stm32f1x.cfg",
        swclk=benchpod.PIN1, swdio=benchpod.PIN2, nreset=benchpod.PIN3,
        target_power=benchpod.INTERNAL,
    )
    assert result.ok
    bp.power_off(benchpod.INTERNAL)
```

## Install

```bash
pip install embeddedci
```

Flashing shells out to **OpenOCD**, which must be on your `PATH`
(`brew install open-ocd` / `apt install openocd`).

## Named constants (no magic numbers)

| Concept | Constants | Wire value |
|---|---|---|
| Target-power eFuse | `benchpod.INTERNAL`, `benchpod.EXTERNAL` | 1, 2 |
| LA pins (SWCLK/SWDIO/NRESET) | `benchpod.PIN1` … `benchpod.PIN12` | 1 … 12 |

Plain ints still work (`efuse=1`, `swclk=1`); they're validated and coerced.

## Using it in pytest

Installing the package registers a pytest plugin. Point it at a pod and use the
fixtures:

```bash
pytest --benchpod-connection=192.168.1.213
# or: export BENCHPOD_CONNECTION=serial
```

```python
def test_firmware_flashes(benchpod):
    benchpod.power_on(benchpod.INTERNAL)
    assert benchpod.flash(
        file="firmware.elf", target="target/stm32f1x.cfg",
        swclk=benchpod.PIN1, swdio=benchpod.PIN2,
    ).ok

# benchpod_target powers the target on for the test and off at teardown:
def test_with_powered_target(benchpod_target):
    ...
```

Without a connection configured the fixtures **skip** rather than fail, so the
suite stays green in CI runners without hardware.

## Connection strings

| Form | Transport |
|---|---|
| `192.168.1.213` or `host:8080` | wifi/network (JSON over TCP, port 8080 default) |
| `/dev/ttyACM0`, `COM3` | serial (USB CDC-ACM console) |
| `serial` / `usb` | serial, auto-detected by USB VID `0x2E8A` |

Resolution order: explicit argument / `--benchpod-connection` →
`benchpod_connection` ini option → `BENCHPOD_CONNECTION` env var.

## Status of features

| Feature | State |
|---|---|
| Connect (wifi + serial) | ✅ |
| Power on/off (+ scheduled delay) | ✅ |
| Flash + assert ok/not ok | ✅ (pure-Python OpenOCD `remote_bitbang` bridge) |
| Emulated I2C sensor (BMP280) | ✅ wifi + serial (serial via `json` console mode) |
| I2C bus decode (`benchpod.i2c`) | ✅ START/STOP, R/W, ACK, register reads |
| UART capture (`capture_uart`) | ✅ wifi + serial |
| Signal helpers (`capture`, `gpio_set`) | ✅ minimal, TCP transport only |

## Emulated I2C sensor + UART capture (HIL)

The pod can **pretend to be an I2C sensor** (a BMP280) on two LA channels while
you capture the DUT's UART — so you can flash an app, power-cycle it, and assert
on its boot output with and without the sensor present:

```python
from embeddedci import benchpod

with benchpod.BenchPod("192.168.1.213") as bp:
    bp.flash(file="app.elf", target="target/stm32f4x.cfg",
             swclk=benchpod.PIN13, swdio=benchpod.PIN14, target_power=benchpod.INTERNAL)

    # I2C is open-drain — enable the pod's pull-ups on SDA/SCL (LA1-8 only),
    # then have the pod become a BMP280 on those lines.
    bp.enable_pullup(benchpod.PIN1, benchpod.PIN2)
    bp.enable_i2c_sensor(benchpod.Sensor.BMP280, sda=benchpod.PIN1, scl=benchpod.PIN2,
                         temperature_c=22.5, pressure_pa=101000)

    # Power-cycle while capturing UART so the boot banner lands in the window.
    cap = bp.power_cycle_and_capture(rx=benchpod.PIN5, tx=benchpod.PIN6,
                                     delay=1.5, duration=6.0, until=r"APP_OK")
    assert cap.match("APP_OK")
    assert cap.match(r"chip id match=0x58|bmp280_detected=yes")
```

How the power-cycle works: `power_cycle_and_capture` powers the eFuse off, then
schedules a power-on `delay` seconds out (a **pod-side timer** via
`target_power`'s `delay_ms`), then enters UART capture — so the scheduled
power-on, and the DUT's boot output, land *inside* the capture window. This
sidesteps the firmware's one-connection-at-a-time limit (you can't send a
power-on while the UART proxy owns the link).

`rx` is the LA channel the pod **samples** (wire the DUT's TX here); `tx` is the
channel the pod **drives** (DUT's RX).

> **Serial gets the full JSON API too.** The pod's TCP API is JSON; the serial
> console is normally text-only. The firmware exposes a `json` console command
> that switches the console into the same JSON dispatcher (exit with
> `{"cmd":"json_exit"}`). The serial transport enters/leaves this mode
> automatically, so `enable_i2c_sensor(...)`, `i2c_sensor_la_decoded(...)`, etc.
> work over serial as well as wifi — no code change in your test.

### Decode the I2C bus

While the pod serves the emulated sensor it also samples the SDA/SCL lines. The
`benchpod.i2c` module turns that raw capture into decoded transactions, so you
can assert what the DUT *actually did* on the wire — not just that it booted:

```python
from embeddedci import benchpod
from embeddedci.benchpod import i2c

with benchpod.BenchPod("192.168.1.213") as bp:
    bp.enable_i2c_sensor(benchpod.Sensor.BMP280, sda=benchpod.PIN7, scl=benchpod.PIN8)
    bp.power_off(benchpod.INTERNAL); bp.power_on(benchpod.INTERNAL)  # DUT boots & probes

    txns = bp.i2c_sensor_la_decoded(samples=4096, sample_rate_mhz=0.5)
    print(i2c.format_transactions(txns))
    # -> S 0x76W+ 0xD0+ Sr 0x76R+ 0x58- P   (write reg 0xD0, read chip id 0x58)
    assert i2c.read_register(txns, 0x76, 0xD0) == [0x58]
```

The decoder handles START/repeated-START/STOP, the R/W bit, per-byte ACK/NACK,
and the common "write register pointer, then read" pattern (`read_register`). It
works on a `(scl, sda)` stream too (`i2c.decode_samples`) and ships a waveform
synthesizer (`i2c.synthesize`) so you can build and decode traces with no
hardware — see [`tests/test_i2c_decode.py`](tests/test_i2c_decode.py).

### Getting started: one clean HIL test

The "hello world" of EmbeddedCI HIL — flash → emulate a BMP280 → power-cycle →
assert on UART — is in [`examples/test_bmp280.py`](examples/test_bmp280.py), with
a wiring table and run command at the top:

```bash
pytest examples/test_bmp280.py \
    --benchpod-connection=/dev/tty.usbserial-0001 \   # or an IP for wifi
    --benchpod-firmware=path/to/your_app.elf
```

It uses the plugin's `benchpod`, `pins` and `firmware` fixtures and the
`@pytest.mark.hardware` marker, and **skips automatically** without a connection.
The pin map comes from `--benchpod-swclk/-swdio/-nreset/-uart-rx/-uart-tx/-i2c-sda/-i2c-scl/-efuse`
options (sensible defaults) so tests aren't hardcoded to your wiring. See
[`examples/README.md`](examples/README.md) for the full wiring table.

A more thorough multi-case version (present/absent + I2C-bus decode) lives in
[`tests/examples/test_bmp280_hil.py`](tests/examples/test_bmp280_hil.py).

## Releasing (maintainers)

Releases publish to [PyPI](https://pypi.org/project/embeddedci/) automatically
via [`.github/workflows/publish.yml`](.github/workflows/publish.yml) using PyPI
**Trusted Publishing** (OIDC) — no API token or secret is stored in GitHub.

**One-time setup** on PyPI (or first via [TestPyPI](https://test.pypi.org)):
project → *Settings → Publishing → Add a pending publisher* with
owner `embeddedci-com`, repo `embeddedci-python`, workflow `publish.yml`,
environment `pypi`.

To cut a release:

```bash
# 1. bump the version in pyproject.toml (e.g. 0.1.0 -> 0.1.1), commit it
# 2. tag and push — the tag must match the version
git tag v0.1.1
git push origin v0.1.1
```

The tag push builds the sdist + wheel, runs `twine check`, and publishes. The
git tag is the source of truth for what shipped; keep it equal to the
`version` in `pyproject.toml`.

Build and verify locally before tagging:

```bash
python -m pip install build twine
python -m build           # -> dist/embeddedci-*.tar.gz and *.whl
twine check dist/*
```
