From 65bd552aecd675668fbb77eba955e09978032c25 Mon Sep 17 00:00:00 2001 From: Aleksander Cynarski Date: Fri, 5 Jun 2026 01:03:27 +0200 Subject: [PATCH] feat: initial commit --- .gitignore | 36 ++ README.md | 258 ++++++++++++++ include/README | 37 ++ lib/README | 46 +++ platformio.ini | 15 + src/main.cpp | 874 ++++++++++++++++++++++++++++++++++++++++++++++ test/README | 11 + zigbee_c6_4mb.csv | 8 + 8 files changed, 1285 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 include/README create mode 100644 lib/README create mode 100644 platformio.ini create mode 100644 src/main.cpp create mode 100644 test/README create mode 100644 zigbee_c6_4mb.csv diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4183d7d --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# PlatformIO +.pio/ +.pioenvs/ +.piolibdeps/ + +# VSCode +.vscode/ +*.code-workspace + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes + +# CLion / JetBrains +.idea/ +cmake-build-*/ + +# Build artifacts +*.o +*.a +*.d +*.elf +*.bin +*.hex +*.map + +# Credentials (WiFi passwords etc.) +secrets.h +credentials.h + +# Logs +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a86513 --- /dev/null +++ b/README.md @@ -0,0 +1,258 @@ +# 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 i wyświetla informacje na żądanie. Konfigurowany przez przeglądarkę. + +--- + +## Sprzęt + +| Komponent | Model | Adres I2C | +|-----------|-------|-----------| +| Mikrokontroler | Seeed XIAO ESP32-C6 | — | +| Wyświetlacz | SSD1306 OLED 128×64 | 0x3C | +| Czujnik gestów | CJMCU-7620 (PAJ7620U2) | 0x73 | + +### Podłączenie + +Oba urządzenia dzielą **jedną magistralę I2C** (hardware, HP I2C bus 0): + +``` +SSD1306 / PAJ7620 XIAO ESP32-C6 +────────────────────────────────────── +VCC → 3.3V +GND → GND +SDA → D4 (GPIO22) +SCL → D5 (GPIO23) +``` + +> **Ważne:** ESP32-C6 ma dwa kontrolery I2C: +> - **I2C0 (HP, `Wire`)** — dowolne piny GPIO ✓ +> - **I2C1 (LP, `Wire1`)** — zablokowany sprzętowo na GPIO6/GPIO7, niedostępne na XIAO ✗ +> +> Nie używaj `Wire1` ani SW I2C (bit-bang blokuje WiFi na single-core CPU ~40 ms/frame). + +--- + +## Funkcje + +### Animacja oczu (Buddy) + +Ciągła animacja na OLED ~20 fps. Buddy ma 7 nastrojów z różnymi kształtami oczu: + +| Nastrój | Wygląd | Opis | +|---------|--------|------| +| `normal` | pełne oczy, dryfujące źrenice | domyślny | +| `happy` | łukowe oczy `^_^` | górna połowa oka zasłonięta | +| `sleepy` | opuszczona powieka `zZz` | bąbelki ZZZ, wolne mruganie | +| `surprised` | wielkie oczy `o_O` | uniesione brwi | +| `angry` | zmrużone `>_<` | skośne brwi do środka | +| `sad` | smutne `T_T` | odwrócone brwi, łzy | +| `excited` | gwiazdy `*_*` | wzór × zamiast źrenic | + +Dodatkowe animacje: +- **Mruganie** — co 2,5–6 s (szybsze w trybie `sleepy`) +- **Ruch źrenic** — płynny drift do losowej pozycji co 1,5–4 s (tylko `normal`) +- **Auto-uśpienie** — po 5 minutach bez gestu przechodzi w `sleepy` + +### Gesty (PAJ7620) + +Czujnik rozpoznaje 9 gestów ręką: + +| Gest | Domyślna reakcja | +|------|-----------------| +| `up` | happy | +| `down` | sad | +| `left` | surprised | +| `right` | surprised | +| `forward` | sleepy (trwały) | +| `backward` | angry | +| `clockwise` | excited | +| `anticlockwise` | normal | +| `wave` | excited | + +Każdemu gestowi można przypisać własny nastrój, webhook i akcję przez panel konfiguracyjny. + +### Webhooki + +Każdy gest może wywoływać webhook HTTP POST na skonfigurowany URL: + +``` +POST http://twoj-serwer/endpoint +Content-Type: application/json + +{"gesture": "wave"} +``` + +- Wywołanie asynchroniczne (FreeRTOS task) — nie blokuje animacji +- Timeout 3 sekundy +- Nastrój po wysłaniu webhooka konfigurowalny niezależnie + +### Akcje + +Akcje wyświetlają informacje na OLED przez 8 sekund, potem wracają do twarzy: + +| Akcja | Co pokazuje | +|-------|------------| +| **Data i godzina** | `HH:MM:SS` (duża czcionka), `DD.MM.RRRR`, dzień tygodnia | +| **Status WiFi** | SSID, adres IP, RSSI w dBm, słupki siły sygnału | + +Czas synchronizowany przez NTP (`pool.ntp.org`) — strefa CET/CEST (Polska). + +--- + +## Konfiguracja + +### Panel webowy + +Po uruchomieniu wejdź przeglądarką na adres IP wypisany w Serial (lub na OLED przy starcie): + +``` +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/webhooku +- **Akcja** — co wyświetlić na OLED +- **ON** — czy gest jest aktywny + +### JSON API + +```bash +# Odczyt konfiguracji +GET http:///api/config + +# Zapis konfiguracji +POST http:///api/config +Content-Type: application/json + +{ + "wave": { + "url": "http://homeassistant.local:8123/api/webhook/my_hook", + "mood": 6, + "action": 0, + "enabled": true + }, + "up": { + "url": "", + "mood": 0, + "action": 1, + "enabled": true + } +} +``` + +#### Wartości `mood` + +| Wartość | Nastrój | +|---------|---------| +| 0 | bez zmiany | +| 1 | happy | +| 2 | sleepy | +| 3 | surprised | +| 4 | angry | +| 5 | sad | +| 6 | excited | + +#### Wartości `action` + +| Wartość | Akcja | +|---------|-------| +| 0 | brak | +| 1 | data i godzina | +| 2 | status WiFi | + +### Konfiguracja WiFi + +Dane sieci są na razie hardcoded w `src/main.cpp`: + +```cpp +const char *WIFI_SSID = "twoja_siec"; +const char *WIFI_PASS = "twoje_haslo"; +``` + +--- + +## Instalacja i build + +### Wymagania + +- [PlatformIO](https://platformio.org/) (CLI lub VS Code extension) + +### Build i upload + +```bash +cd esp32-c6 +pio run --target upload +pio device monitor +``` + +### Zależności (pobierane automatycznie) + +```ini +olikraus/U8g2 +acrandal/RevEng PAJ7620 +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). + +``` +setup() + 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 + +loop() ← ~200 fps bez rysowania + sensor.readGesture() ← polling co 500 ms + handleGesture() + 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 +``` + +### Kluczowe decyzje techniczne + +| Problem | Rozwiązanie | +|---------|-------------| +| SW I2C blokuje WiFi 40ms/frame na single-core ESP32-C6 | HW I2C (Wire) dla obu urządzeń na wspólnej magistrali | +| `WebServer.h` nie działa na ESP32-C6 z IDF 5.x | `ESPAsyncWebServer` (callback-based, brak `handleClient()`) | +| `Wire1` (LP I2C) zablokowany na GPIO6/7 (niedostępne na XIAO) | Jeden `Wire` (HP I2C) dla SSD1306 + PAJ7620 | +| NVS klucze max 15 znaków | Skrócone prefiksy gestów: `u/d/l/r/f/b/cw/ccw/w` | +| `Preferences.begin("ns", true)` crash gdy namespace nie istnieje | Otwierać zawsze z `false` | + +--- + +## Dodawanie nowych akcji + +1. Dodaj wartość do `enum Action` w `main.cpp` +2. Zaktualizuj `NUM_ACTIONS` i `ACTION_LABELS[]` +3. Napisz funkcję `showXxxScreen()` +4. Dodaj `case ACTION_XXX:` w `showBuddyScreen()` (sekcja overlay) diff --git a/include/README b/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/include/README @@ -0,0 +1,37 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the convention is to give header files names that end with `.h'. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into the executable file. + +The source code of each library should be placed in a separate directory +("lib/your_library_name/[Code]"). + +For example, see the structure of the following example libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..c637141 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,15 @@ +[env:esp32-c6] +platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip +board = seeed_xiao_esp32c6 +framework = arduino +monitor_speed = 115200 +upload_port = /dev/cu.usbmodem1101 +monitor_port = /dev/cu.usbmodem1101 +upload_speed = 921600 +lib_deps = + olikraus/U8g2 + acrandal/RevEng PAJ7620 + bblanchon/ArduinoJson@^7.2.1 + mathieucarbou/ESPAsyncWebServer@^3.3.12 +build_flags = + -std=gnu++17 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..226058f --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,874 @@ +#include +#include +#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"; + +// ── Gesture config ───────────────────────────────────────────────────────────── +// Index 0-8 maps to GES_UP..GES_WAVE (Gesture enum value - 1) +static const uint8_t NUM_GESTURES = 9; + +// Human-readable names (used in JSON API and HTML) +static const char *GNAME[NUM_GESTURES] = { + "up", "down", "left", "right", "forward", "backward", "clockwise", "anticlockwise", "wave"}; +// Short NVS key prefixes (≤4 chars so full key "wh.ccw.url" ≤ 15) +static const char *GKEY[NUM_GESTURES] = { + "u", "d", "l", "r", "f", "b", "cw", "ccw", "w"}; +// Mood labels matching Mood enum values +static const char *MOOD_LABELS[] = { + "-- bez zmiany --", "happy ^_^", "sleepy zZz", "surprised o_O", + "angry >_<", "sad T_T", "excited *_*"}; + +// Actions that can be triggered by a gesture +enum Action +{ + ACTION_NONE = 0, + ACTION_DATETIME = 1, + ACTION_WIFI = 2 +}; +static const uint8_t NUM_ACTIONS = 3; +static const char *ACTION_LABELS[] = {"-- brak --", "Data i godzina", "Status WiFi"}; + +struct GestureConfig +{ + char url[128]; + uint8_t mood; // 0=no change, 1=happy..6=excited (matches Mood enum) + uint8_t action; // Action enum + bool enabled; +}; + +GestureConfig gConfig[NUM_GESTURES]; +Preferences prefs; +AsyncWebServer httpServer(80); + +// ── Buddy ───────────────────────────────────────────────────────────────────── +enum Mood +{ + MOOD_NORMAL = 0, + MOOD_HAPPY, + MOOD_SLEEPY, + MOOD_SURPRISED, + MOOD_ANGRY, + MOOD_SAD, + MOOD_EXCITED +}; +enum BlinkState +{ + BLINK_OPEN, + BLINK_CLOSING, + BLINK_CLOSED, + BLINK_OPENING +}; + +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 PUPIL_R = 6; + +struct +{ + Mood mood; + uint32_t revertAt; + uint32_t lastEvent; + BlinkState blinkState; + uint8_t blinkRy; + uint8_t closedTicks; + uint32_t nextBlink; + int8_t pupilDx, pupilDy; + int8_t pupilTargetDx, pupilTargetDy; + uint32_t nextLook; + uint8_t zzzPhase; + uint32_t nextZzz; +} buddy; + +// ── Action overlay ──────────────────────────────────────────────────────────── +uint32_t overlayUntil = 0; // show overlay until this timestamp +Action overlayAction = ACTION_NONE; + +void setBuddyMood(Mood m, uint32_t durationMs = 0) +{ + buddy.mood = m; + buddy.revertAt = (durationMs > 0) ? millis() + durationMs : 0; + buddy.lastEvent = millis(); + switch (m) + { + case MOOD_HAPPY: + buddy.pupilTargetDx = 0; + buddy.pupilTargetDy = -2; + break; + case MOOD_SLEEPY: + buddy.pupilTargetDx = 0; + buddy.pupilTargetDy = 4; + break; + case MOOD_SURPRISED: + buddy.pupilTargetDx = 0; + buddy.pupilTargetDy = 0; + break; + case MOOD_SAD: + buddy.pupilTargetDx = 0; + buddy.pupilTargetDy = 4; + break; + case MOOD_ANGRY: + buddy.pupilTargetDx = 2; + buddy.pupilTargetDy = 2; + break; + default: + break; + } +} + +void initBuddy() +{ + buddy = {}; + buddy.blinkState = BLINK_OPEN; + buddy.blinkRy = EYE_RY; + buddy.lastEvent = millis(); + buddy.nextBlink = millis() + 3000; + buddy.nextLook = millis() + 2000; + buddy.nextZzz = millis() + 3000; +} + +// ── Eye drawing ─────────────────────────────────────────────────────────────── +static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, + int8_t pdx, int8_t pdy, bool isLeft) +{ + if (effRy == 0) + return; + 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); + 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); + } + break; + + case MOOD_SURPRISED: + u8g2.setDrawColor(1); + u8g2.drawFilledEllipse(cx, cy, EYE_RX + 2, effRy, U8G2_DRAW_ALL); + u8g2.setDrawColor(0); + u8g2.drawDisc(cx, cy, (uint8_t)(PUPIL_R - 2)); + 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); + 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); + 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); + 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); + } + } + u8g2.setDrawColor(1); + break; + } +} + +// ── Date/time overlay ───────────────────────────────────────────────────────── +static const char *DAYS_PL[] = { + "Niedziela", "Poniedzialek", "Wtorek", "Sroda", + "Czwartek", "Piatek", "Sobota"}; + +static void showWiFiStatusScreen() +{ + u8g2.clearBuffer(); + u8g2.setFont(u8g2_font_6x10_tr); + u8g2.setDrawColor(1); + + if (WiFi.status() != WL_CONNECTED) + { + u8g2.drawStr(20, 32, "WiFi: brak pol."); + u8g2.sendBuffer(); + return; + } + + char rssi[16]; + snprintf(rssi, sizeof(rssi), "RSSI: %d dBm", WiFi.RSSI()); + + // Signal bar (5 bars, each 3px wide, spaced 5px) + int8_t level = WiFi.RSSI(); // dBm, typically -30 (great) to -90 (poor) + uint8_t bars = (level >= -55) ? 5 : (level >= -65) ? 4 + : (level >= -72) ? 3 + : (level >= -80) ? 2 + : 1; + for (uint8_t b = 0; b < 5; b++) + { + uint8_t h = 3 + b * 3; // heights: 3,6,9,12,15 + uint8_t x = 78 + b * 8; + uint8_t y = 14; + if (b < bars) + u8g2.drawBox(x, y - h, 5, h); + else + u8g2.drawFrame(x, y - h, 5, h); + } + + u8g2.drawStr(0, 12, "WiFi"); + u8g2.drawStr(0, 26, WiFi.SSID().c_str()); + u8g2.drawStr(0, 40, WiFi.localIP().toString().c_str()); + u8g2.drawStr(0, 54, rssi); + u8g2.sendBuffer(); +} + +static void showDateTimeScreen() +{ + struct tm t; + if (!getLocalTime(&t)) + { + // NTP not synced yet + u8g2.clearBuffer(); + u8g2.setFont(u8g2_font_6x10_tr); + u8g2.drawStr(20, 32, "Brak czasu NTP"); + u8g2.sendBuffer(); + return; + } + + char timeBuf[9]; // HH:MM:SS + char dateBuf[11]; // DD.MM.YYYY + strftime(timeBuf, sizeof(timeBuf), "%H:%M:%S", &t); + strftime(dateBuf, sizeof(dateBuf), "%d.%m.%Y", &t); + const char *dayName = DAYS_PL[t.tm_wday]; + + u8g2.clearBuffer(); + u8g2.setDrawColor(1); + + // Time — large font, centered + u8g2.setFont(u8g2_font_10x20_tr); + uint8_t tw = u8g2.getStrWidth(timeBuf); + u8g2.drawStr((128 - tw) / 2, 22, timeBuf); + + // Date — medium font, centered + u8g2.setFont(u8g2_font_6x10_tr); + uint8_t dw = u8g2.getStrWidth(dateBuf); + u8g2.drawStr((128 - dw) / 2, 40, dateBuf); + + // Day of week — small font, centered + u8g2.setFont(u8g2_font_5x7_tr); + uint8_t nw = u8g2.getStrWidth(dayName); + u8g2.drawStr((128 - nw) / 2, 56, dayName); + + u8g2.sendBuffer(); +} + +void showBuddyScreen() +{ + // Action overlay takes priority + if (overlayAction != ACTION_NONE && millis() < overlayUntil) + { + switch (overlayAction) + { + case ACTION_DATETIME: + showDateTimeScreen(); + break; + case ACTION_WIFI: + showWiFiStatusScreen(); + break; + default: + break; + } + return; + } + overlayAction = ACTION_NONE; + + u8g2.clearBuffer(); + 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); + if (buddy.mood == MOOD_SURPRISED) + effRy = EYE_RY + 3; + + 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]); + + 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.sendBuffer(); +} + +void updateBuddyAnim() +{ + uint32_t now = millis(); + + if (buddy.revertAt > 0 && now >= buddy.revertAt) + { + buddy.mood = MOOD_NORMAL; + buddy.revertAt = 0; + } + if (buddy.mood == MOOD_NORMAL && now - buddy.lastEvent > 300000UL) + setBuddyMood(MOOD_SLEEPY, 0); + + if (buddy.mood != MOOD_SURPRISED && buddy.mood != MOOD_EXCITED) + { + switch (buddy.blinkState) + { + case BLINK_OPEN: + if (now >= buddy.nextBlink) + buddy.blinkState = BLINK_CLOSING; + break; + case BLINK_CLOSING: + if (buddy.blinkRy > 3) + buddy.blinkRy -= 4; + else + { + buddy.blinkRy = 1; + buddy.blinkState = BLINK_CLOSED; + buddy.closedTicks = 0; + } + break; + case BLINK_CLOSED: + if (buddy.closedTicks++ >= 2) + buddy.blinkState = BLINK_OPENING; + break; + case BLINK_OPENING: + buddy.blinkRy += 4; + if (buddy.blinkRy >= EYE_RY) + { + buddy.blinkRy = EYE_RY; + buddy.blinkState = BLINK_OPEN; + buddy.nextBlink = now + ((buddy.mood == MOOD_SLEEPY) ? random(800, 2000) : random(2500, 6000)); + } + break; + } + } + else + { + buddy.blinkRy = EYE_RY; + } + + if (buddy.mood == MOOD_NORMAL && now >= buddy.nextLook) + { + buddy.pupilTargetDx = (int8_t)random(-6, 7); + buddy.pupilTargetDy = (int8_t)random(-4, 5); + buddy.nextLook = now + random(1500, 4000); + } + 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) + { + buddy.zzzPhase = (buddy.zzzPhase % 3) + 1; + buddy.nextZzz = now + 700; + } +} + +// ── Config persistence (NVS via Preferences) ────────────────────────────────── +void loadConfig() +{ + 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(); +} + +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); + } + prefs.end(); + Serial.println("[Config] Saved to NVS"); +} + +// ── Async webhook via FreeRTOS task ─────────────────────────────────────────── +struct WebhookTask +{ + char url[128]; + char gesture[16]; +}; + +static void webhookTaskFn(void *pvParam) +{ + WebhookTask *t = (WebhookTask *)pvParam; + + HTTPClient http; + http.begin(t->url); + http.setTimeout(3000); + http.addHeader("Content-Type", "application/json"); + + char body[48]; + snprintf(body, sizeof(body), "{\"gesture\":\"%s\"}", t->gesture); + + int code = http.POST(body); + Serial.printf("[Webhook] %s -> HTTP %d\n", t->gesture, code); + http.end(); + + delete t; + vTaskDelete(NULL); +} + +void fireWebhook(uint8_t gestureIdx) +{ + const GestureConfig &cfg = gConfig[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); + strncpy(t->gesture, GNAME[gestureIdx], sizeof(t->gesture) - 1); + + if (xTaskCreate(webhookTaskFn, "wh", 4096, t, 1, NULL) != pdPASS) + { + Serial.println("[Webhook] Task create failed"); + delete t; + } +} + +// ── HTTP server ─────────────────────────────────────────────────────────────── +static String buildHtml() +{ + String html = F( + "" + "" + "Desk Buddy" + "" + "

Desk Buddy

"); + + html += "
IP: "; + html += WiFi.localIP().toString(); + html += "  |  SSID: "; + html += WIFI_SSID; + html += "
"; + html += "
" + ""; + + for (uint8_t i = 0; i < NUM_GESTURES; i++) + { + html += "
GestURL webhokaNastrójAkcjaON
"; + html += GNAME[i]; + html += "