From 856990f2324028271acd789596e51c40c8a24580 Mon Sep 17 00:00:00 2001 From: Aleksander Cynarski Date: Fri, 5 Jun 2026 16:36:43 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20pogoda,=20poprawki=20w=20wy=C5=9Bwietla?= =?UTF-8?q?niu.=20Clean=20Architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Taskfile.yml | 5 + lib/BuddyDomain/BuddyLogic.cpp | 130 +++ lib/BuddyDomain/BuddyLogic.h | 12 + lib/BuddyDomain/BuddyTypes.h | 40 + lib/BuddyDomain/library.json | 1 + lib/GestureConfig/GestureConfig.cpp | 31 + lib/GestureConfig/GestureConfig.h | 30 + lib/GestureConfig/library.json | 1 + lib/TamaLogic/TamaLogic.cpp | 57 ++ lib/TamaLogic/TamaLogic.h | 23 + lib/TamaLogic/library.json | 1 + memory/MEMORY.md | 87 ++ partitions.csv | 5 + platformio.ini | 12 + src/main.cpp | 1293 +++++++++++++------------ test/native/test_buddy/test_buddy.cpp | 220 +++++ test/native/test_tama/test_tama.cpp | 203 ++++ 17 files changed, 1517 insertions(+), 634 deletions(-) create mode 100644 lib/BuddyDomain/BuddyLogic.cpp create mode 100644 lib/BuddyDomain/BuddyLogic.h create mode 100644 lib/BuddyDomain/BuddyTypes.h create mode 100644 lib/BuddyDomain/library.json create mode 100644 lib/GestureConfig/GestureConfig.cpp create mode 100644 lib/GestureConfig/GestureConfig.h create mode 100644 lib/GestureConfig/library.json create mode 100644 lib/TamaLogic/TamaLogic.cpp create mode 100644 lib/TamaLogic/TamaLogic.h create mode 100644 lib/TamaLogic/library.json create mode 100644 memory/MEMORY.md create mode 100644 partitions.csv create mode 100644 test/native/test_buddy/test_buddy.cpp create mode 100644 test/native/test_tama/test_tama.cpp diff --git a/Taskfile.yml b/Taskfile.yml index 0ad2f7a..7e30f75 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -92,6 +92,11 @@ tasks: && echo "[config-download] Zapisano do data/config.json (tylko gesty, bez WiFi)" \ || { echo "[config-download] BLAD: nie mozna polaczyc z $IP"; exit 1; } + test: + desc: Uruchom testy jednostkowe (native, na Mac) + cmds: + - pio test -e native + default: desc: Pokaz liste dostepnych zadan cmds: diff --git a/lib/BuddyDomain/BuddyLogic.cpp b/lib/BuddyDomain/BuddyLogic.cpp new file mode 100644 index 0000000..95bbb23 --- /dev/null +++ b/lib/BuddyDomain/BuddyLogic.cpp @@ -0,0 +1,130 @@ +#include "BuddyLogic.h" +#include + +// Replaces Arduino constrain() +template +static T clamp(T v, T lo, T hi) { return std::max(lo, std::min(hi, v)); } + +void initBuddy(BuddyState &b, uint32_t now) +{ + b = {}; + b.blinkState = BLINK_OPEN; + b.blinkRy = EYE_RY; + b.lastEvent = now; + b.nextBlink = now + 3000; + b.nextLook = now + 2000; + b.nextZzz = now + 3000; + b.nextMicroTremor = now + 500; +} + +void setBuddyMood(BuddyState &b, Mood m, uint32_t now, uint32_t durationMs) +{ + b.mood = m; + b.revertAt = (durationMs > 0) ? now + durationMs : 0; + b.lastEvent = now; + switch (m) { + case MOOD_HAPPY: + b.pupilTargetDx = 0; b.pupilTargetDy = -2; break; + case MOOD_SLEEPY: + b.pupilTargetDx = 0; b.pupilTargetDy = 4; break; + case MOOD_SURPRISED: + b.pupilTargetDx = 0; b.pupilTargetDy = 0; break; + case MOOD_SAD: + b.pupilTargetDx = 0; b.pupilTargetDy = 4; break; + case MOOD_ANGRY: + b.pupilTargetDx = 2; b.pupilTargetDy = 2; break; + default: break; + } +} + +void updateBuddyAnim(BuddyState &b, uint32_t now, RngFn rng) +{ + // Timed mood revert + if (b.revertAt > 0 && now >= b.revertAt) { + b.mood = MOOD_NORMAL; + b.revertAt = 0; + } + // Auto-sleep after 5 min idle + if (b.mood == MOOD_NORMAL && now - b.lastEvent > 300000UL) + setBuddyMood(b, MOOD_SLEEPY, now, 0); + + // Blink state machine (disabled for SURPRISED, EXCITED, WINKs) + if (b.mood != MOOD_SURPRISED && b.mood != MOOD_EXCITED && + b.mood != MOOD_WINK_L && b.mood != MOOD_WINK_R) + { + switch (b.blinkState) { + case BLINK_OPEN: + if (now >= b.nextBlink) + b.blinkState = BLINK_CLOSING; + break; + case BLINK_CLOSING: + if (b.blinkRy > 3) + b.blinkRy -= 4; + else { + b.blinkRy = 1; + b.blinkState = BLINK_CLOSED; + b.closedTicks = 0; + } + break; + case BLINK_CLOSED: + if (b.closedTicks++ >= 2) + b.blinkState = BLINK_OPENING; + break; + case BLINK_OPENING: + b.blinkRy += 4; + if (b.blinkRy >= EYE_RY) { + b.blinkRy = EYE_RY; + b.blinkState = BLINK_OPEN; + b.nextBlink = now + (int32_t)((b.mood == MOOD_SLEEPY) + ? rng(800, 2000) : rng(2500, 6000)); + } + break; + } + } else { + b.blinkRy = EYE_RY; + } + + // Saccadic pupil movement — fast (3px/tick) when far (|delta|>=4), slow (1px) when close + { + int8_t ddx = b.pupilTargetDx - b.pupilDx; + int8_t ddy = b.pupilTargetDy - b.pupilDy; + int8_t absDdx = ddx < 0 ? (int8_t)-ddx : ddx; + int8_t absDdy = ddy < 0 ? (int8_t)-ddy : ddy; + if (ddx) b.pupilDx = (int8_t)(b.pupilDx + (ddx > 0 ? 1 : -1) * (absDdx >= 4 ? 3 : 1)); + if (ddy) b.pupilDy = (int8_t)(b.pupilDy + (ddy > 0 ? 1 : -1) * (absDdy >= 4 ? 3 : 1)); + } + + // Gaze — random saccades + micro-tremor + { + bool fixated = (b.pupilDx == b.pupilTargetDx && b.pupilDy == b.pupilTargetDy); + + if (b.mood == MOOD_SLEEPY) { + if (fixated && now >= b.nextLook) { + b.pupilTargetDx = (int8_t)rng(-3, 4); + b.pupilTargetDy = (int8_t)rng(2, 6); + b.nextLook = now + (uint32_t)rng(4000, 9000); + } + } 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.pupilTargetDy = (int8_t)rng(-5, 6); + b.nextLook = now + (uint32_t)rng(1500, 4500); + } + if ((b.mood == MOOD_NORMAL || b.mood == MOOD_HAPPY) && + fixated && now >= b.nextMicroTremor) + { + b.pupilTargetDx = (int8_t)clamp( + (int32_t)b.pupilTargetDx + rng(-1, 2), (int32_t)-7, (int32_t)7); + b.pupilTargetDy = (int8_t)clamp( + (int32_t)b.pupilTargetDy + rng(-1, 2), (int32_t)-5, (int32_t)5); + b.nextMicroTremor = now + (uint32_t)rng(300, 700); + } + } + } + + // ZZZ bubbles when sleepy + if (b.mood == MOOD_SLEEPY && now >= b.nextZzz) { + b.zzzPhase = (b.zzzPhase % 3) + 1; + b.nextZzz = now + 700; + } +} diff --git a/lib/BuddyDomain/BuddyLogic.h b/lib/BuddyDomain/BuddyLogic.h new file mode 100644 index 0000000..d42a598 --- /dev/null +++ b/lib/BuddyDomain/BuddyLogic.h @@ -0,0 +1,12 @@ +#pragma once +#include "BuddyTypes.h" + +// Eye radius — used in blink state machine and main.cpp drawing +static const uint8_t EYE_RY = 15; + +// Inject RNG so tests can supply a deterministic function +using RngFn = int32_t(*)(int32_t lo, int32_t hi); + +void initBuddy(BuddyState &b, uint32_t now); +void setBuddyMood(BuddyState &b, Mood m, uint32_t now, uint32_t durationMs = 0); +void updateBuddyAnim(BuddyState &b, uint32_t now, RngFn rng); diff --git a/lib/BuddyDomain/BuddyTypes.h b/lib/BuddyDomain/BuddyTypes.h new file mode 100644 index 0000000..92443f2 --- /dev/null +++ b/lib/BuddyDomain/BuddyTypes.h @@ -0,0 +1,40 @@ +#pragma once +#include + +enum Mood : uint8_t { + MOOD_NORMAL = 0, + MOOD_HAPPY, + MOOD_SLEEPY, + MOOD_SURPRISED, + MOOD_ANGRY, + MOOD_SAD, + MOOD_EXCITED, + MOOD_WINK_L, + MOOD_WINK_R, + MOOD_HUNGRY, + MOOD_PLAYFUL, + MOOD_DIRTY +}; + +enum BlinkState : uint8_t { + BLINK_OPEN, + BLINK_CLOSING, + BLINK_CLOSED, + BLINK_OPENING +}; + +struct BuddyState { + 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; + uint32_t nextMicroTremor; +}; diff --git a/lib/BuddyDomain/library.json b/lib/BuddyDomain/library.json new file mode 100644 index 0000000..365df62 --- /dev/null +++ b/lib/BuddyDomain/library.json @@ -0,0 +1 @@ +{ "name": "BuddyDomain", "version": "1.0.0", "frameworks": "*", "platforms": "*" } diff --git a/lib/GestureConfig/GestureConfig.cpp b/lib/GestureConfig/GestureConfig.cpp new file mode 100644 index 0000000..eabf215 --- /dev/null +++ b/lib/GestureConfig/GestureConfig.cpp @@ -0,0 +1,31 @@ +#include "GestureConfig.h" + +const char *GNAME[NUM_GESTURES] = { + "up", "down", "left", "right", "forward", "backward", "clockwise", "anticlockwise", "wave"}; + +const char *GKEY[NUM_GESTURES] = { + "u", "d", "l", "r", "f", "b", "cw", "ccw", "w"}; + +const char *MOOD_LABELS[] = { + "-- bez zmiany --", "happy ^_^", "sleepy zZz", "surprised o_O", + "angry >_<", "sad T_T", "excited *_*", "wink L ;)", "wink R (;", + "hungry :(", "playful :D", "dirty ..."}; + +const char *ACTION_LABELS[] = { + "-- brak --", "Data i godzina", "Status WiFi", + "Nakarm", "Pobaw sie", "Umyj", "Status tamagotchi"}; + +const Mood DEFAULT_MOOD[NUM_GESTURES] = { + MOOD_HAPPY, // up + MOOD_SAD, // down + MOOD_SURPRISED, // left + MOOD_SURPRISED, // right + MOOD_SLEEPY, // forward + MOOD_ANGRY, // backward + MOOD_EXCITED, // clockwise + MOOD_NORMAL, // anticlockwise + MOOD_EXCITED, // wave +}; + +const uint32_t DEFAULT_MOOD_DUR[NUM_GESTURES] = { + 2000, 2000, 1500, 1500, 0, 3000, 2000, 0, 2000}; diff --git a/lib/GestureConfig/GestureConfig.h b/lib/GestureConfig/GestureConfig.h new file mode 100644 index 0000000..5d5223b --- /dev/null +++ b/lib/GestureConfig/GestureConfig.h @@ -0,0 +1,30 @@ +#pragma once +#include +#include "BuddyTypes.h" + +static const uint8_t NUM_GESTURES = 9; +static const uint8_t NUM_ACTIONS = 7; + +enum Action : uint8_t { + ACTION_NONE = 0, + ACTION_DATETIME = 1, + ACTION_WIFI = 2, + ACTION_FEED = 3, + ACTION_PLAY = 4, + ACTION_CLEAN = 5, + ACTION_STATUS = 6 +}; + +struct GestureConfig { + char url[128]; + uint8_t mood; // 0 = no change, 1-11 matches Mood enum + uint8_t action; // Action enum + bool enabled; +}; + +extern const char *GNAME[NUM_GESTURES]; +extern const char *GKEY[NUM_GESTURES]; +extern const char *MOOD_LABELS[]; +extern const char *ACTION_LABELS[]; +extern const Mood DEFAULT_MOOD[NUM_GESTURES]; +extern const uint32_t DEFAULT_MOOD_DUR[NUM_GESTURES]; diff --git a/lib/GestureConfig/library.json b/lib/GestureConfig/library.json new file mode 100644 index 0000000..f36b27f --- /dev/null +++ b/lib/GestureConfig/library.json @@ -0,0 +1 @@ +{ "name": "GestureConfig", "version": "1.0.0", "frameworks": "*", "platforms": "*" } diff --git a/lib/TamaLogic/TamaLogic.cpp b/lib/TamaLogic/TamaLogic.cpp new file mode 100644 index 0000000..9e9c9b2 --- /dev/null +++ b/lib/TamaLogic/TamaLogic.cpp @@ -0,0 +1,57 @@ +#include "TamaLogic.h" + +void initTama(TamaState &t, uint32_t now) +{ + t.hunger = 10; + t.happiness = 80; + t.hygiene = 90; + t.nextHungerTick = now + 120000UL; + t.nextHappyTick = now + 180000UL; + t.nextHygieneTick = now + 240000UL; +} + +Mood updateTama(TamaState &t, const BuddyState &b, uint32_t now) +{ + // Tick needs over time + if (now >= t.nextHungerTick) { + t.nextHungerTick = now + 120000UL; + if (t.hunger < 100) t.hunger++; + } + if (now >= t.nextHappyTick) { + t.nextHappyTick = now + 180000UL; + if (t.happiness > 0) t.happiness--; + } + if (now >= t.nextHygieneTick) { + t.nextHygieneTick = now + 240000UL; + if (t.hygiene > 0) t.hygiene--; + } + + // Return mood override based on critical needs. + // Guard: skip when buddy has a timed mood or is in non-overridable state. + if (b.revertAt == 0 && b.mood != MOOD_SLEEPY && + b.mood != MOOD_WINK_L && b.mood != MOOD_WINK_R) + { + if (t.hunger >= 80) return MOOD_HUNGRY; + if (t.hygiene <= 20) return MOOD_DIRTY; + if (t.happiness <= 20) return MOOD_PLAYFUL; + // Needs satisfied — signal "reset to normal" (caller handles if buddy is in need-mood) + } + return MOOD_NORMAL; +} + +void tamaFeed(TamaState &t) +{ + t.hunger = (t.hunger >= 30) ? t.hunger - 30 : 0; +} + +void tamaPlay(TamaState &t) +{ + uint16_t v = (uint16_t)t.happiness + 25; + t.happiness = (v > 100) ? 100 : (uint8_t)v; +} + +void tamaClean(TamaState &t) +{ + uint16_t v = (uint16_t)t.hygiene + 40; + t.hygiene = (v > 100) ? 100 : (uint8_t)v; +} diff --git a/lib/TamaLogic/TamaLogic.h b/lib/TamaLogic/TamaLogic.h new file mode 100644 index 0000000..1bfa1e4 --- /dev/null +++ b/lib/TamaLogic/TamaLogic.h @@ -0,0 +1,23 @@ +#pragma once +#include +#include "BuddyTypes.h" + +struct TamaState { + uint8_t hunger; // 0=full → 100=starving + uint8_t happiness; // 100=happy → 0=bored + uint8_t hygiene; // 100=clean → 0=dirty + uint32_t nextHungerTick; + uint32_t nextHappyTick; + uint32_t nextHygieneTick; +}; + +void initTama(TamaState &t, uint32_t now); + +// Updates ticks and returns the mood override for buddy. +// Returns MOOD_HUNGRY / MOOD_DIRTY / MOOD_PLAYFUL when a need is critical. +// Returns MOOD_NORMAL when needs are satisfied or guard blocks (revertAt != 0, sleepy, wink). +Mood updateTama(TamaState &t, const BuddyState &b, uint32_t now); + +void tamaFeed(TamaState &t); // hunger -= 30 (floor 0) +void tamaPlay(TamaState &t); // happy += 25 (cap 100) +void tamaClean(TamaState &t); // hygiene += 40 (cap 100) diff --git a/lib/TamaLogic/library.json b/lib/TamaLogic/library.json new file mode 100644 index 0000000..4f46bb4 --- /dev/null +++ b/lib/TamaLogic/library.json @@ -0,0 +1 @@ +{ "name": "TamaLogic", "version": "1.0.0", "frameworks": "*", "platforms": "*" } diff --git a/memory/MEMORY.md b/memory/MEMORY.md new file mode 100644 index 0000000..36c7ba3 --- /dev/null +++ b/memory/MEMORY.md @@ -0,0 +1,87 @@ +# ESP32-C6 Desk Buddy — Project Memory + +## Hardware +- Board: Seeed XIAO ESP32-C6 +- Display: SSD1306 128x64 OLED +- Gesture sensor: CJMCU-7620 (PAJ7620U2, I2C addr 0x73) + +## I2C — CRITICAL +- ESP32-C6 has two I2C buses: + - I2C0 (HP, `Wire`): configurable to any GPIO — USE THIS + - I2C1 (LP, `Wire1`): hardware-locked to SDA=GPIO6, SCL=GPIO7 — NOT usable on XIAO (pins not exposed) +- **Both SSD1306 and PAJ7620 share one bus**: `Wire.begin(22, 23)` — SDA=GPIO22(D4), SCL=GPIO23(D5) +- SSD1306=0x3C, PAJ7620=0x73 — different addresses, coexist fine +- SW I2C (U8g2 bit-bang) blocks CPU ~40ms/frame — kills WiFi on single-core ESP32-C6. Always use HW I2C. +- U8g2 HW I2C constructor: `U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE, 23, 22)` + +## PAJ7620 API +- Library: `acrandal/RevEng PAJ7620` +- Gesture type: `Gesture` (NOT `Gesture_t`) +- begin() takes pointer: `sensor.begin(&Wire)` (NOT reference) +- `Wire1` pre-defined in framework — do not redeclare `TwoWire Wire1(1)` +- Gesture index: `(int)g - 1` maps GES_UP(1)..GES_WAVE(9) to 0..8 + +## HTTP Server — CRITICAL +- `WebServer.h` does NOT work reliably on ESP32-C6 with IDF 5.x (pioarduino platform) +- Use `AsyncWebServer` from `mathieucarbou/ESPAsyncWebServer@^3.3.12` +- No `handleClient()` needed — callback-based, works on interrupts +- JSON POST body handler: use the 3-arg `on()` with body callback (4th param) + +## NVS / Preferences +- Always open with `prefs.begin("namespace", false)` — `true` (read-only) fails with NOT_FOUND if namespace doesn't exist yet +- NVS key max 15 chars — use short prefixes: u/d/l/r/f/b/cw/ccw/w for gestures + +## platformio.ini +```ini +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 +board_build.partitions = default # no Zigbee partitions needed +``` + +## Desk Buddy — Architecture +- Moods (0-11): NORMAL, HAPPY, SLEEPY, SURPRISED, ANGRY, SAD, EXCITED, WINK_L, WINK_R, HUNGRY, PLAYFUL, DIRTY +- Actions (0-6): NONE, DATETIME, WIFI, FEED, PLAY, CLEAN, STATUS +- Eyes: `drawFilledEllipse` + mood-specific shapes; wink = `drawCircle` UPPER arc +- Wink: WINK_L closes screen-RIGHT eye (!isLeft = buddy's left), WINK_R closes screen-LEFT +- Blink: state machine OPEN→CLOSING→CLOSED→OPENING, 4px/tick +- Pupil: smooth drift toward random target every 1.5-4s (NORMAL only) +- Sleepy: triggered after 5 min idle (`lastEvent` tracking), ZZZ bubbles +- Auto-revert: `revertAt` timestamp, 0 = permanent mood +- Display refresh: 50ms (~20fps) — OK with HW I2C + +## Webhook System +- Per-gesture config: URL (128 chars) + mood (0=none, 1-6) + enabled +- Fired async via FreeRTOS `xTaskCreate` — non-blocking +- POST `{"gesture":"wave"}` to configured URL, 3s timeout +- Config persisted in NVS, served/edited via HTTP at GET/POST / +- JSON API: GET /api/config, POST /api/config + +## Key Files +- `src/main.cpp` — hardware + drawing + HTTP server + WiFi + FreeRTOS (~400 lines) +- `lib/BuddyDomain/` — BuddyTypes.h, BuddyLogic.h/.cpp (mood, blink, pupil — no Arduino) +- `lib/TamaLogic/` — TamaLogic.h/.cpp (hunger/happiness/hygiene ticks — no Arduino) +- `lib/GestureConfig/` — GestureConfig.h/.cpp (struct, enums, string tables) +- `platformio.ini` — [env:esp32-c6] + [env:native] +- `test/native/test_buddy/` + `test/native/test_tama/` — Unity tests (40 total) +- Reference project: `../handsensor/` — webhook/config patterns + +## Testing +- `pio test -e native` or `task test` — runs 40 unit tests on Mac (no device needed) +- `pio run -e esp32-c6` — firmware build +- Domain libs have zero Arduino deps: `` only; RngFn injection for determinism + +## Domain API +- `initBuddy(BuddyState &b, uint32_t now)` — pass millis() +- `setBuddyMood(BuddyState &b, Mood m, uint32_t now, uint32_t durationMs=0)` +- `updateBuddyAnim(BuddyState &b, uint32_t now, RngFn rng)` — RngFn = int32_t(*)(lo,hi) +- `initTama(TamaState &t, uint32_t now)` +- `updateTama(TamaState &t, const BuddyState &b, uint32_t now)` → returns Mood override +- `tamaFeed/Play/Clean(TamaState &t)` — direct state mutations diff --git a/partitions.csv b/partitions.csv new file mode 100644 index 0000000..3404d4b --- /dev/null +++ b/partitions.csv @@ -0,0 +1,5 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x1C0000, +spiffs, data, spiffs, 0x1D0000, 0x230000, diff --git a/platformio.ini b/platformio.ini index 0a70b31..fbab29b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -14,4 +14,16 @@ lib_deps = build_flags = -std=gnu++17 -D FORMAT_LITTLEFS_IF_FAILED +board_build.partitions = partitions.csv board_build.filesystem = littlefs +test_ignore = native/* + +[env:native] +platform = native +build_flags = + -std=c++17 + -I lib/BuddyDomain + -I lib/TamaLogic + -I lib/GestureConfig +test_build_src = no +test_filter = native/* diff --git a/src/main.cpp b/src/main.cpp index 7bf520d..888ae63 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,139 +8,58 @@ #include #include +#include "BuddyLogic.h" // BuddyState, Mood, BlinkState, EYE_RY, initBuddy, setBuddyMood, updateBuddyAnim +#include "TamaLogic.h" // TamaState, initTama, updateTama, tamaFeed/Play/Clean +#include "GestureConfig.h" // GestureConfig struct, Action, NUM_GESTURES, GNAME[], GKEY[], etc. + // ── 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; -// WiFi credentials — loaded from LittleFS /config.json at boot +// WiFi credentials — loaded from LittleFS /wifi.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) -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 *_*", "wink L ;)", "wink R (;", - "hungry :(", "playful :D", "dirty ..."}; - -// Actions that can be triggered by a gesture -enum Action -{ - ACTION_NONE = 0, - ACTION_DATETIME = 1, - 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 = 7; -static const char *ACTION_LABELS[] = { - "-- brak --", "Data i godzina", "Status WiFi", - "Nakarm", "Pobaw sie", "Umyj", "Status tamagotchi"}; - -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; -}; - +// ── Global state ────────────────────────────────────────────────────────────── GestureConfig gConfig[NUM_GESTURES]; +BuddyState buddy; +TamaState tama; AsyncWebServer httpServer(80); -// ── Buddy ───────────────────────────────────────────────────────────────────── -enum Mood -{ - MOOD_NORMAL = 0, - MOOD_HAPPY, - MOOD_SLEEPY, - MOOD_SURPRISED, - MOOD_ANGRY, - MOOD_SAD, - 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 -{ - 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 = 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 -{ - 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; - 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; -} +// ── RNG bridge (Arduino random → domain RngFn) ─────────────────────────────── +static int32_t arduinoRng(int32_t lo, int32_t hi) { return random(lo, hi); } // ── Action overlay ──────────────────────────────────────────────────────────── -uint32_t overlayUntil = 0; // show overlay until this timestamp -Action overlayAction = ACTION_NONE; +static uint32_t overlayUntil = 0; +static Action overlayAction = ACTION_NONE; + +// ── Weather ─────────────────────────────────────────────────────────────────── +enum WeatherIcon : uint8_t { + WICON_NONE = 0, WICON_SUN, WICON_CLOUD_SUN, WICON_CLOUD, + WICON_RAIN, WICON_SNOW, WICON_THUNDER +}; + +struct WeatherConfig { + char city[64]; + uint16_t intervalSec; // show every N seconds (0 = disabled) + uint16_t durationSec; // show for N seconds +}; + +struct WeatherData { + int8_t temp; // °C + int16_t pressure; // hPa + WeatherIcon icon; + bool valid; +}; + +WeatherConfig weatherCfg = { "", 300, 10 }; +WeatherData weatherData = { 0, 0, WICON_NONE, false }; +static uint32_t weatherShowUntil = 0; +static uint32_t weatherNextShow = 0; +static uint32_t weatherLastFetch = 0; // ── 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; @@ -148,56 +67,17 @@ static void setDim(bool dim) { u8g2.setPowerSave(dim ? 1 : 0); } -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; - buddy.nextMicroTremor = millis() + 500; -} +// ── Eye / mouth drawing constants (display geometry, not logic) ─────────────── +static const uint8_t EYE_L_X = 38; +static const uint8_t EYE_R_X = 90; +static const uint8_t EYE_Y = 27; +static const uint8_t EYE_RX = 17; +static const uint8_t PUPIL_R = 6; +static const uint8_t MOUTH_X = 64; +static const uint8_t MOUTH_Y = 52; // ── 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); @@ -206,7 +86,6 @@ static void drawWinkEye(uint8_t cx, uint8_t cy) { static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, int8_t pdx, int8_t pdy, bool isLeft) { - // 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; @@ -216,7 +95,6 @@ static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, { 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) { @@ -228,16 +106,12 @@ static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, } 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); @@ -248,9 +122,7 @@ static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, } 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++) { @@ -262,17 +134,13 @@ static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, } 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) { @@ -284,9 +152,7 @@ static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, } 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) { @@ -296,9 +162,7 @@ static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, } 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) { @@ -309,9 +173,7 @@ static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, } 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) { @@ -322,7 +184,6 @@ static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, } break; } - default: { uint8_t ry = min(effRy, EYE_RY); u8g2.drawFilledEllipse(cx, cy, EYE_RX, ry, U8G2_DRAW_ALL); @@ -332,96 +193,72 @@ static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy, } // ── 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: - // 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); + u8g2.drawDisc(MOUTH_X - 20, MOUTH_Y, 3); + u8g2.drawDisc(MOUTH_X + 20, MOUTH_Y, 3); break; - case MOOD_SLEEPY: - // Thick horizontal bar — tired, barely open u8g2.drawBox(MOUTH_X - 8, MOUTH_Y - 1, 16, 3); break; - case MOOD_SURPRISED: - // Open "O" — filled ring (outline + hollow) u8g2.drawFilledEllipse(MOUTH_X, MOUTH_Y, 5, 6, U8G2_DRAW_ALL); u8g2.setDrawColor(0); u8g2.drawFilledEllipse(MOUTH_X, MOUTH_Y, 3, 4, U8G2_DRAW_ALL); u8g2.setDrawColor(1); break; - case MOOD_ANGRY: - // 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: - // 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: - // 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; - 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 + default: u8g2.drawCircle(MOUTH_X, MOUTH_Y - 3, 6, U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT); u8g2.drawCircle(MOUTH_X, MOUTH_Y - 3, 7, @@ -430,7 +267,122 @@ static void drawMouth() } } -// ── Date/time overlay ───────────────────────────────────────────────────────── +// ── Weather face drawing ────────────────────────────────────────────────────── +static void drawWeatherEye(uint8_t cx, uint8_t cy, WeatherIcon icon) +{ + u8g2.setDrawColor(1); + switch (icon) { + + case WICON_SUN: + u8g2.drawDisc(cx, cy, 8); + u8g2.drawLine(cx, cy-11, cx, cy-14); // N + u8g2.drawLine(cx, cy+11, cx, cy+14); // S + u8g2.drawLine(cx-11, cy, cx-14, cy ); // W + u8g2.drawLine(cx+11, cy, cx+14, cy ); // E + u8g2.drawLine(cx-8, cy-8, cx-10, cy-10); // NW + u8g2.drawLine(cx+8, cy-8, cx+10, cy-10); // NE + u8g2.drawLine(cx-8, cy+8, cx-10, cy+10); // SW + u8g2.drawLine(cx+8, cy+8, cx+10, cy+10); // SE + break; + + case WICON_CLOUD_SUN: { + // Sun peeking top-right + u8g2.drawDisc(cx+5, cy-5, 5); + u8g2.drawLine(cx+5, cy-12, cx+5, cy-14); + u8g2.drawLine(cx+12, cy-5, cx+14, cy-5); + u8g2.drawLine(cx+9, cy-11, cx+11, cy-13); + // Cloud bottom-left (overlapping sun) + u8g2.drawDisc(cx-5, cy+1, 4); + u8g2.drawDisc(cx+2, cy+1, 4); + u8g2.drawDisc(cx-1, cy-3, 4); + u8g2.drawBox(cx-9, cy+1, 14, 6); + break; + } + + case WICON_CLOUD: + u8g2.drawDisc(cx-5, cy-2, 6); + u8g2.drawDisc(cx+5, cy-2, 6); + u8g2.drawDisc(cx, cy-6, 6); + u8g2.drawBox(cx-11, cy-2, 22, 8); + break; + + case WICON_RAIN: + // Cloud (higher up to leave room for drops) + u8g2.drawDisc(cx-4, cy-5, 5); + u8g2.drawDisc(cx+4, cy-5, 5); + u8g2.drawDisc(cx, cy-9, 5); + u8g2.drawBox(cx-9, cy-5, 18, 7); + // Rain drops: 3 diagonal lines + u8g2.drawLine(cx-5, cy+4, cx-7, cy+11); + u8g2.drawLine(cx, cy+4, cx-2, cy+11); + u8g2.drawLine(cx+5, cy+4, cx+3, cy+11); + break; + + case WICON_SNOW: + // Cloud + u8g2.drawDisc(cx-4, cy-5, 5); + u8g2.drawDisc(cx+4, cy-5, 5); + u8g2.drawDisc(cx, cy-9, 5); + u8g2.drawBox(cx-9, cy-5, 18, 7); + // Snowflakes: 3 small crosses + for (int8_t si = -1; si <= 1; si++) { + uint8_t sx = (uint8_t)((int8_t)cx + si * 6); + uint8_t sy = cy + 9; + u8g2.drawHLine(sx-2, sy, 5); + u8g2.drawVLine(sx, sy-2, 5); + } + break; + + case WICON_THUNDER: + // Cloud + u8g2.drawDisc(cx-4, cy-5, 5); + u8g2.drawDisc(cx+4, cy-5, 5); + u8g2.drawDisc(cx, cy-9, 5); + u8g2.drawBox(cx-9, cy-5, 18, 7); + // Lightning bolt (zigzag) + u8g2.drawLine(cx+2, cy+3, cx-2, cy+8 ); + u8g2.drawLine(cx-2, cy+8, cx+2, cy+8 ); + u8g2.drawLine(cx+2, cy+8, cx-2, cy+14); + break; + + default: // WICON_NONE: simple circle + u8g2.drawCircle(cx, cy, 12, U8G2_DRAW_ALL); + break; + } +} + +static void drawWeatherFace() +{ + if (!weatherData.valid) return; + + u8g2.clearBuffer(); + u8g2.setDrawColor(1); + + drawWeatherEye(EYE_L_X, EYE_Y, weatherData.icon); + drawWeatherEye(EYE_R_X, EYE_Y, weatherData.icon); + + // Temperature — centered, large-ish font with ° symbol + char tempBuf[12]; + snprintf(tempBuf, sizeof(tempBuf), "%d°C", (int)weatherData.temp); + u8g2.setFont(u8g2_font_7x13_tf); + uint8_t tw = u8g2.getUTF8Width(tempBuf); + u8g2.drawUTF8((128 - tw) / 2, 51, tempBuf); + + // Pressure — smaller font + char presBuf[12]; + snprintf(presBuf, sizeof(presBuf), "%d hPa", (int)weatherData.pressure); + u8g2.setFont(u8g2_font_5x7_tr); + uint8_t pw = u8g2.getStrWidth(presBuf); + u8g2.drawStr((128 - pw) / 2, 62, presBuf); + + // Small "W" tag top-left corner + u8g2.setFont(u8g2_font_4x6_tr); + u8g2.drawStr(1, 7, "W"); + + u8g2.sendBuffer(); +} + +// ── Overlay screens ─────────────────────────────────────────────────────────── static const char *DAYS_PL[] = { "Niedziela", "Poniedzialek", "Wtorek", "Sroda", "Czwartek", "Piatek", "Sobota"}; @@ -440,34 +392,23 @@ static void showWiFiStatusScreen() u8g2.clearBuffer(); u8g2.setFont(u8g2_font_6x10_tr); u8g2.setDrawColor(1); - - if (WiFi.status() != WL_CONNECTED) - { + 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) + int8_t level = WiFi.RSSI(); 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 + : (level >= -80) ? 2 : 1; + for (uint8_t b = 0; b < 5; b++) { + uint8_t h = 3 + b * 3; 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); + if (b < bars) u8g2.drawBox(x, 14 - h, 5, h); + else u8g2.drawFrame(x, 14 - 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()); @@ -478,54 +419,35 @@ static void showWiFiStatusScreen() static void showDateTimeScreen() { struct tm t; - if (!getLocalTime(&t)) - { - // NTP not synced yet + if (!getLocalTime(&t)) { 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 + char timeBuf[9], dateBuf[11]; 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.drawStr((128 - u8g2.getStrWidth(timeBuf)) / 2, 22, timeBuf); 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.drawStr((128 - u8g2.getStrWidth(dateBuf)) / 2, 40, dateBuf); + const char *dayName = DAYS_PL[t.tm_wday]; u8g2.setFont(u8g2_font_5x7_tr); - uint8_t nw = u8g2.getStrWidth(dayName); - u8g2.drawStr((128 - nw) / 2, 56, dayName); - + u8g2.drawStr((128 - u8g2.getStrWidth(dayName)) / 2, 56, dayName); 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); @@ -537,9 +459,7 @@ static void showPlayScreen() { 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, @@ -558,7 +478,6 @@ static void showCleanScreen() { 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); @@ -572,45 +491,41 @@ 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 + drawBar(11, "Glod:", tama.hunger); 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); - + 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 - if (overlayAction != ACTION_NONE && millis() < overlayUntil) - { - switch (overlayAction) - { - 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; + // Weather face takes priority over normal face (but not action overlays) + if (overlayAction == ACTION_NONE && weatherData.valid && millis() < weatherShowUntil) { + drawWeatherFace(); + return; + } + + if (overlayAction != ACTION_NONE && millis() < overlayUntil) { + switch (overlayAction) { + 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; @@ -621,17 +536,14 @@ void showBuddyScreen() u8g2.setDrawColor(1); uint8_t effRy = buddy.blinkRy; - // SURPRISED: eyes open wider than normal max if (buddy.mood == MOOD_SURPRISED) 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); - drawMouth(); - if (buddy.mood == MOOD_SLEEPY && buddy.zzzPhase > 0) - { + if (buddy.mood == MOOD_SLEEPY && buddy.zzzPhase > 0) { const char *zStr[] = {"z", "zz", "zzz"}; uint8_t zi = buddy.zzzPhase - 1; u8g2.setFont(u8g2_font_5x7_tr); @@ -639,152 +551,19 @@ void showBuddyScreen() 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"); } - } + // Tama need indicators + 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(); - - 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 && - buddy.mood != MOOD_WINK_L && buddy.mood != MOOD_WINK_R) - { - 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; - } - - // Saccadic movement — fast when far (3 px/tick), slow when close (1 px/tick) - { - 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.mood == MOOD_SLEEPY && now >= buddy.nextZzz) - { - buddy.zzzPhase = (buddy.zzzPhase % 3) + 1; - buddy.nextZzz = now + 700; - } -} - // ── Config persistence ───────────────────────────────────────────────────────── -// WiFi: /wifi.json — credentials, NEVER served over HTTP -// Gestures:/config.json — gesture config, served via GET /config.json +static void loadWeatherConfig(); // forward decl (defined after saveConfig) + static void loadJsonFile(const char *path, JsonDocument &doc) { File f = LittleFS.open(path, "r"); @@ -796,13 +575,9 @@ static void loadJsonFile(const char *path, JsonDocument &doc) void loadAllConfig() { - if (!LittleFS.begin(true)) { - Serial.println("[Config] LittleFS mount failed"); - return; - } + if (!LittleFS.begin(true)) { Serial.println("[Config] LittleFS mount failed"); return; } - // WiFi from /wifi.json { "ssid": "...", "password": "..." } - { + { // WiFi from /wifi.json JsonDocument wdoc; loadJsonFile("/wifi.json", wdoc); strncpy(WIFI_SSID, wdoc["ssid"] | "", sizeof(WIFI_SSID) - 1); @@ -810,8 +585,7 @@ void loadAllConfig() Serial.printf("[Config] WiFi SSID: %s\n", WIFI_SSID); } - // Gestures from /config.json { "up": {...}, "down": {...}, ... } - { + { // Gestures from /config.json JsonDocument doc; loadJsonFile("/config.json", doc); for (uint8_t i = 0; i < NUM_GESTURES; i++) { @@ -825,10 +599,13 @@ void loadAllConfig() Serial.println("[Config] Gestures loaded"); } + { // Weather from /weather.json (LittleFS still mounted) + loadWeatherConfig(); + } + LittleFS.end(); } -// Saves ONLY gesture config — WiFi credentials are never written here void saveConfig() { JsonDocument doc; @@ -838,46 +615,180 @@ void saveConfig() doc[GNAME[i]]["action"] = gConfig[i].action; doc[GNAME[i]]["enabled"] = gConfig[i].enabled; } - - if (!LittleFS.begin(true)) { - Serial.println("[Config] LittleFS mount failed — save aborted"); - return; - } + if (!LittleFS.begin(false)) { Serial.println("[Config] LittleFS mount failed"); return; } File f = LittleFS.open("/config.json", "w"); - if (!f) { - Serial.println("[Config] Cannot open /config.json for writing"); - LittleFS.end(); - return; - } + if (!f) { LittleFS.end(); return; } serializeJsonPretty(doc, f); f.close(); LittleFS.end(); - Serial.println("[Config] Gestures saved to /config.json"); + Serial.println("[Config] Gestures saved"); +} + +// ── Weather config persistence ──────────────────────────────────────────────── +static void loadWeatherConfig() +{ + JsonDocument doc; + loadJsonFile("/weather.json", doc); + strncpy(weatherCfg.city, doc["city"] | "", sizeof(weatherCfg.city) - 1); + weatherCfg.city[sizeof(weatherCfg.city) - 1] = '\0'; + weatherCfg.intervalSec = doc["interval"] | 300; + weatherCfg.durationSec = doc["duration"] | 10; + Serial.printf("[Weather] Loaded: city='%s' interval=%ds duration=%ds\n", + weatherCfg.city, weatherCfg.intervalSec, weatherCfg.durationSec); +} + +static void saveWeatherConfig() +{ + Serial.printf("[Weather] Saving: city='%s' interval=%d duration=%d\n", + weatherCfg.city, weatherCfg.intervalSec, weatherCfg.durationSec); + JsonDocument doc; + doc["city"] = weatherCfg.city; + doc["interval"] = weatherCfg.intervalSec; + doc["duration"] = weatherCfg.durationSec; + if (!LittleFS.begin(false)) { + Serial.println("[Weather] SAVE FAILED: LittleFS mount error"); + return; + } + File f = LittleFS.open("/weather.json", "w"); + if (!f) { + Serial.println("[Weather] SAVE FAILED: cannot open /weather.json"); + LittleFS.end(); + return; + } + size_t written = serializeJsonPretty(doc, f); + f.close(); + LittleFS.end(); + Serial.printf("[Weather] Config saved (%u bytes)\n", written); +} + +// ── Weather fetch (FreeRTOS task) ───────────────────────────────────────────── +// WMO weather interpretation codes (Open-Meteo) +static WeatherIcon wmoCodeToIcon(int code) +{ + if (code == 0) return WICON_SUN; + if (code <= 2) return WICON_CLOUD_SUN; + if (code <= 48) return WICON_CLOUD; // 3=overcast, 45/48=fog + if (code <= 67) return WICON_RAIN; // 51-55 drizzle, 61-67 rain + if (code <= 77) return WICON_SNOW; // 71-77 snow + if (code <= 82) return WICON_RAIN; // 80-82 rain showers + if (code <= 86) return WICON_SNOW; // 85-86 snow showers + return WICON_THUNDER; // 95, 96, 99 thunderstorm +} + +static void urlEncode(const char *in, char *out, size_t outSize) +{ + static const char *hex = "0123456789ABCDEF"; + size_t oi = 0; + for (size_t i = 0; in[i] && oi + 4 < outSize; i++) { + uint8_t c = (uint8_t)in[i]; + if (c == ' ') { out[oi++] = '+'; } + else if (c > 0x7E || c < 0x20) { + out[oi++] = '%'; out[oi++] = hex[c >> 4]; out[oi++] = hex[c & 0xF]; + } else { out[oi++] = (char)c; } + } + out[oi] = '\0'; +} + +struct WeatherFetchParams { char city[64]; }; + +static void weatherFetchTask(void *pvParam) +{ + WeatherFetchParams *p = (WeatherFetchParams *)pvParam; + + // Step 1: Geocoding — city name → lat/lon + char encodedCity[192]; + urlEncode(p->city, encodedCity, sizeof(encodedCity)); + + char geoUrl[256]; + snprintf(geoUrl, sizeof(geoUrl), + "http://geocoding-api.open-meteo.com/v1/search" + "?name=%s&count=1&language=pl&format=json", + encodedCity); + + HTTPClient http; + http.begin(geoUrl); + http.setTimeout(8000); + int code = http.GET(); + if (code != 200) { + Serial.printf("[Weather] Geocoding HTTP %d\n", code); + http.end(); delete p; vTaskDelete(NULL); return; + } + + float lat = 0.0f, lon = 0.0f; + { + String body = http.getString(); + http.end(); + JsonDocument geoDoc; + if (deserializeJson(geoDoc, body) != DeserializationError::Ok || + !geoDoc["results"].is() || + geoDoc["results"].as().size() == 0) { + Serial.printf("[Weather] City '%s' not found\n", p->city); + delete p; vTaskDelete(NULL); return; + } + lat = geoDoc["results"][0]["latitude"] | 0.0f; + lon = geoDoc["results"][0]["longitude"] | 0.0f; + } + + // Step 2: Current weather forecast + char fcUrl[256]; + snprintf(fcUrl, sizeof(fcUrl), + "http://api.open-meteo.com/v1/forecast" + "?latitude=%.4f&longitude=%.4f" + "¤t=temperature_2m,surface_pressure,weather_code", + lat, lon); + + http.begin(fcUrl); + http.setTimeout(8000); + code = http.GET(); + if (code == 200) { + String body = http.getString(); + JsonDocument doc; + if (deserializeJson(doc, body) == DeserializationError::Ok) { + weatherData.temp = (int8_t)(float)doc["current"]["temperature_2m"]; + weatherData.pressure = (int16_t)(float)doc["current"]["surface_pressure"]; + int wcode = doc["current"]["weather_code"] | 0; + weatherData.icon = wmoCodeToIcon(wcode); + weatherData.valid = true; + Serial.printf("[Weather] %s (%.3f,%.3f): %d\xc2\xb0""C %dhPa icon=%d (WMO %d)\n", + p->city, lat, lon, + (int)weatherData.temp, (int)weatherData.pressure, + (int)weatherData.icon, wcode); + } + } else { + Serial.printf("[Weather] Forecast HTTP %d\n", code); + } + http.end(); + delete p; + vTaskDelete(NULL); +} + +static void triggerWeatherFetch() +{ + if (strlen(weatherCfg.city) == 0) return; + WeatherFetchParams *p = new WeatherFetchParams; + strncpy(p->city, weatherCfg.city, sizeof(p->city) - 1); + p->city[sizeof(p->city) - 1] = '\0'; + if (xTaskCreate(weatherFetchTask, "wthr", 10240, p, 1, NULL) != pdPASS) { + Serial.println("[Weather] Task create failed"); + delete p; + } } // ── Async webhook via FreeRTOS task ─────────────────────────────────────────── -struct WebhookTask -{ - char url[128]; - char gesture[16]; -}; +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); } @@ -885,16 +796,11 @@ static void webhookTaskFn(void *pvParam) void fireWebhook(uint8_t gestureIdx) { const GestureConfig &cfg = gConfig[gestureIdx]; - if (!cfg.enabled || strlen(cfg.url) == 0) - return; - - // Fire async + if (!cfg.enabled || strlen(cfg.url) == 0) return; WebhookTask *t = new WebhookTask; - strncpy(t->url, cfg.url, sizeof(t->url) - 1); + 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) - { + if (xTaskCreate(webhookTaskFn, "wh", 4096, t, 1, NULL) != pdPASS) { Serial.println("[Webhook] Task create failed"); delete t; } @@ -932,89 +838,169 @@ static String buildHtml() html += WIFI_SSID; html += ""; html += "
" - ""; + "
GestURL webhokaNastrójAkcjaON
"; - for (uint8_t i = 0; i < NUM_GESTURES; i++) - { - html += "
GestURL webhokaNastrojAkcjaON
"; - html += GNAME[i]; - html += "" + "" + "" + "" + "" + "" + "" + "
Miasto
Co ile sekund
Pokazuj przez (s)
" + "" + "" + "
" + "" + + "
" + "

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

