diff --git a/analyze_session.py b/analyze_session.py new file mode 100644 index 0000000..dee5e3d --- /dev/null +++ b/analyze_session.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +""" +analyze_session.py — Cross-reference sn65_monitor unlocks against +video_cycler events to determine whether unlocks are caused by video +START transitions, STOP transitions, or both. + +Usage: + python3 analyze_session.py # use latest sn65 + latest cycle log + python3 analyze_session.py # specify sn65 log + python3 analyze_session.py +""" + +from __future__ import annotations + +import csv +import json +import statistics +import sys +from pathlib import Path + +DATA_DIR = Path(__file__).parent / "data" +SN65_DIR = DATA_DIR / "sn65_log" +CYCLE_DIR = DATA_DIR / "cycle_logs" + + +def find_latest(dir_path: Path, glob: str) -> Path | None: + files = sorted(dir_path.glob(glob)) + return files[-1] if files else None + + +def load_unlocks(sn65_path: Path) -> list[dict]: + """Pair pll_lock False→True transitions and return [{start_ts, end_ts, duration_ms}, ...].""" + data = json.loads(sn65_path.read_text()) + changes = sorted(data.get("session_changes", []), key=lambda c: c["ts"]) + pll = [c for c in changes if "pll_lock" in c.get("delta", {})] + + unlocks: list[dict] = [] + i = 0 + while i < len(pll): + e = pll[i] + _, new = e["delta"]["pll_lock"] + if new is False: + for j in range(i + 1, min(i + 20, len(pll))): + _, n_new = pll[j]["delta"]["pll_lock"] + if n_new is True: + unlocks.append({ + "start_ts": e["ts"], + "end_ts": pll[j]["ts"], + "duration_ms": (pll[j]["ts"] - e["ts"]) * 1000, + "iso": e["iso"], + }) + i = j + break + i += 1 + return unlocks + + +def load_cycle_events(cycle_path: Path) -> list[dict]: + """Load timestamped video start/stop events from a cycle log CSV.""" + out: list[dict] = [] + with open(cycle_path) as f: + for row in csv.DictReader(f): + out.append({ + "ts": float(row["unix_ts"]), + "event": row["event"], + "cycle": int(row["cycle"]), + "iso": row["iso"], + }) + return out + + +def nearest_event(unlock_ts: float, events: list[dict], + event_type: str | None = None) -> dict | None: + candidates = events if event_type is None else [e for e in events if e["event"] == event_type] + if not candidates: + return None + return min(candidates, key=lambda e: abs(e["ts"] - unlock_ts)) + + +def main() -> None: + args = sys.argv[1:] + sn65_path = Path(args[0]) if len(args) >= 1 else find_latest(SN65_DIR, "*.json") + cycle_path = Path(args[1]) if len(args) >= 2 else find_latest(CYCLE_DIR, "*.csv") + + if sn65_path is None or not sn65_path.exists(): + print(f"No sn65 log found.") + sys.exit(1) + + print(f"sn65 log: {sn65_path}") + print(f"cycle log: {cycle_path if cycle_path else '(none — START/STOP correlation unavailable)'}") + + # === Pulse-width analysis === + unlocks = load_unlocks(sn65_path) + print(f"\n=== PLL unlocks ===") + print(f" total: {len(unlocks)}") + if not unlocks: + print(" no unlocks — nothing more to analyse") + return + durs = sorted(u["duration_ms"] for u in unlocks) + n = len(durs) + print(f" pulse-width: min={durs[0]:.1f} ms med={durs[n//2]:.1f} ms" + f" p90={durs[min(n*9//10, n-1)]:.1f} ms max={durs[-1]:.1f} ms") + print(f" per-event:") + for u in unlocks: + print(f" {u['iso']} duration {u['duration_ms']:6.1f} ms") + + # === Correlation against cycle events === + if cycle_path is None or not cycle_path.exists(): + return + + events = load_cycle_events(cycle_path) + n_start = sum(1 for e in events if e["event"] == "start") + n_stop = sum(1 for e in events if e["event"] == "stop") + print(f"\n=== Cycle events ===") + print(f" {len(events)} events total ({n_start} start, {n_stop} stop)") + if events: + print(f" first: {events[0]['iso']} last: {events[-1]['iso']}") + + # For each unlock find offsets to nearest start and nearest stop + print(f"\n=== Unlock vs. nearest cycle event ===") + print(f" {'unlock iso':<25} {'dur_ms':>7} " + f"{'Δ_to_START':>12} {'Δ_to_STOP':>12} verdict") + near_start = [] # offsets (s) from nearest START to each unlock + near_stop = [] + starts = [e for e in events if e["event"] == "start"] + stops = [e for e in events if e["event"] == "stop"] + classifications = {"start": 0, "stop": 0, "neither": 0} + for u in unlocks: + ns = nearest_event(u["start_ts"], starts) + nt = nearest_event(u["start_ts"], stops) + d_start = (u["start_ts"] - ns["ts"]) if ns else None + d_stop = (u["start_ts"] - nt["ts"]) if nt else None + + # Classify: "after start" if 0..3s after a start, "after stop" if 0..3s after a stop. + verdict = "neither" + if d_start is not None and 0.0 <= d_start <= 3.0 \ + and (d_stop is None or d_stop < 0 or d_stop > d_start): + verdict = "after_START" + near_start.append(d_start) + classifications["start"] += 1 + elif d_stop is not None and 0.0 <= d_stop <= 3.0: + verdict = "after_STOP" + near_stop.append(d_stop) + classifications["stop"] += 1 + else: + classifications["neither"] += 1 + + def fmt(v): + if v is None: return " —" + return f"{v:+8.2f}s" + print(f" {u['iso']:<25} {u['duration_ms']:>7.1f} " + f"{fmt(d_start):>12} {fmt(d_stop):>12} {verdict}") + + total = sum(classifications.values()) + print(f"\n=== Verdict ===") + if total: + print(f" after START : {classifications['start']:>3} / {total} " + f"({classifications['start']/total*100:.0f}%)") + print(f" after STOP : {classifications['stop']:>3} / {total} " + f"({classifications['stop']/total*100:.0f}%)") + print(f" neither : {classifications['neither']:>3} / {total} " + f"({classifications['neither']/total*100:.0f}%)") + + if near_start: + print(f"\n offsets after START (n={len(near_start)}): " + f"med={statistics.median(near_start)*1000:.0f} ms, " + f"min={min(near_start)*1000:.0f} ms, max={max(near_start)*1000:.0f} ms") + if near_stop: + print(f" offsets after STOP (n={len(near_stop)}): " + f"med={statistics.median(near_stop)*1000:.0f} ms, " + f"min={min(near_stop)*1000:.0f} ms, max={max(near_stop)*1000:.0f} ms") + + # Per-cycle unlock rate + if events: + cycle_window_s = events[-1]["ts"] - events[0]["ts"] + n_cycles = max(1, max(e["cycle"] for e in events)) + print(f"\n cycles seen: {n_cycles} total span: {cycle_window_s:.1f}s") + print(f" unlocks per cycle: {len(unlocks)/n_cycles:.2f}") + print(f" unlocks per minute: {len(unlocks)/cycle_window_s*60:.2f}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/arrive-logotype-purple-RGB.png b/arrive-logotype-purple-RGB.png new file mode 100644 index 0000000..0aae219 Binary files /dev/null and b/arrive-logotype-purple-RGB.png differ diff --git a/compare_stops.py b/compare_stops.py new file mode 100644 index 0000000..0a2c4f1 --- /dev/null +++ b/compare_stops.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +""" +compare_stops.py — Compare different video-stop styles for SN65 PLL impact. + +For each "stop style" defined in STYLES, this script: + 1. Polls /sn65_registers in a background thread (50 Hz, like sn65_monitor) + 2. Runs N cycles of start → on_s seconds → stop → off_s seconds + 3. Records PLL unlock events and which style was active when each fired + +At the end it prints a comparison table: per-style cycles run, unlocks seen, +unlocks-per-stop probability, and unlock pulse-width stats. This is the +test you use to verify that switching the device's "stop" semantics (e.g. +GStreamer PAUSE instead of NULL) actually reduces the unlock rate. + +Edit STYLES at the top of the file to add new stop variants as your +device server gains them. The start payload can vary too if you want +to test different start sequences. + +Usage: + python3 compare_stops.py # 20 cycles per style + python3 compare_stops.py --cycles 50 --on-s 5 +""" + +from __future__ import annotations + +import argparse +import json +import statistics +import sys +import threading +import time +from datetime import datetime +from pathlib import Path + +import requests + +DEVICE_BASE = "http://192.168.45.8:5000" +VIDEO_URL = f"{DEVICE_BASE}/video" +SN65_EP = f"{DEVICE_BASE}/sn65_registers" + +POLL_DT_S = 0.02 # 50 Hz target +HTTP_TIMEOUT_S = 0.2 + +LOG_DIR = Path(__file__).parent / "data" / "compare_logs" + +# ------------------------------------------------------------------------- +# STYLES — edit this list to add new stop variants +# ------------------------------------------------------------------------- +# Each entry: (label, start_payload_dict, stop_payload_dict) +# The default first entry is the current /video stop behaviour. +# When your device server supports alternative actions (e.g. "pause"), +# uncomment / add the corresponding line below. +STYLES: list[tuple[str, dict, dict]] = [ + ("stop_full", + {"action": "start", "mode": "static-pink"}, + {"action": "stop"}), + + # === Add styles below once the device server supports them === + # ("stop_pause", # GST_STATE_PAUSED — keeps DSI clock alive + # {"action": "start", "mode": "static-pink"}, + # {"action": "pause"}), + # ("stop_ready", # GST_STATE_READY — half-way teardown + # {"action": "start", "mode": "static-pink"}, + # {"action": "ready"}), + # ("stop_keep_clk", + # {"action": "start", "mode": "static-pink"}, + # {"action": "stop", "keep_clock": True}), +] + + +# ------------------------------------------------------------------------- +# SN65 polling thread +# ------------------------------------------------------------------------- +class SN65Poller: + """Background thread polling /sn65_registers; records every PLL transition.""" + + ERROR_BITS = ("pll_unlock", "cha_sot_bit_err", "cha_llp_err", + "cha_ecc_err", "cha_lp_err", "cha_crc_err") + + def __init__(self) -> None: + self.sess = requests.Session() + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self.changes: list[dict] = [] + self.samples: list[dict] = [] # every poll: {ts, state, err?} + self._last_state: dict | None = None + self.actual_hz = 0.0 + self.err_count = 0 + + def start(self) -> None: + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + if self._thread: + self._thread.join(timeout=2) + + def _run(self) -> None: + n_polls = 0 + rate_t0 = time.time() + while not self._stop.is_set(): + t0 = time.time() + try: + r = self.sess.get(SN65_EP, timeout=HTTP_TIMEOUT_S) + r.raise_for_status() + data = r.json() + except requests.exceptions.RequestException: + self.samples.append({"ts": t0, "error": True}) + self.err_count += 1 + if t0 - rate_t0 > 2.0: + self.actual_hz = n_polls / (t0 - rate_t0); rate_t0 = t0; n_polls = 0 + time.sleep(POLL_DT_S) + continue + + regs = data.get("registers", {}) + csr0a = regs.get("csr_0a", {}) or {} + csre5 = regs.get("csr_e5", {}) or {} + state = { + "csr_0a": csr0a.get("value"), + "csr_e5": csre5.get("value"), + "pll_lock": csr0a.get("pll_lock"), + "clk_det": csr0a.get("clk_det"), + } + for k in self.ERROR_BITS: + state[k] = csre5.get(k) + self.samples.append({"ts": t0, "state": state}) + n_polls += 1 + + if self._last_state is not None and state != self._last_state: + delta = {k: (self._last_state.get(k), state.get(k)) + for k in state if state.get(k) != self._last_state.get(k)} + self.changes.append({"ts": t0, "delta": delta, "new_state": state}) + self._last_state = state + + if t0 - rate_t0 > 2.0: + self.actual_hz = n_polls / (t0 - rate_t0); rate_t0 = t0; n_polls = 0 + + elapsed = time.time() - t0 + if elapsed < POLL_DT_S: + time.sleep(POLL_DT_S - elapsed) + + def unlocks(self) -> list[dict]: + """Pair pll_lock False→True transitions into [{start_ts, end_ts, duration_ms}].""" + pll = [c for c in self.changes if "pll_lock" in c["delta"]] + out, i = [], 0 + while i < len(pll): + _, new = pll[i]["delta"]["pll_lock"] + if new is False: + for j in range(i+1, min(i+20, len(pll))): + _, n_new = pll[j]["delta"]["pll_lock"] + if n_new is True: + out.append({"start_ts": pll[i]["ts"], + "end_ts": pll[j]["ts"], + "duration_ms": (pll[j]["ts"]-pll[i]["ts"])*1000}) + i = j + break + i += 1 + return out + + +# ------------------------------------------------------------------------- +# Cycle runner +# ------------------------------------------------------------------------- +def put_video(payload: dict, label: str) -> None: + try: + requests.put(VIDEO_URL, json=payload, timeout=3.0) + except requests.exceptions.RequestException as e: + print(f" {label} HTTP failed: {e}") + + +def run_style(label: str, start_payload: dict, stop_payload: dict, + n_cycles: int, on_s: float, off_s: float, + poller: SN65Poller, transitions: list) -> tuple[float, float]: + """Run n cycles of the given style. Returns (start_ts, end_ts).""" + print(f"\n▸ style '{label}'") + print(f" start={start_payload} stop={stop_payload} cycles={n_cycles}") + style_start = time.time() + for c in range(1, n_cycles + 1): + t = time.time() + put_video(start_payload, "start") + transitions.append({"ts": t, "event": "start", "style": label, "cycle": c}) + time.sleep(on_s) + t = time.time() + put_video(stop_payload, "stop") + transitions.append({"ts": t, "event": "stop", "style": label, "cycle": c}) + if c % 5 == 0 or c == n_cycles: + n_unlocks = len(poller.unlocks()) + print(f" cycle {c:>3}/{n_cycles} " + f"{poller.actual_hz:5.1f} Hz " + f"unlocks so far: {n_unlocks}", flush=True) + time.sleep(off_s) + return style_start, time.time() + + +# ------------------------------------------------------------------------- +# Analysis +# ------------------------------------------------------------------------- +def analyse(styles_run: list[dict], unlocks: list[dict], + transitions: list[dict]) -> None: + """Bucket unlocks by which style was active when they fired.""" + print("\n" + "="*78) + print(" STOP-STYLE COMPARISON") + print("="*78) + print(f"\n {'style':<18} {'cycles':>6} {'unlocks':>8} " + f"{'per_cycle':>10} {'med_pulse':>10} {'min_pulse':>10} {'max_pulse':>10}") + print(f" {'-'*18} {'-'*6} {'-'*8} {'-'*10} {'-'*10} {'-'*10} {'-'*10}") + + for s in styles_run: + in_window = [u for u in unlocks + if s["t_start"] <= u["start_ts"] <= s["t_end"]] + durs = [u["duration_ms"] for u in in_window] + n = len(in_window) + per_cycle = n / s["cycles"] + med = statistics.median(durs) if durs else 0.0 + mn = min(durs) if durs else 0.0 + mx = max(durs) if durs else 0.0 + print(f" {s['label']:<18} {s['cycles']:>6} {n:>8} " + f"{per_cycle:>10.2f} " + f"{med:>8.1f}ms {mn:>8.1f}ms {mx:>8.1f}ms") + + # also: distribution after STOP (we expect 100% if behaviour is same as baseline) + stops_in_style = [t for t in transitions + if t["style"] == s["label"] and t["event"] == "stop"] + after_stop = 0 + after_start = 0 + for u in in_window: + nearest_stop = min((t["ts"] for t in stops_in_style), + key=lambda ts: abs(ts - u["start_ts"]), + default=None) + starts_in_style = [t for t in transitions + if t["style"] == s["label"] and t["event"] == "start"] + nearest_start = min((t["ts"] for t in starts_in_style), + key=lambda ts: abs(ts - u["start_ts"]), + default=None) + if nearest_stop is None or nearest_start is None: + continue + d_stop = u["start_ts"] - nearest_stop + d_start = u["start_ts"] - nearest_start + if 0 <= d_stop <= 3.0 and (d_start < 0 or d_stop < d_start): + after_stop += 1 + elif 0 <= d_start <= 3.0: + after_start += 1 + if n: + print(f" {'':<18} ↳ after STOP: {after_stop}, after START: {after_start}," + f" other: {n - after_stop - after_start}") + + +def save_log(styles_run, unlocks, transitions, samples) -> Path: + LOG_DIR.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + out = LOG_DIR / f"{ts}_compare.json" + out.write_text(json.dumps({ + "saved_at": ts, + "styles": styles_run, + "unlocks": unlocks, + "transitions": transitions, + "n_samples": len(samples), + }, indent=2, default=str)) + return out + + +def main() -> None: + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--cycles", type=int, default=20, + help="number of cycles per style (default 20)") + ap.add_argument("--on-s", type=float, default=10.0, + help="seconds video is ON per cycle (default 10)") + ap.add_argument("--off-s", type=float, default=0.5, + help="seconds video is OFF per cycle (default 0.5)") + ap.add_argument("--settle-s", type=float, default=2.0, + help="seconds to wait between styles (default 2)") + args = ap.parse_args() + + if not STYLES: + print("No styles configured — edit STYLES at the top of this file.") + sys.exit(1) + + print(f"compare_stops — {len(STYLES)} style(s) × {args.cycles} cycles " + f"({args.on_s}s on / {args.off_s}s off)") + print(f" styles: {[s[0] for s in STYLES]}") + + poller = SN65Poller() + poller.start() + print(f" SN65 polling started — waiting 1s for first sample...") + time.sleep(1.0) + + styles_run = [] + transitions: list = [] + try: + for label, start_p, stop_p in STYLES: + t0, t1 = run_style(label, start_p, stop_p, + args.cycles, args.on_s, args.off_s, + poller, transitions) + styles_run.append({"label": label, "cycles": args.cycles, + "t_start": t0, "t_end": t1, + "start_payload": start_p, + "stop_payload": stop_p}) + # Settle so unlocks from one style don't get attributed to the next + print(f" settling {args.settle_s}s ...") + time.sleep(args.settle_s) + except KeyboardInterrupt: + print("\nInterrupted — leaving video stopped") + put_video({"action": "stop"}, "stop-on-exit") + + poller.stop() + + unlocks = poller.unlocks() + print(f"\n SN65 poller: actual ~{poller.actual_hz:.1f} Hz, " + f"{poller.err_count} HTTP errors, " + f"{len(unlocks)} total PLL unlock(s)") + + analyse(styles_run, unlocks, transitions) + out = save_log(styles_run, unlocks, transitions, poller.samples) + print(f"\n log saved → {out.relative_to(LOG_DIR.parent.parent)}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/flicker_investigation_report.html b/flicker_investigation_report.html new file mode 100644 index 0000000..44d15f8 --- /dev/null +++ b/flicker_investigation_report.html @@ -0,0 +1,354 @@ + + + + +MIPI DSI Flicker — Root Cause Investigation + + + + + + +
+ +

