School Air Quality Monitoring — app.lemeit.ar/aq¶
Air quality sensor network (PM1.0/PM2.5/PM10, VOC, CO2, NOx, temperature, humidity, pressure) installed in educational institutions and private homes across Buenos Aires Province — a pilot project in the Saladillo and 25 de Mayo districts. Combines PurpleAir and AirGradient sensors on the same database and the same dashboard.
Repository: github.com/lemeit/lemeit-aq
Architecture¶
PurpleAir API + AirGradient API
↓ (ingestion every 15 min)
cron-job.org (external, only active path) — GitHub Actions and Cloudflare Cron Trigger stay on standby
↓
Cloudflare D1 — "sensores" (metadata) and "lecturas" (time series) tables
↓
Worker "purpleair-saladillo-api" — own REST API (JSON/CSV)
↓
index.html (Cloudflare Pages) — static dashboard
Until August 2026 three ingestion triggers ran in parallel (Cloudflare Cron Trigger, GitHub Actions and cron-job.org) as deliberate redundancy against a Cloudflare bug that leaves the Cron Trigger registered but never firing. The INSERT OR IGNORE + UNIQUE(sensor_index, timestamp) on lecturas prevents duplicate rows, but doesn't prevent each active path from spending its own call to the PurpleAir API — with 4 new sensors about to be added, in September 2026 GitHub Actions was paused (workflow_dispatch stays available to run it manually) and cron-job.org became the only active trigger, to avoid paying double in API quota points. Full detail in the repo README.
Public API¶
No authentication, open CORS, meant for anyone to consume directly — not just the dashboard itself. There's also interactive documentation with examples at app.lemeit.ar/aq/?tab=api.
| Endpoint | Description |
|---|---|
GET /api/sensores |
Metadata for every active sensor |
GET /api/ultimas |
Latest reading for each sensor |
GET /api/historico/:sensor_index |
A sensor's history — range=24h\|7d\|30d, or desde/hasta (absolute date range, UTC), limit (max. 20000) |
GET /tiles/:style/:z/:x/:y{@2x}.png |
Map tile proxy to CARTO Basemaps |
The first three accept &formato=csv to download CSV instead of JSON.
API usage guide¶
Base URL: https://api.lemeit.ar/aq (public domain shared with the other environmental network projects, via a Cloudflare Route — see "Architecture" above; the original *.workers.dev still works the same as a direct fallback, without the /aq prefix). app.lemeit.ar/aq serves the static dashboard, not the API (the old aq.lemeit.ar only redirects there); the dashboard itself (index.html, which includes the API documentation) uses this same API base. Every example below works pasted straight into a browser's address bar or with curl.
Metadata for the active sensors:
Latest reading for each sensor, as CSV:
History for a single sensor, last 7 days:
Same sensor, absolute date range (UTC) and 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 accept YYYY-MM-DD (from the start/end of that day in UTC) or YYYY-MM-DD HH:MM:SS for hour-level precision. When present, they replace range.
From JavaScript (for example, for your own dashboard):
const resp = await fetch("https://api.lemeit.ar/aq/api/ultimas");
const sensores = await resp.json();
From Python + pandas, straight into a DataFrame:
import pandas as pd
df = pd.read_csv("https://api.lemeit.ar/aq/api/historico/12345?range=30d&formato=csv")
Each sensor's sensor_index comes from GET /api/sensores — no need to guess it.
Downloading a sensor's entire history¶
GET /api/historico/:sensor_index returns at most 20,000 rows per request (3,000 by default if limit isn't sent). A sensor with several months of data can easily go over that cap — in that case there's no way to fetch it all in a single request, it has to be split into date chunks with desde/hasta (one per month, for example) and the resulting CSVs merged:
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
# ...
The dashboard's own "Download CSV" button on the History tab has this same limitation (it downloads whatever is filtered on screen, with the same cap). If you need a sensor's complete history in one go — for an analysis, or as your own backup — write to info@lemeit.ar and you'll be sent a direct export from the database.
Admin scripts¶
Two PowerShell scripts under the repo's scripts/, for quick queries from the console without opening a browser (the endpoints they use require a header, which can't be pasted directly into the address bar).
View site visits (scripts/ver-visitas.ps1) — queries the Worker's own admin endpoints (/api/admin/visitas, /api/admin/resumen), protected by the ADMIN_KEY secret:
cd scripts
.\ver-visitas.ps1 # summary: 24h, 7d, top routes, top countries
.\ver-visitas.ps1 -Modo visitas # last 200 visits, one by one
.\ver-visitas.ps1 -Modo visitas -Limit 50
Prompts for ADMIN_KEY on the console (hidden while typing), or it can be set for the whole PowerShell session with $env:PA_ADMIN_KEY = "your_key" so it isn't asked again.
View PurpleAir API usage (scripts/ver-uso-purpleair.ps1) — queries api.purpleair.com directly (doesn't go through our Worker) for how many points are left and at what rate they're being spent:
Prompts for PURPLEAIR_API_KEY (the same one set with wrangler secret put), or it can be set with $env:PA_API_KEY = "your_key" to avoid re-typing it. This particular query doesn't spend quota points — PurpleAir explicitly marks it as "a free API call, so query it as you need" (confirmed by their staff on the official API forum, April 2026), so it can be run as many times as needed.
Sensors and AE codes¶
Each PurpleAir sensor in the network has its own project code (AE-NN), independent of the sensor_index assigned by PurpleAir.
| Code | sensor_index |
Institution | District / setting | Address | CUE | Installed |
|---|---|---|---|---|---|---|
| AE-00 | 198617 | Colegio Secundario Madre Teresa | Saladillo / rural | Calle Jauretche e Yrigoyen | — | before the project (being relocated) |
| AE-01 | 298687 | Esc. de Educación Artística N°1 "Lola Mora" | 25 de Mayo / urban | Calle 7 between 23 and 24 | 061617500 | 2026-10-06 |
| AE-02 | 298701 | Esc. de Educación Secundaria N°2 "Manuel Pardal" | Saladillo / urban | 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 and AE-03 are PurpleAir PA-II-FLEX units. AE-03 is assigned to Instituto Niño Jesús (298691); AE-04 is reserved for Del Valle, with no sensor_index assigned. The convention was defined for the PurpleAir sensors; the AirGradient ones (synthetic ID 900000+) don't have a code assigned yet.
Code convention¶
AE-00is the network's first sensor, from before the project;AE-01onwards are assigned in installation order.- The code is stable: if a sensor moves to another school, the code stays and only the institution changes.
- In the D1 database (
sensorestable) it is stored in thecodigocolumn (unique), and the installation date infecha_instalacion. The API returns both in/api/sensoresand/api/ultimas. - On the portal, each card shows the institution as its title and the sensor name (
AE-NN) below it. - The Worker does not overwrite
nombre,institucion,codigoorfecha_instalacionduring ingestion; it only updates latitude and longitude from PurpleAir.
How to add a sensor¶
- Register it on PurpleAir with the name
AE-NN, the school's coordinates, outdoor location and public visibility. - Note the
sensor_indexPurpleAir assigns (this is not the Device ID). - Update the Worker's
SENSOR_INDEXESsecret. The secret is replaced entirely (in Cloudflare: Rotate, then Deploy): paste the full list, with the sensors already active plus the new one, comma-separated with no spaces. - Wait for the next ingestion run (or trigger it manually): the row is created in
sensores. - Fill in
codigo,fecha_instalacion,institucionandnotas(CUE and provincial code) with anUPDATEfrom the D1 console. - Check with a
SELECTthat the row is complete.
An UPDATE on a sensor_index that doesn't exist yet raises no error and changes nothing.
Roadmap¶
Update, 28 October 2026: AE-01 is still awaiting physical installation according to the latest owner report; AE-02 is operational. AE-03 is online and visible according to the owner, with a recent D1 reading verified independently. Its project code
AE-03was added with explicit authorization and checked by SELECT. AirGradient 900002 and 900003 have old readings; current connectivity is unverified. Rotate created a Worker version that still required deployment in this incident. See the Spanish log and safe procedure and rollback. The hardware description below records an earlier state.
Hardware status (October 2026): all 5 PurpleAir sensors are in the project's hands. The only one running until now was AE-00, at Colegio Secundario Madre Teresa, currently pending relocation. AE-01 (25 de Mayo) and AE-02 (Saladillo) are already registered on PurpleAir and on the portal, and their installation is planned for October 6; AE-03 and AE-04 don't have an assigned destination yet. The 4 new units were a donation from PurpleAir Collective (mid-2024 call, based on a needs proposal submitted by the author — the project placed 2nd in that round). The 2 AirGradient sensors are also in hand and active, currently connected at a private home for testing, pending a decision on which institutions they'll be installed in.
Phase 2 — confirmed expansion (institutional meeting on 9/16/2026, convened by the Regional Education Office): 5 schools — 3 in Saladillo and 2 in the 25 de Mayo district — one urban and one rural site in each district, expanding the network beyond Saladillo for the first time. 4 confirmed; the fifth (Colegio Secundario Madre Teresa) is being relocated. Meeting documentation: deployment plan and specification/requirements sheet in the repo's docs/. See also the EMA Saladillo roadmap (Spanish only), which adds a station in 25 de Mayo for the same reason. This expansion opens the door to a future combined report (air + weather) with spatial analysis between sensors — not immediate today, since the EMA stations and the air sensors don't share a physical location yet.
Technical milestones¶
- Deduplication: when a sensor loses connection, PurpleAir keeps returning its last frozen
last_seen— withoutUNIQUE(sensor_index, timestamp), each cron run generated a new row with the same timestamp, producing duplicate/triplicate cards on the dashboard. - AirGradient (August 2026): added as a second sensor provider, reusing the same tables (
proveedortells the two apart) instead of a separate portal. - Tile proxy: CARTO started requiring an API key to serve tiles; instead of exposing it in the public HTML, the Worker acts as a proxy and adds the key server-side (
CARTO_API_KEYas a secret). - Public API (August 2026): the read endpoints, which already existed to feed the dashboard itself, were documented and expanded (absolute date range, CSV export) so third parties — another school department, or the municipality, for example — can consume the data without depending on the dashboard.
- Visit tracking (September 2026): new
visitastable in D1, written in the background viactx.waitUntilso it never delays the real response, plus two read-only endpoints (/api/admin/visitas,/api/admin/resumen) protected by anADMIN_KEYsecret sent as theX-Admin-Keyheader — same scheme already used byX-Ingest-Key— instead of?key=...in the URL, so the key doesn't end up stuck in browser history or in access logs/intermediate proxies.scripts/ver-visitas.ps1was added to query it from PowerShell, since therecurlis an alias forInvoke-WebRequestwith different header syntax than realcurl(realcurl.exe, or-Headers @{...}, is needed). - History — configurable report and PDF export (September 2026): added a custom date-range filter (
desde/hasta), a parameter filter via checkboxes (including PM1.0/PM10, which PurpleAir and AirGradient already reported but weren't shown), and a PDF export with NOAA-style climatological report formatting — plain monospaced text (jsPDF's Courier font, no embedded font files), always on a white background, independent of the rest of the site's light/dark theme. jsPDF loads from cdnjs with an automatic fallback to jsDelivr if the primary CDN fails (seen in production: a content blocker prevented the script from loading and leftwindow.jspdfundefined, unhandled — now there's a clear notice to the user instead of a broken page). The PDF, besides downloading, now also opens in a new tab (blob URL, the browser's native viewer) within the same click, so it can be viewed without having to go find the downloaded file.- The on-screen "Report" view (a Classic/Report toggle next to the normal table) turned out redundant after real use — the PDF already always looked white/report-style. The toggle was removed: the on-screen table always uses the site's normal format, and the report style stayed reserved for the PDF. Along the way,
accent-colorwas added to the site's checkboxes (the browser's default blue clashed with the palette). - Terminology: "Estación" (Station) was replaced with "Sitio" (Site) — in the PDF header and other places naming a sensor's location — to avoid clashing with "weather station" from the sibling EMA project.
- The on-screen "Report" view (a Classic/Report toggle next to the normal table) turned out redundant after real use — the PDF already always looked white/report-style. The toggle was removed: the on-screen table always uses the site's normal format, and the report style stayed reserved for the PDF. Along the way,
- Chart — site comparison (September 2026): new mode ("Compare sites") where you pick one parameter — not several, to avoid mixing units on the same axis — and several sites at once, each drawn as a differently-colored line on the same Y axis. Since not every site measures at the exact same instant (each one polls on its own), the time axis is built by merging the timestamps of all selected sites, with
spanGapsso lines aren't cut at each site's own gaps — a known limitation, to be improved in the future with rounded-interval bucketing. - Installable PWA (September 2026): the dashboard can be installed as an app (
manifest.json, a "maskable" icon for Android,sw.js). When opened installed — or with?pwa=1in the URL, which is the manifest'sstart_url— it starts directly on the "Current" tab instead of "Map": the speedometer-style AQI gauges are the most useful landing view on a phone. Launch detection viamatchMedia("(display-mode: standalone)")plusnavigator.standalone(covers Safari/iOS, which doesn't support that media query well). The service worker only caches the "app shell" (HTML, manifest, icons); live data (the own API) and CDN libraries (Chart.js, jsPDF, Leaflet, fonts, design.lemeit.ar) always go straight to the network, so readings and library versions never go stale. - Chart — zoom by selection (September 2026): added
chartjs-plugin-zoom(+hammerjsfor touch gestures) to the Chart tab, in both modes (a single site's history and "Compare sites"): mouse drag for box-zoom selection, scroll wheel to zoom in/out, touch pinch on mobile, and a "Reset zoom" button that only shows up once a zoom has been applied. Same dual-CDN fallback pattern already used for jsPDF (jsDelivr first, cdnjs if it fails).- Fix: on the first deploy, dragging to select ended up zooming in the opposite direction from the drag, worse the more nested zooms were applied (zooming into an already-zoomed view). Cause: panning (moving the view by dragging) and zoom-by-selection were bound to the same gesture — a plain drag, no modifier key — and fought each other; panning shifted the axis while the selection rectangle was still being drawn. Fixed by requiring Ctrl+drag to pan, leaving a plain drag exclusively for zoom selection. Along the way, the X axis was switched from a category scale (text labels) to a numeric (
linear) scale, with times shown via a tick callback, becausechartjs-plugin-zoomdoesn't handle nested zooms well on a category axis.
- Fix: on the first deploy, dragging to select ended up zooming in the opposite direction from the drag, worse the more nested zooms were applied (zooming into an already-zoomed view). Cause: panning (moving the view by dragging) and zoom-by-selection were bound to the same gesture — a plain drag, no modifier key — and fought each other; panning shifted the axis while the selection rectangle was still being drawn. Fixed by requiring Ctrl+drag to pan, leaving a plain drag exclusively for zoom selection. Along the way, the X axis was switched from a category scale (text labels) to a numeric (
- Wiki links: the shared footer (
lemeit-common.js, used by all three portals) and each portal's "About" section now link to wiki.lemeit.ar — until now the wiki wasn't mentioned from any portal. - AQ → AE logo and single
app.lemeit.ardomain (September 2026): the logo initials changed from "AQ" (Air Quality, inconsistent with the rest of the Spanish-language site) to AE (Aire Escolar, "School Air"), in its own teal color (#009688) instead of the site's general orange. Alongside this, the network's three portals moved to live under a single domain (app.lemeit.ar/aq,/emas,/wq— see the Network architecture);aq.lemeit.arstill works, it just redirects.manifest.jsonwidened itsscopefrom/aq/to the whole new domain, so switching portals from the installed app doesn't kick out to the browser. - API documentation and credits inside the portal:
api.htmlandcreditos.htmlwent from standalone pages to tabs ofindex.html(?tab=api,?tab=creditos). The original files remain only as redirects so external links don't break, including the ones from this wiki. - Own code per sensor (October 2026): new
codigocolumn insensores(migration 008) with theAE-NNconvention, independent of PurpleAir'ssensor_indexand stable if a sensor moves to another school. The installation date uses thefecha_instalacioncolumn, which already existed in the schema. See "Sensors and AE codes" above for the procedure to add a sensor.
See the project log (Spanish only) for the full session-by-session detail.