Skip to content

11 ‐ Satellite Communication

gfo974 edited this page Jul 6, 2026 · 4 revisions

11 — Satellite Communication (Argos: KIM / SMD SPI / SMD UART)

Consolidated reference for the Argos satellite communication subsystem: hardware variants, TX modes, first-TX optimizations, SMD timing autofallback, and complete message formats. Replaces former pages 10 (Satellite Message Format) and 14 (Argos TX Modes).

Source: core/services/argos_tx_service.cpp/hpp, ports/nrf52840/core/hardware/smd_sat/, ports/nrf52840/core/hardware/kim2/.


Table of contents


Part A — Hardware variants (KIM2 / SMD SPI / SMD UART)

LinKit v4 supports three Argos transmitter variants, selected at build time and mutually exclusive. All use the u-blox M10Q GNSS independently of the radio choice.

Variant Module Bus Protocol CMake flag Build script
KIM2 CLS KIM2 UART Legacy Argos (CLS) (default, no flag) ./scripts/build_linkitv4_kim.sh
SMD SPI Arribada SMD (STM32WL co-MCU) SPI 125 kHz, Mode 0 A+ Binary (64-byte full-duplex frames) -DARGOS_SMD=ON ./scripts/build_linkitv4_smd.sh
SMD UART Same Arribada SMD module Async UART A+ Text AT commands (AT+CMD=…\r\n) -DARGOS_SMD=ON -DSMD_UART=ON ./scripts/build_linkitv4_smd.sh with SMD_UART=ON

Both SMD transports implement the same abstract SmdSatCmd interface (ports/nrf52840/core/hardware/smd_sat/smd_sat_cmd.hpp), so the rest of the firmware is transport-agnostic. CI matrix exposes the variants as KIM / SMD_SPI / SMD_UART.

A.1 KIM2 specifics

  • Module: CLS KIM2 (legacy Argos)
  • Transport: UART, text protocol
  • Modulation switching: kim2.cpp::switch_modulation() — STBYM (standby), TX_MOD, KMAC re-init
  • Security: no A+ KMAC — legacy CLS protocol only
  • First-TX path: ~1.5–2 s warm, no FAST/SAFE autofallback (timings are fixed)
  • Lifecycle: SMD-equivalent autofallback / cold-reboot protection does not apply — KIM2 has no co-processor with VDD-cap state leakage
  • Buil-time signal: no ARGOS_SMD define → ARGOS_KIM2=1

