docs: align with AirCarto 2026 JSON template + fix ISO mapping

formats/json-payload.md: full rewrite around the actual server-side template
  (endpoint api.aircarto.com/receive_data?device_type=<model>, flat schema,
  _unit suffix companions, -1 and 255 sentinel semantics, full bitfield
  tables for error_flags/npm_status/device_status, misc context codes).

formats/iso-pollutant-codes.md: fill in the LCSQA mapping. Fixes my earlier
  inversion — ISO_39=PM2.5 and ISO_24=PM10 (not the other way). Add gases:
  ISO_03=NO2, ISO_04=CO, ISO_05=H2S, ISO_08=O3, ISO_21=NH3.

parsers/udp-miotiq.md:
  - string base function outputs hex (not ASCII) — update description and
    generic Python parser accordingly.
  - Fix ISO_39/ISO_24 labels in NebuleAir Pro 4G byte layout.
  - Name the 5 gases by offset, cross-link bitfield docs and JSON canonical.
  - New TODO: origin of latitude/longitude/misc in final JSON (not in 83B
    descriptor).

README.md: reflect the new file layout and data flow summary.
This commit is contained in:
2026-04-23 00:55:25 +02:00
parent 96260120fc
commit efd1aa438a
4 changed files with 275 additions and 112 deletions

View File

@@ -40,6 +40,13 @@ aircarto-protocols/
## Pourquoi ce repo
Avant : chaque firmware AirCarto (NebuleAir, ModuleAir, MobileAir…) redéfinissait ses trames, ses topics et son format JSON dans son coin. Les parsers côté serveur (`data.mobileair.fr/udp_miotiq_*.php`, `gestion.aircarto.fr`) devaient suivre. Résultat : dérives silencieuses entre capteurs, bugs d'intégration.
Avant : chaque firmware AirCarto (NebuleAir, ModuleAir, MobileAir…) redéfinissait ses trames et son format JSON dans son coin. Les parsers côté serveur (`data.mobileair.fr/udp_miotiq_*.php`, `gestion.aircarto.fr`) devaient suivre. Résultat : dérives silencieuses entre capteurs, bugs d'intégration.
Ici on centralise la **spécification**. Le code de référence reste dans les repos des projets (firmwares, backends) ; ce repo décrit ce qui est attendu sur le fil.
Ici on centralise la **spécification** :
- **Capteur → Miotiq** : payload UDP binaire, décodé côté Miotiq via un *descripteur* ([`parsers/udp-miotiq.md`](parsers/udp-miotiq.md)).
- **Miotiq → serveur AirCarto** : JSON canonique 2026 ([`formats/json-payload.md`](formats/json-payload.md)) posté sur `api.aircarto.com/receive_data`.
- **Vocabulaire polluants** : codes ISO LCSQA ([`formats/iso-pollutant-codes.md`](formats/iso-pollutant-codes.md)).
- **Capteurs physiques** : docs individuelles sous `sensors/` (protocole UART/I2C, câblage, commandes).
Le code de référence reste dans les repos des projets (firmwares, backends) ; ce repo décrit ce qui est attendu sur le fil.

View File

