Richard Dern

Utiliser un GPS en I²C sur ESP32 avec ESPHome

Utiliser un GPS en I²C sur ESP32 avec ESPHome

J’ai à ma disposition un ESP32-S3 et un module GPS PA1010D de chez Adafruit. Ça fait un moment que je cherche à m’en servir, mais le composant ESPHome pour le GPS est limité à l’UART. Or, Adafruit fournit une connectique STEMMA QT qui exploite l’I²C.

Le Feather ESP32-S3 que j’utilise.

Attribution : www.adafruit.com

Le module GPS est le seul qui nous intéresse ici, mais ce montage s’inscrit dans un projet plus vaste, faisant appel à plusieurs modules connectés en STEMMA QT. Je veux donc garder le montage propre, et éviter les soudures à la main. En outre, je veux vraiment utiliser ESPHome pour simplifier l’intégration de mon projet dans mon écosystème domotique sous Home Assistant.

Malheureusement, je n’ai pas les compétences pour développer un composant ESPHome, ou pour étendre un composant existant. J’ajoute que je n’avais pas envie de créer tout un dépôt rien que pour ça, alors qu’ESPHome est parfaitement capable de gérer des expressions lambda. J’ai donc mis ChatGPT au travail pour me pondre un yaml approprié.

Le PA1010D

Le module GPS PA1010D d’Adafruit que j’utilise dans mon projet.

Attribution : www.adafruit.com

C’est un petit module GNSS de 25 mm de côté environ, doté d’une antenne patch intégrée, basé sur un chipset MT3333, compatible GPS, GLONASS, Galileo et QZSS. Adafruit indique une sensibilité de suivi de -165 dBm, une consommation de l’ordre de 30 mA pendant la navigation et une cadence pouvant aller jusqu’à 10 mises à jour par seconde. Le module expose à la fois une interface UART et une interface I²C. Adafruit précise aussi qu’il est compatible avec une pile RTC de type CR1220 pour les démarrages à chaud, et qu’il dispose d’une LED rouge qui s’illumine quand un fix est acquis, en plus de la LED verte de mise sous tension.

J’ai intégré ce composant à mon projet pour répondre à plusieurs attentes. La plus poétique d’entre elles est de voir interagir le matériel et le logiciel avec le monde réel, mais de façon plus prosaïque, je veux pouvoir récupérer une horloge de référence, et obtenir les coordonnées GPS les plus précises possibles à partir d’un capteur aussi modeste, ainsi que son altitude.

ESPHome et le GPS

Le composant GPS d’ESPHome, on l’a dit, nécessite la configuration d’une interface UART. Il fournit ensuite les capteurs suivants :

Or, là encore comme on l’a déjà dit, je veux travailler en I²C. Par conséquent, je ne vais pas pouvoir exploiter le composant natif d’ESPHome. En revanche, les templates et les expressions lambda vont me permettre d’obtenir les mêmes informations (et peut-être davantage) par d’autres moyens.

MT3333 et PMTK

Le chipset Mediatek MT3333 sur lequel s’appuie le PA1010D exploite un protocole propriétaire (les commandes PMTK), qui permet la configuration du module. Les deux commandes les plus importantes sont :

La documentation du protocole indique 19 phrases NMEA supportées, dont certaines sont “réservées”. De plus, il faut s’attendre à ce que notre module ne produise aucune réponse pour une phrase donnée. À partir de là, on va simplement activer toutes les phrases, de façon à voir ce que le module peut effectivement nous retourner.

PMTK étant un protocole propriétaire, il est relativement compliqué de savoir exactement ce que le module peut faire. Le document fourni par Adafruit en version A11 est un socle, sur lequel viennent se greffer diverses évolutions en fonction du firmware qui anime le module. Par exemple, Sparkfun propose une mise à jour de ce document datant de 2016. En pratique évidemment, on n’a pas besoin d’aller fouiller les commandes disponibles : seules celles indiquées ci-dessus sont utiles pour répondre à mon cahier des charges. Il n’empêche que j’ai envie de procéder de façon méthodique : on doit avant tout obtenir la version du firmware du module, grâce à la commande $PMTK605, qui devrait répondre avec un paquet $PMTK705.

On peut déterminer la liste complète des paquets, mais tous ne sont pas disponibles sur tous les modules GPS.

Commande Type Explication
PMTK000 SYS Paquet de test.
PMTK001 ACK Accusé de réception d’une commande PMTK (succès ou erreur).
PMTK010 SYS Message système émis par le module.
PMTK011 SYS Message texte émis par le module.
PMTK101 SET Redémarrage à chaud (hot start).
PMTK102 SET Redémarrage à tiède (warm start).
PMTK103 SET Redémarrage à froid (cold start).
PMTK104 SET Redémarrage complet avec reset des paramètres.
PMTK220 SET Définit l’intervalle de mise à jour NMEA.
PMTK400 Q Demande l’intervalle de mise à jour actuel.
PMTK500 DT Réponse contenant l’intervalle de mise à jour.
PMTK251 SET Configure le débit série du port NMEA.
PMTK301 SET Configure le mode DGPS.
PMTK401 Q Demande le mode DGPS.
PMTK501 DT Réponse indiquant le mode DGPS.
PMTK313 SET Active/désactive le SBAS.
PMTK413 Q Demande l’état du SBAS.
PMTK513 DT Réponse indiquant l’état du SBAS.
PMTK314 SET Configure les phrases NMEA émises.
PMTK414 Q Demande la configuration des phrases NMEA.
PMTK514 DT Réponse avec la configuration NMEA.
PMTK605 Q Demande la version du firmware.
PMTK705 DT Réponse contenant la version du firmware.
PMTK607 Q Demande l’état des données EPO.
PMTK707 DT Réponse sur l’état des données EPO.
PMTK127 SET Efface les données EPO.
PMTK386 SET Définit le seuil de navigation statique.
PMTK447 Q Demande le seuil de navigation statique.
PMTK527 DT Réponse contenant le seuil configuré.
PMTK161 SET Passage en mode veille.
PMTK223 SET Configure la gestion des éphémérides.
PMTK225 SET Configure le mode veille périodique.
PMTK255 SET Synchronisation PPS / NMEA.
PMTK286 SET Active/désactive l’AIC (anti-interférences).
PMTK869 SET/Q Active/désactive ou interroge EASY.
PMTK886 SET Définit le mode de navigation (piéton, véhicule…).
PMTK330 SET Configure le datum géodésique.
PMTK331 SET Définit un datum personnalisé.
PMTK431 Q Demande le datum personnalisé.
PMTK353 SET Configure les constellations GNSS utilisées.
PMTK430 Q Demande le datum courant.
PMTK530 DT Réponse avec le datum courant.
PMTK183 Q Demande l’état du logger LOCUS.
PMTK184 SET Efface la mémoire du logger LOCUS.
PMTK185 SET Active/désactive le logger LOCUS.
PMTK186 SET Force l’écriture d’un point dans le log.
PMTK187 SET Configure le logger LOCUS.
PMTK622 Q Demande le dump des données LOCUS.
PMTK602 Q Demande la configuration du port de données.
PMTK702 DT Réponse avec configuration du port de données.
PMTK285 SET Configure le signal PPS.
PMTK299 SET Active/désactive les logs de debug.
PMTK355 Q Demande la configuration GNSS.
PMTK356 SET Définit un seuil HDOP.
PMTK357 Q Demande le seuil HDOP.
PMTK435 Q Demande l’heure RTC UTC.
PMTK535 DT Réponse contenant l’heure RTC UTC.
PMTK250 SET Configure le port UART (type + débit).

Q désigne une commande de requête, DT désigne une réponse, SET désigne une commande de définition de configuration.

Configuration yaml minimale

Ce bloc de configuration est minimal pour obtenir la version du firmware du PA1010D : vous devrez le compléter avec les blocs habituels pour le wifi, la configuration de la board utilisée, l’I²C, etc. Voyez le code complet à la fin de l’article.

esphome:
  on_boot:
    - priority: 200
      then:
        - lambda: |-
            auto nmea_checksum = [](const std::string &s) -> uint8_t {
              uint8_t cs = 0;
              for (char c : s) cs ^= (uint8_t) c;
              return cs;
            };

            auto send_pmtk = [&](const std::string &body) {
              uint8_t cs = nmea_checksum(body);
              char tail[8];
              snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
              std::string line = "$" + body + tail;
              id(gps_i2c).write((const uint8_t *) line.data(), line.size());
              ESP_LOGD("gps", "TX: %s", line.c_str());
            };

            send_pmtk("PMTK605");
            delay(200);
            send_pmtk("PMTK414");
            delay(200);
            send_pmtk("PMTK447");
            delay(200);
            send_pmtk("PMTK430");
            delay(200);
            send_pmtk("PMTK607");
            delay(200);
            send_pmtk("PMTK183");
            delay(200);
            send_pmtk("PMTK602");

