Petite app menu bar Python qui mesure et affiche la qualité réseau par interface (Wi-Fi, Ethernet, iPhone tethering, dock USB-LAN…) et la consommation 5G d'un routeur TP-Link M8550.
L'idée centrale : sur Mac on a souvent plusieurs liens réseau actifs en même temps, mais macOS ne montre que celui de la route par défaut. NetHealth affiche les N liens, donne un score 0-100 par lien, et marque celui qui sort le trafic. Pratique pour décider quand basculer sur la 5G ou un dock filaire.
- Vue d'ensemble
- Comment ça marche
- Pré-requis
- Build & installation
- Premier lancement
- Configuration
- Mise à jour
- Notes de révision
- Désinstallation
- Emplacements utiles
- Dépannage
NetHealth v1.24
Route active : Wi-Fi · Nostromo
★ ◉ Wi-Fi · 94% · 4ms · perte 0% · 45 Mbps↓
↪ ◎ DockCaseAxUSBToLAN · 78% · 8ms · perte 0% · 21 Mbps↓
↪ ✕ iPhone · 0% · 200ms · perte 100% · (skip: pas de route)
🚦 Sondes : standard
Budget 5G : 0 Mo/h (seuil 100 Mo/h)
Signal Wi-Fi : -54 dBm · SNR 41 dB · 1201 Mbps radio
Download (route active) : 192.0 Mbps
Upload (route active) : 90.4 Mbps
📡 TP-Link M8550 : 5G NSA · Bouygues
Réseau : 5G NSA · WAN 80.x.x.x
Live : 22.4 Mbps↓ / 1.8 Mbps↑
Data consommée : 4.2 Go
Δ depuis NetHealth : 12.3 Mo
Configurer mot de passe routeur…
Rafraîchir TP-Link
Rafraîchir maintenant
Lancer speedtest maintenant
Diagnostic…
Ouvrir le log
Quitter
Lecture en 1 seconde :
- Glyphes par formes (daltonien-safe) :
✕ ◌ ○ ◎ ◉du pire au meilleur - ★ = route active (par où sort le trafic)
- ↪ = interface UP mais en backup
- 📶 (à côté du nom) = lien mobile (iPhone tethering, Wi-Fi ≈ partage 5G)
Cinq barres verticales (1 par interface, max 5), hauteur = score, couleur = palette viridis (bleu profond → turquoise → jaune doré). Petit triangle blanc au-dessus de la barre de la route active. Fonds gris ténus = "ça pourrait monter jusqu'ici".
L'icône Finder reprend le même langage visuel sur fond squircle navy.
Un thread d'arrière-plan tourne en permanence (_monitor_loop), et exécute un tick toutes les 30 s :
- Énumère les interfaces macOS via
networksetup -listallhardwareportsetifconfig. Sépare ce qui a une IPv4 routable (is_ready) du standby (iPhone branché sans IP utile, Wi-Fi associé sans bail DHCP). - Ping chaque interface (4 paquets vers Cloudflare 1.1.1.1) en bindant la source via
ping -S <ip>. - Sonde "medium" round-robin : 1 interface par cycle de 10 min, télécharge 1 MB Cloudflare via
curl --interface <dev>. EWMA pour lisser. Précédée d'un pre-check TCP 2 s socket-bound (IP_BOUND_IF=25sur macOS) qui évite de sécher 8 s de download sur une iface sans route. - TP-Link M8550 (si Keychain renseigné) : récupère via
tplinkrouterc6ules métriques radio + data consommée. Précédé d'un pre-check HTTP 2 s qui vérifie une signature M-series (headerServerlighttpd/boa, balise TP-Link, ou/cgi/getParmavec formatvar nn="…") — sans ça, la lib timeout à 30 s quand le M8550 est hors subnet, ou pire, parle à la box d'un Wi-Fi tiers qui répond aussi sur 192.168.1.1. - Speedtest Cloudflare sur la route par défaut, toutes les 5 min, uniquement si la route n'est pas mobile et qu'on n'est pas en mode économique 5G.
- Score qualité par interface, agrégé en un health 0-1 (cf. plus bas).
- Refresh icône + menu via
_call_on_main()(rumps + Cocoa main thread).
Quand le M8550 dépasse TPLINK_BUDGET_MB_PER_HOUR (défaut 100 Mo/h) :
- Plus de speedtest auto
- Plus de medium probe sur les interfaces mobiles
Les pings cheap restent (négligeables). Évite de cramer un forfait pendant les tests.
score = 0.40 × latence + 0.30 × perte + 0.30 × débit (+ bonus signal Wi-Fi ≤ 5%)
- Latence : 1.0 si ≤ 30 ms, 0.0 si ≥ 250 ms, linéaire au milieu.
- Perte :
1 - loss/20, 0 % → 1.0, 20 %+ → 0.0. - Débit :
min(1.0, mbps / 50). Source : EWMA medium probe en priorité (par iface), sinon speedtest Cloudflare (route active uniquement), sinon neutre 0.5. - Bonus Wi-Fi : +0.025 si RSSI ≥ -55 dBm, +0.025 si SNR ≥ 30 dB.
Court-circuits durs à 0 : si le pre-check TCP a marqué l'iface avec un de ces motifs, le score passe direct à 0 sans calcul :
pas de routeréseau injoignablehost downiface sans IPiface inutilisable
C'est ce qui produit le ✕ · 0% rapide quand un dock est branché à un switch sans uplink.
Inspirée viridis : bleu profond (40, 40, 120) → turquoise (60, 170, 170) → jaune doré (240, 210, 80). Zéro rouge ni vert. Voir _interpolate_color() dans network_health.py.
| Élément | Précision |
|---|---|
| macOS | 12 Monterey minimum (CoreWLAN + PyObjC récents). |
| Compte Apple Developer | Pour la signature Developer ID (99 €/an). Un compte gratuit signe seulement avec une cert « Apple Development » utilisable localement. |
| Outils Xcode | xcode-select --install (fournit codesign, security, notarytool, stapler, iconutil). |
| Python | 3.11 ou 3.12. 3.10 minimum. |
Xcode ne crée pas cette cert automatiquement.
- Xcode → Settings → Accounts → ton Apple ID.
- Manage Certificates… →
+→ Developer ID Application.
Vérifier :
security find-identity -v -p codesigningNoter la chaîne complète :
Developer ID Application: Nom Organisation (ABCDE12345)
C'est la valeur de la variable DEVELOPER_ID ci-dessous.
cd path/to/nethealth # repo root
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
pip install py2appDépendances installées :
rumps— UI menu barPillow— génération dynamique des icônes PNGpyobjc-framework-CoreWLAN— API Wi-Fi native (SSID, RSSI, SNR, canal…)pyobjc-framework-CoreLocation— déclenche la permission Location Servicestplinkrouterc6u— client M8550 (lecture LTE/5G + data)speedtest-cli— fallback (le module principal est vendorisé viaspeedtest_vendor.py)py2app— bundling en.app
DEVELOPER_ID="Developer ID Application: Nom Organisation (ABCDE12345)" \
INSTALL=1 \
./build.shLe script :
- Supprime les builds précédents (
build/,dist/,__pycache__/) - Génère l'icône Finder via
python3 generate_app_icon.pysiicon.icnsn'existe pas - Construit
dist/NetHealth.appvia py2app - Signe le bundle avec l'identité fournie + entitlements de
entitlements.plist - Copie
NetHealth.appdans/Applications/
Durée typique : 30-90 s.
Pré-requis : un app-specific password créé sur appleid.apple.com → Sécurité.
DEVELOPER_ID="Developer ID Application: Nom Organisation (ABCDE12345)" \
APPLE_ID="email@example.com" \
TEAM_ID="ABCDE12345" \
APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx" \
NOTARIZE=1 INSTALL=1 \
./build.shCompter 1-5 min de plus pour la notarisation (envoi à Apple, attente, staple du ticket).
./build.sh
cp -R dist/NetHealth.app /Applications/Gatekeeper refusera au 1er lancement → clic-droit sur l'icône → Ouvrir.
- Ouvrir
/Applications/NetHealth.app(double-clic Finder). - Accepter la popup Location Services quand elle apparaît. Sans cette permission, macOS retourne
<redacted>pour le SSID et NetHealth ne peut pas distinguer les Wi-Fi entre eux. - L'icône (cinq barres) apparaît dans la menu bar.
Menu Configurer mot de passe routeur… → saisir le mot de passe admin du M8550. Stocké dans le Keychain macOS sous eu.mylastnight.nethealth.tplink. NetHealth essaie successivement les usernames user puis admin.
Le plus courant : macOS a mémorisé un refus précédent.
tccutil reset LocationServices eu.mylastnight.nethealth
open /Applications/NetHealth.appOu manuellement : Réglages système → Confidentialité & sécurité → Services de localisation → activer NetHealth.
Si la permission est refusée au runtime, le menu affiche en plus l'entrée
Réglages système → Général → Éléments d'ouverture → + → /Applications/NetHealth.app.
L'app vit uniquement dans la menu bar (LSUIElement = True) — pas de Dock, pas de fenêtre.
network_health.py ligne ~207 :
ICON_STYLE = "gauge" # ou "radar" ou "pie"gauge(défaut) : 5 barres verticales, hauteur = score, triangle blanc sur l'iface active.radar: cercle + rayons partant du centre, longueur = score, disque blanc sur l'iface active.pie: pie-chart historique, 1 quartier par iface.
Modifier la valeur, rebuild, l'icône change.
network_health.py lignes ~196-199 :
LATENCY_GREAT_MS = 30 # ≤ 30 ms → score latence = 1.0
LATENCY_BAD_MS = 250 # ≥ 250 ms → score latence = 0.0
LOSS_BAD_PCT = 20 # 20 % → score perte = 0.0
DOWNLOAD_GREAT_MBPS = 50 # ≥ 50 Mbps → score débit = 1.0network_health.py ligne ~181 :
TPLINK_BUDGET_MB_PER_HOUR = 100 # au-delà → mode économiqueL'icône .icns est produite à partir de generate_app_icon.py. Si tu modifies les paramètres (couleurs, padding, taille du triangle), relance :
python3 generate_app_icon.py # régénère icon.icns + icon_1024.png + icon.iconset/Puis rebuild.
Tant que le bundle identifier ne change pas (eu.mylastnight.nethealth), macOS conserve les permissions accordées (Location, etc.).
cd path/to/nethealth # repo root
source .venv/bin/activate
osascript -e 'tell application "NetHealth" to quit' 2>/dev/null
DEVELOPER_ID="Developer ID Application: Nom Organisation (ABCDE12345)" \
INSTALL=1 \
./build.sh
open /Applications/NetHealth.appBumper VERSION dans network_health.py ET APP_VERSION dans setup.py à chaque build (utile pour distinguer dans les logs >>> NetHealth v1.24 STARTING <<<).
Ne lister que les jalons. Les fixes mineurs intermédiaires (ex. 1.20 → 1.21 → 1.22) sont volontairement omis — l'historique fin est dans git log.
- Pre-check TP-Link par signature HTTP (
_validate_m8550) : remplace le simple connect TCP par un GET qui vérifie le headerServer(lighttpd/boa), le body (tp-link), ou un fallback sur/cgi/getParmau formatvar nn="…". Règle le faux positif sur les Wi-Fi tiers où la gateway répond aussi sur192.168.1.1. - Mapping errno robuste :
_unwrap_oserrordescend la chaîne d'exceptionsrequests/urllib3jusqu'au 1erOSErroravecerrnonon-None(le wrapper extérieur étant lui-même unOSError errno=None, il fallait creuser). - Statut projet : clos. La suite logique est le bonding multi-uplinks (cf.
BONDING_CDC.md).
- Score qualité daltonien-safe : palette viridis + glyphes
✕◌○◎◉au lieu d'un dégradé rouge/vert. - Style d'icône configurable (
pie/gauge/radar),gaugeretenu par défaut. - Icône Finder reproductible via
generate_app_icon.py→icon.icns(plus de drag-drop manuel). - Ménage menu :
copy_ssidetrequest_locationretirés ;open_location_prefsconditionné sur l'OS. _humanize_router_errormappe les exceptionstplinkrouterc6uvers des labels courts en français.
Itérations de stabilisation : pre-check TCP côté TP-Link ajouté en 1.19 (le M8550 hors subnet bloquait toute la boucle pendant 30 s sur le timeout caché de la lib), puis ajustements CoreLocation, gestion du <redacted> SSID, exposition keychain pour le mot de passe routeur.
- Pre-check TCP par interface avec
IP_BOUND_IF: avant cette version, lesmedium probeséchaient encurl rc=28sur les ifaces sans route. Depuis, on tag l'iface comme injoignable proprement et on coupe court. - Mapping
curl rc → label lisible(timeout,pas de route,DNS KO, etc.).
Mise au point progressive : énumération multi-iface via networksetup, classification (wifi / iphone / ethernet), score 0-100 (latence + perte + débit), ping cheap récurrent, medium probe round-robin, intégration tplinkrouterc6u pour le M8550, budget 5G horaire avec bascule en mode économique, popup macOS Location pour avoir le SSID en clair.
osascript -e 'tell application "NetHealth" to quit' 2>/dev/null
rm -rf /Applications/NetHealth.app
rm -rf ~/Library/Logs/NetHealth
tccutil reset LocationServices eu.mylastnight.nethealth
security delete-generic-password -s eu.mylastnight.nethealth.tplink 2>/dev/nullRetirer aussi de Réglages système → Général → Éléments d'ouverture si l'auto-start était activé.
| Chemin | Contenu |
|---|---|
/Applications/NetHealth.app |
Bundle installé. |
~/Library/Logs/NetHealth/nethealth.log |
Log principal (rotatif, 1 Mo, 3 archives). |
~/Library/Logs/NetHealth/debug.log |
Log debug brut. |
<projet>/dist/NetHealth.app |
Sortie py2app avant install. |
<projet>/icon.icns |
Icône Finder. |
<projet>/icon_1024.png |
Master 1024 généré pour preview. |
<projet>/build/ |
Artefacts intermédiaires py2app. |
Keychain : security find-generic-password -s eu.mylastnight.nethealth.tplink.
Permission Location Services non accordée. Voir Premier lancement.
Tu tournes une version < 1.16. Rebuild en 1.24 — le pre-check TCP socket-bound + le mapping rc → label lisible (timeout, pas de route, DNS KO, etc.) sont absents avant.
Idem : il manque le pre-check routeur (TCP en 1.19+, HTTP-signature en 1.24+). Le M8550 hors subnet bloquait toute la boucle. Rebuild résout.
Sur 1.23 et avant, le pre-check était un simple connect TCP : sur un Wi-Fi tiers où la gateway répond aussi sur 192.168.1.1, on parlait à la mauvaise box et tplinkrouterc6u enchaînait 4 endpoints HTTP qui foiraient. Depuis 1.24 le pre-check valide une signature M-series (header Server ∈ {lighttpd, boa}, ou body TP-Link, ou /cgi/getParm retournant var nn="…"). Si la signature est absente, on remonte hôte ≠ M8550 (signature absente) au lieu de tenter la session.
Le .app a été remplacé pendant qu'une instance tournait. Tuer + recopier proprement :
osascript -e 'tell application "NetHealth" to quit'
rm -rf /Applications/NetHealth.app
cp -R dist/NetHealth.app /Applications/
open /Applications/NetHealth.appVérifier dans les logs ~/Library/Logs/NetHealth/nethealth.log : si tu vois speedtest auto skipped (mobile=True…), c'est normal — la route active est mobile, NetHealth s'appuie sur la sonde medium par iface (50× moins de data consommée). Pour forcer un test : menu Lancer speedtest maintenant.
codesign --verify --strict --verbose=2 /Applications/NetHealth.app
spctl --assess --type execute --verbose /Applications/NetHealth.appSi signé Apple Development (et non Developer ID), Gatekeeper refuse hors machine de dev. Créer une cert Developer ID Application (cf. Build) et re-signer.
Re-vérifier que DEVELOPER_ID correspond exactement à la sortie de security find-identity -v -p codesigning (espaces et parenthèses inclus, et toute la chaîne entre guillemets dans la commande shell).
Ajouter dans setup.py clé includes ou packages. Pour les modules critiques (speedtest), préférer la vendorisation : copier le .py directement dans le projet (cf. speedtest_vendor.py).
Bug rumps connu : items sans callback rendus désactivés. Résolu depuis longtemps en passant un callback no-op aux items info — vérifier que tu n'es pas sur une vieille version.
- Cert Developer ID Application créée et listée par
security find-identity -v -p codesigning -
xcode-select --installexécuté - Repo cloné,
venvcréé et activé -
pip install -r requirements.txt && pip install py2app -
python3 generate_app_icon.py(génèreicon.icns) -
DEVELOPER_ID=… INSTALL=1 ./build.shexécuté sans erreur - Bundle présent dans
/Applications/NetHealth.app - Premier lancement effectué, permission Location accordée
- Mot de passe M8550 enregistré via le menu (optionnel)
- Icône cinq barres visible dans la menu bar
- Application ajoutée aux Éléments d'ouverture si démarrage auto souhaité