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.
This commit is contained in:
2026-04-23 00:46:35 +02:00
parent 278775e7e8
commit 7f8d6a21e9
3 changed files with 234 additions and 106 deletions

View File

@@ -11,6 +11,7 @@ aircarto-protocols/
├── CONVENTIONS.md Nommage, versioning, style doc ├── CONVENTIONS.md Nommage, versioning, style doc
├── formats/ Formats d'échange de données ├── formats/ Formats d'échange de données
│ ├── json-payload.md Format JSON canonique des mesures │ ├── json-payload.md Format JSON canonique des mesures
│ ├── iso-pollutant-codes.md Mapping ISO_XX → polluant / grandeur
│ └── mqtt.md Topics et conventions MQTT │ └── mqtt.md Topics et conventions MQTT
├── sensors/ Un fichier par capteur ├── sensors/ Un fichier par capteur
│ ├── _TEMPLATE.md Gabarit à copier pour tout nouveau capteur │ ├── _TEMPLATE.md Gabarit à copier pour tout nouveau capteur
@@ -28,8 +29,8 @@ aircarto-protocols/
## Index des parsers ## Index des parsers
| Nom | Transport | Doc | État | | Nom | Transport | Doc | État |
|-----------------|------------------|-----------------------------------------------|---------| |-----------------|------------------|-----------------------------------------------|-------------------------------|
| UDP Miotiq | UDP → HTTPS JSON | [parsers/udp-miotiq.md](parsers/udp-miotiq.md) | Complet | | UDP Miotiq | UDP → HTTPS JSON | [parsers/udp-miotiq.md](parsers/udp-miotiq.md) | Descripteur NebuleAir Pro 4G + legacy MobileAir |
## Comment ajouter une entrée ## Comment ajouter une entrée

View File

@@ -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.

View File

@@ -1,13 +1,15 @@
# Parser UDP Miotiq # 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 : 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 ## Enveloppe JSON reçue du webhook
Le corps `application/json` reçu par le script PHP contient : 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 | | 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. | | `customerId` | string | Identifiant client Miotiq. |
| `rcvTime` | integer | Timestamp Unix UTC de la réception Miotiq, en secondes. | | `rcvTime` | integer | Timestamp Unix UTC de la réception Miotiq, en secondes. |
| `srcIP` | string | IP source du modem cellulaire (côté opérateur). | | `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. | | `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 ```
<nb_chars_hex>|<nom>|<decodeur>|<unite>|<scale>|<flags>
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 ## Descripteurs actuels
import struct, base64
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): Descripteur de référence :
return struct.pack(FMT, device_id,
int(pm1*10), int(pm25*10), int(pm10*10),
int(lat*10000), int(lon*10000),
sats, sig, moving)
def unpack(data: bytes): ```
if len(data) != 17: 16|device_id|string|||W
raise ValueError(f"payload {len(data)} octets, attendu 17") 2|signal_quality|hex2dec|dB||
dev, pm1, pm25, pm10, lat, lon, sats, sig, moving = struct.unpack(FMT, data) 2|version|hex2dec|||W
return { 4|ISO_68|hex2dec|ugm3|x/10|
"device_id": f"{dev:03d}", 4|ISO_39|hex2dec|ugm3|x/10|
"pm1": pm1 / 10.0, 4|ISO_24|hex2dec|ugm3|x/10|
"pm25": pm25 / 10.0, 4|ISO_54|hex2dec|degC|x/100|
"pm10": pm10 / 10.0, 4|ISO_55|hex2dec|%|x/100|
"lat": lat / 10000.0, 4|ISO_53|hex2dec|hPa||
"lon": lon / 10000.0, 4|noise_cur_leq|hex2dec|dB|x/10|
"sats": sats, 4|noise_cur_level|hex2dec|dB|x/10|
"signal": sig, 4|max_noise|hex2dec|dB|x/10|
"moving_type": moving, 4|ISO_03|hex2dec|ppb||
} 4|ISO_05|hex2dec|ppb||
4|ISO_21|hex2dec|ppb||
# Côté webhook 4|ISO_04|hex2dec|ppb||
body = {"payload": base64.b64encode(pack(1, 12.3, 18.5, 22.1, 43.605, 1.444, 8, 80, 1)).decode(), 4|ISO_08|hex2dec|ppb||
"customerId": "aircarto", "rcvTime": 1713830400, 4|npm_ch1|hex2dec|count||
"srcIP": "10.0.0.1", "srcImsi": "208010000000001"} 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 | | 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 | | | | |
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} {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 `,`. ## Parser serveur — squelette générique
- Décimales : point `.`.
- Valeurs manquantes : `-1` (sentinelle legacy — à **ne pas reproduire** pour les nouveaux formats, voir [`formats/json-payload.md`](../formats/json-payload.md)).
## 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 ```python
<?php import base64, struct
$raw = file_get_contents("php://input"); from dataclasses import dataclass
$json = json_decode($raw, true);
if (!$json || !isset($json['payload'])) { http_response_code(400); exit; } @dataclass
class Field:
size: int # octets
name: str
decoder: str # string | hex2dec | skip
unit: str
scale: str # "x/10" | "x/100" | ""
$imsi = $json['srcImsi'] ?? null; def parse_descriptor(text: str) -> list[Field]:
$rcvTime = $json['rcvTime'] ?? time(); fields = []
$bin = base64_decode($json['payload'], true); 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 ; # Webhook Miotiq
// pour le CSV, tester si $bin est une chaîne imprimable commençant par un chiffre. def on_miotiq_webhook(body: dict, descriptor: str) -> dict:
if (strlen($bin) === 17) { raw = base64.b64decode(body["payload"])
$u = unpack('Cdev/npm1/npm25/npm10/nlat/nlon/Csats/Csig/Cmoving', $bin); fields = parse_descriptor(descriptor)
// … total = sum(f.size for f in fields)
} else if (strlen($bin) === 15) { if len(raw) != total:
// format legacy — décoder sans lat/lon 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é 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.
## 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**.
## Configuration Miotiq ## Configuration Miotiq
- Clé API serveur : stockée dans le code backend (`server/sites/gestion.aircarto.fr/server/routes/sensors.js`). - 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 : `https://data.mobileair.fr/udp_miotiq_byte.php` (binaire) ou `/udp_miotiq_csv.php` (CSV). - 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 : - 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/detail?api_key=<KEY>` — état d'un device par IMSI.
- `POST https://app.miotiq.com/api/device/update?api_key=<KEY>` — renommer / associer un device (voir `miotiq-update` dans `routes/sensors.js`). - `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 ## Historique
| Date | Révision | Changement | | Date | Révision | Changement |
|------------|----------|-------------------------------------------------------| |------------|----------|-----------------------------------------------------------------------------|
| 2026-04-23 | v1 | Création à partir des parsers PHP en prod. | | 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). |