--- 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). ![](images/5477-07.jpg) 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 ![](images/4415-08.jpg) 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 ne 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 ![](images/IMG_0067.jpeg) ```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 | ![](images/mobile-sensor.png) ## 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. ![](images/fix.png) 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.