Firmware Design for Satellite IoT: Almanac Scheduling, Duty Cycles, and Wasted Wakeups

Your CR123A is supposed to last 3 years. It’s dead in 6 months. The radio’s fine, the MCU’s deep-sleeping at 2 µA like the datasheet promised, and your transmit budget checks out on paper. The battery still vanished.
Here’s the part the spreadsheet hid: your device woke up roughly 288 times today to listen for satellites that weren’t there. Each blind listen burned 30 mA for 8 seconds. Do that math against a 1500 mAh cell and the answer matches what you’re seeing on the bench.
Satellite IoT firmware doesn’t fail because radios are wasteful. It fails because firmware schedules them like they’re talking to a cell tower that’s always overhead. This piece walks through almanac-driven scheduling, the state machine that goes with it, and the clock discipline that keeps the whole thing honest. We’ll use Nordic nRF91-class parts as a reference where concrete examples help, but the patterns apply to any LEO constellation: Swarm, Iridium, Kineis, Astrocast, Myriota, Skylo, and yes, Hubble.
Why Wasted Wakeups Dominate the Power Budget
Run the numbers and the picture gets ugly fast. A radio listen at 30 mA for 8 seconds is 67 µAh per attempt, and if you poll every 5 minutes you’re spending 19 mAh per day just listening, before any GNSS, before any actual transmits. That single number is usually 3 to 10x the transmit budget.
Wakeups split into 3 buckets:
- Productive: radio is on, satellite is overhead, packet goes up.
- Speculative: radio is on hoping a satellite shows up. Sometimes works.
- Wasted: radio woke, found nothing, went back to sleep. Pure cost.
A naive periodic scheduler maximizes the wasted bucket. An almanac-driven scheduler nearly eliminates it by refusing to wake the radio when no satellite is geometrically reachable, and most of the battery savings come from that refusal.
Naive periodic wake:
|__|‾‾|__|‾‾|__|‾‾|__|‾‾|__|‾‾|__|‾‾|__|‾‾| ← radio on every N min
^pass ^pass
hit hit (most wakes wasted)
Almanac-scheduled wake:
|________|‾‾|________________|‾‾|__________|
^pass ^pass
hit hit (wake only when needed)The Almanac as a Firmware Primitive
An almanac, in this context, is whatever compact data structure lets the device predict pass windows. Two flavors show up in production:
- Propagated orbital elements. TLE-style or constellation-specific parameters. The device runs an SGP4-ish propagator against its own position and clock to compute pass windows on the fly. Higher CPU cost, smaller storage, longer useful life per update.
- Precomputed pass tables. A gateway or cloud service does the propagation and pushes a list of
{start_time, duration, elevation, azimuth}tuples down. Lower CPU, larger storage, needs more frequent refresh.
Pass tables tend to win for ultra-low-power MCUs that don’t want to burn cycles on orbital math every wake. Propagators win when you have intermittent backhaul and need the device to stay autonomous for weeks.
To use either, the device needs 3 things: its own position (GNSS fix or static config for fixed assets), accurate UTC, and the almanac itself. If any one of those is missing, predictions are guesses.
Storage notes worth getting right early:
- Treat almanacs like firmware: signed, versioned, atomically swapped. A corrupted almanac that puts wake windows in the wrong place will burn the battery faster than no almanac at all.
- Keep at least 2 slots in flash so you can roll back if a fresh almanac fails sanity checks.
- Account for flash wear if you’re updating daily. A 10,000 erase-cycle sector dies in 27 years at 1 update/day, but in 3 months at 100/day. Pick your refresh cadence with that in mind.
The API and format vary by constellation, but the firmware role stays the same. Reference implementations for several constellations live at github.com/HubbleNetwork if you want to see how the pieces fit together.
Designing the Scheduler State Machine for Satellite Pass Scheduling
The state machine should stay small.
┌──────────────┐
│ IDLE_SLEEP │◄──────────────┐
└──────┬───────┘ │
│ RTC alarm │
▼ │
┌──────────────┐ │
│ PRE_PASS_WAKE│ │
└──────┬───────┘ │
▼ │
┌──────────────┐ no sat │
│ ACQUIRE ├───────────────►│
└──────┬───────┘ │
│ sat found │
▼ │
┌──────────────┐ │
│ TX/RX │ │
└──────┬───────┘ │
▼ │
┌──────────────┐ │
│ COOLDOWN ├────────────────┘
└──────────────┘The wake calculation is one line:
next_wake = next_pass_start - guard_band - radio_warmup;radio_warmup is a constant from your modem datasheet. guard_band is where engineering judgment lives. It needs to cover:
- RTC drift since last sync (
ppm × elapsed_time) - Almanac age uncertainty (propagator error grows with time since epoch)
- Position uncertainty (a 10 km position error shifts pass timing by a few seconds at LEO altitudes)
Add those, then add 20% margin. Don’t be a hero on guard band. The cost of a 5-second-too-early wake is small. The cost of a 2-second-too-late wake is a missed pass and a full retry cycle.
On nRF91-class parts, keep the modem in offline mode (AT+CFUN=4) through IDLE_SLEEP and PRE_PASS_WAKE. Only flip to full RF in ACQUIRE. The modem’s idle current in offline mode is a couple of orders of magnitude lower than registered-and-listening, and the activation cost is bounded.
One implementation gotcha worth calling out: don’t compute next_wake from a free-running counter that resets on reboot. Use a calendar RTC peripheral with battery backup, or persist the schedule to NVM before sleep. Watchdogs and brownouts will eat your schedule otherwise.
Three Clocks, One Schedule
Three clocks need to agree for any of this to work: the RTC on the device, the GNSS-derived time fix, and the almanac’s epoch reference. Drift in any one shows up as wider guard bands, which shows up directly in mAh.
Typical 32.768 kHz crystals drift 20 to 50 ppm over temperature. That sounds tiny until you compound it:
| RTC ppm | 1 day | 7 days | 30 days |
|---------|--------|---------|---------|
| 20 ppm | 1.7 s | 12.1 s | 51.8 s |
| 50 ppm | 4.3 s | 30.2 s | 129.6 s |
| 100 ppm | 8.6 s | 60.5 s | 259.2 s |A 50 ppm crystal a month from its last sync needs over 2 minutes of guard band on either side of the predicted pass. That guard band is radio-on time you’re paying for.
Resync strategy, in priority order:
- Pull time from satellite during a successful pass. Free, accurate, already on.
- Pull time from GNSS only when the satellite path has been silent long enough that drift is a real risk. GNSS first-fix is expensive (tens of seconds at >20 mA).
- Never trust the build-time clock past first deployment.
Almanac epoch matters too. Most propagators stay accurate for a few days, degrade past a week, and start lying past 2 weeks. Build a decision tree:
if (now - almanac_epoch) < 3 days: use nominal guard band
if (now - almanac_epoch) < 10 days: widen guard band 2x
if (now - almanac_epoch) > 10 days: enter RECOVERY modeRECOVERY mode listens opportunistically (back to speculative wakes) until it gets a fresh almanac, then snaps back to scheduled mode. Treat it as the failure path it is, not the default.
Satellite Duty Cycle Tuning and Battery Life
Duty cycle is just active_time_per_day / 86400. Plug in real numbers:
- 8 passes/day × 45 s active per pass = 360 s = 0.42% duty cycle
- Naive 5-minute polling at 8 s per wake = 2304 s = 2.7% duty cycle
That’s roughly a 6x reduction in average current draw, and the gap widens further if your guard bands are tight. For a device dominated by listen power, that 6-month bench result starts looking like the 3-year spec on the box.
The tuning knobs that actually matter for satellite IoT power management:
- Guard band width. Tighter saves power, costs reliability. Start loose, tighten in the field with telemetry feedback.
- Almanac refresh cadence. Less frequent saves backhaul and flash wear, costs prediction accuracy. Tie it to drift, not a fixed schedule.
- GNSS fix cadence. Each fix is 15 to 60 mAs of work. Don’t fix on every wake. Fix when position uncertainty actually matters (mobile assets) or when nothing else can resync the clock.
- Pass selection. You don’t need every pass. For most telemetry workloads, picking the 2 or 3 highest-elevation passes per day cuts radio-on time without hurting delivery latency much.
Expose these as DFU-configurable parameters, not #define constants. You will tune them after deployment. Pretending otherwise just means a firmware revision every time the field data surprises you.
Handling Wasted Wakeups Gracefully
Wasted wakeups will still happen. Almanacs go stale, GNSS positions drift on mobile assets, satellites get rescheduled. Plan for it:
- Instrument it. Keep a
wasted_wake_counterin NVM, reset on successful contact. Ship it as telemetry. It’s one of the highest-signal fields you can have for diagnosing fleet-wide drift problems. - Back off intelligently. If N consecutive predicted passes yield nothing (3 is a reasonable default), assume something is stale. Trigger a single GNSS fix, request an almanac refresh on next contact, widen guard bands until you get one.
- Cap recovery cost. Don’t let
RECOVERYmode itself drain the battery. After M failed recovery cycles, fall back to a very conservative low-frequency speculative listen. A device that lasts 6 months in degraded mode is worth more than one that lasts 2 weeks trying heroically to reconnect.
Next: Store-and-Forward Queues for Intermittent Links
Almanac-driven scheduling, disciplined clock management, and explicit stale-data handling turn satellite IoT firmware from a battery sinkhole into a deterministic system. The constellation supplies the orbital data. Whether that data saves power is on the firmware engineer, and the gap between doing that well and doing it poorly is usually 5x on battery life.
The next problem you’ll hit, once your device is sleeping correctly, is what to do with packets that pile up between passes. Store-and-forward queue design for intermittent satellite links is the topic of the next post.
Hubble Network provides the almanac data and pass predictions your firmware needs to schedule wakeups deterministically, so you spend power on transmitting—not searching. See how it works →