Files
aircarto-protocols/parsers/udp-miotiq.md
Paul Vuarambon 7f8d6a21e9 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.
2026-04-23 00:46:35 +02:00

258 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Parser UDP Miotiq
[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.*.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 :
```json
{
"payload": "<base64 du datagramme UDP d'origine>",
"customerId": "string",
"rcvTime": 1713830400,
"srcIP": "10.x.x.x",
"srcImsi": "208xxxxxxxxxxx"
}
```
| Champ | Type | Description |
|--------------|---------|--------------------------------------------------------------------|
| `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. |
## Format descripteur Miotiq
Un descripteur est une suite de lignes, une par champ, au format :
```
<nb_chars_hex>|<nom>|<decodeur>|<unite>|<scale>|<flags>
```
| 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). |
**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.
## Descripteurs actuels
### NebuleAir Pro 4G (83 octets = 166 chars hex)
Descripteur de référence :
```
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|||
```
Layout octet par octet :
| 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 | | 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 |
| 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 | | | | |
### 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}
```
Valeurs manquantes codées `-1` (sentinelle legacy). À **ne pas reproduire** pour les nouveaux formats.
## Parser serveur — squelette générique
Un parser générique qui consomme un descripteur Miotiq et décode n'importe quelle trame :
```python
import base64, struct
from dataclasses import dataclass
@dataclass
class Field:
size: int # octets
name: str
decoder: str # string | hex2dec | skip
unit: str
scale: str # "x/10" | "x/100" | ""
def parse_descriptor(text: str) -> 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
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
# 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),
}
```
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`), référencée comme `<API_KEY>`.
- Webhook à paramétrer côté Miotiq par capteur : `https://data.<projet>.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=<KEY>` — état d'un device par IMSI.
- `POST https://app.miotiq.com/api/device/update?api_key=<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 — MobileAir 17B + CSV à partir des parsers PHP en prod. |
| 2026-04-23 | v2 | Refonte autour du format descripteur Miotiq, ajout NebuleAir Pro 4G (83B). |