Après avoir reçu, décodé, affiché et partagé les données ADS-B, nous allons maintenant apprendre à les exploiter autrement. Ce treizième volet s’éloigne un peu de la technique SDR au sens strict pour entrer dans le monde des fichiers JSON, des scripts Python et des API. L’objectif sera d’interroger notre propre station, puis un service en ligne, afin de trier, convertir, comparer et faire parler les données transmises par les avions.
Introduction – Vers une initiation aux API
Dans le douzième article, nous avons mis en service une station ADS-B complète en partant d’une image ADSB.im. Avec ce treizième volet, nous allons franchir une étape différente. Il ne s’agit plus seulement de recevoir un signal radio à 1090 MHz, de le décoder, puis d’afficher les avions sur une carte. Cette fois, nous allons nous intéresser aux données déjà reçues, déjà décodées et déjà mises à disposition par notre station ou par des services extérieurs.
Nous nous éloignons donc un peu de la SDR au sens strict. L’antenne, la clé de réception et le logiciel de décodage restent indispensables en arrière-plan, mais le sujet principal devient maintenant l’exploitation informatique des données ADS-B. Nous allons parler d’API, de fichiers JSON, d’adresses à interroger, de réponses structurées et de petits scripts Python capables de lire, sélectionner, convertir et présenter ces informations.
Pour commencer simplement, nous n’irons pas tout de suite vers une grande plateforme nécessitant un compte, une clé d’accès ou un abonnement. Nous interrogerons d’abord notre propre station ADS-B, depuis le réseau local, en consultant le fichier aircraft.json qu’elle met à disposition. Ce fichier n’est pas aussi agréable à lire qu’une carte, mais il contient les informations essentielles : indicatif, code ICAO, altitude, vitesse, cap, position, type d’appareil ou encore âge du dernier message reçu.
Dans un second temps, nous écrirons un premier programme Python pour récupérer ces données et les présenter sous une forme plus lisible. Puis nous appliquerons le même principe à une véritable API disponible sur Internet, celle du site api.airplanes.live, afin de demander la liste des avions présents dans un rayon donné autour d’un point géographique.
Ce ne sera pas un cours complet de programmation, ni un inventaire exhaustif des API aéronautiques. L’objectif restera fidèle à l’esprit de cette série : expérimenter pas à pas, comprendre ce que l’on fait, accepter quelques tâtonnements, puis obtenir un résultat exploitable. Après avoir appris à recevoir les avions, nous allons maintenant commencer à faire parler les données qu’ils nous transmettent.
Pour ne pas alourdir le texte, certains scripts ne sont pas intégrés in extenso au texte ci-dessous. Le lecteur pourra cependant se les procurer en téléchargeant le fichier ZIP : Test_Api.zip dont le lien apparait en fin de document. Le scripts qu’il contient sont fournis comme supports pédagogiques ; ils ont été préparés pour éviter un lancement accidentel. Avant toute exécution, le lecteur devra lire un avertissement et confirmer volontairement qu’il souhaite poursuivre. Certains paramètres devront aussi être adaptés à son propre environnement, notamment lorsqu’un script prévoit l’interrogation d’une station ADS-B locale ou l’utilisation de fichiers CSV placés dans des sous-dossiers précis.
Ces fichiers ne contiennent pas d’adresse IP personnelle ni de clé privée utilisable telle quelle. Ils peuvent être lus, modifiés et adaptés librement pour un usage personnel, associatif ou pédagogique, sous réserve de conserver une mention de l’auteur d’origine. Avant de les lancer, il est recommandé de vérifier le contenu du code, les chemins de fichiers, les coordonnées utilisées, ainsi que les règles d’usage des services interrogés, en particulier les API publiques et les sites consultés automatiquement.
1. Des données reçues aux données exploitables
1.1. Pourquoi exploiter les données ADS-B autrement ?
Jusqu’ici, nous avons vu comment notre station ADS-B pouvait recevoir les signaux émis par les avions, les décoder, les afficher localement, puis envoyer ses données vers des sites comme FlightRadar24, ADS-B Exchange ou d’autres plateformes collaboratives. Dans ce cas, notre station travaille surtout comme un contributeur : elle alimente un réseau plus vaste avec les informations qu’elle reçoit depuis notre propre antenne.
Mais le mouvement peut aussi se faire dans l’autre sens. Nombre de ces plateformes, ou d’autres services spécialisés, permettent à des sites web, des applications ou de petits programmes d’interroger leurs données. C’est là qu’intervient la notion d’API.
Une API, pour Application Programming Interface, peut se comprendre comme une porte d’accès organisée à des données ou à des fonctions. Au lieu de consulter une page web destinée à être lue par un humain, un programme interroge directement une adresse prévue pour lui répondre. Il peut, par exemple, demander la liste des avions visibles dans une zone, les informations connues sur un appareil, ou encore les données récemment reçues autour d’un point géographique.
Le service interrogé fournit alors une réponse structurée, généralement sous forme de données informatiques lisibles par l’application qui l’appelle. L’utilisateur ne voit pas forcément cette mécanique, mais elle permet d’alimenter des cartes, des tableaux, des alertes, des outils statistiques ou des applications mobiles.
Dans le domaine du suivi aérien, les API servent donc de passerelle entre la masse de données collectées par les récepteurs ADS-B, puis regroupées par les plateformes spécialisées, et les usages que l’on souhaite en faire. Elles ne remplacent pas la réception locale. Elles permettent plutôt d’exploiter, de compléter, de comparer ou de redistribuer les informations disponibles.
Une nuance mérite toutefois d’être précisée. Lorsque nous saisissons une clé de partage dans notre station ADS-B, nous configurons principalement un envoi automatique de nos données vers une plateforme. Il ne s’agit donc pas encore, pour nous, d’interroger un service au moyen d’une API. L’API intervient plutôt lorsqu’un programme pose une question à une adresse précise, puis reçoit en retour des données structurées, exploitables pour un affichage, une analyse ou une réutilisation.
Avant d’interroger un service extérieur, nous allons commencer plus simplement. Notre propre station ADS-B met déjà à disposition un fichier de données local, nommé aircraft.json. Il n’est pas aussi agréable à consulter qu’une carte, mais il contient les informations essentielles sur les avions récemment reçus. En l’ouvrant dans un navigateur, puis en le faisant lire par un petit programme Python, nous allons découvrir le principe général d’une interrogation de données : une adresse, une demande, une réponse structurée, puis un traitement.
1.2. Première expérimentation locale
L’appel suivant est saisi directement dans le navigateur Internet : http://192.168.1.20:8080/data/aircraft.json
192.168.1.20 correspond à l’adresse IP de ma station. Celle-ci appelle un fichier nommé aircraft.json. L’extension .json (JavaScript Object Notation) indique que les données sont fournies dans un format structuré, dérivé de la notation des objets du langage JavaScript, et très utilisé dans les échanges entre logiciels.


Fichier aircraft.json
En tête de fichier, le champ now correspond au moment où les données ont été générées. Le champ messages indique le nombre total de messages traités par la station. Dans mon exemple, le compteur atteint 119 910 000 messages, ce qui montre que le récepteur travaille en continu. Vient ensuite la partie la plus intéressante : la rubrique aircraft, qui contient la liste des appareils récemment reçus par la station.
Cette première approche permet de mieux comprendre le rôle d’une API. Elle sert d’intermédiaire entre une source de données et le logiciel qui souhaite les exploiter. Le navigateur interroge la station. Celle-ci répond en envoyant des données structurées qui seront, plus tard exploitées par un petit programme qui saura les afficher, les trier, les filtrer, les analyser ou les enregistrer.
Précisons encore que cette interrogation réalisée localement n’est pas en elle-même une API publique comparable à celles proposées par certaines grandes plateformes de suivi aérien. Nous consultons simplement, depuis notre réseau domestique, les données que notre station met à disposition. Mais le principe est le même : une adresse, une demande, une réponse structurée
1.3. Comprendre la rubrique aircraft du fichier aircraft.json
Dans cette rubrique, chaque avion est décrit par une série de champs plus ou moins renseignés selon les informations reçues. En utilisant la première ligne de cette rubrique dans notre exemple ci-dessus, c’est-à-dire l’Airbus A321 immatriculé G-EUXD, indicatif EFW8AK, voici la signification des principaux champs rencontrés :
| Champ | Signification simplifiée | Exemple |
| hex | code hexadécimal ICAO de l’appareil | 400afc |
| type | nature de la donnée reçue | adsb_icao |
| flight | indicatif de vol transmis par l’avion | EFW8AK |
| r | immatriculation de l’appareil | G-EUXD |
| t | type court de l’appareil | A321 |
| desc | description plus complète du type | AIRBUS A-321 |
| alt_baro | altitude barométrique | 36000 pieds |
| gs | vitesse sol | 476.6 nœuds |
| track | route ou cap suivi | 9.54° |
| lat | latitude | 44.082092 |
| lon | longitude | -3.144389 |
| r_dst | distance par rapport à la station, si disponible | 81.131 milles nautiques |
| seen | âge du dernier message reçu | 0.2 seconde |
Ces noms peuvent sembler un peu secs, mais plusieurs d’entre eux ne nous sont pas totalement inconnus. Dans le volet précédent, lorsque nous avons étudié l’affichage de Dump1090 sous Windows, nous avons déjà rencontré certaines de ces informations, parfois sous un autre intitulé. Le champ flight, par exemple, correspond à ce que l’affichage nommait Callsign ; le champ r renvoie au Reg-num, c’est-à-dire à l’immatriculation de l’appareil.
Avant de passer au programme Python, reprenons rapidement les six premiers champs de notre exemple.
- desc complète le champ précédent avec une description plus lisible du type d’appareil. Dans notre exemple, AIRBUS A-321 permet de comprendre immédiatement ce que signifie le code A321. Ce champ est donc surtout un enrichissement destiné à rendre l’affichage plus compréhensible.
- hex correspond au code hexadécimal ICAO de l’appareil. Il s’agit de l’adresse technique 24 bits du transpondeur Mode S / ADS-B, affichée sous forme de six caractères, par exemple 400afc. C’est ce code qui permet d’identifier l’appareil dans les données reçues, même lorsque son indicatif ou son immatriculation ne sont pas encore connus.
- type ne décrit pas le modèle de l’avion. Il indique la nature de la donnée reçue ou, plus exactement, la meilleure source disponible pour décrire cet appareil ou sa position. Dans notre exemple, la valeur adsb_icao signifie que les informations proviennent de messages ADS-B émis par un transpondeur utilisant une adresse ICAO 24 bits.
- flight correspond à l’indicatif de vol transmis par l’avion. Pour un avion de ligne, il s’agit souvent d’un identifiant opérationnel composé du code de la compagnie et d’un numéro ou suffixe de vol, comme EFW8AK. Ce champ peut changer d’un vol à l’autre et il ne doit donc pas être confondu avec l’immatriculation de l’appareil.
- r indique l’immatriculation de l’aéronef, par exemple G-EUXD. Contrairement au champ flight, cette immatriculation reste attachée à l’avion lui-même. Elle provient généralement d’une base de données associant le code ICAO reçu à un appareil connu.
- t donne le type court de l’appareil, selon la nomenclature aéronautique habituelle. Dans notre exemple, A321 désigne un Airbus A321. Ce code compact est pratique pour les logiciels, mais il n’est pas toujours très parlant pour un lecteur débutant.
Le champ type mérite cependant un petit détour, car toutes ses valeurs possibles ne correspondent pas à une réception directe par notre antenne locale. La documentation ADS-B Exchange (https://www.adsbexchange.com/version-2-api/) précise que ce champ décrit la nature des messages ou la meilleure source de la donnée courante, et distingue notamment ADS-B direct, Mode S, MLAT, ADS-R, TIS-B ou ADS-C.
Avec notre station personnelle, nous rencontrerons surtout des données de type adsb_icao, parfois mode_s, et éventuellement des positions mlat si nos données sont croisées avec celles d’autres récepteurs par un serveur extérieur. Les autres valeurs possibles relèvent plutôt d’infrastructures ou de traitements qui dépassent la simple réception locale.
| Valeur type | Réception probable avec votre station locale ? | Synthèse |
| adsb_icao | Oui | C’est le cas principal pour une station locale 1090 MHz. L’avion émet directement ses données ADS-B, avec une adresse ICAO 24 bits. Ces données peuvent notamment comprendre l’identification, la position, l’altitude, la vitesse et le cap. |
| mode_s | Oui | L’appareil est reçu en Mode S par son transpondeur. Ce message permet souvent d’identifier l’avion, mais il ne fournit pas toujours une position exploitable. L’avion peut donc être détecté sans apparaître immédiatement sur une carte. |
| mlat | Pas seule | Votre station peut fournir des signaux utiles au calcul MLAT, mais elle ne produit pas seule la position. Celle-ci est calculée par multilatération, en croisant les signaux reçus par plusieurs stations au sol via un serveur extérieur. Voir Technique SDR-12, paragraphe « Alimentation MLAT). |
| adsb_icao_nt | Rare | Cette valeur concerne plutôt des émetteurs ADS-B non-transpondeurs, avec adresse ICAO. Elle peut par exemple apparaître pour des véhicules au sol sur certains aéroports. Avec une station personnelle, ce cas reste peu probable sauf proximité d’un terrain équipé. |
| adsr_icao | Très improbable en France | ADS-R correspond à une rediffusion de données ADS-B par une infrastructure au sol, souvent à partir d’une autre liaison de données. Ce cas est surtout lié à l’écosystème américain ADS-B/UAT et ne correspond pas à une réception locale classique. |
| tisb_icao | Très improbable en France | TIS-B est un service de rediffusion d’informations de trafic concernant des appareils qui ne sont pas forcément eux-mêmes équipés ADS-B. Il s’agit donc d’une donnée issue d’une infrastructure extérieure, principalement associée au système américain. |
| adsc | Non avec une station 1090 MHz | ADS-C est un système de surveillance par liaison de données, utilisé notamment pour certains suivis longue distance ou satellitaires. Il ne s’agit pas d’une réception ADS-B locale à 1090 MHz. |
| adsb_other | Rare | Il s’agit d’une donnée ADS-B avec une adresse non-ICAO, par exemple dans certains cas particuliers ou anonymisés. Cela peut exister, mais ce n’est pas le cas courant d’une réception locale classique. |
| adsr_other | Très improbable | Même logique que l’ADS-R, mais avec une adresse non-ICAO. La donnée dépend d’une rediffusion par infrastructure sol et non d’une simple réception directe par votre antenne. |
| tisb_other | Très improbable | Même logique que le TIS-B, mais avec une adresse non-ICAO. La donnée provient d’un service de rediffusion de trafic et non d’une réception ADS-B locale directe. |
| tisb_trackfile | Très improbable | Cette valeur correspond à une donnée TIS-B liée à une piste radar ou à une cible suivie par un système sol. Elle relève donc d’une infrastructure extérieure. |
| other | Variable | Cette valeur peut apparaître lorsque la donnée vient d’une source particulière, d’un format intermédiaire ou d’un traitement non classé dans les catégories précédentes. Ce n’est pas le cas typique d’une réception ADS-B locale propre. |
Cette lecture directe de aircraft.json est donc moins spectaculaire qu’une carte, mais beaucoup plus instructive. Elle montre ce que le logiciel reçoit réellement, ce qu’il sait compléter grâce à ses bases de données et ce qu’un programme pourra ensuite exploiter automatiquement. C’est précisément ce que notre premier script Python va faire.
1.4. Notre premier programme Python
Après avoir vérifié que notre station ADS-B pouvait nous retourner un fichier aircraft.json, l’étape suivante consiste à ne plus seulement lire ce fichier dans le navigateur, mais à le faire traiter par un petit programme. Pour cette première expérimentation, nous utiliserons Python, un langage bien adapté à ce type de manipulation.
À ce stade, il ne s’agit pas d’écrire un grand logiciel. Le code reste volontairement simple. Il interroge l’adresse locale de la station, récupère le contenu du fichier JSON, parcourt la liste des avions et affiche quelques informations essentielles.
Précisons que ce premier programme, comme les suivants présentés dans ce tutoriel, n’a pas été écrit seul devant une page blanche. Il a été construit avec l’aide de ChatGPT, à partir de mes demandes successives. Concrètement, j’ai exposé l’objectif recherché, les données disponibles, le résultat attendu et, lorsque ce fut nécessaire, les erreurs rencontrées. ChatGPT a analysé ces éléments, proposé une structure de script et écrit le code Python correspondant. Il l’a ensuite adapté au fil de mes essais et de mes remarques. Il s’agit là du résultat d’un travail guidé : l’intention, les tests et les choix pratiques viennent de moi ; la rédaction du code a été proposée par l’outil.
</> Python
import json
from urllib.request import urlopen
url = "http://192.168.1.20:8080/data/aircraft.json"
with urlopen(url) as response:
data = json.load(response)
aircraft = data.get("aircraft", [])
print(f"Nombre d'avions vus récemment : {len(aircraft)}")
print()
for avion in aircraft:
indicatif = avion.get("flight", "").strip()
hex_code = avion.get("hex", "inconnu")
immatriculation = avion.get("r", "")
type_appareil = avion.get("t", "")
description = avion.get("desc", "")
altitude = avion.get("alt_baro", "non indiquée")
vitesse = avion.get("gs", "non indiquée")
cap = avion.get("track", "non indiqué")
if indicatif:
nom = indicatif
else:
nom = hex_code
ligne = f"{nom}"
if immatriculation:
ligne += f" - {immatriculation}"
if type_appareil:
ligne += f" - {type_appareil}"
if description:
ligne += f" ({description})"
ligne += f" - altitude : {altitude} ft"
ligne += f" - vitesse sol : {vitesse} kt"
ligne += f" - cap : {cap}°"
print(ligne)Le programme commence par importer les outils nécessaires. Le module json permet de lire les données retournées au format JSON. La fonction urlopen permet d’interroger une adresse, exactement comme le ferait un navigateur, mais depuis le programme.
L’adresse de la station est ensuite placée dans une variable : url = « http://192.168.1.20:8080/data/aircraft.json »
Le programme interroge cette adresse, récupère la réponse, puis recherche la rubrique aircraft. Cette rubrique contient la liste des avions récemment reçus par notre récepteur.
Pour chaque appareil, le script extrait quelques champs utiles : l’indicatif de vol, le code hexadécimal ICAO, l’immatriculation, le type d’appareil, l’altitude, la vitesse sol, le cap et, lorsque l’information est disponible, la distance par rapport à la station.
Lorsque l’indicatif de vol existe, il est utilisé comme nom principal. Sinon, le programme affiche le code hexadécimal ICAO. Cette précaution est utile, car tous les appareils ne transmettent pas forcément un indicatif exploitable.
Le résultat obtenu est déjà plus lisible que le fichier JSON brut. Chaque ligne correspond à un avion, avec les principales informations disponibles.

