Software Linux OS Power Management - thepinkmile/Enigma-NG GitHub Wiki
Status: Draft Project: Enigma-NG Author: Izzyonstage & GitHub Copilot Version: v1.0.0 Last Updated: 2026-04-20
The CM5 (Raspberry Pi Compute Module 5) graceful shutdown is hardware-initiated via the CM5
PMIC power-button input (PWR_BUT). No firmware polling is required for the primary shutdown path.
Primary shutdown path (hardware-automatic):
- Primary power fails → 5V_MAIN falls to 4.812V → LTC3350
/INTBasserts LOW. - MIC1555 U15 (monostable one-shot) triggers →
PWR_BUTheld LOW for 3.01 seconds. - CM5 PMIC sends power-key event → Linux
systemd-logindHandlePowerKey=poweroff→ graceful OS shutdown, identical tosudo shutdown -h now. - LTC3350 simultaneously restores 5V_MAIN to 5V; PWR_GD stays HIGH throughout shutdown.
- Hold-up window: ≥33.5 seconds from backup activation — OS typically shuts down in 10–15 s.
Secondary telemetry signals (software-visible, not shutdown triggers):
- PWR_GD (GPIO 7): Active-HIGH rail-health signal from MCP121T-450E (4.50V threshold). Stays HIGH throughout the hold-up window (LTC3350 keeps 5V_MAIN above 4.50V). Deasserts only if supercaps are depleted — by which time OS should already be halted.
-
PM-local status expander (
PCA9534A @ 0x3F): ProvidesPOE_STAT,USB_STAT,BATT_PRES_N,SYS_FAULT, and the runtime SW1 RGB handoff outputs. - LTC3350 I²C telemetry (0x09): Readable via I²C for backup-state detection, supercap charge / health monitoring, LED fault-state control, and post-mortem logging (see DEC-025).
Implementation note: The custom LTC3350 interrupt driver (DEC-025) remains useful for telemetry and LED state control but is not required for shutdown safety. The hardware
PWR_BUTone-shot circuit provides a guaranteed shutdown trigger independent of OS state.
| Signal | Connection | Pull-up | Source | Role |
|---|---|---|---|---|
| PWR_BUT | CM5 PMIC pin (via PM dock J3) |
CM5 module internal 10kΩ | MIC1555 U15 one-shot / SW2 tactile | Primary shutdown trigger — 3 s LOW pulse from U15 on backup-mode entry; or manual press of SW2 |
| PWR_GD | GPIO 7 (BCM) | R3 10kΩ to 3V3_ENIG (Controller board) | MCP121T-450E U8 | Rail-health telemetry only — HIGH while 5V_MAIN ≥ 4.50V; stays HIGH throughout hold-up; deasserts only on supercap depletion |
| PM_IO_INT_N | GPIO 5 (BCM) | Open-drain on PM; controller-side pull-up as required | PCA9534A U16 | Optional interrupt line for PM status / SW1 LED expander updates |
| LTC3350 /INTB | GPIO (TBD — assign at schematic capture) | R29 10kΩ to 3V3_ENIG (Power Module) | LTC3350 U3 | Backup-mode indicator — active-LOW when LTC3350 in backup mode (5V_MAIN < 4.812V, R14=30.1kΩ; see DR-PM-08, DEC-030); also triggers MIC1555 U15 one-shot directly in hardware |
The primary shutdown path requires only a single systemd-logind configuration line. When the CM5
PMIC receives the 3-second PWR_BUT pulse, it generates a power-key event that systemd-logind
handles natively:
# /etc/systemd/logind.conf
[Login]
HandlePowerKey=poweroffThis is sufficient for production use. No polling, no daemon, no I²C read required for the shutdown path itself.
Deferred to Software PoC Stage. The custom Linux driver is useful for telemetry, LED state control, and post-mortem logging, but is not required for shutdown safety. Development is deferred until hardware is available. See DEC-025.
Intended behaviour (reference only):
The driver will:
- Register an interrupt handler on the LTC3350
/INTBpin. - On interrupt, read LTC3350 STATUS register (I2C 0x09, register 0x01) to confirm BACKUP bit (bit 3).
- Read LTC3350 charge / monitor status to determine whether the supercap bank is healthy and charged enough to provide the guaranteed hold-up window.
- Set SW1 LED to solid red whenever the LTC3350 reports a PM fault or the supercap bank is not hold-up ready, even if a normal input source is still present.
- Set SW1 LED to 2 Hz orange flash during valid backup-mode operation when the bank remains healthy.
- Log the event to the kernel ring buffer for post-mortem analysis.
- Optionally read VCAP / charge telemetry for remaining supercap SOC estimation.
I2C parameters (for driver development reference):
| Parameter | Value |
|---|---|
| I2C address | 0x09 |
| STATUS register | 0x01 |
| BACKUP bit | bit 3 (value 0x08) |
| /INTB pin | Active-low open-drain; R29 10kΩ pull-up on Power Module |
Not applicable: PWR_GD (GPIO 7) is rail-health telemetry only (HIGH while 5V_MAIN ≥ 4.50 V). It must NOT be configured as a shutdown trigger. The active hardware shutdown backstop is the LTC3350 /INTB → MIC1555 U15 → Q5 BSS138 → PWR_BUT one-shot circuit (3.01 s LOW pulse), which requires no software driver.
| Event | Time from power loss | Action |
|---|---|---|
| Mains fails / PoE drops | t = 0 | Input source lost |
| 5V_MAIN falls to 4.812V — LTC3350 BACKUP asserted | ~10 ms |
/INTB goes LOW; MIC1555 U15 one-shot triggers; LTC3350 begins restoring 5V_MAIN |
PWR_BUT held LOW (3.01 s pulse begins) |
~10 ms | CM5 PMIC receives power-key event; systemd-logind HandlePowerKey=poweroff initiated |
| LTC3350 hold-up fully engaged | ~20 ms | 5V_MAIN restored to 5V; PWR_GD stays HIGH; ≥33.5 s window active |
PWR_BUT pulse ends |
~3.02 s | MIC1555 output returns HIGH; Q5 off; PWR_BUT returns HIGH via CM5 pull-up |
| OS syncs filesystems, halts | ~10–15 s | ROTOR_EN de-asserted; CM5 PMIC halted |
| Supercaps depleted / system off | ≥33.5 s from power loss | 5V_MAIN → 0V; MCP121T deasserts PWR_GD |
- Python package:
smbus2(pip3 install smbus2) - Python package:
systemd(pip3 install systemd-python) for sd_notify - I²C enabled on CM5 (
dtparam=i2c_arm=onin config.txt) -
enigma-power-monitor.serviceinstalled and enabled (systemctl enable enigma-power-monitor)
The CM5 controls the SW1 RGB LED through the PM-local PCA9534A @ 0x3F once firmware initialises. The
hardware handoff sequence and colour states are defined below.
-
Power on (CM5 not yet booted):
PCA9534Apowers up with all pins as inputs, so the PM hardware path dominates. MIC1555 (U11) drives Q4 → BAT54 diodes → Red + Green only → 1Hz orange flash on SW1. -
CM5 kernel boots, systemd target reached: Power monitor service starts.
Before asserting
SW_LED_CTRL, program the PM expander outputs soSW_LED_R=1,SW_LED_G=1,SW_LED_B=0(solid orange). -
CM5 writes
SW_LED_CTRL=1viaPCA9534A: Hardware Q4 gate disabled → MIC1555 path cut. Firmware has exclusive control of the runtime RGB sink stages. -
Power source detection / PM health: Read
POE_STAT,USB_STAT,BATT_PRES_N, andSYS_FAULTfrom the PM expander, plus LTC3350 backup / charge telemetry, and set LED colour per table below.
| State | SW_LED_R | SW_LED_G | SW_LED_B | Colour | Control |
|---|---|---|---|---|---|
| Booting (pre-CM5) | 1Hz PWM | 1Hz PWM | Off | 🟠 Orange flash | Hardware (MIC1555 + Q4 + D6/D7) |
| CM5 ready, USB-C active | Off | On | Off | 🟢 Solid green | PM expander + RGB sink stages |
| CM5 ready, PoE active | Off | Off | On | 🔵 Solid blue | PM expander + RGB sink stages |
| CM5 ready, Battery active | On | On | Off | 🟠 Solid orange | PM expander + RGB sink stages |
| Supercap hold-up (mains fail, bank healthy) | PWM 2Hz | PWM 2Hz | Off | 🟠 Fast orange flash | PM expander + RGB sink stages |
| PM fault / hold-up unavailable | On | Off | Off | 🔴 Solid red | PM expander + RGB sink stages |
Add to the power monitor daemon startup sequence:
from smbus2 import SMBus
PM_IO_ADDR = 0x3F
REG_INPUT = 0x00
REG_OUTPUT = 0x01
REG_CONFIG = 0x03
BIT_POE_STAT = 0
BIT_SYS_FAULT = 1
BIT_BATT_PRES_N = 2
BIT_USB_STAT = 3
BIT_SW_LED_R = 4
BIT_SW_LED_G = 5
BIT_SW_LED_B = 6
BIT_SW_LED_CTRL = 7
def set_led(bus: SMBus, r: int, g: int, b: int) -> None:
value = (r << BIT_SW_LED_R) | (g << BIT_SW_LED_G) | (b << BIT_SW_LED_B) | (1 << BIT_SW_LED_CTRL)
bus.write_byte_data(PM_IO_ADDR, REG_OUTPUT, value)
with SMBus(1) as bus:
# P0..P3 inputs, P4..P7 outputs
bus.write_byte_data(PM_IO_ADDR, REG_CONFIG, 0x0F)
# Pre-set orange, then take control from the hardware path
set_led(bus, 1, 1, 0)
status = bus.read_byte_data(PM_IO_ADDR, REG_INPUT)
poe_active = not bool(status & (1 << BIT_POE_STAT))
usb_active = not bool(status & (1 << BIT_USB_STAT))
batt_active = not bool(status & (1 << BIT_BATT_PRES_N))
fault_active = not bool(status & (1 << BIT_SYS_FAULT))
if fault_active:
set_led(bus, 1, 0, 0)
elif usb_active:
set_led(bus, 0, 1, 0)
elif poe_active:
set_led(bus, 0, 0, 1)
elif batt_active:
set_led(bus, 1, 1, 0)
else:
set_led(bus, 1, 0, 0)The Stator board carries an INA219 (U2, I2C address 0x45) monitoring the 3V3_ENIG current to the rotor stack via a 10mΩ CSS2H-2512R-R010ELF shunt resistor (R1 on Stator, 2512 Kelvin-sense; PM R23 is the second system CSS2H instance — total build qty: 3).
| Parameter | Value | Notes |
|---|---|---|
| I2C address | 0x45 | Set by A0/A1 pin strapping on Stator INA219 |
| Shunt resistance | 0.010 Ω (10mΩ) | CSS2H-2512R-R010ELF; hardcoded in firmware — do not change without updating Stator BOM |
| PGA range | ±80mV | Covers 0–8A range (3A LDO max → 30mV drop) |
| ADC resolution | 12-bit | |
| Current LSB | 4mA | = 80mV full-scale / 2^11 steps / 0.010Ω |
| Calibration register | 0x0400 (1024 decimal) | CAL = 0.04096 / (Current_LSB × R_SHUNT) = 0.04096 / (0.004 × 0.010) |
⚠️ The INA219 calibration register must be written on every power-up before any current readings are taken. Write smbus2 value 0x0004 (byte-swapped from logical 0x0400 = 1024; smbus2 transmits LSB first so INA219 receives 0x0400 = 1024 as intended). If this is skipped, theCurrent_Registerwill read zero regardless of actual current.
INA219_ADDR = 0x45 # Rotor stack monitor on Stator board
REG_CONFIG = 0x00
REG_CAL = 0x05
REG_SHUNT_V = 0x01
REG_CURRENT = 0x04
R_SHUNT = 0.010 # 10mΩ CSS2H-2512R-R010ELF — hardcoded; do not change without updating Stator BOM
CURRENT_LSB = 0.004 # 4mA per LSB
# INA219 config: 32V bus range, PGA /2 (±80mV shunt), 12-bit, continuous
# Register value 0x299F byte-swapped for smbus2 little-endian transmission
CONFIG_VALUE = 0x9F29
bus.write_word_data(INA219_ADDR, REG_CONFIG, CONFIG_VALUE)
bus.write_word_data(INA219_ADDR, REG_CAL, 0x0004) # CAL = 1024 (big-endian swap)
def read_rotor_current_mA():
raw = bus.read_word_data(INA219_ADDR, REG_CURRENT)
raw = ((raw & 0xFF) << 8) | ((raw >> 8) & 0xFF) # swap bytes (smbus2 LE → INA219 BE)
if raw > 32767: raw -= 65536 # signed 16-bit
return raw * CURRENT_LSB * 1000 # convert to mACross-ref: See
Stator/Design_Spec.md §5. Power Telemetryfor shunt resistor spec anddesign/Electronics/Power_Budgets.mdfor expected current range (2.05A worst-case typical).
The INA219 Power Module Monitor (for 5V_MAIN) is at I2C address 0x40 on the Power Module board (separate device, different rail).
Monitors the 5V_MAIN power rail on the Power Module board. See Power_Module/Design_Spec.md §3 Telemetry for hardware details.
| Parameter | Value | Notes |
|---|---|---|
| I²C address | 0x40 | A0/A1 = GND on U12 |
| Shunt resistance | 0.010 Ω (10mΩ) | CSS2H-2512R-R010ELF R23, Power Module |
| PGA range | ±160mV | Covers 0–16A; 9A worst-case → 90mV drop |
| ADC resolution | 12-bit | |
| Current LSB | 8mA | = 160mV / 2048 / 0.010Ω |
| Calibration register | 0x0200 (512) | CAL = 0.04096 / (0.008 × 0.010) |
The CONFIG and calibration registers must be written on every power-up before current readings are valid.
import smbus2
BUS = smbus2.SMBus(1)
INA219_PM_ADDR = 0x40
# CONFIG register (0x00): 32V bus, PGA/4 (±160mV), 12-bit, continuous (big-endian swap)
BUS.write_word_data(INA219_PM_ADDR, 0x00, 0x9F31) # logical 0x319F byte-swapped for smbus2
# Calibration register (0x05): set before reading current
BUS.write_word_data(INA219_PM_ADDR, 0x05, 0x0002) # CAL = 512 (big-endian swap)
def read_5v_main_current_mA():
"""Read 5V_MAIN rail current in milliamps (INA219 U12, Power Module)."""
raw = BUS.read_word_data(INA219_PM_ADDR, 0x04)
raw = ((raw & 0xFF) << 8) | ((raw >> 8) & 0xFF) # swap bytes
if raw > 32767:
raw -= 65536
return raw * 8 # Current LSB = 8mACross-ref: See
Stator/Design_Spec.md §5. Power Telemetryfor the Rotor-stack INA219 (0x45) hardware spec.
The CM5 MXL7704 PMIC includes a battery charging circuit for the RTC backup battery. When a
non-rechargeable CR2032 is fitted (as specified in Controller/Design_Spec.md §5), the
charging circuit must be disabled in software as a belt-and-suspenders measure alongside the
hardware Schottky diode (D1) that physically blocks the charge path at CM5 VBAT (Pin 76).
Ensure the following line is absent from /boot/firmware/config.txt (do NOT include it):
# DO NOT add this line — it enables PMIC battery charging at 3.0V and will degrade a CR2032:
# dtparam=rtc_bbat_vchg=3000000Ensure the rtc_bbat_vchg parameter is absent from /boot/firmware/config.txt — the CM5 defaults to no charging without it.
Note: The following describes a non-standard alternative configuration only. For the Rev A production design (CR2032 + D1 Schottky), the
rtc_bbat_vchgparameter must remain absent fromconfig.txt. Do not apply the ML2032 configuration to Rev A hardware.Note: The hardware Schottky diode (D1, Nexperia BAT54) already physically prevents the PMIC from charging the CR2032 regardless of this software setting. The config.txt setting is a secondary safeguard. If the battery is ever changed to a rechargeable ML2032, remove D1 from the PCB AND set
dtparam=rtc_bbat_vchg=3000000to enable correct charging.
The CM5 will use systemd-timesyncd (NTP) to synchronise the RTC on boot when network
is available. On first boot, or after battery replacement, the RTC may show an incorrect time
until network sync completes. This is expected behaviour.
To force an immediate RTC sync from the system clock (after NTP has synced):
sudo hwclock --systohcTo read the current RTC time:
sudo hwclock --show- Test hold-up timing under actual CM5 load profile (5W assumed; measure at first prototype)
The servo motor (Miuzei Metal Gearbox 90) is driven by a PCA9685 I²C PWM driver (U_EXP3) at address 0x60 on the I²C-1 bus. The servo requires a 50Hz PWM signal with pulse widths between approximately 1ms (0°) and 2ms (180°).
The PCA9685 is configured via a Device Tree overlay on I²C-1:
// /boot/overlays/enigma-pca9685.dts
/dts-v1/;
/plugin/;
&i2c1 {
#address-cells = <1>;
#size-cells = <0>;
pca9685: pwm@60 {
compatible = "nxp,pca9685-pwm";
reg = <0x60>;
#pwm-cells = <2>;
clock-frequency = <25000000>; /* 25 MHz internal oscillator */
};
};
Load via /boot/firmware/config.txt:
dtoverlay=enigma-pca9685On startup, the enigmad daemon performs the following hardware init sequence before accepting
any cipher commands:
-
PCA9685 all-call disable: Write MODE1 register (0x00) with bit 0 (ALLCAL) = 0 to disable the all-call I²C address (0x70). This prevents unintended broadcast writes from affecting the PCA9685 when addressing other devices.
# Pseudocode i2c.write_byte_data(0x60, 0x00, 0x20) # MODE1: SLEEP=1, ALLCAL=0 time.sleep(0.001) i2c.write_byte_data(0x60, 0x00, 0x00) # MODE1: wake, 50Hz ready pca9685_set_pwm_freq(50) # Set 50Hz for servo
-
Servo homing sequence:
- Assert SERVO_EN (U_EXP2 GPB[0] HIGH via I²C to 0x21).
- Command servo to 0° (pulse width ≈ 1ms at 50Hz).
- Poll SERVO_HOME (U_EXP2 GPB[1]) — wait for LOW within 3-second timeout.
- If timeout expires, log error and halt init (servo not homed — mechanical fault).
- On SERVO_HOME LOW confirmed: servo is at 0° reference position.
-
MCP23017 port direction init:
- U_EXP1 (0x20): GPA = 0xFF (all inputs), GPB = 0xFF (all inputs).
- U_EXP2 (0x21): GPA = 0x00 (all outputs), GPB[0] = output, GPB[1] = input, GPB[2:7] = output.
To inject a virtual keypress for character N (5-bit address):
- Assert SOURCE_SEL=1 (U_EXP2 GPA[6] HIGH) — switches CPLD to CM5 virtual input mode.
- Write KEY_ADDR[4:0] = N to U_EXP2 GPA[4:0].
- Assert KEY_EN (U_EXP2 GPA[5] HIGH) — CPLD samples the key address.
- Deassert KEY_EN (LOW).
- Assert SERVO_EN (U_EXP2 GPB[0] HIGH) — enables PCA9685 Ch0 output.
- Command servo 0°→180° (one sweep half).
- Wait for mechanical actuation period (≈ 300ms).
- Command servo 180°→0° (return sweep).
- Wait for return (≈ 300ms).
- Deassert SERVO_EN (GPB[0] LOW).
- Deassert SOURCE_SEL (GPA[6] LOW) — returns CPLD to keyboard input mode.