diff --git a/README.md b/README.md index b33d7a8..ecaafa7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ESP32-C6 Desk Buddy -Animowany "biurkowy przyjaciel" na Seeed XIAO ESP32-C6 z czujnikiem gestów PAJ7620 i wyświetlaczem OLED 128×64. Reaguje na gesty emocjami, wykonuje webhooki HTTP, wyświetla informacje na żądanie i symuluje potrzeby w stylu Tamagotchi. Konfigurowany przez przeglądarkę. +Animowany "biurkowy przyjaciel" na Seeed XIAO ESP32-C6 z czujnikiem gestów PAJ7620 i wyświetlaczem OLED 128×64. Reaguje na gesty emocjami, wykonuje webhooki HTTP, wyświetla informacje na żądanie, symuluje potrzeby w stylu Tamagotchi i pokazuje pogodę z Open-Meteo. Konfigurowany przez przeglądarkę. --- @@ -73,6 +73,21 @@ Buddy ma trzy potrzeby narastające z czasem: Gdy potrzeba przekroczy próg, Buddy automatycznie zmienia nastrój. W lewym dolnym rogu ekranu pojawiają się litery: **G** (głód), **Z** (zabawa), **M** (mycie). +### Pogoda (Open-Meteo) + +Co jakiś czas (konfigurowalny interwał) wyświetlany jest ekran pogodowy — zamiast oczu pojawia się ikona pogodowa, a zamiast ust — temperatura i ciśnienie: + +| Ikona | Warunki | +|-------|---------| +| Słońce | Bezchmurnie (WMO 0) | +| Chmura + słońce | Częściowe zachmurzenie (WMO 1–2) | +| Chmura | Zachmurzenie, mgła (WMO 3–48) | +| Deszcz | Mżawka, deszcz, przelotny deszcz (WMO 51–82) | +| Śnieg | Śnieg i opady śnieżne (WMO 71–86) | +| Burza | Burza z piorunami (WMO 95–99) | + +Dane z [Open-Meteo](https://open-meteo.com/) — **darmowe, bez klucza API**. Dwuetapowe pobieranie: geocoding (nazwa miasta → współrzędne) + aktualna prognoza. + ### Gesty (PAJ7620) Czujnik rozpoznaje 9 gestów ręką: @@ -132,27 +147,56 @@ Po uruchomieniu wejdź przeglądarką na adres IP wypisany w Serial (lub na OLED http:/// ``` -Dla każdego gestu można ustawić: -- **URL webhoka** — endpoint HTTP POST (pusty = wyłączony) -- **Nastrój** — jaki nastrój ustawić po geście (0 = bez zmiany) -- **Akcja** — co zrobić/wyświetlić na OLED -- **ON** — czy gest jest aktywny +Panel zawiera cztery sekcje: + +#### Gesty +Dla każdego gestu można ustawić URL webhoka, nastrój, akcję i czy gest jest aktywny. + +#### Tamagotchi +Podgląd aktualnych potrzeb (głód, szczęście, higiena) z paskami postępu oraz przyciski: **Nakarm**, **Pobaw się**, **Umyj**. + +#### Pogoda +Aktualny stan pogody (ikona, temperatura, ciśnienie) oraz ustawienia: +- **Miasto** — nazwa w języku polskim lub angielskim (np. `Warszawa`, `Kraków`) +- **Co ile sekund** — interwał wyświetlania ekranu pogodowego (0 = wyłączony) +- **Pokazuj przez (s)** — czas trwania ekranu pogodowego + +#### Test nastroju +12 przycisków do ręcznego ustawienia dowolnego nastroju na **10 sekund** — do testowania wyglądu oczu bez czujnika gestów. ### JSON API ```bash -# Odczyt konfiguracji gestów -GET http:///api/config +# Odczyt / zapis konfiguracji gestów +GET http:///api/config +POST http:///api/config (Content-Type: application/json) -# Zapis konfiguracji gestów -POST http:///api/config -Content-Type: application/json +# Stan Tamagotchi +GET http:///api/tama +# Akcje Tamagotchi +POST http:///api/tama/feed +POST http:///api/tama/play +POST http:///api/tama/clean + +# Aktualna pogoda + konfiguracja +GET http:///api/weather + +# Zapis konfiguracji pogody (form POST) +POST http:///weather/save + +# Test nastroju (0–11) na 10 sekund +POST http:///api/mood/test?m=1 +``` + +Przykład zapisu gestów: + +```json { "wave": { "url": "http://homeassistant.local:8123/api/webhook/my_hook", "mood": 6, - "action": 4, + "action": 0, "enabled": true }, "up": { @@ -215,6 +259,10 @@ Plik `data/config.json` zawiera wyłącznie konfigurację gestów (bez danych Wi } ``` +### Konfiguracja pogody + +Konfiguracja przechowywana w `/weather.json` na LittleFS — zapisywana przez panel webowy, persystuje przez restarty. Nie wymaga reflashowania. + --- ## Instalacja i build @@ -243,7 +291,7 @@ task flash-monitor # uploadfs + upload + monitor |---------|------| | `task build` | Tylko kompilacja | | `task flash` | Wgraj filesystem + firmware | -| `task upload` | Wgraj tylko firmware | +| `task upload` | Wgraj tylko firmware (zachowuje LittleFS) | | `task uploadfs` | Wgraj `data/wifi.json` + `data/config.json` | | `task monitor` | Monitor portu szeregowego | | `task flash-monitor` | Pełne wgranie + monitor | @@ -251,8 +299,11 @@ task flash-monitor # uploadfs + upload + monitor | `task gestures` | Edytuj gesty (`data/config.json`) + wgraj FS | | `task config-download` | Pobierz konfigurację gestów z urządzenia | | `task erase-flash` | Skasuj flash i wgraj od nowa | +| `task test` | Uruchom testy jednostkowe (native, na Mac) | | `task` | Lista wszystkich zadań | +> **Uwaga:** `task uploadfs` nadpisuje LittleFS — konfiguracja pogody zapisana przez panel webowy zostanie utracona. Użyj `task upload` do aktualizacji samego firmware'u. + ### Zmiana WiFi bez rekompilacji ```bash @@ -272,13 +323,31 @@ mathieucarbou/ESPAsyncWebServer@^3.3.12 ## Architektura kodu -Cały projekt mieści się w jednym pliku `src/main.cpp`. +Logika domenowa wydzielona do bibliotek w `lib/` — zero zależności od Arduino, kompilowalne natywnie. + +``` +src/main.cpp — hardware + HTTP server + WiFi + FreeRTOS (~1100 linii) +lib/ + BuddyDomain/ — BuddyTypes.h, BuddyLogic.h/.cpp (nastroje, mruganie, źrenice) + TamaLogic/ — TamaLogic.h/.cpp (potrzeby, ticki, override nastroju) + GestureConfig/ — GestureConfig.h/.cpp (struct, enums, tablice stringów) +test/native/ + test_buddy/ — ~21 testów Unity (initBuddy, mruganie, saccady) + test_tama/ — ~19 testów Unity (ticki, progi, tamaFeed/Play/Clean) +data/ + wifi.json — dane WiFi (nie commitowane) + config.json — konfiguracja gestów +partitions.csv — własna tablica partycji (app 1.75 MB, LittleFS 2.19 MB) +``` + +### Przepływ inicjalizacji ``` setup() loadAllConfig() /wifi.json → WIFI_SSID/PASS (nigdy nie serwowane) /config.json → gConfig[] (gesty) + /weather.json → weatherCfg (miasto, interwał) initBuddy() + initTama() Wire.begin(22, 23) ← I2C0 HP dla obu urządzeń u8g2.begin() ← SSD1306 HW I2C @@ -286,15 +355,16 @@ setup() connectWiFi() → NTP sync setupHttpServer() ← AsyncWebServer port 80 -loop() ← polling +loop() sensor.readGesture() ← co 500 ms handleGesture() - executeAction() ← tama efekt + overlay OLED + executeAction() ← efekt tama + overlay OLED setBuddyMood() ← skonfigurowany lub domyślny fireWebhook() ← FreeRTOS task (async) updateTama() ← co 10 s (potrzeby + mood override) updateBuddyAnim() ← co 50 ms (mruganie, saccady, ZZZ) showBuddyScreen() ← co 50 ms (~20 fps) + drawWeatherFace() ← priorytet gdy weatherShowUntil aktywny ``` ### Kluczowe decyzje techniczne @@ -306,13 +376,23 @@ loop() ← polling | `Wire1` (LP I2C) zablokowany na GPIO6/7 (niedostępne na XIAO) | Jeden `Wire` (HP I2C) dla SSD1306 + PAJ7620 | | `setContrast()` ma zbyt wąski zakres wizualny na SSD1306 | `setPowerSave(1/0)` — komenda 0xAE/0xAF wyłącza panel | | Hasło WiFi dostępne przez HTTP | Oddzielny `/wifi.json` — nigdy nie serwowany; `/config.json` zawiera tylko gesty | +| Firmware nie mieścił się w partycji OTA (1.28 MB) | Własna `partitions.csv`: app0 1.75 MB, LittleFS 2.19 MB | +| Domena logiki — testy natywne bez sprzętu | Biblioteki w `lib/` bez zależności Arduino; `pio test -e native` | + +### Testy + +```bash +task test # pio test -e native — 40 testów na Mac, bez urządzenia +``` + +Testy pokrywają: inicjalizację stanu, cykl mrugania (OPEN→CLOSING→CLOSED→OPENING), saccady źrenic, ticki tamagotchi, progi nastrojów, tamaFeed/Play/Clean z wartościami brzegowymi. --- ## Dodawanie nowych akcji -1. Dodaj wartość do `enum Action` w `main.cpp` -2. Zaktualizuj `NUM_ACTIONS` i `ACTION_LABELS[]` +1. Dodaj wartość do `enum Action` w `GestureConfig.h` +2. Zaktualizuj `NUM_ACTIONS` i `ACTION_LABELS[]` w `GestureConfig.cpp` 3. Napisz funkcję `showXxxScreen()` 4. Dodaj `case ACTION_XXX:` w `showBuddyScreen()` (sekcja overlay) 5. Opcjonalnie: dodaj efekt tama w `executeAction()` (np. zmiana potrzeby) diff --git a/lib/BuddyDomain/BuddyLogic.cpp b/lib/BuddyDomain/BuddyLogic.cpp index 95bbb23..d38fad4 100644 --- a/lib/BuddyDomain/BuddyLogic.cpp +++ b/lib/BuddyDomain/BuddyLogic.cpp @@ -32,7 +32,7 @@ void setBuddyMood(BuddyState &b, Mood m, uint32_t now, uint32_t durationMs) case MOOD_SAD: b.pupilTargetDx = 0; b.pupilTargetDy = 4; break; case MOOD_ANGRY: - b.pupilTargetDx = 2; b.pupilTargetDy = 2; break; + b.pupilTargetDx = -2; b.pupilTargetDy = 2; break; default: break; } } @@ -106,7 +106,7 @@ void updateBuddyAnim(BuddyState &b, uint32_t now, RngFn rng) } } else if (b.mood != MOOD_WINK_L && b.mood != MOOD_WINK_R) { if (fixated && now >= b.nextLook) { - b.pupilTargetDx = (int8_t)rng(-7, 8); + b.pupilTargetDx = (int8_t)rng(-3, 11); b.pupilTargetDy = (int8_t)rng(-5, 6); b.nextLook = now + (uint32_t)rng(1500, 4500); } diff --git a/src/main.cpp b/src/main.cpp index 888ae63..a93f36d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -99,7 +99,7 @@ static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, u8g2.drawFilledEllipse(cx, cy, EYE_RX, ry, U8G2_DRAW_ALL); if (ry >= 5) { u8g2.setDrawColor(0); - int8_t gx = (int8_t)cx + 3 + pdx / 3; + int8_t gx = (int8_t)cx - 3 + pdx / 3; int8_t gy = (int8_t)cy - 3 + pdy / 3; u8g2.drawDisc((uint8_t)gx, (uint8_t)gy, 2); u8g2.setDrawColor(1); @@ -117,20 +117,28 @@ static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, u8g2.drawFilledEllipse(cx, cy, r, ry, U8G2_DRAW_ALL); if (ry >= 6) { u8g2.setDrawColor(0); - u8g2.drawDisc(cx + 5, cy - 5, 3); + u8g2.drawDisc(cx - 5, cy - 5, 3); u8g2.setDrawColor(1); } break; } case MOOD_ANGRY: { - const uint8_t w = EYE_RX + 3; - const uint8_t h = min(effRy, (uint8_t)6); - for (int8_t row = 0; row <= (int8_t)(h * 2); row++) { - int8_t y = (int8_t)cy - (int8_t)h + row; - uint8_t lineW = (row < (int8_t)h) ? w - (uint8_t)((int8_t)h - row) : w; - if (lineW < 2) lineW = 2; - uint8_t x = isLeft ? cx - w : (uint8_t)(cx + w - lineW); - u8g2.drawHLine(x, (uint8_t)y, lineW); + // Oko — dolna połowa elipsy (jak sad) + uint8_t ry = min(effRy, EYE_RX); + u8g2.drawFilledEllipse(cx, cy - 2, EYE_RX, ry, + U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT); + // Źrenica + if (ry >= 4) { + u8g2.setDrawColor(0); + u8g2.drawDisc(cx - 3, cy + 2, 2); + u8g2.setDrawColor(1); + } + // Brew ściągnięta do środka (V kształt nad okiem) + uint8_t browY = cy - ry - 3; + if (isLeft) { + u8g2.drawLine(cx - EYE_RX + 2, browY, cx + EYE_RX - 2, browY + 3); + } else { + u8g2.drawLine(cx - EYE_RX + 2, browY + 3, cx + EYE_RX - 2, browY); } break; } @@ -138,6 +146,19 @@ static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, uint8_t ry = min(effRy, EYE_RX); u8g2.drawFilledEllipse(cx, cy - 2, EYE_RX, ry, U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT); + // Źrenica + if (ry >= 4) { + u8g2.setDrawColor(0); + u8g2.drawDisc(cx - 3, cy + 2, 2); + u8g2.setDrawColor(1); + } + // Brew opadająca na zewnątrz (smutna — odwrotnie niż angry) + uint8_t browY = cy - ry - 3; + if (isLeft) { + u8g2.drawLine(cx - EYE_RX + 2, browY + 3, cx + EYE_RX - 2, browY); + } else { + u8g2.drawLine(cx - EYE_RX + 2, browY, cx + EYE_RX - 2, browY + 3); + } break; } case MOOD_EXCITED: { @@ -145,10 +166,10 @@ static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, u8g2.drawFilledEllipse(cx, cy, EYE_RX, ry, U8G2_DRAW_ALL); if (ry >= 5) { u8g2.setDrawColor(0); - u8g2.drawLine(cx - 5, cy - 5, cx + 5, cy + 5); - u8g2.drawLine(cx + 5, cy - 5, cx - 5, cy + 5); + u8g2.drawLine(cx - 7, cy - 7, cx + 7, cy + 7); + u8g2.drawLine(cx + 7, cy - 7, cx - 7, cy + 7); u8g2.setDrawColor(1); - u8g2.drawDisc(cx, cy, 2); + u8g2.drawDisc(cx, cy, 5); } break; } @@ -167,8 +188,8 @@ static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, u8g2.drawFilledEllipse(cx, cy, EYE_RX, ry, U8G2_DRAW_ALL); if (ry >= 5) { u8g2.setDrawColor(0); - u8g2.drawDisc(cx + 4, cy - 4, 3); - u8g2.drawDisc(cx - 3, cy + 3, 1); + u8g2.drawDisc(cx - 4, cy - 4, 3); + u8g2.drawDisc(cx + 3, cy + 3, 1); u8g2.setDrawColor(1); } break; @@ -178,8 +199,19 @@ static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, u8g2.drawFilledEllipse(cx, cy, EYE_RX, ry, U8G2_DRAW_ALL); if (ry >= 5) { u8g2.setDrawColor(0); - u8g2.drawDisc(cx - 4, cy - 2, 2); - u8g2.drawDisc(cx + 4, cy + 2, 2); + u8g2.drawDisc(cx + 4, cy - 2, 2); + u8g2.drawDisc(cx - 4, cy + 2, 2); + u8g2.setDrawColor(1); + } + break; + } + case MOOD_WINK_L: + case MOOD_WINK_R: { + uint8_t ry = min(effRy, EYE_RX); + u8g2.drawFilledEllipse(cx, cy, EYE_RX, ry, U8G2_DRAW_ALL); + if (ry >= 5) { + u8g2.setDrawColor(0); + u8g2.drawDisc(cx - 3, cy - 3, 2); u8g2.setDrawColor(1); } break; @@ -968,10 +1000,41 @@ static String buildHtml() "});" "" + "
" + + // ── Mood test panel ─────────────────────────────────────────────── + "

