diff --git a/.gitignore b/.gitignore index 50631d890c..cc2719f7b6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,8 @@ cmake-* compile_commands.json .venv/ venv/ + +# local working artifacts from the water-level node work +*.c25bak +*.bak +scratch/ diff --git a/docs/BOM.md b/docs/BOM.md new file mode 100644 index 0000000000..65e034181f --- /dev/null +++ b/docs/BOM.md @@ -0,0 +1,101 @@ +# Bill of materials + +This bill of materials (BOM) covers one complete water-level sensor node plus the receiver used during development. The node measures the distance to a water surface with an ultrasonic rangefinder and reports readings over LoRa (Long Range), a low-power radio scheme that carries small packets for kilometers. Prices were checked in July 2026 against the linked pages; treat them as approximate and expect drift. + +![Complete node: solar lid, enclosure, antenna, and the sonar in its PVC mount](images/node_complete_solar.jpg) + +## Core electronics + +**1. Rook v0.4 carrier board (Don Blair / PVOS)** + +The custom printed circuit board (PCB) that ties everything together: it hosts the microcontroller module and the radio module, breaks out the sensor and display connections, and includes the power-gating transistor that switches the sonar on and off. Open hardware; the KiCAD design files live in the repository under `hardware/`. + +- Source: https://github.com/p-v-o-s/rook (verified July 2026; public repo with `hardware/`, `firmware/v_0.4`, and `plate/` directories) +- Price: not sold assembled. Fabricating the bare board from the KiCAD files at a prototype service such as JLCPCB or OSH Park typically runs $5 to $30 for a small batch, typical range based on standard two-layer prototype pricing, not a quote. You then hand-solder the modules and connectors. +- Caveats: board silkscreen on v0.4 has known labeling errors (the top connector's GND and 3V labels are swapped), so wire against the schematic, not the silkscreen. + +![Rook v0.4 with OLED, radio module, and nRF52840 module fitted](images/rook_v04_board.jpg) + +**2. SuperMini nRF52840 microcontroller module (Nice!Nano-compatible, TENSTAR "red board" recommended)** + +The brains of the node: a small board in the Pro Micro footprint carrying a Nordic nRF52840 microcontroller unit (MCU) with built-in Bluetooth, a lithium battery charger, and a USB-C port for programming. It plugs into the Rook carrier. + +- Source (verified): https://www.tindie.com/products/adz1122/supermini-nrf52840-development-board-for-nicenano/ at $9.90 as of July 2026 +- Source (cheaper, unverified fetch): TENSTAR listings on AliExpress, typically $3 to $6 per board. AliExpress blocks anonymous page fetches, so listings and prices there could not be machine-verified. +- Caveats: this clone family has a documented power defect. Early batches leak about 700 microamps in deep sleep because of a wrong pull-up resistor on the power-control pin, versus about 20 microamps on a genuine Nice!Nano. The joric/nrfmicro wiki documents the defect and the fix, and notes that black and red TENSTAR boards made from 2025 onward use 500 kilo-ohm or larger resistors (leak around 6 microamps), with red boards from late 2024 also carrying an updated low-dropout regulator (LDO). Buy the TENSTAR red-board variant, or be prepared to swap one resistor. Reference: https://github.com/joric/nrfmicro/wiki/Alternatives (verified July 2026). + +**3. Seeed Studio Wio-SX1262 LoRa radio module** + +The radio that actually transmits the water-level readings. It is built around the Semtech SX1262 transceiver chip and solders onto the Rook carrier board. Identification is from the Rook schematic itself: the radio symbol and footprint in `rook.kicad_sch` are `sweet-p:wio-sx1262` / `wio-sx1262:wio-sx1262-extended`, i.e. this exact module. + +- Source: https://www.seeedstudio.com/Wio-SX1262-Wireless-Module-p-5981.html at $4.29 as of July 2026 +- Caveats: order the band matching your region (US915 for North America, EU868 for Europe). You also need a matching antenna; the module uses a small coaxial connector, so budget a few dollars for a 915 MHz antenna and pigtail if your kit does not include one. + +## Sensor + +![MaxBotix MB7388 ultrasonic rangefinder](images/mb7388_sensor.jpg) + +**4. MaxBotix MB7388 ultrasonic rangefinder (HRXL-MaxSonar-WR family)** + +The actual water-level sensor. It hangs above the water, pings ultrasonically, and reports the distance to the surface once per reading, from 500 mm out to 10 m with millimeter resolution. The housing is IP67 weather resistant (sealed against rain and temporary immersion) and threads into standard 3/4 inch PVC pipe fittings, which makes mounting easy. The node reads its TTL (transistor-transistor logic) serial output, a simple one-wire-plus-ground data stream, through the microcontroller's UART (Universal Asynchronous Receiver-Transmitter). + +- Product page: https://maxbotix.com/products/mb7388 at $109.95 as of July 2026 (page title: "MB7388 HRXL-MaxSonar-WRMLT") +- Family overview: https://maxbotix.com/pages/hrxl-maxsonar-wr-ultrasonic-sensor-line (verified July 2026) +- Caveats: this is the single most expensive part of the node. Shorter-range siblings in the same family (7.5 m, 5 m) are cheaper if your deployment does not need the full 10 m. The sensor's serial output idles at its supply voltage; since this build powers the sensor from the battery rail, put the series resistor from item 9 in the data line to protect the 3.3 V microcontroller input. + +## Display and enclosure + +**5. 0.96 inch SSD1306 OLED display, 128x64, I2C** + +A small organic light-emitting diode (OLED) screen that shows live range readings and battery voltage in the field, which makes install-time sanity checks much easier. The firmware drives a 128x64 panel with the SSD1306 controller over I2C (Inter-Integrated Circuit, a two-wire data bus), confirmed by the display constructor in the firmware source (`Adafruit_SSD1306 oled(128, 64, ...)`). + +- Reference source: https://www.adafruit.com/product/326 at $17.50 as of July 2026 +- Caveats: functionally identical generic modules are everywhere on Amazon and AliExpress for $3 to $6, typical range based on common multi-pack listings, not a fetched price. Any "0.96 inch 128x64 SSD1306 I2C" module with a 4-pin header (VCC, GND, SCL, SDA) works. Check the I2C address (0x3C is typical) against the firmware. + +**6. MAKERELE MKMTY-151007 waterproof junction box, 150x100x70 mm, clear lid** + +The weatherproof housing for the electronics. The clear hinged lid lets you read the OLED without opening the box. + +- Source: https://www.amazon.com/dp/B09CMJQ921 (URL verified July 2026; resolves to "MAKERELE ABS Plastic Small Outdoor Waterproof Box Clear Hinged Shell ... 5.9x3.9x2.8 inch (150x100x70mm)") +- Price: Amazon did not expose the price to an anonymous page fetch, so no verified number; comparable clear-lid ABS boxes this size list in the $10 to $20 range on Amazon, typical range, not a fetched price. +- Caveats: you will drill it for the sensor mount and antenna, so buy a spare. Use cable glands or PVC fittings to keep the holes weatherproof. + +## Power + +**7. Single-cell LiPo battery, 3.7 V, JST-PH connector** + +A rechargeable lithium polymer (LiPo) cell powers the whole node; the microcontroller module's onboard charger tops it up over USB. Capacity is your call: bigger cell, longer time between charges. + +- Sourcing note: any 3.7 V single-cell pack with a JST-PH 2-pin connector works. Adafruit's lithium-ion battery category is a reliable US source with correct connector polarity: https://www.adafruit.com/category/574 (verified July 2026; examples: 1200 mAh at $9.95, 2500 mAh at $14.95) +- Caveats: JST-PH polarity is not standardized across vendors. Cheap cells from marketplaces sometimes arrive with reversed pins, which can destroy the board on first plug-in. Meter the connector against the board's markings before connecting anything. + +## Receiver / gateway (development) + +**8. Heltec WiFi LoRa 32 V4** + +The board used on the receiving end during development: an ESP32-S3 microcontroller with WiFi plus the same SX1262 LoRa radio as the node, and its own small display. One of these on your desk gives you a live view of what the node is transmitting. + +- Source: https://heltec.org/project/wifi-lora-32-v4/ at $17.90 to $27.50 as of July 2026, depending on band, display, and warehouse options +- Caveats: pick the LoRa band matching the node's radio. + +## Miscellaneous + +**9. Small parts** + +- 1 kilo-ohm resistor, 1/4 W, in series with the sonar's TTL serial data line into the microcontroller. Protects the 3.3 V input from the sensor's battery-level idle-high voltage. Pennies each; any resistor assortment covers it. +- Hookup wire, 24 to 26 AWG stranded, for sensor, display, and battery runs. +- JST-PH connector pigtails or a crimp kit, so the battery and sensor unplug for service. +- M3 machine screws, nuts, and standoffs to mount the board stack and display inside the enclosure. +- No individual prices verified for these; as a class they total a few dollars from any electronics assortment or existing parts bin. + +## Cost summary + +Verified prices alone (sensor $109.95, radio $4.29, MCU module $9.90 Tindie or about $4 AliExpress, display $17.50 Adafruit or about $4 generic, receiver about $20, battery about $10 to $15) put a single node in the neighborhood of $160 to $180 with name-brand parts, or closer to $140 sourcing the module and display from marketplace vendors, plus PCB fabrication and the enclosure. The MB7388 dominates the cost. + +## Where this is headed + +The sketch below is the deployment shape this hardware serves: sensor nodes at +the water, a hilltop repeater, and a gateway with an internet connection +forwarding readings out. + +![Deployment topology sketch](images/deployment_topology_sketch.jpg) diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md new file mode 100644 index 0000000000..ea92cf4aec --- /dev/null +++ b/docs/DEVLOG.md @@ -0,0 +1,81 @@ +# Development log: the water-level field node + +This documents the work, June-July 2026, of turning the v3-ultrasonic +companion sensor into a deployable, duty-cycled water-level node: +a MaxBotix MB7388 ultrasonic rangefinder measures the distance down to a water +surface, and the reading travels over LoRa (Long Range, a low-power radio scheme +that carries small packets for kilometers) through a mesh network to a gateway. + +Upstream baseline for everything here: + +``` +615ebd4531bf6536e21f1316d887506f8917d086 "version 3+" +``` + +The v3-ultrasonic example already supplied the hard half: the mesh layer, the +acknowledgement state machine, the flood fallback and the non-forwarding leaf +behavior. None of that was re-derived. What this work adds is the sensor +integration, the power work needed to run the thing on a battery +in the field, and the bench validation that caught what the first dock-side +deployment would otherwise have found the hard way. + +## What changed, and where + +The changes live in the application and variant layer: + +- `variants/sonar_field_node/` - the deployment firmware: a mode state machine + (POST, STATUS, SLEEP, WAKE, MEASURE, TRANSMIT) with the sonar read, battery + telemetry, transmit-with-retry and a link-health LED. +- `variants/sonar_oled_demo/` - radio-free sonar bring-up, useful as a known-good + reference when the mesh is not the thing under test. +- `variants/sleep_test/` - a minimal sketch used to find where the sleep current + actually goes. +- `examples/v3-ultrasonic/` - additive debug instrumentation, behind build flags, + so the default build is unchanged. + +**The MeshCore library source is deliberately untouched.** At one point a small +sleep/wake seam was written into the dispatcher and then reverted in favour of an +application-side module (`variants/sonar_field_node/radio_lowpower.h`) that +achieves the same result without patching the library. That separation is +intentional and worth preserving. + +## How to read the commit history + +**The history here is a reconstruction, not archaeology.** The firmware was +developed on the bench between June and July 2026 and was not committed +incrementally at the time. The branches and pull requests were authored +afterwards, from dated engineering notes and instrument captures, to +present the work as an ordered and reviewable sequence. + +The commit dates are therefore not when the work happened, and the sequence is tidier +than the actual path was. Each pull request describes what was measured and when, +including the wrong turns, because several of the conclusions along the way were +wrong and had to be retracted. Those retractions are part of the record on +purpose. A clean narrative that hides them would be less useful to anyone +repeating this work. + +## Status + +Bench-verified end to end: sonar ranging, gated sensor power, radio sleep with +wake, a full measure/transmit/acknowledge/sleep cycle, and a validation pass +that ran the firmware the way the field will run it (battery-first cold boot, +sensor attached, buttons pressed by a human) and fixed the six defects that +pass exposed. + +The power question is settled, though not the way we hoped. The sleep floor +came down from 8 mA to about 1.05 mA, and there it stops: a measurement +campaign that stripped the firmware to nothing and cut every subsystem in turn +showed the remaining current belongs to the microcontroller platform and this +board class, not to anything the application does. Reaching microamps needs +System-OFF (the chip's deep power-down state) plus an external wake source, +which is a hardware change, not a software setting. The pull requests state +the measured numbers and how they were obtained rather than claiming a target +that was not reached. + +## Credit + +Base firmware and the ultrasonic adaptation: Don Blair, Edge Collective. +The mesh protocol work belongs to the MeshCore project. + +Development was assisted by Anthropic's Claude Code agentic platform, using +the Opus 4.8 and Fable 5 models. diff --git a/docs/images/deployment_topology_sketch.jpg b/docs/images/deployment_topology_sketch.jpg new file mode 100644 index 0000000000..d8b9549c5b Binary files /dev/null and b/docs/images/deployment_topology_sketch.jpg differ diff --git a/docs/images/joulescope_146uA.png b/docs/images/joulescope_146uA.png new file mode 100644 index 0000000000..8dce0761e5 Binary files /dev/null and b/docs/images/joulescope_146uA.png differ diff --git a/docs/images/js220_A_vs_B.png b/docs/images/js220_A_vs_B.png new file mode 100644 index 0000000000..b310172085 Binary files /dev/null and b/docs/images/js220_A_vs_B.png differ diff --git a/docs/images/js220_OLED_REMOVED.png b/docs/images/js220_OLED_REMOVED.png new file mode 100644 index 0000000000..7c05bf231f Binary files /dev/null and b/docs/images/js220_OLED_REMOVED.png differ diff --git a/docs/images/js220_SLEEP.png b/docs/images/js220_SLEEP.png new file mode 100644 index 0000000000..11f376dfac Binary files /dev/null and b/docs/images/js220_SLEEP.png differ diff --git a/docs/images/js220_SLEEP_V2.png b/docs/images/js220_SLEEP_V2.png new file mode 100644 index 0000000000..77fe521df6 Binary files /dev/null and b/docs/images/js220_SLEEP_V2.png differ diff --git a/docs/images/js220_SLEEP_V3.png b/docs/images/js220_SLEEP_V3.png new file mode 100644 index 0000000000..b1ebc16fa7 Binary files /dev/null and b/docs/images/js220_SLEEP_V3.png differ diff --git a/docs/images/js220_SLEEP_V4.png b/docs/images/js220_SLEEP_V4.png new file mode 100644 index 0000000000..cb283382cf Binary files /dev/null and b/docs/images/js220_SLEEP_V4.png differ diff --git a/docs/images/js220_don_gate_demo.png b/docs/images/js220_don_gate_demo.png new file mode 100644 index 0000000000..3289406dcf Binary files /dev/null and b/docs/images/js220_don_gate_demo.png differ diff --git a/docs/images/js220_sleepfloor_uart_release_v2.png b/docs/images/js220_sleepfloor_uart_release_v2.png new file mode 100644 index 0000000000..4d89d58ec3 Binary files /dev/null and b/docs/images/js220_sleepfloor_uart_release_v2.png differ diff --git a/docs/images/js220_uart_attribution_AB.png b/docs/images/js220_uart_attribution_AB.png new file mode 100644 index 0000000000..0afb184898 Binary files /dev/null and b/docs/images/js220_uart_attribution_AB.png differ diff --git a/docs/images/mb7388_sensor.jpg b/docs/images/mb7388_sensor.jpg new file mode 100644 index 0000000000..7d646d5961 Binary files /dev/null and b/docs/images/mb7388_sensor.jpg differ diff --git a/docs/images/node_complete_solar.jpg b/docs/images/node_complete_solar.jpg new file mode 100644 index 0000000000..da3a8d02fd Binary files /dev/null and b/docs/images/node_complete_solar.jpg differ diff --git a/docs/images/node_in_box.jpg b/docs/images/node_in_box.jpg new file mode 100644 index 0000000000..bd888163cc Binary files /dev/null and b/docs/images/node_in_box.jpg differ diff --git a/docs/images/rook_v04_board.jpg b/docs/images/rook_v04_board.jpg new file mode 100644 index 0000000000..f86ac18621 Binary files /dev/null and b/docs/images/rook_v04_board.jpg differ diff --git a/docs/images/sonar_gate_off_demo.png b/docs/images/sonar_gate_off_demo.png new file mode 100644 index 0000000000..a8ba8a4485 Binary files /dev/null and b/docs/images/sonar_gate_off_demo.png differ diff --git a/docs/images/sonar_node_current_battery.png b/docs/images/sonar_node_current_battery.png new file mode 100644 index 0000000000..b864fd59c1 Binary files /dev/null and b/docs/images/sonar_node_current_battery.png differ diff --git a/examples/v3-ultrasonic/companion_sensor/main.cpp b/examples/v3-ultrasonic/companion_sensor/main.cpp index 05eda32250..7abdce510c 100644 --- a/examples/v3-ultrasonic/companion_sensor/main.cpp +++ b/examples/v3-ultrasonic/companion_sensor/main.cpp @@ -16,9 +16,11 @@ #include #include +extern RADIO_CLASS radio; // RadioLib radio object (defined in target.cpp) -- for getDeviceErrors() + /* ---------------------------------- CONFIGURATION ------------------------------------- */ -#define FIRMWARE_VER_TEXT "companion_sensor v3-ultrasonic (build: May 2026) [v3 + MaxBotix MB7388 → distance_meters]" +#define FIRMWARE_VER_TEXT "companion_sensor v3-ultrasonic (build: Jul 2026) [v3 + MaxBotix MB7388 → distance_meters] [DEBUG: link-health LED (send=toggle, ACK=off, timeout=on) + OLED tx/ack counters + display always-on]" #ifndef LORA_FREQ #define LORA_FREQ 910.525 @@ -109,7 +111,7 @@ static float last_dist_m = 0; // meters, parsed from MaxBotix static float last_batt = 0; static bool has_reading = false; -// v3: last-send ACK status — surfaced on the OLED status page so a button-triggered +// v3: last-send ACK status -- surfaced on the OLED status page so a button-triggered // send shows whether the receiver actually acknowledged. enum AckStatus { ACK_STATUS_NONE = 0, @@ -149,6 +151,23 @@ class MyMesh : public BaseChatMesh, ContactVisitor { uint32_t last_ack_rt_ms; // round-trip ms of the most recent ACK (serial log only) unsigned long last_ack_at_millis; // millis() when the most recent OK ACK arrived + // debug: lifetime counters surfaced on the OLED. packets_tx = sensor packets + // handed to the radio; packets_ack = ACKs matched back. Watching the gap (and + // whether tx keeps climbing) makes the one-and-done wedge visible at a glance. + uint32_t packets_tx; + uint32_t packets_ack; + + // debug: link-health LED state. Three rules drive it: a send TOGGLES it + // (packet in flight), a positive ACK forces it OFF (healthy), a timeout forces + // it ON (problem). Net: healthy link rests dark with a brief per-cycle ON + // pulse; a failing link sits lit with a short OFF blink at each resend. + bool led_on; + void ledWrite(bool on) { + led_on = on; + digitalWrite(LED_PIN, (on == (LED_STATE_ON != 0)) ? HIGH : LOW); + } + void ledToggle() { ledWrite(!led_on); } + void loadContacts() { if (_fs->exists("/contacts")) { #if defined(RP2040_PLATFORM) @@ -445,6 +464,7 @@ class MyMesh : public BaseChatMesh, ContactVisitor { int sendSensorReading() { last_send_successes = 0; last_send_attempts = 0; + uint16_t err_before = radio.getDeviceErrors(); if (target_count == 0) { Serial.println(" No targets set, skipping send (use 'target add ')"); @@ -506,6 +526,12 @@ class MyMesh : public BaseChatMesh, ContactVisitor { Serial.print(") "); } + // debug: toggle the LED the instant BEFORE we hit the radio (packet in + // flight). processAck() will drive it OFF on a good ACK, onSendTimeout() + // ON on a miss. If the SX1262 wedges inside the send, the LED will have + // toggled but no "sent" line prints and packets_tx won't advance. + ledToggle(); + uint32_t est_timeout; int result = sendMessage(*recipient, getRTCClock()->getCurrentTime(), 0, msg, expected_ack_crc, est_timeout); if (result == MSG_SEND_FAILED) { @@ -516,10 +542,14 @@ class MyMesh : public BaseChatMesh, ContactVisitor { last_ack_status = ACK_STATUS_PENDING; Serial.println(result == MSG_SEND_SENT_FLOOD ? "sent (FLOOD)" : "sent (DIRECT)"); last_send_successes++; + packets_tx++; // debug: count packets the radio actually accepted } } Serial.printf(" (%d/%d sent)\n", last_send_successes, last_send_attempts); + // Probe the SX1262's own fault register around the transmit. 0x0000 = clean; + // bits flip if the TX surge wedged it: 0x20=XOSC_START 0x40=PLL_LOCK 0x100=PA_RAMP. + Serial.printf(" [RADIO_ERR] before=0x%04X after=0x%04X\n", err_before, radio.getDeviceErrors()); return last_send_successes; } @@ -543,8 +573,10 @@ class MyMesh : public BaseChatMesh, ContactVisitor { AckStatus getLastAckStatus() const { return last_ack_status; } uint32_t getLastAckRoundTripMs() const { return last_ack_rt_ms; } unsigned long getLastAckAtMillis() const { return last_ack_at_millis; } + uint32_t getPacketsTx() const { return packets_tx; } + uint32_t getPacketsAck() const { return packets_ack; } - // Read the sensor + battery and cache the values WITHOUT sending — used by + // Read the sensor + battery and cache the values WITHOUT sending -- used by // the OLED's Send page so the user can see a fresh reading before deciding // to long-press send. Blocks for up to ULTRASONIC_READ_TIMEOUT_MS. void refreshReading() { updateSensorReadings(); } @@ -624,6 +656,8 @@ class MyMesh : public BaseChatMesh, ContactVisitor { last_ack_rt_ms = _ms->getMillis() - last_msg_sent; last_ack_at_millis = millis(); last_ack_status = ACK_STATUS_OK; + packets_ack++; // debug: count matched ACKs for the OLED tx/ack line + ledWrite(false); // debug: good ACK -> LED OFF (link healthy) Serial.printf(" Got ACK! (round trip: %lu millis)\n", (unsigned long)last_ack_rt_ms); expected_ack_crc = 0; return NULL; @@ -662,11 +696,12 @@ class MyMesh : public BaseChatMesh, ContactVisitor { void onSendTimeout() override { // If processAck already cleared expected_ack_crc, the ACK arrived just as the - // timeout fired — treat this as a late-but-no-op timeout and skip invalidation. + // timeout fired -- treat this as a late-but-no-op timeout and skip invalidation. // Without this guard, a slow-but-successful FLOOD round-trip would invalidate // the path we just learned from the receiver's PATH-return. if (expected_ack_crc == 0) return; last_ack_status = ACK_STATUS_TIMEOUT; + ledWrite(true); // debug: no ACK -> LED ON (link problem); next resend blinks it off // v2: on timeout, invalidate the cached out_path for the most recent target. // The NEXT scheduled send will flood, which forces a fresh path discovery on @@ -704,6 +739,9 @@ class MyMesh : public BaseChatMesh, ContactVisitor { last_ack_status = ACK_STATUS_NONE; last_ack_rt_ms = 0; last_ack_at_millis = 0; + packets_tx = 0; + packets_ack = 0; + led_on = false; node_id = 1; } @@ -842,6 +880,15 @@ class MyMesh : public BaseChatMesh, ContactVisitor { } else if (strcmp(command, "send") == 0) { Serial.println(" Sending sensor reading now..."); sendSensorReading(); + } else if (memcmp(command, "gate", 4) == 0) { + // Coupling probe: D5/GPS_EN is the low-side sonar gate (Q2). Toggle it + // live and watch the radio -- 'gate on' should break ACKs, 'gate off' should + // restore them (or not, if the disturbance latches). Characterizes the + // GPS_EN -> radio-RX coupling with the radio otherwise fully working. + const char* a = command + 4; while (*a == ' ') a++; + if (strcmp(a, "on") == 0) { digitalWrite(5, HIGH); Serial.println(" GATE D5/GPS_EN = ON"); } + else if (strcmp(a, "off") == 0) { digitalWrite(5, LOW); Serial.println(" GATE D5/GPS_EN = OFF"); } + else Serial.printf(" GATE D5/GPS_EN = %s\n", digitalRead(5) ? "ON" : "OFF"); } else if (memcmp(command, "list", 4) == 0) { int n = 0; if (command[4] == ' ') { @@ -940,6 +987,17 @@ class MyMesh : public BaseChatMesh, ContactVisitor { BaseChatMesh::loop(); // Check if it's time to send a sensor reading +#ifdef SEND_TIMER_MILLIS + // millis()-based gate: direct test of whether the RTC clock (getCurrentTime) + // fails to advance without GPS/NTP, capping repeat sends at one boot packet. + static unsigned long last_send_ms = 0; + if (last_send_ms == 0 || (millis() - last_send_ms) >= (unsigned long)send_interval_secs * 1000UL) { + updateSensorReadings(); + Serial.printf("[SENSOR] node_id=%u dist=%.3fm batt=%.2fV\n", (unsigned)node_id, last_dist_m, last_batt); + sendSensorReading(); + last_send_ms = millis(); + } +#else uint32_t now = getRTCClock()->getCurrentTime(); if (now > 0 && (last_send_time == 0 || (now - last_send_time) >= send_interval_secs)) { updateSensorReadings(); @@ -947,6 +1005,7 @@ class MyMesh : public BaseChatMesh, ContactVisitor { sendSensorReading(); last_send_time = now; } +#endif // Serial command handling int len = strlen(command); @@ -1051,34 +1110,35 @@ static void renderStatusPage(DisplayDriver& d) { d.setCursor(0, 46); d.print(buf); - // v3: last ACK status. After a button-triggered send, this is the user's - // "did the receiver hear me?" feedback: sending… → ACK Xs/Xm/Xh ago / no ACK. - // Falls back to the send interval when no send has happened this boot. + // debug: transmit/ack counters + a short last-ACK freshness token, all on the + // bottom line so a wedge reads at a glance. T = packets handed to the radio, + // A = ACKs matched back. The one-and-done signature is T frozen at 1 with A=0. + char ack_tok[10]; switch (the_mesh.getLastAckStatus()) { case ACK_STATUS_OK: { - unsigned long age_ms = millis() - the_mesh.getLastAckAtMillis(); - unsigned long age_s = age_ms / 1000UL; + unsigned long age_s = (millis() - the_mesh.getLastAckAtMillis()) / 1000UL; if (age_s < 60) { - snprintf(buf, sizeof(buf), "Last: ACK %lus ago", age_s); + snprintf(ack_tok, sizeof(ack_tok), "%lus", age_s); } else if (age_s < 3600) { - snprintf(buf, sizeof(buf), "Last: ACK %lum ago", age_s / 60); - } else if (age_s < 86400) { - snprintf(buf, sizeof(buf), "Last: ACK %luh ago", age_s / 3600); + snprintf(ack_tok, sizeof(ack_tok), "%lum", age_s / 60); } else { - snprintf(buf, sizeof(buf), "Last: ACK %lud ago", age_s / 86400); + snprintf(ack_tok, sizeof(ack_tok), "%luh", age_s / 3600); } break; } case ACK_STATUS_PENDING: - snprintf(buf, sizeof(buf), "Last: sending..."); + StrHelper::strncpy(ack_tok, "pend", sizeof(ack_tok)); break; case ACK_STATUS_TIMEOUT: - snprintf(buf, sizeof(buf), "Last: no ACK"); + StrHelper::strncpy(ack_tok, "noack", sizeof(ack_tok)); break; default: - snprintf(buf, sizeof(buf), "Every %ds", (int)the_mesh.getSendInterval()); + StrHelper::strncpy(ack_tok, "--", sizeof(ack_tok)); break; } + snprintf(buf, sizeof(buf), "T:%lu A:%lu %s", + (unsigned long)the_mesh.getPacketsTx(), + (unsigned long)the_mesh.getPacketsAck(), ack_tok); d.setCursor(0, 56); d.print(buf); } @@ -1162,7 +1222,7 @@ static void handleButtonEvents() { next_display_refresh = 0; // force immediate refresh // Landing on the Send page kicks off an immediate sensor read so the page - // shows a live preview — easy way to verify the sensor is alive without + // shows a live preview -- easy way to verify the sensor is alive without // sending anything over the radio. if (current_page == PAGE_SEND) { the_mesh.refreshReading(); @@ -1173,7 +1233,7 @@ static void handleButtonEvents() { uint8_t succ = the_mesh.getLastSendSuccesses(); uint8_t att = the_mesh.getLastSendAttempts(); if (att == 0) { - // No `target add …` has been done yet — make this very explicit. + // No `target add …` has been done yet -- make this very explicit. showAlert("Target not set"); } else { // Switch to status page so the user can watch the "Last: …" line cycle @@ -1204,11 +1264,14 @@ static void displayLoop() { handleButtonEvents(); renderDisplay(); - // Auto-off after inactivity - if (display.isOn() && last_button_activity > 0 && - (millis() - last_button_activity) > DISPLAY_AUTO_OFF_MS) { - display.turnOff(); - } + // debug: auto-off is DISABLED for this build so the tx/ack counters stay + // visible across the room during a link watch. (Production sonar_field_node + // keeps its own sleep/blank logic; this is the always-on bench node.) + // Auto-off after inactivity -- intentionally left off: + // if (display.isOn() && last_button_activity > 0 && + // (millis() - last_button_activity) > DISPLAY_AUTO_OFF_MS) { + // display.turnOff(); + // } } #endif // DISPLAY_CLASS @@ -1229,12 +1292,23 @@ void setup() { Serial.println("================================================"); Serial.println("setup: Serial up."); + // debug: onboard LED is our hardware TX heartbeat (P0.15, active-high). We + // toggle it just before every radio send so a transmit attempt is visible + // even if the USB console/OLED freezes -- a frozen LED == a stalled TX path. + pinMode(LED_PIN, OUTPUT); + digitalWrite(LED_PIN, LED_STATE_ON ? LOW : HIGH); // start off + + // Probe: D5/GPS_EN = sonar low-side gate (Q2). Start OFF (radio clean); + // toggle live via the 'gate on|off' serial command to watch the coupling. + pinMode(5, OUTPUT); + digitalWrite(5, LOW); + Serial.println("setup: board.begin()..."); board.begin(); Serial.println("setup: radio_init()..."); if (!radio_init()) { - Serial.println("setup: radio_init() FAILED — halting."); + Serial.println("setup: radio_init() FAILED -- halting."); halt(); } Serial.println("setup: radio OK."); @@ -1287,7 +1361,7 @@ void setup() { Serial.println("setup: filesystem.begin()..."); #if defined(NRF52_PLATFORM) InternalFS.begin(); - Serial.println("setup: the_mesh.begin() — if first-boot, will block here on 'Press ENTER to generate key:'"); + Serial.println("setup: the_mesh.begin() -- if first-boot, will block here on 'Press ENTER to generate key:'"); the_mesh.begin(InternalFS); #elif defined(RP2040_PLATFORM) LittleFS.begin(); diff --git a/examples/v3-ultrasonic/companion_sensor_receiver/main.cpp b/examples/v3-ultrasonic/companion_sensor_receiver/main.cpp index 53c871be74..129fca31bc 100644 --- a/examples/v3-ultrasonic/companion_sensor_receiver/main.cpp +++ b/examples/v3-ultrasonic/companion_sensor_receiver/main.cpp @@ -318,7 +318,7 @@ class ReceiverMesh : public BaseChatMesh, ContactVisitor { return true; } } - // Not a duplicate — record in round-robin slot. + // Not a duplicate -- record in round-robin slot. memcpy(dup_cache[dup_cache_next].sender_prefix, sender_pub, 4); dup_cache[dup_cache_next].sender_timestamp = sender_timestamp; dup_cache[dup_cache_next].seen_millis = now; @@ -504,7 +504,7 @@ class ReceiverMesh : public BaseChatMesh, ContactVisitor { if (is_duplicate && pkt->isRouteDirect()) { ContactInfo* c = lookupContactByPubKey(from.id.pub_key, PUB_KEY_SIZE); if (c && c->out_path_len != OUT_PATH_UNKNOWN) { - Serial.printf(" DUP: duplicate of recent direct msg from %s — invalidating reverse path " + Serial.printf(" DUP: duplicate of recent direct msg from %s -- invalidating reverse path " "(was len=%d). ACK will FLOOD this time.\n", from.name, (int)c->out_path_len); c->out_path_len = OUT_PATH_UNKNOWN; @@ -1066,6 +1066,8 @@ void setup() { radio_set_params(LORA_FREQ, LORA_BW, LORA_SF, LORA_CR); radio_set_tx_power(LORA_TX_POWER); + Serial.printf("[RADIO] freq=%.3f MHz bw=%.1f kHz sf=%d cr=4/%d txpwr=%d\n", + (double)LORA_FREQ, (double)LORA_BW, (int)LORA_SF, (int)LORA_CR, (int)LORA_TX_POWER); the_mesh.beginWifi(); the_mesh.showWelcome(); diff --git a/notes/meshcore_architecture_map.md b/notes/meshcore_architecture_map.md new file mode 100644 index 0000000000..6a9b7816ca --- /dev/null +++ b/notes/meshcore_architecture_map.md @@ -0,0 +1,90 @@ +# MeshCore architecture map (Rook / SX1262 build) + +Reverse-engineered July 2026 while working out how to sleep the radio for the +duty-cycled sonar field node. Read this before touching MeshCore internals; the +key trap is that the radio is owned by a base class, not composable away. + +## The two inheritance chains + +**Radio side (a `mesh::Radio` implementation):** +``` +mesh::Radio (abstract iface, src/Dispatcher.h) + <- RadioLibWrapper (src/helpers/radiolib/RadioLibWrappers.{h,cpp}) + <- CustomSX1262Wrapper (src/helpers/radiolib/CustomSX1262Wrapper.h) == WRAPPER_CLASS +``` +The concrete instance is `radio_driver` (`WRAPPER_CLASS radio_driver(radio, board);` +in `variants/rook/target.cpp`), wrapping the RadioLib `radio` object (`extern RADIO_CLASS radio`). + +**Mesh side (the "system" -- this is the trap):** +``` +Dispatcher (src/Dispatcher.{h,cpp}) -- OWNS the radio + the radio loop + <- mesh::Mesh (src/Mesh.{h,cpp}) -- routing / protocol + <- BaseChatMesh (src/helpers/BaseChatMesh.*) -- contacts, chat, ACK tracking + <- MyMesh (the app, e.g. variants/sonar_field_node) == the_mesh +``` +So **`Mesh : public Dispatcher`** -- the mesh protocol IS-A radio dispatcher. You +cannot peel "packet/routing logic" off "radio loop"; they are one object. There is +no fork-free way to run the radio yourself while reusing only the packet layer. + +## Who owns what + +- **Dispatcher** owns `mesh::Radio* _radio`, and its `loop()` services the radio every + call: `triggerNoiseFloorCalibrate()`, `_radio->loop()`, `isInRecvMode()`, `resetAGC()`, + send-complete handling, TX airtime/duty-cycle budget. It runs an **8s "radio stuck in + non-Rx" watchdog** (`radio_nonrx_start` -> `ERR_EVENT_STARTRX_TIMEOUT`). It ASSUMES the + radio is continuously live in a known state. +- **Mesh** adds packet routing/flood/direct + dedup. +- **BaseChatMesh** adds contacts, ACK-CRC tracking, the sensor/chat send helpers. +- **MainBoard** (`src/MeshCore.h`, impl e.g. `NRF52Board`) -- board/power/startup-reason; + `Bluefruit.begin()` runs here (SoftDevice active -> use `waitForEvent()`/`sd_app_evt_wait`, + never raw `__WFE`/RTC banging). +- **The app owns `loop()`** and chooses when to call `the_mesh.loop()`. So MeshCore is + ALREADY driven as a subsystem -- the app controls the cadence. What's missing is a + sleep seam. + +## The `mesh::Radio` interface (what the wrapper must implement) + +begin, recvRaw, getEstAirtimeFor, packetScore, startSendRaw, isSendComplete, +onSendFinished, loop, getNoiseFloor, triggerNoiseFloorCalibrate, resetAGC, +isInRecvMode, isReceiving, getLastRSSI, getLastSNR. +Wrapper (protected, not app-callable): `idle()` (= radio.standby()), `startRecv()` +(= radio.startReceive()). Public: `begin()`, `powerOff()` (= `_radio->sleep()`). + +## The radio-sleep trap, and the seam that was written then reverted + +Sleeping the radio at the app level deadlocks because: + +1. `Dispatcher::loop()` keeps polling the slept radio over SPI (BUSY never clears). +2. The Dispatcher's radio-liveness state is **private** -- `prev_isrecv_mode`, + `radio_nonrx_start`, `next_floor_calib_time`, `next_agc_reset_time`, `outbound` -- + so neither the app nor a subclass can re-sync it after a sleep. +3. `radio.sleep()` is a WARM sleep (config retained); wake = `radio.standby()` then + `startReceive()`. Calling `begin()` on a slept radio deadlocks the same way the + poll does. On nRF52 the USB serial console dies during all this and only a full + battery+USB power cycle recovers it (bench-only concern; the field node is + battery-only). + +**The seam (minimal patch, would be contributable upstream):** + +- Add `virtual void sleep()` / `virtual void wake()` to `mesh::Radio`; wrapper does + `sleep(){ _radio->sleep(); }`, `wake(){ _radio->standby(); startReceive(); }`. +- Add `Dispatcher::radioSleep()` / `radioWake()`: set an `_radio_asleep` flag (loop() + early-returns while set) and on wake reset `prev_isrecv_mode=true`, + `radio_nonrx_start=now`, `next_floor_calib_time=next_agc_reset_time=0`. +- App calls `the_mesh.radioSleep()` on sleep entry, `the_mesh.radioWake()` on wake. + +This seam was written and compiled, then **reverted**: the same result is +reachable without touching the library, because the app already controls when +`the_mesh.loop()` runs. The application-side module (`radio_lowpower.h`) sleeps +the RadioLib object directly and simply stops servicing the mesh while the radio +is asleep, so nothing polls it. The library-level seam remains the better +long-term answer and is worth proposing upstream as its own conversation. + +## Files + +- `src/Dispatcher.{h,cpp}` -- Radio iface + Dispatcher (radio loop, budget, watchdog). +- `src/Mesh.{h,cpp}` -- routing. +- `src/helpers/BaseChatMesh.*` -- contacts/chat/ACK. +- `src/helpers/radiolib/RadioLibWrappers.{h,cpp}` -- the wrapper. +- `variants/rook/target.cpp` -- `radio_driver`, `radio`, board wiring. +- `variants/sonar_field_node/` -- the app (mode machine, `the_mesh`). diff --git a/variants/gate_cmd/gate_cmd_main.cpp b/variants/gate_cmd/gate_cmd_main.cpp new file mode 100644 index 0000000000..a9a4897f24 --- /dev/null +++ b/variants/gate_cmd/gate_cmd_main.cpp @@ -0,0 +1,170 @@ +// gate_cmd -- battery voltage read + commanded (single-shot) ranging via pin4/D1. +// +// Adds two things to the proven low-side gate: +// 1. Battery voltage via the VBAT divider (Arduino pin 17 = P0.31 = AIN7, +// ADC_MULT 1.815 -- same as RookBoard.getBattMilliVolts). Reads ~4100 mV +// with the LiPo switched on, near 0 with the battery OFF -> the firmware can +// SEE "forgot the battery" / low battery instead of failing silently. +// 2. Commanded ranging: MB7388 pin4 (Ranging Start/Stop) wired to D1. Held LOW +// stops the free-run; a >20us HIGH pulse commands exactly one reading, which +// the sonar reports on pin5 (Serial1 RX / D0). We never UART-TX to the sonar, +// so D1 is reclaimed from Serial1 TX as a plain GPIO strobe. +// +// Each period: read+print battery, confirm pin4-low actually stopped the free-run +// (unsolicited frame count should be ~0), strobe once, read the single R#### back. +// unsolicited>0 => pin4 isn't controlling (strobe wiring / D1-reclaim failed); +// cmd->NO RESPONSE => strobe or read path broken. + +#include + +#define PIN_SONAR_GATE 5 // D5 = P0.24 = GPS_EN = Q2 gate (HIGH = sonar on) +#define PIN_S1_RX 0 // D0 = P0.08 (MB7388 pin5 via 10k) +#define PIN_STROBE 1 // D1 = P0.06 (MB7388 pin4) -- reclaimed from Serial1 TX +#define PIN_VBAT 17 // P0.31 = AIN7 (VBAT divider) +#define ADC_MULT 1.815f +#define USB_BAUD 115200 +#define SONAR_BAUD 9600 +#define STROBE_US 30 // >20us commands one reading +#define RESP_TIMEOUT_MS 250 +#define PERIOD_MS 3000 + +// build knobs (override in platformio.ini build_flags with -D): +#ifndef USE_MEDIAN +#define USE_MEDIAN 1 // 1 = median-filter N samples/measurement; 0 = raw single reading (debug) +#endif +#ifndef N_SAMPLES +#define N_SAMPLES 5 // samples per measurement when USE_MEDIAN +#endif + +static uint32_t g_no_target = 0; // cumulative 9999 (no-target) samples seen -- glitch-rate telemetry + +static uint16_t batt_mv() { + analogReadResolution(12); + uint32_t raw = 0; + for (int i = 0; i < 8; i++) raw += analogRead(PIN_VBAT); + raw /= 8; + return (uint16_t)(ADC_MULT * raw); +} + +// parse one 'Rdddd\r' frame within timeout; return mm, or -1 on timeout +static int read_frame(uint32_t timeout_ms) { + uint32_t t0 = millis(); + bool cap = false; char d[8]; int di = 0; + while (millis() - t0 < timeout_ms) { + while (Serial1.available()) { + char c = (char)Serial1.read(); + if (c == 'R') { cap = true; di = 0; } + else if (cap && c == '\r') { d[di] = 0; if (di >= 3) return atoi(d); cap = false; di = 0; } + else if (cap && di < 6 && c >= '0' && c <= '9') d[di++] = c; + else if (cap) { cap = false; di = 0; } + } + } + return -1; +} + +// count self-initiated frames over a window (should be ~0 with pin4 held low) +static unsigned count_unsolicited(uint32_t win_ms) { + uint32_t t0 = millis(); unsigned n = 0; + bool cap = false; char d[8]; int di = 0; + while (millis() - t0 < win_ms) { + while (Serial1.available()) { + char c = (char)Serial1.read(); + if (c == 'R') { cap = true; di = 0; } + else if (cap && c == '\r') { d[di] = 0; if (di >= 3) n++; cap = false; di = 0; } + else if (cap && di < 6 && c >= '0' && c <= '9') d[di++] = c; + else if (cap) { cap = false; di = 0; } + } + } + return n; +} + +void setup() { + Serial.begin(USB_BAUD); + delay(500); + Serial.println(); + Serial.println("=== gate_cmd: battery + commanded ranging ==="); + + uint16_t mv = batt_mv(); + Serial.print("battery: "); Serial.print(mv); Serial.print(" mV "); + Serial.println(mv < 3000 ? "(LOW / battery OFF?)" : "(ok)"); + + pinMode(PIN_SONAR_GATE, OUTPUT); digitalWrite(PIN_SONAR_GATE, HIGH); // gate on + Serial1.begin(SONAR_BAUD); // D0 = RX (UARTE0) + NRF_UARTE0->PSEL.TXD = 0xFFFFFFFF; // detach UART TX from D1 so it can be a GPIO strobe (RXD/D0 stays live) + delay(300); // sonar power-up + + // POSITIVE CONTROL: float pin4 (D1 hi-Z) -> internal pull-up -> continuous mode. + // frames>0 => sonar alive + serial path good + pin4-float=continuous. frames=0 + // => sonar silent (disturbed), no command would work either. + pinMode(PIN_STROBE, INPUT); + delay(200); + unsigned fr = count_unsolicited(3000); + Serial.print("float-test (pin4 hi-Z = continuous): frames in 3s = "); Serial.println(fr); + + pinMode(PIN_STROBE, OUTPUT); digitalWrite(PIN_STROBE, LOW); // back to commanded (pin4 low = stop) + delay(200); +} + +// median of n ints (n small); insertion-sorts a scratch copy, returns the middle. +// 9999 (no-target) participates: it sorts to the top, so a minority of no-target +// glitches is outvoted; only a majority makes the median 9999 (correctly no-target). +static int median_int(int *a, int n) { + for (int i = 1; i < n; i++) { + int v = a[i], j = i - 1; + while (j >= 0 && a[j] > v) { a[j + 1] = a[j]; j--; } + a[j + 1] = v; + } + return a[n / 2]; +} + +void loop() { + uint16_t mv = batt_mv(); + + unsigned unsol = count_unsolicited(200); // pin4-low should give ~0 + + while (Serial1.available()) Serial1.read(); // flush + digitalWrite(PIN_STROBE, HIGH); // pin4 high -> continuous ranging while high + +#if USE_MEDIAN + int want = N_SAMPLES; +#else + int want = 1; +#endif + // grab up to `want` consecutive Rdddd frames; tally raw 9999s as we go. + int samp[N_SAMPLES]; int ns = 0, nbytes = 0; + uint32_t t0 = millis(); + bool cap = false; char d[8]; int di = 0; + while (ns < want && millis() - t0 < 1500) { + while (Serial1.available()) { + char c = (char)Serial1.read(); nbytes++; + if (c == 'R') { cap = true; di = 0; } + else if (cap && c == '\r') { d[di] = 0; if (di >= 3) { int v = atoi(d); samp[ns++] = v; if (v == 9999) g_no_target++; } cap = false; di = 0; } + else if (cap && di < 6 && c >= '0' && c <= '9') d[di++] = c; + else if (cap) { cap = false; di = 0; } + } + } + digitalWrite(PIN_STROBE, LOW); // pin4 low -> stop free-run again + +#if USE_MEDIAN + int reading = ns ? median_int(samp, ns) : -1; +#else + int reading = ns ? samp[0] : -1; +#endif + + Serial.print("batt="); Serial.print(mv); Serial.print("mV"); + Serial.print(mv < 3000 ? "(!) " : " "); + Serial.print("unsol="); Serial.print(unsol); + Serial.print(" n="); Serial.print(ns); +#if USE_MEDIAN + Serial.print(" ["); // raw samples, so the filter is auditable on the bench + for (int i = 0; i < ns; i++) { if (i) Serial.print(' '); Serial.print(samp[i]); } + Serial.print("]"); +#endif + Serial.print(USE_MEDIAN ? " med->" : " raw->"); + if (reading >= 0) { Serial.print(reading); Serial.print("mm ("); Serial.print(reading / 1000.0f, 3); Serial.print(" m)"); } + else Serial.print(nbytes ? "bytes but no frame" : "NO BYTES (dead serial)"); + Serial.print(" 9999seen="); Serial.print(g_no_target); + Serial.println(); + + delay(PERIOD_MS); +} diff --git a/variants/gate_cmd/platformio.ini b/variants/gate_cmd/platformio.ini new file mode 100644 index 0000000000..9f10711a61 --- /dev/null +++ b/variants/gate_cmd/platformio.ini @@ -0,0 +1,22 @@ +; gate_cmd -- battery voltage read + commanded single-shot ranging (pin4/D1 strobe). +; Uses ZERO external libs: variant.cpp is a pin-map, gate_cmd_main.cpp is Arduino.h +; only, USB-CDC Serial is core-provided. RadioLib was cargo-culted from the radio +; siblings; on a cold .pio the floating ^7.3.0 resolved to 7.7.1 and failed on SPI.h. +; USB-CDC Serial = Adafruit_USBD_CDC, from the bundled Adafruit TinyUSB Library. +; TinyUSB also ships a USB-Host source that #includes SPI.h, so SPI must be present +; too (the full MeshCore build has it via arduino_base; minimizing had dropped it). +[env:gate_cmd] +extends = nrf52_base +board = promicro_nrf52840 +lib_deps = + SPI + Adafruit TinyUSB Library +lib_ldf_mode = chain+ +build_flags = ${nrf52_base.build_flags} + -I variants/rook + -D USE_MEDIAN=1 ; 1 = median-filter the ranging samples; 0 = raw single reading (debug A/B) + -D N_SAMPLES=5 ; samples per measurement when USE_MEDIAN +build_src_filter = + -<*> + +<../variants/rook/variant.cpp> + +<../variants/gate_cmd/gate_cmd_main.cpp> diff --git a/variants/gate_probe/gate_probe_main.cpp b/variants/gate_probe/gate_probe_main.cpp new file mode 100644 index 0000000000..fb7a1cccfe --- /dev/null +++ b/variants/gate_probe/gate_probe_main.cpp @@ -0,0 +1,92 @@ +// gate_probe -- disambiguate a "powered but silent" MB7388 sonar. +// +// V+ and GPS_GND already metered good (3.3V across the sonar, gate conducting), yet +// gate_cmd reported NO BYTES. Three causes remain: (1) sonar wedged, (2) command +// strobe (D1->pin4) not driving it, (3) RX path (pin5->D0 via 10k) silent. This +// firmware settles all three each loop: +// - POWER-CYCLE the sonar (gate off 1.5s, on 0.4s) to clear a wedge. +// - CONTINUOUS test: float pin4 (D1 hi-Z -> internal pull-up) -> free-run. +// - COMMANDED test: drive pin4 high via D1 -> range while high. +// Prints frame count AND raw byte count for each mode, so no-bytes (dead RX / sonar) +// is distinguishable from bytes-but-no-frames (baud/level) and from working. +// CONT frames>0 -> sonar + RX good; if CMD=0 the D1 strobe is the fault +// both 0 bytes after cycle -> sonar wedged/dead or RX wire open +// bytes>0 but frames=0 -> serial arriving but garbled + +#include + +#define PIN_SONAR_GATE 5 // D5 = P0.24 = GPS_EN = Q2 gate (HIGH = sonar on) +#define PIN_S1_RX 0 // D0 = P0.08 (MB7388 pin5 via 10k) +#define PIN_STROBE 1 // D1 = P0.06 (MB7388 pin4) -- reclaimed from Serial1 TX +#define PIN_VBAT 17 // P0.31 = AIN7 (VBAT divider) +#define ADC_MULT 1.815f +#define USB_BAUD 115200 +#define SONAR_BAUD 9600 + +static uint16_t batt_mv() { + analogReadResolution(12); + uint32_t raw = 0; + for (int i = 0; i < 8; i++) raw += analogRead(PIN_VBAT); + raw /= 8; + return (uint16_t)(ADC_MULT * raw); +} + +// read for win_ms; count raw bytes and complete 'Rdddd\r' frames, keep last mm +static void read_window(uint32_t win_ms, int *frames, int *nbytes, int *last_mm) { + uint32_t t0 = millis(); + int fr = 0, nb = 0, mm = -1; + bool cap = false; char d[8]; int di = 0; + while (millis() - t0 < win_ms) { + while (Serial1.available()) { + char c = (char)Serial1.read(); nb++; + if (c == 'R') { cap = true; di = 0; } + else if (cap && c == '\r') { d[di] = 0; if (di >= 3) { mm = atoi(d); fr++; } cap = false; di = 0; } + else if (cap && di < 6 && c >= '0' && c <= '9') d[di++] = c; + else if (cap) { cap = false; di = 0; } + } + } + *frames = fr; *nbytes = nb; *last_mm = mm; +} + +void setup() { + Serial.begin(USB_BAUD); + delay(500); + Serial.println(); + Serial.println("=== gate_probe: power-cycle + continuous/commanded discriminator ==="); + pinMode(PIN_SONAR_GATE, OUTPUT); digitalWrite(PIN_SONAR_GATE, HIGH); + Serial1.begin(SONAR_BAUD); + NRF_UARTE0->PSEL.TXD = 0xFFFFFFFF; // reclaim D1 from UART TX so it can strobe pin4 + delay(300); +} + +void loop() { + uint16_t mv = batt_mv(); + + // 1) power-cycle the sonar to clear a possible wedge + digitalWrite(PIN_SONAR_GATE, LOW); // gate off -> sonar loses its ground return + delay(1500); // let the ~100uF discharge for a real power-down + digitalWrite(PIN_SONAR_GATE, HIGH); // gate on + delay(400); // sonar power-up settle + + // 2) continuous: pin4 floats (D1 hi-Z) -> sonar internal pull-up -> free-run + pinMode(PIN_STROBE, INPUT); + while (Serial1.available()) Serial1.read(); + int cf, cb, cmm; read_window(1500, &cf, &cb, &cmm); + + // 3) commanded: drive pin4 high via D1 -> range while high + pinMode(PIN_STROBE, OUTPUT); digitalWrite(PIN_STROBE, LOW); + delay(100); + while (Serial1.available()) Serial1.read(); + digitalWrite(PIN_STROBE, HIGH); + int df, db, dmm; read_window(800, &df, &db, &dmm); + digitalWrite(PIN_STROBE, LOW); + + Serial.print("batt="); Serial.print(mv); Serial.print("mV "); + Serial.print("CONT(float): frames="); Serial.print(cf); Serial.print(" bytes="); Serial.print(cb); + if (cmm >= 0) { Serial.print(" last="); Serial.print(cmm); Serial.print("mm"); } + Serial.print(" CMD(D1-high): frames="); Serial.print(df); Serial.print(" bytes="); Serial.print(db); + if (dmm >= 0) { Serial.print(" last="); Serial.print(dmm); Serial.print("mm"); } + Serial.println(); + + delay(500); +} diff --git a/variants/gate_probe/platformio.ini b/variants/gate_probe/platformio.ini new file mode 100644 index 0000000000..3ef14c7e7b --- /dev/null +++ b/variants/gate_probe/platformio.ini @@ -0,0 +1,17 @@ +; gate_probe -- diagnostic for a "powered but silent" sonar. Power-cycles the sonar +; each loop (clears a wedge), then tests continuous (pin4 float) and commanded +; (D1-driven-high) modes back to back, printing frame + raw byte counts for each. +; Same known-good minimal deps as gate_cmd (SPI + Adafruit TinyUSB, no RadioLib). +[env:gate_probe] +extends = nrf52_base +board = promicro_nrf52840 +lib_deps = + SPI + Adafruit TinyUSB Library +lib_ldf_mode = chain+ +build_flags = ${nrf52_base.build_flags} + -I variants/rook +build_src_filter = + -<*> + +<../variants/rook/variant.cpp> + +<../variants/gate_probe/gate_probe_main.cpp> diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index fb997b972c..67a3683f3f 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -460,6 +460,16 @@ build_src_filter = ${heltec_v4_oled.build_src_filter} lib_deps = ${heltec_v4_oled.lib_deps} +[env:heltec_v4_receiver_v3_ultrasonic_usbdbg] +; WFS_49 receiver, same firmware as ..._v3_ultrasonic, but with Serial routed to +; the ESP32-S3 native USB-Serial-JTAG (the one port the enclosure exposes) so +; debug/console are readable there instead of the unreachable CP2102 UART. +extends = env:heltec_v4_companion_sensor_receiver_v3_ultrasonic +build_flags = + ${env:heltec_v4_companion_sensor_receiver_v3_ultrasonic.build_flags} + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + [env:heltec_v4_companion_sensor_receiver_v3_1_wire] ; Heltec V4 receiver paired with the v3-1-wire DS18B20 sensor. ; Posts temperature_c to Bayou. diff --git a/variants/rook/platformio.ini b/variants/rook/platformio.ini index c0a29cd494..a1475d36fd 100644 --- a/variants/rook/platformio.ini +++ b/variants/rook/platformio.ini @@ -194,6 +194,19 @@ build_src_filter = ${Rook.build_src_filter} lib_deps = ${Rook.lib_deps} adafruit/RTClib @ ^2.1.3 +[env:Rook_companion_sensor_v3_ultrasonic_debug] +; Radio-link debug build of the v3-ultrasonic sensor node. Same source as +; Rook_companion_sensor_v3_ultrasonic plus: +; SEND_TIMER_MILLIS -> send fires off millis() so packets repeat every +; interval even without a GPS/NTP clock (the RTC path +; caps at one boot packet). Lets the OLED tx/ack counters +; actually climb so a one-and-done wedge is watchable. +; The source itself adds the LED-per-TX heartbeat, the tx/ack counters, and the +; always-on display. Reproducible flash target -- no ephemeral -D flags. +extends = env:Rook_companion_sensor_v3_ultrasonic +build_flags = ${env:Rook_companion_sensor_v3_ultrasonic.build_flags} + -D SEND_TIMER_MILLIS + [env:Rook_companion_sensor_v3_1_wire] ; v3-style sensor (OLED ACK status + page-flip on send + Send-page live read ; + verbose boot prints) with DS18B20 OneWire temperature probe → temperature_c → Bayou. diff --git a/variants/sleep_test/platformio.ini b/variants/sleep_test/platformio.ini new file mode 100644 index 0000000000..55d683c473 --- /dev/null +++ b/variants/sleep_test/platformio.ini @@ -0,0 +1,149 @@ +; Minimal deep-sleep env for JS220 sleep-current profiling on the Rook (promicro_nrf52840). +; Reuses the project's known-good board def (S140 v6 ldscript) + the rook Arduino-core variant +; (variant.h via -I, pin map via variant.cpp) + the nrf52_base UF2 scripts. v4 adds RadioLib so +; the SX1262 can be put to SPI sleep (POWER_EN low alone left the radio core in standby ~1 mA). +[env:sleep_test] +extends = nrf52_base +board = promicro_nrf52840 +lib_deps = jgromes/RadioLib @ ^7.3.0 +lib_ldf_mode = chain+ +build_flags = ${nrf52_base.build_flags} + -I variants/rook +build_src_filter = + -<*> + +<../variants/rook/variant.cpp> + +<../variants/sleep_test/sleep_main.cpp> + +; ---- System-ON build-up ladder (additive on the v4 146.6 uA anchor) + +[env:sleep_test_v5_systemon] +; B1 -- THE bridge build: System ON delay(1000) idle instead of SYSTEMOFF, radio +; warm-slept, USBD off once VBUS gone. Measures the bare core's System-ON floor. +extends = env:sleep_test +build_flags = ${env:sleep_test.build_flags} + -D V5_SYSTEMON=1 + +[env:sleep_test_v5_usbd] +; B2: B1 + USBD left enabled on battery -- cross-checks the deployment build's null result. +extends = env:sleep_test +build_flags = ${env:sleep_test.build_flags} + -D V5_SYSTEMON=1 + -D V5_USBD_ON=1 + +[env:sleep_test_v5_prints] +; B3: B1 + per-loop Serial prints on battery (CDC writes, no host). +extends = env:sleep_test +build_flags = ${env:sleep_test.build_flags} + -D V5_SYSTEMON=1 + -D V5_PRINTS=1 + +[env:sleep_test_v5_fsrtc] +; B4: B1 + InternalFS.begin -- the flash service cost in isolation. +extends = env:sleep_test +build_flags = ${env:sleep_test.build_flags} + -D V5_SYSTEMON=1 + -D V5_FSRTC=1 + +[env:sleep_test_v5_d21low] +; B5: B1 + D21/P0.13 driven LOW -- the clone EXT-VCC pull-up A/B (~+0.6 mA +; expected if the SuperMini-style 5.6 k is populated). +extends = env:sleep_test +build_flags = ${env:sleep_test.build_flags} + -D V5_SYSTEMON=1 + -D V5_D21_LOW=1 + +; ---- idle-surgery builds (v6, all single-delta vs B1) ---- + +[env:sleep_test_v6_audit] +; D1: B1 + battery-state audit latch (clock/peripheral ENABLEs, idle-hook rate) +; dumped over CDC on USB replug. The cut list for the surgery ladder comes from +; this arm's readout, not from theory. +extends = env:sleep_test +build_flags = ${env:sleep_test.build_flags} + -D V5_SYSTEMON=1 + -D V6_AUDIT=1 + +[env:sleep_test_v6_spim3off] +; X_spim3: B1 + spiLora.end() after radio.sleep(). nRF52840 anomaly 195 is +; ~900 uA continuous while SPIM3 is ENABLED -- the number matches the whole +; gap. endTransaction() may already have disabled it (audit says); cheap knob. +extends = env:sleep_test +build_flags = ${env:sleep_test.build_flags} + -D V5_SYSTEMON=1 + -D V6_SPIM3_OFF=1 + +[env:sleep_test_v6_hfclkstop] +; X_hfclk: B1 + TASKS_HFCLKSTOP from the (weak-override) idle hook when VBUS +; is absent -- directly tests the nobody-ever-stops-HFXO code fact. +extends = env:sleep_test +build_flags = ${env:sleep_test.build_flags} + -D V5_SYSTEMON=1 + -D V6_HFCLKSTOP=1 + +[env:sleep_test_v6_usbsuspend] +; X_usbtask: B1 + vTaskSuspend the TinyUSB "usbd" task once VBUS is gone +; (found by name via the trace facility; the core discards the handle). +extends = env:sleep_test +build_flags = ${env:sleep_test.build_flags} + -D V5_SYSTEMON=1 + -D V6_USBTASK_SUSPEND=1 + +; [env:sleep_test_v6_nousb] -- REMOVED (build-verified impossible 2026-07-20): +; the core does not compile without USE_TINYUSB (main.cpp _write and Uart.cpp +; serialEventRun reference the CDC Serial object unconditionally). Fallback per +; protocol: sleep_test_v6_usbsuspend + B1's USBD ENABLE=0 is the closest +; achievable cut without core edits. V6_NOUSB knob left in sleep_main.cpp. + +[env:sleep_test_v6_audit2] +; D2: audit + DWT CYCCNT awake-cycles-per-second latch -- direct measurement of +; how much the WFE micro-loop actually sleeps. Pure diagnostic, no cuts. +extends = env:sleep_test +build_flags = ${env:sleep_test.build_flags} + -D V5_SYSTEMON=1 + -D V6_AUDIT=1 + -D V6_AUDIT2=1 + +[env:sleep_test_v6_lowpwr] +; X_lowpwr: B1 + TASKS_LOWPWR once VBUS drops -- insurance against a latched +; constant-latency mode (write-only task, no status readback exists). +extends = env:sleep_test +build_flags = ${env:sleep_test.build_flags} + -D V5_SYSTEMON=1 + -D V6_LOWPWR=1 + +[env:sleep_test_v6_timerstop] +; X_timer: B1 + TASKS_STOP/SHUTDOWN on TIMER0-4 at setup. Timers are +; audit-blind (no ENABLE readback) and a running one holds HFCLK through CPU +; sleep; the UF2 bootloader is the suspected starter. +extends = env:sleep_test +build_flags = ${env:sleep_test.build_flags} + -D V5_SYSTEMON=1 + -D V6_TIMERSTOP=1 + +[env:sleep_test_v6_audit3] +; D3: audit2 + blind-spot registers (GPIOTE configs, CC310, QDEC, COMP, +; LPCOMP, AAR, CCM, MWU regions, RADIO state). +extends = env:sleep_test +build_flags = ${env:sleep_test.build_flags} + -D V5_SYSTEMON=1 + -D V6_AUDIT=1 + -D V6_AUDIT2=1 + -D V6_AUDIT3=1 + +[env:sleep_test_v6_sd] +; X_sd: B1 + Bluefruit.begin() -- SoftDevice-managed sleep. The port prefers +; sd_app_evt_wait() when S140 is ENABLED; this is the config-level test of +; whether the bare-metal WFE branch is the ~890 uA defect. +extends = env:sleep_test +build_flags = ${env:sleep_test.build_flags} + -D V5_SYSTEMON=1 + -D V6_SD=1 + +[env:sleep_test_v6_resleep] +; X_resleep: B1 + radio.sleep() every loop pass. Discriminates "radio stays +; asleep" (floor unchanged, radio innocent) from "runtime wakes the radio" +; (floor collapses; the earlier radio-not-slept 1.05 mA matches our exact gap). +extends = env:sleep_test +build_flags = ${env:sleep_test.build_flags} + -D V5_SYSTEMON=1 + -D V6_RESLEEP=1 diff --git a/variants/sleep_test/sleep_main.cpp b/variants/sleep_test/sleep_main.cpp new file mode 100644 index 0000000000..509752d61f --- /dev/null +++ b/variants/sleep_test/sleep_main.cpp @@ -0,0 +1,356 @@ +// v4: SX1262 SPI sleep + nRF52840 System OFF, for JS220 sleep profiling (Rook). +// +// v1 (System OFF) 2.82 mA; v2/v3 (+ POWER_EN low + USB off + cold cycle) stuck flat at +// 1.05 mA, voltage-INDEPENDENT -> a load behind a regulator that never got the sleep +// command. Hypothesis: POWER_EN low only killed the TCXO/RF-switch (the 1.77 mA we saw +// drop); the SX1262 digital core is still in standby (~1 mA). Only a SPI SLEEP opcode +// (RadioLib radio.sleep()) puts the core to ~1 uA. v4 powers the radio, inits it, sleeps +// it over SPI, then sleeps the MCU. + +#include +#include +#include + +#define P_LORA_NSS 13 +#define P_LORA_DIO_1 11 +#define P_LORA_RESET 10 +#define P_LORA_BUSY 16 +#define P_LORA_MISO 15 +#define P_LORA_SCLK 12 +#define P_LORA_MOSI 14 +#define SX126X_POWER_EN 21 // P0.13 active-HIGH radio module enable +#define SX126X_RXEN 2 // P0.17 RF switch RX + +SPIClass spiLora(NRF_SPIM3, P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); +SX1262 radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spiLora); + +void setup() { + delay(3000); // awake window (boot blip) + + pinMode(SX126X_POWER_EN, OUTPUT); digitalWrite(SX126X_POWER_EN, HIGH); // power radio to talk to it + pinMode(SX126X_RXEN, OUTPUT); digitalWrite(SX126X_RXEN, LOW); + delay(10); + + spiLora.begin(); + // freq, bw, sf, cr, sync, power, preamble, tcxoVoltage=1.8 (Rook DIO3 TCXO), useLDO=false + radio.begin(910.525, 62.5, 7, 5, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, 22, 8, 1.8, false); + radio.setDio2AsRfSwitch(true); + radio.sleep(); // SX1262 -> deep sleep (~1 uA) + + // ---- System-ON build-up knobs (v5) --------------------------------- + // V5_SYSTEMON: stay in System ON with a delay(1000) idle loop (the deployment + // firmware's sleep shape) instead of SYSTEMOFF. THE bridge build: measures the + // bare Adafruit-core System-ON floor with the radio slept. + // V5_USBD_ON: leave USBD enabled (undo the v4 release) -- cross-checks the + // deployment USBD null in the minimal context. + // V5_PRINTS: one Serial line per loop pass (CDC writes with no host). + // V5_FSRTC: InternalFS.begin() (flash service cost in isolation). + // V5_D21_LOW: drive D21/P0.13 LOW -- the clone EXT-VCC pull-up A/B; expect + // ~+0.6 mA if the SuperMini-style 5.6 k is populated. +#ifndef V5_SYSTEMON +#define V5_SYSTEMON 0 +#endif +#ifndef V5_USBD_ON +#define V5_USBD_ON 0 +#endif +#ifndef V5_PRINTS +#define V5_PRINTS 0 +#endif +#ifndef V5_FSRTC +#define V5_FSRTC 0 +#endif +#ifndef V5_D21_LOW +#define V5_D21_LOW 0 +#endif + // ---- idle-surgery knobs (v6, each also sets V5_SYSTEMON) ---- + // V6_AUDIT: latch clock/peripheral enable state + idle-hook rate each pass on + // battery; dump the latched battery snapshot over CDC whenever USB is back + // (attach does not reset the chip, so the snapshot survives into readout). + // V6_HFCLKSTOP: idle hook issues TASKS_HFCLKSTOP when VBUS is absent -- tests + // the nobody-ever-stops-HFXO fact directly. + // V6_USBTASK_SUSPEND: once VBUS is gone, find the "usbd" task by name and + // suspend it (TRACE_FACILITY walk; core discards the handle at create). + // V6_NOUSB: built with build_unflags stripping USE_TINYUSB/USBCON -- no usb + // task, no CDC ever. Alive gate = LED: triple blink at boot, then a 50 ms + // flash every 30 s (the JS220 trace shows the cadence; floor comes from the + // quiet windows between flashes). +#ifndef V6_AUDIT +#define V6_AUDIT 0 +#endif +#ifndef V6_HFCLKSTOP +#define V6_HFCLKSTOP 0 +#endif +#ifndef V6_USBTASK_SUSPEND +#define V6_USBTASK_SUSPEND 0 +#endif +#ifndef V6_NOUSB +#define V6_NOUSB 0 +#endif + // V6_SPIM3_OFF: spiLora.end() after radio.sleep() -- nRF52840 anomaly 195 is + // ~900 uA continuous while SPIM3 is ENABLED. RadioLib's endTransaction() + // already calls nrf_spim_disable, so this may be a no-op; the audit arm + // reads NRF_SPIM3->ENABLE to say which. Single knob either way. +#ifndef V6_SPIM3_OFF +#define V6_SPIM3_OFF 0 +#endif + // V6_AUDIT2: audit + DWT CYCCNT awake-cycles-per-pass latch. CYCCNT clocks + // only while the CPU runs, so the per-second delta is a direct measure of + // how much the WFE micro-loop actually sleeps (invisible to the hook count). + // Env sets V6_AUDIT=1 too. Pure diagnostic, no cuts. +#ifndef V6_AUDIT2 +#define V6_AUDIT2 0 +#endif + // V6_AUDIT3: + the blind-spot registers: GPIOTE CONFIG[0..7] (hi-acc IN + // channels hold HFCLK -- the attachInterrupt trap), CRYPTOCELL, QDEC, + // COMP, LPCOMP, AAR, CCM, MWU regions, RADIO state. +#ifndef V6_AUDIT3 +#define V6_AUDIT3 0 +#endif + // V6_LOWPWR: TASKS_LOWPWR once when VBUS drops -- insurance against a + // bootloader-latched constant-latency mode (no readable status exists). +#ifndef V6_LOWPWR +#define V6_LOWPWR 0 +#endif + +#if V6_AUDIT2 + dwt_enable(); // core delay.h: CYCCNT on +#endif + // V6_TIMERSTOP: stop+shutdown TIMER0-4 at setup. Timers have no ENABLE + // readback (audit-blind) and a running timer holds HFCLK through CPU + // sleep; the UF2 bootloader runs before every app boot and could leave + // one started. Single knob for the bootloader-residue hypothesis. +#ifndef V6_TIMERSTOP +#define V6_TIMERSTOP 0 +#endif + // V6_SD: Bluefruit.begin() -- enable the (already-linked) S140 SoftDevice so + // the port's tickless idle takes the sd_app_evt_wait() branch instead of + // the bare WFE loop. No advertising, no connections; just SD-managed sleep. +#ifndef V6_SD +#define V6_SD 0 +#endif + // V6_RESLEEP: re-issue radio.sleep() every loop pass. A sleeping SX1262 + // wakes on any NSS edge and the module is permanently powered (clone + // pull-up on EXT-VCC); the earlier ladder showed radio-not-slept == 1.05 mA, our exact + // floor. If something in the System-ON runtime wakes it, holding it down + // collapses the floor; if not, the radio is innocent and the cost is MCU. +#ifndef V6_RESLEEP +#define V6_RESLEEP 0 +#endif +#if V6_SD + { extern void v6_sd_begin(); v6_sd_begin(); } +#endif +#if V6_TIMERSTOP + { + NRF_TIMER_Type* t[5] = {NRF_TIMER0, NRF_TIMER1, NRF_TIMER2, NRF_TIMER3, NRF_TIMER4}; + for (int i = 0; i < 5; i++) { t[i]->TASKS_STOP = 1; t[i]->TASKS_SHUTDOWN = 1; } + } +#endif + +#if V6_SPIM3_OFF + spiLora.end(); // SPIM3 ENABLE=0 (anomaly 195) +#endif + +#if V6_NOUSB + pinMode(LED_PIN, OUTPUT); // boot gate: triple blink + for (int i = 0; i < 3; i++) { + digitalWrite(LED_PIN, HIGH); delay(100); + digitalWrite(LED_PIN, LOW); delay(200); + } +#endif + +#if V5_FSRTC + extern int _v5_fs_begin(); // defined below to keep v4 path clean + _v5_fs_begin(); +#endif +#if V5_D21_LOW + pinMode(21, OUTPUT); digitalWrite(21, LOW); // D21 = P0.13, clone EXT-VCC control +#endif + +#if !V5_USBD_ON && !V5_SYSTEMON + NRF_USBD->ENABLE = 0; // release USB (v4 behavior) +#endif + // (System-ON arms release USBD per loop pass once VBUS is gone -- CDC stays + // alive for the USB heartbeat check, and the battery floor matches v4.) + +#if V5_SYSTEMON + // v5: System ON idle, tickless FreeRTOS delay -- fall through to loop() +#else + NRF_POWER->SYSTEMOFF = 1; // nRF System OFF (v4 behavior) + while (1) { __WFE(); } +#endif +} + +#if V5_FSRTC +#include +int _v5_fs_begin() { InternalFS.begin(); return 0; } +#endif + +#if V6_SD +#include +void v6_sd_begin() { Bluefruit.begin(); } // S140 on; idle now sd_app_evt_wait +#endif + +// ---- v6 file-scope machinery (knob macros defined above in setup's text) ---- + +#if V6_AUDIT || V6_HFCLKSTOP +static volatile uint32_t v6_idle_count; +extern "C" void vApplicationIdleHook(void) { // weak in core hooks.c + v6_idle_count++; +#if V6_HFCLKSTOP + if (!(NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk)) { + NRF_CLOCK->TASKS_HFCLKSTOP = 1; + } +#endif +} +#endif + +#if V6_USBTASK_SUSPEND +static void v6_suspend_usbd_once() { + static bool done; + if (done) return; + TaskStatus_t st[8]; + UBaseType_t n = uxTaskGetSystemState(st, 8, NULL); + for (UBaseType_t i = 0; i < n; i++) { + if (0 == strcmp(st[i].pcTaskName, "usbd")) { + vTaskSuspend(st[i].xHandle); + done = true; + return; + } + } +} +#endif + +#if V6_AUDIT +// last battery-idle snapshot; survives USB replug (attach doesn't reset) +static struct { + uint32_t passes, idle_rate; + uint32_t hfclkstat, lfclkstat, usbreg, usbd_en; + uint32_t uarte0, uarte1, box0, box1, spim2, spim3; + uint32_t saadc, qspi, pwm0, pwm1, pwm2, pwm3, pdm, i2s; + uint32_t cyc; // V6_AUDIT2: awake cycles per pass +#if V6_AUDIT3 + uint32_t gpiote[8]; // hi-acc IN channels hold HFCLK + uint32_t cryptocell, qdec, comp, lpcomp, aar, ccm, mwu_regionen, radio_state; +#endif +} v6b; + +static void v6_audit_pass(bool vbus) { + static uint32_t last; + uint32_t rate = v6_idle_count - last; // idle-hook calls per ~1 s pass + last = v6_idle_count; +#if V6_AUDIT2 + static uint32_t last_cyc; + uint32_t cyc_now = DWT->CYCCNT; + uint32_t cyc = cyc_now - last_cyc; // counts only while CPU runs + last_cyc = cyc_now; +#endif + if (!vbus) { + v6b.passes++; v6b.idle_rate = rate; +#if V6_AUDIT2 + v6b.cyc = cyc; +#endif + v6b.hfclkstat = NRF_CLOCK->HFCLKSTAT; v6b.lfclkstat = NRF_CLOCK->LFCLKSTAT; + v6b.usbreg = NRF_POWER->USBREGSTATUS; v6b.usbd_en = NRF_USBD->ENABLE; + v6b.uarte0 = NRF_UARTE0->ENABLE; v6b.uarte1 = NRF_UARTE1->ENABLE; + v6b.box0 = NRF_SPIM0->ENABLE; v6b.box1 = NRF_SPIM1->ENABLE; + v6b.spim2 = NRF_SPIM2->ENABLE; v6b.spim3 = NRF_SPIM3->ENABLE; + v6b.saadc = NRF_SAADC->ENABLE; v6b.qspi = NRF_QSPI->ENABLE; + v6b.pwm0 = NRF_PWM0->ENABLE; v6b.pwm1 = NRF_PWM1->ENABLE; + v6b.pwm2 = NRF_PWM2->ENABLE; v6b.pwm3 = NRF_PWM3->ENABLE; + v6b.pdm = NRF_PDM->ENABLE; v6b.i2s = NRF_I2S->ENABLE; +#if V6_AUDIT3 + for (int i = 0; i < 8; i++) v6b.gpiote[i] = NRF_GPIOTE->CONFIG[i]; + v6b.cryptocell = NRF_CRYPTOCELL->ENABLE; + v6b.qdec = NRF_QDEC->ENABLE; v6b.comp = NRF_COMP->ENABLE; + v6b.lpcomp = NRF_LPCOMP->ENABLE; + v6b.aar = NRF_AAR->ENABLE; v6b.ccm = NRF_CCM->ENABLE; + v6b.mwu_regionen = NRF_MWU->REGIONEN; + v6b.radio_state = NRF_RADIO->STATE; +#endif + } else { + // on USB: report the battery snapshot (if any) then live state + Serial.print("[AUDIT] batt_passes="); Serial.print(v6b.passes); + Serial.print(" idle_rate="); Serial.print(v6b.idle_rate); + Serial.print(" hfclkstat=0x"); Serial.print(v6b.hfclkstat, HEX); + Serial.print(" lfclkstat=0x"); Serial.print(v6b.lfclkstat, HEX); + Serial.print(" usbd="); Serial.print(v6b.usbd_en); + Serial.println(); + Serial.print("[AUDIT] uarte0/1="); Serial.print(v6b.uarte0); + Serial.print("/"); Serial.print(v6b.uarte1); + Serial.print(" box0/1="); Serial.print(v6b.box0); + Serial.print("/"); Serial.print(v6b.box1); + Serial.print(" spim2/3="); Serial.print(v6b.spim2); + Serial.print("/"); Serial.print(v6b.spim3); + Serial.print(" saadc="); Serial.print(v6b.saadc); + Serial.print(" qspi="); Serial.print(v6b.qspi); + Serial.print(" pwm="); Serial.print(v6b.pwm0); + Serial.print(v6b.pwm1); Serial.print(v6b.pwm2); Serial.print(v6b.pwm3); + Serial.print(" pdm="); Serial.print(v6b.pdm); + Serial.print(" i2s="); Serial.print(v6b.i2s); + Serial.print(" live_idle_rate="); Serial.print(rate); + Serial.print(" live_hfclkstat=0x"); Serial.print(NRF_CLOCK->HFCLKSTAT, HEX); + Serial.print(" live_spim3="); Serial.print(NRF_SPIM3->ENABLE); +#if V6_AUDIT2 + Serial.print(" batt_cyc="); Serial.print(v6b.cyc); + Serial.print(" live_cyc="); Serial.print(cyc); +#endif + Serial.println(); +#if V6_AUDIT3 + Serial.print("[AUDIT3] gpiote="); + for (int i = 0; i < 8; i++) { Serial.print(v6b.gpiote[i], HEX); Serial.print(i < 7 ? "," : ""); } + Serial.print(" cc310="); Serial.print(v6b.cryptocell); + Serial.print(" qdec="); Serial.print(v6b.qdec); + Serial.print(" comp="); Serial.print(v6b.comp); + Serial.print(" lpcomp="); Serial.print(v6b.lpcomp); + Serial.print(" aar="); Serial.print(v6b.aar); + Serial.print(" ccm="); Serial.print(v6b.ccm); + Serial.print(" mwu=0x"); Serial.print(v6b.mwu_regionen, HEX); + Serial.print(" radio_state="); Serial.print(v6b.radio_state); + Serial.print(" live_gpiote0=0x"); Serial.print(NRF_GPIOTE->CONFIG[0], HEX); + Serial.println(); +#endif + } +} +#endif + +void loop() { +#if V5_SYSTEMON +#if V6_NOUSB + // no USB stack in this build: 50 ms LED flash every 30 s is the alive + + // RTC-cadence gate (visible in the JS220 trace); dark otherwise + static uint32_t pass; + if (++pass % 30 == 0) { + digitalWrite(LED_PIN, HIGH); delay(50); digitalWrite(LED_PIN, LOW); + delay(950); + } else { + delay(1000); + } +#else + bool vbus = NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk; +#if !V5_USBD_ON + if (!vbus && NRF_USBD->ENABLE) { + NRF_USBD->ENABLE = 0; // USB pulled mid-run: now match v4's battery state + } +#endif +#if V6_USBTASK_SUSPEND + if (!vbus) v6_suspend_usbd_once(); +#endif +#if V6_LOWPWR + if (!vbus) NRF_POWER->TASKS_LOWPWR = 1; // idempotent; clears any CONSTLAT +#endif +#if V6_AUDIT + v6_audit_pass(vbus); +#endif + // Heartbeat on USB always (the alive-check), on battery only for the + // V5_PRINTS arm -- keeps the print-cost delta clean on the battery floor. +#if !V5_PRINTS + if (vbus) +#endif + { Serial.print("[HB] "); Serial.println(millis()); } +#if V6_RESLEEP + radio.sleep(); // hold the SX1262 down every pass +#endif + delay(1000); +#endif +#endif +} diff --git a/variants/sonar_field_node/modes.h b/variants/sonar_field_node/modes.h new file mode 100644 index 0000000000..a936c85d13 --- /dev/null +++ b/variants/sonar_field_node/modes.h @@ -0,0 +1,123 @@ +#pragma once +// sonar_field_node mode machine -- pure state logic, no hardware. +// Kept hardware-free so it compiles and unit-tests on the host; the .cpp +// wires each mode to the sonar/OLED/mesh/RTC primitives. +// +// Deploy path (default, low power): +// reset -> POST -> STATUS(30s) -> SLEEP -> (5min) -> WAKE -> MEASURE +// -> TRANSMIT(ack, retry 5s x5) -> SLEEP -> ... +// Dev path (on demand, richer OLED): a button press in SLEEP just wakes the +// unit -- WAKE powers the peripherals back on and classifies the gesture there, +// then routes the dev mode: +// B1 single (click) -> STATUS (peek at sensor/batt/radio, then back to sleep) +// B1 double (click) -> DEMO (live OLED, runs one cycle time, then back to sleep) +// B1 long press -> TRANSMIT_DEBUG (Don's "send now" gesture; tx attempt + link debug) +// reset -> POST (hard restart to self-test, any time) + +enum mode { + MODE_NONE = -1, // no mode (invalid) + MODE_POST = 0, // power-on self test (entered on reset) + MODE_STATUS, // 30s OLED: sensor / battery / radio (RSSI, s since ACK) + MODE_SLEEP, // display off; deep sleep until timer or button wake + MODE_WAKE, // display off; brief power-up of sonar + radio + MODE_MEASURE, // take the median range reading + MODE_TRANSMIT, // send reading, wait ACK, retry 5s up to 5x (display off) + MODE_DEMO, // always-on OLED live demo; runs for one cycle time + MODE_TRANSMIT_DEBUG, // OLED: tx attempt + radio-link debug info + MODE_COUNT +}; + +// mirror of MomentaryButton.h so this header stays hardware-free / host-testable +#ifndef BUTTON_EVENT_NONE +#define BUTTON_EVENT_NONE 0 +#define BUTTON_EVENT_CLICK 1 +#define BUTTON_EVENT_LONG_PRESS 2 +#define BUTTON_EVENT_DOUBLE_CLICK 3 +#define BUTTON_EVENT_TRIPLE_CLICK 4 +#endif + +// what happened this tick that could drive a transition +struct mode_event { + int btn; // BUTTON_EVENT_* this tick, once classified (NONE if none yet) + bool elapsed; // the current mode's dwell timer expired (STATUS 30s, SLEEP 5min, DEMO cycle) + bool done; // the current mode's action finished (POST selftest ok, MEASURE got a reading, + // TRANSMIT settled = ACK or retries exhausted, TRANSMIT_DEBUG attempt done, + // WAKE peripheral power-up settled on the plain timer path) + bool woke_by_button; // SLEEP was interrupted by a button edge (vs the RTC dwell timer). The .cpp + // only classifies the gesture (sets btn) on this path, in WAKE. +}; + +static inline const char *mode_name(mode m) { + switch (m) { + case MODE_POST: return "POST"; + case MODE_STATUS: return "STATUS"; + case MODE_SLEEP: return "SLEEP"; + case MODE_WAKE: return "WAKE"; + case MODE_MEASURE: return "MEASURE"; + case MODE_TRANSMIT: return "TRANSMIT"; + case MODE_DEMO: return "DEMO"; + case MODE_TRANSMIT_DEBUG: return "TX_DEBUG"; + default: return "?"; + } +} + + +static inline mode handle_button_gesture(mode_event ev) { + switch (ev.btn) { + case BUTTON_EVENT_CLICK: + return MODE_STATUS; + case BUTTON_EVENT_DOUBLE_CLICK: + return MODE_DEMO; + case BUTTON_EVENT_LONG_PRESS: + return MODE_TRANSMIT_DEBUG; + default: + return MODE_NONE; + } +} +// Pure transition: given the current mode and what happened this tick, return +// the next mode. In the awake display modes (STATUS/DEMO) and in WAKE a +// classified gesture overrides the natural flow. SLEEP never interprets a +// gesture: any wake -- a button edge or the 5-min RTC -- routes to WAKE, which +// powers the peripherals back on and classifies the press there. +static inline mode next_mode(mode cur, mode_event ev) { + mode next; + + switch (cur) { + case MODE_POST: + return ev.done ? MODE_STATUS : MODE_POST; + case MODE_STATUS: + next = handle_button_gesture(ev); + if (next != MODE_NONE) { + return next; + } + return ev.elapsed ? MODE_SLEEP : MODE_STATUS; + case MODE_SLEEP: + // a button wake goes STRAIGHT to STATUS -- no gesture-classification + // dwell; STATUS is the gateway to the dev modes. Timer wakes take the + // WAKE bring-up path into the deploy measurement. + if (ev.woke_by_button) return MODE_STATUS; + return ev.elapsed ? MODE_WAKE : MODE_SLEEP; + case MODE_WAKE: + // timer-wake bring-up; button wakes route to STATUS from SLEEP now, so + // this settles the peripherals and falls through to the measurement. + next = handle_button_gesture(ev); + if (next != MODE_NONE) { + return next; + } + return ev.done ? MODE_MEASURE : MODE_WAKE; + case MODE_MEASURE: + return ev.done ? MODE_TRANSMIT : MODE_MEASURE; + case MODE_TRANSMIT: + return ev.done ? MODE_SLEEP : MODE_TRANSMIT; + case MODE_DEMO: + next = handle_button_gesture(ev); + if (next != MODE_NONE) { + return next; + } + return ev.elapsed ? MODE_SLEEP : MODE_DEMO; + case MODE_TRANSMIT_DEBUG: + return ev.done ? MODE_DEMO : MODE_TRANSMIT_DEBUG; + default: + return cur; + } +} \ No newline at end of file diff --git a/variants/sonar_field_node/platformio.ini b/variants/sonar_field_node/platformio.ini new file mode 100644 index 0000000000..ce94dec97d --- /dev/null +++ b/variants/sonar_field_node/platformio.ini @@ -0,0 +1,259 @@ +; sonar_field_node -- duty-cycle water-level deployment firmware. +; Forks the v3-ultrasonic companion_sensor mesh node and layers the field-node +; mode machine (modes.h) on top: POST/STATUS/SLEEP/WAKE/MEASURE/TRANSMIT + the +; B1 dev views (DEMO / TRANSMIT_DEBUG). Defaults to the low-power deploy cycle. +[env:Rook_field_node] +extends = Rook +build_flags = ${Rook.build_flags} + -I variants/sonar_field_node + -D DISPLAY_CLASS=SSD1306Display + -D LORA_FREQ=910.525 ; US ISM (Edgewood, RI) -- must match the receiver + -D LORA_SF=7 + -D SENSOR_SEND_INTERVAL_SECS=300 +build_src_filter = ${Rook.build_src_filter} + + + +<../variants/sonar_field_node/sonar_field_node_main.cpp> +lib_deps = ${Rook.lib_deps} + +[env:Rook_field_node_bench] +; Bench/verify build of the deployment field node: same firmware as +; Rook_field_node with a SHORT duty cycle so the measure/transmit/LED-health and +; STATUS tx-fail behavior is observable in seconds, not the 5-min deploy cadence. +; (Lesson learned the hard way: control the test interval so behavior is watchable.) Flash +; Rook_field_node for the real deployment; this only overrides the cadence macros. +extends = env:Rook_field_node +build_flags = ${env:Rook_field_node.build_flags} + -D CYCLE_SECS=20 + -D STATUS_SECS=15 + +[env:Rook_field_node_bench_sonaroff] +; Diagnostic: same short-cycle bench build but the sonar gate is FORCED OFF the +; whole time (SONAR_FORCE_OFF). Isolation test -- if the link ACKs with the sonar +; never powered, the sonar's gated power/RF noise is desensing the radio RX even +; though the fault register stays clean. Not a deploy build. +extends = env:Rook_field_node_bench +build_flags = ${env:Rook_field_node_bench.build_flags} + -D SONAR_FORCE_OFF + +[env:Rook_field_node_bench_uartsleep] +; Attribution control for the 2026-07-18 sleep-floor result. Same short-cycle +; bench build, but SLEEP_RELEASE_UART=0 leaves UARTE0 enabled across MODE_SLEEP +; the way the firmware behaved before that change. Expect the sleep floor to +; climb back from ~1.07 mA to ~6.6 mA. If it does not, the improvement was NOT +; the UART release and the attribution is wrong. A fix that cannot be un-fixed +; has not been attributed -- this env is how the claim gets falsified on demand. +extends = env:Rook_field_node_bench +build_flags = ${env:Rook_field_node_bench.build_flags} + -D SLEEP_RELEASE_UART=0 + +[env:Rook_field_node_bench_dcdc] +; !!! DO NOT FLASH WITHOUT CONFIRMING THE MODULE'S DC/DC INDUCTORS !!! +; +; 2026-07-18: flashing this (via the combined _lowpower env) preceded the node +; dropping off the USB bus. The nRF52840 DC/DC needs external inductors on the +; DCC pins; without them the regulator cannot supply the core and the chip +; browns out and will not boot. Recovery is a power cycle + bootloader reflash +; (DCDCEN is volatile), but it costs a physical trip to the bench. +; +; The earlier claim that RookBoard skipping NRF52BoardDCDC::begin() is a "bug" is +; RETRACTED. Across this repo the boards that call it are purpose-built low-power +; designs (rak4631, rak3401, xiao_nrf52, wio_*, t1000-e, sensecap_solar, meshtiny, +; gat562); the boards that inherit it and deliberately call NRF52Board::begin() +; include mesh_pocket, thinknode_m6, ikoka x3, rook -- and PROMICRO. The Rook is a +; ProMicro nRF52840 module and MeshCore's own promicro variant skips it the same +; way. That is a defensive pattern, because clone modules vary on whether the +; inductors are fitted. Confirm the hardware before ever enabling this. +extends = env:Rook_field_node_bench +build_flags = ${env:Rook_field_node_bench.build_flags} + -D PWR_ENABLE_DCDC=1 + +[env:Rook_field_node_bench_twi] +; Power candidate B: release TWIM across sleep. Wire.begin() runs once in +; RookBoard::begin() and is never ended, so the I2C peripheral stays enabled and +; holds HFCLK -- the same class of leak the UART turned out to be. The display is +; already off in SLEEP, so nothing needs the bus there. +extends = env:Rook_field_node_bench +build_flags = ${env:Rook_field_node_bench.build_flags} + -D SLEEP_RELEASE_TWI=1 + +[env:Rook_field_node_bench_lowpower] +; !!! CARRIES THE DC/DC RISK -- see the _dcdc env warning above. Do not flash +; until the module's DC/DC inductors are confirmed. Use _twi for the safe half. +; +; Both candidates together. Measure the singles FIRST so each stays attributable; +; this env is for the combined figure once they are individually understood. +extends = env:Rook_field_node_bench +build_flags = ${env:Rook_field_node_bench.build_flags} + -D PWR_ENABLE_DCDC=1 + -D SLEEP_RELEASE_TWI=1 + +[env:Rook_field_node_bench_slowpoll] +; Power candidate C: wake less often during sleep. +; MODE_SLEEP calls delay(SLEEP_POLL_MS) purely to re-check the cadence clock and +; the button. At the 50 ms default that is 20 CPU wakes per second doing nothing. +; The arithmetic points here rather than at a peripheral: 147 uA was measured with +; the radio slept and the MCU in System-OFF, versus ~1066 uA now with the radio +; slept and the MCU in System-ON idle. The ~900 uA difference is idle overhead. +; Trade-off: button latency during sleep rises to ~1 s, and cadence granularity to +; ~1 s. Both are irrelevant against a 20 s bench or 300 s deployment cycle. +extends = env:Rook_field_node_bench +build_flags = ${env:Rook_field_node_bench.build_flags} + -D SLEEP_POLL_MS=1000 + +[env:Rook_field_node_bench_radiopower] +; Power candidate D: also drop SX126X_POWER_EN during sleep. +; The radio's supply gate is driven HIGH once in RookBoard::begin() and never +; touched, so the TCXO + RF-switch domain stays powered even while the SX1262 is +; SPI-slept. the sleep_test ladder priced that domain at ~1.77 mA, and our current 1.06 mA +; floor sits close to the earlier 1.05 mA "POWER_EN low, not yet SPI-slept" rung. +; Wake must cold re-init (radio_init/std_init) since power loss discards config. +; NOT the operation that hung before -- those were begin() against a chip still in +; SPI-sleep; this is a cold power-up, the normal boot path. Verify ACK over serial +; with USB IN before trusting any battery-only capture from this build. +extends = env:Rook_field_node_bench +build_flags = ${env:Rook_field_node_bench.build_flags} + -D SLEEP_RADIO_POWER_OFF=1 + +[env:Rook_field_node_bench_radiogate] +; Power candidate E: gate the radio supply AND tri-state every line into it first. +; The plain _radiopower arm (gate without tri-state) measured 1.0595 -> 3.2120 mA, +; a 3x REGRESSION at 300 sigma: with VCC gated, the MCU kept driving NSS/SCK/MOSI/ +; RXEN/DIO into a dead chip and back-powered it through its protection diodes. +; This arm releases SPIM and hi-Zs all eight lines before dropping POWER_EN, then +; restores SPI + cold-inits on wake. If the TCXO/RF-switch domain really is +; still costing us, this is the arm that can collect it without the sneak path. +extends = env:Rook_field_node_bench +build_flags = ${env:Rook_field_node_bench.build_flags} + -D SLEEP_RADIO_POWER_OFF=1 + -D SLEEP_RADIO_TRISTATE=1 + +[env:Rook_field_node_bench_pindisc] +; Diagnostic (radio-gate + disconnected lines): radiogate, but the eight radio lines go to the nRF52's +; buffer-disconnected reset state (PIN_CNF INPUT=Disconnect) instead of Arduino +; INPUT. The tri-state arm recovered only half the gating regression (3.212 -> +; 2.102 mA, still +1.04 mA vs the radio-powered 1.060 mA floor); a floating pin +; with its input buffer connected conducts near threshold, and pinMode(INPUT) +; leaves the buffer connected. Single delta vs _radiogate: SLEEP_RADIO_PIN_DISCONNECT. +; Success = below 1.060 mA (gating finally nets a saving). Verify sonar cycling + +; TX/ACK over serial with USB IN before any battery capture. +extends = env:Rook_field_node_bench +build_flags = ${env:Rook_field_node_bench.build_flags} + -D SLEEP_RADIO_POWER_OFF=1 + -D SLEEP_RADIO_TRISTATE=1 + -D SLEEP_RADIO_PIN_DISCONNECT=1 + +[env:Rook_field_node_bench_pinaudit] +; Diagnostic (pad audit): radio left powered (slowpoll baseline), but the listed +; non-radio pads (sonar strobe D1, sonar RX D0) are buffer-disconnected during +; sleep instead of left INPUT/floating. Single delta vs _slowpoll: +; SLEEP_PIN_DISCONNECT_AUDIT. Success = >= 50 uA below the 1.060 mA floor. +extends = env:Rook_field_node_bench_slowpoll +build_flags = ${env:Rook_field_node_bench_slowpoll.build_flags} + -D SLEEP_PIN_DISCONNECT_AUDIT=1 + +[env:Rook_field_node_bench_pindisc_audit] +; Diagnostic: the two above combined -- radio gated with disconnected lines AND +; the non-radio pad audit. The sub-mA-without-architecture-fork stretch arm. +; NOT RUN: falsified by implication when the disconnect diagnostic showed the +; gating residual is not pin-state-controllable. +extends = env:Rook_field_node_bench +build_flags = ${env:Rook_field_node_bench.build_flags} + -D SLEEP_RADIO_POWER_OFF=1 + -D SLEEP_RADIO_TRISTATE=1 + -D SLEEP_RADIO_PIN_DISCONNECT=1 + -D SLEEP_PIN_DISCONNECT_AUDIT=1 + +[env:Rook_field_node_bench_diag] +; Diagnostic: slowpoll behavior plus a one-line state dump at each sleep +; entry (SoftDevice? USBD? HFCLK? FPU pending? RXEN/NSS levels). Decides which +; surgery candidates matter before any battery capture. Bench-only. +extends = env:Rook_field_node_bench_slowpoll +build_flags = ${env:Rook_field_node_bench_slowpoll.build_flags} + -D SLEEP_DIAG=1 + +[env:Rook_field_node_bench_usbd] +; Surgery candidate (USBD): disable USBD during battery sleep. The Adafruit core +; enables USBD unconditionally; enabled USBD holds HFCLK (~1 mA-class classic +; floor signature). Fires only when USBREGSTATUS shows no VBUS, so USB bench +; flows are untouched. Single delta vs _slowpoll: SLEEP_DISABLE_USBD. +; Prediction: removes >= 400 uA of the ~910 uA System-ON gap. +extends = env:Rook_field_node_bench_slowpoll +build_flags = ${env:Rook_field_node_bench_slowpoll.build_flags} + -D SLEEP_DISABLE_USBD=1 + +[env:Rook_field_node_bench_radiosleepfix] +; Surgery candidate (radio sleep sequence): corrected sequence -- standby() -> clear all +; radio IRQs -> sleep(true), then RXEN driven LOW and NSS parked driven-HIGH. +; radio.sleep() straight from Rx with a pending IRQ can strand the die in +; STANDBY (600-800 uA; RadioLib #1736 on this same module combo). Single delta +; vs _slowpoll: SLEEP_RADIO_SEQ_FIX. Prediction: -55 uA (die was sleeping, +; RF-switch tranche only) or -600..800 uA (die was stranded). +extends = env:Rook_field_node_bench_slowpoll +build_flags = ${env:Rook_field_node_bench_slowpoll.build_flags} + -D SLEEP_RADIO_SEQ_FIX=1 + +[env:Rook_field_node_bench_fpuclear] +; Surgery candidate (Errata 87): FPU-pending clear before each sleep poll. +; Promoted from contingent to required by the diagnostic dump (2026-07-20): fpu_pend=1 at every +; sleep entry -- WFE falls through, idle task spins. Single delta vs _slowpoll. +extends = env:Rook_field_node_bench_slowpoll +build_flags = ${env:Rook_field_node_bench_slowpoll.build_flags} + -D SLEEP_FPU_CLEAR=1 + +[env:Rook_field_node_bench_hfclkstop] +; Surgery candidate (HFXO): stop the HFXO at battery sleep entry. Motivated by the +; null sweep of the other candidates + the diagnostic hfclkstat=0x10001: the crystal runs through sleep and +; nothing we disabled released it. Boot-on-USB latches an HFCLK request that no +; battery-side event releases; this stops the clock directly (VBUS-gated). +; Single delta vs _slowpoll. Prediction: if the latched HFXO owns the ~900 uA +; gap, the floor collapses toward the ~150-250 uA band. +extends = env:Rook_field_node_bench_slowpoll +build_flags = ${env:Rook_field_node_bench_slowpoll.build_flags} + -D SLEEP_HFCLK_STOP=1 + +[env:Rook_field_node_bench_usbd_radiofix] +; Surgery combo: USBD + radio-sequence + FPU-clear together. Target: sleep floor +; < 400 uA (stretch < 250 uA) with TX/ACK link health intact. Board hardware +; floor is ~147 uA (sleep_test v4). +extends = env:Rook_field_node_bench_slowpoll +build_flags = ${env:Rook_field_node_bench_slowpoll.build_flags} + -D SLEEP_DISABLE_USBD=1 + -D SLEEP_RADIO_SEQ_FIX=1 + -D SLEEP_FPU_CLEAR=1 + +; ---- Strip-down ladder (cumulative; adjacent deltas attribute layers) + +[env:Rook_field_node_bench_strip_display] +; S1: hfclkstop config minus the OLED (begin guarded, on/off stubbed). ACK alive-check. +extends = env:Rook_field_node_bench_hfclkstop +build_flags = ${env:Rook_field_node_bench_hfclkstop.build_flags} + -D STRIP_DISPLAY=1 + +[env:Rook_field_node_bench_strip_sonar] +; S2: + sonar lifecycle removed (helpers no-op, probe skipped, gate held off). ACK. +extends = env:Rook_field_node_bench_hfclkstop +build_flags = ${env:Rook_field_node_bench_hfclkstop.build_flags} + -D STRIP_DISPLAY=1 + -D STRIP_SONAR=1 + +[env:Rook_field_node_bench_strip_mesh] +; S3: + no the_mesh.begin/loop, no mode machine; radio init then immediate warm +; sleep (v4 pattern); bare heartbeat idle loop. InternalFS still begins. +; Alive-check = [HB] heartbeat (cannot ACK). +extends = env:Rook_field_node_bench_hfclkstop +build_flags = ${env:Rook_field_node_bench_hfclkstop.build_flags} + -D STRIP_DISPLAY=1 + -D STRIP_SONAR=1 + -D STRIP_MESH=1 + -D HEARTBEAT=1 + +[env:Rook_field_node_bench_strip_fs] +; S4: + no InternalFS.begin. (FS cannot be stripped below the mesh -- the_mesh +; needs it -- hence mesh strips first; this delta attributes the filesystem.) +extends = env:Rook_field_node_bench_hfclkstop +build_flags = ${env:Rook_field_node_bench_hfclkstop.build_flags} + -D STRIP_DISPLAY=1 + -D STRIP_SONAR=1 + -D STRIP_MESH=1 + -D STRIP_FS=1 + -D HEARTBEAT=1 diff --git a/variants/sonar_field_node/radio_lowpower.h b/variants/sonar_field_node/radio_lowpower.h new file mode 100644 index 0000000000..d8f5e5ed91 --- /dev/null +++ b/variants/sonar_field_node/radio_lowpower.h @@ -0,0 +1,162 @@ +#pragma once +// App-side low-power radio control for the duty-cycled field node. +// +// Keeps MeshCore PRISTINE: instead of patching the Dispatcher (which owns the radio +// and polls it every loop), we SPI-sleep the RadioLib radio object directly and have +// the app PAUSE the mesh -- loop() skips the_mesh.loop() while g_radio_slept is set, +// so nothing polls the slept radio (a poll would deadlock on the SX1262 BUSY line). +// +// Wake is standby + re-arm Rx only. radio.sleep() is a WARM sleep: it retains config +// AND the DIO/ISR mapping, so we do NOT call radio_driver.begin() on wake (begin() +// re-runs SPI setup and deadlocks against a still-slept radio -- that was the original hang). +// The MeshCore wrapper's internal state stays STATE_RX across this (sleep() never +// changes it), and we re-arm hardware Rx to match, so isInRecvMode() stays consistent +// and the dispatcher resumes cleanly when the app un-pauses it. +// +// MCU stays System-ON idle (delay()/waitForEvent) -- required for a timed wake with no +// external RTC. This module only governs the radio. + +#include // SPI.end()/begin() for the tri-state path +#include // RADIO_CLASS + the shared radio object, SX126X_POWER_EN +extern RADIO_CLASS radio; + +// SLEEP_RADIO_POWER_OFF: also drop the radio's supply gate during sleep. +// +// SX126X_POWER_EN (pin 21 / P0.13) is driven HIGH once in RookBoard::begin() and never +// touched again, so the TCXO + RF-switch domain stays powered even while the SX1262 +// itself is SPI-slept. The sleep_test ladder measured what that domain costs: +// POWER_EN high, radio in POR standby ... 2.82 mA +// POWER_EN LOW ..................... 1.05 mA (-1.77 mA: TCXO + RF switch) +// + SPI sleep ...................... 146.6 uA (-0.9 mA: digital core) +// We currently do the second half only, and sit at ~1.06 mA -- close to that 1.05 mA +// rung, which is what makes this the prime suspect for the remaining floor. +// +// COST: cutting power loses the chip's configuration, so warm sleep is off the table +// and wake must do a full cold re-init (radio_init -> std_init). That is deliberately +// NOT the same operation as the original hang: those were begin() against a chip still in +// SPI-sleep, whereas this is a cold power-up, i.e. the normal boot path. +// Default 0 so the measured 1.066 mA configuration stays the default until this is +// proven on the bench. +#ifndef SLEEP_RADIO_POWER_OFF +#define SLEEP_RADIO_POWER_OFF 0 +#endif + +// SLEEP_RADIO_TRISTATE: hi-Z every nRF52 line into the SX1262 before cutting its +// supply, and release SPIM so it stops driving SCK/MOSI. +// +// MEASURED 2026-07-18: dropping POWER_EN *without* this made sleep 3x WORSE +// (1.0595 mA -> 3.2120 mA, 300 sigma). With the radio's VCC gated off, the MCU was +// still driving NSS/SCK/MOSI/RXEN/DIO into a dead chip, so current flowed through +// the SX1262's input protection diodes into its unpowered rail -- back-powering it +// through its own pins, in an undefined state. Same physics as the sonar +// sneak path, where an active UART RX pad loaded the sensor while its ground floated. +// +// This is also why the early sleep_test result (POWER_EN low saves 1.77 mA) did not transfer: that was +// measured in the minimal sleep_test sketch, where these lines were not being driven. +// A power figure measured in a minimal sketch does not carry to full firmware if the +// pin states differ -- the pins are part of the measurement. +#ifndef SLEEP_RADIO_TRISTATE +#define SLEEP_RADIO_TRISTATE 0 +#endif + +// SLEEP_RADIO_PIN_DISCONNECT: use the nRF52's PIN_CNF INPUT=Disconnect state +// instead of Arduino INPUT for the eight radio lines while the supply is gated. +// +// The tri-state arm (2026-07-18) recovered only half the back-powering +// regression: 3.212 -> 2.102 mA, still +1.04 mA over the radio-powered floor. +// pinMode(INPUT) leaves the pin floating with its input buffer CONNECTED, and a +// floating CMOS input sitting near threshold conducts through the buffer. The +// chip's own reset state for every GPIO is buffer-disconnected (PIN_CNF=0x0002); +// nrf_gpio_cfg_default() returns a pin to it. This arm tests whether the +// residual +1.04 mA was buffer conduction. Wake needs no special restore: the +// cold re-init path (SPI.begin + radio_init) re-runs every pinMode, which +// rewrites PIN_CNF completely -- the same restore the tri-state arm proved out. +#ifndef SLEEP_RADIO_PIN_DISCONNECT +#define SLEEP_RADIO_PIN_DISCONNECT 0 +#endif + +#if SLEEP_RADIO_PIN_DISCONNECT +#include "nrf_gpio.h" +// Arduino pin -> nRF GPIO via the variant map (D0/D1-swapped Rook variant -- +// never hand-translate pin numbers; the map is the authority). +static inline void radio_pin_silence(uint32_t arduino_pin) { + nrf_gpio_cfg_default(g_ADigitalPinMap[arduino_pin]); +} +#endif + +// SLEEP_RADIO_SEQ_FIX: enter sleep via standby -> clear IRQs -> sleep(warm), +// then park the RF switch and chip-select for the sleep window. +// +// The bare radio.sleep() below is issued straight from continuous Rx after +// TX/ACK traffic. RadioLib #1736 (measured on this same nRF52840 + Wio-SX1262 +// combo) shows a pending radio interrupt at SetSleep can strand the die in +// STANDBY: 600 uA (RC) / 800 uA (XOSC) instead of ~1 uA -- which would account +// for most of our 1.06 mA floor by itself. Also per the module docs: RF_SW held +// high costs +55 uA, and NSS must stay a DRIVEN HIGH (a floating NSS measures +// 2.2 mA). Wake path is unchanged: standby() + startReceive(), the proven arc. +#ifndef SLEEP_RADIO_SEQ_FIX +#define SLEEP_RADIO_SEQ_FIX 0 +#endif + +static bool g_radio_slept = false; + +// SPI-sleep the radio (~1 uA). Caller must then stop servicing the mesh +// (loop() gates on g_radio_slept) until radio_wake_lp(). +static inline void radio_sleep_lp() { +#if SLEEP_RADIO_SEQ_FIX + radio.standby(); // leave Rx cleanly before sleeping + radio.clearIrqStatus(); // clear ALL pending radio IRQs so SetSleep sticks + radio.sleep(true); // warm sleep, config retained + digitalWrite(SX126X_RXEN, LOW); // RF switch off for the window (+55 uA if left high) + digitalWrite(P_LORA_NSS, HIGH); // chip-select parked driven-HIGH, never floating +#else + radio.sleep(); // SX126x SetSleep (warm: config + DIO retained) +#endif +#if SLEEP_RADIO_POWER_OFF +#if SLEEP_RADIO_TRISTATE + // Stop driving anything into the chip BEFORE removing its supply, or the MCU + // back-powers it through the input protection diodes (measured: +2.15 mA). + SPI.end(); // release SPIM: stops driving SCK/MOSI +#if SLEEP_RADIO_PIN_DISCONNECT + // Buffer-disconnected (chip reset state), not merely floating: a floating + // input with its buffer connected still conducts near threshold. + radio_pin_silence(P_LORA_NSS); // P1.13 + radio_pin_silence(P_LORA_SCLK); // P1.11 + radio_pin_silence(P_LORA_MOSI); // P1.15 + radio_pin_silence(P_LORA_MISO); // P0.02 + radio_pin_silence(P_LORA_RESET); // P0.09 + radio_pin_silence(P_LORA_BUSY); // P0.29 + radio_pin_silence(P_LORA_DIO_1); // P0.10 + radio_pin_silence(SX126X_RXEN); // P0.17 +#else + pinMode(P_LORA_NSS, INPUT); + pinMode(P_LORA_SCLK, INPUT); + pinMode(P_LORA_MOSI, INPUT); + pinMode(P_LORA_MISO, INPUT); + pinMode(P_LORA_RESET, INPUT); + pinMode(P_LORA_BUSY, INPUT); + pinMode(P_LORA_DIO_1, INPUT); + pinMode(SX126X_RXEN, INPUT); +#endif +#endif + digitalWrite(SX126X_POWER_EN, LOW); // cut TCXO + RF-switch domain +#endif + g_radio_slept = true; +} + +// Wake the radio and re-arm continuous Rx, then let the mesh resume. +static inline void radio_wake_lp() { +#if SLEEP_RADIO_POWER_OFF + digitalWrite(SX126X_POWER_EN, HIGH); + delay(10); // TCXO settle -- same 10 ms RookBoard::begin() allows +#if SLEEP_RADIO_TRISTATE + SPI.begin(); // SPIM back up before std_init drives the bus +#endif + radio_init(); // cold re-init (std_init): config was lost with power + radio.startReceive(); // re-arm continuous Rx +#else + radio.standby(); // wake out of warm sleep to standby + radio.startReceive(); // continuous Rx (DIO/ISR mapping retained through warm sleep) +#endif + g_radio_slept = false; +} diff --git a/variants/sonar_field_node/sonar_field_node_main.cpp b/variants/sonar_field_node/sonar_field_node_main.cpp new file mode 100644 index 0000000000..3278fb2ec5 --- /dev/null +++ b/variants/sonar_field_node/sonar_field_node_main.cpp @@ -0,0 +1,1972 @@ +#include // needed for PlatformIO +#include // TWI handle, so sleep can release the bus (SLEEP_RELEASE_TWI) +#include + +#if defined(NRF52_PLATFORM) + #include +#elif defined(RP2040_PLATFORM) + #include +#elif defined(ESP32) + #include +#endif + +#include +#include +#include +#include +#include +#include + +extern RADIO_CLASS radio; // RadioLib radio object (target.cpp) -- for getDeviceErrors() + +/* ---------------------------------- CONFIGURATION ------------------------------------- */ + +#define FIRMWARE_VER_TEXT "sonar_field_node (build: Jul 22 2026, v3.1 gateA fixes) [v3-ultrasonic + MaxBotix MB7388 -> distance_meters; duty-cycle mode machine; link-health LED (send=toggle, ACK=off, timeout=on, off on sleep) + STATUS tx-fail count]" + +#ifndef LORA_FREQ + #define LORA_FREQ 910.525 +#endif +#ifndef LORA_BW + #define LORA_BW 62.5 +#endif +#ifndef LORA_SF + #define LORA_SF 7 +#endif +#ifndef LORA_CR + #define LORA_CR 5 +#endif +#ifndef LORA_TX_POWER + #define LORA_TX_POWER 20 +#endif + +#ifndef MAX_CONTACTS + #define MAX_CONTACTS 32 +#endif + +#ifndef ULTRASONIC_BAUD + #define ULTRASONIC_BAUD 9600 +#endif + +// How long to wait for a fresh MaxBotix frame (continuous mode is ~6 Hz, so +// any wait > ~170 ms should hit at least one frame). +#ifndef ULTRASONIC_READ_TIMEOUT_MS + #define ULTRASONIC_READ_TIMEOUT_MS 400 +#endif + +#ifndef ADVERT_NAME + #define ADVERT_NAME "Sensor" +#endif + +#ifndef SENSOR_SEND_INTERVAL_SECS + #define SENSOR_SEND_INTERVAL_SECS 300 +#endif + +#include + +#define SEND_TIMEOUT_BASE_MILLIS 500 +#define FLOOD_SEND_TIMEOUT_FACTOR 16.0f +#define DIRECT_SEND_PERHOP_FACTOR 6.0f +#define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250 + +#define TARGET_PREFIX_LEN 6 +#ifndef MAX_SEND_TARGETS + #define MAX_SEND_TARGETS 4 +#endif + +/* ---------------------------------- DISPLAY PAGES ------------------------------------- */ + +enum DisplayPage { + PAGE_STATUS = 0, // Shows distance, battery, target, interval + PAGE_SEND, // "Send sensor" -- long press to send + PAGE_ADVERT, // "Send advert" -- long press to broadcast + PAGE_COUNT +}; + +#define DISPLAY_REFRESH_MS 500 +#define DISPLAY_AUTO_OFF_MS 30000 +#define LONG_PRESS_MILLIS 1200 +#define ALERT_DURATION_MS 1500 + +// MB7388 pin 4 (Ranging Start/Stop) wired to Arduino D1 = P0.06, reclaimed from +// Serial1 TX (no TX wire needed). Commanded ranging: hold high to range, low to +// stop. Serial1.begin() otherwise owns D1 as UART TXD idling HIGH -> the sensor +// free-runs and backlogs unsolicited frames, so setup() detaches TXD via PSEL. +#define PIN_STROBE 1 +#ifndef SONAR_SAMPLES +#define SONAR_SAMPLES 7 // frames to collect per reading; median rejects surface chop +#endif + +// --- power knobs, each separately measurable ------------------------------------ +// Default 0 so the default build stays exactly the configuration measured at +// 1.066 mA on 2026-07-18. Each gets its own env so its effect is attributable +// before it is adopted: measure first, then make it the default. +// +// PWR_ENABLE_DCDC: RookBoard derives from NRF52BoardDCDC, but RookBoard::begin() +// calls NRF52Board::begin() -- the GRANDparent -- so NRF52BoardDCDC::begin() never +// runs and the DC/DC enable is silently skipped. The board has been running on the +// LDO. Enabling DC/DC on an nRF52840 typically saves 30-40% of supply current. +// Done app-side rather than by patching Don's RookBoard.cpp; worth reporting +// upstream as a board-support bug either way. +#ifndef PWR_ENABLE_DCDC +#define PWR_ENABLE_DCDC 0 +#endif + +// SLEEP_RELEASE_TWI: Wire.begin() runs once in RookBoard::begin() and is never +// ended, so TWIM stays enabled across sleep. Same class of leak as the UART. +#ifndef SLEEP_RELEASE_TWI +#define SLEEP_RELEASE_TWI 0 +#endif + +// Compile knob for the attribution A/B. 1 (default) releases UARTE0 across sleep; +// 0 leaves it enabled the way the pre-2026-07-18 firmware did, which is what the +// measured 6.6 mA floor came from. Build the paired env to reproduce that arm +// rather than passing an ephemeral -D, so the comparison stays replicable. +#ifndef SLEEP_RELEASE_UART +#define SLEEP_RELEASE_UART 1 +#endif + +// SLEEP_PIN_DISCONNECT_AUDIT: during sleep, put the explicitly listed non-radio +// pins into the nRF52's buffer-disconnected reset state (PIN_CNF INPUT=Disconnect +// via nrf_gpio_cfg_default) instead of whatever floating/input state the sleep +// path leaves them in. pinMode(INPUT) keeps the input buffer connected, and a +// floating buffer near threshold conducts. Restore contract: each pin's own +// bring-up in MODE_WAKE rewrites its PIN_CNF (sonar_serial_begin -> strobe +// OUTPUT + Serial1 RX; Wire.begin -> SDA/SCL), so no explicit reconnect step +// exists to forget. The list is closed-form on purpose -- only pins whose wake +// path provably reconfigures them. Sonar gate (D5/P0.24) stays a driven OUTPUT. +#ifndef SLEEP_PIN_DISCONNECT_AUDIT +#define SLEEP_PIN_DISCONNECT_AUDIT 0 +#endif + +// SLEEP_DISABLE_USBD: turn the USB peripheral off during battery sleep. +// +// The Adafruit core calls TinyUSB_Device_Init unconditionally, so USBD stays +// enabled on battery with no host attached. An enabled USBD holds the HFCLK and +// is the classic signature of a ~1 mA nRF52840 sleep floor. Only fires when +// USBREGSTATUS reports no VBUS, so every USB-attached bench flow is untouched. +// One-way on battery: replugging USB later needs a reset to re-enumerate -- +// acceptable for a field node, and stated wherever this knob gets adopted. +#ifndef SLEEP_DISABLE_USBD +#define SLEEP_DISABLE_USBD 0 +#endif + +// SLEEP_FPU_CLEAR: apply the nRF52840 Errata-87 workaround before each sleep +// poll. Float math latches an FPSCR exception bit which keeps the FPU IRQ +// pending; a pending IRQ makes WFE fall straight through, so the FreeRTOS idle +// task spins instead of sleeping. QT1 (2026-07-20) measured fpu_pend=1 at +// every sleep entry on this firmware, so this is live, not theoretical. +#ifndef SLEEP_FPU_CLEAR +#define SLEEP_FPU_CLEAR 0 +#endif + +// --- bisection strip knobs (cumulative, app layer only) --- +// Each STRIP_* removes one stack layer so adjacent JS220 floors attribute its +// cost. Ladder: S1 display -> S2 sonar -> S3 filesystem -> S4 mesh (radio goes +// RadioLib-direct: init then immediate warm sleep; loop is a bare heartbeat). +// All default 0 -- the deployment build is untouched. +#ifndef STRIP_DISPLAY +#define STRIP_DISPLAY 0 +#endif +#ifndef STRIP_SONAR +#define STRIP_SONAR 0 +#endif +#ifndef STRIP_FS +#define STRIP_FS 0 +#endif +#ifndef STRIP_MESH +#define STRIP_MESH 0 +#endif +// HEARTBEAT: '[HB] ' each sleep poll -- the alive-check for stripped +// arms that cannot TX/ACK. +#ifndef HEARTBEAT +#define HEARTBEAT 0 +#endif + +#if STRIP_DISPLAY +// turnOn() re-inits the panel (SSD1306Display.cpp:24), so a begin() guard alone +// is not enough -- stub the on/off calls at their sites. +#define DISPLAY_TURN_ON() ((void)0) +#define DISPLAY_TURN_OFF() ((void)0) +#else +#define DISPLAY_TURN_ON() display.turnOn() +#define DISPLAY_TURN_OFF() display.turnOff() +#endif + +// SLEEP_HFCLK_STOP: stop the HFXO during battery sleep. QT1 showed the 64 MHz +// crystal RUNNING through sleep; disabling USBD alone did not release it. +// Suspected mechanism: the node always boots on USB, the USB bring-up requests +// HFCLK, and nothing releases the request when VBUS goes away mid-run -- the +// release rides a power event no task processes on battery. Rather than chase +// the requester, stop the clock at sleep entry when VBUS is absent. Peripherals +// that need HF later fall back to HFINT on demand; the SX1262 runs its own +// TCXO and does not care about the MCU crystal. +#ifndef SLEEP_HFCLK_STOP +#define SLEEP_HFCLK_STOP 0 +#endif + +// SLEEP_DIAG: print the power-relevant machine state at each sleep entry +// (SoftDevice enabled? USBD enabled? HFCLK source+state? FPU IRQ pending? +// RXEN/NSS levels). Costs a few ms of serial once per cycle; bench-only knob. +#ifndef SLEEP_DIAG +#define SLEEP_DIAG 0 +#endif + +#if SLEEP_DIAG +static void sleep_diag_dump() { + // Boot-entry guard: the mode machine STARTS in MODE_SLEEP, so this runs in the + // first instants of boot -- and reading USBD registers before the USB power + // domain finishes sequencing bus-faults the core (node dies before CDC ever + // enumerates; cost us three invisible flashes). Skip the boot entry; the + // first informative dump is after a real wake/TX cycle anyway. Same reason + // there is no sd_softdevice_is_enabled() SVC here. + static bool first_entry = true; + if (first_entry) { first_entry = false; return; } + Serial.print("[DIAG] usbd_en="); Serial.print(NRF_USBD->ENABLE); + Serial.print(" vbus="); Serial.print((NRF_POWER->USBREGSTATUS & 1) ? 1 : 0); + Serial.print(" hfclkstat=0x"); Serial.print(NRF_CLOCK->HFCLKSTAT, HEX); + Serial.print(" fpu_pend="); Serial.print(NVIC_GetPendingIRQ(FPU_IRQn)); + Serial.print(" rxen="); Serial.print(digitalRead(SX126X_RXEN)); + Serial.print(" nss="); Serial.println(digitalRead(P_LORA_NSS)); +} +#endif + +#if SLEEP_PIN_DISCONNECT_AUDIT +#include "nrf_gpio.h" +static void sleep_pins_disconnect() { + nrf_gpio_cfg_default(g_ADigitalPinMap[PIN_STROBE]); // D1/P0.06: sonar_serial_end left it INPUT (buffer on) + nrf_gpio_cfg_default(g_ADigitalPinMap[PIN_SERIAL1_RX]); // D0/P0.08: sonar RX pad after UARTE release +#if SLEEP_RELEASE_TWI + nrf_gpio_cfg_default(g_ADigitalPinMap[PIN_WIRE_SDA]); // I2C pads only when TWIM is truly ended + nrf_gpio_cfg_default(g_ADigitalPinMap[PIN_WIRE_SCL]); +#endif +} +#endif + +// --- sonar serial lifecycle (deep-idle support) -------------------------------- +// UARTE0 is a power domain of its own: left enabled it holds the HFCLK running and +// costs on the order of a milliamp even with no traffic, which puts a floor under +// any System-ON idle. So the sleep path must DISABLE it, not merely stop reading. +// +// Every Serial1.begin() re-attaches UARTE TXD to D1, so the PSEL detach has to be +// re-applied here on each bring-up or the sensor free-runs and backlogs unsolicited +// frames (an earlier bug). Keeping both halves in one place is what stops the +// wake path from silently forgetting it. +static void sonar_serial_begin() { +#if STRIP_SONAR + return; // bisection arm: sonar layer removed +#endif + Serial1.begin(ULTRASONIC_BAUD); + NRF_UARTE0->PSEL.TXD = 0xFFFFFFFF; // reclaim D1 from UART TX -> GPIO strobe + pinMode(PIN_STROBE, OUTPUT); + digitalWrite(PIN_STROBE, LOW); // ranging stopped until a read commands it +} + +// Release UARTE0 and both sonar pads. Called BEFORE gating the sensor off: an +// active UART RX pad sneak-loads the MB7388's output while its ground floats, +// which is the loading path chased earlier. +static void sonar_serial_end() { +#if STRIP_SONAR + return; // bisection arm: sonar layer removed +#endif + Serial1.end(); + NRF_UARTE0->ENABLE = 0; // ensure the peripheral is truly off, not idle + pinMode(PIN_STROBE, INPUT); // hi-Z: never drive into an unpowered sensor +} + +// median of the first n ints in a[] (n small; insertion sort in place) +static int median_int(int *a, int n) { + for (int i = 1; i < n; i++) { + int v = a[i], j = i - 1; + while (j >= 0 && a[j] > v) { a[j + 1] = a[j]; j--; } + a[j + 1] = v; + } + return a[n / 2]; +} + +// Believe it or not, this std C function is busted on some platforms! +static uint32_t _atoi(const char* sp) { + uint32_t n = 0; + while (*sp && *sp >= '0' && *sp <= '9') { + n *= 10; + n += (*sp++ - '0'); + } + return n; +} + +/* -------------------------------------------------------------------------------------- */ + +// MaxBotix MB7388 sensor globals. +// Reads ASCII frames of the form "Rxxxx\r" on Serial1 (9600 baud, TTL). +// MB7388 free-runs at ~6 Hz when its pin 4 (RX/strobe) is left floating or HIGH. +// xxxx is millimeters; range 300 mm - 5000 mm. 0 / out-of-range readings are +// reported by the sensor itself (e.g. "R5000" for max-out). +static bool has_sensor = false; +static unsigned long sensor_last_frame_at = 0; + +// Cached sensor readings (updated each send interval) +static float last_dist_m = 0; // meters, parsed from MaxBotix +static float last_batt = 0; +static bool has_reading = false; + +// v3: last-send ACK status -- surfaced on the OLED status page so a button-triggered +// send shows whether the receiver actually acknowledged. +enum AckStatus { + ACK_STATUS_NONE = 0, + ACK_STATUS_PENDING, + ACK_STATUS_OK, + ACK_STATUS_TIMEOUT +}; + +class MyMesh : public BaseChatMesh, ContactVisitor { + FILESYSTEM* _fs; + uint32_t expected_ack_crc; + unsigned long last_msg_sent; + char command[512]; + uint8_t tmp_buf[256]; + char hex_buf[512]; + + // Target contacts (6-byte pub key prefixes, persisted to flash) + uint8_t target_prefixes[MAX_SEND_TARGETS][TARGET_PREFIX_LEN]; + uint8_t target_count; + + // Node config + char node_name[32]; + uint32_t send_interval_secs; + uint32_t last_send_time; // RTC timestamp of last send + uint16_t node_id; + + // Last send result for display feedback + uint8_t last_send_successes; + uint8_t last_send_attempts; + + // v2: remember which target prefix was used for the most recent direct send, + // so onSendTimeout can invalidate its cached path. 0xFF = none pending. + uint8_t last_ack_target_idx; + + // v3: ACK status of most recent send, displayed on OLED status page. + AckStatus last_ack_status; + uint32_t last_ack_rt_ms; // round-trip ms of the most recent ACK (serial log only) + unsigned long last_ack_at_millis; // millis() when the most recent OK ACK arrived + + // link-health LED (P0.15). Rules: a send TOGGLES it (packet in flight), a + // positive ACK forces it OFF (healthy), a timeout forces it ON (problem). The + // mode machine forces it OFF on MODE_SLEEP entry so a fully-failed transmit + // never leaves the lamp lit into sleep (power). + bool led_on; + void ledWrite(bool on) { + led_on = on; + digitalWrite(LED_PIN, (on == (LED_STATE_ON != 0)) ? HIGH : LOW); + } + void ledToggle() { ledWrite(!led_on); } + + // last-transmit outcome, surfaced on the STATUS page. Set by the mode machine + // when a MODE_TRANSMIT sequence ends: failed=true means all attempts missed. + bool last_tx_failed; + uint8_t last_tx_attempts; // sends attempted in that last transmit + + void loadContacts() { + if (_fs->exists("/contacts")) { + #if defined(RP2040_PLATFORM) + File file = _fs->open("/contacts", "r"); + #else + File file = _fs->open("/contacts"); + #endif + if (file) { + bool full = false; + while (!full) { + ContactInfo c; + uint8_t pub_key[32]; + uint8_t unused; + uint32_t reserved; + + bool success = (file.read(pub_key, 32) == 32); + success = success && (file.read((uint8_t *) &c.name, 32) == 32); + success = success && (file.read(&c.type, 1) == 1); + success = success && (file.read(&c.flags, 1) == 1); + success = success && (file.read(&unused, 1) == 1); + success = success && (file.read((uint8_t *) &reserved, 4) == 4); + success = success && (file.read((uint8_t *) &c.out_path_len, 1) == 1); + success = success && (file.read((uint8_t *) &c.last_advert_timestamp, 4) == 4); + success = success && (file.read(c.out_path, 64) == 64); + c.gps_lat = c.gps_lon = 0; + + if (!success) break; // EOF + + c.id = mesh::Identity(pub_key); + c.lastmod = 0; + if (!addContact(c)) full = true; + } + file.close(); + } + } + } + + void saveContacts() { + #if defined(NRF52_PLATFORM) + _fs->remove("/contacts"); + File file = _fs->open("/contacts", FILE_O_WRITE); + #elif defined(RP2040_PLATFORM) + File file = _fs->open("/contacts", "w"); + #else + File file = _fs->open("/contacts", "w", true); + #endif + if (file) { + ContactsIterator iter; + ContactInfo c; + uint8_t unused = 0; + uint32_t reserved = 0; + + while (iter.hasNext(this, c)) { + bool success = (file.write(c.id.pub_key, 32) == 32); + success = success && (file.write((uint8_t *) &c.name, 32) == 32); + success = success && (file.write(&c.type, 1) == 1); + success = success && (file.write(&c.flags, 1) == 1); + success = success && (file.write(&unused, 1) == 1); + success = success && (file.write((uint8_t *) &reserved, 4) == 4); + success = success && (file.write((uint8_t *) &c.out_path_len, 1) == 1); + success = success && (file.write((uint8_t *) &c.last_advert_timestamp, 4) == 4); + success = success && (file.write(c.out_path, 64) == 64); + + if (!success) break; + } + file.close(); + } + } + + void loadTargets() { + target_count = 0; + + // New multi-target format: /targets + if (_fs->exists("/targets")) { + #if defined(RP2040_PLATFORM) + File file = _fs->open("/targets", "r"); + #else + File file = _fs->open("/targets"); + #endif + if (file) { + uint8_t count = 0; + if (file.read(&count, 1) == 1 && count <= MAX_SEND_TARGETS) { + for (uint8_t i = 0; i < count; i++) { + if (file.read(target_prefixes[i], TARGET_PREFIX_LEN) != TARGET_PREFIX_LEN) break; + target_count++; + } + } + file.close(); + } + return; + } + + // Migrate from old single-target format: /target + if (_fs->exists("/target")) { + #if defined(RP2040_PLATFORM) + File file = _fs->open("/target", "r"); + #else + File file = _fs->open("/target"); + #endif + if (file) { + if (file.read(target_prefixes[0], TARGET_PREFIX_LEN) == TARGET_PREFIX_LEN) { + target_count = 1; + } + file.close(); + } + if (target_count > 0) { + saveTargets(); // write in new format + _fs->remove("/target"); + } + } + } + + void saveTargets() { + #if defined(NRF52_PLATFORM) + _fs->remove("/targets"); + File file = _fs->open("/targets", FILE_O_WRITE); + #elif defined(RP2040_PLATFORM) + File file = _fs->open("/targets", "w"); + #else + File file = _fs->open("/targets", "w", true); + #endif + if (file) { + file.write(&target_count, 1); + for (uint8_t i = 0; i < target_count; i++) { + file.write(target_prefixes[i], TARGET_PREFIX_LEN); + } + file.close(); + } + } + + int findTargetIndex(const uint8_t* prefix) { + for (uint8_t i = 0; i < target_count; i++) { + if (memcmp(target_prefixes[i], prefix, TARGET_PREFIX_LEN) == 0) return i; + } + return -1; + } + + bool addTargetPrefix(const uint8_t* prefix) { + if (findTargetIndex(prefix) >= 0) return true; // already present + if (target_count >= MAX_SEND_TARGETS) return false; + memcpy(target_prefixes[target_count], prefix, TARGET_PREFIX_LEN); + target_count++; + saveTargets(); + return true; + } + + bool removeTargetPrefix(const uint8_t* prefix) { + int idx = findTargetIndex(prefix); + if (idx < 0) return false; + for (uint8_t i = idx; i < target_count - 1; i++) { + memcpy(target_prefixes[i], target_prefixes[i+1], TARGET_PREFIX_LEN); + } + target_count--; + saveTargets(); + return true; + } + + void loadPrefs() { + if (_fs->exists("/sensor_prefs")) { + #if defined(RP2040_PLATFORM) + File file = _fs->open("/sensor_prefs", "r"); + #else + File file = _fs->open("/sensor_prefs"); + #endif + if (file) { + uint32_t interval; + if (file.read((uint8_t*)&interval, 4) == 4) { + send_interval_secs = interval; + } + char name[32]; + if (file.read((uint8_t*)name, 32) == 32 && name[0] != 0) { + memcpy(node_name, name, 32); + } + uint16_t id; + if (file.read((uint8_t*)&id, 2) == 2) { + node_id = id; + } + file.close(); + } + } + } + + void savePrefs() { + #if defined(NRF52_PLATFORM) + _fs->remove("/sensor_prefs"); + File file = _fs->open("/sensor_prefs", FILE_O_WRITE); + #elif defined(RP2040_PLATFORM) + File file = _fs->open("/sensor_prefs", "w"); + #else + File file = _fs->open("/sensor_prefs", "w", true); + #endif + if (file) { + file.write((const uint8_t*)&send_interval_secs, 4); + file.write((const uint8_t*)node_name, 32); + file.write((const uint8_t*)&node_id, 2); + file.close(); + } + } + + void importCard(const char* command) { + while (*command == ' ') command++; + if (memcmp(command, "meshcore://", 11) == 0) { + command += 11; + char *ep = strchr(command, 0); + while (ep > command) { + ep--; + if (mesh::Utils::isHexChar(*ep)) break; + *ep = 0; + } + int len = strlen(command); + Serial.printf(" hex len=%d\n", len); + if (len % 2 == 0) { + len >>= 1; + if (mesh::Utils::fromHex(tmp_buf, len, command)) { + if (importContact(tmp_buf, len)) { + Serial.printf(" Advert queued. Contacts before: %d/%d\n", getNumContacts(), MAX_CONTACTS); + Serial.println(" Wait for 'ADVERT from -> ...' to confirm actual add."); + } else { + Serial.println(" error: importContact failed (bad packet)."); + } + return; + } + } + } + Serial.println(" error: invalid format"); + } + + // Drain Serial1 looking for the most recent complete MaxBotix frame + // ("Rdddd\r"). Returns distance in meters, or -1.0 on no-frame / parse error. + // We loop until ULTRASONIC_READ_TIMEOUT_MS elapses to make sure the value + // we return is the freshest one in the buffer. + float readDistanceMeters() { + // Commanded ranging: flush, strobe pin4 high, collect up to SONAR_SAMPLES + // "Rdddd\r" frames, strobe low, return their median (mm -> m). Commanding + // the strobe (vs floating pin4) is what keeps the MB7388 from free-running + // and backlogging unsolicited frames when Serial1 shares the pin. + while (Serial1.available()) Serial1.read(); // drop stale frames + digitalWrite(PIN_STROBE, HIGH); // pin4 high -> ranging + + int samp[SONAR_SAMPLES]; + int ns = 0; + char digits[8]; + int di = 0; + bool capturing = false; + // ~6 Hz frames, so give ~200 ms each plus a settle margin + unsigned long deadline = millis() + (unsigned long)SONAR_SAMPLES * 200UL + 300UL; + while (ns < SONAR_SAMPLES && (long)(deadline - millis()) > 0) { + while (Serial1.available()) { + char c = (char)Serial1.read(); + if (c == 'R') { + capturing = true; + di = 0; + } else if (capturing) { + if (c == '\r') { + digits[di] = 0; + if (di >= 3) { // accept 3 or 4 digit frames + samp[ns++] = atoi(digits); + sensor_last_frame_at = millis(); + if (ns >= SONAR_SAMPLES) break; + } + capturing = false; + di = 0; + } else if (di < (int)sizeof(digits) - 1 && c >= '0' && c <= '9') { + digits[di++] = c; + } else { + capturing = false; + di = 0; + } + } + } + delay(2); + } + + digitalWrite(PIN_STROBE, LOW); // pin4 low -> stop ranging + + if (ns == 0) { + Serial.println(" MaxBotix: no frame within timeout"); + return -1.0f; + } + return (float)median_int(samp, ns) / 1000.0f; + } + + float readBatteryVoltage() { + uint16_t mv = board.getBattMilliVolts(); + return mv / 1000.0; + } + + void updateSensorReadings() { + float d = readDistanceMeters(); + if (d >= 0.0f) { + last_dist_m = d; + has_sensor = true; + has_reading = true; + } else { + // No fresh frame this cycle. Keep prior last_dist_m so display still + // shows a value, but flag has_sensor=false so payload tags it as stale. + has_sensor = false; + has_reading = true; // still send so receiver knows we're alive + } + last_batt = readBatteryVoltage(); + } + +public: + // Returns count of successful sends. sets last_send_successes/last_send_attempts. + int sendSensorReading() { + last_send_successes = 0; + last_send_attempts = 0; + // Decisive probe: clear the fault register, read it back (0x0000 => clear + // works; still 0x0020 => the XOSC fault re-latches instantly = persistent), + // then read again after the TX (0x0020 => the transmit itself re-faults it). + radio.clearDeviceErrors(); + uint16_t err_cleared = radio.getDeviceErrors(); + + if (target_count == 0) { + Serial.println(" No targets set, skipping send (use 'target add ')"); + return 0; + } + + // reading was cached in MODE_MEASURE; re-reading here finds the sonar + // already gated off and clobbers has_sensor with a false failure + + for (uint8_t i = 0; i < target_count; i++) { + last_send_attempts++; + + ContactInfo* recipient = lookupContactByPubKey(target_prefixes[i], TARGET_PREFIX_LEN); + if (!recipient) { + Serial.print(" skip: contact not found for prefix "); + mesh::Utils::printHex(Serial, target_prefixes[i], TARGET_PREFIX_LEN); + Serial.println(); + continue; + } + + // v2: embed the sender's view of the forward path in the payload so the + // receiver can correlate with its measured arrival, and (new) identify the + // specific repeaters by hash byte. Format: + // fwd_hops=N fwd_path=aa.bb.cc (N = byte length of path) + // fwd_hops=255 fwd_path=none (path unknown, will flood) + uint8_t fwd_hops = (recipient->out_path_len == OUT_PATH_UNKNOWN) ? 255 : recipient->out_path_len; + char path_str[48]; + if (recipient->out_path_len == OUT_PATH_UNKNOWN || recipient->out_path_len == 0) { + StrHelper::strncpy(path_str, "none", sizeof(path_str)); + } else { + size_t off = 0; + // Cap at what fits in 47 chars: 15 hops x "xx." = 45 chars + "xx" = 47. Path_len max + // is 64 but in practice routes are short; we truncate defensively. + uint8_t to_write = recipient->out_path_len; + if (to_write > 15) to_write = 15; + for (uint8_t h = 0; h < to_write && off < sizeof(path_str) - 3; h++) { + int n = snprintf(path_str + off, sizeof(path_str) - off, + h == 0 ? "%02x" : ".%02x", recipient->out_path[h]); + if (n < 0) break; + off += n; + } + path_str[sizeof(path_str) - 1] = 0; + } + + char msg[200]; + snprintf(msg, sizeof(msg), + "[SENSOR] node_id=%u dist=%.3fm batt=%.2fV fwd_hops=%u fwd_path=%s", + (unsigned)node_id, last_dist_m, last_batt, (unsigned)fwd_hops, path_str); + Serial.printf(" [SENSOR] %s\n", msg); + + // v2: log the cached path bytes (not just length) before the send. + Serial.printf(" -> %s: ", recipient->name); + if (recipient->out_path_len == OUT_PATH_UNKNOWN) { + Serial.print("(path UNKNOWN -> will FLOOD) "); + } else { + Serial.printf("(direct, len=%d, hashes=", recipient->out_path_len); + for (uint8_t h = 0; h < recipient->out_path_len; h++) { + Serial.printf("%02x%s", recipient->out_path[h], h < recipient->out_path_len - 1 ? "." : ""); + } + Serial.print(") "); + } + + // link-health LED: toggle the instant before every radio send (attempt 1 + // and each retry). processAck() drives it OFF on a good ACK, onSendTimeout() + // ON on a miss; the mode machine forces it OFF entering sleep. + ledToggle(); + + uint32_t est_timeout; + int result = sendMessage(*recipient, getRTCClock()->getCurrentTime(), 0, msg, expected_ack_crc, est_timeout); + if (result == MSG_SEND_FAILED) { + Serial.println("FAILED"); + } else { + last_msg_sent = _ms->getMillis(); + last_ack_target_idx = i; // v2: remember target so onSendTimeout can invalidate + last_ack_status = ACK_STATUS_PENDING; + Serial.println(result == MSG_SEND_SENT_FLOOD ? "sent (FLOOD)" : "sent (DIRECT)"); + last_send_successes++; + } + } + + Serial.printf(" (%d/%d sent)\n", last_send_successes, last_send_attempts); + // 0x20=XOSC_START 0x40=PLL_LOCK 0x100=PA_RAMP. cleared = state right after a + // clear (before TX); after_tx = state once the transmit finished. + Serial.printf(" [RADIO_ERR] cleared=0x%04X after_tx=0x%04X\n", err_cleared, radio.getDeviceErrors()); + return last_send_successes; + } + + bool sendAdvert() { + auto pkt = createSelfAdvert(node_name, 0.0, 0.0); + if (pkt) { + sendZeroHop(pkt); + Serial.println(" (advert sent, zero hop)."); + return true; + } + Serial.println(" ERR: unable to send"); + return false; + } + + const char* getNodeName() const { return node_name; } + uint16_t getNodeId() const { return node_id; } + uint32_t getSendInterval() const { return send_interval_secs; } + uint8_t getTargetCount() const { return target_count; } + uint8_t getLastSendSuccesses() const { return last_send_successes; } + uint8_t getLastSendAttempts() const { return last_send_attempts; } + AckStatus getLastAckStatus() const { return last_ack_status; } + uint32_t getLastAckRoundTripMs() const { return last_ack_rt_ms; } + unsigned long getLastAckAtMillis() const { return last_ack_at_millis; } + + // link-health LED + last-transmit outcome, driven by the mode machine. + void linkLedOff() { ledWrite(false); } // force dark (power) on sleep entry + void recordTxOutcome(bool failed, uint8_t attempts) { last_tx_failed = failed; last_tx_attempts = attempts; } + bool getLastTxFailed() const { return last_tx_failed; } + uint8_t getLastTxAttempts() const { return last_tx_attempts; } + + // Read the sensor + battery and cache the values WITHOUT sending -- used by + // the OLED's Send page so the user can see a fresh reading before deciding + // to long-press send. Blocks for up to ULTRASONIC_READ_TIMEOUT_MS. + void refreshReading() { updateSensorReadings(); } + + // Returns name of target at index, or NULL if not found. + const char* getTargetName(uint8_t idx) { + if (idx >= target_count) return NULL; + ContactInfo* t = lookupContactByPubKey(target_prefixes[idx], TARGET_PREFIX_LEN); + return t ? t->name : NULL; + } + + // Fills summary string with target names separated by commas. + void formatTargetSummary(char* out, size_t out_sz) { + if (target_count == 0) { + StrHelper::strncpy(out, "(none)", out_sz); + return; + } + out[0] = 0; + size_t used = 0; + for (uint8_t i = 0; i < target_count && used < out_sz - 1; i++) { + const char* name = getTargetName(i); + int n; + if (i == 0) { + n = snprintf(out + used, out_sz - used, "%s", name ? name : "?"); + } else { + n = snprintf(out + used, out_sz - used, ",%s", name ? name : "?"); + } + if (n < 0) break; + used += n; + } + } + +protected: + float getAirtimeBudgetFactor() const override { + return 1.0; + } + + int calcRxDelay(float score, uint32_t air_time) const override { + return 0; + } + + bool allowPacketForward(const mesh::Packet* packet) override { + return true; + } + + // Auto-evict oldest non-favourite contact when list is full + bool shouldOverwriteWhenFull() const override { return true; } + + void onContactOverwrite(const uint8_t* pub_key) override { + Serial.print(" (evicted oldest non-favourite contact: "); + mesh::Utils::printHex(Serial, pub_key, 4); + Serial.println(")"); + } + + void onContactsFull() override { + Serial.printf(" WARNING: contacts list is full (%d/%d). Use 'purge' to clear.\n", getNumContacts(), MAX_CONTACTS); + } + + void onDiscoveredContact(ContactInfo& contact, bool is_new, uint8_t path_len, const uint8_t* path) override { + Serial.printf("ADVERT from -> %s\n", contact.name); + Serial.print(" public key: "); mesh::Utils::printHex(Serial, contact.id.pub_key, PUB_KEY_SIZE); Serial.println(); + saveContacts(); + } + + void onContactPathUpdated(const ContactInfo& contact) override { + // v2: log path bytes, not just length, so stale-path debugging is possible. + Serial.printf("PATH to: %s, path_len=%d, hashes=", contact.name, (uint32_t) contact.out_path_len); + for (uint8_t h = 0; h < contact.out_path_len; h++) { + Serial.printf("%02x%s", contact.out_path[h], h < contact.out_path_len - 1 ? "." : ""); + } + Serial.println(); + saveContacts(); + } + + ContactInfo* processAck(const uint8_t *data) override { + if (memcmp(data, &expected_ack_crc, 4) == 0) { + last_ack_rt_ms = _ms->getMillis() - last_msg_sent; + last_ack_at_millis = millis(); + last_ack_status = ACK_STATUS_OK; + ledWrite(false); // good ACK -> LED OFF (link healthy) + Serial.printf(" Got ACK! (round trip: %lu millis)\n", (unsigned long)last_ack_rt_ms); + expected_ack_crc = 0; + return NULL; + } + return NULL; + } + + void onMessageRecv(const ContactInfo& from, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) override { + Serial.printf("(%s) MSG -> from %s\n", pkt->isRouteDirect() ? "DIRECT" : "FLOOD", from.name); + Serial.printf(" %s\n", text); + } + + void onCommandDataRecv(const ContactInfo& from, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) override { + } + void onSignedMessageRecv(const ContactInfo& from, mesh::Packet* pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) override { + } + + void onChannelMessageRecv(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t timestamp, const char *text) override { + } + + uint8_t onContactRequest(const ContactInfo& contact, uint32_t sender_timestamp, const uint8_t* data, uint8_t len, uint8_t* reply) override { + return 0; + } + + void onContactResponse(const ContactInfo& contact, const uint8_t* data, uint8_t len) override { + } + + uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const override { + return SEND_TIMEOUT_BASE_MILLIS + (FLOOD_SEND_TIMEOUT_FACTOR * pkt_airtime_millis); + } + uint32_t calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const override { + uint8_t path_hash_count = path_len & 63; + return SEND_TIMEOUT_BASE_MILLIS + + ( (pkt_airtime_millis*DIRECT_SEND_PERHOP_FACTOR + DIRECT_SEND_PERHOP_EXTRA_MILLIS) * (path_hash_count + 1)); + } + + void onSendTimeout() override { + // If processAck already cleared expected_ack_crc, the ACK arrived just as the + // timeout fired -- treat this as a late-but-no-op timeout and skip invalidation. + // Without this guard, a slow-but-successful FLOOD round-trip would invalidate + // the path we just learned from the receiver's PATH-return. + if (expected_ack_crc == 0) return; + last_ack_status = ACK_STATUS_TIMEOUT; + ledWrite(true); // no ACK -> LED ON (problem); next resend toggles it briefly off + + // v2: on timeout, invalidate the cached out_path for the most recent target. + // The NEXT scheduled send will flood, which forces a fresh path discovery on + // both sides: the flood accumulates path hashes, and the receiver updates its + // own reverse out_path (which is what the ACK uses). + if (last_ack_target_idx < target_count) { + ContactInfo* c = lookupContactByPubKey(target_prefixes[last_ack_target_idx], TARGET_PREFIX_LEN); + if (c && c->out_path_len != OUT_PATH_UNKNOWN) { + Serial.printf(" ERROR: timed out, no ACK. Invalidating cached path to %s (was len=%d). Next send will FLOOD.\n", + c->name, (int)c->out_path_len); + c->out_path_len = OUT_PATH_UNKNOWN; + saveContacts(); + last_ack_target_idx = 0xFF; + return; + } + } + Serial.println(" ERROR: timed out, no ACK."); + } + +public: + MyMesh(mesh::Radio& radio, StdRNG& rng, mesh::RTCClock& rtc, SimpleMeshTables& tables) + : BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables) + { + command[0] = 0; + expected_ack_crc = 0; + last_msg_sent = 0; + target_count = 0; + memset(target_prefixes, 0, sizeof(target_prefixes)); + StrHelper::strncpy(node_name, ADVERT_NAME, sizeof(node_name)); + send_interval_secs = SENSOR_SEND_INTERVAL_SECS; + last_send_time = 0; + last_send_successes = 0; + last_send_attempts = 0; + last_ack_target_idx = 0xFF; // v2: no send pending + last_ack_status = ACK_STATUS_NONE; + last_ack_rt_ms = 0; + last_ack_at_millis = 0; + led_on = false; + last_tx_failed = false; + last_tx_attempts = 0; + node_id = 1; + } + + void begin(FILESYSTEM& fs) { + _fs = &fs; + + BaseChatMesh::begin(); + + #if defined(NRF52_PLATFORM) + IdentityStore store(fs, ""); + #elif defined(RP2040_PLATFORM) + IdentityStore store(fs, "/identity"); + store.begin(); + #else + IdentityStore store(fs, "/identity"); + #endif + if (!store.load("_main", self_id, node_name, sizeof(node_name))) { + Serial.println("Press ENTER to generate key:"); + char c = 0; + while (c != '\n') { + if (Serial.available()) c = Serial.read(); + } + ((StdRNG *)getRNG())->begin(millis()); + + self_id = mesh::LocalIdentity(getRNG()); + int count = 0; + while (count < 10 && (self_id.pub_key[0] == 0x00 || self_id.pub_key[0] == 0xFF)) { + self_id = mesh::LocalIdentity(getRNG()); count++; + } + store.save("_main", self_id); + } + + loadPrefs(); + loadContacts(); + loadTargets(); + } + + void showWelcome() { + Serial.println("===== MeshCore Companion Sensor ====="); + Serial.println(); + Serial.printf("Node: %s\n", node_name); + Serial.print("Public key: "); mesh::Utils::printHex(Serial, self_id.pub_key, PUB_KEY_SIZE); Serial.println(); + Serial.printf("Node ID: %u\n", (unsigned)node_id); + Serial.printf("Send interval: %d seconds\n", send_interval_secs); + + if (has_sensor) { + Serial.println("DS18B20: detected"); + } else { + Serial.println("DS18B20: not found (using dummy value 99.9C)"); + } + + if (target_count > 0) { + char summary[128]; + formatTargetSummary(summary, sizeof(summary)); + Serial.printf("Targets (%d): %s\n", (int)target_count, summary); + } else { + Serial.println("Targets: none (use 'target add ')"); + } + + Serial.println(); + Serial.println(" (enter 'help' for commands)"); + Serial.println(); + } + + void sendSelfAdvert(int delay_millis) { + auto pkt = createSelfAdvert(node_name, 0.0, 0.0); + if (pkt) { + sendFlood(pkt, delay_millis); + } + } + + // ContactVisitor + void onContactVisit(const ContactInfo& contact) override { + Serial.printf(" %s - ", contact.name); + char tmp[40]; + int32_t secs = contact.last_advert_timestamp - getRTCClock()->getCurrentTime(); + AdvertTimeHelper::formatRelativeTimeDiff(tmp, secs, false); + Serial.println(tmp); + } + + void handleCommand(const char* command) { + while (*command == ' ') command++; + + if (memcmp(command, "target add ", 11) == 0) { + const char* name = &command[11]; + ContactInfo* contact = searchContactsByPrefix(name); + if (!contact) { + Serial.println(" Error: name prefix not found in contacts."); + } else if (addTargetPrefix(contact->id.pub_key)) { + Serial.printf(" Added target: %s (%d/%d)\n", contact->name, (int)target_count, MAX_SEND_TARGETS); + } else { + Serial.printf(" Error: target list full (max %d)\n", MAX_SEND_TARGETS); + } + } else if (memcmp(command, "target remove ", 14) == 0) { + const char* name = &command[14]; + ContactInfo* contact = searchContactsByPrefix(name); + if (!contact) { + Serial.println(" Error: name prefix not found in contacts."); + } else if (removeTargetPrefix(contact->id.pub_key)) { + Serial.printf(" Removed target: %s\n", contact->name); + } else { + Serial.println(" Error: contact not in target list."); + } + } else if (strcmp(command, "target clear") == 0) { + target_count = 0; + saveTargets(); + Serial.println(" All targets cleared."); + } else if (memcmp(command, "target ", 7) == 0) { + // Legacy: `target ` replaces all targets with just this one + const char* name = &command[7]; + ContactInfo* contact = searchContactsByPrefix(name); + if (contact) { + memcpy(target_prefixes[0], contact->id.pub_key, TARGET_PREFIX_LEN); + target_count = 1; + saveTargets(); + Serial.printf(" Target set to: %s (replaced any existing)\n", contact->name); + } else { + Serial.println(" Error: name prefix not found in contacts."); + } + } else if (strcmp(command, "target") == 0) { + if (target_count == 0) { + Serial.println(" No targets set."); + } else { + Serial.printf(" Targets (%d/%d):\n", (int)target_count, MAX_SEND_TARGETS); + for (uint8_t i = 0; i < target_count; i++) { + ContactInfo* t = lookupContactByPubKey(target_prefixes[i], TARGET_PREFIX_LEN); + Serial.printf(" [%d] ", (int)i); + if (t) { + Serial.println(t->name); + } else { + mesh::Utils::printHex(Serial, target_prefixes[i], TARGET_PREFIX_LEN); + Serial.println(" (contact not found)"); + } + } + } + } else if (strcmp(command, "send") == 0) { + Serial.println(" Sending sensor reading now..."); + sendSensorReading(); + } else if (memcmp(command, "list", 4) == 0) { + int n = 0; + if (command[4] == ' ') { + n = atoi(&command[5]); + } + Serial.printf("Contacts: %d/%d\n", getNumContacts(), MAX_CONTACTS); + scanRecentContacts(n, this); + } else if (strcmp(command, "purge") == 0) { + int before = getNumContacts(); + resetContacts(); + saveContacts(); + Serial.printf(" Purged %d contacts (0/%d now)\n", before, MAX_CONTACTS); + } else if (memcmp(command, "to ", 3) == 0) { + // Alias for `target ` (replace all) + const char* name = &command[3]; + ContactInfo* contact = searchContactsByPrefix(name); + if (contact) { + memcpy(target_prefixes[0], contact->id.pub_key, TARGET_PREFIX_LEN); + target_count = 1; + saveTargets(); + Serial.printf(" Target set to: %s (replaced any existing)\n", contact->name); + } else { + Serial.println(" Error: name prefix not found in contacts."); + } + } else if (memcmp(command, "card", 4) == 0) { + Serial.printf("Hello %s\n", node_name); + auto pkt = createSelfAdvert(node_name, 0.0, 0.0); + if (pkt) { + uint8_t len = pkt->writeTo(tmp_buf); + releasePacket(pkt); + mesh::Utils::toHex(hex_buf, tmp_buf, len); + Serial.println("Your MeshCore biz card:"); + Serial.print("meshcore://"); Serial.println(hex_buf); + Serial.println(); + } else { + Serial.println(" Error"); + } + } else if (memcmp(command, "import ", 7) == 0) { + importCard(&command[7]); + } else if (strcmp(command, "advert") == 0) { + sendAdvert(); + } else if (strcmp(command, "radio") == 0) { + Serial.printf("[RADIO] freq=%.3f MHz bw=%.1f kHz sf=%d cr=4/%d txpwr=%d\n", + (double)LORA_FREQ, (double)LORA_BW, (int)LORA_SF, (int)LORA_CR, (int)LORA_TX_POWER); + } else if (memcmp(command, "set ", 4) == 0) { + const char* config = &command[4]; + if (memcmp(config, "name ", 5) == 0) { + StrHelper::strncpy(node_name, &config[5], sizeof(node_name)); + savePrefs(); + Serial.printf(" OK, name set to: %s\n", node_name); + } else if (memcmp(config, "node_id ", 8) == 0) { + uint32_t val = _atoi(&config[8]); + if (val > 0 && val <= 65535) { + node_id = (uint16_t)val; + savePrefs(); + Serial.printf(" OK, node_id set to %u\n", (unsigned)node_id); + } else { + Serial.println(" Error: node_id must be 1-65535"); + } + } else if (memcmp(config, "interval ", 9) == 0) { + uint32_t val = _atoi(&config[9]); + if (val >= 10) { + send_interval_secs = val; + savePrefs(); + Serial.printf(" OK, interval set to %d seconds\n", send_interval_secs); + } else { + Serial.println(" Error: minimum interval is 10 seconds"); + } + } else { + Serial.printf(" ERROR: unknown config: %s\n", config); + } + } else if (memcmp(command, "ver", 3) == 0) { + Serial.println(FIRMWARE_VER_TEXT); + } else if (memcmp(command, "help", 4) == 0) { + Serial.println("Commands:"); + Serial.println(" card - show your biz card for sharing"); + Serial.println(" import - import a contact's biz card"); + Serial.println(" list {n} - list contacts (last n)"); + Serial.println(" purge - clear all contacts"); + Serial.println(" target - show current targets"); + Serial.println(" target add - add a send target (max 4)"); + Serial.println(" target remove - remove a send target"); + Serial.println(" target clear - clear all targets"); + Serial.println(" target - set to single target (replaces all)"); + Serial.println(" to - alias for 'target '"); + Serial.println(" send - send a reading now"); + Serial.println(" set name - set node name"); + Serial.println(" set node_id - set node ID (1-65535)"); + Serial.println(" set interval - set send interval (min 10)"); + Serial.println(" advert - send advertisement"); + Serial.println(" ver - show firmware version"); + Serial.println(" help - show this help"); + } else { + Serial.print(" ERROR: unknown command: "); Serial.println(command); + } + } + + void loop() { + BaseChatMesh::loop(); + + // NOTE: the field-node mode machine (MODE_TRANSMIT) is the SOLE transmit + // authority now -- the old interval auto-send is removed so a send can never + // fire outside the duty cycle (e.g. during SLEEP). We still call this loop() + // for BaseChatMesh radio/ACK servicing and the serial command console below. + + // Serial command handling + int len = strlen(command); + while (Serial.available() && len < (int)sizeof(command)-1) { + char c = Serial.read(); + if (c == '\r' || c == '\n') { + if (len > 0) { + Serial.println(); + command[len] = 0; + handleCommand(command); + command[0] = 0; + len = 0; + } + continue; // skip \r and \n + } + command[len++] = c; + command[len] = 0; + Serial.print(c); + } + if (len == (int)sizeof(command)-1) { + command[len] = 0; + Serial.println(); + handleCommand(command); + command[0] = 0; + } + } +}; + +StdRNG fast_rng; +SimpleMeshTables tables; +MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables); + +/* ---------------------------------- DISPLAY & BUTTON ---------------------------------- */ + +#ifdef DISPLAY_CLASS +static uint8_t current_page = PAGE_STATUS; +static unsigned long next_display_refresh = 0; +static unsigned long last_button_activity = 0; +static char alert_text[32] = {0}; +static unsigned long alert_expiry = 0; + +static void showAlert(const char* text) { + strncpy(alert_text, text, sizeof(alert_text)-1); + alert_text[sizeof(alert_text)-1] = 0; + alert_expiry = millis() + ALERT_DURATION_MS; +} + +static void drawPageDots(DisplayDriver& d) { + int y = 2; + int x = d.width() / 2 - 5 * (PAGE_COUNT - 1); + for (uint8_t i = 0; i < PAGE_COUNT; i++, x += 10) { + if (i == current_page) { + d.fillRect(x-1, y-1, 3, 3); + } else { + d.fillRect(x, y, 1, 1); + } + } +} + +static bool g_demo_banner = false; // DEMO renders the status page; banner tells them apart + +static void renderStatusPage(DisplayDriver& d) { + d.setTextSize(1); + d.setColor(DisplayDriver::LIGHT); + + // Node name and ID (DEMO banner in demo mode so the pages are tellable apart) + char buf[32]; + if (g_demo_banner) { + snprintf(buf, sizeof(buf), "** DEMO ** [%u]", (unsigned)the_mesh.getNodeId()); + } else { + snprintf(buf, sizeof(buf), "%s [%u]", the_mesh.getNodeName(), (unsigned)the_mesh.getNodeId()); + } + d.setCursor(0, 10); + d.print(buf); + + // Distance (water-level when mounted pointing down) + if (!has_sensor) { + snprintf(buf, sizeof(buf), "Sensor: NOT DETECTED"); + } else if (has_reading) { + snprintf(buf, sizeof(buf), "Dist: %.2fm", last_dist_m); + } else { + snprintf(buf, sizeof(buf), "Dist: --"); + } + d.setCursor(0, 22); + d.print(buf); + + // Battery + if (has_reading) { + snprintf(buf, sizeof(buf), "Batt: %.2fV", last_batt); + } else { + snprintf(buf, sizeof(buf), "Batt: --"); + } + d.setCursor(0, 34); + d.print(buf); + + // Targets + uint8_t tc = the_mesh.getTargetCount(); + if (tc == 0) { + snprintf(buf, sizeof(buf), "To: (none)"); + } else if (tc == 1) { + const char* tname = the_mesh.getTargetName(0); + snprintf(buf, sizeof(buf), "To: %s", tname ? tname : "(?)"); + } else { + char summary[24]; + the_mesh.formatTargetSummary(summary, sizeof(summary)); + snprintf(buf, sizeof(buf), "To(%d): %s", (int)tc, summary); + } + d.setCursor(0, 46); + d.print(buf); + + // Bottom line: if the last deploy transmit failed every attempt, show that as + // an error with the try count (the installer's "is this node getting through?" + // check). Otherwise the usual last-ACK-age / interval feedback. + if (the_mesh.getLastTxFailed()) { + snprintf(buf, sizeof(buf), "ERR: no ACK x%d", (int)the_mesh.getLastTxAttempts()); + } else { + switch (the_mesh.getLastAckStatus()) { + case ACK_STATUS_OK: { + unsigned long age_ms = millis() - the_mesh.getLastAckAtMillis(); + unsigned long age_s = age_ms / 1000UL; + if (age_s < 60) { + snprintf(buf, sizeof(buf), "Last: ACK %lus ago", age_s); + } else if (age_s < 3600) { + snprintf(buf, sizeof(buf), "Last: ACK %lum ago", age_s / 60); + } else if (age_s < 86400) { + snprintf(buf, sizeof(buf), "Last: ACK %luh ago", age_s / 3600); + } else { + snprintf(buf, sizeof(buf), "Last: ACK %lud ago", age_s / 86400); + } + break; + } + case ACK_STATUS_PENDING: + snprintf(buf, sizeof(buf), "Last: sending..."); + break; + case ACK_STATUS_TIMEOUT: + snprintf(buf, sizeof(buf), "Last: no ACK"); + break; + default: + snprintf(buf, sizeof(buf), "Every %ds", (int)the_mesh.getSendInterval()); + break; + } + } + d.setCursor(0, 56); + d.print(buf); +} + +static void renderSendPage(DisplayDriver& d) { + d.setTextSize(1); + d.setColor(DisplayDriver::LIGHT); + d.drawTextCentered(d.width() / 2, 8, "Send Sensor"); + + // Live preview: refreshed on page-arrival via refreshReading(). Shows the + // user what would actually go out if they long-press now. + char buf[24]; + if (!has_sensor) { + snprintf(buf, sizeof(buf), "Sensor: NOT DETECTED"); + } else if (has_reading) { + snprintf(buf, sizeof(buf), "Dist: %.2fm", last_dist_m); + } else { + snprintf(buf, sizeof(buf), "Dist: --"); + } + d.drawTextCentered(d.width() / 2, 28, buf); + + snprintf(buf, sizeof(buf), "Batt: %.2fV", last_batt); + d.drawTextCentered(d.width() / 2, 40, buf); + + d.drawTextCentered(d.width() / 2, 54, "long press = send"); +} + +static void renderAdvertPage(DisplayDriver& d) { + d.setTextSize(1); + d.setColor(DisplayDriver::LIGHT); + d.drawTextCentered(d.width() / 2, 24, "Send Advert"); + d.drawTextCentered(d.width() / 2, 44, "long press"); +} + +static void renderAlert(DisplayDriver& d) { + int y = d.height() / 3; + int p = d.height() / 16; + d.setColor(DisplayDriver::DARK); + d.fillRect(p, y, d.width() - p*2, y); + d.setColor(DisplayDriver::LIGHT); + d.drawRect(p, y, d.width() - p*2, y); + d.drawTextCentered(d.width() / 2, y + p + 6, alert_text); +} + +static void renderDisplay() { + if (!display.isOn()) return; + if (millis() < next_display_refresh) return; + + display.startFrame(); + drawPageDots(display); + + switch (current_page) { + case PAGE_STATUS: renderStatusPage(display); break; + case PAGE_SEND: renderSendPage(display); break; + case PAGE_ADVERT: renderAdvertPage(display); break; + } + + if (millis() < alert_expiry) { + renderAlert(display); + } + + display.endFrame(); + next_display_refresh = millis() + DISPLAY_REFRESH_MS; +} + +static void handleButtonEvents() { + int ev = user_btn.check(); + if (ev == BUTTON_EVENT_NONE) return; + + // Any button press turns display on and resets auto-off timer + last_button_activity = millis(); + if (!display.isOn()) { + DISPLAY_TURN_ON(); + next_display_refresh = 0; // force immediate refresh + return; // consume this press just to turn on the screen + } + + if (ev == BUTTON_EVENT_CLICK) { + // Short press: cycle to next page + current_page = (current_page + 1) % PAGE_COUNT; + next_display_refresh = 0; // force immediate refresh + + // Landing on the Send page kicks off an immediate sensor read so the page + // shows a live preview -- easy way to verify the sensor is alive without + // sending anything over the radio. + if (current_page == PAGE_SEND) { + the_mesh.refreshReading(); + } + } else if (ev == BUTTON_EVENT_LONG_PRESS) { + if (current_page == PAGE_SEND) { + the_mesh.sendSensorReading(); + uint8_t succ = the_mesh.getLastSendSuccesses(); + uint8_t att = the_mesh.getLastSendAttempts(); + if (att == 0) { + // No `target add ...` has been done yet -- make this very explicit. + showAlert("Target not set"); + } else { + // Switch to status page so the user can watch the "Last: ..." line cycle + // through sending -> ACK Nms / no ACK. Overlay a brief alert as immediate + // confirmation that the long-press registered. + current_page = PAGE_STATUS; + next_display_refresh = 0; + char alert[24]; + if (succ == att) { + snprintf(alert, sizeof(alert), att == 1 ? "Sent" : "Sent %d/%d", succ, att); + } else { + snprintf(alert, sizeof(alert), "Sent %d/%d", succ, att); + } + showAlert(alert); + } + } else if (current_page == PAGE_ADVERT) { + if (the_mesh.sendAdvert()) { + showAlert("Advert sent!"); + } else { + showAlert("Advert failed"); + } + } + next_display_refresh = 0; + } +} + +static void displayLoop() { + handleButtonEvents(); + renderDisplay(); + + // Auto-off after inactivity + if (display.isOn() && last_button_activity > 0 && + (millis() - last_button_activity) > DISPLAY_AUTO_OFF_MS) { + DISPLAY_TURN_OFF(); + } +} +#endif // DISPLAY_CLASS + +/* ============================ FIELD-NODE MODE MACHINE ============================ */ +// Duty-cycle deployment layer over the companion_sensor mesh node. The pure +// transition logic lives in modes.h (host-tested, 29 checks); this wires each +// mode to the sonar gate, OLED, and mesh transmit/ACK path. Defaults to the +// low-power deploy cycle; the dev views (STATUS/DEMO/TX_DEBUG) are opt-in via B1. +#include "modes.h" +#include "radio_lowpower.h" // app-side radio SPI-sleep/wake (MeshCore left pristine) + +#define PIN_SONAR_GATE 5 // D5 = GPS_EN = AO3400 low-side gate (HIGH = sonar powered) +#ifndef CYCLE_SECS +#define CYCLE_SECS 300 // deploy measure/transmit cadence (5 min); -D override for bench +#endif +#ifndef STATUS_SECS +#define STATUS_SECS 30 // MODE_STATUS dwell on the OLED; -D override for bench +#endif +#define DEMO_SECS CYCLE_SECS // MODE_DEMO runs one cycle then lapses back to sleep +#define TX_RETRY_MS 5000 // wait this long for an ACK before a retry +#define TX_MAX_ATTEMPTS 5 // give up after this many sends +#define WAKE_SETTLE_MS 300 // sonar power-up settle on a plain (timer) wake +// MODE_SLEEP idle granularity: delay() WFE-sleeps the CPU this long between +// cadence/button re-checks. At 50 ms the core is woken 20x per second purely to +// re-read a clock and a pin, and each wake costs. Overridable so the wake rate can +// be measured as its own variable: the arithmetic (147 uA System-OFF vs ~1066 uA +// System-ON) says the remaining floor is idle overhead, not a peripheral. +// Cost of raising it: button-press latency during sleep, and cadence granularity - +// both irrelevant against a 20 s bench or 300 s deployment cycle. +#ifndef SLEEP_POLL_MS +#define SLEEP_POLL_MS 50 +#endif +#define GESTURE_WINDOW_MS 1500 // bounded button-classify cap in WAKE (> long-press 1200ms) +#ifndef SONAR_QUIET_MS +#define SONAR_QUIET_MS 2000 // let the gated sonar fully spin down before TX (RX desense fix) +#endif + +static mode g_mode = MODE_POST; +static unsigned long g_mode_since = 0; // millis() at mode entry +static bool g_woke_by_button = false; // how SLEEP was interrupted (button vs RTC) +static int g_tx_attempts = 0; +static unsigned long g_tx_sent_at = 0; +static unsigned long g_txdbg_settled_at = 0; +#define TXDBG_LINGER_MS 3000 + +static void sonar_power(bool on) { +#if STRIP_SONAR + (void)on; return; // bisection arm: sonar layer removed +#endif +#ifdef SONAR_FORCE_OFF + (void)on; + digitalWrite(PIN_SONAR_GATE, LOW); // TEST: sonar forced OFF to isolate whether its + // gated power/RF noise desenses the radio RX +#else + digitalWrite(PIN_SONAR_GATE, on ? HIGH : LOW); +#endif +} + +// draw the shared status page (sensor / battery / target / ACK age) in its own frame +static void render_status_frame() { + if (!display.isOn()) return; + display.startFrame(); + renderStatusPage(display); + display.endFrame(); +} + +static void render_post() { + if (!display.isOn()) return; + display.startFrame(); + display.setCursor(0, 0); display.print("POST self-test"); + display.setCursor(0, 16); display.print(has_sensor ? "sonar: OK" : "sonar: --"); + display.setCursor(0, 28); display.print("radio: OK"); + display.endFrame(); +} + +static void render_tx_debug() { + if (!display.isOn()) return; + char line[40]; + const char* as = "-"; + switch (the_mesh.getLastAckStatus()) { + case ACK_STATUS_PENDING: as = "pending"; break; + case ACK_STATUS_OK: as = "ACK OK"; break; + case ACK_STATUS_TIMEOUT: as = "TIMEOUT"; break; + default: break; + } + display.startFrame(); + display.setCursor(0, 0); display.print("TX DEBUG"); + snprintf(line, sizeof(line), "dist %.2fm", last_dist_m); display.setCursor(0, 14); display.print(line); + snprintf(line, sizeof(line), "batt %.2fV", last_batt); display.setCursor(0, 26); display.print(line); + snprintf(line, sizeof(line), "ack %s rt%lu", as, (unsigned long)the_mesh.getLastAckRoundTripMs()); + display.setCursor(0, 38); display.print(line); + snprintf(line, sizeof(line), "try %d/%d", g_tx_attempts, TX_MAX_ATTEMPTS); + display.setCursor(0, 50); display.print(line); + display.endFrame(); +} + +// entry actions: run once when a mode becomes current. +static void enter_mode(mode m) { + Serial.printf("[MODE] %s -> %s\n", mode_name(g_mode), mode_name(m)); // serial trace for bench HIL + g_mode = m; + g_mode_since = millis(); + + switch (m) { + case MODE_POST: + DISPLAY_TURN_ON(); + break; + case MODE_STATUS: +#if SLEEP_RELEASE_TWI + Wire.begin(); // direct hop from SLEEP: bus up before the panel +#endif + g_demo_banner = false; + DISPLAY_TURN_ON(); + break; + case MODE_SLEEP: + DISPLAY_TURN_OFF(); +#if SLEEP_RELEASE_UART + sonar_serial_end(); // release UARTE0 + both pads BEFORE gating: an enabled + // UARTE holds HFCLK (~mA) and an active RX pad loads the + // sensor output while its ground floats. Measured + // 2026-07-18: this is worth ~5.5 mA of sleep floor. +#endif + sonar_power(false); // gate cuts sonar draw + NRF_P1->LATCH = (1u << 0); // stale press latched while awake must not rewake us + the_mesh.linkLedOff(); // force the lamp dark for sleep, even after a fully-failed transmit + radio_sleep_lp(); // SPI-sleep the radio (~1uA); loop() then skips the_mesh.loop() while slept +#if SLEEP_RELEASE_TWI + Wire.end(); // TWIM is another always-enabled HFCLK holder; the + // display is already off, so the bus has no user here +#endif +#if SLEEP_PIN_DISCONNECT_AUDIT + sleep_pins_disconnect(); // LAST: after every release above, silence the listed + // pads (buffer-disconnected). Wake bring-up rewrites + // each pin's PIN_CNF, so there is no restore to forget. +#endif +#if SLEEP_DIAG + sleep_diag_dump(); // state snapshot AFTER all releases, before the idle loop +#endif +#if SLEEP_DISABLE_USBD + if (!(NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk)) { + NRF_USBD->ENABLE = 0; // battery only: drop USBD so it stops holding HFCLK. + // One-way until reset; VBUS-present skips this entirely. + } +#endif +#if SLEEP_HFCLK_STOP + if (!(NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk)) { + NRF_CLOCK->TASKS_HFCLKSTOP = 1; // battery only: release the latched HFXO. + // HF users fall back to HFINT on demand. + } +#endif + break; + case MODE_WAKE: +#if SLEEP_RELEASE_TWI + Wire.begin(); // bus back up before anything talks to the display +#endif + radio_wake_lp(); // wake radio + re-arm Rx before measure/transmit; un-pauses the mesh + sonar_power(true); +#if SLEEP_RELEASE_UART + sonar_serial_begin(); // UARTE0 back up, D1 reclaimed as the pin-4 strobe. + // Paired with the sleep-side release: if we did not + // release, we must not re-begin. +#endif + break; + case MODE_MEASURE: + break; + case MODE_TRANSMIT: + DISPLAY_TURN_OFF(); + sonar_power(false); // sonar off for the TX/ACK window: its gated power/RF + // noise desenses the radio RX (bench-confirmed). The + // reading was already cached in MODE_MEASURE. + delay(SONAR_QUIET_MS); // let the MaxBotix fully spin down (its bulk caps keep + // it emitting after the gate cuts) before the radio TX + g_tx_attempts = 1; + the_mesh.sendSensorReading(); + g_tx_sent_at = millis(); + break; + case MODE_DEMO: + g_demo_banner = true; + DISPLAY_TURN_ON(); + sonar_power(true); +#if SLEEP_RELEASE_UART + sonar_serial_begin(); // STATUS gateway: UARTE may still be released +#endif + break; + case MODE_TRANSMIT_DEBUG: + DISPLAY_TURN_ON(); + radio_wake_lp(); // STATUS gateway: radio may still be slept + g_txdbg_settled_at = 0; + g_tx_attempts = 1; + the_mesh.sendSensorReading(); + g_tx_sent_at = millis(); + break; + default: + break; + } +} + +// per-tick driver: build the event for the current mode, run its continuous +// action, then advance the state machine via the pure next_mode(). +static void mode_loop() { + int btn = user_btn.check(); // classifies CLICK/DOUBLE/LONG when ready + unsigned long in_mode = millis() - g_mode_since; + + mode_event me; + me.btn = btn; + me.elapsed = false; + me.done = false; + me.woke_by_button = g_woke_by_button; + + switch (g_mode) { + case MODE_POST: + render_post(); + me.done = (in_mode > 3000); // self-test banner, long enough to read + break; + case MODE_STATUS: + render_status_frame(); + me.elapsed = (in_mode > STATUS_SECS * 1000UL); + break; + case MODE_SLEEP: + // Display off + sonar gated off. CPU idle via FreeRTOS delay(): vTaskDelay + // blocks this task so the core WFE-sleeps in the idle task, and the RTC- + // driven RTOS tick reliably resumes us to re-check the cadence + button. + // (A raw waitForEvent() here hung on battery -- with USB, CDC interrupts were + // masking it by waking WFE; the JS220 caught the stuck node. delay() is the + // FreeRTOS-correct low-power idle.) Stage 2: radio.sleep() for the ~147uA + // floor per the sleep_test derisking; radio still RX-listening here (~8mA floor). +#if SLEEP_FPU_CLEAR + // Errata 87: a float op latches the FPU exception -> FPU IRQ pending -> + // WFE returns immediately and the idle task spins instead of sleeping. + // QT1 measured fpu_pend=1 at sleep entry on this firmware. Clear the + // FPSCR exception bits and the pending IRQ before each idle interval. + __set_FPSCR(__get_FPSCR() & ~(0x0000009FUL)); + (void) __get_FPSCR(); + NVIC_ClearPendingIRQ(FPU_IRQn); +#endif + delay(SLEEP_POLL_MS); + if (in_mode > CYCLE_SECS * 1000UL) { g_woke_by_button = false; me.elapsed = true; } + else if (btn != BUTTON_EVENT_NONE || user_btn.isPressed() || + (NRF_P1->LATCH & (1u << 0))) { + NRF_P1->LATCH = (1u << 0); // consume the hardware-latched edge + g_woke_by_button = true; + } + me.woke_by_button = g_woke_by_button; + break; + case MODE_WAKE: + if (g_woke_by_button) { + if (in_mode > GESTURE_WINDOW_MS) me.done = true; // spurious wake -> measure anyway + } else { + me.done = (in_mode > WAKE_SETTLE_MS); // timer wake: settle then measure + } + break; + case MODE_MEASURE: + the_mesh.refreshReading(); // sonar + battery into cache (blocks ~read timeout) + me.done = true; + break; + case MODE_TRANSMIT: { + // Retry only after the FULL TX_RETRY_MS. The mesh flags its own send-timeout + // in <1s, but the real ACK round-trip is ~2.7s -- retrying on the mesh flag + // would re-send before the ACK can arrive (retry < RTT) and never catch it. + // expected_ack_crc stays set across the wait, so a late ACK still lands. + AckStatus s = the_mesh.getLastAckStatus(); + if (s == ACK_STATUS_OK) { + the_mesh.recordTxOutcome(false, g_tx_attempts); // acked (took g_tx_attempts tries) + me.done = true; + } else if ((millis() - g_tx_sent_at) > TX_RETRY_MS) { + if (g_tx_attempts >= TX_MAX_ATTEMPTS) { + the_mesh.recordTxOutcome(true, g_tx_attempts); // all attempts missed -> STATUS shows error + me.done = true; // exhausted -> back to sleep (LED forced off there) + } else { + g_tx_attempts++; the_mesh.sendSensorReading(); g_tx_sent_at = millis(); + } + } + break; + } + case MODE_DEMO: + the_mesh.refreshReading(); + render_status_frame(); + me.elapsed = (in_mode > DEMO_SECS * 1000UL); + break; + case MODE_TRANSMIT_DEBUG: { + render_tx_debug(); + AckStatus s = the_mesh.getLastAckStatus(); + bool settled = (s == ACK_STATUS_OK || s == ACK_STATUS_TIMEOUT || + (millis() - g_tx_sent_at) > TX_RETRY_MS); + if (settled && g_txdbg_settled_at == 0) g_txdbg_settled_at = millis(); + if (settled && (millis() - g_txdbg_settled_at) > TXDBG_LINGER_MS) + me.done = true; // linger so the outcome is readable before leaving + break; + } + default: + break; + } + + mode next = next_mode(g_mode, me); + if (next != g_mode) { + if (g_mode == MODE_SLEEP || g_mode == MODE_WAKE) g_woke_by_button = false; // wake consumed on SLEEP exit + enter_mode(next); + } +} + +/* ---------------------------------- SETUP & LOOP -------------------------------------- */ + +void halt() { + while (1) ; +} + +void setup() { + Serial.begin(115200); + // Give USB CDC a moment to enumerate so the first prints actually land. + delay(200); + + Serial.println(); + Serial.println("================================================"); + Serial.println(FIRMWARE_VER_TEXT); + Serial.println("================================================"); + Serial.println("setup: Serial up."); + + // link-health LED (P0.15, active-high): start dark. Toggled per send, ACK->off, + // timeout->on, forced off on sleep entry (see MyMesh + the mode machine). + pinMode(LED_PIN, OUTPUT); + digitalWrite(LED_PIN, LED_STATE_ON ? LOW : HIGH); + + Serial.println("setup: board.begin()..."); + board.begin(); + +#if PWR_ENABLE_DCDC + // RookBoard::begin() calls NRF52Board::begin(), skipping NRF52BoardDCDC::begin(), + // so the DC/DC enable never fires and the board runs on the LDO. Do it here + // rather than patching Don's board file. Must come AFTER board.begin(), which is + // where the SoftDevice comes up. + { + uint8_t sd_enabled = 0; + sd_softdevice_is_enabled(&sd_enabled); + if (sd_enabled) { + sd_power_dcdc_mode_set(NRF_POWER_DCDC_ENABLE); + } else { + NRF_POWER->DCDCEN = 1; + } + Serial.println("setup: DC/DC regulator ENABLED (PWR_ENABLE_DCDC=1)"); + } +#endif + + // Bring the radio up with the sonar gate OFF. The Rook's SX1262 runs on a TCXO; + // switching the gated sonar rail (AO3400 on pin 5) on during radio_init sags the + // supply right as the TCXO starts -> XOSC_START_ERR (0x0020) and an off-frequency + // radio that can't ACK. Order matters: radio first, sonar second. (HW backstop: + // bulk decoupling on the sonar rail is the follow-up.) + pinMode(PIN_SONAR_GATE, OUTPUT); + sonar_power(false); // keep sonar off through radio startup + + // Without begin() the nRF pin's input buffer stays disconnected and + // digitalRead never sees the button, no matter what the pad does. + user_btn.begin(); + // Polled sampling misses taps that start and end between polls. SENSE-low + // makes the port's LATCH register catch the falling edge in hardware (async, + // no clock, no interrupt); the sleep poll reads and clears it. Same SENSE + // machinery a future System-OFF button-wake needs. + NRF_P1->PIN_CNF[0] |= (GPIO_PIN_CNF_SENSE_Low << GPIO_PIN_CNF_SENSE_Pos); + NRF_P1->LATCH = (1u << 0); + + Serial.println("setup: radio_init()..."); + if (!radio_init()) { + Serial.println("setup: radio_init() FAILED -- halting."); + halt(); + } + delay(20); // let the TCXO fully settle before trusting it + radio.clearDeviceErrors(); // clear any startup XOSC/cal latch now that it's stable + Serial.printf("setup: radio OK. [RADIO_ERR post-init=0x%04X]\n", radio.getDeviceErrors()); + + fast_rng.begin(radio_get_rng_seed()); + +#if STRIP_MESH + // S3+: no mesh, no cadence -- the arm is a bare sleep floor. Warm-sleep the + // radio right after init (the v4 pattern) and let the stripped loop() idle. + radio_sleep_lp(); + Serial.println("setup: STRIP_MESH -- radio warm-slept, entering bare idle loop."); +#endif + +#if !STRIP_SONAR + // radio is up and stable -- now power the sonar for the MaxBotix probe + cycles + sonar_power(true); + + // Initialize MaxBotix MB7388 (TTL serial, 9600 baud, "Rxxxx\r" frames). + // Wire the MaxBotix pin 5 (serial TX) to the Rook's Serial1 RX pin; no TX + // wire back is needed. Power: 3.0-5.5 V on pin 6, ground on pin 7. + Serial.println("setup: probing MaxBotix on Serial1 @ 9600 baud..."); + // Brings up UARTE0 and reclaims D1 from Serial1's TXD so pin 4 is a GPIO strobe + // (else it idles HIGH and the sensor free-runs). Same helper the wake path uses. + sonar_serial_begin(); + digitalWrite(PIN_STROBE, HIGH); // range during the probe window + // Give the sensor up to ~500 ms to emit a first frame so we can flag + // has_sensor on the OLED. has_sensor will be re-asserted on every successful + // updateSensorReadings() anyway. + { + unsigned long start = millis(); + bool got = false; + bool capturing = false; + int di = 0; + while ((millis() - start) < 500 && !got) { + while (Serial1.available()) { + char c = (char)Serial1.read(); + if (c == 'R') { capturing = true; di = 0; } + else if (capturing) { + if (c == '\r' && di >= 3) { got = true; break; } + if (c >= '0' && c <= '9') di++; + else { capturing = false; di = 0; } + } + } + delay(5); + } + if (got) { + has_sensor = true; + Serial.println("MaxBotix MB7388 detected on Serial1."); + } else { + has_sensor = false; + Serial.println("No MaxBotix frames on Serial1 (check wiring / 9600 baud)."); + } + digitalWrite(PIN_STROBE, LOW); // stop ranging; cycles command it per reading + } +#endif /* !STRIP_SONAR */ + +#if defined(DISPLAY_CLASS) && !STRIP_DISPLAY + if (display.begin()) { + display.startFrame(); + display.setCursor(0, 0); + display.print("Starting..."); + display.endFrame(); + } +#endif + +#if STRIP_FS && !STRIP_MESH +#error "STRIP_FS requires STRIP_MESH: the_mesh.begin() needs the filesystem" +#endif +#if !STRIP_FS + Serial.println("setup: filesystem.begin()..."); +#if defined(NRF52_PLATFORM) + InternalFS.begin(); +#elif defined(RP2040_PLATFORM) + LittleFS.begin(); +#elif defined(ESP32) + SPIFFS.begin(true); +#else + #error "need to define filesystem" +#endif +#endif /* !STRIP_FS */ + +#if !STRIP_MESH + Serial.println("setup: the_mesh.begin() -- if first-boot, will block here on 'Press ENTER to generate key:'"); +#if defined(NRF52_PLATFORM) + the_mesh.begin(InternalFS); +#elif defined(RP2040_PLATFORM) + the_mesh.begin(LittleFS); +#elif defined(ESP32) + the_mesh.begin(SPIFFS); +#endif + Serial.println("setup: mesh ready."); + + radio_set_params(LORA_FREQ, LORA_BW, LORA_SF, LORA_CR); + radio_set_tx_power(LORA_TX_POWER); + Serial.printf("[RADIO] freq=%.3f MHz bw=%.1f kHz sf=%d cr=4/%d txpwr=%d\n", + (double)LORA_FREQ, (double)LORA_BW, (int)LORA_SF, (int)LORA_CR, (int)LORA_TX_POWER); + + the_mesh.showWelcome(); + Serial.println("setup: done, entering loop()."); + +#ifdef DISPLAY_CLASS + last_button_activity = millis(); +#endif + + // Send out initial advertisement +#if ENABLE_ADVERT_ON_BOOT == 1 + the_mesh.sendSelfAdvert(1200); +#endif + + // hand control to the field-node mode machine: reset always enters POST. + enter_mode(MODE_POST); +#endif /* !STRIP_MESH */ +} + +void loop() { +#if STRIP_MESH + // Bare bisection idle: radio already warm-slept in setup, no mesh, no mode + // machine. Same per-poll sleep actions the deployment MODE_SLEEP applies. +#if HEARTBEAT + Serial.print("[HB] "); Serial.println(millis()); +#endif +#if SLEEP_HFCLK_STOP + if (!(NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk)) { + NRF_CLOCK->TASKS_HFCLKSTOP = 1; + } +#endif + delay(SLEEP_POLL_MS); +#else + // Pause the mesh while the radio is SPI-slept (MODE_SLEEP) -- servicing it would + // poll the slept radio over SPI and deadlock on BUSY. WAKE re-arms Rx before unpausing. + if (!g_radio_slept) { + the_mesh.loop(); // services the radio and fires processAck/onSendTimeout + } + rtc_clock.tick(); + mode_loop(); // field-node duty-cycle state machine (replaces displayLoop) +#endif +} diff --git a/variants/sonar_field_node/test_modes.cpp b/variants/sonar_field_node/test_modes.cpp new file mode 100644 index 0000000000..e5a73afe7f --- /dev/null +++ b/variants/sonar_field_node/test_modes.cpp @@ -0,0 +1,75 @@ +// Host unit test for the sonar_field_node mode machine (pure logic, no hardware). +// Build + run on the dev machine, not the board: +// g++ -std=c++11 -Wall -o /tmp/test_modes test_modes.cpp && /tmp/test_modes +// Validates the transition table in isolation before the .cpp hangs sonar/radio +// off it -- the "plain control" for the state machine. + +#include +#include "modes.h" + +static int checks = 0, fails = 0; + +static void expect(mode got, mode want, const char *what) { + checks++; + if (got != want) { + fails++; + printf(" FAIL %-34s got %-9s want %s\n", what, mode_name(got), mode_name(want)); + } +} + +// build a mode_event; woke defaults false (the common case) +static mode_event EV(int btn, bool elapsed, bool done, bool woke = false) { + mode_event e; e.btn = btn; e.elapsed = elapsed; e.done = done; e.woke_by_button = woke; + return e; +} + +int main() { + const mode_event IDLE = EV(BUTTON_EVENT_NONE, false, false); + + // --- deploy path: the whole low-power loop, no button --- + expect(next_mode(MODE_POST, IDLE), MODE_POST, "POST holds until selftest"); + expect(next_mode(MODE_POST, EV(0, false, true)), MODE_STATUS, "POST done -> STATUS"); + expect(next_mode(MODE_STATUS, IDLE), MODE_STATUS, "STATUS holds"); + expect(next_mode(MODE_STATUS, EV(0, true, false)), MODE_SLEEP, "STATUS 30s -> SLEEP"); + expect(next_mode(MODE_SLEEP, IDLE), MODE_SLEEP, "SLEEP holds"); + expect(next_mode(MODE_SLEEP, EV(0, true, false)), MODE_WAKE, "SLEEP RTC 5min -> WAKE"); + expect(next_mode(MODE_WAKE, EV(0, false, true)), MODE_MEASURE, "WAKE settled(no gesture) -> MEASURE"); + expect(next_mode(MODE_MEASURE, IDLE), MODE_MEASURE, "MEASURE holds"); + expect(next_mode(MODE_MEASURE, EV(0, false, true)), MODE_TRANSMIT, "MEASURE done -> TRANSMIT"); + expect(next_mode(MODE_TRANSMIT, IDLE), MODE_TRANSMIT, "TRANSMIT holds (retrying)"); + expect(next_mode(MODE_TRANSMIT, EV(0, false, true)), MODE_SLEEP, "TRANSMIT settled -> SLEEP"); + + // --- The race invariant: button-wake, gesture not yet resolved --- + expect(next_mode(MODE_SLEEP, EV(BUTTON_EVENT_NONE, false, false, true)), MODE_STATUS, "SLEEP + button edge -> STATUS"); + expect(next_mode(MODE_WAKE, EV(BUTTON_EVENT_NONE, false, false, true)), MODE_WAKE, "WAKE holds while classifying (no race)"); + + // --- WAKE routes once the classifier commits --- + expect(next_mode(MODE_WAKE, EV(BUTTON_EVENT_CLICK, false, false, true)), MODE_STATUS, "WAKE + CLICK -> STATUS"); + expect(next_mode(MODE_WAKE, EV(BUTTON_EVENT_DOUBLE_CLICK, false, false, true)), MODE_DEMO, "WAKE + DOUBLE -> DEMO"); + expect(next_mode(MODE_WAKE, EV(BUTTON_EVENT_LONG_PRESS, false, false, true)), MODE_TRANSMIT_DEBUG, "WAKE + LONG -> TX_DEBUG"); + + // --- awake gestures (peripherals already on; no wake needed) --- + expect(next_mode(MODE_STATUS, EV(BUTTON_EVENT_CLICK, false, false)), MODE_STATUS, "STATUS + CLICK -> STATUS (keepalive)"); + expect(next_mode(MODE_STATUS, EV(BUTTON_EVENT_DOUBLE_CLICK, false, false)), MODE_DEMO, "STATUS + DOUBLE -> DEMO"); + expect(next_mode(MODE_STATUS, EV(BUTTON_EVENT_LONG_PRESS, false, false)), MODE_TRANSMIT_DEBUG, "STATUS + LONG -> TX_DEBUG"); + expect(next_mode(MODE_DEMO, EV(BUTTON_EVENT_CLICK, false, false)), MODE_STATUS, "DEMO + CLICK -> STATUS"); + expect(next_mode(MODE_DEMO, EV(BUTTON_EVENT_DOUBLE_CLICK, false, false)), MODE_DEMO, "DEMO + DOUBLE -> DEMO"); + expect(next_mode(MODE_DEMO, EV(BUTTON_EVENT_LONG_PRESS, false, false)), MODE_TRANSMIT_DEBUG, "DEMO + LONG -> TX_DEBUG"); + expect(next_mode(MODE_DEMO, EV(0, true, false)), MODE_SLEEP, "DEMO cycle elapsed -> SLEEP"); + expect(next_mode(MODE_TRANSMIT_DEBUG, EV(0, false, true)), MODE_DEMO, "TX_DEBUG done -> DEMO"); + + // --- protected modes ignore the button (don't abort a live cycle) --- + expect(next_mode(MODE_MEASURE, EV(BUTTON_EVENT_LONG_PRESS, false, false)), MODE_MEASURE, "MEASURE ignores button"); + expect(next_mode(MODE_TRANSMIT, EV(BUTTON_EVENT_CLICK, false, false)), MODE_TRANSMIT, "TRANSMIT ignores button"); + expect(next_mode(MODE_POST, EV(BUTTON_EVENT_DOUBLE_CLICK, false, false)), MODE_POST, "POST ignores button"); + expect(next_mode(MODE_TRANSMIT_DEBUG, EV(BUTTON_EVENT_CLICK, false, false)), MODE_TRANSMIT_DEBUG, "TX_DEBUG ignores button mid-attempt"); + + // --- WAKE is timer-only now; settle falls through to the measurement --- + expect(next_mode(MODE_WAKE, EV(BUTTON_EVENT_NONE, false, true, true)), MODE_MEASURE, "WAKE settle -> MEASURE"); + // --- button wake from SLEEP goes straight to STATUS (no gesture dwell) --- + expect(next_mode(MODE_SLEEP, EV(BUTTON_EVENT_NONE, false, false, true)), MODE_STATUS, "SLEEP button wake -> STATUS"); + expect(next_mode(MODE_SLEEP, EV(BUTTON_EVENT_NONE, true, false, false)), MODE_WAKE, "SLEEP timer wake -> WAKE"); + + printf("\n%d checks, %d failures\n", checks, fails); + return fails ? 1 : 0; +} diff --git a/variants/sonar_oled_demo/platformio.ini b/variants/sonar_oled_demo/platformio.ini new file mode 100644 index 0000000000..7f220b8d13 --- /dev/null +++ b/variants/sonar_oled_demo/platformio.ini @@ -0,0 +1,21 @@ +; sonar_oled_demo -- live median-filtered MB7388 range + battery on the SSD1306 OLED. +; Adds the Adafruit SSD1306/GFX stack to the SPI+TinyUSB base (Wire needs TinyUSB; SSD1306 needs GFX+BusIO). +[env:sonar_oled_demo] +extends = nrf52_base +board = promicro_nrf52840 +lib_deps = + SPI + Wire + Adafruit TinyUSB Library + adafruit/Adafruit SSD1306 @ ^2.5.13 + adafruit/Adafruit GFX Library + adafruit/Adafruit BusIO +lib_ldf_mode = chain+ +build_flags = ${nrf52_base.build_flags} + -I variants/rook + -D PIN_OLED_RESET=-1 + -D SSD1306_NO_SPLASH +build_src_filter = + -<*> + +<../variants/rook/variant.cpp> + +<../variants/sonar_oled_demo/sonar_oled_demo_main.cpp> diff --git a/variants/sonar_oled_demo/sonar_oled_demo_main.cpp b/variants/sonar_oled_demo/sonar_oled_demo_main.cpp new file mode 100644 index 0000000000..58c5deb6d6 --- /dev/null +++ b/variants/sonar_oled_demo/sonar_oled_demo_main.cpp @@ -0,0 +1,96 @@ +// sonar_oled_demo -- live median-filtered MB7388 range on the SSD1306 OLED, ~1 Hz. +// Demo firmware: gate held ON (D5), commanded ranging (pin4/D1), median of N frames, +// shown big on the 128x64 OLED (0x3C, Wire SDA=8/SCL=7). Display stays on. No sleep. + +#include +#include +#include + +#define PIN_SONAR_GATE 5 // D5 = GPS_EN = Q2 gate (HIGH = sonar on) +#define PIN_STROBE 1 // D1 = MB7388 pin4 (reclaimed from Serial1 TX) +#define SONAR_BAUD 9600 +#define N_SAMPLES 5 +#define OLED_ADDR 0x3C +#define PIN_VBAT 17 // P0.31 = AIN7 (VBAT divider) +#define ADC_MULT 1.815f + +Adafruit_SSD1306 oled(128, 64, &Wire, -1); // PIN_OLED_RESET = -1 (none) + +// median of n ints (n small); insertion sort, return the middle. 9999 (no-target) +// participates so a lone glitch is outvoted; a majority of 9999 stays "no target". +static int median_int(int *a, int n) { + for (int i = 1; i < n; i++) { + int v = a[i], j = i - 1; + while (j >= 0 && a[j] > v) { a[j + 1] = a[j]; j--; } + a[j + 1] = v; + } + return a[n / 2]; +} + +// battery mV via the VBAT divider (avg 8 reads x 1.815, per RookBoard.getBattMilliVolts). +static uint16_t batt_mv() { + analogReadResolution(12); + uint32_t raw = 0; + for (int i = 0; i < 8; i++) raw += analogRead(PIN_VBAT); + raw /= 8; + return (uint16_t)(ADC_MULT * raw); +} + +// hold pin4 high, collect up to N consecutive Rdddd frames, return their median (mm), +// or -1 if nothing arrived. +static int read_median() { + while (Serial1.available()) Serial1.read(); // flush + digitalWrite(PIN_STROBE, HIGH); // pin4 high -> ranging + int samp[N_SAMPLES]; int ns = 0; + uint32_t t0 = millis(); + bool cap = false; char d[8]; int di = 0; + while (ns < N_SAMPLES && millis() - t0 < 1000) { + while (Serial1.available()) { + char c = (char)Serial1.read(); + if (c == 'R') { cap = true; di = 0; } + else if (cap && c == '\r') { d[di] = 0; if (di >= 3) samp[ns++] = atoi(d); cap = false; di = 0; } + else if (cap && di < 6 && c >= '0' && c <= '9') d[di++] = c; + else if (cap) { cap = false; di = 0; } + } + } + digitalWrite(PIN_STROBE, LOW); // pin4 low -> stop + return ns ? median_int(samp, ns) : -1; +} + +void setup() { + pinMode(PIN_SONAR_GATE, OUTPUT); digitalWrite(PIN_SONAR_GATE, HIGH); // sonar on + Serial1.begin(SONAR_BAUD); + NRF_UARTE0->PSEL.TXD = 0xFFFFFFFF; // reclaim D1 as a GPIO strobe + pinMode(PIN_STROBE, OUTPUT); digitalWrite(PIN_STROBE, LOW); + + Wire.begin(); // SDA=8, SCL=7 (variant defaults) + oled.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR, true, false); + oled.clearDisplay(); oled.display(); + delay(300); // sonar power-up settle +} + +void loop() { + int mm = read_median(); + uint16_t mv = batt_mv(); + + oled.clearDisplay(); + oled.setTextColor(SSD1306_WHITE); + oled.setTextSize(1); + oled.setCursor(0, 0); oled.print("SONAR"); + char bb[12]; snprintf(bb, sizeof(bb), "%u.%02uV", mv / 1000, (mv % 1000) / 10); + oled.setCursor(128 - 6 * (int)strlen(bb), 0); oled.print(bb); // battery, right-aligned + + char buf[16]; + if (mm < 0) snprintf(buf, sizeof(buf), "--.--"); + else if (mm == 9999) snprintf(buf, sizeof(buf), "no tgt"); + else snprintf(buf, sizeof(buf), "%d.%02dm", mm / 1000, (mm % 1000) / 10); + oled.setTextSize(3); oled.setCursor(0, 24); + oled.print(buf); + + oled.setTextSize(1); oled.setCursor(0, 56); + if (mm >= 0 && mm != 9999) { oled.print(mm); oled.print(" mm"); } + else oled.print(mm < 0 ? "(no serial)" : "(9999)"); + oled.display(); + + delay(1000); +}