" ""; return html; } void setupHttpServer() { - // GET / — config UI httpServer.on("/", HTTP_GET, [](AsyncWebServerRequest *req) - { req->send(200, "text/html; charset=utf-8", buildHtml()); }); + { req->send(200, "text/html; charset=utf-8", buildHtml()); }); - // POST /save — form submission - httpServer.on("/save", HTTP_POST, [](AsyncWebServerRequest *req) - { + httpServer.on("/save", HTTP_POST, [](AsyncWebServerRequest *req) { for (uint8_t i = 0; i < NUM_GESTURES; i++) { String urlKey = String("url_") + GKEY[i]; String moodKey = String("mood_") + GKEY[i]; + String actKey = String("act_") + GKEY[i]; String enKey = String("en_") + GKEY[i]; - if (req->hasParam(urlKey, true)) + if (req->hasParam(urlKey, true)) strncpy(gConfig[i].url, req->getParam(urlKey, true)->value().c_str(), sizeof(gConfig[i].url) - 1); if (req->hasParam(moodKey, true)) - gConfig[i].mood = (uint8_t)req->getParam(moodKey, true)->value().toInt(); - String actKey = String("act_") + GKEY[i]; - if (req->hasParam(actKey, true)) - gConfig[i].action = (uint8_t)req->getParam(actKey, true)->value().toInt(); + gConfig[i].mood = (uint8_t)req->getParam(moodKey, true)->value().toInt(); + if (req->hasParam(actKey, true)) + gConfig[i].action = (uint8_t)req->getParam(actKey, true)->value().toInt(); gConfig[i].enabled = req->hasParam(enKey, true); } saveConfig(); - req->redirect("/"); }); + req->redirect("/"); + }); - // GET /api/config — JSON read - httpServer.on("/api/config", HTTP_GET, [](AsyncWebServerRequest *req) - { + httpServer.on("/api/config", HTTP_GET, [](AsyncWebServerRequest *req) { JsonDocument doc; for (uint8_t i = 0; i < NUM_GESTURES; i++) { doc[GNAME[i]]["url"] = gConfig[i].url; @@ -1022,40 +1008,33 @@ void setupHttpServer() doc[GNAME[i]]["action"] = gConfig[i].action; doc[GNAME[i]]["enabled"] = gConfig[i].enabled; } - String out; - serializeJsonPretty(doc, out); - req->send(200, "application/json", out); }); + String out; serializeJsonPretty(doc, out); + req->send(200, "application/json", out); + }); - // POST /api/config — JSON write (body in separate callback) - httpServer.on("/api/config", HTTP_POST, [](AsyncWebServerRequest *req) {}, // request handler — body not ready here - nullptr, // upload handler - [](AsyncWebServerRequest *req, uint8_t *data, size_t len, size_t index, size_t total) - { - String body; - body.reserve(len); + httpServer.on("/api/config", HTTP_POST, + [](AsyncWebServerRequest *req) {}, + nullptr, + [](AsyncWebServerRequest *req, uint8_t *data, size_t len, size_t, size_t) { + String body; body.reserve(len); for (size_t i = 0; i < len; i++) body += (char)data[i]; - JsonDocument doc; if (deserializeJson(doc, body) != DeserializationError::Ok) { - req->send(400, "application/json", "{\"error\":\"invalid JSON\"}"); - return; + req->send(400, "application/json", "{\"error\":\"invalid JSON\"}"); return; } for (uint8_t i = 0; i < NUM_GESTURES; i++) { if (!doc[GNAME[i]].is()) continue; JsonObject g = doc[GNAME[i]]; if (g["url"].is()) strncpy(gConfig[i].url, g["url"] | "", sizeof(gConfig[i].url) - 1); - if (g["mood"].is()) - gConfig[i].mood = (uint8_t)(int)g["mood"]; - if (g["action"].is()) - gConfig[i].action = (uint8_t)(int)g["action"]; - if (g["enabled"].is()) - gConfig[i].enabled = (bool)g["enabled"]; + if (g["mood"].is()) gConfig[i].mood = (uint8_t)(int)g["mood"]; + if (g["action"].is()) gConfig[i].action = (uint8_t)(int)g["action"]; + if (g["enabled"].is()) gConfig[i].enabled = (bool)g["enabled"]; } saveConfig(); - req->send(200, "application/json", "{\"ok\":true}"); }); + req->send(200, "application/json", "{\"ok\":true}"); + }); - // GET /config.json — gesture config only, built from RAM (WiFi NEVER exposed) httpServer.on("/config.json", HTTP_GET, [](AsyncWebServerRequest *req) { JsonDocument doc; for (uint8_t i = 0; i < NUM_GESTURES; i++) { @@ -1064,11 +1043,97 @@ void setupHttpServer() doc[GNAME[i]]["action"] = gConfig[i].action; doc[GNAME[i]]["enabled"] = gConfig[i].enabled; } - String out; - serializeJsonPretty(doc, out); + String out; serializeJsonPretty(doc, out); req->send(200, "application/json", out); }); + // GET /api/tama — current needs state + httpServer.on("/api/tama", HTTP_GET, [](AsyncWebServerRequest *req) { + char buf[64]; + snprintf(buf, sizeof(buf), + "{\"hunger\":%d,\"happiness\":%d,\"hygiene\":%d}", + tama.hunger, tama.happiness, tama.hygiene); + req->send(200, "application/json", buf); + }); + + // POST /api/tama/feed — nakarm + httpServer.on("/api/tama/feed", HTTP_POST, [](AsyncWebServerRequest *req) { + uint32_t now = millis(); + tamaFeed(tama); + setBuddyMood(buddy, MOOD_HAPPY, now, 4000); + overlayAction = ACTION_FEED; + overlayUntil = now + 3000; + Serial.printf("[Web] Feed | H:%d P:%d C:%d\n", + tama.hunger, tama.happiness, tama.hygiene); + char buf[64]; + snprintf(buf, sizeof(buf), + "{\"ok\":true,\"msg\":\"Mniam! Glod: %d%%\",\"hunger\":%d}", + tama.hunger, tama.hunger); + req->send(200, "application/json", buf); + }); + + // POST /api/tama/play — pobaw sie + httpServer.on("/api/tama/play", HTTP_POST, [](AsyncWebServerRequest *req) { + uint32_t now = millis(); + tamaPlay(tama); + setBuddyMood(buddy, MOOD_EXCITED, now, 4000); + overlayAction = ACTION_PLAY; + overlayUntil = now + 3000; + Serial.printf("[Web] Play | H:%d P:%d C:%d\n", + tama.hunger, tama.happiness, tama.hygiene); + char buf[64]; + snprintf(buf, sizeof(buf), + "{\"ok\":true,\"msg\":\"Grajmy! Radosc: %d%%\",\"happiness\":%d}", + tama.happiness, tama.happiness); + req->send(200, "application/json", buf); + }); + + // POST /api/tama/clean — umyj + httpServer.on("/api/tama/clean", HTTP_POST, [](AsyncWebServerRequest *req) { + uint32_t now = millis(); + tamaClean(tama); + setBuddyMood(buddy, MOOD_SURPRISED, now, 3000); + overlayAction = ACTION_CLEAN; + overlayUntil = now + 3000; + Serial.printf("[Web] Clean | H:%d P:%d C:%d\n", + tama.hunger, tama.happiness, tama.hygiene); + char buf[64]; + snprintf(buf, sizeof(buf), + "{\"ok\":true,\"msg\":\"Mycie! Czystosc: %d%%\",\"hygiene\":%d}", + tama.hygiene, tama.hygiene); + req->send(200, "application/json", buf); + }); + + // GET /api/weather — current weather data + config + httpServer.on("/api/weather", HTTP_GET, [](AsyncWebServerRequest *req) { + char buf[180]; + snprintf(buf, sizeof(buf), + "{\"valid\":%s,\"temp\":%d,\"pressure\":%d,\"icon\":%d," + "\"city\":\"%s\",\"interval\":%d,\"duration\":%d}", + weatherData.valid ? "true" : "false", + (int)weatherData.temp, (int)weatherData.pressure, + (int)weatherData.icon, + weatherCfg.city, + (int)weatherCfg.intervalSec, (int)weatherCfg.durationSec); + req->send(200, "application/json", buf); + }); + + // POST /weather/save — update weather config from HTML form + httpServer.on("/weather/save", HTTP_POST, [](AsyncWebServerRequest *req) { + if (req->hasParam("city", true)) + strncpy(weatherCfg.city, req->getParam("city", true)->value().c_str(), + sizeof(weatherCfg.city) - 1); + if (req->hasParam("interval", true)) + weatherCfg.intervalSec = (uint16_t)req->getParam("interval", true)->value().toInt(); + if (req->hasParam("duration", true)) + weatherCfg.durationSec = (uint16_t)req->getParam("duration", true)->value().toInt(); + saveWeatherConfig(); + // Reset show timer so new config takes effect immediately on next cycle + weatherNextShow = millis() + weatherCfg.intervalSec * 1000UL; + triggerWeatherFetch(); // fetch right away with new city/key + req->redirect("/"); + }); + httpServer.begin(); Serial.printf("[HTTP] Server na http://%s/\n", WiFi.localIP().toString().c_str()); } @@ -1090,24 +1155,17 @@ void connectWiFi() WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS); uint8_t attempts = 0; - while (WiFi.status() != WL_CONNECTED && attempts < 20) - { - delay(500); - Serial.print("."); - attempts++; + while (WiFi.status() != WL_CONNECTED && attempts < 20) { + delay(500); Serial.print("."); attempts++; } Serial.println(); - if (WiFi.status() == WL_CONNECTED) - { + if (WiFi.status() == WL_CONNECTED) { String ip = WiFi.localIP().toString(); Serial.printf("[WiFi] IP: %s\n", ip.c_str()); splash("WiFi OK", ip.c_str(), ""); - // NTP sync — Poland CET/CEST configTzTime("CET-1CEST,M3.5.0,M10.5.0/3", "pool.ntp.org", "time.google.com"); Serial.println("[NTP] Sync..."); - } - else - { + } else { Serial.println("[WiFi] Blad polaczenia"); splash("WiFi BLAD", "Sprawdz SSID", ""); } @@ -1115,53 +1173,33 @@ void connectWiFi() } // ── Gesture handling ────────────────────────────────────────────────────────── -// Maps RevEng Gesture enum to our 0-8 index static int gestureIndex(Gesture g) { return (int)g - 1; } -// Default mood reactions when no webhook mood is configured -static const Mood DEFAULT_MOOD[NUM_GESTURES] = { - MOOD_HAPPY, // up - MOOD_SAD, // down - MOOD_SURPRISED, // left - MOOD_SURPRISED, // right - MOOD_SLEEPY, // forward - MOOD_ANGRY, // backward - MOOD_EXCITED, // clockwise - MOOD_NORMAL, // anticlockwise - MOOD_EXCITED, // wave -}; -static const uint32_t DEFAULT_MOOD_DUR[NUM_GESTURES] = { - 2000, 2000, 1500, 1500, 0, 3000, 2000, 0, 2000}; - void executeAction(uint8_t idx) { Action a = (Action)gConfig[idx].action; - if (a == ACTION_NONE) - return; + if (a == ACTION_NONE) return; + uint32_t now = millis(); uint32_t dur = 8000; switch (a) { case ACTION_FEED: - tama.hunger = (tama.hunger >= 30) ? tama.hunger - 30 : 0; - setBuddyMood(MOOD_HAPPY, 4000); - dur = 3000; - break; + tamaFeed(tama); + setBuddyMood(buddy, MOOD_HAPPY, now, 4000); + dur = 3000; break; case ACTION_PLAY: - tama.happiness = (uint8_t)min((int)tama.happiness + 25, 100); - setBuddyMood(MOOD_EXCITED, 4000); - dur = 3000; - break; + tamaPlay(tama); + setBuddyMood(buddy, MOOD_EXCITED, now, 4000); + dur = 3000; break; case ACTION_CLEAN: - tama.hygiene = (uint8_t)min((int)tama.hygiene + 40, 100); - setBuddyMood(MOOD_SURPRISED, 3000); - dur = 3000; - break; - default: - break; + tamaClean(tama); + setBuddyMood(buddy, MOOD_SURPRISED, now, 3000); + dur = 3000; break; + default: break; } overlayAction = a; - overlayUntil = millis() + dur; + overlayUntil = now + dur; Serial.printf("[Action] %s -> %s | H:%d P:%d C:%d\n", GNAME[idx], ACTION_LABELS[a], tama.hunger, tama.happiness, tama.hygiene); @@ -1169,27 +1207,22 @@ void executeAction(uint8_t idx) void handleGesture(Gesture g) { - if (g == GES_NONE) - return; - buddy.lastEvent = millis(); + if (g == GES_NONE) return; + uint32_t now = millis(); + buddy.lastEvent = now; if (g_dimmed) setDim(false); int idx = gestureIndex(g); - if (idx < 0 || idx >= NUM_GESTURES) - return; + if (idx < 0 || idx >= NUM_GESTURES) return; Serial.printf("[Gest] %s\n", GNAME[idx]); - - // Execute action (e.g. show datetime) executeAction(idx); - // Set mood: use configured mood if set, otherwise default for this gesture if (gConfig[idx].mood > 0) - setBuddyMood((Mood)gConfig[idx].mood, 4000); + setBuddyMood(buddy, (Mood)gConfig[idx].mood, now, 4000); else - setBuddyMood(DEFAULT_MOOD[idx], DEFAULT_MOOD_DUR[idx]); + setBuddyMood(buddy, DEFAULT_MOOD[idx], now, DEFAULT_MOOD_DUR[idx]); - // Fire webhook if URL configured if (gConfig[idx].enabled && strlen(gConfig[idx].url) > 0) fireWebhook(idx); } @@ -1199,49 +1232,36 @@ void setup() { Serial.begin(115200); delay(500); - u8g2.begin(); splash("Desk Buddy", "Budze sie...", ""); delay(600); loadAllConfig(); - initBuddy(); - initTama(); - // Single I2C bus for both devices: SDA=GPIO22, SCL=GPIO23 + uint32_t t0 = millis(); + initBuddy(buddy, t0); + initTama(tama, t0); + Wire.begin(22, 23); - if (sensor.begin(&Wire)) - Serial.println("[PAJ7620] OK"); - else - Serial.println("[PAJ7620] Nie znaleziono"); + if (sensor.begin(&Wire)) Serial.println("[PAJ7620] OK"); + else Serial.println("[PAJ7620] Nie znaleziono"); connectWiFi(); - - if (WiFi.status() == WL_CONNECTED) - setupHttpServer(); + if (WiFi.status() == WL_CONNECTED) setupHttpServer(); } // ── Loop ────────────────────────────────────────────────────────────────────── -// -// ESP32-C6 is single-core. U8g2 SW I2C busy-waits ~40 ms per sendBuffer() -// (delayMicroseconds inside bit-bang loop), starving the WiFi FreeRTOS task. -// Fix: draw infrequently + vTaskDelay(1) after each draw to unblock scheduler. -// void loop() { uint32_t now = millis(); - // Gesture polling (every 500 ms) static uint32_t lastGesture = 0; - if (now - lastGesture >= 500) - { + if (now - lastGesture >= 500) { lastGesture = now; Gesture g = sensor.readGesture(); - if (g != GES_NONE) - handleGesture(g); + if (g != GES_NONE) handleGesture(g); } - // Night dim: 00–05, after 5 s idle → display off static uint32_t lastDimCheck = 0; if (now - lastDimCheck >= 1000) { lastDimCheck = now; @@ -1251,35 +1271,40 @@ void loop() setDim(night && idle); } - // WiFi keepalive static uint32_t lastWifi = 0; - if (now - lastWifi > 30000) - { + if (now - lastWifi > 30000) { lastWifi = now; - if (WiFi.status() != WL_CONNECTED) - WiFi.reconnect(); + if (WiFi.status() != WL_CONNECTED) WiFi.reconnect(); + } + + // Weather: show face periodically, re-fetch at most every 60 s + if (weatherCfg.intervalSec > 0 && now >= weatherNextShow) { + weatherShowUntil = now + weatherCfg.durationSec * 1000UL; + weatherNextShow = now + weatherCfg.intervalSec * 1000UL; + if (!weatherData.valid || now - weatherLastFetch >= 60000UL) { + weatherLastFetch = now; + triggerWeatherFetch(); + } } - // Tamagotchi needs (every 10 s — cheap tick check) static uint32_t lastTama = 0; - if (now - lastTama >= 10000) - { + if (now - lastTama >= 10000) { lastTama = now; - updateTama(); + Mood tamaOverride = updateTama(tama, buddy, now); + if (tamaOverride != MOOD_NORMAL) + buddy.mood = tamaOverride; + else if (buddy.mood == MOOD_HUNGRY || buddy.mood == MOOD_DIRTY || buddy.mood == MOOD_PLAYFUL) + buddy.mood = MOOD_NORMAL; // needs satisfied → back to normal } - // Buddy animation state (every 50 ms — no drawing here) static uint32_t lastAnim = 0; - if (now - lastAnim >= 50) - { + if (now - lastAnim >= 50) { lastAnim = now; - updateBuddyAnim(); + updateBuddyAnim(buddy, now, arduinoRng); } - // OLED draw — 50 ms (~20 fps), HW I2C is non-blocking (DMA+interrupts) static uint32_t lastDraw = 0; - if (now - lastDraw >= 50) - { + if (now - lastDraw >= 50) { lastDraw = now; showBuddyScreen(); } diff --git a/test/native/test_buddy/test_buddy.cpp b/test/native/test_buddy/test_buddy.cpp new file mode 100644 index 0000000..d5a78e6 --- /dev/null +++ b/test/native/test_buddy/test_buddy.cpp @@ -0,0 +1,220 @@ +#include +#include "BuddyLogic.h" + +static int32_t fixed_rng(int32_t lo, int32_t hi) { (void)hi; return lo; } + +void setUp() {} +void tearDown() {} + +// ── initBuddy ────────────────────────────────────────────────────────────── + +void test_initBuddy_sets_open_blink() { + BuddyState b{}; + initBuddy(b, 0); + TEST_ASSERT_EQUAL(BLINK_OPEN, b.blinkState); + TEST_ASSERT_EQUAL(EYE_RY, b.blinkRy); +} + +void test_initBuddy_sets_normal_mood() { + BuddyState b{}; + initBuddy(b, 0); + TEST_ASSERT_EQUAL(MOOD_NORMAL, b.mood); +} + +void test_initBuddy_timestamps_relative_to_now() { + BuddyState b{}; + initBuddy(b, 1000); + TEST_ASSERT_EQUAL(1000u, b.lastEvent); + TEST_ASSERT_EQUAL(1000u + 3000, b.nextBlink); + TEST_ASSERT_EQUAL(1000u + 2000, b.nextLook); +} + +// ── setBuddyMood ─────────────────────────────────────────────────────────── + +void test_setBuddyMood_no_duration_sets_revertAt_zero() { + BuddyState b{}; + initBuddy(b, 0); + setBuddyMood(b, MOOD_HAPPY, 1000, 0); + TEST_ASSERT_EQUAL(0u, b.revertAt); +} + +void test_setBuddyMood_with_duration_sets_revertAt() { + BuddyState b{}; + initBuddy(b, 0); + setBuddyMood(b, MOOD_HAPPY, 1000, 4000); + TEST_ASSERT_EQUAL(5000u, b.revertAt); +} + +void test_setBuddyMood_updates_lastEvent() { + BuddyState b{}; + initBuddy(b, 0); + setBuddyMood(b, MOOD_HAPPY, 5000); + TEST_ASSERT_EQUAL(5000u, b.lastEvent); +} + +void test_setBuddyMood_happy_sets_pupil_target_up() { + BuddyState b{}; + initBuddy(b, 0); + setBuddyMood(b, MOOD_HAPPY, 0); + TEST_ASSERT_EQUAL( 0, b.pupilTargetDx); + TEST_ASSERT_EQUAL(-2, b.pupilTargetDy); +} + +void test_setBuddyMood_sad_sets_pupil_target_down() { + BuddyState b{}; + initBuddy(b, 0); + setBuddyMood(b, MOOD_SAD, 0); + TEST_ASSERT_EQUAL(0, b.pupilTargetDx); + TEST_ASSERT_EQUAL(4, b.pupilTargetDy); +} + +// ── updateBuddyAnim — mood revert ───────────────────────────────────────── + +void test_updateBuddyAnim_reverts_mood_when_revertAt_passed() { + BuddyState b{}; + initBuddy(b, 0); + setBuddyMood(b, MOOD_HAPPY, 0, 4000); // revertAt = 4000 + updateBuddyAnim(b, 5000, fixed_rng); // now > revertAt + TEST_ASSERT_EQUAL(MOOD_NORMAL, b.mood); + TEST_ASSERT_EQUAL(0u, b.revertAt); +} + +void test_updateBuddyAnim_does_not_revert_before_revertAt() { + BuddyState b{}; + initBuddy(b, 0); + setBuddyMood(b, MOOD_HAPPY, 0, 4000); + updateBuddyAnim(b, 3999, fixed_rng); + TEST_ASSERT_EQUAL(MOOD_HAPPY, b.mood); +} + +void test_updateBuddyAnim_goes_sleepy_after_idle() { + BuddyState b{}; + initBuddy(b, 0); // lastEvent = 0, mood = NORMAL + updateBuddyAnim(b, 300001, fixed_rng); + TEST_ASSERT_EQUAL(MOOD_SLEEPY, b.mood); +} + +void test_updateBuddyAnim_no_sleepy_before_idle() { + BuddyState b{}; + initBuddy(b, 0); + updateBuddyAnim(b, 299999, fixed_rng); + TEST_ASSERT_EQUAL(MOOD_NORMAL, b.mood); +} + +// ── Blink state machine ──────────────────────────────────────────────────── + +void test_blink_open_to_closing_when_nextBlink_passed() { + BuddyState b{}; + initBuddy(b, 0); // nextBlink = 3000 + updateBuddyAnim(b, 3001, fixed_rng); + TEST_ASSERT_EQUAL(BLINK_CLOSING, b.blinkState); +} + +void test_blink_closing_decrements_blinkRy() { + BuddyState b{}; + initBuddy(b, 0); + b.blinkState = BLINK_CLOSING; + b.blinkRy = 15; + updateBuddyAnim(b, 0, fixed_rng); + TEST_ASSERT_EQUAL(11, b.blinkRy); + TEST_ASSERT_EQUAL(BLINK_CLOSING, b.blinkState); +} + +void test_blink_closing_reaches_closed_when_small() { + BuddyState b{}; + initBuddy(b, 0); + b.blinkState = BLINK_CLOSING; + b.blinkRy = 3; + updateBuddyAnim(b, 0, fixed_rng); + TEST_ASSERT_EQUAL(BLINK_CLOSED, b.blinkState); + TEST_ASSERT_EQUAL(1, b.blinkRy); +} + +void test_blink_closed_ticks_to_opening() { + BuddyState b{}; + initBuddy(b, 0); + b.blinkState = BLINK_CLOSED; + b.closedTicks = 2; + updateBuddyAnim(b, 0, fixed_rng); + TEST_ASSERT_EQUAL(BLINK_OPENING, b.blinkState); +} + +void test_blink_opening_increments_blinkRy() { + BuddyState b{}; + initBuddy(b, 0); + b.blinkState = BLINK_OPENING; + b.blinkRy = 7; // 7+4=11 < EYE_RY(15), stays OPENING + updateBuddyAnim(b, 0, fixed_rng); + TEST_ASSERT_EQUAL(11, b.blinkRy); + TEST_ASSERT_EQUAL(BLINK_OPENING, b.blinkState); +} + +void test_blink_opening_returns_to_open_at_eye_ry() { + BuddyState b{}; + initBuddy(b, 0); + b.blinkState = BLINK_OPENING; + b.blinkRy = 13; // 13+4=17 >= EYE_RY(15) → OPEN + updateBuddyAnim(b, 0, fixed_rng); + TEST_ASSERT_EQUAL(BLINK_OPEN, b.blinkState); + TEST_ASSERT_EQUAL(EYE_RY, b.blinkRy); +} + +void test_surprised_does_not_blink() { + BuddyState b{}; + initBuddy(b, 0); + setBuddyMood(b, MOOD_SURPRISED, 0); + b.nextBlink = 0; // past-due + updateBuddyAnim(b, 1000, fixed_rng); + TEST_ASSERT_EQUAL(BLINK_OPEN, b.blinkState); // no transition + TEST_ASSERT_EQUAL(EYE_RY, b.blinkRy); // stays full +} + +// ── Pupil saccade ────────────────────────────────────────────────────────── + +void test_pupil_moves_fast_when_far() { + BuddyState b{}; + initBuddy(b, 0); + b.pupilDx = 0; + b.pupilTargetDx = 8; // |delta| = 8 >= 4 → step 3 + b.pupilDy = b.pupilTargetDy = 0; + updateBuddyAnim(b, 0, fixed_rng); + TEST_ASSERT_EQUAL(3, b.pupilDx); +} + +void test_pupil_moves_slow_when_close() { + BuddyState b{}; + initBuddy(b, 0); + b.pupilDx = 0; + b.pupilTargetDx = 2; // |delta| = 2 < 4 → step 1 + b.pupilDy = b.pupilTargetDy = 0; + updateBuddyAnim(b, 0, fixed_rng); + TEST_ASSERT_EQUAL(1, b.pupilDx); +} + +// ── main ────────────────────────────────────────────────────────────────── + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_initBuddy_sets_open_blink); + RUN_TEST(test_initBuddy_sets_normal_mood); + RUN_TEST(test_initBuddy_timestamps_relative_to_now); + RUN_TEST(test_setBuddyMood_no_duration_sets_revertAt_zero); + RUN_TEST(test_setBuddyMood_with_duration_sets_revertAt); + RUN_TEST(test_setBuddyMood_updates_lastEvent); + RUN_TEST(test_setBuddyMood_happy_sets_pupil_target_up); + RUN_TEST(test_setBuddyMood_sad_sets_pupil_target_down); + RUN_TEST(test_updateBuddyAnim_reverts_mood_when_revertAt_passed); + RUN_TEST(test_updateBuddyAnim_does_not_revert_before_revertAt); + RUN_TEST(test_updateBuddyAnim_goes_sleepy_after_idle); + RUN_TEST(test_updateBuddyAnim_no_sleepy_before_idle); + RUN_TEST(test_blink_open_to_closing_when_nextBlink_passed); + RUN_TEST(test_blink_closing_decrements_blinkRy); + RUN_TEST(test_blink_closing_reaches_closed_when_small); + RUN_TEST(test_blink_closed_ticks_to_opening); + RUN_TEST(test_blink_opening_increments_blinkRy); + RUN_TEST(test_blink_opening_returns_to_open_at_eye_ry); + RUN_TEST(test_surprised_does_not_blink); + RUN_TEST(test_pupil_moves_fast_when_far); + RUN_TEST(test_pupil_moves_slow_when_close); + return UNITY_END(); +} diff --git a/test/native/test_tama/test_tama.cpp b/test/native/test_tama/test_tama.cpp new file mode 100644 index 0000000..f7ab13e --- /dev/null +++ b/test/native/test_tama/test_tama.cpp @@ -0,0 +1,203 @@ +#include +#include "TamaLogic.h" + +void setUp() {} +void tearDown() {} + +// Helper: build a minimal BuddyState for updateTama calls +static BuddyState makeBuddy(uint32_t revertAt = 0, Mood m = MOOD_NORMAL) { + BuddyState b{}; + b.revertAt = revertAt; + b.mood = m; + return b; +} + +// ── initTama ────────────────────────────────────────────────────────────── + +void test_initTama_sets_initial_values() { + TamaState t{}; + initTama(t, 0); + TEST_ASSERT_EQUAL(10, t.hunger); + TEST_ASSERT_EQUAL(80, t.happiness); + TEST_ASSERT_EQUAL(90, t.hygiene); +} + +void test_initTama_sets_tick_timestamps() { + TamaState t{}; + initTama(t, 1000); + TEST_ASSERT_EQUAL(1000u + 120000u, t.nextHungerTick); + TEST_ASSERT_EQUAL(1000u + 180000u, t.nextHappyTick); + TEST_ASSERT_EQUAL(1000u + 240000u, t.nextHygieneTick); +} + +// ── updateTama ticks ────────────────────────────────────────────────────── + +void test_updateTama_increments_hunger_on_tick() { + TamaState t{}; + initTama(t, 0); // nextHungerTick = 120000 + BuddyState b = makeBuddy(); + updateTama(t, b, 120000); + TEST_ASSERT_EQUAL(11, t.hunger); +} + +void test_updateTama_does_not_increment_hunger_before_tick() { + TamaState t{}; + initTama(t, 0); + BuddyState b = makeBuddy(); + updateTama(t, b, 119999); + TEST_ASSERT_EQUAL(10, t.hunger); +} + +void test_updateTama_caps_hunger_at_100() { + TamaState t{}; + initTama(t, 0); + t.hunger = 100; + BuddyState b = makeBuddy(); + updateTama(t, b, 120000); + TEST_ASSERT_EQUAL(100, t.hunger); +} + +void test_updateTama_decrements_happiness_on_tick() { + TamaState t{}; + initTama(t, 0); // nextHappyTick = 180000 + BuddyState b = makeBuddy(); + updateTama(t, b, 180000); + TEST_ASSERT_EQUAL(79, t.happiness); +} + +void test_updateTama_caps_happiness_at_zero() { + TamaState t{}; + initTama(t, 0); + t.happiness = 0; + BuddyState b = makeBuddy(); + updateTama(t, b, 180000); + TEST_ASSERT_EQUAL(0, t.happiness); +} + +void test_updateTama_decrements_hygiene_on_tick() { + TamaState t{}; + initTama(t, 0); // nextHygieneTick = 240000 + BuddyState b = makeBuddy(); + updateTama(t, b, 240000); + TEST_ASSERT_EQUAL(89, t.hygiene); +} + +// ── updateTama mood returns ─────────────────────────────────────────────── + +void test_updateTama_returns_hungry_when_hunger_high() { + TamaState t{}; + initTama(t, 0); + t.hunger = 80; + t.nextHungerTick = 999999; // prevent tick + BuddyState b = makeBuddy(); + TEST_ASSERT_EQUAL(MOOD_HUNGRY, updateTama(t, b, 0)); +} + +void test_updateTama_returns_dirty_when_hygiene_low() { + TamaState t{}; + initTama(t, 0); + t.hygiene = 20; + t.nextHygieneTick = 999999; + BuddyState b = makeBuddy(); + TEST_ASSERT_EQUAL(MOOD_DIRTY, updateTama(t, b, 0)); +} + +void test_updateTama_returns_playful_when_happiness_low() { + TamaState t{}; + initTama(t, 0); + t.happiness = 20; + t.nextHappyTick = 999999; + BuddyState b = makeBuddy(); + TEST_ASSERT_EQUAL(MOOD_PLAYFUL, updateTama(t, b, 0)); +} + +void test_updateTama_no_override_when_revertAt_set() { + TamaState t{}; + initTama(t, 0); + t.hunger = 90; + t.nextHungerTick = 999999; + BuddyState b = makeBuddy(5000); // revertAt != 0 → guard blocks + TEST_ASSERT_EQUAL(MOOD_NORMAL, updateTama(t, b, 0)); +} + +void test_updateTama_returns_normal_when_needs_ok() { + TamaState t{}; + initTama(t, 0); // hunger=10, happy=80, hygiene=90 — all fine + t.nextHungerTick = t.nextHappyTick = t.nextHygieneTick = 999999; + BuddyState b = makeBuddy(); + TEST_ASSERT_EQUAL(MOOD_NORMAL, updateTama(t, b, 0)); +} + +// ── tamaFeed ────────────────────────────────────────────────────────────── + +void test_tamaFeed_reduces_hunger_by_30() { + TamaState t{}; + t.hunger = 50; + tamaFeed(t); + TEST_ASSERT_EQUAL(20, t.hunger); +} + +void test_tamaFeed_floors_hunger_at_zero() { + TamaState t{}; + t.hunger = 10; + tamaFeed(t); + TEST_ASSERT_EQUAL(0, t.hunger); +} + +// ── tamaPlay ────────────────────────────────────────────────────────────── + +void test_tamaPlay_increases_happiness_by_25() { + TamaState t{}; + t.happiness = 50; + tamaPlay(t); + TEST_ASSERT_EQUAL(75, t.happiness); +} + +void test_tamaPlay_caps_happiness_at_100() { + TamaState t{}; + t.happiness = 90; + tamaPlay(t); + TEST_ASSERT_EQUAL(100, t.happiness); +} + +// ── tamaClean ───────────────────────────────────────────────────────────── + +void test_tamaClean_increases_hygiene_by_40() { + TamaState t{}; + t.hygiene = 50; + tamaClean(t); + TEST_ASSERT_EQUAL(90, t.hygiene); +} + +void test_tamaClean_caps_hygiene_at_100() { + TamaState t{}; + t.hygiene = 80; + tamaClean(t); + TEST_ASSERT_EQUAL(100, t.hygiene); +} + +// ── main ────────────────────────────────────────────────────────────────── + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_initTama_sets_initial_values); + RUN_TEST(test_initTama_sets_tick_timestamps); + RUN_TEST(test_updateTama_increments_hunger_on_tick); + RUN_TEST(test_updateTama_does_not_increment_hunger_before_tick); + RUN_TEST(test_updateTama_caps_hunger_at_100); + RUN_TEST(test_updateTama_decrements_happiness_on_tick); + RUN_TEST(test_updateTama_caps_happiness_at_zero); + RUN_TEST(test_updateTama_decrements_hygiene_on_tick); + RUN_TEST(test_updateTama_returns_hungry_when_hunger_high); + RUN_TEST(test_updateTama_returns_dirty_when_hygiene_low); + RUN_TEST(test_updateTama_returns_playful_when_happiness_low); + RUN_TEST(test_updateTama_no_override_when_revertAt_set); + RUN_TEST(test_updateTama_returns_normal_when_needs_ok); + RUN_TEST(test_tamaFeed_reduces_hunger_by_30); + RUN_TEST(test_tamaFeed_floors_hunger_at_zero); + RUN_TEST(test_tamaPlay_increases_happiness_by_25); + RUN_TEST(test_tamaPlay_caps_happiness_at_100); + RUN_TEST(test_tamaClean_increases_hygiene_by_40); + RUN_TEST(test_tamaClean_caps_hygiene_at_100); + return UNITY_END(); +}