A.2 SMD SPI specifics

  • Module: Arribada SMD (STM32WL co-MCU)
  • Transport: SPI 125 kHz, full-duplex 64-byte frames (binary protocol A+)
  • Security: A+ KMAC authentication with 128-bit AES seckey (IDP13 ARGOS_SECKEY)
  • Modulation switching: smd_sat.cpp::switch_modulation()set_radio_conf SPI cmd + save_radio_conf flash + load_kmac_profil
  • Persisted state in SMD flash: KMAC profile, RCONF, TX modulation — not reloaded at boot unless credentials or RCONF have changed (saves ~500 ms per boot)
  • First-TX path (warm, FAST timings): ~155 ms; cold path (full re-init, ANO stale): ~600–900 ms; SAFE timings: ~310 ms warm / ~1.4 s cold (see Part D)
  • FAST/SAFE autofallback: persisted via SMP00 SMD_DEGRADED_MODE (see Part D)
  • Cold-reboot STM32WL on every soft reset: SAT_PWR_EN LOW, ~500 ms VDD-cap discharge, then re-enable. Eliminates the "INVALID_CMD cascade after soft reset" pattern (commit 6018ace9). Cold-reboot gotcha is now invisible at runtime — still applies to flashing during development.
  • TCXO warmup (ARP35, written every boot, RAM-only register): automatically set to 0 on the first TX after surfacing (priority #3)
  • VPA gating: PA rail (SMD_VPA_PIN) released only at TX-pending — power-saving + PA-protection

A.3 SMD UART specifics

  • Same Arribada SMD module exposed over UART instead of SPI
  • Transport: text-based AT commands (AT+CMD=<params>\r\n)
  • CMake: ARGOS_SMD=ON + SMD_UART=ON — compile defines SMD_UART=1 instead of SMD_SPI=1
  • Cmd layer: smd_sat_cmd_at.hpp instead of smd_sat_cmd_spi.cpp
  • Same A+ KMAC semantics as SMD SPI; same FAST/SAFE autofallback infrastructure (different timing constants relevant to UART line-discipline rather than SPI polling)
  • Used on RSPB (build script build_rspb.sh) and as an alternate LinkIt V4 build variant
  • The cmd_delay / cmd cascade / opcode whitelist machinery is SPI-specific — UART variant has different ones, less prone to the SPI cascade failure mode

A.4 Cross-variant shared parameters

All variants honor the same DTE parameter set in the ARP* (Argos), IDP* / IDT* (identity), CTP* (certification), SMP* (SMD diagnostic — SMD only) namespaces. See 09 — Parameters for the full reference.

Variant Identity Credentials Radio conf Adaptive modulation Autofallback
KIM2 IDP12 ARGOS_DECID, IDT06 ARGOS_HEXID (CLS-specific) IDP14 ARGOS_RADIOCONF ARP54
SMD SPI Same + IDP13 ARGOS_SECKEY A+ KMAC Same + ARP51/52/53 per-modulation ARP54 ✅ (SMP00)
SMD UART Same Same Same ARP54 ✅ (SMP00)

SATVF DTE command verifies that the credentials programmed in the module match the config store values (see 06 — DTE commands).


Part B — TX modes (ARP01)

The Argos mode (ARP01) determines how and when satellite transmissions are scheduled. 6 modes, each suited to different deployment scenarios.

┌─────────────────────────────────────────────────────────────────────────┐
│                          ARGOS TX MODES                                │
├──────────┬──────────┬──────────┬──────────┬──────────┬────────────────┤
│   OFF    │  PASS    │  LEGACY  │  DUTY    │ DOPPLER  │  SURFACING    │
│  (0)     │  PRED(1) │   (2)    │ CYCLE(3) │  (4)     │  BURST (5)   │
│          │          │          │          │          │               │
│ No TX    │ TX on    │ TX every │ TX in    │ 3-byte   │ Doppler then │
│          │ sat pass │ TR_NOM   │ enabled  │ Doppler  │ GNSS burst   │
│          │          │ seconds  │ hours    │ only     │ on surfacing │
└──────────┴──────────┴──────────┴──────────┴──────────┴────────────────┘

B.1 Common parameters (all modes)

Parameter DTE Key Default Description
ARGOS_MODE ARP01 2 Mode selector (0-5)
ARGOS_TCXO_WARMUP_TIME ARP35 5 Crystal warmup before TX (seconds). Skipped on 1st TX after surfacing in SURFACING_BURST.
ARGOS_TX_JITTER_EN ARP31 true Random ±5 s jitter to avoid satellite collisions
ARGOS_RX_EN ARP32 true Kineis AOP downlink reception — not implemented on KIM2/SMD builds (RX service never started); no effect, keep 0. AOP goes in via PASPW.
ARGOS_RX_MAX_WINDOW ARP33 900 RX window duration (s) — inert, see ARP32
ARGOS_ADAPTIVE_MODULATION ARP54 false Auto-select modulation per packet type (VLDA4/LDK/LDA2)
ARGOS_RADIOCONF_LDK ARP51 (factory) Per-modulation radio config (SMD only)
ARGOS_RADIOCONF_LDA2 ARP52 (factory) Per-modulation radio config (SMD only)
ARGOS_RADIOCONF_VLDA4 ARP53 (factory) Per-modulation radio config (SMD only)

Underwater interaction (all modes)

Parameter DTE Key Default Description
UNDERWATER_EN UNP01 false Enable SWS underwater detection
DRY_TIME_BEFORE_TX UNP02 0 Delay after surfacing before TX allowed (s)
MIN_SURFACE_CYCLE_INTERVAL_S UNP20 2700 Cooldown between active surface cycles (0=disabled). See 13 — Underwater & Behavioral Modes.

No-fix behavior (v3 heartbeat)

In LEGACY, DUTY_CYCLE and PASS_PREDICTION (v3 behavior, restored 2026-07): a GPS cycle that yields no fix caches a NO_FIX entry which is transmitted as a 0xFF-position packet — an "alive" heartbeat. Each NO_FIX entry also keeps its own slot on the uniform delta_time_loc grid (no dedup, no purge when a real fix arrives), so multi-position packet dating stays exact. Every entry — real fix or heartbeat — is replayed per NTRY_PER_MESSAGE (ARP19): N>0 = exactly N transmissions, 0 = unlimited replay until evicted by newer entries (FIFO capped at ARGOS_DEPTH_PILE).

PASS_PREDICTION with no satellite pass found (or no known position) goes silent until the next GPS entry (v3 behavior — no duty-cycle fallback).

First-message gate (non-RSPB). In LEGACY/DUTY_CYCLE/PASS_PREDICTION, no message is sent this power session until a valid GPS fix has corrected the clock — not even a NO_FIX heartbeat or the time-sync burst. This prevents transmitting a 0xFF position on a DTE/pseudo-RTC clock before the GPS has actually fixed in the field. RSPB is exempt (compile-time BOARD_RSPB): a boot-modulo session that ends with no fix still sends its empty (0xFF) position + sensor packet. The gate is per-boot (reset every power-on).

SURFACING_BURST keeps its own progressive cascade (CloudLocate → degraded fastloc → cached position → Doppler) and HAULED keeps HAULED_GNSS_STRAT (HMP13) — both unchanged.

Dates. A Long packet carries up to 3 positions with a single base timestamp + delta_time_loc; decoders date them on the uniform grid date(GPS[N]) = base − N × delta_time_loc. The 0xFF fillers preserve that grid for GPS cycles that RUN and fail. Cycles that never run (underwater gate, GPS cooldown, NTRY backoff) leave undated gaps — same limitation as v3.

History. The configurable no-fix policy (ARGOS_TX_NO_FIX_POLICY/ARP36 + ARGOS_LAST_KNOWN_MAX_AGE_S/ARP37) and the "v2 skip fields" packet extension existed only 2026-06 → 2026-07 and were removed; slots 223/224 are reserved again.

BLIND MAC profile (module-owned retransmission)

New 2026-07 (config version 0x20): ARGOS_BLIND_EN (ARP44), ARGOS_BLIND_RETX_NB (ARP45), ARGOS_BLIND_RETX_PERIOD_S (ARP46).

By default the firmware loads the Kineis BASIC KMAC profile: one message on air per TX command, the nRF paces every repetition. With ARGOS_BLIND_EN=1 the driver loads the BLIND profile instead: for each TX command the module itself airs retx_nb copies spaced retx_period_s seconds apart, then reports a single +TX when the whole burst completes. Redundancy against missed satellite passes no longer costs an nRF wake-up per copy.

BASIC (default) BLIND
Copies on air per TX command 1 ARGOS_BLIND_RETX_NB (1-127, default 4)
Spacing within the burst ARGOS_BLIND_RETX_PERIOD_S (60-65535 s, default 60)
Paced by nRF (TR_NOM, NTRY_PER_MESSAGE) module (KMAC BLIND context)
+TX reported per message once, at end of burst

Implementation notes:

  • Transports: KIM2 (AT+KMAC=2 with a packed KNS_MAC_BLIND_usrCfg_t context) and SMD (load_kmac_profil(2), both UART and SPI). If the module firmware rejects profile 2, the driver logs a warning and falls back to BASIC — pre-BLIND module firmware keeps working unchanged.
  • retx_nb clamp: the drivers clamp to 1..127 (the field is an int8 on the module side).
  • Mode gating: BLIND is force-disabled when the effective mode (after low-battery / zone / HAULED override) is SURFACING_BURST or DOPPLER — those modes run their own nRF-paced burst, and a module-owned retransmission would double-transmit. ARGOS_BLIND_EN therefore only takes effect in LEGACY / DUTY_CYCLE / PASS_PREDICTION (including HAULED overrides resolving to those).
  • NTRY_PER_MESSAGE unchanged: ARP19 still counts blind sequences per depth-pile entry on the nRF side; ARP45 counts copies inside one burst. Total airings per entry = NTRY_PER_MESSAGE × retx_nb.
  • Airtime accounting: each completing burst adds retx_nb (not 1) to the session TX count — SHUTDOWN_NTIME_SAT (PWP05) and the rolling rate limiter bound actual airtime, so their budgets are consumed ×retx_nb per logical TX. Size them accordingly (e.g. PWP05=12 with ARP45=4 → powerdown after 3 bursts).
  • Safety timeout: the TX service extends its per-TX watchdog by retx_nb × retx_period_s, capped at 2 h — keep the product under 2 hours or the burst gets cancelled mid-flight.

Parameter reference: 09 — Parameters → BLIND MAC Profile.

B.2 Mode 0 — OFF

No Argos TX. GPS may still acquire fixes (if GNSS_EN=1) but nothing is transmitted via satellite.

B.3 Mode 1 — PASS_PREDICTION

Transmit only when Argos/Kineis satellites are predicted to be overhead, using AOP (Argos Orbital Parameters) data from the PASPW allcast database.

  ┌──────────────┐     ┌──────────────┐     ┌──────────────┐
  │  No satellite │     │  Satellite   │     │  No satellite │
  │  → IDLE       │────>│  overhead    │────>│  → IDLE       │
  │  (no TX)      │     │  → TX burst  │     │  (no TX)      │
  └──────────────┘     └──────────────┘     └──────────────┘
       wait...          PP_MIN_DURATION          wait...

Parameters

Parameter DTE Key Default Range Description
PP_MIN_ELEVATION PPP01 15.0 0-90 Min satellite elevation angle (degrees)
PP_MAX_ELEVATION PPP02 90.0 0-90 Max satellite elevation angle (degrees)
PP_MIN_DURATION PPP03 30 0-max Min pass duration to trigger TX (s)
PP_MAX_PASSES PPP04 1000 1-max Max passes to compute in prediction
PP_LINEAR_MARGIN PPP05 300 0-max Time buffer around predicted pass window (s)
PP_COMP_STEP PPP06 10 1-max Prediction computation resolution (s)
TR_NOM ARP05 60 30-1200 TX interval during the satellite pass window

Notes

  • Requires valid AOP data (loaded via $PASPW command or Argos RX downlink)
  • Most energy-efficient mode when satellite passes are well predicted
  • Falls back to no TX if AOP data is stale or unavailable

B.4 Mode 2 — LEGACY

The simplest active mode. Transmit at a fixed periodic interval.

  ──── TR_NOM ────>──── TR_NOM ────>──── TR_NOM ────>
  │                │                │                │
  TX#1             TX#2             TX#3             TX#4
  (GNSS packet)    (GNSS packet)    (GNSS packet)    ...

Parameters

Parameter DTE Key Default Range Description
TR_NOM ARP05 60 30-1200 TX interval (s)
GNSS_EN GNP01 true bool Include GPS position. If false: Doppler only.
ARGOS_DEPTH_PILE ARP16 16 1-24 GPS fixes accumulated per TX packet
DLOC_ARG_NOM ARP11 3 index Delta-time-loc code for multi-fix packet timing
ARGOS_TIME_SYNC_BURST_EN ARP30 true First TX aligned to Argos time slot

NTRY_PER_MESSAGE bounds replay in LEGACY: each depth-pile entry is transmitted exactly N times through the slot rotation (one TX slot per repetition, so N full sweeps of the pile), then goes inert. 0 = unlimited replay — the pile keeps cycling until newer fixes evict the entries (pre-2026-07 firmware ignored ARP19 here and always behaved like 0).

Packet types

Condition Packet Modulation Size
GNSS_EN=1, 1 fix Short GNSS LDK 96 bits
GNSS_EN=1, 2-3 fixes Long GNSS LDA2 192 bits (incl. CRC8 byte)
GNSS_EN=0 Doppler VLDA4 24 bits
Sensor TX enabled Sensor packet LDA2 192 bits (184 data + CRC8 byte)

Long GNSS packs 3 fixes max per LDA2 frame (down from 4). For ARGOS_DEPTH_PILE > 3, the firmware emits ceil(N/3) packets to drain the pile; slots holding a single fix become short LDK packets. See Part F.

B.5 Mode 3 — DUTY_CYCLE

TX only during specific UTC hours, controlled by a 24-bit bitmask.

  Hour: 0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 ...
  Mask: 1  1  1  1  0  0  0  0  1  1  1  1  0  0  0  ...
        ████████████            ████████████
        TX allowed              TX allowed
        (TR_NOM interval)       (TR_NOM interval)

Bitmask format

Bit:   23 22 21 20 19 18 17 16 15 14 13 12 11 10  9  8  7  6  5  4  3  2  1  0
Hour:   0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
Example Hex Description
24/7 FFFFFF Equivalent to LEGACY
Night only (0-7 UTC) FF0000 8 hours per day
3 windows 0F0F0F Hours 4-7, 12-15, 20-23

Parameters

Same as LEGACY, plus ARP18 DUTY_CYCLE (24-bit hex bitmask). NTRY_PER_MESSAGE behaves as in LEGACY (N>0 = N transmissions per entry, 0 = unlimited replay).

B.6 Mode 4 — DOPPLER

Minimal 3-byte packets. No GPS data — Kineis estimates position from Doppler frequency shift (~5-10 km accuracy).

  ──── TR_NOM ────>──── TR_NOM ────>
  │                │                │
  Doppler (3B)     Doppler (3B)     Doppler (3B)
  [batt|lb_flag]   [batt|lb_flag]   [batt|lb_flag]

Ignored: GNSS_EN, ARGOS_DEPTH_PILE, NTRY_PER_MESSAGE, DLOC_ARG_NOM (no GPS data).

Used: messages within a sequence follow the progressive Doppler-phase intervals (SURFACING_BURST_INIT_S/STEP_S/MAX_S, ARP40-42); SURFACING_BURST_MAX_MSG (ARP43) ends the sequence, then TR_NOM pauses before the next one. ⚠️ Never deploy DOPPLER with ARP43=0 — 0 = unlimited, the sequence never ends and the device transmits continuously until the battery dies. ARGOS_BLIND_EN (ARP44) is force-disabled in this mode.

Use case

  • Low-battery fallback mode (LB_ARGOS_MODE = 4)
  • When only coarse Kineis Doppler positioning is needed
  • Minimum energy per TX (~0.45 mWh vs ~0.60 mWh for GNSS)

B.7 Mode 5 — SURFACING_BURST

The most advanced mode for marine animals with brief surfacings. Designed for sea turtles, cetaceans, and other species that surface briefly and unpredictably.

Complete flow

SURFACE DETECTED (SWS)
  │
  ╔══════════════════════════════════════════════════════════╗
  ║  PHASE 1: DOPPLER BURST                                  ║
  ║  Immediate satellite contact for Doppler positioning      ║
  ╚══════════════════════════════════════════════════════════╝
  │
  ├─ Tick #1 (immediate): Doppler VLDA4 (24 bits, battery only)
  │   → TCXO warmup skipped for fastest first contact
  │   → Satellite estimates position from frequency shift (~5-10 km)
  │
  ├─ Tick #2+: priority cascade (best available)
  │   │
  │   1. CloudLocate raw measurement (FASTLOC_MODE=2)  → LDK or LDA2
  │   2. Degraded PVT (FASTLOC_MODE≥1)                 → Fastloc LDA2
  │   3. Cached position branch (v4.1.8)               → GNSS Multi LDA2
  │   4. Doppler (fallback)                            → VLDA4
  │
  ├─ Timing: init_s, init_s+step_s, init_s+2×step_s, ... capped at max_s
  ├─ Limit: SURFACING_BURST_MAX_MSG ticks total (Doppler + Fastloc + CloudLocate)
  │
  ╔══════════════════════════════════════════════════════════╗
  ║  GPS FIX ARRIVES (normal, passes hAcc/hDOP filters)       ║
  ║  → STOP Phase 1 immediately                               ║
  ║  → Switch to Phase 2                                       ║
  ╚══════════════════════════════════════════════════════════╝
  │
  ╔══════════════════════════════════════════════════════════╗
  ║  PHASE 2: GNSS TX                                         ║
  ║  Precise position from GPS fix                             ║
  ╚══════════════════════════════════════════════════════════╝
  │
  ├─ GNSS TX #1: immediate after fix
  ├─ GNSS TX #2+: every TR_NOM seconds
  ├─ Each fix sent NTRY_PER_MESSAGE times (depth pile burst_counter)
  │   → ARP19=0: once per fix
  │   → ARP19=3: 3 times per fix (60 s apart)
  │
  ╔══════════════════════════════════════════════════════════╗
  ║  GPS TIMEOUT (no normal fix obtained)                      ║
  ╚══════════════════════════════════════════════════════════╝
  │
  ├─ FASTLOC_MODE=2: raw measurement → depth pile → CloudLocate TX
  ├─ FASTLOC_MODE=1: degraded PVT → depth pile → Fastloc TX
  ├─ FASTLOC_MODE=0: no fix → Doppler only
  │
  ╔══════════════════════════════════════════════════════════╗
  ║  DIVE DETECTED (SWS)                                       ║
  ║  → Everything stops immediately                            ║
  ║  → SMD module powered off                                  ║
  ║  → Cooldown timer starts (MIN_SURFACE_CYCLE_INTERVAL_S)    ║
  ╚══════════════════════════════════════════════════════════╝

Parameters

Parameter DTE Key Default Phase Description
SURFACING_BURST_INIT_S ARP40 5 1 Delay before Doppler #2 (s)
SURFACING_BURST_STEP_S ARP41 1 1 Increment per subsequent Doppler
SURFACING_BURST_MAX_S ARP42 30 1 Maximum Doppler interval cap
SURFACING_BURST_MAX_MSG ARP43 0 1 Max tick messages in Phase 1 (0 = unlimited)
TR_NOM ARP05 60 2 GNSS TX interval (s)
NTRY_PER_MESSAGE ARP19 0 2 Retransmissions per GPS fix (0 = once)
MIN_SURFACE_CYCLE_INTERVAL_S UNP20 2700 Cooldown between surfacing cycles
GNSS_FASTLOC_MODE GNP45 0 1 0=OFF, 1=DEGRADED_PVT, 2=CLOUDLOCATE
GNSS_CLOUDLOCATE_FORMAT GNP46 0 1 0=MEASC12 (12 B, LDK), 1=MEAS20 (20 B, LDA2)

Cached-position TX for ping #2+ (v4.1.8 — commit e322fb26)

process_doppler_burst() has a cached-position branch for ping #2+ when no live CloudLocate raw or degraded PVT is available. Picks the most recent between cached GPS fix and cached Fastloc by LogHeader UTC timestamp:

ping #1                         → pre-warm fast path UNTOUCHED (priority #3, first-TX-fast)
ping #2+ live raw/degraded?     → existing CloudLocate / Fastloc branch
ping #2+ no live data?          → cached-position branch
ping #2+ cache empty?           → falls through to standard Doppler
ping #2+ modulation constraint? → falls through to standard Doppler

Cache is m_last_gps_log_entry and m_last_fastloc_log_entry in ConfigurationStore (RAM, populated by gps_service::task_process_degraded_gnss_data and PVT fixes). Ensures that even if the current surface fails to produce a fresh fix, ping #2+ still TX'es a position (the most recent known one) instead of a bare Doppler.

Two independent counters

Counter Controls Parameter Scope
Doppler burst count All Phase 1 ticks (Doppler, Fastloc, CloudLocate, cached) ARP43 (SURFACING_BURST_MAX_MSG) Per surfacing event
Depth pile burst_counter Phase 2 GNSS retransmissions per fix ARP19 (NTRY_PER_MESSAGE) Per GPS fix

Completely independent: Doppler burst limit does not affect GNSS TX, and vice versa.

Historical positions in Phase 2

The depth pile persists across dives — not cleared on submersion. Fixes obtained in previous surfacings remain in RAM and are transmitted in later Phase 2 bursts, until the pile drains naturally or older entries are evicted by FIFO cap.

Key implications:

  • Phase 2 is triggered only by a fresh real fix in the current surfacing. If a surfacing produces no fix, Phase 2 is never entered and historical fixes stay frozen in RAM, waiting for the next successful fix.
  • When ARGOS_DEPTH_PILE is reached and a new fix arrives, the oldest entry is evicted via FIFO pop_front() — even if its retry burst was not yet complete. Intentional trade-off favoring fresh positions over stale retries.
  • To carry more history, increase ARGOS_DEPTH_PILE (up to 24, paid in RAM). Lower values (e.g. 1 for UW model) send only the latest fix per Phase 2.

Example timeline with ARGOS_DEPTH_PILE=4:

Surfacing 1 → fix A         → pile = [A]         → Phase 2 sends A
Surfacing 2 → fix B         → pile = [A, B]      → Phase 2 sends [A, B]
Surfacing 3 → no fix        → Phase 1 only       → pile unchanged
Surfacing 4 → fix C         → pile = [A, B, C]   → Phase 2 sends [A, B, C]
Surfacing 5 → fix D         → pile = [A, B, C, D] → Phase 2 sends [A, B, C, D]
Surfacing 6 → fix E         → pile = [B, C, D, E] → A evicted, sends [B, C, D, E]

Modulation switching

With adaptive modulation (ARP54=1), the firmware automatically switches RCONF between modulations:

Message type Modulation Size When
Doppler VLDA4 24 bits (3 bytes) Phase 1 tick (no GPS data)
Fastloc LDA2 192 bits (24 bytes) Phase 1 tick (degraded PVT)
CloudLocate MEASC12 LDK 128 bits (16 bytes) Phase 1 tick (raw snapshot)
CloudLocate MEAS20 LDA2 192 bits (24 bytes) Phase 1 tick (raw snapshot)
GNSS Short LDK 96 bits (12 bytes) Phase 2 (single fix)
GNSS Long LDA2 192 bits (24 bytes, 3 fixes max + CRC8) Phase 2 (multi-fix depth pile)
Sensor LDA2 192 bits (24 bytes, 184 data + CRC8) Phase 2 (GPS + sensors)

After each TX, the firmware pre-switches to VLDA4 so the next surfacing's first Doppler can be sent without delay. Exception: if GPS is still acquiring with a degraded PVT (next tick likely Fastloc/LDA2), the pre-switch is skipped to avoid RCONF churn.

When ARP54=0 (fixed master modulation), the firmware respects the master modulation for CloudLocate TX sites (commits 02be9b42, 6ae572ba):

  • Previously, those 3 sites unconditionally overrode m_scheduled_mode to LDK for MEASC12 packets BEFORE checking the adaptive-modulation gate. With ARP54=0 + master=LDA2, the SMD stayed at LDA2 while send() was called with LDK → "TX mode 1 != current modulation 0" warning. TX proceeded at LDA2 (no fatal error, payload fits), but log was misleading.
  • Now the LDK/LDA2 override moves inside the adaptive gate. With ARP54=0, master modulation is kept (LDA2 fits 12 B MEASC12 with padding, 20 B MEAS20 fits LDA2). VLDA4 master with MEAS20 skips TX defensively rather than truncating.
  • Uses m_kineis.get_current_modulation() at TX time — live SMD state, always matches what send() will use.

Post-burst behavior

After the Doppler burst ends:

  • GPS fix arrives (even after burst limit): Phase 2 GNSS TX activates immediately
  • Depth pile exhausts (all NTRY sent): service waits for next GPS fix or next surface event
  • No GPS fix and no dive: no TX — service disabled until next UW→SURFACE transition
  • Second GPS fix after depth pile exhaustion: triggers new immediate GNSS TX with fresh NTRY counter

Cooldown trigger modes (UNP30)

Mode DTE Key UNP30 Value When cooldown arms
AT_SURFACE 0 Immediately on surface detection Before any TX
END_OF_DOPPLER 1 When Doppler phase ends (GPS fix or max msg) After burst
AFTER_FIRST_GNSS 2 After first GNSS TX with GPS data After useful data sent
AFTER_LAST_TX 3 After every TX with GPS data or during burst Default — most conservative

All modes: cooldown timer starts on the next dive (not on arming). See 13 — Underwater & Behavioral Modes2 § D.

CloudLocate details

CloudLocate uses raw GNSS signal measurements captured by the u-blox M10Q, sent to a cloud server (u-blox CloudLocate / Thingstream) which computes the position using precise ephemerides.

Advantages over on-device GPS fix:

  • Works with as few as 1-2 visible satellites (vs 4+ for on-device)
  • Snapshot captured in 1-5 s (vs 30-120 s for cold start fix)
  • Server has perfect ephemeris data (no stale almanac issue)
  • Position accuracy: 10-500 m depending on format and conditions

Formats:

Format Blob Argos packet Accuracy Use case
MEASC12 12 B LDK (fits 128 bits) ~100-500 m Argos: minimal bandwidth
MEAS20 20 B LDA2 (fits 192 bits) ~50-200 m Argos: better accuracy
MEAS50 50 B LoRa only ~10-50 m LoRa: best accuracy

Fallback chain at GPS timeout:

CloudLocate raw available? → YES → send raw measurement → server computes position
                             NO ↓
Degraded PVT available?    → YES → send degraded GPS fix → position known but imprecise
                             NO ↓
No data                    → send "no fix" → Doppler only on next surfacing

See 10 — GPS Guide § Part H for GNP51 (GNSS_CLOUDLOCATE_ALWAYS), GNP53 (GNSS_CLOUDLOCATE_ONLY), and REUSE_LAST strategy.


Part C — SURFACING_BURST first-TX latency optimizations

For SURFACING_BURST in particular (CLAUDE.md priority #3: first TX after surface must be FAST), several optimizations have been layered to bring end-to-end Doppler #1 latency from ~2.0 s down to ~155 ms in the warm case.

Optim Commit Saving Mechanism
Pre-warm Doppler packet 555c324f Build moved off the critical path prepare_doppler_packet() called at dive event (UW=true) and on each peer-event while underwater (battery + ADC available). Packet ready in RAM when surface arrives. Refresh threshold 1 h.
Skip state_idle on warm-packet path acf24506 ~40-100 ms When m_packet_buffer.length() > 0 at end of state_load_kmac, go straight to transmit_pending instead of routing through state_idle.
Shorten VDD discharge after dive acf24506 ~50-100 ms If now - m_last_power_off_ms >= 5 s (natural decay during dive), skip the redundant VDD discharge wait at next power-on. Sealed turtle case always satisfies this.
Cold-reboot STM32WL on every soft reset 6018ace9 Avoids cascade failure Eliminates the "looks like a regression but isn't" pattern where leftover STM32WL state from previous SPI session produces INVALID_CMD bursts.
Log demotions on hot paths e322fb26 (v4.1.8) ~5-10 min/day TX time recovered on 1-yr deployment argos_tx_service: Doppler/GNSS interval + 11 payload dumps; gps_service: lat/lon dumps. LFS commit ~50-300 ms per emit was happening on hot paths.
FastLoc → GNSS promotion 49687eec Real position beats Doppler when both eligible should_promote_doppler_to_gnss promotes fresh FastLoc/FIX in pile to fill what would be a Doppler slot. Applied at SURFACING_BURST phase 1, LEGACY/DUTY_CYCLE/PASS_PRED !gnss_en branch, DOPPLER mode global.
Spacing-guard (uptime-based) 49687eec Bounds back-to-back TX even with RTC reset m_last_tx_uptime_ms updated on TX-complete; apply_spacing_guard defers immediate-schedule sites (Doppler #1, GNSS TX #1 after fix) by surfacing_burst_init_s. Uptime-based via PMU::get_timestamp_ms() — RTC-immune.

End-to-end latency budget (typical sealed deployment, ANO fresh)

T+0      Surface event (SWS L1-L5 detection)
T+0      ArgosTxService::notify_peer_event(UW=false) → m_is_surfacing_burst=true
T+0      service_next_schedule_in_ms → return 0 → schedule_at(now)
T+5ms    process_doppler_burst() — pre-built packet retrieved
T+10ms   SmdSat::send() → power_on() → state_starting
T+25ms   state_powering_on (FAST 20 ms + VDD-decay skip)
T+55ms   state_idle_pending (SPI boot 30 ms)
T+115ms  state_load_kmac (TCXO=0 if warm, LPM, skip explicit KMAC reload)
T+125ms  state_transmit_pending (skip state_idle)
T+135ms  initiate_tx SPI cmd, VPA released
T+155ms  RF modulation ACTIVE on antenna  ← critical path end

Cold-path (full re-init, ANO stale): ~600-1400 ms — see Part D § Cold-path budget.

TCXO warmup optimization

TX TCXO warmup Reason
First TX after surfacing Skipped (0 s) Fastest first satellite contact. PA boot (160 ms) includes 50 ms TCXO stabilization.
All subsequent TX Config value (ARP35) Full warmup for optimal RF quality

Part D — SMD timing FAST/SAFE autofallback (SMD only)

On ARGOS_SMD=1 boards (LinkIt V4 SMD, RSPB), the SMD module's timing constants are runtime-selectable between FAST (default) and SAFE (v4.1.4 timings). All values live in ports/nrf52840/core/hardware/smd_sat/smd_sat_registers.hpp, switched by g_smdsat_use_safe_timings (set by SmdSat::degraded_mode_engage()).

KIM2 has no equivalent — the legacy CLS protocol has fixed timing and no co-processor with VDD-cap state leakage.

D.1 FAST vs SAFE table

Constant FAST SAFE Notes
SMDSAT_VDD_DISCHARGE 50 ms 100 ms Skipped if dive > 5 s (natural decay)
SMDSAT_DELAY_POWER_ON 20 ms 50 ms After SAT_PWR_EN HIGH
SMDSAT_SPI_BOOT_DELAY 30 ms 100 ms STM32WL SPI ready wait
SMDSAT_DELAY_LOAD_KMAC 50 ms 150 ms MAC poll retry interval
smdsat_first_tx_base_delay 150 ms 200 ms TCXO settle before 1st TX
SMDSAT_DELAY_STATE_TICK 15 ms 30 ms Scheduler reschedule between state ticks
SMDSAT_DELAY_CMD_MS 30 ms 60 ms Pre-SPI-cmd pacing
smdsat_spi_retry_delay 30 ms 100 ms After failed SPI cmd
SPI_INTER_TX_DELAY 10 ms 15 ms (bumped FAST 5 → 10 ms in b8a462c2)
READ_SPIMAC_STATE cmd_delay 150 ms 150 ms Bumped 30 → 100 → 150 ms (b8a462c2, 6018ace9) for STM async processing margin

D.2 Cumulative gain (FAST vs SAFE)

Path FAST SAFE Δ
First Doppler TX (warm SMD, RCONF already programmed) ~155 ms ~310 ms -150 ms
First Doppler TX (cold boot, full re-init) ~600-900 ms ~1.4 s -500 ms (typical)
Successive TXes in same burst ~250-300 ms ~400-500 ms -150-200 ms

Cold-path budget (cold boot, ANO stale)

T+0         Surface event
T+5 ms      ArgosTxService::notify_peer_event → schedule_at(now)
T+50 ms     SmdSat::send() → power_on() → state_starting
T+150 ms    state_powering_on (FAST + 500 ms VDD discharge from cold-reboot path)
T+650 ms    state_idle_pending (SPI boot 30 ms, KMAC reload needed)
T+850 ms    state_load_kmac complete
T+1.0 s     state_transmit_pending (TCXO warmup default 5 s — typically pre-warmed to 0 on warm packets, here cold)
T+~1.4 s    initiate_tx, RF active   ← cold-path end

The 500 ms VDD discharge runs once per soft reboot (commit 6018ace9 cold-reboots STM32WL on every soft reset) — invisible vs the multi-second service startup that follows.

D.3 Autofallback engagement

SMD_MAX_CONSECUTIVE_ERRORS=3 (lowered 5 → 3 in b8a462c2 to match ArgosTxService::DEVICE_ERROR_MAX_CONSECUTIVE so ArgosTx suspends and SmdSat flips to SAFE in lockstep).

At each state_error_enter(), m_error_count is incremented. On reaching 3:

  • Set g_smdsat_use_safe_timings = true → all accessors return SAFE values
  • Persist SMD_DEGRADED_MODE=1 (SMP00) to flash → survives WDT reset
  • Set m_cooldown_until = now + 30 min — SMD operations blocked during this window
  • Notify KineisEventDeviceError → ArgosTxService session suspended

degraded_mode_note_tx_cancelled_during_cascade() (b8a462c2): if state_transmitting consumed ≥ 25 % of its polling budget without success AND was externally cancelled (dive event mid-poll), counts as 1 toward the consecutive-error threshold. Otherwise dive-flapping devices would never accumulate enough timeout to trip the autofallback. Hooked into power_off_immediate() (dive path) and stop_send() (service_cancel path).

is_flash_op opcode whitelist (d692cd0b, SMD B1): READ_SPIMAC_STATE (now 150 ms) was previously misclassified as a flash op, enabling the NOP-with-seq-bump retry path that Zephyr-derived code warns "causes sequence number desync with STM32". The anti-cascade fix was therefore at risk of re-creating the cascade it was meant to suppress. Whitelisted by opcode (DFU_* / WRITE_*).

D.4 SAFE retest

After autofallback engaged, every successful TX in SAFE mode increments m_safe_mode_tx_count. Retest FAST when:

  • m_safe_mode_tx_count ≥ 20 (SAFE_RETEST_MIN_TX) AND
  • hours_in_safe ≥ m_safe_trust_window_hours (initial 1 h, doubles on each FAST retest failure, cap 24 h)

On retest success: g_smdsat_use_safe_timings = false, SMP00=0.

D.5 SMP00 DTE clear path (29af5362)

SMP00 flipped from read-only to writable to give operators a manual recovery path:

  • PARMW SMP00=0 accepted — clears SAFE mode immediately, next TX uses FAST
  • PARMW SMP00=1 rejected — manual engagement remains under exclusive autofallback control (3 consecutive cascade errors detected by the driver)
  • Lazy sync of g_smdsat_use_safe_timings from the persisted param at the top of send()SMP00=0 takes effect on next TX without reboot

A separate PARMW round-trip bug (c28ca4a3) was fixed: permitted_values={0U} on SMD_DEGRADED_MODE made the DTE decoder throw DTE_PROTOCOL_VALUE_OUT_OF_RANGE whenever the GUI wrote the slot back at its current value. The throw escaped the per-slot try/catch in PARMW_REQ (decoder runs BEFORE that loop), and was only caught at the top-level handler, which cleared the response. GUI then saw a timeout and "lost" the whole config read/write session. The "PARMW only accepts 0" policy is preserved by the explicit check already in PARMW_REQ (rejects via rejected_keys without throwing).

D.6 Cold-reboot gotcha (must respect on developer flash)

After a soft reset (nrfjprog --reset, FSM-triggered PMU::reset()), the STM32WL co-MCU keeps state on its VDD decoupling caps for ~50 ms. That leakage shows up in the next SPI session as INVALID_CMD cascades — and looks like a regression of whatever timing was just changed.

Before declaring an SMD timing reduction broken: power-cycle or hold reset. Commit 6018ace9 made the firmware cold-reboot the STM32WL on every soft reset (drives SAT_PWR_EN LOW, waits for cap discharge, then re-enables). Cold-reboot gotcha is now invisible at runtime but still applies to flashing during development — always power-cycle before declaring a regression.

D.7 Known persistence gaps (deferred)

  • F1: m_safe_trust_window_hours not persisted. After WDT reset in SAFE mode, window resets to 1 h regardless of where doubling had reached.
  • F2: m_safe_mode_since_ms stamped with PMU::get_timestamp_ms() (millis since boot), not RTC UTC → late reboot triggers a full new trust window.
  • F5: no WDT kick in write_credentials_from_config() (~720 ms blocking) — fits in 15-min budget but flagged for future SPI cmd growth.

Deferred because reboots on sealed turtles are rare.

D.8 Cooldown defensive fix (commit dbcca4fe)

is_tx_finished distinguishes MAC_TX_DONE from MAC_TX_TIMEOUT/MAC_ERROR. AFTER_LAST_TX cooldown no longer arms on failed TX. Modulation cast safety: read_radio_conf fixed STM32 KNS_tx_mod_t → SmdArgosModulation cast.

D.9 ArgosTxService consecutive-device-error reset

m_consecutive_device_errors was previously only reset on service_init = boot. On a single-boot multi-year deployment, 3 early errors would suspend TX permanently even after SmdSat 30-min cooldown expires and autofallback flips to SAFE. Now reset on every UW→surface transition (commit d692cd0b, argos_tx_service.cpp:537+).


Part E — Override modes (Low battery, Zone, HAULED)

E.1 Low battery mode

When battery drops below LB_THRESHOLD (%), the device switches to a parallel set of Argos parameters:

Normal param LB equivalent DTE Key Description
ARGOS_MODE LB_ARGOS_MODE LBP04 Mode in low battery
TR_NOM LB_ARGOS_TX_REPETITION LBP06 TX interval in LB
GNSS_EN LB_GNSS_EN LBP05 GPS in LB
DUTY_CYCLE LB_ARGOS_DUTY_CYCLE LBP07 Duty cycle mask in LB
ARGOS_DEPTH_PILE LB_ARGOS_DEPTH_PILE LBP08 Depth pile in LB
GNSS_ACQ_TIMEOUT LB_GNSS_ACQ_TIMEOUT LBP09 GPS timeout in LB
NTRY_PER_MESSAGE LB_NTRY_PER_MESSAGE LBP11 Retries in LB
SHUTDOWN_NTIME_SAT LB_SHUTDOWN_NTIME_SAT LBP14 Session TX limit in LB

Typical LB config for turtles: LB_ARGOS_MODE=5 (keep SURFACING_BURST), LB_GNSS_EN=0 (Doppler only), LB_ARGOS_TX_REPETITION=120 (double interval).

E.2 Zone mode (geofence)

When out-of-zone detection is enabled (ZONE_ENABLE_OUT_OF_ZONE_DETECTION_MODE=1), the device switches to zone-specific parameters outside the configured geofence:

Normal param Zone equivalent DTE Key
ARGOS_MODE ZONE_ARGOS_MODE ZOP11
TR_NOM ZONE_ARGOS_REPETITION_SECONDS ZOP10
DUTY_CYCLE ZONE_ARGOS_DUTY_CYCLE ZOP12
ARGOS_DEPTH_PILE ZONE_ARGOS_DEPTH_PILE ZOP08
NTRY_PER_MESSAGE ZONE_ARGOS_NTRY_PER_MESSAGE ZOP13
GNSS_ACQ_TIMEOUT ZONE_GNSS_ACQ_TIMEOUT ZOP17

E.3 HAULED mode

When the device has been dry for > HAULED_IDLE_THRESHOLD_H hours (default 24 h, opt-in via HAULED_DETECT_EN), the comm-side params are overridden to a conservative HAULED profile (typically LEGACY mode + 2 h interval + GNSS off or REUSE_LAST).

HMP10 HAULED_ARGOS_MODE allowed values were restricted (49687eec): SURFACING_BURST is not allowed — auto-promoted to LEGACY at config read for legacy persisted configs; DTE PARMW rejects value 5. Allowed: LEGACY, DUTY_CYCLE, DOPPLER, PASS_PREDICTION.

Cooldown / rate-limit gates still apply on top. See 13 — Underwater & Behavioral Modes2 § E for full state machine.

E.4 Rolling rate limiter — applies to ALL TX (RLP01–RLP03)

A rolling-window TX cap to bound battery usage in edge scenarios where the device would otherwise burst more often than the deployment budget allows. Off by default — opt-in only.

Added: Plan 1, commit a54a06f2 (2026-05). MAX_CAP bumped 32 → 128 in 143aedc9. Source: core/services/rate_limiter.cpp/hpp.

Motivation

LinKit v4 already has several TX-cadence mechanisms: ARGOS_TX_REPETITION, MIN_SURFACE_CYCLE_INTERVAL_S, SURFACING_BURST_INITIAL_INTERVAL / STEP / MAX, COOLDOWN_TRIGGER_MODE. These are per-event or per-cycle. None enforces a total budget over time — a device that detects 50 surfaces in 12 hours could fire 50 cycles regardless of any per-cycle setting. The Rate Limiter caps that.

Use cases:

  • Predictable battery budget: cap at e.g. 10 TX/hour → known max current draw
  • CLS contract compliance: Argos contracts often specify max messages/day per device
  • Storm scenarios: floating debris with the tracker bouncing surface/UW dozens of times can otherwise produce sustained TX bursts

The rate limiter is always applied when enabled — including the first ping of a SURFACING_BURST. This is the single Plan 1 exception to priority #3 (first-TX-fast): when battery (priority 2) and first-TX-fast conflict, battery wins. Documented in CLAUDE.md §5.

Mechanism — sliding window (noinit + CRC16)

struct RateLimiterNoinit {
    std::time_t ring[MAX_CAP];     // up to 128 timestamps (compile-time bound)
    uint16_t    count;             // current entries
    uint16_t    head;              // next write index
    uint16_t    crc;               // CRC16 over the above
};

MAX_CAP = 128 is the hard upper bound on RATE_LIMIT_MAX_TX. Noinit footprint: 1024 bytes ring + 8 bytes meta + CRC.

RateLimiter::record_tx(now) is called at every Argos TX (just before send). When enabled: push now to ring, compute CRC, save struct, persist to .noinit (survives WDT, lost on power-off). When disabled (RATE_LIMIT_EN=0), this is a no-op.

RateLimiter::is_blocked(now, &reschedule_in_s) is consulted at the top of ArgosTxService::service_next_schedule_in_ms. Returns true if RATE_LIMIT_EN = true AND count of timestamps in [now - RATE_LIMIT_WINDOW_S, now]RATE_LIMIT_MAX_TX. When blocked, reschedule_in_s is the time until the oldest in-window TX falls out — the soonest moment one slot becomes free.

RTC robustness

Works with the virtual RTC fallback because it uses relative-time deltas only (now − ring[i]). On virtual → real epoch transition, reset_for_rtc_sync() clears the ring — otherwise old virtual-time timestamps would appear billions of seconds in the past after the jump.

Parameters

Key Name Encoding Default Range Meaning
RLP01 RATE_LIMIT_EN BOOL false 0/1 Master enable. When false, the rate limiter is a no-op.
RLP02 RATE_LIMIT_WINDOW_S UINT 3600 60–86400 Sliding window duration (s).
RLP03 RATE_LIMIT_MAX_TX UINT 10 1–128 Max TX allowed in a rolling window. Capped at MAX_CAP=128.

Example configurations

  • Default (10 TX/h): RLP01=1, RLP02=3600, RLP03=10 — typical Argos contract floor
  • Hourly burst floor (30/h): RLP02=3600, RLP03=30 — higher-budget deployment
  • Daily cap (100/day): RLP02=86400, RLP03=100 — multi-day rolling window
  • Maximum (128/day): RLP02=86400, RLP03=128 — at MAX_CAP
  • Off (default): RLP01=0 — relies on per-cycle cadence only

Interaction with cooldown / HAULED / SURFACING_BURST

With cooldown — independent and stacked. Both must pass:

service_next_schedule_in_ms():
    if (is_in_cooldown(now))             return SCHEDULE_DISABLED
    if (RateLimiter::is_blocked(now, ...)) return time_until_slot_free * 1000
    ... (normal scheduling)

With HAULEDHAULED_ARGOS_MODE override applies but rate limiter stays active.

With SURFACING_BURST — applies to every TX including the immediate Doppler #1. If the limiter is blocking, the surface event is lost as a "passive surfacing" (counted via ServiceManager::notify_passive_surfacing()) and no TX fires. Next surface event will retry.

State persistence

Event Ring buffer
Normal TX Push timestamp
Disable mid-deploy (RLP01=0) Ring kept, no new writes; queries return false
Re-enable Ring resumes counting
WDT reset Survives via .noinit + CRC16
Power-off (storage mode) Lost
RTC virtual → real Ring cleared (reset_for_rtc_sync())
CRC mismatch at boot Ring zeroed
All entries > now at restore Cleared

Debugging counters

  • RateLimiter::s_noinit.count — current entries in ring (compare with RLP03)
  • passive_count — surface events lost to rate-limit gate

If passive_count > 0 in field data: device is configured tighter than actual surface frequency. Either increase RLP03 or reconsider deployment plan. If passive_count = 0 after a month with RLP01=1: limit isn't biting → maybe over-conservative.

E.5 Spacing-guard (uptime-based, RTC-immune)

Added in Plan 1 follow-up (49687eec). m_last_tx_uptime_ms in ArgosTxService is updated on TX-complete; apply_spacing_guard defers immediate-schedule sites (Doppler #1, GNSS TX #1 after fix) by surfacing_burst_init_s.

Uptime-based (uses PMU::get_timestamp_ms()), immune to RTC reset. Complements the rate limiter — guarantees minimum spacing even on virtual-RTC sealed devices that haven't synced. Where the rate limiter caps total volume in a rolling window, the spacing-guard prevents back-to-back firing of the same immediate-schedule site (e.g. two Doppler #1 within milliseconds across an RTC discontinuity).

E.6 REUSE_LAST GNSS strategy

When gnss_strategy = BaseGnssStrategy::REUSE_LAST (e.g. via HMP13=1 in HAULED, or any future per-phase override from Plan 2), the depth-pile cache is consulted instead of powering on GPS. A cached fix older than GNSS_REUSE_FIX_MAX_AGE_S (GNP50, default 24 h) is rejected, and the firmware falls back to Doppler-only TX automatically. Standard low-power positioning path when the animal is unlikely to have moved.


Part F — Message formats (bit layouts)

All packets are packed MSB-first (big endian) using variable-length bit fields. The firmware uses PACK_BITS() from core/util/bitpack.hpp to build packets sequentially.

F.1 Message type IDs (3-bit header)

All packets use a 3-bit type header as the first field (MSB). The receiver uses this header + packet size to identify the message type.

Type ID Binary Name Modulation Max Bits When Used
0 000 GNSS Short LDK 96 Single GPS fix + heading + altitude
0 000 GNSS Long LDA2 192 Multi-fix GPS pile (header shared with Short, disambiguated by 24 B vs 12 B size)
1 001 Sensor LDA2 192 GPS + environmental sensors (LinkIt generic)
2 010 Fastloc LDA2 192 Degraded GPS fix + quality metadata (GNP45=1)
3 011 Doppler VLDA4 24 No GPS, battery only
4 100 RSPB Long LDA2 192 RSPB: GPS + all sensors + AXL XYZ + mortality
5 101 RSPB Short LDK 122 RSPB: GPS + compact sensors + mortality
6 110 RSPB Doppler VLDA4 24 RSPB: battery SOC + activity + mortality
7 111 CloudLocate LDK/LDA2 128/192 Raw GNSS measurement blob for cloud positioning (GNP45=2)

Modulation sizes: VLDA4 = 24 bits (3 bytes), LDK = 128 bits (16 bytes), LDA2 = 192 bits (24 bytes).

When adaptive modulation (ARP54=true) is enabled, the firmware auto-selects modulation based on packet type. Otherwise, LDA2 is used for all packets.

F.2 Error correction & CRC

  • LDK and VLDA4 modulations: CRC8 and BCH FEC are added by the satellite module (SMD or KIM2). The firmware payload is the raw user data only.
  • LDA2 modulation: the satellite module does not add CRC on air. To detect corruption, the firmware embeds a CRC8 in byte 23 (last byte of every 192-bit LDA2 frame). CRC computed by CRC8::checksum over bits 0..183 (bytes 0..22) — Argos-specific CRC8 with polynomial equivalent 0x1070 << 3 (= 0x8380), init 0, calculated bit-by-bit MSB-first. All five LDA2 message types carry this byte-23 CRC: Long, Sensor, Fastloc, RSPB Long, and CloudLocate MEAS20. Receivers MUST verify CRC8 and discard invalid frames.
def lda2_crc8(payload23: bytes) -> int:
    """Argos LDA2 CRC8 over the first 23 bytes (184 bits) of a frame."""
    crc = 0
    for b in payload23:
        crc ^= b << 8
        for _ in range(8):
            if crc & 0x8000:
                crc ^= 0x8380  # = 0x1070 << 3
            crc = (crc << 1) & 0xFFFF
    return (crc >> 8) & 0xFF

def verify_lda2(frame24: bytes) -> bool:
    assert len(frame24) == 24
    return lda2_crc8(frame24[:23]) == frame24[23]

F.3 Short Packet (GNSS Single Fix)

Type ID: 0 (000). 96 bits / 12 bytes (LDK modulation). Used when ARGOS_DEPTH_PILE=1 or only one fix is available. Header 000 shared with Long Packet — receivers disambiguate by frame size (12 B LDK = Short, 24 B LDA2 = Long).

Bit Layout

Bit Offset Width Field Encoding
0 3 Header 0b000
3 5 Day Day of month (1-31)
8 5 Hour Hour (0-23)
13 6 Minute Minute (0-59)
19 21 Latitude See GPS Encoding
40 22 Longitude See GPS Encoding
62 7 Speed (ground_speed_m_s * 3600) / 2000000
69 1 Out-of-zone 1 = device outside geofence
70 8 Heading heading_degrees / 1.42
78 8 Altitude altitude_mm / (1000 * 40). 255 = no 3D fix
86 7 Battery (voltage_mV - 2700) / 20 (0-127)
93 1 Low battery 1 = battery below threshold
94 2 (padding) Unused (zeros)

No valid GPS fix: Latitude, longitude, and speed fields are filled with all 1s (0x1FFFFF, 0x3FFFFF, 0x7F).

Decoding Example (Python)

def decode_short_packet(data: bytes):
    """Decode a 12-byte Argos short packet."""
    bits = int.from_bytes(data, 'big')
    total = 96

    def extract(offset, width):
        return (bits >> (total - offset - width)) & ((1 << width) - 1)

    header   = extract(0, 3)    # Should be 0b000
    day      = extract(3, 5)
    hour     = extract(8, 5)
    minute   = extract(13, 6)
    lat_raw  = extract(19, 21)
    lon_raw  = extract(40, 22)
    speed_r  = extract(62, 7)
    ooz      = extract(69, 1)
    heading_r= extract(70, 8)
    alt_raw  = extract(78, 8)
    batt_raw = extract(86, 7)
    low_batt = extract(93, 1)

    # Decode latitude (21 bits, bit 20 = sign)
    if lat_raw & (1 << 20):
        lat = -((lat_raw & 0xFFFFF) / 10000.0)
    else:
        lat = lat_raw / 10000.0

    # Decode longitude (22 bits, bit 21 = sign)
    if lon_raw & (1 << 21):
        lon = -((lon_raw & 0x1FFFFF) / 10000.0)
    else:
        lon = lon_raw / 10000.0

    speed_kmh   = speed_r * 2000000 / 3600  # Back to mm/s, then /1000 for m/s
    heading_deg = heading_r * 1.42
    altitude_m  = alt_raw * 40 if alt_raw != 255 else None
    voltage_mV  = batt_raw * 20 + 2700

    # Check for invalid fix (all 1s)
    valid = not (lat_raw == 0x1FFFFF and lon_raw == 0x3FFFFF)

    return {
        'day': day, 'hour': hour, 'minute': minute,
        'latitude': lat if valid else None,
        'longitude': lon if valid else None,
        'speed_m_s': speed_kmh / 1000 if valid else None,
        'heading_deg': heading_deg,
        'altitude_m': altitude_m,
        'battery_mV': voltage_mV,
        'low_battery': bool(low_batt),
        'out_of_zone': bool(ooz),
        'valid': valid,
    }

F.4 Long Packet (GNSS Multi-Fix)

192 bits / 24 bytes (LDA2). Used when ARGOS_DEPTH_PILE > 1 and multiple fixes are available. Packs up to 3 GPS entries per message.

Breaking change (firmware vNext): previously 224 bits / 28 bytes with 4 GPS entries. The 4th entry was already silently truncated by the SMD module to fit the 24-byte LDA2 frame, and the new firmware adds an 8-bit CRC at byte 23 + a 3-bit type header at bit 0 — so GPS[3] is dropped outright and the packet is now exactly 24 bytes.

Bit Layout

Bit Offset Width Field Notes
0 3 Header 0b000 (Type 0, shared with Short Packet)
3 5 Day Day of most recent fix
8 5 Hour
13 6 Minute
19 21 GPS[0] Latitude Most recent fix
40 22 GPS[0] Longitude
62 7 GPS[0] Speed
69 1 Out-of-zone
70 7 Battery voltage
77 1 Low battery
78 4 Delta-time-loc Time interval code between GPS entries
82 21 GPS[1] Latitude
103 22 GPS[1] Longitude
125 21 GPS[2] Latitude
146 22 GPS[2] Longitude
168 16 (reserved) Zero-padded
184 8 CRC8 Argos LDA2 CRC8 over bits 0..183

Header 000 is shared with Short Packet — receivers disambiguate by frame size (12 B LDK = Short, 24 B LDA2 = Long). GPS entries are stored most recent first. By default (version=0) the time of GPS[N] = timestamp - (N × delta_time_loc).

Note. The short-lived "v2 skip fields" extension (bit 168 format-version + per-position skips, firmware 2026-06) was removed 2026-07: the frame is the plain legacy layout, bits 168+ are always zero and dating is the uniform −N×Δ rule.

Depth pile drainage

With the 3-fix limit, deeper piles need more transmissions. With ARGOS_DEPTH_PILE = N, the pile is split into ceil(N/3) slots; slots that contain only one fix are emitted as short packets (LDK 12 bytes) instead of long packets.

ARGOS_DEPTH_PILE Slots Per-slot fix count Modulation per slot
1 1 1 LDK
2 1 2 LDA2
3 1 3 LDA2
4 2 3 + 1 LDA2 + LDK
8 3 3 + 3 + 2 LDA2 ×3
24 8 3 ×8 LDA2 ×8

Delta-Time-Loc Codes (4 bits, 0-15)

Code Interval Code Interval
1 10 min 9 12 hours
2 15 min 10 24 hours
3 30 min 11 1 min
4 1 hour 12 2 min
5 2 hours 13 5 min
6 3 hours 14 20 min
7 4 hours 15 45 min
8 6 hours

Controlled by ARP11 (DLOC_ARG_NOM).

Breaking change v4.0.5: Codes 6-10 re-mapped. Old: 6=6h, 7=12h, 8=24h. New: 6=3h, 7=4h, 8=6h, 9=12h, 10=24h. Codes 11-15 are new.

Decoding Example (Python)

def decode_gnss_long_packet(data: bytes):
    """Decode a 24-byte GNSS Long packet (LDA2, 192 bits with CRC8 byte 23)."""
    if len(data) != 24 or not verify_lda2(data):
        return {'error': 'invalid LDA2 frame (size or CRC8)'}

    bits = int.from_bytes(data, 'big')
    pos = 0
    def extract(width):
        nonlocal pos
        v = (bits >> (192 - pos - width)) & ((1 << width) - 1)
        pos += width
        return v

    if extract(3) != 0b000:
        return {'error': 'unexpected header (expected 000)'}

    result = {'day': extract(5), 'hour': extract(5), 'minute': extract(6), 'gps': []}
    # GPS[0] full
    lat0, lon0 = extract(21), extract(22)
    result['gps'].append({
        'lat': decode_latitude(lat0),
        'lon': decode_longitude(lon0),
        'speed_m_s': extract(7) * 2000000 / 3600 / 1000,
    })
    result['out_of_zone'] = bool(extract(1))
    result['battery_mV']  = extract(7) * 20 + 2700
    result['low_battery'] = bool(extract(1))
    result['delta_time_loc'] = extract(4)
    # GPS[1..2] lat/lon only
    for i in range(2):
        lat, lon = extract(21), extract(22)
        if lat == 0x1FFFFF and lon == 0x3FFFFF:
            result['gps'].append(None)
        else:
            result['gps'].append({'lat': decode_latitude(lat), 'lon': decode_longitude(lon)})
    # Per-entry time (most-recent-first), uniform grid:
    #   date(GPS[N]) = base - N * delta_time_loc
    return result

F.5 Sensor Packet (GNSS + Sensors)

192 bits / 24 bytes (LDA2). Combines one GPS fix with environmental sensor data. Used when any sensor has ENABLE_TX_MODE != OFF.

The frame is always emitted at full LDA2 size so that CRC8 lands at byte 23. Useful sensor data fits in 184 bits (LDA2_DATA_BITS). The adaptive LDK fallback for tiny sensor packets is disabled — sensor packets always go on LDA2.

Base Fields (always present)

Bit Offset Width Field Encoding
0 3 Header 0b001 (Type 1)
3 5 Day
8 5 Hour
13 6 Minute
19 21 Latitude
40 22 Longitude
62 7 Speed
69 1 Out-of-zone
70 7 Battery voltage
77 1 Low battery
78 5 Sensor mask MSB-first: ALS, PH, Pressure, SeaTemp, AXL (1=present, 0=absent)

= 83 bits base + mask (3-bit header + 75 bits payload + 5-bit sensor mask). Sensor fields appended starting at bit 83 in the order ALS → PH → Pressure → SeaTemp → AXL, only for the bits set in the mask. The mask makes the packet self-describing — receivers no longer need an external sensor-config DB.

Sensor Fields (appended in order if enabled with TX mode)

Sensor Width Pre-encoding Decode Formula
ALS (ambient light) 17 bits Raw ADC value value (lumens raw)
pH 14 bits pH * 1000 value / 1000.0
Pressure (bar) 15 bits pressure_hPa * 1000 value / 1000.0 (hPa)
Pressure (temp) 14 bits (temp_C + 40) * 100 value / 100.0 - 40.0 (°C)
Sea temperature 21 bits (temp_C + 126) * 1000 value / 1000.0 - 126.0 (°C)
Thermistor (RSPB) 14 bits (temp_C + 40) * 100 value / 100.0 - 40.0 (°C)
AXL temp 14 bits (temp_C + 40) * 100 value / 100.0 - 40.0 (°C)
AXL X-axis 15 bits (accel_g + g_range) * 1000 value / 1000.0 - g_range (g)
AXL Y-axis 15 bits (same) (same)
AXL Z-axis 15 bits (same) (same)
AXL activity 8 bits Raw (0-255) value
Mortality confidence 7 bits Raw (0-100 %) value (%)

g_range depends on AXP08 (AXL_SENSOR_MEASUREMENT_RANGE): 0=2.0g, 1=4.0g, 2=8.0g, 3=16.0g.

Board-specific variants

RSPB (bird tracker):

  • Thermistor uses 14 bits (offset +40, scale ×100) instead of sea temp's 21 bits
  • Accelerometer: activity only (8 bits) — X/Y/Z axes and temp are dropped
  • Mortality confidence appended (7 bits) if ENABLE_MORTALITY_SENSOR

LinkIt V4 (marine):

  • Sea temperature uses 21 bits (offset +126, scale ×1000)
  • Accelerometer: full format = temp(14) + X(15) + Y(15) + Z(15) + activity(8) = 67 bits
  • No mortality field

AXL temperature redundancy rule

When AXL is included, its die temperature (14 bits) is included only if no other temperature source is in the packet:

  • Pressure present → Pressure carries temperature. AXL temp dropped.
  • SeaTemp/Thermistor present → temperature from that 21-bit field. AXL temp dropped.
  • Neither → AXL temperature is the only source, kept.

If the total still exceeds 184 data bits, the firmware truncates from AXL activity LSBs (preserving XYZ as the primary AXL signal). The only realistic combo where this happens is Pressure + SeaTemp + AXL, truncating AXL activity from 8 to 6 bits (range 0-63 instead of 0-255).

Size examples

Configuration Bits
GPS only 83
GPS + Pressure 83 + 29 = 112
GPS + Pressure + AXL (AXL temp dropped) 83 + 29 + 53 = 165
GPS + AXL only (AXL temp kept) 83 + 67 = 150
GPS + ALS + pH + Pressure + Sea Temp 83 + 17 + 14 + 29 + 21 = 164
GPS + Pressure + Sea Temp + AXL (activity truncated 8→6) 83 + 29 + 21 + 53 = 186 → 184
GPS + ALS + pH + Pressure + Sea Temp + AXL (severe truncation) 217

RSPB Sensor Packet (Fixed Layout)

The RSPB bird tracker uses a fixed sensor combination: Pressure + Thermistor + AXL (compact) + Mortality. Produces a predictable 133-bit packet on every transmission.

Bit Offset Width Field Encoding
0 3 Header 0b001 (Type 1)
3 5 Day
8 5 Hour
13 6 Minute
19 21 Latitude
40 22 Longitude
62 7 Speed
69 1 Out-of-zone
70 7 Battery voltage
77 1 Low battery
78 15 Pressure (hPa) pressure_hPa * 1000
93 14 Pressure temp (temp_C + 40) * 100
107 14 Body temperature (temp_C + 40) * 100
121 8 AXL activity Raw (0-255)
129 7 Mortality confidence Raw (0-100)

Total: 136 bits / 17 bytes — 48 bits free before CRC8 at byte 23.

Body temperature (thermistor): NTC sensor. Live bird: ~38-42 °C. Dead bird converges to ambient (~10-25 °C). Below MORTALITY_TEMP_THRESH (default 25 °C) scores 30 points toward mortality.

AXL activity (compact): BMA400 activity score (0-255). At rest: ~0-5, walking: ~20-50, flying: ~100+. Below MORTALITY_ACTIVITY_THRESH (default 10) scores 40 points toward mortality.

Mortality confidence: EMA-smoothed (0-100%). 0-49%: ALIVE, 50-79%: SUSPECTED, 80-100% sustained over MORTALITY_CONFIRM_DAYS (default 3): CONFIRMED.

Generic decoder (Python)

def decode_sensor_packet(data: bytes, g_range: float = 2.0):
    """Self-describing Argos sensor packet (LDA2, 24 bytes).
    No external sensor config needed — the 5-bit mask at offset 78 tells the decoder
    which sensors are present.
    """
    if len(data) != 24 or not verify_lda2(data):
        return {'error': 'invalid LDA2 frame (size or CRC8)'}

    bits = int.from_bytes(data, 'big')
    total = len(data) * 8
    pos = 0

    def extract(width):
        nonlocal pos
        val = (bits >> (total - pos - width)) & ((1 << width) - 1)
        pos += width
        return val

    result = {}
    header = extract(3)
    if header != 0b001:
        return {'error': f'unexpected header {header:03b} (expected 001)'}
    result['day']       = extract(5)
    result['hour']      = extract(5)
    result['minute']    = extract(6)

    lat_raw = extract(21)
    lon_raw = extract(22)
    result['latitude']  = decode_latitude(lat_raw)
    result['longitude'] = decode_longitude(lon_raw)
    result['speed']     = extract(7) * 2000000 / 3600 / 1000  # m/s
    result['out_of_zone'] = bool(extract(1))
    result['battery_mV'] = extract(7) * 20 + 2700
    result['low_battery'] = bool(extract(1))

    # 5-bit sensor mask (MSB-first): ALS, PH, Pressure, SeaTemp, AXL
    mask = extract(5)
    has_als      = bool(mask & 0b10000)
    has_ph       = bool(mask & 0b01000)
    has_pressure = bool(mask & 0b00100)
    has_seatemp  = bool(mask & 0b00010)
    has_axl      = bool(mask & 0b00001)
    result['sensor_mask'] = mask

    if has_als:
        result['als_raw'] = extract(17)
    if has_ph:
        result['ph'] = extract(14) / 1000.0
    if has_pressure:
        result['pressure_hPa'] = extract(15) / 1000.0
        result['pressure_temp_C'] = extract(14) / 100.0 - 40.0
    if has_seatemp:
        # SeaTemp on LinkIt V4 (marine), Thermistor on RSPB — same 21-bit slot
        result['sea_temp_or_thermistor_C'] = extract(21) / 1000.0 - 126.0
    if has_axl:
        # AXL temperature only if no other temperature source
        if not has_pressure and not has_seatemp:
            result['axl_temp_C'] = extract(14) / 100.0 - 40.0
        result['axl_x_g'] = extract(15) / 1000.0 - g_range
        result['axl_y_g'] = extract(15) / 1000.0 - g_range
        result['axl_z_g'] = extract(15) / 1000.0 - g_range
        activity_bits = min(8, 184 - pos)
        if activity_bits > 0:
            raw = extract(activity_bits)
            result['axl_activity'] = raw << (8 - activity_bits)  # left-align if truncated
            result['axl_activity_resolution_bits'] = activity_bits

    return result


def decode_latitude(raw):
    if raw & (1 << 20):
        return -((raw & 0xFFFFF) / 10000.0)
    return raw / 10000.0

def decode_longitude(raw):
    if raw & (1 << 21):
        return -((raw & 0x1FFFFF) / 10000.0)
    return raw / 10000.0

F.6 Doppler Packet (No GNSS)

Type ID: 3 (011). 24 bits / 3 bytes (VLDA4). Minimal packet with battery info only. Used in SURFACING_BURST before GPS fix, DOPPLER mode (ARP01=4), or Low battery mode with LB_GNSS_EN=0.

Bit Offset Width Field Encoding
0 8 Last position index Usually 0
8 7 Battery voltage (voltage_mV - 2700) / 20
15 1 Low battery 1 = low battery
16 8 (reserved) Zero-padded

CRC8 is added by the SMD/KIM2 module on the air (VLDA4 framing) and stripped before the user payload reaches the decoder — the firmware payload itself has no CRC field.

F.7 Fastloc Packet (Degraded GNSS)

Type ID: 2 (010). 192 bits / 24 bytes (LDA2). Sent when GNP45=1 (GNSS_FASTLOC_MODE=DEGRADED_PVT) and the GPS obtained a 2D/3D fix that failed quality filters (hAcc or hDOP too high).

When is it sent?

  • GNSS acquisition timeout expires (GNP05 or GNP09)
  • GPS obtained at least one 2D or 3D fix during the session
  • That fix failed the hAcc filter (GNP21) or hDOP filter (GNP03)
  • GNP45 >= 1 (GNSS_FASTLOC_MODE = DEGRADED_PVT or CLOUDLOCATE)

If multiple degraded fixes were obtained, the one with the lowest hAcc is selected.

Bit Layout

Bit Offset Width Field Encoding
0 3 Header 0b010 (type 2)
3 5 Day
8 5 Hour
13 6 Minute
19 21 Latitude
40 22 Longitude
62 7 Speed
69 8 Heading
77 8 Altitude
85 7 Battery
92 1 Low battery
93 2 Fix type 0=none, 1=dead reckoning, 2=2D, 3=3D
95 4 Num satellites 0-15 (capped)
99 16 hAcc (m) Horizontal accuracy (0-65535)
115 16 vAcc (m) Vertical accuracy (0-65535)
131 8 pDOP ×10 Position DOP × 10 (0-25.5)
139 8 hDOP ×10 Horizontal DOP × 10 (0-25.5)
147 10 GPS on time (s) Seconds GPS was powered (0-1023)
157 27 (reserved) Zero-filled
184 8 CRC8 LDA2 CRC8 over bits 0..183

Total: 192 bits / 24 bytes (157 useful + 27 reserved + 8 CRC).

The receiver identifies a Fastloc packet by header = 010 AND packet size = 192 bits (LDA2).

F.8 CloudLocate Packet (Raw GNSS Measurements)

Type ID: 7 (111). Variable size (128 or 192 bits). Sent when GNP45=2 (GNSS_FASTLOC_MODE=CLOUDLOCATE). Instead of computing a position on-device, the tracker captures a raw GNSS measurement snapshot from the u-blox M10 and transmits it as an opaque blob. The u-blox CloudLocate cloud service resolves the position server-side.

Format Constraints

Format Size Argos VLDA4 Argos LDK Argos LDA2 LoRa
MEASC12 12 B No Yes Yes Yes
MEAS20 20 B No No Yes Yes
MEAS50 50 B No No No Yes (LoRa only)
  • MEASC12 (12 B): Most compact. Requires position hint. Compatible with sensor messages on Argos (93 bits remaining).
  • MEAS20 (20 B): Autonomous (no position hint needed). Dedicated packet, no sensor data on Argos.
  • MEAS50 (50 B): LoRa only. Highest accuracy. Automatically falls back to MEAS20 on Argos.

Bit Layout — MEASC12 Format (LDK, 128 bits)

Bit Offset Width Field Notes
0 3 Header 0b111 (type 7)
3 2 Format 00 = MEASC12
5 96 MEASC12 blob 12 bytes opaque u-blox data
101 7 Battery (voltage_mV - 2700) / 20
108 1 Low battery 1 = below threshold
109 1 Time-present flag 1 = capture time follows. 0 = legacy frame, no time (decode as before)
110 17 Seconds-of-day UTC seconds since midnight (0..86399) of the measurement capture, only if flag=1. Combine with the day from the Argos Doppler pass for the absolute instant.
127 1 (reserved) Zero-filled

Total: 128 bits / 16 bytes (LDK modulation).

Backward compatibility (2026-06): the time-present flag occupies bit 109, which legacy firmware always left 0 (zero-padding). A decoder reads bit 109: 0 → legacy frame, ignore time; 1 → read the 17-bit seconds-of-day. Old archived frames decode unchanged.

Bit Layout — MEAS20 Format (LDA2, 192 bits)

Bit Offset Width Field Notes
0 3 Header 0b111
3 2 Format 01 = MEAS20
5 160 MEAS20 blob 20 bytes opaque u-blox data
165 7 Battery
172 1 Low battery
173 1 Time-present flag 1 = capture age follows. 0 = legacy frame, no time
174 10 Capture age Seconds between capture and TX (0..1023, ~17 min), only if flag=1. Absolute instant = Argos reception time − age. MEAS20 uses age (not seconds-of-day) because only 11 bits fit before the CRC8 — the cloud already knows the reception time.
184 8 CRC8 LDA2 CRC8 over bits 0..183

Total: 192 bits / 24 bytes (LDA2).

Backward compatibility (2026-06): bit 173 was always 0 in legacy frames (zero-padding before CRC8). Decoders read it: 0 → no time (legacy); 1 → 10-bit age.

The MEASC12 variant (LDK, 128 bits) does not carry a firmware CRC — the SMD/KIM2 module adds its own.

Cloud Resolution

POST https://locationapi.services.u-blox.com/v1/unipos/s2s/location/get
Authorization: Bearer <GNP31 token>
Content-Type: application/json

{
    "GNSSMeasurements": "<base64-encoded blob>",
    "DateTime": "<ISO8601 timestamp of satellite reception>"
}

For MEASC12, also provide ApproximateLocation. Returns: {"Location": {"Lat": ..., "Lon": ..., "Unc68": 50.0}}.

Sensor Data Compatibility

Format Sensor data in Argos packet? Sensor data in LoRa packet?
MEASC12 Yes (93 bits available) Yes
MEAS20 No (29 bits — battery only) Yes
MEAS50 N/A (Argos incompatible) Yes

When sensor data is not available in the packet, a DEBUG_WARN is emitted at boot.

F.9 RSPB Long Packet (Bird Tracker)

Type ID: 4 (100). 192 bits / 24 bytes (LDA2). 181 useful data bits, 3 reserved, then CRC8 at byte 23. RSPB-dedicated format with GPS, full pressure (value + temp), body temperature, accelerometer X/Y/Z + activity, and mortality confidence. Used when RSP01 = 0 (RSPB_LONG).

Bit Offset Width Field
0 3 Header 0b100 (type 4)
3 5 Day
8 5 Hour
13 6 Minute
19 21 Latitude
40 22 Longitude
62 7 Speed
69 1 Out-of-zone
70 7 Battery voltage
77 1 Low battery
78 15 Pressure
93 14 Pressure temp
107 14 Body temp (thermistor)
121 15 AXL X
136 15 AXL Y
151 15 AXL Z
166 8 AXL activity
174 7 Mortality confidence
181 3 (reserved)
184 8 CRC8

Total: 192 bits / 24 bytes (181 useful + 3 reserved + 8 CRC).

F.10 RSPB Short Packet (Bird Tracker, Compact)

Type ID: 5 (101). 122 bits / 16 bytes (LDK). Same as RSPB Long but without pressure temperature (14 bits saved). Used when RSP01 = 1 (RSPB_SHORT).

Bit Offset Width Field
0 3 Header 0b101 (type 5)
3 5 Day
8 5 Hour
13 6 Minute
19 21 Latitude
40 22 Longitude
62 7 Speed
69 1 Out-of-zone
70 7 Battery voltage
77 1 Low battery
78 15 Pressure (no temp)
93 14 Body temp (thermistor)
107 8 AXL activity
115 7 Mortality confidence

Total: 122 bits. Fits in LDK (128 bits max) with 6 bits spare.

F.11 RSPB Doppler Packet

Type ID: 6 (110). 24 bits / 3 bytes (VLDA4). RSPB-specific Doppler with bird status data. Used when mortality detection enabled and no GPS fix (LB mode, Doppler-only).

Bit Offset Width Field Encoding Decode
0 3 Header 0b110 (type 6) RSPB Doppler
3 7 Battery SOC Raw percentage (0-100) raw %
10 7 Activity activity_score / 2 (0-127) raw * 2 (0-254)
17 7 Mortality confidence Raw percentage (0-100) raw %

Total: 24 bits. Fills VLDA4 exactly.

vs Standard Doppler (Type 3): Standard sends battery voltage (7b) + low battery flag (1b) + 8 bits unused. RSPB Doppler replaces this with SOC% + activity + mortality — complete bird status in 3 bytes.

Mortality without GPS: In LB mode, the mortality algorithm runs with activity + temperature only (no GPS stationarity). Confidence can reach ~70% max without GPS. See 14 — RSPB Mortality Tracker.

F.12 Certification Packet

Custom payload for Argos TX power and frequency certification. Not used in production. Controlled by CTP01-CTP04.

F.13 GPS coordinate encoding

Used identically by all Argos packets (and LoRa).

Latitude (21 bits):

  • Positive (North): encoded = latitude * 10000
  • Negative (South): encoded = (|latitude| * 10000) | (1 << 20)
  • Resolution: 0.0001° (~11.1 meters)

Longitude (22 bits):

  • Positive (East): encoded = longitude * 10000
  • Negative (West): encoded = (|longitude| * 10000) | (1 << 21)
  • Resolution: 0.0001° (~11.1 meters at equator)

Decode:

if (bit 20 set for lat / bit 21 set for lon):
    value = -(raw & mask) / 10000.0
else:
    value = raw / 10000.0

F.14 Common field encodings

Field Bits Encode Decode Unit Range
Battery voltage 7 (mV - 2700) / 20 raw * 20 + 2700 mV 2700-5240
Speed 7 (m/s * 3600) / 2000000 raw * 2000000 / 3600 mm/s 0-127 units
Heading 8 degrees / 1.42 raw * 1.42 degrees 0-360
Altitude 8 mm / (1000 * 40) raw * 40 meters 0-10160 m, 255 = invalid
Latitude 21 See above See above degrees ±90
Longitude 22 See above See above degrees ±180

F.15 Quick reference: decode all sensor values

Sensor Bits Encoded As Decode to Physical Range
ALS 17 Raw ADC raw lumens 0-131071
pH 14 pH * 1000 raw / 1000 0.000-16.383
Pressure 15 hPa * 1000 raw / 1000 hPa 0-32.767 hPa
Pressure temp 14 (°C + 40) * 100 raw / 100 - 40 °C -40 to +123.83 °C
Sea temperature 21 (°C + 126) * 1000 raw / 1000 - 126 °C -126 to +1972 °C
Thermistor 14 (°C + 40) * 100 raw / 100 - 40 °C -40 to +123.83 °C
AXL temperature 14 (°C + 40) * 100 raw / 100 - 40 °C -40 to +123.83 °C
AXL axis (X/Y/Z) 15 (g + g_range) * 1000 raw / 1000 - g_range g ±g_range
AXL activity 8 Raw raw 0-255
Mortality 7 Raw percentage raw % 0-100
Battery voltage 7 (mV - 2700) / 20 raw * 20 + 2700 mV 2700-5240 mV
Battery SOC (RSPB Doppler) 7 Raw percentage raw % 0-100
Activity (RSPB Doppler) 7 activity / 2 raw * 2 0-254

Part G — Depth pile aggregation

The depth pile is a FIFO queue that accumulates GPS fixes between transmissions. Its effective capacity is bounded by ARGOS_DEPTH_PILE (same parameter that controls how many fixes are packed per message): every new entry calls set_max_size(ARGOS_DEPTH_PILE), which evicts the oldest entries via FIFO pop_front()even when their burst_counter is still non-zero (pending retries).

This trades retry guarantees for memory hygiene: short surfacings that cannot finish a full retry burst no longer keep stale fixes in RAM. Fresh data wins. To keep zero-loss retries, choose ARGOS_DEPTH_PILE ≥ NTRY_PER_MESSAGE × (fix arrival rate / TX rate).

The per-segment cap depends on the active radio:

Radio Per-segment cap Constraint
Argos LDA2 (long packet) 3 fixes 24-byte LDA2 frame minus CRC8 byte 23
LoRa DR0-DR2 (51 B) 5 fixes computed by LoRaPacketBuilder::max_gps_entries(51) (v2: 66-bit deltas)
LoRa DR3 (115 B) 13 fixes computed by LoRaPacketBuilder::max_gps_entries(115)
LoRa DR4-DR5 (222 B) 15 fixes hard-capped by 4-bit count field

Argos calls retrieve_gps(depth) (default cap = 3). LoRa calls retrieve_gps(depth, max_entries) with the DR-derived cap.

G.1 Example — Argos LDA2

With ARGOS_DEPTH_PILE=16 and 16 accumulated fixes — split into ceil(16/3)=6 segments:

Message 1: fixes [13-15] (3 fixes, LDA2 long)
Message 2: fixes [10-12] (3 fixes, LDA2 long)
Message 3: fixes [7-9]   (3 fixes, LDA2 long)
Message 4: fixes [4-6]   (3 fixes, LDA2 long)
Message 5: fixes [1-3]   (3 fixes, LDA2 long)
Message 6: fix [0]       (1 fix,  LDK short)

With ARGOS_DEPTH_PILE=1 (underwater model): each TX sends the latest fix only (short packet on Argos, GPS Single on LoRa). Maximizes freshness during limited surface time.

G.2 Sensor depth pile

Sensor data uses separate depth piles per sensor type. At TX time:

  • retrieve_sensor_single() returns the most recent cached value
  • Sensor values are pre-encoded when received (offset + scale applied)
  • If a sensor has no data, its field is omitted from the packet

Part H — Decision tree: which mode to use

Is the animal marine (dives)?
  YES → SURFACING_BURST (mode 5)
        + UNDERWATER_EN=1
        + MIN_SURFACE_CYCLE_INTERVAL_S=2700

  NO → Does the animal move continuously?
        YES → LEGACY (mode 2) with TR_NOM=60-300
        NO  → DUTY_CYCLE (mode 3) to save battery during inactive hours

Do you have good AOP data and want maximum battery life?
  YES → PASS_PREDICTION (mode 1)

Is the tracker in low-battery emergency?
  YES → DOPPLER (mode 4) or LB_ARGOS_MODE=4

Part I — Implementation reference

Purpose File
Argos packet builder core/services/argos_tx_service.hpp / .cpp
RSPB packet builders argos_tx_service.cppbuild_rspb_long_packet(), build_rspb_short_packet()
Adaptive modulation argos_tx_service.cppensure_modulation(), get_rconf_for_modulation()
Modulation switching (SMD) ports/nrf52840/core/hardware/smd_sat/smd_sat.cppswitch_modulation()
Modulation switching (KIM2) ports/nrf52840/core/hardware/kim2/kim2.cppswitch_modulation()
SMD SPI cmd layer ports/nrf52840/core/hardware/smd_sat/smd_sat_cmd_spi.cpp
SMD UART cmd layer ports/nrf52840/core/hardware/smd_sat/smd_sat_cmd_at.hpp
SMD timing constants ports/nrf52840/core/hardware/smd_sat/smd_sat_registers.hpp
Bit packing utility core/util/bitpack.hpp
Depth pile template core/services/depth_pile.hpp
Coordinate/value conversion argos_tx_service.cpp:478-506
Sensor pre-encoding argos_tx_service.cpp:1146-1193 (DepthPileManager)
Type enums core/protocol/base_types.hpp
Mortality service core/services/mortality_service.hpp / .cpp

Cross-references

Clone this wiki locally