Skip to content

10 ‐ GPS Guide

gfo974 edited this page May 29, 2026 · 2 revisions

10 — GPS Guide

Complete reference for the u-blox M10Q GNSS subsystem: aggressive configuration for short surfacings, AssistNow, deep-idle mode (GNP52), RTC strategy as it relates to GPS, and field-debug guidance.

Applicable to all variants: Argos SMD, KIM, LoRa, RSPB — the M10Q is shared hardware. Source: ports/nrf52840/core/hardware/m10qasync/m10qasync.cpp/hpp, core/services/gps_service.cpp/hpp.


Table of contents


Part A — Why aggressive GPS for turtles

Surfacing window TTFF M10Q (no aid) TTFF warm/hot (AssistNow + BBR)
30 s – 2 min (green turtle) 26 – 35 s 1 – 5 s
5 – 10 min (loggerhead) same same

Objective: fix < 10 s on every surfacing.

The whole pre-warm + warm-packet + deep-idle effort exists so that warm/hot start is the common case. Cold start is the exception (first deployment, BBR drained, ANO expired).


Part B — Aggressive DTE configuration

Recommended baseline for sea-turtle deployments (works on all comm stacks):

# === Enable GPS ===
PARMW GNP01 1           # GNSS_EN = true

# === Trigger GPS at surface ===
PARMW GNP25 1           # GNSS_TRIGGER_ON_SURFACED = true
PARMW GNP28 0           # GNSS_TRIGGER_COLD_START_ON_SURFACED = false  (keep ephemerides!)

# === AXL pre-trigger (LoRa: recommended; SMD/KIM: optional — Doppler compensates) ===
PARMW GNP26 1           # GNSS_TRIGGER_ON_AXL_WAKEUP = true

# === Timeouts ===
PARMW GNP05 60          # GNSS_ACQ_TIMEOUT = 60 s (warm)
PARMW GNP09 120         # GNSS_COLD_ACQ_TIMEOUT = 120 s (cold, first deploy only)

# === AssistNow (critical) ===
PARMW GNP24 1           # GNSS_ASSISTNOW_EN = true (Autonomous, ~3 days)
PARMW GNP27 1           # GNSS_ASSISTNOW_OFFLINE_EN = true (~14 days)

# === Nominal acquisition period (after first fix) ===
PARMW ARP11 1           # DLOC_ARG_NOM = index 1 → 600 s (10 min)
# For LoRa aggressive: index 11 → 60 s (1 min)

# === Filtering relaxed for fast fix ===
PARMW GNP02 1           # GNSS_HDOPFILT_EN = true
PARMW GNP03 4           # GNSS_HDOPFILT_THR = 4 (was 2 — too strict for short surface)
PARMW GNP20 1           # GNSS_HACCFILT_EN = true
PARMW GNP21 25          # GNSS_HACCFILT_THR = 25 m (was 5 m)

# === Fix mode + dynamic model ===
PARMW GNP10 3           # GNSS_FIX_MODE = AUTO (2D/3D)
PARMW GNP11 0           # GNSS_DYN_MODEL = PORTABLE (best for turtles ~2-5 km/h)

# === Constellations — all four major + augmentation ===
PARMW GNP40 0x1F        # GPS + Galileo + GLONASS + BeiDou + QZSS
# Default 0x0F (4 majors). 0x1F adds QZSS for Asia-Pacific deployments.

# === Signal thresholds relaxed ===
PARMW GNP42 6           # GNSS_MIN_CNO = 6 dBHz (default 10)
PARMW GNP43 5           # GNSS_MIN_ELEV = 5° (default 10°)

See 09 — Parameters for the complete GNP reference, including ANO staleness (GNP44), FastLoc mode (GNP45/46), CloudLocate format, and the deprecated GNSS_BCKP_CHARGE_* slots replaced by GNP52.


Part C — AssistNow

Time-to-first-fix is dominated by ephemeris availability. AssistNow provides predicted/preloaded orbital data.

Without AssistNow (cold start):
  M10Q downloads ephemerides from satellites (must decode 4 subframes)
  → 26-35 s minimum

With AssistNow Autonomous (GNP24):
  M10Q predicts future ephemerides from last fix
  → Valid ~3 days. TTFF: 1-5 s (hot/warm start)

With AssistNow Offline (GNP27):
  Ephemerides pre-calculated, stored in flash (via BLE/USB)
  → Valid ~14 days. TTFF: 1-5 s even after long no-fix

Prerequisite: RTC must have an approximately correct time. The firmware restores LAST_KNOWN_RTC from flash at boot, which is sufficient for ANO validation. See Part E.