globals:
  - id: nmea_buf
    type: std::string
    restore_value: no
    initial_value: '""'

  - id: nmea_types_seen
    type: std::string
    restore_value: no
    initial_value: '""'

text_sensor:
  - platform: template
    name: "GPS Firmware"
    id: gps_firmware
    entity_category: diagnostic

  - platform: template
    name: "GPS Datum"
    id: gps_datum
    entity_category: diagnostic

  - platform: template
    name: "GPS EPO"
    id: gps_epo_status
    entity_category: diagnostic

  - platform: template
    name: "GPS Configuration NMEA"
    id: gps_nmea_config
    entity_category: diagnostic

  - platform: template
    name: "GPS PORT brut"
    id: gps_port_raw
    entity_category: diagnostic

interval:
  - interval: 100ms
    then:
      - lambda: |-
          uint8_t data[128];

          for (int pass = 0; pass < 4; pass++) {
            if (!id(gps_i2c).read_bytes_raw(data, sizeof(data))) {
              break;
            }

            for (size_t j = 0; j < sizeof(data); j++) {
              char c = (char) data[j];
              if (c == '\0') continue;
              id(nmea_buf).push_back(c);
            }
          }

          if (id(nmea_buf).size() > 8192) {
            id(nmea_buf).erase(0, id(nmea_buf).size() - 4096);
          }

          auto trim_crlf = [](std::string &s) {
            while (!s.empty() && (s.back() == '\r' || s.back() == '\n')) s.pop_back();
          };

          auto verify_checksum = [](const std::string &s) -> bool {
            auto star = s.find('*');
            if (s.size() < 4 || s[0] != '$' || star == std::string::npos) return false;
            if (star + 2 >= s.size()) return false;

            uint8_t cs = 0;
            for (size_t i = 1; i < star; i++) cs ^= (uint8_t) s[i];

            auto hex = [](char ch) -> int {
              if (ch >= '0' && ch <= '9') return ch - '0';
              if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10;
              if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10;
              return -1;
            };

            int hi = hex(s[star + 1]);
            int lo = hex(s[star + 2]);
            if (hi < 0 || lo < 0) return false;

            uint8_t want = (uint8_t) ((hi << 4) | lo);
            return cs == want;
          };

          auto split_csv = [](const std::string &line) -> std::vector<std::string> {
            std::vector<std::string> out;
            std::string cur;
            for (char ch : line) {
              if (ch == ',' || ch == '*') {
                out.push_back(cur);
                cur.clear();
                if (ch == '*') break;
              } else {
                cur.push_back(ch);
              }
            }
            out.push_back(cur);
            return out;
          };

          auto to_float = [](const std::string &s, float *out) -> bool {
            if (s.empty()) return false;
            char *end = nullptr;
            float v = strtof(s.c_str(), &end);
            if (end == s.c_str()) return false;
            *out = v;
            return true;
          };

          auto to_int = [](const std::string &s, int *out) -> bool {
            if (s.empty()) return false;
            char *end = nullptr;
            long v = strtol(s.c_str(), &end, 10);
            if (end == s.c_str()) return false;
            *out = (int) v;
            return true;
          };

          auto ddmm_to_deg = [](const std::string &ddmm, char hemi, float *out) -> bool {
            if (ddmm.size() < 4) return false;
            auto dot = ddmm.find('.');
            size_t min_start = (dot == std::string::npos) ? ddmm.size() - 2 : (dot >= 2 ? dot - 2 : 0);
            if (min_start < 2) return false;

            std::string deg_str = ddmm.substr(0, min_start);
            std::string min_str = ddmm.substr(min_start);

            char *end = nullptr;
            float deg = strtof(deg_str.c_str(), &end);
            if (end == deg_str.c_str()) return false;

            end = nullptr;
            float minutes = strtof(min_str.c_str(), &end);
            if (end == min_str.c_str()) return false;

            float v = deg + minutes / 60.0f;
            if (hemi == 'S' || hemi == 'W') v = -v;
            *out = v;
            return true;
          };

          auto hhmmss_to_text = [](const std::string &s) -> std::string {
            if (s.size() < 6) return "";
            char buf[16];
            snprintf(buf, sizeof(buf), "%c%c:%c%c:%c%c", s[0], s[1], s[2], s[3], s[4], s[5]);
            return std::string(buf);
          };

          auto ddmmyy_to_text = [](const std::string &s) -> std::string {
            if (s.size() != 6) return "";
            int yy = (s[4] - '0') * 10 + (s[5] - '0');
            int year = (yy >= 70) ? (1900 + yy) : (2000 + yy);
            char buf[16];
            snprintf(buf, sizeof(buf), "%04d-%c%c-%c%c", year, s[2], s[3], s[0], s[1]);
            return std::string(buf);
          };

          while (true) {
            auto nl = id(nmea_buf).find('\n');
            if (nl == std::string::npos) break;

            std::string line = id(nmea_buf).substr(0, nl + 1);
            id(nmea_buf).erase(0, nl + 1);
            trim_crlf(line);

            if (line.empty() || line[0] != '$') continue;
            if (!verify_checksum(line)) continue;

            auto fields = split_csv(line);
            if (fields.empty()) continue;

            const std::string &type = fields[0];
            bool is_pmtk = type.rfind("$PMTK", 0) == 0;

            if (!is_pmtk && type.size() >= 6 && type[0] == '$') {
              std::string short_type = type.substr(type.size() - 3);
              std::string needle = "," + short_type + ",";
              std::string haystack = "," + id(nmea_types_seen) + ",";

              if (haystack.find(needle) == std::string::npos) {
                if (!id(nmea_types_seen).empty()) id(nmea_types_seen) += ",";
                id(nmea_types_seen) += short_type;
                id(gps_nmea_types).publish_state(id(nmea_types_seen));
                ESP_LOGI("gps", "Nouveau type NMEA: %s", short_type.c_str());
              }
            }

            if (type == "$PMTK705") {
              std::string fw;
              for (size_t i = 1; i < fields.size(); i++) {
                if (!fields[i].empty()) {
                  if (!fw.empty()) fw += ",";
                  fw += fields[i];
                }
              }
              id(gps_firmware).publish_state(fw);
              ESP_LOGI("gps", "Firmware: %s", fw.c_str());
              continue;
            }

            if (type == "$PMTK514") {
              id(gps_nmea_config).publish_state(line);
              ESP_LOGI("gps", "NMEA config: %s", line.c_str());
              continue;
            }

            if (type == "$PMTK527" && fields.size() >= 2) {
              float v;
              if (to_float(fields[1], &v)) {
                id(gps_nav_threshold).publish_state(v);
              }
              ESP_LOGI("gps", "Nav threshold: %s", line.c_str());
              continue;
            }

            if (type == "$PMTK530" && fields.size() >= 2) {
              id(gps_datum).publish_state(fields[1] == "0" ? "WGS84" : fields[1]);
              ESP_LOGI("gps", "Datum: %s", line.c_str());
              continue;
            }

            if (type == "$PMTK707") {
              bool epo_present = false;
              for (size_t i = 1; i < fields.size(); i++) {
                if (!fields[i].empty() && fields[i] != "0") {
                  epo_present = true;
                  break;
                }
              }
              id(gps_epo_status).publish_state(epo_present ? "présent" : "absent");
              ESP_LOGI("gps", "EPO: %s", line.c_str());
              continue;
            }

            if (type == "$PMTKLOG") {
              id(gps_log_raw).publish_state(line);
              ESP_LOGI("gps", "LOG: %s", line.c_str());
              continue;
            }

            if (type == "$PMTK702") {
              id(gps_port_raw).publish_state(line);
              ESP_LOGI("gps", "PORT: %s", line.c_str());
              continue;
            }
          }

On fournit ici une infrastructure minimale pour l’émission et la réception de paquets PMTK, incluant le calcul des sommes de contrôle.

Premières données

Après compilation et installation, on devrait voir apparaitre quelques lignes dans le journal :

[23:30:05.859][I][gps:197]: Firmware: AXN_5.1.7_3333_19020118,0027,PA1010D,1.0

On voit bien qu’on a reçu un paquet $PMTK705 contenant la version du firmware, et qui se termine par la somme de contrôle (*76).

Dans le on_boot, on a aussi envoyé la commande $PMTK414 qui répond avec $PMTK514. Cette commande nous informe des 19 phrases NMEA activées par défaut. Par principe, on va dire qu’on ne sait pas quelles sont ces phrases à ce stade, dans la mesure où l’on considèrera la documentation non-fiable.

[00:12:06.168][I][gps:205]: NMEA config: $PMTK514,0,1,1,1,1,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0*37

On peut toutefois déduire de cette ligne que les phrases 1 à 5 sont activées, et la cinquième fournit une sortie tous les 5 fix.

[00:21:27.724][I][gps:209]: Nav threshold: $PMTK527,0.00*00

