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/.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/API.md b/API.md new file mode 100644 index 0000000..fad6761 --- /dev/null +++ b/API.md @@ -0,0 +1,262 @@ +# Probot API Referansı (0.3.0) + +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. +- **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 + +```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 (`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 | +| `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 (kütüphane sınıf SAĞLAMAZ — kalıp) + +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 +#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 + +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 +#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 +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). Açılışta otomatik +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 +yazacaksanız: + +| Endpoint | Metod | Sahiplik | Açıklama | +|---|---|---|---| +| `/` | 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ı. 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,"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 | +| `/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 ç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): + +``` +'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 + (`estop` alanı dahil: acil durdurma kilidi). +'T' 0x54 telemetri tamponu (text) — içerik değiştiğinde +``` + +≥5 saniye hiç çerçeve alamayan istemci bağlantıyı ölü sayıp yeniden +bağlanmalı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/probot-studio/probot-core.git` +- ESP-IDF + Arduino component: `probot::runtime_setup()` çağırın +- Host unit testleri (donanımsız, g++ ile): + `make tests/control_tests && ./tests/control_tests` diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..33bb8cf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,548 @@ +# 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.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 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). + +### 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. +- **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. + +--- + +## [0.2.9] — Yarışma Hazırlığı + +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` = kaydı temizle, açılışta firmware varsayılanına dön. +- **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 + (+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 + nedeni) yapısal olarak imkânsız. `attach/write/writeMicroseconds/detach`. +- **Otomatik kanal seçimi artık opt-in:** ayrı `PROBOT_WIFI_AUTO_CHANNEL` + makrosu (varsayılan **0/kapalı**). `1` yapılırsa robot açılışta bandı + tarayıp 1/5/9/13 içinden en boş kanalı seçer (~2-3 sn ek açılış); + seçilen kanal Serial'de ve `/info`'da görünür. **Filoda önerilmez** — + robotlar aynı anda açıldığında hiçbiri henüz yayın yapmadığından bandı + boş görür ve hepsi aynı kanala düşebilir; yarışmada + `PROBOT_WIFI_AP_CHANNEL` ile elle dağıtın. (Önceden `PROBOT_WIFI_AP_CHANNEL 0` + tetikliyordu; artık sabit kanal 1-13 olmak zorunda.) +- **`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:** 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 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:** 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). +- **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 robotları 1/5/9/13'e **elle** dağıtın (her + birine farklı `PROBOT_WIFI_AP_CHANNEL`). Otomatik seçim isteyen tek + robotlar `PROBOT_WIFI_AUTO_CHANNEL 1` ekleyebilir (filoda önerilmez). + +--- + +## [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.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. +- **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`, `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`). + +### 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. +- **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. + +--- + +## [0.2.9] — Competition Readiness + +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` = + clear the pin and use the firmware default at next 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 + 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 + PWM (the #1 software cause of servo jitter) is structurally + impossible. `attach/write/writeMicroseconds/detach`. +- **Auto channel select is now opt-in** behind a separate + `PROBOT_WIFI_AUTO_CHANNEL` macro (default **0/off**). When set to `1` + the robot scans the band at boot and picks the least congested of + 1/5/9/13 (~2-3 s added boot); the chosen channel is reported on Serial + and `/info`. **Not recommended for a fleet** — robots booting together + all see an empty band and can converge on the same channel; assign + channels by hand with `PROBOT_WIFI_AP_CHANNEL` for a competition. + (Previously `PROBOT_WIFI_AP_CHANNEL 0` triggered it; the fixed channel + must now be 1-13.) +- **`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:** 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 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). +- **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 assign robots to 1/5/9/13 **by hand** + (a distinct `PROBOT_WIFI_AP_CHANNEL` each). A lone robot may add + `PROBOT_WIFI_AUTO_CHANNEL 1` for auto-select (not for a fleet). + +--- + +## [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/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..6a20776 100644 --- a/FUTURE_WORK.md +++ b/FUTURE_WORK.md @@ -1,35 +1,45 @@ -# 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. +- **Donanım doğrulaması:** joystick hattını gerçek robotta 10 ms örnekleme + + 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; + 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ü. --- -# 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. +- **Hardware validation:** measure joystick latency/jitter on a real + 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 127833c..b58f1ff 100644 --- a/README.md +++ b/README.md @@ -1,212 +1,262 @@ -# Probot Lib +# Probot -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. +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. -**Tüm dokümantasyon için:** https://docs.probotstudio.com/yazilim/ +**Sürüm 0.3.0** · ESP32 / ESP32-S3 · [API Referansı](API.md) · +[English summary below](#probot-en) --- -## Hızlı Başlangıç - -**Kurulum:** -Arduino IDE'nin Library Manager'ından "Probot Lib" arayıp yükleyin. +## Kurulum + +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/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`). +4. **Partition:** Tools → Partition Scheme → **Huge APP (3MB No OTA)**. + Bu ayar şart — varsayılan bölüm yetersiz, derleme sığmaz. + +## İlk robot (5 dakika) + +```cpp +#define PROBOT_WIFI_AP_SSID "MyRobot" +#define PROBOT_WIFI_AP_PASSWORD "robot1234" // en az 8 karakter +#define PROBOT_WIFI_AP_CHANNEL 1 // 1-13 (yarışmada elle dağıtın) +#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 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); } +``` -**İ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 +> `setup()` ve `loop()` **tanımlamayın** — kütüphane kendisi tanımlar. +> 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 — 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 -Kütüphane seviyelerine göre düzenlenmiş örneklerle geliyor: - -**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üş) - -**İleri seviye:** -- `command_based/MecanumDriveDemo` - Mecanum sürüş ve kinematik kontrol -- `ShooterDemo` - Kapalı çevrim atıcı kontrolü - -Her örnek doğrudan çalışır durumda ve yorumlarla açıklanmıştır. - ---- - -## Platform Desteği - -- **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. - -Tüm platformlarda sürüm numarası `VERSION` dosyasından yönetilir; `make version-sync` çağrısı metadata dosyalarını günceller. - ---- - -## Ne içeriyor? - -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 - -Detaylı API dokümantasyonu ve kullanım örnekleri için https://docs.probotstudio.com/yazilim/ adresini ziyaret edin. - ---- - -## Donanım - -**Önerilen:** [Boardoza Pulse S32-S3](https://boardoza.com/product/boardoza-pulse-s32-s3-breakout-board/) - -Kütüphane ESP32-S3 için geliştirilmiştir. Motor kontrolcusu olarak herhangi bir PWM kontrolcu kullanabilirsiniz (Boardoza VNH5019, BTS7960B, TB6612, vb.) - ---- - -## Katkıda Bulunma - -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. - -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 +| Ö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-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) | AP kanalı, **1-13**. Yarışmada robotlara elle farklı kanal verin | +| `PROBOT_WIFI_AUTO_CHANNEL` | `0` | `1`: robot açılışta bandı tarayıp en boş kanalı **kendi** seçer. **Filoda önerilmez** (bkz. kanal planı), sadece tek robot için | +| `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 | +| `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 | +| `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 | +| `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ı + +- 2.4 GHz'te birbirini **ezmeyen** kanallar: **1, 5, 9, 13**. Aynı anda + çalışan robotları bu kanallara **elle** dağıtın — her robota + `PROBOT_WIFI_AP_CHANNEL` ile sabit ve farklı bir kanal verin. + Deterministik atama, koordineli bir filoda en güvenli yöntemdir. +- 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 (uyumlu istemciler bağlantıyı koparmadan takip eder) ve + kalıcı kaydedilir. Maç **sırasında** değiştirmeyin — bazı tabletler + CSA'yı takip etmeyip birkaç saniye kopabilir. +- Bazı dizüstü/tablet'ler bölge kilidi yüzünden **kanal 12-13'ü + görmez**. Bir cihaz robotu bulamıyorsa o robota 1-11 arası bir kanal + verin. +- **Otomatik kanal seçimi (`PROBOT_WIFI_AUTO_CHANNEL 1`) varsayılan + KAPALIDIR ve filoda önerilmez.** Her robot bandı bağımsız tarar; + robotlar aynı anda açıldığında hiçbiri henüz yayın yapmadığından + bandı boş görür ve **hepsi aynı kanala (kanal 1) düşebilir** — + dağıtmak yerine yığar. Yalnızca ortamda tek robot varken + (ev/atölye) mantıklıdır. Seçilen kanal Serial'de ve 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 (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 + #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. + +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 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 | +|---|---| +| 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 | 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) + +- 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. + +## 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.3.0) Arduino kodu yaz. +Önce API referansını oku: +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. +- Joystick: auto js = probot::io::joystick_api::makeDefault(); + js.getLeftY() vb. (-1..+1). probot::io::gamepad() üzerinde getLeftX gibi + metodlar YOKTUR. +- 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. ``` -Detaylar için `CONTRIBUTING.md` dosyasına bakın. - ---- - -## 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 - ---- - -## 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. - ---- - -# Probot Lib (EN) - -Arduino library built for Ministry of Education robot competitions. Includes PID control, a WiFi driver station, and ESP32-S3 support. - -**Full documentation:** https://docs.probotstudio.com/yazilim/ - ---- - -## Quick Start - -**Installation:** -Open the Arduino IDE Library Manager, search for "Probot Lib", and install it. - -**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 - ---- - -## Examples - -The library ships with examples organized by proficiency level: - -**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) - -**Advanced:** -- `command_based/MecanumDriveDemo` - Mecanum drive and kinematic control -- `ShooterDemo` - Closed-loop shooter control - -Every example runs out of the box and is documented with inline comments. - ---- +Makine-okur özet: [`llms.txt`](llms.txt) · Tam referans: [`API.md`](API.md) -## Platform Support +## Sık sorunlar -- **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. +| 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ı — robotları 1/5/9/13'e **elle** dağıtın (her birine farklı sabit kanal) | +| Servo titriyor | Yukarıdaki "Servo kullanımı" bölümü | -`make version-sync` keeps all manifests aligned with the single `VERSION` file. +## Destek ve lisans ---- - -## What's inside? - -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 - -For in-depth API docs and usage guides, visit https://docs.probotstudio.com/yazilim/. - ---- - -## Hardware - -**Recommended:** [Boardoza Pulse S32-S3](https://boardoza.com/product/boardoza-pulse-s32-s3-breakout-board/) - -The library targets the ESP32-S3. You can use any PWM motor controller board (Boardoza VNH5019, BTS7960B, TB6612, etc.). +- 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 --- -## Contributing - -We welcome contributions. Please use GitHub Issues for bug reports or feature requests. Keep pull requests small and focused. +# Probot (EN) -For development: -```bash -git clone https://github.com/nfrproducts/probot-lib -cd probot-lib -make test -``` - -See `CONTRIBUTING.md` for more details. - ---- - -## 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 - ---- +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). -## Support +**Install:** Arduino IDE Library Manager → "probot", or clone +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. -**Documentation:** https://docs.probotstudio.com/yazilim/ -**WhatsApp:** +90 538 040 81 48 -**Bug reports:** [GitHub Issues](https://github.com/nfrproducts/probot-lib/issues) +**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. -Our goal is to help teams arrive on competition day with ready-to-run robots. +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 b003284..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.7 +0.3.0 diff --git a/examples/ServoTest/ServoTest.ino b/examples/ServoTest/ServoTest.ino new file mode 100644 index 0000000..497725d --- /dev/null +++ b/examples/ServoTest/ServoTest.ino @@ -0,0 +1,68 @@ +// 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 (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" +#define PROBOT_WIFI_AP_CHANNEL 1 + +#include + +#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)); +} + +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() { + // 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() { + ledcWrite(SERVO_PIN, 0); // stop the pulse train — safe state +} + +void teleopInit() {} + +void teleopLoop() { + auto js = probot::io::joystick_api::makeDefault(); + + 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); +} + +void autonomousInit() {} +void autonomousLoop() {} diff --git a/examples/TankDrive/TankDrive.ino b/examples/TankDrive/TankDrive.ino new file mode 100644 index 0000000..cb88716 --- /dev/null +++ b/examples/TankDrive/TankDrive.ino @@ -0,0 +1,80 @@ +// 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); +} + +// 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() { + if (millis() - autoStartTime >= 2000) { + stopMotors(); + } + delay(20); +} diff --git a/idf_component.yml b/idf_component.yml index 13495df..d81f115 100644 --- a/idf_component.yml +++ b/idf_component.yml @@ -1,6 +1,6 @@ -version: 0.2.7 +version: 0.3.0 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 new file mode 100644 index 0000000..84dd493 --- /dev/null +++ b/keywords.txt @@ -0,0 +1,87 @@ +####################################### +# 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 +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 +getDpadLeft KEYWORD2 +getDpadRight KEYWORD2 +getRawAxis KEYWORD2 +getRawButton KEYWORD2 +isConnected KEYWORD2 +attach KEYWORD2 +detach KEYWORD2 +write KEYWORD2 +writeMicroseconds KEYWORD2 +readMicroseconds KEYWORD2 +attached KEYWORD2 +print 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_AUTO_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 +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 8113a36..0c28f69 100644 --- a/library.json +++ b/library.json @@ -20,13 +20,13 @@ "websocket", "telemetry" ], - "name": "Probot Lib", + "name": "probot", "platforms": [ "espressif32" ], "repository": { "type": "git", - "url": "https://github.com/nfrproducts/probot-lib" + "url": "https://github.com/probot-studio/probot-core" }, - "version": "0.2.7" + "version": "0.3.0" } diff --git a/library.properties b/library.properties index ba1cfa7..46f8e95 100644 --- a/library.properties +++ b/library.properties @@ -1,11 +1,11 @@ name=probot -version=0.2.7 +version=0.3.0 author=Tuna Gül 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 new file mode 100644 index 0000000..0f257d0 --- /dev/null +++ b/llms.txt @@ -0,0 +1,44 @@ +# 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/probot-studio/probot-core + +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) BEFORE `#include `. For + a fleet, give each robot a distinct fixed channel by hand. Boot-time + auto channel select is opt-in via `PROBOT_WIFI_AUTO_CHANNEL 1` + (default off; single-robot use, not recommended for a fleet). +- 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: 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);` +- Inputs auto-zero 500 ms after the link drops — driving motors directly + from axis values is safe. + +## Docs + +- [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/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 diff --git a/src/driverstation/esp32s3/driver_station_esp32.hpp b/src/driverstation/esp32s3/driver_station_esp32.hpp index c2f05b4..00a55a3 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 @@ -33,8 +35,63 @@ static_assert(sizeof(PROBOT_WIFI_AP_SSID) - 1 <= 32, "PROBOT_WIFI_AP_SSID must b #ifndef PROBOT_WIFI_AP_CHANNEL #error "WiFi AP channel not provided. Define PROBOT_WIFI_AP_CHANNEL (1-13) before including probot.h." #endif + +// Boot-time auto channel select is OPT-IN and OFF by default. A robot scans +// the band and picks its own channel ONLY when PROBOT_WIFI_AUTO_CHANNEL is 1. +// For a fleet (a competition) leave this off and assign each robot a fixed, +// distinct channel by hand — robots booting together all see an empty band +// and would converge on the same channel. See README "Yarışma günü". +#ifndef PROBOT_WIFI_AUTO_CHANNEL +#define PROBOT_WIFI_AUTO_CHANNEL 0 +#endif + +#if PROBOT_WIFI_AUTO_CHANNEL +static_assert(PROBOT_WIFI_AP_CHANNEL >= 0 && PROBOT_WIFI_AP_CHANNEL <= 13, + "PROBOT_WIFI_AP_CHANNEL must be 0-13 (used as the fallback when the auto-select scan finds nothing)."); +#else static_assert(PROBOT_WIFI_AP_CHANNEL >= 1 && PROBOT_WIFI_AP_CHANNEL <= 13, - "PROBOT_WIFI_AP_CHANNEL must be between 1 and 13."); + "PROBOT_WIFI_AP_CHANNEL must be 1-13. To auto-pick the channel at boot, set PROBOT_WIFI_AUTO_CHANNEL 1 (single-robot use only)."); +#endif + +// 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 + +// 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 + +// 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 { @@ -51,21 +108,101 @@ namespace probot::driverstation::esp32 { ssid += suffix; #endif ap_ssid_ = ssid; - WiFi.mode(WIFI_AP); + + // 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 resolution. The default is the fixed macro channel. A manual + // pin saved from the UI (NVS 1-13) overrides everything. Auto-select + // runs ONLY when compiled in (PROBOT_WIFI_AUTO_CHANNEL) and not pinned; + // a saved 0 just means "clear the pin, use the firmware default". + bool autoMode = (PROBOT_WIFI_AUTO_CHANNEL != 0); + channel_ = PROBOT_WIFI_AP_CHANNEL; + ch_source_ = autoMode ? "auto" : "macro"; + { + Preferences prefs; + if (prefs.begin("probot", /*readOnly=*/true)) { + int nvsCh = prefs.getInt("ch", -1); + prefs.end(); + if (nvsCh >= 1 && nvsCh <= 13) { + autoMode = false; // a manual pin wins over auto-select + channel_ = nvsCh; + ch_source_ = "nvs"; + } + // nvsCh == 0 → "use firmware default": leave autoMode/channel_ as is. + } + } + + if (autoMode) { + // 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. OFF by default — see PROBOT_WIFI_AUTO_CHANNEL. + WiFi.mode(WIFI_STA); + esp_wifi_set_country(&country); + channel_ = autoSelectChannel(); + ch_source_ = "auto"; + } + + WiFi.mode(WIFI_AP); +#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); - WiFi.softAP(ssid.c_str(), pw, PROBOT_WIFI_AP_CHANNEL); + 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); 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: "); 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_, ch_source_); Serial.print("[DS ] IP Address: "); Serial.println(WiFi.softAPIP()); Serial.println("[DS ] ========================================"); @@ -75,8 +212,28 @@ 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. + 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/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. + 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"); @@ -87,39 +244,132 @@ 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); - // Attach WebSocket handler +#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). 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 + // 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_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, @@ -134,10 +384,104 @@ 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; + } + + 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 = -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," + "\"estop\":%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", + __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); + 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) { - 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) { @@ -158,52 +502,124 @@ 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 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; } - // 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); - httpd_resp_set_status(req, "403 Forbidden"); - httpd_resp_send(req, "Another client is already connected.", HTTPD_RESP_USE_STRLEN); + 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); + } return false; } - void releaseOwner(uint32_t now_ms) { - Serial.printf("[DS ] Owner released: %s\n", _owner_str); + static bool wsOwnerAuthorizer(void* ctx, httpd_req_t* req) { + auto* ds = static_cast(ctx); + 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; _owner_str[0] = '\0'; - _rs.setClientCount(now_ms, 0); + } + + // 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); + // 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) ── @@ -275,12 +691,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); @@ -307,6 +733,29 @@ 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; + + // 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); @@ -325,26 +774,73 @@ 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 = clear the saved + // pin and fall back to the firmware default (the fixed macro channel, + // or auto-select if PROBOT_WIFI_AUTO_CHANNEL was compiled in); applied + // at next boot since a scan would drop clients. + 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]; + 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)remaining_ms); + (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); @@ -364,49 +860,118 @@ 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; } + // /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; - 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 = -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}", - (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 ssid[40]; + sanitizeSsid(ds->ap_ssid_.c_str(), ssid, sizeof(ssid)); + char page[1024]; + 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.

