Wake-Process-Sleep Scheduling for Solar Nodes

This page solves one concrete problem: building the wake-process-sleep loop itself — the scheduler that decides when a solar-powered gateway wakes, how long it stays awake, and how it re-arms its own alarm before returning to suspend — for a Linux gateway running on a single small solar panel and a Li-ion or LiFePO4 pack with no grid backup. Within Edge Operations & Observability, and specifically as the scheduling mechanics underneath power and duty-cycling for field nodes, this is the loop that turns an energy budget into an actual rtcwake call at 3 a.m. The deployment target is a Cortex-A gateway (Raspberry Pi CM class through industrial SoM modules) running mainline Linux or a Yocto build, with the RTC and suspend-to-RAM support that implies — not a bare-metal MCU, where the equivalent loop is a timer interrupt and a WFI instruction rather than anything below.

Scheduling approach selection rationale

Three mechanisms compete for this job on Linux, and only one of them survives a power cycle correctly. A userspace sleep() loop is the naive option: cheap to write, but it runs on the system clock (CLOCK_REALTIME), which a node with no RTC battery backup loses on every brownout, and it holds the CPU (or at best idles it) rather than suspending the SoC — so it saves none of the sleep-current benefit the whole pattern exists for. A systemd timer unit is the mainstream option on any gateway already running a full init system: it is declarative, supervised, and survives a service crash without extra code, at the cost of systemd’s own resident footprint. The third option, and the one this page builds, is a direct rtcwake invocation driven by a small supervisor loop — the leanest mechanism, and the only one that works unmodified on a minimal Yocto image with no init system beyond BusyBox.

The deciding factor is which clock survives a power interruption. CLOCK_MONOTONIC is only valid within a single boot — it resets to zero on every reboot and is therefore useless for computing “wake at 06:00” across a suspend/resume cycle that itself depends on the RTC continuing to run. The RTC hardware, by contrast, keeps time across both suspend and a full power loss as long as it has its own coin-cell or supercap backup, which is why every wake decision below is expressed as an absolute RTC epoch, never as a monotonic offset computed once and trusted to still be valid later.

Userspace sleep versus RTC-armed suspend Two approaches are contrasted. The left card, userspace sleep loop, runs on CLOCK_REALTIME, keeps the CPU idling rather than suspended, and loses its schedule on any reboot. The right card, RTC-armed suspend, writes an absolute epoch to the wakealarm file, suspends to RAM, and survives both reboots and brownouts because the RTC itself keeps time independently. Userspace sleep loop • runs on CLOCK_REALTIME • CPU stays idle, not suspended • no sleep-current benefit • schedule lost on reboot • simplest to write Best when: bench testing only RTC-armed suspend • wakealarm holds absolute epoch • SoC suspends to RAM (18-25 mA) • survives reboot and brownout • needs RTC with backup power • pairs with systemd or bare loop Best when: field deployment
Only the RTC-armed path keeps its schedule across the exact interruptions a solar node experiences most.

The complete wake-process-sleep scheduler

The loop below runs as a long-lived supervisor process, restarted by the hardware watchdog if it ever dies, and never itself blocks on anything but rtcwake. It reads the battery state of charge and a coarse solar-irradiance proxy (panel open-circuit voltage, sampled through an ADC channel) to decide how aggressively to compress or stretch the cycle, gates peripherals in a fixed order before sleeping, and always re-arms the RTC before it does anything else risky:

#!/usr/bin/env python3
"""Wake-process-sleep supervisor for a solar-powered Linux gateway.
Runs as a systemd service (Type=simple) supervised by the hardware
watchdog; a crash here should trigger a watchdog reboot, not silence.
"""
import subprocess
import time
from pathlib import Path

WAKEALARM = Path("/sys/class/rtc/rtc0/wakealarm")
GNSS_ENABLE_GPIO = Path("/sys/class/gpio/gpio17/value")
LTE_ENABLE_GPIO = Path("/sys/class/gpio/gpio27/value")

NOMINAL_CYCLE_S = 300
MIN_CYCLE_S = 90
MAX_CYCLE_S = 3600
LOW_SOC_THRESHOLD = 0.30
LOW_IRRADIANCE_V = 4.2   # panel open-circuit volts below which harvest is negligible

def read_battery_soc() -> float:
    """Read state of charge from a fuel-gauge driver exposing a sysfs
    percentage file; substitute the actual gauge's path/scale here."""
    raw = Path("/sys/class/power_supply/battery/capacity").read_text().strip()
    return int(raw) / 100.0

def read_panel_voltage() -> float:
    """Read panel open-circuit voltage from an ADC channel used purely
    as a coarse day/night and cloud-cover proxy, not a power measurement."""
    raw = Path("/sys/bus/iio/devices/iio:device0/in_voltage0_raw").read_text().strip()
    return int(raw) * 0.0048  # ADC LSB scaled to volts for this board

def gate_peripherals(enable: bool):
    """Sequence peripheral rails through GPIO load switches. GNSS and LTE
    front-ends both leak milliamps if left 'soft off' instead of gated."""
    val = b"1" if enable else b"0"
    GNSS_ENABLE_GPIO.write_bytes(val)
    LTE_ENABLE_GPIO.write_bytes(val)

