From 997fd3e563969e5f01c034d00786babc8c0009c4 Mon Sep 17 00:00:00 2001 From: Aleksander Cynarski Date: Sun, 7 Jun 2026 13:55:20 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20mapy=20bitowe=20z=20svg,=20dodanie=20po?= =?UTF-8?q?twierdzania=20akcji,=20reset=20urz=C4=85dzenia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Taskfile.yml | 5 + assets/svg/alert.svg | 1 + assets/svg/cloud.svg | 23 + lib/BuddyDomain/BuddyLogic.cpp | 5 +- lib/BuddyDomain/BuddyTypes.h | 3 +- lib/GestureConfig/GestureConfig.cpp | 5 +- lib/GestureConfig/GestureConfig.h | 6 +- src/HttpServer.cpp | 534 ++++++++++++++++++++ src/HttpServer.h | 29 ++ src/WeatherTypes.h | 20 + src/bitmaps/alert_xbm.h | 15 + src/bitmaps/cloud_small_xbm.h | 10 + src/bitmaps/cloud_xbm.h | 12 + src/main.cpp | 729 ++++++++++------------------ tools/svg_to_xbm.sh | 97 ++++ 15 files changed, 1027 insertions(+), 467 deletions(-) create mode 100644 assets/svg/alert.svg create mode 100644 assets/svg/cloud.svg create mode 100644 src/HttpServer.cpp create mode 100644 src/HttpServer.h create mode 100644 src/WeatherTypes.h create mode 100644 src/bitmaps/alert_xbm.h create mode 100644 src/bitmaps/cloud_small_xbm.h create mode 100644 src/bitmaps/cloud_xbm.h create mode 100755 tools/svg_to_xbm.sh diff --git a/Taskfile.yml b/Taskfile.yml index 7e30f75..ff448f9 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; } + convert-assets: + desc: Konwertuj SVG na XBM bitmapy (wymaga rsvg-convert + magick) + cmds: + - bash tools/svg_to_xbm.sh + test: desc: Uruchom testy jednostkowe (native, na Mac) cmds: diff --git a/assets/svg/alert.svg b/assets/svg/alert.svg new file mode 100644 index 0000000..844799e --- /dev/null +++ b/assets/svg/alert.svg @@ -0,0 +1 @@ + diff --git a/assets/svg/cloud.svg b/assets/svg/cloud.svg new file mode 100644 index 0000000..33f8e96 --- /dev/null +++ b/assets/svg/cloud.svg @@ -0,0 +1,23 @@ + + + + + + diff --git a/lib/BuddyDomain/BuddyLogic.cpp b/lib/BuddyDomain/BuddyLogic.cpp index d38fad4..e2c910f 100644 --- a/lib/BuddyDomain/BuddyLogic.cpp +++ b/lib/BuddyDomain/BuddyLogic.cpp @@ -48,9 +48,10 @@ void updateBuddyAnim(BuddyState &b, uint32_t now, RngFn rng) if (b.mood == MOOD_NORMAL && now - b.lastEvent > 300000UL) setBuddyMood(b, MOOD_SLEEPY, now, 0); - // Blink state machine (disabled for SURPRISED, EXCITED, WINKs) + // Blink state machine (disabled for SURPRISED, EXCITED, WINKs, ALERT) if (b.mood != MOOD_SURPRISED && b.mood != MOOD_EXCITED && - b.mood != MOOD_WINK_L && b.mood != MOOD_WINK_R) + b.mood != MOOD_WINK_L && b.mood != MOOD_WINK_R && + b.mood != MOOD_ALERT) { switch (b.blinkState) { case BLINK_OPEN: diff --git a/lib/BuddyDomain/BuddyTypes.h b/lib/BuddyDomain/BuddyTypes.h index 92443f2..1206474 100644 --- a/lib/BuddyDomain/BuddyTypes.h +++ b/lib/BuddyDomain/BuddyTypes.h @@ -13,7 +13,8 @@ enum Mood : uint8_t { MOOD_WINK_R, MOOD_HUNGRY, MOOD_PLAYFUL, - MOOD_DIRTY + MOOD_DIRTY, + MOOD_ALERT }; enum BlinkState : uint8_t { diff --git a/lib/GestureConfig/GestureConfig.cpp b/lib/GestureConfig/GestureConfig.cpp index eabf215..442829d 100644 --- a/lib/GestureConfig/GestureConfig.cpp +++ b/lib/GestureConfig/GestureConfig.cpp @@ -9,11 +9,12 @@ const char *GKEY[NUM_GESTURES] = { 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 ..."}; + "hungry :(", "playful :D", "dirty ...", "alert !"}; const char *ACTION_LABELS[] = { "-- brak --", "Data i godzina", "Status WiFi", - "Nakarm", "Pobaw sie", "Umyj", "Status tamagotchi"}; + "Nakarm", "Pobaw sie", "Umyj", "Status tamagotchi", + "Restart urzadzenia"}; const Mood DEFAULT_MOOD[NUM_GESTURES] = { MOOD_HAPPY, // up diff --git a/lib/GestureConfig/GestureConfig.h b/lib/GestureConfig/GestureConfig.h index 5d5223b..8f0aad4 100644 --- a/lib/GestureConfig/GestureConfig.h +++ b/lib/GestureConfig/GestureConfig.h @@ -3,7 +3,7 @@ #include "BuddyTypes.h" static const uint8_t NUM_GESTURES = 9; -static const uint8_t NUM_ACTIONS = 7; +static const uint8_t NUM_ACTIONS = 8; enum Action : uint8_t { ACTION_NONE = 0, @@ -12,7 +12,8 @@ enum Action : uint8_t { ACTION_FEED = 3, ACTION_PLAY = 4, ACTION_CLEAN = 5, - ACTION_STATUS = 6 + ACTION_STATUS = 6, + ACTION_RESTART = 7 }; struct GestureConfig { @@ -20,6 +21,7 @@ struct GestureConfig { uint8_t mood; // 0 = no change, 1-11 matches Mood enum uint8_t action; // Action enum bool enabled; + bool confirm; // require OLED confirmation before firing webhook/action }; extern const char *GNAME[NUM_GESTURES]; diff --git a/src/HttpServer.cpp b/src/HttpServer.cpp new file mode 100644 index 0000000..a94eaf2 --- /dev/null +++ b/src/HttpServer.cpp @@ -0,0 +1,534 @@ +#include "HttpServer.h" +#include +#include +#include +#include "BuddyLogic.h" +#include "TamaLogic.h" + +static HttpServerCtx* g_ctx = nullptr; + +// ── HTML builder ────────────────────────────────────────────────────────────── +static String buildHtml() +{ + String html = F( + "" + "" + "Desk Buddy" + "" + "

