""" device_server.py — deploy this on the target device (192.168.45.8) Provides: PUT /display {"state": "on"|"off"} — blank/unblank framebuffer GET /registers — read MIPI DSI PHY registers via memtool 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 from flask import Flask, jsonify, request app = Flask(__name__) # --------------------------------------------------------------------------- # Video playback state (managed as a subprocess) # --------------------------------------------------------------------------- KIOSK_SCRIPT = "/root/python/display_test_nexio.py" _video_proc: subprocess.Popen | None = None _video_lock = threading.Lock() _kiosk_args: list[str] = ["python3", KIOSK_SCRIPT] # updated when kiosk is started # --------------------------------------------------------------------------- # Register commands to execute on each GET /registers request. # Each entry is a complete memtool command string. # --------------------------------------------------------------------------- REGISTER_COMMANDS = [ "memtool md -l 0x32e100b4+0x0c", # DSIM_PHYTIMING / PHYTIMING1 / PHYTIMING2 ] # --------------------------------------------------------------------------- # SN65DSI83 I2C configuration # --------------------------------------------------------------------------- SN65_I2C_BUS = 4 # i2c-4 on this board SN65_I2C_ADDR = 0x2C # SN65DSI83 fixed 7-bit I2C address # Settling-period poll — started in a background thread immediately after each # kiosk kill+restart. Samples csr_0a and csr_e5 every SETTLING_INTERVAL_S for # SETTLING_DURATION_S seconds. Results stored in _settling_log and returned by # GET /sn65_settling so the host can correlate DSI errors with LP captures. SETTLING_DURATION_S = 1.5 # seconds to poll after restart SETTLING_INTERVAL_S = 0.010 # 10 ms between I2C reads _settling_log: list = [] _settling_lock: threading.Lock = threading.Lock() # SN65DSI83 configuration registers to snapshot at start and end of each settling window. # Grouped by purpose so a reset-to-default is obvious at a glance. # Register address → human-readable name. _SN65_SNAPSHOT_REGS: dict[int, str] = { # Core enable / PLL 0x09: "CLK_SRC", # DSI clock source / PLL pre-divider 0x0A: "PLL_STATUS", # PLL_EN_STAT (bit7) + CHA_CLK_DET (bit3) [status] 0x0D: "PLL_EN", # bit0 = PLL enable; should be 0x01 when running # DSI receiver config 0x10: "DSI_LANES", # CHA_DSI_DATA_EQ_SEL + lane count 0x11: "DSI_CLK_RANGE", # DSI byte-clock frequency range 0x12: "LVDS_CLK_RANGE", # LVDS output clock range # Active area 0x18: "HACT_LOW", # CHA active line length, low byte 0x19: "HACT_HIGH", # CHA active line length, high byte 0x1A: "VACT_LOW", # CHA vertical display size, low byte 0x1B: "VACT_HIGH", # CHA vertical display size, high byte # Sync timing 0x20: "SYNC_DLY_LOW", # CHA sync delay, low byte 0x21: "SYNC_DLY_HIGH", # CHA sync delay, high byte 0x22: "HSYNC_W_LOW", # CHA HSYNC pulse width, low byte 0x23: "HSYNC_W_HIGH", # CHA HSYNC pulse width, high byte 0x24: "VSYNC_W_LOW", # CHA VSYNC pulse width, low byte 0x25: "VSYNC_W_HIGH", # CHA VSYNC pulse width, high byte 0x26: "HBP", # CHA horizontal back porch 0x28: "VBP", # CHA vertical back porch 0x2A: "HFP", # CHA horizontal front porch 0x2C: "VFP", # CHA vertical front porch # Format / output 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] # Error flags 0xE5: "CHA_ERR", # DSI error flags [status] } def _sn65_snapshot() -> dict: """Read all _SN65_SNAPSHOT_REGS in one pass. Returns {reg_hex: value_hex|None}.""" result = {} for reg, name in _SN65_SNAPSHOT_REGS.items(): val, _ = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, reg) result[f"0x{reg:02x}"] = {"name": name, "value": f"0x{val:02x}" if val is not None else None} return result def _run_settling_poll() -> None: """Poll SN65DSI83 csr_0a + csr_e5 at ~10 ms intervals for 1.5 s after restart. Also takes a full configuration register snapshot at t=0 and t=end so callers can detect bridge re-initialisation or configuration loss.""" t_start = time.time() t_end = t_start + SETTLING_DURATION_S snapshot_start = _sn65_snapshot() readings: list = [] while time.time() < t_end: t_ms = round((time.time() - t_start) * 1000, 1) val_0a, _ = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x0A) val_e5, _ = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0xE5) readings.append({ "t_ms": t_ms, "csr_0a": f"0x{val_0a:02x}" if val_0a is not None else None, "csr_e5": f"0x{val_e5:02x}" if val_e5 is not None else None, "pll_lock": bool(val_0a & 0x80) if val_0a is not None else None, "clk_det": bool(val_0a & 0x08) if val_0a is not None else None, "any_error": bool(val_e5) if val_e5 is not None else None, }) time.sleep(SETTLING_INTERVAL_S) snapshot_end = _sn65_snapshot() with _settling_lock: _settling_log.clear() _settling_log.extend(readings) _settling_extra["snapshot_start"] = snapshot_start _settling_extra["snapshot_end"] = snapshot_end # Stores the two register snapshots from the most recent settling poll. _settling_extra: dict = {} # Known Samsung DSIM register names (base 0x32E10000, i.MX 8M Mini) _DSIM_NAMES = { 0x32e10004: "DSIM_STATUS", 0x32e10008: "DSIM_CLKCTRL", 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", 0x32e100b8: "DSIM_PHYTIMING1", 0x32e100bc: "DSIM_PHYTIMING2", } def _parse_memtool_output(raw: str) -> list: """ Parse 'memtool md -l' output into a list of dicts. Handles both formats: 32e100b4: 00000001 12345678 ... 0x32e100b4: 0x00000001 0x12345678 ... """ registers = [] for line in raw.splitlines(): line = line.strip() if not line: continue m = re.match(r"(?:0x)?([0-9a-fA-F]+)\s*:\s*(.+)", line) if not m: continue base_addr = int(m.group(1), 16) values = re.findall(r"[0-9a-fA-F]{8}", m.group(2)) for i, val in enumerate(values): addr = base_addr + i * 4 registers.append({ "address": f"0x{addr:08x}", "value": f"0x{val.lower()}", "name": _DSIM_NAMES.get(addr, ""), }) return registers # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @app.route("/display", methods=["PUT"]) def control_display(): global _video_proc data = request.get_json(force=True) or {} state = data.get("state", "").lower() if state == "on": with _video_lock: if "--static-pink" in _kiosk_args: # Full kill+restart so the DSI controller goes through LP-11 → HS # startup, giving the scope a clean LP trigger on Pass 1. if _video_proc is not None and _video_proc.poll() is None: _video_proc.terminate() try: _video_proc.wait(timeout=3) except subprocess.TimeoutExpired: _video_proc.kill() _video_proc.wait() time.sleep(0.15) # let DSI reach LP-11 # Start settling poll immediately — captures csr_e5 error flags # during DSI startup so the host can determine root cause of flicker. threading.Thread(target=_run_settling_poll, daemon=True).start() try: log = open("/tmp/kiosk.log", "w") _video_proc = subprocess.Popen( _kiosk_args, stdout=log, stderr=subprocess.STDOUT, env=os.environ.copy(), ) except Exception as e: return jsonify({"error": f"restart failed: {e}"}), 500 return jsonify({"status": "static-pink restarted"}), 200 elif _video_proc is not None and _video_proc.poll() is None: # Video mode: UDP trigger switches clip and reloads pipeline _sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) _sock.sendto(b'switch', ('127.0.0.1', 5001)) _sock.close() return jsonify({"status": "video switched"}), 200 # fallback when no kiosk is running os.system("echo 0 > /sys/class/graphics/fb0/blank") return jsonify({"status": "Display ON"}), 200 elif state == "off": # nothing to do while video is managing the display return jsonify({"status": "ok"}), 200 else: return jsonify({"error": "Invalid state. Use 'on' or 'off'"}), 400 @app.route("/registers", methods=["GET"]) def get_registers(): """Read MIPI DSI PHY timing registers via memtool and return JSON.""" all_registers = [] raw_lines = [] errors = [] for cmd_str in REGISTER_COMMANDS: try: result = subprocess.run( cmd_str.split(), capture_output=True, text=True, timeout=5 ) raw = result.stdout.strip() if raw: raw_lines.append(raw) all_registers.extend(_parse_memtool_output(raw)) if result.returncode != 0 and result.stderr.strip(): errors.append(f"{cmd_str}: {result.stderr.strip()}") except FileNotFoundError: errors.append(f"{cmd_str}: memtool not found in PATH") except subprocess.TimeoutExpired: errors.append(f"{cmd_str}: timed out after 5 s") except Exception as e: errors.append(f"{cmd_str}: {e}") return jsonify({ "commands": REGISTER_COMMANDS, "registers": all_registers, "raw": "\n".join(raw_lines), "errors": errors if errors else None, }), 200 def _i2c_read_byte(bus: int, addr: int, reg: int) -> tuple[int | None, str]: """Read one byte via i2cget. Returns (value, "") on success or (None, error_str) on failure.""" try: result = subprocess.run( ["i2cget", "-y", "-f", str(bus), f"0x{addr:02x}", f"0x{reg:02x}"], capture_output=True, text=True, timeout=3 ) if result.returncode == 0: return int(result.stdout.strip(), 16), "" stderr = result.stderr.strip() or f"exit code {result.returncode}" return None, stderr except FileNotFoundError: return None, "i2cget not found in PATH" except Exception as e: 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. Includes: snapshot_start — full register dump taken immediately before polling begins snapshot_end — full register dump taken immediately after polling ends readings — csr_0a + csr_e5 sampled every ~10 ms during the window """ with _settling_lock: readings = list(_settling_log) snap_start = dict(_settling_extra.get("snapshot_start") or {}) snap_end = dict(_settling_extra.get("snapshot_end") or {}) error_readings = [r for r in readings if r.get("any_error")] # Diff the two snapshots so the caller can immediately see what changed. changed = {} for reg, info_s in snap_start.items(): info_e = snap_end.get(reg, {}) v_s = info_s.get("value") v_e = info_e.get("value") if v_s != v_e: changed[reg] = {"name": info_s.get("name"), "start": v_s, "end": v_e} return jsonify({ "n_readings": len(readings), "n_error": len(error_readings), "duration_s": SETTLING_DURATION_S, "interval_ms": int(SETTLING_INTERVAL_S * 1000), "snapshot_start": snap_start, "snapshot_end": snap_end, "changed_regs": changed, "readings": readings, }), 200 @app.route("/sn65_registers", methods=["GET"]) def get_sn65_registers(): """Read SN65DSI83 CSR 0x0A (PLL/CLK status) and 0xE5 (error flags) via I2C.""" csr_0a, err_0a = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x0A) csr_e5, err_e5 = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0xE5) regs = {} errors = [] if csr_0a is not None: regs["csr_0a"] = { "value": f"0x{csr_0a:02x}", "pll_lock": bool(csr_0a & 0x80), # bit 7: PLL_EN_STAT (powered + locked) "clk_det": bool(csr_0a & 0x08), # bit 3: CHA_CLK_DET (HS clock detected) } else: errors.append(f"CSR 0x0A: {err_0a}") if csr_e5 is not None: regs["csr_e5"] = { "value": f"0x{csr_e5:02x}", "pll_unlock": bool(csr_e5 & 0x01), # default=1 at reset; must clear after init "cha_sot_bit_err": bool(csr_e5 & 0x04), "cha_llp_err": bool(csr_e5 & 0x08), # SoT/EoT/lane-merge error "cha_ecc_err": bool(csr_e5 & 0x10), "cha_lp_err": bool(csr_e5 & 0x20), "cha_crc_err": bool(csr_e5 & 0x40), } else: errors.append(f"CSR 0xE5: {err_e5}") return jsonify({ "i2c_bus": SN65_I2C_BUS, "i2c_addr": f"0x{SN65_I2C_ADDR:02x}", "registers": regs, "errors": errors if errors else None, }), 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. PUT /video {"action": "start"|"stop"} """ global _video_proc data = request.get_json(force=True) or {} action = data.get("action", "").lower() with _video_lock: if action == "start": if _video_proc is not None and _video_proc.poll() is None: return jsonify({"status": "already_running", "pid": _video_proc.pid}), 200 try: cmd = ["python3", KIOSK_SCRIPT] 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( cmd, stdout=log, stderr=subprocess.STDOUT, env=os.environ.copy(), ) except Exception as e: return jsonify({"error": f"failed to launch kiosk: {e}"}), 500 return jsonify({"status": "started", "mode": mode or "video", "pid": _video_proc.pid}), 200 elif action == "stop": if _video_proc is not None and _video_proc.poll() is None: _video_proc.terminate() try: _video_proc.wait(timeout=3) except subprocess.TimeoutExpired: _video_proc.kill() _video_proc.wait() _video_proc = None return jsonify({"status": "stopped"}), 200 else: return jsonify({"error": "Invalid action. Use 'start' or 'stop'"}), 400 if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)