- Python 98.3%
- Dockerfile 1.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
image / publier (push) Successful in 27s
Reviewed-on: #11 |
||
| .forgejo/workflows | ||
| deploy | ||
| grafana | ||
| AGENTS.md | ||
| airthings_exporter.py | ||
| airthings_site.py | ||
| Containerfile | ||
| LICENSE | ||
| README.md | ||
| renovate.json5 | ||
| requirements.txt | ||
| test_config.py | ||
| test_exporter.py | ||
| test_site.py | ||
Airthings → Prometheus exporter
Récupère les mesures de vos capteurs via l'API Airthings Consumer (1 appel / minute) et les expose au format Prometheus pour Grafana. Remplace l'interface web de l'ancien dashboard.
Deux scripts, qui partagent le même client API et la même configuration :
airthings_exporter.py: service permanent, expose/metricspour Prometheus ;airthings_site.py: génère à la demande un site statique local (valeurs, seuils, historique, graphiques agrandissables), voir Site statique local. Il lit les valeurs que l'exporter garde en mémoire, sans appeler l'API Airthings (ou, sans exporter, il appelle l'API lui-même).
Mesures : radon, CO₂, COV, humidité, température, PM1, PM2.5, pression atmosphérique (+ batterie et fraîcheur de la donnée).
1. Créer les identifiants API
Sur https://consumer-api-doc.airthings.com/dashboard : Create New Application, type de flux Client Credentials (machine-to-machine). Vous obtenez un client_id et un client_secret.
2. Configurer et lancer
Python 3.10 ou plus récent. Les identifiants se placent dans un fichier .env (un modèle est fourni) :
pip install -r requirements.txt
cp .env.example .env && chmod 600 .env # puis renseigner AIRTHINGS_CLIENT_ID et AIRTHINGS_CLIENT_SECRET
python airthings_exporter.py # http://localhost:9876/metrics
Le .env est cherché dans le dossier courant, puis à côté du script (pratique pour un lancement planifié). AIRTHINGS_ENV_FILE=/chemin/fichier impose un autre emplacement (une erreur est levée s'il n'existe pas). Une variable déjà définie dans l'environnement l'emporte toujours sur le fichier. Le .env est exclu par .gitignore : ne le versionnez pas.
Conteneur, avec Podman ou Docker (l'image lance l'exporter, en utilisateur non root ; elle contient aussi le générateur du site, lancé avec --entrypoint python suivi de airthings_site.py et ses options ; le .env n'est pas copié dans l'image, il est fourni au lancement ; écrivez les valeurs sans guillemets, --env-file ne les interprète pas) :
podman build -t airthings-exporter .
podman run -d --name airthings-exporter --restart unless-stopped -p 9876:9876 \
--env-file .env airthings-exporter
Avec Docker, mêmes commandes en remplaçant podman par docker et en ajoutant -f Containerfile au build.
L'image part de Chainguard (cgr.dev/chainguard/python), téléchargeable sans compte et signée par Chainguard avec Sigstore : l'image d'exécution n'a ni shell ni gestionnaire de paquets, et tourne en utilisateur non root (uid 65532). L'offre gratuite de Chainguard ne publie que la dernière version de Python : l'image suit donc leur version. Les images de base sont épinglées par digest dans le Containerfile et les dépendances Python sont épinglées avec leurs hashes dans requirements.txt (pip refuse tout paquet dont le hash diffère). La CI vérifie la signature des images de base avec cosign avant de construire ; la même vérification à la main :
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity https://github.com/chainguard-images/images/.github/workflows/release.yaml@refs/heads/main \
cgr.dev/chainguard/python@sha256:… # digest d'une ligne FROM
Les secrets peuvent aussi venir de fichiers (AIRTHINGS_CLIENT_ID_FILE, AIRTHINGS_CLIENT_SECRET_FILE), pratique avec les secrets Podman, Docker ou Kubernetes.
| Variable | Défaut | Rôle |
|---|---|---|
AIRTHINGS_CLIENT_ID / AIRTHINGS_CLIENT_SECRET |
– (obligatoires) | Identifiants OAuth2 |
AIRTHINGS_ENV_FILE |
./.env, puis .env à côté du script |
Chemin du fichier d'environnement à charger |
AIRTHINGS_POLL_INTERVAL |
60 |
Secondes entre deux appels API (plancher : 40) |
AIRTHINGS_DEVICE_REFRESH_INTERVAL |
3600 |
Rafraîchissement de la liste des appareils (noms, pièces) |
LISTEN_ADDR / LISTEN_PORT |
0.0.0.0 / 9876 |
Écoute HTTP |
AIRTHINGS_SCOPE |
non défini | Scope OAuth2 optionnel (ex. read:device:current_values) |
LOG_LEVEL |
INFO |
3. Prometheus
scrape_configs:
- job_name: airthings
scrape_interval: 60s
static_configs:
- targets: ["airthings-exporter:9876"]
Le scrape ne déclenche aucun appel à l'API Airthings : l'exporter interroge l'API de son côté, à son propre rythme, et sert la dernière valeur connue. Vous pouvez donc scraper plus souvent sans risque pour le quota.
Métriques
Labels sur toutes les séries par appareil : serial_number, name, home, type.
| Métrique | Capteur API | Unité |
|---|---|---|
airthings_radon_bq_m3 |
radonShortTermAvg |
Bq/m³ |
airthings_co2_ppm |
co2 |
ppm |
airthings_voc_ppb |
voc |
ppb |
airthings_humidity_percent |
humidity |
% |
airthings_temperature_celsius |
temp |
°C |
airthings_pm1_ug_m3 / airthings_pm25_ug_m3 |
pm1 / pm25 |
µg/m³ |
airthings_pressure_hpa |
pressure |
hPa |
airthings_battery_percent |
batteryPercentage |
% |
airthings_last_recorded_timestamp_seconds |
recorded |
epoch |
airthings_exporter_up |
– | 1 = dernier appel API OK |
airthings_exporter_last_success_timestamp_seconds |
– | epoch |
airthings_exporter_last_poll_duration_seconds |
– | durée du dernier appel à l'API, en secondes |
airthings_exporter_polls_total{result} |
– | success / error / rate_limited |
airthings_api_rate_limit_remaining / _limit |
en-têtes X-RateLimit-* |
requêtes |
Un appareil qui n'a pas un capteur (ex. pas de radon sur un Wave Mini) n'a simplement pas la série correspondante. Pour ajouter une mesure (luminosité, niveau sonore…), ajouter une ligne dans METRICS en haut de airthings_exporter.py ; pour l'afficher aussi sur le site, ajouter une entrée dans SHOWN en haut de airthings_site.py.
Dashboard Grafana
grafana/airthings-dashboard.json, écrit pour Grafana 10.4 ou plus récent. Import : Dashboards → New → Import → Upload dashboard JSON file, puis laisser la variable Source sur votre datasource Prometheus.
Variables en haut de page : Source (datasource Prometheus) et Appareil (multi-sélection, tous par défaut, d'après le label name).
| Section | Contenu |
|---|---|
| Maintenant | Une tuile par mesure (radon, CO₂, COV, humidité, température, PM1, PM2,5, pression) : dernière valeur de chaque appareil, colorée selon les seuils (vert / jaune / rouge), avec sa mini-courbe |
| Évolution | Une courbe par mesure, plus la batterie ; les lignes pointillées sont les seuils |
| Santé de l'exporter | État de l'exporter (OK / Panne), temps depuis le dernier appel réussi, requêtes API restantes, âge de la dernière mesure par appareil, et les appels à l'API par résultat |
Les seuils des tuiles et des courbes sont ceux d'Airthings (radon 100 / 150 Bq/m³, CO₂ 800 / 1 000 ppm, COV 250 / 2 000 ppb, PM2,5 10 / 25 µg/m³, humidité bon entre 30 et 60 %), appliqués aussi aux PM1. La température est verte entre 19 et 25 °C et jaune en dehors ; la pression n'a pas de seuil. Les seuils se modifient dans chaque panneau (Standard options → Thresholds). À adapter à vos appareils : les seuils de la tuile « Âge des mesures » (jaune après 20 min, rouge après 1 h), qui dépendent de leur cadence d'envoi.
Site statique local
python airthings_site.py --from-exporter --open # lit l'exporter local, puis ouvre ./site/index.html
python airthings_site.py --open # sans exporter : appelle l'API Airthings directement
D'où viennent les valeurs. L'exporter garde en mémoire les dernières valeurs lues sur l'API et les sert telles quelles sur /metrics. Avec --from-exporter [URL] (défaut http://127.0.0.1:9876/metrics, hote:port suffit), le site les lit là : aucune requête à l'API, aucun identifiant nécessaire. Sans cette option, le script appelle l'API (3 requêtes : accounts, devices, sensors) avec les identifiants du .env, sur le même quota que l'exporter. L'exporter ne garde que la dernière valeur, pas d'historique : c'est le site qui construit le sien, à chaque exécution.
Le script écrit dans ./site :
| Fichier | Contenu |
|---|---|
index.html |
page autonome (données embarquées, aucun CDN, aucun serveur : un double-clic suffit) |
latest.json |
dernier relevé brut (avec --from-exporter : les mesures que l'exporter expose) |
history.jsonl |
historique, une ligne par relevé ; alimente les courbes |
Options : --out DOSSIER (défaut site), --history-days N (conservation de l'historique, 7 par défaut), --from-exporter [URL], --open. Le dossier de sortie est exclu par .gitignore.
Pour chaque appareil et chaque mesure, la page affiche la valeur, un statut pour les mesures qui ont des seuils (icône et libellé, jamais la couleur seule), la position sur la bande de seuils et une mini-courbe. Le bouton Agrandir ouvre un grand graphique : axes, bandes de seuils, min / max / moyenne, choix de la période, valeur au survol de la souris ou avec les flèches du clavier. Échap, le bouton ✕ ou un clic à côté le referment. Les mesures sont : radon, CO₂, COV, humidité, température, PM1, PM2,5, pression, et le niveau de batterie de l'appareil.
| Mesure | Seuils |
|---|---|
| Radon | moyen dès 100 Bq/m³, mauvais dès 150 |
| CO₂ | moyen dès 800 ppm, mauvais dès 1 000 |
| COV | moyen dès 250 ppb, mauvais dès 2 000 |
| PM2,5 et PM1 | moyen dès 10 µg/m³, mauvais dès 25 |
| Humidité | bon entre 30 et 60 %, moyen entre 25 et 30 % ou 60 et 70 %, mauvais sinon |
| Température | idéale entre 19 et 25 °C ; en dehors, « Trop froide » ou « Trop chaude » (niveau moyen) |
| Pression, batterie | pas de seuil |
Les seuils sont dans le tuple SHOWN en haut de airthings_site.py.
À savoir :
- Pas d'historique côté API : l'API Consumer ne renvoie que la dernière valeur, et l'exporter non plus. Les courbes se construisent donc à chaque exécution du script, avec une résolution égale à l'intervalle de planification. Si le script ne tourne pas pendant un moment, la courbe montre un trou, pas une ligne droite. L'historique complet à la minute est dans Prometheus / Grafana.
- Un relevé n'est ajouté que si Airthings a enregistré une nouvelle mesure (comparaison de
recorded) : lancer le script deux fois de suite ne crée pas de doublon. - Avec
--from-exporter, l'heure affichée et l'axe du temps sont celles du dernier appel réussi de l'exporter à l'API, pas celle de la génération de la page. Si ces données ont plus de 10 minutes, ou si le dernier appel de l'exporter a échoué, le script le signale sur la sortie d'erreur. - Si la source ne répond pas (exporter arrêté, API en erreur, limite de débit), la page existante n'est pas modifiée. Codes de sortie :
0succès,1source injoignable ou sans données,2limite de débit de l'API (mode API).
Déploiement avec systemd
Un service permanent pour l'exporter, un timer qui relance le site :
API Airthings ◀── 1 appel/min ── airthings-exporter.service ◀── scrape /metrics (60 s) ── Prometheus
(garde les dernières valeurs)
▲
│ lit /metrics, 0 requête API
airthings-site.timer ── toutes les 5 min ──▶ airthings-site.service ──▶ /var/lib/airthings-site/index.html
Seul l'exporter appelle l'API et connaît les identifiants. Le service du site n'a ni secret ni accès au réseau au-delà de la boucle locale. Les unités sont dans deploy/. Installation (en root, depuis le dossier du projet) :
useradd --system --home-dir /nonexistent --shell /usr/sbin/nologin airthings
install -d /opt/airthings-exporter
install -m 0644 airthings_exporter.py airthings_site.py requirements.txt /opt/airthings-exporter/
python3 -m venv /opt/airthings-exporter/.venv
/opt/airthings-exporter/.venv/bin/pip install -r /opt/airthings-exporter/requirements.txt
install -d -m 0700 /etc/airthings
install -m 0600 .env /etc/airthings/airthings.env # valeurs sans guillemets ni « export »
install -m 0644 deploy/airthings-exporter.service deploy/airthings-site.service deploy/airthings-site.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now airthings-exporter.service airthings-site.timer
Vérifications :
systemctl status airthings-exporter.service
curl -s localhost:9876/metrics | grep airthings_co2
systemctl list-timers airthings-site.timer
systemctl start airthings-site.service && journalctl -u airthings-site.service -n 5
Le site est écrit dans /var/lib/airthings-site/, lisible par tous : ouvrez index.html directement, ou servez le dossier avec un serveur web (python3 -m http.server -d /var/lib/airthings-site --bind 127.0.0.1 8080).
Réglages :
- Dans
airthings-exporter.service, décommentezEnvironment=LISTEN_ADDR=127.0.0.1si Prometheus tourne sur la même machine, hors conteneur :/metricsn'est alors pas exposé au réseau. Le site, qui lit127.0.0.1:9876, continue de fonctionner. - Exporter dans un conteneur ou sur une autre machine : changez l'URL après
--from-exporterdansairthings-site.serviceet retirez ses deux lignesIPAddress…. - Le timer peut descendre jusqu'à 1 minute sans coûter une seule requête à l'API ; plus il est court, plus l'historique du site est fin et plus la page est lourde (de l'ordre de 100 octets par relevé et par appareil, soit 400 ko pour deux appareils sur une semaine à un relevé toutes les 5 minutes).
- Pas d'exporter, juste le site ? Remplacez dans
airthings-site.service--from-exporter …par rien, ajoutezEnvironmentFile=/etc/airthings/airthings.envet retirez les deux lignesIPAddress…: chaque passage coûte alors 3 requêtes API.
Quota de l'API
L'API Consumer est limitée à 120 requêtes/heure. L'exporter consomme :
- 1 appel
/sensorspar cycle, qui renvoie tous les appareils du compte → 60/h à 1 mesure/minute ; - 1 appel
/devicespar heure (+/accountsau démarrage) ; - en cas de
429, il attend le délai indiqué par l'API (Retry-After/X-RateLimit-Reset) avant de réessayer.
Ne partagez pas les mêmes identifiants API entre l'exporter et une autre intégration (Home Assistant, Homebridge…) : le quota est compté par client. Le site statique ne coûte rien avec --from-exporter ; en mode API direct, chaque passage consomme 3 requêtes sur le même quota (toutes les 10 minutes : environ 79 requêtes par heure au total avec l'exporter ; à 3 minutes, on dépasse 120).
Points d'attention
- Fraîcheur réelle : l'API renvoie la dernière valeur connue du cloud Airthings. Un appel par minute ne garantit pas une nouvelle mesure par minute : la cadence dépend du modèle et de la connexion de l'appareil (les Wave ont besoin d'un SmartLink Hub, View Plus ou View Radon, pour pousser leurs mesures en continu). Utilisez
time() - airthings_last_recorded_timestamp_secondspour voir l'âge de la donnée. - Fuseau de
recorded: l'API renvoie un horodatage sans fuseau ; il est interprété en UTC. À confirmer sur vos données (comparer avec l'heure locale de la dernière mesure dans l'app Airthings). - Pagination : l'API pagine à 50 appareils ; seule la première page est lue (un avertissement est loggé si
hasNextest vrai). - Unités : l'exporter demande
unit=metricet logge un avertissement si une unité inattendue est renvoyée.
Requêtes utiles (Grafana / alertes)
# Âge de la dernière mesure par appareil (secondes)
time() - airthings_last_recorded_timestamp_seconds
# Alerte : l'exporter n'arrive plus à joindre l'API depuis 10 min
max_over_time(airthings_exporter_up[10m]) == 0
# Alerte : CO2 élevé pendant 15 min
avg_over_time(airthings_co2_ppm[15m]) > 1000
Tests
python -m unittest -v # exporter, configuration et site : fausse API locale, aucun accès réseau externe