Résultat du script Python appliqué au fichier aircraft.json de la station ADS-B locale.
Lorsque certaines informations sont absentes, le programme affiche simplement « non indiquée ». Ce n’est pas une erreur. Cela signifie seulement que la station ne dispose pas de cette donnée au moment précis où le fichier a été interrogé.
1.5. Vers un affichage plus lisible pour le lecteur français
Avec ce premier script, nous sommes encore proches des données d’origine basées sur des unités aéronautiques qui ne parlent pas immédiatement à tous les lecteurs. L’altitude est fournie en pieds. La vitesse est fournie en nœuds. La distance, lorsqu’elle est présente, peut être exprimée en milles nautiques. Ces unités sont normales dans le domaine aéronautique, mais elles obligent souvent le lecteur français à faire mentalement une conversion.
Il est possible d’améliorer nos scripts en ajoutant quelques fonctions de conversion (visibles dans le script suivant) :
Une autre fonction, format_nombre(), permet d’afficher les valeurs avec une virgule décimale, plus conforme à nos habitudes d’écriture.
- pieds_vers_km() pour la conversion d’une altitude en pieds vers des kilomètres.
- noeuds_vers_kmh() pour la conversion d’une vitesse en nœuds vers des kilomètres par heure.
- nautiques_vers_km() pour la conversion d’une distance en milles nautiques vers des kilomètres.
- Une autre fonction, format_nombre(), permet d’afficher les valeurs avec une virgule décimale, plus conforme à nos habitudes d’écriture.

Résultat du script Python appliqué au fichier aircraft.json de la station ADS-B locale, avec pour des unités métriques plus familières
Nous disposons maintenant des premières briques de notre démarche. Dans le paragraphe suivant, nous appliquerons le même principe à une source extérieure : l’API api.airplanes.live. Cette fois, les données ne viendront plus seulement de notre station locale, mais d’un service Internet alimenté par de nombreux contributeurs.
2. Interroger une API distante
2.1. De l’interface locale à l’API Airplanes.live
L’objectif de cette interrogation distante reste volontairement simple. Nous allons demander à l’API de nous retourner les avions connus autour d’un point géographique. Pour cela, nous lui transmettons trois informations : une latitude, une longitude et un rayon de recherche en milles.
Dans mon exemple, l’adresse utilisée est celle de ma station : https://api.airplanes.live/v2/point/44.08/-1.26/50


Lire directement la réponse dans un navigateur n’est pas très confortable. Comme pour aircraft.json, il devient donc beaucoup plus intéressant de confier cette lecture à un programme Python.
2.2. Notre second programme Python
Le second programme reprend donc la logique du premier. Il interroge une adresse, récupère une réponse au format JSON, puis affiche les informations utiles.
Pour des raisons de portabilité, les 3 valeurs (latitude, longitude, rayon) sont passées en variable.
</> Python
import json
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
# Coordonnées approximatives de la zone à interroger
latitude = 44.08
longitude = -1.26
# Rayon de recherche en milles nautiques
rayon_nm = 50
url = f"http://api.airplanes.live/v2/point/{latitude}/{longitude}/{rayon_nm}"
def format_nombre(valeur, decimales=1):
return f"{valeur:.{decimales}f}".replace(".", ",")
def pieds_vers_km(pieds):
return pieds * 0.3048 / 1000
def noeuds_vers_kmh(noeuds):
return noeuds * 1.852
def nautiques_vers_km(nautiques):
return nautiques * 1.852
requete = Request(
url,
headers={
"User-Agent": "F80543-HamAnalyst-API-Tests/3b"
}
)
try:
with urlopen(requete, timeout=15) as response:
data = json.load(response)
except HTTPError as erreur:
print(f"Erreur HTTP : {erreur.code} - {erreur.reason}")
print("Le serveur a refusé la requête.")
raise SystemExit
except URLError as erreur:
print(f"Erreur de connexion : {erreur.reason}")
raise SystemExit
aircraft = data.get("ac", [])
total = data.get("total", len(aircraft))
print()
print(f"Nombre d'avions retournés par l'API : {total}")
types_trouves = {}
for avion in aircraft:
type_donnee = avion.get("type", "non indiqué")
types_trouves[type_donnee] = types_trouves.get(type_donnee, 0) + 1
print("Types de données trouvés :")
for type_donnee, nombre in types_trouves.items():
print(f"- {type_donnee} : {nombre}")
print()
for avion in aircraft:
indicatif = avion.get("flight", "").strip()
hex_code = avion.get("hex", "inconnu")
type_donnee = avion.get("type", "non indiqué")
immatriculation = avion.get("r", "")
type_appareil = avion.get("t", "")
description = avion.get("desc", "")
altitude_ft = avion.get("alt_baro")
vitesse_kt = avion.get("gs")
cap = avion.get("track")
distance_nm = avion.get("dst")
nom = indicatif if indicatif else hex_code
ligne = f"{nom}"
ligne += f" - donnée : {type_donnee}"
if immatriculation:
ligne += f" - {immatriculation}"
if type_appareil:
ligne += f" - {type_appareil}"
if description:
ligne += f" ({description})"
if isinstance(altitude_ft, (int, float)):
ligne += f" - altitude : {format_nombre(pieds_vers_km(altitude_ft), 2)} km"
else:
ligne += " - altitude : non indiquée"
if isinstance(vitesse_kt, (int, float)):
ligne += f" - vitesse sol : {format_nombre(noeuds_vers_kmh(vitesse_kt), 0)} km/h"
else:
ligne += " - vitesse sol : non indiquée"
if isinstance(cap, (int, float)):
ligne += f" - cap : {format_nombre(cap, 1)}°"
else:
ligne += " - cap : non indiqué"
if isinstance(distance_nm, (int, float)):
ligne += f" - distance : {format_nombre(nautiques_vers_km(distance_nm), 1)} km"
print(ligne)
2.3. Quelques précautions dans l’appel à l’API
Ce second programme contient une nouveauté importante par rapport au précédent. Il ne se contente pas d’appeler une adresse avec urlopen. Il construit d’abord une requête plus complète, à laquelle il ajoute un User-Agent. Lors de mes premiers essais, l’appel direct avait retourné une erreur 403 Forbidden.

Ce message signifie que le serveur a refusé la requête. En ajoutant un User-Agent, le programme se présente de manière un peu plus claire auprès du serveur, ce qui permet généralement d’obtenir une réponse correcte. Dans mon cas, j’ai choisi la valeur F80543-HamAnalyst-API-Tests/3b, qui indique à la fois mon identifiant d’écoute, le cadre de ces tutoriels et la version du script utilisé. Cela ne donne aucun droit particulier d’accès à l’API, mais la requête est plus clairement identifiée qu’un appel anonyme lancé par un script Python.
Le programme prévoit également deux cas d’erreur :
- une erreur HTTP (HTTPError), lorsque le serveur refuse la requête ou ne peut pas la traiter ;
- une erreur de connexion (URLError), lorsque le programme ne parvient pas à joindre le service distant.
2.4. Lire la réponse retournée par Airplanes.live
La réponse retournée par Airplanes.live ressemble, dans son principe, à celle du fichier local aircraft.json. Mais sa structure n’est pas exactement identique. Dans notre fichier local, la liste des avions se trouve dans une rubrique nommée aircraft. Avec l’API Airplanes.live, cette même liste se trouve dans une rubrique plus courte, nommée ac.
Le programme utilise donc :
aircraft = data.get(« ac », [])
Cette ligne signifie : « dans les données reçues, cherche la rubrique ac; si elle n’existe pas, utilise une liste vide”. Cette précaution évite une erreur si la réponse retournée n’a pas la forme attendue.
L’API fournit également un champ total présent dans les dernières lignes qui correspondent aux informations générales de la réponse API (En fin de réponse, l’API ajoute quelques champs généraux. msg indique si une erreur a été rencontrée ; ici, No error confirme que la requête s’est déroulée normalement. Les champs now et ctime sont des horodatages indiquant respectivement le moment de génération et de mise en cache de la réponse. Enfin, ptime donne le temps de traitement nécessaire côté serveur. Ces informations ne décrivent donc pas un avion, mais l’état général de la réponse retournée par l’API). Il indique le nombre total d’appareils retournés : total = data.get(« total », len(aircraft))

Le programme affiche alors ce nombre, puis fait un petit bilan intitulé Types de données trouvés. Il compte les différentes valeurs rencontrées dans le champ type, par exemple adsb_icao,
mode_s ou mlat. Cette information sera importante pour la suite, car elle indique la nature des données retournées par l’API, et pas seulement la liste des avions détectés.