ANO staleness gate (GNP44 GNSS_ANO_STALE_DAYS, default 5 days, 0 = never discard): ANO data older than this threshold is rejected and the receiver falls back to cold-start timeout. Increase to 14–25 days if BLE updates are infrequent. Audit fix M2: explicit RTC ≥ year 2000 guard on the staleness math prevents virtual-RTC stamps from spuriously rejecting fresh ANO.


Part D — GNSS deep-idle (GNP52)

Replaces the deprecated periodic backup-charge cycle (GNSS_BCKP_CHARGE_* → see page 09). Designed for sealed-tortue 1-year deployments with short surface windows.

Shipped: commits 367285e6b823620b (2026-05), hardened by cc429222, c9006b65, e322fb26, eb3f2c34, 927d0837.

D.1 TL;DR

Operator action Effect
Default (GNP52=0) No change from previous firmware. Rail cut at end of every session.
$PARMW#GNP52=300 + $SAVEP# 5 min of rail-on after each session. M10Q sleeps at ~10 µA. Coin cell recharges. Next session is warm-start (~3 s TTFF).
$PARMW#GNP52=4294967295 + $SAVEP# Rail stays on forever. Maximum battery economy on sealed deploys. ~0.4 mAh/day total GPS conso vs ~7 mAh/day with periodic backup-charge.
$GNSSBCKP#600 (DTE) Manual force a 10-min deep-idle session via BLE (bench tool). Same internal path.

D.2 Why this exists

Before the refactor, the V_BCKP coin cell on the M10Q was recharged by a periodic cycle (GNSS_BCKP_CHARGE_INT/DUR/UW_ONLY). Every 6–24 h the firmware power-cycled the GPS rail, sync'd UART, sent UBX-RXM-PMREQ backup, waited X minutes, then cut the rail. Two problems:

  1. Fragile. Every cycle re-powers the M10Q cold. If UART baud-sync fails (BBR lost, capacitor discharge, contamination from listening on a floating pin pre-power-up), the recharge never happens. Field log 2026-05-23 caught this failing twice in a row — coin cell drained, next session was a 120 s cold start that missed the tortue's surface window entirely.
  2. Wasteful. 24 power-up cycles per day at ~30 mA × ~2 s = ~0.4 mAh/day just on RAMP, never mind the charging window itself.

D.3 How deep-idle works

                 GPS session ends (success or NO_FIX)
                            │
                            ▼
            read GNSS_DEEP_IDLE_AFTER_OFF_S (GNP52)
                            │
            ┌───────────────┼───────────────┐
            ▼ = 0           ▼ = 0xFFFFFFFF  ▼ = X seconds
       power_off()    enter_deep_idle()  enter_deep_idle()
       (rail off)     (rail ON, no timer) (rail ON, +X timer)
                            │                  │
                            │           after X s, power_off()
                            ▼
                       Next session:
                            │
       ┌────────────────────┼────────────────────┐
       ▼ rail still on      ▼ rail was cut
       pulse EXTINT,        normal cold power-on
       sync UART,           (exit_shutdown,
       transit to           baud-sync, configure)
       configure (fast)
  • Deep-idle current (M10Q in PMREQ-backup with wakeupSources=EXTINT0): ~10–12 µA continuous (M10 datasheet typical).
  • Wake-up time (EXTINT pulse → first UBX response): ~30–50 ms.
  • TTFF: warm-start path (~3–15 s) instead of cold-start (~30–120 s) — BBR + almanac + ephemerides + AssistNow Autonomous prediction stay alive while rail is up.

D.4 Battery math (24 surface events/day)

Architecture GPS rail conso/day TTFF/session Total/day
OLD (periodic backup-charge, when working) 24 × 83 µAh recharge + 5 × ~1 mAh warm ~7 mAh/day
OLD degraded (sync fails → no recharge) 0 + 5 × ~3 mAh cold ~15 mAh/day
NEW GNP52=0 (no deep-idle) 0 + 5 × cold (BBR may be drained) up to ~15 mAh/day
NEW GNP52=300 (5 min/session) ~12 µA × 300 s × 5 = ~5 µAh + 5 × ~1 mAh warm ~5–6 mAh/day
NEW GNP52=0xFFFFFFFF (always on) 12 µA × 24 h × ~95 % = ~0.3 mAh + 5 × ~0.5 mAh hot ~3 mAh/day

For 1-year on a 8.5 Ah Saft LS33600: difference between "OLD degraded" and "NEW always-on" ≈ 12 mAh/day × 365 = 4.4 Ah saved — roughly half the battery. Single biggest battery optimization in the refactor pass.

D.5 EXTINT wake mechanism

