diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 4128ff6c..1843517b 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -23,6 +23,7 @@ arguments such as node names, passwords, and keys is left unchanged. - [Configuration](#configuration) - [Radio](#radio) - [System](#system) + - [GPIO](#control-an-exposed-gpio) - [Routing](#routing) - [Flood Filtering](#filter-flood-packets-by-payload-type-and-hop) - [Group Text Moderation](#moderate-flood-group-text-by-channel-sender-and-source-path) @@ -884,6 +885,32 @@ get clock.sync.status --- +#### Control an exposed GPIO + +**Availability:** ESP32 Repeater, Room Server, Bridge, and Sensor firmware. Companion firmware does not expose these commands. On nRF52, the commands are enabled only for Sensor builds on the Heltec T096, ProMicro, RAK3401, and RAK4631. GPIO expanders are not supported. + +**Usage:** + +- `get gpio` — list the Arduino pin numbers this firmware build permits +- `get gpio ` — show `on`, `off`, or `reset`, plus any pending timed transition +- `set gpio on` +- `set gpio off` +- `set gpio reset` +- `set gpio ` + +**Examples:** + +- `set gpio 16 on 30 off` — drive GPIO16 high for 30 seconds, then drive it low +- `set gpio 16 off 5 reset` — drive GPIO16 low for 5 seconds, then return it to high impedance + +`reset` changes the pin to an input with no pull resistor (high impedance). It does not reboot the node. A new command for the same pin cancels and replaces its pending timer. Timers are non-blocking, are not saved, and are lost on reboot. The maximum timer is 2,147,483 seconds. + +The pin number is the Arduino pin number used by that target (the normal GPIO number on ESP32 and the board's `D`/pin index on nRF52). The available-pin list is build-specific. Radio, flash/PSRAM, USB, serial console, display, GPS, I2C, buttons, LEDs, battery measurement, power control, bridge, Ethernet, watchdog, and other pins claimed by the firmware are rejected. A pin must also be physically broken out on your board; `get gpio` cannot detect wiring or an attached peripheral that is not represented by the firmware configuration. + +**Electrical warning:** GPIOs use 3.3 V logic and have limited drive current. Do not power a relay, motor, solenoid, or other load directly from a GPIO. Use a suitable transistor, MOSFET, optocoupler, or driver with the required protection components. + +--- + ### Routing #### View or change this node's repeat flag diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 65745df3..db3e8287 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -8496,6 +8496,7 @@ void MyMesh::loop() { // Check radio FIRST to ensure we don't miss incoming packets // MQTT processing runs in a separate FreeRTOS task on Core 0, so we don't call bridge.loop() here mesh::Mesh::loop(); + _cli.loop(); processDeferredCliCommand(); servicePostMeshLoop(); } @@ -8966,7 +8967,7 @@ bool MyMesh::startNeighborDiscover(char* reply) { // To check if there is pending work bool MyMesh::hasPendingWork() const { - if (deferred_cli_command.pending || pending_self_advert) return true; + if (deferred_cli_command.pending || pending_self_advert || _cli.hasActiveUserGpioTimer()) return true; #if defined(WITH_BRIDGE) const AbstractBridge* active_bridge = activeBridge(); if (active_bridge && active_bridge->isRunning()) return true; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index ad8ec736..fa039de7 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1741,6 +1741,7 @@ void MyMesh::loop() { // Check radio FIRST to ensure we don't miss incoming packets // MQTT processing can take time, so we prioritize radio reception mesh::Mesh::loop(); + _cli.loop(); #ifdef WITH_MQTT_BRIDGE // bridge.loop() is now handled by FreeRTOS task on Core 0 - no need to call it here #endif diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index eac25a1c..9937e8f2 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1058,6 +1058,7 @@ bool SensorMesh::getGPS(uint8_t channel, float& lat, float& lon, float& alt) { } void SensorMesh::loop() { + _cli.loop(); mesh::Mesh::loop(); if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { diff --git a/platformio.ini b/platformio.ini index b2877a20..b2cf84a1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -270,6 +270,7 @@ build_src_filter = +<../src/helpers/ota/OtaProtocol.cpp> +<../src/helpers/ota/OtaManager.cpp> +<../src/helpers/ota/detools/detools.c> + +<../src/helpers/UserGpio.cpp> lib_deps = google/googletest @ 1.17.0 bblanchon/ArduinoJson @ 7.4.3 diff --git a/src/MeshCore.h b/src/MeshCore.h index 558667ba..ec390899 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -74,6 +74,10 @@ public: virtual void sleep(uint32_t secs) { /* no op */ } virtual uint32_t getGpio() { return 0; } virtual void setGpio(uint32_t values) {} + // Returns true only for physical MCU GPIOs that are safe for the user to + // control in this build. Board implementations must reject pins already + // claimed by firmware or internal hardware. + virtual bool isUserGpioAvailable(uint8_t pin) const { return false; } virtual uint8_t getStartupReason() const = 0; virtual bool getBootloaderVersion(char* version, size_t max_len) { return false; } virtual bool startOTAUpdate(const char* id, char reply[], bool force_ap = false) { return false; } // not supported diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 8f72e96c..d2a21b5f 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -8,6 +8,7 @@ #include "AtomicFileWriter.h" #endif #include +#include #include #include #include @@ -133,6 +134,28 @@ static bool isValidName(const char *n) { return true; } +static bool isGpioConfig(const char* config) { + static const char expected[] = "gpio"; + for (size_t i = 0; i < sizeof(expected) - 1; i++) { + if (config[i] == '\0' || tolower((unsigned char)config[i]) != expected[i]) return false; + } + return config[sizeof(expected) - 1] == '\0' || config[sizeof(expected) - 1] == ' '; +} + +void CommonCLI::loop() { +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) + _user_gpio.loop(); +#endif +} + +bool CommonCLI::hasActiveUserGpioTimer() const { +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) + return _user_gpio.hasActiveTimer(); +#else + return false; +#endif +} + // Old fork firmware persisted the (since removed) NodePrefs MQTT fields to /com_prefs // as a zero-filled gap between owner_info (which ends at offset 290) and a trailing // observer block (rx_boosted_gain, flood_max_*, snmp/watchdog/alert settings). @@ -3001,6 +3024,15 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) { const char* config = &command[4]; + if (isGpioConfig(config)) { +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) + _user_gpio.handleSet(config + 4, reply, 160); +#else + strcpy(reply, "Error: GPIO control unsupported on this build"); +#endif + return; + } + #if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED) if (memcmp(config, "webui ", 6) == 0) { const char* value = &config[6]; @@ -4116,6 +4148,15 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) { const char* config = &command[4]; + if (isGpioConfig(config)) { +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) + _user_gpio.handleGet(config + 4, reply, 160); +#else + strcpy(reply, "Error: GPIO control unsupported on this build"); +#endif + return; + } + #if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED) if (strcmp(config, "webui") == 0) { if (!_callbacks->getWebUIStatus(reply)) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 3ee8be10..46d14822 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -12,6 +12,10 @@ #define DEFAULT_CAD_ENABLED 0 #endif +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) +#include +#endif + #if defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) || defined(WITH_MQTT_BRIDGE) #define WITH_BRIDGE #endif @@ -495,6 +499,9 @@ class CommonCLI { NodePrefs* _prefs; CommonCLICallbacks* _callbacks; mesh::MainBoard* _board; +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) + UserGpio _user_gpio; +#endif SensorManager* _sensors; RegionMap* _region_map; ClientACL* _acl; @@ -542,10 +549,16 @@ public: static bool recalculateRxPowerSavingFromLevel(NodePrefs* prefs); CommonCLI(mesh::MainBoard& board, mesh::RTCClock& rtc, SensorManager& sensors, RegionMap& region_map, ClientACL& acl, NodePrefs* prefs, CommonCLICallbacks* callbacks) - : _board(&board), _rtc(&rtc), _sensors(&sensors), _region_map(®ion_map), _acl(&acl), _prefs(prefs), _callbacks(callbacks) { } + : _rtc(&rtc), _prefs(prefs), _callbacks(callbacks), _board(&board), +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) + _user_gpio(board), +#endif + _sensors(&sensors), _region_map(®ion_map), _acl(&acl) { } void loadPrefs(FILESYSTEM* _fs); void savePrefs(FILESYSTEM* _fs, bool save_mqtt = true); + void loop(); + bool hasActiveUserGpioTimer() const; void handleCommand(uint32_t sender_timestamp, char* command, char* reply); mesh::MainBoard* getBoard() { return _board; } uint8_t buildAdvertData(uint8_t node_type, uint8_t* app_data); diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index b2de97fb..b0ac1518 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -2,6 +2,49 @@ #include "ESP32Board.h" #include +#include "UserGpioPinPolicy.h" + +namespace { + +bool isEsp32SystemPin(uint8_t pin) { +#if defined(CONFIG_IDF_TARGET_ESP32) + // SPI flash, boot strapping, and (when present) external PSRAM. + if ((pin >= 6 && pin <= 11) || pin == 0 || pin == 2 || pin == 5 || pin == 12 || pin == 15) return true; +#ifdef BOARD_HAS_PSRAM + if (pin == 16 || pin == 17) return true; +#endif +#elif defined(CONFIG_IDF_TARGET_ESP32S2) + if ((pin >= 26 && pin <= 32) || pin == 0 || pin == 45 || pin == 46) return true; + if (pin == 19 || pin == 20) return true; // native USB +#elif defined(CONFIG_IDF_TARGET_ESP32S3) + if ((pin >= 26 && pin <= 32) || pin == 0 || pin == 3 || pin == 45 || pin == 46) return true; +#ifdef CONFIG_SPIRAM_MODE_OCT + if (pin >= 33 && pin <= 37) return true; +#endif + if (pin == 19 || pin == 20) return true; // native USB/JTAG +#elif defined(CONFIG_IDF_TARGET_ESP32C3) + if ((pin >= 12 && pin <= 17) || pin == 2 || pin == 8 || pin == 9) return true; + if (pin == 18 || pin == 19) return true; // native USB/JTAG +#elif defined(CONFIG_IDF_TARGET_ESP32C6) + if ((pin >= 24 && pin <= 30) || pin == 4 || pin == 5 || pin == 8 || pin == 9 || pin == 15) return true; + if (pin == 12 || pin == 13) return true; // native USB/JTAG +#endif + return false; +} + +} // namespace + +bool ESP32Board::isUserGpioAvailable(uint8_t pin) const { + if (!digitalPinCanOutput(pin) || isEsp32SystemPin(pin)) return false; + + // Serial is the local CLI, and Wire is initialized by ESP32Board::begin(). + if (pin == TX || pin == RX) return false; +#if !defined(PIN_BOARD_SDA) || !defined(PIN_BOARD_SCL) + if (pin == SDA || pin == SCL) return false; +#endif + + return !UserGpioPinPolicy::isFirmwareReserved(pin); +} #if defined(ADMIN_PASSWORD) && defined(LIGHTWEIGHT_WIFI_OTA) #include diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 2a096227..f2e52016 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -137,6 +137,7 @@ public: } uint8_t getStartupReason() const override { return startup_reason; } + bool isUserGpioAvailable(uint8_t pin) const override; #if defined(P_LORA_TX_LED) void onBeforeTransmit() override { diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index 6fe5af1e..fa0e8aee 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -2,6 +2,9 @@ #include "NRF52Board.h" #include "PowerManagementUtils.h" #include +#ifdef USER_GPIO_CONTROL +#include "UserGpioPinPolicy.h" +#endif #include #include "ble_gap.h" @@ -38,6 +41,57 @@ static void format_ota_reply(char reply[]) { mac_addr[2], mac_addr[1], mac_addr[0]); } +#ifdef USER_GPIO_CONTROL +namespace { + +bool isExposedNrf52UserGpio(uint8_t pin) { +#if defined(HELTEC_T096) + // Physical P2/P3 header GPIOs from the T096 schematic. Firmware-owned + // radio, display, GPS, power, button, and I2C pins are removed separately. + static const uint8_t exposed[] = { + 2, 4, 7, 8, 9, 10, 13, 15, 17, 20, 22, 23, 24, 25, 27, 29, 31, + 32, 33, 34, 35, 36, 37, 38, 39, 42, 43, 45, 47 + }; +#elif defined(PROMICRO) + // D0-D17 are broken out on the ProMicro form factor. + if (pin <= 17) return true; + return false; +#elif defined(RAK_3401) || defined(RAK_4631) + // GPIO and bus signals exposed by the WisBlock base/IO connector. + static const uint8_t exposed[] = { + 2, 3, 4, 5, 9, 10, 13, 14, 15, 16, 17, 19, 20, 21, 24, 25, + 26, 28, 29, 30, 31, 33, 34 + }; +#else + return false; +#endif + +#if defined(HELTEC_T096) || defined(RAK_3401) || defined(RAK_4631) + for (size_t i = 0; i < sizeof(exposed) / sizeof(exposed[0]); i++) { + if (pin == exposed[i]) return true; + } +#endif + return false; +} + +} // namespace +#endif + +bool NRF52Board::isUserGpioAvailable(uint8_t pin) const { +#ifdef USER_GPIO_CONTROL + if (pin >= PINS_COUNT || digitalPinToPinName(pin) == 0xFF) return false; +#if defined(RAK_3401) || defined(RAK_4631) + // Sensor startup can toggle these WisBlock slot pins while detecting GPS, + // and WB_IO2 also controls the switched peripheral rail on supported bases. + if (pin == WB_IO2 || pin == WB_IO4 || pin == WB_IO5) return false; +#endif + return isExposedNrf52UserGpio(pin) && !UserGpioPinPolicy::isFirmwareReserved(pin); +#else + (void)pin; + return false; +#endif +} + static void connect_callback(uint16_t conn_handle) { ota_conn_handle = conn_handle; MESH_DEBUG_PRINTLN("BLE client connected"); diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index 30a30f21..bc7e66bc 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -68,6 +68,7 @@ public: virtual bool startOTAUpdate(const char *id, char reply[], bool force_ap = false) override; virtual bool stopOTAUpdate(char reply[]) override; virtual void sleep(uint32_t secs) override; + bool isUserGpioAvailable(uint8_t pin) const override; bool isExternalPowered() override; bool isUsbDataConnected() override; bool isUsbHostConnected() override; diff --git a/src/helpers/UserGpio.cpp b/src/helpers/UserGpio.cpp new file mode 100644 index 00000000..4c5c1d19 --- /dev/null +++ b/src/helpers/UserGpio.cpp @@ -0,0 +1,244 @@ +#include "UserGpio.h" + +#include +#include +#include +#include + +namespace { + +// Keep deadlines within the signed half of the millis() range so wrap-safe +// comparisons remain unambiguous. This is about 24.8 days. +const uint32_t MAX_TIMER_SECONDS = 2147483UL; + +const char* skipSpaces(const char* text) { + while (*text == ' ') text++; + return text; +} + +bool atEnd(const char* text) { + return *skipSpaces(text) == '\0'; +} + +bool parseUnsigned(const char*& cursor, uint32_t& value) { + const char* p = skipSpaces(cursor); + if (*p < '0' || *p > '9') return false; + + uint32_t parsed = 0; + do { + const uint8_t digit = (uint8_t)(*p - '0'); + if (parsed > (UINT32_MAX - digit) / 10U) return false; + parsed = parsed * 10U + digit; + p++; + } while (*p >= '0' && *p <= '9'); + + if (*p != '\0' && *p != ' ') return false; + cursor = p; + value = parsed; + return true; +} + +bool tokenEquals(const char* start, size_t length, const char* expected) { + size_t i = 0; + for (; i < length && expected[i] != '\0'; i++) { + if (tolower((unsigned char)start[i]) != tolower((unsigned char)expected[i])) return false; + } + return i == length && expected[i] == '\0'; +} + +bool parseState(const char*& cursor, UserGpio::State& state) { + const char* start = skipSpaces(cursor); + const char* end = start; + while (*end != '\0' && *end != ' ') end++; + const size_t length = (size_t)(end - start); + + if (tokenEquals(start, length, "on")) { + state = UserGpio::STATE_ON; + } else if (tokenEquals(start, length, "off")) { + state = UserGpio::STATE_OFF; + } else if (tokenEquals(start, length, "reset")) { + state = UserGpio::STATE_RESET; + } else { + return false; + } + + cursor = end; + return true; +} + +} // namespace + +UserGpio::UserGpio(mesh::MainBoard& board) + : _board(&board), + _controlled_mask(0), + _timer_mask(0), + _timer_final_on_mask(0), + _timer_final_reset_mask(0) { + memset(_timer_deadline, 0, sizeof(_timer_deadline)); +} + +const char* UserGpio::stateName(State state) { + switch (state) { + case STATE_ON: return "on"; + case STATE_OFF: return "off"; + default: return "reset"; + } +} + +bool UserGpio::isAvailable(uint32_t pin) const { + return pin <= MAX_GPIO_PIN && _board->isUserGpioAvailable((uint8_t)pin); +} + +UserGpio::State UserGpio::currentState(uint8_t pin) const { + if ((_controlled_mask & pinMask(pin)) == 0) return STATE_RESET; + return digitalRead(pin) == HIGH ? STATE_ON : STATE_OFF; +} + +UserGpio::State UserGpio::timerFinalState(uint8_t pin) const { + const uint64_t mask = pinMask(pin); + if (_timer_final_reset_mask & mask) return STATE_RESET; + if (_timer_final_on_mask & mask) return STATE_ON; + return STATE_OFF; +} + +void UserGpio::writeState(uint8_t pin, State state) { + const uint64_t mask = pinMask(pin); + if (state == STATE_RESET) { + pinMode(pin, INPUT); + _controlled_mask &= ~mask; + return; + } + + const uint8_t level = state == STATE_ON ? HIGH : LOW; + // Prime the output latch before changing direction to minimize relay glitches. + digitalWrite(pin, level); + pinMode(pin, OUTPUT); + digitalWrite(pin, level); + _controlled_mask |= mask; +} + +void UserGpio::cancelTimer(uint8_t pin) { + const uint64_t mask = pinMask(pin); + _timer_mask &= ~mask; + _timer_final_on_mask &= ~mask; + _timer_final_reset_mask &= ~mask; + _timer_deadline[pin] = 0; +} + +void UserGpio::scheduleTimer(uint8_t pin, uint32_t seconds, State final_state) { + const uint64_t mask = pinMask(pin); + _timer_deadline[pin] = millis() + seconds * 1000UL; + _timer_mask |= mask; + + if (final_state == STATE_ON) { + _timer_final_on_mask |= mask; + } else { + _timer_final_on_mask &= ~mask; + } + + if (final_state == STATE_RESET) { + _timer_final_reset_mask |= mask; + } else { + _timer_final_reset_mask &= ~mask; + } +} + +void UserGpio::loop() { + if (_timer_mask == 0) return; + + const uint32_t now = millis(); + for (uint8_t pin = 0; pin <= MAX_GPIO_PIN; pin++) { + const uint64_t mask = pinMask(pin); + if ((_timer_mask & mask) == 0) continue; + if ((int32_t)(now - _timer_deadline[pin]) < 0) continue; + + const State final_state = timerFinalState(pin); + cancelTimer(pin); + writeState(pin, final_state); + } +} + +void UserGpio::handleGet(const char* args, char* reply, size_t reply_size) { + loop(); + args = skipSpaces(args); + + if (*args == '\0') { + size_t used = (size_t)snprintf(reply, reply_size, "> available GPIOs:"); + bool any = false; + for (uint8_t pin = 0; pin <= MAX_GPIO_PIN; pin++) { + if (!_board->isUserGpioAvailable(pin)) continue; + if (used >= reply_size) break; + const int written = snprintf(reply + used, reply_size - used, "%s%u", any ? "," : " ", pin); + if (written < 0) break; + used += (size_t)written; + any = true; + } + if (!any) snprintf(reply, reply_size, "> available GPIOs: none"); + return; + } + + uint32_t parsed_pin; + if (!parseUnsigned(args, parsed_pin) || !atEnd(args)) { + snprintf(reply, reply_size, "Error: use get gpio [pin]"); + return; + } + if (!isAvailable(parsed_pin)) { + snprintf(reply, reply_size, "Error: GPIO %lu is unavailable", (unsigned long)parsed_pin); + return; + } + + const uint8_t pin = (uint8_t)parsed_pin; + const State current = currentState(pin); + const uint64_t mask = pinMask(pin); + if ((_timer_mask & mask) == 0) { + snprintf(reply, reply_size, "> GPIO %u %s", pin, stateName(current)); + return; + } + + const uint32_t remaining_ms = _timer_deadline[pin] - millis(); + const uint32_t remaining_seconds = (remaining_ms + 999UL) / 1000UL; + snprintf(reply, reply_size, "> GPIO %u %s, %lus -> %s", pin, stateName(current), + (unsigned long)remaining_seconds, stateName(timerFinalState(pin))); +} + +void UserGpio::handleSet(const char* args, char* reply, size_t reply_size) { + loop(); + + uint32_t parsed_pin; + State initial_state; + if (!parseUnsigned(args, parsed_pin) || !parseState(args, initial_state)) { + snprintf(reply, reply_size, "Error: use set gpio on|off|reset [seconds on|off|reset]"); + return; + } + if (!isAvailable(parsed_pin)) { + snprintf(reply, reply_size, "Error: GPIO %lu is unavailable", (unsigned long)parsed_pin); + return; + } + + const uint8_t pin = (uint8_t)parsed_pin; + args = skipSpaces(args); + if (*args == '\0') { + cancelTimer(pin); + writeState(pin, initial_state); + snprintf(reply, reply_size, "OK - GPIO %u %s", pin, stateName(initial_state)); + return; + } + + uint32_t seconds; + State final_state; + if (initial_state == STATE_RESET || !parseUnsigned(args, seconds) || + !parseState(args, final_state) || !atEnd(args)) { + snprintf(reply, reply_size, "Error: use set gpio on|off [seconds on|off|reset]"); + return; + } + if (seconds == 0 || seconds > MAX_TIMER_SECONDS) { + snprintf(reply, reply_size, "Error: timer must be 1-%lu seconds", (unsigned long)MAX_TIMER_SECONDS); + return; + } + + cancelTimer(pin); + writeState(pin, initial_state); + scheduleTimer(pin, seconds, final_state); + snprintf(reply, reply_size, "OK - GPIO %u %s for %lus, then %s", pin, + stateName(initial_state), (unsigned long)seconds, stateName(final_state)); +} diff --git a/src/helpers/UserGpio.h b/src/helpers/UserGpio.h new file mode 100644 index 00000000..15440792 --- /dev/null +++ b/src/helpers/UserGpio.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +#include + +class UserGpio { +public: + enum State : uint8_t { + STATE_OFF, + STATE_ON, + STATE_RESET + }; + + explicit UserGpio(mesh::MainBoard& board); + + // args is the text following "get gpio" or "set gpio". + void handleGet(const char* args, char* reply, size_t reply_size); + void handleSet(const char* args, char* reply, size_t reply_size); + + // Applies expired, non-blocking timed transitions. + void loop(); + bool hasActiveTimer() const { return _timer_mask != 0; } + +private: + static const uint8_t MAX_GPIO_PIN = 63; + + mesh::MainBoard* _board; + uint64_t _controlled_mask; + uint64_t _timer_mask; + uint64_t _timer_final_on_mask; + uint64_t _timer_final_reset_mask; + uint32_t _timer_deadline[MAX_GPIO_PIN + 1]; + + static uint64_t pinMask(uint8_t pin) { return UINT64_C(1) << pin; } + static const char* stateName(State state); + + bool isAvailable(uint32_t pin) const; + State currentState(uint8_t pin) const; + State timerFinalState(uint8_t pin) const; + void writeState(uint8_t pin, State state); + void cancelTimer(uint8_t pin); + void scheduleTimer(uint8_t pin, uint32_t seconds, State final_state); +}; diff --git a/src/helpers/UserGpioPinPolicy.h b/src/helpers/UserGpioPinPolicy.h new file mode 100644 index 00000000..7e7df2a3 --- /dev/null +++ b/src/helpers/UserGpioPinPolicy.h @@ -0,0 +1,591 @@ +#pragma once + +#include +#include + +// This header is intentionally included only after target.h. The target's +// build-time pin definitions are the authoritative list of GPIOs claimed by +// the firmware in that particular image. +namespace UserGpioPinPolicy { + +inline bool isFirmwareReserved(uint8_t pin) { + static const int32_t reserved[] = { + -1, + + // LoRa radio, RF switches, FEMs, and activity indicators. +#ifdef P_LORA_DIO_0 + P_LORA_DIO_0, +#endif +#ifdef P_LORA_DIO_1 + P_LORA_DIO_1, +#endif +#ifdef P_LORA_DIO_2 + P_LORA_DIO_2, +#endif +#ifdef P_LORA_NSS + P_LORA_NSS, +#endif +#ifdef P_LORA_RESET + P_LORA_RESET, +#endif +#ifdef P_LORA_BUSY + P_LORA_BUSY, +#endif +#ifdef P_LORA_SCLK + P_LORA_SCLK, +#endif +#ifdef P_LORA_MISO + P_LORA_MISO, +#endif +#ifdef P_LORA_MOSI + P_LORA_MOSI, +#endif +#ifdef P_LORA_EN + P_LORA_EN, +#endif +#ifdef P_LORA_PA_POWER + P_LORA_PA_POWER, +#endif +#ifdef P_LORA_TX_LED + P_LORA_TX_LED, +#endif +#ifdef P_LORA_TX_NEOPIXEL_LED + P_LORA_TX_NEOPIXEL_LED, +#endif +#ifdef P_LORA_GC1109_PA_EN + P_LORA_GC1109_PA_EN, +#endif +#ifdef P_LORA_GC1109_PA_TX_EN + P_LORA_GC1109_PA_TX_EN, +#endif +#ifdef P_LORA_KCT8103L_PA_CSD + P_LORA_KCT8103L_PA_CSD, +#endif +#ifdef P_LORA_KCT8103L_PA_CTX + P_LORA_KCT8103L_PA_CTX, +#endif +#ifdef SX126X_POWER_EN + SX126X_POWER_EN, +#endif +#ifdef SX126X_RXEN + SX126X_RXEN, +#endif +#ifdef SX126X_TXEN + SX126X_TXEN, +#endif +#ifdef SX127X_RXEN + SX127X_RXEN, +#endif +#ifdef SX127X_TXEN + SX127X_TXEN, +#endif +#ifdef LORA_TX_BOOST_PIN + LORA_TX_BOOST_PIN, +#endif +#ifdef LORA_KCT8103L_EN + LORA_KCT8103L_EN, +#endif +#ifdef LORA_KCT8103L_TX_RX + LORA_KCT8103L_TX_RX, +#endif +#ifdef P_PA1_EN + P_PA1_EN, +#endif +#ifdef P_PRIMARY_LNA_EN + P_PRIMARY_LNA_EN, +#endif +#ifdef LR1110_BUSY_PIN + LR1110_BUSY_PIN, +#endif +#ifdef LR1110_GNSS_ANT_PIN + LR1110_GNSS_ANT_PIN, +#endif +#ifdef LR1110_IRQ_PIN + LR1110_IRQ_PIN, +#endif +#ifdef LR1110_NRESET_PIN + LR1110_NRESET_PIN, +#endif +#ifdef LR1110_SPI_MISO_PIN + LR1110_SPI_MISO_PIN, +#endif +#ifdef LR1110_SPI_MOSI_PIN + LR1110_SPI_MOSI_PIN, +#endif +#ifdef LR1110_SPI_NSS_PIN + LR1110_SPI_NSS_PIN, +#endif +#ifdef LR1110_SPI_SCK_PIN + LR1110_SPI_SCK_PIN, +#endif + + // I2C buses actively selected by the board and sensor firmware. +#ifdef PIN_BOARD_SDA + PIN_BOARD_SDA, +#endif +#ifdef PIN_BOARD_SCL + PIN_BOARD_SCL, +#endif +#ifdef PIN_BOARD_SDA1 + PIN_BOARD_SDA1, +#endif +#ifdef PIN_BOARD_SCL1 + PIN_BOARD_SCL1, +#endif +#ifdef PIN_WIRE_SDA + PIN_WIRE_SDA, +#endif +#ifdef PIN_WIRE_SCL + PIN_WIRE_SCL, +#endif +#ifdef ENV_PIN_SDA + ENV_PIN_SDA, +#endif +#ifdef ENV_PIN_SCL + ENV_PIN_SCL, +#endif +#ifdef I2C_SDA + I2C_SDA, +#endif +#ifdef I2C_SCL + I2C_SCL, +#endif +#ifdef I2C_SDA1 + I2C_SDA1, +#endif +#ifdef I2C_SCL1 + I2C_SCL1, +#endif +#ifdef RTC_SDA + RTC_SDA, +#endif +#ifdef RTC_SCL + RTC_SCL, +#endif +#ifdef BQ4050_SDA_PIN + BQ4050_SDA_PIN, +#endif +#ifdef BQ4050_SCL_PIN + BQ4050_SCL_PIN, +#endif + + // GPS and UART bridge connections. +#ifdef PIN_GPS_RX + PIN_GPS_RX, +#endif +#ifdef PIN_GPS_TX + PIN_GPS_TX, +#endif +#ifdef PIN_GPS_EN + PIN_GPS_EN, +#endif +#ifdef PIN_GPS_RESET + PIN_GPS_RESET, +#endif +#ifdef PIN_GPS_PPS + PIN_GPS_PPS, +#endif +#ifdef PIN_GPS_1PPS + PIN_GPS_1PPS, +#endif +#ifdef PIN_GPS_POWER + PIN_GPS_POWER, +#endif +#ifdef PIN_GPS_STANDBY + PIN_GPS_STANDBY, +#endif +#ifdef PIN_GPS_SWITCH + PIN_GPS_SWITCH, +#endif +#ifdef GPS_RX + GPS_RX, +#endif +#ifdef GPS_TX + GPS_TX, +#endif +#ifdef GPS_EN + GPS_EN, +#endif +#ifdef GPS_RESET + GPS_RESET, +#endif +#ifdef GPS_PPS + GPS_PPS, +#endif +#ifdef GPS_RX_PIN + GPS_RX_PIN, +#endif +#ifdef GPS_TX_PIN + GPS_TX_PIN, +#endif +#ifdef GPS_UART_RX + GPS_UART_RX, +#endif +#ifdef GPS_UART_TX + GPS_UART_TX, +#endif +#ifdef GPS_RTC_INT + GPS_RTC_INT, +#endif +#ifdef GPS_SLEEP_INT + GPS_SLEEP_INT, +#endif +#ifdef GPS_VRTC_EN + GPS_VRTC_EN, +#endif +#ifdef WITH_RS232_BRIDGE_RX + WITH_RS232_BRIDGE_RX, +#endif +#ifdef WITH_RS232_BRIDGE_TX + WITH_RS232_BRIDGE_TX, +#endif +#ifdef PIN_SERIAL_RX + PIN_SERIAL_RX, +#endif +#ifdef PIN_SERIAL_TX + PIN_SERIAL_TX, +#endif +#ifdef SERIAL_RX + SERIAL_RX, +#endif +#ifdef SERIAL_TX + SERIAL_TX, +#endif + + // Buttons, LEDs, displays, touch, buzzers, and vibration motors. +#ifdef PIN_USER_BTN + PIN_USER_BTN, +#endif +#ifdef PIN_USER_BTN_ANA + PIN_USER_BTN_ANA, +#endif +#ifdef PIN_BUTTON1 + PIN_BUTTON1, +#endif +#ifdef PIN_BUTTON2 + PIN_BUTTON2, +#endif +#ifdef PIN_BUTTON3 + PIN_BUTTON3, +#endif +#ifdef PIN_BUTTON4 + PIN_BUTTON4, +#endif +#ifdef PIN_BUTTON5 + PIN_BUTTON5, +#endif +#ifdef PIN_BUTTON6 + PIN_BUTTON6, +#endif +#ifdef BUTTON_PIN + BUTTON_PIN, +#endif +#ifdef BUTTON_PIN2 + BUTTON_PIN2, +#endif +#ifdef PIN_BACK_BTN + PIN_BACK_BTN, +#endif +#ifdef PIN_SIDE_BUTTON + PIN_SIDE_BUTTON, +#endif +#ifdef PIN_PWRBTN + PIN_PWRBTN, +#endif +#ifdef PIN_STATUS_LED + PIN_STATUS_LED, +#endif +#ifdef PIN_LED + PIN_LED, +#endif +#ifdef PIN_LED1 + PIN_LED1, +#endif +#ifdef PIN_LED2 + PIN_LED2, +#endif +#ifdef PIN_LED3 + PIN_LED3, +#endif +#ifdef PIN_LED4 + PIN_LED4, +#endif +#ifdef LED_BUILTIN + LED_BUILTIN, +#endif +#ifdef LED_PIN + LED_PIN, +#endif +#ifdef LED_POWER + LED_POWER, +#endif +#ifdef LED_GREEN + LED_GREEN, +#endif +#ifdef LED_BLUE + LED_BLUE, +#endif +#ifdef LED_RED + LED_RED, +#endif +#ifdef LED_WHITE + LED_WHITE, +#endif +#ifdef NEOPIXEL_DATA + NEOPIXEL_DATA, +#endif +#ifdef PIN_NEOPIXEL + PIN_NEOPIXEL, +#endif +#ifdef WS2812_PIN + WS2812_PIN, +#endif +#ifdef PIN_BUZZER + PIN_BUZZER, +#endif +#ifdef PIN_BUZZER_EN + PIN_BUZZER_EN, +#endif +#ifdef BUZZER_PIN + BUZZER_PIN, +#endif +#ifdef BUZZER_EN + BUZZER_EN, +#endif +#ifdef PIN_VIBRATION + PIN_VIBRATION, +#endif +#ifdef PIN_OLED_RESET + PIN_OLED_RESET, +#endif +#ifdef PIN_TFT_CS + PIN_TFT_CS, +#endif +#ifdef PIN_TFT_DC + PIN_TFT_DC, +#endif +#ifdef PIN_TFT_RST + PIN_TFT_RST, +#endif +#ifdef PIN_TFT_SCL + PIN_TFT_SCL, +#endif +#ifdef PIN_TFT_SDA + PIN_TFT_SDA, +#endif +#ifdef PIN_TFT_MISO + PIN_TFT_MISO, +#endif +#ifdef PIN_TFT_LEDA_CTL + PIN_TFT_LEDA_CTL, +#endif +#ifdef PIN_TFT_VDD_CTL + PIN_TFT_VDD_CTL, +#endif +#ifdef PIN_TFT_BL + PIN_TFT_BL, +#endif +#ifdef PIN_TFT_EN + PIN_TFT_EN, +#endif +#ifdef PIN_TOUCH_RST + PIN_TOUCH_RST, +#endif +#ifdef DISP_BUSY + DISP_BUSY, +#endif +#ifdef DISP_CS + DISP_CS, +#endif +#ifdef DISP_DC + DISP_DC, +#endif +#ifdef DISP_MISO + DISP_MISO, +#endif +#ifdef DISP_MOSI + DISP_MOSI, +#endif +#ifdef DISP_POWER + DISP_POWER, +#endif +#ifdef DISP_RST + DISP_RST, +#endif +#ifdef DISP_SCLK + DISP_SCLK, +#endif +#ifdef DISP_BACKLIGHT + DISP_BACKLIGHT, +#endif +#ifdef PIN_DISPLAY_BUSY + PIN_DISPLAY_BUSY, +#endif +#ifdef PIN_DISPLAY_CS + PIN_DISPLAY_CS, +#endif +#ifdef PIN_DISPLAY_DC + PIN_DISPLAY_DC, +#endif +#ifdef PIN_DISPLAY_MISO + PIN_DISPLAY_MISO, +#endif +#ifdef PIN_DISPLAY_MOSI + PIN_DISPLAY_MOSI, +#endif +#ifdef PIN_DISPLAY_RST + PIN_DISPLAY_RST, +#endif +#ifdef PIN_DISPLAY_SCLK + PIN_DISPLAY_SCLK, +#endif + + // Power rails, battery measurement, storage, sensors, and watchdogs. +#ifdef PIN_VEXT_EN + PIN_VEXT_EN, +#endif +#ifdef PIN_3V3_EN + PIN_3V3_EN, +#endif +#ifdef PIN_3V3_ACC_EN + PIN_3V3_ACC_EN, +#endif +#ifdef PIN_EXT_VCC + PIN_EXT_VCC, +#endif +#ifdef VEXT_ENABLE + VEXT_ENABLE, +#endif +#ifdef PIN_PERF_POWERON + PIN_PERF_POWERON, +#endif +#ifdef PIN_PWR_EN + PIN_PWR_EN, +#endif +#ifdef PIN_BAT_CTL + PIN_BAT_CTL, +#endif +#ifdef PIN_BAT_CTRL + PIN_BAT_CTRL, +#endif +#ifdef PIN_BAT_CHG + PIN_BAT_CHG, +#endif +#ifdef PIN_VBAT_READ + PIN_VBAT_READ, +#endif +#ifdef PIN_VBAT_MEAS_EN + PIN_VBAT_MEAS_EN, +#endif +#ifdef PIN_ADC_CTRL + PIN_ADC_CTRL, +#endif +#ifdef BATTERY_PIN + BATTERY_PIN, +#endif +#ifdef BATTERY_ADC_DATA + BATTERY_ADC_DATA, +#endif +#ifdef BAT_POWER + BAT_POWER, +#endif +#ifdef EEPROM_POWER + EEPROM_POWER, +#endif +#ifdef SDCARD_CS + SDCARD_CS, +#endif +#ifdef SENSOR_POWER_CTRL_PIN + SENSOR_POWER_CTRL_PIN, +#endif +#ifdef SENSOR_POWER_PIN + SENSOR_POWER_PIN, +#endif +#ifdef SENSOR_RST_PIN + SENSOR_RST_PIN, +#endif +#ifdef SENSOR_INT_PIN + SENSOR_INT_PIN, +#endif +#ifdef PIN_SENSOR_EN + PIN_SENSOR_EN, +#endif +#ifdef PIN_LSM6DS3TR_C_INT1 + PIN_LSM6DS3TR_C_INT1, +#endif +#ifdef PIN_LSM6DS3TR_C_POWER + PIN_LSM6DS3TR_C_POWER, +#endif +#ifdef LIS3DH_INT_PIN_1 + LIS3DH_INT_PIN_1, +#endif +#ifdef LIS3DH_INT_PIN_2 + LIS3DH_INT_PIN_2, +#endif +#ifdef QMA_6100P_INT_PIN + QMA_6100P_INT_PIN, +#endif +#ifdef EXTERNAL_WATCHDOG_DONE_PIN + EXTERNAL_WATCHDOG_DONE_PIN, +#endif +#ifdef EXTERNAL_WATCHDOG_WAKE_PIN + EXTERNAL_WATCHDOG_WAKE_PIN, +#endif +#ifdef PIN_PMU_IRQ + PIN_PMU_IRQ, +#endif +#ifdef IO_EXPANDER_IRQ + IO_EXPANDER_IRQ, +#endif +#ifdef BQ4050_EMERGENCY_SHUTDOWN_PIN + BQ4050_EMERGENCY_SHUTDOWN_PIN, +#endif + + // Ethernet and board-specific direct I/O. +#ifdef ETH_CS_PIN + ETH_CS_PIN, +#endif +#ifdef ETH_INT_PIN + ETH_INT_PIN, +#endif +#ifdef ETH_MISO_PIN + ETH_MISO_PIN, +#endif +#ifdef ETH_MOSI_PIN + ETH_MOSI_PIN, +#endif +#ifdef ETH_SCLK_PIN + ETH_SCLK_PIN, +#endif +#ifdef PIN_ETHERNET_RESET + PIN_ETHERNET_RESET, +#endif +#ifdef PIN_BOARD_DIGITAL_IN + PIN_BOARD_DIGITAL_IN, +#endif +#ifdef PIN_BOARD_RELAY_CH1 + PIN_BOARD_RELAY_CH1, +#endif +#ifdef PIN_BOARD_RELAY_CH2 + PIN_BOARD_RELAY_CH2, +#endif +#ifdef FAN_CTRL_PIN + FAN_CTRL_PIN, +#endif +#ifdef RF_PA_DETECT_PIN + RF_PA_DETECT_PIN, +#endif + + // Escape hatch for pins used numerically in a target implementation. +#ifdef USER_GPIO_RESERVED_PINS + USER_GPIO_RESERVED_PINS, +#endif + }; + + for (size_t i = 0; i < sizeof(reserved) / sizeof(reserved[0]); i++) { + if (reserved[i] >= 0 && reserved[i] <= 63 && pin == (uint8_t)reserved[i]) return true; + } + return false; +} + +} // namespace UserGpioPinPolicy diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index 01864548..28affb96 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -7,6 +7,14 @@ inline uint32_t g_mock_millis = 0; +constexpr uint8_t LOW = 0; +constexpr uint8_t HIGH = 1; +constexpr uint8_t INPUT = 0; +constexpr uint8_t OUTPUT = 1; + +inline uint8_t g_mock_pin_modes[64] = {}; +inline uint8_t g_mock_pin_levels[64] = {}; + using std::isnan; inline uint32_t millis() { @@ -25,3 +33,23 @@ inline char* ltoa(long value, char* dest, int base) { } return dest; } + +inline void pinMode(uint8_t pin, uint8_t mode) { + if (pin < 64) g_mock_pin_modes[pin] = mode; +} + +inline void digitalWrite(uint8_t pin, uint8_t level) { + if (pin < 64) g_mock_pin_levels[pin] = level; +} + +inline int digitalRead(uint8_t pin) { + return pin < 64 ? g_mock_pin_levels[pin] : LOW; +} + +inline void resetArduinoMock() { + g_mock_millis = 0; + for (uint8_t pin = 0; pin < 64; pin++) { + g_mock_pin_modes[pin] = INPUT; + g_mock_pin_levels[pin] = LOW; + } +} diff --git a/test/test_user_gpio/test_user_gpio.cpp b/test/test_user_gpio/test_user_gpio.cpp new file mode 100644 index 00000000..e88dea18 --- /dev/null +++ b/test/test_user_gpio/test_user_gpio.cpp @@ -0,0 +1,166 @@ +#include + +#include +#include + +#include +#include + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} + +class UserGpioTestBoard : public mesh::MainBoard { +public: + bool available[64] = {}; + + uint16_t getBattMilliVolts() override { return 0; } + const char* getManufacturerName() const override { return "test"; } + void reboot() override {} + uint8_t getStartupReason() const override { return BD_STARTUP_NORMAL; } + bool isUserGpioAvailable(uint8_t pin) const override { + return pin < 64 && available[pin]; + } +}; + +class UserGpioTest : public ::testing::Test { +protected: + UserGpioTestBoard board; + char reply[160]; + + void SetUp() override { + resetArduinoMock(); + memset(reply, 0, sizeof(reply)); + board.available[4] = true; + board.available[16] = true; + } +}; + +TEST_F(UserGpioTest, ListsOnlyBoardApprovedPins) { + UserGpio gpio(board); + + gpio.handleGet("", reply, sizeof(reply)); + + EXPECT_STREQ("> available GPIOs: 4,16", reply); +} + +TEST_F(UserGpioTest, ReportsWhenNoPinsAreAvailable) { + UserGpioTestBoard empty_board; + UserGpio gpio(empty_board); + + gpio.handleGet("", reply, sizeof(reply)); + + EXPECT_STREQ("> available GPIOs: none", reply); +} + +TEST_F(UserGpioTest, SetsAndGetsOnOffAndReset) { + UserGpio gpio(board); + + gpio.handleSet(" 16 ON", reply, sizeof(reply)); + EXPECT_STREQ("OK - GPIO 16 on", reply); + EXPECT_EQ(OUTPUT, g_mock_pin_modes[16]); + EXPECT_EQ(HIGH, g_mock_pin_levels[16]); + + gpio.handleGet(" 16", reply, sizeof(reply)); + EXPECT_STREQ("> GPIO 16 on", reply); + + gpio.handleSet(" 16 off", reply, sizeof(reply)); + EXPECT_EQ(OUTPUT, g_mock_pin_modes[16]); + EXPECT_EQ(LOW, g_mock_pin_levels[16]); + + gpio.handleSet(" 16 reset", reply, sizeof(reply)); + EXPECT_STREQ("OK - GPIO 16 reset", reply); + EXPECT_EQ(INPUT, g_mock_pin_modes[16]); + gpio.handleGet(" 16", reply, sizeof(reply)); + EXPECT_STREQ("> GPIO 16 reset", reply); +} + +TEST_F(UserGpioTest, AppliesTimedTransitionWithoutBlocking) { + UserGpio gpio(board); + + gpio.handleSet(" 16 on 30 off", reply, sizeof(reply)); + EXPECT_STREQ("OK - GPIO 16 on for 30s, then off", reply); + EXPECT_TRUE(gpio.hasActiveTimer()); + + g_mock_millis = 29999; + gpio.loop(); + EXPECT_EQ(HIGH, g_mock_pin_levels[16]); + + gpio.handleGet(" 16", reply, sizeof(reply)); + EXPECT_STREQ("> GPIO 16 on, 1s -> off", reply); + + g_mock_millis = 30000; + gpio.loop(); + EXPECT_EQ(LOW, g_mock_pin_levels[16]); + EXPECT_FALSE(gpio.hasActiveTimer()); +} + +TEST_F(UserGpioTest, ResetCancelsAnExistingTimer) { + UserGpio gpio(board); + + gpio.handleSet(" 16 on 30 off", reply, sizeof(reply)); + gpio.handleSet(" 16 reset", reply, sizeof(reply)); + EXPECT_FALSE(gpio.hasActiveTimer()); + + g_mock_millis = 30000; + gpio.loop(); + EXPECT_EQ(INPUT, g_mock_pin_modes[16]); +} + +TEST_F(UserGpioTest, ANewCommandReplacesAnExistingTimer) { + UserGpio gpio(board); + + gpio.handleSet(" 16 on 30 off", reply, sizeof(reply)); + g_mock_millis = 1000; + gpio.handleSet(" 16 off 2 on", reply, sizeof(reply)); + EXPECT_STREQ("OK - GPIO 16 off for 2s, then on", reply); + + g_mock_millis = 2999; + gpio.loop(); + EXPECT_EQ(LOW, g_mock_pin_levels[16]); + g_mock_millis = 3000; + gpio.loop(); + EXPECT_EQ(HIGH, g_mock_pin_levels[16]); + EXPECT_FALSE(gpio.hasActiveTimer()); +} + +TEST_F(UserGpioTest, TimedFinalStateCanResetThePin) { + UserGpio gpio(board); + + gpio.handleSet(" 4 off 2 reset", reply, sizeof(reply)); + EXPECT_EQ(OUTPUT, g_mock_pin_modes[4]); + g_mock_millis = 2000; + gpio.loop(); + EXPECT_EQ(INPUT, g_mock_pin_modes[4]); + gpio.handleGet(" 4", reply, sizeof(reply)); + EXPECT_STREQ("> GPIO 4 reset", reply); +} + +TEST_F(UserGpioTest, TimersRemainCorrectAcrossMillisWrap) { + g_mock_millis = UINT32_MAX - 499; + UserGpio gpio(board); + + gpio.handleSet(" 4 on 1 off", reply, sizeof(reply)); + g_mock_millis = 499; + gpio.loop(); + EXPECT_EQ(HIGH, g_mock_pin_levels[4]); + + g_mock_millis = 500; + gpio.loop(); + EXPECT_EQ(LOW, g_mock_pin_levels[4]); +} + +TEST_F(UserGpioTest, RejectsUnavailablePinsAndMalformedTimers) { + UserGpio gpio(board); + + gpio.handleSet(" 5 on", reply, sizeof(reply)); + EXPECT_STREQ("Error: GPIO 5 is unavailable", reply); + EXPECT_EQ(INPUT, g_mock_pin_modes[5]); + + gpio.handleSet(" 16 reset 10 on", reply, sizeof(reply)); + EXPECT_STREQ("Error: use set gpio on|off [seconds on|off|reset]", reply); + + gpio.handleSet(" 16 on 0 off", reply, sizeof(reply)); + EXPECT_STREQ("Error: timer must be 1-2147483 seconds", reply); +} diff --git a/variants/heltec_t096/platformio.ini b/variants/heltec_t096/platformio.ini index 1fda9c35..1143f713 100644 --- a/variants/heltec_t096/platformio.ini +++ b/variants/heltec_t096/platformio.ini @@ -163,6 +163,7 @@ build_flags = -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' + -D USER_GPIO_CONTROL=1 -D ENV_PIN_SDA=PIN_WIRE1_SDA -D ENV_PIN_SCL=PIN_WIRE1_SCL ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/lilygo_tdeck/target.h b/variants/lilygo_tdeck/target.h index 9d35af9b..75a73227 100644 --- a/variants/lilygo_tdeck/target.h +++ b/variants/lilygo_tdeck/target.h @@ -1,5 +1,8 @@ #pragma once +// Wire is initialized on these numeric pins in target.cpp. +#define USER_GPIO_RESERVED_PINS 18, 8 + #define RADIOLIB_STATIC_ONLY 1 #include #include diff --git a/variants/promicro/platformio.ini b/variants/promicro/platformio.ini index dba6e291..cb72e913 100644 --- a/variants/promicro/platformio.ini +++ b/variants/promicro/platformio.ini @@ -164,6 +164,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D DISPLAY_CLASS=SSD1306Display + -D USER_GPIO_CONTROL=1 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Promicro.build_src_filter} diff --git a/variants/rak3401/platformio.ini b/variants/rak3401/platformio.ini index cb27ba61..e33a3e41 100644 --- a/variants/rak3401/platformio.ini +++ b/variants/rak3401/platformio.ini @@ -152,6 +152,7 @@ build_flags = -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' + -D USER_GPIO_CONTROL=1 ;-D MESH_PACKET_LOGGING=1 ;-D MESH_DEBUG=1 build_src_filter = ${rak3401.build_src_filter} diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 9c7e5b20..5a1c77d0 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -352,6 +352,7 @@ build_flags = -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' + -D USER_GPIO_CONTROL=1 ; -D MESH_PACKET_LOGGING=1 -D MESH_DEBUG=1 build_src_filter = ${rak4631.build_src_filter} diff --git a/variants/thinknode_m2/target.h b/variants/thinknode_m2/target.h index 37ede89e..411fab26 100644 --- a/variants/thinknode_m2/target.h +++ b/variants/thinknode_m2/target.h @@ -1,5 +1,8 @@ #pragma once +// target.cpp directly configures GPIO48 for the board's status LED. +#define USER_GPIO_RESERVED_PINS 48 + #define RADIOLIB_STATIC_ONLY 1 #include #include @@ -26,4 +29,3 @@ extern SensorManager sensors; bool radio_init(); mesh::LocalIdentity radio_new_identity(); - diff --git a/variants/thinknode_m5/target.h b/variants/thinknode_m5/target.h index a613e390..a0180c20 100644 --- a/variants/thinknode_m5/target.h +++ b/variants/thinknode_m5/target.h @@ -1,5 +1,8 @@ #pragma once +// Wire1 is initialized on these numeric pins in ThinknodeM5Board.cpp. +#define USER_GPIO_RESERVED_PINS 47, 48 + #define RADIOLIB_STATIC_ONLY 1 #include #include @@ -30,4 +33,3 @@ bool radio_init(); mesh::LocalIdentity radio_new_identity(); - \ No newline at end of file diff --git a/variants/xiao_c6/target.h b/variants/xiao_c6/target.h index c4cec063..746cafac 100644 --- a/variants/xiao_c6/target.h +++ b/variants/xiao_c6/target.h @@ -1,5 +1,10 @@ #pragma once +#ifdef USE_XIAO_ESP32C6_EXTERNAL_ANTENNA +// RF switch and antenna select pins configured directly by XiaoC6Board. +#define USER_GPIO_RESERVED_PINS 3, 14 +#endif + #define RADIOLIB_STATIC_ONLY 1 #include #include @@ -16,4 +21,3 @@ extern SensorManager sensors; bool radio_init(); mesh::LocalIdentity radio_new_identity(); - diff --git a/variants/xiao_s3/target.h b/variants/xiao_s3/target.h index 27eddd84..be2e60a7 100644 --- a/variants/xiao_s3/target.h +++ b/variants/xiao_s3/target.h @@ -1,5 +1,8 @@ #pragma once +// target.cpp configures GPIO48 directly during radio initialization. +#define USER_GPIO_RESERVED_PINS 48 + #define RADIOLIB_STATIC_ONLY 1 #include #include