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 @@ + + + + +# 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 @@ + + + + +# 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 +#include +#include +#include +#include + +struct PowerCut {}; +class MemoryFS; +static MemoryFS* metadata_filesystem = nullptr; +class File { + MemoryFS* fs = nullptr; + std::shared_ptr> bytes; + std::string path; + size_t cursor = 0; + bool writable = false, directory = false, valid = false; +public: + File() = delete; + explicit File(MemoryFS* owner) : fs(owner) {} + File(MemoryFS*, std::shared_ptr>, const char*, bool, bool); + operator bool() const { return valid; } + bool isDirectory() const { return directory; } + size_t size(); + int read(uint8_t*, size_t); + size_t write(const uint8_t*, size_t); + void flush(); + void close(); +}; +class MemoryFS { +public: + void _lockFS() {} + void _unlockFS() {} + MemoryFS* _getFS() { return this; } + std::map>> files; + std::set faults, directories; + std::vector trace; + size_t boundary = 0, cut_at = 0; + bool short_write = false, short_read = false; + std::vector replacement_on_close; + unsigned write_opens = 0; + void point(const std::string& event) { + trace.push_back(event); + if (++boundary == cut_at) throw PowerCut{}; + } + bool begin(const std::string& op) { point("before:" + op); return !faults.count(op); } + void end(const std::string& op) { point("after:" + op); } + bool exists(const char* path) { + const std::string op = std::string("stat:") + path; + const bool okay = begin(op); + const bool present = files.count(path) || directories.count(path); + end(op); + return okay && present; + } + bool remove(const char* path) { + const std::string op = std::string("remove:") + path; + if (!begin(op)) return false; + const bool removed = files.erase(path) + directories.erase(path) != 0; + end(op); return removed; + } + bool rename(const char* from, const char* to) { + const std::string op = std::string("rename:") + from + ":" + to; + if (!begin(op) || !files.count(from) || files.count(to) || directories.count(to)) return false; + files[to] = files[from]; files.erase(from); end(op); return true; + } + File open(const char* path, const char* mode = "r", bool create = false) { + (void)create; + const bool write = mode[0] == 'w'; + const std::string op = std::string("open:") + (write ? "w:" : "r:") + path; + if (!begin(op)) return File(this); + if (write) { + ++write_opens; + files[path] = std::make_shared>(); + if (faults.count(std::string("directory:") + path)) directories.insert(path); + } + if (!files.count(path) && !directories.count(path)) { end(op); return File(this); } + if (!files.count(path)) files[path] = std::make_shared>(); + File file(this, files[path], path, write, directories.count(path)); + end(op); return file; + } + File open(const char* path, int mode) { assert(mode == 1); return open(path, "w"); } + void put(const char* path, const std::vector& bytes) { + files[path] = std::make_shared>(bytes); + } + std::vector get(const char* path) const { return *files.at(path); } + void reset() { trace.clear(); boundary = cut_at = 0; faults.clear(); short_write = short_read = false; write_opens = 0; } +}; +File::File(MemoryFS* owner, std::shared_ptr> data, + const char* name, bool write, bool dir) + : fs(owner), bytes(data), path(name), writable(write), directory(dir), valid(true) {} +size_t File::size() { + const std::string op = "size:" + path; + const bool okay = fs->begin(op); const size_t size = okay ? bytes->size() : 0; + fs->end(op); return size; +} +int File::read(uint8_t* output, size_t length) { + const std::string op = "read:" + path; + if (!valid || directory || !fs->begin(op)) return -1; + length = std::min(length, bytes->size() - cursor); + if (fs->short_read && length) --length; + if (length) memcpy(output, bytes->data() + cursor, length); + cursor += length; fs->end(op); return static_cast(length); +} +size_t File::write(const uint8_t* data, size_t length) { + const std::string op = "write:" + path; + if (!valid || directory || !fs->begin(op)) return 0; + if (fs->short_write && length) --length; + bytes->insert(bytes->end(), data, data + length); fs->end(op); return length; +} +void File::flush() { + const std::string op = "flush:" + path; + if (!fs->begin(op) && !bytes->empty()) bytes->pop_back(); + fs->end(op); +} +void File::close() { + const std::string op = std::string("close:") + (writable ? "w:" : "r:") + path; + if (!fs->begin(op) && writable && !bytes->empty()) (*bytes)[0] ^= 1; + if (writable && !fs->replacement_on_close.empty()) *bytes = fs->replacement_on_close; + valid = false; fs->end(op); +} +#define FILE_O_WRITE 1 +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +struct lfs_info {}; +static constexpr int LFS_ERR_NOENT = -2; +static int lfs_stat(MemoryFS* fs, const char* path, lfs_info*) { + const std::string op = std::string("stat:") + path; + const bool okay = fs->begin(op); + const bool present = fs->files.count(path) || fs->directories.count(path); + fs->end(op); + return !okay ? -5 : present ? 0 : LFS_ERR_NOENT; +} +#endif diff --git a/test/test_gps_sync_interval.py b/test/test_gps_sync_interval.py index 36479c097..326711bb2 100644 --- a/test/test_gps_sync_interval.py +++ b/test/test_gps_sync_interval.py @@ -141,6 +141,8 @@ struct Uart:Stream { void fix(){incoming.push_back('\n');} }; struct Gnss { + void flushPVT(){} + bool getPVT(int){return true;} bool getGnssFixOk(int){return true;} long getLatitude(int){return 476100000;} long getLongitude(int){return -1223300000;} diff --git a/test/test_radio_interrupt_recovery.py b/test/test_radio_interrupt_recovery.py index a09126251..0bd2affd6 100644 --- a/test/test_radio_interrupt_recovery.py +++ b/test/test_radio_interrupt_recovery.py @@ -130,6 +130,45 @@ public: class RadioInterruptRecoveryTests(unittest.TestCase): + def run_dispatcher_retry(self, soft_only=False, remove_guard=False): + with tempfile.TemporaryDirectory(prefix="meshcore-radio-retry-") as tmp: + dispatcher = ROOT / "src/Dispatcher.cpp" + if remove_guard: + original = dispatcher.read_text() + guard = (" if (outbound->isRouteFlood() && outbound->flood_retry_policy " + "== FLOOD_RETRY_POLICY_DENY) return false;\n") + self.assertEqual(original.count(guard), 1) + dispatcher = Path(tmp) / "Dispatcher.cpp" + dispatcher.write_text(original.replace(guard, "")) + exe = Path(tmp) / "retry" + command = [os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-Wall", "-Wextra", + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie", + "-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src")] + if soft_only: + command.append("-DRADIO_LIVENESS_SOFT_ONLY") + command += [str(dispatcher), str(ROOT / "src/Packet.cpp"), + str(ROOT / "src/helpers/StaticPoolPacketManager.cpp"), + str(ROOT / "test/fixtures/dispatcher/radio_retry_test.cpp"), "-o", str(exe)] + result = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + env = os.environ.copy() + # Firmware-lifetime production pools do not have destructors. + env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":detect_leaks=0" + result = subprocess.run([str(exe)], capture_output=True, text=True, env=env) + if remove_guard: + self.assertNotEqual(result.returncode, 0) + self.assertIn("a denied flood must never be retransmitted", result.stderr) + else: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_actual_dispatcher_lost_tx_done_respects_flood_retry_policy(self): + for soft_only in (False, True): + with self.subTest(soft_only=soft_only): + self.run_dispatcher_retry(soft_only=soft_only) + + def test_removed_flood_retry_guard_repeats_actual_timed_out_transmission(self): + self.run_dispatcher_retry(remove_guard=True) + def test_actual_wrapper_header_uses_actual_radio_base(self): # Keep Mesh/Dispatcher and the wrapper header real: the common test # Mesh.h gives Radio a virtual destructor that production does not. diff --git a/test/test_sensor_subscriptions.py b/test/test_sensor_subscriptions.py index 5b19af733..787c813a6 100644 --- a/test/test_sensor_subscriptions.py +++ b/test/test_sensor_subscriptions.py @@ -77,6 +77,16 @@ class SensorSubscriptionTest(unittest.TestCase): def test_subscription_collection_and_push_fit_host_packet_budget(self): self.passed('capacity') + def test_tracker_mode_suppresses_active_and_pending_pushes_then_restores(self): + self.passed('tracker') + + def test_missing_tracker_push_guard_is_rejected(self): + def old(source): + guard = ' if (isTrackerModeEnabled()) break; // Check-in replaces background pushes.' + self.assertEqual(source.count(guard), 1) + return source.replace(guard, '', 1) + self.assertNotEqual(self.execute('tracker', old).returncode, 0) + def test_scalar_writer_rejections_leave_buffer_and_length_untouched(self): self.passed('writer') diff --git a/test/test_sensor_tracker_client.py b/test/test_sensor_tracker_client.py new file mode 100644 index 000000000..9bebae22a --- /dev/null +++ b/test/test_sensor_tracker_client.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Execute the actual Sensor tracker with real wire, route and durable storage.""" + +from pathlib import Path +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_client_acl_infrastructure import room_mail_crypto_arguments +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures" +SANITIZERS = (["-fsanitize=address,undefined,float-cast-overflow", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) + + +def peer_methods(source): + methods = "" + for signature in ("int SensorMesh::searchPeersByHash(", "void SensorMesh::getPeerSharedSecret(", + "bool SensorMesh::allowPacketForward(", "void SensorMesh::sendSelfAdvertisement("): + methods += extract_braced(source, signature) + "\n" + # Compile the complete actual authenticated tracker dispatch, including its + # full-owner fallback and ACL bounds. Ordinary application handling beyond + # that boundary is represented by a counter, rather than a second tracker. + for signature, ending in (("void SensorMesh::onPeerDataRecv(", "++ordinary_data;\n}"), + ("bool SensorMesh::onPeerPathRecv(", "++ordinary_paths; return false;\n}")): + function = extract_braced(source, signature) + boundary = " ClientInfo* from = acl.getClientByIdx(i);" + if boundary not in function: + raise AssertionError("ordinary ACL callback boundary changed") + methods += function[:function.index(boundary)] + ending + "\n" + return methods + + +class SensorTrackerClientTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if cls.compiler is None: + raise unittest.SkipTest("a host C++ compiler is required") + cls.temp = tempfile.TemporaryDirectory(prefix="meshcore-tracker-client-") + cls.work = Path(cls.temp.name) + cls.tracker = (ROOT / "examples/simple_sensor/Tracker.cpp").read_text(encoding="ascii") + cls.sensor = (ROOT / "examples/simple_sensor/SensorMesh.cpp").read_text(encoding="ascii") + cls.crypto = room_mail_crypto_arguments("room") + cls.binary = cls.build("production", cls.tracker, cls.sensor) + + @classmethod + def tearDownClass(cls): + cls.temp.cleanup() + + @classmethod + def build(cls, name, tracker, sensor): + work = cls.work / name + work.mkdir() + (work / "SensorMesh.h").write_text('#include "sensor_tracker_client.h"\n', encoding="ascii") + (work / "helpers").mkdir() + # The production watchdog header imports hardware identity/filesystem + # dependencies. Its sole Tracker API is supplied by this radio adapter. + (work / "helpers/UsbLoggingWatchdog.h").write_text( + "#pragma once\nnamespace mesh { bool isUsbLoggingWatchdogArmed(); }\n", + encoding="ascii") + (work / "Tracker.cpp").write_text(tracker, encoding="ascii") + fixture = (FIXTURE / "sensor_tracker_client.cpp").read_text(encoding="ascii") + cpp = work / "client.cpp" + cpp.write_text(fixture.replace("@PEER_METHODS@", peer_methods(sensor)), encoding="ascii") + binary = work / "client" + built = subprocess.run([ + cls.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", + *SANITIZERS, "-I", str(work), "-I", str(FIXTURE), "-I", str(ROOT / "src"), + str(cpp), str(ROOT / "src/Packet.cpp"), *cls.crypto, "-o", str(binary), + ], text=True, capture_output=True, timeout=60) + if built.returncode: + raise AssertionError(built.stdout + built.stderr) + return binary + + def run_group(self, name, binary=None, success=True): + run = subprocess.run([str(binary or self.binary), name], text=True, capture_output=True, timeout=20) + if success: + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + self.assertIn("PASS: sensor tracker client " + name, run.stdout) + else: + self.assertNotEqual(run.returncode, 0, "regression mutation escaped the assertions") + self.assertIn("Assertion", run.stderr) + + def test_local_commands_and_durable_settings(self): + self.run_group("commands") + + def test_safe_lost_unknown_intervals_and_awake_lease(self): + self.run_group("cadence") + + def test_reply_window_starts_after_transmission_and_wraps(self): + self.run_group("timing") + + def test_full_owner_authentication_and_nonce_isolation(self): + self.run_group("identity") + + def test_learn_paths_only_from_accepted_matched_responses(self): + self.run_group("paths") + + def test_tracker_suppresses_normal_adverts_alerts_and_forwarding(self): + self.run_group("suppression") + + def test_six_hour_flood_budget_repeater_postponement_and_four_day_lock(self): + self.run_group("floods") + + def test_storage_faults_and_rebooted_reservation_never_flood(self): + self.run_group("durability") + + def test_queue_and_inflight_cancellation_ownership(self): + self.run_group("ownership") + + def test_gps_timeout_invalid_fixes_and_maintenance_awake_guards(self): + self.run_group("gps") + + def test_reboot_preserves_nonce_floor_and_conservative_flood_wait(self): + self.run_group("reboot") + + def test_idle_clock_rollback_and_response_crossing_four_day_cutoff(self): + self.run_group("clock-cutoff") + + def test_failed_warm_and_hard_wakes_restore_dispatcher_liveness(self): + self.run_group("wake") + + def test_zero_rtc_allows_gps_sync_without_radio_or_storage_fault(self): + self.run_group("zero-clock") + + def test_forward_utc_correction_requires_real_six_hour_quiet_wait(self): + self.run_group("forward-clock") + + def test_gps_manager_owns_acquisition_completion_and_final_fix(self): + self.run_group("gps-handoff") + + def test_negative_control_original_normal_traffic_is_detected(self): + guard = " if (isTrackerModeEnabled()) return false;" + advert = " if (isTrackerModeEnabled()) return;" + forward_function = extract_braced(self.sensor, "bool SensorMesh::allowPacketForward(") + advert_function = extract_braced(self.sensor, "void SensorMesh::sendSelfAdvertisement(") + self.assertEqual(forward_function.count(guard), 1) + self.assertEqual(advert_function.count(advert), 1) + mutated = self.sensor.replace(forward_function, forward_function.replace(guard, "", 1), 1) + mutated = mutated.replace(advert_function, advert_function.replace(advert, "", 1), 1) + self.run_group("suppression", self.build("no-suppression", self.tracker, mutated), success=False) + + def test_negative_control_missing_durability_gate_is_detected(self): + guard = "if (!saveTrackerRecord(candidate)) return; // Reserve before any RF admission." + self.assertEqual(self.tracker.count(guard), 1) + mutated = self.tracker.replace(guard, "saveTrackerRecord(candidate);", 1) + self.run_group("durability", self.build("no-durability", mutated, self.sensor), success=False) + + def test_negative_control_failed_wake_leaves_dispatcher_disabled(self): + guard = "if (!_radio->setTrackerSleep(false)) _radio->recoverRadio(true);" + self.assertEqual(self.tracker.count(guard), 1) + mutated = self.tracker.replace(guard, "if (!_radio->setTrackerSleep(false)) return;", 1) + self.run_group("wake", self.build("failed-wake-stuck", mutated, self.sensor), success=False) + + def test_negative_control_zero_clock_allocates_invalid_nonce(self): + guard = extract_braced(self.tracker, "if (now == 0) {") + mutated = self.tracker.replace(guard, "", 1) + self.run_group("zero-clock", self.build("zero-clock-nonce", mutated, self.sensor), success=False) + + def test_negative_control_forward_clock_can_manufacture_flood_wait(self): + guard = " tracker_boot_anchor = now;" + self.assertEqual(self.tracker.count(guard), 1) + mutated = self.tracker.replace(guard, "", 1) + self.run_group("forward-clock", self.build("forward-clock-flood", mutated, self.sensor), success=False) + + def test_negative_control_tracker_forces_early_manager_handoff(self): + guard = "if (sensors.isTrackerGpsAcquisitionPending()) return;" + self.assertEqual(self.tracker.count(guard), 1) + early = "if (sensors.isTrackerGpsAcquisitionPending() && !millisHasNowPassed(sensors.acquisition_until)) return;" + mutated = self.tracker.replace(guard, early, 1) + self.run_group("gps-handoff", self.build("early-gps-handoff", mutated, self.sensor), success=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_sensor_tracker_gps.py b/test/test_sensor_tracker_gps.py new file mode 100644 index 000000000..68dbbca83 --- /dev/null +++ b/test/test_sensor_tracker_gps.py @@ -0,0 +1,362 @@ +"""Execute production GPS ownership and fresh-acquisition paths on the host.""" +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_gps_upstream_adaptations import ARDUINO, MESH, NMEA, RTC, CAYENNE +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +void LocationProvider::sendSentence(const char*){} +struct Uart:Stream { + std::deque incoming; + bool replenish=false; + int available()override{return static_cast(incoming.size());} + int read()override{ + if(incoming.empty())return -1; + char c=incoming.front();incoming.pop_front(); + if(replenish)incoming.push_back('\n'); + return c; + } + void fix(){incoming.push_back('\n');} +}; +struct TestProvider:MicroNMEALocationProvider { + using MicroNMEALocationProvider::MicroNMEALocationProvider; + void validated(){markTimeSyncApplied();stopTimeSync();} +}; +class TestSensors:public SensorManager { +public: + Uart uart; + mesh::RTCClock clock; + TestProvider provider{uart,&clock}; + bool detected=true,active=false,provider_present=true; + unsigned starts=0,stops=0; + LocationProvider* getLocationProvider()override{return provider_present?&provider:nullptr;} + bool telemetryGpsDetected()const override{return detected;} + bool telemetryGpsActive()const override{return active;} + void telemetryGpsStart()override{active=true;++starts;} + void telemetryGpsStop()override{active=false;++stops;} + void user(bool value){setGpsTelemetryUserEnabled(value);} + bool userEnabled()const{return isGpsTelemetryUserEnabled();} + void blocked(bool value){setGpsTelemetryTransportAvailable(!value);} + void resetTransport(){resetGpsTelemetryTransportState();} + void hardwareStop(){telemetryGpsStop();} + void service(){loopGpsTelemetry(millis());} + void fix(float lat=47.61f,float lon=-122.33f){processGpsTelemetryFix(lat,lon,125,millis());} + bool query(uint8_t perms=TELEM_PERM_LOCATION){CayenneLPP data(64);return queryGpsTelemetry(perms,data);} + bool required()const{return gpsTelemetryReceiverRequired(millis());} + bool pendingMode()const{return isTrackerGpsModeEnabled();} + void sampleProvider(){ + service();if(active)provider.loop(); + if(active&&provider.isValid())fix(); + } + void finishClock(){provider.validated();service();} +}; +class T1000SensorManager:public TestSensors { +public: + LocationProvider* _nmea=&provider; + bool& gps_active=active; + void start_gps(){telemetryGpsStart();} + void stop_gps(){telemetryGpsStop();} + void armGpsPowerSavingCycle(){_nmea->setNextSleep();} + void setPowerSavingEnabled(bool enabled)override; +}; +class EnvironmentSensorManager:public TestSensors { +public: + LocationProvider* _location=&provider; + bool& gps_active=active; + bool& gps_detected=detected; + void start_gps(){telemetryGpsStart();} + void stop_gps(){telemetryGpsStop();} + void armGpsPowerSavingCycle(){_location->setNextSleep();} + void setPowerSavingEnabled(bool enabled)override; +}; +@T1000_SETTER@ +@ENV_SETTER@ +void freshWindows(){ + now_ms=100;TestSensors s;double lat=0,lon=0; + s.user(true);assert(s.active&&s.userEnabled()); + s.fix();assert(s.getCachedGpsPosition(lat,lon)); + s.uart.fix();s.provider.loop();assert(s.provider.isValid()); + s.setTrackerGpsModeEnabled(true); + assert(!s.active&&s.userEnabled()&&!s.provider.getGPSPowerSaving()); + assert(!s.takeTrackerGpsPosition(lat,lon)&&lat==0&&lon==0); + s.uart.fix(); // old UART bytes queued while receiver was off + assert(s.beginTrackerGpsAcquisition()); + assert(s.uart.incoming.empty()&&!s.provider.isValid()); + s.sampleProvider();assert(s.isTrackerGpsAcquisitionPending()); + assert(!s.takeTrackerGpsPosition(lat,lon)); + assert(s.query());assert(s.isTrackerGpsAcquisitionPending()); + s.setTelemetryLocationAccessAvailable(true); + s.setTelemetryLocationAccessAvailable(false); + assert(s.isTrackerGpsAcquisitionPending()); + s.uart.fix();++now_ms;s.sampleProvider(); + assert(s.active&&s.isTrackerGpsAcquisitionPending()); + assert(!s.takeTrackerGpsPosition(lat,lon));s.finishClock(); + assert(!s.active&&!s.required()&&!s.isTrackerGpsAcquisitionPending()); + assert(s.takeTrackerGpsPosition(lat,lon)&&lat>47&&lon< -122); + assert(!s.takeTrackerGpsPosition(lat,lon)); + now_ms+=1800000;s.service();assert(!s.active); + assert(s.beginTrackerGpsAcquisition()); + s.sampleProvider();assert(s.isTrackerGpsAcquisitionPending()); + s.uart.fix();++now_ms;s.sampleProvider();s.finishClock();assert(s.takeTrackerGpsPosition(lat,lon)); + // A long normal gps_interval must not delay the tracker's fresh result. + assert(s.setSettingValue("gps_interval","86400")); + assert(s.beginTrackerGpsAcquisition());s.uart.fix();s.provider.loop(); + ++now_ms;s.service();assert(s.active);s.finishClock(); + assert(!s.active&&s.takeTrackerGpsPosition(lat,lon)); + // A ready result is only for the current check, with its own short age bound. + assert(s.beginTrackerGpsAcquisition());++now_ms;s.fix();s.finishClock();now_ms+=120000; + assert(!s.takeTrackerGpsPosition(lat,lon)); + s.setTrackerGpsModeEnabled(false);assert(s.active&&s.userEnabled()); +} +void timeoutAndInvalid(){ + for(uint32_t started:{500U,UINT32_MAX-60000U}){ + now_ms=started;TestSensors s;double lat=0,lon=0; + s.setTrackerGpsModeEnabled(true);assert(s.beginTrackerGpsAcquisition()); + s.fix(std::numeric_limits::quiet_NaN(),1); + s.fix(91,1);s.fix(1,181);s.fix(0,0); + assert(s.isTrackerGpsAcquisitionPending()&&!s.takeTrackerGpsPosition(lat,lon)); + now_ms=started+60000;assert(s.beginTrackerGpsAcquisition()); + now_ms=started+119999;s.service();assert(s.active); + now_ms=started+120000;s.service(); + assert(!s.active&&!s.isTrackerGpsAcquisitionPending()); + s.fix();assert(!s.takeTrackerGpsPosition(lat,lon)); + // Callback itself also enforces the deadline, even before the service loop. + assert(s.beginTrackerGpsAcquisition());now_ms+=120000;s.fix(); + assert(!s.active&&!s.takeTrackerGpsPosition(lat,lon)); + } +} +void ownershipAndRestore(){ + now_ms=1;TestSensors s;double lat=0,lon=0; + s.powersaving_enabled=true;s.user(true);s.provider.setGPSPowerSaving(true); + s.setTrackerGpsModeEnabled(true);assert(!s.active); + s.blocked(true);unsigned starts=s.starts; + assert(!s.beginTrackerGpsAcquisition());assert(!s.requestGpsTelemetryTimeSync(1)); + s.service();assert(s.starts==starts); + s.blocked(false);assert(!s.active&&s.starts==starts); + assert(s.beginTrackerGpsAcquisition()); + s.hardwareStop();s.blocked(true);assert(!s.isTrackerGpsAcquisitionPending()); + assert(!s.takeTrackerGpsPosition(lat,lon)); + s.blocked(false);s.resetTransport();assert(!s.isTrackerGpsAcquisitionPending()); + s.user(false);assert(!s.userEnabled()); + assert(s.beginTrackerGpsAcquisition());s.setTrackerGpsModeEnabled(false); + assert(!s.active&&!s.userEnabled()&&!s.provider.getGPSPowerSaving()); + s.setTrackerGpsModeEnabled(true);s.user(true);assert(!s.active); + assert(s.beginTrackerGpsAcquisition());s.setTrackerGpsModeEnabled(false); + assert(s.active&&s.userEnabled()&&s.provider.getGPSPowerSaving()); + // Failed discovery and providers unable to invalidate a retained fix fail closed. + s.setTrackerGpsModeEnabled(true);s.detected=false; + assert(!s.beginTrackerGpsAcquisition());s.detected=true;s.provider_present=false; + assert(!s.beginTrackerGpsAcquisition()); +} +templatevoid derivedPowerPreference(){ + now_ms=1;Sensors s;s.user(true);s.setTrackerGpsModeEnabled(true); + s.setPowerSavingEnabled(true);assert(!s.active&&!s.provider.getGPSPowerSaving()); + assert(s.beginTrackerGpsAcquisition()); + s.setPowerSavingEnabled(false);assert(s.active&&!s.provider.getGPSPowerSaving()); + s.user(false);assert(s.active); // current window remains owned by tracker + s.fix();s.finishClock();assert(!s.active); + s.setTrackerGpsModeEnabled(false);assert(!s.active&&!s.userEnabled()); + s.user(true);assert(s.active);s.setPowerSavingEnabled(true); + assert(s.provider.getGPSPowerSaving()); // normal mode still uses its power cycle +} +void fifoBound(){ + Uart uart;MicroNMEALocationProvider provider(uart); + uart.fix();provider.loop();assert(provider.isValid()); + uart.fix();uart.replenish=true; + assert(provider.clearPositionFix()); + assert(!provider.isValid()&&uart.available()==1); // new arrival was retained + uart.replenish=false;provider.loop();assert(provider.isValid()); + for(int i=0;i<1025;++i)uart.fix(); + assert(!provider.clearPositionFix()&&!provider.isValid()); + assert(uart.available()==1025); // fixed cap fails without a partial drain +} +void ordinaryMode(){ + now_ms=10;TestSensors s;assert(!s.beginTrackerGpsAcquisition()); + assert(!s.query(0)&&!s.active);assert(!s.query()&&s.active); + now_ms+=30001;s.fix();now_ms+=30001;s.fix(); + assert(s.query()&&s.active); // ordinary authorized query retains its 2h hold +} +void coldClockAndTimeoutPosition(){ + now_ms=1;TestSensors s;double lat=0,lon=0; + s.clock.setCurrentTime(1772323200); // actual nRF cold-start fallback + s.setTrackerGpsModeEnabled(true);assert(s.beginTrackerGpsAcquisition()); + s.uart.fix();s.sampleProvider(); + assert(s.active&&!s.takeTrackerGpsPosition(lat,lon)); + assert(s.clock.getCurrentTime()==1772323200); + // Newly parsed samples, spaced by the provider's existing validation period. + for(int i=0;i<2;++i){now_ms+=1001;s.uart.fix();s.sampleProvider();assert(s.active);} + now_ms+=1001;s.uart.fix();s.sampleProvider(); + s.service();assert(!s.active&&s.takeTrackerGpsPosition(lat,lon)); + assert(s.clock.getCurrentTime()==1790942400); + // A consumed old success edge still cannot satisfy a new acquisition. + assert(s.provider.consumeTimeSyncApplied()); + assert(s.beginTrackerGpsAcquisition());s.uart.fix();s.sampleProvider(); + assert(s.active&&!s.takeTrackerGpsPosition(lat,lon)); + const uint32_t started=now_ms; + // Retained parser validity without fresh input is not a fresh time sample. + for(int i=0;i<4;++i){now_ms+=1001;s.sampleProvider();assert(s.active);} + assert(s.clock.getCurrentTime()==1790942400); + now_ms=started+120000;assert(s.active); + assert(s.takeTrackerGpsPosition(lat,lon)&&!s.active); // finalize before sensor loop + assert(!s.takeTrackerGpsPosition(lat,lon)); + // Another component consuming the edge cannot steal the generation change. + assert(s.beginTrackerGpsAcquisition());s.uart.fix();s.sampleProvider(); + s.provider.validated();assert(s.provider.consumeTimeSyncApplied()); + s.service();assert(!s.active&&s.takeTrackerGpsPosition(lat,lon)); +} +int main(){ + freshWindows();timeoutAndInvalid();ownershipAndRestore(); + derivedPowerPreference(); + derivedPowerPreference();fifoBound();ordinaryMode(); + coldClockAndTimeoutPosition(); +} +''' + +I2C = r''' +#include +#include +void LocationProvider::sendSentence(const char*){} +struct Gnss { + bool available=false,stale=true;unsigned flushes=0; + void flushPVT(){++flushes;} + bool getPVT(int){return available;} + bool getGnssFixOk(int){return available||stale;} + long getLatitude(int){return 476100000;} + long getLongitude(int){return -1223300000;} + long getAltitude(int){return 125000;} + int getSIV(int){return 8;} + bool getDateValid(int){return true;} + bool getTimeValid(int){return true;} + long getUnixEpoch(int){return 1790942400;} +} ublox_GNSS; +@RAK_PROVIDER@; +int main(){ + now_ms=1;RAK12500LocationProvider provider; + provider.loop();assert(provider.isValid()); + assert(provider.clearPositionFix());assert(!provider.isValid()); + assert(ublox_GNSS.flushes==1&&ublox_GNSS.stale); + provider.loop();assert(!provider.isValid()); + ublox_GNSS.available=true;now_ms+=1000; + provider.loop();assert(provider.isValid()&&provider.getLatitude()==47610000); + mesh::RTCClock clock;provider.setRTCClock(&clock); + for(int i=0;i<2;++i){now_ms+=1000;provider.loop();} + assert(clock.getCurrentTime()==1790942400); // prior window already had >2 valid samples + clock.setCurrentTime(1772323200); + assert(provider.clearPositionFix());provider.beginFreshTimeSync(); + const uint32_t generation=provider.getTimeSyncGeneration(); + provider.loop();assert(provider.isValid()&&clock.getCurrentTime()==1772323200); + now_ms+=1000;provider.loop();assert(clock.getCurrentTime()==1772323200); + now_ms+=1000;provider.loop();assert(clock.getCurrentTime()==1790942400); + assert(provider.getTimeSyncGeneration()==generation+1); + clock.setCurrentTime(1772323200); + assert(provider.clearPositionFix());provider.beginFreshTimeSync();provider.loop(); + ublox_GNSS.available=false;now_ms+=1000;provider.loop();assert(!provider.isValid()); + now_ms+=1000;provider.loop();assert(clock.getCurrentTime()==1772323200); + assert(provider.getTimeSyncGeneration()==generation+1); + ublox_GNSS.available=true; + for(int i=0;i<3;++i){now_ms+=1000;provider.loop();} + assert(clock.getCurrentTime()==1790942400); + assert(provider.getTimeSyncGeneration()==generation+2); +} +''' + + +class SensorTrackerGpsTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.compiler = shutil.which("g++") or shutil.which("clang++") + if cls.compiler is None: + raise unittest.SkipTest("a host C++17 compiler is required") + + def compile_run(self, source, gps=1, sources=(), manager_source=None, + expect_failure=False): + with tempfile.TemporaryDirectory(prefix="meshcore-tracker-gps-") as directory: + path = Path(directory) + for name, value in { + "Arduino.h": ARDUINO, "Mesh.h": MESH, "MicroNMEA.h": NMEA, + "RTClib.h": RTC, "CayenneLPP.h": CAYENNE, + "Wire.h": "#pragma once\nclass TwoWire {};\n", + }.items(): + (path / name).write_text(value, encoding="ascii") + cpp, binary = path / "test.cpp", path / "test" + cpp.write_text(source, encoding="ascii") + compiled_sources = [str(ROOT / name) for name in sources] + if manager_source is not None: + manager = path / "SensorManager.cpp" + manager.write_text(manager_source, encoding="ascii") + compiled_sources = [str(manager)] + subprocess.run([ + self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", f"-DENV_INCLUDE_GPS={gps}", + *(["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []), + "-I", str(path), "-I", str(ROOT / "src"), + "-I", str(ROOT / "src/helpers"), str(cpp), + *compiled_sources, "-o", str(binary), + ], check=True, timeout=60) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + if expect_failure: + self.assertNotEqual(result.returncode, 0, "negative control escaped the regression") + self.assertIn("Assertion", result.stderr) + else: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def harness(self): + t1000 = (ROOT / "variants/t1000-e/target.cpp").read_text() + environment = (ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp").read_text() + return HARNESS.replace("@T1000_SETTER@", extract_braced( + t1000, "void T1000SensorManager::setPowerSavingEnabled(")).replace( + "@ENV_SETTER@", extract_braced( + environment, "void EnvironmentSensorManager::setPowerSavingEnabled(")) + def test_fresh_acquisition_time_bound_transport_and_preferences(self): + self.compile_run(self.harness(), sources=("src/helpers/SensorManager.cpp",)) + + def test_missing_freshness_and_timeout_negative_controls(self): + source = (ROOT / "src/helpers/SensorManager.cpp").read_text() + for old, replacement in ( + ("location == nullptr || !location->clearPositionFix()", "location == nullptr"), + ("if (gps_acquiring\n && static_cast(now - gps_acquire_started_at) >= GPS_TRACKER_MAX_ACQUIRE_MS)", + "if (gps_acquiring && false)"), + ): + with self.subTest(missing=old): + self.assertEqual(source.count(old), 1) + self.compile_run(self.harness(), manager_source=source.replace(old, replacement), + expect_failure=True) + + def test_i2c_discards_library_cached_pvt_before_fresh_poll(self): + source = (ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp").read_text() + provider = extract_braced(source, "class RAK12500LocationProvider") + self.compile_run(I2C.replace("@RAK_PROVIDER@", provider)) + + def test_i2c_previous_validated_samples_must_reset_negative_control(self): + source = (ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp").read_text() + provider = extract_braced(source, "class RAK12500LocationProvider") + reset = "void syncTime() override {\n _valid_time_samples = 0;" + self.assertEqual(provider.count(reset), 1) + provider = provider.replace(reset, "void syncTime() override {") + self.compile_run(I2C.replace("@RAK_PROVIDER@", provider), expect_failure=True) + + def test_no_gps_build_has_no_side_effects(self): + self.compile_run(r''' +#include +#include +void LocationProvider::sendSentence(const char*){} +int main(){SensorManager s;double lat=5,lon=6;s.setTrackerGpsModeEnabled(true); + assert(!s.beginTrackerGpsAcquisition()&&!s.isTrackerGpsAcquisitionPending()); + assert(!s.takeTrackerGpsPosition(lat,lon)&&lat==0&&lon==0);} +''', gps=0, sources=("src/helpers/SensorManager.cpp",)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_sensor_tracker_sleep.py b/test/test_sensor_tracker_sleep.py new file mode 100644 index 000000000..a384137cc --- /dev/null +++ b/test/test_sensor_tracker_sleep.py @@ -0,0 +1,305 @@ +"""Execute Sensor tracker sleep gates, real wake arming and radio handoff.""" +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_gps_upstream_adaptations import ARDUINO +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +DEADLINES = r''' +#include +#include +#define SENSOR_READ_INTERVAL_SECS 60 +bool logging=false; +namespace mesh {bool isUsbLoggingWatchdogArmed(){return logging;}} +struct Board {bool usb=false,ota=false,test=false; + bool isUsbDataConnected()const{return usb;} + bool isOTAUpdateRunning()const{return ota;} + bool isRadioTestActive()const{return test;} +}; +struct Radio {bool watchdog=false,calibration=false; + bool isWatchdogObserving()const{return watchdog;} + bool isCalibratingNoiseFloor()const{return calibration;} +} radio_driver; +struct Cli {bool gpio=false;bool hasActiveUserGpioTimer()const{return gpio;}}; +struct Manager {int queued=0;int getOutboundTotal()const{return queued;}}; +struct Clock {uint32_t now=100;uint32_t getCurrentTime()const{return now;}}; +class SensorMesh { +public: + Board board;Cli _cli;Manager manager;Manager* _mgr=&manager;Clock rtc; +#if MESH_ENABLE_SENSOR_TRACKER + struct {bool enabled=true;} tracker; + bool tracker_pending=false,tracker_acquiring=false,tracker_storage_fault=false; + unsigned long tracker_awake_until=0;uint32_t tracker_next_check=1900; +#endif + bool dual=false,ota=false,temp=false,outbound=false; + bool queue_due=false,retry_due=false,queue_delay_set=false,retry_delay_set=false; + uint32_t queue_delay=0,retry_delay=0; + unsigned long next_flood_advert=0,next_local_advert=0,dirty_contacts_expiry=0; + unsigned long radio_apply_retry_at=0,set_radio_at=0,revert_radio_at=0; + bool saved_radio_apply_pending=false,temp_radio_applied=false; + uint32_t last_read_time=100; + struct Alert {unsigned long send_expiry=0;} alert; + Alert* alert_tasks[1]={&alert};int num_alert_tasks=0; + const Clock* getRTCClock()const{return &rtc;} + bool millisHasNowPassed(unsigned long time)const{return (int32_t)(millis()-time)>=0;} + bool isDualRadioActive()const{return dual;} + bool hasPendingOtaApply()const{return ota;} + bool isAnyTempRadioActive()const{return temp;} + bool hasOutbound()const{return outbound;} + bool hasQueuedWorkDue()const{return queue_due;} + bool hasRetryWorkDue()const{return retry_due;} + bool getNextQueueWakeDelay(uint32_t& out)const{out=queue_delay;return queue_delay_set;} + bool getNextRetryWakeDelay(uint32_t& out)const{out=retry_delay;return retry_delay_set;} + bool isMillisTimerDue(unsigned long timestamp)const; + uint32_t limitSleepToMillisTimer(unsigned long timestamp,uint32_t sleep_secs)const; + bool hasPendingWork()const; + uint32_t getPowerSaveSleepSeconds(uint32_t max_secs)const; +#if MESH_ENABLE_SENSOR_TRACKER + bool trackerNeedsRadio()const; +#endif +}; +@METHODS@ +int main(){ + now_ms=1000;SensorMesh s; + assert(!s.hasPendingWork()&&s.getPowerSaveSleepSeconds(30)==30); +#if MESH_ENABLE_SENSOR_TRACKER + assert(!s.trackerNeedsRadio()); + s.tracker_next_check=107;assert(s.getPowerSaveSleepSeconds(30)==7); + s.tracker_next_check=100;assert(s.hasPendingWork()&&s.trackerNeedsRadio()); + assert(s.getPowerSaveSleepSeconds(30)==0);s.tracker_next_check=1900; + for(bool* pending:{&s.tracker_pending,&s.tracker_acquiring}){ + *pending=true;assert(s.hasPendingWork()&&s.trackerNeedsRadio()); + assert(s.getPowerSaveSleepSeconds(30)==0);*pending=false; + } + s.tracker_awake_until=1001;assert(s.hasPendingWork()&&s.trackerNeedsRadio()); + ++now_ms;assert(!s.hasPendingWork()&&!s.trackerNeedsRadio()); + s.tracker_awake_until=0;s.tracker_storage_fault=true;s.tracker_next_check=0; + assert(!s.hasPendingWork()&&!s.trackerNeedsRadio()); + s.tracker_storage_fault=false;s.tracker_next_check=1900; + // All standard radio/console/update owners veto RF sleep. + for(bool* busy:{&s.board.usb,&s.board.ota,&s.board.test,&logging,&s.temp,&s.ota, + &s.dual,&s._cli.gpio,&s.outbound}){ + *busy=true;assert(s.trackerNeedsRadio());*busy=false; + } + s.manager.queued=1;assert(s.trackerNeedsRadio());s.manager.queued=0; + s.set_radio_at=1;assert(s.trackerNeedsRadio());s.set_radio_at=0; + s.revert_radio_at=1;assert(s.trackerNeedsRadio());s.revert_radio_at=0; + s.saved_radio_apply_pending=true;assert(s.trackerNeedsRadio()); + s.saved_radio_apply_pending=false; +#endif + for(bool* due:{&s.dual,&s.ota,&s._cli.gpio,&radio_driver.watchdog, + &radio_driver.calibration,&s.queue_due,&s.retry_due}){ + *due=true;assert(s.hasPendingWork()&&s.getPowerSaveSleepSeconds(30)==0);*due=false; + } + s.queue_delay_set=true;s.queue_delay=2500;assert(s.getPowerSaveSleepSeconds(30)==3); +#if MESH_ENABLE_SENSOR_TRACKER + assert(s.trackerNeedsRadio()); +#endif + s.queue_delay_set=false;s.retry_delay_set=true;s.retry_delay=1500; + assert(s.getPowerSaveSleepSeconds(30)==2);s.retry_delay_set=false; + s.dirty_contacts_expiry=now_ms+1001;assert(s.getPowerSaveSleepSeconds(30)==2); + s.dirty_contacts_expiry=0;s.last_read_time=45; + assert(s.getPowerSaveSleepSeconds(30)==5);s.last_read_time=40; + assert(s.hasPendingWork()&&s.getPowerSaveSleepSeconds(30)==0); +} +''' + +TIMER = r''' +#include +#include +using TimerHandle_t=void*; +bool allocate=true,queue_ok=true; +unsigned period_ms=0,begin_calls=0,queue_calls=0; +constexpr int pdPASS=1; +uint32_t pdMS_TO_TICKS(uint32_t value){return value/10;} +int xTimerChangePeriod(TimerHandle_t handle,uint32_t ticks,int wait){ + assert(handle&&wait==0);++queue_calls;period_ms=ticks*10;return queue_ok?pdPASS:0; +} +class SoftwareTimer { + TimerHandle_t handle=nullptr; +public: + TimerHandle_t getHandle(){return handle;} + void begin(uint32_t ms,void(*callback)(TimerHandle_t),void* id,bool repeat){ + assert(ms==1000&&callback&&id==nullptr&&!repeat);++begin_calls; + if(allocate)handle=this; + } +}; +static SoftwareTimer tracker_wake_timer; +static void trackerWakeCallback(TimerHandle_t){} +@ARM@ +struct Mesh {bool tracker=true;uint32_t sleep_secs=30; + bool isTrackerModeEnabled()const{return tracker;} + uint32_t getPowerSaveSleepSeconds(uint32_t max)const{assert(max==30);return sleep_secs;} + bool millisHasNowPassed(uint32_t deadline)const{return (int32_t)(millis()-deadline)>=0;} +} the_mesh; +struct Board {unsigned sleeps=0,last=0;void sleep(unsigned value){++sleeps;last=value;}} board; +static const unsigned long POWERSAVING_FIRST_SLEEP_SECS=120; +void sleepGate(bool can_power_save){ +@GATE@ +} +int main(){ +#if defined(NRF52_PLATFORM) && MESH_ENABLE_SENSOR_TRACKER + allocate=false;sleepGate(true);assert(board.sleeps==0&&delay_count==1&&queue_calls==0); + allocate=true;queue_ok=false;sleepGate(true); + assert(board.sleeps==0&&delay_count==2&&queue_calls==1); + queue_ok=true;sleepGate(true);assert(board.sleeps==1&&board.last==0&&period_ms==30000); + assert(!armTrackerSleepWake(0)&&!armTrackerSleepWake(31)); + unsigned calls=queue_calls;sleepGate(false);assert(queue_calls==calls); + the_mesh.sleep_secs=0;sleepGate(true);assert(queue_calls==calls&&board.sleeps==1); + the_mesh.sleep_secs=7;sleepGate(true);assert(period_ms==7000&&board.sleeps==2); + the_mesh.tracker=false;queue_ok=false;sleepGate(true);assert(board.sleeps==3); +#elif defined(NRF52_PLATFORM) + sleepGate(true);assert(board.sleeps==1&&board.last==0); + the_mesh.sleep_secs=0;sleepGate(true);assert(board.sleeps==1); +#else + now_ms=119999;sleepGate(true);assert(board.sleeps==0); + now_ms=120000;sleepGate(true);assert(board.sleeps==1&&board.last==30); + the_mesh.sleep_secs=0;sleepGate(true);assert(board.sleeps==1); +#endif +} +''' + +RADIO = r''' +#include +#include +#define RADIOLIB_ERR_NONE 0 +#define STATE_IDLE 0 +#define STATE_TX_WAIT 3 +struct PhysicalLayer { + int standby_result=0,sleep_result=0;unsigned standbys=0,sleeps=0; + int standby(){++standbys;return standby_result;} + int sleep(){++sleeps;return sleep_result;} +}; +class RadioLibWrapper { +public: + PhysicalLayer layer;PhysicalLayer* _radio=&layer; + bool _cw_active=false,pending=false,_rx_ps_armed=true,_rx_hold_continuous=true; + unsigned state=5; + bool isPacketPendingOrReceiving(){return pending;} + bool setTrackerSleep(bool sleeping); +}; +@RADIO@ +struct Ms {uint32_t getMillis(){return millis();}}; +struct DispatcherRadio {unsigned begins=0;void begin(){++begins;}bool isInRecvMode(){return true;}}; +struct Liveness {unsigned begins=0;uint32_t at=0;void begin(uint32_t now){++begins;at=now;}}; +class Dispatcher { +public: + bool radio_available=true,dispatcher_started=true,radio_nonrx_timer_armed=true; + bool agc_reset_armed=true,prev_isrecv_mode=false,nonrx_soft_recovery_attempted=true; + uint32_t radio_nonrx_start=0,next_floor_calib_time=0,armed_agc_reset_interval=5; + uint32_t rx_watchdog_window_start=0; + Ms ms;Ms* _ms=&ms;DispatcherRadio radio;DispatcherRadio* _radio=&radio;Liveness radio_liveness; + void setRadioAvailable(bool available); +}; +#define MESH_DEBUG_PRINTLN(...) ((void)0) +@DISPATCHER@ +int main(){ + RadioLibWrapper r;r._cw_active=true;assert(!r.setTrackerSleep(true)&&r.layer.standbys==0); + r._cw_active=false;r.state=STATE_TX_WAIT; + assert(!r.setTrackerSleep(true)&&r.layer.standbys==0);r.state=5;r.pending=true; + assert(!r.setTrackerSleep(true)&&r.layer.standbys==0);r.pending=false; + r.layer.standby_result=1;assert(!r.setTrackerSleep(true)&&r.layer.sleeps==0); + r.layer.standby_result=0;r.layer.sleep_result=1;assert(!r.setTrackerSleep(true)); + r.layer.sleep_result=0;assert(r.setTrackerSleep(true)&&r.state==STATE_IDLE); + assert(!r._rx_ps_armed&&!r._rx_hold_continuous); + unsigned sleeps=r.layer.sleeps;assert(r.setTrackerSleep(false)&&r.layer.sleeps==sleeps); + Dispatcher d;now_ms=12345;d.setRadioAvailable(false); + assert(!d.radio_available&&!d.radio_nonrx_timer_armed&&d.radio.begins==0); + d.setRadioAvailable(true);assert(d.radio_available&&d.radio.begins==1&&d.prev_isrecv_mode); + assert(d.radio_nonrx_start==12345&&d.next_floor_calib_time==12345); + assert(!d.agc_reset_armed&&d.armed_agc_reset_interval==0); +#ifdef RADIO_LIVENESS_SOFT_ONLY + assert(d.rx_watchdog_window_start==12345); +#else + assert(d.radio_liveness.begins==1&&d.radio_liveness.at==12345&&!d.nonrx_soft_recovery_attempted); +#endif + d.setRadioAvailable(true);assert(d.radio.begins==1); + d.setRadioAvailable(false);d.dispatcher_started=false;d.setRadioAvailable(true); + assert(d.radio.begins==1); +} +''' + + +class SensorTrackerSleepTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.compiler = shutil.which("g++") or shutil.which("clang++") + if cls.compiler is None: + raise unittest.SkipTest("a host C++17 compiler is required") + + def compile_run(self, source, defines=()): + with tempfile.TemporaryDirectory(prefix="meshcore-tracker-sleep-") as directory: + path = Path(directory) + (path / "Arduino.h").write_text(ARDUINO, encoding="ascii") + cpp, binary = path / "test.cpp", path / "test" + cpp.write_text(source, encoding="ascii") + result = subprocess.run([ + self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-Wno-unused-function", "-Wno-unused-variable", + *(["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []), + *[f"-D{define}" for define in defines], "-I", str(path), + str(cpp), "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_actual_deadlines_and_radio_owners_feature_on_and_off(self): + sensor = (ROOT / "examples/simple_sensor/SensorMesh.cpp").read_text() + tracker = (ROOT / "examples/simple_sensor/Tracker.cpp").read_text() + methods = "\n".join(extract_braced(sensor, name) for name in ( + "bool SensorMesh::isMillisTimerDue(", "uint32_t SensorMesh::limitSleepToMillisTimer(", + "bool SensorMesh::hasPendingWork(", "uint32_t SensorMesh::getPowerSaveSleepSeconds(")) + methods += "\n#if MESH_ENABLE_SENSOR_TRACKER\n" + extract_braced( + tracker, "bool SensorMesh::trackerNeedsRadio(") + "\n#endif\n" + for enabled in (0, 1): + with self.subTest(enabled=enabled): + self.compile_run(DEADLINES.replace("@METHODS@", methods), + (f"MESH_ENABLE_SENSOR_TRACKER={enabled}",)) + + def test_actual_main_timer_allocation_queue_failure_and_platform_paths(self): + main = (ROOT / "examples/simple_sensor/main.cpp").read_text() + source = TIMER.replace("@ARM@", extract_braced( + main, "static bool armTrackerSleepWake(")).replace( + "@GATE@", extract_braced(main, "if (can_power_save)")) + for defines in (("NRF52_PLATFORM=1", "MESH_ENABLE_SENSOR_TRACKER=1"), + ("NRF52_PLATFORM=1", "MESH_ENABLE_SENSOR_TRACKER=0"), + ("ESP32_PLATFORM=1", "MESH_ENABLE_SENSOR_TRACKER=1"), + ("RP2040_PLATFORM=1", "MESH_ENABLE_SENSOR_TRACKER=1")): + with self.subTest(defines=defines): + self.compile_run(source, defines) + + def test_actual_radio_warm_sleep_and_dispatcher_watchdog_reanchor(self): + radio = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + dispatcher = (ROOT / "src/Dispatcher.cpp").read_text() + source = RADIO.replace("@RADIO@", extract_braced( + radio, "bool RadioLibWrapper::setTrackerSleep(")).replace( + "@DISPATCHER@", extract_braced(dispatcher, "void Dispatcher::setRadioAvailable(")) + for defines in ((), ("RADIO_LIVENESS_SOFT_ONLY=1",)): + with self.subTest(defines=defines): + self.compile_run(source, defines) + + def test_feature_defaults_and_overrides(self): + header = (ROOT / "examples/simple_sensor/SensorMesh.h").read_text() + config = re.search(r"#ifndef MESH_ENABLE_SENSOR_TRACKER[\s\S]*?#endif\n#endif", header).group() + source = config + "\nstatic_assert(MESH_ENABLE_SENSOR_TRACKER == EXPECTED);int main(){}\n" + for defines in (("NRF52_PLATFORM=1", "ENV_INCLUDE_GPS=1", "EXPECTED=1"), + ("ESP32_PLATFORM=1", "ENV_INCLUDE_GPS=1", "EXPECTED=1"), + ("RP2040_PLATFORM=1", "ENV_INCLUDE_GPS=1", "EXPECTED=1"), + ("STM32_PLATFORM=1", "ENV_INCLUDE_GPS=1", "EXPECTED=0"), + ("NRF52_PLATFORM=1", "ENV_INCLUDE_GPS=0", "EXPECTED=0"), + ("STM32_PLATFORM=1", "ENV_INCLUDE_GPS=1", "MESH_ENABLE_SENSOR_TRACKER=1", "EXPECTED=1")): + with self.subTest(defines=defines): + self.compile_run(source, defines) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_sensor_tracker_store.py b/test/test_sensor_tracker_store.py new file mode 100644 index 000000000..db3805960 --- /dev/null +++ b/test/test_sensor_tracker_store.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""Run production tracker persistence against faults on all supported FS APIs.""" + +from pathlib import Path +import os +import shutil +import struct +import subprocess +import sys +import tempfile +import unittest +import zlib + +ROOT = Path(__file__).resolve().parents[1] + +STAT_MOCK = r''' +#pragma once +struct stat {}; +inline int stat(const char* absolute, struct stat*) { + if (!metadata_filesystem || strncmp(absolute, "/spiffs", 7) != 0) { errno = EIO; return -1; } + const char* path = absolute + 7; + const std::string op = std::string("stat:") + path; + const bool okay = metadata_filesystem->begin(op); + const bool present = metadata_filesystem->files.count(path) || metadata_filesystem->directories.count(path); + metadata_filesystem->end(op); + if (!okay) { errno = EIO; return -1; } + if (!present) { errno = ENOENT; return -1; } + return 0; +} +''' + +HARNESS = r''' +#include "tracker_storage_fs.h" +#include +#include +using namespace mesh::tracker; +static unsigned scenarios = 0; +static const char* A = kTrackerBank0Path; +static const char* B = kTrackerBank1Path; +static const char* T = kTrackerTempPath; +static const std::vector GOLDEN = {@GOLDEN@}; +static StoreResult load(MemoryFS& fs, TrackerRecord& record) { + metadata_filesystem = &fs; return loadTracker(&fs, record); +} +static StoreResult save(MemoryFS& fs, TrackerRecord& record) { + metadata_filesystem = &fs; return saveTracker(&fs, record); +} +static TrackerRecord initial() { + TrackerRecord record; record.enabled = true; + for (size_t i = 0; i < sizeof(record.owner); ++i) record.owner[i] = static_cast(i + 1); + assert(noteFailure(record.route, 1000, record.route)); + record.path_len = 66; + for (size_t i = 0; i < 4; ++i) record.path[i] = static_cast(i + 10); + record.last_lost = 2; + record.last_request_tag = 1000; + return record; +} +static void repair(std::vector& image) { + route_detail::put32(image.data() + 156, route_detail::crc(image.data(), 156)); +} +static void unavailable(MemoryFS& fs) { + TrackerRecord out = initial(); + assert(load(fs, out) == StoreResult::Unavailable); + assert(!out.enabled && !out.generation && (out.route.flags & kClockFault)); + auto candidate = initial(); + assert(save(fs, candidate) == StoreResult::Unavailable); + assert(!candidate.generation && !fs.write_opens); ++scenarios; +} +static void seed(MemoryFS& fs, unsigned count) { + auto record = initial(); + for (unsigned i = 0; i < count; ++i) assert(save(fs, record) == StoreResult::Success); + fs.reset(); +} +int main() { + { MemoryFS fs; TrackerRecord out = initial(); + assert(load(fs, out) == StoreResult::NotFound); + assert(!out.enabled && !out.generation && out.path_len == kUnknownPath); + assert(out.normal_interval == 1800 && out.lost_interval == 60 && !out.route.flags); + fs.put(T, {1, 2, 3}); assert(load(fs, out) == StoreResult::NotFound); + auto record = initial(); assert(save(fs, record) == StoreResult::Success); + assert(record.generation == 1 && fs.get(A) == GOLDEN && !fs.files.count(T)); + assert(load(fs, out) == StoreResult::Success && out.enabled && out.generation == 1); + assert(memcmp(out.owner, record.owner, 32) == 0 && out.path_len == 66); + assert(memcmp(out.path, record.path, 64) == 0 && out.last_lost == 2); + assert(out.route.outage_since == 1000 && out.last_request_tag == 1000); + record.enabled = false; record.last_lost = 1; record.normal_interval = 86400; record.lost_interval = 60; + assert(save(fs, record) == StoreResult::Success && record.generation == 2); + assert(load(fs, out) == StoreResult::Success && !out.enabled && out.last_lost == 1); + assert(out.normal_interval == 86400 && out.generation == 2); + record.enabled = true; record.path_len = kUnknownPath; memset(record.path, 0xaa, sizeof(record.path)); + assert(save(fs, record) == StoreResult::Success && record.generation == 3); + assert(load(fs, out) == StoreResult::Success && out.path_len == kUnknownPath); + for (uint8_t byte : out.path) assert(byte == 0); + ++scenarios; } + { MemoryFS fs; seed(fs, 2); auto stale = initial(); + assert(save(fs, stale) == StoreResult::Conflict && !fs.write_opens); + stale.generation = 1; assert(save(fs, stale) == StoreResult::Conflict && !fs.write_opens); ++scenarios; } + { MemoryFS fs; auto record = initial(); + memset(record.owner, 0, sizeof(record.owner)); record.owner[31] = 1; + record.path_len = 96; record.normal_interval = 60; record.lost_interval = 86400; record.last_lost = 0; + for (size_t index = 0; index < sizeof(record.path); ++index) record.path[index] = static_cast(255 - index); + assert(save(fs, record) == StoreResult::Success); + TrackerRecord reboot; assert(load(fs, reboot) == StoreResult::Success); + assert(reboot.owner[31] == 1 && reboot.path_len == 96 && reboot.normal_interval == 60 + && reboot.lost_interval == 86400 && reboot.last_lost == 0); + assert(memcmp(reboot.owner, record.owner, sizeof(record.owner)) == 0); + assert(memcmp(reboot.path, record.path, sizeof(record.path)) == 0); ++scenarios; } + { TrackerRecord out; assert(loadTracker(static_cast(nullptr), out) == StoreResult::Unavailable); + assert(!out.enabled && (out.route.flags & kClockFault)); + auto record = initial(); assert(saveTracker(static_cast(nullptr), record) == StoreResult::Unavailable); ++scenarios; } + for (unsigned bad = 0; bad < 12; ++bad) { + MemoryFS fs; auto record = initial(); + if (bad == 0) memset(record.owner, 0, 32); + if (bad == 1) record.normal_interval = 59; + if (bad == 2) record.normal_interval = 86401; + if (bad == 3) record.lost_interval = 59; + if (bad == 4) record.lost_interval = 86401; + if (bad == 5) record.last_lost = 3; + if (bad == 6) record.path_len = 192; + if (bad == 7) record.path_len = 127; + if (bad == 8) record.route.flags = 0x80; + if (bad == 9) record.route.outage_since = 0; + if (bad == 10) record.last_request_tag = 1301; + if (bad == 11) record.route = RouteState(); + assert(save(fs, record) == StoreResult::Invalid && fs.trace.empty()); ++scenarios; + } + // Accepted path encoding and boundary hash counts agree with Packet's wire + // format; 4-byte hashes and count*width > 64 are unsupported. + for (unsigned length = 0; length < 256; ++length) { + auto record = initial(); record.path_len = static_cast(length); + const bool expected = length == 255 || ((length >> 6) != 3 && (length & 63) * ((length >> 6) + 1) <= 64); + assert(isValidTrackerRecord(record) == expected); ++scenarios; + } + + // Corrupt either committed bank: never fall back to an older state that could + // permit a repeated flood, nor overwrite the unexpected image. + for (const char* bank : {A, B}) { + MemoryFS valid; seed(valid, 2); const auto original = valid.get(bank); + for (size_t offset = 0; offset < original.size(); ++offset) { + MemoryFS fs; seed(fs, 2); auto damaged = original; damaged[offset] ^= 1; fs.put(bank, damaged); + unavailable(fs); assert(fs.get(bank) == damaged); ++scenarios; + } + for (const auto& image : {std::vector(), std::vector(159), std::vector(161)}) { + MemoryFS fs; seed(fs, 2); fs.put(bank, image); unavailable(fs); + } + for (const char* operation : {"open:r:", "size:", "read:"}) { + MemoryFS fs; seed(fs, 2); fs.faults.insert(std::string(operation) + bank); unavailable(fs); + } + MemoryFS directory; seed(directory, 2); directory.directories.insert(bank); unavailable(directory); + } + for (unsigned mutation = 0; mutation < 12; ++mutation) { + MemoryFS fs; auto image = GOLDEN; + if (mutation == 0) image[3] = 2; + if (mutation == 1) image[8] = 2; + if (mutation == 2) image[9] = 3; + if (mutation == 3) image[10] = 192; + if (mutation == 4) image[11] = 1; + if (mutation == 5) route_detail::put32(image.data() + 12, 59); + if (mutation == 6) route_detail::put32(image.data() + 16, 86401); + if (mutation == 7) std::fill(image.begin() + 20, image.begin() + 52, 0); + if (mutation == 8) image[152] = 1; + if (mutation == 9) image[88] = 1; + if (mutation == 10) route_detail::put32(image.data() + 4, 0); + if (mutation == 11) { + image[56] = 0x80; + route_detail::put32(image.data() + 80, route_detail::crc(image.data() + 52, 28)); + } + repair(image); fs.put(A, image); unavailable(fs); + } + { MemoryFS fs; seed(fs, 2); auto image = fs.get(B); + route_detail::put32(image.data() + 4, 4); repair(image); fs.put(B, image); unavailable(fs); } + { MemoryFS fs; auto image = GOLDEN; route_detail::put32(image.data() + 4, UINT32_MAX); repair(image); fs.put(A, image); + TrackerRecord record; assert(load(fs, record) == StoreResult::Success); + fs.reset(); assert(save(fs, record) == StoreResult::Unavailable && !fs.write_opens); ++scenarios; } + { MemoryFS fs; seed(fs, 2); fs.short_read = true; unavailable(fs); } + // The durable nonce floor survives the same RTC second across reboot. An + // authenticated older reply cannot match a subsequently allocated tag. + { MemoryFS fs; auto record = initial(); assert(save(fs, record) == StoreResult::Success); + TrackerRecord reboot; assert(load(fs, reboot) == StoreResult::Success); + assert(reboot.last_request_tag == 1000 && reboot.route.latest_observed == 1000); + reboot.last_request_tag = 1001; + assert(save(fs, reboot) == StoreResult::Success); + TrackerRecord second_boot; assert(load(fs, second_boot) == StoreResult::Success); + assert(second_boot.last_request_tag == 1001 && second_boot.route.latest_observed == 1000); + uint8_t response[ResponseLength], state; uint16_t awake; + assert(makeStatusResponse(1000, Lost, 120, response, sizeof(response)) == sizeof(response)); + assert(!parseStatusResponse(response, sizeof(response), second_boot.last_request_tag, state, awake)); + assert(makeStatusResponse(1001, Safe, 0, response, sizeof(response)) == sizeof(response)); + assert(parseStatusResponse(response, sizeof(response), second_boot.last_request_tag, state, awake)); + assert(state == Safe && awake == 0); ++scenarios; } + { MemoryFS fs; auto record = initial(); record.last_request_tag = 1300; + assert(save(fs, record) == StoreResult::Success); + TrackerRecord reboot; assert(load(fs, reboot) == StoreResult::Success && reboot.last_request_tag == 1300); + fs.reset(); record.last_request_tag = 1301; + assert(save(fs, record) == StoreResult::Invalid && fs.trace.empty()); + record.last_request_tag = 1000; + assert(save(fs, record) == StoreResult::Invalid && !fs.write_opens); + record.last_request_tag = 0; + assert(save(fs, record) == StoreResult::Invalid && !fs.write_opens); + assert(load(fs, reboot) == StoreResult::Success && reboot.last_request_tag == 1300); + MemoryFS unallocated; auto fresh = initial(); fresh.last_request_tag = 0; + assert(save(unallocated, fresh) == StoreResult::Success); + assert(load(unallocated, reboot) == StoreResult::Success && !reboot.last_request_tag); ++scenarios; } + { MemoryFS fs; auto record = initial(); record.route = RouteState(); + record.route.latest_observed = UINT32_MAX - 300; record.last_request_tag = UINT32_MAX; + assert(save(fs, record) == StoreResult::Success); + TrackerRecord reboot; assert(load(fs, reboot) == StoreResult::Success && reboot.last_request_tag == UINT32_MAX); + assert(reboot.route.latest_observed == UINT32_MAX - 300); ++scenarios; } +#if defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + for (const char* bank : {A, B}) { + for (bool present : {false, true}) { + MemoryFS fs; if (present) seed(fs, 2); fs.faults.insert(std::string("stat:") + bank); + unavailable(fs); + } + } +#endif + + // Every failure and power-cut boundary around a flood reservation. Exactly + // old or fully committed new is selected after reboot, never partial state. + for (unsigned prior : {0u, 1u, 2u, 3u}) { + MemoryFS baseline; seed(baseline, prior); + TrackerRecord old; assert(load(baseline, old) == (prior ? StoreResult::Success : StoreResult::NotFound)); + if (!prior) old = initial(); + TrackerRecord desired = old; + assert(reserveFlood(desired.route, 22600, desired.route)); + baseline.reset(); assert(save(baseline, desired) == StoreResult::Success); + const auto trace = baseline.trace; const size_t boundaries = baseline.boundary; + std::set operations; + for (const auto& point : trace) if (point.compare(0, 7, "before:") == 0) operations.insert(point.substr(7)); + for (const auto& operation : operations) { + // Boolean-only exists APIs cannot report metadata faults independently. +#if !defined(ESP32_PLATFORM) && !defined(NRF52_PLATFORM) && !defined(STM32_PLATFORM) + if (operation.compare(0, 5, "stat:") == 0) continue; +#endif + MemoryFS fs; seed(fs, prior); fs.faults.insert(operation); auto attempted = old; + assert(reserveFlood(attempted.route, 22600, attempted.route)); + const StoreResult result = save(fs, attempted); + const bool transmitted = result == StoreResult::Success; + fs.reset(); TrackerRecord reboot; + const StoreResult restored = load(fs, reboot); + assert(restored == StoreResult::Success || (!prior && restored == StoreResult::NotFound)); + const bool committed = restored == StoreResult::Success && reboot.route.last_flood == 22600; + assert(!transmitted || committed); + if (committed) { + RouteState candidate; assert(planRoute(reboot.route, 22601, true, candidate) == RouteDecision::Direct); + assert(!reserveFlood(reboot.route, 22601, candidate)); + assert(reboot.generation == prior + 1); + } else assert(reboot.generation == prior); + ++scenarios; + } + for (size_t cut = 1; cut <= boundaries; ++cut) { + MemoryFS fs; seed(fs, prior); fs.cut_at = cut; auto attempted = old; + assert(reserveFlood(attempted.route, 22600, attempted.route)); + try { save(fs, attempted); } catch (const PowerCut&) {} + fs.reset(); TrackerRecord reboot; + const StoreResult restored = load(fs, reboot); + assert(restored == StoreResult::Success || (!prior && restored == StoreResult::NotFound)); + assert(reboot.generation == prior || reboot.generation == prior + 1); + if (reboot.generation == prior + 1) { + assert(reboot.route.last_flood == 22600); + RouteState candidate; assert(!reserveFlood(reboot.route, 22601, candidate)); + } else if (prior) assert(!reboot.route.last_flood); + ++scenarios; + } + for (bool short_write : {false, true}) { + MemoryFS fs; seed(fs, prior); fs.short_write = short_write; + if (!short_write) fs.replacement_on_close = GOLDEN; + auto attempted = old; assert(reserveFlood(attempted.route, 22600, attempted.route)); + assert(save(fs, attempted) == StoreResult::Unavailable); + fs.reset(); TrackerRecord reboot; + assert(load(fs, reboot) == (prior ? StoreResult::Success : StoreResult::NotFound)); + assert(reboot.generation == prior); ++scenarios; + } + } + // Sticky cutoff and saved lost status/path survive a complete reload. + { MemoryFS fs; auto record = initial(); + RouteState cutoff; assert(planRoute(record.route, 346600, false, cutoff) == RouteDecision::Locked); + record.route = cutoff; assert(save(fs, record) == StoreResult::Success); + TrackerRecord reboot; assert(load(fs, reboot) == StoreResult::Success && reboot.last_lost == 2); + assert(reboot.path_len == 66 && reboot.route.flags & kFloodLocked); + RouteState candidate; assert(noteRepeater(reboot.route, 400000, candidate)); + assert(planRoute(candidate, 500000, false, cutoff) == RouteDecision::Locked); + assert(noteAuthenticatedReply(candidate, 500000, cutoff)); + assert(!cutoff.flags); ++scenarios; } + printf("PASS: %u sensor tracker storage, reservation and power-cut scenarios\n", scenarios); +} +''' + + +def golden_image(): + route = b"TRP\x01\x01\0\0\0" + struct.pack("<5I", 1000, 0, 0, 0, 1000) + route += struct.pack(" +#include +#include +#include +#include +#include +#include +#include "examples/companion_radio/CompanionFeatures.h" +#include +#if MESH_ENABLE_LOST_REPLY +#include +#endif +#define PUB_KEY_SIZE 32 +#define ADV_TYPE_CHAT 1 +#define ADV_TYPE_REPEATER 2 +#define ADV_TYPE_ROOM 3 +#define ADV_TYPE_SENSOR 4 +#define REQ_TYPE_GET_TELEMETRY_DATA 3 +#define TELEM_MODE_ALLOW_FLAGS 1 +#define TELEM_MODE_ALLOW_ALL 2 +#define TELEM_PERM_BASE 1 +#define TELEM_PERM_LOCATION 2 +#define TELEM_PERM_ENVIRONMENT 4 +#define TELEM_CHANNEL_SELF 1 +using std::isnan; + +struct ContactInfo { + struct Identity { uint8_t pub_key[32] = {}; } id; + uint8_t type = ADV_TYPE_SENSOR, flags = 0; + bool transient = false; + int32_t gps_lat = 100, gps_lon = 200; + uint32_t last_advert_timestamp = 900, lastmod = 800; +}; +struct Clock { uint32_t now = 0; uint32_t getMillis() const { return now; } }; +struct RTC { uint32_t now = 1000; uint32_t getCurrentTime() const { return now; } }; +struct Board { + uint16_t getBattMilliVolts() { return 3700; } + float getMCUTemperature() { return NAN; } +}; +struct Sensors { void querySensors(uint8_t, ...) {} }; +struct Telemetry { + void reset() {} void addVoltage(uint8_t, float) {} void addTemperature(uint8_t, float) {} + uint8_t getSize() { return 0; } const uint8_t* getBuffer() { return nullptr; } +}; +class MyMesh { +public: + struct Prefs { + uint8_t lost_reply = 0, telemetry_mode_base = 0; + uint8_t telemetry_mode_loc = 0, telemetry_mode_env = 0; + } _prefs; + Clock clock; Clock* _ms = &clock; RTC rtc; + Board board; Sensors sensors; Telemetry telemetry; + std::vector contacts; + bool mutable_contacts = true; + unsigned notifications = 0, queries = 0; + uint32_t user_ack_table[8] = {11,22,33,44,55,66,77,88}; + uint32_t user_ack_timeout = 7654321; +#if MESH_ENABLE_LOST_REPLY + mesh::companion::LostReplyLimiter lost_reply_limiter; + uint8_t handleTrackerStatusRequest(const ContactInfo&, uint32_t, const uint8_t*, uint8_t, uint8_t*); +#endif + uint8_t onContactRequest(const ContactInfo&, uint32_t, const uint8_t*, uint8_t, uint8_t*); + bool isTransientContact(const ContactInfo& contact) { return contact.transient; } + bool canMutateContacts() { return mutable_contacts; } + RTC* getRTCClock() { return &rtc; } + ContactInfo* lookupPersistentContactByPubKey(const uint8_t* key, size_t length) { + assert(length == 32); + ++queries; + for (auto& contact : contacts) { + if (!contact.transient && memcmp(contact.id.pub_key, key, 32) == 0) return &contact; + } + return nullptr; + } + void onDiscoveredContact(ContactInfo& contact, bool added, uint8_t length, const uint8_t* path) { + assert(!added && length == 0 && path == nullptr); + assert(contact.lastmod == rtc.now); + ++notifications; + } + ContactInfo& saved(uint8_t key = 1) { + contacts.emplace_back(); + contacts.back().id.pub_key[0] = key; + return contacts.back(); + } +}; +@METHODS@ + +static mesh::tracker::StatusRequest report(bool gps = false) { + mesh::tracker::StatusRequest value; + value.battery_mv = 3712; + if (gps) { + value.flags = mesh::tracker::FreshGps; + value.latitude = 471234567; + value.longitude = -1222345678; + } + return value; +} + +static uint8_t query(MyMesh& mesh, const ContactInfo& peer, uint32_t tag, + const mesh::tracker::StatusRequest& value, + uint8_t expected, bool padded = true) { + uint8_t bytes[28] = {}; + assert(mesh::tracker::makeStatusRequest(value, bytes, sizeof(bytes)) == 13); + uint8_t answer[18]; memset(answer, 0xa5, sizeof(answer)); + uint32_t ack_before[8]; memcpy(ack_before, mesh.user_ack_table, sizeof(ack_before)); + const uint32_t timeout_before = mesh.user_ack_timeout; + const auto prefs_before = mesh._prefs; + const uint8_t length = mesh.onContactRequest(peer, tag, bytes, + padded ? 28 : 13, answer + 1); + assert(length == expected && answer[0] == 0xa5); + for (size_t i = 1 + length; i < sizeof(answer); ++i) assert(answer[i] == 0xa5); + assert(memcmp(ack_before, mesh.user_ack_table, sizeof(ack_before)) == 0); + assert(mesh.user_ack_timeout == timeout_before); + assert(memcmp(&prefs_before, &mesh._prefs, sizeof(prefs_before)) == 0); + if (length) { + uint8_t status = 99; uint16_t awake = 999; + assert(mesh::tracker::parseStatusResponse(answer + 1, length, tag, status, awake)); + const uint8_t expected_status = mesh._prefs.lost_reply <= 2 ? mesh._prefs.lost_reply : 0; + assert(status == expected_status && awake == (status == 2 ? 120 : 0)); + } + return length; +} + +static void codec() { + const auto original = report(true); + uint8_t bytes[40] = {}; + assert(mesh::tracker::makeStatusRequest(original, bytes, sizeof(bytes)) == 13); + assert(bytes[0] == 0x0c && bytes[1] == 1 && bytes[2] == 1); + assert(bytes[11] == 0x80 && bytes[12] == 0x0e); + for (size_t length = 0; length <= sizeof(bytes); ++length) { + mesh::tracker::StatusRequest parsed; + parsed.latitude = 77; + const bool valid = mesh::tracker::parseStatusRequest(bytes, length, parsed); + assert(valid == (length == 13 || length == 28)); + if (valid) { + assert(parsed.flags == original.flags && parsed.latitude == original.latitude); + assert(parsed.longitude == original.longitude && parsed.battery_mv == original.battery_mv); + } else assert(parsed.latitude == 77); + } + mesh::tracker::StatusRequest parsed; + assert(!mesh::tracker::parseStatusRequest(nullptr, 28, parsed)); + for (size_t i = 13; i < 28; ++i) { + bytes[i] = 1; + assert(!mesh::tracker::parseStatusRequest(bytes, 28, parsed)); + bytes[i] = 0; + } + for (uint8_t flags : {uint8_t(3), uint8_t(4), uint8_t(255)}) { + bytes[2] = flags; + assert(!mesh::tracker::parseStatusRequest(bytes, 13, parsed)); + } + for (int32_t bad : {INT32_MIN, INT32_MAX, -900000001, 900000001}) { + auto value = original; value.latitude = bad; + assert(mesh::tracker::makeStatusRequest(value, bytes, sizeof(bytes)) == 0); + assert(mesh::tracker::makeStatusRequest(original, bytes, sizeof(bytes)) == 13); + mesh::tracker::put32(bytes + 3, uint32_t(bad)); + assert(!mesh::tracker::parseStatusRequest(bytes, 13, parsed)); + } + for (int32_t bad : {INT32_MIN, INT32_MAX, -1800000001, 1800000001}) { + auto value = original; value.longitude = bad; + assert(mesh::tracker::makeStatusRequest(value, bytes, sizeof(bytes)) == 0); + assert(mesh::tracker::makeStatusRequest(original, bytes, sizeof(bytes)) == 13); + mesh::tracker::put32(bytes + 7, uint32_t(bad)); + assert(!mesh::tracker::parseStatusRequest(bytes, 13, parsed)); + } + for (int32_t edge : {-900000000, 900000000}) { + auto value = original; value.latitude = edge; + assert(mesh::tracker::makeStatusRequest(value, bytes, sizeof(bytes)) == 13); + assert(mesh::tracker::parseStatusRequest(bytes, 13, parsed) && parsed.latitude == edge); + } + auto no_fix = report(); no_fix.flags = mesh::tracker::GpsAttemptFailed; + assert(mesh::tracker::makeStatusRequest(no_fix, bytes, sizeof(bytes)) == 13); + no_fix.latitude = 1; + assert(mesh::tracker::makeStatusRequest(no_fix, bytes, sizeof(bytes)) == 0); + assert(mesh::tracker::makeStatusRequest(original, nullptr, 40) == 0); + assert(mesh::tracker::makeStatusRequest(original, bytes, 12) == 0); + + uint8_t answer[32] = {}; + assert(mesh::tracker::makeStatusResponse(0x87654321, 2, 120, answer, 32) == 9); + assert(answer[0] == 0x21 && answer[1] == 0x43 && answer[2] == 0x65 && answer[3] == 0x87); + assert(answer[4] == 12 && answer[5] == 1 && answer[6] == 2 && answer[7] == 120 && answer[8] == 0); + for (size_t length = 0; length <= sizeof(answer); ++length) { + uint8_t status = 99; uint16_t awake = 999; + const bool valid = mesh::tracker::parseStatusResponse(answer, length, 0x87654321, status, awake); + assert(valid == (length == 9 || length == 16)); + assert(status == (valid ? 2 : 99) && awake == (valid ? 120 : 999)); + } + uint8_t status = 99; uint16_t awake = 999; + assert(!mesh::tracker::parseStatusResponse(answer, 16, 0x87654322, status, awake)); + for (size_t i = 9; i < 16; ++i) { + answer[i] = 1; + assert(!mesh::tracker::parseStatusResponse(answer, 16, 0x87654321, status, awake)); + answer[i] = 0; + } + for (size_t index : {size_t(4), size_t(5), size_t(6)}) { + const uint8_t saved = answer[index]; answer[index] = 255; + assert(!mesh::tracker::parseStatusResponse(answer, 9, 0x87654321, status, awake)); + answer[index] = saved; + } + assert(!mesh::tracker::parseStatusResponse(nullptr, 16, 1, status, awake)); + assert(mesh::tracker::makeStatusResponse(1, 3, 0, answer, 32) == 0); + assert(mesh::tracker::makeStatusResponse(1, 2, 120, answer, 8) == 0); + assert(mesh::tracker::makeStatusResponse(1, 2, 120, nullptr, 32) == 0); +} + +static void callbacks() { +#if MESH_ENABLE_LOST_REPLY + for (uint8_t state : {uint8_t(0), uint8_t(1), uint8_t(2), uint8_t(255)}) { + for (uint8_t type : {uint8_t(ADV_TYPE_SENSOR), uint8_t(ADV_TYPE_CHAT)}) { + MyMesh mesh; auto& peer = mesh.saved(); peer.type = type; mesh._prefs.lost_reply = state; + query(mesh, peer, 1000, report(), 9); + assert(mesh.notifications == 0 && peer.gps_lat == 100 && peer.gps_lon == 200); + } + } + for (uint8_t type : {uint8_t(0), uint8_t(ADV_TYPE_ROOM), uint8_t(ADV_TYPE_REPEATER), uint8_t(255)}) { + MyMesh mesh; auto& peer = mesh.saved(); peer.type = type; + query(mesh, peer, 1000, report(true), 0); + } + { + MyMesh mesh; auto& peer = mesh.saved(); peer.transient = true; + query(mesh, peer, 1000, report(true), 0); + peer.transient = false; + ContactInfo unknown = peer; unknown.id.pub_key[31] = 1; + query(mesh, unknown, 1000, report(true), 0); + assert(mesh.notifications == 0); + } + { + MyMesh mesh; auto& peer = mesh.saved(); mesh._prefs.lost_reply = 2; + uint8_t bytes[40] = {}; + assert(mesh::tracker::makeStatusRequest(report(true), bytes, 40) == 13); + uint8_t answer[16]; memset(answer, 0xa5, sizeof(answer)); + assert(mesh.onContactRequest(peer, 1000, nullptr, 13, answer) == 0); + assert(mesh.onContactRequest(peer, 1000, bytes, 0, answer) == 0); + assert(mesh.onContactRequest(peer, 1000, bytes, 13, nullptr) == 0); + for (uint8_t length : {uint8_t(1), uint8_t(12), uint8_t(14), uint8_t(27), uint8_t(29), uint8_t(40)}) + assert(mesh.onContactRequest(peer, 1000, bytes, length, answer) == 0); + bytes[1] = 2; + assert(mesh.onContactRequest(peer, 1000, bytes, 28, answer) == 0); bytes[1] = 1; + bytes[13] = 1; + assert(mesh.onContactRequest(peer, 1000, bytes, 28, answer) == 0); bytes[13] = 0; + assert(mesh.notifications == 0); + query(mesh, peer, 1000, report(true), 9); + assert(peer.gps_lat == 47123456 && peer.gps_lon == -122234567); + assert(peer.last_advert_timestamp == 1000 && peer.lastmod == 1000 && mesh.notifications == 1); + query(mesh, peer, 1000, report(true), 0); // Same logical request cannot produce a second reply. + mesh.clock.now = 60000; + query(mesh, peer, 1000, report(true), 0); // Exact retry remains suppressed beyond cooldown. + query(mesh, peer, 1001, report(true), 9, false); + assert(peer.last_advert_timestamp == 1001 && mesh.notifications == 2); + } + for (uint32_t tag : {uint32_t(699), uint32_t(700), uint32_t(1300), uint32_t(1301), UINT32_MAX}) { + MyMesh mesh; auto& peer = mesh.saved(); peer.last_advert_timestamp = 1; + query(mesh, peer, tag, report(true), 9); + const bool accepted = tag >= 700 && tag <= 1300; + assert(mesh.notifications == (accepted ? 1U : 0U)); + assert(peer.last_advert_timestamp == (accepted ? tag : 1)); + } + for (uint32_t floor : {uint32_t(999), uint32_t(1000), uint32_t(1001)}) { + MyMesh mesh; auto& peer = mesh.saved(); peer.last_advert_timestamp = floor; + query(mesh, peer, 1000, report(true), 9); + assert(mesh.notifications == (floor < 1000 ? 1U : 0U)); + assert(peer.last_advert_timestamp == (floor < 1000 ? 1000 : floor)); + } + { + MyMesh mesh; auto& peer = mesh.saved(); mesh.mutable_contacts = false; + query(mesh, peer, 1000, report(true), 9); + assert(mesh.notifications == 0 && peer.last_advert_timestamp == 900); + } + { + MyMesh mesh; mesh.contacts.reserve(2); + auto& first = mesh.saved(1); auto& second = mesh.saved(2); + query(mesh, first, 1000, report(), 9); + query(mesh, second, 1000, report(), 0); + mesh.clock.now = 10000; + query(mesh, second, 1000, report(), 9); + mesh.clock.now = 59999; + query(mesh, first, 1001, report(), 0); + mesh.clock.now = 60000; + query(mesh, first, 1001, report(), 9); + } +#else + MyMesh mesh; auto& peer = mesh.saved(); mesh._prefs.lost_reply = 2; + query(mesh, peer, 1000, report(true), 0); + assert(mesh.notifications == 0 && peer.last_advert_timestamp == 900); +#endif +} + +int main() { + codec(); callbacks(); + puts("PASS: tracker owner production callback and wire codec"); +} +''' + + +class TrackerOwnerResponseTests(unittest.TestCase): + def test_actual_owner_callback_and_codec(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++ compiler is required") + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + methods = extract_braced(source, "uint8_t MyMesh::onContactRequest(") + methods += "\n#if MESH_ENABLE_LOST_REPLY\n" + extract_braced( + source, "uint8_t MyMesh::handleTrackerStatusRequest(") + "\n#endif\n" + with tempfile.TemporaryDirectory(prefix="meshcore-tracker-owner-") as directory: + work = Path(directory) + cpp = work / "owner.cpp" + cpp.write_text(HARNESS.replace("@METHODS@", methods), encoding="ascii") + for flags in ([], ["-DSTM32_PLATFORM=1"], + ["-DSTM32_PLATFORM=1", "-DMESH_ENABLE_LOST_REPLY=1"], + ["-DMESH_ENABLE_LOST_REPLY=0"]): + with self.subTest(flags=flags): + binary = work / "owner" + built = subprocess.run([ + compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", *SANITIZERS, *flags, + "-I", str(ROOT / "src"), "-I", str(ROOT), + str(cpp), "-o", str(binary), + ], text=True, capture_output=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(binary)], text=True, capture_output=True, timeout=20) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + self.assertIn("PASS: tracker owner production callback and wire codec", ran.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_tracker_route_policy.py b/test/test_tracker_route_policy.py new file mode 100644 index 000000000..82f794c8c --- /dev/null +++ b/test/test_tracker_route_policy.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Execute production tracker routing decisions and its persisted byte format.""" + +from pathlib import Path +import os +import shutil +import struct +import subprocess +import sys +import tempfile +import unittest +import zlib + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +using namespace mesh::tracker; +static unsigned checks = 0; +static void decision(const RouteState& state, uint64_t now, bool direct, RouteDecision expected) { + RouteState next; + assert(planRoute(state, now, direct, next) == expected); + assert(isValidRouteState(next)); + ++checks; +} +static void repairCrc(uint8_t* image) { + route_detail::put32(image + 28, route_detail::crc(image, 28)); +} +int main() { + const uint32_t start = 1000; + RouteState empty, state, next; + assert(isValidRouteState(empty)); + decision(empty, start, true, RouteDecision::Direct); + assert(planRoute(empty, start, false, state) == RouteDecision::Wait); + assert(state.outage_since == start && (state.flags & kOutageActive)); + for (uint32_t offset : {1u, 60u, 1800u, 21540u}) { + assert(noteFailure(state, start + offset, next)); state = next; + assert(state.outage_since == start); + } + decision(state, start + 21599, false, RouteDecision::Wait); + decision(state, start + 21540, false, RouteDecision::Wait); + decision(state, start + 21599, true, RouteDecision::Direct); + decision(state, start + 21600, false, RouteDecision::Flood); + decision(state, start + 21600, true, RouteDecision::Flood); + assert(reserveFlood(state, start + 21600, next)); + assert(state.last_flood == 0 && next.last_flood == start + 21600); + state = next; + decision(state, start + 43199, false, RouteDecision::Wait); + decision(state, start + 43200, false, RouteDecision::Flood); + assert(!reserveFlood(state, start + 43199, next)); + assert(next.last_flood == state.last_flood); + + // A repeater delays the recovery gate, without restarting the target outage. + RouteState repeater; + assert(noteFailure(empty, start, repeater)); + assert(noteRepeater(repeater, start + 21599, next)); repeater = next; + assert(repeater.outage_since == start); + decision(repeater, start + 21600, false, RouteDecision::Wait); + decision(repeater, start + 43198, false, RouteDecision::Wait); + decision(repeater, start + 43199, false, RouteDecision::Flood); + + // The exact four-day boundary permanently suppresses automatic flooding. + RouteState cutoff; + assert(noteFailure(empty, start, cutoff)); + decision(cutoff, start + 342000, false, RouteDecision::Flood); + decision(cutoff, start + 345599, false, RouteDecision::Flood); + assert(planRoute(cutoff, start + 345600, false, next) == RouteDecision::Locked); + cutoff = next; + assert(cutoff.flags & kFloodLocked); + decision(cutoff, start + 345600, true, RouteDecision::Direct); + assert(!reserveFlood(cutoff, start + 345600, next)); + assert(noteRepeater(cutoff, start + 400000, next)); cutoff = next; + assert(cutoff.outage_since == start && (cutoff.flags & kFloodLocked)); + assert(noteFailure(cutoff, start + 500000, next)); cutoff = next; + decision(cutoff, start + 600000, false, RouteDecision::Locked); + assert(noteAuthenticatedReply(cutoff, start + 600000, next)); cutoff = next; + assert(!cutoff.flags && !cutoff.outage_since && cutoff.last_target_reply == start + 600000); + decision(cutoff, start + 600001, true, RouteDecision::Direct); + assert(noteFailure(cutoff, start + 600001, next)); cutoff = next; + decision(cutoff, start + 621600, false, RouteDecision::Wait); + decision(cutoff, start + 621601, false, RouteDecision::Flood); + + // A successful direct response cannot reset the global six-hour flood budget. + assert(noteAuthenticatedReply(state, start + 21601, next)); state = next; + assert(state.last_flood == start + 21600); + assert(noteFailure(state, start + 21602, next)); state = next; + decision(state, start + 43201, false, RouteDecision::Wait); + decision(state, start + 43202, false, RouteDecision::Flood); + + // Invalid and backward clocks latch a conservative fault, including after + // reboot. Hearing a repeater is never an unlock event. + assert(!noteFailure(state, 0, next)); + assert(next.flags & kClockFault); RouteState clock = next; + decision(clock, start + 80000, false, RouteDecision::InvalidClock); + decision(clock, start + 80000, true, RouteDecision::Direct); + assert(noteRepeater(clock, start + 80000, next)); clock = next; + assert(clock.flags & kClockFault); + assert(!noteAuthenticatedReply(clock, start + 79999, next)); + assert(next.flags & kClockFault); + assert(noteAuthenticatedReply(clock, start + 80000, next)); + assert(!next.flags); + decision(state, state.latest_observed - 1, true, RouteDecision::InvalidClock); + decision(empty, uint64_t(UINT32_MAX) + 1, false, RouteDecision::InvalidClock); + decision(empty, UINT64_MAX, true, RouteDecision::InvalidClock); + RouteState upper; + assert(noteFailure(empty, UINT32_MAX - 21600, upper)); + decision(upper, UINT32_MAX - 1, false, RouteDecision::Wait); + decision(upper, UINT32_MAX, true, RouteDecision::Flood); + assert(reserveFlood(upper, UINT32_MAX, next)); + decision(next, 1, false, RouteDecision::InvalidClock); + + // Explicit little-endian format, independently calculated by Python. + RouteState golden; + golden.flags = kOutageActive; + golden.outage_since = 0x01020304; + golden.last_target_reply = 0x01020300; + golden.last_repeater = 0x01020305; + golden.last_flood = 0x01020306; + golden.latest_observed = 0x01020307; + uint8_t image[kRouteStateImageSize], expected[] = {@GOLDEN@}; + assert(encodeRouteState(golden, image, sizeof(image))); + assert(memcmp(image, expected, sizeof(image)) == 0); + RouteState restored; + assert(decodeRouteState(image, sizeof(image), restored)); + decision(restored, golden.latest_observed + 21600, false, RouteDecision::Flood); + assert(!encodeRouteState(golden, nullptr, sizeof(image))); + assert(!encodeRouteState(golden, image, sizeof(image) - 1)); + assert(!decodeRouteState(nullptr, sizeof(image), restored)); + assert(restored.flags & kClockFault); + for (size_t length = 0; length < sizeof(image); ++length) { + assert(!decodeRouteState(image, length, restored)); + assert(restored.flags & kClockFault); ++checks; + } + for (size_t position = 0; position < sizeof(image); ++position) { + uint8_t damaged[kRouteStateImageSize]; memcpy(damaged, image, sizeof(image)); + damaged[position] ^= 1; + assert(!decodeRouteState(damaged, sizeof(damaged), restored)); ++checks; + } + for (uint8_t position : {3, 4, 5, 6, 7}) { + uint8_t damaged[kRouteStateImageSize]; memcpy(damaged, image, sizeof(image)); + damaged[position] = position == 3 ? 2 : (position == 4 ? 0x80 : 1); repairCrc(damaged); + assert(!decodeRouteState(damaged, sizeof(damaged), restored)); ++checks; + } + RouteState invalid = golden; + invalid.outage_since = 0; assert(!isValidRouteState(invalid)); + assert(!encodeRouteState(invalid, image, sizeof(image))); + decision(invalid, start, false, RouteDecision::InvalidState); + invalid = golden; invalid.last_target_reply = golden.latest_observed; + assert(!isValidRouteState(invalid)); + invalid = golden; invalid.last_repeater = golden.latest_observed + 1; + assert(!isValidRouteState(invalid)); + invalid = empty; invalid.flags = kFloodLocked; assert(!isValidRouteState(invalid)); + invalid = empty; invalid.last_flood = 1; assert(!isValidRouteState(invalid)); + printf("PASS: %u tracker route boundary and serialization checks\n", checks); +} +''' + + +class TrackerRoutePolicyTest(unittest.TestCase): + def test_real_route_policy_and_explicit_state_image(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("a host C++ compiler is required") + payload = b"TRP\x01\x01\0\0\0" + struct.pack( + "<5I", 0x01020304, 0x01020300, 0x01020305, 0x01020306, 0x01020307) + golden = payload + struct.pack("setGPSPowerSaving(false); + return; + } bool gps_user_enabled = isGpsTelemetryUserEnabled(); _nmea->setGPSPowerSaving(enabled && gps_user_enabled); if (!gps_user_enabled) return;