From 55d5d06b2e9652083488dcd3a18f184dc7e401a4 Mon Sep 17 00:00:00 2001 From: Peter Wittich Date: Fri, 16 Oct 2026 13:57:48 -0400 Subject: [PATCH 1/5] scripts for LGA80D and Si clock chips --- README.md | 1 + TODO.md | 3 + exercise_mcu.py | 44 ++-- lga80d_checks.py | 373 +++++++++++++++++++++++++++++++ lga80d_dump.py | 118 ++++++---- mcu_dump.py | 111 ++++++++++ si5395_checks.py | 343 +++++++++++++++++++++++++++++ si5395_dump.py | 425 ++++++++++++++++++++++++++++++++++++ tests/test_lga80d_dump.py | 8 + tests/test_mcu_dump.py | 115 ++++++++++ tests/test_si5395_checks.py | 110 ++++++++++ vccint_2phase.py | 323 +++++++++++++++++++++++++++ 12 files changed, 1915 insertions(+), 59 deletions(-) create mode 100644 lga80d_checks.py create mode 100755 mcu_dump.py create mode 100644 si5395_checks.py create mode 100644 si5395_dump.py create mode 100644 tests/test_mcu_dump.py create mode 100644 tests/test_si5395_checks.py create mode 100644 vccint_2phase.py diff --git a/README.md b/README.md index 73ec695..7208b7c 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ cm_interface/ ├─ registry.py # Global Registry for board devices ├─ examples.py # Worked examples (see EXAMPLES.md) ├─ exercise_mcu.py # Exercise the MCU pages; writes/control prompt unless --yes +├─ mcu_dump.py # Read-only MCU page dump (--out-dir) ├─ lga80d_dump.py # LGA80D register dump script (--snapshot adds the SNAPSHOT read) ├─ MCU_REGISTER_MAP.md # MCU wire contract (with MCU_Reg.h) ├─ TODO.md # Open, deferred and dropped cm_interface work diff --git a/TODO.md b/TODO.md index 31dc56e..304a7b8 100644 --- a/TODO.md +++ b/TODO.md @@ -13,6 +13,8 @@ except where marked HW-verified. Delete an item when it lands. - **`FPGA.write_reg` has no guard**, against the refuse-by-default rule in `CLAUDE.md`. Open decision 3 in `FPGA_GENERIC_INTERFACE_PLAN.md` (`unsafe=True` opt-in vs warning). +- **Clocks doesn't respect Rev2 vs Rev3 differences** Rev2 has a Si5341 in position R0A, Rev3 has Si5395. Need to think about how to handle that. + ## LGA80D snapshot follow-ups `LGA80D.read_snapshot`, `lga80d_dump.py --snapshot` and the firmware `SN` device are @@ -38,6 +40,7 @@ temporary DEBUG firmware build) and a capture on an absent or NACKing supply. Sn | TCA9555 layers B, C (`IO` device; gated writes or named `0x7f` commands) | same, §4.2-4.3 | firmware; C needs maintainer sign-off | | Clock `EE` device and load trigger (Phases 3-6) | `CLOCK_PROGRAMMING_PLAN.md` | firmware: the clock EEPROM (0x50, bus 2) needs a 2-byte address and ProgCom `CL` is 1-byte; `EE` unresolved against `MCU_CLI_GAP_PLAN.md` §3.4; page/code numbering stale | | Staleness metadata (`updateTick`/`isFFStale`) as page `0x06` extension at `0x20`+ | UART7 plan §5 | unscheduled | +| LGA80D CLEAR_FAULTS (03h, global, 0 data bytes = SMBus Send Byte): `LGA80D.clear_faults()` and a script option | CM3001 F1VCCINT 2-phase test, 2026-10-13 | firmware: ProgCom rejects a `w DC` with no data (`ProgComTask.c:259`, "write with no data"). Workaround today, MCU CLI: `semaphore 1 take`; `i2cw 1 70 1 `; `i2cw 1 1 03`; `semaphore 1 release` (mux bits and addresses in `pm_addrs_dcdc[]`, `LocalTasks.c:409`; untested on HW) | ## Deferred diff --git a/exercise_mcu.py b/exercise_mcu.py index caa80f2..0fb9364 100755 --- a/exercise_mcu.py +++ b/exercise_mcu.py @@ -65,14 +65,32 @@ def _format_error(exc): return " (caused by: ".join(messages) + ")" * (len(messages) - 1) +def _warn(message): + """Print an indented warning line. The marker falls back to ASCII when + stdout can't encode it (e.g. Python 3.6 under a C locale), so reporting a + failure never raises UnicodeEncodeError itself.""" + encoding = getattr(sys.stdout, "encoding", None) + marker = "\u26a0" + if encoding: + try: + marker.encode(encoding) + except (LookupError, UnicodeEncodeError): + marker = "!" + print(f" {marker} {message}") + + def _run_section(title, fn): + """Run one section; return True if it failed (an unadvertised page is not + a failure).""" print(f"\n--- {title} " + "-" * max(0, 50 - len(title))) try: fn() except McuCapabilityUnavailable: print(" not advertised by firmware") except EXPECTED_HARDWARE_ERRORS as exc: - print(f" ⚠ {_format_error(exc)}") + _warn(_format_error(exc)) + return True + return False def exercise_system(mcu): @@ -97,7 +115,7 @@ def go(): print(f" ff present mask: 0x{info.ff_present_mask:08X}") print(f" build type: {info.build_type!r}") print(f" build time: {info.build_time!r}") - _run_section("System (0x00)", go) + return _run_section("System (0x00)", go) def exercise_config(mcu): @@ -108,7 +126,7 @@ def go(): print(f" temp threshold TM4C: {cfg.alarm_temp_tm4c} C") print(f" temp threshold FPGA: {cfg.alarm_temp_fpga} C") print(f" voltage threshold: {cfg.alarm_volt_threshold_percent:.2f} %") - _run_section("Config (0x05)", go) + return _run_section("Config (0x05)", go) def exercise_runtime(mcu): @@ -121,7 +139,7 @@ def go(): print(f" zynqmon transmit: {info.zynqmon_transmit_enabled}") print(f" fpga done: {info.fpga_done!r}") print(f" rtc: {info.rtc.isoformat()}") - _run_section("Runtime (0x06)", go) + return _run_section("Runtime (0x06)", go) def exercise_adc(mcu): @@ -130,7 +148,7 @@ def go(): for reading in snapshot.readings: flag = "" if reading.valid else " (NaN / unpublished)" print(f" {reading.index:2d} {reading.name:<16s} {reading.value:10.4f}{flag}") - _run_section("ADC sample (0x03)", go) + return _run_section("ADC sample (0x03)", go) def exercise_power(mcu): @@ -146,7 +164,7 @@ def go(): print(f" supply count: {snapshot.supply_count}") for i, state in enumerate(snapshot.supply_states): print(f" supply[{i:2d}] = {state!r}") - _run_section("Power (0x01)", go) + return _run_section("Power (0x01)", go) def exercise_alarm(mcu): @@ -159,7 +177,7 @@ def go(): print(f" voltage alarm gen: 0x{snapshot.voltage_alarm_general:02X}") print(f" voltage alarm F1: 0x{snapshot.voltage_alarm_f1:02X}") print(f" voltage alarm F2: 0x{snapshot.voltage_alarm_f2:02X}") - _run_section("Alarm (0x02)", go) + return _run_section("Alarm (0x02)", go) def exercise_persistent_log(mcu): @@ -173,7 +191,7 @@ def go(): entries = mcu.read_persistent_log_entries() nonzero = sum(1 for word in entries if word not in (0x00000000, 0xFFFFFFFF)) print(f" {nonzero}/{len(entries)} entries look non-empty (raw scan, unfiltered)") - _run_section("Persistent log (0x30/0x31, deferred)", go) + return _run_section("Persistent log (0x30/0x31, deferred)", go) def send_control(mcu, command_name, assume_yes): @@ -194,7 +212,7 @@ def send_control(mcu, command_name, assume_yes): mcu.send_control(command) print(f"Sent {command.name}.") except EXPECTED_HARDWARE_ERRORS as exc: - print(f" ⚠ {_format_error(exc)}") + _warn(_format_error(exc)) _CONFIG_WRITE_WARNING = ( @@ -226,7 +244,7 @@ def set_alarm_temp(mcu, device_name, celsius, assume_yes): field = "alarm_temp_" + device.name.lower() print(f"{device.name} threshold: {getattr(before, field)} -> {getattr(after, field)} C") except EXPECTED_HARDWARE_ERRORS as exc: - print(f" ⚠ {_format_error(exc)}") + _warn(_format_error(exc)) def set_volt_threshold(mcu, percent, assume_yes): @@ -239,7 +257,7 @@ def set_volt_threshold(mcu, percent, assume_yes): print("Voltage threshold: {} -> {} %".format( before.alarm_volt_threshold_percent, after.alarm_volt_threshold_percent)) except EXPECTED_HARDWARE_ERRORS as exc: - print(f" ⚠ {_format_error(exc)}") + _warn(_format_error(exc)) def power_cycle(mcu, assume_yes): @@ -259,7 +277,7 @@ def power_cycle(mcu, assume_yes): print("Power inhibited; waiting 15 seconds.") time.sleep(15) except EXPECTED_HARDWARE_ERRORS as exc: - print(f" ⚠ {_format_error(exc)}") + _warn(_format_error(exc)) finally: # A timeout may occur after firmware has accepted ASSERT, so always # attempt RELEASE after issuing it, including on Ctrl-C during sleep. @@ -267,7 +285,7 @@ def power_cycle(mcu, assume_yes): mcu.send_control(McuControlCommand.RELEASE_PROGCOM_POWER_INHIBIT) print("Released ProgCom power inhibit.") except EXPECTED_HARDWARE_ERRORS as exc: - print(f" ⚠ Could not release ProgCom power inhibit: {_format_error(exc)}") + _warn(f"Could not release ProgCom power inhibit: {_format_error(exc)}") def main(): diff --git a/lga80d_checks.py b/lga80d_checks.py new file mode 100644 index 0000000..559e396 --- /dev/null +++ b/lga80d_checks.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +"""Read-only LGA80D checks from apollo_cm_mcu's lga80d-review-2026-10-09.md. + +READ ONLY: every access is a ProgCom "r DC ..." read (PAGE select, then one +PMBus read). Nothing is written, cleared or reconfigured. + +Checks (select with --check; the default runs all three): + + freq Did LGA80D_init take effect? Reads FREQUENCY_SWITCH (33h, the + programmed value), READ_FREQUENCY (95h, the measured value), + VOUT_DROOP (28h) and MULTI_PHASE_RAMP_GAIN (D5h) and compares them + with what LGA80D_init writes. The VCCINT modules have no SYNC strap + and boot at 400 kHz, so 457 kHz there means init took effect. + READ_FREQUENCY is only valid while the output is enabled: run with + the board powered on. + temp READ_TEMPERATURE_1 (8Dh, controller die; what the firmware alarm + and the IPMC use) against READ_TEMPERATURE_3 (8Fh, DrMOS, the + module hotspot per the TRN), with READ_IOUT for context. Run under + FPGA load. + timing The turn-on/off settings behind the firmware's sequencing: + ON_OFF_CONFIG (02h), VOUT_COMMAND (21h), VOUT_UV_FAULT_LIMIT (44h), + POWER_GOOD_ON (5Eh), TON_DELAY/TON_RISE/TOFF_DELAY/TOFF_FALL + (60h/61h/64h/65h), DDC_CONFIG (D3h) and POWER_GOOD_DELAY (D4h). + Derives the ZL8802 minimum EN pulse widths (Eq. 1/2) and the time + from EN to PG, against the firmware's PG check 25 ms after EN. + +Not covered: the scope measurement of EN against PG at turn-off. + +Usage: + lga80d_checks.py # all checks, all modules + lga80d_checks.py --check freq # one check + lga80d_checks.py --check temp --device F1VCCINT2 +""" +import argparse +import os +import sys +import time + +# Permit direct execution from this package directory (as exercise_mcu.py does). +if not __package__: + _package_dir = os.path.dirname(os.path.realpath(__file__)) + _package_parent = os.path.dirname(_package_dir) + if _package_parent not in sys.path: + sys.path.insert(0, _package_parent) + +from cm_interface.core_config import CORE_CONFIG +from cm_interface.device.lga80d import LGA80D +from cm_interface.device.mcu import PowerFsmState +from cm_interface.errors import CMError +from cm_interface.registry import Registry +from cm_interface.utils import decode_linear11, decode_linear16u + +EXPECTED_ERRORS = (CMError, OSError, TimeoutError, ValueError, RuntimeError) + +# ZL8802 "PMBus Use Guidelines": 2 ms between reads and 5 ms before any other +# command to the same device. Every ProgCom read starts with a PAGE write, which +# would otherwise follow the previous read by only the UART round trip. +GAP_S = 0.01 + +# PMBus commands (ZL8802 FN8760 / LGA80D TRN 3.3) +ON_OFF_CONFIG = 0x02 +VOUT_COMMAND = 0x21 +VOUT_DROOP = 0x28 +FREQUENCY_SWITCH = 0x33 +VOUT_UV_FAULT_LIMIT = 0x44 +POWER_GOOD_ON = 0x5E +TON_DELAY = 0x60 +TON_RISE = 0x61 +TOFF_DELAY = 0x64 +TOFF_FALL = 0x65 +STATUS_WORD = 0x79 +READ_VIN = 0x88 +READ_IOUT = 0x8C +READ_TEMPERATURE_1 = 0x8D +READ_TEMPERATURE_3 = 0x8F +READ_FREQUENCY = 0x95 +DDC_CONFIG = 0xD3 +POWER_GOOD_DELAY = 0xD4 +MULTI_PHASE_RAMP_GAIN = 0xD5 + +STATUS_WORD_OFF = 1 << 6 +PAGES = (0, 1) + +# What LGA80D_init (cm_mcu LocalTasks.c) writes to every module except 3V3/1V8. +INIT_SKIPPED = ("3V3/1V8",) +INIT_FREQ_KHZ = 457.14 # FREQUENCY_SWITCH, 16 MHz / 35 +INIT_DROOP = 0.07 # VOUT_DROOP in mV/A (sent as 573 * 2^-13 = 0.06995) +INIT_GAIN = 7 # MULTI_PHASE_RAMP_GAIN +INIT_TOL = 0.02 + +# Expected switching frequency, from the schematic SYNC straps plus LGA80D_init: +# 3V3/1V8 strap 38.3k = 800 kHz; AVTT/CC strap 23.7k = 457 kHz; VCCINT has no +# strap (open = 400 kHz) and reaches 457 kHz only through LGA80D_init. +STRAP_FREQ_KHZ = {"3V3/1V8": 800.0} +DEFAULT_FREQ_KHZ = 457.0 +VCCINT_STRAP_FREQ_KHZ = 400.0 +FREQ_TOL = 0.03 # READ_FREQUENCY has been seen at 457 and 463 kHz + +VCCINT_PAIRS = (("F1VCCINT1", "F1VCCINT2"), ("F2VCCINT1", "F2VCCINT2")) + +# Same plausibility window as cm_mcu MonitorTask.h (LGA80D_TEMP_MIN/MAX_C). +TEMP_MIN_C, TEMP_MAX_C = 0.0, 175.0 + +FW_PG_CHECK_MS = 25.0 # PowerSupplyTask checks PG one 25 ms period after EN +PRE_RAMP_MS = 2.0 # ~2 ms from enable to the start of the output ramp + + +def _format_error(exc): + """Return a compact message that includes useful chained exceptions.""" + messages = [] + seen = set() + current = exc + while current is not None and id(current) not in seen: + seen.add(id(current)) + message = str(current).strip() or type(current).__name__ + if message not in messages: + messages.append(message) + current = current.__cause__ or current.__context__ + return " (caused by: ".join(messages) + ")" * (len(messages) - 1) + + +def _read(lga, command, page, size): + """One PMBus read of the register's real length, then the inter-command gap.""" + try: + return lga.read_reg(LGA80D._paged_reg(command, page), size=size) + finally: + time.sleep(GAP_S) + + +def _u8(lga, command, page=0): + return _read(lga, command, page, 1)[0] + + +def _u16(lga, command, page=0): + return int.from_bytes(_read(lga, command, page, 2), "little") + + +def _l11(lga, command, page=0): + return decode_linear11(_read(lga, command, page, 2)) + + +def _l16u(lga, command, page=0): + return decode_linear16u(_read(lga, command, page, 2)) + + +def _near(value, target, tol): + return abs(value - target) <= tol * abs(target) + + +def _onoff(enabled): + return "/".join("on" if e else "off" for e in enabled) + + +def read_power_state(reg): + try: + state = reg.get_mcu().read_power().fsm_state + print("MCU power state: %s" % state.name) + return state + except EXPECTED_ERRORS as exc: + print("MCU power state: unknown (%s)" % _format_error(exc)) + return None + + +def check_freq(reg, names, power_state): + """FREQUENCY_SWITCH / READ_FREQUENCY / VOUT_DROOP / ramp gain vs LGA80D_init.""" + print("\n=== freq: did LGA80D_init take effect? ===") + if power_state is not None and power_state != PowerFsmState.POWER_ON: + print("WARNING: power is not ON; READ_FREQUENCY is only valid on enabled outputs") + ok = True + results = {} + for name in names: + lga = reg.get_lga80d(name) + try: + enabled = [not (_u16(lga, STATUS_WORD, p) & STATUS_WORD_OFF) for p in PAGES] + prog = _l11(lga, FREQUENCY_SWITCH) # global + meas = _l11(lga, READ_FREQUENCY) # global + droop = [_l11(lga, VOUT_DROOP, p) for p in PAGES] + gain = _u8(lga, MULTI_PHASE_RAMP_GAIN) # global + except EXPECTED_ERRORS as exc: + ok = False + print("%-10s READ FAILED: %s" % (name, _format_error(exc))) + continue + results[name] = (prog, droop, gain) + print("%-10s outputs p0/p1 %-7s FREQUENCY_SWITCH %6.1f kHz READ_FREQUENCY %6.1f kHz " + "VOUT_DROOP %.4f/%.4f mV/A RAMP_GAIN %d" + % (name, _onoff(enabled), prog, meas, droop[0], droop[1], gain)) + + notes = [] + if name not in INIT_SKIPPED: + if not _near(prog, INIT_FREQ_KHZ, INIT_TOL): + notes.append("FREQUENCY_SWITCH is not the %.2f kHz init writes" % INIT_FREQ_KHZ) + for p in PAGES: + if not _near(droop[p], INIT_DROOP, INIT_TOL): + notes.append("page %d VOUT_DROOP is not the %.2f mV/A init writes" + % (p, INIT_DROOP)) + if gain != INIT_GAIN: + notes.append("MULTI_PHASE_RAMP_GAIN is not the %d init writes" % INIT_GAIN) + expected = STRAP_FREQ_KHZ.get(name, DEFAULT_FREQ_KHZ) + if not any(enabled): + notes.append("both outputs off: READ_FREQUENCY not meaningful") + elif _near(meas, expected, FREQ_TOL): + if "VCCINT" in name: + notes.append("switching at ~457 kHz: init took effect") + elif "VCCINT" in name and _near(meas, VCCINT_STRAP_FREQ_KHZ, FREQ_TOL): + notes.append("switching at the 400 kHz no-strap default: init did NOT take effect") + else: + notes.append("READ_FREQUENCY differs from the expected %.0f kHz" % expected) + for note in notes: + print(" -> %s" % note) + + for a, b in VCCINT_PAIRS: + if a in results and b in results: + same = (_near(results[a][0], results[b][0], INIT_TOL) + and all(_near(results[a][1][p], results[b][1][p], INIT_TOL) for p in PAGES) + and results[a][2] == results[b][2]) + print("4-phase pair %s/%s: %s" % (a, b, "consistent" if same else + "MISMATCH in FREQUENCY_SWITCH, VOUT_DROOP or gain")) + return ok + + +def check_temp(reg, names, power_state): + """READ_TEMPERATURE_1 (controller die) against READ_TEMPERATURE_3 (DrMOS).""" + print("\n=== temp: controller die (8Dh) vs DrMOS hotspot (8Fh) ===") + if power_state is not None and power_state != PowerFsmState.POWER_ON: + print("WARNING: power is not ON; the comparison is meant to be made under load") + print("%-10s page TEMP_1 (C) TEMP_3 (C) T3-T1 (C) IOUT (A)" % "module") + ok = True + max_t1 = None + max_t3 = None + for name in names: + lga = reg.get_lga80d(name) + for page in PAGES: + try: + raw1 = _read(lga, READ_TEMPERATURE_1, page, 2) + raw3 = _read(lga, READ_TEMPERATURE_3, page, 2) + iout = _l11(lga, READ_IOUT, page) + except EXPECTED_ERRORS as exc: + ok = False + print("%-10s %d READ FAILED: %s" % (name, page, _format_error(exc))) + continue + t1 = decode_linear11(raw1) + t3 = decode_linear11(raw3) + valid1 = TEMP_MIN_C <= t1 <= TEMP_MAX_C + valid3 = TEMP_MIN_C <= t3 <= TEMP_MAX_C + line = "%-10s %d %8.2f %8.2f %8.2f %7.2f" % (name, page, t1, t3, t3 - t1, iout) + if not valid1: + line += " TEMP_1 implausible (raw 0x%04X)" % int.from_bytes(raw1, "little") + if not valid3: + line += " TEMP_3 implausible (raw 0x%04X)" % int.from_bytes(raw3, "little") + print(line) + if valid1 and (max_t1 is None or t1 > max_t1[0]): + max_t1 = (t1, name, page) + if valid3 and (max_t3 is None or t3 > max_t3[0]): + max_t3 = (t3, name, page) + if max_t1 is not None and max_t3 is not None: + print("hottest TEMP_1 (what the alarm and IPMC see): %.2f C (%s page %d)" % max_t1) + print("hottest TEMP_3 (DrMOS): %.2f C (%s page %d)" % max_t3) + print("difference: %.2f C" % (max_t3[0] - max_t1[0])) + return ok + + +def _on_off_config_text(value): + source = (value >> 2) & 0x7 + sources = {0b101: "EN pin only", 0b110: "OPERATION only"} + text = sources.get(source, "start source 0b{:03b}".format(source)) + off = "immediate off" if value & 0x1 else "soft off (TOFF_FALL)" + return "%s, %s" % (text, off) + + +def check_timing(reg, names): + """Turn-on/off settings, ZL8802 Eq. 1/2 and time to PG vs the 25 ms check.""" + print("\n=== timing: turn-on/off settings vs firmware sequencing ===") + ok = True + for name in names: + lga = reg.get_lga80d(name) + try: + vin = _l11(lga, READ_VIN) # global + freq_khz = _l11(lga, FREQUENCY_SWITCH) # global + gain = _u8(lga, MULTI_PHASE_RAMP_GAIN) # global + except EXPECTED_ERRORS as exc: + ok = False + print("%-10s READ FAILED: %s" % (name, _format_error(exc))) + continue + for page in PAGES: + try: + on_off = _u8(lga, ON_OFF_CONFIG, page) + ddc = _u16(lga, DDC_CONFIG, page) + vout = _l16u(lga, VOUT_COMMAND, page) + pg_on = _l16u(lga, POWER_GOOD_ON, page) + uv = _l16u(lga, VOUT_UV_FAULT_LIMIT, page) + ton_d = _l11(lga, TON_DELAY, page) + ton_r = _l11(lga, TON_RISE, page) + toff_d = _l11(lga, TOFF_DELAY, page) + toff_f = _l11(lga, TOFF_FALL, page) + pg_delay = _l11(lga, POWER_GOOD_DELAY, page) + except EXPECTED_ERRORS as exc: + ok = False + print("%-10s p%d READ FAILED: %s" % (name, page, _format_error(exc))) + continue + + phases = (ddc & 0x7) + 1 + multiphase = phases >= 4 + print("%-10s p%d: DDC_CONFIG 0x%04X (rail %d, phases in rail %d, phase id %d) " + "ON_OFF_CONFIG 0x%02X (%s)" + % (name, page, ddc, (ddc >> 8) & 0x1F, phases, (ddc >> 13) & 0x7, + on_off, _on_off_config_text(on_off))) + print(" TON_DELAY %.2f TON_RISE %.2f TOFF_DELAY %.2f TOFF_FALL %.2f " + "POWER_GOOD_DELAY %.2f ms" % (ton_d, ton_r, toff_d, toff_f, pg_delay)) + if vout > 0: + print(" VOUT_COMMAND %.4f V POWER_GOOD_ON %.4f V (%.0f%%) " + "VOUT_UV_FAULT_LIMIT %.4f V (%.0f%%; PG drops below this)" + % (vout, pg_on, 100.0 * pg_on / vout, uv, 100.0 * uv / vout)) + else: + print(" VOUT_COMMAND %.4f V POWER_GOOD_ON %.4f V VOUT_UV_FAULT_LIMIT %.4f V" + % (vout, pg_on, uv)) + + en_low_min = toff_d + toff_f + 10.5 # ZL8802 Eq. 1 + en_high_min = ton_d + ton_r + pg_delay + 5.5 # ZL8802 Eq. 2 + note = "" + if multiphase: + note = " (4-phase: TOFF_FALL is ignored, Eq. 1 as written)" + print(" EN low min %.1f ms (Eq. 1), EN high min %.1f ms (Eq. 2)%s" + % (en_low_min, en_high_min, note)) + + # 4-phase rails ramp at the MULTI_PHASE_RAMP_GAIN slew rate instead of + # TON_RISE: slew (mV/ms) = 14 * VIN * gain * f(MHz) (ZL8802 Eq. 8). + if multiphase and vin > 0 and gain > 0 and freq_khz > 0: + rise = 1000.0 * vout / (14.0 * vin * gain * freq_khz / 1000.0) + rise_text = "rise %.2f ms from ramp gain %d, VIN %.2f V, %.1f kHz" % ( + rise, gain, vin, freq_khz) + else: + rise = ton_r + rise_text = "TON_RISE %.2f ms" % rise + t_pg = PRE_RAMP_MS + ton_d + rise + pg_delay + print(" PG ~%.1f ms after EN (%.0f ms pre-ramp + TON_DELAY + %s + PG delay); " + "margin %.1f ms to the firmware's %.0f ms PG check" + % (t_pg, PRE_RAMP_MS, rise_text, FW_PG_CHECK_MS - t_pg, FW_PG_CHECK_MS)) + return ok + + +CHECKS = ("freq", "temp", "timing") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--check", action="append", choices=CHECKS, + help="check to run (repeatable; default: all)") + parser.add_argument("--device", action="append", choices=list(CORE_CONFIG.lga80d), + help="LGA80D supply name (repeatable; default: all)") + parser.add_argument("--dev-path", default="/dev/ttyUL4", + help="UART device (default: %(default)s)") + parser.add_argument("--debug", action="store_true", + help="log every UART transaction to stdout") + args = parser.parse_args() + + checks = args.check or list(CHECKS) + names = args.device or list(CORE_CONFIG.lga80d) + reg = Registry(dev_path=args.dev_path, debug=sys.stdout if args.debug else None) + + power_state = read_power_state(reg) + ok = True + if "freq" in checks: + ok = check_freq(reg, names, power_state) and ok + if "temp" in checks: + ok = check_temp(reg, names, power_state) and ok + if "timing" in checks: + ok = check_timing(reg, names) and ok + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lga80d_dump.py b/lga80d_dump.py index a41896e..1ca7417 100644 --- a/lga80d_dump.py +++ b/lga80d_dump.py @@ -22,6 +22,7 @@ lga80d_dump.py F1VCCINT1 lga80d_dump.py F1VCCINT1 --page 0 --include-unconfirmed lga80d_dump.py F1VCCINT1 --snapshot + lga80d_dump.py --all --out-dir DIR """ import argparse import os @@ -157,6 +158,10 @@ def decoded(name, raw): if exponent & 0x10: exponent -= 0x20 return "%s mode, exponent %d" % ("linear" if mode == 0 else "mode %d" % mode, exponent) + if name in ("VOUT_TRIM", "VOUT_CAL_OFFSET"): + # Two's-complement mantissa with a fixed exponent of -13 (ZL8802 and + # LGA80D text; the LGA80D register table's "L16u" for VOUT_TRIM is wrong). + return "%+.3f mV" % (int.from_bytes(raw, "little", signed=True) * 2 ** -13 * 1000) if name in LINEAR16: return "%.4f V" % decode_linear16u(raw) if name in LINEAR11: @@ -177,58 +182,34 @@ def snapshot_lines(lga, page): return lines, True -def board_header(reg, args): - """Return (header lines, board id or None). Never fatal: old firmware or a - missing MCU capability just yields 'unavailable'.""" - lines = [ - "LGA80D register dump %s" % time.strftime("%Y-%m-%d %H:%M:%S"), - " supply: %s (index 0x%02X)" % (args.device, CORE_CONFIG.lga80d[args.device]), - " uart: %s" % args.dev_path, - ] +def board_info(reg): + """Return (board header lines, board id or None). Never fatal: old firmware + or a missing MCU capability just yields 'unavailable'.""" try: info = reg.get_mcu().system_info except Exception as exc: - lines.append(" board id: unavailable (%s)" % exc) - return lines, None - lines.append(" board id: %d" % info.board_id) - lines.append(" hw rev: %d" % info.hardware_revision) - lines.append(" firmware: %s" % info.git_version) - return lines, info.board_id + return [" board id: unavailable (%s)" % exc], None + return [ + " board id: %d" % info.board_id, + " hw rev: %d" % info.hardware_revision, + " firmware: %s" % info.git_version, + ], info.board_id -def main(): - parser = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("device", choices=sorted(CORE_CONFIG.lga80d), - help="LGA80D supply name") - parser.add_argument("--page", type=int, choices=(0, 1), - help="only this output page (default: both)") - parser.add_argument("--include-unconfirmed", action="store_true", - help="also read registers the datasheet doesn't list as supported") - parser.add_argument("--snapshot", action="store_true", - help="also capture and print the 32-byte SNAPSHOT per page " - "(not a pure read; needs firmware with the SN device)") - parser.add_argument("--dev-path", default="/dev/ttyUL4", - help="UART device (default: %(default)s)") - parser.add_argument("--out-dir", metavar="DIR", - help="also write one file per page to DIR, named " - "cm__.txt (existing files are " - "overwritten; board id is 'unknown' if the MCU can't " - "report it)") - args = parser.parse_args() - - reg = Registry(dev_path=args.dev_path) - lga = reg.get_lga80d(args.device) - header, board_id = board_header(reg, args) - registers = load_registers(args.include_unconfirmed) - pages = (0, 1) if args.page is None else (args.page,) - if args.out_dir: - os.makedirs(args.out_dir, exist_ok=True) +def dump_supply(reg, device, args, board_lines, board_id, registers, pages, echo): + """Dump one supply. Prints to stdout if echo; writes files if --out-dir. + Returns True if anything failed.""" + header = [ + "LGA80D register dump %s" % time.strftime("%Y-%m-%d %H:%M:%S"), + " supply: %s (index 0x%02X)" % (device, CORE_CONFIG.lga80d[device]), + " uart: %s" % args.dev_path, + ] + board_lines + lga = reg.get_lga80d(device) failed = False for page in pages: lines = [ "", - "== %s page %d ==" % (args.device, page), + "== %s page %d ==" % (device, page), " %-28s %-4s %-10s %s" % ("REGISTER", "CMD", "RAW", "DECODED"), " %s" % ("-" * 66), ] @@ -247,14 +228,59 @@ def main(): snap_lines, snap_ok = snapshot_lines(lga, page) lines.extend(snap_lines) failed = failed or not snap_ok - print("\n".join((header if page == pages[0] else []) + lines)) + if echo: + print("\n".join((header if page == pages[0] else []) + lines)) if args.out_dir: # Every file carries the header, even for the second page. + # "/" in a supply name (3V3/1V8) would be a directory separator. path = os.path.join(args.out_dir, "cm%s_%s_%d.txt" % ( - "unknown" if board_id is None else board_id, args.device, page)) + "unknown" if board_id is None else board_id, + device.replace("/", "-"), page)) with open(path, "w") as fh: fh.write("\n".join(header + lines) + "\n") - print("wrote %s" % path) + print("wrote %s%s" % (path, " (read failures)" if failed else "")) + return failed + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("device", nargs="?", choices=sorted(CORE_CONFIG.lga80d), + help="LGA80D supply name") + parser.add_argument("--all", action="store_true", + help="dump every LGA80D supply (requires --out-dir; " + "only the 'wrote' lines go to stdout)") + parser.add_argument("--page", type=int, choices=(0, 1), + help="only this output page (default: both)") + parser.add_argument("--include-unconfirmed", action="store_true", + help="also read registers the datasheet doesn't list as supported") + parser.add_argument("--snapshot", action="store_true", + help="also capture and print the 32-byte SNAPSHOT per page " + "(not a pure read; needs firmware with the SN device)") + parser.add_argument("--dev-path", default="/dev/ttyUL4", + help="UART device (default: %(default)s)") + parser.add_argument("--out-dir", metavar="DIR", + help="also write one file per page to DIR, named " + "cm__.txt (existing files are " + "overwritten; board id is 'unknown' if the MCU can't " + "report it)") + args = parser.parse_args() + if args.all == (args.device is not None): + parser.error("give either a supply name or --all") + if args.all and not args.out_dir: + parser.error("--all requires --out-dir") + + reg = Registry(dev_path=args.dev_path) + board_lines, board_id = board_info(reg) + registers = load_registers(args.include_unconfirmed) + pages = (0, 1) if args.page is None else (args.page,) + if args.out_dir: + os.makedirs(args.out_dir, exist_ok=True) + devices = sorted(CORE_CONFIG.lga80d) if args.all else [args.device] + failed = False + for device in devices: + failed = dump_supply(reg, device, args, board_lines, board_id, + registers, pages, echo=not args.all) or failed return 1 if failed else 0 diff --git a/mcu_dump.py b/mcu_dump.py new file mode 100755 index 0000000..e6763c9 --- /dev/null +++ b/mcu_dump.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Dump the command-module MCU's ProgCom register pages (``MC 0``). + +READ ONLY: this runs the same page reads as ``exercise_mcu.py`` with no +arguments (System, Power, Alarm, Config, Runtime, ADC, and the deferred +persistent-log pages as a best effort) and has no write or Control-page +path. Use ``exercise_mcu.py`` for those. + +Uptime, RTC, heap/stack, the Power generation counter and the ADC values +change on every read; exclude them when diffing two dumps. + +Usage: + mcu_dump.py + mcu_dump.py --out-dir DIR +""" +import argparse +import contextlib +import io +import os +import sys +import time + +# Permit direct execution from this package directory (as exercise_mcu.py does). +if not __package__: + _package_dir = os.path.dirname(os.path.realpath(__file__)) + _package_parent = os.path.dirname(_package_dir) + if _package_parent not in sys.path: + sys.path.insert(0, _package_parent) + +from cm_interface.exercise_mcu import ( + exercise_adc, + exercise_alarm, + exercise_config, + exercise_persistent_log, + exercise_power, + exercise_runtime, + exercise_system, +) +from cm_interface.lga80d_dump import board_info +from cm_interface.registry import Registry + +SECTIONS = ( + exercise_system, + exercise_power, + exercise_alarm, + exercise_config, + exercise_runtime, + exercise_adc, + exercise_persistent_log, +) + + +class _CaptureBuffer(io.StringIO): + """StringIO that reports the real stdout's encoding, so the warning + marker exercise_mcu picks for the capture also prints to that stdout.""" + + def __init__(self, encoding): + super().__init__() + self._encoding = encoding + + @property + def encoding(self): + return self._encoding + + +def dump_mcu(reg, args, board_lines, board_id): + """Dump every page. Prints to stdout; also writes a file if --out-dir. + Returns True if any section failed.""" + header = [ + "MCU register dump %s" % time.strftime("%Y-%m-%d %H:%M:%S"), + " uart: %s" % args.dev_path, + ] + board_lines + mcu = reg.get_mcu() + buf = _CaptureBuffer(getattr(sys.stdout, "encoding", None)) + failed = False + with contextlib.redirect_stdout(buf): + for section in SECTIONS: + failed = section(mcu) or failed + text = "\n".join(header) + "\n" + buf.getvalue() + print(text, end="") + if args.out_dir: + path = os.path.join(args.out_dir, "cm%s_mcu.txt" % ( + "unknown" if board_id is None else board_id)) + # utf-8 explicitly: the warning marker may be non-ASCII, and Python + # 3.6 under a C locale would otherwise default to ASCII. + with open(path, "w", encoding="utf-8") as fh: + fh.write(text) + print("wrote %s%s" % (path, " (read failures)" if failed else "")) + return failed + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--dev-path", default="/dev/ttyUL4", + help="UART device (default: %(default)s)") + parser.add_argument("--out-dir", metavar="DIR", + help="also write the dump to DIR, named cm_mcu.txt " + "(an existing file is overwritten; board id is " + "'unknown' if the MCU can't report it)") + args = parser.parse_args() + + reg = Registry(dev_path=args.dev_path) + board_lines, board_id = board_info(reg) + if args.out_dir: + os.makedirs(args.out_dir, exist_ok=True) + return 1 if dump_mcu(reg, args, board_lines, board_id) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/si5395_checks.py b/si5395_checks.py new file mode 100644 index 0000000..df73f64 --- /dev/null +++ b/si5395_checks.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""Read-only Si5395 clock register checks. + +READ ONLY: every access is a ProgCom "r CL ..." read. Nothing is written; +sticky flags are reported but not cleared. + +DEVICE_READY (0x00FE) is read first. Until it reads 0x0F the reference +manual permits no other register access, so a clock that is not ready is +reported and skipped. + +Checks (select with --check; the default runs all three): + + id DEVICE_ID (0x0002, expect 0x5395), GRADE, DEVICE_REV, TEMP_GRADE, + PKG_ID, DESIGN_ID (0x026B, CBPro string) and ACTIVE_NVM_BANK + (0x00E2: how many NVM banks the user has burned). + status Live status 0x000C-0x000F (internal errors, LOS/OOF per input, + LOL, holdover, calibration), the active input IN_ACTV (0x0507), + input selection mode (0x052A) and the global output enables + OUTALL_DISABLE_LOW (0x0102) and OUT_PDN_ALL (0x0145). + flags Sticky flags 0x0011-0x0014: anything that happened since the + flags were last cleared. + +Expected alarms. Before judging status or flags, the configuration that +decides what an alarm means is read: IN_EN (0x0949), LOS_EN (0x002C), +OOF_EN/FAST_OOF_EN (0x003F), LOL_SLOW_EN_PLL (0x009A), FORCE_HOLD (0x0535) +and LOSXAXB_DIS (0x002C). An alarm is shown but does not fail the check when +the configuration explains it: + LOS/OOF input disabled, its detector disabled, or not the active input + (or the device is in free run, so no input is used) + LOL free run (FORCE_HOLD=1 or no input enabled): nothing to lock to; + or LOL detection disabled (LOL_SLOW_EN_PLL=0) + HOLD FORCE_HOLD=1, or no input enabled (free run) + LOSXAXB LOSXAXB_DIS=1 +SYSINCAL_FLG, LOSXAXB_FLG, XAXB_ERR_FLG and CAL_FLG_PLL also latch while +the device calibrates and the crystal starts after power-up or reset, so on a +board whose flags were never cleared they are noted as such but still fail; +clear the flags and re-run to tell a power-up artifact from a real event. + +Usage: + si5395_checks.py # all checks, all clocks + si5395_checks.py --check status # one check + si5395_checks.py --check flags --device R0A +""" +import argparse +import os +import sys +from collections import namedtuple + +# Permit direct execution from this package directory (as exercise_mcu.py does). +if not __package__: + _package_dir = os.path.dirname(os.path.realpath(__file__)) + _package_parent = os.path.dirname(_package_dir) + if _package_parent not in sys.path: + sys.path.insert(0, _package_parent) + +from cm_interface.core_config import CORE_CONFIG +from cm_interface.device.si5395 import Si5395Reg +from cm_interface.errors import CMError +from cm_interface.registry import Registry + +EXPECTED_ERRORS = (CMError, OSError, TimeoutError, ValueError, RuntimeError) + +EXPECTED_DEVICE_ID = 0x5395 +DEVICE_READY_VALUE = 0x0F +IN_ACTV = 0x0507 # bits 7:6, currently selected DSPLL input (read only) +LOS_EN = 0x002C # bits 3:0 LOS_EN per input, bit 4 LOSXAXB_DIS +OOF_EN = 0x003F # bits 3:0 OOF_EN, bits 7:4 FAST_OOF_EN per input +LOL_SLOW_EN = 0x009A # bit 1 LOL_SLOW_EN_PLL +FORCE_HOLD = 0x0535 # bit 0 +IN_EN = 0x0949 # bits 3:0 input buffer enable per input + +# ACTIVE_NVM_BANK (0x00E2), Si5395 reference manual table 16.62. +NVM_BANKS = {0x03: "no user NVM burn", 0x0F: "1 bank burned", 0x3F: "2 banks burned (last bank used)"} + + +def _format_error(exc): + """Return a compact message that includes useful chained exceptions.""" + messages = [] + seen = set() + current = exc + while current is not None and id(current) not in seen: + seen.add(id(current)) + message = str(current).strip() or type(current).__name__ + if message not in messages: + messages.append(message) + current = current.__cause__ or current.__context__ + return " (caused by: ".join(messages) + ")" * (len(messages) - 1) + + +def _u8(clock, reg): + return clock.read_reg(reg)[0] + + +def _bit(value, n): + return bool(value & (1 << n)) + + +def _inputs(mask): + """Names of the inputs IN0-IN3 whose bit is set in a 4-bit mask.""" + names = ["IN%d" % i for i in range(4) if mask & (1 << i)] + return ",".join(names) if names else "none" + + +def _letter(value): + """GRADE / DEVICE_REV encoding: 0 = A, 1 = B, ...""" + return chr(ord("A") + value) if 0 <= value < 26 else "?" + + +class Monitors(namedtuple("Monitors", "in_en los_en oof_en losxaxb_dis lol_en " + "force_hold in_actv")): + """Configuration that decides whether a status bit is an alarm.""" + + @property + def free_run(self): + return self.force_hold or not self.in_en + + def free_run_reason(self): + return "FORCE_HOLD=1" if self.force_hold else "no input enabled (IN_EN=0)" + + def describe(self): + if self.free_run: + return "free run / forced holdover from XAXB (%s)" % self.free_run_reason() + return "locking to IN%d (inputs enabled: %s)" % (self.in_actv, _inputs(self.in_en)) + + def input_reason(self, kind, n): + """Why LOS/OOF on input n is expected, or None when it is a real alarm.""" + if not self.in_en & (1 << n): + return "IN%d disabled" % n + detector = self.los_en if kind == "LOS" else self.oof_en + if not detector & (1 << n): + return "%s detection off for IN%d" % (kind, n) + if self.free_run: + return "free run, no input used" + if n != self.in_actv: + return "IN%d not the active input" % n + return None + + def lol_reason(self): + if self.free_run: + return "free run with %s, nothing to lock to" % self.free_run_reason() + if not self.lol_en: + return "LOL detection off (LOL_SLOW_EN_PLL=0)" + return None + + def hold_reason(self): + return "free run with %s" % self.free_run_reason() if self.free_run else None + + def losxaxb_reason(self): + return "XAXB LOS detection off (LOSXAXB_DIS=1)" if self.losxaxb_dis else None + + +def read_monitors(clock): + los_en = _u8(clock, LOS_EN) + oof_en = _u8(clock, OOF_EN) + return Monitors( + in_en=_u8(clock, IN_EN) & 0xF, + los_en=los_en & 0xF, + oof_en=(oof_en | (oof_en >> 4)) & 0xF, + losxaxb_dis=_bit(los_en, 4), + lol_en=_bit(_u8(clock, LOL_SLOW_EN), 1), + force_hold=_bit(_u8(clock, FORCE_HOLD), 0), + in_actv=(_u8(clock, IN_ACTV) >> 6) & 0x3, + ) + + +def classify(mon, sysincal, los_oof, lol_hold, cal_pll, sticky=False): + """Split the set bits of the four status (or sticky flag) bytes into + (problems, expected); expected entries carry the reason.""" + sfx = "_FLG" if sticky else "" + problems, expected = [], [] + + def add(name, reason): + (expected if reason else problems).append( + "%s (%s)" % (name, reason) if reason else name) + + if _bit(sysincal, 0): + add("SYSINCAL" + sfx, None) + if _bit(sysincal, 1): + add("LOSXAXB" + sfx, mon.losxaxb_reason()) + if _bit(sysincal, 3): + add("XAXB_ERR" + sfx, None) + if _bit(sysincal, 5): + add("SMBUS_TIMEOUT" + sfx, None) + for n in range(4): + if _bit(los_oof, n): + add("LOS%s IN%d" % (sfx, n), mon.input_reason("LOS", n)) + if _bit(los_oof, n + 4): + add("OOF%s IN%d" % (sfx, n), mon.input_reason("OOF", n)) + if _bit(lol_hold, 1): + add("LOL" + sfx, mon.lol_reason()) + if _bit(lol_hold, 5): + add("HOLD" + sfx, mon.hold_reason()) + if _bit(cal_pll, 5): + add("CAL_PLL" if not sticky else "CAL_FLG_PLL", None) + return problems, expected + + +def check_ready(clock): + """Read only DEVICE_READY; return True when the clock may be accessed further.""" + ready = _u8(clock, Si5395Reg.DEVICE_READY) + if ready != DEVICE_READY_VALUE: + print("%-4s NOT READY: DEVICE_READY 0x%02X (expect 0x%02X); skipping further reads" + % (clock.name, ready, DEVICE_READY_VALUE)) + return False + return True + + +def check_id(clock, mon=None): + """Identity and NVM state. Returns True when nothing is unexpected.""" + device_id = clock.get_device_id() + grade = clock.grade + rev = clock.device_revision + temp_grade = clock.temperature_grade + pkg = clock.package_id + design_id = clock.design_id + nvm = _u8(clock, Si5395Reg.ACTIVE_NVM_BANK) + print("%-4s id: DEVICE_ID 0x%04X part Si%04X%s-%s TEMP_GRADE %d PKG_ID %d " + "DESIGN_ID %r ACTIVE_NVM_BANK 0x%02X (%s)" + % (clock.name, device_id, device_id, _letter(grade), _letter(rev), temp_grade, + pkg, design_id, nvm, NVM_BANKS.get(nvm, "unexpected value"))) + ok = True + if device_id != EXPECTED_DEVICE_ID: + print(" -> DEVICE_ID is not 0x%04X" % EXPECTED_DEVICE_ID) + ok = False + if nvm not in NVM_BANKS: + print(" -> ACTIVE_NVM_BANK is not one of the documented values") + ok = False + return ok + + +def check_status(clock, mon): + """Live (non-sticky) status. Returns True when the clock is healthy.""" + sysincal, los_oof, lol_hold, cal_pll = clock.read_reg(Si5395Reg.SYSINCAL, size=4) + in_sel = _u8(clock, Si5395Reg.IN_SEL_REGCTRL) + outall = _u8(clock, Si5395Reg.OUTALL_DISABLE) + pdn_all = _u8(clock, Si5395Reg.OUT_PDN_ALL) + + lol = _bit(lol_hold, 1) + hold = _bit(lol_hold, 5) + print("%-4s status: raw 0x000C-0x000F = %02X %02X %02X %02X %s%s" + % (clock.name, sysincal, los_oof, lol_hold, cal_pll, + "UNLOCKED" if lol else "locked", ", HOLDOVER/free run" if hold else "")) + print(" mode: %s" % mon.describe()) + print(" LOS %s OOF %s active input IN%d selection %s (IN_SEL IN%d)" + % (_inputs(los_oof & 0xF), _inputs(los_oof >> 4), mon.in_actv, + "register" if _bit(in_sel, 0) else "pin", (in_sel >> 1) & 0x3)) + print(" outputs: OUTALL_DISABLE_LOW %d (%s) OUT_PDN_ALL %d (%s)" + % (outall & 1, "enabled" if _bit(outall, 0) else "ALL DISABLED", + pdn_all & 1, "ALL POWERED DOWN" if _bit(pdn_all, 0) else "no effect")) + + problems, expected = classify(mon, sysincal, los_oof, lol_hold, cal_pll) + if not _bit(outall, 0): + problems.append("all outputs disabled (OUTALL_DISABLE_LOW = 0)") + if _bit(pdn_all, 0): + problems.append("all output drivers powered down (OUT_PDN_ALL = 1)") + for item in expected: + print(" expected: %s" % item) + for problem in problems: + print(" -> %s" % problem) + return not problems + + +# Sticky flags that also latch during power-up calibration / crystal start. +POWER_UP_FLAGS = ("SYSINCAL_FLG", "LOSXAXB_FLG", "XAXB_ERR_FLG", "CAL_FLG_PLL") + + +def check_flags(clock, mon): + """Sticky flags 0x0011-0x0014. Returns True when no unexplained flag is set.""" + sys_flg, los_oof_flg, lol_hold_flg, cal_flg = clock.sticky_flags + print("%-4s flags: raw 0x0011-0x0014 = %02X %02X %02X %02X" + % (clock.name, sys_flg, los_oof_flg, lol_hold_flg, cal_flg)) + problems, expected = classify(mon, sys_flg, los_oof_flg, lol_hold_flg, cal_flg, + sticky=True) + for item in expected: + print(" expected: %s" % item) + for flag in problems: + note = (" (also latched at power-up/reset; clear and re-run to confirm)" + if flag.split()[0] in POWER_UP_FLAGS else "") + print(" -> %s%s" % (flag, note)) + if problems or expected: + print(" (sticky since last clear; this script does not clear them)") + return not problems + + +def check_clock(clock, checks): + """Run the selected checks on one clock. Returns True when all pass.""" + name = clock.name + try: + if not check_ready(clock): + return False + except EXPECTED_ERRORS as exc: + print("%-4s READ FAILED: %s" % (name, _format_error(exc))) + return False + mon = None + if set(checks) & {"status", "flags"}: + try: + mon = read_monitors(clock) + except EXPECTED_ERRORS as exc: + print("%-4s config READ FAILED: %s" % (name, _format_error(exc))) + return False + ok = True + for check in checks: + try: + ok = CHECK_FUNCS[check](clock, mon) and ok + except EXPECTED_ERRORS as exc: + print("%-4s %s READ FAILED: %s" % (name, check, _format_error(exc))) + ok = False + return ok + + +CHECKS = ("id", "status", "flags") +CHECK_FUNCS = {"id": check_id, "status": check_status, "flags": check_flags} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--check", action="append", choices=CHECKS, + help="check to run (repeatable; default: all)") + parser.add_argument("--device", action="append", choices=list(CORE_CONFIG.clocks), + help="clock name (repeatable; default: all)") + parser.add_argument("--dev-path", default="/dev/ttyUL4", + help="UART device (default: %(default)s)") + parser.add_argument("--debug", action="store_true", + help="log every UART transaction to stdout") + args = parser.parse_args() + + checks = [c for c in CHECKS if c in (args.check or CHECKS)] + names = args.device or list(CORE_CONFIG.clocks) + reg = Registry(dev_path=args.dev_path, debug=sys.stdout if args.debug else None) + + ok = True + for name in names: + print() + clock_ok = check_clock(reg.get_clock(name), checks) + print("%-4s result: %s" % (name, "OK" if clock_ok else "PROBLEMS FOUND")) + ok = clock_ok and ok + print("\nresult: %s" % ("OK" if ok else "PROBLEMS FOUND")) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/si5395_dump.py b/si5395_dump.py new file mode 100644 index 0000000..e67ce62 --- /dev/null +++ b/si5395_dump.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +"""Summary dump of one Si5395 clock synthesizer. + +READ ONLY: every access is a ProgCom "r CL ..." read; nothing is written and +sticky flags are not cleared. Field layout follows the Si5395/94/92 Reference +Manual Rev 1.3, chapter 16. + +DEVICE_READY (0x00FE) is read first. Until it reads 0x0F the reference +manual permits no other register access, so a clock that is not ready is +reported and skipped. + +Output, per clock: + status live status, sticky flags, monitor enables, NVM bank, global + output enables and input/reference selection; one line per + register, NAME=value(decoded) per bit field. + outputs one row per output driver (0x0103-0x013D) with its R divider + (0x0247-0x026A). R = (R_REG+1)*2, or 2 when RDIV_FORCE2 is set. + dividers P0-P3, MXAXB, M (feedback ratio = 5 * M_NUM/M_DEN, /5 prescaler) + and N0-N4 with the per-N bits from page A. Fout = Fvco / (N * R); + N_ADD_0P5 is shown but not applied, the manual does not define it. + +CBPro-computed tuning (LOS/OOF/LOL thresholds and windows, holdover, +fastlock, loop bandwidth codes, phase adjust) is not read. + +Usage: + si5395_dump.py R0A + si5395_dump.py --all --out-dir DIR +""" +import argparse +import os +import sys +import time + +# Permit direct execution from this package directory (as exercise_mcu.py does). +if not __package__: + _package_dir = os.path.dirname(os.path.realpath(__file__)) + _package_parent = os.path.dirname(_package_dir) + if _package_parent not in sys.path: + sys.path.insert(0, _package_parent) + +from cm_interface.core_config import CORE_CONFIG +from cm_interface.device.si5395 import Si5395Reg +from cm_interface.lga80d_dump import board_info +from cm_interface.registry import Registry + +DEVICE_READY_VALUE = 0x0F +MAX_READ_BYTES = 4 + +# ---------------------------------------------------------------- decoders + +def _letter(v): + return chr(ord("A") + v) if 0 <= v < 26 else "?" + + +def _inputs(v): + names = ["IN%d" % i for i in range(4) if v & (1 << i)] + return ",".join(names) if names else "none" + + +def _enum(*names): + return lambda v: names[v] if v < len(names) and names[v] else "rsvd" + + +def _ascii(raw): + return repr(bytes(raw).rstrip(b"\x00 ").decode("ascii", "replace")) + + +PRIORITY = _enum("none", "1", "2", "3", "4") +IN_NUM = _enum("IN0", "IN1", "IN2", "IN3") + +# field name -> decoder(value) returning a short string shown in parentheses. +FIELD_DECODE = { + "LOS": _inputs, "OOF": _inputs, "LOS_FLG": _inputs, "OOF_FLG": _inputs, + "LOS_EN": _inputs, "OOF_EN": _inputs, "FAST_OOF_EN": _inputs, + "IN_EN": _inputs, "IN_PULSED_CMOS_EN": _inputs, + "ACTIVE_NVM_BANK": lambda v: {0x03: "no user burn", 0x0F: "1 bank burned", + 0x3F: "2 banks burned, none left"}.get(v, "undocumented"), + "DEVICE_READY": lambda v: "ready" if v == DEVICE_READY_VALUE else "NOT ready", + "OUTALL_DISABLE_LOW": _enum("all outputs disabled", "pass OEs"), + "OUT_PDN_ALL": _enum("no effect", "all drivers powered down"), + "PXAXB": lambda v: "/%d" % (1 << v), + "XAXB_EXTCLK_EN": _enum("crystal", "external clock"), + "ZDM_IN_SEL": _enum("IN0", "IN1", "IN2"), + "IN_ACTV": IN_NUM, + "IN_SEL_REGCTRL": _enum("pin", "register"), + "IN_SEL": IN_NUM, + "CLK_SWTCH_MODE": _enum("manual", "auto non-revertive", "auto revertive"), + "HSW_EN": _enum("glitchless", "hitless"), + "IN0_PRIORITY": PRIORITY, "IN1_PRIORITY": PRIORITY, + "IN2_PRIORITY": PRIORITY, "IN3_PRIORITY": PRIORITY, + "HOLD_EN": _enum("off", "on"), +} + +# ---------------------------------------------------------------- register map +# Summary registers: (addr, "FIELD:hi:lo FIELD:bit ..."). The output and +# divider tables below cover pages 1-3 and A separately. Everything else in +# the manual (LOS/OOF/LOL thresholds and windows, holdover tuning, fastlock, +# phase adjust) is CBPro-computed and deliberately left out. + +REGS = [ + # live status and sticky flags + (0x000C, "SYSINCAL:0 LOSXAXB:1 XAXB_ERR:3 SMBUS_TIMEOUT:5"), + (0x000D, "LOS:3:0 OOF:7:4"), + (0x000E, "LOL:1 HOLD:5"), + (0x000F, "CAL_PLL:5"), + (0x0011, "SYSINCAL_FLG:0 LOSXAXB_FLG:1 XAXB_ERR_FLG:3 SMBUS_TIMEOUT_FLG:5"), + (0x0012, "LOS_FLG:3:0 OOF_FLG:7:4"), + (0x0013, "LOL_FLG:1 HOLD_FLG:5"), + (0x0014, "CAL_FLG_PLL:5"), + # monitors enabled + (0x002C, "LOS_EN:3:0 LOSXAXB_DIS:4"), + (0x003F, "OOF_EN:3:0 FAST_OOF_EN:7:4"), + (0x009A, "LOL_SLOW_EN_PLL:1"), + # NVM, ready + (0x00E2, "ACTIVE_NVM_BANK:7:0"), + (0x00FE, "DEVICE_READY:7:0"), + # global output control + (0x0102, "OUTALL_DISABLE_LOW:0"), + (0x0145, "OUT_PDN_ALL:0"), + # reference and input selection + (0x0206, "PXAXB:1:0"), + (0x090E, "XAXB_EXTCLK_EN:0"), + (0x0949, "IN_EN:3:0 IN_PULSED_CMOS_EN:7:4"), + (0x0487, "ZDM_EN:0 ZDM_IN_SEL:2:1"), + (0x0507, "IN_ACTV:7:6"), + (0x052A, "IN_SEL_REGCTRL:0 IN_SEL:2:1"), + (0x0536, "CLK_SWTCH_MODE:1:0 HSW_EN:2"), + (0x0538, "IN0_PRIORITY:2:0 IN1_PRIORITY:6:4"), + (0x0539, "IN2_PRIORITY:2:0 IN3_PRIORITY:6:4"), + (0x052C, "HOLD_EN:0"), + (0x0535, "FORCE_HOLD:0"), +] + +IDENT = (0x0002, 4) # PN_BASE (2 bytes), GRADE, DEVICE_REV +DESIGN_ID = (0x026B, 8) +OPN_ID = (0x0278, 5) + +# Output drivers: name -> (first of 4 config regs, R divider register). +OUTPUTS = [ + ("OUT0A", 0x0103, 0x0247), ("OUT0", 0x0108, 0x024A), ("OUT1", 0x010D, 0x024D), + ("OUT2", 0x0112, 0x0250), ("OUT3", 0x0117, 0x0253), ("OUT4", 0x011C, 0x0256), + ("OUT5", 0x0121, 0x0259), ("OUT6", 0x0126, 0x025C), ("OUT7", 0x012B, 0x025F), + ("OUT8", 0x0130, 0x0262), ("OUT9", 0x0135, 0x0265), ("OUT9A", 0x013A, 0x0268), +] +OUT_FORMAT = {1: "diff", 2: "diff-LP", 4: "LVCMOS"} +OUT_VDD = {0: "3.3V", 1: "1.8V", 2: "2.5V"} + +# Dividers: (name, num addr, num bytes, den addr, frac/mode register or None) +P_DIVS = [("P%d" % i, 0x0208 + 10 * i, 6, 0x020E + 10 * i, 0x0231 + i) for i in range(4)] +MXAXB = ("MXAXB", 0x0235, 6, 0x023B, None) +M_DIV = ("M", 0x0515, 7, 0x051C, 0x0521) +# N0-N4: numerator, denominator, frequency step word, HIGH_FREQ register. +N_DIVS = [("N%d" % i, 0x0302 + 11 * i, 0x0308 + 11 * i, 0x033B + 6 * i, 0x0A14 + 6 * i) + for i in range(5)] +N_BITS = (("PIBYP", 0x0A04), ("PDNB", 0x0A05), ("TO_OUT", 0x0A03), ("ADD0P5", 0x0A02), + ("FSTEP_MSK", 0x0339), ("CLK_DIS", 0x0B4A)) + + +def _parse_fields(spec): + fields = [] + for item in spec.split(): + parts = item.split(":") + hi = int(parts[1]) + lo = int(parts[2]) if len(parts) > 2 else hi + fields.append((parts[0], hi, lo)) + return fields + + +def addresses(): + """Every register address the dump reads (sorted).""" + addrs = set() + for addr, _ in REGS: + addrs.add(addr) + for addr, nbytes in (IDENT, DESIGN_ID, OPN_ID): + addrs.update(range(addr, addr + nbytes)) + for _, cfg, rdiv in OUTPUTS: + addrs.update(range(cfg, cfg + 4)) + addrs.update(range(rdiv, rdiv + 3)) + for _, num, nnum, den, frac in P_DIVS + [MXAXB, M_DIV]: + addrs.update(range(num, num + nnum)) + addrs.update(range(den, den + 4)) + if frac is not None: + addrs.add(frac) + for _, num, den, step, high in N_DIVS: + addrs.update(range(num, num + 6)) + addrs.update(range(den, den + 4)) + addrs.update(range(step, step + 6)) + addrs.add(high) + addrs.update(addr for _, addr in N_BITS) + return sorted(addrs) + + +def read_all(clock, addrs): + """Read addrs in runs of up to 4 contiguous bytes within one page. + Returns ({addr: byte}, [(addr, error)]); a failed run is recorded, not raised.""" + values, errors = {}, [] + i = 0 + while i < len(addrs): + start = addrs[i] + n = 1 + while (i + n < len(addrs) and n < MAX_READ_BYTES and addrs[i + n] == start + n + and (start + n) >> 8 == start >> 8): + n += 1 + try: + data = clock.read_reg(start, size=n) + for k in range(n): + values[start + k] = data[k] + except Exception as exc: # keep going; one bad read shouldn't hide the rest + errors.append((start, exc)) + i += n + return values, errors + + +class Values(object): + """Byte cache with little-endian multi-byte access; None if any byte is missing.""" + + def __init__(self, values): + self.values = values + + def u8(self, addr): + return self.values.get(addr) + + def word(self, addr, nbytes, bits=None): + raw = [self.values.get(a) for a in range(addr, addr + nbytes)] + if None in raw: + return None + value = int.from_bytes(bytes(raw), "little") + return value & ((1 << bits) - 1) if bits else value + + def raw(self, addr, nbytes): + raw = [self.values.get(a) for a in range(addr, addr + nbytes)] + return None if None in raw else bytes(raw) + + +def _fmt_field(name, value): + decode = FIELD_DECODE.get(name) + return "%s=%d%s" % (name, value, "(%s)" % decode(value) if decode else "") + + +def _ratio(num, den): + if num is None or den is None: + return "?" + if den == 0: + return "den=0" + return "%.9f" % (num / den) + + +def _bits(v, n=5): + """Per-divider bitmask as a string, divider 0 first.""" + return "?" * n if v is None else "".join("1" if v & (1 << i) else "0" for i in range(n)) + + +def identity_lines(vals): + pn = vals.word(0x0002, 2) + grade, rev = vals.u8(0x0004), vals.u8(0x0005) + design = vals.raw(*DESIGN_ID) + opn = vals.raw(*OPN_ID) + part = "?" if None in (pn, grade, rev) else "Si%04X%s-%s" % (pn, _letter(grade), _letter(rev)) + return [" part: %s OPN_ID %s DESIGN_ID %s" % ( + part, "?" if opn is None else _ascii(opn), "?" if design is None else _ascii(design))] + + +def output_lines(vals): + lines = ["", " -- outputs (0x0103-0x013D, R dividers 0x0247-0x026A) --", + " %-6s %3s %2s %-7s %4s %3s %4s %4s %3s %3s %-4s %4s %3s %8s %12s" % ( + "OUTPUT", "PDN", "OE", "FORMAT", "SYNC", "DIS", "CDRV", "AMPL", "CM", + "MUX", "VDD", "VSEL", "INV", "R", "N*R")] + n_ratio = {} + for name, num, den, _, _ in N_DIVS: + n, d = vals.word(num, 6, 44), vals.word(den, 4) + n_ratio[name] = None if n is None or d is None or d == 0 else n / d + for name, cfg, rdiv in OUTPUTS: + r0, r1, r2, r3 = (vals.u8(cfg + k) for k in range(4)) + rreg = vals.word(rdiv, 3) + if None in (r0, r1, r2, r3, rreg): + lines.append(" %-6s READ FAILED" % name) + continue + if r0 & 0x04: + r = 2 + rtxt = "2(F2)" + elif rreg == 0: + r = None + rtxt = "REG=0?" + else: + r = (rreg + 1) * 2 + rtxt = str(r) + mux = r3 & 0x07 + src = "N%d" % mux if mux <= 4 else "rsvd" + nr = n_ratio.get(src) + lines.append(" %-6s %3d %2d %-7s %4d %3s %4d %4d %3d %3s %-4s %4d %3d %8s %12s" % ( + name, r0 & 1, (r0 >> 1) & 1, OUT_FORMAT.get(r1 & 7, "rsvd%d" % (r1 & 7)), + (r1 >> 3) & 1, "LH??"[(r1 >> 4) & 3], (r1 >> 6) & 3, (r2 >> 4) & 7, r2 & 0xF, + src, OUT_VDD.get((r3 >> 4) & 3, "rsvd"), (r3 >> 3) & 1, (r3 >> 6) & 3, rtxt, + "?" if nr is None or r is None else "%.6f" % (nr * r))) + return lines + + +def divider_lines(vals): + lines = ["", " -- dividers P0-P3, MXAXB, M (0x0208-0x023E, 0x0515-0x0521) --", + " %-6s %18s %12s %18s %s" % ("DIV", "NUM", "DEN", "NUM/DEN", "FRAC")] + for name, num, nnum, den, frac in P_DIVS + [MXAXB, M_DIV]: + n, d = vals.word(num, nnum), vals.word(den, 4) + extra = "" + if frac is not None: + f = vals.u8(frac) + extra = "?" if f is None else "FRAC_EN=%d FRACN_MODE=0x%X" % ((f >> 4) & 1, f & 0xF) + if name == "M" and n is not None and d: + extra += " feedback 5*M = %.9f" % (5 * n / d) + lines.append(" %-6s %18s %12s %18s %s" % ( + name, "?" if n is None else n, "?" if d is None else d, _ratio(n, d), extra)) + + bits = dict((label, vals.u8(addr)) for label, addr in N_BITS) + lines += ["", " -- N dividers (0x0302-0x0358, page A bits 0x0A02-0x0A2C, 0x0B4A) --", + " %-3s %15s %12s %16s %5s %4s %6s %6s %5s %5s %7s %15s" % ( + "N", "NUM", "DEN", "NUM/DEN", "INT", "PDNB", "TO_OUT", "ADD0P5", + "HIGHF", "FSMSK", "CLK_DIS", "FSTEPW")] + for i, (name, num, den, step, high) in enumerate(N_DIVS): + n, d = vals.word(num, 6, 44), vals.word(den, 4) + h = vals.u8(high) + + def bit(label): + v = bits[label] + return "?" if v is None else (v >> i) & 1 + + fstep = vals.word(step, 6, 44) + lines.append(" %-3s %15s %12s %16s %5s %4s %6s %6s %5s %5s %7s %15s" % ( + name, "?" if n is None else n, "?" if d is None else d, _ratio(n, d), + bit("PIBYP"), bit("PDNB"), bit("TO_OUT"), bit("ADD0P5"), + "?" if h is None else (h >> 3) & 1, bit("FSTEP_MSK"), bit("CLK_DIS"), + "?" if fstep is None else fstep)) + return lines + + +def field_lines(vals): + lines = ["", " -- status and configuration --", " %-7s %-4s %s" % ("ADDR", "RAW", "FIELDS")] + for addr, spec in sorted(REGS): + v = vals.u8(addr) + if v is None: + lines.append(" 0x%04X READ FAILED %s" % (addr, spec)) + continue + text = " ".join(_fmt_field(name, (v >> lo) & ((1 << (hi - lo + 1)) - 1)) + for name, hi, lo in _parse_fields(spec)) + lines.append(" 0x%04X 0x%02X %s" % (addr, v, text)) + return lines + + +def dump_clock(reg, name, args, board_lines, board_id, echo): + """Dump one clock. Prints to stdout if echo; writes a file if --out-dir. + Returns True if anything failed.""" + clock = reg.get_clock(name) + lines = [ + "Si5395 register dump %s" % time.strftime("%Y-%m-%d %H:%M:%S"), + " clock: %s (address 0x%02X)" % (name, CORE_CONFIG.clocks[name]), + " uart: %s" % args.dev_path, + ] + board_lines + failed = False + try: + ready = clock.read_reg(Si5395Reg.DEVICE_READY)[0] + except Exception as exc: + ready = None + lines.append(" DEVICE_READY READ FAILED: %s" % exc) + if ready is not None and ready != DEVICE_READY_VALUE: + lines.append(" NOT READY: DEVICE_READY 0x%02X (expect 0x%02X); no further reads" + % (ready, DEVICE_READY_VALUE)) + if ready != DEVICE_READY_VALUE: + failed = True + result = "READ FAILED" if ready is None else "NOT READY" + else: + values, errors = read_all(clock, addresses()) + vals = Values(values) + lines += identity_lines(vals) + for addr, exc in errors: + lines.append(" READ FAILED at 0x%04X: %s" % (addr, exc)) + failed = bool(errors) + result = "READ FAILED (%d)" % len(errors) if errors else "OK" + lines += field_lines(vals) + output_lines(vals) + divider_lines(vals) + result_line = "%-4s result: %s" % (name, result) + lines.append(result_line) + if echo: + print("\n".join(lines)) + if args.out_dir: + path = os.path.join(args.out_dir, "cm%s_%s.txt" % ( + "unknown" if board_id is None else board_id, name)) + with open(path, "w") as fh: + fh.write("\n".join(lines) + "\n") + print("wrote %s" % path) + if not echo: + print(result_line) + return failed + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("device", nargs="?", choices=list(CORE_CONFIG.clocks), + help="clock name") + parser.add_argument("--all", action="store_true", + help="dump every clock (requires --out-dir; only the " + "'wrote' lines go to stdout)") + parser.add_argument("--dev-path", default="/dev/ttyUL4", + help="UART device (default: %(default)s)") + parser.add_argument("--out-dir", metavar="DIR", + help="also write one file per clock to DIR, named " + "cm_.txt (existing files are overwritten; " + "board id is 'unknown' if the MCU can't report it)") + args = parser.parse_args() + if args.all == (args.device is not None): + parser.error("give either a clock name or --all") + if args.all and not args.out_dir: + parser.error("--all requires --out-dir") + + reg = Registry(dev_path=args.dev_path) + board_lines, board_id = board_info(reg) + if args.out_dir: + os.makedirs(args.out_dir, exist_ok=True) + names = list(CORE_CONFIG.clocks) if args.all else [args.device] + failed = False + for name in names: + failed = dump_clock(reg, name, args, board_lines, board_id, + echo=not args.all) or failed + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_lga80d_dump.py b/tests/test_lga80d_dump.py index 71264df..dcb6901 100644 --- a/tests/test_lga80d_dump.py +++ b/tests/test_lga80d_dump.py @@ -59,5 +59,13 @@ def test_failure_is_reported_not_raised(self): self.assertIn("invalid device type", "\n".join(lines)) +class VoutTrimDecodeTest(unittest.TestCase): + def test_signed_millivolts(self): + self.assertEqual(lga80d_dump.decoded("VOUT_TRIM", b"\xf9\xff"), "-0.854 mV") + self.assertEqual(lga80d_dump.decoded("VOUT_TRIM", b"\x02\x00"), "+0.244 mV") + self.assertEqual(lga80d_dump.decoded("VOUT_TRIM", b"\x00\x00"), "+0.000 mV") + self.assertEqual(lga80d_dump.decoded("VOUT_CAL_OFFSET", b"\xf9\xff"), "-0.854 mV") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_mcu_dump.py b/tests/test_mcu_dump.py new file mode 100644 index 0000000..3b13fad --- /dev/null +++ b/tests/test_mcu_dump.py @@ -0,0 +1,115 @@ +import contextlib +import io +import os +import tempfile +import types +import unittest + +from cm_interface import mcu_dump +from cm_interface.device.mcu import MCU, McuCapability, McuPage, SystemReg +from cm_interface.lga80d_dump import board_info + +ALL_CAPS = (McuCapability.SYSTEM | McuCapability.POWER | McuCapability.ALARMS + | McuCapability.ADC | McuCapability.PERSISTENT_LOG + | McuCapability.CONTROLS | McuCapability.RUNTIME + | McuCapability.CONFIG | McuCapability.CONFIG_WRITE) + + +class MemoryMCU(MCU): + """Zero-filled pages with a valid System header, map 1.1.""" + + def __init__(self, capabilities=ALL_CAPS, board_id=3019): + self.pages = {page: bytearray(256) for page in McuPage} + self.writes = [] + self.put(McuPage.SYSTEM, SystemReg.MAGIC, b"CMCU") + self.put(McuPage.SYSTEM, SystemReg.MAP_MAJOR, bytes((1, 1))) + self.put(McuPage.SYSTEM, SystemReg.CAPABILITIES, + int(capabilities).to_bytes(4, "little")) + self.put(McuPage.SYSTEM, SystemReg.BOARD_ID, board_id.to_bytes(4, "little")) + + def put(self, page, offset, data): + self.pages[int(page)][int(offset):int(offset) + len(data)] = data + + def read_reg(self, reg, size=1): + page, offset = divmod(int(reg), 256) + return bytes(self.pages[page][offset:offset + size]) + + def write_reg(self, reg, data): + self.writes.append((int(reg), bytes(data))) + + +class FailingMCU(MCU): + def __init__(self): + self.writes = [] + + def read_reg(self, reg, size=1): + raise OSError("could not open port") + + def write_reg(self, reg, data): + self.writes.append((int(reg), bytes(data))) + + +class FakeRegistry: + def __init__(self, mcu): + self.mcu = mcu + + def get_mcu(self): + return self.mcu + + +def _dump(mcu, out_dir=None): + """Run dump_mcu; return (failed, stdout text).""" + reg = FakeRegistry(mcu) + args = types.SimpleNamespace(dev_path="/dev/fake", out_dir=out_dir) + board_lines, board_id = board_info(reg) + out = io.StringIO() + with contextlib.redirect_stdout(out): + failed = mcu_dump.dump_mcu(reg, args, board_lines, board_id) + return failed, out.getvalue() + + +class DumpMcuTest(unittest.TestCase): + def test_all_sections_and_file(self): + mcu = MemoryMCU() + with tempfile.TemporaryDirectory() as out_dir: + failed, text = _dump(mcu, out_dir) + path = os.path.join(out_dir, "cm3019_mcu.txt") + with open(path, encoding="utf-8") as fh: + written = fh.read() + + self.assertFalse(failed) + self.assertIn("board id: 3019", text) + for title in ("System (0x00)", "Power (0x01)", "Alarm (0x02)", + "Config (0x05)", "Runtime (0x06)", "ADC sample (0x03)", + "Persistent log"): + self.assertIn(title, written) + self.assertNotIn("⚠", written) + # stdout is the file contents plus the 'wrote' line + self.assertEqual(text, written + "wrote %s\n" % path) + + def test_read_only(self): + mcu = MemoryMCU() + _dump(mcu) + self.assertEqual(mcu.writes, []) + + def test_unadvertised_page_is_not_a_failure(self): + mcu = MemoryMCU(capabilities=McuCapability.SYSTEM) + failed, text = _dump(mcu) + self.assertFalse(failed) + self.assertIn("not advertised by firmware", text) + + def test_failure_is_reported_not_raised(self): + mcu = FailingMCU() + with tempfile.TemporaryDirectory() as out_dir: + failed, text = _dump(mcu, out_dir) + self.assertTrue(os.path.exists(os.path.join(out_dir, "cmunknown_mcu.txt"))) + + self.assertTrue(failed) + self.assertIn("board id: unavailable", text) + self.assertIn("could not open port", text) + self.assertIn("(read failures)", text) + self.assertEqual(mcu.writes, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_si5395_checks.py b/tests/test_si5395_checks.py new file mode 100644 index 0000000..7a78d77 --- /dev/null +++ b/tests/test_si5395_checks.py @@ -0,0 +1,110 @@ +import contextlib +import io +import unittest + +from cm_interface import si5395_checks as checks +from cm_interface.device.si5395 import Clock + + +class FakeUART: + """Answers reads from a register dict (missing registers read 0x00).""" + + def __init__(self, regs): + self.regs = regs + self.writes = [] + + def write(self, data): + self.writes.append(data) + + def readline(self): + fields = self.writes[-1].decode("ascii").split() + if fields[0] == "w": + return b"c\n" + addr = (int(fields[3], 16) << 8) | int(fields[4], 16) + data = [self.regs.get(addr + k, 0) for k in range(int(fields[5], 16))] + return ("d " + " ".join("%02X" % b for b in data) + "\n").encode("ascii") + + +# Board 3002 R0A, 2026-10-14: free run from XAXB, all inputs and monitors off. +FREE_RUN = { + 0x000C: 0x00, 0x000D: 0x00, 0x000E: 0x22, 0x000F: 0xD0, + 0x0011: 0x1F, 0x0012: 0xF0, 0x0013: 0x22, 0x0014: 0xF2, + 0x002C: 0x00, 0x003F: 0x00, 0x009A: 0x00, 0x0102: 0x01, 0x0145: 0x00, + 0x0507: 0x00, 0x052A: 0x01, 0x0535: 0x01, 0x0949: 0x00, 0x00FE: 0x0F, +} + +# Locked to IN1 with IN0 and IN1 enabled and all monitors on. +LOCKED_IN1 = { + 0x002C: 0x03, 0x003F: 0x03, 0x009A: 0x02, 0x0102: 0x01, + 0x0507: 0x40, 0x0535: 0x00, 0x0949: 0x03, 0x00FE: 0x0F, +} + + +def _with(base, changes): + regs = dict(base) + regs.update(changes) + return regs + + +def _run(func, regs): + clock = Clock(FakeUART(regs), address=0x10, name="R0A") + out = io.StringIO() + with contextlib.redirect_stdout(out): + ok = func(clock, checks.read_monitors(clock)) + return ok, out.getvalue() + + +class FreeRunTest(unittest.TestCase): + def test_lol_and_hold_are_expected_in_free_run(self): + ok, text = _run(checks.check_status, FREE_RUN) + self.assertTrue(ok, text) + self.assertIn("expected: LOL (free run", text) + self.assertIn("expected: HOLD (free run", text) + self.assertIn("FORCE_HOLD=1", text) + + def test_flags_oof_on_disabled_inputs_expected_but_power_up_flags_fail(self): + ok, text = _run(checks.check_flags, FREE_RUN) + self.assertFalse(ok) + self.assertIn("expected: OOF_FLG IN3 (IN3 disabled)", text) + self.assertIn("expected: LOL_FLG", text) + self.assertIn("-> SYSINCAL_FLG (also latched at power-up", text) + self.assertIn("-> CAL_FLG_PLL", text) + + def test_flags_pass_once_power_up_flags_cleared(self): + regs = _with(FREE_RUN, {0x0011: 0x00, 0x0014: 0x00}) + ok, text = _run(checks.check_flags, regs) + self.assertTrue(ok, text) + + +class LockedTest(unittest.TestCase): + def test_lol_is_a_problem_when_input_enabled_and_detection_on(self): + ok, text = _run(checks.check_status, _with(LOCKED_IN1, {0x000E: 0x02})) + self.assertFalse(ok) + self.assertIn("-> LOL", text) + + def test_lol_expected_when_detection_off(self): + regs = _with(LOCKED_IN1, {0x000E: 0x02, 0x009A: 0x00}) + ok, text = _run(checks.check_status, regs) + self.assertTrue(ok, text) + self.assertIn("LOL detection off", text) + + def test_hold_is_a_problem_when_not_forced(self): + ok, text = _run(checks.check_status, _with(LOCKED_IN1, {0x000E: 0x20})) + self.assertFalse(ok) + self.assertIn("-> HOLD", text) + + def test_los_on_active_input_fails_on_standby_input_expected(self): + ok, text = _run(checks.check_status, _with(LOCKED_IN1, {0x000D: 0x03})) + self.assertFalse(ok) + self.assertIn("-> LOS IN1", text) + self.assertIn("expected: LOS IN0 (IN0 not the active input)", text) + + def test_oof_with_detector_off_expected(self): + regs = _with(LOCKED_IN1, {0x000D: 0x20, 0x003F: 0x01}) + ok, text = _run(checks.check_status, regs) + self.assertTrue(ok, text) + self.assertIn("OOF detection off for IN1", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/vccint_2phase.py b/vccint_2phase.py new file mode 100644 index 0000000..b105697 --- /dev/null +++ b/vccint_2phase.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""Reconfigure FnVCCINT (n = 1 or 2, --fpga, required) from 4-phase +(FnVCCINT1 + FnVCCINT2) to 2-phase (FnVCCINT1 only), overriding the CFG strap +resistors over PMBus. + +NOT PERSISTENT: nothing is stored (no STORE_USER_ALL). After any LGA80D VIN +cycle the modules come back with their straps (4-phase master/slave) and +this script must be run again, before the supplies are enabled. + +Steps (LGA80D_40A_dual_AppNote.pdf, ZL8802 datasheet): + 1. FnVCCINT2, both pages: ON_OFF_CONFIG (02h) = 0x1B (start from OPERATION + only, ignore the shared EN pin), then OPERATION (01h) = 0x00 (off). + 2. FnVCCINT1, both pages: DDC_CONFIG (D3h) -> same rail ID, phase IDs 0 and + 1, phases-in-rail reduced by 2. The datasheet describes the field as + "number of phases (+1)", which is ambiguous, so the new value is derived + from the value read back (current - 2), and the current value must be + the 4-phase one (3 or 4) or the script refuses. + 3. Optional, --drop-rail N: FnVCCINT2, both pages: DDC_CONFIG -> rail ID N, + phase IDs 0 and 1, same phases field as FnVCCINT1's new value, so it is + a self-consistent 2-phase rail of its own and no longer part of + FnVCCINT1's rail (DDC current-share / fault traffic). Refuses if N is + FnVCCINT1's rail. Each VCCINT pair has its own DDC (SHARE) net, so no + other LGA80D is checked. + +Only FnVCCINT1 and FnVCCINT2 of the selected FPGA are read or written (plus +the MCU power state); the other FPGA's supplies are never accessed. + 4. Read everything back and compare. +The 2-phase step is idempotent: FnVCCINT1 already at phase IDs 0/1 with a +2-phase field is left as is, so --drop-rail can be added on a later run. + +--restore undoes this without a VIN cycle, back to the strap configuration +(4-phase rail across FnVCCINT1 + FnVCCINT2): + 1. FnVCCINT1 and FnVCCINT2, both pages: DDC_CONFIG -> FnVCCINT1's rail ID + (never changed by this script), phase IDs 0/2 and 1/3, 4-phase field. + The 4-phase field is copied from FnVCCINT2 if it still holds the strap + values (phase IDs 1 and 3 on FnVCCINT1's rail); if FnVCCINT2 was moved + with --drop-rail it is FnVCCINT1's 2-phase field + 2. Anything else + refuses. + 2. FnVCCINT2, both pages: ON_OFF_CONFIG = 0x17 (factory default, EN pin + only) FIRST, then OPERATION = 0x80 (default; on/off bit now ignored). + The reverse order would turn FnVCCINT2 on immediately. + 3. Read everything back and compare. +A VIN cycle of the LGA80Ds gives the same result. + +The output must be on VSEN0P/N in 2-phase mode; on this board VS1 and VS2 +are tied together and go to the output, so USER_GLOBAL_CONFIG is not touched. +USER_GLOBAL_CONFIG (E9h) is only READ and printed (before and after), so the +VSENSE-select bits set by the CFG strap can be inspected; the ZL8802 datasheet and +the LGA80D app note disagree on their meaning. + +Does NOT touch the MCU. The MCU power state machine requires PG_Fn_INT_A and +PG_Fn_INT_B at level L1 and cannot ignore them, so with FnVCCINT2 disabled +the MCU is expected to report a power failure unless firmware/PG wiring +handles it. + +Without --apply this only reads and prints the plan. With --apply it writes, +but still refuses unless the MCU power state machine reads POWER_OFF. Same for +--restore. + +Usage: + vccint_2phase.py --fpga 2 # read-only: show current state + plan + vccint_2phase.py --fpga 2 --apply + vccint_2phase.py --fpga 1 # read-only + vccint_2phase.py --fpga 1 --apply + vccint_2phase.py --fpga 1 --apply --drop-rail 20 # also move FnVCCINT2 to rail 20 + vccint_2phase.py --fpga 1 --restore # read-only: restore plan + vccint_2phase.py --fpga 1 --restore --apply +""" +import argparse +import os +import sys +import time + +# Permit direct execution from this package directory (as lga80d_status.py does). +if not __package__: + _package_dir = os.path.dirname(os.path.realpath(__file__)) + _package_parent = os.path.dirname(_package_dir) + if _package_parent not in sys.path: + sys.path.insert(0, _package_parent) + +from cm_interface.device.lga80d import LGA80DReg +from cm_interface.device.mcu import PowerFsmState +from cm_interface.registry import Registry + +FPGAS = (1, 2) + +DDC_CONFIG = 0xD3 +USER_GLOBAL_CONFIG = 0xE9 # global, 2 bytes; read-only here +ON_OFF_OPERATION_ONLY = 0x1B # bits 4:2 = 110, EN pin ignored; bits 1:0 = 11 (as default) +OPERATION_OFF = 0x00 # immediate off +ON_OFF_EN_ONLY = 0x17 # factory default: bits 4:2 = 101, EN pin only +OPERATION_DEFAULT = 0x80 # power-up value (CM3001/CM3019 dumps) +OPERATION_ON_OFF_BITS = 0xC0 # bits compared on read-back +# Strap 4-phase layout (CM3019 baseline): FnVCCINT1 pages = phase IDs 0, 2; +# FnVCCINT2 pages = phase IDs 1, 3. +KEEP_4PHASE_IDS = (0, 2) +DROP_4PHASE_IDS = (1, 3) +# 2-phase layout (TRN DDC_CONFIG): phase IDs 0, 1 on one module. +TWO_PHASE_IDS = (0, 1) +RAIL_IDS = range(32) # DDC_CONFIG bits 12:8 +PAGES = (0, 1) +# Datasheet: 5 ms between commands, 100 ms after STORE/RESTORE (unused here). +WRITE_GAP_S = 0.01 + + +def fields(ddc): + return {"phase_id": ddc >> 13, "rail": (ddc >> 8) & 0x1F, + "phases": ddc & 0x7, "other": ddc & 0x00F8} + + +def read_ddc(lga, page): + raw = lga.read_reg(lga._paged_reg(DDC_CONFIG, page), size=2) + return int.from_bytes(raw, "little") + + +def rail_names(fpga): + """Return (KEEP, DROP) LGA80D names for FPGA 1 or 2.""" + return "F%dVCCINT1" % fpga, "F%dVCCINT2" % fpga + + +def plan_ddc(keep, KEEP): + """Return {page: new DDC_CONFIG} for the 2-phase rail, or raise. + + If KEEP is already in the 2-phase layout, new == current.""" + cur = {p: read_ddc(keep, p) for p in PAGES} + f = {p: fields(cur[p]) for p in PAGES} + if (all(f[p]["phase_id"] == TWO_PHASE_IDS[p] and f[p]["phases"] in (1, 2) + and not f[p]["other"] for p in PAGES) + and f[0]["rail"] == f[1]["rail"] + and f[0]["phases"] == f[1]["phases"]): + return cur, dict(cur) + if f[0]["rail"] != f[1]["rail"]: + raise RuntimeError("%s pages are on different rails (%d, %d); not a " + "single shared rail, refusing" % + (KEEP, f[0]["rail"], f[1]["rail"])) + for p in PAGES: + if f[p]["phases"] not in (3, 4): + raise RuntimeError( + "%s page %d DDC_CONFIG=0x%04X: phases field %d is not the " + "4-phase value (3 or 4); refusing to guess" % + (KEEP, p, cur[p], f[p]["phases"])) + if f[p]["other"]: + raise RuntimeError("%s page %d DDC_CONFIG=0x%04X has nonzero " + "reserved bits 7:3; refusing" % (KEEP, p, cur[p])) + if f[0]["phases"] != f[1]["phases"]: + raise RuntimeError("%s pages disagree on phases field" % KEEP) + new = {} + for p in PAGES: + new[p] = (p << 13) | (f[p]["rail"] << 8) | (f[p]["phases"] - 2) + return cur, new + + +def plan_drop_rail(drop, keep_rail, rail, phases): + """Return ({page: current}, {page: new}) DDC_CONFIG moving DROP to its own + 2-phase rail `rail`, or raise. Each VCCINT pair has its own DDC (SHARE) + net, so only the kept rail can collide; no other LGA80D is read.""" + if rail == keep_rail: + raise RuntimeError("--drop-rail %d is the kept rail; refusing" % rail) + cur = {p: read_ddc(drop, p) for p in PAGES} + new = {p: (TWO_PHASE_IDS[p] << 13) | (rail << 8) | phases for p in PAGES} + return cur, new + + +def plan_restore(keep, KEEP, drop, DROP): + """Return (cur_keep, new_keep, cur_drop, new_drop) strap 4-phase + DDC_CONFIG values, or raise. The rail ID is KEEP's (never changed).""" + cur = {p: read_ddc(keep, p) for p in PAGES} + ref = {p: read_ddc(drop, p) for p in PAGES} + f = {p: fields(cur[p]) for p in PAGES} + g = {p: fields(ref[p]) for p in PAGES} + if f[0]["rail"] != f[1]["rail"] or f[0]["other"] or f[1]["other"]: + raise RuntimeError("%s DDC_CONFIG 0x%04X/0x%04X: pages on different rails or " + "reserved bits set; refusing" % (KEEP, cur[0], cur[1])) + rail = f[0]["rail"] + drop_strap = all(g[p]["phase_id"] == DROP_4PHASE_IDS[p] and g[p]["rail"] == rail + and g[p]["phases"] in (3, 4) and not g[p]["other"] + and g[p]["phases"] == g[0]["phases"] for p in PAGES) + drop_moved = all(g[p]["phase_id"] == TWO_PHASE_IDS[p] and g[p]["rail"] != rail + and g[p]["rail"] == g[0]["rail"] and not g[p]["other"] + and g[p]["phases"] == f[0]["phases"] for p in PAGES) + drop_moved = drop_moved and f[0]["phases"] in (1, 2) + if drop_strap: + phases = g[0]["phases"] + elif drop_moved: + phases = f[0]["phases"] + 2 + else: + raise RuntimeError( + "%s DDC_CONFIG 0x%04X/0x%04X is neither the strap 4-phase layout " + "(phase IDs 1/3 on rail %d) nor the --drop-rail 2-phase layout " + "matching %s; refusing" % (DROP, ref[0], ref[1], rail, KEEP)) + for p in PAGES: + if f[p]["phases"] not in (phases - 2, phases): + raise RuntimeError( + "%s page %d DDC_CONFIG=0x%04X: phases field %d is neither the " + "2-phase (%d) nor the 4-phase (%d) value; refusing" % + (KEEP, p, cur[p], f[p]["phases"], phases - 2, phases)) + new = {p: (KEEP_4PHASE_IDS[p] << 13) | (rail << 8) | phases for p in PAGES} + new_drop = {p: (DROP_4PHASE_IDS[p] << 13) | (rail << 8) | phases for p in PAGES} + return cur, new, ref, new_drop + + +def show_user_global_config(label, KEEP, keep_dev, DROP, drop_dev): + """Print E9h (read-only). Bits 9:8 = VSENSE select; the ZL8802 datasheet and the + LGA80D app note disagree on their meaning, so just report the raw value.""" + for name, lga in ((KEEP, keep_dev), (DROP, drop_dev)): + raw = lga.read_reg(lga._paged_reg(USER_GLOBAL_CONFIG, 0), size=2) + val = int.from_bytes(raw, "little") + print("%s %s USER_GLOBAL_CONFIG (E9h) = 0x%04X bits 9:8 (VSENSE select) = %d" % ( + label, name, val, (val >> 8) & 3)) + + +def write_checked(lga, command, page, data): + lga.write_reg(lga._paged_reg(command, page), data) + time.sleep(WRITE_GAP_S) + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--fpga", type=int, choices=FPGAS, required=True, + help="FPGA rail to reconfigure: 1 = F1VCCINT, 2 = F2VCCINT") + parser.add_argument("--apply", action="store_true", + help="perform the writes (default: read-only)") + parser.add_argument("--restore", action="store_true", + help="go back to the strap 4-phase configuration " + "instead of 2-phase") + parser.add_argument("--drop-rail", type=int, choices=RAIL_IDS, metavar="N", + help="2-phase mode only: also move FnVCCINT2 to its own " + "2-phase rail N (0-31, must be unused)") + parser.add_argument("--dev-path", default="/dev/ttyUL4", + help="UART device (default: %(default)s)") + args = parser.parse_args() + if args.restore and args.drop_rail is not None: + parser.error("--drop-rail cannot be combined with --restore") + + KEEP, DROP = rail_names(args.fpga) + reg = Registry(dev_path=args.dev_path) + keep = reg.get_lga80d(KEEP) + drop = reg.get_lga80d(DROP) + + fsm = reg.get_mcu().read_power().fsm_state + print("MCU power state machine: %s" % fsm.name) + + show_user_global_config("before:", KEEP, keep, DROP, drop) + if args.restore: + print("mode: restore strap 4-phase configuration") + cur, new, cur_drop, new_drop = plan_restore(keep, KEEP, drop, DROP) + onoff_new, op_new = ON_OFF_EN_ONLY, OPERATION_DEFAULT + else: + print("mode: 2-phase (%s only)" % KEEP) + cur, new = plan_ddc(keep, KEEP) + if args.drop_rail is not None: + cur_drop, new_drop = plan_drop_rail( + drop, fields(new[0])["rail"], args.drop_rail, new[0] & 0x7) + else: + cur_drop = {p: read_ddc(drop, p) for p in PAGES} + new_drop = dict(cur_drop) + onoff_new, op_new = ON_OFF_OPERATION_ONLY, OPERATION_OFF + for p in PAGES: + print("%s page %d DDC_CONFIG: 0x%04X -> 0x%04X" % (KEEP, p, cur[p], new[p])) + for p in PAGES: + print("%s page %d DDC_CONFIG: 0x%04X -> 0x%04X%s" % ( + DROP, p, cur_drop[p], new_drop[p], + "" if new_drop[p] != cur_drop[p] else " (unchanged)")) + for p in PAGES: + print("%s page %d: ON_OFF_CONFIG=0x%02X OPERATION=0x%02X (current %02X/%02X)" % ( + DROP, p, onoff_new, op_new, + drop.read_reg(drop._paged_reg(LGA80DReg.ON_OFF_CONFIG, p))[0], + drop.read_reg(drop._paged_reg(LGA80DReg.OPERATION, p))[0])) + + if not args.apply: + print("read-only: re-run with --apply to write") + return 0 + if fsm != PowerFsmState.POWER_OFF: + print("refusing to write: power state machine is %s, not POWER_OFF" % fsm.name) + return 1 + + # 2-phase: disable DROP before shrinking the rail. Restore: grow the rail + # first, then hand DROP back to the EN pin. In both cases ON_OFF_CONFIG is + # written before OPERATION. + def write_drop(): + for p in PAGES: + write_checked(drop, LGA80DReg.ON_OFF_CONFIG, p, bytes([onoff_new])) + write_checked(drop, LGA80DReg.OPERATION, p, bytes([op_new])) + + def write_ddc(): + for lga, cur_v, new_v in ((drop, cur_drop, new_drop), (keep, cur, new)): + for p in PAGES: + if new_v[p] != cur_v[p]: + write_checked(lga, DDC_CONFIG, p, new_v[p].to_bytes(2, "little")) + + if args.restore: + write_ddc() + write_drop() + else: + write_drop() + write_ddc() + + ok = True + for p in PAGES: + got = read_ddc(keep, p) + got_drop = read_ddc(drop, p) + onoff = drop.read_reg(drop._paged_reg(LGA80DReg.ON_OFF_CONFIG, p))[0] + op = drop.read_reg(drop._paged_reg(LGA80DReg.OPERATION, p))[0] + good = (got == new[p] and got_drop == new_drop[p] and onoff == onoff_new and + (op & OPERATION_ON_OFF_BITS) == (op_new & OPERATION_ON_OFF_BITS)) + ok = ok and good + print("%s page %d DDC_CONFIG=0x%04X | %s page %d DDC_CONFIG=0x%04X " + "ON_OFF_CONFIG=0x%02X OPERATION=0x%02X %s" % ( + KEEP, p, got, DROP, p, got_drop, onoff, op, + "OK" if good else "MISMATCH")) + show_user_global_config("after:", KEEP, keep, DROP, drop) + if not ok: + print("VERIFY FAILED") + elif args.restore: + print("restored strap 4-phase configuration") + else: + print("applied, NOT stored: reverts on LGA80D VIN cycle") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) From 2ea42bebf867c66a371a4a110835b4d0c8dfb5c3 Mon Sep 17 00:00:00 2001 From: Peter Wittich Date: Fri, 16 Oct 2026 15:57:27 -0400 Subject: [PATCH 2/5] add Si5341 (rev2), generalize --- AGENTS.md | 2 +- EXAMPLES.md | 2 +- README.md | 4 +- TODO.md | 7 +- si5395_checks.py => clock_checks.py | 235 ++++++++++++++-- si5395_dump.py => clock_dump.py | 214 +++++++++++---- core_config.py | 13 +- design.md | 6 +- device/mcu.py | 17 +- device/si5395.py | 162 ++---------- errors.py | 11 +- examples.py | 15 +- registry.py | 72 ++++- tests/hw/README.md | 9 +- .../{test_hw_si5395.py => test_hw_clock.py} | 19 +- tests/test_clock_checks.py | 250 ++++++++++++++++++ tests/test_si5395_checks.py | 110 -------- 17 files changed, 776 insertions(+), 372 deletions(-) rename si5395_checks.py => clock_checks.py (56%) rename si5395_dump.py => clock_dump.py (64%) rename tests/hw/{test_hw_si5395.py => test_hw_clock.py} (73%) create mode 100644 tests/test_clock_checks.py delete mode 100644 tests/test_si5395_checks.py diff --git a/AGENTS.md b/AGENTS.md index 043a922..6478fd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,7 +218,7 @@ reg = Registry(setup='tf') # providing clocks, LGA80Ds, the MCU, and both FPGA objects. # Access devices -clock = reg.get_clock('R0A') # Si5395 clock generator +clock = reg.get_clock('R0A') # Si5395 (Rev3) or Si5341 (Rev2) firefly = reg.get_firefly('F2_6') # populated TF Firefly4 transceiver lga80d = reg.get_lga80d('F1VCCINT1') # LGA80D DC-DC converter mcu = reg.get_mcu() # ProgCom MC 0 client diff --git a/EXAMPLES.md b/EXAMPLES.md index 9cd3228..b4cbc78 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -537,7 +537,7 @@ example_disable_cdr_all_fireflies() | Method | Description | |--------|-------------| | `Registry(setup='tf')` | Create registry with board preset ('tf', 'it_dtc', or None) | -| `get_clock(name)` | Get Si5395 clock by name (e.g., 'R0A') | +| `get_clock(name)` | Get clock by name (e.g., 'R0A'); Si5341 or Si5395 by board revision | | `get_firefly(loc)` | Get a configured Firefly by location; raises `KeyError` when absent | | `get_lga80d(name)` | Get LGA80D converter by supply name | diff --git a/README.md b/README.md index 7208b7c..1718d8d 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ cm_interface/ ├─ device/ │ ├─ __init__.py │ ├─ base.py # Abstract Device class -│ ├─ si5395.py # Clock implementation +│ ├─ clock.py # Shared clock base (SiClock, ClockHealth) +│ ├─ si5395.py # Si5395 clock (all positions on Rev3) +│ ├─ si5341.py # Si5341 clock (R0A on Rev2) │ ├─ firefly.py # Tx, Rx, and 4‑channel firefly classes │ ├─ lga80d.py # LGA80D DC-DC converter implementation │ ├─ mcu.py # Command-module MCU register interface diff --git a/TODO.md b/TODO.md index 304a7b8..e47f57c 100644 --- a/TODO.md +++ b/TODO.md @@ -12,8 +12,11 @@ except where marked HW-verified. Delete an item when it lands. Phase 3 firmware lands. - **`FPGA.write_reg` has no guard**, against the refuse-by-default rule in `CLAUDE.md`. Open decision 3 in `FPGA_GENERIC_INTERFACE_PLAN.md` (`unsafe=True` opt-in vs warning). - -- **Clocks doesn't respect Rev2 vs Rev3 differences** Rev2 has a Si5341 in position R0A, Rev3 has Si5395. Need to think about how to handle that. +- **Rev2 Si5341 (R0A) alarm rules are only partly hardware-checked.** Board 210 (2026-10-16) + confirmed hw rev 2, PN 0x5341, the status/flag decode and the dump layout on a healthy, + locked R0A (now the test fixture). The `clock_checks.py` expected-alarm rules (LOS on an + unselected input, FB_IN outside zero delay mode, pin-selected reference) are still from + the reference manual only. ## LGA80D snapshot follow-ups diff --git a/si5395_checks.py b/clock_checks.py similarity index 56% rename from si5395_checks.py rename to clock_checks.py index df73f64..032c02b 100644 --- a/si5395_checks.py +++ b/clock_checks.py @@ -1,14 +1,17 @@ #!/usr/bin/env python3 -"""Read-only Si5395 clock register checks. +"""Read-only clock register checks (Si5395, and the Si5341 at R0A on Rev2). READ ONLY: every access is a ProgCom "r CL ..." read. Nothing is written; sticky flags are reported but not cleared. -DEVICE_READY (0x00FE) is read first. Until it reads 0x0F the reference -manual permits no other register access, so a clock that is not ready is -reported and skipped. +The chip at each position comes from the board revision the MCU reports +(Registry.clocks). DEVICE_READY (0x00FE) is read first. Until it reads 0x0F +the reference manuals permit no other register access, so a clock that is +not ready is reported and skipped. Then PN_BASE must match the chip the board +revision implies; on a mismatch the clock is reported and skipped, since its +status bits would be decoded with the wrong register map. -Checks (select with --check; the default runs all three): +Si5395 checks (select with --check; the default runs all three): id DEVICE_ID (0x0002, expect 0x5395), GRADE, DEVICE_REV, TEMP_GRADE, PKG_ID, DESIGN_ID (0x026B, CBPro string) and ACTIVE_NVM_BANK @@ -31,15 +34,37 @@ or LOL detection disabled (LOL_SLOW_EN_PLL=0) HOLD FORCE_HOLD=1, or no input enabled (free run) LOSXAXB LOSXAXB_DIS=1 -SYSINCAL_FLG, LOSXAXB_FLG, XAXB_ERR_FLG and CAL_FLG_PLL also latch while -the device calibrates and the crystal starts after power-up or reset, so on a -board whose flags were never cleared they are noted as such but still fail; -clear the flags and re-run to tell a power-up artifact from a real event. +Sticky flags and clock loading: the MCU clears the sticky flags after it +loads the clocks (load_all_clocks -> clear_clk_stickybits), so a set flag is +an event after configuration. Firmware whose clear_clk_stickybits() does not +select page 0 first (cm_mcu master up to dcfe0d2; fixed on the fix/clksticky +and fix/powering branches, 2026-10-16) misses the flags. On such firmware SYSINCAL_FLG, LOSXAXB_FLG, XAXB_ERR_FLG and +CAL_FLG_PLL, which also latch while the device calibrates after a load or +reset, may be load artifacts; they are noted as such but still fail. + +Si5341 (Si5341/40 Rev D RM Rev 1.3, chapter 14) differs: + + id as above, expect 0x5341. + status 0x000C-0x000D only: SYSINCAL, LOSXAXB, LOSREF, LOL (0x000C bit 3), + SMBUS_TIMEOUT, LOS per input IN0, IN1, IN2, FB_IN. There is no + holdover, OOF or PLL calibration status. Reference selection is + 0x0021 (IN_SEL 3 = XA/XB); outputs as above. + flags Sticky flags 0x0011-0x0012. + + Expected alarms: LOS on an input that is disabled (IN_EN 0x0949), has LOS + detection off (LOS_EN 0x002C) or is not the register-selected reference; + LOS on FB_IN unless zero-delay mode is on (ZDM_EN 0x091C = 3); LOSXAXB with + LOSXAXB_DIS=1. LOL and LOSREF are always alarms: the PLL locks to whichever + reference is selected, the crystal included. With pin-controlled selection + (IN_SEL_REGCTRL=0) the selected input cannot be read, so LOS on an enabled, + monitored input is reported as a problem. These rules come from the + reference manual and have not been checked on a Rev2 board. Usage: - si5395_checks.py # all checks, all clocks - si5395_checks.py --check status # one check - si5395_checks.py --check flags --device R0A + clock_checks.py # all checks, all clocks + clock_checks.py --check status # one check + clock_checks.py --check flags --device R0A + clock_checks.py --hw-rev 2 # override the MCU's board revision """ import argparse import os @@ -54,13 +79,13 @@ sys.path.insert(0, _package_parent) from cm_interface.core_config import CORE_CONFIG +from cm_interface.device.si5341 import Si5341Reg from cm_interface.device.si5395 import Si5395Reg -from cm_interface.errors import CMError +from cm_interface.errors import ClockIdentityError, CMError from cm_interface.registry import Registry EXPECTED_ERRORS = (CMError, OSError, TimeoutError, ValueError, RuntimeError) -EXPECTED_DEVICE_ID = 0x5395 DEVICE_READY_VALUE = 0x0F IN_ACTV = 0x0507 # bits 7:6, currently selected DSPLL input (read only) LOS_EN = 0x002C # bits 3:0 LOS_EN per input, bit 4 LOSXAXB_DIS @@ -69,7 +94,7 @@ FORCE_HOLD = 0x0535 # bit 0 IN_EN = 0x0949 # bits 3:0 input buffer enable per input -# ACTIVE_NVM_BANK (0x00E2), Si5395 reference manual table 16.62. +# ACTIVE_NVM_BANK (0x00E2): Si5395 RM table 16.62; same values in the Si5341 RM. NVM_BANKS = {0x03: "no user NVM burn", 0x0F: "1 bank burned", 0x3F: "2 banks burned (last bank used)"} @@ -197,7 +222,7 @@ def add(name, reason): def check_ready(clock): """Read only DEVICE_READY; return True when the clock may be accessed further.""" - ready = _u8(clock, Si5395Reg.DEVICE_READY) + ready = _u8(clock, clock.REG.DEVICE_READY) if ready != DEVICE_READY_VALUE: print("%-4s NOT READY: DEVICE_READY 0x%02X (expect 0x%02X); skipping further reads" % (clock.name, ready, DEVICE_READY_VALUE)) @@ -205,6 +230,16 @@ def check_ready(clock): return True +def check_identity(clock): + """PN_BASE must match the chip the board revision implies.""" + try: + clock.verify_identity() + except ClockIdentityError as exc: + print("%-4s WRONG CHIP: %s; skipping further reads" % (clock.name, exc)) + return False + return True + + def check_id(clock, mon=None): """Identity and NVM state. Returns True when nothing is unexpected.""" device_id = clock.get_device_id() @@ -213,14 +248,14 @@ def check_id(clock, mon=None): temp_grade = clock.temperature_grade pkg = clock.package_id design_id = clock.design_id - nvm = _u8(clock, Si5395Reg.ACTIVE_NVM_BANK) + nvm = _u8(clock, clock.REG.ACTIVE_NVM_BANK) print("%-4s id: DEVICE_ID 0x%04X part Si%04X%s-%s TEMP_GRADE %d PKG_ID %d " "DESIGN_ID %r ACTIVE_NVM_BANK 0x%02X (%s)" % (clock.name, device_id, device_id, _letter(grade), _letter(rev), temp_grade, pkg, design_id, nvm, NVM_BANKS.get(nvm, "unexpected value"))) ok = True - if device_id != EXPECTED_DEVICE_ID: - print(" -> DEVICE_ID is not 0x%04X" % EXPECTED_DEVICE_ID) + if device_id != clock.EXPECTED_PN: + print(" -> DEVICE_ID is not 0x%04X (%s)" % (clock.EXPECTED_PN, clock.CHIP)) ok = False if nvm not in NVM_BANKS: print(" -> ACTIVE_NVM_BANK is not one of the documented values") @@ -260,7 +295,7 @@ def check_status(clock, mon): return not problems -# Sticky flags that also latch during power-up calibration / crystal start. +# Si5395 sticky flags that also latch during calibration after a load or reset. POWER_UP_FLAGS = ("SYSINCAL_FLG", "LOSXAXB_FLG", "XAXB_ERR_FLG", "CAL_FLG_PLL") @@ -274,7 +309,7 @@ def check_flags(clock, mon): for item in expected: print(" expected: %s" % item) for flag in problems: - note = (" (also latched at power-up/reset; clear and re-run to confirm)" + note = (" (also latches during clock load; see --help)" if flag.split()[0] in POWER_UP_FLAGS else "") print(" -> %s%s" % (flag, note)) if problems or expected: @@ -282,26 +317,157 @@ def check_flags(clock, mon): return not problems +# ---------------------------------------------------------------- Si5341 + +SI5341_INPUTS = ("IN0", "IN1", "IN2", "FB_IN") +SI5341_REFS = ("IN0", "IN1", "IN2", "XA/XB") +ZDM_ON = 3 # ZDM_EN (0x091C bits 2:0): 3 = zero delay, 4 = normal + + +def _inputs_5341(mask): + names = [SI5341_INPUTS[i] for i in range(4) if mask & (1 << i)] + return ",".join(names) if names else "none" + + +class Monitors5341(namedtuple("Monitors5341", "in_en los_en losxaxb_dis regctrl " + "in_sel zdm")): + """Si5341 configuration that decides whether a status bit is an alarm.""" + + @property + def reference(self): + """Selected reference name, or None when selected by pins.""" + return SI5341_REFS[self.in_sel] if self.regctrl else None + + def describe(self): + if self.reference is None: + ref = "reference selected by IN_SEL pins (not readable)" + else: + ref = "reference %s (IN_SEL register)" % self.reference + return "%s, inputs enabled: %s%s" % (ref, _inputs_5341(self.in_en), + ", zero delay mode" if self.zdm else "") + + def input_reason(self, n): + """Why LOS on input n (3 = FB_IN) is expected, or None for a real alarm.""" + name = SI5341_INPUTS[n] + if not self.in_en & (1 << n): + return "%s disabled" % name + if not self.los_en & (1 << n): + return "LOS detection off for %s" % name + if n == 3: + return None if self.zdm else "FB_IN unused outside zero delay mode" + if self.reference is not None and self.reference != name: + return "%s not the selected reference" % name + return None + + def losxaxb_reason(self): + return "XAXB LOS detection off (LOSXAXB_DIS=1)" if self.losxaxb_dis else None + + +def read_monitors_5341(clock): + los_en = _u8(clock, Si5341Reg.LOS_ENABLE) + in_sel = _u8(clock, Si5341Reg.IN_SEL_REGCTRL) + return Monitors5341( + in_en=_u8(clock, Si5341Reg.IN_EN) & 0xF, + los_en=los_en & 0xF, + losxaxb_dis=_bit(los_en, 4), + regctrl=_bit(in_sel, 0), + in_sel=(in_sel >> 1) & 0x3, + zdm=(_u8(clock, Si5341Reg.ZDM_EN) & 0x7) == ZDM_ON, + ) + + +def classify_5341(mon, status, los, sticky=False): + """Split the set bits of 0x000C/0x000D (or the sticky 0x0011/0x0012) into + (problems, expected); expected entries carry the reason.""" + sfx = "_FLG" if sticky else "" + problems, expected = [], [] + + def add(name, reason): + (expected if reason else problems).append( + "%s (%s)" % (name, reason) if reason else name) + + if _bit(status, 0): + add("SYSINCAL" + sfx, None) + if _bit(status, 1): + add("LOSXAXB" + sfx, mon.losxaxb_reason()) + if _bit(status, 2): + add("LOSREF" + sfx, None) + if _bit(status, 3): + add("LOL" + sfx, None) + if _bit(status, 5): + add("SMBUS_TIMEOUT" + sfx, None) + for n in range(4): + if _bit(los, n): + add("LOS%s %s" % (sfx, SI5341_INPUTS[n]), mon.input_reason(n)) + return problems, expected + + +def check_status_5341(clock, mon): + """Si5341 live status. Returns True when the clock is healthy.""" + status, los = clock.read_reg(Si5341Reg.STATUS, size=2) + outall = _u8(clock, Si5341Reg.OUTALL_DISABLE) + pdn_all = _u8(clock, Si5341Reg.OUT_PDN_ALL) + + print("%-4s status: raw 0x000C-0x000D = %02X %02X %s" + % (clock.name, status, los, "UNLOCKED" if _bit(status, 3) else "locked")) + print(" mode: %s" % mon.describe()) + print(" LOS %s" % _inputs_5341(los & 0xF)) + print(" outputs: OUTALL_DISABLE_LOW %d (%s) OUT_PDN_ALL %d (%s)" + % (outall & 1, "enabled" if _bit(outall, 0) else "ALL DISABLED", + pdn_all & 1, "ALL POWERED DOWN" if _bit(pdn_all, 0) else "no effect")) + + problems, expected = classify_5341(mon, status, los) + if mon.reference is None: + problems = [p + " (reference selected by pins; cannot tell if used)" + if p.startswith("LOS IN") else p for p in problems] + if not _bit(outall, 0): + problems.append("all outputs disabled (OUTALL_DISABLE_LOW = 0)") + if _bit(pdn_all, 0): + problems.append("all output drivers powered down (OUT_PDN_ALL = 1)") + for item in expected: + print(" expected: %s" % item) + for problem in problems: + print(" -> %s" % problem) + return not problems + + +def check_flags_5341(clock, mon): + """Si5341 sticky flags 0x0011-0x0012. Returns True when no unexplained flag is set.""" + status_flg, los_flg = clock.sticky_flags + print("%-4s flags: raw 0x0011-0x0012 = %02X %02X" % (clock.name, status_flg, los_flg)) + problems, expected = classify_5341(mon, status_flg, los_flg, sticky=True) + for item in expected: + print(" expected: %s" % item) + for flag in problems: + print(" -> %s" % flag) + if problems or expected: + print(" (sticky since last clear; this script does not clear them)") + return not problems + + +# ---------------------------------------------------------------- driver + def check_clock(clock, checks): """Run the selected checks on one clock. Returns True when all pass.""" name = clock.name try: - if not check_ready(clock): + if not check_ready(clock) or not check_identity(clock): return False except EXPECTED_ERRORS as exc: print("%-4s READ FAILED: %s" % (name, _format_error(exc))) return False + profile = PROFILES[clock.CHIP] mon = None if set(checks) & {"status", "flags"}: try: - mon = read_monitors(clock) + mon = profile["monitors"](clock) except EXPECTED_ERRORS as exc: print("%-4s config READ FAILED: %s" % (name, _format_error(exc))) return False ok = True for check in checks: try: - ok = CHECK_FUNCS[check](clock, mon) and ok + ok = profile[check](clock, mon) and ok except EXPECTED_ERRORS as exc: print("%-4s %s READ FAILED: %s" % (name, check, _format_error(exc))) ok = False @@ -309,7 +475,12 @@ def check_clock(clock, checks): CHECKS = ("id", "status", "flags") -CHECK_FUNCS = {"id": check_id, "status": check_status, "flags": check_flags} +PROFILES = { + "Si5395": {"monitors": read_monitors, "id": check_id, + "status": check_status, "flags": check_flags}, + "Si5341": {"monitors": read_monitors_5341, "id": check_id, + "status": check_status_5341, "flags": check_flags_5341}, +} def main(): @@ -321,13 +492,25 @@ def main(): help="clock name (repeatable; default: all)") parser.add_argument("--dev-path", default="/dev/ttyUL4", help="UART device (default: %(default)s)") + parser.add_argument("--hw-rev", type=int, metavar="N", + help="board revision to assume instead of the MCU's " + "HARDWARE_REVISION (e.g. EEPROM not programmed)") parser.add_argument("--debug", action="store_true", help="log every UART transaction to stdout") args = parser.parse_args() checks = [c for c in CHECKS if c in (args.check or CHECKS)] names = args.device or list(CORE_CONFIG.clocks) - reg = Registry(dev_path=args.dev_path, debug=sys.stdout if args.debug else None) + reg = Registry(dev_path=args.dev_path, debug=sys.stdout if args.debug else None, + hw_rev=args.hw_rev) + try: + clocks = reg.clocks + except EXPECTED_ERRORS as exc: + print("cannot determine clock chips: %s" % _format_error(exc)) + return 1 + print("board revision %d%s: %s" % ( + reg.hw_rev, " (--hw-rev)" if args.hw_rev is not None else " (MCU)", + " ".join("%s=%s" % (n, clocks[n].CHIP) for n in names))) ok = True for name in names: diff --git a/si5395_dump.py b/clock_dump.py similarity index 64% rename from si5395_dump.py rename to clock_dump.py index e67ce62..fe32087 100644 --- a/si5395_dump.py +++ b/clock_dump.py @@ -1,13 +1,16 @@ #!/usr/bin/env python3 -"""Summary dump of one Si5395 clock synthesizer. +"""Summary dump of one clock synthesizer (Si5395, or the Si5341 at R0A on Rev2). READ ONLY: every access is a ProgCom "r CL ..." read; nothing is written and sticky flags are not cleared. Field layout follows the Si5395/94/92 Reference -Manual Rev 1.3, chapter 16. +Manual Rev 1.3, chapter 16, and the Si5341/40 Rev D Reference Manual Rev 1.3, +chapter 14. The chip at each position comes from the board revision the MCU +reports (Registry.clocks); --hw-rev overrides it. DEVICE_READY (0x00FE) is read first. Until it reads 0x0F the reference manual permits no other register access, so a clock that is not ready is -reported and skipped. +reported and skipped. If PN_BASE does not match the expected chip the dump +still runs, with a warning, using the expected chip's layout. Output, per clock: status live status, sticky flags, monitor enables, NVM bank, global @@ -18,13 +21,16 @@ dividers P0-P3, MXAXB, M (feedback ratio = 5 * M_NUM/M_DEN, /5 prescaler) and N0-N4 with the per-N bits from page A. Fout = Fvco / (N * R); N_ADD_0P5 is shown but not applied, the manual does not define it. + Si5341: OUT0-OUT9, P0-P3 are integers, M (0x0235) is the + feedback divider, and there is no N_ADD_0P5 or N HIGH_FREQ. CBPro-computed tuning (LOS/OOF/LOL thresholds and windows, holdover, fastlock, loop bandwidth codes, phase adjust) is not read. Usage: - si5395_dump.py R0A - si5395_dump.py --all --out-dir DIR + clock_dump.py R0A + clock_dump.py --all --out-dir DIR + clock_dump.py R0A --hw-rev 2 """ import argparse import os @@ -38,8 +44,9 @@ if _package_parent not in sys.path: sys.path.insert(0, _package_parent) +from collections import namedtuple + from cm_interface.core_config import CORE_CONFIG -from cm_interface.device.si5395 import Si5395Reg from cm_interface.lga80d_dump import board_info from cm_interface.registry import Registry @@ -97,7 +104,7 @@ def _ascii(raw): # the manual (LOS/OOF/LOL thresholds and windows, holdover tuning, fastlock, # phase adjust) is CBPro-computed and deliberately left out. -REGS = [ +REGS_5395 = [ # live status and sticky flags (0x000C, "SYSINCAL:0 LOSXAXB:1 XAXB_ERR:3 SMBUS_TIMEOUT:5"), (0x000D, "LOS:3:0 OOF:7:4"), @@ -136,7 +143,7 @@ def _ascii(raw): OPN_ID = (0x0278, 5) # Output drivers: name -> (first of 4 config regs, R divider register). -OUTPUTS = [ +OUTPUTS_5395 = [ ("OUT0A", 0x0103, 0x0247), ("OUT0", 0x0108, 0x024A), ("OUT1", 0x010D, 0x024D), ("OUT2", 0x0112, 0x0250), ("OUT3", 0x0117, 0x0253), ("OUT4", 0x011C, 0x0256), ("OUT5", 0x0121, 0x0259), ("OUT6", 0x0126, 0x025C), ("OUT7", 0x012B, 0x025F), @@ -146,15 +153,93 @@ def _ascii(raw): OUT_VDD = {0: "3.3V", 1: "1.8V", 2: "2.5V"} # Dividers: (name, num addr, num bytes, den addr, frac/mode register or None) -P_DIVS = [("P%d" % i, 0x0208 + 10 * i, 6, 0x020E + 10 * i, 0x0231 + i) for i in range(4)] -MXAXB = ("MXAXB", 0x0235, 6, 0x023B, None) -M_DIV = ("M", 0x0515, 7, 0x051C, 0x0521) +P_DIVS_5395 = [("P%d" % i, 0x0208 + 10 * i, 6, 0x020E + 10 * i, 0x0231 + i) for i in range(4)] +MXAXB_5395 = ("MXAXB", 0x0235, 6, 0x023B, None) +M_DIV_5395 = ("M", 0x0515, 7, 0x051C, 0x0521) # N0-N4: numerator, denominator, frequency step word, HIGH_FREQ register. -N_DIVS = [("N%d" % i, 0x0302 + 11 * i, 0x0308 + 11 * i, 0x033B + 6 * i, 0x0A14 + 6 * i) +N_DIVS_5395 = [("N%d" % i, 0x0302 + 11 * i, 0x0308 + 11 * i, 0x033B + 6 * i, 0x0A14 + 6 * i) for i in range(5)] -N_BITS = (("PIBYP", 0x0A04), ("PDNB", 0x0A05), ("TO_OUT", 0x0A03), ("ADD0P5", 0x0A02), +N_BITS_5395 = (("PIBYP", 0x0A04), ("PDNB", 0x0A05), ("TO_OUT", 0x0A03), ("ADD0P5", 0x0A02), ("FSTEP_MSK", 0x0339), ("CLK_DIS", 0x0B4A)) +# ---------------------------------------------------------------- Si5341 +# Si5341/40 Rev D RM Rev 1.3, chapter 14. Output and N divider registers are +# at the Si5395 addresses, but OUT0A/OUT9 (0x0103, 0x0135) do not exist and +# the Si5341's OUT9 sits where the Si5395's OUT9A is. + +SI5341_INPUTS = ("IN0", "IN1", "IN2", "FB_IN") + + +def _inputs_5341(v): + names = [SI5341_INPUTS[i] for i in range(4) if v & (1 << i)] + return ",".join(names) if names else "none" + + +REGS_5341 = [ + (0x000C, "SYSINCAL:0 LOSXAXB:1 LOSREF:2 LOL:3 SMBUS_TIMEOUT:5"), + (0x000D, "LOSIN:3:0"), + (0x0011, "SYSINCAL_FLG:0 LOSXAXB_FLG:1 LOSREF_FLG:2 LOL_FLG:3 SMBUS_TIMEOUT_FLG:5"), + (0x0012, "LOSIN_FLG:3:0"), + (0x002C, "LOS_EN:3:0 LOSXAXB_DIS:4"), + (0x00E2, "ACTIVE_NVM_BANK:7:0"), + (0x00FE, "DEVICE_READY:7:0"), + (0x0102, "OUTALL_DISABLE_LOW:0"), + (0x0145, "OUT_PDN_ALL:0"), + (0x0206, "PXAXB:1:0"), + (0x090E, "XAXB_EXTCLK_EN:0 XAXB_PDNB:1"), + (0x0949, "IN_EN:3:0"), + (0x094A, "INx_TO_PFD_EN:6:4"), + (0x091C, "ZDM_EN:2:0"), + (0x0021, "IN_SEL_REGCTRL:0 IN_SEL:2:1"), +] + +# Decoders that differ from FIELD_DECODE on the Si5341. +FIELD_DECODE_5341 = { + "LOSIN": _inputs_5341, "LOSIN_FLG": _inputs_5341, + "LOS_EN": _inputs_5341, "IN_EN": _inputs_5341, + "INx_TO_PFD_EN": _inputs, + "IN_SEL": _enum("IN0", "IN1", "IN2", "XA/XB"), + "ZDM_EN": lambda v: {3: "zero delay", 4: "normal"}.get(v, "rsvd"), + "XAXB_PDNB": _enum("powered down", "on"), +} + +OUTPUTS_5341 = [ + ("OUT0", 0x0108, 0x024A), ("OUT1", 0x010D, 0x024D), ("OUT2", 0x0112, 0x0250), + ("OUT3", 0x0117, 0x0253), ("OUT4", 0x011C, 0x0256), ("OUT5", 0x0121, 0x0259), + ("OUT6", 0x0126, 0x025C), ("OUT7", 0x012B, 0x025F), ("OUT8", 0x0130, 0x0262), + ("OUT9", 0x013A, 0x0268), +] +# P0-P3 are 48-bit integers (0x020E+10i is P_SET, CBPro-internal, not a +# denominator); M_NUM/M_DEN at 0x0235/0x023B is the feedback divider. +P_DIVS_5341 = [("P%d" % i, 0x0208 + 10 * i, 6, None, None) for i in range(4)] +M_DIV_5341 = ("M", 0x0235, 6, 0x023B, None) +N_DIVS_5341 = [(name, num, den, step, None) for name, num, den, step, _ in N_DIVS_5395] +N_BITS_5341 = (("PIBYP", 0x0A04), ("PDNB", 0x0A05), ("TO_OUT", 0x0A03), + ("FSTEP_MSK", 0x0339), ("CLK_DIS", 0x0B4A)) + +# ---------------------------------------------------------------- layouts + +Layout = namedtuple("Layout", "regs decode outputs outputs_title p_divs m_divs " + "m_feedback dividers_title n_divs n_bits n_title") + +LAYOUTS = { + "Si5395": Layout( + regs=REGS_5395, decode=FIELD_DECODE, outputs=OUTPUTS_5395, + outputs_title="0x0103-0x013D, R dividers 0x0247-0x026A", + p_divs=P_DIVS_5395, m_divs=[MXAXB_5395, M_DIV_5395], m_feedback=5, + dividers_title="P0-P3, MXAXB, M (0x0208-0x023E, 0x0515-0x0521)", + n_divs=N_DIVS_5395, n_bits=N_BITS_5395, + n_title="0x0302-0x0358, page A bits 0x0A02-0x0A2C, 0x0B4A"), + "Si5341": Layout( + regs=REGS_5341, decode=dict(FIELD_DECODE, **FIELD_DECODE_5341), + outputs=OUTPUTS_5341, + outputs_title="0x0108-0x013D, R dividers 0x024A-0x026A", + p_divs=P_DIVS_5341, m_divs=[M_DIV_5341], m_feedback=None, + dividers_title="P0-P3, M (0x0208-0x022B, 0x0235-0x023E)", + n_divs=N_DIVS_5341, n_bits=N_BITS_5341, + n_title="0x0302-0x0358, page A bits 0x0A03-0x0A05, 0x0B4A"), +} + def _parse_fields(spec): fields = [] @@ -166,27 +251,29 @@ def _parse_fields(spec): return fields -def addresses(): - """Every register address the dump reads (sorted).""" +def addresses(layout=LAYOUTS["Si5395"]): + """Every register address the dump reads for a layout (sorted).""" addrs = set() - for addr, _ in REGS: + for addr, _ in layout.regs: addrs.add(addr) for addr, nbytes in (IDENT, DESIGN_ID, OPN_ID): addrs.update(range(addr, addr + nbytes)) - for _, cfg, rdiv in OUTPUTS: + for _, cfg, rdiv in layout.outputs: addrs.update(range(cfg, cfg + 4)) addrs.update(range(rdiv, rdiv + 3)) - for _, num, nnum, den, frac in P_DIVS + [MXAXB, M_DIV]: + for _, num, nnum, den, frac in layout.p_divs + layout.m_divs: addrs.update(range(num, num + nnum)) - addrs.update(range(den, den + 4)) + if den is not None: + addrs.update(range(den, den + 4)) if frac is not None: addrs.add(frac) - for _, num, den, step, high in N_DIVS: + for _, num, den, step, high in layout.n_divs: addrs.update(range(num, num + 6)) addrs.update(range(den, den + 4)) addrs.update(range(step, step + 6)) - addrs.add(high) - addrs.update(addr for _, addr in N_BITS) + if high is not None: + addrs.add(high) + addrs.update(addr for _, addr in layout.n_bits) return sorted(addrs) @@ -232,8 +319,8 @@ def raw(self, addr, nbytes): return None if None in raw else bytes(raw) -def _fmt_field(name, value): - decode = FIELD_DECODE.get(name) +def _fmt_field(name, value, decoders=FIELD_DECODE): + decode = decoders.get(name) return "%s=%d%s" % (name, value, "(%s)" % decode(value) if decode else "") @@ -260,16 +347,16 @@ def identity_lines(vals): part, "?" if opn is None else _ascii(opn), "?" if design is None else _ascii(design))] -def output_lines(vals): - lines = ["", " -- outputs (0x0103-0x013D, R dividers 0x0247-0x026A) --", +def output_lines(vals, layout=LAYOUTS["Si5395"]): + lines = ["", " -- outputs (%s) --" % layout.outputs_title, " %-6s %3s %2s %-7s %4s %3s %4s %4s %3s %3s %-4s %4s %3s %8s %12s" % ( "OUTPUT", "PDN", "OE", "FORMAT", "SYNC", "DIS", "CDRV", "AMPL", "CM", "MUX", "VDD", "VSEL", "INV", "R", "N*R")] n_ratio = {} - for name, num, den, _, _ in N_DIVS: + for name, num, den, _, _ in layout.n_divs: n, d = vals.word(num, 6, 44), vals.word(den, 4) n_ratio[name] = None if n is None or d is None or d == 0 else n / d - for name, cfg, rdiv in OUTPUTS: + for name, cfg, rdiv in layout.outputs: r0, r1, r2, r3 = (vals.u8(cfg + k) for k in range(4)) rreg = vals.word(rdiv, 3) if None in (r0, r1, r2, r3, rreg): @@ -295,30 +382,39 @@ def output_lines(vals): return lines -def divider_lines(vals): - lines = ["", " -- dividers P0-P3, MXAXB, M (0x0208-0x023E, 0x0515-0x0521) --", +def divider_lines(vals, layout=LAYOUTS["Si5395"]): + lines = ["", " -- dividers %s --" % layout.dividers_title, " %-6s %18s %12s %18s %s" % ("DIV", "NUM", "DEN", "NUM/DEN", "FRAC")] - for name, num, nnum, den, frac in P_DIVS + [MXAXB, M_DIV]: - n, d = vals.word(num, nnum), vals.word(den, 4) + for name, num, nnum, den, frac in layout.p_divs + layout.m_divs: + n = vals.word(num, nnum) + if den is None: + # Integer divider: no denominator register. + lines.append(" %-6s %18s %12s %18s %s" % ( + name, "?" if n is None else n, "-", "?" if n is None else n, "")) + continue + d = vals.word(den, 4) extra = "" if frac is not None: f = vals.u8(frac) extra = "?" if f is None else "FRAC_EN=%d FRACN_MODE=0x%X" % ((f >> 4) & 1, f & 0xF) - if name == "M" and n is not None and d: - extra += " feedback 5*M = %.9f" % (5 * n / d) + if name == "M" and layout.m_feedback and n is not None and d: + extra += " feedback %d*M = %.9f" % (layout.m_feedback, + layout.m_feedback * n / d) lines.append(" %-6s %18s %12s %18s %s" % ( name, "?" if n is None else n, "?" if d is None else d, _ratio(n, d), extra)) - bits = dict((label, vals.u8(addr)) for label, addr in N_BITS) - lines += ["", " -- N dividers (0x0302-0x0358, page A bits 0x0A02-0x0A2C, 0x0B4A) --", + bits = dict((label, vals.u8(addr)) for label, addr in layout.n_bits) + lines += ["", " -- N dividers (%s) --" % layout.n_title, " %-3s %15s %12s %16s %5s %4s %6s %6s %5s %5s %7s %15s" % ( "N", "NUM", "DEN", "NUM/DEN", "INT", "PDNB", "TO_OUT", "ADD0P5", "HIGHF", "FSMSK", "CLK_DIS", "FSTEPW")] - for i, (name, num, den, step, high) in enumerate(N_DIVS): + for i, (name, num, den, step, high) in enumerate(layout.n_divs): n, d = vals.word(num, 6, 44), vals.word(den, 4) - h = vals.u8(high) + h = None if high is None else vals.u8(high) def bit(label): + if label not in bits: + return "-" v = bits[label] return "?" if v is None else (v >> i) & 1 @@ -326,19 +422,21 @@ def bit(label): lines.append(" %-3s %15s %12s %16s %5s %4s %6s %6s %5s %5s %7s %15s" % ( name, "?" if n is None else n, "?" if d is None else d, _ratio(n, d), bit("PIBYP"), bit("PDNB"), bit("TO_OUT"), bit("ADD0P5"), - "?" if h is None else (h >> 3) & 1, bit("FSTEP_MSK"), bit("CLK_DIS"), + "-" if high is None else "?" if h is None else (h >> 3) & 1, + bit("FSTEP_MSK"), bit("CLK_DIS"), "?" if fstep is None else fstep)) return lines -def field_lines(vals): +def field_lines(vals, layout=LAYOUTS["Si5395"]): lines = ["", " -- status and configuration --", " %-7s %-4s %s" % ("ADDR", "RAW", "FIELDS")] - for addr, spec in sorted(REGS): + for addr, spec in sorted(layout.regs): v = vals.u8(addr) if v is None: lines.append(" 0x%04X READ FAILED %s" % (addr, spec)) continue - text = " ".join(_fmt_field(name, (v >> lo) & ((1 << (hi - lo + 1)) - 1)) + text = " ".join(_fmt_field(name, (v >> lo) & ((1 << (hi - lo + 1)) - 1), + layout.decode) for name, hi, lo in _parse_fields(spec)) lines.append(" 0x%04X 0x%02X %s" % (addr, v, text)) return lines @@ -348,14 +446,15 @@ def dump_clock(reg, name, args, board_lines, board_id, echo): """Dump one clock. Prints to stdout if echo; writes a file if --out-dir. Returns True if anything failed.""" clock = reg.get_clock(name) + layout = LAYOUTS[clock.CHIP] lines = [ - "Si5395 register dump %s" % time.strftime("%Y-%m-%d %H:%M:%S"), + "%s register dump %s" % (clock.CHIP, time.strftime("%Y-%m-%d %H:%M:%S")), " clock: %s (address 0x%02X)" % (name, CORE_CONFIG.clocks[name]), " uart: %s" % args.dev_path, ] + board_lines failed = False try: - ready = clock.read_reg(Si5395Reg.DEVICE_READY)[0] + ready = clock.read_reg(clock.REG.DEVICE_READY)[0] except Exception as exc: ready = None lines.append(" DEVICE_READY READ FAILED: %s" % exc) @@ -366,14 +465,23 @@ def dump_clock(reg, name, args, board_lines, board_id, echo): failed = True result = "READ FAILED" if ready is None else "NOT READY" else: - values, errors = read_all(clock, addresses()) + values, errors = read_all(clock, addresses(layout)) vals = Values(values) lines += identity_lines(vals) + pn = vals.word(0x0002, 2) + wrong_chip = pn is not None and pn != clock.EXPECTED_PN + if wrong_chip: + lines.append(" WARNING: PN_BASE 0x%04X is not the expected 0x%04X (%s); " + "fields below are decoded with the %s layout and are " + "probably wrong" % (pn, clock.EXPECTED_PN, clock.CHIP, clock.CHIP)) for addr, exc in errors: lines.append(" READ FAILED at 0x%04X: %s" % (addr, exc)) - failed = bool(errors) + failed = bool(errors) or wrong_chip result = "READ FAILED (%d)" % len(errors) if errors else "OK" - lines += field_lines(vals) + output_lines(vals) + divider_lines(vals) + if wrong_chip: + result = "WRONG CHIP" + ("" if result == "OK" else ", " + result) + lines += (field_lines(vals, layout) + output_lines(vals, layout) + + divider_lines(vals, layout)) result_line = "%-4s result: %s" % (name, result) lines.append(result_line) if echo: @@ -403,14 +511,24 @@ def main(): help="also write one file per clock to DIR, named " "cm_.txt (existing files are overwritten; " "board id is 'unknown' if the MCU can't report it)") + parser.add_argument("--hw-rev", type=int, metavar="N", + help="board revision to assume instead of the MCU's " + "HARDWARE_REVISION (e.g. EEPROM not programmed)") args = parser.parse_args() if args.all == (args.device is not None): parser.error("give either a clock name or --all") if args.all and not args.out_dir: parser.error("--all requires --out-dir") - reg = Registry(dev_path=args.dev_path) + reg = Registry(dev_path=args.dev_path, hw_rev=args.hw_rev) board_lines, board_id = board_info(reg) + if args.hw_rev is not None: + board_lines = board_lines + [" clocks for hw rev %d (--hw-rev)" % args.hw_rev] + try: + reg.clocks + except Exception as exc: + print("cannot determine clock chips: %s" % exc) + return 1 if args.out_dir: os.makedirs(args.out_dir, exist_ok=True) names = list(CORE_CONFIG.clocks) if args.all else [args.device] diff --git a/core_config.py b/core_config.py index 458d005..f0d3c05 100644 --- a/core_config.py +++ b/core_config.py @@ -1,6 +1,6 @@ """Immutable wiring configuration for all boards. -Clocks (Si5395) and DC‑DC converters (LGA80D) have fixed UART addresses that are +Clocks (Si5395, plus an Si5341 at R0A on Rev2) and DC‑DC converters (LGA80D) have fixed UART addresses that are soldered onto the PCB. These mappings never change at runtime – they are stored in a frozen dataclass so any accidental mutation raises an error. """ @@ -36,3 +36,14 @@ class CoreConfig: "F2AVTT/CC": 0x46, }, ) + + +# Clock chip at each position, by board revision (MCU HARDWARE_REVISION). +# cm_mcu LocalTasks.c clk_moni2c_addrs[]: REV2 has an Si5341 (0x77) at R0A, +# REV3 has Si5395s (0x6b) everywhere. Rev1 firmware has no ProgCom. +CLOCK_CHIPS: Mapping[int, Mapping[str, str]] = { + 2: {"R0A": "si5341", "R0B": "si5395", "R1A": "si5395", "R1B": "si5395", + "R1C": "si5395"}, + 3: {"R0A": "si5395", "R0B": "si5395", "R1A": "si5395", "R1B": "si5395", + "R1C": "si5395"}, +} diff --git a/design.md b/design.md index 7266f0d..f0548e3 100644 --- a/design.md +++ b/design.md @@ -88,7 +88,9 @@ Complete JSON register maps are in `registers/`: ## Board Configurations The suggestion is to have representation of all the devices. There are -five clocks, R0A, R0B, R1A, R1B, R1C. They are all Si5395. Data sheet in the +five clocks, R0A, R0B, R1A, R1B, R1C. They are all Si5395 on Rev3; on Rev2, +R0A is an Si5341. `Registry.clocks` picks the class from the MCU's +HARDWARE_REVISION (`core_config.CLOCK_CHIPS`). Data sheet in the directory. There are 20 Samtec fireflies. They are available Using a naming convention as below @@ -203,7 +205,7 @@ status = clock.read_reg(Si5395Reg.LOL_HOLD_STATUS) # Clear, self-documenting is_locked = clock.is_locked() # Check PLL lock status mode = clock.mode # Get current mode health = clock.health # Structured live clock status -device_id = clock.get_device_id() # Should return 0x5395 +device_id = clock.get_device_id() # 0x5395 (0x5341 for R0A on Rev2) # Access clock - Method 3: Status checks if clock.has_loss_of_signal(0): # Check LOS on IN0 diff --git a/device/mcu.py b/device/mcu.py index 21292dc..b47bd27 100644 --- a/device/mcu.py +++ b/device/mcu.py @@ -421,8 +421,9 @@ class MCU(Device): Register numbers use the normal package convention ``page << 8 | offset``. """ - # Class-level default so test doubles that skip ``__init__`` still work. + # Class-level defaults so test doubles that skip ``__init__`` still work. _capabilities = None + _hardware_revision = None _CAPABILITY_FOR_PAGE = { McuPage.POWER: McuCapability.POWER, @@ -438,6 +439,7 @@ class MCU(Device): def __init__(self, uart): super().__init__(uart, address=0) self._capabilities = None + self._hardware_revision = None def _encode_command(self, reg: int, write: bool, payload: bytes = b"", read_size: int = 1) -> bytes: @@ -483,6 +485,19 @@ def capabilities(self, refresh: bool = False) -> McuCapability: ) return self._capabilities + def hardware_revision(self, refresh: bool = False) -> int: + """Return the board revision, cached after the first read. + + The firmware takes this from the board EEPROM (``first_mcu`` / + ``set_id``), not from the REV it was compiled for, so an unprogrammed + EEPROM gives a meaningless value. Pass ``refresh=True`` to re-read. + """ + if refresh or self._hardware_revision is None: + self._hardware_revision = self._read_u8( + McuPage.SYSTEM, SystemReg.HARDWARE_REVISION + ) + return self._hardware_revision + def _require_page(self, page: McuPage, check: bool = True) -> None: """Refuse to touch a page the firmware does not advertise. diff --git a/device/si5395.py b/device/si5395.py index d27523d..e359886 100644 --- a/device/si5395.py +++ b/device/si5395.py @@ -1,39 +1,10 @@ from enum import IntEnum -from typing import Tuple -from ..compat import dataclass -from ..errors import ClockNvmWriteRefused -from .base import Device +from .clock import ClockHealth, SiClock +__all__ = ["Clock", "ClockHealth", "LolStatusBits", "LosStatusBits", + "Si5395Clock", "Si5395Reg"] -@dataclass(frozen=True) -class ClockHealth: - """Decoded live status from an Si5395.""" - - system_calibrating: bool - xa_xb_loss_of_signal: bool - xa_xb_error: bool - smbus_timeout: bool - loss_of_signal: Tuple[bool, bool, bool, bool] - out_of_frequency: Tuple[bool, bool, bool, bool] - loss_of_lock: bool - holdover: bool - pll_calibrating: bool - ready: bool - - @property - def healthy(self) -> bool: - return not any(( - self.system_calibrating, - self.xa_xb_loss_of_signal, - self.xa_xb_error, - self.smbus_timeout, - any(self.loss_of_signal), - any(self.out_of_frequency), - self.loss_of_lock, - self.holdover, - self.pll_calibrating, - )) and self.ready class Si5395Reg(IntEnum): """Register map for Si5395 clock generator (Page 0x00 registers). @@ -144,14 +115,7 @@ class LosStatusBits(IntEnum): OOF_IN3 = 7 -# Writing these starts an NVM burn / bank read on the chip. Nothing in this -# package should do that by accident, so Clock.write_reg refuses them. -_NVM_CONTROL_REGS = frozenset( - (int(Si5395Reg.NVM_WRITE), int(Si5395Reg.NVM_READ_BANK)) -) - - -class Clock(Device): +class Si5395Clock(SiClock): """Concrete representation of an Si5395 clock generator. Parameters @@ -162,10 +126,12 @@ class Clock(Device): Device address on the serial bus (e.g., 0x10 for R0A). name : str Logical identifier (e.g., "R0A", "R1B"). - + verify_identity : bool + Check PN_BASE reads 0x5395 before the first health read or write. + Examples -------- - >>> clock = Clock(uart, address=0x10, name="R0A") + >>> clock = Si5395Clock(uart, address=0x10, name="R0A") >>> # Using symbolic register names (recommended) >>> status = clock.read_reg(Si5395Reg.LOL_HOLD_STATUS) >>> # Using convenience properties @@ -174,64 +140,11 @@ class Clock(Device): >>> if clock.is_locked(): ... print("PLL is locked") """ - def __init__(self, uart, address: int, name: str): - super().__init__(uart, address) - self.name = name - - def _encode_command(self, reg: int, write: bool, payload: bytes = b"", - read_size: int = 1) -> bytes: - return self._encode_ascii_command( - "CL", self.address - 0x10, reg, write, payload, read_size - ) - def _decode_response(self, raw: bytes) -> bytes: - return self._decode_ascii_response(raw) - - def write_reg(self, reg: int, data: bytes, allow_nvm: bool = False) -> None: - """Write registers, refusing any write that touches an NVM control register. - - ``NVM_WRITE`` (0x00E3) burns the chip's non-volatile memory and - ``NVM_READ_BANK`` (0x00E4) loads a bank; a stray write to either is not - recoverable by this package. A multi-byte write that spans one of them - is refused too. ``allow_nvm=True`` is the explicit opt-in. - """ - start = int(reg) - touched = [r for r in range(start, start + len(data)) if r in _NVM_CONTROL_REGS] - if touched and not allow_nvm: - raise ClockNvmWriteRefused( - "refusing write to Si5395 NVM control register 0x{:04X}; " - "pass allow_nvm=True to override".format(touched[0]) - ) - super().write_reg(reg, data) - - @property - def mode(self) -> str: - """Return a mode inferred from documented live status registers.""" - health = self.health - if health.system_calibrating or health.pll_calibrating: - return "calibrating" - if health.holdover: - return "holdover" - if health.loss_of_lock: - return "unlocked" - return "locked" - - @property - def status(self) -> ClockHealth: - """Compatibility name for the structured live health snapshot.""" - return self.health - - def is_ready(self) -> bool: - """Return whether the device has finished its startup calibration. - - This reads *only* DEVICE_READY, which is the sole register access - the manufacturer's startup sequence permits until the device - reports ready -- callers must not read/write anything else - beforehand. The device is ready only when this register reads - exactly ``0x0F``; any other nonzero value is still "not ready", - not a partial-ready state. - """ - return self.read_reg(Si5395Reg.DEVICE_READY)[0] == 0x0F + CHIP = "Si5395" + EXPECTED_PN = 0x5395 + REG = Si5395Reg + STICKY_FLAG_BYTES = 4 @property def health(self) -> ClockHealth: @@ -240,6 +153,7 @@ def health(self) -> ClockHealth: Do not call this until :meth:`is_ready` returns ``True`` -- the registers read here are not part of the startup-safe set. """ + self._ensure_identity() sysincal, los_oof, lol_hold, cal_pll = self.read_reg( Si5395Reg.SYSINCAL, size=4 ) @@ -248,6 +162,7 @@ def health(self) -> ClockHealth: system_calibrating=bool(sysincal & (1 << 0)), xa_xb_loss_of_signal=bool(sysincal & (1 << 1)), xa_xb_error=bool(sysincal & (1 << 3)), + ref_loss_of_signal=None, smbus_timeout=bool(sysincal & (1 << 5)), loss_of_signal=tuple(bool(los_oof & (1 << i)) for i in range(4)), out_of_frequency=tuple(bool(los_oof & (1 << (i + 4))) for i in range(4)), @@ -284,39 +199,6 @@ def is_in_holdover(self) -> bool: status = self.lol_status return bool(status & (1 << LolStatusBits.HOLD)) - def get_device_id(self) -> int: - """Read device ID register (should be 0x5395).""" - data = self.read_reg(Si5395Reg.DEVICE_ID, size=2) - return int.from_bytes(data, "little") - - @property - def device_id(self) -> int: - return self.get_device_id() - - def get_device_rev(self) -> int: - """Read device revision.""" - return self.read_reg(Si5395Reg.DEVICE_REV)[0] - - @property - def device_revision(self) -> int: - return self.get_device_rev() - - @property - def grade(self) -> int: - return self.read_reg(Si5395Reg.GRADE)[0] - - @property - def temperature_grade(self) -> int: - return self.read_reg(Si5395Reg.TEMP_GRADE)[0] - - @property - def package_id(self) -> int: - return self.read_reg(Si5395Reg.PKG_ID)[0] - - @property - def design_id(self) -> str: - return self.read_ascii(Si5395Reg.DESIGN_ID, 8) - @property def sticky_flags(self) -> bytes: return self.read_reg(Si5395Reg.SYSINCAL_FLAG, size=4) @@ -324,13 +206,6 @@ def sticky_flags(self) -> bytes: def clear_sticky_flags(self) -> None: self.write_reg(Si5395Reg.SYSINCAL_FLAG, bytes(4)) - def reset(self, soft: bool = True) -> None: - """Perform device reset.""" - if soft: - self.write_reg(Si5395Reg.SOFT_RESET, b"\x01") - else: - self.write_reg(Si5395Reg.POWER_DOWN, bytes([0x02])) - def set_input_select(self, input_num: int) -> None: """Manually select input clock (0-3).""" if not 0 <= input_num <= 3: @@ -342,9 +217,6 @@ def enable_hitless_switching(self, enable: bool = True) -> None: """Enable or disable hitless input switching.""" self.update_bits(Si5395Reg.HSW_EN, 0x04, 0x04 if enable else 0x00) - def get_register_name(self, addr: int) -> str: - """Get symbolic name for a register address.""" - for reg in Si5395Reg: - if reg.value == addr: - return reg.name - return f"0x{addr:04X}" + +# Historical name: every clock was an Si5395 before Rev2 support. +Clock = Si5395Clock diff --git a/errors.py b/errors.py index b839cfd..4b311c2 100644 --- a/errors.py +++ b/errors.py @@ -20,8 +20,15 @@ class RegisterAccessError(CMError): class ClockNvmWriteRefused(CMError): - """A write would touch an Si5395 NVM control register (burn / bank read) - and the caller did not opt in with ``allow_nvm=True``.""" + """A write would touch a clock chip's NVM control register (burn / bank + read) and the caller did not opt in with ``allow_nvm=True``.""" + pass + + +class ClockIdentityError(CMError): + """A clock chip's PN_BASE does not match the part the board revision + says should be at that position (wrong EEPROM revision, or firmware + built for another board revision).""" pass diff --git a/examples.py b/examples.py index b220137..7a008f2 100644 --- a/examples.py +++ b/examples.py @@ -168,8 +168,8 @@ def example_cdr_status_check(): def example_clock_monitoring(): """ - Monitor Si5395 clock status using symbolic register names and - convenience methods. + Monitor clock status using convenience methods. R0A is an Si5341 on + Rev2 boards (no holdover), an Si5395 on Rev3. """ print("=" * 70) print("Example 4: Clock Status Monitoring") @@ -179,7 +179,7 @@ def example_clock_monitoring(): # Iterate over all clocks for name, clock in reg.clocks.items(): - print(f"\nClock {name}:") + print(f"\nClock {name} ({clock.CHIP}):") # Method 1: Using convenience properties print(f" Device ID: 0x{clock.get_device_id():04X}") @@ -191,7 +191,7 @@ def example_clock_monitoring(): else: print(f" ✗ PLL is NOT locked") - if clock.is_in_holdover(): + if hasattr(clock, "is_in_holdover") and clock.is_in_holdover(): print(f" ⚠ In holdover mode") # Method 3: Check for loss of signal on inputs @@ -199,9 +199,8 @@ def example_clock_monitoring(): if clock.has_loss_of_signal(i): print(f" ⚠ LOS on Input {i}") - # Method 4: Read raw status register using symbolic name - status = clock.read_reg(Si5395Reg.LOL_HOLD_STATUS) - print(f" LOL Status Register: 0x{int.from_bytes(status, 'big'):04X}") + # Method 4: Raw register that holds LOL (0x000E Si5395, 0x000C Si5341) + print(f" LOL Status Register: 0x{clock.lol_status:02X}") print("\n" + "=" * 70) @@ -358,7 +357,7 @@ def example_register_access_patterns(): print("=" * 70) reg = Registry(setup='tf', dev_path='/dev/ttyUSB2') - clock = reg.get_clock('R0A') + clock = reg.get_clock('R0B') # Si5395 on every board revision print("\nThree ways to read the LOL status register:") diff --git a/registry.py b/registry.py index d6e337d..40c4fc0 100644 --- a/registry.py +++ b/registry.py @@ -1,12 +1,16 @@ from .uart import UART -from .device.si5395 import Clock +from .device.clock import SiClock +from .device.si5341 import Si5341Clock +from .device.si5395 import Si5395Clock from .device.firefly import Firefly12, Firefly4, FireflyTx, FireflyRx, FireflyTxCern, FireflyRxCern, Firefly from .device.lga80d import LGA80D from .device.mcu import MCU, PowerFsmState from .device.fpga import FPGA -from .core_config import CORE_CONFIG +from .core_config import CLOCK_CHIPS, CORE_CONFIG from .firefly_presets import FIREfly_PRESETS, BoardSetup -from typing import Optional, TextIO +from typing import Dict, Optional, TextIO + +CLOCK_CLASSES = {"si5341": Si5341Clock, "si5395": Si5395Clock} class Registry: """Singleton registry that creates a single UART instance and populates @@ -16,31 +20,39 @@ class Registry: load a predefined board configuration (``'tf'`` or ``'it_dtc'``). When ``setup`` is omitted or ``None``, the original generic mapping is used, allowing full manual control of the address maps. + + Which chip sits at each clock position depends on the board revision + (R0A is an Si5341 on Rev2, an Si5395 on Rev3). ``clocks`` is built on + first access from the MCU's HARDWARE_REVISION, or from ``hw_rev`` when + given (e.g. for a board whose EEPROM revision was never programmed). + Constructing the registry itself sends nothing on the UART. """ _instance = None def __new__(cls, setup: str = None, debug: Optional[TextIO] = None, dev_path: str = "/dev/ttyUL4", - timeout: float = UART.DEFAULT_TIMEOUT): + timeout: float = UART.DEFAULT_TIMEOUT, + hw_rev: Optional[int] = None): if cls._instance is None: cls._instance = super().__new__(cls) - cls._instance._init(setup, debug, dev_path, timeout) + cls._instance._init(setup, debug, dev_path, timeout, hw_rev) return cls._instance def _init(self, setup: str = None, debug: Optional[TextIO] = None, dev_path: str = "/dev/ttyUL4", - timeout: float = UART.DEFAULT_TIMEOUT): + timeout: float = UART.DEFAULT_TIMEOUT, + hw_rev: Optional[int] = None): self.uart = UART(debug=debug, dev_path=dev_path, timeout=timeout) self.mcu = MCU(self.uart) self.fpgas = { "F1": FPGA(self.uart, fpga_number=0, name="F1"), "F2": FPGA(self.uart, fpga_number=1, name="F2"), } - # Instantiate device objects from CORE_CONFIG addresses - self.clocks = {} + # Instantiate device objects from CORE_CONFIG addresses; clocks are + # built lazily because the chip type needs the board revision. + self._hw_rev = hw_rev + self._clocks = None self.lga80d = {} - for name, addr in CORE_CONFIG.clocks.items(): - self.clocks[name] = Clock(self.uart, addr, name) for name, addr in CORE_CONFIG.lga80d.items(): self.lga80d[name] = LGA80D(self.uart, addr, name) # Firefly wiring – mutable layout dict @@ -151,9 +163,45 @@ def claim_index(key, idx): self.fireflies[tx_key] = tx_cls(self.uart, address=base + tx_idx, location=loc) self.fireflies[rx_key] = rx_cls(self.uart, address=base + rx_idx, location=loc) + # --------------------------------------------------------------------- + # Clocks + # --------------------------------------------------------------------- + @property + def hw_rev(self) -> int: + """Board revision used to pick the clock chips: the ``hw_rev`` + override if given, otherwise the MCU's HARDWARE_REVISION.""" + if self._hw_rev is None: + self._hw_rev = self.mcu.hardware_revision() + return self._hw_rev + + @property + def clocks(self) -> Dict[str, SiClock]: + """Clock devices by name, typed for this board revision. + + Raises ``ValueError`` for a revision with no known clock layout. + Each clock checks its PN_BASE on first use and raises + ``ClockIdentityError`` if the chip is not the expected one. + """ + if self._clocks is None: + rev = self.hw_rev + try: + chips = CLOCK_CHIPS[rev] + except KeyError: + raise ValueError( + "no clock layout for board revision %d (known: %s); program " + "the MCU EEPROM revision with first_mcu / set_id, or pass " + "hw_rev= to Registry" % (rev, sorted(CLOCK_CHIPS)) + ) from None + self._clocks = { + name: CLOCK_CLASSES[chips[name]](self.uart, addr, name, + verify_identity=True) + for name, addr in CORE_CONFIG.clocks.items() + } + return self._clocks + # Helper look‑ups --------------------------------------------------- - def get_clock(self, name: str) -> Clock: - """Return the ``Clock`` instance identified by ``name``. + def get_clock(self, name: str) -> SiClock: + """Return the clock instance identified by ``name``. Raises ``KeyError`` if the clock does not exist. """ diff --git a/tests/hw/README.md b/tests/hw/README.md index fa3106c..9f65017 100644 --- a/tests/hw/README.md +++ b/tests/hw/README.md @@ -47,7 +47,7 @@ And one more that describes board *population*, not connectivity: transaction with `e Firefly not enabled` regardless of register addressing, which would otherwise show up as spurious failures unrelated to the F03/F04/F05 fixes those tests check. `test_hw_registry.py`, - `test_hw_si5395.py`, and `test_hw_lga80d.py` are unaffected by this flag + `test_hw_clock.py`, and `test_hw_lga80d.py` are unaffected by this flag since they don't depend on FireFly modules being present. ## Running it @@ -85,9 +85,10 @@ always hold. UART address matches the pinned `wire_index` map in `firefly_presets.py`. Needs a `Registry` object but not real device responses, so it runs even if the serial port is unavailable. -- **`test_hw_si5395.py`** (S01) -- `Clock.get_device_id()` must read back - `0x5395` for every configured clock, a strong self-verifying check that - the register addressing/framing is right at all. `is_ready()` is +- **`test_hw_clock.py`** (S01) -- `get_device_id()` must read back the + part the board revision implies (`0x5395`, or `0x5341` for R0A on Rev2) + for every configured clock, a strong self-verifying check that the + register addressing/framing and the board revision are right. `is_ready()` is asserted to return a `bool` (the strict `== 0x0F` fix from S01); actual readiness/lock state is printed, not hard-asserted, since it depends on board power-up and input-clock wiring this suite doesn't control. diff --git a/tests/hw/test_hw_si5395.py b/tests/hw/test_hw_clock.py similarity index 73% rename from tests/hw/test_hw_si5395.py rename to tests/hw/test_hw_clock.py index 6aa16c2..30116d6 100644 --- a/tests/hw/test_hw_si5395.py +++ b/tests/hw/test_hw_clock.py @@ -1,9 +1,10 @@ -"""Hardware-runnable checks for the Si5395 clock generator. +"""Hardware-runnable checks for the clock chips (Si5395; Si5341 at R0A on Rev2). Focused on what this session's audit fix touched (S01: DEVICE_READY must read exactly 0x0F, not merely nonzero) plus a strong, self-verifying wiring -sanity check (DEVICE_ID must read back 0x5395 -- if the register addresses -or framing were wrong, this would almost certainly not match by accident). +sanity check (DEVICE_ID must read back the part the board revision implies, +0x5395 or 0x5341 -- if the register addresses, framing or the board revision +were wrong, this would almost certainly not match by accident). Run with -s to see the printed telemetry. """ @@ -11,15 +12,17 @@ from cm_interface.core_config import CORE_CONFIG -def test_device_id_identifies_si5395(hw_registry): - """Read-only wiring sanity check: DEVICE_ID must read back 0x5395.""" +def test_device_id_matches_board_revision(hw_registry): + """Read-only wiring sanity check: DEVICE_ID must be the expected chip.""" + print(f"hw rev: {hw_registry.hw_rev}") mismatches = [] for name in CORE_CONFIG.clocks: clock = hw_registry.get_clock(name) device_id = clock.get_device_id() - print(f"{name}: device_id=0x{device_id:04X}") - if device_id != 0x5395: - mismatches.append(f"{name}: device_id=0x{device_id:04X}, expected 0x5395") + print(f"{name}: {clock.CHIP} device_id=0x{device_id:04X}") + if device_id != clock.EXPECTED_PN: + mismatches.append(f"{name}: device_id=0x{device_id:04X}, " + f"expected 0x{clock.EXPECTED_PN:04X} ({clock.CHIP})") assert not mismatches, "\n".join(mismatches) diff --git a/tests/test_clock_checks.py b/tests/test_clock_checks.py new file mode 100644 index 0000000..ac94891 --- /dev/null +++ b/tests/test_clock_checks.py @@ -0,0 +1,250 @@ +import contextlib +import io +import unittest + +from cm_interface import clock_checks as checks +from cm_interface import clock_dump +from cm_interface.device.si5341 import Si5341Clock +from cm_interface.device.si5395 import Clock + + +class FakeUART: + """Answers reads from a register dict (missing registers read 0x00).""" + + def __init__(self, regs): + self.regs = regs + self.writes = [] + + def write(self, data): + self.writes.append(data) + + def readline(self): + fields = self.writes[-1].decode("ascii").split() + if fields[0] == "w": + return b"c\n" + addr = (int(fields[3], 16) << 8) | int(fields[4], 16) + data = [self.regs.get(addr + k, 0) for k in range(int(fields[5], 16))] + return ("d " + " ".join("%02X" % b for b in data) + "\n").encode("ascii") + + +# Board 3002 R0A, 2026-10-14: free run from XAXB, all inputs and monitors off. +FREE_RUN = { + 0x000C: 0x00, 0x000D: 0x00, 0x000E: 0x22, 0x000F: 0xD0, + 0x0011: 0x1F, 0x0012: 0xF0, 0x0013: 0x22, 0x0014: 0xF2, + 0x002C: 0x00, 0x003F: 0x00, 0x009A: 0x00, 0x0102: 0x01, 0x0145: 0x00, + 0x0507: 0x00, 0x052A: 0x01, 0x0535: 0x01, 0x0949: 0x00, 0x00FE: 0x0F, +} + +# Locked to IN1 with IN0 and IN1 enabled and all monitors on. +LOCKED_IN1 = { + 0x002C: 0x03, 0x003F: 0x03, 0x009A: 0x02, 0x0102: 0x01, + 0x0507: 0x40, 0x0535: 0x00, 0x0949: 0x03, 0x00FE: 0x0F, +} + + +def _with(base, changes): + regs = dict(base) + regs.update(changes) + return regs + + +def _run(func, regs): + clock = Clock(FakeUART(regs), address=0x10, name="R0A") + out = io.StringIO() + with contextlib.redirect_stdout(out): + ok = func(clock, checks.read_monitors(clock)) + return ok, out.getvalue() + + +class FreeRunTest(unittest.TestCase): + def test_lol_and_hold_are_expected_in_free_run(self): + ok, text = _run(checks.check_status, FREE_RUN) + self.assertTrue(ok, text) + self.assertIn("expected: LOL (free run", text) + self.assertIn("expected: HOLD (free run", text) + self.assertIn("FORCE_HOLD=1", text) + + def test_flags_oof_on_disabled_inputs_expected_but_power_up_flags_fail(self): + ok, text = _run(checks.check_flags, FREE_RUN) + self.assertFalse(ok) + self.assertIn("expected: OOF_FLG IN3 (IN3 disabled)", text) + self.assertIn("expected: LOL_FLG", text) + self.assertIn("-> SYSINCAL_FLG (also latches during clock load", text) + self.assertIn("-> CAL_FLG_PLL", text) + + def test_flags_pass_once_power_up_flags_cleared(self): + regs = _with(FREE_RUN, {0x0011: 0x00, 0x0014: 0x00}) + ok, text = _run(checks.check_flags, regs) + self.assertTrue(ok, text) + + +class LockedTest(unittest.TestCase): + def test_lol_is_a_problem_when_input_enabled_and_detection_on(self): + ok, text = _run(checks.check_status, _with(LOCKED_IN1, {0x000E: 0x02})) + self.assertFalse(ok) + self.assertIn("-> LOL", text) + + def test_lol_expected_when_detection_off(self): + regs = _with(LOCKED_IN1, {0x000E: 0x02, 0x009A: 0x00}) + ok, text = _run(checks.check_status, regs) + self.assertTrue(ok, text) + self.assertIn("LOL detection off", text) + + def test_hold_is_a_problem_when_not_forced(self): + ok, text = _run(checks.check_status, _with(LOCKED_IN1, {0x000E: 0x20})) + self.assertFalse(ok) + self.assertIn("-> HOLD", text) + + def test_los_on_active_input_fails_on_standby_input_expected(self): + ok, text = _run(checks.check_status, _with(LOCKED_IN1, {0x000D: 0x03})) + self.assertFalse(ok) + self.assertIn("-> LOS IN1", text) + self.assertIn("expected: LOS IN0 (IN0 not the active input)", text) + + def test_oof_with_detector_off_expected(self): + regs = _with(LOCKED_IN1, {0x000D: 0x20, 0x003F: 0x01}) + ok, text = _run(checks.check_status, regs) + self.assertTrue(ok, text) + self.assertIn("OOF detection off for IN1", text) + + +# Board 210 (Rev2) R0A Si5341A-D, 2026-10-16, firmware 5221fc7 (int_test/cm210_R0A.txt): +# locked to IN0 selected by register, only IN0 enabled and monitored, XAXB LOS +# disabled. The undefined bits set in 0x000C/0x000D/0x0011/0x0012/0x0021/0x002C +# are as read from the chip; the checks must ignore them. +BOARD210_R0A = { + 0x0002: 0x41, 0x0003: 0x53, 0x0004: 0x00, 0x0005: 0x03, + 0x000C: 0xC0, 0x000D: 0xF0, 0x0011: 0xD0, 0x0012: 0xF0, + 0x0021: 0x09, 0x002C: 0x31, 0x00E2: 0x03, 0x00FE: 0x0F, + 0x0102: 0x01, 0x0145: 0x00, 0x0206: 0x00, 0x090E: 0x00, + 0x091C: 0x04, 0x0949: 0x01, 0x094A: 0x10, +} +BOARD210_R0A.update({0x026B + i: b for i, b in enumerate(b"R0Av0006")}) +# Variant: reference IN1 by register, IN0/IN1/FB_IN enabled and monitored. +SI5341_IN1 = _with(BOARD210_R0A, {0x0021: 0x0B, 0x002C: 0x3B, 0x0949: 0x0B}) + + +def _run_5341(func, regs): + clock = Si5341Clock(FakeUART(regs), address=0x10, name="R0A") + out = io.StringIO() + with contextlib.redirect_stdout(out): + ok = func(clock, checks.read_monitors_5341(clock)) + return ok, out.getvalue() + + +class Si5341StatusTest(unittest.TestCase): + def test_clean_status_passes_and_reads_no_si5395_registers(self): + clock = Si5341Clock(FakeUART(BOARD210_R0A), address=0x10, name="R0A") + out = io.StringIO() + with contextlib.redirect_stdout(out): + ok = checks.check_clock(clock, list(checks.CHECKS)) + text = out.getvalue() + self.assertTrue(ok, text) + self.assertIn("raw 0x000C-0x000D", text) + self.assertIn("raw 0x000C-0x000D = C0 F0 locked", text) + self.assertIn("reference IN0 (IN_SEL register), inputs enabled: IN0", text) + self.assertIn("raw 0x0011-0x0012 = D0 F0", text) + reads = [(int(f[3], 16) << 8) | int(f[4], 16) + for f in (w.decode().split() for w in clock.uart.writes)] + for addr in (0x000E, 0x000F, 0x0013, 0x0014, 0x0507, 0x0535, 0x003F, + 0x009A, 0x052A): + self.assertNotIn(addr, reads, hex(addr)) + + def test_lol_is_bit_3_and_always_a_problem(self): + ok, text = _run_5341(checks.check_status_5341, _with(BOARD210_R0A, {0x000C: 0xC8})) + self.assertFalse(ok) + self.assertIn("UNLOCKED", text) + self.assertIn("-> LOL", text) + + def test_bit_1_is_losxaxb_not_lol(self): + # Board 210 has LOSXAXB_DIS=1 (crystal powered down, IN0 reference). + ok, text = _run_5341(checks.check_status_5341, _with(BOARD210_R0A, {0x000C: 0xC2})) + self.assertTrue(ok, text) + self.assertIn("expected: LOSXAXB (XAXB LOS detection off (LOSXAXB_DIS=1))", text) + self.assertNotIn("LOL", text) + regs = _with(BOARD210_R0A, {0x000C: 0xC2, 0x002C: 0x21}) + ok, text = _run_5341(checks.check_status_5341, regs) + self.assertFalse(ok) + self.assertIn("-> LOSXAXB", text) + + def test_losref_is_a_problem(self): + ok, text = _run_5341(checks.check_status_5341, _with(BOARD210_R0A, {0x000C: 0xC4})) + self.assertFalse(ok) + self.assertIn("-> LOSREF", text) + + def test_los_on_unselected_input_expected_on_reference_fails(self): + ok, text = _run_5341(checks.check_status_5341, _with(SI5341_IN1, {0x000D: 0xF3})) + self.assertFalse(ok) + self.assertIn("-> LOS IN1", text) + self.assertIn("expected: LOS IN0 (IN0 not the selected reference)", text) + + def test_los_on_disabled_or_unmonitored_input_expected(self): + ok, text = _run_5341(checks.check_status_5341, _with(SI5341_IN1, {0x000D: 0xF4})) + self.assertTrue(ok, text) + self.assertIn("IN2 disabled", text) + regs = _with(SI5341_IN1, {0x000D: 0xF2, 0x002C: 0x39}) + ok, text = _run_5341(checks.check_status_5341, regs) + self.assertTrue(ok, text) + self.assertIn("LOS detection off for IN1", text) + + def test_fb_in_los_expected_outside_zero_delay_mode(self): + ok, text = _run_5341(checks.check_status_5341, _with(SI5341_IN1, {0x000D: 0xF8})) + self.assertTrue(ok, text) + self.assertIn("FB_IN unused outside zero delay mode", text) + regs = _with(SI5341_IN1, {0x000D: 0xF8, 0x091C: 0x03}) + ok, text = _run_5341(checks.check_status_5341, regs) + self.assertFalse(ok) + self.assertIn("-> LOS FB_IN", text) + + def test_pin_selected_reference_flags_los_as_unknown(self): + regs = _with(SI5341_IN1, {0x0021: 0x08, 0x000D: 0xF1}) + ok, text = _run_5341(checks.check_status_5341, regs) + self.assertFalse(ok) + self.assertIn("selected by IN_SEL pins", text) + self.assertIn("-> LOS IN0 (reference selected by pins; cannot tell if used)", text) + + def test_flags_two_bytes_no_power_up_note(self): + regs = _with(BOARD210_R0A, {0x0011: 0xD9, 0x0012: 0xF8}) + ok, text = _run_5341(checks.check_flags_5341, regs) + self.assertFalse(ok) + self.assertIn("raw 0x0011-0x0012 = D9 F8", text) + self.assertIn("-> LOL_FLG\n", text) + self.assertIn("expected: LOS_FLG FB_IN", text) + + def test_id_expects_5341(self): + ok, text = _run_5341(checks.check_id, BOARD210_R0A) + self.assertTrue(ok, text) + self.assertIn("DEVICE_ID 0x5341 part Si5341A-D", text) + self.assertIn("DESIGN_ID 'R0Av0006' ACTIVE_NVM_BANK 0x03 (no user NVM burn)", text) + + +class WrongChipTest(unittest.TestCase): + def test_check_clock_skips_on_pn_mismatch(self): + # Si5395 PN where the board revision says Si5341. + regs = _with(BOARD210_R0A, {0x0002: 0x95}) + clock = Si5341Clock(FakeUART(regs), address=0x10, name="R0A") + out = io.StringIO() + with contextlib.redirect_stdout(out): + ok = checks.check_clock(clock, list(checks.CHECKS)) + self.assertFalse(ok) + self.assertIn("WRONG CHIP", out.getvalue()) + self.assertNotIn("status:", out.getvalue()) + + +class DumpLayoutTest(unittest.TestCase): + def test_si5341_reads_no_si5395_only_registers(self): + addrs = set(clock_dump.addresses(clock_dump.LAYOUTS["Si5341"])) + for addr in (0x000E, 0x000F, 0x0013, 0x0014, 0x0103, 0x0135, 0x0247, 0x0265, + 0x0507, 0x0515, 0x052A, 0x0535, 0x003F, 0x009A, 0x0A02, 0x0A14): + self.assertNotIn(addr, addrs, hex(addr)) + for addr in (0x000C, 0x0021, 0x0108, 0x013A, 0x024A, 0x0268, 0x0235, 0x091C): + self.assertIn(addr, addrs, hex(addr)) + + def test_si5395_default_layout(self): + self.assertEqual(clock_dump.addresses(), + clock_dump.addresses(clock_dump.LAYOUTS["Si5395"])) + self.assertIn(0x0515, clock_dump.addresses()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_si5395_checks.py b/tests/test_si5395_checks.py deleted file mode 100644 index 7a78d77..0000000 --- a/tests/test_si5395_checks.py +++ /dev/null @@ -1,110 +0,0 @@ -import contextlib -import io -import unittest - -from cm_interface import si5395_checks as checks -from cm_interface.device.si5395 import Clock - - -class FakeUART: - """Answers reads from a register dict (missing registers read 0x00).""" - - def __init__(self, regs): - self.regs = regs - self.writes = [] - - def write(self, data): - self.writes.append(data) - - def readline(self): - fields = self.writes[-1].decode("ascii").split() - if fields[0] == "w": - return b"c\n" - addr = (int(fields[3], 16) << 8) | int(fields[4], 16) - data = [self.regs.get(addr + k, 0) for k in range(int(fields[5], 16))] - return ("d " + " ".join("%02X" % b for b in data) + "\n").encode("ascii") - - -# Board 3002 R0A, 2026-10-14: free run from XAXB, all inputs and monitors off. -FREE_RUN = { - 0x000C: 0x00, 0x000D: 0x00, 0x000E: 0x22, 0x000F: 0xD0, - 0x0011: 0x1F, 0x0012: 0xF0, 0x0013: 0x22, 0x0014: 0xF2, - 0x002C: 0x00, 0x003F: 0x00, 0x009A: 0x00, 0x0102: 0x01, 0x0145: 0x00, - 0x0507: 0x00, 0x052A: 0x01, 0x0535: 0x01, 0x0949: 0x00, 0x00FE: 0x0F, -} - -# Locked to IN1 with IN0 and IN1 enabled and all monitors on. -LOCKED_IN1 = { - 0x002C: 0x03, 0x003F: 0x03, 0x009A: 0x02, 0x0102: 0x01, - 0x0507: 0x40, 0x0535: 0x00, 0x0949: 0x03, 0x00FE: 0x0F, -} - - -def _with(base, changes): - regs = dict(base) - regs.update(changes) - return regs - - -def _run(func, regs): - clock = Clock(FakeUART(regs), address=0x10, name="R0A") - out = io.StringIO() - with contextlib.redirect_stdout(out): - ok = func(clock, checks.read_monitors(clock)) - return ok, out.getvalue() - - -class FreeRunTest(unittest.TestCase): - def test_lol_and_hold_are_expected_in_free_run(self): - ok, text = _run(checks.check_status, FREE_RUN) - self.assertTrue(ok, text) - self.assertIn("expected: LOL (free run", text) - self.assertIn("expected: HOLD (free run", text) - self.assertIn("FORCE_HOLD=1", text) - - def test_flags_oof_on_disabled_inputs_expected_but_power_up_flags_fail(self): - ok, text = _run(checks.check_flags, FREE_RUN) - self.assertFalse(ok) - self.assertIn("expected: OOF_FLG IN3 (IN3 disabled)", text) - self.assertIn("expected: LOL_FLG", text) - self.assertIn("-> SYSINCAL_FLG (also latched at power-up", text) - self.assertIn("-> CAL_FLG_PLL", text) - - def test_flags_pass_once_power_up_flags_cleared(self): - regs = _with(FREE_RUN, {0x0011: 0x00, 0x0014: 0x00}) - ok, text = _run(checks.check_flags, regs) - self.assertTrue(ok, text) - - -class LockedTest(unittest.TestCase): - def test_lol_is_a_problem_when_input_enabled_and_detection_on(self): - ok, text = _run(checks.check_status, _with(LOCKED_IN1, {0x000E: 0x02})) - self.assertFalse(ok) - self.assertIn("-> LOL", text) - - def test_lol_expected_when_detection_off(self): - regs = _with(LOCKED_IN1, {0x000E: 0x02, 0x009A: 0x00}) - ok, text = _run(checks.check_status, regs) - self.assertTrue(ok, text) - self.assertIn("LOL detection off", text) - - def test_hold_is_a_problem_when_not_forced(self): - ok, text = _run(checks.check_status, _with(LOCKED_IN1, {0x000E: 0x20})) - self.assertFalse(ok) - self.assertIn("-> HOLD", text) - - def test_los_on_active_input_fails_on_standby_input_expected(self): - ok, text = _run(checks.check_status, _with(LOCKED_IN1, {0x000D: 0x03})) - self.assertFalse(ok) - self.assertIn("-> LOS IN1", text) - self.assertIn("expected: LOS IN0 (IN0 not the active input)", text) - - def test_oof_with_detector_off_expected(self): - regs = _with(LOCKED_IN1, {0x000D: 0x20, 0x003F: 0x01}) - ok, text = _run(checks.check_status, regs) - self.assertTrue(ok, text) - self.assertIn("OOF detection off for IN1", text) - - -if __name__ == "__main__": - unittest.main() From a1cd5e3d08eedebb627a71f7fe1c1ad4b304e536 Mon Sep 17 00:00:00 2001 From: Peter Wittich Date: Fri, 16 Oct 2026 15:58:11 -0400 Subject: [PATCH 3/5] add Si5341 (rev2), generalize --- device/clock.py | 219 ++++++++++++++++++++++++++++++++++++++++ device/si5341.py | 151 ++++++++++++++++++++++++++++ tests/test_si5341.py | 233 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 603 insertions(+) create mode 100644 device/clock.py create mode 100644 device/si5341.py create mode 100644 tests/test_si5341.py diff --git a/device/clock.py b/device/clock.py new file mode 100644 index 0000000..350fe76 --- /dev/null +++ b/device/clock.py @@ -0,0 +1,219 @@ +"""Shared base for the Skyworks/Silicon Labs clock chips on the CM. + +R0A is an Si5341 on Rev2 boards and an Si5395 on Rev3; R0B-R1C are Si5395 +on both. The registers below are at the same address with the same meaning +on both parts (Si5395/94/92 RM Rev 1.3 and Si5341/40 Rev D RM Rev 1.3 and +1.1 agree); everything else lives in the chip subclasses. +""" +from typing import Optional, Tuple + +from ..compat import dataclass +from ..errors import ClockIdentityError, ClockNvmWriteRefused +from .base import Device + +PN_BASE = 0x0002 +GRADE = 0x0004 +DEVICE_REV = 0x0005 +TEMP_GRADE = 0x0009 +PKG_ID = 0x000A +SOFT_RESET = 0x001C +POWER_DOWN = 0x001E # bit 1 HARD_RST +NVM_WRITE = 0x00E3 +NVM_READ_BANK = 0x00E4 +DEVICE_READY = 0x00FE +DESIGN_ID = 0x026B +DEVICE_READY_VALUE = 0x0F + +# Writing these starts an NVM burn / bank read on the chip. Nothing in this +# package should do that by accident, so SiClock.write_reg refuses them. +_NVM_CONTROL_REGS = frozenset((NVM_WRITE, NVM_READ_BANK)) + + +@dataclass(frozen=True) +class ClockHealth: + """Decoded live status from a clock chip. + + A field is ``None`` when the chip has no such status bit (the Si5341 has + no holdover, PLL calibration or XAXB_ERR status, and the Si5395 has no + LOSREF). ``out_of_frequency`` is empty on the Si5341, which has no OOF + monitors. ``loss_of_signal`` is per input: IN0-IN3 on the Si5395, + IN0, IN1, IN2, FB_IN on the Si5341. + """ + + system_calibrating: bool + xa_xb_loss_of_signal: bool + xa_xb_error: Optional[bool] + ref_loss_of_signal: Optional[bool] + smbus_timeout: bool + loss_of_signal: Tuple[bool, ...] + out_of_frequency: Tuple[bool, ...] + loss_of_lock: bool + holdover: Optional[bool] + pll_calibrating: Optional[bool] + ready: bool + + @property + def healthy(self) -> bool: + return not any(( + self.system_calibrating, + self.xa_xb_loss_of_signal, + self.xa_xb_error, + self.ref_loss_of_signal, + self.smbus_timeout, + any(self.loss_of_signal), + any(self.out_of_frequency), + self.loss_of_lock, + self.holdover, + self.pll_calibrating, + )) and self.ready + + +class SiClock(Device): + """Common behavior of the clock chips reached over ProgCom ``CL``. + + Subclasses set ``CHIP``, ``EXPECTED_PN``, ``REG`` (their register + IntEnum) and implement ``health``. + + With ``verify_identity=True`` (what :class:`~cm_interface.registry.Registry` + passes) the first ``health`` read or register write checks PN_BASE + against ``EXPECTED_PN`` and raises :class:`ClockIdentityError` on a + mismatch. The check is skipped while the chip is not ready, because the + reference manuals permit no access but DEVICE_READY until then. + """ + + CHIP = None + EXPECTED_PN = None + REG = None + + def __init__(self, uart, address: int, name: str, verify_identity: bool = False): + super().__init__(uart, address) + self.name = name + self._verify = verify_identity + self._identity_ok = False + + def _encode_command(self, reg: int, write: bool, payload: bytes = b"", + read_size: int = 1) -> bytes: + return self._encode_ascii_command( + "CL", self.address - 0x10, reg, write, payload, read_size + ) + + def _decode_response(self, raw: bytes) -> bytes: + return self._decode_ascii_response(raw) + + def verify_identity(self) -> int: + """Read PN_BASE and raise ``ClockIdentityError`` unless it is ``EXPECTED_PN``. + + Returns the part number. Call only once :meth:`is_ready` is True. + """ + pn = self.get_device_id() + if pn != self.EXPECTED_PN: + raise ClockIdentityError( + "%s: PN_BASE reads 0x%04X, expected 0x%04X (%s) for this board " + "revision; check the MCU EEPROM revision (first_mcu / set_id) " + "and that the firmware was built for this board" + % (self.name, pn, self.EXPECTED_PN, self.CHIP) + ) + self._identity_ok = True + return pn + + def _ensure_identity(self) -> None: + if self._verify and not self._identity_ok and self.is_ready(): + self.verify_identity() + + def write_reg(self, reg: int, data: bytes, allow_nvm: bool = False) -> None: + """Write registers, refusing any write that touches an NVM control register. + + ``NVM_WRITE`` (0x00E3) burns the chip's non-volatile memory and + ``NVM_READ_BANK`` (0x00E4) loads a bank; a stray write to either is not + recoverable by this package. A multi-byte write that spans one of them + is refused too. ``allow_nvm=True`` is the explicit opt-in. + """ + start = int(reg) + touched = [r for r in range(start, start + len(data)) if r in _NVM_CONTROL_REGS] + if touched and not allow_nvm: + raise ClockNvmWriteRefused( + "refusing write to %s NVM control register 0x%04X; " + "pass allow_nvm=True to override" % (self.CHIP, touched[0]) + ) + self._ensure_identity() + super().write_reg(reg, data) + + @property + def health(self) -> ClockHealth: + raise NotImplementedError + + @property + def mode(self) -> str: + """Return a mode inferred from documented live status registers.""" + health = self.health + if health.system_calibrating or health.pll_calibrating: + return "calibrating" + if health.holdover: + return "holdover" + if health.loss_of_lock: + return "unlocked" + return "locked" + + @property + def status(self) -> ClockHealth: + """Compatibility name for the structured live health snapshot.""" + return self.health + + def is_ready(self) -> bool: + """Return whether the device has finished its startup calibration. + + This reads *only* DEVICE_READY, which is the sole register access + the manufacturer's startup sequence permits until the device + reports ready -- callers must not read/write anything else + beforehand. The device is ready only when this register reads + exactly ``0x0F``; any other nonzero value is still "not ready", + not a partial-ready state. + """ + return self.read_reg(DEVICE_READY)[0] == DEVICE_READY_VALUE + + def get_device_id(self) -> int: + """Read PN_BASE (0x5395 or 0x5341).""" + data = self.read_reg(PN_BASE, size=2) + return int.from_bytes(data, "little") + + @property + def device_id(self) -> int: + return self.get_device_id() + + def get_device_rev(self) -> int: + """Read device revision.""" + return self.read_reg(DEVICE_REV)[0] + + @property + def device_revision(self) -> int: + return self.get_device_rev() + + @property + def grade(self) -> int: + return self.read_reg(GRADE)[0] + + @property + def temperature_grade(self) -> int: + return self.read_reg(TEMP_GRADE)[0] + + @property + def package_id(self) -> int: + return self.read_reg(PKG_ID)[0] + + @property + def design_id(self) -> str: + return self.read_ascii(DESIGN_ID, 8) + + def reset(self, soft: bool = True) -> None: + """Perform device reset.""" + if soft: + self.write_reg(SOFT_RESET, b"\x01") + else: + self.write_reg(POWER_DOWN, bytes([0x02])) + + def get_register_name(self, addr: int) -> str: + """Get symbolic name for a register address.""" + for reg in self.REG: + if reg.value == addr: + return reg.name + return f"0x{addr:04X}" diff --git a/device/si5341.py b/device/si5341.py new file mode 100644 index 0000000..563cd31 --- /dev/null +++ b/device/si5341.py @@ -0,0 +1,151 @@ +"""Si5341 clock generator (R0A on Rev2 boards). + +Register map from the Si5341/40 Rev D Family Reference Manual, Rev 1.3 +(2021-12-07) chapter 14, cross-checked against Rev 1.1. Differences from the +Si5395 that matter here: + +* LOL is 0x000C bit 3 (on the Si5395 that bit is XAXB_ERR, and LOL is + 0x000E bit 1). +* There is no 0x000E/0x000F status, no holdover, no OOF monitor and no + sticky 0x0013/0x0014. +* 0x000D/0x0012 bits 3:0 are LOS for IN0, IN1, IN2, FB_IN. +* Input selection is 0x0021 (IN_SEL 3 = XA/XB), not 0x052A. +""" +from enum import IntEnum + +from .clock import ClockHealth, SiClock + +__all__ = ["Si5341Clock", "Si5341Reg", "Si5341StatusBits"] + + +class Si5341Reg(IntEnum): + """Si5341 registers used by this package (Si5341/40 RM Rev 1.3, ch. 14).""" + PAGE = 0x0001 + DEVICE_ID = 0x0002 # PN_BASE, 2 bytes little-endian + GRADE = 0x0004 + DEVICE_REV = 0x0005 + TEMP_GRADE = 0x0009 + PKG_ID = 0x000A + I2C_ADDR = 0x000B + + # Status: SYSINCAL, LOSXAXB, LOSREF, LOL, SMBUS_TIMEOUT + STATUS = 0x000C + LOS_STATUS = 0x000D # LOSIN[3:0] = FB_IN, IN2, IN1, IN0 + + # Sticky flags + STATUS_FLAG = 0x0011 + LOS_FLAG = 0x0012 + + # Interrupt masks + INT_MASK_0 = 0x0017 + INT_MASK_1 = 0x0018 + + # Reset and control + SOFT_RESET = 0x001C + FINC_FDEC = 0x001D + POWER_DOWN = 0x001E # bit 0 PDN, bit 1 HARD_RST, bit 2 SYNC + IN_SEL_REGCTRL = 0x0021 # bit 0 REGCTRL, bits 2:1 IN_SEL + + # LOS configuration + LOS_ENABLE = 0x002C # bits 3:0 LOS_EN, bit 4 LOSXAXB_DIS + LOL_SET_THR = 0x009E + + # NVM + ACTIVE_NVM_BANK = 0x00E2 + NVM_WRITE = 0x00E3 + NVM_READ_BANK = 0x00E4 + + DEVICE_READY = 0x00FE + + # Outputs + OUTALL_DISABLE = 0x0102 + OUT_PDN_ALL = 0x0145 + + # Dividers and reference + PXAXB = 0x0206 + M_NUM = 0x0235 + M_DEN = 0x023B + DESIGN_ID = 0x026B + OPN_ID = 0x0278 + XAXB_EXTCLK_EN = 0x090E + ZDM_EN = 0x091C + IN_EN = 0x0949 # bits 3:0 = FB_IN, IN2, IN1, IN0 + IN_TO_PFD_EN = 0x094A # bits 6:4 = IN2, IN1, IN0 + + # Frequency-change preamble/postamble (RM section 7) + FREQ_CHANGE_PREAMBLE_1 = 0x0B24 + FREQ_CHANGE_PREAMBLE_2 = 0x0B25 + + +class Si5341StatusBits(IntEnum): + """Bits of 0x000C (and of the sticky copy 0x0011).""" + SYSINCAL = 0 + LOSXAXB = 1 + LOSREF = 2 + LOL = 3 + SMBUS_TIMEOUT = 5 + + +class Si5341Clock(SiClock): + """Concrete representation of an Si5341 clock generator. + + Same constructor as :class:`~cm_interface.device.si5395.Si5395Clock`. + The Si5395-only operations (holdover, input select register 0x052A, + hitless switching) do not exist here. + """ + + CHIP = "Si5341" + EXPECTED_PN = 0x5341 + REG = Si5341Reg + STICKY_FLAG_BYTES = 2 + + @property + def health(self) -> ClockHealth: + """Live health snapshot for post-startup monitoring. + + Do not call this until :meth:`is_ready` returns ``True`` -- + the registers read here are not part of the startup-safe set. + """ + self._ensure_identity() + status, los = self.read_reg(Si5341Reg.STATUS, size=2) + ready = self.is_ready() + return ClockHealth( + system_calibrating=bool(status & (1 << Si5341StatusBits.SYSINCAL)), + xa_xb_loss_of_signal=bool(status & (1 << Si5341StatusBits.LOSXAXB)), + xa_xb_error=None, + ref_loss_of_signal=bool(status & (1 << Si5341StatusBits.LOSREF)), + smbus_timeout=bool(status & (1 << Si5341StatusBits.SMBUS_TIMEOUT)), + loss_of_signal=tuple(bool(los & (1 << i)) for i in range(4)), + out_of_frequency=(), + loss_of_lock=bool(status & (1 << Si5341StatusBits.LOL)), + holdover=None, + pll_calibrating=None, + ready=ready, + ) + + @property + def lol_status(self) -> int: + """Get the status register that holds LOL (0x000C).""" + return self.read_reg(Si5341Reg.STATUS)[0] + + @property + def los_status(self) -> int: + """Get Loss of Signal status for IN0, IN1, IN2, FB_IN.""" + return self.read_reg(Si5341Reg.LOS_STATUS)[0] + + def is_locked(self) -> bool: + """Check if the PLL is locked to its reference.""" + return not self.lol_status & (1 << Si5341StatusBits.LOL) + + def has_loss_of_signal(self, input_num: int = 0) -> bool: + """Check LOS on input 0-2, or 3 for FB_IN.""" + if not 0 <= input_num <= 3: + raise ValueError("input number must be between 0 and 3") + return bool(self.los_status & (1 << input_num)) + + @property + def sticky_flags(self) -> bytes: + return self.read_reg(Si5341Reg.STATUS_FLAG, size=2) + + def clear_sticky_flags(self) -> None: + self.write_reg(Si5341Reg.STATUS_FLAG, bytes(2)) diff --git a/tests/test_si5341.py b/tests/test_si5341.py new file mode 100644 index 0000000..a44bf7e --- /dev/null +++ b/tests/test_si5341.py @@ -0,0 +1,233 @@ +"""Si5341 (R0A on Rev2) device class, the shared SiClock identity check, and +Registry's board-revision clock selection. + +Status bytes in SI5341_READY are from board 210 (Rev2) R0A, 2026-10-16 +(int_test/cm210_R0A.txt), including the undefined bits the chip sets; the +fault cases are built from the Si5341/40 Rev D RM Rev 1.3 chapter 14. +""" +import unittest +from unittest.mock import MagicMock + +from cm_interface.core_config import CORE_CONFIG +from cm_interface.device.si5341 import Si5341Clock, Si5341Reg +from cm_interface.device.si5395 import Si5395Clock +from cm_interface.errors import ClockIdentityError, ClockNvmWriteRefused +from cm_interface.registry import Registry + + +class RegUART: + """Answers reads from a register dict (missing registers read 0x00).""" + + def __init__(self, regs): + self.regs = regs + self.writes = [] + + def write(self, data): + self.writes.append(data) + + def readline(self): + fields = self.writes[-1].decode("ascii").split() + if fields[0] == "w": + return b"c\n" + addr = (int(fields[3], 16) << 8) | int(fields[4], 16) + data = [self.regs.get(addr + k, 0) for k in range(int(fields[5], 16))] + return ("d " + " ".join("%02X" % b for b in data) + "\n").encode("ascii") + + def read_addrs(self): + out = [] + for cmd in self.writes: + f = cmd.decode("ascii").split() + if f[0] == "r": + out.append((int(f[3], 16) << 8) | int(f[4], 16)) + return out + + +SI5341_READY = {0x00FE: 0x0F, 0x0002: 0x41, 0x0003: 0x53, + 0x000C: 0xC0, 0x000D: 0xF0, 0x0011: 0xD0, 0x0012: 0xF0} +SI5395_READY = {0x00FE: 0x0F, 0x0002: 0x95, 0x0003: 0x53} + + +def _clock(cls, regs, verify=False): + uart = RegUART(dict(regs)) + return cls(uart, address=0x10, name="R0A", verify_identity=verify), uart + + +class Si5341HealthTest(unittest.TestCase): + def test_lol_is_status_bit_3(self): + clock, _ = _clock(Si5341Clock, SI5341_READY) + clock.uart.regs[0x000C] = 0xC8 + health = clock.health + self.assertTrue(health.loss_of_lock) + self.assertFalse(health.system_calibrating) + self.assertIsNone(health.xa_xb_error) + self.assertFalse(health.healthy) + + def test_each_status_bit(self): + clock, _ = _clock(Si5341Clock, SI5341_READY) + clock.uart.regs.update({0x000C: 0xE7, 0x000D: 0xF9}) + h = clock.health + self.assertTrue(h.system_calibrating) + self.assertTrue(h.xa_xb_loss_of_signal) + self.assertTrue(h.ref_loss_of_signal) + self.assertFalse(h.loss_of_lock) + self.assertTrue(h.smbus_timeout) + self.assertEqual(h.loss_of_signal, (True, False, False, True)) + + def test_captured_board210_status_is_healthy(self): + clock, _ = _clock(Si5341Clock, SI5341_READY) + h = clock.health + self.assertIsNone(h.holdover) + self.assertIsNone(h.pll_calibrating) + self.assertEqual(h.out_of_frequency, ()) + self.assertTrue(h.healthy) + self.assertEqual(clock.mode, "locked") + + def test_health_never_reads_si5395_only_registers(self): + clock, uart = _clock(Si5341Clock, SI5341_READY) + clock.health + self.assertEqual(uart.writes[0], b"r CL 0 0 C 2\n") + for addr in (0x000E, 0x000F, 0x0013, 0x0014): + self.assertNotIn(addr, uart.read_addrs()) + + def test_is_locked(self): + clock, _ = _clock(Si5341Clock, {0x000C: 0x00}) + self.assertTrue(clock.is_locked()) + clock, _ = _clock(Si5341Clock, {0x000C: 0x08}) + self.assertFalse(clock.is_locked()) + # Si5395's LOL position (bit 1) is LOSXAXB here, not LOL. + clock, _ = _clock(Si5341Clock, {0x000C: 0x02}) + self.assertTrue(clock.is_locked()) + + def test_sticky_flags_are_two_bytes(self): + clock, uart = _clock(Si5341Clock, SI5341_READY) + self.assertEqual(clock.sticky_flags, b"\xD0\xF0") + clock.clear_sticky_flags() + self.assertEqual(uart.writes[-1], b"w CL 0 0 11 00 00\n") + + def test_si5395_only_operations_absent(self): + for attr in ("is_in_holdover", "set_input_select", "enable_hitless_switching"): + self.assertFalse(hasattr(Si5341Clock, attr), attr) + + +class Si5341SharedBehaviorTest(unittest.TestCase): + def test_nvm_guard(self): + clock, uart = _clock(Si5341Clock, {}) + with self.assertRaises(ClockNvmWriteRefused): + clock.write_reg(Si5341Reg.NVM_WRITE, b"\xC7") + self.assertEqual(uart.writes, []) + + def test_reset_wire_format(self): + clock, uart = _clock(Si5341Clock, {}) + clock.reset(soft=True) + clock.reset(soft=False) + self.assertEqual(uart.writes, [b"w CL 0 0 1C 01\n", b"w CL 0 0 1E 02\n"]) + + def test_device_id(self): + clock, _ = _clock(Si5341Clock, SI5341_READY) + self.assertEqual(clock.device_id, 0x5341) + + +class IdentityCheckTest(unittest.TestCase): + def test_mismatch_raises_before_health_read(self): + clock, uart = _clock(Si5341Clock, SI5395_READY, verify=True) + with self.assertRaisesRegex(ClockIdentityError, "0x5395.*0x5341"): + clock.health + self.assertNotIn(0x000C, uart.read_addrs()) + + def test_mismatch_blocks_writes(self): + clock, uart = _clock(Si5395Clock, SI5341_READY, verify=True) + with self.assertRaises(ClockIdentityError): + clock.clear_sticky_flags() + self.assertFalse(any(w.startswith(b"w ") for w in uart.writes)) + + def test_match_is_checked_once(self): + clock, uart = _clock(Si5341Clock, SI5341_READY, verify=True) + clock.health + clock.health + self.assertEqual(uart.read_addrs().count(0x0002), 1) + + def test_not_ready_skips_the_check(self): + clock, uart = _clock(Si5341Clock, {0x00FE: 0x00, 0x0002: 0x95, 0x0003: 0x53}, + verify=True) + clock.reset() + self.assertNotIn(0x0002, uart.read_addrs()) + self.assertEqual(uart.writes[-1], b"w CL 0 0 1C 01\n") + + def test_off_by_default(self): + clock, uart = _clock(Si5341Clock, SI5395_READY) + clock.health + self.assertNotIn(0x0002, uart.read_addrs()) + + def test_verify_identity_returns_pn(self): + clock, _ = _clock(Si5395Clock, SI5395_READY) + self.assertEqual(clock.verify_identity(), 0x5395) + + +class RegistryRevisionTest(unittest.TestCase): + def tearDown(self): + Registry._instance = None + + def _types(self, registry): + return {name: type(clock) for name, clock in registry.clocks.items()} + + def test_rev2_has_si5341_at_r0a(self): + registry = Registry(hw_rev=2) + types = self._types(registry) + self.assertIs(types.pop("R0A"), Si5341Clock) + self.assertTrue(all(t is Si5395Clock for t in types.values())) + + def test_rev3_is_all_si5395(self): + registry = Registry(hw_rev=3) + self.assertTrue(all(t is Si5395Clock for t in self._types(registry).values())) + + def test_addresses_and_names_unchanged(self): + for rev in (2, 3): + Registry._instance = None + registry = Registry(hw_rev=rev) + self.assertEqual({n: c.address for n, c in registry.clocks.items()}, + dict(CORE_CONFIG.clocks)) + self.assertEqual({n: c.name for n, c in registry.clocks.items()}, + {n: n for n in CORE_CONFIG.clocks}) + + def test_registry_clocks_verify_identity(self): + registry = Registry(hw_rev=2) + self.assertTrue(all(c._verify for c in registry.clocks.values())) + + def test_unknown_revision_raises(self): + for rev in (0, 1, 0xFF): + Registry._instance = None + registry = Registry(hw_rev=rev) + with self.assertRaisesRegex(ValueError, "first_mcu"): + registry.clocks + + def test_revision_read_from_mcu_lazily_and_once(self): + registry = Registry() + registry.mcu = MagicMock() + registry.mcu.hardware_revision.return_value = 2 + registry.mcu.hardware_revision.assert_not_called() + self.assertIs(type(registry.get_clock("R0A")), Si5341Clock) + registry.clocks + registry.mcu.hardware_revision.assert_called_once_with() + self.assertEqual(registry.hw_rev, 2) + + def test_override_skips_mcu(self): + registry = Registry(hw_rev=3) + registry.mcu = MagicMock() + registry.clocks + registry.mcu.hardware_revision.assert_not_called() + + +class McuHardwareRevisionTest(unittest.TestCase): + def test_cached_u8_read_of_system_offset_6(self): + from cm_interface.device.mcu import MCU + uart = RegUART({0x0006: 2}) + mcu = MCU(uart) + self.assertEqual(mcu.hardware_revision(), 2) + self.assertEqual(mcu.hardware_revision(), 2) + self.assertEqual(uart.writes, [b"r MC 0 0 6 1\n"]) + mcu.hardware_revision(refresh=True) + self.assertEqual(len(uart.writes), 2) + + +if __name__ == "__main__": + unittest.main() From 4be790874a984b35248e84f659462d98c38ce049 Mon Sep 17 00:00:00 2001 From: Peter Wittich Date: Fri, 16 Oct 2026 21:45:07 -0400 Subject: [PATCH 4/5] Simplify Rev2 clock support after review - Drop the implicit PN_BASE check (verify_identity constructor flag); clock_checks/clock_dump keep calling verify_identity() explicitly. - MCU.hardware_revision() is a plain read; Registry caches the revision. - Remove unused STICKY_FLAG_BYTES, dead PN branch in check_id, duplicate DEVICE_READY_VALUE, default layout/decoders args in clock_dump; trim Si5341Reg to the registers the code reads. - "cannot determine clock chips" messages point to --hw-rev; document get_clock errors; Rev2 notes in design.md / EXAMPLES.md. - Tests: verify_identity for both chips, Si5341 dump rendering from the board 210 R0A values. --- EXAMPLES.md | 3 +- clock_checks.py | 14 ++++----- clock_dump.py | 15 +++++----- design.md | 2 +- device/clock.py | 19 ++---------- device/mcu.py | 16 ++++------ device/si5341.py | 56 +++++------------------------------ device/si5395.py | 4 --- registry.py | 12 ++++---- tests/test_clock_checks.py | 37 ++++++++++++++++++++--- tests/test_si5341.py | 60 ++++++++++++-------------------------- 11 files changed, 90 insertions(+), 148 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index b4cbc78..b1d1bfd 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -240,6 +240,7 @@ for name, clock in reg.clocks.items(): ```python # Method 1: Symbolic register names (RECOMMENDED) +# Si5395 register; on Rev2, R0A is an Si5341 (use Si5341Reg there) status = clock.read_reg(Si5395Reg.LOL_HOLD_STATUS) mode = clock.mode @@ -418,7 +419,7 @@ The same operations are available from the hardware exercise script: ```python # ✓ RECOMMENDED: Symbolic names from cm_interface.device.si5395 import Si5395Reg -status = clock.read_reg(Si5395Reg.LOL_HOLD_STATUS) +status = clock.read_reg(Si5395Reg.LOL_HOLD_STATUS) # Si5395 only (not R0A on Rev2) # ✓ RECOMMENDED: Convenience methods if clock.is_locked(): diff --git a/clock_checks.py b/clock_checks.py index 032c02b..c05d555 100644 --- a/clock_checks.py +++ b/clock_checks.py @@ -79,6 +79,7 @@ sys.path.insert(0, _package_parent) from cm_interface.core_config import CORE_CONFIG +from cm_interface.device.clock import DEVICE_READY_VALUE from cm_interface.device.si5341 import Si5341Reg from cm_interface.device.si5395 import Si5395Reg from cm_interface.errors import ClockIdentityError, CMError @@ -86,7 +87,6 @@ EXPECTED_ERRORS = (CMError, OSError, TimeoutError, ValueError, RuntimeError) -DEVICE_READY_VALUE = 0x0F IN_ACTV = 0x0507 # bits 7:6, currently selected DSPLL input (read only) LOS_EN = 0x002C # bits 3:0 LOS_EN per input, bit 4 LOSXAXB_DIS OOF_EN = 0x003F # bits 3:0 OOF_EN, bits 7:4 FAST_OOF_EN per input @@ -253,14 +253,11 @@ def check_id(clock, mon=None): "DESIGN_ID %r ACTIVE_NVM_BANK 0x%02X (%s)" % (clock.name, device_id, device_id, _letter(grade), _letter(rev), temp_grade, pkg, design_id, nvm, NVM_BANKS.get(nvm, "unexpected value"))) - ok = True - if device_id != clock.EXPECTED_PN: - print(" -> DEVICE_ID is not 0x%04X (%s)" % (clock.EXPECTED_PN, clock.CHIP)) - ok = False + # PN_BASE was already checked by check_identity. if nvm not in NVM_BANKS: print(" -> ACTIVE_NVM_BANK is not one of the documented values") - ok = False - return ok + return False + return True def check_status(clock, mon): @@ -506,7 +503,8 @@ def main(): try: clocks = reg.clocks except EXPECTED_ERRORS as exc: - print("cannot determine clock chips: %s" % _format_error(exc)) + print("cannot determine clock chips: %s (use --hw-rev N to set the board " + "revision)" % _format_error(exc)) return 1 print("board revision %d%s: %s" % ( reg.hw_rev, " (--hw-rev)" if args.hw_rev is not None else " (MCU)", diff --git a/clock_dump.py b/clock_dump.py index fe32087..1e9cfff 100644 --- a/clock_dump.py +++ b/clock_dump.py @@ -47,10 +47,10 @@ from collections import namedtuple from cm_interface.core_config import CORE_CONFIG +from cm_interface.device.clock import DEVICE_READY_VALUE from cm_interface.lga80d_dump import board_info from cm_interface.registry import Registry -DEVICE_READY_VALUE = 0x0F MAX_READ_BYTES = 4 # ---------------------------------------------------------------- decoders @@ -251,7 +251,7 @@ def _parse_fields(spec): return fields -def addresses(layout=LAYOUTS["Si5395"]): +def addresses(layout): """Every register address the dump reads for a layout (sorted).""" addrs = set() for addr, _ in layout.regs: @@ -319,7 +319,7 @@ def raw(self, addr, nbytes): return None if None in raw else bytes(raw) -def _fmt_field(name, value, decoders=FIELD_DECODE): +def _fmt_field(name, value, decoders): decode = decoders.get(name) return "%s=%d%s" % (name, value, "(%s)" % decode(value) if decode else "") @@ -347,7 +347,7 @@ def identity_lines(vals): part, "?" if opn is None else _ascii(opn), "?" if design is None else _ascii(design))] -def output_lines(vals, layout=LAYOUTS["Si5395"]): +def output_lines(vals, layout): lines = ["", " -- outputs (%s) --" % layout.outputs_title, " %-6s %3s %2s %-7s %4s %3s %4s %4s %3s %3s %-4s %4s %3s %8s %12s" % ( "OUTPUT", "PDN", "OE", "FORMAT", "SYNC", "DIS", "CDRV", "AMPL", "CM", @@ -382,7 +382,7 @@ def output_lines(vals, layout=LAYOUTS["Si5395"]): return lines -def divider_lines(vals, layout=LAYOUTS["Si5395"]): +def divider_lines(vals, layout): lines = ["", " -- dividers %s --" % layout.dividers_title, " %-6s %18s %12s %18s %s" % ("DIV", "NUM", "DEN", "NUM/DEN", "FRAC")] for name, num, nnum, den, frac in layout.p_divs + layout.m_divs: @@ -428,7 +428,7 @@ def bit(label): return lines -def field_lines(vals, layout=LAYOUTS["Si5395"]): +def field_lines(vals, layout): lines = ["", " -- status and configuration --", " %-7s %-4s %s" % ("ADDR", "RAW", "FIELDS")] for addr, spec in sorted(layout.regs): v = vals.u8(addr) @@ -527,7 +527,8 @@ def main(): try: reg.clocks except Exception as exc: - print("cannot determine clock chips: %s" % exc) + print("cannot determine clock chips: %s (use --hw-rev N to set the board " + "revision)" % exc) return 1 if args.out_dir: os.makedirs(args.out_dir, exist_ok=True) diff --git a/design.md b/design.md index f0548e3..6785ab6 100644 --- a/design.md +++ b/design.md @@ -198,7 +198,7 @@ from cm_interface.device.si5395 import Si5395Reg reg = Registry(setup='tf') # Access clock - Method 1: Symbolic register names (recommended) -clock = reg.get_clock('R0A') +clock = reg.get_clock('R0B') # Si5395 on every revision; R0A is an Si5341 on Rev2 status = clock.read_reg(Si5395Reg.LOL_HOLD_STATUS) # Clear, self-documenting # Access clock - Method 2: Convenience properties diff --git a/device/clock.py b/device/clock.py index 350fe76..08c0092 100644 --- a/device/clock.py +++ b/device/clock.py @@ -72,24 +72,17 @@ class SiClock(Device): """Common behavior of the clock chips reached over ProgCom ``CL``. Subclasses set ``CHIP``, ``EXPECTED_PN``, ``REG`` (their register - IntEnum) and implement ``health``. - - With ``verify_identity=True`` (what :class:`~cm_interface.registry.Registry` - passes) the first ``health`` read or register write checks PN_BASE - against ``EXPECTED_PN`` and raises :class:`ClockIdentityError` on a - mismatch. The check is skipped while the chip is not ready, because the - reference manuals permit no access but DEVICE_READY until then. + IntEnum) and implement ``health``. Nothing checks automatically that the + chip is the expected part; call :meth:`verify_identity` for that. """ CHIP = None EXPECTED_PN = None REG = None - def __init__(self, uart, address: int, name: str, verify_identity: bool = False): + def __init__(self, uart, address: int, name: str): super().__init__(uart, address) self.name = name - self._verify = verify_identity - self._identity_ok = False def _encode_command(self, reg: int, write: bool, payload: bytes = b"", read_size: int = 1) -> bytes: @@ -113,13 +106,8 @@ def verify_identity(self) -> int: "and that the firmware was built for this board" % (self.name, pn, self.EXPECTED_PN, self.CHIP) ) - self._identity_ok = True return pn - def _ensure_identity(self) -> None: - if self._verify and not self._identity_ok and self.is_ready(): - self.verify_identity() - def write_reg(self, reg: int, data: bytes, allow_nvm: bool = False) -> None: """Write registers, refusing any write that touches an NVM control register. @@ -135,7 +123,6 @@ def write_reg(self, reg: int, data: bytes, allow_nvm: bool = False) -> None: "refusing write to %s NVM control register 0x%04X; " "pass allow_nvm=True to override" % (self.CHIP, touched[0]) ) - self._ensure_identity() super().write_reg(reg, data) @property diff --git a/device/mcu.py b/device/mcu.py index b47bd27..94e98ad 100644 --- a/device/mcu.py +++ b/device/mcu.py @@ -421,9 +421,8 @@ class MCU(Device): Register numbers use the normal package convention ``page << 8 | offset``. """ - # Class-level defaults so test doubles that skip ``__init__`` still work. + # Class-level default so test doubles that skip ``__init__`` still work. _capabilities = None - _hardware_revision = None _CAPABILITY_FOR_PAGE = { McuPage.POWER: McuCapability.POWER, @@ -439,7 +438,6 @@ class MCU(Device): def __init__(self, uart): super().__init__(uart, address=0) self._capabilities = None - self._hardware_revision = None def _encode_command(self, reg: int, write: bool, payload: bytes = b"", read_size: int = 1) -> bytes: @@ -485,18 +483,14 @@ def capabilities(self, refresh: bool = False) -> McuCapability: ) return self._capabilities - def hardware_revision(self, refresh: bool = False) -> int: - """Return the board revision, cached after the first read. + def hardware_revision(self) -> int: + """Read the board revision. The firmware takes this from the board EEPROM (``first_mcu`` / ``set_id``), not from the REV it was compiled for, so an unprogrammed - EEPROM gives a meaningless value. Pass ``refresh=True`` to re-read. + EEPROM gives a meaningless value. """ - if refresh or self._hardware_revision is None: - self._hardware_revision = self._read_u8( - McuPage.SYSTEM, SystemReg.HARDWARE_REVISION - ) - return self._hardware_revision + return self._read_u8(McuPage.SYSTEM, SystemReg.HARDWARE_REVISION) def _require_page(self, page: McuPage, check: bool = True) -> None: """Refuse to touch a page the firmware does not advertise. diff --git a/device/si5341.py b/device/si5341.py index 563cd31..8405a86 100644 --- a/device/si5341.py +++ b/device/si5341.py @@ -19,62 +19,24 @@ class Si5341Reg(IntEnum): - """Si5341 registers used by this package (Si5341/40 RM Rev 1.3, ch. 14).""" - PAGE = 0x0001 + """Si5341 registers used by this package (Si5341/40 RM Rev 1.3, ch. 14). + + Only what the code reads; clock_dump.py carries its own address tables. + """ DEVICE_ID = 0x0002 # PN_BASE, 2 bytes little-endian - GRADE = 0x0004 - DEVICE_REV = 0x0005 - TEMP_GRADE = 0x0009 - PKG_ID = 0x000A - I2C_ADDR = 0x000B - - # Status: SYSINCAL, LOSXAXB, LOSREF, LOL, SMBUS_TIMEOUT - STATUS = 0x000C + STATUS = 0x000C # SYSINCAL, LOSXAXB, LOSREF, LOL, SMBUS_TIMEOUT LOS_STATUS = 0x000D # LOSIN[3:0] = FB_IN, IN2, IN1, IN0 - - # Sticky flags - STATUS_FLAG = 0x0011 - LOS_FLAG = 0x0012 - - # Interrupt masks - INT_MASK_0 = 0x0017 - INT_MASK_1 = 0x0018 - - # Reset and control - SOFT_RESET = 0x001C - FINC_FDEC = 0x001D - POWER_DOWN = 0x001E # bit 0 PDN, bit 1 HARD_RST, bit 2 SYNC - IN_SEL_REGCTRL = 0x0021 # bit 0 REGCTRL, bits 2:1 IN_SEL - - # LOS configuration + STATUS_FLAG = 0x0011 # sticky copies of 0x000C + IN_SEL_REGCTRL = 0x0021 # bit 0 REGCTRL, bits 2:1 IN_SEL (3 = XA/XB) LOS_ENABLE = 0x002C # bits 3:0 LOS_EN, bit 4 LOSXAXB_DIS - LOL_SET_THR = 0x009E - - # NVM ACTIVE_NVM_BANK = 0x00E2 NVM_WRITE = 0x00E3 NVM_READ_BANK = 0x00E4 - DEVICE_READY = 0x00FE - - # Outputs OUTALL_DISABLE = 0x0102 OUT_PDN_ALL = 0x0145 - - # Dividers and reference - PXAXB = 0x0206 - M_NUM = 0x0235 - M_DEN = 0x023B - DESIGN_ID = 0x026B - OPN_ID = 0x0278 - XAXB_EXTCLK_EN = 0x090E - ZDM_EN = 0x091C + ZDM_EN = 0x091C # 3 = zero delay mode, 4 = normal IN_EN = 0x0949 # bits 3:0 = FB_IN, IN2, IN1, IN0 - IN_TO_PFD_EN = 0x094A # bits 6:4 = IN2, IN1, IN0 - - # Frequency-change preamble/postamble (RM section 7) - FREQ_CHANGE_PREAMBLE_1 = 0x0B24 - FREQ_CHANGE_PREAMBLE_2 = 0x0B25 class Si5341StatusBits(IntEnum): @@ -97,7 +59,6 @@ class Si5341Clock(SiClock): CHIP = "Si5341" EXPECTED_PN = 0x5341 REG = Si5341Reg - STICKY_FLAG_BYTES = 2 @property def health(self) -> ClockHealth: @@ -106,7 +67,6 @@ def health(self) -> ClockHealth: Do not call this until :meth:`is_ready` returns ``True`` -- the registers read here are not part of the startup-safe set. """ - self._ensure_identity() status, los = self.read_reg(Si5341Reg.STATUS, size=2) ready = self.is_ready() return ClockHealth( diff --git a/device/si5395.py b/device/si5395.py index e359886..9283922 100644 --- a/device/si5395.py +++ b/device/si5395.py @@ -126,8 +126,6 @@ class Si5395Clock(SiClock): Device address on the serial bus (e.g., 0x10 for R0A). name : str Logical identifier (e.g., "R0A", "R1B"). - verify_identity : bool - Check PN_BASE reads 0x5395 before the first health read or write. Examples -------- @@ -144,7 +142,6 @@ class Si5395Clock(SiClock): CHIP = "Si5395" EXPECTED_PN = 0x5395 REG = Si5395Reg - STICKY_FLAG_BYTES = 4 @property def health(self) -> ClockHealth: @@ -153,7 +150,6 @@ def health(self) -> ClockHealth: Do not call this until :meth:`is_ready` returns ``True`` -- the registers read here are not part of the startup-safe set. """ - self._ensure_identity() sysincal, los_oof, lol_hold, cal_pll = self.read_reg( Si5395Reg.SYSINCAL, size=4 ) diff --git a/registry.py b/registry.py index 40c4fc0..fe96002 100644 --- a/registry.py +++ b/registry.py @@ -178,9 +178,8 @@ def hw_rev(self) -> int: def clocks(self) -> Dict[str, SiClock]: """Clock devices by name, typed for this board revision. - Raises ``ValueError`` for a revision with no known clock layout. - Each clock checks its PN_BASE on first use and raises - ``ClockIdentityError`` if the chip is not the expected one. + Raises ``ValueError`` for a revision with no known clock layout. The + chips are not checked; ``clock.verify_identity()`` does that. """ if self._clocks is None: rev = self.hw_rev @@ -193,8 +192,7 @@ def clocks(self) -> Dict[str, SiClock]: "hw_rev= to Registry" % (rev, sorted(CLOCK_CHIPS)) ) from None self._clocks = { - name: CLOCK_CLASSES[chips[name]](self.uart, addr, name, - verify_identity=True) + name: CLOCK_CLASSES[chips[name]](self.uart, addr, name) for name, addr in CORE_CONFIG.clocks.items() } return self._clocks @@ -203,7 +201,9 @@ def clocks(self) -> Dict[str, SiClock]: def get_clock(self, name: str) -> SiClock: """Return the clock instance identified by ``name``. - Raises ``KeyError`` if the clock does not exist. + Raises ``KeyError`` for an unknown name. The first call builds + ``clocks``, so it can also raise ``ValueError`` (unknown board + revision) or a UART/register error from reading the revision. """ return self.clocks[name] diff --git a/tests/test_clock_checks.py b/tests/test_clock_checks.py index ac94891..5a74383 100644 --- a/tests/test_clock_checks.py +++ b/tests/test_clock_checks.py @@ -1,3 +1,4 @@ +import argparse import contextlib import io import unittest @@ -240,10 +241,38 @@ def test_si5341_reads_no_si5395_only_registers(self): for addr in (0x000C, 0x0021, 0x0108, 0x013A, 0x024A, 0x0268, 0x0235, 0x091C): self.assertIn(addr, addrs, hex(addr)) - def test_si5395_default_layout(self): - self.assertEqual(clock_dump.addresses(), - clock_dump.addresses(clock_dump.LAYOUTS["Si5395"])) - self.assertIn(0x0515, clock_dump.addresses()) + def test_si5341_dump_renders(self): + # Board 210 R0A status registers plus its dump's divider values: P0 = 3 + # (integer, no denominator), M = 125.11..., N0 = 21; OUT0 on N0 with R = 2. + regs = dict(BOARD210_R0A) + regs[0x0208] = 3 + regs.update(zip(range(0x0235, 0x023B), (360777252864).to_bytes(6, "little"))) + regs.update(zip(range(0x023B, 0x023F), (2883584000).to_bytes(4, "little"))) + regs.update(zip(range(0x0302, 0x0308), (45097156608).to_bytes(6, "little"))) + regs.update(zip(range(0x0308, 0x030C), (2147483648).to_bytes(4, "little"))) + regs.update({0x0108: 0x06, 0x0109: 0x09, 0x010B: 0x00, 0x0A03: 0x01}) + + class Reg(object): + def get_clock(self, name): + return Si5341Clock(FakeUART(regs), address=0x10, name=name) + + args = argparse.Namespace(dev_path="/dev/fake", out_dir=None) + out = io.StringIO() + with contextlib.redirect_stdout(out): + failed = clock_dump.dump_clock(Reg(), "R0A", args, [], None, echo=True) + text = out.getvalue() + self.assertFalse(failed, text) + self.assertIn("Si5341 register dump", text) + self.assertIn("part: Si5341A-D", text) + self.assertIn("0x000C 0xC0 SYSINCAL=0 LOSXAXB=0 LOSREF=0 LOL=0", text) + self.assertIn("IN_SEL_REGCTRL=1(register) IN_SEL=0(IN0)", text) + self.assertIn("LOS_EN=1(IN0) LOSXAXB_DIS=1", text) + self.assertRegex(text, r"P0 +3 +- +3 ") + self.assertIn("125.114181818", text) + self.assertRegex(text, r"OUT0 +0 1 diff .* N0 .* 2\(F2\) +42\.000000") + self.assertRegex(text, r"N0 .*21\.000000000 .* - +- ") + self.assertNotIn("OUT0A", text) + self.assertNotIn("MXAXB", text) if __name__ == "__main__": diff --git a/tests/test_si5341.py b/tests/test_si5341.py index a44bf7e..33f457d 100644 --- a/tests/test_si5341.py +++ b/tests/test_si5341.py @@ -47,9 +47,9 @@ def read_addrs(self): SI5395_READY = {0x00FE: 0x0F, 0x0002: 0x95, 0x0003: 0x53} -def _clock(cls, regs, verify=False): +def _clock(cls, regs): uart = RegUART(dict(regs)) - return cls(uart, address=0x10, name="R0A", verify_identity=verify), uart + return cls(uart, address=0x10, name="R0A"), uart class Si5341HealthTest(unittest.TestCase): @@ -128,40 +128,24 @@ def test_device_id(self): class IdentityCheckTest(unittest.TestCase): - def test_mismatch_raises_before_health_read(self): - clock, uart = _clock(Si5341Clock, SI5395_READY, verify=True) - with self.assertRaisesRegex(ClockIdentityError, "0x5395.*0x5341"): - clock.health - self.assertNotIn(0x000C, uart.read_addrs()) - - def test_mismatch_blocks_writes(self): - clock, uart = _clock(Si5395Clock, SI5341_READY, verify=True) - with self.assertRaises(ClockIdentityError): - clock.clear_sticky_flags() - self.assertFalse(any(w.startswith(b"w ") for w in uart.writes)) - - def test_match_is_checked_once(self): - clock, uart = _clock(Si5341Clock, SI5341_READY, verify=True) - clock.health - clock.health - self.assertEqual(uart.read_addrs().count(0x0002), 1) - - def test_not_ready_skips_the_check(self): - clock, uart = _clock(Si5341Clock, {0x00FE: 0x00, 0x0002: 0x95, 0x0003: 0x53}, - verify=True) - clock.reset() - self.assertNotIn(0x0002, uart.read_addrs()) - self.assertEqual(uart.writes[-1], b"w CL 0 0 1C 01\n") - - def test_off_by_default(self): + def test_match_returns_pn(self): + for cls, regs, pn in ((Si5341Clock, SI5341_READY, 0x5341), + (Si5395Clock, SI5395_READY, 0x5395)): + clock, uart = _clock(cls, regs) + self.assertEqual(clock.verify_identity(), pn) + self.assertEqual(uart.writes, [b"r CL 0 0 2 2\n"]) + + def test_mismatch_raises_both_ways(self): + for cls, regs in ((Si5341Clock, SI5395_READY), (Si5395Clock, SI5341_READY)): + clock, _ = _clock(cls, regs) + with self.assertRaises(ClockIdentityError): + clock.verify_identity() + + def test_not_checked_implicitly(self): clock, uart = _clock(Si5341Clock, SI5395_READY) clock.health self.assertNotIn(0x0002, uart.read_addrs()) - def test_verify_identity_returns_pn(self): - clock, _ = _clock(Si5395Clock, SI5395_READY) - self.assertEqual(clock.verify_identity(), 0x5395) - class RegistryRevisionTest(unittest.TestCase): def tearDown(self): @@ -189,10 +173,6 @@ def test_addresses_and_names_unchanged(self): self.assertEqual({n: c.name for n, c in registry.clocks.items()}, {n: n for n in CORE_CONFIG.clocks}) - def test_registry_clocks_verify_identity(self): - registry = Registry(hw_rev=2) - self.assertTrue(all(c._verify for c in registry.clocks.values())) - def test_unknown_revision_raises(self): for rev in (0, 1, 0xFF): Registry._instance = None @@ -218,15 +198,11 @@ def test_override_skips_mcu(self): class McuHardwareRevisionTest(unittest.TestCase): - def test_cached_u8_read_of_system_offset_6(self): + def test_u8_read_of_system_offset_6(self): from cm_interface.device.mcu import MCU uart = RegUART({0x0006: 2}) - mcu = MCU(uart) - self.assertEqual(mcu.hardware_revision(), 2) - self.assertEqual(mcu.hardware_revision(), 2) + self.assertEqual(MCU(uart).hardware_revision(), 2) self.assertEqual(uart.writes, [b"r MC 0 0 6 1\n"]) - mcu.hardware_revision(refresh=True) - self.assertEqual(len(uart.writes), 2) if __name__ == "__main__": From 1d7589c72f42031b5aa27058099c8bee069dc674 Mon Sep 17 00:00:00 2001 From: Peter Wittich Date: Tue, 20 Oct 2026 15:45:39 -0400 Subject: [PATCH 5/5] Document POWER_CONFIG in MCU register map --- MCU_REGISTER_MAP.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/MCU_REGISTER_MAP.md b/MCU_REGISTER_MAP.md index 2011eca..b498114 100644 --- a/MCU_REGISTER_MAP.md +++ b/MCU_REGISTER_MAP.md @@ -89,7 +89,7 @@ different situation from Alarm/ADC, where dropping the counter was safe. | Offset | Size | Field | | --- | ---: | --- | | `0x00` | 4 | generation counter | -| `0x04` | 1 | power FSM state (0-10, see state list below) | +| `0x04` | 1 | power FSM state (0-11, see state list below) | | `0x05` | 1 | flags bitmap (see below) | | `0x06` | 2 | reserved | | `0x08` | 4 | live power-good mask (freshest reading each cycle) | @@ -100,15 +100,19 @@ different situation from Alarm/ADC, where dropping the counter was safe. | `0x19` | 3 | reserved | | `0x1c` | 12 | per-supply state, one byte each, index = PG-pin index (state values below) | -Power FSM states (offset `0x04`), in order 0-10: +Power FSM states (offset `0x04`), in order 0-11: ```text 0 POWER_FAILURE 4 POWER_L1ON 8 POWER_L5ON 1 POWER_INIT 5 POWER_L2ON 9 POWER_L6ON 2 POWER_DOWN 6 POWER_L3ON 10 POWER_ON -3 POWER_OFF 7 POWER_L4ON +3 POWER_OFF 7 POWER_L4ON 11 POWER_CONFIG ``` +`POWER_CONFIG` runs `LGA80D_init` with the supplies off on every power-up +(`POWER_OFF` → `POWER_CONFIG` → `POWER_L1ON`); an init failure goes to +`POWER_FAILURE`. + Flags bitmap (offset `0x05`): | Bit | Name |