feat: initial commit

This commit is contained in:
2026-06-05 01:03:27 +02:00
commit 65bd552aec
8 changed files with 1285 additions and 0 deletions
+874
View File
@@ -0,0 +1,874 @@
#include <Arduino.h>
#include <Wire.h>
#include <U8g2lib.h>
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <ArduinoJson.h>
#include <RevEng_PAJ7620.h>
// ── Hardware ──────────────────────────────────────────────────────────────────
// Both SSD1306 (0x3C) and PAJ7620 (0x73) share Wire on GPIO22(SDA)/GPIO23(SCL)
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE, 23, 22);
RevEng_PAJ7620 sensor;
const char *WIFI_SSID = "SSID";
const char *WIFI_PASS = "PASSWORD";
// ── Gesture config ─────────────────────────────────────────────────────────────
// Index 0-8 maps to GES_UP..GES_WAVE (Gesture enum value - 1)
static const uint8_t NUM_GESTURES = 9;
// Human-readable names (used in JSON API and HTML)
static const char *GNAME[NUM_GESTURES] = {
"up", "down", "left", "right", "forward", "backward", "clockwise", "anticlockwise", "wave"};
// Short NVS key prefixes (≤4 chars so full key "wh.ccw.url" ≤ 15)
static const char *GKEY[NUM_GESTURES] = {
"u", "d", "l", "r", "f", "b", "cw", "ccw", "w"};
// Mood labels matching Mood enum values
static const char *MOOD_LABELS[] = {
"-- bez zmiany --", "happy ^_^", "sleepy zZz", "surprised o_O",
"angry >_<", "sad T_T", "excited *_*"};
// Actions that can be triggered by a gesture
enum Action
{
ACTION_NONE = 0,
ACTION_DATETIME = 1,
ACTION_WIFI = 2
};
static const uint8_t NUM_ACTIONS = 3;
static const char *ACTION_LABELS[] = {"-- brak --", "Data i godzina", "Status WiFi"};
struct GestureConfig
{
char url[128];
uint8_t mood; // 0=no change, 1=happy..6=excited (matches Mood enum)
uint8_t action; // Action enum
bool enabled;
};
GestureConfig gConfig[NUM_GESTURES];
Preferences prefs;
AsyncWebServer httpServer(80);
// ── Buddy ─────────────────────────────────────────────────────────────────────
enum Mood
{
MOOD_NORMAL = 0,
MOOD_HAPPY,
MOOD_SLEEPY,
MOOD_SURPRISED,
MOOD_ANGRY,
MOOD_SAD,
MOOD_EXCITED
};
enum BlinkState
{
BLINK_OPEN,
BLINK_CLOSING,
BLINK_CLOSED,
BLINK_OPENING
};
static const uint8_t EYE_L_X = 38;
static const uint8_t EYE_R_X = 90;
static const uint8_t EYE_Y = 30;
static const uint8_t EYE_RX = 17;
static const uint8_t EYE_RY = 15;
static const uint8_t PUPIL_R = 6;
struct
{
Mood mood;
uint32_t revertAt;
uint32_t lastEvent;
BlinkState blinkState;
uint8_t blinkRy;
uint8_t closedTicks;
uint32_t nextBlink;
int8_t pupilDx, pupilDy;
int8_t pupilTargetDx, pupilTargetDy;
uint32_t nextLook;
uint8_t zzzPhase;
uint32_t nextZzz;
} buddy;
// ── Action overlay ────────────────────────────────────────────────────────────
uint32_t overlayUntil = 0; // show overlay until this timestamp
Action overlayAction = ACTION_NONE;
void setBuddyMood(Mood m, uint32_t durationMs = 0)
{
buddy.mood = m;
buddy.revertAt = (durationMs > 0) ? millis() + durationMs : 0;
buddy.lastEvent = millis();
switch (m)
{
case MOOD_HAPPY:
buddy.pupilTargetDx = 0;
buddy.pupilTargetDy = -2;
break;
case MOOD_SLEEPY:
buddy.pupilTargetDx = 0;
buddy.pupilTargetDy = 4;
break;
case MOOD_SURPRISED:
buddy.pupilTargetDx = 0;
buddy.pupilTargetDy = 0;
break;
case MOOD_SAD:
buddy.pupilTargetDx = 0;
buddy.pupilTargetDy = 4;
break;
case MOOD_ANGRY:
buddy.pupilTargetDx = 2;
buddy.pupilTargetDy = 2;
break;
default:
break;
}
}
void initBuddy()
{
buddy = {};
buddy.blinkState = BLINK_OPEN;
buddy.blinkRy = EYE_RY;
buddy.lastEvent = millis();
buddy.nextBlink = millis() + 3000;
buddy.nextLook = millis() + 2000;
buddy.nextZzz = millis() + 3000;
}
// ── Eye drawing ───────────────────────────────────────────────────────────────
static void drawEye(uint8_t cx, uint8_t cy, uint8_t effRy,
int8_t pdx, int8_t pdy, bool isLeft)
{
if (effRy == 0)
return;
switch (buddy.mood)
{
case MOOD_HAPPY:
u8g2.setDrawColor(1);
u8g2.drawFilledEllipse(cx, cy, EYE_RX, effRy, U8G2_DRAW_ALL);
u8g2.setDrawColor(0);
u8g2.drawBox(cx - EYE_RX - 1, cy - effRy - 1, EYE_RX * 2 + 3, effRy + 2);
u8g2.setDrawColor(1);
break;
case MOOD_SLEEPY:
u8g2.setDrawColor(1);
u8g2.drawFilledEllipse(cx, cy, EYE_RX, effRy, U8G2_DRAW_ALL);
u8g2.setDrawColor(0);
u8g2.drawBox(cx - EYE_RX - 1, cy - effRy - 1, EYE_RX * 2 + 3, (effRy * 3) / 2 + 1);
u8g2.setDrawColor(1);
if (effRy > 3)
{
u8g2.setDrawColor(0);
u8g2.drawDisc(cx + pdx, cy + effRy - 3, (uint8_t)(PUPIL_R - 3));
u8g2.setDrawColor(1);
}
break;
case MOOD_SURPRISED:
u8g2.setDrawColor(1);
u8g2.drawFilledEllipse(cx, cy, EYE_RX + 2, effRy, U8G2_DRAW_ALL);
u8g2.setDrawColor(0);
u8g2.drawDisc(cx, cy, (uint8_t)(PUPIL_R - 2));
u8g2.setDrawColor(1);
u8g2.drawDisc(cx - 3, cy - 2, 2);
{
int8_t s = isLeft ? -2 : 2;
u8g2.drawLine(cx - EYE_RX + 2, cy - effRy - 6, cx + EYE_RX - 2, cy - effRy - 6 + s);
}
break;
case MOOD_ANGRY:
u8g2.setDrawColor(1);
u8g2.drawFilledEllipse(cx, cy, EYE_RX, effRy, U8G2_DRAW_ALL);
u8g2.setDrawColor(0);
u8g2.drawDisc(cx + pdx, cy + pdy, PUPIL_R);
u8g2.setDrawColor(1);
u8g2.drawDisc(cx + pdx - 2, cy + pdy - 2, 2);
if (isLeft)
u8g2.drawLine(cx - EYE_RX + 2, cy - effRy - 4, cx + EYE_RX - 2, cy - effRy - 9);
else
u8g2.drawLine(cx - EYE_RX + 2, cy - effRy - 9, cx + EYE_RX - 2, cy - effRy - 4);
break;
case MOOD_SAD:
u8g2.setDrawColor(1);
u8g2.drawFilledEllipse(cx, cy, EYE_RX, effRy, U8G2_DRAW_ALL);
u8g2.setDrawColor(0);
u8g2.drawDisc(cx + pdx, cy + pdy, PUPIL_R);
u8g2.setDrawColor(1);
u8g2.drawDisc(cx + pdx - 2, cy + pdy - 2, 2);
if (isLeft)
u8g2.drawLine(cx - EYE_RX + 2, cy - effRy - 9, cx + EYE_RX - 2, cy - effRy - 4);
else
u8g2.drawLine(cx - EYE_RX + 2, cy - effRy - 4, cx + EYE_RX - 2, cy - effRy - 9);
u8g2.drawLine(cx + (isLeft ? 4 : -4), cy + effRy,
cx + (isLeft ? 4 : -4), cy + effRy + 7);
u8g2.drawDisc(cx + (isLeft ? 4 : -4), cy + effRy + 8, 2);
break;
case MOOD_EXCITED:
u8g2.setDrawColor(1);
u8g2.drawCircle(cx, cy, EYE_RX - 1, U8G2_DRAW_ALL);
u8g2.drawLine(cx - 9, cy - 9, cx + 9, cy + 9);
u8g2.drawLine(cx + 9, cy - 9, cx - 9, cy + 9);
u8g2.drawLine(cx - 11, cy, cx + 11, cy);
u8g2.drawLine(cx, cy - 11, cx, cy + 11);
u8g2.drawDisc(cx, cy, 3);
break;
default:
u8g2.setDrawColor(1);
u8g2.drawFilledEllipse(cx, cy, EYE_RX, effRy, U8G2_DRAW_ALL);
if (effRy > 3)
{
int8_t pr = (effRy >= PUPIL_R) ? (int8_t)PUPIL_R : (int8_t)(effRy - 1);
u8g2.setDrawColor(0);
u8g2.drawDisc(cx + pdx, cy + pdy, (uint8_t)pr);
if (pr >= 3)
{
u8g2.setDrawColor(1);
u8g2.drawDisc(cx + pdx - 2, cy + pdy - 2, 2);
}
}
u8g2.setDrawColor(1);
break;
}
}
// ── Date/time overlay ─────────────────────────────────────────────────────────
static const char *DAYS_PL[] = {
"Niedziela", "Poniedzialek", "Wtorek", "Sroda",
"Czwartek", "Piatek", "Sobota"};
static void showWiFiStatusScreen()
{
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_6x10_tr);
u8g2.setDrawColor(1);
if (WiFi.status() != WL_CONNECTED)
{
u8g2.drawStr(20, 32, "WiFi: brak pol.");
u8g2.sendBuffer();
return;
}
char rssi[16];
snprintf(rssi, sizeof(rssi), "RSSI: %d dBm", WiFi.RSSI());
// Signal bar (5 bars, each 3px wide, spaced 5px)
int8_t level = WiFi.RSSI(); // dBm, typically -30 (great) to -90 (poor)
uint8_t bars = (level >= -55) ? 5 : (level >= -65) ? 4
: (level >= -72) ? 3
: (level >= -80) ? 2
: 1;
for (uint8_t b = 0; b < 5; b++)
{
uint8_t h = 3 + b * 3; // heights: 3,6,9,12,15
uint8_t x = 78 + b * 8;
uint8_t y = 14;
if (b < bars)
u8g2.drawBox(x, y - h, 5, h);
else
u8g2.drawFrame(x, y - h, 5, h);
}
u8g2.drawStr(0, 12, "WiFi");
u8g2.drawStr(0, 26, WiFi.SSID().c_str());
u8g2.drawStr(0, 40, WiFi.localIP().toString().c_str());
u8g2.drawStr(0, 54, rssi);
u8g2.sendBuffer();
}
static void showDateTimeScreen()
{
struct tm t;
if (!getLocalTime(&t))
{
// NTP not synced yet
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_6x10_tr);
u8g2.drawStr(20, 32, "Brak czasu NTP");
u8g2.sendBuffer();
return;
}
char timeBuf[9]; // HH:MM:SS
char dateBuf[11]; // DD.MM.YYYY
strftime(timeBuf, sizeof(timeBuf), "%H:%M:%S", &t);
strftime(dateBuf, sizeof(dateBuf), "%d.%m.%Y", &t);
const char *dayName = DAYS_PL[t.tm_wday];
u8g2.clearBuffer();
u8g2.setDrawColor(1);
// Time — large font, centered
u8g2.setFont(u8g2_font_10x20_tr);
uint8_t tw = u8g2.getStrWidth(timeBuf);
u8g2.drawStr((128 - tw) / 2, 22, timeBuf);
// Date — medium font, centered
u8g2.setFont(u8g2_font_6x10_tr);
uint8_t dw = u8g2.getStrWidth(dateBuf);
u8g2.drawStr((128 - dw) / 2, 40, dateBuf);
// Day of week — small font, centered
u8g2.setFont(u8g2_font_5x7_tr);
uint8_t nw = u8g2.getStrWidth(dayName);
u8g2.drawStr((128 - nw) / 2, 56, dayName);
u8g2.sendBuffer();
}
void showBuddyScreen()
{
// Action overlay takes priority
if (overlayAction != ACTION_NONE && millis() < overlayUntil)
{
switch (overlayAction)
{
case ACTION_DATETIME:
showDateTimeScreen();
break;
case ACTION_WIFI:
showWiFiStatusScreen();
break;
default:
break;
}
return;
}
overlayAction = ACTION_NONE;
u8g2.clearBuffer();
u8g2.setDrawColor(1);
uint8_t effRy = buddy.blinkRy;
if (buddy.mood == MOOD_HAPPY)
effRy = min(effRy, (uint8_t)13);
if (buddy.mood == MOOD_SLEEPY)
effRy = min(effRy, (uint8_t)11);
if (buddy.mood == MOOD_SURPRISED)
effRy = EYE_RY + 3;
drawEye(EYE_L_X, EYE_Y, effRy, buddy.pupilDx, buddy.pupilDy, true);
drawEye(EYE_R_X, EYE_Y, effRy, -buddy.pupilDx, buddy.pupilDy, false);
const char *labels[] = {"", "^_^", "zZz", "o_O", ">_<", "T_T", "*_*"};
u8g2.setFont(u8g2_font_5x7_tr);
uint8_t lw = u8g2.getStrWidth(labels[buddy.mood]);
u8g2.drawStr((128 - lw) / 2, 63, labels[buddy.mood]);
if (buddy.mood == MOOD_SLEEPY && buddy.zzzPhase > 0)
{
const char *zStr[] = {"z", "zz", "zzz"};
uint8_t zi = buddy.zzzPhase - 1;
u8g2.drawStr(EYE_R_X + EYE_RX + 2 + zi * 2, EYE_Y - EYE_RY - 3 - zi * 5, zStr[zi]);
}
u8g2.sendBuffer();
}
void updateBuddyAnim()
{
uint32_t now = millis();
if (buddy.revertAt > 0 && now >= buddy.revertAt)
{
buddy.mood = MOOD_NORMAL;
buddy.revertAt = 0;
}
if (buddy.mood == MOOD_NORMAL && now - buddy.lastEvent > 300000UL)
setBuddyMood(MOOD_SLEEPY, 0);
if (buddy.mood != MOOD_SURPRISED && buddy.mood != MOOD_EXCITED)
{
switch (buddy.blinkState)
{
case BLINK_OPEN:
if (now >= buddy.nextBlink)
buddy.blinkState = BLINK_CLOSING;
break;
case BLINK_CLOSING:
if (buddy.blinkRy > 3)
buddy.blinkRy -= 4;
else
{
buddy.blinkRy = 1;
buddy.blinkState = BLINK_CLOSED;
buddy.closedTicks = 0;
}
break;
case BLINK_CLOSED:
if (buddy.closedTicks++ >= 2)
buddy.blinkState = BLINK_OPENING;
break;
case BLINK_OPENING:
buddy.blinkRy += 4;
if (buddy.blinkRy >= EYE_RY)
{
buddy.blinkRy = EYE_RY;
buddy.blinkState = BLINK_OPEN;
buddy.nextBlink = now + ((buddy.mood == MOOD_SLEEPY) ? random(800, 2000) : random(2500, 6000));
}
break;
}
}
else
{
buddy.blinkRy = EYE_RY;
}
if (buddy.mood == MOOD_NORMAL && now >= buddy.nextLook)
{
buddy.pupilTargetDx = (int8_t)random(-6, 7);
buddy.pupilTargetDy = (int8_t)random(-4, 5);
buddy.nextLook = now + random(1500, 4000);
}
if (buddy.pupilDx < buddy.pupilTargetDx)
buddy.pupilDx++;
else if (buddy.pupilDx > buddy.pupilTargetDx)
buddy.pupilDx--;
if (buddy.pupilDy < buddy.pupilTargetDy)
buddy.pupilDy++;
else if (buddy.pupilDy > buddy.pupilTargetDy)
buddy.pupilDy--;
if (buddy.mood == MOOD_SLEEPY && now >= buddy.nextZzz)
{
buddy.zzzPhase = (buddy.zzzPhase % 3) + 1;
buddy.nextZzz = now + 700;
}
}
// ── Config persistence (NVS via Preferences) ──────────────────────────────────
void loadConfig()
{
prefs.begin("buddy", false); // false = read-write, creates namespace if missing
for (uint8_t i = 0; i < NUM_GESTURES; i++)
{
char key[16];
snprintf(key, sizeof(key), "wh.%s.url", GKEY[i]);
String url = prefs.getString(key, "");
strncpy(gConfig[i].url, url.c_str(), sizeof(gConfig[i].url) - 1);
snprintf(key, sizeof(key), "wh.%s.mood", GKEY[i]);
gConfig[i].mood = (uint8_t)prefs.getUInt(key, 0);
snprintf(key, sizeof(key), "wh.%s.en", GKEY[i]);
gConfig[i].enabled = prefs.getBool(key, true);
snprintf(key, sizeof(key), "wh.%s.act", GKEY[i]);
gConfig[i].action = (uint8_t)prefs.getUInt(key, 0);
}
prefs.end();
}
void saveConfig()
{
prefs.begin("buddy", false); // read-write
for (uint8_t i = 0; i < NUM_GESTURES; i++)
{
char key[16];
snprintf(key, sizeof(key), "wh.%s.url", GKEY[i]);
prefs.putString(key, gConfig[i].url);
snprintf(key, sizeof(key), "wh.%s.mood", GKEY[i]);
prefs.putUInt(key, gConfig[i].mood);
snprintf(key, sizeof(key), "wh.%s.en", GKEY[i]);
prefs.putBool(key, gConfig[i].enabled);
snprintf(key, sizeof(key), "wh.%s.act", GKEY[i]);
prefs.putUInt(key, gConfig[i].action);
}
prefs.end();
Serial.println("[Config] Saved to NVS");
}
// ── Async webhook via FreeRTOS task ───────────────────────────────────────────
struct WebhookTask
{
char url[128];
char gesture[16];
};
static void webhookTaskFn(void *pvParam)
{
WebhookTask *t = (WebhookTask *)pvParam;
HTTPClient http;
http.begin(t->url);
http.setTimeout(3000);
http.addHeader("Content-Type", "application/json");
char body[48];
snprintf(body, sizeof(body), "{\"gesture\":\"%s\"}", t->gesture);
int code = http.POST(body);
Serial.printf("[Webhook] %s -> HTTP %d\n", t->gesture, code);
http.end();
delete t;
vTaskDelete(NULL);
}
void fireWebhook(uint8_t gestureIdx)
{
const GestureConfig &cfg = gConfig[gestureIdx];
if (!cfg.enabled || strlen(cfg.url) == 0)
return;
// Set mood before firing (immediate feedback)
if (cfg.mood > 0)
setBuddyMood((Mood)cfg.mood, 4000);
// Fire async
WebhookTask *t = new WebhookTask;
strncpy(t->url, cfg.url, sizeof(t->url) - 1);
strncpy(t->gesture, GNAME[gestureIdx], sizeof(t->gesture) - 1);
if (xTaskCreate(webhookTaskFn, "wh", 4096, t, 1, NULL) != pdPASS)
{
Serial.println("[Webhook] Task create failed");
delete t;
}
}
// ── 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>Nastrój</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 < 7; 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'>"
"<p style='color:#555;font-size:11px'>GET /api/config &nbsp; POST /api/config (JSON)</p>"
"</body></html>";
return html;
}
void setupHttpServer()
{
// GET / — config UI
httpServer.on("/", HTTP_GET, [](AsyncWebServerRequest *req)
{ req->send(200, "text/html; charset=utf-8", buildHtml()); });
// POST /save — form submission
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 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();
String actKey = String("act_") + GKEY[i];
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("/"); });
// GET /api/config — JSON read
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); });
// POST /api/config — JSON write (body in separate callback)
httpServer.on("/api/config", HTTP_POST, [](AsyncWebServerRequest *req) {}, // request handler — body not ready here
nullptr, // upload handler
[](AsyncWebServerRequest *req, uint8_t *data, size_t len, size_t index, size_t total)
{
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.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 = "")
{
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_6x10_tr);
u8g2.drawStr(0, 12, l1);
u8g2.drawStr(0, 28, l2);
u8g2.drawStr(0, 44, l3);
u8g2.sendBuffer();
}
void connectWiFi()
{
splash("WiFi", "Laczenie...", WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
uint8_t attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20)
{
delay(500);
Serial.print(".");
attempts++;
}
Serial.println();
if (WiFi.status() == WL_CONNECTED)
{
String ip = WiFi.localIP().toString();
Serial.printf("[WiFi] IP: %s\n", ip.c_str());
splash("WiFi OK", ip.c_str(), "");
// NTP sync — Poland CET/CEST
configTzTime("CET-1CEST,M3.5.0,M10.5.0/3", "pool.ntp.org", "time.google.com");
Serial.println("[NTP] Sync...");
}
else
{
Serial.println("[WiFi] Blad polaczenia");
splash("WiFi BLAD", "Sprawdz SSID", "");
}
delay(1500);
}
// ── Gesture handling ──────────────────────────────────────────────────────────
// Maps RevEng Gesture enum to our 0-8 index
static int gestureIndex(Gesture g) { return (int)g - 1; }
// Default mood reactions when no webhook mood is configured
static const Mood DEFAULT_MOOD[NUM_GESTURES] = {
MOOD_HAPPY, // up
MOOD_SAD, // down
MOOD_SURPRISED, // left
MOOD_SURPRISED, // right
MOOD_SLEEPY, // forward
MOOD_ANGRY, // backward
MOOD_EXCITED, // clockwise
MOOD_NORMAL, // anticlockwise
MOOD_EXCITED, // wave
};
static const uint32_t DEFAULT_MOOD_DUR[NUM_GESTURES] = {
2000, 2000, 1500, 1500, 0, 3000, 2000, 0, 2000};
void executeAction(uint8_t idx)
{
Action a = (Action)gConfig[idx].action;
if (a == ACTION_NONE)
return;
overlayAction = a;
overlayUntil = millis() + 8000; // show for 8 s
Serial.printf("[Action] %s -> %s\n", GNAME[idx], ACTION_LABELS[a]);
}
void handleGesture(Gesture g)
{
if (g == GES_NONE)
return;
buddy.lastEvent = millis();
int idx = gestureIndex(g);
if (idx < 0 || idx >= NUM_GESTURES)
return;
Serial.printf("[Gest] %s\n", GNAME[idx]);
// Execute action (e.g. show datetime)
executeAction(idx);
// Fire webhook if configured
bool webhookFired = (gConfig[idx].enabled && strlen(gConfig[idx].url) > 0);
if (webhookFired)
fireWebhook(idx);
// Set mood (webhook config overrides default)
if (!webhookFired || gConfig[idx].mood == 0)
setBuddyMood(DEFAULT_MOOD[idx], DEFAULT_MOOD_DUR[idx]);
}
// ── Setup ─────────────────────────────────────────────────────────────────────
void setup()
{
Serial.begin(115200);
delay(500);
u8g2.begin();
splash("Desk Buddy", "Budze sie...", "");
delay(600);
loadConfig();
initBuddy();
// Single I2C bus for both devices: SDA=GPIO22, SCL=GPIO23
Wire.begin(22, 23);
if (sensor.begin(&Wire))
Serial.println("[PAJ7620] OK");
else
Serial.println("[PAJ7620] Nie znaleziono");
connectWiFi();
if (WiFi.status() == WL_CONNECTED)
setupHttpServer();
}
// ── Loop ──────────────────────────────────────────────────────────────────────
//
// ESP32-C6 is single-core. U8g2 SW I2C busy-waits ~40 ms per sendBuffer()
// (delayMicroseconds inside bit-bang loop), starving the WiFi FreeRTOS task.
// Fix: draw infrequently + vTaskDelay(1) after each draw to unblock scheduler.
//
void loop()
{
uint32_t now = millis();
// Gesture polling (every 500 ms)
static uint32_t lastGesture = 0;
if (now - lastGesture >= 500)
{
lastGesture = now;
Gesture g = sensor.readGesture();
if (g != GES_NONE)
handleGesture(g);
}
// WiFi keepalive
static uint32_t lastWifi = 0;
if (now - lastWifi > 30000)
{
lastWifi = now;
if (WiFi.status() != WL_CONNECTED)
WiFi.reconnect();
}
// Buddy animation state (every 50 ms — no drawing here)
static uint32_t lastAnim = 0;
if (now - lastAnim >= 50)
{
lastAnim = now;
updateBuddyAnim();
}
// OLED draw — 50 ms (~20 fps), HW I2C is non-blocking (DMA+interrupts)
static uint32_t lastDraw = 0;
if (now - lastDraw >= 50)
{
lastDraw = now;
showBuddyScreen();
}
delay(5);
}