On peut déduire de cette ligne que le seuil de vitesse pour que le capteur se considère en mouvement est de 0 ; autrement dit, le GPS de filtre pas l’immobilité, la position est mise à jour en permanence, même à l’arrêt. C’est une bonne chose pour l’instrumentation ou pour un tracking précis.

[00:25:55.240][I][gps:216]: Datum: $PMTK530,0*28

Ici, on récupère le datum 0 qui correspond à WGS84, le système de référence mondial imposant :

Ces informations sont exploitables par tous les fournisseurs de cartes (Google Maps, OpenStreetMap, etc.) et cohérentes avec tous les systèmes GNSS modernes.

[00:32:55.849][I][gps:220]: EPO: $PMTK707,0,0,0,0,0,0,0,0,0*2E

On interroge ici l’état de l’EPO (Extended Prediction Orbit). Le fait d’obtenir une réponse PMTK707 indique que le module supporte bien l’EPO, mais les 0 qui suivent nous indiquent aussi qu’aucune donnée n’est chargée. En principe, les données de l’EPO sont téléchargées depuis l’extérieur et stockées dans le module. Ce sont des données précalculées sur plusieurs jours.

[00:43:16.534][I][gps:224]: LOG: $PMTKLOG,0,1,a,31,15,0,0,1,0,0*11

Ces premières commandes (et leur réponse) nous indiquent que notre module (et son firmware) ne sont pas aussi basiques que ce que l’on pourrait croire puisqu’il :

[00:48:08.064][I][gps:228]: PORT: $PMTK702,0,0,0*2B

Cette dernière commande exploratoire des capacités de notre module nous informe des ports utilisés en interne pour la communication en I²C : il n’en utilise aucun ! L’I²C est une couche de communication supplémentaire, mais il continue d’utiliser UART en interne. L’I²C est donc ici une commodité qui nous arrange bien.

Configuration du module

Commençons par activer toutes les phrases dans on_boot :

send_pmtk("PMTK314,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1");
delay(200);
send_pmtk("PMTK414");

Cette fois, les phrases activées ont changé :

[01:19:34.039][I][gps:205]: NMEA config: $PMTK514,1,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,0,0,0*32

Les phrases 8 à 12 restent désactivées sur ce module, malgré notre activation forcée. Elles doivent correspondre aux phrases “réservées” indiquées dans les documentations.

Extraction des informations

On peut alors détecter proprement les phrases NEMA réellement supportées assez facilement.

text_sensor:
  - platform: template
    name: "GPS Types NMEA détectés"
    id: gps_nmea_types
    entity_category: diagnostic

Le capteur fraîchement créé m’indique alors une série de “mots” de 3 lettres (ainsi que des séries de chiffres et LOG qui correspondent aux réponses PMTK, qu’on ignore ici) :

GST,DTM,GGA,GLL,GSA,GSV,VTG,ZDA,GRS,RMC,CHN

Ce qui correspond aux phrases suivantes, celles que l’on peut réellement utiliser avec notre module :

Type Signification
GLL latitude / longitude
RMC données minimales (position, vitesse, date/heure)
VTG cap et vitesse au sol
GGA position + altitude + qualité du fix
GSA satellites utilisés + DOP (précision)
GSV satellites visibles (liste détaillée)
GRS résidus de pseudorange (qualité des mesures)
GST estimation d’erreur de position (précision avancée)
DTM datum géodésique utilisé
ZDA date et heure complètes
CHN état des canaux GNSS (propriétaire MediaTek)

À ce stade, on obtient des données brutes, qu’il faut encore formater pour en faire des capteurs utiles.

Code complet

Mon module est connecté à l’ESP32 par l’intermédiaire d’un multiplexeur (un TCA9548A). C’est juste la partie GPS de mon projet, mais le multiplexeur sera bien rempli à la fin… Si vous n’utilisez pas de multiplexeur, la configuration présentée ici devra être adaptée.

Attribution : Richard Dern

esphome:
  name: mobile-sensor
  friendly_name: mobile-sensor

  on_boot:
    - priority: 1100
      then:
        - lambda: |-
            gpio_set_direction((gpio_num_t)7, GPIO_MODE_OUTPUT);
            gpio_set_level((gpio_num_t)7, 1);
            gpio_set_direction((gpio_num_t)21, GPIO_MODE_OUTPUT);
            gpio_set_level((gpio_num_t)21, 1);

    - priority: 200
      then:
        - lambda: |-
            auto nmea_checksum = [](const std::string &s) -> uint8_t {
              uint8_t cs = 0;
              for (char c : s) cs ^= (uint8_t) c;
              return cs;
            };

            auto send_pmtk = [&](const std::string &body) {
              uint8_t cs = nmea_checksum(body);
              char tail[8];
              snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
              std::string line = "$" + body + tail;
              id(gps_i2c).write((const uint8_t *) line.data(), line.size());
              id(gps_last_pmtk_tx).publish_state(body);
              ESP_LOGI("gps", "TX: %s", line.c_str());
            };

            delay(300);
            send_pmtk("PMTK605");
            delay(200);
            send_pmtk("PMTK414");
            delay(200);
            send_pmtk("PMTK447");
            delay(200);
            send_pmtk("PMTK430");
            delay(200);
            send_pmtk("PMTK607");
            delay(200);
            send_pmtk("PMTK183");
            delay(200);
            send_pmtk("PMTK602");

esp32:
  board: adafruit_feather_esp32s3
  cpu_frequency: 240MHz
  framework:
    type: esp-idf

psram:
  mode: quad

logger:
  level: INFO

api:
  encryption:
    key: !secret api

ota:
  - platform: esphome
    password: !secret ota

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    ssid: "Mobile-Sensor Fallback Hotspot"
    password: !secret ap

web_server:
  port: 80
  auth:
    username: !secret web_user
    password: !secret web_passwd
  include_internal: true
  ota: false
  local: true
  version: 3

captive_portal:

i2c:
  sda: GPIO03
  scl: GPIO04
  scan: true
  id: i2c0

tca9548a:
  - address: 0x70
    id: multiplex0
    i2c_id: i2c0
    channels:
      - bus_id: multiplex0channel6
        channel: 6

i2c_device:
  id: gps_i2c
  i2c_id: multiplex0channel6
  address: 0x10

globals:
  - id: nmea_buf
    type: std::string
    restore_value: no
    initial_value: '""'

  - id: nmea_types_seen
    type: std::string
    restore_value: no
    initial_value: '""'

  - id: last_fix_millis
    type: uint32_t
    restore_value: no
    initial_value: "0"

  - id: last_valid_sentence_millis
    type: uint32_t
    restore_value: no
    initial_value: "0"

  - id: valid_sentence_count
    type: uint32_t
    restore_value: no
    initial_value: "0"

  - id: invalid_sentence_count
    type: uint32_t
    restore_value: no
    initial_value: "0"

