diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml
index 4c88c5862..060b0962a 100644
--- a/.github/workflows/run-unit-tests.yml
+++ b/.github/workflows/run-unit-tests.yml
@@ -10,6 +10,24 @@ on:
workflow_dispatch:
jobs:
+ nrf52-sensor-smoke:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Build Environment
+ uses: ./.github/actions/setup-build-environment
+ - name: Compile GPS Sensor tracker on nRF52 boards
+ run: pio run -e t1000e_sensor -e RAK_WisMesh_Tag_sensor
+
+ esp32-sensor-smoke:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Build Environment
+ uses: ./.github/actions/setup-build-environment
+ - name: Compile GPS-capable Sensor tracker on ESP32-S3
+ run: pio run -e heltec_v4_sensor
+
management-reports:
runs-on: ubuntu-latest
steps:
@@ -205,6 +223,15 @@ jobs:
- name: Verify DNS lease restoration and bounded diagnostics
run: python3 -B test/test_network_dns.py -v
+ - name: Verify Sensor dog tracker ownership, routes, storage, GPS and sleep
+ run: |
+ python3 -B test/test_tracker_route_policy.py -v
+ python3 -B test/test_sensor_tracker_store.py -v
+ python3 -B test/test_tracker_owner_response.py -v
+ python3 -B test/test_sensor_tracker_client.py -v
+ python3 -B test/test_sensor_tracker_gps.py -v
+ python3 -B test/test_sensor_tracker_sleep.py -v
+
- name: Verify Companion contact and channel TX routing
run: |
python3 -B test/test_companion_tx_routing.py -v
diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md
index 4b1105de2..882d5d7e5 100644
--- a/docs/cli_command_availability.md
+++ b/docs/cli_command_availability.md
@@ -83,6 +83,12 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
## Companion framed CLI
+GPS-equipped Sensor builds also provide the local-only
+[dog tracker settings](sensor_tracker.md): `get tracker`,
+`get/set tracker.owner`, `get/set tracker.mode`, `get/set tracker.interval`,
+`get/set tracker.lost.interval`, `get/set tracker.path`, and `tracker check`.
+They are excluded from compact STM32 defaults. LoRa CLI cannot change them.
+
Companion builds are not represented by the role/profile matrices below. Their
local terminal and command `0x42` (`CMD_RUN_CLI_COMMAND`) expose these settings
over the normal binary USB, BLE, or TCP connection:
diff --git a/docs/companion_lost_reply.md b/docs/companion_lost_reply.md
index 9034b240b..6f1e2080c 100644
--- a/docs/companion_lost_reply.md
+++ b/docs/companion_lost_reply.md
@@ -43,14 +43,20 @@ When the tracker sends `Am I lost?` privately to that reachable Companion, it
receives the selected answer, even when the owner's phone is disconnected.
The same configured answer applies to all eligible contacts on this Companion.
-A tracker client can use that answer to choose its reporting behavior, but its
-check-in schedule, sleep/wake policy and GPS reporting must be configured in
-the tracker client firmware. The tracker must stay awake for the separate
-private answer; a delivery ACK for the question is not that answer. These plain
-text answers have no request ID, so a delayed answer cannot be precisely
-correlated with a newer check-in. The responding Companion must be powered on
-and reachable when a question arrives. For mail that waits while a tracker sleeps,
-use [a personal room mailbox](room_services.md#sleeping-tracker-example).
+The opt-in [Sensor dog tracker](sensor_tracker.md) implements periodic fresh
+GPS acquisition, low power sleep, and owner check-ins. It uses a tagged encrypted
+request and response rather than parsing plain `Yes` or `No` text, so old answers
+cannot change a newer check-in. Store the Sensor as a persistent Sensor contact
+on the owner's Companion. The same saved `lost.reply` setting answers those
+checks; `off` returns Unknown. A fresh accepted GPS position updates the contact
+location through normal contact notifications.
+
+Other clients can still use the plain text question. They must stay awake for
+the separate private answer; a delivery ACK is not that answer. Plain text
+answers have no request ID. The responding Companion must be powered on and
+reachable. [Personal room mailboxes](room_services.md#sleeping-tracker-example)
+provide store-and-forward mail; automatic mailbox polling is not yet implemented
+in the Sensor tracker.
## Matching and traffic limits
diff --git a/docs/nrf52_power_management.md b/docs/nrf52_power_management.md
index c7b9567b0..d45fb9fb4 100644
--- a/docs/nrf52_power_management.md
+++ b/docs/nrf52_power_management.md
@@ -225,6 +225,10 @@ DEBUG: PWRMGT: LPCOMP wake configured (AIN7, ref=3/8 VDD)
## Further power-management work (Planned)
+For the opt-in Sensor check-in workflow using System ON idle sleep and bounded
+GPS acquisition, see [Sensor dog tracker](sensor_tracker.md). It is separate
+from the battery-protection SYSTEMOFF shutdown described here.
+
- A voltage state machine (Normal -> Warning -> Critical -> Shutdown)
- Configurable wake comparator thresholds
- Load shedding callbacks for power reduction
diff --git a/docs/payloads.md b/docs/payloads.md
index 1fbb90bb6..5af04932f 100644
--- a/docs/payloads.md
+++ b/docs/payloads.md
@@ -116,6 +116,7 @@ For the common chat/server helpers in `BaseChatMesh`, the current request type v
| `0x06` | get neighbors | node neighbors query |
| `0x08` | subscribe | subscribe to telemetry push |
| `0x09` | ubsubscribe | unsubscribe from telemetry push |
+| `0x0C` | tracker status | encrypted Sensor dog tracker check to a configured owner's Companion; [wire format](sensor_tracker.md#encrypted-check-format) |
#### Get stats
diff --git a/docs/room_services.md b/docs/room_services.md
index cff99aac2..b33fdb834 100644
--- a/docs/room_services.md
+++ b/docs/room_services.md
@@ -109,6 +109,10 @@ For a reachable owner's Companion, the optional
[lost-status auto responder](companion_lost_reply.md) can also answer a private
`Am I lost?` question with a saved `Yes` or `No`.
+The opt-in [Sensor dog tracker](sensor_tracker.md) currently checks a reachable
+owner's Companion through tagged encrypted requests. It does not yet log in
+to a Room Server or automatically poll these mailboxes.
+
### Wi-Fi mailbox management
Open `/room`, join with your room credentials, and open **Personal mailbox**.
diff --git a/docs/sensor_tracker.md b/docs/sensor_tracker.md
new file mode 100644
index 000000000..57b7c50f4
--- /dev/null
+++ b/docs/sensor_tracker.md
@@ -0,0 +1,200 @@
+
+
View this page on MeshCore Docs.
+
+
+# Sensor dog tracker
+
+Dog tracker mode is an optional mode in the Sensor firmware. It acquires a new
+GPS position, sends an encrypted check to one configured owner's Companion,
+and waits for that owner's saved lost status. The owner's answer controls the
+tracker's reporting rate and awake window.
+
+Tracker mode starts off. It is compiled by default for GPS Sensor builds on
+nRF52, ESP32, and RP2040; compact STM32 builds exclude it by default. A build
+can override `MESH_ENABLE_SENSOR_TRACKER`. GPS hardware must be detected and
+available for position acquisition.
+
+## Set up the owner
+
+Use a Companion build with lost replies enabled. Add the tracker as a saved
+contact on the owner's Companion, using its full public key. The responder
+accepts saved Chat or Sensor contacts for the encrypted tracker check.
+
+Set the answer locally on the owner's Companion:
+
+```text
+set lost.reply no
+get lost.reply
+```
+
+Use `set lost.reply yes` when the dog is lost. Use `set lost.reply off` to
+return an unknown status. These are owner settings; an incoming tracker check
+cannot change them. A normal private `Am I lost?` message also receives the
+saved answer where the text responder is available, but the automatic Sensor
+tracker uses an encrypted request with a matching response tag.
+
+The owner must be reachable during the tracker's receive window. A Room or
+public message board does not substitute for the owner's Companion in this
+mode.
+
+## Set up the tracker
+
+Configure the Sensor through its local text console. Replace the key below
+with the owner's full 64-character hexadecimal public key:
+
+```text
+set tracker.owner
+set tracker.interval 1800
+set tracker.lost.interval 60
+set tracker.path direct
+set tracker.mode dog
+get tracker status
+```
+
+Check `clock` on both devices before enabling the mode. If needed, use
+`time ` to set the tracker clock. The owner's
+Companion accepts map coordinates only when the request time is within five
+minutes of its own clock. Tracker acquisition waits for the provider's validated
+multi-sample GPS clock sync as well as its position, allowing cold-start clock
+recovery within the acquisition window. If clock sync cannot finish, an operator
+can still set the clock locally. Saved routing limits do not reset to grant
+floods after clock loss.
+
+`direct` selects a direct radio path with no repeaters. If a repeater path is
+known, use comma-separated repeater prefixes, for example
+`set tracker.path aa,bb` or `set tracker.path aabb,ccdd`. All prefixes must have
+the same length: one, two, or three bytes. `set tracker.path clear` keeps the
+path unknown; the flood fallback delay
+still applies. Encrypted path replies can update the retained path.
+
+| Command | Meaning |
+| --- | --- |
+| `get tracker status` | Show mode, saved owner status, intervals, flood lock, clock and storage state. |
+| `get tracker.owner` | Show the configured owner's full public key. |
+| `get tracker.path` | Show whether the route is direct, saved, or unknown, and its hop count. |
+| `set tracker.owner ` | Save the destination identity. |
+| `set tracker.mode dog` | Enable automatic checks and tracker power control. |
+| `set tracker.mode off` | Stop automatic checks and restore normal Sensor operation. |
+| `set tracker.interval ` | Normal interval, 60 to 86400 seconds; default 1800. |
+| `set tracker.lost.interval ` | Lost interval, 60 to 86400 seconds; default 60. |
+| `set tracker.path ` | Save a known path or mark it unknown. |
+| `tracker check` | Schedule a check now, if no check is already pending. |
+
+Tracker configuration and routing checkpoints use a separate durable store.
+Mode changes do not replace the saved manual GPS or power-saving preferences.
+If storage cannot safely retain configuration or a flood reservation, automatic
+tracker checks fail closed. Volatile filesystem recovery is unsuitable for this
+mode because a restart could otherwise reset the flood limits.
+
+## Reporting and sleep
+
+Each check asks the GPS manager for a new position and validated GPS clock
+sync. It discards the previous parser fix and previous time-validation samples,
+then stops once both are ready or after 120 seconds.
+An old cached position is not reported as a new tracker fix. If acquisition
+gets a position but clock sync times out, the fresh position is retained. If no
+position is acquired, the encrypted check marks the failure and carries no
+position. A retained parser fix or one unvalidated time sample cannot set the
+tracker clock.
+
+The tracker then allows 20 seconds for the tagged owner response. `No` uses the
+normal reporting interval. `Yes` uses the shorter lost interval and requests a
+120-second awake window, renewed by further `Yes` responses. This permits the
+tracker to remain reachable while the owner continues to report it as lost.
+An unknown answer does not replace the last saved `Yes` or `No`. Intervals run
+from the completed check; GPS acquisition and the response window add time.
+
+Between active windows, tracker mode pauses radio reception when there is no
+queued radio work, temporary radio setting, update operation, or active console
+that needs service. The radio resumes before the next check. Unsolicited
+messages sent while reception is paused cannot be received; the owner should
+set the status and wait for the next check. A delivery ACK alone does not
+contain the owner's lost answer.
+
+Tracker mode temporarily suppresses ordinary periodic advertisements,
+subscriptions, and Sensor alerts. Manual requests can still be served while
+the radio is awake. Leaving tracker mode restores the normal Sensor behavior.
+
+This uses idle or light sleep while retaining runtime state. It does not use
+the nRF52 battery-protection SYSTEMOFF path described in
+[nRF52 power management](nrf52_power_management.md). Receiver power control
+depends on the hardware: T1000-E retains the GPS backup supply for faster fixes,
+and shared GPS power rails may prevent physically cutting receiver power.
+
+nRF52 tracker sleep arms a real one-shot software timer before each System ON
+wait. Failure to allocate or queue that timer keeps the loop polling. Sleep
+chunks are at most 30 seconds so clock and watchdog housekeeping continue.
+ESP32 light sleep uses the board's existing timer wake. RP2040 uses the core's
+interrupt-driven idle wait; its physical tracker wake behavior is not yet
+qualified by these host tests.
+
+## Flood fallback
+
+Automatic tracker checks retain a known direct path and try it while the
+owner cannot be reached. A failed check starts an outage; further failures do
+not restart that outage's six-hour clock.
+
+- A recovery flood is eligible only after six hours without a qualifying owner
+ response, six hours without hearing a repeater, and six hours since the last
+ recovery flood.
+- Hearing a repeater starts another six-hour wait. It does not clear the
+ four-day outage limit.
+- At most one automatic recovery flood is reserved per six hours. It does not
+ schedule additional flood retries.
+- After four days without qualifying owner success, automatic floods
+ stop. Known direct paths can still be tried. An authenticated matching direct
+ owner response can clear the lockout. A bare delivery ACK cannot do this.
+
+The cutoff and flood checkpoint survive sleep and restart. Invalid or backward
+clock changes prevent fresh flood eligibility. A forward GPS or manual clock
+correction starts another conservative six-hour quiet wait; it does not reset
+the outage or the flood checkpoint. Repeater observations and
+unrelated public traffic cannot unlock a stopped tracker. Ordinary Room
+responses, login traffic, and manual messaging keep their existing routing.
+
+## Encrypted check format
+
+This uses the existing encrypted peer `PAYLOAD_TYPE_REQ` and
+`PAYLOAD_TYPE_RESPONSE` datagrams. Request subtype `0x0C` is inside the
+encrypted body; it is separate from the packet header's OTA payload type.
+All multi-byte fields are little-endian.
+
+| Request field | Bytes | Meaning |
+| --- | --- | --- |
+| Tag | 4 | RTC-based request identity, durably increasing across reboot. |
+| Subtype | 1 | `0x0C`. |
+| Version | 1 | `1`. |
+| Flags | 1 | `1` for fresh GPS, `2` for failed acquisition, or `0` for no position; other flags are rejected. |
+| Latitude | 4 | Signed degrees times 10,000,000. |
+| Longitude | 4 | Signed degrees times 10,000,000. |
+| Battery | 2 | Millivolts; carried for reporting, not a lost-status decision. |
+
+The request has 17 logical plaintext bytes and zero padding to 32 bytes.
+Latitude and longitude must be zero when no fresh position is present.
+
+| Response field | Bytes | Meaning |
+| --- | --- | --- |
+| Tag | 4 | Exact request tag. |
+| Subtype | 1 | `0x0C`. |
+| Version | 1 | `1`. |
+| Status | 1 | `0` Unknown, `1` Safe, `2` Lost. |
+| Awake lease | 2 | Seconds; the Companion requests 120 for Lost and zero otherwise. |
+
+The response has nine logical plaintext bytes and zero padding to 16 bytes.
+The client authenticates the configured owner's complete public key, requires
+the pending tag within its receive window, and caps an awake lease at 300
+seconds. Short, oversized, nonzero-padded and unknown-version bodies are
+rejected. An encrypted returned-path packet may bundle the same response and
+provide a route for subsequent direct checks.
+
+## Remaining work
+
+This mode implements GPS checks and an owner's saved lost answer. It does not
+provide Bluetooth pings every 30 seconds, an automatic Room mailbox poller,
+public-board subscriptions, or a mailbox command interpreter. Room private
+mailboxes and the public board remain separate features. Mail text is not
+executed as a tracker or console command.
+
+Battery life and reliable wake behavior still need measurements on the actual
+hardware, including GPS reception, repeater availability, and the chosen
+reporting intervals.
diff --git a/docs/test-results/index.md b/docs/test-results/index.md
index 33468a895..8412b57ce 100644
--- a/docs/test-results/index.md
+++ b/docs/test-results/index.md
@@ -41,6 +41,7 @@ Completed validation reports, experiments, and test guides are collected here. O
## Companion, USB, BLE, and display
- [Companion lost-status replies](companion_lost_reply.md)
+- [Sensor dog tracker checks](sensor_dog_tracker.md)
- [Contact-cache and NimBLE RAM qualification](companion_contact_cache_results.md)
- [USB Companion ASCII default validation](companion_usb_ascii_validation.md)
- [USB Companion client compatibility](companion_usb_client_validation.md)
diff --git a/docs/test-results/sensor_dog_tracker.md b/docs/test-results/sensor_dog_tracker.md
new file mode 100644
index 000000000..5e9a7fd81
--- /dev/null
+++ b/docs/test-results/sensor_dog_tracker.md
@@ -0,0 +1,75 @@
+
+View this page on MeshCore Docs.
+
+
+# Sensor dog tracker validation
+
+The opt-in [Sensor dog tracker](../sensor_tracker.md) was checked on
+2026-10-10 using executable host tests and firmware builds. This report
+qualifies the code paths named below; no radio was flashed for these checks,
+and battery current, GPS acquisition outdoors, and live RF delivery were not
+measured.
+
+## Executed regressions
+
+| Suite | Checks |
+| --- | --- |
+| `test_tracker_route_policy.py` | 99 boundary and byte-format assertions: six-hour gates, repeater postponement, sticky four-day cutoff, invalid clocks, overflow and restart. |
+| `test_sensor_tracker_store.py` | 6,063 storage and failure scenarios over five filesystem API modes, including torn saves, unavailable storage, durable flood reservations and a monotonic request-tag floor. |
+| `test_sensor_tracker_client.py` | 22 tests execute actual `Tracker.cpp`, peer dispatch, route/store/codec and packet hash implementation: setup, Safe/Lost/Unknown cadence, fresh GPS, transmit-based response deadline, exact owner identity, nonce reuse across reboot, path updates and packet ownership. |
+| `test_tracker_owner_response.py` | Actual Companion responder across four feature configurations: persistent contact authentication, flags and padding, time-bounded position updates, rate limits, and preserved ordinary message state. |
+| `test_sensor_tracker_gps.py` | 5 tests: fresh parser/PVT samples, GPS ownership, 120-second bound, validated UTC recovery after a cold start, stale sync notifications and saved preference restoration. |
+| `test_sensor_tracker_sleep.py` | 4 tests: actual sleep gates, nRF52 timer allocation/queue failures, warm radio pause and resume, Dispatcher watchdog anchors, and feature defaults. |
+| `test_sensor_subscriptions.py` | 18 tests: existing delivery behavior plus suppression in dog mode and restoration after leaving it. |
+| `test_radio_interrupt_recovery.py` | 9 tests: radio recovery preserves the no-flood-retry policy when a missing transmit-complete interrupt could conceal a transmission. |
+
+Host C++ tests use AddressSanitizer and UndefinedBehaviorSanitizer where
+available. Negative controls intentionally remove guards and verify that
+assertions detect the resulting regressions. Transport, UART, receiver and
+filesystem hardware are replaced by bounded adapters; they do not qualify
+physical RF range or current draw.
+
+The ordinary PlatformIO suites `native`, `native_kiss_modem` and
+`native_ota_channel` also passed: 1,829 test cases, with no failures.
+
+The client checks also cover:
+
+- A backward RTC change while already asleep remains latched after the clock
+ is restored, until a matching authenticated owner response.
+- A forward UTC correction cannot manufacture six hours of RF silence.
+- A flood reply crossing the exact four-day boundary retains the cutoff;
+ a subsequent matching direct response clears it.
+- A failed warm radio wake attempts peripheral recovery and restores the
+ dispatcher's watchdog ownership even when recovery fails.
+- An unset RTC defers the request without being reported as a storage failure.
+- The GPS manager completes its bounded position/time acquisition before the
+ client consumes the result.
+
+## Firmware builds
+
+All six firmware builds passed their flash and runtime memory checks. Values
+below are linked firmware bytes, rather than measured running heap usage.
+
+| Environment | Flash used / limit | Static RAM used | Runtime internal RAM available / required |
+| --- | --- | --- | --- |
+| `t1000e_sensor` | 377,520 / 811,008 | 64,400 | 171,112 / 54,320 |
+| `RAK_WisMesh_Tag_sensor` | 421,084 / 815,104 | 66,336 | 169,176 / 54,320 |
+| `heltec_v4_sensor` | 1,490,461 / 6,553,600 | 97,024 | 277,760 / 95,296 |
+| `t1000e_companion_radio_usb` | 427,964 / 708,608 | 187,272 | 48,240 / 38,944 |
+| `RAK_3x72_companion_radio_usb` | 229,000 / 229,376 | 39,780 | 24,728 / 17,408 |
+| `wio-e5-mini_companion_radio_usb` | 229,012 / 229,376 | 39,804 | 24,704 / 17,408 |
+
+The compact STM32 Companion retains its existing feature exclusions and
+application/storage boundary. Its limited remaining flash is checked by the
+existing CI build. The workflow now also builds T1000-E and RAK Tag Sensors
+and the ESP32-S3 V4 Sensor, in addition to the executable tracker regressions.
+The Wio-E5 Mini check uses the complete
+`v1.17.1.9-halo-keymind-cascade-dev` version string and USA Cascade profile.
+
+## Limits
+
+Bluetooth Find My/OpenHaystack advertising and automatic Room mailbox polling
+are not implemented by this change. RP2040's interrupt-driven physical wake
+has not been tested on a board. The GPS/RF and current measurements still
+needed on T1000-E and RAK Tag are listed in the
+[tracker guide](../sensor_tracker.md#remaining-work).
diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp
index 4ecbdfe59..c4b56d6c6 100644
--- a/examples/companion_radio/MyMesh.cpp
+++ b/examples/companion_radio/MyMesh.cpp
@@ -2053,6 +2053,12 @@ void MyMesh::onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *
uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
uint8_t len, uint8_t *reply) {
+ if (data == NULL || len == 0 || reply == NULL) return 0;
+#if MESH_ENABLE_LOST_REPLY
+ if (data[0] == mesh::tracker::RequestType) {
+ return handleTrackerStatusRequest(contact, sender_timestamp, data, len, reply);
+ }
+#endif
if (data[0] == REQ_TYPE_GET_TELEMETRY_DATA) {
uint8_t permissions = 0;
uint8_t cp = contact.flags >> 1; // LSB used as 'favourite' bit (so only use upper bits)
@@ -2100,6 +2106,50 @@ uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_tim
return 0; // unknown
}
+#if MESH_ENABLE_LOST_REPLY
+uint8_t MyMesh::handleTrackerStatusRequest(const ContactInfo& from, uint32_t tag,
+ const uint8_t* data, uint8_t len,
+ uint8_t* reply) {
+ mesh::tracker::StatusRequest request;
+ if (reply == NULL || !mesh::tracker::parseStatusRequest(data, len, request)
+ || isTransientContact(from)
+ || (from.type != ADV_TYPE_CHAT && from.type != ADV_TYPE_SENSOR)) return 0;
+
+ // A contact cache reference can move when another contact is loaded.
+ uint8_t key[PUB_KEY_SIZE];
+ memcpy(key, from.id.pub_key, sizeof(key));
+ ContactInfo* saved = lookupPersistentContactByPubKey(key, sizeof(key));
+ if (saved == NULL
+ || (saved->type != ADV_TYPE_CHAT && saved->type != ADV_TYPE_SENSOR)) return 0;
+ const uint32_t now = _ms->getMillis();
+ if (!lost_reply_limiter.canReply(key, tag, now)) return 0;
+
+ const uint8_t state = _prefs.lost_reply <= mesh::companion::LostReplyYes
+ ? _prefs.lost_reply : uint8_t(mesh::companion::LostReplyOff);
+ const size_t count = mesh::tracker::makeStatusResponse(
+ tag, state, state == mesh::tracker::Lost ? 120 : 0,
+ reply, mesh::tracker::ResponseLength);
+ if (count != 0) {
+ lost_reply_limiter.rememberReply(key, tag, now);
+ const uint32_t owner_time = getRTCClock()->getCurrentTime();
+ const uint32_t clock_difference = tag > owner_time
+ ? tag - owner_time : owner_time - tag;
+ // The freshest authenticated advert/report owns position. Clock-bounded
+ // reports cannot poison that durable floor with a wildly future tag.
+ if ((request.flags & mesh::tracker::FreshGps) && canMutateContacts()
+ && tag > saved->last_advert_timestamp && clock_difference <= 300) {
+ saved->gps_lat = request.latitude / 10;
+ saved->gps_lon = request.longitude / 10;
+ saved->last_advert_timestamp = tag;
+ saved->lastmod = owner_time;
+ onDiscoveredContact(*saved, false, 0, NULL);
+ }
+ }
+ // BaseChatMesh sends this response without touching the user DM ACK table.
+ return uint8_t(count);
+}
+#endif
+
void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, uint8_t len) {
const uint32_t now = _ms->getMillis();
servicePendingSerialReply(now); // Expire before considering any radio callback.
diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h
index fa9f909f0..3734d12c6 100644
--- a/examples/companion_radio/MyMesh.h
+++ b/examples/companion_radio/MyMesh.h
@@ -46,6 +46,7 @@
#if MESH_ENABLE_LOST_REPLY
#include
#endif
+#include
#include
#include
#include
@@ -814,6 +815,9 @@ private:
mesh::companion::LostReplyLimiter lost_reply_limiter;
void maybeReplyToLostQuestion(const ContactInfo& from, uint32_t sender_timestamp,
const char* text);
+ uint8_t handleTrackerStatusRequest(const ContactInfo& from, uint32_t tag,
+ const uint8_t* data, uint8_t len,
+ uint8_t* reply);
#endif
#if MESH_ENABLE_ONE_KEY_DM
// A recent ACK proves that peer can decrypt our normal text packets. Retry
diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp
index a06fe109d..8577ddd83 100644
--- a/examples/simple_sensor/SensorMesh.cpp
+++ b/examples/simple_sensor/SensorMesh.cpp
@@ -381,6 +381,7 @@ void SensorMesh::sendAlert(const ClientInfo* c, Trigger* t) {
}
void SensorMesh::alertIf(bool condition, Trigger& t, AlertPriority pri, const char* text) {
+ if (isTrackerModeEnabled()) return;
if (condition) {
if (!t.isTriggered() && num_alert_tasks < MAX_CONCURRENT_ALERTS) {
StrHelper::strncpy(t.text, text, sizeof(t.text));
@@ -418,6 +419,7 @@ bool SensorMesh::getCADEnabled() const {
}
bool SensorMesh::allowPacketForward(const mesh::Packet* packet) {
+ if (isTrackerModeEnabled()) return false;
if (_prefs.disable_fwd) return false;
if (packet->isRouteFlood() && packet->getPathHashCount() >= _prefs.flood_max) return false;
_clock_sync.observeAcceptedFlood(packet);
@@ -440,6 +442,13 @@ uint32_t SensorMesh::getDirectRetransmitDelay(const mesh::Packet* packet) {
bool SensorMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash,
uint8_t next_hop_hash_len) const {
+#if MESH_ENABLE_SENSOR_TRACKER
+ if (tracker_pending && packet != NULL) {
+ uint8_t hash[MAX_HASH_SIZE];
+ packet->calculatePacketHash(hash);
+ if (memcmp(hash, tracker_packet_hash, sizeof(hash)) == 0) return false;
+ }
+#endif
(void)packet;
(void)next_hop_hash;
(void)next_hop_hash_len;
@@ -472,6 +481,7 @@ uint32_t SensorMesh::getDirectRetryAttemptDelay(const mesh::Packet* packet, uint
}
bool SensorMesh::allowFloodRetry(const mesh::Packet* packet) const {
+ if (packet != NULL && packet->flood_retry_policy == mesh::FLOOD_RETRY_POLICY_DENY) return false;
if (_prefs.disable_fwd || _prefs.flood_retry_attempts == 0) return false;
return packet == NULL || packet->getPayloadType() != PAYLOAD_TYPE_ADVERT
|| _prefs.flood_retry_advert_enabled;
@@ -709,6 +719,20 @@ void SensorMesh::handleCommand(uint32_t sender_timestamp, char* command, char* r
mesh::cli::normalizeCommandVerb(command);
+#if MESH_ENABLE_SENSOR_TRACKER
+ if (sender_timestamp == 0 && tracker.enabled) {
+ wakeTrackerRadio();
+ tracker_awake_until = futureMillis(120000);
+ }
+ if (handleTrackerCommand(sender_timestamp, command, reply)) return;
+#else
+ if (strncmp(command, "get tracker", 11) == 0
+ || strncmp(command, "set tracker.", 12) == 0) {
+ strcpy(reply, "ERR: tracker unavailable on this build");
+ return;
+ }
+#endif
+
// first, see if this is a custom-handled CLI command (ie. in main.cpp)
if (handleCustomCommand(sender_timestamp, command, reply)) {
return; // command has been handled
@@ -782,6 +806,9 @@ void SensorMesh::handleCommand(uint32_t sender_timestamp, char* command, char* r
} else{
_cli.handleCommand(sender_timestamp, command, reply); // common CLI commands
updateGpsTelemetryPolicy();
+#if MESH_ENABLE_SENSOR_TRACKER
+ if (tracker.enabled) configureTrackerRuntime();
+#endif
}
}
@@ -790,6 +817,20 @@ void SensorMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id,
size_t app_data_len) {
mesh::Mesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len);
_clock_sync.observeVerifiedAdvert(packet, id, timestamp);
+#if MESH_ENABLE_SENSOR_TRACKER
+ AdvertDataParser advert(app_data, app_data_len);
+ if (tracker.enabled && !tracker_storage_fault && advert.isValid()
+ && advert.getType() == ADV_TYPE_REPEATER && packet->getPathHashCount() == 0) {
+ // RF activity can postpone recovery, never clear an outage or its cutoff.
+ mesh::tracker::RouteState proposed;
+ mesh::tracker::noteRepeater(tracker.route, getRTCClock()->getCurrentTime(), proposed);
+ if (proposed.flags != tracker.route.flags) {
+ mesh::tracker::TrackerRecord candidate = tracker;
+ candidate.route = proposed;
+ saveTrackerRecord(candidate);
+ } else tracker.route = proposed;
+ }
+#endif
}
void SensorMesh::onGroupPacketRecv(mesh::Packet* packet) {
@@ -843,6 +884,13 @@ void SensorMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, con
int SensorMesh::searchPeersByHash(const uint8_t* hash) {
int n = 0;
+#if MESH_ENABLE_SENSOR_TRACKER
+ // Give the full configured owner its own receive slot, independent of ACL
+ // privilege. A public-key prefix match alone never authenticates a response.
+ if (tracker.enabled && memcmp(tracker.owner, hash, PATH_HASH_SIZE) == 0) {
+ matching_peer_indexes[n++] = -2;
+ }
+#endif
for (int i = 0; i < acl.getNumClients() && n < MAX_SEARCH_RESULTS; i++) {
if (acl.getClientByIdx(i)->id.isHashMatch(hash)) {
matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods)
@@ -852,7 +900,14 @@ int SensorMesh::searchPeersByHash(const uint8_t* hash) {
}
void SensorMesh::getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) {
+ if (peer_idx < 0 || peer_idx >= MAX_SEARCH_RESULTS) {
+ memset(dest_secret, 0, PUB_KEY_SIZE);
+ return;
+ }
int i = matching_peer_indexes[peer_idx];
+#if MESH_ENABLE_SENSOR_TRACKER
+ if (i == -2) { memcpy(dest_secret, tracker_secret, PUB_KEY_SIZE); return; }
+#endif
if (i >= 0 && i < acl.getNumClients()) {
// lookup pre-calculated shared_secret
memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE);
@@ -879,7 +934,17 @@ void SensorMesh::sendAckTo(const ClientInfo& dest, uint32_t ack_hash, uint8_t pa
}
void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) {
+ if (sender_idx < 0 || sender_idx >= MAX_SEARCH_RESULTS) return;
int i = matching_peer_indexes[sender_idx];
+#if MESH_ENABLE_SENSOR_TRACKER
+ if (i == -2) {
+ if (handleTrackerResponse(packet, type, data, len)) return;
+ i = -1;
+ for (int index = 0; index < acl.getNumClients(); ++index) {
+ if (acl.getClientByIdx(index)->id.matches(tracker.owner)) { i = index; break; }
+ }
+ }
+#endif
if (i < 0 || i >= acl.getNumClients()) {
MESH_DEBUG_PRINTLN("onPeerDataRecv: Invalid sender idx: %d", i);
return;
@@ -1030,7 +1095,17 @@ void SensorMesh::onControlDataRecv(mesh::Packet* packet) {
}
bool SensorMesh::onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) {
+ if (sender_idx < 0 || sender_idx >= MAX_SEARCH_RESULTS) return false;
int i = matching_peer_indexes[sender_idx];
+#if MESH_ENABLE_SENSOR_TRACKER
+ if (i == -2) {
+ if (handleTrackerPath(packet, path, path_len, extra_type, extra, extra_len)) return false;
+ i = -1;
+ for (int index = 0; index < acl.getNumClients(); ++index) {
+ if (acl.getClientByIdx(index)->id.matches(tracker.owner)) { i = index; break; }
+ }
+ }
+#endif
if (i < 0 || i >= acl.getNumClients()) {
MESH_DEBUG_PRINTLN("onPeerPathRecv: Invalid sender idx: %d", i);
return false;
@@ -1185,6 +1260,16 @@ void SensorMesh::begin(FILESYSTEM* fs) {
#if ENV_INCLUDE_GPS == 1
applyGpsPrefs();
#endif
+#if MESH_ENABLE_SENSOR_TRACKER
+ const auto tracker_load = tracker_storage_available
+ ? mesh::tracker::loadTracker(_fs, tracker)
+ : mesh::tracker::StoreResult::Unavailable;
+ tracker_storage_fault = tracker_load != mesh::tracker::StoreResult::Success
+ && tracker_load != mesh::tracker::StoreResult::NotFound;
+ if (tracker_storage_fault) tracker.enabled = false;
+ tracker_boot_anchor = getRTCClock()->getCurrentTime();
+ configureTrackerRuntime();
+#endif
}
bool SensorMesh::applySavedRadioParams() {
@@ -1279,6 +1364,7 @@ bool SensorMesh::sendFloodReply(mesh::Packet* packet,
}
void SensorMesh::sendSelfAdvertisement(int delay_millis, bool flood) {
+ if (isTrackerModeEnabled()) return;
mesh::Packet* pkt = createSelfAdvert();
if (pkt) {
if (flood) {
@@ -1292,6 +1378,7 @@ void SensorMesh::sendSelfAdvertisement(int delay_millis, bool flood) {
}
void SensorMesh::updateAdvertTimer() {
+ if (isTrackerModeEnabled()) { next_local_advert = 0; return; }
if (_prefs.advert_interval > 0) { // schedule local advert timer
next_local_advert = futureMillis((int)((uint32_t)_prefs.advert_interval * 2 * 60 * 1000));
} else {
@@ -1299,6 +1386,7 @@ void SensorMesh::updateAdvertTimer() {
}
}
void SensorMesh::updateFloodAdvertTimer() {
+ if (isTrackerModeEnabled()) { next_flood_advert = 0; return; }
if (_prefs.flood_advert_interval > 0) { // schedule flood advert timer
next_flood_advert = futureMillis( ((uint32_t)_prefs.flood_advert_interval) * 60 * 60 * 1000);
} else {
@@ -1348,9 +1436,15 @@ bool SensorMesh::getGPS(uint8_t channel, float& lat, float& lon, float& alt) {
}
void SensorMesh::loop() {
+#if MESH_ENABLE_SENSOR_TRACKER
+ if (tracker.enabled && trackerNeedsRadio()) wakeTrackerRadio();
+#endif
_cli.loop();
mesh::Mesh::loop();
_clock_sync.loop();
+#if MESH_ENABLE_SENSOR_TRACKER
+ serviceTracker();
+#endif
if (next_flood_advert && millisHasNowPassed(next_flood_advert)) {
mesh::Packet* pkt = createSelfAdvert();
@@ -1446,6 +1540,7 @@ void SensorMesh::loop() {
}
for (int i = 0; i < acl.getNumClients(); i++) {
+ if (isTrackerModeEnabled()) break; // Check-in replaces background pushes.
ClientInfo* client = acl.getClientByIdx(i);
if ((client->permissions & PERM_ACL_ROLE_MASK) < PERM_ACL_READ_ONLY
|| client->extra.sensor.scope_region_id == 0
@@ -1481,7 +1576,7 @@ void SensorMesh::loop() {
}
// check the alert send queue
- if (num_alert_tasks > 0) {
+ if (num_alert_tasks > 0 && !isTrackerModeEnabled()) {
auto t = alert_tasks[0]; // process head of queue
if (millisHasNowPassed(t->send_expiry)) { // next send needed?
@@ -1555,6 +1650,11 @@ uint32_t SensorMesh::limitSleepToMillisTimer(unsigned long timestamp,
}
bool SensorMesh::hasPendingWork() const {
+#if MESH_ENABLE_SENSOR_TRACKER
+ if (tracker.enabled && (tracker_pending || tracker_acquiring
+ || (tracker_awake_until && !millisHasNowPassed(tracker_awake_until))
+ || (!tracker_storage_fault && getRTCClock()->getCurrentTime() >= tracker_next_check))) return true;
+#endif
if (isDualRadioActive()) return true;
if (hasPendingOtaApply()) return true;
if (_cli.hasActiveUserGpioTimer()) return true;
@@ -1581,6 +1681,14 @@ uint32_t SensorMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const {
if (max_secs == 0 || hasPendingWork()) return 0;
uint32_t sleep_secs = max_secs;
+#if MESH_ENABLE_SENSOR_TRACKER
+ if (tracker.enabled && !tracker_storage_fault) {
+ const uint32_t now = getRTCClock()->getCurrentTime();
+ if (now >= tracker_next_check) return 0;
+ const uint32_t until_check = tracker_next_check - now;
+ if (until_check < sleep_secs) sleep_secs = until_check;
+ }
+#endif
uint32_t wake_delay_ms;
if (getNextQueueWakeDelay(wake_delay_ms)) {
uint32_t wake_delay_secs = (wake_delay_ms + 999UL) / 1000UL;
diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h
index 82db85aba..d8f7b03f2 100644
--- a/examples/simple_sensor/SensorMesh.h
+++ b/examples/simple_sensor/SensorMesh.h
@@ -30,6 +30,18 @@
#include
#include
+#ifndef MESH_ENABLE_SENSOR_TRACKER
+#if !defined(STM32_PLATFORM) && ENV_INCLUDE_GPS == 1
+#define MESH_ENABLE_SENSOR_TRACKER 1
+#else
+#define MESH_ENABLE_SENSOR_TRACKER 0
+#endif
+#endif
+#if MESH_ENABLE_SENSOR_TRACKER
+#include
+#include
+#endif
+
#define PERM_RESERVED1 (1 << 2)
#define PERM_RESERVED2 (1 << 3)
#define PERM_RESERVED3 (1 << 4)
@@ -74,6 +86,20 @@ public:
const char* getNodeName() { return _prefs.node_name; }
NodePrefs* getNodePrefs() { return &_prefs; }
uint32_t getPowerSaveSleepSeconds(uint32_t max_secs) const;
+ bool isTrackerModeEnabled() const {
+#if MESH_ENABLE_SENSOR_TRACKER
+ return tracker.enabled;
+#else
+ return false;
+#endif
+ }
+ void setTrackerStorageAvailable(bool available) {
+#if MESH_ENABLE_SENSOR_TRACKER
+ tracker_storage_available = available;
+#else
+ (void)available;
+#endif
+ }
void savePrefs(
PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common) override {
_cli.savePrefs(_fs, scope);
@@ -192,6 +218,10 @@ protected:
bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override;
void onControlDataRecv(mesh::Packet* packet) override;
void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override;
+#if MESH_ENABLE_SENSOR_TRACKER
+ void onSendComplete(mesh::Packet* packet) override;
+ void onSendFail(mesh::Packet* packet) override;
+#endif
void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id,
uint32_t timestamp, const uint8_t* app_data,
size_t app_data_len) override;
@@ -199,6 +229,37 @@ protected:
virtual bool handleIncomingMsg(ClientInfo& from, uint32_t timestamp, uint8_t* data, uint8_t flags, size_t len);
void sendAckTo(const ClientInfo& dest, uint32_t ack_hash, uint8_t path_hash_size=1);
private:
+#if MESH_ENABLE_SENSOR_TRACKER
+ mesh::tracker::TrackerRecord tracker;
+ uint8_t tracker_secret[PUB_KEY_SIZE] = {};
+ bool tracker_storage_available = true;
+ bool tracker_storage_fault = false;
+ bool tracker_radio_asleep = false;
+ bool tracker_acquiring = false;
+ bool tracker_pending = false;
+ bool tracker_sent = false;
+ uint8_t tracker_packet_hash[MAX_HASH_SIZE] = {};
+ uint32_t tracker_next_check = 0;
+ uint32_t tracker_pending_tag = 0;
+ unsigned long tracker_reply_until = 0;
+ unsigned long tracker_awake_until = 0;
+ uint32_t tracker_boot_anchor = 0;
+ uint32_t tracker_clock_rtc = 0;
+ unsigned long tracker_clock_millis = 0;
+ bool handleTrackerCommand(uint32_t sender_timestamp, const char* command, char* reply);
+ void configureTrackerRuntime();
+ bool saveTrackerRecord(mesh::tracker::TrackerRecord& candidate);
+ bool trackerNeedsRadio() const;
+ void wakeTrackerRadio();
+ void cancelTrackerPending(bool delivered = false);
+ void serviceTracker();
+ bool handleTrackerResponse(mesh::Packet* packet, uint8_t type,
+ const uint8_t* data, size_t length,
+ const uint8_t* learned_path = nullptr,
+ uint8_t learned_path_len = 0xff);
+ bool handleTrackerPath(mesh::Packet* packet, uint8_t* path, uint8_t path_len,
+ uint8_t extra_type, uint8_t* extra, size_t extra_len);
+#endif
FILESYSTEM* _fs;
uint16_t pending_preamble = 0;
unsigned long next_local_advert, next_flood_advert;
diff --git a/examples/simple_sensor/Tracker.cpp b/examples/simple_sensor/Tracker.cpp
new file mode 100644
index 000000000..9d84b8ea3
--- /dev/null
+++ b/examples/simple_sensor/Tracker.cpp
@@ -0,0 +1,399 @@
+#include "SensorMesh.h"
+#include
+#include
+#include
+
+#if MESH_ENABLE_SENSOR_TRACKER
+
+static uint32_t trackerFutureSeconds(uint32_t now, uint32_t interval) {
+ return interval > UINT32_MAX - now ? UINT32_MAX : now + interval;
+}
+
+bool SensorMesh::saveTrackerRecord(mesh::tracker::TrackerRecord& candidate) {
+ if (!tracker_storage_available || tracker_storage_fault
+ || mesh::tracker::saveTracker(_fs, candidate) != mesh::tracker::StoreResult::Success) {
+ tracker_storage_fault = true;
+ return false;
+ }
+ tracker = candidate;
+ return true;
+}
+
+void SensorMesh::configureTrackerRuntime() {
+ sensors.setTrackerGpsModeEnabled(tracker.enabled);
+ sensors.setPowerSavingEnabled(tracker.enabled || _prefs.powersaving_enabled != 0);
+ if (tracker.enabled) {
+ if (tracker_clock_millis == 0) {
+ tracker_clock_rtc = getRTCClock()->getCurrentTime();
+ tracker_clock_millis = futureMillis(0);
+ }
+ self_id.calcSharedSecret(tracker_secret, tracker.owner);
+ if (tracker_next_check == 0) tracker_next_check = getRTCClock()->getCurrentTime();
+ next_local_advert = next_flood_advert = 0;
+ for (int i = 0; i < num_alert_tasks; ++i) alert_tasks[i]->text[0] = 0;
+ num_alert_tasks = 0;
+ } else {
+ cancelTrackerPending();
+ tracker_acquiring = false;
+ tracker_awake_until = 0;
+ tracker_next_check = 0;
+ memset(tracker_secret, 0, sizeof(tracker_secret));
+ wakeTrackerRadio();
+ updateAdvertTimer();
+ updateFloodAdvertTimer();
+ }
+}
+
+void SensorMesh::wakeTrackerRadio() {
+ if (!tracker_radio_asleep) return;
+ // A failed warm wake must not leave the dispatcher's liveness recovery
+ // disabled forever. Try a peripheral reset once, then return ownership to
+ // the normal watchdog even when the hardware still reports failure.
+ if (!_radio->setTrackerSleep(false)) _radio->recoverRadio(true);
+ setRadioAvailable(true); // Also resets the dispatcher's RX watchdog anchors.
+ tracker_radio_asleep = false;
+}
+
+void SensorMesh::cancelTrackerPending(bool delivered) {
+ if (!tracker_pending) return;
+ cancelActiveRetries(tracker_packet_hash);
+ for (int i = _mgr->getOutboundTotal() - 1; i >= 0; --i) {
+ const mesh::Packet* queued = _mgr->getOutboundByIdx(i);
+ uint8_t hash[MAX_HASH_SIZE];
+ queued->calculatePacketHash(hash);
+ if (memcmp(hash, tracker_packet_hash, sizeof(hash)) != 0) continue;
+ mesh::Packet* removed = _mgr->removeOutboundByIdx(i);
+ cancelOutboundRadioRetry(removed, delivered);
+ releasePacket(removed);
+ }
+ const mesh::Packet* in_flight = getOutboundInFlight();
+ if (in_flight) {
+ uint8_t hash[MAX_HASH_SIZE];
+ in_flight->calculatePacketHash(hash);
+ if (memcmp(hash, tracker_packet_hash, sizeof(hash)) == 0)
+ cancelOutboundRadioRetry(in_flight, delivered);
+ }
+ tracker_pending = tracker_sent = false;
+ tracker_pending_tag = 0;
+ tracker_reply_until = 0;
+}
+
+bool SensorMesh::trackerNeedsRadio() const {
+ if (!tracker.enabled || board.isUsbDataConnected() || board.isOTAUpdateRunning()
+ || board.isRadioTestActive() || mesh::isUsbLoggingWatchdogArmed()
+ || isAnyTempRadioActive() || hasPendingOtaApply() || isDualRadioActive()
+ || set_radio_at || revert_radio_at || saved_radio_apply_pending
+ || _cli.hasActiveUserGpioTimer() || hasOutbound() || _mgr->getOutboundTotal() != 0)
+ return true;
+ uint32_t delay;
+ if (getNextQueueWakeDelay(delay) || getNextRetryWakeDelay(delay)) return true;
+ if (tracker_pending || tracker_acquiring
+ || (tracker_awake_until && !millisHasNowPassed(tracker_awake_until))) return true;
+ return !tracker_storage_fault && getRTCClock()->getCurrentTime() >= tracker_next_check;
+}
+
+void SensorMesh::serviceTracker() {
+ if (!tracker.enabled) { wakeTrackerRadio(); return; }
+ const uint32_t now = getRTCClock()->getCurrentTime();
+ const uint32_t now_ms = uint32_t(futureMillis(0));
+ const uint32_t elapsed = uint32_t(now_ms - uint32_t(tracker_clock_millis)) / 1000;
+ if (now > tracker_clock_rtc && uint64_t(now - tracker_clock_rtc) > uint64_t(elapsed) + 2) {
+ // GPS/manual UTC correction cannot manufacture six hours of RF silence.
+ // Refresh the conservative quiet anchor without forgiving an outage.
+ tracker_boot_anchor = now;
+ }
+ tracker_clock_rtc = now;
+ tracker_clock_millis = now_ms;
+ // A transient clock rollback must remain visible even if the next automatic
+ // check is hours away, or the radio is already asleep. Persist only this
+ // exceptional transition; observing each idle second would wear the flash.
+ if (!tracker_storage_fault && !(tracker.route.flags & mesh::tracker::kClockFault)
+ && (now == 0 || now < tracker.route.latest_observed)) {
+ mesh::tracker::TrackerRecord candidate = tracker;
+ mesh::tracker::planRoute(tracker.route, now, true, candidate.route);
+ saveTrackerRecord(candidate);
+ }
+ if (trackerNeedsRadio()) wakeTrackerRadio();
+ if (tracker_radio_asleep) return;
+ if (tracker_storage_fault) {
+ cancelTrackerPending();
+ tracker_acquiring = false;
+ } else if (tracker_pending) {
+ if (!millisHasNowPassed(tracker_reply_until)) return;
+ cancelTrackerPending();
+ mesh::tracker::TrackerRecord candidate = tracker;
+ mesh::tracker::noteFailure(tracker.route, now, candidate.route);
+ saveTrackerRecord(candidate);
+ tracker_next_check = trackerFutureSeconds(now,
+ tracker.last_lost == mesh::tracker::Lost ? tracker.lost_interval : tracker.normal_interval);
+ } else if (!tracker_acquiring && now >= tracker_next_check) {
+ if (board.isOTAUpdateRunning() || board.isRadioTestActive()
+ || isAnyTempRadioActive() || hasPendingOtaApply() || isDualRadioActive()
+ || set_radio_at || revert_radio_at || saved_radio_apply_pending
+ || _mgr->getFreeCount() <= 2 || hasOutbound()) return;
+ tracker_acquiring = true;
+ sensors.beginTrackerGpsAcquisition();
+ }
+ if (tracker_acquiring) {
+ // SensorManager owns the 120-second GPS/time-sync bound. It runs after
+ // mesh in main, so finishing at our earlier deadline could miss its fix.
+ if (sensors.isTrackerGpsAcquisitionPending()) return;
+ tracker_acquiring = false;
+ mesh::tracker::StatusRequest request;
+ double lat, lon;
+ if (sensors.takeTrackerGpsPosition(lat, lon) && isfinite(lat) && isfinite(lon)
+ && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
+ request.flags = mesh::tracker::FreshGps;
+ request.latitude = (int32_t)lround(lat * 1e7);
+ request.longitude = (int32_t)lround(lon * 1e7);
+ } else request.flags = mesh::tracker::GpsAttemptFailed;
+ request.battery_mv = board.getBattMilliVolts();
+
+ // GPS gets its bounded chance to establish UTC, but an unset RTC cannot
+ // allocate a durable request tag. This is a clock wait, not a storage fault.
+ if (now == 0) {
+ tracker_next_check = 1;
+ return;
+ }
+
+ mesh::tracker::TrackerRecord candidate = tracker;
+ // A boot conservatively opens a fresh six-hour RF wait. This covers an
+ // advert heard immediately before power loss without per-packet writes.
+ const uint32_t radio_anchor = tracker_boot_anchor > tracker.route.last_repeater
+ ? tracker_boot_anchor : tracker.route.last_repeater;
+ candidate.route.last_repeater = radio_anchor;
+ if (candidate.route.latest_observed < radio_anchor)
+ candidate.route.latest_observed = radio_anchor;
+ const auto route = mesh::tracker::planRoute(candidate.route, now, true, candidate.route);
+ const bool flood = route == mesh::tracker::RouteDecision::Flood;
+ if (flood && !mesh::tracker::reserveFlood(candidate.route, now, candidate.route)) return;
+ if (candidate.last_request_tag == UINT32_MAX) {
+ tracker_storage_fault = true;
+ return;
+ }
+ const uint32_t minimum_tag = candidate.last_request_tag + 1;
+ candidate.last_request_tag = now > minimum_tag ? now : minimum_tag;
+ if ((uint64_t)candidate.last_request_tag > (uint64_t)now + 300) {
+ tracker_next_check = trackerFutureSeconds(now, 60);
+ return;
+ }
+ if (!saveTrackerRecord(candidate)) return; // Reserve before any RF admission.
+
+ uint8_t payload[4 + mesh::tracker::RequestLength] = {};
+ tracker_pending_tag = tracker.last_request_tag;
+ mesh::tracker::put32(payload, tracker_pending_tag);
+ if (!mesh::tracker::makeStatusRequest(request, payload + 4, sizeof(payload) - 4)) return;
+ mesh::Packet* packet = createDatagram(PAYLOAD_TYPE_REQ,
+ mesh::Identity(tracker.owner), tracker_secret, payload, sizeof(payload));
+ if (!packet) {
+ tracker_next_check = trackerFutureSeconds(now, 60);
+ return;
+ }
+ packet->tx_radio = mesh::RADIO_TX_PRIMARY; // One physical/profile copy.
+ packet->flood_retry_policy = mesh::FLOOD_RETRY_POLICY_DENY;
+ packet->calculatePacketHash(tracker_packet_hash);
+ tracker_pending = true;
+ tracker_sent = false;
+ tracker_reply_until = futureMillis(120000); // Bound queued admission too.
+ const bool queued = flood
+ ? sendFloodScoped(default_scope, packet, 0, _prefs.path_hash_mode + 1)
+ : sendDirect(packet, tracker.path,
+ mesh::Packet::isValidPathLen(tracker.path_len) ? tracker.path_len : 0);
+ if (!queued) {
+ cancelTrackerPending();
+ tracker_next_check = trackerFutureSeconds(now, 60);
+ }
+ return;
+ }
+ if (!trackerNeedsRadio() && !radio_driver.isWatchdogObserving()
+ && !radio_driver.isCalibratingNoiseFloor() && _radio->setTrackerSleep(true)) {
+ setRadioAvailable(false);
+ tracker_radio_asleep = true;
+ }
+}
+
+bool SensorMesh::handleTrackerResponse(mesh::Packet* packet, uint8_t type,
+ const uint8_t* data, size_t length,
+ const uint8_t* learned_path, uint8_t learned_path_len) {
+ uint8_t status;
+ uint16_t stayawake;
+ if (!tracker.enabled || !tracker_pending || !tracker_sent
+ || millisHasNowPassed(tracker_reply_until) || type != PAYLOAD_TYPE_RESPONSE
+ || !mesh::tracker::parseStatusResponse(data, length, tracker_pending_tag, status, stayawake))
+ return false;
+ mesh::tracker::TrackerRecord candidate = tracker;
+ const uint32_t now = getRTCClock()->getCurrentTime();
+ const auto observed = mesh::tracker::planRoute(tracker.route, now, true, candidate.route);
+ if (observed == mesh::tracker::RouteDecision::InvalidClock
+ || observed == mesh::tracker::RouteDecision::InvalidState) {
+ if (candidate.route.flags != tracker.route.flags) saveTrackerRecord(candidate);
+ return false;
+ }
+ // The cutoff can become due while a recovery flood waits for its reply.
+ // Evaluate it at arrival, then require a direct reply to clear that lock.
+ const bool keep_cutoff = (candidate.route.flags & mesh::tracker::kFloodLocked)
+ && !packet->isRouteDirect();
+ if (!keep_cutoff && !mesh::tracker::noteAuthenticatedReply(candidate.route,
+ now, candidate.route)) {
+ if (candidate.route.flags != tracker.route.flags) saveTrackerRecord(candidate);
+ return false;
+ }
+ if (learned_path != nullptr) {
+ if (!mesh::Packet::isValidPathLen(learned_path_len)) return false;
+ candidate.path_len = learned_path_len;
+ memset(candidate.path, 0, sizeof(candidate.path));
+ memcpy(candidate.path, learned_path, mesh::tracker::trackerPathByteLength(learned_path_len));
+ }
+ if (status != mesh::tracker::Unknown) candidate.last_lost = status;
+ if (!saveTrackerRecord(candidate)) return false;
+ packet->markDoNotRetransmit();
+ cancelTrackerPending(true);
+ if (status == mesh::tracker::Safe) tracker_awake_until = 0;
+ else if (status == mesh::tracker::Lost) {
+ if (stayawake > 300) stayawake = 300;
+ tracker_awake_until = stayawake ? futureMillis((uint32_t)stayawake * 1000UL) : 0;
+ }
+ tracker_next_check = trackerFutureSeconds(getRTCClock()->getCurrentTime(),
+ tracker.last_lost == mesh::tracker::Lost ? tracker.lost_interval : tracker.normal_interval);
+ return true;
+}
+
+bool SensorMesh::handleTrackerPath(mesh::Packet* packet, uint8_t* path, uint8_t path_len,
+ uint8_t extra_type, uint8_t* extra, size_t extra_len) {
+ if (!path || !mesh::Packet::isValidPathLen(path_len)) return false;
+ return handleTrackerResponse(packet, extra_type, extra, extra_len, path, path_len);
+}
+
+void SensorMesh::onSendComplete(mesh::Packet* packet) {
+ mesh::Mesh::onSendComplete(packet);
+ if (!tracker_pending || !packet || packet->getPayloadType() != PAYLOAD_TYPE_REQ) return;
+ uint8_t hash[MAX_HASH_SIZE];
+ packet->calculatePacketHash(hash);
+ if (memcmp(hash, tracker_packet_hash, sizeof(hash)) == 0) {
+ tracker_sent = true;
+ tracker_reply_until = futureMillis(mesh::tracker::kReplyWindowSeconds * 1000UL);
+ }
+}
+
+void SensorMesh::onSendFail(mesh::Packet* packet) {
+ mesh::Mesh::onSendFail(packet);
+ if (!tracker_pending || !packet) return;
+ uint8_t hash[MAX_HASH_SIZE];
+ packet->calculatePacketHash(hash);
+ if (memcmp(hash, tracker_packet_hash, sizeof(hash)) == 0)
+ tracker_reply_until = futureMillis(1);
+}
+
+bool SensorMesh::handleTrackerCommand(uint32_t sender_timestamp, const char* command, char* reply) {
+ const bool getter = strcmp(command, "get tracker") == 0
+ || strcmp(command, "get tracker status") == 0 || strncmp(command, "get tracker.", 12) == 0;
+ const bool setter = strncmp(command, "set tracker.", 12) == 0;
+ if (!getter && !setter && strcmp(command, "tracker check") != 0) return false;
+ if (sender_timestamp != 0) { strcpy(reply, "ERR: tracker is local-only"); return true; }
+ if (strcmp(command, "get tracker.owner") == 0) {
+ static const char hex[] = "0123456789abcdef";
+ for (size_t i = 0; i < 32; ++i) {
+ reply[i * 2] = hex[tracker.owner[i] >> 4]; reply[i * 2 + 1] = hex[tracker.owner[i] & 15];
+ }
+ reply[64] = 0;
+ return true;
+ }
+ if (getter) {
+ if (strcmp(command, "get tracker.mode") == 0) {
+ strcpy(reply, tracker.enabled ? "dog" : "off"); return true;
+ }
+ if (strcmp(command, "get tracker.interval") == 0
+ || strcmp(command, "get tracker.lost.interval") == 0) {
+ snprintf(reply, 157, "%lu", (unsigned long)(command[12] == 'l'
+ ? tracker.lost_interval : tracker.normal_interval)); return true;
+ }
+ if (strcmp(command, "get tracker.path") == 0) {
+ snprintf(reply, 157, "path=%s hops=%u hash.bytes=%u",
+ tracker.path_len == mesh::tracker::kUnknownPath ? "unknown"
+ : (tracker.path_len & 63) == 0 ? "direct" : "saved",
+ tracker.path_len == mesh::tracker::kUnknownPath ? 0 : tracker.path_len & 63,
+ tracker.path_len == mesh::tracker::kUnknownPath ? 0 : (tracker.path_len >> 6) + 1);
+ return true;
+ }
+ if (strcmp(command, "get tracker") != 0 && strcmp(command, "get tracker status") != 0) {
+ strcpy(reply, "ERR: unknown tracker setting"); return true;
+ }
+ snprintf(reply, 157, "mode=%s status=%s interval=%lu lost.interval=%lu flood=%s clock=%s storage=%s",
+ tracker.enabled ? "dog" : "off", tracker.last_lost == 2 ? "yes" : tracker.last_lost == 1 ? "no" : "unknown",
+ (unsigned long)tracker.normal_interval, (unsigned long)tracker.lost_interval,
+ tracker.route.flags & mesh::tracker::kFloodLocked ? "locked" : "6h",
+ tracker.route.flags & mesh::tracker::kClockFault ? "fault" : "ok",
+ tracker_storage_fault ? "fault" : "ok");
+ return true;
+ }
+ if (strcmp(command, "tracker check") == 0) {
+ if (!tracker.enabled || tracker_storage_fault) strcpy(reply, "ERR: tracker unavailable");
+ else if (tracker_pending || tracker_acquiring) strcpy(reply, "ERR: check already pending");
+ else { tracker_next_check = getRTCClock()->getCurrentTime(); strcpy(reply, "OK scheduled"); }
+ return true;
+ }
+ mesh::tracker::TrackerRecord candidate = tracker;
+ bool changed_owner = false;
+ if (strncmp(command, "set tracker.owner ", 18) == 0) {
+ const char* key = command + 18;
+ if (strlen(key) != 64) { strcpy(reply, "ERR: full owner key required"); return true; }
+ uint8_t nonzero = 0;
+ for (size_t i = 0; i < 64; ++i) {
+ const char c = key[i];
+ const int digit = c >= '0' && c <= '9' ? c - '0'
+ : c >= 'a' && c <= 'f' ? c - 'a' + 10 : c >= 'A' && c <= 'F' ? c - 'A' + 10 : -1;
+ if (digit < 0) { strcpy(reply, "ERR: invalid owner key"); return true; }
+ if ((i & 1) == 0) candidate.owner[i / 2] = uint8_t(digit << 4);
+ else { candidate.owner[i / 2] |= uint8_t(digit); nonzero |= candidate.owner[i / 2]; }
+ }
+ if (!nonzero || memcmp(candidate.owner, self_id.pub_key, PUB_KEY_SIZE) == 0) {
+ strcpy(reply, "ERR: invalid owner key"); return true;
+ }
+ changed_owner = memcmp(candidate.owner, tracker.owner, PUB_KEY_SIZE) != 0;
+ if (changed_owner) {
+ candidate.path_len = mesh::tracker::kUnknownPath;
+ memset(candidate.path, 0, sizeof(candidate.path));
+ candidate.last_lost = mesh::tracker::Unknown;
+ }
+ } else if (strcmp(command, "set tracker.mode dog") == 0) {
+ if (!sensors.isGPSDetected()) { strcpy(reply, "ERR: GPS required"); return true; }
+ candidate.enabled = true;
+ } else if (strcmp(command, "set tracker.mode off") == 0) candidate.enabled = false;
+ else if (strncmp(command, "set tracker.interval ", 21) == 0
+ || strncmp(command, "set tracker.lost.interval ", 26) == 0) {
+ const bool lost = command[12] == 'l';
+ int32_t seconds;
+ if (!mesh::cli::parseIntegerStrict(command + (lost ? 26 : 21), seconds)
+ || seconds < 60 || seconds > 86400) {
+ strcpy(reply, "ERR: interval must be 60..86400 seconds"); return true;
+ }
+ if (lost) candidate.lost_interval = uint32_t(seconds);
+ else candidate.normal_interval = uint32_t(seconds);
+ } else if (strncmp(command, "set tracker.path ", 17) == 0) {
+ mesh::cli::TerminalPath parsed;
+ memset(candidate.path, 0, sizeof(candidate.path));
+ if (mesh::cli::parseTerminalPath(command + 17, candidate.path, sizeof(candidate.path), 63, parsed)
+ != mesh::cli::TerminalPathParseResult::Valid) {
+ strcpy(reply, "ERR: invalid tracker path"); return true;
+ }
+ candidate.path_len = parsed.mode == mesh::cli::TerminalPathMode::Clear
+ ? mesh::tracker::kUnknownPath : parsed.encoded_len;
+ } else { strcpy(reply, "ERR: unknown tracker setting"); return true; }
+ if (!mesh::tracker::isValidTrackerRecord(candidate)) {
+ strcpy(reply, "ERR: configure tracker.owner first"); return true;
+ }
+ uint8_t secret[PUB_KEY_SIZE];
+ self_id.calcSharedSecret(secret, candidate.owner);
+ uint8_t secret_bits = 0;
+ for (size_t i = 0; i < sizeof(secret); ++i) secret_bits |= secret[i];
+ if (!secret_bits) { strcpy(reply, "ERR: invalid owner key"); return true; }
+ if (!saveTrackerRecord(candidate)) { strcpy(reply, "ERR: tracker save failed"); return true; }
+ cancelTrackerPending();
+ tracker_acquiring = false;
+ tracker_next_check = getRTCClock()->getCurrentTime();
+ configureTrackerRuntime();
+ strcpy(reply, "OK");
+ return true;
+}
+
+#endif
diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp
index 340c3a70a..93c848113 100644
--- a/examples/simple_sensor/main.cpp
+++ b/examples/simple_sensor/main.cpp
@@ -10,6 +10,22 @@
#include
#include
#include
+#if defined(NRF52_PLATFORM) && MESH_ENABLE_SENSOR_TRACKER
+#include
+static SoftwareTimer tracker_wake_timer;
+static void trackerWakeCallback(TimerHandle_t) { }
+
+// SYSTEMON event sleep has no built-in deadline. Arm a real timer before
+// leaving the loop; allocation/queue failure keeps polling instead of hanging.
+static bool armTrackerSleepWake(uint32_t seconds) {
+ if (!tracker_wake_timer.getHandle()) {
+ tracker_wake_timer.begin(1000, trackerWakeCallback, nullptr, false);
+ }
+ const TimerHandle_t handle = tracker_wake_timer.getHandle();
+ if (!handle || seconds == 0 || seconds > 30) return false;
+ return xTimerChangePeriod(handle, pdMS_TO_TICKS(seconds * 1000UL), 0) == pdPASS;
+}
+#endif
#if defined(ESP32_PLATFORM)
#include
@@ -212,6 +228,9 @@ void setup() {
sensors.begin();
+#if defined(NRF52_PLATFORM)
+ the_mesh.setTrackerStorageAvailable(!volatile_primary_fs);
+#endif
the_mesh.begin(fs);
#if defined(NRF52_PLATFORM)
@@ -321,7 +340,8 @@ void loop() {
external_watchdog.loop();
#endif
- bool can_power_save = the_mesh.getNodePrefs()->powersaving_enabled
+ bool can_power_save = (the_mesh.getNodePrefs()->powersaving_enabled
+ || the_mesh.isTrackerModeEnabled())
&& !board.isUsbDataConnected()
&& !mesh::isUsbLoggingWatchdogArmed();
#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \
@@ -335,7 +355,14 @@ void loop() {
if (sleep_secs > 0) external_watchdog.feed();
#endif
#if defined(NRF52_PLATFORM)
- if (sleep_secs > 0) board.sleep(0);
+ if (sleep_secs > 0) {
+#if MESH_ENABLE_SENSOR_TRACKER
+ if (!the_mesh.isTrackerModeEnabled() || armTrackerSleepWake(sleep_secs)) board.sleep(0);
+ else delay(1);
+#else
+ board.sleep(0);
+#endif
+ }
#else
if (sleep_secs > 0
&& the_mesh.millisHasNowPassed(
@@ -345,7 +372,8 @@ void loop() {
#endif
}
#if defined(ESP32_PLATFORM)
- if (!can_power_save && the_mesh.getNodePrefs()->powersaving_enabled) {
+ if (!can_power_save && (the_mesh.getNodePrefs()->powersaving_enabled
+ || the_mesh.isTrackerModeEnabled())) {
delay(1); // Keep USB and the button wake interval serviced while idle.
}
#endif
diff --git a/mkdocs.yml b/mkdocs.yml
index a3f64cb6b..234602cf4 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -82,6 +82,7 @@ nav:
- flood_filtering.md
- gps_tracking.md
- companion_lost_reply.md
+ - sensor_tracker.md
- nrf52_power_management.md
- CLI and protocol reference:
- cli_commands.md
diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp
index a3a06712b..91420a238 100644
--- a/src/Dispatcher.cpp
+++ b/src/Dispatcher.cpp
@@ -224,6 +224,9 @@ bool Dispatcher::startOutboundTransmit() {
bool Dispatcher::scheduleOutboundRadioRetry() {
if (outbound == NULL || outbound_radio_retry_used || outbound_cancellation != OutboundCancellation::None) return false;
+ // A lost TxDone may follow a real transmission, so a denied flood must not
+ // spend its reserved airtime again during radio recovery.
+ if (outbound->isRouteFlood() && outbound->flood_retry_policy == FLOOD_RETRY_POLICY_DENY) return false;
outbound_radio_retry_used = true;
outbound_radio_retry_pending = true;
diff --git a/src/Dispatcher.h b/src/Dispatcher.h
index 801753275..597469d74 100644
--- a/src/Dispatcher.h
+++ b/src/Dispatcher.h
@@ -119,6 +119,11 @@ public:
virtual void idle() { }
virtual void startRecv() { }
+ // A tracker may stop RF reception between explicit check-in windows. The
+ // dispatcher must be made unavailable while asleep so watchdog recovery
+ // cannot restart RX. Unsupported transports decline without changing state.
+ virtual bool setTrackerSleep(bool sleeping) { (void)sleeping; return false; }
+
// Recover a radio whose MCU driver is still running but whose RF state has
// stopped making progress. hard=false is a safe receive/AGC re-arm;
// hard=true may reset only the radio peripheral. The default remains useful
diff --git a/src/helpers/SensorManager.cpp b/src/helpers/SensorManager.cpp
index 82f140b24..b21008ac2 100644
--- a/src/helpers/SensorManager.cpp
+++ b/src/helpers/SensorManager.cpp
@@ -1,6 +1,10 @@
#include "SensorManager.h"
#include
+#if ENV_INCLUDE_GPS
+static const uint32_t GPS_TRACKER_MAX_ACQUIRE_MS = 120000UL;
+#endif
+
bool SensorManager::getCachedGpsPosition(double& latitude, double& longitude) const {
latitude = longitude = 0;
#if ENV_INCLUDE_GPS
@@ -17,6 +21,82 @@ bool SensorManager::getCachedGpsPosition(double& latitude, double& longitude) co
#endif
}
+void SensorManager::setTrackerGpsModeEnabled(bool enabled) {
+#if ENV_INCLUDE_GPS
+ if (gps_tracker_enabled == enabled) return;
+ // Leave an interrupted window through the same hardware stop/start hooks so
+ // the normal provider power cycle is rearmed when a saved GPS-on preference
+ // resumes. In particular, GPS-off must not leave the receiver running.
+ if (!enabled && gps_transport_available && telemetryGpsDetected() && telemetryGpsActive()) {
+ telemetryGpsStop();
+ }
+ gps_tracker_enabled = enabled;
+ gps_tracker_fix_ready = false;
+ const unsigned long now = millis();
+ cancelGpsTelemetryDemand(now);
+ LocationProvider* location = getLocationProvider();
+ if (location != nullptr) {
+ location->endFreshTimeSync();
+ // The provider's normal clock/power cycle must not wake GPS while the
+ // tracker is idle. The normal preference is restored when mode ends.
+ location->setGPSPowerSaving(!enabled && powersaving_enabled && gps_user_enabled);
+ }
+ if (enabled) {
+ maybeStopGpsForTelemetry(now);
+ } else if (gps_transport_available && telemetryGpsDetected() && gps_user_enabled) {
+ if (!telemetryGpsActive()) telemetryGpsStart();
+ } else if (gps_location_access_available) {
+ gps_next_cache_update_at = 0;
+ }
+#else
+ (void)enabled;
+#endif
+}
+
+bool SensorManager::beginTrackerGpsAcquisition() {
+#if ENV_INCLUDE_GPS
+ if (!gps_tracker_enabled || !gps_transport_available || !telemetryGpsDetected()) return false;
+ if (gps_acquiring) return true; // A repeated request must not extend its bound.
+ gps_tracker_fix_ready = false;
+ gps_hold_until = 0;
+ LocationProvider* location = getLocationProvider();
+ if (location == nullptr || !location->clearPositionFix()) return false;
+ gps_tracker_sync_generation = location->getTimeSyncGeneration();
+ location->beginFreshTimeSync();
+ if (location->getGPSPowerSaving()) location->setGPSPowerSaving(false);
+ beginGpsTelemetryAcquisition(millis());
+ return gps_acquiring;
+#else
+ return false;
+#endif
+}
+
+bool SensorManager::isTrackerGpsAcquisitionPending() const {
+#if ENV_INCLUDE_GPS
+ return gps_tracker_enabled && gps_acquiring;
+#else
+ return false;
+#endif
+}
+
+bool SensorManager::takeTrackerGpsPosition(double& latitude, double& longitude) {
+ latitude = longitude = 0;
+#if ENV_INCLUDE_GPS
+ const unsigned long now = millis();
+ if (gps_tracker_enabled && gps_acquiring
+ && static_cast(now - gps_acquire_started_at) >= GPS_TRACKER_MAX_ACQUIRE_MS)
+ finishGpsTelemetryAcquisition(now, false);
+ if (!gps_tracker_enabled || gps_acquiring || !gps_tracker_fix_ready) return false;
+ gps_tracker_fix_ready = false;
+ if (static_cast(now - gps_stable_started_at) >= GPS_TRACKER_MAX_ACQUIRE_MS) return false;
+ latitude = gps_cache_lat;
+ longitude = gps_cache_lon;
+ return true;
+#else
+ return false;
+#endif
+}
+
#if ENV_INCLUDE_GPS
#ifndef GPS_TELEMETRY_CACHE_INTERVAL_SEC
#define GPS_TELEMETRY_CACHE_INTERVAL_SEC (2UL * 60UL * 60UL)
@@ -66,7 +146,7 @@ void SensorManager::updateGpsTelemetryCache(float lat, float lon, float altitude
}
void SensorManager::maybeStopGpsForTelemetry(unsigned long now) {
- if (gps_transport_available && telemetryGpsActive() && !gps_user_enabled
+ if (gps_transport_available && telemetryGpsActive() && (gps_tracker_enabled || !gps_user_enabled)
&& !gps_acquiring && !gpsTelemetryHoldActive(now)) {
telemetryGpsStop();
gps_next_cache_update_at = now + GPS_TELEMETRY_CACHE_INTERVAL_SEC * 1000UL;
@@ -114,6 +194,13 @@ void SensorManager::finishGpsTelemetryAcquisition(unsigned long now, bool use_we
now);
}
gps_acquiring = false;
+ if (gps_tracker_enabled) {
+ // Keep a fresh position acquired before a clock-sync timeout. Its one-shot
+ // result expires relative to completion, not the first position sample.
+ gps_stable_started_at = now;
+ LocationProvider* location = getLocationProvider();
+ if (location != nullptr) location->endFreshTimeSync();
+ }
gps_next_cache_update_at = now + GPS_TELEMETRY_CACHE_INTERVAL_SEC * 1000UL;
maybeStopGpsForTelemetry(now);
}
@@ -123,6 +210,13 @@ bool SensorManager::queryGpsTelemetry(uint8_t requester_permissions, CayenneLPP&
|| !telemetryGpsDetected()) return false;
unsigned long now = millis();
+ if (gps_tracker_enabled) {
+ // Ordinary telemetry can observe the cache, but may not create a two-hour
+ // hold or acquire GPS outside the tracker's scheduled window.
+ if (!gpsTelemetryCacheFresh(now)) return false;
+ telemetry.addGPS(TELEM_CHANNEL_SELF, gps_cache_lat, gps_cache_lon, gps_cache_altitude);
+ return true;
+ }
if (!gps_transport_available) {
// A bridge may temporarily own the GPS UART. Authorized callers can still
// receive the last good fix while it is inside the normal freshness bound,
@@ -155,6 +249,21 @@ bool SensorManager::queryGpsTelemetry(uint8_t requester_permissions, CayenneLPP&
void SensorManager::processGpsTelemetryFix(float lat, float lon, float altitude, unsigned long now) {
if (!gps_transport_available) return;
+ if (gps_tracker_enabled) {
+ if (!gps_acquiring) return;
+ if (static_cast(now - gps_acquire_started_at) >= GPS_TRACKER_MAX_ACQUIRE_MS) {
+ finishGpsTelemetryAcquisition(now, false);
+ return;
+ }
+ if (!isfinite(lat) || !isfinite(lon) || lat < -90.0f || lat > 90.0f
+ || lon < -180.0f || lon > 180.0f || (lat == 0 && lon == 0)) return;
+ updateGpsTelemetryCache(lat, lon, altitude, now);
+ gps_tracker_fix_ready = true;
+ LocationProvider* location = getLocationProvider();
+ if (location != nullptr && location->getTimeSyncGeneration() != gps_tracker_sync_generation)
+ finishGpsTelemetryAcquisition(now, false);
+ return;
+ }
if (!gps_acquiring) {
if (gps_user_enabled || gpsTelemetryHoldActive(now)) {
updateGpsTelemetryCache(lat, lon, altitude, now);
@@ -196,6 +305,28 @@ void SensorManager::loopGpsTelemetry(unsigned long now) {
// Expire the validity bit even while a bridge owns the UART. A stale fix
// must not become fresh again when the 32-bit millis counter cycles.
if (gps_cache_valid && !gpsTelemetryCacheFresh(now)) gps_cache_valid = false;
+ if (gps_tracker_enabled) {
+ LocationProvider* location = getLocationProvider();
+ if (location != nullptr && location->getGPSPowerSaving()) location->setGPSPowerSaving(false);
+ if (!gps_transport_available) return;
+ if (gps_acquiring
+ && static_cast(now - gps_acquire_started_at) >= GPS_TRACKER_MAX_ACQUIRE_MS) {
+ finishGpsTelemetryAcquisition(now, false);
+ }
+ if (gps_acquiring && telemetryGpsActive() && location != nullptr && location->isValid()) {
+ // Tracker freshness is independent of the saved telemetry gps_interval.
+ // Derived managers have already polled this provider in their previous
+ // loop; clearPositionFix() made any retained pre-window sample invalid.
+ processGpsTelemetryFix(static_cast(location->getLatitude()) / 1000000.0f,
+ static_cast(location->getLongitude()) / 1000000.0f,
+ static_cast(location->getAltitude()) / 1000.0f, now);
+ }
+ if (gps_acquiring && gps_tracker_fix_ready && location != nullptr
+ && location->getTimeSyncGeneration() != gps_tracker_sync_generation)
+ finishGpsTelemetryAcquisition(now, false);
+ maybeStopGpsForTelemetry(now);
+ return;
+ }
if (!gps_transport_available) return;
if (!gps_user_enabled && !gpsTelemetryHoldActive(now) && !gps_acquiring) {
maybeStopGpsForTelemetry(now);
@@ -213,6 +344,12 @@ void SensorManager::loopGpsTelemetry(unsigned long now) {
void SensorManager::setGpsTelemetryUserEnabled(bool enabled) {
gps_user_enabled = enabled;
unsigned long now = millis();
+ if (gps_tracker_enabled) {
+ LocationProvider* location = getLocationProvider();
+ if (location != nullptr && location->getGPSPowerSaving()) location->setGPSPowerSaving(false);
+ maybeStopGpsForTelemetry(now);
+ return;
+ }
if (enabled) {
if (gps_transport_available && telemetryGpsDetected()
&& !telemetryGpsActive()) telemetryGpsStart();
@@ -227,10 +364,13 @@ void SensorManager::setGpsTelemetryTransportAvailable(bool available) {
gps_transport_available = available;
if (!available) {
+ LocationProvider* location = getLocationProvider();
+ if (location != nullptr) location->endFreshTimeSync();
// The UART is no longer ours. Cancel both the short acquisition and the
// two-hour remote-query hold so neither can silently reclaim it from a
// bridge. Preserve the user's preference and last good cache.
gps_acquiring = false;
+ gps_tracker_fix_ready = false;
gps_acquire_has_fix = false;
gps_hold_until = 0;
gps_acquire_started_at = 0;
@@ -244,7 +384,7 @@ void SensorManager::setGpsTelemetryTransportAvailable(bool available) {
}
gps_next_cache_update_at = 0;
- if (gps_user_enabled && telemetryGpsDetected() && !telemetryGpsActive()) {
+ if (!gps_tracker_enabled && gps_user_enabled && telemetryGpsDetected() && !telemetryGpsActive()) {
telemetryGpsStart();
}
}
@@ -255,6 +395,7 @@ void SensorManager::resetGpsTelemetryTransportState() {
// new probe has established which provider, if any, is present.
gps_transport_available = true;
gps_acquiring = false;
+ gps_tracker_fix_ready = false;
gps_acquire_has_fix = false;
gps_hold_until = 0;
gps_acquire_started_at = 0;
@@ -276,7 +417,7 @@ void SensorManager::setTelemetryLocationAccessAvailable(bool available) {
gps_location_access_available = available;
if (available) {
gps_next_cache_update_at = 0;
- } else if (gps_acquiring && !gps_user_enabled && !gpsTelemetryHoldActive(now)) {
+ } else if (!gps_tracker_enabled && gps_acquiring && !gps_user_enabled && !gpsTelemetryHoldActive(now)) {
gps_acquiring = false;
maybeStopGpsForTelemetry(now);
}
diff --git a/src/helpers/SensorManager.h b/src/helpers/SensorManager.h
index 160048705..a6ba28938 100644
--- a/src/helpers/SensorManager.h
+++ b/src/helpers/SensorManager.h
@@ -37,6 +37,9 @@ class SensorManager {
unsigned long gps_acquire_started_at = 0;
unsigned long gps_stable_started_at = 0;
uint32_t gps_update_interval_sec = 0;
+ bool gps_tracker_enabled = false;
+ bool gps_tracker_fix_ready = false;
+ uint32_t gps_tracker_sync_generation = 0;
bool gpsTelemetryHoldActive(unsigned long now) const;
bool gpsTelemetryCacheFresh(unsigned long now) const;
@@ -58,10 +61,12 @@ protected:
void setGpsTelemetryTransportAvailable(bool available);
void resetGpsTelemetryTransportState();
bool isGpsTelemetryUserEnabled() const { return gps_user_enabled; }
+ bool isTrackerGpsModeEnabled() const { return gps_tracker_enabled; }
bool isGpsTelemetryTransportAvailable() const {
return gps_transport_available;
}
bool gpsTelemetryReceiverRequired(unsigned long now) const {
+ if (gps_tracker_enabled) return gps_acquiring;
return gps_acquiring || gpsTelemetryHoldActive(now);
}
bool setGpsUpdateIntervalValue(const char* value) {
@@ -112,12 +117,18 @@ public:
// Read the existing fresh cache without acquiring GPS, changing power, or
// taking a UART. Sleeping/off receivers may retain a recent valid fix.
bool getCachedGpsPosition(double& latitude, double& longitude) const;
+ // Runtime tracker ownership leaves the saved manual GPS preference intact.
+ // Each acquisition needs a new fix and validated clock sync, bounded to 120s.
+ void setTrackerGpsModeEnabled(bool enabled);
+ bool beginTrackerGpsAcquisition();
+ bool isTrackerGpsAcquisitionPending() const;
+ bool takeTrackerGpsPosition(double& latitude, double& longitude);
bool requestGpsTelemetryTimeSync(uint64_t min_interval_secs) {
#if ENV_INCLUDE_GPS
// A denied request must not consume the provider's throttle or reclaim a
// bridge-owned UART. Shared GPS rails make isEnabled() an unsafe substitute
// for the manager's established transport ownership.
- if (!gps_transport_available || !telemetryGpsDetected()) return false;
+ if (gps_tracker_enabled || !gps_transport_available || !telemetryGpsDetected()) return false;
LocationProvider* location = getLocationProvider();
if (location != nullptr && location->getTimeSyncIntervalHours() != 0) {
min_interval_secs = static_cast(location->getTimeSyncIntervalHours()) * 3600UL;
diff --git a/src/helpers/SensorTrackerStore.h b/src/helpers/SensorTrackerStore.h
new file mode 100644
index 000000000..4b38e3587
--- /dev/null
+++ b/src/helpers/SensorTrackerStore.h
@@ -0,0 +1,221 @@
+#pragma once
+
+#include
+#include
+#include
+#include "FilePresence.h"
+#include "TrackerRoutePolicy.h"
+
+namespace mesh {
+namespace tracker {
+
+static constexpr size_t kPathCapacity = 64;
+static constexpr uint8_t kUnknownPath = 0xff;
+static constexpr uint32_t kReplyWindowSeconds = 20;
+static constexpr uint32_t kGpsAcquireSeconds = 120;
+static constexpr size_t kTrackerImageSize = 160;
+static const char kTrackerBank0Path[] = "/tracker.0";
+static const char kTrackerBank1Path[] = "/tracker.1";
+static const char kTrackerTempPath[] = "/tracker.tmp";
+
+struct TrackerRecord {
+ bool enabled = false;
+ uint8_t owner[32] = {};
+ uint32_t normal_interval = 1800;
+ uint32_t lost_interval = 60;
+ RouteState route;
+ uint8_t path_len = kUnknownPath;
+ uint8_t path[kPathCapacity] = {};
+ uint8_t last_lost = 0; // 0 unknown, 1 no, 2 yes
+ uint32_t last_request_tag = 0; // Persisted nonce floor, bounded near RTC time.
+ uint32_t generation = 0;
+};
+
+enum class StoreResult : uint8_t { Success, NotFound, Invalid, Unavailable, Conflict };
+
+inline size_t trackerPathByteLength(uint8_t encoded_length) {
+ return static_cast(encoded_length & 63) * ((encoded_length >> 6) + 1);
+}
+
+inline bool isValidTrackerRecord(const TrackerRecord& record) {
+ uint8_t owner_bits = 0;
+ for (size_t index = 0; index < sizeof(record.owner); ++index) owner_bits |= record.owner[index];
+ if (!owner_bits || record.normal_interval < 60 || record.normal_interval > 86400
+ || record.lost_interval < 60 || record.lost_interval > 86400
+ || record.last_lost > 2 || !isValidRouteState(record.route)) return false;
+ if (record.last_request_tag && (!record.route.latest_observed
+ || uint64_t(record.last_request_tag) > uint64_t(record.route.latest_observed) + 300)) return false;
+ // Match Packet::isValidPathLen: four-byte hashes are reserved, not supported.
+ return record.path_len == kUnknownPath || ((record.path_len >> 6) != 3
+ && trackerPathByteLength(record.path_len) <= kPathCapacity);
+}
+
+namespace tracker_store_detail {
+inline void failClosed(TrackerRecord& record) {
+ record = TrackerRecord();
+ record.route = route_detail::failClosed();
+}
+
+inline bool encode(const TrackerRecord& record, uint8_t* image) {
+ if (!image || !record.generation || !isValidTrackerRecord(record)) return false;
+ memset(image, 0, kTrackerImageSize);
+ image[0] = 'S'; image[1] = 'T'; image[2] = 'R'; image[3] = 1;
+ route_detail::put32(image + 4, record.generation);
+ image[8] = record.enabled ? 1 : 0;
+ image[9] = record.last_lost;
+ image[10] = record.path_len;
+ route_detail::put32(image + 12, record.normal_interval);
+ route_detail::put32(image + 16, record.lost_interval);
+ memcpy(image + 20, record.owner, sizeof(record.owner));
+ if (!encodeRouteState(record.route, image + 52, kRouteStateImageSize)) return false;
+ if (record.path_len != kUnknownPath) memcpy(image + 84, record.path, trackerPathByteLength(record.path_len));
+ route_detail::put32(image + 148, record.last_request_tag);
+ route_detail::put32(image + 156, route_detail::crc(image, 156));
+ return true;
+}
+
+inline bool decode(const uint8_t* image, TrackerRecord& record) {
+ if (!image || image[0] != 'S' || image[1] != 'T' || image[2] != 'R' || image[3] != 1
+ || !route_detail::get32(image + 4) || image[8] > 1 || image[11]
+ || route_detail::get32(image + 156) != route_detail::crc(image, 156)) return false;
+ TrackerRecord decoded;
+ decoded.generation = route_detail::get32(image + 4);
+ decoded.enabled = image[8] != 0;
+ decoded.last_lost = image[9];
+ decoded.path_len = image[10];
+ decoded.normal_interval = route_detail::get32(image + 12);
+ decoded.lost_interval = route_detail::get32(image + 16);
+ decoded.last_request_tag = route_detail::get32(image + 148);
+ memcpy(decoded.owner, image + 20, sizeof(decoded.owner));
+ if (!decodeRouteState(image + 52, kRouteStateImageSize, decoded.route)
+ || !isValidTrackerRecord(decoded)) return false;
+ const size_t path_bytes = decoded.path_len == kUnknownPath ? 0 : trackerPathByteLength(decoded.path_len);
+ memcpy(decoded.path, image + 84, path_bytes);
+ for (size_t index = 84 + path_bytes; index < 148; ++index) if (image[index]) return false;
+ for (size_t index = 152; index < 156; ++index) if (image[index]) return false;
+ record = decoded;
+ return true;
+}
+
+template
+bool readImage(Filesystem* fs, const char* path, TrackerRecord& record,
+ const uint8_t* expected = nullptr) {
+#if defined(RP2040_PLATFORM)
+ auto file = fs->open(path, "r");
+#else
+ auto file = fs->open(path);
+#endif
+ if (!file) return false;
+ uint8_t image[kTrackerImageSize];
+ bool okay = !file.isDirectory() && file.size() == sizeof(image);
+ if (okay) okay = static_cast(file.read(image, sizeof(image))) == sizeof(image);
+ file.close();
+ return okay && (!expected || memcmp(image, expected, sizeof(image)) == 0) && decode(image, record);
+}
+
+// A bad present bank is never treated as an absent store or replaced. Temps
+// are uncommitted and ignored. A power cut during publication leaves the older
+// active bank intact; the inactive bank is removed only after verifying temp.
+template
+StoreResult inspect(Filesystem* fs, TrackerRecord& record, uint8_t& active_bank) {
+ record = TrackerRecord();
+ active_bank = 0xff;
+ bool present[2];
+ if (!fs || !filePresence(fs, kTrackerBank0Path, present[0])
+ || !filePresence(fs, kTrackerBank1Path, present[1])) {
+ failClosed(record);
+ return StoreResult::Unavailable;
+ }
+ if (!present[0] && !present[1]) return StoreResult::NotFound;
+ const char* paths[2] = { kTrackerBank0Path, kTrackerBank1Path };
+ for (uint8_t bank = 0; bank < 2; ++bank) {
+ if (!present[bank]) continue;
+ TrackerRecord read;
+ if (!readImage(fs, paths[bank], read) || ((read.generation - 1) & 1) != bank) {
+ failClosed(record);
+ return StoreResult::Unavailable;
+ }
+ if (active_bank != 0xff) {
+ const uint32_t high = read.generation > record.generation ? read.generation : record.generation;
+ const uint32_t low = read.generation < record.generation ? read.generation : record.generation;
+ if (high - low != 1) {
+ failClosed(record);
+ return StoreResult::Unavailable;
+ }
+ }
+ if (read.generation > record.generation) {
+ record = read;
+ active_bank = bank;
+ }
+ }
+ return StoreResult::Success;
+}
+
+template
+bool removeArtifact(Filesystem* fs, const char* path) {
+ bool present;
+ if (!filePresence(fs, path, present)) return false;
+ if (!present) return true;
+ fs->remove(path);
+ return filePresence(fs, path, present) && !present;
+}
+} // namespace tracker_store_detail
+
+template
+StoreResult loadTracker(Filesystem* fs, TrackerRecord& record) {
+ uint8_t bank;
+ return tracker_store_detail::inspect(fs, record, bank);
+}
+
+// Single writer, main-loop owned, durable filesystems only. generation is a
+// compare-and-swap token: refresh after a conflict or ambiguous failed save.
+// No radio transmission is permitted unless its reservation save succeeds.
+template
+StoreResult saveTracker(Filesystem* fs, TrackerRecord& record) {
+ if (!fs) return StoreResult::Unavailable;
+ if (!isValidTrackerRecord(record)) return StoreResult::Invalid;
+ TrackerRecord current;
+ uint8_t bank;
+ const StoreResult loaded = tracker_store_detail::inspect(fs, current, bank);
+ if (loaded != StoreResult::Success && loaded != StoreResult::NotFound) return loaded;
+ if (record.generation != current.generation) return StoreResult::Conflict;
+ if (record.last_request_tag < current.last_request_tag) return StoreResult::Invalid;
+ if (current.generation == UINT32_MAX) return StoreResult::Unavailable;
+ TrackerRecord proposed = record;
+ proposed.generation = current.generation + 1;
+ uint8_t image[kTrackerImageSize];
+ if (!tracker_store_detail::encode(proposed, image)
+ || !tracker_store_detail::removeArtifact(fs, kTrackerTempPath)) return StoreResult::Unavailable;
+#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
+ auto file = fs->open(kTrackerTempPath, FILE_O_WRITE);
+#elif defined(ESP32_PLATFORM)
+ auto file = fs->open(kTrackerTempPath, "w", true);
+#else
+ auto file = fs->open(kTrackerTempPath, "w");
+#endif
+ if (!file) return StoreResult::Unavailable;
+ if (file.isDirectory()) {
+ file.close();
+ tracker_store_detail::removeArtifact(fs, kTrackerTempPath);
+ return StoreResult::Unavailable;
+ }
+ const bool written = file.write(image, sizeof(image)) == sizeof(image);
+ file.flush();
+ file.close();
+ if (!written || !tracker_store_detail::readImage(fs, kTrackerTempPath, current, image)) {
+ tracker_store_detail::removeArtifact(fs, kTrackerTempPath);
+ return StoreResult::Unavailable;
+ }
+ const char* destination = ((proposed.generation - 1) & 1) ? kTrackerBank1Path : kTrackerBank0Path;
+ if (!tracker_store_detail::removeArtifact(fs, destination)
+ || !fs->rename(kTrackerTempPath, destination)) {
+ tracker_store_detail::removeArtifact(fs, kTrackerTempPath);
+ return StoreResult::Unavailable;
+ }
+ if (!tracker_store_detail::readImage(fs, destination, current, image)) return StoreResult::Unavailable;
+ record.generation = proposed.generation;
+ return StoreResult::Success;
+}
+
+} // namespace tracker
+} // namespace mesh
diff --git a/src/helpers/TrackerProtocol.h b/src/helpers/TrackerProtocol.h
new file mode 100644
index 000000000..07e536e75
--- /dev/null
+++ b/src/helpers/TrackerProtocol.h
@@ -0,0 +1,120 @@
+#pragma once
+
+#include
+#include
+
+namespace mesh {
+namespace tracker {
+
+// Encrypted PAYLOAD_TYPE_REQ/RESPONSE packets carry the usual four-byte tag.
+// Only the configured full peer identity may answer a client's pending tag.
+static constexpr uint8_t RequestType = 0x0c;
+static constexpr uint8_t Version = 1;
+static constexpr size_t TagLength = 4;
+static constexpr size_t RequestLength = 13;
+static constexpr size_t ResponseLength = 9;
+static constexpr size_t CipherBlockLength = 16;
+
+enum Status : uint8_t { Unknown = 0, Safe = 1, Lost = 2 };
+enum RequestFlags : uint8_t { FreshGps = 1, GpsAttemptFailed = 2 };
+
+struct StatusRequest {
+ uint8_t flags = 0;
+ int32_t latitude = 0; // degrees * 1e7
+ int32_t longitude = 0; // degrees * 1e7
+ uint16_t battery_mv = 0;
+};
+
+inline uint32_t get32(const uint8_t* bytes) {
+ uint32_t value = 0;
+ for (size_t i = 0; i < 4; ++i) value |= uint32_t(bytes[i]) << (8 * i);
+ return value;
+}
+
+inline void put32(uint8_t* bytes, uint32_t value) {
+ for (size_t i = 0; i < 4; ++i) bytes[i] = uint8_t(value >> (8 * i));
+}
+
+inline int32_t getSigned32(const uint8_t* bytes) {
+ const uint32_t value = get32(bytes);
+ // Avoid implementation-defined conversion when the sign bit is set.
+ return value <= UINT32_C(0x7fffffff) ? int32_t(value)
+ : -1 - int32_t(UINT32_MAX - value);
+}
+
+inline bool validStatusRequest(const StatusRequest& request) {
+ if (request.flags > GpsAttemptFailed) return false;
+ if (!(request.flags & FreshGps)) {
+ return request.latitude == 0 && request.longitude == 0;
+ }
+ return request.latitude >= -900000000 && request.latitude <= 900000000
+ && request.longitude >= -1800000000 && request.longitude <= 1800000000;
+}
+
+inline bool zeroPadded(const uint8_t* bytes, size_t length, size_t logical,
+ size_t padded) {
+ if (bytes == NULL || (length != logical && length != padded)) return false;
+ for (size_t i = logical; i < length; ++i) {
+ if (bytes[i] != 0) return false;
+ }
+ return true;
+}
+
+// The callback has removed the tag. The seventeen-byte plaintext occupies two
+// cipher blocks, leaving a twenty-eight-byte body including zero padding.
+inline bool parseStatusRequest(const uint8_t* bytes, size_t length,
+ StatusRequest& request) {
+ if (!zeroPadded(bytes, length, RequestLength,
+ 2 * CipherBlockLength - TagLength)
+ || bytes[0] != RequestType || bytes[1] != Version) return false;
+ StatusRequest value;
+ value.flags = bytes[2];
+ value.latitude = getSigned32(bytes + 3);
+ value.longitude = getSigned32(bytes + 7);
+ value.battery_mv = uint16_t(bytes[11]) | (uint16_t(bytes[12]) << 8);
+ if (!validStatusRequest(value)) return false;
+ request = value;
+ return true;
+}
+
+inline size_t makeStatusRequest(const StatusRequest& request, uint8_t* bytes,
+ size_t capacity) {
+ if (bytes == NULL || capacity < RequestLength || !validStatusRequest(request))
+ return 0;
+ bytes[0] = RequestType;
+ bytes[1] = Version;
+ bytes[2] = request.flags;
+ put32(bytes + 3, uint32_t(request.latitude));
+ put32(bytes + 7, uint32_t(request.longitude));
+ bytes[11] = uint8_t(request.battery_mv);
+ bytes[12] = uint8_t(request.battery_mv >> 8);
+ return RequestLength;
+}
+
+inline size_t makeStatusResponse(uint32_t tag, uint8_t status,
+ uint16_t stayawake_seconds, uint8_t* bytes,
+ size_t capacity) {
+ if (bytes == NULL || capacity < ResponseLength || status > Lost) return 0;
+ put32(bytes, tag);
+ bytes[4] = RequestType;
+ bytes[5] = Version;
+ bytes[6] = status;
+ bytes[7] = uint8_t(stayawake_seconds);
+ bytes[8] = uint8_t(stayawake_seconds >> 8);
+ return ResponseLength;
+}
+
+inline bool parseStatusResponse(const uint8_t* bytes, size_t length,
+ uint32_t expected_tag, uint8_t& status,
+ uint16_t& stayawake_seconds) {
+ if (!zeroPadded(bytes, length, ResponseLength, CipherBlockLength)
+ || bytes[4] != RequestType || bytes[5] != Version || bytes[6] > Lost)
+ return false;
+ if (get32(bytes) != expected_tag) return false;
+ status = bytes[6];
+ stayawake_seconds = uint16_t(bytes[7]) | (uint16_t(bytes[8]) << 8);
+ return true;
+}
+
+} // namespace tracker
+} // namespace mesh
diff --git a/src/helpers/TrackerRoutePolicy.h b/src/helpers/TrackerRoutePolicy.h
new file mode 100644
index 000000000..78dfc8ac4
--- /dev/null
+++ b/src/helpers/TrackerRoutePolicy.h
@@ -0,0 +1,186 @@
+#pragma once
+
+#include
+#include
+#include
+
+namespace mesh {
+namespace tracker {
+
+static constexpr uint32_t kFloodIntervalSeconds = UINT32_C(21600);
+static constexpr uint32_t kOutageCutoffSeconds = UINT32_C(345600);
+static constexpr size_t kRouteStateImageSize = 32;
+static constexpr uint8_t kOutageActive = 1;
+static constexpr uint8_t kFloodLocked = 2;
+static constexpr uint8_t kClockFault = 4;
+
+// RTC seconds, never millis(). Zero denotes an unknown timestamp. Persist with
+// encodeRouteState, rather than copying this compiler-dependent struct layout.
+struct RouteState {
+ uint32_t outage_since = 0;
+ uint32_t last_target_reply = 0;
+ uint32_t last_repeater = 0;
+ uint32_t last_flood = 0;
+ uint32_t latest_observed = 0;
+ uint8_t flags = 0;
+};
+
+enum class RouteDecision : uint8_t {
+ Direct, Wait, Flood, Locked, InvalidClock, InvalidState
+};
+
+namespace route_detail {
+inline uint32_t crcByte(uint32_t crc, uint8_t value) {
+ crc ^= value;
+ for (uint8_t bit = 0; bit < 8; ++bit) {
+ crc = (crc >> 1) ^ ((crc & 1) ? UINT32_C(0xedb88320) : 0);
+ }
+ return crc;
+}
+inline uint32_t crc(const uint8_t* data, size_t length) {
+ uint32_t value = UINT32_C(0xffffffff);
+ for (size_t index = 0; index < length; ++index) value = crcByte(value, data[index]);
+ return value ^ UINT32_C(0xffffffff);
+}
+inline void put32(uint8_t* data, uint32_t value) {
+ for (uint8_t index = 0; index < 4; ++index) data[index] = static_cast(value >> (index * 8));
+}
+inline uint32_t get32(const uint8_t* data) {
+ uint32_t value = 0;
+ for (uint8_t index = 0; index < 4; ++index) value |= static_cast(data[index]) << (index * 8);
+ return value;
+}
+inline RouteState failClosed() {
+ RouteState state;
+ state.flags = kClockFault;
+ return state;
+}
+} // namespace route_detail
+
+inline bool isValidRouteState(const RouteState& state) {
+ if (state.flags & ~(kOutageActive | kFloodLocked | kClockFault)) return false;
+ const bool active = (state.flags & kOutageActive) != 0;
+ if (active != (state.outage_since != 0) || ((state.flags & kFloodLocked) && !active)) return false;
+ if (state.outage_since > state.latest_observed || state.last_target_reply > state.latest_observed
+ || state.last_repeater > state.latest_observed || state.last_flood > state.latest_observed) return false;
+ if (active && state.last_target_reply > state.outage_since) return false;
+ return true;
+}
+
+namespace route_detail {
+// Invalid input never wraps a timestamp or silently resets an outage. A clock
+// fault is sticky until an authenticated target reply at a valid later time.
+inline bool observe(const RouteState& current, uint64_t now, RouteState& candidate) {
+ candidate = current;
+ if (!isValidRouteState(current)) {
+ candidate = failClosed();
+ return false;
+ }
+ if (now == 0 || now > UINT32_MAX || now < current.latest_observed) {
+ candidate.flags |= kClockFault;
+ return false;
+ }
+ candidate.latest_observed = static_cast(now);
+ if ((candidate.flags & kOutageActive)
+ && candidate.latest_observed - candidate.outage_since >= kOutageCutoffSeconds) {
+ candidate.flags |= kFloodLocked;
+ }
+ return true;
+}
+inline void startOutage(RouteState& state) {
+ if (!(state.flags & kOutageActive)) {
+ state.flags |= kOutageActive;
+ state.outage_since = state.latest_observed;
+ }
+}
+inline bool elapsed(uint32_t now, uint32_t anchor, uint32_t interval) {
+ return anchor == 0 || (now >= anchor && now - anchor >= interval);
+}
+} // namespace route_detail
+
+// Planning does not authorize transmission. Persist every changed candidate,
+// and reserve/persist a flood before transmitting it. During an outage, eligible
+// recovery floods take precedence over a retained path that has stopped working.
+inline RouteDecision planRoute(const RouteState& current, uint64_t now,
+ bool has_direct_path, RouteState& candidate) {
+ if (!isValidRouteState(current)) {
+ candidate = route_detail::failClosed();
+ return RouteDecision::InvalidState;
+ }
+ if (!route_detail::observe(current, now, candidate)) return RouteDecision::InvalidClock;
+ if (!has_direct_path) route_detail::startOutage(candidate);
+ if (candidate.flags & kClockFault) return has_direct_path ? RouteDecision::Direct : RouteDecision::InvalidClock;
+ if (candidate.flags & kFloodLocked) return has_direct_path ? RouteDecision::Direct : RouteDecision::Locked;
+ if (candidate.flags & kOutageActive) {
+ const uint32_t time = candidate.latest_observed;
+ if (route_detail::elapsed(time, candidate.outage_since, kFloodIntervalSeconds)
+ && route_detail::elapsed(time, candidate.last_repeater, kFloodIntervalSeconds)
+ && route_detail::elapsed(time, candidate.last_flood, kFloodIntervalSeconds)) return RouteDecision::Flood;
+ }
+ return has_direct_path ? RouteDecision::Direct : RouteDecision::Wait;
+}
+
+inline bool noteFailure(const RouteState& current, uint64_t now, RouteState& candidate) {
+ if (!route_detail::observe(current, now, candidate)) return false;
+ route_detail::startOutage(candidate);
+ return true;
+}
+
+inline bool noteRepeater(const RouteState& current, uint64_t now, RouteState& candidate) {
+ if (!route_detail::observe(current, now, candidate)) return false;
+ candidate.last_repeater = candidate.latest_observed;
+ return true;
+}
+
+// The caller must first authenticate the configured target and establish that
+// this is its direct DM response or matching direct ACK. Repeater adverts, room
+// messages and unrelated ACKs cannot call this function.
+inline bool noteAuthenticatedReply(const RouteState& current, uint64_t now, RouteState& candidate) {
+ if (!route_detail::observe(current, now, candidate)) return false;
+ candidate.last_target_reply = candidate.latest_observed;
+ candidate.outage_since = 0;
+ candidate.flags = 0;
+ return true;
+}
+
+// Save this reservation successfully BEFORE sending. A failed save means no
+// transmission; a reboot after saving conservatively spends this flood slot.
+inline bool reserveFlood(const RouteState& current, uint64_t now, RouteState& reserved) {
+ if (planRoute(current, now, false, reserved) != RouteDecision::Flood) return false;
+ reserved.last_flood = reserved.latest_observed;
+ return true;
+}
+
+inline bool encodeRouteState(const RouteState& state, uint8_t* image, size_t size) {
+ if (!image || size != kRouteStateImageSize || !isValidRouteState(state)) return false;
+ memset(image, 0, size);
+ image[0] = 'T'; image[1] = 'R'; image[2] = 'P'; image[3] = 1;
+ image[4] = state.flags;
+ route_detail::put32(image + 8, state.outage_since);
+ route_detail::put32(image + 12, state.last_target_reply);
+ route_detail::put32(image + 16, state.last_repeater);
+ route_detail::put32(image + 20, state.last_flood);
+ route_detail::put32(image + 24, state.latest_observed);
+ route_detail::put32(image + 28, route_detail::crc(image, 28));
+ return true;
+}
+
+inline bool decodeRouteState(const uint8_t* image, size_t size, RouteState& state) {
+ state = route_detail::failClosed();
+ if (!image || size != kRouteStateImageSize || image[0] != 'T' || image[1] != 'R'
+ || image[2] != 'P' || image[3] != 1 || image[5] || image[6] || image[7]
+ || route_detail::get32(image + 28) != route_detail::crc(image, 28)) return false;
+ RouteState decoded;
+ decoded.flags = image[4];
+ decoded.outage_since = route_detail::get32(image + 8);
+ decoded.last_target_reply = route_detail::get32(image + 12);
+ decoded.last_repeater = route_detail::get32(image + 16);
+ decoded.last_flood = route_detail::get32(image + 20);
+ decoded.latest_observed = route_detail::get32(image + 24);
+ if (!isValidRouteState(decoded)) return false;
+ state = decoded;
+ return true;
+}
+
+} // namespace tracker
+} // namespace mesh
diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp
index 108782bfe..b84c1bd12 100644
--- a/src/helpers/radiolib/RadioLibWrappers.cpp
+++ b/src/helpers/radiolib/RadioLibWrappers.cpp
@@ -671,6 +671,19 @@ void RadioLibWrapper::idle() {
state = STATE_IDLE; // need another startReceive()
}
+bool RadioLibWrapper::setTrackerSleep(bool sleeping) {
+ if (_cw_active || (state & 0x0f) == STATE_TX_WAIT) return false;
+ if (sleeping && isPacketPendingOrReceiving()) return false;
+ if (_radio->standby() != RADIOLIB_ERR_NONE) return false;
+ _rx_ps_armed = false;
+ _rx_hold_continuous = false;
+ state = STATE_IDLE;
+ // PhysicalLayer's no-argument sleep retains configuration on SX126x and
+ // LR11x0. The board's powerOff() intentionally uses cold sleep on some
+ // radios and must not be used for a scheduled tracker wake.
+ return !sleeping || _radio->sleep() == RADIOLIB_ERR_NONE;
+}
+
void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) {
_threshold = threshold;
// The Dispatcher calls this every two seconds. Repeatedly recalibrating a
diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h
index f92329375..97cb7846b 100644
--- a/src/helpers/radiolib/RadioLibWrappers.h
+++ b/src/helpers/radiolib/RadioLibWrappers.h
@@ -356,6 +356,7 @@ public:
void resetAGC() override;
void loop() override;
+ bool setTrackerSleep(bool sleeping) override;
bool recoverRadio(bool hard) override;
uint32_t getPacketsRecv() const { return n_recv; }
diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp
index 227124e7a..a1397ea00 100644
--- a/src/helpers/sensors/EnvironmentSensorManager.cpp
+++ b/src/helpers/sensors/EnvironmentSensorManager.cpp
@@ -419,6 +419,7 @@ class RAK12500LocationProvider : public LocationProvider {
int _sats = 0;
long _epoch = 0;
bool _fix = false;
+ bool _fresh_position_requested = false;
mesh::RTCClock* _clock = NULL;
unsigned long _next_time_check = 0;
unsigned long _last_time_sync = 0;
@@ -436,6 +437,13 @@ public:
long getAltitude() override { return _alt; }
long satellitesCount() override { return _sats; }
bool isValid() override { return _fix; }
+ bool clearPositionFix() override {
+ _fix = false;
+ _fresh_position_requested = true;
+ _next_time_check = 0;
+ ublox_GNSS.flushPVT();
+ return true;
+ }
long getTimestamp() override { return _epoch; }
void sendSentence(const char * sentence) override { }
void reset() override {
@@ -448,6 +456,8 @@ public:
_sats = 0;
_epoch = 0;
_fix = false;
+ _fresh_position_requested = false;
+ _fresh_time_sync_required = false;
_next_time_check = 0;
_last_time_sync = 0;
_valid_time_samples = 0;
@@ -463,6 +473,18 @@ public:
if ((int32_t)(now - _next_time_check) >= 0) {
_next_time_check = now + 1000;
+ if (_fresh_position_requested || _fresh_time_sync_required) {
+ // The library's scalar getters can return retained data even when
+ // their implicit PVT poll fails. Require a successfully received PVT
+ // before exposing a position for this tracker acquisition.
+ ublox_GNSS.flushPVT();
+ if (!ublox_GNSS.getPVT(8)) {
+ _fix = false;
+ _valid_time_samples = 0;
+ return;
+ }
+ _fresh_position_requested = false;
+ }
if (ublox_GNSS.getGnssFixOk(8)) {
_fix = true;
_lat = ublox_GNSS.getLatitude(2) / 10;
@@ -1156,6 +1178,10 @@ void EnvironmentSensorManager::setPowerSavingEnabled(bool enabled) {
#if ENV_INCLUDE_GPS
if (!gps_detected) return;
+ if (isTrackerGpsModeEnabled()) {
+ _location->setGPSPowerSaving(false);
+ return;
+ }
bool gps_user_enabled = isGpsTelemetryUserEnabled();
#if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE)
if (serialGPSFlag) {
diff --git a/src/helpers/sensors/LocationProvider.h b/src/helpers/sensors/LocationProvider.h
index 592b087f4..9e0ac32ed 100644
--- a/src/helpers/sensors/LocationProvider.h
+++ b/src/helpers/sensors/LocationProvider.h
@@ -29,9 +29,13 @@ protected:
uint32_t _last_time_sync_applied_ms = 0;
bool _time_sync_request_seen = false;
bool _time_sync_applied_seen = false;
+ bool _fresh_time_sync_required = false;
+ uint32_t _time_sync_generation = 0;
void markTimeSyncApplied() {
_time_sync_applied = true;
+ ++_time_sync_generation;
+ _fresh_time_sync_required = false;
_last_time_sync_applied_ms = static_cast(millis());
_time_sync_applied_seen = true;
}
@@ -63,6 +67,14 @@ public:
else (void)requestTimeSync(static_cast(_gps_sync_interval_hours) * 3600UL);
}
virtual void syncTime() { _time_sync_needed = true; }
+ // A bounded tracker window needs new validated samples even when an older
+ // clock sync is still waiting to be consumed by another component.
+ virtual void beginFreshTimeSync() {
+ _fresh_time_sync_required = true;
+ syncTime();
+ }
+ void endFreshTimeSync() { _fresh_time_sync_required = false; }
+ uint32_t getTimeSyncGeneration() const { return _time_sync_generation; }
virtual bool waitingTimeSync() { return _time_sync_needed; }
// Telemetry requests share an in-progress acquisition and rate-limit new
// ones using monotonic time, not an RTC which GPS/manual sync may correct.
@@ -147,6 +159,10 @@ public:
virtual long getAltitude() = 0;
virtual long satellitesCount() = 0;
virtual bool isValid() = 0;
+ // Invalidate a previous position without resetting receiver ephemeris or
+ // clock policy. Call before starting a bounded fresh acquisition. Unknown
+ // providers fail closed rather than presenting retained data as new.
+ virtual bool clearPositionFix() { return false; }
virtual long getTimestamp() = 0;
virtual void sendSentence(const char * sentence);
virtual bool waitFor(const char* prefix, uint32_t timeout_ms) { return false; }
diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h
index c4c4b4635..704f24f0b 100644
--- a/src/helpers/sensors/MicroNMEALocationProvider.h
+++ b/src/helpers/sensors/MicroNMEALocationProvider.h
@@ -79,6 +79,7 @@ class MicroNMEALocationProvider : public LocationProvider {
bool _uart_seen = false;
bool _nmea_seen = false;
bool _fix_seen = false;
+ bool _fresh_clock_sample = false;
static uint32_t ageMs(unsigned long timestamp) {
return (uint32_t)(millis() - timestamp);
@@ -170,6 +171,12 @@ public :
}
void syncTime() override { nmea.clear(); LocationProvider::syncTime(); }
+ void beginFreshTimeSync() override {
+ time_valid = 0;
+ _fresh_clock_sample = false;
+ next_check = millis();
+ LocationProvider::beginFreshTimeSync();
+ }
mesh::RTCClock* getRTCClock() override { return _clock; }
long getLatitude() override { return nmea.getLatitude(); }
long getLongitude() override { return nmea.getLongitude(); }
@@ -180,6 +187,17 @@ public :
}
long satellitesCount() override { return nmea.getNumSatellites(); }
bool isValid() override { return nmea.isValid(); }
+ bool clearPositionFix() override {
+ nmea.clear();
+ // Discard only bytes already queued before the acquisition begins.
+ // Snapshot the count so a noisy live UART cannot make this unbounded.
+ int buffered = _gps_serial->available();
+ if (buffered < 0 || buffered > 1024) return false;
+ while (buffered-- > 0) {
+ if (_gps_serial->read() < 0) return false;
+ }
+ return true;
+ }
long getTimestamp() override {
const uint16_t year = nmea.getYear();
@@ -256,6 +274,7 @@ public :
(strcmp(message_id, "GGA") == 0 || strcmp(message_id, "RMC") == 0)) {
_last_fix_ms = millis();
_fix_seen = true;
+ _fresh_clock_sample = true;
}
} else {
_nmea_checksum_bad++;
@@ -274,11 +293,13 @@ public :
_time_sync_needed = true;
}
const long timestamp = getTimestamp();
- if (isValid() && satellitesCount() >= 5 && timestamp > 0) {
+ if (isValid() && satellitesCount() >= 5 && timestamp > 0
+ && (!_fresh_time_sync_required || _fresh_clock_sample)) {
time_valid++;
} else {
time_valid = 0;
}
+ _fresh_clock_sample = false;
if (_time_sync_needed && time_valid > 2) {
if (_clock != NULL) {
_clock->setCurrentTime(timestamp);
diff --git a/test/fixtures/dispatcher/radio_retry_test.cpp b/test/fixtures/dispatcher/radio_retry_test.cpp
new file mode 100644
index 000000000..bc51d49c1
--- /dev/null
+++ b/test/fixtures/dispatcher/radio_retry_test.cpp
@@ -0,0 +1,148 @@
+#include
+#include
+#include
+#include
+
+class TestClock : public mesh::MillisecondClock {
+ public:
+ unsigned long now = 1000;
+ unsigned long getMillis() override { return now; }
+};
+
+// The frame reaches RF, but its TxDone interrupt never reaches the driver.
+class TestRadio : public mesh::Radio {
+ public:
+ bool in_rx = true, complete = false, fail_first_start = false;
+ unsigned starts = 0, transmitted = 0, finished = 0, recoveries = 0;
+ int recvRaw(uint8_t*, int) override { return 0; }
+ uint32_t getEstAirtimeFor(int) override { return 100; }
+ float packetScore(float, int) override { return 0; }
+ bool startSendRaw(const uint8_t*, int) override {
+ ++starts;
+ if (fail_first_start && starts == 1) return false;
+ ++transmitted;
+ in_rx = false;
+ return true;
+ }
+ bool isSendComplete() override { return complete; }
+ void onSendFinished() override { ++finished; in_rx = true; }
+ bool isInRecvMode() const override { return in_rx; }
+ bool recoverRadio(bool hard) override {
+ assert(hard);
+ ++recoveries;
+ in_rx = true;
+ return true;
+ }
+};
+
+class TestDispatcher : public mesh::Dispatcher {
+ public:
+ unsigned failures = 0, completions = 0;
+ TestDispatcher(TestRadio& radio, TestClock& clock, StaticPoolPacketManager& manager)
+ : Dispatcher(radio, clock, manager) {}
+ mesh::DispatcherAction onRecvPacket(mesh::Packet*) override { return ACTION_RELEASE; }
+ void onSendFail(mesh::Packet*) override { ++failures; }
+ void onSendComplete(mesh::Packet*) override { ++completions; }
+};
+
+struct Fixture {
+ TestClock clock;
+ TestRadio radio;
+ StaticPoolPacketManager manager{4};
+ TestDispatcher dispatcher{radio, clock, manager};
+ Fixture() { dispatcher.begin(); }
+ void loopAt(unsigned long now) { clock.now = now; dispatcher.loop(); }
+ void queue(uint8_t route, uint8_t policy) {
+ mesh::Packet* packet = dispatcher.obtainNewPacket();
+ assert(packet);
+ packet->header = route | (PAYLOAD_TYPE_REQ << PH_TYPE_SHIFT);
+ packet->payload_len = 4;
+ for (unsigned i = 0; i < packet->payload_len; ++i) packet->payload[i] = i;
+ packet->transport_codes[0] = packet->transport_codes[1] = 0;
+ packet->flood_retry_policy = policy;
+ assert(dispatcher.sendPacket(packet, 0));
+ }
+ void expectRetired(unsigned failures, unsigned completions) {
+ assert(!dispatcher.hasOutbound());
+ assert(manager.getOutboundTotal() == 0 && manager.getFreeCount() == 4);
+ assert(dispatcher.failures == failures && dispatcher.completions == completions);
+ }
+};
+
+static bool retryAllowed(uint8_t route, uint8_t policy) {
+ const bool flood = route == ROUTE_TYPE_FLOOD || route == ROUTE_TYPE_TRANSPORT_FLOOD;
+ return !flood || policy != mesh::FLOOD_RETRY_POLICY_DENY;
+}
+
+static void lostTxDone(uint8_t route, uint8_t policy, bool second_done) {
+ Fixture f;
+ f.queue(route, policy);
+ f.loopAt(1001);
+ assert(f.radio.starts == 1 && f.radio.transmitted == 1 && f.dispatcher.hasOutbound());
+ f.loopAt(1151); // Exact expiry has not passed yet.
+ assert(f.radio.recoveries == 0 && f.radio.finished == 0);
+ f.loopAt(1152); // Actual Dispatcher TX timeout and hard peripheral recovery.
+ assert(f.radio.recoveries == 1 && f.radio.finished == 1);
+ assert(f.dispatcher.getErrFlags() & ERR_EVENT_RADIO_WATCHDOG);
+ f.loopAt(1352); // Retry backoff has not passed yet either.
+ assert(f.radio.starts == 1);
+ f.loopAt(1353);
+ if (!retryAllowed(route, policy)) {
+ assert(f.radio.starts == 1 && "a denied flood must never be retransmitted");
+ assert(f.radio.transmitted == 1);
+ f.expectRetired(1, 0);
+ f.loopAt(5000);
+ assert(f.radio.starts == 1 && f.radio.recoveries == 1);
+ } else {
+ assert(f.radio.starts == 2 && f.radio.transmitted == 2);
+ f.radio.complete = second_done;
+ f.loopAt(1504);
+ f.expectRetired(second_done ? 0 : 1, second_done ? 1 : 0);
+ assert(f.radio.recoveries == (second_done ? 1U : 2U));
+ f.loopAt(5000);
+ assert(f.radio.starts == 2); // Never a third hardware attempt.
+ }
+}
+
+static void failedStart(uint8_t route, uint8_t policy) {
+ Fixture f;
+ f.radio.fail_first_start = true;
+ f.queue(route, policy);
+ f.loopAt(1001);
+ assert(f.radio.starts == 1 && f.radio.transmitted == 0);
+ f.loopAt(1202);
+ if (!retryAllowed(route, policy)) {
+ assert(f.radio.starts == 1);
+ f.expectRetired(1, 0);
+ } else {
+ assert(f.radio.starts == 2 && f.radio.transmitted == 1);
+ f.radio.complete = true;
+ f.loopAt(1203);
+ f.expectRetired(0, 1);
+ }
+ assert(f.radio.recoveries == 0);
+}
+
+int main() {
+ // Run the strict policy first so removing its guard fails through the real
+ // missing-TxDone timeout path, rather than a textual contract assertion.
+ lostTxDone(ROUTE_TYPE_FLOOD, mesh::FLOOD_RETRY_POLICY_DENY, false);
+ for (uint8_t route : {ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD,
+ ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT}) {
+ for (uint8_t policy : {mesh::FLOOD_RETRY_POLICY_DEFAULT,
+ mesh::FLOOD_RETRY_POLICY_DENY,
+ mesh::FLOOD_RETRY_POLICY_ALLOW}) {
+ lostTxDone(route, policy, true);
+ lostTxDone(route, policy, false);
+ failedStart(route, policy);
+ }
+ }
+ // Denied floods still get the ordinary successful-send completion hook.
+ Fixture f;
+ f.queue(ROUTE_TYPE_FLOOD, mesh::FLOOD_RETRY_POLICY_DENY);
+ f.loopAt(1001);
+ f.radio.complete = true;
+ f.loopAt(1002);
+ f.expectRetired(0, 1);
+ assert(f.radio.starts == 1 && f.radio.transmitted == 1 && f.radio.recoveries == 0);
+}
diff --git a/test/fixtures/sensor_subscriptions/test_sensor_subscriptions.cpp b/test/fixtures/sensor_subscriptions/test_sensor_subscriptions.cpp
index a8cbd7d4c..50f1a9109 100644
--- a/test/fixtures/sensor_subscriptions/test_sensor_subscriptions.cpp
+++ b/test/fixtures/sensor_subscriptions/test_sensor_subscriptions.cpp
@@ -71,9 +71,11 @@ public:
ACL acl;
struct { uint8_t path_hash_mode = 0; } _prefs;
bool refuse_allocation = false, refuse_queue = false;
+ bool tracker_mode = false;
unsigned allocations = 0, attempts = 0, queued = 0;
mesh::Packet owned_packet;
Clock* getRTCClock() { return &clock; }
+ bool isTrackerModeEnabled() const { return tracker_mode; }
bool telemHasChanged(ClientInfo*);
void snapshotTelemetry(ClientInfo*);
uint8_t handleRequest(ClientInfo*, uint32_t, uint8_t, uint8_t*, size_t,
@@ -211,6 +213,40 @@ static void admission_checks() {
}
}
}
+static void tracker_mode_checks() {
+ for (uint8_t stored_path : {0, 0xFF}) {
+ SensorMesh target;
+ target.acl.clients.resize(1);
+ ClientInfo& client = target.acl.clients[0];
+ client.out_path_len = stored_path;
+ assert(subscribe(target, client, LPP_VOLTAGE, 2) == 12);
+ target.telemetry.bytes = {1, LPP_VOLTAGE, 1, 104};
+ const auto original = client.extra.sensor;
+ target.tracker_mode = true;
+ for (unsigned repeat = 0; repeat < 3; ++repeat) target.pushSubscriptions();
+ assert(!target.allocations && !target.attempts && !target.queued);
+ assert(!memcmp(&client.extra.sensor, &original, sizeof(original)));
+ assert(target.telemHasChanged(&client));
+
+ target.tracker_mode = false;
+ target.refuse_queue = true;
+ target.pushSubscriptions();
+ assert(target.allocations == 1 && target.attempts == 1 && !target.queued);
+ assert(target.telemHasChanged(&client)); // An admitted-later push remains pending.
+ target.refuse_queue = false;
+ target.tracker_mode = true;
+ for (unsigned repeat = 0; repeat < 3; ++repeat) target.pushSubscriptions();
+ assert(target.allocations == 1 && target.attempts == 1 && !target.queued);
+ assert(!memcmp(&client.extra.sensor, &original, sizeof(original)));
+
+ target.tracker_mode = false;
+ target.pushSubscriptions();
+ assert(target.allocations == 2 && target.attempts == 2 && target.queued == 1);
+ assert(!target.telemHasChanged(&client));
+ target.pushSubscriptions();
+ assert(target.allocations == 2 && target.queued == 1);
+ }
+}
static void writer_checks() {
uint8_t buffer[16];
memset(buffer, 0xAA, sizeof(buffer));
@@ -318,6 +354,7 @@ int main(int argc, char** argv) {
else if (!strcmp(argv[1], "conversion")) conversion_checks();
else if (!strcmp(argv[1], "capacity")) capacity_checks();
else if (!strcmp(argv[1], "writer")) writer_checks();
+ else if (!strcmp(argv[1], "tracker")) tracker_mode_checks();
else assert(false);
std::cout << "Sensor subscription checks passed\n";
}
diff --git a/test/fixtures/sensor_tracker_client.cpp b/test/fixtures/sensor_tracker_client.cpp
new file mode 100644
index 000000000..ac13bcd42
--- /dev/null
+++ b/test/fixtures/sensor_tracker_client.cpp
@@ -0,0 +1,570 @@
+#include "SensorMesh.h"
+#include "Tracker.cpp"
+@PEER_METHODS@
+using namespace mesh::tracker;
+using mesh::Packet;
+
+static unsigned scenarios = 0;
+static std::array ownerKey(uint8_t suffix = 1) {
+ std::array key{};
+ for (size_t i = 0; i < key.size(); ++i) key[i] = uint8_t(i + 11);
+ key[31] = suffix; return key;
+}
+static std::string hexKey(const std::array& key) {
+ static const char hex[] = "0123456789abcdef"; std::string value;
+ for (uint8_t byte : key) { value += hex[byte >> 4]; value += hex[byte & 15]; }
+ return value;
+}
+static std::string command(SensorMesh& mesh, const std::string& value, uint32_t timestamp = 0) {
+ char answer[160]; memset(answer, 0xa5, sizeof(answer));
+ assert(mesh.handleTrackerCommand(timestamp, value.c_str(), answer + 1));
+ assert(answer[0] == char(0xa5) && answer[158] == char(0xa5) && answer[159] == char(0xa5));
+ return answer + 1;
+}
+static void configure(SensorMesh& mesh) {
+ assert(command(mesh, "set tracker.owner " + hexKey(ownerKey())) == "OK");
+ assert(command(mesh, "set tracker.mode dog") == "OK");
+ assert(mesh.tracker.enabled && mesh.tracker.generation == 2);
+ assert(sensors.mode && sensors.power_save && mesh.num_alert_tasks == 0);
+ assert(mesh.alert1.text[0] == 0 && mesh.alert2.text[0] == 0);
+ assert(!mesh.next_local_advert && !mesh.next_flood_advert);
+}
+static Packet* queueCheck(SensorMesh& mesh, bool gps = true) {
+ mesh.serviceTracker();
+ assert(mesh.tracker_acquiring && !mesh.tracker_pending && sensors.pending);
+ if (gps) sensors.fix(); else { sensors.pending = false; sensors.fresh = false; }
+ mesh.serviceTracker();
+ assert(!mesh.tracker_acquiring && mesh.tracker_pending && !mesh.tracker_sent);
+ assert(mesh.pool.queued.size() == 1);
+ assert(mesh.last_payload.size() == 4 + RequestLength);
+ assert(get32(mesh.last_payload.data()) == mesh.tracker_pending_tag);
+ StatusRequest request;
+ assert(parseStatusRequest(mesh.last_payload.data() + 4, RequestLength, request));
+ assert(request.flags == (gps ? FreshGps : GpsAttemptFailed));
+ assert(request.battery_mv == 3765);
+ if (gps) assert(request.latitude == 471234567 && request.longitude == -1222345678);
+ else assert(!request.latitude && !request.longitude);
+ assert(memcmp(mesh.last_dest, mesh.tracker.owner, 32) == 0);
+ assert(memcmp(mesh.last_secret, mesh.tracker_secret, 32) == 0);
+ return mesh.pool.queued.front();
+}
+static void transmitted(SensorMesh& mesh) {
+ assert(mesh.pool.queued.size() == 1 && mesh.tracker_pending);
+ Packet* packet = mesh.pool.removeOutboundByIdx(0);
+ mesh.pool.in_flight = packet;
+ mesh.onSendComplete(packet);
+ assert(mesh.tracker_sent && mesh.tracker_reply_until == uint32_t(mesh.now_ms + 20000));
+ // The dispatcher owns and frees the original packet after this callback.
+ mesh.pool.in_flight = nullptr; mesh.releasePacket(packet);
+}
+static void savedClient(SensorMesh& mesh, const std::array& key) {
+ ClientInfo client; client.id = mesh::Identity(key.data());
+ mesh.self_id.calcSharedSecret(client.shared_secret, key.data()); mesh.acl.clients.push_back(client);
+}
+static bool authenticated(SensorMesh& mesh, const std::array& key,
+ Packet& packet, uint8_t* response, size_t length,
+ uint8_t* path = nullptr, uint8_t path_length = 0) {
+ uint8_t authenticated_secret[32]; mesh.self_id.calcSharedSecret(authenticated_secret, key.data());
+ const int count = mesh.searchPeersByHash(key.data());
+ for (int slot = 0; slot < count; ++slot) {
+ uint8_t candidate_secret[32]; memset(candidate_secret, 0xa5, sizeof(candidate_secret));
+ mesh.getPeerSharedSecret(candidate_secret, slot);
+ // Radio adapter calls the actual peer callback only for the full secret
+ // which authenticated the packet, as Mesh's encrypted receive loop does.
+ if (memcmp(candidate_secret, authenticated_secret, 32) != 0) continue;
+ if (path) mesh.onPeerPathRecv(&packet, slot, candidate_secret, path, path_length,
+ PAYLOAD_TYPE_RESPONSE, response, uint8_t(length));
+ else mesh.onPeerDataRecv(&packet, PAYLOAD_TYPE_RESPONSE, slot, candidate_secret, response, length);
+ return true;
+ }
+ return false;
+}
+static bool response(SensorMesh& mesh, uint8_t state, uint16_t awake = 0,
+ uint32_t tag = 0, bool padded = true, uint8_t* path = nullptr,
+ uint8_t path_length = 0, bool direct = true) {
+ uint8_t bytes[16] = {};
+ assert(makeStatusResponse(tag ? tag : mesh.tracker_pending_tag, state, awake, bytes, sizeof(bytes)) == 9);
+ Packet packet;
+ packet.header = (PAYLOAD_TYPE_RESPONSE << PH_TYPE_SHIFT) | (direct ? ROUTE_TYPE_DIRECT : ROUTE_TYPE_FLOOD);
+ assert(authenticated(mesh, ownerKey(), packet, bytes, padded ? 16 : 9, path, path_length));
+ return packet.isMarkedDoNotRetransmit();
+}
+static TrackerRecord restored(SensorMesh& mesh) {
+ TrackerRecord record; assert(loadTracker(&mesh.fs, record) == StoreResult::Success); return record;
+}
+
+static void commands() {
+ SensorMesh mesh;
+ assert(command(mesh, "get tracker").find("mode=off status=unknown interval=1800 lost.interval=60") == 0);
+ assert(command(mesh, "tracker check") == "ERR: tracker unavailable");
+ assert(command(mesh, "set tracker.mode dog").find("configure tracker.owner") != std::string::npos);
+ assert(command(mesh, "set tracker.owner " + std::string(64, '0')) == "ERR: invalid owner key");
+ auto self = ownerKey(); memcpy(self.data(), mesh.self_id.pub_key, 32);
+ assert(command(mesh, "set tracker.owner " + hexKey(self)) == "ERR: invalid owner key");
+ for (const std::string& invalid : {std::string(63, 'a'), std::string(65, 'a'), std::string(64, 'g')})
+ assert(command(mesh, "set tracker.owner " + invalid).find("ERR:") == 0);
+ assert(!mesh.tracker.generation && !mesh.fs.write_opens);
+ configure(mesh);
+ assert(command(mesh, "get tracker.owner") == hexKey(ownerKey()));
+ assert(command(mesh, "set tracker.mode other") == "ERR: unknown tracker setting");
+ assert(command(mesh, "set tracker.interval") == "ERR: unknown tracker setting");
+ const auto before = mesh.tracker; const auto writes = mesh.fs.write_opens;
+ for (const char* value : {"get tracker", "get tracker.owner", "tracker check", "set tracker.mode off", "set tracker.interval 60"})
+ assert(command(mesh, value, mesh.rtc.now) == "ERR: tracker is local-only");
+ assert(mesh.tracker.generation == before.generation && mesh.fs.write_opens == writes && mesh.tracker.enabled);
+ for (const std::string& setting : {std::string("interval"), std::string("lost.interval")}) {
+ for (const char* invalid : {"59", "86401", "-1", "60x", "2147483648", "", "60 61"})
+ assert(command(mesh, "set tracker." + setting + " " + invalid).find("ERR:") == 0);
+ assert(command(mesh, "set tracker." + setting + " 86400") == "OK");
+ assert(command(mesh, "set tracker." + setting + " 60") == "OK");
+ }
+ assert(mesh.tracker.normal_interval == 60 && mesh.tracker.lost_interval == 60);
+ assert(command(mesh, "set tracker.path aabb,ccdd") == "OK");
+ assert(mesh.tracker.path_len == 66 && mesh.tracker.path[0] == 0xaa && mesh.tracker.path[3] == 0xdd);
+ auto routed = restored(mesh); assert(routed.path_len == 66);
+ for (const char* invalid : {"aaaa,bb", "a", "aabbccdd", "", "clear x"})
+ assert(command(mesh, std::string("set tracker.path ") + invalid) == "ERR: invalid tracker path");
+ assert(command(mesh, "set tracker.path direct") == "OK" && mesh.tracker.path_len == 0);
+ assert(command(mesh, "set tracker.path clear") == "OK" && mesh.tracker.path_len == kUnknownPath);
+ assert(command(mesh, "set tracker.owner " + hexKey(ownerKey(2))) == "OK");
+ assert(mesh.tracker.path_len == kUnknownPath && !mesh.tracker.last_lost);
+ sensors.detected = false;
+ assert(command(mesh, "set tracker.mode dog") == "ERR: GPS required");
+ assert(command(mesh, "set tracker.mode off") == "OK" && !mesh.tracker.enabled && !sensors.mode);
+ assert(!mesh.tracker_next_check && mesh.advert_timer_updates && mesh.flood_timer_updates);
+ char unused[160] = {}; assert(!mesh.handleTrackerCommand(0, "other", unused));
+ ++scenarios;
+}
+
+static void cadence() {
+ SensorMesh mesh; configure(mesh); queueCheck(mesh); transmitted(mesh);
+ assert(response(mesh, Safe));
+ assert(!mesh.tracker_pending && mesh.tracker.last_lost == Safe && !mesh.tracker_awake_until);
+ assert(mesh.tracker_next_check == mesh.rtc.now + 1800 && restored(mesh).last_lost == Safe);
+ assert(mesh.tracker.route.last_target_reply == mesh.rtc.now && !mesh.tracker.route.flags);
+ mesh.serviceTracker(); assert(mesh.tracker_radio_asleep && !mesh.available && !mesh.trackerNeedsRadio());
+ mesh.rtc.now += 1799; mesh.now_ms += 1799000; mesh.serviceTracker();
+ assert(mesh.tracker_radio_asleep && mesh.created == 1);
+ ++mesh.rtc.now; mesh.now_ms += 1000;
+ queueCheck(mesh); assert(!mesh.tracker_radio_asleep && mesh.radio.wakes == 1);
+ transmitted(mesh); assert(response(mesh, Lost, 120, 0, false));
+ assert(mesh.tracker_next_check == mesh.rtc.now + 60 && mesh.tracker_awake_until == mesh.now_ms + 120000);
+ assert(restored(mesh).last_lost == Lost && mesh.trackerNeedsRadio());
+ mesh.rtc.now += 59; mesh.now_ms += 59000; mesh.serviceTracker(); assert(mesh.created == 2);
+ ++mesh.rtc.now; mesh.now_ms += 1000; queueCheck(mesh); transmitted(mesh);
+ assert(response(mesh, Unknown));
+ assert(mesh.tracker.last_lost == Lost && mesh.tracker_next_check == mesh.rtc.now + 60);
+ assert(mesh.tracker_awake_until != 0); // Unknown never silently declares the dog safe.
+ mesh.rtc.now += 60; mesh.now_ms += 60000; queueCheck(mesh); transmitted(mesh);
+ assert(response(mesh, Lost, 65535));
+ assert(mesh.tracker_awake_until == mesh.now_ms + 300000);
+ mesh.rtc.now += 60; mesh.now_ms += 60000; queueCheck(mesh); transmitted(mesh);
+ assert(response(mesh, Safe, 65535));
+ assert(!mesh.tracker_awake_until && mesh.tracker_next_check == mesh.rtc.now + 1800);
+ ++scenarios;
+}
+
+static void timing() {
+ { SensorMesh mesh; configure(mesh); queueCheck(mesh);
+ assert(!response(mesh, Lost, 120)); // A reply cannot start before the send callback.
+ mesh.now_ms += 100000; mesh.serviceTracker();
+ assert(mesh.tracker_pending && mesh.created == 1 && !mesh.tracker.route.outage_since);
+ transmitted(mesh); mesh.now_ms += 19999; mesh.serviceTracker(); assert(mesh.tracker_pending);
+ mesh.now_ms += 2; mesh.rtc.now += 120;
+ assert(!response(mesh, Safe)); mesh.serviceTracker();
+ assert(!mesh.tracker_pending && mesh.tracker.route.outage_since == mesh.rtc.now);
+ assert(mesh.tracker_next_check == mesh.rtc.now + 1800 && mesh.flood_sends == 0); }
+ { SensorMesh mesh; configure(mesh); queueCheck(mesh); mesh.now_ms += 120001;
+ mesh.serviceTracker(); assert(!mesh.tracker_pending && mesh.pool.queued.empty() && mesh.pool.frees == 1);
+ assert(mesh.tracker.route.flags & kOutageActive); }
+ { SensorMesh mesh; configure(mesh); mesh.now_ms = UINT32_MAX - 10000;
+ queueCheck(mesh); transmitted(mesh); mesh.now_ms += 19000;
+ assert(response(mesh, Safe)); }
+ ++scenarios;
+}
+
+static void identityAndNonce() {
+ SensorMesh mesh; configure(mesh); queueCheck(mesh); transmitted(mesh);
+ auto collision = ownerKey(2); savedClient(mesh, collision);
+ uint8_t bytes[16] = {}; assert(makeStatusResponse(mesh.tracker_pending_tag, Lost, 120, bytes, sizeof(bytes)) == 9);
+ Packet wrong_peer; assert(authenticated(mesh, collision, wrong_peer, bytes, sizeof(bytes)));
+ assert(mesh.tracker_pending && mesh.tracker.last_lost == Unknown && mesh.ordinary_data == 1);
+ auto unknown = ownerKey(3); Packet unknown_peer;
+ assert(!authenticated(mesh, unknown, unknown_peer, bytes, sizeof(bytes)) && mesh.tracker_pending);
+ assert(!response(mesh, Safe, 0, mesh.tracker_pending_tag + 1));
+ bytes[15] = 1; Packet wrong_tail;
+ assert(authenticated(mesh, ownerKey(), wrong_tail, bytes, sizeof(bytes)) && mesh.tracker_pending);
+ bytes[15] = 0; bytes[5] = 2; Packet wrong_version;
+ assert(authenticated(mesh, ownerKey(), wrong_version, bytes, sizeof(bytes)) && mesh.tracker_pending);
+ Packet unrelated; unrelated.header = PAYLOAD_TYPE_REQ << PH_TYPE_SHIFT;
+ unrelated.payload[0] = 1; unrelated.payload_len = 1;
+ const auto deadline = mesh.tracker_reply_until; mesh.now_ms += 1000;
+ mesh.onSendComplete(&unrelated); mesh.onSendFail(&unrelated);
+ assert(mesh.tracker_reply_until == deadline);
+ assert(response(mesh, Safe));
+ assert(!response(mesh, Lost, 120, 1000000)); // Repeated and unsolicited replies have no authority.
+ savedClient(mesh, ownerKey());
+ mesh.rtc.now += 1800; mesh.now_ms += 1800000; queueCheck(mesh); transmitted(mesh);
+ makeStatusResponse(mesh.tracker_pending_tag + 1, Safe, 0, bytes, sizeof(bytes));
+ Packet ordinary; assert(authenticated(mesh, ownerKey(), ordinary, bytes, sizeof(bytes)));
+ assert(mesh.ordinary_data == 2 && mesh.tracker_pending); // Saved owner ordinary traffic keeps ACL routing.
+ assert(response(mesh, Safe));
+ uint8_t secret[32]; memset(secret, 0x55, sizeof(secret)); mesh.getPeerSharedSecret(secret, -1);
+ for (auto value : secret) assert(value == 0);
+ ++scenarios;
+}
+
+static void paths() {
+ SensorMesh mesh; configure(mesh); queueCheck(mesh); transmitted(mesh);
+ uint8_t route[] = {0xaa, 0xbb, 0xcc, 0xdd};
+ const auto generation = mesh.tracker.generation;
+ assert(!response(mesh, Safe, 0, mesh.tracker_pending_tag + 1, true, route, 66));
+ assert(mesh.tracker.path_len == kUnknownPath && mesh.tracker.generation == generation);
+ assert(!response(mesh, Safe, 0, 0, true, route, 192));
+ assert(mesh.tracker.path_len == kUnknownPath);
+ auto collision = ownerKey(2); savedClient(mesh, collision);
+ uint8_t bytes[16] = {}; makeStatusResponse(mesh.tracker_pending_tag, Safe, 0, bytes, sizeof(bytes));
+ Packet wrong_peer; assert(authenticated(mesh, collision, wrong_peer, bytes, sizeof(bytes), route, 66));
+ assert(mesh.tracker.path_len == kUnknownPath && mesh.ordinary_paths == 1);
+ assert(response(mesh, Safe, 0, 0, true, route, 66));
+ auto durable = restored(mesh); assert(durable.path_len == 66 && memcmp(durable.path, route, 4) == 0);
+ mesh.rtc.now += 1800; mesh.now_ms += 1800000; auto* next = queueCheck(mesh);
+ assert(next->path_len == 66 && memcmp(next->path, route, 4) == 0); transmitted(mesh);
+ const auto prior = restored(mesh); --mesh.rtc.now;
+ uint8_t different[] = {1, 2};
+ assert(!response(mesh, Lost, 120, 0, true, different, 2));
+ const auto after = restored(mesh);
+ assert(after.path_len == prior.path_len && memcmp(after.path, prior.path, 64) == 0);
+ assert(mesh.tracker_pending && !mesh.tracker_awake_until);
+ ++scenarios;
+}
+
+static void suppressedNormalTraffic() {
+ SensorMesh mesh; Packet packet; packet.header = ROUTE_TYPE_FLOOD;
+ assert(mesh.allowPacketForward(&packet)); mesh.sendSelfAdvertisement(0, true);
+ assert(mesh.adverts == 1);
+ configure(mesh);
+ assert(!mesh.allowPacketForward(&packet));
+ mesh.sendSelfAdvertisement(0, true); mesh.sendSelfAdvertisement(0, false);
+ assert(mesh.adverts == 1 && mesh.zero_hops == 0 && mesh._clock_sync.observed == 1);
+ queueCheck(mesh); assert(mesh.direct_sends == 1 && mesh.flood_sends == 0 && mesh.created == 1);
+ assert(mesh.num_alert_tasks == 0); transmitted(mesh); assert(response(mesh, Safe));
+ mesh.rtc.now += 1800; mesh.now_ms += 1800000; queueCheck(mesh);
+ assert(mesh.direct_sends == 2 && mesh.flood_sends == 0);
+ assert(command(mesh, "set tracker.mode off") == "OK");
+ assert(mesh.pool.queued.empty() && !mesh.tracker_pending && mesh.allowPacketForward(&packet));
+ mesh.sendSelfAdvertisement(0, false); assert(mesh.adverts == 2 && mesh.zero_hops == 1);
+ ++scenarios;
+}
+
+static void outage(SensorMesh& mesh, uint32_t now) {
+ auto candidate = mesh.tracker;
+ assert(noteFailure(candidate.route, mesh.rtc.now, candidate.route));
+ assert(mesh.saveTrackerRecord(candidate));
+ mesh.now_ms += uint32_t((uint64_t(now) - mesh.rtc.now) * 1000);
+ mesh.rtc.now = now; mesh.tracker_next_check = now;
+}
+static void floods() {
+ SensorMesh mesh; configure(mesh); const auto start = mesh.rtc.now;
+ outage(mesh, start + kFloodIntervalSeconds - 1); queueCheck(mesh); transmitted(mesh);
+ assert(mesh.flood_sends == 0 && mesh.direct_sends == 1);
+ mesh.now_ms += 20001; mesh.serviceTracker();
+ mesh.rtc.now = start + kFloodIntervalSeconds; mesh.tracker_next_check = mesh.rtc.now;
+ queueCheck(mesh); assert(mesh.flood_sends == 1 && restored(mesh).route.last_flood == mesh.rtc.now);
+ transmitted(mesh); mesh.now_ms += 20001; mesh.serviceTracker();
+ mesh.rtc.now += 60; mesh.now_ms += 60000; mesh.tracker_next_check = mesh.rtc.now; queueCheck(mesh);
+ assert(mesh.flood_sends == 1 && mesh.direct_sends == 2); transmitted(mesh);
+ mesh.now_ms += 20001; mesh.serviceTracker();
+ mesh.now_ms += uint32_t((uint64_t(start + 2 * kFloodIntervalSeconds) - mesh.rtc.now) * 1000);
+ mesh.rtc.now = start + 2 * kFloodIntervalSeconds;
+ assert(noteRepeater(mesh.tracker.route, mesh.rtc.now, mesh.tracker.route));
+ mesh.tracker_next_check = mesh.rtc.now; queueCheck(mesh); assert(mesh.flood_sends == 1);
+ transmitted(mesh); mesh.now_ms += 20001; mesh.serviceTracker();
+ mesh.rtc.now += kFloodIntervalSeconds; mesh.now_ms += kFloodIntervalSeconds * 1000;
+ mesh.tracker_next_check = mesh.rtc.now;
+ queueCheck(mesh); assert(mesh.flood_sends == 2); transmitted(mesh);
+ mesh.now_ms += 20001; mesh.serviceTracker();
+ mesh.now_ms += uint32_t((uint64_t(start + kOutageCutoffSeconds) - mesh.rtc.now) * 1000);
+ mesh.rtc.now = start + kOutageCutoffSeconds; mesh.tracker_next_check = mesh.rtc.now;
+ queueCheck(mesh); assert(mesh.flood_sends == 2 && mesh.tracker.route.flags & kFloodLocked);
+ assert(restored(mesh).route.flags & kFloodLocked); transmitted(mesh);
+ assert(response(mesh, Lost, 120, 0, true, nullptr, 0, false));
+ assert(mesh.tracker.route.flags & kFloodLocked && restored(mesh).route.flags & kFloodLocked);
+ assert(mesh.tracker_next_check == mesh.rtc.now + 60 && mesh.tracker.last_lost == Lost);
+ mesh.rtc.now += 60; mesh.now_ms += 60000; queueCheck(mesh);
+ assert(mesh.flood_sends == 2); transmitted(mesh);
+ assert(response(mesh, Safe) && !mesh.tracker.route.flags && !restored(mesh).route.flags);
+ ++scenarios;
+}
+
+static void durability() {
+ { SensorMesh mesh; configure(mesh); const auto start = mesh.rtc.now;
+ outage(mesh, start + kFloodIntervalSeconds);
+ const auto before = restored(mesh); mesh.fs.faults.insert("rename:/tracker.tmp:/tracker.1");
+ mesh.serviceTracker(); sensors.fix(); mesh.serviceTracker();
+ assert(mesh.tracker_storage_fault && !mesh.tracker_pending && mesh.pool.allocated.empty());
+ assert(!mesh.created && !mesh.flood_sends && !mesh.direct_sends);
+ mesh.fs.reset(); const auto after = restored(mesh);
+ assert(after.generation == before.generation && after.route.last_flood == before.route.last_flood);
+ mesh.rtc.now += kFloodIntervalSeconds; mesh.serviceTracker(); assert(!mesh.created); }
+ { SensorMesh mesh; configure(mesh); outage(mesh, mesh.rtc.now + kFloodIntervalSeconds);
+ mesh.allocation_failure = true; mesh.serviceTracker(); sensors.fix(); mesh.serviceTracker();
+ assert(!mesh.tracker_pending && mesh.pool.allocated.empty() && !mesh.flood_sends);
+ auto durable = restored(mesh); assert(durable.route.last_flood == mesh.rtc.now);
+ mesh.tracker = durable; mesh.allocation_failure = false;
+ mesh.rtc.now += 60; mesh.now_ms += 60000; mesh.tracker_next_check = mesh.rtc.now; queueCheck(mesh);
+ assert(!mesh.flood_sends && mesh.direct_sends == 1); }
+ { SensorMesh mesh; configure(mesh); queueCheck(mesh); transmitted(mesh);
+ const auto before = restored(mesh); mesh.fs.faults.insert("open:w:/tracker.tmp");
+ assert(!response(mesh, Lost, 120));
+ assert(mesh.tracker_pending && mesh.tracker_storage_fault && !mesh.tracker_awake_until);
+ assert(mesh.tracker.last_lost == Unknown);
+ mesh.serviceTracker(); assert(!mesh.tracker_pending && mesh.created == 1);
+ mesh.fs.reset(); assert(restored(mesh).generation == before.generation); }
+ { SensorMesh mesh; configure(mesh); auto candidate = mesh.tracker;
+ assert(noteFailure(candidate.route, mesh.rtc.now, candidate.route)); assert(mesh.saveTrackerRecord(candidate));
+ mesh.rtc.now -= 1; mesh.tracker_next_check = mesh.rtc.now; queueCheck(mesh);
+ // Fail closed on flood; a retained direct probe is permitted.
+ assert(mesh.flood_sends == 0 && mesh.tracker.route.flags & kClockFault); }
+ ++scenarios;
+}
+
+static void ownership() {
+ { SensorMesh mesh; configure(mesh); auto* original = queueCheck(mesh);
+ auto* duplicate = mesh.pool.alloc(); *duplicate = *original; mesh.pool.queued.push_back(duplicate);
+ auto* unrelated = mesh.pool.alloc(); unrelated->payload[0] = 9; unrelated->payload_len = 1;
+ mesh.pool.queued.push_back(unrelated);
+ mesh.cancelTrackerPending();
+ assert(mesh.pool.queued.size() == 1 && mesh.pool.queued.front() == unrelated);
+ assert(mesh.pool.frees == 2 && mesh.cancel_queued == 2 && mesh.cancel_active == 1);
+ assert(!mesh.cancel_delivered && !mesh.tracker_pending && !mesh.tracker_pending_tag);
+ mesh.cancelTrackerPending(); assert(mesh.pool.frees == 2);
+ mesh.pool.removeOutboundByIdx(0); mesh.releasePacket(unrelated); }
+ { SensorMesh mesh; configure(mesh); auto* packet = queueCheck(mesh);
+ mesh.pool.removeOutboundByIdx(0); mesh.pool.in_flight = packet;
+ mesh.cancelTrackerPending(true);
+ assert(mesh.cancel_inflight == 1 && mesh.cancel_delivered && !mesh.pool.frees);
+ mesh.onSendComplete(packet); assert(!mesh.tracker_pending && !mesh.tracker_sent);
+ mesh.pool.in_flight = nullptr; mesh.releasePacket(packet); assert(mesh.pool.frees == 1); }
+ { SensorMesh mesh; configure(mesh); auto* packet = queueCheck(mesh);
+ mesh.pool.removeOutboundByIdx(0); mesh.pool.in_flight = packet; mesh.onSendComplete(packet);
+ assert(response(mesh, Safe));
+ assert(mesh.cancel_inflight == 1 && mesh.cancel_delivered && !mesh.pool.frees);
+ assert(mesh.pool.allocated.count(packet) && !mesh.tracker_pending);
+ mesh.onSendFail(packet); assert(!mesh.tracker_pending && !mesh.tracker_reply_until);
+ mesh.pool.in_flight = nullptr; mesh.releasePacket(packet); assert(mesh.pool.frees == 1); }
+ { SensorMesh mesh; configure(mesh); mesh.admission_failure = true;
+ mesh.serviceTracker(); sensors.fix(); mesh.serviceTracker();
+ assert(!mesh.tracker_pending && mesh.pool.allocated.empty() && mesh.pool.frees == 1);
+ assert(mesh.base_send_fail == 1 && mesh.tracker_next_check == mesh.rtc.now + 60); }
+ { SensorMesh mesh; configure(mesh); queueCheck(mesh); transmitted(mesh);
+ assert(command(mesh, "set tracker.mode off") == "OK");
+ assert(!mesh.tracker_pending && !mesh.tracker_sent && !mesh.tracker_pending_tag && !mesh.tracker_awake_until); }
+ ++scenarios;
+}
+
+static void rebootAndNonces() {
+ { SensorMesh prior; configure(prior); queueCheck(prior); transmitted(prior);
+ const auto old_tag = prior.tracker_pending_tag;
+ assert(old_tag == prior.rtc.now && restored(prior).last_request_tag == old_tag);
+ SensorMesh reboot; reboot.fs.files = prior.fs.files;
+ assert(loadTracker(&reboot.fs, reboot.tracker) == StoreResult::Success);
+ reboot.rtc.now = prior.rtc.now; reboot.tracker_boot_anchor = reboot.rtc.now;
+ reboot.configureTrackerRuntime(); queueCheck(reboot); transmitted(reboot);
+ assert(reboot.tracker_pending_tag == old_tag + 1 && restored(reboot).last_request_tag == old_tag + 1);
+ assert(!response(reboot, Lost, 120, old_tag) && reboot.tracker_pending);
+ assert(response(reboot, Safe) && reboot.tracker.last_lost == Safe); }
+ { SensorMesh prior; configure(prior); outage(prior, prior.rtc.now + kFloodIntervalSeconds);
+ queueCheck(prior); transmitted(prior); assert(prior.flood_sends == 1);
+ SensorMesh reboot; reboot.fs.files = prior.fs.files;
+ assert(loadTracker(&reboot.fs, reboot.tracker) == StoreResult::Success);
+ reboot.rtc.now = prior.rtc.now + kFloodIntervalSeconds;
+ reboot.tracker_boot_anchor = reboot.rtc.now; reboot.configureTrackerRuntime();
+ queueCheck(reboot); assert(reboot.flood_sends == 0 && reboot.direct_sends == 1);
+ assert(reboot.tracker.route.last_flood == prior.rtc.now);
+ assert(reboot.tracker.route.last_repeater == reboot.rtc.now); }
+ { SensorMesh mesh; configure(mesh); auto candidate = mesh.tracker;
+ candidate.route.latest_observed = mesh.rtc.now;
+ candidate.last_request_tag = mesh.rtc.now + 300; assert(mesh.saveTrackerRecord(candidate));
+ mesh.serviceTracker(); sensors.fix(); mesh.serviceTracker();
+ assert(!mesh.tracker_pending && !mesh.created && !mesh.tracker_storage_fault);
+ assert(mesh.tracker_next_check == mesh.rtc.now + 60);
+ mesh.rtc.now += 60; mesh.now_ms += 60000;
+ queueCheck(mesh); assert(mesh.tracker_pending_tag <= mesh.rtc.now + 300); }
+ { SensorMesh mesh; configure(mesh); mesh.rtc.now = UINT32_MAX;
+ mesh.tracker_boot_anchor = UINT32_MAX; auto candidate = mesh.tracker;
+ candidate.route.latest_observed = UINT32_MAX; candidate.last_request_tag = UINT32_MAX;
+ assert(mesh.saveTrackerRecord(candidate)); mesh.tracker_next_check = UINT32_MAX;
+ mesh.serviceTracker(); sensors.fix(); mesh.serviceTracker();
+ assert(mesh.tracker_storage_fault && !mesh.created && !mesh.tracker_pending); }
+ ++scenarios;
+}
+
+static void idleClockAndCutoffCrossing() {
+ { SensorMesh mesh; configure(mesh); queueCheck(mesh); transmitted(mesh); assert(response(mesh, Safe));
+ const uint32_t accepted = mesh.rtc.now; const uint32_t next = mesh.tracker_next_check;
+ mesh.serviceTracker(); assert(mesh.tracker_radio_asleep);
+ mesh.rtc.now -= 60; ++mesh.now_ms; mesh.serviceTracker();
+ assert(mesh.tracker.route.flags & kClockFault && restored(mesh).route.flags & kClockFault);
+ assert(mesh.tracker_next_check == next && mesh.created == 1);
+ mesh.rtc.now = accepted + 60; mesh.serviceTracker();
+ assert(mesh.tracker.route.flags & kClockFault && mesh.created == 1);
+ mesh.rtc.now = next; mesh.now_ms += 1800000;
+ queueCheck(mesh); assert(!mesh.flood_sends && mesh.direct_sends == 2); transmitted(mesh);
+ assert(response(mesh, Safe) && !mesh.tracker.route.flags && !restored(mesh).route.flags); }
+ for (bool direct : {false, true}) {
+ SensorMesh mesh; configure(mesh); const uint32_t start = mesh.rtc.now;
+ outage(mesh, start + kOutageCutoffSeconds - 5); queueCheck(mesh);
+ assert(mesh.flood_sends == 1 && !(mesh.tracker.route.flags & kFloodLocked)); transmitted(mesh);
+ mesh.rtc.now += 5; mesh.now_ms += 5000;
+ assert(response(mesh, Safe, 0, 0, true, nullptr, 0, direct));
+ if (direct) assert(!mesh.tracker.route.flags && !restored(mesh).route.flags);
+ else {
+ assert(mesh.tracker.route.flags & kFloodLocked && restored(mesh).route.flags & kFloodLocked);
+ assert(mesh.tracker.route.outage_since == start);
+ mesh.rtc.now += 1800; mesh.now_ms += 1800000; queueCheck(mesh);
+ assert(mesh.flood_sends == 1); transmitted(mesh);
+ assert(response(mesh, Safe) && !mesh.tracker.route.flags && !restored(mesh).route.flags);
+ }
+ }
+ ++scenarios;
+}
+
+static void wakeRecovery() {
+ for (bool recovery : {false, true}) {
+ SensorMesh mesh; configure(mesh); queueCheck(mesh); transmitted(mesh); assert(response(mesh, Safe));
+ mesh.serviceTracker(); assert(mesh.tracker_radio_asleep && !mesh.available && mesh.radio.sleeping);
+ mesh.radio.permit_wake = false; mesh.radio.permit_recovery = recovery;
+ mesh.board.usb = true; // Keeps the dispatcher live for its ordinary hardware watchdog.
+ mesh.serviceTracker();
+ assert(mesh.radio.wake_attempts == 1 && mesh.radio.hard_recoveries == 1);
+ assert(!mesh.tracker_radio_asleep && mesh.available && mesh.radio.sleeping == !recovery);
+ for (unsigned repeat = 0; repeat < 5; ++repeat) {
+ mesh.wakeTrackerRadio(); mesh.serviceTracker();
+ assert(!mesh.tracker_radio_asleep && mesh.available);
+ assert(mesh.radio.wake_attempts == 1 && mesh.radio.hard_recoveries == 1);
+ }
+ assert(mesh.created == 1 && !mesh.tracker_pending);
+ }
+ { SensorMesh mesh; configure(mesh); queueCheck(mesh); transmitted(mesh); assert(response(mesh, Safe));
+ mesh.serviceTracker(); mesh.board.usb = true; mesh.serviceTracker();
+ assert(mesh.radio.wake_attempts == 1 && mesh.radio.wakes == 1 && !mesh.radio.hard_recoveries);
+ assert(!mesh.tracker_radio_asleep && mesh.available); }
+ ++scenarios;
+}
+
+static void zeroClockAcquisition() {
+ SensorMesh mesh; mesh.rtc.now = 0; mesh.tracker_boot_anchor = 0; configure(mesh);
+ mesh.serviceTracker(); assert(mesh.tracker_acquiring && sensors.pending && sensors.starts == 1);
+ assert(!mesh.created && !mesh.tracker_pending && !mesh.tracker_storage_fault);
+ sensors.fix(); mesh.serviceTracker();
+ assert(!mesh.created && !mesh.tracker_pending && !mesh.tracker_storage_fault);
+ assert(!mesh.tracker.last_request_tag && !restored(mesh).last_request_tag);
+ for (unsigned repeat = 0; repeat < 5; ++repeat) {
+ mesh.now_ms += 1000; mesh.serviceTracker();
+ assert(!mesh.created && !mesh.tracker_pending && !mesh.tracker_storage_fault);
+ }
+ // A GPS/RTC sync makes the next due acquisition eligible for a direct probe.
+ mesh.rtc.now = 1000000; mesh.serviceTracker();
+ assert(mesh.tracker_acquiring && !mesh.tracker_pending);
+ sensors.fix(); mesh.serviceTracker();
+ assert(mesh.tracker_pending && mesh.created == 1 && mesh.direct_sends == 1 && !mesh.flood_sends);
+ assert(!mesh.tracker_storage_fault && mesh.tracker_pending_tag == mesh.rtc.now);
+ transmitted(mesh); assert(response(mesh, Safe));
+ assert(!mesh.tracker.route.flags && mesh.tracker_next_check == mesh.rtc.now + 1800);
+ ++scenarios;
+}
+
+static void forwardClockCorrection() {
+ SensorMesh mesh; configure(mesh); const uint32_t start = mesh.rtc.now;
+ auto candidate = mesh.tracker;
+ assert(noteFailure(candidate.route, start, candidate.route));
+ candidate.route.last_flood = start - kFloodIntervalSeconds;
+ assert(mesh.saveTrackerRecord(candidate));
+ const auto prior_flood = candidate.route.last_flood;
+ mesh.serviceTracker(); assert(mesh.tracker_acquiring && sensors.pending && !mesh.created);
+ mesh.now_ms += 1000; mesh.rtc.now += kFloodIntervalSeconds;
+ const uint32_t corrected = mesh.rtc.now;
+ sensors.fix(); mesh.serviceTracker();
+ assert(mesh.tracker_pending && mesh.direct_sends == 1 && !mesh.flood_sends);
+ assert(mesh.tracker_boot_anchor == corrected && mesh.tracker.route.last_repeater == corrected);
+ assert(mesh.tracker.route.outage_since == start && mesh.tracker.route.last_flood == prior_flood);
+ assert(restored(mesh).route.outage_since == start && restored(mesh).route.last_flood == prior_flood);
+ transmitted(mesh); mesh.now_ms += 20001; mesh.serviceTracker();
+ mesh.rtc.now += kFloodIntervalSeconds;
+ mesh.now_ms += kFloodIntervalSeconds * 1000;
+ queueCheck(mesh); assert(mesh.flood_sends == 1 && mesh.direct_sends == 1);
+ assert(restored(mesh).route.last_flood == corrected + kFloodIntervalSeconds);
+ assert(mesh.tracker.route.outage_since == start);
+ transmitted(mesh); mesh.now_ms += 20001; mesh.serviceTracker();
+ mesh.rtc.now += 60; mesh.now_ms += 60000; mesh.tracker_next_check = mesh.rtc.now;
+ queueCheck(mesh); assert(mesh.flood_sends == 1 && mesh.direct_sends == 2);
+ ++scenarios;
+}
+
+static void managerOwnsAcquisitionHandoff() {
+ SensorMesh mesh; configure(mesh); mesh.serviceTracker();
+ assert(mesh.tracker_acquiring && sensors.pending);
+ sensors.hold_window = true;
+ mesh.now_ms += 120001; mesh.rtc.now += 120; mesh.serviceTracker();
+ assert(mesh.tracker_acquiring && sensors.pending && !mesh.created && !mesh.tracker_pending);
+ // The manager can hand off its final fresh fix on the following main-loop
+ // turn; Tracker may not have consumed an earlier empty result.
+ sensors.fix(); mesh.serviceTracker();
+ assert(!mesh.tracker_acquiring && mesh.tracker_pending && mesh.created == 1);
+ StatusRequest request; assert(parseStatusRequest(mesh.last_payload.data() + 4, RequestLength, request));
+ assert(request.flags == FreshGps && request.latitude == 471234567 && request.longitude == -1222345678);
+ ++scenarios;
+}
+
+static void gpsAndMaintenance() {
+ for (const auto& coordinates : {std::pair{NAN, 0}, {0, INFINITY}, {91, 0}, {0, -181}}) {
+ SensorMesh mesh; configure(mesh); mesh.serviceTracker();
+ sensors.fix(coordinates.first, coordinates.second); mesh.serviceTracker();
+ StatusRequest request;
+ assert(parseStatusRequest(mesh.last_payload.data() + 4, RequestLength, request));
+ assert(request.flags == GpsAttemptFailed && !request.latitude && !request.longitude);
+ }
+ { SensorMesh mesh; configure(mesh); mesh.serviceTracker(); mesh.now_ms += 119999;
+ mesh.serviceTracker(); assert(!mesh.created && mesh.tracker_acquiring);
+ mesh.now_ms += 2; mesh.serviceTracker(); assert(mesh.created == 1 && mesh.tracker_pending);
+ StatusRequest request; assert(parseStatusRequest(mesh.last_payload.data() + 4, RequestLength, request));
+ assert(request.flags == GpsAttemptFailed); }
+ { SensorMesh mesh; configure(mesh); queueCheck(mesh); transmitted(mesh); assert(response(mesh, Safe));
+ bool* inhibitors[] = {&mesh.board.usb, &mesh.board.ota, &mesh.board.test, &mesh.temporary,
+ &mesh.pending_ota, &mesh.dual, &mesh.saved_radio_apply_pending, &mesh._cli.active,
+ &mesh.queue_wake, &mesh.retry_wake, &mesh::test_logging_watchdog};
+ for (bool* condition : inhibitors) {
+ *condition = true; assert(mesh.trackerNeedsRadio()); mesh.serviceTracker();
+ assert(!mesh.tracker_radio_asleep); *condition = false;
+ }
+ mesh.radio_driver.watchdog = true; mesh.serviceTracker(); assert(!mesh.tracker_radio_asleep);
+ mesh.radio_driver.watchdog = false; mesh.radio_driver.calibrating = true;
+ mesh.serviceTracker(); assert(!mesh.tracker_radio_asleep); mesh.radio_driver.calibrating = false;
+ mesh.serviceTracker(); assert(mesh.tracker_radio_asleep);
+ mesh.board.usb = true; mesh.serviceTracker(); assert(!mesh.tracker_radio_asleep && mesh.available);
+ assert(mesh.created == 1); }
+ ++scenarios;
+}
+
+int main(int argc, char** argv) {
+ assert(argc == 2); const std::string name = argv[1];
+ if (name == "commands") commands();
+ else if (name == "cadence") cadence();
+ else if (name == "timing") timing();
+ else if (name == "identity") identityAndNonce();
+ else if (name == "paths") paths();
+ else if (name == "suppression") suppressedNormalTraffic();
+ else if (name == "floods") floods();
+ else if (name == "durability") durability();
+ else if (name == "ownership") ownership();
+ else if (name == "reboot") rebootAndNonces();
+ else if (name == "clock-cutoff") idleClockAndCutoffCrossing();
+ else if (name == "wake") wakeRecovery();
+ else if (name == "zero-clock") zeroClockAcquisition();
+ else if (name == "forward-clock") forwardClockCorrection();
+ else if (name == "gps-handoff") managerOwnsAcquisitionHandoff();
+ else if (name == "gps") gpsAndMaintenance();
+ else assert(false);
+ printf("PASS: sensor tracker client %s (%u scenario groups)\n", name.c_str(), scenarios);
+}
diff --git a/test/fixtures/sensor_tracker_client.h b/test/fixtures/sensor_tracker_client.h
new file mode 100644
index 000000000..4b02a8933
--- /dev/null
+++ b/test/fixtures/sensor_tracker_client.h
@@ -0,0 +1,261 @@
+#pragma once
+// Hardware adapter for the complete production Tracker.cpp. The wire codec,
+// route policy, durable store, Packet layout and packet SHA256 stay production.
+#include "tracker_storage_fs.h"
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define MESH_ENABLE_SENSOR_TRACKER 1
+#define MAX_SEARCH_RESULTS 24
+
+namespace mesh {
+static bool test_logging_watchdog = false;
+bool isUsbLoggingWatchdogArmed() { return test_logging_watchdog; }
+struct Identity {
+ uint8_t pub_key[32] = {};
+ Identity() = default;
+ explicit Identity(const uint8_t* key) { memcpy(pub_key, key, sizeof(pub_key)); }
+ bool matches(const uint8_t* key) const { return memcmp(pub_key, key, sizeof(pub_key)) == 0; }
+ bool isHashMatch(const uint8_t* hash) const { return memcmp(pub_key, hash, PATH_HASH_SIZE) == 0; }
+};
+struct LocalIdentity : Identity {
+ // Deterministic transport adapter preserves all 32 key bytes. Production
+ // encrypted transport authenticates one of the selected full-key secrets.
+ void calcSharedSecret(uint8_t* out, const uint8_t* owner) const {
+ for (size_t i = 0; i < 32; ++i) out[i] = owner[i] ^ pub_key[i] ^ 0xa7;
+ }
+};
+class Mesh {
+public:
+ unsigned base_send_complete = 0, base_send_fail = 0;
+ void onSendComplete(Packet*) { ++base_send_complete; }
+ void onSendFail(Packet*) { ++base_send_fail; }
+};
+}
+
+struct FakeRTC : mesh::RTCClock {
+ uint32_t now = 1000000;
+ uint32_t getCurrentTime() override { return now; }
+ void setCurrentTime(uint32_t value) override { now = value; }
+};
+struct FakeBoard {
+ bool usb = false, ota = false, test = false;
+ bool isUsbDataConnected() const { return usb; }
+ bool isOTAUpdateRunning() const { return ota; }
+ bool isRadioTestActive() const { return test; }
+ uint16_t getBattMilliVolts() const { return 3765; }
+};
+struct FakeRadio {
+ bool permit_sleep = true, permit_wake = true, permit_recovery = true, sleeping = false;
+ unsigned sleeps = 0, wakes = 0, wake_attempts = 0, hard_recoveries = 0;
+ bool setTrackerSleep(bool value) {
+ if (!value) ++wake_attempts;
+ if ((value && !permit_sleep) || (!value && !permit_wake)) return false;
+ sleeping = value;
+ if (value) ++sleeps; else ++wakes;
+ return true;
+ }
+ bool recoverRadio(bool hard) {
+ assert(hard); ++hard_recoveries;
+ if (!permit_recovery) return false;
+ sleeping = false; return true;
+ }
+};
+struct FakeDriver {
+ bool watchdog = false, calibrating = false;
+ bool isWatchdogObserving() const { return watchdog; }
+ bool isCalibratingNoiseFloor() const { return calibrating; }
+};
+struct FakeSensors {
+ bool detected = true, mode = false, power_save = false;
+ bool pending = false, fresh = false;
+ double latitude = 0, longitude = 0;
+ uint32_t* clock = nullptr;
+ uint32_t acquisition_until = 0;
+ bool hold_window = false;
+ unsigned starts = 0;
+ void setTrackerGpsModeEnabled(bool value) { mode = value; if (!value) pending = fresh = false; }
+ void setPowerSavingEnabled(bool value) { power_save = value; }
+ bool isGPSDetected() const { return detected; }
+ bool beginTrackerGpsAcquisition() {
+ assert(clock); ++starts; pending = true; fresh = false;
+ acquisition_until = *clock + 120000; return detected;
+ }
+ bool isTrackerGpsAcquisitionPending() {
+ // SensorManager owns finalizing its acquisition, including receiver stop.
+ if (pending && !hold_window && int32_t(*clock - acquisition_until) >= 0) pending = fresh = false;
+ return pending;
+ }
+ bool takeTrackerGpsPosition(double& lat, double& lon) {
+ if (!fresh) return false;
+ lat = latitude; lon = longitude; fresh = false; return true;
+ }
+ void fix(double lat = 47.1234567, double lon = -122.2345678) {
+ latitude = lat; longitude = lon; pending = false; fresh = true;
+ }
+};
+static FakeSensors sensors;
+
+struct FakePool {
+ std::set allocated;
+ std::vector queued;
+ mesh::Packet* in_flight = nullptr;
+ unsigned frees = 0;
+ int free_limit = 32;
+ ~FakePool() { for (auto* packet : allocated) delete packet; }
+ int getOutboundTotal() const { return static_cast(queued.size()); }
+ const mesh::Packet* getOutboundByIdx(int i) const { return queued.at(i); }
+ mesh::Packet* removeOutboundByIdx(int i) {
+ auto* packet = queued.at(i); queued.erase(queued.begin() + i); return packet;
+ }
+ int getFreeCount() const { return free_limit - static_cast(allocated.size()); }
+ mesh::Packet* alloc() { auto* p = new mesh::Packet; assert(allocated.insert(p).second); return p; }
+ void free(mesh::Packet* p) {
+ assert(p && p != in_flight);
+ assert(std::find(queued.begin(), queued.end(), p) == queued.end());
+ assert(allocated.erase(p) == 1); ++frees; delete p;
+ }
+};
+struct ClientInfo {
+ mesh::Identity id;
+ uint8_t shared_secret[32] = {};
+};
+struct FakeACL {
+ std::vector clients;
+ int getNumClients() const { return static_cast(clients.size()); }
+ ClientInfo* getClientByIdx(int index) { return &clients.at(index); }
+};
+struct FakeTrigger { char text[8] = "alert"; };
+
+class SensorMesh : public mesh::Mesh {
+public:
+ MemoryFS fs;
+ MemoryFS* _fs = &fs;
+ FakeRTC rtc;
+ uint32_t now_ms = 100;
+ struct Milliseconds { uint32_t* value; uint32_t getMillis() const { return *value; } } clock_ms{&now_ms};
+ Milliseconds* _ms = &clock_ms;
+ FakeBoard board;
+ FakeRadio radio;
+ FakeRadio* _radio = &radio;
+ FakeDriver radio_driver;
+ FakePool pool;
+ FakePool* _mgr = &pool;
+ mesh::LocalIdentity self_id;
+ mesh::tracker::TrackerRecord tracker;
+ uint8_t tracker_secret[32] = {};
+ bool tracker_storage_available = true, tracker_storage_fault = false;
+ bool tracker_radio_asleep = false, tracker_acquiring = false;
+ bool tracker_pending = false, tracker_sent = false;
+ uint8_t tracker_packet_hash[MAX_HASH_SIZE] = {};
+ uint32_t tracker_next_check = 0, tracker_pending_tag = 0;
+ unsigned long tracker_reply_until = 0, tracker_awake_until = 0;
+ uint32_t tracker_boot_anchor = 1000000;
+ uint32_t tracker_clock_rtc = 0;
+ unsigned long tracker_clock_millis = 0;
+ unsigned long next_local_advert = 123, next_flood_advert = 456;
+ struct Prefs { uint8_t powersaving_enabled = 0, path_hash_mode = 0;
+ bool disable_fwd = false; unsigned flood_max = 64; } _prefs;
+ FakeTrigger alert1, alert2;
+ FakeTrigger* alert_tasks[4] = {&alert1, &alert2};
+ int num_alert_tasks = 2;
+ struct CLI { bool active = false; bool hasActiveUserGpioTimer() const { return active; } } _cli;
+ struct ClockSync { unsigned observed = 0;
+ void observeAcceptedFlood(const mesh::Packet*) { ++observed; } } _clock_sync;
+ uint32_t set_radio_at = 0, revert_radio_at = 0;
+ bool saved_radio_apply_pending = false, temporary = false, pending_ota = false, dual = false;
+ bool queue_wake = false, retry_wake = false, available = true;
+ bool allocation_failure = false, admission_failure = false;
+ unsigned created = 0, direct_sends = 0, flood_sends = 0, adverts = 0, zero_hops = 0;
+ unsigned cancel_active = 0, cancel_queued = 0, cancel_inflight = 0;
+ bool cancel_delivered = false;
+ unsigned advert_timer_updates = 0, flood_timer_updates = 0;
+ std::vector last_payload;
+ uint8_t last_dest[32] = {}, last_secret[32] = {};
+ int default_scope = 0;
+ FakeACL acl;
+ int matching_peer_indexes[MAX_SEARCH_RESULTS] = {};
+ unsigned ordinary_data = 0, ordinary_paths = 0;
+ SensorMesh() {
+ sensors = FakeSensors(); mesh::test_logging_watchdog = false;
+ sensors.clock = &now_ms;
+ metadata_filesystem = &fs;
+ self_id.pub_key[0] = 0xf1;
+ }
+ FakeRTC* getRTCClock() const { return const_cast(&rtc); }
+ unsigned long futureMillis(int delta) const { return uint32_t(now_ms + uint32_t(delta)); }
+ bool millisHasNowPassed(unsigned long value) const { return int32_t(now_ms - uint32_t(value)) > 0; }
+ bool hasOutbound() const { return pool.in_flight != nullptr; }
+ bool isAnyTempRadioActive() const { return temporary; }
+ bool hasPendingOtaApply() const { return pending_ota; }
+ bool isDualRadioActive() const { return dual; }
+ bool getNextQueueWakeDelay(uint32_t& value) const { value = 1; return queue_wake; }
+ bool getNextRetryWakeDelay(uint32_t& value) const { value = 1; return retry_wake; }
+ void setRadioAvailable(bool value) { available = value; }
+ void updateAdvertTimer() { ++advert_timer_updates; }
+ void updateFloodAdvertTimer() { ++flood_timer_updates; }
+ void releasePacket(mesh::Packet* p) { pool.free(p); }
+ const mesh::Packet* getOutboundInFlight() const { return pool.in_flight; }
+ void cancelActiveRetries(const uint8_t* hash) {
+ assert(memcmp(hash, tracker_packet_hash, MAX_HASH_SIZE) == 0); ++cancel_active;
+ }
+ void cancelOutboundRadioRetry(const mesh::Packet* p, bool delivered) {
+ assert(pool.allocated.count(const_cast(p)));
+ if (p == pool.in_flight) ++cancel_inflight; else ++cancel_queued;
+ cancel_delivered = delivered;
+ }
+ mesh::Packet* createDatagram(uint8_t type, const mesh::Identity& dest,
+ const uint8_t* secret, const uint8_t* data, size_t length) {
+ ++created;
+ assert(length <= MAX_PACKET_PAYLOAD);
+ memcpy(last_dest, dest.pub_key, 32); memcpy(last_secret, secret, 32);
+ last_payload.assign(data, data + length);
+ if (allocation_failure) return nullptr;
+ auto* packet = pool.alloc(); packet->header = type << PH_TYPE_SHIFT;
+ packet->payload_len = length; memcpy(packet->payload, data, length); return packet;
+ }
+ bool admit(mesh::Packet* packet) {
+ assert(packet->tx_radio == mesh::RADIO_TX_PRIMARY);
+ assert(packet->flood_retry_policy == mesh::FLOOD_RETRY_POLICY_DENY);
+ if (admission_failure) { onSendFail(packet); releasePacket(packet); return false; }
+ pool.queued.push_back(packet); return true;
+ }
+ bool sendDirect(mesh::Packet* packet, const uint8_t* path, uint8_t length) {
+ ++direct_sends; assert(mesh::Packet::isValidPathLen(length));
+ packet->header = (packet->header & ~PH_ROUTE_MASK) | ROUTE_TYPE_DIRECT;
+ packet->path_len = length;
+ if (length) memcpy(packet->path, path, mesh::tracker::trackerPathByteLength(length));
+ return admit(packet);
+ }
+ bool sendFloodScoped(int, mesh::Packet* packet, unsigned = 0, uint8_t width = 1) {
+ if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT) { ++adverts; releasePacket(packet); return true; }
+ ++flood_sends; assert(width >= 1 && width <= 3);
+ packet->header = (packet->header & ~PH_ROUTE_MASK) | ROUTE_TYPE_FLOOD;
+ packet->setPathHashSizeAndCount(width, 0); return admit(packet);
+ }
+ mesh::Packet* createSelfAdvert() { auto* p = pool.alloc(); p->header = PAYLOAD_TYPE_ADVERT << PH_TYPE_SHIFT; return p; }
+ void sendZeroHop(mesh::Packet* p, int) { ++adverts; ++zero_hops; releasePacket(p); }
+ bool isTrackerModeEnabled() const { return tracker.enabled; }
+ bool handleTrackerCommand(uint32_t, const char*, char*);
+ void configureTrackerRuntime();
+ bool saveTrackerRecord(mesh::tracker::TrackerRecord&);
+ bool trackerNeedsRadio() const;
+ void wakeTrackerRadio();
+ void cancelTrackerPending(bool delivered = false);
+ void serviceTracker();
+ bool handleTrackerResponse(mesh::Packet*, uint8_t, const uint8_t*, size_t,
+ const uint8_t* path = nullptr, uint8_t path_len = 0xff);
+ bool handleTrackerPath(mesh::Packet*, uint8_t*, uint8_t, uint8_t, uint8_t*, size_t);
+ void onSendComplete(mesh::Packet*);
+ void onSendFail(mesh::Packet*);
+ int searchPeersByHash(const uint8_t*);
+ void getPeerSharedSecret(uint8_t*, int);
+ void onPeerDataRecv(mesh::Packet*, uint8_t, int, const uint8_t*, uint8_t*, size_t);
+ bool onPeerPathRecv(mesh::Packet*, int, const uint8_t*, uint8_t*, uint8_t, uint8_t, uint8_t*, uint8_t);
+ bool allowPacketForward(const mesh::Packet*);
+ void sendSelfAdvertisement(int, bool);
+};
diff --git a/test/fixtures/tracker_storage_fs.h b/test/fixtures/tracker_storage_fs.h
new file mode 100644
index 000000000..d1ae39e76
--- /dev/null
+++ b/test/fixtures/tracker_storage_fs.h
@@ -0,0 +1,140 @@
+#pragma once
+// Fault-injecting adapter for the actual tracker-store filesystem calls.
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include