@@ -1,37 +1,37 @@
# Codes polluants ISO — convention AirCarto
Les descripteurs Miotiq (voir [`parsers/udp-miotiq.md`](../parsers/udp-miotiq.md)) utilisent des codes `ISO_XX` pour désigner les grandeurs physiques et polluants. Ces codes sont inspirés de la norme **ISO 7168-2** (échange de données de qualité de l'air) et du vocabulaire EIONET / Directive européenne 2008/50/CE, mais le mapping exact **est la convention AirCarto** — à verrouiller ici.
Les descripteurs Miotiq (voir [`parsers/udp-miotiq.md`](../parsers/udp-miotiq.md)) et le JSON canonique (voir [`json-payload.md`](json-payload.md)) utilisent des codes `ISO_XX` pour désigner les polluants et grandeurs physiques. Ces codes suivent la nomenclature du **LCSQA** (Laboratoire Central de Surveillance de la Qualité de l'Air), basée sur la norme **ISO 7168**.
Les codes vont théoriquement de `ISO_01` à `ISO_99`. Seuls ceux effectivement mesurés par au moins un capteur AirCarto sont documentés ici.
## Mapping
| Code | Grandeur / polluant | Unité de référence | Scale standard | Remarques |
|----------|---------------------|--------------------|----------------|-----------|
| `ISO_03` | *à confirmer* (gaz) | ppb | | Candidat : O₃ (ozone) |
| `ISO_04` | *à confirmer* (gaz) | ppb | | Candidat : NO₂ |
| `ISO_05` | *à confirmer* (gaz) | ppb | | Candidat : NO ou SO₂ |
| `ISO_08` | *à confirmer* (gaz) | ppb | | Candidat : CO |
| `ISO_21` | *à confirmer* (gaz) | ppb | | |
| `ISO_24` | PM2.5 | µg/m³ | x/10 | Poussières fines |
| `ISO_39` | PM10 | µg/m³ | x/10 | |
| `ISO_53` | Pression | hPa | | Pression atmosphérique |
| `ISO_54` | Température | °C | x/100 | |
| `ISO_55` | Humidité relative | % | x/100 | |
| `ISO_68` | PM1 | µg/m³ | x/10 | |
| Code | Grandeur / polluant | Unité de référence | Equation descripteur Miotiq | Remarques |
|----------|---------------------|--------------------|------------------------------|------------------------------|
| `ISO_03` | NO₂ — dioxyde d'azote | ppb | | |
| `ISO_04` | CO — monoxyde de carbone | ppb | | |
| `ISO_05` | H₂S — sulfure d'hydrogène | ppb | | |
| `ISO_08` | O₃ — ozone | ppb | | |
| `ISO_21` | NH₃ — ammoniac | ppb | | |
| `ISO_24` | PM10 | µg/m³ | `x/10` | Particules ≤ 10 µm |
| `ISO_39` | PM2.5 | µg/m³ | `x/10` | Particules fines ≤ 2.5 µm |
| `ISO_53` | Pression | hPa | | Pression atmosphérique |
| `ISO_54` | Température | °C | `x/100` | |
| `ISO_55` | Humidité relative | % | `x/100` | |
| `ISO_68` | PM1 | µg/m³ | `x/10` | Particules ultrafines ≤ 1 µm |
## Règles
- Un code ISO est **unique** au sein de l'écosystème AirCarto : pas de redéfinition par capteur.
- Un nouveau polluant / nouvelle grandeur suit la numérotation ISO 7168-2 si elle existe ; sinon on alloue le prochain code libre au-dessus de 100 et on le documente ici.
- Unité de référence : celle stockée dans InfluxDB (donc celle après application de `scale`). Les firmwares peuvent encoder à une précision différente, le `scale` ramène à l'unité finale.
- Pour ajouter un polluant :
1. Chercher son code dans la nomenclature LCSQA / ISO 7168.
2. S'il existe, ajouter la ligne ici avec l'unité de référence et l'équation standard.
3. S'il n'existe pas, allouer le prochain code libre > 70 et documenter la décision en « À faire » ci-dessous.
- **Unité de référence** = unité stockée en base (après application de l'équation du descripteur). Les firmwares peuvent encoder à une précision différente, l'équation ramène à l'unité finale.
- Les grandeurs **non-polluantes** propres aux capteurs AirCarto (bruit, vent, batterie, solaire…) ne reçoivent **pas** de code ISO : elles gardent leur nom `snake_case` (`wind_speed`, `battery_voltage`, etc.). Seuls les polluants atmosphériques et paramètres ambiants standard utilisent `ISO_XX`.
## À faire
## Sources
- [ ] Remplir les gaz `ISO_03`, `ISO_04`, `ISO_05`, `ISO_08`, `ISO_21` — les 5 gaz du NebuleAir Pro 4G.
- [ ] Vérifier que le mapping ci-dessus correspond bien à la norme ISO 7168-2 version utilisée en interne chez AirCarto.
- [ ] Ajouter les codes pour les grandeurs qui n'existent pas encore en ISO : bruit (`noise_cur_leq`, etc.), vent, batterie, solaire — ou laisser en noms `snake_case` non-ISO comme aujourd'hui dans le descripteur.
## Références
- ISO 7168-2:1999 — Air quality — Exchange of data, Part 2: Condensed data format.
- EIONET Air Quality data flow — https://www.eionet.europa.eu/
- Directive 2008/50/CE — annexe listant les codes polluants européens.
- **LCSQA** — nomenclature polluants : https://www.lcsqa.org/
- **ISO 7168-2:1999** — Air quality — Exchange of data, Part 2: Condensed data format.
- **Directive 2008/50/CE** — codes polluants européens.

View File

@@ -1,83 +1,234 @@
# Format JSON canonique — mesures capteurs
# Format JSON canonique — mesures capteurs AirCarto (2026)
Format recommandé pour tout nouvel envoi de mesures d'un capteur AirCarto vers un backend (HTTP POST, MQTT publish, webhook…).
JSON envoyé par les capteurs AirCarto (ou par le webhook Miotiq en leur nom) au serveur central.
> Les chemins d'envoi existants (Miotiq binaire 17 octets, CSV) restent documentés dans [`parsers/udp-miotiq.md`](../parsers/udp-miotiq.md) pour compatibilité. **Ce JSON est la cible** pour les nouveaux développements.
## Endpoint
## Schéma
```
POST https://api.aircarto.com/receive_data?device_type=<modèle>
Content-Type: application/json
```
Le **type de capteur** est passé en query string. Modèles supportés :
| `device_type` | Description |
|------------------|--------------------------------------------|
| `NebuleAir` | Station fixe NebuleAir |
| `NebuleAir_Pro` | Station fixe NebuleAir Pro (4G) |
| `ModuleAir` | Module AirCarto |
| `ModuleAir_Pro` | Module AirCarto Pro (4G) |
| `MobileAir` | Capteur mobile |
## Règles générales
- **Tous les champs sont optionnels** sauf `device_id`. Un capteur n'envoie que les champs qu'il mesure.
- **Valeur sentinelle `-1`** : donnée non disponible ou capteur non renseigné (ex. pas de GPS fix, pas de pression).
- **Valeur sentinelle `255` (`0xFF`)** sur `error_flags`, `npm_status`, `device_status` uniquement : le firmware du capteur est antérieur à l'introduction du champ. À interpréter comme « non disponible », **pas** comme « toutes les erreurs actives ». Les nouveaux firmwares initialisent ces octets et envoient une valeur ≤ 254.
- **Champs `<nom>_unit`** : optionnels, ajoutés par Miotiq quand la colonne `units` du descripteur est remplie et que l'export JSON est `Y` (voir [`parsers/udp-miotiq.md`](../parsers/udp-miotiq.md)). Format : `"<valeur> <unité>"`. Le backend peut les ignorer, la valeur canonique est toujours le champ sans suffixe.
## Identification
| Champ | Type | Description |
|------------------|--------|--------------------------------------------------------------------------------------------------|
| `device_id` | string | Identifiant unique, **représentation hexadécimale** de 16 caractères (8 octets ASCII). Convertir hex → ASCII pour obtenir le numéro de série imprimé sur le boîtier. Ex. `"4430353234313938"``"D0524198"`. |
| `signal_quality` | int | Qualité du signal réseau (dB, souvent RSSI négatif). |
| `version` | int | Version du protocole de communication (pas la version firmware — voir `version_*` plus bas). |
## Polluants (codes ISO LCSQA)
Le mapping complet vit dans [`formats/iso-pollutant-codes.md`](iso-pollutant-codes.md). Liste rappel :
| Champ | Grandeur | Unité |
|-----------|--------------|--------|
| `ISO_68` | PM1 | µg/m³ |
| `ISO_39` | PM2.5 | µg/m³ |
| `ISO_24` | PM10 | µg/m³ |
| `ISO_54` | Température | °C |
| `ISO_55` | Humidité | % |
| `ISO_53` | Pression | hPa |
| `ISO_03` | NO₂ | ppb |
| `ISO_05` | H₂S | ppb |
| `ISO_21` | NH₃ | ppb |
| `ISO_04` | CO | ppb |
| `ISO_08` | O₃ | ppb |
Les codes ISO vont théoriquement de `ISO_01` à `ISO_99`. Seuls les polluants effectivement mesurés par le capteur sont présents dans le JSON.
## Bruit
| Champ | Unité | Description |
|-------------------|-------|-----------------------------------------------------|
| `noise_cur_leq` | dB | Niveau sonore équivalent continu (Leq) courant. |
| `noise_cur_level` | dB | Niveau sonore instantané courant. |
| `max_noise` | dB | Niveau sonore maximal sur la période. |
## Comptage particulaire NPM (Naneos Partector)
| Champ | Unité | Description |
|----------------|-------|--------------------------------------|
| `npm_ch1` | count | Comptage canal 1. |
| `npm_ch2` | count | Comptage canal 2. |
| `npm_ch3` | count | Comptage canal 3. |
| `npm_ch4` | count | Comptage canal 4. |
| `npm_ch5` | count | Comptage canal 5. |
| `npm_temp` | °C | Température interne du module NPM. |
| `npm_humidity` | % | Humidité interne du module NPM. |
| `npm_status` | int | Statut NPM — bitfield, voir ci-dessous. |
## Alimentation
| Champ | Unité | Description |
|-------------------|-------|------------------------------------|
| `battery_voltage` | V | Tension batterie. |
| `battery_current` | A | Courant batterie. |
| `solar_voltage` | V | Tension panneau solaire. |
| `solar_power` | W | Puissance panneau solaire. |
| `charger_status` | int | Code de statut du chargeur MPPT. |
## Vent
| Champ | Unité | Description |
|------------------|---------|-----------------------------------|
| `wind_speed` | m/s | Vitesse du vent. |
| `wind_direction` | degrés | Direction du vent, 0360 (0 = Nord). |
## Diagnostic & firmware
### `error_flags` — bitfield système (1 octet)
Erreurs matérielles détectées par le capteur. `255` (0xFF) = firmware ancien, champ non supporté.
| Bit | Masque | Nom | Signification |
|-----|--------|---------------------|--------------------------------------------------------------|
| 0 | 0x01 | `RTC_DISCONNECTED` | Module RTC DS3231 non détecté (I2C). |
| 1 | 0x02 | `RTC_RESET` | RTC en date par défaut (année 2000). |
| 2 | 0x04 | `BME280_ERROR` | Capteur BME280 non détecté ou erreur. |
| 3 | 0x08 | `NPM_ERROR` | Capteur NextPM non détecté ou erreur. |
| 4 | 0x10 | `ENVEA_ERROR` | Capteurs Envea non détectés ou erreur. |
| 5 | 0x20 | `NOISE_ERROR` | Capteur bruit NSRT MK4 non détecté. |
| 6 | 0x40 | `MPPT_ERROR` | Chargeur solaire MPPT non détecté. |
| 7 | 0x80 | `WIND_ERROR` / `CO2_ERROR` | NebuleAir : vent non détecté. ModuleAir : CO₂ non détecté. |
Exemple : `error_flags = 5` → RTC déconnecté + BME280 en erreur.
### `npm_status` — bitfield NextPM (1 octet)
Registre d'état interne du capteur NextPM. Copie du byte `STATE` de la trame UART NextPM (voir [`sensors/nextpm.md`](../sensors/nextpm.md)). `255` = firmware ancien.
| Bit | Masque | Nom | Signification |
|-----|--------|------------------|------------------------------------------------------|
| 0 | 0x01 | `SLEEP_STATE` | Capteur en veille. |
| 1 | 0x02 | `DEGRADED_STATE` | Erreur mineure, précision réduite. |
| 2 | 0x04 | `NOT_READY` | Démarrage en cours (~15 s). |
| 3 | 0x08 | `HEAT_ERROR` | Humidité > 60 % pendant > 10 min. |
| 4 | 0x10 | `TRH_ERROR` | T/HR interne hors spécification. |
| 5 | 0x20 | `FAN_ERROR` | Ventilateur hors plage. |
| 6 | 0x40 | `MEMORY_ERROR` | Accès mémoire impossible. |
| 7 | 0x80 | `LASER_ERROR` | Aucune particule > 240 s, erreur laser. |
Exemple : `npm_status = 40``HEAT_ERROR` + `FAN_ERROR`.
### `device_status` — bitfield boîtier (1 octet)
État général du boîtier capteur. `255` = firmware ancien.
| Bit | Masque | Nom | Signification |
|-----|--------|------------------|--------------------------------------------------------------------|
| 0 | 0x01 | `SARA_REBOOTED` | Modem SARA a rebooté (hardware) au cycle précédent. |
| 1 | 0x02 | `WIFI_CONNECTED` | Device connecté en WiFi (mode atelier). |
| 2 | 0x04 | `HOTSPOT_ACTIVE` | Hotspot WiFi actif (mode configuration). |
| 3 | 0x08 | `GPS_NO_FIX` | Pas de position GPS valide. |
| 4 | 0x10 | `BATTERY_LOW` | Tension batterie sous seuil critique. |
| 5 | 0x20 | `DISK_FULL` | Espace disque critique (< 5 %). |
| 6 | 0x40 | `DB_ERROR` | Erreur d'accès à la base SQLite locale. |
| 7 | 0x80 | `BOOT_RECENT` | Device redémarré récemment (uptime < 5 min). |
Exemple : `device_status = 145` (0x91) → modem reboot + batterie faible + boot récent.
### Version firmware
| Champ | Type | Description |
|------------------|------|-----------------------------------------------|
| `version_major` | int | Numéro majeur (`X.y.z`). |
| `version_minor` | int | Numéro mineur (`x.Y.z`). |
| `version_patch` | int | Numéro de patch (`x.y.Z`). |
Reconstitution : `f"{version_major}.{version_minor}.{version_patch}"` → ex. `"1.2.3"`.
## Géolocalisation & contexte
| Champ | Type | Unité | Description |
|-------------|--------|---------|-------------------------------------------------------------------|
| `latitude` | number | degrés | Latitude GPS WGS84 (décimal). |
| `longitude` | number | degrés | Longitude GPS WGS84 (décimal). |
| `misc` | int | | Contexte de mesure, voir table ci-dessous. |
| `misc` | Contexte |
|--------|-----------------------------------------|
| 0 | Aucun |
| 1 | Mesure en intérieur |
| 2 | Mesure en extérieur |
| 3 | Mesure en voiture |
| 4 | Mesure en piéton |
| 5 | Mesure en vélo |
| 6 | Mesure en transport en commun |
## Exemple complet (NebuleAir_Pro)
```json
{
"token": "string",
"ts": 1713830400,
"type_conn": "LTE-M",
"measurements": {
"pm1": 0.0,
"pm25": 0.0,
"pm10": 0.0,
"temperature": 0.0,
"humidity": 0.0
},
"gps": {
"lat": 43.605,
"lon": 1.444,
"sats": 8
},
"link": {
"signal": -78,
"imsi": "208xxxxxxxxxxx"
},
"fw": "1.4.2"
"device_id": "4430353234313938",
"signal_quality": -22,
"signal_quality_unit": "-22 dB",
"version": 1,
"ISO_68": 0.8, "ISO_68_unit": "0,8 ugm3",
"ISO_54": 25.5, "ISO_54_unit": "25.5 °C",
"noise_cur_leq": 25.5, "noise_cur_leq_unit": "25,5 dB",
"noise_cur_level": 25.5, "noise_cur_unit": "25,5 dB",
"max_noise": 25.5, "max_noise_unit": "25,5 dB",
"npm_ch1": 255, "npm_ch1_unit": "255 nb",
"npm_ch2": 255, "npm_ch2_unit": "255 nb",
"npm_ch3": 255, "npm_ch3_unit": "255 nb",
"npm_ch4": 255, "npm_ch4_unit": "255 nb",
"npm_ch5": 255, "npm_ch5_unit": "255 nb",
"battery_voltage": 25.5, "battery_voltage_unit": "25,5 V",
"battery_current": 25.5, "battery_current_unit": "25,5 A",
"solar_voltage": 25.5, "solar_voltage_unit": "25,5 V",
"solar_power": 255, "solar_power_unit": "255 W",
"npm_temp": 25.5, "npm_temp_unit": "25,5 °C",
"npm_humidity": 25.5, "npm_humidity_unit": "25,5 %",
"wind_speed": 25.5, "wind_speed_unit": "25,5 m/s",
"wind_direction": 255, "wind_direction_unit": "255 degrees",
"charger_status": 255, "charger_status_unit": "255",
"error_flags": 0,
"npm_status": 0,
"device_status": 0,
"version_major": 1,
"version_minor": 2,
"version_patch": 3,
"latitude": 43.2964,
"longitude": 5.36978,
"misc": 2
}
```
## Champs
## Notes d'intégration
| Champ | Type | Obligatoire | Description |
|-------------------------|---------|-------------|--------------------------------------------------------------------|
| `token` | string | oui | Identifiant unique du capteur (= clé en base `capteurs.capteurs`). |
| `ts` | integer | oui | Timestamp Unix UTC en secondes de la mesure (pas de la réception). |
| `type_conn` | string | oui | Un des : `WiFi`, `LTE-M`, `LTE-BIN`, `NB-IoT`, `LoRa`, `Ethernet`. |
| `measurements.pm1` | number | si mesuré | µg/m³ |
| `measurements.pm25` | number | si mesuré | µg/m³ |
| `measurements.pm10` | number | si mesuré | µg/m³ |
| `measurements.temperature` | number | si mesuré | °C |
| `measurements.humidity` | number | si mesuré | % HR |
| `gps.lat` | number | si GPS | Degrés décimaux WGS84, 6 décimales. |
| `gps.lon` | number | si GPS | Idem. |
| `gps.sats` | integer | si GPS | Nombre de satellites vus. |
| `link.signal` | integer | non | RSSI en dBm (négatif) **ou** % de qualité ; préciser dans `type_conn`. |
| `link.imsi` | string | non | IMSI de la SIM (modem cellulaire uniquement). |
| `fw` | string | non | Version firmware du capteur, ex. `1.4.2`. |
### Côté capteur / firmware
## Règles
- Envoyer uniquement les champs mesurés par ton modèle. Omettre les autres, ou les remplir à `-1` si leur présence est structurellement attendue par un parser amont (ex. descripteur Miotiq à taille fixe).
- Initialiser `error_flags`, `npm_status`, `device_status` à `0`. Les laisser à `0xFF` uniquement si tu ne sais pas renseigner (= firmware non migré), pour que le backend interprète bien « non disponible ».
- Omettre un champ si non mesuré. Ne pas envoyer `null` ni `-1` comme valeur sentinelle (hérité du legacy CSV Miotiq).
- `ts` côté capteur si dispo (GPS / NTP), sinon côté serveur à la réception — documenter au cas par cas dans la doc du capteur.
- Toutes les valeurs numériques utilisent le point `.` comme séparateur décimal.
- Un seul capteur par message. Pas de batch (à revoir si besoin).
### Côté backend
## Réponse attendue du serveur
- Ignorer toute valeur `-1` (ne pas la stocker comme une mesure).
- Ignorer `error_flags`, `npm_status`, `device_status` si `== 255` — c'est un firmware ancien, l'absence de diagnostic n'est pas une alarme.
- Les `_unit` sont purement informatifs / de debug. La valeur métier est toujours le champ sans suffixe.
- `device_id` est **hex** dans le JSON ; convertir en ASCII (`bytes.fromhex(v).decode('ascii')`) pour afficher le numéro de série lisible.
```json
{ "ok": true }
```
## Historique
Ou en cas d'erreur :
```json
{ "ok": false, "error": "token inconnu" }
```
HTTP 200 dans les deux cas pour ne pas déclencher de retry cellulaire côté modem ; le `ok: false` indique à la supervision qu'il y a un problème applicatif.
## Exemple minimal
```json
{
"token": "001",
"ts": 1713830400,
"type_conn": "WiFi",
"measurements": { "pm25": 12.3 }
}
```
| Date | Révision | Changement |
|------------|----------|---------------------------------------------------------------------------|
| 2026-04-23 | v1 | Version initiale inventée (schéma imbriqué avec `token`/`ts`/`measurements`) — remplacée. |
| 2026-04-23 | v2 | **Format officiel AirCarto 2026** : schéma plat, bitfields détaillés, compat firmware ancien. |

View File

@@ -5,10 +5,10 @@
Chemin de données :
```
Capteur ──UDP──> Miotiq ──HTTPS POST JSON──> data.*.aircarto.fr/udp_miotiq_*.php ──> PostgreSQL + InfluxDB
Capteur ──UDP──> Miotiq ──HTTPS POST JSON──> api.aircarto.com/receive_data ──> PostgreSQL + InfluxDB
```
**Principe** : chaque capteur a un **descripteur Miotiq** (format texte pipe-séparé) qui décrit l'ordonnancement et le décodage de sa charge utile. Miotiq applique ce descripteur à l'ingestion, et c'est aussi lui qui sert de **contrat** pour le firmware capteur et le parser serveur.
**Principe** : chaque capteur a un **descripteur Miotiq** (format texte pipe-séparé) qui décrit l'ordonnancement et le décodage de sa charge utile. Miotiq applique ce descripteur à l'ingestion, et produit le **JSON canonique AirCarto** (voir [`formats/json-payload.md`](../formats/json-payload.md)) qui est posté sur le backend.
## Enveloppe JSON reçue du webhook
@@ -53,7 +53,7 @@ Un descripteur est une suite de lignes, une par champ. **Format officiel Miotiq*
| Valeur | Effet |
|------------|-------------------------------------------------------------------------------------------------|
| `string` | Décode les octets comme ASCII (non null-terminé, padé à droite). |
| `string` | Sort la **représentation hexadécimale** des octets tels quels (n'effectue *pas* de décodage ASCII). Ex. `device_id` 8 octets `0x44 30 35 32 34 31 39 38` sort `"4430353234313938"` ; le client convertit en ASCII si besoin (`bytes.fromhex(v).decode("ascii")``"D0524198"`). |
| `hex2dec` | Convertit les octets en entier non-signé **big-endian**. |
| `hex2bin` | Convertit les octets en chaîne binaire (bitfield lisible, utile pour les registres d'état). |
| `userdef` | Pas de transformation — les octets bruts sont passés tels quels comme valeur. |
@@ -124,23 +124,23 @@ Layout octet par octet :
| Offset | Taille | Champ | Unité | Scale | Notes |
|--------|--------|-------------------|---------|--------|------------------------------------------|
| 0 | 8 | `device_id` | | | ASCII, identifiant capteur |
| 0 | 8 | `device_id` | | | Hex 16 chars, converti en ASCII côté client (ex. `"D0524198"`) |
| 8 | 1 | `signal_quality` | dB | | Signal cellulaire |
| 9 | 1 | `version` | | | Version encodée (complète en v_major/minor/patch plus bas) |
| 9 | 1 | `version` | | | Version du protocole de communication |
| 10 | 2 | `ISO_68` | µg/m³ | /10 | PM1 |
| 12 | 2 | `ISO_39` | µg/m³ | /10 | PM10 |
| 14 | 2 | `ISO_24` | µg/m³ | /10 | PM2.5 |
| 12 | 2 | `ISO_39` | µg/m³ | /10 | PM2.5 |
| 14 | 2 | `ISO_24` | µg/m³ | /10 | PM10 |
| 16 | 2 | `ISO_54` | °C | /100 | Température |
| 18 | 2 | `ISO_55` | % HR | /100 | Humidité |
| 20 | 2 | `ISO_53` | hPa | | Pression |
| 22 | 2 | `noise_cur_leq` | dB | /10 | Leq instantané |
| 24 | 2 | `noise_cur_level` | dB | /10 | Niveau sonore courant |
| 26 | 2 | `max_noise` | dB | /10 | Crête |
| 28 | 2 | `ISO_03` | ppb | | Gaz — voir `formats/iso-pollutant-codes.md` |
| 30 | 2 | `ISO_05` | ppb | | Gaz |
| 32 | 2 | `ISO_21` | ppb | | Gaz |
| 34 | 2 | `ISO_04` | ppb | | Gaz |
| 36 | 2 | `ISO_08` | ppb | | Gaz |
| 28 | 2 | `ISO_03` | ppb | | NO₂ |
| 30 | 2 | `ISO_05` | ppb | | H₂S |
| 32 | 2 | `ISO_21` | ppb | | NH₃ |
| 34 | 2 | `ISO_04` | ppb | | CO |
| 36 | 2 | `ISO_08` | ppb | | O₃ |
| 38 | 2 | `npm_ch1` | count | | NextPM — nombre de particules canal 1 |
| 40 | 2 | `npm_ch2` | count | | NextPM canal 2 |
| 42 | 2 | `npm_ch3` | count | | NextPM canal 3 |
@@ -155,15 +155,17 @@ Layout octet par octet :
| 60 | 2 | `charger_status` | | | Bitfield, cf. firmware |
| 62 | 2 | `wind_speed` | m/s | /10 | |
| 64 | 2 | `wind_direction` | degrés | | 0359, 0 = Nord |
| 66 | 1 | `error_flags` | | | Bitfield d'erreur, cf. firmware |
| 67 | 1 | `npm_status` | | | Copie du `STATE` NextPM (voir [sensors/nextpm.md](../sensors/nextpm.md)) |
| 68 | 1 | `device_status` | | | Bitfield global |
| 66 | 1 | `error_flags` | | | Bitfield erreurs système, détail dans [`formats/json-payload.md`](../formats/json-payload.md#error_flags--bitfield-système-1-octet). `0xFF` = firmware ancien. |
| 67 | 1 | `npm_status` | | | Bitfield statut NextPM (copie du `STATE` UART, voir [`sensors/nextpm.md`](../sensors/nextpm.md) et [`json-payload.md`](../formats/json-payload.md#npm_status--bitfield-nextpm-1-octet)). `0xFF` = firmware ancien. |
| 68 | 1 | `device_status` | | | Bitfield état boîtier, détail dans [`formats/json-payload.md`](../formats/json-payload.md#device_status--bitfield-boîtier-1-octet). `0xFF` = firmware ancien. |
| 69 | 1 | `version_major` | | | Version firmware `X.y.z` |
| 70 | 1 | `version_minor` | | | `x.Y.z` |
| 71 | 1 | `version_patch` | | | `x.y.Z` |
| 72 | 11 | `reserved` | | | À ignorer (évolution future du descripteur) |
| **83** | total | | | | |
> Les champs `latitude`, `longitude`, `misc` présents dans le JSON final (voir [`json-payload.md`](../formats/json-payload.md#géolocalisation--contexte)) **ne sont pas** dans ce descripteur 83B — à documenter : ajoutés par Miotiq depuis les métadonnées device, ou transmis via un autre canal.
### MobileAir (17 octets — legacy, pré-descripteur)
> Ce capteur envoie encore un format binaire packé **sans descripteur Miotiq formel**. Migration prévue vers la même approche descripteur que NebuleAir Pro 4G.
@@ -236,7 +238,8 @@ def decode(payload: bytes, fields: list[Field], emit_units: bool = True) -> dict
raise ValueError(f"payload trop court au champ {f.name}")
if f.fn == "string":
val = chunk.decode("ascii", errors="replace").rstrip("\x00 ")
# Miotiq: garde la représentation hex des octets, pas de décodage ASCII.
val = chunk.hex()
elif f.fn == "hex2dec":
val = _apply_equation(int.from_bytes(chunk, "big", signed=False), f.equation)
elif f.fn == "hex2bin":
@@ -283,6 +286,7 @@ Côté PHP (cf. implémentations existantes `udp_miotiq_byte.php` / `udp_miotiq_
- [ ] Confirmer l'endianness des champs multi-octets (big-endian supposé).
- [ ] Confirmer le caractère signé/non-signé de `battery_current` (décharge = négatif ?).
- [ ] D'où viennent `latitude` / `longitude` / `misc` dans le JSON final ? (pas dans le descripteur 83B ; métadonnées Miotiq ? trame séparée ?)
- [ ] Migrer MobileAir du format binaire 17B vers un descripteur Miotiq formel.
- [ ] Ajouter un descripteur ModuleAir Pro 4G quand dispo.
@@ -293,3 +297,4 @@ Côté PHP (cf. implémentations existantes `udp_miotiq_byte.php` / `udp_miotiq_
| 2026-04-23 | v1 | Création — MobileAir 17B + CSV à partir des parsers PHP en prod. |
| 2026-04-23 | v2 | Refonte autour du format descripteur Miotiq, ajout NebuleAir Pro 4G (83B). |
| 2026-04-23 | v3 | Format descripteur aligné sur doc officielle Miotiq : 6e colonne = export JSON (W/Y/N), ajout base functions `hex2bin` et `userdef`, colonne `equation` (expression en x). |
| 2026-04-23 | v4 | Correction : `string` produit du hex (pas ASCII). Correction ISO_39=PM2.5 et ISO_24=PM10 (inversion). Gaz confirmés (NO₂/CO/H₂S/NH₃/O₃). Lien vers JSON canonique AirCarto 2026. |