Saltar a contenido

AQ Monitoreo Ambiental Escolar — app.lemeit.ar/aq

Red de sensores de calidad del aire (PM1.0/PM2.5/PM10, VOC, CO2, NOx, temperatura, humedad, presión) instalada en instituciones educativas y domicilios de la Provincia de Buenos Aires — proyecto piloto en los partidos de Saladillo y 25 de Mayo. Combina sensores PurpleAir y AirGradient en una misma base y un mismo dashboard.

Repositorio: github.com/lemeit/lemeit-aq

Arquitectura

PurpleAir API + AirGradient API
        ↓ (ingesta cada 15 min)
cron-job.org (externo, único camino activo) — GitHub Actions y Cron Trigger de Cloudflare quedan en reserva
        ↓
Cloudflare D1 — tablas "sensores" (metadata) y "lecturas" (serie temporal)
        ↓
Worker "purpleair-saladillo-api" — API REST propia (JSON/CSV)
        ↓
index.html (Cloudflare Pages) — dashboard estático

Hasta agosto 2026 corrían en paralelo tres disparadores de ingesta (Cron Trigger de Cloudflare, GitHub Actions y cron-job.org) como redundancia deliberada ante el bug de Cloudflare que hace que el Cron Trigger quede registrado pero nunca dispare. El INSERT OR IGNORE + UNIQUE(sensor_index, timestamp) en lecturas evita filas duplicadas, pero no evita que cada camino activo gaste su propia llamada a la API de PurpleAir — con 4 sensores nuevos por sumarse, en septiembre 2026 se pausó GitHub Actions (queda workflow_dispatch para ejecutarlo manualmente) y cron-job.org pasó a ser el único disparador activo, para no pagar doble en puntos de la API. Detalle completo en el README del repo.

API pública

Sin autenticación, CORS abierto, pensada para que cualquiera la consuma directo — no solo el propio dashboard. También hay documentación interactiva con ejemplos en app.lemeit.ar/aq/?tab=api.

Endpoint Descripción
GET /api/sensores Metadata de todos los sensores activos
GET /api/ultimas Última lectura de cada sensor
GET /api/historico/:sensor_index Historial de un sensor — range=24h\|7d\|30d, o desde/hasta (rango de fechas absoluto en UTC), limit (máx. 20000)
GET /tiles/:style/:z/:x/:y{@2x}.png Proxy de tiles del mapa hacia CARTO Basemaps

Los tres primeros aceptan &formato=csv para descargar CSV en vez de JSON.

Guía de uso de la API

Base URL: https://api.lemeit.ar/aq (dominio público compartido con los otros proyectos de la red ambiental, vía Route de Cloudflare — ver "Arquitectura" arriba; el *.workers.dev original sigue funcionando igual como respaldo directo, sin el prefijo /aq). app.lemeit.ar/aq sirve el dashboard estático, no la API (aq.lemeit.ar viejo redirige solo ahí); el propio dashboard (index.html, que incluye la documentación de la API) usa esta misma base de API. Todos los ejemplos funcionan pegados directo en la barra del navegador o con curl.

Metadata de los sensores activos:

curl "https://api.lemeit.ar/aq/api/sensores"

Última lectura de cada sensor, en CSV:

curl "https://api.lemeit.ar/aq/api/ultimas?formato=csv" -o ultimas.csv

Histórico de un sensor puntual, últimos 7 días:

curl "https://api.lemeit.ar/aq/api/historico/12345?range=7d"

Mismo sensor, rango de fechas absoluto (UTC) y en CSV:

curl "https://api.lemeit.ar/aq/api/historico/12345?desde=2026-08-01&hasta=2026-08-31&formato=csv" -o agosto.csv

desde/hasta aceptan YYYY-MM-DD (toma desde el inicio/fin de ese día en UTC) o YYYY-MM-DD HH:MM:SS para precisión horaria. Cuando están presentes, reemplazan a range.

Desde JavaScript (por ejemplo, para un dashboard propio):