MIPI DSI Flicker — Root Cause Investigation

+
+ Display flicker on i.MX 8M Mini → SN65DSI83 → LVDS panel
+ Investigation period: May 7 – May 11, 2026 · + Author: David Rice +
+ +
+ TL;DR. The flicker is caused by the + PUT /video stop path on the i.MX, not by signal-integrity + on the MIPI bus, not by the SN65DSI83 itself, and not by the panel. + Each video stop tears down the DSI HS-clock; the SN65's PLL loses lock + for 15–45 ms (1–3 display frames); on the subsequent start, the + PLL must re-acquire and the LVDS output is briefly garbled — the + visible flicker. Fix: change "stop" semantics so the + HS-clock keeps running (e.g. GStreamer PAUSED instead of + NULL), or simply avoid stopping video in production. +
+ +

1. The problem

+

+ An i.MX 8M Mini SoC drives a SN65DSI83 MIPI-DSI → LVDS bridge into an + LCD panel. Under steady-state operation the display sometimes flickers. + Initial hypothesis: a MIPI bit-error or signal-integrity issue on the + CLK or data lanes. +

+ +

2. Investigation summary

+ + + + + + + + + + + + + + + + + + + + + + + +
PhaseApproachResult
1 – proto decoderDecode raw MIPI bursts from differential captures, look for byte-level anomaliesInconclusive. Capture coverage was ~0.0004% of time + (20 µs windows on a 10 s cycle). Flicker events not captured.
2 – segmented memoryUse scope's segmented-memory mode to capture 100 LP-trigger events per acquisition + (~50× higher coverage), analyse per-segmentNegative result. Flicker and non-flicker captures + statistically indistinguishable across all LP-state metrics.
3 – SN65 register monitorHigh-rate HTTP polling of SN65DSI83's status registers + (csr_0a, csr_e5) to catch transient PLL events + the post-event snapshot missedSmoking gun found. PLL unlocks during flicker + sessions, never during good sessions.
4 – Pulse-width characterisation100 Hz polling (median 20 ms) to measure actual unlock pulse widthPulse width 15–45 ms (1–3 display frames). Too short for + a software-driven clock pause; too short for a brownout-and-restart. + Consistent with a brief clock-lane disturbance.
5 – Cycling vs holdCompare PLL behaviour under continuous video to behaviour under + on/off cyclingDefinitive. 0 unlocks in 10 min 51 s of + continuous video. 30 unlocks in 9 min of cycling. The cycling is + the trigger.
6 – Transition isolationTime-correlate every unlock against each PUT /video start + and PUT /video stop eventConclusive. 100% of unlocks happen 225–259 ms + after stop. 0% after start.
+ +

