feat: mapy bitowe z svg, dodanie potwierdzania akcji, reset urządzenia

This commit is contained in:
2026-06-07 13:55:20 +02:00
parent a7ed8f6cdd
commit 997fd3e563
15 changed files with 1027 additions and 467 deletions
+5
View File
@@ -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:
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 512"><path d="M256 0c70.69 0 134.69 28.66 181.02 74.98C483.34 121.31 512 185.31 512 256c0 70.69-28.66 134.69-74.98 181.02C390.69 483.34 326.69 512 256 512c-70.69 0-134.69-28.66-181.02-74.98C28.66 390.69 0 326.69 0 256c0-70.69 28.66-134.69 74.98-181.02C121.31 28.66 185.31 0 256 0zm-15.38 310.18l-2.51-31.62c-4.26-52.99-8.07-94.08-8.08-149.34-.01-1.26.51-2.41 1.34-3.24.83-.83 1.98-1.35 3.24-1.35h42.77c1.27 0 2.41.52 3.24 1.35a4.54 4.54 0 011.35 3.24c0 55.01-3.58 96.75-7.53 149.85l-2.31 31.32a4.584 4.584 0 01-1.43 2.97c-.83.77-1.94 1.24-3.13 1.24H245.2a4.52 4.52 0 01-3.21-1.32c-.8-.79-1.32-1.89-1.37-3.1zm-6.01 72.6v-30.2c0-1.25.51-2.4 1.34-3.23a4.54 4.54 0 013.24-1.35h33.61c1.26 0 2.41.51 3.24 1.34l.08.09c.78.83 1.27 1.95 1.27 3.15v30.2c0 1.27-.52 2.41-1.35 3.24-.83.83-1.98 1.35-3.24 1.35h-33.61c-1.26 0-2.41-.52-3.24-1.35a4.556 4.556 0 01-1.34-3.24z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px" y="0px" viewBox="0 0 122.88 79.13"
style="enable-background:new 0 0 122.88 79.13" xml:space="preserve">
<g>
<path d="M86.35,29.93c-0.75,0.37-1.51,0.78-2.26,1.21c-2.25,1.32-4.47,2.93-6.74,4.78l-4.84-5.54
c1.67-1.55,3.48-2.96,5.4-4.21c1.53-1,3.13-1.89,4.78-2.65c0.66-0.33,1.32-0.64,2-0.93
c-3.19-5.65-7.78-9.7-12.98-12.2c-5.2-2.49-11.02-3.45-16.69-2.9c-5.63,0.54-11.1,2.59-15.62,6.1
c-5.23,4.05-9.2,10.11-10.73,18.14l-0.48,2.51l-2.5,0.44c-2.45,0.43-4.64,1.02-6.56,1.77
c-1.86,0.72-3.52,1.61-4.97,2.66c-1.16,0.84-2.16,1.78-3.01,2.8c-2.63,3.15-3.85,7.1-3.82,11.1
c0.03,4.06,1.35,8.16,3.79,11.53c0.91,1.25,1.96,2.4,3.16,3.4c1.22,1.01,2.59,1.85,4.13,2.48
c1.53,0.63,3.22,1.08,5.09,1.34l72.55,0c3.53-0.85,6.65-2,9.3-3.48c2.63-1.47,4.78-3.26,6.39-5.41
c2.5-3.33,3.73-8.04,3.78-12.87c0.06-5.07-1.18-10.16-3.59-13.86c-0.69-1.07-1.45-2.03-2.25-2.89
c-3.61-3.89-8.19-5.59-12.95-5.62C93.3,27.6,89.73,28.43,86.35,29.93L86.35,29.93L86.35,29.93z
M91.99,20.65c1.6-0.25,3.2-0.38,4.79-0.36c6.72,0.05,13.2,2.45,18.3,7.95c1.07,1.15,2.08,2.45,3.03,3.9
c3.2,4.92,4.84,11.49,4.77,17.92c-0.07,6.31-1.77,12.59-5.25,17.21c-2.27,3.01-5.18,5.47-8.67,7.42
c-3.36,1.88-7.28,3.31-11.68,4.33l-0.82,0.1l-73.08,0l-0.46-0.04c-2.67-0.34-5.09-0.97-7.29-1.88
c-2.27-0.94-4.28-2.15-6.05-3.63c-1.68-1.4-3.15-2.99-4.4-4.72C1.84,64.25,0.04,58.63,0,53.03
c-0.04-5.66,1.72-11.29,5.52-15.85c1.23-1.48,2.68-2.84,4.34-4.04c1.93-1.4,4.14-2.58,6.64-3.55
c1.72-0.67,3.56-1.23,5.5-1.68c2.2-8.74,6.89-15.47,12.92-20.14c5.64-4.37,12.43-6.92,19.42-7.59
c6.96-0.67,14.12,0.51,20.55,3.6C81.9,7.15,88.02,12.76,91.99,20.65L91.99,20.65L91.99,20.65z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+3 -2
View File
@@ -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:
+2 -1
View File
@@ -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 {
+3 -2
View File
@@ -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
+4 -2
View File
@@ -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];
+534
View File
@@ -0,0 +1,534 @@
#include "HttpServer.h"
#include <Arduino.h>
#include <WiFi.h>
#include <ArduinoJson.h>
#include "BuddyLogic.h"
#include "TamaLogic.h"
static HttpServerCtx* g_ctx = nullptr;
// ── HTML builder ──────────────────────────────────────────────────────────────
static String buildHtml()
{
String html = F(
"<!DOCTYPE html><html><head>"
"<meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Desk Buddy</title>"
"<style>"
"*{box-sizing:border-box}"
"body{font-family:monospace;background:#111;color:#ddd;margin:0;padding:16px}"
"h2{color:#4f4;margin:0 0 4px}"
".st{color:#888;font-size:12px;margin-bottom:14px}"
"table{width:100%;border-collapse:collapse}"
"th,td{padding:5px 8px;border:1px solid #2a2a2a;vertical-align:middle}"
"th{background:#1a1a1a;color:#777;font-size:11px}"
"input[type=text]{background:#181818;color:#ddd;border:1px solid #444;padding:3px 6px;"
"width:100%;font-family:monospace;font-size:12px}"
"select{background:#181818;color:#ddd;border:1px solid #444;padding:3px 6px;font-size:12px}"
"input[type=checkbox]{width:16px;height:16px;cursor:pointer}"
"button{background:#185;color:#fff;border:none;padding:8px 24px;cursor:pointer;"
"font-size:14px;margin-top:12px;border-radius:3px}"
"button:hover{background:#2a6}"
".gest{color:#9cf;font-weight:bold;white-space:nowrap}"
"</style></head><body>"
"<h2>Desk Buddy</h2>");
html += "<div class='st' style='display:flex;align-items:center;gap:12px'>";
html += "<span>IP: ";
html += WiFi.localIP().toString();
html += " &nbsp;|&nbsp; SSID: ";
html += g_ctx->wifiSsid;
html += "</span>";
html += "<button type='button' onclick='doRestart()'"
" style='background:#422;padding:4px 12px;font-size:12px;margin:0'>"
"Restart</button></div>"
"<div id='rstmsg' style='color:#f44;font-size:12px;height:14px;margin-bottom:6px'></div>"
"<script>"
"function doRestart(){"
"if(!confirm('Zrestartowac urzadzenie?'))return;"
"document.getElementById('rstmsg').textContent='Restartowanie...';"
"fetch('/api/restart',{method:'POST'}).catch(()=>{});"
"}"
"</script>";
html += "<form method='POST' action='/save'>"
"<table><tr><th>Gest</th><th>URL webhoka</th><th>Nastroj</th><th>Akcja</th><th>Potw.</th><th>ON</th></tr>";
for (uint8_t i = 0; i < NUM_GESTURES; i++) {
html += "<tr><td class='gest'>"; html += GNAME[i];
html += "</td><td><input type='text' name='url_"; html += GKEY[i];
html += "' value='"; html += g_ctx->gConfig[i].url;
html += "'></td><td><select name='mood_"; html += GKEY[i]; html += "'>";
for (uint8_t m = 0; m < 12; m++) {
html += "<option value='"; html += m; html += "'";
if (g_ctx->gConfig[i].mood == m) html += " selected";
html += ">"; html += MOOD_LABELS[m]; html += "</option>";
}
html += "</select></td><td><select name='act_"; html += GKEY[i]; html += "'>";
for (uint8_t a = 0; a < NUM_ACTIONS; a++) {
html += "<option value='"; html += a; html += "'";
if (g_ctx->gConfig[i].action == a) html += " selected";
html += ">"; html += ACTION_LABELS[a]; html += "</option>";
}
html += "</select></td><td style='text-align:center'>"
"<input type='checkbox' name='cfm_"; html += GKEY[i]; html += "'";
if (g_ctx->gConfig[i].confirm) html += " checked";
html += "></td><td style='text-align:center'>"
"<input type='checkbox' name='en_"; html += GKEY[i]; html += "'";
if (g_ctx->gConfig[i].enabled) html += " checked";
html += "></td></tr>";
}
html += "</table><button type='submit'>Zapisz</button></form>"
"<hr style='border-color:#222;margin:16px 0'>"
// ── Tama panel ────────────────────────────────────────────────────
"<h3 style='color:#4f4;margin:0 0 10px'>Tamagotchi</h3>"
"<div id='ts' style='margin-bottom:12px;font-size:13px'>"
"<div style='margin:5px 0'>"
"<span style='display:inline-block;width:70px;color:#888'>Glod:</span>"
"<div style='display:inline-block;background:#1a1a1a;width:180px;height:12px;"
"vertical-align:middle;border:1px solid #333'>"
"<div id='hb' style='height:100%;transition:width .4s'></div></div>"
"<span id='hv' style='color:#ddd;margin-left:8px'></span>"
"</div>"
"<div style='margin:5px 0'>"
"<span style='display:inline-block;width:70px;color:#888'>Radosc:</span>"
"<div style='display:inline-block;background:#1a1a1a;width:180px;height:12px;"
"vertical-align:middle;border:1px solid #333'>"
"<div id='pb' style='height:100%;transition:width .4s'></div></div>"
"<span id='pv' style='color:#ddd;margin-left:8px'></span>"
"</div>"
"<div style='margin:5px 0'>"
"<span style='display:inline-block;width:70px;color:#888'>Czystosc:</span>"
"<div style='display:inline-block;background:#1a1a1a;width:180px;height:12px;"
"vertical-align:middle;border:1px solid #333'>"
"<div id='cb' style='height:100%;transition:width .4s'></div></div>"
"<span id='cv' style='color:#ddd;margin-left:8px'></span>"
"</div>"
"</div>"
"<div style='margin-bottom:14px'>"
"<button type='button' onclick='ta(\"feed\")'"
" style='margin-right:8px'>Nakarm</button>"
"<button type='button' onclick='ta(\"play\")'"
" style='margin-right:8px'>Pobaw sie</button>"
"<button type='button' onclick='ta(\"clean\")'>Umyj</button>"
"</div>"
"<div id='tmsg' style='color:#4f4;font-size:12px;height:16px'></div>"
"<script>"
"function bar(id,val,invert){"
"var el=document.getElementById(id);"
"var pct=invert?(100-val):val;"
"var h=pct<30?'#e55':pct<60?'#fa0':'#3c3';"
"el.style.width=val+'%';el.style.background=h;"
"}"
"function loadT(){"
"fetch('/api/tama').then(r=>r.json()).then(d=>{"
"bar('hb',d.hunger,true);"
"document.getElementById('hv').textContent=d.hunger+'%';"
"bar('pb',d.happiness,false);"
"document.getElementById('pv').textContent=d.happiness+'%';"
"bar('cb',d.hygiene,false);"
"document.getElementById('cv').textContent=d.hygiene+'%';"
"}).catch(()=>{});"
"}"
"function ta(a){"
"fetch('/api/tama/'+a,{method:'POST'})"
".then(r=>r.json()).then(d=>{"
"var m=document.getElementById('tmsg');"
"m.textContent=d.msg||'OK';"
"setTimeout(()=>{m.textContent='';},3000);"
"setTimeout(loadT,400);"
"});"
"}"
"loadT();setInterval(loadT,5000);"
"</script>"
"<hr style='border-color:#222;margin:16px 0'>"
// ── Weather panel ─────────────────────────────────────────────────
"<h3 style='color:#4f4;margin:0 0 10px'>Pogoda</h3>"
"<div id='ws' style='margin-bottom:12px;font-size:13px;color:#888'>"
"Ladowanie..."
"</div>"
"<form method='POST' action='/weather/save'>"
"<table style='width:auto'>"
"<tr><td style='color:#888;font-size:11px;padding:4px 8px'>Miasto</td>"
"<td><input type='text' name='city' id='wcity'"
" style='width:200px' placeholder='np. Warszawa'></td></tr>"
"<tr><td style='color:#888;font-size:11px;padding:4px 8px'>Co ile sekund</td>"
"<td><input type='number' name='interval' id='winterval'"
" style='width:80px' min='30' max='86400'></td></tr>"
"<tr><td style='color:#888;font-size:11px;padding:4px 8px'>Pokazuj przez (s)</td>"
"<td><input type='number' name='duration' id='wduration'"
" style='width:80px' min='3' max='300'></td></tr>"
"</table>"
"<button type='submit' style='margin-top:8px'>Zapisz pogode</button>"
"</form>"
"<div id='wmsg' style='color:#4f4;font-size:12px;height:16px;margin-top:4px'></div>"
"<div style='margin-top:10px;margin-bottom:4px;color:#888;font-size:11px'>Podglad widoku (15s):</div>"
"<div style='display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px'>"
"<button type='button' onclick='tw(1)' style='background:#553'>&#9728; Slonce</button>"
"<button type='button' onclick='tw(2)' style='background:#445'>&#9925; Zmienne</button>"
"<button type='button' onclick='tw(3)' style='background:#334'>&#9729; Chmury</button>"
"<button type='button' onclick='tw(4)' style='background:#248'>&#127783; Deszcz</button>"
"<button type='button' onclick='tw(5)' style='background:#446'>&#10052; Snieg</button>"
"<button type='button' onclick='tw(6)' style='background:#524'>&#9928; Burza</button>"
"</div>"
"<div id='wtmsg' style='color:#4f4;font-size:12px;height:16px'></div>"
"<script>"
"function tw(i){"
"fetch('/api/weather/test?icon='+i,{method:'POST'})"
".then(r=>r.json()).then(d=>{"
"var el=document.getElementById('wtmsg');"
"el.textContent=d.msg||'OK';"
"setTimeout(function(){el.textContent='';},3000);"
"});"
"}"
"</script>"
"<script>"
"var wicons=['','Slonce','Zmienne','Chmury','Deszcz','Snieg','Burza'];"
"fetch('/api/weather').then(function(r){return r.json();}).then(function(d){"
"var s=document.getElementById('ws');"
"if(d.valid){"
"s.style.color='#ddd';"
"s.textContent=(wicons[d.icon]||'?')+' '+d.temp+'\\u00b0C '+d.pressure+' hPa';"
"} else {"
"s.textContent='Brak danych (ustaw miasto, dane z Open-Meteo)';"
"}"
"document.getElementById('wcity').value=d.city||'';"
"document.getElementById('winterval').value=d.interval||300;"
"document.getElementById('wduration').value=d.duration||10;"
"}).catch(function(){"
"document.getElementById('ws').textContent='Blad ladowania';"
"});"
"</script>"
"<hr style='border-color:#222;margin:16px 0'>"
// ── Mood test panel ───────────────────────────────────────────────
"<h3 style='color:#4f4;margin:0 0 10px'>Test nastroju</h3>"
"<div style='margin-bottom:8px;display:flex;flex-wrap:wrap;gap:6px'>"
"<button type='button' onclick='tm(0)' style='background:#333'>Normal</button>"
"<button type='button' onclick='tm(1)' style='background:#185'>Happy</button>"
"<button type='button' onclick='tm(2)' style='background:#226'>Sleepy</button>"
"<button type='button' onclick='tm(3)' style='background:#550'>Surprised</button>"
"<button type='button' onclick='tm(4)' style='background:#622'>Angry</button>"
"<button type='button' onclick='tm(5)' style='background:#246'>Sad</button>"
"<button type='button' onclick='tm(6)' style='background:#185'>Excited</button>"
"<button type='button' onclick='tm(7)' style='background:#444'>Wink L</button>"
"<button type='button' onclick='tm(8)' style='background:#444'>Wink R</button>"
"<button type='button' onclick='tm(9)' style='background:#532'>Hungry</button>"
"<button type='button' onclick='tm(10)' style='background:#245'>Playful</button>"
"<button type='button' onclick='tm(11)' style='background:#432'>Dirty</button>"
"</div>"
"<div id='mmsg' style='color:#4f4;font-size:12px;height:16px'></div>"
"<script>"
"function tm(m){"
"fetch('/api/mood/test?m='+m,{method:'POST'})"
".then(r=>r.json()).then(d=>{"
"var el=document.getElementById('mmsg');"
"el.textContent=d.msg||'OK';"
"setTimeout(function(){el.textContent='';},3000);"
"});"
"}"
"</script>"
"<hr style='border-color:#222;margin:16px 0'>"
// ── Alert panel ───────────────────────────────────────────────────
"<h3 style='color:#f84;margin:0 0 10px'>Alert</h3>"
"<div style='margin-bottom:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap'>"
"<input type='text' id='alMsg' placeholder='Krotka wiadomosc (max 31 znak.)'"
" maxlength='31' style='width:280px'>"
"<input type='number' id='alDur' value='10' min='1' max='60'"
" style='width:60px' title='Czas wyswietlania (s)'>"
"<span style='color:#888;font-size:11px'>sek</span>"
"<button type='button' onclick='alSend()' style='background:#742'>Wyslij alert</button>"
"<button type='button' onclick='alClear()' style='background:#333'>Skasuj</button>"
"</div>"
"<div id='almsg' style='color:#f84;font-size:12px;height:16px'></div>"
"<script>"
"function alSend(){"
"var msg=document.getElementById('alMsg').value;"
"var dur=parseInt(document.getElementById('alDur').value)||10;"
"fetch('/api/alert',{method:'POST',"
"headers:{'Content-Type':'application/json'},"
"body:JSON.stringify({message:msg,duration:dur})})"
".then(r=>r.json()).then(d=>{"
"var el=document.getElementById('almsg');"
"el.textContent=d.msg||'OK';"
"setTimeout(()=>{el.textContent='';},3000);"
"});"
"}"
"function alClear(){"
"fetch('/api/alert/clear',{method:'POST'})"
".then(r=>r.json()).then(d=>{"
"var el=document.getElementById('almsg');"
"el.textContent=d.msg||'OK';"
"setTimeout(()=>{el.textContent='';},2000);"
"});"
"}"
"</script>"
"<hr style='border-color:#222;margin:16px 0'>"
"<p style='color:#555;font-size:11px'>GET /api/config &nbsp; POST /api/config (JSON)"
" &nbsp;|&nbsp; GET /api/tama &nbsp; POST /api/tama/{feed,play,clean}"
" &nbsp;|&nbsp; GET /api/weather &nbsp; POST /weather/save"
" &nbsp;|&nbsp; POST /api/weather/test?icon={1-6}"
" &nbsp;|&nbsp; POST /api/mood/test?m={0-11}"
" &nbsp;|&nbsp; POST /api/alert {message,duration}"
" &nbsp;|&nbsp; POST /api/alert/clear</p>"
"</body></html>";
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<JsonObject>()) continue;
JsonObject g = doc[GNAME[i]];
if (g["url"].is<const char*>())
strncpy(g_ctx->gConfig[i].url, g["url"] | "", sizeof(g_ctx->gConfig[i].url) - 1);
if (g["mood"].is<int>()) g_ctx->gConfig[i].mood = (uint8_t)(int)g["mood"];
if (g["action"].is<int>()) g_ctx->gConfig[i].action = (uint8_t)(int)g["action"];
if (g["enabled"].is<bool>()) g_ctx->gConfig[i].enabled = (bool)g["enabled"];
if (g["confirm"].is<bool>()) 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());
}
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <ESPAsyncWebServer.h>
#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);
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <stdint.h>
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;
};
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <stdint.h>
// 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, };
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <stdint.h>
// 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, };
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <stdint.h>
// 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, };
+269 -460
View File
@@ -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<JsonArray>() ||
geoDoc["results"].as<JsonArray>().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<JsonArray>() ||
geoDoc["results"].as<JsonArray>().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)
"&current=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(
"<!DOCTYPE html><html><head>"
"<meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Desk Buddy</title>"
"<style>"
"*{box-sizing:border-box}"
"body{font-family:monospace;background:#111;color:#ddd;margin:0;padding:16px}"
"h2{color:#4f4;margin:0 0 4px}"
".st{color:#888;font-size:12px;margin-bottom:14px}"
"table{width:100%;border-collapse:collapse}"
"th,td{padding:5px 8px;border:1px solid #2a2a2a;vertical-align:middle}"
"th{background:#1a1a1a;color:#777;font-size:11px}"
"input[type=text]{background:#181818;color:#ddd;border:1px solid #444;padding:3px 6px;"
"width:100%;font-family:monospace;font-size:12px}"
"select{background:#181818;color:#ddd;border:1px solid #444;padding:3px 6px;font-size:12px}"
"input[type=checkbox]{width:16px;height:16px;cursor:pointer}"
"button{background:#185;color:#fff;border:none;padding:8px 24px;cursor:pointer;"
"font-size:14px;margin-top:12px;border-radius:3px}"
"button:hover{background:#2a6}"
".gest{color:#9cf;font-weight:bold;white-space:nowrap}"
"</style></head><body>"
"<h2>Desk Buddy</h2>");
html += "<div class='st'>IP: ";
html += WiFi.localIP().toString();
html += " &nbsp;|&nbsp; SSID: ";
html += WIFI_SSID;
html += "</div>";
html += "<form method='POST' action='/save'>"
"<table><tr><th>Gest</th><th>URL webhoka</th><th>Nastroj</th><th>Akcja</th><th>ON</th></tr>";
for (uint8_t i = 0; i < NUM_GESTURES; i++) {
html += "<tr><td class='gest'>"; html += GNAME[i];
html += "</td><td><input type='text' name='url_"; html += GKEY[i];
html += "' value='"; html += gConfig[i].url;
html += "'></td><td><select name='mood_"; html += GKEY[i]; html += "'>";
for (uint8_t m = 0; m < 12; m++) {
html += "<option value='"; html += m; html += "'";
if (gConfig[i].mood == m) html += " selected";
html += ">"; html += MOOD_LABELS[m]; html += "</option>";
}
html += "</select></td><td><select name='act_"; html += GKEY[i]; html += "'>";
for (uint8_t a = 0; a < NUM_ACTIONS; a++) {
html += "<option value='"; html += a; html += "'";
if (gConfig[i].action == a) html += " selected";
html += ">"; html += ACTION_LABELS[a]; html += "</option>";
}
html += "</select></td><td style='text-align:center'>"
"<input type='checkbox' name='en_"; html += GKEY[i]; html += "'";
if (gConfig[i].enabled) html += " checked";
html += "></td></tr>";
}
html += "</table><button type='submit'>Zapisz</button></form>"
"<hr style='border-color:#222;margin:16px 0'>"
// ── Tama panel ────────────────────────────────────────────────────
"<h3 style='color:#4f4;margin:0 0 10px'>Tamagotchi</h3>"
"<div id='ts' style='margin-bottom:12px;font-size:13px'>"
"<div style='margin:5px 0'>"
"<span style='display:inline-block;width:70px;color:#888'>Glod:</span>"
"<div style='display:inline-block;background:#1a1a1a;width:180px;height:12px;"
"vertical-align:middle;border:1px solid #333'>"
"<div id='hb' style='height:100%;transition:width .4s'></div></div>"
"<span id='hv' style='color:#ddd;margin-left:8px'></span>"
"</div>"
"<div style='margin:5px 0'>"
"<span style='display:inline-block;width:70px;color:#888'>Radosc:</span>"
"<div style='display:inline-block;background:#1a1a1a;width:180px;height:12px;"
"vertical-align:middle;border:1px solid #333'>"
"<div id='pb' style='height:100%;transition:width .4s'></div></div>"
"<span id='pv' style='color:#ddd;margin-left:8px'></span>"
"</div>"
"<div style='margin:5px 0'>"
"<span style='display:inline-block;width:70px;color:#888'>Czystosc:</span>"
"<div style='display:inline-block;background:#1a1a1a;width:180px;height:12px;"
"vertical-align:middle;border:1px solid #333'>"
"<div id='cb' style='height:100%;transition:width .4s'></div></div>"
"<span id='cv' style='color:#ddd;margin-left:8px'></span>"
"</div>"
"</div>"
"<div style='margin-bottom:14px'>"
"<button type='button' onclick='ta(\"feed\")'"
" style='margin-right:8px'>Nakarm</button>"
"<button type='button' onclick='ta(\"play\")'"
" style='margin-right:8px'>Pobaw sie</button>"
"<button type='button' onclick='ta(\"clean\")'>Umyj</button>"
"</div>"
"<div id='tmsg' style='color:#4f4;font-size:12px;height:16px'></div>"
"<script>"
"function bar(id,val,invert){"
"var el=document.getElementById(id);"
"var pct=invert?(100-val):val;" // hunger: invert (full bar=starving)
"var h=pct<30?'#e55':pct<60?'#fa0':'#3c3';"
"el.style.width=val+'%';el.style.background=h;"
"}"
"function loadT(){"
"fetch('/api/tama').then(r=>r.json()).then(d=>{"
"bar('hb',d.hunger,true);"
"document.getElementById('hv').textContent=d.hunger+'%';"
"bar('pb',d.happiness,false);"
"document.getElementById('pv').textContent=d.happiness+'%';"
"bar('cb',d.hygiene,false);"
"document.getElementById('cv').textContent=d.hygiene+'%';"
"}).catch(()=>{});"
"}"
"function ta(a){"
"fetch('/api/tama/'+a,{method:'POST'})"
".then(r=>r.json()).then(d=>{"
"var m=document.getElementById('tmsg');"
"m.textContent=d.msg||'OK';"
"setTimeout(()=>{m.textContent='';},3000);"
"setTimeout(loadT,400);"
"});"
"}"
"loadT();setInterval(loadT,5000);"
"</script>"
"<hr style='border-color:#222;margin:16px 0'>"
// ── Weather panel ─────────────────────────────────────────────────
"<h3 style='color:#4f4;margin:0 0 10px'>Pogoda</h3>"
"<div id='ws' style='margin-bottom:12px;font-size:13px;color:#888'>"
"Ladowanie..."
"</div>"
"<form method='POST' action='/weather/save'>"
"<table style='width:auto'>"
"<tr><td style='color:#888;font-size:11px;padding:4px 8px'>Miasto</td>"
"<td><input type='text' name='city' id='wcity'"
" style='width:200px' placeholder='np. Warszawa'></td></tr>"
"<tr><td style='color:#888;font-size:11px;padding:4px 8px'>Co ile sekund</td>"
"<td><input type='number' name='interval' id='winterval'"
" style='width:80px' min='30' max='86400'></td></tr>"
"<tr><td style='color:#888;font-size:11px;padding:4px 8px'>Pokazuj przez (s)</td>"
"<td><input type='number' name='duration' id='wduration'"
" style='width:80px' min='3' max='300'></td></tr>"
"</table>"
"<button type='submit' style='margin-top:8px'>Zapisz pogode</button>"
"</form>"
"<div id='wmsg' style='color:#4f4;font-size:12px;height:16px;margin-top:4px'></div>"
"<script>"
"var wicons=['','Slonce','Zmienne','Chmury','Deszcz','Snieg','Burza'];"
"fetch('/api/weather').then(function(r){return r.json();}).then(function(d){"
"var s=document.getElementById('ws');"
"if(d.valid){"
"s.style.color='#ddd';"
"s.textContent=(wicons[d.icon]||'?')+' '+d.temp+'\\u00b0C '+d.pressure+' hPa';"
"} else {"
"s.textContent='Brak danych (ustaw miasto, dane z Open-Meteo)';"
"}"
"document.getElementById('wcity').value=d.city||'';"
"document.getElementById('winterval').value=d.interval||300;"
"document.getElementById('wduration').value=d.duration||10;"
"}).catch(function(){"
"document.getElementById('ws').textContent='Blad ladowania';"
"});"
"</script>"
"<hr style='border-color:#222;margin:16px 0'>"
// ── Mood test panel ───────────────────────────────────────────────
"<h3 style='color:#4f4;margin:0 0 10px'>Test nastroju</h3>"
"<div style='margin-bottom:8px;display:flex;flex-wrap:wrap;gap:6px'>"
"<button type='button' onclick='tm(0)' style='background:#333'>Normal</button>"
"<button type='button' onclick='tm(1)' style='background:#185'>Happy</button>"
"<button type='button' onclick='tm(2)' style='background:#226'>Sleepy</button>"
"<button type='button' onclick='tm(3)' style='background:#550'>Surprised</button>"
"<button type='button' onclick='tm(4)' style='background:#622'>Angry</button>"
"<button type='button' onclick='tm(5)' style='background:#246'>Sad</button>"
"<button type='button' onclick='tm(6)' style='background:#185'>Excited</button>"
"<button type='button' onclick='tm(7)' style='background:#444'>Wink L</button>"
"<button type='button' onclick='tm(8)' style='background:#444'>Wink R</button>"
"<button type='button' onclick='tm(9)' style='background:#532'>Hungry</button>"
"<button type='button' onclick='tm(10)' style='background:#245'>Playful</button>"
"<button type='button' onclick='tm(11)' style='background:#432'>Dirty</button>"
"</div>"
"<div id='mmsg' style='color:#4f4;font-size:12px;height:16px'></div>"
"<script>"
"function tm(m){"
"fetch('/api/mood/test?m='+m,{method:'POST'})"
".then(r=>r.json()).then(d=>{"
"var el=document.getElementById('mmsg');"
"el.textContent=d.msg||'OK';"
"setTimeout(function(){el.textContent='';},3000);"
"});"
"}"
"</script>"
"<hr style='border-color:#222;margin:16px 0'>"
"<p style='color:#555;font-size:11px'>GET /api/config &nbsp; POST /api/config (JSON)"
" &nbsp;|&nbsp; GET /api/tama &nbsp; POST /api/tama/{feed,play,clean}"
" &nbsp;|&nbsp; GET /api/weather &nbsp; POST /weather/save"
" &nbsp;|&nbsp; POST /api/mood/test?m={0-11}</p>"
"</body></html>";
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<JsonObject>()) continue;
JsonObject g = doc[GNAME[i]];
if (g["url"].is<const char*>())
strncpy(gConfig[i].url, g["url"] | "", sizeof(gConfig[i].url) - 1);
if (g["mood"].is<int>()) gConfig[i].mood = (uint8_t)(int)g["mood"];
if (g["action"].is<int>()) gConfig[i].action = (uint8_t)(int)g["action"];
if (g["enabled"].is<bool>()) 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();
}
+97
View File
@@ -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 <stdint.h>
// 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 <stdint.h>
// 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."