const resp = await fetch("https://api.lemeit.ar/aq/api/ultimas");
const sensores = await resp.json();

Desde Python + pandas, directo a un DataFrame:

import pandas as pd
df = pd.read_csv("https://api.lemeit.ar/aq/api/historico/12345?range=30d&formato=csv")

El sensor_index de cada sensor sale de GET /api/sensores — no hay que adivinarlo.

Descargar todo el historial de un sensor

GET /api/historico/:sensor_index devuelve como máximo 20.000 filas por pedido (3000 por defecto si no se manda limit). Un sensor con varios meses de datos puede superar ese tope fácilmente — en ese caso no hay forma de traerlo todo en un solo pedido, hay que partirlo en tramos de fecha con desde/hasta (por ejemplo, uno por mes) y unir los CSV resultantes:

curl "https://api.lemeit.ar/aq/api/historico/12345?desde=2026-01-01&hasta=2026-01-31&formato=csv" -o 2026-01.csv
curl "https://api.lemeit.ar/aq/api/historico/12345?desde=2026-02-01&hasta=2026-02-28&formato=csv" -o 2026-02.csv
# ...

El mismo botón "Descargar CSV" de la pestaña Historial del dashboard tiene esta misma limitación (te descarga lo que ves filtrado en pantalla, con el mismo tope). Si necesitás el historial completo de un sensor de una sola vez — por ejemplo para un análisis o como respaldo propio — escribí a info@lemeit.ar y te pasan un export directo de la base.

Scripts de administración

Dos scripts de PowerShell en scripts/ del repo, para consultas rápidas por consola sin abrir el navegador (los endpoints que usan van por header, no se pueden pegar directo en la barra de direcciones).

Ver visitas al sitio (scripts/ver-visitas.ps1) — consulta los endpoints admin del propio Worker (/api/admin/visitas, /api/admin/resumen), protegidos por el secret ADMIN_KEY:

cd scripts
.\ver-visitas.ps1                    # resumen: 24h, 7d, top rutas, top países
.\ver-visitas.ps1 -Modo visitas      # últimas 200 visitas, una por una
.\ver-visitas.ps1 -Modo visitas -Limit 50

Pide la ADMIN_KEY por consola (oculta al tipear), o se puede dejar puesta para toda la sesión de PowerShell con $env:PA_ADMIN_KEY = "tu_clave" así no se vuelve a pedir.

Ver uso de la API de PurpleAir (scripts/ver-uso-purpleair.ps1) — consulta directo contra api.purpleair.com (no pasa por nuestro Worker) cuántos puntos quedan y a qué ritmo se consumen:

cd scripts
.\ver-uso-purpleair.ps1

Pide la PURPLEAIR_API_KEY (la misma que se cargó con wrangler secret put), o se puede fijar con $env:PA_API_KEY = "tu_clave" para no re-tipearla. Esta consulta no gasta puntos de la cuota — PurpleAir la marca explícitamente como "a free API call, so query it as you need" (confirmado por su staff en el foro oficial de la API, abril 2026), así que se puede correr las veces que haga falta.

Sensores y códigos AE

Cada sensor PurpleAir de la red tiene un código propio del proyecto (AE-NN), independiente del sensor_index que asigna PurpleAir.

Código sensor_index Institución Distrito / ámbito Dirección CUE Alta
AE-00 198617 Colegio Secundario Madre Teresa Saladillo / rural Calle Jauretche e Yrigoyen — previa al proyecto (en reubicación)
AE-01 298687 Esc. de Educación Artística N°1 "Lola Mora" 25 de Mayo / urbano Calle 7 entre 23 y 24 061617500 2026-10-06
AE-02 298701 Esc. de Educación Secundaria N°2 "Manuel Pardal" Saladillo / urbano Av. Pereyra 3046 060342300 2026-10-06
AE-03 298691 Instituto Niño Jesús — — — Confirmado 2026-10-28
AE-04 — Del Valle (reservado) — — — Pendiente

