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

@@ -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

Binary file not shown.

14
cycle.sh Normal file
View 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
View 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

View File

@@ -8,9 +8,11 @@ Provides:
Add addresses to REGISTER_COMMANDS to capture more register ranges. Add addresses to REGISTER_COMMANDS to capture more register ranges.
""" """
import mmap
import os import os
import re import re
import socket import socket
import struct
import subprocess import subprocess
import threading import threading
import time import time
@@ -22,7 +24,7 @@ app = Flask(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Video playback state (managed as a subprocess) # 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_proc: subprocess.Popen | None = None
_video_lock = threading.Lock() _video_lock = threading.Lock()
@@ -82,8 +84,8 @@ _SN65_SNAPSHOT_REGS: dict[int, str] = {
0x2A: "HFP", # CHA horizontal front porch 0x2A: "HFP", # CHA horizontal front porch
0x2C: "VFP", # CHA vertical front porch 0x2C: "VFP", # CHA vertical front porch
# Format / output # Format / output
0x2D: "TEST_PATTERN", # bit0 = enable colour bar test pattern 0x2D: "REG_0x2D", # unknown — was mislabeled "TEST_PATTERN" but isn't
0x3C: "LVDS_FORMAT", # LVDS output format (colour depth, channel swap) 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 # Live LVDS line counter — changes every frame when bridge is actively outputting
0xE0: "LINE_CNT_LOW", # CHA line count, low byte [live] 0xE0: "LINE_CNT_LOW", # CHA line count, low byte [live]
0xE1: "LINE_CNT_HIGH", # CHA line count, high byte [live] 0xE1: "LINE_CNT_HIGH", # CHA line count, high byte [live]
@@ -144,6 +146,13 @@ _DSIM_NAMES = {
0x32e1000c: "DSIM_TIMEOUT", 0x32e1000c: "DSIM_TIMEOUT",
0x32e10010: "DSIM_CONFIG", 0x32e10010: "DSIM_CONFIG",
0x32e10014: "DSIM_ESCMODE", 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", 0x32e100ac: "DSIM_PHYACCHR",
0x32e100b0: "DSIM_PHYACCHR1", 0x32e100b0: "DSIM_PHYACCHR1",
0x32e100b4: "DSIM_PHYTIMING", 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) 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"]) @app.route("/sn65_settling", methods=["GET"])
def get_sn65_settling(): def get_sn65_settling():
"""Return the most recent post-restart settling poll. """Return the most recent post-restart settling poll.
@@ -356,6 +430,385 @@ def get_sn65_registers():
}), 200 }), 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"]) @app.route("/video", methods=["PUT"])
def control_video(): def control_video():
"""Start or stop the kiosk video player. """Start or stop the kiosk video player.
@@ -375,6 +828,9 @@ def control_video():
mode = data.get("mode", "") mode = data.get("mode", "")
if mode == "static-pink": if mode == "static-pink":
cmd.append("--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 _kiosk_args[:] = cmd # persist so control_display knows the mode
log = open("/tmp/kiosk.log", "w") log = open("/tmp/kiosk.log", "w")
_video_proc = subprocess.Popen( _video_proc = subprocess.Popen(

View File

@@ -1,18 +1,21 @@
import argparse
import gi import gi
import os
import socket import socket
import struct import struct
import threading import sys
import os
gi.require_version('Gst', '1.0') gi.require_version('Gst', '1.0')
from gi.repository import Gst, GLib from gi.repository import Gst, GLib
SWITCH_UDP_PORT = 5001
class KioskManager: class KioskManager:
def __init__(self, pipeline): def __init__(self, pipeline):
self.pipeline = pipeline self.pipeline = pipeline
self.videos = [ self.videos = [
"file:///root/vid.mp4", "file:///root/python/vid.mp4",
"file:///root/vid2.mp4" "file:///root/python/vid2.mp4"
] ]
self.current_video_index = 0 self.current_video_index = 0
@@ -117,7 +120,32 @@ def handle_button(source, condition, manager):
return True 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) Gst.init(None)
pipeline = Gst.ElementFactory.make("playbin", "player") pipeline = Gst.ElementFactory.make("playbin", "player")
@@ -129,17 +157,19 @@ def play_kiosk():
pipeline.set_property("audio-sink", Gst.ElementFactory.make("fakesink")) pipeline.set_property("audio-sink", Gst.ElementFactory.make("fakesink"))
manager = KioskManager(pipeline) 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) # --- UDP SWITCH LISTENER ---
def _udp_listener(): # device_server.py sends b'switch' to 127.0.0.1:5001 to cycle videos remotely.
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try:
sock.bind(('127.0.0.1', 5001)) udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
while True: udp_sock.setblocking(False)
sock.recv(64) udp_sock.bind(("127.0.0.1", SWITCH_UDP_PORT))
GLib.idle_add(manager.switch_video) GLib.io_add_watch(udp_sock, GLib.IO_IN, handle_udp_switch, manager)
except Exception as e:
threading.Thread(target=_udp_listener, daemon=True).start() print(f"UDP Listener Error: {e}")
# --- INPUT MONITORING --- # --- INPUT MONITORING ---
try: try:
@@ -159,16 +189,13 @@ def play_kiosk():
def on_message(bus, msg, manager_instance): def on_message(bus, msg, manager_instance):
if msg.type == Gst.MessageType.EOS: if msg.type == Gst.MessageType.EOS:
# Video ended. Cycle LED and advance to the next video in the list.
manager_instance.change_led_colour() 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: elif msg.type == Gst.MessageType.ERROR:
err, debug = msg.parse_error() err, debug = msg.parse_error()
print(f"GStreamer Error: {err}\nDebug: {debug}", flush=True) print(f"GStreamer Error: {err}")
loop.quit() 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)
bus.connect("message", on_message, manager) bus.connect("message", on_message, manager)
@@ -180,73 +207,18 @@ def play_kiosk():
except KeyboardInterrupt: except KeyboardInterrupt:
pipeline.set_state(Gst.State.NULL) 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__": if __name__ == "__main__":
import sys p = argparse.ArgumentParser(description="Kiosk video player")
if "--static-pink" in sys.argv: p.add_argument("--start", default="vid.mp4",
play_static_color(255, 51, 187) # R:255 G:51 B:187 help="Initial video filename (basename match against kiosk list)")
else: # parse_known_args so legacy flags like --static-pink don't crash the kiosk
play_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
View 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())

File diff suppressed because one or more lines are too long

397
trigger.py Normal file
View 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())