From 0edb95d7e10a00afb948506a79bdb4331847643d Mon Sep 17 00:00:00 2001 From: david rice Date: Wed, 6 May 2026 15:57:48 +0100 Subject: [PATCH] Updates --- CLAUDE.md | 49 + MIPI_FLICKER_SPEC.md | 867 ++++++++++++++++++ __pycache__/config.cpython-312.pyc | Bin 0 -> 2917 bytes __pycache__/master_loop.cpython-312.pyc | Bin 0 -> 10325 bytes analysis/__init__.py | 1 + analysis/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 199 bytes .../__pycache__/registers.cpython-312.pyc | Bin 0 -> 5157 bytes analysis/__pycache__/report.cpython-312.pyc | Bin 0 -> 9431 bytes analysis/__pycache__/waveform.cpython-312.pyc | Bin 0 -> 16884 bytes analysis/registers.py | 132 +++ analysis/report.py | 172 ++++ analysis/waveform.py | 401 ++++++++ captures/.gitkeep | 0 config.py | 118 +++ hardware/__init__.py | 7 + hardware/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 414 bytes hardware/__pycache__/psu.cpython-312.pyc | Bin 0 -> 3248 bytes hardware/__pycache__/scope.cpython-312.pyc | Bin 0 -> 7726 bytes hardware/__pycache__/target.cpython-312.pyc | Bin 0 -> 3656 bytes hardware/psu.py | 58 ++ hardware/scope.py | 133 +++ hardware/target.py | 53 ++ master_loop.py | 205 +++++ requirements.txt | 6 + server/__init__.py | 0 server/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 115 bytes server/__pycache__/app.cpython-312.pyc | Bin 0 -> 3787 bytes .../__pycache__/hw_interface.cpython-312.pyc | Bin 0 -> 8766 bytes server/app.py | 76 ++ server/hw_interface.py | 215 +++++ 30 files changed, 2493 insertions(+) create mode 100644 CLAUDE.md create mode 100644 MIPI_FLICKER_SPEC.md create mode 100644 __pycache__/config.cpython-312.pyc create mode 100644 __pycache__/master_loop.cpython-312.pyc create mode 100644 analysis/__init__.py create mode 100644 analysis/__pycache__/__init__.cpython-312.pyc create mode 100644 analysis/__pycache__/registers.cpython-312.pyc create mode 100644 analysis/__pycache__/report.cpython-312.pyc create mode 100644 analysis/__pycache__/waveform.cpython-312.pyc create mode 100644 analysis/registers.py create mode 100644 analysis/report.py create mode 100644 analysis/waveform.py create mode 100644 captures/.gitkeep create mode 100644 config.py create mode 100644 hardware/__init__.py create mode 100644 hardware/__pycache__/__init__.cpython-312.pyc create mode 100644 hardware/__pycache__/psu.cpython-312.pyc create mode 100644 hardware/__pycache__/scope.cpython-312.pyc create mode 100644 hardware/__pycache__/target.cpython-312.pyc create mode 100644 hardware/psu.py create mode 100644 hardware/scope.py create mode 100644 hardware/target.py create mode 100644 master_loop.py create mode 100644 requirements.txt create mode 100644 server/__init__.py create mode 100644 server/__pycache__/__init__.cpython-312.pyc create mode 100644 server/__pycache__/app.cpython-312.pyc create mode 100644 server/__pycache__/hw_interface.cpython-312.pyc create mode 100644 server/app.py create mode 100644 server/hw_interface.py 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 0000000000000000000000000000000000000000..340c81135a24ab0d530d4b7e97d35d7b959cdbfb GIT binary patch literal 2917 zcmZ8jO>7&-6<+>|{}d_y=+_xba%83!V>yx*eAH~gy6=0!Gi;W7dr$W4hnueDFkpx2;#6XiCsbnPYGch zAyYU?B6T~C{mL#xtqh5h$*H}Hb+2EHL6{DyE7BViHW6qfL!uuK-o($NINkY!4RTX;!$6E6!Z_?B=R zqE0}RZ{atE?;WlrZui0vRED%wx!OdvPP;BOdU92jI&D<%$VlBM=n+@sP`xSDJ|Qxa z+6P2YrG~jh=}9U{dOYc76LCdU#aJeaLXDN;Xb4_*sn)J>&t?ap#5*sCUAPKwf$mDj!qB-StXEx2> z1<{=Ijd#qEYJC4Ucfa}V;rc@f{c(b6{NWiLFFw#+d3FoZd_Wp?V|Y>xa0}z&gCWDD zB<4>a+Rvv!sRz2!6VeoG%}(tTasNnn%0%tS?b@*E3`-gp4e)9M$fwLPqZ(>Tj^JUa z^ajQ_0`3=|$V z5rt9K3iJ*%QWZ7eCT(N{X)Z(GAQ%;eVD$5SSX*5+R##2q&H<5S2@|u_nAEGirfL+I zx+DZc8?@C9QV49UHVt1{=q<5aWHV#ErF3Yo9HLfFQPCcO&KyRI&3>SbWb~I&`=sIL z+y&P%l1#q(DxI+D9=MrEtGO-c-i=+J9SM#KC+5ous~*_k__A1JOJb4xF`Luf)FPM8 zv*mg%@=(7*1$L4XTOdiExxFQVwV$kpYwvT zJDgWw%*d0JgeVD95(jdvR{%K8Ua12EKnW0u0UiMy0UnQyemQ~_3Y24#8+%?$>Z{(v zUQ-LUW4k{e_8azL|8o!YQ{d?Rjh+M#mKJ3UE)Ld`Yon5`suBQ417WEKFM6!CDqM2- zF1XQ4$qbpFc8AR0$GReh4M7zI;kf{|>z|KJ|YoQS*oha!bnIRR^%@Kb&Q1e(Rkgk##wf)<<8 zmTqhDIW0DOQaZi$Oc=ygwCVYipPjBhI~+{k)}kYQbmgD1nUk9*13xK08kVk-lX9bx-;WC4U8?yK2=g zAQVyZi97m)DB?~{6m>VBE^V>A$Q5<(kS!KUyzVMz3PqNxoyBs6&H+=C*-q#4Y_6<_ zilxE^E2ep#%~#SqSICdH@Z2MIBVA^7pJ`GoaCyG02l*1WwFNJB=;E~^$3A9r;$z)E z+ELEI;rbLkdAM23a^+$!y=y9mg7~$Vx_42GU*~;DY0UyP~cxMMnf8p;#=1b&Hn@*%8wzP^tC7tfnL}w z7R#m8VX9gf4I$a{hJAD#(=~?MdX#U94j|cw6zDEm0pSA{pWr)rdd^$03P4^v!@yWVje9DiE z{qra6OW$JO4TnL5el0i&5eBEm$=9Jc4LwSAltdO!KORKZzQ_$CDYzNEz)c!D1Pk{9 Y0-EU?XG}zk&Ydw~kS1q;=`-^Ce=0{kXaE2J literal 0 HcmV?d00001 diff --git a/__pycache__/master_loop.cpython-312.pyc b/__pycache__/master_loop.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..53be98b4355aabf33af8ad77d9b15e03b70fa5bd GIT binary patch literal 10325 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 literal 0 HcmV?d00001 diff --git a/analysis/__pycache__/registers.cpython-312.pyc b/analysis/__pycache__/registers.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2fb60fc640493a4b70c1bed988f53979654cac4e GIT binary patch literal 5157 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 B`3(R7 literal 0 HcmV?d00001 diff --git a/analysis/__pycache__/report.cpython-312.pyc b/analysis/__pycache__/report.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..223fdc1aaaced94d7153e31a54d38de9722a8a25 GIT binary patch literal 9431 zcmb_iYit`=cAg<;D85O(B~!A;df29|hh@i(Bfp}DAF^diw&Wy|;!J4HNTSTw&Ws|7 zpt=eWY$e2iWjL+qI;oXziztZO^%gCX0{!6x1vWtc3_-hMW)mQ2x^?vx#xcOjQ`o;uo7_n)9`-<|FMoBzQG&)GHMy}>Ayo}jo^rJ zf+IOYhzuLX4J77`A>+7FO{sB8P3dtOQYvH$o5oElh7Og5&EsY@Zwgt$)^TgDu5H{7 z*s_o#>>PK3&OGj#A~?%mkmGLN$r&dNTqS3HiyklMsyN$Q)OZC~&DkNXf=VGBh+AcRka- zw_Gv7u-G7{Sg;XM@`q!JTSsxc#0MmvQ_94s#PWimSj2@$U|P*uW1$d?6NbwvW{C~O z=Glm-xFmL3WMcv!^9wvG%C{(Deo<815*rBJCh6ELdT8ngoE-}KgEhD5y2(Q^U>NO~3_@W|`sF?ZrpeV7? z8*xYLSZlbIV_VO+UTPgtD6|m8crYD8eD^8(JJ zVvI!R6tgG^ld5T&pp9r$Qpz-2n^&moevwyb)!YP}35AM*?TRMIGV1k1g&z!fK@5tW zU`tU!>WD2U_OSm3ul5C93tj;4g`X&ZSR%f(xmKySyO;Vil=+V@ym?_&TIZH7Jf;q0 z%A89-|FUBDdP}-u?{e9f_NrThD}!(QHto$Rd-M8G+Wy?qnT)G)^<1)V_2fFa-nw2Z zJG@J0GtlCmm6OFS^4MY<6f9kQ3cV76yqUlaK8R{UXgq5~f|w~TML1mgOYSFbkpaUL z5indk4lj;OklVdD9W9bKs{nzM`^+_yH>pAaN9x7`^$u=d@3!IQNs3rBK+gt|T!4`* z8WV%@rQZE3dSc$;#^RgR!o|kRgA%i1XdXf1trBgfaZKg5Tb%& zeaRo1<+Z#q?4S42LMt?`kg?pV0nY=?&W1&WjEJ~AR2vrd;V>9o9EOaDFLa5>FfAY0 z-?#tL`7f@CWY@#pAMgA1zQ5o9sQfoIpVn;lTuJp@kw?a!^o*~PYof>6 z%8bjMsi^~tbX0#&P;~V&nKc+K)fv0{*2R^J?;4Xm_s`uux88SeaHIV0P`a+|iM>5b z1MoX5v8(xJAFPmpB)M`)ro0;@i2wY)l5oFD{>Sf~M7ihP3$knP_XKIF{=#0BHA4D> z*aYLfQ{Br@A9_H1N;^?9ZN06A&str*&(ohBB|&cSq-c=pGWc`Iz?Ki&JqJFEqu-^# zgOWeJD6YXQy$YUm(THAcyCqRD7xZ>Z9AMwAzGrT`A%><@lf8|4rUu5P$IT;ngY+%}_?!__KpT!s-U_Sfc zSjXWGp$%$&i%$Pr+!MaVwdti4x^M6zJFF!>#nj(3dJt~4)aO%dMa{-3Z1*he(sU4w z&1-~=#Df(Xr+7&*Nl_Ncy(Z0XqUl=n+M;3$`LFXK7BSNbt==dGZc;G>iruJcf{Igw zXJPYo1s@FcMfi!YgIFTIZfM*fA6|a6J5|xYbTL!CbB(>trmOd$h4qjtmSuWb$T;e5 zu`6uyYTD7fY{=Lg%iS4!RmN<+Wm&PT?oHmIC;<%cU3}%R_VHzHXCx9%vwCgk2NM)$d_d_hxZrOV2run} znWZ=}7*;4Ta`Xu7OuQt8V9~~%0zU=29x$!T0n zQ_dHc&Shx3?An#0cWqJ!Qq+M5{lB{K;f05DkAC*JZQu!YL7UEEd(TfN2W{!o3DZHs zdZYgj6H5Qogi?wt%=^Vzfe#~lP^lj-4mJ=Cga7nL0=(jk`GFlu?4*A-B(Z+Q9`Z+c z7W^&{$LsN`9Ane>`N9hjM&Ln&s~~`E;4H(&U(^?nQ0z#he)i%n6l@KjHe~DV^5~8={pPUGA*6U+6j|GcD1JH)=jD_MRomM z%g6hFz5kP!KfnC=(9jcVSbHz%4)hDnRW);T6i5d+vXp5j@*6fufWaDkaho_B4*P|L z4ryNUlEO>Sgx8Rpj9igwlp^-XFxunU)yK;f3sX>nb|zMX02`z2%Zn+xVUyaIqV{bx zJfV*Mc+7cQs#7VZoaX&-I#kTsaWXmw3#J(!PB98dz~K}L%LEAyP#+knLQX0s4f>;{QK4}l zRzLeeT_FKYy#_z=4G_Svfg|_79e>==mU6W%ozIllukE_MD_!0sJDPwkTgq=Wt~9QS z>%Gg3Y0KU$>>iC9U0;@0t?j(MbDezL(IXGP^0<6#+4P02JY%oewC_yWcRsc=8ME`2 zYsHl`J~lV}0rrf>KVa4GQ2Zd`JO8+c=p~Gw5p=JmEPyOqzbYW3F0my_qZ$bU4nzjd zP%zw?yt(R?3dXse;i^tA$J^DmXwC#gg51`G&H6s0A}u9Q)-5Po36y;c%25L4+=6nI zK)JV|%1fXswxB9YpsKc@Xs&w7v1o=xpI@v$o>w?U{31u=EQ?k+No;dQNmzpU#kOcm z*tWxPHG1EIfvb&>T-~C*1hQU7E;t%gNs!uqBqVNFAKpf)qBbpr?1RLx|z6bR1c0EQVE8fDL1xuPKiAlYimpda(~o!3hQ^tT%Rs;`ed z(+5ASqu6F60hSNIPY|#?w<24gZfBW$HF-D_UBC{_ddYNk&-GR8DVd$b_ zd1+v1uxE5&$fsC<-%JG~Kpo>{TJ~g|I?!=wGH%V?Mmqg*BPbxA&RSyL0G(V5s4r?8#0~47}MuK2&XiEc%E0V8@^jVA>0f$-@3k_;o=LDlZkjkgw znRe*U)C*5K+}_p2s3q_q`6szvcoIMlMk7-Y@ntmdzOaZ;9KS21l|W3ltBVbb>T4Yt zxWs@d!BDj6ZMX0gbxXA~x z+HMBe6YTMXup5M835roXoBJi8$6#%EO~)q|xuzps(<@i^$>x4Ic4y4B8LKCG@&3@=p^d?G!wa&fTQ(mr=zQshz2|<- z-I{gNyE|`JZA>hiGUb(PHMeV$wsd(5+#NhBAM9P7OTMwrCBv!ew$JU$*38ab$?!u6 zSp2PNwQF_c_TEpk!4=Fs^$x`XAOjG=Ea|$nYD}r=~~q z=@*CP)0gGTBl75L@(3%luglf`C+6!}2aYl8CTN&h_}7xLc5EazJ5Q%NPs=Y}Nq3IO z9iy`O>K64_x^rCan2^n{f;wZX-L!d9HjmuUp0;(!*3J#_;fc+|eW}BJa{txz;g{q? zFJn+`jQkpVfOp)oKK{XL@4qHLcQ)O6PHs6bn+J-NXVR@_<(6}@`Mfr_8Ebv=!2Qm< zo$~(f^p4|lz4mVN%H!!BC*=B*viVdIvY^cFayw1m5pEjZ&v#9P*$y+d+LSaUrR3yl zR4(6>GVfWYzqC1Sb+2^aI=yl_c_eMy{g=HTTzLON3Z94R&`yhZLe#JPY4IJf;58x$ zC)@;a2jYGIKlB?~qDzocTHp8qV=93$6=6yd__895xkNj85&(T!fcS1llJXQEFt!qy zJi!NyV|!A}*$bq2+kW!&AKGx0=qFG90aIQAlc)ZGsVsrX6Mw)|ml$u=|6;r~C8%g^ z(L1OsfyvW*7z@UGw|fVkqIMb*^c@nuaW(o>=P%%4Mff8WKSzONMP)!L-BDSVfSgdF z)eo5hh7uHe@s~>h-Ma7=3XCHtxi6Nv51j%YL=|h{E2dD1ilXnB_%$^DDO0Gzb}%d> z+(A);;vE#VDEIYFiFJSCe?k1s<4|fX7}oztPyfwn=9XWW$o45uiklWv${Q1-TrVvu5M3P_he}( zh1i%Sx9e!$KU;=cGhwM%J-&AO_UZK((v|I53o5OIrD}BoW6A3i>FTbm4V8AnQoVX( zEqXh;9!l37&N@)(BrKJygPT za-maAJAh5pau8CQfEL@B7}wG7m;7f1h*Dis>nM~j8sWWGHN>b@!q7H^&7qPO;C_fr zW0X5?LnV`7LJ&IFdi)irz4oHYuOnEkp&F-}Q>m7kL%yYzs^e8_YoT7P>q#vCYZS%h z7}?UabKBG>)iag1LR%baWp#z99loBg@YBOl7m->6S5nasusyHyf82J-cCc;l4yBA` zxoCi86)Ve5&cdtYSyq5Z79j{IUPiHo0y!^!Rgk7b!RzYez;~gA7<{`BaQ9VAG58-O zemGmjIK>3V%*o)CfX-2fqL5H{Q7|ZcD8^B6D5g+gj88x+s``Zw3Dy2ozWRgtL^#UL zhWJy$KR`J+3qt%?5I|x`@(beJ?}(;v2Am6IHD; z;r`xGQEQNi%Itn(=l)0izb7oD`)Qq(-2Ze(8QDWVtul}o$*k2(UM4ep4`hvM=7p2F zOz+vJs^)18Nk0G7Tt+s+T0Qh!mTo6IGmX2mMohboXldU$HT`;5vs(W3fetM_cKj*g jv)4(Yk32(Wja8&ct&Hix=d}A1r?qt8((kccorC`dJ<_LW literal 0 HcmV?d00001 diff --git a/analysis/__pycache__/waveform.cpython-312.pyc b/analysis/__pycache__/waveform.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3059aebef70ea556e8f94b1229908f3ed6bc5ea8 GIT binary patch literal 16884 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 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 0000000000000000000000000000000000000000..37a5bc2790102eccd526456aa81e2a7af82bc3b9 GIT binary patch literal 414 zcmY*Vze~eF82v6un^O8iP@HcPDwH}T! zPug)RakmHYB?WK23zsEA-xBZw~u&I9QZV J?`%;~t8Ys{a*Y50 literal 0 HcmV?d00001 diff --git a/hardware/__pycache__/psu.cpython-312.pyc b/hardware/__pycache__/psu.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fae9bfaf9633ae5f2ac5c8a1973528dbd354293e GIT binary patch literal 3248 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 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 0000000000000000000000000000000000000000..709ce9a651d4b4051a0d7e7f83d447b611b04320 GIT binary patch literal 115 zcmX@j%ge<81ZM+&XM*U*AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdB~+YRRF+z#A0MBY omst`YuUAm{i^C>2KczG$)vkyYsDu%Si$RQ!%#4hTMa)1J00vbTS^xk5 literal 0 HcmV?d00001 diff --git a/server/__pycache__/app.cpython-312.pyc b/server/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a773e23b2c0ba0004507a42dc0027bffc16bcfad GIT binary patch literal 3787 zcmcImTW{RP6&_ypO06!tvQ{@IquP$Ls%~~8TTz4{fUS#>XzfaB?FuMZddbyN+9kI` z%8nEXShodY7b(0C0ipl};sSlJAwA@06zI#Y8CL?4hqg&kw(`VoPxbu}I-oI=o2_pD@~a_~uEw;T`xIZ4JwA z_bu2f%^qf{1!fNg-dmN{l`}!CD=SIQLXP|j?KA*ODL`kwjh3T6Iu<~mIsqN`(Lw;- zaRR!@M<)X4G@#GFWF@|*a!UZ+bppE8pSvx9?pB)RFo1tPaoJ#N%^#PSsRw zN@Q6v2XXe=A~p*OF6f4duU{UH$EP&8tgD)7;Mb*M5ffcE6&Xt=E|_L{Vq|2}HGO+x zY;<%qGZDvlW@-`R5uz-shN%z(H``%nSt8-VgGgw|LmGNy=!X>3->Uz<) zG_;FjV$ggcdvOe`$A5rfv?P`B9n~!0oRouw-dZn92CS_lX|iPMWIc0q9z!wBqN**^ zMO?rJoxxcaF6-dUJNW$QC@vW=nXG6z1#GYXh^}5%jdD?1_tuW_2Zkw`$^_Q6O+BCA z@`1NiSisH|$MTk&t`p=B6bkTgxF3SBd@ z8O{zlCuc`i3|&+6>vn`FKVMS}6QE6nJ0hHfLh@2hf%i~Rxvdm)jf>}yAPhawfqDKj z5I#YsGf2CY21)z@5M*W(?Xd0$gUm+YfN|D@pyD<_H0GDN-?F~U=h+3631v5u^X~F+ zUQ&ySJb}*{(~nu3)mQD1LWoY(e}a96x4ELeY{!)Mb4r=|O6b>Y&M*l$!YBs)2!4@p zxjG`1%fsb$J0|Ly=*&WrpzngeF#uf!ea%NcoVz<$OTPAq`GOz#Qb=x}+dcD0+Ig*> zI&TRVKEGTOF4g!;&jF1#;)iU^(E+dVAUL}<2?FevzmulFTPqZEta*XR+l>YEn1Q2n zU1AG&>~^9^vM3vBNh}xEMN=&S10N%jf{92Qban!6+abpZ_3Tyn8#Lg8cS7>x3wx=7 zdTPK*4O+rbjURH1H(ExH-j46a9dZhm(Qt^+CD|#*qody@G!F!PN>ZPWL3#ml7XC*a z%hXdDEA@R#7_9Mwu4B!vW8QT=;uocgb4>4m5IiQC`QHvoWe$%j!)EQ6p-E+wyZ9KQT3#nP z#SS?E7Xpc9a}aT-H+q}_Tv~(7bW6egy4H_%0?&NDpU_2pQ-%9W1duWjLIa^4rRPNh zsm+1pGCAG|?GRdQ!I$%S=k{h;o4=*Y>%`$K-F=MR3oDe@OtAqcL!cOjzp)CPbLe|Q zPhIGFc=ijS|A{d4=tAYnepBm%+55BA8&=cl%G7?OsmlLi>&s~TLE?VmH!b_I=IXg$ zze3-e?>9es+Lvs&i|%TW=+RvmN-VU=IkQ*9vqw< zc2l)OHDcO4&C(k}AWLT$LaRHQr&)Y@1H1S`3IddvE(7<vCKik#`SBcdqlIZ?FZqL^Q! zkyaE5M0jLJph{SVWC>A#7O;eeL1$mw3mtar147G_#%A7<3^jLI*YfJJ&0oz8Lb%ovJ6ch!h7mI+1P5BiGEiv3)a8VCn}e-)?!2?} zHV0MovCQxyItk=iD=Kf3Sumq@gmD`B1CC*sXKa+=|J9C|Ghd*(K1Kce;dEtrFPyH2)7w!i+*jfL5*F^w{!*`S2Vum9_L#Oh(^kz` zOz$(6WyYU#h&_EkrGq%AKTO|E-|MxQj%O^x3{z$LfJz6^hO!pxwwNBO9irM^svQbw zyDX-gYTu;V9;$uQ*WLru_GfI2>7&Y12UI%f^uF3@F-c#U@|1kjy^SYa*FF?$Gk0gI VT@Two=PNT$`LP2I@!d`|`wz;oGlBpB literal 0 HcmV?d00001 diff --git a/server/__pycache__/hw_interface.cpython-312.pyc b/server/__pycache__/hw_interface.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd65419e05c450aa51a802ab2e1c14a835061ff2 GIT binary patch literal 8766 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 Px7-WUn!R+5?%w|wd%@b< literal 0 HcmV?d00001 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