def compute_cycle_seconds(soc: float, panel_v: float) -> int:
    """Stretch the cycle when the battery is low or the panel shows no
    useful light; compress it when both are healthy."""
    cycle = NOMINAL_CYCLE_S
    if soc < LOW_SOC_THRESHOLD:
        cycle = int(cycle * 3.0)
    if panel_v < LOW_IRRADIANCE_V:
        cycle = int(cycle * 1.5)   # nighttime: no harvest, spend reserve carefully
    return max(MIN_CYCLE_S, min(MAX_CYCLE_S, cycle))

def arm_rtc(sleep_seconds: int):
    """Clear any stale alarm, then arm an absolute epoch. Clearing first
    avoids a race against a leftover alarm from a prior crashed cycle."""
    wake_epoch = int(time.time()) + sleep_seconds
    WAKEALARM.write_text("0")
    WAKEALARM.write_text(str(wake_epoch))

def run_cycle():
    soc = read_battery_soc()
    panel_v = read_panel_voltage()
    gate_peripherals(enable=True)
    try:
        process_pending_geometry_batch()   # defined by the calling pipeline
    finally:
        gate_peripherals(enable=False)
    cycle_s = compute_cycle_seconds(soc, panel_v)
    arm_rtc(cycle_s)
    subprocess.run(["rtcwake", "-m", "mem", "-s", str(cycle_s)], check=True)

if __name__ == "__main__":
    while True:
        run_cycle()

The ordering inside run_cycle is deliberate and load-bearing: peripherals gate on before processing starts and gate off in a finally block before the RTC is armed, so a crash mid-cycle cannot leave a rail powered through an entire sleep window. The RTC arm itself happens last, immediately before the blocking rtcwake call, so the window between “decide to sleep” and “actually suspend” is as short as possible — every second spent deciding is a second not accounted for by the sleep-current model.

Constraint validation table

Constraint Expected impact Mitigation built into the code
RTC backup power An RTC with no coin cell loses its epoch on a full power cycle, breaking every wake calculation above Treat RTC epoch as untrusted after a cold boot; resync from a GNSS or NTP fix at first opportunity before trusting arm_rtc
ADC/GPIO sysfs latency Reading fuel-gauge and ADC sysfs files takes low-single-digit milliseconds each, negligible against a 5-minute cycle Reads happen once per cycle, outside any hot loop; no polling
Suspend/resume reliability Some SoC and kernel combinations fail to re-enter suspend cleanly after certain peripheral states gate_peripherals(False) runs before rtcwake, so peripherals are never left in an unusual state across the suspend boundary
Watchdog interaction A supervisor stuck in process_pending_geometry_batch past the hardware watchdog window causes a mid-cycle reboot Bound process_pending_geometry_batch’s own worker timeout well under the watchdog interval, independent of this scheduler
Cycle bounds An unbounded adaptive cycle could stretch to days on a persistently low battery, effectively going dark MIN_CYCLE_S/MAX_CYCLE_S clamp keeps the node checking in at least once an hour even in the worst case

Gotchas and edge cases

The traps here are almost all about clocks and race conditions, not the scheduling logic itself.

  • Stale wakealarm race. Writing a new epoch to wakealarm without first writing 0 can leave the old alarm active if the kernel driver does not atomically replace it; the explicit clear-then-set sequence in arm_rtc is not decorative, it is the fix for a real race on several common RTC drivers.
  • RTC epoch drift. Cheap RTC crystals commonly drift several seconds per day. Over weeks that drift can walk a wake schedule far enough to miss a modem paging window; resynchronize the RTC from any trustworthy time source (a GNSS fix, an NTP response once the modem is briefly up) whenever one is available, rather than assuming the hardware clock is exact.
  • rtcwake mode support varies by SoC. Not every ARM board’s kernel supports -m mem (suspend-to-RAM); some only implement -m standby or -m freeze, which save far less power. Verify supported modes with cat /sys/power/state on the actual target board during bring-up rather than assuming parity with a reference design.
  • Peripheral gate ordering under a crash. If the process crashes between gating peripherals on and the finally block running, the rail can be left powered indefinitely. Pair the software ordering above with a hardware watchdog reboot as the backstop, since software alone cannot guarantee cleanup after every possible failure.
  • Panel voltage as a irradiance proxy is noisy. Open-circuit voltage saturates quickly once there is any daylight, so it distinguishes night from day well but does not distinguish light cloud from full sun; do not use it for anything more precise than the day/night branch above.

Integrating with the gateway pipeline

process_pending_geometry_batch() is the seam where this scheduler hands off to the rest of the pipeline: it should invoke whatever bounded, timeout-guarded work unit the async execution model already provides, exactly the pattern used in async execution for spatial workloads, rather than running geometry code inline in the scheduler process. Keeping the scheduler and the compute path as separate concerns means a hang in geometry processing surfaces as a worker timeout the scheduler can log and move past, not as a wedged supervisor that never reaches arm_rtc at all.

Deploy the script as a systemd service with Restart=always and WatchdogSec= set below the hardware watchdog’s own interval, so a supervisor crash is caught and restarted by systemd before the hardware timer has to intervene. On a minimal image with no systemd, an equivalent effect comes from wrapping the while True loop in a small shell trampoline that a BusyBox init respawns on exit.