Résultat du script Python appliqué à l’appel de l’API Airplanes.live
Pour chaque avion, le programme extrait ensuite les informations les plus utiles : indicatif de vol, code hexadécimal, immatriculation, type d’appareil, description, altitude, vitesse sol, cap et distance par rapport au point choisi. Comme indiqué précédemment, notre programme convertit les unités aéronautiques en unités métriques plus familières.
Cette seconde expérience montre clairement ce qu’apporte une véritable API distante. Nous ne sommes plus limités à ce que notre propre antenne reçoit. Nous interrogeons un service extérieur, nous lui transmettons des paramètres simples, puis nous exploitons la réponse obtenue. Le programme devient alors capable de comparer les appareils présents dans une zone, d’observer les types de données utilisés, de repérer certaines sources particulières comme l’ADS-B classique ou la MLAT, et de présenter le tout sous une forme adaptée à nos besoins.
2.5. Rechercher spécifiquement les appareils en MLAT
Nous pouvons maintenant reprendre le script précédent en lui donnant un objectif plus précis : rechercher uniquement les appareils dont la position est issue de la multilatération, ou MLAT. Pour cela, le programme interroge l’API api.airplanes.live autour de ma station, mais avec un rayon beaucoup plus large : 249 milles nautiques, soit environ 461 km.
Pourquoi 249 milles nautiques ? Parce que l’endpoint /point/[lat]/[lon]/[radius] de l’API Airplanes.live accepte un rayon maximal de 250 nm. En restant juste en dessous, nous évitons de travailler exactement sur la valeur limite, tout en couvrant une zone très large autour de la station.
Le programme récupère donc la liste complète des appareils retournés par l’API, puis ne conserve que ceux dont le champ type vaut mlat.
</> Python
[…]
aircraft = data.get("ac", [])
# Filtre MLAT
aircraft_mlat = [avion for avion in aircraft if avion.get("type") == "mlat"]
print(f"Nombre total d'avions retournés par l'API : {len(aircraft)}")
print(f"Nombre d'avions repérés en mode MLAT : {len(aircraft_mlat)}")
print()
for avion in aircraft_mlat:
indicatif = avion.get("flight", "").strip()Trois lignes complémentaires constituent le cœur de cette version du programme. La première parcourt tous les avions retournés par l’API et ne garde que ceux dont le champ type est égal à mlat. Les deux lignes suivantes affichent ensuite le nombre total d’appareils reçus, puis le nombre d’appareils effectivement repérés en mode MLAT. Le script ne change donc pas la manière d’interroger l’API ; il ajoute simplement un filtre pour isoler une catégorie particulière de données.
Sachant que ma station envoie ses données à Airplanes.live, celles-ci sont intégrées aux données globales du site qui les regroupe pour ne retenir que la meilleure donnée disponible.
Le code complet de ce script [04-Airplanes_MLAT.py // Test_Api_4.py] peut être téléchargé à partir du lien placé en fin de document (fichier Test_Api.zip).
2.6. Faire le bilan des données locales
Terminons cette première série de scripts avec un comptage des avions vus par notre station et classés par types de données reçues. Le script [05-Ma_Station_par_Type // Test_Api_5.py], disponible au téléchargement à partir du lien placé en fin de document (fichier Test_Api.zip), reprend la lecture du fichier local aircraft.json, mais modifie l’affichage. Au lieu de présenter les avions dans l’ordre où ils apparaissent dans le fichier, le programme commence par les regrouper selon la valeur du champ type. Il crée pour cela un dictionnaire dans lequel chaque clé correspond à une nature de donnée, par exemple adsb_icao, mode_s ou mlat. Les avions sont ensuite affichés groupe par groupe. Cette présentation permet de voir immédiatement quels types de données notre station reçoit réellement.


Résultat du tri par type appliqué au fichier aircraft.json local.
3. Comparer sa station avec une source extérieure
Jusqu’ici, nous avons utilisé deux sources séparées. D’un côté, notre station locale, consultée à travers le fichier aircraft.json
. De l’autre, l’API api.airplanes.live, interrogée autour d’un point géographique. Nous pouvons maintenant franchir une étape supplémentaire : comparer les deux réponses.
3.1. Rapprocher les avions vus localement et ceux retournés par l’API
Notre script interrogera tout d’abord le fichier local : http://192.168.1.20:8080/data/aircraft.json
Puis il interrogera l’API distante autour de la même position : https://api.airplanes.live/v2/point/44.08/-1.26/50
J’ai choisi un rayon de comparaison à 50 milles nautiques, soit environ 92,6 km. Le programme ne cherche donc pas à comparer toute la couverture mondiale d’Airplanes.live avec notre station. Il se limite volontairement à une zone proche, afin que la comparaison garde un sens.
Le principe utilisé est le suivant : chaque avion est identifié par son code hexadécimal ICAO, contenu dans le champ hex. Le script crée alors deux listes indexées par ce code : une pour les avions vus localement, une autre pour les avions retournés par l’API.
Le code complet de ce script [06-Comparaison.py // Test_Api_6e.py] peut être téléchargé à partir du lien placé en fin de document (fichier Test_Api.zip).
Il commence par préparer la comparaison géographique. Pour cela, il crée deux dictionnaires : l’un pour les avions locaux comparables dans le rayon choisi, l’autre pour les avions retournés par l’API avec l’appel /point/. Dans chaque cas, la clé du dictionnaire est le code hexadécimal ICAO.
</> Python
local_par_hex = {}
for avion in aircraft_local_rayon:
hex_code = normaliser_hex(avion.get("hex"))
if hex_code:
local_par_hex[hex_code] = avion
api_point_par_hex = {}
for avion in aircraft_api_point:
hex_code = normaliser_hex(avion.get("hex"))
if hex_code:
api_point_par_hex[hex_code] = avion
Avant la comparaison proprement dite, le programme distingue d’abord les avions locaux réellement comparables géographiquement de ceux qui ne le sont pas. Pour qu’un avion local puisse être rapproché de la réponse /point/
d’Airplanes.live, il doit disposer d’une distance exploitable et se trouver dans le rayon choisi. Les autres cas sont classés à part.
</> Python
aircraft_local_rayon = []
aircraft_local_hors_rayon = []
aircraft_local_sans_distance = []
for avion in aircraft_local:
distance = distance_nm_locale(avion)
if isinstance(distance, (int, float)):
if distance <= rayon_nm:
aircraft_local_rayon.append(avion)
else:
aircraft_local_hors_rayon.append(avion)
else:
aircraft_local_sans_distance.append(avion)Cette précaution est importante. Certains appareils reçus localement, notamment en mode_s, peuvent être présents dans aircraft.json sans disposer de position ou de distance exploitable. Ils sont bien vus par la station, mais ils ne peuvent pas être comparés directement avec une recherche géographique autour d’un point.
Le programme prépare ensuite la comparaison entre les avions locaux comparables et ceux retournés par l’API avec l’appel /point/. Il transforme les codes ICAO en ensembles, puis calcule trois catégories :
</> Python
hex_local = set(local_par_hex.keys())
hex_api_point = set(api_point_par_hex.keys())
communs = sorted(hex_local & hex_api_point)
seulement_local = sorted(hex_local - hex_api_point)
seulement_api = sorted(hex_api_point - hex_local)La première catégorie contient les avions présents à la fois dans aircraft.json et dans la réponse géographique de l’API. La seconde regroupe les appareils vus localement, mais non retrouvés dans la réponse /point/ d’Airplanes.live. La troisième rassemble les avions retournés par l’API, mais non reçus localement par notre station au moment du test.
Les avions locaux sans distance exploitable ne sont donc pas oubliés. Ils sont traités dans une étape complémentaire : le script utilise leur code ICAO hexadécimal et interroge l’API avec l’endpoint /hex/. On ne demande alors plus : « quels avions sont présents autour de ma station ? », mais plutôt : « cet avion précis est-il connu actuellement par l’API ? »
Voici, à titre d’exemple, le bilan général retourné par le script lorsqu’il analyse le trafic aérien dans un rayon de 50 milles nautiques autour de ma station, soit environ 92,6 km.

Bilan général de la comparaison aircraft.json local / API Airplanes.live
Dans l’exemple ci-dessous, le script commence par lire les 33 avions présents dans le fichier local aircraft.json. Parmi eux, 11 sont directement comparables dans le rayon choisi, 19 sont hors rayon, et 3 sont présents localement sans distance exploitable.
De son côté, l’appel /point/ à api.airplanes.live retourne 13 avions dans le même rayon. La comparaison géographique donne alors trois résultats :
- 10 avions sont présents dans les deux listes ;
- 1 avion est vu seulement par la station locale ;
- 3 avions sont vus seulement par api.airplanes.live.
Le programme poursuit ensuite avec les 3 avions locaux sans distance exploitable. Aucun n’était déjà présent dans la réponse /point/, mais tous les trois sont retrouvés par une recherche complémentaire /hex/. Aucun avion ne reste donc sans correspondance API dans cet exemple.
Cette première capture constitue le résumé le plus important du script. Elle montre que la comparaison ne se limite pas à une simple opposition entre “vu” et “non vu”. Le programme distingue les avions réellement comparables dans le rayon, les avions locaux hors rayon, les avions locaux sans distance, puis les appareils retrouvés ou non par une recherche complémentaire.
Elle permet aussi de vérifier la nature des données reçues. Dans cet exemple, la station locale voit 30 appareils en adsb_icao, 1 en mlat et 2 en mode_s. Parmi les avions locaux comparables dans le rayon, on retrouve 10 adsb_icao et 1 mlat. Les trois avions locaux sans distance exploitable comprennent, eux, 1 adsb_icao et 2 mode_s.
3.2. Lire les résultats de la comparaison
Après le bilan général, le script détaille les différentes catégories d’avions. Cette présentation est plus lisible qu’une simple liste brute, car elle permet de comprendre d’où vient chaque appareil et pourquoi il est classé dans telle ou telle rubrique.
La première rubrique rassemble les avions présents dans les deux listes géographiques. Ce sont les appareils que notre station reçoit localement et que l’API api.airplanes.live retrouve également dans le rayon demandé. Dans l’exemple présenté, ils sont au nombre de 10.

Avions présents dans les deux listes géographiques (aircraft.json local & API Airplanes.live)
On remarque que ces dix appareils sont tous de type adsb_icao. Ce sont principalement des avions de ligne, à des altitudes comprises ici entre environ 10,36 km et 11,58 km. Ce résultat est assez logique : les avions volant à haute altitude sont généralement bien reçus par notre station locale et bien connus de l’API. Ils constituent donc le cœur de la zone de recouvrement entre les deux sources.
La rubrique suivante est plus intéressante, car elle montre les écarts entre les deux visions du trafic.

Avions vus seulement par la station locale, avions vus seulement par l’API, et avions locaux sans distance retrouvés par l’API
La première liste montre les avions vus seulement par la station locale, avec une distance connue. Dans cet exemple, un seul appareil apparaît dans cette catégorie : le Pilatus PC-6 Turbo-Porter immatriculé F-HBSF. Il est reçu localement en mlat, à une altitude d’environ 1,01 km et à seulement 10,1 km de la station. Il est donc bien proche et exploitable côté local, mais il n’apparaît pas dans la réponse géographique retournée par Airplanes.live au moment du test.
Ce cas rappelle qu’une API distante ne reproduit pas toujours exactement ce que notre station reçoit. Le décalage peut venir du moment de l’interrogation, de la fraîcheur des données, de la manière dont le service traite les positions MLAT, ou encore des choix internes de l’API.
La liste suivante présente les avions vus seulement par api.airplanes.live. Ici, trois appareils apparaissent : un Issoire APM-30 Lion, un Cirrus SR-22 et un Robin DR-400. Ils sont tous indiqués en adsb_icao, mais ils ne sont pas reçus localement par notre station dans cette comparaison.
Leur altitude explique peut-être une partie de l’écart. Deux d’entre eux sont très bas, autour de 549 m et 526 m. À ces hauteurs, la réception 1090 MHz dépend beaucoup du relief, des obstacles, de l’orientation de l’antenne et de la position réelle de l’avion. Un appareil bas peut être parfaitement visible pour une autre station contributrice du réseau, mais invisible depuis notre propre antenne.
La dernière rubrique concerne les avions locaux sans distance exploitable, mais retrouvés par l’API. Elle est importante, car elle évite de faire disparaître des appareils simplement parce qu’ils ne peuvent pas être comparés géographiquement.
Dans cet exemple, trois avions locaux étaient sans distance exploitable. Le script les a donc recherchés par leur code ICAO avec l’endpoint /hex/. Tous les trois ont été retrouvés : un Airbus A330-300, un Socata Tobago et un Airbus A400M. Les deux derniers sont d’ailleurs associés à une donnée mode_s, ce qui illustre bien l’intérêt de ce traitement complémentaire.
Pour ces avions, la distance reste indiquée comme non renseignée. C’est normal : ils ne sont pas comparés à partir d’une position locale connue, mais retrouvés par leur identifiant ICAO. Le programme ne dit donc pas nécessairement où ils se trouvent par rapport à la station ; il indique simplement que l’API connaît bien ces appareils au moment de l’interrogation.
La dernière catégorie, “Avions locaux sans distance et sans correspondance API”, est vide dans cet exemple. C’est plutôt une bonne nouvelle : tous les avions locaux sans distance ont pu être retrouvés par Airplanes.live. Dans un autre essai, cette rubrique pourrait contenir des appareils vus par la station locale, mais absents de l’API au moment du test.
Cette comparaison montre donc que les deux sources ne se recouvrent jamais parfaitement. La station locale reflète ce que notre propre antenne reçoit réellement. L’API donne une vision issue d’un réseau plus large, mais avec ses propres limites et ses propres choix de traitement. Le script permet de rapprocher ces deux visions, de classer les différences, puis de comprendre pourquoi un appareil apparaît dans une liste et pas dans l’autre.
En pratique, nous ne faisons plus seulement afficher des avions. Nous commençons à analyser des données : les trier, les croiser, repérer les écarts, puis interpréter leur origine probable. C’est tout l’intérêt de cette progression avec Python : transformer un fichier JSON et une réponse API en informations lisibles et exploitables.
4. Surveiller une liste d’appareils
Après avoir comparé les avions reçus par notre station avec ceux retournés par une API autour d’un point géographique, nous allons maintenant partir d’une liste d’appareils précis, puis demander à l’API s’ils sont en vol au moment de l’interrogation.
L’idée est simple. Si nous connaissons le code hexadécimal ICAO d’un avion, nous pouvons interroger directement api.airplanes.live avec l’endpoint /hex/. Le programme ne demande donc plus : Quels avions se trouvent autour de ma station ?. Il demande plutôt : Cet avion précis est-il actuellement connu de l’API ?.
4.1. Première recherche ciblée : la « flotte présidentielle »
Pour cet essai, j’ai choisi un exemple parlant : quelques appareils de la flotte française utilisée pour les déplacements officiels. Le but n’est évidemment pas de tirer des conclusions opérationnelles sur leurs missions ; il s’agit seulement d’un cas pédagogique intéressant, car les appareils sont identifiables, peu nombreux et faciles à placer dans une liste de test.
J’ai pour cela visité le site flottepresidentielle.com (https://flottepresidentielle.com/). Celui-ci rassemble des données publiques consacrées aux avions utilisés pour le transport du Président de la République, des ministres et de hautes autorités françaises. Il recense actuellement 9 appareils, avec leur type, leur immatriculation, leur code ICAO hexadécimal, leur statut et, lorsque les données sont disponibles, l’historique ou les dernières positions de vol.

L’une des pages du site flottepresidentielle.com
Ce site m’a donc permis de constituer une première liste d’appareils à inscrire dans le script Python.

Les premières lignes du script avec la liste des avions
Chaque appareil est défini par son immatriculation, son code hexadécimal ICAO et son type. La méthode n’est pas la plus souple, mais elle a l’avantage d’être très lisible pour comprendre le principe.
Le programme parcourt ensuite cette liste, avion par avion. Pour chacun, il construit une URL du type : https://api.airplanes.live/v2/hex/3B76B3
L’API retourne alors une réponse JSON. Si la rubrique ac contient un avion, le programme analyse ses données. Si cette rubrique est vide, l’appareil est simplement signalé comme non vu au moment du test.
</> Python
USER_AGENT = "F80543-HamAnalyst-API-Tests/7"
def interroger_airplanes_live(hex_code):
url = f"http://api.airplanes.live/v2/hex/{hex_code}"
return charger_json(url, "api.airplanes.live")
def afficher_non_vu(numero, total, immatriculation, type_reference):
print(
f"{numero}/{total} - {immatriculation} - "
f"{type_reference} - non vu actuellement par l'API"
)
def afficher_en_vol(numero, total, immatriculation, type_reference):
print(
f"{numero}/{total} - {immatriculation} - "
f"{type_reference} - probablement en vol"
)Notons dès à présent que la méthode préconisée ici présente une limite importante. Si l’API ne retourne rien pour un appareil, cela ne signifie pas forcément que celui-ci est au sol. Il peut être hors couverture, ne pas transmettre sa position, être masqué par les règles du service interrogé ou simplement ne pas être présent dans les données instantanées accessibles au moment du test.
Le critère “probablement en vol” reste volontairement simple. Le programme considère l’appareil comme actif si son altitude est supérieure à 1000 pieds ou si sa vitesse sol dépasse 80 nœuds. Ce n’est pas une règle aéronautique absolue. C’est seulement un filtre pratique pour éviter de détailler un appareil éventuellement présent dans les données, mais manifestement immobile ou sans information suffisante.
</> Python
total_avions = len(avions)
for numero, (immatriculation, infos) in enumerate(avions.items(), start=1):
hex_code = infos["hex"]
type_reference = infos["type"]
data = interroger_airplanes_live(hex_code)
if not data:
afficher_non_vu(numero, total_avions, immatriculation, type_reference)
time.sleep(1.2)
continue
aircraft = data.get("ac", [])
if not aircraft:
afficher_non_vu(numero, total_avions, immatriculation, type_reference)
time.sleep(1.2)
continue
avion = aircraft[0]
altitude_ft = avion.get("alt_baro")
vitesse_kt = avion.get("gs")
en_vol = False
if isinstance(altitude_ft, (int, float)) and altitude_ft > 1000:
en_vol = True
if isinstance(vitesse_kt, (int, float)) and vitesse_kt > 80:
en_vol = True
if not en_vol:
afficher_non_vu(numero, total_avions, immatriculation, type_reference)
time.sleep
...
Le code complet de ce script [07-Presidentiel_simple // Test_Api_7.py] peut être téléchargé à partir du lien placé en fin de document (fichier Test_Api.zip).

Code simple – L’un des appareils utilisés pour les déplacements officiels français en vol
4.2. Préciser la position de l’appareil – Base Nominatim
Pour un lecteur non spécialiste, l’affichage brut d’une latitude et une longitude n’est pas très parlant. Pour améliorer la lecture du résultat, je décide donc d’utiliser Nominatim, un service de géocodage associé à OpenStreetMap.
Le principe est simple : le script transmet la latitude et la longitude à Nominatim, puis celui-ci tente de retrouver un nom de ville, de commune, de département, de région ou de pays correspondant à cette position. Il devient alors possible d’afficher une indication plus lisible, du type près de …, au-dessus de … ou à proximité de …, plutôt qu’une simple paire de coordonnées chiffrées.
Ce service peut cependant ne pas répondre, répondre lentement ou retourner une localisation trop approximative. Comme le montre l’extrait ci-dessous, le programme prévoit donc une phrase de secours lorsque la position ne peut pas être déterminée avec suffisamment de précision.
</> Python
from urllib.parse import urlencode
def geocoder_position(lat, lon):
"""
Transforme une latitude et une longitude en indication géographique
lisible à l'aide de Nominatim / OpenStreetMap.
"""
params = urlencode({
"format": "jsonv2",
"lat": lat,
"lon": lon,
"zoom": 10,
"addressdetails": 1,
"accept-language": "fr",
})
url = f"https://nominatim.openstreetmap.org/reverse?{params}"
data = charger_json(url, "Nominatim / OpenStreetMap")
if not data:
return "position géographique non déterminée"
adresse = data.get("address", {})
pays = adresse.get("country", "")
ville = (
adresse.get("city")
or adresse.get("town")
or adresse.get("village")
or adresse.get("municipality")
or adresse.get("county")
or adresse.get("state")
or ""
)
if ville and pays:
return f"près de {ville}, {pays}"
if pays:
return f"au-dessus ou à proximité de {pays}"
nom = data.get("display_name", "")
if nom:
return nom
return "position géographique non déterminée"
Le programme n’appelle Nominatim que si les coordonnées existent réellement dans la réponse API. Cela évite d’interroger inutilement le service lorsqu’un appareil est seulement identifié sans position exploitable.

Code Nominatim – L’un des appareils utilisés pour les déplacements officiels français est en vol, repéré à proximité de l’Italie
Le code complet de ce script 08-Presidentiel_Nominatim // Test_Api_7d4.py] peut être téléchargé à partir du lien placé en fin de document (fichier Test_Api.zip).
4.3. Préciser la position de l’appareil – Fichiers CSV locaux
Afin de ne plus dépendre systématiquement d’un service extérieur, mais aussi de mieux traiter les avions situés “au milieu de nulle part”, notamment au-dessus d’un océan, nous allons créer puis utiliser de petits fichiers CSV locaux. L’objectif n’est pas de remplacer une base géographique complète, mais de disposer de repères simples, maîtrisés et modifiables, capables d’améliorer la position affichée dans certains cas.
Le principe est le suivant : lorsque l’API retourne une latitude et une longitude, le script compare cette position avec plusieurs références connues. Si l’avion est proche d’une ville figurant dans notre fichier, le programme affiche une phrase du type près de Bordeaux, France ou à environ 80 km de Mont-de-Marsan, France. Si l’avion est au-dessus de la mer, il peut aussi reconnaître une zone maritime de référence, par exemple le golfe de Gascogne ou l’océan Atlantique. Nous utiliserons pour cela 3 fichiers :
coordonnees_reference_pays.csv
villes_reference.csv
zones_maritimes_reference.csv
Le fichier villes_reference.csv contient des villes ou des points de repère suffisamment connus pour situer approximativement un avion. On y trouve le nom de la ville, le pays, sa latitude, sa longitude et une distance maximale d’utilisation.

Fichier villes_reference.csv
Le fichier coordonnees_reference_pays.csv
joue un rôle général. Il fournit un point de référence par pays. Il ne permet pas de localiser finement un appareil, mais il sert de secours lorsque la position ne peut pas être rapprochée d’une ville connue. Grâce à ce fichier, le programme peut afficher une indication prudente du type à environ … km de la référence pays : France.

Fichier coordonnees_reference_pays.csv
Les coordonnées ne décrivent pas les frontières des pays et ne correspondent pas nécessairement à leur capitale. Dans la plupart des cas, il s’agit d’une position approximative, plutôt centrale ou représentative, destinée uniquement à fournir une indication de secours lorsque ni les villes ni les zones maritimes ne permettent une localisation plus parlante.
Le fichier zones_maritimes_reference.csv complète les deux précédents. Il décrit des rectangles géographiques simples, définis par des limites de latitude et de longitude. Lorsqu’un avion se trouve dans l’un de ces rectangles, le programme peut afficher une indication maritime plutôt que de chercher inutilement une ville proche. C’est particulièrement utile pour les vols au-dessus de l’Atlantique ou du golfe de Gascogne.

Fichier zones_maritimes_reference.csv
Code partiel : définir les fichiers de référence
Dans le script, nous commençons par indiquer où se trouvent les fichiers CSV. Le répertoire de travail reste celui du programme Python, et les références géographiques sont rangées dans un sous-répertoire séparé.
</> Python
import csv
from pathlib import Path
DOSSIER_SCRIPT = Path(__file__).resolve().parent
DOSSIER_CSV = DOSSIER_SCRIPT / "Fichiers_CSV"
FICHIER_COORDONNEES_PAYS = DOSSIER_CSV / "coordonnees_reference_pays.csv"
FICHIER_VILLES_REFERENCE = DOSSIER_CSV / "villes_reference.csv"
FICHIER_ZONES_MARITIMES_REFERENCE = DOSSIER_CSV / "zones_maritimes_reference.csv"
COORDONNEES_REFERENCE_PAYS = {}
VILLES_REFERENCE = []
ZONES_MARITIMES_REFERENCE = []Cette organisation évite d’écrire les références géographiques directement dans le programme. Les données sont dans des fichiers séparés, plus simples à modifier, tandis que le script conserve uniquement la logique de lecture et de comparaison.
Code partiel : lire une valeur numérique
Les fichiers CSV sont des fichiers texte. Une latitude ou une longitude peut donc arriver sous forme de chaîne de caractères. La fonction suivante transforme ces valeurs en nombres utilisables par le programme. Elle accepte aussi les virgules décimales, ce qui est pratique lorsque l’on modifie les fichiers avec des habitudes françaises d’écriture.
</> Python
def lire_float(valeur):
if valeur is None:
return None
texte = str(valeur).strip().replace(",", ".")
if not texte:
return None
try:
return float(texte)
except ValueError:
return NoneCode partiel : charger les villes de référence
La fonction suivante lit le fichier villes_reference.csv. Chaque ligne exploitable est ajoutée à une liste de villes. Le programme conserve le nom, le pays, la latitude, la longitude et une distance maximale d’utilisation.
</> Python
def charger_villes_reference():
villes = []
if not FICHIER_VILLES_REFERENCE.exists():
print(f"Villes de référence absentes : {FICHIER_VILLES_REFERENCE}")
return villes
with open(FICHIER_VILLES_REFERENCE, "r", encoding="utf-8-sig", newline="") as fichier:
lecteur = csv.DictReader(fichier)
for ligne in lecteur:
nom = str(ligne.get("nom") or "").strip()
pays = str(ligne.get("pays") or "").strip()
lat = lire_float(ligne.get("lat"))
lon = lire_float(ligne.get("lon"))
distance_max = lire_float(ligne.get("distance_max_km"))
if distance_max is None:
distance_max = 150.0
if nom and pays and lat is not None and lon is not None:
villes.append({
"nom": nom,
"pays": pays,
"lat": lat,
"lon": lon,
"distance_max_km": distance_max,
})
return villesCette lecture est volontairement tolérante. Si une ligne est incomplète, elle n’est pas utilisée. Le fichier peut donc être enrichi progressivement, sans rendre tout le programme inutilisable à la moindre erreur.
Code partiel : charger les zones maritimes
Pour les zones maritimes, la logique est un peu différente. On ne cherche pas un point précis, mais une zone définie par des limites. Le programme vérifie donc si la latitude et la longitude d’un avion se trouvent dans l’un des rectangles décrits dans le fichier.
</> Python
def charger_zones_maritimes_reference():
zones = []
if not FICHIER_ZONES_MARITIMES_REFERENCE.exists():
print(f"Zones maritimes absentes : {FICHIER_ZONES_MARITIMES_REFERENCE}")
return zones
with open(FICHIER_ZONES_MARITIMES_REFERENCE, "r", encoding="utf-8-sig", newline="") as fichier:
lecteur = csv.DictReader(fichier)
for ligne in lecteur:
nom_zone = str(ligne.get("nom_zone") or "").strip()
if not nom_zone:
continue
lat_min = lire_float(ligne.get("lat_min"))
lat_max = lire_float(ligne.get("lat_max"))
lon_min = lire_float(ligne.get("lon_min"))
lon_max = lire_float(ligne.get("lon_max"))
if None in {lat_min, lat_max, lon_min, lon_max}:
continue
formulation = str(ligne.get("formulation") or "").strip()
if not formulation:
formulation = nom_zone
zones.append({
"nom_zone": nom_zone,
"formulation": formulation,
"lat_min": min(lat_min, lat_max),
"lat_max": max(lat_min, lat_max),
"lon_min": lon_min,
"lon_max": lon_max,
})
return zonesCette méthode reste approximative, mais elle est largement suffisante pour notre besoin. Nous ne cherchons pas à faire de la cartographie maritime précise. Nous voulons simplement éviter d’afficher une position brute ou une ville trop éloignée lorsqu’un avion se trouve clairement au-dessus de la mer.
Code partiel : rechercher la ville la plus proche
Pour trouver une ville proche, le programme calcule la distance entre la position de l’avion et chaque ville de référence. Il conserve ensuite la plus proche. Le calcul repose sur la formule de Haversine, déjà utilisée dans plusieurs scripts précédents pour calculer une distance entre deux coordonnées géographiques.
</> Python
def trouver_ville_la_plus_proche(lat, lon):
meilleure = None
for ville in VILLES_REFERENCE:
distance = distance_km(lat, lon, ville["lat"], ville["lon"])
if meilleure is None or distance < meilleure[1]:
meilleure = (ville, distance)
return meilleureLe résultat n’est pas une preuve que l’avion survole réellement cette ville. Il indique seulement que cette ville est le repère connu le plus proche dans notre petite base locale.
Code partiel : rechercher une zone maritime
La recherche d’une zone maritime est plus directe. On regarde si la latitude de l’avion se trouve entre lat_min et lat_max, puis si sa longitude se trouve entre lon_min et lon_max.
</> Python
def trouver_zone_maritime(lat, lon):
for zone in ZONES_MARITIMES_REFERENCE:
if not (zone["lat_min"] <= lat <= zone["lat_max"]):
continue
if zone["lon_min"] <= lon <= zone["lon_max"]:
return zone
return NoneSi une zone est trouvée, le programme pourra afficher la formulation prévue dans le CSV, par exemple “au-dessus du golfe de Gascogne”.
Code partiel : produire une position approximative
La fonction finale rassemble les éléments précédents. Elle cherche d’abord une ville proche. Si aucune ville n’est suffisamment proche, elle regarde si l’avion se trouve dans une zone maritime. Si ce n’est toujours pas le cas, elle peut revenir à un affichage plus général.
</> Python
def position_approximative(lat, lon):
if lat is None or lon is None:
return "position non renseignée"
ville_proche = trouver_ville_la_plus_proche(lat, lon)
if ville_proche is not None:
ville, distance = ville_proche
if distance <= 50:
return f"près de {ville['nom']}, {ville['pays']}"
zone_maritime = trouver_zone_maritime(lat, lon)
if zone_maritime is not None:
return zone_maritime["formulation"]
if ville_proche is not None:
ville, distance = ville_proche
distance_max = float(ville.get("distance_max_km") or 150)
if distance <= distance_max:
return f"à environ {distance:.0f} km de {ville['nom']}, {ville['pays']}"
if distance <= 500:
return f"à environ {distance:.0f} km de {ville['nom']}, {ville['pays']}"
return f"lat {lat:.5f}, lon {lon:.5f}"Cette version du code reste volontairement simple. Elle montre clairement l’ordre de recherche : ville proche, zone maritime, ville plus éloignée, puis coordonnées brutes en dernier recours. Elle suffit donc pour comprendre la logique générale.
Elle présente cependant une petite limite. Lorsque le programme trouve une zone maritime, il affiche directement le contenu du champ formulation présent dans le fichier CSV. Cela suppose que cette formulation soit déjà correctement rédigée. Si le fichier contient “golfe de Gascogne”, le résultat ne sera pas aussi naturel que “au-dessus du golfe de Gascogne”. À l’inverse, si le fichier contient déjà “au-dessus du golfe de Gascogne”, il ne faut pas que le programme ajoute une seconde fois “au-dessus de”. Le bon affichage dépend donc ici de la qualité rédactionnelle du fichier CSV.
Dans la version plus avancée du script, cette difficulté sera traitée par une petite fonction supplémentaire chargée de formater plus proprement les zones maritimes avant affichage.
Code partiel : charger les fichiers au lancement
Au début du traitement, les trois fichiers sont chargés une seule fois. Les listes ainsi créées seront ensuite utilisées par toutes les fonctions de localisation.
</> Python
COORDONNEES_REFERENCE_PAYS = charger_coordonnees_reference_pays()
VILLES_REFERENCE = charger_villes_reference()
ZONES_MARITIMES_REFERENCE = charger_zones_maritimes_reference()
print("Références géographiques chargées :")
print(f"- pays : {len(COORDONNEES_REFERENCE_PAYS)}")
print(f"- villes : {len(VILLES_REFERENCE)}")
print(f"- zones maritimes : {len(ZONES_MARITIMES_REFERENCE)}")
Le code complet de ce script [09-Presidentiel_CSV // Test_Api_7d2.py] peut être téléchargé à partir du lien placé en fin de document (fichier Test_Api.zip).

Fichiers CSV locaux – L’un des appareils utilisés pour les déplacements officiels français est en vol, repéré au-dessus de la mer Adriatique
Avec cette version, nous poursuivons notre travail de séparation entre le code et les données. Le programme contient la logique. Les fichiers CSV contiennent les références. C’est une étape importante, car elle va permettre de gérer plus facilement des listes d’avions, des pays à surveiller, des mots-clés ou des priorités personnelles.
5. Créer des listes d’avions prioritaires
Notre liste, inscrite directement dans le code, fonctionne pour un test, mais elle devient vite peu pratique. Chaque ajout ou modification impose d’ouvrir le script Python et de le modifier avec le risque d’introduire une erreur de syntaxe.
Avec ce nouveau chapitre, nous allons franchir une étape importante. Si nous savons interroger une petite liste préparée à la main, nous pouvons maintenant chercher à construire automatiquement des listes plus riches qu’un script Python saura appeler. C’est ce que permettent les bases publiques plane-alert-db, qui recensent les appareils gouvernementaux, militaires, officiels ou sensibles. Nous allons donc télécharger ces bases, les enrichir, créer des listes « d’appareils prioritaires » afin d’interroger api.airplanes.live afin de visualiser ceux qui sont en vol au moment du test.
5.1. Organisation générale des dossiers utilisés
Le script que nous allons maintenant décrire s’appuie sur plusieurs sous-répertoires placés à côté du programme Python. Cette organisation permet de séparer les bases d’origine, les fichiers de référence, les fichiers temporaires, les listes prioritaires et les résultats finaux.

Structure des répertoires après un premier lancement du script Test_Api_17o.py
Fichiers utilisés pour la localisation. Déjà mis en place (introduits dans le paragraphe 4.3), ils permettent de transformer une latitude et une longitude en indication plus lisible :
- coordonnees_reference_pays.csv : points de repère approximatifs par pays, utilisés en dernier recours.
- villes_reference.csv : villes ou lieux de référence permettant d’afficher une position du type “près de…” ou “à environ … km de…”.
- zones_maritimes_reference.csv : zones en mer permettant d’éviter d’afficher uniquement des coordonnées lorsqu’un avion se trouve au-dessus d’un océan ou d’un golfe.
- positions_a_completer.csv : carnet de travail alimenté lorsque les références locales ne suffisent pas.
Bases plane-alert (introduites et décrites dans le paragraphe 5.2) :
- plane-alert-gov.csv
- plane-alert-mil.csv
- plane-alert-pol.csv
- plane-alert-civ.csv
- plane-alert-db_meta.json : Fichier technique de contrôle qui mémorise l’état des fichiers plane-alert utilisés.
Fichiers plane-alert enrichis (introduits et décrits dans le paragraphe 5.3) :
- plane-alert-index-enrichi.csv : index enrichi utilisé par défaut, basé sur gov, mil et pol.
- plane-alert-index-enrichi-avec-civ.csv : variante créée si l’utilisateur demande aussi l’intégration de la base civ (absente de l’arborescence présentée plus haut).
- plane-alert-index-enrichi.csv.meta.json : fichier technique de contrôle qui mémorise l’état des fichiers enrichis utilisés.
Fichiers d’aide à l’enrichissement (introduits dans le paragraphe 5.4) :
- mots_cles_pays.csv : associe des mots-clés à des pays.
- prefixes_immatriculation_pays.csv : associe certains préfixes d’immatriculation à un pays probable.
- rattachements_reference.csv : associe des mots-clés à des organisations, agences ou missions particulières.
Fichiers résultats :
- PRIO_DTB.csv : fichier contenant le résultat de la recherche dans la base. Le précédent est écrasé.
- PRIO_*.csv : fichiers résultats sauvegardés sur demande de l’utilisateur pour une utilisation dans des requêtes ultérieures (exemples : PRIO_FR_Presidentiel.csv, PRIO_Mirages2000.csv).
- resultats_finaux.json : résultat final structuré, uniquement créé lorsque des appareils probablement en vol sont trouvés et destiné à d’éventuelles requêtes ultérieures.
Fichiers_tempo : dossier prévu comme espace de travail temporaire sans rôle actif dans la version décrite dans ce chapitre.
5.2. Découverte des bases plane-alert
Les bases plane-alert-db proviennent d’un projet hébergé sur GitHub, une plateforme qui permet de publier et de mettre à jour des fichiers dans un dépôt accessible en ligne. Ce type de projet permet leur évolution dans le temps : les fichiers peuvent ainsi être corrigés, complétés ou réorganisés par leurs auteurs. Ces bases, issues d’un travail collaboratif vont nous permettre de repérer certains aéronefs d’intérêt. Elles sont constituées de quatre fichiers principaux :
- plane-alert-gov.csv regroupe des appareils à caractère gouvernemental, officiel ou diplomatique. On peut y trouver des avions associés à des États, à des gouvernements, à des chefs d’État, à des ministères, à des autorités publiques ou à des déplacements officiels.
- plane-alert-mil.csv concerne les appareils militaires. Il peut s’agir d’avions de transport, de ravitailleurs, d’avions de patrouille, d’appareils d’entraînement, de commandement ou plus généralement d’aéronefs associés à des forces armées.
- plane-alert-pol.csv rassemble des appareils liés aux forces de police, aux douanes, aux garde-côtes, aux services de surveillance, de sécurité ou de contrôle des frontières. Cette base est intéressante, car elle couvre des appareils qui ne sont pas toujours militaires, mais qui relèvent tout de même de missions publiques particulières.
- plane-alert-civ.csv contient des appareils civils signalés comme intéressants dans le cadre de ce projet. Cette base est potentiellement plus large et parfois moins directement liée à notre objectif de surveillance prioritaire. Elle peut être utile pour élargir une recherche, mais elle risque aussi d’amener davantage de résultats moins pertinents pour nos essais.

Structure des fichiers plane-alert ; ici plane-alert-gov.csv
Dans ces fichiers, chaque ligne correspond à un appareil, identifié principalement par son code ICAO hexadécimal et complété par plusieurs informations descriptives :
- ICAO : Code hexadécimal ICAO 24 bits de l’appareil transmis par le transpondeur Mode S / ADS-B.
- Registration : immatriculation officielle de l’appareil, par exemple F-RAFA, G-XXXX ou N123AB. Plus parlante que le code ICAO, elle est parfois moins fiable que l’identifiant hexadécimal.
- Operator : Opérateur, propriétaire ou organisme associé à l’appareil. Il peut s’agir d’une armée, d’un gouvernement, d’une force de police, d’une agence publique, d’une société privée ou d’une organisation internationale. C’est souvent l’un des champs les plus utiles pour comprendre pourquoi l’avion figure dans la base.
- Type : Description lisible du modèle d’appareil. Il peut indiquer, par exemple, un Airbus A330, un Falcon, un hélicoptère ou un avion de transport. Cette information est destinée à une lecture humaine.
- ICAO Type : Type de l’appareil inscrit sous forme normalisée selon la codification aéronautique ICAO, par exemple A332, FA50, C130 ou B738. Il est plus court que le champ Type et plus pratique pour les recherches ou les tris automatiques.
- CMPG : Famille générale de classement utilisée par plane-alert. On y rencontre notamment des valeurs liées aux grandes familles civil, militaire, gouvernemental ou police. Il permet de comprendre rapidement dans quel grand groupe l’appareil a été rangé.
- Tag1 : Indication complémentaire sur le rôle, la nature ou l’intérêt particulier de l’appareil. Il peut préciser une mission, une appartenance, une spécialité ou un élément distinctif.
- Tag2 : En complément du précédent, il permet d’ajouter une autre information courte telle qu’une catégorie secondaire ou une remarque caractéristique.
- Tag3 : Second complément descriptif. Les tags ne sont pas toujours normalisés ; certains sont très explicites, d’autres plus humoristiques ou propres à la culture du projet plane-alert. Ces 3 champs sont des étiquettes communautaires, utiles pour enrichir l’affichage, mais sans aucun lien avec une classification officielle. Sur le site https://github.com/sdr-enthusiasts/plane-alert-db il est d’ailleurs précisé que les noms de catégories — et les tags — viennent d’un sens de l’humour assez personnel de l’auteur : The category names (and tags) come from my rather idiosyncratic sense of humour.
- Category : Catégorie thématique de l’appareil dans la base plane-alert. Les catégories regroupent des appareils ayant un intérêt commun : forces aériennes, gouvernements, forces de police, avions de lutte contre les incendies, appareils historiques, avions de personnalités, organisations internationales, etc.
- Link : Lien Internet permettant de retrouver certains détails sur l’appareil. Il ne s’agit généralement pas d’une source officielle, mais d’un point de départ utile pour comprendre l’origine du classement.
Pour nos besoins, nous n’utiliserons généralement que trois de ces bases : gov, mil et pol. Elles correspondent le mieux aux appareils que nous cherchons à repérer. La base civ restera cependant connue du programme. Elle pourra être activée, sur demande, si nous souhaitons élargir volontairement le champ de recherche.
Ces fichiers téléchargeables évoluent dans le temps. Rapatriés sur notre ordinateur, le script que nous allons développer ici vérifiera si une version plus récente est disponible et procèdera si nécessaire à sa mise à jour. Il utilisera le fichier technique de contrôle plane-alert-db_meta.json qui mémorise l’état des fichiers plane-alert utilisés — taille, date de modification, empreinte du contenu, version du script, date de création — afin de savoir au lancement suivant si la base enrichie peut être réutilisée ou si elle doit être reconstruite.

Contenu du fichier plane-alert_meta.json
5.3. Bases enrichies créées par le programme
Afin de mener des recherches fiables, le seul appel des bases plane-alert ne suffit pas. Notre script va donc ajouter une série de champs destinés à faciliter les recherches par rattachement. Ces champs supplémentaires (fichier plane-alert-index-enrichi.csv ou plane-alert-index-enrichi-avec-civ.csv) ne remplaceront pas les informations d’origine mais constitueront une couche d’interprétation locale, construite à partir de fichiers de référence que nous présenterons dans le prochain paragraphe.
Ici aussi, chaque ligne correspond à un appareil. Les premiers champs sont des copies des champs d’origine plane-alert : icao, registration, operator, type, icao_type, cmpg, tag1, tag2, tag3, category, link.
Les 4 champs suivants (image1, image2, image3, image4) sont réservés à une évolution future. Ils concernent des références visuelles de l’appareil contenues dans les versions plane-alert avec images. Viennent ensuite :
- rattachement_principal : Rattachement retenu par le programme. Il peut s’agir d’un pays, comme France ou Royaume-Uni, mais aussi d’une organisation, d’une agence ou d’un regroupement particulier, comme OTAN, ONU ou Croix-Rouge. C’est le champ principal utilisé pour les recherches par rattachement.
- type_rattachement : Précision sur le rattachement retenu. Il permet de distinguer, par exemple, un rattachement de type pays, organisation_internationale, agence, gouvernement, mission ou indéterminé (indetermine). Il évite de mélanger artificiellement des pays et des organismes internationaux dans une même logique.
- zone_rattachement : Grande zone géographique ou logique associée au rattachement. On peut y trouver, par exemple, Europe, Afrique, Moyen-Orient, Amérique du Nord, Asie, Océanie, Pays non classés ou Autre. Il sert notamment à proposer un premier niveau de choix dans le menu de recherche.
- pays_probable : Pays supposé lorsque le programme peut en identifier un. Il reste vide lorsque le rattachement principal correspond plutôt à une organisation, une agence ou une mission sans pays unique clairement pertinent.
- organisation_probable : Organisation ou organisme supposé lorsque l’appareil semble relever d’un rattachement non strictement national. Il peut également préciser un organisme gouvernemental, par exemple Gouvernement Togo, lorsque cette information est plus parlante que le seul pays.
- operateur_normalise : Nom de l’opérateur ou du propriétaire sous une forme destinée à faciliter les recherches et les comparaisons. Dans l’état actuel, il reste souvent très proche du champ operator, mais il pourra servir plus tard à harmoniser certaines écritures différentes d’un même organisme.
- confiance_rattachement : Niveau de confiance accordé au rattachement retenu. Il peut prendre des valeurs comme fort, moyen, faible ou indéterminé. Un rattachement est plus solide lorsque plusieurs indices convergent, par exemple une immatriculation, un opérateur et un mot-clé cohérents.
- raison_rattachement : Ce champ explique pourquoi le programme a retenu ce rattachement. Il peut mentionner, par exemple, un préfixe d’immatriculation, un mot-clé trouvé dans l’opérateur, une règle de secours ou une correspondance issue d’un fichier de référence. C’est un champ important pour comprendre et contrôler le classement automatique.
- Sources : Ce champ indique le ou les fichiers plane-alert d’origine dans lesquels l’appareil a été trouvé. Il permet de savoir si l’information provient, par exemple, de la famille GOV, MIL, POL ou CIV. Lorsqu’un même appareil apparaît dans plusieurs fichiers, ce champ peut regrouper plusieurs sources.


Structure du fichier enrichi plane-alert-index-enrichi.csv
Là aussi un fichier json – plane-alert-index-enrichi.csv.meta.json
– permet de vérifier si l’index enrichi est toujours à jour. Il mémorise l’état des fichiers plane-alert utilisés, mais aussi la version du script ayant servi à créer l’index. Le champ version_script permet ainsi de savoir si l’index enrichi a été créé avec la même version que celle du script actuellement lancé. En cas d’incohérence, les fichiers enrichis sont reconstruits.

Contenu du fichier plane-alert-index-enrichi.csv.meta.json
5.4. Les fichiers d’aide à l’enrichissement
L’alimentation des fichiers enrichis s’appuie sur des fichiers CSV qui décrivent des mots-clés, des préfixes d’immatriculation ou des rattachements particuliers.
Le fichier mots_cles_pays.csv sert à guider la création de l’index enrichi. Le programme y trouve des correspondances permettant de reconnaître qu’un libellé rencontré dans les bases plane-alert se rattache probablement à un pays donné. Par exemple, une expression comme French Air Force, Royal Australian Air Force ou Gov of Togo peut ainsi être rapprochée de la France, de l’Australie ou du Togo.

Contenu du fichier mots_cles_pays.csv
Le fichier se compose de 6 colonnes :
- pays : il indique le pays auquel le mot-clé doit être rattaché.
- zone : il précise la grande zone géographique associée au pays : Europe, Afrique, Moyen-Orient, Amérique du Nord, Asie, Océanie, etc. Cette information alimente le champ type_rattachement de l’index enrichi. Elle permettra au script de proposer un premier choix par continent ou grande région avant de descendre vers les pays disponibles.
- mot_cle : il contient le mot ou l’expression à rechercher dans les informations issues des bases plane-alert, notamment l’opérateur, la catégorie, les tags ou les libellés associés à l’appareil. Il peut s’agir d’un nom de pays, d’une traduction anglaise, d’une armée, d’une agence ou d’une formulation couramment rencontrée dans les bases.
- poids : il donne l’importance accordée au mot-clé. Plus le poids est élevé, plus la correspondance est considérée comme significative.
- actif : il indique si la ligne doit être utilisée par le programme. Une valeur comme oui signifie que le mot-clé est pris en compte. Cela permet de désactiver temporairement une correspondance douteuse sans la supprimer du fichier.
- commentaire : Il sert à expliquer l’origine ou l’utilité de la ligne. Il peut préciser qu’il s’agit d’une traduction, d’un libellé sans accent, d’une forme anglaise, d’un opérateur militaire, d’une force de police ou d’une correction ajoutée après observation d’un mauvais classement.
Le fichier rattachements_reference.csv sert, lui aussi, à guider la création de l’index enrichi. Le programme y trouve des correspondances permettant de reconnaître qu’un libellé rencontré dans les bases plane-alert se rattache probablement à des organisations, agences ou missions particulières.

Contenu du fichier rattachements_reference.csv
Il se compose, lui-aussi, de 6 colonnes :
- rattachement_principal : il indique le rattachement à retenir lorsque le mot-clé est trouvé. Il peut s’agir d’une organisation, d’une agence, d’une mission ou d’un regroupement particulier. Par exemple : OTAN, ONU, Croix-Rouge, Frontex ou tout autre libellé que l’on souhaite voir apparaître dans l’index enrichi.
- type_rattachement : il précise la nature du rattachement. Il permet de distinguer une organisation internationale, une agence européenne, une mission humanitaire, un organisme gouvernemental ou tout autre type de regroupement. Cette information alimente le champ type_rattachement de l’index enrichi. Elle permettra au script de proposer un premier choix d’organismes avant de descendre vers les pays disponibles.
- mot_cle : il contient le mot ou l’expression à rechercher dans les informations issues des bases plane-alert. Le programme peut le retrouver dans l’opérateur, la catégorie, les tags ou les autres libellés associés à l’appareil. Par exemple, un mot-clé comme NATO pourra conduire au rattachement principal OTAN.
- confiance : il indique le niveau de confiance associé à la règle. Il peut prendre des valeurs comme fort, moyen ou faible. Une règle très explicite, comme NATO vers OTAN, pourra recevoir une confiance forte. Une règle plus générale ou plus ambiguë pourra recevoir une confiance moyenne ou faible.
- actif : il indique si la ligne doit être utilisée par le programme. Une valeur comme oui permet de prendre la règle en compte. Une valeur comme non permet de conserver une ligne en réserve, sans l’utiliser lors de la création de l’index enrichi.
- commentaire : il sert à expliquer le rôle de la ligne, son origine ou la raison de son ajout. Il peut préciser qu’il s’agit d’une organisation internationale, d’une correction manuelle, d’un libellé observé dans les bases plane-alert ou d’une règle ajoutée après un mauvais classement.
Le fichier prefixes_immatriculation_pays.csv sert également à la création de l’index enrichi. Il permet au programme de reconnaître qu’une immatriculation commençant par F-, G-, D-, N, EC- ou OO- correspond probablement à un registre national donné. Cette information constitue un indice utile de rattachement, même si elle ne suffit pas toujours à déterminer l’utilisateur réel de l’appareil.

Contenu du fichier prefixes_immatriculation_pays.csv
Ses six colonnes sont :
- pays : il indique le pays associé au préfixe d’immatriculation. Par exemple, F- est associé à la France, G- au Royaume-Uni, D- à l’Allemagne ou OO- à la Belgique. Il s’agit surtout du pays du registre d’immatriculation.
- zone : il précise la zone géographique associée au pays : Europe, Afrique, Moyen-Orient, Amérique du Nord, Asie, Océanie, etc. Il permet ensuite au script de proposer une recherche par continent ou grande région.
- prefixe : il contient le début d’immatriculation à reconnaître. Le programme le compare avec l’immatriculation de l’appareil. Par exemple, une immatriculation commençant par F- pourra être associée à la France, tandis qu’une immatriculation commençant par EC- sera rapprochée de l’Espagne.
- poids : il indique l’importance accordée à cette correspondance. Un préfixe d’immatriculation est un bon indice, mais il peut être moins déterminant qu’un opérateur très explicite. Par exemple, un appareil immatriculé en France mais exploité par un gouvernement étranger ne doit pas forcément être rattaché à la France.
- actif : il indique si la ligne doit être utilisée par le programme. Une valeur comme oui active la règle. Une valeur comme non permet de conserver une ligne dans le fichier sans l’utiliser lors de la création de l’index enrichi.
- commentaire : sert à expliquer la règle ou à rappeler son usage. Il peut indiquer qu’il s’agit d’immatriculations françaises, britanniques, allemandes, américaines, etc., ou préciser une particularité utile pour de futures corrections.
5.5. Présentation du script
Le code complet de ce script [17-Interroger_Plane_Alert.py // Test_Api_17t.py] peut être téléchargé à partir du lien placé en fin de document (fichier Test_Api.zip). En voici les principaux éléments expliqués. Ces blocs sont des extraits du script complet : ils peuvent appeler des constantes ou fonctions définies ailleurs dans le fichier principal.
Code partiel 1 — Les paramètres généraux et les noms de fichiers
Le script commence par regrouper ses réglages principaux : dossiers de travail, noms des fichiers produits ou relus, adresse de l’API externe, User-Agent utilisé pour les requêtes et adresses possibles de la station ADS-B. Cette organisation évite d’éparpiller les paramètres dans tout le programme ; le lecteur comprend immédiatement où modifier une version, un chemin ou une adresse réseau sans fouiller dans les fonctions internes.
</> Python
VERSION = "17t"
DOSSIER_DB = "Fichiers_db"
DOSSIER_CSV = "Fichiers_CSV"
DOSSIER_TEMPO = "Fichiers_tempo"
DOSSIER_RESULTATS = "Fichiers_resultats"
DOSSIER_PRIO = "Fichiers_F80543"
FICHIER_INDEX_ENRICHI = "plane-alert-index-enrichi.csv"
FICHIER_META_INDEX = "plane-alert-index-enrichi.csv.meta.json"
FICHIER_INDEX_ENRICHI_AVEC_CIV = "plane-alert-index-enrichi-avec-civ.csv"
FICHIER_META_INDEX_AVEC_CIV = "plane-alert-index-enrichi-avec-civ.csv.meta.json"
FAMILLES_PLANE_ALERT_DEFAUT = ["gov", "mil", "pol"]
FAMILLES_PLANE_ALERT_AVEC_CIV = ["gov", "mil", "pol", "civ"]
FICHIER_PRIO_DTB = "PRIO_DTB.csv"
FICHIER_MOTS_CLES_PAYS = "mots_cles_pays.csv"
FICHIER_PREFIXES_IMMATRICULATION = "prefixes_immatriculation_pays.csv"
FICHIER_RATTACHEMENTS_REFERENCE = "rattachements_reference.csv"
FICHIER_COORDONNEES_PAYS = "coordonnees_reference_pays.csv"
FICHIER_VILLES_REFERENCE = "villes_reference.csv"
FICHIER_ZONES_MARITIMES_REFERENCE = "zones_maritimes_reference.csv"
FICHIER_POSITIONS_A_COMPLETER = "positions_a_completer.csv"
API_BASE = "https://api.airplanes.live/v2"
URL_ADSBEXCHANGE = "https://globe.adsbexchange.com/?icao="
TIMEOUT_API_SECONDES = 10
PAUSE_ENTRE_APPELS_SECONDES = 2
USER_AGENT = "F80543-HamAnalyst-API-Tests/17t"
# Station ADS-B locale, avec secours ZeroTier comme dans les scripts précédents.
IP_STATION_LOCALE = "192.168.1.20"
IP_STATION_ZEROTIER = "10.XXX.YYY.TTT"
PORT_STATION = 8080
CHEMIN_AIRCRAFT_JSON = "/data/aircraft.json"
TIMEOUT_STATION_SECONDES = 3
# Position de référence de la station locale.
STATION_NOM = "station locale"
STATION_LAT = 44.0793
STATION_LON = -1.2585
SEUIL_AFFINAGE = 25
AFFICHAGE_CANDIDATS_MAX = 25
PROFONDEUR_SAUVEGARDE = 5Code partiel 2 — Le choix du mode de recherche
Au lancement, l’utilisateur n’est pas enfermé dans un seul scénario. Le menu permet soit de naviguer par rattachement, soit de rechercher un motif libre, soit d’interroger directement la station locale ou distante pour retenir les cinq avions les plus proches. Le même outil sert donc à explorer la base, à préparer une liste de surveillance ou à examiner le trafic réellement reçu par la station.
</> Python
def choisir_mode_recherche() -> str:
print("\nMode de recherche :")
print(" 1 - Recherche par rattachement")
print(" 2 - Recherche par Motif / Mot-Clé")
print(" 3 - Interroger ma station et retenir les 5 avions les plus proches")
while True:
choix = input("Votre choix [1] : ").strip()
if not choix:
return "rattachement"
if choix == "1":
return "rattachement"
if choix == "2":
return "motif"
if choix == "3":
return "station"
print("Choix incorrect. Tapez 1, 2 ou 3.")
Lancement du programme (nous étions alors en version 17p)
Code partiel 3 — Le choix des bases plane-alert utilisées
Pour ne pas noyer l’utilisateur dans une base trop large, le programme ne charge pas automatiquement tous les fichiers plane-alert disponibles. Il retient d’abord les familles GOV, MIL et POL, puis propose d’ajouter CIV si l’on souhaite élargir la recherche aux appareils civils recensés dans la base. Le choix reste donc ciblé par défaut, tout en laissant une ouverture lorsque le besoin s’en présente.
</> Python
def trouver_fichiers_plane_alert(dossier_db: Path, inclure_civ: bool = False) -> List[Path]:
"""
Retourne uniquement les fichiers plane-alert correspondant aux familles retenues.
Par défaut, on conserve les familles GOV, MIL et POL. La famille CIV n'est
ajoutée que si l'utilisateur le demande explicitement au lancement.
On évite volontairement plane-alert-db.csv ici : c'est la base générale,
beaucoup plus large. L'objectif de ce script est de travailler sur les
familles thématiques choisies.
"""
familles = FAMILLES_PLANE_ALERT_AVEC_CIV if inclure_civ else FAMILLES_PLANE_ALERT_DEFAUT
fichiers: List[Path] = []
# Recherche insensible à la casse, pour tolérer d'éventuelles différences de nommage.
par_nom = {chemin.name.lower(): chemin for chemin in dossier_db.glob("plane-alert*.csv") if chemin.is_file()}
for famille in familles:
nom_attendu = f"plane-alert-{famille}.csv"
chemin = par_nom.get(nom_attendu)
if chemin is not None:
fichiers.append(chemin)
else:
print(f"Attention : fichier absent pour la famille {famille.upper()} : {nom_attendu}")
return fichiers
def demander_inclusion_civ() -> bool:
"""
Demande si la famille CIV doit être ajoutée à GOV/MIL/POL.
Le choix par défaut reste volontairement limité à GOV, MIL et POL,
afin de ne pas élargir inutilement les résultats.
"""
print("\nFamilles plane-alert utilisées pour l'index enrichi :")
print(" Par défaut : GOV, MIL, POL")
print(" Option : ajouter CIV")
while True:
choix = input("Ajouter aussi la famille CIV ? [o/N] : ").strip().lower()
if choix in {"", "n", "non", "no"}:
print("Familles retenues : GOV, MIL, POL")
return False
if choix in {"o", "oui", "y", "yes"}:
print("Familles retenues : GOV, MIL, POL, CIV")
return TrueCode partiel 4 — La lecture robuste des fichiers CSV
Avant d’exploiter les fichiers locaux, le programme doit accepter des CSV parfois retouchés à la main. Cette partie détecte le séparateur utilisé, tolère plusieurs encodages courants et normalise les noms de colonnes afin qu’une variante comme « reg », « hex » ou « aircraft type » soit ramenée vers un nom attendu par le script. On limite ainsi les erreurs liées à l’ouverture des fichiers dans un tableur.
</> Python
def normaliser_nom_colonne(valeur: object) -> str:
texte = normaliser_texte(valeur)
remplacements = {
"icao hex": "icao",
"hex": "icao",
"icao": "icao",
"reg": "registration",
"reg num": "registration",
"regnum": "registration",
"registration": "registration",
"operator": "operator",
"owner": "operator",
"icao type": "icao_type",
"aircraft type": "type",
"tag 1": "tag1",
"tag 2": "tag2",
"tag 3": "tag3",
"mot cle": "mot_cle",
"motcle": "mot_cle",
"keyword": "mot_cle",
"prefix": "prefixe",
"prefixe immatriculation": "prefixe",
"rattachement principal": "rattachement_principal",
"type rattachement": "type_rattachement",
"zone rattachement": "zone_rattachement",
"zone": "zone",
"pays probable": "pays_probable",
"organisation probable": "organisation_probable",
"operateur normalise": "operateur_normalise",
"confiance rattachement": "confiance_rattachement",
"raison rattachement": "raison_rattachement",
}
if texte in remplacements:
return remplacements[texte]
return texte.replace(" ", "_")
def detecter_dialecte_csv(chemin: Path) -> csv.Dialect:
try:
with chemin.open("r", encoding="utf-8-sig", newline="") as f:
extrait = f.read(4096)
return csv.Sniffer().sniff(extrait, delimiters=",;\t")
except Exception:
return csv.excel
def lire_csv_dict(chemin: Path) -> List[Dict[str, str]]:
if not chemin.exists():
return []
for encodage in ("utf-8-sig", "latin-1"):
try:
dialecte = detecter_dialecte_csv(chemin)
with chemin.open("r", encoding=encodage, newline="") as f:
lecteur = csv.DictReader(f, dialect=dialecte)
if not lecteur.fieldnames:
return []
lecteur.fieldnames = [normaliser_nom_colonne(c) for c in lecteur.fieldnames]
return [{k: (v or "").strip() for k, v in row.items() if k is not None} for row in lecteur]
except UnicodeDecodeError:
continue
except Exception as erreur:
print(f"Erreur de lecture {chemin.name} : {erreur}")
return []
return []Code partiel 5 — La création ou la réutilisation de l’index enrichi
La base enrichie n’est pas reconstruite aveuglément à chaque lancement. Le script compare la version utilisée et les empreintes des fichiers sources ; si rien n’a changé, il relit l’index existant. Dans le cas contraire, il sauvegarde l’ancienne base, reconstruit l’index et affiche désormais des points de progression, ce qui rend l’attente beaucoup plus lisible lorsque le traitement dure plusieurs secondes.
</> Python
def pas_progression(nombre_total: int, points_voulus: int = 40) -> int:
"""Calcule la fréquence d'affichage des points de progression."""
if nombre_total <= 0:
return 1
return max(1, math.ceil(nombre_total / points_voulus))
def afficher_point_progression(
compteur: int,
nombre_total: int,
points_voulus: int = 40,
) -> None:
"""Affiche un point de progression, sans ralentir fortement le traitement."""
if nombre_total <= 0:
return
pas = pas_progression(nombre_total, points_voulus)
if compteur == nombre_total or compteur % pas == 0:
print(".", end="", flush=True)
def meta_a_jour(meta: Optional[Dict[str, object]], empreintes: Dict[str, Dict[str, object]]) -> bool:
if not meta:
return False
# On force la régénération si le schéma ou la version du script change.
if meta.get("version_script") != VERSION:
return False
return meta.get("empreintes") == empreintes
def creer_index_enrichi(
dossier_db: Path,
dossier_csv: Path,
fichiers_plane_alert: List[Path],
chemin_index: Path,
chemin_meta: Path,
empreintes: Dict[str, Dict[str, object]],
) -> List[Dict[str, str]]:
if chemin_index.exists():
print("Base enrichie obsolète : sauvegarde de l'ancienne version.")
rotation_sauvegardes(chemin_index)
if chemin_meta.exists():
rotation_sauvegardes(chemin_meta)
print("Création de la base enrichie locale...")
lignes_brutes: List[Dict[str, str]] = []
details_lecture: List[Tuple[str, int]] = []
print(" Lecture des fichiers plane-alert : ", end="", flush=True)
for fichier in fichiers_plane_alert:
lignes = lire_plane_alert(fichier)
print(".", end="", flush=True)
details_lecture.append((fichier.name, len(lignes)))
lignes_brutes.extend(lignes)
print(" terminé")
for nom_fichier, nombre_lignes in details_lecture:
print(f" {nom_fichier} : {nombre_lignes} ligne(s) lue(s)")
print(" Fusion des doublons : ", end="", flush=True)
lignes_uniques = fusionner_doublons(lignes_brutes)
print(f". terminé ({len(lignes_uniques)} ligne(s) unique(s))")
print(" Chargement des fichiers de référence : ", end="", flush=True)
mots_cles_pays = charger_mots_cles_pays(dossier_csv / FICHIER_MOTS_CLES_PAYS)
print(".", end="", flush=True)
prefixes_pays = charger_prefixes_pays(dossier_csv / FICHIER_PREFIXES_IMMATRICULATION)
print(".", end="", flush=True)
rattachements_reference = charger_rattachements_reference(dossier_csv / FICHIER_RATTACHEMENTS_REFERENCE)
print(". terminé")
print(" Enrichissement des lignes : ", end="", flush=True)
lignes_enrichies: List[Dict[str, str]] = []
total_lignes = len(lignes_uniques)
for numero, ligne in enumerate(lignes_uniques, start=1):
lignes_enrichies.append(
enrichir_ligne(ligne, mots_cles_pays, prefixes_pays, rattachements_reference)
)
afficher_point_progression(numero, total_lignes)
print(f" terminé ({len(lignes_enrichies)} appareil(s))")
chemin_index.parent.mkdir(parents=True, exist_ok=True)
print(" Écriture de la base enrichie : ", end="", flush=True)
with chemin_index.open("w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=COLONNES_ENRICHIES, delimiter=";")
writer.writeheader()
for numero, ligne in enumerate(lignes_enrichies, start=1):
writer.writerow({col: ligne.get(col, "") for col in COLONNES_ENRICHIES})
afficher_point_progression(numero, len(lignes_enrichies))
print(" terminé")
meta = {
"version_script": VERSION,
"date_creation_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"familles_plane_alert": FAMILLES_PLANE_ALERT_AVEC_CIV if "civ" in [fp.stem.lower().replace("plane-alert-", "") for fp in fichiers_plane_alert] else FAMILLES_PLANE_ALERT_DEFAUT,
"empreintes": empreintes,
"nombre_lignes": len(lignes_enrichies),
}
print(" Écriture des métadonnées : ", end="", flush=True)
with chemin_meta.open("w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
print(". terminé")
Lancement du programme dans sa version 17p avec création de la base enrichie
Code partiel 6 — La fusion des doublons
Un même appareil peut apparaître dans plusieurs familles plane-alert. Plutôt que de le présenter plusieurs fois, le script rapproche les lignes par code ICAO, ou à défaut par immatriculation et opérateur, puis fusionne les informations disponibles dans une seule entrée enrichie. La colonne sources conserve la mémoire des fichiers d’origine, afin de ne pas confondre doublon technique et avion différent.
</> Python
def fusionner_doublons(lignes: Iterable[Dict[str, str]]) -> List[Dict[str, str]]:
resultat: Dict[str, Dict[str, str]] = {}
for ligne in lignes:
icao = normaliser_icao(champ(ligne, "icao"))
registration = normaliser_registration(champ(ligne, "registration"))
operator = champ(ligne, "operator").upper()
cle = icao or f"{registration}|{operator}"
if not cle.strip("|"):
continue
if cle not in resultat:
copie = {col: champ(ligne, col) for col in COLONNES_PLANE_ALERT}
copie["sources"] = ligne.get("_source", "")
resultat[cle] = copie
continue
existant = resultat[cle]
source = ligne.get("_source", "")
sources = [s for s in existant.get("sources", "").split(", ") if s]
if source and source not in sources:
sources.append(source)
existant["sources"] = ", ".join(sources)
for col in COLONNES_PLANE_ALERT:
valeur = champ(ligne, col)
if not existant.get(col) and valeur:
existant[col] = valeur
return list(resultat.values())Code partiel 7 — L’enrichissement par mots-clés, préfixes et rattachements
L’intérêt de l’index enrichi est d’ajouter du sens aux lignes plane-alert d’origine. Le programme cherche d’abord les rattachements particuliers déclarés dans le fichier de référence, puis tente une reconnaissance par mots-clés de pays ou par préfixes d’immatriculation. Quelques règles de secours évitent enfin de classer trop vite des organisations comme l’OTAN, l’ONU ou Frontex comme de simples pays.
</> Python
def charger_mots_cles_pays(chemin: Path) -> Dict[str, List[CriterePays]]:
criteres: Dict[str, List[CriterePays]] = {}
for ligne in lire_csv_dict(chemin):
if not valeur_active(ligne.get("actif", "oui")):
continue
pays = (ligne.get("pays") or "").strip()
valeur = (ligne.get("mot_cle") or ligne.get("valeur") or ligne.get("motif") or "").strip()
commentaire = (ligne.get("commentaire") or "").strip()
zone = (ligne.get("zone") or "").strip()
if pays and valeur:
criteres.setdefault(pays, []).append(CriterePays(pays, valeur, commentaire, zone))
return criteres
def charger_prefixes_pays(chemin: Path) -> Dict[str, List[CriterePays]]:
criteres: Dict[str, List[CriterePays]] = {}
for ligne in lire_csv_dict(chemin):
if not valeur_active(ligne.get("actif", "oui")):
continue
pays = (ligne.get("pays") or "").strip()
valeur = (ligne.get("prefixe") or ligne.get("valeur") or ligne.get("prefix") or "").strip()
commentaire = (ligne.get("commentaire") or "").strip()
zone = (ligne.get("zone") or "").strip()
if pays and valeur:
criteres.setdefault(pays, []).append(CriterePays(pays, valeur, commentaire, zone))
return criteres
def charger_rattachements_reference(chemin: Path) -> List[CritereRattachement]:
criteres: List[CritereRattachement] = []
for ligne in lire_csv_dict(chemin):
if not valeur_active(ligne.get("actif", "oui")):
continue
rattachement = (
ligne.get("rattachement_principal")
or ligne.get("rattachement")
or ligne.get("organisation")
or ""
).strip()
type_rattachement = (ligne.get("type_rattachement") or "organisation").strip()
valeur = (ligne.get("mot_cle") or ligne.get("valeur") or ligne.get("motif") or "").strip()
confiance = (ligne.get("confiance") or ligne.get("confiance_rattachement") or "moyen").strip()
commentaire = (ligne.get("commentaire") or "").strip()
if rattachement and valeur:
criteres.append(CritereRattachement(rattachement, type_rattachement, valeur, confiance, commentaire))
return criteres
def texte_contient_motcle(texte_normalise: str, valeur: str) -> bool:
motif = normaliser_texte(valeur)
if not motif:
return False
return motif in texte_normalise
def prefixe_match(registration: str, prefixe: str) -> bool:
reg = registration.strip().upper().replace(" ", "")
pref = prefixe.strip().upper().replace(" ", "")
if not reg or not pref:
return False
return reg.startswith(pref)
def enrichir_ligne(
ligne: Dict[str, str],
mots_cles_pays: Dict[str, List[CriterePays]],
prefixes_pays: Dict[str, List[CriterePays]],
rattachements_reference: List[CritereRattachement],
) -> Dict[str, str]:
enrichie = dict(ligne)
texte = texte_recherche_ligne(ligne)
texte_norm = normaliser_texte(texte)
registration = champ(ligne, "registration")
operator = champ(ligne, "operator")
raisons: List[str] = []
# 1. Organisations ou rattachements explicites.
for critere in rattachements_reference:
if texte_contient_motcle(texte_norm, critere.valeur):
enrichie.update({
"rattachement_principal": critere.rattachement,
"type_rattachement": critere.type_rattachement,
"zone_rattachement": "Autre",
"pays_probable": "" if "pays" not in normaliser_texte(critere.type_rattachement) else critere.rattachement,
"organisation_probable": critere.rattachement,
"operateur_normalise": operator,
"confiance_rattachement": critere.confiance or "moyen",
"raison_rattachement": f"mot-clé rattachement '{critere.valeur}'" + (f" - {critere.commentaire}" if critere.commentaire else ""),
})
return enrichie
for rattachement, type_rattachement, mots, confiance in RATTACHEMENTS_SECOURS:
for mot in mots:
if texte_contient_motcle(texte_norm, mot):
enrichie.update({
"rattachement_principal": rattachement,
"type_rattachement": type_rattachement,
"zone_rattachement": "Autre",</> Python
def zones_disponibles(index: List[Dict[str, str]]) -> List[str]:
zones = {zone_canonique(zone_ligne(ligne) or "Autre") for ligne in index}
zones = {z for z in zones if z}
ordre = [
"Europe", "Afrique du Nord", "Afrique", "Moyen-Orient",
"Amérique du Nord", "Amérique centrale", "Amérique du Sud",
"Asie", "Océanie", "Pays non classés", "Autre"
]
return sorted(zones, key=lambda z: (ordre.index(z) if z in ordre else 99, normaliser_texte(z)))
def choisir_zone(index: List[Dict[str, str]]) -> str:
zones = zones_disponibles(index)
if not zones:
return "Autre"
defaut = "Europe" if "Europe" in zones else zones[0]
print("\nZones disponibles :")
for numero, zone in enumerate(zones, start=1):
suffixe = " [défaut]" if normaliser_texte(zone) == normaliser_texte(defaut) else ""
print(f" {numero:2d} - {libelle_zone(zone)}{suffixe}")
while True:
choix = input(f"Votre choix [{libelle_zone(defaut)}] : ").strip()
if not choix:
return defaut
if choix.isdigit():
numero = int(choix)
if 1 <= numero <= len(zones):
return zones[numero - 1]
print("Numéro hors liste.")
continue
choix_norm = normaliser_texte(choix)
correspondances = [
z for z in zones
if choix_norm == normaliser_texte(z)
or choix_norm == normaliser_texte(libelle_zone(z))
or choix_norm in normaliser_texte(libelle_zone(z))
]
if len(correspondances) == 1:
return correspondances[0]
if len(correspondances) > 1:
print("Choix ambigu : " + ", ".join(libelle_zone(z) for z in correspondances))
else:
print("Choix non reconnu.")
def rattachements_disponibles(index: List[Dict[str, str]], zone: Optional[str] = None) -> List[str]:
valeurs = set()
zone_norm = normaliser_texte(zone) if zone is not None else ""
for ligne in index:
if zone is not None and normaliser_texte(zone_ligne(ligne)) != zone_norm:
continue
# La zone Autre doit rester réservée aux organisations et cas atypiques.
# Par sécurité, on écarte les lignes manifestement liées à un pays,
# même si elles proviennent d'un ancien index enrichi.
if zone_norm == "autre":
type_norm = normaliser_texte(ligne.get("type_rattachement"))
if type_norm == "pays" or (type_norm in {"gouvernement", "government"} and (ligne.get("pays_probable") or ligne.get("rattachement_principal"))):
continue
valeurs.add((ligne.get("rattachement_principal") or "Indéterminé").strip())
valeurs_liste = sorted(valeurs, key=normaliser_texte)
return sorted(valeurs_liste, key=lambda v: (normaliser_texte(v) == "indetermine", normaliser_texte(v)))
def choisir_rattachement_avec_retour(zone: str, valeurs: List[str], defaut: str) -> Optional[str]:
"""
Affiche les rattachements d'une zone et ajoute un choix explicite
permettant de revenir au menu des zones disponibles.
"""
print(f"\nRattachements disponibles pour {libelle_zone(zone)} :")
for index, valeur in enumerate(valeurs, start=1):
suffixe = " [défaut]" if defaut and normaliser_texte(valeur) == normaliser_texte(defaut) else ""
print(f" {index:2d} - {valeur}{suffixe}")
numero_retour = len(valeurs) + 1
print(f" {numero_retour:2d} - Retour au niveau précédent")
while True:
prompt = "Votre choix"
if defaut:
prompt += f" [{defaut}]"
prompt += " : "
choix = input(prompt).strip()
if not choix and defaut:
return defaut
if choix.isdigit():
numero = int(choix)
if 1 <= numero <= len(valeurs):
return valeurs[numero - 1]
if numero == numero_retour:
return None
print("Numéro hors liste.")
continue
choix_norm = normaliser_texte(choix)
if choix_norm in {"retour", "precedent", "retour precedent", "niveau precedent"}:
return None
exact = [v for v in valeurs if normaliser_texte(v) == choix_norm]

Choix par rattachement – Exemple : Iran
Code partiel 9 — La recherche par motif ou mot-clé
Pour les cas où l’on sait déjà ce que l’on cherche, le mode motif est plus direct. Il accepte une immatriculation complète ou partielle, un type d’avion, un opérateur, un tag ou encore un motif avec astérisque, par exemple F-RAF*, F-RAR*, NATO ou Canadair. Ce mode sert surtout à constituer rapidement une liste d’appareils à surveiller.
</> Python
def verifier_motif(motif: str) -> bool:
morceaux = [m.strip() for m in motif.split("+") if m.strip()]
if not morceaux:
return False
for morceau in morceaux:
if caracteres_significatifs(morceau) < 2:
return False
return True
def demander_motif_libre(numero: int, facultatif: bool = False) -> str:
while True:
suffixe = " facultatif" if facultatif else ""
valeur = input(f"Motif {numero}{suffixe} : ").strip()
if facultatif and not valeur:
return ""
if verifier_motif(valeur):
return valeur
print("Motif trop court ou invalide : indiquez au moins 2 caractères utiles par motif.")
print("Exemples : F-RAF* + F-RAR*, CANADAIR, French Air*, FA50")
def normaliser_pour_motif(valeur: object) -> str:
texte = str(valeur or "").strip().upper()
texte = unicodedata.normalize("NFKD", texte)
texte = "".join(c for c in texte if not unicodedata.combining(c))
return texte
def motif_simple_correspond(texte: str, motif: str) -> bool:
texte_norm = normaliser_pour_motif(texte)
motif_norm = normaliser_pour_motif(motif)
if not motif_norm:
return False
if "*" in motif_norm or "?" in motif_norm:
return fnmatch.fnmatch(texte_norm, motif_norm) or fnmatch.fnmatch(texte_norm.replace(" ", ""), motif_norm.replace(" ", ""))
return motif_norm in texte_norm
def valeurs_recherche_motif(ligne: Dict[str, str]) -> List[str]:
"""
Retourne les champs testés séparément pour les motifs.
C'est important pour les jokers : F-RAF* doit pouvoir correspondre
à l'immatriculation F-RAFA, même si l'ensemble de la ligne ne commence
évidemment pas par F-RAF.
"""
champs = [
"icao", "registration", "operator", "type", "icao_type", "cmpg",
"tag1", "tag2", "tag3", "category", "link",
"rattachement_principal", "type_rattachement", "zone_rattachement", "pays_probable",
"organisation_probable", "operateur_normalise", "raison_rattachement", "sources",
]
valeurs = [ligne.get(champ, "") for champ in champs]
valeurs.append(texte_recherche_ligne(ligne))
return [v for v in valeurs if str(v).strip()]
def groupe_motifs_correspond(ligne: Dict[str, str], motif_groupe: str) -> bool:
morceaux = [m.strip() for m in motif_groupe.split("+") if m.strip()]
valeurs = valeurs_recherche_motif(ligne)
for motif in morceaux:
if any(motif_simple_correspond(valeur, motif) for valeur in valeurs):
return True
return False
def recherche_par_motif(index: List[Dict[str, str]]) -> Tuple[str, List[Dict[str, str]]]:
print("\nRecherche par Motif / Mot-Clé")
print("Le signe + indique des choix alternatifs : F-RAF* + F-RAR*")
motif1 = demander_motif_libre(1, facultatif=False)
motif2 = demander_motif_libre(2, facultatif=True)
resultats = [ligne for ligne in index if groupe_motifs_correspond(ligne, motif1)]
libelle = motif1
if motif2:
resultats = [ligne for ligne in resultats if groupe_motifs_correspond(ligne, motif2)]
libelle = f"({motif1}) ET ({motif2})"
return libelle, resultats
Code partiel 10 — L’affinage des résultats
Lorsqu’une recherche retourne trop de lignes, le programme évite de lancer immédiatement une longue interrogation API. Il propose plutôt un filtre supplémentaire sur certains champs, comme le type, la catégorie, l’opérateur, le rattachement ou la source. Cette étape réduit les listes trop larges avant l’appel à Airplanes.live et limite les requêtes inutiles.
</> Python
def proposer_affinage(resultats: List[Dict[str, str]]) -> List[Dict[str, str]]:
while len(resultats) > SEUIL_AFFINAGE:
print(f"\nLa recherche retourne {len(resultats)} avions.")
choix = input("Souhaitez-vous affiner la recherche ? [O/n] : ").strip().lower()
if choix in {"n", "non", "no"}:
return resultats
dimensions = [
("type", "Type d'appareil"),
("icao_type", "Type ICAO"),
("category", "Catégorie plane-alert"),
("operator", "Opérateur / propriétaire"),
("rattachement_principal", "Rattachement principal"),
("type_rattachement", "Type de rattachement"),
("tag", "Tag1 / Tag2 / Tag3"),
("source", "Fichier source"),
("immat_prefixe", "Début d'immatriculation"),
]
while True:
print("\nCritère d'affinage :")
for index, (_, libelle) in enumerate(dimensions, start=1):
print(f" {index} - {libelle}")
print(" 0 - Ne pas affiner")
choix_dimension = input("Votre choix : ").strip()
if choix_dimension in {"", "0"}:
return resultats
if not choix_dimension.isdigit() or not (1 <= int(choix_dimension) <= len(dimensions)):
print("Choix incorrect.")
continue
champ_filtre, libelle_filtre = dimensions[int(choix_dimension) - 1]
valeurs = compter_valeurs(resultats, champ_filtre)
if not valeurs:
print("Aucune valeur disponible pour ce critère.")
continue
print(f"\nValeurs disponibles pour {libelle_filtre} :")
valeurs_affichees = valeurs[:30]
for index, (valeur, nombre) in enumerate(valeurs_affichees, start=1):
print(f" {index:2d} - {valeur} ({nombre})")
numero_retour = len(valeurs_affichees) + 1
print(" 0 - Annuler cet affinage")
print(f" {numero_retour:2d} - Retour au niveau précédent")
choix_valeur = input("Votre choix : ").strip()
if choix_valeur in {"", "0"}:
# Retour direct au sous-menu Critère d'affinage, sans repasser par la question générale.
continue
if choix_valeur.isdigit() and int(choix_valeur) == numero_retour:
continue
if not choix_valeur.isdigit() or not (1 <= int(choix_valeur) <= len(valeurs_affichees)):
print("Choix incorrect.")
continue
valeur_retenue = valeurs_affichees[int(choix_valeur) - 1][0]
valeur_norm = normaliser_texte(valeur_retenue)
if champ_filtre == "tag":
resultats = [
ligne for ligne in resultats
if valeur_norm in normaliser_texte(" ".join([ligne.get("tag1", ""), ligne.get("tag2", ""), ligne.get("tag3", "")]))
]
else:
resultats = [
ligne for ligne in resultats
if normaliser_texte(valeur_filtre(ligne, champ_filtre)) == valeur_norm
]
print(f"Affinage appliqué : {libelle_filtre} = {valeur_retenue}")
break


Choix par mot-clé avec affinage – Exemple : Airbus A400M, military (F-R*)
Code partiel 11 — La création du fichier PRIO_DTB.csv
Une fois les candidats définis, le programme les écrit automatiquement dans PRIO_DTB.csv afin qu’ils puissent être rappelés ultérieurement.
</> Python
def assurer_dossier_prio(base: Path) -> Path:
return assurer_dossier(base / DOSSIER_PRIO)
def ligne_vers_prio(ligne: Dict[str, str]) -> Dict[str, str]:
return {
"icao": normaliser_icao(ligne.get("icao", "")),
"registration": ligne.get("registration", ""),
"operator": ligne.get("operator", ""),
"type": ligne.get("type", ""),
"icao_type": ligne.get("icao_type", ""),
"category": ligne.get("category", ""),
"pays_europe": ligne.get("rattachement_principal", ""),
"tag1": ligne.get("tag1", ""),
"tag2": ligne.get("tag2", ""),
"tag3": ligne.get("tag3", ""),
"link": ligne.get("link", ""),
}
def sauvegarder_prio(base: Path, lignes: List[Dict[str, str]], chemin: Path, sauvegarder_precedent: bool) -> Path:
chemin.parent.mkdir(parents=True, exist_ok=True)
if sauvegarder_precedent and chemin.exists():
rotation_sauvegardes(chemin)
with chemin.open("w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=COLONNES_PRIO)
writer.writeheader()
for ligne in lignes:
writer.writerow(ligne_vers_prio(ligne))
return chemin
def sauvegarder_prio_dtb(base: Path, candidats: List[Dict[str, str]]) -> Path:
chemin = assurer_dossier_prio(base) / FICHIER_PRIO_DTB
return sauvegarder_prio(base, candidats, chemin, sauvegarder_precedent=False)
Le fichier PRIO_DTB.csv est généré automatiquement par le programme. À chaque nouveau lancement, il sera donc recréé et écrasera la version précédente. Si l’utilisateur souhaite conserver durablement cette liste, il doit l’enregistrer sous un autre nom commençant par PRIO_. Elle pourra alors être sélectionnée ultérieurement par les programmes capables de relire les fichiers prioritaires au format PRIO_*.csv.


Sauvegarde du fichier PRIO_FR_Presidentiel_2.csv
Code partiel 12 — L’interrogation directe de la station locale ou ZeroTier
Le troisième mode ne part pas d’une recherche dans plane-alert. Il interroge d’abord le fichier aircraft.json de la station locale, puis bascule vers l’adresse ZeroTier si la première ne répond pas. Les avions sont ensuite triés par distance afin de retenir les cinq plus proches, avant d’être enrichis lorsque leur code ICAO existe dans l’index local.
</> Python
def urls_station() -> List[str]:
return [
f"http://{IP_STATION_LOCALE}:{PORT_STATION}{CHEMIN_AIRCRAFT_JSON}",
f"http://{IP_STATION_ZEROTIER}:{PORT_STATION}{CHEMIN_AIRCRAFT_JSON}",
]
def lire_aircraft_station() -> Tuple[List[Dict[str, object]], str]:
"""
Lit aircraft.json en essayant d'abord l'adresse locale, puis ZeroTier.
La version 17r conserve la logique de bascule de 17p, mais évite
d'afficher le détail technique complet de l'exception réseau. Pour le
lecteur, l'information utile est simplement de savoir que l'adresse locale
n'a pas répondu assez vite, puis que le script tente l'adresse ZeroTier.
"""
headers = {"User-Agent": USER_AGENT}
url_locale = f"http://{IP_STATION_LOCALE}:{PORT_STATION}{CHEMIN_AIRCRAFT_JSON}"
url_zerotier = f"http://{IP_STATION_ZEROTIER}:{PORT_STATION}{CHEMIN_AIRCRAFT_JSON}"
print(f"Tentative station locale : {url_locale}")
try:
reponse = requests.get(url_locale, headers=headers, timeout=TIMEOUT_STATION_SECONDES)
if reponse.status_code == 200:
data = reponse.json()
avions = data.get("aircraft") if isinstance(data, dict) else None
if isinstance(avions, list):
return [a for a in avions if isinstance(a, dict)], url_locale
except (requests.RequestException, ValueError):
pass
print("Station locale inaccessible ou trop lente.")
print(f"Bascule vers ZeroTier : {url_zerotier}")
try:
reponse = requests.get(url_zerotier, headers=headers, timeout=TIMEOUT_STATION_SECONDES)
if reponse.status_code != 200:
print(f"ZeroTier inaccessible ou erreur HTTP {reponse.status_code}.")
return [], ""
data = reponse.json()
avions = data.get("aircraft") if isinstance(data, dict) else None
if isinstance(avions, list):
return [a for a in avions if isinstance(a, dict)], url_zerotier
print("Réponse ZeroTier reçue, mais sans liste aircraft exploitable.")
except requests.RequestException:
print("ZeroTier inaccessible ou trop lent.")
except ValueError:
print("Réponse ZeroTier JSON illisible.")
return [], ""
def distance_station_avion_km(avion: Dict[str, object]) -> Optional[float]:
r_dst = convertir_nombre(avion.get("r_dst"))
if r_dst is not None:
# r_dst est généralement fourni en milles nautiques dans aircraft.json.
return r_dst * 1.852
lat = convertir_nombre(avion.get("lat"))
lon = convertir_nombre(avion.get("lon"))
if lat is None or lon is None:
return None
return haversine_km(STATION_LAT, STATION_LON, lat, lon)
def construire_index_par_icao(index: List[Dict[str, str]]) -> Dict[str, Dict[str, str]]:
resultat: Dict[str, Dict[str, str]] = {}
for ligne in index:
icao = normaliser_icao(ligne.get("icao"))
if icao and icao not in resultat:
resultat[icao] = ligne
return resultat
def ligne_depuis_station(avion: Dict[str, object], index_par_icao: Dict[str, Dict[str, str]]) -> Dict[str, str]:
icao = normaliser_icao(avion.get("hex"))
if icao and icao in index_par_icao:
return index_par_icao[icao]
return {
"icao": icao,
"registration": valeur_non_vide(avion.get("r"), ""),
"operator": valeur_non_vide(avion.get("flight"), "Réception station locale"),
"type": valeur_non_vide(avion.get("desc"), avion.get("t"), ""),
"icao_type": valeur_non_vide(avion.get("t"), ""),
"cmpg": "",
"tag1": "",
"tag2": "",
"tag3": "",
"category": "Station locale",
"link": "",
"image1": "",
"image2": "",
"image3": "",
"image4": "",
"rattachement_principal": "Station locale",
"type_rattachement": "réception_locale",
"zone_rattachement": "Autre",
"pays_probable": "",
"organisation_probable": "",
"operateur_normalise": valeur_non_vide(avion.get("flight"), ""),
Lancement du programme dans sa version 17t avec choix d’interrogation de ma station

Les versions précédentes fonctionnaient, mais avec l’affichage d’une longue exception technique en cas d’une connexion impossible sur la station locale.
Dans ce cas, la mention « Confiance non applicable » signifie que l’appareil n’a pas été sélectionné à partir d’un rattachement calculé dans les bases plane-alert, mais directement depuis les données reçues par la station ADS-B ; il n’y a donc pas de niveau de confiance à attribuer.
Code partiel 13 — L’interrogation d’Airplanes.live
Après la sélection des candidats, le script vérifie leur présence dans l’API Airplanes.live. Il interroge d’abord par code ICAO, qui reste l’identifiant le plus fiable, puis utilise l’immatriculation lorsque le code n’est pas disponible ou ne retourne rien. On passe ainsi d’une liste théorique d’appareils à une vérification de présence dans les données extérieures.
</> Python
def url_api_par_icao(icao: str) -> str:
return f"{API_BASE}/hex/{icao.lower()}"
def url_api_par_registration(registration: str) -> str:
return f"{API_BASE}/reg/{registration}"
def extraire_liste_avions(reponse_json: Dict[str, object]) -> List[Dict[str, object]]:
avions = reponse_json.get("ac")
if isinstance(avions, list):
return [a for a in avions if isinstance(a, dict)]
avions = reponse_json.get("aircraft")
if isinstance(avions, list):
return [a for a in avions if isinstance(a, dict)]
return []
def interroger_api(url: str) -> List[Dict[str, object]]:
headers = {"User-Agent": USER_AGENT}
try:
reponse = requests.get(url, headers=headers, timeout=TIMEOUT_API_SECONDES)
if reponse.status_code != 200:
print(f" Code HTTP {reponse.status_code} pour {url}")
return []
contenu = reponse.json()
except requests.RequestException as erreur:
print(f" Erreur réseau : {erreur}")
return []
except ValueError:
print(" Réponse JSON illisible.")
return []
if not isinstance(contenu, dict):
return []
return extraire_liste_avions(contenu)
def interroger_appareil(ligne: Dict[str, str]) -> List[Dict[str, object]]:
icao = normaliser_icao(ligne.get("icao", ""))
registration = normaliser_registration(ligne.get("registration", ""))
resultats: List[Dict[str, object]] = []
if icao:
resultats = interroger_api(url_api_par_icao(icao))
if not resultats and registration:
resultats = interroger_api(url_api_par_registration(registration))
return resultats
def interroger_liste_appareils(candidats: List[Dict[str, str]]) -> List[Tuple[Dict[str, str], Dict[str, object]]]:
resultats: List[Tuple[Dict[str, str], Dict[str, object]]] = []
print("\nInterrogation Airplanes.live en cours...")
for index, ligne in enumerate(candidats, start=1):
identifiant = ligne.get("icao") or ligne.get("registration") or "?"
print(f" {index:>3}/{len(candidats)} - {identifiant}", end="", flush=True)
avions = interroger_appareil(ligne)
en_vol = [avion for avion in avions if avion_probablement_en_vol(avion)]
if en_vol:
print(f" : {len(en_vol)} appareil(s) probablement en vol")
for avion in en_vol:
resultats.append((ligne, avion))
else:
print(" : aucun appareil en vol trouvé")
time.sleep(PAUSE_ENTRE_APPELS_SECONDES)
return dedoublonner_avions(resultats)

Interrogation Airplanes.live après affinage. Dans cet exemple : A400M & Military (F-R*)
Code partiel 14 — Le critère “probablement en vol”
Toutes les réponses de l’API ne doivent pas être considérées comme des avions réellement visibles. Le programme écarte les appareils explicitement signalés au sol et ne retient que ceux pour lesquels une altitude, une vitesse ou une position exploitable permet de supposer une activité en cours. Cette précaution évite de transformer une simple fiche connue de l’API en détection réelle.
</> Python
def avion_probablement_en_vol(avion: Dict[str, object]) -> bool:
alt_baro = avion.get("alt_baro")
if isinstance(alt_baro, str) and alt_baro.strip().lower() == "ground":
return False
ground = avion.get("ground")
if ground is True:
return False
if isinstance(ground, str) and ground.strip().lower() in {"true", "1", "yes"}:
return False
altitude = convertir_nombre(alt_baro)
if altitude is None:
altitude = convertir_nombre(avion.get("alt_geom"))
vitesse = convertir_nombre(avion.get("gs"))
lat = convertir_nombre(avion.get("lat"))
lon = convertir_nombre(avion.get("lon"))
if altitude is not None and altitude > 0:
return True
if vitesse is not None and vitesse > 30:
return True
if lat is not None and lon is not None and (altitude is not None or vitesse is not None):
return True
return FalseCode partiel 15 — La localisation approximative
Pour rendre le tableau final plus parlant, le programme ne se contente pas d’afficher une latitude et une longitude. Il cherche d’abord une ville proche, puis une zone maritime, puis un point de référence pays, avant de revenir aux coordonnées brutes si aucune référence locale n’est suffisante. Le lecteur obtient ainsi une indication lisible sans dépendre systématiquement de Nominatim.
</> Python
def position_approximative(
lat: Optional[float],
lon: Optional[float],
base: Optional[Path] = None,
enregistrer_manques: bool = False,
) -> str:
if lat is None or lon is None:
return "position non renseignée"
ville_proche = trouver_ville_la_plus_proche(lat, lon)
if ville_proche is not None:
ville, distance = ville_proche
distance_max = float(ville.get("distance_max_km", 150.0))
if distance <= distance_max:
if distance < 10:
return f"près de {ville['nom']} ({ville['pays']})"
return f"à environ {distance:.0f} km de {ville['nom']} ({ville['pays']})"
zone_maritime = trouver_zone_maritime(lat, lon)
if zone_maritime is not None:
return formater_zone_maritime(zone_maritime)
pays_proche = trouver_pays_le_plus_proche(lat, lon)
if pays_proche is not None:
pays, distance = pays_proche
if distance <= 500:
if enregistrer_manques and base is not None:
enregistrer_position_a_completer(
base,
lat,
lon,
pays,
f"{lat:.5f}, {lon:.5f}",
"Aucune ville ou zone maritime exploitable, référence pays utilisée",
"Vérifier s'il faut ajouter une ville ou une zone maritime.",
)
return f"à environ {distance:.0f} km de la référence pays : {pays}"
if enregistrer_manques and base is not None:
enregistrer_position_a_completer(
base,
lat,
lon,
"",
f"{lat:.5f}, {lon:.5f}",
"Aucune référence locale exploitable",
"Vérifier s'il faut compléter les fichiers de référence.",
)
return f"lat {lat:.5f}, lon {lon:.5f}"Le programme ne se contente pas d’afficher directement le champ formulation du fichier des zones maritimes. Il passe d’abord par une fonction spécialisée, formater_zone_maritime(), repérable ci-dessus.
Cette fonction commence par récupérer la formulation prévue dans le fichier CSV. Si cette formulation est absente, elle utilise le nom de la zone maritime. Si rien n’est exploitable, elle renvoie une phrase de secours : “au-dessus d’une zone maritime référencée”.
Elle vérifie ensuite si la formulation commence déjà par une expression utilisable telle que “au-dessus”, “dans”, “près”, “sur” ou “en”. Dans ce cas, elle la conserve telle quelle. Cela permet par exemple de garder une formulation déjà correcte comme “au-dessus du golfe de Gascogne” ou “dans la Manche”.
En revanche, si la formulation se limite à un nom brut, par exemple “mer Adriatique” ou “golfe de Gascogne”, le programme ajoute automatiquement “au-dessus de”. L’affichage devient alors plus lisible pour le lecteur.
Cette procédure évite des formulations maladroites. Elle permet surtout de rendre le fichier CSV plus souple : certaines lignes peuvent contenir une phrase complète, d’autres seulement le nom de la zone. Le programme essaie ensuite de produire une phrase cohérente.
</> Python
def formater_zone_maritime(zone):
formulation = str(zone.get("formulation") or zone.get("nom_zone") or "").strip()
if not formulation:
return "au-dessus d'une zone maritime référencée"
debut = formulation.lower()
if debut.startswith(("au-dessus", "dans ", "près ", "sur ", "en ")):
return formulation
return f"au-dessus de {formulation}"
def position_approximative(lat, lon, base=None, enregistrer_manques=False):
if lat is None or lon is None:
return "position non renseignée"
ville_proche = trouver_ville_la_plus_proche(lat, lon)
if ville_proche is not None:
ville, distance = ville_proche
if distance <= 50:
return f"près de {ville['nom']}, {ville['pays']}"
zone_maritime = trouver_zone_maritime(lat, lon)
if zone_maritime is not None:
return formater_zone_maritime(zone_maritime)
# Suite de la fonction :
# ville plus éloignée, référence pays, puis latitude/longitude brute.La fonction position_approximative() est elle aussi enrichie. Elle conserve la même logique générale : chercher une ville proche, puis une zone maritime, puis une ville plus éloignée. Mais elle ajoute deux sécurités supplémentaires.
Dans le programme complet, cette fonction est un peu plus longue. Elle prévoit aussi un recours au fichier coordonnees_reference_pays.csv et, lorsque la position semble mal couverte par nos références locales, elle peut alimenter positions_a_completer.csv (voir paragraphe suivant). L’encart ci-dessus ne reprend donc que la partie la plus significative : le passage d’une formulation maritime brute à une phrase plus lisible.
Lorsque ni les villes ni les zones maritimes ne donnent une indication satisfaisante, le programme tente encore une dernière recherche à partir du fichier coordonnees_reference_pays.csv. Cette information reste volontairement prudente : elle n’affirme pas que l’avion se trouve au-dessus du pays, mais seulement qu’il est à une certaine distance du point de référence associé à ce pays.

Interrogation Airplanes.live après affinage – Dans cet exemple : A400M & Military (F-R*)
Code partiel 16 — L’alimentation de positions_a_completer.csv
Les positions mal reconnues ne sont pas perdues. Lorsque les fichiers géographiques locaux ne suffisent pas, le programme peut inscrire le point dans positions_a_completer.csv. Ce fichier devient alors une liste de travail pour améliorer plus tard les villes, pays ou zones maritimes de référence.
</> Python
def chemin_positions_a_completer(base: Path) -> Path:
return dossier_csv(base) / FICHIER_POSITIONS_A_COMPLETER
def assurer_fichier_positions_a_completer(base: Path) -> Path:
chemin = chemin_positions_a_completer(base)
chemin.parent.mkdir(parents=True, exist_ok=True)
if not chemin.exists() or chemin.stat().st_size == 0:
with chemin.open("w", newline="", encoding="utf-8-sig") as fichier:
writer = csv.DictWriter(fichier, fieldnames=COLONNES_POSITIONS_A_COMPLETER)
writer.writeheader()
return chemin
def cle_position_a_completer(lat: float, lon: float, raison: str) -> str:
return f"{round(lat, 2):.2f}|{round(lon, 2):.2f}|{raison.strip().lower()}"
def lire_cles_positions_a_completer(chemin: Path) -> set[str]:
cles: set[str] = set()
if not chemin.exists() or chemin.stat().st_size == 0:
return cles
try:
with chemin.open("r", newline="", encoding="utf-8-sig") as fichier:
reader = csv.DictReader(fichier)
for row in reader:
lat = convertir_nombre(row.get("lat"))
lon = convertir_nombre(row.get("lon"))
raison = str(row.get("raison") or "").strip()
if lat is None or lon is None:
continue
cles.add(cle_position_a_completer(lat, lon, raison))
except Exception:
return set()
return cles
def enregistrer_position_a_completer(
base: Path,
lat: Optional[float],
lon: Optional[float],
pays: str,
display_name: str,
raison: str,
suggestion: str = "",
) -> None:
if lat is None or lon is None:
return
chemin = assurer_fichier_positions_a_completer(base)
cle = cle_position_a_completer(lat, lon, raison)
if cle in lire_cles_positions_a_completer(chemin):
print(
f"Position déjà présente dans {chemin.name} : "
f"lat {lat:.5f}, lon {lon:.5f}"
)
return
ligne = {
"date_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"pays": pays,
"lat": f"{lat:.5f}",
"lon": f"{lon:.5f}",
"display_name": display_name,
"raison": raison,
"suggestion": suggestion,
}
try:
with chemin.open("a", newline="", encoding="utf-8-sig") as fichier:
writer = csv.DictWriter(fichier, fieldnames=COLONNES_POSITIONS_A_COMPLETER)
writer.writerow(ligne)
print(
f"Ajout dans {chemin.name} : "
f"lat {lat:.5f}, lon {lon:.5f} — {raison}"
)
except Exception as erreur:
print(f"Impossible d'écrire dans {chemin.name} : {erreur}")
Interrogation Airplanes.live après affinage – Dans cet exemple : AE08* & Type C17
Code partiel 17 — L’affichage final en tableau
La restitution finale est la partie la plus visible pour l’utilisateur. Pour conserver un tableau lisible malgré la quantité d’informations, chaque appareil est présenté sur trois lignes : identification, références, rattachement, localisation, type de donnée, altitude et distance. L’affichage reste compact, mais conserve les éléments utiles à l’identification.
</> Python
def ligne_tableau_resultat(numero: int, ligne: Dict[str, str], avion: Dict[str, object], base: Optional[Path] = None) -> Dict[str, object]:
icao = normaliser_icao(avion.get("hex") or ligne.get("icao")) or "?"
registration = valeur_non_vide(avion.get("r"), ligne.get("registration"), "?")
indicatif = str(avion.get("flight") or "").strip() or "?"
type_donnee = valeur_non_vide(avion.get("type"), "?")
lat = convertir_nombre(avion.get("lat"))
lon = convertir_nombre(avion.get("lon"))
localisation = position_approximative(lat, lon, base, False)
return {
"numero": numero,
"icao": icao,
"registration": registration,
"indicatif": indicatif,
"references_1": reference_ligne_1(ligne),
"references_2": reference_ligne_2(ligne, avion),
"localisation": f"Localisation : {localisation}",
"type_donnee": type_donnee,
"altitude": formater_altitude(avion),
"distance": formater_distance_station_km(avion),
}
def afficher_tableau_resultats(resultats: List[Tuple[Dict[str, str], Dict[str, object]]], base: Optional[Path] = None) -> None:
w = LARGEURS_TABLEAU
entete = (
f"{'N°':>{w['numero']}} "
f"{'Appareil':<{w['appareil']}} "
f"{'Références et localisation':<{w['references']}} "
f"{'Donnée':<{w['type_donnee']}} "
f"{'Altitude / Distance':<{w['altitude_distance']}}"
)
print()
print(entete)
print("-" * len(entete))
for numero, (ligne, avion) in enumerate(resultats, start=1):
tableau = ligne_tableau_resultat(numero, ligne, avion, base)
print(
f"{tableau['numero']:>{w['numero']}} "
f"{tronquer(tableau['icao'], w['appareil']):<{w['appareil']}} "
f"{tronquer(tableau['references_1'], w['references']):<{w['references']}} "
f"{tronquer(tableau['type_donnee'], w['type_donnee']):<{w['type_donnee']}} "
f"{tronquer(tableau['altitude'], w['altitude_distance']):<{w['altitude_distance']}}"
)
print(
f"{'':>{w['numero']}} "
f"{tronquer(tableau['registration'], w['appareil']):<{w['appareil']}} "
f"{tronquer(tableau['references_2'], w['references']):<{w['references']}} "
f"{'':<{w['type_donnee']}} "
f"{tronquer(tableau['distance'], w['altitude_distance']):<{w['altitude_distance']}}"
)
print(
f"{'':>{w['numero']}} "
f"{tronquer(tableau['indicatif'], w['appareil']):<{w['appareil']}} "
f"{tronquer(tableau['localisation'], w['references']):<{w['references']}} "
f"{'':<{w['type_donnee']}} "
f"{'':<{w['altitude_distance']}}"
)
print()
Exemple d’affichage final – Interrogation de ma station locale
Code partiel 18 — La sauvegarde finale en JSON
Lorsque des appareils sont effectivement trouvés, le programme sauvegarde un résultat structuré dans resultats_finaux.json. Ce fichier contient les données utiles à une relecture ultérieure : identification, type de donnée, altitude, vitesse, position approximative, rattachement et lien vers ADS-B Exchange. La sortie console devient donc réutilisable par un autre programme.
</> Python
def avion_vers_ligne_resultat(base: Path, ligne: Dict[str, str], avion: Dict[str, object]) -> Dict[str, object]:
icao = normaliser_icao(avion.get("hex") or ligne.get("icao"))
lat = convertir_nombre(avion.get("lat"))
lon = convertir_nombre(avion.get("lon"))
distance = ""
if lat is not None and lon is not None:
distance = f"{haversine_km(STATION_LAT, STATION_LON, lat, lon):.1f}"
return {
"icao": icao,
"registration": valeur_non_vide(avion.get("r"), ligne.get("registration")),
"flight": str(avion.get("flight") or "").strip(),
"type_donnee": avion.get("type", ""),
"type_appareil": valeur_non_vide(avion.get("t"), ligne.get("icao_type"), ligne.get("type")),
"altitude": formater_altitude(avion),
"vitesse": formater_vitesse(avion),
"lat": lat if lat is not None else "",
"lon": lon if lon is not None else "",
"distance_station_km": distance,
"position_approximative": position_approximative(lat, lon, base, False),
"rattachement_principal": ligne.get("rattachement_principal", ""),
"type_rattachement": ligne.get("type_rattachement", ""),
"zone_rattachement": ligne.get("zone_rattachement", ""),
"confiance_rattachement": ligne.get("confiance_rattachement", ""),
"raison_rattachement": ligne.get("raison_rattachement", ""),
"operator": ligne.get("operator", ""),
"category": ligne.get("category", ""),
"tag1": ligne.get("tag1", ""),
"tag2": ligne.get("tag2", ""),
"tag3": ligne.get("tag3", ""),
"sources": ligne.get("sources", ""),
"url_adsbexchange": url_carte_adsbexchange(icao) if icao else "",
}
def sauvegarder_resultats(base: Path, resultats: List[Tuple[Dict[str, str], Dict[str, object]]]) -> None:
"""
Sauvegarde uniquement le fichier JSON final.
La sauvegarde CSV intermédiaire resultats_test_api_17n.csv, présente dans la
version 17n, a été supprimée afin d'éviter le doublon avec le fichier PRIO_*
généré lors des recherches par mots-clés.
"""
dossier = assurer_dossier(base / DOSSIER_RESULTATS)
lignes = [avion_vers_ligne_resultat(base, ligne, avion) for ligne, avion in resultats]
chemin_json = dossier / "resultats_finaux.json"
with chemin_json.open("w", encoding="utf-8") as f:
json.dump(lignes, f, ensure_ascii=False, indent=2)
Code partiel 19 — L’ouverture sur ADS-B Exchange
Dernière étape facultative, le script peut prolonger l’analyse en ouvrant un appareil sur ADS-B Exchange. Il construit l’URL à partir du code ICAO hexadécimal, puis demande à l’utilisateur de choisir un seul avion afin d’éviter l’ouverture incontrôlée de plusieurs onglets. On passe ainsi de l’information console à une vérification visuelle sur une carte externe.
</> Python
def avion_vers_ligne_resultat(base: Path, ligne: Dict[str, str], avion: Dict[str, object]) -> Dict[str, object]:
icao = normaliser_icao(avion.get("hex") or ligne.get("icao"))
lat = convertir_nombre(avion.get("lat"))
lon = convertir_nombre(avion.get("lon"))
distance = ""
if lat is not None and lon is not None:
distance = f"{haversine_km(STATION_LAT, STATION_LON, lat, lon):.1f}"
return {
"icao": icao,
"registration": valeur_non_vide(avion.get("r"), ligne.get("registration")),
"flight": str(avion.get("flight") or "").strip(),
"type_donnee": avion.get("type", ""),
"type_appareil": valeur_non_vide(avion.get("t"), ligne.get("icao_type"), ligne.get("type")),
"altitude": formater_altitude(avion),
"vitesse": formater_vitesse(avion),
"lat": lat if lat is not None else "",
"lon": lon if lon is not None else "",
"distance_station_km": distance,
"position_approximative": position_approximative(lat, lon, base, False),
"rattachement_principal": ligne.get("rattachement_principal", ""),
"type_rattachement": ligne.get("type_rattachement", ""),
"zone_rattachement": ligne.get("zone_rattachement", ""),
"confiance_rattachement": ligne.get("confiance_rattachement", ""),
"raison_rattachement": ligne.get("raison_rattachement", ""),
"operator": ligne.get("operator", ""),
"category": ligne.get("category", ""),
"tag1": ligne.get("tag1", ""),
"tag2": ligne.get("tag2", ""),
"tag3": ligne.get("tag3", ""),
"sources": ligne.get("sources", ""),
"url_adsbexchange": url_carte_adsbexchange(icao) if icao else "",
}
def sauvegarder_resultats(base: Path, resultats: List[Tuple[Dict[str, str], Dict[str, object]]]) -> None:
"""
Sauvegarde uniquement le fichier JSON final.
La sauvegarde CSV intermédiaire resultats_test_api_17n.csv, présente dans la
version 17n, a été supprimée afin d'éviter le doublon avec le fichier PRIO_*
généré lors des recherches par mots-clés.
"""
dossier = assurer_dossier(base / DOSSIER_RESULTATS)
lignes = [avion_vers_ligne_resultat(base, ligne, avion) for ligne, avion in resultats]
chemin_json = dossier / "resultats_finaux.json"
with chemin_json.open("w", encoding="utf-8") as f:
json.dump(lignes, f, ensure_ascii=False, indent=2)
print(f"\nRésultats sauvegardés : {chemin_json}")
Ouverture sur ADS-B Exchange pour CTM2008 (proche de la Grèce – voir code partiel 15)
À suivre…
Avec ce treizième volet, nous avons franchi une étape importante. Notre station ADS-B n’est plus seulement un récepteur qui affiche des avions sur une carte locale ou qui alimente des plateformes extérieures. Elle devient aussi une source de données que nous pouvons interroger, comparer, filtrer et enrichir à l’aide de petits programmes Python.
La suite prolongera donc cette logique, mais avec des outils plus aboutis. Nous reprendrons notamment le travail engagé autour des interrogations des bases Plane-Alert. La version présentée au cinquième paragraphe n’est déjà plus la dernière : les essais se sont poursuivis jusqu’à une version, plus complète, mieux organisée et mieux adaptée à l’exploitation des bases Plane-Alert locales et enrichies.
Nous aborderons aussi l’optimisation de nos propres fichiers prioritaires. L’objectif ne sera plus seulement de consulter des bases existantes, mais aussi d’ajouter, de compléter et d’organiser nos propres listes d’appareils à surveiller, qu’elles proviennent d’une recherche, d’une interrogation API ou d’une observation particulière.
Un autre progrès important concernera la localisation lisible des appareils. Les premiers fichiers de villes ont été constitués au fil des essais, de manière assez artisanale. Nous verrons comment les remplacer ou les compléter grâce aux données GeoNames, afin d’obtenir un maillage géographique plus solide et des indications de position plus fiables.
Enfin, une fois les données mieux structurées, une nouvelle question se posera naturellement : comment les visualiser autrement qu’en console ? Après les tableaux, les fichiers CSV et les résultats JSON, il deviendra tentant de préparer une représentation cartographique de nos propres sélections d’appareils. Ce sera peut-être l’étape suivante : passer de la donnée exploitée à la donnée visualisée.
Fichiers Test API Partie1.zip
Cet article est reproduit sur HamAnalyst avec l’autorisation de son auteur, François Paget, qui en conserve la pleine propriété intellectuelle. Retrouvez les travaux originaux sur : François Paget.
François Paget est aujourd’hui retraité. Membre fondateur du McAfee Labs, il y travailla de 1993 à 2016 comme spécialiste de la sécurité informatique et de la cybercriminalité. Il s’est notamment intéressé aux programmes malveillants, aux nouvelles menaces numériques, à la fraude financière et aux cyber-conflits. Longtemps Secrétaire général du CLUSIF, il anima son Panorama de la Cybercriminalité de 2002 à 2017 et publia en 2005, chez Dunod, l’ouvrage Vers & Virus.
Toujours curieux de techniques nouvelles, il s’est également passionné, à partir de 2021, pour la radio logicielle, ou SDR. Il a depuis publié plusieurs articles de vulgarisation consacrés au décodage de signaux numériques, notamment dans la revue Radioamateurs France et sur les sites ON5VL et HamAnalyst. Ses expérimentations récentes portent en particulier sur la réception ADS-B dans la bande des 1090 MHz.
Avec son épouse Yvette, il partage son temps entre la région parisienne et les Landes. Ils ont trois enfants et six petits-enfants.







