docs: documentar deploy automático + firmware ESP32
Some checks failed
Deploy DummyBot / Build & Deploy to ${{ env.DEPLOY_HOST }} (push) Has been cancelled

This commit is contained in:
javiservices 2026-06-18 12:32:35 +02:00
parent cebbe513da
commit 3a80d5e76b
3 changed files with 492 additions and 141 deletions

200
README.md
View File

@ -1,158 +1,76 @@
# DummyBot Backend
# DummyBot
API REST y UI web para **DummyBot** — un bot físico de tareas para personas con TDAH, basado en ESP32 + ST7789 + caritas kawaii.
Bot físico de tareas para personas con TDAH, basado en ESP32 + OLED + 3 botones.
Stack: **Laravel 12 · PHP 8.4 · MySQL 8 · Sanctum · Tailwind (CDN) + Alpine.js (CDN)**.
- **Stack backend**: Laravel 12 · PHP 8.4 · MySQL 8 · Sanctum · Tailwind (CDN) + Alpine.js (CDN)
- **Stack firmware**: ESP32 (Arduino framework) · OLED SSD1306 128x64 · TTP223 buttons · vibrador
- **Deploy**: automático vía webhook de Gitea en push a `main`
---
## Estructura
## 🚀 Setup rápido
```
dummybot/
├── app/ # Laravel: controllers, models
├── bootstrap/ # Laravel: bootstrap + middleware config
├── config/ # Laravel: app, db, sanctum, etc.
├── database/ # Migraciones y seeders
├── docs/ # Documentación técnica
│ ├── DEPLOY.md # Pipeline de deploy automático
│ └── FIRMWARE.md # Hardware, pines, flasheo
├── firmware/ # PlatformIO + ESP32 Arduino
│ ├── platformio.ini
│ ├── partitions.csv
│ └── src/main.cpp # Firmware v6.1
├── resources/ # Blade views + CSS
│ ├── css/
│ ├── js/
│ └── views/
│ ├── layouts/base.blade.php
│ └── pages/
│ ├── app.blade.php
│ ├── auth.blade.php
│ ├── home.blade.php
│ └── settings.blade.php
├── routes/
│ ├── api.php
│ └── web.php
├── deploy-runner.sh # Script que ejecuta el webhook
├── docker-compose.yml
├── Dockerfile
└── README.md
```
## Documentación adicional
- [`docs/DEPLOY.md`](docs/DEPLOY.md) — Pipeline de deploy automático
- [`docs/FIRMWARE.md`](docs/FIRMWARE.md) — Hardware, cableado, flasheo del ESP32
- [`firmware/README.md`](firmware/README.md) — Detalles de la build PlatformIO
## Quick start (desarrollo local)
```bash
# 1. Crear BD
mysql -u root -p
> CREATE DATABASE dummybot CHARACTER SET utf8mb4;
> CREATE USER 'dummybot'@'localhost' IDENTIFIED BY 'TU_PASSWORD';
> GRANT ALL ON dummybot.* TO 'dummybot'@'localhost';
# 2. Configurar
# Backend
composer install
cp .env.example .env
# Edita DB_PASSWORD, APP_URL, etc.
# 3. Instalar + migrar
composer install --no-dev --optimize-autoloader
php artisan key:generate
php artisan migrate
# 4. Arrancar
php artisan serve --host=0.0.0.0 --port=8000
# Firmware
cd firmware
pio run -t upload --upload-port /dev/ttyUSB0
```
Abre `http://localhost:8000/`. Crea una cuenta, ve a **Ajustes → Dispositivos** y crea tu primer DummyBot para obtener un token.
## Despliegue
## 🐳 Docker
```bash
AUTO_MIGRATE=1 docker compose up -d --build
```
Esto levanta `dummybot_app` (PHP-FPM), `dummybot_web` (nginx) y `dummybot_db` (MySQL 8). El puerto 80 queda interno (el proxy externo ya está gestionado en producción).
## 🧠 Modelo de datos
| Tabla | Descripción |
|------------|-------------|
| `users` | Usuarios (auth vía Sanctum) |
| `tasks` | Tareas (`title`, `description`, `priority`, `estimate_min`, `deadline`, `recurrence`, `done`, `snooze_until`, `done_at`, `project_id`, `user_id`) |
| `projects` | Agrupa tareas (color + icono) |
| `tags` | Etiquetas transversales (muchas a muchas con tasks) |
| `devices` | Cada ESP32 con su propio `token` y `last_seen_at` |
## 🔌 API REST
Todas las rutas devuelven JSON. Auth vía `Authorization: Bearer <token>`.
### Auth (web)
```
POST /api/auth/register {name, email, password, password_confirmation}
POST /api/auth/login {email, password}
POST /api/auth/logout (auth)
GET /api/auth/me (auth)
```
### Tareas (web, con sesión o token Sanctum)
```
GET /api/tasks Listar (con project + tags)
POST /api/tasks Crear
PUT /api/tasks/{id} Editar
DELETE /api/tasks/{id} Borrar
POST /api/tasks/{id}/done Marcar como hecha (genera recurrente si aplica)
POST /api/tasks/{id}/snooze Posponer {minutes: N}
POST /api/tasks/{id}/restore Reabrir
GET /api/tasks/stats {pending, today, done_today, overdue, total_done}
```
### Proyectos
```
GET /api/projects
POST /api/projects {name, color, icon}
PUT /api/projects/{id}
DELETE /api/projects/{id}
```
### Tags
```
GET /api/tags
POST /api/tags {name, color}
DELETE /api/tags/{id}
```
### Dispositivos (DummyBots)
```
GET /api/devices Tus DummyBots
POST /api/devices {name, hardware?, firmware_version?} → devuelve token (solo 1 vez)
POST /api/devices/{id}/rotate Invalida y crea nuevo token
DELETE /api/devices/{id}
```
### Para el ESP32 (auth.device, con token del dispositivo)
```
GET /api/device/tasks/pending Polling cada 30s
POST /api/device/tasks Crear tarea (desde bot, ej. comandos)
POST /api/device/tasks/{id}/done
POST /api/device/tasks/{id}/snooze
DELETE /api/device/tasks/{id}
POST /api/device/heartbeat Ping (refresca last_seen_at)
```
## 🎨 Frontend (Blade + Alpine)
- `pages/home.blade.php` — landing
- `pages/login.blade.php` / `pages/register.blade.php` — auth
- `pages/app.blade.php`**app principal** (lista tareas, filtros, atajos teclado)
- `pages/settings.blade.php` — gestión de dispositivos, proyectos, tags
### Atajos de teclado (en `/app`)
- `n` → nueva tarea
- `/` → buscador
- `Esc` → cerrar modal
- `1` / `2` / `3` / `4` → filtro Todas / Pendientes / Hoy / Hechas
- `s` → snooze 5 min (sobre tarea enfocada)
### TDAH-friendly
- **Sin gamificación tóxica** (sin puntos, sin streaks).
- **No judgemental**: "sin tareas" muestra un emoji festivo, no un mensaje triste.
- **Dark mode por defecto** con gradientes suaves.
- **Atajos de teclado** para no tener que tocar el ratón.
- **Filtros contextuales** (Hoy, Vencidas, Por proyecto) para evitar el agobio del "todo".
## 🔌 Webhooks (TODO)
Pendiente: hook de salida cuando se crea/marca una tarea. Pensado para integrarse con Notion, Zapier, n8n, etc.
## 📋 Importar / Exportar (TODO)
Endpoints para CSV/JSON de tareas.
## 🚀 Despliegue en producción
Ya está dockerizado. El repo está en `https://git.anabolictrack.com/javiservices/dummybot`.
```bash
# En el server
cd /root/dummybot
git pull
AUTO_MIGRATE=1 docker compose up -d --build
```
Para que el proxy-manager lo exponga en HTTPS, añadir host en `http://157.180.77.232:81` apuntando al contenedor `dummybot_web:80`.
## Licencia
MIT
Ver [`docs/DEPLOY.md`](docs/DEPLOY.md). Push a `main` → deploy automático en
~30 segundos.
## API
Ver `resources/views/pages/settings.blade.php` (sección "API") o el README
original.
## Hardware
Ver [`docs/FIRMWARE.md`](docs/FIRMWARE.md) para cableado y pinout completo.