3. Key data

+ +

3.1 Continuous video (hold) — baseline

+ + + + + + + + +
RunDurationPLL unlocksRateI²C read errors
Hold (no cycling)650.9 s00.0/min0.0%
+ +

3.2 Video on/off cycling

+ + + + + + + + + + + +
RunDurationCyclesPLL unlocksUnlocks / cycle
May 11 — 30 unlock-recovery pairs~9 min~54300.56
May 11 — controlled (17 cycles)177 s1780.47
+ +

3.3 Unlock pulse-width distribution

+ + + + + + + + +
MetricValueNotes
min 14.5 msunder 1 frame at 60 Hz
median 21.3 ms~1.3 frames
p90 40.0 ms~2.4 frames
max 44.5 ms~2.7 frames
+ +
+ Transition-isolation verdict (n = 8)

+ Unlocks after STOP:  8 / 8  (100%)  · + median offset 230 ms (range 225–259 ms)
+ Unlocks after START:  0 / 8  (0%)
+ Unlocks unattributable to either:  0 / 8  (0%) +
+ +

4. Mechanism

+ +
+                  PUT /video stop arrives
+                      │
+                      │ ~5 ms     HTTP / Flask processing
+                      │ ~50-150ms App + GStreamer pipeline tears down
+                      │           (state goes to NULL)
+                      ▼
+              DSIM driver disables HS_CLK_EN  ──────►  ~230 ms after stop
+                      │
+                      ▼
+              MIPI CLK lane goes to LP-11
+                      │
+                      ▼
+              SN65DSI83 sees no reference clock
+                      │
+                      ▼
+              PLL drops lock          ◄── csr_e5.pll_unlock = 1 caught here
+                                            (pulse width 15-45 ms)
+                      │
+                      ▼
+              PLL re-acquires to "no-signal" idle state
+                      │
+                  (~500 ms OFF window)
+                      │
+                  PUT /video start
+                      ▼
+              DSIM re-enables HS_CLK_EN; MIPI traffic resumes
+                      │
+                      ▼
+              SN65DSI83 PLL has to re-acquire to the new clock
+                      │      (~10-30 ms, LVDS output is garbage during this)
+                      ▼
+              ──── visible flicker on the panel ────
+
+ +

