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