Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/rpm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
'

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
10 changes: 5 additions & 5 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

---
Expand Down Expand Up @@ -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()
```

Expand Down
2 changes: 1 addition & 1 deletion FUTURE_IDEAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dev> <page> 00 01` captures into a one-slot
cache and `r SN <dev> <page> <off> [<len>]` 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
Expand Down
29 changes: 21 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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-<name>
│ ├─ _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)
Expand Down Expand Up @@ -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-<name>` (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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
5 changes: 5 additions & 0 deletions device/firefly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion device/si5341.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packaging/cm_interface.spec
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,27 @@ 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-<name> 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

%files
%doc README.md EXAMPLES.md MCU_REGISTER_MAP.md
%{python3_sitelib}/cm_interface/
%{_bindir}/cm-*
2 changes: 2 additions & 0 deletions registers/firefly12.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
Expand All @@ -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."},
Expand Down
4 changes: 2 additions & 2 deletions tests/hw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <SUPPLY> --snapshot`) and immediately run `snapshot <10*dev+page> 0`
`cm-lga80d-dump <SUPPLY> --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`)
Expand Down
12 changes: 6 additions & 6 deletions tests/test_clock_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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())

Expand Down
29 changes: 29 additions & 0 deletions tests/test_ff_status.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion tests/test_lga80d_dump.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down
2 changes: 1 addition & 1 deletion tests/test_mcu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading