This commit is contained in:
david rice
2026-05-07 09:01:32 +01:00
parent 9c75598728
commit dd93fbd893
4 changed files with 531 additions and 64 deletions

369
flicker_watch.py Normal file
View File

@@ -0,0 +1,369 @@
#!/usr/bin/env python3
"""
flicker_watch.py — Continuous LP capture during video on/off cycles.
Operator watches the display. Script keeps cycling the video stream on/off
and triggering LP captures in the background. Files accumulate on the scope
without being transferred (fast).
Keys (no Enter needed):
f — flicker observed: transfer + archive + analyse recent captures
g — good baseline: transfer + archive recent captures (no analysis)
q — quit
Captures are organised under data/flicker/{event_ts}/ or data/good/{event_ts}/.
"""
import json
import select
import shutil
import sys
import termios
import time
import tty
from datetime import datetime
from pathlib import Path
import requests
import vxi11
import ai_mgmt
from csv_preprocessor import analyze_lp_file
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
SCOPE_IP = "192.168.45.4"
DEVICE_BASE = "http://192.168.45.8:5000"
VIDEO_URL = f"{DEVICE_BASE}/video"
DATA_DIR = Path(__file__).parent / "data"
FLICKER_DIR = DATA_DIR / "flicker"
GOOD_DIR = DATA_DIR / "good"
# LP capture parameters (matched to mipi_test_interactive.py)
LP_SCALE = 1e-6 # 1 µs/div → 20 µs window
LP_POINTS = 200_000
LP_TRIG_OFFSET = 9e-6 # 1 µs pre / 19 µs post-trigger
LP_V_SCALE = 0.2
LP_V_OFFSET = 0.6
LP_TRIG_LEVEL = 0.6
CYCLE_S = 10.0 # seconds video is on per cycle
TRIG_TIMEOUT_S = 2.0 # per-capture trigger wait
# ---------------------------------------------------------------------------
# Scope setup
# ---------------------------------------------------------------------------
scope = vxi11.Instrument(SCOPE_IP)
scope.timeout = 30
def setup_scope() -> None:
"""One-shot scope init — channels, math, default trigger."""
print("CONFIGURING SCOPE...")
cmds = [
"*RST", ":RUN", ":STOP",
":CHANnel1:DISPlay ON", ":CHANnel1:INPut DC50", ":CHANnel1:PROBe 19.2",
":CHANnel1:LABel 'CLK+'",
":CHANnel2:DISPlay ON", ":CHANnel2:INPut DC50", ":CHANnel2:PROBe 19.2",
":CHANnel2:LABel 'CLK-'",
":CHANnel3:DISPlay ON", ":CHANnel3:INPut DC50", ":CHANnel3:PROBe 19.2",
":CHANnel3:LABel 'DAT0+'",
":CHANnel4:DISPlay ON", ":CHANnel4:INPut DC50", ":CHANnel4:PROBe 19.2",
":CHANnel4:LABel 'DAT0-'",
":TIMebase:REFerence CENTer",
":TRIGger:MODE EDGE",
":ACQuire:MODE RTIMe", ":ACQuire:INTerpolate ON",
":DISPlay:LAYout STACKED",
]
for c in cmds:
scope.write(c)
time.sleep(0.05)
print("SCOPE READY.")
def configure_for_lp() -> None:
"""LP-mode: widen vertical range, falling-edge trigger on Ch3."""
for ch in (1, 2, 3, 4):
scope.write(f":CHANnel{ch}:SCALe {LP_V_SCALE:.3f}")
scope.write(f":CHANnel{ch}:OFFSet {LP_V_OFFSET:.3f}")
scope.write(":TRIGger:EDGE:SOURce CHANnel3")
scope.write(":TRIGger:EDGE:SLOPe NEGative")
scope.write(f":TRIGger:EDGE:LEVel {LP_TRIG_LEVEL:.3f}")
scope.write(":TRIGger:SWEep NORMal")
scope.write(f":TIMebase:SCALe {LP_SCALE:.3E}")
scope.write(f":ACQuire:POINts {LP_POINTS}")
scope.write(f":TIMebase:POSition {LP_TRIG_OFFSET:.2E}")
time.sleep(0.3)
def arm_and_wait(timeout_s: float) -> bool:
""":DIGitize + *OPC?. Returns True if trigger fired within timeout."""
global scope
prev = scope.timeout
try:
scope.timeout = timeout_s + 2
scope.write(":DIGitize")
return scope.ask("*OPC?").strip() == "1"
except Exception:
# Trigger timed out or scope locked up — reconnect.
try:
scope.close()
except Exception:
pass
time.sleep(1.0)
scope = vxi11.Instrument(SCOPE_IP)
scope.timeout = 30
try:
scope.write(":STOP")
except Exception:
pass
return False
finally:
try:
scope.timeout = prev
except Exception:
pass
def save_lp(base_name: str) -> None:
"""Save Ch1 (CLK+) and Ch3 (DAT0+) as CSV to scope's C:\\TEMP\\."""
base = f"C:\\TEMP\\{base_name}"
scope.write(f':DISK:SAVE:WAVeform CHANnel1,"{base}_clk.csv",CSV')
time.sleep(2.5)
scope.write(f':DISK:SAVE:WAVeform CHANnel3,"{base}_dat.csv",CSV')
time.sleep(2.5)
# ---------------------------------------------------------------------------
# Non-blocking keyboard
# ---------------------------------------------------------------------------
class KeyReader:
def __enter__(self):
self.fd = sys.stdin.fileno()
self.old = termios.tcgetattr(self.fd)
tty.setcbreak(self.fd)
return self
def get_key(self) -> str | None:
if select.select([sys.stdin], [], [], 0)[0]:
return sys.stdin.read(1).lower()
return None
def __exit__(self, *_):
termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old)
# ---------------------------------------------------------------------------
# Video control
# ---------------------------------------------------------------------------
def video_start() -> None:
try:
requests.put(VIDEO_URL,
json={"action": "start", "mode": "static-pink"},
timeout=3)
except requests.exceptions.RequestException as e:
print(f" VIDEO START failed: {e}")
def video_stop() -> None:
try:
requests.put(VIDEO_URL, json={"action": "stop"}, timeout=3)
except requests.exceptions.RequestException as e:
print(f" VIDEO STOP failed: {e}")
# ---------------------------------------------------------------------------
# Register snapshot from device (DSIM PHY + SN65DSI83)
# ---------------------------------------------------------------------------
def fetch_registers_snapshot(target_dir: Path, event_ts: str) -> None:
"""GET /registers + /sn65_registers, print key indicators, save JSON."""
combined: dict = {}
for endpoint, key in [("/registers", "dsim"),
("/sn65_registers", "sn65")]:
try:
r = requests.get(f"{DEVICE_BASE}{endpoint}", timeout=5)
r.raise_for_status()
combined[key] = r.json()
except Exception as e:
print(f" REGISTERS: {endpoint} failed — {e}")
combined[key] = None
# Quick-look indicators
sn65 = combined.get("sn65") or {}
regs = sn65.get("registers", {}) if isinstance(sn65, dict) else {}
csr_0a = regs.get("csr_0a", {}) or {}
csr_e5 = regs.get("csr_e5", {}) or {}
if csr_0a:
pll_str = "LOCKED" if csr_0a.get("pll_lock") else "*** UNLOCKED ***"
clk_str = "detected" if csr_0a.get("clk_det") else "NOT detected"
print(f" SN65: PLL {pll_str} CLK {clk_str} (CSR 0x0A = {csr_0a.get('value')})")
if csr_e5:
flags = [
("pll_unlock", "PLL_UNLOCK"),
("cha_sot_bit_err", "SOT_BIT_ERR"),
("cha_llp_err", "LLP_ERR"),
("cha_ecc_err", "ECC_ERR"),
("cha_lp_err", "LP_ERR"),
("cha_crc_err", "CRC_ERR"),
]
active = [label for k, label in flags if csr_e5.get(k)]
if active:
print(f" SN65: *** ERROR FLAGS: {', '.join(active)} "
f"(CSR 0xE5 = {csr_e5.get('value')}) ***")
else:
print(f" SN65: no error flags (CSR 0xE5 = {csr_e5.get('value')})")
out = target_dir / f"{event_ts}_registers.json"
try:
out.write_text(json.dumps(combined, indent=2))
print(f" registers → {out.relative_to(DATA_DIR.parent)}")
except Exception as e:
print(f" REGISTERS save failed: {e}")
# ---------------------------------------------------------------------------
# Event handling: archive recent captures and (for flicker) analyse
# ---------------------------------------------------------------------------
def archive_and_analyse(event: str, since_iso: str) -> None:
"""
Pull every CSV from the scope, move into data/{event}/{event_ts}/.
For flicker events, run csv_preprocessor on each LP capture and print a
summary table. Always pulls a register snapshot from the device too.
"""
event_ts = datetime.now().strftime("%Y%m%d_%H%M%S")
target = (FLICKER_DIR if event == "flicker" else GOOD_DIR) / event_ts
target.mkdir(parents=True, exist_ok=True)
print(f"\n *** {event.upper()} EVENT @ {event_ts} ***")
# Register snapshot first (fast, before scope transfer which takes longer)
fetch_registers_snapshot(target, event_ts)
print(f" Transferring scope → {target} ...")
try:
copied, failed = ai_mgmt.transfer_csv_files()
except Exception as e:
print(f" TRANSFER ERROR: {e}")
return
print(f" {copied} file(s) transferred ({failed} failed)")
# Move just-arrived CSVs out of data/ (flat) into the event folder.
moved = 0
for csv in DATA_DIR.glob("*.csv"):
if csv.is_file():
shutil.move(str(csv), target / csv.name)
moved += 1
print(f" {moved} file(s) archived to {target.relative_to(DATA_DIR.parent)}")
if event != "flicker":
return
# Analyse the LP captures we just archived.
print("\n LP analysis (csv_preprocessor):")
print(" " + "-" * 78)
print(f" {'file':<46} {'lp_low_ns':>10} {'hs_amp_mV':>10} {'flicker?':>9}")
print(" " + "-" * 78)
lp_files = sorted(target.glob("*_lp_*_dat.csv"))
for f in lp_files:
try:
m = analyze_lp_file(f)
lp_low = getattr(m, "lp_low_duration_ns", None)
hs_amp = getattr(m, "hs_amp_mV", None)
sus = getattr(m, "flicker_suspect", False)
print(f" {f.name:<46} "
f"{(f'{lp_low:.1f}' if lp_low is not None else '?'):>10} "
f"{(f'{hs_amp:.1f}' if hs_amp is not None else '?'):>10} "
f"{('YES' if sus else 'no'):>9}")
except Exception as e:
print(f" {f.name:<46} ERROR: {e}")
print(" " + "-" * 78)
# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
def main() -> None:
DATA_DIR.mkdir(exist_ok=True)
FLICKER_DIR.mkdir(exist_ok=True)
GOOD_DIR.mkdir(exist_ok=True)
setup_scope()
configure_for_lp()
print("\n" + "=" * 64)
print(" FLICKER WATCH — keys: f=flicker g=good q=quit")
print("=" * 64 + "\n")
cycle = 0
try:
with KeyReader() as keys:
while True:
cycle += 1
cycle_ts = datetime.now().strftime("%Y%m%d_%H%M%S")
cycle_caps = []
cycle_end = time.time() + CYCLE_S
video_start()
print(f"\n[cycle {cycle:03d} {cycle_ts}] video ON "
f"({CYCLE_S:.0f}s window)", flush=True)
event = None
last_tick = 0.0
while time.time() < cycle_end:
seq = len(cycle_caps) + 1
base = f"{cycle_ts}_lp_c{cycle:03d}_{seq:02d}"
remaining = lambda: max(0, cycle_end - time.time())
if arm_and_wait(TRIG_TIMEOUT_S):
try:
save_lp(base)
cycle_caps.append(base)
print(f" + cap {seq:02d} [{remaining():4.1f}s left]",
flush=True)
except Exception as e:
print(f" save error: {e}", flush=True)
else:
# Trigger timed out — print a heartbeat at most every 2s
if time.time() - last_tick > 2.0:
print(f" ... waiting for trigger "
f"[{remaining():4.1f}s left]", flush=True)
last_tick = time.time()
key = keys.get_key()
if key in ("f", "g", "q"):
event = key
break
video_stop()
if event is None:
print(f"[cycle {cycle:03d}] ended "
f"({len(cycle_caps)} cap(s), no event)",
flush=True)
if event == "f":
archive_and_analyse("flicker", cycle_ts)
elif event == "g":
archive_and_analyse("good", cycle_ts)
elif event == "q":
print("\nQUIT requested.")
break
# Brief pause before next cycle so video stop settles.
time.sleep(0.5)
except KeyboardInterrupt:
print("\nInterrupted (Ctrl+C).")
finally:
try:
video_stop()
except Exception:
pass
if __name__ == "__main__":
main()

