From b5b738a97997a8afbe163f4c5bc565f86c3c3d4f Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 29 Oct 2026 14:53:12 +0100 Subject: [PATCH 01/18] perf: keep the WiFi radio awake while the hub is in use IDF's default modem sleep (WIFI_PS_MIN_MODEM) has the AP hold frames for the hub until the next DTIM beacon, so every gateway or local command arrived up to a beacon interval late (typically 100-300 ms). CommandHandler now posts OPENSHOCK_EVENT_DEVICE_ACTIVITY for accepted commands, at most once a minute; WiFiManager's update task turns power save off on the first one and back on after an hour without activity. The first command of a session still waits for the beacon. --- components/core/src/wifi/WiFiManager.cpp | 52 +++++++++++++++++++ .../host_test/main/host_fakes.cpp | 11 ++++ .../host_test/main/host_fakes.h | 1 + .../host_test/main/test_command_handler.cpp | 23 ++++++++ .../device_control/src/CommandHandler.cpp | 25 +++++++++ components/events/include/events/Events.h | 1 + 6 files changed, 113 insertions(+) diff --git a/components/core/src/wifi/WiFiManager.cpp b/components/core/src/wifi/WiFiManager.cpp index 99748849..d8d246e2 100644 --- a/components/core/src/wifi/WiFiManager.cpp +++ b/components/core/src/wifi/WiFiManager.cpp @@ -551,10 +551,57 @@ static void wakeUpdateTask() } } +// Modem sleep (IDF's default WIFI_PS_MIN_MODEM) has the AP hold frames for us until the next DTIM beacon, delaying each +// one by up to a beacon interval (typically 100-300 ms). The radio stays awake from the first command until there has +// been no activity for an hour, so a session's commands arrive at once while an idle hub still saves power. The first +// command of a session still waits for the beacon. CommandHandler refreshes the activity at most once a minute. +static constexpr TickType_t kPowerSaveIdleTicks = pdMS_TO_TICKS(60 * 60 * 1000); + +// Set by the event loop on each activity event, consumed by the update task, which alone switches power save so the +// switches can't reorder. +static std::atomic s_deviceActivity = false; + +static void deviceActivityHandler(void*, esp_event_base_t, int32_t, void*) +{ + if (!s_deviceActivity.exchange(true, std::memory_order_relaxed)) { + wakeUpdateTask(); + } +} + +static void setPowerSave(wifi_ps_type_t type) +{ + esp_err_t err = esp_wifi_set_ps(type); + if (err != ESP_OK) { + OS_LOGW(TAG, "esp_wifi_set_ps(%d) failed: %s", static_cast(type), esp_err_to_name(err)); + } else { + OS_LOGD(TAG, "WiFi power save %s", type == WIFI_PS_NONE ? "off (device active)" : "on (device idle)"); + } +} + +// Called by the update task, which owns `lastActivity` and `awake` (true while power save is off). +static void updatePowerSave(TickType_t& lastActivity, bool& awake) +{ + TickType_t now = xTaskGetTickCount(); + if (s_deviceActivity.exchange(false, std::memory_order_relaxed)) { + lastActivity = now; + if (!awake) { + awake = true; + setPowerSave(WIFI_PS_NONE); + } + } else if (awake && now - lastActivity >= kPowerSaveIdleTicks) { + awake = false; + setPowerSave(WIFI_PS_MIN_MODEM); + } +} + static void wifimanagerUpdateTask(void*) { int64_t lastScanRequest = 0; + TickType_t lastActivity = 0; + bool awake = false; while (true) { + updatePowerSave(lastActivity, awake); + WiFiScanManager::ProcessEvents(); if (s_wifiState.load(std::memory_order_relaxed) == WiFiState::Disconnected && !WiFiScanManager::IsScanning()) { @@ -610,6 +657,11 @@ bool WiFiManager::Init() OS_LOGE(TAG, "Failed to register IP_EVENT handler: %s", esp_err_to_name(err)); return false; } + err = esp_event_handler_register(OPENSHOCK_EVENTS, OPENSHOCK_EVENT_DEVICE_ACTIVITY, deviceActivityHandler, nullptr); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to register device activity handler: %s", esp_err_to_name(err)); + return false; + } WiFiScanManager::RegisterStatusChangedHandler(evWiFiScanStatusChanged); WiFiScanManager::RegisterNetworksDiscoveredHandler(evWiFiNetworksDiscovery); diff --git a/components/device_control/host_test/main/host_fakes.cpp b/components/device_control/host_test/main/host_fakes.cpp index bbd0dfee..d642065d 100644 --- a/components/device_control/host_test/main/host_fakes.cpp +++ b/components/device_control/host_test/main/host_fakes.cpp @@ -79,6 +79,7 @@ namespace HostFake { OpenShock::Config::RFConfig RfConfig = {.txPin = static_cast(4), .keepAliveEnabled = false}; OpenShock::Config::EStopConfig EStopCfg = {.enabled = false, .gpioPin = GPIO_NUM_NC}; esp_event_handler_t EStopHandler = nullptr; + int ActivityPosts = 0; static std::vector s_liveQueues; // Queues only; mutexes are not counted static std::vector> s_tasks; @@ -93,6 +94,7 @@ namespace HostFake { FailRmtChannel = false; FailRmtTransmit = false; TaskDelays = 0; + ActivityPosts = 0; Transmissions.clear(); OnIdle = nullptr; OnTransmit = nullptr; @@ -363,6 +365,14 @@ static esp_err_t fakeEventHandlerRegister(esp_event_base_t, int32_t event_id, es return ESP_OK; } +static esp_err_t fakeEventPost(esp_event_base_t, int32_t event_id, const void*, size_t, TickType_t, int) +{ + if (event_id == OPENSHOCK_EVENT_DEVICE_ACTIVITY) { + ++ActivityPosts; + } + return ESP_OK; +} + // Installed before any C++ static constructor runs: CommandHandler.cpp's static // SimpleMutexes call xSemaphoreCreateMutexStatic during static initialization. __attribute__((constructor(101))) static void installMockCallbacks() @@ -390,6 +400,7 @@ __attribute__((constructor(101))) static void installMockCallbacks() rmt_tx_wait_all_done_Stub(fakeRmtTxWaitAllDone); esp_event_handler_register_Stub(fakeEventHandlerRegister); + esp_event_post_Stub(fakeEventPost); } // --- TaskUtils (common's util/TaskUtils.h): record tasks instead of starting them --- diff --git a/components/device_control/host_test/main/host_fakes.h b/components/device_control/host_test/main/host_fakes.h index 3921bde8..d8539f5f 100644 --- a/components/device_control/host_test/main/host_fakes.h +++ b/components/device_control/host_test/main/host_fakes.h @@ -45,6 +45,7 @@ namespace HostFake { extern OpenShock::Config::RFConfig RfConfig; extern OpenShock::Config::EStopConfig EStopCfg; extern esp_event_handler_t EStopHandler; // Handler registered for OPENSHOCK_EVENT_ESTOP_STATE_CHANGED + extern int ActivityPosts; // OPENSHOCK_EVENT_DEVICE_ACTIVITY posts since Reset() /// @brief Clears recorded frames and hooks and releases the e-stop. Queues and tasks are owned by the code under test. void Reset(); diff --git a/components/device_control/host_test/main/test_command_handler.cpp b/components/device_control/host_test/main/test_command_handler.cpp index 90b5b2da..8474f2d7 100644 --- a/components/device_control/host_test/main/test_command_handler.cpp +++ b/components/device_control/host_test/main/test_command_handler.cpp @@ -72,6 +72,28 @@ TEST_CASE("CommandHandler transmits commands while not e-stopped", "[device_cont TEST_ASSERT_TRUE(TestFrames::Count(shock) > 0); } +TEST_CASE("CommandHandler reports activity at most once a minute", "[device_control][command]") +{ + resetCommandHandler(); + HostFake::Now += 60'000; // Clear of whatever an earlier test reported + + // The activity event keeps WiFiManager's radio out of power save; one per command would flood the event loop. + for (int i = 0; i < 3; i++) { + TEST_ASSERT_TRUE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Vibrate, 10, 300)); + } + TEST_ASSERT_EQUAL(1, HostFake::ActivityPosts); + + HostFake::Now += 59'999; + TEST_ASSERT_TRUE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Vibrate, 10, 300)); + TEST_ASSERT_EQUAL(1, HostFake::ActivityPosts); + + HostFake::Now += 1; + TEST_ASSERT_TRUE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Vibrate, 10, 300)); + TEST_ASSERT_EQUAL(2, HostFake::ActivityPosts); + + HostFake::RunTask(transmitTask(), kBudget); // Send what was queued so the next test starts with an empty queue +} + TEST_CASE("CommandHandler refuses commands while e-stopped", "[device_control][command][estop]") { resetCommandHandler(); @@ -80,6 +102,7 @@ TEST_CASE("CommandHandler refuses commands while e-stopped", "[device_control][c TEST_ASSERT_FALSE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Shock, 100, 1000)); TEST_ASSERT_FALSE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Vibrate, 100, 1000)); TEST_ASSERT_FALSE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Sound, 0, 1000)); + TEST_ASSERT_EQUAL(0, HostFake::ActivityPosts); // Refused commands are not activity // Nothing even reached the transmitter's queue. HostFake::EStopped = false; diff --git a/components/device_control/src/CommandHandler.cpp b/components/device_control/src/CommandHandler.cpp index b68b4461..ed809232 100644 --- a/components/device_control/src/CommandHandler.cpp +++ b/components/device_control/src/CommandHandler.cpp @@ -8,6 +8,7 @@ const char* const TAG = "CommandHandler"; #include "Chipset.h" #include "config/Config.h" #include "estop/EStopManager.h" +#include "events/Events.h" #include "Logging.h" #include "OpenShock.h" @@ -28,6 +29,9 @@ const int64_t KEEP_ALIVE_INTERVAL = 60'000; const uint16_t KEEP_ALIVE_DURATION = 300; // Shocker IDs come from the network, so the set is capped; past it the least recently used shocker is dropped const size_t KEEP_ALIVE_MAX_SHOCKERS = 16; +// WiFiManager keeps the radio out of power save for an hour after the last activity event, so a session only needs +// refreshing now and then, not a broadcast per command. +const int64_t ACTIVITY_POST_INTERVAL = 60'000; static uint32_t calculateEepyTime(int64_t timeToKeepAlive) { @@ -162,6 +166,23 @@ static void commandhandler_keepalivetask() } } +// Requires s_keepAliveMutex. Lets WiFiManager keep the radio awake while commands are coming in, at most once per +// ACTIVITY_POST_INTERVAL. Never waits: when the event queue is full the next command reports the activity instead. +static void reportActivity() +{ + static int64_t lastPost = 0; + static bool posted = false; + + int64_t now = OpenShock::millis(); + if (posted && now - lastPost < ACTIVITY_POST_INTERVAL) { + return; + } + if (esp_event_post(OPENSHOCK_EVENTS, OPENSHOCK_EVENT_DEVICE_ACTIVITY, nullptr, 0, 0) == ESP_OK) { + lastPost = now; + posted = true; + } +} + static bool internalSetKeepAliveEnabled(bool enabled) { // Called from Init and from serial/portal tasks; the check must be under the lock. @@ -362,6 +383,10 @@ bool CommandHandler::HandleCommand(ShockerModelType model, uint16_t shockerId, S ScopedLock lock__ka(&s_keepAliveMutex); + if (ok) { + reportActivity(); + } + if (ok && s_keepAliveQueue != nullptr) { KnownShocker cmd {.killTask = false, .model = model, .shockerId = shockerId, .lastActivityTimestamp = OpenShock::millis() + durationMs}; if (xQueueSend(s_keepAliveQueue, &cmd, pdMS_TO_TICKS(10)) != pdTRUE) { diff --git a/components/events/include/events/Events.h b/components/events/include/events/Events.h index 7f1a75d8..56712348 100644 --- a/components/events/include/events/Events.h +++ b/components/events/include/events/Events.h @@ -12,6 +12,7 @@ enum { OPENSHOCK_EVENT_ESTOP_STATE_CHANGED, // Event for when the EStop activation state changes OPENSHOCK_EVENT_GATEWAY_CLIENT_STATE_CHANGED, // Event for when the gateway connection state changes OPENSHOCK_EVENT_WIFI_STATE_CHANGED, // Event for when the WiFi station connectivity state changes + OPENSHOCK_EVENT_DEVICE_ACTIVITY, // A shocker command was accepted (no payload) }; // Coarse WiFi station connectivity, posted as the OPENSHOCK_EVENT_WIFI_STATE_CHANGED payload. From 6b86a2c163ed61ccf5db5ce2d8f1dd718f6851d0 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 29 Oct 2026 14:53:13 +0100 Subject: [PATCH 02/18] perf: pick up RF commands between frames instead of between rounds The transmit task sent one frame for every active sequence before it looked at its queue again, so with N shockers live a new command or Stop waited up to N frames (45-77 ms each). It now sends one frame per pass, round-robin, and drains the queue in between. E-Stop is still checked before every frame. --- .../host_test/main/test_rf_transmitter.cpp | 34 +++++++ .../src/radio/RFTransmitter.cpp | 90 ++++++++++--------- 2 files changed, 83 insertions(+), 41 deletions(-) diff --git a/components/device_control/host_test/main/test_rf_transmitter.cpp b/components/device_control/host_test/main/test_rf_transmitter.cpp index d155a400..ef45dbb0 100644 --- a/components/device_control/host_test/main/test_rf_transmitter.cpp +++ b/components/device_control/host_test/main/test_rf_transmitter.cpp @@ -226,6 +226,40 @@ TEST_CASE("RFTransmitter Stop replaces a live command with a short zero-intensit TEST_ASSERT_TRUE(HostFake::Transmissions.back().at < start + 1000); } +TEST_CASE("RFTransmitter picks up a Stop on the next frame, not after the round", "[device_control][rf]") +{ + HostFake::Reset(); + RFTransmitter tx(kPin); + + // Three live shockers, so a round is three frames. The Stop for the last one arrives right after the first frame of + // a round; it must win before that shocker's next frame instead of waiting for the round to finish. + const uint16_t kLastShockerId = kShockerId + 2; + auto lastShock = TestFrames::Payload(kModel, kLastShockerId, ShockerCommandType::Shock, 80); + auto lastStop = TestFrames::Payload(kModel, kLastShockerId, ShockerCommandType::Vibrate, 0); + + for (uint16_t id = kShockerId; id <= kLastShockerId; id++) { + TEST_ASSERT_TRUE(tx.SendCommand(kModel, id, ShockerCommandType::Shock, 80, 1000)); + } + + size_t stoppedAfter = 0; + HostFake::OnTransmit = [&] { + if (stoppedAfter == 0 && HostFake::Transmissions.size() == 4) { // First frame of the second round + stoppedAfter = HostFake::Transmissions.size(); + tx.SendCommand(kModel, kLastShockerId, ShockerCommandType::Stop, 0, 0); + } + }; + + TEST_ASSERT_FALSE(HostFake::RunTask(transmitTask(), kBudget)); + + TEST_ASSERT_EQUAL(1, TestFrames::Count(lastShock)); // Only its frame from the first round + bool sawStop = false; + for (size_t i = stoppedAfter; i < HostFake::Transmissions.size(); i++) { + TEST_ASSERT_TRUE(HostFake::Transmissions[i].symbols != lastShock); + sawStop = sawStop || HostFake::Transmissions[i].symbols == lastStop; + } + TEST_ASSERT_TRUE(sawStop); +} + TEST_CASE("RFTransmitter Stop overtakes a backlog and drops that shocker's older commands", "[device_control][rf]") { HostFake::Reset(); diff --git a/components/device_control/src/radio/RFTransmitter.cpp b/components/device_control/src/radio/RFTransmitter.cpp index b7b33697..18dfb386 100644 --- a/components/device_control/src/radio/RFTransmitter.cpp +++ b/components/device_control/src/radio/RFTransmitter.cpp @@ -33,7 +33,7 @@ const uint32_t kMaxFailureBackoffMs = 100; const uint8_t kFlagOverwrite = 1 << 0; const uint8_t kFlagDeleteTask = 1 << 1; const uint8_t kFlagStop = 1 << 2; -// A Stop waits longer for a slot than other commands: the queue only stays full while the task is mid-round +// A Stop waits longer for a slot than other commands: the queue only stays full while the task is mid-frame const TickType_t kStopQueueWait = pdMS_TO_TICKS(100); using namespace OpenShock; @@ -268,54 +268,56 @@ static bool transmitSymbols(rmt_channel_handle_t channel, rmt_encoder_handle_t e return false; } -static void writeSequences(rmt_channel_handle_t channel, rmt_encoder_handle_t encoder, std::vector& sequences, const ManagedTask& task, uint32_t& failures, const TaskUtils::TaskWatchdog& watchdog) +// Sends one frame for the sequence at `cursor` (its payload, or its terminator once it expired) and advances the cursor +// round-robin. One frame per call lets the task check its queue between frames instead of between whole rounds, so a +// new command or Stop waits at most one frame rather than a frame per active shocker. +static void writeNextFrame(rmt_channel_handle_t channel, rmt_encoder_handle_t encoder, std::vector& sequences, std::size_t& cursor, uint32_t& failures) { - // Send queued commands - for (auto seq = sequences.begin(); seq != sequences.end();) { - watchdog.feed(); // Per frame: a round over many shockers can outlast the watchdog timeout - if (failures >= kMaxConsecutiveFailures) { - // Nothing is getting out; dropping them beats retrying forever. New commands get a fresh run. - OS_LOGE(TAG, "RMT keeps failing, dropping %zu pending sequences", sequences.size()); - sequences.clear(); - failures = 0; - return; - } + if (failures >= kMaxConsecutiveFailures) { + // Nothing is getting out; dropping them beats retrying forever. New commands get a fresh run. + OS_LOGE(TAG, "RMT keeps failing, dropping %zu pending sequences", sequences.size()); + sequences.clear(); + failures = 0; + cursor = 0; + return; + } - // A round of many sequences can outlast stop()'s timeout; the shocker stops on its own once frames stop. - if (task.stopRequested()) { - return; - } + if (cursor >= sequences.size()) { + cursor = 0; + } - int64_t now = OpenShock::millis(); + // Indexed, not an iterator: commands received between frames may push_back and reallocate the vector. + Rmt::Sequence& seq = sequences[cursor]; - // A round can take several frames; re-check EStop per sequence so no payload goes out after activation. - if (seq->transmitEnd() > now && OpenShock::EStopManager::IsEStopped()) { - seq->setTransmitEnd(now); - } + int64_t now = OpenShock::millis(); - int64_t timeToLive = seq->transmitEnd() - now; + // Re-check EStop per frame so no payload goes out after activation. + if (seq.transmitEnd() > now && OpenShock::EStopManager::IsEStopped()) { + seq.setTransmitEnd(now); + } - if (timeToLive > 0) { - // Send the command - transmitSymbols(channel, encoder, seq->payload(), seq->size(), failures); - ++seq; - continue; - } + int64_t timeToLive = seq.transmitEnd() - now; - // Remove command once it has sent out its termination sequence for long enough. A sequence that never got a - // terminator out (a slow round skipped past the whole window, or a failed fill zeroed its end) sends one first. - if (timeToLive <= -kTerminatorDurationMs && seq->terminatorSent()) { - seq = sequences.erase(seq); - continue; - } + if (timeToLive > 0) { + // Send the command + transmitSymbols(channel, encoder, seq.payload(), seq.size(), failures); + ++cursor; + return; + } - // Send the termination sequence to stop the shocker. Only a terminator that went out counts: after a failure it is - // retried next round, and kMaxConsecutiveFailures bounds the retries. - if (transmitSymbols(channel, encoder, seq->terminator(), seq->size(), failures)) { - seq->markTerminatorSent(); - } - ++seq; + // Remove command once it has sent out its termination sequence for long enough. A sequence that never got a + // terminator out (a slow round skipped past the whole window, or a failed fill zeroed its end) sends one first. + if (timeToLive <= -kTerminatorDurationMs && seq.terminatorSent()) { + sequences.erase(sequences.begin() + static_cast(cursor)); // cursor now points at the next one + return; + } + + // Send the termination sequence to stop the shocker. Only a terminator that went out counts: after a failure it is + // retried on its next turn, and kMaxConsecutiveFailures bounds the retries. + if (transmitSymbols(channel, encoder, seq.terminator(), seq.size(), failures)) { + seq.markTerminatorSent(); } + ++cursor; } void RFTransmitter::TransmitTask() @@ -324,6 +326,7 @@ void RFTransmitter::TransmitTask() bool wasEstopped = false; uint32_t rmtFailures = 0; // Transmit failures in a row + std::size_t cursor = 0; // Next sequence to send a frame for std::vector sequences; // (shocker key, seq) of Stops taken from the front of the queue while older commands may still be behind them. // Cleared once the queue drains, so it stays as small as the number of distinct shockers stopped in one backlog. @@ -404,6 +407,11 @@ void RFTransmitter::TransmitTask() if (!sequences.empty()) { watchdog.subscribe(); } - writeSequences(m_rmtChannel, m_rmtEncoder, sequences, m_task, rmtFailures, watchdog); + // Once stop() is requested no further frames go out (the shocker stops on its own); the task exits on the kill + // command stop() queues. + if (!sequences.empty() && !m_task.stopRequested()) { + watchdog.feed(); + writeNextFrame(m_rmtChannel, m_rmtEncoder, sequences, cursor, rmtFailures); + } } } From f42d283be8f50c99d826fc26d6925f682e002665 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 29 Oct 2026 14:53:13 +0100 Subject: [PATCH 03/18] perf: read read-only littlefs mounts through a flash mapping Every esp_partition_read is a SPI1 transaction that disables the flash cache and, on dual-core chips, stalls the other core (WiFi/lwIP) - serving the portal's index.html.gz took 375 of them. Read-only mounts now map the partition and read with memcpy through the cache, falling back to esp_partition_read if the map fails. OTA unmounts static0 (CaptivePortal::ForceClose) before rewriting it, so the mapping never sees a partition change under it. --- components/fs/src/LfsPartition.cpp | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/components/fs/src/LfsPartition.cpp b/components/fs/src/LfsPartition.cpp index 85e127f8..711a896c 100644 --- a/components/fs/src/LfsPartition.cpp +++ b/components/fs/src/LfsPartition.cpp @@ -31,6 +31,9 @@ struct OpenShock::detail::LfsState { struct lfs_config cfg; const esp_partition_t* partition; bool writable; + // Read-only mounts map the whole partition and read through the flash cache (nullptr when not mapped) + const uint8_t* map; + esp_partition_mmap_handle_t mapHandle; }; using OpenShock::detail::LfsState; @@ -42,6 +45,24 @@ static int bdRead(const struct lfs_config* c, lfs_block_t block, lfs_off_t off, return esp_partition_read(st->partition, addr, buffer, size) == ESP_OK ? 0 : LFS_ERR_IO; } +// esp_partition_read is a SPI1 transaction: it disables the flash cache and, on dual-core chips, stalls the other core +// (where WiFi and lwIP run) for every call, moving 64 bytes per transaction. A static asset costs hundreds of those; +// reading the mapped partition is a plain memcpy through the cache instead. +static int bdReadMapped(const struct lfs_config* c, lfs_block_t block, lfs_off_t off, void* buffer, lfs_size_t size) +{ + auto* st = static_cast(c->context); + std::memcpy(buffer, st->map + static_cast(block) * c->block_size + off, size); + return 0; +} + +static void unmapPartition(LfsState* st) +{ + if (st->map != nullptr) { + esp_partition_munmap(st->mapHandle); + st->map = nullptr; + } +} + static int bdProg(const struct lfs_config* c, lfs_block_t block, lfs_off_t off, const void* buffer, lfs_size_t size) { auto* st = static_cast(c->context); @@ -106,17 +127,31 @@ bool LfsPartition::mount(const esp_partition_t* partition, bool writable, bool f cfg.cache_size = LFS_CACHE_SIZE; cfg.lookahead_size = LFS_LOOKAHEAD_SIZE; + // Only read-only mounts: a writable one would read stale data through the cache after its own writes. Writers to a + // read-only partition (OTA) unmount it first. A failed map just keeps the esp_partition_read path. + if (!writable) { + const void* map = nullptr; + if (esp_partition_mmap(partition, 0, partition->size, ESP_PARTITION_MMAP_DATA, &map, &m_state->mapHandle) == ESP_OK) { + m_state->map = static_cast(map); + cfg.read = &bdReadMapped; + } else { + OS_LOGW(TAG, "Failed to map partition, reading it through esp_partition_read"); + } + } + int err = lfs_mount(&m_state->lfs, &cfg); if (err < 0) { if (writable && formatOnFail) { OS_LOGW(TAG, "Mount failed (lfs error %d), formatting partition", err); if (lfs_format(&m_state->lfs, &cfg) < 0 || lfs_mount(&m_state->lfs, &cfg) < 0) { OS_LOGE(TAG, "Failed to format and mount partition"); + unmapPartition(m_state); m_state->partition = nullptr; return false; } } else { OS_LOGE(TAG, "Failed to mount partition (lfs error %d)", err); + unmapPartition(m_state); m_state->partition = nullptr; return false; } @@ -132,6 +167,7 @@ void LfsPartition::unmount() lfs_unmount(&m_state->lfs); m_mounted = false; } + unmapPartition(m_state); m_state->partition = nullptr; } From 29996ddbf5fc459ef7b1d7e4fd5a627f0118bb8b Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 29 Oct 2026 14:53:13 +0100 Subject: [PATCH 04/18] chore: log where flashing a partition from a URL spends its time One INFO line per partition with the total, the throughput and the time in erase, write, hash and progress reporting; the rest is the network. Shows whether OTA is bound by the download or by the flash before deciding how to overlap them. --- components/hwutil/src/PartitionUtils.cpp | 34 +++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/components/hwutil/src/PartitionUtils.cpp b/components/hwutil/src/PartitionUtils.cpp index 8f8c8f0c..5c42522a 100644 --- a/components/hwutil/src/PartitionUtils.cpp +++ b/components/hwutil/src/PartitionUtils.cpp @@ -49,6 +49,12 @@ bool OpenShock::FlashPartitionFromUrl(HTTP::Client& client, const esp_partition_ std::size_t erasedTo = 0; int64_t lastProgress = 0; + // Where the time goes (us), logged once at the end: whatever isn't flash, hashing or progress reporting is the + // network (TLS reads and waiting for data), which can't overlap the flash work in this single-task loop. + struct { + int64_t erase, write, hash, progress; + } spent = {}; + auto sizeValidator = [partition, &contentLength, progressCallback, &lastProgress](std::size_t size) -> bool { if (size > partition->size) { OS_LOGE(TAG, "Remote partition binary is too large"); @@ -63,7 +69,8 @@ bool OpenShock::FlashPartitionFromUrl(HTTP::Client& client, const esp_partition_ return true; }; - auto dataWriter = [partition, &sha256, &contentLength, &contentWritten, &erasedTo, progressCallback, &lastProgress](std::size_t offset, const uint8_t* data, std::size_t length) -> bool { + auto dataWriter = [partition, &sha256, &contentLength, &contentWritten, &erasedTo, progressCallback, &lastProgress, &spent](std::size_t offset, const uint8_t* data, std::size_t length) -> bool { + int64_t t0 = OpenShock::micros(); while (erasedTo < offset + length) { // The step is a multiple of the erase size, and partitions end on an erase-size boundary std::size_t eraseLength = std::min(kEraseStep, partition->size - erasedTo); @@ -73,16 +80,19 @@ bool OpenShock::FlashPartitionFromUrl(HTTP::Client& client, const esp_partition_ } erasedTo += eraseLength; } + int64_t t1 = OpenShock::micros(); if (esp_partition_write(partition, offset, data, length) != ESP_OK) { OS_LOGE(TAG, "Failed to write to partition"); return false; } + int64_t t2 = OpenShock::micros(); if (!sha256.update(data, length)) { OS_LOGE(TAG, "Failed to update SHA256 hash"); return false; } + int64_t t3 = OpenShock::micros(); contentWritten += length; @@ -92,9 +102,16 @@ bool OpenShock::FlashPartitionFromUrl(HTTP::Client& client, const esp_partition_ progressCallback(contentWritten, contentLength, static_cast(contentWritten) / static_cast(contentLength)); } + spent.erase += t1 - t0; + spent.write += t2 - t1; + spent.hash += t3 - t2; + spent.progress += OpenShock::micros() - t3; + return true; }; + int64_t downloadStart = OpenShock::micros(); + // Start streaming binary to app partition. auto appBinaryResponse = client.Download( remoteUrl, @@ -111,6 +128,21 @@ bool OpenShock::FlashPartitionFromUrl(HTTP::Client& client, const esp_partition_ return false; } + int64_t total = OpenShock::micros() - downloadStart; + int64_t other = total - spent.erase - spent.write - spent.hash - spent.progress; + OS_LOGI( + TAG, + "Flashed %zu bytes in %lld ms (%lld KB/s): erase %lld ms, write %lld ms, hash %lld ms, progress %lld ms, network+other %lld ms", + contentWritten, + total / 1000, + total > 0 ? static_cast(contentWritten) * 1000 / total : 0, // bytes/us * 1000 = KB/s (1 KB = 1000 B) + spent.erase / 1000, + spent.write / 1000, + spent.hash / 1000, + spent.progress / 1000, + other / 1000 + ); + progressCallback(contentLength, contentLength, 1.0f); OS_LOGD(TAG, "Wrote %zu bytes to partition", appBinaryResponse.data); From 3ee53bb6d672c92106668b79f971adf2a18fac3a Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 29 Oct 2026 14:53:13 +0100 Subject: [PATCH 05/18] perf: raise the TCP receive window to 16 KB The 5760-byte default caps a download at one window per round trip (~190 KB/s at 30 ms) and stalls the sender while an OTA erases a flash block. Raises the peak heap a busy TCP socket can use; measure OTA time and free heap on a device before keeping this. --- sdkconfig.defaults | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sdkconfig.defaults b/sdkconfig.defaults index ac41e1c0..578e53e5 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -94,6 +94,15 @@ CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y # websocket sockets; the default of 10 would make httpd_start fail. CONFIG_LWIP_MAX_SOCKETS=16 +# The receive window caps a download at one window per round trip, and while an OTA erases a 64 KB flash block the +# sender stalls once the window is full (the download and the flash writes share one task). The 5760-byte default +# allows ~190 KB/s at 30 ms RTT; 16 KB lets the network keep going through an erase. A window is a promise, not an +# allocation: only data actually queued and unread uses heap, but that peak now applies to every TCP socket. The +# recvmbox holds received pbufs, so it is raised to cover a full window of segments. Measure OTA time and heap on a +# device (PartitionUtils logs the breakdown) before keeping this. +CONFIG_LWIP_TCP_WND_DEFAULT=16384 +CONFIG_LWIP_TCP_RECVMBOX_SIZE=16 + # The gateway websocket and the kept-alive HTTP client each hold a TLS session. With static # buffers every session keeps a 16 KB input + 4 KB output record buffer for its whole life; # dynamic buffers allocate them per record and shrink while idle, lowering the heap peak From 259bbe16f10d0fd5524b679592f283a9e1f2d163 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 3 Nov 2026 09:13:07 +0100 Subject: [PATCH 06/18] refactor: make the WiFi power save idle timeout a Kconfig option develop moved build-time tunables into per-component Kconfig fragments; the hour the radio stays awake after the last shocker command is one of them, so it is now CONFIG_OPENSHOCK_WIFI_POWERSAVE_IDLE_MS in the WiFi menu. The 5 minute floor keeps it well above CommandHandler's once-a-minute activity refresh. --- components/core/Kconfig | 10 ++++++++++ components/core/src/wifi/WiFiManager.cpp | 7 ++++--- components/device_control/src/CommandHandler.cpp | 4 ++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/components/core/Kconfig b/components/core/Kconfig index eeb8048f..4cb51671 100644 --- a/components/core/Kconfig +++ b/components/core/Kconfig @@ -27,6 +27,16 @@ menu "OpenShock WiFi" help A network that failed to connect is retried after 2 s, 4 s, 8 s, ... up to this. + config OPENSHOCK_WIFI_POWERSAVE_IDLE_MS + int "Radio stays awake after the last command (ms)" + range 300000 86400000 + default 3600000 + help + Modem sleep has the AP hold frames until the next DTIM beacon, delaying each one + by up to a beacon interval (typically 100-300 ms). Power save is switched off on + the first shocker command and back on after this long without one. The command + handler reports activity at most once a minute, so this stays well above that. + endmenu menu "OpenShock gateway" diff --git a/components/core/src/wifi/WiFiManager.cpp b/components/core/src/wifi/WiFiManager.cpp index 09401a9e..bdab5151 100644 --- a/components/core/src/wifi/WiFiManager.cpp +++ b/components/core/src/wifi/WiFiManager.cpp @@ -552,9 +552,10 @@ static void wakeUpdateTask() // Modem sleep (IDF's default WIFI_PS_MIN_MODEM) has the AP hold frames for us until the next DTIM beacon, delaying each // one by up to a beacon interval (typically 100-300 ms). The radio stays awake from the first command until there has -// been no activity for an hour, so a session's commands arrive at once while an idle hub still saves power. The first -// command of a session still waits for the beacon. CommandHandler refreshes the activity at most once a minute. -static constexpr TickType_t kPowerSaveIdleTicks = pdMS_TO_TICKS(60 * 60 * 1000); +// been no activity for CONFIG_OPENSHOCK_WIFI_POWERSAVE_IDLE_MS, so a session's commands arrive at once while an idle hub +// still saves power. The first command of a session still waits for the beacon. CommandHandler refreshes the activity +// at most once a minute. +static constexpr TickType_t kPowerSaveIdleTicks = pdMS_TO_TICKS(CONFIG_OPENSHOCK_WIFI_POWERSAVE_IDLE_MS); // Set by the event loop on each activity event, consumed by the update task, which alone switches power save so the // switches can't reorder. diff --git a/components/device_control/src/CommandHandler.cpp b/components/device_control/src/CommandHandler.cpp index ee9237d7..52f67882 100644 --- a/components/device_control/src/CommandHandler.cpp +++ b/components/device_control/src/CommandHandler.cpp @@ -26,8 +26,8 @@ const char* const TAG = "CommandHandler"; #include #include -// WiFiManager keeps the radio out of power save for an hour after the last activity event, so a session only needs -// refreshing now and then, not a broadcast per command. +// WiFiManager keeps the radio out of power save for CONFIG_OPENSHOCK_WIFI_POWERSAVE_IDLE_MS (at least 5 minutes) after +// the last activity event, so a session only needs refreshing now and then, not a broadcast per command. const int64_t ACTIVITY_POST_INTERVAL = 60'000; static uint32_t calculateEepyTime(int64_t timeToKeepAlive) From a21883b5045e6f39a8853f19a1a56526b698085e Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 3 Nov 2026 09:36:02 +0100 Subject: [PATCH 07/18] perf: resume TLS sessions when reconnecting to the gateway and HTTP hosts Every gateway reconnect and every HTTP request after the server closed the kept-alive socket did a full handshake: certificate chain verification and the key exchange, around a second of CPU. CONFIG_ESP_TLS_CLIENT_SESSION_TICKETS lets a client offer its last session instead. esp_websocket_client builds new transports for each handle and GatewayClient recreates the handle per connection, so GatewayClient now owns the TLS and websocket transports and passes them as ext_transport. It saves the session on CONNECTED and offers it on the next connect, rebuilding the transports when the host changed or no session was saved. HTTP::Client turns on save_client_session and closes rather than destroys its handle when the server closes the connection, so the next request resumes. esp_http_client would offer the session to any host the URL names, so a handle is only reused for the host it was created for. A server that declines the session gets a full handshake, as before. Needs verifying on a device against the gateway. --- components/core/CMakeLists.txt | 1 + components/core/src/GatewayClient.cpp | 80 ++++++++++++++++++- components/core/src/GatewayClient.h | 9 +++ .../http/include/http/HTTPRequestManager.h | 1 + components/http/src/HTTPRequestManager.cpp | 29 +++++-- sdkconfig.defaults | 5 ++ 6 files changed, 117 insertions(+), 8 deletions(-) diff --git a/components/core/CMakeLists.txt b/components/core/CMakeLists.txt index a2e7fe99..edb334a0 100644 --- a/components/core/CMakeLists.txt +++ b/components/core/CMakeLists.txt @@ -18,6 +18,7 @@ idf_component_register( REQUIRES common temporal logging http chipset led_drivers protocols osjson events esp_driver_rmt esp_driver_gpio esp_netif esp_event esp_wifi esp_timer nvs_flash mbedtls flatbuffers serialization config # Internal-only now that GatewayClient.h is private: PRIV_REQUIRES espressif__esp_websocket_client + tcp_transport # GatewayClient owns its TLS/websocket transports to resume TLS sessions device_control # estop/CommandHandler/visual used only in core .cpp ) diff --git a/components/core/src/GatewayClient.cpp b/components/core/src/GatewayClient.cpp index acd084b7..aeeb2e2f 100644 --- a/components/core/src/GatewayClient.cpp +++ b/components/core/src/GatewayClient.cpp @@ -15,6 +15,8 @@ const char* const TAG = "GatewayClient"; #include "Temporal.h" #include +#include +#include #include @@ -42,6 +44,9 @@ GatewayClient::GatewayClient(const std::string& authToken) , m_state(GatewayClientState::Disconnected) , m_lastPingTimestamp(0) , m_retired(false) + , m_tls(nullptr) + , m_wss(nullptr) + , m_tlsHost() , m_binReasm() , m_binReasmActive(false) , m_binReasmDiscard(false) @@ -65,6 +70,8 @@ GatewayClient::~GatewayClient() } esp_websocket_client_destroy(handle); } + + _destroyTransport(); // After the client: it doesn't own an ext_transport, but its task uses it until destroyed } void GatewayClient::retire() @@ -101,6 +108,67 @@ void GatewayClient::_destroyHandle() esp_websocket_client_destroy(handle); } +void GatewayClient::_destroyTransport() +{ + // ws_destroy leaves its parent alone, so both go + if (m_wss != nullptr) { + esp_transport_destroy(m_wss); + m_wss = nullptr; + } + if (m_tls != nullptr) { + esp_transport_destroy(m_tls); // Also frees the saved session + m_tls = nullptr; + } + m_tlsHost.clear(); +} + +// A full TLS handshake (certificate chain verification and the key exchange) costs on the order of a second of CPU; +// resuming the previous connection's session skips both. esp_websocket_client builds a fresh transport for every +// handle and the handle is recreated per connection, so the session would die with it: the transports live here +// instead and go to the client as ext_transport. The client never creates or destroys an ext_transport. If the server +// declines the session the handshake falls back to a full one. +esp_transport_handle_t GatewayClient::_prepareTransport(const std::string& host, const std::string& path) +{ + // A session is only offered to the host that issued it. And when no session was saved, USE leaves the TLS config + // pointing at the session the last SAVE freed, so the transports are rebuilt rather than reused without one. + if (m_tls != nullptr && (m_tlsHost != host || esp_transport_ssl_session_ticket_operation(m_tls, ESP_TRANSPORT_SESSION_TICKET_USE) != ESP_OK)) { + _destroyTransport(); + } + + if (m_tls == nullptr) { + m_tls = esp_transport_ssl_init(); + if (m_tls == nullptr) { + return nullptr; + } + esp_transport_set_default_port(m_tls, 443); + esp_transport_ssl_crt_bundle_attach(m_tls, esp_crt_bundle_attach); // verify server against the compiled-in CA bundle + + m_wss = esp_transport_ws_init(m_tls); + if (m_wss == nullptr) { + _destroyTransport(); + return nullptr; + } + esp_transport_set_default_port(m_wss, 443); + m_tlsHost = host; + } + + // What esp_websocket_client applies to the transports it creates itself. The strings are copied. + const esp_transport_ws_config_t wsConfig = { + .ws_path = path.c_str(), + .sub_protocol = nullptr, + .user_agent = OpenShock::Constants::UserAgent(), + .headers = m_headers.c_str(), + .auth = nullptr, + .propagate_control_frames = true, + }; + if (esp_transport_ws_set_config(m_wss, &wsConfig) != ESP_OK) { + _destroyTransport(); + return nullptr; + } + + return m_wss; +} + void GatewayClient::connect(const std::string& host, uint16_t port, const std::string& path) { // A retired client may still be connected by a caller that fetched it before it was retired (the LCG lookup is slow) @@ -113,6 +181,14 @@ void GatewayClient::connect(const std::string& host, uint16_t port, const std::s _setState(GatewayClientState::Connecting); + // After _destroyHandle(): no websocket task is left using the transports + esp_transport_handle_t transport = _prepareTransport(host, path); + if (transport == nullptr) { + OS_LOGE(TAG, "Failed to create the WebSocket transport"); + _setState(GatewayClientState::Disconnected); + return; + } + esp_websocket_client_config_t config = {}; config.host = host.c_str(); config.port = port; @@ -121,7 +197,7 @@ void GatewayClient::connect(const std::string& host, uint16_t port, const std::s config.user_agent = OpenShock::Constants::UserAgent(); config.headers = m_headers.c_str(); config.disable_auto_reconnect = true; // GatewayConnectionManager owns reconnection - config.crt_bundle_attach = esp_crt_bundle_attach; // verify server against the compiled-in CA bundle + config.ext_transport = transport; // Verifies the server, see _prepareTransport() config.task_stack = CONFIG_OPENSHOCK_GATEWAY_TASK_STACK_SIZE; // Message handlers (config reads, flash writes, FlatBuffers verification) run on it // TLS work stays on core 0 with the WiFi/lwIP tasks, away from the E-Stop and RF tasks on core 1 config.task_core_id_set = true; @@ -276,6 +352,8 @@ void GatewayClient::_eventHandler(void* arg, esp_event_base_t /*base*/, int32_t switch (eventId) { case WEBSOCKET_EVENT_CONNECTED: + // On the websocket task, the only one using the transport now. Freed with the transport, or replaced next time. + esp_transport_ssl_session_ticket_operation(self->m_tls, ESP_TRANSPORT_SESSION_TICKET_SAVE); self->m_lastPingTimestamp = OpenShock::millis(); self->_setState(GatewayClientState::Connected); self->_sendBootStatus(); diff --git a/components/core/src/GatewayClient.h b/components/core/src/GatewayClient.h index 0caab9cb..91394b07 100644 --- a/components/core/src/GatewayClient.h +++ b/components/core/src/GatewayClient.h @@ -5,6 +5,7 @@ #include "SimpleMutex.h" #include +#include #include #include @@ -49,6 +50,8 @@ namespace OpenShock { esp_websocket_client_handle_t _takeHandle(); void _destroyHandle(); + esp_transport_handle_t _prepareTransport(const std::string& host, const std::string& path); + void _destroyTransport(); std::string m_headers; SimpleMutex m_handleMutex; // Held across sends from other tasks so the handle can't be destroyed mid-send @@ -58,6 +61,12 @@ namespace OpenShock { std::atomic m_lastPingTimestamp; std::atomic m_retired; + // The TLS and websocket transports, kept across connections so the TLS session saved on each connect resumes the + // next handshake. Only touched while no websocket task runs, except the session save on CONNECTED (on that task). + esp_transport_handle_t m_tls; + esp_transport_handle_t m_wss; // Websocket framing over m_tls; handed to the client as ext_transport + std::string m_tlsHost; // The host m_tls's saved session belongs to + // Reassembly of fragmented/chunked binary messages, only touched from the websocket task std::vector m_binReasm; bool m_binReasmActive; diff --git a/components/http/include/http/HTTPRequestManager.h b/components/http/include/http/HTTPRequestManager.h index 1105a7b6..c74fab1b 100644 --- a/components/http/include/http/HTTPRequestManager.h +++ b/components/http/include/http/HTTPRequestManager.h @@ -127,6 +127,7 @@ namespace OpenShock::HTTP { void drop(); esp_http_client_handle_t m_handle; + std::string m_host; // host m_handle was created for; its saved TLS session belongs to it std::vector m_headerKeys; // headers set on the last request, cleared before reuse std::string m_retryAfter; // "Retry-After" response header, captured by eventHandler bool m_connectionClose; // server sent "Connection: close" diff --git a/components/http/src/HTTPRequestManager.cpp b/components/http/src/HTTPRequestManager.cpp index a94f49bd..a59bdbac 100644 --- a/components/http/src/HTTPRequestManager.cpp +++ b/components/http/src/HTTPRequestManager.cpp @@ -99,6 +99,7 @@ static std::shared_ptr createRateLimiterForURL(std::stri HTTP::Client::Client() noexcept : m_handle(nullptr) + , m_host() , m_headerKeys() , m_retryAfter() , m_connectionClose(false) @@ -139,6 +140,7 @@ void HTTP::Client::drop() esp_http_client_cleanup(m_handle); m_handle = nullptr; } + m_host.clear(); m_headerKeys.clear(); } @@ -166,9 +168,17 @@ HTTP::Response return begin + timeoutMs - OpenShock::millis(); }; + // The handle keeps the TLS session of its last connection and offers it on every reconnect, so a server that closed + // the kept-alive socket costs a resumed handshake instead of a full one. esp_http_client offers it to whatever host + // the URL names, so a handle is only reused for the host it was created for. + std::string_view host = getHostFromURL(url); + if (m_handle != nullptr && !OpenShock::StringIEquals(m_host, host)) { + drop(); + } + // Open the connection, reusing the kept-alive handle when possible. If a - // reused (kept-alive) socket has been closed by the server, drop it and - // reconnect fresh once. HTTPS servers are verified against the compiled-in + // reused (kept-alive) socket has been closed by the server, close it and + // reconnect once on the same handle. HTTPS servers are verified against the compiled-in // CA bundle via config.crt_bundle_attach below. esp_err_t err = ESP_FAIL; int64_t contentLength = -1; @@ -185,12 +195,14 @@ HTTP::Response config.event_handler = &Client::eventHandler; config.user_data = this; config.crt_bundle_attach = esp_crt_bundle_attach; // verify HTTPS servers against the compiled-in CA bundle + config.save_client_session = true; // resume the TLS session when reconnecting, see above m_handle = esp_http_client_init(&config); if (m_handle == nullptr) { OS_LOGE(TAG, "Failed to initialize HTTP client"); return {RequestResult::RequestFailed, 0, 0}; } + m_host.assign(host); } else { esp_http_client_set_url(m_handle, urlStr.c_str()); esp_http_client_set_method(m_handle, HTTP_METHOD_GET); @@ -224,11 +236,13 @@ HTTP::Response err = ESP_FAIL; } - drop(); - if (!reused) { + if (!reused || attempt > 0) { + drop(); OS_LOGE(TAG, "Failed to send HTTP request: %s", esp_err_to_name(err)); return {RequestResult::RequestFailed, 0, 0}; } + // Closed, not dropped: the next open reconnects on the same handle and resumes its TLS session + esp_http_client_close(m_handle); OS_LOGD(TAG, "Reused connection failed, reconnecting"); } if (err != ESP_OK) { @@ -283,7 +297,7 @@ HTTP::Response } else if (!chunked) { // No Content-Length and not chunked => empty body. Nothing to drain. if (m_connectionClose) { - drop(); + esp_http_client_close(m_handle); // The next request reconnects on this handle, resuming its TLS session } return {RequestResult::Success, responseCode, 0}; } @@ -345,9 +359,10 @@ HTTP::Response return fail(result, responseCode, totalWritten); // connection state uncertain } - // Success: keep the connection alive for the next request unless the server closed it. + // Success: keep the connection alive for the next request unless the server closed it. Even then the handle stays, + // so the next request reconnects on it and resumes its TLS session. if (m_connectionClose) { - drop(); + esp_http_client_close(m_handle); } return {result, responseCode, totalWritten}; diff --git a/sdkconfig.defaults b/sdkconfig.defaults index f61db3f9..26824611 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -94,6 +94,11 @@ CONFIG_LWIP_MAX_SOCKETS=16 CONFIG_LWIP_TCP_WND_DEFAULT=16384 CONFIG_LWIP_TCP_RECVMBOX_SIZE=16 +# Resume TLS sessions: a reconnect then skips certificate chain verification and the key exchange, about a second +# of CPU each. GatewayClient and HTTP::Client keep the session of their last connection and only offer it to the host +# that issued it. +CONFIG_ESP_TLS_CLIENT_SESSION_TICKETS=y + # The gateway websocket and the kept-alive HTTP client each hold a TLS session. With static # buffers every session keeps a 16 KB input + 4 KB output record buffer for its whole life; # dynamic buffers allocate them per record and shrink while idle, lowering the heap peak From bd7b5012ddebe8861f0cad6f0442fd819e36e8e3 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 3 Nov 2026 09:36:11 +0100 Subject: [PATCH 08/18] perf: raise the HTTP client and gateway websocket buffers esp_http_client caps each transport read at its receive buffer, 512 bytes by default, so a 4 KB body read was eight TLS reads and parser passes. It is now CONFIG_OPENSHOCK_HTTP_RX_BUFFER_SIZE, 2 KB. esp_websocket_client delivers a message larger than its 1 KB buffer in pieces, which GatewayClient reassembles into a copy. CONFIG_OPENSHOCK_GATEWAY_WS_BUFFER_SIZE, 2 KB, keeps more messages on the in-place path. The client allocates a receive and a send buffer of that size, so this costs 2 KB of heap per connection; the HTTP buffer costs 1.5 KB per client. --- components/core/Kconfig | 9 +++++++++ components/core/src/GatewayClient.cpp | 1 + components/http/Kconfig | 8 ++++++++ components/http/src/HTTPRequestManager.cpp | 2 ++ 4 files changed, 20 insertions(+) diff --git a/components/core/Kconfig b/components/core/Kconfig index 4cb51671..cb4da49d 100644 --- a/components/core/Kconfig +++ b/components/core/Kconfig @@ -46,6 +46,15 @@ menu "OpenShock gateway" range 1024 32768 default 4096 + config OPENSHOCK_GATEWAY_WS_BUFFER_SIZE + int "Websocket buffer (bytes)" + range 1024 OPENSHOCK_GATEWAY_MAX_MESSAGE_SIZE + default 2048 + help + esp_websocket_client allocates a receive and a send buffer of this size. A message + larger than it arrives in pieces that are reassembled into a copy; one that fits is + handled in place. + config OPENSHOCK_GATEWAY_PING_TIMEOUT_MS int "Ping timeout (ms)" range 10000 600000 diff --git a/components/core/src/GatewayClient.cpp b/components/core/src/GatewayClient.cpp index aeeb2e2f..ab91a14c 100644 --- a/components/core/src/GatewayClient.cpp +++ b/components/core/src/GatewayClient.cpp @@ -198,6 +198,7 @@ void GatewayClient::connect(const std::string& host, uint16_t port, const std::s config.headers = m_headers.c_str(); config.disable_auto_reconnect = true; // GatewayConnectionManager owns reconnection config.ext_transport = transport; // Verifies the server, see _prepareTransport() + config.buffer_size = CONFIG_OPENSHOCK_GATEWAY_WS_BUFFER_SIZE; config.task_stack = CONFIG_OPENSHOCK_GATEWAY_TASK_STACK_SIZE; // Message handlers (config reads, flash writes, FlatBuffers verification) run on it // TLS work stays on core 0 with the WiFi/lwIP tasks, away from the E-Stop and RF tasks on core 1 config.task_core_id_set = true; diff --git a/components/http/Kconfig b/components/http/Kconfig index da0ea8d2..8be19142 100644 --- a/components/http/Kconfig +++ b/components/http/Kconfig @@ -5,6 +5,14 @@ menu "OpenShock HTTP client" range 512 32768 default 4096 + config OPENSHOCK_HTTP_RX_BUFFER_SIZE + int "Client receive buffer (bytes)" + range 512 16384 + default 2048 + help + esp_http_client's own receive buffer, held for each client's lifetime. Every + transport read is capped at this, so it sets how many TLS reads a download takes. + config OPENSHOCK_HTTP_MAX_STRING_RESPONSE_SIZE int "Largest response read into memory (bytes)" range 4096 262144 diff --git a/components/http/src/HTTPRequestManager.cpp b/components/http/src/HTTPRequestManager.cpp index a59bdbac..598d3a0a 100644 --- a/components/http/src/HTTPRequestManager.cpp +++ b/components/http/src/HTTPRequestManager.cpp @@ -196,6 +196,8 @@ HTTP::Response config.user_data = this; config.crt_bundle_attach = esp_crt_bundle_attach; // verify HTTPS servers against the compiled-in CA bundle config.save_client_session = true; // resume the TLS session when reconnecting, see above + // Each transport read is capped at this; the 512-byte default turns a 4 KB body read into eight TLS reads + config.buffer_size = CONFIG_OPENSHOCK_HTTP_RX_BUFFER_SIZE; m_handle = esp_http_client_init(&config); if (m_handle == nullptr) { From 5dba821b10395a72fb24536bed99ada1045906b4 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 3 Nov 2026 09:40:27 +0100 Subject: [PATCH 09/18] perf: back off gateway reconnects from 2 s instead of a flat 20 s The connect backoff was built with the same first and capped delay and never reset, so every attempt after a drop waited 20 s. It now starts at CONFIG_OPENSHOCK_GATEWAY_CONNECT_RETRY_MIN_MS (2 s) and doubles up to the existing CONFIG_OPENSHOCK_GATEWAY_CONNECT_RETRY_MS cap. It starts over only once a connection has stayed up for the cap, so a gateway that drops every connection right away still gets the capped delay. --- components/core/Kconfig | 10 +++++++++- components/core/src/GatewayConnectionManager.cpp | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/components/core/Kconfig b/components/core/Kconfig index cb4da49d..c953aa6a 100644 --- a/components/core/Kconfig +++ b/components/core/Kconfig @@ -73,10 +73,18 @@ menu "OpenShock gateway" default 300000 config OPENSHOCK_GATEWAY_CONNECT_RETRY_MS - int "Retry between gateway connection attempts (ms)" + int "Retry between gateway connection attempts, cap (ms)" range 1000 600000 default 20000 + config OPENSHOCK_GATEWAY_CONNECT_RETRY_MIN_MS + int "Retry between gateway connection attempts, first (ms)" + range 1000 OPENSHOCK_GATEWAY_CONNECT_RETRY_MS + default 2000 + help + The delay before each connection attempt doubles from this up to the cap. It + starts over once a connection has stayed up for the cap. + config OPENSHOCK_GATEWAY_HUB_INFO_RETRY_MIN_MS int "Hub info fetch retry, first (ms)" range 1000 600000 diff --git a/components/core/src/GatewayConnectionManager.cpp b/components/core/src/GatewayConnectionManager.cpp index f65cb94e..4f59d294 100644 --- a/components/core/src/GatewayConnectionManager.cpp +++ b/components/core/src/GatewayConnectionManager.cpp @@ -36,8 +36,11 @@ static std::atomic s_flags = 0; // Retry timing, owned by the main task (Update()). Other tasks ask for a reset through s_retryResetRequested. // After the backend rejects the token static OpenShock::Backoff s_authBackoff(CONFIG_OPENSHOCK_GATEWAY_AUTH_RETRY_MS, CONFIG_OPENSHOCK_GATEWAY_AUTH_RETRY_MS); -// Between LCG connection attempts -static OpenShock::Backoff s_connectBackoff(CONFIG_OPENSHOCK_GATEWAY_CONNECT_RETRY_MS, CONFIG_OPENSHOCK_GATEWAY_CONNECT_RETRY_MS); +// Between LCG connection attempts: doubling from the first value up to the cap, so a brief drop reconnects in seconds +static OpenShock::Backoff s_connectBackoff(CONFIG_OPENSHOCK_GATEWAY_CONNECT_RETRY_MIN_MS, CONFIG_OPENSHOCK_GATEWAY_CONNECT_RETRY_MS); +// When the current connection came up (0 while not connected). Only a connection that stayed up for the whole cap +// resets s_connectBackoff, so a gateway that drops every connection right away still gets the capped delay. +static int64_t s_connectedSinceMs = 0; // After a failed hub info fetch: doubling from the first value up to the cap static OpenShock::Backoff s_hubInfoBackoff(CONFIG_OPENSHOCK_GATEWAY_HUB_INFO_RETRY_MIN_MS, CONFIG_OPENSHOCK_GATEWAY_HUB_INFO_RETRY_MAX_MS); static std::atomic s_retryResetRequested = false; @@ -398,8 +401,15 @@ void GatewayConnectionManager::Update() if (client != nullptr) { // Client exists; run its loop and optionally reconnect if (client->loop()) { + int64_t now = OpenShock::millis(); + if (s_connectedSinceMs == 0) { + s_connectedSinceMs = now; + } else if (now - s_connectedSinceMs >= CONFIG_OPENSHOCK_GATEWAY_CONNECT_RETRY_MS) { + s_connectBackoff.reset(); + } return; } + s_connectedSinceMs = 0; StartConnectingToLCG(); return; From 2551e16cd958845a795af20930f2e1c4e14201af Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 3 Nov 2026 09:40:27 +0100 Subject: [PATCH 10/18] perf: raise the TCP send buffer to 16 KB to match the receive window lwIP stops accepting writes once CONFIG_LWIP_TCP_SND_BUF_DEFAULT is unacknowledged, so at the 5760-byte default a portal asset or a burst of websocket messages went out at one round trip per 5.7 KB. Like the window it bounds queued data rather than allocating it, but raises the heap a busy socket can hold. --- sdkconfig.defaults | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 26824611..8a03b7f5 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -93,6 +93,10 @@ CONFIG_LWIP_MAX_SOCKETS=16 # device (PartitionUtils logs the breakdown) before keeping this. CONFIG_LWIP_TCP_WND_DEFAULT=16384 CONFIG_LWIP_TCP_RECVMBOX_SIZE=16 +# The same for sending: lwIP stops accepting writes once this much is unacknowledged, so at the 5760-byte default a +# portal asset or a websocket burst goes out one round trip per 5.7 KB. Like the window it bounds queued data, it is +# not allocated up front. +CONFIG_LWIP_TCP_SND_BUF_DEFAULT=16384 # Resume TLS sessions: a reconnect then skips certificate chain verification and the key exchange, about a second # of CPU each. GatewayClient and HTTP::Client keep the session of their last connection and only offer it to the host From d3382f3d77b28e3e29aeeda373be5de6aa6b2b99 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 3 Nov 2026 09:40:27 +0100 Subject: [PATCH 11/18] perf: turn off Nagle on captive portal sockets httpd only sets TCP_NODELAY around error responses. httpd_ws_send_frame writes a frame's header and payload separately, so Nagle held the payload until the client acknowledged the header, which a delayed-ACK client does up to ~200 ms later. An open_fn now sets TCP_NODELAY on every session. --- components/captive_portal/CMakeLists.txt | 2 +- .../src/captiveportal/CaptivePortalInstance.cpp | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/components/captive_portal/CMakeLists.txt b/components/captive_portal/CMakeLists.txt index 6158ad91..1fda0966 100644 --- a/components/captive_portal/CMakeLists.txt +++ b/components/captive_portal/CMakeLists.txt @@ -21,7 +21,7 @@ idf_component_register( PRIV_REQUIRES core device_control # wifi/gateway/message handlers/AppHooks; Config::; CommandHandler/EStopManager common chipset logging temporal osjson serialization # foundation utilities, json, HubToLocalMessage fbs dns_server http rfc8908 www # DNSServer, ContentTypes, RFC8908Handler, the www image - esp_http_server esp_netif esp_timer esp_wifi + esp_http_server esp_netif esp_timer esp_wifi lwip ) # Match core: IDF compiles application components with -Werror, the legacy build did not. diff --git a/components/captive_portal/src/captiveportal/CaptivePortalInstance.cpp b/components/captive_portal/src/captiveportal/CaptivePortalInstance.cpp index c397663b..59a888b7 100644 --- a/components/captive_portal/src/captiveportal/CaptivePortalInstance.cpp +++ b/components/captive_portal/src/captiveportal/CaptivePortalInstance.cpp @@ -9,6 +9,8 @@ const char* const TAG = "CaptivePortalInstance"; #include "Logging.h" #include "rfc8908/RFC8908Handler.h" +#include + const uint16_t HTTP_PORT = 80; // httpd needs 3 lwIP sockets besides its client sockets; the DNS server, the gateway connection and an outbound HTTP request need one each static_assert(CONFIG_LWIP_MAX_SOCKETS >= CONFIG_OPENSHOCK_PORTAL_HTTPD_MAX_SOCKETS + 3 + 3, "httpd client sockets would leave no lwIP sockets for the DNS server and outbound connections"); @@ -31,6 +33,17 @@ void CaptivePortal::CaptivePortalInstance::registerHandlers() m_staticFiles.registerHandler(m_server); } +// httpd turns Nagle off only around error responses. A websocket frame goes out as two writes (header, then payload), +// and Nagle holds the second until the first is acknowledged, which a delayed-ACK client sends up to ~200 ms later. +static esp_err_t onSessionOpen(httpd_handle_t /*server*/, int sockfd) +{ + int one = 1; + if (setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)) != 0) { + OS_LOGW(TAG, "Failed to set TCP_NODELAY on fd %d (errno %d)", sockfd, errno); + } + return ESP_OK; // Still usable, just slower +} + bool CaptivePortal::CaptivePortalInstance::startHttpServer() { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); @@ -43,6 +56,7 @@ bool CaptivePortal::CaptivePortalInstance::startHttpServer() config.recv_wait_timeout = CONFIG_OPENSHOCK_PORTAL_HTTPD_TIMEOUT_S; config.send_wait_timeout = CONFIG_OPENSHOCK_PORTAL_HTTPD_TIMEOUT_S; config.uri_match_fn = httpd_uri_match_wildcard; + config.open_fn = onSessionOpen; // Same core as the WiFi/lwIP tasks, away from the E-Stop and RF tasks on core 1 config.core_id = 0; From 4a32ab259bd920040f7b157247db1e8d42e50d30 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 3 Nov 2026 09:40:27 +0100 Subject: [PATCH 12/18] perf: reuse one receive buffer for portal websocket frames wsHandler allocated a vector for every received frame. It runs only on the single httpd task and onWsFrame copies what it keeps, so the endpoint now owns one buffer that grows to the largest frame seen (at most CONFIG_OPENSHOCK_LOCAL_MAX_MESSAGE_SIZE) and lives as long as the portal. --- .../captive_portal/src/captiveportal/WebSocketEndpoint.cpp | 6 +++++- .../captive_portal/src/captiveportal/WebSocketEndpoint.h | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/components/captive_portal/src/captiveportal/WebSocketEndpoint.cpp b/components/captive_portal/src/captiveportal/WebSocketEndpoint.cpp index 1ac80c1c..2c280368 100644 --- a/components/captive_portal/src/captiveportal/WebSocketEndpoint.cpp +++ b/components/captive_portal/src/captiveportal/WebSocketEndpoint.cpp @@ -179,7 +179,11 @@ esp_err_t CaptivePortal::WebSocketEndpoint::wsHandler(httpd_req_t* req) return ESP_FAIL; } - std::vector buf(frame.len); + // onWsFrame copies what it keeps (reassembly), so the buffer is free again once it returns + std::vector& buf = self->m_rxBuf; + if (buf.size() < frame.len) { + buf.resize(frame.len); + } frame.payload = buf.data(); ret = httpd_ws_recv_frame(req, &frame, frame.len); if (ret != ESP_OK) { diff --git a/components/captive_portal/src/captiveportal/WebSocketEndpoint.h b/components/captive_portal/src/captiveportal/WebSocketEndpoint.h index 9f5948be..7baed6cd 100644 --- a/components/captive_portal/src/captiveportal/WebSocketEndpoint.h +++ b/components/captive_portal/src/captiveportal/WebSocketEndpoint.h @@ -76,6 +76,10 @@ namespace OpenShock::CaptivePortal { WsClient m_clients[CONFIG_OPENSHOCK_PORTAL_MAX_CLIENTS] {}; SimpleMutex m_clientsMutex; + // Received frame payloads, reused across frames: only wsHandler, on the single httpd task, touches it. Grows to + // the largest frame seen, at most CONFIG_OPENSHOCK_LOCAL_MAX_MESSAGE_SIZE, and lives as long as the portal. + std::vector m_rxBuf; + struct WsSend { int fd; // -1 to broadcast bool binary; From 0a8f4dac466560f2611d2b26a9ae72ea2e076eb9 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 3 Nov 2026 12:22:13 +0100 Subject: [PATCH 13/18] fix: keep the power-save idle option in core's Kconfig scope, add the activity interval to it device_control named CONFIG_OPENSHOCK_WIFI_POWERSAVE_IDLE_MS in a comment, which the Kconfig ownership check rejects. The activity post interval now lives in events/Events.h, shared by both sides, and WiFiManager adds it to the idle period: the last activity report can trail the last command by that long. The idle ticks are computed in 64 bits, since pdMS_TO_TICKS wraps above about 4.3 million ms. --- components/core/Kconfig | 3 ++- components/core/src/wifi/WiFiManager.cpp | 8 +++++--- components/device_control/src/CommandHandler.cpp | 6 +++--- components/events/include/events/Events.h | 4 ++++ 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/components/core/Kconfig b/components/core/Kconfig index c953aa6a..4e9debf2 100644 --- a/components/core/Kconfig +++ b/components/core/Kconfig @@ -35,7 +35,8 @@ menu "OpenShock WiFi" Modem sleep has the AP hold frames until the next DTIM beacon, delaying each one by up to a beacon interval (typically 100-300 ms). Power save is switched off on the first shocker command and back on after this long without one. The command - handler reports activity at most once a minute, so this stays well above that. + handler reports activity at most once a minute; that minute is added on top, so the + radio stays awake at least this long after the last command. endmenu diff --git a/components/core/src/wifi/WiFiManager.cpp b/components/core/src/wifi/WiFiManager.cpp index bdab5151..5dccde39 100644 --- a/components/core/src/wifi/WiFiManager.cpp +++ b/components/core/src/wifi/WiFiManager.cpp @@ -553,9 +553,11 @@ static void wakeUpdateTask() // Modem sleep (IDF's default WIFI_PS_MIN_MODEM) has the AP hold frames for us until the next DTIM beacon, delaying each // one by up to a beacon interval (typically 100-300 ms). The radio stays awake from the first command until there has // been no activity for CONFIG_OPENSHOCK_WIFI_POWERSAVE_IDLE_MS, so a session's commands arrive at once while an idle hub -// still saves power. The first command of a session still waits for the beacon. CommandHandler refreshes the activity -// at most once a minute. -static constexpr TickType_t kPowerSaveIdleTicks = pdMS_TO_TICKS(CONFIG_OPENSHOCK_WIFI_POWERSAVE_IDLE_MS); +// still saves power. The first command of a session still waits for the beacon. CommandHandler reports activity at most +// once per OPENSHOCK_EVENT_DEVICE_ACTIVITY_INTERVAL_MS, so the last report can trail the last command by that much: add +// it, so the radio never sleeps before the full idle period after the last command. Computed in 64 bits: +// pdMS_TO_TICKS multiplies in TickType_t, which wraps above about 4.3 million ms at a 1 kHz tick. +static constexpr TickType_t kPowerSaveIdleTicks = static_cast((static_cast(CONFIG_OPENSHOCK_WIFI_POWERSAVE_IDLE_MS) + OPENSHOCK_EVENT_DEVICE_ACTIVITY_INTERVAL_MS) * configTICK_RATE_HZ / 1000); // Set by the event loop on each activity event, consumed by the update task, which alone switches power save so the // switches can't reorder. diff --git a/components/device_control/src/CommandHandler.cpp b/components/device_control/src/CommandHandler.cpp index 52f67882..18277564 100644 --- a/components/device_control/src/CommandHandler.cpp +++ b/components/device_control/src/CommandHandler.cpp @@ -26,9 +26,9 @@ const char* const TAG = "CommandHandler"; #include #include -// WiFiManager keeps the radio out of power save for CONFIG_OPENSHOCK_WIFI_POWERSAVE_IDLE_MS (at least 5 minutes) after -// the last activity event, so a session only needs refreshing now and then, not a broadcast per command. -const int64_t ACTIVITY_POST_INTERVAL = 60'000; +// WiFiManager keeps the radio out of power save for its idle period (at least 5 minutes) after the last command, so a +// session only needs refreshing now and then, not a broadcast per command. +const int64_t ACTIVITY_POST_INTERVAL = OPENSHOCK_EVENT_DEVICE_ACTIVITY_INTERVAL_MS; static uint32_t calculateEepyTime(int64_t timeToKeepAlive) { diff --git a/components/events/include/events/Events.h b/components/events/include/events/Events.h index 56712348..df827937 100644 --- a/components/events/include/events/Events.h +++ b/components/events/include/events/Events.h @@ -15,6 +15,10 @@ enum { OPENSHOCK_EVENT_DEVICE_ACTIVITY, // A shocker command was accepted (no payload) }; +// OPENSHOCK_EVENT_DEVICE_ACTIVITY is posted at most this often, so a listener's view of the last activity can be this +// far behind the last command. +#define OPENSHOCK_EVENT_DEVICE_ACTIVITY_INTERVAL_MS 60000 + // Coarse WiFi station connectivity, posted as the OPENSHOCK_EVENT_WIFI_STATE_CHANGED payload. typedef enum { OPENSHOCK_WIFI_STATE_DISCONNECTED = 0, // Not associated to an AP From 34e23c206e3cca5c33bc1e39b742dbb6931fe4c3 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 3 Nov 2026 12:22:13 +0100 Subject: [PATCH 14/18] fix(gateway): only reset the connect backoff after time spent connected GatewayClient::loop() is also true while connecting, so a long handshake that then failed reset the backoff. --- components/core/src/GatewayConnectionManager.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/core/src/GatewayConnectionManager.cpp b/components/core/src/GatewayConnectionManager.cpp index 4f59d294..d6076c38 100644 --- a/components/core/src/GatewayConnectionManager.cpp +++ b/components/core/src/GatewayConnectionManager.cpp @@ -401,6 +401,12 @@ void GatewayConnectionManager::Update() if (client != nullptr) { // Client exists; run its loop and optionally reconnect if (client->loop()) { + // loop() is also true while connecting: only time spent connected may reset the backoff, or a handshake that + // runs long and then fails would reset it too + if (client->state() != GatewayClientState::Connected) { + s_connectedSinceMs = 0; + return; + } int64_t now = OpenShock::millis(); if (s_connectedSinceMs == 0) { s_connectedSinceMs = now; From 0049ed07a479c73a9b74d693350a2526a97bfb48 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 3 Nov 2026 12:22:13 +0100 Subject: [PATCH 15/18] fix(http): keep bracketed IPv6 hosts whole, clear retry state per attempt --- components/http/src/HTTPRequestManager.cpp | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/components/http/src/HTTPRequestManager.cpp b/components/http/src/HTTPRequestManager.cpp index 598d3a0a..c1ce974e 100644 --- a/components/http/src/HTTPRequestManager.cpp +++ b/components/http/src/HTTPRequestManager.cpp @@ -54,9 +54,11 @@ static std::string_view getHostFromURL(std::string_view url) url = url.substr(0, seperator); } - // Remove the port eg. "api.example.com:443" -> "api.example.com" - seperator = url.rfind(':'); - if (seperator != std::string_view::npos) { + // Remove the port eg. "api.example.com:443" -> "api.example.com". In a bracketed IPv6 host ("[2001:db8::1]:443") + // only a colon after the closing bracket starts the port. + seperator = url.rfind(':'); + const std::size_t closeBracket = url.rfind(']'); + if (seperator != std::string_view::npos && (closeBracket == std::string_view::npos || seperator > closeBracket)) { url = url.substr(0, seperator); } @@ -157,8 +159,6 @@ HTTP::Response } std::string urlStr(url); - m_retryAfter.clear(); - m_connectionClose = false; int64_t begin = OpenShock::millis(); @@ -185,6 +185,10 @@ HTTP::Response for (int attempt = 0; attempt < 2; ++attempt) { bool reused = m_handle != nullptr; + // Headers seen on a failed attempt must not carry over to the retry + m_retryAfter.clear(); + m_connectionClose = false; + if (m_handle == nullptr) { esp_http_client_config_t config = {}; config.url = urlStr.c_str(); From 875b3fabb3e06438aafc20dfef9baef7d8590328 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 3 Nov 2026 12:22:13 +0100 Subject: [PATCH 16/18] chore(ota): count the first progress callback as progress time --- components/ota/src/PartitionUtils.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/ota/src/PartitionUtils.cpp b/components/ota/src/PartitionUtils.cpp index aed1d967..5d5e3e29 100644 --- a/components/ota/src/PartitionUtils.cpp +++ b/components/ota/src/PartitionUtils.cpp @@ -41,7 +41,7 @@ bool OpenShock::FlashPartitionFromUrl( int64_t erase, write, hash, progress; } spent = {}; - auto sizeValidator = [partition, &contentLength, progressCallback, &lastProgress](std::size_t size) -> bool { + auto sizeValidator = [partition, &contentLength, progressCallback, &lastProgress, &spent](std::size_t size) -> bool { if (size > partition->size) { OS_LOGE(TAG, "Remote partition binary is too large"); return false; @@ -51,7 +51,9 @@ bool OpenShock::FlashPartitionFromUrl( contentLength = size; lastProgress = OpenShock::millis(); + int64_t t0 = OpenShock::micros(); progressCallback(0, contentLength, 0.0f); + spent.progress += OpenShock::micros() - t0; return true; }; From dfc3f2d5f0244cbe97a8d2ed1995d83803114d74 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 3 Nov 2026 12:22:13 +0100 Subject: [PATCH 17/18] test(device_control): cover refused sends and failed activity posts --- .../host_test/main/host_fakes.cpp | 9 ++++- .../host_test/main/host_fakes.h | 1 + .../host_test/main/test_command_handler.cpp | 38 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/components/device_control/host_test/main/host_fakes.cpp b/components/device_control/host_test/main/host_fakes.cpp index 42256d5a..9627bc43 100644 --- a/components/device_control/host_test/main/host_fakes.cpp +++ b/components/device_control/host_test/main/host_fakes.cpp @@ -80,6 +80,7 @@ namespace HostFake { OpenShock::Config::EStopConfig EStopCfg = {.enabled = false, .gpioPin = GPIO_NUM_NC}; esp_event_handler_t EStopHandler = nullptr; int ActivityPosts = 0; + bool FailEventPost = false; static std::vector s_liveQueues; // Queues only; mutexes are not counted static std::vector> s_tasks; @@ -95,6 +96,7 @@ namespace HostFake { FailRmtTransmit = false; TaskDelays = 0; ActivityPosts = 0; + FailEventPost = false; Transmissions.clear(); OnIdle = nullptr; OnTransmit = nullptr; @@ -365,9 +367,12 @@ static esp_err_t fakeEventHandlerRegister(esp_event_base_t, int32_t event_id, es return ESP_OK; } -static esp_err_t fakeEventPost(esp_event_base_t, int32_t event_id, const void*, size_t, TickType_t, int) +static esp_err_t fakeEventPost(esp_event_base_t event_base, int32_t event_id, const void*, size_t, TickType_t, int) { - if (event_id == OPENSHOCK_EVENT_DEVICE_ACTIVITY) { + if (FailEventPost) { + return ESP_FAIL; + } + if (event_base == OPENSHOCK_EVENTS && event_id == OPENSHOCK_EVENT_DEVICE_ACTIVITY) { ++ActivityPosts; } return ESP_OK; diff --git a/components/device_control/host_test/main/host_fakes.h b/components/device_control/host_test/main/host_fakes.h index 8105eae6..6f8a0ef6 100644 --- a/components/device_control/host_test/main/host_fakes.h +++ b/components/device_control/host_test/main/host_fakes.h @@ -47,6 +47,7 @@ namespace HostFake { extern OpenShock::Config::EStopConfig EStopCfg; extern esp_event_handler_t EStopHandler; // Handler registered for OPENSHOCK_EVENT_ESTOP_STATE_CHANGED extern int ActivityPosts; // OPENSHOCK_EVENT_DEVICE_ACTIVITY posts since Reset() + extern bool FailEventPost; // esp_event_post fails (and counts nothing) while set /// @brief Clears recorded frames and hooks and releases the e-stop. Queues and tasks are owned by the code under test. void Reset(); diff --git a/components/device_control/host_test/main/test_command_handler.cpp b/components/device_control/host_test/main/test_command_handler.cpp index 8474f2d7..c45e5aa5 100644 --- a/components/device_control/host_test/main/test_command_handler.cpp +++ b/components/device_control/host_test/main/test_command_handler.cpp @@ -94,6 +94,44 @@ TEST_CASE("CommandHandler reports activity at most once a minute", "[device_cont HostFake::RunTask(transmitTask(), kBudget); // Send what was queued so the next test starts with an empty queue } +TEST_CASE("CommandHandler posts no activity when the transmit queue rejects a command", "[device_control][command]") +{ + resetCommandHandler(); + HostFake::Now += 60'000; // Clear the throttle window left by earlier tests + + // Fill the transmit queue until a command is rejected + int accepted = 0; + while (CommandHandler::HandleCommand(kModel, kShockerId + accepted, ShockerCommandType::Vibrate, 10, 300)) { + accepted++; + } + TEST_ASSERT_GREATER_THAN(0, accepted); + + // Past the throttle window, so an unconditional post would not be suppressed + HostFake::Now += 60'000; + const int postsBefore = HostFake::ActivityPosts; + TEST_ASSERT_FALSE(CommandHandler::HandleCommand(kModel, kShockerId + accepted, ShockerCommandType::Vibrate, 10, 300)); + TEST_ASSERT_EQUAL(postsBefore, HostFake::ActivityPosts); + + HostFake::RunTask(transmitTask(), kBudget); // Drain the queue for the next test +} + +TEST_CASE("CommandHandler reports activity again after a failed event post", "[device_control][command]") +{ + resetCommandHandler(); + HostFake::Now += 60'000; // Clear the throttle window left by earlier tests + + HostFake::FailEventPost = true; + TEST_ASSERT_TRUE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Vibrate, 10, 300)); + TEST_ASSERT_EQUAL(0, HostFake::ActivityPosts); + + // The failed post did not start the throttle window: the next command posts right away + HostFake::FailEventPost = false; + TEST_ASSERT_TRUE(CommandHandler::HandleCommand(kModel, kShockerId, ShockerCommandType::Vibrate, 10, 300)); + TEST_ASSERT_EQUAL(1, HostFake::ActivityPosts); + + HostFake::RunTask(transmitTask(), kBudget); +} + TEST_CASE("CommandHandler refuses commands while e-stopped", "[device_control][command][estop]") { resetCommandHandler(); From d24202ea479a74d6d6435c076508dd894ff4445f Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Tue, 3 Nov 2026 12:22:14 +0100 Subject: [PATCH 18/18] chore(changes): add change file for hub latency --- .changes/hub-latency.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changes/hub-latency.md diff --git a/.changes/hub-latency.md b/.changes/hub-latency.md new file mode 100644 index 00000000..49080c58 --- /dev/null +++ b/.changes/hub-latency.md @@ -0,0 +1,16 @@ +--- +kind: changed +--- +Cut network latency between the hub, the gateway and the setup portal + +## Release Note +Faster response to commands + +Commands from the app reach your shockers sooner. While you are using the hub its WiFi radio stays awake instead of waiting for the access point's next beacon, reconnections are quicker, and the connection buffers are larger. + +- WiFi power save is switched off on the first command and back on after an idle period (`CONFIG_OPENSHOCK_WIFI_POWERSAVE_IDLE_MS`, default 1 hour) +- RF commands are picked up between frames instead of between rounds +- TLS sessions are resumed when reconnecting to the gateway and HTTP hosts +- Gateway reconnects back off from 2 s instead of a flat 20 s +- Larger HTTP client, gateway websocket and TCP send/receive buffers (16 KB window); Nagle is off on portal sockets +- Read-only littlefs mounts are read through a flash mapping