diff --git a/scripts/run-host-tests.sh b/scripts/run-host-tests.sh index e534423..e28598b 100755 --- a/scripts/run-host-tests.sh +++ b/scripts/run-host-tests.sh @@ -25,7 +25,7 @@ extra_flags() { # Its subject is a board variant header, not shared src/. test_crowpanel_sd_codec) echo "-I variants/crowpanel_35" ;; test_m9_keyboard) echo "-I test/m9_keyboard_fakes" ;; - test_indicator_expander) echo "-I variants/sensecap_indicator" ;; + test_indicator_expander|test_indicator_dio1) echo "-I variants/sensecap_indicator" ;; *) echo "" ;; esac } diff --git a/test/test_indicator_dio1.cpp b/test/test_indicator_dio1.cpp new file mode 100644 index 0000000..36fc5c3 --- /dev/null +++ b/test/test_indicator_dio1.cpp @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Host test for the SenseCAP Indicator DIO1 edge detector (IndicatorDio1.h): the +// virtual-pin routing and the rule that every read of expander port 0 -- the +// dispatch task's and the BUSY polls' alike -- fires the radio callback exactly +// once per DIO1 rise. +// +// c++ -std=c++17 -Wall -Wextra -I src -I variants/sensecap_indicator test/test_indicator_dio1.cpp -o /tmp/ind_dio1 && /tmp/ind_dio1 + +#include +#include + +#include "IndicatorDio1.h" + +using namespace indicator; + +static int s_calls = 0; +static void onDio1() { ++s_calls; } + +static void virtual_pin_routing() { + static_assert(kExpanderPinFlag == 0x40, "virtual pin flag"); + assert(isExpanderPin(0x43)); + assert(isExpanderPin(0x40)); + assert(!isExpanderPin(41)); + assert(!isExpanderPin(0)); + assert(expanderBit(0x43) == 3); + assert(expanderBit(0x40) == pins::kLoraNss); +} + +static void rising_edge_fires_once() { + s_calls = 0; + Dio1Edge edge; + edge.arm(onDio1); + assert(edge.armed()); + assert(!edge.onPort0(0x00)); + assert(edge.onPort0(0x08)); + assert(s_calls == 1); + assert(!edge.onPort0(0x08)); + assert(s_calls == 1); +} + +static void edge_seen_in_busy_read_fires_once() { + s_calls = 0; + Dio1Edge edge; + edge.arm(onDio1); + assert(!edge.onPort0(0x04)); // BUSY high, DIO1 low + assert(edge.onPort0(0x08)); // the HAL's BUSY poll sees DIO1 rise + assert(!edge.onPort0(0x08)); // the dispatch task's read that follows + assert(s_calls == 1); +} + +static void level_high_without_edge_does_not_refire() { + s_calls = 0; + Dio1Edge edge; + edge.arm(onDio1); + assert(edge.onPort0(0x08)); + assert(!edge.onPort0(0x48)); // touch INT changed, DIO1 still high + assert(!edge.onPort0(0x4C)); // BUSY toggles too + assert(s_calls == 1); +} + +static void no_callback_while_disarmed() { + s_calls = 0; + Dio1Edge edge; + edge.disarm(); + assert(!edge.armed()); + assert(!edge.onPort0(0x00)); + assert(!edge.onPort0(0x08)); + assert(s_calls == 0); + + edge.arm(onDio1); // DIO1 is high now: no phantom edge on re-arm + assert(!edge.onPort0(0x08)); + assert(s_calls == 0); + + assert(!edge.onPort0(0x00)); + assert(edge.onPort0(0x08)); + assert(s_calls == 1); +} + +int main() { + virtual_pin_routing(); + rising_edge_fires_once(); + edge_seen_in_busy_read_fires_once(); + level_high_without_edge_does_not_refire(); + no_callback_while_disarmed(); + printf("all indicator dio1 tests passed\n"); + return 0; +} diff --git a/variants/sensecap_indicator/IndicatorBoard.h b/variants/sensecap_indicator/IndicatorBoard.h index 2606603..9d91bec 100644 --- a/variants/sensecap_indicator/IndicatorBoard.h +++ b/variants/sensecap_indicator/IndicatorBoard.h @@ -3,10 +3,20 @@ #include +#include "IndicatorExpander.h" + class IndicatorBoard : public ESP32Board { public: void begin(); uint16_t getBattMilliVolts() override { return 0; } // mains-powered, no battery bool isExternalPowered() override { return true; } const char* getManufacturerName() const override { return "Seeed SenseCAP Indicator"; } + + // P_LORA_DIO_1 is an expander pin, not a GPIO. The line that reports radio + // events to the ESP32 is the expander's /INT on GPIO42 (active low). + uint32_t getIRQGpio() override { return indicator::kExpanderIntGpio; } + + // ESP32Board::sleep light-sleeps with getIRQGpio() as a high-level wake source. + // /INT is active low and DIO1 is only seen through I2C, so never light-sleep. + void sleep(uint32_t secs) override { (void)secs; delay(1); } }; diff --git a/variants/sensecap_indicator/IndicatorDio1.h b/variants/sensecap_indicator/IndicatorDio1.h new file mode 100644 index 0000000..3eb74e2 --- /dev/null +++ b/variants/sensecap_indicator/IndicatorDio1.h @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +// DIO1 of the SX1262 reaches the ESP32 only through the TCA9535, and any read of +// input port 0 clears the expander's /INT. So the radio callback cannot hang off +// a GPIO edge: it fires from whichever read first sees DIO1 go high, be that the +// dispatch task woken by /INT or a BUSY poll made by RadioLib. Header-only and +// hardware-free; the host test drives it with raw port-0 bytes. + +#include + +#include "IndicatorExpander.h" + +namespace indicator { + +// Virtual pin numbers 0x40..0x4F name expander pins (P_LORA_NSS=0x40 and so on). +constexpr uint32_t kExpanderPinFlag = 0x40; + +constexpr bool isExpanderPin(uint32_t pin) { return (pin & ~0x0FU) == kExpanderPinFlag; } + +constexpr uint8_t expanderBit(uint32_t pin) { return (uint8_t)(pin & 0x0F); } + +// Edge detector over the port-0 reads. The caller serialises every call (the +// HAL holds IndicatorIo::BusLock); the callback runs inside onPort0 and must not +// block. +class Dio1Edge { +public: + // `last` keeps the level seen by the latest read, armed or not: arming while + // DIO1 is already high must not count as a rise. + void arm(void (*cb)()) { + _cb = cb; + _armed = cb != nullptr; + } + + void disarm() { _armed = false; } + + bool onPort0(uint8_t port0) { + const bool high = (port0 & kDio1Mask) != 0; + const bool rise = high && !_last; + _last = high; + if (!rise || !_armed) return false; + _cb(); + return true; + } + + bool armed() const { return _armed; } + +private: + static constexpr uint8_t kDio1Mask = (uint8_t)(1U << pins::kLoraDio1); + + void (*_cb)() = nullptr; + bool _armed = false; + bool _last = false; +}; + +} // namespace indicator diff --git a/variants/sensecap_indicator/IndicatorExpander.h b/variants/sensecap_indicator/IndicatorExpander.h index be7135f..367668d 100644 --- a/variants/sensecap_indicator/IndicatorExpander.h +++ b/variants/sensecap_indicator/IndicatorExpander.h @@ -25,6 +25,9 @@ constexpr uint8_t kRp2040Reset = 8; constexpr uint8_t kRadioStrap = 11; // input, high = TCXO } // namespace pins +// The expander's open-drain /INT output (active low) reaches the ESP32 on this GPIO. +constexpr uint8_t kExpanderIntGpio = 42; + struct ExpanderBus { virtual bool read(uint8_t reg, uint8_t* data, size_t n) = 0; virtual bool write(uint8_t reg, const uint8_t* data, size_t n) = 0; diff --git a/variants/sensecap_indicator/IndicatorRadioHal.cpp b/variants/sensecap_indicator/IndicatorRadioHal.cpp new file mode 100644 index 0000000..f6f4464 --- /dev/null +++ b/variants/sensecap_indicator/IndicatorRadioHal.cpp @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "IndicatorRadioHal.h" + +#include +#include +#include + +#include "IndicatorIo.h" + +namespace { + +using indicator::expanderBit; +using indicator::isExpanderPin; +using indicator::kExpanderIntGpio; +namespace pins = indicator::pins; + +constexpr uint32_t kDispatchStackBytes = 3072; +constexpr TickType_t kLostIntPoll = pdMS_TO_TICKS(50); +constexpr uint32_t kResetSettleMs = 20; // without it begin() reports CHIP_NOT_FOUND + +// /INT wake log. Several port-1 inputs float with no pull-up, so /INT can toggle +// without end, and each toggle costs an I2C read at high priority. One line for +// each of the first six 10 s windows (the first minute), then only while the rate +// stays above 500/s, at most once a minute. The windows are counted rather than +// timed from the start: each closes on the first wake after 10 s, so six of them +// run slightly past 60 s. +constexpr TickType_t kWakeWindow = pdMS_TO_TICKS(10000); +constexpr uint8_t kWakeVerboseWindows = 6; +constexpr TickType_t kWakeStormLogEvery = pdMS_TO_TICKS(60000); +constexpr uint32_t kWakeStormPerWindow = 500 * 10; + +// Set in OUTPUT and OUTPUT_OPEN_DRAIN, clear in INPUT and its pull variants. +constexpr uint32_t kOutputModeBit = OUTPUT & ~INPUT; + +TaskHandle_t s_dispatch = nullptr; + +// Only wakes the task: the expander read needs I2C and the bus mutex. +void IRAM_ATTR onExpanderInt() { + BaseType_t woken = pdFALSE; + if (s_dispatch) vTaskNotifyGiveFromISR(s_dispatch, &woken); + if (woken) portYIELD_FROM_ISR(); +} + +// Never touches RadioLib or SPI. The input read clears /INT and feeds the +// observer, which calls the radio callback on a DIO1 rise. The timeout re-reads +// anyway, in case /INT fell while the ISR was not yet attached or a change +// landed inside another reader's transfer. +void dispatchTask(void*) { + TickType_t windowStart = xTaskGetTickCount(); + TickType_t lastStormLog = 0; + bool stormLogged = false; + uint8_t verboseLeft = kWakeVerboseWindows; + uint32_t wakes = 0, timeouts = 0; + for (;;) { + // The return value is the number of ISR notifications since the last take; + // zero means the 50 ms timeout expired. + const uint32_t notified = ulTaskNotifyTake(pdTRUE, kLostIntPoll); + if (notified) wakes += notified; + else ++timeouts; + { + IndicatorIo::BusLock lock; + // Both input bytes: /INT clears only when the port that changed is read, so a + // port-0-only read would leave a port-1 change holding GPIO42 low for good. + // A port-1 pin read covers both bytes and still reports port 0 to the observer. + bool strap = false; + IndicatorIo::chip().readPin(pins::kRadioStrap, strap); + } + const TickType_t now = xTaskGetTickCount(); + if (now - windowStart < kWakeWindow) continue; + const bool early = verboseLeft > 0; + if (early) --verboseLeft; + const bool storm = wakes > kWakeStormPerWindow && + (!stormLogged || now - lastStormLog >= kWakeStormLogEvery); + if (early || storm) { + if (!early) { + stormLogged = true; + lastStormLog = now; + } + Serial.printf("[indicator] /INT wakes=%lu/10s timeouts=%lu\n", (unsigned long)wakes, + (unsigned long)timeouts); + } + wakes = 0; + timeouts = 0; + windowStart = now; + } +} + +} // namespace + +IndicatorRadioHal::IndicatorRadioHal(SPIClass& spi, uint32_t spiFreq) + : ArduinoHal(spi, SPISettings(spiFreq, MSBFIRST, SPI_MODE0)) {} + +// Module::init() runs this from SX126x::begin(), before any pin call. FSPI is +// already running on GPIO41/47/48: std_init starts it before begin(), and this +// HAL never calls spi.begin() itself, which would move FSPI to its default pins +// (RGB data lines on this board). The observer goes in here rather than +// in the constructor: the expander object lives in another translation unit and +// may not be constructed yet at static init time. +void IndicatorRadioHal::init() { + ArduinoHal::init(); + IndicatorIo::BusLock lock; + IndicatorIo::chip().setPort0Observer(&IndicatorRadioHal::onPort0, this); +} + +// Runs inside a port-0 read, so under the BusLock the reader holds. +void IndicatorRadioHal::onPort0(uint8_t value, void* ctx) { + static_cast(ctx)->_dio1.onPort0(value); +} + +// An output keeps its current latch: the NSS high set at boot must survive +// RadioLib's pinMode(cs, OUTPUT). +void IndicatorRadioHal::pinMode(uint32_t pin, uint32_t mode) { + if (!isExpanderPin(pin)) { + if (pin != RADIOLIB_NC) ::pinMode(pin, mode); + return; + } + const uint8_t bit = expanderBit(pin); + auto& io = IndicatorIo::chip(); + IndicatorIo::BusLock lock; + if (mode & kOutputModeBit) { + io.setOutput(bit, (io.output(bit >> 3) & (1U << (bit & 7))) != 0); + } else { + io.setInput(bit); + } +} + +void IndicatorRadioHal::digitalWrite(uint32_t pin, uint32_t value) { + if (!isExpanderPin(pin)) { + if (pin != RADIOLIB_NC) ::digitalWrite(pin, value); + return; + } + const uint8_t bit = expanderBit(pin); + { + IndicatorIo::BusLock lock; + IndicatorIo::chip().setOutput(bit, value != 0); + } + if (bit == pins::kLoraReset && value) ::delay(kResetSettleMs); +} + +// A BUSY read is a port-0 read, so it samples DIO1 too: a rise that lands while +// RadioLib waits on BUSY fires the callback from here, and the read that clears +// /INT is never a lost edge. +uint32_t IndicatorRadioHal::digitalRead(uint32_t pin) { + if (!isExpanderPin(pin)) return pin == RADIOLIB_NC ? 0 : ::digitalRead(pin); + bool high = false; + IndicatorIo::BusLock lock; + if (!IndicatorIo::chip().readPin(expanderBit(pin), high)) return LOW; + return high ? HIGH : LOW; +} + +// Only DIO1 has an edge path, and Dio1Edge only knows rising edges, which is +// what SX126x::setDio1Action asks for. +void IndicatorRadioHal::attachInterrupt(uint32_t interruptNum, void (*interruptCb)(void), + uint32_t mode) { + if (!isExpanderPin(interruptNum)) { + if (interruptNum != RADIOLIB_NC) ::attachInterrupt(interruptNum, interruptCb, mode); + return; + } + if (expanderBit(interruptNum) != pins::kLoraDio1) return; + { + IndicatorIo::BusLock lock; + _dio1.arm(interruptCb); + } + startDispatch(); +} + +// The dispatch task keeps reading while disarmed, so Dio1Edge keeps tracking the +// level and a later re-arm with DIO1 already high is no edge. +void IndicatorRadioHal::detachInterrupt(uint32_t interruptNum) { + if (!isExpanderPin(interruptNum)) { + if (interruptNum != RADIOLIB_NC) ::detachInterrupt(interruptNum); + return; + } + if (expanderBit(interruptNum) != pins::kLoraDio1) return; + IndicatorIo::BusLock lock; + _dio1.disarm(); +} + +uint32_t IndicatorRadioHal::pinToInterrupt(uint32_t pin) { + return isExpanderPin(pin) ? pin : digitalPinToInterrupt(pin); +} + +void IndicatorRadioHal::startDispatch() { + if (s_dispatch) return; + if (xTaskCreatePinnedToCore(dispatchTask, "ind_dio1", kDispatchStackBytes, nullptr, + configMAX_PRIORITIES - 2, &s_dispatch, + ARDUINO_RUNNING_CORE) != pdPASS) { + s_dispatch = nullptr; + Serial.println("[indicator] radio hal: dispatch task not created"); + return; + } + ::pinMode(kExpanderIntGpio, INPUT_PULLUP); + ::attachInterrupt(kExpanderIntGpio, onExpanderInt, FALLING); + Serial.printf("[indicator] radio hal: dio1 via expander /INT on GPIO%u\n", + (unsigned)kExpanderIntGpio); +} diff --git a/variants/sensecap_indicator/IndicatorRadioHal.h b/variants/sensecap_indicator/IndicatorRadioHal.h new file mode 100644 index 0000000..7417cd4 --- /dev/null +++ b/variants/sensecap_indicator/IndicatorRadioHal.h @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +// RadioLib HAL for the SenseCAP Indicator. SCK/MISO/MOSI are real GPIOs on the +// FSPI peripheral, but the SX1262's NSS, RESET, BUSY and DIO1 sit on the TCA9535 +// (P0.0..P0.3). The env names them with virtual pins 0x40..0x43; this HAL turns +// those into expander transfers and hands every real GPIO straight to the +// Arduino pin functions, as ArduinoHal does. +// +// DIO1 has no GPIO edge. The expander's open-drain /INT on GPIO42 wakes a +// dispatch task that reads port 0, and every port-0 read -- that one, or a BUSY +// poll RadioLib makes -- goes through Dio1Edge, which calls the radio callback +// once per DIO1 rise. TX-done and RX-done both arrive that way, so the stock +// core sees the same callback an IRQ board does. + +#include +#include + +#include "IndicatorDio1.h" + +class IndicatorRadioHal : public ArduinoHal { +public: + explicit IndicatorRadioHal(SPIClass& spi, uint32_t spiFreq = 4000000); + + void init() override; + void pinMode(uint32_t pin, uint32_t mode) override; + void digitalWrite(uint32_t pin, uint32_t value) override; + uint32_t digitalRead(uint32_t pin) override; + void attachInterrupt(uint32_t interruptNum, void (*interruptCb)(void), uint32_t mode) override; + void detachInterrupt(uint32_t interruptNum) override; + uint32_t pinToInterrupt(uint32_t pin) override; + + // Creates the dispatch task and the GPIO42 ISR. Idempotent; attachInterrupt on + // DIO1 calls it the first time. + void startDispatch(); + +private: + static void onPort0(uint8_t value, void* ctx); + + indicator::Dio1Edge _dio1; +}; diff --git a/variants/sensecap_indicator/target.cpp b/variants/sensecap_indicator/target.cpp index e0baff0..a6c0f46 100644 --- a/variants/sensecap_indicator/target.cpp +++ b/variants/sensecap_indicator/target.cpp @@ -3,14 +3,16 @@ #include "target.h" #include "IndicatorIo.h" +#include "IndicatorRadioHal.h" IndicatorBoard board; // The radio's NSS/RESET/BUSY/DIO1 sit on the TCA9535; the P_LORA_* values are -// virtual pins that a HAL resolves to expander bits. Until that HAL exists this -// is a plain Module, which links but cannot drive the radio. +// virtual pins that IndicatorRadioHal resolves to expander bits. SCK/MISO/MOSI +// are real GPIOs on FSPI. static SPIClass radioSpi(FSPI); -RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, radioSpi); +static IndicatorRadioHal radio_hal(radioSpi); +RADIO_CLASS radio = new Module(&radio_hal, P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); WRAPPER_CLASS radio_driver(radio, board); ESP32RTCClock fallback_clock; @@ -21,14 +23,36 @@ EnvironmentSensorManager sensors; IndicatorDisplay display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +// The strap picks the TCXO setting. std_init only falls back from TCXO to 0 V, +// so a TCXO board whose strap misreads as crystal would never start: a failed +// start tries the other setting before giving up. main.cpp's retry after a +// transient failure runs both again. +static bool s_tcxoAlternate = false; + float indicatorTcxoVoltage() { - return IndicatorIo::radioHasTcxo() ? 2.4f : 0.0f; + const bool tcxo = IndicatorIo::radioHasTcxo() != s_tcxoAlternate; + return tcxo ? 2.4f : 0.0f; +} + +static bool radioStart(bool alternate) { + s_tcxoAlternate = alternate; + Serial.printf("[indicator] radio tcxo requested=%.1f V (strap: %s%s)\n", indicatorTcxoVoltage(), + IndicatorIo::radioHasTcxo() ? "tcxo" : "crystal", + alternate ? ", trying the other setting" : ""); + // std_init starts FSPI on GPIO41/47/48; main.cpp runs display.begin() first, + // and the panel init has handed GPIO41/48 back by then. + const bool ok = radio.std_init(&radioSpi); + // std_init and RadioLib can each drop to 0 V on their own; tcxoVoltage holds + // the value the last begin() ran with. + Serial.printf("[indicator] radio %s, tcxo used=%.1f V\n", ok ? "up" : "init failed", + radio.tcxoVoltage); + return ok; } bool radio_init() { fallback_clock.begin(); rtc_clock.begin(Wire); - return radio.std_init(&radioSpi); + return radioStart(false) || radioStart(true); } mesh::LocalIdentity radio_new_identity() { diff --git a/variants/sensecap_indicator/target.h b/variants/sensecap_indicator/target.h index 7bd46a6..54fc2d1 100644 --- a/variants/sensecap_indicator/target.h +++ b/variants/sensecap_indicator/target.h @@ -2,7 +2,8 @@ #pragma once // The env sets SX126X_DIO3_TCXO_VOLTAGE=indicatorTcxoVoltage(): the strap picks -// TCXO or crystal at run time, and CustomSX1262.h expands it inline. +// TCXO or crystal at run time (a failed start tries the other one), and +// CustomSX1262.h expands it inline. float indicatorTcxoVoltage(); #define RADIOLIB_STATIC_ONLY 1