J’ai à ma disposition un ESP32-S3 et un module GPS PA1010D de chez Adafruit. Ça fait un moment que je cherche à m’en servir, mais le composant ESPHome pour le GPS est limité à l’UART. Or, Adafruit fournit une connectique STEMMA QT qui exploite l’I²C.
Le module GPS est le seul qui nous intéresse ici, mais ce montage s’inscrit dans un projet plus vaste, faisant appel à plusieurs modules connectés en STEMMA QT. Je veux donc garder le montage propre, et éviter les soudures à la main. En outre, je veux vraiment utiliser ESPHome pour simplifier l’intégration de mon projet dans mon écosystème domotique sous Home Assistant.
Malheureusement, je n’ai pas les compétences pour développer un composant ESPHome, ou pour étendre un composant existant. J’ajoute que je n’avais pas envie de créer tout un dépôt rien que pour ça, alors qu’ESPHome est parfaitement capable de gérer des expressions lambda. J’ai donc mis ChatGPT au travail pour me pondre un yaml approprié.
Le PA1010D
C’est un petit module GNSS de 25 mm de côté environ, doté d’une antenne patch intégrée, basé sur un chipset MT3333, compatible GPS, GLONASS, Galileo et QZSS. Adafruit indique une sensibilité de suivi de -165 dBm, une consommation de l’ordre de 30 mA pendant la navigation et une cadence pouvant aller jusqu’à 10 mises à jour par seconde. Le module expose à la fois une interface UART et une interface I²C. Adafruit précise aussi qu’il est compatible avec une pile RTC de type CR1220 pour les démarrages à chaud, et qu’il dispose d’une LED rouge qui s’illumine quand un fix est acquis, en plus de la LED verte de mise sous tension.
J’ai intégré ce composant à mon projet pour répondre à plusieurs attentes. La plus poétique d’entre elles est de voir interagir le matériel et le logiciel avec le monde réel, mais de façon plus prosaïque, je veux pouvoir récupérer une horloge de référence, et obtenir les coordonnées GPS les plus précises possibles à partir d’un capteur aussi modeste, ainsi que son altitude.
ESPHome et le GPS
Le composant GPS d’ESPHome, on l’a dit, nécessite la configuration d’une interface UART. Il fournit ensuite les capteurs suivants :
- 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), 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 de ce document datant de 2016.
En pratique évidemment, on n’a pas besoin d’aller fouiller les commandes disponibles : seules celles indiquées ci-dessus sont utiles pour répondre à mon cahier des charges.
Il n’empêche que j’ai envie de procéder de façon méthodique : on doit avant tout obtenir la version du firmware du module, grâce à la commande $PMTK605, qui devrait répondre avec un paquet $PMTK705.
On peut déterminer la liste complète des paquets, mais tous ne sont pas disponibles sur tous les modules GPS.
| Commande | Type | Explication |
|---|---|---|
PMTK000 |
SYS | Paquet de test. |
PMTK001 |
ACK | Accusé de réception d’une commande PMTK (succès ou erreur). |
PMTK010 |
SYS | Message système émis par le module. |
PMTK011 |
SYS | Message texte émis par le module. |
PMTK101 |
SET | Redémarrage à chaud (hot start). |
PMTK102 |
SET | Redémarrage à tiède (warm start). |
PMTK103 |
SET | Redémarrage à froid (cold start). |
PMTK104 |
SET | Redémarrage complet avec reset des paramètres. |
PMTK220 |
SET | Définit l’intervalle de mise à jour NMEA. |
PMTK400 |
Q | Demande l’intervalle de mise à jour actuel. |
PMTK500 |
DT | Réponse contenant l’intervalle de mise à jour. |
PMTK251 |
SET | Configure le débit série du port NMEA. |
PMTK301 |
SET | Configure le mode DGPS. |
PMTK401 |
Q | Demande le mode DGPS. |
PMTK501 |
DT | Réponse indiquant le mode DGPS. |
PMTK313 |
SET | Active/désactive le SBAS. |
PMTK413 |
Q | Demande l’état du SBAS. |
PMTK513 |
DT | Réponse indiquant l’état du SBAS. |
PMTK314 |
SET | Configure les phrases NMEA émises. |
PMTK414 |
Q | Demande la configuration des phrases NMEA. |
PMTK514 |
DT | Réponse avec la configuration NMEA. |
PMTK605 |
Q | Demande la version du firmware. |
PMTK705 |
DT | Réponse contenant la version du firmware. |
PMTK607 |
Q | Demande l’état des données EPO. |
PMTK707 |
DT | Réponse sur l’état des données EPO. |
PMTK127 |
SET | Efface les données EPO. |
PMTK386 |
SET | Définit le seuil de navigation statique. |
PMTK447 |
Q | Demande le seuil de navigation statique. |
PMTK527 |
DT | Réponse contenant le seuil configuré. |
PMTK161 |
SET | Passage en mode veille. |
PMTK223 |
SET | Configure la gestion des éphémérides. |
PMTK225 |
SET | Configure le mode veille périodique. |
PMTK255 |
SET | Synchronisation PPS / NMEA. |
PMTK286 |
SET | Active/désactive l’AIC (anti-interférences). |
PMTK869 |
SET/Q | Active/désactive ou interroge EASY. |
PMTK886 |
SET | Définit le mode de navigation (piéton, véhicule…). |
PMTK330 |
SET | Configure le datum géodésique. |
PMTK331 |
SET | Définit un datum personnalisé. |
PMTK431 |
Q | Demande le datum personnalisé. |
PMTK353 |
SET | Configure les constellations GNSS utilisées. |
PMTK430 |
Q | Demande le datum courant. |
PMTK530 |
DT | Réponse avec le datum courant. |
PMTK183 |
Q | Demande l’état du logger LOCUS. |
PMTK184 |
SET | Efface la mémoire du logger LOCUS. |
PMTK185 |
SET | Active/désactive le logger LOCUS. |
PMTK186 |
SET | Force l’écriture d’un point dans le log. |
PMTK187 |
SET | Configure le logger LOCUS. |
PMTK622 |
Q | Demande le dump des données LOCUS. |
PMTK602 |
Q | Demande la configuration du port de données. |
PMTK702 |
DT | Réponse avec configuration du port de données. |
PMTK285 |
SET | Configure le signal PPS. |
PMTK299 |
SET | Active/désactive les logs de debug. |
PMTK355 |
Q | Demande la configuration GNSS. |
PMTK356 |
SET | Définit un seuil HDOP. |
PMTK357 |
Q | Demande le seuil HDOP. |
PMTK435 |
Q | Demande l’heure RTC UTC. |
PMTK535 |
DT | Réponse contenant l’heure RTC UTC. |
PMTK250 |
SET | Configure le port UART (type + débit). |
Q désigne une commande de requête, DT désigne une réponse, SET désigne une commande de définition de configuration.
Configuration yaml minimale
Ce bloc de configuration est minimal pour obtenir la version du firmware du PA1010D : vous devrez le compléter avec les blocs habituels pour le wifi, la configuration de la board utilisée, l’I²C, etc. Voyez le code complet à la fin de l’article.
esphome:
on_boot:
- priority: 200
then:
- lambda: |-
auto nmea_checksum = [](const std::string &s) -> uint8_t {
uint8_t cs = 0;
for (char c : s) cs ^= (uint8_t) c;
return cs;
};
auto send_pmtk = [&](const std::string &body) {
uint8_t cs = nmea_checksum(body);
char tail[8];
snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
std::string line = "$" + body + tail;
id(gps_i2c).write((const uint8_t *) line.data(), line.size());
ESP_LOGD("gps", "TX: %s", line.c_str());
};
send_pmtk("PMTK605");
delay(200);
send_pmtk("PMTK414");
delay(200);
send_pmtk("PMTK447");
delay(200);
send_pmtk("PMTK430");
delay(200);
send_pmtk("PMTK607");
delay(200);
send_pmtk("PMTK183");
delay(200);
send_pmtk("PMTK602");
globals:
- id: nmea_buf
type: std::string
restore_value: no
initial_value: '""'
- id: nmea_types_seen
type: std::string
restore_value: no
initial_value: '""'
text_sensor:
- platform: template
name: "GPS Firmware"
id: gps_firmware
entity_category: diagnostic
- platform: template
name: "GPS Datum"
id: gps_datum
entity_category: diagnostic
- platform: template
name: "GPS EPO"
id: gps_epo_status
entity_category: diagnostic
- platform: template
name: "GPS Configuration NMEA"
id: gps_nmea_config
entity_category: diagnostic
- platform: template
name: "GPS PORT brut"
id: gps_port_raw
entity_category: diagnostic
interval:
- interval: 100ms
then:
- lambda: |-
uint8_t data[128];
for (int pass = 0; pass < 4; pass++) {
if (!id(gps_i2c).read_bytes_raw(data, sizeof(data))) {
break;
}
for (size_t j = 0; j < sizeof(data); j++) {
char c = (char) data[j];
if (c == '\0') continue;
id(nmea_buf).push_back(c);
}
}
if (id(nmea_buf).size() > 8192) {
id(nmea_buf).erase(0, id(nmea_buf).size() - 4096);
}
auto trim_crlf = [](std::string &s) {
while (!s.empty() && (s.back() == '\r' || s.back() == '\n')) s.pop_back();
};
auto verify_checksum = [](const std::string &s) -> bool {
auto star = s.find('*');
if (s.size() < 4 || s[0] != '$' || star == std::string::npos) return false;
if (star + 2 >= s.size()) return false;
uint8_t cs = 0;
for (size_t i = 1; i < star; i++) cs ^= (uint8_t) s[i];
auto hex = [](char ch) -> int {
if (ch >= '0' && ch <= '9') return ch - '0';
if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10;
if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10;
return -1;
};
int hi = hex(s[star + 1]);
int lo = hex(s[star + 2]);
if (hi < 0 || lo < 0) return false;
uint8_t want = (uint8_t) ((hi << 4) | lo);
return cs == want;
};
auto split_csv = [](const std::string &line) -> std::vector<std::string> {
std::vector<std::string> out;
std::string cur;
for (char ch : line) {
if (ch == ',' || ch == '*') {
out.push_back(cur);
cur.clear();
if (ch == '*') break;
} else {
cur.push_back(ch);
}
}
out.push_back(cur);
return out;
};
auto to_float = [](const std::string &s, float *out) -> bool {
if (s.empty()) return false;
char *end = nullptr;
float v = strtof(s.c_str(), &end);
if (end == s.c_str()) return false;
*out = v;
return true;
};
auto to_int = [](const std::string &s, int *out) -> bool {
if (s.empty()) return false;
char *end = nullptr;
long v = strtol(s.c_str(), &end, 10);
if (end == s.c_str()) return false;
*out = (int) v;
return true;
};
auto ddmm_to_deg = [](const std::string &ddmm, char hemi, float *out) -> bool {
if (ddmm.size() < 4) return false;
auto dot = ddmm.find('.');
size_t min_start = (dot == std::string::npos) ? ddmm.size() - 2 : (dot >= 2 ? dot - 2 : 0);
if (min_start < 2) return false;
std::string deg_str = ddmm.substr(0, min_start);
std::string min_str = ddmm.substr(min_start);
char *end = nullptr;
float deg = strtof(deg_str.c_str(), &end);
if (end == deg_str.c_str()) return false;
end = nullptr;
float minutes = strtof(min_str.c_str(), &end);
if (end == min_str.c_str()) return false;
float v = deg + minutes / 60.0f;
if (hemi == 'S' || hemi == 'W') v = -v;
*out = v;
return true;
};
auto hhmmss_to_text = [](const std::string &s) -> std::string {
if (s.size() < 6) return "";
char buf[16];
snprintf(buf, sizeof(buf), "%c%c:%c%c:%c%c", s[0], s[1], s[2], s[3], s[4], s[5]);
return std::string(buf);
};
auto ddmmyy_to_text = [](const std::string &s) -> std::string {
if (s.size() != 6) return "";
int yy = (s[4] - '0') * 10 + (s[5] - '0');
int year = (yy >= 70) ? (1900 + yy) : (2000 + yy);
char buf[16];
snprintf(buf, sizeof(buf), "%04d-%c%c-%c%c", year, s[2], s[3], s[0], s[1]);
return std::string(buf);
};
while (true) {
auto nl = id(nmea_buf).find('\n');
if (nl == std::string::npos) break;
std::string line = id(nmea_buf).substr(0, nl + 1);
id(nmea_buf).erase(0, nl + 1);
trim_crlf(line);
if (line.empty() || line[0] != '$') continue;
if (!verify_checksum(line)) continue;
auto fields = split_csv(line);
if (fields.empty()) continue;
const std::string &type = fields[0];
bool is_pmtk = type.rfind("$PMTK", 0) == 0;
if (!is_pmtk && type.size() >= 6 && type[0] == '$') {
std::string short_type = type.substr(type.size() - 3);
std::string needle = "," + short_type + ",";
std::string haystack = "," + id(nmea_types_seen) + ",";
if (haystack.find(needle) == std::string::npos) {
if (!id(nmea_types_seen).empty()) id(nmea_types_seen) += ",";
id(nmea_types_seen) += short_type;
id(gps_nmea_types).publish_state(id(nmea_types_seen));
ESP_LOGI("gps", "Nouveau type NMEA: %s", short_type.c_str());
}
}
if (type == "$PMTK705") {
std::string fw;
for (size_t i = 1; i < fields.size(); i++) {
if (!fields[i].empty()) {
if (!fw.empty()) fw += ",";
fw += fields[i];
}
}
id(gps_firmware).publish_state(fw);
ESP_LOGI("gps", "Firmware: %s", fw.c_str());
continue;
}
if (type == "$PMTK514") {
id(gps_nmea_config).publish_state(line);
ESP_LOGI("gps", "NMEA config: %s", line.c_str());
continue;
}
if (type == "$PMTK527" && fields.size() >= 2) {
float v;
if (to_float(fields[1], &v)) {
id(gps_nav_threshold).publish_state(v);
}
ESP_LOGI("gps", "Nav threshold: %s", line.c_str());
continue;
}
if (type == "$PMTK530" && fields.size() >= 2) {
id(gps_datum).publish_state(fields[1] == "0" ? "WGS84" : fields[1]);
ESP_LOGI("gps", "Datum: %s", line.c_str());
continue;
}
if (type == "$PMTK707") {
bool epo_present = false;
for (size_t i = 1; i < fields.size(); i++) {
if (!fields[i].empty() && fields[i] != "0") {
epo_present = true;
break;
}
}
id(gps_epo_status).publish_state(epo_present ? "présent" : "absent");
ESP_LOGI("gps", "EPO: %s", line.c_str());
continue;
}
if (type == "$PMTKLOG") {
id(gps_log_raw).publish_state(line);
ESP_LOGI("gps", "LOG: %s", line.c_str());
continue;
}
if (type == "$PMTK702") {
id(gps_port_raw).publish_state(line);
ESP_LOGI("gps", "PORT: %s", line.c_str());
continue;
}
}
On fournit ici une infrastructure minimale pour l’émission et la réception de paquets PMTK, incluant le calcul des sommes de contrôle.
Premières données
Après compilation et installation, on devrait voir apparaitre quelques lignes dans le journal :
[23:30:05.859][I][gps:197]: Firmware: AXN_5.1.7_3333_19020118,0027,PA1010D,1.0
On voit bien qu’on a reçu un paquet $PMTK705 contenant la version du firmware, et qui se termine par la somme de contrôle (*76).
Dans le on_boot, on a aussi envoyé la commande $PMTK414 qui répond avec $PMTK514.
Cette commande nous informe des 19 phrases NMEA activées par défaut.
Par principe, on va dire qu’on ne sait pas quelles sont ces phrases à ce stade, dans la mesure où l’on considèrera la documentation non-fiable.
[00:12:06.168][I][gps:205]: NMEA config: $PMTK514,0,1,1,1,1,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0*37
On peut toutefois déduire de cette ligne que les phrases 1 à 5 sont activées, et la cinquième fournit une sortie tous les 5 fix.
[00:21:27.724][I][gps:209]: Nav threshold: $PMTK527,0.00*00
On peut déduire de cette ligne que le seuil de vitesse pour que le capteur se considère en mouvement est de 0 ; autrement dit, le GPS de filtre pas l’immobilité, la position est mise à jour en permanence, même à l’arrêt.
C’est une bonne chose pour l’instrumentation ou pour un tracking précis.
[00:25:55.240][I][gps:216]: Datum: $PMTK530,0*28
Ici, on récupère le datum 0 qui correspond à WGS84, le système de référence mondial imposant :
- 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 :
send_pmtk("PMTK314,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1");
delay(200);
send_pmtk("PMTK414");
Cette fois, les phrases activées ont changé :
[01:19:34.039][I][gps:205]: NMEA config: $PMTK514,1,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,0,0,0*32
Les phrases 8 à 12 restent désactivées sur ce module, malgré notre activation forcée. Elles doivent correspondre aux phrases “réservées” indiquées dans les documentations.
Extraction des informations
On peut alors détecter proprement les phrases NEMA réellement supportées assez facilement.
text_sensor:
- platform: template
name: "GPS Types NMEA détectés"
id: gps_nmea_types
entity_category: diagnostic
Le capteur fraîchement créé m’indique alors une série de “mots” de 3 lettres (ainsi que des séries de chiffres et LOG qui correspondent aux réponses PMTK, qu’on ignore ici) :
GST,DTM,GGA,GLL,GSA,GSV,VTG,ZDA,GRS,RMC,CHN
Ce qui correspond aux phrases suivantes, celles que l’on peut réellement utiliser avec notre module :
| Type | Signification |
|---|---|
| GLL | latitude / longitude |
| RMC | données minimales (position, vitesse, date/heure) |
| VTG | cap et vitesse au sol |
| GGA | position + altitude + qualité du fix |
| GSA | satellites utilisés + DOP (précision) |
| GSV | satellites visibles (liste détaillée) |
| GRS | résidus de pseudorange (qualité des mesures) |
| GST | estimation d’erreur de position (précision avancée) |
| DTM | datum géodésique utilisé |
| ZDA | date et heure complètes |
| CHN | état des canaux GNSS (propriétaire MediaTek) |
À ce stade, on obtient des données brutes, qu’il faut encore formater pour en faire des capteurs utiles.
Code complet
esphome:
name: mobile-sensor
friendly_name: mobile-sensor
on_boot:
- priority: 1100
then:
- lambda: |-
gpio_set_direction((gpio_num_t)7, GPIO_MODE_OUTPUT);
gpio_set_level((gpio_num_t)7, 1);
gpio_set_direction((gpio_num_t)21, GPIO_MODE_OUTPUT);
gpio_set_level((gpio_num_t)21, 1);
- priority: 200
then:
- lambda: |-
auto nmea_checksum = [](const std::string &s) -> uint8_t {
uint8_t cs = 0;
for (char c : s) cs ^= (uint8_t) c;
return cs;
};
auto send_pmtk = [&](const std::string &body) {
uint8_t cs = nmea_checksum(body);
char tail[8];
snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
std::string line = "$" + body + tail;
id(gps_i2c).write((const uint8_t *) line.data(), line.size());
id(gps_last_pmtk_tx).publish_state(body);
ESP_LOGI("gps", "TX: %s", line.c_str());
};
delay(300);
send_pmtk("PMTK605");
delay(200);
send_pmtk("PMTK414");
delay(200);
send_pmtk("PMTK447");
delay(200);
send_pmtk("PMTK430");
delay(200);
send_pmtk("PMTK607");
delay(200);
send_pmtk("PMTK183");
delay(200);
send_pmtk("PMTK602");
esp32:
board: adafruit_feather_esp32s3
cpu_frequency: 240MHz
framework:
type: esp-idf
psram:
mode: quad
logger:
level: INFO
api:
encryption:
key: !secret api
ota:
- platform: esphome
password: !secret ota
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: "Mobile-Sensor Fallback Hotspot"
password: !secret ap
web_server:
port: 80
auth:
username: !secret web_user
password: !secret web_passwd
include_internal: true
ota: false
local: true
version: 3
captive_portal:
i2c:
sda: GPIO03
scl: GPIO04
scan: true
id: i2c0
tca9548a:
- address: 0x70
id: multiplex0
i2c_id: i2c0
channels:
- bus_id: multiplex0channel6
channel: 6
i2c_device:
id: gps_i2c
i2c_id: multiplex0channel6
address: 0x10
globals:
- id: nmea_buf
type: std::string
restore_value: no
initial_value: '""'
- id: nmea_types_seen
type: std::string
restore_value: no
initial_value: '""'
- id: last_fix_millis
type: uint32_t
restore_value: no
initial_value: "0"
- id: last_valid_sentence_millis
type: uint32_t
restore_value: no
initial_value: "0"
- id: valid_sentence_count
type: uint32_t
restore_value: no
initial_value: "0"
- id: invalid_sentence_count
type: uint32_t
restore_value: no
initial_value: "0"
sensor:
- platform: template
name: "GPS Seuil de mouvement"
id: gps_nav_threshold
unit_of_measurement: "m/s"
accuracy_decimals: 2
device_class: speed
state_class: measurement
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Latitude"
id: gps_latitude
unit_of_measurement: "°"
accuracy_decimals: 6
state_class: measurement
update_interval: never
- platform: template
name: "GPS Longitude"
id: gps_longitude
unit_of_measurement: "°"
accuracy_decimals: 6
state_class: measurement
update_interval: never
- platform: template
name: "GPS Altitude"
id: gps_altitude
unit_of_measurement: "m"
accuracy_decimals: 1
device_class: distance
state_class: measurement
update_interval: never
- platform: template
name: "GPS Vitesse"
id: gps_speed_kmh
unit_of_measurement: "km/h"
accuracy_decimals: 2
device_class: speed
state_class: measurement
update_interval: never
- platform: template
name: "GPS Cap"
id: gps_course_deg
unit_of_measurement: "°"
accuracy_decimals: 1
state_class: measurement
update_interval: never
- platform: template
name: "GPS Variation magnétique"
id: gps_mag_variation
unit_of_measurement: "°"
accuracy_decimals: 1
state_class: measurement
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Satellites utilisés"
id: gps_satellites_used
accuracy_decimals: 0
state_class: measurement
update_interval: never
- platform: template
name: "GPS Satellites visibles"
id: gps_satellites_visible
accuracy_decimals: 0
state_class: measurement
update_interval: never
- platform: template
name: "GPS Satellites visibles GPS"
id: gps_satellites_visible_gps
accuracy_decimals: 0
state_class: measurement
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Satellites visibles GLONASS"
id: gps_satellites_visible_glonass
accuracy_decimals: 0
state_class: measurement
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Satellites visibles GNSS mixte"
id: gps_satellites_visible_gn
accuracy_decimals: 0
state_class: measurement
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Qualité du fix"
id: gps_fix_quality
accuracy_decimals: 0
state_class: measurement
update_interval: never
- platform: template
name: "GPS Type de fix"
id: gps_fix_type
accuracy_decimals: 0
state_class: measurement
update_interval: never
- platform: template
name: "GPS HDOP"
id: gps_hdop
accuracy_decimals: 2
state_class: measurement
update_interval: never
- platform: template
name: "GPS PDOP"
id: gps_pdop
accuracy_decimals: 2
state_class: measurement
update_interval: never
- platform: template
name: "GPS VDOP"
id: gps_vdop
accuracy_decimals: 2
state_class: measurement
update_interval: never
- platform: template
name: "GPS SNR max"
id: gps_snr_max
unit_of_measurement: "dB"
accuracy_decimals: 0
state_class: measurement
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS SNR max GPS"
id: gps_snr_max_gps
unit_of_measurement: "dB"
accuracy_decimals: 0
state_class: measurement
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS SNR max GLONASS"
id: gps_snr_max_glonass
unit_of_measurement: "dB"
accuracy_decimals: 0
state_class: measurement
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS SNR max GNSS mixte"
id: gps_snr_max_gn
unit_of_measurement: "dB"
accuracy_decimals: 0
state_class: measurement
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS GST RMS"
id: gps_gst_rms
unit_of_measurement: "m"
accuracy_decimals: 2
device_class: distance
state_class: measurement
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS GST Sigma latitude"
id: gps_gst_sigma_lat
unit_of_measurement: "m"
accuracy_decimals: 2
device_class: distance
state_class: measurement
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS GST Sigma longitude"
id: gps_gst_sigma_lon
unit_of_measurement: "m"
accuracy_decimals: 2
device_class: distance
state_class: measurement
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS GST Sigma altitude"
id: gps_gst_sigma_alt
unit_of_measurement: "m"
accuracy_decimals: 2
device_class: distance
state_class: measurement
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Âge du dernier fix"
id: gps_fix_age_s
unit_of_measurement: "s"
accuracy_decimals: 0
state_class: measurement
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Trames valides"
id: gps_valid_sentence_count
accuracy_decimals: 0
state_class: total_increasing
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Trames invalides"
id: gps_invalid_sentence_count
accuracy_decimals: 0
state_class: total_increasing
entity_category: diagnostic
update_interval: never
text_sensor:
- platform: template
name: "GPS Firmware"
id: gps_firmware
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Dernier PMTK envoyé"
id: gps_last_pmtk_tx
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Dernier ACK PMTK"
id: gps_last_pmtk_ack
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Statut du dernier ACK"
id: gps_last_pmtk_ack_status
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Message système"
id: gps_system_message
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Type de constellation"
id: gps_constellation
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Type de fix texte"
id: gps_fix_type_text
update_interval: never
- platform: template
name: "GPS Qualité du fix texte"
id: gps_fix_quality_text
update_interval: never
- platform: template
name: "GPS Qualité globale"
id: gps_quality_text
update_interval: never
- platform: template
name: "GPS Mode navigation"
id: gps_nav_mode_text
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Antenne"
id: gps_antenna_status
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Datum"
id: gps_datum
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS EPO"
id: gps_epo_status
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Configuration NMEA"
id: gps_nmea_config
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Types NMEA détectés"
id: gps_nmea_types
entity_category: diagnostic
update_interval: never
- platform: template
name: "GPS Date UTC"
id: gps_date_utc
update_interval: never
- platform: template
name: "GPS Heure UTC"
id: gps_time_utc
update_interval: never
- platform: template
name: "GPS DateTime UTC"
id: gps_datetime_utc
update_interval: never
- platform: template
name: "GPS DTM brut"
id: gps_dtm_raw
entity_category: diagnostic
disabled_by_default: true
update_interval: never
- platform: template
name: "GPS CHN brut"
id: gps_chn_raw
entity_category: diagnostic
disabled_by_default: true
update_interval: never
- platform: template
name: "GPS LOG brut"
id: gps_log_raw
entity_category: diagnostic
disabled_by_default: true
update_interval: never
- platform: template
name: "GPS PORT brut"
id: gps_port_raw
entity_category: diagnostic
disabled_by_default: true
update_interval: never
binary_sensor:
- platform: template
name: "GPS Fix"
id: gps_has_fix
- platform: template
name: "GPS Position fraîche"
id: gps_fresh_fix
device_class: connectivity
entity_category: diagnostic
lambda: |-
if (id(last_fix_millis) == 0) return false;
return (millis() - id(last_fix_millis)) < 15000;
- platform: template
name: "GPS Configuration valide"
id: gps_config_valid
device_class: connectivity
entity_category: diagnostic
- platform: template
name: "GPS Problème antenne"
id: gps_antenna_problem
device_class: problem
entity_category: diagnostic
button:
- platform: template
name: "GPS Interroger firmware"
entity_category: config
on_press:
- lambda: |-
auto nmea_checksum = [](const std::string &s) -> uint8_t {
uint8_t cs = 0;
for (char c : s) cs ^= (uint8_t) c;
return cs;
};
auto send_pmtk = [&](const std::string &body) {
uint8_t cs = nmea_checksum(body);
char tail[8];
snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
std::string line = "$" + body + tail;
id(gps_i2c).write((const uint8_t *) line.data(), line.size());
id(gps_last_pmtk_tx).publish_state(body);
ESP_LOGI("gps", "TX: %s", line.c_str());
};
send_pmtk("PMTK605");
- platform: template
name: "GPS Interroger configuration NMEA"
entity_category: config
on_press:
- lambda: |-
auto nmea_checksum = [](const std::string &s) -> uint8_t {
uint8_t cs = 0;
for (char c : s) cs ^= (uint8_t) c;
return cs;
};
auto send_pmtk = [&](const std::string &body) {
uint8_t cs = nmea_checksum(body);
char tail[8];
snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
std::string line = "$" + body + tail;
id(gps_i2c).write((const uint8_t *) line.data(), line.size());
id(gps_last_pmtk_tx).publish_state(body);
ESP_LOGI("gps", "TX: %s", line.c_str());
};
send_pmtk("PMTK414");
- platform: template
name: "GPS Interroger seuil de mouvement"
entity_category: config
on_press:
- lambda: |-
auto nmea_checksum = [](const std::string &s) -> uint8_t {
uint8_t cs = 0;
for (char c : s) cs ^= (uint8_t) c;
return cs;
};
auto send_pmtk = [&](const std::string &body) {
uint8_t cs = nmea_checksum(body);
char tail[8];
snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
std::string line = "$" + body + tail;
id(gps_i2c).write((const uint8_t *) line.data(), line.size());
id(gps_last_pmtk_tx).publish_state(body);
ESP_LOGI("gps", "TX: %s", line.c_str());
};
send_pmtk("PMTK447");
- platform: template
name: "GPS Interroger datum"
entity_category: config
on_press:
- lambda: |-
auto nmea_checksum = [](const std::string &s) -> uint8_t {
uint8_t cs = 0;
for (char c : s) cs ^= (uint8_t) c;
return cs;
};
auto send_pmtk = [&](const std::string &body) {
uint8_t cs = nmea_checksum(body);
char tail[8];
snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
std::string line = "$" + body + tail;
id(gps_i2c).write((const uint8_t *) line.data(), line.size());
id(gps_last_pmtk_tx).publish_state(body);
ESP_LOGI("gps", "TX: %s", line.c_str());
};
send_pmtk("PMTK430");
- platform: template
name: "GPS Interroger release"
entity_category: config
on_press:
- lambda: |-
auto nmea_checksum = [](const std::string &s) -> uint8_t {
uint8_t cs = 0;
for (char c : s) cs ^= (uint8_t) c;
return cs;
};
auto send_pmtk = [&](const std::string &body) {
uint8_t cs = nmea_checksum(body);
char tail[8];
snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
std::string line = "$" + body + tail;
id(gps_i2c).write((const uint8_t *) line.data(), line.size());
id(gps_last_pmtk_tx).publish_state(body);
ESP_LOGI("gps", "TX: %s", line.c_str());
};
send_pmtk("PMTK607");
- platform: template
name: "GPS Interroger logger"
entity_category: config
on_press:
- lambda: |-
auto nmea_checksum = [](const std::string &s) -> uint8_t {
uint8_t cs = 0;
for (char c : s) cs ^= (uint8_t) c;
return cs;
};
auto send_pmtk = [&](const std::string &body) {
uint8_t cs = nmea_checksum(body);
char tail[8];
snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
std::string line = "$" + body + tail;
id(gps_i2c).write((const uint8_t *) line.data(), line.size());
id(gps_last_pmtk_tx).publish_state(body);
ESP_LOGI("gps", "TX: %s", line.c_str());
};
send_pmtk("PMTK183");
- platform: template
name: "GPS Interroger port"
entity_category: config
on_press:
- lambda: |-
auto nmea_checksum = [](const std::string &s) -> uint8_t {
uint8_t cs = 0;
for (char c : s) cs ^= (uint8_t) c;
return cs;
};
auto send_pmtk = [&](const std::string &body) {
uint8_t cs = nmea_checksum(body);
char tail[8];
snprintf(tail, sizeof(tail), "*%02X\r\n", cs);
std::string line = "$" + body + tail;
id(gps_i2c).write((const uint8_t *) line.data(), line.size());
id(gps_last_pmtk_tx).publish_state(body);
ESP_LOGI("gps", "TX: %s", line.c_str());
};
send_pmtk("PMTK602");
interval:
- interval: 100ms
then:
- lambda: |-
uint8_t data[128];
for (int pass = 0; pass < 4; pass++) {
if (!id(gps_i2c).read_bytes_raw(data, sizeof(data))) {
break;
}
for (size_t j = 0; j < sizeof(data); j++) {
char c = (char) data[j];
if (c == '\0') continue;
id(nmea_buf).push_back(c);
}
}
if (id(nmea_buf).size() > 8192) {
id(nmea_buf).erase(0, id(nmea_buf).size() - 4096);
}
auto trim_crlf = [](std::string &s) {
while (!s.empty() && (s.back() == '\r' || s.back() == '\n')) s.pop_back();
};
auto verify_checksum = [](const std::string &s) -> bool {
auto star = s.find('*');
if (s.size() < 4 || s[0] != '$' || star == std::string::npos) return false;
if (star + 2 >= s.size()) return false;
uint8_t cs = 0;
for (size_t i = 1; i < star; i++) cs ^= (uint8_t) s[i];
auto hex = [](char ch) -> int {
if (ch >= '0' && ch <= '9') return ch - '0';
if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10;
if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10;
return -1;
};
int hi = hex(s[star + 1]);
int lo = hex(s[star + 2]);
if (hi < 0 || lo < 0) return false;
uint8_t want = (uint8_t) ((hi << 4) | lo);
return cs == want;
};
auto split_csv = [](const std::string &line) -> std::vector<std::string> {
std::vector<std::string> out;
std::string cur;
for (char ch : line) {
if (ch == ',' || ch == '*') {
out.push_back(cur);
cur.clear();
if (ch == '*') break;
} else {
cur.push_back(ch);
}
}
out.push_back(cur);
return out;
};
auto to_float = [](const std::string &s, float *out) -> bool {
if (s.empty()) return false;
char *end = nullptr;
float v = strtof(s.c_str(), &end);
if (end == s.c_str()) return false;
*out = v;
return true;
};
auto to_int = [](const std::string &s, int *out) -> bool {
if (s.empty()) return false;
char *end = nullptr;
long v = strtol(s.c_str(), &end, 10);
if (end == s.c_str()) return false;
*out = (int) v;
return true;
};
auto ddmm_to_deg = [](const std::string &ddmm, char hemi, float *out) -> bool {
if (ddmm.size() < 4) return false;
auto dot = ddmm.find('.');
size_t min_start = (dot == std::string::npos) ? ddmm.size() - 2 : (dot >= 2 ? dot - 2 : 0);
if (min_start < 2) return false;
std::string deg_str = ddmm.substr(0, min_start);
std::string min_str = ddmm.substr(min_start);
char *end = nullptr;
float deg = strtof(deg_str.c_str(), &end);
if (end == deg_str.c_str()) return false;
end = nullptr;
float minutes = strtof(min_str.c_str(), &end);
if (end == min_str.c_str()) return false;
float v = deg + minutes / 60.0f;
if (hemi == 'S' || hemi == 'W') v = -v;
*out = v;
return true;
};
auto hhmmss_to_text = [](const std::string &s) -> std::string {
if (s.size() < 6) return "";
char buf[16];
snprintf(buf, sizeof(buf), "%c%c:%c%c:%c%c", s[0], s[1], s[2], s[3], s[4], s[5]);
return std::string(buf);
};
auto ddmmyy_to_text = [](const std::string &s) -> std::string {
if (s.size() != 6) return "";
int yy = (s[4] - '0') * 10 + (s[5] - '0');
int year = (yy >= 70) ? (1900 + yy) : (2000 + yy);
char buf[16];
snprintf(buf, sizeof(buf), "%04d-%c%c-%c%c", year, s[2], s[3], s[0], s[1]);
return std::string(buf);
};
auto publish_fix_age_and_quality = [&]() {
if (id(last_fix_millis) == 0) {
id(gps_fix_age_s).publish_state(NAN);
id(gps_quality_text).publish_state("aucun fix");
return;
}
float age_s = (millis() - id(last_fix_millis)) / 1000.0f;
id(gps_fix_age_s).publish_state(age_s);
float hdop = id(gps_hdop).has_state() ? id(gps_hdop).state : NAN;
int sats = id(gps_satellites_used).has_state() ? (int) id(gps_satellites_used).state : 0;
if (age_s > 15.0f) {
id(gps_quality_text).publish_state("fix périmé");
} else if (!std::isnan(hdop) && hdop <= 1.5f && sats >= 6) {
id(gps_quality_text).publish_state("excellent");
} else if (!std::isnan(hdop) && hdop <= 3.0f && sats >= 4) {
id(gps_quality_text).publish_state("bon");
} else if (!std::isnan(hdop) && hdop <= 6.0f) {
id(gps_quality_text).publish_state("moyen");
} else {
id(gps_quality_text).publish_state("faible");
}
};
while (true) {
auto nl = id(nmea_buf).find('\n');
if (nl == std::string::npos) break;
std::string line = id(nmea_buf).substr(0, nl + 1);
id(nmea_buf).erase(0, nl + 1);
trim_crlf(line);
if (line.empty() || line[0] != '$') continue;
if (!verify_checksum(line)) {
id(invalid_sentence_count)++;
id(gps_invalid_sentence_count).publish_state((float) id(invalid_sentence_count));
continue;
}
id(valid_sentence_count)++;
id(last_valid_sentence_millis) = millis();
id(gps_valid_sentence_count).publish_state((float) id(valid_sentence_count));
auto fields = split_csv(line);
if (fields.empty()) continue;
const std::string &type = fields[0];
bool is_pmtk = type.rfind("$PMTK", 0) == 0;
if (!is_pmtk && type.size() >= 6 && type[0] == '$') {
std::string short_type = type.substr(type.size() - 3);
std::string needle = "," + short_type + ",";
std::string haystack = "," + id(nmea_types_seen) + ",";
if (haystack.find(needle) == std::string::npos) {
if (!id(nmea_types_seen).empty()) id(nmea_types_seen) += ",";
id(nmea_types_seen) += short_type;
id(gps_nmea_types).publish_state(id(nmea_types_seen));
ESP_LOGI("gps", "Nouveau type NMEA: %s", short_type.c_str());
}
}
if (type == "$PMTK001" && fields.size() >= 3) {
int cmd = 0;
int flag = -1;
to_int(fields[1], &cmd);
to_int(fields[2], &flag);
char buf[32];
snprintf(buf, sizeof(buf), "PMTK%03d=%d", cmd, flag);
id(gps_last_pmtk_ack).publish_state(buf);
std::string status = "inconnu";
bool ok = false;
if (flag == 0) status = "paquet invalide";
if (flag == 1) status = "commande non supportée";
if (flag == 2) status = "commande valide mais échec";
if (flag == 3) {
status = "succès";
ok = true;
}
id(gps_last_pmtk_ack_status).publish_state(status);
id(gps_config_valid).publish_state(ok);
continue;
}
if (type == "$PMTK010" && fields.size() >= 2) {
id(gps_system_message).publish_state(line);
continue;
}
if (type == "$PMTK705") {
std::string fw;
for (size_t i = 1; i < fields.size(); i++) {
if (!fields[i].empty()) {
if (!fw.empty()) fw += ",";
fw += fields[i];
}
}
id(gps_firmware).publish_state(fw);
ESP_LOGI("gps", "Firmware: %s", fw.c_str());
continue;
}
if (type == "$PMTK514") {
id(gps_nmea_config).publish_state(line);
ESP_LOGI("gps", "NMEA config: %s", line.c_str());
continue;
}
if (type == "$PMTK527" && fields.size() >= 2) {
float v;
if (to_float(fields[1], &v)) {
id(gps_nav_threshold).publish_state(v);
}
ESP_LOGI("gps", "Nav threshold: %s", line.c_str());
continue;
}
if (type == "$PMTK530" && fields.size() >= 2) {
id(gps_datum).publish_state(fields[1] == "0" ? "WGS84" : fields[1]);
ESP_LOGI("gps", "Datum: %s", line.c_str());
continue;
}
if (type == "$PMTK707") {
bool epo_present = false;
for (size_t i = 1; i < fields.size(); i++) {
if (!fields[i].empty() && fields[i] != "0") {
epo_present = true;
break;
}
}
id(gps_epo_status).publish_state(epo_present ? "présent" : "absent");
ESP_LOGI("gps", "EPO: %s", line.c_str());
continue;
}
if (type == "$PMTKLOG") {
id(gps_log_raw).publish_state(line);
continue;
}
if (type == "$PMTK702") {
id(gps_port_raw).publish_state(line);
continue;
}
if ((type == "$PGTOP" || type == "$PCD") && fields.size() >= 3 && fields[1] == "11") {
std::string st = "inconnu";
bool problem = false;
if (fields[2] == "1") st = "antenne interne";
else if (fields[2] == "2") st = "antenne externe";
else if (fields[2] == "3") { st = "court-circuit antenne"; problem = true; }
else if (fields[2] == "0") st = "statut indisponible";
else st = line;
id(gps_antenna_status).publish_state(st);
id(gps_antenna_problem).publish_state(problem);
continue;
}
if (type.size() >= 3 && type.substr(type.size() - 3) == "GGA") {
if (fields.size() >= 10) {
int fixq = 0;
int sats = 0;
float hdop = 0.0f;
float alt = 0.0f;
float lat = 0.0f;
float lon = 0.0f;
bool fix_ok = to_int(fields[6], &fixq) && fixq > 0;
id(gps_has_fix).publish_state(fix_ok);
if (fix_ok) id(last_fix_millis) = millis();
if (to_int(fields[6], &fixq)) {
id(gps_fix_quality).publish_state((float) fixq);
std::string txt = "inconnu";
if (fixq == 0) txt = "pas de fix";
else if (fixq == 1) txt = "GPS";
else if (fixq == 2) txt = "DGPS";
else if (fixq == 3) txt = "PPS";
else if (fixq == 4) txt = "RTK fixe";
else if (fixq == 5) txt = "RTK flottant";
else if (fixq == 6) txt = "estimation";
id(gps_fix_quality_text).publish_state(txt);
}
if (to_int(fields[7], &sats)) id(gps_satellites_used).publish_state((float) sats);
if (to_float(fields[8], &hdop)) id(gps_hdop).publish_state(hdop);
if (to_float(fields[9], &alt)) id(gps_altitude).publish_state(alt);
if (ddmm_to_deg(fields[2], fields[3].empty() ? 'N' : fields[3][0], &lat)) {
id(gps_latitude).publish_state(lat);
}
if (ddmm_to_deg(fields[4], fields[5].empty() ? 'E' : fields[5][0], &lon)) {
id(gps_longitude).publish_state(lon);
}
if (type.rfind("$GP", 0) == 0) id(gps_constellation).publish_state("GPS");
else if (type.rfind("$GL", 0) == 0) id(gps_constellation).publish_state("GLONASS");
else if (type.rfind("$GN", 0) == 0) id(gps_constellation).publish_state("GNSS mixte");
publish_fix_age_and_quality();
}
continue;
}
if (type.size() >= 3 && type.substr(type.size() - 3) == "GLL") {
if (fields.size() >= 5) {
float lat = 0.0f;
float lon = 0.0f;
if (ddmm_to_deg(fields[1], fields[2].empty() ? 'N' : fields[2][0], &lat)) {
id(gps_latitude).publish_state(lat);
}
if (ddmm_to_deg(fields[3], fields[4].empty() ? 'E' : fields[4][0], &lon)) {
id(gps_longitude).publish_state(lon);
}
}
continue;
}
if (type.size() >= 3 && type.substr(type.size() - 3) == "RMC") {
if (fields.size() >= 10) {
bool valid = (!fields[2].empty() && fields[2][0] == 'A');
id(gps_has_fix).publish_state(valid);
if (valid) id(last_fix_millis) = millis();
float lat = 0.0f;
float lon = 0.0f;
float sp_kn = 0.0f;
float course = 0.0f;
float mag_var = 0.0f;
if (ddmm_to_deg(fields[3], fields[4].empty() ? 'N' : fields[4][0], &lat)) {
id(gps_latitude).publish_state(lat);
}
if (ddmm_to_deg(fields[5], fields[6].empty() ? 'E' : fields[6][0], &lon)) {
id(gps_longitude).publish_state(lon);
}
if (to_float(fields[7], &sp_kn)) {
id(gps_speed_kmh).publish_state(sp_kn * 1.852f);
}
if (to_float(fields[8], &course)) {
id(gps_course_deg).publish_state(course);
}
if (fields.size() >= 12 && to_float(fields[10], &mag_var)) {
if (!fields[11].empty() && fields[11][0] == 'W') mag_var = -mag_var;
id(gps_mag_variation).publish_state(mag_var);
}
if (fields.size() >= 13 && !fields[12].empty()) {
std::string mode = "inconnu";
char m = fields[12][0];
if (m == 'A') mode = "autonome";
else if (m == 'D') mode = "différentiel";
else if (m == 'E') mode = "estimé";
else if (m == 'N') mode = "invalide";
id(gps_nav_mode_text).publish_state(mode);
}
std::string time_txt = hhmmss_to_text(fields[1]);
std::string date_txt = ddmmyy_to_text(fields[9]);
if (!time_txt.empty()) id(gps_time_utc).publish_state(time_txt);
if (!date_txt.empty()) id(gps_date_utc).publish_state(date_txt);
if (!date_txt.empty() && !time_txt.empty()) {
id(gps_datetime_utc).publish_state(date_txt + "T" + time_txt + "Z");
}
publish_fix_age_and_quality();
}
continue;
}
if (type.size() >= 3 && type.substr(type.size() - 3) == "VTG") {
if (fields.size() >= 8) {
float course = 0.0f;
float speed_kmh = 0.0f;
if (to_float(fields[1], &course)) id(gps_course_deg).publish_state(course);
if (to_float(fields[7], &speed_kmh)) id(gps_speed_kmh).publish_state(speed_kmh);
if (fields.size() >= 10 && !fields[9].empty()) {
std::string mode = "inconnu";
char m = fields[9][0];
if (m == 'A') mode = "autonome";
else if (m == 'D') mode = "différentiel";
else if (m == 'E') mode = "estimé";
else if (m == 'N') mode = "invalide";
id(gps_nav_mode_text).publish_state(mode);
}
}
continue;
}
if (type.size() >= 3 && type.substr(type.size() - 3) == "GSA") {
if (fields.size() >= 18) {
int fix_type = 0;
float pdop = 0.0f;
float hdop = 0.0f;
float vdop = 0.0f;
if (to_int(fields[2], &fix_type)) {
id(gps_fix_type).publish_state((float) fix_type);
std::string txt = "inconnu";
if (fix_type == 1) txt = "aucun";
else if (fix_type == 2) txt = "2D";
else if (fix_type == 3) txt = "3D";
id(gps_fix_type_text).publish_state(txt);
}
if (to_float(fields[15], &pdop)) id(gps_pdop).publish_state(pdop);
if (to_float(fields[16], &hdop)) id(gps_hdop).publish_state(hdop);
if (to_float(fields[17], &vdop)) id(gps_vdop).publish_state(vdop);
publish_fix_age_and_quality();
}
continue;
}
if (type.size() >= 3 && type.substr(type.size() - 3) == "GSV") {
if (fields.size() >= 4) {
int sats_visible = 0;
if (to_int(fields[3], &sats_visible)) {
id(gps_satellites_visible).publish_state((float) sats_visible);
if (type.rfind("$GP", 0) == 0) id(gps_satellites_visible_gps).publish_state((float) sats_visible);
else if (type.rfind("$GL", 0) == 0) id(gps_satellites_visible_glonass).publish_state((float) sats_visible);
else if (type.rfind("$GN", 0) == 0) id(gps_satellites_visible_gn).publish_state((float) sats_visible);
}
int max_snr = -1;
for (size_t i = 7; i + 3 < fields.size(); i += 4) {
int snr = 0;
if (to_int(fields[i], &snr)) {
if (snr > max_snr) max_snr = snr;
}
}
if (max_snr >= 0) {
id(gps_snr_max).publish_state((float) max_snr);
if (type.rfind("$GP", 0) == 0) id(gps_snr_max_gps).publish_state((float) max_snr);
else if (type.rfind("$GL", 0) == 0) id(gps_snr_max_glonass).publish_state((float) max_snr);
else if (type.rfind("$GN", 0) == 0) id(gps_snr_max_gn).publish_state((float) max_snr);
}
}
continue;
}
if (type.size() >= 3 && type.substr(type.size() - 3) == "ZDA") {
if (fields.size() >= 5) {
std::string time_txt = hhmmss_to_text(fields[1]);
if (!time_txt.empty()) id(gps_time_utc).publish_state(time_txt);
if (!fields[2].empty() && !fields[3].empty() && !fields[4].empty()) {
char buf[32];
snprintf(buf, sizeof(buf), "%s-%02d-%02d",
fields[4].c_str(),
atoi(fields[3].c_str()),
atoi(fields[2].c_str()));
std::string date_txt(buf);
id(gps_date_utc).publish_state(date_txt);
if (!time_txt.empty()) {
id(gps_datetime_utc).publish_state(date_txt + "T" + time_txt + "Z");
}
}
}
continue;
}
if (type.size() >= 3 && type.substr(type.size() - 3) == "GST") {
if (fields.size() >= 9) {
float v = 0.0f;
if (to_float(fields[2], &v)) id(gps_gst_rms).publish_state(v);
if (to_float(fields[6], &v)) id(gps_gst_sigma_lat).publish_state(v);
if (to_float(fields[7], &v)) id(gps_gst_sigma_lon).publish_state(v);
if (to_float(fields[8], &v)) id(gps_gst_sigma_alt).publish_state(v);
publish_fix_age_and_quality();
}
continue;
}
if (type.size() >= 3 && type.substr(type.size() - 3) == "DTM") {
id(gps_dtm_raw).publish_state(line);
continue;
}
if (type.size() >= 3 && type.substr(type.size() - 3) == "CHN") {
id(gps_chn_raw).publish_state(line);
continue;
}
}
if (id(last_fix_millis) != 0) {
float age_s = (millis() - id(last_fix_millis)) / 1000.0f;
id(gps_fix_age_s).publish_state(age_s);
}
Nous obtenons maintenant des données exhaustives que notre module peut fournir :
| Capteur | Signification |
|---|---|
| GPS Seuil de mouvement | Vitesse minimale (m/s) à partir de laquelle le module considère qu’il est en déplacement (sinon il peut stabiliser la position) |
| GPS Latitude | Latitude actuelle en degrés décimaux (référentiel WGS84) |
| GPS Longitude | Longitude actuelle en degrés décimaux (référentiel WGS84) |
| GPS Altitude | Altitude au-dessus du niveau moyen de la mer (en mètres) |
| GPS Vitesse | Vitesse au sol (en km/h), dérivée du déplacement GNSS |
| GPS Cap | Direction du déplacement (angle en degrés par rapport au nord) |
| GPS Satellites utilisés | Nombre de satellites utilisés dans le calcul de position (fix) |
| GPS Satellites visibles | Nombre total de satellites visibles par le récepteur |
| GPS Qualité du fix | Indicateur de qualité du positionnement (0 = pas de fix, 1 = GPS, 2 = DGPS, etc.) |
| GPS Type de fix | Type de solution GNSS (1 = pas de fix, 2 = 2D, 3 = 3D) |
| GPS HDOP | Précision horizontale (Horizontal Dilution of Precision, plus c’est bas, mieux c’est) |
| GPS PDOP | Précision globale 3D (Position Dilution of Precision) |
| GPS VDOP | Précision verticale (Vertical Dilution of Precision) |
| GPS SNR max | Meilleur signal satellite reçu (en dB), indicateur de qualité radio |
| GPS GST RMS | Erreur RMS estimée de la position (en mètres) |
| GPS GST Sigma latitude | Incertitude estimée sur la latitude (en mètres) |
| GPS GST Sigma longitude | Incertitude estimée sur la longitude (en mètres) |
| GPS GST Sigma altitude | Incertitude estimée sur l’altitude (en mètres) |
| GPS Fix | Indique si un fix valide est disponible (true/false) |
| GPS Firmware | Version du firmware du module GNSS |
| GPS Datum | Référentiel géodésique utilisé (ex: WGS84) |
| GPS EPO | Indique si des données d’assistance (EPO) sont présentes |
| GPS Configuration NMEA | Configuration actuelle des phrases NMEA émises par le module |
| GPS Types NMEA détectés | Liste des types de phrases NMEA effectivement observées |
| GPS Date UTC | Date GNSS (UTC) |
| GPS Heure UTC | Heure GNSS (UTC) |
| GPS DateTime UTC | Date et heure combinées (UTC) |
| GPS DTM brut | Trame brute indiquant le datum (peu utilisée en pratique) |
| GPS CHN brut | Trame propriétaire indiquant l’état des canaux GNSS |
| GPS LOG brut | État brut du logger interne du module |
| GPS PORT brut | Configuration brute des interfaces du module |
Obtention d’un fix
Il n’y a pas de commande pour “obtenir un fix”.
Il faut suivre exactement le trajet d’une trame dans le code, depuis l’I²C jusqu’au moment où GPS Fix change d’état.
Tout commence ici :
interval:
- interval: 100ms
then:
- lambda: |-
Cela veut dire que, toutes les 100 ms, ESPHome exécute ce gros bloc C++. C’est lui qui fait vivre tout le parser GPS. La première étape consiste à lire des octets bruts depuis le périphérique I²C :
uint8_t data[128];
for (int pass = 0; pass < 4; pass++) {
if (!id(gps_i2c).read_bytes_raw(data, sizeof(data))) {
break;
}
for (size_t j = 0; j < sizeof(data); j++) {
char c = (char) data[j];
if (c == '\0') continue;
id(nmea_buf).push_back(c);
}
}
Ici, le code tente jusqu’à 4 lectures de 128 octets, donc jusqu’à 512 octets par passage de 100 ms.
Chaque octet non nul est ajouté à nmea_buf, qui est simplement un tampon texte global.
Autrement dit, le GPS envoie un flux continu de caractères, et le code les accumule jusqu’à former des lignes NMEA complètes. Par exemple :
$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A
Ensuite, on évite que le tampon devienne énorme :
if (id(nmea_buf).size() > 8192) {
id(nmea_buf).erase(0, id(nmea_buf).size() - 4096);
}
Cela ne participe pas au fix lui-même ; c’est juste une sécurité mémoire.
Ensuite, on définit plusieurs petites fonctions utilitaires. Il y en a trois qui comptent particulièrement pour le fix. La première supprime les fins de ligne :
auto trim_crlf = [](std::string &s) {
while (!s.empty() && (s.back() == '\r' || s.back() == '\n')) s.pop_back();
};
La deuxième vérifie le checksum NMEA :
auto verify_checksum = [](const std::string &s) -> bool {
auto star = s.find('*');
if (s.size() < 4 || s[0] != '$' || star == std::string::npos) return false;
if (star + 2 >= s.size()) return false;
uint8_t cs = 0;
for (size_t i = 1; i < star; i++) cs ^= (uint8_t) s[i];
...
return cs == want;
};
C’est très important : si le checksum ne correspond pas, la phrase est rejetée. Donc une trame corrompue ne pourra pas déclencher un faux fix. La troisième découpe la ligne en champs :
auto split_csv = [](const std::string &line) -> std::vector<std::string> {
std::vector<std::string> out;
std::string cur;
for (char ch : line) {
if (ch == ',' || ch == '*') {
out.push_back(cur);
cur.clear();
if (ch == '*') break;
} else {
cur.push_back(ch);
}
}
out.push_back(cur);
return out;
};
C’est ce qui transforme une phrase NMEA en tableau de champs, par exemple :
$GPRMC,123519,A,...
devient quelque chose comme :
fields[0] = "$GPRMC"
fields[1] = "123519"
fields[2] = "A"
...
Après cela, le code entre dans cette boucle :
while (true) {
auto nl = id(nmea_buf).find('\n');
if (nl == std::string::npos) break;
std::string line = id(nmea_buf).substr(0, nl + 1);
id(nmea_buf).erase(0, nl + 1);
trim_crlf(line);
if (line.empty() || line[0] != '$') continue;
if (!verify_checksum(line)) continue;
auto fields = split_csv(line);
if (fields.empty()) continue;
const std::string &type = fields[0];
C’est là que tout se joue : à chaque itération, on prend une ligne complète du buffer.
Si elle n’est pas vide, qu’elle commence bien par $, et que son checksum est bon, alors elle devient une trame exploitable.
Ensuite, le code identifie le type de phrase par fields[0].
C’est cette valeur qui permet de savoir si on a affaire à GGA, RMC, GSV, etc.
Pour le fix, il y a deux blocs principaux.
Le premier est celui des phrases GGA :
if (type.size() >= 3 && type.substr(type.size() - 3) == "GGA") {
if (fields.size() >= 10) {
int fixq = 0;
int sats = 0;
float hdop = 0.0f;
float alt = 0.0f;
float lat = 0.0f;
float lon = 0.0f;
bool fix_ok = to_int(fields[6], &fixq) && fixq > 0;
id(gps_has_fix).publish_state(fix_ok);
if (to_int(fields[6], &fixq)) id(gps_fix_quality).publish_state((float) fixq);
if (to_int(fields[7], &sats)) id(gps_satellites_used).publish_state((float) sats);
if (to_float(fields[8], &hdop)) id(gps_hdop).publish_state(hdop);
if (to_float(fields[9], &alt)) id(gps_altitude).publish_state(alt);
if (ddmm_to_deg(fields[2], fields[3].empty() ? 'N' : fields[3][0], &lat)) {
id(gps_latitude).publish_state(lat);
}
if (ddmm_to_deg(fields[4], fields[5].empty() ? 'E' : fields[5][0], &lon)) {
id(gps_longitude).publish_state(lon);
}
}
continue;
}
Ici, la ligne cruciale est :
bool fix_ok = to_int(fields[6], &fixq) && fixq > 0;
id(gps_has_fix).publish_state(fix_ok);
Dans une phrase GGA, le champ 6 est la qualité du fix.
Si ce champ vaut 0, pas de fix.
S’il vaut 1, 2, 3, etc., le récepteur considère qu’il a une solution valide.
Donc notre code traduit cela directement en booléen et publie l’état de gps_has_fix.
Autrement dit, dès qu’une GGA valide arrive avec fields[6] > 0, le binaire GPS Fix passe à true.
Ce même bloc profite ensuite de la trame pour publier les données associées au fix : nombre de satellites utilisés, HDOP, altitude, latitude, longitude.
Donc, dans notre logique, GGA n’est pas seulement un indicateur de fix : c’est aussi une source de contenu détaillé sur ce fix.
Le deuxième bloc important est celui des phrases RMC :
if (type.size() >= 3 && type.substr(type.size() - 3) == "RMC") {
if (fields.size() >= 10) {
bool valid = (!fields[2].empty() && fields[2][0] == 'A');
id(gps_has_fix).publish_state(valid);
float lat = 0.0f;
float lon = 0.0f;
float sp_kn = 0.0f;
float course = 0.0f;
if (ddmm_to_deg(fields[3], fields[4].empty() ? 'N' : fields[4][0], &lat)) {
id(gps_latitude).publish_state(lat);
}
if (ddmm_to_deg(fields[5], fields[6].empty() ? 'E' : fields[6][0], &lon)) {
id(gps_longitude).publish_state(lon);
}
if (to_float(fields[7], &sp_kn)) {
id(gps_speed_kmh).publish_state(sp_kn * 1.852f);
}
if (to_float(fields[8], &course)) {
id(gps_course_deg).publish_state(course);
}
std::string time_txt = hhmmss_to_text(fields[1]);
std::string date_txt = ddmmyy_to_text(fields[9]);
if (!time_txt.empty()) id(gps_time_utc).publish_state(time_txt);
if (!date_txt.empty()) id(gps_date_utc).publish_state(date_txt);
if (!date_txt.empty() && !time_txt.empty()) {
id(gps_datetime_utc).publish_state(date_txt + "T" + time_txt + "Z");
}
}
continue;
}
Ici, la ligne déterminante est :
bool valid = (!fields[2].empty() && fields[2][0] == 'A');
id(gps_has_fix).publish_state(valid);
Dans RMC, le champ 2 contient le statut.
A signifie “Active”, donc données valides.
V signifie “Void”, donc invalide.
Notre code s’en sert comme deuxième source de vérité pour dire s’il y a un fix ou non.
Donc, si une phrase RMC valide arrive avec A, on passe aussi gps_has_fix à true.
À partir de là, le comportement global du code est simple : gps_has_fix est mis à jour chaque fois qu’une GGA ou une RMC est reçue.
Il ne s’agit pas d’un état calculé une fois pour toutes ; c’est un état réécrit en continu selon la dernière phrase pertinente reçue.
C’est important, car cela veut dire que le booléen peut changer plusieurs fois par seconde. Par exemple, imaginons cette séquence :
- une
GGAarrive avec qualité 0, doncGPS Fix = false - puis une
RMCarrive avec statutA, doncGPS Fix = true - puis une autre
GGAarrive avec qualité 1, doncGPS 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 :
if (to_int(fields[2], &fix_type)) id(gps_fix_type).publish_state((float) fix_type);
if (to_float(fields[15], &pdop)) id(gps_pdop).publish_state(pdop);
if (to_float(fields[16], &hdop)) id(gps_hdop).publish_state(hdop);
if (to_float(fields[17], &vdop)) id(gps_vdop).publish_state(vdop);
Cela nous renseigne sur la qualité géométrique de la solution, mais ce n’est pas ce code qui décide du binaire GPS Fix.
GSV publie les satellites visibles et le SNR max :
if (to_int(fields[3], &sats_visible)) {
id(gps_satellites_visible).publish_state((float) sats_visible);
}
Là encore, cela aide à comprendre pourquoi un fix existe ou non, mais ce n’est pas ce qui le déclare dans notre logique. Donc, si on suit une trame complète depuis le début, le scénario typique est celui-ci.
- 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
\napparaî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 litfields[6]. Si ce champ est supérieur à 0,gps_has_fixdevient vrai. - Si son type finit par
RMC, notre code litfields[2]. Si ce champ vautA,gps_has_fixdevient 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.
Richard Dern
Échanger autour de ce texte
Si vous souhaitez réagir publiquement, un fil dédié vous attend.
Ouvrir le fil de discussion