diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1b502d3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(python *)", + "Bash(pip list *)", + "Read(//c/Users/DavidRice/AppData/Local/Programs/Python/Python312/Scripts/**)", + "Bash(py -3.11 -c \"import matplotlib; print\\(matplotlib.__version__\\)\")", + "Bash(py -3.10 -c \"import matplotlib; print\\(matplotlib.__version__\\)\")", + "Bash(pip install *)", + "Bash(\"C:/Users/DavidRice/AppData/Local/Programs/Python/Python312/python.exe\" -m pip install matplotlib)" + ] + } +} diff --git a/.gitignore b/.gitignore index c381bb5..a59fbbd 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/cycle.sh b/cycle.sh new file mode 100644 index 0000000..33cb8b8 --- /dev/null +++ b/cycle.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Blank/unblank cycle reproducer for the MIPI flicker investigation. +# Each iteration: blank the display, wait 1s, unblank, wait 3s. +# Watch the screen and count cycles that produced visible flicker. + +N=${1:-30} +for i in $(seq 1 $N); do + echo "--- cycle $i / $N ---" + echo 4 > /sys/class/graphics/fb0/blank + sleep 1 + echo 0 > /sys/class/graphics/fb0/blank + sleep 3 +done +echo "done." diff --git a/device-server.service b/device-server.service new file mode 100644 index 0000000..197da7e --- /dev/null +++ b/device-server.service @@ -0,0 +1,16 @@ +[Unit] +Description=MiPi device HTTP control server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/root/python +ExecStart=/usr/bin/python3 /root/python/device_server.py +Restart=on-failure +RestartSec=2 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/device_server.py b/device_server.py index b8d88b7..c1cef20 100644 --- a/device_server.py +++ b/device_server.py @@ -8,9 +8,11 @@ Provides: Add addresses to REGISTER_COMMANDS to capture more register ranges. """ +import mmap import os import re import socket +import struct import subprocess import threading import time @@ -22,7 +24,7 @@ app = Flask(__name__) # --------------------------------------------------------------------------- # Video playback state (managed as a subprocess) # --------------------------------------------------------------------------- -KIOSK_SCRIPT = "/root/display_test_nexio.py" +KIOSK_SCRIPT = "/root/python/display_test_nexio.py" _video_proc: subprocess.Popen | None = None _video_lock = threading.Lock() @@ -82,8 +84,8 @@ _SN65_SNAPSHOT_REGS: dict[int, str] = { 0x2A: "HFP", # CHA horizontal front porch 0x2C: "VFP", # CHA vertical front porch # Format / output - 0x2D: "TEST_PATTERN", # bit0 = enable colour bar test pattern - 0x3C: "LVDS_FORMAT", # LVDS output format (colour depth, channel swap) + 0x2D: "REG_0x2D", # unknown — was mislabeled "TEST_PATTERN" but isn't + 0x3C: "LVDS_FORMAT", # LVDS output format. bit 4 = CHA_TEST_PATTERN (write 0x10 to enable) # Live LVDS line counter — changes every frame when bridge is actively outputting 0xE0: "LINE_CNT_LOW", # CHA line count, low byte [live] 0xE1: "LINE_CNT_HIGH", # CHA line count, high byte [live] @@ -144,6 +146,13 @@ _DSIM_NAMES = { 0x32e1000c: "DSIM_TIMEOUT", 0x32e10010: "DSIM_CONFIG", 0x32e10014: "DSIM_ESCMODE", + 0x32e10018: "DSIM_MDRESOL", + 0x32e1001c: "DSIM_MVPORCH", + 0x32e10020: "DSIM_MHPORCH", + 0x32e10024: "DSIM_MSYNC", + 0x32e10028: "DSIM_SDRESOL", + 0x32e1002c: "DSIM_INTSRC", # interrupt source — bits latch on event, write-1-clear + 0x32e10030: "DSIM_INTMSK", # interrupt mask config 0x32e100ac: "DSIM_PHYACCHR", 0x32e100b0: "DSIM_PHYACCHR1", 0x32e100b4: "DSIM_PHYTIMING", @@ -280,6 +289,71 @@ def _i2c_read_byte(bus: int, addr: int, reg: int) -> tuple[int | None, str]: return None, str(e) +def _i2c_write_byte(bus: int, addr: int, reg: int, val: int) -> tuple[bool, str]: + """Write one byte via i2cset. Returns (ok, error_str).""" + try: + result = subprocess.run( + ["i2cset", "-y", "-f", str(bus), f"0x{addr:02x}", + f"0x{reg:02x}", f"0x{val:02x}"], + capture_output=True, text=True, timeout=3 + ) + if result.returncode == 0: + return True, "" + return False, result.stderr.strip() or f"exit code {result.returncode}" + except FileNotFoundError: + return False, "i2cset not found in PATH" + except Exception as e: + return False, str(e) + + +def _read_memtool_words(base_addr: int, n_words: int) -> list: + """Read n 32-bit words via 'memtool md -l'. Returns list of (addr, value).""" + try: + cmd = ["memtool", "md", "-l", f"0x{base_addr:08x}+0x{n_words*4:x}"] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=2) + if result.returncode != 0: + return [] + return [(int(r["address"], 16), int(r["value"], 16)) + for r in _parse_memtool_output(result.stdout)] + except Exception: + return [] + + +# DSIM register blocks worth watching. Two contiguous ranges → 2 memtool calls per snapshot. +# Block 1: status / config / timing / interrupts (0x004-0x030) +# STATUS, CLKCTRL, TIMEOUT, CONFIG, ESCMODE, MDRESOL, MVPORCH, MHPORCH, +# MSYNC, SDRESOL, INTSRC, INTMSK +# Block 2: PHY (0xAC-0xBC) +# PHYACCHR, PHYACCHR1, PHYTIMING, PHYTIMING1, PHYTIMING2 +_DSIM_SNAPSHOT_BLOCKS = [ + (0x32e10004, 12), + (0x32e100ac, 5), +] + + +def _dsim_snapshot() -> dict: + """Read DSIM status/config/PHY registers via memtool. + Returns {address_hex: {name, value}} or value=None on read failure.""" + snapshot = {} + for base, n in _DSIM_SNAPSHOT_BLOCKS: + words = _read_memtool_words(base, n) + # If read failed entirely, log Nones for each expected address so a diff still surfaces + if not words: + for i in range(n): + addr = base + i * 4 + snapshot[f"0x{addr:08x}"] = { + "name": _DSIM_NAMES.get(addr, ""), + "value": None, + } + continue + for addr, val in words: + snapshot[f"0x{addr:08x}"] = { + "name": _DSIM_NAMES.get(addr, ""), + "value": f"0x{val:08x}", + } + return snapshot + + @app.route("/sn65_settling", methods=["GET"]) def get_sn65_settling(): """Return the most recent post-restart settling poll. @@ -356,6 +430,385 @@ def get_sn65_registers(): }), 200 +# --------------------------------------------------------------------------- +# High-rate PLL monitor — runs on-device so we sample at ~10 ms instead of +# the ~55 ms HTTP-polling could achieve. Logs only transitions (unlock / +# recovered) so the event log stays small. +# --------------------------------------------------------------------------- +PLL_MONITOR_DEFAULT_MS = 10 +PLL_MONITOR_MAX_EVENTS = 10000 + +_pll_monitor_thread: threading.Thread | None = None +_pll_wide_thread: threading.Thread | None = None +_dsim_fast_thread: threading.Thread | None = None +_pll_monitor_stop: threading.Event = threading.Event() +_pll_monitor_lock: threading.Lock = threading.Lock() +_pll_monitor_events: list = [] +_pll_monitor_stats: dict = { + "running": False, "interval_ms": 0, "wide_interval_ms": 0, + "fast_dsim_interval_ms": 0, "fast_dsim_polls": 0, "fast_dsim_error": None, + "polls": 0, "errors": 0, "wide_polls": 0, "started_at": None, +} + +# --------------------------------------------------------------------------- +# Fast DSIM register poller via /dev/mem mmap. Bypasses the memtool subprocess +# overhead so we can poll at sub-millisecond resolution and catch transient +# register changes that the 500 ms wide loop would miss. +# --------------------------------------------------------------------------- +DSIM_BASE = 0x32E10000 +DSIM_PAGE_SIZE = 0x1000 # one 4 KB page covers offsets 0x000-0xFFF + +# Register offsets within the DSIM page that we want to watch. +_DSIM_FAST_REGS = { + 0x004: "DSIM_STATUS", # flutters with frame counter — excluded from diff + 0x008: "DSIM_CLKCTRL", + 0x00c: "DSIM_TIMEOUT", + 0x010: "DSIM_CONFIG", + 0x014: "DSIM_ESCMODE", + 0x018: "DSIM_MDRESOL", + 0x01c: "DSIM_MVPORCH", + 0x020: "DSIM_MHPORCH", + 0x024: "DSIM_MSYNC", + 0x028: "DSIM_SDRESOL", + 0x02c: "DSIM_INTSRC", + 0x030: "DSIM_INTMSK", + 0x0ac: "DSIM_PHYACCHR", + 0x0b0: "DSIM_PHYACCHR1", + 0x0b4: "DSIM_PHYTIMING", + 0x0b8: "DSIM_PHYTIMING1", + 0x0bc: "DSIM_PHYTIMING2", +} + +# DSIM_STATUS flutters every frame so a naive diff drowns out everything else. +# Exclude it from the diff. (Reading it still happens — the value is captured +# in the event payload for any iteration that flagged a change in another reg.) +_DSIM_FAST_SKIP = {0x004} + +# Registers that change every frame — exclude from diff so we don't drown in noise. +# SN65 uses short hex (e.g. "0xe0"); DSIM uses long hex (e.g. "0x32e10004") so no collisions. +_PLL_WIDE_LIVE_REGS = { + "0xe0", "0xe1", # SN65 LINE_CNT_LOW / LINE_CNT_HIGH + # DSIM_STATUS bits 0-3 are FRAME_DONE/BUSY/TX_READY-style flags that fluctuate + # every frame, so the whole register is treated as live by default. Comment out + # if you want to see all its changes (and accept the noise). + "0x32e10004", # DSIM_STATUS +} + + +def _classify_sn65(val_0a: int, val_e5: int) -> tuple[bool, list]: + """Returns (any_error, flag_list). pll_lock=False is included as a flag.""" + pll_locked = bool(val_0a & 0x80) + flags = [] + if not pll_locked: flags.append("pll_lock_false") + if val_e5 & 0x01: flags.append("pll_unlock") + if val_e5 & 0x04: flags.append("cha_sot_bit_err") + if val_e5 & 0x08: flags.append("cha_llp_err") + if val_e5 & 0x10: flags.append("cha_ecc_err") + if val_e5 & 0x20: flags.append("cha_lp_err") + if val_e5 & 0x40: flags.append("cha_crc_err") + return bool(flags), flags + + +def _pll_monitor_loop(interval_ms: int) -> None: + interval_s = interval_ms / 1000.0 + last_bad = False + + with _pll_monitor_lock: + _pll_monitor_stats.update(running=True, interval_ms=interval_ms, + polls=0, errors=0, started_at=time.time()) + _pll_monitor_events.clear() + + while not _pll_monitor_stop.is_set(): + t0 = time.time() + + val_0a, _ = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x0A) + val_e5, _ = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0xE5) + + with _pll_monitor_lock: + _pll_monitor_stats["polls"] += 1 + if val_0a is None or val_e5 is None: + _pll_monitor_stats["errors"] += 1 + else: + bad, flags = _classify_sn65(val_0a, val_e5) + if bad and not last_bad: + _pll_monitor_events.append({ + "t": t0, + "type": "unlock", + "csr_0a": f"0x{val_0a:02x}", + "csr_e5": f"0x{val_e5:02x}", + "flags": flags, + }) + elif (not bad) and last_bad: + _pll_monitor_events.append({ + "t": t0, + "type": "recovered", + "csr_0a": f"0x{val_0a:02x}", + "csr_e5": f"0x{val_e5:02x}", + }) + last_bad = bad + + if len(_pll_monitor_events) > PLL_MONITOR_MAX_EVENTS: + del _pll_monitor_events[:len(_pll_monitor_events) - PLL_MONITOR_MAX_EVENTS] + + elapsed = time.time() - t0 + if elapsed < interval_s: + time.sleep(interval_s - elapsed) + + with _pll_monitor_lock: + _pll_monitor_stats["running"] = False + + +def _pll_wide_loop(interval_ms: int) -> None: + """Background thread: snapshot all SN65 config/status registers AND the + DSIM status/PHY register block at interval_ms cadence, log a single + 'register_change' event per poll containing any diffs vs previous snapshot. + Excludes registers in _PLL_WIDE_LIVE_REGS from the diff.""" + interval_s = interval_ms / 1000.0 + + def _combined_snapshot() -> dict: + snap = _sn65_snapshot() + snap.update(_dsim_snapshot()) + return snap + + prev = _combined_snapshot() # baseline + + with _pll_monitor_lock: + _pll_monitor_stats["wide_interval_ms"] = interval_ms + _pll_monitor_stats["wide_polls"] = 0 + + while not _pll_monitor_stop.is_set(): + t0 = time.time() + cur = _combined_snapshot() + + changes = {} + for reg, info in cur.items(): + if reg in _PLL_WIDE_LIVE_REGS: + continue + prev_info = prev.get(reg, {}) + if info.get("value") != prev_info.get("value"): + changes[reg] = { + "name": info.get("name"), + "from": prev_info.get("value"), + "to": info.get("value"), + } + + with _pll_monitor_lock: + _pll_monitor_stats["wide_polls"] += 1 + if changes: + _pll_monitor_events.append({ + "t": t0, + "type": "register_change", + "changes": changes, + }) + if len(_pll_monitor_events) > PLL_MONITOR_MAX_EVENTS: + del _pll_monitor_events[:len(_pll_monitor_events) - PLL_MONITOR_MAX_EVENTS] + + prev = cur + + elapsed = time.time() - t0 + if elapsed < interval_s: + time.sleep(interval_s - elapsed) + + +def _dsim_fast_loop(interval_ms: int) -> None: + """High-rate DSIM register poller using /dev/mem mmap. + Reads ONLY DSIM_CLKCTRL each iteration (the register known to flip + sub-millisecond). Other registers are left to the 500 ms wide loop — + this keeps the fast loop's CPU cost low enough not to starve Flask. + Logs every CLKCTRL transition as a 'dsim_fast_change' event.""" + interval_s = max(interval_ms, 1) / 1000.0 + try: + fd = os.open("/dev/mem", os.O_RDONLY | os.O_SYNC) + except OSError as e: + with _pll_monitor_lock: + _pll_monitor_stats["fast_dsim_error"] = f"open /dev/mem failed: {e}" + return + try: + mm = mmap.mmap(fd, DSIM_PAGE_SIZE, mmap.MAP_SHARED, mmap.PROT_READ, + offset=DSIM_BASE) + except (OSError, ValueError) as e: + os.close(fd) + with _pll_monitor_lock: + _pll_monitor_stats["fast_dsim_error"] = f"mmap DSIM page failed: {e}" + return + + CLKCTRL_OFFSET = 0x008 + CLKCTRL_ADDR = f"0x{DSIM_BASE + CLKCTRL_OFFSET:08x}" + + prev = struct.unpack_from(" PLL_MONITOR_MAX_EVENTS: + del _pll_monitor_events[:len(_pll_monitor_events) - PLL_MONITOR_MAX_EVENTS] + prev = cur + + # Push polls counter to stats periodically + if polls % 1000 == 0: + with _pll_monitor_lock: + _pll_monitor_stats["fast_dsim_polls"] = polls + + elapsed = time.time() - t0 + if elapsed < interval_s: + time.sleep(interval_s - elapsed) + except Exception as e: + # Don't die silently; surface the error as an event and keep polling + with _pll_monitor_lock: + _pll_monitor_stats["fast_dsim_error"] = f"iter {polls}: {type(e).__name__}: {e}" + _pll_monitor_events.append({ + "t": time.time(), + "type": "dsim_fast_error", + "error": str(e), + }) + time.sleep(0.1) # back off briefly so we don't spin on a persistent error + finally: + with _pll_monitor_lock: + _pll_monitor_stats["fast_dsim_polls"] = polls + mm.close() + os.close(fd) + + +@app.route("/pll_monitor", methods=["PUT"]) +def control_pll_monitor(): + """Start, stop, or clear the device-side PLL event monitor. + + PUT /pll_monitor {"action":"start","interval_ms":10,"wide_interval_ms":500} + PUT /pll_monitor {"action":"stop"} + PUT /pll_monitor {"action":"clear"} + + wide_interval_ms (optional, default 0 = disabled): if > 0, also runs a + second thread that snapshots all SN65 config/status registers at that + cadence and logs 'register_change' events on any non-trivial diff. + """ + global _pll_monitor_thread, _pll_wide_thread, _dsim_fast_thread + data = request.get_json(force=True) or {} + action = data.get("action", "").lower() + + if action == "start": + if _pll_monitor_thread is not None and _pll_monitor_thread.is_alive(): + return jsonify({"status": "already_running", "device_now": time.time()}), 200 + interval_ms = max(5, int(data.get("interval_ms", PLL_MONITOR_DEFAULT_MS))) + wide_interval_ms = max(0, int(data.get("wide_interval_ms", 0))) + fast_dsim_interval_ms = max(0, int(data.get("fast_dsim_interval_ms", 0))) + _pll_monitor_stop.clear() + _pll_monitor_thread = threading.Thread( + target=_pll_monitor_loop, args=(interval_ms,), daemon=True + ) + _pll_monitor_thread.start() + if wide_interval_ms > 0: + _pll_wide_thread = threading.Thread( + target=_pll_wide_loop, args=(wide_interval_ms,), daemon=True + ) + _pll_wide_thread.start() + else: + _pll_wide_thread = None + if fast_dsim_interval_ms > 0: + _dsim_fast_thread = threading.Thread( + target=_dsim_fast_loop, args=(fast_dsim_interval_ms,), daemon=True + ) + _dsim_fast_thread.start() + else: + _dsim_fast_thread = None + return jsonify({"status": "started", "interval_ms": interval_ms, + "wide_interval_ms": wide_interval_ms, + "fast_dsim_interval_ms": fast_dsim_interval_ms, + "device_now": time.time()}), 200 + + elif action == "stop": + _pll_monitor_stop.set() + if _pll_monitor_thread is not None: + _pll_monitor_thread.join(timeout=2) + if _pll_wide_thread is not None: + _pll_wide_thread.join(timeout=2) + if _dsim_fast_thread is not None: + _dsim_fast_thread.join(timeout=2) + return jsonify({"status": "stopped"}), 200 + + elif action == "clear": + with _pll_monitor_lock: + _pll_monitor_events.clear() + return jsonify({"status": "cleared"}), 200 + + else: + return jsonify({"error": "Invalid action. Use 'start', 'stop', or 'clear'"}), 400 + + +@app.route("/pll_monitor/events", methods=["GET"]) +def get_pll_monitor_events(): + """Return events with t > since (epoch seconds float), plus stats + device_now.""" + since_raw = request.args.get("since", default="0") + try: + since_f = float(since_raw) + except ValueError: + since_f = 0.0 + with _pll_monitor_lock: + events = [e for e in _pll_monitor_events if e["t"] > since_f] + stats = dict(_pll_monitor_stats) + return jsonify({"events": events, "stats": stats, "device_now": time.time()}), 200 + + +@app.route("/sn65_testpattern", methods=["PUT"]) +def control_testpattern(): + """Enable/disable the SN65DSI83 internal LVDS test pattern (CSR 0x2D bit 0). + + PUT /sn65_testpattern {"state":"on"|"off"} + + Test pattern is generated inside the SN65 at the LVDS output stage, downstream + of the MIPI input and conversion logic. Used to bisect flicker root cause: + if test pattern is clean while MIPI input flickers, fault is upstream of LVDS. + """ + data = request.get_json(force=True) or {} + state = data.get("state", "").lower() + if state not in ("on", "off"): + return jsonify({"error": "Invalid state. Use 'on' or 'off'"}), 400 + + # Known-working sequence (user-confirmed): + # i2cset -y -f 4 0x2c 0x3c 0x10 # enable + # i2cset -y -f 4 0x2c 0x3c 0x00 # disable + # Test pattern is bit 4 of CSR 0x3C (LVDS_FORMAT). Write whole byte to match. + current, err = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x3C) + if current is None: + return jsonify({"error": f"read 0x3C failed: {err}"}), 500 + + new = 0x10 if state == "on" else 0x00 + ok, werr = _i2c_write_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x3C, new) + if not ok: + return jsonify({"error": f"write 0x3C failed: {werr}"}), 500 + + verify, _ = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x3C) + return jsonify({ + "status": "ok", + "state": state, + "register": "0x3C", + "before": f"0x{current:02x}", + "after": f"0x{verify:02x}" if verify is not None else None, + }), 200 + + @app.route("/video", methods=["PUT"]) def control_video(): """Start or stop the kiosk video player. @@ -375,6 +828,9 @@ def control_video(): mode = data.get("mode", "") if mode == "static-pink": cmd.append("--static-pink") + video = data.get("video") + if video: + cmd.extend(["--start", video]) _kiosk_args[:] = cmd # persist so control_display knows the mode log = open("/tmp/kiosk.log", "w") _video_proc = subprocess.Popen( diff --git a/display_test_nexio.py b/display_test_nexio.py index 939f757..80220e0 100644 --- a/display_test_nexio.py +++ b/display_test_nexio.py @@ -1,18 +1,21 @@ +import argparse import gi +import os import socket import struct -import threading -import os +import sys gi.require_version('Gst', '1.0') from gi.repository import Gst, GLib +SWITCH_UDP_PORT = 5001 + class KioskManager: def __init__(self, pipeline): self.pipeline = pipeline self.videos = [ - "file:///root/vid.mp4", - "file:///root/vid2.mp4" + "file:///root/python/vid.mp4", + "file:///root/python/vid2.mp4" ] self.current_video_index = 0 @@ -117,7 +120,32 @@ def handle_button(source, condition, manager): return True -def play_kiosk(): + +def handle_udp_switch(sock, condition, manager): + """Receives 'switch' datagrams from device_server.py and cycles the video.""" + try: + data, _ = sock.recvfrom(64) + except BlockingIOError: + return True + if data.strip() == b"switch": + print("UDP Trigger: switch") + manager.switch_video() + return True + + +def _resolve_start_index(start_name: str, videos: list) -> int: + """Map a basename like 'vid.mp4' to its index in the videos list.""" + target = os.path.basename(start_name).lower() + for i, uri in enumerate(videos): + if os.path.basename(uri).lower() == target: + return i + raise SystemExit( + f"--start {start_name!r} not in kiosk video list: " + + ", ".join(os.path.basename(u) for u in videos) + ) + + +def play_kiosk(start_index: int = 0): Gst.init(None) pipeline = Gst.ElementFactory.make("playbin", "player") @@ -129,17 +157,19 @@ def play_kiosk(): pipeline.set_property("audio-sink", Gst.ElementFactory.make("fakesink")) manager = KioskManager(pipeline) - pipeline.set_property("uri", manager.videos[0]) + manager.current_video_index = start_index + pipeline.set_property("uri", manager.videos[start_index]) + print(f"Starting on: {manager.videos[start_index]}") - # UDP trigger → switch video (device_server sends a packet to port 5001) - def _udp_listener(): - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.bind(('127.0.0.1', 5001)) - while True: - sock.recv(64) - GLib.idle_add(manager.switch_video) - - threading.Thread(target=_udp_listener, daemon=True).start() + # --- UDP SWITCH LISTENER --- + # device_server.py sends b'switch' to 127.0.0.1:5001 to cycle videos remotely. + try: + udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + udp_sock.setblocking(False) + udp_sock.bind(("127.0.0.1", SWITCH_UDP_PORT)) + GLib.io_add_watch(udp_sock, GLib.IO_IN, handle_udp_switch, manager) + except Exception as e: + print(f"UDP Listener Error: {e}") # --- INPUT MONITORING --- try: @@ -159,16 +189,13 @@ def play_kiosk(): def on_message(bus, msg, manager_instance): if msg.type == Gst.MessageType.EOS: + # Video ended. Cycle LED and advance to the next video in the list. manager_instance.change_led_colour() - pipeline.seek_simple(Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, 0) + manager_instance.switch_video() elif msg.type == Gst.MessageType.ERROR: err, debug = msg.parse_error() - print(f"GStreamer Error: {err}\nDebug: {debug}", flush=True) - loop.quit() - elif msg.type == Gst.MessageType.STATE_CHANGED: - if msg.src == pipeline: - old, new, _ = msg.parse_state_changed() - print(f"Pipeline: {old.value_nick} -> {new.value_nick}", flush=True) + print(f"GStreamer Error: {err}") + loop.quit bus.connect("message", on_message, manager) @@ -180,73 +207,18 @@ def play_kiosk(): except KeyboardInterrupt: pipeline.set_state(Gst.State.NULL) -def play_static_color(r: int, g: int, b: int): - """Display a solid colour using GStreamer videotestsrc (no video file required). - - Uses videotestsrc pattern=solid-color so every DSI line carries the same - repeating RGB triplet — any deviation in the proto_decoder output is a DSI fault. - - Listens on UDP port 5001 for a trigger packet (same as play_kiosk), which - briefly cycles the pipeline through READY→PLAYING to generate the LP→HS - startup sequence that the scope captures on Pass 1. - """ - Gst.init(None) - - argb = (0xFF << 24) | (r << 16) | (g << 8) | b - - SINK_STR = ("videoconvert ! video/x-raw,format=BGRx ! " - "kmssink driver-name=mxsfb-drm connector-id=37 plane-id=31 can-scale=false") - pipeline_str = ( - f"videotestsrc pattern=solid-color foreground-color={argb} ! " - f"video/x-raw,width=1280,height=800,framerate=60/1 ! " - f"{SINK_STR}" - ) - - pipeline = Gst.parse_launch(pipeline_str) - bus = pipeline.get_bus() - bus.add_signal_watch() - - loop = GLib.MainLoop() - - def on_message(bus, msg): - if msg.type == Gst.MessageType.ERROR: - err, debug = msg.parse_error() - print(f"GStreamer Error: {err}\nDebug: {debug}", flush=True) - loop.quit() - elif msg.type == Gst.MessageType.STATE_CHANGED: - if msg.src == pipeline: - old, new, _ = msg.parse_state_changed() - print(f"Pipeline: {old.value_nick} -> {new.value_nick}", flush=True) - - def _restart_pipeline(): - """Cycle READY→PLAYING to produce the LP→HS startup the scope triggers on.""" - print("UDP trigger: restarting pipeline (READY → PLAYING)", flush=True) - pipeline.set_state(Gst.State.READY) - pipeline.set_state(Gst.State.PLAYING) - return False # GLib.idle_add one-shot - - def _udp_listener(): - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.bind(('127.0.0.1', 5001)) - while True: - sock.recv(64) - GLib.idle_add(_restart_pipeline) - - threading.Thread(target=_udp_listener, daemon=True).start() - - bus.connect("message", on_message) - pipeline.set_state(Gst.State.PLAYING) - print(f"Static colour R:{r} G:{g} B:{b} (0x{argb:08X}) — running", flush=True) - - try: - loop.run() - except KeyboardInterrupt: - pipeline.set_state(Gst.State.NULL) - - if __name__ == "__main__": - import sys - if "--static-pink" in sys.argv: - play_static_color(255, 51, 187) # R:255 G:51 B:187 - else: - play_kiosk() + p = argparse.ArgumentParser(description="Kiosk video player") + p.add_argument("--start", default="vid.mp4", + help="Initial video filename (basename match against kiosk list)") + # parse_known_args so legacy flags like --static-pink don't crash the kiosk + args, _unknown = p.parse_known_args() + + # We need the video list to resolve --start, so we recreate it here (must + # stay in sync with KioskManager.videos). + _videos = [ + "file:///root/python/vid.mp4", + "file:///root/python/vid2.mp4", + ] + start_index = _resolve_start_index(args.start, _videos) + play_kiosk(start_index=start_index) diff --git a/embed_frames.py b/embed_frames.py new file mode 100644 index 0000000..3ed22eb --- /dev/null +++ b/embed_frames.py @@ -0,0 +1,87 @@ +"""Resize the flicker frame PNGs to embeddable JPGs and inject into the HTML report. + +Reads flicker_investigation_handover.html, replaces the static "ul.tight" frame +list in Section 1 with an inline gallery of base64-embedded JPGs, and writes +back the same file. +""" +import base64 +import sys +from pathlib import Path + +from PIL import Image + +REPO = Path(__file__).parent +PICS = REPO / "data" / "pics" +HTML = REPO / "flicker_investigation_continued.html" +TMP = REPO / ".embed_tmp" + +# (filename, caption) +FRAMES = [ + ("frame0362.png", "Frame 362 — baseline. Clean image."), + ("frame0363.png", "Frame 363 — flicker. Vertical displacement; content recognisable but geometry wrong."), + ("frame0370.png", "Frame 370 — flicker. Same vertical shift pattern."), + ("frame0376.png", "Frame 376 — flicker. Lower-amplitude shift."), + ("frame0381.png", "Frame 381 — flicker (most dramatic). Multi-band tearing; multiple partial frames stacked vertically."), + ("frame0382.png", "Frame 382 — flicker. Frame after the dramatic shift; partial recovery still showing artifacts."), +] + + +def resize_to_jpg(src: Path, dst: Path, width: int = 800, quality: int = 75) -> None: + """Resize src to width px and write as JPG at given quality, via Pillow.""" + img = Image.open(src) + if img.mode != "RGB": + img = img.convert("RGB") + w, h = img.size + if w > width: + new_h = int(h * width / w) + img = img.resize((width, new_h), Image.LANCZOS) + img.save(dst, "JPEG", quality=quality, optimize=True) + + +def main() -> int: + if not HTML.exists(): + print(f"missing {HTML}", file=sys.stderr); return 1 + + TMP.mkdir(exist_ok=True) + blocks = [] + for fname, caption in FRAMES: + src = PICS / fname + if not src.exists(): + print(f"skip (missing): {src}"); continue + dst = TMP / (src.stem + ".jpg") + resize_to_jpg(src, dst) + b64 = base64.b64encode(dst.read_bytes()).decode("ascii") + kb = len(b64) * 3 // 4 // 1024 + blocks.append( + f'
\n' + f' {fname}\n' + f'
{caption}
\n' + f'
' + ) + print(f"embedded {fname} -> {kb} KB") + + if not blocks: + print("no frames embedded; HTML unchanged"); return 1 + + gallery = ( + '" + ) + + html = HTML.read_text(encoding="utf-8") + + placeholder = "" + if placeholder not in html: + print(f"WARNING: placeholder {placeholder!r} not found in HTML; " + "no embedding done", file=sys.stderr) + return 1 + new_html = html.replace(placeholder, gallery, 1) + + HTML.write_text(new_html, encoding="utf-8") + print(f"wrote {HTML} ({len(new_html)//1024} KB)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/flicker_investigation_continued.html b/flicker_investigation_continued.html new file mode 100644 index 0000000..e56dc19 --- /dev/null +++ b/flicker_investigation_continued.html @@ -0,0 +1,399 @@ + + + + +Flicker Investigation Continued — MIPI DSI Screen-Shift + + + + + + + +
+ +

Flicker Investigation Continued

+
+Author: David Rice (Electronics)  ·  +Hawk unit at 10.32.33.157  ·  Kernel 6.6.44-pknbsp-svn9520 +
+ +

1. Visual evidence — the artifact is "screen shift"

+ +

Captured slow-motion phone video of the screen during a flicker event and +pulled individual frames out. Frame 362 is a clean baseline. Frames 363, 370, +376, 381, 382 are flickers. The consistent signature:

+ + + +

To my eye this is the signature of a frame-tearing / VSYNC-alignment slip. +Not a data integrity problem — a timing-alignment problem.

+ +

Reference frames (slow-motion phone capture, resized for inclusion; +I have the original full-size PNGs if you want them):

+ + + +

2. Test-pattern bisection rules out the LVDS output stage and panel

+ +

While a burst of flicker was actively occurring on the MIPI input path, I +enabled the SN65DSI83's internal LVDS test pattern (write 0x10 +to CSR 0x3C, i.e. bit 4 of LVDS_FORMAT — confirmed via the known +i2cset sequence). With no other change:

+ + + + + + + + + + + +
SN65 sourceObserved
MIPI input (normal)Flicker happening as before
Internal LVDS test pattern enabledClean colour-bar pattern, no flicker, for as long as enabled
Back to MIPI inputFlicker resumes
+ +

The internal test pattern is generated inside the SN65 at the LVDS +output stage, downstream of the MIPI input and the MIPI-to-LVDS conversion. +The fact that it is clean while the MIPI-fed image flickers means everything +from the SN65 LVDS PLL through the panel is physically healthy. The fault +must therefore lie upstream of that point — somewhere in the i.MX DSIM +controller output, the MIPI bus, the SN65's MIPI receiver, or the SN65's +internal MIPI-to-LVDS conversion logic.

+ +

3. On-target instrumentation I built today

+ +

To complement the visual capture I wrote a small Flask HTTP service that +runs on the Hawk and polls register state at high rate, plus a host-side +Python tool that drives it and records events. This is not +production-quality software — it's a measurement harness, and Claude +helped me write the Python.

+ + + + + + + + + + + + + +
FileRole
device_server.pyRuns on Hawk as a systemd service. Polls SN65 registers + (csr_0a, csr_e5) every 10 ms via I²C, and a + block of DSIM registers (STATUS, CLKCTRL, CONFIG, ESCMODE, MDRESOL, + MVPORCH, MHPORCH, MSYNC, SDRESOL, INTSRC, INTMSK, PHYTIMING0/1/2) + every 500 ms via memtool. Logs transitions. Exposes endpoints to + start/stop monitoring and toggle the SN65 test pattern.
trigger.pyHost-side. Talks to the on-target service over HTTP, prints alerts as + events arrive, lets the operator press f to mark a visible + flicker observation, correlates marks against switch/unlock timing, and + writes a full session log to file.
display_test_nexio.pyThe existing GStreamer kiosk, modified to support a UDP "switch video" + trigger and a --start CLI flag so the test rig can drive + switches deterministically.
cycle.shShell-side reproducer that loops echo 4 / echo 0 > + /sys/class/graphics/fb0/blank. Drives many pipeline + restart cycles in sequence so flicker rate can be eyeballed.
+ +

4. Captured a burst of 93 flickers with no register-level signal

+ +

In one session, with the monitor running and an operator (me) pressing +f on each visible flicker, I recorded a single burst of +93 visible flickers in ~29 s (~3 Hz, randomly spaced from +roughly 100 ms to 700 ms apart). All 93 marks fell within a single +~30-second window between two scheduled video switches.

+ +

Throughout the burst, the on-target poller reported:

+ + + +

So from the register-polling perspective the system looks healthy at the +sampling rates I used. I have the full session log if useful.

+ +

How confident am I that nothing happened?

+ +

Less confident than the above bullet list might suggest, and I want to be +honest about that. 500 ms is a long time compared to a single video frame +(40 ms at 25 Hz), so the wide DSIM-register snapshot could easily miss a +transient that completes within one poll interval. Specifically:

+ + + +

So the correct way to read section 4 is: at the resolutions I could poll, +nothing showed up. That is consistent with — but not proof of — the fault +being below register visibility (e.g. a sub-frame transient timing event).

+ +

5. Reproducer without GStreamer or video

+ +

To check whether GStreamer / userspace was contributing, I stopped both the +kiosk and the device-server service, leaving just the Linux text console on +screen. Then ran the cycle script:

+ +
echo 4 > /sys/class/graphics/fb0/blank
+echo 0 > /sys/class/graphics/fb0/blank
+sleep ...
+(repeat)
+ +

A random subset of the cycles produced the same vertical-shift artifact as +during video playback, at roughly the same hit rate. The flicker is also +visible on the U-Boot splash screen during boot, before Linux is up.

+ +

As I read this, the fault is reachable from both the U-Boot DSI bring-up +path and the Linux DRM/KMS pipeline-restart path on every cycle. GStreamer is +not required and not the cause.

+ +

6. dmesg signature on every pipeline restart

+ +

Every blank/unblank cycle produces this exact 3-line sequence in dmesg, +deterministically across many cycles:

+ +
mxsfb 32e00000.lcdif: [drm] magic pixel crtc=0 offset 0x3e7ffc
+sn65dsi83 4-002c: Using DSI clock for LVDS
+@MF@ sn65dsi83_get_dsi_range dsi_rate=216000000 mode_clock=72000000 min_rate=216000000 => range=0x2b
+ +

So the SN65 bridge driver is re-initialising on every cycle (recomputing +its DSI range and re-applying LVDS configuration). The visible flicker +variability appears to be in how this re-init lands relative to the +panel's own scan cycle — sometimes the timing aligns and the panel locks +cleanly; sometimes it does not. But that is software-side interpretation and +I am not confident in it.

+ +

At boot the dmesg also shows the line:

+ +
samsung-dsim 32e10000.dsi: @MF@ flb-flags=0x0
+ +

which led me to poke around in the source code, see section 7.

+ +

7. Something I noticed in the BSP source — may well be a red herring

+ +

This may turn out to be unrelated, but while poking around in the kernel +patches looking for anything that might be connected to what I am seeing, +I came across flb-hack-dsi-autoflush in +parkeon_linux_bsp/trunk/kernel/patches-6.6/ (dated 2025-05-16, +authored by Martin Fuzzey). The commit message reads:

+ +
+"dsi: allow forcing of FIFO flush on vsync from DT — Hack for screen shift +problem to let electronics team test while I'm on holiday..." +
+ +

The phrase "screen shift" jumped out because it is exactly the words I +would use to describe the artifact I am seeing. From my limited reading of +the patch source, it looks like it adds two device-tree flags on the Samsung +DSIM driver:

+ + + + + + + + + +
BitFlagWhat I think it does
0FLB_FLAG_AUTO_FLUSHClears DSIM_MFLUSH_VS, switching from manual FIFO flush + to auto flush on VSYNC
1FLB_FLAG_NO_BURSTClears DSIM_BURST_MODE
+ +

The patch also adds dev_info(dev, "@MF@ flb-flags=0x%x\n", +dsi->flb_flags); in samsung_dsim_parse_dt(), which I +believe is where the @MF@ flb-flags=0x0 line in my boot dmesg +comes from. If that mapping is right, then the patch is in the running +kernel, but flb_flags == 0 at runtime, and the behavioural +changes the patch adds are wrapped in two if blocks that both +evaluate to false:

+ +
if (dsi->flb_flags & FLB_FLAG_AUTO_FLUSH)
+    reg &= ~DSIM_MFLUSH_VS;
+if (dsi->flb_flags & FLB_FLAG_NO_BURST)
+    reg &= ~DSIM_BURST_MODE;
+ +

So, again from my limited understanding: it looks like this patch may have +been written as a fix associated with the same kind of artifact I am +investigating, and on the unit I have it is compiled in but not actually doing +anything because no flb-flags property has been set in the device +tree. If that interpretation is correct, the fix would be to add +flb-flags = <1>; to the DSIM device-tree node (which I +think lives in the flb-dt-hawk patch, but I have not opened it +to confirm).

+ +

I am writing this section mainly to check on its validity — or at the very +least to try to understand it. I am not making a claim that this is the fix. +Specifically I do not know:

+ + + +

If you can tell me whether this is a real lead or a red herring, that +alone would be very useful. And if it is a real lead, a one-line device-tree +change would let me test it against the blank/unblank reproducer immediately +— I would expect dmesg to then show flb-flags=0x1 and the +visible flicker rate to drop sharply if the hypothesis is right.

+ +

8. What I have on my side, if any of it is useful

+ +

All of this lives on my workstation; happy to share any of it on request:

+ + +
+Written 2026-05-26 to summarise the electronics-side investigation done +in a single day. Hardware observations and on-target test results are +solid; the patch interpretation in §7 is speculative and may be wrong. +
+ +
+ + diff --git a/trigger.py b/trigger.py new file mode 100644 index 0000000..6735210 --- /dev/null +++ b/trigger.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +"""Manual test triggers for device_server.py running on the remote unit. + +Usage: + python trigger.py start # start kiosk on default video (vid.mp4) + python trigger.py start --video vid2.mp4 # start kiosk on vid2.mp4 + python trigger.py switch # cycle to the other video + python trigger.py loop --interval 30 # fire 'switch' every 30s until Ctrl-C + python trigger.py monitor # device-side 10 ms polling, alert on unlock + python trigger.py monitor --device-poll-ms 5 # tighter (5 ms) device polling + python trigger.py monitor --switch-every 5 # also fire switch every 5s, mask post-switch unlocks + python trigger.py monitor --switch-every 5 --mask 0.5 # custom mask window (default 0.5s) + python trigger.py monitor --switch-every 5 --wide-ms 500 # also watch full SN65 + DSIM register set + python trigger.py monitor --switch-every 5 --wide-ms 500 --fast-dsim-ms 1 # add 1 ms /dev/mem DSIM poll + python trigger.py monitor --switch-every 5 --wide-ms 500 --log # auto-named log in data/ + python trigger.py monitor --switch-every 5 --wide-ms 500 --log mysession.log # custom path + python trigger.py testpattern-on # enable SN65 internal LVDS test pattern + python trigger.py testpattern-off # back to MIPI input + # While monitor is running, press 'f' to mark a visible flicker observation. + # On Ctrl-C, a correlation table reports whether each mark matched an unlock or not. + python trigger.py start-pink # start kiosk in static-pink mode + python trigger.py stop # stop kiosk + python trigger.py registers # GET /registers + python trigger.py sn65 # GET /sn65_registers + python trigger.py settling # GET /sn65_settling + +Override host/port: + python trigger.py switch --host 10.32.33.96 --port 5000 +""" + +import argparse +import json +import sys +import time +import urllib.error +import urllib.request + +# Non-blocking single-key input. Windows-only (msvcrt). Falls back gracefully. +try: + import msvcrt + _HAS_KBHIT = True +except ImportError: + _HAS_KBHIT = False + + +def _fmt_ts(t: float) -> str: + return time.strftime("%H:%M:%S", time.localtime(t)) + f".{int((t % 1) * 1000):03d}" + + +class _Tee: + """Minimal stdout-tee: forwards writes to multiple streams. Used for --log.""" + def __init__(self, *streams): + self.streams = streams + def write(self, data): + for s in self.streams: + s.write(data) + # Flush file streams so the log is always current if the user tails it. + for s in self.streams[1:]: + s.flush() + def flush(self): + for s in self.streams: + s.flush() + + +ACTIONS = { + "switch": ("PUT", "/display", {"state": "on"}), + "start": ("PUT", "/video", {"action": "start"}), + "start-pink": ("PUT", "/video", {"action": "start", "mode": "static-pink"}), + "stop": ("PUT", "/video", {"action": "stop"}), + "registers": ("GET", "/registers", None), + "sn65": ("GET", "/sn65_registers", None), + "settling": ("GET", "/sn65_settling", None), + "testpattern-on": ("PUT", "/sn65_testpattern", {"state": "on"}), + "testpattern-off": ("PUT", "/sn65_testpattern", {"state": "off"}), + "loop": None, # handled specially in main() + "monitor": None, # handled specially in main() +} + + +def _put_json(url: str, payload, timeout: float) -> dict: + """PUT JSON, return parsed JSON response.""" + data = json.dumps(payload).encode() + headers = {"Content-Type": "application/json"} + req = urllib.request.Request(url, data=data, headers=headers, method="PUT") + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + + +def _fmt_event_summary(ev: dict) -> str: + if ev["type"] == "recovered": + return f"recovered (csr_0a={ev['csr_0a']}, csr_e5={ev['csr_e5']})" + if ev["type"] in ("register_change", "dsim_fast_change"): + parts = [f"{r}({info.get('name')}) {info.get('from')}->{info.get('to')}" + for r, info in (ev.get("changes") or {}).items()] + return "; ".join(parts) + flags = ",".join(ev.get("flags") or []) + return f"csr_0a={ev['csr_0a']} csr_e5={ev['csr_e5']} {flags}" + + +def _run_monitor(host: str, port: int, device_poll_ms: int, fetch_s: float, timeout: float, + switch_every: float | None = None, mask_s: float = 0.5, + wide_ms: int = 0, fast_dsim_ms: int = 0) -> int: + """Device-side PLL monitor. Starts a background poll thread on the device at + device_poll_ms cadence, then fetches new events from the host every fetch_s + seconds and prints alerts. Suppresses unlocks within mask_s of any switch. + """ + base = f"http://{host}:{port}" + mon_url = f"{base}/pll_monitor" + events_url = f"{base}/pll_monitor/events" + switch_url = f"{base}/display" + switch_payload = {"state": "on"} + + # Start the device-side monitor + start_payload = {"action": "start", "interval_ms": device_poll_ms} + if wide_ms > 0: + start_payload["wide_interval_ms"] = wide_ms + if fast_dsim_ms > 0: + start_payload["fast_dsim_interval_ms"] = fast_dsim_ms + try: + resp = _put_json(mon_url, start_payload, timeout) + except urllib.error.HTTPError as e: + if e.code == 404: + print("Device doesn't have /pll_monitor — scp the updated device_server.py and restart device-server.") + return 1 + raise + + host_start = time.time() + device_now = float(resp.get("device_now", host_start)) + # offset to convert device time → host time + dev_to_host = host_start - device_now + actual_ms = int(resp.get("interval_ms", device_poll_ms)) + actual_wide = int(resp.get("wide_interval_ms", 0)) + actual_fast_dsim = int(resp.get("fast_dsim_interval_ms", 0)) + + print(f"Device monitor running at {actual_ms} ms; host fetching every {fetch_s*1000:.0f} ms. Ctrl-C to stop.") + if actual_wide > 0: + print(f"Wide-register snapshot enabled at {actual_wide} ms (alerts on any non-frame-counter change).") + if actual_fast_dsim > 0: + print(f"Fast DSIM mmap poll enabled at {actual_fast_dsim} ms (direct /dev/mem; catches sub-frame register transients).") + print(f"Clock offset (host - device): {dev_to_host*1000:+.1f} ms.") + if switch_every: + print(f"Driving switch every {switch_every}s; masking unlocks within {mask_s*1000:.0f} ms of each switch.") + print() + + last_seen = 0.0 # device time of last event we've already shown + events = 0 # steady-state (alerted) unlocks + masked = 0 # unlocks in mask window + switches = 0 + next_switch = (host_start + switch_every) if switch_every else None + fetch_errs = 0 + switch_history: list = [] # host times of recent switches, for mask lookup + flicker_marks: list = [] # host times the user pressed 'f' to mark visible flicker + unlocks_log: list = [] # (host_t, event_dict) for every unlock event, for correlation + + if _HAS_KBHIT: + print("Press 'f' to mark a visible flicker observation. Ctrl-C to stop.\n") + else: + print("(Flicker-mark key disabled — msvcrt not available on this platform.)\n") + + try: + while True: + now = time.time() + + # Drain any pending keypresses (flicker mark) + if _HAS_KBHIT: + while msvcrt.kbhit(): + ch = msvcrt.getch() + if ch == b'\x03': # Ctrl-C as raw byte (rarely happens here, but safe) + raise KeyboardInterrupt + c = ch.decode("utf-8", errors="replace").lower() + if c == "f": + flicker_marks.append(now) + print(f"*** [{_fmt_ts(now)}] FLICKER MARK #{len(flicker_marks)}") + + # Fire scheduled switch + if next_switch is not None and now >= next_switch: + switches += 1 + ts = time.strftime("%H:%M:%S") + f".{int((now % 1) * 1000):03d}" + rc = _send("PUT", switch_url, switch_payload, timeout, quiet=True) + tag = "OK" if rc == 0 else f"FAIL rc={rc}" + print(f"[{ts}] switch #{switches} fired ({tag}) — masking {mask_s*1000:.0f} ms") + switch_history.append(now) + # keep history small + if len(switch_history) > 200: + del switch_history[:100] + next_switch = now + switch_every + + # Fetch new events from device + try: + with urllib.request.urlopen(f"{events_url}?since={last_seen}", timeout=timeout) as r: + body = json.loads(r.read().decode()) + except Exception as e: + fetch_errs += 1 + if fetch_errs <= 3 or fetch_errs % 50 == 0: + print(f"[{time.strftime('%H:%M:%S')}] fetch error #{fetch_errs}: {e}") + time.sleep(max(fetch_s, 0.5)) + continue + + new_events = body.get("events", []) + for ev in new_events: + last_seen = max(last_seen, ev["t"]) + # device time → host time + host_t = ev["t"] + dev_to_host + ts = time.strftime("%H:%M:%S", time.localtime(host_t)) + f".{int((host_t % 1) * 1000):03d}" + summary = _fmt_event_summary(ev) + + if ev["type"] == "recovered": + print(f" [{ts}] {summary}") + continue + + if ev["type"] == "register_change": + # Always alert — register changes are rare and interesting. + # Mask-status is informational only. + in_mask = any(0 <= (host_t - sw_t) <= mask_s for sw_t in switch_history) + tag = " (post-switch)" if in_mask else "" + print(f"\a!!! [{ts}] REGISTER CHANGE{tag}: {summary}") + continue + + if ev["type"] == "dsim_fast_change": + in_mask = any(0 <= (host_t - sw_t) <= mask_s for sw_t in switch_history) + tag = " (post-switch)" if in_mask else "" + print(f"\a>>> [{ts}] DSIM FAST{tag}: {summary}") + continue + + # unlock — log unconditionally so flicker-mark correlation has full picture + unlocks_log.append((host_t, ev)) + in_mask = any(0 <= (host_t - sw_t) <= mask_s for sw_t in switch_history) + if in_mask: + masked += 1 + print(f" [{ts}] (masked, post-switch) {summary}") + else: + events += 1 + print(f"\a>>> [{ts}] UNLOCK #{events} (STEADY-STATE): {summary}") + + time.sleep(fetch_s) + + except KeyboardInterrupt: + dur = time.time() - host_start + # Pull final stats from device + try: + with urllib.request.urlopen(events_url, timeout=timeout) as r: + final = json.loads(r.read().decode()) + stats = final.get("stats", {}) + except Exception: + stats = {} + # Stop the device-side monitor + try: + _put_json(mon_url, {"action": "stop"}, timeout) + except Exception: + pass + + print(f"\nStopped after {dur:.1f}s.") + if stats: + polls = stats.get("polls", 0) + errs = stats.get("errors", 0) + rate = polls / dur if dur > 0 else 0 + print(f" Device PLL polls: {polls} ({rate:.1f}/s, {errs} I²C errors)") + fast_polls = stats.get("fast_dsim_polls", 0) + if fast_polls: + fast_rate = fast_polls / dur if dur > 0 else 0 + print(f" Fast DSIM polls (mmap): {fast_polls} ({fast_rate:.0f}/s)") + fast_err = stats.get("fast_dsim_error") + if fast_err: + print(f" Fast DSIM error: {fast_err}") + print(f" Host fetches: errors={fetch_errs}") + if switch_every: + print(f" Switches fired: {switches}") + print(f" Masked (post-switch) unlocks: {masked}") + print(f" STEADY-STATE unlocks (alerted): {events}") + + # --- Flicker observation correlation --- + if flicker_marks: + print(f"\n Flicker observations: {len(flicker_marks)}") + print(f" {'Mark':>5} {'Time':<13} {'Δ nearest switch':>17} " + f"{'Δ nearest unlock':>17} Verdict") + within = 0 # marks within mask window of a switch unlock + between = 0 + for i, mark_t in enumerate(flicker_marks, 1): + nearest_sw = min(switch_history, key=lambda t: abs(t - mark_t)) if switch_history else None + nearest_un = min(unlocks_log, key=lambda x: abs(x[0] - mark_t))[0] if unlocks_log else None + sw_delta = (mark_t - nearest_sw) * 1000 if nearest_sw is not None else None + un_delta = (mark_t - nearest_un) * 1000 if nearest_un is not None else None + # Classify: if mark is within mask_s of an unlock event, call it switch-induced + if un_delta is not None and abs(un_delta) <= mask_s * 1000: + verdict = "MATCHES switch unlock" + within += 1 + else: + verdict = "BETWEEN events (no PLL unlock nearby)" + between += 1 + sw_str = f"{sw_delta:+8.0f} ms" if sw_delta is not None else " —" + un_str = f"{un_delta:+8.0f} ms" if un_delta is not None else " —" + print(f" #{i:<4} {_fmt_ts(mark_t):<13} {sw_str:>17} {un_str:>17} {verdict}") + print(f"\n Summary: {within} match an unlock, {between} between events.") + return 0 + + +def _send(method: str, url: str, payload, timeout: float, quiet: bool = False) -> int: + data = json.dumps(payload).encode() if payload is not None else None + headers = {"Content-Type": "application/json"} if data else {} + req = urllib.request.Request(url, data=data, headers=headers, method=method) + + if not quiet: + print(f"{method} {url}" + (f" body={json.dumps(payload)}" if payload else "")) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read().decode(errors="replace") + if not quiet: + print(f"HTTP {resp.status}") + try: + print(json.dumps(json.loads(body), indent=2)) + except json.JSONDecodeError: + print(body) + except urllib.error.HTTPError as e: + print(f"HTTP {e.code} {e.reason}") + print(e.read().decode(errors="replace")) + return 1 + except urllib.error.URLError as e: + print(f"connection error: {e.reason}", file=sys.stderr) + return 2 + return 0 + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("action", choices=ACTIONS.keys()) + p.add_argument("--video", help="Initial video filename for 'start' (e.g. vid.mp4, vid2.mp4)") + p.add_argument("--interval", type=float, default=30.0, + help="Seconds between switches for 'loop' action (default: 30)") + p.add_argument("--device-poll-ms", type=int, default=10, + help="For 'monitor': device-side I²C polling interval in ms (default: 10)") + p.add_argument("--fetch", type=float, default=0.1, + help="For 'monitor': how often host fetches new events from device (default: 0.1 = 100 ms)") + p.add_argument("--switch-every", type=float, default=None, + help="For 'monitor': also fire a switch every N seconds") + p.add_argument("--mask", type=float, default=0.5, + help="For 'monitor': suppress unlocks within N seconds of each switch (default: 0.5)") + p.add_argument("--wide-ms", type=int, default=0, + help="For 'monitor': also snapshot all SN65 config/status regs every N ms (0=off, try 500)") + p.add_argument("--fast-dsim-ms", type=int, default=0, + help="For 'monitor': enable device-side fast DSIM poll via /dev/mem mmap (0=off, try 1)") + p.add_argument("--log", nargs='?', const='auto', default=None, + help="Tee output to a log file. Use --log alone for auto-named file in data/, or --log path/to/file.log") + p.add_argument("--host", default="10.32.33.100") + p.add_argument("--port", type=int, default=5000) + p.add_argument("--timeout", type=float, default=10.0) + args = p.parse_args() + + if args.action == "monitor": + log_path = None + log_fh = None + if args.log: + if args.log == 'auto': + import os + os.makedirs("data", exist_ok=True) + log_path = os.path.join("data", f"monitor_{time.strftime('%Y%m%d_%H%M%S')}.log") + else: + log_path = args.log + log_fh = open(log_path, 'w', encoding='utf-8') + sys.stdout = _Tee(sys.__stdout__, log_fh) + print(f"Logging to {log_path}") + try: + return _run_monitor(args.host, args.port, args.device_poll_ms, args.fetch, + args.timeout, switch_every=args.switch_every, mask_s=args.mask, + wide_ms=args.wide_ms, fast_dsim_ms=args.fast_dsim_ms) + finally: + if log_fh is not None: + sys.stdout = sys.__stdout__ + log_fh.close() + print(f"Log saved to {log_path}") + + if args.action == "loop": + url = f"http://{args.host}:{args.port}/display" + payload = {"state": "on"} + print(f"Looping 'switch' every {args.interval}s. Ctrl-C to stop.") + n = 0 + try: + while True: + time.sleep(args.interval) + n += 1 + ts = time.strftime("%H:%M:%S") + print(f"[{ts}] switch #{n}") + rc = _send("PUT", url, payload, args.timeout, quiet=True) + if rc != 0: + print(f" (request failed, continuing)") + except KeyboardInterrupt: + print(f"\nStopped after {n} switches.") + return 0 + + method, path, payload = ACTIONS[args.action] + if args.video and args.action in ("start", "start-pink"): + payload = {**payload, "video": args.video} + url = f"http://{args.host}:{args.port}{path}" + return _send(method, url, payload, args.timeout) + + +if __name__ == "__main__": + sys.exit(main())