Desk Buddy

"); + + html += "
"; + html += "IP: "; + html += WiFi.localIP().toString(); + html += "  |  SSID: "; + html += g_ctx->wifiSsid; + html += ""; + html += "
" + "
" + ""; + html += "
" + ""; + + for (uint8_t i = 0; i < NUM_GESTURES; i++) { + html += ""; + } + + html += "
GestURL webhokaNastrojAkcjaPotw.ON
"; html += GNAME[i]; + html += "" + "gConfig[i].confirm) html += " checked"; + html += ">" + "gConfig[i].enabled) html += " checked"; + html += ">
" + "
" + + // ── Tama panel ──────────────────────────────────────────────────── + "

Tamagotchi

" + "
" + "
" + "Glod:" + "
" + "
" + "" + "
" + "
" + "Radosc:" + "
" + "
" + "" + "
" + "
" + "Czystosc:" + "
" + "
" + "" + "
" + "
" + "
" + "" + "" + "" + "
" + "
" + + "" + + "
" + + // ── Weather panel ───────────────────────────────────────────────── + "

Pogoda

" + "
" + "Ladowanie..." + "
" + "
" + "" + "" + "" + "" + "" + "" + "" + "
Miasto
Co ile sekund
Pokazuj przez (s)
" + "" + "
" + "
" + "
Podglad widoku (15s):
" + "
" + "" + "" + "" + "" + "" + "" + "
" + "
" + "" + "" + + "
" + + // ── Mood test panel ─────────────────────────────────────────────── + "

Test nastroju

" + "
" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "
" + "
" + "" + + "
" + + // ── Alert panel ─────────────────────────────────────────────────── + "

Alert

" + "
" + "" + "" + "sek" + "" + "" + "
" + "
" + "" + + "
" + "

GET /api/config   POST /api/config (JSON)" + "  |  GET /api/tama   POST /api/tama/{feed,play,clean}" + "  |  GET /api/weather   POST /weather/save" + "  |  POST /api/weather/test?icon={1-6}" + "  |  POST /api/mood/test?m={0-11}" + "  |  POST /api/alert {message,duration}" + "  |  POST /api/alert/clear

