Firmware embebido en C++ para Particle Photon (ARM Cortex-M3, sin FPU) que convierte un microcontrolador de 128 KB de RAM en una estación de calidad del aire con DSP de audio en tiempo real: muestrea el micrófono por ISR a 16 kHz, detecta palmas (FSM con umbral adaptativo μ + K·σ) y silbidos (algoritmo Goertzel en aritmética entera Q15), agrega un AQI estándar EPA (con NowCast) sobre media docena de sensores ambientales y publica todo por MQTT con auto-discovery de Home Assistant — todo bajo un scheduler cooperativo no bloqueante, sin RTOS y con cero asignación de heap.
Proyecto personal de mdps: el foco está en la ingeniería de firmware — planificación determinista, pipeline de audio por interrupción con buffering multi-etapa, DSP en punto fijo y optimización agresiva del footprint en flash/RAM.
- DSP de audio en tiempo real sin FPU. Captura por ISR a 16 kHz en buffers circulares; el ISR solo escribe y el procesado vive en el loop principal. Cuádruple buffer (512 muestras/bloque ≈ 32 ms) que tolera ~96 ms de jitter del loop antes de perder datos, con contador de bloques descartados para diagnóstico.
- Detección de palmas (FSM). Envolvente (rectificación + IIR), umbral adaptativo μ + K·σ con floor de σ, Schmitt trigger (histéresis) y una máquina de estados (IDLE→RISING→PEAK→FALLING→GAP) con validación temporal que cuenta secuencias de 1–4 palmas y reporta SNR.
- Detección de silbidos (Goertzel). Banco de bins tonales entre
FMIN–FMAXresuelto con coeficientes Q15 (enteros) — apto para un núcleo sin FPU — con criterios de tonalidad, estabilidad de frecuencia y duración mínima, más guard anti-falsos tras una palma. - Motor AQI estándar EPA. Agregación en cubos minuto→hora→día con truncado por contaminante y breakpoints EPA (PM2.5 y PM10), variante NowCast (buffer circular de 12 h) y callbacks al cerrar cada cubo.
- Cero heap, footprint optimizado. Sin
malloc/newen caliente (almacenamiento estático + placement new), filtros EMA en punto flotante ligero, subsetting de fuentes y flags de compilación por feature para encajar en la flash (~13 KB recuperados en una pasada de optimización). - Robustez de campo. Persistencia EEPROM versionada con checksum FNV-1a y escritura lazy (anti-desgaste), watchdog hardware (IWDG), reconexión MQTT con backoff y pausa de tareas durante OTA.
- Particle Photon (ARM Cortex-M3, 1 MB Flash, 128 KB RAM)
- Particle OS con
SYSTEM_THREAD(ENABLED)
| Sensor | Parámetros | Interfaz |
|---|---|---|
| CCS811 | CO2 (ppm), TVOC (ppb) | I2C |
| BME280 | Temperatura, Humedad, Presión | I2C |
| BMP280 | Temperatura, Presión | I2C |
| PMS5003/5003ST | PM1.0, PM2.5, PM10 (+HCHO, T, H en ST) | UART |
| BH1750 | Luminosidad (lux) | I2C |
| ADC interno | Nivel de ruido (dBFS) | ADC @ 16 kHz |
- Display OLED 128×64 (SSD1306)
- NeoPixel RGB LED
- Buzzer (tonos RTTTL)
El loop principal corre una tabla de tareas, cada una con su período (ms) y dos flags: catchup (re-planifica sobre el período exacto, sin deriva) y run_if_flashing (qué tareas siguen vivas durante OTA). Cada loop() dispone de un presupuesto de tiempo (budget_ms) para repartir CPU de forma justa. No hay bloqueos largos: ninguna tarea cede el control con delay(). Resultado: comportamiento determinista en un solo hilo, sin la sobrecarga ni la RAM de un RTOS.
SparkIntervalTimer dispara un ISR a 16 kHz que lee el ADC interno y lo escribe en un anillo de 4 buffers de 512 muestras (≈32 ms/bloque). El ISR solo produce; el consumidor (loop principal) procesa el bloque completo. Con 4 buffers el sistema absorbe ~96 ms de jitter (I2C del display, ráfagas MQTT) antes de descartar datos, y los descartes se contabilizan para diagnóstico. El ADC se configura a 112 ciclos para muestrear señal de audio con margen temporal real.
La envolvente de la señal se obtiene por rectificación + IIR exponencial. Un umbral adaptativo μ + K·σ (con floor de σ y Schmitt trigger para histéresis) alimenta una FSM (IDLE→RISING→PEAK→FALLING→GAP) que valida duración de pico, separación entre palmas y timeouts de secuencia (1–4 palmas), reportando recuento, pico y SNR. La detección de silbidos usa Goertzel con coeficientes Q15 enteros (sin FPU) sobre un banco de bins entre FMIN–FMAX, exigiendo tonalidad (potencia_pico/total), estabilidad de frecuencia y duración mínima, con guard anti-falsos tras una palma.
Agrega concentraciones en cubos alineados minuto → hora → día con truncado por contaminante y breakpoints EPA (PM2.5 y PM10), más la variante NowCast sobre un buffer circular de 12 h. Cada nivel exige un mínimo de muestras válidas y emite un callback al cerrarse el cubo. Diseño sin asignación dinámica (2 canales fijos).
- Publica telemetría de sensores en JSON construido sin asignación dinámica (
COMMS::JSON) - Auto-discovery de entidades en Home Assistant (con disponibilidad vinculada al LWT)
- Comandos entrantes enrutados por una tabla de handlers: LED, buzzer, offsets de calibración, calibración de micrófono, duty-cycle del PMS y comandos de sistema
- Reconexión con backoff y soporte opcional TLS
Cabecera versionada con checksum FNV-1a: al arrancar valida la integridad y, si falla, cae a defaults. Escritura lazy (flag dirty + intervalo mínimo) para minimizar el desgaste de la flash emulada. Layout binario fijo (sin #ifdef) para garantizar orden estable entre builds. Guarda offsets de sensores, calibración de micrófono, baseline del CCS811 y config de LED/buzzer/PMS.
El archivo config/config_device.h selecciona qué dispositivo compilar:
#include "config_device_3.h" // ← cambiar el número según el dispositivoCada archivo config_device_X.h define los flags del hardware disponible y sus parámetros:
#define ENABLE_PARTICLE_SENSOR // Sensor de particulas PMS5003/5003ST/7003
#define ENABLE_LUX_SENSOR // Sensor de luz BH1750
#define TEMP_SENSOR_BME280 // Sensor BME280 (vs BMP280)
#define ENABLE_NEOPIXEL // LED NeoPixel
#define ENABLE_BUZZER // Buzzer piezo (melodias RTTTL)
#define AUDIO_ENABLE_DETECT_CLAPS // Deteccion de palmas (FSM)
#define AUDIO_ENABLE_WHISTLE_GOERTZEL // Deteccion de silbidos (Goertzel)
#define HA_ENABLE_DISCOVERY // Home Assistant auto-discovery
#define ENABLE_AQI // Motor AQI (EPA + NowCast)Los parámetros globales (intervalos de polling, host MQTT, tamaños de buffer de audio, reglas AQI) se configuran en config/config.h.
src/
├── SAQAirQuality.ino # Punto de entrada + scheduler de tareas
├── config/ # Configuración global y por dispositivo
├── libs/ # Librerías propias (AQI, audio, PMS, LED, JSON…)
├── utils/ # Debugging, profiling, helpers
├── handlers/ # Manejadores de comandos MQTT
├── sensors.h/cpp # Lectura y promediado de sensores
├── sound.h/cpp # Procesamiento de audio + ISR
├── screen.h/cpp # Renderizado del display OLED
├── comms.h/cpp # Gestión de conexión MQTT
├── comms_router.h/cpp # Enrutado de mensajes MQTT
├── ha_discovery.h/cpp # Auto-discovery para Home Assistant
└── persistence.h/cpp # Almacenamiento EEPROM
Este repositorio contiene únicamente el código propio. Las librerías de terceros no se
incluyen; instálalas con el gestor de librerías de Particle (o colócalas en lib/):
Adafruit_CCS811(CO2/TVOC) — con extensionesgetBaseline()/setBaseline()Adafruit_BME280/Adafruit_BMP280+Adafruit_SensorBH1750(luminosidad)neopixel(LED RGB)MQTT-TLS(cliente MQTT con TLS)SparkIntervalTimer(muestreo de audio por ISR)
| Subtopic | Descripcion | Retained | Periodo/Trigger |
|---|---|---|---|
/LWT |
Estado de conexion (Online/Offline) |
Si | Conectar/desconectar |
/tele/INFO |
Version firmware, IP, uptime | Si | Al conectar |
/tele/SENSOR |
Todos los sensores (T, H, P, CO2, TVOC, PM, lux) | No | ~90s |
/tele/SENSOR/NOISE |
Nivel de ruido (dBFS rapido + lento) | No | Adaptativo (2s-60s) |
/tele/SENSOR/CLAP |
Palmas detectadas (count, SNR, peak) | No | Al detectar |
/tele/SENSOR/WHISTLE |
Silbido detectado (freq, tonality) | No | Al detectar |
/tele/SENSOR/AQI/{p}/{s} |
Indice AQI por contaminante/periodo | No | Al cerrar cubo |
/tele/PMS_STATE |
Estado duty-cycle PMS | Si | Al cambiar estado |
| Subtopic | Respuesta | Descripcion |
|---|---|---|
/cmd/LED |
/stat/LED |
Control NeoPixel |
/cmd/BUZZER |
/stat/BUZZER |
On/off buzzer |
/cmd/BUZZER/play |
/stat/BUZZER |
Reproducir melodia RTTTL |
/cmd/OFFSETS |
/stat/OFFSETS |
Fijar offsets de calibracion |
/cmd/OFFSETS/get |
/stat/OFFSETS |
Leer offsets actuales |
/cmd/AUDIO_CAL |
/stat/AUDIO_CAL |
Calibracion de microfono |
/cmd/PMS_CFG |
/stat/PMS_CFG |
Configurar duty-cycle PMS |
/cmd/HA |
/stat/HA |
Control HA auto-discovery |
/cmd/REBOOT |
/stat/SYSTEM |
Reiniciar dispositivo |
El topic base es específico por dispositivo (definido en config_device_X.h):
| Dispositivo | BASE_TOPIC | CLIENT_ID |
|---|---|---|
| SAQ1 | homebot/SAQ-1 |
saq1 |
| SAQ2 | homebot/SAQ-2 |
saq2 |
| SAQ3 | homebot/SAQ-3 |
saq3 |
| SAQ4 | homebot/SAQ-4 |
saq4 |
| Global | homebot/saqs |
— |
| Topic | {BASE_TOPIC}/LWT |
|---|---|
| Payload | "Online" / "Offline" (retained) |
| Cuándo | Al conectar / al desconectar |
| Topic | {BASE_TOPIC}/tele/SENSOR |
|---|---|
| Período | ~90 s |
{
"pm1_ugm3": 10,
"pm25_ugm3": 25,
"pm10_ugm3": 45,
"hcho": 5,
"lux": 1500.00,
"temp_c": 22.50,
"hum_percent": 45.50,
"press_mb": 1013,
"co2_ppm": 450,
"tvoc_ppm": 25,
"time": 1708345600
}| Topic | {BASE_TOPIC}/tele/SENSOR/NOISE |
|---|---|
| Publicacion | Adaptativa: cuando cambia >= 2 dB (min 2s, max 60s heartbeat) |
{
"dbfs": -42.5,
"dbfs_slow": -44.2,
"time": 1708345600
}dbfs: nivel actual (IIR rapido, tau ~1.5s)dbfs_slow: nivel ambiente (IIR lento, tau ~45s)
| Topic | {BASE_TOPIC}/tele/SENSOR/CLAP |
|---|---|
| Cuándo | Al detectar una secuencia |
{
"count": 3,
"period": 2000,
"peak_dbfs": -20.5,
"ambient_dbfs": -45.3,
"snr_db": 24.8,
"time": 1708345600
}| Topic | {BASE_TOPIC}/tele/SENSOR/WHISTLE |
|---|---|
| Cuándo | Al detectar un silbido |
{
"type": "whistle",
"duration_ms": 850,
"freq_hz": 2500,
"tonality": 0.92,
"level_dbfs": -22.5
}| Topic | {BASE_TOPIC}/tele/SENSOR/AQI/{pollutant}/{scope} |
|---|---|
| Cuándo | Al cerrarse cada cubo de agregación |
pollutant: pm25 / pm10
scope: minute / hour / day / nowcast
{
"pollutant": "pm25",
"scope": "hour",
"conc_ugm3": 28.5,
"aqi": 85,
"time": 1708345600
}| Topic | {BASE_TOPIC}/tele/INFO |
|---|---|
| Cuándo | Al conectar al broker MQTT (retained) |
{
"fw": "0.0.0-dev",
"build": "Feb 28 2026 15:30:00",
"device": 2,
"uptime_s": 3600,
"ip": "192.168.1.100"
}| Topic | {BASE_TOPIC}/tele/PMS_STATE |
|---|---|
| Cuándo | Al cambiar de estado en el duty-cycle (retained) |
{
"state": "sleeping",
"time": 1708345600
}Estados posibles: active, warmup, sleeping.
Todos los comandos responden en
{BASE_TOPIC}/stat/{SUBSYSTEM}con{"status": "OK"|"ERR", "msg": "...", "time": ...}.
| Topic | {BASE_TOPIC}/cmd/LED |
|---|
{
"effect": "scanner",
"dur_ms": 1200,
"c1": "ff0000",
"c2": "00ff00"
}Efectos disponibles: scanner, fade.
| Topic | Descripción |
|---|---|
{BASE_TOPIC}/cmd/BUZZER |
Activar/desactivar: {"enabled": true} |
{BASE_TOPIC}/cmd/BUZZER/play |
Reproducir melodía RTTTL: {"melody": "C5:2,G4:4"} o parar: {"stop": true} |
| Topic | Descripción |
|---|---|
{BASE_TOPIC}/cmd/OFFSETS |
Establecer offsets de calibración |
{BASE_TOPIC}/cmd/OFFSETS/get |
Leer offsets actuales |
{
"temp": 0.5,
"hum": -2.0,
"lux": 50.0,
"press": -5.0
}Todos los campos son opcionales. La respuesta incluye los offsets activos.
| Topic | Descripcion |
|---|---|
{BASE_TOPIC}/cmd/AUDIO_CAL |
Calibracion del microfono (noise floor) |
{BASE_TOPIC}/stat/AUDIO_CAL |
Respuesta de calibracion |
Acciones disponibles:
{"action": "measure"} // Mide noise floor (5s silencio)
{"action": "measure", "seconds": 10} // Medicion con duracion custom (2-30s)
{"action": "set", "noise_floor": -45.0} // Fijar valor manualmente
{"action": "get"} // Consultar valor actualEl resultado se almacena en EEPROM y se aplica automaticamente al reiniciar. Ver docs/MIC_CALIBRATION_GUIDE.md para detalles.
| Topic | Descripcion |
|---|---|
{BASE_TOPIC}/cmd/PMS_CFG |
Configurar duty-cycle del sensor PMS |
{BASE_TOPIC}/stat/PMS_CFG |
Respuesta con configuracion actual |
Acciones disponibles:
{"action": "get"} // Consultar config actual
{"action": "set", "sleep_min": 9, "wake_sec": 40} // Activar duty-cycle
{"action": "set", "sleep_min": 0} // Modo continuo (sin duty-cycle)Respuesta ejemplo:
{"status": "OK", "detail": "pms_cfg", "sleep_min": 9, "wake_sec": 40, "state": "active"}sleep_min: 0-60 (0 = continuo, sin duty-cycle)wake_sec: minimoPMS_WARMUP_SEC + 10(40s por defecto). Se ajusta automaticamente si el valor es menor.state: estado actual del PMS (active,warmup,sleeping)- Los valores se persisten en EEPROM y se restauran al reiniciar.
| Topic | Descripcion |
|---|---|
{BASE_TOPIC}/cmd/REBOOT |
Reiniciar el dispositivo |
{BASE_TOPIC}/stat/SYSTEM |
Respuesta de sistema |
{BASE_TOPIC}/cmd/HA |
Control de auto-discovery HA |
{BASE_TOPIC}/stat/HA |
Respuesta de HA discovery |
Reboot:
{"now": true, "delay_ms": 500}Home Assistant Discovery:
{"action": "announce"} // Publica configs de discovery para todas las entidades
{"action": "cleanup"} // Borra todos los configs de discovery (retained vacios)El flujo habitual para regenerar entidades en HA es: primero cleanup, luego announce.
Los comandos enviados al topic global (homebot/saqs/cmd/...) aplican a todos los dispositivos simultaneamente (si MQTT_LISTEN_TO_GLOBAL esta activo en el dispositivo):
| Topic global | Equivalente por dispositivo |
|---|---|
homebot/saqs/cmd/BUZZER/play |
{BASE_TOPIC}/cmd/BUZZER/play |
homebot/saqs/cmd/LED |
{BASE_TOPIC}/cmd/LED |
homebot/saqs/cmd/REBOOT |
{BASE_TOPIC}/cmd/REBOOT |
homebot/saqs/cmd/HA |
{BASE_TOPIC}/cmd/HA |
Si HA_ENABLE_DISCOVERY está activo, al conectar se publican topics retenidos en homeassistant/sensor/{CLIENT_ID}/{object_id}/config para cada entidad:
| Object ID | Entidad | Campo JSON |
|---|---|---|
temperature |
Temperatura | temp_c |
humidity |
Humedad | hum_percent |
pressure |
Presión | press_mb |
illuminance |
Luminosidad | lux |
co2 |
CO2 | co2_ppm |
tvoc |
TVOC | tvoc_ppm |
pm1 |
PM1.0 | pm1_ugm3 |
pm25 |
PM2.5 | pm25_ugm3 |
pm10 |
PM10 | pm10_ugm3 |
hcho |
Formaldehído | hcho |
noise_dbfs |
Ruido (dBFS) | dbfs |
noise_ambient |
Ruido Ambiente (dBFS) | dbfs_slow |
aqi25 |
AQI PM2.5 | — |
aqi10 |
AQI PM10 | — |
La disponibilidad de cada entidad se vincula al topic LWT (Online/Offline).
El logging es opcional y se activa por flags de compilación. La salida base (puerto y
baudrate) se define en config/config.h (DEBUG_SERIAL_PORT,
DEBUG_BAUD); los canales se habilitan por dispositivo en config_device_X.h:
| Flag | Canal |
|---|---|
ENABLE_SERIAL_DEBUG |
Trazas de depuración por serie |
ENABLE_SERIAL_MQTT_DEBUG |
Tramas MQTT por serie |
ENABLE_MEGUNO_DEBUG |
Datos hacia MegunoLink |
ENABLE_MEGUNO_TIMEPLOT_DEBUG |
Series de sensores para TimePlot |
Si ninguno está activo, el código de logging no se compila (cero coste en flash/RAM).
Macros principales: LOG_INFO / LOG_ERROR / DBG para mensajes formateados,
LOG_ONCE y LOG_EVERY_MS para limitar la frecuencia, SCOPE_TIMER para medir el tiempo
de un bloque, y DBG_HEXDUMP para volcar buffers. Las macros LOG_* escriben a la vez por
serie y a MegunoLink cuando ambos canales están activos.
dBFS = decibelios relativos a "full scale" del ADC (0 dBFS = saturación). Valores normales son negativos; cuanto más cerca de 0, más volumen.
Todos los parámetros se definen en config.h / config_device_X.h. A continuación, los más
relevantes para ajustar la detección.
| Parámetro | Efecto |
|---|---|
AUDIO_SAMPLE_BUFFER_SIZE |
Muestras por bloque. block_ms ≈ size/rate·1000 (512 @ 16 kHz ≈ 32 ms). Más grande = más estable pero más latencia. |
AUDIO_SAMPLE_NUM_BUFFERS |
Nº de buffers. Más buffers = más margen contra dropouts a costa de RAM. |
AUDIO_SAMPLE_RATE_HZ |
Frecuencia de muestreo. Más alta = mejor respuesta a agudos y Goertzel, más CPU. |
Envolvente (rectificación + IIR), umbral adaptativo μ + K·σ y FSM con validación temporal.
| Parámetro | Efecto (subir ⇒) |
|---|---|
AUDIO_CLAP_K_SIGMA |
Ganancia sobre σ en el umbral; subir = menos falsos, menos sensible. |
AUDIO_CLAP_SIGMA_FLOOR_DB |
Floor de σ en ambientes muy silenciosos (en cuentas de envolvente; el sufijo _DB es histórico). Subir = menos falsos en silencio. |
AUDIO_CLAP_TRIGGER_MIN_DB |
Gate absoluto en dBFS; más cerca de 0 = más estricto. |
AUDIO_CLAP_PEAK_MIN_MS / _MAX_MS |
Ventana válida de duración del pico (usa múltiplos de block_ms). |
AUDIO_CLAP_MIN_TIME_BETWEEN_MS |
Separación mínima entre palmas de una secuencia. |
AUDIO_CLAP_WAIT_AFTER_FIRST_MS / _PREV_MS |
Timeouts para cerrar la secuencia (1ª palma / palmas siguientes). |
AUDIO_CLAP_DEBOUNCE_MS |
Silencio tras publicar para evitar rebotes. |
Banco de bins tonales entre FMIN–FMAX con criterios de tonalidad, estabilidad y duración.
| Parámetro | Efecto |
|---|---|
AUDIO_GOERTZEL_N |
Tamaño de bloque Goertzel; mayor = más resolución en frecuencia, más CPU. |
AUDIO_GOERTZEL_BINS |
Nº de frecuencias evaluadas entre FMIN y FMAX. |
AUDIO_GOERTZEL_FMIN_HZ / _FMAX_HZ |
Rango objetivo (silbido humano típico ~1.7–3.5 kHz). |
AUDIO_WHISTLE_TONALITY_MIN |
Tonalidad mínima = pico/total [0..1]; subir = exige tono más puro. |
AUDIO_WHISTLE_MIN_MS |
Duración acumulada mínima; subir = menos falsos. |
AUDIO_WHISTLE_DB_EXTRA_GATE |
dB extra sobre el gate de palmas. |
AUDIO_WHISTLE_DEBOUNCE_MS |
Silencio tras un silbido publicado. |
AUDIO_WHISTLE_GUARD_AFTER_CLAP_MS |
Mute de silbidos tras una palma válida (evita falsos). |
AUDIO_WHISTLE_FREQ_STABILITY_HZ |
Tolerancia para considerar el pico estable; subir = más permisivo con vibrato. |
- Falsos claps: sube
K_SIGMA/SIGMA_FLOOR_DBy acercaTRIGGER_MIN_DBa 0. - No detecta palmas suaves: baja
TRIGGER_MIN_DByK_SIGMA. - Falsos whistles (golpes agudos): sube
TONALITY_MIN,DB_EXTRA_GATEyWHISTLE_MIN_MS; manténGUARD_AFTER_CLAP_MSactivo. - No detecta silbidos reales: baja
TONALITY_MINyDB_EXTRA_GATE, y ajustaFMIN/FMAXa tu tono.
MIT © 2026 mdps
Aviso: proyecto experimental; las medidas son orientativas y no provienen de un instrumento calibrado/certificado. Entregado tal cual, sin garantía.
Un proyecto de mdps · 2026 · desarrollado en Murcia.