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
+36
View File
@@ -0,0 +1,36 @@
# PlatformIO
.pio/
.pioenvs/
.piolibdeps/
# VSCode
.vscode/
*.code-workspace
# macOS
.DS_Store
.AppleDouble
.LSOverride
._*
.Spotlight-V100
.Trashes
# CLion / JetBrains
.idea/
cmake-build-*/
# Build artifacts
*.o
*.a
*.d
*.elf
*.bin
*.hex
*.map
# Credentials (WiFi passwords etc.)
secrets.h
credentials.h
# Logs
*.log
+258
View File
@@ -0,0 +1,258 @@
# ESP32-C6 Desk Buddy
Animowany "biurkowy przyjaciel" na Seeed XIAO ESP32-C6 z czujnikiem gestów PAJ7620 i wyświetlaczem OLED 128×64. Reaguje na gesty emocjami, wykonuje webhooki HTTP i wyświetla informacje na żądanie. Konfigurowany przez przeglądarkę.
---
## Sprzęt
| Komponent | Model | Adres I2C |
|-----------|-------|-----------|
| Mikrokontroler | Seeed XIAO ESP32-C6 | — |
| Wyświetlacz | SSD1306 OLED 128×64 | 0x3C |
| Czujnik gestów | CJMCU-7620 (PAJ7620U2) | 0x73 |
### Podłączenie
Oba urządzenia dzielą **jedną magistralę I2C** (hardware, HP I2C bus 0):
```
SSD1306 / PAJ7620 XIAO ESP32-C6
──────────────────────────────────────
VCC → 3.3V
GND → GND
SDA → D4 (GPIO22)
SCL → D5 (GPIO23)
```
> **Ważne:** ESP32-C6 ma dwa kontrolery I2C:
> - **I2C0 (HP, `Wire`)** — dowolne piny GPIO ✓
> - **I2C1 (LP, `Wire1`)** — zablokowany sprzętowo na GPIO6/GPIO7, niedostępne na XIAO ✗
>
> Nie używaj `Wire1` ani SW I2C (bit-bang blokuje WiFi na single-core CPU ~40 ms/frame).
---
## Funkcje
### Animacja oczu (Buddy)
Ciągła animacja na OLED ~20 fps. Buddy ma 7 nastrojów z różnymi kształtami oczu:
| Nastrój | Wygląd | Opis |
|---------|--------|------|
| `normal` | pełne oczy, dryfujące źrenice | domyślny |
| `happy` | łukowe oczy `^_^` | górna połowa oka zasłonięta |
| `sleepy` | opuszczona powieka `zZz` | bąbelki ZZZ, wolne mruganie |
| `surprised` | wielkie oczy `o_O` | uniesione brwi |
| `angry` | zmrużone `>_<` | skośne brwi do środka |
| `sad` | smutne `T_T` | odwrócone brwi, łzy |
| `excited` | gwiazdy `*_*` | wzór × zamiast źrenic |
Dodatkowe animacje:
- **Mruganie** — co 2,56 s (szybsze w trybie `sleepy`)
- **Ruch źrenic** — płynny drift do losowej pozycji co 1,54 s (tylko `normal`)
- **Auto-uśpienie** — po 5 minutach bez gestu przechodzi w `sleepy`
### Gesty (PAJ7620)
Czujnik rozpoznaje 9 gestów ręką:
| Gest | Domyślna reakcja |
|------|-----------------|
| `up` | happy |
| `down` | sad |
| `left` | surprised |
| `right` | surprised |
| `forward` | sleepy (trwały) |
| `backward` | angry |
| `clockwise` | excited |
| `anticlockwise` | normal |
| `wave` | excited |
Każdemu gestowi można przypisać własny nastrój, webhook i akcję przez panel konfiguracyjny.
### Webhooki
Każdy gest może wywoływać webhook HTTP POST na skonfigurowany URL:
```
POST http://twoj-serwer/endpoint
Content-Type: application/json
{"gesture": "wave"}
```
- Wywołanie asynchroniczne (FreeRTOS task) — nie blokuje animacji
- Timeout 3 sekundy
- Nastrój po wysłaniu webhooka konfigurowalny niezależnie
### Akcje
Akcje wyświetlają informacje na OLED przez 8 sekund, potem wracają do twarzy:
| Akcja | Co pokazuje |
|-------|------------|
| **Data i godzina** | `HH:MM:SS` (duża czcionka), `DD.MM.RRRR`, dzień tygodnia |
| **Status WiFi** | SSID, adres IP, RSSI w dBm, słupki siły sygnału |
Czas synchronizowany przez NTP (`pool.ntp.org`) — strefa CET/CEST (Polska).
---
## Konfiguracja
### Panel webowy
Po uruchomieniu wejdź przeglądarką na adres IP wypisany w Serial (lub na OLED przy starcie):
```
http://<IP>/
```
Dla każdego gestu można ustawić:
- **URL webhoka** — endpoint HTTP POST (pusty = wyłączony)
- **Nastrój** — jaki nastrój ustawić po geście/webhooku
- **Akcja** — co wyświetlić na OLED
- **ON** — czy gest jest aktywny
### JSON API
```bash
# Odczyt konfiguracji
GET http://<IP>/api/config
# Zapis konfiguracji
POST http://<IP>/api/config
Content-Type: application/json
{
"wave": {
"url": "http://homeassistant.local:8123/api/webhook/my_hook",
"mood": 6,
"action": 0,
"enabled": true
},
"up": {
"url": "",
"mood": 0,
"action": 1,
"enabled": true
}
}
```
#### Wartości `mood`
| Wartość | Nastrój |
|---------|---------|
| 0 | bez zmiany |
| 1 | happy |
| 2 | sleepy |
| 3 | surprised |
| 4 | angry |
| 5 | sad |
| 6 | excited |
#### Wartości `action`
| Wartość | Akcja |
|---------|-------|
| 0 | brak |
| 1 | data i godzina |
| 2 | status WiFi |
### Konfiguracja WiFi
Dane sieci są na razie hardcoded w `src/main.cpp`:
```cpp
const char *WIFI_SSID = "twoja_siec";
const char *WIFI_PASS = "twoje_haslo";
```
---
## Instalacja i build
### Wymagania
- [PlatformIO](https://platformio.org/) (CLI lub VS Code extension)
### Build i upload
```bash
cd esp32-c6
pio run --target upload
pio device monitor
```
### Zależności (pobierane automatycznie)
```ini
olikraus/U8g2
acrandal/RevEng PAJ7620
bblanchon/ArduinoJson@^7.2.1
mathieucarbou/ESPAsyncWebServer@^3.3.12
```
### platformio.ini
```ini
[env:esp32-c6]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip
board = seeed_xiao_esp32c6
framework = arduino
lib_deps =
olikraus/U8g2
acrandal/RevEng PAJ7620
bblanchon/ArduinoJson@^7.2.1
mathieucarbou/ESPAsyncWebServer@^3.3.12
build_flags = -std=gnu++17
```
---
## Architektura kodu
Cały projekt mieści się w jednym pliku `src/main.cpp` (~650 linii).
```
setup()
Wire.begin(22, 23) ← I2C0 HP dla obu urządzeń
u8g2.begin() ← SSD1306 HW I2C
sensor.begin(&Wire) ← PAJ7620 HW I2C
connectWiFi()
configTzTime(...) ← NTP sync
setupHttpServer() ← AsyncWebServer port 80
loop() ← ~200 fps bez rysowania
sensor.readGesture() ← polling co 500 ms
handleGesture()
executeAction() ← overlay na OLED
fireWebhook() ← FreeRTOS task (async)
setBuddyMood()
updateBuddyAnim() ← tick co 50 ms (stan)
showBuddyScreen() ← rysowanie co 50 ms (~20 fps)
showDateTimeScreen() ← overlay jeśli aktywny
showWiFiStatusScreen() ← overlay jeśli aktywny
```
### Kluczowe decyzje techniczne
| Problem | Rozwiązanie |
|---------|-------------|
| SW I2C blokuje WiFi 40ms/frame na single-core ESP32-C6 | HW I2C (Wire) dla obu urządzeń na wspólnej magistrali |
| `WebServer.h` nie działa na ESP32-C6 z IDF 5.x | `ESPAsyncWebServer` (callback-based, brak `handleClient()`) |
| `Wire1` (LP I2C) zablokowany na GPIO6/7 (niedostępne na XIAO) | Jeden `Wire` (HP I2C) dla SSD1306 + PAJ7620 |
| NVS klucze max 15 znaków | Skrócone prefiksy gestów: `u/d/l/r/f/b/cw/ccw/w` |
| `Preferences.begin("ns", true)` crash gdy namespace nie istnieje | Otwierać zawsze z `false` |
---
## Dodawanie nowych akcji
1. Dodaj wartość do `enum Action` w `main.cpp`
2. Zaktualizuj `NUM_ACTIONS` i `ACTION_LABELS[]`
3. Napisz funkcję `showXxxScreen()`
4. Dodaj `case ACTION_XXX:` w `showBuddyScreen()` (sekcja overlay)
+37
View File
@@ -0,0 +1,37 @@
This directory is intended for project header files.
A header file is a file containing C declarations and macro definitions
to be shared between several project source files. You request the use of a
header file in your project source file (C, C++, etc) located in `src` folder
by including it, with the C preprocessing directive `#include'.
```src/main.c
#include "header.h"
int main (void)
{
...
}
```
Including a header file produces the same results as copying the header file
into each source file that needs it. Such copying would be time-consuming
and error-prone. With a header file, the related declarations appear
in only one place. If they need to be changed, they can be changed in one
place, and programs that include the header file will automatically use the
new version when next recompiled. The header file eliminates the labor of
finding and changing all the copies as well as the risk that a failure to
find one copy will result in inconsistencies within a program.
In C, the convention is to give header files names that end with `.h'.
Read more about using header files in official GCC documentation:
* Include Syntax
* Include Operation
* Once-Only Headers
* Computed Includes
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
+46
View File
@@ -0,0 +1,46 @@
This directory is intended for project specific (private) libraries.
PlatformIO will compile them to static libraries and link into the executable file.
The source code of each library should be placed in a separate directory
("lib/your_library_name/[Code]").
For example, see the structure of the following example libraries `Foo` and `Bar`:
|--lib
| |
| |--Bar
| | |--docs
| | |--examples
| | |--src
| | |- Bar.c
| | |- Bar.h
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
| |
| |--Foo
| | |- Foo.c
| | |- Foo.h
| |
| |- README --> THIS FILE
|
|- platformio.ini
|--src
|- main.c
Example contents of `src/main.c` using Foo and Bar:
```
#include <Foo.h>
#include <Bar.h>
int main (void)
{
...
}
```
The PlatformIO Library Dependency Finder will find automatically dependent
libraries by scanning project source files.
More information about PlatformIO Library Dependency Finder
- https://docs.platformio.org/page/librarymanager/ldf.html
+15
View File
@@ -0,0 +1,15 @@
[env:esp32-c6]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip
board = seeed_xiao_esp32c6
framework = arduino
monitor_speed = 115200
upload_port = /dev/cu.usbmodem1101
monitor_port = /dev/cu.usbmodem1101
upload_speed = 921600
lib_deps =
olikraus/U8g2
acrandal/RevEng PAJ7620
bblanchon/ArduinoJson@^7.2.1
mathieucarbou/ESPAsyncWebServer@^3.3.12
build_flags =
-std=gnu++17
+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);
}
+11
View File
@@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
+8
View File
@@ -0,0 +1,8 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x2C0000,
zb_storage, data, fat, 0x2D0000, 0x4000,
zb_fct, data, fat, 0x2D4000, 0x1000,
spiffs, data, spiffs, 0x2D5000, 0x12A000,
coredump, data, coredump,0x3FF000, 0x1000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 otadata data ota 0xe000 0x2000
4 app0 app ota_0 0x10000 0x2C0000
5 zb_storage data fat 0x2D0000 0x4000
6 zb_fct data fat 0x2D4000 0x1000
7 spiffs data spiffs 0x2D5000 0x12A000
8 coredump data coredump 0x3FF000 0x1000