-
Notifications
You must be signed in to change notification settings - Fork 3
07 ‐ Architecture
┌─────────────────────────────────┐
│ Application │ main.cpp, state machine
├─────────────────────────────────┤
│ Services Framework │ ServiceManager, 15+ services
├─────────────────────────────────┤
│ DTE Protocol Layer │ DTEHandler, encoder/decoder
├─────────────────────────────────┤
│ Configuration Store │ 214 params, flash persistence
├─────────────────────────────────┤
│ Hardware Abstraction Layer │ Sensor, Timer, PMU, GPIO
├─────────────────────────────────┤
│ Board Support Package │ linkitv4_v1.0, rspbtracker_v1.0
├─────────────────────────────────┤
│ nRF52840 + SoftDevice │ Nordic SDK 17.0.2
└─────────────────────────────────┘
-
core/— Portable code, no hardware dependencies-
core/services/— Service implementations -
core/protocol/— DTE protocol, encoder, decoder, params -
core/configuration/— ConfigStore, calibration -
core/hardware/— Abstract interfaces (Sensor, Timer) -
core/logging/— Logger, messages
-
-
ports/nrf52840/— Hardware-specific implementations-
ports/nrf52840/bsp/— Board support packages -
ports/nrf52840/core/hardware/— Sensor drivers (BMA400, LPS28DFW, M8, etc.)
-
-
Dependency Inversion:
core/defines abstract interfaces,ports/provides implementations - RAII: SensorsPowerGuard for VSENSORS power management
- Static Registry: SensorManager, LoggerManager, CalibratableManager, ServiceManager
- Variant-based Configuration: BaseType = std::variant<...> for type-safe parameter storage
- Event-driven Services: ServiceEvent for inter-service communication
- Bounded recovery > clever optimization (sealed-device mindset): boot-fail counter with factory-reset cascade, exception barriers around every destructive step, virtual RTC fallback, cooldown gates kill latent tasks before reaching the hardware. See Sealed-device hardening below for the full defense layer summary.
LinkIt v4 deployments target sealed wildlife trackers running months to years without operator access. Every robustness defense is bounded — fail loudly into a known-safe state rather than try to keep going. This section catalogues the defenses; the per-subsystem details live next to their primary owner (RTC in 10 — GPS Guide § E; cooldown in 13 — Underwater § D; rate limiter in 11 — Satellite Communication § E.4; SMD autofallback in 11 § Part D).
Added in: commit
ee81573a. Located atcore/sm/gentracker.cpp.
A struct s_bootfail_noinit lives in .noinit RAM (survives reset, lost on power-off):
struct BootFailNoinit {
uint16_t consecutive_failures; // bumped at each BootState::entry
uint8_t factory_reset_attempted; // 1 = already tried
uint8_t _pad;
uint16_t crc; // CRC16 over the above
};Bumped at every BootState::entry(). Cleared on successful entry to OperationalState.
| consecutive_failures | Action |
|---|---|
| 1–2 | Normal boot, ErrorState → soft reset retry |
| 3 | Trigger configuration_store->factory_reset(), set factory_reset_attempted=1, then soft reset. Factory reset preserves DECID/HEXID/calibration. |
| 4 | Counter resumed; if Operational reached → reset to 0 (added commit dbcca4fe) |
| 5 | Fall through to OffState (permanent off, magnet wake required) |
In CPPUTEST builds, the cascade is disabled and the counter is reset at BootState::entry() under #ifdef CPPUTEST to prevent contamination between tests.
The cascade addresses the BAD_FILESYSTEM brick identified by the FSM review: previously, multiple firmware paths could put the device in OffState (which goes to System OFF with reed-magnet as the only wake source) without operator intervention, permanently bricking a sealed unit.
Added across gentracker.cpp and service.cpp (commit ee81573a, robustness pass 2). Each barrier prevents a single subsystem panic from cascading into a permanent freeze.
| # | Location | Catches | Effect |
|---|---|---|---|
| B2 |
main.cpp top-level |
std::exception, ... (added to existing ErrorCode) |
std::bad_alloc, bad_function_call, etc. now post a generic ErrorEvent. Without these, std::terminate → abort() would hang in fputc until 15-min WDT cycle. |
| B3 | OperationalState::exit() |
every destructive step independently | A teardown exception during a reed-magnet gesture would have escaped to main → ErrorState → OffState, bricking the device on a glitchy peripheral. Steps: save_params, LED dispatch, ServiceManager::stopall, BLE stop, battery_monitor unsubscribe. |
| B4 |
apply_file_update() (gentracker.cpp:642) |
OTA-apply exceptions | CRC mismatch mid-stream or SMD DFU SPI error previously escaped → ErrorState → OffState. Now stays in Configuration, ready for OTA retry. |
| GenTracker | periodic_config_flush() |
save_params() exceptions |
LFS unwritable or RAM transient doesn't terminate the periodic-flush task. |
| ServiceManager | stopall() |
per-service try/catch + WDT kick between each | A single service throwing during teardown doesn't leak past the stopall loop, and the WDT kick prevents the cumulative stopall budget from exhausting the 15 min WDT. |
The boot-fail counter is the last-resort recovery if these barriers fail to contain the issue.
Power-fail-warning handler (nrf_pmu.cpp, commit 12fdd581) was previously calling configuration_store->save_params() after the cooldown noinit save and the RAM-only RTC update. That triggers an LFS journal write of ~100 ms while the device has only supercap energy left.
POFCON V27 fires at 2.7 V on Vbatt; if the brown-out was caused by deep discharge (supercap drained), the write is truncated and can corrupt the params block. LFS itself is journaling so the filesystem doesn't brick, but a half-written params record can be rejected on next boot and lose all 30 minutes of param updates that the periodic flush already covers cleanly.
Current policy in the POF handler:
-
ServiceManager::save_cooldown_state()— noinit RAM, atomic, ~µs (kept) -
write_param(LAST_KNOWN_RTC, now)— RAM-only mutation, no flash (kept) -
save_params()— REMOVED
Durable persistence still happens via the 30-min periodic flush (CONFIG_FLUSH_INTERVAL_MS) and the clean-shutdown save_params() in PMU::powerdown(). Trade-off accepted: on POF we may lose up to 30 min of RTC drift / TX counter, but never corrupt all 227 params.
Power-off device while GPS is running (commit 927d0837): the factory-reset / shutdown paths now correctly power off the GPS rail before sending the SoC into System OFF. Previously, a power-off while GPS was actively acquiring left the GPS rail energized, draining the battery for hours.
PREOP_STUCK_REED_MAX_MS=20 s: a manufacturing magnet residue or hardware short would leave m_confirmation_pending != NONE forever, with no RELEASE event arriving to start the 2-s confirmation timeout. The previous code returned without rescheduling, wedging the device in PreOp forever at full active current (~5–20 mA) — the only HW fault that doesn't self-degrade to near-zero current.
Now the transit task re-arms itself and, after PREOP_STUCK_REED_MAX_MS, forces transit to Operational regardless of the pending confirmation.
| Defense | Failure handled | Recovery time |
|---|---|---|
start_watchdog (15 min) |
Genuine CPU hang | Hard reset after 15 min |
| WDT kicks in long ops | Long flash op exhausts WDT | Refreshed budget |
static_assert(sizeof(time_t) >= 8) at file scope (service.cpp) |
32-bit time_t silently wrapping January 2038 |
Build-time guarantee |
GenTracker::kick_watchdog() posts to the scheduler at high priority every ~13.5 min (90 % of WDT period). BootState mount/format and ServiceManager::stopall issue per-service WDT kicks for known-long operations.
| Defense | Failure handled | Owner / location | Recovery time |
|---|---|---|---|
| CRC16 on noinit structs | Single-bit flip in RAM | All _noinit structs (cooldown, rate-limiter, hauled-mode, bootfail) |
Immediate (struct zeroed on mismatch) |
| Virtual RTC fallback | Cold boot without GNSS |
10 § E.3 (main.cpp boot path) |
Immediate (rtc=1) |
| M1a periodic RTC save | WDT reset losing virtual time |
10 § E.4 (every 30 min) |
At most 30 min lost |
| LAST_KNOWN_RTC restore validation | Flash corruption (POF mid-write) |
10 § E.2 (range check) |
Fallback to virtual RTC |
| K + L RTC-sync hooks | Virtual → real epoch jump |
10 § E.7 (m10qasync.cpp::on_fix) |
Immediate |
| ANO staleness math guard | Virtual-RTC stamp vs real-RTC last save | 10 § E.8 |
Immediate |
| POF handler doesn't save_params() | Brown-out mid-flush corrupting LFS | This page (nrf_pmu.cpp, commit 12fdd581) |
Prevents corruption |
| Power-off cuts GPS rail | Power-off while GPS still running | This page (nrf_pmu.cpp, commit 927d0837) |
Prevents stuck-rail drain |
| Boot-fail counter | Any boot path freezes | This page (gentracker.cpp::BootState) |
1 boot/reset, factory_reset @ 3, OffState @ 5 |
| 5 exception barriers | Single subsystem throws | This page | Caught, logged, FSM continues |
| PreOp stuck-reed escape | Reed-switch stuck closed (manuf. magnet residue) | This page (gentracker.cpp::PreOperationalState) |
After 20 s, force transit to Operational |
| Cooldown gate | Latent tasks posted before cycle complete | 13 § D.5 |
Active until cooldown expires |
| Rate limiter | Burst TX exceeding battery budget |
11 § E.4 (rate_limiter.cpp) |
Configurable rolling window |
| Spacing-guard | Immediate-TX sites firing back-to-back |
11 § E.5 (uptime-based, RTC-immune) |
Bounded by surfacing_burst_init_s
|
| SMD FAST/SAFE autofallback | SMD timing too tight for batch / environment | 11 § Part D |
3 errors → SAFE; trust window 1 h → 24 h |
| ArgosTxService::service_term hardening (FIX F) | Radio rail left indeterminate on FSM teardown | argos_tx_service.cpp::service_term |
Unconditional power_off_immediate() + state reset |
| ArgosTxService consecutive-device-error reset | 3 early errors suspending TX for multi-year deploy | argos_tx_service.cpp:537+ |
Reset on every UW→surface transition |
| WDT 15-min | Genuine CPU hang | nrf_pmu.cpp::start_watchdog |
Hard reset after 15 min |
| WDT kicks in long ops | Long flash op exhausts WDT | BootState mount/format, stopall per-service | Refreshed budget |
static_assert(sizeof(time_t) >= 8) |
32-bit time_t silently wrapping 2038 |
service.cpp file scope |
Build-time guarantee |
Counters exposed via diagnostics (STATR or logs):
| Counter | Meaning | Threshold for concern |
|---|---|---|
bootfail_noinit.consecutive_failures |
Boots since last successful Operational | > 1 = something blocking init |
m_diag.dive_timeout_count (SWS) |
UW_MAX_DIVE_TIME exceeded |
> 1/day = sensor drift or misconfig |
m_diag.force_surface_count (SWS) |
Cascade level 3 fired | > 0 = sustained sensor issue |
m_diag.stuck_recovery_count (SWS) |
Air baseline collapse recovery | > 0 = dry-electrode reading anomaly |
m_diag.invalid_adc_count (SWS) |
SAADC returned invalid value | > 60 in a row = ADC hardware issue |
RateLimiter::s_noinit.count |
Current TX in rolling window | Compare with RLP03 |
RateLimiter::passive_count |
TX blocked by rate limit | Per-deployment config |
HauledModeService::m_dive_count |
UW events since last HAULED engagement | High = animal active |
| # | Gap | Where | Why deferred |
|---|---|---|---|
| F1 |
m_safe_trust_window_hours not persisted |
SMD autofallback | Reboots rare on sealed turtles |
| F2 |
m_safe_mode_since_ms reset on reboot |
SMD autofallback | Same |
| F5 | No WDT kick in write_credentials_from_config
|
SMD credentials write | ~720 ms blocking, fits in 15-min budget |
| — | No adaptive water DOWN recalib | SWS detection | See .claude/sws_adaptive_water_down.md
|
| — | Stuck SMD MAC_TX_TIMEOUT indistinguishable from "no satellite" | SMD autofallback | Relies on Argos-side observability to detect dead unit |
The Service Framework is the core scheduling and lifecycle engine. All firmware functionality (GNSS, Argos TX, sensors, underwater detection) is implemented as services managed by ServiceManager.
Location: core/services/service.hpp
Lifecycle:
- Construction — Service registers with ServiceManager
- start() — Calls service_init(), enables scheduling
- Scheduling loop — ServiceManager calls service_initiate() based on service_next_schedule_in_ms()
- stop() — Calls service_term(), disables scheduling
Virtual methods to implement:
| Method | Required | Description |
|---|---|---|
| service_init() | No | Initialize resources |
| service_term() | No | Release resources |
| service_is_enabled() | Yes | Is service enabled in config? |
| service_next_schedule_in_ms() | Yes | Interval until next execution |
| service_initiate() | Yes | Execute the service task |
| service_cancel() | No | Cancel pending operation |
| service_is_usable_underwater() | No | Can run underwater? (default: false) |
| service_is_triggered_on_surfaced() | No | Trigger when surface detected? |
| service_is_triggered_on_event() | No | Trigger on inter-service event? |
Utility methods:
- service_complete() — Mark task complete, optionally reschedule
- service_log() — Write entry to logger
- service_read_param<T>(ParamID) — Read configuration parameter
- service_write_param<T>(ParamID, value) — Write configuration parameter
- service_current_time() — Get system time
- service_is_battery_level_low() — Check low battery
- service_reschedule() — Force reschedule
Static class managing all services:
- add(Service&) / remove(Service&)
- startall() / stopall()
- notify_underwater_state(bool) — Broadcast underwater state
- notify_peer_event(ServiceEvent&) — Distribute events
- inject_event(ServiceEvent&) — Inject custom events
Services return SCHEDULE_DISABLED (0xFFFFFFFF) to disable automatic scheduling. Otherwise return milliseconds until next execution. Services multiply seconds config values by 1000.
Location: core/services/sensor_service.hpp
Extends Service for sensor-based services. Handles periodic sensor reading, sample aggregation, log entry creation, and TX data collection.
| Method | Required | Description |
|---|---|---|
| sensor_populate_log_entry() | Yes | Fill log entry from sensor data |
| sensor_init() | No | Sensor-specific init (e.g., set full scale) |
| sensor_is_enabled() | Yes | Is sensor enabled? |
| sensor_periodic() | Yes | Sampling period in ms |
| sensor_tx_periodic() | Yes | TX sampling period in ms |
| sensor_max_samples() | Yes | Max samples per TX event |
| sensor_num_channels() | Yes | Number of channels (1-6) |
| sensor_enable_tx_mode() | Yes | TX aggregation mode |
| sensor_is_usable_underwater() | No | Usable underwater? |
Aggregation modes:
| Mode | Behavior |
|---|---|
| OFF | Sensor data not included in TX |
| ONESHOT | First sample only |
| MEAN | Average of all samples |
| MEDIAN | Median of all samples |
| Service | File | Purpose | Key Params |
|---|---|---|---|
| ARGOSTxService | core/services/argos_tx_service.hpp | Argos satellite TX with pass prediction. Modes: OFF, PASS_PREDICTION, LEGACY, DUTY_CYCLE, DOPPLER, SURFACING_BURST | ARGOS_MODE, TR_NOM, NTRY_PER_MESSAGE, DUTY_CYCLE, ARGOS_DEPTH_PILE |
| ARGOSRxService | core/services/argos_rx_service.hpp | Receives AOP updates from Argos downlink | ARGOS_RX_EN, ARGOS_RX_MAX_WINDOW, ARGOS_RX_AOP_UPDATE_PERIOD |
| GPSService | core/services/gps_service.hpp | GNSS acquisition with filtering and dynamic models. Cold start retry, AssistNow, HDOP/HACC filtering | GNSS_EN, GNSS_ACQ_TIMEOUT, GNSS_FIX_MODE, GNSS_DYN_MODEL |
| LoRaTxService | core/services/lora_tx_service.hpp | LoRa RAK3172 TX service. Compact binary packets over LoRaWAN (OTAA/ABP) | LRP01-LRP15 |
| MortalityService | core/services/mortality_service.hpp | RSPB bird mortality detection from activity, temperature, GPS stationarity | MTP01-MTP07 |
| Service | File | Channels | Key Params |
|---|---|---|---|
| PressureSensorService | core/services/pressure_sensor_service.hpp | 2 (pressure bar, temperature C) + computed altitude | PRP01-PRP07 |
| AXLSensorService | core/services/axl_sensor_service.hpp | 6 (X, Y, Z, activity, temperature, wakeup) | AXP01-AXP09 |
| ThermistorSensorService | core/services/thermistor_sensor_service.hpp | 1 (temperature C) | THP01-THP08 |
| ALSSensorService | core/services/als_sensor_service.hpp | 1 (lumens) | LTP01-LTP06 |
| PHSensorService | core/services/ph_sensor_service.hpp | 1 (pH) | PHP01-PHP06 |
| SeaTempSensorService | core/services/sea_temp_sensor_service.hpp | 1 (temperature C, RTD or TSYS01) | STP01-STP06 |
| CDTSensorService | core/services/cdt_sensor_service.hpp | 3 (conductivity, depth, temperature) | CDP01-CDP05 |
| CAMService | core/services/cam_service.hpp | Camera trigger on surfaced/AXL wakeup | CAP01-CAP05 |
| Service | File | Source | Description |
|---|---|---|---|
| SWSService | core/services/sws_service.hpp | SWS (0) | Digital saltwater switch |
| SWSAnalogService | core/services/sws_analog_service.hpp | SWS (0) | Analog SWS with auto-calibration, 5-level detection, biofouling adaptation. Test mode via SWSTST DTE command |
| PressureDetectorService | core/services/pressure_detector_service.hpp | PRESSURE_SENSOR (1) | Pressure threshold detection |
| GNSSDetectorService | core/services/gnss_detector_service.hpp | GNSS (2) / SWS_GNSS (3) | GNSS signal quality detection |
| Service | File | Purpose |
|---|---|---|
| DiveModeService | core/services/dive_mode_service.hpp | Dive state machine with reed switch pause/resume |
| MemoryMonitorService | core/services/memory_monitor_service.hpp | Logs heap/stack statistics every 12 hours |
The configuration system manages 240 ParamID slots (__PARAM_SIZE = 240 in base_types.hpp) stored in flash memory, of which ~210 are live parameters and the rest are reserved/deprecated slots kept for flash-layout backward compatibility. Parameters are accessed by ParamID enum and exposed to pylinkit / GUI via 5-character DTE keys (ARP01, GNP05, etc.). See 09 — Parameters for the complete table.
Location: core/configuration/config_store.hpp
Abstract base class with pure virtual methods:
- init() — Load config from flash
- is_valid() — Check if config is loaded
- factory_reset() — Reset all params to defaults
- read_pass_predict() / write_pass_predict() — AOP satellite data
- serialize_config() — Save to flash
Parameter access:
// Read parameter (type-safe)
bool gnss_en = configuration_store->read_param<bool>(ParamID::GNSS_EN);
unsigned int timeout = configuration_store->read_param<unsigned int>(ParamID::GNSS_ACQ_TIMEOUT);
// Write parameter
configuration_store->write_param(ParamID::GNSS_EN, true);
configuration_store->save_params(); // Persist to flashParameters are stored as std::variant:
using BaseType = std::variant<
std::string, unsigned int, int, double, std::time_t, BaseRawData,
BaseArgosMode, BaseArgosPower, BaseArgosDepthPile, bool,
BaseGNSSFixMode, BaseGNSSDynModel, BaseLEDMode, BaseZoneType,
BaseArgosModulation, BaseUnderwaterDetectSource, BaseDebugMode,
BasePressureSensorLoggingMode, BasePressureSensorFullScale,
BaseSensorEnableTxMode
>;static inline const unsigned int m_config_version_code = 0x1c07e800 | 0x20;When the version code is bumped, existing devices perform a factory reset on next boot to load new defaults — keeping only ARGOS_DECID/ARGOS_HEXID, everything else returns to factory values. This is necessary when adding new parameters or changing default values. Consequence: BLE re-provisioning is required after flashing across a bump; never OTA-update deployed devices across one (see 09 — Parameters → Firmware upgrade note).
The firmware dynamically selects parameter sets based on state:
- NORMAL — Default parameters
- LOW_BATTERY — LB_* parameters (when LB_EN=true and battery below LB_THRESHOLD)
- OUT_OF_ZONE — ZONE_* parameters (when outside geofencing zone)
Location: core/configuration/config_store_fs.hpp
Flash-backed implementation using LittleFS. Serializes/deserializes BaseType variants to/from binary via operator() overloads (serializer) and switch cases (deserializer). Stores AOP pass prediction data separately.
Location: core/configuration/calibration.hpp
class Calibratable {
virtual void calibration_write(const double value, const unsigned int offset) {}
virtual void calibration_read(double& value, const unsigned int offset) {}
virtual void calibration_save(bool force) {}
};Devices inherit from Calibratable and register with CalibratableManager. Used for sea level pressure reference, accelerometer axis offsets, and SWS analog baselines.
DTE interface: SCALW (write calibration) and SCALR (read calibration). The Calibration helper class persists values to LittleFS files, with a map<unsigned int, double> keyed by offset.
The DTE (Data Terminal Equipment) protocol handles communication between the firmware and external tools (pylinkit, LinkIt GUI). It provides parameter read/write, sensor access, log retrieval, and device management.
pylinkit / LinkIt GUI (BLE)
|
DTE Framing ($CMD#LEN;PAYLOAD\r)
|
DTEDecoder (parse + validate)
|
DTEHandler (dispatch to command handler)
|
ConfigStore / SensorManager / LoggerManager
|
DTEEncoder (format response)
|
DTE Framing (response)
DTEDecoder (core/protocol/dte_protocol.hpp): Parses incoming DTE messages — extracts command name, payload length, payload. Validates length, decodes arguments based on command prototype (BaseEncoding). For PARMR/PARMW: resolves DTE keys to ParamIDs.
DTEEncoder (core/protocol/dte_protocol.hpp): Formats outgoing DTE responses — OK: $O;CMD#LEN;PAYLOAD\r, Error: $N;CMD#LEN;ERROR_CODE\r. Encodes BaseType values to string using BaseEncoding rules.
DTEHandler (core/protocol/dte_handler.cpp): Central dispatcher. Calls DTEDecoder::decode(), switches on DTECommand enum, calls the appropriate handler, returns DTEAction for post-command processing (RESET, FACTR, CONFIG_UPDATED, etc.).
| Encoding | Wire Format | C++ Type |
|---|---|---|
| UINT | Decimal string | unsigned int |
| FLOAT | Decimal with decimals | double |
| BOOLEAN | "0" or "1" | bool |
| HEXADECIMAL | Hex string | unsigned int |
| TEXT | Raw string | std::string |
| DATESTRING | DD/MM/YYYY HH:MM:SS | std::time_t |
| BASE64 | Base64-encoded binary | BaseRawData |
| ARGOSMODE | "0"-"4" | BaseArgosMode |
| GNSSFIXMODE | "1"-"3" | BaseGNSSFixMode |
| GNSSDYNMODEL | "0"-"10" | BaseGNSSDynModel |
| LEDMODE | "0","1","3" | BaseLEDMode |
| DEPTHPILE | Decimal | BaseArgosDepthPile |
| MODULATION | "0"-"2" | BaseArgosModulation |
| UWDETECTSOURCE | "0"-"3" | BaseUnderwaterDetectSource |
| DEBUGMODE | "0"-"3" | BaseDebugMode |
| PRESSURESENSORLOGGINGMODE | "0"-"1" | BasePressureSensorLoggingMode |
| PRESSURESENSORFULLSCALE | "0"-"1" | BasePressureSensorFullScale |
| SENSORENABLETXMODE | "0"-"3" | BaseSensorEnableTxMode |
| KEY_LIST | Comma-separated keys | vector<ParamID> |
| KEY_VALUE_LIST | key=value pairs | vector<ParamValue> |
For satellite transmission, a separate binary encoder/decoder packs GPS fixes and sensor data into compact Argos packets. DTEEncoder bit-packs GPS fixes into depth-piled payloads, DTEDecoder decodes received satellite data.
Location: core/protocol/dte_params.cpp
Static array mapping ParamID to DTE metadata:
struct BaseMap {
BaseName name; // Human-readable name
BaseKey key; // 5-char DTE key (e.g., "GNP01")
BaseEncoding encoding; // Type encoding
BaseConstraint min_value; // Minimum value
BaseConstraint max_value; // Maximum value
vector<BaseConstraint> permitted_values; // Enum values
bool is_implemented; // Available in this build?
bool is_writable; // Can be written via PARMW?
};The is_implemented flag uses ENABLE_* macros, so disabled sensors have their keys hidden from PARML.
The firmware uses abstract C++ interfaces in core/hardware/ that are implemented in ports/nrf52840/core/hardware/. This allows the core firmware to be portable across different MCUs.
Location: core/hardware/sensor.hpp
class Sensor : public Calibratable {
public:
Sensor(const char *name = "Sensor");
virtual double read(unsigned int port = 0) = 0;
virtual void install_event_handler(unsigned int, std::function<void()>) {}
virtual void remove_event_handler(unsigned int) {}
virtual void set_full_scale(unsigned int mode) {}
};SensorManager: Static registry — add(Sensor&, name), find_by_name(name), clear().
| Name | Driver | Bus | File |
|---|---|---|---|
| "PRS" | LPS28DFW (pressure) | I2C | ports/nrf52840/core/hardware/lps28dfw/ |
| "AXL" | BMA400 (accelerometer) | I2C | ports/nrf52840/core/hardware/bma400/ |
| "RTD" | OEM RTD (temperature) | I2C | ports/nrf52840/core/hardware/ |
| "TSYS01" | TSYS01 (temperature) | I2C | ports/nrf52840/core/hardware/ |
| "ALS" | LTR-303 (ambient light) | I2C | ports/nrf52840/core/hardware/ |
| "PH" | OEM pH | I2C | ports/nrf52840/core/hardware/ |
| "CDT" | AD5933 + ADS1115 | I2C | ports/nrf52840/core/hardware/ |
| "THERM" | NTC Thermistor | ADC | ports/nrf52840/core/hardware/ |
SensorsPowerGuard (RAII): Acquires VSENSORS power rail on construction, releases on destruction. Sensor I2C registers are volatile and lost when VSENSORS is powered off — drivers must re-apply configuration in every read() call.
void LPS28DFW::read(double& temperature, double& pressure) {
SensorsPowerGuard power_guard; // VSENSORS ON
lps28dfw_init_set(&m_ctx, LPS28DFW_DRV_RDY);
lps28dfw_mode_set(&m_ctx, &m_mode);
// ... read sensor ...
} // VSENSORS OFF (guard destroyed)PMU (Power Management Unit): Abstract interface — delay_ms(), hardware_version(), device_identifier().
- I2C: NrfI2C::write/read, two buses (ONBOARD_I2C_BUS, EXTERNAL_I2C_BUS)
- SPI: Satellite module communication (SMD A+ protocol), SPI_SATELLITE bus
- UART: UART_GPS for u-blox GPS, debug output via UART or USB CDC
- GPIO: GPIOPins::set/clear/read, acquire_sensors_pwr/release_sensors_pwr, named pins in BSP
Three mutually exclusive satellite/radio communication modules, selected at build time:
| Module | Interface | Baud Rate | Files | Build Flag |
|---|---|---|---|---|
| KIM2 | UART async (AT commands) | 9600 | ports/nrf52840/core/hardware/kim2/ |
Default (no flag) |
| SMD | SPI (A+ protocol) | N/A | ports/nrf52840/core/hardware/smd_sat/ |
-DARGOS_SMD=ON |
| LoRa RAK3172 | UART async (RUI3 AT commands) | 115200 | ports/nrf52840/core/hardware/lora_rak3172/ |
-DLORA_RAK3172=ON |
All three implement the KineisDevice interface and use the EventEmitter<Listener> pattern. KIM2 and SMD are used by ArgosTxService; LoRa RAK3172 is used by LoRaTxService.
The SMD module (STM32WL) is fully powered off between communication sessions (SAT_PWR_EN=LOW). At each session, the boot sequence is:
power_on (150ms) → SPI boot wait (100ms) → ping → state_load_kmac → idle → TX
state_load_kmac handles initialization on every boot:
| Step | Condition | Duration | Description |
|---|---|---|---|
| Deferred RCONF | Only on error recovery | ~180ms | Write RCONF + save + KMAC reload |
| Credentials | Only if dirty (DTE write) | ~240ms | Write ID, ADDR, seckey. Skip master RCONF if adaptive modulation ON. Force KMAC reload. |
| KMAC load | Only on first boot or after credential/RCONF change | 500ms |
load_kmac_profil(1) — skipped on normal power cycles (profile persisted in SMD flash) |
| TCXO warmup | Always | ~30ms |
write_tcxo_warmup() — RAM-only register, lost on power cycle |
| LPM mode | Only if != NONE | ~30ms |
write_lpm() — RAM-only register, lost on power cycle |
Typical boot (no credential change, KMAC in flash): ~340ms instead of ~2s.
In Surfacing Burst mode with adaptive modulation (ARP54=1, ARP01=5), the RCONF is managed to minimize boot time:
- After every TX complete: pre-switch to VLDA4 while SMD is still powered on (live SPI, not deferred). The SMD always powers off with VLDA4 in flash.
-
Doppler TX (
process_doppler_burst): SMD boots with VLDA4 already in flash → no RCONF write needed. -
GNSS TX (
process_gnss_burst):ensure_modulation(LDK)switches live (SMD already on from Doppler phase). After TX complete, pre-switch back to VLDA4. -
Wakeup pin (
SAT_EXTWAKEUP): driven HIGH at power_on, LOW at stopped/idle_enter (for STANDBY/SHUTDOWN LPM modes). Only available on LinkIt V4 board.
Single-Color LED (core/hardware/led.hpp): on(), off(), get_state(), flash(period_ms), is_flashing(). Implementations: GPIOLed, NrfLed.
RGB LED (core/hardware/rgb_led.hpp): set(color), off(), flash(color, period_ms), flash_alternate(color1, color2, period_ms). Implementation: NrfRGBLed (active-low, timer-based).
| Pin | GPIO | Function |
|---|---|---|
| GPIO_LED_RED | P1.07 | Red channel (active low) |
| GPIO_LED_GREEN | P1.10 | Green channel (active low) |
| GPIO_LED_BLUE | P1.04 | Blue channel (active low) |
Global instances: status_led (RGB, main), ext_status_led (optional single-color). The LED state machine (core/sm/ledsm.hpp) controls status_led during normal operation.
The logging system provides per-service flash-based data logging with CSV-formatted retrieval via the DUMPD command.
Location: core/logging/messages.hpp
struct LogHeader { // 9 bytes
uint8_t day, month;
uint16_t year;
uint8_t hours, minutes, seconds;
LogType log_type;
uint8_t payload_size;
};
struct LogEntry { // 128 bytes total
LogHeader header;
uint8_t data[MAX_LOG_PAYLOAD]; // 119 bytes payload
};Each service defines a packed struct overlaying LogEntry:
| Service | Log Struct | Fields |
|---|---|---|
| GPS | GPSLogEntry | event_type, batt_voltage, iTOW, fix data (lat, lon, height, etc.) |
| Pressure | PressureLogEntry | pressure (bar), temperature (C), altitude (m) |
| AXL | AXLLogEntry | x, y, z (g), activity, temperature |
| Thermistor | ThermistorLogEntry | temperature (C) |
| Camera | CAMLogEntry | event_type, batt_voltage, counter |
| Underwater | UnderwaterLogEntry | DRY or WET event |
| Battery | BatteryLogEntry | event, voltage |
| System | SystemStartupLogEntry | cause (BROWNOUT, WATCHDOG, etc.) |
Logger (core/logging/logger.hpp): create(), write(void*), read(void*, int), num_entries(), truncate().
LogFormatter: Converts raw log entries to CSV. Each service implements header() and log_entry(). Example: log_datetime,pressure,temperature,altitude\r\n.
LoggerManager: Static registry — add/remove, create(), truncate(), find_by_name().
| Type ID | Name | Description |
|---|---|---|
| 0 | GPS | GNSS fixes |
| 1 | CAM | Camera events |
| 2 | AXL | Accelerometer data |
| 3 | STARTUP | System startup events |
| 4 | UNDERWATER | Wet/dry transitions |
| 5 | BATTERY | Battery events |
| 6 | STATE | State changes |
| 7 | ZONE | Geofence events |
| 8 | OTA_UPDATE | Firmware update events |
| 9 | BLE | BLE connection events |
| 10 | PRESSURE | Pressure sensor data |
| 11 | THERMISTOR | Thermistor sensor data |
| 12 | TSYS01 | Sea temperature sensor data |
| 13 | SWS | Saltwater switch events |
| 14 | MORTALITY | Mortality detection logs |
Logs are retrieved via the DUMPD command, which paginates entries and returns base64-encoded CSV data.
Services communicate through a typed event system. Events are broadcast by ServiceManager to all registered services.
Services emit and receive ServiceEvent objects containing:
- event_source: ServiceIdentifier (which service sent the event)
- event_type: Type of event
- event_data: std::variant with event-specific payload
| Event | Source | Description |
|---|---|---|
| SERVICE_INIT | Any | Service initialized |
| SERVICE_ACTIVE | Any | Service started processing |
| SERVICE_INACTIVE | Any | Service completed processing |
| SERVICE_LOG_UPDATED | Sensors | New log entry written |
| GNSS_ON | GPS | GNSS acquisition started |
| GNSS_OFF | GPS | GNSS acquisition completed |
- Service calls service_complete() or directly emits event
- ServiceManager::notify_peer_event() broadcasts to all services
- Each service's notify_peer_event() checks if event is relevant
- Service may trigger its own action in response
| Value | Service |
|---|---|
| SERVICE_MANAGER | ServiceManager itself |
| GNSS_SENSOR | GPSService |
| UW_SENSOR | Underwater detection |
| ALS_SENSOR | Ambient light |
| AXL_SENSOR | Accelerometer |
| CDT_SENSOR | Conductivity-Depth-Temp |
| PH_SENSOR | pH |
| PRESSURE_SENSOR | Pressure |
| SEA_TEMP_SENSOR | Sea temperature |
| THERMISTOR_SENSOR | Thermistor |
| CAM_SENSOR | Camera |
| ARGOS_TX_SERVICE | Argos TX |
| ARGOS_RX_SERVICE | Argos RX |
| DIVE_MODE | Dive mode |
| MEMORY_MONITOR | Memory monitor |
A special broadcast path exists for underwater state changes:
ServiceManager::notify_underwater_state(bool is_underwater);This notifies all services simultaneously, allowing them to defer scheduling when submerged, trigger GNSS acquisition on surfacing, or start/stop dive mode tracking.
For trackers deployed sealed for 12+ months without operator access, three independent recovery layers protect against unexpected freezes:
A .noinit-RAM struct tracks consecutive boot failures, CRC16-protected:
struct BootFailNoinit {
uint16_t consecutive_failures;
uint8_t factory_reset_attempted;
uint16_t crc;
};BootState::entry() increments on every boot, cleared on successful OperationalState::entry(). Cascade:
| Failures | Action |
|---|---|
| 1–2 | Normal — ErrorState → soft reset retry |
| 3 |
configuration_store->factory_reset(), set factory_reset_attempted=1, soft reset |
| ≥ 5 | Fall through to OffState (last resort, magnet required to wake) |
Located in core/sm/gentracker.cpp (BootState::entry, ErrorState::entry).
5 strategic try/catch blocks ensure a panic in any single subsystem does NOT cascade into a permanent freeze:
| Barrier | Catches | Recovery |
|---|---|---|
GenTracker::periodic_config_flush() |
save_params() throws |
Log, continue. Next 30-min flush retries. |
OperationalState::exit() |
Each destructive step (LED, BLE stop, stopall, save_params, battery unsubscribe) | Each independently caught, next step still runs. |
BootState::entry() |
factory_reset() itself throws |
Log, soft reset anyway. |
ServiceManager::stopall() |
Per-service stop() exception + per-service WDT kick |
One bad service doesn't stop the others. |
service.cpp::reschedule() |
Service-level throws | Reset m_is_initiated, reschedule cleanly. |
The 15-min nRF WDT is kicked by GenTracker::kick_watchdog() posted at HIGHEST priority every ~13.5 min (90 % of period). For long blocking operations, additional explicit kicks are added:
-
BootState::entry()beforefactory_reset(), after, and around filesystem mount/format -
ServiceManager::stopall()between each service -
PMUlong delays (firmware-update reboot countdown)
This means a single subsystem hang is bounded by the WDT (15 min max), then the boot-fail counter handles the recovery cycle. Worst-case unattended recovery: ~75 min (5 × 15 min) before falling through to OffState.
See Sealed-device hardening above for the defense layer catalog and 10 — GPS Guide § Part E for RTC handling under sealed operation.