View File

@@ -1535,37 +1535,30 @@ def run_interactive_test() -> None:
def run_continuous_test() -> None: def run_continuous_test() -> None:
""" """
Continuous LP capture loop — no kiosk restart between iterations. Continuous LP capture loop — pipeline restart per iteration.
Designed for periodic flicker that repeats roughly every second once the The pipeline (kiosk) is stopped and restarted on every iteration so the
display pipeline has started. The kiosk is started once; the scope scope captures the startup LP-11→LP-01 transition that triggers the flicker.
re-arms on the NORMAL LP trigger (VBLANK LP-11 → LP-01 falling edge on The scope is configured and armed BEFORE _start_video() is called so that
Ch3) after each capture, effectively sampling one random display frame the first HS burst after pipeline load is always captured.
every ~7 s.
With flicker on ~1/60 frames the expected time to first catch is Sequence per iteration:
~60 × 7 s ≈ 7 minutes of unattended running. 1. _stop_video() — tear down pipeline
2. _configure_for_lp() — set scope channels + trigger (takes ~400 ms)
3. _start_video() — reload pipeline (LP transition fires ~1-2 s later)
4. _arm_and_wait() — scope captures first LP-11→LP-01 on Ch3
5. Transfer + LP analysis
6. If suspect: LP bit decode + byte comparison vs last clean capture
When the LP rule-based detector flags a suspect: Press Ctrl+C to stop. No HTML report is written; raw LP CSVs are kept in data/.
• The LP file already on disk (10 GSa/s, 100 ps/sample) is decoded
directly using single-ended CLK+/DAT0+ thresholds — no extra capture.
• proto_decoder checks the HS-SYNC byte position (misalignment) and the
Lane 0 pixel content (corruption).
• compare_lp_captures() shows byte-level diffs vs the last clean capture.
Press Ctrl+C to stop. No report is written (raw LP/proto CSVs are kept).
""" """
import proto_decoder as _pd import proto_decoder as _pd
print("\n===== CONTINUOUS CAPTURE MODE =====") print("\n===== CONTINUOUS CAPTURE MODE =====")
print("Kiosk starts once. Scope re-arms on each VBLANK trigger (no restart).") print("Pipeline restart per iteration — captures startup LP transition.")
print("LP-only per iteration; LP bit decode fires directly on LP suspect files.") print("LP bit decode fires automatically on flicker suspects.")
print("Press Ctrl+C to stop.\n") print("Press Ctrl+C to stop.\n")
_start_video()
print("Waiting 5 s for display pipeline to stabilise...")
time.sleep(5.0)
iteration = 1 iteration = 1
clean_count = 0 clean_count = 0
flicker_count = 0 flicker_count = 0
@@ -1575,11 +1568,20 @@ def run_continuous_test() -> None:
while True: while True:
ts = datetime.now().strftime("%Y%m%d_%H%M%S") ts = datetime.now().strftime("%Y%m%d_%H%M%S")
# ── LP capture ────────────────────────────────────────────────── # ── Stop pipeline, configure scope, then restart pipeline ─────────
_stop_video()
time.sleep(0.3)
# Configure scope while pipeline is down — scope will be ready before
# the first LP edge fires after _start_video().
_configure_for_lp() _configure_for_lp()
_set_timebase(LP_SCALE, LP_POINTS) _set_timebase(LP_SCALE, LP_POINTS)
scope.write(f":TIMebase:POSition {LP_TRIG_OFFSET:.2E}") scope.write(f":TIMebase:POSition {LP_TRIG_OFFSET:.2E}")
ok = _arm_and_wait(timeout=5)
_start_video()
# ── LP capture on startup transition ─────────────────────────────
ok = _arm_and_wait(timeout=10)
scope.write(":TIMebase:POSition 0") scope.write(":TIMebase:POSition 0")
_restore_hs_config() _restore_hs_config()
@@ -1598,7 +1600,7 @@ def run_continuous_test() -> None:
iteration += 1 iteration += 1
continue continue
# ── LP analysis (quiet) ────────────────────────────────────────── # ── LP analysis ──────────────────────────────────────────────────
lp_summaries, suspects = _analyze_lp_files(ts, iteration) lp_summaries, suspects = _analyze_lp_files(ts, iteration)
if not suspects: if not suspects:
@@ -1662,7 +1664,7 @@ def main_menu() -> None:
print("3. CONFIGURE PSU (DEFAULT 24V / 1.5A)") print("3. CONFIGURE PSU (DEFAULT 24V / 1.5A)")
print("4. PSU OUTPUT ON/OFF (CH1)") print("4. PSU OUTPUT ON/OFF (CH1)")
print("5. START INTERACTIVE FLICKER TEST (kiosk restart per iteration)") print("5. START INTERACTIVE FLICKER TEST (kiosk restart per iteration)")
print("6. START CONTINUOUS CAPTURE TEST (no restart; LP bit decode on flicker)") print("6. START CONTINUOUS CAPTURE TEST (no restart; proto decode on flicker)")
print("7. EXIT") print("7. EXIT")
choice = input("\nSELECT OPTION (1-7): ").strip() choice = input("\nSELECT OPTION (1-7): ").strip()

