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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ The Python `MCU` client for ProgCom device `MC 0` follows
minor 0;
- `read_persistent_log_info()` and `read_persistent_log_entries()` read the
frozen (offsets final, not yet firmware-served) persistent-log pages; and
- `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;
- `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
27 changes: 24 additions & 3 deletions MCU_REGISTER_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Read-only.
| --- | ---: | --- |
| `0x00` | 4 | ASCII magic `CMCU` |
| `0x04` | 1 | map major version (`1`) |
| `0x05` | 1 | map minor version (`3`) |
| `0x05` | 1 | map minor version (`4`) |
| `0x06` | 1 | hardware revision |
| `0x07` | 1 | ADC channel count (`21`) |
| `0x08` | 4 | capability bitmap |
Expand Down Expand Up @@ -62,6 +62,7 @@ Capability bits (bitmap at `0x08`):
| 7 | `CONFIG` | yes (page `0x05` readable; map minor ≥ 2) |
| 8 | `RUNTIME` | yes |
| 9 | `CONFIG_WRITE` | yes (page `0x05` writable; map minor ≥ 3) |
| 10 | `RTC_WRITE` | yes (page `0x7f` offset `0x04`; map minor ≥ 4) |

A capability bit is set only once its page is fully implemented; a client
must not assume a page works just because its offsets are frozen here.
Expand Down Expand Up @@ -311,7 +312,13 @@ count is published — scan all 64 and filter erased/empty sentinels.

## Page `0x7f` — Control — **implemented**

Write-only. One command byte at offset `0x00`.
Write-only. One command byte at offset `0x00`, and from map minor 4 the RTC
set word at offset `0x04`.

| Offset | Size | Field |
| --- | ---: | --- |
| `0x00` | 1 | command code, table below |
| `0x04` | 4 | RTC set: local wall-clock seconds since 1970-01-01 00:00:00 (needs `RTC_WRITE`, bit 10) |

| Code | Command |
| ---: | --- |
Expand Down Expand Up @@ -348,6 +355,20 @@ timeout): a full queue is real backpressure and should surface to the
remote client immediately as `MCU_REG_QUEUE_FULL`, not stall the ProgCom
UART link waiting for space.

**RTC set (`0x04`).** The RTC carries no time zone. The value is the setter's
local wall-clock time expressed as seconds since 1970-01-01 00:00:00 of that
clock, so it is *not* Unix time; `cm_interface` (`MCU.set_rtc`) computes it
with `calendar.timegm` on the host's local time. A DST change shifts the RTC
only when someone sets it again. Values below `1767225600` (2026-01-01
00:00:00) return `invalid MCU value`. The write is synchronous, with no queue,
and is what the Runtime page's RTC fields read back. On map minor 3 firmware
the offset returns `invalid MCU address`; check bit 10 first.

The RTC set is not serialised against the CLI `time set` command. Each loads
the calendar with two register writes (time, then date), so two concurrent
setters could leave one caller's time with the other's date. No lock is taken; do not set the clock from both
interfaces at once.

Each alarm-clear command targets exactly one alarm task's queue — there is
no combined "clear all alarms" command, so no command ever needs atomic
delivery across more than one queue.
Expand All @@ -361,7 +382,7 @@ delivery across more than one queue.
| address not inside a declared field | `invalid MCU address` |
| read/write crosses the end of a field, or length outside 1-4 | `invalid MCU read span` |
| write to a field with a length other than its width (pages `0x05`, `0x7f`) | `invalid MCU write span` |
| valid address and length, value outside the field's clamp (page `0x05`) | `invalid MCU value` |
| valid address and length, value outside the field's clamp (page `0x05`, page `0x7f` RTC set) | `invalid MCU value` |
| write to a read-only page | `MCU register is read only` |
| read from a write-only page | `MCU register is write only` |
| valid request, queue-owned op not yet accepted | `MCU queue full` |
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ rt = mcu.read_runtime() # page 0x06, map minor >= 1 firmware
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
mcu.set_rtc()