" + "", + ssid, + 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); - 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 ssid[40]; + sanitizeSsid(ds->ap_ssid_.c_str(), ssid, sizeof(ssid)); 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,\"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(), - PROBOT_WIFI_AP_CHANNEL, - PROBOT_WIFI_AP_PASSWORD, + ssid, + ds->channel_, + ds->ch_source_, WiFi.softAPIP().toString().c_str(), ESP.getChipModel(), - ESP.getCpuFreqMHz(), + (unsigned long)ESP.getCpuFreqMHz(), ESP.getSdkVersion(), (unsigned long)ESP.getHeapSize(), (unsigned long)ESP.getFlashChipSize(), @@ -424,11 +989,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 = 5000; + 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 a42b0ff..ee3221d 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){ @@ -488,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; @@ -661,6 +694,12 @@ const char MAIN_page[] PROGMEM = R"=====( Trying to reconnect... +
+ EMERGENCY STOPPED + Robot disabled — reboot required to clear + +
+
@@ -679,6 +718,7 @@ const char MAIN_page[] PROGMEM = R"=====(
+
Autonomous Countdown @@ -757,10 +797,6 @@ const char MAIN_page[] PROGMEM = R"=====( SSID --
-
- Password - -- -
Channel -- @@ -770,6 +806,23 @@ const char MAIN_page[] PROGMEM = R"=====( --
+
+ +
+ + +
+ 1/5/9/13 önerilir +

Network Status

@@ -790,6 +843,18 @@ const char MAIN_page[] PROGMEM = R"=====( Deadline Miss -- +
+ Joystick Age + -- +
+
+ Clients + -- +
+
+ Last Disconnect + -- +
@@ -965,13 +1030,17 @@ const char MAIN_page[] PROGMEM = R"=====( updateAutoDisplay(); } - /* ===== SYNC STATE ===== */ - function syncState(){ - fetch('/getState').then(function(r){ - if(!r.ok) return; - return r.json(); - }).then(function(data){ + /* ===== STATE RENDER ===== */ + /* 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; + 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; @@ -1033,8 +1102,23 @@ const char MAIN_page[] PROGMEM = R"=====( stopAutoTimer(); setPhaseDisplay('stopped'); } - }).catch(function(e){ - 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; }); } @@ -1050,9 +1134,7 @@ const char MAIN_page[] PROGMEM = R"=====( default: cmd="stop"; break; } - if(cmd==="stop"){wsStopped=true;killWs();} - else if(cmd==="init"){connectWebSocket();} - + 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); @@ -1091,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():[]; @@ -1100,10 +1201,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 +