+#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 += ""
+ "Restart
"
+ "
"
+ "";
+ html += ""
+ " "
+
+ // ── Tama panel ────────────────────────────────────────────────────
+ "Tamagotchi "
+ ""
+ "
"
+ "
Glod: "
+ "
"
+ "
"
+ "
"
+ "
"
+ "
Radosc: "
+ "
"
+ "
"
+ "
"
+ "
"
+ "
Czystosc: "
+ "
"
+ "
"
+ "
"
+ "
"
+ ""
+ "Nakarm "
+ "Pobaw sie "
+ "Umyj "
+ "
"
+ "
"
+
+ ""
+
+ " "
+
+ // ── Weather panel ─────────────────────────────────────────────────
+ "Pogoda "
+ ""
+ "Ladowanie..."
+ "
"
+ ""
+ "
"
+ "Podglad widoku (15s):
"
+ ""
+ "☀ Slonce "
+ "⛅ Zmienne "
+ "☁ Chmury "
+ "🌧 Deszcz "
+ "❄ Snieg "
+ "⛈ Burza "
+ "
"
+ "
"
+ ""
+ ""
+
+ " "
+
+ // ── Mood test panel ───────────────────────────────────────────────
+ "Test nastroju "
+ ""
+ "Normal "
+ "Happy "
+ "Sleepy "
+ "Surprised "
+ "Angry "
+ "Sad "
+ "Excited "
+ "Wink L "
+ "Wink R "
+ "Hungry "
+ "Playful "
+ "Dirty "
+ "
"
+ "
"
+ ""
+
+ " "
+
+ // ── Alert panel ───────────────────────────────────────────────────
+ "Alert "
+ ""
+ " "
+ " "
+ "sek "
+ "Wyslij alert "
+ "Skasuj "
+ "
"
+ "
"
+ ""
+
+ " "
+ "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 += ""
- " "
-
- // ── Tama panel ────────────────────────────────────────────────────
- "Tamagotchi "
- ""
- "
"
- "
Glod: "
- "
"
- "
"
- "
"
- "
"
- "
Radosc: "
- "
"
- "
"
- "
"
- "
"
- "
Czystosc: "
- "
"
- "
"
- "
"
- "
"
- ""
- "Nakarm "
- "Pobaw sie "
- "Umyj "
- "
"
- "
"
-
- ""
-
- " "
-
- // ── Weather panel ─────────────────────────────────────────────────
- "Pogoda "
- ""
- "Ladowanie..."
- "
"
- ""
- "
"
- ""
-
- " "
-
- // ── Mood test panel ───────────────────────────────────────────────
- "Test nastroju "
- ""
- "Normal "
- "Happy "
- "Sleepy "
- "Surprised "
- "Angry "
- "Sad "
- "Excited "
- "Wink L "
- "Wink R "
- "Hungry "
- "Playful "
- "Dirty "
- "
"
- "
"
- ""
-
- " "
- "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."