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.
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
wakealarmwithout first writing0can leave the old alarm active if the kernel driver does not atomically replace it; the explicit clear-then-set sequence inarm_rtcis 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.
rtcwakemode support varies by SoC. Not every ARM board’s kernel supports-m mem(suspend-to-RAM); some only implement-m standbyor-m freeze, which save far less power. Verify supported modes withcat /sys/power/stateon 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
finallyblock 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.
Related
- Power & Duty-Cycling for Field Nodes — the energy budget and constraint mapping this scheduling loop is built to satisfy.
- Power Budgeting for GPS Duty-Cycled Nodes — sizing the battery and solar capacity this loop draws against.
- Async Execution for Spatial Workloads — the bounded worker model this scheduler hands processing off to each cycle.