feat: initial commit
This commit is contained in:
+874
@@ -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 += " | 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 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);
|
||||
}
|
||||
Reference in New Issue
Block a user