sensor:
  - platform: template
    name: "GPS Seuil de mouvement"
    id: gps_nav_threshold
    unit_of_measurement: "m/s"
    accuracy_decimals: 2
    device_class: speed
    state_class: measurement
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Latitude"
    id: gps_latitude
    unit_of_measurement: "°"
    accuracy_decimals: 6
    state_class: measurement
    update_interval: never

  - platform: template
    name: "GPS Longitude"
    id: gps_longitude
    unit_of_measurement: "°"
    accuracy_decimals: 6
    state_class: measurement
    update_interval: never

  - platform: template
    name: "GPS Altitude"
    id: gps_altitude
    unit_of_measurement: "m"
    accuracy_decimals: 1
    device_class: distance
    state_class: measurement
    update_interval: never

  - platform: template
    name: "GPS Vitesse"
    id: gps_speed_kmh
    unit_of_measurement: "km/h"
    accuracy_decimals: 2
    device_class: speed
    state_class: measurement
    update_interval: never

  - platform: template
    name: "GPS Cap"
    id: gps_course_deg
    unit_of_measurement: "°"
    accuracy_decimals: 1
    state_class: measurement
    update_interval: never

  - platform: template
    name: "GPS Variation magnétique"
    id: gps_mag_variation
    unit_of_measurement: "°"
    accuracy_decimals: 1
    state_class: measurement
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Satellites utilisés"
    id: gps_satellites_used
    accuracy_decimals: 0
    state_class: measurement
    update_interval: never

  - platform: template
    name: "GPS Satellites visibles"
    id: gps_satellites_visible
    accuracy_decimals: 0
    state_class: measurement
    update_interval: never

  - platform: template
    name: "GPS Satellites visibles GPS"
    id: gps_satellites_visible_gps
    accuracy_decimals: 0
    state_class: measurement
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Satellites visibles GLONASS"
    id: gps_satellites_visible_glonass
    accuracy_decimals: 0
    state_class: measurement
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Satellites visibles GNSS mixte"
    id: gps_satellites_visible_gn
    accuracy_decimals: 0
    state_class: measurement
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Qualité du fix"
    id: gps_fix_quality
    accuracy_decimals: 0
    state_class: measurement
    update_interval: never

  - platform: template
    name: "GPS Type de fix"
    id: gps_fix_type
    accuracy_decimals: 0
    state_class: measurement
    update_interval: never

  - platform: template
    name: "GPS HDOP"
    id: gps_hdop
    accuracy_decimals: 2
    state_class: measurement
    update_interval: never

  - platform: template
    name: "GPS PDOP"
    id: gps_pdop
    accuracy_decimals: 2
    state_class: measurement
    update_interval: never

  - platform: template
    name: "GPS VDOP"
    id: gps_vdop
    accuracy_decimals: 2
    state_class: measurement
    update_interval: never

  - platform: template
    name: "GPS SNR max"
    id: gps_snr_max
    unit_of_measurement: "dB"
    accuracy_decimals: 0
    state_class: measurement
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS SNR max GPS"
    id: gps_snr_max_gps
    unit_of_measurement: "dB"
    accuracy_decimals: 0
    state_class: measurement
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS SNR max GLONASS"
    id: gps_snr_max_glonass
    unit_of_measurement: "dB"
    accuracy_decimals: 0
    state_class: measurement
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS SNR max GNSS mixte"
    id: gps_snr_max_gn
    unit_of_measurement: "dB"
    accuracy_decimals: 0
    state_class: measurement
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS GST RMS"
    id: gps_gst_rms
    unit_of_measurement: "m"
    accuracy_decimals: 2
    device_class: distance
    state_class: measurement
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS GST Sigma latitude"
    id: gps_gst_sigma_lat
    unit_of_measurement: "m"
    accuracy_decimals: 2
    device_class: distance
    state_class: measurement
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS GST Sigma longitude"
    id: gps_gst_sigma_lon
    unit_of_measurement: "m"
    accuracy_decimals: 2
    device_class: distance
    state_class: measurement
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS GST Sigma altitude"
    id: gps_gst_sigma_alt
    unit_of_measurement: "m"
    accuracy_decimals: 2
    device_class: distance
    state_class: measurement
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Âge du dernier fix"
    id: gps_fix_age_s
    unit_of_measurement: "s"
    accuracy_decimals: 0
    state_class: measurement
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Trames valides"
    id: gps_valid_sentence_count
    accuracy_decimals: 0
    state_class: total_increasing
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Trames invalides"
    id: gps_invalid_sentence_count
    accuracy_decimals: 0
    state_class: total_increasing
    entity_category: diagnostic
    update_interval: never

text_sensor:
  - platform: template
    name: "GPS Firmware"
    id: gps_firmware
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Dernier PMTK envoyé"
    id: gps_last_pmtk_tx
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Dernier ACK PMTK"
    id: gps_last_pmtk_ack
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Statut du dernier ACK"
    id: gps_last_pmtk_ack_status
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Message système"
    id: gps_system_message
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Type de constellation"
    id: gps_constellation
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Type de fix texte"
    id: gps_fix_type_text
    update_interval: never

  - platform: template
    name: "GPS Qualité du fix texte"
    id: gps_fix_quality_text
    update_interval: never

  - platform: template
    name: "GPS Qualité globale"
    id: gps_quality_text
    update_interval: never

  - platform: template
    name: "GPS Mode navigation"
    id: gps_nav_mode_text
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Antenne"
    id: gps_antenna_status
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Datum"
    id: gps_datum
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS EPO"
    id: gps_epo_status
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Configuration NMEA"
    id: gps_nmea_config
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Types NMEA détectés"
    id: gps_nmea_types
    entity_category: diagnostic
    update_interval: never

  - platform: template
    name: "GPS Date UTC"
    id: gps_date_utc
    update_interval: never

  - platform: template
    name: "GPS Heure UTC"
    id: gps_time_utc
    update_interval: never

  - platform: template
    name: "GPS DateTime UTC"
    id: gps_datetime_utc
    update_interval: never

  - platform: template
    name: "GPS DTM brut"
    id: gps_dtm_raw
    entity_category: diagnostic
    disabled_by_default: true
    update_interval: never

  - platform: template
    name: "GPS CHN brut"
    id: gps_chn_raw
    entity_category: diagnostic
    disabled_by_default: true
    update_interval: never

  - platform: template
    name: "GPS LOG brut"
    id: gps_log_raw
    entity_category: diagnostic
    disabled_by_default: true
    update_interval: never

  - platform: template
    name: "GPS PORT brut"
    id: gps_port_raw
    entity_category: diagnostic
    disabled_by_default: true
    update_interval: never

binary_sensor:
  - platform: template
    name: "GPS Fix"
    id: gps_has_fix

  - platform: template
    name: "GPS Position fraîche"
    id: gps_fresh_fix
    device_class: connectivity
    entity_category: diagnostic
    lambda: |-
      if (id(last_fix_millis) == 0) return false;
      return (millis() - id(last_fix_millis)) < 15000;

  - platform: template
    name: "GPS Configuration valide"
    id: gps_config_valid
    device_class: connectivity
    entity_category: diagnostic

  - platform: template
    name: "GPS Problème antenne"
    id: gps_antenna_problem
    device_class: problem
    entity_category: diagnostic

button:
  - platform: template
    name: "GPS Interroger firmware"
    entity_category: config
    on_press:
      - lambda: |-
          auto nmea_checksum = [](const std::string &s) -> uint8_t {
            uint8_t cs = 0;
            for (char c : s) cs ^= (uint8_t) c;
            return cs;
          };
          auto send_pmtk = [&](const std::string &body) {
            uint8_t cs = nmea_checksum(body);
            char tail[8];
            snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
            std::string line = "$" + body + tail;
            id(gps_i2c).write((const uint8_t *) line.data(), line.size());
            id(gps_last_pmtk_tx).publish_state(body);
            ESP_LOGI("gps", "TX: %s", line.c_str());
          };
          send_pmtk("PMTK605");

  - platform: template
    name: "GPS Interroger configuration NMEA"
    entity_category: config
    on_press:
      - lambda: |-
          auto nmea_checksum = [](const std::string &s) -> uint8_t {
            uint8_t cs = 0;
            for (char c : s) cs ^= (uint8_t) c;
            return cs;
          };
          auto send_pmtk = [&](const std::string &body) {
            uint8_t cs = nmea_checksum(body);
            char tail[8];
            snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
            std::string line = "$" + body + tail;
            id(gps_i2c).write((const uint8_t *) line.data(), line.size());
            id(gps_last_pmtk_tx).publish_state(body);
            ESP_LOGI("gps", "TX: %s", line.c_str());
          };
          send_pmtk("PMTK414");

  - platform: template
    name: "GPS Interroger seuil de mouvement"
    entity_category: config
    on_press:
      - lambda: |-
          auto nmea_checksum = [](const std::string &s) -> uint8_t {
            uint8_t cs = 0;
            for (char c : s) cs ^= (uint8_t) c;
            return cs;
          };
          auto send_pmtk = [&](const std::string &body) {
            uint8_t cs = nmea_checksum(body);
            char tail[8];
            snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
            std::string line = "$" + body + tail;
            id(gps_i2c).write((const uint8_t *) line.data(), line.size());
            id(gps_last_pmtk_tx).publish_state(body);
            ESP_LOGI("gps", "TX: %s", line.c_str());
          };
          send_pmtk("PMTK447");

  - platform: template
    name: "GPS Interroger datum"
    entity_category: config
    on_press:
      - lambda: |-
          auto nmea_checksum = [](const std::string &s) -> uint8_t {
            uint8_t cs = 0;
            for (char c : s) cs ^= (uint8_t) c;
            return cs;
          };
          auto send_pmtk = [&](const std::string &body) {
            uint8_t cs = nmea_checksum(body);
            char tail[8];
            snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
            std::string line = "$" + body + tail;
            id(gps_i2c).write((const uint8_t *) line.data(), line.size());
            id(gps_last_pmtk_tx).publish_state(body);
            ESP_LOGI("gps", "TX: %s", line.c_str());
          };
          send_pmtk("PMTK430");

  - platform: template
    name: "GPS Interroger release"
    entity_category: config
    on_press:
      - lambda: |-
          auto nmea_checksum = [](const std::string &s) -> uint8_t {
            uint8_t cs = 0;
            for (char c : s) cs ^= (uint8_t) c;
            return cs;
          };
          auto send_pmtk = [&](const std::string &body) {
            uint8_t cs = nmea_checksum(body);
            char tail[8];
            snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
            std::string line = "$" + body + tail;
            id(gps_i2c).write((const uint8_t *) line.data(), line.size());
            id(gps_last_pmtk_tx).publish_state(body);
            ESP_LOGI("gps", "TX: %s", line.c_str());
          };
          send_pmtk("PMTK607");

  - platform: template
    name: "GPS Interroger logger"
    entity_category: config
    on_press:
      - lambda: |-
          auto nmea_checksum = [](const std::string &s) -> uint8_t {
            uint8_t cs = 0;
            for (char c : s) cs ^= (uint8_t) c;
            return cs;
          };
          auto send_pmtk = [&](const std::string &body) {
            uint8_t cs = nmea_checksum(body);
            char tail[8];
            snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
            std::string line = "$" + body + tail;
            id(gps_i2c).write((const uint8_t *) line.data(), line.size());
            id(gps_last_pmtk_tx).publish_state(body);
            ESP_LOGI("gps", "TX: %s", line.c_str());
          };
          send_pmtk("PMTK183");

  - platform: template
    name: "GPS Interroger port"
    entity_category: config
    on_press:
      - lambda: |-
          auto nmea_checksum = [](const std::string &s) -> uint8_t {
            uint8_t cs = 0;
            for (char c : s) cs ^= (uint8_t) c;
            return cs;
          };
          auto send_pmtk = [&](const std::string &body) {
            uint8_t cs = nmea_checksum(body);
            char tail[8];
            snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
            std::string line = "$" + body + tail;
            id(gps_i2c).write((const uint8_t *) line.data(), line.size());
            id(gps_last_pmtk_tx).publish_state(body);
            ESP_LOGI("gps", "TX: %s", line.c_str());
          };
          send_pmtk("PMTK602");

