feat: initial commit
This commit is contained in:
+36
@@ -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
|
||||
@@ -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,5–6 s (szybsze w trybie `sleepy`)
|
||||
- **Ruch źrenic** — płynny drift do losowej pozycji co 1,5–4 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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
+11
@@ -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
|
||||
@@ -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,
|
||||
|
Reference in New Issue
Block a user