Commit
This commit is contained in:
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal file
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
14
cycle.sh
Normal file
14
cycle.sh
Normal file
@@ -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."
|
||||
16
device-server.service
Normal file
16
device-server.service
Normal file
@@ -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
|
||||
462
device_server.py
462
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("<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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
87
embed_frames.py
Normal file
87
embed_frames.py
Normal file
@@ -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'<figure class="frame">\n'
|
||||
f' <img src="data:image/jpeg;base64,{b64}" alt="{fname}">\n'
|
||||
f' <figcaption>{caption}</figcaption>\n'
|
||||
f'</figure>'
|
||||
)
|
||||
print(f"embedded {fname} -> {kb} KB")
|
||||
|
||||
if not blocks:
|
||||
print("no frames embedded; HTML unchanged"); return 1
|
||||
|
||||
gallery = (
|
||||
'<div class="frame-gallery">\n'
|
||||
+ "\n".join(blocks)
|
||||
+ "\n</div>"
|
||||
)
|
||||
|
||||
html = HTML.read_text(encoding="utf-8")
|
||||
|
||||
placeholder = "<!-- FRAME_GALLERY_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())
|
||||
399
flicker_investigation_continued.html
Normal file
399
flicker_investigation_continued.html
Normal file
File diff suppressed because one or more lines are too long
397
trigger.py
Normal file
397
trigger.py
Normal file
@@ -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())
|
||||
Reference in New Issue
Block a user