244
docs/DEPLOY.md Normal file
View File

@ -0,0 +1,244 @@
# Despliegue automático
DummyBot se despliega automáticamente a producción cuando se hace push a la rama
`main` del repositorio.
```
push a main
→ Gitea dispara webhook "deploy"
→ POST http://dummybot_expose:80/webhook/deploy (red Docker interna)
→ Laravel valida HMAC-SHA256 (X-Hub-Signature-256)
→ exec('nohup deploy-runner.sh &')
→ git pull + composer install + npm ci + npm run build
→ php artisan migrate --force
→ php artisan optimize
→ Healthcheck GET /up
→ Log en storage/logs/deploy-YYYYMMDD-HHMMSS.log
```
Tiempo total: ~25-35 segundos desde push hasta server actualizado.
---
## Arquitectura
```
┌─────────────────────┐
│ git.anabolictrack │ Gitea 1.22.6
│ (rootless, 3000) │ - Repos: dummybot, dummybot-firmware
└──────────┬──────────┘
│ webhook push event
│ POST http://dummybot_expose:80/webhook/deploy
┌─────────────────────┐
│ 157.180.77.232:80 │ nginx-proxy-manager (172.20.0.2)
└──────────┬──────────┘
│ red "proxy" (172.20.0.0/16)
┌─────────────────────┐ ┌──────────────────────┐
│ dummybot_expose │───▶│ dummybot_web (nginx) │
│ socat :80→web:80 │ │ /var/www/public │
│ (172.20.0.8) │ └──────────┬───────────┘
└─────────────────────┘ │
┌──────────────────────┐
│ dummybot_app (php-fpm)│
│ php artisan │
│ /var/www │
└──────────┬───────────┘
┌──────────▼───────────┐
│ dummybot_db (mysql) │
└──────────────────────┘
```
**Red Docker `proxy`** (172.20.0.0/16): comparten gitea, dummybot_web,
dummybot_expose, nginx-proxy-manager. Gitea hace POST al webhook usando
`http://dummybot_expose:80/webhook/deploy` — resolución DNS por nombre de
container, no atraviesa UFW ni la interfaz pública.
**Volúmenes clave**:
- `/root/dummybot:/var/www` (en el container) — el repo clonado, mismo path
en host y container.
- `/root/dummybot.env` — backup del `.env` real (no se commitea).
- `/root/dummybot/storage/logs/` — logs de deploy.
---
## Webhook de Gitea
**Endpoint**: `POST /webhook/deploy`
**Auth**: HMAC-SHA256 con secreto compartido en header `X-Hub-Signature-256`.
**Trigger**: push a rama `main`.
**Branch filter**: `main` (configurado en Gitea).
**Validación en Laravel** (`app/Http/Controllers/WebhookController.php`):
1. Lee `DEPLOY_WEBHOOK_SECRET` del `.env`.
2. Calcula `hash_hmac('sha256', body, secret)` y compara con header.
3. Decodifica payload, comprueba `ref == "refs/heads/main"`.
4. Lanza `nohup bash deploy-runner.sh > log 2>&1 &`.
5. Responde 202 con path al log.
**Excluido de CSRF** en `bootstrap/app.php`:
```php
$middleware->validateCsrfTokens(except: ['webhook/*']);
```
---
## Configuración inicial del server
### 1. SSH key para acceso de emergencia
```bash
ssh-keygen -t ed25519 -C "deploy-key" -f ~/.ssh/dummybot_deploy -N ""
ssh-copy-id -i ~/.ssh/dummybot_deploy.pub root@157.180.77.232
```
### 2. Respaldo del .env
```bash
# En el server, una vez esté configurado
cp /root/dummybot/.env /root/dummybot.env
```
El deploy restaura `.env` desde `/root/dummybot.env` en cada push.
### 3. Generar secreto del webhook
```bash
openssl rand -hex 32 # → "75f02729eecd38c9..."
```
Guardarlo en dos sitios:
- `/root/dummybot/.env`: `DEPLOY_WEBHOOK_SECRET=<secret>`
- Configuración del webhook en Gitea (al crearlo).
### 4. Configurar Gitea para permitir webhooks a hosts internos
Gitea 1.22 tiene whitelist de hosts (`webhook.ALLOWED_HOST_LIST`). Por defecto
está vacía y rechaza cualquier host. Editar `/etc/gitea/app.ini`:
```ini
[webhook]
ALLOWED_HOST_LIST = *
SKIP_TLS_VERIFY = false
```
Y reiniciar: `docker restart gitea`.
### 5. Crear el webhook en Gitea
```bash
SECRET=$(cat /root/dummybot-webhook-secret)
curl -X POST "https://git.anabolictrack.com/api/v1/repos/javiservices/dummybot/hooks" \
-u "javiservices:<token>" \
-H "Content-Type: application/json" \
-d "{
\"type\": \"gitea\",
\"config\": {
\"url\": \"http://dummybot_expose:80/webhook/deploy\",
\"content_type\": \"json\",
\"secret\": \"$SECRET\"
},
\"events\": [\"push\"],
\"active\": true,
\"branch_filter\": \"main\"
}"
```
**Importante**: la URL debe usar el **nombre del container** (`dummybot_expose`)
no la IP pública. Si el container se renombra, hay que actualizar el webhook.
### 6. Primer deploy manual
Como el endpoint `/webhook/deploy` no existe hasta que se despliega por primera
vez, hay que hacerlo manualmente:
```bash
ssh root@157.180.77.232
cd /root/dummybot
git fetch origin main
git reset --hard origin/main
cp /root/dummybot.env .env
docker compose exec -T app php artisan migrate --force
docker compose exec -T app php artisan config:clear route:clear view:clear optimize
docker compose up -d --remove-orphans
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/up # debe ser 200
```
Tras este primer deploy, los siguientes son automáticos.
---
## Verificación
### Probar manualmente (sin push)
```bash
SECRET=$(cat /root/dummybot-webhook-secret)
BODY='{"ref":"refs/heads/main","pusher":{"name":"manual"}}'
SIG="sha256=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
curl -X POST "http://157.180.77.232:18080/webhook/deploy" \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: $SIG" \
-d "$BODY"
```
Respuesta esperada: `{"ok":true,"status":"deploy started","log":"deploy-..."}` con HTTP 202.
### Ver logs
```bash
# Logs de deploy
ls -lat /root/dummybot/storage/logs/deploy-*.log | head
# Log de un deploy concreto
tail -50 /root/dummybot/storage/logs/deploy-20260618-102042.log
# Laravel general
tail -f /root/dummybot/storage/logs/laravel.log
```
### Probar webhook desde Gitea
```bash
curl -X POST "https://git.anabolictrack.com/api/v1/repos/javiservices/dummybot/hooks/1/tests" \
-u "javiservices:<token>"
```
HTTP 204 = OK. Si hay error de entrega, ver logs del container gitea:
`docker logs gitea --tail 50`.
---
## Troubleshooting
| Síntoma | Causa | Solución |
|---------|-------|----------|
| Push no dispara deploy | `webhook.ALLOWED_HOST_LIST` no configurado | Editar `/etc/gitea/app.ini`, reiniciar gitea |
| HTTP 419 al webhook | CSRF no excluido | Revisar `bootstrap/app.php` |
| HTTP 401 al webhook | Firma inválida | Verificar `DEPLOY_WEBHOOK_SECRET` en server y Gitea |
| Webhook devuelve "skipped: branch != main" | Bug `substr($ref, 10)` debe ser `11` | Actualizar `WebhookController.php` |
| Deploy log dice "Deploy OK" pero server tiene código viejo | `php artisan optimize` no recarga | Reiniciar container: `docker compose restart app` |
| `docker compose up` falla con "container name already in use" | Container huérfano de un deploy anterior | `docker rm -f dummybot_expose` y reintentar |
---
## Por qué webhook y no Actions
Gitea 1.22.6 tiene Actions parcial:
- ✓ Registro de runners funciona
- ✓ API de secrets/variables funciona
- ✗ API de dispatch devuelve 404
- ✗ Ejecución automática de workflows no se dispara
Gitea Actions se estabilizó en 1.23+. El webhook de Gitea es la solución
estándar pre-Actions y funciona en cualquier versión.
Si en el futuro se actualiza Gitea, el workflow `.gitea/workflows/deploy.yml`
ya está listo y se puede activar configurando el secreto `DEPLOY_SSH_KEY` en
Gitea (la SSH key pública ya está en el server en
`/root/.ssh/authorized_keys`).

