This commit is contained in:
David Rice
2026-05-26 17:33:02 +02:00
parent 423766f7a3
commit 0f7b0e1ac5
9 changed files with 1448 additions and 94 deletions

View File

@@ -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("<I", mm, CLKCTRL_OFFSET)[0]
polls = 0
with _pll_monitor_lock:
_pll_monitor_stats["fast_dsim_interval_ms"] = interval_ms
_pll_monitor_stats["fast_dsim_polls"] = 0
_pll_monitor_stats["fast_dsim_error"] = None
try:
while not _pll_monitor_stop.is_set():
try:
t0 = time.time()
polls += 1
cur = struct.unpack_from("<I", mm, CLKCTRL_OFFSET)[0]
if cur != prev:
with _pll_monitor_lock:
_pll_monitor_events.append({
"t": t0,
"type": "dsim_fast_change",
"changes": {
CLKCTRL_ADDR: {
"name": "DSIM_CLKCTRL",
"from": f"0x{prev:08x}",
"to": f"0x{cur:08x}",
}
},
})
if len(_pll_monitor_events) > 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(