interval:
  - interval: 100ms
    then:
      - lambda: |-
          uint8_t data[128];

          for (int pass = 0; pass < 4; pass++) {
            if (!id(gps_i2c).read_bytes_raw(data, sizeof(data))) {
              break;
            }

            for (size_t j = 0; j < sizeof(data); j++) {
              char c = (char) data[j];
              if (c == '\0') continue;
              id(nmea_buf).push_back(c);
            }
          }

          if (id(nmea_buf).size() > 8192) {
            id(nmea_buf).erase(0, id(nmea_buf).size() - 4096);
          }

          auto trim_crlf = [](std::string &s) {
            while (!s.empty() && (s.back() == '\r' || s.back() == '\n')) s.pop_back();
          };

          auto verify_checksum = [](const std::string &s) -> bool {
            auto star = s.find('*');
            if (s.size() < 4 || s[0] != '$' || star == std::string::npos) return false;
            if (star + 2 >= s.size()) return false;

            uint8_t cs = 0;
            for (size_t i = 1; i < star; i++) cs ^= (uint8_t) s[i];

            auto hex = [](char ch) -> int {
              if (ch >= '0' && ch <= '9') return ch - '0';
              if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10;
              if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10;
              return -1;
            };

            int hi = hex(s[star + 1]);
            int lo = hex(s[star + 2]);
            if (hi < 0 || lo < 0) return false;

            uint8_t want = (uint8_t) ((hi << 4) | lo);
            return cs == want;
          };

          auto split_csv = [](const std::string &line) -> std::vector<std::string> {
            std::vector<std::string> out;
            std::string cur;
            for (char ch : line) {
              if (ch == ',' || ch == '*') {
                out.push_back(cur);
                cur.clear();
                if (ch == '*') break;
              } else {
                cur.push_back(ch);
              }
            }
            out.push_back(cur);
            return out;
          };

          auto to_float = [](const std::string &s, float *out) -> bool {
            if (s.empty()) return false;
            char *end = nullptr;
            float v = strtof(s.c_str(), &end);
            if (end == s.c_str()) return false;
            *out = v;
            return true;
          };

          auto to_int = [](const std::string &s, int *out) -> bool {
            if (s.empty()) return false;
            char *end = nullptr;
            long v = strtol(s.c_str(), &end, 10);
            if (end == s.c_str()) return false;
            *out = (int) v;
            return true;
          };

          auto ddmm_to_deg = [](const std::string &ddmm, char hemi, float *out) -> bool {
            if (ddmm.size() < 4) return false;
            auto dot = ddmm.find('.');
            size_t min_start = (dot == std::string::npos) ? ddmm.size() - 2 : (dot >= 2 ? dot - 2 : 0);
            if (min_start < 2) return false;

            std::string deg_str = ddmm.substr(0, min_start);
            std::string min_str = ddmm.substr(min_start);

            char *end = nullptr;
            float deg = strtof(deg_str.c_str(), &end);
            if (end == deg_str.c_str()) return false;

            end = nullptr;
            float minutes = strtof(min_str.c_str(), &end);
            if (end == min_str.c_str()) return false;

            float v = deg + minutes / 60.0f;
            if (hemi == 'S' || hemi == 'W') v = -v;
            *out = v;
            return true;
          };

          auto hhmmss_to_text = [](const std::string &s) -> std::string {
            if (s.size() < 6) return "";
            char buf[16];
            snprintf(buf, sizeof(buf), "%c%c:%c%c:%c%c", s[0], s[1], s[2], s[3], s[4], s[5]);
            return std::string(buf);
          };

          auto ddmmyy_to_text = [](const std::string &s) -> std::string {
            if (s.size() != 6) return "";
            int yy = (s[4] - '0') * 10 + (s[5] - '0');
            int year = (yy >= 70) ? (1900 + yy) : (2000 + yy);
            char buf[16];
            snprintf(buf, sizeof(buf), "%04d-%c%c-%c%c", year, s[2], s[3], s[0], s[1]);
            return std::string(buf);
          };

          auto publish_fix_age_and_quality = [&]() {
            if (id(last_fix_millis) == 0) {
              id(gps_fix_age_s).publish_state(NAN);
              id(gps_quality_text).publish_state("aucun fix");
              return;
            }

            float age_s = (millis() - id(last_fix_millis)) / 1000.0f;
            id(gps_fix_age_s).publish_state(age_s);

            float hdop = id(gps_hdop).has_state() ? id(gps_hdop).state : NAN;
            int sats = id(gps_satellites_used).has_state() ? (int) id(gps_satellites_used).state : 0;

            if (age_s > 15.0f) {
              id(gps_quality_text).publish_state("fix périmé");
            } else if (!std::isnan(hdop) && hdop <= 1.5f && sats >= 6) {
              id(gps_quality_text).publish_state("excellent");
            } else if (!std::isnan(hdop) && hdop <= 3.0f && sats >= 4) {
              id(gps_quality_text).publish_state("bon");
            } else if (!std::isnan(hdop) && hdop <= 6.0f) {
              id(gps_quality_text).publish_state("moyen");
            } else {
              id(gps_quality_text).publish_state("faible");
            }
          };

          while (true) {
            auto nl = id(nmea_buf).find('\n');
            if (nl == std::string::npos) break;

            std::string line = id(nmea_buf).substr(0, nl + 1);
            id(nmea_buf).erase(0, nl + 1);
            trim_crlf(line);

            if (line.empty() || line[0] != '$') continue;

            if (!verify_checksum(line)) {
              id(invalid_sentence_count)++;
              id(gps_invalid_sentence_count).publish_state((float) id(invalid_sentence_count));
              continue;
            }

            id(valid_sentence_count)++;
            id(last_valid_sentence_millis) = millis();
            id(gps_valid_sentence_count).publish_state((float) id(valid_sentence_count));

            auto fields = split_csv(line);
            if (fields.empty()) continue;

            const std::string &type = fields[0];
            bool is_pmtk = type.rfind("$PMTK", 0) == 0;

            if (!is_pmtk && type.size() >= 6 && type[0] == '$') {
              std::string short_type = type.substr(type.size() - 3);
              std::string needle = "," + short_type + ",";
              std::string haystack = "," + id(nmea_types_seen) + ",";

              if (haystack.find(needle) == std::string::npos) {
                if (!id(nmea_types_seen).empty()) id(nmea_types_seen) += ",";
                id(nmea_types_seen) += short_type;
                id(gps_nmea_types).publish_state(id(nmea_types_seen));
                ESP_LOGI("gps", "Nouveau type NMEA: %s", short_type.c_str());
              }
            }

            if (type == "$PMTK001" && fields.size() >= 3) {
              int cmd = 0;
              int flag = -1;
              to_int(fields[1], &cmd);
              to_int(fields[2], &flag);

              char buf[32];
              snprintf(buf, sizeof(buf), "PMTK%03d=%d", cmd, flag);
              id(gps_last_pmtk_ack).publish_state(buf);

              std::string status = "inconnu";
              bool ok = false;
              if (flag == 0) status = "paquet invalide";
              if (flag == 1) status = "commande non supportée";
              if (flag == 2) status = "commande valide mais échec";
              if (flag == 3) {
                status = "succès";
                ok = true;
              }

              id(gps_last_pmtk_ack_status).publish_state(status);
              id(gps_config_valid).publish_state(ok);
              continue;
            }

            if (type == "$PMTK010" && fields.size() >= 2) {
              id(gps_system_message).publish_state(line);
              continue;
            }

            if (type == "$PMTK705") {
              std::string fw;
              for (size_t i = 1; i < fields.size(); i++) {
                if (!fields[i].empty()) {
                  if (!fw.empty()) fw += ",";
                  fw += fields[i];
                }
              }
              id(gps_firmware).publish_state(fw);
              ESP_LOGI("gps", "Firmware: %s", fw.c_str());
              continue;
            }

            if (type == "$PMTK514") {
              id(gps_nmea_config).publish_state(line);
              ESP_LOGI("gps", "NMEA config: %s", line.c_str());
              continue;
            }

            if (type == "$PMTK527" && fields.size() >= 2) {
              float v;
              if (to_float(fields[1], &v)) {
                id(gps_nav_threshold).publish_state(v);
              }
              ESP_LOGI("gps", "Nav threshold: %s", line.c_str());
              continue;
            }

            if (type == "$PMTK530" && fields.size() >= 2) {
              id(gps_datum).publish_state(fields[1] == "0" ? "WGS84" : fields[1]);
              ESP_LOGI("gps", "Datum: %s", line.c_str());
              continue;
            }

            if (type == "$PMTK707") {
              bool epo_present = false;
              for (size_t i = 1; i < fields.size(); i++) {
                if (!fields[i].empty() && fields[i] != "0") {
                  epo_present = true;
                  break;
                }
              }
              id(gps_epo_status).publish_state(epo_present ? "présent" : "absent");
              ESP_LOGI("gps", "EPO: %s", line.c_str());
              continue;
            }

            if (type == "$PMTKLOG") {
              id(gps_log_raw).publish_state(line);
              continue;
            }

            if (type == "$PMTK702") {
              id(gps_port_raw).publish_state(line);
              continue;
            }

            if ((type == "$PGTOP" || type == "$PCD") && fields.size() >= 3 && fields[1] == "11") {
              std::string st = "inconnu";
              bool problem = false;

              if (fields[2] == "1") st = "antenne interne";
              else if (fields[2] == "2") st = "antenne externe";
              else if (fields[2] == "3") { st = "court-circuit antenne"; problem = true; }
              else if (fields[2] == "0") st = "statut indisponible";
              else st = line;

              id(gps_antenna_status).publish_state(st);
              id(gps_antenna_problem).publish_state(problem);
              continue;
            }

            if (type.size() >= 3 && type.substr(type.size() - 3) == "GGA") {
              if (fields.size() >= 10) {
                int fixq = 0;
                int sats = 0;
                float hdop = 0.0f;
                float alt = 0.0f;
                float lat = 0.0f;
                float lon = 0.0f;

                bool fix_ok = to_int(fields[6], &fixq) && fixq > 0;
                id(gps_has_fix).publish_state(fix_ok);

                if (fix_ok) id(last_fix_millis) = millis();

                if (to_int(fields[6], &fixq)) {
                  id(gps_fix_quality).publish_state((float) fixq);
                  std::string txt = "inconnu";
                  if (fixq == 0) txt = "pas de fix";
                  else if (fixq == 1) txt = "GPS";
                  else if (fixq == 2) txt = "DGPS";
                  else if (fixq == 3) txt = "PPS";
                  else if (fixq == 4) txt = "RTK fixe";
                  else if (fixq == 5) txt = "RTK flottant";
                  else if (fixq == 6) txt = "estimation";
                  id(gps_fix_quality_text).publish_state(txt);
                }
                if (to_int(fields[7], &sats)) id(gps_satellites_used).publish_state((float) sats);
                if (to_float(fields[8], &hdop)) id(gps_hdop).publish_state(hdop);
                if (to_float(fields[9], &alt)) id(gps_altitude).publish_state(alt);

                if (ddmm_to_deg(fields[2], fields[3].empty() ? 'N' : fields[3][0], &lat)) {
                  id(gps_latitude).publish_state(lat);
                }
                if (ddmm_to_deg(fields[4], fields[5].empty() ? 'E' : fields[5][0], &lon)) {
                  id(gps_longitude).publish_state(lon);
                }

                if (type.rfind("$GP", 0) == 0) id(gps_constellation).publish_state("GPS");
                else if (type.rfind("$GL", 0) == 0) id(gps_constellation).publish_state("GLONASS");
                else if (type.rfind("$GN", 0) == 0) id(gps_constellation).publish_state("GNSS mixte");

                publish_fix_age_and_quality();
              }
              continue;
            }

            if (type.size() >= 3 && type.substr(type.size() - 3) == "GLL") {
              if (fields.size() >= 5) {
                float lat = 0.0f;
                float lon = 0.0f;
                if (ddmm_to_deg(fields[1], fields[2].empty() ? 'N' : fields[2][0], &lat)) {
                  id(gps_latitude).publish_state(lat);
                }
                if (ddmm_to_deg(fields[3], fields[4].empty() ? 'E' : fields[4][0], &lon)) {
                  id(gps_longitude).publish_state(lon);
                }
              }
              continue;
            }

            if (type.size() >= 3 && type.substr(type.size() - 3) == "RMC") {
              if (fields.size() >= 10) {
                bool valid = (!fields[2].empty() && fields[2][0] == 'A');
                id(gps_has_fix).publish_state(valid);
                if (valid) id(last_fix_millis) = millis();

                float lat = 0.0f;
                float lon = 0.0f;
                float sp_kn = 0.0f;
                float course = 0.0f;
                float mag_var = 0.0f;

                if (ddmm_to_deg(fields[3], fields[4].empty() ? 'N' : fields[4][0], &lat)) {
                  id(gps_latitude).publish_state(lat);
                }
                if (ddmm_to_deg(fields[5], fields[6].empty() ? 'E' : fields[6][0], &lon)) {
                  id(gps_longitude).publish_state(lon);
                }
                if (to_float(fields[7], &sp_kn)) {
                  id(gps_speed_kmh).publish_state(sp_kn * 1.852f);
                }
                if (to_float(fields[8], &course)) {
                  id(gps_course_deg).publish_state(course);
                }
                if (fields.size() >= 12 && to_float(fields[10], &mag_var)) {
                  if (!fields[11].empty() && fields[11][0] == 'W') mag_var = -mag_var;
                  id(gps_mag_variation).publish_state(mag_var);
                }

                if (fields.size() >= 13 && !fields[12].empty()) {
                  std::string mode = "inconnu";
                  char m = fields[12][0];
                  if (m == 'A') mode = "autonome";
                  else if (m == 'D') mode = "différentiel";
                  else if (m == 'E') mode = "estimé";
                  else if (m == 'N') mode = "invalide";
                  id(gps_nav_mode_text).publish_state(mode);
                }

                std::string time_txt = hhmmss_to_text(fields[1]);
                std::string date_txt = ddmmyy_to_text(fields[9]);
                if (!time_txt.empty()) id(gps_time_utc).publish_state(time_txt);
                if (!date_txt.empty()) id(gps_date_utc).publish_state(date_txt);
                if (!date_txt.empty() && !time_txt.empty()) {
                  id(gps_datetime_utc).publish_state(date_txt + "T" + time_txt + "Z");
                }

                publish_fix_age_and_quality();
              }
              continue;
            }

            if (type.size() >= 3 && type.substr(type.size() - 3) == "VTG") {
              if (fields.size() >= 8) {
                float course = 0.0f;
                float speed_kmh = 0.0f;
                if (to_float(fields[1], &course)) id(gps_course_deg).publish_state(course);
                if (to_float(fields[7], &speed_kmh)) id(gps_speed_kmh).publish_state(speed_kmh);

                if (fields.size() >= 10 && !fields[9].empty()) {
                  std::string mode = "inconnu";
                  char m = fields[9][0];
                  if (m == 'A') mode = "autonome";
                  else if (m == 'D') mode = "différentiel";
                  else if (m == 'E') mode = "estimé";
                  else if (m == 'N') mode = "invalide";
                  id(gps_nav_mode_text).publish_state(mode);
                }
              }
              continue;
            }

            if (type.size() >= 3 && type.substr(type.size() - 3) == "GSA") {
              if (fields.size() >= 18) {
                int fix_type = 0;
                float pdop = 0.0f;
                float hdop = 0.0f;
                float vdop = 0.0f;

                if (to_int(fields[2], &fix_type)) {
                  id(gps_fix_type).publish_state((float) fix_type);
                  std::string txt = "inconnu";
                  if (fix_type == 1) txt = "aucun";
                  else if (fix_type == 2) txt = "2D";
                  else if (fix_type == 3) txt = "3D";
                  id(gps_fix_type_text).publish_state(txt);
                }
                if (to_float(fields[15], &pdop)) id(gps_pdop).publish_state(pdop);
                if (to_float(fields[16], &hdop)) id(gps_hdop).publish_state(hdop);
                if (to_float(fields[17], &vdop)) id(gps_vdop).publish_state(vdop);

                publish_fix_age_and_quality();
              }
              continue;
            }

            if (type.size() >= 3 && type.substr(type.size() - 3) == "GSV") {
              if (fields.size() >= 4) {
                int sats_visible = 0;
                if (to_int(fields[3], &sats_visible)) {
                  id(gps_satellites_visible).publish_state((float) sats_visible);

                  if (type.rfind("$GP", 0) == 0) id(gps_satellites_visible_gps).publish_state((float) sats_visible);
                  else if (type.rfind("$GL", 0) == 0) id(gps_satellites_visible_glonass).publish_state((float) sats_visible);
                  else if (type.rfind("$GN", 0) == 0) id(gps_satellites_visible_gn).publish_state((float) sats_visible);
                }

                int max_snr = -1;
                for (size_t i = 7; i + 3 < fields.size(); i += 4) {
                  int snr = 0;
                  if (to_int(fields[i], &snr)) {
                    if (snr > max_snr) max_snr = snr;
                  }
                }

                if (max_snr >= 0) {
                  id(gps_snr_max).publish_state((float) max_snr);

                  if (type.rfind("$GP", 0) == 0) id(gps_snr_max_gps).publish_state((float) max_snr);
                  else if (type.rfind("$GL", 0) == 0) id(gps_snr_max_glonass).publish_state((float) max_snr);
                  else if (type.rfind("$GN", 0) == 0) id(gps_snr_max_gn).publish_state((float) max_snr);
                }
              }
              continue;
            }

            if (type.size() >= 3 && type.substr(type.size() - 3) == "ZDA") {
              if (fields.size() >= 5) {
                std::string time_txt = hhmmss_to_text(fields[1]);
                if (!time_txt.empty()) id(gps_time_utc).publish_state(time_txt);

                if (!fields[2].empty() && !fields[3].empty() && !fields[4].empty()) {
                  char buf[32];
                  snprintf(buf, sizeof(buf), "%s-%02d-%02d",
                           fields[4].c_str(),
                           atoi(fields[3].c_str()),
                           atoi(fields[2].c_str()));
                  std::string date_txt(buf);
                  id(gps_date_utc).publish_state(date_txt);
                  if (!time_txt.empty()) {
                    id(gps_datetime_utc).publish_state(date_txt + "T" + time_txt + "Z");
                  }
                }
              }
              continue;
            }

            if (type.size() >= 3 && type.substr(type.size() - 3) == "GST") {
              if (fields.size() >= 9) {
                float v = 0.0f;
                if (to_float(fields[2], &v)) id(gps_gst_rms).publish_state(v);
                if (to_float(fields[6], &v)) id(gps_gst_sigma_lat).publish_state(v);
                if (to_float(fields[7], &v)) id(gps_gst_sigma_lon).publish_state(v);
                if (to_float(fields[8], &v)) id(gps_gst_sigma_alt).publish_state(v);

                publish_fix_age_and_quality();
              }
              continue;
            }

            if (type.size() >= 3 && type.substr(type.size() - 3) == "DTM") {
              id(gps_dtm_raw).publish_state(line);
              continue;
            }

            if (type.size() >= 3 && type.substr(type.size() - 3) == "CHN") {
              id(gps_chn_raw).publish_state(line);
              continue;
            }
          }

          if (id(last_fix_millis) != 0) {
            float age_s = (millis() - id(last_fix_millis)) / 1000.0f;
            id(gps_fix_age_s).publish_state(age_s);
          }

