Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b5b738a
perf: keep the WiFi radio awake while the hub is in use
hhvrc Oct 29, 2026
6b86a2c
perf: pick up RF commands between frames instead of between rounds
hhvrc Oct 29, 2026
f42d283
perf: read read-only littlefs mounts through a flash mapping
hhvrc Oct 29, 2026
29996dd
chore: log where flashing a partition from a URL spends its time
hhvrc Oct 29, 2026
3ee53bb
perf: raise the TCP receive window to 16 KB
hhvrc Oct 29, 2026
356b441
Merge remote-tracking branch 'origin/develop' into perf/hub-latency
hhvrc Nov 3, 2026
259bbe1
refactor: make the WiFi power save idle timeout a Kconfig option
hhvrc Nov 3, 2026
a21883b
perf: resume TLS sessions when reconnecting to the gateway and HTTP h…
hhvrc Nov 3, 2026
bd7b501
perf: raise the HTTP client and gateway websocket buffers
hhvrc Nov 3, 2026
5dba821
perf: back off gateway reconnects from 2 s instead of a flat 20 s
hhvrc Nov 3, 2026
2551e16
perf: raise the TCP send buffer to 16 KB to match the receive window
hhvrc Nov 3, 2026
d3382f3
perf: turn off Nagle on captive portal sockets
hhvrc Nov 3, 2026
4a32ab2
perf: reuse one receive buffer for portal websocket frames
hhvrc Nov 3, 2026
0a8f4da
fix: keep the power-save idle option in core's Kconfig scope, add the…
hhvrc Nov 3, 2026
34e23c2
fix(gateway): only reset the connect backoff after time spent connected
hhvrc Nov 3, 2026
0049ed0
fix(http): keep bracketed IPv6 hosts whole, clear retry state per att…
hhvrc Nov 3, 2026
875b3fa
chore(ota): count the first progress callback as progress time
hhvrc Nov 3, 2026
dfc3f2d
test(device_control): cover refused sends and failed activity posts
hhvrc Nov 3, 2026
d24202e
chore(changes): add change file for hub latency
hhvrc Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changes/hub-latency.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion components/captive_portal/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const char* const TAG = "CaptivePortalInstance";
#include "Logging.h"
#include "rfc8908/RFC8908Handler.h"

#include <lwip/sockets.h>

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");
Expand All @@ -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();
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,11 @@ esp_err_t CaptivePortal::WebSocketEndpoint::wsHandler(httpd_req_t* req)
return ESP_FAIL;
}

std::vector<uint8_t> buf(frame.len);
// onWsFrame copies what it keeps (reassembly), so the buffer is free again once it returns
std::vector<uint8_t>& 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint8_t> m_rxBuf;

struct WsSend {
int fd; // -1 to broadcast
bool binary;
Expand Down
1 change: 1 addition & 0 deletions components/core/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
30 changes: 29 additions & 1 deletion components/core/Kconfig
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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; that minute is added on top, so the
radio stays awake at least this long after the last command.

endmenu

menu "OpenShock gateway"
Expand All @@ -36,6 +47,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
Expand All @@ -54,10 +74,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
Expand Down
81 changes: 80 additions & 1 deletion components/core/src/GatewayClient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const char* const TAG = "GatewayClient";
#include "Temporal.h"

#include <esp_crt_bundle.h>
#include <esp_transport_ssl.h>
#include <esp_transport_ws.h>

#include <cstring>

Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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;
Expand All @@ -121,7 +197,8 @@ 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.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;
Expand Down Expand Up @@ -276,6 +353,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();
Expand Down
9 changes: 9 additions & 0 deletions components/core/src/GatewayClient.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include "SimpleMutex.h"

#include <esp_event.h>
#include <esp_transport.h>
#include <esp_websocket_client.h>

#include <atomic>
Expand Down Expand Up @@ -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
Expand All @@ -58,6 +61,12 @@ namespace OpenShock {
std::atomic<int64_t> m_lastPingTimestamp;
std::atomic<bool> 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<uint8_t> m_binReasm;
bool m_binReasmActive;
Expand Down
20 changes: 18 additions & 2 deletions components/core/src/GatewayConnectionManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ static std::atomic<uint8_t> 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<bool> s_retryResetRequested = false;
Expand Down Expand Up @@ -398,8 +401,21 @@ 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;
} else if (now - s_connectedSinceMs >= CONFIG_OPENSHOCK_GATEWAY_CONNECT_RETRY_MS) {
s_connectBackoff.reset();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
return;
}
s_connectedSinceMs = 0;

StartConnectingToLCG();
return;
Expand Down
Loading
Loading