diff --git a/packages/embeddedci-mcp/CHANGELOG.md b/packages/embeddedci-mcp/CHANGELOG.md index 348e8eb..99f807c 100644 --- a/packages/embeddedci-mcp/CHANGELOG.md +++ b/packages/embeddedci-mcp/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog — `embeddedci-mcp` +## 2.13.13 + +- I2C master tools (gateware v55+): `i2c_open`, `i2c_transfer` (a NACK returns `ack: false` and + `nack_at`), `i2c_scan` and `i2c_close`. +- `capture_la` takes `send`: one I2C, SPI or gpio transaction right after the capture arms, with + its reply in the result's `send`. Without `trigger_la` it triggers on SDA or CS falling, or the + gpio pin's edge, and keeps 64 samples from before the trigger. +- `la_dir` sets which way each pin's DUT signal goes (the pod then refuses push-pull drivers on a + DUT output); `set_wiring` sends the profile's directions. `la_pins` rows have `dir` and + `warnings`. +- The SPI tools take modes 1 and 2 (gateware v55+). +- Needs embeddedci 2.13.13. + ## 2.13.12 - `sensor_capture` waits for the DUT's next I2C transaction (up to 2 s) and captures it from its diff --git a/packages/embeddedci-mcp/README.md b/packages/embeddedci-mcp/README.md index 3bb6a1c..592d55c 100644 --- a/packages/embeddedci-mcp/README.md +++ b/packages/embeddedci-mcp/README.md @@ -192,7 +192,7 @@ description, ranges and enums, so an agent sees more than this table. | Tool | Purpose | Parameters | | --- | --- | --- | | `wiring` | Which DUT signal is on which LA channel, plus the power rail, UART baud and SWD target. | none | -| `set_wiring` | Replace the wiring profile for this connection, or store it on embeddedci.com. | **`profile`**, `save=false` | +| `set_wiring` | Replace the wiring profile for this connection, or store it on embeddedci.com. Also sends the profile's pin directions to the pod (`la_dir`; `directions` in the result says whether it worked). | **`profile`**, `save=false` | ### Power @@ -226,6 +226,21 @@ description, ranges and enums, so an agent sees more than this table. | `spi_flash_read` | Read flash bytes to a file, or up to 4096 bytes as hex. | **`addr`**, **`length`**, `file`, `sck`, `mosi`, `miso`, `cs`, `hz=6000000`, `mode=0` | | `spi_transfer` | One full-duplex SPI transaction with any device on the SPI pins. | **`tx_hex`**, `sck`, `mosi`, `miso`, `cs`, `hz=1000000`, `mode=0` | +`mode` is 0 to 3; modes 1 and 2 need gateware v55+. + +### I2C master + +The pod as an I2C master on two LA pins (gateware v55+), to read and write registers of a chip on +the DUT's board. The bus needs pull-ups: the pod's on LA1-LA6 (`set_pull` first, LA voltage 3.3), or +the board's with `pullups="external"`. + +| Tool | Purpose | Parameters | +| --- | --- | --- | +| `i2c_open` | Make the pod an I2C master on two LA pins; `hz` in the result is the SCL rate the pod runs (a real bus is somewhat slower: the rise time adds to each period). | `sda`, `scl`, `hz=100000`, `pullups=pod` | +| `i2c_transfer` | Write, read, or write then read (repeated START); neither is a probe. A NACK returns `ack: false` and `nack_at`. | **`addr`**, `write_hex`, `read=0` | +| `i2c_scan` | Which addresses answer. | `start=8`, `end=119` | +| `i2c_close` | Release the I2C master's pins. | none | + ### UART | Tool | Purpose | Parameters | @@ -286,6 +301,7 @@ version the SDK pins (0.1.0, bundled) unless `BENCHPOD_SENSOR_LIBRARY`, the CLI' | `gpio_wait` | Wait until an LA channel reads a level (`reached: false` on timeout). | **`la`**, `level=1`, `timeout=5` | | `gpio_pulse` | FPGA-timed pulses on an LA channel: `width` 4 us to 65.535 ms, `count` up to 65535. | **`la`**, **`width`**, `count=1` | | `gpio_release` | Return channels to high-Z, watched by captures. | `la` | +| `la_dir` | Which way each pin's DUT signal goes (`dut_input`, `dut_output`, `bidir`, `unknown`); on a DUT output the pod refuses push-pull drivers. `from_wiring` sends the wiring profile's directions; no arguments lists every pin. | `la`, `dir`, `from_wiring=false` | ### Analog @@ -306,7 +322,7 @@ version the SDK pins (0.1.0, bundled) unless `BENCHPOD_SENSOR_LIBRARY`, the CLI' | Tool | Purpose | Parameters | | --- | --- | --- | | `capture_adc` | Capture the ADC: calibrated statistics, dominant frequency and a min/max envelope; `clipped_samples` counts samples past the range's full scale. `oversample` converts at 400 kS/s and averages down to `sample_rate_hz` for lower noise, about 5 s at most. | `samples=4096`, `sample_rate_hz`, `source`, `points=200`, `trigger_la`, `trigger_edge=rising`, `trigger_timeout=10`, `range`, `oversample` | -| `capture_la` | Capture all 14 LA channels: levels, edge count, first edge and frequency per channel. Up to 48 MS/s on gateware v50+. | `samples=4096`, `sample_rate_hz`, `stop_dac_after`, `trigger_la`, `trigger_edge=rising`, `trigger_timeout=10`, `trigger_pre_samples=0` | +| `capture_la` | Capture all 14 LA channels: levels, edge count, first edge and frequency per channel. Up to 48 MS/s on gateware v50+. `send` (gateware v55+) runs one transaction right after the capture arms: `{"bus":"i2c","addr":118,"write_hex":"d0","read":1}` (the `i2c_open` session, else one from the wiring), `{"bus":"spi","tx_hex":"9f00"}` (SPI pins from the wiring) or `{"bus":"gpio","la":6,"level":0}` (a GPIO output); without `trigger_la` it triggers on SDA or CS falling or the pin's edge, and the result's `send` has the reply. | `samples=4096`, `sample_rate_hz`, `stop_dac_after`, `trigger_la`, `trigger_edge=rising`, `trigger_timeout=10`, `trigger_pre_samples=0`, `send` | | `capture_correlated` | ADC and LA from one hardware trigger, on one timebase. | `adc_samples=4096`, `adc_sample_rate_hz`, `la_samples=4096`, `la_sample_rate_hz`, `stop_dac_after`, `points=200`, `trigger_la`, `trigger_edge=rising`, `trigger_timeout=10`, `source` | | `decode_la` | Decode I2C, UART or SPI from the last LA capture (or a new one). | **`protocol`**, `sda`, `scl`, `rx`, `baud`, `sclk`, `mosi`, `miso`, `cs`, `mode=0`, `capture=last`, `samples=16384`, `sample_rate_hz=1000000`, `max_items=200` | | `la_timing` | Edge timestamps, pulse widths, frequency, duty cycle and channel-to-channel delay from the last capture. | **`la`**, `edge=rising`, `to_la`, `to_edge=rising`, `after=0`, `max_edges=100` | diff --git a/packages/embeddedci-mcp/pyproject.toml b/packages/embeddedci-mcp/pyproject.toml index c8f8817..d0228e1 100644 --- a/packages/embeddedci-mcp/pyproject.toml +++ b/packages/embeddedci-mcp/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "embeddedci-mcp" -version = "2.13.12" +version = "2.13.13" description = "MCP server that lets AI agents drive an EmbeddedCI BenchPod hardware-in-the-loop bench" readme = "README.md" requires-python = ">=3.10" @@ -26,7 +26,7 @@ classifiers = [ dependencies = [ # cloud: the embeddedci: destination; discovery: connect("discover") over mDNS; # analysis: dominant frequency in capture summaries; yaml: sensor definitions an agent writes. - "embeddedci[cloud,discovery,analysis,yaml]>=2.13.12,<3", + "embeddedci[cloud,discovery,analysis,yaml]>=2.13.13,<3", "mcp>=1.12,<2", "anyio>=4", "pydantic>=2.7", diff --git a/packages/embeddedci-mcp/server.json b/packages/embeddedci-mcp/server.json index d7202d7..99dda54 100644 --- a/packages/embeddedci-mcp/server.json +++ b/packages/embeddedci-mcp/server.json @@ -3,7 +3,7 @@ "name": "io.github.embeddedci-com/embeddedci-mcp", "title": "EmbeddedCI BenchPod", "description": "Drive a BenchPod hardware-in-the-loop bench: power, flash, UART, I2C, CAN, analog and logic.", - "version": "2.13.12", + "version": "2.13.13", "websiteUrl": "https://www.embeddedci.com/docs/benchpod-mcp", "repository": { "url": "https://github.com/embeddedci-com/embeddedci-python", @@ -15,7 +15,7 @@ "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "embeddedci-mcp", - "version": "2.13.12", + "version": "2.13.13", "runtimeHint": "uvx", "transport": { "type": "stdio" diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py b/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py index 8a4a5c4..1cb1770 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/guide.py @@ -21,6 +21,7 @@ Typical flows: - Flash and boot: flash(file) then power_cycle_and_capture(until_regex) — with a wiring profile that is all; otherwise pass swclk, swdio, target, nreset and rx, tx. - SPI flash (a 25-series chip on four LA pins, wiring spi_sclk/spi_mosi/spi_miso/spi_cs): spi_flash_info to see it answers, then spi_flash_program(file, addr); hold_reset=true keeps the DUT off the bus. spi_transfer talks to any other SPI device. +- I2C master (gateware v55+): set_pull([sda, scl], true), i2c_open, then i2c_transfer(addr, write_hex, read) or i2c_scan; i2c_close. capture_la(send={...}) runs one I2C, SPI or gpio transaction right after the capture arms and triggers on it. la_dir marks DUT outputs so the pod never drives against them (set_wiring sends the profile's directions). - Interactive console: uart_open, power_on, uart_read(until_regex), uart_write, uart_close. - Emulate sensors (I2C, SPI, UART, analog; firmware with the 'sensors' capability): the pod plays a sensor from a definition. sensor_library lists ready ones (bme280, bmp280, sht4x, mpu6050, gps_nmea, ...) and sensor_show gives one's YAML and params. For I2C, set_pull([sda, scl], true) on LA1-LA6 (LA7/LA8 pull DOWN) first. sensor_start(definition=, pins={sda, scl} | {tx} | ...), then sensor_set(instance, values) changes readings, sensor_stimulus_load + sensor_stimulus_start play values over time, sensor_fault injects a fault, sensor_status shows bus activity, sensor_capture waits for the DUT's next transaction on an I2C sensor's bus and decodes it, sensor_stop ends it. A part the library lacks: read sensor_format, write a definition from the datasheet, check it with sensor_validate, then sensor_start it. A GPS module is gps_nmea on pins={tx: }. - Analog: dac_output (DC), generate (sine/square/sawtooth) and replay drive the DAC and route its path; adc_read gives one calibrated value, capture_adc a waveform summary; dac_stop ends any DAC output. diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/models.py b/packages/embeddedci-mcp/src/embeddedci_mcp/models.py index 11066da..116200a 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/models.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/models.py @@ -4,7 +4,7 @@ structured content. Units follow the SDK: volts, amps, seconds, hertz. """ -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Dict, List, Literal, Optional, Union from pydantic import BaseModel, Field @@ -21,6 +21,20 @@ class FaultSpec(BaseModel): description="Raw DAC code to hold (default per fault type).") +class SendSpec(BaseModel): + """One transaction capture_la runs right after it arms (gateware v55+).""" + + bus: Literal["i2c", "spi", "gpio"] + addr: Optional[int] = Field(None, ge=0, le=127, description="i2c: 7-bit address.") + write_hex: str = Field("", description="i2c: bytes to write, hex (up to 32).") + read: int = Field(0, ge=0, le=32, description="i2c: bytes to read after the write.") + tx_hex: str = Field("", description="spi: one CS frame, hex (1-32 bytes); SPI pins from the wiring.") + spi_mode: int = Field(0, ge=0, le=3, description="spi: mode 0-3.") + spi_hz: int = Field(1_000_000, ge=190_000, le=6_000_000, description="spi: SCK rate.") + la: Optional[Union[int, str]] = Field(None, description="gpio: an LA channel already a GPIO output.") + level: Optional[Literal[0, 1]] = Field(None, description="gpio: the level to set.") + + class InputMapSpec(BaseModel): """Engineering-units input map for the control loop (gateware >= v30).""" @@ -89,6 +103,7 @@ def from_caps(cls, caps: Any) -> "CapabilitiesInfo": class SessionInfo(BaseModel): uart_open: bool = False can_open: bool = False + i2c_open: bool = False power_profile_running: bool = Field(False, description="power_profile_start ran without power_profile_stop.") last_adc_capture_samples: Optional[int] = None last_la_capture_samples: Optional[int] = None @@ -157,6 +172,8 @@ class WiringResult(BaseModel): description="Wiring that works but is risky (e.g. an I2C bus with no pull-up).") profile: Dict[str, Any] = Field(default_factory=dict, description="The profile as its JSON object.") saved: bool = Field(False, description="set_wiring stored this profile on embeddedci.com.") + directions: Optional[str] = Field(None, description=( + "set_wiring: 'sent' when the pod now has the profile's pin directions (la_dir), else why not.")) # -- LA pin ownership + GPIO ------------------------------------------------------- @@ -165,13 +182,16 @@ class LaPinResult(BaseModel): la: int function: str = Field(description=( "What owns the channel: none (free, watched by captures), gpio, uart_rx, uart_tx, swd_clk, " - "swd_dio, i2c_sda, i2c_scl, step or step_dir.")) + "swd_dio, i2c_sda, i2c_scl, step, step_dir, spi_*, sensor_*, or i2cm_sda/i2cm_scl (i2c_open).")) gpio: Optional[str] = Field(None, description="GPIO mode when function is gpio: input, output or open_drain.") level: Optional[int] = Field(None, description="Commanded level of a GPIO output / open-drain channel.") pull: Optional[str] = Field(None, description="Bias direction: up, down, or null on LA9-LA14.") pull_ohms: Optional[str] = None pull_on: bool = False in_use: bool = Field(description="A function other than none owns the channel.") + dir: Optional[str] = Field(None, description=( + "The DUT signal's direction set with la_dir: dut_input, dut_output or bidir; null when unknown.")) + warnings: Optional[List[str]] = Field(None, description="Pin warnings from the pod, if any.") class PinLevel(BaseModel): @@ -327,6 +347,38 @@ class SpiFlashReadResult(BaseModel): hex: str = "" +class I2cSessionResult(BaseModel): + open: bool + sda: Optional[int] = None + scl: Optional[int] = None + hz: Optional[int] = Field(None, description=( + "SCL rate the pod runs; a real bus is somewhat slower (the rise time adds to each period).")) + pullups: Optional[str] = None + + +class I2cTransferResult(BaseModel): + addr: int + ack: bool = Field(description="Every byte was acknowledged.") + rx_hex: str = Field("", description="The bytes read.") + nack_at: Optional[int] = Field(None, description=( + "When ack is false: 0 = the address, 1..N = the Nth written byte, N+1 = the read address.")) + + +class I2cScanResult(BaseModel): + found: List[int] = Field(description="7-bit addresses that answered.") + found_hex: List[str] + + +class SendResultModel(BaseModel): + bus: str + ack: Optional[bool] = None + rx_hex: str = "" + nack_at: Optional[int] = None + la: Optional[int] = None + level: Optional[int] = None + error: Optional[str] = Field(None, description="Why the transfer failed; the capture still returned.") + + class SpiTransferResult(BaseModel): rx_hex: str hz: int @@ -572,6 +624,7 @@ class LaCaptureResult(BaseModel): truncated: Optional[str] = Field(None, description=( "Above 12 MS/s only: why the capture stopped early ('busy': the signals changed too often, " "'memory'), so it holds fewer samples than asked; null when complete.")) + send: Optional[SendResultModel] = Field(None, description="What the capture's send did.") class PulseStats(BaseModel): diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py index eec053f..b7874e9 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/server.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/server.py @@ -44,9 +44,12 @@ Fault, FpgaImage, GpioMode, + GpioSend, + I2CSend, LoopInputMap, LoopSource, ReplayMapping, + SpiSend, Trigger, TriggerEdge, Waveshape, @@ -172,6 +175,13 @@ def isa(name: str) -> bool: '{"cmd":"spi_stop"})'), (re.compile(r'\(\{"cmd":"spi_stop"\}\)'), '(another client holds the SPI session; the command tool can send {"cmd":"spi_stop"})'), + (re.compile(r'stop the I2C session first \(\{"cmd":"i2c_stop"\}\)'), + "close the I2C session first with i2c_close"), + (re.compile(r"send i2c_stop first"), "call i2c_close first"), + (re.compile(r"send i2c_start first"), "call i2c_open first"), + (re.compile(r"i2c_start again"), "call i2c_open again"), + (re.compile(r'\{"cmd":"la","la":(\d+),"pullup":"on"\}'), r"set_pull(las=[\1], enabled=true)"), + (re.compile(r'pass "pullups":"external"'), 'pass pullups="external"'), (re.compile(r'\{"cmd":"la","la":(\d+),"pullup":"off"\}'), r"set_pull(las=[\1], enabled=false)"), (re.compile(r'\{"cmd":"gpio","la":(\d+),"mode":"output"\}'), r'gpio_mode(la=[\1], mode="output")'), (re.compile(r'\{"cmd":"fpga_image","image":0\}'), 'fpga_image(image="loop")'), @@ -438,11 +448,24 @@ def op() -> m.WiringResult: pod.wiring = Wiring.from_dict(profile) if save: pod.save_wiring() - return wiring_summary(pod.wiring, saved=save) + result = wiring_summary(pod.wiring, saved=save) + result.directions = _push_directions(pod) + return result return await _call(op) +def _push_directions(pod: Any) -> str: + """Send the profile's pin directions (la_dir); 'sent', or why not (older firmware, a driven pin).""" + try: + pod.apply_pin_directions() + return "sent" + except _errors.UnsupportedFeatureError: + return "not sent: this pod's firmware has no la_dir" + except BenchPodError as exc: + return f"not sent: {_error_text(exc)}" + + # -- power ---------------------------------------------------------------------------- def _switch_power(efuse: Optional[int], on: bool, delay: Optional[float]) -> m.PowerResult: @@ -574,6 +597,7 @@ def op() -> m.ResetResult: # -- SPI flash -------------------------------------------------------------------------- _SPI_PIN = ("LA channel (or wiring name) for {}; omit for the profile's {}.") +SpiMode = Annotated[Literal[0, 1, 2, 3], Field(description="SPI mode; 1 and 2 need gateware v55+.")] def _spi_kwargs(sck: Any, mosi: Any, miso: Any, cs: Any, hz: int, mode: int) -> Dict[str, Any]: @@ -587,7 +611,7 @@ async def spi_flash_info( miso: Annotated[Optional[LaRef], Field(description=_SPI_PIN.format("MISO (the chip's DO)", "spi_miso"))] = None, cs: Annotated[Optional[LaRef], Field(description=_SPI_PIN.format("CS", "spi_cs"))] = None, hz: Annotated[int, Field(ge=190_000, le=6_000_000, description="SCK rate; rounded down to what the pod can do.")] = 1_000_000, - mode: Literal[0, 3] = 0, + mode: SpiMode = 0, ) -> m.SpiFlashInfoResult: """Read the JEDEC ID of the SPI NOR flash on the SPI pins (gateware v45+, capability spi_master). @@ -617,7 +641,7 @@ async def spi_flash_program( miso: Annotated[Optional[LaRef], Field(description=_SPI_PIN.format("MISO (the chip's DO)", "spi_miso"))] = None, cs: Annotated[Optional[LaRef], Field(description=_SPI_PIN.format("CS", "spi_cs"))] = None, hz: Annotated[int, Field(ge=190_000, le=6_000_000)] = 6_000_000, - mode: Literal[0, 3] = 0, + mode: SpiMode = 0, ctx: Context = None, # type: ignore[assignment] ) -> m.SpiFlashProgramResult: """Erase, write and verify an image into the SPI NOR flash wired to the SPI pins. @@ -647,7 +671,7 @@ async def spi_flash_read( miso: Annotated[Optional[LaRef], Field(description=_SPI_PIN.format("MISO (the chip's DO)", "spi_miso"))] = None, cs: Annotated[Optional[LaRef], Field(description=_SPI_PIN.format("CS", "spi_cs"))] = None, hz: Annotated[int, Field(ge=190_000, le=6_000_000)] = 6_000_000, - mode: Literal[0, 3] = 0, + mode: SpiMode = 0, ctx: Context = None, # type: ignore[assignment] ) -> m.SpiFlashReadResult: """Read bytes back from the SPI NOR flash, to a file or (up to 4096 bytes) as hex.""" @@ -675,7 +699,7 @@ async def spi_transfer( miso: Annotated[Optional[LaRef], Field(description=_SPI_PIN.format("MISO", "spi_miso"))] = None, cs: Annotated[Optional[LaRef], Field(description=_SPI_PIN.format("CS", "spi_cs"))] = None, hz: Annotated[int, Field(ge=190_000, le=6_000_000)] = 1_000_000, - mode: Literal[0, 3] = 0, + mode: SpiMode = 0, ) -> m.SpiTransferResult: """One full-duplex SPI transaction (CS asserted for all of tx) with any SPI device on the SPI pins. @@ -693,6 +717,70 @@ def op() -> m.SpiTransferResult: return await _call(op) +# -- I2C master ------------------------------------------------------------------------- + +@mcp.tool(annotations=_ann("Open the I2C master", destructive=True)) +async def i2c_open( + sda: Annotated[Optional[LaRef], Field(description="SDA channel; omit for the profile's i2c_sda.")] = None, + scl: Annotated[Optional[LaRef], Field(description="SCL channel; omit for the profile's i2c_scl.")] = None, + hz: Annotated[int, Field(ge=10_000, le=1_000_000, description="SCL rate.")] = 100_000, + pullups: Annotated[Literal["pod", "external"], Field(description=( + "pod: LA1-LA6 with the pod's pull-ups on (set_pull first, LA voltage 3.3); " + "external: the board has its own."))] = "pod", +) -> m.I2cSessionResult: + """Make the pod an I2C master on two LA pins (gateware v55+), replacing any open session. + + Then i2c_transfer, i2c_scan, or capture_la with send. SWD, SPI and I2C exclude each other. + """ + def op() -> m.I2cSessionResult: + pod = SESSION.require() + SESSION.close_i2c() + i2c = pod.open_i2c(sda, scl, hz=hz, pullups=pullups) + SESSION.i2c = i2c + return m.I2cSessionResult(open=True, sda=i2c.sda, scl=i2c.scl, hz=i2c.hz, pullups=i2c.pullups) + + return await _call(op) + + +@mcp.tool(annotations=_ann("I2C transfer", destructive=True)) +async def i2c_transfer( + addr: Annotated[int, Field(ge=0, le=127, description="7-bit address.")], + write_hex: Annotated[str, Field(description="Bytes to write, hex (up to 128), e.g. a register.")] = "", + read: Annotated[int, Field(ge=0, le=248, description="Bytes to read after a repeated START.")] = 0, +) -> m.I2cTransferResult: + """Write, read, or write then read on the open I2C session. Neither = a probe. + + A NACK is not an error: ack is false and nack_at says which byte. + """ + data = bytes.fromhex(write_hex.replace(" ", "")) + + def op() -> m.I2cTransferResult: + r = SESSION.require_i2c().xfer(addr, data, read) + return m.I2cTransferResult(addr=r.addr, ack=r.ack, rx_hex=r.data.hex(), nack_at=r.nack_at) + + return await _call(op) + + +@mcp.tool(annotations=_ann("Scan the I2C bus", read_only=True)) +async def i2c_scan( + start: Annotated[int, Field(ge=0, le=127)] = 0x08, + end: Annotated[int, Field(ge=0, le=127)] = 0x77, +) -> m.I2cScanResult: + """Which addresses answer on the open I2C session.""" + def op() -> m.I2cScanResult: + found = SESSION.require_i2c().scan(start, end) + return m.I2cScanResult(found=found, found_hex=[f"0x{a:02x}" for a in found]) + + return await _call(op) + + +@mcp.tool(annotations=_ann("Close the I2C master", idempotent=True)) +async def i2c_close() -> m.I2cSessionResult: + """Release the I2C master's pins (safe when none is open).""" + await _call(SESSION.close_i2c) + return m.I2cSessionResult(open=False) + + # -- flash ------------------------------------------------------------------------------ @mcp.tool(annotations=_ann("Flash firmware over SWD", destructive=True)) @@ -1254,6 +1342,32 @@ def op() -> m.LaPinsResult: return await _call(op) +@mcp.tool(annotations=_ann("Set pin directions", idempotent=True)) +async def la_dir( + la: Annotated[Optional[Union[List[LaRef], Literal["all"]]], Field(description=( + "Channels or wiring names, or 'all'. Omit with dir to list every pin."))] = None, + dir: Annotated[Optional[Literal["dut_input", "dut_output", "bidir", "unknown"]], Field(description=( + "Which way the DUT signal goes. On dut_output the pod refuses push-pull drivers."))] = None, + from_wiring: Annotated[bool, Field(description=( + "Send the wiring profile's directions instead (uart_rx, spi_miso and input signals are " + "DUT outputs)."))] = False, +) -> m.GpioPinsResult: + """Tell the pod which way each pin's DUT signal goes, so it never drives against a DUT output. + + The pod forgets this on a reboot; set_wiring sends the profile's directions. + """ + def op() -> m.GpioPinsResult: + pod = SESSION.require() + if from_wiring: + pins = pod.apply_pin_directions() + else: + target = la if la == "all" or la is None else [_la(pod, ch) for ch in la] + pins = pod.la_dir(target, dir) + return m.GpioPinsResult(pins=[pin_state(p) for p in pins]) + + return await _call(op) + + @mcp.tool(annotations=_ann("Use LA channels as GPIO", destructive=True, idempotent=True)) async def gpio_mode( la: GpioChannels, @@ -1603,6 +1717,10 @@ async def capture_la( trigger_pre_samples: Annotated[int, Field(ge=0, le=16_777_215, description=( "With trigger_la: also return up to this many samples from before the trigger " "(gateware v50+)."))] = 0, + send: Annotated[Optional[m.SendSpec], Field(description=( + "Run one transaction right after the capture arms (gateware v55+): i2c (the i2c_open " + "session, else one from the wiring), spi (SPI pins from the wiring) or gpio (a GPIO " + "output). Without trigger_la it triggers on SDA / CS falling or the gpio pin's edge."))] = None, ctx: Context = None, # type: ignore[assignment] ) -> m.LaCaptureResult: """Capture all 14 LA channels and summarise each: levels, edge count, first edge, estimated frequency. @@ -1614,16 +1732,49 @@ async def capture_la( retry at 12 MS/s. """ def op() -> m.LaCaptureResult: - la = SESSION.require().capture_la(samples, sample_rate_hz=sample_rate_hz, - stop_dac_after=stop_dac_after, - trigger=_trigger(trigger_la, trigger_edge, trigger_pre_samples), - trigger_timeout=trigger_timeout) + pod = SESSION.require() + if send is not None: + la = _capture_with_send(pod, send, samples, sample_rate_hz, stop_dac_after, trigger_la, + trigger_edge, trigger_timeout, trigger_pre_samples) + else: + la = pod.capture_la(samples, sample_rate_hz=sample_rate_hz, + stop_dac_after=stop_dac_after, + trigger=_trigger(trigger_la, trigger_edge, trigger_pre_samples), + trigger_timeout=trigger_timeout) SESSION.last_la = la return la_summary(la) return await _call_reporting(ctx, "capturing LA", op) +def _capture_with_send(pod: Any, spec: m.SendSpec, samples: int, rate: Optional[float], + stop_dac_after: Optional[float], trigger_la: Any, edge: str, timeout: float, + pre: int) -> Any: + """capture_la with a send: the session it needs (opened from the wiring when none is), and the + trigger on the line the transaction starts with when none is given.""" + def capture(send: Any, auto_la: int, auto_edge: str) -> Any: + trig = _trigger(trigger_la, edge, pre) if trigger_la is not None else Trigger(auto_la, auto_edge, pre) + return pod.capture_la(samples, sample_rate_hz=rate, stop_dac_after=stop_dac_after, + trigger=trig, trigger_timeout=timeout, send=send) + + if spec.bus == "i2c": + if spec.addr is None: + raise ValueError("an i2c send needs addr") + send = I2CSend(spec.addr, bytes.fromhex(spec.write_hex.replace(" ", "")), spec.read) + if SESSION.i2c is not None: + return capture(send, SESSION.i2c.sda, "falling") + with pod.open_i2c() as i2c: + return capture(send, i2c.sda, "falling") + if spec.bus == "spi": + send = SpiSend(bytes.fromhex(spec.tx_hex.replace(" ", ""))) + with pod.open_spi(hz=spec.spi_hz, mode=spec.spi_mode) as spi: + return capture(send, spi.cs, "falling") + if spec.la is None or spec.level is None: + raise ValueError("a gpio send needs la and level") + la_i = _la(pod, spec.la) + return capture(GpioSend(la_i, spec.level), la_i, "falling" if spec.level == 0 else "rising") + + @mcp.tool(annotations=_ann("Capture ADC and logic together")) async def capture_correlated( adc_samples: Annotated[int, Field(ge=0, le=8_000_000)] = 4096, diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/session.py b/packages/embeddedci-mcp/src/embeddedci_mcp/session.py index 60f4571..7adaf7b 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/session.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/session.py @@ -24,6 +24,7 @@ BenchPod, CanBus, Capture, + I2CMaster, LaCapture, PowerProfileSession, UartSession, @@ -146,6 +147,8 @@ def __init__(self) -> None: self.uart: Optional[UartSession] = None self.uart_port: Optional[Tuple[int, int, int]] = None self.can: Optional[CanBus] = None + #: An I2C master session from ``i2c_open``. + self.i2c: Optional[I2CMaster] = None self.can_config: Optional[Tuple[int, str, bool]] = None #: A power profile started by ``power_profile_start`` and not yet stopped. self.power_profile: Optional[PowerProfileSession] = None @@ -243,6 +246,7 @@ def disconnect(self) -> None: def _close_device(self) -> None: self.close_uart() self.close_can() + self.close_i2c() self.power_profile = None # pod-side sampling stops by itself at its max_duration if self._pod is not None: try: @@ -317,6 +321,19 @@ def close_can(self) -> None: self.can = None self.can_config = None + def require_i2c(self) -> I2CMaster: + if self.i2c is None: + raise SessionStateError("no I2C session is open: call i2c_open first") + return self.i2c + + def close_i2c(self) -> None: + if self.i2c is not None: + try: + self.i2c.close() + except Exception: + pass + self.i2c = None + def require_power_profile(self) -> PowerProfileSession: if self.power_profile is None: raise SessionStateError("no power profile is running — call power_profile_start first " @@ -326,7 +343,7 @@ def require_power_profile(self) -> PowerProfileSession: def info(self) -> m.SessionInfo: leased = self._pod is not None and self._pod.leased return m.SessionInfo( - uart_open=self.uart is not None, can_open=self.can is not None, + uart_open=self.uart is not None, can_open=self.can is not None, i2c_open=self.i2c is not None, power_profile_running=self.power_profile is not None, last_adc_capture_samples=len(self.last_adc) if self.last_adc is not None else None, last_la_capture_samples=len(self.last_la) if self.last_la is not None else None, diff --git a/packages/embeddedci-mcp/src/embeddedci_mcp/summaries.py b/packages/embeddedci-mcp/src/embeddedci_mcp/summaries.py index 6fee9fb..439c755 100644 --- a/packages/embeddedci-mcp/src/embeddedci_mcp/summaries.py +++ b/packages/embeddedci-mcp/src/embeddedci_mcp/summaries.py @@ -98,7 +98,16 @@ def la_summary(la: LaCapture) -> m.LaCaptureResult: )) return m.LaCaptureResult(samples=len(la), sample_rate_hz=rate, duration=la.duration, channels=channels, trigger=trigger_text(la.trigger), - pre_samples=la.pre_samples, truncated=la.truncated or None) + pre_samples=la.pre_samples, truncated=la.truncated or None, + send=send_summary(la.send_result)) + + +def send_summary(r: Any) -> Optional[m.SendResultModel]: + """A capture's :class:`~embeddedci.benchpod.SendResult` as a tool result, or ``None``.""" + if r is None: + return None + return m.SendResultModel(bus=r.bus, ack=r.ack, rx_hex=r.data.hex(), nack_at=r.nack_at, la=r.la, + level=r.level, error=r.error or None) # -- wiring, pins and power ------------------------------------------------------------ @@ -127,7 +136,7 @@ def pin_state(pin: Any) -> m.LaPinResult: """One :class:`~embeddedci.benchpod.LaPinState` as a tool result row.""" return m.LaPinResult(la=pin.la, function=pin.function, gpio=pin.gpio, level=pin.level, pull=pin.pull, pull_ohms=pin.pull_ohms, pull_on=pin.pull_on, - in_use=pin.in_use) + in_use=pin.in_use, dir=pin.dir, warnings=pin.warnings or None) def power_summary(profile: PowerProfile, points: int) -> m.PowerProfileResult: diff --git a/packages/embeddedci-mcp/tests/conftest.py b/packages/embeddedci-mcp/tests/conftest.py index e59e32e..c6a5996 100644 --- a/packages/embeddedci-mcp/tests/conftest.py +++ b/packages/embeddedci-mcp/tests/conftest.py @@ -96,6 +96,10 @@ def __init__(self) -> None: #: Set to a firmware message to make the next streamed capture / power profile fail with it. self.error: Optional[str] = None self.power_profile_running = False + #: The I2C master session (i2c_start) and the DUT directions (la_dir). + self.i2c: Optional[Dict[str, Any]] = None + self.spi: Optional[Dict[str, Any]] = None + self.dirs: Dict[int, str] = {} self.power_efuse = 1 #: The bin-averaged trace a power_profile reply carries (µA / mV, µs since the start). self.power_samples: Dict[str, Any] = { @@ -114,7 +118,7 @@ def status(self) -> Any: if self.image is not None: caps.append("dac_control_loop" if self.image == 0 else "dac_deep_replay") return {"version": "2.0.0", "board": "stm32h563", "adc_bits": 16, "adc_fullscale_mv": 4096, - "caps": caps} + "caps": caps, "gateware": 55} def ping(self) -> Any: return "pong" @@ -292,8 +296,61 @@ def _pin(self, la: int) -> Dict[str, Any]: if la in PULL_OHMS: pull = {"dir": "down" if la in PULLDOWN_CHANNELS else "up", "ohms": PULL_OHMS[la], "on": self.pull_on[la]} - return {"la": la, "function": self.function[la], "gpio": self.mode[la], - "level": self.level[la], "pull": pull} + e = {"la": la, "function": self.function[la], "gpio": self.mode[la], + "level": self.level[la], "pull": pull} + if self.dirs.get(la, "unknown") != "unknown": + e["dir"] = self.dirs[la] + return e + + # -- bus master (i2c_*, spi_start, la_dir) -- + def _cmd_i2c_start(self, req): + if self.i2c is not None: + raise FirmwareError("i2c busy: send i2c_stop first", cmd="i2c_start") + self.i2c = req + self.function[req["sda"]], self.function[req["scl"]] = "i2cm_sda", "i2cm_scl" + return {"sda": req["sda"], "scl": req["scl"], "hz": req["hz"], "pullups": req["pullups"]} + + def _cmd_i2c_stop(self, req): + if self.i2c is not None: + self.function[self.i2c["sda"]] = self.function[self.i2c["scl"]] = "none" + self.i2c = None + return "i2c stopped" + + def _i2c_xfer(self, addr, write, read): + if self.i2c is None: + raise FirmwareError("no I2C session: send i2c_start first", cmd="i2c_xfer") + if addr != 0x76: + return {"addr": addr, "ack": False, "nack_at": 0, "rx": ""} + rx = base64.urlsafe_b64encode(bytes([0x58] * read)).decode().rstrip("=") + return {"addr": addr, "ack": True, "rx": rx} + + def _cmd_i2c_xfer(self, req): + return self._i2c_xfer(req["addr"], req.get("write", ""), req.get("read", 0)) + + def _cmd_i2c_scan(self, req): + if self.i2c is None: + raise FirmwareError("no I2C session: send i2c_start first", cmd="i2c_scan") + return {"from": req.get("from", 8), "to": req.get("to", 119), "found": [0x76]} + + def _cmd_spi_start(self, req): + self.spi = req + return {k: req[k] for k in ("sck", "mosi", "miso", "cs", "hz", "mode")} + + def _cmd_spi_xfer(self, req): + return {"rx": req["tx"], "cs": "released"} + + def _cmd_spi_stop(self, req): + self.spi = None + return "spi stopped" + + def _cmd_la_dir(self, req): + if "la" not in req: + return {"pins": [self._pin(la) for la in range(1, 15)]} + raw = req["la"] + las = list(range(1, 15)) if raw == "all" else (raw if isinstance(raw, list) else [raw]) + for la in las: + self.dirs[la] = req["dir"] + return {"pins": [self._pin(la) for la in las]} def _levels(self) -> int: mask = self.inputs @@ -500,8 +557,17 @@ def stream_chunks(self, req: dict) -> Iterator[Dict[str, Any]]: n = req["samples"] # LA1 toggles every 50 samples (10 kHz at 1 MS/s); LA3 stays high. edges = [[i, (0b100 | ((i // 50) % 2))] for i in range(0, n, 50)] - yield {"status": "ok", "la": True, "la_edges": edges, "la_upto": n, - "la_rate_hz": 1_000_000.0, "more": False} + last = {"status": "ok", "la": True, "la_edges": edges, "la_upto": n, + "la_rate_hz": 1_000_000.0, "more": False} + send = req.get("send") + if send and send["bus"] == "i2c": + r = self._i2c_xfer(send["addr"], send.get("write", ""), send.get("read", 0)) + last["send"] = {"bus": "i2c", **{k: v for k, v in r.items() if k != "addr"}} + elif send and send["bus"] == "spi": + last["send"] = {"bus": "spi", "rx": send["tx"]} + elif send: + last["send"] = dict(send) + yield last elif cmd == "capture_dual": yield {"status": "ok", "data": sine_counts(req["adc_samples"]), "adc_rate_hz": 100_000.0, "la_rate_hz": 1_000_000.0, "more": True} diff --git a/packages/embeddedci-mcp/tests/test_bus_master_tools.py b/packages/embeddedci-mcp/tests/test_bus_master_tools.py new file mode 100644 index 0000000..a071969 --- /dev/null +++ b/packages/embeddedci-mcp/tests/test_bus_master_tools.py @@ -0,0 +1,114 @@ +"""The bus-master tools: i2c_open / i2c_transfer / i2c_scan / i2c_close, la_dir, SPI modes 1/2 and +capture_la's send, against the fake pod.""" + +from __future__ import annotations + +import pytest +from mcp.server.fastmcp.exceptions import ToolError + +from conftest import call + + +def _sent(fake, cmd): + return [r for r in fake.requests if r["cmd"] == cmd] + + +def test_i2c_session_transfer_scan_and_close(connected): + r = call("i2c_open", hz=400_000) + assert r == {"open": True, "sda": 1, "scl": 2, "hz": 400_000, "pullups": "pod"} + t = call("i2c_transfer", addr=0x76, write_hex="d0", read=1) + assert t == {"addr": 0x76, "ack": True, "rx_hex": "58", "nack_at": None} + assert _sent(connected, "i2c_xfer")[-1] == {"cmd": "i2c_xfer", "addr": 0x76, "write": "0A", "read": 1} + nack = call("i2c_transfer", addr=0x50) + assert nack["ack"] is False and nack["nack_at"] == 0 + assert call("i2c_scan") == {"found": [0x76], "found_hex": ["0x76"]} + assert call("status")["session"]["i2c_open"] is True + assert call("i2c_close") == {"open": False, "sda": None, "scl": None, "hz": None, "pullups": None} + assert connected.i2c is None + with pytest.raises(ToolError, match="i2c_open"): + call("i2c_scan") + + +def test_i2c_open_replaces_the_open_session(connected): + call("i2c_open") + call("i2c_open", sda=3, scl=4, pullups="external") + assert [r["cmd"] for r in connected.requests if r["cmd"].startswith("i2c")][-3:] == [ + "i2c_start", "i2c_stop", "i2c_start"] + assert connected.i2c["sda"] == 3 + + +def test_i2c_refusals_name_the_tools(): + from embeddedci_mcp.server import _mcp_refusal_text + + assert "i2c_close" in _mcp_refusal_text( + 'pin conflict: LA1 is in use by i2cm_sda; stop the I2C session first ({"cmd":"i2c_stop"})') + assert _mcp_refusal_text("no I2C session: send i2c_start first").endswith("call i2c_open first") + text = _mcp_refusal_text('I2C needs pull-ups: switch on LA1\'s with {"cmd":"la","la":1,"pullup":"on"}, ' + 'or pass "pullups":"external"') + assert "set_pull(las=[1], enabled=true)" in text and 'pullups="external"' in text + + +def test_la_dir_sets_lists_and_follows_the_wiring(connected): + r = call("la_dir", la=[9, "uart_rx"], dir="dut_output") + assert [(p["la"], p["dir"]) for p in r["pins"]] == [(9, "dut_output"), (5, "dut_output")] + assert len(call("la_dir")["pins"]) == 14 + call("la_dir", from_wiring=True) + assert connected.dirs[4] == "dut_input" and connected.dirs[1] == "bidir" + with pytest.raises(ToolError, match="invalid argument"): + call("la_dir", la=[9]) + + +def test_set_wiring_sends_the_directions(connected): + r = call("set_wiring", profile={"uart_rx": 7, "uart_tx": 8}) + assert r["directions"] == "sent" + assert connected.dirs[7] == "dut_output" and connected.dirs[8] == "dut_input" + + +def test_spi_modes_1_and_2(connected): + connected.caps.append("spi_master") + from embeddedci_mcp.session import SESSION + SESSION.require().refresh_capabilities() + call("spi_transfer", tx_hex="9f00", sck=3, mosi=4, miso=5, cs=6, mode=2) + assert _sent(connected, "spi_start")[-1]["mode"] == 2 + + +def test_capture_la_i2c_send_auto_triggers_on_sda(connected): + call("i2c_open") + r = call("capture_la", samples=1000, send={"bus": "i2c", "addr": 0x76, "write_hex": "d0", "read": 1}) + req = _sent(connected, "la_capture")[-1] + assert req["send"] == {"bus": "i2c", "addr": 0x76, "write": "0A", "read": 1} + assert req["trigger"] == {"la": 1, "edge": "falling", "pre_samples": 64} + assert r["send"] == {"bus": "i2c", "ack": True, "rx_hex": "58", "nack_at": None, "la": None, + "level": None, "error": None} + + +def test_capture_la_i2c_send_opens_a_session_from_the_wiring(connected): + call("capture_la", samples=1000, send={"bus": "i2c", "addr": 0x76, "read": 1}) + assert [r["cmd"] for r in connected.requests if r["cmd"].startswith("i2c")] == ["i2c_start", "i2c_stop"] + assert connected.i2c is None + + +def test_capture_la_spi_and_gpio_sends(connected): + connected.caps.append("spi_master") + from embeddedci_mcp.session import SESSION + SESSION.require().refresh_capabilities() + call("set_wiring", profile={"spi_sclk": 9, "spi_mosi": 10, "spi_miso": 11, "spi_cs": 12, + "swd_swclk": None, "swd_swdio": None}) + r = call("capture_la", samples=1000, send={"bus": "spi", "tx_hex": "9f00", "spi_mode": 1}) + req = _sent(connected, "la_capture")[-1] + assert req["trigger"]["la"] == 12 and _sent(connected, "spi_start")[-1]["mode"] == 1 + assert r["send"]["bus"] == "spi" and r["send"]["rx_hex"] == "9f00" + assert connected.spi is None + r = call("capture_la", samples=1000, trigger_la=6, trigger_edge="rising", + send={"bus": "gpio", "la": 6, "level": 1}) + req = _sent(connected, "la_capture")[-1] + assert req["send"] == {"bus": "gpio", "la": 6, "level": 1} + assert req["trigger"] == {"la": 6, "edge": "rising", "pre_samples": 64} + assert r["send"]["la"] == 6 and r["send"]["level"] == 1 + + +def test_capture_la_send_needs_its_fields(connected): + with pytest.raises(ToolError, match="addr"): + call("capture_la", send={"bus": "i2c"}) + with pytest.raises(ToolError, match="la and level"): + call("capture_la", send={"bus": "gpio", "la": 6}) diff --git a/packages/embeddedci-mcp/tests/test_gpio_tools.py b/packages/embeddedci-mcp/tests/test_gpio_tools.py index 0e852d9..432bd05 100644 --- a/packages/embeddedci-mcp/tests/test_gpio_tools.py +++ b/packages/embeddedci-mcp/tests/test_gpio_tools.py @@ -20,7 +20,8 @@ def test_la_pins_reports_every_channel_with_levels(connected): result = call("la_pins") assert [p["la"] for p in result["pins"]] == list(range(1, 15)) assert _pin(result, 1) == {"la": 1, "function": "none", "gpio": None, "level": None, - "pull": "up", "pull_ohms": "4.7k", "pull_on": False, "in_use": False} + "pull": "up", "pull_ohms": "4.7k", "pull_on": False, "in_use": False, + "dir": None, "warnings": None} assert _pin(result, 7)["pull"] == "down" and _pin(result, 12)["pull"] is None assert _pin(result, 14)["pull"] is None assert {lv["la"]: lv["level"] for lv in result["levels"]}[11] == 1 @@ -52,7 +53,8 @@ def test_gpio_mode_claims_channels_in_one_command(connected): result = call("gpio_mode", la=[3, 4], mode="output", level=1) assert [p["la"] for p in result["pins"]] == [3, 4] assert result["pins"][0] == {"la": 3, "function": "gpio", "gpio": "output", "level": 1, - "pull": "up", "pull_ohms": "2.2k", "pull_on": False, "in_use": True} + "pull": "up", "pull_ohms": "2.2k", "pull_on": False, "in_use": True, + "dir": None, "warnings": None} assert connected.requests[-1] == {"cmd": "gpio", "la": [3, 4], "mode": "output", "level": 1} diff --git a/packages/embeddedci-mcp/tests/test_tools.py b/packages/embeddedci-mcp/tests/test_tools.py index f0f6f23..03ace47 100644 --- a/packages/embeddedci-mcp/tests/test_tools.py +++ b/packages/embeddedci-mcp/tests/test_tools.py @@ -20,6 +20,7 @@ "measure_power", "power_profile_start", "power_profile_stop", "la_pins", "gpio_mode", "gpio_write", "gpio_read", "gpio_wait", "gpio_pulse", "gpio_release", "flash", "spi_flash_info", "spi_flash_program", "spi_flash_read", "spi_transfer", + "i2c_open", "i2c_transfer", "i2c_scan", "i2c_close", "la_dir", "capture_uart", "power_cycle_and_capture", "uart_open", "uart_write", "uart_read", "uart_close", "sensor_library", "sensor_show", "sensor_format", "sensor_validate", "sensor_start", "sensor_set", "sensor_stimulus_load", "sensor_stimulus_start", "sensor_stimulus_stop", diff --git a/packages/embeddedci-mcp/tests/tools_surface.json b/packages/embeddedci-mcp/tests/tools_surface.json index 73baed6..75a17d9 100644 --- a/packages/embeddedci-mcp/tests/tools_surface.json +++ b/packages/embeddedci-mcp/tests/tools_surface.json @@ -852,6 +852,111 @@ "title": "Capture logic channels" }, "input": { + "$defs": { + "SendSpec": { + "description": "One transaction capture_la runs right after it arms (gateware v55+).", + "properties": { + "addr": { + "anyOf": [ + { + "maximum": 127, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "i2c: 7-bit address.", + "title": "Addr" + }, + "bus": { + "enum": [ + "i2c", + "spi", + "gpio" + ], + "title": "Bus", + "type": "string" + }, + "la": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "gpio: an LA channel already a GPIO output.", + "title": "La" + }, + "level": { + "anyOf": [ + { + "enum": [ + 0, + 1 + ], + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "gpio: the level to set.", + "title": "Level" + }, + "read": { + "default": 0, + "description": "i2c: bytes to read after the write.", + "maximum": 32, + "minimum": 0, + "title": "Read", + "type": "integer" + }, + "spi_hz": { + "default": 1000000, + "description": "spi: SCK rate.", + "maximum": 6000000, + "minimum": 190000, + "title": "Spi Hz", + "type": "integer" + }, + "spi_mode": { + "default": 0, + "description": "spi: mode 0-3.", + "maximum": 3, + "minimum": 0, + "title": "Spi Mode", + "type": "integer" + }, + "tx_hex": { + "default": "", + "description": "spi: one CS frame, hex (1-32 bytes); SPI pins from the wiring.", + "title": "Tx Hex", + "type": "string" + }, + "write_hex": { + "default": "", + "description": "i2c: bytes to write, hex (up to 32).", + "title": "Write Hex", + "type": "string" + } + }, + "required": [ + "bus" + ], + "title": "SendSpec", + "type": "object" + } + }, "properties": { "sample_rate_hz": { "anyOf": [ @@ -873,6 +978,18 @@ "title": "Samples", "type": "integer" }, + "send": { + "anyOf": [ + { + "$ref": "#/$defs/SendSpec" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Run one transaction right after the capture arms (gateware v55+): i2c (the i2c_open session, else one from the wiring), spi (SPI pins from the wiring) or gpio (a GPIO output). Without trigger_la it triggers on SDA / CS falling or the gpio pin's edge." + }, "stop_dac_after": { "anyOf": [ { @@ -2243,6 +2360,236 @@ "type": "object" } }, + "i2c_close": { + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Close the I2C master" + }, + "input": { + "properties": {}, + "title": "i2c_closeArguments", + "type": "object" + } + }, + "i2c_open": { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Open the I2C master" + }, + "input": { + "properties": { + "hz": { + "default": 100000, + "description": "SCL rate.", + "maximum": 1000000, + "minimum": 10000, + "title": "Hz", + "type": "integer" + }, + "pullups": { + "default": "pod", + "description": "pod: LA1-LA6 with the pod's pull-ups on (set_pull first, LA voltage 3.3); external: the board has its own.", + "enum": [ + "pod", + "external" + ], + "title": "Pullups", + "type": "string" + }, + "scl": { + "anyOf": [ + { + "anyOf": [ + { + "maximum": 14, + "minimum": 1, + "type": "integer" + }, + { + "type": "string" + } + ], + "description": "LA channel 1-14, or a name from the wiring profile \u2014 a signal ('READY') or a role ('uart_rx', 'i2c_sda', 'swd_swclk'). Call `wiring` to see the names." + }, + { + "type": "null" + } + ], + "default": null, + "description": "SCL channel; omit for the profile's i2c_scl.", + "title": "Scl" + }, + "sda": { + "anyOf": [ + { + "anyOf": [ + { + "maximum": 14, + "minimum": 1, + "type": "integer" + }, + { + "type": "string" + } + ], + "description": "LA channel 1-14, or a name from the wiring profile \u2014 a signal ('READY') or a role ('uart_rx', 'i2c_sda', 'swd_swclk'). Call `wiring` to see the names." + }, + { + "type": "null" + } + ], + "default": null, + "description": "SDA channel; omit for the profile's i2c_sda.", + "title": "Sda" + } + }, + "title": "i2c_openArguments", + "type": "object" + } + }, + "i2c_scan": { + "annotations": { + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Scan the I2C bus" + }, + "input": { + "properties": { + "end": { + "default": 119, + "maximum": 127, + "minimum": 0, + "title": "End", + "type": "integer" + }, + "start": { + "default": 8, + "maximum": 127, + "minimum": 0, + "title": "Start", + "type": "integer" + } + }, + "title": "i2c_scanArguments", + "type": "object" + } + }, + "i2c_transfer": { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "I2C transfer" + }, + "input": { + "properties": { + "addr": { + "description": "7-bit address.", + "maximum": 127, + "minimum": 0, + "title": "Addr", + "type": "integer" + }, + "read": { + "default": 0, + "description": "Bytes to read after a repeated START.", + "maximum": 248, + "minimum": 0, + "title": "Read", + "type": "integer" + }, + "write_hex": { + "default": "", + "description": "Bytes to write, hex (up to 128), e.g. a register.", + "title": "Write Hex", + "type": "string" + } + }, + "required": [ + "addr" + ], + "title": "i2c_transferArguments", + "type": "object" + } + }, + "la_dir": { + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Set pin directions" + }, + "input": { + "properties": { + "dir": { + "anyOf": [ + { + "enum": [ + "dut_input", + "dut_output", + "bidir", + "unknown" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which way the DUT signal goes. On dut_output the pod refuses push-pull drivers.", + "title": "Dir" + }, + "from_wiring": { + "default": false, + "description": "Send the wiring profile's directions instead (uart_rx, spi_miso and input signals are DUT outputs).", + "title": "From Wiring", + "type": "boolean" + }, + "la": { + "anyOf": [ + { + "items": { + "anyOf": [ + { + "maximum": 14, + "minimum": 1, + "type": "integer" + }, + { + "type": "string" + } + ], + "description": "LA channel 1-14, or a name from the wiring profile \u2014 a signal ('READY') or a role ('uart_rx', 'i2c_sda', 'swd_swclk'). Call `wiring` to see the names." + }, + "type": "array" + }, + { + "const": "all", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Channels or wiring names, or 'all'. Omit with dir to list every pin.", + "title": "La" + } + }, + "title": "la_dirArguments", + "type": "object" + } + }, "la_pins": { "annotations": { "idempotentHint": false, @@ -4069,8 +4416,11 @@ }, "mode": { "default": 0, + "description": "SPI mode; 1 and 2 need gateware v55+.", "enum": [ 0, + 1, + 2, 3 ], "title": "Mode", @@ -4217,8 +4567,11 @@ }, "mode": { "default": 0, + "description": "SPI mode; 1 and 2 need gateware v55+.", "enum": [ 0, + 1, + 2, 3 ], "title": "Mode", @@ -4366,8 +4719,11 @@ }, "mode": { "default": 0, + "description": "SPI mode; 1 and 2 need gateware v55+.", "enum": [ 0, + 1, + 2, 3 ], "title": "Mode", @@ -4493,8 +4849,11 @@ }, "mode": { "default": 0, + "description": "SPI mode; 1 and 2 need gateware v55+.", "enum": [ 0, + 1, + 2, 3 ], "title": "Mode", diff --git a/packages/embeddedci/CHANGELOG.md b/packages/embeddedci/CHANGELOG.md index 8b4ad72..3891af8 100644 --- a/packages/embeddedci/CHANGELOG.md +++ b/packages/embeddedci/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog — `embeddedci` +## 2.13.13 + +- The pod as a bus master (gateware v55+). `bp.open_i2c(sda, scl, *, hz=100_000, pullups="pod")` + returns an `I2CMaster`: `write()`, `read()`, `write_read()`, `read_reg()`, `write_reg()`, + `probe()`, `scan()`, `xfer()` (an `I2CResult`) and `close()`, also as a context manager. A NACK + raises `I2CNackError` with `addr` and `nack_at`. Pins take wiring names. +- `open_spi()` takes modes 1 and 2 on gateware v55+; older gateware raises + `UnsupportedFeatureError` before anything is sent. +- Send and capture: `capture_la(..., send=I2CSend(...) | SpiSend(...) | GpioSend(...))` runs one + transaction right after the capture arms, in the same command (over the cloud too). The result + is `LaCapture.send_result` (a `SendResult`). With an edge trigger and no `pre_samples`, a send + keeps 64 samples from before the trigger, so the decoder sees the first START. +- Pin directions: `bp.la_dir(la, dir)`, `bp.pin_directions()` (from the wiring profile) and + `bp.apply_pin_directions()`. A push-pull driver on a DUT output raises + `DirectionConflictError`. `LaPinState` has `dir`, `warn` and `warnings`. +- `Capabilities.gateware` (the running gateware version) and `Capabilities.bus_master`. +- Pod refusals about the I2C master and pull-ups name the SDK call (`bp.open_i2c()`, + `bp.enable_pullup(1)`); "needs gateware vNN" refusals raise `UnsupportedFeatureError`. + ## 2.13.12 - `Sensor.capture()` / `i2c_transactions()` (and `SensorEmulation.capture()`) wait for the DUT's diff --git a/packages/embeddedci/README.md b/packages/embeddedci/README.md index 9db3afd..e09a465 100644 --- a/packages/embeddedci/README.md +++ b/packages/embeddedci/README.md @@ -689,7 +689,8 @@ print(res.artifact["name"], res.artifact.get("sha")) # which file was fl ## SPI flash and SPI devices Pods with gateware v45+ (`bp.capabilities.spi_master`) run an SPI master on any four LA pins, up -to 6 MHz, modes 0 and 3. Wire a 25-series SPI NOR flash (W25Q, MX25, GD25, ...) with its SCK, DI, +to 6 MHz, modes 0 and 3. Gateware v55+ also runs modes 1 and 2 (`open_spi(mode=1)`); on older +gateware they raise `UnsupportedFeatureError` before anything is sent. Wire a 25-series SPI NOR flash (W25Q, MX25, GD25, ...) with its SCK, DI, DO and CS on LA channels (power it at the LA voltage, tie /WP and /HOLD high) and program it: ```python @@ -718,6 +719,44 @@ into the pod's PSRAM first, then clocked out 512 bytes at a time (an FPGA bitstr slave-SPI configuration port). It needs a LAN or cloud connection and firmware with the `spi_stream` command (`bp.capabilities.spi_stream`). +## I2C master + +Pods with gateware v55+ (`bp.capabilities.gateware`, `bp.capabilities.bus_master`) are an I2C master +on any two LA pins, 10 kHz to 1 MHz: read and write registers of a chip on the DUT's board, or scan +the bus. The pins are open drain, so the bus needs pull-ups: the pod's own on LA1-LA6 (switch them +on first; they pull to 3.3 V, so the LA voltage must be 3.3 V), or the board's with +`pullups="external"`. + +```python +from embeddedci.benchpod import I2CNackError + +bp.enable_pullup(1, 2) # SDA, SCL on LA1, LA2 +with bp.open_i2c(hz=400_000) as i2c: # pins from the wiring profile's i2c_sda, i2c_scl + print([hex(a) for a in i2c.scan()]) # [0x76] + chip_id = i2c.read_reg(0x76, 0xD0)[0] # write 0xD0, repeated START, read 1 byte + i2c.write_reg(0x76, 0xF4, b"\x27") + raw = i2c.write_read(0x76, b"\xf7", 6) + assert i2c.probe(0x76) + try: + i2c.read(0x50, 2) + except I2CNackError as exc: # exc.addr, exc.nack_at (0 = the address) + print(exc) + r = i2c.xfer(0x50, b"\x00", read=2) # the I2CResult, without raising + print(r.ack, r.nack_at, r.data) +``` + +`open_i2c(sda=None, scl=None, *, hz=100_000, pullups="pod")` takes channels or wiring names. A +transfer writes up to 128 bytes and reads up to 248; `read_reg` and `write_reg` take a register +as an int (one byte) or as bytes (a wider address). `scan(start=0x08, end=0x77)` lists the +addresses that answer. The session's `hz` is the rate the pod runs; a real bus runs somewhat +slower, because the master waits for SCL to read high (which lets a target stretch the clock), so +the SCL rise time adds to each period: with the pod's pull-ups on a short cable, 400 kHz measured +about 333 kHz and 1 MHz about 774 kHz. Stronger pull-ups bring it closer. `i2c.status()` is the +pod's view of the session. + +SWD, SPI and I2C sessions share one engine in the pod, so they exclude each other. Every command +fits one cloud frame: this works over the LAN, USB and the cloud alike. + ## Motor & battery emulator The BenchPod motor emulator boards (ECP5, up to four on one SPI link) are driven with @@ -867,6 +906,35 @@ Captures observe every channel whatever its function. GPIO channels stay GPIO disconnects — until released (the pytest `benchpod` fixture releases them at session start and end), and the LA voltage can't change while any channel is in use. +**Pin directions.** `bp.la_dir(la, dir)` tells the pod which way a pin's DUT signal goes: +`dut_input`, `dut_output`, `bidir` or `unknown` (the default after a reboot). On a `dut_output` pin +the pod refuses every push-pull driver (a GPIO `output`, UART TX, SPI SCK / MOSI / CS, SWD, ...) +with `DirectionConflictError` (`.la`), because two push-pull outputs would drive against each +other; open drain and inputs still work, and there is no override. A pin that is driven can't be +marked `dut_output` until it is released. + +```python +from embeddedci.benchpod import DirectionConflictError + +bp.la_dir([9, 10], "dut_output") +try: + bp.gpio(9, "output") +except DirectionConflictError: # use "open_drain", or fix the wiring + bp.gpio(9, "open_drain") + +bp.apply_pin_directions() # the wiring profile's directions, in one go +print(bp.pin_directions()) # {5: 'dut_output', 4: 'dut_input', 1: 'bidir', ...} +print(bp.la_dir()) # every pin's LaPinState, dir included +``` + +`pin_directions()` reads them from the wiring profile: `uart_rx` and `spi_miso` are DUT outputs, +`uart_tx`, `spi_sclk`, `spi_mosi` and `spi_cs` DUT inputs, the I2C pins `bidir`; a signal with +direction `input` (the pod watches it) is a DUT output, `output` a DUT input, `open_drain` and +`bidir` are `bidir`. The pod forgets the directions on a reboot, so send them after connecting. +`LaPinState.dir` is a pin's direction (`None` when unknown), and `LaPinState.warn` holds warning +bits that `LaPinState.warnings` spells out: a pull-up under a push-pull output, and a pin driven high +or pulled up while the target power is off (it powers an unpowered DUT through its I/O pins). + **Bias resistors must suit the function** — the pod refuses the combination with `PullConflictError`, whichever you set first: @@ -1379,6 +1447,39 @@ Above 12 MS/s (gateware v50+) the pod records only the changes, at up to about 6 for all channels together. A signal that changes faster for long ends such a capture early: it then holds the samples up to that point and `truncated` says why (`"busy"` or `"memory"`). +### Send and capture + +`capture_la(..., send=...)` (gateware v55+) runs one transaction right after the capture arms, in the +same command, so a trigger on the bus line catches it. It works over the cloud too, which runs one +command at a time. Trigger on the line the transaction starts with: SDA falling (I2C START), CS +falling (SPI), or the GPIO pin's edge. + +```python +from embeddedci.benchpod import GpioSend, I2CSend, SpiSend, Trigger + +bp.enable_pullup(1, 2) +with bp.open_i2c(hz=400_000) as i2c: + cap = bp.capture_la(8192, sample_rate_hz=12_000_000, trigger=Trigger("i2c_sda", "falling"), + send=i2c.send(0x76, b"\xd0", read=1)) # the same as I2CSend(0x76, b"\xd0", 1) +print(cap.send_result) # SendResult(bus='i2c', ack=True, data=b'X', ...) +print(bp.decode(cap, "i2c", sda=1, scl=2)) # the transfer as the bus saw it + +with bp.open_spi(mode=1) as spi: + cap = bp.capture_la(4096, trigger=Trigger("spi_cs", "falling"), send=SpiSend(b"\x9f\x00")) + +bp.gpio(6, "output", level=1) +cap = bp.capture_la(1024, trigger=Trigger(6, "falling"), send=GpioSend(6, 0)) +``` + +`I2CSend(addr, write=None, read=0)` needs an open `open_i2c` session and writes and reads up to 32 +bytes; `SpiSend(tx)` needs an open `open_spi` session and sends 1-32 bytes in one CS frame; +`GpioSend(la, level)` sets a pin that is already a GPIO `output` or `open_drain`. The capture's +`send_result` is a `SendResult`: `bus`, `ack` and `nack_at` (I2C), `data` (the bytes read, or clocked +in on MISO), `la` and `level` (GPIO), `error` (`""`, or why the transfer failed, for example +`"SCL held low"`; the capture still returns) and `ok`. A send that does not validate (no session) +raises before anything is armed. With an edge trigger and no `pre_samples`, a send keeps 64 samples +from before the trigger, so the decoder sees the first START. + ## DAC: generator, replay and faults Every DAC output keeps running until stopped. The returned `DacHandle` / `ReplayHandle` is a @@ -1804,6 +1905,8 @@ Invalid arguments raise `ValueError` before anything is sent. Everything else ra | `UartTimeout` | `UartSession.expect` timed out | `text` | | `UartLinkError` (a `UartTimeout` and `ConnectionClosedError`) | the UART link died with an error while `UartSession` waited | `text`, `cause` | | `PinConflictError` | an LA channel is already used by another function (a `FirmwareError`) | `la`, `function` | +| `DirectionConflictError` | a push-pull driver on a pin `la_dir` marks as a DUT output (a `FirmwareError`) | `la` | +| `I2CNackError` | an I2C device did not acknowledge (`I2CMaster.write`, `read`, `write_read`, `read_reg`, `write_reg`) | `addr`, `nack_at` | | `PullConflictError` | an engaged bias resistor can't work with the channel's function (a `FirmwareError`) | `la` | | `TriggerTimeout` | a triggered capture's condition never happened (a `FirmwareError`) | `la`, `edge` | | `CanTimeout` | `CanBus.expect` timed out | `frames` | @@ -1838,12 +1941,13 @@ lease_wait=600.0, lease_ttl=120, wiring=None)`. A context manager; `close()` is | LA voltage | `set_la_voltage(volts)`, `get_la_voltage()` | | Power and reset | `power_on(efuse=None, *, delay=None)`, `power_off(efuse=None, *, delay=None)`, `target_power(efuse=None, *, on, delay=None)`, `target_status()`, `power_status()`, `measure_power(duration, *, efuse=None, rate_hz=500.0, keep_samples=0)`, `power_profile(*, efuse=None, rate_hz=500.0, keep_samples=4096, max_duration=60.0)`, `reset_target(*, pulse=0.1)`, `set_reset(asserted)`, `reset_state()` | | Flashing | `flash(...)`, `spi_flash(image, addr=0, *, erase=True, verify=True, hold_reset=False, sck=, mosi=, miso=, cs=)`, `open_spi(...)`, `open_motor_emulator(...)` | +| I2C master | `open_i2c(sda=None, scl=None, *, hz=100_000, pullups="pod")` (see [I2C master](#i2c-master)) | | UART | `capture_uart(...)`, `power_cycle_and_capture(...)`, `open_uart(...)` | | Bias resistors | `enable_pullup(*las)`, `disable_pullup(*las)`, `enable_pulldown(*las)`, `disable_pulldown(*las)` (LA7/LA8), `set_pull(la, enabled)`, `pull_state(la)`, `enabled_pulls()` | -| GPIO and pins | `gpio(la, mode="output", *, level=None)`, `gpio_pins(las, mode="output", *, level=None)`, `configure_gpio(las, mode="output", *, level=None)`, `set_gpio(la, level)`, `read_gpio(la)`, `wait_for_level(la, level, *, timeout, poll=0.005)` (`False` when `timeout` passes first), `pin_levels()`, `la_pins()`, `release_gpio(*las)`, `la_step(la, *, steps, delay, dir_la=None, direction=0)` | +| GPIO and pins | `gpio(la, mode="output", *, level=None)`, `gpio_pins(las, mode="output", *, level=None)`, `configure_gpio(las, mode="output", *, level=None)`, `set_gpio(la, level)`, `read_gpio(la)`, `wait_for_level(la, level, *, timeout, poll=0.005)` (`False` when `timeout` passes first), `pin_levels()`, `la_pins()`, `la_dir(la=None, dir=None)`, `pin_directions(wiring=None)`, `apply_pin_directions(wiring=None)`, `release_gpio(*las)`, `la_step(la, *, steps, delay, dir_la=None, direction=0)` | | Emulated sensors | `sensors` (a `SensorEmulation`: `start(definition, *, instance=None, pins=None, address=None, baud=None, values=None)`, `define()`, `validate()`, `set()`, `fault()`, `clear_fault()`, `load_stimulus()`, `start_stimulus()`, `stop_stimulus()`, `stop()`, `status()`, `regs()`, `capture()`, `i2c_transactions()`, `library()`); see [Emulated sensors](#emulated-sensors) | | Analog | `analog_path(path, *, range=None)`, `adc_read(source="ext", *, range=None)`, `adc_range(range=None)`, `adc_range_test()`, `dac_output(path, *, volts=None)`, `current_out(current)`, `current_out_range()`, `calibrate(source="current_in")`, `calibration()`, `clear_calibration(part=None)`, `calibrate_dac(path, points)`, `calibrate_adc_gain(source, *, range="normal")`, `DMM_SET_POINTS` | -| Captures | `capture_adc(...)`, `capture_la(...)`, `capture_correlated(...)`, `decode(source, protocol="i2c", ...)` | +| Captures | `capture_adc(...)`, `capture_la(..., send=None)`, `capture_correlated(...)`, `decode(source, protocol="i2c", ...)` | | DAC | `generate(...)`, `replay(...)`, `replay_waveform(...)`, `dac_stop()`, `control_loop(...)`, `loop_input(...)`, `loop_probe()` | | CAN | `open_can(*, bitrate=500000, mode="normal", term=False, fd=False, data_bitrate=None)`, `can_config(...)`, `can_disable()`, `can_status()`, `can_term(on)`, `can_write(...)`, `can_read(...)`, `can_respond(...)`, `can_respond_clear()` | | Cloud library and server | `waveforms`, `save_capture_as_recording(...)`, `server_api` (the `ServerApi`; needs an API key or a cloud session) | @@ -1868,10 +1972,14 @@ few built from other shapes); `to_dict()` goes the other way where a type is sen | `LanTls` | `lan_tls`, `set_lan_tls_https_only`, `regenerate_lan_tls`, `set_lan_tls_cert` | `port`, `https_only`, `sessions`, `origin` (`generated`/`user`), `key`, `subject`, `san`, `not_after`, `sha256` (what clients pin), `error` (no certificate) | | `UsbCcStatus` | `usb_cc` | `orientation`, `advertised`, `advertised_current` (A), `cc1_voltage`, `cc2_voltage` (V) | | `PullState` | `pull_state`, `set_pull` | `la`, `enabled`, `direction`, `ohms`, `available` | -| `LaPinState` | `la_pins`, `configure_gpio` | `la`, `function`, `gpio`, `level`, `pull`, `pull_ohms`, `pull_on`, `in_use` | +| `LaPinState` | `la_pins`, `configure_gpio`, `la_dir` | `la`, `function`, `gpio`, `level`, `pull`, `pull_ohms`, `pull_on`, `dir`, `warn`, `warnings`, `in_use` | | `GpioPin` (class) | `gpio`, `gpio_pins`, `signal` | `name`, `configure()`, `set(level)`, `high()`, `low()`, `activate()`, `deactivate()`, `is_active()`, `read()`, `wait_for(level, *, timeout)`, `pulse(width, *, count=1)`, `state()`, `release()` | | `FlashResult` | `flash` | `ok`, `returncode`, `stdout`, `stderr`, `target_unreachable`, `stalled`, `artifact` (the uploaded build, for `flash(build=...)`) | | `SpiSession` (class) | `open_spi` | `flash_id()`, `flash_read(addr, length)`, `flash_erase(addr, length)`, `flash_chip_erase()` (whole chip; seconds taken), `flash_write(addr, data)`, `flash_program(data, addr=0, *, erase=True, verify=True)` (erase, write and verify a whole image), `transfer(data)`, `stream(data, *, head=b"", hold_cs=False)`, `close()` | +| `I2CMaster` (class) | `open_i2c` (a context manager) | `sda`, `scl`, `hz`, `pullups`, `xfer(addr, write=None, read=0)`, `write(addr, data)`, `read(addr, n)`, `write_read(addr, data, n)`, `read_reg(addr, reg, n=1)`, `write_reg(addr, reg, data)`, `probe(addr)`, `scan(start=0x08, end=0x77)`, `send(addr, write=None, read=0)` (an `I2CSend`), `status()`, `close()` | +| `I2CResult` | `I2CMaster.xfer` | `addr`, `ack`, `data`, `nack_at`, `check()` (raises `I2CNackError` on a NACK) | +| `I2CSend`, `SpiSend`, `GpioSend` | `capture_la(send=)` | `addr`, `write`, `read`; `tx`; `la`, `level`; `to_request()` | +| `SendResult` | `LaCapture.send_result` | `bus`, `ack`, `data`, `nack_at`, `la`, `level`, `error`, `ok` | | `SpiFlashInfo` | `SpiSession.flash_id` | `jedec_id`, `present`, `size`, `status` | | `SpiFlashResult` | `spi_flash`, `SpiSession.flash_program` | `jedec_id`, `addr`, `length`, `erased`, `verified`, `seconds` | | `SpiStreamResult` | `SpiSession.stream` | `sent`, `seconds` | @@ -1896,7 +2004,7 @@ few built from other shapes); `to_dict()` goes the other way where a type is sen | `Calibration` | `calibrate`, `calibration`, `clear_calibration`, `calibrate_dac`, `calibrate_adc_gain` | `source`, `calibrated`, `offset`, `a`, `b`, `count`, `span`, `samples`, `dac`, `adc_gain_ppm`, `adc_low_gain_ppm`, `dmm`, `measured`, `zero_count` | | `DacLine` | `Calibration.dac` | `a`, `b`, `offset`, `gain_ppm` | | `Capture` | `capture_adc` | `counts`, `volts`, `currents`, `sample_rate_hz`, `source`, `trigger`, `range`, `overrange`, `clipped_samples`, `duration`, `times()`, `mean()`, `min()`, `max()`, `rms()`, `rms_ac()`, `peak_to_peak()`, `first_crossing()`, `crossing_times()`, `fft()`, `dominant_frequency()` | -| `LaCapture` | `capture_la` | `words`, `channels`, `sample_rate_hz`, `trigger`, `duration`, `channel(la)`, `level_at()`, `edges()`, `edge_times()`, `first_edge()`, `delay()`, `frequency()`, `duty_cycle()`, `pulse_widths()`, `decode()` | +| `LaCapture` | `capture_la` | `words`, `channels`, `sample_rate_hz`, `trigger`, `send_result`, `duration`, `channel(la)`, `level_at()`, `edges()`, `edge_times()`, `first_edge()`, `delay()`, `frequency()`, `duty_cycle()`, `pulse_widths()`, `decode()` | | `CorrelatedCapture` | `capture_correlated` | `adc` (a `Capture`), `la` (a `LaCapture`), sharing one hardware trigger | | `Trigger` | `capture_adc`, `capture_la`, `capture_correlated` | `la`, `edge` | | `DacHandle` (class) | `generate` (a context manager) | `stop()` | diff --git a/packages/embeddedci/pyproject.toml b/packages/embeddedci/pyproject.toml index 2d9689b..cabc198 100644 --- a/packages/embeddedci/pyproject.toml +++ b/packages/embeddedci/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "embeddedci" -version = "2.13.12" +version = "2.13.13" description = "BenchPod SDK and pytest plugin for EmbeddedCI hardware-in-the-loop testing" readme = "README.md" requires-python = ">=3.10" diff --git a/packages/embeddedci/src/embeddedci/benchpod/__init__.py b/packages/embeddedci/src/embeddedci/benchpod/__init__.py index 8b3f0a7..403920a 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/__init__.py +++ b/packages/embeddedci/src/embeddedci/benchpod/__init__.py @@ -101,6 +101,7 @@ def test_boots(benchpod_target, firmware): # fixtures from the pytest plugin ConnectionClosedError, ConnectionConfigError, DeviceBusyError, + DirectionConflictError, FirmwareError, FlashError, PermissionDeniedError, @@ -121,6 +122,7 @@ def test_boots(benchpod_target, firmware): # fixtures from the pytest plugin from .flash import FlashResult from .gpio import GpioPin, LaPinState from .i2c import I2CByte, I2CMessage, I2CTransaction +from .i2c_master import GpioSend, I2CMaster, I2CNackError, I2CResult, I2CSend, SendResult, SpiSend from .lease import DeviceLease from .lowlevel import LowLevel from .power import PowerProfile, PowerProfileSession @@ -214,6 +216,14 @@ def test_boots(benchpod_target, firmware): # fixtures from the pytest plugin "I2CByte", "I2CMessage", "I2CTransaction", + "I2CMaster", + "I2CResult", + "I2CNackError", + # send and capture + "I2CSend", + "SpiSend", + "GpioSend", + "SendResult", # capture / analysis "decode", "dsp", @@ -326,6 +336,7 @@ def test_boots(benchpod_target, firmware): # fixtures from the pytest plugin "CanTimeout", "PinConflictError", "PullConflictError", + "DirectionConflictError", "TriggerTimeout", "UnsupportedFeatureError", # device lease diff --git a/packages/embeddedci/src/embeddedci/benchpod/capabilities.py b/packages/embeddedci/src/embeddedci/benchpod/capabilities.py index 1911236..863bba8 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/capabilities.py +++ b/packages/embeddedci/src/embeddedci/benchpod/capabilities.py @@ -83,6 +83,9 @@ def _as_bool(m: Mapping[str, Any], *keys: str) -> Optional[bool]: #: Boolean capabilities the firmware's ``status`` reports as top-level ``true`` fields (and the #: server as ``cap.``), command_handler.c handle_status / cloud_client.c. +#: The first gateware with the I2C master, SPI modes 1 and 2 and send-and-capture. +BUS_MASTER_GATEWARE = 55 + _STATUS_FLAGS = ("ota_sig", "sig_policy_cmd", "lan_policy_cmd", "tunnel_max_tier", "lease_state", "cloud_ca", "cloud_proxy", "blob_slots", "can_fd") @@ -181,6 +184,8 @@ class Capabilities: #: ``spi_stream``: send an upload staged in PSRAM through the SPI master in one CS frame #: (an FPGA bitstream): :meth:`SpiSession.stream `. spi_stream: bool = False + #: The running gateware version (``status`` ``gateware``); 0 when not reported. + gateware: int = 0 #: The pod can calibrate its own ADC and keeps the result: :meth:`BenchPod.calibrate`. calibrate: bool = False #: ``calibrate`` also takes the DMM-guided forms (firmware 3.9.4+): this pod's own DAC line @@ -297,6 +302,8 @@ def from_status(cls, status: Mapping[str, Any]) -> "Capabilities": c.analog = v if (v := _as_int(status, "flash_kb")) is not None and v > 0: c.flash_kb = v + if (v := _as_int(status, "gateware")) is not None and v > 0: + c.gateware = v if (v := _as_int(status, "sensors_format")) is not None and v > 0: c.sensors_format = v if status.get("sensors_digital") is True: @@ -431,6 +438,8 @@ def from_parameters(cls, params: Mapping[str, Any]) -> "Capabilities": c.analog = _as_bool(params, "cap.analog") if (v := _as_int(params, "cap.flash_kb")) is not None and v > 0: c.flash_kb = v + if (v := _as_int(params, "cap.gateware")) is not None and v > 0: + c.gateware = v if (v := _as_int(params, "cap.sensors_format")) is not None and v > 0: c.sensors_format = v if (v := _as_int(params, "cap.sig_keys")) is not None and v > 0: @@ -490,6 +499,11 @@ def boot_warning(self) -> Optional[str]: # -- scaling ------------------------------------------------------------ + @property + def bus_master(self) -> bool: + """The I2C master, SPI modes 1 and 2 and send-and-capture (gateware v55+).""" + return self.gateware >= BUS_MASTER_GATEWARE + @property def adc_max_count(self) -> int: if not 1 <= self.adc_bits <= 24: diff --git a/packages/embeddedci/src/embeddedci/benchpod/capture.py b/packages/embeddedci/src/embeddedci/benchpod/capture.py index 297281d..370cc22 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/capture.py +++ b/packages/embeddedci/src/embeddedci/benchpod/capture.py @@ -21,6 +21,7 @@ from .capabilities import ADC_WRAP_SPAN, ADC_WRAP_THRESHOLD, Capabilities from .errors import BenchPodError +from .i2c_master import SendResult from .results import Capture, CorrelatedCapture, LaCapture, Trigger _log = logging.getLogger(__name__) @@ -217,9 +218,12 @@ def capture_adc_oversampled(transport: Any, caps: Capabilities, *, samples: int def capture_la(transport: Any, *, samples: int = 4096, sample_rate_hz: Optional[float] = None, stop_dac_after: Optional[float] = None, trigger: Optional[Trigger] = None, - trigger_timeout: float = 10.0) -> LaCapture: + trigger_timeout: float = 10.0, send: Optional[dict] = None) -> LaCapture: """Capture ``samples`` raw 14-channel LA words and return a :class:`LaCapture`. + ``send`` is a resolved ``send`` request (``{"bus":"i2c",...}``): the pod runs it right after + the capture arms, and its result lands on :attr:`LaCapture.send_result`. + ``stop_dac_after`` (seconds) auto-stops a concurrently-running DAC that far into the capture — the iCE40 cuts the DAC at exactly that offset from the capture's hardware t0 (sample-precise), so the captured window shows the target reacting to the output dropping. No-op on @@ -233,13 +237,18 @@ def capture_la(transport: Any, *, samples: int = 4096, sample_rate_hz: Optional[ if stop_us > 0: req["stop_dac_after_us"] = stop_us _apply_trigger(req, trigger, trigger_timeout) + if send is not None: + req["send"] = dict(send) dense: List[int] = [] edges: List[List[int]] = [] upto = 0 rate = 0.0 pre = 0 truncated = "" + sent: Optional[SendResult] = None for chunk in _stream_or_samples(transport, req): + if isinstance(chunk.get("send"), dict): + sent = SendResult.from_reply(chunk["send"]) if chunk.get("la_rate_hz"): rate = float(chunk["la_rate_hz"]) if chunk.get("pre_samples"): @@ -261,7 +270,7 @@ def capture_la(transport: Any, *, samples: int = 4096, sample_rate_hz: Optional[ if truncated: _log.warning("capture_la stopped early (%s): %d of %d samples", truncated, len(words), samples) return LaCapture(words=words, sample_rate_hz=_rate_hz(rate, sample_rate_hz), trigger=trigger, - pre_samples=pre, truncated=truncated) + pre_samples=pre, truncated=truncated, send_result=sent) def _expand_la_edges(edges: List[List[int]], upto: int) -> List[int]: diff --git a/packages/embeddedci/src/embeddedci/benchpod/client.py b/packages/embeddedci/src/embeddedci/benchpod/client.py index 4a17eaa..327d95e 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/client.py +++ b/packages/embeddedci/src/embeddedci/benchpod/client.py @@ -40,10 +40,11 @@ from . import dsp as _dsp from . import flash as _flash from . import i2c as _i2c +from . import i2c_master as _i2c_master from . import motor_emulator as _motor_emulator from . import spi as _spi from . import uart as _uart -from .capabilities import ADC_WRAP_SPAN, ADC_WRAP_THRESHOLD, AffineCal, Capabilities +from .capabilities import ADC_WRAP_SPAN, ADC_WRAP_THRESHOLD, BUS_MASTER_GATEWARE, AffineCal, Capabilities from .constants import ( ADC_SOURCE_PATHS, ADC_GAIN_SOURCES, @@ -92,7 +93,7 @@ from .connection import resolve_connection from .errors import BenchPodError, FirmwareError, UnsupportedFeatureError, classify_firmware_error from .flash import FlashResult -from .gpio import GpioPin, LaPinState +from .gpio import LA_DIRS, GpioPin, LaPinState from .lease import DEFAULT_LEASE_TTL, DEFAULT_LEASE_WAIT, DeviceLease from .lowlevel import LowLevel from .power import PowerProfile, PowerProfileSession @@ -151,6 +152,13 @@ _log = logging.getLogger("embeddedci.benchpod") +#: The DUT direction of each wiring role (:meth:`BenchPod.pin_directions`). +_ROLE_DIRS = {"uart_rx": "dut_output", "uart_tx": "dut_input", "i2c_sda": "bidir", "i2c_scl": "bidir", + "spi_sclk": "dut_input", "spi_mosi": "dut_input", "spi_miso": "dut_output", + "spi_cs": "dut_input"} +#: A wiring signal's direction (seen from the pod) as the DUT's (:meth:`BenchPod.pin_directions`). +_SIGNAL_DIRS = {"input": "dut_output", "output": "dut_input", "open_drain": "bidir", "bidir": "bidir"} + #: Capability flags only the analog board announces: the digital-only board lacks them whatever its #: firmware (cloud_caps_hw.c). _ANALOG_FLAGS = frozenset({"scope", "dac_replay", "dac_deep_replay", "dac_control_loop", "dac_cotrig", @@ -163,6 +171,10 @@ FpgaImage.EMULATION: "sensors_digital"} _IMAGE_FEATURE = {FpgaImage.LOOP: 0x01, FpgaImage.DEEP_REPLAY: 0x02, FpgaImage.EMULATION: 0x04} +#: Pre-trigger samples a capture with a ``send`` keeps, so the decoder sees the first START (an +#: edge trigger fires on that START itself; without them the first message is lost). +_SEND_PRE_SAMPLES = 64 + #: Seconds to wait for the server's cached capabilities to follow an image switch. _SERVER_CAPS_WAIT = 15.0 @@ -475,6 +487,15 @@ def _require_capability(self, flag: str, what: str) -> None: f"this pod runs firmware {fw})", feature=flag, firmware_version=self.capabilities.firmware_version) + def _require_gateware(self, version: int, what: str) -> None: + """Refuse ahead when the pod reports gateware older than ``version``; when it reports none, + the pod decides.""" + gw = self.capabilities.gateware + if gw and gw < version: + raise UnsupportedFeatureError( + f"{what} need gateware v{version}+ (this pod runs v{gw}): update the pod's firmware", + feature=f"gateware_v{version}", firmware_version=self.capabilities.firmware_version) + # -- wiring profile ------------------------------------------------------- @property @@ -1566,17 +1587,64 @@ def open_spi( Omitted pins come from the wiring profile (``spi_sclk``, ``spi_mosi``, ``spi_miso``, ``spi_cs``). ``hz`` is rounded down to 24 MHz / (2 * n), n = 2..63 (6 MHz .. 190 kHz); the - session's ``hz`` is the rate used. ``mode`` is 0 or 3. Needs gateware v45+ - (capability ``spi_master``); an SWD flash cannot run while a session is open. + session's ``hz`` is the rate used. ``mode`` is 0..3 (CPOL = mode / 2, CPHA = mode % 2); + modes 1 and 2 need gateware v55+. Needs gateware v45+ (capability ``spi_master``); an SWD + flash or an I2C session cannot run while a session is open. """ self._require_capability("spi_master", "the SPI master") - if mode not in (0, 3): - raise ValueError("mode must be 0 or 3") + if isinstance(mode, bool) or mode not in (0, 1, 2, 3): + raise ValueError(f"mode must be 0, 1, 2 or 3, got {mode!r}") + if mode in (1, 2): + self._require_gateware(BUS_MASTER_GATEWARE, "SPI modes 1 and 2") req = {"cmd": "spi_start", "sck": self._wired(sck, "spi_sclk"), "mosi": self._wired(mosi, "spi_mosi"), "miso": self._wired(miso, "spi_miso"), "cs": self._wired(cs, "spi_cs"), "hz": int(hz), "mode": int(mode)} stage = getattr(self._transport, "stage_psram", None) - return _spi.SpiSession(self.command, _dict(self.command(req)), stage=stage) + with _classified_errors(): + info = _dict(self.command(req)) + return _spi.SpiSession(self.command, info, stage=stage) + + # -- I2C master -------------------------------------------------------------- + + def open_i2c( + self, + sda: Union[Pin, int, str, None] = None, + scl: Union[Pin, int, str, None] = None, + *, + hz: int = 100_000, + pullups: str = "pod", + ) -> _i2c_master.I2CMaster: + """Arm the pod's I2C master on two LA pins and return an + :class:`~embeddedci.benchpod.i2c_master.I2CMaster` (a context manager; closing it releases + the pins):: + + bp.enable_pullup(1, 2) # SDA, SCL on LA1, LA2 + with bp.open_i2c(hz=400_000) as i2c: + print([hex(a) for a in i2c.scan()]) + chip_id = i2c.read_reg(0x76, 0xD0)[0] + + Omitted pins come from the wiring profile (``i2c_sda``, ``i2c_scl``). ``hz`` is + 10 kHz..1 MHz; the session's ``hz`` is the rate the pod runs, and a real bus runs somewhat + slower because the SCL rise time adds to each period. ``pullups="pod"`` (the default) needs + both pins on LA1..LA6 with the pod's pull-ups switched on (:meth:`enable_pullup`; they pull to + 3.3 V, so the LA voltage must be 3.3 V); ``"external"`` when the board has its own. Needs + gateware v55+. SWD, SPI and I2C sessions exclude each other. + """ + check_choice(pullups, _i2c_master.PULLUPS, "pullups") + if isinstance(hz, bool) or not _i2c_master.HZ_MIN <= int(hz) <= _i2c_master.HZ_MAX: + raise ValueError(f"hz must be {_i2c_master.HZ_MIN}..{_i2c_master.HZ_MAX}, got {hz!r}") + self._require_gateware(BUS_MASTER_GATEWARE, "the I2C master") + req = {"cmd": "i2c_start", "sda": self._wired(sda, "i2c_sda"), "scl": self._wired(scl, "i2c_scl"), + "hz": int(hz), "pullups": pullups} + if req["sda"] == req["scl"]: + raise ValueError("sda and scl must be different LA pins") + with _classified_errors(): + info = _dict(self.command(req)) + return _i2c_master.I2CMaster(self._classified_command, info) + + def _classified_command(self, req: Dict[str, Any]) -> Any: + with _classified_errors(): + return self.command(req) def spi_flash( self, @@ -1737,7 +1805,8 @@ def _scale_current_in(cap: Capture, fit: Optional[Calibration]) -> Capture: def capture_la(self, samples: int = 4096, *, sample_rate_hz: Optional[float] = None, stop_dac_after: Optional[float] = None, trigger: Optional[Trigger] = None, - trigger_timeout: float = 10.0) -> LaCapture: + trigger_timeout: float = 10.0, + send: Optional[_i2c_master.Send] = None) -> LaCapture: """Capture raw 14-channel logic-analyzer words and return a :class:`LaCapture`. ``sample_rate_hz`` goes up to 12 MS/s, or 48 MS/s on gateware v50 and later @@ -1757,12 +1826,42 @@ def capture_la(self, samples: int = 4096, *, sample_rate_hz: Optional[float] = N :class:`Trigger`, gateware >= v35) makes t0 an edge or level on an LA channel — ``capture_la(..., trigger=Trigger("READY", "rising"))`` — and raises :class:`~embeddedci.benchpod.errors.TriggerTimeout` after ``trigger_timeout`` seconds without it. + + ``send`` (gateware v55+) runs one transaction right after the capture arms, in the same + command, so a trigger on the bus line catches it (over the cloud too): an + :class:`~embeddedci.benchpod.i2c_master.I2CSend` (an open :meth:`open_i2c` session), a + :class:`~embeddedci.benchpod.i2c_master.SpiSend` (an open :meth:`open_spi` session) or a + :class:`~embeddedci.benchpod.i2c_master.GpioSend` (a GPIO output). Trigger on the line it + starts with: SDA falling, CS falling, or the GPIO pin's edge. Its result is + :attr:`LaCapture.send_result`; a failed transfer is reported there and the capture still + returns. With an edge trigger and no ``pre_samples``, a send keeps 64 samples from before + the trigger, so the decoder sees the first START:: + + with bp.open_i2c() as i2c: + cap = bp.capture_la(8192, sample_rate_hz=12e6, trigger=Trigger("i2c_sda", "falling"), + send=i2c.send(0x76, b"\xd0", read=1)) + assert cap.send_result.ok + print(bp.decode(cap, "i2c", sda=1, scl=2)) """ trig = self._resolve_trigger(trigger) + send_req = None + if send is not None: + send_req = self._send_request(send) + if trig is not None and trig.edge in ("rising", "falling") and not trig.pre_samples: + trig = Trigger(trig.la, trig.edge, _SEND_PRE_SAMPLES) with _classified_errors(): return _capture.capture_la(self._transport, samples=samples, sample_rate_hz=sample_rate_hz, stop_dac_after=stop_dac_after, - trigger=trig, trigger_timeout=trigger_timeout) + trigger=trig, trigger_timeout=trigger_timeout, send=send_req) + + def _send_request(self, send: Any) -> Dict[str, Any]: + if isinstance(send, _i2c_master.GpioSend) and isinstance(send.la, str): + send = _i2c_master.GpioSend(self._resolve_la(send.la, "send la")[0], send.level) + if not isinstance(send, (_i2c_master.I2CSend, _i2c_master.SpiSend, _i2c_master.GpioSend)): + raise ValueError(f"send must be an I2CSend, SpiSend or GpioSend, got {send!r}") + req = send.to_request() + self._require_gateware(BUS_MASTER_GATEWARE, "send and capture") + return req def capture_correlated(self, *, adc_samples: int = 4096, adc_sample_rate_hz: Optional[float] = None, @@ -2512,6 +2611,62 @@ def la_pins(self) -> List[LaPinState]: data = _dict(self.command({"cmd": "la_pins"})) return [LaPinState.from_reply(e) for e in data.get("pins") or []] + def la_dir(self, la: Union[Pin, int, str, Sequence[Union[Pin, int, str]], None] = None, + dir: Optional[str] = None) -> List[LaPinState]: + """Tell the pod which way each pin's DUT signal goes, and return the changed pins' state. + + ``la`` is a channel, a wiring name, a list of them, or ``"all"``; ``dir`` is ``dut_input``, + ``dut_output``, ``bidir`` or ``unknown`` (the default after a reboot). On a ``dut_output`` + pin the pod refuses every push-pull driver (a GPIO ``output``, UART TX, SPI SCK / MOSI / + CS, SWD, ...) with :class:`~embeddedci.benchpod.errors.DirectionConflictError`; open + drain and inputs still work. A pin that is driven can't be marked ``dut_output`` until it is + released. With no arguments, it lists every pin. :meth:`apply_pin_directions` sends the + wiring profile's directions. + """ + req: Dict[str, Any] = {"cmd": "la_dir"} + if la is not None or dir is not None: + if la is None or dir is None: + raise ValueError("give both la and dir, or neither to list every pin") + check_choice(dir, LA_DIRS, "dir") + req["la"] = "all" if la == "all" else _one_or_many(self._resolve_las(la)) + req["dir"] = dir + with _classified_errors(): + data = self.command(req) + entries = data.get("pins") if isinstance(data, dict) else data + return [LaPinState.from_reply(e) for e in entries or []] if isinstance(entries, list) else [] + + def pin_directions(self, wiring: Optional[Wiring] = None) -> Dict[int, str]: + """The DUT direction of every pin the wiring profile names, as :meth:`la_dir` takes it: + ``{5: "dut_output", 4: "dut_input", 1: "bidir", ...}``. + + Roles: ``uart_rx`` and ``spi_miso`` are DUT outputs, ``uart_tx``, ``spi_sclk``, + ``spi_mosi`` and ``spi_cs`` DUT inputs, the I2C pins ``bidir``. Signals: ``input`` (the + pod watches it) is a DUT output, ``output`` a DUT input, ``open_drain`` and ``bidir`` are + ``bidir``. SWD pins are left out.""" + w = wiring if wiring is not None else self.wiring + out: Dict[int, str] = {} + for key, d in _ROLE_DIRS.items(): + la = getattr(w, key, None) + if la: + out[int(la)] = d + for sig in w.signals: + out[int(sig.la)] = _SIGNAL_DIRS.get(sig.direction, "unknown") + return out + + def apply_pin_directions(self, wiring: Optional[Wiring] = None) -> List[LaPinState]: + """Send :meth:`pin_directions` of the wiring profile to the pod (:meth:`la_dir`), one + command per direction, and return the pins' new state. The pod forgets them on a reboot; + call this after connecting or changing the wiring. Raises + :class:`~embeddedci.benchpod.errors.DirectionConflictError` when a pin the profile calls a + DUT output is driven right now.""" + by_dir: Dict[str, List[int]] = {} + for la, d in sorted(self.pin_directions(wiring).items()): + by_dir.setdefault(d, []).append(la) + out: List[LaPinState] = [] + for d, las in by_dir.items(): + out += self.la_dir(las, d) + return out + def gpio(self, la: Union[Pin, int, str], mode: GpioMode = "output", *, level: Optional[int] = None) -> GpioPin: """Use an LA channel as GPIO and return its :class:`~embeddedci.benchpod.gpio.GpioPin`. diff --git a/packages/embeddedci/src/embeddedci/benchpod/errors.py b/packages/embeddedci/src/embeddedci/benchpod/errors.py index 3494565..4e4ed26 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/errors.py +++ b/packages/embeddedci/src/embeddedci/benchpod/errors.py @@ -175,6 +175,18 @@ def __init__(self, message: str, *, cmd: Optional[str] = None, la: int = 0) -> N self.la = la +class DirectionConflictError(FirmwareError): + """The pod refused to drive a pin that :meth:`BenchPod.la_dir + ` marks as wired to a DUT output (a push-pull GPIO output, + UART TX, SPI SCK / MOSI / CS, SWD, ...), because two push-pull outputs would drive against each + other. ``la`` is the channel. Use an ``open_drain`` GPIO, or fix the wiring if the pin is not a + DUT output. There is no override.""" + + def __init__(self, message: str, *, cmd: Optional[str] = None, la: int = 0) -> None: + super().__init__(message, cmd=cmd) + self.la = la + + class TriggerTimeout(FirmwareError): """A triggered capture's condition (``edge`` on LA ``la``) never happened within its timeout.""" @@ -236,6 +248,9 @@ def __init__(self, message: str, *, cmd: Optional[str] = None, _PIN_CONFLICT = re.compile(r"pin conflict: LA(\d+) is in use by ([\w:]+)") _PULL_CONFLICT = re.compile(r"pull conflict: LA(\d+)") +_DIRECTION_CONFLICT = re.compile(r"direction conflict: LA(\d+)") +#: "the I2C master needs gateware v55+", "SPI modes 1 and 2 need gateware v55+". +_NEEDS_GATEWARE = re.compile(r"needs? gateware v\d+") _TRIGGER_TIMEOUT = re.compile(r"trigger timeout: no (\w+) \w+ on LA(\d+)") #: "busy: a cloud job holds this pod (, s left)" (command_handler.c dispatch_line). _LEASED = re.compile(r"busy: a cloud job holds this pod \((.*), (\d+) s left\)") @@ -280,6 +295,16 @@ def __init__(self, message: str, *, cmd: Optional[str] = None, "close the SPI session from bp.open_spi() first (spi.close(), or leave its with block)"), (re.compile(r'\(\{"cmd":"spi_stop"\}\)'), "(close the SPI session from bp.open_spi() first)"), + (re.compile(r'stop the I2C session first \(\{"cmd":"i2c_stop"\}\)'), + "close the I2C session from bp.open_i2c() first (i2c.close(), or leave its with block)"), + (re.compile(r"i2c busy: send i2c_stop first"), + "i2c busy: close the I2C session from bp.open_i2c() first"), + (re.compile(r"send i2c_start first"), "open one with bp.open_i2c() first"), + (re.compile(r"i2c_start again"), "open it again with bp.open_i2c()"), + (re.compile(r'switch on LA(\d+)\'s with \{"cmd":"la","la":\d+,"pullup":"on"\}'), + r"switch on LA\1's with bp.enable_pullup(\1)"), + (re.compile(r'\{"cmd":"la","la":(\d+),"pullup":"on"\}'), r"bp.enable_pullup(\1)"), + (re.compile(r'pass "pullups":"external"'), 'pass pullups="external"'), (re.compile(r'\{"cmd":"la","la":(\d+),"pullup":"off"\}'), r"bp.set_pull(\1, False)"), (re.compile(r'\{"cmd":"gpio","la":(\d+),"mode":"output"\}'), r'bp.gpio(\1, "output")'), (re.compile(r'\{"cmd":"fpga_image","image":([012])\}'), r"bp.fpga_image(\1)"), @@ -312,6 +337,11 @@ def classify_firmware_error(exc: FirmwareError) -> FirmwareError: m = _PULL_CONFLICT.search(msg) if m: return PullConflictError(msg, cmd=exc.cmd, la=int(m.group(1))) + m = _DIRECTION_CONFLICT.search(msg) + if m: + return DirectionConflictError(msg, cmd=exc.cmd, la=int(m.group(1))) + if _NEEDS_GATEWARE.search(msg): + return UnsupportedFeatureError(msg, cmd=exc.cmd, feature=exc.cmd or "") m = _TRIGGER_TIMEOUT.search(msg) if m: return TriggerTimeout(msg, cmd=exc.cmd, la=int(m.group(2)), edge=m.group(1)) diff --git a/packages/embeddedci/src/embeddedci/benchpod/gpio.py b/packages/embeddedci/src/embeddedci/benchpod/gpio.py index 617da83..376ee61 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/gpio.py +++ b/packages/embeddedci/src/embeddedci/benchpod/gpio.py @@ -18,7 +18,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional if TYPE_CHECKING: # pragma: no cover from .client import BenchPod @@ -26,13 +26,21 @@ from .wiring import Signal +#: :attr:`LaPinState.warn` bits. +WARN_PULL_UNDER_PUSH_PULL = 1 +WARN_UNPOWERED_DUT = 2 +#: :meth:`BenchPod.la_dir` directions. +LA_DIRS = ("dut_input", "dut_output", "bidir", "unknown") + + @dataclass(frozen=True) class LaPinState: """One LA channel as the pod reports it (:meth:`BenchPod.la_pins`).""" la: int #: ``none``, ``gpio``, ``uart_rx``, ``uart_tx``, ``swd_clk``, ``swd_dio``, ``i2c_sda``, ``i2c_scl``, - #: ``step`` or ``step_dir``. + #: ``step``, ``step_dir``, the ``spi_*`` and ``sensor_*`` functions, or ``i2cm_sda`` / + #: ``i2cm_scl`` (the I2C master). function: str #: The GPIO mode (``input``/``output``/``open_drain``) when ``function`` is ``gpio``. gpio: Optional[str] = None @@ -42,8 +50,28 @@ class LaPinState: pull: Optional[str] = None pull_ohms: Optional[str] = None pull_on: bool = False + #: Which way the DUT's signal goes, when set with :meth:`BenchPod.la_dir`: ``dut_input``, + #: ``dut_output`` or ``bidir``; ``None`` when unknown. + dir: Optional[str] = None + #: Warning bits, 0 when none: :data:`WARN_PULL_UNDER_PUSH_PULL` (a pull is on under a push-pull + #: output) and :data:`WARN_UNPOWERED_DUT` (target power is off while the pin is driven high or + #: pulled up). :attr:`warnings` spells them out. + warn: int = 0 raw: Dict[str, Any] = field(default_factory=dict, compare=False, repr=False) + @property + def warnings(self) -> List[str]: + """:attr:`warn` as sentences.""" + out = [] + if self.warn & WARN_PULL_UNDER_PUSH_PULL: + out.append(f"LA{self.la}: a pull resistor is on under a push-pull output (it only costs " + "current; use open_drain or switch the pull off)") + if self.warn & WARN_UNPOWERED_DUT: + out.append(f"LA{self.la}: target power is off while the pin is driven high or pulled up " + "(this powers an unpowered DUT through its I/O pins; ignore it when the DUT " + "has its own supply)") + return out + @property def in_use(self) -> bool: """True when a function other than ``none`` owns the pin.""" @@ -57,7 +85,7 @@ def from_reply(cls, entry: Any) -> "LaPinState": return cls(la=int(e.get("la", 0) or 0), function=str(e.get("function") or "none"), gpio=e.get("gpio") or None, level=None if level is None else int(level), pull=pull.get("dir"), pull_ohms=pull.get("ohms"), pull_on=bool(pull.get("on", False)), - raw=dict(e)) + dir=e.get("dir") or None, warn=int(e.get("warn") or 0), raw=dict(e)) class GpioPin: diff --git a/packages/embeddedci/src/embeddedci/benchpod/i2c_master.py b/packages/embeddedci/src/embeddedci/benchpod/i2c_master.py new file mode 100644 index 0000000..0663508 --- /dev/null +++ b/packages/embeddedci/src/embeddedci/benchpod/i2c_master.py @@ -0,0 +1,302 @@ +"""The pod as an I2C master on two LA pins, and the transactions a capture can send. + +Firmware with gateware v55+ runs an I2C master on any two LA channels (SDA, SCL), 10 kHz to 1 MHz. +The pins are open drain, so the bus needs pull-ups: the pod's own (LA1..LA6, switched on with +:meth:`BenchPod.enable_pullup `) or the board's. SWD, +SPI and I2C sessions share one engine in the FPGA, so they exclude each other. Every command and +reply fits one cloud frame, so this works over the LAN, USB and the cloud alike. + +Open a session with :meth:`BenchPod.open_i2c `. + +:class:`I2CSend`, :class:`SpiSend` and :class:`GpioSend` are what +:meth:`BenchPod.capture_la ` takes as ``send``: the pod +runs that one transaction right after the capture is armed, in the same command. +""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Sequence, Union + +from .errors import BenchPodError + +#: Bytes one ``i2c_xfer`` writes after the address, and reads. +WRITE_MAX = 128 +READ_MAX = 248 +#: Bytes a send-and-capture I2C transfer writes and reads; an SPI send's frame. +SEND_MAX = 32 +#: The SCL range the pod runs. +HZ_MIN = 10_000 +HZ_MAX = 1_000_000 +#: ``pullups`` values: the pod's own switched pull-ups, or the board's. +PULLUPS = ("pod", "external") + +Data = Union[bytes, bytearray, memoryview, Sequence[int]] + + +def _b64(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +def _unb64(text: Any) -> bytes: + s = str(text or "") + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) if s else b"" + + +def _bytes(data: Optional[Data], name: str, limit: int) -> bytes: + out = b"" if data is None else bytes(data) + if len(out) > limit: + raise ValueError(f"{name} is at most {limit} bytes, got {len(out)}") + return out + + +def _addr(addr: Any) -> int: + if isinstance(addr, bool) or not isinstance(addr, int) or not 0 <= addr <= 127: + raise ValueError(f"addr must be a 7-bit I2C address 0..127, got {addr!r}") + return addr + + +def _count(n: Any, limit: int) -> int: + if isinstance(n, bool) or not isinstance(n, int) or not 0 <= n <= limit: + raise ValueError(f"read must be 0..{limit} bytes, got {n!r}") + return n + + +def _reg(reg: Union[int, Data]) -> bytes: + if isinstance(reg, int) and not isinstance(reg, bool): + if not 0 <= reg <= 0xFF: + raise ValueError(f"reg must be 0..255 (pass bytes for a 16-bit register), got {reg!r}") + return bytes([reg]) + return bytes(reg) + + +class I2CNackError(BenchPodError): + """A device did not acknowledge (:meth:`I2CMaster.write`, :meth:`I2CMaster.read`, ...). + + :attr:`addr` is the 7-bit address and :attr:`nack_at` which byte was not acknowledged: 0 the + address, 1..N the Nth written byte, N+1 the address of the read after a write. + """ + + def __init__(self, addr: int, nack_at: int) -> None: + self.addr = addr + self.nack_at = nack_at + what = "its address" if nack_at == 0 else f"byte {nack_at}" + super().__init__(f"I2C device 0x{addr:02x} did not acknowledge {what} (nack_at={nack_at})") + + +@dataclass(frozen=True) +class I2CResult: + """One I2C transfer as the pod reports it (:meth:`I2CMaster.xfer`).""" + + addr: int + #: True when every byte was acknowledged. + ack: bool + #: The bytes read (empty when nothing was read or the device did not answer). + data: bytes = b"" + #: Which byte was not acknowledged when ``ack`` is False (see :class:`I2CNackError`). + nack_at: Optional[int] = None + raw: Dict[str, Any] = field(default_factory=dict, compare=False, repr=False) + + @classmethod + def from_reply(cls, reply: Any, addr: int = 0) -> "I2CResult": + r = reply if isinstance(reply, dict) else {} + nack = r.get("nack_at") + return cls(addr=int(r.get("addr", addr)), ack=bool(r.get("ack", False)), + data=_unb64(r.get("rx")), nack_at=None if nack is None else int(nack), raw=dict(r)) + + def check(self) -> "I2CResult": + """Raise :class:`I2CNackError` unless every byte was acknowledged; else return self.""" + if not self.ack: + raise I2CNackError(self.addr, self.nack_at or 0) + return self + + +class I2CMaster: + """An armed I2C master (from :meth:`BenchPod.open_i2c `). + Use it as a context manager, or call :meth:`close` to release the two pins:: + + with bp.open_i2c("i2c_sda", "i2c_scl", hz=400_000) as i2c: + print([hex(a) for a in i2c.scan()]) + chip_id = i2c.read_reg(0x76, 0xD0)[0] + """ + + def __init__(self, command: Callable[[Dict[str, Any]], Any], info: Dict[str, Any]) -> None: + self._command = command + self.sda = int(info.get("sda", 0)) + self.scl = int(info.get("scl", 0)) + #: The SCL rate the pod runs (at or below the one asked for). A real bus runs somewhat + #: slower: the SCL rise time adds to each period (400 kHz measured about 333 kHz with the + #: pod's pull-ups on a short cable). + self.hz = int(info.get("hz", 0)) + #: ``pod`` or ``external``. + self.pullups = str(info.get("pullups", "pod")) + self._closed = False + + # -- lifetime ---------------------------------------------------------------------------- + def close(self) -> None: + """Stop the I2C master and release its pins. Idempotent.""" + if not self._closed: + self._closed = True + self._command({"cmd": "i2c_stop"}) + + def __enter__(self) -> "I2CMaster": + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + def status(self) -> Dict[str, Any]: + """The pod's ``i2c_status``: ``{"active": False}`` or the session's pins, rate and pull-ups.""" + r = self._command({"cmd": "i2c_status"}) + return r if isinstance(r, dict) else {} + + # -- transfers --------------------------------------------------------------------------- + def xfer(self, addr: int, write: Optional[Data] = None, read: int = 0) -> I2CResult: + """Write ``write`` (up to 128 bytes), then read ``read`` bytes (up to 248) after a repeated + START. With neither, the address alone is sent (a probe). Returns the + :class:`I2CResult` whether or not the device acknowledged; :meth:`write`, :meth:`read` and + :meth:`write_read` raise :class:`I2CNackError` instead.""" + req: Dict[str, Any] = {"cmd": "i2c_xfer", "addr": _addr(addr)} + tx = _bytes(write, "write", WRITE_MAX) + if tx: + req["write"] = _b64(tx) + n = _count(read, READ_MAX) + if n: + req["read"] = n + return I2CResult.from_reply(self._command(req), addr) + + def write(self, addr: int, data: Data) -> None: + """Write ``data`` to the device at ``addr``. Raises :class:`I2CNackError` on a NACK.""" + self.xfer(addr, data).check() + + def read(self, addr: int, n: int) -> bytes: + """Read ``n`` bytes from the device at ``addr``. Raises :class:`I2CNackError` on a NACK.""" + if n < 1: + raise ValueError("read needs at least one byte") + return self.xfer(addr, read=n).check().data + + def write_read(self, addr: int, data: Data, n: int) -> bytes: + """Write ``data``, then read ``n`` bytes after a repeated START (no STOP between). + Raises :class:`I2CNackError` on a NACK.""" + if n < 1: + raise ValueError("write_read needs at least one byte to read") + return self.xfer(addr, data, n).check().data + + def read_reg(self, addr: int, reg: Union[int, Data], n: int = 1) -> bytes: + """Read ``n`` bytes from register ``reg`` (an int 0..255, or bytes for a wider register + address).""" + return self.write_read(addr, _reg(reg), n) + + def write_reg(self, addr: int, reg: Union[int, Data], data: Data) -> None: + """Write ``data`` to register ``reg`` (an int 0..255, or bytes for a wider address).""" + self.write(addr, _reg(reg) + bytes(data)) + + def probe(self, addr: int) -> bool: + """True when a device acknowledges ``addr``.""" + return self.xfer(addr).ack + + def scan(self, start: int = 0x08, end: int = 0x77) -> List[int]: + """The addresses in ``start..end`` that acknowledge (the reserved ones are left out by + default).""" + r = self._command({"cmd": "i2c_scan", "from": _addr(start), "to": _addr(end)}) + r = r if isinstance(r, dict) else {} + return [int(a) for a in r.get("found") or []] + + def send(self, addr: int, write: Optional[Data] = None, read: int = 0) -> "I2CSend": + """An :class:`I2CSend` for :meth:`BenchPod.capture_la + `: this transfer, run right after the capture arms.""" + return I2CSend(addr, write, read) + + +# -- send and capture ------------------------------------------------------------------------- + +@dataclass(frozen=True) +class I2CSend: + """An I2C transfer for ``capture_la(send=...)``: needs an open :meth:`BenchPod.open_i2c + ` session; writes and reads up to 32 bytes each.""" + + addr: int + write: Optional[Data] = None + read: int = 0 + + def to_request(self) -> Dict[str, Any]: + req: Dict[str, Any] = {"bus": "i2c", "addr": _addr(self.addr)} + tx = _bytes(self.write, "write", SEND_MAX) + if tx: + req["write"] = _b64(tx) + n = _count(self.read, SEND_MAX) + if n: + req["read"] = n + return req + + +@dataclass(frozen=True) +class SpiSend: + """One SPI frame (1..32 bytes, CS asserted around it) for ``capture_la(send=...)``: needs an + open :meth:`BenchPod.open_spi ` session, CS not held.""" + + tx: Data + + def to_request(self) -> Dict[str, Any]: + tx = _bytes(self.tx, "tx", SEND_MAX) + if not tx: + raise ValueError("an SPI send needs at least one byte") + return {"bus": "spi", "tx": _b64(tx)} + + +@dataclass(frozen=True) +class GpioSend: + """Set a GPIO pin's level for ``capture_la(send=...)``: the pin must already be a GPIO + ``output`` or ``open_drain`` (:meth:`BenchPod.gpio `). + ``la`` is 1-14 or a wiring-profile name.""" + + la: Union[int, str] + level: int + + def to_request(self) -> Dict[str, Any]: + if not isinstance(self.la, int) or isinstance(self.la, bool) or not 1 <= self.la <= 14: + raise ValueError(f"la must be an LA channel 1-14, got {self.la!r}") + if self.level not in (0, 1): + raise ValueError(f"level must be 0 or 1, got {self.level!r}") + return {"bus": "gpio", "la": self.la, "level": int(self.level)} + + +Send = Union[I2CSend, SpiSend, GpioSend] + + +@dataclass(frozen=True) +class SendResult: + """What a capture's ``send`` did (:attr:`LaCapture.send_result + `). + + ``bus`` is ``i2c``, ``spi`` or ``gpio``. ``error`` is ``""`` or why the transfer failed + (``"SCL held low"``); the capture still returns then. For I2C, ``ack`` and ``nack_at`` are as + in :class:`I2CResult`; ``data`` is the bytes read (I2C) or clocked in on MISO (SPI). + """ + + bus: str + ack: Optional[bool] = None + data: bytes = b"" + nack_at: Optional[int] = None + la: Optional[int] = None + level: Optional[int] = None + error: str = "" + raw: Dict[str, Any] = field(default_factory=dict, compare=False, repr=False) + + @property + def ok(self) -> bool: + """No error, and for I2C every byte acknowledged.""" + return not self.error and self.ack is not False + + @classmethod + def from_reply(cls, reply: Any) -> "SendResult": + r = reply if isinstance(reply, dict) else {} + ack = r.get("ack") + nack = r.get("nack_at") + la = r.get("la") + level = r.get("level") + return cls(bus=str(r.get("bus", "")), ack=None if ack is None else bool(ack), + data=_unb64(r.get("rx")), nack_at=None if nack is None else int(nack), + la=None if la is None else int(la), level=None if level is None else int(level), + error=str(r.get("error") or ""), raw=dict(r)) diff --git a/packages/embeddedci/src/embeddedci/benchpod/results.py b/packages/embeddedci/src/embeddedci/benchpod/results.py index 53798d5..cdd2c28 100644 --- a/packages/embeddedci/src/embeddedci/benchpod/results.py +++ b/packages/embeddedci/src/embeddedci/benchpod/results.py @@ -19,6 +19,7 @@ from typing import Any, List, Optional, Sequence, Tuple, Union from .constants import TRIGGER_EDGES, Edge, TriggerEdge +from .i2c_master import SendResult _EDGES = ("rising", "falling", "both") @@ -253,6 +254,9 @@ class LaCapture: #: ``""``, or why a capture above 12 MS/s stopped early and holds fewer samples than asked: #: ``"busy"`` (the signals changed faster than the pod can store) or ``"memory"``. truncated: str = "" + #: What ``capture_la(send=...)`` did (:class:`~embeddedci.benchpod.i2c_master.SendResult`), + #: or ``None`` without a send. + send_result: Optional["SendResult"] = None def __len__(self) -> int: return len(self.words) diff --git a/packages/embeddedci/tests/api_surface.json b/packages/embeddedci/tests/api_surface.json index 8c6019f..baaf8a9 100644 --- a/packages/embeddedci/tests/api_surface.json +++ b/packages/embeddedci/tests/api_surface.json @@ -85,6 +85,7 @@ "adc_range_test": "(self) -> 'AdcRangeTest'", "adc_read": "(self, source: 'AdcSource' = 'ext', *, range: 'Optional[AdcRange]' = None) -> 'AdcReading'", "analog_path": "(self, path: 'AnalogPath', *, range: 'Optional[AdcRange]' = None) -> 'AnalogPathState'", + "apply_pin_directions": "(self, wiring: 'Optional[Wiring]' = None) -> 'List[LaPinState]'", "calibrate": "(self, source: 'CalibrateSource' = 'current_in') -> 'Calibration'", "calibrate_adc_gain": "(self, source: 'AdcGainSource', *, range: 'AdcRange' = 'normal') -> 'Calibration'", "calibrate_dac": "(self, path: 'DacCalPath', points: 'Sequence[Tuple[int, float]]') -> 'Calibration'", @@ -108,7 +109,7 @@ "capabilities": "property", "capture_adc": "(self, samples: 'int' = 4096, *, sample_rate_hz: 'Optional[float]' = None, source: 'Optional[AdcSource]' = None, trigger: 'Optional[Trigger]' = None, trigger_timeout: 'float' = 10.0, range: 'Optional[AdcRange]' = None, oversample: 'bool' = False) -> 'Capture'", "capture_correlated": "(self, *, adc_samples: 'int' = 4096, adc_sample_rate_hz: 'Optional[float]' = None, la_samples: 'int' = 4096, la_sample_rate_hz: 'Optional[float]' = None, stop_dac_after: 'Optional[float]' = None, trigger: 'Optional[Trigger]' = None, trigger_timeout: 'float' = 10.0, source: 'Optional[AdcSource]' = None) -> 'CorrelatedCapture'", - "capture_la": "(self, samples: 'int' = 4096, *, sample_rate_hz: 'Optional[float]' = None, stop_dac_after: 'Optional[float]' = None, trigger: 'Optional[Trigger]' = None, trigger_timeout: 'float' = 10.0) -> 'LaCapture'", + "capture_la": "(self, samples: 'int' = 4096, *, sample_rate_hz: 'Optional[float]' = None, stop_dac_after: 'Optional[float]' = None, trigger: 'Optional[Trigger]' = None, trigger_timeout: 'float' = 10.0, send: 'Optional[_i2c_master.Send]' = None) -> 'LaCapture'", "capture_uart": "(self, *, rx: 'Union[Pin, int, str, None]' = None, tx: 'Union[Pin, int, str, None]' = None, baud: 'Optional[int]' = None, duration: 'float', until: 'Optional[_uart.Until]' = None) -> '_uart.UartCapture'", "clear_calibration": "(self, part: 'Optional[CalibrationPart]' = None) -> 'Calibration'", "close": "(self) -> 'None'", @@ -131,6 +132,7 @@ "get_la_voltage": "(self) -> 'LaVoltage'", "gpio": "(self, la: 'Union[Pin, int, str]', mode: 'GpioMode' = 'output', *, level: 'Optional[int]' = None) -> 'GpioPin'", "gpio_pins": "(self, las: 'Sequence[Union[Pin, int, str]]', mode: 'GpioMode' = 'output', *, level: 'Optional[int]' = None) -> 'List[GpioPin]'", + "la_dir": "(self, la: 'Union[Pin, int, str, Sequence[Union[Pin, int, str]], None]' = None, dir: 'Optional[str]' = None) -> 'List[LaPinState]'", "la_pins": "(self) -> 'List[LaPinState]'", "la_step": "(self, la: 'Union[Pin, int]', *, steps: 'int', delay: 'float', dir_la: 'Optional[Union[Pin, int]]' = None, direction: 'int' = 0) -> 'Dict[str, Any]'", "lan_tls": "(self) -> \"'LanTls'\"", @@ -140,9 +142,11 @@ "measure_power": "(self, duration: 'float', *, efuse: 'Optional[Union[Efuse, int]]' = None, rate_hz: 'float' = 500.0, keep_samples: 'int' = 0) -> 'PowerProfile'", "module": "(self) -> 'ModuleInfo'", "open_can": "(self, *, bitrate: 'int' = 500000, mode: 'CanMode' = 'normal', term: 'bool' = False, fd: 'bool' = False, data_bitrate: 'Optional[int]' = None) -> '_can.CanBus'", + "open_i2c": "(self, sda: 'Union[Pin, int, str, None]' = None, scl: 'Union[Pin, int, str, None]' = None, *, hz: 'int' = 100000, pullups: 'str' = 'pod') -> '_i2c_master.I2CMaster'", "open_motor_emulator": "(self, *, sck: 'Union[Pin, int, str, None]' = None, mosi: 'Union[Pin, int, str, None]' = None, miso: 'Union[Pin, int, str, None]' = None, cs: 'Union[Pin, int, str, None]' = None, programn: 'Union[Pin, int, str, None]' = None, done: 'Union[Pin, int, str, None]' = None, hz: 'int' = 6000000, calibration: 'Optional[_motor_emulator.EmulatorCalibration]' = None) -> '_motor_emulator.MotorEmulator'", "open_spi": "(self, *, sck: 'Union[Pin, int, str, None]' = None, mosi: 'Union[Pin, int, str, None]' = None, miso: 'Union[Pin, int, str, None]' = None, cs: 'Union[Pin, int, str, None]' = None, hz: 'int' = 1000000, mode: 'int' = 0) -> '_spi.SpiSession'", "open_uart": "(self, *, rx: 'Union[Pin, int, str, None]' = None, tx: 'Union[Pin, int, str, None]' = None, baud: 'Optional[int]' = None, max_buffer: 'int' = 1048576) -> '_uart.UartSession'", + "pin_directions": "(self, wiring: 'Optional[Wiring]' = None) -> 'Dict[int, str]'", "pin_levels": "(self) -> 'Dict[int, int]'", "ping": "(self) -> 'Any'", "power_cycle_and_capture": "(self, *, rx: 'Union[Pin, int, str, None]' = None, tx: 'Union[Pin, int, str, None]' = None, efuse: 'Optional[Union[Efuse, int]]' = None, delay: 'float' = 1.0, duration: 'float' = 4.0, baud: 'Optional[int]' = None, until: 'Optional[_uart.Until]' = None, off_settle: 'float' = 0.3) -> '_uart.UartCapture'", @@ -303,6 +307,7 @@ "usb_cc", "spi_master", "spi_stream", + "gateware", "calibrate", "cal_dmm", "current_out", @@ -337,6 +342,7 @@ ], "adc_max_count": "property", "boot_warning": "(self) -> 'Optional[str]'", + "bus_master": "property", "counts_to_volts": "(self, count: 'float') -> 'float'", "from_parameters": "(cls, params: 'Mapping[str, Any]') -> \"'Capabilities'\"", "from_status": "(cls, status: 'Mapping[str, Any]') -> \"'Capabilities'\"", @@ -445,6 +451,9 @@ "lease_id": "property", "release": "(self) -> 'None'" }, + "DirectionConflictError": { + "__init__": "(self, message: 'str', *, cmd: 'Optional[str]' = None, la: 'int' = 0) -> 'None'" + }, "EXTERNAL": "Efuse.EXTERNAL", "Edge": "Literal['rising', 'falling', 'both']", "Efuse": { @@ -531,12 +540,33 @@ "state": "(self) -> 'LaPinState'", "wait_for": "(self, level: 'int', *, timeout: 'float', poll: 'float' = 0.005) -> 'bool'" }, + "GpioSend": { + "__fields__": [ + "la", + "level" + ], + "to_request": "(self) -> 'Dict[str, Any]'" + }, "I2CByte": { "__fields__": [ "value", "ack" ] }, + "I2CMaster": { + "__init__": "(self, command: 'Callable[[Dict[str, Any]], Any]', info: 'Dict[str, Any]') -> 'None'", + "close": "(self) -> 'None'", + "probe": "(self, addr: 'int') -> 'bool'", + "read": "(self, addr: 'int', n: 'int') -> 'bytes'", + "read_reg": "(self, addr: 'int', reg: 'Union[int, Data]', n: 'int' = 1) -> 'bytes'", + "scan": "(self, start: 'int' = 8, end: 'int' = 119) -> 'List[int]'", + "send": "(self, addr: 'int', write: 'Optional[Data]' = None, read: 'int' = 0) -> \"'I2CSend'\"", + "status": "(self) -> 'Dict[str, Any]'", + "write": "(self, addr: 'int', data: 'Data') -> 'None'", + "write_read": "(self, addr: 'int', data: 'Data', n: 'int') -> 'bytes'", + "write_reg": "(self, addr: 'int', reg: 'Union[int, Data]', data: 'Data') -> 'None'", + "xfer": "(self, addr: 'int', write: 'Optional[Data]' = None, read: 'int' = 0) -> 'I2CResult'" + }, "I2CMessage": { "__fields__": [ "address", @@ -546,6 +576,28 @@ ], "values": "property" }, + "I2CNackError": { + "__init__": "(self, addr: 'int', nack_at: 'int') -> 'None'" + }, + "I2CResult": { + "__fields__": [ + "addr", + "ack", + "data", + "nack_at", + "raw" + ], + "check": "(self) -> \"'I2CResult'\"", + "from_reply": "(cls, reply: 'Any', addr: 'int' = 0) -> \"'I2CResult'\"" + }, + "I2CSend": { + "__fields__": [ + "addr", + "write", + "read" + ], + "to_request": "(self) -> 'Dict[str, Any]'" + }, "I2CTransaction": { "__fields__": [ "messages", @@ -572,7 +624,8 @@ "channels", "trigger", "pre_samples", - "truncated" + "truncated", + "send_result" ], "channel": "(self, la: 'int') -> 'List[int]'", "decode": "(self, protocol: 'str' = 'i2c', **channels)", @@ -595,10 +648,13 @@ "pull", "pull_ohms", "pull_on", + "dir", + "warn", "raw" ], "from_reply": "(cls, entry: 'Any') -> \"'LaPinState'\"", - "in_use": "property" + "in_use": "property", + "warnings": "property" }, "LaVoltage": { "__fields__": [ @@ -842,6 +898,20 @@ ], "to_dict": "(self) -> 'Dict[str, Any]'" }, + "SendResult": { + "__fields__": [ + "bus", + "ack", + "data", + "nack_at", + "la", + "level", + "error", + "raw" + ], + "from_reply": "(cls, reply: 'Any') -> \"'SendResult'\"", + "ok": "property" + }, "Sensor": { "__init__": "(self, emulation: \"'SensorEmulation'\", instance: 'str', *, info: 'Optional[Mapping[str, Any]]' = None, started: 'Optional[Mapping[str, Any]]' = None) -> 'None'", "capture": "(self, samples: 'int' = 4096, sample_rate_hz: 'float' = 500000.0, *, timeout_s: 'float' = 2.0, wait_for_traffic: 'bool' = True) -> 'List[int]'", @@ -946,6 +1016,12 @@ "miso_hex" ] }, + "SpiSend": { + "__fields__": [ + "tx" + ], + "to_request": "(self) -> 'Dict[str, Any]'" + }, "SpiSession": { "__init__": "(self, command: 'Callable[[Dict[str, Any]], Any]', info: 'Dict[str, Any]', *, stage: 'Optional[Callable[[bytes], int]]' = None) -> 'None'", "close": "(self) -> 'None'", @@ -1160,6 +1236,7 @@ "DefinitionError", "DeviceBusyError", "DeviceLease", + "DirectionConflictError", "EXTERNAL", "Edge", "Efuse", @@ -1174,8 +1251,13 @@ "FpgaImageInfo", "GpioMode", "GpioPin", + "GpioSend", "I2CByte", + "I2CMaster", "I2CMessage", + "I2CNackError", + "I2CResult", + "I2CSend", "I2CTransaction", "INTERNAL", "IVPoint", @@ -1222,6 +1304,7 @@ "ResetState", "SENSOR_LIBRARY_VERSION", "Segment", + "SendResult", "Sensor", "SensorEmulation", "SensorLibrary", @@ -1232,6 +1315,7 @@ "SpiFlashInfo", "SpiFlashResult", "SpiFrame", + "SpiSend", "SpiSession", "SpiStreamResult", "TargetStatus", diff --git a/packages/embeddedci/tests/test_i2c_master.py b/packages/embeddedci/tests/test_i2c_master.py new file mode 100644 index 0000000..7c89712 --- /dev/null +++ b/packages/embeddedci/tests/test_i2c_master.py @@ -0,0 +1,398 @@ +"""The I2C master, la_dir, la_pins dir/warn and send-and-capture, against a fake pod that speaks the +firmware's contract (docs/API.md "I2C master", la_dir, la_capture "Send and capture").""" + +from __future__ import annotations + +import base64 +from typing import Any, Dict, Iterator, List, Optional + +import pytest + +from embeddedci.benchpod import ( + BenchPod, + DirectionConflictError, + FirmwareError, + GpioSend, + I2CNackError, + I2CSend, + PinConflictError, + SpiSend, + Trigger, + UnsupportedFeatureError, + Wiring, +) +from embeddedci.benchpod.wiring import Signal + + +def b64(d: bytes) -> str: + return base64.urlsafe_b64encode(d).decode().rstrip("=") + + +def unb64(t: str) -> bytes: + return base64.urlsafe_b64decode(t + "=" * (-len(t) % 4)) if t else b"" + + +class BusPod: + """An I2C target at 0x42 with a 256-byte register file (as the process-node bench), la_dir and + la_capture with send.""" + + TARGET = 0x42 + + def __init__(self, *, gateware: Optional[int] = 55) -> None: + self.gateware = gateware + self.commands: List[dict] = [] + self.session: Optional[dict] = None + self.mem = bytearray(range(256)) + self.ptr = 0 + self.dirs: Dict[int, str] = {} + self.driven: Dict[int, str] = {} + + # transport surface + def status(self) -> Dict[str, Any]: + st = {"board": "stm32h563", "adc_bits": 16, "version": "3.10.4", + "caps": ["la", "la_pins", "gpio_read", "capture_trigger", "spi_master"]} + if self.gateware is not None: + st["gateware"] = self.gateware + return st + + def ping(self) -> Any: + return "pong" + + def close(self) -> None: + pass + + def _pin(self, la: int) -> dict: + e: Dict[str, Any] = {"la": la, "function": self.driven.get(la, "none"), "gpio": None, + "level": None, "pull": None} + if la in self.dirs and self.dirs[la] != "unknown": + e["dir"] = self.dirs[la] + if la == 3: + e["warn"] = 3 + return e + + def _xfer(self, addr: int, write: bytes, read: int) -> dict: + if addr != self.TARGET: + return {"addr": addr, "ack": False, "nack_at": 0, "rx": ""} + if write: + self.ptr = write[0] + for i, b in enumerate(write[1:]): + self.mem[(self.ptr + i) & 0xFF] = b + rx = bytes(self.mem[(self.ptr + i) & 0xFF] for i in range(read)) + return {"addr": addr, "ack": True, "rx": b64(rx)} + + def command(self, req: dict) -> Any: + self.commands.append(req) + cmd = req["cmd"] + if cmd == "i2c_start": + if (self.gateware or 0) < 55: + raise FirmwareError("the I2C master needs gateware v55+: update the pod's firmware", cmd=cmd) + if req["pullups"] == "pod" and req["sda"] > 6: + raise FirmwareError(f"LA{req['sda']} has no pod pull-up (only LA1..LA6 do)", cmd=cmd) + if self.session: + raise FirmwareError("i2c busy: send i2c_stop first", cmd=cmd) + self.session = req + self.driven[req["sda"]] = "i2cm_sda" + self.driven[req["scl"]] = "i2cm_scl" + return {"sda": req["sda"], "scl": req["scl"], "hz": 363636 if req["hz"] == 400_000 else req["hz"], + "pullups": req["pullups"]} + if cmd == "i2c_stop": + self.session = None + self.driven = {k: v for k, v in self.driven.items() if not v.startswith("i2cm")} + return "i2c stopped" + if cmd == "i2c_status": + return {"active": bool(self.session)} + if cmd in ("i2c_xfer", "i2c_scan") and not self.session: + raise FirmwareError("no I2C session: send i2c_start first", cmd=cmd) + if cmd == "i2c_xfer": + write = unb64(req.get("write", "")) + assert len(write) <= 128 and req.get("read", 0) <= 248 + return self._xfer(req["addr"], write, req.get("read", 0)) + if cmd == "i2c_scan": + lo, hi = req.get("from", 8), req.get("to", 119) + return {"from": lo, "to": hi, "found": [self.TARGET] if lo <= self.TARGET <= hi else []} + if cmd == "la_dir": + if "la" not in req: + return {"pins": [self._pin(la) for la in range(1, 15)]} + las = list(range(1, 15)) if req["la"] == "all" else ( + req["la"] if isinstance(req["la"], list) else [req["la"]]) + for la in las: + if req["dir"] == "dut_output" and self.driven.get(la) == "gpio_output": + raise FirmwareError(f"direction conflict: LA{la} is driven by gpio output, so it " + "can't be wired to a DUT output; stop that first", cmd=cmd) + for la in las: + self.dirs[la] = req["dir"] + return {"pins": [self._pin(la) for la in las]} + if cmd == "gpio" and req.get("mode") == "output": + la = req["la"] + if self.dirs.get(la) == "dut_output": + raise FirmwareError(f"direction conflict: LA{la} is wired to a DUT output and gpio " + "output would drive against it; use gpio open_drain, or fix the " + f"wiring if LA{la} is not a DUT output", cmd=cmd) + self.driven[la] = "gpio_output" + return {"pins": [{"la": la, "function": "gpio", "gpio": "output", "level": 0}]} + if cmd == "la_pins": + return {"pins": [self._pin(la) for la in range(1, 15)], "levels": 0} + raise AssertionError(f"unexpected {req}") + + def stream_chunks(self, req: dict) -> Iterator[Dict[str, Any]]: + self.commands.append(req) + assert req["cmd"] == "la_capture" + send = req.get("send") + result = None + if send and send["bus"] == "i2c": + if not self.session: + raise FirmwareError("send: no I2C session: send i2c_start first", cmd="la_capture") + r = self._xfer(send["addr"], unb64(send.get("write", "")), send.get("read", 0)) + result = {"bus": "i2c", **{k: v for k, v in r.items() if k != "addr"}} + elif send and send["bus"] == "spi": + result = {"bus": "spi", "rx": b64(bytes(b ^ 0xFF for b in unb64(send["tx"])))} + elif send: + result = {"bus": "gpio", "la": send["la"], "level": send["level"]} + pre = (req.get("trigger") or {}).get("pre_samples", 0) + first: Dict[str, Any] = {"status": "ok", "bits": 16, "la_rate_hz": 12_000_000, "la": True, + "la_edges": [[0, 3], [10, 1]], "la_upto": 64, "more": True} + if pre: + first["pre_samples"] = min(pre, 16) + yield first + last: Dict[str, Any] = {"status": "ok", "la": True, "la_edges": [[64, 3]], "la_upto": 128, + "more": False} + if result is not None: + last["send"] = result + yield last + + +def pod(**kw) -> "tuple[BenchPod, BusPod]": + fake = BusPod(**kw) + return BenchPod(transport=fake, wiring=Wiring(i2c_sda=1, i2c_scl=2)), fake + + +# -- I2C master --------------------------------------------------------------------------------- + +def test_open_i2c_pins_from_wiring_and_rate_reply(): + bp, fake = pod() + with bp.open_i2c(hz=400_000) as i2c: + assert (i2c.sda, i2c.scl, i2c.hz, i2c.pullups) == (1, 2, 363636, "pod") + assert fake.commands[-1] == {"cmd": "i2c_start", "sda": 1, "scl": 2, "hz": 400_000, "pullups": "pod"} + assert fake.commands[-1] == {"cmd": "i2c_stop"} + assert fake.session is None + + +def test_open_i2c_accepts_wiring_names_and_validates(): + bp, fake = pod() + with bp.open_i2c("i2c_scl", "i2c_sda", pullups="external") as i2c: + assert (i2c.sda, i2c.scl) == (2, 1) + with pytest.raises(ValueError, match="hz"): + bp.open_i2c(hz=5_000) + with pytest.raises(ValueError, match="pullups"): + bp.open_i2c(pullups="weak") + with pytest.raises(ValueError, match="different"): + bp.open_i2c(1, 1) + + +def test_write_read_and_register_helpers(): + bp, fake = pod() + with bp.open_i2c() as i2c: + i2c.write_reg(0x42, 0x20, b"\xa5\x5a") + assert fake.mem[0x20:0x22] == b"\xa5\x5a" + assert i2c.read_reg(0x42, 0x20, 2) == b"\xa5\x5a" + assert i2c.write_read(0x42, b"\x80", 4) == bytes([0x80, 0x81, 0x82, 0x83]) + i2c.write(0x42, [0x30]) + assert i2c.read(0x42, 2) == bytes([0x30, 0x31]) + sent = [c for c in fake.commands if c["cmd"] == "i2c_xfer"] + assert sent[0] == {"cmd": "i2c_xfer", "addr": 0x42, "write": b64(b"\x20\xa5\x5a")} + assert sent[1] == {"cmd": "i2c_xfer", "addr": 0x42, "write": b64(b"\x20"), "read": 2} + + +def test_nack_raises_with_nack_at_and_xfer_reports_it(): + bp, fake = pod() + with bp.open_i2c() as i2c: + with pytest.raises(I2CNackError) as ei: + i2c.read_reg(0x50, 0x00, 2) + assert ei.value.addr == 0x50 and ei.value.nack_at == 0 + r = i2c.xfer(0x50, b"\x00", 2) + assert r.ack is False and r.nack_at == 0 and r.data == b"" + + +def test_probe_and_scan(): + bp, fake = pod() + with bp.open_i2c() as i2c: + assert i2c.probe(0x42) is True + assert i2c.probe(0x43) is False + assert fake.commands[-1] == {"cmd": "i2c_xfer", "addr": 0x43} + assert i2c.scan() == [0x42] + assert fake.commands[-1] == {"cmd": "i2c_scan", "from": 8, "to": 119} + assert i2c.scan(0x50, 0x60) == [] + + +def test_limits_are_checked_before_sending(): + bp, fake = pod() + with bp.open_i2c() as i2c: + n = len(fake.commands) + with pytest.raises(ValueError): + i2c.write(0x42, bytes(129)) + with pytest.raises(ValueError): + i2c.read(0x42, 249) + with pytest.raises(ValueError): + i2c.probe(128) + assert len(fake.commands) == n + + +def test_old_gateware_refused_before_sending(): + bp, fake = pod(gateware=53) + with pytest.raises(UnsupportedFeatureError, match="gateware v55"): + bp.open_i2c() + assert not [c for c in fake.commands if c["cmd"] == "i2c_start"] + + +def test_firmware_gateware_refusal_is_unsupported_feature(): + fake = BusPod(gateware=None) + fake.gateware = None + bp = BenchPod(transport=fake, wiring=Wiring()) + fake.gateware = 40 # the pod itself refuses; the SDK saw no gateware in status + with pytest.raises(UnsupportedFeatureError): + bp.open_i2c() + + +def test_refusals_name_sdk_calls(): + bp, fake = pod() + i2c = bp.open_i2c() + with pytest.raises(FirmwareError, match=r"bp\.open_i2c\(\)"): + bp.open_i2c() + i2c.close() + i2c.close() # idempotent + with pytest.raises(FirmwareError, match=r"open one with bp\.open_i2c\(\)"): + i2c.xfer(0x42) + + +# -- la_dir and la_pins ------------------------------------------------------------------------- + +def test_la_dir_sets_and_lists(): + bp, fake = pod() + pins = bp.la_dir([9, 10], "dut_output") + assert fake.commands[-1] == {"cmd": "la_dir", "la": [9, 10], "dir": "dut_output"} + assert [(p.la, p.dir) for p in pins] == [(9, "dut_output"), (10, "dut_output")] + bp.la_dir("all", "unknown") + assert fake.commands[-1]["la"] == "all" + listed = bp.la_dir() + assert fake.commands[-1] == {"cmd": "la_dir"} + assert len(listed) == 14 and all(p.dir is None for p in listed) + with pytest.raises(ValueError): + bp.la_dir(9, "out") + with pytest.raises(ValueError): + bp.la_dir(9) + + +def test_direction_conflict_is_its_own_error(): + bp, fake = pod() + bp.la_dir(5, "dut_output") + with pytest.raises(DirectionConflictError) as ei: + bp.gpio(5, "output") + assert ei.value.la == 5 + + +def test_la_pins_dir_and_warn(): + bp, fake = pod() + bp.la_dir(4, "dut_input") + pins = {p.la: p for p in bp.la_pins()} + assert pins[4].dir == "dut_input" and pins[1].dir is None + assert pins[3].warn == 3 and len(pins[3].warnings) == 2 and pins[4].warnings == [] + + +def test_pin_directions_from_wiring(): + w = Wiring(uart_rx=5, uart_tx=4, i2c_sda=1, i2c_scl=2, spi_sclk=7, spi_mosi=8, spi_miso=9, spi_cs=10, swd_swclk=None, swd_swdio=None, + signals=(Signal("ALARM", 11, "input"), Signal("TRIG", 12, "output"), + Signal("RST", 13, "open_drain"))) + bp, fake = pod() + dirs = bp.pin_directions(w) + assert dirs == {5: "dut_output", 4: "dut_input", 1: "bidir", 2: "bidir", 7: "dut_input", + 8: "dut_input", 9: "dut_output", 10: "dut_input", 11: "dut_output", + 12: "dut_input", 13: "bidir"} + bp.apply_pin_directions(w) + sent = [c for c in fake.commands if c["cmd"] == "la_dir"] + assert {c["dir"]: c["la"] for c in sent} == {"bidir": [1, 2, 13], "dut_input": [4, 7, 8, 10, 12], + "dut_output": [5, 9, 11]} + assert fake.dirs[11] == "dut_output" + + +# -- send and capture --------------------------------------------------------------------------- + +def test_capture_with_i2c_send_returns_result_and_defaults_pre_samples(): + bp, fake = pod() + with bp.open_i2c() as i2c: + cap = bp.capture_la(128, sample_rate_hz=12e6, trigger=Trigger("i2c_sda", "falling"), + send=i2c.send(0x42, b"\x10", read=2)) + req = [c for c in fake.commands if c["cmd"] == "la_capture"][0] + assert req["send"] == {"bus": "i2c", "addr": 0x42, "write": b64(b"\x10"), "read": 2} + assert req["trigger"] == {"la": 1, "edge": "falling", "pre_samples": 64} + r = cap.send_result + assert r.bus == "i2c" and r.ack is True and r.ok and r.data == bytes([0x10, 0x11]) + assert len(cap) == 128 and cap.pre_samples == 16 + + +def test_capture_send_keeps_explicit_pre_samples_and_level_triggers(): + bp, fake = pod() + bp.open_i2c() + bp.capture_la(128, trigger=Trigger(1, "falling", pre_samples=500), send=I2CSend(0x42, read=1)) + bp.capture_la(128, trigger=Trigger(1, "low"), send=I2CSend(0x42, read=1)) + caps = [c for c in fake.commands if c["cmd"] == "la_capture"] + assert caps[0]["trigger"]["pre_samples"] == 500 + assert "pre_samples" not in caps[1]["trigger"] + + +def test_capture_without_send_is_unchanged(): + bp, fake = pod() + cap = bp.capture_la(128, trigger=Trigger(1, "falling")) + req = fake.commands[-1] + assert "send" not in req and "pre_samples" not in req["trigger"] + assert cap.send_result is None + + +def test_capture_spi_and_gpio_sends(): + bp, fake = pod() + cap = bp.capture_la(128, send=SpiSend(b"\x9f\x00")) + assert fake.commands[-1]["send"] == {"bus": "spi", "tx": b64(b"\x9f\x00")} + assert cap.send_result.bus == "spi" and cap.send_result.data == b"\x60\xff" + w = Wiring(signals=(Signal("STROBE", 6, "output"),)) + bp2 = BenchPod(transport=fake, wiring=w) + cap = bp2.capture_la(128, trigger=Trigger("STROBE", "falling"), send=GpioSend("STROBE", 0)) + assert fake.commands[-1]["send"] == {"bus": "gpio", "la": 6, "level": 0} + assert cap.send_result.la == 6 and cap.send_result.level == 0 and cap.send_result.ok + + +def test_send_nack_and_error_land_on_the_result(): + bp, fake = pod() + bp.open_i2c() + cap = bp.capture_la(128, send=I2CSend(0x50, b"\x00", 1)) + assert cap.send_result.ack is False and cap.send_result.nack_at == 0 and not cap.send_result.ok + from embeddedci.benchpod import SendResult + err = SendResult.from_reply({"bus": "i2c", "error": "SCL held low"}) + assert err.error == "SCL held low" and not err.ok + + +def test_send_validation_and_gateware(): + bp, fake = pod() + with pytest.raises(ValueError): + bp.capture_la(128, send=SpiSend(bytes(33))) + with pytest.raises(ValueError): + bp.capture_la(128, send=I2CSend(0x42, read=33)) + with pytest.raises(ValueError): + bp.capture_la(128, send={"bus": "i2c"}) # type: ignore[arg-type] + old, fake_old = pod(gateware=54) + with pytest.raises(UnsupportedFeatureError): + old.capture_la(128, send=SpiSend(b"\x01")) + assert not [c for c in fake_old.commands if c["cmd"] == "la_capture"] + + +def test_send_without_session_names_open_i2c(): + bp, fake = pod() + with pytest.raises(FirmwareError, match=r"bp\.open_i2c\(\)"): + bp.capture_la(128, send=I2CSend(0x42, read=1)) + + +def test_pin_conflict_with_i2c_master_names_the_session(): + from embeddedci.benchpod.errors import firmware_error + + e = firmware_error('pin conflict: LA1 is in use by i2cm_sda; stop the I2C session first ' + '({"cmd":"i2c_stop"})', cmd="gpio") + assert isinstance(e, PinConflictError) and e.function == "i2cm_sda" + assert "bp.open_i2c()" in str(e) diff --git a/packages/embeddedci/tests/test_spi.py b/packages/embeddedci/tests/test_spi.py index aaa8713..b256393 100644 --- a/packages/embeddedci/tests/test_spi.py +++ b/packages/embeddedci/tests/test_spi.py @@ -22,7 +22,8 @@ def unb64(t: str) -> bytes: class SpiPod: """The firmware's SPI commands over a 1 MB flash model (limits as in command_handler_spi.c).""" - def __init__(self, *, caps=None, present=True) -> None: + def __init__(self, *, caps=None, present=True, gateware=None) -> None: + self.gateware = gateware self.caps = ["la", "la_pins", "spi_master", "nrst_pin"] if caps is None else caps self.mem = bytearray(b"\xff" * (1 << 20)) self.present = present @@ -33,8 +34,11 @@ def __init__(self, *, caps=None, present=True) -> None: self.fail_write_at = None def status(self) -> Dict[str, Any]: - return {"board": "stm32h563", "adc_bits": 16, "version": "3.3.0", "caps": self.caps, - "nrst_pin": True} + st = {"board": "stm32h563", "adc_bits": 16, "version": "3.3.0", "caps": self.caps, + "nrst_pin": True} + if self.gateware is not None: + st["gateware"] = self.gateware + return st def ping(self) -> Any: return "pong" @@ -115,7 +119,24 @@ def test_open_spi_needs_the_capability_and_a_valid_mode(): bp.open_spi() bp = BenchPod(transport=SpiPod(), wiring=Wiring(spi_sclk=7, spi_mosi=8, spi_miso=9, spi_cs=10)) with pytest.raises(ValueError): - bp.open_spi(mode=1) + bp.open_spi(mode=4) + + +def test_spi_modes_1_and_2_need_gateware_55(): + from embeddedci.benchpod import UnsupportedFeatureError + + wiring = Wiring(spi_sclk=7, spi_mosi=8, spi_miso=9, spi_cs=10) + old = SpiPod(gateware=54) + with pytest.raises(UnsupportedFeatureError, match="gateware v55"): + BenchPod(transport=old, wiring=wiring).open_spi(mode=1) + assert not [c for c in old.commands if c["cmd"] == "spi_start"] + new = SpiPod(gateware=55) + for mode in (1, 2): + with BenchPod(transport=new, wiring=wiring).open_spi(mode=mode) as spi: + assert spi.mode == mode + # a pod that does not report its gateware decides itself + with BenchPod(transport=SpiPod(), wiring=wiring).open_spi(mode=2) as spi: + assert spi.mode == 2 def test_open_spi_without_wiring_pins_says_which_is_missing():