From 7f8d6a21e95d2d314cbdad8f97d07da72b06d63b Mon Sep 17 00:00:00 2001 From: Paul Vuarambon Date: Thu, 23 Apr 2026 00:46:35 +0200 Subject: [PATCH] docs(miotiq): Miotiq descriptor format as canonical parser + NebuleAir Pro 4G 83B descriptor - Refactor parsers/udp-miotiq.md around the pipe-separated descriptor format used on Miotiq side. - Document the full 83-byte NebuleAir Pro 4G descriptor (PM, gases, noise, weather, power, status). - Keep legacy MobileAir 17B binary format for reference. - Add formats/iso-pollutant-codes.md placeholder for AirCarto ISO 7168 code mapping. - Open TODOs: flag W semantics, endianness, signed battery_current, MobileAir migration. --- README.md | 7 +- formats/iso-pollutant-codes.md | 37 +++++ parsers/udp-miotiq.md | 296 +++++++++++++++++++++------------ 3 files changed, 234 insertions(+), 106 deletions(-) create mode 100644 formats/iso-pollutant-codes.md diff --git a/README.md b/README.md index 552d4f1..cf6b990 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ aircarto-protocols/ ├── CONVENTIONS.md Nommage, versioning, style doc ├── formats/ Formats d'échange de données │ ├── json-payload.md Format JSON canonique des mesures +│ ├── iso-pollutant-codes.md Mapping ISO_XX → polluant / grandeur │ └── mqtt.md Topics et conventions MQTT ├── sensors/ Un fichier par capteur │ ├── _TEMPLATE.md Gabarit à copier pour tout nouveau capteur @@ -27,9 +28,9 @@ aircarto-protocols/ ## Index des parsers -| Nom | Transport | Doc | État | -|-----------------|------------------|-----------------------------------------------|---------| -| UDP Miotiq | UDP → HTTPS JSON | [parsers/udp-miotiq.md](parsers/udp-miotiq.md) | Complet | +| Nom | Transport | Doc | État | +|-----------------|------------------|-----------------------------------------------|-------------------------------| +| UDP Miotiq | UDP → HTTPS JSON | [parsers/udp-miotiq.md](parsers/udp-miotiq.md) | Descripteur NebuleAir Pro 4G + legacy MobileAir | ## Comment ajouter une entrée diff --git a/formats/iso-pollutant-codes.md b/formats/iso-pollutant-codes.md new file mode 100644 index 0000000..51133b8 --- /dev/null +++ b/formats/iso-pollutant-codes.md @@ -0,0 +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. + +## 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 | | + +## 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. + +## À faire + +- [ ] 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. diff --git a/parsers/udp-miotiq.md b/parsers/udp-miotiq.md index 82edcad..b6ed9d1 100644 --- a/parsers/udp-miotiq.md +++ b/parsers/udp-miotiq.md @@ -1,13 +1,15 @@ # Parser UDP Miotiq -[Miotiq](https://app.miotiq.com/) est la plateforme IoT cellulaire utilisée par AirCarto pour la connectivité LTE-M / NB-IoT (MobileAir, NebuleAir Pro 4G). Les capteurs envoient des **datagrammes UDP** vers un endpoint Miotiq ; Miotiq forwarde ces datagrammes à un webhook HTTPS AirCarto sous forme de **POST JSON**. +[Miotiq](https://app.miotiq.com/) est la plateforme IoT cellulaire utilisée par AirCarto pour la connectivité LTE-M / NB-IoT (NebuleAir Pro 4G, MobileAir, ModuleAir Pro 4G). Les capteurs envoient des **datagrammes UDP** vers un endpoint Miotiq ; Miotiq forwarde ces datagrammes à un webhook HTTPS AirCarto sous forme de **POST JSON**. Chemin de données : ``` -Capteur ──UDP──> Miotiq ──HTTPS POST JSON──> data.mobileair.fr/udp_miotiq_*.php ──> PostgreSQL + InfluxDB +Capteur ──UDP──> Miotiq ──HTTPS POST JSON──> data.*.aircarto.fr/udp_miotiq_*.php ──> 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. + ## Enveloppe JSON reçue du webhook Le corps `application/json` reçu par le script PHP contient : @@ -24,144 +26,232 @@ Le corps `application/json` reçu par le script PHP contient : | Champ | Type | Description | |--------------|---------|--------------------------------------------------------------------| -| `payload` | string | **Base64** des octets UDP envoyés par le capteur (charge utile brute). | +| `payload` | string | **Base64** des octets UDP envoyés par le capteur. | | `customerId` | string | Identifiant client Miotiq. | | `rcvTime` | integer | Timestamp Unix UTC de la réception Miotiq, en secondes. | | `srcIP` | string | IP source du modem cellulaire (côté opérateur). | | `srcImsi` | string | IMSI de la SIM, sert à rattacher la mesure à un capteur en DB. | -## Formats de payload interne +## Format descripteur Miotiq -Après décodage base64, le contenu est soit **binaire**, soit **CSV**, selon le firmware du capteur. +Un descripteur est une suite de lignes, une par champ, au format : -### Format binaire (17 octets) — MobileAir - -Format packé big-endian, 17 octets. Parser de référence : [`data.mobileair.fr/udp_miotiq_byte.php`](https://gitea.aircarto.fr/PaulVua) (endpoint `/udp_miotiq_byte.php`). - -| Offset | Taille | Champ | Type | Décodage | -|--------|--------|------------------|--------|---------------------------------| -| 0 | 1 | `device_id` | uint8 | `str(val).zfill(3)` → token `"001"` | -| 1 | 2 | `pm1_x10` | uint16 BE | `raw / 10.0` → µg/m³ | -| 3 | 2 | `pm25_x10` | uint16 BE | `raw / 10.0` → µg/m³ | -| 5 | 2 | `pm10_x10` | uint16 BE | `raw / 10.0` → µg/m³ | -| 7 | 2 | `lat_x10000` | uint16 BE | `raw / 10000.0` → degrés (0 si pas de fix) | -| 9 | 2 | `lon_x10000` | uint16 BE | `raw / 10000.0` → degrés (0 si pas de fix) | -| 11 | 1 | `num_sats` | uint8 | nombre de satellites | -| 12 | 1 | `signal_quality` | uint8 | % qualité modem | -| 13 | 1 | `moving_type` | uint8 | énumération déplacement | - -Format C sur capteur (pseudo, big-endian packé) : - -```c -struct __attribute__((packed)) mobileair_udp_t { - uint8_t device_id; - uint16_t pm1_x10; // htons avant envoi - uint16_t pm25_x10; - uint16_t pm10_x10; - uint16_t lat_x10000; - uint16_t lon_x10000; - uint8_t num_sats; - uint8_t signal_quality; - uint8_t moving_type; -}; // sizeof = 14 — attention : le format sur le fil fait 17 octets +``` +||||| ``` -> Un ancien format **15 octets** existe (sans `lat`/`lon`/`moving_type`) — le parser PHP le gère en fallback. Ne plus l'utiliser pour un nouveau firmware. +| Colonne | Description | +|----------------|------------------------------------------------------------------------------------------| +| `nb_chars_hex` | Taille du champ en **caractères hexadécimaux** (2 chars = 1 octet). | +| `nom` | Identifiant logique du champ. Les codes polluants suivent [ISO 7168 AirCarto](../formats/iso-pollutant-codes.md). | +| `decodeur` | `string` (ASCII), `hex2dec` (entier non-signé big-endian), `skip` (ignorer N octets). | +| `unite` | Unité physique finale (`ugm3`, `degC`, `%`, `hPa`, `ppb`, `dB`, `V`, `A`, `W`, `m/s`, `degrees`, `count`). Peut être vide pour les champs d'état. | +| `scale` | Facteur à appliquer après `hex2dec`. `x/10`, `x/100`… Vide = pas de division. | +| `flags` | Optionnel. `W` observé en fin de certaines lignes — **signification à confirmer** (hypothèse : champ writable / configurable via downlink Miotiq). | -Python équivalent pour lire / écrire : +**Conventions implicites** : +- Les champs `hex2dec` multi-octets sont **big-endian** (à vérifier au cas par cas avec Miotiq si un doute apparaît). +- Les champs `string` sont ASCII, non null-terminés, padés à droite. +- L'ordre des lignes du descripteur est l'ordre des octets sur le fil : pas de séparateur, pas d'alignement. +- La taille totale du payload = somme des `nb_chars_hex / 2`. Toute trame de taille différente doit être rejetée. -```python -import struct, base64 +## Descripteurs actuels -FMT = ">B HHHHH BBB" # 17 octets +### NebuleAir Pro 4G (83 octets = 166 chars hex) -def pack(device_id, pm1, pm25, pm10, lat, lon, sats, sig, moving): - return struct.pack(FMT, device_id, - int(pm1*10), int(pm25*10), int(pm10*10), - int(lat*10000), int(lon*10000), - sats, sig, moving) +Descripteur de référence : -def unpack(data: bytes): - if len(data) != 17: - raise ValueError(f"payload {len(data)} octets, attendu 17") - dev, pm1, pm25, pm10, lat, lon, sats, sig, moving = struct.unpack(FMT, data) - return { - "device_id": f"{dev:03d}", - "pm1": pm1 / 10.0, - "pm25": pm25 / 10.0, - "pm10": pm10 / 10.0, - "lat": lat / 10000.0, - "lon": lon / 10000.0, - "sats": sats, - "signal": sig, - "moving_type": moving, - } - -# Côté webhook -body = {"payload": base64.b64encode(pack(1, 12.3, 18.5, 22.1, 43.605, 1.444, 8, 80, 1)).decode(), - "customerId": "aircarto", "rcvTime": 1713830400, - "srcIP": "10.0.0.1", "srcImsi": "208010000000001"} +``` +16|device_id|string|||W +2|signal_quality|hex2dec|dB|| +2|version|hex2dec|||W +4|ISO_68|hex2dec|ugm3|x/10| +4|ISO_39|hex2dec|ugm3|x/10| +4|ISO_24|hex2dec|ugm3|x/10| +4|ISO_54|hex2dec|degC|x/100| +4|ISO_55|hex2dec|%|x/100| +4|ISO_53|hex2dec|hPa|| +4|noise_cur_leq|hex2dec|dB|x/10| +4|noise_cur_level|hex2dec|dB|x/10| +4|max_noise|hex2dec|dB|x/10| +4|ISO_03|hex2dec|ppb|| +4|ISO_05|hex2dec|ppb|| +4|ISO_21|hex2dec|ppb|| +4|ISO_04|hex2dec|ppb|| +4|ISO_08|hex2dec|ppb|| +4|npm_ch1|hex2dec|count|| +4|npm_ch2|hex2dec|count|| +4|npm_ch3|hex2dec|count|| +4|npm_ch4|hex2dec|count|| +4|npm_ch5|hex2dec|count|| +4|npm_temp|hex2dec|°C|x/10| +4|npm_humidity|hex2dec|%|x/10| +4|battery_voltage|hex2dec|V|x/100| +4|battery_current|hex2dec|A|x/100| +4|solar_voltage|hex2dec|V|x/100| +4|solar_power|hex2dec|W|| +4|charger_status|hex2dec||| +4|wind_speed|hex2dec|m/s|x/10| +4|wind_direction|hex2dec|degrees|| +2|error_flags|hex2dec||| +2|npm_status|hex2dec||| +2|device_status|hex2dec||| +2|version_major|hex2dec||| +2|version_minor|hex2dec||| +2|version_patch|hex2dec||| +22|reserved|skip||| ``` -### Format CSV — MobileAir (legacy) +Layout octet par octet : -Parser de référence : endpoint `/udp_miotiq_csv.php`. +| Offset | Taille | Champ | Unité | Scale | Notes | +|--------|--------|-------------------|---------|--------|------------------------------------------| +| 0 | 8 | `device_id` | | | ASCII, identifiant capteur | +| 8 | 1 | `signal_quality` | dB | | Signal cellulaire | +| 9 | 1 | `version` | | | Version encodée (complète en v_major/minor/patch plus bas) | +| 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 | +| 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 | +| 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 | +| 44 | 2 | `npm_ch4` | count | | NextPM canal 4 | +| 46 | 2 | `npm_ch5` | count | | NextPM canal 5 | +| 48 | 2 | `npm_temp` | °C | /10 | NextPM T interne | +| 50 | 2 | `npm_humidity` | % | /10 | NextPM HR interne | +| 52 | 2 | `battery_voltage` | V | /100 | | +| 54 | 2 | `battery_current` | A | /100 | Signé ? à confirmer (décharge = négatif ?) | +| 56 | 2 | `solar_voltage` | V | /100 | | +| 58 | 2 | `solar_power` | W | | | +| 60 | 2 | `charger_status` | | | Bitfield, cf. firmware | +| 62 | 2 | `wind_speed` | m/s | /10 | | +| 64 | 2 | `wind_direction` | degrés | | 0–359, 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 | +| 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 | | | | | -Le payload base64-décodé est une chaîne ASCII : +### 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. + +Format packé big-endian, 17 octets. Parsers de référence en prod : `server/sites/data.mobileair.fr/udp_miotiq_byte.php` (binaire) et `udp_miotiq_csv.php` (CSV). + +| Offset | Taille | Champ | Unité | Décodage | +|--------|--------|------------------|-----------|----------------------------------| +| 0 | 1 | `device_id` | | `str(val).zfill(3)` | +| 1 | 2 | `pm1_x10` | µg/m³ × 10 | `/10.0` | +| 3 | 2 | `pm25_x10` | µg/m³ × 10 | `/10.0` | +| 5 | 2 | `pm10_x10` | µg/m³ × 10 | `/10.0` | +| 7 | 2 | `lat_x10000` | deg × 1e4 | `/10000.0` (0 si pas de fix) | +| 9 | 2 | `lon_x10000` | deg × 1e4 | `/10000.0` | +| 11 | 1 | `num_sats` | | nombre de satellites | +| 12 | 1 | `signal_quality` | % | | +| 13 | 1 | `moving_type` | | énumération déplacement | + +Un ancien format **15 octets** existe aussi (sans `lat`/`lon`/`moving_type`) — le parser PHP le gère en fallback. À ne plus utiliser pour du nouveau firmware. + +Format CSV (base64-décodé = chaîne ASCII) : ``` {device_id},{pm1},{pm25},{pm10},{lat},{lon},{num_sats},{signal_quality},{moving_type} ``` -Exemple : `001,12.3,18.5,22.1,43.605000,1.444000,8,80,1` +Valeurs manquantes codées `-1` (sentinelle legacy). À **ne pas reproduire** pour les nouveaux formats. -- Séparateur : virgule `,`. -- Décimales : point `.`. -- Valeurs manquantes : `-1` (sentinelle legacy — à **ne pas reproduire** pour les nouveaux formats, voir [`formats/json-payload.md`](../formats/json-payload.md)). +## Parser serveur — squelette générique -## Côté serveur — squelette du webhook PHP +Un parser générique qui consomme un descripteur Miotiq et décode n'importe quelle trame : -```php - list[Field]: + fields = [] + for line in text.strip().splitlines(): + parts = line.split("|") + nb_chars, name, decoder, unit, scale = parts[0], parts[1], parts[2], parts[3], parts[4] + fields.append(Field(int(nb_chars) // 2, name, decoder, unit, scale)) + return fields -if ($bin === false) { http_response_code(400); exit; } +def decode(payload: bytes, fields: list[Field]) -> dict: + out, off = {}, 0 + for f in fields: + chunk = payload[off:off + f.size] + if len(chunk) != f.size: + raise ValueError(f"payload trop court au champ {f.name}") + if f.decoder == "string": + out[f.name] = chunk.decode("ascii", errors="replace").rstrip("\x00 ") + elif f.decoder == "hex2dec": + raw = int.from_bytes(chunk, "big", signed=False) + if f.scale == "x/10": out[f.name] = raw / 10.0 + elif f.scale == "x/100": out[f.name] = raw / 100.0 + else: out[f.name] = raw + elif f.decoder == "skip": + pass + off += f.size + return out -// Dispatcher selon la taille pour les formats binaires ; -// pour le CSV, tester si $bin est une chaîne imprimable commençant par un chiffre. -if (strlen($bin) === 17) { - $u = unpack('Cdev/npm1/npm25/npm10/nlat/nlon/Csats/Csig/Cmoving', $bin); - // … -} else if (strlen($bin) === 15) { - // format legacy — décoder sans lat/lon -} +# Webhook Miotiq +def on_miotiq_webhook(body: dict, descriptor: str) -> dict: + raw = base64.b64decode(body["payload"]) + fields = parse_descriptor(descriptor) + total = sum(f.size for f in fields) + if len(raw) != total: + raise ValueError(f"payload {len(raw)} octets, descripteur attend {total}") + return { + "imsi": body.get("srcImsi"), + "rcvTime": body.get("rcvTime"), + "data": decode(raw, fields), + } ``` -Voir les implémentations complètes en prod : `server/sites/data.mobileair.fr/udp_miotiq_byte.php` et `udp_miotiq_csv.php`. - -## Côté capteur — envoi UDP - -Côté modem cellulaire (nRF9151 / autre), envoyer le datagramme à l'IP/port fournis par Miotiq pour ta SIM. Le tunnel Miotiq encapsule ensuite et ajoute l'enveloppe JSON avant de poster sur le webhook AirCarto. - -Pas d'ACK côté capteur : fire-and-forget. Un cycle d'envoi typique est de **1 à 5 minutes**. +Côté PHP (cf. implémentations existantes `udp_miotiq_byte.php` / `udp_miotiq_csv.php`), la logique sera à réécrire autour de ce même principe de descripteur au fur et à mesure de la migration. ## Configuration Miotiq -- Clé API serveur : stockée dans le code backend (`server/sites/gestion.aircarto.fr/server/routes/sensors.js`). -- Webhook à paramétrer côté Miotiq : `https://data.mobileair.fr/udp_miotiq_byte.php` (binaire) ou `/udp_miotiq_csv.php` (CSV). +- Clé API serveur : stockée dans le code backend (`server/sites/gestion.aircarto.fr/server/routes/sensors.js`), référencée comme ``. +- Webhook à paramétrer côté Miotiq par capteur : `https://data..aircarto.fr/udp_miotiq.php`. +- Descripteur à coller dans la fiche device Miotiq (ou par endpoint côté projet). - API utile : - `POST https://app.miotiq.com/api/device/detail?api_key=` — état d'un device par IMSI. - - `POST https://app.miotiq.com/api/device/update?api_key=` — renommer / associer un device (voir `miotiq-update` dans `routes/sensors.js`). + - `POST https://app.miotiq.com/api/device/update?api_key=` — renommer / associer. + +## À faire + +- [ ] Confirmer la signification du flag `W` dans le descripteur (hypothèse : writable via downlink). +- [ ] Confirmer l'endianness des champs multi-octets (big-endian supposé). +- [ ] Confirmer le caractère signé/non-signé de `battery_current` (décharge = négatif ?). +- [ ] Migrer MobileAir du format binaire 17B vers un descripteur Miotiq formel. +- [ ] Ajouter un descripteur ModuleAir Pro 4G quand dispo. ## Historique -| Date | Révision | Changement | -|------------|----------|-------------------------------------------------------| -| 2026-04-23 | v1 | Création à partir des parsers PHP en prod. | +| Date | Révision | Changement | +|------------|----------|-----------------------------------------------------------------------------| +| 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). |