diff --git a/.github/workflows/rpm.yml b/.github/workflows/rpm.yml index 97ebd32..4a5c9a4 100644 --- a/.github/workflows/rpm.yml +++ b/.github/workflows/rpm.yml @@ -58,6 +58,7 @@ jobs: dnf install -y /rpmbuild/RPMS/noarch/*.rpm cd /tmp python3 -c "from cm_interface import Registry, MCU, FPGA" + for launcher in /usr/bin/cm-*; do "$launcher" --help > /dev/null; done rpm -ql python3-cm-interface ' diff --git a/AGENTS.md b/AGENTS.md index 239a096..95b8e38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ The Python `MCU` client for ProgCom device `MC 0` follows - `set_rtc(when=None)` writes the Control-page RTC set word (needs `RTC_WRITE`, map minor >= 4): host local wall-clock time as seconds since 1970-01-01 of that clock, not Unix time. Client and firmware refuse values - before 2026-01-01. `mcu_set_rtc.py` is the command-line wrapper; + before 2026-01-01. `cm-mcu-set-rtc` is the command-line wrapper; - `send_control(McuControlCommand...)` writes the one-byte write-only Control-page command (`ASSERT`/`RELEASE_PROGCOM_POWER_INHIBIT`, `CLEAR_POWER_FAULT`, `CLEAR_ALARM_LATCHES`, `ZYNQMON_ENABLE_TRANSMIT`, diff --git a/EXAMPLES.md b/EXAMPLES.md index b1d1bfd..891bb44 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -398,8 +398,8 @@ fault, and release it only after the cause is resolved. The same operations are available from the hardware exercise script: ```bash -./exercise_mcu.py --power-cycle --yes -./exercise_mcu.py --send-control CLEAR_POWER_FAULT +cm-exercise-mcu --power-cycle --yes +cm-exercise-mcu --send-control CLEAR_POWER_FAULT ``` --- @@ -516,16 +516,16 @@ print(f" Rx-only: {len(inventory['rx_only'])} - {inventory['rx_only']}") ## Running the Examples -The complete examples are available in `examples.py`. Run them with: +The complete examples are available in `tools/examples.py`. Run them with: ```bash -python3 examples.py +python -m cm_interface.tools.examples # from the directory above cm_interface ``` Or import individual functions: ```python -from cm_interface.examples import example_disable_cdr_all_fireflies +from cm_interface.tools.examples import example_disable_cdr_all_fireflies example_disable_cdr_all_fireflies() ``` diff --git a/FUTURE_IDEAS.md b/FUTURE_IDEAS.md index 462ed65..8c91bd7 100644 --- a/FUTURE_IDEAS.md +++ b/FUTURE_IDEAS.md @@ -107,7 +107,7 @@ register writes, so it is implemented in Python — `LGA80D.reset_snapshot(page) read cannot go through the 4-byte `DC` path, so the firmware gained a ProgCom `SN` device instead of the earlier `s DC` verb idea: `w SN 00 01` captures into a one-slot cache and `r SN []` reads it (`../PROGCOM_SNAPSHOT_PLAN.md`, -`cm_mcu/projects/cm_mcu/README.md`). `lga80d_dump.py --snapshot` prints it. +`cm_mcu/projects/cm_mcu/README.md`). `cm-lga80d-dump --snapshot` prints it. **Outstanding:** optionally a `reset_after` option on the read, which was left out on purpose because reset is already reachable and has a physical diff --git a/README.md b/README.md index 1c840ad..2740f41 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,9 @@ cm_interface/ ├─ errors.py # Exception hierarchy ├─ utils.py # PMBus Linear11/Linear16 codecs (crc8/pack_uint16 are unused) ├─ 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 ├─ FUTURE_IDEAS.md # Ideas not yet planned -├─ lga80d_status.py # Read-only decoded LGA80D status registers (one device or --all) ├─ core_config.py # Fixed UART addresses for clocks and LGA80D ├─ firefly_presets.py # Board-specific Firefly layouts (TF, IT-DTC) ├─ device/ @@ -35,6 +30,19 @@ cm_interface/ │ ├─ lga80d.py # LGA80D DC-DC converter implementation │ ├─ mcu.py # Command-module MCU register interface │ └─ fpga.py # Raw F1/F2 generic register interface +├─ tools/ # Command-line tools: run ./tools/NAME.py from a checkout; the RPM installs cm- +│ ├─ _common.py # Shared conventions: NAME.../--all, --out-dir, --dev-path, --debug, check summary +│ ├─ clock_checks.py # cm-clock-checks: read-only Si5395/Si5341 status checks +│ ├─ clock_dump.py # cm-clock-dump: clock register dump (--out-dir) +│ ├─ exercise_mcu.py # cm-exercise-mcu: exercise the MCU pages; writes/control prompt unless --yes +│ ├─ ff_status.py # cm-ff-status: Firefly status summary (--out-dir) +│ ├─ lga80d_checks.py # cm-lga80d-checks: read-only LGA80D frequency/temperature/timing checks +│ ├─ lga80d_dump.py # cm-lga80d-dump: LGA80D register dump (--snapshot adds the SNAPSHOT read) +│ ├─ lga80d_status.py # cm-lga80d-status: decoded LGA80D status registers (--out-dir) +│ ├─ mcu_dump.py # cm-mcu-dump: read-only MCU page dump (--out-dir) +│ ├─ mcu_set_rtc.py # cm-mcu-set-rtc: set the MCU RTC from the host clock +│ ├─ vccint_2phase.py # VCCINT 2-phase experiment (checkout only; not in the RPM) +│ └─ examples.py # Worked examples (see EXAMPLES.md; not installed in /usr/bin) ├─ tests/ # FakeUART unit tests; tests/hw/ is the opt-in hardware suite └─ registers/ ├─ si5395.json # Si5395 register map (page-based) @@ -62,6 +70,11 @@ includes the workflow run number and commit ID. The binary package is register JSON maps. Install a downloaded binary RPM with `sudo dnf install ./python3-cm-interface-*.noarch.rpm`. +The RPM also installs the command-line tools as `cm-` (for example +`cm-lga80d-dump --all`). The same tool runs from a checkout as +`./tools/lga80d_dump.py --all`; the `cm-*` names exist only after the RPM is +installed. + ## Basic usage ```python from cm_interface.registry import Registry @@ -134,7 +147,7 @@ print(rt.heap_free_bytes, rt.system_stack_untouched_words, rt.fpga_done) print(rt.rtc.isoformat()) # "unset" when the RTC is not valid # Needs map minor >= 4 (RTC_WRITE). Sets host local wall-clock time; the RTC -# has no time zone. Refuses times before 2026-01-01. CLI: mcu_set_rtc.py +# has no time zone. Refuses times before 2026-01-01. CLI: cm-mcu-set-rtc mcu.set_rtc() mcu.send_control(McuControlCommand.CLEAR_ALARM_LATCHES) @@ -289,7 +302,7 @@ polling (up to ~0.7 s seen on hardware), and much longer if the I2C bus stays co the UART timeout is not changed. Failures (including old firmware without `SN`, which answers `e invalid device type`) raise `RegisterAccessError`. A new unit holds a factory-qualification fault until it is erased with `reset_snapshot` (output off). -`print(snapshot)` (or `snapshot.format_lines()`) gives a readable summary; `lga80d_dump.py --snapshot` +`print(snapshot)` (or `snapshot.format_lines()`) gives a readable summary; `cm-lga80d-dump --snapshot` uses it. `snapshot.is_stored_record` reads the flash status byte (byte 22, undocumented, so inferred from the failure-analysis notes): `True` for `0x00` (a fault record is stored, and the values are that record, not live data), `False` for `0xFF` (erased, values are live), `None` for @@ -337,7 +350,7 @@ bare `RuntimeError`s, so a caller that caught `RuntimeError` for them must now catch `CMError` (or `McuProtocolError`). `Registry.reset_all_lga80d_snapshots` still raises `RuntimeError` for its `force=True` and power-state checks. -The worked `examples.py` runner handles these expected conditions: missing +The worked `tools/examples.py` runner handles these expected conditions: missing preset entries are reported as skips, per-device communication failures do not stop iteration, and an error in one example does not prevent later examples from running. It exits with status 1 if an entire example is stopped by an diff --git a/TODO.md b/TODO.md index e47f57c..34108bb 100644 --- a/TODO.md +++ b/TODO.md @@ -14,13 +14,13 @@ except where marked HW-verified. Delete an item when it lands. Open decision 3 in `FPGA_GENERIC_INTERFACE_PLAN.md` (`unsafe=True` opt-in vs warning). - **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 + locked R0A (now the test fixture). The `cm-clock-checks` 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 -`LGA80D.read_snapshot`, `lga80d_dump.py --snapshot` and the firmware `SN` device are +`LGA80D.read_snapshot`, `cm-lga80d-dump --snapshot` and the firmware `SN` device are hardware-verified (2026-10-07: `tests/hw/test_hw_lga80d_snapshot.py` passed, manual CLI comparison, `stack_usage` and `log` checks OK). Not exercised: the short-block path (needs a temporary DEBUG firmware build) and a capture on an absent or NACKing supply. Snapshot bytes 12-13 are a copy of the temperature word on this board and are deliberately not decoded. @@ -32,7 +32,7 @@ temporary DEBUG firmware build) and a capture on an absent or NACKing supply. Sn pushes Si5395 registers over `w CL` (16-bit `page<<8 | addr`, ProgCom writes the page register), so it is unaffected by the EEPROM address width below. - Add an MCU hardware test under `tests/hw/` if a repeatable one is wanted - (`exercise_mcu.py` is the only MCU hardware path today). + (`cm-exercise-mcu` is the only MCU hardware path today). ## Blocked on firmware or hardware diff --git a/device/firefly.py b/device/firefly.py index fe578f9..f7429ab 100644 --- a/device/firefly.py +++ b/device/firefly.py @@ -194,6 +194,7 @@ class FireflyTx(_FireflyBase): class Reg(IntEnum): STATUS = 0x06 + TX_LOS_LATCH = 0x07 # Bytes 7-8 (Table 13) TX_FAULT_LATCH = 0x09 TEMP_ALARM = 0x11 VCC_ALARM = 0x12 @@ -273,6 +274,7 @@ class FireflyRx(_FireflyBase): class Reg(IntEnum): STATUS = 0x06 RX_LOS_LATCH = 0x07 + RX_POWER_ALARM = 0x0E # Bytes 14-16, 2 bits per channel (Table 36) TEMP_ALARM = 0x11 VCC_ALARM = 0x12 TEMP_MONITOR = 0x16 @@ -375,6 +377,9 @@ class Firefly4(_FireflyBase): class Reg(IntEnum): STATUS = 0x02 + LOS_ALARMS = 0x03 # Latched Tx/Rx LOS (Table 29) + TX_FAULT_LATCH = 0x04 # Latched Tx fault, bits 0-3 (Table 30) + CDR_LOL_ALARMS = 0x05 # Latched Tx/Rx CDR LOL (Table 31) TEMP_ALARM = 0x06 # Combined temperature warning/alarm byte VCC_ALARM = 0x07 # Combined VCC3.3/VCC1.8 warning/alarm byte ELAPSED_TIME = 0x13 # 2 bytes diff --git a/device/si5341.py b/device/si5341.py index 8405a86..e385930 100644 --- a/device/si5341.py +++ b/device/si5341.py @@ -21,7 +21,7 @@ class Si5341Reg(IntEnum): """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. + Only what the code reads; tools/clock_dump.py carries its own address tables. """ DEVICE_ID = 0x0002 # PN_BASE, 2 bytes little-endian STATUS = 0x000C # SYSINCAL, LOSXAXB, LOSREF, LOL, SMBUS_TIMEOUT diff --git a/packaging/cm_interface.spec b/packaging/cm_interface.spec index 62fdc5c..2b52ee7 100644 --- a/packaging/cm_interface.spec +++ b/packaging/cm_interface.spec @@ -27,9 +27,22 @@ power supplies, MCU registers, and raw FPGA generic interfaces. %install install -d %{buildroot}%{python3_sitelib}/cm_interface/device install -d %{buildroot}%{python3_sitelib}/cm_interface/registers +install -d %{buildroot}%{python3_sitelib}/cm_interface/tools +install -d %{buildroot}%{_bindir} install -m 0644 *.py %{buildroot}%{python3_sitelib}/cm_interface/ install -m 0644 device/*.py %{buildroot}%{python3_sitelib}/cm_interface/device/ install -m 0644 registers/*.json %{buildroot}%{python3_sitelib}/cm_interface/registers/ +install -m 0644 tools/*.py %{buildroot}%{python3_sitelib}/cm_interface/tools/ +# vccint_2phase rewrites LGA80D rail configuration; keep it out of the RPM. +rm %{buildroot}%{python3_sitelib}/cm_interface/tools/vccint_2phase.py +# Each tool module gets a cm- launcher (underscores become dashes). +for tool in clock_checks clock_dump exercise_mcu ff_status lga80d_checks \ + lga80d_dump lga80d_status mcu_dump mcu_set_rtc; do + launcher=%{buildroot}%{_bindir}/cm-$(echo "$tool" | tr _ -) + printf '#!%{__python3}\nimport sys\nfrom cm_interface.tools.%s import main\nsys.exit(main())\n' \ + "$tool" > "$launcher" + chmod 0755 "$launcher" +done %check PYTHONPATH=%{buildroot}%{python3_sitelib} %{__python3} -m pytest tests/ --ignore=tests/hw @@ -37,3 +50,4 @@ PYTHONPATH=%{buildroot}%{python3_sitelib} %{__python3} -m pytest tests/ --ignore %files %doc README.md EXAMPLES.md MCU_REGISTER_MAP.md %{python3_sitelib}/cm_interface/ +%{_bindir}/cm-* diff --git a/registers/firefly12.json b/registers/firefly12.json index d28f505..c9f6486 100644 --- a/registers/firefly12.json +++ b/registers/firefly12.json @@ -15,6 +15,7 @@ }, "tx_registers": { "STATUS_SUMMARY": {"addr": "0x06", "size": 1, "access": "RO", "bits": {"7": "TX LOS summary", "6-5": "Reserved", "1": "Module Alarm Summary", "0": "Init Complete (resets to 0 on read)"}, "description": "Table 12. Bit 0 is cleared by reading this byte."}, + "TX_LOS_LATCH": {"addr": "0x07", "size": 2, "access": "RO", "description": "Bytes 7-8 (Table 13). Latched TX Loss of Signal (12 channels), same channel/nibble layout as CDR_ENABLE. Latched bits clear on read or module reset."}, "TX_FAULT_LATCH": {"addr": "0x09", "size": 2, "access": "RO", "description": "Latched TX fault flags (12 channels, bits 0-11). Latched bits clear on read or module reset (may re-assert immediately if the fault persists and its mask bit is unset)."}, "TEMP_ALARM_LATCH": {"addr": "0x11", "size": 1, "access": "RO", "bits": {"7": "Temp High", "6": "Temp Low", "5-0": "Reserved"}}, "VCC_ALARM_LATCH": {"addr": "0x12", "size": 1, "access": "RO", "bits": {"7": "VCC High", "6": "VCC Low", "5-0": "Reserved"}}, @@ -30,6 +31,7 @@ "rx_registers": { "STATUS_SUMMARY": {"addr": "0x06", "size": 1, "access": "RO", "bits": {"7": "Reserved", "6": "LOS summary", "5": "Fault Summary", "4-3": "Reserved", "2": "Low-power summary", "1": "Module Alarm Summary", "0": "Init Complete (resets to 0 on read)"}, "description": "Table 34. Bit 0 is cleared by reading this byte."}, "RX_LOS_LATCH": {"addr": "0x07", "size": 2, "access": "RO", "description": "Latched RX Loss of Signal (12 channels). Latched bits clear on read or module reset."}, + "RX_POWER_ALARM": {"addr": "0x0E", "size": 3, "access": "RO", "description": "Bytes 14-16 (Table 36). Latched Rx power alarms, 2 bits per channel: byte 16 bits 1-0 = Rx00 .. byte 14 bits 7-6 = Rx11 (0-based). 00 = in range, 01 = too low, 10 = too high. Latched bits clear on read or module reset."}, "TEMP_ALARM_LATCH": {"addr": "0x11", "size": 1, "access": "RO", "bits": {"7": "Temp High", "6": "Temp Low", "5-0": "Reserved"}}, "VCC_ALARM_LATCH": {"addr": "0x12", "size": 1, "access": "RO", "bits": {"7": "VCC High", "6": "VCC Low", "5-0": "Reserved"}}, "RX_DISABLE": {"addr": "0x34", "size": 2, "access": "RW", "description": "Per-channel RX disable (bits 0-11, 0-based). Table 40. NOT the same as RX_OUTPUT_DISABLE."}, diff --git a/tests/hw/README.md b/tests/hw/README.md index 9f65017..06cc91b 100644 --- a/tests/hw/README.md +++ b/tests/hw/README.md @@ -9,7 +9,7 @@ board in front of them confirm that the datasheet-audit bugfixes (see `/nfs/cms/hw/wittich/int_test/cm-interface-datasheet-audit.md`) actually hold up against real modules. -There is no MCU test here; `exercise_mcu.py` is the MCU hardware path. +There is no MCU test here; `cm-exercise-mcu` is the MCU hardware path. **This suite is never run in CI** and is invisible to a plain `pytest cm_interface/tests/` invocation: `tests/hw/conftest.py` uses @@ -136,7 +136,7 @@ The host has no UART0, so these are done by a person at the CLI console, on the known-good board, after the new firmware is flashed: 1. Take an `SN` capture (`test_hw_lga80d_snapshot.py -s`, or - `lga80d_dump.py --snapshot`) and immediately run `snapshot <10*dev+page> 0` + `cm-lga80d-dump --snapshot`) and immediately run `snapshot <10*dev+page> 0` on the same supply and page. Fields should agree to rounding, apart from drift (about 1 C, a few hundredths of an amp). 2. `stack_usage` and `taskinfo` before and after the captures: ProgCom (`PRGCM`) diff --git a/tests/test_clock_checks.py b/tests/test_clock_checks.py index 5a74383..75b25c0 100644 --- a/tests/test_clock_checks.py +++ b/tests/test_clock_checks.py @@ -3,8 +3,8 @@ import io import unittest -from cm_interface import clock_checks as checks -from cm_interface import clock_dump +from cm_interface.tools import clock_checks as checks +from cm_interface.tools import clock_dump from cm_interface.device.si5341 import Si5341Clock from cm_interface.device.si5395 import Clock @@ -138,9 +138,9 @@ 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)) + results = checks.check_clock(clock, list(checks.CHECKS)) text = out.getvalue() - self.assertTrue(ok, text) + self.assertEqual(results, dict.fromkeys(checks.CHECKS, True), 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) @@ -226,8 +226,8 @@ def test_check_clock_skips_on_pn_mismatch(self): 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) + results = checks.check_clock(clock, list(checks.CHECKS)) + self.assertEqual(results, {}) self.assertIn("WRONG CHIP", out.getvalue()) self.assertNotIn("status:", out.getvalue()) diff --git a/tests/test_ff_status.py b/tests/test_ff_status.py new file mode 100644 index 0000000..07d729f --- /dev/null +++ b/tests/test_ff_status.py @@ -0,0 +1,29 @@ +import unittest + +from cm_interface.tools import ff_status + +REV3 = ff_status.SLOTS[3] +# Slots 0/1 = F1_1 Tx/Rx, 9 = F1_6 (x4). +POPULATED = [0, 1, 9] + + +class SelectSlotsTest(unittest.TestCase): + def test_all_reads_every_populated_slot_without_notes(self): + notes, to_read = ff_status.select_slots(REV3, POPULATED, None, 3) + self.assertEqual(notes, []) + self.assertEqual(to_read, POPULATED) + + def test_location_selects_both_x12_halves(self): + notes, to_read = ff_status.select_slots(REV3, POPULATED, ["F1_1"], 3) + self.assertEqual(notes, []) + self.assertEqual(to_read, [0, 1]) + + def test_named_absent_and_unpopulated_locations_are_reported(self): + notes, to_read = ff_status.select_slots(REV3, POPULATED, ["F1_7", "F2_6", "F1_6"], 3) + self.assertEqual(notes, ["F1_7: no such location on board revision 3", + "F2_6: not populated"]) + self.assertEqual(to_read, [9]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lga80d_dump.py b/tests/test_lga80d_dump.py index dcb6901..ae4a53c 100644 --- a/tests/test_lga80d_dump.py +++ b/tests/test_lga80d_dump.py @@ -1,6 +1,6 @@ import unittest -from cm_interface import lga80d_dump +from cm_interface.tools import lga80d_dump from cm_interface.device.lga80d import LGA80D diff --git a/tests/test_mcu.py b/tests/test_mcu.py index 4c6522b..c9522c9 100644 --- a/tests/test_mcu.py +++ b/tests/test_mcu.py @@ -638,7 +638,7 @@ def test_unstable_power_generation_raises_coherency_error(self): class ExerciseMcuTest(unittest.TestCase): def test_run_section_degrades_runtime_error_to_a_warning(self): - from cm_interface import exercise_mcu + from cm_interface.tools import exercise_mcu def boom(): raise RuntimeError("MCU map version mismatch") diff --git a/tests/test_mcu_dump.py b/tests/test_mcu_dump.py index 3b13fad..21a844a 100644 --- a/tests/test_mcu_dump.py +++ b/tests/test_mcu_dump.py @@ -5,9 +5,9 @@ import types import unittest -from cm_interface import mcu_dump +from cm_interface.tools import mcu_dump from cm_interface.device.mcu import MCU, McuCapability, McuPage, SystemReg -from cm_interface.lga80d_dump import board_info +from cm_interface.tools._common import board_info ALL_CAPS = (McuCapability.SYSTEM | McuCapability.POWER | McuCapability.ALARMS | McuCapability.ADC | McuCapability.PERSISTENT_LOG @@ -78,14 +78,14 @@ def test_all_sections_and_file(self): written = fh.read() self.assertFalse(failed) - self.assertIn("board id: 3019", text) + self.assertIn("board id: 3019", written) 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) + # with --out-dir, stdout is only the 'wrote' line + self.assertEqual(text, "wrote %s\n" % path) def test_read_only(self): mcu = MemoryMCU() @@ -102,11 +102,12 @@ 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"))) + with open(os.path.join(out_dir, "cmunknown_mcu.txt"), encoding="utf-8") as fh: + written = fh.read() self.assertTrue(failed) - self.assertIn("board id: unavailable", text) - self.assertIn("could not open port", text) + self.assertIn("board id: unavailable", written) + self.assertIn("could not open port", written) self.assertIn("(read failures)", text) self.assertEqual(mcu.writes, []) diff --git a/tests/test_tools_common.py b/tests/test_tools_common.py new file mode 100644 index 0000000..827b5fd --- /dev/null +++ b/tests/test_tools_common.py @@ -0,0 +1,95 @@ +import argparse +import contextlib +import io +import os +import tempfile +import unittest + +from cm_interface.tools import _common + +NAMES = ["A", "B", "C/D"] + + +def _select(argv, default_all=False): + parser = argparse.ArgumentParser() + _common.add_device_args(parser, "thing", NAMES, default_all=default_all) + args = parser.parse_args(argv) + err = io.StringIO() + with contextlib.redirect_stderr(err): + try: + return _common.selected_devices(parser, args, NAMES, default_all=default_all) + except SystemExit: + return "error: " + err.getvalue().strip().splitlines()[-1] + + +class SelectedDevicesTest(unittest.TestCase): + def test_names_in_given_order_without_duplicates(self): + self.assertEqual(_select(["C/D", "A", "C/D"]), ["C/D", "A"]) + + def test_all(self): + self.assertEqual(_select(["--all"]), NAMES) + + def test_dump_needs_names_or_all(self): + self.assertIn("give one or more names, or --all", _select([])) + + def test_check_defaults_to_all(self): + self.assertEqual(_select([], default_all=True), NAMES) + + def test_names_and_all_conflict(self): + self.assertIn("not both", _select(["A", "--all"])) + + def test_unknown_name(self): + self.assertIn("unknown name(s): X", _select(["A", "X"])) + + +class OutputTest(unittest.TestCase): + def test_out_path_replaces_slash(self): + self.assertEqual(_common.out_path("d", 3019, "3V3/1V8_0"), + os.path.join("d", "cm3019_3V3-1V8_0.txt")) + self.assertEqual(_common.out_path("d", None, "mcu"), + os.path.join("d", "cmunknown_mcu.txt")) + + def test_write_lines(self): + with tempfile.TemporaryDirectory() as out_dir: + path = os.path.join(out_dir, "x.txt") + out = io.StringIO() + with contextlib.redirect_stdout(out): + _common.write_lines(path, ["a", "b"], failed=True) + with open(path, encoding="utf-8") as fh: + self.assertEqual(fh.read(), "a\nb\n") + self.assertEqual(out.getvalue(), "wrote %s (read failures)\n" % path) + + +class SummaryTest(unittest.TestCase): + def _summary(self, names, checks, results): + out = io.StringIO() + with contextlib.redirect_stdout(out): + ok = _common.print_summary(names, checks, results) + return ok, out.getvalue() + + def test_all_pass(self): + ok, text = self._summary(["A"], ["x", "y"], {"A": {"x": True, "y": True}}) + self.assertTrue(ok) + self.assertRegex(text, r"A +OK +OK +OK") + self.assertTrue(text.endswith("result: OK\n")) + + def test_one_failure_fails_device_and_overall(self): + ok, text = self._summary(["A", "B"], ["x", "y"], + {"A": {"x": True, "y": False}, "B": {"x": True, "y": True}}) + self.assertFalse(ok) + self.assertRegex(text, r"A +OK +FAIL +FAIL") + self.assertRegex(text, r"B +OK +OK +OK") + self.assertTrue(text.endswith("result: PROBLEMS FOUND\n")) + + def test_device_that_ran_nothing_fails(self): + ok, text = self._summary(["A"], ["x"], {"A": {}}) + self.assertFalse(ok) + self.assertRegex(text, r"A +- +FAIL") + + def test_missing_device_fails(self): + ok, _ = self._summary(["A"], ["x"], {}) + self.assertFalse(ok) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..b47296c --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1,5 @@ +"""Command-line tools built on cm_interface. + +Installed as cm-* commands in /usr/bin by the RPM. From a checkout, run them +directly (./tools/NAME.py); each adds the checkout's parent to sys.path. +""" diff --git a/tools/_common.py b/tools/_common.py new file mode 100644 index 0000000..50beb42 --- /dev/null +++ b/tools/_common.py @@ -0,0 +1,113 @@ +"""Argument and output conventions shared by the command-line tools. + +Every tool takes --dev-path and --debug. Tools that work on named devices take +the names as positional arguments or --all; dumps need one of the two, checks +default to every device. Dumps take --out-dir: with it, each entry goes to its +own cm_.txt and stdout shows only the 'wrote' and result lines; +without it, everything is printed. Checks end with print_summary(). +""" +import os +import sys + +from cm_interface.registry import Registry + +DEFAULT_DEV_PATH = "/dev/ttyUL4" + + +def add_common_args(parser): + parser.add_argument("--dev-path", default=DEFAULT_DEV_PATH, + help="UART device (default: %(default)s)") + parser.add_argument("--debug", action="store_true", + help="log every UART transaction to stdout") + + +def make_registry(args, **kwargs): + return Registry(dev_path=args.dev_path, + debug=sys.stdout if args.debug else None, **kwargs) + + +def add_device_args(parser, what, names, default_all=False): + """Positional device names plus --all. Names are checked in + selected_devices(), not by argparse: nargs='*' with choices rejects an + empty list on older Pythons.""" + parser.add_argument("device", nargs="*", metavar="NAME", + help="%s name(s)%s, given together (not split around " + "options): %s" % ( + what, " (default: all)" if default_all else "", + ", ".join(names))) + parser.add_argument("--all", action="store_true", help="every %s" % what) + + +def selected_devices(parser, args, names, default_all=False): + """Return the device names to work on, in the order given (or names order + for --all). Exits through parser.error on bad input.""" + unknown = [d for d in args.device if d not in names] + if unknown: + parser.error("unknown name(s): %s (choose from %s)" + % (", ".join(unknown), ", ".join(names))) + if args.all and args.device: + parser.error("give names or --all, not both") + if args.all or (default_all and not args.device): + return list(names) + if not args.device: + parser.error("give one or more names, or --all") + return list(dict.fromkeys(args.device)) + + +def add_out_dir_arg(parser, file_name): + parser.add_argument("--out-dir", metavar="DIR", + help="write to DIR instead of stdout, as %s (existing files " + "are overwritten; board id is 'unknown' if the MCU " + "can't report it)" % file_name) + + +def out_path(out_dir, board_id, name): + """DIR/cm_.txt; '/' in a name (3V3/1V8) becomes '-'.""" + return os.path.join(out_dir, "cm%s_%s.txt" % ( + "unknown" if board_id is None else board_id, name.replace("/", "-"))) + + +def write_lines(path, lines, failed=False): + # utf-8 explicitly: Python 3.6 under a C locale would otherwise use ASCII. + with open(path, "w", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + print("wrote %s%s" % (path, " (read failures)" if failed else "")) + + +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: + 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 _cell(row, check): + if check not in row: + return "-" + return "OK" if row[check] else "FAIL" + + +def print_summary(names, checks, results): + """Print a device x check table and the overall result; return True if + everything passed. results[name] maps check -> pass/fail; a check missing + from it did not run ("-") and fails the device.""" + width = max([len(n) for n in names] + [6]) + print("\nsummary") + print(" %-*s %s result" % (width, "device", "".join("%-8s" % c for c in checks))) + all_ok = True + for name in names: + row = results.get(name, {}) + device_ok = all(row.get(c) for c in checks) + all_ok = all_ok and device_ok + print(" %-*s %s %s" % (width, name, + "".join("%-8s" % _cell(row, c) for c in checks), + "OK" if device_ok else "FAIL")) + print("result: %s" % ("OK" if all_ok else "PROBLEMS FOUND")) + return all_ok diff --git a/clock_checks.py b/tools/clock_checks.py old mode 100644 new mode 100755 similarity index 92% rename from clock_checks.py rename to tools/clock_checks.py index c05d555..26e5da0 --- a/clock_checks.py +++ b/tools/clock_checks.py @@ -61,19 +61,19 @@ reference manual and have not been checked on a Rev2 board. Usage: - 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 + cm-clock-checks # all checks, all clocks + cm-clock-checks --check status # one check + cm-clock-checks R0A R1B --check flags # some clocks + cm-clock-checks --hw-rev 2 # override the MCU's board revision """ import argparse import os import sys from collections import namedtuple -# Permit direct execution from this package directory (as exercise_mcu.py does). +# Permit direct execution from a checkout (./tools/NAME.py) without the RPM. if not __package__: - _package_dir = os.path.dirname(os.path.realpath(__file__)) + _package_dir = os.path.dirname(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) @@ -83,7 +83,8 @@ from cm_interface.device.si5341 import Si5341Reg from cm_interface.device.si5395 import Si5395Reg from cm_interface.errors import ClockIdentityError, CMError -from cm_interface.registry import Registry +from cm_interface.tools._common import (add_common_args, add_device_args, make_registry, + print_summary, selected_devices) EXPECTED_ERRORS = (CMError, OSError, TimeoutError, ValueError, RuntimeError) @@ -445,14 +446,17 @@ def check_flags_5341(clock, mon): # ---------------------------------------------------------------- driver def check_clock(clock, checks): - """Run the selected checks on one clock. Returns True when all pass.""" + """Run the selected checks on one clock. Returns check -> pass/fail for + each check that ran; empty if the clock is not ready, is the wrong chip + or its configuration could not be read.""" + results = {} name = clock.name try: if not check_ready(clock) or not check_identity(clock): - return False + return results except EXPECTED_ERRORS as exc: print("%-4s READ FAILED: %s" % (name, _format_error(exc))) - return False + return results profile = PROFILES[clock.CHIP] mon = None if set(checks) & {"status", "flags"}: @@ -460,15 +464,14 @@ def check_clock(clock, checks): mon = profile["monitors"](clock) except EXPECTED_ERRORS as exc: print("%-4s config READ FAILED: %s" % (name, _format_error(exc))) - return False - ok = True + return results for check in checks: try: - ok = profile[check](clock, mon) and ok + results[check] = bool(profile[check](clock, mon)) except EXPECTED_ERRORS as exc: print("%-4s %s READ FAILED: %s" % (name, check, _format_error(exc))) - ok = False - return ok + results[check] = False + return results CHECKS = ("id", "status", "flags") @@ -483,23 +486,18 @@ def check_clock(clock, checks): def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + add_device_args(parser, "clock", list(CORE_CONFIG.clocks), default_all=True) 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("--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") + add_common_args(parser) 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, - hw_rev=args.hw_rev) + names = selected_devices(parser, args, list(CORE_CONFIG.clocks), default_all=True) + reg = make_registry(args, hw_rev=args.hw_rev) try: clocks = reg.clocks except EXPECTED_ERRORS as exc: @@ -510,14 +508,11 @@ def main(): 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 + results = {} 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 + results[name] = check_clock(reg.get_clock(name), checks) + return 0 if print_summary(names, checks, results) else 1 if __name__ == "__main__": diff --git a/clock_dump.py b/tools/clock_dump.py old mode 100644 new mode 100755 similarity index 92% rename from clock_dump.py rename to tools/clock_dump.py index 1e9cfff..bbf6486 --- a/clock_dump.py +++ b/tools/clock_dump.py @@ -28,18 +28,19 @@ fastlock, loop bandwidth codes, phase adjust) is not read. Usage: - clock_dump.py R0A - clock_dump.py --all --out-dir DIR - clock_dump.py R0A --hw-rev 2 + cm-clock-dump R0A + cm-clock-dump R0A R1B + cm-clock-dump --all --out-dir DIR + cm-clock-dump R0A --hw-rev 2 """ import argparse import os import sys import time -# Permit direct execution from this package directory (as exercise_mcu.py does). +# Permit direct execution from a checkout (./tools/NAME.py) without the RPM. if not __package__: - _package_dir = os.path.dirname(os.path.realpath(__file__)) + _package_dir = os.path.dirname(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) @@ -48,8 +49,9 @@ 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 +from cm_interface.tools._common import (add_common_args, add_device_args, add_out_dir_arg, + board_info, make_registry, out_path, + selected_devices, write_lines) MAX_READ_BYTES = 4 @@ -487,11 +489,7 @@ def dump_clock(reg, name, args, board_lines, board_id, echo): 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) + write_lines(out_path(args.out_dir, board_id, name), lines) if not echo: print(result_line) return failed @@ -500,27 +498,16 @@ def dump_clock(reg, name, args, board_lines, board_id, echo): 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)") + add_device_args(parser, "clock", list(CORE_CONFIG.clocks)) + add_out_dir_arg(parser, "cm_.txt, one file per clock") 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)") + add_common_args(parser) 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") + names = selected_devices(parser, args, list(CORE_CONFIG.clocks)) - reg = Registry(dev_path=args.dev_path, hw_rev=args.hw_rev) + reg = make_registry(args, 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] @@ -532,11 +519,10 @@ def main(): 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] failed = False for name in names: failed = dump_clock(reg, name, args, board_lines, board_id, - echo=not args.all) or failed + echo=not args.out_dir) or failed return 1 if failed else 0 diff --git a/examples.py b/tools/examples.py old mode 100644 new mode 100755 similarity index 97% rename from examples.py rename to tools/examples.py index 7a008f2..0fc29d2 --- a/examples.py +++ b/tools/examples.py @@ -6,6 +6,16 @@ Firefly optical transceivers, and LGA80D DC-DC converters. """ +import os +import sys + +# Permit direct execution from a checkout (./tools/NAME.py) without the RPM. +if not __package__: + _package_dir = os.path.dirname(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.registry import Registry from cm_interface.device.si5395 import Si5395Reg from cm_interface.device.firefly import Firefly12, Firefly4, FireflyTx, FireflyRx diff --git a/exercise_mcu.py b/tools/exercise_mcu.py similarity index 93% rename from exercise_mcu.py rename to tools/exercise_mcu.py index 0fb9364..b776361 100755 --- a/exercise_mcu.py +++ b/tools/exercise_mcu.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 """ Exercise the command-module MCU's ProgCom register area (``MC 0``) on real hardware, following ``MCU_REGISTER_MAP.md``. @@ -12,11 +12,11 @@ (persisted to EEPROM) and need the same confirmation. Usage: - ./exercise_mcu.py [--dev-path /dev/ttyUL4] [--debug] - ./exercise_mcu.py --send-control CLEAR_ALARM_LATCHES [--yes] - ./exercise_mcu.py --power-cycle [--yes] - ./exercise_mcu.py --set-alarm-temp FPGA 85 [--yes] - ./exercise_mcu.py --set-volt-threshold 5 [--yes] + cm-exercise-mcu [--dev-path /dev/ttyUL4] [--debug] + cm-exercise-mcu --send-control CLEAR_ALARM_LATCHES [--yes] + cm-exercise-mcu --power-cycle [--yes] + cm-exercise-mcu --set-alarm-temp FPGA 85 [--yes] + cm-exercise-mcu --set-volt-threshold 5 [--yes] """ import argparse @@ -24,16 +24,14 @@ import sys import time -# Permit direct execution from either the repository root (via its symlink) -# or this package directory, without requiring an installation step. +# Permit direct execution from a checkout (./tools/NAME.py) without the RPM. if not __package__: - _package_dir = os.path.dirname(os.path.realpath(__file__)) + _package_dir = os.path.dirname(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.errors import CMError, McuCapabilityUnavailable -from cm_interface.registry import Registry from cm_interface.device.mcu import ( ALARM_TEMP_MAX_C, ALARM_TEMP_MIN_C, @@ -43,6 +41,7 @@ McuControlCommand, describe_reset_cause, ) +from cm_interface.tools._common import add_common_args, make_registry # RuntimeError is here on purpose: _run_section is a per-section warning @@ -290,10 +289,7 @@ def power_cycle(mcu, assume_yes): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--dev-path", default="/dev/ttyUL4", - help="serial device path (default: /dev/ttyUL4)") - parser.add_argument("--debug", action="store_true", - help="log raw UART transactions to stdout") + add_common_args(parser) operation = parser.add_mutually_exclusive_group() operation.add_argument("--send-control", metavar="COMMAND", choices=[c.name for c in McuControlCommand], @@ -322,8 +318,7 @@ def main(): except ValueError: parser.error("CELSIUS must be a whole number") - reg = Registry(dev_path=args.dev_path, - debug=sys.stdout if args.debug else None) + reg = make_registry(args) mcu = reg.get_mcu() if args.send_control: diff --git a/tools/ff_status.py b/tools/ff_status.py new file mode 100755 index 0000000..c0ddc35 --- /dev/null +++ b/tools/ff_status.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""Print a status summary of the populated Fireflies (all, or the locations named). + +"Populated" is the MCU's Firefly present mask (system page 0x58, built from +the *_PRESENT pins at MCU boot). A present module that is not also in the +user mask (0x54) is listed but not read: the MCU refuses ProgCom access to it. + +For each module read: part ID, serial (x12 only), case temperature, Vcc3.3, +status/summary bytes and the latched alarm bytes, decoded. Register addresses +are from the ECUO 25G x12 Rev 4, CERN-B x12 and ECUO 25G/28G x4 datasheets. + +Nothing is written. Reading a latched alarm byte clears it, but the MCU +already polls these same bytes continuously (MON_I2C_rev*.yml), so this adds +no new side effect; it also means a latched bit shown here is usually a +condition that is still present (it re-latches at once), not history. + +Usage: + cm-ff-status --all + cm-ff-status F1_1 F2_6 + cm-ff-status --all --out-dir DIR +""" +import argparse +import os +import sys +import time + +# Permit direct execution from a checkout (./tools/NAME.py) without the RPM. +if not __package__: + _package_dir = os.path.dirname(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.firefly import Firefly4, FireflyReg, FireflyRx, FireflyTx +from cm_interface.tools._common import (add_common_args, add_device_args, add_out_dir_arg, + make_registry, out_path, selected_devices, + write_lines) + +# Firmware ff_moni2c_addrs[] (cm_mcu LocalTasks.c) by board revision; the +# device number is the index. Kind: "Tx"/"Rx" = x12 half, "4" = x4 XCVR. +SLOTS = { + 2: (("F1_1 Tx", "Tx"), ("F1_1 Rx", "Rx"), ("F1_2 Tx", "Tx"), + ("F1_2 Rx", "Rx"), ("F1_3 Tx", "Tx"), ("F1_3 Rx", "Rx"), + ("F1_4", "4"), ("F1_5", "4"), ("F1_6", "4"), ("F1_7", "4"), + ("F2_1 Tx", "Tx"), ("F2_1 Rx", "Rx"), ("F2_2 Tx", "Tx"), + ("F2_2 Rx", "Rx"), ("F2_3 Tx", "Tx"), ("F2_3 Rx", "Rx"), + ("F2_4", "4"), ("F2_5", "4"), ("F2_6", "4"), ("F2_7", "4")), + 3: (("F1_1 Tx", "Tx"), ("F1_1 Rx", "Rx"), ("F1_2 Tx", "Tx"), + ("F1_2 Rx", "Rx"), ("F1_3 Tx", "Tx"), ("F1_3 Rx", "Rx"), + ("F1_4 Tx", "Tx"), ("F1_4 Rx", "Rx"), ("F1_5", "4"), ("F1_6", "4"), + ("F2_1 Tx", "Tx"), ("F2_1 Rx", "Rx"), ("F2_2 Tx", "Tx"), + ("F2_2 Rx", "Rx"), ("F2_3 Tx", "Tx"), ("F2_3 Rx", "Rx"), + ("F2_4 Tx", "Tx"), ("F2_4 Rx", "Rx"), ("F2_5", "4"), ("F2_6", "4")), +} +CLASSES = {"Tx": FireflyTx, "Rx": FireflyRx, "4": Firefly4} + + +def location(slot_name): + """'F1_1 Tx' -> 'F1_1': a location selects both halves of an x12 pair.""" + return slot_name.split()[0] + + +# Every location on any revision, for --help and name checking before the MCU +# says which revision this board is. +LOCATIONS = sorted({location(name) for slots in SLOTS.values() for name, _ in slots}) + +def select_slots(slots, populated, wanted, hw_rev): + """Return (note lines, populated slot numbers to read). wanted is None for + --all, meaning every populated slot of this revision; otherwise the named + locations, and only those are reported when absent or not populated.""" + if wanted is None: + return [], populated + rev_locations = [location(name) for name, _ in slots] + notes = [] + for loc in wanted: + if loc not in rev_locations: + notes.append("%s: no such location on board revision %d" % (loc, hw_rev)) + elif not any(location(slots[n][0]) == loc for n in populated): + notes.append("%s: not populated" % loc) + return notes, [n for n in populated if location(slots[n][0]) in wanted] + + +# x12 status summary byte 6 (Tables 12/34; CERN-B Tables 24/39). Bit 0 +# (init complete) clears on read and is not an alarm, so it is not listed. +SUMMARY_BITS = { + "Tx": {7: "Tx LOS", 5: "Tx fault", 1: "temp/Vcc alarm"}, + "Rx": {6: "Rx LOS", 5: "fault", 2: "Rx low power", 1: "temp/Vcc alarm"}, +} +# x12 bytes 17/18 (Tables 15/16). +X12_TEMP_BITS = {7: "temp high alarm", 6: "temp low alarm"} +X12_VCC_BITS = {7: "Vcc3.3 high alarm", 6: "Vcc3.3 low alarm"} +# x4 bytes 6/7 (Tables 32/33). Byte 6 bit 0 is the init-complete flag. +X4_TEMP_BITS = {7: "temp high alarm", 6: "temp low alarm", + 5: "temp high warning", 4: "temp low warning"} +X4_VCC_BITS = {7: "Vcc3.3 high alarm", 6: "Vcc3.3 low alarm", + 5: "Vcc3.3 high warning", 4: "Vcc3.3 low warning", + 3: "Vcc1.8 high alarm", 2: "Vcc1.8 low alarm", + 1: "Vcc1.8 high warning", 0: "Vcc1.8 low warning"} + + +def bit_names(value, table): + return [table[b] for b in sorted(table, reverse=True) if value & (1 << b)] + + +def x12_channels(ff, reg): + """Decode a 2-byte x12 per-channel latch: byte n low nibble = ch 9-12, + byte n+1 = ch 1-8 (e.g. x12 Rev 4 Table 13). Returns 1-based channels.""" + hi, lo = ff.read_reg(reg, size=2) + mask = ((hi & 0x0F) << 8) | lo + return [ch + 1 for ch in range(12) if mask & (1 << ch)] + + +def x12_rx_power(ff): + """Decode x12 Rx bytes 14-16 (Table 36): 2 bits per channel, byte 16 + bits 1-0 = ch 1 up to byte 14 bits 7-6 = ch 12; 01 = too low, 10 = too + high. Returns (low, high) lists of 1-based channels.""" + raw = ff.read_reg(FireflyRx.Reg.RX_POWER_ALARM, size=3) + value = int.from_bytes(raw, "big") + low, high = [], [] + for ch in range(12): + code = (value >> (2 * ch)) & 0x3 + if code & 0x1: + low.append(ch + 1) + if code & 0x2: + high.append(ch + 1) + return low, high + + +def x4_channels(value, shift): + return [ch + 1 for ch in range(4) if value & (1 << (ch + shift))] + + +def chans(label, channels): + return "%s ch %s" % (label, ",".join(str(c) for c in channels)) + + +def family(part): + """Same rule as the firmware's ff_map_25gb_parts().""" + if "CERNB" in part or "CRRNB" in part: + return "cernb" + if "14" in part: + return "14g" + return "25g" + + +def read_x12(ff, kind, part): + fam = family(part) + problems = [] + status = ff.read_reg(FireflyReg.STATUS)[0] + if status & 0x01: + problems.append("data not ready") + summary = ff.status_summary + problems += bit_names(summary, SUMMARY_BITS[kind]) + # 14G map not checked against its manual; the firmware reads no latches + # on it either, so stop at the summary. + if fam != "14g": + problems += bit_names(ff.temperature_alarm, X12_TEMP_BITS) + problems += bit_names(ff.supply_voltage_alarm, X12_VCC_BITS) + if kind == "Rx": + los = x12_channels(ff, FireflyRx.Reg.RX_LOS_LATCH) + if los: + problems.append(chans("LOS", los)) + if fam == "25g": # CERN-B Rx bytes 9-16 are reserved (Table 37) + low, high = x12_rx_power(ff) + if low: + problems.append(chans("Rx power low", low)) + if high: + problems.append(chans("Rx power high", high)) + else: + if fam == "25g": # CERN-B Tx has no LOS bytes (Table 22) + los = x12_channels(ff, FireflyTx.Reg.TX_LOS_LATCH) + if los: + problems.append(chans("LOS", los)) + fault = x12_channels(ff, FireflyTx.Reg.TX_FAULT_LATCH) + if fault: + problems.append(chans("fault", fault)) + if fam == "25g": # CERN-B has no CDR + lol = x12_channels(ff, FireflyTx.Reg.CDR_LOL_STATUS_BASE) + if lol: + problems.append(chans("CDR LOL", lol)) + return fam, "status=0x%02X summary=0x%02X" % (status, summary), problems + + +def read_x4(ff): + problems = [] + status = ff.read_reg(Firefly4.Reg.STATUS)[0] + if status & 0x01: + problems.append("data not ready") + los = ff.read_reg(Firefly4.Reg.LOS_ALARMS)[0] + fault = ff.read_reg(Firefly4.Reg.TX_FAULT_LATCH)[0] + lol = ff.read_reg(Firefly4.Reg.CDR_LOL_ALARMS)[0] + for label, value, shift in (("Tx LOS", los, 4), ("Rx LOS", los, 0), + ("Tx fault", fault, 0), + ("Tx CDR LOL", lol, 4), ("Rx CDR LOL", lol, 0)): + ch = x4_channels(value, shift) + if ch: + problems.append(chans(label, ch)) + problems += bit_names(ff.temperature_alarm, X4_TEMP_BITS) + problems += bit_names(ff.supply_voltage_alarm, X4_VCC_BITS) + return "x4", "status=0x%02X" % status, problems + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + add_device_args(parser, "Firefly location", LOCATIONS) + add_out_dir_arg(parser, "cm_ff_status.txt") + add_common_args(parser) + args = parser.parse_args() + wanted = selected_devices(parser, args, LOCATIONS) + if args.all: + wanted = None # this board revision's slots, not every revision's + + reg = make_registry(args) + info = reg.mcu.system_info + if info.ff_present_mask is None: + print("MCU map minor %d has no Firefly masks (needs >= 1)" % info.map_minor) + return 1 + slots = SLOTS.get(info.hardware_revision) + if slots is None: + print("no Firefly slot table for board revision %d" % info.hardware_revision) + return 1 + + present, user = info.ff_present_mask, info.ff_user_mask + populated = [n for n in range(len(slots)) if present & (1 << n)] + lines = [ + "Firefly status %s" % time.strftime("%Y-%m-%d %H:%M:%S"), + "board %d rev %d fw %s" % (info.board_id, info.hardware_revision, + info.git_version), + "present mask 0x%05X user mask 0x%05X populated %d of %d" + % (present, user, len(populated), len(slots)), + ] + notes, to_read = select_slots(slots, populated, wanted, info.hardware_revision) + lines += notes + lines.append("%2s %-8s %-6s %-16s %-10s %5s %6s %s" + % ("#", "slot", "type", "part", "serial", "T(C)", "Vcc(V)", "status")) + + failed = False + for n in to_read: + name, kind = slots[n] + if not user & (1 << n): + lines.append("%2d %-8s present but not in user mask; MCU will not read it" + % (n, name)) + continue + ff = CLASSES[kind](reg.uart, address=0x20 + n, location=name) + try: + part = ff.part_id + serial = ff.serial_number or "-" + temp = ff.temperature + vcc = ff.supply_voltage + if kind == "4": + fam, raw, problems = read_x4(ff) + else: + fam, raw, problems = read_x12(ff, kind, part) + except Exception as exc: # keep going so one bad module doesn't hide the rest + failed = True + lines.append("%2d %-8s READ FAILED: %s" % (n, name, exc)) + continue + lines.append("%2d %-8s %-6s %-16s %-10s %5d %6.3f %s %s" + % (n, name, fam, part, serial, temp, vcc, + "ALARM" if problems else "OK", raw)) + for p in problems: + lines.append(" %s" % p) + if args.out_dir: + os.makedirs(args.out_dir, exist_ok=True) + write_lines(out_path(args.out_dir, info.board_id, "ff_status"), lines, failed) + else: + print("\n".join(lines)) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lga80d_checks.py b/tools/lga80d_checks.py old mode 100644 new mode 100755 similarity index 89% rename from lga80d_checks.py rename to tools/lga80d_checks.py index 559e396..130045e --- a/lga80d_checks.py +++ b/tools/lga80d_checks.py @@ -27,18 +27,18 @@ 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 + cm-lga80d-checks # all checks, all modules + cm-lga80d-checks --check freq # one check + cm-lga80d-checks F1VCCINT2 --check temp # one module """ import argparse import os import sys import time -# Permit direct execution from this package directory (as exercise_mcu.py does). +# Permit direct execution from a checkout (./tools/NAME.py) without the RPM. if not __package__: - _package_dir = os.path.dirname(os.path.realpath(__file__)) + _package_dir = os.path.dirname(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) @@ -47,7 +47,8 @@ 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.tools._common import (add_common_args, add_device_args, make_registry, + print_summary, selected_devices) from cm_interface.utils import decode_linear11, decode_linear16u EXPECTED_ERRORS = (CMError, OSError, TimeoutError, ValueError, RuntimeError) @@ -162,11 +163,12 @@ def read_power_state(reg): def check_freq(reg, names, power_state): - """FREQUENCY_SWITCH / READ_FREQUENCY / VOUT_DROOP / ramp gain vs LGA80D_init.""" + """FREQUENCY_SWITCH / READ_FREQUENCY / VOUT_DROOP / ramp gain vs LGA80D_init. + Returns name -> True if read, False if a read failed (the notes don't fail).""" 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 + ok = {} results = {} for name in names: lga = reg.get_lga80d(name) @@ -177,9 +179,10 @@ def check_freq(reg, names, power_state): 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 + ok[name] = False print("%-10s READ FAILED: %s" % (name, _format_error(exc))) continue + ok[name] = True 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" @@ -219,23 +222,25 @@ def check_freq(reg, names, power_state): def check_temp(reg, names, power_state): - """READ_TEMPERATURE_1 (controller die) against READ_TEMPERATURE_3 (DrMOS).""" + """READ_TEMPERATURE_1 (controller die) against READ_TEMPERATURE_3 (DrMOS). + Returns name -> False if any page's read failed, else True.""" 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 + ok = {} max_t1 = None max_t3 = None for name in names: lga = reg.get_lga80d(name) + ok[name] = True 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 + ok[name] = False print("%-10s %d READ FAILED: %s" % (name, page, _format_error(exc))) continue t1 = decode_linear11(raw1) @@ -268,17 +273,19 @@ def _on_off_config_text(value): def check_timing(reg, names): - """Turn-on/off settings, ZL8802 Eq. 1/2 and time to PG vs the 25 ms check.""" + """Turn-on/off settings, ZL8802 Eq. 1/2 and time to PG vs the 25 ms check. + Returns name -> False if any read failed, else True.""" print("\n=== timing: turn-on/off settings vs firmware sequencing ===") - ok = True + ok = {} for name in names: lga = reg.get_lga80d(name) + ok[name] = True 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 + ok[name] = False print("%-10s READ FAILED: %s" % (name, _format_error(exc))) continue for page in PAGES: @@ -294,7 +301,7 @@ def check_timing(reg, names): toff_f = _l11(lga, TOFF_FALL, page) pg_delay = _l11(lga, POWER_GOOD_DELAY, page) except EXPECTED_ERRORS as exc: - ok = False + ok[name] = False print("%-10s p%d READ FAILED: %s" % (name, page, _format_error(exc))) continue @@ -344,29 +351,28 @@ def check_timing(reg, names): def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + add_device_args(parser, "LGA80D supply", list(CORE_CONFIG.lga80d), default_all=True) 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") + add_common_args(parser) 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) + checks = [c for c in CHECKS if c in (args.check or CHECKS)] + names = selected_devices(parser, args, list(CORE_CONFIG.lga80d), default_all=True) + reg = make_registry(args) 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 + results = {name: {} for name in names} + for check in checks: + if check == "freq": + per_device = check_freq(reg, names, power_state) + elif check == "temp": + per_device = check_temp(reg, names, power_state) + else: + per_device = check_timing(reg, names) + for name, passed in per_device.items(): + results[name][check] = passed + return 0 if print_summary(names, checks, results) else 1 if __name__ == "__main__": diff --git a/lga80d_dump.py b/tools/lga80d_dump.py old mode 100644 new mode 100755 similarity index 80% rename from lga80d_dump.py rename to tools/lga80d_dump.py index 1ca7417..c18785d --- a/lga80d_dump.py +++ b/tools/lga80d_dump.py @@ -19,25 +19,28 @@ with the SN device, so it is opt-in. Usage: - lga80d_dump.py F1VCCINT1 - lga80d_dump.py F1VCCINT1 --page 0 --include-unconfirmed - lga80d_dump.py F1VCCINT1 --snapshot - lga80d_dump.py --all --out-dir DIR + cm-lga80d-dump F1VCCINT1 + cm-lga80d-dump F1VCCINT1 --page 0 --include-unconfirmed + cm-lga80d-dump F1VCCINT1 --snapshot + cm-lga80d-dump F1VCCINT1 F2VCCINT1 + cm-lga80d-dump --all --out-dir DIR """ import argparse import os import sys import time -# Permit direct execution from this package directory (as exercise_mcu.py does). +# Permit direct execution from a checkout (./tools/NAME.py) without the RPM. if not __package__: - _package_dir = os.path.dirname(os.path.realpath(__file__)) + _package_dir = os.path.dirname(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.registry import Registry +from cm_interface.tools._common import (add_common_args, add_device_args, add_out_dir_arg, + board_info, make_registry, out_path, + selected_devices, write_lines) from cm_interface.utils import decode_linear11, decode_linear16u # (name, command, size in bytes, unconfirmed). Mirrors registers/lga80d.json @@ -182,20 +185,6 @@ def snapshot_lines(lga, page): return lines, True -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: - 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 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.""" @@ -232,24 +221,15 @@ def dump_supply(reg, device, args, board_lines, board_id, registers, pages, 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, - device.replace("/", "-"), page)) - with open(path, "w") as fh: - fh.write("\n".join(header + lines) + "\n") - print("wrote %s%s" % (path, " (read failures)" if failed else "")) + write_lines(out_path(args.out_dir, board_id, "%s_%d" % (device, page)), + header + lines, failed) 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)") + add_device_args(parser, "LGA80D supply", sorted(CORE_CONFIG.lga80d)) parser.add_argument("--page", type=int, choices=(0, 1), help="only this output page (default: both)") parser.add_argument("--include-unconfirmed", action="store_true", @@ -257,30 +237,21 @@ def main(): 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)") + add_out_dir_arg(parser, "cm__.txt, one file per page") + add_common_args(parser) 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") + devices = selected_devices(parser, args, sorted(CORE_CONFIG.lga80d)) - reg = Registry(dev_path=args.dev_path) + reg = make_registry(args) 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 + registers, pages, echo=not args.out_dir) or failed return 1 if failed else 0 diff --git a/lga80d_status.py b/tools/lga80d_status.py old mode 100644 new mode 100755 similarity index 68% rename from lga80d_status.py rename to tools/lga80d_status.py index 16e61a2..dde6af3 --- a/lga80d_status.py +++ b/tools/lga80d_status.py @@ -8,22 +8,26 @@ Bit meanings are from LGA80D_40A_dual_AppNote.pdf, "STATUS_*" command pages. Usage: - lga80d_status.py F1VCCINT1 # one device, both output pages - lga80d_status.py --all # every LGA80D on the board + cm-lga80d-status F1VCCINT1 # one device, both output pages + cm-lga80d-status --all # every LGA80D on the board + cm-lga80d-status --all --out-dir DIR """ import argparse import os import sys +import time -# Permit direct execution from this package directory (as exercise_mcu.py does). +# Permit direct execution from a checkout (./tools/NAME.py) without the RPM. if not __package__: - _package_dir = os.path.dirname(os.path.realpath(__file__)) + _package_dir = os.path.dirname(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.registry import Registry +from cm_interface.tools._common import (add_common_args, add_device_args, add_out_dir_arg, + board_info, make_registry, out_path, + selected_devices, write_lines) # bit number -> meaning. Bits missing from a table are "Not Used" in the # datasheet; if one is set it is printed as undocumented. @@ -91,39 +95,51 @@ ] -def print_status(name, page, status): - print("%s page %d has_faults=%s" % (name, page, status.has_faults)) +def status_lines(name, page, status): + lines = ["%s page %d has_faults=%s" % (name, page, status.has_faults)] for title, attr, bits, width in REGISTERS: value = getattr(status, attr) - print(" %-27s = 0x%0*X" % (title, width // 4, value)) + lines.append(" %-27s = 0x%0*X" % (title, width // 4, value)) for bit in range(width - 1, -1, -1): if value & (1 << bit): - print(" bit %2d: %s" % (bit, bits.get(bit, "undocumented bit"))) + lines.append(" bit %2d: %s" % (bit, bits.get(bit, "undocumented bit"))) + return lines def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - which = parser.add_mutually_exclusive_group(required=True) - which.add_argument("device", nargs="?", choices=sorted(CORE_CONFIG.lga80d), - help="LGA80D supply name") - which.add_argument("--all", action="store_true", - help="read every LGA80D on the board") - parser.add_argument("--dev-path", default="/dev/ttyUL4", - help="UART device (default: %(default)s)") + add_device_args(parser, "LGA80D supply", sorted(CORE_CONFIG.lga80d)) + add_out_dir_arg(parser, "cm__status.txt, one file per supply") + add_common_args(parser) args = parser.parse_args() + names = selected_devices(parser, args, sorted(CORE_CONFIG.lga80d)) - names = sorted(CORE_CONFIG.lga80d) if args.all else [args.device] - reg = Registry(dev_path=args.dev_path) + reg = make_registry(args) + board_lines, board_id = board_info(reg) + if args.out_dir: + os.makedirs(args.out_dir, exist_ok=True) failed = False for name in names: lga = reg.get_lga80d(name) + lines = [ + "LGA80D status %s" % time.strftime("%Y-%m-%d %H:%M:%S"), + " supply: %s (index 0x%02X)" % (name, CORE_CONFIG.lga80d[name]), + " uart: %s" % args.dev_path, + ] + board_lines + supply_failed = False for page in (0, 1): try: - print_status(name, page, lga.read_status(page)) + lines += status_lines(name, page, lga.read_status(page)) except Exception as exc: # keep going so one bad device doesn't hide the rest - failed = True - print("%s page %d READ FAILED: %s" % (name, page, exc)) + supply_failed = True + lines.append("%s page %d READ FAILED: %s" % (name, page, exc)) + failed = failed or supply_failed + if args.out_dir: + write_lines(out_path(args.out_dir, board_id, name + "_status"), lines, + supply_failed) + else: + print("\n".join(lines)) return 1 if failed else 0 diff --git a/mcu_dump.py b/tools/mcu_dump.py similarity index 61% rename from mcu_dump.py rename to tools/mcu_dump.py index e6763c9..128ea6c 100755 --- a/mcu_dump.py +++ b/tools/mcu_dump.py @@ -1,17 +1,17 @@ #!/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 +READ ONLY: this runs the same page reads as ``cm-exercise-mcu`` 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. +path. Use ``cm-exercise-mcu`` 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 + cm-mcu-dump + cm-mcu-dump --out-dir DIR """ import argparse import contextlib @@ -20,14 +20,14 @@ import sys import time -# Permit direct execution from this package directory (as exercise_mcu.py does). +# Permit direct execution from a checkout (./tools/NAME.py) without the RPM. if not __package__: - _package_dir = os.path.dirname(os.path.realpath(__file__)) + _package_dir = os.path.dirname(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 ( +from cm_interface.tools.exercise_mcu import ( exercise_adc, exercise_alarm, exercise_config, @@ -36,8 +36,8 @@ exercise_runtime, exercise_system, ) -from cm_interface.lga80d_dump import board_info -from cm_interface.registry import Registry +from cm_interface.tools._common import (add_common_args, add_out_dir_arg, board_info, + make_registry, out_path, write_lines) SECTIONS = ( exercise_system, @@ -64,7 +64,7 @@ def encoding(self): def dump_mcu(reg, args, board_lines, board_id): - """Dump every page. Prints to stdout; also writes a file if --out-dir. + """Dump every page. Writes a file if --out-dir, else prints to stdout. Returns True if any section failed.""" header = [ "MCU register dump %s" % time.strftime("%Y-%m-%d %H:%M:%S"), @@ -77,30 +77,23 @@ def dump_mcu(reg, args, board_lines, board_id): 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 "")) + # write_lines writes utf-8: the warning marker may be non-ASCII. + write_lines(out_path(args.out_dir, board_id, "mcu"), text.rstrip("\n").split("\n"), + failed) + else: + print(text, end="") 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)") + add_out_dir_arg(parser, "cm_mcu.txt") + add_common_args(parser) args = parser.parse_args() - reg = Registry(dev_path=args.dev_path) + reg = make_registry(args) board_lines, board_id = board_info(reg) if args.out_dir: os.makedirs(args.out_dir, exist_ok=True) diff --git a/tools/mcu_set_rtc.py b/tools/mcu_set_rtc.py new file mode 100755 index 0000000..99c5b2a --- /dev/null +++ b/tools/mcu_set_rtc.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Set the command-module MCU's RTC to this host's local time (``MC 0``). + +Reads the RTC, writes the host's local wall-clock time through the Control +page (``MCU.set_rtc``), then reads it back and compares. The RTC has no time +zone. The time comes from whichever host runs this script (on the board, the +Zynq), so check that host's clock first: the MCU only refuses times before +2026-01-01. + +Exits 1 if the write is refused, or if the read-back is invalid or more than +--tolerance seconds from the host clock. + +Usage: + cm-mcu-set-rtc + cm-mcu-set-rtc --dry-run +""" +import argparse +import datetime +import os +import sys + +# Permit direct execution from a checkout (./tools/NAME.py) without the RPM. +if not __package__: + _package_dir = os.path.dirname(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.mcu import McuCapability, rtc_local_seconds +from cm_interface.errors import McuCapabilityUnavailable +from cm_interface.tools._common import add_common_args, make_registry + + +def rtc_datetime(rtc): + """McuRtc as a naive datetime, or None when the RTC is not valid.""" + if not rtc.valid: + return None + return datetime.datetime(rtc.year, rtc.month, rtc.day, + rtc.hour, rtc.minute, rtc.second) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--dry-run", action="store_true", + help="read the RTC and show what would be written; write nothing") + parser.add_argument("--tolerance", type=float, default=2.0, metavar="S", + help="maximum read-back offset from the host clock, seconds " + "(default: %(default)s)") + add_common_args(parser) + args = parser.parse_args() + + mcu = make_registry(args).get_mcu() + print("MCU RTC before: %s" % mcu.read_runtime().rtc.isoformat()) + + now = datetime.datetime.now().replace(microsecond=0) + if args.dry_run: + supported = bool(mcu.capabilities() & McuCapability.RTC_WRITE) + print("would set: %s (0x%08x)%s" % ( + now.isoformat(), rtc_local_seconds(now), + "" if supported else " -- firmware does not advertise RTC_WRITE")) + return 0 + + try: + mcu.set_rtc(now) + except McuCapabilityUnavailable as exc: + print("error: %s (firmware older than map minor 4?)" % exc, file=sys.stderr) + return 1 + except ValueError as exc: + print("error: %s; is this host's clock set?" % exc, file=sys.stderr) + return 1 + + rtc = mcu.read_runtime().rtc + print("MCU RTC after: %s" % rtc.isoformat()) + after = rtc_datetime(rtc) + if after is None: + print("error: RTC reads back invalid", file=sys.stderr) + return 1 + offset = (after - datetime.datetime.now()).total_seconds() + print("offset vs host: %+.1f s" % offset) + if abs(offset) > args.tolerance: + print("error: offset exceeds %.1f s" % args.tolerance, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vccint_2phase.py b/tools/vccint_2phase.py old mode 100644 new mode 100755 similarity index 94% rename from vccint_2phase.py rename to tools/vccint_2phase.py index b105697..8c0f206 --- a/vccint_2phase.py +++ b/tools/vccint_2phase.py @@ -58,29 +58,29 @@ --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 + python -m cm_interface.tools.vccint_2phase --fpga 2 # read-only: show current state + plan + python -m cm_interface.tools.vccint_2phase --fpga 2 --apply + python -m cm_interface.tools.vccint_2phase --fpga 1 # read-only + python -m cm_interface.tools.vccint_2phase --fpga 1 --apply + python -m cm_interface.tools.vccint_2phase --fpga 1 --apply --drop-rail 20 # also move FnVCCINT2 to rail 20 + python -m cm_interface.tools.vccint_2phase --fpga 1 --restore # read-only: restore plan + python -m cm_interface.tools.vccint_2phase --fpga 1 --restore --apply """ import argparse import os import sys import time -# Permit direct execution from this package directory (as lga80d_status.py does). +# Permit direct execution from a checkout (./tools/NAME.py) without the RPM. if not __package__: - _package_dir = os.path.dirname(os.path.realpath(__file__)) + _package_dir = os.path.dirname(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 +from cm_interface.tools._common import add_common_args, make_registry FPGAS = (1, 2) @@ -227,14 +227,13 @@ def main(): 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)") + add_common_args(parser) 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) + reg = make_registry(args) keep = reg.get_lga80d(KEEP) drop = reg.get_lga80d(DROP)