Exporter prometheus airthings utilisant l'API consumer
  • Python 98.3%
  • Dockerfile 1.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
solene 73bd1929cd
All checks were successful
image / publier (push) Successful in 27s
Merge pull request 'renovate: update dependances' (#11) from renovate/dependances into main
Reviewed-on: #11
2026-11-07 10:42:30 +00:00
.forgejo/workflows renovate: update dependances 2026-11-07 04:01:27 +00:00
deploy Units systemd pour le site statique 2026-10-14 19:58:19 +02:00
grafana Grafana dashboard 2026-10-14 19:58:04 +02:00
AGENTS.md ci: images de base Chainguard signées et vérifiées, dépendances épinglées avec hashes 2026-10-31 15:45:24 +00:00
airthings_exporter.py Airthings exporter 2026-10-14 18:53:55 +02:00
airthings_site.py Airthings exporter 2026-10-14 18:53:55 +02:00
Containerfile renovate: update dependances 2026-11-07 04:01:27 +00:00
LICENSE Ajout licence MIT 2026-10-14 19:59:47 +02:00
README.md ci: images de base Chainguard signées et vérifiées, dépendances épinglées avec hashes 2026-10-31 15:45:24 +00:00
renovate.json5 ci: images de base Chainguard signées et vérifiées, dépendances épinglées avec hashes 2026-10-31 15:45:24 +00:00
requirements.txt ci: images de base Chainguard signées et vérifiées, dépendances épinglées avec hashes 2026-10-31 15:45:24 +00:00
test_config.py Tests unitaires 2026-10-14 18:54:30 +02:00
test_exporter.py Tests unitaires 2026-10-14 18:54:30 +02:00
test_site.py Tests unitaires 2026-10-14 18:54:30 +02:00

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 /metrics pour 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 : 0 succès, 1 source injoignable ou sans données, 2 limite 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écommentez Environment=LISTEN_ADDR=127.0.0.1 si Prometheus tourne sur la même machine, hors conteneur : /metrics n'est alors pas exposé au réseau. Le site, qui lit 127.0.0.1:9876, continue de fonctionner.
  • Exporter dans un conteneur ou sur une autre machine : changez l'URL après --from-exporter dans airthings-site.service et retirez ses deux lignes IPAddress….
  • 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, ajoutez EnvironmentFile=/etc/airthings/airthings.env et retirez les deux lignes IPAddress… : 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 /sensors par cycle, qui renvoie tous les appareils du compte → 60/h à 1 mesure/minute ;
  • 1 appel /devices par heure (+ /accounts au 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_seconds pour 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 hasNext est vrai).
  • Unités : l'exporter demande unit=metric et 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