-
Notifications
You must be signed in to change notification settings - Fork 3
11 ‐ Satellite Communication
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/.
- Part A — Hardware variants (KIM2 / SMD SPI / SMD UART)
- Part B — TX modes (ARP01)
- Part C — SURFACING_BURST first-TX latency optimizations
- Part D — SMD timing FAST/SAFE autofallback
- Part E — Override modes (Low battery, Zone, HAULED)
- Part F — Message formats (bit layouts)
- Part G — Depth pile aggregation
- Part H — Decision tree: which mode to use
- Part I — Implementation reference (file pointers)
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.
- 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_SMDdefine →ARGOS_KIM2=1
- 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_confSPI cmd +save_radio_confflash +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_ENLOW, ~500 ms VDD-cap discharge, then re-enable. Eliminates the "INVALID_CMD cascade after soft reset" pattern (commit6018ace9). 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
- 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 definesSMD_UART=1instead ofSMD_SPI=1 -
Cmd layer:
smd_sat_cmd_at.hppinstead ofsmd_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
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).
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 │
└──────────┴──────────┴──────────┴──────────┴──────────┴────────────────┘
| 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) |
| 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. |
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.
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=2with a packedKNS_MAC_BLIND_usrCfg_tcontext) 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_nbclamp: 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_ENtherefore only takes effect in LEGACY / DUTY_CYCLE / PASS_PREDICTION (including HAULED overrides resolving to those). -
NTRY_PER_MESSAGEunchanged: 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_nbper 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.
No Argos TX. GPS may still acquire fixes (if GNSS_EN=1) but nothing is transmitted via satellite.
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...
| 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 |
- Requires valid AOP data (loaded via
$PASPWcommand 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
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) ...
| 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).
| 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 emitsceil(N/3)packets to drain the pile; slots holding a single fix become short LDK packets. See Part F.
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)
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 |
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).
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. ARGOS_BLIND_EN (ARP44) is force-disabled in this mode.
- 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)
The most advanced mode for marine animals with brief surfacings. Designed for sea turtles, cetaceans, and other species that surface briefly and unpredictably.
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) ║
╚══════════════════════════════════════════════════════════╝
| 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) |
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.
| 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.
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_PILEis reached and a new fix arrives, the oldest entry is evicted via FIFOpop_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]
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_modeto LDK for MEASC12 packets BEFORE checking the adaptive-modulation gate. WithARP54=0+ master=LDA2, the SMD stayed at LDA2 whilesend()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 whatsend()will use.
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
| 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 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.
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. |
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.
| 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 |
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.
| 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 |
| 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 |
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.
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_*).
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.
SMP00 flipped from read-only to writable to give operators a manual recovery path:
-
PARMW SMP00=0accepted — clears SAFE mode immediately, next TX uses FAST -
PARMW SMP00=1rejected — manual engagement remains under exclusive autofallback control (3 consecutive cascade errors detected by the driver) - Lazy sync of
g_smdsat_use_safe_timingsfrom the persisted param at the top ofsend()—SMP00=0takes 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).
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.
-
F1:
m_safe_trust_window_hoursnot persisted. After WDT reset in SAFE mode, window resets to 1 h regardless of where doubling had reached. -
F2:
m_safe_mode_since_msstamped withPMU::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.
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.
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+).
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).
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 |
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.
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 in143aedc9. Source:core/services/rate_limiter.cpp/hpp.
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.
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.
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.
| 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. |
-
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
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 HAULED — HAULED_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.
| 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 |
-
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.
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).
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.
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.
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.
- 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::checksumover bits 0..183 (bytes 0..22) — Argos-specific CRC8 with polynomial equivalent0x1070 << 3(=0x8380), init0, 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]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 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).
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,
}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 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.
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 |
| 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.
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 result192 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.
| 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 | 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.
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
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).
| 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 |
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.
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.0Type 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.
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).
- GNSS acquisition timeout expires (
GNP05orGNP09) - 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 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).
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 | 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 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 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.
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}}.
| 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.
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).
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.
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.
Custom payload for Argos TX power and frequency certification. Not used in production. Controlled by CTP01-CTP04.
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
| 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 |
| 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 |
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.
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.
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
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
| Purpose | File |
|---|---|
| Argos packet builder |
core/services/argos_tx_service.hpp / .cpp
|
| RSPB packet builders |
argos_tx_service.cpp — build_rspb_long_packet(), build_rspb_short_packet()
|
| Adaptive modulation |
argos_tx_service.cpp — ensure_modulation(), get_rconf_for_modulation()
|
| Modulation switching (SMD) |
ports/nrf52840/core/hardware/smd_sat/smd_sat.cpp — switch_modulation()
|
| Modulation switching (KIM2) |
ports/nrf52840/core/hardware/kim2/kim2.cpp — switch_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
|
-
09 — Parameters — all
ARP*,SMP*,IDP*/IDT*,CTP*parameters - 05 — Boards — per-board satellite stack
- 06 — DTE commands — SATDP, SATVF, SMDDFU, SMDTST, PARMR/PARMW
- 13 — Underwater & Behavioral Modes — cooldown, HAULED, surface-cycle gating
- 14 — RSPB Mortality Tracker — RSPB-specific TX modes and packet layouts
- 12 — LoRa Communication — LoRa packet formats (LoRa is NOT satellite — different physical layer)
- 10 — GPS Guide — GPS aggressive config, CloudLocate, REUSE_LAST
- 07 — Architecture § Sealed-device hardening — boot-fail counter, exception barriers, defense layers summary
- 10 — GPS Guide § Part E — RTC lifecycle, virtual fallback, K+L hooks