The M10Q's deepest sleep is PMREQ-backup with wakeupSources=EXTINT0 (~10–12 µA, UART block fully powered down). Wake triggered by an edge on EXTINT.

  • Pin: BSP::GPIO_GPS_EXT_INT = nRF52 P1.11 (LinkIt V4). Direction set to OUTPUT in BSP (2026-05 refactor).
  • Pulse: pulse_extint_wake() in m10qasync.cpp — drives HIGH for 1 ms (10× the M10's ~100 µs minimum), back to LOW, then releases to high-Z to avoid leakage when M10Q is awake.
  • Power-off (enter_shutdown): releases EXTINT to high-Z. No leakage into a powered-off M10Q input.

Why EXTINT not UART RX? UARTRX wake works (~15 µA) but the UART block stays partially powered to monitor RX edges. EXTINT-only mode is the lowest-power configuration.

D.6 Robustness — fail-safe cascade

Inverting "rail always off" invariant created a new failure surface. If EXTINT wake fails (HW issue, PMREQ flag mismatch, contamination), firmware would otherwise hang waiting for UART banner. Multiple defensive layers:

Boot-level (R1, R2)

  • R1 — At every boot, main() drives GPIO_GPS_PWR_EN LOW via raw nrf_gpio API before init_peripherals runs. Hardware-level invariant survives even fault paths that skip clean shutdown.
  • R2GPSService::service_term() unconditionally calls m_device.power_off(). Cuts rail on every FSM transit out of OperationalState (ConfigurationState entry, BatteryCriticalState/ErrorState/OffState, OTA, factory_reset).

Reboot-level (R4, R5)

  • R4 — If PMU::reset_cause() == WDT_RESET at boot, GPS service inhibits deep-idle fast-path for the first session post-boot. Forces a cold-cycle to prove the cold path still works before re-engaging optimization.
  • R5 — Every service_next_schedule_in_ms call checks if M10Q has been in deep-idle > 24 h. If so, forces a prophylactic rail-cycle. Daily insurance against unobserved drift.

EXTINT wake fast-path (audit FIX 1, cc429222)

Added m_consecutive_wake_failures (uint8) + WAKE_FAIL_FAST_FALLBACK=2. In power_on(), the backupidle branch pre-increments the counter before pulsing EXTINT, and after 2 consecutive failures forces a full cold rail-cycle (rail off, wait, exit_shutdown) instead of trusting the same fast-path that just failed. Counter resets on first valid NAV report (react(UBXCommsEventNavReport)). Prevents the failure mode where a broken EXTINT pin or BBR-lost M10Q loops the wake path forever, never trying proven cold-boot recovery.

R4 inhibit clears on cold-cycle (audit FIX 2)

Previously the WDT-reset inhibit flag was cleared only in react(GPSEventPVT) on a valid fix. In HAULED + REUSE_LAST/OFF, or indoor bench testing, no PVT ever arrives → inhibit stuck forever → deep-idle permanently disabled post-WDT. Now the flag is also cleared the first time the cold-off branch completes — reaching that path proves cold-power-off survived without freezing.

Rate-limit re-check in service_initiate (audit FIX 3)

Mirrors the existing service_next_schedule_in_ms rate-limit gate. The Service framework's reschedule lambda calls service_initiate DIRECTLY after the delay (skipping service_next_schedule_in_ms), so without this re-check a GPS session can fire while Argos is still rate-limited, burning a full cold-acq window of GPS current for a fix that can't be transmitted.

R5 24 h cap when GNSS_EN=false (audit FIX 4)

Side-effect in service_is_enabled: if GNSS is disabled by config while deep-idle is engaged (rail on, M10Q in PMREQ-backup), the rail is cut immediately. Without this hook the Service framework stops calling service_next_schedule_in_ms (because service_is_enabled returns false), so R5's 24 h hygiene cap never fires — rail could stay on for a year, slowly draining the main battery via M10Q backup current. Documented violation of the "pure query" idiom — trade-off worth it for sealed-deploy robustness.

CLOUDLOCATE_ONLY state coherence (audit FIX 5)

When CLOUDLOCATE_ONLY=true (GNP53) ends the session on raw measurement (no PVT), also: (a) reset m_cold_start_ntry=0 — capturing a raw IS the success in this mode, no NTRY_BACKOFF on next cycle; (b) clear the WDT inhibit flag — reaching the raw measurement step proves UART + nav config + cold-power survived.

M10Q deep-idle robustness (v4.1.8, commit e322fb26)

  • Service::next_schedule gate: refuses to schedule while M10Q is in PMREQ-backup, otherwise the 10 s cold_start_retry kept waking the module every 10 s during the GNP52 window (bench-observed: 3 s of true backup then 297 s at ~2 mA every 10 s cycle).
  • try_enter_deep_idle_or_poweroff: calls service_reschedule(false) after the auto-off timer fires, so the framework re-arms the next acquisition through the normal path.
  • state_enterbackup: drives GPIO_GPS_EXT_INT LOW before PMREQ-backup so the M10Q observes a clean stable idle on EXTINT for the entire window (floating EXTINT was being interpreted as wake edges).
  • PMREQ-backup verification: probes the M10Q after PMREQ. TIMEOUT means backup confirmed; SUCCESS means M10Q still awake → retry up to PMREQ_VERIFY_RETRIES=3.
  • Wake diagnostic: includes slept_ms in EXTINT-wake INFO log so an unexpectedly short sleep duration immediately flags an unintended caller.

Backup-charge teardown (commits 561ca588, 694ac3e1)

  • backup-charge boots GPS at 9600 baud + clean UART teardown
  • Reverted "graceful fallback" — on sync failure during backup-charge, poweroff immediately rather than attempt a degraded continuation. Bounded recovery > silent partial success.

LED freeze safety 130 s → 650 s (low audit)

LED_FREEZE_TIMEOUT_MS in core/sm/ledsm.cpp. The 130 s ceiling covered the default GNSS_COLD_ACQ_TIMEOUT=120 s but not the param's full 10–600 s configurable range. Bumped to 650 s (600 + 50 s margin). Still well below the 15 min system watchdog, so a true freeze still recovers automatically.

"Wake-from-deep-idle" path fallback

If the pulse fails and UART sync doesn't succeed within BOOT_BAUD_SYNC_RETRIES × timeout (typical ~6 s), the driver bails to state_poweroff (rail cut), which the next acquisition fully re-powers cold. No scenario where the device freezes waiting for a sleeping M10Q.

D.7 DTE / pyLinkit workflow

Enable deep-idle on existing device

# (old, no longer needed — keys silently no-op now)
# $PARMW#GNP47=0
# $PARMW#GNP48=0
# $PARMW#GNP49=false

# Set the new param:
$PARMW#GNP52=300        # 5 min deep-idle window per session (tortue recommended)
$SAVEP#

Or for max battery:

$PARMW#GNP52=4294967295 # rail always on
$SAVEP#

Manual coin-cell charge (bench)

The $GNSSBCKP#duration_s DTE command is unchanged — same wire protocol, same arguments. It now delegates to request_deep_idle(duration_s) internally but is invisible to operator scripts.

$GNSSBCKP#600           # force a 10-min deep-idle session (bench operator charge)

D.8 GNP53 — CloudLocate-only mode

When GNP53=true AND GNP45=2 (CLOUDLOCATE), the GPS session terminates immediately on first raw measurement (GPSEventCloudLocateReady) — no waiting for full PVT or GNSS_ACQ_TIMEOUT. Drops typical session 30–120 s → 5–15 s.

Trade-off: no local lat/lon (position resolved cloud-side from raw uploaded via Argos). LED double-blinks CYAN at capture moment (LEDGNSSCloudLocateReady). Off by default.

Recommended for short-surface tortues (surfacing < 30 s) where any local fix is unlikely. See Part H — CloudLocate.

D.9 File locations (developer reference)

  • Param + DTE keys: core/protocol/base_types.hpp (GNSS_DEEP_IDLE_AFTER_OFF_S=240, GNSS_CLOUDLOCATE_ONLY=241), core/protocol/dte_params.cpp (GNP52, GNP53)
  • Dispatch: core/services/gps_service.cpp::try_enter_deep_idle_or_poweroff()
  • M10Q wake: ports/.../m10qasync.cpp::pulse_extint_wake(), power_on() fast-path
  • Robustness R1/R4: ports/nrf52840/main.cpp
  • Robustness R5: gps_service.cpp::service_next_schedule_in_ms
  • LED states: core/sm/ledsm.cpp::LEDGNSSCloudLocateReady, LEDGNSSDeepIdle, LEDGNSSPowerOff

Part E — RTC strategy

The GPS subsystem is the single source of real UTC in the firmware (via NAV-PVT epoch). It is also the largest consumer (ANO staleness, deep-idle 24 h cap, REUSE_LAST age check). Every time-based feature in the firmware — cooldown, HAULED-mode, rate limiter, REUSE_LAST, LED 24-h window — depends on the policies described in this section.

LinKit v4 deployments target sealed wildlife trackers, often running for months to years without ever obtaining a GNSS fix. The RTC architecture below must remain meaningful regardless of:

  • whether GNSS has ever synchronized the RTC
  • watchdog resets mid-deployment
  • brown-out (POF) interrupting flash writes
  • single-bit corruption in noinit RAM or LFS param files

E.1 RTC lifecycle

                  ┌──────────────────────────────────────────────┐
                  │                                              │
   Cold first boot ─────► main.cpp restore (validated, ≤ 2100)   │
   LAST_KNOWN_RTC=0 ──┐   ↓                                      │
                      │   ├─ if last_rtc > 0: rtc->settime()     │
                      │   └─ if rtc still !is_set: settime(1)    │
                      │       ↓                                  │
                      │   Virtual RTC (= 1 + uptime)             │
                      │       ↓                                  │
                      │   Periodic config flush (every 30 min)   │
                      │   M1a save: LAST_KNOWN_RTC = rtc->gettime()
                      │       ↓                                  │
   WDT reboot ◄───────┘   ↓                                      │
                          ↓                                      │
                  GNSS first fix arrives:                        │
                  m10qasync.cpp::rtc->settime(real_utc)          │
                          ↓                                      │
                          K + L hooks fire:                      │
                          HauledModeService::reset_for_rtc_sync  │
                          RateLimiter::reset_for_rtc_sync        │
                          ↓                                      │
                  Real RTC (Unix epoch, drifts ~20 ppm)          │
                          ↓                                      │
                  M1a continues saving real values               │
                          ↓                                      │
                  More GNSS fixes: rtc resynced                  │
                          │                                      │
                          └──────────────────────────────────────┘

E.2 Restore validation (boot path)

At every boot, LAST_KNOWN_RTC is read from LFS flash. Validation rules:

Value Action
0 Skip restore. Virtual RTC fallback fires (rtc->settime(1)).
1 to 4 102 444 800 (≤ year 2100) Accept and apply.
> 4 102 444 800 Reject as corrupt, treat as 0.

The lower bound is deliberately wide-open: RSPB's TPL5111 pseudo-RTC chain stores small values during the pre-GNSS phase, and sealed LinkIt V4 deployments rely on saved virtual-RTC values for cross-WDT continuity. Tighter validation would defeat both use cases.

E.3 Virtual RTC fallback

When LAST_KNOWN_RTC = 0 (cold first boot or rejected corruption), the firmware falls back to rtc->settime(1). From this point:

  • rtc->is_set() returns true everywhere → gating logic works.
  • rtc->gettime() ≈ uptime seconds since boot (+1 offset).
  • Relative-time math (deltas) remains correct — cooldown, HAULED idle threshold, rate limiter window, REUSE_LAST age check all work.
  • Absolute UTC is wrong — features needing real UTC (PASS_PREDICTION, human-readable log timestamps) must check rtc->gettime() >= 946684800 (year 2000 sentinel) themselves.

E.4 Periodic save (M1a)

GenTracker::periodic_config_flush() runs every 30 minutes. Before saving params, it writes the current rtc->gettime() to LAST_KNOWN_RTC (any value > 0, virtual or real). Without this:

  • Sealed LinkIt V4 without GNSS would reset RTC to 1 on every WDT, losing noinit-state continuity entirely.
  • RSPB's pseudo-RTC chain depends on this mechanism for cross-cycle continuity.

E.5 RTC rollback handling (M1b)

HauledModeService and other consumers detect when now < last_recorded_event_rtc (e.g. WDT reset + virtual RTC re-initializes to 1 while noinit retains last_uw_event_rtc = 3600).

Before correctifs: blind return → state machine frozen for the rest of the boot session.

After M1b: re-baseline last_event_rtc = now. Time count restarts from the new RTC frame, but state flags themselves are preserved.

if (now < s_noinit.last_event_rtc) {
    DEBUG_WARN("RTC rollback detected — re-baselining");
    s_noinit.last_event_rtc = now;
    persist();
}

Applied in: HauledModeService::evaluate, service.cpp::restore_cooldown_state (M1c), rate_limiter.cpp::restore_ring_state (M1c).

E.6 Noinit timestamp future-rejection (M1c)

service.cpp / rate_limiter.cpp / hauled_mode_service.cpp restore validation rejects future-dated noinit timestamps — same root cause as E.5 but in reverse (real RTC at save time, virtual RTC at restore time). Ensures stale future timestamps don't permanently gate the service.

E.7 Virtual → real RTC sync (K + L hooks)

When m10qasync.cpp::on_fix transitions virtual → real RTC, it calls:

  • HauledModeService::reset_for_rtc_sync() — clears last_uw_event_rtc stamped in virtual epoch
  • RateLimiter::reset_for_rtc_sync() — clears ring buffer timestamps in virtual epoch

Without these, noinit timestamps stamped in virtual epoch (~uptime seconds) would appear billions of seconds in the past after the jump to real UTC, breaking the windowed math harmlessly but spamming WARN logs.

E.8 ANO staleness math guard (M2)

m10qasync.cpp has an explicit RTC ≥ year 2000 guard on the ANO staleness math (GNP44 GNSS_ANO_STALE_DAYS). Without this, a virtual-RTC stamp (~uptime seconds) would compare against last_ano_save_rtc from a previous real-RTC session, producing a "23 trillion days old" delta and spuriously rejecting fresh ANO blobs.

For the sealed-device hardening architecture (boot-fail counter, exception barriers, POF handler, WDT, defense-layers summary), see 07 — Architecture § Sealed-device hardening. For cooldown gates, see 13 — Underwater & Behavioral Modes § Part D. For the rolling rate limiter, see 11 — Satellite Communication § Part E.4.


Part F — Surfacing sequence (warm/cold/hot)

With AXL pre-trigger (LoRa recommended)

[DIVE]
  BMA400 LOW_POWER (~3 µA), GPS off, SWS periodic sampling

[APPROACH SURFACE]
  T=-10 s  BMA400 detects turbulence/swim → wakeup interrupt
  T=-10 s  GPS power on (GNP26=1)
  T=-10 s  M10Q acquisition starts (AssistNow ephemerides in RAM)

[SURFACE]
  T=0 s    SWS confirms surface (dry)
  T=0 s    Argos/LoRa TX rescheduled
  T=1-5 s  GPS fix ✅ (warm + 10 s head-start)
  T=3-5 s  First TX with position

Without AXL pre-trigger (SMD/KIM — Doppler covers it)

  T=0 s    SWS confirms surface
  T=0 s    GPS power on
  T=5-15 s GPS fix (warm, no head-start)
  T=15-18 s First TX with GPS → risk of re-dive before TX

With deep-idle warm path (GNP52 set)

[DIVE]
  GPS rail ON, M10Q in PMREQ-backup (~10 µA)
  EXTINT held LOW (clean idle)

[SURFACE]
  T=0 s    SWS confirms surface
  T=0 s    Pulse EXTINT HIGH 1 ms → LOW, release high-Z
  T=30-50 ms  M10Q boots from PMREQ, UBX banner
  T=1-5 s  GPS warm fix ✅ (BBR + almanac all preserved)
  T=1-5 s  Service::next_schedule gate releases — TX path runs

Part G — Per-variant tuning

Argos SMD (Satellite)

# Doppler compensates absence of GPS fix → less aggressive OK
PARMW GNP03 2           # HDOP_THR = 2 (stricter, Doppler backup)
PARMW GNP21 10          # HACC_THR = 10 m
PARMW GNP05 120         # ACQ_TIMEOUT = 120 s
PARMW GNP26 0           # AXL pre-trigger = OFF (Doppler covers)

LoRa (RAK3172)

# No Doppler → GPS is the SOLE source of position → very aggressive
PARMW GNP03 4           # HDOP_THR = 4 (relaxed)
PARMW GNP21 25          # HACC_THR = 25 m
PARMW GNP05 60          # ACQ_TIMEOUT = 60 s
PARMW GNP26 1           # AXL pre-trigger = ON (compensates no Doppler)
PARMW ARP05 120         # TR_NOM = 120 s (TX more frequent, no Doppler)

KIM2 (legacy Argos via UART)

Same as Argos SMD.

RSPB (TPL5111-driven, mortality)

PARMW GNP30 1           # GNSS_SESSION_SINGLE_FIX = 1 (stop after 1 fix)
PARMW GNP09 180         # GNSS_COLD_ACQ_TIMEOUT = 180 s (3 min — battery saver)
PARMW GNP05 90          # GNSS_ACQ_TIMEOUT = 90 s

Part H — CloudLocate & REUSE_LAST

H.1 CloudLocate (GNP45=2)

The M10 receiver captures raw GNSS measurement snapshots (MEASC12 / MEAS20 / MEAS50) alongside PVT acquisition. If timeout without valid fix and raw measurements are available:

  • CloudLocate prioritizes over DEGRADED_PVT
  • Raw blob stored in depth pile (overlaid on unused position fields)
  • TX service emits a CloudLocate packet (type 7 Argos, type 4 LoRa)

During SURFACING_BURST: progressive CloudLocate — from the 2nd Doppler ping onwards, if raw is available, a CloudLocate packet is sent instead of Doppler.

GNP51 GNSS_CLOUDLOCATE_ALWAYS

Default behavior captures raw only on the FIRST surface acquisition of a session (cold-start path), then powers off as soon as a real fix arrives on subsequent warm surfaces — battery-friendly but no fallback if warm fix fails.

When GNP51=true, raw-meas is captured at every SURFACING_BURST surface, regardless of whether a real fix is obtained, giving the cloud a position fallback even when warm-GPS misses the 30 s timeout. Trade-off: GPS stays on the full cold_acq_timeout each surface (~30 s with fresh ANO). Useful for short-surface species (turtles surfacing < 30 s). Requires GNP45=2 AND ARGOS_MODE=SURFACING_BURST.

GNP53 GNSS_CLOUDLOCATE_ONLY

See D.8. Short-surface optimisation — terminate session on first raw measurement.

H.2 REUSE_LAST strategy

BaseGnssStrategy::REUSE_LAST (used by HAULED via HMP13, but also exposed as a general strategy) consumes the most recent FIX/UPDATE entry from the depth pile without powering GPS at all.

  • process_gnss_burst_from_cached() builds an Argos GNSS packet from depth-pile cached FIX/UPDATE
  • Falls back to Doppler when stale
  • FASTLOC entries are deliberately excluded from REUSE_LAST (semantically "last known good fix")
  • GNP50 GNSS_REUSE_FIX_MAX_AGE_S (default 86400 s = 24 h) gates the age check. 0 disables reuse entirely.

H.3 FastLoc → GNSS promotion (Plan 1 follow-up)

should_promote_doppler_to_gnss promotes a fresh FastLoc/FIX in the pile to fill what would otherwise be a Doppler slot. Applied at:

  • SURFACING_BURST phase 1
  • LEGACY/DUTY_CYCLE/PASS_PREDICTION !gnss_en branch
  • DOPPLER mode global

Lets a real position (cached or freshly captured) take precedence over a Doppler packet whenever both are eligible.


Part I — Battery impact

I.1 Per-fix energy

Scenario TTFF GPS current Energy/fix
Cold start (no AssistNow) 30 s 25 mA 210 µAh
Warm start (AssistNow) 5 s 25 mA 35 µAh
Hot start (AXL pre-trigger) 1-3 s 25 mA 14-21 µAh
Deep-idle wake (GNP52) 1-5 s 25 mA initial + ~10 µA continuous ~5-25 µAh

AssistNow divides GPS energy by ~6×. Most impactful single setting.

I.2 Annual budgets (4 surfacings/day)

Configuration Annual GPS conso
Cold start every time, no AssistNow 4 × 210 µAh × 365 = 307 mAh/yr
Warm start, AssistNow only 4 × 35 µAh × 365 = 51 mAh/yr
Hot start, AXL pre-trigger + AssistNow 4 × 20 µAh × 365 = 29 mAh/yr
Deep-idle GNP52=0xFFFFFFFF + AssistNow (12 µA × 24 h × 365) + (4 × 20 µAh × 365) = 134 mAh/yr

Note: deep-idle always-on is 4× more conso than aggressive AXL+AssistNow with rail cut between surfacings. But: deep-idle eliminates cold-start failures entirely on sealed devices that lose BBR power. The right trade-off depends on whether you trust the supercap V_BCKP retention vs. the cost of 100 mAh/yr extra.


Part J — LED indicators

GPS-related LED states emitted by ledsm.cpp (commit c9006b65, 2026-05). The end-of-session patterns were redesigned so that the operator can distinguish the sleep depth of the M10Q at a glance: double-blink = rail still on, M10Q in PMREQ-backup; fast-blink = rail cut, full power-off, next session is a cold boot.

State Pattern (period) Color (fix → / no-fix →) Duration When
LEDGNSSOn (legacy LEDGNSSAcquiring) Slow blink CYAN Cyan continuous GPS acquisition in progress (until session ends)
LEDGNSSCloudLocateReady Double-blink (120 ms alternate) Cyan ~500 ms CLOUDLOCATE_ONLY (GNP53) raw captured — session terminating on first raw measurement. Transit back to LEDGNSSOn if GPS still active, else LEDOff.
LEDGNSSDeepIdle (new 2026-05) Double-blink (120 ms alternate) GREEN / RED ~500 ms GNSS session ended, rail kept on (GNP52) — M10Q parked in PMREQ-backup (~10 µA). Color reflects whether session captured a valid fix. Then transit to LEDOff.
LEDGNSSPowerOff (new 2026-05) Fast-blink (50 ms ≈ 10 Hz) GREEN / RED ~500 ms GNSS session ended, rail cut — full M10Q power-off, next session cold-boots. Color reflects whether session captured a valid fix. Visually heavier than deep-idle on purpose.
LEDGNSSOffWithoutFix (legacy fallback) Solid Red 1.5 s (was 3 s) Fires only when neither GNSS_OFF_DEEP_IDLE nor GNSS_OFF_POWEROFF event was emitted. Defers 500 ms if LEDGNSSCloudLocateReady is in progress, to avoid stomping the CYAN double-blink.
LEDArgosTX Solid MAGENTA Magenta duration of TX Argos satellite transmission in progress (also fires for SATDP cert/calibration regardless of LED_MODE, commit 143aedc9).

Sleep-depth distinction rationale

Before commit c9006b65, end-of-session LED was always a solid 3 s flash GREEN (fix) or RED (no fix) via the legacy LEDGNSSOffWithFix / LEDGNSSOffWithoutFix states — and there was no way to tell whether the rail had been cut or whether the M10Q was in PMREQ-backup. With GNP52 GNSS_DEEP_IDLE_AFTER_OFF_S now controlling sleep depth per-session, the operator needs to see which sleep mode the device just entered:

Pattern Sleep depth Next session TTFF
Double-blink (LEDGNSSDeepIdle) M10Q PMREQ-backup, rail on (~10 µA) Warm start ~3-15 s (BBR + almanac + ANO preserved)
Fast-blink (LEDGNSSPowerOff) Full rail cut, M10Q off (0 µA) Cold start ~30-120 s (BBR lost)

The color (GREEN = valid fix this session, RED = no valid fix) is latched from the session result, not from the persistent BBR state — so on the second consecutive session that completes deep-idle without ever getting a fix, you'll see RED double-blink even though the M10Q is happily sleeping at 10 µA.

End-of-session events emitted by gps_service.cpp

  • GNSS_OFF_DEEP_IDLE — emitted from all paths that leave the rail on (rail in PMREQ-backup). LED FSM dispatches SetLEDGNSSDeepIdle.
  • GNSS_OFF_POWEROFF — emitted from all paths that cut the rail (service_term R2, GNP52 auto-off timer firing via poweroff_from_deep_idle(), R5 24 h hygiene cycle, FIX 4 GNSS_EN=false rail-cut). LED FSM dispatches SetLEDGNSSPowerOff.

WARM-path detection via the state_poweroff flag (GPIO read on GPIO_GPS_PWR_EN is broken by input-disconnect when rail is off — can't use GPIO state to infer the rail state). m_pmreq_baud tracks the actual PMREQ baud for EXTINT-wake pre-sync. service_term routes through poweroff_from_deep_idle when called while in backupidle, so even a transit out of OperationalState fires GNSS_OFF_POWEROFF correctly and the LED reflects the actual hardware state.

LED freeze safety

Every LED state arms a freeze-safety timeout (LED_FREEZE_TIMEOUT_MS = 650 s, bumped 130 s → 650 s in cc429222 to cover the full configurable range of GNSS_COLD_ACQ_TIMEOUT 10-600 s + 50 s margin). If the FSM somehow gets stuck in a single LED state past 650 s, the safety fires SetLEDOff. Still well below the 15 min system watchdog, so a true freeze still recovers automatically; the LED safety is for catastrophic FSM hangs, not for tight UX timing. The PreOperationalState / LEDOff disarm this safety (magnet activation UX must remain visible until magnet release; commit bc11660b).


Part K — Field-debug & troubleshooting

TTFF systematically > 30 s

  • AssistNow disabled or ephemerides expired → check GNP24=1, GNP27=1
  • ANO blob older than GNP44 threshold → check $GNSSA status
  • Cold start required (BBR drained) → enable GNP52 (deep-idle) or schedule manual $GNSSBCKP

TTFF OK initially, degrades over weeks

  • AssistNow Offline expired (14 d) → recharge MGA-ANO via BLE
  • Coin cell V_BCKP drained → enable GNP52 deep-idle (most reliable solution)

Fix 2D only, never 3D

  • Not enough satellites → check constellation mask (GNP40), lower GNP43 GNSS_MIN_ELEV
  • Fix mode forced to AUTO accepts 2D — change GNP10 to 3D ONLY if needed (rare)

No fix ever

  • Antenna defective or shielded
  • Check signal: STATR GNSS section, debug log SAT level / count

HDOP always > threshold

  • Threshold too strict → increase GNP03 to 4–6

"first TX always fails after fresh flash, OK after power-cycle"

If this happens on SMD variants: see the cold-reboot gotcha in 11 — Satellite Communication § Part D. Not GPS-related — the STM32WL state on VDD caps leaks into the next SPI session as INVALID_CMD cascades.

Deep-idle: device wakes every 10 s instead of staying in backup

Bench-observed pre-e322fb26: 3 s of true backup then 297 s at ~2 mA every 10 s cycle. The Service::next_schedule gate fix prevents this — verify v4.1.8 or later is flashed.

Deep-idle: WDT inhibit stuck after WDT reset

Indoor bench: no PVT ever arrives → R4 inhibit never clears (legacy). Fixed in audit FIX 2 — the inhibit clears the first time the cold-off branch completes.

"GNSS_DEEP_IDLE active but next session is still cold start"

  • Check state_enterbackup was actually reached → debug log
  • Verify EXTINT pin direction is OUTPUT in BSP
  • Check m_consecutive_wake_failures counter (audit FIX 1) — if ≥ 2, the driver is forcing cold-cycles deliberately

RTC drifts during deployment

LAST_KNOWN_RTC is refreshed every 30 min (M1a) but only saved to flash on clean shutdown or in PMU::powerdown(). POF brown-out preserves it but cannot save params (corrupts LFS). Drift between flush events is normal — ~30 min RTC drift on virtual epoch after WDT, irrelevant for relative-time math.


Cross-references

Clone this wiki locally