diff --git a/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/data/images/4415-08.yaml b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/data/images/4415-08.yaml
new file mode 100644
index 00000000..4e9658d2
--- /dev/null
+++ b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/data/images/4415-08.yaml
@@ -0,0 +1,4 @@
+#title: ""
+attribution: "https://www.adafruit.com/product/4415"
+description: "Le module GPS PA1010D d'Adafruit que j'utilise dans mon projet."
+#prompt: ""
diff --git a/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/data/images/5477-07.yaml b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/data/images/5477-07.yaml
new file mode 100644
index 00000000..f1d174dd
--- /dev/null
+++ b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/data/images/5477-07.yaml
@@ -0,0 +1,4 @@
+#title: ""
+attribution: "https://www.adafruit.com/product/5477"
+description: "Le Feather ESP32-S3 que j'utilise."
+#prompt: ""
diff --git a/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/data/images/IMG_0067.yaml b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/data/images/IMG_0067.yaml
new file mode 100644
index 00000000..5c04343f
--- /dev/null
+++ b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/data/images/IMG_0067.yaml
@@ -0,0 +1,4 @@
+#title: ""
+attribution: "Richard Dern"
+description: "Mon module est connecté à l'ESP32 par l'intermédiaire d'un multiplexeur (un [TCA9548A](https://esphome.io/components/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."
+#prompt: ""
diff --git a/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/data/images/fix.yaml b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/data/images/fix.yaml
new file mode 100644
index 00000000..e74d41a7
--- /dev/null
+++ b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/data/images/fix.yaml
@@ -0,0 +1,4 @@
+#title: ""
+attribution: "Richard Dern"
+description: "Flux de travail pour l'obtention d'un fix GPS."
+#prompt: ""
diff --git a/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/data/images/mobile-sensor.yaml b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/data/images/mobile-sensor.yaml
new file mode 100644
index 00000000..685fe677
--- /dev/null
+++ b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/data/images/mobile-sensor.yaml
@@ -0,0 +1,4 @@
+#title: ""
+attribution: "Richard Dern"
+description: "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."
+#prompt: ""
diff --git a/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/diagrams/fix.mermaid b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/diagrams/fix.mermaid
new file mode 100644
index 00000000..e5c33734
--- /dev/null
+++ b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/diagrams/fix.mermaid
@@ -0,0 +1,44 @@
+flowchart TD
+ A[Intervalle ESPHome toutes les 100 ms] --> B[Lecture I²C depuis gps_i2c
jusqu'à 4 passes de 128 octets]
+ B --> C[Ajout des caractères non nuls dans nmea_buf]
+ C --> D{nmea_buf contient-il
au moins un saut de ligne ?}
+
+ D -- Non --> A
+ D -- Oui --> E[Extraction d'une ligne complète]
+ E --> F[Suppression CR/LF]
+ F --> G{Ligne vide ou ne commence pas par '$' ?}
+
+ G -- Oui --> D
+ G -- Non --> H[Vérification du checksum NMEA]
+
+ H -- Échec --> I[Incrément des trames invalides]
+ I --> D
+
+ H -- Succès --> J[Incrément des trames valides]
+ J --> K[Découpage CSV en fields]
+ K --> L[Identification du type de trame
ex. GGA RMC GSA GSV ZDA GST PMTK]
+
+ L --> M{Type = GGA ?}
+ M -- Oui --> N[Lecture des champs GGA]
+ N --> O[fields_6 = fix quality]
+ O --> P{fix quality > 0 ?}
+ P -- Oui --> Q[Publier gps_has_fix = true]
+ P -- Non --> R[Publier gps_has_fix = false]
+ Q --> S[Publier qualité fix, satellites utilisés,
HDOP, altitude, latitude, longitude]
+ R --> S
+ S --> D
+
+ M -- Non --> T{Type = RMC ?}
+ T -- Oui --> U[Lecture des champs RMC]
+ U --> V[fields_2 = statut]
+ V --> W{statut = 'A' ?}
+ W -- Oui --> X[Publier gps_has_fix = true]
+ W -- Non --> Y[Publier gps_has_fix = false]
+ X --> Z[Publier latitude, longitude,
vitesse, cap, date, heure]
+ Y --> Z
+ Z --> D
+
+ T -- Non --> AA{Autre type utile ?}
+ AA -- Oui --> AB[Mettre à jour d'autres entités
GSA GSV ZDA GST PMTK etc.]
+ AB --> D
+ AA -- Non --> D
\ No newline at end of file
diff --git a/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/images/4415-08.jpg b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/images/4415-08.jpg
new file mode 100644
index 00000000..5d13d8a1
Binary files /dev/null and b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/images/4415-08.jpg differ
diff --git a/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/images/5477-07.jpg b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/images/5477-07.jpg
new file mode 100644
index 00000000..a324ffa9
Binary files /dev/null and b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/images/5477-07.jpg differ
diff --git a/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/images/IMG_0067.jpeg b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/images/IMG_0067.jpeg
new file mode 100644
index 00000000..9c21acc2
Binary files /dev/null and b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/images/IMG_0067.jpeg differ
diff --git a/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/images/fix.png b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/images/fix.png
new file mode 100644
index 00000000..21641783
Binary files /dev/null and b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/images/fix.png differ
diff --git a/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/images/mobile-sensor.png b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/images/mobile-sensor.png
new file mode 100644
index 00000000..0290b73e
Binary files /dev/null and b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/images/mobile-sensor.png differ
diff --git a/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/index.md b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/index.md
new file mode 100644
index 00000000..e8cec172
--- /dev/null
+++ b/content/interets/electronique/2026/04/01/utiliser-un-gps-en-i2c-sur-esp32-avec-esphome/index.md
@@ -0,0 +1,2131 @@
+---
+comments_url: https://com.richard-dern.fr/post/549
+cover: images/mobile-sensor.png
+date: '2026-04-01 16:10:19'
+entreprises:
+- Adafruit
+- Sparkfun
+tags:
+- ESPHome
+- Home Assistant
+- GPS
+- ESP32
+- PA1010D
+- NMEA
+- PMTK
+title: Utiliser un GPS en I²C sur ESP32 avec ESPHome
+weather:
+ humidity: 55.0
+ illuminance: 12771.6
+ precipitations: false
+ pressure: 1023.36664711105
+ source:
+ - influxdb
+ temperature: 8.77777777777778
+ wind_direction: 66.0
+ wind_speed: 5.3108352
+---
+
+J'ai à ma disposition un [ESP32-S3](https://www.adafruit.com/product/5477) et un module GPS [PA1010D](https://www.adafruit.com/product/4415) de chez [Adafruit](https://www.adafruit.com/).
+Ça fait un moment que je cherche à m'en servir, mais le composant [ESPHome](https://esphome.io/) pour le [GPS](https://esphome.io/components/gps/) est limité à l'[UART](https://fr.wikipedia.org/wiki/UART).
+Or, Adafruit fournit une connectique [STEMMA QT](https://learn.adafruit.com/introducing-adafruit-stemma-qt/what-is-stemma-qt) qui exploite l'[I²C](https://fr.wikipedia.org/wiki/I2C).
+
+
+
+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](https://www.home-assistant.io/).
+
+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](https://esphome.io/automations/templates/#config-lambda).
+J'ai donc mis [ChatGPT](https://chatgpt.com/) au travail pour me pondre un [yaml](https://fr.wikipedia.org/wiki/YAML) approprié.
+
+## Le PA1010D
+
+
+
+C'est un petit module [GNSS](https://fr.wikipedia.org/wiki/Système_de_positionnement_par_satellites) de 25 mm de côté environ, doté d'une antenne patch intégrée, basé sur un chipset [MT3333](https://www.datasheetq.com/en/preview/MT3333-MediaTek), compatible [GPS](https://fr.wikipedia.org/wiki/Global_Positioning_System), [GLONASS](https://fr.wikipedia.org/wiki/GLONASS), [Galileo]() et [QZSS](https://fr.wikipedia.org/wiki/Quasi-Zenith_Satellite_System).
+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](https://fr.wikipedia.org/wiki/Horloge_temps_réel) de type [CR1220](https://www.amazon.fr/dp/B08J4QR18R/) 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 :
+
+- la latitude et la longitude ;
+- la vitesse au sol ;
+- le cap, c'est-à-dire la direction, exprimé en degrés ;
+- l'altitude ;
+- le nombre de satellites utilisés pour le calcul de la position ;
+- et le HDOP qui indique la précision horizontale de la position.
+
+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](https://cdn-shop.adafruit.com/datasheets/PMTK_A11.pdf)), qui permet la configuration du module.
+Les deux commandes les plus importantes sont :
+
+- `$PMTK314`, qui permet de configurer les phrases NMEA que le module émettra (et donc les réponses obtenues),
+- `$PMTK220`, qui permet de définir l'intervalle de mise à jour.
+
+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](https://cdn.sparkfun.com/assets/parts/1/2/2/8/0/PMTK_Packet_User_Manual.pdf) 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.
+
+```yaml
+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::vector 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](https://fr.wikipedia.org/wiki/WGS_84), le système de référence mondial imposant :
+
+- le modèle mathématique de la Terre (ellipsoïde),
+- l'origine du repère (centre de masse de la Terre),
+- la définition des coordonnées latitude / longitude / altitude.
+
+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 :
+
+- supporte PMTK avancé (LOG, EPO, datum, etc.)
+- expose des fonctionnalités internes non triviales
+- est bien au-delà d'un simple "GPS NMEA basique"
+
+```
+[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` :
+
+```yaml
+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.
+
+```yaml
+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
+
+
+
+```yaml
+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::vector 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 |
+
+
+
+## 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 :
+
+```yaml
+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 :
+
+```cpp
+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 :
+
+```text
+$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 :
+
+```cpp
+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 :
+
+```cpp
+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 :
+
+```cpp
+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 :
+
+```cpp
+auto split_csv = [](const std::string &line) -> std::vector {
+ std::vector 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 :
+
+```text
+$GPRMC,123519,A,...
+```
+
+devient quelque chose comme :
+
+```cpp
+fields[0] = "$GPRMC"
+fields[1] = "123519"
+fields[2] = "A"
+...
+```
+
+Après cela, le code entre dans cette boucle :
+
+```cpp
+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` :
+
+```cpp
+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 :
+
+```cpp
+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` :
+
+```cpp
+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 :
+
+```cpp
+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 :
+
+- une `GGA` arrive avec qualité 0, donc `GPS Fix = false`
+- puis une `RMC` arrive avec statut `A`, donc `GPS Fix = true`
+- puis une autre `GGA` arrive avec qualité 1, donc `GPS Fix = true`
+
+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 :
+
+```cpp
+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 :
+
+```cpp
+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.
+
+- Le GPS produit une phrase comme `$GPGGA...` ou `$GNRMC...`.
+- Notre ESP32 la lit en morceaux sur l'I²C.
+- Ces morceaux sont ajoutés à `nmea_buf`.
+- Quand un `\n` apparaît, notre code extrait une ligne complète.
+- La ligne est nettoyée, puis son checksum est vérifié.
+- Si elle est valide, elle est découpée en champs.
+- Si son type finit par `GGA`, notre code lit `fields[6]`. Si ce champ est supérieur à 0, `gps_has_fix` devient vrai.
+- Si son type finit par `RMC`, notre code lit `fields[2]`. Si ce champ vaut `A`, `gps_has_fix` devient vrai.
+
+Dans les deux cas, la trame sert aussi à mettre à jour les données de position et d'état associées.
+
+
+
+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.
\ No newline at end of file