feat: testowanie nastrojów za pomocą rest api

This commit is contained in:
2026-06-05 23:08:31 +02:00
parent 856990f232
commit a7ed8f6cdd
3 changed files with 212 additions and 42 deletions
+98 -18
View File
@@ -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 12) |
| Chmura | Zachmurzenie, mgła (WMO 348) |
| Deszcz | Mżawka, deszcz, przelotny deszcz (WMO 5182) |
| Śnieg | Śnieg i opady śnieżne (WMO 7186) |
| Burza | Burza z piorunami (WMO 9599) |
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://<IP>/
```
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://<IP>/api/config
# Odczyt / zapis konfiguracji gestów
GET http://<IP>/api/config
POST http://<IP>/api/config (Content-Type: application/json)
# Zapis konfiguracji gestów
POST http://<IP>/api/config
Content-Type: application/json
# Stan Tamagotchi
GET http://<IP>/api/tama
# Akcje Tamagotchi
POST http://<IP>/api/tama/feed
POST http://<IP>/api/tama/play
POST http://<IP>/api/tama/clean
# Aktualna pogoda + konfiguracja
GET http://<IP>/api/weather
# Zapis konfiguracji pogody (form POST)
POST http://<IP>/weather/save
# Test nastroju (011) na 10 sekund
POST http://<IP>/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)
+2 -2
View File
@@ -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);
}
+112 -22
View File
@@ -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()
"});"
"</script>"
"<hr style='border-color:#222;margin:16px 0'>"
// ── Mood test panel ───────────────────────────────────────────────
"<h3 style='color:#4f4;margin:0 0 10px'>Test nastroju</h3>"
"<div style='margin-bottom:8px;display:flex;flex-wrap:wrap;gap:6px'>"
"<button type='button' onclick='tm(0)' style='background:#333'>Normal</button>"
"<button type='button' onclick='tm(1)' style='background:#185'>Happy</button>"
"<button type='button' onclick='tm(2)' style='background:#226'>Sleepy</button>"
"<button type='button' onclick='tm(3)' style='background:#550'>Surprised</button>"
"<button type='button' onclick='tm(4)' style='background:#622'>Angry</button>"
"<button type='button' onclick='tm(5)' style='background:#246'>Sad</button>"
"<button type='button' onclick='tm(6)' style='background:#185'>Excited</button>"
"<button type='button' onclick='tm(7)' style='background:#444'>Wink L</button>"
"<button type='button' onclick='tm(8)' style='background:#444'>Wink R</button>"
"<button type='button' onclick='tm(9)' style='background:#532'>Hungry</button>"
"<button type='button' onclick='tm(10)' style='background:#245'>Playful</button>"
"<button type='button' onclick='tm(11)' style='background:#432'>Dirty</button>"
"</div>"
"<div id='mmsg' style='color:#4f4;font-size:12px;height:16px'></div>"
"<script>"
"function tm(m){"
"fetch('/api/mood/test?m='+m,{method:'POST'})"
".then(r=>r.json()).then(d=>{"
"var el=document.getElementById('mmsg');"
"el.textContent=d.msg||'OK';"
"setTimeout(function(){el.textContent='';},3000);"
"});"
"}"
"</script>"
"<hr style='border-color:#222;margin:16px 0'>"
"<p style='color:#555;font-size:11px'>GET /api/config &nbsp; POST /api/config (JSON)"
" &nbsp;|&nbsp; GET /api/tama &nbsp; POST /api/tama/{feed,play,clean}"
" &nbsp;|&nbsp; GET /api/weather &nbsp; POST /weather/save</p>"
" &nbsp;|&nbsp; GET /api/weather &nbsp; POST /weather/save"
" &nbsp;|&nbsp; POST /api/mood/test?m={0-11}</p>"
"</body></html>";
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