Skip to content

12 ‐ LoRa Communication

gfo974 edited this page Jul 6, 2026 · 4 revisions

12 — LoRa Communication

Complete reference for the LoRa terrestrial communication subsystem (RAK3172-SiP, Semtech SX1262, LoRaWAN 1.0.3). Covers ABP vs OTAA activation, EU868 / DR trade-offs, ETSI DCS compliance, packet formats, ChirpStack/TTN setup, and the key behavioral differences with the Argos satellite stack documented in 11 — Satellite Communication.

Source: ports/nrf52840/core/hardware/lora_rak3172/, core/services/lora_tx_service.cpp/hpp, core/services/lora_packet_builder.hpp/cpp. CMake: LORA_RAK3172=ON — mutually exclusive with ARGOS_SMD=ON.


Differences vs Argos Satellite (cheatsheet)

Aspect Argos (KIM2 / SMD) LoRa (RAK3172)
Coverage Global satellite (CLS / Kineis) Terrestrial gateway-dependent (range 2-30 km LoS per DR)
Module KIM2 (CLS) or STM32WL co-MCU (SMD) RAK3172-SiP (SX1262)
Bus UART (KIM/SMD-UART) or SPI 125 kHz (SMD-SPI) UART
Protocol Argos legacy CLS (KIM) or A+ binary KMAC (SMD) LoRaWAN MAC 1.0.3
Security A+ KMAC AES-128 (SMD only) LoRaWAN AppKey + session keys (OTAA) / fixed keys (ABP)
Power-off between TX Always — SAT_PWR_EN=LOW Optional (LORA_POWER_OFF_UNDERWATER); default OFF → standby Stop2 ~1.7 µA
First-TX latency (warm) ~155 ms (SMD FAST) ~10 ms (standby wake)
First-TX latency (cold) ~600-900 ms (SMD) / ~2 s (KIM) ~2.8 s (cold boot + configure)
Modulations LDK (12 B) / LDA2 (24 B) / VLDA4 (3 B) SF7–SF12, BW 125/250 kHz — DR0-DR7 (EU868)
Payload limit per TX 24 B (LDA2 max) 51 B (DR0-DR2) → 222 B (DR4-DR5)
Packet header 3-bit type field 14-bit common header (type 3 + flags 4 + voltage 7)
CRC LDA2: firmware CRC8 byte 23; LDK/VLDA4: modem-added LoRaWAN MIC (4 B) added by stack
GPS Multi format 3 fixes max per LDA2 frame, fixed 4-bit delta-time-loc 5-15 fixes per frame (DR-dependent), per-entry 16-bit DELTA_T_MIN (newest-first)
CloudLocate raw blob MEASC12 (12 B) or MEAS20 (20 B) MEASC12 / MEAS20 / MEAS50 (50 B, LoRa only)
Sensor packet 5-bit self-describing mask, capped at 184 useful bits 6-bit mask, AXL always includes die temp (budget permitting)
Duty-cycle regulation None — Argos has its own ground-segment policy ETSI EN 300 220 enforced by RAK3172 (AT+DCS=1): 1 % on g1/g4, 0.1 % on g2, 10 % on g3
Adaptive Data Rate (ADR) N/A Optional (LORA_ADR, default off for mobile turtle)
TX cost (per packet, max payload) ~210 µAh per Doppler TX (Argos) ~3.3 mAh at SF7/14 dBm for 100 ms; ~120 mA peak
Pass scheduling PASS_PREDICTION (mode 1) with AOP data Any time — LORA_DCS enforces inter-TX gap
Rate limiter (RLP01-03) Applies Does not apply (rate limiter is Argos-only in current firmware)

Net implication for SURFACING_BURST:

  • Argos can fire first ping at ~155 ms post-surface; LoRa fires at ~10 ms warm (standby) but the configure path adds ~2.8 s if the RAK3172 was fully powered off (LORA_POWER_OFF_UNDERWATER=ON or cold boot).
  • LoRa DR3+ is the practical floor for sea turtles with MEAS50 fastloc; lower DR works but with ToA ≥ 1 s and 50-300 s off-time afterwards which won't fit a 30 s surfacing.
  • LoRa payload budget is much larger — you can pack 13 GPS fixes per packet on DR3 vs Argos's 3-fix LDA2 limit.

What's new in v4.1.7 / v4.1.8 (LoRa-relevant)

  • SURFACING_BURST cached GPS/Fastloc on 1st ping (36ddd114): Phase-1 surfacing burst previously sent pure status pings (battery only, no position) until a live fix arrived. Now the 1st ping carries the most recent cached position between the last GPS fix and the last Fastloc/degraded PVT. If no cached position exists (first deployment, no prior fix ever), a single status-pure ping is sent then the burst is silenced (m_is_surfacing_burst=false, m_awaiting_surfacing=true) until the next surfacing event. END_OF_DOPPLER cooldown is armed for parity with the max-msg-reached branch. Scheduler change: the 1st-ping CloudLocate defer (waiting up to GNSS_ACQ_TIMEOUT for raw) is skipped when any cached position is available — 1st TX fires immediately at surface using the cache as "prepared underwater" content.
  • New cache m_last_fastloc_log_entry in ConfigurationStore (RAM), populated by gps_service::task_process_degraded_gnss_data alongside the existing m_last_gps_log_entry FIX cache.
  • Skip state_idle on standby wake (e322fb26, v4.1.8): when a packet is queued on standby wake, the LoRa state machine goes directly to TX (saving ~100 ms on the surface critical path — mirrors the Argos warm-packet skip). Guarded by m_config_reload_pending so a PARMW between dive warm-up and surface still drops the stale packet and reconfigures.
  • Log demotions on hot paths (e322fb26): state_power_on retry, state_standby_enter, state_transmit_enter, progressive ping interval, 6 payload dumps (gps/sensor/status_burst including cached). LFS commit was ~50-300 ms per emit. ~5-10 min/day TX time recovered on a 1-yr deployment.
  • LPM readback (e322fb26): AT_GET_LPM / AT_GET_LPMLVL enum entries + cmd table rows appended (existing ATCmd enum values stay stable — AT_SLEEP_NOW stays at 45). LRP15 LORA_LP_MODE docstring updated to reflect Stop1 (~3 µA, wake-on-UART1) vs Stop2 limitation on this PCB.
  • CloudLocate 1st STATUS skipped when FASTLOC_MODE=CLOUDLOCATE (654dcc9e): the 1st surfacing-burst ping is deferred up to GNSS_ACQ_TIMEOUT waiting for raw data. Saves one TX per surfacing burst on the happy path. Safety fallback: if GPS never emits raw before the timeout, a normal STATUS ping fires.
  • MAC_TX_TIMEOUT/MAC_ERROR no longer arms AFTER_LAST_TX cooldown (dbcca4fe): is_tx_finished now distinguishes MAC_TX_DONE from MAC_TX_TIMEOUT/MAC_ERROR.
  • AFTER_LAST_TX / AFTER_FIRST_GNSS only re-arm cooldown when NOT in cooldown (dea5d734): DUTY_CYCLE / LEGACY / PASS_PREDICTION modes were creeping the cooldown timer forward by one interval per cycle (TX fired during cooldown reset m_cooldown_armed). Now gated.

Why ABP over OTAA for Turtles?

OTAA ABP
Time to first TX 13–93s (join procedure) ~3–4s (no join needed)
After standby wake ~10ms (if already joined) ~10ms
Security Session key rotation Fixed session keys
Roaming Yes No
Counter sync Automatic Manual (needs flash persistence)

Verdict: Use ABP for turtle trackers. The 10–80s saved at each surfacing is critical when the animal surfaces briefly.

Note: ABP requires manual key provisioning on both the device and the network server (TTN/ChirpStack). Session keys are fixed for the device lifetime. Frame counter persistence is handled by the firmware via flash storage.


Aggressive LoRa Configuration

Firmware Parameters (DTE commands)

# === Join Mode: ABP (no join latency) ===
PARMW LRP07 0           # LORA_NJM = 0 (ABP)

# === ABP Credentials ===
PARMW LRP04 <DEVADDR>   # 8 hex chars from TTN
PARMW LRP05 <APPSKEY>   # 32 hex chars from TTN
PARMW LRP06 <NWKSKEY>   # 32 hex chars from TTN

