openapi: 3.0.3
info:
  title: AirCarto API
  description: |
    # API AirCarto

    Données des capteurs AirCarto (qualité de l'air, bruit, vibration, trafic) et gestion
    des comptes, organisations et projets.

    **Cette spec est la référence publique de l'API.** Toute modification d'un endpoint la
    met à jour dans le même commit ; en cas d'écart avec le comportement observé, écrire à
    contact@aircarto.fr. Fichier brut : [`/openapi.yaml`](/openapi.yaml).

    ## Nouveautés

    | Date | Changement |
    |------|------------|
    | 2026-10-01 | `iut-amu/push` : champ `npm_status` (entier 0 à 255, état du NextPM ; 255 = capteur muet). |
    | 2026-10-01 | `iut-amu/push` : `device` renommé **`device_id`**, désormais **obligatoire** (400 `missing_device_id`) ; `bad_device` devient `bad_device_id`. `iut-amu/data` : paramètre et colonne `device_id`. Envoi groupé (`points`, `ts`) retiré de la documentation : les cartes du TP n'ont ni horloge ni stockage. |
    | 2026-09-30 | `capteurs/moduleTraffic` et `capteurs/dataModuleTraffic` : sens de passage. Champs facultatifs `<catégorie>_in` / `<catégorie>_out` (ex. `cars_in`, `cars_out`) et `direction_axis` (`x` : in = de gauche à droite de l'image, `y` : in = de haut en bas). `null` en lecture pour les capteurs qui ne les envoient pas. |
    | 2026-09-28 | Nouveaux `iut-amu/push`, `iut-amu/last`, `iut-amu/data` : TP IUT Aix-Marseille, envoi de mesures par clé de groupe (HTTP simple accepté), champ `device` (adresse MAC), journal des requêtes pour le débogage, guide étudiant sur [`/iut-amu/`](/iut-amu/). |
    | 2026-09-24 | `capteurs/dataModuleAir` : sans `freq`, la période est limitée à 31 jours de points bruts ; au-delà, 400 `range_too_large` avec message (au lieu d'un 500 muet). Avec `freq`, pas de limite. |
    | 2026-09-24 | Nouveau `capteurs/liveMobileAir` : MobileAir actifs (5 min par défaut) avec leurs derniers points géolocalisés, 1 point en mesure fixe, 3 en mobilité. Un seul appel pour la carte temps réel. |
    | 2026-09-22 | Spec unifiée (`openapi.yaml`), Scalar seule interface. Documentés : `capteurs/pushMobileAir`, `context/upload_photo`, `openairmap/dataBoats`, `openairmap/dataPlanes`. |
    | 2026-09-14 | `context/get_context` : clé `photos` sur les contextes qui en portent. Nouveau `context/upload_photo` pour en joindre une. |
    | 2026-09-07 | `capteurs/dataMobileAir` : champ `moving` (0 à pied, 1 vélo, 2 voiture, 3 transports en commun, **4 mesure fixe**), champs `TEMP`, `HUM`, `acc`, paramètre `freq`. |

    ## Types de capteurs

    | Type | Description | Accès données |
    |------|-------------|---------------|
    | **NebuleAir** | Capteur d'air extérieur | Public (open data) |
    | **ModuleAir** | Capteur d'air intérieur | Protégé par token capteur |
    | **MobileAir** | Capteur mobile (PMScan relayé par l'app Cyan Sensor, ou LTE) | Public, identifié par son token |
    | **ModuleTraffic** | Capteur de comptage trafic routier (voitures, vélos, piétons, …) | Protégé par token capteur |

    ## Support

    - Site web : [aircarto.fr](https://aircarto.fr)
    - Email : contact@aircarto.fr
    - Carte des capteurs : [openairmap.aircarto.fr](https://openairmap.aircarto.fr)

  version: "3.0"
  contact:
    name: AirCarto Support
    email: contact@aircarto.fr
    url: https://aircarto.fr
  license:
    name: Apache 2.0
    url: http://www.apache.org/licenses/LICENSE-2.0.html
  x-logo:
    url: https://aircarto.fr/images/logo_aircarto.png
    altText: AirCarto Logo

servers:
  - url: https://api.aircarto.fr
    description: Production API

tags:
  - name: capteurs
    description: |
      Récupérer les mesures et métadonnées des capteurs.

      - **NebuleAir** : données ouvertes (pas de token requis)
      - **ModuleAir** : token capteur requis
      - **MobileAir** : lecture par token du capteur (`sensorToken` de `/capteurs/metadata`)
  - name: context
    description: |
      Gestion des contextes de mesure.

      Permet d'importer et de lire des messages de contextualisation des données de mesure
      (incendie, trafic, activité industrielle, etc.) liés à un capteur ou à une position géographique.
      Ces informations aident à interpréter correctement les données de qualité de l'air.
  - name: configuration
    description: Configuration des capteurs (localisation, paramètres)
  - name: openairmap
    description: Couches temps réel de la carte OpenAirMap (bateaux AIS, avions ADS-B). Lecture seule.
  - name: iut-amu
    description: |
      TP IUT Aix-Marseille (BUT Mesures Physiques) : envoi des mesures des PCB NebuleAir /
      ModuleAir reprogrammés par les étudiants. Une clé par groupe, donnée par l'enseignant.
      `push` accepte le HTTP simple (`http://api.aircarto.fr/iut-amu/push`).
      Guide étudiant (exemple ESP32, débogage) : [api.aircarto.fr/iut-amu/](/iut-amu/).

paths:
  # ============================================
  # CAPTEURS ENDPOINTS
  # ============================================

  /capteurs/metadata:
    get:
      tags:
        - capteurs
      summary: Métadonnées et dernières mesures
      description: |
        Récupère les métadonnées et les dernières mesures des capteurs.

        - **NebuleAir** : données publiques
        - **ModuleAir** : token capteur requis
        - **MobileAir** : données publiques. Chaque objet porte `sensorId` (nom, ex. `mobileair-012`,
          à passer à `/context/get_context`) et `sensorToken` (token, à passer à `/capteurs/dataMobileAir`).
          Pour n'afficher que les capteurs qui émettent en ce moment, avec leurs derniers points,
          préférer `/capteurs/liveMobileAir`.

        Les capteurs envoient leurs données toutes les 2 minutes.
      operationId: metadata
      parameters:
        - name: capteurType
          in: query
          description: Type de capteur
          required: true
          schema:
            type: string
            enum: [ModuleAir, NebuleAir, MobileAir, ModuleTraffic]
            default: NebuleAir
        - name: capteurID
          in: query
          description: Identifiant du capteur (optionnel, pour un capteur spécifique)
          schema:
            type: string
            example: nebuleair-025
        - name: token
          in: query
          description: Token du capteur (requis pour ModuleAir)
          schema:
            type: string
        - name: campagne
          in: query
          description: Nom de la campagne de mesure
          schema:
            type: string
        - name: format
          in: query
          description: Format de sortie
          schema:
            type: string
            enum: [JSON, GeoJSON, CSV]
            default: JSON
      responses:
        "200":
          description: Métadonnées récupérées
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/SensorMetadata"
        "400":
          description: Paramètres invalides
        "404":
          description: Capteur non trouvé

  /capteurs/dataNebuleAir:
    get:
      tags:
        - capteurs
      summary: Données historiques NebuleAir
      description: |
        Récupère les données historiques d'un capteur NebuleAir.

        **Données ouvertes** - Aucun token requis.

        Les NebuleAir envoient leurs données toutes les 2 minutes.
      operationId: dataNebuleAir
      parameters:
        - $ref: "#/components/parameters/capteurID"
        - $ref: "#/components/parameters/start"
        - $ref: "#/components/parameters/end"
        - $ref: "#/components/parameters/freq"
        - name: Five_channels
          in: query
          description: Inclure les 5 canaux PM détaillés
          schema:
            type: boolean
            default: false
        - name: gas
          in: query
          description: Inclure les polluants gazeux (CO, NO2, O3, SO2, H2S, NH3, CO2, CH4, VOC)
          schema:
            type: boolean
            default: false
        - name: battery
          in: query
          description: Inclure les données batterie/solaire
          schema:
            type: boolean
            default: false
        - name: errors
          in: query
          description: Inclure les données d'erreur (error_flags, npm_status, device_status)
          schema:
            type: boolean
            default: false
        - $ref: "#/components/parameters/format"
      responses:
        "200":
          description: Données récupérées
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/NebuleAirReading"
              example:
                - time: "2024-12-15T10:30:00Z"
                  sensorId: nebuleair-025
                  PM1: 5.23
                  PM25: 8.45
                  PM10: 12.67
                  TEMP: 22.5
                  HUM: 45.2
                  PRESS: 1013.25
                  NOISE: 42.3
                  COV: 125
            text/csv:
              schema:
                type: string
        "400":
          description: Paramètres invalides
        "404":
          description: Capteur non trouvé

  /capteurs/dataModuleAir:
    get:
      tags:
        - capteurs
      summary: Données historiques ModuleAir
      description: |
        Récupère les données historiques d'un capteur ModuleAir.

        **Token capteur requis** - Le token est inscrit au dos de l'appareil.
      operationId: dataModuleAir
      parameters:
        - $ref: "#/components/parameters/capteurID"
        - name: token
          in: query
          description: Token du capteur (inscrit au dos de l'appareil)
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/start"
        - $ref: "#/components/parameters/end"
        - $ref: "#/components/parameters/freq"
        - $ref: "#/components/parameters/format"
      responses:
        "200":
          description: Données récupérées
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ModuleAirReading"
              example:
                - time: "2024-12-15T10:30:00Z"
                  sensorId: E99
                  CO2: 420
                  COV: 125
                  PM1: 5.23
                  PM25: 8.45
                  PM10: 12.67
                  TEMP: 22.5
                  HUM: 45.2
        "400":
          description: |
            `range_too_large` : sans `freq`, la période demandée dépasse 31 jours de points bruts.
            Découper la demande, ou ajouter `freq` (ex. `10m`) pour des moyennes sur toute la période.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    example: range_too_large
                  message:
                    type: string
                  max_days:
                    type: integer
                    example: 31
        "401":
          description: Token invalide
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    error:
                      type: string
                    message:
                      type: string
              example:
                - error: invalid_request
                  message: Wrong token provided

  /capteurs/dataMobileAir:
    get:
      tags:
        - capteurs
      summary: Mesures d'un MobileAir (trajets et mesures fixes)
      description: |
        Mesures d'un capteur MobileAir sur une période : particules, position, et pour chaque
        point le relevé auquel il appartient (`sessionId`) et le mode de mesure (`moving`).

        **Identifiant** : `capteurID` est le **token** du capteur (`sensorToken` de
        `/capteurs/metadata`), pas son nom.

        **Relevés** : un relevé (trajet ou mesure fixe) est démarré et arrêté dans l'app Cyan
        Sensor ; tous ses points partagent le même `sessionId` (epoch de début, en secondes).
        C'est la clé pour découper les trajets.

        **Mode de mesure** (`moving`, identique pour tout le relevé) :

        | Code | Mode |
        |------|------|
        | 0 | À pied |
        | 1 | Vélo |
        | 2 | Voiture |
        | 3 | Transports en commun |
        | 4 | **Mesure fixe** : le capteur n'a pas bougé, une seule position pour tout le relevé |
        | null | Inconnu (relevé antérieur à cette information) |

        **Position** : `lat` / `lon` viennent du GPS du téléphone, `null` si la mesure n'était pas
        géolocalisée ; `acc` = précision en mètres. `TEMP` / `HUM` sont la température et
        l'humidité **internes au boîtier**, pas l'air ambiant (null sur les MobileAir LTE).

        Sans `freq` : points bruts (toutes les 10 s). Avec `freq` : moyenne par fenêtre, fenêtres
        vides omises, `sessionId` et `moving` ramenés à l'entier.
      operationId: dataMobileAir
      parameters:
        - name: capteurID
          in: query
          required: true
          description: Token du capteur (`sensorToken` de `/capteurs/metadata`)
          schema:
            type: string
          example: E45293E0AF79
        - $ref: "#/components/parameters/start"
        - $ref: "#/components/parameters/end"
        - $ref: "#/components/parameters/freq"
        - name: GPSnull
          in: query
          description: |
            - `true` (défaut) : tous les points, `lat` / `lon` à `null` sans position
            - `false` : uniquement les points géolocalisés
          schema:
            type: boolean
            default: true
        - name: format
          in: query
          schema:
            type: string
            enum: [JSON, CSV]
            default: JSON
      responses:
        "200":
          description: Points de mesure, ordre chronologique
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/MobileAirReading"
        "400":
          description: |
            Paramètres invalides : `capteurID` et `start` requis ; `start` / `end` en durée
            relative (`-24h`) ou RFC3339 ; `freq` en durée (`15m`).

  /capteurs/liveMobileAir:
    get:
      tags:
        - capteurs
      summary: MobileAir actifs en ce moment, avec leurs derniers points
      description: |
        Les MobileAir qui émettent **en ce moment**, avec leurs derniers points géolocalisés.
        Un seul appel, pensé pour une carte temps réel : inutile de lister la flotte via
        `/capteurs/metadata` puis d'appeler `/capteurs/dataMobileAir` capteur par capteur.

        **Actif** = dernière donnée reçue dans la fenêtre `since` (5 min par défaut, 1 h max).

        **Points** (`points`, mêmes champs que `/capteurs/dataMobileAir`, ordre chronologique) :

        | Mode du dernier point | `fixed` | Points renvoyés |
        |------|------|------|
        | `moving` = 4 (mesure fixe) | `true` | le dernier point |
        | autre valeur ou `null` (mobilité, ou inconnu) | `false` | les 3 derniers points |
        | aucun point géolocalisé dans la fenêtre | `null` | `[]` |

        Seuls les points **géolocalisés** sont renvoyés. Un capteur actif en intérieur ou sans
        fixe GPS apparaît donc avec `points: []` : il émet, mais ne peut pas être placé sur la
        carte. `sessionId` et `moving` au niveau du capteur sont ceux du dernier point.

        Les capteurs sont triés du plus récent au plus ancien (`lastSeen`). `lastSeenSec` est
        l'âge de la dernière donnée en secondes au moment de l'appel.
      operationId: liveMobileAir
      parameters:
        - name: since
          in: query
          description: Fenêtre d'activité, durée en `s`, `m` ou `h`. 1 h maximum.
          schema:
            type: string
            pattern: '^\d+(s|m|h)$'
            default: 5m
          example: 5m
      responses:
        "200":
          description: Capteurs actifs, du plus récent au plus ancien. `[]` si aucun.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/MobileAirLive"
        "400":
          description: "`since` invalide : durée en `s`, `m` ou `h`, 1 h maximum"
        "502":
          description: Base de mesures (InfluxDB) injoignable
        "503":
          description: Base capteurs (PostgreSQL) injoignable

  /capteurs/pushMobileAir:
    post:
      tags:
        - capteurs
      summary: Ingestion des mesures MobileAir relayées par l'app Cyan Sensor
      description: |
        Réservé à l'app Cyan Sensor : le capteur (PMScan) parle en Bluetooth au téléphone, qui
        relaie ici en HTTPS. Documenté pour comprendre d'où viennent les données lues par
        `/capteurs/dataMobileAir`.

        - Chaque mesure porte son horodatage `ts` (epoch UTC en secondes) : l'app peut envoyer
          plus tard des mesures restées dans le capteur (rattrapage).
        - Écriture **idempotente** : renvoyer un lot ne crée pas de doublon. C'est ainsi que
          l'app corrige après coup le mode `moving` ou ajoute une position.
        - `moving` : même vocabulaire qu'en lecture (0 à pied, 1 vélo, 2 voiture, 3 transports
          en commun, 4 mesure fixe).
        - Toute réponse 2xx signifie « lot pris en compte, ne pas renvoyer ».
      operationId: pushMobileAir
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MobileAirPush"
            example:
              token: E45293E0AF79
              app: { platform: ios, version: "1.2.0" }
              measures:
                - { ts: 1756900010, pm1: 1.2, pm25: 2.3, pm10: 3.4, temp: 32.1, hum: 30.0, lat: 43.2901, lon: 5.3701, acc: 8.5, session: 1756899000, moving: 1 }
      responses:
        "200":
          description: Lot pris en compte
          content:
            application/json:
              example: { ok: true, sensor: mobileair-012, accepted: 1, rejected: 0, rejected_reasons: { bad_timestamp: 0, missing_pm: 0, not_an_object: 0 }, newest_ts: 1756900010, meta_updated: true, server_time: 1756900100 }
        "400":
          description: Corps invalide (`empty_body`, `invalid_json`, `invalid_token`, `invalid_measures`)
        "403":
          description: Le token n'est pas celui d'un MobileAir (`wrong_device_type`)
        "404":
          description: Token inconnu (`unknown_device`)
        "413":
          description: Plus de 2000 mesures dans le lot (`too_many_measures`)
        "502":
          description: Base ou stockage indisponible, réessayer plus tard (`database_unavailable`, `influx_unavailable`)

  /capteurs/dataModuleTraffic:
    get:
      tags:
        - capteurs
      summary: Données historiques ModuleTraffic
      description: |
        Récupère les données de comptage du trafic d'un capteur ModuleTraffic
        (voitures, vélos, piétons, camions, motos + vitesse moyenne).

        Sans `freq`, la donnée brute est retournée.
        Avec `freq`, les comptages sont **sommés** sur la fenêtre tandis que
        `avg_speed`, `signal`, `latitude`, `longitude` sont **moyennés**.

        Sens de passage : `cars_in`/`cars_out`, `bikes_in`/`bikes_out`,
        `pedestrians_in`/`pedestrians_out`, `trucks_in`/`trucks_out`,
        `motorbikes_in`/`motorbikes_out` (sommés comme les totaux) et
        `direction_axis` (dernière valeur de la fenêtre). `x` : `in` = de gauche
        à droite de l'image, `out` = de droite à gauche ; `y` : `in` = de haut en
        bas. `null` quand le capteur n'envoie pas le sens (firmware < 1.1.0).
      operationId: dataModuleTraffic
      parameters:
        - $ref: "#/components/parameters/capteurID"
        - name: token
          in: query
          description: Token du capteur
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/start"
        - $ref: "#/components/parameters/end"
        - $ref: "#/components/parameters/freq"
        - $ref: "#/components/parameters/format"
      responses:
        "200":
          description: Données récupérées
        "400":
          description: Paramètres invalides
        "404":
          description: Capteur non trouvé

  /capteurs/moduleTraffic:
    post:
      tags:
        - capteurs
      summary: Endpoint d'ingestion ModuleTraffic
      description: |
        Endpoint utilisé par les capteurs ModuleTraffic pour pousser leurs comptages.

        Le `device_id` est l'identifiant unique du capteur encodé en hexadécimal
        (conversion hex → ASCII pour retrouver le numéro de série, ex.
        `4430353234313938` → `D0524198`).

        Tous les autres champs sont optionnels ; une valeur de `-1` indique une
        donnée non disponible. Une coordonnée `(0, 0)` est ignorée.
      operationId: ingestModuleTraffic
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - device_id
              properties:
                device_id:
                  type: string
                  description: Identifiant du capteur en hexadécimal (hex → ASCII)
                  example: "4430353234313938"
                signal_quality:
                  type: number
                  description: Qualité du signal réseau (dB)
                  example: -78
                version:
                  type: integer
                  description: Version du protocole
                  example: 1
                timestamp:
                  type: string
                  format: date-time
                  description: Timestamp ISO8601 de la mesure (sinon now côté serveur)
                  example: "2026-04-27T12:34:56Z"
                cars:
                  type: integer
                bikes:
                  type: integer
                pedestrians:
                  type: integer
                trucks:
                  type: integer
                motorbikes:
                  type: integer
                avg_speed:
                  type: number
                  description: Vitesse moyenne sur la période (km/h)
                latitude:
                  type: number
                  format: float
                longitude:
                  type: number
                  format: float
                version_major:
                  type: integer
                version_minor:
                  type: integer
                version_patch:
                  type: integer
                direction_axis:
                  type: string
                  enum: [x, y]
                  description: |
                    Axe de l'image sur lequel le sens est mesuré. `x` : `in` = de
                    gauche à droite, `out` = de droite à gauche. `y` : `in` = de
                    haut en bas, `out` = de bas en haut.
                cars_in:
                  type: integer
                  description: Voitures dans le sens `in` (`cars_in` + `cars_out` = `cars`). Idem pour chaque catégorie.
                cars_out:
                  type: integer
                bikes_in:
                  type: integer
                bikes_out:
                  type: integer
                pedestrians_in:
                  type: integer
                pedestrians_out:
                  type: integer
                trucks_in:
                  type: integer
                trucks_out:
                  type: integer
                motorbikes_in:
                  type: integer
                motorbikes_out:
                  type: integer
            example:
              device_id: "4430353234313938"
              signal_quality: -78
              version: 1
              timestamp: "2026-04-27T12:34:56Z"
              cars: 12
              bikes: 3
              pedestrians: 5
              trucks: 1
              motorbikes: 2
              avg_speed: 45.2
              latitude: 43.2964
              longitude: 5.36978
      responses:
        "200":
          description: Données ingérées (ou ignorées avec un message lisible)

  /capteurs/dataNebuleAirAll:
    get:
      tags:
        - capteurs
      summary: Données de tous les NebuleAir
      description: |
        Récupère les données de tous les capteurs NebuleAir (ou un seul si `capteurID` est fourni).

        Les données sont regroupées par capteur dans la réponse.

        **Limite** : 1000 résultats max par requête.
      operationId: dataNebuleAirAll
      parameters:
        - name: capteurID
          in: query
          description: Identifiant du capteur (optionnel, si absent retourne tous)
          schema:
            type: string
        - $ref: "#/components/parameters/start"
        - $ref: "#/components/parameters/end"
        - $ref: "#/components/parameters/freq"
        - name: Five_channels
          in: query
          schema:
            type: boolean
            default: false
        - name: gas
          in: query
          schema:
            type: boolean
            default: false
        - $ref: "#/components/parameters/format"
      responses:
        "200":
          description: Données récupérées (groupées par capteur)
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  type: array
                  items:
                    $ref: "#/components/schemas/NebuleAirReading"

  /capteurs/count:
    get:
      tags:
        - capteurs
      summary: Nombre de capteurs par type
      description: Retourne le nombre total de capteurs pour un type donné.
      operationId: count
      parameters:
        - name: capteurType
          in: query
          description: Type de capteur à compter
          schema:
            type: string
            enum: [ModuleAir, NebuleAir, MobileAir, ModuleTraffic]
            default: ModuleAir
      responses:
        "200":
          description: Comptage réussi
          content:
            application/json:
              schema:
                type: object
                properties:
                  capteurType:
                    type: string
                    example: ModuleAir
                  count:
                    type: integer
                    example: 42

  # ============================================
  # CONFIGURATION ENDPOINTS
  # ============================================

  /capteurs/changeLoc:
    post:
      tags:
        - configuration
      summary: Modifier la localisation
      description: |
        Définit ou modifie les coordonnées GPS d'un capteur fixe.

        **Token capteur requis.**
      operationId: changeLoc
      parameters:
        - $ref: "#/components/parameters/capteurID"
        - name: token
          in: query
          required: true
          schema:
            type: string
        - name: lat
          in: query
          description: Latitude
          required: true
          schema:
            type: number
            format: float
            minimum: -90
            maximum: 90
            example: 43.2965
        - name: long
          in: query
          description: Longitude
          required: true
          schema:
            type: number
            format: float
            minimum: -180
            maximum: 180
            example: 5.3698
      responses:
        "200":
          description: Localisation mise à jour
        "401":
          description: Token invalide
        "404":
          description: Capteur non trouvé

  /capteurs/changeRoom:
    post:
      tags:
        - configuration
      summary: Définir le type de pièce
      description: |
        Définit le type de pièce où se situe le capteur (cuisine, chambre, etc.).

        **Token capteur requis.**
      operationId: changeRoom
      parameters:
        - $ref: "#/components/parameters/capteurID"
        - name: token
          in: query
          required: true
          schema:
            type: string
        - name: room
          in: query
          description: Type de pièce
          required: true
          schema:
            type: string
            example: "Chambre 1"
      responses:
        "200":
          description: Pièce mise à jour
        "401":
          description: Token invalide

  /capteurs/changeHeight:
    post:
      tags:
        - configuration
      summary: Définir l'étage
      description: |
        Définit l'étage où se situe le capteur.

        **Token capteur requis.**
      operationId: changeHeight
      parameters:
        - $ref: "#/components/parameters/capteurID"
        - name: token
          in: query
          required: true
          schema:
            type: string
        - name: height
          in: query
          description: Numéro d'étage
          required: true
          schema:
            type: string
            example: "2"
      responses:
        "200":
          description: Étage mis à jour
        "401":
          description: Token invalide

  /capteurs/link:
    post:
      tags:
        - configuration
      summary: Lier ModuleAir et NebuleAir
      description: |
        Lie un capteur ModuleAir (intérieur) à un NebuleAir (extérieur)
        pour comparer les mesures PM intérieur/extérieur.

        **Token ModuleAir requis.**
      operationId: link
      parameters:
        - name: capteurID_ModuleAir
          in: query
          required: true
          schema:
            type: string
        - name: token
          in: query
          description: Token du ModuleAir
          required: true
          schema:
            type: string
        - name: capteurID_NebuleAir
          in: query
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Capteurs liés
        "401":
          description: Token invalide

  /capteurs/displayMap:
    post:
      tags:
        - configuration
      summary: Afficher/masquer sur OpenAirMap
      description: |
        Active ou désactive l'affichage d'un NebuleAir sur la carte OpenAirMap.

        **Token capteur requis.**
      operationId: displayMap
      parameters:
        - $ref: "#/components/parameters/capteurID"
        - name: token
          in: query
          required: true
          schema:
            type: string
        - name: displayMap
          in: query
          required: true
          schema:
            type: string
            enum: [OUI, NON]
      responses:
        "200":
          description: Paramètre mis à jour

  # ============================================
  # CONTEXT ENDPOINTS
  # ============================================

  /context/get_context:
    get:
      tags:
        - context
      summary: Récupérer les contextes / signalements
      description: |
        Événements contextuels (incendie, trafic, chantier, …) qui aident à lire les mesures.

        **Deux modes :**

        1. **Par capteur** : `?capteur_id=nebuleair-166`. Vaut aussi pour un **MobileAir** :
           `?capteur_id=mobileair-012` renvoie les signalements posés depuis l'app Cyan Sensor
           sur ses relevés (`datetime_start` = `datetime_stop` = l'instant du relevé signalé).
           Le serveur ne stocke pas de position pour ceux-là : la retrouver dans
           `/capteurs/dataMobileAir` par le point le plus proche dans le temps.
        2. **Points géolocalisés** : `?geoloc=true` (signalements posés sur la carte).

        La clé `photos` n'est présente que sur les contextes qui en portent ; `link` de même
        (aperçu d'un article joint par la modération). Dates en **UTC**, ISO 8601.
      operationId: getContext
      parameters:
        - name: capteur_id
          in: query
          description: Identifiant du capteur (mode capteur)
          schema:
            type: string
            example: nebuleair-166
        - name: geoloc
          in: query
          description: Récupérer les points géolocalisés (mode carte)
          schema:
            type: boolean
            example: true
        - name: start
          in: query
          description: Filtre `datetime_start >= start` (ISO 8601)
          schema:
            type: string
            format: date-time
            example: "2024-01-01T00:00:00Z"
        - name: end
          in: query
          description: Filtre `datetime_stop <= end` (ISO 8601)
          schema:
            type: string
            format: date-time
            example: "2024-12-31T23:59:59Z"
      responses:
        "200":
          description: Contextes récupérés
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Context"
              examples:
                capteurMode:
                  summary: Mode capteur
                  value:
                    - id: 1201
                      capteur_id: nebuleair-166
                      datetime_start: "2024-09-01T00:00:00Z"
                      datetime_stop: "2024-09-01T23:59:59Z"
                      context_type: works
                      user: username
                      comments: travaux sur la route
                      resolved: false
                mobileAirMode:
                  summary: Signalement sur un relevé MobileAir, avec photo
                  value:
                    - id: 1234
                      capteur_id: mobileair-012
                      datetime_start: "2026-09-15T08:12:40Z"
                      datetime_stop: "2026-09-15T08:12:40Z"
                      context_type: traffic
                      user: paul
                      comments: camion à l'arrêt moteur allumé
                      resolved: false
                      photos:
                        - url: https://api.aircarto.fr/uploads/context/2026/09/a3f9c2….jpg
                          width: 1200
                          height: 1600
                          bytes: 415052
                geolocMode:
                  summary: Mode géolocalisation
                  value:
                    - id: 987
                      capteur_id: sig-4f2a9c
                      datetime_start: "2024-09-01T00:00:00Z"
                      datetime_stop: "2024-09-01T23:59:59Z"
                      context_type: fire
                      user: username
                      comments: fumée visible au nord
                      lat: 48.8566
                      long: 2.3522
                      resolved: true
        "400":
          description: Paramètre manquant (capteur_id ou geoloc=true requis)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /context/post_context:
    post:
      tags:
        - context
      summary: Créer un contexte / signalement
      description: |
        Ajoute un message de contextualisation des mesures. Anonyme par conception ;
        limité à 10 envois par minute et par IP.

        **Deux modes :**

        1. **Lié à un capteur** : fournir `capteur_id` (NebuleAir, ModuleAir ou MobileAir)
        2. **Point géolocalisé** : fournir `geoloc: true` + `lat` + `long`

        **Fuseaux horaires :** si `timezone` (IANA) est fourni, les dates sont converties de ce
        fuseau vers UTC ; sinon elles sont considérées en UTC.

        La réponse porte l'`id` de la ligne créée : c'est la clé pour y joindre une photo
        avec `/context/upload_photo`.
      operationId: postContext
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContextInput"
            examples:
              capteurMode:
                summary: Lié à un capteur
                value:
                  capteur_id: nebuleair-166
                  datetime_start: "2024-09-01T14:00:00"
                  datetime_end: "2024-09-01T15:59:59"
                  timezone: Europe/Paris
                  context_type: industrial
                  comments: fumée observée depuis l'usine
                  user: jean_dupont
              mobileAirMode:
                summary: Signalement sur un relevé MobileAir (ce qu'envoie l'app)
                value:
                  capteur_id: mobileair-012
                  datetime_start: "2026-09-15T10:12:40"
                  datetime_end: "2026-09-15T10:12:40"
                  timezone: Europe/Paris
                  context_type: traffic
                  comments: camion à l'arrêt moteur allumé
                  user: paul
              geolocMode:
                summary: Point géolocalisé
                value:
                  geoloc: true
                  lat: 48.8566
                  long: 2.3522
                  datetime_start: "2024-09-01T14:00:00"
                  datetime_end: "2024-09-01T15:59:59"
                  timezone: Europe/Paris
                  context_type: fire
                  comments: incendie de forêt
                  user: jean_dupont
      responses:
        "200":
          description: Contexte créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Event recorded successfully
                  id:
                    type: integer
                    description: Id de la ligne créée (à passer à `/context/upload_photo`)
                    example: 1234
        "400":
          description: Données invalides
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: false
                  error:
                    type: string
              examples:
                missingField:
                  value:
                    success: false
                    error: Invalid or missing datetime_start
                invalidContextType:
                  value:
                    success: false
                    error: "Invalid context_type"
        "429":
          description: Plus de 10 envois par minute pour cette IP (`Retry-After`)

  /context/upload_photo:
    post:
      tags:
        - context
      summary: Joindre une photo à un contexte
      description: |
        Attache un JPEG à un contexte **déjà créé** (l'`id` renvoyé par `post_context`).
        Une photo par ligne de contexte. Les métadonnées EXIF/GPS sont purgées côté serveur.
        Renvoyer la même image sur le même contexte est idempotent (`duplicate: true`).
        Limité à 10 photos par 10 minutes et par IP.

        La photo ressort ensuite dans `get_context`, clé `photos`, avec une URL publique.
      operationId: uploadContextPhoto
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [context_id, photo]
              properties:
                context_id:
                  type: integer
                  description: Id renvoyé par `post_context`
                photo:
                  type: string
                  format: binary
                  description: JPEG uniquement, 2 Mo max, entre 200 et 2400 px
      responses:
        "200":
          description: Photo jointe
          content:
            application/json:
              example:
                ok: true
                context_id: 1234
                photo: { url: "https://api.aircarto.fr/uploads/context/2026/09/a3f9c2….jpg", width: 1200, height: 1600, bytes: 415052 }
        "400":
          description: "`invalid_context_id`, `no_file`, `upload_error`"
        "404":
          description: "`context_not_found`"
        "409":
          description: "`photo_limit_reached` : ce contexte a déjà sa photo"
        "413":
          description: "`too_large` : plus de 2 Mo"
        "415":
          description: "`not_an_image`, `jpeg_only`"
        "422":
          description: "`too_small`, `too_large_dim` : hors bornes 200–2400 px"
        "429":
          description: "`rate_limited`"
        "503":
          description: "`db_unavailable`, `photos_unavailable`"

  # ============================================
  # OPENAIRMAP ENDPOINTS
  # ============================================

  /openairmap/dataBoats:
    get:
      tags:
        - openairmap
      summary: Trajectoires des bateaux (AIS)
      description: |
        Pour chaque navire vu dans la fenêtre demandée : positions échantillonnées et dernières
        infos connues (nom, type AIS, destination, dimensions, cap, vitesse). Lecture seule.
      operationId: dataBoats
      parameters:
        - name: hours
          in: query
          description: Profondeur du tracé en heures (0.5 = 30 min). Borné entre 0.1 et 168.
          schema:
            type: number
            default: 2
          example: 1
        - name: every
          in: query
          description: Finesse des points, un point par tranche. Si absent, choisi selon `hours`.
          schema:
            type: string
            enum: [10s, 30s, 60s, 120s, 300s, 600s, 900s, 1800s, 3600s]
          example: 30s
      responses:
        "200":
          description: Navires et leurs positions, ordre chronologique
          content:
            application/json:
              example:
                now: 1718123456789
                hours: 2
                boats:
                  - id: "244110290"
                    points: [[1718116256789, 43.2951, 5.3612], [1718116286789, 43.2953, 5.3609]]
                    label: { name: DANIELLE CASANOVA, ship_type: 60, class: A, nav_status: 5, destination: AJACCIO, length: 176, width: 30, heading: 312, cog: 308.4, sog: 0.1 }
        "500":
          description: "`influx_query_failed`"

  /openairmap/dataPlanes:
    get:
      tags:
        - openairmap
      summary: Trajectoires des avions (ADS-B)
      description: |
        Pour chaque aéronef vu dans la fenêtre demandée : positions échantillonnées et dernières
        infos connues (indicatif, immatriculation, type, altitude). Lecture seule.
      operationId: dataPlanes
      parameters:
        - name: hours
          in: query
          description: Profondeur du sillage en heures. Borné entre 1/60 et 168.
          schema:
            type: number
            default: 1
        - name: every
          in: query
          description: Finesse des points, un point par tranche. Si absent, choisi selon `hours`.
          schema:
            type: string
            enum: [10s, 30s, 60s, 120s, 300s, 600s, 900s, 1800s, 3600s]
          example: 30s
      responses:
        "200":
          description: Aéronefs et leurs positions, ordre chronologique
          content:
            application/json:
              example:
                now: 1718123456789
                hours: 1
                every: 30s
                planes:
                  - id: "0a0049"
                    points: [[1718119856789, 43.44, 5.22], [1718119886789, 43.41, 5.25]]
                    label: { callsign: DAH1155, reg: 7T-VKA, type: B738, type_desc: BOEING 737-800, category: A3, track: 178.4, gs: 494.2, alt_baro: 33000, on_ground: false }
        "500":
          description: "`influx_query_failed`"

  # ============================================
  # TP IUT AIX-MARSEILLE
  # ============================================

  /iut-amu/push:
    servers:
      - url: http://api.aircarto.fr
        description: HTTP simple, pour les cartes sans TLS (ESP32, SIM7600)
      - url: https://api.aircarto.fr
        description: HTTPS
    post:
      tags:
        - iut-amu
      summary: Envoyer des mesures
      description: |
        Une mesure par envoi, datée à la réception par le serveur.

        - **Clé** : en-tête `X-Group-Key`, ou `?key=` dans l'URL.
        - **`device_id`** (obligatoire) : adresse MAC de la carte (`WiFi.macAddress()`), avec ou
          sans `:`. Identifie la carte ; c'est la clé qui authentifie le groupe.
        - **Mesures** (facultatives, au moins une) : `pm1`, `pm25`, `pm10` (µg/m³, 0 à 1000),
          `temp` (°C, -40 à 85), `hum` (%, 0 à 100), `pressure` (hPa, 300 à 1100),
          `co2` (ppm, 0 à 10000), `uptime` (s, `millis()/1000`), `rssi` (dBm, -120 à 0,
          `WiFi.RSSI()`), `heap` (octets, `ESP.getFreeHeap()`), `npm_status` (entier 0 à 255, registre
          d'état du NextPM ; 0 = OK, 255 = capteur muet). Un champ absent n'est pas une
          erreur (un NebuleAir n'envoie pas de `co2`).
          Champs libres : `x_votre_nom` (minuscules, 10 au plus, nombre fini, ±10^12).
        - **Nombres JSON** uniquement : `"pm25": 12.3`, pas `"pm25": "12.3"`, ni `null`, ni `nan`.
          Capteur non lu : ne pas envoyer le champ.
        - **Débit** : un envoi accepté toutes les 10 s par groupe (réglage par groupe).
        - Une seule mesure invalide fait refuser **tout** l'envoi, avec un message qui dit
          quoi corriger.

        Chaque requête, acceptée ou non, est visible dans `/iut-amu/last`.
      operationId: iutAmuPush
      parameters:
        - $ref: "#/components/parameters/iutAmuKeyHeader"
        - $ref: "#/components/parameters/iutAmuKeyQuery"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/IutAmuPoint"
            examples:
              une_mesure:
                summary: Une mesure
                value: { device_id: "A4:CF:12:34:56:78", pm1: 4.1, pm25: 7.8, pm10: 11.2, temp: 21.4, hum: 48, pressure: 1013.2, uptime: 3600, rssi: -67, heap: 182344 }
      responses:
        "201":
          description: Mesures enregistrées
          content:
            application/json:
              example: { ok: true, message: "1 mesure enregistrée.", group: g03, device_type: moduleair, points_written: 1, device_id: A4CF12345678, fields: [pm25, temp, rssi], first_ts: 1790605783, last_ts: 1790605783, server_time: 1790605783, next_push_in_s: 10 }
        "400":
          description: |
            Corps refusé : `empty_body`, `invalid_json`, `invalid_point`, `empty_point`,
            `unknown_field`, `too_many_fields`, `not_a_number`, `out_of_range`,
            `missing_device_id`, `bad_device_id`. Le corps JSON (`ok`, `error`, `message`) est toujours renvoyé avec un
            `Content-Length` : lisible par l'ESP (`http.getString()`) pour l'afficher sur le
            moniteur série.
          content:
            application/json:
              example: { ok: false, error: not_a_number, message: "\"pm25\" est une chaîne (\"12.3\") : envoyez le nombre sans guillemets, \"pm25\": 12.3." }
        "401":
          description: "`missing_key`, `invalid_key`, `key_revoked`, `key_expired`"
        "405":
          description: "`method_not_allowed` (POST attendu)"
        "413":
          description: "`too_large` : corps de plus de 16 Ko"
        "429":
          description: "`rate_limited` : envoi trop rapproché. `retry_after_s` et l'en-tête `Retry-After` donnent l'attente."
          content:
            application/json:
              example: { ok: false, error: rate_limited, message: "Trop rapide : un envoi toutes les 10 s au plus. Réessayez dans 4 s, ou regroupez vos mesures dans \"points\".", retry_after_s: 4 }
        "502":
          description: "`database_unavailable`, `storage_unavailable` : incident serveur, réessayer"

  /iut-amu/last:
    get:
      tags:
        - iut-amu
      summary: Dernières requêtes reçues (débogage)
      description: |
        Ce que le serveur a reçu de votre groupe sur `/iut-amu/push`, acceptées ou non : corps
        exact, code HTTP, message d'erreur. À ouvrir dans un navigateur à côté du moniteur
        série : `https://api.aircarto.fr/iut-amu/last?key=<votre clé>`.

        Au-delà de 30 requêtes par minute, les suivantes ne sont plus journalisées (elles
        reçoivent quand même leur réponse).
      operationId: iutAmuLast
      parameters:
        - $ref: "#/components/parameters/iutAmuKeyHeader"
        - $ref: "#/components/parameters/iutAmuKeyQuery"
        - name: limit
          in: query
          description: Nombre de requêtes renvoyées (1 à 100)
          schema:
            type: integer
            default: 20
      responses:
        "200":
          description: Groupe, bilan de la dernière heure et dernières requêtes (plus récente d'abord)
          content:
            application/json:
              example:
                ok: true
                group: { slug: g03, label: Les Capteurs Fous, device_type: moduleair, school_year: 2026-2027, min_interval_s: 10, expires_at: null, last_push_at: "2026-10-05 09:12:31+00", last_seen_at: "2026-10-05 09:12:35+00" }
                last_hour: { accepted_requests: 42, refused_requests: 3, points_written: 42 }
                server_time: 1791191555
                requests:
                  - { received_at: "2026-10-05 09:12:35+00", http_status: 400, ok: false, error: not_a_number, message: "\"pm25\" vaut null : capteur non lu ? Retirez le champ plutôt que d'envoyer null.", points_written: 0, duration_ms: 4, content_type: application/json, user_agent: ESP32HTTPClient, body: "{\"pm25\":null,\"temp\":21.4}" }
        "400":
          description: "`invalid_limit`"
        "401":
          description: "`missing_key`, `invalid_key`, `key_revoked`, `key_expired`"

  /iut-amu/data:
    get:
      tags:
        - iut-amu
      summary: Lire les mesures du groupe
      description: |
        Mesures enregistrées, en JSON ou CSV. Sans `freq` : points bruts, 7 jours au plus.
        Avec `freq` (moyenne par tranche) : 31 jours au plus.
      operationId: iutAmuData
      parameters:
        - $ref: "#/components/parameters/iutAmuKeyHeader"
        - $ref: "#/components/parameters/iutAmuKeyQuery"
        - name: start
          in: query
          description: Relatif (`-30m`, `-6h`, `-7d`) ou ISO 8601 UTC
          schema:
            type: string
            default: "-1h"
        - name: end
          in: query
          description: "`now`, relatif ou ISO 8601 UTC"
          schema:
            type: string
            default: now
        - name: device_id
          in: query
          description: Ne garder qu'une carte (adresse MAC, avec ou sans `:`)
          schema:
            type: string
        - name: freq
          in: query
          description: Moyenne par tranche (`5m`, `1h`, `1d`)
          schema:
            type: string
        - name: format
          in: query
          schema:
            type: string
            enum: [JSON, CSV]
            default: JSON
      responses:
        "200":
          description: Points dans l'ordre chronologique ; un champ absent d'une mesure n'apparaît pas dans son point
          content:
            application/json:
              example:
                ok: true
                group: g03
                start: "2026-10-05T08:00:00Z"
                end: "2026-10-05T09:00:00Z"
                freq: null
                fields: [device_id, pm25, temp, co2, rssi]
                count: 2
                points:
                  - { time: "2026-10-05T08:00:12Z", device_id: A4CF12345678, pm25: 5, co2: 600 }
                  - { time: "2026-10-05T08:00:22Z", device_id: A4CF12345678, pm25: 3.2, temp: 21.4, rssi: -67 }
            text/csv:
              example: |
                time,device_id,pm25,temp,co2,rssi
                2026-10-05T08:00:12Z,A4CF12345678,5,,600,
                2026-10-05T08:00:22Z,A4CF12345678,3.2,21.4,,-67
        "400":
          description: "`invalid_range`, `invalid_freq`, `range_too_large`, `invalid_format`, `bad_device_id`, `unknown_param` (`device` au lieu de `device_id`)"
        "401":
          description: "`missing_key`, `invalid_key`, `key_revoked`, `key_expired`"
        "502":
          description: "`database_unavailable`, `storage_unavailable`"

# ============================================
# COMPONENTS
# ============================================

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Token JWT obtenu via `/auth/login.php`.

        Expire après 24 heures.
    sensorToken:
      type: apiKey
      in: query
      name: token
      description: Token du capteur (inscrit au dos de l'appareil ModuleAir)

  parameters:
    iutAmuKeyHeader:
      name: X-Group-Key
      in: header
      description: Clé du groupe (32 caractères), donnée par l'enseignant. Ou `?key=`.
      schema:
        type: string
    iutAmuKeyQuery:
      name: key
      in: query
      description: Clé du groupe, si l'en-tête `X-Group-Key` n'est pas utilisable.
      schema:
        type: string
    capteurID:
      name: capteurID
      in: query
      description: Identifiant du capteur
      required: true
      schema:
        type: string
      example: nebuleair-025
    start:
      name: start
      in: query
      description: |
        Début de la période. Formats acceptés :
        - Relatif : `-1h`, `-24h`, `-7d`, `-30d`
        - ISO 8601 : `2024-01-15T10:30:00Z`
      required: true
      schema:
        type: string
      example: "-24h"
    end:
      name: end
      in: query
      description: |
        Fin de la période. Formats acceptés :
        - `now` ou `now()`
        - ISO 8601 : `2024-01-16T10:30:00Z`
      schema:
        type: string
        default: "now()"
      example: "now()"
    freq:
      name: freq
      in: query
      description: |
        Pas de temps pour l'agrégation (moyenne).

        Exemples : `15m`, `1h`, `1d`
      schema:
        type: string
      example: "1h"
    format:
      name: format
      in: query
      description: Format de sortie
      schema:
        type: string
        enum: [JSON, GeoJSON, CSV]
        default: JSON

  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
          description: ID unique de l'utilisateur
          example: 1
        username:
          type: string
          description: Nom d'utilisateur
          example: john_doe
        email:
          type: string
          format: email
          example: john@example.com
        full_name:
          type: string
          example: John Doe
        role:
          type: string
          enum: [client, admin]
          example: client

    AuthResponse:
      type: object
      properties:
        message:
          type: string
          example: Login successful
        token:
          type: string
          description: Token JWT (valide 24h)
          example: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
        user:
          $ref: "#/components/schemas/User"

    ConfiguredSensor:
      type: object
      description: Capteur configuré par l'utilisateur
      properties:
        id:
          type: string
          description: Identifiant du capteur
          example: nebuleair-025
        name:
          type: string
          description: Nom personnalisé donné par l'utilisateur
          example: Salon
        type:
          type: string
          enum: [NebuleAir, ModuleAir, MobileAir]
          example: NebuleAir
        token:
          type: string
          description: Token du capteur (pour ModuleAir uniquement)
          example: abc123xyz

    SensorMetadata:
      type: object
      properties:
        id:
          type: integer
        nom:
          type: string
          example: nebuleair-025
        capteur_type:
          type: string
          example: NebuleAir
        lat:
          type: number
          format: float
          example: 43.2965
        long:
          type: number
          format: float
          example: 5.3698
        last_seen:
          type: string
          format: date-time
        pm25_last:
          type: number
          example: 8.5
        temp_last:
          type: number
          example: 22.5

    NebuleAirReading:
      type: object
      properties:
        time:
          type: string
          format: date-time
          example: "2024-12-15T10:30:00Z"
        sensorId:
          type: string
          example: nebuleair-025
        PM1:
          type: number
          nullable: true
          example: 5.23
        PM25:
          type: number
          nullable: true
          example: 8.45
        PM10:
          type: number
          nullable: true
          example: 12.67
        TEMP:
          type: number
          nullable: true
          description: Température (°C)
          example: 22.5
        HUM:
          type: number
          nullable: true
          description: Humidité (%)
          example: 45.2
        PRESS:
          type: number
          nullable: true
          description: Pression atmosphérique (hPa)
          example: 1013.25
        NOISE:
          type: number
          nullable: true
          description: Niveau sonore (dB)
          example: 42.3
        COV:
          type: number
          nullable: true
          description: Composés Organiques Volatils
          example: 125
        error_flags:
          type: integer
          nullable: true
          description: Flags d'erreur (nécessite errors=true)
        npm_status:
          type: integer
          nullable: true
          description: Statut du capteur NPM (nécessite errors=true)
        device_status:
          type: integer
          nullable: true
          description: Statut du device (nécessite errors=true)

    ModuleAirReading:
      type: object
      properties:
        time:
          type: string
          format: date-time
        sensorId:
          type: string
        CO2:
          type: number
          nullable: true
          description: CO2 (ppm)
          example: 420
        COV:
          type: number
          nullable: true
        PM1:
          type: number
          nullable: true
        PM25:
          type: number
          nullable: true
        PM10:
          type: number
          nullable: true
        TEMP:
          type: number
          nullable: true
        HUM:
          type: number
          nullable: true

    MobileAirReading:
      type: object
      description: Un point de mesure MobileAir (`/capteurs/dataMobileAir`)
      properties:
        time:
          type: string
          format: date-time
          example: "2026-09-15T08:12:40Z"
        sensorId:
          type: string
          example: mobileair-012
        sessionId:
          type: integer
          nullable: true
          description: Relevé (trajet ou mesure fixe) auquel appartient le point, epoch de début en secondes
          example: 1757923800
        sat:
          type: number
          nullable: true
          description: Satellites GPS (MobileAir LTE uniquement)
        PM1:
          type: number
          nullable: true
          example: 5.2
        PM25:
          type: number
          nullable: true
          example: 8.4
        PM10:
          type: number
          nullable: true
          example: 12.6
        TEMP:
          type: number
          nullable: true
          description: Température interne du boîtier (°C), pas l'air ambiant
        HUM:
          type: number
          nullable: true
          description: Humidité interne du boîtier (%)
        lat:
          type: number
          nullable: true
          example: 43.2901
        lon:
          type: number
          nullable: true
          example: 5.3701
        acc:
          type: number
          nullable: true
          description: Précision GPS (m)
          example: 8.5
        moving:
          type: integer
          nullable: true
          description: "Mode de mesure : 0 à pied, 1 vélo, 2 voiture, 3 transports en commun, 4 mesure fixe, null inconnu"
          enum: [0, 1, 2, 3, 4, null]
          example: 4

    MobileAirLive:
      type: object
      description: Un MobileAir actif et ses derniers points (`/capteurs/liveMobileAir`)
      properties:
        id:
          type: integer
          example: 976
        sensorId:
          type: string
          description: Nom du capteur (à passer à `/context/get_context`)
          example: mobileair-032
        sensorToken:
          type: string
          description: Token du capteur (à passer à `/capteurs/dataMobileAir` pour le trajet complet)
          example: 6C1DEB1385B2
        lastSeen:
          type: string
          format: date-time
          description: Dernière donnée reçue (UTC)
          example: "2026-09-24T13:31:51Z"
        lastSeenSec:
          type: integer
          description: Âge de la dernière donnée en secondes au moment de l'appel
          example: 42
        fixed:
          type: boolean
          nullable: true
          description: "`true` mesure fixe (1 point), `false` en mobilité (3 points), `null` aucun point géolocalisé"
          example: true
        sessionId:
          type: integer
          nullable: true
          description: Relevé en cours (celui du dernier point), epoch de début en secondes
          example: 1790256274
        moving:
          type: integer
          nullable: true
          description: Mode de mesure du dernier point, même vocabulaire que `MobileAirReading.moving`
          enum: [0, 1, 2, 3, 4, null]
          example: 4
        points:
          type: array
          description: Derniers points géolocalisés, ordre chronologique (1 en mesure fixe, 3 au plus en mobilité)
          items:
            $ref: "#/components/schemas/MobileAirReading"

    MobileAirPush:
      type: object
      required: [token, measures]
      properties:
        token:
          type: string
          description: Adresse MAC du capteur, 12 caractères hexadécimaux
          example: E45293E0AF79
        app:
          type: object
          description: Journal seulement
          properties:
            platform: { type: string, example: ios }
            version: { type: string, example: "1.2.0" }
        measures:
          type: array
          maxItems: 2000
          items:
            type: object
            required: [ts, pm1, pm25, pm10]
            properties:
              ts: { type: integer, description: Epoch UTC en secondes, posé par l'horloge du capteur }
              pm1: { type: number }
              pm25: { type: number }
              pm10: { type: number }
              temp: { type: number, description: Température interne du boîtier }
              hum: { type: number, description: Humidité interne du boîtier }
              lat: { type: number }
              lon: { type: number }
              acc: { type: number, description: Précision GPS (m) }
              session: { type: integer, description: Epoch de début du relevé }
              moving: { type: integer, enum: [0, 1, 2, 3, 4], description: "0 à pied, 1 vélo, 2 voiture, 3 transports en commun, 4 mesure fixe" }

    ContextPhoto:
      type: object
      properties:
        url:
          type: string
          format: uri
          description: URL publique du JPEG (métadonnées purgées)
          example: https://api.aircarto.fr/uploads/context/2026/09/a3f9c2….jpg
        width: { type: integer, example: 1200 }
        height: { type: integer, example: 1600 }
        bytes: { type: integer, example: 415052 }

    Context:
      type: object
      properties:
        id:
          type: integer
          description: Id de la ligne (clé de `/context/upload_photo`)
          example: 1234
        capteur_id:
          type: string
          description: Nom du capteur (mode capteur), ou identifiant de fil `sig-…` (mode geoloc)
          example: mobileair-012
        datetime_start:
          type: string
          format: date-time
          example: "2024-09-01T00:00:00Z"
        datetime_stop:
          type: string
          format: date-time
          example: "2024-09-01T23:59:59Z"
        context_type:
          type: string
          enum: [fire, industrial, traffic, neighbourhood, works, cleaning, cooking, meeting, fault, other, ""]
          example: traffic
        user:
          type: string
          example: username
        comments:
          type: string
          example: embouteillage observé
        resolved:
          type: boolean
          description: Commentaire de clôture (événement déclaré terminé)
          example: false
        lat:
          type: number
          description: Latitude (mode geoloc uniquement)
          example: 48.8566
        long:
          type: number
          description: Longitude (mode geoloc uniquement)
          example: 2.3522
        photos:
          type: array
          description: Présent uniquement si le contexte porte au moins une photo
          items:
            $ref: "#/components/schemas/ContextPhoto"
        link:
          type: object
          description: Présent uniquement si la modération a joint un article (aperçu Open Graph)
          properties:
            url: { type: string, format: uri }
            og_title: { type: string }
            og_description: { type: string }
            og_image: { type: string, format: uri }
            og_site_name: { type: string }

    ContextInput:
      type: object
      required:
        - datetime_start
        - datetime_end
      properties:
        capteur_id:
          type: string
          maxLength: 100
          pattern: "^[a-zA-Z0-9_-]+$"
          description: Nom du capteur (requis si geoloc=false), ex. `nebuleair-166`, `mobileair-012`
        geoloc:
          type: boolean
          description: Mode point géolocalisé
          default: false
        lat:
          type: number
          description: Latitude (requis si geoloc=true)
          minimum: -90
          maximum: 90
        long:
          type: number
          description: Longitude (requis si geoloc=true)
          minimum: -180
          maximum: 180
        datetime_start:
          type: string
          description: Début de l'événement, `YYYY-MM-DDTHH:MM:SS` (+ `Z` si UTC)
        datetime_end:
          type: string
          description: Fin de l'événement (égal au début pour un signalement ponctuel)
        timezone:
          type: string
          description: |
            Fuseau horaire des dates (format IANA).
            Si absent, les dates sont considérées en UTC.
          example: Europe/Paris
        context_type:
          type: string
          enum: [fire, industrial, traffic, neighbourhood, works, cleaning, cooking, meeting, fault, other]
          description: Type d'événement
        comments:
          type: string
          maxLength: 1000
          description: Description de l'événement
        user:
          type: string
          maxLength: 100
          description: Nom de l'utilisateur
          default: anonymous
        resolved:
          type: boolean
          default: false
          description: Commentaire de clôture d'un fil de signalement

    IutAmuPoint:
      type: object
      description: Une mesure. `device_id` et au moins une mesure ; les champs absents ne sont pas enregistrés.
      required: [device_id]
      properties:
        pm1: { type: number, minimum: 0, maximum: 1000, description: µg/m³ }
        pm25: { type: number, minimum: 0, maximum: 1000, description: µg/m³ }
        pm10: { type: number, minimum: 0, maximum: 1000, description: µg/m³ }
        temp: { type: number, minimum: -40, maximum: 85, description: °C }
        hum: { type: number, minimum: 0, maximum: 100, description: "%" }
        pressure: { type: number, minimum: 300, maximum: 1100, description: hPa }
        co2: { type: number, minimum: 0, maximum: 10000, description: ppm }
        uptime: { type: number, minimum: 0, maximum: 315360000, description: "s depuis le démarrage (millis()/1000)" }
        rssi: { type: number, minimum: -120, maximum: 0, description: "dBm (WiFi.RSSI())" }
        heap: { type: number, minimum: 0, maximum: 100000000, description: "octets libres (ESP.getFreeHeap())" }
        npm_status: { type: integer, minimum: 0, maximum: 255, description: "État du NextPM (registre 0x13) : 0 = OK, 255 = capteur muet" }
        device_id: { type: string, example: "A4:CF:12:34:56:78", description: "Obligatoire. Adresse MAC de la carte (WiFi.macAddress()), avec ou sans ':'" }
      additionalProperties:
        type: number
        description: "Champs libres `x_[a-z0-9_]{1,30}` uniquement (10 au plus)"

    Error:
      type: object
      properties:
        error:
          type: string
          description: Message d'erreur
          example: Invalid request

externalDocs:
  description: Page d'accueil de l'API (export CSV guidé, vue d'ensemble)
  url: https://api.aircarto.fr/
