diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 1843517b..4c4e007c 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -892,18 +892,25 @@ get clock.sync.status **Usage:** - `get gpio` — list the Arduino pin numbers this firmware build permits +- `get gpio state`, `get gpio states`, or `get gpio status` — list every available pin currently controlled by the user (anything not in `reset`) +- `get gpio state ` — show one pin's state; `states` and `status` are accepted here too - `get gpio ` — show `on`, `off`, or `reset`, plus any pending timed transition - `set gpio on` - `set gpio off` - `set gpio reset` -- `set gpio ` +- `set gpio ` **Examples:** - `set gpio 16 on 30 off` — drive GPIO16 high for 30 seconds, then drive it low +- `set gpio 16 on 5ms off` — drive GPIO16 high for 5 milliseconds, 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. +An integer duration has seconds as its default unit, so `5` means 5 seconds. Add `ms` for milliseconds (`5ms`); an explicit `s` suffix is also accepted (`5s`). The maximum duration is 24 hours (86,400 seconds or 86,400,000 milliseconds). `on` or `off` without a duration remains in that state until another command, reset, or reboot. + +`reset` changes the pin to an input with no pull resistor (high impedance). It does not reboot the node. It also cancels any pending timer for that pin. A new timed command for the same pin cancels and replaces the previous timer. GPIOs begin in `reset`; states and timers are not saved and are lost on reboot. + +The immediate reply confirms the applied state and any pending transition. When a timed transition finishes, a command issued over the authenticated remote CLI receives a second report such as `> GPIO 16 timer complete: off`. Retries of the same authenticated timed command are recognized and do not restart its countdown. 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. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index db3e8287..60cc3343 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -2588,6 +2588,44 @@ void MyMesh::sendRemoteCliReply(ClientInfo* client, const uint8_t* secret, path_hash_size, fallback_scope); } +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) +void MyMesh::onUserGpioTimerCompleted(uint8_t pin, uint8_t state, + uint32_t request_id) { + int client_index; + uint8_t path_hash_size; + uint8_t client_tag[UserGpioReplyTracker::CLIENT_TAG_SIZE]; + if (!_gpio_reply_tracker.takeRoute(pin, request_id, client_index, + path_hash_size, client_tag)) { + MESH_DEBUG_PRINTLN("GPIO %u timer complete: %s", pin, + UserGpio::stateName((UserGpio::State)state)); + return; + } + ClientInfo* client = NULL; + if (client_index >= 0 && client_index < acl.getNumClients()) { + ClientInfo* indexed = acl.getClientByIdx(client_index); + if (UserGpioReplyTracker::matchesClient(indexed->id.pub_key, client_tag)) { + client = indexed; + } + } + if (client == NULL) { + for (int i = 0; i < acl.getNumClients(); i++) { + ClientInfo* candidate = acl.getClientByIdx(i); + if (UserGpioReplyTracker::matchesClient(candidate->id.pub_key, client_tag)) { + client = candidate; + break; + } + } + } + if (client == NULL) return; + + char reply[64]; + snprintf(reply, sizeof(reply), "> GPIO %u timer complete: %s", pin, + UserGpio::stateName((UserGpio::State)state)); + sendRemoteCliReply(client, client->shared_secret, path_hash_size, request_id, + reply, NULL); +} +#endif + void __attribute__((noinline)) MyMesh::processDeferredCliCommand() { if (!deferred_cli_command.pending) return; @@ -2604,7 +2642,8 @@ void __attribute__((noinline)) MyMesh::processDeferredCliCommand() { char* reply = (char*)&reply_data[5]; reply[0] = 0; handleCommand(deferred_cli_command.sender_timestamp, client, - deferred_cli_command.command, reply); + deferred_cli_command.command, reply, client_index, + deferred_cli_command.path_hash_size); sendRemoteCliReply(client, deferred_cli_command.secret, deferred_cli_command.path_hash_size, deferred_cli_command.sender_timestamp, reply, @@ -8006,7 +8045,13 @@ static bool isFilterMgrAllowed(const char* cmd) { || commandFamilyMatches(cmd, "set repeat"); } -void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *command, char *reply) { +void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *command, + char *reply, int gpio_client_index, + uint8_t gpio_path_hash_size) { +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) + _gpio_reply_tracker.beginCommand(gpio_client_index, gpio_path_hash_size, + sender == NULL ? NULL : sender->id.pub_key); +#endif char* reply_start = reply; int recent_page = 1; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index dcb1a272..9dc32df4 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -63,6 +63,9 @@ #include #include #include +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) +#include +#endif #include #include #include @@ -233,6 +236,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks NodePrefs _prefs; ClientACL acl; CommonCLI _cli; +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) + UserGpioReplyTracker _gpio_reply_tracker; +#endif uint8_t reply_data[MAX_PACKET_PAYLOAD]; uint8_t reply_path[MAX_PATH_SIZE]; int8_t reply_path_len; @@ -713,6 +719,19 @@ public: // CommonCLICallbacks void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) + uint32_t getUserGpioRequestSource() const override { + return _gpio_reply_tracker.requestSource(); + } + void onUserGpioTimerScheduled(uint8_t pin, uint32_t request_id) override { + _gpio_reply_tracker.timerScheduled(pin, request_id); + } + void onUserGpioTimerCancelled(uint8_t pin) override { + _gpio_reply_tracker.timerCancelled(pin); + } + void onUserGpioTimerCompleted(uint8_t pin, uint8_t state, + uint32_t request_id) override; +#endif #ifdef WITH_MQTT_BRIDGE void onAlertConfigChanged() override { _alerter.onConfigChanged(); } @@ -760,7 +779,9 @@ public: void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override; - void handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char* command, char* reply); + void handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char* command, + char* reply, int gpio_client_index = -1, + uint8_t gpio_path_hash_size = 1); void handleCommand(uint32_t sender_timestamp, char* command, char* reply) { handleCommand(sender_timestamp, NULL, command, reply); } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index fa039de7..9ff47596 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -607,7 +607,8 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, if (is_retry) { temp[5] = 0; // no reply } else { - handleCommand(sender_timestamp, (char *)&data[5], (char *)&temp[5]); + handleCommand(sender_timestamp, (char *)&data[5], (char *)&temp[5], + i, packet->getPathHashSize()); temp[4] = (TXT_TYPE_CLI_DATA << 2); // attempt and flags, (NOTE: legacy was: TXT_TYPE_PLAIN) } send_ack = false; @@ -1598,8 +1599,70 @@ void MyMesh::formatPacketStatsReply(char *reply) { getNumRecvFlood(), getNumRecvDirect()); } +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) +void MyMesh::onUserGpioTimerCompleted(uint8_t pin, uint8_t state, + uint32_t request_id) { + int client_index; + uint8_t path_hash_size; + uint8_t client_tag[UserGpioReplyTracker::CLIENT_TAG_SIZE]; + if (!_gpio_reply_tracker.takeRoute(pin, request_id, client_index, + path_hash_size, client_tag)) { + MESH_DEBUG_PRINTLN("GPIO %u timer complete: %s", pin, + UserGpio::stateName((UserGpio::State)state)); + return; + } + ClientInfo* client = NULL; + if (client_index >= 0 && client_index < acl.getNumClients()) { + ClientInfo* indexed = acl.getClientByIdx(client_index); + if (UserGpioReplyTracker::matchesClient(indexed->id.pub_key, client_tag)) { + client = indexed; + } + } + if (client == NULL) { + for (int i = 0; i < acl.getNumClients(); i++) { + ClientInfo* candidate = acl.getClientByIdx(i); + if (UserGpioReplyTracker::matchesClient(candidate->id.pub_key, client_tag)) { + client = candidate; + break; + } + } + } + if (client == NULL) return; -void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { + uint8_t data[72]; + uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); + if (timestamp == request_id) timestamp++; + memcpy(data, ×tamp, 4); + data[4] = (TXT_TYPE_CLI_DATA << 2); + const int text_len = snprintf((char*)&data[5], sizeof(data) - 5, + "> GPIO %u timer complete: %s", pin, + UserGpio::stateName((UserGpio::State)state)); + if (text_len <= 0) return; + + mesh::Packet* packet = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, + client->shared_secret, data, + 5 + (size_t)text_len); + if (!packet) return; + if (client->out_path_len == OUT_PATH_UNKNOWN) { + sendFlood(packet, SERVER_RESPONSE_DELAY, path_hash_size); + } else { + sendDirect(packet, client->out_path, client->out_path_len, + SERVER_RESPONSE_DELAY); + } +} +#endif + + +void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply, + int gpio_client_index, + uint8_t gpio_path_hash_size) { +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) + const uint8_t* gpio_client_key = gpio_client_index >= 0 && + gpio_client_index < acl.getNumClients() + ? acl.getClientByIdx(gpio_client_index)->id.pub_key : NULL; + _gpio_reply_tracker.beginCommand(gpio_client_index, gpio_path_hash_size, + gpio_client_key); +#endif if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation region_load_active = false; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 38cc4a2a..4c34203d 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -19,6 +19,9 @@ #include #include #include +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) +#include +#endif #include #include #include @@ -127,6 +130,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks RegionMap region_map, temp_map; ClientACL acl; CommonCLI _cli; +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) + UserGpioReplyTracker _gpio_reply_tracker; +#endif unsigned long dirty_contacts_expiry; uint8_t reply_data[MAX_PACKET_PAYLOAD]; unsigned long next_push; @@ -299,6 +305,19 @@ public: // CommonCLICallbacks void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) + uint32_t getUserGpioRequestSource() const override { + return _gpio_reply_tracker.requestSource(); + } + void onUserGpioTimerScheduled(uint8_t pin, uint32_t request_id) override { + _gpio_reply_tracker.timerScheduled(pin, request_id); + } + void onUserGpioTimerCancelled(uint8_t pin) override { + _gpio_reply_tracker.timerCancelled(pin); + } + void onUserGpioTimerCompleted(uint8_t pin, uint8_t state, + uint32_t request_id) override; +#endif #ifdef WITH_MQTT_BRIDGE void onAlertConfigChanged() override { _alerter.onConfigChanged(); } @@ -339,7 +358,9 @@ public: void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override; - void handleCommand(uint32_t sender_timestamp, char* command, char* reply); + void handleCommand(uint32_t sender_timestamp, char* command, char* reply, + int gpio_client_index = -1, + uint8_t gpio_path_hash_size = 1); void loop(); #if defined(WITH_BRIDGE) diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 9937e8f2..f352c8e8 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -495,7 +495,69 @@ uint8_t SensorMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* return 13; // reply length } -void SensorMesh::handleCommand(uint32_t sender_timestamp, char* command, char* reply) { +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) +void SensorMesh::onUserGpioTimerCompleted(uint8_t pin, uint8_t state, + uint32_t request_id) { + int client_index; + uint8_t path_hash_size; + uint8_t client_tag[UserGpioReplyTracker::CLIENT_TAG_SIZE]; + if (!_gpio_reply_tracker.takeRoute(pin, request_id, client_index, + path_hash_size, client_tag)) { + MESH_DEBUG_PRINTLN("GPIO %u timer complete: %s", pin, + UserGpio::stateName((UserGpio::State)state)); + return; + } + ClientInfo* client = NULL; + if (client_index >= 0 && client_index < acl.getNumClients()) { + ClientInfo* indexed = acl.getClientByIdx(client_index); + if (UserGpioReplyTracker::matchesClient(indexed->id.pub_key, client_tag)) { + client = indexed; + } + } + if (client == NULL) { + for (int i = 0; i < acl.getNumClients(); i++) { + ClientInfo* candidate = acl.getClientByIdx(i); + if (UserGpioReplyTracker::matchesClient(candidate->id.pub_key, client_tag)) { + client = candidate; + break; + } + } + } + if (client == NULL) return; + + uint8_t data[72]; + uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); + if (timestamp == request_id) timestamp++; + memcpy(data, ×tamp, 4); + data[4] = (TXT_TYPE_CLI_DATA << 2); + const int text_len = snprintf((char*)&data[5], sizeof(data) - 5, + "> GPIO %u timer complete: %s", pin, + UserGpio::stateName((UserGpio::State)state)); + if (text_len <= 0) return; + + mesh::Packet* packet = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, + client->shared_secret, data, + 5 + (size_t)text_len); + if (!packet) return; + if (client->out_path_len == OUT_PATH_UNKNOWN) { + sendFlood(packet, CLI_REPLY_DELAY_MILLIS, path_hash_size); + } else { + sendDirect(packet, client->out_path, client->out_path_len, + CLI_REPLY_DELAY_MILLIS); + } +} +#endif + +void SensorMesh::handleCommand(uint32_t sender_timestamp, char* command, char* reply, + int gpio_client_index, + uint8_t gpio_path_hash_size) { +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) + const uint8_t* gpio_client_key = gpio_client_index >= 0 && + gpio_client_index < acl.getNumClients() + ? acl.getClientByIdx(gpio_client_index)->id.pub_key : NULL; + _gpio_reply_tracker.beginCommand(gpio_client_index, gpio_path_hash_size, + gpio_client_key); +#endif while (*command == ' ') command++; // skip leading spaces if (strlen(command) > 4 && command[2] == '|') { // optional prefix (for companion radio CLI) @@ -707,7 +769,8 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i uint8_t temp[166]; char *command = (char *) &data[5]; char *reply = (char *) &temp[5]; - handleCommand(sender_timestamp, command, reply); + handleCommand(sender_timestamp, command, reply, i, + packet->getPathHashSize()); int text_len = strlen(reply); if (text_len > 0) { diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 295a7193..2b5153fc 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -20,6 +20,9 @@ #include #include #include +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) +#include +#endif #include #include #include @@ -51,7 +54,9 @@ public: SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); void begin(FILESYSTEM* fs); void loop(); - void handleCommand(uint32_t sender_timestamp, char* command, char* reply); + void handleCommand(uint32_t sender_timestamp, char* command, char* reply, + int gpio_client_index = -1, + uint8_t gpio_path_hash_size = 1); // CommonCLI callbacks const char* getFirmwareVer() override { return FIRMWARE_VERSION; } @@ -81,6 +86,19 @@ public: void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override { } void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) + uint32_t getUserGpioRequestSource() const override { + return _gpio_reply_tracker.requestSource(); + } + void onUserGpioTimerScheduled(uint8_t pin, uint32_t request_id) override { + _gpio_reply_tracker.timerScheduled(pin, request_id); + } + void onUserGpioTimerCancelled(uint8_t pin) override { + _gpio_reply_tracker.timerCancelled(pin); + } + void onUserGpioTimerCompleted(uint8_t pin, uint8_t state, + uint32_t request_id) override; +#endif float getTelemValue(uint8_t channel, uint8_t type); @@ -159,6 +177,9 @@ private: NodePrefs _prefs; ClientACL acl; CommonCLI _cli; +#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) + UserGpioReplyTracker _gpio_reply_tracker; +#endif uint8_t reply_data[MAX_PACKET_PAYLOAD]; unsigned long dirty_contacts_expiry; CayenneLPP telemetry; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index d2a21b5f..36796562 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -145,6 +145,11 @@ static bool isGpioConfig(const char* config) { void CommonCLI::loop() { #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) _user_gpio.loop(); + UserGpio::Completion completion; + while (_user_gpio.takeCompletion(completion)) { + _callbacks->onUserGpioTimerCompleted(completion.pin, (uint8_t)completion.state, + completion.request_id); + } #endif } @@ -3026,7 +3031,15 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep const char* config = &command[4]; if (isGpioConfig(config)) { #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) - _user_gpio.handleSet(config + 4, reply, 160); + const UserGpio::SetResult result = _user_gpio.handleSet( + config + 4, reply, 160, sender_timestamp, + _callbacks->getUserGpioRequestSource()); + if (result.outcome == UserGpio::SetResult::TIMER_STARTED) { + _callbacks->onUserGpioTimerScheduled(result.pin, sender_timestamp); + } else if (result.outcome == UserGpio::SetResult::APPLIED && + result.cancelled_timer) { + _callbacks->onUserGpioTimerCancelled(result.pin); + } #else strcpy(reply, "Error: GPIO control unsupported on this build"); #endif diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 46d14822..98b1f9f4 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -261,6 +261,14 @@ public: // derive time from untrusted radio traffic can use this to prefer the manual // value for the remainder of the boot. virtual void onManualClockSet() { } + // GPIO timers use the active authenticated CLI client as a deduplication + // source and to route the asynchronous final-transition report. Local CLI + // commands keep the default source of zero and do not get a network report. + virtual uint32_t getUserGpioRequestSource() const { return 0; } + virtual void onUserGpioTimerScheduled(uint8_t /*pin*/, uint32_t /*request_id*/) { } + virtual void onUserGpioTimerCancelled(uint8_t /*pin*/) { } + virtual void onUserGpioTimerCompleted(uint8_t /*pin*/, uint8_t /*state*/, + uint32_t /*request_id*/) { } virtual const char* getFirmwareVer() = 0; virtual const char* getBuildDate() = 0; virtual const char* getRole() = 0; diff --git a/src/helpers/UserGpio.cpp b/src/helpers/UserGpio.cpp index 4c5c1d19..623519f2 100644 --- a/src/helpers/UserGpio.cpp +++ b/src/helpers/UserGpio.cpp @@ -7,9 +7,7 @@ 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 uint32_t MAX_TIMER_MILLIS = 24UL * 60UL * 60UL * 1000UL; const char* skipSpaces(const char* text) { while (*text == ' ') text++; @@ -38,6 +36,39 @@ bool parseUnsigned(const char*& cursor, uint32_t& value) { return true; } +bool parseDuration(const char*& cursor, uint32_t& duration_ms, + bool& display_milliseconds) { + 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'); + + display_milliseconds = false; + if ((p[0] == 'm' || p[0] == 'M') && (p[1] == 's' || p[1] == 'S')) { + display_milliseconds = true; + p += 2; + } else if (*p == 's' || *p == 'S') { + p++; + } + + if (*p != '\0' && *p != ' ') return false; + if (display_milliseconds) { + duration_ms = parsed; + } else { + if (parsed > UINT32_MAX / 1000UL) return false; + duration_ms = parsed * 1000UL; + } + + cursor = p; + return true; +} + bool tokenEquals(const char* start, size_t length, const char* expected) { size_t i = 0; for (; i < length && expected[i] != '\0'; i++) { @@ -73,8 +104,16 @@ UserGpio::UserGpio(mesh::MainBoard& board) _controlled_mask(0), _timer_mask(0), _timer_final_on_mask(0), - _timer_final_reset_mask(0) { + _timer_final_reset_mask(0), + _timer_millisecond_mask(0), + _completion_mask(0), + _completion_on_mask(0), + _completion_reset_mask(0), + _next_recent_request(0) { memset(_timer_deadline, 0, sizeof(_timer_deadline)); + memset(_timer_request_id, 0, sizeof(_timer_request_id)); + memset(_completion_request_id, 0, sizeof(_completion_request_id)); + memset(_recent_requests, 0, sizeof(_recent_requests)); } const char* UserGpio::stateName(State state) { @@ -122,14 +161,25 @@ void UserGpio::cancelTimer(uint8_t pin) { _timer_mask &= ~mask; _timer_final_on_mask &= ~mask; _timer_final_reset_mask &= ~mask; + _timer_millisecond_mask &= ~mask; _timer_deadline[pin] = 0; + _timer_request_id[pin] = 0; } -void UserGpio::scheduleTimer(uint8_t pin, uint32_t seconds, State final_state) { +void UserGpio::scheduleTimer(uint8_t pin, uint32_t duration_ms, + bool display_milliseconds, State final_state, + uint32_t request_id) { const uint64_t mask = pinMask(pin); - _timer_deadline[pin] = millis() + seconds * 1000UL; + _timer_deadline[pin] = millis() + duration_ms; + _timer_request_id[pin] = request_id; _timer_mask |= mask; + if (display_milliseconds) { + _timer_millisecond_mask |= mask; + } else { + _timer_millisecond_mask &= ~mask; + } + if (final_state == STATE_ON) { _timer_final_on_mask |= mask; } else { @@ -143,6 +193,34 @@ void UserGpio::scheduleTimer(uint8_t pin, uint32_t seconds, State final_state) { } } +bool UserGpio::isDuplicateTimedRequest(uint8_t pin, uint32_t request_id, + uint32_t request_source, + uint32_t command_spec) const { + if (request_id == 0 || request_source == 0) return false; + for (uint8_t i = 0; i < RECENT_REQUEST_COUNT; i++) { + const RecentTimedRequest& recent = _recent_requests[i]; + if (recent.valid && recent.pin == pin && recent.request_id == request_id && + recent.request_source == request_source && + recent.command_spec == command_spec) { + return true; + } + } + return false; +} + +void UserGpio::rememberTimedRequest(uint8_t pin, uint32_t request_id, + uint32_t request_source, + uint32_t command_spec) { + if (request_id == 0 || request_source == 0) return; + RecentTimedRequest& recent = _recent_requests[_next_recent_request]; + recent.request_id = request_id; + recent.request_source = request_source; + recent.command_spec = command_spec; + recent.pin = pin; + recent.valid = true; + _next_recent_request = (uint8_t)((_next_recent_request + 1U) % RECENT_REQUEST_COUNT); +} + void UserGpio::loop() { if (_timer_mask == 0) return; @@ -153,33 +231,170 @@ void UserGpio::loop() { if ((int32_t)(now - _timer_deadline[pin]) < 0) continue; const State final_state = timerFinalState(pin); + const uint32_t request_id = _timer_request_id[pin]; cancelTimer(pin); writeState(pin, final_state); + + _completion_request_id[pin] = request_id; + _completion_mask |= mask; + if (final_state == STATE_ON) { + _completion_on_mask |= mask; + } else { + _completion_on_mask &= ~mask; + } + if (final_state == STATE_RESET) { + _completion_reset_mask |= mask; + } else { + _completion_reset_mask &= ~mask; + } } } +bool UserGpio::takeCompletion(Completion& completion) { + if (_completion_mask == 0) return false; + + for (uint8_t pin = 0; pin <= MAX_GPIO_PIN; pin++) { + const uint64_t mask = pinMask(pin); + if ((_completion_mask & mask) == 0) continue; + + completion.pin = pin; + completion.request_id = _completion_request_id[pin]; + if (_completion_reset_mask & mask) { + completion.state = STATE_RESET; + } else if (_completion_on_mask & mask) { + completion.state = STATE_ON; + } else { + completion.state = STATE_OFF; + } + + _completion_mask &= ~mask; + _completion_on_mask &= ~mask; + _completion_reset_mask &= ~mask; + _completion_request_id[pin] = 0; + return true; + } + return false; +} + +void UserGpio::formatPinStatus(uint8_t pin, const char* prefix, char* reply, + size_t reply_size) const { + const State current = currentState(pin); + const uint64_t mask = pinMask(pin); + if ((_timer_mask & mask) == 0) { + snprintf(reply, reply_size, "%sGPIO %u %s", prefix, pin, stateName(current)); + return; + } + + const int32_t remaining_delta = (int32_t)(_timer_deadline[pin] - millis()); + const uint32_t remaining_ms = remaining_delta > 0 + ? (uint32_t)remaining_delta : 0; + if (_timer_millisecond_mask & mask) { + snprintf(reply, reply_size, "%sGPIO %u %s, %lums -> %s", prefix, pin, + stateName(current), (unsigned long)remaining_ms, + stateName(timerFinalState(pin))); + } else { + const uint32_t remaining_seconds = (remaining_ms + 999UL) / 1000UL; + snprintf(reply, reply_size, "%sGPIO %u %s, %lus -> %s", prefix, pin, + stateName(current), (unsigned long)remaining_seconds, + stateName(timerFinalState(pin))); + } +} + +void UserGpio::formatAvailablePins(char* reply, size_t reply_size) const { + 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"); +} + +void UserGpio::formatActivePins(char* reply, size_t reply_size) const { + size_t used = (size_t)snprintf(reply, reply_size, "> active GPIOs:"); + bool any = false; + for (uint8_t pin = 0; pin <= MAX_GPIO_PIN; pin++) { + const uint64_t mask = pinMask(pin); + if ((_controlled_mask & mask) == 0 || !_board->isUserGpioAvailable(pin)) continue; + if (used >= reply_size) break; + const State current = currentState(pin); + int written; + if ((_timer_mask & mask) == 0) { + written = snprintf(reply + used, reply_size - used, "%s%u=%s", + any ? "," : " ", pin, stateName(current)); + } else { + const int32_t remaining_delta = (int32_t)(_timer_deadline[pin] - millis()); + const uint32_t remaining_ms = remaining_delta > 0 + ? (uint32_t)remaining_delta : 0; + if (_timer_millisecond_mask & mask) { + written = snprintf(reply + used, reply_size - used, "%s%u=%s(%lums->%s)", + any ? "," : " ", pin, stateName(current), + (unsigned long)remaining_ms, + stateName(timerFinalState(pin))); + } else { + const uint32_t remaining_seconds = (remaining_ms + 999UL) / 1000UL; + written = snprintf(reply + used, reply_size - used, "%s%u=%s(%lus->%s)", + any ? "," : " ", pin, stateName(current), + (unsigned long)remaining_seconds, + stateName(timerFinalState(pin))); + } + } + if (written < 0) break; + used += (size_t)written; + any = true; + } + if (!any) snprintf(reply, reply_size, "> active GPIOs: none"); +} + 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; + formatAvailablePins(reply, reply_size); + return; + } + + const char* token_end = args; + while (*token_end != '\0' && *token_end != ' ') token_end++; + if (tokenEquals(args, (size_t)(token_end - args), "available") && atEnd(token_end)) { + formatAvailablePins(reply, reply_size); + return; + } + + const size_t token_length = (size_t)(token_end - args); + const bool is_state_query = tokenEquals(args, token_length, "state") || + tokenEquals(args, token_length, "states") || + tokenEquals(args, token_length, "status"); + if (is_state_query) { + const char* state_args = skipSpaces(token_end); + if (*state_args == '\0') { + formatActivePins(reply, reply_size); + return; } - if (!any) snprintf(reply, reply_size, "> available GPIOs: none"); + + uint32_t state_pin; + if (!parseUnsigned(state_args, state_pin) || !atEnd(state_args)) { + snprintf(reply, reply_size, "Error: use get gpio state [pin]"); + return; + } + if (!isAvailable(state_pin)) { + snprintf(reply, reply_size, "Error: GPIO %lu is unavailable", + (unsigned long)state_pin); + return; + } + formatPinStatus((uint8_t)state_pin, "> ", reply, reply_size); return; } uint32_t parsed_pin; if (!parseUnsigned(args, parsed_pin) || !atEnd(args)) { - snprintf(reply, reply_size, "Error: use get gpio [pin]"); + snprintf(reply, reply_size, "Error: use get gpio [state|states|status [pin]|pin]"); return; } if (!isAvailable(parsed_pin)) { @@ -187,58 +402,75 @@ void UserGpio::handleGet(const char* args, char* reply, size_t reply_size) { 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))); + formatPinStatus((uint8_t)parsed_pin, "> ", reply, reply_size); } -void UserGpio::handleSet(const char* args, char* reply, size_t reply_size) { +UserGpio::SetResult UserGpio::handleSet(const char* args, char* reply, + size_t reply_size, uint32_t request_id, + uint32_t request_source) { + SetResult result = {SetResult::ERROR, 0, false}; 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; + snprintf(reply, reply_size, "Error: use set gpio on|off|reset [duration on|off|reset]"); + return result; } if (!isAvailable(parsed_pin)) { snprintf(reply, reply_size, "Error: GPIO %lu is unavailable", (unsigned long)parsed_pin); - return; + return result; } const uint8_t pin = (uint8_t)parsed_pin; + result.pin = pin; args = skipSpaces(args); if (*args == '\0') { + result.cancelled_timer = (_timer_mask & pinMask(pin)) != 0; cancelTimer(pin); writeState(pin, initial_state); snprintf(reply, reply_size, "OK - GPIO %u %s", pin, stateName(initial_state)); - return; + result.outcome = SetResult::APPLIED; + return result; } - uint32_t seconds; + uint32_t duration_ms; + bool display_milliseconds; State final_state; - if (initial_state == STATE_RESET || !parseUnsigned(args, seconds) || + if (initial_state == STATE_RESET || !parseDuration(args, duration_ms, display_milliseconds) || !parseState(args, final_state) || !atEnd(args)) { - snprintf(reply, reply_size, "Error: use set gpio on|off [seconds on|off|reset]"); - return; + snprintf(reply, reply_size, "Error: use set gpio on|off [duration on|off|reset]"); + return result; } - if (seconds == 0 || seconds > MAX_TIMER_SECONDS) { - snprintf(reply, reply_size, "Error: timer must be 1-%lu seconds", (unsigned long)MAX_TIMER_SECONDS); - return; + if (duration_ms == 0 || duration_ms > MAX_TIMER_MILLIS) { + snprintf(reply, reply_size, "Error: timer must be 1ms-24h"); + return result; } + const uint32_t command_spec = duration_ms + | ((uint32_t)(initial_state == STATE_ON) << 27) + | ((uint32_t)final_state << 28) + | ((uint32_t)display_milliseconds << 30); + if (isDuplicateTimedRequest(pin, request_id, request_source, command_spec)) { + formatPinStatus(pin, "OK - duplicate ignored; ", reply, reply_size); + result.outcome = SetResult::DUPLICATE_IGNORED; + return result; + } + + result.cancelled_timer = (_timer_mask & pinMask(pin)) != 0; 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)); + scheduleTimer(pin, duration_ms, display_milliseconds, final_state, request_id); + rememberTimedRequest(pin, request_id, request_source, command_spec); + if (display_milliseconds) { + snprintf(reply, reply_size, "OK - GPIO %u %s for %lums, then %s", pin, + stateName(initial_state), (unsigned long)duration_ms, + stateName(final_state)); + } else { + snprintf(reply, reply_size, "OK - GPIO %u %s for %lus, then %s", pin, + stateName(initial_state), (unsigned long)(duration_ms / 1000UL), + stateName(final_state)); + } + result.outcome = SetResult::TIMER_STARTED; + return result; } diff --git a/src/helpers/UserGpio.h b/src/helpers/UserGpio.h index 15440792..e921226c 100644 --- a/src/helpers/UserGpio.h +++ b/src/helpers/UserGpio.h @@ -13,33 +13,81 @@ public: STATE_RESET }; + struct SetResult { + enum Outcome : uint8_t { + ERROR, + APPLIED, + TIMER_STARTED, + DUPLICATE_IGNORED + }; + + Outcome outcome; + uint8_t pin; + bool cancelled_timer; + }; + + struct Completion { + uint8_t pin; + State state; + uint32_t request_id; + }; + 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); + SetResult handleSet(const char* args, char* reply, size_t reply_size, + uint32_t request_id = 0, uint32_t request_source = 0); // Applies expired, non-blocking timed transitions. void loop(); + bool takeCompletion(Completion& completion); bool hasActiveTimer() const { return _timer_mask != 0; } + static const char* stateName(State state); + private: static const uint8_t MAX_GPIO_PIN = 63; + static const uint8_t RECENT_REQUEST_COUNT = 8; + + struct RecentTimedRequest { + uint32_t request_id; + uint32_t request_source; + uint32_t command_spec; + uint8_t pin; + bool valid; + }; mesh::MainBoard* _board; uint64_t _controlled_mask; uint64_t _timer_mask; uint64_t _timer_final_on_mask; uint64_t _timer_final_reset_mask; + uint64_t _timer_millisecond_mask; + uint64_t _completion_mask; + uint64_t _completion_on_mask; + uint64_t _completion_reset_mask; uint32_t _timer_deadline[MAX_GPIO_PIN + 1]; + uint32_t _timer_request_id[MAX_GPIO_PIN + 1]; + uint32_t _completion_request_id[MAX_GPIO_PIN + 1]; + RecentTimedRequest _recent_requests[RECENT_REQUEST_COUNT]; + uint8_t _next_recent_request; 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); + void scheduleTimer(uint8_t pin, uint32_t duration_ms, bool display_milliseconds, + State final_state, uint32_t request_id); + bool isDuplicateTimedRequest(uint8_t pin, uint32_t request_id, + uint32_t request_source, uint32_t command_spec) const; + void rememberTimedRequest(uint8_t pin, uint32_t request_id, + uint32_t request_source, uint32_t command_spec); + void formatAvailablePins(char* reply, size_t reply_size) const; + void formatActivePins(char* reply, size_t reply_size) const; + void formatPinStatus(uint8_t pin, const char* prefix, char* reply, + size_t reply_size) const; }; diff --git a/src/helpers/UserGpioReplyTracker.h b/src/helpers/UserGpioReplyTracker.h new file mode 100644 index 00000000..40d04554 --- /dev/null +++ b/src/helpers/UserGpioReplyTracker.h @@ -0,0 +1,95 @@ +#pragma once + +#include +#include + +// Keeps the authenticated remote-CLI route associated with each GPIO timer. +// The timer engine remains transport-independent; roles use this small tracker +// to send the final transition back to the client that scheduled it. +class UserGpioReplyTracker { +public: + static const uint8_t MAX_GPIO_PIN = 63; + static const uint8_t CLIENT_TAG_SIZE = 8; + + UserGpioReplyTracker() : _active_client_index(-1), _active_path_hash_size(1), + _active_client_valid(false) { + memset(_active_client_tag, 0, sizeof(_active_client_tag)); + for (uint8_t pin = 0; pin <= MAX_GPIO_PIN; pin++) clear(pin); + } + + void beginCommand(int client_index, uint8_t path_hash_size, + const uint8_t* client_public_key = NULL) { + _active_client_index = client_index >= 0 && client_index <= INT8_MAX + ? (int8_t)client_index : -1; + _active_path_hash_size = path_hash_size == 0 ? 1 : path_hash_size; + _active_client_valid = _active_client_index >= 0 && client_public_key != NULL; + if (_active_client_valid) { + memcpy(_active_client_tag, client_public_key, CLIENT_TAG_SIZE); + } else { + memset(_active_client_tag, 0, sizeof(_active_client_tag)); + } + } + + uint32_t requestSource() const { + if (!_active_client_valid) return 0; + uint32_t source = 2166136261UL; + for (uint8_t i = 0; i < CLIENT_TAG_SIZE; i++) { + source ^= _active_client_tag[i]; + source *= 16777619UL; + } + return source == 0 ? 1U : source; + } + + void timerScheduled(uint8_t pin, uint32_t request_id) { + if (pin > MAX_GPIO_PIN) return; + _routes[pin].request_id = request_id; + _routes[pin].client_index = _active_client_valid ? _active_client_index : -1; + _routes[pin].path_hash_size = _active_path_hash_size; + memcpy(_routes[pin].client_tag, _active_client_tag, CLIENT_TAG_SIZE); + } + + void timerCancelled(uint8_t pin) { + if (pin <= MAX_GPIO_PIN) clear(pin); + } + + bool takeRoute(uint8_t pin, uint32_t request_id, int& client_index, + uint8_t& path_hash_size, + uint8_t client_tag[CLIENT_TAG_SIZE]) { + if (pin > MAX_GPIO_PIN) return false; + Route& route = _routes[pin]; + if (route.client_index < 0 || route.request_id != request_id) return false; + + client_index = route.client_index; + path_hash_size = route.path_hash_size; + memcpy(client_tag, route.client_tag, CLIENT_TAG_SIZE); + clear(pin); + return true; + } + + static bool matchesClient(const uint8_t* client_public_key, + const uint8_t client_tag[CLIENT_TAG_SIZE]) { + return client_public_key != NULL && + memcmp(client_public_key, client_tag, CLIENT_TAG_SIZE) == 0; + } + +private: + struct Route { + uint32_t request_id; + uint8_t client_tag[CLIENT_TAG_SIZE]; + int8_t client_index; + uint8_t path_hash_size; + }; + + Route _routes[MAX_GPIO_PIN + 1]; + int8_t _active_client_index; + uint8_t _active_path_hash_size; + uint8_t _active_client_tag[CLIENT_TAG_SIZE]; + bool _active_client_valid; + + void clear(uint8_t pin) { + _routes[pin].request_id = 0; + memset(_routes[pin].client_tag, 0, CLIENT_TAG_SIZE); + _routes[pin].client_index = -1; + _routes[pin].path_hash_size = 1; + } +}; diff --git a/test/test_user_gpio/test_user_gpio.cpp b/test/test_user_gpio/test_user_gpio.cpp index e88dea18..e20d28ee 100644 --- a/test/test_user_gpio/test_user_gpio.cpp +++ b/test/test_user_gpio/test_user_gpio.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -54,6 +55,25 @@ TEST_F(UserGpioTest, ReportsWhenNoPinsAreAvailable) { EXPECT_STREQ("> available GPIOs: none", reply); } +TEST_F(UserGpioTest, StateAliasesListOnlyPinsNotInResetState) { + UserGpio gpio(board); + + gpio.handleGet(" state", reply, sizeof(reply)); + EXPECT_STREQ("> active GPIOs: none", reply); + + gpio.handleSet(" 4 off", reply, sizeof(reply)); + gpio.handleSet(" 16 on 5 reset", reply, sizeof(reply)); + gpio.handleGet(" states", reply, sizeof(reply)); + EXPECT_STREQ("> active GPIOs: 4=off,16=on(5s->reset)", reply); + + gpio.handleSet(" 4 reset", reply, sizeof(reply)); + gpio.handleGet(" status", reply, sizeof(reply)); + EXPECT_STREQ("> active GPIOs: 16=on(5s->reset)", reply); + + gpio.handleGet(" status 16", reply, sizeof(reply)); + EXPECT_STREQ("> GPIO 16 on, 5s -> reset", reply); +} + TEST_F(UserGpioTest, SetsAndGetsOnOffAndReset) { UserGpio gpio(board); @@ -96,6 +116,62 @@ TEST_F(UserGpioTest, AppliesTimedTransitionWithoutBlocking) { EXPECT_FALSE(gpio.hasActiveTimer()); } +TEST_F(UserGpioTest, BareDurationsAreSeconds) { + UserGpio gpio(board); + + gpio.handleSet(" 16 on 5 off", reply, sizeof(reply)); + EXPECT_STREQ("OK - GPIO 16 on for 5s, then off", reply); + + g_mock_millis = 4999; + gpio.loop(); + EXPECT_EQ(HIGH, g_mock_pin_levels[16]); + g_mock_millis = 5000; + gpio.loop(); + EXPECT_EQ(LOW, g_mock_pin_levels[16]); +} + +TEST_F(UserGpioTest, SupportsMillisecondDurations) { + UserGpio gpio(board); + + gpio.handleSet(" 16 on 5ms off", reply, sizeof(reply)); + EXPECT_STREQ("OK - GPIO 16 on for 5ms, then off", reply); + + g_mock_millis = 1; + gpio.handleGet(" 16", reply, sizeof(reply)); + EXPECT_STREQ("> GPIO 16 on, 4ms -> off", reply); + + g_mock_millis = 4; + gpio.loop(); + EXPECT_EQ(HIGH, g_mock_pin_levels[16]); + g_mock_millis = 5; + gpio.loop(); + EXPECT_EQ(LOW, g_mock_pin_levels[16]); +} + +TEST_F(UserGpioTest, AcceptsUpToTwentyFourHours) { + UserGpio gpio(board); + + gpio.handleSet(" 16 on 86400 off", reply, sizeof(reply)); + EXPECT_STREQ("OK - GPIO 16 on for 86400s, then off", reply); + EXPECT_TRUE(gpio.hasActiveTimer()); + + gpio.handleSet(" 16 on 86401 off", reply, sizeof(reply)); + EXPECT_STREQ("Error: timer must be 1ms-24h", reply); + + gpio.handleSet(" 16 on 86400001ms off", reply, sizeof(reply)); + EXPECT_STREQ("Error: timer must be 1ms-24h", reply); +} + +TEST_F(UserGpioTest, OnAndOffWithoutDurationRemainUntilChanged) { + UserGpio gpio(board); + + gpio.handleSet(" 16 on", reply, sizeof(reply)); + g_mock_millis = 24UL * 60UL * 60UL * 1000UL; + gpio.loop(); + EXPECT_EQ(HIGH, g_mock_pin_levels[16]); + EXPECT_FALSE(gpio.hasActiveTimer()); +} + TEST_F(UserGpioTest, ResetCancelsAnExistingTimer) { UserGpio gpio(board); @@ -106,6 +182,9 @@ TEST_F(UserGpioTest, ResetCancelsAnExistingTimer) { g_mock_millis = 30000; gpio.loop(); EXPECT_EQ(INPUT, g_mock_pin_modes[16]); + + UserGpio::Completion completion; + EXPECT_FALSE(gpio.takeCompletion(completion)); } TEST_F(UserGpioTest, ANewCommandReplacesAnExistingTimer) { @@ -137,6 +216,56 @@ TEST_F(UserGpioTest, TimedFinalStateCanResetThePin) { EXPECT_STREQ("> GPIO 4 reset", reply); } +TEST_F(UserGpioTest, ReportsCompletedTimedTransition) { + UserGpio gpio(board); + + gpio.handleSet(" 4 off 2 reset", reply, sizeof(reply), 1234, 2); + g_mock_millis = 2000; + gpio.loop(); + + UserGpio::Completion completion; + ASSERT_TRUE(gpio.takeCompletion(completion)); + EXPECT_EQ(4, completion.pin); + EXPECT_EQ(UserGpio::STATE_RESET, completion.state); + EXPECT_EQ(1234U, completion.request_id); + EXPECT_FALSE(gpio.takeCompletion(completion)); +} + +TEST_F(UserGpioTest, DuplicateNetworkTimerDoesNotRestartCountdown) { + UserGpio gpio(board); + + gpio.handleSet(" 16 on 30 off", reply, sizeof(reply), 1234, 2); + g_mock_millis = 1000; + UserGpio::SetResult result = + gpio.handleSet(" 16 on 30 off", reply, sizeof(reply), 1234, 2); + EXPECT_EQ(UserGpio::SetResult::DUPLICATE_IGNORED, result.outcome); + EXPECT_STREQ("OK - duplicate ignored; GPIO 16 on, 29s -> off", reply); + + g_mock_millis = 29999; + gpio.loop(); + EXPECT_EQ(HIGH, g_mock_pin_levels[16]); + g_mock_millis = 30000; + gpio.loop(); + EXPECT_EQ(LOW, g_mock_pin_levels[16]); +} + +TEST_F(UserGpioTest, SameTimestampFromAnotherClientIsNotADuplicate) { + UserGpio gpio(board); + + gpio.handleSet(" 16 on 30 off", reply, sizeof(reply), 1234, 2); + g_mock_millis = 1000; + UserGpio::SetResult result = + gpio.handleSet(" 16 on 30 off", reply, sizeof(reply), 1234, 3); + EXPECT_EQ(UserGpio::SetResult::TIMER_STARTED, result.outcome); + + g_mock_millis = 30000; + gpio.loop(); + EXPECT_EQ(HIGH, g_mock_pin_levels[16]); + g_mock_millis = 31000; + gpio.loop(); + EXPECT_EQ(LOW, g_mock_pin_levels[16]); +} + TEST_F(UserGpioTest, TimersRemainCorrectAcrossMillisWrap) { g_mock_millis = UINT32_MAX - 499; UserGpio gpio(board); @@ -159,8 +288,44 @@ TEST_F(UserGpioTest, RejectsUnavailablePinsAndMalformedTimers) { 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); + EXPECT_STREQ("Error: use set gpio on|off [duration on|off|reset]", reply); gpio.handleSet(" 16 on 0 off", reply, sizeof(reply)); - EXPECT_STREQ("Error: timer must be 1-2147483 seconds", reply); + EXPECT_STREQ("Error: timer must be 1ms-24h", reply); +} + +TEST(UserGpioReplyTrackerTest, KeepsTheSchedulingClientRouteUntilCompletion) { + UserGpioReplyTracker tracker; + const uint8_t client_key[UserGpioReplyTracker::CLIENT_TAG_SIZE] = + {1, 2, 3, 4, 5, 6, 7, 8}; + tracker.beginCommand(3, 2, client_key); + EXPECT_NE(0U, tracker.requestSource()); + tracker.timerScheduled(16, 1234); + + tracker.beginCommand(7, 1, client_key); + int client_index = -1; + uint8_t path_hash_size = 0; + uint8_t returned_tag[UserGpioReplyTracker::CLIENT_TAG_SIZE]; + ASSERT_TRUE(tracker.takeRoute(16, 1234, client_index, path_hash_size, + returned_tag)); + EXPECT_EQ(3, client_index); + EXPECT_EQ(2, path_hash_size); + EXPECT_TRUE(UserGpioReplyTracker::matchesClient(client_key, returned_tag)); + EXPECT_FALSE(tracker.takeRoute(16, 1234, client_index, path_hash_size, + returned_tag)); +} + +TEST(UserGpioReplyTrackerTest, ResetCancellationDropsTheCompletionRoute) { + UserGpioReplyTracker tracker; + const uint8_t client_key[UserGpioReplyTracker::CLIENT_TAG_SIZE] = + {1, 2, 3, 4, 5, 6, 7, 8}; + tracker.beginCommand(3, 2, client_key); + tracker.timerScheduled(16, 1234); + tracker.timerCancelled(16); + + int client_index = -1; + uint8_t path_hash_size = 0; + uint8_t returned_tag[UserGpioReplyTracker::CLIENT_TAG_SIZE]; + EXPECT_FALSE(tracker.takeRoute(16, 1234, client_index, path_hash_size, + returned_tag)); }