Nous obtenons maintenant des données exhaustives que notre module peut fournir :

Capteur Signification
GPS Seuil de mouvement Vitesse minimale (m/s) à partir de laquelle le module considère qu’il est en déplacement (sinon il peut stabiliser la position)
GPS Latitude Latitude actuelle en degrés décimaux (référentiel WGS84)
GPS Longitude Longitude actuelle en degrés décimaux (référentiel WGS84)
GPS Altitude Altitude au-dessus du niveau moyen de la mer (en mètres)
GPS Vitesse Vitesse au sol (en km/h), dérivée du déplacement GNSS
GPS Cap Direction du déplacement (angle en degrés par rapport au nord)
GPS Satellites utilisés Nombre de satellites utilisés dans le calcul de position (fix)
GPS Satellites visibles Nombre total de satellites visibles par le récepteur
GPS Qualité du fix Indicateur de qualité du positionnement (0 = pas de fix, 1 = GPS, 2 = DGPS, etc.)
GPS Type de fix Type de solution GNSS (1 = pas de fix, 2 = 2D, 3 = 3D)
GPS HDOP Précision horizontale (Horizontal Dilution of Precision, plus c’est bas, mieux c’est)
GPS PDOP Précision globale 3D (Position Dilution of Precision)
GPS VDOP Précision verticale (Vertical Dilution of Precision)
GPS SNR max Meilleur signal satellite reçu (en dB), indicateur de qualité radio
GPS GST RMS Erreur RMS estimée de la position (en mètres)
GPS GST Sigma latitude Incertitude estimée sur la latitude (en mètres)
GPS GST Sigma longitude Incertitude estimée sur la longitude (en mètres)
GPS GST Sigma altitude Incertitude estimée sur l’altitude (en mètres)
GPS Fix Indique si un fix valide est disponible (true/false)
GPS Firmware Version du firmware du module GNSS
GPS Datum Référentiel géodésique utilisé (ex: WGS84)
GPS EPO Indique si des données d’assistance (EPO) sont présentes
GPS Configuration NMEA Configuration actuelle des phrases NMEA émises par le module
GPS Types NMEA détectés Liste des types de phrases NMEA effectivement observées
GPS Date UTC Date GNSS (UTC)
GPS Heure UTC Heure GNSS (UTC)
GPS DateTime UTC Date et heure combinées (UTC)
GPS DTM brut Trame brute indiquant le datum (peu utilisée en pratique)
GPS CHN brut Trame propriétaire indiquant l’état des canaux GNSS
GPS LOG brut État brut du logger interne du module
GPS PORT brut Configuration brute des interfaces du module