AE-01, AE-02 y AE-03 son PurpleAir PA-II-FLEX. AE-03 corresponde al Instituto Niño Jesús (298691); AE-04 queda reservado para Del Valle, sin sensor_index asignado. La convención se definió para los PurpleAir; los AirGradient (ID sintético 900000+) todavía no tienen código asignado.

Estado al 28/10/2026: AE-01 sigue pendiente de instalación física según última información; AE-02 está operativo. AE-03 está online y visible según el titular, con lectura reciente verificada en D1 y codigo = 'AE-03' confirmado tras una actualización autorizada. Los AirGradient 900002 y 900003 conservan lecturas antiguas; no se acredita conexión actual. Ver bitácora y procedimiento seguro y reversión.

Convención de códigos

  • AE-00 es el primer sensor de la red, anterior al proyecto; AE-01 en adelante se asignan por orden de instalación.
  • El código es estable: si un sensor cambia de escuela, el código no cambia, solo la institución.
  • En la base D1 (tabla sensores) se guarda en la columna codigo (única) y la fecha de alta en fecha_instalacion. La API los devuelve en /api/sensores y /api/ultimas.
  • En el portal, la tarjeta muestra la institución como título y el nombre del sensor (AE-NN) debajo.
  • El Worker no pisa nombre, institucion, codigo ni fecha_instalacion en las ingestas; solo actualiza latitud y longitud desde PurpleAir.

Cómo dar de alta un sensor

  1. Registrarlo en PurpleAir con el nombre AE-NN, coordenadas de la escuela, ubicación exterior y visibilidad pública.
  2. Anotar el sensor_index que le asigna PurpleAir (no es el Device ID).
  3. Respaldar privadamente la lista completa autorizada; D1 por sí sola no garantiza conocer la lista activa. Con autorización explícita, actualizar el secret SENSOR_INDEXES del Worker. El secret se reemplaza entero (en Cloudflare: Rotate y luego Deploy): hay que pegar la lista completa, con los sensores ya activos más el nuevo, separada por comas y sin espacios.
  4. Comprobar que la versión nueva está efectivamente desplegada; Rotate/Save version por sí solo puede dejarla sin activar. Esperar al próximo disparo de la ingesta (ejecutarla a mano requiere autorización explícita): se crea la fila en sensores.
  5. Con autorización explícita para escribir D1, cargar codigo, fecha_instalacion, institucion y notas (CUE y clave provincial) con un UPDATE desde la consola de D1.
  6. Verificar con un SELECT que la fila quedó completa.

Un UPDATE sobre un sensor_index que todavía no existe en la tabla no da error y no cambia nada.

Roadmap

Estado del hardware (octubre 2026): los 5 sensores PurpleAir están en poder del proyecto. El único que venía funcionando era AE-00, en el Colegio Secundario Madre Teresa, hoy pendiente de reubicación. AE-01 (25 de Mayo) permanece pendiente de instalación física según última información; AE-02 (Saladillo) está operativo; AE-03 está asignado al Instituto Niño Jesús, online y visible según el titular; AE-04 queda reservado para Del Valle, sin índice asignado. Las 4 unidades nuevas fueron una donación de PurpleAir Collective (convocatoria de mitad de 2024, en base a una propuesta de necesidad presentada por el autor — el proyecto quedó en 2° puesto de esa ronda). Los 2 sensores AirGradient registrados (900002 y 900003) tienen lecturas antiguas en el corte D1 del 28/10; no se acredita conexión actual ni instalación definitiva del kit.

Fase 2 — expansión confirmada (reunión institucional del 16/9/2026, convocada por la Jefatura Regional de Educación): 5 escuelas — 3 en Saladillo y 2 en el partido de 25 de Mayo — una de zona urbana y una de zona rural en cada partido, ampliando la red más allá del partido de Saladillo por primera vez. 4 confirmadas; la quinta (Colegio Secundario Madre Teresa) está en proceso de reubicación. Documentación de la reunión: plan de despliegue y hoja de especificaciones/requerimientos en docs/ del repo. Ver también el roadmap de EMA Saladillo, que suma una estación en 25 de Mayo por el mismo motivo. Esta expansión abre la puerta a un futuro apartado de reportes combinados (aire + meteorología) con análisis espacial entre sensores — hoy no es inmediato porque las estaciones EMA y los sensores de aire no comparten ubicación física.

