diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ecfda07 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,49 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project status + +Greenfield. The repository currently contains only `README.md` and `MIPI_FLICKER_SPEC.md` — no Python source, no `requirements.txt`, no tests. All implementation work is driven from the spec. **Read `MIPI_FLICKER_SPEC.md` before making any non-trivial change**; it is the source of truth for module boundaries, REST endpoints, register layouts, and timing thresholds. + +When the user asks to "build" or "implement" something, follow the layout in `MIPI_FLICKER_SPEC.md` §3 rather than inventing a new structure. + +## Purpose + +A two-host test harness that hunts for a MIPI D-PHY flicker/blackout fault on a custom PCB driven by an i.MX 8M Mini → TI SN65DSI83 (DSI→LVDS) bridge. The fault is hypothesised to be timing-violation rounding in the mainline `samsung-dsim` driver that drops several D-PHY parameters below MIPI v1.1 minimums. + +The harness loops: power-cycle the display rail, arm the scope, restart video, capture 4-channel waveforms + bridge/SoC registers, decode timings + DSI Lane 0 packets, log a verdict. + +## Architecture (from spec §2, §3) + +Three top-level concerns; keep them in separate packages: + +- **`hardware/`** — instrument I/O only. `scope.py` (DSO80204B over VXI-11), `psu.py` (Siglent SPD3303X-E over VXI-11), `target.py` (HTTP REST client to the i.MX). Each is a thin controller class; no analysis logic. +- **`analysis/`** — pure functions over captured data. `waveform.py` extracts D-PHY timings and decodes Lane 0 DSI packets from CSVs; `registers.py` parses SN65 + DSIM register dumps; `report.py` writes per-run artefacts. +- **`server/app.py`** — Flask REST server that runs **on the i.MX target**, not the host PC. It shells out to `memtool`, `i2cget`, and `/sys/kernel/debug/regmap/...` and exposes `/registers`, `/sn65_registers`, `/sn65_settling`, `/display`, `/video`. The host-side `hardware/target.py` is just a `requests` wrapper around it. +- **`master_loop.py`** — orchestrator only. Reset → arm scope → stimulate → wait for trigger → capture → analyse → save. See spec §9. + +`config.py` centralises all IPs, register addresses, MIPI spec minimums, and probe attenuation. Treat it as the single tuning surface. + +## Non-obvious invariants + +These will silently break the experiment if violated: + +1. **Cache-bypass before every SN65 read.** The server **must** `echo 1 > /sys/kernel/debug/regmap/4-002c/cache_bypass` before reading `IRQ_STAT (0xE5)`, every time. Without it, reads return the last-written cached value, not hardware state — flicker events become invisible. Spec §7.2, §15.4. *(Note: spec §7/§11 says bus 2; live hardware on this board has the bridge on bus 4 — `i2cdetect` confirms `UU` at 0x2c on bus 4 only. Code uses bus 4.)* +2. **Scope channels are 50 Ω DC, not 1 MΩ.** The 910R+50R probe divider only gives the documented 19.2× attenuation with 50 Ω termination. Any code that configures the scope must set `:CHANnel:INPut DC50` explicitly. Spec §15.1–2. +3. **Probe attenuation 19.2× is baked into thresholds.** All voltage thresholds in `analysis/waveform.py` are post-attenuation values (e.g. LP-high > 40 mV ≈ 770 mV on wire). Don't "correct" them back to wire voltages. +4. **UI depends on pixel clock.** `UI_NS = 1e9 / DSI_CLK_HZ` and several spec minimums are `f(UI)`. If `--pixel-clock` is overridden at runtime, recompute `DPHY_SPEC` rather than using stale module-level constants. Spec §15.5. +5. **DSIM PHY_TIMING bit-field layout is undocumented** in the i.MX 8M Mini reference manual. The parser must log raw hex *and* decoded cycle counts so they can be cross-checked against kernel dmesg. Don't assume the layout in spec §10 is correct without dmesg verification. Spec §15.6. +6. **Packet-level decode is the ground truth, not the SN65 error registers.** A flicker can be confirmed only by decoding Lane 0 bytes (Fault A: first long packet payload is all `0x00`; Fault B: lane stalls in LP-11 for ~20 ms). The SN65 `IRQ_STAT` bits are a hint, not a verdict. Spec §1 (Falcon prior art), §13. + +## Network topology + +Host PC talks to three fixed IPs on the bench LAN — these are wired into `config.py` and the spec, not discovered: + +- `192.168.45.3` — Siglent PSU (VXI-11) +- `192.168.45.4` — Keysight DSO80204B scope (VXI-11) +- `192.168.45.8:5000` — i.MX target Flask server (HTTP) + +## Commands + +No build/test/lint commands exist yet — the project hasn't been scaffolded. Spec §4 lists the dependency set (`python-vxi11`, `requests`, `flask`, `numpy`, `pandas`, `matplotlib`) when a `requirements.txt` is created. Spec §9.3 lists the eventual `master_loop.py` CLI surface (`--max-runs`, `--timeout`, `--pixel-clock`, `--note`, `--no-video`, `--output-dir`). diff --git a/MIPI_FLICKER_SPEC.md b/MIPI_FLICKER_SPEC.md new file mode 100644 index 0000000..72e45c1 --- /dev/null +++ b/MIPI_FLICKER_SPEC.md @@ -0,0 +1,867 @@ +# MIPI D-PHY Display Flicker Investigation Suite +## Specification for Claude Code — Build From Scratch + +--- + +## 1. CONTEXT & BACKGROUND + +This suite investigates **infrequent vertical jitter/flicker and total display blackout** on a +custom PCB using a **TI SN65DSI83 MIPI-DSI-to-LVDS bridge IC**. + +The LVDS output side has been validated with static test images and is considered healthy. +The fault is upstream — on the **MIPI D-PHY physical layer** between the i.MX 8M Mini SoM +and the bridge input. + +### Root Cause Hypothesis (from hardware engineer investigation) +The Linux mainline `samsung-dsim` driver uses **"best-fit" (round-to-nearest) rounding** when +converting D-PHY timing parameters from picoseconds to byte-clock cycles. Several parameters +consistently round **below the MIPI D-PHY v1.1 specification minimums**, causing the SN65DSI83 +to occasionally fail to latch the Start-of-Transmission (SoT) sequence, producing a frame jump +or blackout. + +Key violating parameters at 72 MHz pixel clock (432 Mbps DSI, byte clock = 54 MHz): + +| Parameter | Spec Min (ps) | Mainline "Best Fit" (ps) | Violation? | +|-----------------|---------------|--------------------------|------------| +| `clk_prepare` | 38,000 | 37,037 | YES | +| `clk_zero` | 262,000* | 259,259 | YES | +| `clk_trail` | 60,000 | 55,555 | YES | +| `hs_zero` | 118,890 | 111,111 | YES | +| `hs_trail` | 97,040 | 92,592 | YES | +| `hs_exit` | 100,000 | 92,592 | YES | + +> *`clk_prepare + clk_zero` combined minimum is 300,000 ps per MIPI D-PHY v1.1 Table 14. + +A "round-up" patch was applied (u-boot `dsi-tweak` bit 2) and reduces flicker frequency +but does not fully eliminate it. Additional per-parameter padding registers are available. + +The goal of this suite is to **automate stress testing across parameter combinations** and +**correlate scope-captured D-PHY waveforms with bridge error registers** to find the exact +boundary condition that triggers a flicker event. + +### Prior Art: Falcon Board MIPI Analysis (S. Bouriot, May 2024) +A previous investigation on the **Falcon board** (different product, same SN65DSI83 bridge, +lower pixel clock ~52 MHz) manually decoded MIPI DSI packets from scope captures during a +spread-spectrum-induced shift fault. That analysis is directly applicable here. Key findings: + +**What was proven by direct packet decoding:** + +The fault manifests at the **DSI packet payload layer**, not just the PHY layer. Two distinct +failure modes were identified by decoding Lane 0 data bytes: + +**Failure Mode A — Pixel Data Offset (Shifted Display):** +- In a healthy frame, the first long packet (0x3E = Packed Pixel Stream, 24-bit RGB) contains + the correct pixel colour bytes (e.g. 0xD1, 0xB5, 0x90) starting from byte 0 of the payload. +- In the fault state, the **first long packet contains only 0x00 bytes**. The correct pixel + data (0xD1, 0xB5, 0x90) appears **in the second long packet, offset into the line**, not + at the start. +- This means the bridge is rendering pixel N's data at pixel N+offset position — causing the + visible horizontal/vertical shift. +- The packet **header** (0x21 H-Sync Start, 0x3E Data ID) is **identical** in both good and + fault states — the fault is in the payload, not the framing. + +**Failure Mode B — Lane Stall (Flickering/Blackout):** +- The data lane enters **LP-11 (Stop state) for ~20 ms** at ~20 ms intervals. +- No pixel data packets are transmitted during this period. +- This is **not observed** in the healthy display state — it is definitively abnormal. +- This corresponds to the "total blackout" failure mode seen on the Nexio board. + +**What the Falcon analysis could NOT determine:** +- Whether the fault originates at the DSI transmitter (i.MX), the bridge input, or is a + timing/PLL issue. The Falcon fault was triggered by spread spectrum; the Nexio fault + occurs without spread spectrum, suggesting a different (or additional) root cause. + +**Implication for this suite:** +The analysis must operate at **two levels simultaneously**: +1. **PHY level** — scope capture of LP/HS transitions, measuring T_HS_PREPARE etc. +2. **Packet level** — decoding Lane 0 differential data to verify pixel bytes are in the + correct position within each long packet. + +The packet-level decode is the **ground truth** for whether a flicker event has occurred, +independent of what the SN65DSI83 error registers report. + +--- + +## 2. SYSTEM ARCHITECTURE + +``` +┌─────────────────────────────────────────────────────────┐ +│ Host PC (Python 3.x) │ +│ │ +│ master_loop.py │ +│ ├── TargetController → HTTP REST → 192.168.45.8 │ +│ ├── ScopeController → VXI-11 → 192.168.45.4 │ +│ └── PSUController → VXI-11 → 192.168.45.3 │ +└─────────────────────────────────────────────────────────┘ + +Target (192.168.45.8:5000) + └── i.MX 8M Mini (Digi ConnectCore 8M Mini SoM) + ├── samsung-dsim driver → MIPI D-PHY 4-lane + ├── TI SN65DSI83 bridge → I2C bus 2 @ 0x2C + └── Flask REST server → /registers, /sn65_registers, + /sn65_settling, /display, /video + +Scope (192.168.45.4) + └── Agilent/Keysight DSO80204B (2 GHz, LXI) + CH1: MIPI CLK+ (19.2× atten, DC 50Ω) + CH2: MIPI CLK- (19.2× atten, DC 50Ω) + CH3: MIPI DAT0+ (19.2× atten, DC 50Ω) + CH4: MIPI DAT0- (19.2× atten, DC 50Ω) + +PSU (192.168.45.3) + └── Siglent SPD3303X-E (LXI, SCPI) + CH1: Display 3.3V rail +``` + +--- + +## 3. PROJECT FILE STRUCTURE + +``` +mipi_flicker/ +├── SPEC.md ← This document +├── requirements.txt +├── config.py ← All constants, IPs, thresholds +├── hardware/ +│ ├── __init__.py +│ ├── scope.py ← ScopeController (VXI-11, DSO80204B) +│ ├── psu.py ← PSUController (VXI-11, SPD3303X-E) +│ └── target.py ← TargetController (HTTP REST) +├── analysis/ +│ ├── __init__.py +│ ├── waveform.py ← D-PHY timing extraction from CSV +│ ├── registers.py ← SN65 / DSIM register parsing & flagging +│ └── report.py ← Per-run HTML/JSON/CSV report generation +├── server/ +│ ├── app.py ← Flask REST server (deploy on i.MX8) +│ └── hw_interface.py ← memtool, i2cget, display/video control +├── master_loop.py ← Main entry point — the flicker hunt loop +└── captures/ ← Auto-created; one subfolder per run + └── run_001_20260505_143022/ + ├── waveform_ch1.csv + ├── waveform_ch2.csv + ├── waveform_ch3.csv + ├── waveform_ch4.csv + ├── registers.json + └── summary.txt +``` + +--- + +## 4. PYTHON DEPENDENCIES (`requirements.txt`) + +``` +python-vxi11>=0.9 +requests>=2.28 +flask>=3.0 # server side only +numpy>=1.24 +pandas>=2.0 +matplotlib>=3.7 # optional, for waveform plots +``` + +--- + +## 5. CONFIGURATION (`config.py`) + +```python +# Network +TARGET_IP = "192.168.45.8" +TARGET_PORT = 5000 +SCOPE_IP = "192.168.45.4" +PSU_IP = "192.168.45.3" + +# Scope hardware +SCOPE_CHANNELS = { + "CLK_P": 1, # MIPI Clock Lane + + "CLK_N": 2, # MIPI Clock Lane - + "DAT0_P": 3, # MIPI Data Lane 0 + + "DAT0_N": 4, # MIPI Data Lane 0 - +} +PROBE_ATTENUATION = 19.2 # 910R + 50R divider, calibrated +SCOPE_TIMEBASE = 5e-9 # 5 ns/div — resolves ~430 MHz transitions +SCOPE_POINTS = 500_000 +TRIGGER_CHANNEL = 3 # DAT0+ — catches LP-01→LP-00 SoT entry +TRIGGER_LEVEL_V = 0.05 # 50 mV after 19.2× attenuation factor + # (represents ~960 mV on actual signal) +TRIGGER_SLOPE = "NEGative" + +# PSU +PSU_CHANNEL_DISPLAY = 1 +PSU_DISPLAY_VOLTAGE = 3.3 +PSU_DISPLAY_CURRENT = 1.0 +PSU_POWER_CYCLE_DELAY_S = 2.0 + +# Pixel clock & DSI parameters +PIXEL_CLOCK_HZ = 72_000_000 +DSI_LANES = 4 +BITS_PER_PIXEL = 24 +# DSI clock = pixel_clock * bpp / lanes (DDR, so /2 for freq) +DSI_CLK_HZ = PIXEL_CLOCK_HZ * BITS_PER_PIXEL // DSI_LANES # 432 MHz +BYTE_CLK_HZ = DSI_CLK_HZ // 8 # 54 MHz +UI_NS = 1e9 / DSI_CLK_HZ # ~2.315 ns + +# MIPI D-PHY v1.1 timing minimums (nanoseconds) +# Table 14, Section 9 — all are MINIMUM values; violations cause SoT errors +DPHY_SPEC = { + "t_lpx": 50.0, + "t_clk_prepare": 38.0, + "t_clk_zero": 262.0, # clk_prepare + clk_zero >= 300 ns + "t_clk_prepare_plus_zero": 300.0, # combined hard limit + "t_clk_trail": 60.0, + "t_clk_post": 60.0 + 52 * UI_NS, + "t_hs_prepare": 40.0 + 4 * UI_NS, + "t_hs_zero": 145.0 + 10 * UI_NS, + "t_hs_trail": max(8 * UI_NS, 60.0 + 4 * UI_NS), + "t_hs_exit": 100.0, +} + +# SN65DSI83 I2C +SN65_I2C_ADDR = 0x2C +SN65_I2C_BUS = 2 # /dev/i2c-2 on target +SN65_REG_IRQ = 0xE5 # IRQ_STAT — volatile, must bypass cache +SN65_REG_PLL = 0x0A # RC_LVDS_PLL — bit 7 = PLL lock +SN65_REG_CLK = 0x0B # RC_LVDS_CLK — bit 0 = clock detect + +# Error bit masks in SN65 REG_IRQ (0xE5) +SN65_ERR_SYNCH = (1 << 3) # CHA_SYNCH_ERR +SN65_ERR_SOT = (1 << 4) # CHA_SOT_ERR +SN65_ERR_UNC = (1 << 6) # CHA_UNC_ECC_ERR +SN65_FLICKER_MASK = SN65_ERR_SYNCH | SN65_ERR_SOT | SN65_ERR_UNC + +# DSIM PHY timing registers (i.MX8M Mini, samsung-dsim) +DSIM_PHYTIMING_BASE = 0x32E100B4 # PHY_TIMING offset 0 +DSIM_PHYTIMING1 = 0x32E100B8 # PHY_TIMING1 offset 4 +DSIM_PHYTIMING2 = 0x32E100BC # PHY_TIMING2 offset 8 + +# Tweak parameters available via u-boot env (for reference/logging) +TWEAK_BIT_FIFO_FLUSH = (1 << 0) +TWEAK_BIT_ROUND_UP = (1 << 2) + +# Output +CAPTURE_ROOT = "captures" +``` + +--- + +## 6. MODULE SPECIFICATIONS + +### 6.1 `hardware/psu.py` — `PSUController` + +**Purpose:** Control the Siglent SPD3303X-E to power-cycle the display 3.3 V rail. + +**Library:** `python-vxi11` + +**SCPI command reference:** +``` +*IDN? → identity string +CH1:VOLTage → set voltage +CH1:CURRent → set current limit +OUTPut CH1,ON / OFF → enable/disable output +MEASure:VOLTage? CH1 → read back actual voltage +MEASure:CURRent? CH1 → read back actual current +``` + +**Methods required:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `__init__` | `(ip: str)` | Connect via vxi11, verify IDN, configure CH1 to 3.3 V / 1.0 A | +| `output_on` | `()` | `OUTPut CH1,ON` | +| `output_off` | `()` | `OUTPut CH1,OFF` | +| `power_cycle` | `(delay_s=2.0)` | Off → sleep → On | +| `measure` | `() → dict` | Returns `{"voltage_v": float, "current_a": float}` | +| `close` | `()` | Disconnect instrument | + +--- + +### 6.2 `hardware/scope.py` — `ScopeController` + +**Purpose:** Configure the DSO80204B, arm triggers, detect acquisitions, and download waveform data. + +**Library:** `python-vxi11` + +**Key SCPI commands for DSO80204B:** + +``` +*RST → factory reset +:RUN / :STOP / :SINGle → run modes +:CHANnel:DISPlay ON +:CHANnel:INPut DC50 → 50Ω termination (REQUIRED) +:CHANnel:PROBe 19.2 → attenuation ratio +:CHANnel:SCALe 0.05 → 50 mV/div +:CHANnel:OFFSet 0.0 +:CHANnel:LABel '' +:TIMebase:SCALe 5E-9 → 5 ns/div +:TIMebase:POSition 0 +:TIMebase:REFerence CENTer +:TRIGger:MODE EDGE +:TRIGger:EDGE:SOURce CHANnel +:TRIGger:EDGE:SLOPe NEGative +:TRIGger:EDGE:LEVel +:TRIGger:SWEep NORMal +:ACQuire:MODE RTIMe +:ACQuire:INTerpolate ON +:ACQuire:POINts 500000 +:DISPlay:LAYout STACKed +:TER? → trigger event register (1 = triggered) +:WAVeform:SOURce CHANnel +:WAVeform:FORMat ASCii +:WAVeform:STReaming ON +:WAVeform:DATA? → returns CSV waveform data +:WAVeform:PREamble? → returns x-increment, x-origin etc. +``` + +**Methods required:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `__init__` | `(ip: str)` | Connect, verify IDN | +| `setup` | `()` | Full channel + timebase + trigger configuration per `config.py` | +| `arm_single` | `()` | `:SINGle` — waits for one trigger event then holds | +| `wait_for_trigger` | `(timeout_s=30) → bool` | Poll `:TER?` every 100 ms; return True on trigger | +| `download_waveform` | `(channel: int) → pd.DataFrame` | Fetch ASCII waveform, apply preamble scaling, return DataFrame with columns `time_s`, `voltage_v` | +| `download_all` | `() → dict[str, pd.DataFrame]` | Download all 4 channels; keys: `CLK_P`, `CLK_N`, `DAT0_P`, `DAT0_N` | +| `close` | `()` | Disconnect | + +**Important:** After downloading a waveform, apply the `x_increment` and `x_origin` from `:WAVeform:PREamble?` so timestamps are absolute seconds. + +--- + +### 6.3 `hardware/target.py` — `TargetController` + +**Purpose:** HTTP REST client for the i.MX 8M Mini target. + +**Library:** `requests` + +**Endpoints (all at `http://192.168.45.8:5000`):** + +| Method | Endpoint | Payload | Returns | +|--------|----------|---------|---------| +| GET | `/registers` | — | JSON: DSIM PHY_TIMING registers | +| GET | `/sn65_registers` | — | JSON: Full SN65DSI83 register map | +| GET | `/sn65_settling` | — | JSON: Register poll over settling window | +| PUT | `/display` | `{"state": "on"\|"off"}` | `{"ok": true}` | +| PUT | `/video` | `{"action": "start"\|"stop", "mode": "static-pink"}` | `{"ok": true}` | + +**Methods required:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `__init__` | `(ip: str, port: int)` | Build base URL, verify connectivity with a GET /registers | +| `get_dsim_registers` | `() → dict` | GET /registers | +| `get_sn65_registers` | `() → dict` | GET /sn65_registers — server MUST bypass regmap cache before reading | +| `get_sn65_settling` | `() → dict` | GET /sn65_settling | +| `display_on` | `()` | PUT /display {"state": "on"} | +| `display_off` | `()` | PUT /display {"state": "off"} | +| `video_start` | `(mode="static-pink")` | PUT /video {"action": "start", "mode": mode} | +| `video_stop` | `()` | PUT /video {"action": "stop"} | + +--- + +## 7. REST SERVER SPECIFICATION (`server/app.py`) + +**Deploy on i.MX 8M Mini. Python 3.x, Flask.** + +### 7.1 GET `/registers` + +Executes on the SoM: +```bash +memtool md -l 0x32e100b4+0x0c +``` +Parse and return: +```json +{ + "PHY_TIMING": "0x00000305", + "PHY_TIMING1": "0x020e0a03", + "PHY_TIMING2": "0x00030605", + "raw_hex": "00000305 020e0a03 00030605" +} +``` + +### 7.2 GET `/sn65_registers` + +**Critical:** Before any I2C read, execute: +```bash +echo 1 > /sys/kernel/debug/regmap/2-002c/cache_bypass +``` +Then read the SN65DSI83 register file: +```bash +cat /sys/kernel/debug/regmap/2-002c/registers +``` +Parse the output and return a JSON dict of `{ "reg_hex": value_hex }` pairs. + +**Always explicitly include and flag these registers:** +```json +{ + "registers": { "00": "35", ..., "0a": "85", "e5": "00" }, + "pll_locked": true, // reg 0x0A bit 7 + "clk_detected": true, // reg 0x0B bit 0 + "irq_stat_raw": "0x00", // reg 0xE5 raw value + "sot_err": false, // reg 0xE5 bit 4 + "synch_err": false, // reg 0xE5 bit 3 + "unc_ecc_err":false // reg 0xE5 bit 6 +} +``` + +### 7.3 GET `/sn65_settling` + +Poll GET `/sn65_registers` every 100 ms for 2 seconds after power-up. +Return array of snapshots with timestamps. This catches transient error spikes +during the LP→HS initialization handshake. + +### 7.4 PUT `/display` + +```json +{"state": "on"} → echo 0 > /sys/class/graphics/fb0/blank +{"state": "off"} → echo 4 > /sys/class/graphics/fb0/blank +``` + +### 7.5 PUT `/video` + +```json +{"action": "start", "mode": "static-pink"} +``` +Start a GStreamer pipeline (or equivalent) that outputs a solid pink +framebuffer image to drive continuous DSI traffic. Example pipeline: +```bash +gst-launch-1.0 videotestsrc pattern=solid-color foreground-color=0xFFFF69B4 \ + ! video/x-raw,width=1280,height=800,framerate=60/1 \ + ! fbdevsink device=/dev/fb0 +``` +`{"action": "stop"}` → kill the pipeline. + +--- + +## 8. ANALYSIS MODULE SPECIFICATIONS + +### 8.1 `analysis/waveform.py` + +**Input:** Dict of DataFrames from `ScopeController.download_all()` + +**Purpose:** Extract D-PHY timing parameters from raw voltage waveforms. + +#### 8.1.1 Signal Reconstruction +- Compute differential signals: `CLK_DIFF = CLK_P - CLK_N`, `DAT0_DIFF = DAT0_P - DAT0_N` +- Compute common-mode: `CLK_CM = (CLK_P + CLK_N) / 2` + +#### 8.1.2 Lane State Detection +The D-PHY lane state machine uses **single-ended** voltage levels for LP states +and **differential** for HS. Thresholds (after 19.2× probe): + +| State | CLK_P | CLK_N | Interpretation | +|-------|-------|-------|----------------| +| LP-11 (Stop/Idle) | ~62 mV | ~62 mV | Both high (~1.2 V on wire) | +| LP-01 (HS Request)| ~0 mV | ~62 mV | Dp low, Dn high | +| LP-00 (Bridge) | ~0 mV | ~0 mV | Both low (Prepare phase) | +| HS-0 (HS burst) | diff ~±10 mV | — | Low-swing differential | + +> Threshold for LP "high": > 40 mV post-attenuation (~770 mV on wire). +> Threshold for LP "low": < 10 mV post-attenuation (~192 mV on wire). + +#### 8.1.3 Timing Measurements Required + +**`measure_t_lpx(data_lane_p, data_lane_n) → float (ns)`** +- Measure duration of LP-01 state on data lane (Dp low, Dn high). + +**`measure_t_hs_prepare(data_lane_p, data_lane_n) → float (ns)`** +- Measure duration of LP-00 state on data lane before HS-0 transition. + +**`measure_t_clk_prepare(clk_p, clk_n) → float (ns)`** +- Measure duration of LP-00 on clock lane before HS clock starts. + +**`measure_t_clk_zero(clk_p, clk_n) → float (ns)`** +- Measure duration of HS-0 (differential zero) on clock lane before + first clock toggle. Start: exit LP-00. End: first differential edge. + +**`measure_t_clk_prepare_plus_zero(clk_p, clk_n) → float (ns)`** +- Combined `t_clk_prepare + t_clk_zero`. **Must be >= 300 ns.** + +**`measure_t_hs_zero(data_lane_p, data_lane_n) → float (ns)`** +- Measure HS-0 preamble duration on data lane before SoT sync byte (00011101). + +#### 8.1.4 Spec Validation + +**`check_spec_compliance(measurements: dict) → dict`** +- Compare each measured value against `DPHY_SPEC` from `config.py`. +- Return dict with per-parameter: `{"measured_ns": float, "min_ns": float, "pass": bool, "margin_ns": float}` + +--- + +### 8.2 `analysis/registers.py` + +**`parse_sn65(reg_json: dict) → dict`** +- Extract and interpret all flags from `/sn65_registers` response. +- Return structured dict including `flicker_detected: bool` based on `SN65_FLICKER_MASK`. + +**`parse_dsim(reg_json: dict) → dict`** +- Decode PHY_TIMING, PHY_TIMING1, PHY_TIMING2 into individual cycle counts. +- PHY_TIMING (0x32E100B4): bits [7:4]=lpx, bits[3:0]=hs_exit +- PHY_TIMING1 (0x32E100B8): bits[31:24]=clk_zero, bits[23:16]=clk_post, + bits[15:8]=clk_trail, bits[7:0]=clk_prepare +- PHY_TIMING2 (0x32E100BC): bits[23:16]=hs_prepare, bits[15:8]=hs_zero, + bits[7:0]=hs_trail +- Convert cycles to nanoseconds: `ns = cycles / BYTE_CLK_HZ * 1e9` + +--- + +### 8.3 `analysis/report.py` + +Per-run outputs saved to `captures/run_NNN_YYYYMMDD_HHMMSS/`: + +| File | Format | Content | +|------|--------|---------| +| `waveform_ch1.csv` through `ch4.csv` | CSV | Raw scope data, columns: `time_s`, `voltage_v` | +| `registers.json` | JSON | DSIM + SN65 register snapshot at trigger time | +| `timing_analysis.json` | JSON | Measured D-PHY timings + spec compliance results | +| `summary.txt` | Text | Human-readable pass/fail + key measurements | +| `flicker_log.csv` | CSV (appended) | Master log — one row per run | + +**`flicker_log.csv` columns:** +``` +run_id, timestamp, flicker_detected, sot_err, synch_err, pll_locked, +t_lpx_ns, t_hs_prepare_ns, t_hs_prepare_pass, +t_clk_prepare_ns, t_clk_zero_ns, t_clk_prep_plus_zero_ns, t_clk_prep_zero_pass, +phy_timing_raw, phy_timing1_raw, phy_timing2_raw, +notes +``` + +--- + +## 9. MASTER LOOP (`master_loop.py`) + +### 9.1 Startup Sequence + +``` +1. Instantiate PSUController, ScopeController, TargetController +2. PSU: Configure CH1 to 3.3V / 1.0A, output OFF +3. Target: PUT /display off, PUT /video stop +4. Scope: setup() — full channel + trigger configuration +5. Log start time and initial DSIM register state +``` + +### 9.2 Main Loop (runs until KeyboardInterrupt or max_runs reached) + +``` +FOR each run: + + [RESET PHASE] + 1. target.display_off() + 2. target.video_stop() + 3. psu.output_off() + 4. sleep(PSU_POWER_CYCLE_DELAY_S) + + [ARM PHASE] + 5. scope.arm_single() + 6. Create timestamped run directory under captures/ + + [STIMULUS PHASE] + 7. psu.output_on() + 8. sleep(0.5) # allow rails to stabilise + 9. target.display_on() + 10. target.video_start(mode="static-pink") + + [ACQUIRE PHASE] + 11. triggered = scope.wait_for_trigger(timeout_s=30) + 12. If NOT triggered: log "TIMEOUT", continue to next run + + [CAPTURE PHASE] + 13. waveforms = scope.download_all() # all 4 channels + 14. sn65_data = target.get_sn65_registers() + 15. dsim_data = target.get_dsim_registers() + 16. settling = target.get_sn65_settling() + + [ANALYSIS PHASE] + 17. timings = waveform.measure_all(waveforms) + 18. spec_pass = waveform.check_spec_compliance(timings) + 19. sn65_parsed = registers.parse_sn65(sn65_data) + 20. dsim_parsed = registers.parse_dsim(dsim_data) + + [DETECT PHASE] + 21. flicker_detected = sn65_parsed["flicker_detected"] + 22. If flicker_detected: print "*** FLICKER EVENT CAPTURED ***" + + [SAVE PHASE] + 23. Save all waveform CSVs + 24. Save registers.json, timing_analysis.json, summary.txt + 25. Append row to flicker_log.csv + + [REPORT PHASE] + 26. Print one-line status: run ID, flicker Y/N, key timing margins + +END LOOP +``` + +### 9.3 Command-Line Arguments + +``` +python master_loop.py [options] + +--max-runs N Stop after N captures (default: unlimited) +--timeout S Scope trigger timeout per run in seconds (default: 30) +--pixel-clock HZ Override pixel clock (default: 72000000) +--note "string" Append a note to every log row (e.g. "dsi-tweak=5") +--no-video Skip PUT /video (test display blank/unblank only) +--output-dir PATH Override captures root directory +``` + +--- + +## 10. DSIM REGISTER DECODE REFERENCE + +At 72 MHz pixel clock (DSI = 432 MHz, byte clock = 54 MHz): + +### Expected "Round Up" values (dsi-tweak bit 2 set): + +``` +PHY_TIMING = 0x00000306 + [7:4] hs_exit = 0x0 = 0 cycles ← NOTE: check kernel log for actual value + [3:0] lpx = 6 = 6 cycles → 111 ns (spec ≥50 ns ✓) + +PHY_TIMING1 = 0x03120a04 + [31:24] clk_zero = 0x03 = 3? ← cross-check with kernel log + [23:16] clk_post = 0x12 = 18 cycles + [15:8] clk_trail = 0x0a = 10 cycles + [7:0] clk_prepare = 0x04 = 4 cycles → 74 ns (spec ≥38 ns ✓) + +PHY_TIMING2 = 0x00040707 + [23:16] hs_prepare = 0x04 = 4 cycles → 74 ns + [15:8] hs_zero = 0x07 = 7 cycles → 130 ns + [7:0] hs_trail = 0x07 = 7 cycles → 130 ns +``` + +> **Note:** The exact bit-field layout is **not well-documented** in the i.MX8M Mini +> reference manual (see the TODO comment in `samsung-dsim.c`). The register parser +> should log the raw hex values AND the decoded cycle counts so they can be +> cross-referenced against kernel log output for verification. + +--- + +## 11. SN65DSI83 REGISTER REFERENCE + +**I2C address:** 0x2C (ADDR pin = GND) +**Bus:** i2c-2 on target (`/dev/i2c-2`) + +| Register | Address | Key Bits | Description | +|----------|---------|----------|-------------| +| RC_RESET | 0x09 | [0] = SOFT_RESET | Write 1 to reset | +| RC_LVDS_PLL | 0x0A | [7] = PLL_EN, read-back = PLL_LOCK | Volatile — reads hardware | +| RC_LVDS_CLK | 0x0B | [0] = CLK_DETECT | Clock detected on DSI input | +| IRQ_STAT | 0xE5 | [6]=UNC_ECC, [4]=SOT_ERR, [3]=SYNCH_ERR, [1]=CRC_ERR | **Volatile** — must bypass cache | + +**Cache bypass (execute before reading IRQ_STAT):** +```bash +echo 1 > /sys/kernel/debug/regmap/2-002c/cache_bypass +``` + +**Manual I2C read (fallback if regmap unavailable):** +```bash +i2cget -y -f 2 0x2c 0xe5 +``` + +**Test pattern commands (useful for isolating LVDS vs DSI fault):** +```bash +# Enable test pattern (removes DSI as variable) +i2cset -y -f 4 0x2c 0x3c 0x10 +# Disable test pattern +i2cset -y -f 4 0x2c 0x3c 0x00 +``` + +--- + +## 12. U-BOOT TUNING PARAMETERS (Reference) + +These are set on the SoM via U-Boot environment variables. The REST server +can optionally expose a PUT `/uboot_env` endpoint to change them between +test runs (requires reboot), or they can be set manually between test sessions. + +```bash +# Pixel clock +setenv flb_dtovar/lvds-freq 72000000 + +# DSI clock override (should be >= 6 × lvds-freq for 24bpp/4-lane) +setenv flb_dtovar/dsi-freq 432000000 + +# DSI tweak bitmask +# Bit 0: FIFO flush on VSync (recommended ON) +# Bit 2: Round-up rounding (recommended ON) +setenv flb_dtovar/dsi-tweak 5 + +# Per-parameter extra cycle padding +setenv flb_dtovar/dsi-phy-extra-clk_zero 3 +setenv flb_dtovar/dsi-phy-extra-hs_prepare 1 +setenv flb_dtovar/dsi-phy-extra-hs_trail 1 + +# Other available padding parameters +# flb_dtovar/dsi-phy-extra-lpx +# flb_dtovar/dsi-phy-extra-hs_exit +# flb_dtovar/dsi-phy-extra-clk_prepare +# flb_dtovar/dsi-phy-extra-clk_post +# flb_dtovar/dsi-phy-extra-clk_trail +# flb_dtovar/dsi-phy-extra-hs_zero + +saveenv +reset +``` + +> **Note:** Changing `lvds-freq` breaks U-Boot splash (hardcoded PLL table). +> `dsi-tweak` / padding params do NOT require matching splash changes. + +--- + +## 13. PACKET-LEVEL DECODE (Lane 0 Data Analysis) + +This section defines how the analysis script should decode the DSI packet stream from +the raw Lane 0 differential waveform. This is the **ground truth** fault detector, +validated by the Falcon board manual decode (S. Bouriot, May 2024). + +### 13.1 DSI Long Packet Structure (24-bit RGB, Data Type 0x3E) + +``` + 1 byte 2 bytes 1 byte WORD COUNT bytes 2 bytes +[DATA TYPE][WORD COUNT][ ECC ][ 24bpp Pixel Stream ... ][ CRC ] + <-------- Packet Header ------> <-- Packet Payload ----> Footer +``` + +With 4 data lanes, bytes are distributed across lanes in round-robin order. +Lane 0 carries bytes 0, 4, 8, 12... of the serialised packet stream. + +For a 1024-pixel-wide line (24bpp, 4 lanes): +- Total payload bytes = 1024 x 3 = 3072 +- Bytes on Lane 0 = 768 +- Theoretical clock edges per line (header excluded) = 1024 x 3 x 8 / 4 = **6144** + (Falcon measured ~6150 OK / ~6146 KO — within measurement error, line count consistent) + +### 13.2 Short Packet Types Observed + +- `0x21` = Sync Event, H Sync Start (short packet preceding each long packet) +- `0x3E` = Packed Pixel Stream, 24-bit RGB (long packet, one per display line) +- Each frame is preceded by a group of short packets (~39 LP11->LP0x transitions per frame, + per Falcon measurement on a 768-line display) + +### 13.3 Healthy Frame Signature + +Lane 0 byte sequence after SoT: +``` +0x21 → H-Sync short packet (repeated ~39 times as frame preamble) +0x3E → Long packet Data Type (start of line 1 RGB payload) +[payload byte 0]: non-zero R/G/B value present IMMEDIATELY from start of payload +``` +Example with test pixels (R=0xD1, G=0xB5, B=0x90): +``` +Lane 0 payload bytes: D1 B5 90 00 D1 B5 90 00 ... (pixel 0 RR, pixel 1 GG, pixel 2 BB...) +``` + +### 13.4 Fault Signature A — Pixel Data Offset (Shifted Display) + +First long packet after frame preamble: +``` +0x3E → Data Type correct +payload bytes 0..N: 00 00 00 00 00 00 ... ← ALL ZEROS, pixel data absent +``` +Second long packet (~995 µs later per Falcon measurement): +``` +payload at offset ~15 µs: D1 B5 90 ← pixel data arrives in WRONG packet/position +``` +This is confirmed by Falcon analysis as the direct cause of visible horizontal shift. +The packet headers are identical in OK and KO states — the fault is purely in payload +position, not in framing or Data Type bytes. + +### 13.5 Fault Signature B — Lane Stall (Flickering/Blackout) + +- Lane 0 differential collapses to ~0 V (LP-11 Stop state) for **~20 ms** at ~20 ms intervals +- Zero HS bursts transmitted during stall window +- Abnormal — never observed in healthy display operation +- Corresponds to the Nexio "total blackout" failure mode requiring power cycle to recover +- May co-occur with Fault A (shifted display + flicker seen simultaneously on Falcon) + +### 13.6 Packet Decode Implementation Notes + +`analysis/waveform.py` should implement `decode_lane0_packets()`: + +1. Compute `DAT0_DIFF = DAT0_P - DAT0_N` +2. Identify HS burst boundaries: look for differential swing exceeding ±8 mV (post 19.2x + attenuation) after a period of LP-00 (both lines near 0 V) +3. Use recovered clock from `CLK_DIFF` edges to sample data bits (DDR — both edges) +4. Deserialise bytes MSB-first; check for Lane 0 SoT sync word (`0xB8` after the + `LP-11 -> LP-01 -> LP-00 -> HS-0` preamble) +5. Extract Data Type byte, Word Count (2 bytes), ECC (1 byte), then payload +6. Return structured list: `[{burst_idx, timestamp_s, data_type, word_count, payload_hex}]` + +**Scope window constraint:** At 5 ns/div with 500 kpts, the capture window is ~2.5 µs — +sufficient for SoT + packet header + first ~200 bytes of payload. For full line decode, +set timebase to 500 ns/div or use the scope's segmented memory mode if available. +The header check alone (first 4 payload bytes = 0x00 vs non-zero) is sufficient to +classify Fault A without needing to decode the entire line. + +--- + +## 14. KNOWN FAILURE MODES & EXPECTED SIGNATURES + +### 14.1 Transient Flicker (Vertical Jitter) +- **Frequency:** Every few seconds to minutes depending on pixel clock +- **Scope signature:** Normal-looking SoT sequence but `t_hs_prepare` or + `t_clk_prepare + t_clk_zero` just below spec minimum +- **Register signature:** REG_IRQ 0xE5 bits 3 or 4 set transiently +- **Packet signature:** First long packet payload starts with 0x00 bytes; + pixel data found offset into second packet (confirmed by Falcon analysis) +- **Recovery:** Automatic — next valid frame corrects sync + +### 14.2 Total Display Blackout +- **Frequency:** Rare; requires power cycle or driver unbind/rebind to recover +- **Cause:** DSI controller enters hang state; display power rail remains up + (shared with touchscreen, light sensor, LED) +- **Recovery steps:** + ```bash + echo 3-0023 > /sys/bus/i2c/devices/i2c-3/3-0023/driver/unbind + echo 3-002d > /sys/bus/i2c/devices/i2c-3/3-002d/driver/unbind + echo 3-0041 > /sys/bus/i2c/devices/i2c-3/3-0041/driver/unbind + echo 4 > /sys/class/graphics/fb0/blank + echo 0 > /sys/class/graphics/fb0/blank + ``` +- **Scope signature:** LP lines fail to return from LP-00 to LP-11 after burst; + lane stays in LP-11 for ~20 ms intervals (confirmed by Falcon analysis) + +### 14.3 Pixel Clock Sensitivity Pattern +Empirically observed — some clock frequencies are stable, others cause +consistent jitter. The boundary appears non-monotonic: + +| Pixel Clock | Behaviour | +|-------------|-----------| +| 70 MHz | Stable | +| 71 MHz | Jitter | +| 72 MHz | Stable (current setting) | +| 72.4 MHz | Jitter (datasheet "typical") | +| 73 MHz | Stable | +| 74 MHz | Jitter | +| 75–76 MHz | Stable | + +This non-monotonic pattern suggests **resonance interaction** between the DSI PLL +and the SN65DSI83 PLL, or undocumented constraints in the samsung-dsim PHY. +The scope capture at multiple clock frequencies may reveal whether `t_clk_prepare + +t_clk_zero` tracks the instability boundary. + +--- + +## 15. IMPORTANT IMPLEMENTATION NOTES + +1. **Always use `DC 50Ω` input on scope** — 1 MΩ input with MIPI-speed signals + will produce useless ringing. The 910R + 50R divider is only correct with 50Ω input. + +2. **19.2× attenuation:** The probe factor is `(910 + 50) / 50 = 19.2`. LP-11 state + (~1.2 V on wire) appears as ~62.5 mV at scope. HS swing (~200 mV on wire) + appears as ~10.4 mV. Set scope vertical scale to 50 mV/div. + +3. **Trigger level 50 mV (post-attenuation):** This catches the falling edge of + DAT0+ from LP-01 (~62 mV) to LP-00 (~0 mV) — the entry into T_HS_PREPARE. + +4. **Cache bypass is mandatory** before reading IRQ_STAT (0xE5). Without it you + get the last-written value, not the current hardware state. The server must + do this every time `/sn65_registers` is called. + +5. **Unit Interval (UI):** At 72 MHz pixel clock, DSI = 432 Mbps per lane, + UI = 1/432MHz ≈ 2.315 ns. All spec minimums involving UI must be calculated + dynamically if pixel clock is changed via `--pixel-clock` argument. + +6. **The DSIM register layout is undocumented** (see TODO in `samsung-dsim.c`). + Log raw hex AND decoded values. Cross-reference against kernel `dmesg` output + which prints the cycle counts explicitly when `dsi-tweak` logging is enabled. diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..340c811 Binary files /dev/null and b/__pycache__/config.cpython-312.pyc differ diff --git a/__pycache__/master_loop.cpython-312.pyc b/__pycache__/master_loop.cpython-312.pyc new file mode 100644 index 0000000..53be98b Binary files /dev/null and b/__pycache__/master_loop.cpython-312.pyc differ diff --git a/analysis/__init__.py b/analysis/__init__.py new file mode 100644 index 0000000..38d71c5 --- /dev/null +++ b/analysis/__init__.py @@ -0,0 +1 @@ +"""Pure analysis functions over captured waveforms and register dumps.""" diff --git a/analysis/__pycache__/__init__.cpython-312.pyc b/analysis/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..30edfef Binary files /dev/null and b/analysis/__pycache__/__init__.cpython-312.pyc differ diff --git a/analysis/__pycache__/registers.cpython-312.pyc b/analysis/__pycache__/registers.cpython-312.pyc new file mode 100644 index 0000000..2fb60fc Binary files /dev/null and b/analysis/__pycache__/registers.cpython-312.pyc differ diff --git a/analysis/__pycache__/report.cpython-312.pyc b/analysis/__pycache__/report.cpython-312.pyc new file mode 100644 index 0000000..223fdc1 Binary files /dev/null and b/analysis/__pycache__/report.cpython-312.pyc differ diff --git a/analysis/__pycache__/waveform.cpython-312.pyc b/analysis/__pycache__/waveform.cpython-312.pyc new file mode 100644 index 0000000..3059aeb Binary files /dev/null and b/analysis/__pycache__/waveform.cpython-312.pyc differ diff --git a/analysis/registers.py b/analysis/registers.py new file mode 100644 index 0000000..24ab48f --- /dev/null +++ b/analysis/registers.py @@ -0,0 +1,132 @@ +"""Parse SN65DSI83 and DSIM register dumps into structured flags. + +DSIM PHY_TIMING bit-field layout is undocumented in the i.MX 8M Mini RM. +We log raw hex AND decoded cycle counts so they can be cross-checked +against kernel dmesg output that prints the cycle counts explicitly. +""" + +from __future__ import annotations + +from typing import Optional + +from config import ( + BYTE_CLK_HZ, + SN65_ERR_SOT, + SN65_ERR_SYNCH, + SN65_ERR_UNC, + SN65_FLICKER_MASK, +) + + +def _to_int(v) -> Optional[int]: + if v is None: + return None + if isinstance(v, int): + return v + s = str(v).strip().lower() + try: + if s.startswith("0x"): + return int(s, 16) + return int(s, 16) + except ValueError: + return None + + +# --------------------------------------------------------------------------- +# SN65DSI83 +# --------------------------------------------------------------------------- + +def parse_sn65(reg_json: dict) -> dict: + """Extract structured flicker flags from /sn65_registers response. + + Accepts either the server's pre-parsed shape (with explicit bool keys) + or a raw {register: hex} mapping; falls back to bit-decoding in either case. + """ + irq_raw = _to_int(reg_json.get("irq_stat_raw")) + if irq_raw is None: + regs = reg_json.get("registers", {}) + irq_raw = _to_int(regs.get("e5") or regs.get("E5") or regs.get("0xE5")) + irq_raw = irq_raw or 0 + + pll_raw = _to_int(reg_json.get("registers", {}).get("0a")) if reg_json.get("registers") else None + clk_raw = _to_int(reg_json.get("registers", {}).get("0b")) if reg_json.get("registers") else None + + pll_locked = reg_json.get("pll_locked") + if pll_locked is None and pll_raw is not None: + pll_locked = bool(pll_raw & 0x80) + + clk_detected = reg_json.get("clk_detected") + if clk_detected is None and clk_raw is not None: + clk_detected = bool(clk_raw & 0x01) + + sot_err = bool(irq_raw & SN65_ERR_SOT) + synch_err = bool(irq_raw & SN65_ERR_SYNCH) + unc_ecc_err = bool(irq_raw & SN65_ERR_UNC) + flicker_detected = bool(irq_raw & SN65_FLICKER_MASK) + + return { + "irq_stat_raw": f"0x{irq_raw:02X}", + "irq_stat_int": irq_raw, + "pll_locked": bool(pll_locked) if pll_locked is not None else None, + "clk_detected": bool(clk_detected) if clk_detected is not None else None, + "sot_err": sot_err, + "synch_err": synch_err, + "unc_ecc_err": unc_ecc_err, + "flicker_detected": flicker_detected, + "registers": reg_json.get("registers", {}), + } + + +# --------------------------------------------------------------------------- +# DSIM PHY_TIMING / PHY_TIMING1 / PHY_TIMING2 +# --------------------------------------------------------------------------- + +def _cycles_to_ns(cycles: int) -> float: + return cycles / BYTE_CLK_HZ * 1e9 + + +def parse_dsim(reg_json: dict) -> dict: + pt = _to_int(reg_json.get("PHY_TIMING")) + pt1 = _to_int(reg_json.get("PHY_TIMING1")) + pt2 = _to_int(reg_json.get("PHY_TIMING2")) + + out: dict = { + "PHY_TIMING_raw": f"0x{pt:08X}" if pt is not None else None, + "PHY_TIMING1_raw": f"0x{pt1:08X}" if pt1 is not None else None, + "PHY_TIMING2_raw": f"0x{pt2:08X}" if pt2 is not None else None, + } + + if pt is not None: + hs_exit = (pt >> 4) & 0xF + lpx = pt & 0xF + out["hs_exit_cycles"] = hs_exit + out["hs_exit_ns"] = _cycles_to_ns(hs_exit) + out["lpx_cycles"] = lpx + out["lpx_ns"] = _cycles_to_ns(lpx) + + if pt1 is not None: + clk_zero = (pt1 >> 24) & 0xFF + clk_post = (pt1 >> 16) & 0xFF + clk_trail = (pt1 >> 8) & 0xFF + clk_prepare = pt1 & 0xFF + out["clk_zero_cycles"] = clk_zero + out["clk_zero_ns"] = _cycles_to_ns(clk_zero) + out["clk_post_cycles"] = clk_post + out["clk_post_ns"] = _cycles_to_ns(clk_post) + out["clk_trail_cycles"] = clk_trail + out["clk_trail_ns"] = _cycles_to_ns(clk_trail) + out["clk_prepare_cycles"] = clk_prepare + out["clk_prepare_ns"] = _cycles_to_ns(clk_prepare) + + if pt2 is not None: + hs_prepare = (pt2 >> 16) & 0xFF + hs_zero = (pt2 >> 8) & 0xFF + hs_trail = pt2 & 0xFF + out["hs_prepare_cycles"] = hs_prepare + out["hs_prepare_ns"] = _cycles_to_ns(hs_prepare) + out["hs_zero_cycles"] = hs_zero + out["hs_zero_ns"] = _cycles_to_ns(hs_zero) + out["hs_trail_cycles"] = hs_trail + out["hs_trail_ns"] = _cycles_to_ns(hs_trail) + + return out diff --git a/analysis/report.py b/analysis/report.py new file mode 100644 index 0000000..d1dc76b --- /dev/null +++ b/analysis/report.py @@ -0,0 +1,172 @@ +"""Per-run artefact writers and the master flicker_log.csv appender.""" + +from __future__ import annotations + +import csv +import json +import os +from datetime import datetime +from pathlib import Path +from typing import Optional + +import pandas as pd + +from config import CAPTURE_ROOT + + +FLICKER_LOG_NAME = "flicker_log.csv" +FLICKER_LOG_COLUMNS = [ + "run_id", + "timestamp", + "flicker_detected", + "sot_err", + "synch_err", + "pll_locked", + "t_lpx_ns", + "t_hs_prepare_ns", + "t_hs_prepare_pass", + "t_clk_prepare_ns", + "t_clk_zero_ns", + "t_clk_prep_plus_zero_ns", + "t_clk_prep_zero_pass", + "phy_timing_raw", + "phy_timing1_raw", + "phy_timing2_raw", + "notes", +] + + +def make_run_dir(root: str = CAPTURE_ROOT, run_idx: Optional[int] = None) -> Path: + base = Path(root) + base.mkdir(parents=True, exist_ok=True) + if run_idx is None: + run_idx = _next_run_index(base) + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + run_id = f"run_{run_idx:03d}_{stamp}" + path = base / run_id + path.mkdir(parents=True, exist_ok=False) + return path + + +def _next_run_index(base: Path) -> int: + existing = [p.name for p in base.iterdir() if p.is_dir() and p.name.startswith("run_")] + if not existing: + return 1 + nums: list[int] = [] + for n in existing: + try: + nums.append(int(n.split("_")[1])) + except (IndexError, ValueError): + continue + return (max(nums) + 1) if nums else 1 + + +def save_waveforms(run_dir: Path, waveforms: dict[str, pd.DataFrame]) -> None: + """Save each channel as waveform_chN.csv per spec §8.3.""" + label_to_ch = {"CLK_P": 1, "CLK_N": 2, "DAT0_P": 3, "DAT0_N": 4} + for label, df in waveforms.items(): + ch = label_to_ch.get(label) + if ch is None: + continue + df.to_csv(run_dir / f"waveform_ch{ch}.csv", index=False) + + +def save_registers(run_dir: Path, dsim: dict, sn65: dict, settling: dict | list) -> None: + payload = {"dsim": dsim, "sn65": sn65, "settling": settling} + (run_dir / "registers.json").write_text(json.dumps(payload, indent=2)) + + +def save_timing_analysis(run_dir: Path, measurements: dict, spec_pass: dict, + packet_fault: dict, lane_stall: dict) -> None: + payload = { + "measurements_ns": measurements, + "spec_compliance": spec_pass, + "packet_fault_a": packet_fault, + "lane_stall_b": lane_stall, + } + (run_dir / "timing_analysis.json").write_text(json.dumps(payload, indent=2)) + + +def save_summary(run_dir: Path, summary_text: str) -> None: + (run_dir / "summary.txt").write_text(summary_text) + + +def append_flicker_log(root: str, row: dict) -> None: + log_path = Path(root) / FLICKER_LOG_NAME + is_new = not log_path.exists() + with log_path.open("a", newline="") as f: + writer = csv.DictWriter(f, fieldnames=FLICKER_LOG_COLUMNS, extrasaction="ignore") + if is_new: + writer.writeheader() + writer.writerow(row) + + +def build_summary(run_id: str, sn65_parsed: dict, measurements: dict, + spec_pass: dict, packet_fault: dict, lane_stall: dict, + dsim_parsed: dict, note: str = "") -> str: + lines = [ + f"Run: {run_id}", + f"Timestamp: {datetime.now().isoformat(timespec='seconds')}", + "", + "[ SN65DSI83 ]", + f" PLL locked: {sn65_parsed.get('pll_locked')}", + f" Clock detect: {sn65_parsed.get('clk_detected')}", + f" IRQ_STAT: {sn65_parsed.get('irq_stat_raw')}", + f" SOT_ERR: {sn65_parsed.get('sot_err')}", + f" SYNCH_ERR: {sn65_parsed.get('synch_err')}", + f" UNC_ECC_ERR: {sn65_parsed.get('unc_ecc_err')}", + f" FLICKER: {sn65_parsed.get('flicker_detected')}", + "", + "[ D-PHY timings (ns) ]", + ] + for k, v in measurements.items(): + sp = spec_pass.get(k, {}) + marker = "OK" if sp.get("pass") else "VIOLATION" + margin = sp.get("margin_ns") + margin_str = f"margin={margin:+.2f}" if margin is not None else "margin=n/a" + v_str = f"{v:.2f}" if v is not None and v == v else "nan" # NaN check + lines.append(f" {k:30s} {v_str:>8s} [{marker}] (min={sp.get('min_ns')}, {margin_str})") + + lines += [ + "", + "[ Packet decode (Lane 0) ]", + f" Fault A (zero-payload pixel pkt): {packet_fault.get('fault_a_detected')}", + f" First payload bytes: {packet_fault.get('first_pixel_payload_hex')}", + f" Pixel packets / total: " + f"{packet_fault.get('n_pixel_packets')} / {packet_fault.get('n_total_packets')}", + "", + "[ Lane stall ]", + f" Fault B (LP-11 stall): {lane_stall.get('fault_b_detected')}", + f" Longest LP-11 (ms): {lane_stall.get('longest_lp11_ms')}", + "", + "[ DSIM raw / decoded ]", + f" PHY_TIMING: {dsim_parsed.get('PHY_TIMING_raw')}", + f" PHY_TIMING1: {dsim_parsed.get('PHY_TIMING1_raw')}", + f" PHY_TIMING2: {dsim_parsed.get('PHY_TIMING2_raw')}", + ] + if note: + lines += ["", f"Note: {note}"] + return os.linesep.join(lines) + os.linesep + + +def build_log_row(run_id: str, sn65_parsed: dict, measurements: dict, + spec_pass: dict, dsim_parsed: dict, note: str = "") -> dict: + return { + "run_id": run_id, + "timestamp": datetime.now().isoformat(timespec="seconds"), + "flicker_detected": sn65_parsed.get("flicker_detected"), + "sot_err": sn65_parsed.get("sot_err"), + "synch_err": sn65_parsed.get("synch_err"), + "pll_locked": sn65_parsed.get("pll_locked"), + "t_lpx_ns": measurements.get("t_lpx"), + "t_hs_prepare_ns": measurements.get("t_hs_prepare"), + "t_hs_prepare_pass": spec_pass.get("t_hs_prepare", {}).get("pass"), + "t_clk_prepare_ns": measurements.get("t_clk_prepare"), + "t_clk_zero_ns": measurements.get("t_clk_zero"), + "t_clk_prep_plus_zero_ns": measurements.get("t_clk_prepare_plus_zero"), + "t_clk_prep_zero_pass": spec_pass.get("t_clk_prepare_plus_zero", {}).get("pass"), + "phy_timing_raw": dsim_parsed.get("PHY_TIMING_raw"), + "phy_timing1_raw": dsim_parsed.get("PHY_TIMING1_raw"), + "phy_timing2_raw": dsim_parsed.get("PHY_TIMING2_raw"), + "notes": note, + } diff --git a/analysis/waveform.py b/analysis/waveform.py new file mode 100644 index 0000000..4050986 --- /dev/null +++ b/analysis/waveform.py @@ -0,0 +1,401 @@ +"""D-PHY timing extraction and Lane 0 packet decode from scope waveforms. + +All voltage thresholds in this module are POST-attenuation values (i.e. what +the scope sees after the 19.2× probe divider). Don't rescale them back to +wire voltages — the divider is calibrated and the thresholds were chosen +to give clean LP/HS state separation at probe output. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Optional + +import numpy as np +import pandas as pd + +from config import DPHY_SPEC + +log = logging.getLogger(__name__) + +# Post-attenuation thresholds (volts at scope input, after 19.2× divider). +LP_HIGH_V = 0.040 # "above" → LP-1 (~770 mV on wire) +LP_LOW_V = 0.010 # "below" → LP-0 / HS-0 (~190 mV on wire) +HS_DIFF_V = 0.008 # |CLK_P − CLK_N| above this means HS burst is active + + +@dataclass +class LaneStateSpan: + """A contiguous run of single-ended-detected lane state.""" + state: str # "LP-11" | "LP-01" | "LP-10" | "LP-00" | "HS" + t_start: float + t_end: float + + @property + def duration_ns(self) -> float: + return (self.t_end - self.t_start) * 1e9 + + +# --------------------------------------------------------------------------- +# Signal reconstruction +# --------------------------------------------------------------------------- + +def differential(lane_p: pd.DataFrame, lane_n: pd.DataFrame) -> pd.Series: + return pd.Series(lane_p["voltage_v"].values - lane_n["voltage_v"].values) + + +def common_mode(lane_p: pd.DataFrame, lane_n: pd.DataFrame) -> pd.Series: + return pd.Series((lane_p["voltage_v"].values + lane_n["voltage_v"].values) / 2.0) + + +# --------------------------------------------------------------------------- +# Lane state machine +# --------------------------------------------------------------------------- + +def _classify_sample(vp: float, vn: float, vdiff: float) -> str: + """Classify a single (p, n) sample into a D-PHY lane state.""" + if abs(vdiff) > HS_DIFF_V and vp < LP_HIGH_V and vn < LP_HIGH_V: + return "HS" + p_high = vp > LP_HIGH_V + n_high = vn > LP_HIGH_V + p_low = vp < LP_LOW_V + n_low = vn < LP_LOW_V + if p_high and n_high: + return "LP-11" + if p_low and n_high: + return "LP-01" + if p_high and n_low: + return "LP-10" + if p_low and n_low: + return "LP-00" + return "TRANS" # in-between, not yet a settled state + + +def classify_lane(lane_p: pd.DataFrame, lane_n: pd.DataFrame) -> list[LaneStateSpan]: + """Walk both single-ended traces and emit consecutive state spans. + + Spans labelled "TRANS" are dropped — they are sub-sample edge transitions, + not real D-PHY states. Adjacent same-state spans are merged. + """ + t = lane_p["time_s"].values + vp = lane_p["voltage_v"].values + vn = lane_n["voltage_v"].values + vd = vp - vn + + spans: list[LaneStateSpan] = [] + cur_state: Optional[str] = None + cur_start = t[0] + + for i in range(len(t)): + s = _classify_sample(vp[i], vn[i], vd[i]) + if s == "TRANS": + continue + if cur_state is None: + cur_state = s + cur_start = t[i] + continue + if s != cur_state: + spans.append(LaneStateSpan(cur_state, cur_start, t[i])) + cur_state = s + cur_start = t[i] + + if cur_state is not None: + spans.append(LaneStateSpan(cur_state, cur_start, t[-1])) + + return spans + + +def _first_span(spans: list[LaneStateSpan], state: str, + start_idx: int = 0) -> Optional[tuple[int, LaneStateSpan]]: + for i in range(start_idx, len(spans)): + if spans[i].state == state: + return i, spans[i] + return None + + +# --------------------------------------------------------------------------- +# Per-parameter measurements +# --------------------------------------------------------------------------- +# Each function returns nanoseconds, or NaN if the relevant state span is not +# present in the capture window. + +def measure_t_lpx(data_lane_p: pd.DataFrame, data_lane_n: pd.DataFrame) -> float: + """Duration of LP-01 (Dp low, Dn high) on data lane — HS Request.""" + spans = classify_lane(data_lane_p, data_lane_n) + hit = _first_span(spans, "LP-01") + return hit[1].duration_ns if hit else float("nan") + + +def measure_t_hs_prepare(data_lane_p: pd.DataFrame, data_lane_n: pd.DataFrame) -> float: + """Duration of LP-00 on data lane immediately before HS-0 entry.""" + spans = classify_lane(data_lane_p, data_lane_n) + for i in range(len(spans) - 1): + if spans[i].state == "LP-00" and spans[i + 1].state == "HS": + return spans[i].duration_ns + return float("nan") + + +def measure_t_clk_prepare(clk_p: pd.DataFrame, clk_n: pd.DataFrame) -> float: + """Duration of LP-00 on clock lane immediately before HS clock starts.""" + spans = classify_lane(clk_p, clk_n) + for i in range(len(spans) - 1): + if spans[i].state == "LP-00" and spans[i + 1].state == "HS": + return spans[i].duration_ns + return float("nan") + + +def measure_t_clk_zero(clk_p: pd.DataFrame, clk_n: pd.DataFrame) -> float: + """Duration of HS-0 on clock lane before first clock toggle. + + Implementation: find the LP-00 → HS transition, then walk the differential + until the first edge crossing in the opposite polarity (clock toggle). + """ + t = clk_p["time_s"].values + vd = clk_p["voltage_v"].values - clk_n["voltage_v"].values + + spans = classify_lane(clk_p, clk_n) + hs_start: Optional[float] = None + for i in range(len(spans) - 1): + if spans[i].state == "LP-00" and spans[i + 1].state == "HS": + hs_start = spans[i + 1].t_start + break + if hs_start is None: + return float("nan") + + start_idx = int(np.searchsorted(t, hs_start)) + initial = vd[start_idx] + sign = -1 if initial >= 0 else 1 # look for opposite-polarity crossing + for j in range(start_idx + 1, len(vd)): + if (sign > 0 and vd[j] > HS_DIFF_V) or (sign < 0 and vd[j] < -HS_DIFF_V): + return (t[j] - hs_start) * 1e9 + return float("nan") + + +def measure_t_clk_prepare_plus_zero(clk_p: pd.DataFrame, clk_n: pd.DataFrame) -> float: + a = measure_t_clk_prepare(clk_p, clk_n) + b = measure_t_clk_zero(clk_p, clk_n) + if np.isnan(a) or np.isnan(b): + return float("nan") + return a + b + + +def measure_t_hs_zero(data_lane_p: pd.DataFrame, data_lane_n: pd.DataFrame) -> float: + """HS-0 preamble on data lane before SoT sync byte (00011101 = 0xB8 LSB-first). + + Approximated as duration from HS entry until first differential transition + (i.e. first clock-edge-aligned bit flip). + """ + t = data_lane_p["time_s"].values + vd = data_lane_p["voltage_v"].values - data_lane_n["voltage_v"].values + + spans = classify_lane(data_lane_p, data_lane_n) + hs_start: Optional[float] = None + for i in range(len(spans) - 1): + if spans[i].state == "LP-00" and spans[i + 1].state == "HS": + hs_start = spans[i + 1].t_start + break + if hs_start is None: + return float("nan") + + start_idx = int(np.searchsorted(t, hs_start)) + initial = vd[start_idx] + sign = -1 if initial >= 0 else 1 + for j in range(start_idx + 1, len(vd)): + if (sign > 0 and vd[j] > HS_DIFF_V) or (sign < 0 and vd[j] < -HS_DIFF_V): + return (t[j] - hs_start) * 1e9 + return float("nan") + + +# --------------------------------------------------------------------------- +# Aggregate measurement + spec compliance +# --------------------------------------------------------------------------- + +def measure_all(waveforms: dict[str, pd.DataFrame]) -> dict[str, float]: + clk_p = waveforms["CLK_P"] + clk_n = waveforms["CLK_N"] + dat_p = waveforms["DAT0_P"] + dat_n = waveforms["DAT0_N"] + return { + "t_lpx": measure_t_lpx(dat_p, dat_n), + "t_hs_prepare": measure_t_hs_prepare(dat_p, dat_n), + "t_clk_prepare": measure_t_clk_prepare(clk_p, clk_n), + "t_clk_zero": measure_t_clk_zero(clk_p, clk_n), + "t_clk_prepare_plus_zero": measure_t_clk_prepare_plus_zero(clk_p, clk_n), + "t_hs_zero": measure_t_hs_zero(dat_p, dat_n), + } + + +def check_spec_compliance(measurements: dict[str, float], + spec: dict[str, float] = DPHY_SPEC) -> dict: + out: dict[str, dict] = {} + for name, measured_ns in measurements.items(): + min_ns = spec.get(name) + if min_ns is None: + continue + if measured_ns is None or np.isnan(measured_ns): + out[name] = { + "measured_ns": None, + "min_ns": min_ns, + "pass": False, + "margin_ns": None, + } + continue + out[name] = { + "measured_ns": float(measured_ns), + "min_ns": float(min_ns), + "pass": bool(measured_ns >= min_ns), + "margin_ns": float(measured_ns - min_ns), + } + return out + + +# --------------------------------------------------------------------------- +# Lane 0 DSI packet decode +# --------------------------------------------------------------------------- +# Ground-truth fault detector (Falcon prior art, May 2024). The SN65 IRQ +# register is a hint — packet payload position is the verdict. + +DSI_SOT_SYNC = 0xB8 # SoT sync byte after LP-11 → LP-01 → LP-00 → HS-0 +DSI_DT_PIXEL = 0x3E # Packed Pixel Stream, 24-bit RGB (long packet) +DSI_DT_HSYNC_START = 0x21 + + +@dataclass +class DSIPacket: + burst_idx: int + timestamp_s: float + data_type: int + word_count: int + ecc: int + payload: bytes + + +def _find_hs_bursts(clk_p: pd.DataFrame, clk_n: pd.DataFrame, + dat_p: pd.DataFrame, dat_n: pd.DataFrame) -> list[tuple[float, float]]: + """Return (t_start, t_end) for each HS burst on the data lane.""" + spans = classify_lane(dat_p, dat_n) + return [(s.t_start, s.t_end) for s in spans if s.state == "HS"] + + +def _sample_bits_in_burst(clk_p: pd.DataFrame, clk_n: pd.DataFrame, + dat_p: pd.DataFrame, dat_n: pd.DataFrame, + t_start: float, t_end: float) -> list[int]: + """DDR-sample the data lane at every clock edge inside the burst window. + + Returns a list of 0/1 bit values, in clock-edge order. + """ + t_clk = clk_p["time_s"].values + vd_clk = clk_p["voltage_v"].values - clk_n["voltage_v"].values + t_dat = dat_p["time_s"].values + vd_dat = dat_p["voltage_v"].values - dat_n["voltage_v"].values + + i0 = int(np.searchsorted(t_clk, t_start)) + i1 = int(np.searchsorted(t_clk, t_end)) + if i1 - i0 < 2: + return [] + + edges: list[float] = [] + prev_sign = 1 if vd_clk[i0] >= 0 else -1 + for k in range(i0 + 1, i1): + cur_sign = 1 if vd_clk[k] >= 0 else -1 + if cur_sign != prev_sign: + edges.append(t_clk[k]) + prev_sign = cur_sign + + bits: list[int] = [] + for et in edges: + idx = int(np.searchsorted(t_dat, et)) + if 0 <= idx < len(vd_dat): + bits.append(1 if vd_dat[idx] > 0 else 0) + return bits + + +def _bits_to_bytes_msb_first(bits: list[int]) -> bytes: + out = bytearray() + for i in range(0, len(bits) - 7, 8): + b = 0 + for k in range(8): + b = (b << 1) | (bits[i + k] & 1) + out.append(b) + return bytes(out) + + +def decode_lane0_packets(waveforms: dict[str, pd.DataFrame], + max_payload_bytes: int = 16) -> list[DSIPacket]: + """Best-effort DSI Lane 0 packet decode. + + Scope window at 5 ns/div × 500 kpts is ~2.5 µs — enough for SoT + header + + first ~200 bytes of payload. We only need the first few payload bytes + to classify Fault A (all-zero payload start). + """ + clk_p = waveforms["CLK_P"] + clk_n = waveforms["CLK_N"] + dat_p = waveforms["DAT0_P"] + dat_n = waveforms["DAT0_N"] + + bursts = _find_hs_bursts(clk_p, clk_n, dat_p, dat_n) + packets: list[DSIPacket] = [] + + for idx, (t0, t1) in enumerate(bursts): + bits = _sample_bits_in_burst(clk_p, clk_n, dat_p, dat_n, t0, t1) + bs = _bits_to_bytes_msb_first(bits) + + sot_pos = bs.find(bytes([DSI_SOT_SYNC])) + if sot_pos < 0 or len(bs) < sot_pos + 5: + continue + + header = bs[sot_pos + 1 : sot_pos + 5] + data_type = header[0] + word_count = header[1] | (header[2] << 8) + ecc = header[3] + + payload_start = sot_pos + 5 + payload_end = min(payload_start + max_payload_bytes, len(bs)) + payload = bs[payload_start:payload_end] + + packets.append(DSIPacket( + burst_idx=idx, + timestamp_s=t0, + data_type=data_type, + word_count=word_count, + ecc=ecc, + payload=payload, + )) + + return packets + + +def classify_packet_fault(packets: list[DSIPacket]) -> dict: + """Classify Fault A (zero-payload pixel packet) from decoded packets.""" + pixel_packets = [p for p in packets if p.data_type == DSI_DT_PIXEL] + if not pixel_packets: + return {"fault_a_detected": False, "reason": "no pixel packets decoded"} + + first = pixel_packets[0] + head = first.payload[:8] if first.payload else b"" + fault_a = len(head) >= 4 and all(b == 0x00 for b in head[:4]) + + return { + "fault_a_detected": bool(fault_a), + "first_pixel_payload_hex": head.hex(), + "n_pixel_packets": len(pixel_packets), + "n_total_packets": len(packets), + } + + +def detect_lane_stall(data_lane_p: pd.DataFrame, data_lane_n: pd.DataFrame, + stall_threshold_ms: float = 10.0) -> dict: + """Fault B: continuous LP-11 longer than threshold during what should be active video.""" + spans = classify_lane(data_lane_p, data_lane_n) + longest_lp11_ms = 0.0 + for s in spans: + if s.state == "LP-11": + ms = s.duration_ns / 1e6 + if ms > longest_lp11_ms: + longest_lp11_ms = ms + return { + "fault_b_detected": bool(longest_lp11_ms > stall_threshold_ms), + "longest_lp11_ms": float(longest_lp11_ms), + "threshold_ms": float(stall_threshold_ms), + } diff --git a/captures/.gitkeep b/captures/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/config.py b/config.py new file mode 100644 index 0000000..6b1a555 --- /dev/null +++ b/config.py @@ -0,0 +1,118 @@ +"""Central configuration for the MIPI flicker investigation suite. + +All IP addresses, register addresses, MIPI D-PHY spec minimums, and probe +calibration constants live here. This is the single tuning surface — modules +should import from here rather than hard-coding values. +""" + +# --------------------------------------------------------------------------- +# Network +# --------------------------------------------------------------------------- +TARGET_IP = "192.168.45.8" +TARGET_PORT = 5000 +SCOPE_IP = "192.168.45.4" +PSU_IP = "192.168.45.3" + +# --------------------------------------------------------------------------- +# Scope hardware +# --------------------------------------------------------------------------- +SCOPE_CHANNELS = { + "CLK_P": 1, + "CLK_N": 2, + "DAT0_P": 3, + "DAT0_N": 4, +} +PROBE_ATTENUATION = 19.2 +SCOPE_TIMEBASE = 5e-9 +SCOPE_POINTS = 500_000 +TRIGGER_CHANNEL = 3 +TRIGGER_LEVEL_V = 0.05 +TRIGGER_SLOPE = "NEGative" + +# --------------------------------------------------------------------------- +# PSU +# --------------------------------------------------------------------------- +PSU_CHANNEL_DISPLAY = 1 +PSU_DISPLAY_VOLTAGE = 3.3 +PSU_DISPLAY_CURRENT = 1.0 +PSU_POWER_CYCLE_DELAY_S = 2.0 + +# --------------------------------------------------------------------------- +# Pixel clock & DSI parameters +# --------------------------------------------------------------------------- +PIXEL_CLOCK_HZ = 72_000_000 +DSI_LANES = 4 +BITS_PER_PIXEL = 24 + + +def derive_clocks(pixel_clock_hz: int) -> dict: + """Recompute DSI/byte clock and UI for a given pixel clock. + + Used when --pixel-clock overrides the default — UI feeds into several + DPHY_SPEC minimums, so they must be recomputed from the live value. + """ + dsi_clk_hz = pixel_clock_hz * BITS_PER_PIXEL // DSI_LANES + byte_clk_hz = dsi_clk_hz // 8 + ui_ns = 1e9 / dsi_clk_hz + return { + "DSI_CLK_HZ": dsi_clk_hz, + "BYTE_CLK_HZ": byte_clk_hz, + "UI_NS": ui_ns, + } + + +_clocks = derive_clocks(PIXEL_CLOCK_HZ) +DSI_CLK_HZ = _clocks["DSI_CLK_HZ"] +BYTE_CLK_HZ = _clocks["BYTE_CLK_HZ"] +UI_NS = _clocks["UI_NS"] + + +def build_dphy_spec(ui_ns: float) -> dict: + """Build the MIPI D-PHY v1.1 minimum-timing dict for a given UI.""" + return { + "t_lpx": 50.0, + "t_clk_prepare": 38.0, + "t_clk_zero": 262.0, + "t_clk_prepare_plus_zero": 300.0, + "t_clk_trail": 60.0, + "t_clk_post": 60.0 + 52 * ui_ns, + "t_hs_prepare": 40.0 + 4 * ui_ns, + "t_hs_zero": 145.0 + 10 * ui_ns, + "t_hs_trail": max(8 * ui_ns, 60.0 + 4 * ui_ns), + "t_hs_exit": 100.0, + } + + +DPHY_SPEC = build_dphy_spec(UI_NS) + +# --------------------------------------------------------------------------- +# SN65DSI83 I2C +# --------------------------------------------------------------------------- +SN65_I2C_ADDR = 0x2C +SN65_I2C_BUS = 4 # Spec §11 says 2; live hardware shows the bridge on bus 4 +SN65_REG_IRQ = 0xE5 +SN65_REG_PLL = 0x0A +SN65_REG_CLK = 0x0B + +SN65_ERR_SYNCH = 1 << 3 +SN65_ERR_SOT = 1 << 4 +SN65_ERR_UNC = 1 << 6 +SN65_FLICKER_MASK = SN65_ERR_SYNCH | SN65_ERR_SOT | SN65_ERR_UNC + +# --------------------------------------------------------------------------- +# DSIM PHY timing registers (i.MX8M Mini, samsung-dsim) +# --------------------------------------------------------------------------- +DSIM_PHYTIMING_BASE = 0x32E100B4 +DSIM_PHYTIMING1 = 0x32E100B8 +DSIM_PHYTIMING2 = 0x32E100BC + +# --------------------------------------------------------------------------- +# U-boot dsi-tweak bitmask (reference — not written from here) +# --------------------------------------------------------------------------- +TWEAK_BIT_FIFO_FLUSH = 1 << 0 +TWEAK_BIT_ROUND_UP = 1 << 2 + +# --------------------------------------------------------------------------- +# Output +# --------------------------------------------------------------------------- +CAPTURE_ROOT = "captures" \ No newline at end of file diff --git a/hardware/__init__.py b/hardware/__init__.py new file mode 100644 index 0000000..a756e36 --- /dev/null +++ b/hardware/__init__.py @@ -0,0 +1,7 @@ +"""Instrument I/O — VXI-11 to scope/PSU and HTTP REST to the i.MX target.""" + +from hardware.psu import PSUController +from hardware.scope import ScopeController +from hardware.target import TargetController + +__all__ = ["PSUController", "ScopeController", "TargetController"] diff --git a/hardware/__pycache__/__init__.cpython-312.pyc b/hardware/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..37a5bc2 Binary files /dev/null and b/hardware/__pycache__/__init__.cpython-312.pyc differ diff --git a/hardware/__pycache__/psu.cpython-312.pyc b/hardware/__pycache__/psu.cpython-312.pyc new file mode 100644 index 0000000..fae9bfa Binary files /dev/null and b/hardware/__pycache__/psu.cpython-312.pyc differ diff --git a/hardware/__pycache__/scope.cpython-312.pyc b/hardware/__pycache__/scope.cpython-312.pyc new file mode 100644 index 0000000..42d3ce8 Binary files /dev/null and b/hardware/__pycache__/scope.cpython-312.pyc differ diff --git a/hardware/__pycache__/target.cpython-312.pyc b/hardware/__pycache__/target.cpython-312.pyc new file mode 100644 index 0000000..d844126 Binary files /dev/null and b/hardware/__pycache__/target.cpython-312.pyc differ diff --git a/hardware/psu.py b/hardware/psu.py new file mode 100644 index 0000000..63d79dc --- /dev/null +++ b/hardware/psu.py @@ -0,0 +1,58 @@ +"""Siglent SPD3303X-E PSU controller over VXI-11 / SCPI. + +Drives the display 3.3 V rail so the master loop can power-cycle the PCB +between captures. +""" + +from __future__ import annotations + +import logging +import time + +import vxi11 + +from config import ( + PSU_CHANNEL_DISPLAY, + PSU_DISPLAY_CURRENT, + PSU_DISPLAY_VOLTAGE, + PSU_POWER_CYCLE_DELAY_S, +) + +log = logging.getLogger(__name__) + + +class PSUController: + def __init__(self, ip: str) -> None: + self.ip = ip + self._inst = vxi11.Instrument(ip) + idn = self._inst.ask("*IDN?").strip() + log.info("PSU connected: %s", idn) + self.idn = idn + + ch = PSU_CHANNEL_DISPLAY + self._inst.write(f"CH{ch}:VOLTage {PSU_DISPLAY_VOLTAGE}") + self._inst.write(f"CH{ch}:CURRent {PSU_DISPLAY_CURRENT}") + self.output_off() + + def output_on(self) -> None: + self._inst.write(f"OUTPut CH{PSU_CHANNEL_DISPLAY},ON") + + def output_off(self) -> None: + self._inst.write(f"OUTPut CH{PSU_CHANNEL_DISPLAY},OFF") + + def power_cycle(self, delay_s: float = PSU_POWER_CYCLE_DELAY_S) -> None: + self.output_off() + time.sleep(delay_s) + self.output_on() + + def measure(self) -> dict: + ch = PSU_CHANNEL_DISPLAY + voltage = float(self._inst.ask(f"MEASure:VOLTage? CH{ch}")) + current = float(self._inst.ask(f"MEASure:CURRent? CH{ch}")) + return {"voltage_v": voltage, "current_a": current} + + def close(self) -> None: + try: + self._inst.close() + except Exception: + pass diff --git a/hardware/scope.py b/hardware/scope.py new file mode 100644 index 0000000..e699f68 --- /dev/null +++ b/hardware/scope.py @@ -0,0 +1,133 @@ +"""Keysight DSO80204B scope controller over VXI-11 / SCPI. + +Configures channels for MIPI D-PHY probing (50 Ω DC, 19.2× attenuation), +arms a single trigger, and downloads ASCII waveforms with absolute +timestamps reconstructed from the preamble. + +CRITICAL: All four channels must be DC50 — the 910R+50R divider only gives +the documented 19.2× ratio with 50 Ω termination. +""" + +from __future__ import annotations + +import io +import logging +import time + +import pandas as pd +import vxi11 + +from config import ( + PROBE_ATTENUATION, + SCOPE_CHANNELS, + SCOPE_POINTS, + SCOPE_TIMEBASE, + TRIGGER_CHANNEL, + TRIGGER_LEVEL_V, + TRIGGER_SLOPE, +) + +log = logging.getLogger(__name__) + + +class ScopeController: + def __init__(self, ip: str) -> None: + self.ip = ip + self._inst = vxi11.Instrument(ip) + idn = self._inst.ask("*IDN?").strip() + log.info("Scope connected: %s", idn) + self.idn = idn + + def setup(self) -> None: + i = self._inst + i.write("*RST") + time.sleep(1.0) + i.write(":STOP") + + for label, ch in SCOPE_CHANNELS.items(): + i.write(f":CHANnel{ch}:DISPlay ON") + i.write(f":CHANnel{ch}:INPut DC50") + i.write(f":CHANnel{ch}:PROBe {PROBE_ATTENUATION}") + i.write(f":CHANnel{ch}:SCALe 0.05") + i.write(f":CHANnel{ch}:OFFSet 0.0") + i.write(f":CHANnel{ch}:LABel '{label}'") + + i.write(f":TIMebase:SCALe {SCOPE_TIMEBASE:.3E}") + i.write(":TIMebase:POSition 0") + i.write(":TIMebase:REFerence CENTer") + + i.write(":TRIGger:MODE EDGE") + i.write(f":TRIGger:EDGE:SOURce CHANnel{TRIGGER_CHANNEL}") + i.write(f":TRIGger:EDGE:SLOPe {TRIGGER_SLOPE}") + i.write(f":TRIGger:EDGE:LEVel {TRIGGER_LEVEL_V}") + i.write(":TRIGger:SWEep NORMal") + + i.write(":ACQuire:MODE RTIMe") + i.write(":ACQuire:INTerpolate ON") + i.write(f":ACQuire:POINts {SCOPE_POINTS}") + + i.write(":DISPlay:LAYout STACKed") + + def arm_single(self) -> None: + self._inst.write(":SINGle") + + def wait_for_trigger(self, timeout_s: float = 30.0) -> bool: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + try: + ter = int(self._inst.ask(":TER?").strip()) + except ValueError: + ter = 0 + if ter == 1: + return True + time.sleep(0.1) + return False + + def _read_preamble(self, channel: int) -> dict: + self._inst.write(f":WAVeform:SOURce CHANnel{channel}") + raw = self._inst.ask(":WAVeform:PREamble?").strip() + parts = raw.split(",") + return { + "format": int(parts[0]), + "type": int(parts[1]), + "points": int(parts[2]), + "count": int(parts[3]), + "x_increment": float(parts[4]), + "x_origin": float(parts[5]), + "x_reference": float(parts[6]), + "y_increment": float(parts[7]), + "y_origin": float(parts[8]), + "y_reference": float(parts[9]), + } + + def download_waveform(self, channel: int) -> pd.DataFrame: + preamble = self._read_preamble(channel) + + self._inst.write(":WAVeform:FORMat ASCii") + self._inst.write(":WAVeform:STReaming ON") + self._inst.write(f":WAVeform:SOURce CHANnel{channel}") + raw = self._inst.ask(":WAVeform:DATA?") + + if raw.startswith("#"): + n_digits = int(raw[1]) + raw = raw[2 + n_digits :] + + voltages = pd.read_csv(io.StringIO(raw), header=None).iloc[0].astype(float).values + n = len(voltages) + + x_inc = preamble["x_increment"] + x_origin = preamble["x_origin"] + times = x_origin + x_inc * pd.Series(range(n), dtype="float64") + + return pd.DataFrame({"time_s": times, "voltage_v": voltages}) + + def download_all(self) -> dict[str, pd.DataFrame]: + return { + label: self.download_waveform(ch) for label, ch in SCOPE_CHANNELS.items() + } + + def close(self) -> None: + try: + self._inst.close() + except Exception: + pass diff --git a/hardware/target.py b/hardware/target.py new file mode 100644 index 0000000..e608678 --- /dev/null +++ b/hardware/target.py @@ -0,0 +1,53 @@ +"""HTTP REST client for the i.MX 8M Mini target. + +Talks to the Flask server in `server/app.py`. The target must run that server +with appropriate privileges to access /sys/kernel/debug, memtool, and i2c-2. +""" + +from __future__ import annotations + +import logging + +import requests + +log = logging.getLogger(__name__) + + +class TargetController: + def __init__(self, ip: str, port: int, timeout_s: float = 10.0) -> None: + self.base_url = f"http://{ip}:{port}" + self.timeout = timeout_s + probe = requests.get(f"{self.base_url}/registers", timeout=timeout_s) + probe.raise_for_status() + log.info("Target reachable at %s", self.base_url) + + def _get(self, path: str) -> dict: + r = requests.get(f"{self.base_url}{path}", timeout=self.timeout) + r.raise_for_status() + return r.json() + + def _put(self, path: str, payload: dict) -> dict: + r = requests.put(f"{self.base_url}{path}", json=payload, timeout=self.timeout) + r.raise_for_status() + return r.json() + + def get_dsim_registers(self) -> dict: + return self._get("/registers") + + def get_sn65_registers(self) -> dict: + return self._get("/sn65_registers") + + def get_sn65_settling(self) -> dict: + return self._get("/sn65_settling") + + def display_on(self) -> dict: + return self._put("/display", {"state": "on"}) + + def display_off(self) -> dict: + return self._put("/display", {"state": "off"}) + + def video_start(self, mode: str = "static-pink") -> dict: + return self._put("/video", {"action": "start", "mode": mode}) + + def video_stop(self) -> dict: + return self._put("/video", {"action": "stop"}) diff --git a/master_loop.py b/master_loop.py new file mode 100644 index 0000000..fb2485d --- /dev/null +++ b/master_loop.py @@ -0,0 +1,205 @@ +"""Main entry point — the flicker hunt loop. + +Runs on the host PC. Cycles power, arms the scope, restarts video, captures +4-channel waveforms + bridge/SoC registers, decodes timings + Lane 0 packets, +appends a row to flicker_log.csv. Repeats until KeyboardInterrupt or +--max-runs is hit. + +If --pixel-clock differs from the default, DPHY_SPEC must be rebuilt for the +new UI before checking compliance. +""" + +from __future__ import annotations + +import argparse +import logging +import math +import sys +import time +from typing import Optional + +import config +from analysis import registers as reg_analysis +from analysis import report +from analysis import waveform as wave_analysis +from hardware import PSUController, ScopeController, TargetController + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", +) +log = logging.getLogger("master_loop") + + +def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: + p = argparse.ArgumentParser(description="MIPI flicker hunt loop") + p.add_argument("--max-runs", type=int, default=None) + p.add_argument("--timeout", type=float, default=30.0, + help="Scope trigger timeout per run (s)") + p.add_argument("--pixel-clock", type=int, default=config.PIXEL_CLOCK_HZ, + help="Override pixel clock in Hz") + p.add_argument("--note", type=str, default="", + help="Note appended to every log row (e.g. 'dsi-tweak=5')") + p.add_argument("--no-video", action="store_true", + help="Skip PUT /video — display blank/unblank only") + p.add_argument("--output-dir", type=str, default=config.CAPTURE_ROOT) + return p.parse_args(argv) + + +def _maybe_rebuild_spec(pixel_clock_hz: int) -> dict: + """Recompute UI-dependent spec minimums if pixel clock was overridden.""" + if pixel_clock_hz == config.PIXEL_CLOCK_HZ: + return config.DPHY_SPEC + derived = config.derive_clocks(pixel_clock_hz) + log.info("Rebuilding DPHY_SPEC for %.3f MHz pixel clock (UI=%.3f ns)", + pixel_clock_hz / 1e6, derived["UI_NS"]) + return config.build_dphy_spec(derived["UI_NS"]) + + +def run_one(idx: int, args: argparse.Namespace, spec: dict, + psu: PSUController, scope: ScopeController, + target: TargetController) -> None: + log.info("=== run %03d ===", idx) + + # RESET + try: + target.display_off() + except Exception as e: + log.warning("display_off failed: %s", e) + if not args.no_video: + try: + target.video_stop() + except Exception as e: + log.warning("video_stop failed: %s", e) + psu.output_off() + time.sleep(config.PSU_POWER_CYCLE_DELAY_S) + + # ARM + scope.arm_single() + run_dir = report.make_run_dir(root=args.output_dir, run_idx=idx) + log.info("run dir: %s", run_dir) + + # STIMULUS + psu.output_on() + time.sleep(0.5) + target.display_on() + if not args.no_video: + target.video_start(mode="static-pink") + + # ACQUIRE + triggered = scope.wait_for_trigger(timeout_s=args.timeout) + if not triggered: + log.warning("TIMEOUT waiting for scope trigger (run %03d)", idx) + report.save_summary(run_dir, f"run_{idx:03d}: TIMEOUT — no scope trigger\n") + return + + # CAPTURE + waveforms = scope.download_all() + sn65_data = target.get_sn65_registers() + dsim_data = target.get_dsim_registers() + settling = target.get_sn65_settling() + + # ANALYSIS + measurements = wave_analysis.measure_all(waveforms) + spec_pass = wave_analysis.check_spec_compliance(measurements, spec) + sn65_parsed = reg_analysis.parse_sn65(sn65_data) + dsim_parsed = reg_analysis.parse_dsim(dsim_data) + packets = wave_analysis.decode_lane0_packets(waveforms) + packet_fault = wave_analysis.classify_packet_fault(packets) + lane_stall = wave_analysis.detect_lane_stall(waveforms["DAT0_P"], waveforms["DAT0_N"]) + + flicker_detected = ( + sn65_parsed.get("flicker_detected") + or packet_fault.get("fault_a_detected") + or lane_stall.get("fault_b_detected") + ) + if flicker_detected: + log.warning("*** FLICKER EVENT CAPTURED (run %03d) ***", idx) + + # SAVE + report.save_waveforms(run_dir, waveforms) + report.save_registers(run_dir, dsim_parsed, sn65_parsed, settling) + report.save_timing_analysis(run_dir, measurements, spec_pass, packet_fault, lane_stall) + summary = report.build_summary( + run_id=run_dir.name, + sn65_parsed=sn65_parsed, + measurements=measurements, + spec_pass=spec_pass, + packet_fault=packet_fault, + lane_stall=lane_stall, + dsim_parsed=dsim_parsed, + note=args.note, + ) + report.save_summary(run_dir, summary) + + log_row = report.build_log_row( + run_id=run_dir.name, + sn65_parsed=sn65_parsed, + measurements=measurements, + spec_pass=spec_pass, + dsim_parsed=dsim_parsed, + note=args.note, + ) + report.append_flicker_log(args.output_dir, log_row) + + # REPORT + hsp = measurements.get("t_hs_prepare") + cpz = measurements.get("t_clk_prepare_plus_zero") + log.info( + "run %03d: flicker=%s | t_hs_prepare=%s ns | t_clk_prep+zero=%s ns", + idx, + "YES" if flicker_detected else "no", + f"{hsp:.1f}" if hsp is not None and not math.isnan(hsp) else "nan", + f"{cpz:.1f}" if cpz is not None and not math.isnan(cpz) else "nan", + ) + + +def main(argv: Optional[list[str]] = None) -> int: + args = parse_args(argv) + spec = _maybe_rebuild_spec(args.pixel_clock) + + log.info("Connecting instruments...") + psu = PSUController(config.PSU_IP) + scope = ScopeController(config.SCOPE_IP) + target = TargetController(config.TARGET_IP, config.TARGET_PORT) + + try: + log.info("Configuring scope...") + scope.setup() + + psu.output_off() + try: + target.display_off() + if not args.no_video: + target.video_stop() + except Exception as e: + log.warning("Initial reset failed: %s", e) + + idx = 0 + while True: + idx += 1 + try: + run_one(idx, args, spec, psu, scope, target) + except KeyboardInterrupt: + raise + except Exception as e: + log.exception("run %03d failed: %s", idx, e) + + if args.max_runs is not None and idx >= args.max_runs: + log.info("Reached --max-runs=%d, stopping", args.max_runs) + break + except KeyboardInterrupt: + log.info("Interrupted by user") + finally: + try: + psu.output_off() + except Exception: + pass + scope.close() + psu.close() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f42435e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +python-vxi11>=0.9 +requests>=2.28 +flask>=3.0 +numpy>=1.24 +pandas>=2.0 +matplotlib>=3.7 diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/__pycache__/__init__.cpython-312.pyc b/server/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..709ce9a Binary files /dev/null and b/server/__pycache__/__init__.cpython-312.pyc differ diff --git a/server/__pycache__/app.cpython-312.pyc b/server/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000..a773e23 Binary files /dev/null and b/server/__pycache__/app.cpython-312.pyc differ diff --git a/server/__pycache__/hw_interface.cpython-312.pyc b/server/__pycache__/hw_interface.cpython-312.pyc new file mode 100644 index 0000000..bd65419 Binary files /dev/null and b/server/__pycache__/hw_interface.cpython-312.pyc differ diff --git a/server/app.py b/server/app.py new file mode 100644 index 0000000..84c5663 --- /dev/null +++ b/server/app.py @@ -0,0 +1,76 @@ +"""Flask REST server — runs ON THE i.MX 8M Mini target, NOT the host PC. + +Endpoints (all rooted at http://:5000): + GET /registers DSIM PHY_TIMING dump via memtool + GET /sn65_registers SN65DSI83 regmap with cache bypass (mandatory) + GET /sn65_settling 2 s register poll @ 100 ms cadence + PUT /display {state: on|off} + PUT /video {action: start|stop, mode: static-pink} +""" + +from __future__ import annotations + +import logging + +from flask import Flask, jsonify, request + +try: + from server import hw_interface as hw +except ImportError: + import hw_interface as hw # flat-layout deployment (target /home/root) + +logging.basicConfig(level=logging.INFO) +log = logging.getLogger(__name__) + +app = Flask(__name__) + + +@app.errorhandler(Exception) +def _on_error(e): # noqa: ANN001 + log.exception("Request failed: %s", e) + return jsonify({"ok": False, "error": str(e)}), 500 + + +@app.get("/registers") +def get_registers(): + return jsonify(hw.read_dsim_phy_timing()) + + +@app.get("/sn65_registers") +def get_sn65_registers(): + return jsonify(hw.read_sn65_registers()) + + +@app.get("/sn65_settling") +def get_sn65_settling(): + return jsonify({"snapshots": hw.settling_capture()}) + + +@app.put("/display") +def put_display(): + body = request.get_json(force=True) or {} + state = body.get("state") + if state == "on": + hw.display_on() + elif state == "off": + hw.display_off() + else: + return jsonify({"ok": False, "error": "state must be 'on' or 'off'"}), 400 + return jsonify({"ok": True}) + + +@app.put("/video") +def put_video(): + body = request.get_json(force=True) or {} + action = body.get("action") + if action == "start": + hw.video_start(mode=body.get("mode", "static-pink")) + elif action == "stop": + hw.video_stop() + else: + return jsonify({"ok": False, "error": "action must be 'start' or 'stop'"}), 400 + return jsonify({"ok": True}) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, threaded=True) diff --git a/server/hw_interface.py b/server/hw_interface.py new file mode 100644 index 0000000..00549a3 --- /dev/null +++ b/server/hw_interface.py @@ -0,0 +1,215 @@ +"""On-target hardware shims used by the Flask app. + +Runs ON THE i.MX 8M Mini, not the host PC. Shells out to memtool, i2cget, +and /sys/kernel/debug/regmap. Cache bypass for SN65DSI83 is mandatory +before every IRQ_STAT read — see CLAUDE.md invariant 1. +""" + +from __future__ import annotations + +import logging +import re +import shlex +import signal +import subprocess +import time +from pathlib import Path +from typing import Optional + +log = logging.getLogger(__name__) + +SN65_REGMAP_DIR = "/sys/kernel/debug/regmap/4-002c" +SN65_I2C_BUS = 4 +SN65_I2C_ADDR = 0x2C + +DSIM_PHYTIMING_BASE = 0x32E100B4 +DSIM_PHYTIMING_LEN = 0x0C + +FB_BLANK_PATH = "/sys/class/graphics/fb0/blank" + +# Held while a video pipeline is running so PUT /video stop can kill it. +_video_proc: Optional[subprocess.Popen] = None + + +# --------------------------------------------------------------------------- +# Process helpers +# --------------------------------------------------------------------------- + +def _run(cmd: str, check: bool = True, timeout: float = 5.0) -> str: + log.debug("run: %s", cmd) + res = subprocess.run( + shlex.split(cmd), + capture_output=True, + text=True, + timeout=timeout, + ) + if check and res.returncode != 0: + raise RuntimeError( + f"Command failed: {cmd}\nstderr: {res.stderr.strip()}" + ) + return res.stdout + + +def _write_sysfs(path: str, value: str) -> None: + Path(path).write_text(value) + + +# --------------------------------------------------------------------------- +# DSIM PHY_TIMING registers +# --------------------------------------------------------------------------- + +def read_dsim_phy_timing() -> dict: + """memtool md -l 0x32e100b4+0x0c → 3 little-endian 32-bit words. + + memtool prints lines like '0x32e100b4: 00000306 03120a04 00040707' — + we strip the address prefix (everything up to and including the colon) + before extracting hex words to avoid matching the address itself. + """ + out = _run(f"memtool md -l 0x{DSIM_PHYTIMING_BASE:x}+0x{DSIM_PHYTIMING_LEN:x}") + hex_words: list[str] = [] + for line in out.splitlines(): + if ":" in line: + line = line.split(":", 1)[1] + hex_words.extend(re.findall(r"\b([0-9a-fA-F]{8})\b", line)) + if len(hex_words) < 3: + raise RuntimeError(f"memtool returned unexpected output:\n{out}") + + pt, pt1, pt2 = hex_words[:3] + return { + "PHY_TIMING": f"0x{pt}", + "PHY_TIMING1": f"0x{pt1}", + "PHY_TIMING2": f"0x{pt2}", + "raw_hex": f"{pt} {pt1} {pt2}", + } + + +# --------------------------------------------------------------------------- +# SN65DSI83 register map — regmap-bypassed +# --------------------------------------------------------------------------- + +def _bypass_sn65_regmap_cache() -> None: + bypass = Path(SN65_REGMAP_DIR) / "cache_bypass" + try: + bypass.write_text("1\n") + except (FileNotFoundError, PermissionError) as e: + log.warning("Could not bypass regmap cache (%s); falling back to i2cget", e) + + +def _read_sn65_regmap() -> dict[str, str]: + """Read /sys/kernel/debug/regmap/2-002c/registers — returns {hex_addr: hex_val}.""" + regs_path = Path(SN65_REGMAP_DIR) / "registers" + text = regs_path.read_text() + out: dict[str, str] = {} + for line in text.splitlines(): + m = re.match(r"\s*([0-9a-fA-F]+)\s*:?\s*([0-9a-fA-F]+)", line.strip()) + if m: + out[m.group(1).lower().lstrip("0").rjust(2, "0")] = m.group(2).lower() + return out + + +def _read_sn65_via_i2cget(reg: int) -> int: + out = _run(f"i2cget -y -f {SN65_I2C_BUS} 0x{SN65_I2C_ADDR:02x} 0x{reg:02x}") + return int(out.strip(), 16) + + +def read_sn65_registers() -> dict: + """Cache-bypassed SN65DSI83 register read with explicit IRQ flags decoded. + + Critical: the bypass write must happen on every call — without it, + IRQ_STAT (0xE5) returns the last cached value, not the current hardware + state, and flicker events become invisible. + """ + _bypass_sn65_regmap_cache() + + try: + regs = _read_sn65_regmap() + irq_raw = int(regs.get("e5", "0"), 16) + pll_raw = int(regs.get("0a", "0"), 16) + clk_raw = int(regs.get("0b", "0"), 16) + except FileNotFoundError: + regs = {} + irq_raw = _read_sn65_via_i2cget(0xE5) + pll_raw = _read_sn65_via_i2cget(0x0A) + clk_raw = _read_sn65_via_i2cget(0x0B) + regs = { + "e5": f"{irq_raw:02x}", + "0a": f"{pll_raw:02x}", + "0b": f"{clk_raw:02x}", + } + + return { + "registers": regs, + "pll_locked": bool(pll_raw & 0x80), + "clk_detected": bool(clk_raw & 0x01), + "irq_stat_raw": f"0x{irq_raw:02X}", + "sot_err": bool(irq_raw & (1 << 4)), + "synch_err": bool(irq_raw & (1 << 3)), + "unc_ecc_err": bool(irq_raw & (1 << 6)), + } + + +def settling_capture(duration_s: float = 2.0, interval_s: float = 0.1) -> list[dict]: + """Sample SN65 registers at fixed cadence — catches transient LP→HS errors.""" + snapshots: list[dict] = [] + deadline = time.monotonic() + duration_s + while time.monotonic() < deadline: + snap = read_sn65_registers() + snap["t_s"] = time.monotonic() + snapshots.append(snap) + time.sleep(interval_s) + return snapshots + + +# --------------------------------------------------------------------------- +# Display / video control +# --------------------------------------------------------------------------- + +def display_on() -> None: + _write_sysfs(FB_BLANK_PATH, "0\n") + + +def display_off() -> None: + _write_sysfs(FB_BLANK_PATH, "4\n") + + +_VIDEO_PIPELINES = { + "static-pink": ( + "gst-launch-1.0 videotestsrc pattern=solid-color foreground-color=0xFFFF69B4 " + "! video/x-raw,width=1280,height=800,framerate=60/1 " + "! fbdevsink device=/dev/fb0" + ), +} + + +def video_start(mode: str = "static-pink") -> None: + global _video_proc + if _video_proc is not None and _video_proc.poll() is None: + video_stop() + + pipeline = _VIDEO_PIPELINES.get(mode) + if pipeline is None: + raise ValueError(f"Unknown video mode: {mode}") + + _video_proc = subprocess.Popen( + shlex.split(pipeline), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + +def video_stop() -> None: + global _video_proc + if _video_proc is None: + return + if _video_proc.poll() is None: + try: + import os + os.killpg(os.getpgid(_video_proc.pid), signal.SIGTERM) + _video_proc.wait(timeout=2.0) + except (ProcessLookupError, subprocess.TimeoutExpired): + try: + _video_proc.kill() + except ProcessLookupError: + pass + _video_proc = None