L’interface web du projet, nous indiquant en temps réel toutes les informations à notre disposition. Le bloc Diagnostic n’a plus d’utilité à ce stade.

Attribution : Richard Dern

Obtention d’un fix

Il n’y a pas de commande pour “obtenir un fix”. Il faut suivre exactement le trajet d’une trame dans le code, depuis l’I²C jusqu’au moment où GPS Fix change d’état.

Tout commence ici :

interval:
  - interval: 100ms
    then:
      - lambda: |-

Cela veut dire que, toutes les 100 ms, ESPHome exécute ce gros bloc C++. C’est lui qui fait vivre tout le parser GPS. La première étape consiste à lire des octets bruts depuis le périphérique I²C :

uint8_t data[128];

for (int pass = 0; pass < 4; pass++) {
  if (!id(gps_i2c).read_bytes_raw(data, sizeof(data))) {
    break;
  }

  for (size_t j = 0; j < sizeof(data); j++) {
    char c = (char) data[j];
    if (c == '\0') continue;
    id(nmea_buf).push_back(c);
  }
}

Ici, le code tente jusqu’à 4 lectures de 128 octets, donc jusqu’à 512 octets par passage de 100 ms. Chaque octet non nul est ajouté à nmea_buf, qui est simplement un tampon texte global.

Autrement dit, le GPS envoie un flux continu de caractères, et le code les accumule jusqu’à former des lignes NMEA complètes. Par exemple :

$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A

Ensuite, on évite que le tampon devienne énorme :

if (id(nmea_buf).size() > 8192) {
  id(nmea_buf).erase(0, id(nmea_buf).size() - 4096);
}

Cela ne participe pas au fix lui-même ; c’est juste une sécurité mémoire.

Ensuite, on définit plusieurs petites fonctions utilitaires. Il y en a trois qui comptent particulièrement pour le fix. La première supprime les fins de ligne :

auto trim_crlf = [](std::string &s) {
  while (!s.empty() && (s.back() == '\r' || s.back() == '\n')) s.pop_back();
};

La deuxième vérifie le checksum NMEA :

auto verify_checksum = [](const std::string &s) -> bool {
  auto star = s.find('*');
  if (s.size() < 4 || s[0] != '$' || star == std::string::npos) return false;
  if (star + 2 >= s.size()) return false;

  uint8_t cs = 0;
  for (size_t i = 1; i < star; i++) cs ^= (uint8_t) s[i];

  ...
  return cs == want;
};

C’est très important : si le checksum ne correspond pas, la phrase est rejetée. Donc une trame corrompue ne pourra pas déclencher un faux fix. La troisième découpe la ligne en champs :

auto split_csv = [](const std::string &line) -> std::vector<std::string> {
  std::vector<std::string> out;
  std::string cur;
  for (char ch : line) {
    if (ch == ',' || ch == '*') {
      out.push_back(cur);
      cur.clear();
      if (ch == '*') break;
    } else {
      cur.push_back(ch);
    }
  }
  out.push_back(cur);
  return out;
};

C’est ce qui transforme une phrase NMEA en tableau de champs, par exemple :

$GPRMC,123519,A,...

devient quelque chose comme :

fields[0] = "$GPRMC"
fields[1] = "123519"
fields[2] = "A"
...

Après cela, le code entre dans cette boucle :

