From d73aa2f2a4024a7d28804fbe5c70a28f9edc7a03 Mon Sep 17 00:00:00 2001 From: david rice Date: Mon, 11 May 2026 16:14:19 +0100 Subject: [PATCH] Changes --- analyze_session.py | 183 +++++++++++++++ arrive-logotype-purple-RGB.png | Bin 0 -> 22543 bytes compare_stops.py | 321 +++++++++++++++++++++++++++ flicker_investigation_report.html | 354 ++++++++++++++++++++++++++++++ video_cycler.py | 116 ++++++++-- 5 files changed, 957 insertions(+), 17 deletions(-) create mode 100644 analyze_session.py create mode 100644 arrive-logotype-purple-RGB.png create mode 100644 compare_stops.py create mode 100644 flicker_investigation_report.html 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 0000000000000000000000000000000000000000..0aae2192d4b18e68ddc0c404c7c02261ca4f97d3 GIT binary patch literal 22543 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 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__":