+ The bridge is behaving correctly: a PLL is expected to lose lock when its + reference clock disappears. The defect is upstream — the act of + stopping the video drops the MIPI HS-clock, which puts the bridge + through an unlock-relock cycle every time, and the next start has to + re-acquire from cold. That re-acquisition window is the visible flicker. +

+ +

5. Recommended fix

+ +

Two orthogonal options. Either should eliminate the flicker.

+ +

5.1 Don't tear the pipeline down

+

+ In the device-side video stack, change the "stop" path from a full + teardown to a soft pause that keeps the DSI HS-clock running. + For GStreamer: +

+
// Today  (causes flicker):
+gst_element_set_state(pipeline, GST_STATE_NULL);
+
+// Proposed:
+gst_element_set_state(pipeline, GST_STATE_PAUSED);
+
+

+ PAUSED retains the pipeline graph and buffers — and, importantly, + doesn't trigger the bridge-disable path in the i.MX DSIM driver, so HS-CLK + stays on and the SN65 PLL stays locked through the transition. Resume is + near-instant and visually clean. +

+ +

5.2 Don't stop video in production

+

+ If the only reason /video stop is called in real deployments + is the flicker test harness itself, the flicker mode is purely an artefact + of the test. Production code that starts the stream once at boot and + leaves it running will see zero PLL unlocks (confirmed + empirically — 0 unlocks in 10 min 51 s of continuous video). +