while (true) {
  auto nl = id(nmea_buf).find('\n');
  if (nl == std::string::npos) break;

  std::string line = id(nmea_buf).substr(0, nl + 1);
  id(nmea_buf).erase(0, nl + 1);
  trim_crlf(line);

  if (line.empty() || line[0] != '$') continue;
  if (!verify_checksum(line)) continue;

  auto fields = split_csv(line);
  if (fields.empty()) continue;

  const std::string &type = fields[0];

C’est là que tout se joue : à chaque itération, on prend une ligne complète du buffer. Si elle n’est pas vide, qu’elle commence bien par $, et que son checksum est bon, alors elle devient une trame exploitable.

Ensuite, le code identifie le type de phrase par fields[0]. C’est cette valeur qui permet de savoir si on a affaire à GGA, RMC, GSV, etc.

Pour le fix, il y a deux blocs principaux. Le premier est celui des phrases GGA :

if (type.size() >= 3 && type.substr(type.size() - 3) == "GGA") {
  if (fields.size() >= 10) {
    int fixq = 0;
    int sats = 0;
    float hdop = 0.0f;
    float alt = 0.0f;
    float lat = 0.0f;
    float lon = 0.0f;

    bool fix_ok = to_int(fields[6], &fixq) && fixq > 0;
    id(gps_has_fix).publish_state(fix_ok);

    if (to_int(fields[6], &fixq)) id(gps_fix_quality).publish_state((float) fixq);
    if (to_int(fields[7], &sats)) id(gps_satellites_used).publish_state((float) sats);
    if (to_float(fields[8], &hdop)) id(gps_hdop).publish_state(hdop);
    if (to_float(fields[9], &alt)) id(gps_altitude).publish_state(alt);

    if (ddmm_to_deg(fields[2], fields[3].empty() ? 'N' : fields[3][0], &lat)) {
      id(gps_latitude).publish_state(lat);
    }
    if (ddmm_to_deg(fields[4], fields[5].empty() ? 'E' : fields[5][0], &lon)) {
      id(gps_longitude).publish_state(lon);
    }
  }
  continue;
}

Ici, la ligne cruciale est :

bool fix_ok = to_int(fields[6], &fixq) && fixq > 0;
id(gps_has_fix).publish_state(fix_ok);

Dans une phrase GGA, le champ 6 est la qualité du fix. Si ce champ vaut 0, pas de fix. S’il vaut 1, 2, 3, etc., le récepteur considère qu’il a une solution valide. Donc notre code traduit cela directement en booléen et publie l’état de gps_has_fix.

Autrement dit, dès qu’une GGA valide arrive avec fields[6] > 0, le binaire GPS Fix passe à true.

Ce même bloc profite ensuite de la trame pour publier les données associées au fix : nombre de satellites utilisés, HDOP, altitude, latitude, longitude. Donc, dans notre logique, GGA n’est pas seulement un indicateur de fix : c’est aussi une source de contenu détaillé sur ce fix.

Le deuxième bloc important est celui des phrases RMC :

if (type.size() >= 3 && type.substr(type.size() - 3) == "RMC") {
  if (fields.size() >= 10) {
    bool valid = (!fields[2].empty() && fields[2][0] == 'A');
    id(gps_has_fix).publish_state(valid);

    float lat = 0.0f;
    float lon = 0.0f;
    float sp_kn = 0.0f;
    float course = 0.0f;

    if (ddmm_to_deg(fields[3], fields[4].empty() ? 'N' : fields[4][0], &lat)) {
      id(gps_latitude).publish_state(lat);
    }
    if (ddmm_to_deg(fields[5], fields[6].empty() ? 'E' : fields[6][0], &lon)) {
      id(gps_longitude).publish_state(lon);
    }
    if (to_float(fields[7], &sp_kn)) {
      id(gps_speed_kmh).publish_state(sp_kn * 1.852f);
    }
    if (to_float(fields[8], &course)) {
      id(gps_course_deg).publish_state(course);
    }

    std::string time_txt = hhmmss_to_text(fields[1]);
    std::string date_txt = ddmmyy_to_text(fields[9]);
    if (!time_txt.empty()) id(gps_time_utc).publish_state(time_txt);
    if (!date_txt.empty()) id(gps_date_utc).publish_state(date_txt);
    if (!date_txt.empty() && !time_txt.empty()) {
      id(gps_datetime_utc).publish_state(date_txt + "T" + time_txt + "Z");
    }
  }
  continue;
}

Ici, la ligne déterminante est :

bool valid = (!fields[2].empty() && fields[2][0] == 'A');
id(gps_has_fix).publish_state(valid);

Dans RMC, le champ 2 contient le statut. A signifie “Active”, donc données valides. V signifie “Void”, donc invalide. Notre code s’en sert comme deuxième source de vérité pour dire s’il y a un fix ou non.

Donc, si une phrase RMC valide arrive avec A, on passe aussi gps_has_fix à true.

À partir de là, le comportement global du code est simple : gps_has_fix est mis à jour chaque fois qu’une GGA ou une RMC est reçue. Il ne s’agit pas d’un état calculé une fois pour toutes ; c’est un état réécrit en continu selon la dernière phrase pertinente reçue.

C’est important, car cela veut dire que le booléen peut changer plusieurs fois par seconde. Par exemple, imaginons cette séquence :

Dans ce cas tout va bien, mais on voit bien que l’état dépend de la dernière phrase traitée. Notre code ne fusionne pas les informations dans un état plus stable ; il prend la dernière trame comme référence immédiate.

Il faut aussi voir ce qui ne participe pas directement à l’obtention du fix. Les blocs GSA, GSV, ZDA, GST, DTM, CHN n’activent pas gps_has_fix : ils enrichissent seulement le contexte.

GSA publie le type de fix 2D/3D et les DOP :

if (to_int(fields[2], &fix_type)) id(gps_fix_type).publish_state((float) fix_type);
if (to_float(fields[15], &pdop)) id(gps_pdop).publish_state(pdop);
if (to_float(fields[16], &hdop)) id(gps_hdop).publish_state(hdop);
if (to_float(fields[17], &vdop)) id(gps_vdop).publish_state(vdop);

Cela nous renseigne sur la qualité géométrique de la solution, mais ce n’est pas ce code qui décide du binaire GPS Fix. GSV publie les satellites visibles et le SNR max :

if (to_int(fields[3], &sats_visible)) {
  id(gps_satellites_visible).publish_state((float) sats_visible);
}

Là encore, cela aide à comprendre pourquoi un fix existe ou non, mais ce n’est pas ce qui le déclare dans notre logique. Donc, si on suit une trame complète depuis le début, le scénario typique est celui-ci.

Dans les deux cas, la trame sert aussi à mettre à jour les données de position et d’état associées.

Flux de travail pour l’obtention d’un fix GPS.

Attribution : Richard Dern

Le point central est donc le suivant : le code ne “cherche” jamais le fix. Il écoute un flux NMEA et traduit certains champs en un booléen pour Home Assistant. Le fix existe déjà dans le GPS ; notre code ne fait que reconnaître les signes qu’il a été obtenu.

Il y a aussi une conséquence pratique importante : comme on expose GPS Fix comme simple binary_sensor, Home Assistant voit seulement un état instantané. Il ne sait pas, avec ce seul capteur, depuis combien de temps le fix est valide, ni depuis combien de temps il est perdu. C’est pour cela que l’ajout de last_fix_millis est utile : il transforme un état très “volatile” en quelque chose de plus exploitable.

Conclusion

Le PA1010D n’est pas le dernier cri en matière de GPS, mais il permet déjà de faire beaucoup de choses. Les outils comme ESPHome sont excellents. Ce que fournit Adafruit aussi. Mais une partie de la richesse de ce module reste masquée afin de le rendre plus accessible.

Je suis content d’avoir pu compter sur ChatGPT pour m’aider à dévoiler cette richesse. Encore une fois, si j’avais voulu aller au plus simple, j’aurais soudé 4 fils sur les ports UART du module, et en quelques lignes j’aurais pu faire fonctionner mon module comme la majorité des gens le font. Et si j’avais voulu aller plus loin, j’aurais créé un projet Arduino avec le code fournit par Adafruit, en reproduisant simplement les exemples fournis.

Mais je voulais faire les choses à ma façon, pour obtenir le résultat que je voulais. Et ce que je veux, c’est tirer le maximum d’informations que le constructeur a prévu pour son produit.

En plus, ça m’a permis de mieux comprendre comment fonctionne le GPS, de me documenter sur les protocoles employés, et de tirer profit d’ESPHome par un biais que je n’avais encore que survolé. Le résultat est long et dense, mais finalement pas si complexe. C’était un projet très intéressant, et il me tarde maintenant de le complèter avec les autres modules à ma disposition.

Échanger autour de ce texte

Si vous souhaitez réagir publiquement, un fil dédié vous attend.

Ouvrir le fil de discussion

Taxonomies

Entreprises
Tags