Updates
This commit is contained in:
49
CLAUDE.md
Normal file
49
CLAUDE.md
Normal file
@@ -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<N>: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`).
|
||||
867
MIPI_FLICKER_SPEC.md
Normal file
867
MIPI_FLICKER_SPEC.md
Normal file
@@ -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 <V> → set voltage
|
||||
CH1:CURRent <A> → 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<N>:DISPlay ON
|
||||
:CHANnel<N>:INPut DC50 → 50Ω termination (REQUIRED)
|
||||
:CHANnel<N>:PROBe 19.2 → attenuation ratio
|
||||
:CHANnel<N>:SCALe 0.05 → 50 mV/div
|
||||
:CHANnel<N>:OFFSet 0.0
|
||||
:CHANnel<N>:LABel '<name>'
|
||||
:TIMebase:SCALe 5E-9 → 5 ns/div
|
||||
:TIMebase:POSition 0
|
||||
:TIMebase:REFerence CENTer
|
||||
:TRIGger:MODE EDGE
|
||||
:TRIGger:EDGE:SOURce CHANnel<N>
|
||||
:TRIGger:EDGE:SLOPe NEGative
|
||||
:TRIGger:EDGE:LEVel <V>
|
||||
:TRIGger:SWEep NORMal
|
||||
:ACQuire:MODE RTIMe
|
||||
:ACQuire:INTerpolate ON
|
||||
:ACQuire:POINts 500000
|
||||
:DISPlay:LAYout STACKed
|
||||
:TER? → trigger event register (1 = triggered)
|
||||
:WAVeform:SOURce CHANnel<N>
|
||||
: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.
|
||||
BIN
__pycache__/config.cpython-312.pyc
Normal file
BIN
__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/master_loop.cpython-312.pyc
Normal file
BIN
__pycache__/master_loop.cpython-312.pyc
Normal file
Binary file not shown.
1
analysis/__init__.py
Normal file
1
analysis/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Pure analysis functions over captured waveforms and register dumps."""
|
||||
BIN
analysis/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
analysis/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
analysis/__pycache__/registers.cpython-312.pyc
Normal file
BIN
analysis/__pycache__/registers.cpython-312.pyc
Normal file
Binary file not shown.
BIN
analysis/__pycache__/report.cpython-312.pyc
Normal file
BIN
analysis/__pycache__/report.cpython-312.pyc
Normal file
Binary file not shown.
BIN
analysis/__pycache__/waveform.cpython-312.pyc
Normal file
BIN
analysis/__pycache__/waveform.cpython-312.pyc
Normal file
Binary file not shown.
132
analysis/registers.py
Normal file
132
analysis/registers.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Parse SN65DSI83 and DSIM register dumps into structured flags.
|
||||
|
||||
DSIM PHY_TIMING bit-field layout is undocumented in the i.MX 8M Mini RM.
|
||||
We log raw hex AND decoded cycle counts so they can be cross-checked
|
||||
against kernel dmesg output that prints the cycle counts explicitly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from config import (
|
||||
BYTE_CLK_HZ,
|
||||
SN65_ERR_SOT,
|
||||
SN65_ERR_SYNCH,
|
||||
SN65_ERR_UNC,
|
||||
SN65_FLICKER_MASK,
|
||||
)
|
||||
|
||||
|
||||
def _to_int(v) -> Optional[int]:
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, int):
|
||||
return v
|
||||
s = str(v).strip().lower()
|
||||
try:
|
||||
if s.startswith("0x"):
|
||||
return int(s, 16)
|
||||
return int(s, 16)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SN65DSI83
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_sn65(reg_json: dict) -> dict:
|
||||
"""Extract structured flicker flags from /sn65_registers response.
|
||||
|
||||
Accepts either the server's pre-parsed shape (with explicit bool keys)
|
||||
or a raw {register: hex} mapping; falls back to bit-decoding in either case.
|
||||
"""
|
||||
irq_raw = _to_int(reg_json.get("irq_stat_raw"))
|
||||
if irq_raw is None:
|
||||
regs = reg_json.get("registers", {})
|
||||
irq_raw = _to_int(regs.get("e5") or regs.get("E5") or regs.get("0xE5"))
|
||||
irq_raw = irq_raw or 0
|
||||
|
||||
pll_raw = _to_int(reg_json.get("registers", {}).get("0a")) if reg_json.get("registers") else None
|
||||
clk_raw = _to_int(reg_json.get("registers", {}).get("0b")) if reg_json.get("registers") else None
|
||||
|
||||
pll_locked = reg_json.get("pll_locked")
|
||||
if pll_locked is None and pll_raw is not None:
|
||||
pll_locked = bool(pll_raw & 0x80)
|
||||
|
||||
clk_detected = reg_json.get("clk_detected")
|
||||
if clk_detected is None and clk_raw is not None:
|
||||
clk_detected = bool(clk_raw & 0x01)
|
||||
|
||||
sot_err = bool(irq_raw & SN65_ERR_SOT)
|
||||
synch_err = bool(irq_raw & SN65_ERR_SYNCH)
|
||||
unc_ecc_err = bool(irq_raw & SN65_ERR_UNC)
|
||||
flicker_detected = bool(irq_raw & SN65_FLICKER_MASK)
|
||||
|
||||
return {
|
||||
"irq_stat_raw": f"0x{irq_raw:02X}",
|
||||
"irq_stat_int": irq_raw,
|
||||
"pll_locked": bool(pll_locked) if pll_locked is not None else None,
|
||||
"clk_detected": bool(clk_detected) if clk_detected is not None else None,
|
||||
"sot_err": sot_err,
|
||||
"synch_err": synch_err,
|
||||
"unc_ecc_err": unc_ecc_err,
|
||||
"flicker_detected": flicker_detected,
|
||||
"registers": reg_json.get("registers", {}),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DSIM PHY_TIMING / PHY_TIMING1 / PHY_TIMING2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cycles_to_ns(cycles: int) -> float:
|
||||
return cycles / BYTE_CLK_HZ * 1e9
|
||||
|
||||
|
||||
def parse_dsim(reg_json: dict) -> dict:
|
||||
pt = _to_int(reg_json.get("PHY_TIMING"))
|
||||
pt1 = _to_int(reg_json.get("PHY_TIMING1"))
|
||||
pt2 = _to_int(reg_json.get("PHY_TIMING2"))
|
||||
|
||||
out: dict = {
|
||||
"PHY_TIMING_raw": f"0x{pt:08X}" if pt is not None else None,
|
||||
"PHY_TIMING1_raw": f"0x{pt1:08X}" if pt1 is not None else None,
|
||||
"PHY_TIMING2_raw": f"0x{pt2:08X}" if pt2 is not None else None,
|
||||
}
|
||||
|
||||
if pt is not None:
|
||||
hs_exit = (pt >> 4) & 0xF
|
||||
lpx = pt & 0xF
|
||||
out["hs_exit_cycles"] = hs_exit
|
||||
out["hs_exit_ns"] = _cycles_to_ns(hs_exit)
|
||||
out["lpx_cycles"] = lpx
|
||||
out["lpx_ns"] = _cycles_to_ns(lpx)
|
||||
|
||||
if pt1 is not None:
|
||||
clk_zero = (pt1 >> 24) & 0xFF
|
||||
clk_post = (pt1 >> 16) & 0xFF
|
||||
clk_trail = (pt1 >> 8) & 0xFF
|
||||
clk_prepare = pt1 & 0xFF
|
||||
out["clk_zero_cycles"] = clk_zero
|
||||
out["clk_zero_ns"] = _cycles_to_ns(clk_zero)
|
||||
out["clk_post_cycles"] = clk_post
|
||||
out["clk_post_ns"] = _cycles_to_ns(clk_post)
|
||||
out["clk_trail_cycles"] = clk_trail
|
||||
out["clk_trail_ns"] = _cycles_to_ns(clk_trail)
|
||||
out["clk_prepare_cycles"] = clk_prepare
|
||||
out["clk_prepare_ns"] = _cycles_to_ns(clk_prepare)
|
||||
|
||||
if pt2 is not None:
|
||||
hs_prepare = (pt2 >> 16) & 0xFF
|
||||
hs_zero = (pt2 >> 8) & 0xFF
|
||||
hs_trail = pt2 & 0xFF
|
||||
out["hs_prepare_cycles"] = hs_prepare
|
||||
out["hs_prepare_ns"] = _cycles_to_ns(hs_prepare)
|
||||
out["hs_zero_cycles"] = hs_zero
|
||||
out["hs_zero_ns"] = _cycles_to_ns(hs_zero)
|
||||
out["hs_trail_cycles"] = hs_trail
|
||||
out["hs_trail_ns"] = _cycles_to_ns(hs_trail)
|
||||
|
||||
return out
|
||||
172
analysis/report.py
Normal file
172
analysis/report.py
Normal file
@@ -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,
|
||||
}
|
||||
401
analysis/waveform.py
Normal file
401
analysis/waveform.py
Normal file
@@ -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),
|
||||
}
|
||||
0
captures/.gitkeep
Normal file
0
captures/.gitkeep
Normal file
118
config.py
Normal file
118
config.py
Normal file
@@ -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"
|
||||
7
hardware/__init__.py
Normal file
7
hardware/__init__.py
Normal file
@@ -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"]
|
||||
BIN
hardware/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
hardware/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
hardware/__pycache__/psu.cpython-312.pyc
Normal file
BIN
hardware/__pycache__/psu.cpython-312.pyc
Normal file
Binary file not shown.
BIN
hardware/__pycache__/scope.cpython-312.pyc
Normal file
BIN
hardware/__pycache__/scope.cpython-312.pyc
Normal file
Binary file not shown.
BIN
hardware/__pycache__/target.cpython-312.pyc
Normal file
BIN
hardware/__pycache__/target.cpython-312.pyc
Normal file
Binary file not shown.
58
hardware/psu.py
Normal file
58
hardware/psu.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Siglent SPD3303X-E PSU controller over VXI-11 / SCPI.
|
||||
|
||||
Drives the display 3.3 V rail so the master loop can power-cycle the PCB
|
||||
between captures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import vxi11
|
||||
|
||||
from config import (
|
||||
PSU_CHANNEL_DISPLAY,
|
||||
PSU_DISPLAY_CURRENT,
|
||||
PSU_DISPLAY_VOLTAGE,
|
||||
PSU_POWER_CYCLE_DELAY_S,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PSUController:
|
||||
def __init__(self, ip: str) -> None:
|
||||
self.ip = ip
|
||||
self._inst = vxi11.Instrument(ip)
|
||||
idn = self._inst.ask("*IDN?").strip()
|
||||
log.info("PSU connected: %s", idn)
|
||||
self.idn = idn
|
||||
|
||||
ch = PSU_CHANNEL_DISPLAY
|
||||
self._inst.write(f"CH{ch}:VOLTage {PSU_DISPLAY_VOLTAGE}")
|
||||
self._inst.write(f"CH{ch}:CURRent {PSU_DISPLAY_CURRENT}")
|
||||
self.output_off()
|
||||
|
||||
def output_on(self) -> None:
|
||||
self._inst.write(f"OUTPut CH{PSU_CHANNEL_DISPLAY},ON")
|
||||
|
||||
def output_off(self) -> None:
|
||||
self._inst.write(f"OUTPut CH{PSU_CHANNEL_DISPLAY},OFF")
|
||||
|
||||
def power_cycle(self, delay_s: float = PSU_POWER_CYCLE_DELAY_S) -> None:
|
||||
self.output_off()
|
||||
time.sleep(delay_s)
|
||||
self.output_on()
|
||||
|
||||
def measure(self) -> dict:
|
||||
ch = PSU_CHANNEL_DISPLAY
|
||||
voltage = float(self._inst.ask(f"MEASure:VOLTage? CH{ch}"))
|
||||
current = float(self._inst.ask(f"MEASure:CURRent? CH{ch}"))
|
||||
return {"voltage_v": voltage, "current_a": current}
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._inst.close()
|
||||
except Exception:
|
||||
pass
|
||||
133
hardware/scope.py
Normal file
133
hardware/scope.py
Normal file
@@ -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
|
||||
53
hardware/target.py
Normal file
53
hardware/target.py
Normal file
@@ -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"})
|
||||
205
master_loop.py
Normal file
205
master_loop.py
Normal file
@@ -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())
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
python-vxi11>=0.9
|
||||
requests>=2.28
|
||||
flask>=3.0
|
||||
numpy>=1.24
|
||||
pandas>=2.0
|
||||
matplotlib>=3.7
|
||||
0
server/__init__.py
Normal file
0
server/__init__.py
Normal file
BIN
server/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
server/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
server/__pycache__/app.cpython-312.pyc
Normal file
BIN
server/__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
BIN
server/__pycache__/hw_interface.cpython-312.pyc
Normal file
BIN
server/__pycache__/hw_interface.cpython-312.pyc
Normal file
Binary file not shown.
76
server/app.py
Normal file
76
server/app.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Flask REST server — runs ON THE i.MX 8M Mini target, NOT the host PC.
|
||||
|
||||
Endpoints (all rooted at http://<target>: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)
|
||||
215
server/hw_interface.py
Normal file
215
server/hw_interface.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user