chore: initial skeleton — NextPM sensor, JSON format, Miotiq UDP parser

This commit is contained in:
2026-04-23 00:36:19 +02:00
commit 278775e7e8
8 changed files with 649 additions and 0 deletions

167
parsers/udp-miotiq.md Normal file
View File

@@ -0,0 +1,167 @@
# 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**.
Chemin de données :
```
Capteur ──UDP──> Miotiq ──HTTPS POST JSON──> data.mobileair.fr/udp_miotiq_*.php ──> PostgreSQL + InfluxDB
```
## 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 (charge utile brute). |
| `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
Après décodage base64, le contenu est soit **binaire**, soit **CSV**, selon le firmware du capteur.
### 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.
Python équivalent pour lire / écrire :
```python
import struct, base64
FMT = ">B HHHHH BBB" # 17 octets
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)
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"}
```
### Format CSV — MobileAir (legacy)
Parser de référence : endpoint `/udp_miotiq_csv.php`.
Le payload base64-décodé est une 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`
- 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)).
## Côté serveur — squelette du webhook PHP
```php
<?php
$raw = file_get_contents("php://input");
$json = json_decode($raw, true);
if (!$json || !isset($json['payload'])) { http_response_code(400); exit; }
$imsi = $json['srcImsi'] ?? null;
$rcvTime = $json['rcvTime'] ?? time();
$bin = base64_decode($json['payload'], true);
if ($bin === false) { http_response_code(400); exit; }
// 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
}
```
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**.
## 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).
- 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 un device (voir `miotiq-update` dans `routes/sensors.js`).
## Historique
| Date | Révision | Changement |
|------------|----------|-------------------------------------------------------|
| 2026-04-23 | v1 | Création à partir des parsers PHP en prod. |