From 7f66b93edb9cd21dbb551148042418e2d6ea6471 Mon Sep 17 00:00:00 2001 From: Aleksander Cynarski Date: Fri, 5 Jun 2026 13:35:41 +0200 Subject: [PATCH] feat: moods, emotions, needs --- .gitignore | 7 + README.md | 89 +++-- Taskfile.yml | 98 +++++ data/config.json.example | 11 + data/wifi.json.example | 4 + platformio.ini | 2 + src/main.cpp | 764 ++++++++++++++++++++++++++++++--------- 7 files changed, 765 insertions(+), 210 deletions(-) create mode 100644 Taskfile.yml create mode 100644 data/config.json.example create mode 100644 data/wifi.json.example diff --git a/.gitignore b/.gitignore index 4183d7d..0b2ebe9 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,10 @@ credentials.h # Logs *.log + +# WiFi credentials — never commit +data/wifi.json + +# Gesture config — may contain webhook URLs, keep local +data/config.json + diff --git a/README.md b/README.md index 7a86513..83fbd06 100644 --- a/README.md +++ b/README.md @@ -164,13 +164,19 @@ Content-Type: application/json ### Konfiguracja WiFi -Dane sieci są na razie hardcoded w `src/main.cpp`: +Dane sieci przechowywane są w pliku `data/config.json` na LittleFS (osobny obszar flash). Plik **nie jest commitowany do repozytorium** (`.gitignore`). -```cpp -const char *WIFI_SSID = "twoja_siec"; -const char *WIFI_PASS = "twoje_haslo"; +```json +{ + "wifi": { + "ssid": "twoja_siec", + "password": "twoje_haslo" + } +} ``` +Plik wgrywany jest osobnym poleceniem (`task uploadfs`) — zmiana WiFi nie wymaga rekompilacji firmware. + --- ## Instalacja i build @@ -178,13 +184,36 @@ const char *WIFI_PASS = "twoje_haslo"; ### Wymagania - [PlatformIO](https://platformio.org/) (CLI lub VS Code extension) +- [Task](https://taskfile.dev/) (`brew install go-task`) -### Build i upload +### Pierwsze uruchomienie ```bash -cd esp32-c6 -pio run --target upload -pio device monitor +cp data/config.json.example data/config.json # lub edytuj ręcznie +# uzupełnij ssid i password w data/config.json + +task flash-monitor # uploadfs + upload + monitor +``` + +### Typowe komendy + +| Komenda | Opis | +|---------|------| +| `task build` | Tylko kompilacja | +| `task flash` | Wgraj filesystem + firmware | +| `task upload` | Wgraj tylko firmware | +| `task uploadfs` | Wgraj tylko `data/config.json` | +| `task monitor` | Monitor portu szeregowego | +| `task flash-monitor` | Pełne wgranie + monitor | +| `task config-upload` | Edytuj config WiFi + wgraj FS | +| `task erase-flash` | Skasuj flash i wgraj od nowa | +| `task` | Lista wszystkich zadań | + +### Zmiana WiFi bez rekompilacji + +```bash +# Edytuj data/config.json, potem: +task uploadfs ``` ### Zależności (pobierane automatycznie) @@ -196,46 +225,32 @@ bblanchon/ArduinoJson@^7.2.1 mathieucarbou/ESPAsyncWebServer@^3.3.12 ``` -### platformio.ini - -```ini -[env:esp32-c6] -platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip -board = seeed_xiao_esp32c6 -framework = arduino -lib_deps = - olikraus/U8g2 - acrandal/RevEng PAJ7620 - bblanchon/ArduinoJson@^7.2.1 - mathieucarbou/ESPAsyncWebServer@^3.3.12 -build_flags = -std=gnu++17 -``` - --- ## Architektura kodu -Cały projekt mieści się w jednym pliku `src/main.cpp` (~650 linii). +Cały projekt mieści się w jednym pliku `src/main.cpp` (~700 linii). ``` setup() - Wire.begin(22, 23) ← I2C0 HP dla obu urządzeń - u8g2.begin() ← SSD1306 HW I2C - sensor.begin(&Wire) ← PAJ7620 HW I2C + loadWiFiConfig() ← LittleFS /config.json → WIFI_SSID/PASS + Wire.begin(22, 23) ← I2C0 HP dla obu urządzeń + u8g2.begin() ← SSD1306 HW I2C + sensor.begin(&Wire) ← PAJ7620 HW I2C connectWiFi() - configTzTime(...) ← NTP sync - setupHttpServer() ← AsyncWebServer port 80 + configTzTime(...) ← NTP sync + setupHttpServer() ← AsyncWebServer port 80 -loop() ← ~200 fps bez rysowania - sensor.readGesture() ← polling co 500 ms +loop() ← ~200 fps bez rysowania + sensor.readGesture() ← polling co 500 ms handleGesture() - executeAction() ← overlay na OLED - fireWebhook() ← FreeRTOS task (async) + executeAction() ← overlay na OLED + fireWebhook() ← FreeRTOS task (async) setBuddyMood() - updateBuddyAnim() ← tick co 50 ms (stan) - showBuddyScreen() ← rysowanie co 50 ms (~20 fps) - showDateTimeScreen() ← overlay jeśli aktywny - showWiFiStatusScreen() ← overlay jeśli aktywny + updateBuddyAnim() ← tick co 50 ms (stan) + showBuddyScreen() ← rysowanie co 50 ms (~20 fps) + showDateTimeScreen() ← overlay jeśli aktywny + showWiFiStatusScreen() ← overlay jeśli aktywny ``` ### Kluczowe decyzje techniczne diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..0ad2f7a --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,98 @@ +version: '3' + +vars: + ENV: esp32-c6 + PORT: /dev/cu.usbmodem1101 + BAUD: "115200" + DEVICE_IP: "" # ustaw np.: task config-download DEVICE_IP=192.168.1.42 + +tasks: + + build: + desc: Kompiluj firmware + cmds: + - pio run -e {{.ENV}} + + upload: + desc: Wgraj firmware na urzadzenie + cmds: + - pio run -e {{.ENV}} --target upload + + uploadfs: + desc: Wgraj filesystem (data/wifi.json + data/config.json) na urzadzenie + cmds: + - pio run -e {{.ENV}} --target uploadfs + + flash: + desc: Wgraj filesystem i firmware (pelne wgranie) + cmds: + - task: uploadfs + - task: upload + + monitor: + desc: Otworz monitor portu szeregowego + cmds: + - pio device monitor -p {{.PORT}} -b {{.BAUD}} + + upload-monitor: + desc: Wgraj firmware i otworz monitor + cmds: + - task: upload + - task: monitor + + flash-monitor: + desc: Pelne wgranie (fs + firmware) i otworz monitor + cmds: + - task: flash + - task: monitor + + erase: + desc: Skasuj cala pamiec flash urzadzenia + cmds: + - esptool.py --port {{.PORT}} erase_flash + + erase-flash: + desc: Skasuj flash, wgraj fs i firmware od nowa + cmds: + - task: erase + - task: flash + + clean: + desc: Wyczysc katalog build + cmds: + - pio run -e {{.ENV}} --target clean + + deps: + desc: Zainstaluj/zaktualizuj biblioteki + cmds: + - pio pkg install -e {{.ENV}} + + wifi: + desc: Edytuj konfiguracje WiFi (data/wifi.json) i wgraj filesystem + cmds: + - ${EDITOR:-nano} data/wifi.json + - task: uploadfs + + gestures: + desc: Edytuj konfiguracje gestow (data/config.json) i wgraj filesystem + cmds: + - ${EDITOR:-nano} data/config.json + - task: uploadfs + + config-download: + desc: "Pobierz konfiguracje gestow z urzadzenia -> data/config.json (uzyj: task config-download DEVICE_IP=)" + cmds: + - | + IP="{{.DEVICE_IP}}" + if [ -z "$IP" ]; then + read -rp "IP urzadzenia: " IP + fi + echo "[config-download] Pobieranie z http://$IP/config.json ..." + curl -sf "http://$IP/config.json" -o data/config.json \ + && echo "[config-download] Zapisano do data/config.json (tylko gesty, bez WiFi)" \ + || { echo "[config-download] BLAD: nie mozna polaczyc z $IP"; exit 1; } + + default: + desc: Pokaz liste dostepnych zadan + cmds: + - task --list diff --git a/data/config.json.example b/data/config.json.example new file mode 100644 index 0000000..d2a2e06 --- /dev/null +++ b/data/config.json.example @@ -0,0 +1,11 @@ +{ + "up": { "url": "", "mood": 0, "action": 0, "enabled": true }, + "down": { "url": "", "mood": 0, "action": 0, "enabled": true }, + "left": { "url": "", "mood": 0, "action": 0, "enabled": true }, + "right": { "url": "", "mood": 0, "action": 0, "enabled": true }, + "forward": { "url": "", "mood": 0, "action": 0, "enabled": true }, + "backward": { "url": "", "mood": 0, "action": 0, "enabled": true }, + "clockwise": { "url": "", "mood": 0, "action": 0, "enabled": true }, + "anticlockwise": { "url": "", "mood": 0, "action": 0, "enabled": true }, + "wave": { "url": "", "mood": 0, "action": 0, "enabled": true } +} diff --git a/data/wifi.json.example b/data/wifi.json.example new file mode 100644 index 0000000..4f61840 --- /dev/null +++ b/data/wifi.json.example @@ -0,0 +1,4 @@ +{ + "ssid": "twoja_siec", + "password": "twoje_haslo" +} diff --git a/platformio.ini b/platformio.ini index c637141..0a70b31 100644 --- a/platformio.ini +++ b/platformio.ini @@ -13,3 +13,5 @@ lib_deps = mathieucarbou/ESPAsyncWebServer@^3.3.12 build_flags = -std=gnu++17 + -D FORMAT_LITTLEFS_IF_FAILED +board_build.filesystem = littlefs diff --git a/src/main.cpp b/src/main.cpp index 226058f..f58c5a2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,17 +4,19 @@ #include #include #include -#include #include #include +#include // ── Hardware ────────────────────────────────────────────────────────────────── // Both SSD1306 (0x3C) and PAJ7620 (0x73) share Wire on GPIO22(SDA)/GPIO23(SCL) U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE, 23, 22); RevEng_PAJ7620 sensor; -const char *WIFI_SSID = "SSID"; -const char *WIFI_PASS = "PASSWORD"; +// WiFi credentials — loaded from LittleFS /config.json at boot +static char WIFI_SSID[64] = ""; +static char WIFI_PASS[64] = ""; + // ── Gesture config ───────────────────────────────────────────────────────────── // Index 0-8 maps to GES_UP..GES_WAVE (Gesture enum value - 1) @@ -29,17 +31,24 @@ static const char *GKEY[NUM_GESTURES] = { // Mood labels matching Mood enum values static const char *MOOD_LABELS[] = { "-- bez zmiany --", "happy ^_^", "sleepy zZz", "surprised o_O", - "angry >_<", "sad T_T", "excited *_*"}; + "angry >_<", "sad T_T", "excited *_*", "wink L ;)", "wink R (;", + "hungry :(", "playful :D", "dirty ..."}; // Actions that can be triggered by a gesture enum Action { - ACTION_NONE = 0, + ACTION_NONE = 0, ACTION_DATETIME = 1, - ACTION_WIFI = 2 + ACTION_WIFI = 2, + ACTION_FEED = 3, // nakarm — zmniejsza głód + ACTION_PLAY = 4, // pobaw się — zwiększa szczęście + ACTION_CLEAN = 5, // umyj — zwiększa higienę + ACTION_STATUS = 6 // pokaż status potrzeb }; -static const uint8_t NUM_ACTIONS = 3; -static const char *ACTION_LABELS[] = {"-- brak --", "Data i godzina", "Status WiFi"}; +static const uint8_t NUM_ACTIONS = 7; +static const char *ACTION_LABELS[] = { + "-- brak --", "Data i godzina", "Status WiFi", + "Nakarm", "Pobaw sie", "Umyj", "Status tamagotchi"}; struct GestureConfig { @@ -50,7 +59,6 @@ struct GestureConfig }; GestureConfig gConfig[NUM_GESTURES]; -Preferences prefs; AsyncWebServer httpServer(80); // ── Buddy ───────────────────────────────────────────────────────────────────── @@ -62,7 +70,12 @@ enum Mood MOOD_SURPRISED, MOOD_ANGRY, MOOD_SAD, - MOOD_EXCITED + MOOD_EXCITED, + MOOD_WINK_L, // lewe oko zamknięte + MOOD_WINK_R, // prawe oko zamknięte + MOOD_HUNGRY, // głodny — chce jeść + MOOD_PLAYFUL, // chce się bawić + MOOD_DIRTY // potrzeba mycia }; enum BlinkState { @@ -74,10 +87,12 @@ enum BlinkState static const uint8_t EYE_L_X = 38; static const uint8_t EYE_R_X = 90; -static const uint8_t EYE_Y = 30; -static const uint8_t EYE_RX = 17; -static const uint8_t EYE_RY = 15; +static const uint8_t EYE_Y = 27; +static const uint8_t EYE_RX = 17; +static const uint8_t EYE_RY = 15; static const uint8_t PUPIL_R = 6; +static const uint8_t MOUTH_X = 64; +static const uint8_t MOUTH_Y = 52; struct { @@ -91,14 +106,48 @@ struct int8_t pupilDx, pupilDy; int8_t pupilTargetDx, pupilTargetDy; uint32_t nextLook; - uint8_t zzzPhase; + uint8_t zzzPhase; uint32_t nextZzz; + uint32_t nextMicroTremor; // involuntary fixation tremor } buddy; +// ── Tamagotchi needs ────────────────────────────────────────────────────────── +// hunger 0=full→100=starving (++/2min), happiness 100=happy→0=bored (--/3min), +// hygiene 100=clean→0=dirty (--/4min). Threshold 70 triggers mood override. +struct { + uint8_t hunger; + uint8_t happiness; + uint8_t hygiene; + uint32_t nextHungerTick; + uint32_t nextHappyTick; + uint32_t nextHygieneTick; +} tama; + +void initTama() { + tama.hunger = 10; + tama.happiness = 80; + tama.hygiene = 90; + uint32_t now = millis(); + tama.nextHungerTick = now + 120000UL; + tama.nextHappyTick = now + 180000UL; + tama.nextHygieneTick = now + 240000UL; +} + // ── Action overlay ──────────────────────────────────────────────────────────── uint32_t overlayUntil = 0; // show overlay until this timestamp Action overlayAction = ACTION_NONE; +// ── Night dim ───────────────────────────────────────────────────────────────── +// 00:00–05:00, after 5 s idle → display off (SSD1306 0xAE). Gesture restores. +// setContrast() is unreliable on SSD1306 (visual range too narrow); +// setPowerSave(1) sends the display-off command directly. +static bool g_dimmed = false; +static void setDim(bool dim) { + if (g_dimmed == dim) return; + g_dimmed = dim; + u8g2.setPowerSave(dim ? 1 : 0); +} + void setBuddyMood(Mood m, uint32_t durationMs = 0) { buddy.mood = m; @@ -137,110 +186,247 @@ void initBuddy() buddy.blinkState = BLINK_OPEN; buddy.blinkRy = EYE_RY; buddy.lastEvent = millis(); - buddy.nextBlink = millis() + 3000; - buddy.nextLook = millis() + 2000; - buddy.nextZzz = millis() + 3000; + buddy.nextBlink = millis() + 3000; + buddy.nextLook = millis() + 2000; + buddy.nextZzz = millis() + 3000; + buddy.nextMicroTremor = millis() + 500; } // ── Eye drawing ─────────────────────────────────────────────────────────────── +// Manga style: white sclera + heavy top lid + large dark iris + highlights. +// effRy drives the blink animation (compresses the eye vertically). +// pdx/pdy shift the iris for gaze tracking (NORMAL/HAPPY). +static void drawWinkEye(uint8_t cx, uint8_t cy) { + // Closed crescent — upper arc of a circle centered below the eye + u8g2.setDrawColor(1); + u8g2.drawCircle(cx, cy + 8, 10, U8G2_DRAW_UPPER_LEFT | U8G2_DRAW_UPPER_RIGHT); + u8g2.drawCircle(cx, cy + 8, 11, U8G2_DRAW_UPPER_LEFT | U8G2_DRAW_UPPER_RIGHT); +} + static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, int8_t pdx, int8_t pdy, bool isLeft) { - if (effRy == 0) - return; + // Buddy faces the viewer, so buddy's LEFT eye = screen RIGHT (!isLeft) + if (buddy.mood == MOOD_WINK_L && !isLeft) { drawWinkEye(cx, cy); return; } + if (buddy.mood == MOOD_WINK_R && isLeft) { drawWinkEye(cx, cy); return; } + if (effRy == 0) return; + u8g2.setDrawColor(1); + + switch (buddy.mood) + { + case MOOD_NORMAL: + case MOOD_HAPPY: { + // Filled ellipse + drifting specular glint + uint8_t ry = min(effRy, EYE_RX); + 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 gy = (int8_t)cy - 3 + pdy / 3; + u8g2.drawDisc((uint8_t)gx, (uint8_t)gy, 2); + u8g2.setDrawColor(1); + } + break; + } + + case MOOD_SLEEPY: { + // Thick horizontal dash; height shrinks with effRy for blink + uint8_t h = (effRy >= 3) ? 4 : effRy; + u8g2.drawBox(cx - EYE_RX, cy - h / 2, EYE_RX * 2, h ? h : 1); + break; + } + + case MOOD_SURPRISED: { + // Wider ellipse + larger glint + uint8_t r = EYE_RX + 2; + uint8_t ry = min((uint8_t)(effRy + 2), r); + u8g2.drawFilledEllipse(cx, cy, r, ry, U8G2_DRAW_ALL); + if (ry >= 6) { + u8g2.setDrawColor(0); + u8g2.drawDisc(cx + 5, cy - 5, 3); + u8g2.setDrawColor(1); + } + break; + } + + case MOOD_ANGRY: { + // Angular wedge: full width outer-bottom, diagonal cut inner-top + 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); + } + break; + } + + case MOOD_SAD: { + // Droopy: lower half of ellipse only + uint8_t ry = min(effRy, EYE_RX); + u8g2.drawFilledEllipse(cx, cy - 2, EYE_RX, ry, + U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT); + break; + } + + case MOOD_EXCITED: { + // Shining disc with X-star cutout + uint8_t ry = min(effRy, EYE_RX); + 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.setDrawColor(1); + u8g2.drawDisc(cx, cy, 2); + } + break; + } + + case MOOD_HUNGRY: { + // Worried eyes — smaller, looking down toward imaginary food + uint8_t ry = min(effRy, (uint8_t)(EYE_RX - 2)); + u8g2.drawFilledEllipse(cx, cy + 2, EYE_RX - 2, ry, U8G2_DRAW_ALL); + if (ry >= 4) { + u8g2.setDrawColor(0); + u8g2.drawDisc(cx, cy + 4, 2); + u8g2.setDrawColor(1); + } + break; + } + + case MOOD_PLAYFUL: { + // Wide sparkling eyes — lively double glint + uint8_t ry = min((uint8_t)(effRy + 1), (uint8_t)(EYE_RX + 1)); + 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.setDrawColor(1); + } + break; + } + + case MOOD_DIRTY: { + // Dizzy/disgusted — two offset pupils, unfocused look + 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 - 4, cy - 2, 2); + u8g2.drawDisc(cx + 4, cy + 2, 2); + u8g2.setDrawColor(1); + } + break; + } + + default: { + uint8_t ry = min(effRy, EYE_RY); + u8g2.drawFilledEllipse(cx, cy, EYE_RX, ry, U8G2_DRAW_ALL); + break; + } + } +} + +// ── Mouth drawing ───────────────────────────────────────────────────────────── +// Coordinate notes (Y increases downward): +// drawCircle(..., LOWER) → arc opens downward = smile ✓ +// drawCircle(..., UPPER) → arc opens upward = frown ✓ +static void drawMouth() +{ + u8g2.setDrawColor(1); switch (buddy.mood) { case MOOD_HAPPY: - u8g2.setDrawColor(1); - u8g2.drawFilledEllipse(cx, cy, EYE_RX, effRy, U8G2_DRAW_ALL); - u8g2.setDrawColor(0); - u8g2.drawBox(cx - EYE_RX - 1, cy - effRy - 1, EYE_RX * 2 + 3, effRy + 2); - u8g2.setDrawColor(1); + // Wide "U" smile + round cheek blush dots + u8g2.drawCircle(MOUTH_X, MOUTH_Y - 7, 10, + U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT); + u8g2.drawCircle(MOUTH_X, MOUTH_Y - 7, 11, + U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT); + u8g2.drawDisc(MOUTH_X - 20, MOUTH_Y, 3); + u8g2.drawDisc(MOUTH_X + 20, MOUTH_Y, 3); break; case MOOD_SLEEPY: - u8g2.setDrawColor(1); - u8g2.drawFilledEllipse(cx, cy, EYE_RX, effRy, U8G2_DRAW_ALL); - u8g2.setDrawColor(0); - u8g2.drawBox(cx - EYE_RX - 1, cy - effRy - 1, EYE_RX * 2 + 3, (effRy * 3) / 2 + 1); - u8g2.setDrawColor(1); - if (effRy > 3) - { - u8g2.setDrawColor(0); - u8g2.drawDisc(cx + pdx, cy + effRy - 3, (uint8_t)(PUPIL_R - 3)); - u8g2.setDrawColor(1); - } + // Thick horizontal bar — tired, barely open + u8g2.drawBox(MOUTH_X - 8, MOUTH_Y - 1, 16, 3); break; case MOOD_SURPRISED: - u8g2.setDrawColor(1); - u8g2.drawFilledEllipse(cx, cy, EYE_RX + 2, effRy, U8G2_DRAW_ALL); + // Open "O" — filled ring (outline + hollow) + u8g2.drawFilledEllipse(MOUTH_X, MOUTH_Y, 5, 6, U8G2_DRAW_ALL); u8g2.setDrawColor(0); - u8g2.drawDisc(cx, cy, (uint8_t)(PUPIL_R - 2)); + u8g2.drawFilledEllipse(MOUTH_X, MOUTH_Y, 3, 4, U8G2_DRAW_ALL); u8g2.setDrawColor(1); - u8g2.drawDisc(cx - 3, cy - 2, 2); - { - int8_t s = isLeft ? -2 : 2; - u8g2.drawLine(cx - EYE_RX + 2, cy - effRy - 6, cx + EYE_RX - 2, cy - effRy - 6 + s); - } break; case MOOD_ANGRY: - u8g2.setDrawColor(1); - u8g2.drawFilledEllipse(cx, cy, EYE_RX, effRy, U8G2_DRAW_ALL); - u8g2.setDrawColor(0); - u8g2.drawDisc(cx + pdx, cy + pdy, PUPIL_R); - u8g2.setDrawColor(1); - u8g2.drawDisc(cx + pdx - 2, cy + pdy - 2, 2); - if (isLeft) - u8g2.drawLine(cx - EYE_RX + 2, cy - effRy - 4, cx + EYE_RX - 2, cy - effRy - 9); - else - u8g2.drawLine(cx - EYE_RX + 2, cy - effRy - 9, cx + EYE_RX - 2, cy - effRy - 4); + // Tight frown with lower lip bar + u8g2.drawCircle(MOUTH_X, MOUTH_Y + 7, 7, + U8G2_DRAW_UPPER_LEFT | U8G2_DRAW_UPPER_RIGHT); + u8g2.drawCircle(MOUTH_X, MOUTH_Y + 7, 8, + U8G2_DRAW_UPPER_LEFT | U8G2_DRAW_UPPER_RIGHT); + u8g2.drawBox(MOUTH_X - 7, MOUTH_Y + 6, 14, 2); break; case MOOD_SAD: - u8g2.setDrawColor(1); - u8g2.drawFilledEllipse(cx, cy, EYE_RX, effRy, U8G2_DRAW_ALL); - u8g2.setDrawColor(0); - u8g2.drawDisc(cx + pdx, cy + pdy, PUPIL_R); - u8g2.setDrawColor(1); - u8g2.drawDisc(cx + pdx - 2, cy + pdy - 2, 2); - if (isLeft) - u8g2.drawLine(cx - EYE_RX + 2, cy - effRy - 9, cx + EYE_RX - 2, cy - effRy - 4); - else - u8g2.drawLine(cx - EYE_RX + 2, cy - effRy - 4, cx + EYE_RX - 2, cy - effRy - 9); - u8g2.drawLine(cx + (isLeft ? 4 : -4), cy + effRy, - cx + (isLeft ? 4 : -4), cy + effRy + 7); - u8g2.drawDisc(cx + (isLeft ? 4 : -4), cy + effRy + 8, 2); + // Deep frown — teardrops on cheeks + u8g2.drawCircle(MOUTH_X, MOUTH_Y + 9, 9, + U8G2_DRAW_UPPER_LEFT | U8G2_DRAW_UPPER_RIGHT); + u8g2.drawCircle(MOUTH_X, MOUTH_Y + 9, 10, + U8G2_DRAW_UPPER_LEFT | U8G2_DRAW_UPPER_RIGHT); + // teardrops + u8g2.drawDisc(MOUTH_X - 18, MOUTH_Y + 5, 2); + u8g2.drawLine(MOUTH_X - 18, MOUTH_Y + 7, MOUTH_X - 18, MOUTH_Y + 11); + u8g2.drawDisc(MOUTH_X + 18, MOUTH_Y + 5, 2); + u8g2.drawLine(MOUTH_X + 18, MOUTH_Y + 7, MOUTH_X + 18, MOUTH_Y + 11); break; case MOOD_EXCITED: - u8g2.setDrawColor(1); - u8g2.drawCircle(cx, cy, EYE_RX - 1, U8G2_DRAW_ALL); - u8g2.drawLine(cx - 9, cy - 9, cx + 9, cy + 9); - u8g2.drawLine(cx + 9, cy - 9, cx - 9, cy + 9); - u8g2.drawLine(cx - 11, cy, cx + 11, cy); - u8g2.drawLine(cx, cy - 11, cx, cy + 11); - u8g2.drawDisc(cx, cy, 3); + // Wide open "D" mouth — flat top, arc bottom + u8g2.drawCircle(MOUTH_X, MOUTH_Y - 6, 11, + U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT); + u8g2.drawCircle(MOUTH_X, MOUTH_Y - 6, 12, + U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT); + u8g2.drawBox(MOUTH_X - 12, MOUTH_Y - 7, 24, 2); break; - default: - u8g2.setDrawColor(1); - u8g2.drawFilledEllipse(cx, cy, EYE_RX, effRy, U8G2_DRAW_ALL); - if (effRy > 3) - { - int8_t pr = (effRy >= PUPIL_R) ? (int8_t)PUPIL_R : (int8_t)(effRy - 1); - u8g2.setDrawColor(0); - u8g2.drawDisc(cx + pdx, cy + pdy, (uint8_t)pr); - if (pr >= 3) - { - u8g2.setDrawColor(1); - u8g2.drawDisc(cx + pdx - 2, cy + pdy - 2, 2); - } - } + case MOOD_HUNGRY: + // Open oval mouth — anticipating food + u8g2.drawFilledEllipse(MOUTH_X, MOUTH_Y, 5, 4, U8G2_DRAW_ALL); + u8g2.setDrawColor(0); + u8g2.drawFilledEllipse(MOUTH_X, MOUTH_Y, 3, 2, U8G2_DRAW_ALL); u8g2.setDrawColor(1); break; + + case MOOD_PLAYFUL: + // Extra-wide smile + u8g2.drawCircle(MOUTH_X, MOUTH_Y - 8, 12, + U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT); + u8g2.drawCircle(MOUTH_X, MOUTH_Y - 8, 13, + U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT); + break; + + case MOOD_DIRTY: + // Wavy nauseous line + u8g2.drawDisc(MOUTH_X - 8, MOUTH_Y + 1, 1); + u8g2.drawDisc(MOUTH_X - 4, MOUTH_Y - 1, 1); + u8g2.drawDisc(MOUTH_X, MOUTH_Y + 2, 1); + u8g2.drawDisc(MOUTH_X + 4, MOUTH_Y - 1, 1); + u8g2.drawDisc(MOUTH_X + 8, MOUTH_Y + 1, 1); + break; + + default: // MOOD_NORMAL + winks + // Small gentle smile + u8g2.drawCircle(MOUTH_X, MOUTH_Y - 3, 6, + U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT); + u8g2.drawCircle(MOUTH_X, MOUTH_Y - 3, 7, + U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT); + break; } } @@ -329,6 +515,89 @@ static void showDateTimeScreen() u8g2.sendBuffer(); } +// ── Tamagotchi action screens ────────────────────────────────────────────────── +static void showFeedScreen() { + u8g2.clearBuffer(); + u8g2.setDrawColor(1); + u8g2.setFont(u8g2_font_7x13_tr); + u8g2.drawStr(28, 14, "Mniam!"); + // plate + u8g2.drawEllipse(64, 44, 18, 7, U8G2_DRAW_ALL); + // food on plate + u8g2.drawFilledEllipse(64, 40, 10, 5, U8G2_DRAW_ALL); + // steam lines + u8g2.drawLine(57, 30, 55, 22); + u8g2.drawLine(64, 30, 64, 22); + u8g2.drawLine(71, 30, 73, 22); + u8g2.sendBuffer(); +} + +static void showPlayScreen() { + u8g2.clearBuffer(); + u8g2.setDrawColor(1); + u8g2.setFont(u8g2_font_7x13_tr); + u8g2.drawStr(16, 14, "Grajmy!"); + // ball + u8g2.drawDisc(44, 42, 10); + // star burst lines + for (int8_t a = 0; a < 8; a++) { + float rad = a * 3.14159f / 4.0f; + u8g2.drawLine(80, 38, + (uint8_t)(80 + 13 * cos(rad)), + (uint8_t)(38 + 13 * sin(rad))); + } + u8g2.drawDisc(80, 38, 7); + u8g2.setDrawColor(0); + u8g2.drawStr(74, 42, "!"); + u8g2.setDrawColor(1); + u8g2.sendBuffer(); +} + +static void showCleanScreen() { + u8g2.clearBuffer(); + u8g2.setDrawColor(1); + u8g2.setFont(u8g2_font_7x13_tr); + u8g2.drawStr(24, 14, "Mycie!"); + // water drops + for (uint8_t i = 0; i < 5; i++) { + uint8_t dx = 30 + i * 16; + u8g2.drawDisc(dx, 44, 5); + u8g2.drawLine(dx, 36, dx, 38); + u8g2.drawLine(dx - 2, 37, dx + 2, 37); + } + u8g2.sendBuffer(); +} + +static void showTamaStatusScreen() { + u8g2.clearBuffer(); + u8g2.setDrawColor(1); + u8g2.setFont(u8g2_font_5x7_tr); + + auto drawBar = [](uint8_t y, const char *lbl, uint8_t val) { + u8g2.drawStr(0, y, lbl); + u8g2.drawFrame(36, y - 7, 74, 8); + u8g2.drawBox(36, y - 7, (uint8_t)(val * 74 / 100), 8); + }; + + drawBar(11, "Glod:", tama.hunger); // bar = how hungry (fill=bad) + // invert happiness/hygiene bars: fill = good + u8g2.drawStr(0, 27, "Rad:"); + u8g2.drawFrame(36, 20, 74, 8); + u8g2.drawBox(36, 20, (uint8_t)(tama.happiness * 74 / 100), 8); + + u8g2.drawStr(0, 43, "Czyst:"); + u8g2.drawFrame(36, 36, 74, 8); + u8g2.drawBox(36, 36, (uint8_t)(tama.hygiene * 74 / 100), 8); + + // value labels + char buf[5]; + snprintf(buf, sizeof(buf), "%3d%%", tama.hunger); u8g2.drawStr(112, 11, buf); + snprintf(buf, sizeof(buf), "%3d%%", tama.happiness); u8g2.drawStr(112, 27, buf); + snprintf(buf, sizeof(buf), "%3d%%", tama.hygiene); u8g2.drawStr(112, 43, buf); + + u8g2.sendBuffer(); +} + void showBuddyScreen() { // Action overlay takes priority @@ -336,14 +605,13 @@ void showBuddyScreen() { switch (overlayAction) { - case ACTION_DATETIME: - showDateTimeScreen(); - break; - case ACTION_WIFI: - showWiFiStatusScreen(); - break; - default: - break; + case ACTION_DATETIME: showDateTimeScreen(); break; + case ACTION_WIFI: showWiFiStatusScreen(); break; + case ACTION_FEED: showFeedScreen(); break; + case ACTION_PLAY: showPlayScreen(); break; + case ACTION_CLEAN: showCleanScreen(); break; + case ACTION_STATUS: showTamaStatusScreen(); break; + default: break; } return; } @@ -353,30 +621,70 @@ void showBuddyScreen() u8g2.setDrawColor(1); uint8_t effRy = buddy.blinkRy; - if (buddy.mood == MOOD_HAPPY) - effRy = min(effRy, (uint8_t)13); - if (buddy.mood == MOOD_SLEEPY) - effRy = min(effRy, (uint8_t)11); + // SURPRISED: eyes open wider than normal max if (buddy.mood == MOOD_SURPRISED) - effRy = EYE_RY + 3; + effRy = min((uint8_t)(EYE_RY + 2), (uint8_t)(buddy.blinkRy + 2)); - drawEye(EYE_L_X, EYE_Y, effRy, buddy.pupilDx, buddy.pupilDy, true); + drawEye(EYE_L_X, EYE_Y, effRy, buddy.pupilDx, buddy.pupilDy, true); drawEye(EYE_R_X, EYE_Y, effRy, -buddy.pupilDx, buddy.pupilDy, false); - const char *labels[] = {"", "^_^", "zZz", "o_O", ">_<", "T_T", "*_*"}; - u8g2.setFont(u8g2_font_5x7_tr); - uint8_t lw = u8g2.getStrWidth(labels[buddy.mood]); - u8g2.drawStr((128 - lw) / 2, 63, labels[buddy.mood]); + drawMouth(); if (buddy.mood == MOOD_SLEEPY && buddy.zzzPhase > 0) { const char *zStr[] = {"z", "zz", "zzz"}; uint8_t zi = buddy.zzzPhase - 1; - u8g2.drawStr(EYE_R_X + EYE_RX + 2 + zi * 2, EYE_Y - EYE_RY - 3 - zi * 5, zStr[zi]); + u8g2.setFont(u8g2_font_5x7_tr); + u8g2.drawStr(EYE_R_X + EYE_RX + 3 + zi * 2, + EYE_Y - EYE_RY - 2 - zi * 4, zStr[zi]); } + + // Tama need indicators — small icons bottom-left when threshold exceeded + { + u8g2.setFont(u8g2_font_4x6_tr); + uint8_t ix = 1; + if (tama.hunger >= 70) { u8g2.drawStr(ix, 63, "G"); ix += 6; } + if (tama.happiness <= 30) { u8g2.drawStr(ix, 63, "Z"); ix += 6; } + if (tama.hygiene <= 30) { u8g2.drawStr(ix, 63, "M"); } + } + u8g2.sendBuffer(); } +void updateTama() +{ + uint32_t now = millis(); + + // Tick needs over time + if (now >= tama.nextHungerTick) { + tama.nextHungerTick = now + 120000UL; + if (tama.hunger < 100) tama.hunger++; + } + if (now >= tama.nextHappyTick) { + tama.nextHappyTick = now + 180000UL; + if (tama.happiness > 0) tama.happiness--; + } + if (now >= tama.nextHygieneTick) { + tama.nextHygieneTick = now + 240000UL; + if (tama.hygiene > 0) tama.hygiene--; + } + + // Override buddy mood based on needs (only when no temp mood + not in sleep) + // Direct assignment avoids resetting lastEvent + if (buddy.revertAt == 0 && buddy.mood != MOOD_SLEEPY && + buddy.mood != MOOD_WINK_L && buddy.mood != MOOD_WINK_R) { + if (tama.hunger >= 80) + buddy.mood = MOOD_HUNGRY; + else if (tama.hygiene <= 20) + buddy.mood = MOOD_DIRTY; + else if (tama.happiness <= 20) + buddy.mood = MOOD_PLAYFUL; + else if (buddy.mood == MOOD_HUNGRY || buddy.mood == MOOD_DIRTY || + buddy.mood == MOOD_PLAYFUL) + buddy.mood = MOOD_NORMAL; // needs satisfied → back to normal + } +} + void updateBuddyAnim() { uint32_t now = millis(); @@ -389,7 +697,8 @@ void updateBuddyAnim() if (buddy.mood == MOOD_NORMAL && now - buddy.lastEvent > 300000UL) setBuddyMood(MOOD_SLEEPY, 0); - if (buddy.mood != MOOD_SURPRISED && buddy.mood != MOOD_EXCITED) + if (buddy.mood != MOOD_SURPRISED && buddy.mood != MOOD_EXCITED && + buddy.mood != MOOD_WINK_L && buddy.mood != MOOD_WINK_R) { switch (buddy.blinkState) { @@ -427,20 +736,44 @@ void updateBuddyAnim() buddy.blinkRy = EYE_RY; } - if (buddy.mood == MOOD_NORMAL && now >= buddy.nextLook) + // Saccadic movement — fast when far (3 px/tick), slow when close (1 px/tick) { - buddy.pupilTargetDx = (int8_t)random(-6, 7); - buddy.pupilTargetDy = (int8_t)random(-4, 5); - buddy.nextLook = now + random(1500, 4000); + int8_t ddx = buddy.pupilTargetDx - buddy.pupilDx; + int8_t ddy = buddy.pupilTargetDy - buddy.pupilDy; + if (ddx) buddy.pupilDx += (ddx > 0 ? 1 : -1) * ((abs(ddx) >= 4) ? 3 : 1); + if (ddy) buddy.pupilDy += (ddy > 0 ? 1 : -1) * ((abs(ddy) >= 4) ? 3 : 1); + } + + // Gaze — random saccades + micro-tremor for expressive moods + { + bool fixated = (buddy.pupilDx == buddy.pupilTargetDx && + buddy.pupilDy == buddy.pupilTargetDy); + + if (buddy.mood == MOOD_SLEEPY) { + // Slow drowsy drift — limited range, eyes mostly down + if (fixated && now >= buddy.nextLook) { + buddy.pupilTargetDx = (int8_t)random(-3, 4); + buddy.pupilTargetDy = (int8_t)random(2, 6); + buddy.nextLook = now + random(4000, 9000); + } + } else if (buddy.mood != MOOD_WINK_L && buddy.mood != MOOD_WINK_R) { + // Active gaze — full range saccades + if (fixated && now >= buddy.nextLook) { + buddy.pupilTargetDx = (int8_t)random(-7, 8); + buddy.pupilTargetDy = (int8_t)random(-5, 6); + buddy.nextLook = now + random(1500, 4500); + } + // Micro-tremor while fixated (NORMAL / HAPPY only) + if ((buddy.mood == MOOD_NORMAL || buddy.mood == MOOD_HAPPY) && + fixated && now >= buddy.nextMicroTremor) { + buddy.pupilTargetDx = (int8_t)constrain( + buddy.pupilTargetDx + (int8_t)random(-1, 2), -7, 7); + buddy.pupilTargetDy = (int8_t)constrain( + buddy.pupilTargetDy + (int8_t)random(-1, 2), -5, 5); + buddy.nextMicroTremor = now + random(300, 700); + } + } } - if (buddy.pupilDx < buddy.pupilTargetDx) - buddy.pupilDx++; - else if (buddy.pupilDx > buddy.pupilTargetDx) - buddy.pupilDx--; - if (buddy.pupilDy < buddy.pupilTargetDy) - buddy.pupilDy++; - else if (buddy.pupilDy > buddy.pupilTargetDy) - buddy.pupilDy--; if (buddy.mood == MOOD_SLEEPY && now >= buddy.nextZzz) { @@ -449,51 +782,77 @@ void updateBuddyAnim() } } -// ── Config persistence (NVS via Preferences) ────────────────────────────────── -void loadConfig() +// ── Config persistence ───────────────────────────────────────────────────────── +// WiFi: /wifi.json — credentials, NEVER served over HTTP +// Gestures:/config.json — gesture config, served via GET /config.json +static void loadJsonFile(const char *path, JsonDocument &doc) { - prefs.begin("buddy", false); // false = read-write, creates namespace if missing - for (uint8_t i = 0; i < NUM_GESTURES; i++) - { - char key[16]; - - snprintf(key, sizeof(key), "wh.%s.url", GKEY[i]); - String url = prefs.getString(key, ""); - strncpy(gConfig[i].url, url.c_str(), sizeof(gConfig[i].url) - 1); - - snprintf(key, sizeof(key), "wh.%s.mood", GKEY[i]); - gConfig[i].mood = (uint8_t)prefs.getUInt(key, 0); - - snprintf(key, sizeof(key), "wh.%s.en", GKEY[i]); - gConfig[i].enabled = prefs.getBool(key, true); - - snprintf(key, sizeof(key), "wh.%s.act", GKEY[i]); - gConfig[i].action = (uint8_t)prefs.getUInt(key, 0); - } - prefs.end(); + File f = LittleFS.open(path, "r"); + if (!f) { Serial.printf("[Config] %s not found\n", path); return; } + if (deserializeJson(doc, f) != DeserializationError::Ok) + Serial.printf("[Config] %s parse error\n", path); + f.close(); } +void loadAllConfig() +{ + if (!LittleFS.begin(true)) { + Serial.println("[Config] LittleFS mount failed"); + return; + } + + // WiFi from /wifi.json { "ssid": "...", "password": "..." } + { + JsonDocument wdoc; + loadJsonFile("/wifi.json", wdoc); + strncpy(WIFI_SSID, wdoc["ssid"] | "", sizeof(WIFI_SSID) - 1); + strncpy(WIFI_PASS, wdoc["password"] | "", sizeof(WIFI_PASS) - 1); + Serial.printf("[Config] WiFi SSID: %s\n", WIFI_SSID); + } + + // Gestures from /config.json { "up": {...}, "down": {...}, ... } + { + JsonDocument doc; + loadJsonFile("/config.json", doc); + for (uint8_t i = 0; i < NUM_GESTURES; i++) { + JsonObject g = doc[GNAME[i]]; + if (g.isNull()) continue; + strncpy(gConfig[i].url, g["url"] | "", sizeof(gConfig[i].url) - 1); + gConfig[i].mood = g["mood"] | 0; + gConfig[i].action = g["action"] | 0; + gConfig[i].enabled = g["enabled"] | true; + } + Serial.println("[Config] Gestures loaded"); + } + + LittleFS.end(); +} + +// Saves ONLY gesture config — WiFi credentials are never written here void saveConfig() { - prefs.begin("buddy", false); // read-write - for (uint8_t i = 0; i < NUM_GESTURES; i++) - { - char key[16]; - - snprintf(key, sizeof(key), "wh.%s.url", GKEY[i]); - prefs.putString(key, gConfig[i].url); - - snprintf(key, sizeof(key), "wh.%s.mood", GKEY[i]); - prefs.putUInt(key, gConfig[i].mood); - - snprintf(key, sizeof(key), "wh.%s.en", GKEY[i]); - prefs.putBool(key, gConfig[i].enabled); - - snprintf(key, sizeof(key), "wh.%s.act", GKEY[i]); - prefs.putUInt(key, gConfig[i].action); + JsonDocument doc; + for (uint8_t i = 0; i < NUM_GESTURES; i++) { + doc[GNAME[i]]["url"] = gConfig[i].url; + doc[GNAME[i]]["mood"] = gConfig[i].mood; + doc[GNAME[i]]["action"] = gConfig[i].action; + doc[GNAME[i]]["enabled"] = gConfig[i].enabled; } - prefs.end(); - Serial.println("[Config] Saved to NVS"); + + if (!LittleFS.begin(true)) { + Serial.println("[Config] LittleFS mount failed — save aborted"); + return; + } + File f = LittleFS.open("/config.json", "w"); + if (!f) { + Serial.println("[Config] Cannot open /config.json for writing"); + LittleFS.end(); + return; + } + serializeJsonPretty(doc, f); + f.close(); + LittleFS.end(); + Serial.println("[Config] Gestures saved to /config.json"); } // ── Async webhook via FreeRTOS task ─────────────────────────────────────────── @@ -529,10 +888,6 @@ void fireWebhook(uint8_t gestureIdx) if (!cfg.enabled || strlen(cfg.url) == 0) return; - // Set mood before firing (immediate feedback) - if (cfg.mood > 0) - setBuddyMood((Mood)cfg.mood, 4000); - // Fire async WebhookTask *t = new WebhookTask; strncpy(t->url, cfg.url, sizeof(t->url) - 1); @@ -590,7 +945,7 @@ static String buildHtml() html += "'>