Hitos técnicos

  • Deduplicación: cuando un sensor pierde conexión, PurpleAir sigue devolviendo su último last_seen congelado — sin UNIQUE(sensor_index, timestamp) cada corrida del cron generaba una fila nueva con el mismo timestamp, produciendo tarjetas duplicadas/triplicadas en el dashboard.
  • AirGradient (agosto 2026): se sumó como segundo proveedor de sensores, reutilizando las mismas tablas (proveedor distingue el origen) en vez de un portal separado.
  • Proxy de tiles: CARTO empezó a exigir API key para servir tiles; en vez de exponerla en el HTML público, el Worker actúa de proxy y agrega la key del lado del servidor (CARTO_API_KEY como secret).
  • API pública (agosto 2026): los endpoints de lectura, que ya existían para alimentar el propio dashboard, se documentaron y ampliaron (rango de fechas absoluto, export CSV) para que terceros —por ejemplo, otro sector de la escuela o de la Municipalidad— puedan consumir los datos sin depender del dashboard.
  • Administración de visitas (septiembre 2026): nueva tabla visitas en D1, escrita en segundo plano vía ctx.waitUntil para no demorar la respuesta real, y dos endpoints de solo lectura (/api/admin/visitas, /api/admin/resumen) protegidos por un secret ADMIN_KEY enviado como header X-Admin-Key — mismo esquema que ya usaba X-Ingest-Key — en vez de ?key=... en la URL, para no dejar la clave pegada en el historial del navegador ni en logs de acceso/proxies intermedios. Se sumó scripts/ver-visitas.ps1 para consultarlo desde PowerShell, ya que ahí curl es un alias de Invoke-WebRequest con una sintaxis de headers distinta a la del curl real (hace falta curl.exe o -Headers @{...}).
  • Historial — reporte configurable y export PDF (septiembre 2026): se agregó filtro de rango de fechas personalizado (desde/hasta), filtro de parámetros por checkboxes (incluyendo PM1.0/PM10, que PurpleAir y AirGradient ya reportaban pero no se mostraban) y exportación a PDF con formato de reporte climatológico estilo NOAA — texto plano monoespaciado (fuente Courier de jsPDF, sin embeber archivos de fuente), siempre en fondo blanco, independiente del tema claro/oscuro del resto del sitio. jsPDF se carga desde cdnjs con respaldo automático a jsDelivr si el CDN principal falla (se vio en producción: un bloqueador de contenido impedía cargar el script y tiraba window.jspdf indefinido, sin manejar la excepción — ahora hay un aviso claro al usuario en vez de romper la página). El PDF, además de descargarse, ahora también se abre en una pestaña nueva (blob URL, visor nativo del navegador) dentro del mismo click, para poder verlo sin ir a buscar el archivo descargado.
    • La vista "Reporte" en pantalla (toggle Clásica/Reporte junto a la tabla normal) resultó redundante tras uso real — el PDF ya se veía siempre en blanco/formato reporte. Se sacó el toggle: la tabla en pantalla usa siempre el formato normal del sitio, y el estilo reporte quedó reservado al PDF. De paso se agregó accent-color a los checkboxes del sitio (el azul por defecto del navegador desentonaba con la paleta).
    • Terminología: "Estación" se reemplazó por "Sitio" (encabezado del PDF y demás lugares que nombran la ubicación de un sensor), para no chocar con "estación meteorológica" del proyecto hermano EMA.
  • Gráfico — comparación de sitios (septiembre 2026): nuevo modo ("Comparar sitios") donde se elige un parámetro — no varios, para no mezclar unidades en un mismo eje — y varios sitios a la vez, cada uno dibujado como una línea de color distinto sobre el mismo eje Y. Al no medir todos los sitios en el mismo instante exacto (cada uno hace su propio polling), el eje de tiempo se arma uniendo las marcas de todos los sitios elegidos, con spanGaps para no cortar las líneas en los huecos propios de cada uno — una limitación conocida, a mejorar a futuro con un agrupado por intervalos redondeados.
  • PWA instalable (septiembre 2026): el dashboard se puede instalar como app (manifest.json, ícono "maskable" para Android, sw.js). Al abrirse instalado — o con ?pwa=1 en la URL, que es el start_url del manifest — arranca directo en la pestaña "Actuales" en vez de "Mapa": los relojes tipo velocímetro de AQI son la portada más útil en el celular. Detección de lanzamiento vía matchMedia("(display-mode: standalone)") más navigator.standalone (cubre Safari/iOS, que no soporta bien esa media query). El service worker cachea solo el "app shell" (HTML, manifest, íconos); los datos en vivo (API propia) y las librerías de CDN (Chart.js, jsPDF, Leaflet, fuentes, design.lemeit.ar) van siempre directo a red, para no quedar con lecturas ni versiones de librerías desactualizadas.
  • Gráfico — zoom por selección (septiembre 2026): se sumó chartjs-plugin-zoom (+ hammerjs para gestos táctiles) al Gráfico, en ambos modos (histórico de un sitio y "Comparar sitios"): arrastre del mouse para zoom por selección ("box zoom"), rueda para acercar/alejar, pellizco táctil en celular, y un botón "Reset zoom" que aparece solo cuando hay zoom aplicado. Mismo patrón de doble CDN con respaldo (jsDelivr primero, cdnjs si falla) ya usado para jsPDF.
    • Fix: en el primer despliegue, arrastrar para seleccionar terminaba haciendo zoom hacia el lado contrario de donde se arrastraba, y peor cuantos más zooms anidados (zoom sobre un zoom ya aplicado). Causa: el paneo (mover la vista arrastrando) y el zoom por selección estaban atados al mismo gesto — arrastre simple, sin modificador — y competían entre sí; el paneo corría el eje mientras se armaba el rectángulo de selección. Se resolvió exigiendo Ctrl+arrastre para panear, dejando el arrastre simple exclusivamente para la selección de zoom. De paso se cambió el eje X de una escala de categorías (labels de texto) a una escala numérica (linear, con los horarios mostrados vía un callback de ticks), porque chartjs-plugin-zoom no soporta bien los zooms anidados sobre un eje de categorías.
  • Enlaces a la wiki: el footer compartido (lemeit-common.js, usado por los tres portales) y la sección "Acerca de" de cada uno pasan a enlazar a wiki.lemeit.ar — hasta ahora la wiki no se mencionaba desde ningún portal.
  • Logo AQ → AE y dominio único app.lemeit.ar (septiembre 2026): la sigla del logo pasa de "AQ" (Air Quality, inconsistente con el resto del sitio en español) a AE (Aire Escolar), en un verde-azulado propio (#009688) en vez del naranja general del sitio. Junto con esto, los tres portales de la red pasan a convivir bajo un solo dominio (app.lemeit.ar/aq, /emas, /wq — ver arquitectura de la Red); aq.lemeit.ar sigue funcionando, redirige solo. El manifest.json amplía su scope de /aq/ a todo el dominio nuevo, para que cambiar de portal desde la app instalada no salga al navegador.
  • Documentación de la API y créditos dentro del portal: api.html y creditos.html pasaron de páginas sueltas a pestañas de index.html (?tab=api, ?tab=creditos). Los archivos originales quedan solo como redirección para no romper links externos, entre ellos los de esta wiki.
  • Código propio por sensor (octubre 2026): columna codigo en sensores (migración 008) con la convención AE-NN, independiente del sensor_index de PurpleAir y estable si un sensor cambia de escuela. La fecha de alta usa la columna fecha_instalacion, que ya existía en el esquema. Ver "Sensores y códigos AE" más arriba, con el procedimiento de alta de un sensor.

Ver la Bitácora del proyecto para el detalle sesión por sesión.