Test nastroju

" + "
" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "
" + "
" + "" + "
" "

GET /api/config   POST /api/config (JSON)" "  |  GET /api/tama   POST /api/tama/{feed,play,clean}" - "  |  GET /api/weather   POST /weather/save

" + "  |  GET /api/weather   POST /weather/save" + "  |  POST /api/mood/test?m={0-11}

" ""; return html; } @@ -1104,6 +1167,19 @@ void setupHttpServer() req->send(200, "application/json", buf); }); + // POST /api/mood/test?m=N — ustaw nastoj testowy na 10s + httpServer.on("/api/mood/test", HTTP_POST, [](AsyncWebServerRequest *req) { + uint32_t now = millis(); + uint8_t m = 0; + if (req->hasParam("m")) + m = (uint8_t)constrain(req->getParam("m")->value().toInt(), 0, 11); + setBuddyMood(buddy, (Mood)m, now, 10000); + Serial.printf("[Web] Mood test: %s (%d)\n", MOOD_LABELS[m], m); + char buf[64]; + snprintf(buf, sizeof(buf), "{\"ok\":true,\"msg\":\"Nastoj: %s (10s)\"}", MOOD_LABELS[m]); + req->send(200, "application/json", buf); + }); + // GET /api/weather — current weather data + config httpServer.on("/api/weather", HTTP_GET, [](AsyncWebServerRequest *req) { char buf[180]; @@ -1271,10 +1347,24 @@ void loop() setDim(night && idle); } - static uint32_t lastWifi = 0; - if (now - lastWifi > 30000) { - lastWifi = now; - if (WiFi.status() != WL_CONNECTED) WiFi.reconnect(); + // WiFi watchdog: próba reconnect co 30 s, restart po 5 min bez połączenia + static uint32_t lastWifiCheck = 0; + static uint32_t wifiDownSince = 0; // 0 = połączony lub nie śledzony + if (now - lastWifiCheck > 30000) { + lastWifiCheck = now; + if (WiFi.status() == WL_CONNECTED) { + wifiDownSince = 0; + } else { + if (wifiDownSince == 0) wifiDownSince = now ? now : 1; + uint32_t downMs = now - wifiDownSince; + Serial.printf("[WiFi] Brak polaczenia od %lu s, proba reconnect...\n", downMs / 1000); + WiFi.reconnect(); + if (downMs >= 300000UL) { + Serial.println("[WiFi] Brak WiFi > 5 min — restart"); + delay(200); + ESP.restart(); + } + } } // Weather: show face periodically, re-fetch at most every 60 s