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