" + ""; + return html; +} + +// ── Route handlers ──────────────────────────────────────────────────────────── +void setupHttpServer(HttpServerCtx* ctx) +{ + g_ctx = ctx; + AsyncWebServer& srv = *ctx->server; + + srv.on("/", HTTP_GET, [](AsyncWebServerRequest *req) { + req->send(200, "text/html; charset=utf-8", buildHtml()); + }); + + srv.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 cfmKey = String("cfm_") + GKEY[i]; + String enKey = String("en_") + GKEY[i]; + if (req->hasParam(urlKey, true)) + strncpy(g_ctx->gConfig[i].url, + req->getParam(urlKey, true)->value().c_str(), + sizeof(g_ctx->gConfig[i].url) - 1); + if (req->hasParam(moodKey, true)) + g_ctx->gConfig[i].mood = (uint8_t)req->getParam(moodKey, true)->value().toInt(); + if (req->hasParam(actKey, true)) + g_ctx->gConfig[i].action = (uint8_t)req->getParam(actKey, true)->value().toInt(); + g_ctx->gConfig[i].confirm = req->hasParam(cfmKey, true); + g_ctx->gConfig[i].enabled = req->hasParam(enKey, true); + } + g_ctx->cbSaveConfig(); + req->redirect("/"); + }); + + srv.on("/api/config", HTTP_GET, [](AsyncWebServerRequest *req) { + JsonDocument doc; + for (uint8_t i = 0; i < NUM_GESTURES; i++) { + doc[GNAME[i]]["url"] = g_ctx->gConfig[i].url; + doc[GNAME[i]]["mood"] = g_ctx->gConfig[i].mood; + doc[GNAME[i]]["action"] = g_ctx->gConfig[i].action; + doc[GNAME[i]]["enabled"] = g_ctx->gConfig[i].enabled; + doc[GNAME[i]]["confirm"] = g_ctx->gConfig[i].confirm; + } + String out; serializeJsonPretty(doc, out); + req->send(200, "application/json", out); + }); + + srv.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; + } + 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(g_ctx->gConfig[i].url, g["url"] | "", sizeof(g_ctx->gConfig[i].url) - 1); + if (g["mood"].is()) g_ctx->gConfig[i].mood = (uint8_t)(int)g["mood"]; + if (g["action"].is()) g_ctx->gConfig[i].action = (uint8_t)(int)g["action"]; + if (g["enabled"].is()) g_ctx->gConfig[i].enabled = (bool)g["enabled"]; + if (g["confirm"].is()) g_ctx->gConfig[i].confirm = (bool)g["confirm"]; + } + g_ctx->cbSaveConfig(); + req->send(200, "application/json", "{\"ok\":true}"); + }); + + srv.on("/config.json", HTTP_GET, [](AsyncWebServerRequest *req) { + JsonDocument doc; + for (uint8_t i = 0; i < NUM_GESTURES; i++) { + doc[GNAME[i]]["url"] = g_ctx->gConfig[i].url; + doc[GNAME[i]]["mood"] = g_ctx->gConfig[i].mood; + doc[GNAME[i]]["action"] = g_ctx->gConfig[i].action; + doc[GNAME[i]]["enabled"] = g_ctx->gConfig[i].enabled; + doc[GNAME[i]]["confirm"] = g_ctx->gConfig[i].confirm; + } + String out; serializeJsonPretty(doc, out); + req->send(200, "application/json", out); + }); + + // GET /api/tama — current needs state + srv.on("/api/tama", HTTP_GET, [](AsyncWebServerRequest *req) { + char buf[64]; + snprintf(buf, sizeof(buf), + "{\"hunger\":%d,\"happiness\":%d,\"hygiene\":%d}", + g_ctx->tama->hunger, g_ctx->tama->happiness, g_ctx->tama->hygiene); + req->send(200, "application/json", buf); + }); + + // POST /api/tama/feed + srv.on("/api/tama/feed", HTTP_POST, [](AsyncWebServerRequest *req) { + uint32_t now = millis(); + tamaFeed(*g_ctx->tama); + setBuddyMood(*g_ctx->buddy, MOOD_HAPPY, now, 4000); + *g_ctx->overlayAction = ACTION_FEED; + *g_ctx->overlayUntil = now + 3000; + Serial.printf("[Web] Feed | H:%d P:%d C:%d\n", + g_ctx->tama->hunger, g_ctx->tama->happiness, g_ctx->tama->hygiene); + char buf[64]; + snprintf(buf, sizeof(buf), + "{\"ok\":true,\"msg\":\"Mniam! Glod: %d%%\",\"hunger\":%d}", + g_ctx->tama->hunger, g_ctx->tama->hunger); + req->send(200, "application/json", buf); + }); + + // POST /api/tama/play + srv.on("/api/tama/play", HTTP_POST, [](AsyncWebServerRequest *req) { + uint32_t now = millis(); + tamaPlay(*g_ctx->tama); + setBuddyMood(*g_ctx->buddy, MOOD_EXCITED, now, 4000); + *g_ctx->overlayAction = ACTION_PLAY; + *g_ctx->overlayUntil = now + 3000; + Serial.printf("[Web] Play | H:%d P:%d C:%d\n", + g_ctx->tama->hunger, g_ctx->tama->happiness, g_ctx->tama->hygiene); + char buf[64]; + snprintf(buf, sizeof(buf), + "{\"ok\":true,\"msg\":\"Grajmy! Radosc: %d%%\",\"happiness\":%d}", + g_ctx->tama->happiness, g_ctx->tama->happiness); + req->send(200, "application/json", buf); + }); + + // POST /api/tama/clean + srv.on("/api/tama/clean", HTTP_POST, [](AsyncWebServerRequest *req) { + uint32_t now = millis(); + tamaClean(*g_ctx->tama); + setBuddyMood(*g_ctx->buddy, MOOD_SURPRISED, now, 3000); + *g_ctx->overlayAction = ACTION_CLEAN; + *g_ctx->overlayUntil = now + 3000; + Serial.printf("[Web] Clean | H:%d P:%d C:%d\n", + g_ctx->tama->hunger, g_ctx->tama->happiness, g_ctx->tama->hygiene); + char buf[64]; + snprintf(buf, sizeof(buf), + "{\"ok\":true,\"msg\":\"Mycie! Czystosc: %d%%\",\"hygiene\":%d}", + g_ctx->tama->hygiene, g_ctx->tama->hygiene); + req->send(200, "application/json", buf); + }); + + // POST /api/mood/test?m=N — nastoj testowy na 10 s + srv.on("/api/mood/test", HTTP_POST, [](AsyncWebServerRequest *req) { + uint32_t now = millis(); + uint8_t m = 0; + if (req->hasParam("m")) + m = (uint8_t)constrain(req->getParam("m")->value().toInt(), 0, 11); + setBuddyMood(*g_ctx->buddy, (Mood)m, now, 10000); + Serial.printf("[Web] Mood test: %s (%d)\n", MOOD_LABELS[m], m); + char buf[64]; + snprintf(buf, sizeof(buf), "{\"ok\":true,\"msg\":\"Nastoj: %s (10s)\"}", MOOD_LABELS[m]); + req->send(200, "application/json", buf); + }); + + // GET /api/weather — current weather data + config + srv.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}", + g_ctx->weatherData->valid ? "true" : "false", + (int)g_ctx->weatherData->temp, + (int)g_ctx->weatherData->pressure, + (int)g_ctx->weatherData->icon, + g_ctx->weatherCfg->city, + (int)g_ctx->weatherCfg->intervalSec, + (int)g_ctx->weatherCfg->durationSec); + req->send(200, "application/json", buf); + }); + + // POST /weather/save — update weather config from HTML form + srv.on("/weather/save", HTTP_POST, [](AsyncWebServerRequest *req) { + if (req->hasParam("city", true)) + strncpy(g_ctx->weatherCfg->city, + req->getParam("city", true)->value().c_str(), + sizeof(g_ctx->weatherCfg->city) - 1); + if (req->hasParam("interval", true)) + g_ctx->weatherCfg->intervalSec = + (uint16_t)req->getParam("interval", true)->value().toInt(); + if (req->hasParam("duration", true)) + g_ctx->weatherCfg->durationSec = + (uint16_t)req->getParam("duration", true)->value().toInt(); + g_ctx->cbSaveWeatherConfig(); + g_ctx->weatherCachedCity[0] = '\0'; // invalidate geocode cache + *g_ctx->weatherNextShow = millis() + g_ctx->weatherCfg->intervalSec * 1000UL; + g_ctx->cbTriggerWeatherFetch(); + req->redirect("/"); + }); + + // POST /api/weather/test?icon=N — pokaż widok pogodowy przez 15 s (ikona 1-6) + static const char* WICON_NAMES[] = { "brak", "Slonce", "Zmienne", "Chmury", "Deszcz", "Snieg", "Burza" }; + srv.on("/api/weather/test", HTTP_POST, [](AsyncWebServerRequest *req) { + uint8_t icon = 1; + if (req->hasParam("icon")) + icon = (uint8_t)constrain(req->getParam("icon")->value().toInt(), 1, 6); + // Użyj aktualnej temperatury jeśli mamy dane, inaczej przykładowa wartość + if (!g_ctx->weatherData->valid) { + g_ctx->weatherData->temp = 20; + g_ctx->weatherData->pressure = 1013; + } + g_ctx->weatherData->icon = (WeatherIcon)icon; + g_ctx->weatherData->valid = true; + *g_ctx->overlayAction = ACTION_NONE; + *g_ctx->weatherShowUntil = millis() + 15000UL; + Serial.printf("[Web] Weather test: icon=%d (%s)\n", icon, WICON_NAMES[icon]); + char buf[80]; + snprintf(buf, sizeof(buf), "{\"ok\":true,\"msg\":\"Podglad: %s (15s)\"}", WICON_NAMES[icon]); + req->send(200, "application/json", buf); + }); + + // POST /api/alert — display alert mood with optional short message + srv.on("/api/alert", 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; + } + const char* msg = doc["message"] | ""; + uint16_t dur = (uint16_t)constrain((int)(doc["duration"] | 10), 1, 60); + strncpy(g_ctx->alertMsg, msg, 31); + g_ctx->alertMsg[31] = '\0'; + uint32_t now = millis(); + setBuddyMood(*g_ctx->buddy, MOOD_ALERT, now, (uint32_t)dur * 1000UL); + *g_ctx->overlayAction = ACTION_NONE; + Serial.printf("[Web] Alert: \"%s\" (%ds)\n", g_ctx->alertMsg, dur); + char buf[80]; + snprintf(buf, sizeof(buf), "{\"ok\":true,\"msg\":\"Alert aktywny (%ds)\"}", dur); + req->send(200, "application/json", buf); + }); + + // POST /api/restart — reboot device after sending response + srv.on("/api/restart", HTTP_POST, [](AsyncWebServerRequest *req) { + req->send(200, "application/json", "{\"ok\":true,\"msg\":\"Restarting...\"}"); + xTaskCreate([](void*){ vTaskDelay(400 / portTICK_PERIOD_MS); ESP.restart(); }, + "rst", 1024, nullptr, 5, nullptr); + }); + + // POST /api/alert/clear — dismiss alert immediately + srv.on("/api/alert/clear", HTTP_POST, [](AsyncWebServerRequest *req) { + g_ctx->alertMsg[0] = '\0'; + setBuddyMood(*g_ctx->buddy, MOOD_NORMAL, millis(), 0); + req->send(200, "application/json", "{\"ok\":true,\"msg\":\"Alert skasowany\"}"); + }); + + srv.begin(); + Serial.printf("[HTTP] Server na http://%s/\n", WiFi.localIP().toString().c_str()); +} diff --git a/src/HttpServer.h b/src/HttpServer.h new file mode 100644 index 0000000..ecee4b2 --- /dev/null +++ b/src/HttpServer.h @@ -0,0 +1,29 @@ +#pragma once +#include +#include "BuddyLogic.h" +#include "TamaLogic.h" +#include "GestureConfig.h" +#include "WeatherTypes.h" + +// All state that the HTTP server needs to read/write, plus callbacks for +// operations that belong to main (LittleFS saves, weather fetch trigger). +struct HttpServerCtx { + AsyncWebServer* server; + GestureConfig* gConfig; // [NUM_GESTURES] + BuddyState* buddy; + TamaState* tama; + WeatherConfig* weatherCfg; + WeatherData* weatherData; + Action* overlayAction; + uint32_t* overlayUntil; + uint32_t* weatherNextShow; + uint32_t* weatherShowUntil; + char* weatherCachedCity; // invalidated on city change + char* alertMsg; // g_alertMsg[32], displayed on MOOD_ALERT + const char* wifiSsid; + void (*cbSaveConfig)(); + void (*cbSaveWeatherConfig)(); + void (*cbTriggerWeatherFetch)(); +}; + +void setupHttpServer(HttpServerCtx* ctx); diff --git a/src/WeatherTypes.h b/src/WeatherTypes.h new file mode 100644 index 0000000..b6ceed7 --- /dev/null +++ b/src/WeatherTypes.h @@ -0,0 +1,20 @@ +#pragma once +#include + +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; +}; diff --git a/src/bitmaps/alert_xbm.h b/src/bitmaps/alert_xbm.h new file mode 100644 index 0000000..3404005 --- /dev/null +++ b/src/bitmaps/alert_xbm.h @@ -0,0 +1,15 @@ +#pragma once +#include +// W=26, H=26 -- generated from alert.svg +#define ALERT_W 26 +#define ALERT_H 26 +static const uint8_t ALERT_XBM[] = { + 0x00, 0xFC, 0x00, 0x00, 0x80, 0xFF, 0x07, 0x00, 0xE0, 0xFF, 0x1F, 0x00, + 0xF0, 0xFF, 0x3F, 0x00, 0xF8, 0xFF, 0x7F, 0x00, 0xFC, 0xFF, 0xFF, 0x00, + 0xFC, 0xCF, 0xFF, 0x00, 0xFE, 0xCF, 0xFF, 0x01, 0xFE, 0xCF, 0xFF, 0x01, + 0xFE, 0xCF, 0xFF, 0x01, 0xFF, 0xCF, 0xFF, 0x03, 0xFF, 0xCF, 0xFF, 0x03, + 0xFF, 0xCF, 0xFF, 0x03, 0xFF, 0xCF, 0xFF, 0x03, 0xFF, 0xCF, 0xFF, 0x03, + 0xFF, 0xCF, 0xFF, 0x03, 0xFE, 0xFF, 0xFF, 0x01, 0xFE, 0xFF, 0xFF, 0x01, + 0xFE, 0xCF, 0xFF, 0x01, 0xFC, 0xCF, 0xFF, 0x00, 0xFC, 0xFF, 0xFF, 0x00, + 0xF8, 0xFF, 0x7F, 0x00, 0xF0, 0xFF, 0x3F, 0x00, 0xE0, 0xFF, 0x1F, 0x00, + 0x80, 0xFF, 0x07, 0x00, 0x00, 0xFC, 0x00, 0x00, }; diff --git a/src/bitmaps/cloud_small_xbm.h b/src/bitmaps/cloud_small_xbm.h new file mode 100644 index 0000000..20126cb --- /dev/null +++ b/src/bitmaps/cloud_small_xbm.h @@ -0,0 +1,10 @@ +#pragma once +#include +// W=20, H=13 -- generated from assets/svg/cloud.svg +#define CLOUD_SMALL_W 20 +#define CLOUD_SMALL_H 13 +static const uint8_t CLOUD_SMALL_XBM[] = { + 0x80, 0x0F, 0x00, 0xE0, 0x18, 0x00, 0x20, 0x20, 0x00, 0x10, 0xC0, 0x01, + 0x18, 0xF0, 0x07, 0x0C, 0x10, 0x04, 0x02, 0x00, 0x0C, 0x01, 0x00, 0x08, + 0x01, 0x00, 0x08, 0x01, 0x00, 0x08, 0x03, 0x00, 0x04, 0x06, 0x00, 0x07, + 0xFC, 0xFF, 0x01, }; diff --git a/src/bitmaps/cloud_xbm.h b/src/bitmaps/cloud_xbm.h new file mode 100644 index 0000000..6d7d07e --- /dev/null +++ b/src/bitmaps/cloud_xbm.h @@ -0,0 +1,12 @@ +#pragma once +#include +// W=26, H=17 -- generated from assets/svg/cloud.svg +#define CLOUD_W 26 +#define CLOUD_H 17 +static const uint8_t CLOUD_XBM[] = { + 0x00, 0x7C, 0x00, 0x00, 0x00, 0xFF, 0x01, 0x00, 0x80, 0x01, 0x03, 0x00, + 0xC0, 0x00, 0x06, 0x00, 0x60, 0x00, 0x3C, 0x00, 0x20, 0x00, 0xFE, 0x00, + 0x38, 0x00, 0xC3, 0x01, 0x1C, 0x00, 0x80, 0x01, 0x06, 0x00, 0x00, 0x03, + 0x03, 0x00, 0x00, 0x03, 0x03, 0x00, 0x00, 0x03, 0x03, 0x00, 0x00, 0x03, + 0x03, 0x00, 0x00, 0x03, 0x03, 0x00, 0x00, 0x01, 0x06, 0x00, 0x80, 0x01, + 0xFC, 0xFF, 0xFF, 0x00, 0xF8, 0xFF, 0x3F, 0x00, }; diff --git a/src/main.cpp b/src/main.cpp index a93f36d..da78227 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -11,6 +11,11 @@ #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. +#include "WeatherTypes.h" // WeatherIcon, WeatherConfig, WeatherData +#include "HttpServer.h" // HttpServerCtx, setupHttpServer +#include "bitmaps/cloud_xbm.h" // CLOUD_XBM, CLOUD_W, CLOUD_H +#include "bitmaps/cloud_small_xbm.h" // CLOUD_SMALL_XBM, CLOUD_SMALL_W, CLOUD_SMALL_H +#include "bitmaps/alert_xbm.h" // ALERT_XBM, ALERT_W, ALERT_H // ── Hardware ────────────────────────────────────────────────────────────────── // Both SSD1306 (0x3C) and PAJ7620 (0x73) share Wire on GPIO22(SDA)/GPIO23(SCL) @@ -34,30 +39,32 @@ static int32_t arduinoRng(int32_t lo, int32_t hi) { return random(lo, hi); } static uint32_t overlayUntil = 0; static Action overlayAction = ACTION_NONE; +// ── Confirmation state ──────────────────────────────────────────────────────── +static bool g_confirmPending = false; +static Action g_confirmAction = ACTION_NONE; // for ACTION_RESTART etc. +static int8_t g_confirmGestIdx = -1; // >=0 for gesture webhook confirm +static uint32_t g_confirmUntil = 0; + +// ── Alert ───────────────────────────────────────────────────────────────────── +char g_alertMsg[32] = ""; + // ── 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; +static volatile bool weatherFetchRunning = false; // blokuje równoległe taski +static float weatherLat = 0.0f; // cache geocodingu +static float weatherLon = 0.0f; +static char weatherCachedCity[64] = ""; +// HTTP health watchdog — zlicza kolejne błędy connect; po przekroczeniu progu restart +static volatile uint8_t httpFailStreak = 0; +static const uint8_t HTTP_FAIL_RESTART = 3; +// Weather retry backoff — po błędzie fetch czeka dłużej +static uint32_t weatherRetryAfter = 0; +// Po resecie WiFi blokujemy nowe zadania HTTP przez 20 s (WiFi.begin jest async) +static uint32_t wifiResettingUntil = 0; // ── Night dim ───────────────────────────────────────────────────────────────── static bool g_dimmed = false; @@ -86,6 +93,11 @@ 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) { + if (buddy.mood == MOOD_ALERT) { + u8g2.setDrawColor(1); + u8g2.drawXBM(cx - ALERT_W/2, cy - ALERT_H/2, ALERT_W, ALERT_H, ALERT_XBM); + return; + } 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; @@ -290,6 +302,13 @@ static void drawMouth() u8g2.drawDisc(MOUTH_X + 4, MOUTH_Y - 1, 1); u8g2.drawDisc(MOUTH_X + 8, MOUTH_Y + 1, 1); break; + case MOOD_ALERT: + if (g_alertMsg[0]) { + u8g2.setFont(u8g2_font_7x13_tr); + uint8_t tw = u8g2.getStrWidth(g_alertMsg); + u8g2.drawStr((128 - tw) / 2, MOUTH_Y + 8, g_alertMsg); + } + break; default: u8g2.drawCircle(MOUTH_X, MOUTH_Y - 3, 6, U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT); @@ -318,45 +337,29 @@ static void drawWeatherEye(uint8_t cx, uint8_t cy, WeatherIcon icon) break; case WICON_CLOUD_SUN: { - // Sun peeking top-right + // Słońce wychylające się górnym-prawym rogiem 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); + u8g2.drawLine(cx+5, cy-12, cx+5, cy-14); // promień N + u8g2.drawLine(cx+12, cy-5, cx+14, cy-5 ); // promień E + u8g2.drawLine(cx+9, cy-11, cx+11, cy-13); // promień NE + // Mała chmura bottom-left zasłaniająca słońce + u8g2.drawXBM(cx - CLOUD_SMALL_W + 2, cy - 2, CLOUD_SMALL_W, CLOUD_SMALL_H, CLOUD_SMALL_XBM); 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); + u8g2.drawXBM(cx - CLOUD_W/2, cy - CLOUD_H/2 - 2, CLOUD_W, CLOUD_H, CLOUD_XBM); 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); + u8g2.drawXBM(cx - CLOUD_W/2, cy - CLOUD_H - 4, CLOUD_W, CLOUD_H, CLOUD_XBM); + u8g2.drawLine(cx-5, cy+4, cx-7, cy+12); + u8g2.drawLine(cx, cy+4, cx-2, cy+12); + u8g2.drawLine(cx+5, cy+4, cx+3, cy+12); 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 + u8g2.drawXBM(cx - CLOUD_W/2, cy - CLOUD_H - 4, CLOUD_W, CLOUD_H, CLOUD_XBM); for (int8_t si = -1; si <= 1; si++) { uint8_t sx = (uint8_t)((int8_t)cx + si * 6); uint8_t sy = cy + 9; @@ -366,12 +369,7 @@ static void drawWeatherEye(uint8_t cx, uint8_t cy, WeatherIcon icon) 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.drawXBM(cx - CLOUD_W/2, cy - CLOUD_H - 4, CLOUD_W, CLOUD_H, CLOUD_XBM); 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); @@ -542,8 +540,65 @@ static void showTamaStatusScreen() { u8g2.sendBuffer(); } +static void showConfirmScreen() +{ + uint32_t now = millis(); + u8g2.clearBuffer(); + u8g2.setDrawColor(1); + u8g2.setFont(u8g2_font_6x10_tr); + u8g2.drawStr(5, 13, "Potwierdzic?"); + u8g2.setFont(u8g2_font_5x7_tr); + if (g_confirmGestIdx >= 0) + u8g2.drawStr(5, 28, GNAME[g_confirmGestIdx]); + else + u8g2.drawStr(5, 28, ACTION_LABELS[g_confirmAction]); + u8g2.drawStr(2, 46, "< Nie"); + u8g2.drawStr(80, 46, "Tak >"); + uint32_t rem = (g_confirmUntil > now) ? g_confirmUntil - now : 0; + uint8_t barW = (uint8_t)(rem * 118UL / 10000UL); + u8g2.drawFrame(5, 55, 118, 6); + if (barW) u8g2.drawBox(5, 55, barW, 6); + u8g2.sendBuffer(); +} + +static void showRestartScreen() +{ + u8g2.clearBuffer(); + u8g2.setFont(u8g2_font_6x10_tr); + u8g2.drawStr(20, 32, "Restartuje..."); + u8g2.sendBuffer(); +} + +static void executeConfirmedAction(Action a) +{ + switch (a) { + case ACTION_RESTART: + showRestartScreen(); + Serial.println("[Action] Restart potwierdzony"); + delay(800); + ESP.restart(); + break; + default: break; + } +} + + void showBuddyScreen() { + // Confirmation — highest display priority + if (g_confirmPending) { + if (millis() < g_confirmUntil) { + showConfirmScreen(); + return; + } + // Timeout — auto-cancel + g_confirmPending = false; + g_confirmAction = ACTION_NONE; + g_confirmGestIdx = -1; + setBuddyMood(buddy, MOOD_NORMAL, millis(), 0); + Serial.println("[Action] Anulowano (timeout)"); + } + // Weather face takes priority over normal face (but not action overlays) if (overlayAction == ACTION_NONE && weatherData.valid && millis() < weatherShowUntil) { drawWeatherFace(); @@ -627,6 +682,7 @@ void loadAllConfig() gConfig[i].mood = g["mood"] | 0; gConfig[i].action = g["action"] | 0; gConfig[i].enabled = g["enabled"] | true; + gConfig[i].confirm = g["confirm"] | false; } Serial.println("[Config] Gestures loaded"); } @@ -646,6 +702,7 @@ void saveConfig() doc[GNAME[i]]["mood"] = gConfig[i].mood; doc[GNAME[i]]["action"] = gConfig[i].action; doc[GNAME[i]]["enabled"] = gConfig[i].enabled; + doc[GNAME[i]]["confirm"] = gConfig[i].confirm; } if (!LittleFS.begin(false)) { Serial.println("[Config] LittleFS mount failed"); return; } File f = LittleFS.open("/config.json", "w"); @@ -669,7 +726,7 @@ static void loadWeatherConfig() weatherCfg.city, weatherCfg.intervalSec, weatherCfg.durationSec); } -static void saveWeatherConfig() +void saveWeatherConfig() { Serial.printf("[Weather] Saving: city='%s' interval=%d duration=%d\n", weatherCfg.city, weatherCfg.intervalSec, weatherCfg.durationSec); @@ -727,38 +784,55 @@ 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)); + float lat = weatherLat; + float lon = weatherLon; + bool needGeo = (strncmp(p->city, weatherCachedCity, sizeof(weatherCachedCity)) != 0 + || (lat == 0.0f && lon == 0.0f)); - char geoUrl[256]; - snprintf(geoUrl, sizeof(geoUrl), - "http://geocoding-api.open-meteo.com/v1/search" - "?name=%s&count=1&language=pl&format=json", - encodedCity); + if (needGeo) { + // Step 1: Geocoding — city name → lat/lon + char encodedCity[192]; + urlEncode(p->city, encodedCity, sizeof(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; - } + char geoUrl[256]; + snprintf(geoUrl, sizeof(geoUrl), + "http://geocoding-api.open-meteo.com/v1/search" + "?name=%s&count=1&language=pl&format=json", + encodedCity); - 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); + 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(); + if (code == -1) httpFailStreak = httpFailStreak + 1; + weatherRetryAfter = millis() + 300000UL; // backoff 5 min + weatherFetchRunning = false; delete p; vTaskDelete(NULL); return; } - lat = geoDoc["results"][0]["latitude"] | 0.0f; - lon = geoDoc["results"][0]["longitude"] | 0.0f; + httpFailStreak = 0; + { + 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); + weatherFetchRunning = false; + delete p; vTaskDelete(NULL); return; + } + lat = geoDoc["results"][0]["latitude"] | 0.0f; + lon = geoDoc["results"][0]["longitude"] | 0.0f; + } + // Cache coordinates + weatherLat = lat; + weatherLon = lon; + strncpy(weatherCachedCity, p->city, sizeof(weatherCachedCity) - 1); + weatherCachedCity[sizeof(weatherCachedCity) - 1] = '\0'; + Serial.printf("[Weather] Geocoded '%s' → %.4f, %.4f\n", p->city, lat, lon); } // Step 2: Current weather forecast @@ -769,9 +843,10 @@ static void weatherFetchTask(void *pvParam) "¤t=temperature_2m,surface_pressure,weather_code", lat, lon); + HTTPClient http; http.begin(fcUrl); http.setTimeout(8000); - code = http.GET(); + int code = http.GET(); if (code == 200) { String body = http.getString(); JsonDocument doc; @@ -781,27 +856,39 @@ static void weatherFetchTask(void *pvParam) int wcode = doc["current"]["weather_code"] | 0; weatherData.icon = wmoCodeToIcon(wcode); weatherData.valid = true; + httpFailStreak = 0; + weatherRetryAfter = 0; 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); + if (code == -1) httpFailStreak = httpFailStreak + 1; + weatherRetryAfter = millis() + 300000UL; // backoff 5 min + Serial.printf("[Weather] Forecast HTTP %d (streak=%d)\n", code, (int)httpFailStreak); } http.end(); + weatherFetchRunning = false; delete p; vTaskDelete(NULL); } -static void triggerWeatherFetch() +void triggerWeatherFetch() { if (strlen(weatherCfg.city) == 0) return; + if (WiFi.status() != WL_CONNECTED || millis() < wifiResettingUntil) return; + if (weatherFetchRunning) { + Serial.println("[Weather] Fetch already running, skipping"); + return; + } WeatherFetchParams *p = new WeatherFetchParams; strncpy(p->city, weatherCfg.city, sizeof(p->city) - 1); p->city[sizeof(p->city) - 1] = '\0'; + weatherFetchRunning = true; if (xTaskCreate(weatherFetchTask, "wthr", 10240, p, 1, NULL) != pdPASS) { Serial.println("[Weather] Task create failed"); + weatherFetchRunning = false; delete p; } } @@ -819,6 +906,9 @@ static void webhookTaskFn(void *pvParam) char body[48]; snprintf(body, sizeof(body), "{\"gesture\":\"%s\"}", t->gesture); int code = http.POST(body); + if (WiFi.status() == WL_CONNECTED) { + if (code == -1) httpFailStreak = httpFailStreak + 1; else httpFailStreak = 0; + } Serial.printf("[Webhook] %s -> HTTP %d\n", t->gesture, code); http.end(); delete t; @@ -829,6 +919,10 @@ void fireWebhook(uint8_t gestureIdx) { const GestureConfig &cfg = gConfig[gestureIdx]; if (!cfg.enabled || strlen(cfg.url) == 0) return; + if (WiFi.status() != WL_CONNECTED || millis() < wifiResettingUntil) { + Serial.printf("[Webhook] Pomijam %s — WiFi nie gotowe\n", GNAME[gestureIdx]); + return; + } WebhookTask *t = new WebhookTask; strncpy(t->url, cfg.url, sizeof(t->url) - 1); strncpy(t->gesture, GNAME[gestureIdx], sizeof(t->gesture) - 1); @@ -838,381 +932,6 @@ void fireWebhook(uint8_t gestureIdx) } } -// ── HTTP server ─────────────────────────────────────────────────────────────── -static String buildHtml() -{ - String html = F( - "" - "" - "Desk Buddy" - "" - "

