feat: moods, emotions, needs

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