+ +

5.3 Verify the fix

+

+ Once the device server gains a soft-stop action (e.g. + {"action": "pause"}), compare_stops.py in this + repo runs an A/B test automatically: +

+
STYLES = [
+    ("stop_full",  {"action": "start", "mode": "static-pink"}, {"action": "stop"}),
+    ("stop_pause", {"action": "start", "mode": "static-pink"}, {"action": "pause"}),
+]
+$ python3 compare_stops.py --cycles 30
+
+

+ A successful fix will show ~0.5 unlocks/cycle for + stop_full and 0.00 for stop_pause. +

+ +

6. Tools developed

+ + + + + + + + + + + + +
ScriptPurpose
sn65_monitor.pyPolls SN65 status registers at 50–100 Hz, logs every PLL transition with + millisecond timestamps. Rolling 30 s buffer dumped to JSON on + f/g keypress.
video_cycler.pyToggles /video start/stop on the device on a configurable + cadence. Logs every transition to a CSV. Has a --hold + mode for the no-cycling baseline.
analyze_session.pyCross-references the latest SN65 log with the latest cycle log, + classifies each unlock as "after_START / after_STOP / neither", + prints a verdict.
compare_stops.pyRuns a controlled A/B/… test across multiple stop-style payloads. + Polls SN65 inline, attributes unlocks to the active style, + prints a comparison table. Use this to verify the eventual fix.
+ +

