-
Notifications
You must be signed in to change notification settings - Fork 3
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 withARGOS_SMD=ON.
| 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=ONor 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.
-
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 toGNSS_ACQ_TIMEOUTfor 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_entryinConfigurationStore(RAM), populated bygps_service::task_process_degraded_gnss_dataalongside the existingm_last_gps_log_entryFIX cache. -
Skip
state_idleon 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 bym_config_reload_pendingso 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_LPMLVLenum entries + cmd table rows appended (existing ATCmd enum values stay stable —AT_SLEEP_NOWstays at 45).LRP15 LORA_LP_MODEdocstring 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 toGNSS_ACQ_TIMEOUTwaiting 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_ERRORno longer arms AFTER_LAST_TX cooldown (dbcca4fe):is_tx_finishednow distinguishesMAC_TX_DONEfromMAC_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 resetm_cooldown_armed). Now gated.
| 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.
# === 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)# === 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)Note —
ARGOS_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 paramGNSS_NBLASTFIX_TOSENDwas removed in v4.x — useARGOS_DEPTH_PILEinstead. Some GUIs may still display "GNSS_NBLASTFIX_TOSEND" but should internally map to the same DTE keyARP16.
Bounded depth pile (v4.x) —
ARGOS_DEPTH_PILEnow 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 FIFOpop_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, setARGOS_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_locglobal field to a per-entry 16-bitDELTA_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.
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é
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).
| 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 |
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.
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 prochainsend()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_pendingest levé ; l'apply se fait au retour en idle, le packet en cours est droppé proprement avecKineisEventDeviceError(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 dewrite_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.
# 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# 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 matchentCas 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.
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::substrpar 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_valuesqui 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
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.
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()
Solutions firmware possibles, pas encore implémentées (pour ne pas casser l'existant qui marche) :
-
Remplacer
safe_sscanfparstrtoul/strtod: newlib'sstrto*ne touche pas au heap. ~30 lignes dansdte_protocol.hpp:1188-1232. -
Remplacer
substr()parstd::string_viewdans la boucle KEY_VALUE_LIST : ~0 allocations vs ~300/template. -
Augmenter
configTOTAL_HEAP_SIZEde 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).
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 :
- Premier PARMW : 20 params (ex. les
LRP*) - Deuxième : 20 suivants (ex. les
GNP*) - 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 | 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)
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 g1 → 1 % 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 %.
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_countest incrémenté avant TX → un packet droppé pour DCS compte quand même dansSURFACING_BURST_MAX_MSG - Le
burst_counterdu fix est décrémenté dansretrieve()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.
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 | INIT=20, STEP=2, MAX=60 |
|
| DR4 | ~10 s | 5 s | INIT=10, STEP=2, MAX=30 |
|
| DR5 | ~5 s | 5 s | ✅ tout juste OK | défauts conviennent |
# 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_ENABLEplus bas.
| 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).
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- 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/jour → largement sous le quota TTN 30 s/jour ✅
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) —ONuniquement 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.
[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.
- Gateway : RAK7268V2 / RAK7289 / WisGate Edge series
-
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
- Inscris la gateway dans WisDM (
-
ChirpStack :
- Crée un tenant, application, device-profile (cf. ci-dessous)
- Inscris ton tracker (DevEUI, AppEUI, AppKey en OTAA — ou DevAddr/keys en ABP)
-
Tracker : configure les credentials qui matchent ChirpStack (
PARMW LRP01-LRP06)
# 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 }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- Pas de quota Fair Use → bursts plus longs (
MAX_MSG=8au 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)
- 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)
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- Go to TTN Console
- Applications → + Create application
- Application ID:
turtle-tracker-lora(ou similaire)
- Dans l'application → End devices → + Register end device
-
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
-
Frequency plan:
-
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
-
Activation mode:
Dans General settings → Network layer :
-
Frame counter width:
32 bit(par défaut) -
Resets frame counters:
ON— CRITIQUE 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 settings → Application layer :
-
Skip payload crypto: Laisser
OFF(payload chiffré normalement)
Dans Payload formatters → Uplink → Custom 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).
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)
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.
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.
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.
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]
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)
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_PILEentré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.
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.
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)
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)
| 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 |
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
0xFFpadding - 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
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.
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.
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.
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
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=0 → 0 µ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) →
ONavantageux 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
# 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 ...| 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).
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).
| 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 |
┌──────────┬───────────┬───────────────┐
│ 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" quandencoded==0 && is_low_battery==1-
encoded == 127→ afficher "≥ 5.24 V"
┌──────────┬──────────┬───────────┬───────────────┐
│ 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.
┌──────┬───────┬──────┬──────────┬───────────┬───────┬─────────┬──────────┬───────┐
│ 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 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.
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).
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 |
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) —
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)
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"
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
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).
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.