189
docs/FIRMWARE.md Normal file
View File

@ -0,0 +1,189 @@
# Firmware del ESP32
Firmware para el bot físico DummyBot. ESP32 + OLED SSD1306 128x64 + WiFi
+ polling backend.
## Hardware
- **ESP32-D0WD-V3** (DevKitC / WROOM-32, 4 MB flash)
- **Pantalla OLED SSD1306 0.96"** (128x64, I2C)
- **3 botones** (NEXT / DONE / SNOOZE)
- **1 motor vibrador 1027** (3V DC, MOSFET en GPIO 25)
### Pinout
| Función | GPIO | Notas |
|------------------|-------|--------------------------------------|
| OLED SDA | 21 | I2C data |
| OLED SCL | 22 | I2C clock |
| OLED addr | 0x3C | I2C address |
| Botón NEXT | 26 | INPUT_PULLDOWN, flanco ascendente |
| Botón DONE | 27 | INPUT_PULLDOWN, flanco ascendente |
| Botón SNOOZE | 14 | INPUT_PULLDOWN, flanco ascendente |
| Vibrador | 25 | OUTPUT, MOSFET gate |
| BOOT (reset cfg) | 0 | INPUT_PULLUP, mantener 5s al arrancar |
### Cableado OLED
```
ESP32 (WROOM-32) OLED SSD1306 0.96"
───────────────────── ─────────────────────
3V3 ───────▶ VDD
GND ───────▶ GND
GPIO 22 ───────▶ SCK (I2C SCL)
GPIO 21 ───────▶ SDA (I2C SDA)
```
### Cableado botones
Los botones se conectan entre GND y el GPIO correspondiente. Con
`INPUT_PULLDOWN` interno del ESP32, el GPIO lee LOW en reposo y HIGH al
pulsar. El firmware detecta el flanco ascendente con debounce de 200 ms.
```
ESP32 Botón (entre pin y GND)
───── ─────
GPIO 26 ──────▶ Siguiente
GPIO 27 ──────▶ Hecha
GPIO 14 ──────▶ Snooze 5 min
GND ──────▶ Común
```
### Cableado vibrador
```
ESP32 MOSFET (AO3400 / 2N7000) Vibrador 1027
───── ────── ─────
GPIO 25 ──[100Ω]──▶ Gate
├──▶ Source ──▶ GND
└──▶ Drain ──▶ Vibrador ──▶ 3V3
```
Si solo se dispone de NPN (BC547 / 2N2222 / S8050), esquema alternativo:
motor entre 3V3 y colector, resistencia 1kΩ en base.
## Build flags (platformio.ini)
```ini
build_flags =
-DCORE_DEBUG_LEVEL=3
-DUSER_SETUP_LOADED=1
-DDEFAULT_WIFI_SSID=\"PHARMACO\"
-DDEFAULT_WIFI_PASS=\"arnoldgym2010\"
-DBACKEND_URL=\"http://157.180.77.232:18080\"
-DDEFAULT_DEVICE_TOKEN=\"<token-del-bot>\"
```
## Build y flasheo
```bash
# Compilar
pio run
# Flashear
pio run -t upload --upload-port /dev/ttyUSB0
# Monitor serie
pio device monitor --port /dev/ttyUSB0 --baud 115200
```
## Configuración en runtime (captive portal)
Si no hay config WiFi válida en NVS, el bot abre un AP `DummyBot-Setup`
(pass `dummybot`) en `192.168.4.1`. Desde el móvil, configurar WiFi y token
del dispositivo. El bot se reinicia automáticamente y conecta.
Para resetear la config: mantener pulsado el botón **BOOT (GPIO 0)** durante
5 segundos al arrancar.
## Comandos serie (debug)
Conectar a `115200 baud`:
| Comando | Acción |
|---------|-------------------------------------------------|
| `S` | Imprime estado (WiFi, IP, tarea actual) |
| `B0` | Simula pulsación botón NEXT (GPIO 26) |
| `B1` | Simula pulsación botón DONE (GPIO 27) |
| `B2` | Simula pulsación botón SNOOZE (GPIO 14) |
| `P` | Lee estado actual de los 3 pines de botón |
| `A` | Lectura continua del ADC en GPIO 34 (60s) |
| `C` | Calibración guiada de umbrales ADC (3 botones) |
| `W` | Watch: monitor rápido de cambios en pines |
## Arquitectura del firmware
```cpp
// Namespace principales
namespace Config { void load(); void save(); void clear(); }
namespace UI { showBoot, showConnecting, showIdle, showTask, showPortal }
namespace Vib { buzz(), tick() — patrón 2-buzz al recibir tarea nueva }
namespace Api { pollPending(), markDone(), snooze() }
namespace Input { tick() — polling 3 botones con debounce por flanco }
namespace Portal { start(), stop(), tick() — AP + DNS + WebServer }
namespace Wifi { wifiStartSTA(), wifiTick() — máquina de estados }
```
### Máquina de estados WiFi
```
BOOT → CONNECTING (timeout 20s)
├─ OK → CONNECTED (poll cada 30s)
└─ FAIL → PORTAL (AP DummyBot-Setup)
CONNECTED
└─ WiFi perdido → CONNECTING
PORTAL
└─ ESP.restart() al guardar config
```
### Flujo de polling
```
CONNECTED
→ Api::pollPending() cada 30s
→ GET /api/device/tasks/pending
→ Si tiene tareas: muestra primera en OLED
→ Si cambia id: buzz (vibrador)
```
## Troubleshooting
### WiFi no conecta
- Verificar SSID y password (son case-sensitive).
- Si el router está en 5 GHz, el ESP32 solo soporta 2.4 GHz.
- Si la red bloquea nuevos dispositivos (MAC filtering), añadir la MAC del
ESP32 (visible en el log de `WiFi.status()`).
### Botones no responden
- Verificar que el botón hace contacto entre el GPIO y GND.
- Comandos serie `P` muestra el estado actual de los pines. Pulsar y ver
si cambia de 0 a 1.
- Si el botón usa active-low (HIGH en reposo, LOW al pulsar), cambiar a
`INPUT_PULLUP` y la lógica de flanco a descendente.
### OLED no enciende
- Verificar 3V3 en VDD del OLED.
- Verificar SDA (GPIO 21) y SCL (GPIO 22) — pines fijos I2C del ESP32.
- I2C addr por defecto 0x3C. Si la OLED es 0x3D, cambiar `OLED_ADDR`.
### API no responde
- Verificar que el bot tiene acceso a internet (ping al backend desde el
ESP32 con el comando serie `S`).
- Si el backend usa HTTPS con cert auto-firmado, hay que añadir el cert al
firmware (más complejo) o usar HTTP plano.
## Versiones
- **v6.1** (actual): producción, sin botones (pendiente nueva placa).
Funcionalidad core: WiFi, OLED, polling, portal cautivo.
- **v6.0** (legacy): intentona con ADC para matriz de botones, no funcionó
en hardware del usuario.
- **v5** (legacy): SPIFFS en lugar de NVS, varios bugs en defaults WiFi.