7. Open questions

+
    +
  • The rare catastrophic black-screen mode. Most unlocks + recover cleanly within ~40 ms. Twice during the investigation a + flicker resulted in a persistent black screen. That is presumably the + same root cause but where PLL re-acquisition fails outright; eliminating + the trigger should eliminate the rare variant too, but worth + confirming with extended runs.
  • +
  • The 8 % of "good" sessions that contained early unlock activity. + One f press on 11 May (08:33:28) had zero PLL activity in + the preceding minute, suggesting either an observation false-positive + or a separate, sub-poll-rate fault path. Worth keeping the + sn65_monitor running in any future regression testing + to catch.
  • +
  • Device-side endpoint additions. Three small additions + would close out the diagnostic kit: +
      +
    • POST /video support for action=pause + (GStreamer GST_STATE_PAUSED)
    • +
    • /registers to expose the runtime DSIM registers + DSIM_STATUS, DSIM_CLKCTRL, + DSIM_INTSRC, DSIM_FIFOCTRL alongside the + existing PHY-timing config registers
    • +
    • GET /video/state exposing the GStreamer pipeline + state (NULL / READY / PAUSED / PLAYING), which would let us + time-correlate the actual pipeline state transitions with the + PLL unlock window
    • +
    +
  • +
+ +
+ Investigation traces, raw register snapshots, and the analysis scripts + are in this repository under data/sn65_log/, + data/cycle_logs/, and data/compare_logs/. + Each timestamped run is independently reproducible. +
+ +
+ + + \ No newline at end of file diff --git a/video_cycler.py b/video_cycler.py index de9ee48..3dcc163 100644 --- a/video_cycler.py +++ b/video_cycler.py @@ -1,25 +1,64 @@ #!/usr/bin/env python3 """ -video_cycler.py — Toggle /video start/stop on the device every CYCLE_S seconds. +video_cycler.py — Toggle /video start/stop on the device. -Pairs with sn65_monitor.py: this script provokes the flicker by cycling the -static-pink video stream, while sn65_monitor measures. Press Ctrl+C to stop. +Pairs with sn65_monitor.py: this script provokes flicker by cycling the +static-pink video stream, while sn65_monitor measures. All start/stop +events are timestamp-logged to data/cycle_logs/{ts}.csv so we can later +cross-reference PLL unlocks against the precise transition moments. + +Modes: + python3 video_cycler.py # 10 s on / 0.5 s off, forever + python3 video_cycler.py --on-s 5 --off-s 2 # 5 s on, 2 s off + python3 video_cycler.py --cycles 30 # stop after 30 cycles + python3 video_cycler.py --hold # one start, hold forever + +Press Ctrl+C to stop (always sends a final video stop). """ +import argparse +import csv import signal import sys import time from datetime import datetime +from pathlib import Path import requests -DEVICE_BASE = "http://192.168.45.8:5000" -VIDEO_URL = f"{DEVICE_BASE}/video" -CYCLE_S = 10.0 -HTTP_TIMEOUT_S = 3.0 +DEVICE_BASE = "http://192.168.45.8:5000" +VIDEO_URL = f"{DEVICE_BASE}/video" +HTTP_TIMEOUT_S = 3.0 +LOG_DIR = Path(__file__).parent / "data" / "cycle_logs" -def video_start() -> None: +_log_writer = None +_log_file = None + + +def _open_log() -> Path: + """Open a fresh cycle-event CSV in LOG_DIR; return its path.""" + global _log_writer, _log_file + LOG_DIR.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + path = LOG_DIR / f"{ts}_cycles.csv" + _log_file = open(path, "w", newline="") + _log_writer = csv.writer(_log_file) + _log_writer.writerow(["iso", "unix_ts", "event", "cycle"]) + _log_file.flush() + return path + + +def _log_event(event: str, cycle: int) -> None: + t = time.time() + iso = datetime.fromtimestamp(t).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + if _log_writer is not None: + _log_writer.writerow([iso, f"{t:.6f}", event, cycle]) + _log_file.flush() + + +def video_start(cycle: int = 0) -> None: + _log_event("start", cycle) try: requests.put(VIDEO_URL, json={"action": "start", "mode": "static-pink"}, @@ -28,7 +67,8 @@ def video_start() -> None: print(f" video START failed: {e}") -def video_stop() -> None: +def video_stop(cycle: int = 0) -> None: + _log_event("stop", cycle) try: requests.put(VIDEO_URL, json={"action": "stop"}, timeout=HTTP_TIMEOUT_S) @@ -37,24 +77,66 @@ def video_stop() -> None: def main() -> None: - # On Ctrl+C, make sure we leave video stopped. + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + ap.add_argument("--on-s", type=float, default=10.0, + help="seconds video is ON per cycle (default 10)") + ap.add_argument("--off-s", type=float, default=0.5, + help="seconds video is OFF per cycle (default 0.5)") + ap.add_argument("--cycles", type=int, default=0, + help="stop after this many cycles (0 = forever, default)") + ap.add_argument("--hold", action="store_true", + help="Send a single video START and hold (no cycling). " + "Use as a baseline: do unlocks still happen when " + "we don't toggle on/off?") + args = ap.parse_args() + + log_path = _open_log() + print(f" event log → {log_path.relative_to(LOG_DIR.parent.parent)}") + def _shutdown(*_): print("\nshutting down — video off") - video_stop() + video_stop(cycle=-1) + if _log_file: + _log_file.close() sys.exit(0) signal.signal(signal.SIGINT, _shutdown) signal.signal(signal.SIGTERM, _shutdown) - print(f"VIDEO CYCLER — {CYCLE_S:.0f} s on / 0.5 s off (Ctrl+C to stop)\n") + if args.hold: + ts = datetime.now().strftime("%H:%M:%S") + print(f"VIDEO HOLD — video ON, no cycling (Ctrl+C to stop)") + print(f"[{ts}] video START\n", flush=True) + video_start(cycle=0) + elapsed = 0 + while True: + time.sleep(30.0) + elapsed += 30 + ts = datetime.now().strftime("%H:%M:%S") + print(f"[{ts}] still holding — {elapsed}s elapsed", flush=True) + + on_s, off_s = args.on_s, args.off_s + print(f"VIDEO CYCLER — {on_s:.1f}s on / {off_s:.1f}s off" + + (f", {args.cycles} cycles" if args.cycles else ", forever") + + " (Ctrl+C to stop)\n") cycle = 0 while True: cycle += 1 ts = datetime.now().strftime("%H:%M:%S") - print(f"[{ts}] cycle {cycle:04d} video ON", flush=True) - video_start() - time.sleep(CYCLE_S) - video_stop() - time.sleep(0.5) + print(f"[{ts}] cycle {cycle:04d} START", flush=True) + video_start(cycle=cycle) + time.sleep(on_s) + ts = datetime.now().strftime("%H:%M:%S") + print(f"[{ts}] cycle {cycle:04d} STOP", flush=True) + video_stop(cycle=cycle) + if args.cycles and cycle >= args.cycles: + print(f"\nReached {args.cycles} cycles, exiting.") + if _log_file: + _log_file.close() + return + time.sleep(off_s) if __name__ == "__main__":