# === Data Rate: DR5 (SF7) — fastest TX ===
# SF7 = ~5s TX for 51 bytes, ~100ms for small packets
# Range: ~2-3 km LOS (sufficient for coastal deployments with nearby gateways)
PARMW LRP10 5           # LORA_DR = 5 (SF7/125kHz)

# === ADR: OFF (mobile device) ===
# ADR assumes static device — network would lower DR over time
PARMW LRP11 0           # LORA_ADR = false

# === TX Power: Maximum ===
PARMW LRP12 0           # LORA_TXP = 0 (+16 dBm EIRP, EU868 max)

# === Unconfirmed TX (no ACK needed) ===
# Saves RX window time and avoids downlink dependency
PARMW LRP13 0           # LORA_CFM = false

# === Band: EU868 ===
PARMW LRP08 4           # LORA_BAND = 4 (EU868)

# === Low Power Mode: Standby (fast wake) ===
# Standby = 1.7µA, wake ~10ms vs Shutdown = 0µA, wake 2.5s
PARMW LRP15 1           # LORA_LP_MODE = 1 (standby)

# === FPort ===
PARMW LRP14 2           # LORA_FPORT = 2 (application port)

# === Class A (default, lowest power) ===
PARMW LRP09 0           # LORA_CLASS = 0 (Class A)

TX Mode Configuration

# === Surfacing Burst Mode (optimal for turtles) ===
PARMW ARP01 5           # ARGOS_MODE = SURFACING_BURST

# === Burst timing ===
PARMW ARP40 5           # SURFACING_BURST_INIT_S = 5s (first status after 5s)
PARMW ARP41 10          # SURFACING_BURST_STEP_S = 10s (progressive interval)
PARMW ARP42 60          # SURFACING_BURST_MAX_S = 60s (cap)
PARMW ARP43 0           # SURFACING_BURST_MAX_MSG = 0 (unlimited status bursts —
                        #  OK in SURFACING_BURST; NEVER 0 in DOPPLER mode)

# === Include sensors in TX (per-sensor TX aggregation) ===
# There is no global SENSOR_TX_ENABLE param — each sensor has its own
# *_ENABLE_TX_MODE (0=OFF 1=ONESHOT 2=MEAN 3=MEDIAN):
PARMW AXP05 1           # AXL_SENSOR_ENABLE_TX_MODE = ONESHOT (AXL in TX packet)
                        # (pressure PRP04, thermistor THP06, sea-temp STP04, ...)

# === Nominal TX interval (after GNSS fix acquired) ===
PARMW ARP05 120         # TR_NOM = 120s (TX every 2 min when surfaced with GPS)

# === Dry time before TX ===
PARMW UNP02 3           # DRY_TIME_BEFORE_TX = 3s (minimal delay)

# === Depth pile (multi-GPS) ===
PARMW ARP16 4           # ARGOS_DEPTH_PILE = 4 (accumulate 4 GPS entries per packet)

NoteARGOS_DEPTH_PILE (ARP16) is also used by the LoRa stack despite the "ARGOS_" prefix. It governs how many of the latest GPS fixes are packed into a LoRa GPS_MULTI v2 packet (max 7 on DR0-DR2 (51 B payload, auto-truncate), 15 on DR3-DR5 — capped by the 4-bit count field in the packet header). See the LoRa Data Rate table below for the exact per-DR breakdown. The legacy DTE param GNSS_NBLASTFIX_TOSEND was removed in v4.x — use ARGOS_DEPTH_PILE instead. Some GUIs may still display "GNSS_NBLASTFIX_TOSEND" but should internally map to the same DTE key ARP16.

Bounded depth pile (v4.x)ARGOS_DEPTH_PILE now also limits the effective in-RAM capacity of the pile (previously it was hardcoded to 24 and the param only controlled retrieval slot rotation). When a new fix arrives on a full pile, the oldest entry is evicted via FIFO pop_front() even if its retry burst is not yet complete. This is intentional — short turtle surfacings cannot finish a full retry burst, so we prefer fresh data over stale retries. To keep zero retry loss, set ARGOS_DEPTH_PILE ≥ NTRY_PER_MESSAGE × (fix arrival rate / TX rate).

GPS_MULTI v2 (breaking change v4.x) — the per-packet timestamp encoding switched from a single 4-bit delta_time_loc global field to a per-entry 16-bit DELTA_T_MIN (minutes-back). Entries are now emitted newest-first. This fixes timestamp reconstruction when surfacings are irregular. The server decoder MUST be updated before flashing firmware that emits this format — see 12 — LoRa § Packet formats.

Timing Breakdown — Fastest Path (ABP + DR5 + Standby)

T=0.0s  Tortue fait surface → SWS détecte dry
T=0.0s  BMA400 wakeup → GPS power on (si AXL pre-trigger activé)
T=3.0s  DRY_TIME_BEFORE_TX expire → LoRaTxService reschedule
T=3.0s  RAK3172 wake from standby (~10ms)
T=3.1s  AT+SEND status heartbeat (battery) → TX ~100ms at SF7
T=3.2s  ✅ PREMIER MESSAGE ENVOYÉ (3.2s après surface)
T=5-15s GPS fix (warm start + AssistNow)
T=15.1s AT+SEND GPS position → TX ~200ms at SF7
T=15.3s ✅ POSITION GPS ENVOYÉE

Comparaison avec OTAA :

T=0.0s  Surface → SWS
T=3.0s  RAK3172 wake → AT+JOIN
T=13-83s Join procedure (8 attempts × 10s worst case)
T=83.1s AT+SEND status → ❌ trop tard, tortue a replongé

Timing cold-boot ABP (post-DFU / batterie remise)

Le chemin cold-boot (module RAK3172 totalement éteint, pas de standby Stop2 préservé) est traversé une seule fois après un DFU ou un changement de batterie. Optimisé en mai 2026 pour le minimum de temps avant le 1er TX possible :

Phase Avant optim Après optim Détail
power_on boot wait 2000 ms (hardcoded) 1000 ms initial + retry agressif state_power_on(): première ping AT_TEST à T+1000 ms (était T+2000 ms), 8 retries à 300 ms (était 4 à 500 ms), timeout par ping 400 ms (était 2000 ms)
configure 13 étapes AT ~150 ms × 13 = 1950 ms ~120 ms × 13 = 1560 ms Inter-step delay 50 ms → 20 ms dans state_configure() (send_AT blocking ~50-100 ms reste, le scheduler a largement de quoi tick BLE/sensors)
ABP transition vers idle 3 ticks 100 ms (case 14→100→101→idle) 1 tick (case 14 → idle direct) Short-circuit dans state_configure() case 14 : si m_config.njm == 0 (ABP) on saute la common path et on LORA_STATE_CHANGE(configure, idle) directement
Total cold-boot → idle prêt à TX ~4350 ms ~2760 ms (-1.6 s, -37 %)

Le wake depuis standby Stop2 (cycle nominal entre 2 surfaces) reste à ~10 ms — déjà optimal, non touché.

Sécurité conservée : si le RAK3172 est lent à booter (froid, alim faible), les 8 retries à 400 ms d'attente active absorbent un boot jusqu'à ~3 s ; au-delà on tombe en state_error après ~5.9 s (vs ~11 s avant — fail-fast amélioré quand le module est vraiment HS).


LoRa Credential Parameters

Key Param Mode Format Length Description
LRP01 LORA_DEVEUI ABP+OTAA Hex 16 chars Device EUI (auto-generated from nRF52840 FICR if empty)
LRP02 LORA_APPEUI OTAA only Hex 16 chars Application EUI (from TTN/ChirpStack)
LRP03 LORA_APPKEY OTAA only Hex 32 chars Application Key (from TTN/ChirpStack)
LRP04 LORA_DEVADDR ABP only Hex 8 chars Device Address
LRP05 LORA_APPSKEY ABP only Hex 32 chars Application Session Key
LRP06 LORA_NWKSKEY ABP only Hex 32 chars Network Session Key
LRP07 LORA_NJM Both UINT 0-1 0=ABP, 1=OTAA

Credential Validation

Important: The firmware validates credential length only at module configuration time (boot), not during PARMW. Invalid credentials (wrong length or non-hex characters) will cause a configuration error at the next boot.

Use $SATVF#000;\r to verify that module credentials match config store values. See SATVF.

Hot reload runtime (depuis mai 2026)

Avant : tout changement de credential / band / DR via PARMW nécessitait un RSTBW (reboot MCU) pour que le RAK3172 voie les nouvelles valeurs. Maintenant le firmware propage automatiquement via LoRaDevice::reload_config_if_changed(), déclenché par l'action DTE CONFIG_UPDATED à la fin de chaque PARMW.

