From 6d070c0aa4b54a505da9b7f391aec7a833b2a668 Mon Sep 17 00:00:00 2001 From: tunapro Date: Fri, 6 Feb 2026 23:54:14 +0300 Subject: [PATCH 1/3] feat: add WebSocket joystick channel with HTTP fallback Binary protocol over WS port 81 for low-latency joystick input. Falls back to HTTP POST if WS is unavailable. Co-Authored-By: Claude Opus 4.6 --- VERSION | 2 +- idf_component.yml | 2 +- library.json | 2 +- library.properties | 2 +- .../esp32s3/driver_station_esp32.hpp | 5 +- src/driverstation/esp32s3/index_html.h | 52 +++++++- src/driverstation/esp32s3/ws_joystick.hpp | 120 ++++++++++++++++++ 7 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 src/driverstation/esp32s3/ws_joystick.hpp diff --git a/VERSION b/VERSION index ee1372d..abd4105 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.2 +0.2.4 diff --git a/idf_component.yml b/idf_component.yml index 5469b5d..f7e8cf0 100644 --- a/idf_component.yml +++ b/idf_component.yml @@ -1,4 +1,4 @@ -version: 0.2.2 +version: 0.2.4 description: ESP32-S3 robotics control library url: https://github.com/nfrproducts/probot-lib dependencies: diff --git a/library.json b/library.json index 2ac5ab9..32899a6 100644 --- a/library.json +++ b/library.json @@ -26,5 +26,5 @@ "type": "git", "url": "https://github.com/nfrproducts/probot-lib" }, - "version": "0.2.2" + "version": "0.2.4" } diff --git a/library.properties b/library.properties index b71fdac..cd9d516 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,5 @@ name=probot -version=0.2.2 +version=0.2.4 author=Tuna Gül maintainer=Tuna Gül sentence=ProBot Library for Robotics Competitions. diff --git a/src/driverstation/esp32s3/driver_station_esp32.hpp b/src/driverstation/esp32s3/driver_station_esp32.hpp index 65c8168..eac6193 100644 --- a/src/driverstation/esp32s3/driver_station_esp32.hpp +++ b/src/driverstation/esp32s3/driver_station_esp32.hpp @@ -7,6 +7,7 @@ #include #include #include "index_html.h" +#include "ws_joystick.hpp" #ifndef PROBOT_WIFI_AP_PASSWORD #error "DriverStation AP password not provided. Define PROBOT_WIFI_AP_PASSWORD (>=8 chars) before including probot.h." @@ -31,7 +32,7 @@ namespace probot::driverstation::esp32 { class DriverStation { public: DriverStation(robot::StateService& rs, io::GamepadService& gs) - : _rs(rs), _gs(gs), _server(80) {} + : _rs(rs), _gs(gs), _ws(gs), _server(80) {} void begin(){ const char* pw = PROBOT_WIFI_AP_PASSWORD; @@ -63,6 +64,7 @@ namespace probot::driverstation::esp32 { _server.on("/getBattery", HTTP_GET, [this](){ handleGetBattery(); }); _server.on("/telemetry", HTTP_GET, [this](){ if (!enforceOwner()) return; handleTelemetry(); }); _server.begin(); + _ws.begin(81); } void handleClient(){ @@ -203,6 +205,7 @@ namespace probot::driverstation::esp32 { robot::StateService& _rs; io::GamepadService& _gs; + WsJoystick _ws; WebServer _server; bool _owner_set=false; IPAddress _owner; diff --git a/src/driverstation/esp32s3/index_html.h b/src/driverstation/esp32s3/index_html.h index d07c660..612234c 100644 --- a/src/driverstation/esp32s3/index_html.h +++ b/src/driverstation/esp32s3/index_html.h @@ -996,13 +996,62 @@ function stopAutoTimer(){ let gamepadSending=false; let lastGamepadSend=0; const GAMEPAD_SEND_INTERVAL=20; // 50Hz max + + // WebSocket joystick channel + let wsJoystick=null; + let wsConnected=false; + let wsReconnectTimer=null; + + function connectWebSocket(){ + if(wsJoystick && wsJoystick.readyState<=1) return; + try{ + wsJoystick=new WebSocket(`ws://${location.hostname}:81/joystick`); + wsJoystick.binaryType='arraybuffer'; + wsJoystick.onopen=()=>{wsConnected=true;console.log('[WS] Connected');}; + wsJoystick.onclose=()=>{wsConnected=false;console.log('[WS] Closed');scheduleReconnect();}; + wsJoystick.onerror=()=>{wsConnected=false;}; + }catch(e){wsConnected=false;scheduleReconnect();} + } + function scheduleReconnect(){ + if(wsReconnectTimer) return; + wsReconnectTimer=setTimeout(()=>{wsReconnectTimer=null;connectWebSocket();},2000); + } + function packJoystickBinary(gp){ + const nA=gp.axes.length; + const nB=gp.buttons.length; + const btnBytes=Math.ceil(nB/8); + const buf=new ArrayBuffer(4+nA*2+btnBytes); + const view=new DataView(buf); + view.setUint8(0,0x4A); + view.setUint8(1,nA); + view.setUint8(2,nB); + view.setUint8(3,0); + for(let i=0;ib.pressed)}; try{ + if(wsConnected && wsJoystick && wsJoystick.readyState===1){ + try{ + wsJoystick.send(packJoystickBinary(gp)); + gamepadSending=false; + return; + }catch(e){wsConnected=false;scheduleReconnect();} + } + const data={axes:Array.from(gp.axes),buttons:gp.buttons.map(b=>b.pressed)}; await fetch("/updateController",{ method:"POST", headers:{"Content-Type":"application/json"}, @@ -1060,6 +1109,7 @@ function stopAutoTimer(){ setPhaseDisplay('standby'); requestAnimationFrame(gamepadLoop); syncState(); + connectWebSocket(); }); document.getElementById('autoPeriod').addEventListener('input',e=>{ diff --git a/src/driverstation/esp32s3/ws_joystick.hpp b/src/driverstation/esp32s3/ws_joystick.hpp new file mode 100644 index 0000000..bcddb78 --- /dev/null +++ b/src/driverstation/esp32s3/ws_joystick.hpp @@ -0,0 +1,120 @@ +#pragma once +#ifdef ESP32 +#include +#include + +namespace probot::driverstation::esp32 { + + /** + * WebSocket joystick server (ESP-IDF httpd, port 81) + * + * Binary frame format: + * [0] uint8 0x4A ('J' magic) + * [1] uint8 axisCount (max 20) + * [2] uint8 buttonCount (max 20) + * [3] uint8 reserved + * [4..] int16[] axes (big-endian, value = float * 32767) + * [4+nA*2] uint8[] buttons (packed bits, LSB first) + */ + class WsJoystick { + public: + explicit WsJoystick(io::GamepadService& gs) : _gs(gs) {} + + void begin(uint16_t port = 81) { + httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); + cfg.server_port = port; + cfg.ctrl_port = port + 1; + cfg.stack_size = 4096; + + if (httpd_start(&_server, &cfg) != ESP_OK) { + Serial.println("[WS ] Failed to start WebSocket server"); + return; + } + + httpd_uri_t ws_uri = { + .uri = "/joystick", + .method = HTTP_GET, + .handler = wsHandler, + .user_ctx = this, + .is_websocket = true, + .handle_ws_control_frames = true, + }; + httpd_register_uri_handler(_server, &ws_uri); + + Serial.print("[WS ] WebSocket server on port "); + Serial.println(port); + } + + private: + static constexpr uint8_t MAGIC = 0x4A; + static constexpr uint32_t MAX_AXES = 20; + static constexpr uint32_t MAX_BTNS = 20; + static constexpr size_t MAX_FRAME = 4 + MAX_AXES * 2 + (MAX_BTNS + 7) / 8; + + static esp_err_t wsHandler(httpd_req_t* req) { + if (req->method == HTTP_GET) { + return ESP_OK; // WS handshake — just accept + } + + auto* self = static_cast(req->user_ctx); + + // Step 1: 0-length receive to learn frame size & type + httpd_ws_frame_t frame = {}; + frame.payload = nullptr; + esp_err_t ret = httpd_ws_recv_frame(req, &frame, 0); + if (ret != ESP_OK) return ret; + + // Handle control frames + if (frame.type == HTTPD_WS_TYPE_CLOSE) return ESP_OK; + if (frame.type == HTTPD_WS_TYPE_PING) { + httpd_ws_frame_t pong = {}; + pong.type = HTTPD_WS_TYPE_PONG; + return httpd_ws_send_frame(req, &pong); + } + if (frame.type != HTTPD_WS_TYPE_BINARY) return ESP_OK; + + // Step 2: bounds check, then receive payload + if (frame.len == 0 || frame.len > MAX_FRAME) return ESP_OK; + + uint8_t buf[MAX_FRAME]; + frame.payload = buf; + ret = httpd_ws_recv_frame(req, &frame, frame.len); + if (ret != ESP_OK) return ret; + + self->parseFrame(buf, frame.len); + return ESP_OK; + } + + void parseFrame(const uint8_t* data, size_t len) { + if (len < 4 || data[0] != MAGIC) return; + + uint32_t nA = data[1]; + uint32_t nB = data[2]; + if (nA > MAX_AXES) nA = MAX_AXES; + if (nB > MAX_BTNS) nB = MAX_BTNS; + + size_t expected = 4 + nA * 2 + (nB + 7) / 8; + if (len < expected) return; + + float axes[MAX_AXES]; + const uint8_t* p = data + 4; + for (uint32_t i = 0; i < nA; i++) { + int16_t raw = static_cast((p[0] << 8) | p[1]); + axes[i] = static_cast(raw) / 32767.0f; + p += 2; + } + + bool buttons[MAX_BTNS]; + for (uint32_t i = 0; i < nB; i++) { + buttons[i] = (p[i / 8] >> (i % 8)) & 1; + } + + _gs.write(millis(), axes, nA, buttons, nB); + } + + io::GamepadService& _gs; + httpd_handle_t _server = nullptr; + }; + +} +#endif // ESP32 From a5e1643239e85a07a4af99995a2c3c303dd9ffa7 Mon Sep 17 00:00:00 2001 From: tunapro Date: Sat, 7 Feb 2026 00:40:38 +0300 Subject: [PATCH 2/3] fix: replace 802.11b-only with HT20 bandwidth and disable power save Co-Authored-By: Claude Opus 4.6 --- src/driverstation/esp32s3/driver_station_esp32.hpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/driverstation/esp32s3/driver_station_esp32.hpp b/src/driverstation/esp32s3/driver_station_esp32.hpp index eac6193..7600544 100644 --- a/src/driverstation/esp32s3/driver_station_esp32.hpp +++ b/src/driverstation/esp32s3/driver_station_esp32.hpp @@ -1,6 +1,7 @@ #pragma once #ifdef ESP32 #include +#include #include #include #include @@ -45,6 +46,8 @@ namespace probot::driverstation::esp32 { ap_ssid_ = ssid; WiFi.mode(WIFI_AP); WiFi.softAP(ssid.c_str(), pw, PROBOT_WIFI_AP_CHANNEL); + esp_wifi_set_bandwidth(WIFI_IF_AP, WIFI_BW_HT20); + esp_wifi_set_ps(WIFI_PS_NONE); Serial.println("[DS ] ========================================"); Serial.print("[DS ] WiFi SSID: "); From 878db8a8e2966802b5dc1fefea0c149535ec4f06 Mon Sep 17 00:00:00 2001 From: tunapro Date: Sat, 7 Feb 2026 00:50:16 +0300 Subject: [PATCH 3/3] chore: increase gamepad timeout from 300ms to 500ms Co-Authored-By: Claude Opus 4.6 --- src/probot/io/gamepad.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/probot/io/gamepad.hpp b/src/probot/io/gamepad.hpp index a4643ad..01c080b 100644 --- a/src/probot/io/gamepad.hpp +++ b/src/probot/io/gamepad.hpp @@ -25,7 +25,7 @@ namespace probot::io { GamepadSnapshot z{}; _buf[0] = z; _buf[1] = z; - _timeout_ms = 300; + _timeout_ms = 500; } void write(uint32_t now_ms, const float* axes, uint32_t nAxis, const bool* buttons, uint32_t nButton){