View File

@@ -44,6 +44,9 @@ DSI_DT_RGB888 = 0x3E
DSI_DT_HSYNC = 0x21 # short packet — H sync start DSI_DT_HSYNC = 0x21 # short packet — H sync start
DSI_DT_VSYNC = 0x01 # short packet — V sync start DSI_DT_VSYNC = 0x01 # short packet — V sync start
# Known-valid DSI data types used in sync-byte validation (VC=0 + DT in this set)
VALID_DSI_DT = {0x01, 0x11, 0x21, 0x31, 0x08, 0x09, 0x19, 0x29, 0x39, 0x3E}
# MIPI D-PHY HS sync byte (transmitted at start of each HS burst, all-lanes) # MIPI D-PHY HS sync byte (transmitted at start of each HS burst, all-lanes)
HS_SYNC_BYTE = 0xB8 # 1011_1000 in bit order (LSB first → 00011101 on wire) HS_SYNC_BYTE = 0xB8 # 1011_1000 in bit order (LSB first → 00011101 on wire)
@@ -149,23 +152,70 @@ def find_hs_start(t_dat, v_dat, t_clk=None, window_ns=500.0, single_ended=False)
N = len(v_dat) N = len(v_dat)
# --- Single-ended LP path --- # --- Single-ended LP path ---
# LP-01 + LP-00 + HS-PREPARE + HS-ZERO form a continuous "LP-low" region where
# DAT+ < 0.25 V and rolling std < 45 mV. The LP-low region ends when the first
# '1' bit transition in 0xB8 causes rolling std > 45 mV. Start bit decoding a
# few bits BEFORE that spike so the phase search can find complete 0xB8 near byte 0.
if single_ended: if single_ended:
min_lp01 = max(2, int(20.0 / dt_ns)) LP11_THRESH_SE = 0.8 # V — LP-11 state (DAT+ high)
run = 0 LP_LOW_V_SE = 0.25 # V — LP-01/LP-00/HS-ZERO are all below this
lp01_end = None HS_STD_V_SE = 0.045 # V — rolling std above this → first HS data bit
for i in range(N): LP_LOW_MIN_NS = 5.0 # ns — ignore LP-low runs shorter than this
if v_dat[i] < LP_SE_LP01_THRESH_V: LP_MARGIN_NS = 25.0 # ns — start decode this far before first data bit
run += 1
else:
if run >= min_lp01:
lp01_end = i
break
run = 0
if lp01_end is not None: win_samples = max(10, int(1.0 / dt_ns))
skip = max(1, int(50.0 / dt_ns)) try:
return min(lp01_end + skip, N - 1) from numpy.lib.stride_tricks import sliding_window_view
return None rstd = np.zeros(N)
wins = sliding_window_view(v_dat, win_samples)
rstd[win_samples - 1:win_samples - 1 + len(wins)] = wins.std(axis=-1)
except Exception:
rstd = np.array([v_dat[max(0, i - win_samples):i + 1].std() for i in range(N)])
# Find LP-11 end (first sample below LP11_THRESH_SE after LP-11)
lp11_end_idx = None
in_lp11 = False
for i in range(N):
if v_dat[i] > LP11_THRESH_SE:
in_lp11 = True
elif in_lp11:
lp11_end_idx = i
break
if lp11_end_idx is None:
return None
search_end = min(lp11_end_idx + int(2000.0 / dt_ns), N)
# Find LP-low plateau start: first sustained block of v < LP_LOW_V_SE
# AND rstd < HS_STD_V_SE (the LP-11 fall edge has high rstd so we skip it).
min_lp_run = max(5, int(LP_LOW_MIN_NS / dt_ns))
lp_low_start = None
run = 0
for i in range(lp11_end_idx, search_end):
if v_dat[i] < LP_LOW_V_SE and rstd[i] < HS_STD_V_SE:
run += 1
if run >= min_lp_run:
lp_low_start = i - run + 1
break
else:
run = 0
if lp_low_start is None:
return min(lp11_end_idx + max(1, int(50.0 / dt_ns)), N - 1)
# Find LP-low plateau end: first rstd > HS_STD_V_SE after the plateau begins.
# This is where the first '1' bit in 0xB8 creates a large voltage transition.
lp_low_end = None
for i in range(lp_low_start, search_end):
if rstd[i] > HS_STD_V_SE:
lp_low_end = i
break
if lp_low_end is None:
return min(lp_low_start + max(1, int(50.0 / dt_ns)), N - 1)
# Start decode LP_MARGIN_NS before the first '1' bit of 0xB8 so the 8-phase
# search sees the complete sync byte near byte 0.
margin = max(1, int(LP_MARGIN_NS / dt_ns))
return max(lp_low_start, lp_low_end - margin)
# --- Differential LP-triggered path --- # --- Differential LP-triggered path ---
# LP-01: D+ = 0 V, D- = high → diff strongly negative (< -0.5 V for ≥ 20 ns) # LP-01: D+ = 0 V, D- = high → diff strongly negative (< -0.5 V for ≥ 20 ns)
@@ -379,21 +429,37 @@ def decode_capture(cap_num: int, data_dir: Path, verbose: bool = True):
print(" ERROR: Too few bits decoded") print(" ERROR: Too few bits decoded")
return None return None
# Try all 8 bit-phase offsets to handle framing uncertainty from LP-00 CLK edges. # Try all 8 bit-phase offsets. Pass 1: find earliest 0xB8 whose next byte has
# LP-00 CLK edges before HS starts produce garbage bits; the correct phase is # VC=0 and a known DSI DT (validated sync). Pass 2 fallback: earliest bare 0xB8.
# the one where 0xB8 appears earliest in the byte stream. raw_bytes = None
raw_bytes = None sync_idx = None
sync_idx = None
best_phase = 0 best_phase = 0
best_sync = len(bits) # sentinel: "not found" best_sync = len(bits)
validated = False
for phase in range(8): for phase in range(8):
rb = bits_to_bytes(bits[phase:]) rb = bits_to_bytes(bits[phase:])
si = find_sync_byte(rb) for i in range(len(rb) - 1):
if si is not None and si < best_sync: if rb[i][1] == HS_SYNC_BYTE:
best_sync = si next_byte = rb[i + 1][1]
best_phase = phase if (next_byte >> 6) == 0 and (next_byte & 0x3F) in VALID_DSI_DT:
raw_bytes = rb if i < best_sync:
sync_idx = si best_sync = i
best_phase = phase
raw_bytes = rb
sync_idx = i
validated = True
break # stop at first validated pair for this phase
if not validated:
for phase in range(8):
rb = bits_to_bytes(bits[phase:])
si = find_sync_byte(rb)
if si is not None and si < best_sync:
best_sync = si
best_phase = phase
raw_bytes = rb
sync_idx = si
if raw_bytes is None: if raw_bytes is None:
raw_bytes = bits_to_bytes(bits) raw_bytes = bits_to_bytes(bits)
@@ -405,7 +471,8 @@ def decode_capture(cap_num: int, data_dir: Path, verbose: bool = True):
else: else:
if verbose: if verbose:
t_sync = raw_bytes[sync_idx][0] t_sync = raw_bytes[sync_idx][0]
print(f" HS sync byte found at byte {sync_idx} (t={t_sync:.0f} ns, bit phase={best_phase})") qual = "validated" if validated else "bare"
print(f" HS sync byte found at byte {sync_idx} (t={t_sync:.0f} ns, bit phase={best_phase}, {qual})")
# Data bytes after sync # Data bytes after sync
data_bytes = raw_bytes[sync_idx + 1:] # skip the sync byte itself data_bytes = raw_bytes[sync_idx + 1:] # skip the sync byte itself
@@ -507,8 +574,19 @@ def decode_lp_capture(cap_num: int, data_dir: Path, verbose: bool = True):
print(f" HS burst start: {t_hs_start_ns:.0f} ns " print(f" HS burst start: {t_hs_start_ns:.0f} ns "
f"({hs_duration_us:.1f} µs available of ~18 µs full burst)") f"({hs_duration_us:.1f} µs available of ~18 µs full burst)")
# Auto-detect HS common mode from the first 200 ns of the HS burst.
# CLK+ common mode (~217 mV) and DAT+ common mode (~104 mV on this board) differ;
# hard-coding one value for DAT+ breaks the decode. The median of the HS burst
# gives the correct bit threshold for any board without manual calibration.
hs_probe_end = min(hs_start_idx + max(1, int(200.0 / dt_ns)), len(v_dat))
dat_common_mode = float(np.median(v_dat[hs_start_idx:hs_probe_end]))
dat_common_mode = max(0.030, min(0.250, dat_common_mode)) # clamp to 30250 mV
if verbose:
print(f" DAT+ HS common mode: {dat_common_mode*1000:.0f} mV (auto-detected, used as bit threshold)")
bits = decode_bits(t_dat, v_dat, t_clk, v_clk, hs_start_idx, bits = decode_bits(t_dat, v_dat, t_clk, v_clk, hs_start_idx,
dat_thresh=LP_SE_DAT_THRESH_V, clk_thresh=LP_SE_CLK_THRESH_V) dat_thresh=dat_common_mode, clk_thresh=LP_SE_CLK_THRESH_V)
if verbose: if verbose:
print(f" Decoded {len(bits)} bits ({len(bits)//8} bytes)") print(f" Decoded {len(bits)} bits ({len(bits)//8} bytes)")
@@ -518,18 +596,35 @@ def decode_lp_capture(cap_num: int, data_dir: Path, verbose: bool = True):
print(" ERROR: Too few bits decoded") print(" ERROR: Too few bits decoded")
return None return None
raw_bytes = None raw_bytes = None
sync_idx = None sync_idx = None
best_phase = 0 best_phase = 0
best_sync = len(bits) best_sync = len(bits)
validated = False
for phase in range(8): for phase in range(8):
rb = bits_to_bytes(bits[phase:]) rb = bits_to_bytes(bits[phase:])
si = find_sync_byte(rb) for i in range(len(rb) - 1):
if si is not None and si < best_sync: if rb[i][1] == HS_SYNC_BYTE:
best_sync = si next_byte = rb[i + 1][1]
best_phase = phase if (next_byte >> 6) == 0 and (next_byte & 0x3F) in VALID_DSI_DT:
raw_bytes = rb if i < best_sync:
sync_idx = si best_sync = i
best_phase = phase
raw_bytes = rb
sync_idx = i
validated = True
break # stop at first validated pair for this phase
if not validated:
for phase in range(8):
rb = bits_to_bytes(bits[phase:])
si = find_sync_byte(rb)
if si is not None and si < best_sync:
best_sync = si
best_phase = phase
raw_bytes = rb
sync_idx = si
if raw_bytes is None: if raw_bytes is None:
raw_bytes = bits_to_bytes(bits) raw_bytes = bits_to_bytes(bits)
@@ -541,7 +636,8 @@ def decode_lp_capture(cap_num: int, data_dir: Path, verbose: bool = True):
else: else:
if verbose: if verbose:
t_sync = raw_bytes[sync_idx][0] t_sync = raw_bytes[sync_idx][0]
print(f" HS sync byte found at byte {sync_idx} (t={t_sync:.0f} ns, bit phase={best_phase})") qual = "validated" if validated else "bare"
print(f" HS sync byte found at byte {sync_idx} (t={t_sync:.0f} ns, bit phase={best_phase}, {qual})")
data_bytes = raw_bytes[sync_idx + 1:] data_bytes = raw_bytes[sync_idx + 1:]
header = parse_long_packet_header([b for _, b in data_bytes[:8]]) header = parse_long_packet_header([b for _, b in data_bytes[:8]])