Comportement :

  • Si le device est idle / standby / power_off au moment du PARMW → le module est immédiatement éteint (power_off_immediate), et le prochain send() repassera par le full configure avec les nouvelles valeurs (cold-boot ~2.8 s) — credentials/band/DR/etc tous repoussés au module.
  • Si le device est transmit / joining / configure / power_on (busy) → un flag m_config_reload_pending est levé ; l'apply se fait au retour en idle, le packet en cours est droppé proprement avec KineisEventDeviceError (le service le re-queue depuis depth pile).
  • Si le bridge mode (LORABR=1) est actif → l'apply est différé jusqu'à stop_bridge(), pour ne pas tuer l'UART en plein passthrough utilisateur.
  • En OTAA, l'invalidation de session est automatique : m_joined=false + m_is_configured=false à la fin de write_credentials_from_config() → re-Join propre au prochain cycle.

Plus besoin de RSTBW pour appliquer les credentials. RSTBW reste utile si tu veux un reset complet pour diagnostic.

ABP Credential Provisioning (post-hot-reload)

# 1. Set join mode to ABP
PARMW LRP07=0

# 2. Write credentials (from TTN/ChirpStack device registration)
PARMW LRP04=<8_hex_chars>     # DEVADDR
PARMW LRP05=<32_hex_chars>    # APPSKEY
PARMW LRP06=<32_hex_chars>    # NWKSKEY

# 3. (Optionnel) Forcer une écriture immédiate dans le module pour vérif
SATVF 1
# Force un write_credentials_from_config sur le RAK + relecture.
# Pas obligatoire — le prochain cycle l'appliquerait de toute façon.

# 4. Vérification
SATVF
# Response: match=1 si DEVADDR correspond au module

OTAA Credential Provisioning (post-hot-reload)

# 1. Set join mode to OTAA
PARMW LRP07=1

# 2. Write credentials (from TTN/ChirpStack application)
PARMW LRP02=<16_hex_chars>    # APPEUI
PARMW LRP03=<32_hex_chars>    # APPKEY

# 3. (Optionnel) Forcer le push + Re-Join immédiat
SATVF 1
# write_credentials_from_config invalide la session OTAA actuelle
# (m_joined=false), le module rejoint au prochain TX

# 4. Vérification
SATVF
# Response: match=1 si DEVEUI + APPEUI + APPKEY matchent

Cas NJM change (OTAA ↔ ABP) via SATVF 1 : write_credentials_from_config() lit le NJM courant du module avant l'écrasement, et si la valeur change il attend 3 s pour le reboot RUI3 + drain du banner avant d'envoyer les credentials suivants. Sans ce wait les AT+DEVADDR/APPSKEY/etc. partaient dans le vide pendant que le RAK rebootait silencieusement.

GUI recommendation: Show LRP02/LRP03 fields only when LRP07=1 (OTAA). Show LRP04/LRP05/LRP06 fields only when LRP07=0 (ABP). Validate hex format and length client-side before PARMW.


Template upload — chunking obligatoire (MALLOC)

Le problème

Le décodeur DTE (DTEDecoder::decode dans dte_protocol.hpp) utilise safe_sscanf qui appelle sscanf de la newlib full (pas nano — exceptions C++ nécessaires). Cette implémentation alloue dynamiquement pour parser des doubles (%lf) et fragmente progressivement le heap FreeRTOS (64 KB) à chaque param.

Combiné avec :

  • Les ~3 std::string::substr par paramètre dans la boucle KEY_VALUE_LIST,
  • Le payload.substr(payload_pos, npos) à chaque arg_index (full copy du payload restant),
  • Les vecteurs arg_list / key_values qui croissent par doublement,

un PARMW unique contenant ~220 params (template complet) sature le heap → pvPortMalloc retourne NULL → vApplicationMallocFailedHook()soft reset MALLOC.

Trace observée :

[INFO ] PMU reset type: MALLOC
[INFO ] PMU PC[1] = pvPortMalloc (heap_4.c:264)
[INFO ] PMU PC[2] = sscanf
[INFO ] PMU PC[3] = DTEDecoder::decode (dte_protocol.hpp:1642)
[INFO ] PMU PC[6] = DTEHandler::handle_dte_message

Le fix (côté GUI)

Découpe le template en chunks de 20 params PARMW max et envoie-les séquentiellement, en attendant la réponse $O;PARMW#000;\r entre chaque chunk.

Pourquoi 20 :

Chunk size Marge heap Vitesse upload
10 très large lent (12 round-trips pour 120 params)
20 confortable bon (6 round-trips pour 120 params)
30 serrée rapide mais risqué
50+ dangereuse ne pas faire

Le firmware persiste chaque PARMW (config_store flush à la fin de chaque commande) — pas besoin d'appel RSTBW ou SAVEW après les chunks.

Pseudo-algorithme GUI

template_params = parse_template_file()    # list of (key, value) pairs
chunks = split_into_chunks(template_params, size=20)

for i, chunk in enumerate(chunks):
    payload = ",".join(f"{k}={v}" for k, v in chunk)
    header = f"$PARMW#{len(payload):03X};"
    response = dte_send(header + payload + "\r", timeout_s=5)

    if response.startswith("$O;PARMW"):
        progress(i+1, len(chunks))
    elif response.startswith("$N;PARMW"):
        rejected_keys.extend(parse_rejected(response))
    else:
        retry(chunk, max=3) or abort()

Mitigation firmware (TODO long terme)

