- 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.
14 KiB
Parser UDP Miotiq
Miotiq 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 :
{
"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. |
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
hex2decmulti-octets sont big-endian (à vérifier au cas par cas avec Miotiq si un doute apparaît). - Les champs
stringsont 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) |
||
| 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 :
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
Wdans 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). |