From 1357eb242d215778e2593f039b5d280b8f7473d9 Mon Sep 17 00:00:00 2001 From: tunapro Date: Sat, 18 Apr 2026 15:19:43 +0300 Subject: [PATCH 01/27] feat(ws): tolerate 3 consecutive ping failures before closing A single failed ws_send_frame_async used to close the WebSocket connection, which caused spurious disconnects in noisy RF environments where one lost frame is routine. Track per-fd consecutive failure counts; close only after 3 in a row. Successful send resets the counter. Stale fds are pruned on each tick. --- src/driverstation/esp32s3/ws_joystick.hpp | 41 +++++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/driverstation/esp32s3/ws_joystick.hpp b/src/driverstation/esp32s3/ws_joystick.hpp index a668d88..38a0f6c 100644 --- a/src/driverstation/esp32s3/ws_joystick.hpp +++ b/src/driverstation/esp32s3/ws_joystick.hpp @@ -59,6 +59,14 @@ namespace probot::driverstation::esp32 { static constexpr uint32_t MAX_BTNS = 20; static constexpr size_t MAX_FRAME = 4 + MAX_AXES * 2 + (MAX_BTNS + 7) / 8; + // Close a WS session only after this many consecutive ping send + // failures. Tolerates brief RF hiccups in noisy environments (a + // single lost frame used to close the connection). + static constexpr uint8_t PING_MAX_FAILS = 3; + static constexpr uint8_t PING_TRACK_SLOTS = 8; + + struct PingState { int fd = -1; uint8_t fails = 0; }; + static esp_err_t wsHandler(httpd_req_t* req) { if (req->method == HTTP_GET) { return ESP_OK; // WS handshake — just accept @@ -121,6 +129,16 @@ namespace probot::driverstation::esp32 { __atomic_store_n(&probot::robot::g_ds_last_activity_ms, millis(), __ATOMIC_SEQ_CST); } + uint8_t* trackPingFd(int fd) { + for (auto& s : _pingState) if (s.fd == fd) return &s.fails; + for (auto& s : _pingState) if (s.fd == -1) { s.fd = fd; s.fails = 0; return &s.fails; } + return nullptr; + } + + void clearPingFd(int fd) { + for (auto& s : _pingState) if (s.fd == fd) { s.fd = -1; s.fails = 0; } + } + static void pingTimerCb(TimerHandle_t t) { auto* self = static_cast(pvTimerGetTimerID(t)); if (!self->_server) return; @@ -129,12 +147,28 @@ namespace probot::driverstation::esp32 { size_t fds = 8; int clients[8]; if (httpd_get_client_list(self->_server, &fds, clients) != ESP_OK) return; + + // Prune tracked fds that are no longer WS clients + for (auto& s : self->_pingState) { + if (s.fd == -1) continue; + if (httpd_ws_get_fd_info(self->_server, s.fd) != HTTPD_WS_CLIENT_WEBSOCKET) { + s.fd = -1; s.fails = 0; + } + } + for (size_t i = 0; i < fds; i++) { - if (httpd_ws_get_fd_info(self->_server, clients[i]) == HTTPD_WS_CLIENT_WEBSOCKET) { - esp_err_t err = httpd_ws_send_frame_async(self->_server, clients[i], &ping); - if (err != ESP_OK) { + if (httpd_ws_get_fd_info(self->_server, clients[i]) != HTTPD_WS_CLIENT_WEBSOCKET) continue; + uint8_t* fails = self->trackPingFd(clients[i]); + esp_err_t err = httpd_ws_send_frame_async(self->_server, clients[i], &ping); + if (err != ESP_OK) { + if (fails && ++(*fails) >= PING_MAX_FAILS) { + Serial.printf("[WS ] /joystick ping fail x%u, closing fd=%d\n", + (unsigned)*fails, clients[i]); httpd_sess_trigger_close(self->_server, clients[i]); + self->clearPingFd(clients[i]); } + } else if (fails) { + *fails = 0; } } } @@ -142,6 +176,7 @@ namespace probot::driverstation::esp32 { io::GamepadService& _gs; httpd_handle_t _server = nullptr; TimerHandle_t _pingTimer = nullptr; + PingState _pingState[PING_TRACK_SLOTS] = {}; }; } From 56b62390c25f801eb7a3dbf7caddb58d3b0f777c Mon Sep 17 00:00:00 2001 From: tunapro Date: Sat, 18 Apr 2026 15:20:44 +0300 Subject: [PATCH 02/27] feat(ds): make /health and /info owner-free monitoring endpoints Previously /health required the caller to be the DS owner, which meant judges/referees and side-monitoring stations could not observe robot liveness while an active driver was connected. They'd either get 403 or have to steal the ownership slot mid-match. Drop enforceOwner() from handleHealth and handleInfo. Also remove the password field from /info since it is now publicly readable by anything on the AP; passwords should not leak to other teams' scans. --- .../esp32s3/driver_station_esp32.hpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/driverstation/esp32s3/driver_station_esp32.hpp b/src/driverstation/esp32s3/driver_station_esp32.hpp index c2f05b4..352f8a6 100644 --- a/src/driverstation/esp32s3/driver_station_esp32.hpp +++ b/src/driverstation/esp32s3/driver_station_esp32.hpp @@ -368,9 +368,11 @@ namespace probot::driverstation::esp32 { return ESP_OK; } + // /health and /info are OPEN (no owner enforcement). + // Monitoring stations (judge/referee) need to observe robot liveness + // without grabbing the DS ownership slot away from the active driver. static esp_err_t handleHealth(httpd_req_t* req) { auto* ds = self(req); - if (!ds->enforceOwner(req)) return ESP_OK; int8_t rssi = -100; wifi_sta_list_t sta_list; @@ -393,20 +395,21 @@ namespace probot::driverstation::esp32 { static esp_err_t handleInfo(httpd_req_t* req) { auto* ds = self(req); - if (!ds->enforceOwner(req)) return ESP_OK; + // /info is now open (no owner check) so the password field is + // omitted — anyone connected already has the password; we don't + // want other teams' monitoring stations harvesting it. char buf[512]; snprintf(buf, sizeof(buf), - "{\"ssid\":\"%s\",\"ch\":%d,\"pw\":\"%s\",\"ip\":\"%s\"," - "\"chip\":\"%s\",\"cpuMhz\":%u,\"sdk\":\"%s\"," + "{\"ssid\":\"%s\",\"ch\":%d,\"ip\":\"%s\"," + "\"chip\":\"%s\",\"cpuMhz\":%lu,\"sdk\":\"%s\"," "\"totalHeap\":%lu,\"totalFlash\":%lu," "\"sketchSize\":%lu,\"freeSketch\":%lu,\"psram\":%lu}", ds->ap_ssid_.c_str(), PROBOT_WIFI_AP_CHANNEL, - PROBOT_WIFI_AP_PASSWORD, WiFi.softAPIP().toString().c_str(), ESP.getChipModel(), - ESP.getCpuFreqMHz(), + (unsigned long)ESP.getCpuFreqMHz(), ESP.getSdkVersion(), (unsigned long)ESP.getHeapSize(), (unsigned long)ESP.getFlashChipSize(), From c84c5ce3527eb2884fa487f040af867ab80c48b6 Mon Sep 17 00:00:00 2001 From: tunapro Date: Sat, 18 Apr 2026 15:21:12 +0300 Subject: [PATCH 03/27] feat(ds): zero gamepad state on owner release When the DS owner is released (idle timeout, force disconnect, or a new client claims the slot), the gamepad buffer kept its last known values. User teleop loops reading getA() / getRawAxis() would see the last button press frozen, causing motors to keep whatever command was current when the link died. Call _gs.write(now, null, 0, null, 0) in releaseOwner() to zero the state so reads return neutral regardless of the driver's last frame. --- 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 352f8a6..a24f001 100644 --- a/src/driverstation/esp32s3/driver_station_esp32.hpp +++ b/src/driverstation/esp32s3/driver_station_esp32.hpp @@ -203,6 +203,9 @@ namespace probot::driverstation::esp32 { Serial.printf("[DS ] Owner released: %s\n", _owner_str); _owner_set = false; _owner_str[0] = '\0'; + // Zero the gamepad state so user code reading axes/buttons does + // not see stale values (last-command runaway when link dies). + _gs.write(now_ms, nullptr, 0, nullptr, 0); _rs.setClientCount(now_ms, 0); } From a3dc610b97bcf93b2b44dbd234f22a7b1fafb2c1 Mon Sep 17 00:00:00 2001 From: tunapro Date: Sat, 18 Apr 2026 15:21:56 +0300 Subject: [PATCH 04/27] feat(runtime): make DS timeout behavior configurable (FORCE_STOP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously DS timeout always forced Status::STOP, killing the teleop task and requiring a full init+start cycle to resume driving. For teams that want a softer behavior — keep user loops running while the gamepad is zeroed, then resume on reconnect without restart — add PROBOT_DS_TIMEOUT_FORCE_STOP define. Default is 1 (current behavior, safe). Setting it to 0 just releases the owner and closes WS sessions; teleop keeps running with neutral input. --- src/probot/core/runtime.hpp | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/probot/core/runtime.hpp b/src/probot/core/runtime.hpp index 8909075..6580114 100644 --- a/src/probot/core/runtime.hpp +++ b/src/probot/core/runtime.hpp @@ -8,6 +8,15 @@ #ifndef PROBOT_DS_TIMEOUT_MS #define PROBOT_DS_TIMEOUT_MS 10000 #endif + +// On DS activity timeout: +// 1 -> set Status::STOP (kills teleop/auto tasks, user code stops) — default, safe +// 0 -> only forceDisconnect (release owner, zero gamepad); user loops keep running +// Teams that want auto-recovery when the link returns without a full +// robot restart can set this to 0 in their sketch. +#ifndef PROBOT_DS_TIMEOUT_FORCE_STOP +#define PROBOT_DS_TIMEOUT_FORCE_STOP 1 +#endif #include #include #include @@ -244,15 +253,19 @@ namespace probot { } } - // DS connection heartbeat: no activity → stop robot + disconnect + // DS connection heartbeat: no activity → stop robot and/or disconnect { uint32_t dsAct = __atomic_load_n(&probot::robot::g_ds_last_activity_ms, __ATOMIC_SEQ_CST); if (dsAct != 0 && s.status != Status::STOP && (int32_t)(now - dsAct) > (int32_t)PROBOT_DS_TIMEOUT_MS){ Serial.printf("[SYS ] DS timeout: no activity for %lu ms\n", (unsigned long)(now - dsAct)); +#if PROBOT_DS_TIMEOUT_FORCE_STOP probot::telemetry::println("!! DS CONNECTION LOST — stopping robot"); probot::robot::state().setStatus(now, Status::STOP); lastStatus = Status::STOP; +#else + probot::telemetry::println("!! DS CONNECTION LOST — joystick neutral, waiting reconnect"); +#endif #ifdef ESP32 if (probot::driverstation::detail::g_driver_station){ probot::driverstation::detail::g_driver_station->forceDisconnect(now); From a272f0a31ddbc4118a5254bd9a902b2104b3283b Mon Sep 17 00:00:00 2001 From: tunapro Date: Sat, 18 Apr 2026 15:23:13 +0300 Subject: [PATCH 05/27] feat(ws): enforce owner check at /joystick handshake and per-frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /joystick WebSocket previously accepted any client at handshake; owner enforcement only covered HTTP routes. A second driver station on the same AP could open a parallel WS and inject joystick frames, silently racing against the legitimate owner. Add an OwnerAuthorizer callback to WsJoystick. DriverStation wires it to enforceOwner (in silent mode — we close the socket instead of emitting an HTTP 403 on a WS frame). Handshake and every subsequent frame re-check ownership; non-owner clients get their session torn down immediately. --- .../esp32s3/driver_station_esp32.hpp | 20 +++++++++---- src/driverstation/esp32s3/ws_joystick.hpp | 29 +++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/driverstation/esp32s3/driver_station_esp32.hpp b/src/driverstation/esp32s3/driver_station_esp32.hpp index a24f001..0a01e45 100644 --- a/src/driverstation/esp32s3/driver_station_esp32.hpp +++ b/src/driverstation/esp32s3/driver_station_esp32.hpp @@ -93,7 +93,8 @@ namespace probot::driverstation::esp32 { registerUri("/health", HTTP_GET, handleHealth); registerUri("/info", HTTP_GET, handleInfo); - // Attach WebSocket handler + // Attach WebSocket handler (with owner gatekeeper) + _ws.setOwnerAuthorizer(&DriverStation::wsOwnerAuthorizer, this); _ws.attach(_server); Serial.println("[DS ] HTTP server started on port 80"); @@ -159,11 +160,13 @@ namespace probot::driverstation::esp32 { // ── Owner enforcement ── - bool enforceOwner(httpd_req_t* req) { + bool enforceOwner(httpd_req_t* req, bool sendHttpError = true) { uint32_t now = millis(); char ip[48]; if (!getClientIP(req, ip, sizeof(ip))) { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Cannot determine client IP"); + if (sendHttpError) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Cannot determine client IP"); + } return false; } @@ -194,11 +197,18 @@ namespace probot::driverstation::esp32 { } Serial.printf("[DS ] Rejected %s (owner: %s)\n", ip, _owner_str); - httpd_resp_set_status(req, "403 Forbidden"); - httpd_resp_send(req, "Another client is already connected.", HTTPD_RESP_USE_STRLEN); + if (sendHttpError) { + httpd_resp_set_status(req, "403 Forbidden"); + httpd_resp_send(req, "Another client is already connected.", HTTPD_RESP_USE_STRLEN); + } return false; } + static bool wsOwnerAuthorizer(void* ctx, httpd_req_t* req) { + auto* ds = static_cast(ctx); + return ds->enforceOwner(req, /*sendHttpError=*/false); + } + void releaseOwner(uint32_t now_ms) { Serial.printf("[DS ] Owner released: %s\n", _owner_str); _owner_set = false; diff --git a/src/driverstation/esp32s3/ws_joystick.hpp b/src/driverstation/esp32s3/ws_joystick.hpp index 38a0f6c..858523a 100644 --- a/src/driverstation/esp32s3/ws_joystick.hpp +++ b/src/driverstation/esp32s3/ws_joystick.hpp @@ -19,8 +19,17 @@ namespace probot::driverstation::esp32 { */ class WsJoystick { public: + using OwnerAuthorizer = bool (*)(void* ctx, httpd_req_t* req); + explicit WsJoystick(io::GamepadService& gs) : _gs(gs) {} + // Optional gatekeeper invoked at WS handshake and for every incoming + // frame. Returning false rejects the client (non-owner). + void setOwnerAuthorizer(OwnerAuthorizer authorizer, void* ctx) { + _ownerAuthorizer = authorizer; + _ownerCtx = ctx; + } + void attach(httpd_handle_t server) { _server = server; @@ -68,11 +77,25 @@ namespace probot::driverstation::esp32 { struct PingState { int fd = -1; uint8_t fails = 0; }; static esp_err_t wsHandler(httpd_req_t* req) { + auto* self = static_cast(req->user_ctx); + if (req->method == HTTP_GET) { - return ESP_OK; // WS handshake — just accept + // WS handshake — reject non-owner clients so a stray tablet + // can't hijack joystick input. + if (self->_ownerAuthorizer && !self->_ownerAuthorizer(self->_ownerCtx, req)) { + return ESP_FAIL; + } + return ESP_OK; } - auto* self = static_cast(req->user_ctx); + // Frame-time owner re-check (owner may have changed since handshake). + if (self->_ownerAuthorizer && !self->_ownerAuthorizer(self->_ownerCtx, req)) { + int fd = httpd_req_to_sockfd(req); + if (fd >= 0 && self->_server) { + httpd_sess_trigger_close(self->_server, fd); + } + return ESP_OK; + } // Step 1: 0-length receive to learn frame size & type httpd_ws_frame_t frame = {}; @@ -176,6 +199,8 @@ namespace probot::driverstation::esp32 { io::GamepadService& _gs; httpd_handle_t _server = nullptr; TimerHandle_t _pingTimer = nullptr; + OwnerAuthorizer _ownerAuthorizer = nullptr; + void* _ownerCtx = nullptr; PingState _pingState[PING_TRACK_SLOTS] = {}; }; From 86669492fed544eb4cefaefe47dd5dc139246d8e Mon Sep 17 00:00:00 2001 From: tunapro Date: Sat, 18 Apr 2026 15:23:45 +0300 Subject: [PATCH 06/27] chore: bump version to 0.2.8 Link-layer reliability release: * ws_joystick: 3-fail ping tolerance (was single-fail close) * /health and /info open for monitoring (no owner required) * /info no longer leaks WiFi password * gamepad state zeroed on owner release (no stale input) * PROBOT_DS_TIMEOUT_FORCE_STOP configurable (default 1 = safe) * /joystick WS enforces owner at handshake and per-frame --- VERSION | 2 +- library.json | 2 +- library.properties | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index b003284..a45be46 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.7 +0.2.8 diff --git a/library.json b/library.json index 8113a36..5af67f0 100644 --- a/library.json +++ b/library.json @@ -28,5 +28,5 @@ "type": "git", "url": "https://github.com/nfrproducts/probot-lib" }, - "version": "0.2.7" + "version": "0.2.8" } diff --git a/library.properties b/library.properties index ba1cfa7..0949287 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,5 @@ name=probot -version=0.2.7 +version=0.2.8 author=Tuna Gül maintainer=Tuna Gül sentence=Probot Communication Library for ESP32-S3 Robotics. From 118cf3bb0c38247864618db535cd7a9d509d6aab Mon Sep 17 00:00:00 2001 From: tunapro Date: Sat, 18 Apr 2026 15:32:28 +0300 Subject: [PATCH 07/27] docs: update README for 0.2.8, add CHANGELOG - README: bilingual rewrite to accurately describe what 0.2.8 ships (joystick DS + WS transport), drop references to modules that do not exist in this branch (PID/Kalman/LQR/mecanum), document the new configuration macros and the "What changed in 0.2.8" section - CHANGELOG.md: first entry describing the five reliability changes and upgrade notes for PROBOT_DS_TIMEOUT_FORCE_STOP - gitignore: exclude connection-test/ and .claude/ --- .gitignore | 2 + CHANGELOG.md | 124 ++++++++++++++++++++ README.md | 314 +++++++++++++++++++++++++++++++-------------------- 3 files changed, 318 insertions(+), 122 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.gitignore b/.gitignore index eac5abb..e07bd19 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ tests/control_tests .build/ review/ tuna_test/ +connection-test/ +.claude/ *.code-workspace diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..156449a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,124 @@ +# Changelog + +Tüm önemli değişiklikler burada dokümante edilir. +Biçim [Keep a Changelog](https://keepachangelog.com/), sürümler +[Semantic Versioning](https://semver.org/). + +--- + +## [0.2.8] — Bağlantı Güvenilirliği + +Tek odak: **link-layer dayanıklılığı**. Davranış değiştiren API yok; +mevcut sketch'ler değişiklik gerektirmeden derlenir ve çalışır. + +### Eklendi +- **`PROBOT_DS_TIMEOUT_FORCE_STOP`** makrosu (`runtime.hpp`). + - `1` (varsayılan, önceki davranışla aynı): DS timeout'unda + `Status::STOP` set edilir, teleop/auto task'ları sonlandırılır. + - `0`: sadece `forceDisconnect` çağrılır, WS oturumları kapatılır, + gamepad nötrlenir. Kullanıcı loopları çalışmaya devam eder; bağlantı + geri gelince init/start gerekmez. + +### Değişti +- **WS `/joystick` ping toleransı** (`ws_joystick.hpp`). + Tek `httpd_ws_send_frame_async` fail'inde oturum kapatılıyordu; artık + fd başına sayaç tutulur ve **ardışık 3 fail**'den önce kapatma + yapılmaz. Başarılı ping sayaç sıfırlar. Gürültülü RF'de sahte + disconnect'leri ortadan kaldırır. +- **`/health` ve `/info` endpoint'leri owner-check'siz** + (`driver_station_esp32.hpp`). Hakem/izleme cihazları aktif sürücüyü + etkilemeden robot sağlığını okuyabilir. +- **`/info` response'undan `pw` alanı kaldırıldı.** Endpoint artık + açık okunabilir olduğu için AP parolası dışarı sızmamalı. +- **`/joystick` WebSocket handshake'i ve her frame için owner + doğrulaması** (`ws_joystick.hpp`, `driver_station_esp32.hpp`). + Daha önce handshake her client'ı kabul ediyor, owner kontrolü sadece + HTTP route'larında uygulanıyordu. Artık ikinci bir client `/joystick` + üzerinden paralel frame yollayamaz. +- **Owner release'de gamepad state'i sıfırlanır** + (`driver_station_esp32.hpp`). `releaseOwner()` artık + `_gs.write(now, nullptr, 0, nullptr, 0)` çağırır; kullanıcı kodu + stale axis/button değerleri okuyup motorlara göndermez. + +### Düzeltildi +- Yok (davranış değişiklikleri yukarıda listelendi). + +### Yükseltme notları +- **Yarışmada robot mutlaka durmaya devam etsin istiyorsanız:** hiçbir + şey yapmayın. `PROBOT_DS_TIMEOUT_FORCE_STOP` varsayılan `1`. +- **Bağlantı kesintilerinde otomatik devam etsin istiyorsanız:** + sketch'inize şunu ekleyin: + ```cpp + #define PROBOT_DS_TIMEOUT_FORCE_STOP 0 + ``` +- Hakem laptop'u / 2. tablet `/health` üzerinden robotları izlemek + istiyorsa artık mümkün — 403 almaz. + +--- + +## [0.2.7] — 2026-02-08 + +Önceki son yayın. Bağlantı davranışı için bkz. git `0.2.7` tag'i. + +--- + +# Changelog (EN) + +All notable changes are documented here. +Format: [Keep a Changelog](https://keepachangelog.com/). +Versioning: [Semantic Versioning](https://semver.org/). + +--- + +## [0.2.8] — Connection Reliability + +Single theme: **link-layer hardening**. No behavioral API changes; +existing sketches keep compiling and running. + +### Added +- **`PROBOT_DS_TIMEOUT_FORCE_STOP`** macro (`runtime.hpp`). + - `1` (default, matches prior behavior): DS timeout forces + `Status::STOP`, tears down teleop/auto tasks. + - `0`: only `forceDisconnect` runs, WS sessions are closed, gamepad + is zeroed. User loops keep running; no init/start needed when the + link returns. + +### Changed +- **`/joystick` WS ping tolerance** (`ws_joystick.hpp`). A single + `httpd_ws_send_frame_async` failure used to close the session. We + now track per-fd consecutive failures and close only after + **three in a row**. A successful send resets the counter. Kills + spurious disconnects under noisy RF. +- **`/health` and `/info` endpoints are owner-free** + (`driver_station_esp32.hpp`). Judges and monitoring stations can + observe robot health without stealing the active driver's ownership + slot. +- **`pw` field removed from `/info`**. The endpoint is now publicly + readable, so the AP password must not leak. +- **Owner check at `/joystick` WS handshake and per-frame** + (`ws_joystick.hpp`, `driver_station_esp32.hpp`). The handshake used + to accept any client; enforcement only covered HTTP routes. A second + driver can no longer open a parallel WS and race frames. +- **Gamepad state zeroed on owner release** + (`driver_station_esp32.hpp`). `releaseOwner()` now calls + `_gs.write(now, nullptr, 0, nullptr, 0)` so user code doesn't read + stale axis/button values and drive motors with them. + +### Fixed +- None (behavior changes listed above). + +### Upgrade notes +- **If you want the robot to always hard-stop on disconnect:** do + nothing. `PROBOT_DS_TIMEOUT_FORCE_STOP` defaults to `1`. +- **If you want automatic recovery instead:** add to your sketch + ```cpp + #define PROBOT_DS_TIMEOUT_FORCE_STOP 0 + ``` +- Judge laptops / secondary tablets can now poll `/health` without + receiving 403. + +--- + +## [0.2.7] — 2026-02-08 + +Previous release. See git tag `0.2.7` for reference. diff --git a/README.md b/README.md index 127833c..b22ed9b 100644 --- a/README.md +++ b/README.md @@ -1,212 +1,282 @@ # Probot Lib -MEB robot yarışmaları için geliştirilmiş Arduino kütüphanesi. PID kontrolü, WiFi sürücü istasyonu ve ESP32-S3 desteği ile geliyor. +MEB robot yarışmaları için ESP32 tabanlı Arduino kütüphanesi. Kablosuz +driver station, WiFi AP, WebSocket üzerinden düşük gecikmeli joystick +aktarımı ve FreeRTOS tabanlı çift çekirdek görev yönetimi sunar. -**Tüm dokümantasyon için:** https://docs.probotstudio.com/yazilim/ +**Dokümantasyon:** https://docs.probotstudio.com/yazilim/ + +> **0.2.8** bir **bağlantı güvenilirliği** sürümüdür. Tam liste için +> aşağıdaki "Bu sürümde neler değişti" bölümüne ve `CHANGELOG.md` +> dosyasına bakın. --- ## Hızlı Başlangıç -**Kurulum:** -Arduino IDE'nin Library Manager'ından "Probot Lib" arayıp yükleyin. - -**İlk robot kodunuz:** -1. `File → Examples → Probot Lib → command_based → TankDriveDemo` açın -2. ESP32-S3'e yükleyin -3. `Probot-XXXX` WiFi ağına bağlanın -4. Tarayıcıdan `http://192.168.4.1` adresini açın -5. Joystick ile robotunuzu kontrol edin +**Kurulum (Arduino IDE):** Library Manager'dan "Probot Lib" arayıp yükleyin. ---- +**Kurulum (arduino-cli):** +```bash +arduino-cli lib install "Probot Lib" +# veya doğrudan git deposundan: +git clone https://github.com/probot-studio/probot-core ~/Arduino/libraries/probot-core +``` -## Örnekler +**Kart ayarı:** `ESP32 Dev Module` veya `ESP32S3 Dev Module`, partition +şeması **`Huge APP (3MB No OTA)`** seçin — varsayılan 1.2MB bölümü +yetersizdir. + +**İlk sketch:** +```cpp +#define PROBOT_WIFI_AP_SSID "MyRobot" +#define PROBOT_WIFI_AP_PASSWORD "robot1234" +#define PROBOT_WIFI_AP_CHANNEL 1 +#include + +void robotInit() {} +void robotEnd() {} +void teleopInit() {} +void teleopLoop() { + auto& gp = probot::io::gamepad(); + // gp.getLeftX(), gp.getA(), … ile motor kodunu buraya yaz +} +void autonomousInit() {} +void autonomousLoop() { delay(100); } +``` -Kütüphane seviyelerine göre düzenlenmiş örneklerle geliyor: +ESP'ye yükle → `MyRobot` WiFi ağına bağlan → `http://192.168.4.1`'i aç +→ joystick'le kontrol et. -**Başlangıç seviyesi:** -- `command_based/TankDriveDemo` - Tank sürüş sistemi ve joystick kontrolü -- `MotorOpenLoopDemo` - Motor kontrolcu test ve kalibrasyonu +--- -**Orta seviye:** -- `MotorControllerDemo` - PID tabanlı hız kontrolü (PidMotorWrapper) -- `command_based/AutonomousDemo` - Otonom hareket (mesafe ve dönüş) +## Örnekler -**İleri seviye:** -- `command_based/MecanumDriveDemo` - Mecanum sürüş ve kinematik kontrol -- `ShooterDemo` - Kapalı çevrim atıcı kontrolü +`examples/JoystickTest/` — joystick eksenlerini ve butonlarını seri +porta ve telemetri paneline yazdırır. API'yı öğrenmek için başlangıç +noktası. -Her örnek doğrudan çalışır durumda ve yorumlarla açıklanmıştır. +Motor sürücü entegrasyonları (tank drive, mecanum, PID, kapalı çevrim) +bu sürümde kullanıcı tarafında yazılır; referans implementasyonlar +ileriki sürümlerde gelecek. --- -## Platform Desteği +## Bu sürümde neler değişti (0.2.8) + +Tümü bağlantı dayanıklılığına odaklı: -- **Arduino IDE / arduino-cli:** `library.properties` ve `Makefile` üzerinden doğrudan desteklenir. `make build EXAMPLE=command_based/TankDriveDemo` komutu, `arduino-cli` ile örnekleri derler. -- **PlatformIO (Arduino framework):** Kütüphaneyi `lib_deps = /path/to/probot-lib` ya da Git URL'siyle ekleyin. `library.json` sürüm bilgisi `VERSION` dosyasından otomatik güncellenir. -- **ESP-IDF + Arduino bileşeni:** Depoyu IDF projenizin `components/` klasörüne yerleştirip `idf.py build` çalıştırabilirsiniz. `idf_component.yml` otomatik olarak Arduino bileşenine bağımlıdır; `app_main` içinde `probot::runtime_setup()` çağırarak Arduino dışındaki uygulamalarda da aynı robot yaşam döngüsünü başlatabilirsiniz. +1. **WS ping 3-fail toleransı** — Tek başarısız ping'de kapatma + yerine ardışık 3 fail'de kapat. Gürültülü RF ortamında sahte + kopmaları ortadan kaldırır. +2. **`/health` ve `/info` owner'sız** — Hakem/izleme cihazları, aktif + sürücünün sahiplik slotunu çalmadan robot sağlığını görebilir. +3. **Owner release'de gamepad nötr** — Bağlantı kopunca son eksen/buton + state'i buffer'dan sıfırlanır. Kullanıcı kodu stale input okuyup + motorları sürmez. +4. **`PROBOT_DS_TIMEOUT_FORCE_STOP`** — Varsayılan `1` (güvenli, + bağlantı kopunca robot STOP). `0` yaparsan loop çalışmaya devam + eder, joystick nötrlenir, bağlantı dönünce restart gerekmez. +5. **`/joystick` WS handshake'de owner kontrolü** — Handshake ve her + frame'de yeniden doğrulama. İkinci bir client slot'u ele geçiremez. -Tüm platformlarda sürüm numarası `VERSION` dosyasından yönetilir; `make version-sync` çağrısı metadata dosyalarını günceller. +Detay: `CHANGELOG.md`. --- -## Ne içeriyor? +## Yapılandırma makroları -Kütüphane şunları sağlar: -- WiFi tabanlı driver station (web arayüzü) -- PID ve feedforward -- State-space kontrol araçları (Kalman filtre, LQR) -- Tank ve mecanum sürüş soyutlamaları -- Mekanizma yardımcıları (kol, asansör, slider) -- 20ms periyotlu gerçek zamanlı görev yöneticisi +`probot.h`'yi include etmeden önce tanımla: -Detaylı API dokümantasyonu ve kullanım örnekleri için https://docs.probotstudio.com/yazilim/ adresini ziyaret edin. +```cpp +#define PROBOT_WIFI_AP_SSID "RobotAdi" // 1-32 karakter +#define PROBOT_WIFI_AP_PASSWORD "en-az-8-char" // en az 8 karakter +#define PROBOT_WIFI_AP_CHANNEL 1 // 1-13 +// isteğe bağlı: +#define PROBOT_WIFI_AP_SSID_MAC_SUFFIX // SSID'ye -XXXXXX ekle +#define PROBOT_DS_TIMEOUT_MS 10000 // DS aktivite timeout +#define PROBOT_DS_TIMEOUT_FORCE_STOP 1 // 0 = soft, 1 = STOP +``` --- -## Donanım +## Platform desteği -**Önerilen:** [Boardoza Pulse S32-S3](https://boardoza.com/product/boardoza-pulse-s32-s3-breakout-board/) +- **Arduino IDE / arduino-cli**: `library.properties` üzerinden doğrudan + derlenir. `make build EXAMPLE=JoystickTest`. +- **PlatformIO (Arduino framework)**: `lib_deps`'e path veya git URL ekle. +- **ESP-IDF + Arduino component**: `components/` altına kopyala, + `app_main` içinden `probot::runtime_setup()` çağır. -Kütüphane ESP32-S3 için geliştirilmiştir. Motor kontrolcusu olarak herhangi bir PWM kontrolcu kullanabilirsiniz (Boardoza VNH5019, BTS7960B, TB6612, vb.) +Sürüm numarası `VERSION` dosyasından yönetilir; `make version-sync` +metadata dosyalarını eşitler. --- -## Katkıda Bulunma +## Donanım -Katkılarınızı bekliyoruz. Hata bildirimi veya özellik önerisi için GitHub Issues kullanabilirsiniz. Pull request'ler için küçük ve odaklı değişiklikler tercih edilir. +Kütüphane **ESP32 ve ESP32-S3** ile uyumludur (WROOM, WROVER, S3 DevKit). +Test edilmiş kart: [Boardoza Pulse S32-S3](https://boardoza.com/product/boardoza-pulse-s32-s3-breakout-board/). -Geliştirme için: -```bash -git clone https://github.com/nfrproducts/probot-lib -cd probot-lib -make libs # gerekli Arduino kütüphanelerini (Adafruit NeoPixel) kur -make test -``` - -Detaylar için `CONTRIBUTING.md` dosyasına bakın. +PWM çıkışı veren herhangi bir motor sürücü kullanılabilir: VNH5019, +BTS7960B, TB6612, L298N, vb. --- ## Lisans -Proje MIT lisansı ile yayınlanır. Ticari kullanım için Commons Clause koşulu geçerlidir. - -Eğitim ve yarışma amaçlı kullanım ücretsizdir. Ticari lisans için: tunagul54@gmail.com +MIT + Commons Clause. Eğitim ve yarışma kullanımı ücretsiz; ticari +lisans için: tunagul54@gmail.com --- ## Destek -**Dokümantasyon:** https://docs.probotstudio.com/yazilim/ -**WhatsApp:** +90 538 040 81 48 -**Hata bildirimi:** [GitHub Issues](https://github.com/nfrproducts/probot-lib/issues) - -Amacımız ekiplerin yarışma gününe hazır robotlarla çıkmasını sağlamak. +- **Dokümantasyon:** https://docs.probotstudio.com/yazilim/ +- **WhatsApp:** +90 538 040 81 48 +- **Hata bildirimi:** https://github.com/probot-studio/probot-core/issues +--- --- # Probot Lib (EN) -Arduino library built for Ministry of Education robot competitions. Includes PID control, a WiFi driver station, and ESP32-S3 support. +ESP32-based Arduino library for Ministry of Education robotics +competitions. Provides a wireless driver station over WiFi AP, +low-latency joystick transport via WebSocket, and a dual-core +FreeRTOS-based task manager. -**Full documentation:** https://docs.probotstudio.com/yazilim/ +**Documentation:** https://docs.probotstudio.com/yazilim/ ---- +> **0.2.8** is a **connection-reliability** release. See "What changed +> in this release" below and `CHANGELOG.md` for the full list. -## Quick Start +--- -**Installation:** -Open the Arduino IDE Library Manager, search for "Probot Lib", and install it. +## Quick start -**Your first robot code:** -1. Open `File → Examples → Probot Lib → command_based → TankDriveDemo` -2. Upload it to the ESP32-S3 -3. Connect to the `Probot-XXXX` WiFi network -4. Visit `http://192.168.4.1` in your browser -5. Control the robot with the joystick +**Install (Arduino IDE):** Library Manager → search "Probot Lib" → install. ---- +**Install (arduino-cli):** +```bash +arduino-cli lib install "Probot Lib" +# or from git: +git clone https://github.com/probot-studio/probot-core ~/Arduino/libraries/probot-core +``` -## Examples +**Board setup:** Pick `ESP32 Dev Module` or `ESP32S3 Dev Module`. Set +partition scheme to **`Huge APP (3MB No OTA)`** — the default 1.2 MB +slot is not enough. + +**Minimal sketch:** +```cpp +#define PROBOT_WIFI_AP_SSID "MyRobot" +#define PROBOT_WIFI_AP_PASSWORD "robot1234" +#define PROBOT_WIFI_AP_CHANNEL 1 +#include + +void robotInit() {} +void robotEnd() {} +void teleopInit() {} +void teleopLoop() { + auto& gp = probot::io::gamepad(); + // drive motors with gp.getLeftX(), gp.getA(), … +} +void autonomousInit() {} +void autonomousLoop() { delay(100); } +``` -The library ships with examples organized by proficiency level: +Flash → join `MyRobot` WiFi → visit `http://192.168.4.1` → drive with a +joystick. -**Beginner level:** -- `command_based/TankDriveDemo` - Tank drive system with joystick control -- `MotorOpenLoopDemo` - Motor controller open-loop test and calibration +--- -**Intermediate:** -- `MotorControllerDemo` - PID-based speed control (PidMotorWrapper) -- `command_based/AutonomousDemo` - Autonomous motion (distance and turn) +## Examples -**Advanced:** -- `command_based/MecanumDriveDemo` - Mecanum drive and kinematic control -- `ShooterDemo` - Closed-loop shooter control +`examples/JoystickTest/` — prints joystick axes and buttons to Serial +and the telemetry panel. Starting point for learning the API. -Every example runs out of the box and is documented with inline comments. +Motor integrations (tank drive, mecanum, PID, closed-loop) are written +by the user in this release; reference implementations will ship in a +later version. --- -## Platform Support - -- **Arduino IDE / arduino-cli:** build examples with `make build EXAMPLE=command_based/TankDriveDemo`; metadata comes from `library.properties`. -- **PlatformIO (Arduino framework):** add `lib_deps = /path/to/probot-lib` or the Git URL; `library.json` stays in sync with `VERSION`. -- **ESP-IDF with the Arduino component:** drop the repository under your project's `components/` directory (or use `idf_component.yml` via the component manager) and call `idf.py build`. Invoke `probot::runtime_setup()` from `app_main()` to reuse the Arduino lifecycle on pure ESP-IDF projects. - -`make version-sync` keeps all manifests aligned with the single `VERSION` file. +## What changed in this release (0.2.8) + +All focused on connection reliability: + +1. **WS ping 3-fail tolerance** — Closing on a single failed ping + caused spurious disconnects under noisy RF. The link now survives + short interference bursts (closes only after three consecutive + failures). +2. **`/health` and `/info` are open** — Judges and monitoring stations + can observe robot health without stealing the active driver's + ownership slot. `/info` no longer leaks the WiFi password. +3. **Gamepad state zeroed on owner release** — When the link dies the + gamepad buffer is cleared so user code reading axes/buttons sees + neutral values instead of whatever the driver held at the moment + of disconnect. +4. **`PROBOT_DS_TIMEOUT_FORCE_STOP`** — Default `1` (safe: set Status + to STOP when DS goes quiet). Set to `0` for a softer behavior: user + loops keep running with neutral input, no manual restart needed on + reconnect. +5. **Owner check at `/joystick` WS handshake and per-frame** — + Previously the WebSocket accepted any client at handshake and only + HTTP routes enforced ownership. Now a second driver can't open a + parallel WS and race joystick frames. + +Full details: `CHANGELOG.md`. --- -## What's inside? +## Configuration macros -The library provides: -- WiFi-based driver station (web interface) -- PID and feedforward utilities -- State-space control tools (Kalman filter, LQR) -- Tank and mecanum drive abstractions -- Mechanism helpers (arm, elevator, slider) -- A real-time task manager with a 20 ms period +Define before including `probot.h`: -For in-depth API docs and usage guides, visit https://docs.probotstudio.com/yazilim/. +```cpp +#define PROBOT_WIFI_AP_SSID "RobotName" // 1-32 chars +#define PROBOT_WIFI_AP_PASSWORD "minimum8" // >= 8 chars +#define PROBOT_WIFI_AP_CHANNEL 1 // 1-13 +// optional: +#define PROBOT_WIFI_AP_SSID_MAC_SUFFIX // append -XXXXXX +#define PROBOT_DS_TIMEOUT_MS 10000 // DS activity timeout +#define PROBOT_DS_TIMEOUT_FORCE_STOP 1 // 0 = soft, 1 = hard stop +``` --- -## Hardware +## Platform support -**Recommended:** [Boardoza Pulse S32-S3](https://boardoza.com/product/boardoza-pulse-s32-s3-breakout-board/) +- **Arduino IDE / arduino-cli**: built directly via `library.properties`. + Try `make build EXAMPLE=JoystickTest`. +- **PlatformIO (Arduino framework)**: add via `lib_deps` path or git URL. +- **ESP-IDF + Arduino component**: drop into `components/`, call + `probot::runtime_setup()` from `app_main`. -The library targets the ESP32-S3. You can use any PWM motor controller board (Boardoza VNH5019, BTS7960B, TB6612, etc.). +The single source of truth for version is `VERSION`; `make version-sync` +keeps the manifests aligned. --- -## Contributing +## Hardware -We welcome contributions. Please use GitHub Issues for bug reports or feature requests. Keep pull requests small and focused. +Works on **ESP32 and ESP32-S3** variants (WROOM, WROVER, S3 DevKit). +Tested on [Boardoza Pulse S32-S3](https://boardoza.com/product/boardoza-pulse-s32-s3-breakout-board/). -For development: -```bash -git clone https://github.com/nfrproducts/probot-lib -cd probot-lib -make test -``` - -See `CONTRIBUTING.md` for more details. +Any PWM motor controller works: VNH5019, BTS7960B, TB6612, L298N, etc. --- ## License -The project is released under the MIT license. Commercial use follows the Commons Clause terms. - -Educational and competition use is free. For commercial licensing, contact: tunagul54@gmail.com +MIT + Commons Clause. Educational and competition use is free; contact +tunagul54@gmail.com for commercial licensing. --- ## Support -**Documentation:** https://docs.probotstudio.com/yazilim/ -**WhatsApp:** +90 538 040 81 48 -**Bug reports:** [GitHub Issues](https://github.com/nfrproducts/probot-lib/issues) - -Our goal is to help teams arrive on competition day with ready-to-run robots. +- **Docs:** https://docs.probotstudio.com/yazilim/ +- **WhatsApp:** +90 538 040 81 48 +- **Issues:** https://github.com/probot-studio/probot-core/issues From 1f153bc4373e03cf6931de84c2ebc67e21828613 Mon Sep 17 00:00:00 2001 From: tunapro Date: Tue, 9 Jun 2026 23:45:18 +0300 Subject: [PATCH 08/27] fix(ws): visible heartbeat + real client-side dead-link detection Replace the WS PING with a 2-byte BINARY heartbeat ('H', seq) every 2s. Browsers auto-pong pings invisibly to JS, so the page could never tell a live link from a dead one. Server-side 3-fail close logic unchanged. Client: own sends no longer count as link activity (ws.send() into a dead TCP socket succeeds silently, masking mid-drive link loss); stale threshold 3s -> 5s (two missed heartbeats). Also fixes the idle reconnect churn loop. UI polling hardened: telemetry 50ms -> 150ms with in-flight guard and timeout, same guard on /getState, hidden tabs stop polling, dead Password row removed from Logs. --- src/driverstation/esp32s3/index_html.h | 41 +++++++++++++++++------ src/driverstation/esp32s3/ws_joystick.hpp | 31 +++++++++++------ 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/src/driverstation/esp32s3/index_html.h b/src/driverstation/esp32s3/index_html.h index a42b0ff..3f62ac7 100644 --- a/src/driverstation/esp32s3/index_html.h +++ b/src/driverstation/esp32s3/index_html.h @@ -757,10 +757,6 @@ const char MAIN_page[] PROGMEM = R"=====( SSID -- -
- Password - -- -
Channel -- @@ -966,11 +962,18 @@ const char MAIN_page[] PROGMEM = R"=====( } /* ===== SYNC STATE ===== */ + var syncStateBusy=false; function syncState(){ - fetch('/getState').then(function(r){ + if(syncStateBusy) return; + syncStateBusy=true; + var ac=new AbortController(); + var tid=setTimeout(function(){ac.abort();},3000); + fetch('/getState',{signal:ac.signal}).then(function(r){ + clearTimeout(tid); if(!r.ok) return; return r.json(); }).then(function(data){ + syncStateBusy=false; if(!data) return; var btn=document.getElementById('robotButton'); if(!btn) return; @@ -1034,6 +1037,7 @@ const char MAIN_page[] PROGMEM = R"=====( setPhaseDisplay('stopped'); } }).catch(function(e){ + syncStateBusy=false; console.error('syncState failed:',e); }); } @@ -1159,10 +1163,15 @@ const char MAIN_page[] PROGMEM = R"=====( if(wsReconnectTimer||wsStopped) return; wsReconnectTimer=setTimeout(function(){wsReconnectTimer=null;if(!wsStopped)connectWebSocket();},2000); } + /* Robot sends a binary heartbeat every 2s; missing ~2 in a row means + the link is dead even if the socket still looks open. Own sends do + NOT count as activity: ws.send() into a dead TCP socket succeeds + silently (frames just buffer), which used to mask dead links while + driving. */ function wsHealthCheck(){ if(wsStopped||!wsJoystick) return; if(wsJoystick.readyState>1){wsConnected=false;wsJoystick=null;scheduleReconnect();return;} - if(wsConnected&&performance.now()-wsLastActivity>3000){ + if(wsConnected&&performance.now()-wsLastActivity>5000){ console.log('[WS] Stale, reconnecting'); killWs();scheduleReconnect(); } @@ -1200,7 +1209,6 @@ const char MAIN_page[] PROGMEM = R"=====( if(wsConnected&&wsJoystick&&wsJoystick.readyState===1){ try{ wsJoystick.send(packJoystickBinary(gp)); - wsLastActivity=now; gamepadSending=false; return; }catch(e){wsConnected=false;wsJoystick=null;scheduleReconnect();} @@ -1263,16 +1271,26 @@ const char MAIN_page[] PROGMEM = R"=====( }); /* ===== TELEMETRY ===== */ + /* In-flight guard + timeout: without it, a congested link lets + requests pile up faster than they complete, making the jam worse. + Hidden tabs skip polling entirely — joystick frames matter more. */ + var telemetryBusy=false; function pollTelemetry(){ - fetch('/telemetry').then(function(r){ + if(telemetryBusy||document.hidden) return; + telemetryBusy=true; + var ac=new AbortController(); + var tid=setTimeout(function(){ac.abort();},2000); + fetch('/telemetry',{signal:ac.signal}).then(function(r){ + clearTimeout(tid); if(r.ok) return r.text(); }).then(function(text){ + telemetryBusy=false; var el=document.getElementById('telemetryOutput'); if(el&&text){ el.textContent=text; if(autoScroll) el.scrollTop=el.scrollHeight; } - }).catch(function(){}); + }).catch(function(){telemetryBusy=false;}); } function clearTelemetry(){ var el=document.getElementById('telemetryOutput'); @@ -1291,7 +1309,9 @@ const char MAIN_page[] PROGMEM = R"=====( if(el) el.scrollTop=el.scrollHeight; } } - setInterval(pollTelemetry,50); + /* 150ms is still smooth for a text log and cuts HTTP airtime ~3x + vs the old 50ms — leaves more room for joystick frames. */ + setInterval(pollTelemetry,150); setInterval(syncState,1000); /* ===== CONNECTION HEALTH ===== */ @@ -1403,7 +1423,6 @@ const char MAIN_page[] PROGMEM = R"=====( if(!data) return; var el=function(id){return document.getElementById(id);}; if(el('dbgSsid')) el('dbgSsid').textContent=data.ssid||'--'; - if(el('dbgPw')) el('dbgPw').textContent=data.pw||'--'; if(el('dbgCh')) el('dbgCh').textContent=data.ch||'--'; if(el('dbgIp')) el('dbgIp').textContent=data.ip||'--'; if(el('dbgChip')) el('dbgChip').textContent=data.chip||'--'; diff --git a/src/driverstation/esp32s3/ws_joystick.hpp b/src/driverstation/esp32s3/ws_joystick.hpp index 858523a..17cbd09 100644 --- a/src/driverstation/esp32s3/ws_joystick.hpp +++ b/src/driverstation/esp32s3/ws_joystick.hpp @@ -43,8 +43,8 @@ namespace probot::driverstation::esp32 { }; httpd_register_uri_handler(_server, &ws_uri); - // Periodic ping to detect dead connections - _pingTimer = xTimerCreate("ws_ping", pdMS_TO_TICKS(2000), pdTRUE, this, pingTimerCb); + // Periodic heartbeat to detect dead connections on both ends + _pingTimer = xTimerCreate("ws_hb", pdMS_TO_TICKS(HEARTBEAT_PERIOD_MS), pdTRUE, this, heartbeatTimerCb); if (_pingTimer) xTimerStart(_pingTimer, 0); Serial.println("[WS ] WebSocket handler attached to /joystick"); @@ -68,9 +68,16 @@ namespace probot::driverstation::esp32 { static constexpr uint32_t MAX_BTNS = 20; static constexpr size_t MAX_FRAME = 4 + MAX_AXES * 2 + (MAX_BTNS + 7) / 8; - // Close a WS session only after this many consecutive ping send - // failures. Tolerates brief RF hiccups in noisy environments (a - // single lost frame used to close the connection). + // Heartbeat is a 2-byte BINARY frame ('H', seq), not a WS PING: + // browsers auto-pong pings invisibly to JS, so the page cannot use + // them to tell a live link from a dead one. A data frame fires + // onmessage, giving the client a real liveness signal. + static constexpr uint8_t HEARTBEAT_MAGIC = 0x48; // 'H' + static constexpr uint32_t HEARTBEAT_PERIOD_MS = 2000; + + // Close a WS session only after this many consecutive heartbeat + // send failures. Tolerates brief RF hiccups in noisy environments + // (a single lost frame used to close the connection). static constexpr uint8_t PING_MAX_FAILS = 3; static constexpr uint8_t PING_TRACK_SLOTS = 8; @@ -162,11 +169,15 @@ namespace probot::driverstation::esp32 { for (auto& s : _pingState) if (s.fd == fd) { s.fd = -1; s.fails = 0; } } - static void pingTimerCb(TimerHandle_t t) { + static void heartbeatTimerCb(TimerHandle_t t) { auto* self = static_cast(pvTimerGetTimerID(t)); if (!self->_server) return; - httpd_ws_frame_t ping = {}; - ping.type = HTTPD_WS_TYPE_PING; + static uint8_t seq = 0; + uint8_t payload[2] = { HEARTBEAT_MAGIC, ++seq }; + httpd_ws_frame_t hb = {}; + hb.type = HTTPD_WS_TYPE_BINARY; + hb.payload = payload; + hb.len = sizeof(payload); size_t fds = 8; int clients[8]; if (httpd_get_client_list(self->_server, &fds, clients) != ESP_OK) return; @@ -182,10 +193,10 @@ namespace probot::driverstation::esp32 { for (size_t i = 0; i < fds; i++) { if (httpd_ws_get_fd_info(self->_server, clients[i]) != HTTPD_WS_CLIENT_WEBSOCKET) continue; uint8_t* fails = self->trackPingFd(clients[i]); - esp_err_t err = httpd_ws_send_frame_async(self->_server, clients[i], &ping); + esp_err_t err = httpd_ws_send_frame_async(self->_server, clients[i], &hb); if (err != ESP_OK) { if (fails && ++(*fails) >= PING_MAX_FAILS) { - Serial.printf("[WS ] /joystick ping fail x%u, closing fd=%d\n", + Serial.printf("[WS ] /joystick heartbeat fail x%u, closing fd=%d\n", (unsigned)*fails, clients[i]); httpd_sess_trigger_close(self->_server, clients[i]); self->clearPingFd(clients[i]); From d607816b7503f1b4e7ac30d7888c1cbeb0680f20 Mon Sep 17 00:00:00 2001 From: tunapro Date: Tue, 9 Jun 2026 23:45:26 +0300 Subject: [PATCH 09/27] fix(ds): owner-state race, gamepad writer lock; feat: auto channel select Owner fields were touched concurrently by the httpd task and sysloop without synchronization - all access now goes through a portMUX critical section, with logging and side effects kept outside the lock. GamepadService::write gains a writer spinlock (called from both WS/HTTP handlers and the owner-release zeroing path); readers stay lock-free. PROBOT_WIFI_AP_CHANNEL 0 now auto-selects the least congested of the non-overlapping channels (1/5/9/13) by scanning at boot; /info reports the actual channel. New PROBOT_DS_OWNER_TIMEOUT_MS macro (default 5000). httpd pinned to core 0 so core 1 stays exclusive to user code. Dead constants removed from core_config. --- .../esp32s3/driver_station_esp32.hpp | 168 ++++++++++++++---- src/probot/core/core_config.hpp | 7 +- src/probot/io/gamepad.hpp | 16 ++ 3 files changed, 155 insertions(+), 36 deletions(-) diff --git a/src/driverstation/esp32s3/driver_station_esp32.hpp b/src/driverstation/esp32s3/driver_station_esp32.hpp index 0a01e45..7d5da23 100644 --- a/src/driverstation/esp32s3/driver_station_esp32.hpp +++ b/src/driverstation/esp32s3/driver_station_esp32.hpp @@ -31,10 +31,16 @@ static_assert(sizeof(PROBOT_WIFI_AP_SSID) - 1 <= 32, "PROBOT_WIFI_AP_SSID must b #endif #ifndef PROBOT_WIFI_AP_CHANNEL -#error "WiFi AP channel not provided. Define PROBOT_WIFI_AP_CHANNEL (1-13) before including probot.h." +#error "WiFi AP channel not provided. Define PROBOT_WIFI_AP_CHANNEL (1-13, or 0 for auto-select) before including probot.h." +#endif +static_assert(PROBOT_WIFI_AP_CHANNEL >= 0 && PROBOT_WIFI_AP_CHANNEL <= 13, + "PROBOT_WIFI_AP_CHANNEL must be 1-13, or 0 for auto-select."); + +// How long the owner slot survives without any request from the owning +// client before another client may take over. +#ifndef PROBOT_DS_OWNER_TIMEOUT_MS +#define PROBOT_DS_OWNER_TIMEOUT_MS 5000 #endif -static_assert(PROBOT_WIFI_AP_CHANNEL >= 1 && PROBOT_WIFI_AP_CHANNEL <= 13, - "PROBOT_WIFI_AP_CHANNEL must be between 1 and 13."); namespace probot::driverstation::esp32 { class DriverStation { @@ -51,10 +57,23 @@ namespace probot::driverstation::esp32 { ssid += suffix; #endif ap_ssid_ = ssid; - WiFi.mode(WIFI_AP); + wifi_country_t country = { .cc = "TR", .schan = 1, .nchan = 13, .policy = WIFI_COUNTRY_POLICY_MANUAL }; + + channel_ = PROBOT_WIFI_AP_CHANNEL; + if (PROBOT_WIFI_AP_CHANNEL == 0) { + // Auto-select: scan the band and pick the least congested of the + // non-overlapping channels. Adds ~2-3 s to boot. Clients find the + // AP by SSID regardless of channel, so this is transparent to the + // driver station. + WiFi.mode(WIFI_STA); + esp_wifi_set_country(&country); + channel_ = autoSelectChannel(); + } + + WiFi.mode(WIFI_AP); esp_wifi_set_country(&country); - WiFi.softAP(ssid.c_str(), pw, PROBOT_WIFI_AP_CHANNEL); + WiFi.softAP(ssid.c_str(), pw, channel_); esp_wifi_set_bandwidth(WIFI_IF_AP, WIFI_BW_HT20); esp_wifi_set_ps(WIFI_PS_NONE); WiFi.setTxPower(WIFI_POWER_19_5dBm); @@ -64,8 +83,8 @@ namespace probot::driverstation::esp32 { Serial.println(ssid); Serial.print("[DS ] Password: "); Serial.println("********"); - Serial.print("[DS ] Channel: "); - Serial.println(PROBOT_WIFI_AP_CHANNEL); + Serial.printf("[DS ] Channel: %d%s\n", channel_, + (PROBOT_WIFI_AP_CHANNEL == 0) ? " (auto)" : ""); Serial.print("[DS ] IP Address: "); Serial.println(WiFi.softAPIP()); Serial.println("[DS ] ========================================"); @@ -77,6 +96,9 @@ namespace probot::driverstation::esp32 { cfg.stack_size = 8192; cfg.max_uri_handlers = 12; cfg.lru_purge_enable = true; + // Keep all networking on core 0 with the WiFi stack; core 1 stays + // exclusively for user teleop/autonomous loops. + cfg.core_id = 0; if (httpd_start(&_server, &cfg) != ESP_OK) { Serial.println("[DS ] Failed to start HTTP server"); @@ -101,26 +123,81 @@ namespace probot::driverstation::esp32 { } void expireOwnerIfIdle(){ - if (!_owner_set || _owner_timeout_ms == 0) return; + if (_owner_timeout_ms == 0) return; uint32_t now = millis(); - if ((uint32_t)(now - _owner_last_ms) > _owner_timeout_ms){ - uint32_t idle = now - _owner_last_ms; + char prev[sizeof(_owner_str)] = {0}; + uint32_t idle = 0; + bool expired = false; + portENTER_CRITICAL(&_owner_mux); + if (_owner_set && (uint32_t)(now - _owner_last_ms) > _owner_timeout_ms){ + idle = now - _owner_last_ms; + strncpy(prev, _owner_str, sizeof(prev) - 1); + releaseOwnerLocked(); + expired = true; + } + portEXIT_CRITICAL(&_owner_mux); + if (expired){ Serial.printf("[DS ] Owner expired: %s idle %lu ms\n", - _owner_str, (unsigned long)idle); - releaseOwner(now); + prev, (unsigned long)idle); + onOwnerReleased(now); } } void forceDisconnect(uint32_t now_ms){ Serial.println("[DS ] Force disconnect: connection timeout"); - if (_owner_set) releaseOwner(now_ms); + char prev[sizeof(_owner_str)] = {0}; + bool released = false; + portENTER_CRITICAL(&_owner_mux); + if (_owner_set){ + strncpy(prev, _owner_str, sizeof(prev) - 1); + releaseOwnerLocked(); + released = true; + } + portEXIT_CRITICAL(&_owner_mux); + if (released){ + Serial.printf("[DS ] Owner released: %s\n", prev); + onOwnerReleased(now_ms); + } _ws.closeAll(); - Serial.println("[DS ] Owner released, WS connections closed"); + Serial.println("[DS ] WS connections closed"); } private: // ── Helpers ── + // Pick the least congested of the four non-overlapping 2.4 GHz + // channels. Each visible network adds interference weight to + // channels within ±3 of its own (20 MHz overlap), stronger signals + // weigh more. Ties go to the lower channel. + static int autoSelectChannel() { + static constexpr int CANDIDATES[] = {1, 5, 9, 13}; + Serial.println("[DS ] Scanning band for channel auto-select..."); + int n = WiFi.scanNetworks(/*async=*/false, /*show_hidden=*/true); + int32_t score[4] = {0, 0, 0, 0}; + for (int i = 0; i < n; i++) { + int ch = WiFi.channel(i); + int32_t rssi = WiFi.RSSI(i); + // -100 dBm (negligible) .. -30 dBm (very strong) → 5..70 + int32_t strength = rssi + 100; + if (strength < 5) strength = 5; + if (strength > 70) strength = 70; + for (int c = 0; c < 4; c++) { + int d = ch - CANDIDATES[c]; + if (d < 0) d = -d; + if (d < 4) score[c] += (4 - d) * strength; + } + } + int best = 0; + for (int c = 1; c < 4; c++) { + if (score[c] < score[best]) best = c; + } + Serial.printf("[DS ] Scan: %d networks. Scores ch1=%ld ch5=%ld ch9=%ld ch13=%ld -> ch%d\n", + n, (long)score[0], (long)score[1], (long)score[2], (long)score[3], + CANDIDATES[best]); + WiFi.scanDelete(); + return CANDIDATES[best]; + } + void registerUri(const char* uri, httpd_method_t method, esp_err_t (*handler)(httpd_req_t*)) { httpd_uri_t u = { .uri = uri, @@ -159,6 +236,9 @@ namespace probot::driverstation::esp32 { } // ── Owner enforcement ── + // Owner fields are touched from two tasks (httpd handlers here, the + // sysloop expiry/timeout path above), so every access goes through + // _owner_mux. Critical sections stay short: no logging or I/O inside. bool enforceOwner(httpd_req_t* req, bool sendHttpError = true) { uint32_t now = millis(); @@ -170,33 +250,53 @@ namespace probot::driverstation::esp32 { return false; } - // Check timeout on current owner + enum class Verdict : uint8_t { ACQUIRED, REFRESHED, REJECTED }; + Verdict verdict; + char prev[sizeof(_owner_str)] = {0}; + uint32_t idle = 0; + bool expired = false; + + portENTER_CRITICAL(&_owner_mux); if (_owner_set && _owner_timeout_ms > 0 && (uint32_t)(now - _owner_last_ms) > _owner_timeout_ms) { - uint32_t idle = now - _owner_last_ms; - Serial.printf("[DS ] Owner timeout: %s idle %lu ms (limit %lu ms)\n", - _owner_str, (unsigned long)idle, (unsigned long)_owner_timeout_ms); - releaseOwner(now); + idle = now - _owner_last_ms; + strncpy(prev, _owner_str, sizeof(prev) - 1); + releaseOwnerLocked(); + expired = true; } - if (!_owner_set) { strncpy(_owner_str, ip, sizeof(_owner_str) - 1); _owner_str[sizeof(_owner_str) - 1] = '\0'; _owner_set = true; _owner_last_ms = now; + verdict = Verdict::ACQUIRED; + } else if (strcmp(ip, _owner_str) == 0) { + _owner_last_ms = now; + verdict = Verdict::REFRESHED; + } else { + strncpy(prev, _owner_str, sizeof(prev) - 1); + verdict = Verdict::REJECTED; + } + portEXIT_CRITICAL(&_owner_mux); + + if (expired) { + Serial.printf("[DS ] Owner timeout: %s idle %lu ms (limit %lu ms)\n", + prev, (unsigned long)idle, (unsigned long)_owner_timeout_ms); + onOwnerReleased(now); + } + + if (verdict == Verdict::ACQUIRED) { __atomic_store_n(&probot::robot::g_ds_last_activity_ms, now, __ATOMIC_SEQ_CST); _rs.setClientCount(now, 1); - Serial.printf("[DS ] Owner acquired: %s\n", _owner_str); + Serial.printf("[DS ] Owner acquired: %s\n", ip); return true; } - - if (strcmp(ip, _owner_str) == 0) { - _owner_last_ms = now; + if (verdict == Verdict::REFRESHED) { __atomic_store_n(&probot::robot::g_ds_last_activity_ms, now, __ATOMIC_SEQ_CST); return true; } - Serial.printf("[DS ] Rejected %s (owner: %s)\n", ip, _owner_str); + Serial.printf("[DS ] Rejected %s (owner: %s)\n", ip, prev); if (sendHttpError) { httpd_resp_set_status(req, "403 Forbidden"); httpd_resp_send(req, "Another client is already connected.", HTTPD_RESP_USE_STRLEN); @@ -209,12 +309,16 @@ namespace probot::driverstation::esp32 { return ds->enforceOwner(req, /*sendHttpError=*/false); } - void releaseOwner(uint32_t now_ms) { - Serial.printf("[DS ] Owner released: %s\n", _owner_str); + // Clears the owner slot. Caller must hold _owner_mux. + void releaseOwnerLocked() { _owner_set = false; _owner_str[0] = '\0'; - // Zero the gamepad state so user code reading axes/buttons does - // not see stale values (last-command runaway when link dies). + } + + // Post-release side effects — run OUTSIDE the critical section. + // Zeroes the gamepad so user code reading axes/buttons does not see + // stale values (last-command runaway when the link dies). + void onOwnerReleased(uint32_t now_ms) { _gs.write(now_ms, nullptr, 0, nullptr, 0); _rs.setClientCount(now_ms, 0); } @@ -419,7 +523,7 @@ namespace probot::driverstation::esp32 { "\"totalHeap\":%lu,\"totalFlash\":%lu," "\"sketchSize\":%lu,\"freeSketch\":%lu,\"psram\":%lu}", ds->ap_ssid_.c_str(), - PROBOT_WIFI_AP_CHANNEL, + ds->channel_, WiFi.softAPIP().toString().c_str(), ESP.getChipModel(), (unsigned long)ESP.getCpuFreqMHz(), @@ -440,10 +544,12 @@ namespace probot::driverstation::esp32 { io::GamepadService& _gs; WsJoystick _ws; httpd_handle_t _server = nullptr; + portMUX_TYPE _owner_mux = portMUX_INITIALIZER_UNLOCKED; bool _owner_set = false; char _owner_str[48] = {0}; uint32_t _owner_last_ms = 0; - uint32_t _owner_timeout_ms = 5000; + uint32_t _owner_timeout_ms = PROBOT_DS_OWNER_TIMEOUT_MS; + int channel_ = PROBOT_WIFI_AP_CHANNEL; String ap_ssid_; }; } diff --git a/src/probot/core/core_config.hpp b/src/probot/core/core_config.hpp index 2eed629..aba0603 100644 --- a/src/probot/core/core_config.hpp +++ b/src/probot/core/core_config.hpp @@ -4,18 +4,15 @@ #include namespace probot { + // Core 0 runs WiFi/httpd/sysloop ("UI"), core 1 runs user code ("CTRL"). constexpr int CORE_UI = 0; constexpr int CORE_CTRL = 1; constexpr UBaseType_t PRIO_CTRL = 4; - constexpr UBaseType_t PRIO_STATE = 5; // state manager higher than scheduler constexpr UBaseType_t PRIO_USER = 1; - constexpr UBaseType_t PRIO_UI = 3; - constexpr uint32_t STACK_UI = 4096; constexpr uint32_t STACK_CTRL = 4096; constexpr uint32_t STACK_USER = 4096; - constexpr uint32_t INIT_KILL_TIMEOUT_MS = 3000; - constexpr uint32_t END_KILL_TIMEOUT_MS = 1000; + constexpr uint32_t END_KILL_TIMEOUT_MS = 1000; } diff --git a/src/probot/io/gamepad.hpp b/src/probot/io/gamepad.hpp index 01c080b..b78f285 100644 --- a/src/probot/io/gamepad.hpp +++ b/src/probot/io/gamepad.hpp @@ -28,7 +28,11 @@ namespace probot::io { _timeout_ms = 500; } + // Written from two tasks (WS/HTTP handlers push frames, sysloop + // zeroes state on owner release) — the spinlock serializes writers; + // readers stay lock-free on the double buffer. void write(uint32_t now_ms, const float* axes, uint32_t nAxis, const bool* buttons, uint32_t nButton){ + lock(); uint32_t cur = __atomic_load_n(&_cur, __ATOMIC_SEQ_CST); uint32_t w = 1u - cur; GamepadSnapshot s = _buf[cur]; @@ -41,6 +45,7 @@ namespace probot::io { __atomic_thread_fence(__ATOMIC_SEQ_CST); _buf[w] = s; __atomic_store_n(&_cur, w, __ATOMIC_SEQ_CST); + unlock(); } void setTimeoutMs(uint32_t timeout_ms){ @@ -64,8 +69,19 @@ namespace probot::io { } private: + void lock() const { + uint32_t expected = 0; + while (!__atomic_compare_exchange_n(&_write_lock, &expected, 1u, false, + __ATOMIC_ACQUIRE, __ATOMIC_RELAXED)) { + expected = 0; + } + } + + void unlock() const { __atomic_store_n(&_write_lock, 0u, __ATOMIC_RELEASE); } + mutable GamepadSnapshot _buf[2]; mutable volatile uint32_t _cur; mutable volatile uint32_t _timeout_ms; + mutable volatile uint32_t _write_lock = 0; }; } From 162f7371d78404c8fc6989f8e15fa6f36a747544 Mon Sep 17 00:00:00 2001 From: tunapro Date: Tue, 9 Jun 2026 23:45:43 +0300 Subject: [PATCH 10/27] feat(devices): LEDC servo class + ServoTest/TankDrive examples probot::devices::Servo drives hobby servos on 50Hz/14-bit LEDC hardware PWM and allocates channels from the top of the range downward, so a timer collision with analogWrite motor PWM (the main software cause of servo jitter) is structurally impossible. No pulses until first write() to avoid the boot jump. Requires arduino-esp32 3.x; no-op stub off-ESP32. ServoTest shows jitter-safe servo control from the joystick (including the separate-BEC power warning); TankDrive is the BTS7960-style dual motor template teams keep asking for. --- examples/ServoTest/ServoTest.ino | 47 +++++++++++ examples/TankDrive/TankDrive.ino | 76 ++++++++++++++++++ src/probot.h | 1 + src/probot/devices/servo/servo.hpp | 125 +++++++++++++++++++++++++++++ 4 files changed, 249 insertions(+) create mode 100644 examples/ServoTest/ServoTest.ino create mode 100644 examples/TankDrive/TankDrive.ino create mode 100644 src/probot/devices/servo/servo.hpp diff --git a/examples/ServoTest/ServoTest.ino b/examples/ServoTest/ServoTest.ino new file mode 100644 index 0000000..0447da1 --- /dev/null +++ b/examples/ServoTest/ServoTest.ino @@ -0,0 +1,47 @@ +// ServoTest - Sol joystick Y ekseni ile servo kontrolü. +// +// Bağlantı: +// - Servo sinyal teli -> SERVO_PIN (varsayılan GPIO 4) +// - Servo güç (kırmızı/kahverengi) -> AYRI 5-6V kaynak (BEC). ESP32'nin +// 5V/3V3 pininden servo BESLEMEYİN — WiFi anlık akım çekişleri servoyu +// titretir. Toprakları (GND) ortak bağlayın. + +#define PROBOT_WIFI_AP_SSID "Probot" +#define PROBOT_WIFI_AP_PASSWORD "Probot1234" +#define PROBOT_WIFI_AP_CHANNEL 1 + +#include + +#define SERVO_PIN 4 + +probot::devices::Servo servo; + +void robotInit() { + servo.attach(SERVO_PIN); // 500-2500us, 50Hz +} + +void robotEnd() { + servo.detach(); +} + +void teleopInit() {} + +void teleopLoop() { + auto js = probot::io::joystick_api::makeDefault(); + + // Sol Y ekseni (-1..+1) -> 0-180 derece + float angle = (js.getLeftY() + 1.0f) * 90.0f; + + // A basılıyken ortala + if (js.getA()) angle = 90.0f; + + servo.write(angle); + + probot::clearTelemetry(); + probot::printf("Servo: %.0f derece\n", angle); + + delay(20); +} + +void autonomousInit() {} +void autonomousLoop() { delay(100); } diff --git a/examples/TankDrive/TankDrive.ino b/examples/TankDrive/TankDrive.ino new file mode 100644 index 0000000..41d917a --- /dev/null +++ b/examples/TankDrive/TankDrive.ino @@ -0,0 +1,76 @@ +// TankDrive - Çift motorlu tank sürüşü (BTS7960 / IBT-2 tarzı sürücü). +// +// Her motor için iki PWM girişi: RPWM (ileri) ve LPWM (geri). +// Sol çubuk Y -> sol motor, sağ çubuk Y -> sağ motor. +// +// Diğer sürücüler (L298N, TB6612: PWM + DIR pinli) için analogWrite/ +// digitalWrite satırlarını kendi sürücünüze göre uyarlayın. + +#define PROBOT_WIFI_AP_SSID "Probot" +#define PROBOT_WIFI_AP_PASSWORD "Probot1234" +#define PROBOT_WIFI_AP_CHANNEL 1 + +#include + +// Pinleri kendi kartınıza göre değiştirin +#define LEFT_RPWM 5 +#define LEFT_LPWM 6 +#define RIGHT_RPWM 7 +#define RIGHT_LPWM 8 + +// Motor yönü ters ise true yapın +#define LEFT_INVERTED false +#define RIGHT_INVERTED true + +void setMotor(uint8_t rpwmPin, uint8_t lpwmPin, float power, bool inverted) { + if (inverted) power = -power; + int duty = (int)(fabsf(power) * 255.0f); + if (duty > 255) duty = 255; + if (power > 0.02f) { analogWrite(rpwmPin, duty); analogWrite(lpwmPin, 0); } + else if (power < -0.02f) { analogWrite(rpwmPin, 0); analogWrite(lpwmPin, duty); } + else { analogWrite(rpwmPin, 0); analogWrite(lpwmPin, 0); } +} + +void stopMotors() { + setMotor(LEFT_RPWM, LEFT_LPWM, 0, false); + setMotor(RIGHT_RPWM, RIGHT_LPWM, 0, false); +} + +void robotInit() { + pinMode(LEFT_RPWM, OUTPUT); + pinMode(LEFT_LPWM, OUTPUT); + pinMode(RIGHT_RPWM, OUTPUT); + pinMode(RIGHT_LPWM, OUTPUT); + stopMotors(); +} + +void robotEnd() { + stopMotors(); // STOP komutunda motorlar güvenli konuma +} + +void teleopInit() {} + +void teleopLoop() { + auto js = probot::io::joystick_api::makeDefault(); + + // Bağlantı koptuğunda kütüphane eksenleri sıfırlar -> motorlar durur. + setMotor(LEFT_RPWM, LEFT_LPWM, js.getLeftY(), LEFT_INVERTED); + setMotor(RIGHT_RPWM, RIGHT_LPWM, js.getRightY(), RIGHT_INVERTED); + + delay(20); +} + +void autonomousInit() {} + +void autonomousLoop() { + // Örnek: 2 saniye ileri git, dur. + static bool done = false; + if (!done) { + setMotor(LEFT_RPWM, LEFT_LPWM, 0.4f, LEFT_INVERTED); + setMotor(RIGHT_RPWM, RIGHT_LPWM, 0.4f, RIGHT_INVERTED); + delay(2000); + stopMotors(); + done = true; + } + delay(100); +} diff --git a/src/probot.h b/src/probot.h index 2a5580b..a051e29 100644 --- a/src/probot.h +++ b/src/probot.h @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include diff --git a/src/probot/devices/servo/servo.hpp b/src/probot/devices/servo/servo.hpp new file mode 100644 index 0000000..02ccd51 --- /dev/null +++ b/src/probot/devices/servo/servo.hpp @@ -0,0 +1,125 @@ +#pragma once +#include + +#if defined(ARDUINO) && defined(ESP32) +#include +#include + +#if ESP_ARDUINO_VERSION_MAJOR < 3 +#error "probot::devices::Servo requires arduino-esp32 core 3.x (uses the 3.x LEDC API)." +#endif + +namespace probot::devices { + +/** + * Hobby servo on ESP32 LEDC hardware PWM — jitter-safe by construction. + * + * Why not ESP32Servo / raw analogWrite? + * - Servos need a 50 Hz pulse train. analogWrite() defaults to 1 kHz, + * and LEDC channels share timers in pairs: if a 50 Hz servo and a + * 1 kHz motor land on the same timer, one of them silently + * reconfigures the other — that is the classic "servo titremesi". + * - This class allocates channels from the TOP of the channel range + * downward, while analogWrite() allocates from the bottom up, so + * servos and motor PWM never collide on a timer. + * - 14-bit resolution at 50 Hz → 1.2 µs pulse granularity (~0.1°). + * + * Usage: + * probot::devices::Servo arm; + * void robotInit() { arm.attach(4); } // GPIO 4 + * void teleopLoop() { arm.write(90); ... } // 0-180° + * + * Note: signal jitter can also come from POWER, not timers. Servos must + * be fed from their own 5-6 V supply (BEC/UBEC), never from the ESP32 + * board's regulator. WiFi TX bursts cause voltage dips that twitch + * servos sharing a rail. Common ground is required. + */ +class Servo { +public: + static constexpr uint32_t FREQ_HZ = 50; + static constexpr uint8_t RESOLUTION = 14; // bits + static constexpr uint32_t PERIOD_US = 1000000 / FREQ_HZ; + static constexpr uint32_t DUTY_MAX = (1u << RESOLUTION) - 1; + + // Returns false if the pin is invalid or no LEDC channel is free. + bool attach(uint8_t pin, uint16_t minUs = 500, uint16_t maxUs = 2500) { + if (_attached) detach(); + if (minUs >= maxUs) return false; + int8_t ch = claimChannel(); + if (ch < 0) return false; + if (!ledcAttachChannel(pin, FREQ_HZ, RESOLUTION, (uint8_t)ch)) { + return false; + } + _pin = pin; + _min_us = minUs; + _max_us = maxUs; + _attached = true; + // No pulses until the first write() — the servo stays where it is + // instead of jumping on boot. + return true; + } + + void writeMicroseconds(uint16_t us) { + if (!_attached) return; + if (us < _min_us) us = _min_us; + if (us > _max_us) us = _max_us; + _last_us = us; + uint32_t duty = (uint32_t)((uint64_t)us * DUTY_MAX / PERIOD_US); + ledcWrite(_pin, duty); + } + + // angle: 0-180 degrees, mapped onto [minUs, maxUs] + void write(float angle) { + if (angle < 0.0f) angle = 0.0f; + if (angle > 180.0f) angle = 180.0f; + writeMicroseconds((uint16_t)(_min_us + (angle / 180.0f) * (_max_us - _min_us))); + } + + uint16_t readMicroseconds() const { return _last_us; } + bool attached() const { return _attached; } + + // Stops the pulse train (servo goes limp) and frees the pin. The LEDC + // channel itself is not recycled — channels are claimed once, top-down. + void detach() { + if (!_attached) return; + ledcDetach(_pin); + _attached = false; + } + +private: + // analogWrite() hands out channels from 0 upward; we hand out from the + // top downward so servo timers (50 Hz) never pair with motor timers. + static int8_t claimChannel() { +#ifdef LEDC_CHANNELS + static int8_t next = LEDC_CHANNELS - 1; +#else + static int8_t next = 7; +#endif + if (next < 0) return -1; + return next--; + } + + uint8_t _pin = 255; + uint16_t _min_us = 500; + uint16_t _max_us = 2500; + uint16_t _last_us = 1500; + bool _attached = false; +}; + +} // namespace probot::devices + +#else // !ESP32: no-op stub so host builds/tests can include probot.h + +namespace probot::devices { +class Servo { +public: + bool attach(uint8_t, uint16_t = 500, uint16_t = 2500) { return false; } + void writeMicroseconds(uint16_t) {} + void write(float) {} + uint16_t readMicroseconds() const { return 1500; } + bool attached() const { return false; } + void detach() {} +}; +} // namespace probot::devices + +#endif From 837a3aaf95b95348cda919e1327b52618e91b71b Mon Sep 17 00:00:00 2001 From: tunapro Date: Tue, 9 Jun 2026 23:45:43 +0300 Subject: [PATCH 11/27] test: cover telemetry ring buffer on host Wrap-around, overflow-keeps-newest, oversized single message, clear, and seq behavior - the trickiest header had zero coverage. --- tests/test_telemetry.cpp | 79 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/test_telemetry.cpp diff --git a/tests/test_telemetry.cpp b/tests/test_telemetry.cpp new file mode 100644 index 0000000..f75ec97 --- /dev/null +++ b/tests/test_telemetry.cpp @@ -0,0 +1,79 @@ +#include "test_harness.hpp" + +#include + +#include +#include + +namespace { + void resetTelemetry() { + probot::telemetry::clear(); + } +} + +TEST_CASE(telemetry_basic_print){ + resetTelemetry(); + probot::telemetry::print("hello"); + EXPECT_TRUE(std::strcmp(probot::telemetry::getBuffer(), "hello") == 0); + EXPECT_TRUE(probot::telemetry::getLength() == 5); +} + +TEST_CASE(telemetry_println_appends_newline){ + resetTelemetry(); + probot::telemetry::println("a"); + probot::telemetry::println("b"); + EXPECT_TRUE(std::strcmp(probot::telemetry::getBuffer(), "a\nb\n") == 0); +} + +TEST_CASE(telemetry_printf_formats){ + resetTelemetry(); + probot::telemetry::printf("x=%d y=%.1f", 7, 1.5); + EXPECT_TRUE(std::strcmp(probot::telemetry::getBuffer(), "x=7 y=1.5") == 0); +} + +TEST_CASE(telemetry_clear_empties_buffer){ + resetTelemetry(); + probot::telemetry::print("data"); + probot::telemetry::clear(); + EXPECT_TRUE(probot::telemetry::getLength() == 0); + EXPECT_TRUE(std::strcmp(probot::telemetry::getBuffer(), "") == 0); +} + +TEST_CASE(telemetry_overflow_keeps_most_recent){ + resetTelemetry(); + // Fill well past BUFFER_SIZE (256) with distinguishable chunks. + for (int i = 0; i < 60; i++) { + char chunk[16]; + snprintf(chunk, sizeof(chunk), "[%03d]", i); // 5 bytes each, 300 total + probot::telemetry::print(chunk); + } + const char* out = probot::telemetry::getBuffer(); + size_t len = probot::telemetry::getLength(); + EXPECT_TRUE(len == probot::telemetry::detail::BUFFER_SIZE); + // The newest chunk must be the tail of the buffer. + std::string s(out); + EXPECT_TRUE(s.size() == len); + EXPECT_TRUE(s.rfind("[059]") == s.size() - 5); + // The oldest data must have been dropped. + EXPECT_TRUE(s.find("[000]") == std::string::npos); +} + +TEST_CASE(telemetry_single_message_larger_than_buffer){ + resetTelemetry(); + std::string big(400, 'x'); + big += "END"; + probot::telemetry::print(big.c_str()); + const char* out = probot::telemetry::getBuffer(); + size_t len = probot::telemetry::getLength(); + EXPECT_TRUE(len == probot::telemetry::detail::BUFFER_SIZE); + std::string s(out); + // Only the last BUFFER_SIZE bytes survive, ending with END. + EXPECT_TRUE(s.rfind("END") == s.size() - 3); +} + +TEST_CASE(telemetry_seq_increments){ + resetTelemetry(); + uint32_t s0 = probot::telemetry::getSeq(); + probot::telemetry::print("x"); + EXPECT_TRUE(probot::telemetry::getSeq() > s0); +} From 9365f1590f018c91628beedef866c19cf4757052 Mon Sep 17 00:00:00 2001 From: tunapro Date: Tue, 9 Jun 2026 23:45:53 +0300 Subject: [PATCH 12/27] docs: rewrite README, add API.md + llms.txt, bump to 0.2.9 README rewritten minimal and task-oriented: a quick-start that actually compiles (the old one called nonexistent methods on io::gamepad()), correct install name ("probot" in Library Manager) and repo URLs (nfrproducts/probot-lib), competition channel planning (1/5/9/13 + auto-select), servo jitter guide, status LED table, and an AI-usage section with a paste-able prompt. API.md is the single-page full reference (lifecycle, joystick API, servo, telemetry, macros, HTTP/WS protocol incl. the binary frame and heartbeat). llms.txt gives AI tools the rules + raw links. keywords.txt for IDE highlighting. CONTRIBUTING cleaned of stale branches and removed examples; FUTURE_WORK pruned to communication-only scope. Version 0.2.9 synced across manifests (idf_component.yml was stuck at 0.2.7). No tag - 0.2.9 stays open for further changes. --- API.md | 190 +++++++++++++++++++++ CHANGELOG.md | 116 +++++++++++++ CONTRIBUTING.md | 24 +-- FUTURE_WORK.md | 61 +++---- README.md | 412 ++++++++++++++++++--------------------------- VERSION | 2 +- idf_component.yml | 2 +- keywords.txt | 71 ++++++++ library.json | 2 +- library.properties | 2 +- llms.txt | 39 +++++ 11 files changed, 625 insertions(+), 296 deletions(-) create mode 100644 API.md create mode 100644 keywords.txt create mode 100644 llms.txt diff --git a/API.md b/API.md new file mode 100644 index 0000000..8f6b107 --- /dev/null +++ b/API.md @@ -0,0 +1,190 @@ +# Probot API Referansı (0.2.9) + +Tek sayfalık tam referans. Kurulum ve örnekler için: [README.md](README.md) + +## Program iskeleti + +`setup()` ve `loop()` kütüphaneye aittir — sketch'te **tanımlanmaz**. +Sketch şu altı fonksiyonu tanımlamak **zorundadır** (boş olabilirler): + +```cpp +void robotInit(); // Arayüzde Init'e basılınca 1 kez +void robotEnd(); // Stop'ta 1 kez — motorları güvenli konuma al +void teleopInit(); // Teleop fazı başlarken 1 kez +void teleopLoop(); // Teleop boyunca ~50 Hz tekrar çağrılır +void autonomousInit(); // Otonom fazı başlarken 1 kez +void autonomousLoop(); // Otonom boyunca ~50 Hz tekrar çağrılır +``` + +Faz akışı (arayüzdeki tek buton yönetir): + +``` +STOP ──Init──▶ INITED ──Start──▶ [AUTONOMOUS (N sn)] ──▶ TELEOP ──Stop──▶ STOP + (kapatılabilir) +``` + +- Otonom süresi ve aç/kapa arayüzden seçilir; süre bitince teleop'a + kendiliğinden geçilir. +- `Stop`: teleop/otonom task'ları sonlandırılır, `robotEnd()` çağrılır + (1 sn içinde dönmezse zorla kesilir). +- Loop'lar ayrı FreeRTOS task'ında, **core 1**'de koşar. WiFi/sunucu + core 0'dadır — kullanıcı kodu ağı yavaşlatmaz. +- Bir loop çağrısı **2 saniyeden** uzun bloke olursa "deadline miss" + sayılır: teleop'ta uyarı verilir, otonomdaysa otonom öldürülüp + teleop'a geçilir. LED kırmızı yanıp söner. + +## Joystick + +```cpp +#include // joystick_api dahildir + +auto js = probot::io::joystick_api::makeDefault(); +``` + +`makeDefault()` her çağrıda hafif bir sarmalayıcı döndürür; loop içinde +her seferinde çağırmak normaldir. + +| Metod | Dönüş | Açıklama | +|---|---|---| +| `getLeftX()`, `getLeftY()` | `float` -1..+1 | Sol çubuk. Y yukarı = pozitif. Deadzone 0.08 | +| `getRightX()`, `getRightY()` | `float` -1..+1 | Sağ çubuk | +| `getLeftTriggerAxis()`, `getRightTriggerAxis()` | `float` 0 / 1 | Tetikler (buton olarak okunur) | +| `getA()`, `getB()`, `getX()`, `getY()` | `bool` | Xbox isimleri | +| `getCross()`, `getCircle()`, `getSquare()`, `getTriangle()` | `bool` | PlayStation eşdeğerleri | +| `getLB()`, `getRB()` | `bool` | Omuz butonları | +| `getBack()`, `getStart()`, `getOptions()` | `bool` | Orta butonlar | +| `getLeftStickButton()`, `getRightStickButton()` | `bool` | Çubuğa basma (L3/R3) | +| `getPOV()` | `int` | D-Pad: -1 yok, 0 yukarı, 90 sağ, 180 aşağı, 270 sol | +| `getDpadUp()/Right()/Down()/Left()` | `bool` | D-Pad tek yön | +| `getRawAxis(i)`, `getRawButton(i)` | `float` / `bool` | Ham erişim (mapping'siz) | +| `isConnected()` | `bool` | En az bir eksen/buton verisi geldi mi | +| `getSeq()`, `getMs()` | `uint32_t` | Paket sayacı / son paket zamanı | +| `getAxisCount()`, `getButtonCount()` | `uint32_t` | Kumandanın bildirdiği sayılar | + +**Failsafe:** joystick verisi 500 ms kesilirse tüm eksen/butonlar +sıfır okunur. Ayrıca bağlantı koptuğunda state anında sıfırlanır. +Motor kodunu doğrudan eksen değerine bağlamak güvenlidir. + +**Deadzone/ayarlar:** + +```cpp +probot::io::joystick_api::Options opt; +opt.deadzone = 0.12f; // varsayılan 0.08 +auto js = probot::io::joystick_api::makeDefault(opt); +``` + +**Kumanda eşlemesi (mapping):** varsayılan `logitech-f310` (W3C +standart düzenle aynı). Farklı kumanda için: + +```cpp +probot::io::joystick_mapping::setActiveByName("standard"); // robotInit içinde +// isimler: "logitech-f310"/"f310", "standard"/"xbox"/"ds4", "axis9-dpad", "tuna-default" +``` + +## Telemetri + +Driver Station arayüzündeki panele yazar (256 baytlık halka tampon — +eskiyen satırlar düşer): + +```cpp +probot::print("merhaba"); +probot::println("satır"); +probot::printf("hiz=%.2f\n", hiz); +probot::clearTelemetry(); +``` + +## Servo + +50 Hz LEDC donanım PWM; kanalları üstten ayırır, `analogWrite` motor +PWM'iyle timer çakışması yaşamaz (titreme nedeni #1). Detay ve güç +uyarıları: README "Servo kullanımı". + +```cpp +probot::devices::Servo kol; +kol.attach(4); // pin; opsiyonel: attach(pin, minUs, maxUs) +kol.write(90.0f); // 0-180 derece +kol.writeMicroseconds(1500); // 500-2500 µs +kol.readMicroseconds(); // son yazılan değer +kol.attached(); // bool +kol.detach(); // sinyali kes (servo gevşer) +``` + +`attach()` ilk `write()`'a kadar darbe üretmez — robot açılışta zıplamaz. + +## Durum LED'i (NeoPixel) + +Kütüphane durum renklerini kendisi sürer (README'de tablo). Pin +varsayılanı GPIO 3; `#define NEOPIXEL_PIN 48` ile değiştirilir. +El ile renk basmak isterseniz: + +```cpp +probot::builtinled::setColor(255, 0, 255); +probot::builtinled::setBrightness(64); // 0-255, varsayılan 32 +``` + +## Robot durumu (ileri seviye) + +```cpp +auto s = probot::robot::state().read(); // atomik snapshot +// s.status : Status::INIT/START/STOP +// s.phase : Phase::NOT_INIT/INITED/AUTONOMOUS/TELEOP +// s.autonomousEnabled, s.autoPeriodSeconds, s.autoStartMs +// s.clientCount, s.deadlineMiss, s.batteryVoltage +``` + +## Yapılandırma makroları + +Tamamı `#include `'den **önce** tanımlanır. Tablo: +README "Ayar makroları". Zorunlu olanlar: `PROBOT_WIFI_AP_PASSWORD` +(≥8 karakter) ve `PROBOT_WIFI_AP_CHANNEL` (1-13 veya 0 = otomatik). + +## HTTP / WebSocket arayüzü + +Robot `192.168.4.1:80`'de tek sunucu çalıştırır. Kendi DS istemcinizi +yazacaksanız: + +| Endpoint | Metod | Sahiplik | Açıklama | +|---|---|---|---| +| `/` | GET | gerekli | Driver Station arayüzü (SPA) | +| `/joystick` | WS | gerekli | Binary joystick akışı (aşağıda) + 2 sn'de bir sunucudan heartbeat | +| `/updateController` | POST | gerekli | JSON fallback: `{"axes":[...],"buttons":[...]}` | +| `/robotControl?cmd=init\|start\|stop\|cancelAuto&auto=0\|1&autoLen=N` | GET | gerekli | Faz komutları | +| `/getState` | GET | gerekli | `{"phase":N,"autonomousEnabled":b,"autoPeriodSeconds":N,"autoRemainingMs":N}` | +| `/telemetry` | GET | gerekli | Telemetri tamponunun içeriği (text) | +| `/getBattery` | GET | serbest | Pil gerilimi (şu an kullanıcı beslemeli) | +| `/health` | GET | serbest | `{"rssi":N,"up":ms,"heap":N,"dm":b}` — izleme/hakem için | +| `/info` | GET | serbest | SSID, kanal, IP, çip/heap/flash bilgisi | + +**Sahiplik (owner) modeli:** korumalı endpoint'e ilk istek atan IP +sahip olur; diğer IP'ler `403 Forbidden` alır. Sahip +`PROBOT_DS_OWNER_TIMEOUT_MS` (5 sn) sessiz kalırsa slot boşalır. +Sahip düştüğünde gamepad verisi anında sıfırlanır. + +**WS binary joystick çerçevesi** (istemci → robot): + +``` +[0] uint8 0x4A ('J' sihirli bayt) +[1] uint8 eksen sayısı (maks 20) +[2] uint8 buton sayısı (maks 20) +[3] uint8 rezerve (0) +[4..] int16 eksenler, big-endian, değer = float × 32767 +[sonra] uint8[] butonlar, bit-paketli, LSB önce +``` + +Robot → istemci: 2 saniyede bir 2 baytlık heartbeat `[0x48, seq]`. +5 saniye heartbeat alamayan istemci bağlantıyı ölü saymalıdır. + +**Bağlantı kesilme zinciri:** + +1. Joystick verisi 500 ms kesilir → eksenler sıfır okunur. +2. Sahip 5 sn istek atmaz → owner slotu boşalır, gamepad sıfırlanır. +3. DS 10 sn tamamen sessiz → `PROBOT_DS_TIMEOUT_FORCE_STOP=1` (varsayılan) + ise robot STOP'a geçer; `0` ise loop'lar sürer, bağlantı dönünce + kaldığı yerden devam eder. + +## Derleme hedefleri + +- Arduino IDE / arduino-cli: `library.properties` ile (`make build EXAMPLE=JoystickTest`) +- PlatformIO: `lib_deps = https://github.com/nfrproducts/probot-lib.git` +- ESP-IDF + Arduino component: `probot::runtime_setup()` çağırın +- Host unit testleri: `make test` (donanımsız, g++ ile) diff --git a/CHANGELOG.md b/CHANGELOG.md index 156449a..6562e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,63 @@ Biçim [Keep a Changelog](https://keepachangelog.com/), sürümler --- +## [0.2.9] — Yarışma Hazırlığı + +Bağlantı sağlamlaştırma (devam), servo desteği ve doküman yenileme. + +### Eklendi +- **`probot::devices::Servo`** (`devices/servo/servo.hpp`): 50 Hz LEDC + donanım PWM ile servo sınıfı. Kanalları üstten ayırır — `analogWrite` + motor PWM'iyle timer çakışması (servo titremesinin 1 numaralı yazılım + nedeni) yapısal olarak imkânsız. `attach/write/writeMicroseconds/detach`. +- **`PROBOT_WIFI_AP_CHANNEL 0` = otomatik kanal seçimi.** Açılışta band + taranır, 1/5/9/13 içinden en boş kanal seçilir (~2-3 sn ek açılış). + Seçilen kanal Serial'de ve `/info`'da raporlanır. +- **`PROBOT_DS_OWNER_TIMEOUT_MS`** makrosu (varsayılan 5000) — owner + slotunun boşalma süresi artık yapılandırılabilir. +- **Yeni örnekler:** `TankDrive` (BTS7960 tarzı çift motor) ve + `ServoTest` (joystick ile servo). +- **Doküman seti:** README yeniden yazıldı (derlenen quick-start, kanal + planı, servo rehberi); `API.md` tek sayfa tam referans; `llms.txt` + (yapay zekâ araçları için kurallar + ham linkler); `keywords.txt`. +- Telemetri halka tamponu için host unit testleri. + +### Değişti +- **WS ping yerine görünür heartbeat** (`ws_joystick.hpp`): sunucu artık + WS PING yerine 2 baytlık BINARY çerçeve (`'H'`, seq) yolluyor. + Tarayıcılar PING'i JS'e göstermediği için istemci ölü linki ayırt + edemiyordu; şimdi `onmessage` ile gerçek canlılık sinyali var. + Sunucu tarafındaki 3-fail kapatma mantığı aynen korundu. +- **Web UI ölü-link tespiti düzeltildi:** kendi gönderimleri artık + aktivite sayılmıyor (ölü TCP soketine `ws.send()` sessizce başarılı + olur — sürüş sırasında kopan bağlantı hiç fark edilmiyordu). Stale + eşiği 3 sn → 5 sn (2 kaçan heartbeat). Boştayken yaşanan sürekli + kopma/yeniden bağlanma döngüsü de bu sayede bitti. +- **Web UI HTTP sağlamlaştırma:** telemetri 50 ms → 150 ms; telemetri ve + durum sorgularına eşzamanlılık kilidi + zaman aşımı eklendi (tıkanan + hatta istek yığılması önlenir); arka plandaki sekme sorgulamaz. +- **httpd core 0'a sabitlendi** — core 1 tamamen kullanıcı koduna kaldı. +- `/info` artık makro yerine gerçek (otomatik seçilmiş olabilecek) + kanalı döndürür. Logs sayfasındaki anlamsız "Password" satırı kalktı. + +### Düzeltildi +- **Owner state yarışı:** owner alanlarına httpd task'ı ile sysloop + task'ı eşzamanlı erişiyordu; tüm erişimler `portMUX` kritik bölgesine + alındı (log/G-Ç kritik bölge dışında). +- **Gamepad çift yazar yarışı:** `GamepadService::write` hem WS/HTTP + handler'larından hem owner release yolundan çağrılıyor; yazarlar artık + spinlock ile sıralanıyor (okuyucular kilitsiz kalır). +- Kullanılmayan sabitler temizlendi (`INIT_KILL_TIMEOUT_MS`, `PRIO_STATE`, + `PRIO_UI`, `STACK_UI`). + +### Yükseltme notları +- Davranış kıran API değişikliği yok; mevcut sketch'ler aynen derlenir. +- Servo kullanan takımlar `ESP32Servo` yerine `probot::devices::Servo`'ya + geçmeli (README "Servo kullanımı"). +- Kalabalık RF ortamında `#define PROBOT_WIFI_AP_CHANNEL 0` deneyin. + +--- + ## [0.2.8] — Bağlantı Güvenilirliği Tek odak: **link-layer dayanıklılığı**. Davranış değiştiren API yok; @@ -70,6 +127,65 @@ Versioning: [Semantic Versioning](https://semver.org/). --- +## [0.2.9] — Competition Readiness + +Continued link hardening, servo support, documentation overhaul. + +### Added +- **`probot::devices::Servo`** (`devices/servo/servo.hpp`): hobby-servo + class on 50 Hz LEDC hardware PWM. Channels are allocated from the top + of the range downward, so a timer collision with `analogWrite` motor + PWM (the #1 software cause of servo jitter) is structurally + impossible. `attach/write/writeMicroseconds/detach`. +- **`PROBOT_WIFI_AP_CHANNEL 0` = auto channel select.** Scans the band + at boot and picks the least congested of 1/5/9/13 (~2-3 s added boot + time). The chosen channel is reported on Serial and `/info`. +- **`PROBOT_DS_OWNER_TIMEOUT_MS`** macro (default 5000) — the owner-slot + idle timeout is now configurable. +- **New examples:** `TankDrive` (BTS7960-style dual motor) and + `ServoTest` (servo from joystick). +- **Documentation set:** rewritten README (a quick-start that actually + compiles, channel planning, servo guide); `API.md` single-page full + reference; `llms.txt` (rules + raw links for AI tools); `keywords.txt`. +- Host unit tests for the telemetry ring buffer. + +### Changed +- **Visible heartbeat instead of WS ping** (`ws_joystick.hpp`): the + server now sends a 2-byte BINARY frame (`'H'`, seq) instead of a WS + PING. Browsers auto-pong pings invisibly to JS, so the client could + never tell a live link from a dead one; a data frame fires + `onmessage` and gives a real liveness signal. The server-side + 3-consecutive-failure close logic is unchanged. +- **Web UI dead-link detection fixed:** the client no longer counts its + own sends as link activity (`ws.send()` into a dead TCP socket + succeeds silently — a link dying mid-drive was never detected). Stale + threshold 3 s → 5 s (2 missed heartbeats). This also ends the + reconnect churn loop the UI used to enter while idle. +- **Web UI HTTP hardening:** telemetry polling 50 ms → 150 ms; in-flight + guards + timeouts on the telemetry and state pollers (no request + pile-up on a congested link); hidden tabs stop polling. +- **httpd pinned to core 0** — core 1 is now exclusively user code. +- `/info` reports the actual (possibly auto-selected) channel instead of + the macro. The meaningless "Password" row was removed from Logs. + +### Fixed +- **Owner-state race:** owner fields were accessed concurrently from the + httpd task and sysloop; all access now goes through a `portMUX` + critical section (logging/I-O kept outside). +- **Gamepad dual-writer race:** `GamepadService::write` is called from + both WS/HTTP handlers and the owner-release path; writers are now + serialized with a spinlock (readers stay lock-free). +- Removed dead constants (`INIT_KILL_TIMEOUT_MS`, `PRIO_STATE`, + `PRIO_UI`, `STACK_UI`). + +### Upgrade notes +- No breaking API changes; existing sketches compile unchanged. +- Teams using servos should switch from `ESP32Servo` to + `probot::devices::Servo` (see README "Servo kullanımı"). +- In crowded RF environments try `#define PROBOT_WIFI_AP_CHANNEL 0`. + +--- + ## [0.2.8] — Connection Reliability Single theme: **link-layer hardening**. No behavioral API changes; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1bd2f95..7a1dbc3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,8 +5,6 @@ Katkınız için teşekkürler! Aşağıdaki rehber; nasıl çalıştığımız ## Çalışma Akışı ve Dallar - `dev`: Aktif geliştirme dalı. Tüm değişiklikler önce buraya gelir. - `stable`: Yayınlanan sürüm. Doğrudan commit yapılmaz; `dev` → PR/merge ile güncellenir. -- `gh-pages`: Dokümantasyon sitesi (https://docs.probotstudio.com/) bu daldan yayınlanır. -- `legacy`: Eski kütüphane yapısı, yalnızca inceleme amaçlı. Önerilen akış: 1) `dev` üzerinden bir feature dalı açın: `feature/…`, `fix/…`, `docs/…` @@ -14,7 +12,7 @@ Katkınız için teşekkürler! Aşağıdaki rehber; nasıl çalıştığımız 3) PR’ı `dev` hedefine açın; kısa açıklama, test adımları ve ekran çıktısı ekleyin 4) Onay sonrası `dev`’e birleştirilir; yayın döngüsünde `stable` güncellenir -Dokümantasyon katkıları için: İçerik `gh-pages` dalında tutulur. Doküman PR’larını `gh-pages` hedefine açın. +Dokümantasyon (README, API.md, llms.txt) bu repodadır — doküman PR'larını da `dev` hedefine açın. ## Geliştirme Ön Koşulları - Arduino IDE 2.x veya `arduino-cli` @@ -27,9 +25,10 @@ Dokümantasyon katkıları için: İçerik `gh-pages` dalında tutulur. Doküman ## Örnekleri Derlemek (Makefile) - Listele: `make list` -- Derle: `make build EXAMPLE=ClosedLoopDemo` -- Yükle: `make upload EXAMPLE=ClosedLoopDemo PORT=/dev/ttyACM0` +- Derle: `make build EXAMPLE=JoystickTest` (veya `EXAMPLE=all`) +- Yükle: `make upload EXAMPLE=JoystickTest PORT=/dev/ttyACM0` - Seri monitör: `make serial` (115200 baud) +- Host unit testleri (donanımsız): `make tests/control_tests && ./tests/control_tests` ## Kod Stili ve İlkeler - Anlamlı isimler; 1–2 harfli değişkenlerden kaçının @@ -42,7 +41,8 @@ Dokümantasyon katkıları için: İçerik `gh-pages` dalında tutulur. Doküman Commit mesajları (öneri): `feat: …`, `fix: …`, `docs: …`, `refactor: …`, `chore: …` ## Test Beklentileri -- En az bir örneği derleyip çalıştırın (örn. `ClosedLoopDemo`, `BasicTankDrive`) +- En az bir örneği derleyip çalıştırın (örn. `JoystickTest`, `TankDrive`) +- Host unit testlerinin geçtiğini doğrulayın - Seri loglarıyla temel akışı doğrulayın (115200 baud) - Sürücü istasyonu/joystick varsa kısa bir manuel senaryo ekleyin @@ -69,8 +69,6 @@ Thanks for contributing! This guide summarizes the workflow, branches, and what ## Workflow and Branches - `dev`: Active development. Open PRs against this branch. - `stable`: Release branch. Updated via merges from `dev`. -- `gh-pages`: Documentation site (https://docs.probotstudio.com/) is published from here. -- `legacy`: Previous library layout for reference only. Recommended flow: 1) Branch off `dev`: `feature/...`, `fix/...`, `docs/...` @@ -78,7 +76,7 @@ Recommended flow: 3) Open a PR to `dev` with clear description and test steps 4) After review, merge into `dev`; `stable` is updated in the release cycle -Documentation contributions: open PRs targeting `gh-pages`. +Documentation (README, API.md, llms.txt) lives in this repo — open documentation PRs against `dev` too. ## Prerequisites - Arduino IDE 2.x or `arduino-cli` @@ -91,9 +89,10 @@ Documentation contributions: open PRs targeting `gh-pages`. ## Building Examples (Makefile) - List: `make list` -- Build: `make build EXAMPLE=ClosedLoopDemo` -- Upload: `make upload EXAMPLE=ClosedLoopDemo PORT=/dev/ttyACM0` +- Build: `make build EXAMPLE=JoystickTest` (or `EXAMPLE=all`) +- Upload: `make upload EXAMPLE=JoystickTest PORT=/dev/ttyACM0` - Serial monitor: `make serial` (115200 baud) +- Host unit tests (no hardware): `make tests/control_tests && ./tests/control_tests` ## Code Style and Principles - Clear, descriptive names; avoid 1–2 letter identifiers @@ -106,7 +105,8 @@ Documentation contributions: open PRs targeting `gh-pages`. Commit message convention (suggested): `feat: …`, `fix: …`, `docs: …`, `refactor: …`, `chore: …` ## Testing Expectations -- Compile and run at least one example (e.g., `ClosedLoopDemo`, `BasicTankDrive`) +- Compile and run at least one example (e.g., `JoystickTest`, `TankDrive`) +- Make sure the host unit tests pass - Validate basic flow via serial logs (115200 baud) - If applicable, include a short manual scenario for driver station/joystick diff --git a/FUTURE_WORK.md b/FUTURE_WORK.md index 4e6a153..6424611 100644 --- a/FUTURE_WORK.md +++ b/FUTURE_WORK.md @@ -1,35 +1,36 @@ -# Gelecek Çalışmalar Notları (TR) +# Gelecek Çalışmalar (TR) -- **S-Eğrisi Motion Profile Desteği**: Önceden hesaplanmış trajeler için yüksek bellek tüketimi (en kötü 720KB) nedeniyle şu anda devre dışı. Gelecekteki seçenekler: (1) Kaydırmalı pencere yaklaşımı (motor başına 2.4KB), (2) Analitik formülasyon (dinamik bellek yok), (3) Hibrit yaklaşım. Trapez profiller mevcut ve çoğu kullanım senaryosu için yeterli. -- Resmi ölçüler geldikten sonra NFR şasi geometrisi sabitlerini (iz genişliği, dingil mesafesi, tekerlek çapı) güncelle. -- Boardoza motor kontrolcüsü desteğini entegre et (donanım ekibinden gelecek PWM/CAN detayları bekleniyor). -- ESP32-S3 için donanımsal quadrature encoder sürücüsü ekle (temel örnek: PCNT + 1024 CPR tekerlek). -- IMotorController arayüzünü PIDF + feedforward slotları ve isteğe bağlı motion profile zamanlaması (trapez/S-eğrisi) ile genişlet. -- NFR örneklerinde kullanılan NullIMotorController yer tutucularını gerçek IMotorController uygulamalarıyla değiştir. -- Her aktarma için motor motion profile ve feedforward ön ayarlarını yapılandıracak şasi seviyesinde yardımcı fonksiyonlar sun. -- MPU6050 entegrasyonunu sağlamlaştır (kalibrasyon akışı, hata yönetimi) ve ilerideki MPU9050/BNO varyantlarına hazırlık yap. -- Robotlar hazır olduğunda joystick hattını donanım üzerinde 10 ms örnekleme + 20 ms kontrol döngüsü ile doğrula. -- Nihai şasi parametrelerini kullanarak otonom şablonlar ekle (10 cm ileri → 90° dönüş → 10 cm ileri). -- Robotlar hazır olduğunda donanım-iç-döngü ve saha test kampanyasını planla. -- ESP32 ADC (gerilim bölücü devresi) ile pil gerilimi ölçümü uygula ve driver station arayüzünde göster. -- Yarışma sırasında bağlantı koptuğunda driver station için otomatik WiFi yeniden bağlanma mekanizması ekle. -- Motion profile bellek kullanımını gözden geçirip optimize et (SCurveProfile en kötü durumda 720KB ayırabiliyor). +Kütüphane 0.2.7'den beri yalnızca iletişim katmanıdır; motor/encoder/IMU +maddeleri bu listeden çıkarılmıştır (gerekirse git geçmişine bakın). + +- **Pil gerilimi ölçümü:** ESP32 ADC + gerilim bölücü ile `batteryVoltage` + alanını doldur, arayüzde göster (`/getBattery` ve UI hazır, veri yok). +- **Saha test kampanyası:** `connection-test/` düzeneği ile C senaryosu + (30+ dk stabilite) ve B senaryosu (worst-case tek kanal) koşulmadı — + 0.2.8/0.2.9 bağlantı değişikliklerini sahada doğrula. +- **Kanal değişikliği için NVS:** kanalı yeniden derlemeden değiştirmek + için arayüzden seçim + NVS'te saklama (şimdilik `CHANNEL 0` otomatik + seçim var). +- **Donanım doğrulaması:** joystick hattını gerçek robotta 10 ms örnekleme + + 20 ms kontrol döngüsüyle ölç (gecikme/jitter karakterizasyonu). +- **Telemetri tamponu:** 256 bayt yarışma sırasında küçük kalabiliyor; + WS üzerinden push + daha büyük tampon değerlendir. --- -# Future Work Notes (EN) +# Future Work (EN) + +The library is communication-only since 0.2.7; motor/encoder/IMU items +were dropped from this list (see git history if needed). -- **S-Curve Motion Profile Support**: Currently disabled due to high memory usage (up to 720KB worst-case for pre-computed trajectories). Future implementation options: (1) Sliding window approach (2.4KB per motor), (2) Analytical formulation (zero dynamic memory), or (3) Hybrid approach. Trapezoid profiles are available and sufficient for most use cases. -- Update NFR chassis geometry constants (track width, wheel base, wheel diameter) once official dimensions arrive. -- Integrate Boardoza motor controller support (PWM/CAN specifics pending from hardware team). -- Add hardware quadrature encoder driver implementation for ESP32-S3 (baseline example: PCNT + 1024 CPR wheel). -- Extend IMotorController to PIDF + feedforward slots and optional motion profile scheduling (trapezoid/S-curve). -- Swap placeholder NullIMotorController usages with real IMotorController implementations in NFR examples. -- Expose chassis-level helpers to configure motor motion profiles and feedforward presets per drivetrain. -- Harden MPU6050 integration (calibration flow, failure handling) and prepare for future MPU9050/BNO variants. -- Validate joystick pipeline at 10 ms sampling + 20 ms control loop on hardware once robots are available. -- Add autonomous templates (10 cm forward → 90° turn → 10 cm forward) using finalized chassis parameters. -- Plan hardware-in-the-loop / field testing campaign when robots are ready. -- Implement battery voltage measurement using ESP32 ADC (voltage divider circuit) and expose via driver station UI. -- Add WiFi auto-reconnection mechanism for driver station when connection drops during competition. -- Review and optimize memory usage for motion profiles (SCurveProfile can allocate up to 720KB in worst case). +- **Battery voltage:** feed `batteryVoltage` via ESP32 ADC + divider; + `/getBattery` and the UI exist but receive no data today. +- **Field test campaign:** run connection-test scenario C (30+ min + stability) and scenario B (worst-case single channel) to validate the + 0.2.8/0.2.9 connectivity changes under real RF load. +- **NVS channel override:** change the WiFi channel from the UI without + reflashing (compile-time `CHANNEL 0` auto-select exists today). +- **Hardware validation:** measure joystick latency/jitter on a real + robot at 10 ms sampling + 20 ms control loop. +- **Telemetry buffer:** 256 bytes is tight during matches; consider WS + push and a larger ring buffer. diff --git a/README.md b/README.md index b22ed9b..b0c5efd 100644 --- a/README.md +++ b/README.md @@ -1,282 +1,194 @@ -# Probot Lib +# Probot -MEB robot yarışmaları için ESP32 tabanlı Arduino kütüphanesi. Kablosuz -driver station, WiFi AP, WebSocket üzerinden düşük gecikmeli joystick -aktarımı ve FreeRTOS tabanlı çift çekirdek görev yönetimi sunar. +ESP32 tabanlı robot yarışması iletişim kütüphanesi. Robot bir WiFi +erişim noktası açar, tarayıcıdan çalışan Driver Station arayüzü sunar +ve joystick verisini WebSocket ile düşük gecikmeyle robota taşır. -**Dokümantasyon:** https://docs.probotstudio.com/yazilim/ - -> **0.2.8** bir **bağlantı güvenilirliği** sürümüdür. Tam liste için -> aşağıdaki "Bu sürümde neler değişti" bölümüne ve `CHANGELOG.md` -> dosyasına bakın. +**Sürüm 0.2.9** · ESP32 / ESP32-S3 · [API Referansı](API.md) · +[English summary below](#probot-en) --- -## Hızlı Başlangıç - -**Kurulum (Arduino IDE):** Library Manager'dan "Probot Lib" arayıp yükleyin. +## Kurulum -**Kurulum (arduino-cli):** -```bash -arduino-cli lib install "Probot Lib" -# veya doğrudan git deposundan: -git clone https://github.com/probot-studio/probot-core ~/Arduino/libraries/probot-core -``` +1. **Kütüphane:** Arduino IDE → Library Manager → **"probot"** ara → Install. + Ya da en güncel sürüm için: + ```bash + git clone https://github.com/nfrproducts/probot-lib ~/Arduino/libraries/probot-core + ``` +2. **ESP32 core:** Boards Manager → "esp32" (Espressif) → **3.x** kurulu olmalı. +3. **Kart:** `ESP32S3 Dev Module` (veya `ESP32 Dev Module`). +4. **Partition:** Tools → Partition Scheme → **Huge APP (3MB No OTA)**. + Bu ayar şart — varsayılan bölüm yetersiz, derleme sığmaz. -**Kart ayarı:** `ESP32 Dev Module` veya `ESP32S3 Dev Module`, partition -şeması **`Huge APP (3MB No OTA)`** seçin — varsayılan 1.2MB bölümü -yetersizdir. +## İlk robot (5 dakika) -**İlk sketch:** ```cpp #define PROBOT_WIFI_AP_SSID "MyRobot" -#define PROBOT_WIFI_AP_PASSWORD "robot1234" -#define PROBOT_WIFI_AP_CHANNEL 1 +#define PROBOT_WIFI_AP_PASSWORD "robot1234" // en az 8 karakter +#define PROBOT_WIFI_AP_CHANNEL 1 // 1-13, veya 0 = otomatik seç #include -void robotInit() {} -void robotEnd() {} -void teleopInit() {} -void teleopLoop() { - auto& gp = probot::io::gamepad(); - // gp.getLeftX(), gp.getA(), … ile motor kodunu buraya yaz -} -void autonomousInit() {} -void autonomousLoop() { delay(100); } -``` - -ESP'ye yükle → `MyRobot` WiFi ağına bağlan → `http://192.168.4.1`'i aç -→ joystick'le kontrol et. - ---- - -## Örnekler - -`examples/JoystickTest/` — joystick eksenlerini ve butonlarını seri -porta ve telemetri paneline yazdırır. API'yı öğrenmek için başlangıç -noktası. - -Motor sürücü entegrasyonları (tank drive, mecanum, PID, kapalı çevrim) -bu sürümde kullanıcı tarafında yazılır; referans implementasyonlar -ileriki sürümlerde gelecek. - ---- - -## Bu sürümde neler değişti (0.2.8) - -Tümü bağlantı dayanıklılığına odaklı: - -1. **WS ping 3-fail toleransı** — Tek başarısız ping'de kapatma - yerine ardışık 3 fail'de kapat. Gürültülü RF ortamında sahte - kopmaları ortadan kaldırır. -2. **`/health` ve `/info` owner'sız** — Hakem/izleme cihazları, aktif - sürücünün sahiplik slotunu çalmadan robot sağlığını görebilir. -3. **Owner release'de gamepad nötr** — Bağlantı kopunca son eksen/buton - state'i buffer'dan sıfırlanır. Kullanıcı kodu stale input okuyup - motorları sürmez. -4. **`PROBOT_DS_TIMEOUT_FORCE_STOP`** — Varsayılan `1` (güvenli, - bağlantı kopunca robot STOP). `0` yaparsan loop çalışmaya devam - eder, joystick nötrlenir, bağlantı dönünce restart gerekmez. -5. **`/joystick` WS handshake'de owner kontrolü** — Handshake ve her - frame'de yeniden doğrulama. İkinci bir client slot'u ele geçiremez. - -Detay: `CHANGELOG.md`. - ---- - -## Yapılandırma makroları - -`probot.h`'yi include etmeden önce tanımla: - -```cpp -#define PROBOT_WIFI_AP_SSID "RobotAdi" // 1-32 karakter -#define PROBOT_WIFI_AP_PASSWORD "en-az-8-char" // en az 8 karakter -#define PROBOT_WIFI_AP_CHANNEL 1 // 1-13 -// isteğe bağlı: -#define PROBOT_WIFI_AP_SSID_MAC_SUFFIX // SSID'ye -XXXXXX ekle -#define PROBOT_DS_TIMEOUT_MS 10000 // DS aktivite timeout -#define PROBOT_DS_TIMEOUT_FORCE_STOP 1 // 0 = soft, 1 = STOP -``` - ---- - -## Platform desteği - -- **Arduino IDE / arduino-cli**: `library.properties` üzerinden doğrudan - derlenir. `make build EXAMPLE=JoystickTest`. -- **PlatformIO (Arduino framework)**: `lib_deps`'e path veya git URL ekle. -- **ESP-IDF + Arduino component**: `components/` altına kopyala, - `app_main` içinden `probot::runtime_setup()` çağır. - -Sürüm numarası `VERSION` dosyasından yönetilir; `make version-sync` -metadata dosyalarını eşitler. - ---- - -## Donanım - -Kütüphane **ESP32 ve ESP32-S3** ile uyumludur (WROOM, WROVER, S3 DevKit). -Test edilmiş kart: [Boardoza Pulse S32-S3](https://boardoza.com/product/boardoza-pulse-s32-s3-breakout-board/). - -PWM çıkışı veren herhangi bir motor sürücü kullanılabilir: VNH5019, -BTS7960B, TB6612, L298N, vb. - ---- - -## Lisans - -MIT + Commons Clause. Eğitim ve yarışma kullanımı ücretsiz; ticari -lisans için: tunagul54@gmail.com - ---- - -## Destek - -- **Dokümantasyon:** https://docs.probotstudio.com/yazilim/ -- **WhatsApp:** +90 538 040 81 48 -- **Hata bildirimi:** https://github.com/probot-studio/probot-core/issues - ---- ---- - -# Probot Lib (EN) - -ESP32-based Arduino library for Ministry of Education robotics -competitions. Provides a wireless driver station over WiFi AP, -low-latency joystick transport via WebSocket, and a dual-core -FreeRTOS-based task manager. - -**Documentation:** https://docs.probotstudio.com/yazilim/ - -> **0.2.8** is a **connection-reliability** release. See "What changed -> in this release" below and `CHANGELOG.md` for the full list. - ---- - -## Quick start - -**Install (Arduino IDE):** Library Manager → search "Probot Lib" → install. - -**Install (arduino-cli):** -```bash -arduino-cli lib install "Probot Lib" -# or from git: -git clone https://github.com/probot-studio/probot-core ~/Arduino/libraries/probot-core -``` - -**Board setup:** Pick `ESP32 Dev Module` or `ESP32S3 Dev Module`. Set -partition scheme to **`Huge APP (3MB No OTA)`** — the default 1.2 MB -slot is not enough. - -**Minimal sketch:** -```cpp -#define PROBOT_WIFI_AP_SSID "MyRobot" -#define PROBOT_WIFI_AP_PASSWORD "robot1234" -#define PROBOT_WIFI_AP_CHANNEL 1 -#include +void robotInit() {} // Init'e basınca 1 kez +void robotEnd() {} // Stop'ta 1 kez — motorları burada durdur +void teleopInit() {} // teleop başlarken 1 kez -void robotInit() {} -void robotEnd() {} -void teleopInit() {} -void teleopLoop() { - auto& gp = probot::io::gamepad(); - // drive motors with gp.getLeftX(), gp.getA(), … +void teleopLoop() { // ~50 Hz tekrar çağrılır + auto js = probot::io::joystick_api::makeDefault(); + float ileri = js.getLeftY(); // -1..+1 (ileri pozitif) + bool buton = js.getA(); + // motor kodun burada + delay(20); } -void autonomousInit() {} -void autonomousLoop() { delay(100); } -``` - -Flash → join `MyRobot` WiFi → visit `http://192.168.4.1` → drive with a -joystick. - ---- - -## Examples - -`examples/JoystickTest/` — prints joystick axes and buttons to Serial -and the telemetry panel. Starting point for learning the API. - -Motor integrations (tank drive, mecanum, PID, closed-loop) are written -by the user in this release; reference implementations will ship in a -later version. - ---- -## What changed in this release (0.2.8) - -All focused on connection reliability: - -1. **WS ping 3-fail tolerance** — Closing on a single failed ping - caused spurious disconnects under noisy RF. The link now survives - short interference bursts (closes only after three consecutive - failures). -2. **`/health` and `/info` are open** — Judges and monitoring stations - can observe robot health without stealing the active driver's - ownership slot. `/info` no longer leaks the WiFi password. -3. **Gamepad state zeroed on owner release** — When the link dies the - gamepad buffer is cleared so user code reading axes/buttons sees - neutral values instead of whatever the driver held at the moment - of disconnect. -4. **`PROBOT_DS_TIMEOUT_FORCE_STOP`** — Default `1` (safe: set Status - to STOP when DS goes quiet). Set to `0` for a softer behavior: user - loops keep running with neutral input, no manual restart needed on - reconnect. -5. **Owner check at `/joystick` WS handshake and per-frame** — - Previously the WebSocket accepted any client at handshake and only - HTTP routes enforced ownership. Now a second driver can't open a - parallel WS and race joystick frames. - -Full details: `CHANGELOG.md`. +void autonomousInit() {} +void autonomousLoop() { delay(100); } +``` ---- +> `setup()` ve `loop()` **tanımlamayın** — kütüphane kendisi tanımlar. +> Altı fonksiyonun altısı da sketch'te bulunmak zorundadır. -## Configuration macros +1. Yükle → Serial monitörde IP'yi gör (`192.168.4.1`). +2. Tablet/telefonu `MyRobot` WiFi ağına bağla. +3. Tarayıcıda `http://192.168.4.1` aç. +4. Kumandayı tablete bağla (USB/Bluetooth) → **Init** → **Start**. -Define before including `probot.h`: +## Örnekler -```cpp -#define PROBOT_WIFI_AP_SSID "RobotName" // 1-32 chars -#define PROBOT_WIFI_AP_PASSWORD "minimum8" // >= 8 chars -#define PROBOT_WIFI_AP_CHANNEL 1 // 1-13 -// optional: -#define PROBOT_WIFI_AP_SSID_MAC_SUFFIX // append -XXXXXX -#define PROBOT_DS_TIMEOUT_MS 10000 // DS activity timeout -#define PROBOT_DS_TIMEOUT_FORCE_STOP 1 // 0 = soft, 1 = hard stop +| Örnek | Ne yapar | +|---|---| +| `JoystickTest` | Eksen/buton değerlerini Serial'e ve telemetri paneline basar. İlk deneme için. | +| `TankDrive` | Çift motor tank sürüşü (BTS7960/IBT-2 tarzı sürücü). Motor kodunun şablonu. | +| `ServoTest` | Joystick ile servo kontrolü — titreşimsiz servo kullanımının doğru yolu. | + +## Ayar makroları + +Hepsi `#include ` satırından **önce** tanımlanır: + +| Makro | Varsayılan | Açıklama | +|---|---|---| +| `PROBOT_WIFI_AP_SSID` | `"Probot"` | AP adı (1-32 karakter) | +| `PROBOT_WIFI_AP_PASSWORD` | — (zorunlu) | AP şifresi (≥8 karakter) | +| `PROBOT_WIFI_AP_CHANNEL` | — (zorunlu) | 1-13, veya **0 = açılışta en boş kanalı otomatik seç** | +| `PROBOT_WIFI_AP_SSID_MAC_SUFFIX` | kapalı | SSID sonuna `-XXXXXX` (MAC) ekler | +| `PROBOT_DS_TIMEOUT_MS` | `10000` | DS'ten veri kesilirse timeout (ms) | +| `PROBOT_DS_TIMEOUT_FORCE_STOP` | `1` | `1`: timeout'ta robot STOP. `0`: loop sürer, joystick nötr, bağlantı dönünce devam | +| `PROBOT_DS_OWNER_TIMEOUT_MS` | `5000` | Sahip client sessiz kalırsa slotun boşalma süresi | +| `NEOPIXEL_PIN` / `NEOPIXEL_COUNT` | `3` / `1` | Durum LED'i pini/adedi | + +## Yarışma günü: kanal planı + +- 2.4 GHz'te birbirini **ezmeyen** kanallar: **1, 5, 9, 13**. Aynı anda + çalışan robotlar bu dörtlüden farklı kanallara dağıtılmalı. +- `PROBOT_WIFI_AP_CHANNEL 0` → robot açılışta ortamı tarar, en boş + kanalı kendisi seçer (açılışa ~2-3 sn ekler). Pit alanı gibi kalabalık + RF ortamında en pratik çözüm; seçilen kanal Serial'de ve arayüzün + Logs sayfasında görünür. +- Telefon hotspot'ları ve seyirci cihazları da 2.4 GHz'i doldurur — + maç sırasında robot çevresinde hotspot açtırmayın. +- Sinyal sorunlarını sahada ayıklamak için `/health` endpoint'i RSSI + verir; -70 dBm'den kötüyse mesafe/anten sorununa bakın. + +## Servo kullanımı (titreme çözümü) + +Servo titremesinin iki yaygın sebebi var; ikisi de kütüphane dışında: + +1. **Timer çakışması:** `analogWrite` (motorlar, 1 kHz) ile servo + kütüphaneleri (50 Hz) aynı LEDC timer'ına düşerse biri diğerinin + frekansını bozar. Çözüm: `probot::devices::Servo` kullanın — kanalları + üstten ayırır, motor PWM'iyle asla çakışmaz: + ```cpp + probot::devices::Servo kol; + void robotInit() { kol.attach(4); } // GPIO 4 + void teleopLoop() { kol.write(90); ... } // 0-180° + ``` +2. **Güç:** Servoyu ESP32'nin 5V/3V3 pininden beslemeyin. WiFi anlık + akım çekişleri gerilimi düşürür, servo seğirir. Servoya **ayrı 5-6V + kaynak (BEC/UBEC)** verin, toprakları ortak bağlayın. + +PCA9685 kullanıyorsanız: servo çıkışları için PWM frekansı **50 Hz** +olmalı (1 kHz'te servo darbe genişliği fiziksel olarak üretilemez). + +## Durum LED'i + +| Renk | Anlam | +|---|---| +| Mavi sabit | Açık, DS bağlı değil | +| Mavi yanıp sönüyor | DS bağlı, Init bekleniyor | +| Sarı sabit | Init tamam, Start bekleniyor | +| Turuncu yanıp sönüyor | Otonom çalışıyor | +| Yeşil yanıp sönüyor | Teleop çalışıyor | +| Kırmızı yanıp sönüyor | Deadline miss — loop 2 sn'den uzun bloke oldu | + +## Bağlantı davranışı (güvenlik) + +- Joystick verisi **500 ms** kesilirse eksen/butonlar otomatik sıfırlanır + → motorlar son komutla kaçmaz. +- DS **10 sn** tamamen sessiz kalırsa robot STOP'a geçer + (`PROBOT_DS_TIMEOUT_FORCE_STOP 0` ile yumuşak moda alınabilir). +- Aynı anda **tek client** kontrol edebilir (ilk bağlanan IP sahip olur). + İkinci cihaz arayüzü açarsa `403` alır. `/health` ve `/info` ise + sahiplik gerektirmez — hakem/izleme cihazları serbestçe okuyabilir. + +## Yapay zeka ile kod yazma + +Gemini / ChatGPT / Claude'a robot kodu yazdırırken bu satırları +prompt'unuzun başına ekleyin: + +```text +ESP32 için "probot" kütüphanesiyle (0.2.9) Arduino kodu yaz. +Önce API referansını oku: +https://raw.githubusercontent.com/nfrproducts/probot-lib/stable/API.md +Kurallar: +- setup()/loop() TANIMLAMA; robotInit, robotEnd, teleopInit, teleopLoop, + autonomousInit, autonomousLoop — altısı da tanımlı olacak. +- Joystick: auto js = probot::io::joystick_api::makeDefault(); + js.getLeftY() vb. (-1..+1). probot::io::gamepad() üzerinde getLeftX gibi + metodlar YOKTUR. +- Servo için probot::devices::Servo kullan, ESP32Servo kullanma. +- teleopLoop ~50 Hz çağrılır; içinde sonsuz döngü/uzun blocking yapma. ``` ---- - -## Platform support - -- **Arduino IDE / arduino-cli**: built directly via `library.properties`. - Try `make build EXAMPLE=JoystickTest`. -- **PlatformIO (Arduino framework)**: add via `lib_deps` path or git URL. -- **ESP-IDF + Arduino component**: drop into `components/`, call - `probot::runtime_setup()` from `app_main`. +Makine-okur özet: [`llms.txt`](llms.txt) · Tam referans: [`API.md`](API.md) -The single source of truth for version is `VERSION`; `make version-sync` -keeps the manifests aligned. +## Sık sorunlar ---- +| Belirti | Çözüm | +|---|---| +| "Sketch too big" | Partition Scheme → Huge APP (3MB No OTA) | +| `#error ... PASSWORD` | Makroları `#include `'den önce yazın | +| Arayüz açılmıyor / 403 | Başka bir cihaz bağlı (tek client kuralı). Diğerini kapatın, ~5 sn bekleyin | +| Joystick görünmüyor | Kumandada herhangi bir tuşa basın (tarayıcı gamepad'i tuşa basılınca tanır) | +| Sık kopma | Kanal çakışması — `PROBOT_WIFI_AP_CHANNEL 0` deneyin veya 1/5/9/13'e dağıtın | +| Servo titriyor | Yukarıdaki "Servo kullanımı" bölümü | -## Hardware +## Destek ve lisans -Works on **ESP32 and ESP32-S3** variants (WROOM, WROVER, S3 DevKit). -Tested on [Boardoza Pulse S32-S3](https://boardoza.com/product/boardoza-pulse-s32-s3-breakout-board/). - -Any PWM motor controller works: VNH5019, BTS7960B, TB6612, L298N, etc. +- Hata bildirimi: https://github.com/nfrproducts/probot-lib/issues +- WhatsApp: +90 538 040 81 48 +- Lisans: MIT + Commons Clause — eğitim ve yarışma kullanımı ücretsiz, + ticari lisans için tunagul54@gmail.com --- -## License +# Probot (EN) -MIT + Commons Clause. Educational and competition use is free; contact -tunagul54@gmail.com for commercial licensing. +ESP32 communication library for educational robotics competitions: +the robot hosts a WiFi AP and a browser-based driver station; joystick +input streams over a binary WebSocket at 50 Hz with automatic failsafes +(input zeroing after 500 ms, robot stop after 10 s of DS silence). ---- +**Install:** Arduino IDE Library Manager → "probot", or clone +https://github.com/nfrproducts/probot-lib into `~/Arduino/libraries/`. +Requires arduino-esp32 core 3.x and the **Huge APP (3MB No OTA)** +partition scheme. -## Support +**Minimal sketch:** see the Turkish quick start above — the code is +identical. Define the three `PROBOT_WIFI_*` macros, include `probot.h`, +implement the six lifecycle hooks (`robotInit`, `robotEnd`, +`teleopInit`, `teleopLoop`, `autonomousInit`, `autonomousLoop`), and +read input via `probot::io::joystick_api::makeDefault()`. Do not define +`setup()`/`loop()` — the library owns them. -- **Docs:** https://docs.probotstudio.com/yazilim/ -- **WhatsApp:** +90 538 040 81 48 -- **Issues:** https://github.com/probot-studio/probot-core/issues +Full API reference: [API.md](API.md) · Machine-readable index: +[llms.txt](llms.txt) · Changes: [CHANGELOG.md](CHANGELOG.md) diff --git a/VERSION b/VERSION index a45be46..1866a36 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.8 +0.2.9 diff --git a/idf_component.yml b/idf_component.yml index 13495df..ad49005 100644 --- a/idf_component.yml +++ b/idf_component.yml @@ -1,4 +1,4 @@ -version: 0.2.7 +version: 0.2.9 description: ESP32-S3 communication library for robotics url: https://github.com/nfrproducts/probot-lib dependencies: diff --git a/keywords.txt b/keywords.txt new file mode 100644 index 0000000..f23a025 --- /dev/null +++ b/keywords.txt @@ -0,0 +1,71 @@ +####################################### +# Syntax coloring for probot +####################################### + +####################################### +# Datatypes / classes (KEYWORD1) +####################################### +probot KEYWORD1 +Joystick KEYWORD1 +Servo KEYWORD1 +GamepadService KEYWORD1 +StateService KEYWORD1 + +####################################### +# Functions (KEYWORD2) +####################################### +robotInit KEYWORD2 +robotEnd KEYWORD2 +teleopInit KEYWORD2 +teleopLoop KEYWORD2 +autonomousInit KEYWORD2 +autonomousLoop KEYWORD2 +makeDefault KEYWORD2 +getLeftX KEYWORD2 +getLeftY KEYWORD2 +getRightX KEYWORD2 +getRightY KEYWORD2 +getLeftTriggerAxis KEYWORD2 +getRightTriggerAxis KEYWORD2 +getA KEYWORD2 +getB KEYWORD2 +getX KEYWORD2 +getY KEYWORD2 +getLB KEYWORD2 +getRB KEYWORD2 +getBack KEYWORD2 +getStart KEYWORD2 +getOptions KEYWORD2 +getPOV KEYWORD2 +getDpadUp KEYWORD2 +getDpadDown KEYWORD2 +getDpadLeft KEYWORD2 +getDpadRight KEYWORD2 +getRawAxis KEYWORD2 +getRawButton KEYWORD2 +isConnected KEYWORD2 +attach KEYWORD2 +detach KEYWORD2 +write KEYWORD2 +writeMicroseconds KEYWORD2 +readMicroseconds KEYWORD2 +attached KEYWORD2 +println KEYWORD2 +printf KEYWORD2 +clearTelemetry KEYWORD2 +setActiveByName KEYWORD2 +setColor KEYWORD2 +setBrightness KEYWORD2 + +####################################### +# Constants (LITERAL1) +####################################### +PROBOT_WIFI_AP_SSID LITERAL1 +PROBOT_WIFI_AP_PASSWORD LITERAL1 +PROBOT_WIFI_AP_CHANNEL LITERAL1 +PROBOT_WIFI_AP_SSID_MAC_SUFFIX LITERAL1 +PROBOT_DS_TIMEOUT_MS LITERAL1 +PROBOT_DS_TIMEOUT_FORCE_STOP LITERAL1 +PROBOT_DS_OWNER_TIMEOUT_MS LITERAL1 +NEOPIXEL_PIN LITERAL1 +NEOPIXEL_COUNT LITERAL1 diff --git a/library.json b/library.json index 5af67f0..f05591a 100644 --- a/library.json +++ b/library.json @@ -28,5 +28,5 @@ "type": "git", "url": "https://github.com/nfrproducts/probot-lib" }, - "version": "0.2.8" + "version": "0.2.9" } diff --git a/library.properties b/library.properties index 0949287..8b01355 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,5 @@ name=probot -version=0.2.8 +version=0.2.9 author=Tuna Gül maintainer=Tuna Gül sentence=Probot Communication Library for ESP32-S3 Robotics. diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..5b1cb73 --- /dev/null +++ b/llms.txt @@ -0,0 +1,39 @@ +# Probot + +> ESP32 Arduino library for educational robotics competitions (Turkish +> Ministry of Education / MEB). The robot hosts a WiFi access point and a +> browser-based driver station; joystick input streams to the robot over a +> binary WebSocket at 50 Hz. Library name in the Arduino Library Manager: +> "probot". Repository: https://github.com/nfrproducts/probot-lib + +Rules that generated code MUST follow: + +- Never define `setup()` or `loop()` — the library defines them. +- The sketch must define all six hooks: `robotInit`, `robotEnd`, + `teleopInit`, `teleopLoop`, `autonomousInit`, `autonomousLoop` + (empty bodies are fine). +- Define `PROBOT_WIFI_AP_SSID`, `PROBOT_WIFI_AP_PASSWORD` (min 8 chars) + and `PROBOT_WIFI_AP_CHANNEL` (1-13, or 0 = auto-select) BEFORE + `#include `. +- Joystick access: `auto js = probot::io::joystick_api::makeDefault();` + then `js.getLeftY()`, `js.getA()`, `js.getPOV()` etc. Axis range is + -1..+1, Y is positive up. `probot::io::gamepad()` is the low-level + service and has NO getLeftX/getA-style methods. +- Servos: use `probot::devices::Servo` (attach/write/writeMicroseconds), + not ESP32Servo and not analogWrite — avoids LEDC timer conflicts. +- `teleopLoop`/`autonomousLoop` are called repeatedly (~50 Hz); do not + write infinite loops or block longer than 2 s inside them. +- Telemetry to the driver station panel: `probot::printf("v=%.2f\n", v);` +- Inputs auto-zero 500 ms after the link drops — driving motors directly + from axis values is safe. + +## Docs + +- [API reference](https://raw.githubusercontent.com/nfrproducts/probot-lib/stable/API.md): complete API, lifecycle, HTTP/WS protocol +- [README](https://raw.githubusercontent.com/nfrproducts/probot-lib/stable/README.md): install, configuration macros, competition channel planning, servo jitter guide + +## Examples + +- [JoystickTest](https://raw.githubusercontent.com/nfrproducts/probot-lib/stable/examples/JoystickTest/JoystickTest.ino): read axes/buttons, print telemetry +- [TankDrive](https://raw.githubusercontent.com/nfrproducts/probot-lib/stable/examples/TankDrive/TankDrive.ino): two-motor drive with an H-bridge (BTS7960-style) +- [ServoTest](https://raw.githubusercontent.com/nfrproducts/probot-lib/stable/examples/ServoTest/ServoTest.ino): jitter-free servo control from the joystick From 4c28f0e658c933cf89bb317f94118dcee5abd701 Mon Sep 17 00:00:00 2001 From: tunapro Date: Tue, 9 Jun 2026 23:46:42 +0300 Subject: [PATCH 13/27] docs: use canonical repo URL (repo moved to probot-studio/probot-core) GitHub reports nfrproducts/probot-lib as moved; raw links in llms.txt and the AI prompt must point at the canonical location. --- API.md | 2 +- README.md | 8 ++++---- library.json | 2 +- library.properties | 2 +- llms.txt | 12 ++++++------ 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/API.md b/API.md index 8f6b107..5c235af 100644 --- a/API.md +++ b/API.md @@ -185,6 +185,6 @@ Robot → istemci: 2 saniyede bir 2 baytlık heartbeat `[0x48, seq]`. ## Derleme hedefleri - Arduino IDE / arduino-cli: `library.properties` ile (`make build EXAMPLE=JoystickTest`) -- PlatformIO: `lib_deps = https://github.com/nfrproducts/probot-lib.git` +- PlatformIO: `lib_deps = https://github.com/probot-studio/probot-core.git` - ESP-IDF + Arduino component: `probot::runtime_setup()` çağırın - Host unit testleri: `make test` (donanımsız, g++ ile) diff --git a/README.md b/README.md index b0c5efd..dd5d101 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ve joystick verisini WebSocket ile düşük gecikmeyle robota taşır. 1. **Kütüphane:** Arduino IDE → Library Manager → **"probot"** ara → Install. Ya da en güncel sürüm için: ```bash - git clone https://github.com/nfrproducts/probot-lib ~/Arduino/libraries/probot-core + git clone https://github.com/probot-studio/probot-core ~/Arduino/libraries/probot-core ``` 2. **ESP32 core:** Boards Manager → "esp32" (Espressif) → **3.x** kurulu olmalı. 3. **Kart:** `ESP32S3 Dev Module` (veya `ESP32 Dev Module`). @@ -138,7 +138,7 @@ prompt'unuzun başına ekleyin: ```text ESP32 için "probot" kütüphanesiyle (0.2.9) Arduino kodu yaz. Önce API referansını oku: -https://raw.githubusercontent.com/nfrproducts/probot-lib/stable/API.md +https://raw.githubusercontent.com/probot-studio/probot-core/stable/API.md Kurallar: - setup()/loop() TANIMLAMA; robotInit, robotEnd, teleopInit, teleopLoop, autonomousInit, autonomousLoop — altısı da tanımlı olacak. @@ -164,7 +164,7 @@ Makine-okur özet: [`llms.txt`](llms.txt) · Tam referans: [`API.md`](API.md) ## Destek ve lisans -- Hata bildirimi: https://github.com/nfrproducts/probot-lib/issues +- Hata bildirimi: https://github.com/probot-studio/probot-core/issues - WhatsApp: +90 538 040 81 48 - Lisans: MIT + Commons Clause — eğitim ve yarışma kullanımı ücretsiz, ticari lisans için tunagul54@gmail.com @@ -179,7 +179,7 @@ input streams over a binary WebSocket at 50 Hz with automatic failsafes (input zeroing after 500 ms, robot stop after 10 s of DS silence). **Install:** Arduino IDE Library Manager → "probot", or clone -https://github.com/nfrproducts/probot-lib into `~/Arduino/libraries/`. +https://github.com/probot-studio/probot-core into `~/Arduino/libraries/`. Requires arduino-esp32 core 3.x and the **Huge APP (3MB No OTA)** partition scheme. diff --git a/library.json b/library.json index f05591a..76c4de1 100644 --- a/library.json +++ b/library.json @@ -26,7 +26,7 @@ ], "repository": { "type": "git", - "url": "https://github.com/nfrproducts/probot-lib" + "url": "https://github.com/probot-studio/probot-core" }, "version": "0.2.9" } diff --git a/library.properties b/library.properties index 8b01355..78140f5 100644 --- a/library.properties +++ b/library.properties @@ -5,7 +5,7 @@ maintainer=Tuna Gül sentence=Probot Communication Library for ESP32-S3 Robotics. paragraph=Driver station, WiFi AP, WebSocket joystick, telemetry, and match state management. category=Device Control -url=https://github.com/nfrproducts/probot-lib +url=https://github.com/probot-studio/probot-core architectures=esp32 includes=probot.h depends=Adafruit NeoPixel diff --git a/llms.txt b/llms.txt index 5b1cb73..4288cc9 100644 --- a/llms.txt +++ b/llms.txt @@ -4,7 +4,7 @@ > Ministry of Education / MEB). The robot hosts a WiFi access point and a > browser-based driver station; joystick input streams to the robot over a > binary WebSocket at 50 Hz. Library name in the Arduino Library Manager: -> "probot". Repository: https://github.com/nfrproducts/probot-lib +> "probot". Repository: https://github.com/probot-studio/probot-core Rules that generated code MUST follow: @@ -29,11 +29,11 @@ Rules that generated code MUST follow: ## Docs -- [API reference](https://raw.githubusercontent.com/nfrproducts/probot-lib/stable/API.md): complete API, lifecycle, HTTP/WS protocol -- [README](https://raw.githubusercontent.com/nfrproducts/probot-lib/stable/README.md): install, configuration macros, competition channel planning, servo jitter guide +- [API reference](https://raw.githubusercontent.com/probot-studio/probot-core/stable/API.md): complete API, lifecycle, HTTP/WS protocol +- [README](https://raw.githubusercontent.com/probot-studio/probot-core/stable/README.md): install, configuration macros, competition channel planning, servo jitter guide ## Examples -- [JoystickTest](https://raw.githubusercontent.com/nfrproducts/probot-lib/stable/examples/JoystickTest/JoystickTest.ino): read axes/buttons, print telemetry -- [TankDrive](https://raw.githubusercontent.com/nfrproducts/probot-lib/stable/examples/TankDrive/TankDrive.ino): two-motor drive with an H-bridge (BTS7960-style) -- [ServoTest](https://raw.githubusercontent.com/nfrproducts/probot-lib/stable/examples/ServoTest/ServoTest.ino): jitter-free servo control from the joystick +- [JoystickTest](https://raw.githubusercontent.com/probot-studio/probot-core/stable/examples/JoystickTest/JoystickTest.ino): read axes/buttons, print telemetry +- [TankDrive](https://raw.githubusercontent.com/probot-studio/probot-core/stable/examples/TankDrive/TankDrive.ino): two-motor drive with an H-bridge (BTS7960-style) +- [ServoTest](https://raw.githubusercontent.com/probot-studio/probot-core/stable/examples/ServoTest/ServoTest.ino): jitter-free servo control from the joystick From b7754c1a214229dc4db266acf42b5ca7a762ef81 Mon Sep 17 00:00:00 2001 From: tunapro Date: Wed, 10 Jun 2026 00:07:23 +0300 Subject: [PATCH 14/27] feat(rf): research-grounded RF/TCP tuning pass TX power: WIFI_POWER_19_5dBm (=78) quantizes DOWN to 18 dBm in the IDF API ([72,79]->72); any value >=80 yields the true API max of 20 dBm. Request WIFI_POWER_21dBm for +2 dB over the previous setting (S3 datasheet PHY caps at 21 dBm on 11b rates; 20 dBm is the API ceiling). 802.11b rates off by default (PROBOT_WIFI_ENABLE_11B=1 restores): beacons drop from 1 Mbps DSSS (~2.5% airtime per AP) to 6 Mbps OFDM (~0.4%) - significant at a venue with dozens of robot APs. OFDM is mandatory for all 11g+ clients, i.e. any 2010+ tablet. httpd: TCP_NODELAY via open_fn (esp_http_server never sets it; Nagle x delayed-ACK holds small server->client frames for tens of ms), TCP keepalive 3/2/2 (~7 s dead-client detection at the TCP layer), send_wait_timeout 5s->2s (bounds stalls on a wedged client), max_open_sockets 10. WS sends serialized with a mutex: concurrent httpd_ws_send_frame_async calls to one fd interleave header/payload and corrupt frames (esp-idf issues #14495, #5405). Heartbeat skips a round rather than blocking the timer-service task. New macros: PROBOT_INPUT_TIMEOUT_MS (input zeroing window, FRC=125 FTC=300 XRP=500), PROBOT_WIFI_PMF_REQUIRED (deauth-spoof protection, off by default for old-tablet compat). --- CHANGELOG.md | 22 +++++++ README.md | 3 + .../esp32s3/driver_station_esp32.hpp | 59 ++++++++++++++++++- src/driverstation/esp32s3/ws_joystick.hpp | 18 +++++- src/probot/io/gamepad.hpp | 9 ++- 5 files changed, 108 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6562e24..f2044e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,17 @@ Biçim [Keep a Changelog](https://keepachangelog.com/), sürümler Bağlantı sağlamlaştırma (devam), servo desteği ve doküman yenileme. ### Eklendi +- **RF/TCP ince ayarları** (datasheet/IDF kaynak araştırmasına dayalı): + TX gücü düzeltildi — `WIFI_POWER_19_5dBm` API'de aşağı yuvarlanıp + **18 dBm** veriyordu, artık gerçek maksimum **20 dBm** kullanılıyor + (+2 dB); 802.11b hızları varsayılan kapalı (beacon airtime 6 kat + azalır, `PROBOT_WIFI_ENABLE_11B` ile geri açılır); httpd soketlerine + `TCP_NODELAY` (Nagle × delayed-ACK gecikmesi biter) ve TCP keepalive + (~7 sn'de ölü client tespiti); WS gönderimleri mutex ile sıralandı + (eşzamanlı `httpd_ws_send_frame_async` çerçeve bozuyor, esp-idf + #14495); `send_wait_timeout` 5 sn → 2 sn; `max_open_sockets` 10. + Yeni makrolar: `PROBOT_INPUT_TIMEOUT_MS`, `PROBOT_WIFI_ENABLE_11B`, + `PROBOT_WIFI_PMF_REQUIRED`. - **`probot::devices::Servo`** (`devices/servo/servo.hpp`): 50 Hz LEDC donanım PWM ile servo sınıfı. Kanalları üstten ayırır — `analogWrite` motor PWM'iyle timer çakışması (servo titremesinin 1 numaralı yazılım @@ -132,6 +143,17 @@ Versioning: [Semantic Versioning](https://semver.org/). Continued link hardening, servo support, documentation overhaul. ### Added +- **RF/TCP tuning** (grounded in datasheet/IDF source research): TX + power fix — `WIFI_POWER_19_5dBm` quantized down to **18 dBm** in the + API; we now request the true API max of **20 dBm** (+2 dB); 802.11b + rates disabled by default (6x less beacon airtime; re-enable with + `PROBOT_WIFI_ENABLE_11B`); `TCP_NODELAY` on httpd sockets (kills the + Nagle × delayed-ACK stall) and TCP keepalive (~7 s dead-client + detection); WS sends serialized with a mutex (concurrent + `httpd_ws_send_frame_async` corrupts frames, esp-idf #14495); + `send_wait_timeout` 5 s → 2 s; `max_open_sockets` 10. New macros: + `PROBOT_INPUT_TIMEOUT_MS`, `PROBOT_WIFI_ENABLE_11B`, + `PROBOT_WIFI_PMF_REQUIRED`. - **`probot::devices::Servo`** (`devices/servo/servo.hpp`): hobby-servo class on 50 Hz LEDC hardware PWM. Channels are allocated from the top of the range downward, so a timer collision with `analogWrite` motor diff --git a/README.md b/README.md index dd5d101..cb0e39d 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,9 @@ Hepsi `#include ` satırından **önce** tanımlanır: | `PROBOT_DS_TIMEOUT_MS` | `10000` | DS'ten veri kesilirse timeout (ms) | | `PROBOT_DS_TIMEOUT_FORCE_STOP` | `1` | `1`: timeout'ta robot STOP. `0`: loop sürer, joystick nötr, bağlantı dönünce devam | | `PROBOT_DS_OWNER_TIMEOUT_MS` | `5000` | Sahip client sessiz kalırsa slotun boşalma süresi | +| `PROBOT_INPUT_TIMEOUT_MS` | `500` | Joystick verisi kesilince eksenlerin sıfırlanma süresi | +| `PROBOT_WIFI_ENABLE_11B` | `0` | `1`: 802.11b hızlarını aç (sadece 2010 öncesi cihazlar için; beacon airtime'ını 6 kat artırır) | +| `PROBOT_WIFI_PMF_REQUIRED` | `0` | `1`: PMF (802.11w) zorunlu — deauth sahteciliğine karşı koruma, eski tabletlerle uyumsuz olabilir | | `NEOPIXEL_PIN` / `NEOPIXEL_COUNT` | `3` / `1` | Durum LED'i pini/adedi | ## Yarışma günü: kanal planı diff --git a/src/driverstation/esp32s3/driver_station_esp32.hpp b/src/driverstation/esp32s3/driver_station_esp32.hpp index 7d5da23..ac7e4a0 100644 --- a/src/driverstation/esp32s3/driver_station_esp32.hpp +++ b/src/driverstation/esp32s3/driver_station_esp32.hpp @@ -42,6 +42,23 @@ static_assert(PROBOT_WIFI_AP_CHANNEL >= 0 && PROBOT_WIFI_AP_CHANNEL <= 13, #define PROBOT_DS_OWNER_TIMEOUT_MS 5000 #endif +// 802.11b rates: disabled by default. With b-rates on, beacons go out at +// 1 Mbps DSSS (~2.5 ms airtime each — ~2.5% of the channel per AP); with +// them off, 6 Mbps OFDM (~0.4%). Every WiFi-certified 2.4 GHz client +// since 802.11g (any 2010+ phone/tablet) supports OFDM. Set to 1 only if +// you must support a pre-802.11g device. +#ifndef PROBOT_WIFI_ENABLE_11B +#define PROBOT_WIFI_ENABLE_11B 0 +#endif + +// Protected Management Frames (802.11w). Set to 1 to require PMF, which +// blocks deauth-spoofing attacks (a real sabotage vector at events) but +// also blocks rare clients without PMF support. Default off for maximum +// compatibility with old tablets. +#ifndef PROBOT_WIFI_PMF_REQUIRED +#define PROBOT_WIFI_PMF_REQUIRED 0 +#endif + namespace probot::driverstation::esp32 { class DriverStation { public: @@ -73,10 +90,29 @@ namespace probot::driverstation::esp32 { WiFi.mode(WIFI_AP); esp_wifi_set_country(&country); +#if !PROBOT_WIFI_ENABLE_11B + esp_err_t rate_err = esp_wifi_config_11b_rate(WIFI_IF_AP, true); + if (rate_err != ESP_OK) { + Serial.printf("[DS ] 11b rate disable failed: 0x%x\n", rate_err); + } +#endif WiFi.softAP(ssid.c_str(), pw, channel_); esp_wifi_set_bandwidth(WIFI_IF_AP, WIFI_BW_HT20); esp_wifi_set_ps(WIFI_PS_NONE); - WiFi.setTxPower(WIFI_POWER_19_5dBm); + // The TX power API quantizes downward: requesting 19.5 dBm (=78) + // actually yields 18 dBm; any request >= 80 yields the true API + // max of 20 dBm. WIFI_POWER_21dBm therefore buys +2 dB over the + // old WIFI_POWER_19_5dBm setting. + WiFi.setTxPower(WIFI_POWER_21dBm); +#if PROBOT_WIFI_PMF_REQUIRED + { + wifi_config_t apcfg; + if (esp_wifi_get_config(WIFI_IF_AP, &apcfg) == ESP_OK) { + apcfg.ap.pmf_cfg.required = true; + esp_wifi_set_config(WIFI_IF_AP, &apcfg); + } + } +#endif Serial.println("[DS ] ========================================"); Serial.print("[DS ] WiFi SSID: "); @@ -99,6 +135,21 @@ namespace probot::driverstation::esp32 { // Keep all networking on core 0 with the WiFi stack; core 1 stays // exclusively for user teleop/autonomous loops. cfg.core_id = 0; + // Ceiling is CONFIG_LWIP_MAX_SOCKETS(16) - 3 reserved = 13. + cfg.max_open_sockets = 10; + // httpd does NOT set TCP_NODELAY by default; without it, LWIP's + // Nagle + the client's delayed ACK can hold small server->client + // frames for tens of ms. + cfg.open_fn = onSocketOpen; + // Bound how long a send to a stalled client can block (default 5s). + cfg.send_wait_timeout = 2; + // TCP keepalive detects a vanished client (tablet walked away, + // battery died) in ~7s at the TCP layer, closing the session + // without app-level machinery. + cfg.keep_alive_enable = true; + cfg.keep_alive_idle = 3; + cfg.keep_alive_interval = 2; + cfg.keep_alive_count = 2; if (httpd_start(&_server, &cfg) != ESP_OK) { Serial.println("[DS ] Failed to start HTTP server"); @@ -212,6 +263,12 @@ namespace probot::driverstation::esp32 { return static_cast(req->user_ctx); } + static esp_err_t onSocketOpen(httpd_handle_t hd, int sockfd) { + int yes = 1; + setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &yes, sizeof(yes)); + return ESP_OK; + } + // ── Client IP extraction ── static bool getClientIP(httpd_req_t* req, char* out, size_t out_len) { diff --git a/src/driverstation/esp32s3/ws_joystick.hpp b/src/driverstation/esp32s3/ws_joystick.hpp index 17cbd09..0614cef 100644 --- a/src/driverstation/esp32s3/ws_joystick.hpp +++ b/src/driverstation/esp32s3/ws_joystick.hpp @@ -1,6 +1,8 @@ #pragma once #ifdef ESP32 #include +#include +#include #include #include @@ -32,6 +34,7 @@ namespace probot::driverstation::esp32 { void attach(httpd_handle_t server) { _server = server; + _sendMutex = xSemaphoreCreateMutex(); httpd_uri_t ws_uri = { .uri = "/joystick", @@ -113,9 +116,17 @@ namespace probot::driverstation::esp32 { // Handle control frames if (frame.type == HTTPD_WS_TYPE_CLOSE) return ESP_OK; if (frame.type == HTTPD_WS_TYPE_PING) { + // Serialize against the heartbeat timer: concurrent WS sends to + // one fd interleave header/payload and corrupt frames + // (esp-idf #14495). httpd_ws_frame_t pong = {}; pong.type = HTTPD_WS_TYPE_PONG; - return httpd_ws_send_frame(req, &pong); + esp_err_t perr = ESP_OK; + if (!self->_sendMutex || xSemaphoreTake(self->_sendMutex, pdMS_TO_TICKS(100)) == pdTRUE) { + perr = httpd_ws_send_frame(req, &pong); + if (self->_sendMutex) xSemaphoreGive(self->_sendMutex); + } + return perr; } if (frame.type != HTTPD_WS_TYPE_BINARY) return ESP_OK; @@ -172,6 +183,9 @@ namespace probot::driverstation::esp32 { static void heartbeatTimerCb(TimerHandle_t t) { auto* self = static_cast(pvTimerGetTimerID(t)); if (!self->_server) return; + // Never block the timer-service task: if a send is in flight, + // skip this round (next heartbeat comes in 2s). + if (self->_sendMutex && xSemaphoreTake(self->_sendMutex, 0) != pdTRUE) return; static uint8_t seq = 0; uint8_t payload[2] = { HEARTBEAT_MAGIC, ++seq }; httpd_ws_frame_t hb = {}; @@ -205,11 +219,13 @@ namespace probot::driverstation::esp32 { *fails = 0; } } + if (self->_sendMutex) xSemaphoreGive(self->_sendMutex); } io::GamepadService& _gs; httpd_handle_t _server = nullptr; TimerHandle_t _pingTimer = nullptr; + SemaphoreHandle_t _sendMutex = nullptr; OwnerAuthorizer _ownerAuthorizer = nullptr; void* _ownerCtx = nullptr; PingState _pingState[PING_TRACK_SLOTS] = {}; diff --git a/src/probot/io/gamepad.hpp b/src/probot/io/gamepad.hpp index b78f285..24afaa8 100644 --- a/src/probot/io/gamepad.hpp +++ b/src/probot/io/gamepad.hpp @@ -2,6 +2,13 @@ #include #include +// How long after the last received joystick packet axes/buttons keep +// their values before read() returns neutral. FRC uses 125 ms, FTC +// ~300 ms, WPILib XRP 500 ms; tighten for faster failsafe. +#ifndef PROBOT_INPUT_TIMEOUT_MS +#define PROBOT_INPUT_TIMEOUT_MS 500 +#endif + namespace probot::io { struct GamepadSnapshot { uint32_t ms; @@ -25,7 +32,7 @@ namespace probot::io { GamepadSnapshot z{}; _buf[0] = z; _buf[1] = z; - _timeout_ms = 500; + _timeout_ms = PROBOT_INPUT_TIMEOUT_MS; } // Written from two tasks (WS/HTTP handlers push frames, sysloop From 08a605cea9565f0f4e462e8091b3438d57ca7b2d Mon Sep 17 00:00:00 2001 From: tunapro Date: Wed, 10 Jun 2026 00:56:12 +0300 Subject: [PATCH 15/27] feat(ds): single-WS push architecture, captive portal, diagnostics, runtime channel switch Push: a dedicated task streams 'S' frames (state+health JSON, on change or every 1s - doubles as the heartbeat) and 'T' frames (telemetry text, on change) to all WS clients via mutex-guarded sendToAll. The page no longer polls /telemetry, /getState or /health over HTTP; it falls back to 1 Hz HTTP polling only while the WS is down, plus one /health every 10s for the RTT display. The WS stays connected across Init/Stop; with no gamepad the client sends a 1-byte 'P' keepalive every 2s so the owner slot and DS activity stay alive. Telemetry ring buffer gained a spinlock (user-core writer vs network-core reader had none) and a copyBuffer API; GamepadService gained lastWriteMs for true frame age. Captive portal (PROBOT_CAPTIVE_PORTAL=0 disables): DNS catch-all on the softAP + spoofed OS connectivity probes (Android generate_204, iOS hotspot-detect, Windows connecttest/ncsi) pop a landing page that sends users to the DS in a real browser; wpad.dat answered with a plain 404 to stop proxy-probe storms; all other 404s redirect to the portal. Diagnostics: AP STA join/leave logged with MAC + IEEE reason code; /health and the S frame now carry joyAgeMs, sta count and the last disconnect reason; shown on the Logs page. Channel: NVS override (UI > macro precedence, 0 = auto-scan) and /setChannel?ch=N switches 1-13 live via CSA (csa_count=3) so clients migrate without dropping; ch=0 applies at next boot. Logs page gained a channel picker; /info reports chSource. --- .../esp32s3/driver_station_esp32.hpp | 327 ++++++++++++++++-- src/driverstation/esp32s3/index_html.h | 258 +++++++++----- src/driverstation/esp32s3/ws_joystick.hpp | 152 ++++---- src/probot/core/runtime.hpp | 1 + src/probot/io/gamepad.hpp | 8 + src/probot/telemetry/telemetry.hpp | 43 ++- 6 files changed, 605 insertions(+), 184 deletions(-) diff --git a/src/driverstation/esp32s3/driver_station_esp32.hpp b/src/driverstation/esp32s3/driver_station_esp32.hpp index ac7e4a0..fe9116b 100644 --- a/src/driverstation/esp32s3/driver_station_esp32.hpp +++ b/src/driverstation/esp32s3/driver_station_esp32.hpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include #include @@ -59,6 +61,23 @@ static_assert(PROBOT_WIFI_AP_CHANNEL >= 0 && PROBOT_WIFI_AP_CHANNEL <= 13, #define PROBOT_WIFI_PMF_REQUIRED 0 #endif +// Captive portal: answer every DNS query with the robot's IP and spoof +// the OS connectivity probes so joining the AP pops a landing page — +// students never type an IP. Set to 0 to disable. +#ifndef PROBOT_CAPTIVE_PORTAL +#define PROBOT_CAPTIVE_PORTAL 1 +#endif + +namespace probot::driverstation::esp32::diag { + // Written from the WiFi event task, read by the push task / handlers. + inline volatile uint8_t g_last_disc_reason = 0; + inline volatile uint32_t g_last_disc_ms = 0; + inline volatile int32_t g_sta_count = 0; + // Captive-portal redirect target; the 404 error handler has no + // user_ctx, so this lives at namespace scope. + inline char g_portal_url[48] = "http://192.168.4.1/portal"; +} + namespace probot::driverstation::esp32 { class DriverStation { public: @@ -75,10 +94,47 @@ namespace probot::driverstation::esp32 { #endif ap_ssid_ = ssid; + // Log STA joins/leaves with the IEEE reason code — the field + // answer to "why did it disconnect?". + WiFi.onEvent([](WiFiEvent_t, WiFiEventInfo_t info){ + diag::g_sta_count = diag::g_sta_count + 1; + Serial.printf("[DS ] STA joined: %02X:%02X:%02X:%02X:%02X:%02X (aid %d)\n", + info.wifi_ap_staconnected.mac[0], info.wifi_ap_staconnected.mac[1], + info.wifi_ap_staconnected.mac[2], info.wifi_ap_staconnected.mac[3], + info.wifi_ap_staconnected.mac[4], info.wifi_ap_staconnected.mac[5], + info.wifi_ap_staconnected.aid); + }, ARDUINO_EVENT_WIFI_AP_STACONNECTED); + WiFi.onEvent([](WiFiEvent_t, WiFiEventInfo_t info){ + if (diag::g_sta_count > 0) diag::g_sta_count = diag::g_sta_count - 1; + diag::g_last_disc_reason = info.wifi_ap_stadisconnected.reason; + diag::g_last_disc_ms = millis(); + Serial.printf("[DS ] STA left: %02X:%02X:%02X:%02X:%02X:%02X reason=%d\n", + info.wifi_ap_stadisconnected.mac[0], info.wifi_ap_stadisconnected.mac[1], + info.wifi_ap_stadisconnected.mac[2], info.wifi_ap_stadisconnected.mac[3], + info.wifi_ap_stadisconnected.mac[4], info.wifi_ap_stadisconnected.mac[5], + (int)info.wifi_ap_stadisconnected.reason); + }, ARDUINO_EVENT_WIFI_AP_STADISCONNECTED); + wifi_country_t country = { .cc = "TR", .schan = 1, .nchan = 13, .policy = WIFI_COUNTRY_POLICY_MANUAL }; - channel_ = PROBOT_WIFI_AP_CHANNEL; - if (PROBOT_WIFI_AP_CHANNEL == 0) { + // Channel precedence: NVS override (set from the UI) > macro. + // 0 means auto-select in either source. + int requestedChannel = PROBOT_WIFI_AP_CHANNEL; + ch_source_ = "macro"; + { + Preferences prefs; + if (prefs.begin("probot", /*readOnly=*/true)) { + int nvsCh = prefs.getInt("ch", -1); + prefs.end(); + if (nvsCh >= 0 && nvsCh <= 13) { + requestedChannel = nvsCh; + ch_source_ = "nvs"; + } + } + } + + channel_ = requestedChannel; + if (requestedChannel == 0) { // Auto-select: scan the band and pick the least congested of the // non-overlapping channels. Adds ~2-3 s to boot. Clients find the // AP by SSID regardless of channel, so this is transparent to the @@ -86,6 +142,7 @@ namespace probot::driverstation::esp32 { WiFi.mode(WIFI_STA); esp_wifi_set_country(&country); channel_ = autoSelectChannel(); + ch_source_ = "auto"; } WiFi.mode(WIFI_AP); @@ -130,7 +187,7 @@ namespace probot::driverstation::esp32 { cfg.server_port = 80; cfg.ctrl_port = 32768; cfg.stack_size = 8192; - cfg.max_uri_handlers = 12; + cfg.max_uri_handlers = 24; cfg.lru_purge_enable = true; // Keep all networking on core 0 with the WiFi stack; core 1 stays // exclusively for user teleop/autonomous loops. @@ -160,19 +217,53 @@ namespace probot::driverstation::esp32 { registerUri("/", HTTP_GET, handleRoot); registerUri("/updateController", HTTP_POST, handleUpdateController); registerUri("/robotControl", HTTP_GET, handleRobotControl); + registerUri("/setChannel", HTTP_GET, handleSetChannel); registerUri("/getState", HTTP_GET, handleGetState); registerUri("/getBattery", HTTP_GET, handleGetBattery); registerUri("/telemetry", HTTP_GET, handleTelemetry); registerUri("/health", HTTP_GET, handleHealth); registerUri("/info", HTTP_GET, handleInfo); +#if PROBOT_CAPTIVE_PORTAL + // Captive portal: spoofed OS connectivity probes + catch-all DNS + // make the landing page pop when a device joins the AP. + snprintf(diag::g_portal_url, sizeof(diag::g_portal_url), "http://%s/portal", + WiFi.softAPIP().toString().c_str()); + registerUri("/portal", HTTP_GET, handlePortal); + registerUri("/generate_204", HTTP_GET, handleProbeRedirect); // Android + registerUri("/gen_204", HTTP_GET, handleProbeRedirect); // Android (alt) + registerUri("/hotspot-detect.html", HTTP_GET, handleProbeRedirect); // iOS/macOS + registerUri("/connecttest.txt", HTTP_GET, handleProbeRedirect); // Windows + registerUri("/ncsi.txt", HTTP_GET, handleProbeRedirect); // Windows legacy + registerUri("/redirect", HTTP_GET, handleProbeRedirect); + registerUri("/canonical.html", HTTP_GET, handleProbeRedirect); // Firefox + registerUri("/wpad.dat", HTTP_GET, handleWpad); // stop proxy-probe storms + httpd_register_err_handler(_server, HTTPD_404_NOT_FOUND, handle404Redirect); + + _dns_started = _dns.start(53, "*", WiFi.softAPIP()); + Serial.printf("[DS ] Captive portal %s\n", _dns_started ? "active" : "DNS FAILED"); +#endif + // Attach WebSocket handler (with owner gatekeeper) _ws.setOwnerAuthorizer(&DriverStation::wsOwnerAuthorizer, this); _ws.attach(_server); + // Push task: streams state/health/telemetry to WS clients so the + // page never has to poll over HTTP. Core 0 with the rest of + // networking; user code keeps core 1. + xTaskCreatePinnedToCore(pushTaskEntry, "ds_push", 4096, this, 3, &_push_task, 0); + Serial.println("[DS ] HTTP server started on port 80"); } + // Called from sysloop (~1 kHz): answers pending captive-portal DNS + // queries. Non-blocking. + void processDns() { +#if PROBOT_CAPTIVE_PORTAL + if (_dns_started) _dns.processNextRequest(); +#endif + } + void expireOwnerIfIdle(){ if (_owner_timeout_ms == 0) return; uint32_t now = millis(); @@ -269,6 +360,86 @@ namespace probot::driverstation::esp32 { return ESP_OK; } + static int8_t readApRssi() { + wifi_sta_list_t sta_list; + if (esp_wifi_ap_get_sta_list(&sta_list) == ESP_OK && sta_list.num > 0) { + return sta_list.sta[0].rssi; + } + return -100; + } + + static uint32_t computeAutoRemainingMs(const robot::StateSnapshot& s, uint32_t now_ms) { + if (s.phase != probot::robot::Phase::AUTONOMOUS || !s.autonomousEnabled || + s.autoStartMs == 0 || s.autoPeriodSeconds <= 0) { + return 0; + } + uint32_t total_ms = static_cast(s.autoPeriodSeconds) * 1000u; + uint32_t elapsed = now_ms - s.autoStartMs; + return (elapsed >= total_ms) ? 0u : (total_ms - elapsed); + } + + // ── WS push task ── + // Streams 'S' (state+health JSON, also the heartbeat) and 'T' + // (telemetry text) frames so the page never polls over HTTP. + + size_t buildStateHealthJson(char* out, size_t out_size, const robot::StateSnapshot& s, uint32_t now) { + uint32_t joyLast = _gs.lastWriteMs(); + long joyAge = joyLast ? (long)(uint32_t)(now - joyLast) : -1; + int n = snprintf(out, out_size, + "{\"phase\":%u,\"autonomousEnabled\":%s,\"autoPeriodSeconds\":%d," + "\"autoRemainingMs\":%u,\"rssi\":%d,\"up\":%lu,\"heap\":%lu,\"dm\":%s," + "\"joyAgeMs\":%ld,\"sta\":%ld,\"disc\":%u}", + static_cast(s.phase), + s.autonomousEnabled ? "true" : "false", + (int)s.autoPeriodSeconds, + (unsigned)computeAutoRemainingMs(s, now), + (int)readApRssi(), + (unsigned long)now, + (unsigned long)ESP.getFreeHeap(), + s.deadlineMiss ? "true" : "false", + joyAge, + (long)diag::g_sta_count, + (unsigned)diag::g_last_disc_reason); + if (n < 0) return 0; + return ((size_t)n >= out_size) ? out_size - 1 : (size_t)n; + } + + static void pushTaskEntry(void* arg) { + static_cast(arg)->pushTaskLoop(); + } + + void pushTaskLoop() { + uint32_t lastStateSent = 0; + uint32_t lastStateSeq = ~0u; + uint32_t lastTelemSeq = ~0u; + for (;;) { + vTaskDelay(pdMS_TO_TICKS(250)); + if (!_server) continue; + uint32_t now = millis(); + + // State changes go out on the next tick; otherwise re-sent every + // second so the frame doubles as the link heartbeat. + auto s = _rs.read(); + if (s.seq != lastStateSeq || (uint32_t)(now - lastStateSent) >= 1000) { + char buf[352]; + buf[0] = 'S'; + size_t n = 1 + buildStateHealthJson(buf + 1, sizeof(buf) - 1, s, now); + _ws.sendToAll(reinterpret_cast(buf), n); + lastStateSent = now; + lastStateSeq = s.seq; + } + + uint32_t tseq = probot::telemetry::getSeq(); + if (tseq != lastTelemSeq) { + char tbuf[1 + probot::telemetry::detail::BUFFER_SIZE + 1]; + tbuf[0] = 'T'; + size_t n = probot::telemetry::copyBuffer(tbuf + 1, sizeof(tbuf) - 1); + _ws.sendToAll(reinterpret_cast(tbuf), 1 + n); + lastTelemSeq = tseq; + } + } + } + // ── Client IP extraction ── static bool getClientIP(httpd_req_t* req, char* out, size_t out_len) { @@ -499,26 +670,70 @@ namespace probot::driverstation::esp32 { return ESP_OK; } + static esp_err_t handleSetChannel(httpd_req_t* req) { + auto* ds = self(req); + if (!ds->enforceOwner(req)) return ESP_OK; + + char query[32] = {0}; + char chVal[8] = {0}; + httpd_req_get_url_query_str(req, query, sizeof(query)); + if (httpd_query_key_value(query, "ch", chVal, sizeof(chVal)) != ESP_OK || chVal[0] == '\0') { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "ch parameter missing"); + return ESP_OK; + } + int ch = atoi(chVal); + if (ch < 0 || ch > 13) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "ch must be 0-13 (0 = auto at boot)"); + return ESP_OK; + } + + bool saved = false; + { + Preferences prefs; + if (prefs.begin("probot", /*readOnly=*/false)) { + saved = prefs.putInt("ch", ch) > 0; + prefs.end(); + } + } + + // 1-13: switch live via CSA — beacons announce the migration and + // compliant clients follow without disconnecting. 0 (auto) needs a + // scan, which would drop clients, so it applies at next boot. + bool live = false; + if (ch >= 1 && ch <= 13 && ch != ds->channel_) { + wifi_config_t cfg; + if (esp_wifi_get_config(WIFI_IF_AP, &cfg) == ESP_OK) { + cfg.ap.channel = (uint8_t)ch; + cfg.ap.csa_count = 3; + if (esp_wifi_set_config(WIFI_IF_AP, &cfg) == ESP_OK) { + ds->channel_ = ch; + ds->ch_source_ = "nvs"; + live = true; + Serial.printf("[DS ] Channel switching to %d via CSA\n", ch); + } + } + } + + char buf[96]; + snprintf(buf, sizeof(buf), "{\"ok\":%s,\"ch\":%d,\"live\":%s}", + saved ? "true" : "false", ch, live ? "true" : "false"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, buf, HTTPD_RESP_USE_STRLEN); + return ESP_OK; + } + static esp_err_t handleGetState(httpd_req_t* req) { auto* ds = self(req); if (!ds->enforceOwner(req)) return ESP_OK; auto s = ds->_rs.read(); - uint32_t now_ms = millis(); - uint32_t remaining_ms = 0; - if (s.phase == probot::robot::Phase::AUTONOMOUS && s.autonomousEnabled && - s.autoStartMs != 0 && s.autoPeriodSeconds > 0) { - uint32_t total_ms = static_cast(s.autoPeriodSeconds) * 1000u; - uint32_t elapsed = now_ms - s.autoStartMs; - remaining_ms = (elapsed >= total_ms) ? 0u : (total_ms - elapsed); - } char buf[128]; snprintf(buf, sizeof(buf), "{\"phase\":%u,\"autonomousEnabled\":%s,\"autoPeriodSeconds\":%d,\"autoRemainingMs\":%u}", static_cast(s.phase), s.autonomousEnabled ? "true" : "false", (int)s.autoPeriodSeconds, - (unsigned)remaining_ms); + (unsigned)computeAutoRemainingMs(s, millis())); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, buf, HTTPD_RESP_USE_STRLEN); @@ -538,7 +753,9 @@ namespace probot::driverstation::esp32 { auto* ds = self(req); if (!ds->enforceOwner(req)) return ESP_OK; - httpd_resp_send(req, probot::telemetry::getBuffer(), HTTPD_RESP_USE_STRLEN); + char buf[probot::telemetry::detail::BUFFER_SIZE + 1]; + probot::telemetry::copyBuffer(buf, sizeof(buf)); + httpd_resp_send(req, buf, HTTPD_RESP_USE_STRLEN); return ESP_OK; } @@ -548,25 +765,80 @@ namespace probot::driverstation::esp32 { static esp_err_t handleHealth(httpd_req_t* req) { auto* ds = self(req); - int8_t rssi = -100; - wifi_sta_list_t sta_list; - if (esp_wifi_ap_get_sta_list(&sta_list) == ESP_OK && sta_list.num > 0) { - rssi = sta_list.sta[0].rssi; - } auto s = ds->_rs.read(); - char buf[128]; + uint32_t now = millis(); + uint32_t joyLast = ds->_gs.lastWriteMs(); + long joyAge = joyLast ? (long)(uint32_t)(now - joyLast) : -1; + char buf[192]; snprintf(buf, sizeof(buf), - "{\"rssi\":%d,\"up\":%lu,\"heap\":%lu,\"dm\":%s}", - (int)rssi, - (unsigned long)millis(), + "{\"rssi\":%d,\"up\":%lu,\"heap\":%lu,\"dm\":%s," + "\"joyAgeMs\":%ld,\"sta\":%ld,\"disc\":%u}", + (int)readApRssi(), + (unsigned long)now, (unsigned long)ESP.getFreeHeap(), - s.deadlineMiss ? "true" : "false"); + s.deadlineMiss ? "true" : "false", + joyAge, + (long)diag::g_sta_count, + (unsigned)diag::g_last_disc_reason); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, buf, HTTPD_RESP_USE_STRLEN); return ESP_OK; } + // ── Captive portal (all owner-free) ── + + static esp_err_t handleProbeRedirect(httpd_req_t* req) { + httpd_resp_set_status(req, "302 Found"); + httpd_resp_set_hdr(req, "Location", diag::g_portal_url); + httpd_resp_send(req, NULL, 0); + return ESP_OK; + } + + static esp_err_t handle404Redirect(httpd_req_t* req, httpd_err_code_t) { + httpd_resp_set_status(req, "302 Found"); + httpd_resp_set_hdr(req, "Location", diag::g_portal_url); + httpd_resp_send(req, NULL, 0); + return ESP_OK; + } + + // Plain 404 (NOT via httpd_resp_send_err, which would invoke the + // redirect above) — stops Windows wpad proxy-probe retry storms. + static esp_err_t handleWpad(httpd_req_t* req) { + httpd_resp_set_status(req, "404 Not Found"); + httpd_resp_send(req, NULL, 0); + return ESP_OK; + } + + // Landing page shown by the OS sign-in sheet. Must not contain the + // word "Success" (iOS treats that as "no portal"). The OS mini + // browser is throttled and killed when dismissed, so the page sends + // users to a real browser instead of hosting the DS itself. + static esp_err_t handlePortal(httpd_req_t* req) { + auto* ds = self(req); + char page[768]; + snprintf(page, sizeof(page), + "" + "" + "Probot" + "

🤖 %s

" + "Driver Station'ı Aç" + "

Buton bu pencerede açılırsa: pencereyi kapatıp " + "tarayıcıda http://%s adresini açın.

" + "", + ds->ap_ssid_.c_str(), + WiFi.softAPIP().toString().c_str(), + WiFi.softAPIP().toString().c_str()); + httpd_resp_set_type(req, "text/html; charset=utf-8"); + httpd_resp_send(req, page, HTTPD_RESP_USE_STRLEN); + return ESP_OK; + } + static esp_err_t handleInfo(httpd_req_t* req) { auto* ds = self(req); @@ -575,12 +847,13 @@ namespace probot::driverstation::esp32 { // want other teams' monitoring stations harvesting it. char buf[512]; snprintf(buf, sizeof(buf), - "{\"ssid\":\"%s\",\"ch\":%d,\"ip\":\"%s\"," + "{\"ssid\":\"%s\",\"ch\":%d,\"chSource\":\"%s\",\"ip\":\"%s\"," "\"chip\":\"%s\",\"cpuMhz\":%lu,\"sdk\":\"%s\"," "\"totalHeap\":%lu,\"totalFlash\":%lu," "\"sketchSize\":%lu,\"freeSketch\":%lu,\"psram\":%lu}", ds->ap_ssid_.c_str(), ds->channel_, + ds->ch_source_, WiFi.softAPIP().toString().c_str(), ESP.getChipModel(), (unsigned long)ESP.getCpuFreqMHz(), @@ -601,13 +874,19 @@ namespace probot::driverstation::esp32 { io::GamepadService& _gs; WsJoystick _ws; httpd_handle_t _server = nullptr; + TaskHandle_t _push_task = nullptr; portMUX_TYPE _owner_mux = portMUX_INITIALIZER_UNLOCKED; bool _owner_set = false; char _owner_str[48] = {0}; uint32_t _owner_last_ms = 0; uint32_t _owner_timeout_ms = PROBOT_DS_OWNER_TIMEOUT_MS; int channel_ = PROBOT_WIFI_AP_CHANNEL; + const char* ch_source_ = "macro"; String ap_ssid_; +#if PROBOT_CAPTIVE_PORTAL + DNSServer _dns; + bool _dns_started = false; +#endif }; } #endif // ESP32 diff --git a/src/driverstation/esp32s3/index_html.h b/src/driverstation/esp32s3/index_html.h index 3f62ac7..4ef268f 100644 --- a/src/driverstation/esp32s3/index_html.h +++ b/src/driverstation/esp32s3/index_html.h @@ -766,6 +766,23 @@ const char MAIN_page[] PROGMEM = R"=====( --
+
+ +
+ + +
+ 1/5/9/13 önerilir +

Network Status

@@ -786,6 +803,18 @@ const char MAIN_page[] PROGMEM = R"=====( Deadline Miss -- +
+ Joystick Age + -- +
+
+ Clients + -- +
+
+ Last Disconnect + -- +
@@ -961,19 +990,9 @@ const char MAIN_page[] PROGMEM = R"=====( updateAutoDisplay(); } - /* ===== SYNC STATE ===== */ - var syncStateBusy=false; - function syncState(){ - if(syncStateBusy) return; - syncStateBusy=true; - var ac=new AbortController(); - var tid=setTimeout(function(){ac.abort();},3000); - fetch('/getState',{signal:ac.signal}).then(function(r){ - clearTimeout(tid); - if(!r.ok) return; - return r.json(); - }).then(function(data){ - syncStateBusy=false; + /* ===== STATE RENDER ===== */ + /* Fed by 'S' WS frames normally; by the HTTP fallback when WS is down. */ + function applyState(data){ if(!data) return; var btn=document.getElementById('robotButton'); if(!btn) return; @@ -1036,9 +1055,23 @@ const char MAIN_page[] PROGMEM = R"=====( stopAutoTimer(); setPhaseDisplay('stopped'); } - }).catch(function(e){ - syncStateBusy=false; - console.error('syncState failed:',e); + } + + var fetchStateBusy=false; + function fetchState(){ + if(fetchStateBusy) return; + fetchStateBusy=true; + var ac=new AbortController(); + var tid=setTimeout(function(){ac.abort();},3000); + fetch('/getState',{signal:ac.signal}).then(function(r){ + clearTimeout(tid); + if(!r.ok) return; + return r.json(); + }).then(function(data){ + fetchStateBusy=false; + if(data){lastDataMs=performance.now();applyState(data);} + }).catch(function(){ + fetchStateBusy=false; }); } @@ -1054,9 +1087,6 @@ const char MAIN_page[] PROGMEM = R"=====( default: cmd="stop"; break; } - if(cmd==="stop"){wsStopped=true;killWs();} - else if(cmd==="init"){connectWebSocket();} - var url='/robotControl?cmd='+cmd+'&auto='+(enableAuto?1:0)+'&autoLen='+autoLen; var ac=new AbortController(); var tid=setTimeout(function(){ac.abort();},3000); @@ -1134,49 +1164,85 @@ const char MAIN_page[] PROGMEM = R"=====( var lastGamepadSend=0; var GAMEPAD_SEND_INTERVAL=20; - /* ===== WEBSOCKET ===== */ + /* ===== WEBSOCKET ===== + Single always-on socket. Client sends 'J' joystick frames (50Hz) + or a 'P' idle ping (2s, keeps the owner slot alive). Robot pushes + 'S' state+health JSON (>=1Hz, doubles as heartbeat) and 'T' + telemetry text — no HTTP polling while the socket is up. */ var wsJoystick=null; var wsConnected=false; var wsReconnectTimer=null; var wsLastActivity=0; - var wsStopped=false; + var lastDataMs=performance.now(); + var textDecoder=new TextDecoder(); function killWs(){ if(wsReconnectTimer){clearTimeout(wsReconnectTimer);wsReconnectTimer=null;} if(wsJoystick){wsJoystick.onopen=null;wsJoystick.onclose=null;wsJoystick.onerror=null;wsJoystick.onmessage=null;try{wsJoystick.close();}catch(e){}} wsJoystick=null;wsConnected=false; } + function handleWsMessage(ev){ + wsLastActivity=performance.now(); + if(!(ev.data instanceof ArrayBuffer)) return; + var v=new Uint8Array(ev.data); + if(v.length<1) return; + if(v[0]===0x53){ /* 'S' state+health */ + var data; + try{data=JSON.parse(textDecoder.decode(v.subarray(1)));}catch(e){return;} + lastDataMs=performance.now(); + applyState(data); + applyHealth(data); + }else if(v[0]===0x54){ /* 'T' telemetry */ + lastDataMs=performance.now(); + renderTelemetry(textDecoder.decode(v.subarray(1))); + } + } function connectWebSocket(){ killWs(); - wsStopped=false; try{ var ws=new WebSocket('ws://'+location.host+'/joystick'); ws.binaryType='arraybuffer'; ws.onopen=function(){wsConnected=true;wsLastActivity=performance.now();console.log('[WS] Connected');}; - ws.onclose=function(){wsConnected=false;wsJoystick=null;if(!wsStopped)scheduleReconnect();}; + ws.onclose=function(){wsConnected=false;wsJoystick=null;scheduleReconnect();}; ws.onerror=function(){wsConnected=false;}; - ws.onmessage=function(){wsLastActivity=performance.now();}; + ws.onmessage=handleWsMessage; wsJoystick=ws; - }catch(e){if(!wsStopped)scheduleReconnect();} + }catch(e){scheduleReconnect();} } function scheduleReconnect(){ - if(wsReconnectTimer||wsStopped) return; - wsReconnectTimer=setTimeout(function(){wsReconnectTimer=null;if(!wsStopped)connectWebSocket();},2000); - } - /* Robot sends a binary heartbeat every 2s; missing ~2 in a row means - the link is dead even if the socket still looks open. Own sends do - NOT count as activity: ws.send() into a dead TCP socket succeeds - silently (frames just buffer), which used to mask dead links while - driving. */ - function wsHealthCheck(){ - if(wsStopped||!wsJoystick) return; - if(wsJoystick.readyState>1){wsConnected=false;wsJoystick=null;scheduleReconnect();return;} - if(wsConnected&&performance.now()-wsLastActivity>5000){ - console.log('[WS] Stale, reconnecting'); - killWs();scheduleReconnect(); + if(wsReconnectTimer) return; + wsReconnectTimer=setTimeout(function(){wsReconnectTimer=null;connectWebSocket();},2000); + } + /* Robot pushes an 'S' frame at least every second; ~5s without any + message means the link is dead even if the socket looks open. Own + sends do NOT count as activity: ws.send() into a dead TCP socket + succeeds silently. The overlay appears when no data has arrived + from any source (WS or HTTP fallback) for 6s. */ + function linkSupervisor(){ + if(wsJoystick){ + if(wsJoystick.readyState>1){ + wsConnected=false;wsJoystick=null;scheduleReconnect(); + }else if(wsConnected&&performance.now()-wsLastActivity>5000){ + console.log('[WS] Stale, reconnecting'); + killWs();scheduleReconnect(); + } + } + var overlay=document.getElementById('disconnectOverlay'); + if(overlay){ + if(performance.now()-lastDataMs>6000) overlay.classList.add('show'); + else overlay.classList.remove('show'); } } - setInterval(wsHealthCheck,1000); + setInterval(linkSupervisor,1000); + + /* Idle keepalive: with no gamepad active nothing else flows + client->robot, and the robot would release the owner slot. */ + setInterval(function(){ + if(wsConnected&&wsJoystick&&wsJoystick.readyState===1&& + performance.now()-lastGamepadSend>2000){ + try{wsJoystick.send(new Uint8Array([0x50]));}catch(e){} + } + },2000); function packJoystickBinary(gp){ var nA=gp.axes.length; @@ -1271,26 +1337,14 @@ const char MAIN_page[] PROGMEM = R"=====( }); /* ===== TELEMETRY ===== */ - /* In-flight guard + timeout: without it, a congested link lets - requests pile up faster than they complete, making the jam worse. - Hidden tabs skip polling entirely — joystick frames matter more. */ - var telemetryBusy=false; - function pollTelemetry(){ - if(telemetryBusy||document.hidden) return; - telemetryBusy=true; - var ac=new AbortController(); - var tid=setTimeout(function(){ac.abort();},2000); - fetch('/telemetry',{signal:ac.signal}).then(function(r){ - clearTimeout(tid); - if(r.ok) return r.text(); - }).then(function(text){ - telemetryBusy=false; - var el=document.getElementById('telemetryOutput'); - if(el&&text){ - el.textContent=text; - if(autoScroll) el.scrollTop=el.scrollHeight; - } - }).catch(function(){telemetryBusy=false;}); + /* Pushed by the robot over WS ('T' frames) whenever the buffer + changes — no polling. */ + function renderTelemetry(text){ + var el=document.getElementById('telemetryOutput'); + if(el&&text){ + el.textContent=text; + if(autoScroll) el.scrollTop=el.scrollHeight; + } } function clearTelemetry(){ var el=document.getElementById('telemetryOutput'); @@ -1309,10 +1363,17 @@ const char MAIN_page[] PROGMEM = R"=====( if(el) el.scrollTop=el.scrollHeight; } } - /* 150ms is still smooth for a text log and cuts HTTP airtime ~3x - vs the old 50ms — leaves more room for joystick frames. */ - setInterval(pollTelemetry,150); - setInterval(syncState,1000); + /* HTTP fallback: only while the WS is down. One state+health pair + per second keeps the UI alive; joystick falls back to HTTP POST + in sendGamepadData. */ + setInterval(function(){ + if(!wsConnected){fetchState();fetchHealth();} + },1000); + /* RTT sample while WS is up: a single /health every 10s feeds the + ping display; everything else arrives over WS. */ + setInterval(function(){ + if(wsConnected) fetchHealth(); + },10000); /* ===== CONNECTION HEALTH ===== */ var healthFailCount=0; @@ -1321,8 +1382,27 @@ const char MAIN_page[] PROGMEM = R"=====( var lastHeap=0; var lastUpMs=0; var lastDm=false; + var lastJoyAge=-1; + var lastSta=0; + var lastDisc=0; + + /* Fed by 'S' WS frames normally; by fetchHealth over HTTP otherwise. */ + function applyHealth(data){ + lastRssi=(typeof data.rssi==='number')?data.rssi:-100; + lastHeap=(typeof data.heap==='number')?data.heap:0; + lastUpMs=(typeof data.up==='number')?data.up:0; + lastDm=!!data.dm; + if(typeof data.joyAgeMs==='number') lastJoyAge=data.joyAgeMs; + if(typeof data.sta==='number') lastSta=data.sta; + if(typeof data.disc==='number') lastDisc=data.disc; + updateConnUI(true); + updateDebugPanel(); + } - function healthCheck(){ + var fetchHealthBusy=false; + function fetchHealth(){ + if(fetchHealthBusy) return; + fetchHealthBusy=true; var start=performance.now(); var ac=new AbortController(); var tid=setTimeout(function(){ac.abort();},3000); @@ -1331,15 +1411,13 @@ const char MAIN_page[] PROGMEM = R"=====( if(!r.ok) throw new Error('health'); return r.json(); }).then(function(data){ + fetchHealthBusy=false; lastPingMs=Math.round(performance.now()-start); - lastRssi=(typeof data.rssi==='number')?data.rssi:-100; - lastHeap=(typeof data.heap==='number')?data.heap:0; - lastUpMs=(typeof data.up==='number')?data.up:0; - lastDm=!!data.dm; + lastDataMs=performance.now(); healthFailCount=0; - updateConnUI(true); - updateDebugPanel(); + applyHealth(data); }).catch(function(){ + fetchHealthBusy=false; healthFailCount++; updateConnUI(false); updateDebugPanel(); @@ -1351,19 +1429,16 @@ const char MAIN_page[] PROGMEM = R"=====( var ping=document.getElementById('connPing'); var heap=document.getElementById('connHeap'); var signal=document.getElementById('connSignal'); - var overlay=document.getElementById('disconnectOverlay'); - if(!dot||!ping||!signal||!overlay) return; + if(!dot||!ping||!signal) return; if(!ok){ dot.className='conn-dot bad'; ping.textContent='--'; if(heap) heap.textContent='--'; signal.querySelectorAll('.bar').forEach(function(b){b.classList.remove('active');}); - if(healthFailCount>=3) overlay.classList.add('show'); return; } - overlay.classList.remove('show'); ping.textContent=lastPingMs+'ms'; if(heap){ if(lastHeap>0&&infoTotalHeap>0) heap.textContent=Math.round(lastHeap/1024)+'/'+Math.round(infoTotalHeap/1024)+'KB'; @@ -1413,6 +1488,9 @@ const char MAIN_page[] PROGMEM = R"=====( } if(el('dbgWs')) el('dbgWs').textContent=wsConnected?'Connected':'Disconnected'; if(el('dbgDm')) el('dbgDm').textContent=lastDm?'YES':'No'; + if(el('dbgJoyAge')) el('dbgJoyAge').textContent=(lastJoyAge>=0)?(lastJoyAge+' ms'):'--'; + if(el('dbgSta')) el('dbgSta').textContent=String(lastSta); + if(el('dbgDisc')) el('dbgDisc').textContent=lastDisc?('reason '+lastDisc):'--'; } function fetchInfo(){ @@ -1423,7 +1501,9 @@ const char MAIN_page[] PROGMEM = R"=====( if(!data) return; var el=function(id){return document.getElementById(id);}; if(el('dbgSsid')) el('dbgSsid').textContent=data.ssid||'--'; - if(el('dbgCh')) el('dbgCh').textContent=data.ch||'--'; + if(el('dbgCh')) el('dbgCh').textContent=data.ch?(data.ch+(data.chSource?' ('+data.chSource+')':'')):'--'; + var chSel=document.getElementById('chSelect'); + if(chSel&&typeof data.ch==='number') chSel.value=String(data.ch); if(el('dbgIp')) el('dbgIp').textContent=data.ip||'--'; if(el('dbgChip')) el('dbgChip').textContent=data.chip||'--'; if(el('dbgCpu')) el('dbgCpu').textContent=data.cpuMhz?data.cpuMhz+' MHz':'--'; @@ -1442,7 +1522,27 @@ const char MAIN_page[] PROGMEM = R"=====( }).catch(function(){}); } - setInterval(healthCheck,2000); + /* ===== CHANNEL SWITCH (Logs page) ===== */ + function applyChannel(){ + var sel=document.getElementById('chSelect'); + var status=document.getElementById('chStatus'); + if(!sel) return; + var ch=parseInt(sel.value,10); + if(isNaN(ch)) return; + if(status) status.textContent='...'; + fetch('/setChannel?ch='+ch).then(function(r){ + if(!r.ok) throw new Error('setChannel '+r.status); + return r.json(); + }).then(function(d){ + if(!status) return; + if(d.live) status.textContent='Kanal '+d.ch+' (canlı geçiş)'; + else if(d.ch===0) status.textContent='Otomatik — yeniden başlatınca'; + else status.textContent='Kanal '+d.ch+' — kayıtlı'; + fetchInfo(); + }).catch(function(){ + if(status) status.textContent='Hata — tekrar deneyin'; + }); + } /* ===== AUTO PERIOD INPUT ===== */ document.getElementById('autoPeriod').addEventListener('input',function(e){ @@ -1476,9 +1576,9 @@ const char MAIN_page[] PROGMEM = R"=====( updateAutoDisplay(); setPhaseDisplay('standby'); requestAnimationFrame(gamepadLoop); - syncState(); connectWebSocket(); - healthCheck(); + fetchState(); + fetchHealth(); fetchInfo(); }); diff --git a/src/driverstation/esp32s3/ws_joystick.hpp b/src/driverstation/esp32s3/ws_joystick.hpp index 0614cef..8771dbd 100644 --- a/src/driverstation/esp32s3/ws_joystick.hpp +++ b/src/driverstation/esp32s3/ws_joystick.hpp @@ -9,15 +9,22 @@ namespace probot::driverstation::esp32 { /** - * WebSocket joystick handler (attaches to existing ESP-IDF httpd) + * WebSocket handler for /joystick (attaches to existing ESP-IDF httpd) * - * 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) + * Client -> robot binary frames (first byte = type): + * 'J' 0x4A joystick data: + * [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) + * 'P' 0x50 idle keepalive (no payload) — sent when no gamepad is + * active so the owner slot / DS activity stay alive. + * + * Robot -> client binary frames (sent by the DS push task via + * sendToAll; first byte = type): + * 'S' 0x53 state+health JSON (also serves as the heartbeat) + * 'T' 0x54 telemetry buffer text */ class WsJoystick { public: @@ -46,17 +53,60 @@ namespace probot::driverstation::esp32 { }; httpd_register_uri_handler(_server, &ws_uri); - // Periodic heartbeat to detect dead connections on both ends - _pingTimer = xTimerCreate("ws_hb", pdMS_TO_TICKS(HEARTBEAT_PERIOD_MS), pdTRUE, this, heartbeatTimerCb); - if (_pingTimer) xTimerStart(_pingTimer, 0); - Serial.println("[WS ] WebSocket handler attached to /joystick"); } + // Broadcast one binary frame to every connected WS client. Sends are + // serialized behind _sendMutex (concurrent sends to one fd corrupt + // frames — esp-idf #14495); per-fd consecutive failures close the + // session after SEND_MAX_FAILS. Returns the number of clients that + // received the frame. + int sendToAll(const uint8_t* data, size_t len) { + if (!_server || len == 0) return 0; + httpd_ws_frame_t frame = {}; + frame.type = HTTPD_WS_TYPE_BINARY; + frame.payload = const_cast(data); + frame.len = len; + + size_t fds = MAX_CLIENTS; + int clients[MAX_CLIENTS]; + if (httpd_get_client_list(_server, &fds, clients) != ESP_OK) return 0; + + if (_sendMutex && xSemaphoreTake(_sendMutex, pdMS_TO_TICKS(500)) != pdTRUE) return 0; + + // Prune tracked fds that are no longer WS clients + for (auto& s : _sendState) { + if (s.fd == -1) continue; + if (httpd_ws_get_fd_info(_server, s.fd) != HTTPD_WS_CLIENT_WEBSOCKET) { + s.fd = -1; s.fails = 0; + } + } + + int sent = 0; + for (size_t i = 0; i < fds; i++) { + if (httpd_ws_get_fd_info(_server, clients[i]) != HTTPD_WS_CLIENT_WEBSOCKET) continue; + uint8_t* fails = trackFd(clients[i]); + esp_err_t err = httpd_ws_send_frame_async(_server, clients[i], &frame); + if (err != ESP_OK) { + if (fails && ++(*fails) >= SEND_MAX_FAILS) { + Serial.printf("[WS ] /joystick send fail x%u, closing fd=%d\n", + (unsigned)*fails, clients[i]); + httpd_sess_trigger_close(_server, clients[i]); + clearFd(clients[i]); + } + } else { + sent++; + if (fails) *fails = 0; + } + } + if (_sendMutex) xSemaphoreGive(_sendMutex); + return sent; + } + void closeAll() { if (!_server) return; - size_t fds = 8; - int clients[8]; + size_t fds = MAX_CLIENTS; + int clients[MAX_CLIENTS]; if (httpd_get_client_list(_server, &fds, clients) != ESP_OK) return; for (size_t i = 0; i < fds; i++) { if (httpd_ws_get_fd_info(_server, clients[i]) == HTTPD_WS_CLIENT_WEBSOCKET) { @@ -66,25 +116,18 @@ namespace probot::driverstation::esp32 { } private: - static constexpr uint8_t MAGIC = 0x4A; + static constexpr uint8_t MAGIC = 0x4A; // 'J' joystick frame (client->robot) 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; - // Heartbeat is a 2-byte BINARY frame ('H', seq), not a WS PING: - // browsers auto-pong pings invisibly to JS, so the page cannot use - // them to tell a live link from a dead one. A data frame fires - // onmessage, giving the client a real liveness signal. - static constexpr uint8_t HEARTBEAT_MAGIC = 0x48; // 'H' - static constexpr uint32_t HEARTBEAT_PERIOD_MS = 2000; - - // Close a WS session only after this many consecutive heartbeat - // send failures. Tolerates brief RF hiccups in noisy environments + // Close a WS session only after this many consecutive send + // failures. Tolerates brief RF hiccups in noisy environments // (a single lost frame used to close the connection). - static constexpr uint8_t PING_MAX_FAILS = 3; - static constexpr uint8_t PING_TRACK_SLOTS = 8; + static constexpr uint8_t SEND_MAX_FAILS = 3; + static constexpr size_t MAX_CLIENTS = 8; - struct PingState { int fd = -1; uint8_t fails = 0; }; + struct SendState { int fd = -1; uint8_t fails = 0; }; static esp_err_t wsHandler(httpd_req_t* req) { auto* self = static_cast(req->user_ctx); @@ -170,65 +213,22 @@ namespace probot::driverstation::esp32 { __atomic_store_n(&probot::robot::g_ds_last_activity_ms, millis(), __ATOMIC_SEQ_CST); } - uint8_t* trackPingFd(int fd) { - for (auto& s : _pingState) if (s.fd == fd) return &s.fails; - for (auto& s : _pingState) if (s.fd == -1) { s.fd = fd; s.fails = 0; return &s.fails; } + uint8_t* trackFd(int fd) { + for (auto& s : _sendState) if (s.fd == fd) return &s.fails; + for (auto& s : _sendState) if (s.fd == -1) { s.fd = fd; s.fails = 0; return &s.fails; } return nullptr; } - void clearPingFd(int fd) { - for (auto& s : _pingState) if (s.fd == fd) { s.fd = -1; s.fails = 0; } - } - - static void heartbeatTimerCb(TimerHandle_t t) { - auto* self = static_cast(pvTimerGetTimerID(t)); - if (!self->_server) return; - // Never block the timer-service task: if a send is in flight, - // skip this round (next heartbeat comes in 2s). - if (self->_sendMutex && xSemaphoreTake(self->_sendMutex, 0) != pdTRUE) return; - static uint8_t seq = 0; - uint8_t payload[2] = { HEARTBEAT_MAGIC, ++seq }; - httpd_ws_frame_t hb = {}; - hb.type = HTTPD_WS_TYPE_BINARY; - hb.payload = payload; - hb.len = sizeof(payload); - size_t fds = 8; - int clients[8]; - if (httpd_get_client_list(self->_server, &fds, clients) != ESP_OK) return; - - // Prune tracked fds that are no longer WS clients - for (auto& s : self->_pingState) { - if (s.fd == -1) continue; - if (httpd_ws_get_fd_info(self->_server, s.fd) != HTTPD_WS_CLIENT_WEBSOCKET) { - s.fd = -1; s.fails = 0; - } - } - - for (size_t i = 0; i < fds; i++) { - if (httpd_ws_get_fd_info(self->_server, clients[i]) != HTTPD_WS_CLIENT_WEBSOCKET) continue; - uint8_t* fails = self->trackPingFd(clients[i]); - esp_err_t err = httpd_ws_send_frame_async(self->_server, clients[i], &hb); - if (err != ESP_OK) { - if (fails && ++(*fails) >= PING_MAX_FAILS) { - Serial.printf("[WS ] /joystick heartbeat fail x%u, closing fd=%d\n", - (unsigned)*fails, clients[i]); - httpd_sess_trigger_close(self->_server, clients[i]); - self->clearPingFd(clients[i]); - } - } else if (fails) { - *fails = 0; - } - } - if (self->_sendMutex) xSemaphoreGive(self->_sendMutex); + void clearFd(int fd) { + for (auto& s : _sendState) if (s.fd == fd) { s.fd = -1; s.fails = 0; } } io::GamepadService& _gs; httpd_handle_t _server = nullptr; - TimerHandle_t _pingTimer = nullptr; SemaphoreHandle_t _sendMutex = nullptr; OwnerAuthorizer _ownerAuthorizer = nullptr; void* _ownerCtx = nullptr; - PingState _pingState[PING_TRACK_SLOTS] = {}; + SendState _sendState[MAX_CLIENTS] = {}; }; } diff --git a/src/probot/core/runtime.hpp b/src/probot/core/runtime.hpp index 6580114..ecabb32 100644 --- a/src/probot/core/runtime.hpp +++ b/src/probot/core/runtime.hpp @@ -279,6 +279,7 @@ namespace probot { #ifdef ESP32 if (probot::driverstation::detail::g_driver_station){ probot::driverstation::detail::g_driver_station->expireOwnerIfIdle(); + probot::driverstation::detail::g_driver_station->processDns(); } #endif diff --git a/src/probot/io/gamepad.hpp b/src/probot/io/gamepad.hpp index 24afaa8..53576a5 100644 --- a/src/probot/io/gamepad.hpp +++ b/src/probot/io/gamepad.hpp @@ -59,6 +59,14 @@ namespace probot::io { __atomic_store_n(&_timeout_ms, timeout_ms, __ATOMIC_SEQ_CST); } + // Raw time of the last received packet — unlike read().ms this is + // not rewritten when the data goes stale, so it measures true + // joystick frame age for diagnostics. + uint32_t lastWriteMs() const { + uint32_t idx = __atomic_load_n(&_cur, __ATOMIC_SEQ_CST); + return _buf[idx].ms; + } + GamepadSnapshot read() const override { uint32_t idx = __atomic_load_n(&_cur, __ATOMIC_SEQ_CST); GamepadSnapshot s = _buf[idx]; diff --git a/src/probot/telemetry/telemetry.hpp b/src/probot/telemetry/telemetry.hpp index 1cf4526..ed04f2b 100644 --- a/src/probot/telemetry/telemetry.hpp +++ b/src/probot/telemetry/telemetry.hpp @@ -17,11 +17,32 @@ namespace detail { }; inline TelemetryBuffer g_buffer{}; + + // Writers run on the user core, readers on the network core — a + // spinlock keeps head/len/data consistent. Sections are short + // (memcpy of <=256 bytes), so spinning is fine. + inline volatile uint32_t g_lock = 0; + + inline void lock() { + uint32_t expected = 0; + while (!__atomic_compare_exchange_n(&g_lock, &expected, 1u, false, + __ATOMIC_ACQUIRE, __ATOMIC_RELAXED)) { + expected = 0; + } + } + + inline void unlock() { __atomic_store_n(&g_lock, 0u, __ATOMIC_RELEASE); } + + struct LockGuard { + LockGuard() { lock(); } + ~LockGuard() { unlock(); } + }; } inline void writeBytes(const char* msg, size_t msgLen) { auto& buf = detail::g_buffer; if (msgLen == 0) return; + detail::LockGuard guard; if (msgLen >= detail::BUFFER_SIZE) { msg += (msgLen - detail::BUFFER_SIZE); @@ -71,22 +92,26 @@ inline void printf(const char* fmt, ...) { inline void clear() { auto& buf = detail::g_buffer; + detail::LockGuard guard; buf.head = 0; buf.len = 0; uint32_t s = buf.seq; buf.seq = s + 1; } -// Internal: DS tarafından çağrılır -inline const char* getBuffer() { +// Internal: called by the DS. Copies into the caller's buffer (out_size +// must be >= BUFFER_SIZE + 1); returns bytes written excluding the NUL. +inline size_t copyBuffer(char* out, size_t out_size) { auto& buf = detail::g_buffer; - static char out[detail::BUFFER_SIZE + 1]; + if (out_size == 0) return 0; + detail::LockGuard guard; uint16_t len = buf.len; + if (len >= out_size) len = static_cast(out_size - 1); if (len == 0) { out[0] = '\0'; - return out; + return 0; } uint16_t head = buf.head; - uint16_t tail = static_cast((head + detail::BUFFER_SIZE - len) % detail::BUFFER_SIZE); + uint16_t tail = static_cast((head + detail::BUFFER_SIZE - buf.len) % detail::BUFFER_SIZE); size_t first = detail::BUFFER_SIZE - tail; if (first > len) first = len; memcpy(out, buf.data + tail, first); @@ -95,6 +120,14 @@ inline const char* getBuffer() { memcpy(out + first, buf.data, remaining); } out[len] = '\0'; + return len; +} + +// Legacy convenience: returns a static buffer. Not safe if two readers +// call it concurrently — the DS uses copyBuffer() with its own storage. +inline const char* getBuffer() { + static char out[detail::BUFFER_SIZE + 1]; + copyBuffer(out, sizeof(out)); return out; } From 78efe2ea405186da82afb63ee9bf8cd35d1cc741 Mon Sep 17 00:00:00 2001 From: tunapro Date: Wed, 10 Jun 2026 00:59:06 +0300 Subject: [PATCH 16/27] docs: document WS push protocol, captive portal, channel switch --- API.md | 50 +++++++++++++++++++++++++++++++++++--------------- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ README.md | 9 +++++++-- 3 files changed, 77 insertions(+), 17 deletions(-) diff --git a/API.md b/API.md index 5c235af..0b614cd 100644 --- a/API.md +++ b/API.md @@ -146,33 +146,53 @@ yazacaksanız: | Endpoint | Metod | Sahiplik | Açıklama | |---|---|---|---| | `/` | GET | gerekli | Driver Station arayüzü (SPA) | -| `/joystick` | WS | gerekli | Binary joystick akışı (aşağıda) + 2 sn'de bir sunucudan heartbeat | -| `/updateController` | POST | gerekli | JSON fallback: `{"axes":[...],"buttons":[...]}` | +| `/joystick` | WS | gerekli | Çift yönlü binary kanal (çerçeve formatları aşağıda) | +| `/updateController` | POST | gerekli | JSON fallback: `{"axes":[...],"buttons":[...]}` — WS koptuğunda | | `/robotControl?cmd=init\|start\|stop\|cancelAuto&auto=0\|1&autoLen=N` | GET | gerekli | Faz komutları | -| `/getState` | GET | gerekli | `{"phase":N,"autonomousEnabled":b,"autoPeriodSeconds":N,"autoRemainingMs":N}` | -| `/telemetry` | GET | gerekli | Telemetri tamponunun içeriği (text) | +| `/setChannel?ch=N` | GET | gerekli | Kanalı NVS'e kaydet; 1-13 ise CSA ile **canlı** geçiş, 0 = açılışta otomatik seçim. Dönüş: `{"ok":b,"ch":N,"live":b}` | +| `/getState` | GET | gerekli | `{"phase":N,"autonomousEnabled":b,"autoPeriodSeconds":N,"autoRemainingMs":N}` (WS yokken fallback) | +| `/telemetry` | GET | gerekli | Telemetri tamponunun içeriği (text) (WS yokken fallback) | | `/getBattery` | GET | serbest | Pil gerilimi (şu an kullanıcı beslemeli) | -| `/health` | GET | serbest | `{"rssi":N,"up":ms,"heap":N,"dm":b}` — izleme/hakem için | -| `/info` | GET | serbest | SSID, kanal, IP, çip/heap/flash bilgisi | +| `/health` | GET | serbest | `{"rssi":N,"up":ms,"heap":N,"dm":b,"joyAgeMs":N,"sta":N,"disc":N}` — izleme/hakem için. `joyAgeMs`: son joystick paketinin yaşı (-1 = hiç gelmedi), `sta`: bağlı istemci sayısı, `disc`: son kopuşun IEEE reason kodu | +| `/info` | GET | serbest | SSID, kanal + `chSource` (macro/nvs/auto), IP, çip/heap/flash | +| `/portal` | GET | serbest | Captive portal karşılama sayfası (`PROBOT_CAPTIVE_PORTAL 0` ile kapatılır) | + +**Captive portal:** robot, AP'sine katılan cihazların DNS sorgularını +kendine çözer ve işletim sistemi bağlantı sondalarını (`/generate_204`, +`/hotspot-detect.html`, `/connecttest.txt` vb.) yakalar — tablete +bağlanınca karşılama sayfası kendiliğinden açılır, IP yazmak gerekmez. **Sahiplik (owner) modeli:** korumalı endpoint'e ilk istek atan IP sahip olur; diğer IP'ler `403 Forbidden` alır. Sahip `PROBOT_DS_OWNER_TIMEOUT_MS` (5 sn) sessiz kalırsa slot boşalır. Sahip düştüğünde gamepad verisi anında sıfırlanır. -**WS binary joystick çerçevesi** (istemci → robot): +**WS çerçeveleri** — ilk bayt tipi belirler. + +İstemci → robot: + +``` +'J' 0x4A joystick verisi: + [1] uint8 eksen sayısı (maks 20) + [2] uint8 buton sayısı (maks 20) + [3] uint8 rezerve (0) + [4..] int16 eksenler, big-endian, değer = float × 32767 + [sonra] uint8[] butonlar, bit-paketli, LSB önce +'P' 0x50 boşta keepalive (tek bayt) — gamepad yokken 2 sn'de bir; + owner slotunu ve DS aktivitesini canlı tutar +``` + +Robot → istemci (push): ``` -[0] uint8 0x4A ('J' sihirli bayt) -[1] uint8 eksen sayısı (maks 20) -[2] uint8 buton sayısı (maks 20) -[3] uint8 rezerve (0) -[4..] int16 eksenler, big-endian, değer = float × 32767 -[sonra] uint8[] butonlar, bit-paketli, LSB önce +'S' 0x53 durum+sağlık JSON'u — değişiklikte anında, en geç 1 sn'de bir + (heartbeat görevi de görür). Alanlar /getState + /health + birleşimi. +'T' 0x54 telemetri tamponu (text) — içerik değiştiğinde ``` -Robot → istemci: 2 saniyede bir 2 baytlık heartbeat `[0x48, seq]`. -5 saniye heartbeat alamayan istemci bağlantıyı ölü saymalıdır. +≥5 saniye hiç çerçeve alamayan istemci bağlantıyı ölü sayıp yeniden +bağlanmalıdır. **Bağlantı kesilme zinciri:** diff --git a/CHANGELOG.md b/CHANGELOG.md index f2044e4..b7fd575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,24 @@ Biçim [Keep a Changelog](https://keepachangelog.com/), sürümler Bağlantı sağlamlaştırma (devam), servo desteği ve doküman yenileme. ### Eklendi +- **Tek-WS push mimarisi:** robot, durum+sağlık (`'S'`, ≥1 Hz, heartbeat + görevi de görür) ve telemetriyi (`'T'`, değişince) WebSocket üzerinden + kendisi yollar; arayüz artık HTTP poll yapmıyor (eskiden ~9 istek/sn). + WS Init/Stop boyunca açık kalır; gamepad yokken istemci 2 sn'de bir + `'P'` keepalive yollar. WS koparsa arayüz 1 Hz HTTP fallback'e döner. +- **Captive portal:** robota bağlanan tablet/telefonda karşılama sayfası + kendiliğinden açılır (DNS catch-all + OS sonda yakalama). IP yazmak + gerekmez. `PROBOT_CAPTIVE_PORTAL 0` ile kapatılır. +- **Saha teşhisleri:** STA katıl/ayrıl olayları MAC + IEEE reason + koduyla loglanır; `/health` ve `'S'` çerçevesi `joyAgeMs` (son + joystick paketinin yaşı), `sta` (istemci sayısı) ve `disc` (son kopuş + nedeni) alanlarını taşır; Logs sayfasında görünür. +- **Çalışma anında kanal değişimi:** Logs sayfasından kanal seçilir, + `/setChannel` NVS'e kaydeder ve 1-13 için **CSA ile canlı geçiş** + yapar (istemciler bağlantıyı koparmadan takip eder). NVS > makro + önceliği; `0` = açılışta otomatik seçim. +- **Telemetri tamponu artık kilitli** (kullanıcı çekirdeği yazar, ağ + çekirdeği okur — race vardı) ve `copyBuffer` API'si eklendi. - **RF/TCP ince ayarları** (datasheet/IDF kaynak araştırmasına dayalı): TX gücü düzeltildi — `WIFI_POWER_19_5dBm` API'de aşağı yuvarlanıp **18 dBm** veriyordu, artık gerçek maksimum **20 dBm** kullanılıyor @@ -143,6 +161,23 @@ Versioning: [Semantic Versioning](https://semver.org/). Continued link hardening, servo support, documentation overhaul. ### Added +- **Single-WS push architecture:** the robot pushes state+health (`'S'`, + ≥1 Hz, doubles as the heartbeat) and telemetry (`'T'`, on change) over + the WebSocket; the UI no longer polls HTTP (was ~9 req/s). The WS + stays open across Init/Stop; with no gamepad the client sends a `'P'` + keepalive every 2 s. If the WS drops, the UI falls back to 1 Hz HTTP. +- **Captive portal:** joining the robot AP auto-opens a landing page + (DNS catch-all + OS probe spoofing) — no IP typing. Disable with + `PROBOT_CAPTIVE_PORTAL 0`. +- **Field diagnostics:** STA join/leave logged with MAC + IEEE reason + code; `/health` and the `'S'` frame carry `joyAgeMs`, `sta` and + `disc`; surfaced on the Logs page. +- **Runtime channel switching:** pick a channel on the Logs page; + `/setChannel` persists to NVS and switches 1-13 **live via CSA** + (clients migrate without dropping). NVS > macro precedence; `0` = + auto-select at boot. +- **Telemetry buffer is now locked** (user-core writer vs network-core + reader raced) with a new `copyBuffer` API. - **RF/TCP tuning** (grounded in datasheet/IDF source research): TX power fix — `WIFI_POWER_19_5dBm` quantized down to **18 dBm** in the API; we now request the true API max of **20 dBm** (+2 dB); 802.11b diff --git a/README.md b/README.md index cb0e39d..155ff0e 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,9 @@ void autonomousLoop() { delay(100); } > Altı fonksiyonun altısı da sketch'te bulunmak zorundadır. 1. Yükle → Serial monitörde IP'yi gör (`192.168.4.1`). -2. Tablet/telefonu `MyRobot` WiFi ağına bağla. -3. Tarayıcıda `http://192.168.4.1` aç. +2. Tablet/telefonu `MyRobot` WiFi ağına bağla — karşılama sayfası + kendiliğinden açılır (captive portal). +3. Açılmazsa tarayıcıda `http://192.168.4.1` aç. 4. Kumandayı tablete bağla (USB/Bluetooth) → **Init** → **Start**. ## Örnekler @@ -77,6 +78,7 @@ Hepsi `#include ` satırından **önce** tanımlanır: | `PROBOT_INPUT_TIMEOUT_MS` | `500` | Joystick verisi kesilince eksenlerin sıfırlanma süresi | | `PROBOT_WIFI_ENABLE_11B` | `0` | `1`: 802.11b hızlarını aç (sadece 2010 öncesi cihazlar için; beacon airtime'ını 6 kat artırır) | | `PROBOT_WIFI_PMF_REQUIRED` | `0` | `1`: PMF (802.11w) zorunlu — deauth sahteciliğine karşı koruma, eski tabletlerle uyumsuz olabilir | +| `PROBOT_CAPTIVE_PORTAL` | `1` | Ağa katılan cihazda karşılama sayfası kendiliğinden açılır; `0` kapatır | | `NEOPIXEL_PIN` / `NEOPIXEL_COUNT` | `3` / `1` | Durum LED'i pini/adedi | ## Yarışma günü: kanal planı @@ -87,6 +89,9 @@ Hepsi `#include ` satırından **önce** tanımlanır: kanalı kendisi seçer (açılışa ~2-3 sn ekler). Pit alanı gibi kalabalık RF ortamında en pratik çözüm; seçilen kanal Serial'de ve arayüzün Logs sayfasında görünür. +- Kanal yarışma günü **yeniden flash gerektirmeden** değiştirilebilir: + Logs sayfası → Kanal Değiştir. 1-13 arası seçimler CSA ile canlı + uygulanır (bağlantı korunur) ve kalıcı kaydedilir. - Telefon hotspot'ları ve seyirci cihazları da 2.4 GHz'i doldurur — maç sırasında robot çevresinde hotspot açtırmayın. - Sinyal sorunlarını sahada ayıklamak için `/health` endpoint'i RSSI From 7e66493fb0e23b4a290cbb36b3be29edcc68818e Mon Sep 17 00:00:00 2001 From: tunapro Date: Wed, 10 Jun 2026 01:30:35 +0300 Subject: [PATCH 17/27] =?UTF-8?q?fix:=20top-to-bottom=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=202=20critical,=208=20major=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four parallel reviewers (runtime/concurrency, DS server vs IDF 5.5 sources, web UI JS, docs/examples consistency) audited the whole library. Everything found is fixed: CRITICAL: DS-timeout FORCE_STOP never stopped the robot (0.2.8 bug) - setting lastStatus alongside the status suppressed the STOP transition, so teleop kept running and robotEnd never ran. CRITICAL: state/gamepad/ telemetry CAS spinlocks livelocked under same-core priority inversion - all three converted to portMUX critical sections (readers too). Runtime: init/end workers no longer delete their own handles (use-after-free); cooperative stop with 60ms grace before vTaskDelete; sysloop subscribes to the task watchdog (it supervised zero tasks); deadline-miss warning made one-shot (was ~1000x/s, wiping telemetry); INITED reported only after robotInit completes; task-create failures logged; autoLen clamped. DS server: client-list arrays sized for max sockets (broadcasts died entirely above 8 fds); broadcast-time owner filter (handshake-time authorization stops being invoked on newer IDF); WS control-frame payloads drained/echoed per RFC 6455; oversize frames close cleanly; >20-axis frames rejected instead of misparsed into phantom buttons; 11b-rate disable moved between wifi stop/start per API contract; owner-release side effects no longer clobber a concurrent re-acquire; SSID sanitized into /info JSON and portal HTML; portal buffer sized; updateController reads split bodies; joyAge clamped. Servo: channels claimed once per instance - repeated DS Init burned a fresh LEDC channel per attach until all servos died mid-match. Web UI: joystick frames clamp to 20 axes/20 buttons (HOTAS-class controllers were silently ignored); gamepad dropdown rebuilt only on device changes (60Hz rebuild reset the selection); late onclose from a dying socket can no longer orphan its replacement; 600ms command grace window stops stale S frames reverting the button; healthFailCount reset by WS health; status detail visible; portrait channel row fits. Examples: TankDrive autonomous rewritten - delay(2000) tripped the deadline-miss kill on every single run. Docs/meta: CHANGELOG no longer describes the dead 'H'-frame design; CI runs on dev/dev-* and compiles all examples; idf_component.yml URL and library.json name fixed; keywords.txt completed; FUTURE_WORK updated (incl. OTA verification of 11b/CSA on hardware). --- .github/workflows/ci.yml | 11 +- API.md | 13 +- CHANGELOG.md | 123 ++++++++++++++--- FUTURE_WORK.md | 29 ++-- README.md | 2 +- examples/TankDrive/TankDrive.ino | 22 +-- idf_component.yml | 2 +- keywords.txt | 15 +++ library.json | 2 +- .../esp32s3/driver_station_esp32.hpp | 97 ++++++++++++-- src/driverstation/esp32s3/index_html.h | 53 +++++--- src/driverstation/esp32s3/ws_joystick.hpp | 69 ++++++++-- src/probot/core/lock.hpp | 61 +++++++++ src/probot/core/runtime.hpp | 126 +++++++++++++++--- src/probot/devices/leds/builtin.hpp | 34 ++++- src/probot/devices/servo/servo.hpp | 17 ++- src/probot/io/gamepad.hpp | 36 ++--- src/probot/robot/state.hpp | 38 +++--- src/probot/telemetry/telemetry.hpp | 23 +--- 19 files changed, 594 insertions(+), 179 deletions(-) create mode 100644 src/probot/core/lock.hpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ab3e3b..ca8de8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,13 +4,12 @@ on: push: branches: - stable - - main - - master + - dev + - dev-* pull_request: branches: - stable - - main - - master + - dev jobs: test: @@ -71,5 +70,7 @@ jobs: make tests/control_tests ./tests/control_tests else - make test + make build EXAMPLE=all + make tests/control_tests + ./tests/control_tests fi diff --git a/API.md b/API.md index 0b614cd..de4a5a5 100644 --- a/API.md +++ b/API.md @@ -52,7 +52,7 @@ her seferinde çağırmak normaldir. | `getA()`, `getB()`, `getX()`, `getY()` | `bool` | Xbox isimleri | | `getCross()`, `getCircle()`, `getSquare()`, `getTriangle()` | `bool` | PlayStation eşdeğerleri | | `getLB()`, `getRB()` | `bool` | Omuz butonları | -| `getBack()`, `getStart()`, `getOptions()` | `bool` | Orta butonlar | +| `getBack()`, `getStart()`, `getOptions()` | `bool` | Orta butonlar (`getOptions` yalnız `tuna-default` eşlemesinde tanımlı) | | `getLeftStickButton()`, `getRightStickButton()` | `bool` | Çubuğa basma (L3/R3) | | `getPOV()` | `int` | D-Pad: -1 yok, 0 yukarı, 90 sağ, 180 aşağı, 270 sol | | `getDpadUp()/Right()/Down()/Left()` | `bool` | D-Pad tek yön | @@ -149,7 +149,7 @@ yazacaksanız: | `/joystick` | WS | gerekli | Çift yönlü binary kanal (çerçeve formatları aşağıda) | | `/updateController` | POST | gerekli | JSON fallback: `{"axes":[...],"buttons":[...]}` — WS koptuğunda | | `/robotControl?cmd=init\|start\|stop\|cancelAuto&auto=0\|1&autoLen=N` | GET | gerekli | Faz komutları | -| `/setChannel?ch=N` | GET | gerekli | Kanalı NVS'e kaydet; 1-13 ise CSA ile **canlı** geçiş, 0 = açılışta otomatik seçim. Dönüş: `{"ok":b,"ch":N,"live":b}` | +| `/setChannel?ch=N` | GET | gerekli | Kanalı NVS'e kaydet; 1-13 ise CSA ile **canlı** geçiş (zaten o kanaldaysa `live:false`), 0 = açılışta otomatik seçim. Dönüş: `{"ok":b,"ch":N,"live":b}` | | `/getState` | GET | gerekli | `{"phase":N,"autonomousEnabled":b,"autoPeriodSeconds":N,"autoRemainingMs":N}` (WS yokken fallback) | | `/telemetry` | GET | gerekli | Telemetri tamponunun içeriği (text) (WS yokken fallback) | | `/getBattery` | GET | serbest | Pil gerilimi (şu an kullanıcı beslemeli) | @@ -185,9 +185,9 @@ Sahip düştüğünde gamepad verisi anında sıfırlanır. Robot → istemci (push): ``` -'S' 0x53 durum+sağlık JSON'u — değişiklikte anında, en geç 1 sn'de bir - (heartbeat görevi de görür). Alanlar /getState + /health - birleşimi. +'S' 0x53 durum+sağlık JSON'u — değişiklikte bir sonraki tick'te + (250 ms), değişiklik yoksa en geç ~1.25 sn'de bir (heartbeat + görevi de görür). Alanlar /getState + /health birleşimi. 'T' 0x54 telemetri tamponu (text) — içerik değiştiğinde ``` @@ -207,4 +207,5 @@ bağlanmalıdır. - Arduino IDE / arduino-cli: `library.properties` ile (`make build EXAMPLE=JoystickTest`) - PlatformIO: `lib_deps = https://github.com/probot-studio/probot-core.git` - ESP-IDF + Arduino component: `probot::runtime_setup()` çağırın -- Host unit testleri: `make test` (donanımsız, g++ ile) +- Host unit testleri (donanımsız, g++ ile): + `make tests/control_tests && ./tests/control_tests` diff --git a/CHANGELOG.md b/CHANGELOG.md index b7fd575..f1ae038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,24 +57,67 @@ Bağlantı sağlamlaştırma (devam), servo desteği ve doküman yenileme. - Telemetri halka tamponu için host unit testleri. ### Değişti -- **WS ping yerine görünür heartbeat** (`ws_joystick.hpp`): sunucu artık - WS PING yerine 2 baytlık BINARY çerçeve (`'H'`, seq) yolluyor. - Tarayıcılar PING'i JS'e göstermediği için istemci ölü linki ayırt - edemiyordu; şimdi `onmessage` ile gerçek canlılık sinyali var. - Sunucu tarafındaki 3-fail kapatma mantığı aynen korundu. +- **WS PING yerine görünür heartbeat:** tarayıcılar PING/PONG'u JS'e + göstermediği için istemci ölü linki ayırt edemiyordu. Sunucunun + ≥1 Hz `'S'` (durum+sağlık) push çerçevesi artık heartbeat görevini + görüyor; `onmessage` gerçek canlılık sinyali. Sunucu tarafındaki + ardışık-3-fail kapatma mantığı `sendToAll` içinde korundu. - **Web UI ölü-link tespiti düzeltildi:** kendi gönderimleri artık aktivite sayılmıyor (ölü TCP soketine `ws.send()` sessizce başarılı olur — sürüş sırasında kopan bağlantı hiç fark edilmiyordu). Stale - eşiği 3 sn → 5 sn (2 kaçan heartbeat). Boştayken yaşanan sürekli + eşiği 5 sn (≈5 kaçırılmış `'S'` çerçevesi). Boştayken yaşanan sürekli kopma/yeniden bağlanma döngüsü de bu sayede bitti. -- **Web UI HTTP sağlamlaştırma:** telemetri 50 ms → 150 ms; telemetri ve - durum sorgularına eşzamanlılık kilidi + zaman aşımı eklendi (tıkanan - hatta istek yığılması önlenir); arka plandaki sekme sorgulamaz. +- **Web UI HTTP sağlamlaştırma:** durum/sağlık sorgularına eşzamanlılık + kilidi + zaman aşımı eklendi (tıkanan hatta istek yığılması önlenir); + HTTP artık yalnızca WS koptuğunda (1 Hz fallback) ve 10 sn'de bir RTT + ölçümü için kullanılır. - **httpd core 0'a sabitlendi** — core 1 tamamen kullanıcı koduna kaldı. - `/info` artık makro yerine gerçek (otomatik seçilmiş olabilecek) kanalı döndürür. Logs sayfasındaki anlamsız "Password" satırı kalktı. ### Düzeltildi +- **KRİTİK — DS timeout'u robotu gerçekten durdurmuyordu (0.2.8 + hatası):** FORCE_STOP yolu status'u STOP yaparken `lastStatus`'u da + STOP'a çekiyordu; geçiş bloğu değişikliği hiç görmüyor, teleop/auto + task'ları çalışmaya devam ediyor, `robotEnd()` hiç koşmuyordu. +- **Aynı çekirdekte öncelik tersinmesi kilitlenmesi:** state/gamepad/ + telemetri spinlock'ları farklı öncelikli task'lar arasında + paylaşılıyordu — yüksek öncelikli task spin'e girince kilidi tutan + düşük öncelikli task bir daha hiç koşamıyordu. Üçü de kısa portMUX + kritik bölgesine çevrildi (okuyucular dahil — yırtık snapshot da + kapandı). +- **Görev yaşam döngüsü:** init/end worker'larının kendi handle'larını + silmesi use-after-free yaratabiliyordu (handle'lar artık yalnız + sysloop'a ait, worker'lar bayrakla park ediyor); teleop/auto artık + önce kooperatif durduruluyor (60 ms), Serial/Wire ortasında + öldürülme riski büyük ölçüde kalktı; INITED fazı artık `robotInit()` + gerçekten bitince raporlanıyor; task yaratma hataları loglanıyor. +- **Watchdog hiçbir görevi izlemiyordu** — sysloop artık TWDT'ye abone + (3 sn panik, kilitlenmede yeniden başlatma). +- **Deadline-miss telemetri seli:** uyarı saniyede ~1000 kez basılıp + 256 baytlık tamponu tam ihtiyaç anında siliyordu — artık bölüm + başına tek atış. +- **Servo kanal tükenmesi:** her DS Init'i `robotInit()`'i yeniden + çalıştırır; `attach()` her seferinde yeni LEDC kanalı yakıyordu — + birkaç Init sonrası tüm servolar ölüyordu. Kanal artık nesneye bir + kez tahsis ediliyor. +- **WS yayını 8+ sokette tamamen duruyordu:** `httpd_get_client_list` + küçük diziyle çağrılınca hata veriyor, tüm push/heartbeat kesiliyordu + (dizi 13'e çıkarıldı). Yayın alıcıları artık owner IP'siyle de + süzülüyor (ikinci cihaz state/telemetri dinleyemez). +- **WS akış hizası:** payload'lı PING/PONG ve boyut aşan çerçeveler + TCP akışını kaydırabiliyordu — PING payload'u artık RFC'ye uygun + yankılanıyor, aşırı boyut oturumu temiz kapatıyor; 20'den fazla + eksen bildiren çerçeveler hayalet buton üretmek yerine reddediliyor + (istemci de 20/20'ye kırpıyor). +- **TankDrive otonom örneği** her çalıştırmada deadline-miss + tetikliyordu (2 sn'lik blocking delay) — zaman damgalı kalıba + çevrildi. +- Web UI: gamepad listesi 60 Hz'de yeniden kurulup seçimi sıfırlıyordu; + optimistic buton güncellemesi eski 'S' çerçevesiyle çakışabiliyordu + (600 ms komut penceresi); kapanan soketin geç `onclose`'u yeni soketi + düşürebiliyordu; SSID artık /info JSON'una ve portal HTML'ine + süzülerek gömülüyor. - **Owner state yarışı:** owner alanlarına httpd task'ı ile sysloop task'ı eşzamanlı erişiyordu; tüm erişimler `portMUX` kritik bölgesine alındı (log/G-Ç kritik bölge dışında). @@ -207,25 +250,65 @@ Continued link hardening, servo support, documentation overhaul. - Host unit tests for the telemetry ring buffer. ### Changed -- **Visible heartbeat instead of WS ping** (`ws_joystick.hpp`): the - server now sends a 2-byte BINARY frame (`'H'`, seq) instead of a WS - PING. Browsers auto-pong pings invisibly to JS, so the client could - never tell a live link from a dead one; a data frame fires - `onmessage` and gives a real liveness signal. The server-side - 3-consecutive-failure close logic is unchanged. +- **Visible heartbeat instead of WS PING:** browsers auto-pong pings + invisibly to JS, so the client could never tell a live link from a + dead one. The server's ≥1 Hz `'S'` (state+health) push frame now + doubles as the heartbeat — `onmessage` is a real liveness signal. + The server-side 3-consecutive-failure close logic lives on inside + `sendToAll`. - **Web UI dead-link detection fixed:** the client no longer counts its own sends as link activity (`ws.send()` into a dead TCP socket succeeds silently — a link dying mid-drive was never detected). Stale - threshold 3 s → 5 s (2 missed heartbeats). This also ends the - reconnect churn loop the UI used to enter while idle. -- **Web UI HTTP hardening:** telemetry polling 50 ms → 150 ms; in-flight - guards + timeouts on the telemetry and state pollers (no request - pile-up on a congested link); hidden tabs stop polling. + threshold 5 s (≈5 missed `'S'` frames). This also ends the reconnect + churn loop the UI used to enter while idle. +- **Web UI HTTP hardening:** in-flight guards + timeouts on the state + and health fetches (no request pile-up on a congested link); HTTP is + now used only while the WS is down (1 Hz fallback) and for a 10 s + RTT sample. - **httpd pinned to core 0** — core 1 is now exclusively user code. - `/info` reports the actual (possibly auto-selected) channel instead of the macro. The meaningless "Password" row was removed from Logs. ### Fixed +- **CRITICAL — DS timeout never actually stopped the robot (0.2.8 + bug):** the FORCE_STOP path set `lastStatus` together with the + status, so the transition block never saw the change — teleop/auto + kept running and `robotEnd()` never ran. +- **Same-core priority-inversion livelock:** the state/gamepad/ + telemetry spinlocks were shared between different-priority tasks on + one core — a spinning high-priority task starved the lock holder + forever. All three are now short portMUX critical sections (readers + included, which also closes the torn-snapshot window). +- **Task lifecycle:** init/end workers deleting their own handles could + use-after-free (handles are now owned solely by the sysloop; workers + park on a flag); teleop/auto now stop cooperatively (60 ms grace) + before a hard kill; INITED is reported only when `robotInit()` + actually finished; task-creation failures are logged. +- **The watchdog supervised zero tasks** — the sysloop now subscribes + to the TWDT (3 s panic, reboots out of livelocks). +- **Deadline-miss telemetry flood:** the warning printed ~1000×/s and + wiped the 256-byte buffer exactly when needed — now one-shot per + episode. +- **Servo channel exhaustion:** every DS Init re-runs `robotInit()`; + `attach()` burned a fresh LEDC channel each time — a few Inits killed + all servos. Channels are now claimed once per instance. +- **WS broadcast died entirely above 8 sockets:** `httpd_get_client_list` + fails outright with a too-small array, stopping all push/heartbeat + frames (array now 13). Broadcast recipients are also filtered by the + owner IP (a second device can no longer eavesdrop state/telemetry). +- **WS stream alignment:** PING/PONG with payloads and oversize frames + could desync the TCP stream — ping payloads are now echoed per RFC, + oversize closes the session cleanly, and frames declaring >20 axes + are rejected instead of misparsed into phantom buttons (the client + also clamps to 20/20). +- **TankDrive autonomous example** tripped the deadline-miss kill on + every run (2 s blocking delay) — rewritten with the timestamp + pattern. +- Web UI: the gamepad list rebuilt at 60 Hz resetting the selection; + optimistic button updates raced stale 'S' frames (600 ms command + grace window); a dying socket's late `onclose` could drop the new + socket; the SSID is now sanitized before embedding in /info JSON and + the portal HTML. - **Owner-state race:** owner fields were accessed concurrently from the httpd task and sysloop; all access now goes through a `portMUX` critical section (logging/I-O kept outside). diff --git a/FUTURE_WORK.md b/FUTURE_WORK.md index 6424611..6a20776 100644 --- a/FUTURE_WORK.md +++ b/FUTURE_WORK.md @@ -8,13 +8,17 @@ maddeleri bu listeden çıkarılmıştır (gerekirse git geçmişine bakın). - **Saha test kampanyası:** `connection-test/` düzeneği ile C senaryosu (30+ dk stabilite) ve B senaryosu (worst-case tek kanal) koşulmadı — 0.2.8/0.2.9 bağlantı değişikliklerini sahada doğrula. -- **Kanal değişikliği için NVS:** kanalı yeniden derlemeden değiştirmek - için arayüzden seçim + NVS'te saklama (şimdilik `CHANNEL 0` otomatik - seçim var). - **Donanım doğrulaması:** joystick hattını gerçek robotta 10 ms örnekleme - + 20 ms kontrol döngüsüyle ölç (gecikme/jitter karakterizasyonu). + + 20 ms kontrol döngüsüyle ölç (gecikme/jitter karakterizasyonu). Ayrıca + havadan doğrula: 11b devre dışı bırakma gerçekten beacon'ları 6 Mbps'e + taşıyor mu (sniffer ile), CSA kanal geçişini tabletler takip ediyor mu. - **Telemetri tamponu:** 256 bayt yarışma sırasında küçük kalabiliyor; - WS üzerinden push + daha büyük tampon değerlendir. + daha büyük tampon değerlendir (WS push 0.2.9'da geldi). +- **ESP-NOW el kumandası:** bağlantısız kontrol linki (yeniden bağlanma + problemi yapısal olarak yok); ESP32 el kumandası + robot tarafında + IGamepadSource implementasyonu. +- **ESP32-C5 değerlendirmesi:** 5 GHz softAP — kalabalık 2.4 GHz salon + sorununun yapısal çözümü. --- @@ -28,9 +32,14 @@ were dropped from this list (see git history if needed). - **Field test campaign:** run connection-test scenario C (30+ min stability) and scenario B (worst-case single channel) to validate the 0.2.8/0.2.9 connectivity changes under real RF load. -- **NVS channel override:** change the WiFi channel from the UI without - reflashing (compile-time `CHANNEL 0` auto-select exists today). - **Hardware validation:** measure joystick latency/jitter on a real - robot at 10 ms sampling + 20 ms control loop. -- **Telemetry buffer:** 256 bytes is tight during matches; consider WS - push and a larger ring buffer. + robot at 10 ms sampling + 20 ms control loop. Also verify over the + air: does the 11b disable really move beacons to 6 Mbps (sniffer), + and do tablets follow the CSA channel switch. +- **Telemetry buffer:** 256 bytes is tight during matches; consider a + larger ring buffer (WS push shipped in 0.2.9). +- **ESP-NOW handheld controller:** connectionless control link (the + reconnection problem is structurally absent); ESP32 handheld + an + IGamepadSource implementation on the robot side. +- **ESP32-C5 evaluation:** 5 GHz softAP — the structural fix for + crowded 2.4 GHz venues. diff --git a/README.md b/README.md index 155ff0e..5f9d44b 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Hepsi `#include ` satırından **önce** tanımlanır: | Makro | Varsayılan | Açıklama | |---|---|---| -| `PROBOT_WIFI_AP_SSID` | `"Probot"` | AP adı (1-32 karakter) | +| `PROBOT_WIFI_AP_SSID` | `Probot-XXXXXX` | AP adı. Tanımsız bırakılırsa MAC eki otomatik açılır. Ek açıkken en fazla 25, kapalıyken 32 karakter | | `PROBOT_WIFI_AP_PASSWORD` | — (zorunlu) | AP şifresi (≥8 karakter) | | `PROBOT_WIFI_AP_CHANNEL` | — (zorunlu) | 1-13, veya **0 = açılışta en boş kanalı otomatik seç** | | `PROBOT_WIFI_AP_SSID_MAC_SUFFIX` | kapalı | SSID sonuna `-XXXXXX` (MAC) ekler | diff --git a/examples/TankDrive/TankDrive.ino b/examples/TankDrive/TankDrive.ino index 41d917a..cb88716 100644 --- a/examples/TankDrive/TankDrive.ino +++ b/examples/TankDrive/TankDrive.ino @@ -60,17 +60,21 @@ void teleopLoop() { delay(20); } -void autonomousInit() {} +// Otonom örneği: 2 saniye ileri git, dur. +// ÖNEMLİ: autonomousLoop kısa sürede dönmeli — 2 saniyeden uzun bloke +// olan loop "deadline miss" sayılır ve görev sonlandırılır. Bu yüzden +// delay(2000) yerine zaman damgasıyla durum takibi yapılır. +uint32_t autoStartTime = 0; + +void autonomousInit() { + autoStartTime = millis(); + setMotor(LEFT_RPWM, LEFT_LPWM, 0.4f, LEFT_INVERTED); + setMotor(RIGHT_RPWM, RIGHT_LPWM, 0.4f, RIGHT_INVERTED); +} void autonomousLoop() { - // Örnek: 2 saniye ileri git, dur. - static bool done = false; - if (!done) { - setMotor(LEFT_RPWM, LEFT_LPWM, 0.4f, LEFT_INVERTED); - setMotor(RIGHT_RPWM, RIGHT_LPWM, 0.4f, RIGHT_INVERTED); - delay(2000); + if (millis() - autoStartTime >= 2000) { stopMotors(); - done = true; } - delay(100); + delay(20); } diff --git a/idf_component.yml b/idf_component.yml index ad49005..76cc11a 100644 --- a/idf_component.yml +++ b/idf_component.yml @@ -1,6 +1,6 @@ version: 0.2.9 description: ESP32-S3 communication library for robotics -url: https://github.com/nfrproducts/probot-lib +url: https://github.com/probot-studio/probot-core dependencies: idf: version: ">=5.0" diff --git a/keywords.txt b/keywords.txt index f23a025..4375fbc 100644 --- a/keywords.txt +++ b/keywords.txt @@ -36,6 +36,16 @@ getRB KEYWORD2 getBack KEYWORD2 getStart KEYWORD2 getOptions KEYWORD2 +getCross KEYWORD2 +getCircle KEYWORD2 +getSquare KEYWORD2 +getTriangle KEYWORD2 +getLeftStickButton KEYWORD2 +getRightStickButton KEYWORD2 +getSeq KEYWORD2 +getMs KEYWORD2 +getAxisCount KEYWORD2 +getButtonCount KEYWORD2 getPOV KEYWORD2 getDpadUp KEYWORD2 getDpadDown KEYWORD2 @@ -50,6 +60,7 @@ write KEYWORD2 writeMicroseconds KEYWORD2 readMicroseconds KEYWORD2 attached KEYWORD2 +print KEYWORD2 println KEYWORD2 printf KEYWORD2 clearTelemetry KEYWORD2 @@ -67,5 +78,9 @@ PROBOT_WIFI_AP_SSID_MAC_SUFFIX LITERAL1 PROBOT_DS_TIMEOUT_MS LITERAL1 PROBOT_DS_TIMEOUT_FORCE_STOP LITERAL1 PROBOT_DS_OWNER_TIMEOUT_MS LITERAL1 +PROBOT_INPUT_TIMEOUT_MS LITERAL1 +PROBOT_WIFI_ENABLE_11B LITERAL1 +PROBOT_WIFI_PMF_REQUIRED LITERAL1 +PROBOT_CAPTIVE_PORTAL LITERAL1 NEOPIXEL_PIN LITERAL1 NEOPIXEL_COUNT LITERAL1 diff --git a/library.json b/library.json index 76c4de1..f2a0bb9 100644 --- a/library.json +++ b/library.json @@ -20,7 +20,7 @@ "websocket", "telemetry" ], - "name": "Probot Lib", + "name": "probot", "platforms": [ "espressif32" ], diff --git a/src/driverstation/esp32s3/driver_station_esp32.hpp b/src/driverstation/esp32s3/driver_station_esp32.hpp index fe9116b..3630e55 100644 --- a/src/driverstation/esp32s3/driver_station_esp32.hpp +++ b/src/driverstation/esp32s3/driver_station_esp32.hpp @@ -146,12 +146,19 @@ namespace probot::driverstation::esp32 { } WiFi.mode(WIFI_AP); - esp_wifi_set_country(&country); #if !PROBOT_WIFI_ENABLE_11B + // esp_wifi_config_11b_rate is documented to run between init and + // start; WiFi.mode() already started the driver, so bounce it + // once at boot (no clients yet — harmless). + esp_wifi_stop(); + esp_wifi_set_country(&country); esp_err_t rate_err = esp_wifi_config_11b_rate(WIFI_IF_AP, true); if (rate_err != ESP_OK) { Serial.printf("[DS ] 11b rate disable failed: 0x%x\n", rate_err); } + esp_wifi_start(); +#else + esp_wifi_set_country(&country); #endif WiFi.softAP(ssid.c_str(), pw, channel_); esp_wifi_set_bandwidth(WIFI_IF_AP, WIFI_BW_HT20); @@ -244,8 +251,11 @@ namespace probot::driverstation::esp32 { Serial.printf("[DS ] Captive portal %s\n", _dns_started ? "active" : "DNS FAILED"); #endif - // Attach WebSocket handler (with owner gatekeeper) + // Attach WebSocket handler (with owner gatekeeper). The peer + // filter additionally re-checks every broadcast recipient — the + // handshake-time check stops being invoked on newer IDF releases. _ws.setOwnerAuthorizer(&DriverStation::wsOwnerAuthorizer, this); + _ws.setPeerFilter(&DriverStation::wsPeerFilter, this); _ws.attach(_server); // Push task: streams state/health/telemetry to WS clients so the @@ -384,7 +394,11 @@ namespace probot::driverstation::esp32 { size_t buildStateHealthJson(char* out, size_t out_size, const robot::StateSnapshot& s, uint32_t now) { uint32_t joyLast = _gs.lastWriteMs(); - long joyAge = joyLast ? (long)(uint32_t)(now - joyLast) : -1; + long joyAge = -1; + if (joyLast) { + int32_t d = (int32_t)(now - joyLast); + joyAge = d < 0 ? 0 : d; // a frame can land between the two reads + } int n = snprintf(out, out_size, "{\"phase\":%u,\"autonomousEnabled\":%s,\"autoPeriodSeconds\":%d," "\"autoRemainingMs\":%u,\"rssi\":%d,\"up\":%lu,\"heap\":%lu,\"dm\":%s," @@ -443,7 +457,10 @@ namespace probot::driverstation::esp32 { // ── Client IP extraction ── static bool getClientIP(httpd_req_t* req, char* out, size_t out_len) { - int sockfd = httpd_req_to_sockfd(req); + return getPeerIP(httpd_req_to_sockfd(req), out, out_len); + } + + static bool getPeerIP(int sockfd, char* out, size_t out_len) { struct sockaddr_in6 addr; socklen_t addr_len = sizeof(addr); if (getpeername(sockfd, (struct sockaddr*)&addr, &addr_len) != 0) { @@ -537,6 +554,19 @@ namespace probot::driverstation::esp32 { return ds->enforceOwner(req, /*sendHttpError=*/false); } + // Broadcast-time check used by WsJoystick::sendToAll: only the + // owner's IP (or anyone, while no owner is set) may receive state/ + // telemetry pushes. Read-only — never acquires the slot. + static bool wsPeerFilter(void* ctx, int sockfd) { + auto* ds = static_cast(ctx); + char ip[48]; + if (!getPeerIP(sockfd, ip, sizeof(ip))) return false; + portENTER_CRITICAL(&ds->_owner_mux); + bool ok = !ds->_owner_set || (strcmp(ip, ds->_owner_str) == 0); + portEXIT_CRITICAL(&ds->_owner_mux); + return ok; + } + // Clears the owner slot. Caller must hold _owner_mux. void releaseOwnerLocked() { _owner_set = false; @@ -548,7 +578,27 @@ namespace probot::driverstation::esp32 { // stale values (last-command runaway when the link dies). void onOwnerReleased(uint32_t now_ms) { _gs.write(now_ms, nullptr, 0, nullptr, 0); - _rs.setClientCount(now_ms, 0); + // Another client may have legitimately taken the slot between the + // release (inside the mux) and here — don't clobber its count. + portENTER_CRITICAL(&_owner_mux); + bool hasOwner = _owner_set; + portEXIT_CRITICAL(&_owner_mux); + _rs.setClientCount(now_ms, hasOwner ? 1 : 0); + } + + // Replace JSON/HTML-breaking characters for safe embedding of the + // user-defined SSID into /info JSON and the portal page. + static void sanitizeSsid(const char* in, char* out, size_t out_size) { + size_t j = 0; + for (size_t i = 0; in[i] && j + 1 < out_size; i++) { + char c = in[i]; + if (c == '"' || c == '\\' || c == '<' || c == '>' || c == '&' || + (unsigned char)c < 0x20) { + c = '_'; + } + out[j++] = c; + } + out[j] = '\0'; } // ── Parsers (unchanged) ── @@ -620,12 +670,22 @@ namespace probot::driverstation::esp32 { if (!ds->enforceOwner(req)) return ESP_OK; char body[512]; - int len = httpd_req_recv(req, body, sizeof(body) - 1); - if (len <= 0) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Empty body"); + int total = (int)req->content_len; + if (total <= 0 || total > (int)sizeof(body) - 1) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad body size"); return ESP_OK; } - body[len] = '\0'; + // A body can arrive split across TCP segments — read all of it. + int got = 0; + while (got < total) { + int r = httpd_req_recv(req, body + got, total - got); + if (r <= 0) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Body recv failed"); + return ESP_OK; + } + got += r; + } + body[got] = '\0'; float axes[20]; bool buttons[20]; uint32_t nA = 0, nB = 0; parseFloatArray(body, "axes", axes, 20, nA); @@ -652,6 +712,9 @@ namespace probot::driverstation::esp32 { bool enAuto = atoi(autoVal) != 0; int autoLen = atoi(autoLenVal); + // autoLen*1000 happens in the sysloop — clamp to avoid signed + // overflow on hostile/typo'd input. + if (autoLen > 3600) autoLen = 3600; if (strcmp(cmd, "init") == 0) { ds->_rs.setStatus(millis(), robot::Status::INIT); @@ -768,7 +831,11 @@ namespace probot::driverstation::esp32 { auto s = ds->_rs.read(); uint32_t now = millis(); uint32_t joyLast = ds->_gs.lastWriteMs(); - long joyAge = joyLast ? (long)(uint32_t)(now - joyLast) : -1; + long joyAge = -1; + if (joyLast) { + int32_t d = (int32_t)(now - joyLast); + joyAge = d < 0 ? 0 : d; + } char buf[192]; snprintf(buf, sizeof(buf), "{\"rssi\":%d,\"up\":%lu,\"heap\":%lu,\"dm\":%s," @@ -816,7 +883,9 @@ namespace probot::driverstation::esp32 { // users to a real browser instead of hosting the DS itself. static esp_err_t handlePortal(httpd_req_t* req) { auto* ds = self(req); - char page[768]; + char ssid[40]; + sanitizeSsid(ds->ap_ssid_.c_str(), ssid, sizeof(ssid)); + char page[1024]; snprintf(page, sizeof(page), "" "" @@ -831,7 +900,7 @@ namespace probot::driverstation::esp32 { "

Buton bu pencerede açılırsa: pencereyi kapatıp " "tarayıcıda http://%s adresini açın.

" "", - ds->ap_ssid_.c_str(), + ssid, WiFi.softAPIP().toString().c_str(), WiFi.softAPIP().toString().c_str()); httpd_resp_set_type(req, "text/html; charset=utf-8"); @@ -845,13 +914,15 @@ namespace probot::driverstation::esp32 { // /info is now open (no owner check) so the password field is // omitted — anyone connected already has the password; we don't // want other teams' monitoring stations harvesting it. + char ssid[40]; + sanitizeSsid(ds->ap_ssid_.c_str(), ssid, sizeof(ssid)); char buf[512]; snprintf(buf, sizeof(buf), "{\"ssid\":\"%s\",\"ch\":%d,\"chSource\":\"%s\",\"ip\":\"%s\"," "\"chip\":\"%s\",\"cpuMhz\":%lu,\"sdk\":\"%s\"," "\"totalHeap\":%lu,\"totalFlash\":%lu," "\"sketchSize\":%lu,\"freeSketch\":%lu,\"psram\":%lu}", - ds->ap_ssid_.c_str(), + ssid, ds->channel_, ds->ch_source_, WiFi.softAPIP().toString().c_str(), diff --git a/src/driverstation/esp32s3/index_html.h b/src/driverstation/esp32s3/index_html.h index 4ef268f..202a280 100644 --- a/src/driverstation/esp32s3/index_html.h +++ b/src/driverstation/esp32s3/index_html.h @@ -99,7 +99,10 @@ const char MAIN_page[] PROGMEM = R"=====( letter-spacing:0.14em; } .app-header .header-status .status-detail{ - display:none; + font-size:0.7rem; + letter-spacing:0.08em; + opacity:0.7; + text-transform:none; } @media(max-width:900px){ @@ -768,8 +771,8 @@ const char MAIN_page[] PROGMEM = R"=====(
-
- @@ -991,9 +994,14 @@ const char MAIN_page[] PROGMEM = R"=====( } /* ===== STATE RENDER ===== */ - /* Fed by 'S' WS frames normally; by the HTTP fallback when WS is down. */ + /* Fed by 'S' WS frames normally; by the HTTP fallback when WS is down. + After a button press we ignore incoming state briefly: an 'S' frame + generated BEFORE the command was applied would revert the optimistic + UI and a second click would then send the wrong command. */ + var lastCmdMs=-10000; function applyState(data){ if(!data) return; + if(performance.now()-lastCmdMs<600) return; var btn=document.getElementById('robotButton'); if(!btn) return; @@ -1087,6 +1095,7 @@ const char MAIN_page[] PROGMEM = R"=====( default: cmd="stop"; break; } + lastCmdMs=performance.now(); var url='/robotControl?cmd='+cmd+'&auto='+(enableAuto?1:0)+'&autoLen='+autoLen; var ac=new AbortController(); var tid=setTimeout(function(){ac.abort();},3000); @@ -1134,10 +1143,18 @@ const char MAIN_page[] PROGMEM = R"=====( if(gp) gamepads[gp.index]=gp; } } + /* Runs from gamepadLoop at ~60Hz — only touch the DOM when the set + of gamepads actually changed, and always restore the selection + afterwards (removing the selected option silently resets a + - + @@ -1557,7 +1557,7 @@ const char MAIN_page[] PROGMEM = R"=====( }).then(function(d){ if(!status) return; if(d.live) status.textContent='Kanal '+d.ch+' (canlı geçiş)'; - else if(d.ch===0) status.textContent='Otomatik — yeniden başlatınca'; + else if(d.ch===0) status.textContent='Varsayılan — yeniden başlatınca'; else status.textContent='Kanal '+d.ch+' — kayıtlı'; fetchInfo(); }).catch(function(){ From 458c2b24bf5588a4980d18444334688245ab8676 Mon Sep 17 00:00:00 2001 From: tunapro Date: Tue, 23 Jun 2026 10:00:17 +0300 Subject: [PATCH 19/27] =?UTF-8?q?fix(led):=20lock-free=20status=20LED=20?= =?UTF-8?q?=E2=80=94=20drop=20portMAX=5FDELAY=20mutex=20across=20show()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NeoPixel mutex was taken with portMAX_DELAY and held across the blocking pixel.show(); a task killed mid-show() orphaned it forever and could wedge the sysloop (which also drives the LED). setColor/set/ setBrightness now only stash an atomic word; the blocking show() runs from a single task (the sysloop) via flush(). No mutex, one show() caller, no reentrancy. --- src/probot/devices/leds/builtin.hpp | 89 ++++++++++++----------------- 1 file changed, 37 insertions(+), 52 deletions(-) diff --git a/src/probot/devices/leds/builtin.hpp b/src/probot/devices/leds/builtin.hpp index 7317041..5fd3ec6 100644 --- a/src/probot/devices/leds/builtin.hpp +++ b/src/probot/devices/leds/builtin.hpp @@ -4,10 +4,6 @@ #if defined(ARDUINO) #include #endif -#if defined(ESP32) -#include -#include -#endif #ifndef NEOPIXEL_PIN #define NEOPIXEL_PIN 3 @@ -18,84 +14,73 @@ namespace probot::builtinled { #if defined(ARDUINO) + // Lock-free status LED. + // + // The blocking RMT transmit (pixel.show()) used to run under a + // portMAX_DELAY FreeRTOS mutex held across the transmit. A task killed + // mid-show() orphaned that mutex forever, and the sysloop — which also + // drives the LED — would then wedge on it. To remove that whole class of + // bug: setColor/set/setBrightness only stash the desired state in one + // atomic word; the actual show() happens in flush(), which is called from + // exactly one task (the sysloop). No mutex, single show() caller, no + // reentrancy. User setColor() takes effect at the next flush (<=500 ms); + // the sysloop overwrites it each tick with the status color, as before. namespace detail { + // packed desired state: [31:24]=brightness [23:16]=r [15:8]=g [7:0]=b + inline volatile uint32_t g_word = (32u << 24); + struct BuiltinLedState { Adafruit_NeoPixel pixel; - uint8_t brightness = 32; bool initialized = false; -#if defined(ESP32) - // The sysloop blinks the status LED while user code may also call - // setColor — NeoPixel show() is not reentrant (shared RMT). - SemaphoreHandle_t mtx = xSemaphoreCreateMutex(); -#endif - BuiltinLedState() : pixel(NEOPIXEL_COUNT, NEOPIXEL_PIN, NEO_GRB + NEO_KHZ800) {} - - void lock(){ -#if defined(ESP32) - if (mtx) xSemaphoreTake(mtx, portMAX_DELAY); -#endif - } - void unlock(){ -#if defined(ESP32) - if (mtx) xSemaphoreGive(mtx); -#endif - } }; inline BuiltinLedState& state(){ static BuiltinLedState s{}; return s; } - - // Caller must hold the lock. - inline void ensureInitLocked(BuiltinLedState& s){ - if (!s.initialized){ - s.pixel.begin(); - s.pixel.setBrightness(s.brightness); - s.pixel.clear(); - s.pixel.show(); - s.initialized = true; - } - } } // namespace detail + inline void setColor(uint8_t r, uint8_t g, uint8_t b){ + uint32_t w = __atomic_load_n(&detail::g_word, __ATOMIC_RELAXED); + w = (w & 0xFF000000u) | ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)b; + __atomic_store_n(&detail::g_word, w, __ATOMIC_RELAXED); + } + inline void setBrightness(uint8_t brightness){ - auto& s = detail::state(); - s.lock(); - s.brightness = brightness; - if (s.initialized){ - s.pixel.setBrightness(s.brightness); - s.pixel.show(); - } - s.unlock(); + uint32_t w = __atomic_load_n(&detail::g_word, __ATOMIC_RELAXED); + w = (w & 0x00FFFFFFu) | ((uint32_t)brightness << 24); + __atomic_store_n(&detail::g_word, w, __ATOMIC_RELAXED); } inline void set(bool on){ - auto& s = detail::state(); - s.lock(); - detail::ensureInitLocked(s); - if (on){ s.pixel.setPixelColor(0, s.pixel.Color(0, 0, 255)); } - else { s.pixel.setPixelColor(0, 0); } - s.pixel.show(); - s.unlock(); + setColor(0, 0, on ? 255 : 0); } - inline void setColor(uint8_t r, uint8_t g, uint8_t b){ + // Push the stashed color to the strip. MUST be called from a single task + // (the sysloop) — it performs the blocking RMT transmit. + inline void flush(){ auto& s = detail::state(); - s.lock(); - detail::ensureInitLocked(s); + uint32_t w = __atomic_load_n(&detail::g_word, __ATOMIC_RELAXED); + uint8_t br = (uint8_t)(w >> 24), r = (uint8_t)(w >> 16), + g = (uint8_t)(w >> 8), b = (uint8_t)w; + if (!s.initialized){ + s.pixel.begin(); + s.initialized = true; + } + s.pixel.setBrightness(br); s.pixel.setPixelColor(0, s.pixel.Color(r, g, b)); s.pixel.show(); - s.unlock(); } #elif defined(PROBOT_BUILTINLED_EXTERNAL) void set(bool on); void setBrightness(uint8_t brightness); void setColor(uint8_t r, uint8_t g, uint8_t b); + inline void flush() {} #else inline void set(bool) {} inline void setBrightness(uint8_t) {} inline void setColor(uint8_t, uint8_t, uint8_t) {} + inline void flush() {} #endif } // namespace probot::builtinled From 96668dff9a509a8d9fb045591329eb1c073d245c Mon Sep 17 00:00:00 2001 From: tunapro Date: Tue, 23 Jun 2026 10:00:30 +0300 Subject: [PATCH 20/27] feat(core)!: cooperative single-task lifecycle + halt-safe watchdog + emergency stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-cause fix for the issue #21 freeze. Replace the per-phase task create/vTaskDelete model with ONE persistent user task (core 1, created at boot, never killed in normal operation) running a phase state machine. Buttons set an atomic "requested mode"; the task performs the transition at its own loop boundary, so a Stop or phase change can no longer interrupt user code mid-transaction and orphan a Wire/malloc lock. The pure logic lives in core/lifecycle.hpp (host unit-testable). - Stall watchdog is halt-safe: a loop iteration past PROBOT_LOOP_DEADLINE_MS zeroes inputs and holds safe (red LED) with NO kill and NO reboot, preserving homed/relative mechanism state. - TWDT watches only the sysloop — a wedge reboots only on a supervisor/library fault, never on user code. - Terminal emergencyStop(): cut the enable pin, kill the user task, run robotEnd() in a fresh task under a PROBOT_ESTOP_END_MS watchdog (reboot if it hangs on an orphaned bus), then latch until reboot. - New macros: PROBOT_LOOP_DEADLINE_MS, PROBOT_WDT_TIMEOUT_S, PROBOT_ESTOP_END_MS, PROBOT_ESTOP_ENABLE_PIN, USER_LOOP_PERIOD_MS. STACK_USER 4096 -> 8192 (one task now hosts all six hooks). BREAKING: Stop takes effect at the next loop boundary (up to one iteration of latency); a wedged autonomous no longer auto-recovers to teleop (it falls to halt-safe). The 6-hook API is source-compatible. --- src/probot/core/core_config.hpp | 11 +- src/probot/core/lifecycle.hpp | 136 ++++++++++ src/probot/core/runtime.hpp | 458 +++++++++++++------------------- src/probot/robot/state.hpp | 8 + 4 files changed, 330 insertions(+), 283 deletions(-) create mode 100644 src/probot/core/lifecycle.hpp diff --git a/src/probot/core/core_config.hpp b/src/probot/core/core_config.hpp index aba0603..5b869c7 100644 --- a/src/probot/core/core_config.hpp +++ b/src/probot/core/core_config.hpp @@ -12,7 +12,12 @@ namespace probot { constexpr UBaseType_t PRIO_USER = 1; constexpr uint32_t STACK_CTRL = 4096; - constexpr uint32_t STACK_USER = 4096; - - constexpr uint32_t END_KILL_TIMEOUT_MS = 1000; + // One persistent user task now hosts all six hooks (init/end/teleop/auto), + // so it gets the headroom the four separate worker stacks used to split. + constexpr uint32_t STACK_USER = 8192; } + +// User loop cadence: how often teleopLoop/autonomousLoop are called (~50 Hz). +#ifndef USER_LOOP_PERIOD_MS +#define USER_LOOP_PERIOD_MS 20 +#endif diff --git a/src/probot/core/lifecycle.hpp b/src/probot/core/lifecycle.hpp new file mode 100644 index 0000000..a05d299 --- /dev/null +++ b/src/probot/core/lifecycle.hpp @@ -0,0 +1,136 @@ +#pragma once +#include +#include + +// Cooperative lifecycle core — pure logic, no FreeRTOS / Arduino deps so it +// builds and unit-tests on the host. The ESP32 plumbing (the persistent +// userTask, the sysloop, watchdogs, emergency stop) lives in runtime.hpp and +// drives these primitives. +// +// Design: there is exactly ONE long-lived user task. It never gets killed +// during normal operation — a kill mid-transaction is what orphaned a lock +// (Wire/malloc) and froze the robot in 0.2.x. Phase changes are cooperative: +// the supervisor publishes a "requested mode"; the user task observes it at a +// loop boundary and runs the transition hooks itself. The only force is a +// full reboot (watchdog) or the terminal emergency stop. + +namespace probot::core { + + enum class Mode : uint8_t { STOP = 0, INIT = 1, TELEOP = 2, AUTON = 3 }; + + // User lifecycle hooks, indirected so the phase machine is testable with + // mock callbacks. Any may be null (treated as empty). + struct Hooks { + void (*robotInit)(); + void (*robotEnd)(); + void (*teleopInit)(); + void (*teleopLoop)(); + void (*autonomousInit)(); + void (*autonomousLoop)(); + }; + + // Which mode the user task should be in for a given button Status. Pure. + inline Mode modeForStatus(robot::Status st, bool autonomousEnabled) { + switch (st) { + case robot::Status::INIT: return Mode::INIT; + case robot::Status::START: return autonomousEnabled ? Mode::AUTON : Mode::TELEOP; + case robot::Status::STOP: + default: return Mode::STOP; + } + } + + inline robot::Phase phaseForMode(Mode m) { + switch (m) { + case Mode::INIT: return robot::Phase::INITED; + case Mode::TELEOP: return robot::Phase::TELEOP; + case Mode::AUTON: return robot::Phase::AUTONOMOUS; + case Mode::STOP: + default: return robot::Phase::NOT_INIT; + } + } + + // Runs inside the single persistent user task. Owns the current mode and + // drives hook calls at safe loop boundaries — never mid-hook, so a stop or + // phase change can never kill user code holding a lock. + class PhaseMachine { + public: + Mode current() const { return cur_; } + + // One iteration. If the requested mode changed, run the transition + // (zero inputs, then the enter hook for the new mode, publishing the + // public Phase). Then call the active loop hook once. `onTransition` + // (e.g. zero the joystick) runs once per transition; `hb`, if given, is + // refreshed on entry to a loop phase so the stall watchdog starts clean. + void step(const Hooks& h, Mode requested, robot::StateService& rs, uint32_t now, + void (*onTransition)() = nullptr, volatile uint32_t* hb = nullptr) { + if (requested != cur_) { + if (onTransition) onTransition(); + enter(h, requested, rs, now); + cur_ = requested; + if (hb && (cur_ == Mode::TELEOP || cur_ == Mode::AUTON)) *hb = now; + } + if (cur_ == Mode::TELEOP) { if (h.teleopLoop) h.teleopLoop(); } + else if (cur_ == Mode::AUTON) { if (h.autonomousLoop) h.autonomousLoop(); } + } + + private: + void enter(const Hooks& h, Mode m, robot::StateService& rs, uint32_t now) { + switch (m) { + case Mode::STOP: + if (h.robotEnd) h.robotEnd(); + break; + case Mode::INIT: + if (h.robotInit) h.robotInit(); + break; + case Mode::TELEOP: + if (h.teleopInit) h.teleopInit(); + break; + case Mode::AUTON: + // Stamp the autonomous start BEFORE the init hook so the period + // timer (and the UI countdown) reference this run, not a stale one. + rs.setAutoStartMs(now, now); + if (h.autonomousInit) h.autonomousInit(); + break; + } + rs.setPhase(now, phaseForMode(m)); + } + + Mode cur_ = Mode::STOP; + }; + + // Runs inside the sysloop (supervisor). Translates button Status into the + // requested mode and enforces the autonomous period — cooperatively, by + // flipping the autonomous flag so the mode resolves to TELEOP (no task + // kill). Stateless apart from the StateService it reads/writes. + class Supervisor { + public: + // Returns the mode the user task should run. When estop is latched the + // answer is always STOP. + Mode update(robot::StateService& rs, uint32_t now, bool estopLatched) { + if (estopLatched) return Mode::STOP; + auto s = rs.read(); + // Autonomous period expiry. Gate on phase==AUTONOMOUS so we only count + // once the user task has actually entered autonomous and stamped a + // fresh autoStartMs — otherwise a stale timestamp from a previous run + // would expire the new run instantly. + if (s.status == robot::Status::START && s.autonomousEnabled && + s.phase == robot::Phase::AUTONOMOUS && s.autoStartMs != 0 && + s.autoPeriodSeconds > 0 && + (int32_t)(now - s.autoStartMs) >= s.autoPeriodSeconds * 1000) { + rs.setAutonomous(now, false); + s.autonomousEnabled = false; + } + return modeForStatus(s.status, s.autonomousEnabled); + } + }; + + // Stall detection: a loop iteration that hasn't returned within the + // deadline. Pure so it is unit-testable. + inline bool isStalled(robot::Phase phase, uint32_t now, uint32_t heartbeat_ms, + uint32_t deadline_ms) { + bool loopPhase = (phase == robot::Phase::TELEOP || phase == robot::Phase::AUTONOMOUS); + return loopPhase && heartbeat_ms != 0 && + (int32_t)(now - heartbeat_ms) > (int32_t)deadline_ms; + } + +} // namespace probot::core diff --git a/src/probot/core/runtime.hpp b/src/probot/core/runtime.hpp index aaa5bc9..df2c018 100644 --- a/src/probot/core/runtime.hpp +++ b/src/probot/core/runtime.hpp @@ -4,25 +4,58 @@ #include #include #include +#include +// Driver-station inactivity → STOP (1, default, safe) or just disconnect (0). #ifndef PROBOT_DS_TIMEOUT_MS #define PROBOT_DS_TIMEOUT_MS 10000 #endif - -// On DS activity timeout: -// 1 -> set Status::STOP (kills teleop/auto tasks, user code stops) — default, safe -// 0 -> only forceDisconnect (release owner, zero gamepad); user loops keep running -// Teams that want auto-recovery when the link returns without a full -// robot restart can set this to 0 in their sketch. #ifndef PROBOT_DS_TIMEOUT_FORCE_STOP #define PROBOT_DS_TIMEOUT_FORCE_STOP 1 #endif + +// A single teleop/autonomous loop iteration that has not returned within this +// many ms is treated as STALLED: inputs are zeroed and the robot is held safe +// (no kill, no reboot — see the design notes below). Must stay below the +// hardware watchdog timeout. +#ifndef PROBOT_LOOP_DEADLINE_MS +#define PROBOT_LOOP_DEADLINE_MS 2000 +#endif + +// Hardware task watchdog timeout (seconds). Only the sysloop supervisor is +// subscribed, so this reboots ONLY on a library/supervisor wedge — never on +// user code (a wedged user loop is held safe, not rebooted, to preserve any +// relative/homed mechanism state). Keep it comfortably above the loop +// deadline so a legitimately slow iteration never trips it. +#ifndef PROBOT_WDT_TIMEOUT_S +#define PROBOT_WDT_TIMEOUT_S 8 +#endif + +// Emergency stop: after killing the user task we run robotEnd() in a fresh +// task; if robotEnd does not return within this budget (e.g. it touches a bus +// the kill orphaned) the chip reboots instead of hanging. +#ifndef PROBOT_ESTOP_END_MS +#define PROBOT_ESTOP_END_MS 500 +#endif + +// Optional library-owned enable/E-stop GPIO. Wire it to your motor drivers' +// enable lines (or a contactor). Driven HIGH at boot, LOW on emergency stop — +// a hardware kill path independent of how user code drives outputs. -1 = off. +#ifndef PROBOT_ESTOP_ENABLE_PIN +#define PROBOT_ESTOP_ENABLE_PIN -1 +#endif + #include +#include #include #include +#include namespace probot { void runtime_setup(); + // Terminal emergency stop: kill the user task, run robotEnd under a + // watchdog, latch dead until reboot. Safe to call from any task. + void emergencyStop(); } // User hooks (provided by sketch) @@ -35,325 +68,161 @@ void autonomousLoop(); namespace probot { namespace detail { - // Task handles are owned EXCLUSIVELY by the sysloop task: workers - // never touch them (a worker nulling its own handle while sysloop - // reads it was a use-after-free). Completion is signalled through - // the atomic flags below; finished workers park in vTaskSuspend and - // wait to be reaped. - struct RuntimeState { - TaskHandle_t hSysloop = nullptr; - TaskHandle_t hAuto = nullptr; - TaskHandle_t hTeleop = nullptr; - TaskHandle_t hInit = nullptr; - TaskHandle_t hEnd = nullptr; - volatile uint32_t auto_start_ms = 0; - volatile uint32_t end_start_ms = 0; - volatile uint32_t stop_req = 0; // loop workers exit at the next boundary - volatile uint32_t auto_parked = 0; - volatile uint32_t teleop_parked = 0; - volatile uint32_t init_done = 0; - volatile uint32_t end_done = 0; - }; - - inline RuntimeState g_state{}; - - inline void autonomousWorker(void*){ - uint32_t now = millis(); - __atomic_store_n(&g_state.auto_start_ms, now, __ATOMIC_SEQ_CST); - __atomic_store_n(&probot::robot::g_loop_heartbeat_ms, now, __ATOMIC_SEQ_CST); - probot::robot::state().setAutoStartMs(now, now); - ::autonomousInit(); - while (!__atomic_load_n(&g_state.stop_req, __ATOMIC_SEQ_CST)){ - ::autonomousLoop(); - __atomic_store_n(&probot::robot::g_loop_heartbeat_ms, millis(), __ATOMIC_SEQ_CST); - vTaskDelay(pdMS_TO_TICKS(20)); - } - __atomic_store_n(&g_state.auto_parked, 1u, __ATOMIC_SEQ_CST); - vTaskSuspend(NULL); - } - - inline void teleopWorker(void*){ - __atomic_store_n(&probot::robot::g_loop_heartbeat_ms, millis(), __ATOMIC_SEQ_CST); - ::teleopInit(); - while (!__atomic_load_n(&g_state.stop_req, __ATOMIC_SEQ_CST)){ - ::teleopLoop(); - __atomic_store_n(&probot::robot::g_loop_heartbeat_ms, millis(), __ATOMIC_SEQ_CST); - vTaskDelay(pdMS_TO_TICKS(20)); - } - __atomic_store_n(&g_state.teleop_parked, 1u, __ATOMIC_SEQ_CST); - vTaskSuspend(NULL); - } + using core::Mode; - inline void robotInitWorker(void*){ - ::robotInit(); - __atomic_store_n(&g_state.init_done, 1u, __ATOMIC_SEQ_CST); - vTaskSuspend(NULL); - } + // requested mode: written by sysloop, read by the user task. + inline volatile uint32_t g_requested_mode = (uint32_t)Mode::STOP; - inline void robotEndWorker(void*){ - __atomic_store_n(&g_state.end_start_ms, millis(), __ATOMIC_SEQ_CST); - ::robotEnd(); - __atomic_store_n(&g_state.end_done, 1u, __ATOMIC_SEQ_CST); - vTaskSuspend(NULL); - } + inline TaskHandle_t g_user_task = nullptr; + inline TaskHandle_t g_sysloop_task = nullptr; + inline TaskHandle_t g_estop_task = nullptr; - // Wait for a worker to reach its park point. Returns false on - // timeout (user code is blocked — caller hard-kills). - inline bool waitFlag(volatile uint32_t* flag, uint32_t timeout_ms){ - uint32_t t0 = millis(); - while (!__atomic_load_n(flag, __ATOMIC_SEQ_CST)){ - if ((uint32_t)(millis() - t0) >= timeout_ms) return false; - vTaskDelay(pdMS_TO_TICKS(5)); - } - return true; - } - - inline void taskCreateFailed(const char* what){ - probot::telemetry::printf("!! %s task create FAILED\n", what); - probot::robot::state().setDeadlineMiss(millis(), true); - } + // emergency-stop robotEnd watchdog + inline volatile uint32_t g_estop_end_start = 0; + inline volatile uint32_t g_estop_end_done = 0; - inline void stopAutonomous(){ - auto& s = g_state; - if (s.hAuto){ - // Cooperative stop: let the worker finish its current loop - // iteration (~3 periods grace) so it isn't killed mid-Serial/ - // Wire transaction; hard-kill only if the user code is blocked. - __atomic_store_n(&s.stop_req, 1u, __ATOMIC_SEQ_CST); - waitFlag(&s.auto_parked, 60); - vTaskDelete(s.hAuto); - s.hAuto = nullptr; - __atomic_store_n(&s.stop_req, 0u, __ATOMIC_SEQ_CST); - __atomic_store_n(&s.auto_parked, 0u, __ATOMIC_SEQ_CST); - } - __atomic_store_n(&s.auto_start_ms, 0u, __ATOMIC_SEQ_CST); - __atomic_store_n(&probot::robot::g_loop_heartbeat_ms, 0u, __ATOMIC_SEQ_CST); - probot::robot::state().setAutoStartMs(millis(), 0u); - } + inline core::PhaseMachine g_machine; - inline void stopTeleop(){ - auto& s = g_state; - if (s.hTeleop){ - __atomic_store_n(&s.stop_req, 1u, __ATOMIC_SEQ_CST); - waitFlag(&s.teleop_parked, 60); - vTaskDelete(s.hTeleop); - s.hTeleop = nullptr; - __atomic_store_n(&s.stop_req, 0u, __ATOMIC_SEQ_CST); - __atomic_store_n(&s.teleop_parked, 0u, __ATOMIC_SEQ_CST); - } - __atomic_store_n(&probot::robot::g_loop_heartbeat_ms, 0u, __ATOMIC_SEQ_CST); + inline core::Hooks userHooks(){ + return core::Hooks{ &::robotInit, &::robotEnd, &::teleopInit, + &::teleopLoop, &::autonomousInit, &::autonomousLoop }; } - inline void stopInit(){ - auto& s = g_state; - if (s.hInit){ vTaskDelete(s.hInit); s.hInit = nullptr; } - __atomic_store_n(&s.init_done, 0u, __ATOMIC_SEQ_CST); + inline void zeroInputs(){ + probot::io::gamepad().write(millis(), nullptr, 0, nullptr, 0); } - inline void startAutonomous(){ - auto& s = g_state; - stopTeleop(); - stopAutonomous(); - __atomic_store_n(&s.auto_parked, 0u, __ATOMIC_SEQ_CST); - if (xTaskCreatePinnedToCore(autonomousWorker, "auto", STACK_USER, NULL, PRIO_USER, &s.hAuto, CORE_CTRL) != pdPASS){ - s.hAuto = nullptr; - taskCreateFailed("autonomous"); - } - } - - inline void startTeleop(){ - auto& s = g_state; - stopAutonomous(); - stopTeleop(); - __atomic_store_n(&s.teleop_parked, 0u, __ATOMIC_SEQ_CST); - if (xTaskCreatePinnedToCore(teleopWorker, "teleop", STACK_USER, NULL, PRIO_USER, &s.hTeleop, CORE_CTRL) != pdPASS){ - s.hTeleop = nullptr; - taskCreateFailed("teleop"); - } - } - - inline void startInit(){ - auto& s = g_state; - if (s.hInit) return; - __atomic_store_n(&s.init_done, 0u, __ATOMIC_SEQ_CST); - if (xTaskCreatePinnedToCore(robotInitWorker, "init", STACK_USER, NULL, PRIO_USER, &s.hInit, CORE_CTRL) != pdPASS){ - s.hInit = nullptr; - taskCreateFailed("robotInit"); + // ── The single persistent user task (core 1) ── + // Created once, NEVER deleted in normal operation. All six hooks run + // here, in sequence, only at loop boundaries — so a Stop or phase change + // can never interrupt user code mid-transaction and orphan a lock. + inline void userTask(void*){ + auto hooks = userHooks(); + auto& rs = probot::robot::state(); + __atomic_store_n(&probot::robot::g_loop_heartbeat_ms, millis(), __ATOMIC_SEQ_CST); + for(;;){ + Mode req = (Mode)__atomic_load_n(&g_requested_mode, __ATOMIC_SEQ_CST); + g_machine.step(hooks, req, rs, millis(), &zeroInputs, + &probot::robot::g_loop_heartbeat_ms); + __atomic_store_n(&probot::robot::g_loop_heartbeat_ms, millis(), __ATOMIC_SEQ_CST); + vTaskDelay(pdMS_TO_TICKS(USER_LOOP_PERIOD_MS)); } } - inline void startRobotEnd(){ - auto& s = g_state; - if (s.hEnd) return; - __atomic_store_n(&s.end_done, 0u, __ATOMIC_SEQ_CST); - __atomic_store_n(&s.end_start_ms, 0u, __ATOMIC_SEQ_CST); - if (xTaskCreatePinnedToCore(robotEndWorker, "end", STACK_USER, NULL, PRIO_USER, &s.hEnd, CORE_CTRL) != pdPASS){ - s.hEnd = nullptr; - taskCreateFailed("robotEnd"); - } + // ── Emergency stop ── + inline void estopEndTask(void*){ + ::robotEnd(); + __atomic_store_n(&g_estop_end_done, 1u, __ATOMIC_SEQ_CST); + vTaskSuspend(NULL); } - inline void stopRobotEnd(){ - auto& s = g_state; - if (s.hEnd){ vTaskDelete(s.hEnd); s.hEnd = nullptr; } - __atomic_store_n(&s.end_start_ms, 0u, __ATOMIC_SEQ_CST); - __atomic_store_n(&s.end_done, 0u, __ATOMIC_SEQ_CST); + inline void setEnablePin(bool enabled){ +#if PROBOT_ESTOP_ENABLE_PIN >= 0 + digitalWrite(PROBOT_ESTOP_ENABLE_PIN, enabled ? HIGH : LOW); +#else + (void)enabled; +#endif } - inline void updateLed(){ - auto s = probot::robot::state().read(); + inline void updateLed(bool estop){ static bool on = false; on = !on; - static uint32_t dmLedTime = 0; - - if (s.deadlineMiss){ - if (dmLedTime == 0) dmLedTime = millis(); - if (millis() - dmLedTime > 3000){ - probot::robot::state().setDeadlineMiss(millis(), false); - dmLedTime = 0; - } else { - if (on) builtinled::setColor(255,0,0); - else builtinled::setColor(0,0,0); - return; - } - } else { - dmLedTime = 0; - } + auto s = probot::robot::state().read(); + uint8_t r = 0, g = 0, b = 0; - switch (s.phase){ - case probot::robot::Phase::NOT_INIT: - if (s.clientCount > 0){ if (on) builtinled::setColor(0,0,255); else builtinled::setColor(0,0,0); } - else { builtinled::setColor(0,0,255); } - break; - case probot::robot::Phase::INITED: - builtinled::setColor(255,255,0); - break; - case probot::robot::Phase::AUTONOMOUS: - if (on) builtinled::setColor(255,128,0); else builtinled::setColor(0,0,0); - break; - case probot::robot::Phase::TELEOP: - if (on) builtinled::setColor(0,255,0); else builtinled::setColor(0,0,0); - break; + if (estop){ + r = 255; // solid red — terminal + } else if (s.deadlineMiss){ + if (on) r = 255; // blinking red — stalled (held safe) + } else { + switch (s.phase){ + case probot::robot::Phase::NOT_INIT: + b = 255; + if (s.clientCount > 0 && !on) b = 0; // blink blue when a client is connected + break; + case probot::robot::Phase::INITED: + r = 255; g = 255; // solid yellow + break; + case probot::robot::Phase::AUTONOMOUS: + if (on){ r = 255; g = 128; } // blink orange + break; + case probot::robot::Phase::TELEOP: + if (on) g = 255; // blink green + break; + } } + builtinled::setColor(r, g, b); + builtinled::flush(); // only the sysloop calls show() } - inline void sysloopTask(){ + inline void sysloopTask(void*){ using probot::robot::Status; using probot::robot::Phase; #ifdef ESP32 - // Subscribe to the task watchdog — without at least one - // subscribed task the TWDT never fires and a wedged sysloop - // (the task that supervises everything else) goes unnoticed. esp_task_wdt_add(NULL); #endif - Status lastStatus = Status::STOP; - int32_t autoLen = 0; + core::Supervisor sup; uint32_t lastLed = 0; for(;;){ #ifdef ESP32 esp_task_wdt_reset(); #endif - auto s = probot::robot::state().read(); uint32_t now = millis(); + bool estop = __atomic_load_n(&probot::robot::g_estop_latched, __ATOMIC_SEQ_CST) != 0; - // Reap finished init/end workers (they park in vTaskSuspend). - // INITED is reported only when robotInit() actually completed — - // pressing Start mid-init no longer runs teleop on - // half-initialized hardware. - if (detail::g_state.hInit && - __atomic_load_n(&detail::g_state.init_done, __ATOMIC_SEQ_CST)){ - stopInit(); - probot::robot::state().setPhase(now, Phase::INITED); - } - if (detail::g_state.hEnd && - __atomic_load_n(&detail::g_state.end_done, __ATOMIC_SEQ_CST)){ - stopRobotEnd(); - } - - bool endRunning = (detail::g_state.hEnd != nullptr); - uint32_t endStart = __atomic_load_n(&detail::g_state.end_start_ms, __ATOMIC_SEQ_CST); - if (endRunning && endStart != 0u && (int32_t)(now - endStart) >= (int32_t)END_KILL_TIMEOUT_MS){ - stopRobotEnd(); - endRunning = false; + // Deliberate reboot (UI "reboot to clear estop" button). + if (__atomic_load_n(&probot::robot::g_reboot_requested, __ATOMIC_SEQ_CST)){ + Serial.println("[SYS ] reboot requested"); + delay(50); + ESP.restart(); } - if (s.status != lastStatus){ - if (s.status == Status::INIT){ - stopAutonomous(); - stopTeleop(); - stopRobotEnd(); - startInit(); - } else if (s.status == Status::START){ - stopInit(); - stopRobotEnd(); - if (s.autonomousEnabled){ - autoLen = s.autoPeriodSeconds; - __atomic_store_n(&detail::g_state.auto_start_ms, 0u, __ATOMIC_SEQ_CST); - probot::robot::state().setAutoStartMs(now, 0u); - probot::robot::state().setPhase(now, Phase::AUTONOMOUS); - startAutonomous(); - } else { - probot::robot::state().setPhase(now, Phase::TELEOP); - startTeleop(); - } - } else if (s.status == Status::STOP){ - stopInit(); - stopAutonomous(); - stopTeleop(); - startRobotEnd(); - probot::robot::state().setPhase(now, Phase::NOT_INIT); - } - lastStatus = s.status; + // Run the terminal emergency-stop sequence once, from here (so it is + // serialized and off the HTTP task). + if (!estop && __atomic_load_n(&probot::robot::g_estop_requested, __ATOMIC_SEQ_CST)){ + probot::emergencyStop(); + estop = true; } - if (s.status == Status::START && s.autonomousEnabled){ - uint32_t autoStart = __atomic_load_n(&detail::g_state.auto_start_ms, __ATOMIC_SEQ_CST); - if (autoLen > 0 && autoStart != 0u && (int32_t)(now - autoStart) >= autoLen * 1000){ - stopAutonomous(); - probot::robot::state().setAutonomous(now, false); - probot::robot::state().setPhase(now, Phase::TELEOP); - startTeleop(); + // estop robotEnd watchdog: if it didn't return in time, reboot + // (the kill may have orphaned a bus robotEnd needs). + if (estop){ + uint32_t es = __atomic_load_n(&g_estop_end_start, __ATOMIC_SEQ_CST); + if (es != 0 && !__atomic_load_n(&g_estop_end_done, __ATOMIC_SEQ_CST) && + (int32_t)(now - es) >= (int32_t)PROBOT_ESTOP_END_MS){ + Serial.println("[SYS ] estop robotEnd timed out -> restart"); + delay(20); + ESP.restart(); } } - if (s.status == Status::START && s.phase == Phase::AUTONOMOUS && !s.autonomousEnabled){ - stopAutonomous(); - probot::robot::state().setPhase(now, Phase::TELEOP); - startTeleop(); - } + // Translate buttons → requested mode (and enforce the auto period). + Mode req = sup.update(probot::robot::state(), now, estop); + __atomic_store_n(&g_requested_mode, (uint32_t)req, __ATOMIC_SEQ_CST); - // Deadline miss: warn on teleop, kill autonomous. The - // !s.deadlineMiss gate keeps this one-shot per episode — - // without it the telemetry println fires every 1 ms iteration - // and wipes the 256-byte ring exactly when it matters most. - { - bool taskRunning = (s.phase == Phase::TELEOP || s.phase == Phase::AUTONOMOUS); + // Stall watchdog (halt-safe). A loop iteration past the deadline → + // zero inputs, flag stalled (red LED), hold. No kill, no reboot. + if (!estop){ + auto s = probot::robot::state().read(); uint32_t hb = __atomic_load_n(&probot::robot::g_loop_heartbeat_ms, __ATOMIC_SEQ_CST); - if (taskRunning && !s.deadlineMiss && hb != 0 && (int32_t)(now - hb) > 2000){ + bool stalled = core::isStalled(s.phase, now, hb, PROBOT_LOOP_DEADLINE_MS); + if (stalled && !s.deadlineMiss){ probot::robot::state().setDeadlineMiss(now, true); - if (s.phase == Phase::AUTONOMOUS){ - probot::telemetry::println("!! DEADLINE MISS — auto blocked, switching to teleop"); - stopAutonomous(); - probot::robot::state().setAutonomous(now, false); - probot::robot::state().setPhase(now, Phase::TELEOP); - startTeleop(); - } else { - probot::telemetry::println("!! DEADLINE MISS — teleop blocked"); - } + zeroInputs(); + probot::telemetry::println("!! LOOP STALLED — inputs zeroed, holding safe (no reboot)"); + } else if (!stalled && s.deadlineMiss){ + probot::robot::state().setDeadlineMiss(now, false); // recovered } } - // DS connection heartbeat: no activity → stop robot and/or disconnect - { + // DS connection heartbeat: no activity → stop and/or disconnect. + if (!estop){ uint32_t dsAct = __atomic_load_n(&probot::robot::g_ds_last_activity_ms, __ATOMIC_SEQ_CST); - if (dsAct != 0 && s.status != Status::STOP && + if (dsAct != 0 && probot::robot::state().status() != Status::STOP && (int32_t)(now - dsAct) > (int32_t)PROBOT_DS_TIMEOUT_MS){ Serial.printf("[SYS ] DS timeout: no activity for %lu ms\n", (unsigned long)(now - dsAct)); #if PROBOT_DS_TIMEOUT_FORCE_STOP probot::telemetry::println("!! DS CONNECTION LOST — stopping robot"); - // Only set the status — the transition block above must see - // status != lastStatus on the next iteration to actually - // tear down teleop/auto and run robotEnd. probot::robot::state().setStatus(now, Status::STOP); #else probot::telemetry::println("!! DS CONNECTION LOST — joystick neutral, waiting reconnect"); @@ -367,7 +236,6 @@ namespace probot { } } - // Expire DS owner if idle (replaces handleClient polling) #ifdef ESP32 if (probot::driverstation::detail::g_driver_station){ probot::driverstation::detail::g_driver_station->expireOwnerIfIdle(); @@ -377,26 +245,56 @@ namespace probot { if (now - lastLed >= 500){ lastLed = now; - updateLed(); + updateLed(estop); } - vTaskDelay(pdMS_TO_TICKS(1)); + vTaskDelay(pdMS_TO_TICKS(5)); } } } // namespace detail + // Terminal emergency stop. Idempotent. + inline void emergencyStop(){ + if (__atomic_exchange_n(&probot::robot::g_estop_latched, 1u, __ATOMIC_SEQ_CST)) return; + detail::setEnablePin(false); // hardware kill path, if wired + // Kill the single user task. Safe for probot's own portMUX locks (they + // can't be killed mid critical-section); only a user-held Wire/malloc lock + // can orphan, which is acceptable because this path is terminal + reboot. + if (detail::g_user_task){ + vTaskDelete(detail::g_user_task); + detail::g_user_task = nullptr; + } + // Run robotEnd() in a fresh task under the sysloop watchdog. + __atomic_store_n(&detail::g_estop_end_done, 0u, __ATOMIC_SEQ_CST); + uint32_t t = millis(); if (t == 0) t = 1; + __atomic_store_n(&detail::g_estop_end_start, t, __ATOMIC_SEQ_CST); + xTaskCreatePinnedToCore(detail::estopEndTask, "estop", STACK_USER, NULL, + PRIO_USER, &detail::g_estop_task, CORE_CTRL); + probot::robot::state().setStatus(millis(), robot::Status::STOP); + probot::telemetry::println("!! EMERGENCY STOP — robot disabled, reboot required"); + Serial.println("[SYS ] EMERGENCY STOP"); + } + inline void runtime_setup(){ Serial.begin(115200); delay(200); - Serial.println("\n[Probot] Core0=DS+SYS, Core1=USER"); + Serial.println("\n[Probot] Core0=DS+SYS, Core1=USER (cooperative lifecycle)"); + +#if PROBOT_ESTOP_ENABLE_PIN >= 0 + pinMode(PROBOT_ESTOP_ENABLE_PIN, OUTPUT); + digitalWrite(PROBOT_ESTOP_ENABLE_PIN, HIGH); // enabled +#endif - wdt_init_no_idle(3, true); + wdt_init_no_idle(PROBOT_WDT_TIMEOUT_S, true); #ifdef ESP32 probot::driverstation::start_driver_station(); #endif - auto& s = detail::g_state; - xTaskCreatePinnedToCore([](void*){ detail::sysloopTask(); }, "sysloop", STACK_CTRL, NULL, PRIO_CTRL, &s.hSysloop, CORE_UI); + // Persistent user task (core 1) and sysloop supervisor (core 0). + xTaskCreatePinnedToCore(detail::userTask, "user", STACK_USER, NULL, + PRIO_USER, &detail::g_user_task, CORE_CTRL); + xTaskCreatePinnedToCore(detail::sysloopTask, "sysloop", STACK_CTRL, NULL, + PRIO_CTRL, &detail::g_sysloop_task, CORE_UI); } } // namespace probot diff --git a/src/probot/robot/state.hpp b/src/probot/robot/state.hpp index 77b2294..4086a5e 100644 --- a/src/probot/robot/state.hpp +++ b/src/probot/robot/state.hpp @@ -84,4 +84,12 @@ namespace probot::robot { // Driver station activity — updated by HTTP/WS handlers on every request. // Checked by sysloop to detect connection loss. inline volatile uint32_t g_ds_last_activity_ms = 0; + + // Emergency stop. Set requested=1 from an HTTP handler; the sysloop runs + // the terminal emergency-stop sequence and sets latched=1. While latched, + // init/start commands are refused — the robot stays dead until reboot. + inline volatile uint32_t g_estop_requested = 0; + inline volatile uint32_t g_estop_latched = 0; + // Deliberate software reboot request (the "reboot to clear estop" button). + inline volatile uint32_t g_reboot_requested = 0; } From c911ea22205df4804f4ea6bdc0b61320de8baf81 Mon Sep 17 00:00:00 2001 From: tunapro Date: Tue, 23 Jun 2026 10:00:41 +0300 Subject: [PATCH 21/27] feat(ds): emergency-stop/reboot commands, estop status field, recv timeout /robotControl gains cmd=estop (requests the terminal emergency stop, run by the sysloop) and cmd=reboot (ESP.restart, the way to clear the latch). While estop-latched, init/start are refused with 409. The estop flag is published on the 'S' push frame and /getState so the UI can show a persistent banner. httpd recv_wait_timeout lowered 5s -> 2s, symmetric with send_wait_timeout, to bound a half-open client's worker hold. --- .../esp32s3/driver_station_esp32.hpp | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/driverstation/esp32s3/driver_station_esp32.hpp b/src/driverstation/esp32s3/driver_station_esp32.hpp index 7675611..00a55a3 100644 --- a/src/driverstation/esp32s3/driver_station_esp32.hpp +++ b/src/driverstation/esp32s3/driver_station_esp32.hpp @@ -223,8 +223,10 @@ namespace probot::driverstation::esp32 { // Nagle + the client's delayed ACK can hold small server->client // frames for tens of ms. cfg.open_fn = onSocketOpen; - // Bound how long a send to a stalled client can block (default 5s). + // Bound how long a send/recv to a stalled client can block (default 5s + // each). Symmetric 2 s caps the worker hold for half-open clients. cfg.send_wait_timeout = 2; + cfg.recv_wait_timeout = 2; // TCP keepalive detects a vanished client (tablet walked away, // battery died) in ~7s at the TCP layer, closing the session // without app-level machinery. @@ -420,7 +422,7 @@ namespace probot::driverstation::esp32 { int n = snprintf(out, out_size, "{\"phase\":%u,\"autonomousEnabled\":%s,\"autoPeriodSeconds\":%d," "\"autoRemainingMs\":%u,\"rssi\":%d,\"up\":%lu,\"heap\":%lu,\"dm\":%s," - "\"joyAgeMs\":%ld,\"sta\":%ld,\"disc\":%u}", + "\"estop\":%s,\"joyAgeMs\":%ld,\"sta\":%ld,\"disc\":%u}", static_cast(s.phase), s.autonomousEnabled ? "true" : "false", (int)s.autoPeriodSeconds, @@ -429,6 +431,7 @@ namespace probot::driverstation::esp32 { (unsigned long)now, (unsigned long)ESP.getFreeHeap(), s.deadlineMiss ? "true" : "false", + __atomic_load_n(&probot::robot::g_estop_latched, __ATOMIC_SEQ_CST) ? "true" : "false", joyAge, (long)diag::g_sta_count, (unsigned)diag::g_last_disc_reason); @@ -734,6 +737,26 @@ namespace probot::driverstation::esp32 { // overflow on hostile/typo'd input. if (autoLen > 3600) autoLen = 3600; + // Emergency stop and reboot are handled even while latched. + if (strcmp(cmd, "estop") == 0) { + __atomic_store_n(&probot::robot::g_estop_requested, 1u, __ATOMIC_SEQ_CST); + httpd_resp_send(req, "ESTOP", HTTPD_RESP_USE_STRLEN); + return ESP_OK; + } + if (strcmp(cmd, "reboot") == 0) { + __atomic_store_n(&probot::robot::g_reboot_requested, 1u, __ATOMIC_SEQ_CST); + httpd_resp_send(req, "REBOOT", HTTPD_RESP_USE_STRLEN); + return ESP_OK; + } + + // Terminal latch: once emergency-stopped the robot stays dead until a + // reboot — refuse anything that would re-arm it. + if (__atomic_load_n(&probot::robot::g_estop_latched, __ATOMIC_SEQ_CST)) { + httpd_resp_set_status(req, "409 Conflict"); + httpd_resp_send(req, "EMERGENCY STOPPED — reboot required", HTTPD_RESP_USE_STRLEN); + return ESP_OK; + } + if (strcmp(cmd, "init") == 0) { ds->_rs.setStatus(millis(), robot::Status::INIT); ds->_rs.setDeadlineMiss(millis(), false); @@ -810,13 +833,14 @@ namespace probot::driverstation::esp32 { if (!ds->enforceOwner(req)) return ESP_OK; auto s = ds->_rs.read(); - char buf[128]; + char buf[160]; snprintf(buf, sizeof(buf), - "{\"phase\":%u,\"autonomousEnabled\":%s,\"autoPeriodSeconds\":%d,\"autoRemainingMs\":%u}", + "{\"phase\":%u,\"autonomousEnabled\":%s,\"autoPeriodSeconds\":%d,\"autoRemainingMs\":%u,\"estop\":%s}", static_cast(s.phase), s.autonomousEnabled ? "true" : "false", (int)s.autoPeriodSeconds, - (unsigned)computeAutoRemainingMs(s, millis())); + (unsigned)computeAutoRemainingMs(s, millis()), + __atomic_load_n(&probot::robot::g_estop_latched, __ATOMIC_SEQ_CST) ? "true" : "false"); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, buf, HTTPD_RESP_USE_STRLEN); From 376763b598e7403a09673a827807f874f5c6edf3 Mon Sep 17 00:00:00 2001 From: tunapro Date: Tue, 23 Jun 2026 10:00:41 +0300 Subject: [PATCH 22/27] feat(ui): emergency stop button, latched overlay, reboot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a red EMERGENCY STOP button to Match Control (sends cmd=estop) and a full-screen "EMERGENCY STOPPED — reboot required" overlay driven by the estop state field, with a Reboot button (cmd=reboot). --- src/driverstation/esp32s3/index_html.h | 58 ++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/driverstation/esp32s3/index_html.h b/src/driverstation/esp32s3/index_html.h index 1fe5a32..ee3221d 100644 --- a/src/driverstation/esp32s3/index_html.h +++ b/src/driverstation/esp32s3/index_html.h @@ -491,6 +491,36 @@ const char MAIN_page[] PROGMEM = R"=====( letter-spacing:0.1em;opacity:0.85; } + /* Emergency stop */ + .estop-btn{ + width:100%;margin-top:14px; + padding:18px;border:none;border-radius:14px;cursor:pointer; + background:var(--stop);color:#fff; + font-size:1.4rem;font-weight:800;letter-spacing:0.12em; + text-transform:uppercase; + } + .estop-btn:active{filter:brightness(0.85);} + .estop-overlay{ + display:none; + position:fixed;inset:0;z-index:10000; + color:#fff; + justify-content:center;align-items:center; + flex-direction:column;gap:18px; + font-size:2.2rem;font-weight:800; + letter-spacing:0.2em;text-transform:uppercase; + background:rgba(140,12,12,0.97); + } + .estop-overlay.show{display:flex;} + .estop-overlay .sub{ + font-size:0.95rem;font-weight:400; + letter-spacing:0.08em;opacity:0.9;text-transform:none; + } + .estop-overlay button{ + margin-top:10px;padding:16px 32px;border:none;border-radius:12px; + cursor:pointer;background:#fff;color:#8c0c0c; + font-size:1.1rem;font-weight:800;letter-spacing:0.06em; + } + /* Debug grid for Logs page */ .debug-grid{ display:grid; @@ -664,6 +694,12 @@ const char MAIN_page[] PROGMEM = R"=====( Trying to reconnect...
+
+ EMERGENCY STOPPED + Robot disabled — reboot required to clear + +
+
@@ -682,6 +718,7 @@ const char MAIN_page[] PROGMEM = R"=====(
+
Autonomous Countdown @@ -1001,6 +1038,8 @@ const char MAIN_page[] PROGMEM = R"=====( var lastCmdMs=-10000; function applyState(data){ if(!data) return; + var estopOv=document.getElementById('estopOverlay'); + if(estopOv) estopOv.classList.toggle('show',data.estop===true); if(performance.now()-lastCmdMs<600) return; var btn=document.getElementById('robotButton'); if(!btn) return; @@ -1134,6 +1173,25 @@ const char MAIN_page[] PROGMEM = R"=====( } document.getElementById('robotButton').addEventListener('click',handleRobotButton); + /* ===== EMERGENCY STOP / REBOOT ===== */ + function sendSimpleCmd(cmd){ + var ac=new AbortController(); + var tid=setTimeout(function(){ac.abort();},3000); + return fetch('/robotControl?cmd='+cmd,{signal:ac.signal}).then(function(r){ + clearTimeout(tid);return r; + }).catch(function(err){console.error(cmd+' fetch error:',err);}); + } + document.getElementById('estopButton').addEventListener('click',function(){ + lastCmdMs=performance.now(); + sendSimpleCmd('estop'); + var ov=document.getElementById('estopOverlay'); + if(ov) ov.classList.add('show'); + }); + document.getElementById('rebootButton').addEventListener('click',function(){ + this.textContent='Rebooting...';this.disabled=true; + sendSimpleCmd('reboot'); + }); + /* ===== GAMEPAD ===== */ function updateGamepads(){ var gpList=navigator.getGamepads?navigator.getGamepads():[]; From d8b8162defcb6f1a56d1d97379ce092d740a4735 Mon Sep 17 00:00:00 2001 From: tunapro Date: Tue, 23 Jun 2026 10:00:41 +0300 Subject: [PATCH 23/27] test: host unit tests for the cooperative lifecycle Cover the phase state machine (full STOP->INIT->TELEOP->STOP path, autonomous entry stamping the start time), the supervisor's status->mode mapping and autonomous-period expiry, and stall detection. --- tests/test_lifecycle.cpp | 134 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 tests/test_lifecycle.cpp diff --git a/tests/test_lifecycle.cpp b/tests/test_lifecycle.cpp new file mode 100644 index 0000000..8974644 --- /dev/null +++ b/tests/test_lifecycle.cpp @@ -0,0 +1,134 @@ +#include "test_harness.hpp" + +#include +#include + +#include +#include + +namespace robot = probot::robot; +namespace core = probot::core; +using core::Mode; + +namespace { + std::vector g_calls; + int g_transitions = 0; + + void h_robotInit() { g_calls.push_back("robotInit"); } + void h_robotEnd() { g_calls.push_back("robotEnd"); } + void h_teleopInit() { g_calls.push_back("teleopInit"); } + void h_teleopLoop() { g_calls.push_back("teleopLoop"); } + void h_autoInit() { g_calls.push_back("autonomousInit"); } + void h_autoLoop() { g_calls.push_back("autonomousLoop"); } + void h_onTransition() { g_transitions++; } + + core::Hooks mkHooks(){ + return core::Hooks{ h_robotInit, h_robotEnd, h_teleopInit, + h_teleopLoop, h_autoInit, h_autoLoop }; + } +} + +TEST_CASE(mode_for_status){ + EXPECT_TRUE(core::modeForStatus(robot::Status::STOP, false) == Mode::STOP); + EXPECT_TRUE(core::modeForStatus(robot::Status::INIT, true) == Mode::INIT); + EXPECT_TRUE(core::modeForStatus(robot::Status::START, false) == Mode::TELEOP); + EXPECT_TRUE(core::modeForStatus(robot::Status::START, true) == Mode::AUTON); +} + +TEST_CASE(lifecycle_full_transition_path){ + g_calls.clear(); g_transitions = 0; + robot::StateService rs; + core::PhaseMachine m; + auto h = mkHooks(); + volatile uint32_t hb = 0; + + // STOP -> INIT: robotInit runs, phase becomes INITED, no loop hook. + m.step(h, Mode::INIT, rs, 100, h_onTransition, &hb); + EXPECT_TRUE(m.current() == Mode::INIT); + EXPECT_TRUE(rs.read().phase == robot::Phase::INITED); + EXPECT_TRUE(g_calls.size() == 1); + EXPECT_TRUE(g_calls[0] == "robotInit"); + + // INIT -> TELEOP: teleopInit then teleopLoop; heartbeat reset on entry. + m.step(h, Mode::TELEOP, rs, 200, h_onTransition, &hb); + EXPECT_TRUE(rs.read().phase == robot::Phase::TELEOP); + EXPECT_TRUE(g_calls.size() == 3); + EXPECT_TRUE(g_calls[1] == "teleopInit"); + EXPECT_TRUE(g_calls[2] == "teleopLoop"); + EXPECT_TRUE(hb == 200u); + + // Stay in TELEOP: just another loop, no transition. + m.step(h, Mode::TELEOP, rs, 220, h_onTransition, &hb); + EXPECT_TRUE(g_calls.size() == 4); + EXPECT_TRUE(g_calls[3] == "teleopLoop"); + + // TELEOP -> STOP: robotEnd, phase NOT_INIT. + m.step(h, Mode::STOP, rs, 300, h_onTransition, &hb); + EXPECT_TRUE(rs.read().phase == robot::Phase::NOT_INIT); + EXPECT_TRUE(g_calls.back() == "robotEnd"); + + // Three real transitions (INIT, TELEOP, STOP), not the stay-in-TELEOP. + EXPECT_TRUE(g_transitions == 3); +} + +TEST_CASE(lifecycle_autonomous_entry_stamps_start){ + g_calls.clear(); + robot::StateService rs; + core::PhaseMachine m; + auto h = mkHooks(); + + m.step(h, Mode::AUTON, rs, 500, nullptr, nullptr); + auto s = rs.read(); + EXPECT_TRUE(s.phase == robot::Phase::AUTONOMOUS); + EXPECT_TRUE(s.autoStartMs == 500u); + EXPECT_TRUE(g_calls.size() == 2); + EXPECT_TRUE(g_calls[0] == "autonomousInit"); + EXPECT_TRUE(g_calls[1] == "autonomousLoop"); +} + +TEST_CASE(supervisor_modes_and_estop){ + robot::StateService rs; + core::Supervisor sup; + + rs.setStatus(0, robot::Status::STOP); + EXPECT_TRUE(sup.update(rs, 10, false) == Mode::STOP); + + rs.setStatus(0, robot::Status::INIT); + EXPECT_TRUE(sup.update(rs, 10, false) == Mode::INIT); + + rs.setStatus(0, robot::Status::START); + rs.setAutonomous(0, false); + EXPECT_TRUE(sup.update(rs, 10, false) == Mode::TELEOP); + + rs.setAutonomous(0, true); + EXPECT_TRUE(sup.update(rs, 10, false) == Mode::AUTON); + + // Latched estop overrides everything. + EXPECT_TRUE(sup.update(rs, 10, true) == Mode::STOP); +} + +TEST_CASE(supervisor_auto_period_expiry){ + robot::StateService rs; + core::Supervisor sup; + rs.setStatus(0, robot::Status::START); + rs.setAutonomous(0, true); + rs.setPhase(0, robot::Phase::AUTONOMOUS); // user task has entered auton + rs.setAutoStartMs(0, 1000); + rs.setAutoPeriodSeconds(0, 5); // 5 s window + + // 4 s in: still autonomous. + EXPECT_TRUE(sup.update(rs, 5000, false) == Mode::AUTON); + EXPECT_TRUE(rs.read().autonomousEnabled == true); + + // 5 s in: period elapsed -> autonomous flag dropped -> TELEOP. + EXPECT_TRUE(sup.update(rs, 6000, false) == Mode::TELEOP); + EXPECT_TRUE(rs.read().autonomousEnabled == false); +} + +TEST_CASE(stall_detection){ + using core::isStalled; + EXPECT_TRUE(isStalled(robot::Phase::TELEOP, 5000, 1000, 2000) == true); // 4000 > 2000 + EXPECT_TRUE(isStalled(robot::Phase::TELEOP, 2500, 1000, 2000) == false); // 1500 < 2000 + EXPECT_TRUE(isStalled(robot::Phase::INITED, 5000, 1000, 2000) == false); // not a loop phase + EXPECT_TRUE(isStalled(robot::Phase::AUTONOMOUS, 5000, 0, 2000) == false); // no loop yet (hb==0) +} From ad5f0fbf6b7a2f94dca5f43004a337caa0b6ce9e Mon Sep 17 00:00:00 2001 From: tunapro Date: Tue, 23 Jun 2026 10:00:51 +0300 Subject: [PATCH 24/27] docs: document 0.3.0 cooperative lifecycle + emergency stop; bump to 0.3.0 README/API.md/CHANGELOG: the new lifecycle and loop-return contract ("every iteration must return; bound blocking calls"), halt-safe stall behavior, emergency stop + PROBOT_ESTOP_ENABLE_PIN, the new macros, and migration notes. Version 0.2.9 -> 0.3.0 (synced to library.properties, library.json, idf_component.yml). --- API.md | 62 +++++++++++++++++---- CHANGELOG.md | 132 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 36 ++++++++++++- VERSION | 2 +- idf_component.yml | 2 +- library.json | 2 +- library.properties | 2 +- 7 files changed, 221 insertions(+), 17 deletions(-) diff --git a/API.md b/API.md index ecc60b8..0904215 100644 --- a/API.md +++ b/API.md @@ -1,4 +1,4 @@ -# Probot API Referansı (0.2.9) +# Probot API Referansı (0.3.0) Tek sayfalık tam referans. Kurulum ve örnekler için: [README.md](README.md) @@ -25,13 +25,26 @@ STOP ──Init──▶ INITED ──Start──▶ [AUTONOMOUS (N sn)] ── - Otonom süresi ve aç/kapa arayüzden seçilir; süre bitince teleop'a kendiliğinden geçilir. -- `Stop`: teleop/otonom task'ları sonlandırılır, `robotEnd()` çağrılır - (1 sn içinde dönmezse zorla kesilir). -- Loop'lar ayrı FreeRTOS task'ında, **core 1**'de koşar. WiFi/sunucu - core 0'dadır — kullanıcı kodu ağı yavaşlatmaz. -- Bir loop çağrısı **2 saniyeden** uzun bloke olursa "deadline miss" - sayılır: teleop'ta uyarı verilir, otonomdaysa otonom öldürülüp - teleop'a geçilir. LED kırmızı yanıp söner. +- **Altı hook, tek kalıcı task'ta** çalışır (**core 1**); boot'ta açılır, + normal işleyişte **asla öldürülmez**. WiFi/sunucu core 0'dadır. +- Geçişler **kooperatiftir**: buton "istenen mod"u set eder, task geçişi + o anki tur **bittikten sonra**, güvenli sınırda yapar. Bu yüzden bir + Stop/faz değişimi kullanıcı kodunu iş ortasında (Wire/malloc kilidi + tutarken) **kesemez** — eski sürümlerdeki orphaned-lock donmasının + (issue #21) kök sebebi buydu. +- `Stop`: o anki loop turu dönünce `robotEnd()` çağrılır (en fazla bir + loop periyodu gecikme; loop bloklarsa daha uzun). Anında kesme için + **acil durdurma** ya da donanım E-stop kullanın. +- **Sözleşme:** her loop turu bir gün **dönmeli** (öneri < ~2 sn). + Blocking serbest, *sonsuz* blocking yasak — I2C/sensör çağrılarına + timeout koyun (`Wire.setTimeOut(50)`). +- **Stall (halt-safe):** bir loop turu `PROBOT_LOOP_DEADLINE_MS` (2000) + içinde dönmezse input sıfırlanır, LED kırmızı yanıp söner, robot + güvende tutulur — **task öldürülmez, çip reboot edilmez** (homing + state'i korunur). Tur dönünce temizlenir. +- **Acil durdurma (`cmd=estop`):** kullanıcı task'ı öldürülür, `robotEnd()` + watchdog'lu çalıştırılır, robot **reboot'a kadar kilitlenir** + (init/start reddedilir). Bkz. HTTP tablosu + "Acil durdurma". ## Joystick @@ -140,6 +153,30 @@ README "Ayar makroları". Zorunlu olanlar: `PROBOT_WIFI_AP_PASSWORD` kanal seçimi opt-in'dir: `PROBOT_WIFI_AUTO_CHANNEL 1` (varsayılan kapalı, filoda önerilmez — bkz. README kanal planı). +Yaşam döngüsü / güvenlik makroları (0.3.0): + +| Makro | Varsayılan | Anlamı | +|---|---|---| +| `PROBOT_LOOP_DEADLINE_MS` | 2000 | Bir loop turu bu süreyi aşarsa "stalled" — input sıfır, halt-safe | +| `PROBOT_WDT_TIMEOUT_S` | 8 | Donanım watchdog (yalnız sysloop abone; > loop deadline olmalı) | +| `PROBOT_ESTOP_END_MS` | 500 | Acil durdurmada `robotEnd()`'e tanınan süre; aşılırsa reboot | +| `PROBOT_ESTOP_ENABLE_PIN` | -1 | Kütüphanenin sürdüğü enable GPIO'su (-1 = kapalı). Boot'ta HIGH, estop'ta LOW | +| `USER_LOOP_PERIOD_MS` | 20 | Loop çağrı periyodu (~50 Hz) | + +## Acil durdurma + +İki ayrı durdurma var: + +- **Stop** (`cmd=stop`): kooperatif. O anki tur dönünce `robotEnd()` koşar. + Robot tekrar Init/Start edilebilir. +- **Emergency stop** (`cmd=estop`): terminal. Sırası: latch → enable pini + LOW → kullanıcı task'ını öldür → taze task'ta `robotEnd()`'i + `PROBOT_ESTOP_END_MS` watchdog'lu çalıştır (takılırsa `ESP.restart()`). + Robot **reboot'a kadar kilitli** — `init`/`start` reddedilir (`cmd=reboot` + ya da güç döngüsü temizler). Donmuş bir loop'u bile durdurur (task + öldürülür); ama gerçek güvenlik garantisi için **donanım E-stop**'u güç/ + enable hattına koyun — çip tamamen kilitliyse yalnız o çalışır. + ## HTTP / WebSocket arayüzü Robot `192.168.4.1:80`'de tek sunucu çalıştırır. Kendi DS istemcinizi @@ -150,9 +187,11 @@ yazacaksanız: | `/` | GET | gerekli | Driver Station arayüzü (SPA) | | `/joystick` | WS | gerekli | Çift yönlü binary kanal (çerçeve formatları aşağıda) | | `/updateController` | POST | gerekli | JSON fallback: `{"axes":[...],"buttons":[...]}` — WS koptuğunda | -| `/robotControl?cmd=init\|start\|stop\|cancelAuto&auto=0\|1&autoLen=N` | GET | gerekli | Faz komutları | +| `/robotControl?cmd=init\|start\|stop\|cancelAuto&auto=0\|1&autoLen=N` | GET | gerekli | Faz komutları. Estop kilitliyken `init`/`start` reddedilir (409) | +| `/robotControl?cmd=estop` | GET | gerekli | **Acil durdurma**: kullanıcı task'ı öldürülür, `robotEnd()` watchdog'lu (`PROBOT_ESTOP_END_MS`) çalışır, enable pini kesilir, robot reboot'a kadar kilitlenir | +| `/robotControl?cmd=reboot` | GET | gerekli | Çipi yeniden başlatır (`ESP.restart()`) — estop kilidini temizlemenin yolu | | `/setChannel?ch=N` | GET | gerekli | Kanalı NVS'e kaydet; 1-13 ise CSA ile **canlı** geçiş (zaten o kanaldaysa `live:false`), `0` = kaydı temizle, açılışta firmware varsayılanına dön. Dönüş: `{"ok":b,"ch":N,"live":b}` | -| `/getState` | GET | gerekli | `{"phase":N,"autonomousEnabled":b,"autoPeriodSeconds":N,"autoRemainingMs":N}` (WS yokken fallback) | +| `/getState` | GET | gerekli | `{"phase":N,"autonomousEnabled":b,"autoPeriodSeconds":N,"autoRemainingMs":N,"estop":b}` (WS yokken fallback) | | `/telemetry` | GET | gerekli | Telemetri tamponunun içeriği (text) (WS yokken fallback) | | `/getBattery` | GET | serbest | Pil gerilimi (şu an kullanıcı beslemeli) | | `/health` | GET | serbest | `{"rssi":N,"up":ms,"heap":N,"dm":b,"joyAgeMs":N,"sta":N,"disc":N}` — izleme/hakem için. `joyAgeMs`: son joystick paketinin yaşı (-1 = hiç gelmedi), `sta`: bağlı istemci sayısı, `disc`: son kopuşun IEEE reason kodu | @@ -189,7 +228,8 @@ Robot → istemci (push): ``` 'S' 0x53 durum+sağlık JSON'u — değişiklikte bir sonraki tick'te (250 ms), değişiklik yoksa en geç ~1.25 sn'de bir (heartbeat - görevi de görür). Alanlar /getState + /health birleşimi. + görevi de görür). Alanlar /getState + /health birleşimi + (`estop` alanı dahil: acil durdurma kilidi). 'T' 0x54 telemetri tamponu (text) — içerik değiştiğinde ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 39c498a..6c07fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,72 @@ Biçim [Keep a Changelog](https://keepachangelog.com/), sürümler --- +## [0.3.0] — Kooperatif Yaşam Döngüsü + Acil Durdurma + +Güvenlik/yaşam döngüsü yeniden tasarımı. Issue #21'deki donma sınıfını +kökten kapatır. 6 hook API'si aynen derlenir, ama **davranış değişir** +(aşağıdaki yükseltme notları). + +### Değişti +- **Tek kalıcı kullanıcı task'ı + faz state machine.** Artık faz başına + task açılıp `vTaskDelete` ile öldürülmüyor. Core 1'de boot'ta açılan, + **asla öldürülmeyen** tek bir task altı hook'u sırayla, yalnız döngü + sınırlarında çalıştırır. Buton komutu "istenen mod"u set eder; geçişi + task kendi güvenli sınırında yapar. **Bir Stop ya da faz değişimi artık + kullanıcı kodunu iş ortasında (Wire/malloc kilidi tutarken) kesemez** — + 0.2.x'teki orphaned-lock donmasının kök sebebi buydu (issue #21). + `runtime.hpp` baştan yazıldı; saf mantık `core/lifecycle.hpp`'de + (host'ta unit-test edilir). +- **Stall watchdog artık halt-safe (öldürme/reboot yok).** Bir loop turu + `PROBOT_LOOP_DEADLINE_MS` (2000) içinde dönmezse: input sıfırlanır, + kırmızı LED, robot güvende tutulur — **task öldürülmez, çip reboot + edilmez** (homing/relative mekanizma state'i korunur). Tur dönünce + kendiliğinden temizlenir. Donmadan gerçek çıkış: acil durdurma ya da + donanım E-stop. +- **TWDT yalnız sysloop'u izler** (kullanıcı task'ı kasıtlı olarak abone + değil): yalnızca bir **süpervizör/kütüphane** kilitlenmesi reboot + ettirir, kullanıcı state'i asla. Timeout `PROBOT_WDT_TIMEOUT_S` (8). +- **Status LED kilitsiz.** `pixel.show()` artık `portMAX_DELAY` mutex'i + altında değil; tek task (sysloop) `flush()` ile basar. Kullanıcı + `setColor` yalnız bir atomik söze yazar. Öldürülen task'ın LED mutex'ini + orphan etme (kütüphane içi tek orphan riski) tamamen kalktı. +- httpd `recv_wait_timeout` 5 sn → 2 sn (yarım-açık client worker'ı + tutamaz; `send_wait_timeout` ile simetrik). + +### Eklendi +- **Acil durdurma (terminal).** Arayüzde kırmızı **EMERGENCY STOP** butonu + + `/robotControl?cmd=estop`. Sırası: latch → enable pini kes → + kullanıcı task'ını öldür → **taze bir task'ta `robotEnd()`'i + `PROBOT_ESTOP_END_MS` (500) watchdog'lu çalıştır** (takılırsa orphaned + bir bus yüzünden → `ESP.restart()`). Sonra robot **reboot'a kadar + kilitli**: init/start reddedilir, arayüzde "EMERGENCY STOPPED" + Reboot + butonu (`cmd=reboot`). Normal Stop'tan farkı: Stop kooperatif bekler, + acil durdurma keser ve terminaldir. +- **Opsiyonel `PROBOT_ESTOP_ENABLE_PIN`** (varsayılan -1/kapalı): + kütüphanenin sürdüğü tek enable GPIO'su. Motor sürücülerinin enable + hattına (ya da bir kontaktöre) bağla; boot'ta HIGH, acil durdurmada + LOW — kullanıcı kodundan bağımsız donanım kill yolu. +- **Yeni makrolar:** `PROBOT_LOOP_DEADLINE_MS`, `PROBOT_WDT_TIMEOUT_S`, + `PROBOT_ESTOP_END_MS`, `PROBOT_ESTOP_ENABLE_PIN`, `USER_LOOP_PERIOD_MS`. +- `'S'` push çerçevesine ve `/getState`'e `estop` alanı (arayüz banner'ı). +- Faz machine / supervisor / stall tespiti için host unit testleri + (`tests/test_lifecycle.cpp`). + +### Yükseltme notları +- **Sözleşme:** kullanıcı `teleopLoop`/`autonomousLoop`'unun her turu bir + gün **dönmeli** (öneri: < ~2 sn). Blocking yasak değil; *sonsuz* + blocking yasak. I2C/sensör çağrılarına timeout koyun + (`Wire.setTimeOut(50)`), yoksa takılı bir cihaz turu wedge'ler. +- **Stop gecikmesi:** Stop artık o anki tur dönünce etkili olur (en fazla + bir loop periyodu; loop bloklarsa daha uzun). Anında kesme için acil + durdurma / donanım E-stop kullanın. +- **Auto-wedge → teleop otomatik kurtarması kalktı** (eski "öldür ve + devam et" güvensizdi). Donmuş bir otonom artık halt-safe'e düşer. +- Davranış kıran kaynak değişikliği yok; mevcut sketch'ler aynen derlenir. +- 4 ayrı worker stack'i tek task'ta birleşti — `STACK_USER` 4096 → 8192. + +--- + ## [0.2.9] — Yarışma Hazırlığı Bağlantı sağlamlaştırma (devam), servo desteği ve doküman yenileme. @@ -206,6 +272,72 @@ Versioning: [Semantic Versioning](https://semver.org/). --- +## [0.3.0] — Cooperative Lifecycle + Emergency Stop + +Safety/lifecycle rework that closes the freeze class from issue #21 at the +root. The 6-hook API compiles unchanged, but **behavior changes** (see +upgrade notes). + +### Changed +- **One persistent user task + phase state machine.** No more per-phase + task create/`vTaskDelete`. A single task on core 1, created at boot and + **never killed**, runs all six hooks in sequence, only at loop + boundaries. A button sets the "requested mode"; the task performs the + transition at its own safe boundary. **A Stop or phase change can no + longer interrupt user code mid-transaction (holding a Wire/malloc + lock)** — that was the root of the 0.2.x orphaned-lock freeze (issue + #21). `runtime.hpp` rewritten; the pure logic lives in + `core/lifecycle.hpp` (host unit-tested). +- **Stall watchdog is now halt-safe (no kill, no reboot).** A loop + iteration that doesn't return within `PROBOT_LOOP_DEADLINE_MS` (2000) + → inputs zeroed, red LED, held safe — **the task is not killed and the + chip is not rebooted** (preserving homed/relative mechanism state). + Clears itself when the loop returns. Real recovery from a true wedge: + emergency stop or the hardware E-stop. +- **TWDT watches only the sysloop** (the user task is deliberately not + subscribed): only a **supervisor/library** wedge reboots, never user + state. Timeout `PROBOT_WDT_TIMEOUT_S` (8). +- **Lock-free status LED.** `pixel.show()` no longer runs under a + `portMAX_DELAY` mutex; a single task (sysloop) pushes via `flush()`, + user `setColor` only stores an atomic word. The orphan-on-kill of the + LED mutex (the one in-library orphan) is gone. +- httpd `recv_wait_timeout` 5 s → 2 s (caps a half-open client's worker + hold; symmetric with `send_wait_timeout`). + +### Added +- **Emergency stop (terminal).** A red **EMERGENCY STOP** button in the UI + + `/robotControl?cmd=estop`. Sequence: latch → cut the enable pin → + kill the user task → **run `robotEnd()` in a fresh task under a + `PROBOT_ESTOP_END_MS` (500) watchdog** (if it hangs on a bus the kill + orphaned → `ESP.restart()`). The robot then stays **locked until + reboot**: init/start are refused, the UI shows "EMERGENCY STOPPED" + a + Reboot button (`cmd=reboot`). Unlike Stop (cooperative wait), emergency + stop cuts and is terminal. +- **Optional `PROBOT_ESTOP_ENABLE_PIN`** (default -1/off): one + library-driven enable GPIO. Wire it to your motor drivers' enable lines + (or a contactor); HIGH at boot, LOW on emergency stop — a hardware kill + path independent of how user code drives outputs. +- **New macros:** `PROBOT_LOOP_DEADLINE_MS`, `PROBOT_WDT_TIMEOUT_S`, + `PROBOT_ESTOP_END_MS`, `PROBOT_ESTOP_ENABLE_PIN`, `USER_LOOP_PERIOD_MS`. +- `estop` field on the `'S'` push frame and `/getState` (UI banner). +- Host unit tests for the phase machine / supervisor / stall detection + (`tests/test_lifecycle.cpp`). + +### Upgrade notes +- **Contract:** every iteration of `teleopLoop`/`autonomousLoop` must + eventually **return** (aim for < ~2 s). Blocking is allowed; *unbounded* + blocking is not. Put a timeout on I2C/sensor calls + (`Wire.setTimeOut(50)`) or a stuck device wedges the iteration. +- **Stop latency:** Stop now takes effect when the current iteration + returns (at most one loop period; longer if the loop blocks). For an + instant cut use emergency stop / the hardware E-stop. +- **Auto-wedge → teleop auto-recovery removed** (the old "kill and + continue" was unsafe). A wedged autonomous now falls to halt-safe. +- No breaking source changes; existing sketches compile unchanged. +- The four worker stacks collapsed into one — `STACK_USER` 4096 → 8192. + +--- + ## [0.2.9] — Competition Readiness Continued link hardening, servo support, documentation overhaul. diff --git a/README.md b/README.md index 98e433f..6a4b9b9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ESP32 tabanlı robot yarışması iletişim kütüphanesi. Robot bir WiFi erişim noktası açar, tarayıcıdan çalışan Driver Station arayüzü sunar ve joystick verisini WebSocket ile düşük gecikmeyle robota taşır. -**Sürüm 0.2.9** · ESP32 / ESP32-S3 · [API Referansı](API.md) · +**Sürüm 0.3.0** · ESP32 / ESP32-S3 · [API Referansı](API.md) · [English summary below](#probot-en) --- @@ -81,6 +81,10 @@ Hepsi `#include ` satırından **önce** tanımlanır: | `PROBOT_WIFI_PMF_REQUIRED` | `0` | `1`: PMF (802.11w) zorunlu — deauth sahteciliğine karşı koruma, eski tabletlerle uyumsuz olabilir | | `PROBOT_CAPTIVE_PORTAL` | `1` | Ağa katılan cihazda karşılama sayfası kendiliğinden açılır; `0` kapatır | | `NEOPIXEL_PIN` / `NEOPIXEL_COUNT` | `3` / `1` | Durum LED'i pini/adedi | +| `PROBOT_LOOP_DEADLINE_MS` | `2000` | Loop turu bu süreyi aşarsa "stalled": input sıfır, halt-safe (öldürme/reboot yok) | +| `PROBOT_WDT_TIMEOUT_S` | `8` | Donanım watchdog (yalnız sysloop; bir *kütüphane* kilidi reboot ettirir, kullanıcı kodu değil) | +| `PROBOT_ESTOP_ENABLE_PIN` | `-1` | Kütüphanenin sürdüğü enable GPIO'su (motor sürücü enable / kontaktör). Boot'ta HIGH, acil durdurmada LOW | +| `PROBOT_ESTOP_END_MS` | `500` | Acil durdurmada `robotEnd()`'e tanınan süre; aşılırsa çip reboot eder | ## Yarışma günü: kanal planı @@ -149,13 +153,41 @@ olmalı (1 kHz'te servo darbe genişliği fiziksel olarak üretilemez). İkinci cihaz arayüzü açarsa `403` alır. `/health` ve `/info` ise sahiplik gerektirmez — hakem/izleme cihazları serbestçe okuyabilir. +## Yaşam döngüsü ve loop sözleşmesi (0.3.0) + +- Altı hook **tek kalıcı task'ta**, yalnız döngü sınırlarında çalışır. + Stop/faz değişimi kullanıcı kodunu iş ortasında **kesmez** — bu yüzden + bir Wire/I2C ya da malloc kilidi asla orphan olmaz (eski sürümlerdeki + donmanın kök sebebi buydu). +- **Kural:** her `teleopLoop`/`autonomousLoop` turu bir gün **dönmeli** + (öneri < ~2 sn). Blocking serbest, *sonsuz* blocking yasak. I2C/sensör + çağrılarına timeout koyun — örn. `Wire.begin()` sonrası + `Wire.setTimeOut(50);` — yoksa takılı bir cihaz turu kilitler. +- **Stop kooperatiftir:** o anki tur dönünce `robotEnd()` koşar (en fazla + bir loop periyodu gecikme). Anında kesme için acil durdurma kullanın. +- **Stall (halt-safe):** bir tur `PROBOT_LOOP_DEADLINE_MS` (2 sn) içinde + dönmezse input sıfırlanır, LED kırmızı yanar, robot güvende tutulur — + **task öldürülmez, çip reboot edilmez** (homing/relative state korunur). + +## Acil durdurma + +- Arayüzdeki kırmızı **EMERGENCY STOP** butonu (ya da + `/robotControl?cmd=estop`) kullanıcı task'ını öldürür, `robotEnd()`'i + watchdog'lu çalıştırır ve robotu **reboot'a kadar kilitler** (Init/Start + reddedilir; "Reboot" butonu ya da güç döngüsü temizler). Donmuş bir + loop'u bile durdurur. +- Gerçek güvenlik garantisi için **donanım E-stop**'unu güç/enable hattına + koyun: çip tamamen kilitlense bile çalışan tek katman odur. Kütüphanenin + `PROBOT_ESTOP_ENABLE_PIN`'ini motor sürücülerinin enable hattına + bağlarsanız acil durdurma o hattı da donanımda keser. + ## Yapay zeka ile kod yazma Gemini / ChatGPT / Claude'a robot kodu yazdırırken bu satırları prompt'unuzun başına ekleyin: ```text -ESP32 için "probot" kütüphanesiyle (0.2.9) Arduino kodu yaz. +ESP32 için "probot" kütüphanesiyle (0.3.0) Arduino kodu yaz. Önce API referansını oku: https://raw.githubusercontent.com/probot-studio/probot-core/stable/API.md Kurallar: diff --git a/VERSION b/VERSION index 1866a36..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.9 +0.3.0 diff --git a/idf_component.yml b/idf_component.yml index 76cc11a..d81f115 100644 --- a/idf_component.yml +++ b/idf_component.yml @@ -1,4 +1,4 @@ -version: 0.2.9 +version: 0.3.0 description: ESP32-S3 communication library for robotics url: https://github.com/probot-studio/probot-core dependencies: diff --git a/library.json b/library.json index f2a0bb9..0c28f69 100644 --- a/library.json +++ b/library.json @@ -28,5 +28,5 @@ "type": "git", "url": "https://github.com/probot-studio/probot-core" }, - "version": "0.2.9" + "version": "0.3.0" } diff --git a/library.properties b/library.properties index 78140f5..46f8e95 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,5 @@ name=probot -version=0.2.9 +version=0.3.0 author=Tuna Gül maintainer=Tuna Gül sentence=Probot Communication Library for ESP32-S3 Robotics. From 3cade0078801cfbecedd390c7786f73e5941d985 Mon Sep 17 00:00:00 2001 From: tunapro Date: Tue, 23 Jun 2026 10:07:14 +0300 Subject: [PATCH 25/27] fix(core): make emergencyStop() safe to call from any task The public emergencyStop() ran the full sequence inline, including vTaskDelete(userTask). Called from a user hook (which runs in that very task) it would delete its own caller mid-sequence, so robotEnd never spawned and the status never latched. emergencyStop() now only sets the request flag; the sysloop runs the actual kill/robotEnd/latch sequence (detail::runEmergencyStop), so it never deletes its caller and a team can safely trigger an e-stop from teleopLoop on a detected fault. --- src/probot/core/runtime.hpp | 49 ++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/src/probot/core/runtime.hpp b/src/probot/core/runtime.hpp index df2c018..e396254 100644 --- a/src/probot/core/runtime.hpp +++ b/src/probot/core/runtime.hpp @@ -124,6 +124,29 @@ namespace probot { #endif } + // The actual terminal emergency-stop sequence. Runs ONLY from the sysloop + // (core 0) so it never deletes its own caller. Idempotent. + inline void runEmergencyStop(){ + if (__atomic_exchange_n(&probot::robot::g_estop_latched, 1u, __ATOMIC_SEQ_CST)) return; + setEnablePin(false); // hardware kill path, if wired + // Kill the single user task. Safe for probot's own portMUX locks (they + // can't be killed mid critical-section); only a user-held Wire/malloc + // lock can orphan, acceptable because this path is terminal + reboot. + if (g_user_task){ + vTaskDelete(g_user_task); + g_user_task = nullptr; + } + // Run robotEnd() in a fresh task under the sysloop watchdog. + __atomic_store_n(&g_estop_end_done, 0u, __ATOMIC_SEQ_CST); + uint32_t t = millis(); if (t == 0) t = 1; + __atomic_store_n(&g_estop_end_start, t, __ATOMIC_SEQ_CST); + xTaskCreatePinnedToCore(estopEndTask, "estop", STACK_USER, NULL, + PRIO_USER, &g_estop_task, CORE_CTRL); + probot::robot::state().setStatus(millis(), probot::robot::Status::STOP); + probot::telemetry::println("!! EMERGENCY STOP — robot disabled, reboot required"); + Serial.println("[SYS ] EMERGENCY STOP"); + } + inline void updateLed(bool estop){ static bool on = false; on = !on; @@ -180,7 +203,7 @@ namespace probot { // Run the terminal emergency-stop sequence once, from here (so it is // serialized and off the HTTP task). if (!estop && __atomic_load_n(&probot::robot::g_estop_requested, __ATOMIC_SEQ_CST)){ - probot::emergencyStop(); + runEmergencyStop(); estop = true; } @@ -252,26 +275,12 @@ namespace probot { } } // namespace detail - // Terminal emergency stop. Idempotent. + // Request a terminal emergency stop. Safe to call from ANY task, including + // a user hook: it only sets a flag. The sysloop runs the real sequence + // (kill the user task, run robotEnd under a watchdog, latch until reboot), + // so it never deletes its own caller. inline void emergencyStop(){ - if (__atomic_exchange_n(&probot::robot::g_estop_latched, 1u, __ATOMIC_SEQ_CST)) return; - detail::setEnablePin(false); // hardware kill path, if wired - // Kill the single user task. Safe for probot's own portMUX locks (they - // can't be killed mid critical-section); only a user-held Wire/malloc lock - // can orphan, which is acceptable because this path is terminal + reboot. - if (detail::g_user_task){ - vTaskDelete(detail::g_user_task); - detail::g_user_task = nullptr; - } - // Run robotEnd() in a fresh task under the sysloop watchdog. - __atomic_store_n(&detail::g_estop_end_done, 0u, __ATOMIC_SEQ_CST); - uint32_t t = millis(); if (t == 0) t = 1; - __atomic_store_n(&detail::g_estop_end_start, t, __ATOMIC_SEQ_CST); - xTaskCreatePinnedToCore(detail::estopEndTask, "estop", STACK_USER, NULL, - PRIO_USER, &detail::g_estop_task, CORE_CTRL); - probot::robot::state().setStatus(millis(), robot::Status::STOP); - probot::telemetry::println("!! EMERGENCY STOP — robot disabled, reboot required"); - Serial.println("[SYS ] EMERGENCY STOP"); + __atomic_store_n(&probot::robot::g_estop_requested, 1u, __ATOMIC_SEQ_CST); } inline void runtime_setup(){ From 9dd3165200422d70dda15ecd8fe2b31d38ab3f1a Mon Sep 17 00:00:00 2001 From: tunapro Date: Tue, 23 Jun 2026 19:14:17 +0300 Subject: [PATCH 26/27] feat(led)!: status-only builtin LED + optional RSL signal pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The builtin status LED is now driven solely by the library — the manual color API (builtinled::setColor/set/setBrightness) is removed, since the LED color always means something (match phase / stalled / e-stop). One internal render() entry point, called only from the sysloop. Add PROBOT_RSL_PIN: an FRC-RSL-style signal light on a plain digital pin that the library drives — blink while the robot can move (teleop/auton), solid on otherwise. Also NEOPIXEL_BRIGHTNESS macro (default 32). BREAKING: sketches calling builtinled::setColor/set/setBrightness no longer compile; remove those calls (use PROBOT_RSL_PIN for an indicator). --- API.md | 17 ++++--- CHANGELOG.md | 25 ++++++++--- README.md | 14 +++++- src/probot/core/runtime.hpp | 21 ++++++++- src/probot/devices/leds/builtin.hpp | 69 +++++++---------------------- 5 files changed, 76 insertions(+), 70 deletions(-) diff --git a/API.md b/API.md index 0904215..bd062ec 100644 --- a/API.md +++ b/API.md @@ -124,17 +124,22 @@ kol.detach(); // sinyali kes (servo gevşer) `attach()` ilk `write()`'a kadar darbe üretmez — robot açılışta zıplamaz. -## Durum LED'i (NeoPixel) +## Durum LED'i (NeoPixel) + RSL -Kütüphane durum renklerini kendisi sürer (README'de tablo). Pin -varsayılanı GPIO 3; `#define NEOPIXEL_PIN 48` ile değiştirilir. -El ile renk basmak isterseniz: +Builtin NeoPixel **yalnız maç durumunu** gösterir; renkleri kütüphane sürer +(tablo README'de). **El ile renk atama API'si yoktur** — LED'in rengi her zaman +bir anlam taşır. Pin varsayılanı GPIO 3 (`#define NEOPIXEL_PIN 48` ile değişir); +parlaklık `#define NEOPIXEL_BRIGHTNESS 32`. + +Ek bir sinyal lambası (FRC RSL tarzı) için düz bir digital pin verin: ```cpp -probot::builtinled::setColor(255, 0, 255); -probot::builtinled::setBrightness(64); // 0-255, varsayılan 32 +#define PROBOT_RSL_PIN 10 // probot.h'den önce ``` +Kütüphane bu pini sürer: robot **hareket edebilirken** (teleop/otonom) yanıp +söner, aksi halde (disabled/stop/e-stop) **sabit açık** kalır. + ## Robot durumu (ileri seviye) ```cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c07fbf..f27ec5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,10 +31,11 @@ kökten kapatır. 6 hook API'si aynen derlenir, ama **davranış değişir** - **TWDT yalnız sysloop'u izler** (kullanıcı task'ı kasıtlı olarak abone değil): yalnızca bir **süpervizör/kütüphane** kilitlenmesi reboot ettirir, kullanıcı state'i asla. Timeout `PROBOT_WDT_TIMEOUT_S` (8). -- **Status LED kilitsiz.** `pixel.show()` artık `portMAX_DELAY` mutex'i - altında değil; tek task (sysloop) `flush()` ile basar. Kullanıcı - `setColor` yalnız bir atomik söze yazar. Öldürülen task'ın LED mutex'ini - orphan etme (kütüphane içi tek orphan riski) tamamen kalktı. +- **Status LED artık status-only ve kilitsiz.** El ile renk atama API'si + (`setColor`/`set`/`setBrightness`) **kaldırıldı** — LED'in rengi her zaman + maç durumunu gösterir, kütüphane sürer (tek task: sysloop, `render()`). + `portMAX_DELAY` mutex'i kalktı → öldürülen task'ın LED kilidini orphan + etme riski (kütüphane içi tek orphan) tamamen bitti. - httpd `recv_wait_timeout` 5 sn → 2 sn (yarım-açık client worker'ı tutamaz; `send_wait_timeout` ile simetrik). @@ -67,7 +68,10 @@ kökten kapatır. 6 hook API'si aynen derlenir, ama **davranış değişir** durdurma / donanım E-stop kullanın. - **Auto-wedge → teleop otomatik kurtarması kalktı** (eski "öldür ve devam et" güvensizdi). Donmuş bir otonom artık halt-safe'e düşer. -- Davranış kıran kaynak değişikliği yok; mevcut sketch'ler aynen derlenir. +- **BREAKING:** `probot::builtinled::setColor/set/setBrightness` kaldırıldı — + status LED artık yalnız kütüphane tarafından sürülüyor. Bu çağrıları yapan + sketch'ler derlenmez; satırları silin (gösterge için `PROBOT_RSL_PIN` kullanın). +- Bunun dışında davranış kıran kaynak değişikliği yok; sketch'ler aynen derlenir. - 4 ayrı worker stack'i tek task'ta birleşti — `STACK_USER` 4096 → 8192. --- @@ -317,8 +321,12 @@ upgrade notes). library-driven enable GPIO. Wire it to your motor drivers' enable lines (or a contactor); HIGH at boot, LOW on emergency stop — a hardware kill path independent of how user code drives outputs. +- **Optional `PROBOT_RSL_PIN`** (default -1/off): an FRC-RSL-style signal + light on a plain digital pin. The library blinks it while the robot can + move (teleop/autonomous) and holds it solid on otherwise. - **New macros:** `PROBOT_LOOP_DEADLINE_MS`, `PROBOT_WDT_TIMEOUT_S`, - `PROBOT_ESTOP_END_MS`, `PROBOT_ESTOP_ENABLE_PIN`, `USER_LOOP_PERIOD_MS`. + `PROBOT_ESTOP_END_MS`, `PROBOT_ESTOP_ENABLE_PIN`, `PROBOT_RSL_PIN`, + `NEOPIXEL_BRIGHTNESS`, `USER_LOOP_PERIOD_MS`. - `estop` field on the `'S'` push frame and `/getState` (UI banner). - Host unit tests for the phase machine / supervisor / stall detection (`tests/test_lifecycle.cpp`). @@ -333,7 +341,10 @@ upgrade notes). instant cut use emergency stop / the hardware E-stop. - **Auto-wedge → teleop auto-recovery removed** (the old "kill and continue" was unsafe). A wedged autonomous now falls to halt-safe. -- No breaking source changes; existing sketches compile unchanged. +- **BREAKING:** `probot::builtinled::setColor/set/setBrightness` were removed + — the status LED is now library-driven only. Sketches calling them won't + compile; delete those lines (use `PROBOT_RSL_PIN` for an indicator). +- Otherwise no breaking source changes; existing sketches compile unchanged. - The four worker stacks collapsed into one — `STACK_USER` 4096 → 8192. --- diff --git a/README.md b/README.md index 6a4b9b9..1ef71cb 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ Hepsi `#include ` satırından **önce** tanımlanır: | `PROBOT_WDT_TIMEOUT_S` | `8` | Donanım watchdog (yalnız sysloop; bir *kütüphane* kilidi reboot ettirir, kullanıcı kodu değil) | | `PROBOT_ESTOP_ENABLE_PIN` | `-1` | Kütüphanenin sürdüğü enable GPIO'su (motor sürücü enable / kontaktör). Boot'ta HIGH, acil durdurmada LOW | | `PROBOT_ESTOP_END_MS` | `500` | Acil durdurmada `robotEnd()`'e tanınan süre; aşılırsa çip reboot eder | +| `PROBOT_RSL_PIN` | `-1` | Sinyal lambası (RSL) digital pini: hareket edebilirken blink, yoksa sabit açık | +| `NEOPIXEL_BRIGHTNESS` | `32` | Durum LED'i parlaklığı (0-255) | ## Yarışma günü: kanal planı @@ -132,7 +134,10 @@ Servo titremesinin iki yaygın sebebi var; ikisi de kütüphane dışında: PCA9685 kullanıyorsanız: servo çıkışları için PWM frekansı **50 Hz** olmalı (1 kHz'te servo darbe genişliği fiziksel olarak üretilemez). -## Durum LED'i +## Durum LED'i ve RSL + +Builtin NeoPixel **yalnız maç durumunu** gösterir, rengini kütüphane sürer — +elle renk atama API'si yoktur (LED'in rengi hep bir anlam taşır). | Renk | Anlam | |---|---| @@ -141,7 +146,12 @@ olmalı (1 kHz'te servo darbe genişliği fiziksel olarak üretilemez). | Sarı sabit | Init tamam, Start bekleniyor | | Turuncu yanıp sönüyor | Otonom çalışıyor | | Yeşil yanıp sönüyor | Teleop çalışıyor | -| Kırmızı yanıp sönüyor | Deadline miss — loop 2 sn'den uzun bloke oldu | +| Kırmızı yanıp sönüyor | Stalled — loop 2 sn'den uzun döndü, güvende tutuluyor | +| Kırmızı sabit | Acil durdurma (kilitli, reboot gerekli) | + +**RSL (sinyal lambası):** `#define PROBOT_RSL_PIN ` verirseniz kütüphane +o digital pini sürer — robot **hareket edebilirken** (teleop/otonom) yanıp +söner, aksi halde **sabit açık** kalır. ## Bağlantı davranışı (güvenlik) diff --git a/src/probot/core/runtime.hpp b/src/probot/core/runtime.hpp index e396254..85c85e1 100644 --- a/src/probot/core/runtime.hpp +++ b/src/probot/core/runtime.hpp @@ -45,6 +45,13 @@ #define PROBOT_ESTOP_ENABLE_PIN -1 #endif +// Optional robot signal light (FRC-RSL style) on a plain digital pin. The +// library drives it: BLINK while the robot can move (teleop/autonomous), +// SOLID ON otherwise (disabled/stopped/e-stopped). -1 = off. +#ifndef PROBOT_RSL_PIN +#define PROBOT_RSL_PIN -1 +#endif + #include #include #include @@ -174,8 +181,14 @@ namespace probot { break; } } - builtinled::setColor(r, g, b); - builtinled::flush(); // only the sysloop calls show() + builtinled::render(r, g, b); // single caller (sysloop): paints status + +#if PROBOT_RSL_PIN >= 0 + // Robot signal light: blink while the robot can move, else solid on. + bool moving = !estop && (s.phase == probot::robot::Phase::TELEOP || + s.phase == probot::robot::Phase::AUTONOMOUS); + digitalWrite(PROBOT_RSL_PIN, moving ? (on ? HIGH : LOW) : HIGH); +#endif } inline void sysloopTask(void*){ @@ -292,6 +305,10 @@ namespace probot { pinMode(PROBOT_ESTOP_ENABLE_PIN, OUTPUT); digitalWrite(PROBOT_ESTOP_ENABLE_PIN, HIGH); // enabled #endif +#if PROBOT_RSL_PIN >= 0 + pinMode(PROBOT_RSL_PIN, OUTPUT); + digitalWrite(PROBOT_RSL_PIN, HIGH); // solid on until moving +#endif wdt_init_no_idle(PROBOT_WDT_TIMEOUT_S, true); diff --git a/src/probot/devices/leds/builtin.hpp b/src/probot/devices/leds/builtin.hpp index 5fd3ec6..d37dc9d 100644 --- a/src/probot/devices/leds/builtin.hpp +++ b/src/probot/devices/leds/builtin.hpp @@ -11,76 +11,39 @@ #ifndef NEOPIXEL_COUNT #define NEOPIXEL_COUNT 1 #endif +#ifndef NEOPIXEL_BRIGHTNESS +#define NEOPIXEL_BRIGHTNESS 32 +#endif namespace probot::builtinled { +// The status LED is reserved for match-state signaling and is driven solely +// by the runtime sysloop — the color always MEANS something (phase / stalled / +// emergency stop). There is intentionally NO user-facing color API. +// +// `render()` is the single, library-internal entry point. It must be called +// from exactly one task (the sysloop): it performs the blocking NeoPixel RMT +// transmit, so a single caller means no mutex and no reentrancy. #if defined(ARDUINO) - // Lock-free status LED. - // - // The blocking RMT transmit (pixel.show()) used to run under a - // portMAX_DELAY FreeRTOS mutex held across the transmit. A task killed - // mid-show() orphaned that mutex forever, and the sysloop — which also - // drives the LED — would then wedge on it. To remove that whole class of - // bug: setColor/set/setBrightness only stash the desired state in one - // atomic word; the actual show() happens in flush(), which is called from - // exactly one task (the sysloop). No mutex, single show() caller, no - // reentrancy. User setColor() takes effect at the next flush (<=500 ms); - // the sysloop overwrites it each tick with the status color, as before. namespace detail { - // packed desired state: [31:24]=brightness [23:16]=r [15:8]=g [7:0]=b - inline volatile uint32_t g_word = (32u << 24); - - struct BuiltinLedState { + struct State { Adafruit_NeoPixel pixel; bool initialized = false; - BuiltinLedState() : pixel(NEOPIXEL_COUNT, NEOPIXEL_PIN, NEO_GRB + NEO_KHZ800) {} + State() : pixel(NEOPIXEL_COUNT, NEOPIXEL_PIN, NEO_GRB + NEO_KHZ800) {} }; - - inline BuiltinLedState& state(){ - static BuiltinLedState s{}; - return s; - } - } // namespace detail - - inline void setColor(uint8_t r, uint8_t g, uint8_t b){ - uint32_t w = __atomic_load_n(&detail::g_word, __ATOMIC_RELAXED); - w = (w & 0xFF000000u) | ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)b; - __atomic_store_n(&detail::g_word, w, __ATOMIC_RELAXED); - } - - inline void setBrightness(uint8_t brightness){ - uint32_t w = __atomic_load_n(&detail::g_word, __ATOMIC_RELAXED); - w = (w & 0x00FFFFFFu) | ((uint32_t)brightness << 24); - __atomic_store_n(&detail::g_word, w, __ATOMIC_RELAXED); - } - - inline void set(bool on){ - setColor(0, 0, on ? 255 : 0); + inline State& state(){ static State s{}; return s; } } - // Push the stashed color to the strip. MUST be called from a single task - // (the sysloop) — it performs the blocking RMT transmit. - inline void flush(){ + inline void render(uint8_t r, uint8_t g, uint8_t b){ auto& s = detail::state(); - uint32_t w = __atomic_load_n(&detail::g_word, __ATOMIC_RELAXED); - uint8_t br = (uint8_t)(w >> 24), r = (uint8_t)(w >> 16), - g = (uint8_t)(w >> 8), b = (uint8_t)w; if (!s.initialized){ s.pixel.begin(); s.initialized = true; } - s.pixel.setBrightness(br); + s.pixel.setBrightness(NEOPIXEL_BRIGHTNESS); s.pixel.setPixelColor(0, s.pixel.Color(r, g, b)); s.pixel.show(); } -#elif defined(PROBOT_BUILTINLED_EXTERNAL) - void set(bool on); - void setBrightness(uint8_t brightness); - void setColor(uint8_t r, uint8_t g, uint8_t b); - inline void flush() {} #else - inline void set(bool) {} - inline void setBrightness(uint8_t) {} - inline void setColor(uint8_t, uint8_t, uint8_t) {} - inline void flush() {} + inline void render(uint8_t, uint8_t, uint8_t) {} #endif } // namespace probot::builtinled From 586292c9ad3bf53719241b46243bcfb6a61d9b39 Mon Sep 17 00:00:00 2001 From: tunapro Date: Tue, 23 Jun 2026 19:30:47 +0300 Subject: [PATCH 27/27] =?UTF-8?q?refactor(devices)!:=20remove=20Servo=20cl?= =?UTF-8?q?ass=20=E2=80=94=20document=20the=20raw-LEDC=20pattern=20instead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library is the communication + lifecycle layer; it shouldn't own/maintain output-hardware wrappers (the same reasoning already applied to motors, which never had a class). probot::devices::Servo is removed; servos are now driven with raw LEDC, documented as the reference pattern. The pattern keeps the jitter fix the class provided: attach the servo on a HIGH LEDC channel (ledcAttachChannel(pin, 50, 14, 7)) so it never shares a timer with analogWrite motor PWM (which uses the low channels) — the timer collision is the #1 software cause of servo jitter. ServoTest is rewritten as the worked example; README / API.md / llms.txt updated. BREAKING: sketches using probot::devices::Servo won't compile — switch to the ledcAttachChannel/ledcWrite pattern in examples/ServoTest. --- API.md | 26 +++--- CHANGELOG.md | 10 +++ README.md | 23 +++-- examples/ServoTest/ServoTest.ino | 57 +++++++++---- llms.txt | 7 +- src/probot.h | 1 - src/probot/devices/servo/servo.hpp | 132 ----------------------------- 7 files changed, 84 insertions(+), 172 deletions(-) delete mode 100644 src/probot/devices/servo/servo.hpp diff --git a/API.md b/API.md index bd062ec..fad6761 100644 --- a/API.md +++ b/API.md @@ -106,22 +106,26 @@ probot::printf("hiz=%.2f\n", hiz); probot::clearTelemetry(); ``` -## Servo +## Servo (kütüphane sınıf SAĞLAMAZ — kalıp) -50 Hz LEDC donanım PWM; kanalları üstten ayırır, `analogWrite` motor -PWM'iyle timer çakışması yaşamaz (titreme nedeni #1). Detay ve güç -uyarıları: README "Servo kullanımı". +probot çıkış donanımını sarmalamaz; servoyu **ham LEDC** ile sen sürersin. +Servo 50 Hz / 14-bit ister; `analogWrite` (~1 kHz, motorlar) uymaz. Titreme +(timer çakışması) olmaması için servoya **yüksek bir LEDC kanalı** ver — +motorlar `analogWrite` ile alttan (0,1,2…) kullanır, çakışmaz: ```cpp -probot::devices::Servo kol; -kol.attach(4); // pin; opsiyonel: attach(pin, minUs, maxUs) -kol.write(90.0f); // 0-180 derece -kol.writeMicroseconds(1500); // 500-2500 µs -kol.readMicroseconds(); // son yazılan değer -kol.attached(); // bool -kol.detach(); // sinyali kes (servo gevşer) +#define SERVO_PIN 4 +void robotInit(){ ledcAttachChannel(SERVO_PIN, 50, 14, 7); } // 50 Hz, 14-bit, kanal 7 +void teleopLoop(){ + uint16_t us = 500 + (angle/180.0f)*2000; // 0-180° -> 500-2500 µs + ledcWrite(SERVO_PIN, (uint32_t)us * 16383 / 20000); +} +void robotEnd(){ ledcWrite(SERVO_PIN, 0); } // darbeyi kes (güvenli) ``` +Tam çalışan örnek: `examples/ServoTest`. Güç uyarıları: README "Servo +kullanımı" (servoyu ayrı 5-6 V kaynaktan besle, GND ortak). + `attach()` ilk `write()`'a kadar darbe üretmez — robot açılışta zıplamaz. ## Durum LED'i (NeoPixel) + RSL diff --git a/CHANGELOG.md b/CHANGELOG.md index f27ec5e..33bb8cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,11 @@ kökten kapatır. 6 hook API'si aynen derlenir, ama **davranış değişir** - **BREAKING:** `probot::builtinled::setColor/set/setBrightness` kaldırıldı — status LED artık yalnız kütüphane tarafından sürülüyor. Bu çağrıları yapan sketch'ler derlenmez; satırları silin (gösterge için `PROBOT_RSL_PIN` kullanın). +- **BREAKING:** `probot::devices::Servo` sınıfı **kaldırıldı**. Kütüphane çıkış + donanımını sarmalamıyor; servoyu ham LEDC ile sür — `robotInit`'te + `ledcAttachChannel(pin, 50, 14, 7)` (yüksek kanal → motor `analogWrite`'ıyla + timer çakışması olmaz), loop'ta `ledcWrite(pin, us*16383/20000)`. Tam kalıp: + `examples/ServoTest` ve README "Servo kullanımı". - Bunun dışında davranış kıran kaynak değişikliği yok; sketch'ler aynen derlenir. - 4 ayrı worker stack'i tek task'ta birleşti — `STACK_USER` 4096 → 8192. @@ -344,6 +349,11 @@ upgrade notes). - **BREAKING:** `probot::builtinled::setColor/set/setBrightness` were removed — the status LED is now library-driven only. Sketches calling them won't compile; delete those lines (use `PROBOT_RSL_PIN` for an indicator). +- **BREAKING:** the `probot::devices::Servo` class was **removed**. The library + no longer wraps output hardware; drive servos with raw LEDC — + `ledcAttachChannel(pin, 50, 14, 7)` in `robotInit` (a HIGH channel so it never + shares a timer with `analogWrite` motor PWM) and `ledcWrite(pin, us*16383/20000)` + in the loop. Full pattern: `examples/ServoTest` and README "Servo kullanımı". - Otherwise no breaking source changes; existing sketches compile unchanged. - The four worker stacks collapsed into one — `STACK_USER` 4096 → 8192. diff --git a/README.md b/README.md index 1ef71cb..b58f1ff 100644 --- a/README.md +++ b/README.md @@ -118,15 +118,21 @@ Hepsi `#include ` satırından **önce** tanımlanır: Servo titremesinin iki yaygın sebebi var; ikisi de kütüphane dışında: -1. **Timer çakışması:** `analogWrite` (motorlar, 1 kHz) ile servo - kütüphaneleri (50 Hz) aynı LEDC timer'ına düşerse biri diğerinin - frekansını bozar. Çözüm: `probot::devices::Servo` kullanın — kanalları - üstten ayırır, motor PWM'iyle asla çakışmaz: +1. **Timer çakışması:** `analogWrite` (motorlar, ~1 kHz) ile servo (50 Hz) + aynı LEDC timer'ına düşerse biri diğerinin frekansını bozar. probot bir + servo sınıfı vermez (donanımı sen sürersin); titremeyi önlemek için + servoya **yüksek bir LEDC kanalı** verin — motorların `analogWrite`'ı + alttan (0,1,2…) kullandığı için çakışmaz: ```cpp - probot::devices::Servo kol; - void robotInit() { kol.attach(4); } // GPIO 4 - void teleopLoop() { kol.write(90); ... } // 0-180° + #define SERVO_PIN 4 + void robotInit() { ledcAttachChannel(SERVO_PIN, 50, 14, 7); } // 50 Hz, 14-bit, kanal 7 + void teleopLoop() { + uint16_t us = 500 + (angle/180.0f)*2000; // 0-180° -> 500-2500 µs + ledcWrite(SERVO_PIN, (uint32_t)us * 16383 / 20000); + } + void robotEnd() { ledcWrite(SERVO_PIN, 0); } // darbeyi kes ``` + Tam örnek: `examples/ServoTest`. 2. **Güç:** Servoyu ESP32'nin 5V/3V3 pininden beslemeyin. WiFi anlık akım çekişleri gerilimi düşürür, servo seğirir. Servoya **ayrı 5-6V kaynak (BEC/UBEC)** verin, toprakları ortak bağlayın. @@ -206,7 +212,8 @@ Kurallar: - Joystick: auto js = probot::io::joystick_api::makeDefault(); js.getLeftY() vb. (-1..+1). probot::io::gamepad() üzerinde getLeftX gibi metodlar YOKTUR. -- Servo için probot::devices::Servo kullan, ESP32Servo kullanma. +- Servo için ham LEDC kullan: robotInit'te ledcAttachChannel(pin,50,14,7) + (yüksek kanal → motor analogWrite'ıyla çakışmaz), teleopLoop'ta ledcWrite. - teleopLoop ~50 Hz çağrılır; içinde sonsuz döngü/uzun blocking yapma. ``` diff --git a/examples/ServoTest/ServoTest.ino b/examples/ServoTest/ServoTest.ino index 0447da1..497725d 100644 --- a/examples/ServoTest/ServoTest.ino +++ b/examples/ServoTest/ServoTest.ino @@ -1,10 +1,19 @@ // ServoTest - Sol joystick Y ekseni ile servo kontrolü. // +// probot bir servo SINIFI SAĞLAMAZ — kütüphane iletişim + yaşam döngüsü +// katmanıdır; çıkış donanımını sen sürersin. Aşağıdaki kalıp titreme-güvenli: +// +// 1) Servo 50 Hz / 14-bit bir LEDC sinyali ister (20 ms çerçeve içinde +// 0.5-2.5 ms darbe). analogWrite ~1 kHz verir, servoya UYMAZ. +// 2) Timer çakışması: LEDC'de 4 timer var; aynı timer'daki kanallar aynı +// frekansı paylaşır. analogWrite (motorlar) kanalları ALTTAN (0,1,2…) +// kullanır; servoya YÜKSEK bir kanal (7) verirsek aynı timer'a düşmez +// → jitter (titreme nedeni #1) yapısal olarak olmaz. +// // Bağlantı: -// - Servo sinyal teli -> SERVO_PIN (varsayılan GPIO 4) -// - Servo güç (kırmızı/kahverengi) -> AYRI 5-6V kaynak (BEC). ESP32'nin -// 5V/3V3 pininden servo BESLEMEYİN — WiFi anlık akım çekişleri servoyu -// titretir. Toprakları (GND) ortak bağlayın. +// - Servo sinyal teli -> SERVO_PIN (GPIO 4) +// - Servo gücü -> AYRI 5-6 V kaynak (BEC). ESP32 pininden BESLEMEYİN — +// WiFi anlık akım çekişleri servoyu titretir. GND'leri ortak bağlayın. #define PROBOT_WIFI_AP_SSID "Probot" #define PROBOT_WIFI_AP_PASSWORD "Probot1234" @@ -12,16 +21,34 @@ #include -#define SERVO_PIN 4 +#define SERVO_PIN 4 +#define SERVO_LEDC_CH 7 // yüksek kanal → motor PWM'iyle çakışmaz +#define SERVO_FREQ_HZ 50 +#define SERVO_RES_BITS 14 +static const uint32_t SERVO_PERIOD_US = 1000000UL / SERVO_FREQ_HZ; // 20000 +static const uint32_t SERVO_DUTY_MAX = (1UL << SERVO_RES_BITS) - 1; // 16383 + +void servoWriteUs(uint16_t us) { + if (us < 500) us = 500; + if (us > 2500) us = 2500; + ledcWrite(SERVO_PIN, (uint32_t)((uint64_t)us * SERVO_DUTY_MAX / SERVO_PERIOD_US)); +} -probot::devices::Servo servo; +void servoWriteAngle(float deg) { + if (deg < 0) deg = 0; + if (deg > 180) deg = 180; + servoWriteUs((uint16_t)(500 + (deg / 180.0f) * 2000)); // 0-180° -> 500-2500 µs +} void robotInit() { - servo.attach(SERVO_PIN); // 500-2500us, 50Hz + // 50 Hz / 14-bit on a fixed high channel. No pulse until the first write + // -> the servo doesn't jump on boot. A fixed channel means re-running + // robotInit on each Init press just re-uses it (no channel exhaustion). + ledcAttachChannel(SERVO_PIN, SERVO_FREQ_HZ, SERVO_RES_BITS, SERVO_LEDC_CH); } void robotEnd() { - servo.detach(); + ledcWrite(SERVO_PIN, 0); // stop the pulse train — safe state } void teleopInit() {} @@ -29,19 +56,13 @@ void teleopInit() {} void teleopLoop() { auto js = probot::io::joystick_api::makeDefault(); - // Sol Y ekseni (-1..+1) -> 0-180 derece - float angle = (js.getLeftY() + 1.0f) * 90.0f; - - // A basılıyken ortala - if (js.getA()) angle = 90.0f; - - servo.write(angle); + float angle = (js.getLeftY() + 1.0f) * 90.0f; // -1..+1 -> 0-180 + if (js.getA()) angle = 90.0f; // A centers + servoWriteAngle(angle); probot::clearTelemetry(); probot::printf("Servo: %.0f derece\n", angle); - - delay(20); } void autonomousInit() {} -void autonomousLoop() { delay(100); } +void autonomousLoop() {} diff --git a/llms.txt b/llms.txt index c2b77ac..0f257d0 100644 --- a/llms.txt +++ b/llms.txt @@ -21,8 +21,11 @@ Rules that generated code MUST follow: then `js.getLeftY()`, `js.getA()`, `js.getPOV()` etc. Axis range is -1..+1, Y is positive up. `probot::io::gamepad()` is the low-level service and has NO getLeftX/getA-style methods. -- Servos: use `probot::devices::Servo` (attach/write/writeMicroseconds), - not ESP32Servo and not analogWrite — avoids LEDC timer conflicts. +- Servos: the library provides NO servo class — drive raw LEDC. In + `robotInit` call `ledcAttachChannel(pin, 50, 14, 7)` (a HIGH channel, so it + never shares a timer with `analogWrite` motor PWM → no jitter); in the loop + `ledcWrite(pin, us * 16383 / 20000)` for a 500-2500 us pulse. Not ESP32Servo, + not analogWrite (1 kHz can't make a 50 Hz servo frame). - `teleopLoop`/`autonomousLoop` are called repeatedly (~50 Hz); do not write infinite loops or block longer than 2 s inside them. - Telemetry to the driver station panel: `probot::printf("v=%.2f\n", v);` diff --git a/src/probot.h b/src/probot.h index a051e29..2a5580b 100644 --- a/src/probot.h +++ b/src/probot.h @@ -10,7 +10,6 @@ #include #include #include -#include #include #include #include diff --git a/src/probot/devices/servo/servo.hpp b/src/probot/devices/servo/servo.hpp deleted file mode 100644 index 38c6a4b..0000000 --- a/src/probot/devices/servo/servo.hpp +++ /dev/null @@ -1,132 +0,0 @@ -#pragma once -#include - -#if defined(ARDUINO) && defined(ESP32) -#include -#include - -#if ESP_ARDUINO_VERSION_MAJOR < 3 -#error "probot::devices::Servo requires arduino-esp32 core 3.x (uses the 3.x LEDC API)." -#endif - -namespace probot::devices { - -/** - * Hobby servo on ESP32 LEDC hardware PWM — jitter-safe by construction. - * - * Why not ESP32Servo / raw analogWrite? - * - Servos need a 50 Hz pulse train. analogWrite() defaults to 1 kHz, - * and LEDC channels share timers in pairs: if a 50 Hz servo and a - * 1 kHz motor land on the same timer, one of them silently - * reconfigures the other — that is the classic "servo titremesi". - * - This class allocates channels from the TOP of the channel range - * downward, while analogWrite() allocates from the bottom up, so - * servos and motor PWM never collide on a timer. - * - 14-bit resolution at 50 Hz → 1.2 µs pulse granularity (~0.1°). - * - * Usage: - * probot::devices::Servo arm; - * void robotInit() { arm.attach(4); } // GPIO 4 - * void teleopLoop() { arm.write(90); ... } // 0-180° - * - * Note: signal jitter can also come from POWER, not timers. Servos must - * be fed from their own 5-6 V supply (BEC/UBEC), never from the ESP32 - * board's regulator. WiFi TX bursts cause voltage dips that twitch - * servos sharing a rail. Common ground is required. - */ -class Servo { -public: - static constexpr uint32_t FREQ_HZ = 50; - static constexpr uint8_t RESOLUTION = 14; // bits - static constexpr uint32_t PERIOD_US = 1000000 / FREQ_HZ; - static constexpr uint32_t DUTY_MAX = (1u << RESOLUTION) - 1; - - // Returns false if the pin is invalid or no LEDC channel is free. - // Each Servo instance claims its channel ONCE and keeps it for life — - // robotInit() re-runs on every DS Init press, so re-attach must reuse - // the same channel instead of burning a new one each time. - bool attach(uint8_t pin, uint16_t minUs = 500, uint16_t maxUs = 2500) { - if (_attached) detach(); - if (minUs >= maxUs) return false; - if (_ch < 0) { - _ch = claimChannel(); - if (_ch < 0) return false; - } - if (!ledcAttachChannel(pin, FREQ_HZ, RESOLUTION, (uint8_t)_ch)) { - return false; - } - _pin = pin; - _min_us = minUs; - _max_us = maxUs; - _attached = true; - // No pulses until the first write() — the servo stays where it is - // instead of jumping on boot. - return true; - } - - void writeMicroseconds(uint16_t us) { - if (!_attached) return; - if (us < _min_us) us = _min_us; - if (us > _max_us) us = _max_us; - _last_us = us; - uint32_t duty = (uint32_t)((uint64_t)us * DUTY_MAX / PERIOD_US); - ledcWrite(_pin, duty); - } - - // angle: 0-180 degrees, mapped onto [minUs, maxUs] - void write(float angle) { - if (angle < 0.0f) angle = 0.0f; - if (angle > 180.0f) angle = 180.0f; - writeMicroseconds((uint16_t)(_min_us + (angle / 180.0f) * (_max_us - _min_us))); - } - - uint16_t readMicroseconds() const { return _last_us; } - bool attached() const { return _attached; } - - // Stops the pulse train (servo goes limp) and frees the pin. The - // instance keeps its LEDC channel for the next attach(). - void detach() { - if (!_attached) return; - ledcDetach(_pin); - _attached = false; - } - -private: - // analogWrite() hands out channels from 0 upward; we hand out from the - // top downward so servo timers (50 Hz) never pair with motor timers. - // Only called from user hooks (single user task) — no locking needed. - static int8_t claimChannel() { -#ifdef LEDC_CHANNELS - static int8_t next = LEDC_CHANNELS - 1; -#else - static int8_t next = 7; -#endif - if (next < 0) return -1; - return next--; - } - - uint8_t _pin = 255; - int8_t _ch = -1; - uint16_t _min_us = 500; - uint16_t _max_us = 2500; - uint16_t _last_us = 1500; - bool _attached = false; -}; - -} // namespace probot::devices - -#else // !ESP32: no-op stub so host builds/tests can include probot.h - -namespace probot::devices { -class Servo { -public: - bool attach(uint8_t, uint16_t = 500, uint16_t = 2500) { return false; } - void writeMicroseconds(uint16_t) {} - void write(float) {} - uint16_t readMicroseconds() const { return 1500; } - bool attached() const { return false; } - void detach() {} -}; -} // namespace probot::devices - -#endif