- 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.
258 lines
14 KiB
Markdown
258 lines
14 KiB
Markdown
# 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 | | 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 | | | | |
|
||
|
||
### 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). |
|