Desk Buddy

"); - - html += "
IP: "; - html += WiFi.localIP().toString(); - html += "  |  SSID: "; - html += WIFI_SSID; - html += "
"; - html += "
" - ""; - - for (uint8_t i = 0; i < NUM_GESTURES; i++) { - html += "
GestURL webhokaNastrojAkcjaON
"; html += GNAME[i]; - html += "" - "" - "" - "" - "" - "" - "" - "
Miasto
Co ile sekund
Pokazuj przez (s)
" - "" - "" - "
" - "" - - "
" - - // ── Mood test panel ─────────────────────────────────────────────── - "

Test nastroju

" - "
" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "" - "
" - "
" - "" - - "
" - "

GET /api/config   POST /api/config (JSON)" - "  |  GET /api/tama   POST /api/tama/{feed,play,clean}" - "  |  GET /api/weather   POST /weather/save" - "  |  POST /api/mood/test?m={0-11}

" - ""; - return html; -} - -void setupHttpServer() -{ - httpServer.on("/", HTTP_GET, [](AsyncWebServerRequest *req) - { req->send(200, "text/html; charset=utf-8", buildHtml()); }); - - 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)) - 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(); - 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("/"); - }); - - 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; - doc[GNAME[i]]["mood"] = gConfig[i].mood; - 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); - }); - - 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; - } - 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"]; - } - saveConfig(); - req->send(200, "application/json", "{\"ok\":true}"); - }); - - httpServer.on("/config.json", HTTP_GET, [](AsyncWebServerRequest *req) { - 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; - } - 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); - }); - - // POST /api/mood/test?m=N — ustaw nastoj testowy na 10s - httpServer.on("/api/mood/test", HTTP_POST, [](AsyncWebServerRequest *req) { - uint32_t now = millis(); - uint8_t m = 0; - if (req->hasParam("m")) - m = (uint8_t)constrain(req->getParam("m")->value().toInt(), 0, 11); - setBuddyMood(buddy, (Mood)m, now, 10000); - Serial.printf("[Web] Mood test: %s (%d)\n", MOOD_LABELS[m], m); - char buf[64]; - snprintf(buf, sizeof(buf), "{\"ok\":true,\"msg\":\"Nastoj: %s (10s)\"}", MOOD_LABELS[m]); - req->send(200, "application/json", buf); - }); - - // GET /api/weather — current weather data + config - httpServer.on("/api/weather", HTTP_GET, [](AsyncWebServerRequest *req) { - char buf[180]; - 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()); -} // ── WiFi ────────────────────────────────────────────────────────────────────── static void splash(const char *l1, const char *l2 = "", const char *l3 = "") @@ -1271,6 +990,13 @@ void executeAction(uint8_t idx) tamaClean(tama); setBuddyMood(buddy, MOOD_SURPRISED, now, 3000); dur = 3000; break; + case ACTION_RESTART: + g_confirmPending = true; + g_confirmAction = ACTION_RESTART; + g_confirmUntil = millis() + 10000UL; + setBuddyMood(buddy, MOOD_SURPRISED, now, 0); + Serial.println("[Action] Restart — czeka na potwierdzenie"); + return; // nie ustawia overlayAction default: break; } @@ -1281,10 +1007,48 @@ void executeAction(uint8_t idx) tama.hunger, tama.happiness, tama.hygiene); } +static void executeConfirmedGesture(int8_t idx) +{ + uint32_t now = millis(); + Serial.printf("[Action] Gest '%s' potwierdzony\n", GNAME[idx]); + executeAction((uint8_t)idx); + if (gConfig[idx].mood > 0) + setBuddyMood(buddy, (Mood)gConfig[idx].mood, now, 4000); + else + setBuddyMood(buddy, DEFAULT_MOOD[idx], now, DEFAULT_MOOD_DUR[idx]); + if (gConfig[idx].enabled && strlen(gConfig[idx].url) > 0) + fireWebhook((uint8_t)idx); +} + void handleGesture(Gesture g) { if (g == GES_NONE) return; uint32_t now = millis(); + + // Intercept gestures during confirmation mode + if (g_confirmPending) { + buddy.lastEvent = now; + if (g_dimmed) setDim(false); + if (g == GES_RIGHT) { + g_confirmPending = false; + if (g_confirmGestIdx >= 0) { + int8_t idx = g_confirmGestIdx; + g_confirmGestIdx = -1; + executeConfirmedGesture(idx); + } else { + executeConfirmedAction(g_confirmAction); + } + } else if (g == GES_LEFT) { + g_confirmPending = false; + g_confirmAction = ACTION_NONE; + g_confirmGestIdx = -1; + setBuddyMood(buddy, MOOD_NORMAL, now, 0); + Serial.println("[Action] Anulowano"); + } + // all other gestures ignored during confirmation + return; + } + buddy.lastEvent = now; if (g_dimmed) setDim(false); @@ -1292,6 +1056,18 @@ void handleGesture(Gesture g) if (idx < 0 || idx >= NUM_GESTURES) return; Serial.printf("[Gest] %s\n", GNAME[idx]); + + // Gesture-level confirmation: defer all effects to user gesture RIGHT + if (gConfig[idx].confirm) { + g_confirmPending = true; + g_confirmGestIdx = (int8_t)idx; + g_confirmAction = ACTION_NONE; + g_confirmUntil = now + 10000UL; + setBuddyMood(buddy, MOOD_SURPRISED, now, 0); + Serial.printf("[Action] '%s' czeka na potwierdzenie\n", GNAME[idx]); + return; + } + executeAction(idx); if (gConfig[idx].mood > 0) @@ -1323,7 +1099,26 @@ void setup() else Serial.println("[PAJ7620] Nie znaleziono"); connectWiFi(); - if (WiFi.status() == WL_CONNECTED) setupHttpServer(); + if (WiFi.status() == WL_CONNECTED) { + static HttpServerCtx httpCtx; + httpCtx.server = &httpServer; + httpCtx.gConfig = gConfig; + httpCtx.buddy = &buddy; + httpCtx.tama = &tama; + httpCtx.weatherCfg = &weatherCfg; + httpCtx.weatherData = &weatherData; + httpCtx.overlayAction = &overlayAction; + httpCtx.overlayUntil = &overlayUntil; + httpCtx.weatherNextShow = &weatherNextShow; + httpCtx.weatherShowUntil = &weatherShowUntil; + httpCtx.weatherCachedCity = weatherCachedCity; + httpCtx.alertMsg = g_alertMsg; + httpCtx.wifiSsid = WIFI_SSID; + httpCtx.cbSaveConfig = saveConfig; + httpCtx.cbSaveWeatherConfig = saveWeatherConfig; + httpCtx.cbTriggerWeatherFetch = triggerWeatherFetch; + setupHttpServer(&httpCtx); + } } // ── Loop ────────────────────────────────────────────────────────────────────── @@ -1347,6 +1142,19 @@ void loop() setDim(night && idle); } + // HTTP socket watchdog — wyczerpany pool: reset stosu WiFi zamiast restartu urządzenia + if (httpFailStreak >= HTTP_FAIL_RESTART) { + Serial.printf("[HTTP] %d kolejnych bledow connect — reset WiFi (zachowanie uptime)\n", + (int)httpFailStreak); + httpFailStreak = 0; + weatherRetryAfter = now + 30000UL; // 30s przerwa po resecie + wifiResettingUntil = now + 20000UL; // 20s blokada nowych HTTP tasks + WiFi.disconnect(false); // rozłącz, zachowaj ssid/pass w RAM + delay(300); + WiFi.begin(WIFI_SSID, WIFI_PASS); // podnosi interfejs + czyści socket pool lwIP + Serial.println("[HTTP] WiFi begin po resecie — reconnect w toku"); + } + // WiFi watchdog: próba reconnect co 30 s, restart po 5 min bez połączenia static uint32_t lastWifiCheck = 0; static uint32_t wifiDownSince = 0; // 0 = połączony lub nie śledzony @@ -1371,7 +1179,8 @@ void loop() if (weatherCfg.intervalSec > 0 && now >= weatherNextShow) { weatherShowUntil = now + weatherCfg.durationSec * 1000UL; weatherNextShow = now + weatherCfg.intervalSec * 1000UL; - if (!weatherData.valid || now - weatherLastFetch >= 60000UL) { + if (now >= weatherRetryAfter && + (!weatherData.valid || now - weatherLastFetch >= 60000UL)) { weatherLastFetch = now; triggerWeatherFetch(); } diff --git a/tools/svg_to_xbm.sh b/tools/svg_to_xbm.sh new file mode 100755 index 0000000..c14302f --- /dev/null +++ b/tools/svg_to_xbm.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Convert assets/svg/cloud.svg to XBM C headers in src/bitmaps/ +# Requires: rsvg-convert, magick (ImageMagick 7) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +SVG="$PROJECT_DIR/assets/svg/cloud.svg" +OUT_DIR="$PROJECT_DIR/src/bitmaps" + +mkdir -p "$OUT_DIR" + +convert_cloud() { + local W="$1" H="$2" VARNAME="$3" OUTFILE="$4" + local TMP_PNG TMP_XBM + TMP_PNG=$(mktemp /tmp/cloud_XXXXXX.png) + TMP_XBM=$(mktemp /tmp/cloud_XXXXXX.xbm) + + # SVG → PNG (rsvg-convert scales, magick flattens on white + thresholds to 1-bit) + rsvg-convert -w "$W" -h "$H" "$SVG" \ + | magick - -background white -flatten -threshold 50% "$TMP_PNG" + + # PNG → XBM + magick "$TMP_PNG" "$TMP_XBM" + + # XBM → C header via Python (handles edge cases in XBM formatting) + python3 - "$TMP_XBM" "$W" "$H" "$VARNAME" "$OUT_DIR/$OUTFILE" <<'PYEOF' +import sys, re + +xbm_file, W, H, varname, outfile = sys.argv[1:] +W, H = int(W), int(H) + +with open(xbm_file) as f: + content = f.read() + +# Remove #define lines +content = re.sub(r'#define\s+\S+\s+\d+\n?', '', content) +# Replace "static char NAME[] =" with our typed array +content = re.sub(r'static\s+char\s+\S+\s*=', f'static const uint8_t {varname}_XBM[] =', content) +content = content.strip() + +header = f"""#pragma once +#include +// W={W}, H={H} -- generated from assets/svg/cloud.svg +#define {varname}_W {W} +#define {varname}_H {H} +{content} +""" +with open(outfile, 'w') as f: + f.write(header) +print(f"[svg_to_xbm] Generated {outfile} ({W}x{H})") +PYEOF + + rm -f "$TMP_PNG" "$TMP_XBM" +} + +convert_cloud 26 17 "CLOUD" "cloud_xbm.h" +convert_cloud 20 13 "CLOUD_SMALL" "cloud_small_xbm.h" + +# Alert icon (circle with !) — rendered as both eyes for MOOD_ALERT +convert_svg() { + local SVG_SRC="$1" W="$2" H="$3" VARNAME="$4" OUTFILE="$5" + local TMP_PNG TMP_XBM + TMP_PNG=$(mktemp /tmp/icon_XXXXXX.png) + TMP_XBM=$(mktemp /tmp/icon_XXXXXX.xbm) + + rsvg-convert -w "$W" -h "$H" "$SVG_SRC" \ + | magick - -background white -flatten -threshold 50% "$TMP_PNG" + magick "$TMP_PNG" "$TMP_XBM" + + python3 - "$TMP_XBM" "$W" "$H" "$VARNAME" "$OUT_DIR/$OUTFILE" "$SVG_SRC" <<'PYEOF' +import sys, re +xbm_file, W, H, varname, outfile, src = sys.argv[1:] +W, H = int(W), int(H) +with open(xbm_file) as f: + content = f.read() +content = re.sub(r'#define\s+\S+\s+\d+\n?', '', content) +content = re.sub(r'static\s+char\s+\S+\s*=', f'static const uint8_t {varname}_XBM[] =', content) +import os +header = f"""#pragma once +#include +// W={W}, H={H} -- generated from {os.path.basename(src)} +#define {varname}_W {W} +#define {varname}_H {H} +{content.strip()} +""" +with open(outfile, 'w') as f: + f.write(header) +print(f"[svg_to_xbm] Generated {outfile} ({W}x{H})") +PYEOF + + rm -f "$TMP_PNG" "$TMP_XBM" +} + +convert_svg "$PROJECT_DIR/assets/svg/alert.svg" 26 26 "ALERT" "alert_xbm.h" + +echo "[svg_to_xbm] Done."