Solutions firmware possibles, pas encore implémentées (pour ne pas casser l'existant qui marche) :

  • Remplacer safe_sscanf par strtoul/strtod : newlib's strto* ne touche pas au heap. ~30 lignes dans dte_protocol.hpp:1188-1232.
  • Remplacer substr() par std::string_view dans la boucle KEY_VALUE_LIST : ~0 allocations vs ~300/template.
  • Augmenter configTOTAL_HEAP_SIZE de 65536 à 98304 (96 KB) — nRF52840 a 256 KB RAM. Risque faible.
  • Réutiliser les _RESERVED_* slots au lieu d'ajouter des nouveaux params en queue (réduit la taille du template aussi, accessoirement).

À ne PAS faire : activer --specs=nano.specs (le code utilise des exceptions C++, nano-newlib est compilé sans support exceptions).

En attendant — workaround côté field

Si le firmware tourne sur un device déployé et que tu ne peux pas mettre à jour la GUI immédiatement, fractionne manuellement ton template :

  1. Premier PARMW : 20 params (ex. les LRP*)
  2. Deuxième : 20 suivants (ex. les GNP*)
  3. etc.

À 60-80 PARMW pour ~150-200 params, l'upload prend ~30-60 s au lieu de < 5 s d'un seul coup. Acceptable pour un device de field, surtout que ça ne crashe plus.


DR Trade-offs for Turtle Deployments

DR SF / BW Sensibilité Max payload PHY Payload app (−13 B MAC) Portée LoS marine ToA max payload Off-time 1 % DC Best for
DR0 SF12/125 kHz −137 dBm 51 B ~38 B 15-30 km (jusqu'à 50 km) ~2.5 s ~250 s Portée maximale, peu de TX/h
DR1 SF11/125 kHz −134.5 dBm 51 B ~38 B 12-20 km ~1.5 s ~150 s Couverture étendue
DR2 SF10/125 kHz −132 dBm 51 B ~38 B 8-15 km ~700 ms ~70 s Gateway lointaine
DR3 SF9/125 kHz −129 dBm 115 B ~102 B 6-10 km ~640 ms (112 B) ~64 s Compromis recommandé + plancher MEAS50
DR4 SF8/125 kHz −126 dBm 222 B ~209 B 3-6 km ~330 ms (129 B) ~33 s Gateway à portée moyenne
DR5 SF7/125 kHz −123 dBm 222 B ~209 B 2-4 km ~180 ms (129 B) ~18 s Gateway proche (< 3 km), max TX/h

Voir tableau complet (avec ToA status / GNSS Multi par count, lecture pratique par profil de tortue) → 12 — LoRa § DR Trade-offs.

Recommandation par scénario :

  • Gateway côtière proche (< 3 km, lagon/bouée) → DR5 — 15 fixes / packet, 18 s entre TX, idéal pour bursts rapides
  • Gateway côtière moyenne (3-8 km) → DR3 — 13 fixes / packet, plancher pour MEAS50 fastloc
  • Gateway haute mer / longue portée (> 10 km) → DR0-DR1 — 5 fixes / packet, mais ~2-4 min cooldown entre TX
  • Avec MEAS50 (CloudLocate fastloc 50 B) activé → DR3 minimum imposé par le firmware (auto-bump)

DCS — Duty Cycle Setting (réglementation EU868)

Pourquoi le DCS existe

ETSI EN 300 220 impose en bande EU868 :

Sous-bande Fréquences Duty cycle max
g1 868.0 – 868.6 MHz 1 %
g2 868.7 – 869.2 MHz 0.1 %
g3 869.4 – 869.65 MHz 10 % (canal alternatif rare)
g4 869.7 – 870.0 MHz 1 %

Les canaux LoRaWAN par défaut (868.1, 868.3, 868.5 MHz puis 867.1-867.9) tombent dans g11 % duty cycle obligatoire. Concrètement, après chaque TX, le module doit attendre 99 × ToA avant de pouvoir réémettre sur la même sous-bande.

⚠️ La régulation s'applique au transmetteur (le tag), pas au réseau. Que tes gateways soient à toi, à TTN, à un opérateur privé : ETSI s'applique pareil. Posséder la gateway ne te dispense pas du 1 %.

Comment le firmware applique DCS

Le compile-time flag LORA_DCS_ENABLE (default ON) envoie AT+DCS=1 au RAK3172 pendant la phase state_configure (lora_rak3172.cpp:639). Le module enforce alors lui-même la limite ETSI :

  • Avant chaque AT+SEND, le RAK3172 vérifie le budget airtime restant sur la sous-bande
  • Si le budget est insuffisant, retourne AT_DUTYCYCLE_RESTRICTED (ou différé selon la version RUI3)
  • Le service LoRa reçoit KineisEventDeviceError → backoff exponentiel (lora_tx_service.cpp:770)

Impact pratique côté firmware :

  • Le compteur m_status_burst_count est incrémenté avant TX → un packet droppé pour DCS compte quand même dans SURFACING_BURST_MAX_MSG
  • Le burst_counter du fix est décrémenté dans retrieve() avant TX → un retry est consommé même si DCS bloque

⇒ Conséquence : à DR < 5 avec un surfacing burst rapide (INIT=5/STEP=1), une partie des packets est silencieusement perdue. Voir tableau ci-dessous pour ajuster.

Conformité du surfacing burst selon DR

Avec config par défaut (INIT=5 s, STEP=1 s, MAX=30 s) et un status packet de 2 B (~15 B PHY) :

DR Off-time 1 % Gap min burst (5 s) Conformité Phase 1 Recommandation
DR0 ~98 s 5 s ❌ tous gaps insuffisants Allonger : INIT=120, STEP=10, MAX=300
DR1 ~65 s 5 s INIT=90, STEP=10, MAX=180
DR2 ~37 s 5 s INIT=60, STEP=5, MAX=120
DR3 ~16 s 5 s ⚠️ #1→#3 droppés INIT=20, STEP=2, MAX=60
DR4 ~10 s 5 s ⚠️ #1→#5 droppés INIT=10, STEP=2, MAX=30
DR5 ~5 s 5 s ✅ tout juste OK défauts conviennent

Désactiver DCS — uniquement en test

# Build sans DCS — ILLÉGAL en déploiement EU
cmake -DLORA_RAK3172=ON -DLORA_DCS_ENABLE=OFF ...

Cas d'usage légitimes : tests RF en cage de Faraday, certification interne, mesures de portée en champ contrôlé. Jamais en déploiement terrain en EU868.

Détail compile-flag complet → voir section LORA_DCS_ENABLE plus bas.


Choisir son réseau LoRaWAN — TTN vs privé

Aspect TTN public Réseau privé (ChirpStack via WisDM)
Couverture multi-gateways ✅ Communauté + roaming ❌ Tes gateways uniquement
Coût Gratuit (Community) ou TTS Enterprise (payant) Gratuit en self-hosted
Fair Use Policy 30 s airtime / device / jour + 10 downlinks Aucune — tu décides
Hébergement NS Cloud TTI Sur la gateway (WisGate Edge) ou serveur perso
Internet requis ✅ permanent côté gateway ❌ optionnel (NS local sur gateway)
ETSI EN 300 220 (DCS 1 %) ✅ s'applique ✅ s'applique (rien ne change)
Gestion à distance ✅ via TTI Console ✅ via WisDM + ChirpStack UI
Idéal pour Tortue côtière dans zone TTN couverte Bateau de recherche, déploiement isolé, projet long

Règle d'or : ETSI s'applique dans les deux cas. La différence porte uniquement sur la couche applicative (qui fait l'AS/NS et ses propres limites).


Configuration recommandée — TTN (réseau public)

Côté firmware (DTE commands)

Profile "côtier MEAS50 + GNSS Multi", conforme ETSI + Fair Use TTN :

# === Identité ===
PARMW LRP07 1                  # LORA_NJM = OTAA (recommandé TTN)
PARMW LRP08 4                  # LORA_BAND = EU868
PARMW LRP09 0                  # LORA_CLASS = A

# === Radio (fixe pour respecter Fair Use 30 s/jour) ===
PARMW LRP10 3                  # LORA_DR = 3 (SF9, 6-10 km LoS, plancher MEAS50)
PARMW LRP11 0                  # LORA_ADR = 0 (FIXÉ — évite que TTN ADR pousse vers DR0)
PARMW LRP12 0                  # LORA_TXP = 0 (max 14 dBm)
PARMW LRP13 0                  # LORA_CFM = 0 (unconfirmed — évite quota downlink TTN)

# === Surfacing burst conforme DR3 (off-time 64 s pour MAX payload) ===
PARMW SBP01 20                 # SURFACING_BURST_INIT_S
PARMW SBP02 5                  # SURFACING_BURST_STEP_S
PARMW SBP03 90                 # SURFACING_BURST_MAX_S
PARMW SBP04 4                  # SURFACING_BURST_MAX_MSG (limite quota Fair Use)

# === Phase 2 GNSS ===
PARMW ARP05 90                 # TR_NOM = 90 s (> 64 s off-time DR3)
PARMW ARP16 4                  # ARGOS_DEPTH_PILE = 4 fixes / packet
PARMW ARP19 3                  # NTRY_PER_MESSAGE = 3

# === Fastloc CloudLocate MEAS50 ===
PARMW GNP45 1                  # GNSS_FASTLOC_MODE = CLOUDLOCATE
PARMW GNP46 2                  # GNSS_CLOUDLOCATE_FORMAT = MEAS50

Budget Fair Use TTN à DR3 + ce profile

  • Status packet (~2 B) : ToA ~165 ms
  • GNSS Multi 4 fixes (~38 B) : ToA ~330 ms
  • MEAS50 (52 B) : ToA ~430 ms

Tortue type 4 surfaces / jour avec 1 status + 1 GNSS Multi par surface :

  • 4 × (165 + 330) ms ≈ 2 s/jourlargement sous le quota TTN 30 s/jour

Côté TTN Console

Dans la Device → General settings :

  • LoRaWAN version : 1.0.3
  • Regional Parameters : RP001 1.0.3 revision A
  • Frequency plan : Europe 863-870 MHz (SF9 for RX2 - recommended)
  • Resets frame counters : ⚠️ OFF (en OTAA, pas besoin) — ON uniquement si ABP avec resets fréquents
  • RX2 data rate : 3 (SF9, match config RAK3172)
  • RX2 frequency : 869.525 MHz

Activation OTAA recommandée sur TTN (gestion clés simplifiée). Voir détails section TTN Device Configuration.


Configuration recommandée — Réseau privé (ChirpStack via WisDM)

Architecture

[Tracker] ──── LoRa EU868 ────► [Ta gateway RAK]
                                       │
                                       │ packet forwarder (UDP/MQTT/BasicStation)
                                       │
                                       ▼
                          [ChirpStack v4]
                          (WisGate Edge built-in OU serveur perso)
                                       │
                                       │ HTTP / MQTT / gRPC integration
                                       ▼
                            [Ton backend / GUI]

WisDM = console RAK pour pousser la config aux gateways à distance (sélection du packet forwarder, destination NS, mise à jour firmware OTA, monitoring santé). Il n'est pas le NS — il configure juste où la gateway envoie ses uplinks.

Étapes setup

  1. Gateway : RAK7268V2 / RAK7289 / WisGate Edge series
  2. WisDM :
    • Inscris la gateway dans WisDM (https://wisdm.rakwireless.com)
    • Push la config "ChirpStack local" si gateway compatible (WisGateOS embarque ChirpStack)
    • OU push la config "Custom UDP packet forwarder" pointant vers ton ChirpStack serveur
  3. ChirpStack :
    • Crée un tenant, application, device-profile (cf. ci-dessous)
    • Inscris ton tracker (DevEUI, AppEUI, AppKey en OTAA — ou DevAddr/keys en ABP)
  4. Tracker : configure les credentials qui matchent ChirpStack (PARMW LRP01-LRP06)

Device-profile ChirpStack recommandé

# Device-profile : "TurtleTracker_LoRaWAN_1.0.3"
mac_version: "1.0.3"
reg_params_revision: "A"
region: "EU868"
factory_preset_freqs: []          # standard EU868 channels
supports_class_b: false
supports_class_c: false
supports_join: true               # OTAA
mac_settings:
  rx1_delay: 1
  rx1_dr_offset: 0
  rx2_dr: 3                       # SF9
  rx2_freq: 869525000
adr:
  enabled: true
  algorithm: "default"
  min_dr: 3                       # FLOOR — protège ton timing burst
  max_dr: 5                       # CAP — accélère si proche gateway
adr_ack_limit: 64
adr_ack_delay: 32
class_b: { enabled: false }
class_c: { enabled: false }

Côté firmware (DTE commands)

Profile "côtier MEAS50 + ADR adaptatif" — tu peux te permettre ADR car ton NS privé n'a pas de limite Fair Use, et le min_dr=3 du device-profile garantit que le timing burst reste valide même au pire cas :

# === Identité ===
PARMW LRP07 1                  # LORA_NJM = OTAA
PARMW LRP08 4                  # LORA_BAND = EU868
PARMW LRP09 0                  # LORA_CLASS = A

# === Radio avec ADR (NS optimise SF/TXP au fil du temps) ===
PARMW LRP10 3                  # LORA_DR = 3 (départ, NS peut pousser jusqu'à 5)
PARMW LRP11 1                  # LORA_ADR = 1 (ON — convergence sur la zone tortue)
PARMW LRP12 0                  # LORA_TXP = 0 (max 14 dBm, ADR peut le baisser)
PARMW LRP13 0                  # LORA_CFM = 0

# === Surfacing burst conforme DR3 worst case (= ADR floor) ===
PARMW SBP01 20                 # SURFACING_BURST_INIT_S
PARMW SBP02 5                  # SURFACING_BURST_STEP_S
PARMW SBP03 90                 # SURFACING_BURST_MAX_S
PARMW SBP04 8                  # SURFACING_BURST_MAX_MSG = 8 (pas de Fair Use)

# === Phase 2 GNSS — TX plus fréquent autorisé sans Fair Use ===
PARMW ARP05 90                 # TR_NOM = 90 s
PARMW ARP16 8                  # ARGOS_DEPTH_PILE = 8 (max ~88 B, fits DR3)
PARMW ARP19 3

# === Fastloc CloudLocate ===
PARMW GNP45 1                  # GNSS_FASTLOC_MODE = CLOUDLOCATE
PARMW GNP46 2                  # GNSS_CLOUDLOCATE_FORMAT = MEAS50

Avantages du privé pour un projet tortue

  • Pas de quota Fair Use → bursts plus longs (MAX_MSG=8 au lieu de 4)
  • Depth pile plus profonde possible (8 au lieu de 4) → meilleur historique par packet
  • ADR utilisable sans risque de drop arbitraire par TTN
  • Possibilité d'algorithme ADR custom (Lua/JS dans ChirpStack v4) — voir section ADR custom plus bas
  • Données restent chez toi (RGPD plus simple si publication scientifique)

Notes WisDM

  • WisDM ne change rien à la conformité ETSI : ton tracker reste soumis au DCS 1 %
  • WisDM permet de pousser des config OTA aux gateways en flotte — utile si tu déploies plusieurs sites
  • WisDM gère la mise à jour firmware OTA des gateways RAK (gain énorme en maintenance)
  • Compatible BasicStation (recommandé) ou Semtech UDP packet forwarder (legacy)

Algorithme ADR custom (ChirpStack v4)

Pour une tortue mobile lente, un ADR plus agressif vers DR5 quand SNR > 15 dB peut être bénéfique :

-- ChirpStack ADR custom plugin (Lua)
function handle(req)
  local margin = req.snr_margin
  local current_dr = req.dr

  -- SNR excellent → push DR5 rapidement
  if margin > 15 and current_dr < 5 then
    return { dr = math.min(current_dr + 2, 5), tx_power = 0, nb_trans = 1 }
  end
  -- SNR moyen → DR4
  if margin > 5 then
    return { dr = math.max(3, math.min(current_dr, 4)), tx_power = 0, nb_trans = 1 }
  end
  -- SNR faible → reste au plancher DR3
  return { dr = 3, tx_power = 0, nb_trans = 1 }
end

TTN (The Things Network) Device Configuration

1. Create Application

  1. Go to TTN Console
  2. Applications+ Create application
  3. Application ID: turtle-tracker-lora (ou similaire)

2. Register Device (ABP)

  1. Dans l'application → End devices+ Register end device
  2. Manually → remplir :
    • Frequency plan: Europe 863-870 MHz (SF9 for RX2 - recommended)
    • LoRaWAN version: LoRaWAN Specification 1.0.3
    • Regional Parameters version: RP001 Regional Parameters 1.0.3 revision A
  3. Provisioning :
    • Activation mode: Activation by personalization (ABP)
    • DevEUI: Lire depuis le device (PARMR LRP01) ou laisser auto-générer
    • Device address: Generate → copier pour PARMW LRP04
    • AppSKey: Generate → copier pour PARMW LRP05
    • NwkSKey: Generate → copier pour PARMW LRP06

3. Device Settings (important pour ABP)

Dans General settingsNetwork layer :

  • Frame counter width: 32 bit (par défaut)
  • Resets frame counters: ONCRITIQUE pour les devices avec power cycles
    • Sans ça, le TTN rejette les messages après un reboot (compteur reset à 0)
  • RX1 delay: 1 second (par défaut)
  • RX1 data rate offset: 0
  • RX2 data rate index: 3 (SF9 — match avec la config par défaut RAK3172)
  • RX2 frequency: 869.525 MHz (EU868 default)

Dans General settingsApplication layer :

  • Skip payload crypto: Laisser OFF (payload chiffré normalement)

4. Payload Decoder (uplink formatter)

Dans Payload formattersUplinkCustom Javascript formatter :

function decodeUplink(input) {
  var data = {};
  var bytes = input.bytes;

  // Packet type is in the FIRST 3 BITS (bit 7-5 of byte 0, MSB first)
  var pkt_type = (bytes[0] >> 5) & 0x07;

  var TYPES = {
    0: 'gps_single',    // 0b000
    1: 'gps_multi',     // 0b001
    2: 'sensor',        // 0b010
    3: 'status',        // 0b011
    4: 'cloudlocate'    // 0b100
  };
  data.packet_type = TYPES[pkt_type] || 'unknown';
  data.raw_hex = bytes.map(function(b){return b.toString(16).padStart(2,'0');}).join('');

  // TODO: Implement full bit-level decoder — see wiki "Satellite Message Format" § 2
  // Header layout: type(3) + flags(4) + voltage(7) = 14 bits (2 bytes, padded)
  // For CloudLocate: type(3) + format(2) + flags(4) + voltage(7) = 16 bits

  return { data: data };
}

Note : Un décodeur complet est à implémenter en suivant le format défini dans LoRaPacketBuilder (voir 12 — LoRa § Packet formats).

5. Gateway Deployment

Pour un déploiement côtier tortue :

  • Gateway recommandé : RAK7268V2 (Wisgate Edge Lite 2) ou Kerlink iStation
  • Placement : En hauteur (>5m), vue dégagée sur la mer
  • Antenne : Omnidirectionnelle 868 MHz, gain 5-8 dBi
  • Alimentation : PoE ou solaire + batterie
  • Portée attendue : 5-15 km sur eau (propagation favorable)

Multi-GPS Entries per Packet

Le firmware supporte l'envoi de plusieurs positions GPS dans un seul paquet LoRa, via le depth pile :

DR Max Payload GPS entries par paquet
DR0-DR2 51 bytes 7 entries (tronqué auto)
DR3 115 bytes 15 entries (capé par compteur 4-bit)
DR4-DR5 222 bytes 15 entries (capé par compteur 4-bit)

Format multi-GPS :

  • 1ère entry : full (86 bits : lat, lon, speed, heading, altitude, numSV)
  • Entries suivantes : delta (50 bits : lat, lon, speed seulement)
  • Overhead header : 22 bits (type 3 + flags 4 + voltage 7 + count 4 + delta_time 4)

Configurer DEPTH_PILE >= 4 pour accumuler des positions entre les surfaces.


Historique des positions GPS (depth pile cross-surfacing)

Le depth pile persiste entre les plongées (n'est pas vidé sur dive). Les fixes obtenus aux surfacings précédents restent en RAM et sont envoyés lors des surfacings ultérieurs, jusqu'à ce que la pile se vide ou que des entrées plus anciennes soient évincées.

Quand l'historique est-il transmis ?

L'envoi de positions historiques se déclenche uniquement en Phase 2 du SURFACING_BURST, c'est-à-dire après le premier vrai fix GPS du surfacing courant. Avant ce fix (Phase 1 = status burst + CloudLocate live), aucune position historique ne sort — seuls des paquets status ou des paquets CloudLocate "live" (raw measurement courant) sont transmis.

Flot complet

Surface 1 → fix A → stocké [A]            → phase 2 → TX A
Dive (depth pile conservée)
Surface 2 → fix B → stocké [A, B]         → phase 2 → TX inclut [A, B]
Dive
Surface 3 → pas de fix                    → phase 1 seulement
                                            → CloudLocate "live" envoyé
                                            → A et B restent en RAM, jamais émis
Dive
Surface 4 → fix C → stocké [A, B, C]      → phase 2 → TX inclut [A, B, C]

Eviction FIFO sur pile pleine

Quand ARGOS_DEPTH_PILE est atteint (ex: cap=4) et qu'un nouveau fix arrive :

  • L'entrée la plus ancienne est éjectée via pop_front() (depth_pile.hpp:55)
  • Cette éviction est inconditionnelle — même si l'entrée évincée n'a pas fini ses retries (burst_counter > 0)
  • C'est un choix délibéré : sur tortue à surfacings courts, garder du frais est plus précieux que de finir des retries de vieilles positions.
[A, B, C, D] (pile pleine, cap=4)
   ↓ fix E arrive
[B, C, D, E]  (A évincé, ses retries éventuels perdus)

Cas limite — pas de fix pendant N surfacings

Si la tortue ne capture aucun vrai fix pendant plusieurs surfacings d'affilée :

  • Phase 1 envoie status / CloudLocate live → OK, données collectées
  • Phase 2 jamais déclenchée → les fixes historiques restent figés en RAM
  • Au prochain fix réel (surfacing N), Phase 2 s'enclenche et envoie l'historique conservé (jusqu'à ARGOS_DEPTH_PILE entrées les plus récentes)
  • Les fixes plus anciens que ce cap sont définitivement perdus (FIFO)

Comportement intentionnel : privilégier les positions récentes à coup sûr plutôt que de risquer de saturer la pile avec des données vieilles. Si tu as besoin de plus de profondeur historique, augmenter ARGOS_DEPTH_PILE (jusqu'à 24) — au prix de plus de RAM.


Gestion des GPS échoués (NO_FIX) dans la pile

Quand le GPS ne parvient pas à se fixer, le firmware génère une entrée NO_FIX (event_type=NO_FIX, valid=false). Ces entrées sont normalement transmises dans un paquet GPS Multi avec des marqueurs 0xFF à la place des champs position — mais c'est inutile dans la plupart des cas et consomme de l'airtime.

Règles appliquées

1. Skip complet des NO_FIX (cache absent) — depth_pile.cpp:28-50

Si les deux conditions sont vraies, l'entrée NO_FIX est jetée directement :

  • GNSS_FASTLOC_MODE (GNP45) ≠ OFF (= 1 DEGRADED_PVT ou 2 CLOUDLOCATE)
  • ARGOS_MODE (UNP04) == SURFACING_BURST (5)

Dans ce cas, process_status_burst() dispose d'un chemin fastloc/cloudlocate dédié qui produit un paquet bien plus utile (quality metadata hAcc/numSV ou blob u-blox brut). Laisser le NO_FIX dans la pile ne ferait que doubler la TX et gaspiller de l'airtime.

Log attendu :

[INFO] DepthPileManager::notify_peer_event: skip NO_FIX (fastloc GNP45=1 + SURFACING_BURST)

2. Déduplication des NO_FIX consécutifs — depth_pile.cpp:176-192

Hors du cas 1 (LEGACY, DUTY_CYCLE, ou fastloc=OFF), le NO_FIX sert encore de heartbeat ("device alive, no sats"). Mais un seul heartbeat suffit — pas besoin d'accumuler 4-10 NO_FIX identiques.

Règle : si le cache est NO_FIX et la dernière entrée de la pile est aussi NO_FIX, on remplace l'ancienne au lieu d'ajouter. Le timestamp est rafraîchi mais la taille de la pile ne croît pas.

Cas concrets :

Séquence événements GPS Pile avant Pile après Remarque
NO_FIX T1 → pile vide [] [NO_FIX(T1)] append (cas initial)
NO_FIX T2 (encore un) [NO_FIX(T1)] [NO_FIX(T2)] replace (heartbeat refresh)
NO_FIX T3 (puis un 3e) [NO_FIX(T2)] [NO_FIX(T3)] replace (pas d'accumulation)
FIX T4 (fix enfin OK) [NO_FIX(T3)] [NO_FIX(T3), FIX(T4)] append (nouveau type)
NO_FIX T5 après fix [NO_FIX(T3), FIX(T4)] [NO_FIX(T3), FIX(T4), NO_FIX(T5)] append (last=FIX)
NO_FIX T6 (4e) [..., FIX(T4), NO_FIX(T5)] [..., FIX(T4), NO_FIX(T6)] replace

Log attendu (replace) :

[INFO] DepthPileManager: NO_FIX dedup — replaced previous NO_FIX (heartbeat refreshed)

Tableau de synthèse — comportement NO_FIX

ARGOS_MODE GNP45 NO_FIX arrivant Pile après
SURFACING_BURST DEGRADED_PVT ou CLOUDLOCATE Jeté (fastloc fait le boulot) inchangée
SURFACING_BURST OFF Dédup si dernier = NO_FIX au plus 1 NO_FIX de suite
LEGACY OFF ou ON Dédup si dernier = NO_FIX idem
DUTY_CYCLE OFF ou ON Dédup si dernier = NO_FIX idem

Ce qu'on évite

Avant ces règles, un device sans fix pendant 4 surfacings consécutifs :

  • Accumulait 4 NO_FIX dans la pile
  • Émettait un paquet GPS Multi 33 B avec 4 × 50 bits de 0xFF padding
  • Airtime SF9 ≈ 300 ms pour zéro info position

Après :

  • Cas fastloc+SURFACING_BURST : 0 TX GPS Multi inutile, un fastloc packet ~21 B émis à la place
  • Cas dédup seul : 1 NO_FIX dans la pile → paquet GPS Single 14 B → airtime ÷ 2.5, preserve le heartbeat backend

FASTLOC ≠ NO_FIX

Attention, les entrées FASTLOC (event_type=FASTLOC, valid=true) contiennent une vraie position dégradée (lat/lon utilisables avec hAcc élevé) — elles ne sont pas concernées par la skip ni la dédup. Elles passent normalement dans le depth pile.


Auto DR Bump at Boot

Certains messages LoRa ne tiennent pas dans tous les DR. À chaque boot, le driver calcule la taille max que la config active peut générer et force LORA_DR au minimum qui fait passer le paquet.

Déclencheurs :

Config Taille max DR minimum forcé
GNP45=2 (CloudLocate) + GNP46=2 (MEAS50) 52 B DR ≥ 3
Sensor packet > 51 B (théorique, tous capteurs + fastloc = 39 B aujourd'hui) variable DR ≥ 3 ou 4
MEAS20, MEASC12, sensor ≤ 39 B ≤ 22 B pas de bump

Log au boot lors d'un bump :

[WARN] LoRaDevice: max packet 52 B requires DR≥3 — bumping LORA_DR 0 → 3

Le LORA_DR en flash reste la valeur utilisateur — le bump n'agit que sur la config runtime envoyée au RAK3172. Pour désactiver le bump, il faut réduire la taille du paquet (ex: passer de MEAS50 à MEAS20).

Garantie : aucun paquet n'est jamais rejeté silencieusement à cause de la taille. Si MEAS50 est activé et que tu configures LORA_DR=0, le driver te dira dans le log qu'il passe à DR3.


Compile-Time Flags (LoRa-specific)

Ces deux flags sont des options CMake passables à cmake -D... ou via le script build_linkitv4_lora.sh. Ils ne sont effectifs que quand LORA_RAK3172=ON.

LORA_DCS_ENABLE (default: ON)

Contrôle l'envoi de AT+DCS=<n> au RAK3172 pendant state_configure.

Valeur Effet Usage
ON (1) Module applique les limites ETSI EN 300 220 : 1 % airtime sur g1/g4, 0.1 % sur g2, 10 % sur g3 Production obligatoire en EU — conforme légalement
OFF (0) Duty cycle désactivé côté module — permet TX continu Test RF en cage de Faraday uniquement — illégal en déploiement EU

Comment le module applique DCS : quand ON, si tu essaies un TX dont l'airtime dépasse le budget horaire restant sur la sous-bande courante, le RAK3172 retourne AT_DUTYCYCLE_RESTRICTED (ou reporte le TX). Notre driver log [ERROR] AT+SEND command failed et retry via backoff service 60 s.

À DR3 (SF9) avec un status 2 B : airtime ~120 ms → DC 1 % autorise ~300 TX/h/sous-bande. Large. Jamais gênant en usage tracker normal.

Log au boot (DCS ON) :

[TRACE] LoRaComm::send> AT+DCS=1
[TRACE] LoRaComm::rx< OK

LORA_POWER_OFF_UNDERWATER (default: OFF)

Action sur chaque événement dive (plongée détectée par le SWS) côté LoRaTxService.

Valeur Comportement dive Wake suivant
OFF (default) Module reste dans son LP mode (LORA_LP_MODE=1 → Stop2 ~1.7 µA) ~10 ms — byte UART réveille le module de Stop2
ON m_device.power_off_immediate() : SAT_PWR_EN=00 µA ~2 s — full boot RAK3172 + re-exécution de state_configure (+ join si OTAA)

Consommation économisée (ON vs OFF) sur une tortue qui plonge 95 % du temps :

Standby (OFF) Shutdown (ON) Économie
1.7 µA × 8322 h/an 14.15 mAh/an 0 mAh/an ~14 mAh/an

Sur une batterie 700 mAh : 14 mAh = 2 % du budget annuel. Gain marginal.

Coût (ON) : si la tortue surface 20 s et que la boot + join prend 10-15 s, tu perds la moitié de la fenêtre pour TX. En ABP la boot + configure = ~3 s, c'est jouable. En OTAA, c'est prohibitif.

Recommandations :

  • Tracker tortue courte surface (ABP)OFF (default). Standby 10 ms + 1.7 µA négligeable.
  • Tracker statique longue plongée (>1 h, rares surfaces) → ON avantageux si ABP.
  • OTAA → toujours OFF. Le rejoin coûte >10 s et n'est pas acceptable sur surface courte.

Log au boot quand activé :

[INFO] LoRaTxService: dive detected — powering off LoRa module (LORA_POWER_OFF_UNDERWATER=1)
[INFO] LoRaDevice::state_power_off_enter

Exemples de build

# Default — production EU, standby préservé (recommandé tortue)
./scripts/build_linkitv4_lora.sh

# Tracker longue plongée, batterie limite — cut power underwater
cmake -DLORA_POWER_OFF_UNDERWATER=ON ...

# Test RF en labo — désactiver DCS (NE PAS flasher en opérationnel EU)
cmake -DLORA_DCS_ENABLE=OFF ...

Power Budget Estimate (ABP + DR5 + Standby)

Phase Duration Current Energy
Sleep (underwater) ~95% du temps ~3µA (nRF52840 + SWS) minimal
RAK3172 standby entre TX 1.7µA minimal
Wake from standby 10ms ~10mA 0.03 mAh
TX at SF7/+16dBm ~100ms ~120mA 3.3 mAh
GPS acquisition 5-30s ~25mA 35-210 mAh
Total per cycle ~40-215 mAh

Avec une batterie 700mAh et 4 surfacings/jour : ~180 jours d'autonomie estimée (hors courant de fuite et température).


Format d'encodage des packets LoRa

Le firmware émet 5 types de packets sur le FPort configuré (par défaut LRP14=2). Le type est identifié par les 3 premiers bits du payload. Bit packing MSB-first big-endian (cf bitpack.hpp).

Vue d'ensemble

Type ID Nom Cas d'usage Taille typique
0b000 GPS_SINGLE Une seule position GPS valide 104 bits = 13 B
0b001 GPS_MULTI Plusieurs positions accumulées dans le depth pile 104 + (N-1)×66 bits
0b010 SENSOR GPS + capteurs (pH, pression, AXL, etc.) ou fastloc PVT variable
0b011 STATUS Heartbeat batterie seule (phase 1 surfacing burst) 14 bits = 2 B
0b100 CLOUDLOCATE Mesures GNSS brutes pour résolution cloud-side 16 + blob×8 bits

Header standard (14 bits — partagé par GPS/SENSOR/STATUS)

┌──────────┬───────────┬───────────────┐
│ type     │ flags     │ battery       │
│ 3 bits   │ 4 bits    │ 7 bits        │
└──────────┴───────────┴───────────────┘

Flags (4 bits, MSB-first) :

  • bit 3 : is_out_of_zone (1 = device hors zone GPS définie)
  • bit 2 : is_low_battery (1 = batterie critique)
  • bit 1 : valid (1 = fix GPS valide ; 0 = sentinelles dans les champs lat/lon/etc.)
  • bit 0 : is_fastloc (1 = SENSOR packet contient un fastloc PVT, pas un vrai fix)

Battery (7 bits) :

  • voltage_mV = 2700 + encoded × 20
  • Range : 2700-5240 mV en pas de 20 mV
  • ⚠️ Dead zone < 2700 mV : tout encode à 0. Décodeur doit afficher "≤ 2.7 V" quand encoded==0 && is_low_battery==1
  • encoded == 127 → afficher "≥ 5.24 V"

Header CloudLocate (16 bits — NON-STANDARD)

┌──────────┬──────────┬───────────┬───────────────┐
│ type     │ format   │ flags     │ battery       │
│ 3 bits   │ 2 bits   │ 4 bits    │ 7 bits        │
└──────────┴──────────┴───────────┴───────────────┘

CloudLocate insère un champ format 2 bits entre type et flags :

  • 0b00 = MEASC12 (12 B blob)
  • 0b01 = MEAS20 (20 B blob)
  • 0b10 = MEAS50 (50 B blob, LoRa-only)

Décodeur doit brancher sur type==0b100 pour lire ces 2 bits avant les flags.

GPS_FULL block (86 bits — utilisé dans SENSOR avec GPS, et entry[0] de GPS_SINGLE/MULTI)

┌──────┬───────┬──────┬──────────┬───────────┬───────┬─────────┬──────────┬───────┐
│ day  │ hour  │ min  │ latitude │ longitude │ speed │ heading │ altitude │ numSV │
│ 5b   │ 5b    │ 6b   │ 21b      │ 22b       │ 7b    │ 8b      │ 8b       │ 4b    │
└──────┴───────┴──────┴──────────┴───────────┴───────┴─────────┴──────────┴───────┘

Conversions (formules de décodage) :

Champ Bits Décodage Sentinelle invalid
latitude 21 (bit 20=signe) (encoded & 0xFFFFF) / 10000.0 × (sign ? -1 : 1) 0x1FFFFF
longitude 22 (bit 21=signe) (encoded & 0x1FFFFF) / 10000.0 × (sign ? -1 : 1) 0x3FFFFF
speed 7 km/h = encoded × 2 × 3600 / 1000 = encoded × 7.2 ⚠️ encoded==127 = soit max soit invalide → utiliser le flag valid 0x7F (collision avec max)
heading 8 degrees = encoded × 1.42 (max valide 254, sentinel 255) 0xFF
altitude 8 meters = encoded × 40 (max valide 254 = 10160 m, sentinel 255) 0xFF
numSV 4 direct (0-15) 0 quand fix invalide
day 5 jour du mois 1-31 (timestamp UTC) n/a (toujours présent)
hour 5 heure 0-23 n/a
min 6 minute 0-59 n/a

Note : le timestamp n'a ni année ni mois → ambiguïté à long terme. Le décodeur doit reconstruire l'année/mois en utilisant la date de réception côté serveur comme référence.

GPS_MULTI delta entries (66 bits chacune)

Pour N > 1 entries dans GPS_MULTI :

  • entry[0] = newest, format GPS_FULL avec timestamp absolu
  • entry[1..N-1] = older entries en delta-encoding :
┌──────────┬───────────┬───────┬───────────────┐
│ latitude │ longitude │ speed │ delta_t_min   │
│ 21b      │ 22b       │ 7b    │ 16b           │
└──────────┴───────────┴───────┴───────────────┘

delta_t_min[i] = nombre de minutes entre entry[i-1] et entry[i] (entry[i] est delta_t_min[i] minutes PLUS VIEUX que entry[i-1]).

Reconstruction décodeur :

t[0] = newest_absolute_timestamp  (du GPS_FULL)
for i = 1..N-1:
    t[i] = t[i-1] - delta_t_min[i] × 60s

delta_t_min == 0xFFFF (sentinel) = ≥ 65535 min = ~45 jours (entry très vieille).

SENSOR packet — bitmask + sensor blocks

Après le header, un mask 6 bits indique quels blocs sont présents :

┌──────┬──────┬──────┬──────┬──────┬──────┐
│ gps  │ als  │ ph   │ pres │ tmp  │ axl  │
│ bit5 │ bit4 │ bit3 │ bit2 │ bit1 │ bit0 │
└──────┴──────┴──────┴──────┴──────┴──────┘

Les blocs suivent dans l'ordre gps → fastloc_quality? → als → ph → pressure → sea_temp → axl, chacun présent uniquement si son bit dans le mask est à 1.

Bloc Bits Encodage
GPS_FULL 86 voir tableau ci-dessus
FASTLOC_QUALITY 60 présent si is_fastloc==1 dans flags. fixType(2) + hAcc_m(16) + vAcc_m(16) + pDOP×10(8) + hDOP×10(8) + onTime_s(10)
ALS 17 luminosité raw uint17 du LTR-303 (table conversion lux côté datasheet)
PH 14 encoded = pH × 1000 → decoder pH = encoded / 1000 (ex: pH 7.200 = 7200)
PRESSURE 15 + 14 pressure(15) + press_temp(14), voir formules détaillées ci-dessous
SEA_TEMP 14 encoded = (°C + 126) × 1000 → decoder °C = encoded/1000 − 126 (EZO-RTD, steps 0.001°C)
THERMISTOR 14 encoded = (°C + 40) × 100 → decoder °C = encoded/100 − 40 (RSPB only, steps 0.01°C, même slot mask que sea_temp)
AXL 67 axl_temp(14) + x_axis(15) + y_axis(15) + z_axis(15) + activity(8) — formules détaillées ci-dessous

Formules exactes (firmware → wire → decoder)

⚠️ La conversion double → uint se fait dans depth_pile.cpp:58-101 AVANT le bit-packing. Voici la source de vérité :

Pressure :

firmware : raw_15 = pressure_bar × 1000        (donc raw_15 est en hPa directement)
decoder  : hPa    = raw_15                     (direct, pas de division)
         : bar    = raw_15 / 1000
         : Pa     = raw_15 × 100
ex       : 1.013 bar (sea level) → raw_15 = 1013 (hPa)
range    : 0 − 32.767 bar

Pressure temperature :

firmware : raw_14 = (°C + 40) × 100
decoder  : °C     = raw_14 / 100 − 40
ex       : 25.30 °C → raw_14 = 6530 ; −10 °C → raw_14 = 3000
range    : −40 à +123.83 °C (couvre LPS28DFW −40 à +85 °C)

Sea temp (EZO-RTD)⚠️ BUG FIRMWARE DÉTECTÉ — encoding déborde le champ 14 bits :

firmware actuel : raw = (°C + 126.0) × 1000     [depth_pile.cpp:77]

Pour T = -2°C (mer arctique) : raw = 124000 → champ 14b (max 16383) → débordement
                                            → PACK_BITS tronque silencieusement
Pour T = 0°C   :              raw = 126000 → débordement
Pour T = 15°C  :              raw = 141000 → débordement
Pour T = 35°C  :              raw = 161000 → débordement

→ TOUTE température de mer normale est mal encodée.

Le champ 14 bits ne permet d'encoder qu'environ -126°C à -109.6°C avec
cette formule, ce qui n'a aucun sens physique pour de la mer.

Fix probable côté firmware : `× 100` au lieu de `× 1000` (résolution 0.01°C
au lieu de 0.001°C) → range -126°C à +37.83°C → couvre la plage marine OK.

À reporter à l'équipe firmware. En attendant :
  - Ne pas faire confiance à SEA_TEMP en field
  - Si LPS28DFW présent : utiliser pressure_temp à la place (formule fiable)
  - Pour RSPB : utiliser THERMISTOR (formule fiable, même slot)

Si le fix `× 100` est confirmé en future release :
  decoder : °C = raw / 100 − 126

Thermistor (RSPB only) :

firmware : raw_14 = (°C + 40) × 100
decoder  : °C     = raw_14 / 100 − 40
range    : −40 à +123.83 °C

AXL die temp :

firmware : raw_14 = (°C + 40) × 100
decoder  : °C     = raw_14 / 100 − 40
range    : −40 à +123.83 °C (BMA400 spec −40 à +85 °C)

AXL X / Y / Z axes (15 bits chacun) :

firmware : raw_15 = (g + g_range) × 1000
decoder  : g      = raw_15 / 1000 − g_range

⚠️ g_range dépend du paramètre AXL_SENSOR_MEASUREMENT_RANGE :

reg value g_range raw range encode
0 ±2g 0 à 4000
1 ±4g 0 à 8000
2 ±8g 0 à 16000
3 ±16g 0 à 32000 (max 15 bits = 32767)

Le décodeur doit lire AXL_SENSOR_MEASUREMENT_RANGE via PARMR pour savoir comment décoder. Sans ça, impossible de produire des valeurs de g correctes.

AXL activity :

firmware : raw_8 = port[4]                     (counter direct)
decoder  : activity = raw_8                    (0-255 raw)

pH :

firmware : raw_14 = pH × 1000
decoder  : pH = raw_14 / 1000
ex       : pH 7.200 → raw_14 = 7200
range    : 0 à 16.383 (couvre 0-14 pH)

ALS (luminosité) :

firmware : raw_17 = port[0]                    (raw du driver LTR-303, lux brut)
decoder  : appliquer table de conversion datasheet LTR-303
range    : 0 à 131071 (17 bits)

Sentinelles "timeout / no data"

Si un capteur activé n'a pas reporté à temps, le firmware met 0xFFFFFFFF dans le cache (depth_pile.cpp:113-141). Après packing dans le champ N bits, ça devient (1 << N) − 1 (tous les bits à 1) :

Champ Sentinel encoded
ALS 0x1FFFF (17 bits)
pH 0x3FFF (14 bits)
Pressure 0x7FFF (15 bits)
Pressure temp / sea_temp / thermistor / AXL temp 0x3FFF (14 bits)
AXL X/Y/Z 0x7FFF chacun (15 bits)
AXL activity 0xFF (8 bits)

Decoder : if (encoded == (1 << bits) - 1) → afficher "TIMEOUT / NO DATA"

Exemple complet de décodage

Payload reçu : 6104 (hex) sur 2 bytes = 14 bits utiles (STATUS packet) :

Bytes:  0x61 0x04
Bits:   01100001 00000100
        ┌─┴─┐┌──┴─┐┌─────┴─────┐
        type flags  voltage
        011  0000   0000100... (premiers 7 bits)
  • type = 011 = STATUS
  • flags = 0000 (pas d'info hors zone, pas low battery, pas valid, pas fastloc)
  • voltage = 0000100 = 4 → 2700 + 4×20 = 2780 mV

Limites de payload par DR

Cf DR Trade-offs. Le firmware bump automatiquement LORA_DR au minimum requis pour le plus gros payload activable (cf LoRaDevice::load_config_from_store).

Spec décodeur côté serveur

Un prompt clé-en-main pour implémenter un décodeur (Python / TS / autre) dans ton GUI est fourni séparément dans le dépôt. Tous les bits sont en MSB-first big-endian — utiliser une lib bitstring (Python) ou équivalent.

Clone this wiki locally