feat: mapy bitowe z svg, dodanie potwierdzania akcji, reset urządzenia
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 += " | 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'>☀ Slonce</button>"
|
||||
"<button type='button' onclick='tw(2)' style='background:#445'>⛅ Zmienne</button>"
|
||||
"<button type='button' onclick='tw(3)' style='background:#334'>☁ Chmury</button>"
|
||||
"<button type='button' onclick='tw(4)' style='background:#248'>🌧 Deszcz</button>"
|
||||
"<button type='button' onclick='tw(5)' style='background:#446'>❄ Snieg</button>"
|
||||
"<button type='button' onclick='tw(6)' style='background:#524'>⛈ 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 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</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());
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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
@@ -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)
|
||||
"¤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(
|
||||
"<!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 += " | 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 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}</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();
|
||||
}
|
||||
|
||||
Executable
+97
@@ -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."
|
||||
Reference in New Issue
Block a user