mcu.send_control(McuControlCommand.CLEAR_ALARM_LATCHES)
```

Expand Down
47 changes: 47 additions & 0 deletions device/mcu.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
``MCU_Reg.h`` — keep this module in sync with it, not the other way around.
"""

import calendar
import datetime
import math
import struct
from enum import IntEnum, IntFlag
Expand Down Expand Up @@ -127,6 +129,22 @@ class ConfigReg(IntEnum):
ALARM_VOLT_CPCT_MIN = 100 # 1 %
ALARM_VOLT_CPCT_MAX = 1000 # 10 %

# Lowest RTC set value the firmware accepts (``RTC_SET_MIN_SECONDS``):
# 2026-01-01 00:00:00 local wall-clock time, in the units of rtc_local_seconds().
RTC_SET_MIN_SECONDS = 1767225600


def rtc_local_seconds(when: datetime.datetime) -> int:
"""Wire value for the RTC set field: seconds since 1970-01-01 00:00:00 of
``when``'s wall clock. Not Unix time: the RTC carries no time zone.

A naive datetime is taken as wall-clock time as given; an aware one is
first converted to the host's local zone. Sub-second precision is dropped.
"""
if when.tzinfo is not None:
when = when.astimezone().replace(tzinfo=None)
return calendar.timegm(when.timetuple())


class AlarmTempDevice(IntEnum):
"""Matches the firmware's ``enum device`` (``Tasks.h``): FF, DCDC, TM4C, FPGA."""
Expand Down Expand Up @@ -159,6 +177,7 @@ class RuntimeReg(IntEnum):

class ControlReg(IntEnum):
COMMAND = 0x00 # uint8, write-only
RTC_SET_LOCAL = 0x04 # uint32, write-only, see rtc_local_seconds()


class McuControlCommand(IntEnum):
Expand All @@ -180,6 +199,7 @@ class McuCapability(IntFlag):
CONFIG = 1 << 7
RUNTIME = 1 << 8
CONFIG_WRITE = 1 << 9
RTC_WRITE = 1 << 10


class McuBuildType(IntEnum):
Expand Down Expand Up @@ -835,3 +855,30 @@ def send_control(self, command: McuControlCommand,
page = McuPage.CONTROL
self._require_page(page, check_capability)
self.write_reg(self._reg(page, ControlReg.COMMAND), bytes([int(command)]))

def set_rtc(self, when: Optional[datetime.datetime] = None,
check_capability: bool = True) -> int:
"""Set the MCU RTC to a local wall-clock time; returns the value sent.

``when`` defaults to the host's local time now; see
:func:`rtc_local_seconds` for naive vs. aware datetimes. The RTC has
no time zone, so a DST change shifts it only when it is set again.
Values before 2026-01-01 raise ``ValueError`` and send nothing.

Not serialised against the CLI ``time set``: two concurrent setters
can leave one's time with the other's date.
"""
if when is None:
when = datetime.datetime.now()
seconds = rtc_local_seconds(when)
if not RTC_SET_MIN_SECONDS <= seconds <= 0xFFFFFFFF:
raise ValueError(
"RTC time {} outside 2026-01-01..2106-02-07".format(when.isoformat()))
page = McuPage.CONTROL
self._require_page(page, check_capability)
if check_capability and not self.capabilities() & McuCapability.RTC_WRITE:
raise McuCapabilityUnavailable(
"firmware does not advertise {}".format(McuCapability.RTC_WRITE.name)
)
self.write_reg(self._reg(page, ControlReg.RTC_SET_LOCAL), struct.pack("<I", seconds))
return seconds
55 changes: 54 additions & 1 deletion tests/test_mcu.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import contextlib
import datetime
import io
import math
import struct
Expand All @@ -7,6 +8,7 @@
from cm_interface.device.mcu import (
ADC_CHANNEL_COUNT,
MCU,
RTC_SET_MIN_SECONDS,
AdcReg,
AlarmReg,
AlarmTaskState,
Expand All @@ -28,6 +30,7 @@
SystemReg,
TemperatureAlarmBit,
describe_reset_cause,
rtc_local_seconds,
)
from cm_interface.errors import (
CMError,
Expand All @@ -50,7 +53,7 @@ def __init__(self):
| McuCapability.ALARMS | McuCapability.ADC
| McuCapability.PERSISTENT_LOG | McuCapability.CONTROLS
| McuCapability.RUNTIME | McuCapability.CONFIG
| McuCapability.CONFIG_WRITE)
| McuCapability.CONFIG_WRITE | McuCapability.RTC_WRITE)
self.put(McuPage.SYSTEM, SystemReg.CAPABILITIES,
int(all_caps).to_bytes(4, "little"))

Expand Down Expand Up @@ -483,6 +486,56 @@ def test_centipercent_roundtrip_all_wire_percents(self):
self.assertEqual(int(round((cpct / 100.0) * 100)), cpct)


class McuRtcWriteTest(unittest.TestCase):
def _rtc_write(self, seconds):
return (int(McuPage.CONTROL), int(ControlReg.RTC_SET_LOCAL),
seconds.to_bytes(4, "little"))

def test_set_rtc_writes_local_seconds(self):
# expected values hand-computed from day counts, not via calendar.timegm
cases = (
(datetime.datetime(2026, 1, 1, 0, 0, 0), 1767225600),
(datetime.datetime(2028, 2, 29, 0, 0, 0), 1835395200),
(datetime.datetime(2026, 12, 31, 23, 59, 59), 1798761599),
)
for when, expected in cases:
mcu = MemoryMCU()
self.assertEqual(mcu.set_rtc(when), expected)
self.assertEqual(mcu.writes, [self._rtc_write(expected)])

def test_set_rtc_drops_microseconds(self):
mcu = MemoryMCU()
mcu.set_rtc(datetime.datetime(2026, 1, 1, 0, 0, 0, 999999))
self.assertEqual(mcu.writes, [self._rtc_write(1767225600)])

def test_set_rtc_bound_matches_constant(self):
self.assertEqual(
rtc_local_seconds(datetime.datetime(2026, 1, 1)), RTC_SET_MIN_SECONDS)

def test_set_rtc_rejects_before_2026(self):
for bad in (datetime.datetime(2025, 12, 31, 23, 59, 59),
datetime.datetime(1970, 1, 1),
datetime.datetime(2106, 2, 8)):
mcu = MemoryMCU()
with self.assertRaises(ValueError):
mcu.set_rtc(bad)
self.assertEqual(mcu.writes, [])

def test_aware_datetime_converts_to_host_local(self):
aware = datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
local = datetime.datetime.fromtimestamp(aware.timestamp())
self.assertEqual(rtc_local_seconds(aware), rtc_local_seconds(local))

def test_set_rtc_gated_by_separate_capability(self):
mcu = MemoryMCU()
_put_u32(mcu, McuPage.SYSTEM, SystemReg.CAPABILITIES,
McuCapability.SYSTEM | McuCapability.CONTROLS)
mcu.send_control(McuControlCommand.CLEAR_POWER_FAULT) # commands still work
with self.assertRaises(McuCapabilityUnavailable):
mcu.set_rtc(datetime.datetime(2026, 6, 1))
self.assertEqual(len(mcu.writes), 1)


class McuCapabilityGatingTest(unittest.TestCase):
def _system_only(self):
mcu = MemoryMCU()
Expand Down
6 changes: 6 additions & 0 deletions tests/test_wire_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ def test_config_write_clamps(defines):
assert _resolve("CFG_VOLT_MAX_CPCT", defines) == mcu.ALARM_VOLT_CPCT_MAX


def test_rtc_set_lower_bound(defines):
assert _resolve("RTC_SET_MIN_SECONDS", defines) == mcu.RTC_SET_MIN_SECONDS


def test_runtime_page(defines):
_check(defines, [
("RT_OFF_HEAP_FREE", mcu.RuntimeReg.HEAP_FREE),
Expand Down Expand Up @@ -250,6 +254,7 @@ def test_adc_and_control_pages(defines):
_check(defines, [
("ADC_PAGE_VALUES_OFF", mcu.AdcReg.VALUES),
("CTRL_OFF_COMMAND", mcu.ControlReg.COMMAND),
("CTRL_OFF_RTC_SET_LOCAL", mcu.ControlReg.RTC_SET_LOCAL),
("CTRL_CMD_ASSERT_PROGCOM_POWER_INHIBIT",
mcu.McuControlCommand.ASSERT_PROGCOM_POWER_INHIBIT),
("CTRL_CMD_RELEASE_PROGCOM_POWER_INHIBIT",
Expand All @@ -274,6 +279,7 @@ def test_capability_and_health_bits(defines):
("MCU_CAP_CONFIG", mcu.McuCapability.CONFIG),
("MCU_CAP_RUNTIME", mcu.McuCapability.RUNTIME),
("MCU_CAP_CONFIG_WRITE", mcu.McuCapability.CONFIG_WRITE),
("MCU_CAP_RTC_WRITE", mcu.McuCapability.RTC_WRITE),
("MCU_HEALTH_POWER_FAULT", mcu.McuHealth.POWER_FAULT),
("MCU_HEALTH_TEMPERATURE_ALARM", mcu.McuHealth.TEMPERATURE_ALARM),
("MCU_HEALTH_VOLTAGE_ALARM", mcu.McuHealth.VOLTAGE_ALARM),
Expand Down
Loading