AUDIT-2026-08-13.md
Rama main · réplica de lectura del repositorio original.
# Rapport d’audit de blink2video.py
Date de l’audit : 13 août 2026
Fuseau : Europe/Paris
Révision auditée : 58f5566
Périmètre principal : blink2video.py
Périmètre connexe nécessaire : runtime.py, serve.py, merge_daily.py, watch.py,
tests.py, smoketest.py, blink2video.spec
Environnement d’inspection : Python 3.12.0, blinkpy 0.25.9, aiohttp 3.14.3
## 1. Objet du rapport
Ce document conserve l’analyse détaillée de blink2video.py et transforme ses
conclusions en programme de travail progressif.
Les objectifs sont :
1. corriger les erreurs pouvant provoquer un arrêt, une corruption silencieuse,
une perte d’état ou un processus orphelin ;
2. garantir chaque correction par un test de non-régression isolé ;
3. mesurer les performances avant toute modification ;
4. vérifier après chaque étape que les coûts ont diminué ou, à défaut, qu’aucune
régression mesurable n’a été introduite ;
5. ne passer à l’étape suivante qu’après satisfaction de critères de sortie
explicites ;
6. consigner, pour chaque étape réellement terminée, le travail effectué, les
tests exécutés, les valeurs mesurées, les écarts au plan et le verdict.
Le rapport décrit un plan. Aucune correction de production n’a été appliquée
pendant l’audit.
## 2. Résumé exécutif
Le fichier est lisible, très commenté et possède plusieurs bonnes fondations :
- téléchargement vers un fichier partiel puis renommage ;
- normalisation des dates en UTC ;
- sauvegarde atomique envisagée pour les fichiers JSON ;
- poursuite du lot après l’échec isolé d’un clip ;
- séparation des gros verbes délégués dans des modules distincts ;
- volonté explicite de préserver les clips et les exclusions.
La configuration recommandée, blink2video start, met cependant plusieurs
processus en concurrence. Les mécanismes actuels ne garantissent pas les
invariants annoncés par le code.
Les cinq invariants à rétablir en premier sont :
1. une collection vide est un succès vide, jamais une erreur de type ;
2. un fichier déclaré téléchargé est réellement une vidéo complète ;
3. un événement déjà acquis reste acquis après renumérotation distante ;
4. une décision plus récente, notamment une exclusion, ne peut pas être écrasée
par une copie d’état plus ancienne ;
5. une ressource verrouillée ne peut être utilisée que par un propriétaire à la
fois.
La prochaine release devrait être bloquée tant que les anomalies B-01 à B-06 ne
sont pas corrigées et couvertes.
Une évolution fonctionnelle a été ajoutée au périmètre après l’audit :
- lancer blink2video sans argument doit être équivalent à blink2video start ;
- le démarrage doit d’abord vérifier réellement l’authentification ;
- si elle manque ou n’est plus valide, une page locale de connexion doit
s’ouvrir dans le navigateur ;
- le formulaire doit gérer les identifiants et la vérification en deux étapes ;
- un premier contrôle read-only de type list ou mini-smoke doit confirmer que
la session et le compte fonctionnent ;
- les boucles permanentes ne doivent être lancées qu’après ce contrôle.
## 3. Méthode et limites
### 3.1 Travail effectué
L’audit a comporté :
- lecture intégrale des 1 064 lignes de blink2video.py ;
- cartographie de ses appels à runtime.py, serve.py, merge_daily.py et watch.py ;
- lecture de tests.py et smoketest.py ;
- inspection de la version de blinkpy installée ;
- analyse AST ;
- analyse Ruff sans correction automatique ;
- sondes ciblées en mémoire ou dans des dossiers temporaires ;
- revue séparée des chemins téléchargement, CLI/processus et couverture.
### 3.2 Défauts reproduits sans compte Blink
Les comportements suivants ont été reproduits :
- manifeste cloud vide : TypeError lors du déballage du résultat ;
- option --since 0 : requête portant sur 30 jours ;
- option --loop -1 : valeur acceptée ;
- port 70000 pour open : OverflowError brut ;
- noms A/B et A_B : même safe_name ;
- download --camera watch : watch est interprété comme un nouveau verbe ;
- JSON racine de mauvais type : AttributeError ;
- fusion d’un état périmé : suppression du drapeau excluded.
### 3.3 Limites
L’audit n’a pas utilisé d’identifiants Blink, de caméra ni de Sync Module. Les
interactions réelles avec le service devront être validées dans un test manuel
optionnel, jamais dans la CI.
La suite complète tests.py n’a pas été exécutée pendant cette phase de lecture
seule, car elle peut envoyer une notification système et écrire des états dans
le dossier applicatif réel.
## 4. Carte du fichier
| Zone | Lignes approximatives | Responsabilité |
|---|---:|---|
| Authentification | 59–187 | session enregistrée, identifiants, 2FA |
| Inventaires | 190–345 | modules, manifeste USB, cloud, filtres |
| Noms et chemins | 348–378 | tailles, safe_name, target_path |
| Téléchargement USB | 381–399 | préparation, fichier partiel, renommage |
| Registre incrémental | 402–501 | chargement, fusion, identité, exclusion |
| Arguments | 515–599 | analyseur des verbes locaux |
| Traitement cloud | 602–677 | rapprochement et téléchargement |
| Boucles et passage | 680–826 | connexion, USB, cloud, notifications |
| Délégation | 829–885 | import des autres verbes et open |
| Arrêt | 888–929 | lecture des fiches et terminaison |
| Supervision | 932–1038 | composition, enfants, codes de retour |
| Point d’entrée | 1041–1064 | routage global et erreurs terminales |
## 5. Échelle de priorité
- B : bloquant. Crash fréquent, corruption silencieuse, perte d’une décision
utilisateur ou exclusion mutuelle non garantie.
- I : important. Fiabilité dégradée, comportement incohérent, sécurité ou
portabilité insuffisante.
- O : optimisation ou amélioration structurelle. Coût évitable, dette technique
ou couverture incomplète.
## 6. Anomalies bloquantes
### B-01 — Contrat de retour cassé sur un cloud vide
Emplacements :
- blink2video.py:602, traiter_cloud ;
- blink2video.py:613–614, retour False ;
- blink2video.py:806–809, déballage de deux valeurs.
Quand le cloud est vide ou que le filtre de caméra ne retient rien,
traiter_cloud renvoie un booléen seul. L’appelant attend toujours
erreur_cloud, nouveaux_cloud.
Conséquences :
- TypeError ;
- arrêt de list avec la source all ;
- arrêt de download --from cloud ;
- mort définitive de la boucle cloud de start.
Correction attendue :
- un type de retour unique ;
- idéalement un résultat nommé contenant téléchargés, adoptés, ignorés et
échecs ;
- à court terme, retourner systématiquement un tuple.
Test de non-régression : T-B01.
### B-02 — Corps HTTP d’erreur enregistré comme MP4
Emplacements :
- blink2video.py:260–271, CloudClip.download_to ;
- blink2video.py:646–650, renommage et inscription au registre.
Le statut HTTP n’est jamais contrôlé. Tout corps non vide est accepté. Une
réponse 401, 404, 429 ou 500 contenant du JSON ou du HTML peut donc devenir un
fichier .mp4 et être inscrite comme téléchargement réussi.
Conséquences :
- corruption silencieuse ;
- erreur découverte seulement pendant l’assemblage ;
- fichier durablement considéré comme acquis.
Correction attendue :
- exiger un statut de succès ;
- distinguer les erreurs transitoires des erreurs définitives ;
- écrire le corps par blocs ;
- vérifier la taille annoncée si elle existe ;
- vérifier au minimum la boîte MP4 ftyp ou effectuer un probe vidéo ;
- ne renommer et ne committer le registre qu’après validation.
Tests : T-B02-200, T-B02-401, T-B02-404, T-B02-429, T-B02-500,
T-B02-TRUNCATED.
Benchmarks : P-06 et P-07.
### B-03 — Réindexation USB suivie d’un retéléchargement
Emplacements :
- blink2video.py:372–378, target_path ;
- blink2video.py:451–464, state_key ;
- blink2video.py:485–501, is_downloaded.
La clé d’état ignore clip.id afin de survivre à une réindexation, mais le nom de
fichier contient clip.id. Après une réindexation :
1. la clé d’état reste la même ;
2. le chemin recalculé change ;
3. is_downloaded ne consulte pas entry.path ;
4. le nouveau chemin est absent ;
5. le même clip est téléchargé une seconde fois.
Conséquences :
- doublons en masse ;
- espace disque gaspillé ;
- ancien fichier orphelin ;
- promesse du README non tenue.
Correction attendue :
- faire de entry.path l’autorité pour un acquis existant ;
- séparer identité distante, identité d’événement et nom de fichier ;
- prévoir une migration de registre versionnée.
Tests : T-B03-REINDEX, T-B03-MISSING, T-B03-EXCLUDED.
Benchmark : P-04.
### B-04 — Une sauvegarde périmée peut annuler une exclusion
Emplacements :
- blink2video.py:417–448, save_download_state et _ecrire_registre ;
- merge_daily.py:455–463, écriture d’une exclusion.
Le registre est verrouillé au moment de l’écriture, mais le downloader détient
une copie complète chargée auparavant. _ecrire_registre relit le disque puis
applique clips.update avec la copie ancienne.
Scénario :
1. A charge le registre ;
2. B pose excluded=True et sauvegarde ;
3. A ajoute un autre clip ;
4. A sauvegarde sa copie ;
5. la version non exclue de A remplace la décision de B.
Conséquences :
- perte d’une action explicite de l’utilisateur ;
- clip exclu susceptible d’être téléchargé à nouveau ;
- incohérence entre Blink_Excluded et le registre.
Correction attendue :
- transaction unique couvrant lecture, décision et écriture ;
- mise à jour par delta et non par copie complète ;
- protection forte des tombstones ;
- recommandation : stockage transactionnel SQLite en mode WAL.
Tests : T-B04-INTERLEAVE, T-B04-TOMBSTONE, T-B04-CRASH.
Benchmarks : P-02 et P-03.
### B-05 — Verrou interprocessus non atomique et volé après dix minutes
Emplacements :
- runtime.py:491–541, verrou ;
- blink2video.py:719–723, verrou download ;
- blink2video.py:431, verrou registre.
Le protocole actuel lit le fichier, conclut qu’il est libre, puis l’écrit. Deux
processus peuvent effectuer les deux premières opérations simultanément et
entrer tous les deux.
Un verrou plus vieux que stale_after est en outre considéré périmé même si son
PID est vivant. Le téléchargement conserve son verrou sur un passage complet,
qui peut durer plus de dix minutes.
Conséquences :
- deux écrivains sur le même .part ;
- deux mises à jour concurrentes du registre ;
- assemblages concurrents ;
- suppression possible du verrou du voisin.
Correction attendue :
- verrou OS ou création exclusive atomique ;
- jeton UUID propre à l’acquisition ;
- identité du processus incluant son heure de création ;
- ne jamais expirer un propriétaire vivant sur le seul critère du temps ;
- heartbeat si une lease temporelle reste nécessaire.
Tests : T-B05-RACE20, T-B05-LONG, T-B05-CRASH, T-B05-PID-REUSE.
Benchmark : P-08.
### B-06 — Le téléchargement USB ne réserve pas le Sync Module
Emplacements :
- blink2video.py:34–36, hub_lock ;
- blink2video.py:736–805, parcours USB ;
- serve.py:833 et serve.py:1149, verrou pris par le direct et l’actualisation.
Le downloader prend le verrou download, mais jamais le verrou hub partagé avec
le direct. Un direct peut donc commencer pendant la fabrication du manifeste,
prepare_download ou download_video.
Conséquences :
- réponses System is busy ;
- échecs intermittents ;
- retries coûteux ;
- fiabilité dépendante du hasard de planification.
Correction attendue :
- verrou par sync_id ;
- section limitée aux opérations qui utilisent réellement le module ;
- aucune réservation du hub pour le cloud ;
- éviter les verrous imbriqués non réentrants dans serve.
Tests : T-B06-DIRECT-USB, T-B06-HUBS-DISTINCTS, T-B06-CLOUD-INDEPENDANT.
Benchmark : P-09.
## 7. Anomalies importantes
### I-01 — CONFIG et OUTPUT ignorent BLINK_HOME
Emplacements :
- blink2video.py:26–27 ;
- runtime.py:206–216 ;
- blink2video.py:994 et 1008.
Les chemins par défaut sont relatifs au répertoire courant. Une commande simple
et la même commande composée ne travaillent donc pas nécessairement au même
endroit.
Risques :
- refresh token déposé dans un autre projet ;
- clips écrits ailleurs ;
- serveur ne voyant pas la session créée en CLI ;
- contradiction avec le README.
### I-02 — Sauvegardes d’authentification concurrentes
Emplacements :
- blink2video.py:157–164 ;
- runtime.py:62–66, composition start.
Toutes les sessions emploient blink_auth.tmp sans verrou. Plusieurs clients
peuvent remplacer le même temporaire ou écraser un token plus récent.
Le temporaire blink_auth.tmp n’est pas couvert par les motifs actuels du
.gitignore.
### I-03 — Tokens rafraîchis non persistés
Emplacement : blink2video.py:88–92.
make_blink ne fournit pas de callback de persistance à Auth. Une session longue
peut rafraîchir son token uniquement en mémoire. Après un crash, le disque peut
contenir une version ancienne.
### I-04 — Déduplication asymétrique USB/cloud
Emplacements :
- blink2video.py:300–326, rapprocher ;
- blink2video.py:620–624, emploi côté cloud ;
- blink2video.py:756–764, absence d’équivalent côté USB.
Si l’USB arrive avant le cloud, le cloud est rapproché. Si le cloud arrive avant
l’USB, l’USB retélécharge l’événement.
### I-05 — Fichier cloud absent impossible à réparer
Emplacements : blink2video.py:620–638.
Toutes les entrées du registre participent au rapprochement, même si leur
fichier est absent ou vide. --overwrite intervient trop tard pour changer cette
décision.
Une absence accidentelle doit entraîner une réparation. Seule une tombstone
excluded doit interdire le téléchargement.
### I-06 — Manifeste privé, cumulatif et manifest_id potentiellement périmé
Emplacements :
- blink2video.py:209–231 ;
- blink2video.py:391–392.
Le code dépend de sync._local_storage, interface privée de blinkpy. Dans la
version inspectée, le manifeste est un SortedSet conservé entre les mises à
jour. Les objets déjà présents peuvent conserver leur ancien manifest_id.
L’adaptateur du projet doit produire son propre snapshot frais et actualiser
l’adresse de chaque item.
### I-07 — --since 0 devient 30 jours
Emplacement : blink2video.py:281.
since_days or 30 confond zéro et absence de valeur. Le chemin USB traite zéro
comme un cutoff immédiat, le cloud comme 30 jours.
### I-08 — Pagination cloud plafonnée et coûteuse
Emplacements : blink2video.py:281–284.
stop=20 correspond à 19 pages dans blinkpy 0.25.9, soit approximativement 475
éléments si une page en contient 25. Les mêmes pages peuvent être relues chaque
minute.
Risques :
- clips anciens jamais vus dans une fenêtre très active ;
- centaines ou milliers de requêtes quotidiennes inutiles ;
- latence proportionnelle à 30 jours d’historique plutôt qu’aux nouveautés.
### I-09 — Compte sans Sync Module refusé
Emplacements : blink2video.py:690–705.
main exige blink.sync avant même de terminer login ou de considérer une source
cloud. Cela contredit l’indépendance annoncée du cloud et l’existence de
_HubCloud.
### I-10 — JSON valide mais schéma invalide non géré
Emplacements :
- blink2video.py:59–77 ;
- blink2video.py:402–414 ;
- blink2video.py:622–676 ;
- runtime.py:516–527 ;
- runtime.py:288–303.
Les lecteurs supposent trop tôt un dictionnaire et des champs convertibles. Une
racine [], null, une entrée de clip non dictionnaire ou une date invalide peut
faire échouer toute la commande.
### I-11 — Valeur d’option confondue avec un verbe
Emplacement : runtime.py:652–674.
Toute chaîne égale à un verbe ouvre un nouveau groupe. Les guillemets ne
changent rien, car le shell les retire avant Python.
Exemples problématiques :
blink2video download --camera watch
blink2video download --camera update
blink2video watch --ignore serve
Le second exemple peut aller jusqu’à lancer une mise à jour non demandée après
l’échec du premier groupe.
### I-12 — Boucles non validées et classification textuelle
Emplacements :
- runtime.py:677–686 ;
- blink2video.py:727–729 ;
- blink2video.py:987.
Problèmes :
- valeur négative acceptée ;
- zéro signifie une exécution ;
- --loop=1 n’est pas détecté comme persistant ;
- comportements différents entre asyncio.sleep et time.sleep ;
- risque de boucle serrée contre l’API.
### I-13 — Supervision incomplète
Emplacements :
- blink2video.py:969–1038 ;
- runtime.py:264–340.
Cas non couverts :
- download --loop seul non inscrit ;
- enfant ponctuel non inscrit ;
- exception pendant les démarrages ;
- Ctrl+C avant l’entrée dans le bloc de nettoyage ;
- descendants ffmpeg non arrêtés ;
- absence de wait après terminate ;
- fiche supprimée alors qu’un enfant vit ;
- enfant qui termine avant la première itération de surveillance.
### I-14 — stop peut perdre sa cible ou viser un PID recyclé
Emplacements :
- blink2video.py:905–928 ;
- runtime.py:233–303.
La fiche est supprimée avant confirmation de la mort. L’identité du processus
est limitée au PID ; l’heure enregistrée dans la fiche n’est jamais comparée à
l’heure de création fournie par le système.
### I-15 — Contrôle d’intégrité limité à une taille non nulle
Emplacements :
- blink2video.py:383–384 ;
- blink2video.py:485–501 ;
- blink2video.py:638–642 ;
- blink2video.py:760–762.
Un fichier d’un octet est considéré complet. Le champ bytes enregistré dans le
registre n’est jamais utilisé pour vérifier le fichier.
### I-16 — Collisions de noms et composants de chemin non bornés
Emplacements : blink2video.py:366–378.
safe_name transforme des noms distincts en une même chaîne. Les noms réservés
Windows, la longueur maximale et clip.id ne sont pas normalisés. Une valeur API
inattendue peut créer un chemin invalide ou sortir de l’organisation prévue.
### I-17 — Erreurs transitoires non contenues
Emplacements :
- blink2video.py:714–729 ;
- blink2video.py:738–743 ;
- blink2video.py:806–809.
boucler ne capture que BusyError. Une panne HTTP, une erreur d’authentification
ou un changement de schéma peut tuer définitivement une boucle de fond.
Il faut distinguer :
- succès vide ;
- réponse partielle ;
- indisponibilité transitoire ;
- authentification à renouveler ;
- erreur de données définitive.
### I-18 — Compteurs cloud incorrects
Emplacements : blink2video.py:638–661.
Un fichier déjà présent mais adopté dans le registre est inclus dans
len(inedits) - echecs et peut être annoncé comme nouveau téléchargement.
### I-19 — Aide de --overwrite non conforme
Emplacements :
- blink2video.py:580 ;
- blink2video.py:758–764 ;
- blink2video.py:621–638.
L’aide parle de remplacer les fichiers de taille différente. Le chemin USB
retélécharge en pratique tout ce qui est sélectionné avec --overwrite. Le cloud
peut au contraire ne rien remplacer si le registre contient déjà l’événement.
Les notions repair, overwrite et force doivent être définies séparément.
### I-20 — Identité cloud liée arbitrairement au premier module
Emplacement : blink2video.py:630–668.
Le premier Sync Module sélectionné est utilisé pour former les clés de tous les
clips cloud. Sur un compte multi-réseaux, clip.network_id est l’identité plus
pertinente.
### I-21 — Matching temporel non bijectif
Emplacements : blink2video.py:312–325.
Plusieurs clips cloud peuvent être classés comme doublons du même clip local.
Le rapprochement doit consommer un match et employer un identifiant stable de
caméra.
### I-22 — Port open non validé et serveur non identifié
Emplacements : blink2video.py:870–884.
Un port hors plage produit une trace brute. Toute socket locale ouverte est
considérée comme l’interface blink2video, sans endpoint de santé ni version.
## 7.1 Évolution fonctionnelle demandée
### E-01 — Démarrage sans argument et onboarding web
Comportement actuel :
- blink2video sans argument affiche l’aide et se termine ;
- start lance directement plusieurs processus ;
- en l’absence de session, plusieurs consommateurs peuvent tenter de se
connecter ou rester sans authentification ;
- /api/status vérifie principalement la présence du fichier, pas la validité de
la session ;
- serve.py possède déjà LoginFlow, /api/login, /api/2fa et une boîte de dialogue
de connexion réutilisables.
Comportement cible :
1. blink2video et blink2video start appellent exactement le même parcours ;
2. --help et --version conservent leur sens et ne démarrent rien ;
3. un unique processus propriétaire exécute le préflight ;
4. la session sauvegardée est testée sans interaction ;
5. si elle est valide, le parcours continue sans ouvrir le navigateur ;
6. si elle est absente ou invalide, un serveur local limité à 127.0.0.1 expose
la page de connexion ;
7. le navigateur s’ouvre sur cette page ; si cela échoue, l’URL est affichée ;
8. l’e-mail, le mot de passe et le code 2FA sont traités dans ce processus
unique ;
9. le mot de passe n’est ni écrit, ni journalisé, ni conservé après le flux ;
10. après succès, un mini-smoke read-only vérifie le compte ;
11. le résultat du contrôle est affiché dans la page et le terminal ;
12. les processus de start ne sont créés qu’après succès ;
13. en cas d’échec ou d’annulation, aucune boucle de fond n’est laissée.
Le mini-smoke recommandé réutilise la logique métier de list mais expose un
résultat structuré :
- refresh/authentification réussie ;
- homescreen du compte lisible ;
- inventaire des réseaux, Sync Modules et caméras lisible ;
- compte cloud-only accepté ;
- aucun direct, armement, manifeste USB ou téléchargement déclenché.
Sécurité minimale de la page :
- écoute loopback uniquement ;
- jeton CSRF aléatoire et à usage court ;
- une seule tentative active à la fois ;
- réponses no-store ;
- aucun identifiant dans les logs ;
- délai d’expiration et bouton d’annulation ;
- limitation des tentatives ;
- fermeture de la session temporaire après succès ou abandon.
Cette évolution est planifiée à l’étape 5, après la stabilisation du magasin
d’authentification et avant le refactor général de la CLI.
## 8. Optimisations et améliorations structurelles
### O-01 — Téléchargement cloud entièrement en mémoire
Emplacement : blink2video.py:264–270.
Le corps entier est lu avant Path.write_bytes. Le streaming par blocs permet :
- mémoire bornée ;
- suivi de progression ;
- taille et hash au fil de l’eau ;
- reprise ou nettoyage plus simples.
### O-02 — Réécriture complète du JSON après chaque clip
Emplacements : blink2video.py:788–794 et 648–650.
Chaque succès relit et sérialise la totalité du registre. Un lot de N clips peut
ainsi approcher un coût quadratique.
### O-03 — Rapprochement temporel quadratique
Emplacements : blink2video.py:312–325.
Pour chaque clip cloud, toutes les dates de la caméra sont parcourues. Un index
trié avec bisect ou un index SQL réduit ce coût.
### O-04 — Scan cloud sans watermark
Emplacements : blink2video.py:274–297.
Une boucle à la minute relit jusqu’à 30 jours. Un watermark durable avec
recouvrement réduit les appels tout en protégeant contre les arrivées tardives.
### O-05 — Cadence qui dérive
Emplacements : blink2video.py:727–729 et runtime.py:689–706.
Le sommeil commence après le travail. Une opération de quatre minutes avec une
cadence de cinq minutes démarre en réalité toutes les neuf minutes. Une
échéance fondée sur time.monotonic évite cette dérive.
### O-06 — Bootstrap trop précoce
Emplacements : blink2video.py:14–23 et runtime.py:136–198.
Le bootstrap s’exécute avant de savoir si la commande est help, stop, open,
update ou un verbe Blink. Une installation cassée peut rendre stop inutilisable
et une simple aide peut créer un environnement ou contacter PyPI.
### O-07 — Dépendances non contraintes
Emplacements :
- runtime.py:127–133 ;
- build.py:33 ;
- workflows CI et release.
Le projet dépend d’une API privée de blinkpy mais installe la dernière version
disponible. Il faut une contrainte testée et une tâche séparée de mise à niveau.
### O-08 — Responsabilités trop nombreuses dans un fichier
blink2video.py contient cinq sous-systèmes :
1. authentification ;
2. adaptateurs de sources ;
3. persistance ;
4. CLI ;
5. supervision.
Leur séparation faciliterait les tests sans compte, les types explicites et les
évolutions indépendantes.
### O-09 — Code devenu mort
contextlib, HUB_LOCK et _process_alive ne sont plus utilisés dans
blink2video.py.
Les avertissements Ruff E402 autour des imports après runtime.bootstrap sont en
revanche intentionnels tant que cette architecture existe ; ils doivent être
documentés ou supprimés par le bootstrap tardif, pas contournés aveuglément.
### O-10 — Fichiers de verrou non ignorés
Le .gitignore cite .blink_hub.lock mais pas les nouveaux verrous download,
merge et registre. Le motif .blink_*.lock doit couvrir tous les artefacts
d’exécution.
## 9. État de la couverture
tests.py couvre principalement :
- aide des verbes ;
- assemblage vidéo ;
- exclusion dans merge_daily ;
- interface HTTP ;
- un chemin heureux de stop.
Il ne couvre directement aucune fonction d’authentification, de manifeste, de
rapprochement, de téléchargement ou de registre de blink2video.py.
Le commentaire de rapprocher affirmant que tests.py l’éprouve est faux.
Le mode BLINK_TEST_BUNDLE est incomplet :
- test_verbes lance toujours le fichier Python ;
- le test HTTP lance toujours serve.py ;
- test_relance inspecte runtime.py des sources ;
- seuls certains chemins merge et stop utilisent le bundle.
Les ports fixes 8899 et 8945 peuvent entrer en collision avec un service réel ou
produire un faux positif.
## 10. Architecture cible
La cible proposée reste une application locale, sans service externe ajouté.
### 10.1 Parcours de démarrage convivial
Le point d’entrée suit une machine d’état explicite :
invocation
→ préflight d’authentification
→ session valide ?
oui → mini-smoke read-only
non → serveur d’onboarding local
→ formulaire identifiants
→ formulaire 2FA si demandé
→ sauvegarde de session
→ mini-smoke read-only
→ préflight réussi ?
oui → lancement de la composition standard
non → diagnostic, aucune boucle lancée
Le serveur d’onboarding peut réutiliser les composants de serve.py, mais le
flux d’authentification doit vivre dans un module indépendant afin que le
lanceur ne dépende pas du serveur complet.
### 10.2 Racine de données unique
Tous les chemins applicatifs dérivent d’une seule racine :
- BLINK_HOME si défini ;
- sinon le dossier de données documenté.
La racine contient authentification, médias, base d’état, verrous, journaux et
fiches de processus. Le CWD ne change jamais leur emplacement.
### 10.3 Modèle de clip explicite
Un clip porte séparément :
- source : usb ou cloud ;
- compte et réseau ;
- identifiant stable de caméra ;
- nom d’affichage ;
- identifiant distant ;
- instant UTC ;
- chemin local ;
- taille et éventuellement hash ;
- statut ;
- provenance corrélée ;
- état excluded.
### 10.4 Persistance transactionnelle
SQLite WAL est recommandé pour :
- transactions multi-processus ;
- contraintes uniques ;
- index temporels ;
- claims de téléchargement ;
- tombstones protégées ;
- curseurs de pagination ;
- mises à jour partielles ;
- migrations versionnées.
Le JSON peut rester un format d’export et de diagnostic.
### 10.5 Adaptateurs de sources
Un adaptateur USB encapsule toute dépendance aux détails privés de blinkpy.
Un adaptateur cloud gère pagination, watermark et réponses HTTP. Le reste du
programme manipule le même modèle de clip.
### 10.6 Superviseur unique
Le superviseur :
- parse toute la commande avant tout effet ;
- connaît le cycle de vie de chaque verbe ;
- enregistre tous les enfants ;
- utilise groupes POSIX ou Job Objects Windows ;
- arrête, attend puis force si nécessaire ;
- conserve la fiche tant qu’un processus survit.
## 11. Règles communes à toutes les étapes
Chaque étape suit la séquence suivante :
1. écrire ou sélectionner les tests qui échouent sur la version précédente ;
2. enregistrer le baseline des benchmarks concernés ;
3. effectuer uniquement les changements de l’étape ;
4. faire passer les tests nouveaux puis la suite complète ;
5. mesurer la version nouvelle avec le même protocole ;
6. produire un tableau avant/après ;
7. analyser toute régression supérieure aux seuils ;
8. ne fusionner qu’après satisfaction du critère de sortie.
Une amélioration fonctionnelle ne justifie pas de supprimer un test existant.
Un seuil de performance ne doit être relâché qu’avec une raison documentée dans
le rapport de l’étape.
### 11.1 Compte rendu obligatoire après chaque étape
Une étape n’est pas considérée réalisée parce que son code est présent. Elle
est réalisée seulement quand son journal, à la section 28, contient :
- statut et dates de début/fin ;
- commits de départ et d’arrivée ;
- périmètre prévu ;
- travail effectivement effectué ;
- fichiers et migrations concernés ;
- décisions prises et écarts par rapport au plan ;
- tests ajoutés, modifiés et exécutés ;
- résultat absolu des tests, pas seulement « vert » ;
- environnement exact des mesures ;
- valeurs baseline et après modification ;
- médiane, p95, mémoire, appels API et tailles selon le cas ;
- delta absolu et relatif ;
- intervalle de confiance pour les mesures ciblées ;
- seuil attendu et verdict ;
- régressions observées et justification éventuelle ;
- anomalies restantes ;
- décision : acceptée, à reprendre ou bloquée.
Le journal doit conserver les nombres même lorsqu’ils sont défavorables. Une
mesure remplacée reste historisée avec la raison de son invalidation.
### 11.2 Format du tableau de mesure d’une étape
| Mesure | Baseline | Après | Delta | IC 95 % | Seuil | Verdict |
|---|---:|---:|---:|---:|---:|---|
| À remplir | — | — | — | — | — | non mesuré |
### 11.3 Format du bilan de tests d’une étape
| Suite/scénario | Avant | Après | Durée | Environnement | Verdict |
|---|---|---|---:|---|---|
| À remplir | — | — | — | — | non exécuté |
## 12. Étape 0 — Banc de sécurité et baseline
### 12.1 Objectif
Rendre toutes les étapes suivantes mesurables et reproductibles avant de
modifier la production.
### 12.2 Travail
0.1. Créer une suite unitaire dédiée à blink2video.
0.2. Forcer BLINK_BOOTSTRAP=none dans les tests.
0.3. Utiliser un BLINK_HOME temporaire par test.
0.4. Placer le CWD ailleurs que la racine de données.
0.5. Fournir des faux Blink, Sync, Clip, Auth et ClientResponse.
0.6. Injecter horloge, sommeil, session HTTP, notification et navigateur.
0.7. Ajouter un petit serveur HTTP local contrôlable.
0.8. Créer des workers multi-processus pour les courses.
0.9. Créer un générateur déterministe de registres et manifests.
0.10. Créer la commande de benchmark et son format JSON de résultat.
0.11. Enregistrer matériel, OS, Python, versions et commit avec le résultat.
0.12. Corriger les tests bundle pour employer réellement le bundle partout.
0.13. Allouer les ports dynamiquement.
0.14. Fournir un faux navigateur et un serveur d’onboarding pilotable.
0.15. Capturer les appels d’authentification et vérifier l’absence de secrets
dans les fichiers, journaux et exceptions.
### 12.3 Tests à écrire en premier
- T-B01 : cloud vide et filtre vide ;
- T-B02 : statuts HTTP et corps invalides ;
- T-B03 : réindexation ;
- T-B04 : interleaving exclusion/download ;
- T-B05 : acquisition concurrente ;
- T-B06 : direct contre USB ;
- JSON racine et entrées invalides ;
- BLINK_HOME avec CWD différent ;
- --since 0 ;
- --loop négatif, nul, séparé et forme égale ;
- valeurs de caméra égales à un verbe ;
- parent mort avec enfant vivant ;
- échec de kill et conservation de fiche ;
- sans argument contre start, avec plan de lancement observable ;
- session valide, absente et expirée sans ouvrir un navigateur réel ;
- succès, refus, 2FA, annulation et timeout du futur onboarding ;
- mini-smoke en succès et en échec, sans worker permanent.
### 12.4 Baseline à enregistrer
Exécuter P-01 à P-12 définis à la section 22 sur la révision 58f5566.
Pour P-12, les branches qui n’existent pas encore dans la révision auditée
sont consignées comme « non disponibles », jamais comme zéro. Le baseline doit
néanmoins mesurer le comportement actuel sans argument, start avec session
valide/invalide et le nombre d’appels ou processus observables. Les tests du
comportement cible peuvent être conservés comme tests attendus en échec avec un
identifiant E-01 explicite jusqu’à l’étape 5 ; ils ne doivent pas être masqués
comme succès.
### 12.5 Critère de sortie
- tous les tests historiques passent ;
- les nouveaux tests reproduisent effectivement les défauts attendus ;
- aucun test ne touche le compte ou les données réelles ;
- deux exécutions successives des benchmarks varient de moins de 10 % sur leur
médiane, hors benchmark réseau réel ;
- le baseline brut est conservé.
## 13. Étape 1 — Correctifs de sûreté immédiats
### 13.1 Périmètre
Cette étape corrige les défauts isolés sans changer encore le modèle de données.
1.1. Retour homogène de traiter_cloud.
1.2. Distinction entre since=None et since=0.
1.3. Validation stricte des jours, cadences et ports.
1.4. Validation de la racine JSON et des entrées.
1.5. Refus des statuts HTTP non réussis.
1.6. Compteurs downloaded, adopted, skipped et failed séparés.
1.7. Autoriser login et le cloud sans Sync Module.
1.8. Messages d’erreur typés, sans trace brute pour une erreur utilisateur.
1.9. Définition explicite de repair, overwrite et force.
### 13.2 Non-régression
- cloud vide retourne zéro et code 0 ;
- filtre vide retourne zéro ;
- réponse non-2xx ne crée ni cible ni entrée ;
- fichier partiel supprimé après tout échec ;
- --since 0 n’utilise jamais 30 jours ;
- port hors plage rejeté par argparse ;
- cadence non positive rejetée ;
- JSON invalide ou de mauvais type produit un diagnostic et une stratégie
documentée ;
- compte sans hub peut effectuer login et cloud.
### 13.3 Performance
- comparer P-01, P-06 et P-07 ;
- aucune régression de plus de 10 % sur le démarrage d’une commande simple ;
- aucune régression de plus de 5 % du débit d’écriture vers un faux serveur
local ;
- mémoire temporairement inchangée acceptée, le streaming arrivant à l’étape 6.
### 13.4 Critère de sortie
B-01 et la partie statut HTTP de B-02 sont fermés. Tous les tests de l’étape
passent sur les trois OS.
## 14. Étape 2 — Identité, déduplication et réparation
### 14.1 Périmètre
2.1. Définir l’identité source stable.
2.2. Définir l’identité de corrélation inter-source.
2.3. Ne plus employer safe_name comme identité métier.
2.4. Faire de entry.path l’autorité pour un acquis existant.
2.5. Rendre le matching symétrique USB/cloud.
2.6. Rendre le matching un-à-un.
2.7. Réparer un fichier absent ou invalide.
2.8. Préserver toute tombstone excluded.
2.9. Utiliser network_id propre au clip cloud.
2.10. Produire un nom de fichier sûr, borné et stable.
2.11. Concevoir une migration idempotente des clés version 1.
### 14.2 Non-régression
- réindexation 42 vers 99 : aucun nouveau téléchargement ;
- USB puis cloud : un seul événement ;
- cloud puis USB : un seul événement ;
- deux événements légitimes proches : deux résultats ;
- un match local ne consomme qu’un cloud ;
- fichier absent non exclu : réparation ;
- fichier absent exclu : aucun téléchargement ;
- caméra renommée : pas de duplication si un identifiant stable existe ;
- A/B et A_B restent distincts ;
- multi-réseaux et multi-hubs sans collision ;
- migration relancée deux fois sans changement supplémentaire.
### 14.3 Performance
- comparer P-04 et P-05 ;
- objectif initial : rapprochement au moins cinq fois plus rapide à 50 000
éléments ;
- objectif initial : temps sous-linéaire par rapport au produit
archive × cloud ;
- aucune augmentation de plus de 5 % du nombre de fichiers produits pour un jeu
sans doublon ;
- zéro doublon sur les jeux corrélés.
### 14.4 Critère de sortie
B-03, I-04, I-05, I-16, I-20 et I-21 sont fermés. La migration possède un test
de retour arrière à partir d’une copie de sauvegarde.
## 15. Étape 3 — Persistance et verrous transactionnels
### 15.1 Périmètre
3.1. Choisir SQLite WAL, sauf contre-indication mesurée.
3.2. Définir le schéma et les migrations.
3.3. Ajouter contraintes uniques et index temporels.
3.4. Représenter séparément téléchargement et exclusion.
3.5. Implémenter un claim atomique par clip.
3.6. Remplacer les copies complètes par des mises à jour partielles.
3.7. Rendre les migrations atomiques avec sauvegarde.
3.8. Remplacer runtime.verrou par un mécanisme atomique.
3.9. Employer un jeton d’acquisition, pas seulement le PID.
3.10. Ne jamais voler le verrou d’un processus vivant sur le seul âge.
3.11. Prévoir récupération après crash.
3.12. Conserver un export JSON lisible si nécessaire.
Si SQLite est refusé, les exigences minimales sont :
- création atomique exclusive ;
- temporaire unique ;
- fsync fichier puis dossier ;
- merge de deltas ;
- numéro de génération ;
- verrou couvrant lecture et écriture ;
- tests de course identiques.
### 15.2 Non-régression
- vingt processus ne peuvent acquérir le même verrou ;
- mille itérations de l’interleaving exclusion/download sans perte ;
- processus tué pendant un commit : base cohérente ;
- processus vivant au-delà de dix minutes : verrou conservé ;
- processus mort : ressource récupérable ;
- PID recyclé : ancien verrou non attribué au nouveau processus ;
- migration interrompue : ancien état restaurable ;
- aucun clip ni tombstone perdu.
### 15.3 Performance
- comparer P-02, P-03, P-04 et P-08 ;
- écriture de 10 000 événements au moins cinq fois plus rapide que les
sauvegardes JSON clip par clip ;
- p95 d’une mise à jour unitaire inférieur ou égal au baseline ;
- croissance proche de O(N) pour un lot, pas O(N²) ;
- débit concurrent supérieur au débit séquentiel sans perte de correction ;
- taille de stockage documentée pour 1 000, 10 000 et 100 000 événements.
### 15.4 Critère de sortie
B-04 et B-05 sont fermés. La base survit aux tests de crash et de concurrence.
Aucune tombstone n’est perdue sur 100 répétitions complètes.
## 16. Étape 4 — Racine de données et authentification
### 16.1 Périmètre
4.1. Dériver CONFIG et OUTPUT de la racine unique.
4.2. Documenter la résolution d’un --output relatif.
4.3. Créer le dossier de données avec permissions privées.
4.4. Écrire le fichier d’authentification en 0600 sur POSIX.
4.5. Utiliser une liste blanche de champs persistés.
4.6. Employer un temporaire unique et un replace atomique.
4.7. Coordonner charge, refresh et sauvegarde.
4.8. Persister les tokens rafraîchis par callback.
4.9. Choisir entre propriétaire unique de session et contrôle de génération.
4.10. Ajouter éventuellement keyring, DPAPI ou Keychain comme amélioration
optionnelle, sans rendre le mode fichier inutilisable.
### 16.2 Non-régression
- même chemin depuis tout CWD ;
- BLINK_HOME respecté par login, download, serve et merge ;
- programme installé dans un dossier en lecture seule ;
- deux sauvegardes concurrentes sans exception ni perte du token le plus
récent ;
- refresh puis crash puis redémarrage réussi ;
- mot de passe absent du fichier et du temporaire ;
- permissions vérifiées ;
- fichier JSON ne contient que les champs autorisés.
### 16.3 Performance
- comparer P-01 et P-10 ;
- pas plus de 10 % de régression sur connexion depuis une session valide ;
- une seule écriture durable par rotation réelle de token ;
- pas de polling supplémentaire au repos.
### 16.4 Critère de sortie
I-01, I-02 et I-03 sont fermés. Les quatre processus du scénario start peuvent
fonctionner 24 heures en test accéléré sans corruption du magasin de session.
## 17. Étape 5 — Démarrage convivial et onboarding web
### 17.1 Objectif utilisateur
Une installation neuve doit pouvoir être utilisée avec une seule commande :
blink2video
Cette commande devient strictement équivalente à :
blink2video start
Le premier écran utile doit être soit l’interface prête, soit la fenêtre de
connexion Blink. L’utilisateur ne doit plus devoir découvrir puis taper
manuellement blink2video login avant de pouvoir démarrer.
### 17.2 Périmètre
5.1. Faire converger l’absence d’argument et le verbe start vers une fonction
unique.
5.2. Préserver le comportement explicite de --help et --version.
5.3. Exécuter un préflight avant tout spawn permanent.
5.4. Tester la validité de la session, pas seulement l’existence du fichier.
5.5. Garantir un seul propriétaire du flux d’authentification.
5.6. Extraire ou réutiliser LoginFlow sans démarrer toute l’application.
5.7. Lancer un serveur d’onboarding sur 127.0.0.1 uniquement.
5.8. Ouvrir automatiquement le navigateur sur une URL contenant un jeton
éphémère.
5.9. Afficher l’URL si le navigateur ne peut pas être ouvert.
5.10. Gérer identifiants, refus, timeout et 2FA dans la page.
5.11. Fermer et nettoyer le flux après succès, annulation ou expiration.
5.12. Exécuter un mini-smoke read-only après authentification.
5.13. Afficher modules, réseaux et caméras trouvés sous une forme concise.
5.14. Accepter un compte valide sans Sync Module.
5.15. Ne lancer runtime.STANDARD qu’après succès du mini-smoke.
5.16. En cas d’échec, ne laisser aucun enfant ni verrou.
5.17. Conserver blink2video login comme solution CLI explicite et fallback.
5.18. Documenter le comportement en lancement manuel, autostart et environnement
sans navigateur.
### 17.3 Contrat du préflight
Le préflight renvoie un résultat structuré et stable :
- authenticated : session réellement utilisable ;
- account_reachable : homescreen obtenu ;
- networks : nombre et identifiants non sensibles ;
- sync_modules : nombre et noms d’affichage ;
- cameras : nombre et noms d’affichage ;
- cloud_only : compte valide sans module ;
- warnings : capacités absentes mais non bloquantes ;
- error : cause typée et message utilisateur ;
- elapsed : temps par sous-phase.
Le préflight est strictement read-only :
- aucun armement ;
- aucun direct ;
- aucun manifeste USB ;
- aucun téléchargement ;
- aucun assemblage ;
- aucune notification système.
### 17.4 Parcours nominal
Session valide :
1. invocation ;
2. chargement et refresh éventuel ;
3. mini-smoke ;
4. synthèse « compte accessible, N caméras » ;
5. lancement de start ;
6. ouverture de l’interface normale selon la politique choisie.
Session absente ou invalide :
1. invocation ;
2. échec contrôlé du test non interactif ;
3. démarrage du serveur local d’onboarding ;
4. ouverture de la page ;
5. saisie e-mail et mot de passe ;
6. affichage du champ 2FA si Blink le demande ;
7. sauvegarde atomique de la session ;
8. mini-smoke ;
9. affichage du résultat ;
10. lancement de start ;
11. redirection vers l’interface normale.
### 17.5 Cas d’échec
- mauvais identifiants : rester sur la page, sans écrire de session invalide ;
- mauvais code 2FA : afficher les tentatives restantes ;
- timeout : fermer la session temporaire et proposer de recommencer ;
- navigateur indisponible : imprimer l’URL et la commande CLI de secours ;
- port occupé par blink2video : réutiliser l’instance si son endpoint de santé
et sa version correspondent ;
- port occupé par un autre programme : prendre un port d’onboarding libre et
l’annoncer ;
- réseau Blink indisponible : ne pas demander inutilement de nouveaux
identifiants si le problème est typé comme transitoire ;
- compte valide sans caméra : préflight réussi avec avertissement ;
- mini-smoke en échec : ne pas démarrer les boucles et conserver le diagnostic.
### 17.6 Sécurité et confidentialité
- bind explicite sur 127.0.0.1 et ::1 seulement si correctement contrôlé ;
- jeton CSRF aléatoire lié au processus et expirant ;
- méthode POST et Content-Type vérifiés ;
- en-têtes Cache-Control: no-store ;
- aucune valeur de formulaire dans les logs ou exceptions affichées ;
- écrasement des références au mot de passe dès la fin du flux ;
- nombre de tentatives et cadence bornés ;
- une seule authentification active ;
- comparaison de l’origine et de l’hôte local ;
- arrêt du serveur d’onboarding lorsqu’il n’est plus utile ;
- tests garantissant que le mot de passe n’atteint aucun fichier.
### 17.7 Tests de non-régression
- T-E01-DEFAULT : sans argument et start produisent le même plan ;
- T-E01-HELP : --help affiche l’aide sans préflight ni réseau ;
- T-E01-VERSION : --version ne démarre rien ;
- T-E01-VALID : session valide, aucun navigateur, mini-smoke une fois ;
- T-E01-ABSENT : session absente, page ouverte ;
- T-E01-EXPIRED : fichier présent mais session invalide, page ouverte ;
- T-E01-2FA : transition identifiants vers code puis succès ;
- T-E01-BAD-CREDENTIALS : diagnostic, aucune session écrite ;
- T-E01-BAD-2FA : refus borné et aucun spawn ;
- T-E01-CANCEL : annulation et nettoyage ;
- T-E01-TIMEOUT : expiration et nettoyage ;
- T-E01-NO-BROWSER : URL imprimée et parcours encore utilisable ;
- T-E01-PORT-FOREIGN : autre port choisi, service étranger intact ;
- T-E01-PORT-BLINK : instance reconnue et réutilisée ;
- T-E01-CLOUD-ONLY : authentification valide sans hub ;
- T-E01-EMPTY-ACCOUNT : avertissement sans crash ;
- T-E01-SMOKE-FAIL : aucun processus permanent lancé ;
- T-E01-CONCURRENT : deux invocations, un seul onboarding propriétaire ;
- T-E01-NO-SECRET : mot de passe absent des fichiers, logs et erreurs ;
- T-E01-AUTOSTART : comportement documenté sans console interactive ;
- T-E01-BUNDLE : parcours identique depuis le bundle.
### 17.8 Mesures de performance et d’expérience
Ajouter le benchmark P-12 Onboarding :
- temps invocation → décision session valide/invalide ;
- temps invocation → page locale prête ;
- temps succès auth → résultat mini-smoke ;
- temps résultat mini-smoke → processus standard lancés ;
- temps total invocation → interface prête ;
- nombre de créations de ClientSession ;
- nombre d’appels Blink ;
- nombre d’ouvertures du navigateur ;
- RSS du processus d’onboarding ;
- CPU au repos pendant l’attente du formulaire ;
- nombre d’enfants et de verrous avant/après échec.
Budgets initiaux :
- coût local du préflight hors réseau inférieur à 200 ms en médiane ;
- page d’onboarding prête en moins de 500 ms en p95 sur la machine de
référence ;
- exactement un test d’authentification par lancement ;
- exactement un mini-smoke après authentification ;
- aucun appel Blink dupliqué entre préflight et lancement lorsque le résultat
peut être réutilisé ;
- CPU d’attente inférieur à 1 % d’un cœur ;
- RSS stable pendant dix minutes d’attente ;
- sur session valide, surcoût de blink2video sans argument inférieur à 5 % par
rapport à start avec le même préflight ;
- zéro enfant, verrou ou serveur d’onboarding survivant après échec ;
- les temps réseau sont rapportés séparément des temps locaux.
### 17.9 Compte rendu attendu
À la clôture, le journal de l’étape 5 doit notamment indiquer :
- choix de réutilisation ou extraction de LoginFlow ;
- URL et stratégie de port retenues ;
- politique autostart/headless ;
- nature exacte du mini-smoke ;
- appels API effectués ;
- captures des quatre parcours : valide, login simple, login 2FA, échec ;
- temps avant/après et time-to-ready ;
- preuve de l’absence de secret persistant ;
- preuve qu’aucun worker n’est lancé avant le succès.
### 17.10 Critère de sortie
E-01 est livré lorsque le parcours sans argument fonctionne de bout en bout en
source et en bundle, que le mini-smoke précède chaque lancement permanent et
que tous les tests de sécurité, nettoyage et performance ci-dessus passent.
## 18. Étape 6 — Adaptateurs USB/cloud et performance des entrées-sorties
### 18.1 Périmètre
6.1. Encapsuler _local_storage dans un adaptateur.
6.2. Construire un snapshot USB frais.
6.3. Actualiser manifest_id pour chaque téléchargement.
6.4. Réserver seulement le hub concerné.
6.5. Laisser cloud et hubs distincts avancer en parallèle.
6.6. Paginer le cloud jusqu’à page vide ou cutoff.
6.7. Ajouter watermark durable et recouvrement.
6.8. Ne committer le watermark qu’après scan complet.
6.9. Respecter Retry-After et ajouter backoff avec jitter.
6.10. Reconnecter une session longue après erreur d’authentification typée.
6.11. Streamer les réponses par blocs vers .part.
6.12. Vérifier taille, signature et éventuellement hash/probe.
6.13. Publier une progression fondée sur les octets lorsque disponibles.
6.14. Contraindre la version de blinkpy et créer une tâche de mise à niveau.
### 18.2 Non-régression
- ancien manifest_id remplacé ;
- clip supprimé du manifeste absent du snapshot courant ;
- direct et USB du même hub exclusifs ;
- deux hubs distincts parallèles ;
- cloud parallèle à l’USB ;
- 600 clips cloud tous visités ;
- panne à la page N : watermark inchangé ;
- nouvel événement légèrement antérieur au watermark récupéré grâce au
recouvrement ;
- 429 respecte Retry-After ;
- flux interrompu laisse uniquement un .part nettoyable ;
- vidéo d’un gigaoctet traitée sans chargement complet en mémoire.
### 18.3 Performance
- comparer P-06, P-07, P-09 et P-10 ;
- mémoire supplémentaire maximale inférieure à 32 Mio pour un flux de 1 Gio,
hors buffers du système ;
- réduction d’au moins 90 % des appels cloud en régime stable sans nouveauté ;
- débit local à moins de 5 % du débit brut du faux serveur ;
- hubs distincts : gain parallèle mesurable sans appels supplémentaires ;
- aucune boucle serrée après 429 ou erreur réseau.
### 18.4 Critère de sortie
B-02, B-06, I-06, I-08, I-15 et I-17 sont fermés. Les mesures démontrent une
mémoire bornée et une baisse nette du nombre d’appels API.
## 19. Étape 7 — Grammaire CLI et supervision
### 19.1 Périmètre
7.1. Définir une grammaire qui connaît l’arité des options.
7.2. Prévalider toute la composition avant tout effet.
7.3. Ajouter aux verbes les métadonnées composable et persistent.
7.4. Classer la persistance après parsing, jamais par recherche de chaîne.
7.5. Interdire ou définir clairement les compositions de stop, update,
autostart, login et open.
7.6. Inscrire toute instance persistante, y compris un verbe local seul.
7.7. Enregistrer les enfants ponctuels.
7.8. Installer le nettoyage avant le premier spawn.
7.9. Employer groupes POSIX et Job Objects Windows.
7.10. Arrêter coopérativement puis forcer après délai.
7.11. Appeler wait pour chaque enfant.
7.12. Identifier PID, heure de création et exécutable.
7.13. Écrire les fiches atomiquement avec états starting, running et stopping.
7.14. Ne supprimer une fiche qu’après confirmation.
7.15. Collecter exactement une fois tous les codes de sortie.
7.16. Ajouter un endpoint de santé signé pour open.
### 19.2 Non-régression
- caméra appelée watch, serve ou update ;
- --loop 1 et --loop=1 équivalents ;
- cadence nulle ou négative refusée ;
- échec du second spawn nettoie le premier ;
- Ctrl+C pendant une étape ponctuelle ;
- enfant persistant mort immédiatement ;
- parent tué, enfant encore vivant ;
- petit-enfant ffmpeg ;
- kill refusé : fiche conservée ;
- PID réutilisé : aucun processus étranger tué ;
- deux stop simultanés ;
- open sur un autre service local : refus ;
- bundle : chaque verbe réellement lancé depuis le bundle.
### 19.3 Performance
- comparer P-01 et P-11 ;
- parsing de 10 000 compositions synthétiques sans croissance anormale ;
- stop p95 inférieur à 5 secondes pour des enfants coopératifs ;
- stop forcé borné par le délai documenté ;
- zéro processus survivant ;
- surveillance des processus sous 1 % d’un cœur au repos ;
- démarrage d’une composition sans régression supérieure à 10 % hors
bootstrap retiré à l’étape suivante.
### 19.4 Critère de sortie
I-11, I-12, I-13, I-14 et I-22 sont fermés. Les tests de processus passent
sur Windows, Linux et macOS sans survivant.
## 20. Étape 8 — Découpage, bootstrap et maintenabilité
### 20.1 Périmètre
8.1. Extraire auth.
8.2. Extraire les modèles et adaptateurs de sources.
8.3. Extraire le registre.
8.4. Extraire le moteur de téléchargement.
8.5. Extraire la CLI et le superviseur.
8.6. Garder blink2video.py comme lanceur léger.
8.7. Retarder le bootstrap jusqu’au besoin réel du verbe.
8.8. Garantir help, version, open, stop et update sans dépendances Blink.
8.9. Verrouiller la création du venv.
8.10. Vérifier les dépendances depuis l’interpréteur cible.
8.11. Contraindre les versions testées.
8.12. Retirer code mort et règles d’ignore obsolètes.
8.13. Remplacer progressivement print par une journalisation structurée.
### 20.2 Non-régression
- import de chaque module sans bootstrap implicite ;
- stop fonctionnel avec aiohttp et blinkpy absents ;
- help et version hors ligne ;
- venv créé simultanément par deux processus sans corruption ;
- bundle complet ;
- documentation générée cohérente avec les parseurs.
### 20.3 Performance
- comparer P-01 et P-10 ;
- help, version, open et stop au moins 30 % plus rapides en mode source, ou
justification basée sur le baseline ;
- aucun import de ffmpeg/aiohttp/blinkpy pour les commandes qui n’en ont pas
besoin ;
- mémoire de démarrage réduite et mesurée.
### 20.4 Critère de sortie
O-06 à O-10 sont fermés. Le point d’entrée ne contient plus de logique métier
et chaque sous-système possède sa suite unitaire.
## 21. Étape 9 — Qualification finale et release
### 21.1 Matrice
9.1. Python 3.11 et 3.12.
9.2. Windows 10/11 x86-64.
9.3. Linux glibc pris en charge.
9.4. macOS Apple Silicon.
9.5. Sources et bundle PyInstaller.
9.6. CWD extérieur.
9.7. BLINK_HOME absent puis personnalisé.
9.8. Dossier programme en lecture seule.
9.9. 0, 1, 10 000 et 100 000 événements.
9.10. 600 clips cloud.
9.11. Backlog USB supérieur à dix minutes.
9.12. Multi-hubs, caméras renommées et noms collisionnants.
9.13. Crashs injectés à chaque frontière commit/rename.
9.14. Test réel opt-in avec secrets locaux, exclu de la CI.
9.15. Installation neuve sans session, puis onboarding simple et 2FA.
9.16. Session expirée, navigateur indisponible, annulation et timeout.
9.17. Démarrage interactif, headless et autostart sans ouverture répétée du
navigateur.
9.18. Sans argument et start en source et dans le bundle, avec le même plan de
lancement.
### 21.2 Critère de release
- zéro anomalie B ouverte ;
- zéro anomalie I sans décision explicite ;
- suite complète verte sur la matrice ;
- aucune donnée réelle touchée par la CI ;
- zéro processus survivant ;
- migrations testées sur une copie réaliste ;
- rapport de performance avant/après publié ;
- aucun benchmark critique au-delà du budget de régression ;
- onboarding validé sans secret dans les fichiers ou journaux ;
- aucun worker permanent lancé avant validation du mini-smoke ;
- README français et anglais mis à jour.
## 22. Protocole de performance
### 22.1 Règles de mesure
Les résultats avant et après doivent être pris :
- sur la même machine et le même mode d’alimentation ;
- avec les mêmes versions de Python et dépendances ;
- avec les mêmes jeux de données générés et leur graine ;
- sans réseau Internet, sauf test explicitement réel ;
- avec la graine de référence 20260813 ;
- après trois passages de chauffe ;
- sur vingt passages pour les mesures courtes ;
- sur au moins cinq passages pour les scénarios lourds ;
- en conservant médiane, p95, minimum, maximum, écart-type et coefficient de
variation ;
- avec un intervalle de confiance bootstrap à 95 % pour toute amélioration
revendiquée ;
- avec time.perf_counter_ns pour le temps ;
- avec mémoire RSS maximale de tout l’arbre de processus et, si utile,
tracemalloc ;
- avec nombre d’appels HTTP, octets lus/écrits et taille du stockage ;
- en JSON brut plus un résumé Markdown.
Une comparaison est invalide si l’environnement ou le jeu de données diffère.
Elle est également invalide si le résultat fonctionnel est incorrect. Une
campagne est rejouée si le coefficient de variation dépasse 5 % sans cause
expliquée.
Pour une zone non ciblée par l’étape, la médiane ne doit pas régresser de plus
de 5 %, et le p95 ou la RSS de plus de 10 %. Une amélioration n’est déclarée que
si son intervalle de confiance confirme le seuil annoncé.
### 22.2 Jeux de données
| Jeu | Événements registre | Clips cloud | Taille médias | Usage |
|---|---:|---:|---:|---|
| S | 100 | 25 | 1–10 Mio | tests rapides |
| M | 10 000 | 500 | 10–100 Mio | benchmark courant |
| L | 100 000 | 5 000 | métadonnées seules | index et migration |
| STREAM | 1 | 1 | 1 Gio synthétique | mémoire et débit |
| RACE | 10 000 | — | — | 20 processus |
Scénarios transversaux :
| ID | Contenu |
|---|---|
| J0 | cloud vide, un clip, aucun hub, JSON null, tableau et entrées invalides |
| J1 | HTTP 200/206/401/404/429/500, HTML, JSON, vide et MP4 tronqué |
| J2 | deux sources, ordre permuté et événements aux bornes de tolérance |
| J3 | 100 000 entrées, fichiers absents, exclusions et entrées héritées |
| J4 | 20 processus, 1 000 mutations chacun et barrières de course |
| J5 | 2 501 clips paginés puis 0, 1 et 100 nouveautés |
| J6 | 32 clips de 32 Mio et flux de 512 Mio coupé à 25, 50 et 99 % |
| J7 | arbre de processus profondeur 3 et erreurs injectées à chaque spawn |
| J8 | échelles 1 000, 10 000, 50 000 et 100 000 pour la complexité |
Les données doivent inclure :
- deux sources ;
- plusieurs hubs ;
- noms Unicode et collisionnants ;
- événements à ±2 secondes ;
- IDs renumérotés ;
- exclusions ;
- fichiers absents, vides et tronqués ;
- dates invalides isolées ;
- plus de 20 pages cloud.
### 22.3 Benchmarks
#### P-01 — Démarrage CLI
Commandes :
- sans argument ;
- --help ;
- --version ;
- open sur port fermé ;
- stop sans instance.
Mesures : temps total, RSS maximale, modules lourds importés.
#### P-02 — Écriture d’un lot
Insérer 100, 1 000, 10 000 puis 100 000 événements.
Mesures : temps total, p95 par commit, octets écrits, croissance du stockage.
#### P-03 — Mise à jour unitaire
Mettre à jour un clip et une tombstone dans un registre de taille croissante.
Mesures : latence p50/p95 et octets écrits.
#### P-04 — Chargement et vérification de l’acquis
Charger le registre puis vérifier 1 000 clips, dont réindexés, absents et
exclus.
Mesures : temps, lectures disque et erreurs.
#### P-05 — Rapprochement USB/cloud
Rapprocher 5 000 clips cloud avec 100 000 événements locaux.
Mesures : temps, RSS, doublons vrais, faux positifs, faux négatifs.
#### P-06 — Scan cloud
Simuler 600 clips la première fois, puis zéro, un et dix nouveaux clips.
Mesures : appels HTTP, pages, octets de métadonnées et temps.
#### P-07 — Streaming média
Télécharger depuis un serveur local des médias de 10 Mio, 100 Mio et 1 Gio.
Mesures : débit, RSS maximale, temps, taille/hash, nettoyage après coupure.
#### P-08 — Concurrence registre/verrou
Vingt processus effectuent des claims, commits et exclusions.
Mesures : débit, attente p95, conflits attendus, pertes et erreurs.
#### P-09 — Parallélisme des sources
Comparer :
- même hub USB contre direct ;
- deux hubs USB distincts ;
- USB contre cloud.
Mesures : durée murale, chevauchements autorisés, erreurs System is busy.
#### P-10 — Authentification enregistrée
Simuler charge, refresh et sauvegarde par plusieurs clients.
Mesures : latence, écritures disque, version finale et erreurs.
#### P-11 — Supervision et arrêt
Lancer parent, enfants et petits-enfants coopératifs ou récalcitrants.
Mesures : temps d’arrêt p50/p95, appels système, CPU au repos, survivants.
#### P-12 — Onboarding et time-to-ready
Scénarios :
- session valide ;
- session absente ;
- session expirée ;
- login simple ;
- login avec 2FA ;
- mauvais identifiants ;
- navigateur indisponible ;
- mini-smoke en échec.
Mesures :
- temps local avant la décision d’authentification ;
- temps avant disponibilité de la page ;
- temps distant d’authentification, rapporté séparément ;
- temps du mini-smoke ;
- temps avant lancement des workers ;
- temps total avant interface prête ;
- nombre de sessions et d’appels Blink ;
- ouvertures du navigateur ;
- RSS et CPU pendant l’attente ;
- enfants, serveurs et verrous survivants après chaque sortie.
Le temps actuel de blink2video sans argument, qui affiche seulement l’aide,
n’est pas comparé directement au nouveau time-to-ready. La comparaison
pertinente est :
- blink2video contre start avec le nouveau préflight ;
- start avant/après hors temps réseau ;
- commandes --help et --version avant/après, qui doivent rester légères.
### 22.4 Budgets d’acceptation initiaux
Les seuils relatifs priment sur les seuils absolus tant que le baseline n’est
pas enregistré.
| Indicateur | Budget |
|---|---|
| Tests de correction | zéro échec |
| Tombstones perdues | zéro |
| Doublons sur jeux corrélés | zéro |
| Faux MP4 acceptés | zéro |
| Processus survivants après stop | zéro |
| Course de verrou gagnée par plusieurs processus | zéro |
| Régression d’une opération non ciblée | moins de 10 % |
| Débit de streaming local | au moins 95 % du baseline brut |
| RSS supplémentaire pour flux 1 Gio | moins de 32 Mio |
| Appels cloud sans nouveauté | réduction d’au moins 90 % |
| Écriture de 10 000 événements | gain d’au moins 5× |
| Rapprochement à grande échelle | gain d’au moins 5× |
| CPU des boucles au repos | moins de 1 % d’un cœur |
| Arrêt coopératif p95 | moins de 5 secondes |
| Page d’onboarding locale prête | moins de 500 ms en p95 |
| Tests auth par lancement | exactement un |
| Mini-smoke par lancement réussi | exactement un |
| Ressources survivantes après onboarding en échec | zéro |
Après l’étape 0, ces budgets seront complétés par les nombres absolus observés.
### 22.5 Fréquence des campagnes
- à chaque commit fonctionnel : J0 à J2 et benchmarks courts touchés ;
- avant la clôture d’une étape : tous les tests et benchmarks annoncés dans
cette étape, sur la révision de départ et la révision candidate ;
- sur chaque pull request : J0 à J6, avec comparaison automatique au baseline ;
- chaque nuit : J3 à J8 et scénarios multi-processus lourds ;
- avant release : matrice de l’étape 9, endurance d’au moins douze heures et
campagne complète multi-OS ;
- après toute variation supérieure aux seuils : répétition sur machine au repos
et conservation des deux campagnes avec l’explication retenue.
Le rapport d’une étape doit distinguer un gain mesuré, une absence de régression
et une mesure non concluante. « Plus rapide » ne peut être écrit sans valeurs
avant/après, unité, taille d’échantillon et intervalle de confiance.
## 23. Matrice de traçabilité
| Anomalie | Étape | Tests principaux | Benchmarks |
|---|---:|---|---|
| B-01 | 1 | T-B01 | P-01 |
| B-02 | 1 fermé, streaming/mémoire (O-01) toujours ouvert, voir 28.11 | T-B02-* | P-06, P-07 |
| B-03 | 2 | T-B03-* | P-04, P-05 |
| B-04 | 3 (fermé le 17/08, option légère, voir 28.8) | T-B04-* | P-02, P-03, P-08 |
| B-05 | 3 (fermé le 17/08 hors T-B05-PID-REUSE, voir 28.8) | T-B05-* | P-08 |
| B-06 | 6 (fermé le 17/08, voir 28.11) | T-B06-* | P-09 |
| I-01 à I-03 | 4 (fermé le 17/08, voir 28.9) | auth/data-root | P-01, P-10 |
| I-04, I-05 | 2 | ordre sources/réparation | P-04, P-05 |
| I-06 à I-08 | 6 | manifests/pagination | P-06, P-09 |
| I-09, I-10 | 1 | cloud-only/JSON | P-01 |
| I-11, I-12 | I-12 fermé étape 1, I-11 fermé le 17/08 (voir 28.12) | grammaire/boucles | P-01 |
| I-13, I-14 | fermés le 17/08 (voir 28.12) | supervision/stop | P-11 |
| I-15 | 6 (fermé le 17/08 pour la validation ftyp, voir 28.11 ; probe complet non retenu) | intégrité/stream | P-07 |
| I-16, I-20, I-21 | 2 | identité/matching | P-05 |
| I-17 | 6 (fermé hors séquence le 17/08, voir 28.6) | erreurs transitoires | P-06, P-09 |
| I-18, I-19 | 1 | compteurs/options | P-04 |
| I-22 | 1 fermé (validation de port) ; endpoint de santé signé non retenu, voir 28.12 | port/health | P-01 |
| E-01 | 5 (livré en version resserrée le 17/08, voir 28.10) | T-E01-* | P-01, P-10, P-12 (non mesurés) |
| O-01 à O-05 | 3 et 6 (O-05 fermé hors séquence le 17/08, voir 28.6) | performance | P-02 à P-09 |
| O-06 à O-10 | O-06/O-09 fermés le 17/08 (voir 28.13) ; O-07 (pin blinkpy), O-10 (gitignore verrous) non traités | imports/bundle | P-01 |
## 24. Décisions à prendre avant les étapes concernées
### D-01 — SQLite ou JSON renforcé
Recommandation : SQLite WAL. La combinaison multi-processus, exclusions,
watermarks, claims et index temporels justifie un stockage transactionnel.
Décision requise avant l’étape 3.
### D-02 — Racine de données
Définir si un --output relatif est résolu :
- sous BLINK_HOME ;
- ou sous le CWD comme exception explicite.
Recommandation : sous la racine de données, avec possibilité d’un chemin absolu.
Décision requise avant l’étape 4.
### D-03 — Identifiant stable de caméra
Vérifier quels identifiants device_id/network_id sont disponibles dans les deux
sources. Définir le fallback lorsque l’API ne fournit pas le même identifiant.
Décision requise pendant l’étape 2.
### D-04 — Propriétaire des tokens
Choisir entre :
- un processus propriétaire de la session ;
- plusieurs clients coordonnés par génération ;
- un petit broker local.
La solution la plus simple compatible avec blinkpy doit être éprouvée par un
test de rotation de refresh token.
Décision requise avant l’étape 4.
### D-05 — Validation vidéo
Choisir le niveau :
- statut + taille + signature MP4 ;
- ou probe ffmpeg systématique.
Recommandation : validation légère au téléchargement, probe complet avant
inscription définitive si le coût P-07 reste acceptable.
Décision requise avant l’étape 6.
### D-06 — Propriétaire de l’onboarding et politique headless
Décider comment le parcours se comporte pour un lancement manuel, un bundle,
un terminal sans navigateur et l’autostart du système.
Recommandation :
- faire converger sans argument et start vers un coordinateur unique ;
- n’autoriser qu’un propriétaire du préflight et de l’onboarding ;
- ouvrir le navigateur une seule fois pour un lancement interactif ;
- en autostart/headless, servir la page sur loopback et publier une URL par le
canal disponible, sans boucle d’ouvertures de navigateur ;
- réutiliser une instance existante uniquement après vérification signée de son
endpoint de santé et de sa version ; sinon choisir un port éphémère libre ;
- implémenter le mini-smoke comme service de domaine partagé avec list, et non
comme sous-processus CLI qui rouvrirait une session et fausserait les mesures.
Décision requise avant l’étape 5.
## 25. Stratégie de migration et retour arrière
Toute migration d’état doit :
1. refuser un schéma source inconnu ;
2. créer une sauvegarde nommée et datée ;
3. écrire la cible dans un nouveau fichier ;
4. valider le nombre d’entrées, tombstones et chemins ;
5. remplacer atomiquement seulement après validation ;
6. être idempotente ;
7. conserver la sauvegarde jusqu’à une release suivante ;
8. fournir un outil de diagnostic ou d’export ;
9. ne jamais déplacer ni supprimer un média pendant la migration du registre.
Le rollback de code doit être documenté si l’ancien programme ne sait pas lire
le nouveau format. Un export JSON version 1 en lecture seule peut servir de
pont, sans redevenir la source de vérité.
## 26. Définition globale de terminé
Le chantier est terminé lorsque :
- chaque anomalie de ce rapport possède un test ou une décision documentée ;
- tous les tests sont isolés des données réelles ;
- la CI couvre sources et bundle sur les plateformes prises en charge ;
- les migrations ont été testées sur des copies réalistes ;
- les processus ne survivent jamais à stop ;
- les fichiers invalides ne sont jamais inscrits comme acquis ;
- les exclusions sont préservées sous concurrence et après crash ;
- les appels cloud et la mémoire de téléchargement ont diminué selon les
budgets ;
- les benchmarks avant/après sont archivés et reproductibles ;
- blink2video sans argument et blink2video start exécutent le même parcours ;
- une session absente ou invalide peut être établie depuis la page locale ;
- le mini-smoke read-only réussit avant tout lancement permanent ;
- l’onboarding ne laisse ni secret, ni serveur, ni processus après sa sortie ;
- les deux README reflètent le comportement final ;
- un test réel opt-in confirme auth, USB et cloud sans publier de secret.
## 27. Ordre de démarrage recommandé
Le prochain travail doit commencer strictement ainsi :
1. ouvrir l’étape 0 uniquement ;
2. écrire T-B01 à T-B06, T-E01 et les tests JSON/CLI/processus ;
3. enregistrer P-01 à P-12 sur la révision auditée ;
4. publier le premier tableau baseline ;
5. seulement ensuite commencer l’étape 1 ;
6. ne pas mélanger refactor structurel et correction P0 dans le même changement ;
7. fermer chaque étape avec ses résultats de tests et de performance.
## 28. Journal d’exécution des étapes
Ce journal est la source de vérité de l’avancement. Une étape ne peut passer à
« terminée » que si un compte rendu détaillé, conforme aux sections 11.1 à
11.3, est ajouté sous son entrée. Un résultat défavorable ou non concluant doit
être enregistré ; il ne doit jamais être remplacé par une appréciation sans
mesure.
État au 13 août 2026 : l’audit et le plan ont été rédigés, mais aucune étape de
correction n’a commencé. Aucun benchmark de référence complet n’a donc été
enregistré et aucune amélioration de performance n’est revendiquée.
| Étape | Intitulé | Statut initial | Tests à consigner | Mesures à consigner |
|---:|---|---|---|---|
| 0 | Banc de sécurité et baseline | en cours — filet installé, baseline non qualifiée | historique, T-B01 à T-B06, T-E01, JSON/CLI/processus | P-01 à P-12 baseline |
| 1 | Correctifs de sûreté immédiats | implémentée localement — CI multi-OS à confirmer | section 13.2 | P-01, P-06, P-07 |
| 2 | Identité, déduplication et réparation | implémentée localement — CI multi-OS à confirmer | 24 tests dédiés, section 28.5 | P-04 et P-05 rapides |
| 3 | Persistance transactionnelle et verrous | implémentée localement (option légère JSON+verrou atomique, D-01 non suivie, voir 28.8) — CI multi-OS à confirmer | section 15.2 | P-02, P-03, P-08, P-09 (non mesurés formellement) |
| 4 | Racine de données et authentification | implémentée localement (voir 28.9) — CI multi-OS à confirmer | section 16.2 | P-01, P-10 (non mesurés formellement) |
| 5 | Démarrage convivial et onboarding web | implémentée en version resserrée (voir 28.10) — CI multi-OS à confirmer | T-E01-* | P-01, P-10, P-12 (non mesurés) |
| 6 | Adaptateurs USB/cloud et entrées-sorties | partielle : B-06/I-15 fermés en périmètre resserré (voir 28.11), reste du périmètre (18.1) non commencé | section 18.2 | P-06, P-07, P-09, P-10 (non mesurés) |
| 7 | Grammaire CLI et supervision | partielle : I-11/I-13/I-14 fermés en périmètre resserré (voir 28.12) | section 19.2 | P-01, P-08, P-11 (non mesurés) |
| 8 | Refactor modulaire et optimisation du démarrage | implémentée en périmètre complet (8.1-8.9, 8.12), 8.10/8.11/8.13 différés (voir 28.13) — CI multi-OS à confirmer | section 20.2 | P-01, P-10 (non mesurés) |
| 9 | Qualification finale et release | partielle : volumétrie 100 000 événements et README vérifiés localement, matrice CI déclenchée (voir 28.14) — reste hors de portée d'une session locale | matrice 21.1 | P-02 à P-05 mesurés à 100k |
### 28.1 Travail préparatoire P-00 — Sauvegarde et checkpoint
Statut : **terminé le 13 août 2026**. Ce travail prépare l’ouverture de l’étape
0 ; il ne constitue ni sa baseline de performance ni sa clôture.
Révision source au départ : `58f5566`, tag `v0.5.5`, branche `main` alignée sur
`origin/main`. Le seul fichier de travail initial était le présent rapport,
encore non suivi.
#### Travail effectué
1. lecture de la fiche d’instance et comparaison des PID avec les processus OS ;
2. vérification que le parent 5080 et ses cinq enfants Python dataient tous de
13:35, conformément à la fiche ;
3. arrêt applicatif obtenu avec un code de sortie nul ;
4. copie locale des originaux, exclusions et états vers
`C:\Users\Nico\Documents\blink-backups\2026-08-13_pre-remediation_v0.5.5` ;
5. détection par empreinte d’une modification concurrente de
`.blink_passages.json` : une nouvelle composition avait démarré à 16:01,
avec le parent 7424 et cinq enfants ;
6. inventaire puis retrait temporaire des deux raccourcis de démarrage
`blink2video-start.lnk` et `blink2video-serve.lnk` ;
7. arrêt de la seconde composition, resynchronisation de deux fichiers pour
14,20 Mio, puis nouvelle vérification intégrale ;
8. restauration à l’identique des deux raccourcis d’autostart ; leur commande
a été contrôlée et ils reprendront effet à la prochaine ouverture de session ;
9. création de la branche `audit-remediation` depuis `58f5566` ;
10. préparation du checkpoint Git `pre-remediation-2026-08-13`, destiné à
pointer le commit contenant ce rapport, puis publication de la branche et
du tag sur `origin` ;
11. maintien de l’application à l’arrêt après la sauvegarde afin de commencer
l’étape 0 depuis un état maîtrisé.
Le secret `blink_auth.json` n’a été copié ni dans le snapshot non chiffré ni
dans Git. Les répertoires `Blink_Normalized`, `Blink_Daily`, `Blink_Weekly` et
`Blink_Monthly`, qui représentaient 3 059 528 467 octets, ont été omis car ils
sont dérivés et régénérables. Les originaux et médias exclus ont été conservés.
#### Vérifications et mesures
| Vérification | Résultat | Verdict |
|---|---:|---|
| Processus correspondant à la première fiche | 6/6 | identité confirmée avant arrêt |
| Code de sortie du premier arrêt | 0 | réussi |
| Relance concurrente détectée | 1 composition, 6 processus | snapshot initial invalidé puis repris |
| Raccourcis d’autostart avant/après | 2/2 | configuration restaurée |
| Fichiers sauvegardés hors présent README | 90 | conforme à l’inventaire |
| Volume logique sauvegardé | 582 356 434 octets | conforme source/copie |
| Empreintes `Blink_Clips` | 67/67 identiques | réussi |
| Empreintes `Blink_Excluded` | 19/19 identiques | réussi |
| Empreintes des états et du journal | 4/4 identiques | réussi |
| Empreintes totales | 90/90 identiques | réussi |
| Copies non chiffrées de `blink_auth.json` créées | 0 | réussi |
| Fiches d’instance après préparation | 0 | application arrêtée |
| Espace libre restant sur `C:` après copie | 3 888 934 912 octets | suffisant pour le chantier, à surveiller |
Le premier contrôle des états avait donné 3 empreintes identiques sur 4. Ce
résultat n’a pas été masqué : il a permis de repérer la relance, d’invalider la
première photographie et de refaire le contrôle dans de bonnes conditions.
#### Limite de la sauvegarde
Le snapshot se trouve sur le même volume physique `C:`. Il protège d’une
régression logicielle ou d’une migration fautive, mais pas d’une panne du
disque. Une copie supplémentaire sur un support distinct et chiffré reste
recommandée. Le fichier `BACKUP-README.md` placé dans le snapshot documente son
contenu, ses exclusions et les précautions de restauration.
#### Verdict
Préparation acceptée : le code de production reste à la révision auditée, les
données irremplaçables disposent d’une copie vérifiée, les secrets restent hors
Git et l’étape 0 peut commencer. Aucun gain de performance n’est revendiqué à
ce stade.
### 28.2 Étape 0 — Ouverture du banc de sécurité et de la baseline
Statut : **en cours, à reprendre avant l’étape 1**.
Début : 13 août 2026.
Révision de départ du chantier : `4f23039`.
Révision de production mesurée : `58f5566` ; aucun fichier de production n’a
été modifié pendant cette ouverture.
#### Travail effectué
- ajout de `test_blink2video_audit.py`, suite `unittest` isolée portant les IDs
du rapport ;
- usage systématique de `BLINK_BOOTSTRAP=none`, d’un `BLINK_HOME` temporaire et
d’un CWD différent ;
- faux Blink, Sync, Clip, réponse HTTP, session, horloge/sommeil et appels
système ;
- courses multi-processus déterministes pour le verrou et tests de processus
sans viser une instance réelle ;
- ajout d’une garde prouvant qu’un `unexpected success` rend la CI rouge, et de
quatre spécifications E-01 ignorées explicitement tant que le
serveur d’onboarding de l’étape 5 n’existe pas ;
- durcissement de `tests.py` : sandbox posée avant les imports, commande unique
source/bundle, environnement hermétique pour tous les enfants, ports
dynamiques, vérification de la version et du BLINK_HOME servis, contrôle de
tous les enfants avant/après stop ;
- remplacement de la notification réelle de la CI par une simulation ; le
contrôle visuel reste uniquement dans `smoketest.py` manuel ;
- ajout dans `.github/workflows/ci.yml` d’une matrice de caractérisation Python
3.11/3.12 sur Windows, Linux et macOS ;
- ajout de `benchmarks/stage0_baseline.py`, de sa documentation et de sorties
JSON/Markdown conservant environnement, révision, empreintes des fichiers de
production, échantillons, médiane, p95, coefficient de variation et IC 95 % ;
- ajout des profils `quick`, `full` et `safe`, du filtrage `--only` et d’une
commande de comparaison ;
- distinction entre révision documentaire et HEAD/empreintes réellement
exécutés ; noms de campagne horodatés, écriture sans écrasement et refus des
comparaisons dont profil, protocole, environnement, scénarios ou validité
diffèrent ;
- ajout d’un délai hors chronométrage entre les 120 créations de processus de
P-01 `quick`, avec décompte explicite dans le résultat ;
- serveur média HTTP loopback pour P-07, générateurs déterministes de registres
et manifests, charge multi-processus P-08, fausses E/S P-09, faux tokens P-10
et arbre de processus local P-11 ;
- conservation explicite des scénarios P-12 non disponibles, sans leur donner
artificiellement une durée nulle.
#### Bilan des tests
| Suite/scénario | Résultat | Durée observée | Verdict |
|---|---:|---:|---|
| Compilation de `tests.py`, `test_blink2video_audit.py` et du benchmark | 3/3 | 0,5 s | réussi |
| `docs.py --check` | 2 README à jour | 4,9 s | réussi |
| Suite dédiée finale | 33 tests : 7 réussis, 22 échecs attendus, 4 ignorés E-01 | 1,717 s | réussi comme caractérisation |
| Verbes, notification simulée et relance de la suite historique | 20 assertions | mesure ciblée agent | réussi |
| Composition, endpoint, enfants et stop | 9 assertions | 9,3 s | réussi |
| `tests.py` complet, bac à sable et chaîne ffmpeg | 70 contrôles observés | 25,4 s | réussi, zéro processus/fiche survivant |
| Validation YAML du workflow CI | 1 fichier | < 1 s | réussi |
| Ruff sur les trois fichiers Python de l’étape 0 | 0 erreur | 0,3 s | réussi |
| `git diff --check` | 0 erreur | < 1 s | réussi |
| Bundle réel sur les trois OS | non exécuté localement | — | confié à la CI, restant |
Les 22 échecs attendus reproduisent B-01 à B-06 et les défauts I-01, I-05,
I-07, I-09 à I-14 et I-16 sélectionnés. `unittest` considère tout succès
inattendu comme un échec : lorsqu’un défaut sera corrigé, son marqueur devra
être retiré dans le même changement. Les quatre tests E-01 ignorés décrivent
login web simple, 2FA, annulation/timeout/navigateur absent et ordre
mini-smoke/workers.
#### Incident de mesure et décision de sécurité
La campagne `quick-run-a` a exécuté le protocole 3 chauffes, 20 mesures courtes
et 5 lourdes. Pendant P-01, les 115 créations de processus ont amené
`MsMpEng` à 100 % selon le constat de l’utilisateur. Le processus de benchmark
avait terminé lorsque son arrêt a été vérifié. L’antivirus n’a pas été arrêté
ni désactivé par le chantier. L’utilisateur a ensuite indiqué avoir désactivé
la protection en temps réel ; aucune campagne de performance n’a été lancée
dans cet état, car ses résultats ne représenteraient pas le fonctionnement
normal. La réactivation immédiate de la protection a été recommandée.
Les résultats bruts sont conservés dans
`benchmarks/results/stage0-baseline-20260813-quick-run-a.*`, avec le statut
`invalid`. Tous ses temps et mesures de ressources sont interdits pour une
comparaison avant/après. Ses constats fonctionnels restent utiles :
- P-04 : 100 erreurs sur 1 000 vérifications, toutes liées aux 100 clips
renumérotés ;
- P-08 : 8 gagnants sur 8 lors de chacun des 5 essais de verrou ;
- P-09 : absence de chevauchement des deux hubs et du cloud simulés ;
- P-10 : erreurs concurrentes par essai `[0, 1, 0, 1, 7]` ;
- P-11 : aucun survivant sur les cinq arbres locaux coopératifs ;
- P-12 : sans argument observé comme `help_and_exit`, onboarding indisponible.
Pour poursuivre sans créer de rafale de processus, trois campagnes internes
A, B et C ont mesuré P-02 à P-07, P-09 et P-10. Le rapport détaillé est dans
`benchmarks/results/stage0-stability-20260813.md`.
| Mesure | Campagne B | Campagne C | Delta | Seuil | Verdict |
|---|---:|---:|---:|---:|---|
| P-02, 100 entrées | 2,677 ms | 2,857 ms | +6,72 % | < 10 % | stable |
| P-02, 1 000 entrées | 15,233 ms | 29,975 ms | +96,78 % | < 10 % | instable |
| P-02, 5 000 entrées | 73,521 ms | 94,454 ms | +28,47 % | < 10 % | instable |
| P-03, mise à jour sur 5 000 | 78,877 ms | 112,758 ms | +42,95 % | < 10 % | instable |
| P-04, 1 000 vérifications | 85,466 ms | 94,525 ms | +10,60 % | < 10 % | instable |
| P-05, 5 000 × 500 | 48,035 ms | 55,529 ms | +15,60 % | < 10 % | instable |
| P-06, 600 clips | 1,424 ms | 1,865 ms | +30,99 % | < 10 % | instable |
| P-07, 1 Mio | 6,996 ms | 5,907 ms | -15,56 % | < 10 % | instable |
| P-07, 8 Mio | 27,136 ms | 24,382 ms | -10,15 % | < 10 % | instable |
| P-09, deux hubs et cloud | 93,850 ms | 93,937 ms | +0,09 % | < 10 % | stable |
| P-10, huit écrivains | 5,729 ms | 5,315 ms | -7,22 % | < 10 % | stable |
| P-10, sauvegarde série | 0,837 ms | 0,764 ms | -8,69 % | < 10 % | stable |
Seuls 4 scénarios sur 12 sont stables entre B et C. Aucune valeur n’est donc
promue au rang de baseline qualifiante et aucune amélioration de performance
n’est revendiquée.
#### Écarts au plan et reste à faire
- P-07 a été borné à 8 Mio au lieu de 1 Gio par prudence lors de la conception
initiale ; le profil complet garde le scénario 1 Gio ;
- P-01, P-08, P-11 et la partie mesurable de P-12 doivent être rejoués sans
saturation antivirus, mais sans désactiver la protection ;
- le faux navigateur et le serveur d’onboarding pilotable restent différés,
l’API cible n’existant pas avant l’étape 5 ;
- la preuve systématique d’absence de secrets dans tous les journaux du futur
onboarding reste à écrire à l’étape 5 ;
- les bundles et la matrice multi-OS doivent passer ;
- deux campagnes successives qualifiantes doivent rester sous 10 % d’écart.
#### Verdict
**À reprendre, étape 0 maintenue en cours.** Le filet de caractérisation et
l’outil de mesure sont installés, mais les critères de sortie ne sont pas tous
satisfaits. À la demande de l’utilisateur, qui a donné priorité aux correctifs
avant épuisement du quota hebdomadaire, l’étape 1 a néanmoins été ouverte avec
des points avant/après P-01, P-06 et P-07 strictement ciblés. Cette décision ne
qualifie pas la baseline globale et n’autorise aucune revendication de gain sur
les scénarios non rejoués.
### 28.3 Bloc à ajouter lors de la réalisation de chaque étape
Chaque étape reçoit un sous-titre daté et conserve au minimum :
1. statut, dates, révisions avant/après et environnement ;
2. travail effectivement réalisé, fichier par fichier ;
3. décisions prises et écarts au plan ;
4. tests ajoutés, résultats avant/après, durée et éventuels échecs ;
5. mesures brutes et synthèse baseline/après/delta/IC 95 % ;
6. comparaison aux budgets et analyse des régressions ;
7. anomalies fermées, risques restants et rollback disponible ;
8. verdict explicite : acceptée, à reprendre ou bloquée.
Le statut du tableau ci-dessus et le compte rendu détaillé doivent être modifiés
dans le même changement que la clôture de l’étape. Les artefacts JSON bruts de
tests et benchmarks sont référencés depuis le compte rendu au lieu d’être
recopiés ou résumés sans preuve.
### 28.4 Étape 1 — Correctifs de sûreté immédiats
Statut : **implémentée et validée localement le 13 août 2026 ; qualification
CI sur trois OS encore requise pour la clôture formelle**.
Révision de départ : `6c283d8`.
Révision portant le code et les tests : `38269ca`.
Protection en temps réel Microsoft Defender : active pendant les mesures.
Checkpoint Git du journal : `4f67817`.
#### Travail effectué
- `blink2video.py` :
- `traiter_cloud` retourne toujours un `CloudResult` homogène ;
- les compteurs `downloaded`, `adopted`, `skipped` et `failed` sont séparés ;
- un cloud vide ou un filtre sans résultat est un succès à quatre compteurs
nuls ;
- seuls les statuts HTTP 2xx sont acceptés comme média ; un corps HTML/JSON
de 404, 429 ou 500 n’est plus enregistré comme MP4 ;
- le corps d’erreur est consommé pour permettre la réutilisation de la
connexion, puis tout fichier partiel est supprimé ;
- `--since 0` désigne maintenant le jour courant ; `None` conserve
explicitement trente jours ;
- les racines et entrées JSON cloud/registre/session sont validées avant tout
accès ; une entrée corrompue est isolée sans abattre le passage ;
- `login` et la source cloud fonctionnent sans Sync Module ; la source USB
explique au contraire qu’un module est requis ;
- les erreurs par clip sont catégorisées (`HTTP`, `réseau`, `données`), sans
afficher de trace ni de contenu distant ;
- `--overwrite` est défini comme un retéléchargement forcé de tous les clips
visibles, y compris ceux déjà acquis ;
- `open --port` emploie le validateur partagé.
- `runtime.py` : ajout de validateurs argparse communs pour une cadence
strictement positive, un nombre de jours positif ou nul et un port compris
entre 1 et 65535.
- `serve.py` et `watch.py` : validation partagée du port ; toutes les boucles
utilisant `runtime.ajouter_boucle` refusent désormais zéro et les valeurs
négatives.
- `test_blink2video_audit.py` : scénarios détaillés HTTP 404/429/500, JSON
`[]`/`null`/entrées malformées, fenêtres temporelles, cloud vide, cloud sans
hub, compteurs et ports. Dix-huit marqueurs `expectedFailure` ont été retirés
seulement après succès réel des correctifs correspondants.
- `test_runtime_validation.py` : neuf tests unitaires des bornes et parseurs.
- `.github/workflows/ci.yml` : les deux suites isolées passent dans la matrice
Python 3.11/3.12 sur Windows, Linux et macOS.
- `benchmarks/stage0_baseline.py` : correction du nom des artefacts ; le point
présent dans l’horodatage UTC ne tronque plus profil et libellé.
#### Décisions et écarts au plan
- aucun nouveau drapeau `--force` n’est ajouté : `--overwrite` porte cette
sémantique afin de ne pas créer deux synonymes ;
- la réparation automatique d’un média cloud absent du disque reste I-05 et
appartient à l’étape 2 ; elle n’est pas présentée comme corrigée ici ;
- la pagination au-delà de `stop=20`, le streaming par blocs et la validation
MP4 approfondie restent à l’étape 6 ;
- la baseline globale de l’étape 0 reste non qualifiée. Les mesures ci-dessous
sont des campagnes partielles comparables, destinées uniquement à contrôler
ce lot.
#### Tests et vérifications
| Contrôle | Résultat | Durée | Verdict |
|---|---:|---:|---|
| Suites isolées combinées | 54 tests : 38 réussis, 12 défauts futurs attendus, 4 E-01 ignorés | 1,690 s | réussi |
| Chaîne historique `tests.py` | 70 contrôles observés | 23,5 s | réussi |
| Compilation des 7 fichiers Python touchés/outillage | 7/7 | 0,5 s | réussi |
| Ruff, règles d’erreur hors dettes préexistantes E402/F401 | 0 erreur | 0,3 s | réussi |
| Cohérence des README | 2/2 | 0,4 s | réussi |
| Validation YAML CI | 1 workflow | 0,4 s | réussi |
| Nettoyage après tests | 0 processus Python, 0 fiche `.blink_run` | < 1 s | réussi |
Tous ces tests sont isolés par `BLINK_BOOTSTRAP=none`, `BLINK_HOME` temporaire,
faux Blink/réponses HTTP et loopback local. Aucun compte, identifiant, navigateur
ou accès Internet réel n’a été utilisé.
#### Mesures P-01 — démarrage CLI prudent
Profil `safe`, deux mesures par scénario, aucun échauffement, un contrôle
sémantique et une pause de 500 ms entre créations : 15 processus par campagne.
| Scénario | Avant | Après | Delta | Budget | Verdict |
|---|---:|---:|---:|---:|---|
| sans argument | 624,285 ms | 669,744 ms | +7,28 % | < +10 % | conforme |
| `--help` | 613,738 ms | 663,346 ms | +8,08 % | < +10 % | conforme |
| `--version` | 626,055 ms | 658,677 ms | +5,21 % | < +10 % | conforme |
| `open` sur port fermé | 2 618,987 ms | 2 679,289 ms | +2,30 % | < +10 % | conforme |
| `stop` sans instance | 706,102 ms | 684,048 ms | -3,12 % | < +10 % | conforme |
La campagne prudente prouve l’absence de régression au seuil de l’étape 1 ; son
faible effectif ne permet pas d’attribuer les variations à une optimisation.
#### Mesures P-06 — parsing du manifeste cloud simulé
Profil `quick`, cinq mesures par scénario après trois échauffements, sans réseau.
| Scénario | Avant | Après | Delta | Verdict |
|---|---:|---:|---:|---|
| première passe, 600 entrées | 9,928 ms | 5,472 ms | -44,89 % | gain observé, non attribuable |
| seconde passe, 0 nouvelle | 5,488 ms | 5,590 ms | +1,87 % | absence de régression |
| seconde passe, 1 nouvelle | 9,670 ms | 5,470 ms | -43,43 % | gain observé, non attribuable |
| seconde passe, 10 nouvelles | 7,096 ms | 5,784 ms | -18,49 % | gain observé, non attribuable |
Le faux fournisseur rend l’inventaire complet en un appel. Il mesure le parseur
de production mais pas la pagination du fournisseur ; `stop=20` reste donc un
écart connu et P-06 n’est pas promu comme baseline qualifiante.
#### Mesures P-07 — téléchargement HTTP loopback
Profil `quick`, cinq mesures après trois échauffements.
| Taille | Avant | Après | Delta temps | Débit avant | Débit après | Mémoire Python après |
|---:|---:|---:|---:|---:|---:|---:|
| 1 Mio | 6,991 ms | 5,799 ms | -17,05 % | 143,0 Mio/s | 172,4 Mio/s | 2,09 Mio |
| 8 Mio | 29,117 ms | 24,693 ms | -15,20 % | 274,8 Mio/s | 324,0 Mio/s | 16,10 Mio |
Le débit observé augmente et la mémoire reste pratiquement inchangée (+8 260
octets sur le scénario 8 Mio). Comme l’étape 1 ne change pas encore le modèle
d’entrée-sortie, ces valeurs satisfont le budget mais ne sont pas revendiquées
comme un gain structurel ; le streaming arrive à l’étape 6.
Artefacts bruts :
- avant P-06/P-07 : `stage0-baseline-20260813T155541.{json,md}` ;
- avant P-01 : `stage0-baseline-20260813T155617.{json,md}` ;
- après P-06/P-07 :
`stage0-baseline-20260813T160748.055435Z-quick-stage1-after.{json,md}` ;
- après P-01 :
`stage0-baseline-20260813T160757.129162Z-safe-stage1-after.{json,md}`.
#### Verdict et retour arrière
B-01 et la partie statut HTTP de B-02 sont fermés. I-07, I-09, I-10, I-12,
I-18 et la validation de port I-22 sont également fermés dans le périmètre de
cette étape. Tous les budgets locaux sont respectés et aucune régression de la
suite historique n’est observée.
**Verdict local : acceptée. Verdict formel : en attente de la matrice CI sur les
trois OS.** Le retour arrière du code consiste à revenir de `38269ca` à
`6c283d8` ; les artefacts avant/après permettent alors de reproduire la décision.
### 28.5 Étape 2 — Identité, déduplication et réparation
Statut : **implémentée et validée localement le 13 août 2026 ; qualification
CI sur trois OS encore requise pour la clôture formelle**.
Révision de départ : `4f67817`. Protection en temps réel Microsoft Defender :
active pendant les tests et mesures. Aucun compte Blink ni accès réseau réel.
Révision portant le code, les tests et les mesures : `fc254c0`.
#### Travail effectué
- le registre migre de v1 vers v2 de façon additive et idempotente ; anciennes
clés et archives restent lisibles, et le JSON v1 d'origine est conservé dans
`.blink_download_state.v1.backup.json` avant la première écriture v2 ;
- identité source et corrélation inter-source sont séparées : l'USB ignore l'ID
renuméroté du manifeste, le cloud conserve son ID distant, et réseau/module/
caméra stable empêchent les collisions multi-réseaux et multi-hubs ;
- le nom API brut sert au repli d'identité ; `safe_name` ne sert plus qu'aux
chemins. Les composants Windows réservés, séparateurs et longueurs sont
neutralisés, puis une empreinte SHA-256 courte distingue les valeurs qui
deviendraient identiques après assainissement ;
- `entry.path` est l'autorité pour un acquis : chemin relatif obligatoire,
contenu sous `output`, fichier ordinaire, taille non nulle et taille enregistrée
cohérente. Un média absent/tronqué est réparé ; une tombstone reste prioritaire,
y compris avec `--overwrite` ;
- la fusion JSON force une version monotone et empêche une copie downloader
périmée d'annuler une exclusion concurrente. La transactionnalité complète et
le remplacement du verrou restent volontairement à l'étape 3 ;
- le rapprochement USB/cloud est symétrique, bijectif et de cardinalité maximale.
Il emploie un index temporel `bisect` et Hopcroft-Karp itératif ; le passage USB
apparie désormais le lot entier au registre et consomme chaque match une fois ;
- un index éphémère du registre et de ses chemins accélère les vérifications. Il
n'est jamais sérialisé et est invalidé par les helpers d'écriture ;
- le cloud utilise le `network_id` propre à chaque clip et le filtre caméra
compare le nom original, de sorte que `A/B` ne sélectionne pas `A_B` ;
- `.github/workflows/ci.yml` exécute désormais `test_stage2_identity.py` dans la
matrice Windows/Linux/macOS × Python 3.11/3.12.
Limite documentée : blinkpy 0.25 n'expose pas toujours `device_id` ni
`network_id` sur les objets USB. Dans ce cas, la corrélation se replie sur le nom
API original ; un renommage USB ne peut donc pas être reconnu avec certitude.
#### Tests et vérifications
| Contrôle | Résultat | Durée | Verdict |
|---|---:|---:|---|
| Suite dédiée étape 2 | 27/27 | 0,198 s | réussi |
| Suites isolées combinées | 81 tests : 68 réussis, 9 défauts futurs attendus, 4 E-01 ignorés | 1,926 s | réussi |
| Chaîne historique `tests.py` | 70 contrôles observés | 23,5 s | réussi |
| Compilation Python et `git diff --check` | 0 erreur | < 1 s | réussi |
Les 27 contrats couvrent réindexation, ordre USB/cloud, cardinalité maximale,
deux événements proches, caméra renommée avec ID stable, noms collisionnants,
multi-réseaux/hubs, médias absents ou tronqués, exclusions avec overwrite,
chemins hostiles, migration/restauration, version monotone, filtres et limites
Windows. Tous emploient `BLINK_HOME` temporaire et de faux objets Blink.
#### Mesures P-04 — vérification d'un acquis
Profil `quick`, 1 000 vérifications dont 100 réindexations et 100 exclusions,
vingt mesures après trois chauffes.
| Mesure | Avant | Après | Delta | Verdict |
|---|---:|---:|---:|---|
| médiane | 87,894 ms | 82,810 ms | -5,78 % | gain observé |
| p95 | 128,844 ms | 87,242 ms | -32,29 % | gain observé |
| erreurs | 100/1 000 | 0/1 000 | -100 % | corrigé |
La médiane est 1,06× plus rapide tout en réparant les 100 faux négatifs de
réindexation. Le CV final de 2,83 % rend cette campagne locale stable.
#### Mesures P-05 — rapprochement USB/cloud
Profil `quick`, 5 000 événements locaux × 500 cloud, cinq mesures après trois
chauffes.
| Mesure | Avant | Après | Delta | Verdict |
|---|---:|---:|---:|---|
| médiane | 43,489 ms | 5,774 ms | -86,72 % | 7,53× plus rapide |
| doublons / inédits | 250 / 250 | 250 / 250 | identique | exact |
| pic heap Python | 48 096 o | 591 276 o | +543 180 o | coût accepté de l'index |
Le gain dépasse l'objectif initial de cinq fois sur le volume `quick` et la
complexité devient `O((N+M) log N + E)` hors appariement, au lieu du produit
complet `N×M`. La dispersion finale est notable (CV 22,60 %), mais l'intervalle
reste très inférieur à la référence ; cette campagne partielle n'est pas extrapolée au
profil 100 000 × 5 000 et ne remplace pas la qualification finale.
Artefacts bruts conservés :
- avant P-04/P-05 :
`stage0-baseline-20260813T161544.863841Z-quick-stage2-before.{json,md}` ;
- après P-04/P-05 sur le code final :
`stage0-baseline-20260813T170234.187443Z-quick-stage2-final-reviewed-indexed.{json,md}`.
#### Verdict et retour arrière
B-03, I-04, I-05, I-16, I-20 et I-21 sont fermés dans le périmètre local de
l'étape. B-04 n'est fermé que pour la conservation d'une tombstone lors de la
fusion ; ses courses interprocessus et B-05 restent à traiter à l'étape 3.
**Verdict local : acceptée. Verdict formel : en attente de la matrice CI sur les
trois OS.** Le retour arrière s'effectue vers `4f67817`; le registre v1 original
reste restaurable depuis sa sauvegarde avant toute écriture v2.
### 28.6 Correctifs hors séquence — retours d'usage réel du 17 août 2026
Statut : **implémenté et validé localement le 17 août 2026 ; qualification CI
sur trois OS encore requise pour la clôture formelle**, comme pour les étapes 1
et 2.
Contexte : cette intervention n'ouvre pas l'étape 3 prévue par l'ordre de
démarrage (section 27). Elle répond à une remontée directe de l'utilisateur
sur l'installation réellement en service (six processus actifs, composition
`start`), avant que le chantier ne reprenne l'étape 3. Huit symptômes ont été
rapportés et diagnostiqués :
1. horodatage incrusté absent sur des clips récents — rattaché à I-17 (retard
ou arrêt silencieux du worker de normalisation) ;
2. synchronisation cloud pas vraiment à la minute — rattaché à O-05 (dérive de
cadence) et à O-04 (pas de watermark, non traité ici) ;
3. ouverture de page lente — cause distincte identifiée, `ffmpeg -i` relancé en
synchrone à chaque requête pour tout clip écarté sans durée connue,
**non corrigée dans ce lot**, à traiter séparément (mise en cache du sondage) ;
4. bouton « Réessayer » du direct rendu inopérant par le message d'erreur
superposé — bug CSS, sans anomalie correspondante dans le rapport initial ;
5. le direct déclenche un nouvel enregistrement côté caméra — comportement du
firmware/cloud Blink, hors du périmètre de ce projet, aucune action possible ;
6. direct plus fluide sur téléphone que sur la page web — conséquence
structurelle du relais MJPEG côté serveur, non corrigée ;
7. absence de suppression de clip via l'API — choix de conception déjà
documenté (section 2), pas une anomalie ;
8. le verbe `start` reste actif en permanence — comportement voulu du
superviseur (section 10.6), pas une anomalie.
Seuls les points 1, 2 (partiellement) et 4 ont donné lieu à un correctif dans
ce lot, par choix explicite de l'utilisateur de traiter I-17 et O-05 en
priorité. Les points 3 et 6 restent des anomalies ouvertes, non numérotées
dans le rapport initial ; ils devront recevoir un identifiant lors de la
prochaine révision du rapport.
#### Travail effectué
- `runtime.py`, fonction `repeter()` (utilisée par `merge_daily.py --loop` et
`watch.py --loop`) :
- toute exception de `travail()` autre que `KeyboardInterrupt` est désormais
interceptée, journalisée, et le tour suivant a lieu normalement au lieu de
tuer le worker (I-17) ;
- l'échéance du prochain tour se calcule dès le début du tour courant via
`time.monotonic()`, au lieu de dormir la pleine durée après la fin du
travail : un tour plus lent que la cadence ne décale plus tous les
suivants d'autant (O-05).
- `blink2video.py`, fonction `boucler()` (boucle `download --loop`, seule
boucle de fond qui n'utilise pas `runtime.repeter` car asynchrone) :
- même durcissement : capture de toute erreur de tour hors `BusyError`
(I-17), échéance calculée depuis `time.monotonic()` avant le tour plutôt
qu'un `asyncio.sleep(args.loop * 60)` fixe après (O-05).
- `watch.py` n'a pas eu besoin de ce traitement : `_controler()` encadre déjà
son appel réseau (`read_state`) d'un `except Exception` propre.
- `serve.py`, CSS `.live .hint.overlay` : la règle n'avait aucun décalage
(`position:absolute` sans `top/left/bottom`), donc se recentrait exactement
sur le seul enfant en flux du conteneur flex — le bouton « Réessayer » — et
interceptait son clic. Correctif : décalage explicite au-dessus du bouton et
`pointer-events:none`, pour que le message ne capte plus jamais de clic,
y compris pendant l'attente initiale du direct où il n'y a pas de bug visible
aujourd'hui mais où le même risque existait.
- `test_runtime_validation.py` : nouvelle classe `RepeterTests`, deux tests
(erreur de tour non fatale, échéance calculée depuis le début du tour à
l'aide d'une horloge et d'un sommeil simulés).
- `test_blink2video_audit.py` : deux tests ajoutés à `TestsDefautsAsynchrones`
(`test_I17_boucler_survit_a_une_erreur_de_tour`,
`test_O05_boucler_calcule_l_echeance_depuis_le_debut_du_tour`), même
méthode de simulation côté `boucler()`.
#### Décisions et écarts au plan
- ce lot n'ouvre pas formellement l'étape 3 : aucune modification du format de
registre ni du verrou interprocessus n'a été faite ici, seulement les deux
boucles de fond et un bug d'interface découvert en passant ;
- la mise en cache du sondage `ffmpeg -i` des clips écartés (point 3) et le
relais MJPEG (point 6) restent des anomalies ouvertes, à qualifier et
numéroter avant traitement ;
- O-04 (watermark cloud) n'a pas été traité : il aurait changé la sémantique de
pagination de `read_cloud_manifest`, hors du périmètre resserré demandé pour
ce lot.
#### Tests et vérifications
| Contrôle | Résultat | Durée | Verdict |
|---|---:|---:|---|
| Suites isolées combinées (`test_runtime_validation`, `test_blink2video_audit`, `test_stage2_identity`) | 85 tests : 72 réussis, 9 défauts futurs attendus, 4 E-01 ignorés | 1,843 s | réussi |
| Nouveaux tests I-17/O-05 (4) | 4/4 | inclus ci-dessus | réussi |
| Chaîne historique `tests.py` | 70 contrôles observés, dont la suite « Interface web » | 25,6 s | réussi, « Tout est vert » |
| Compilation des 5 fichiers Python touchés | 5/5 | < 1 s | réussi |
| Ruff sur les fichiers touchés | 9 erreurs, toutes E402 ou pré-existantes hors du diff (confirmé par `git diff --stat`) | 0,3 s | réussi, aucune régression |
Aucune mesure de performance formelle (P-xx) n'a été enregistrée pour ce lot :
il ne s'agit pas d'une étape numérotée du plan, et la correction porte sur la
robustesse d'une boucle de fond, pas sur son débit. Un futur passage par
l'étape 6 (P-06, P-09) restera l'occasion de mesurer l'effet cumulé d'I-17 et
O-05 avec le protocole complet.
#### Verdict et retour arrière
I-17 et O-05 sont fermés pour les trois boucles de fond (`download`, `merge`,
`watch`), hors séquence par rapport à l'ordre de démarrage recommandé mais à la
demande explicite de l'utilisateur, suite à un usage réel révélant leurs
symptômes avant que l'étape 3 ne soit rouverte. Un bug d'interface non
répertorié (bouton « Réessayer » du direct) est également fermé.
**Verdict local : acceptée. Verdict formel : en attente de la matrice CI sur les
trois OS**, comme pour les étapes 1 et 2. Retour arrière : `git revert` ciblé
sur les trois fichiers de production concernés (`runtime.py`, `blink2video.py`,
`serve.py`) ; aucune migration de données n'est impliquée.
Reste à faire, en dehors de la séquence normale du plan : qualifier et
numéroter les deux anomalies découvertes aux points 3 et 6, avant de décider
si elles rejoignent une étape existante ou en ouvrent une nouvelle.
### 28.7 Correctif hors séquence — plafonnement de /api/clips et cache de durée
Statut : **implémenté et validé localement le 17 août 2026 ; qualification CI
sur trois OS encore requise pour la clôture formelle**.
Contexte : ferme le point 3 laissé ouvert en 28.6 (« ouverture de page
lente ») et répond à une question de suivi de l'utilisateur, posée avant de
partir sur l'étape 3 : le nombre de clips connus grossit chaque jour, sans
plafond, et `/api/clips` renvoyait déjà tout l'historique à chaque requête.
Vérifié sur le registre réel de l'installation avant toute décision : 147
entrées, 82,2 Ko — un `json.loads` sur ce volume ne coûte rien de mesurable,
SQLite n'aurait apporté aucun gain ici. Le vrai coût, confirmé sur les mêmes
données : 38 clips écartés sur 38 n'avaient aucune durée en cache, donc
`ffmpeg -i` était relancé 38 fois, en synchrone, à chaque ouverture de page.
Décision pour l'étape 3 elle-même (verrou/registre) : option légère retenue
(verrou atomique + JSON), pas de migration SQLite, faute de preuve que le
format JSON soit en cause où que ce soit dans le projet actuel.
#### Travail effectué
- `serve.py`, `collect()` :
- nouveau paramètre `depuis` (date) : les entrées plus anciennes sont
ignorées avant même d'être sondées, donc sans coût ffmpeg supplémentaire
pour l'historique masqué ;
- la réponse porte désormais `window_days` (fenêtre appliquée, ou `null` si
aucune) et `total_known` (nombre total d'entrées valides du registre,
fenêtre ou non), pour que la page puisse annoncer « 30 derniers jours,
X sur Y clips connus » ;
- `probe_duration_cached()` : la durée d'un clip écarté est mise en cache
dans `Blink_Thumbs/excluded_durations.json`, clé = identité, valeur =
durée + empreinte (taille, mtime) du fichier source. Un média remplacé
(réparation d'un fichier absent, par exemple) change l'empreinte et est
re-sondé ; sinon la valeur en cache est réutilisée sans relancer ffmpeg.
- `/api/clips` accepte `?all=1` pour lever explicitement la fenêtre ; sans ce
paramètre, la fenêtre par défaut est `DEFAULT_WINDOW_DAYS = 30` jours,
cohérente avec la fenêtre déjà utilisée ailleurs dans le projet (cloud
`since` par défaut).
- Page web : la liste affiche un bandeau « N derniers jours affichés (X sur Y
clip(s) connus) · afficher tout l'historique » quand la fenêtre est active,
avec un lien qui recharge une fois avec `?all=1` (mémorisé le temps de la
session, perdu au rechargement de la page).
- `tests.py` : le test « l'inventaire liste tous les clips, écartés compris »
attendait explicitement l'historique complet (les clips synthétiques du
test d'horodatage couvrent plusieurs mois) ; sa requête est passée à
`/api/clips?all=1` pour préserver son intention plutôt que de contourner la
nouvelle fenêtre.
- `test_serve_clips_window.py` (nouveau) : quatre tests — clip récent visible
et clip ancien masqué par défaut, `depuis=None` renvoie tout, une durée de
clip écarté n'est sondée qu'une fois, un média remplacé est re-sondé.
#### Décisions et écarts au plan
- pas de pagination ni de chargement par jour à la demande : un simple bandeau
« afficher tout » suffit à la volumétrie actuelle et évite d'inventer une
mécanique de pagination non standard pour un usage encore modeste ;
- le compteur `total_known` traverse tout le registre même en mode fenêtré,
ce qui reste bon marché (itération pure Python sur 147 entrées) : seul le
sondage ffmpeg, coûteux, est réellement évité par la fenêtre et le cache ;
- `provenances(entries)` (panneau « sources ») continue de porter sur
l'intégralité du registre, fenêtre ou non : c'est un agrégat par caméra, pas
une liste de vignettes, son coût ne grossit pas avec le nombre de clips.
#### Tests et vérifications
| Contrôle | Résultat | Durée | Verdict |
|---|---:|---:|---|
| `test_serve_clips_window.py` (nouveau) | 4/4 | 0,021 s | réussi |
| Suites isolées combinées (avec `test_serve_clips_window`) | 89 tests : 76 réussis, 9 défauts futurs attendus, 4 E-01 ignorés | 1,794 s | réussi |
| Chaîne historique `tests.py`, dont « Interface web » corrigée | 70 contrôles observés | ~25 s | réussi, « Tout est vert » |
| Compilation `serve.py` | 1/1 | < 1 s | réussi |
| Ruff sur `serve.py`, `tests.py`, `test_serve_clips_window.py` | 5 erreurs, toutes pré-existantes dans `serve.py` (E402 déjà documentées en O-09), 0 dans les fichiers nouveaux/modifiés de tests | 0,3 s | réussi, aucune régression |
Mesure ponctuelle sur les données réelles (hors protocole P-xx formel, à
l'appui de la décision JSON/SQLite) :
| Mesure | Valeur | Source |
|---|---:|---|
| Entrées du registre principal | 147 | `Blink_Clips/.blink_download_state.json`, 82,2 Ko |
| Clips écartés sans durée en cache avant correctif | 38 / 38 | `Blink_Normalized/.blink_normalized.json` |
| Appels `ffmpeg -i` par ouverture de page avant correctif | 38 | déduit du point précédent |
| Appels `ffmpeg -i` par ouverture de page après correctif (cache froid puis chaud) | 38 puis 0 | `test_serve_clips_window.py` |
#### Verdict et retour arrière
Point 3 de la section 28.6 (« ouverture de page lente ») est fermé. Aucune
anomalie numérotée du rapport initial n'était directement concernée : ce
correctif, comme celui du point 4 en 28.6, est un défaut trouvé en creusant un
retour d'usage réel plutôt qu'une anomalie déjà répertoriée à l'audit.
**Verdict local : acceptée. Verdict formel : en attente de la matrice CI sur les
trois OS.** Retour arrière : `git revert` ciblé sur `serve.py` et `tests.py` ;
`excluded_durations.json` est un cache pur, sa suppression ne perd aucune
donnée et le pire cas est un retour au comportement précédent (re-sondage
systématique).
## Étape 3 — Persistance et verrous transactionnels
Statut : **implémentée et validée localement le 17 août 2026 ; qualification
CI sur trois OS encore requise pour la clôture formelle**, comme pour les
étapes précédentes.
Révision de départ : `e7ede05`.
### 28.8 Décision D-01 et travail effectué
#### Décision D-01, tranchée hors recommandation du rapport
La section 24 recommandait SQLite en mode WAL pour l'étape 3. Avant de
trancher, le coût réel du JSON a été mesuré sur le registre de l'installation
en service plutôt que supposé : **147 entrées, 82,2 Ko**. Un `json.loads` sur
ce volume est négligeable, quel que soit le moteur de stockage ; le vrai coût
mesuré à la même occasion (voir 28.7) était un problème différent, un sondage
ffmpeg synchrone, déjà corrigé et sans lien avec le format d'état.
Décision retenue, à la demande explicite de l'utilisateur : **option légère —
verrou atomique + registre JSON existant**, plutôt que la migration SQLite.
Justification : les deux anomalies bloquantes de cette étape (B-04, B-05)
viennent de l'absence d'atomicité dans le protocole de verrouillage, pas du
format de stockage ; la fusion par delta et la protection des tombstones du
registre JSON existaient déjà depuis l'étape 2 (section 28.5). Une seconde
migration de format, juste après celle de l'étape 2 (v1→v2), aurait ajouté une
dette (schéma versionné, migration, dépendance `sqlite3` de fait) sans
preuve que le JSON soit en cause. SQLite reste une évolution valable si le
volume ou les besoins de requête grossissent un jour ; ce n'est pas le cas
aujourd'hui.
#### Travail effectué
- `runtime.py`, fonction `verrou()`, réécrite intégralement :
- acquisition par création exclusive du fichier de verrou
(`os.open(..., os.O_CREAT | os.O_EXCL | os.O_WRONLY)`), garantie atomique
au niveau de l'OS (`CreateFileW` avec `CREATE_NEW` sous Windows, `open`
avec `O_EXCL` sous POSIX) : deux processus qui tentent la création en même
temps ne peuvent plus réussir tous les deux. L'ancien protocole
lisait le fichier, concluait qu'il était libre, puis l'écrivait en deux
opérations séparées, ce qui laissait une fenêtre de course (B-05) ;
- jeton d'acquisition (`uuid.uuid4().hex`) écrit avec owner/pid : la
libération en sortie ne retire le fichier que si son contenu porte encore
notre propre jeton, ce qui évite de retirer la marque d'un autre
propriétaire dans un cas limite ;
- un propriétaire dont le PID n'existe plus est retiré puis la création est
retentée, sans jamais voler un propriétaire vivant sur le seul critère de
l'âge : `stale_after` ne déclenche plus de vol temporel (B-05, moitié
« volé après dix minutes ») ;
- une lecture illisible (fichier disparu entre l'échec de la création et la
lecture, ou création concurrente pas encore écrite) retente
immédiatement plutôt que de trancher à tort.
- `_ecrire_registre()` (blink2video.py) et son verrou `"registre"` n'ont pas
été modifiés : la fusion par delta et la protection des tombstones déjà en
place depuis l'étape 2 suffisaient une fois le verrou lui-même rendu
atomique — les deux processus qui l'utilisaient en concurrence ne peuvent
plus désormais entrer en même temps dans `_ecrire_registre`.
- `test_blink2video_audit.py` :
- retrait de `@unittest.expectedFailure` sur
`test_B05_un_verrou_vivant_ne_perime_pas_sur_le_seul_ttl` et
`test_B05_acquisition_concurrente_n_a_qu_un_gagnant`, devenus des succès
réels ;
- `test_B04_fusion_perimee_preserve_une_exclusion_plus_recente` corrigé et
dé-marqué : la fixture omettait `created_at` sur l'entrée disque, ce qui
la faisait écarter par `load_download_state()` avant même la fusion —
échec pour une raison étrangère à B-04, indépendante de cette étape.
- `test_runtime_validation.py` : nouvelle classe `VerrouTests`, trois tests
(verrou d'un processus mort récupéré, libération qui ne retire que son
propre jeton, cycle acquisition/libération normal).
#### Décisions et écarts au plan
- **T-B05-PID-REUSE non couvert**, en connaissance de cause : un PID recyclé
par un processus non lié, actif pendant que le vrai propriétaire est mort,
ne peut pas être distingué sans comparer l'heure de création du processus,
non disponible dans la bibliothèque standard de façon portable. L'ajouter
proprement supposerait soit une dépendance supplémentaire (`psutil`, déjà
présente en usage optionnel dans `benchmarks/`, jamais en production), soit
un code par plateforme fragile (WMI/PowerShell sous Windows, `/proc` ou
`ps` ailleurs) — décision non prise unilatéralement, à soumettre si ce
risque devient réel (poste qui redémarre beaucoup, PID recyclé vite). Le
même gisement de risque était déjà documenté sans solution en I-14 ;
- pas de test de charge à 1000 itérations (section 15.2) ni de scénario à 20
processus (T-B05-RACE20) : le test à 2 processus prouve l'exclusion mutuelle
au niveau du verrou lui-même, propriété qui ne dépend pas du nombre de
concurrents une fois l'opération atomique — un test à plus grande échelle
resterait un exercice de performance (P-08), pas de correction
supplémentaire, et n'a pas été jugé nécessaire pour clore B-05 ;
- aucun test d'intégration multi-processus dédié à B-04 (T-B04-INTERLEAVE)
n'a été ajouté au-delà de l'existant : la combinaison du verrou désormais
atomique (prouvé par le test de course B-05) et de la fusion par delta déjà
prouvée (test B-04 corrigé) couvre le même chemin par composition, sans
dupliquer un test déjà écrit à chaque couche.
#### Tests et vérifications
| Contrôle | Résultat | Durée | Verdict |
|---|---:|---:|---|
| Suites isolées combinées (avec `VerrouTests`) | 92 tests : 78 réussis, 6 défauts futurs attendus (contre 9 avant cette étape), 4 E-01 ignorés, 4 skips | 2,617 s | réussi |
| Dont T-B05 (2 tests) | passés de « échec attendu » à réussis | inclus ci-dessus | corrigé |
| Dont T-B04 (1 test) | fixture corrigée, passé de « échec attendu » à réussi | inclus ci-dessus | corrigé |
| Nouveaux `VerrouTests` (3) | 3/3 | inclus ci-dessus | réussi |
| Chaîne historique `tests.py`, cycle complet (login, download, merge, serve, stop) | 70 contrôles observés | ~24 s | réussi, « Tout est vert » |
| Compilation `runtime.py` | 1/1 | < 1 s | réussi |
| Ruff sur `runtime.py`, `test_runtime_validation.py`, `test_blink2video_audit.py` | 0 erreur | 0,3 s | réussi |
#### Mesures
Aucune mesure formelle P-02/P-03/P-08/P-09 n'a été enregistrée pour cette
étape : le format de stockage n'a pas changé (donc P-02/P-03, coût
d'écriture du registre, ne peuvent pas bouger significativement), et P-08
(course sur le verrou) n'a pas de protocole de mesure de débit prévu au-delà
du constat correction/incorrection déjà couvert par le test de course.
Écart documenté au lieu d'un chiffre inventé, conformément à la règle de la
section 11.1.
#### Verdict et retour arrière
B-04 et B-05 sont fermés, hors T-B05-PID-REUSE explicitement laissé ouvert et
documenté plutôt que masqué. La décision D-01 est tranchée en faveur de
l'option légère, à rebours de la recommandation par défaut du rapport, avec
la mesure qui la justifie.
**Verdict local : acceptée. Verdict formel : en attente de la matrice CI sur
les trois OS.** Retour arrière : `git revert` ciblé sur `runtime.py` et les
deux fichiers de tests modifiés ; aucune migration de données n'est
impliquée, le format du registre n'a pas changé.
## Étape 4 — Racine de données et authentification
Statut : **implémentée et validée localement le 17 août 2026 ; qualification
CI sur trois OS encore requise pour la clôture formelle**, comme pour les
étapes précédentes.
Révision de départ : le commit fermant l'étape 3.
### 28.9 Travail effectué
#### I-01 — CONFIG et OUTPUT ignoraient BLINK_HOME
Cause identifiée par comparaison des jumeaux (les quatre points d'entrée du
projet) : `merge_daily.py`, `serve.py` et `watch.py` dérivent tous leurs
chemins par défaut de `BASE_DIR = runtime.app_dir()`. `blink2video.py` était
le seul des quatre à définir `CONFIG`/`OUTPUT` comme des `Path` relatifs bruts,
résolus au hasard du répertoire courant de lancement. Un drift accidentel pur,
pas une différence voulue.
- `blink2video.py` : `CONFIG` et `OUTPUT` dérivent désormais de
`BASE_DIR = runtime.app_dir()`, exactement comme les trois autres fichiers.
- Décision D-02 (résolution d'un `--output` relatif explicite) : **non
traitée**, restée hors périmètre. Le comportement existant (résolution
contre le CWD pour une valeur explicite, inchangé dans les quatre
programmes) n'est pas un défaut observé comme l'était le seul défaut de
*valeur par défaut* de blink2video.py ; le changer toucherait les quatre
programmes à la fois pour un besoin encore hypothétique. Décision affichée
ici plutôt que tranchée en silence, comme le demande la section 24.
#### I-02 — Sauvegardes d'authentification concurrentes
- `save_session()` : liste blanche explicite de champs persistés
(`AUTH_FIELDS`) au lieu de copier `blink.auth.login_attributes` en bloc — le
mot de passe, présent dans ce dict source, n'est plus jamais écrit, ni vide
ni en clair, et un champ que blinkpy ajouterait un jour ne fuiterait pas
automatiquement sur disque (4.5).
- Temporaire propre à chaque appel (`blink_auth.<pid>.<jeton>.tmp`) au lieu du
`blink_auth.tmp` partagé par tous les processus : deux sauvegardes
concurrentes n'écrivent plus jamais dans le même fichier intermédiaire (4.6).
- Horodatage interne `updated_at` : une sauvegarde plus ancienne (callback de
rafraîchissement arrivé en retard, par exemple) ne peut plus écraser une
version plus récente déjà sur disque — même principe de version monotone
que la fusion du registre de téléchargement depuis l'étape 2, appliqué ici
au fichier de session (4.7, en partie de D-04 : contrôle de génération sans
broker).
- Écriture en 0600 sur POSIX (`os.chmod`, ignoré sous Windows où le modèle de
permissions diffère) avant le remplacement atomique (4.3, 4.4).
- `.gitignore` : `blink_auth.tmp` n'était couvert par aucun motif existant
(`blink_auth.json.*` ne couvre pas un remplacement d'extension). Motif
`blink_auth.*.tmp` ajouté pour couvrir la nouvelle forme du temporaire.
#### I-03 — Tokens rafraîchis non persistés
- Confirmé dans le code de blinkpy installé (0.25.9) : `Auth.query()` appelle
`self.callback()` juste après un rafraîchissement transparent de jeton, mais
`make_blink()` ne fournissait aucun callback — un rafraîchissement en cours
de boucle ne touchait jamais le disque.
- `make_blink()` fournit désormais `callback=lambda: save_session(blink)` à
la construction de `Auth`. Comme `save_session` s'appuie sur la liste
blanche et l'horodatage d'I-02, chaque rafraîchissement automatique est
maintenant écrit, sans jamais pouvoir régresser une sauvegarde plus récente.
#### Décisions et écarts au plan (4.9, 4.10)
- **4.9 (propriétaire unique vs génération)** : le contrôle de génération
(`updated_at`) a été retenu plutôt qu'un unique processus propriétaire de la
session. Un broker local (D-04, troisième option) n'a pas été implémenté :
jugé disproportionné tant que rien ne prouve que le contrôle de génération
est insuffisant en pratique ;
- **4.10 (keyring/DPAPI/Keychain)** différé sans être retiré du plan : le
mode fichier reste requis dans tous les cas (portabilité, absence de trousseau
sur certaines installations), et rien dans les anomalies observées ne
justifie de l'ajouter maintenant.
### Tests et vérifications
| Contrôle | Résultat | Durée | Verdict |
|---|---:|---:|---|
| Suites isolées combinées | 96 tests : 87 réussis, 5 défauts futurs attendus (contre 6 avant), 4 E-01 ignorés (inclus dans les skips) | 2,379 s | réussi |
| Dont T-I01 (déjà écrit à l'étape 0, spawn un vrai sous-processus) | passé de « échec attendu » à réussi | inclus ci-dessus | corrigé |
| Nouveaux tests I-02 (3) et I-03 (1) | 4/4 | inclus ci-dessus | réussi |
| Chaîne historique `tests.py`, cycle complet | 70 contrôles observés | ~24 s | réussi, « Tout est vert » |
| Compilation `blink2video.py` | 1/1 | < 1 s | réussi |
| Ruff sur `blink2video.py`, `test_blink2video_audit.py` | 3 erreurs, toutes E402 pré-existantes (imports après `runtime.bootstrap()`, documentées en O-09) | 0,3 s | réussi, aucune régression |
### Mesures
Aucune mesure formelle P-01/P-10 enregistrée : ce lot ne change pas le volume
ni la fréquence des écritures (une sauvegarde de session reste un événement
rare — login initial et rafraîchissements occasionnels de jeton), et la
baseline globale de l'étape 0 reste elle-même non qualifiée (section 28.2).
Écart documenté plutôt que chiffre inventé.
### Verdict et retour arrière
I-01, I-02 et I-03 sont fermés. D-02 (résolution d'un `--output` relatif
explicite) reste une décision ouverte, affichée plutôt que tranchée en
silence : elle changerait un comportement identique aujourd'hui dans les
quatre programmes, pour un cas non observé comme problème.
**Verdict local : acceptée. Verdict formel : en attente de la matrice CI sur
les trois OS.** Retour arrière : `git revert` ciblé sur `blink2video.py`,
`.gitignore` et `test_blink2video_audit.py` ; aucune migration n'est
impliquée, un `blink_auth.json` existant reste lisible tel quel (mêmes clés,
sous-ensemble de celles déjà écrites par l'ancien code).
## Étape 5 — Démarrage convivial et onboarding web
Statut : **livrée en version resserrée le 17 août 2026 ; qualification CI sur
trois OS encore requise pour la clôture formelle**.
### 28.10 Décision de périmètre et travail effectué
#### Décision de périmètre, prise avec l'utilisateur avant le codage
La section 17 du rapport décrit un périmètre complet (jeton CSRF à usage
court, limitation de tentatives, réutilisation de port via endpoint de santé
signé, verrou de propriétaire unique du flux, 21 tests `T-E01-*`, benchmark
P-12 complet). Avant de commencer, l'utilisateur a choisi explicitement la
**version resserrée** proposée en alternative : réutiliser `LoginFlow` déjà
présent dans `serve.py`, faire converger `blink2video` sans argument et
`blink2video start`, ajouter un préflight et un mini-smoke read-only avant
tout worker permanent — sans CSRF ni rotation de port ni benchmark P-12
formel. Ce choix est documenté ici pour que la comparaison au périmètre
complet du rapport reste traçable plutôt qu'silencieuse.
#### Travail effectué
- `blink2video.py` :
- `route(argv: list) -> int`, dispatch pur extrait de `if __name__ ==
"__main__"` (5.1/5.2) : une liste vide emprunte le même chemin que
`start` (préflight compris) ; `--help`/`--version` gardent leur sens
inchangé. Extrait en fonction ordinaire précisément pour rester testable
sans sous-processus, ce que le bloc `if __name__` interdisait.
- `preflight() -> dict` (async, 5.3/5.4) : test réellement la session via
`connect_saved()` (pas seulement la présence du fichier), strictement
read-only (aucun armement, direct, manifeste, téléchargement,
assemblage, notification — 17.3). Renvoie `authenticated`, `networks`,
`sync_modules`, `cameras`, `cloud_only`, `error`, conforme au contrat de
la section 17.3 hors `warnings`/`elapsed` (non retenus dans la version
resserrée).
- `accueillir(etat, supplement, delai=600.0) -> int` (5.6 à 5.16) :
lance un `serve` temporaire (réutilise `LoginFlow` sans modification),
attend que son port réponde, ouvre le navigateur sur `?login=1`,
interroge `preflight()` toutes les 2 s jusqu'à authentification ou
délai. Le serveur temporaire est systématiquement arrêté en sortie,
succès compris (5.16) : `start` relance ensuite la composition complète
plutôt que de faire rejoindre ce processus à la supervision normale —
un bref redémarrage de l'interface, coût assumé de la simplicité.
- `_port_ouvert(port) -> bool` : extrait pour rester mockable sans toucher
au module `socket` global, dont la boucle asyncio de Windows se sert
aussi pour elle-même.
- `executer()`, branche `"start"` : appelle `preflight()` avant tout spawn ;
si non authentifié, `accueillir()` doit réussir avant que la composition
`runtime.STANDARD` soit lancée (5.15).
- Deux variables d'environnement réservées aux tests, non documentées côté
utilisateur : `BLINK_ONBOARDING_TIMEOUT` (raccourcit le délai) et
`BLINK_NO_BROWSER` (empêche l'ouverture réelle d'un navigateur).
- `serve.py` : la page ouvre automatiquement la boîte de dialogue de
connexion existante quand chargée avec `?login=1` (5.10), sans nouvelle
route ni CSRF, en réutilisant `authenticate()` déjà présent.
- `tests.py`, `test_verbes()` : l'assertion « sans argument, l'aide s'affiche »
ne pouvait plus tenir (E-01 change ce comportement délibérément). Remplacée
par une vérification que l'onboarding démarre bien et respecte le délai
(`BLINK_ONBOARDING_TIMEOUT`/`BLINK_NO_BROWSER` ajoutés à
`environnement_test`), et une assertion séparée que `--help` continue
d'afficher l'aide sans déclencher d'onboarding.
- `test_blink2video_audit.py` :
- `test_E01_sans_argument_selectionne_start` réécrit : la version
d'origine (étape 0) attendait que `parse_args().command` vaille
`"start"`, une hypothèse d'implémentation antérieure au choix réel
(dispatch via `route()`, pas via le positionnel `command`). Corrigé pour
vérifier le mécanisme effectivement construit ;
- nouveaux tests `preflight()` (4, dans `TestsDefautsAsynchrones`) et
`accueillir()`/`route()`/`executer()` (6, nouvelle classe
`TestsE01Onboarding`, remplace le stub `TestsOnboardingFutur` retiré) ;
- **incident évité pendant l'écriture des tests** : le premier jet de
`test_E01_preflight_sans_session_ne_touche_pas_le_reseau` n'avait pas
patché `b2v.CONFIG`. Comme `BASE_DIR`/`CONFIG` se calculent une seule
fois à l'import du module (§28.9), et que ce fichier de test n'impose pas
`BLINK_HOME` avant son propre `import blink2video`, le test lisait le
`blink_auth.json` réel de la machine et tentait de le rafraîchir contre
l'API Blink réelle — exactement ce que la section 12.5 interdit. Repéré à
l'exécution (durée anormale, ~2,3 s pour un test censé être local) avant
tout commit, puis corrigé par un patch explicite de `CONFIG`. Consigné
ici plutôt que passé sous silence, conformément à la règle du journal.
#### Ce qui n'a pas été fait (périmètre resserré, assumé)
- pas de jeton CSRF ni de vérification d'origine : l'interface reste liée à
127.0.0.1 uniquement, contre-mesure déjà en place et jugée suffisante pour
ce périmètre ;
- pas de limitation de tentatives ni de politique de cadence dédiées à
l'onboarding : `LoginFlow` existant n'en a pas non plus, inchangé ;
- pas de réutilisation d'une instance existante via endpoint de santé signé
ni de rotation de port automatique : un port déjà occupé par une vraie
instance ou un programme tiers fait simplement échouer `accueillir()` avec
un message clair, sans tentative de port alternatif (5.10 partiellement
couvert : le cas « port étranger » n'est pas distingué du cas « port
blink2video déjà actif ») ;
- pas de synthèse structurée affichée dans la page elle-même (modules,
réseaux, caméras) : le résumé de succès (« compte accessible, N
caméra(s) ») s'affiche dans le terminal, pas dans l'interface web ;
- pas de benchmark P-12 ni de mesure formelle des budgets de la section 17.8 ;
- transition onboarding → composition complète non transparente pour un
onglet déjà ouvert : le serveur temporaire redémarre (assumé en 5.6/5.16
ci-dessus), un onglet resterait sur une erreur de connexion quelques
instants avant de pouvoir être rechargé.
#### Tests et vérifications
| Contrôle | Résultat | Durée | Verdict |
|---|---:|---:|---|
| Suites isolées combinées (avec `TestsE01Onboarding`) | 103 tests : 99 réussis, 4 défauts futurs attendus (contre 5 avant), 0 skip (le stub `TestsOnboardingFutur` a été remplacé par de vrais tests) | 5,0 s | réussi |
| Nouveaux tests E-01 (`preflight` ×4, `TestsE01Onboarding` ×6, dispatch ×2) | 12/12 | inclus ci-dessus | réussi |
| Chaîne historique `tests.py`, dont `test_verbes` réécrit | 70 contrôles observés | ~30 s (onboarding borné à 3 s ajouté) | réussi, « Tout est vert » |
| Compilation `blink2video.py`, `serve.py` | 2/2 | < 1 s | réussi |
| Ruff sur les 4 fichiers touchés | 8 erreurs, toutes E402 pré-existantes (imports après `runtime.bootstrap()`, O-09) | 0,3 s | réussi, aucune régression |
#### Mesures
Aucune mesure P-01/P-10/P-12 formelle : le benchmark P-12 lui-même n'existe
pas encore dans `benchmarks/stage0_baseline.py` (jamais implémenté depuis
l'étape 0, où il était déjà noté « non disponible »). L'ajouter appartient à
une reprise future de l'étape 5 vers son périmètre complet, pas à cette
version resserrée. Écart documenté plutôt que chiffre inventé.
#### Verdict et retour arrière
E-01 est livré en version resserrée : le parcours nominal (sans argument →
préflight → onboarding si nécessaire → composition complète) fonctionne de
bout en bout et est testé sans toucher de compte ni de navigateur réels.
CSRF, rotation de port, endpoint de santé signé et P-12 restent hors
périmètre et devront faire l'objet d'une décision explicite s'ils sont un
jour requis (aucune décision D-xx correspondante n'a été ajoutée : le choix
a été fait directement avec l'utilisateur pour cette étape).
**Verdict local : acceptée en périmètre resserré. Verdict formel : en attente
de la matrice CI sur les trois OS.** Retour arrière : `git revert` ciblé sur
`blink2video.py`, `serve.py`, `tests.py` et `test_blink2video_audit.py` ;
aucune migration de données.
## Étape 6 — Adaptateurs USB/cloud et performance des entrées-sorties
Statut : **partielle, périmètre resserré, implémentée et validée localement
le 17 août 2026 ; qualification CI sur trois OS encore requise**.
### 28.11 Décision de périmètre et travail effectué
#### Décision de périmètre
Les 14 points de la section 18.1 couvrent à la fois de la fiabilité (B-06,
I-15) et de la performance/robustesse pure (watermark durable, streaming par
blocs borné à 32 Mio, backoff+jitter sur 429, pin de blinkpy). Choix pris
avec l'utilisateur avant le codage : traiter d'abord les deux anomalies
actives (B-06, I-15), laisser le reste en périmètre différé et documenté.
L'adaptateur `_local_storage` (6.1, dépendance privée blinkpy déjà
documentée en I-06), le watermark cloud (6.7/6.8, O-04), le streaming par
blocs (6.11/6.12, O-01) et le pin de version (6.14, O-07/D-01 implicite ne
s'applique pas ici) restent donc ouverts.
#### Travail effectué
- `blink2video.py`, `un_passage()` (B-06) : le bloc par module (lecture du
manifeste USB puis téléchargement) est désormais entièrement couvert par
`hub_lock(name)`, la même primitive que `serve.py` utilise pour le direct
et l'actualisation. Un module déjà réservé lève `BusyError`, rattrapée
localement : le tour continue avec les autres modules plutôt que
d'abandonner tout le passage (composition avec le correctif I-17 déjà en
place). Limite assumée, identique à celle qui existait déjà pour
direct/actualisation : le verrou reste global à tous les hubs (une seule
ressource nommée `"hub"`), pas par `sync_id` comme le demande 6.4/6.5 dans
le périmètre complet — deux Sync Modules distincts se bloquent donc encore
l'un l'autre inutilement. Non traité dans ce lot, documenté ici plutôt que
silencieusement laissé de côté.
- `download_clip()` (USB) et `CloudClip.download_to()` (cloud), pour I-15 :
les deux ne vérifiaient qu'une taille non nulle après écriture, ce qui
laissait passer un fichier d'un octet ou une page d'erreur HTML servie
avec un statut 2xx. Les deux appellent désormais `merge_daily.valid_mp4()`
(import ajouté : `merge_daily as md`), qui relit la boîte `ftyp` en début
de fichier — la même fonction que `merge_daily.py` utilise déjà, pas une
seconde copie qui aurait dérivé. Import vérifié sans cycle : `merge_daily`
n'importe jamais `blink2video`.
- Validation de portée : seule la boîte `ftyp` est vérifiée, pas un probe
ffmpeg complet (durée, résolution, decodabilité). C'est la moitié « légère »
de la décision D-05 (section 24), pas encore la validation complète — la
probe systématique reste à trancher si la légère se révèle insuffisante en
usage réel.
#### Tests et vérifications
| Contrôle | Résultat | Durée | Verdict |
|---|---:|---:|---|
| Suites isolées combinées | 109 tests : 106 réussis, 3 défauts futurs attendus (contre 4 avant cette étape) | 6,0 s | réussi |
| Dont T-B06 (3, dont 2 nouveaux : occupation par le direct, indépendance du cloud) | 3/3 | inclus ci-dessus | réussi |
| Dont I-15 (4 nouveaux : cloud/USB × invalide/valide) | 4/4 | inclus ci-dessus | réussi |
| Chaîne historique `tests.py`, dont assemblage réel de clips ffmpeg | 70 contrôles observés | ~24 s | réussi, « Tout est vert » |
| Compilation `blink2video.py` | 1/1 | < 1 s | réussi |
| Ruff sur `blink2video.py`, `test_blink2video_audit.py` | 4 erreurs, toutes E402 (imports après `runtime.bootstrap()`, dont le nouvel `import merge_daily as md`, même catégorie déjà documentée en O-09) | 0,3 s | réussi, aucune régression |
#### Mesures
Aucune mesure P-06/P-07/P-09/P-10 : ce lot ne touche ni le débit ni la
mémoire (la validation `ftyp` lit 64 octets d'un fichier déjà entièrement
écrit sur disque, coût négligeable et non mesuré séparément), et les vrais
sujets de performance de cette étape (streaming, watermark) restent hors
périmètre. Écart documenté plutôt que chiffre inventé.
#### Verdict et retour arrière
B-06 et I-15 sont fermés en périmètre resserré. I-17 était déjà fermé hors
séquence (28.6) et compte pour cette étape sans travail supplémentaire.
Restent ouverts dans cette étape : I-06 (adaptateur `_local_storage`), I-08
(pagination cloud plafonnée à `stop=20`), O-01 (streaming par blocs), O-04
(watermark durable). Le critère de sortie formel de la section 18.4 n'est
donc pas atteint dans son intégralité.
**Verdict local : acceptée en périmètre resserré. Verdict formel : en attente
de la matrice CI sur les trois OS, et du reste du périmètre de l'étape 6 si
retenu plus tard.** Retour arrière : `git revert` ciblé sur `blink2video.py`
et `test_blink2video_audit.py` ; aucune migration de données.
## Étape 7 — Grammaire CLI et supervision
Statut : **partielle, périmètre resserré, implémentée et validée localement
le 17 août 2026 ; qualification CI sur trois OS encore requise**.
### 28.12 Décision de périmètre et travail effectué
#### Décision de périmètre
La section 19.1 couvre 16 points, dont une grammaire d'arité générique
(7.1/7.2/7.3/7.4), une politique explicite de composition pour chaque verbe
délégué (7.5), des groupes POSIX et Job Objects Windows pour la supervision
(7.9), des fiches à états `starting`/`running`/`stopping` (7.13), et un
endpoint de santé signé pour `open` (7.16). Poursuite du même choix que les
étapes 5 et 6 : fermer d'abord les anomalies actives et déjà testées (I-11,
I-13, I-14 — les trois seuls défauts encore marqués `expectedFailure` dans la
suite isolée après l'étape 6), laisser le reste en périmètre différé.
#### Travail effectué
- `runtime.py`, `decouper_verbes()` (I-11) : le découpage ne considérait que
la présence littérale d'un nom de verbe pour ouvrir un nouveau groupe,
sans savoir qu'une option attend une valeur. `download --camera watch`
ouvrait donc deux groupes, le second (`watch`) démarrant une surveillance
non demandée. Corrigé par une table explicite des options à valeur
unique (`--camera`, `--hub`, `--output`, `--port`...), une table des
options à valeurs multiples qui consomment jusqu'au prochain mot
commençant par `-` (`--exclude`, `--ignore`...), et un traitement
spécifique de `--loop` (`nargs="?"` : sa valeur n'est prise que si le mot
suivant est un nombre, sans quoi le mot suivant reste un verbe à part
entière). Cette table est tenue à la main dans `runtime.py` plutôt que
déduite des analyseurs de chaque programme (blink2video.py, merge_daily.py,
serve.py, watch.py) : ceux-ci vivent dans des fichiers que ce découpage
précède et ne peut pas encore interroger. C'est la version resserrée de
7.1 (grammaire d'arité), pas la table générique construite depuis les
vrais analyseurs que demande le périmètre complet.
- `runtime.py`, `lire_instances()` (I-13) : une fiche n'était conservée que
si le PID du parent restait vivant, même si un enfant persistant lui
survivait — cas réel sous POSIX, où le parent ne partage pas le groupe de
processus de ses enfants. Corrigé : la fiche reste tant qu'au moins un
membre, parent ou enfant, répond vivant.
- `blink2video.py`, `arreter()` (I-14) : la fiche d'instance était effacée
inconditionnellement après la tentative d'arrêt, avant même de savoir si
elle avait réussi. Un survivant devenait alors introuvable au `stop`
suivant, sa seule piste ayant disparu avec la fiche. Corrigé : la fiche
n'est retirée que si tous ses membres sont confirmés morts ; sinon elle
reste, et ses survivants sont comptés dans le message final.
- `test_blink2video_audit.py` : les trois tests déjà écrits à l'étape 0
(`test_I11_valeur_de_camera_egale_a_un_verbe_reste_une_valeur`,
`test_I13_parent_mort_ne_fait_pas_oublier_un_enfant_vivant`,
`test_I14_echec_arret_conserve_la_fiche`) sont démarqués. Cinq nouveaux
tests I-11 ajoutés : `--camera update` (le cas le plus dangereux, car il
aurait pu déclencher une mise à jour non demandée), `--ignore` à valeurs
multiples avalant un nom de verbe, arrêt correct de cette consommation au
prochain vrai groupe, et les deux formes de `--loop` (seul, puis suivi
d'un nombre).
#### Ce qui n'a pas été fait (périmètre resserré, assumé)
- pas de grammaire d'arité générique interrogeant les vrais analyseurs
(7.1/7.2) : la table de `_OPTIONS_VALEUR_UNIQUE`/`_OPTIONS_VALEURS_MULTIPLES`
est tenue à la main et devra être mise à jour si une option change d'arité
dans un des quatre programmes, sans garde-fou automatique ;
- pas de métadonnées `composable`/`persistent` sur les verbes (7.3/7.4), ni
de politique explicite pour les compositions de `stop`/`update`/`autostart`/
`login`/`open` (7.5) : ces verbes restent réglés au cas par cas dans
`executer()`, comme avant ;
- pas de groupes POSIX/Job Objects Windows dédiés (7.9), pas de `wait()`
systématique par enfant (7.11), pas d'identification par heure de création
de processus (7.12, même limite déjà documentée en 28.8 pour B-05/PID-reuse) ;
- pas de fiches à états `starting`/`running`/`stopping` (7.13) ;
- pas d'endpoint de santé signé pour `open` (7.16, cette absence était déjà
notée dans le compte rendu de l'étape 5, 28.10).
#### Tests et vérifications
| Contrôle | Résultat | Durée | Verdict |
|---|---:|---:|---|
| Suites isolées combinées | **114 tests : 114 réussis, 0 défaut futur attendu** (contre 3 avant cette étape) | 4,8 s | réussi |
| Dont I-11 (6, dont 5 nouveaux) | 6/6 | inclus ci-dessus | réussi |
| Dont I-13, I-14 (2) | 2/2 | inclus ci-dessus | réussi |
| Chaîne historique `tests.py`, dont « Arrêt d'une instance » (fiche réelle) | 70 contrôles observés | ~24 s | réussi, « Tout est vert » |
| Compilation `runtime.py`, `blink2video.py` | 2/2 | < 1 s | réussi |
| Ruff sur les 3 fichiers touchés | 4 erreurs, toutes E402 pré-existantes (O-09), 0 nouvelle dans `runtime.py` | 0,3 s | réussi, aucune régression |
Tous les défauts caractérisés depuis l'étape 0 (T-B01 à T-B06, JSON/CLI/
processus, I-01 à I-22 hors ceux explicitement différés) sont désormais
fermés dans la suite isolée. Les défauts restants du rapport concernent des
points d'optimisation ou de refactor (section 8) et les périmètres complets
volontairement différés des étapes 5, 6 et 7, tous documentés par leur
propre section 28.x.
#### Mesures
Aucune mesure P-01/P-08/P-11 : ce lot corrige un découpage de chaîne et deux
conditions de garde sur des structures déjà en mémoire, sans changer le
volume ni la fréquence d'aucune opération mesurée par le protocole. Écart
documenté plutôt que chiffre inventé.
#### Verdict et retour arrière
I-11, I-13 et I-14 sont fermés en périmètre resserré. I-12 et la validation
de port d'I-22 étaient déjà fermés depuis l'étape 1 ; seul le volet « endpoint
de santé signé » d'I-22 reste ouvert. Le critère de sortie formel de la
section 19.4 est donc atteint pour les anomalies numérotées qu'il cite
(I-11 à I-14, I-22 partiellement), mais pas pour l'ensemble du périmètre
structurel de la section 19.1.
**Verdict local : acceptée en périmètre resserré. Verdict formel : en attente
de la matrice CI sur les trois OS.** Retour arrière : `git revert` ciblé sur
`runtime.py`, `blink2video.py` et `test_blink2video_audit.py` ; aucune
migration de données.
## Étape 8 — Découpage, bootstrap et maintenabilité
Statut : **implémentée en périmètre complet pour 8.1 à 8.9 et 8.12, avec
8.10/8.11/8.13 différés, le 17 août 2026 ; qualification CI sur trois OS
encore requise**.
Seule étape parmi 3 à 8 où l'utilisateur a choisi le périmètre complet plutôt
qu'une version resserrée, malgré le risque de régression signalé comme le
plus élevé du plan (gros déplacement de code dans le fichier le plus
central du projet).
### 28.13 Décision, travail effectué et incidents de refactor
#### Travail effectué (8.1 à 8.6) : découpage en cinq modules
`blink2video.py` (2 034 lignes) est éclaté en :
- `blink_auth.py` (8.1) : session, identifiants, 2FA, `preflight()` ;
- `blink_models.py` (8.2) : adaptateurs USB/cloud, identité, chemins ;
- `blink_registre.py` (8.3) : registre incrémental, corrélation, réparation ;
- `blink_engine.py` (8.4) : un passage de téléchargement, en boucle ou une fois ;
- `blink_cli.py` (8.5) : grammaire, verbes délégués, supervision, onboarding.
`blink2video.py` (8.6) ne contient plus que `import runtime`, `from
blink_cli import route`, et le bloc `if __name__`. Graphe de dépendances
volontairement acyclique : `blink_models` ← `blink_registre` ← `blink_engine`
← `blink_cli` ; `blink_auth` est une feuille, consommée uniquement par
`blink_cli` et `serve.py`/`watch.py`.
#### Travail effectué (8.7/8.8) : bootstrap différé, O-06 fermé
`runtime.bootstrap()` n'est plus appelé sans condition en tête de fichier. Il
est maintenant appelé juste avant chaque import de `blink_auth`/
`blink_models`/`blink_engine` — tous localisés à l'intérieur des fonctions
qui en ont réellement besoin (`main()`, la branche « start » d'`executer()`,
`accueillir()`), sur le même principe que `deleguer()` appliquait déjà pour
merge_daily/serve/watch/maj/autostart. Vérifié directement, pas supposé :
`stop`, `--help`, `open` et `update --help` exécutés avec `sys.modules['aiohttp']
= None` et `sys.modules['blinkpy'] = None` (aiohttp et blinkpy rendus
réellement inimportables) fonctionnent sans erreur ; `login` dans les mêmes
conditions échoue proprement via le message `runtime.bootstrap()` existant
(« Dépendances absentes »), sans traceback.
#### Travail effectué (8.12) : code mort retiré, O-09 fermé
`HUB_LOCK` (variable jamais lue) et `_process_alive` (fonction jamais
appelée, doublon de `runtime.processus_vivant`) n'ont pas été reportés dans
les nouveaux modules : ils disparaissent simplement en n'étant recopiés
nulle part, confirmé par une recherche vide sur tout le dépôt après coup.
#### Incident de refactor majeur, corrigé avant tout commit
Le premier découpage utilisait `from blink_models import (nom1, nom2, ...)`
dans `blink_engine.py`, et `from blink_engine import boucler` /
`from blink_models import select_sync_modules` localement dans
`blink_cli.main()`. Ce style casse le mocking par test : `nom` importé ainsi
devient une liaison locale au module importeur, résolue dans SON espace de
noms à l'appel — patcher `blink_models.nom` ne change alors rien à ce que
`blink_engine` appelle réellement. Découvert par une cascade d'échecs
`AttributeError: <module 'blink2video'> does not have the attribute ...`
puis, après une première correction incomplète, par des appels réels non
mockés qui atteignaient un faux objet HTTP dépourvu de la bonne méthode.
Corrigé en généralisant `import module` + `module.nom(...)` (accès par
attribut, toujours patchable) à toutes les frontières entre les cinq
nouveaux fichiers ; seul `deleguer()` et les imports véritablement
day-one-lazy (`from aiohttp import ClientSession` dans `main()`) restent en
forme locale, documentés comme tels.
Un second incident, mineur : un test `mock.patch("aiohttp.ClientSession", ...)`
devait remplacer un `mock.patch.object(b2v, "ClientSession", ...)` devenu
invalide pour la même raison — `main()` importe `ClientSession` localement
par nom, donc seul un patch à la source (`aiohttp.ClientSession`) l'atteint,
puisque l'import s'exécute à nouveau, et voit le correctif, à chaque appel.
#### Migration des tests : ampleur réelle, pas un détail
`test_blink2video_audit.py` et `test_stage2_identity.py` contenaient des
dizaines de références `b2v.NOM` (accès direct et `mock.patch.object(b2v,
"NOM", ...)`), dont la cible réelle a changé de fichier avec le découpage.
Traité par script Python ponctuel (regex ciblées par nom, deux passes pour
couvrir les appels étalés sur plusieurs lignes), puis les échecs restants
corrigés à la main un par un jusqu'à connaissance complète des transferts
manquants. `time`, `datetime`, `asyncio` et `runtime` restent accessibles
via `b2v.time`/`b2v.dt`/`b2v.asyncio`/`b2v.runtime` sans aucune correction :
modules stdlib/projet partagés (mis en cache une seule fois par Python), un
patch sur l'un affecte le même objet quel que soit le nom sous lequel on
l'atteint — seuls les noms définis dans ce projet (fonctions, classes,
constantes) posent le problème d'espace de noms ci-dessus.
`serve.py` (4 usages : `bk.BusyError`, `bk.connect_saved`, `bk.hub_lock`,
`bk.login`) et `watch.py` (1 usage : `bk.connect_saved`) important
`blink2video as bk` pour des noms désormais absents de ce module ont été
corrigés pour importer `blink_auth`/`blink_engine` directement ; `import
blink2video as bk` devenu inutile a été retiré de `serve.py`.
#### Vérification par bundle réel, pas seulement statique
`build.py` a été exécuté intégralement (PyInstaller 6.20.0) sans aucune
modification de `blink2video.spec` : l'analyse statique de PyInstaller suit
les `import` situés à l'intérieur des fonctions aussi bien qu'en tête de
fichier, donc `blink_auth`/`blink_models`/`blink_engine` sont découverts
sans figurer dans `hiddenimports` (seuls les verbes délégués par
`importlib.import_module` avec une chaîne dynamique en ont besoin, logique
inchangée). L'exécutable produit (109 Mo) a ensuite été exercé réellement,
pas seulement compilé : `stop`, `--help`, `start --help`, `watch --help`,
`merge --help`, `download --help`, `login --help`, `open --port 1` — tous
corrects, contre le vrai bundle plutôt que les sources.
#### Ce qui n'a pas été fait (périmètre différé, assumé)
- 8.9 (verrouillage de la création du venv), 8.10 (vérification des
dépendances depuis l'interpréteur cible), 8.11 (contrainte de version
testée pour blinkpy, O-07) : non traités, le venv de construction et
d'exécution restent gérés comme avant ce lot ;
- 8.13 (remplacement progressif de `print` par une journalisation
structurée) : explicitement décliné. Pour un outil CLI local dont `print`
*est* l'interface utilisateur (chaque message est lu par une personne dans
un terminal, jamais agrégé ni interrogé), une bascule vers `logging`
n'apporterait rien à l'usage réel et représenterait une réécriture
mécanique de très large surface pour un gain non démontré — jugé hors de
proportion, contrairement au reste du périmètre complet choisi ;
- O-10 (motif `.gitignore` pour les nouveaux verrous `download`/`merge`/
`registre`) : non traité, resté hors du périmètre de cette étape.
#### Tests et vérifications
| Contrôle | Résultat | Durée | Verdict |
|---|---:|---:|---|
| Suites isolées combinées | 119 tests : 119 réussis (contre 114 avant cette étape), 0 défaut futur attendu | 7,8 s | réussi |
| Dont O-06 (5 nouveaux, sous-processus avec aiohttp/blinkpy réellement bloqués) | 5/5 | inclus ci-dessus | réussi |
| Chaîne historique `tests.py`, cycle complet réel (login, download, merge, serve, stop, direct simulé) | 70 contrôles observés | ~24 s | réussi, « Tout est vert » |
| Compilation des 10 fichiers touchés/créés | 10/10 | < 1 s | réussi |
| Ruff sur les fichiers nouveaux/modifiés du cœur (6) | 0 erreur | 0,3 s | réussi |
| Ruff sur `serve.py`/`watch.py` (fichiers appelants modifiés) | erreurs pré-existantes uniquement (E402/F401 déjà présentes sur HEAD, vérifié par comparaison directe) | 0,3 s | réussi, aucune régression |
| Bundle PyInstaller réel, 8 commandes exercées sur l'exécutable produit | 8/8 | ~30 s de build + secondes d'exécution | réussi |
#### Mesures
Aucune mesure P-01/P-10 formelle (temps de démarrage, mémoire). Écart
documenté : le protocole de benchmark (`benchmarks/stage0_baseline.py`)
mesure `blink2video.py` en tant que script, pas les nouveaux modules pris
séparément, et sa baseline globale reste elle-même non qualifiée depuis
l'étape 0 (28.2). Une future reprise formelle de ce protocole donnerait un
chiffre réel au gain de démarrage que 8.7/8.8 rendent possible pour
stop/open/--help (déjà démontré fonctionnel, pas encore chronométré).
#### Verdict et retour arrière
O-06 et O-09 sont fermés. Le point d'entrée ne contient plus de logique
métier (8.6) et chaque sous-système a désormais son propre fichier, condition
de sortie de la section 20.4 — mais O-07, O-10 et une partie de 8.9-8.11
restent ouverts, donc le critère de sortie formel n'est pas intégralement
atteint.
**Verdict local : acceptée. Verdict formel : en attente de la matrice CI sur
les trois OS.** Retour arrière : plus délicat que les étapes précédentes vu
l'ampleur du déplacement de code ; `git revert` du commit de cette étape
reste mécaniquement possible (aucune migration de données, aucun format de
fichier changé), mais toucherait dix fichiers d'un coup. Alternative plus
sûre en cas de régression isolée : ne revenir que sur le fichier concerné
(par exemple `blink_engine.py`) en gardant le reste du découpage, les cinq
modules étant faiblement couplés par construction (graphe acyclique déjà
documenté ci-dessus).
## Étape 9 — Qualification finale et release
Statut : **partielle le 17 août 2026 ; la matrice complète (section 21.1)
dépasse ce qu'une session locale peut couvrir**.
### 28.14 Ce qui a été fait localement, ce qui ne pouvait pas l'être
#### Décision de périmètre
La quasi-totalité de la matrice 21.1 (Python 3.11/3.12 × Windows/Linux/
macOS, Apple Silicon, bundle sur trois OS, test réel avec compte et 2FA,
crash injecté à chaque frontière commit/rename, jeu de 600 clips cloud
réel) exige une infrastructure multi-OS que cette session, sur une seule
machine Windows, ne peut pas produire elle-même. Décision prise avec
l'utilisateur : faire ce qui est réellement local, puis déclencher la
matrice CI déjà configurée dans `.github/workflows/ci.yml` par un push —
seule vraie voie vers une qualification multi-OS depuis cette position.
#### Travail effectué
- **9.9, volumétrie** (`benchmarks/stage0_baseline.py --profile full --only
P-02,P-03,P-04,P-05`) : mesuré à 100 000 événements avec exactitude
vérifiée à chaque palier :
| Mesure | 100 | 1 000 | 10 000 | 100 000 |
|---|---:|---:|---:|---:|
| P-02 (lecture/écriture registre JSON) | 3,4 ms | 25,6 ms | 237 ms | 2 744 ms |
| P-03 (mise à jour d'état) | — | — | — | 2 741 ms |
| P-04 (vérification d'acquisition) | — | — | — | 7 848 ms |
| P-05 (rapprochement 100 000 local × 5 000 cloud) | — | — | — | 117,6 ms |
Aucune dérive quadratique observée ; P-05 confirme à grande échelle le
gain déjà mesuré à l'étape 2 (index temporel + appariement Hopcroft-Karp,
section 28.5).
- **Incident découvert en y touchant** : `benchmarks/stage0_baseline.py`
référençait encore `b2v.STATE_FILENAME`, `b2v.target_path`,
`b2v._ecrire_registre`, `b2v.rapprocher`, `b2v.state_key`,
`b2v.remember_download`, `b2v.is_downloaded`, `b2v.read_cloud_manifest`,
`b2v.CloudClip`, `b2v.save_session`, `b2v.load_saved_session` — tous
déplacés par l'étape 8. Le fichier était donc cassé pour toute mesure
volumétrique depuis ce commit, sans qu'aucun test ne l'ait couvert (les
benchmarks ne tournent pas dans la suite `unittest`). Corrigé avec la
même méthode qu'à l'étape 8 (script de correspondance par nom), vérifié
en relançant réellement la mesure plutôt qu'en se fiant à la compilation
seule.
- **README FR/EN** (`docs.py --check`) : les deux à jour, aucune régénération
nécessaire — la table des verbes et les chemins documentés dérivent tous
de `runtime.VERBES`/`runtime.app_dir()`, inchangés dans leur forme
publique malgré le découpage interne.
- **Passe de régression finale** : 119 tests isolés (119 réussis), suite
historique `tests.py` complète verte, immédiatement avant le push.
- **9.18, sans argument = start, sources et bundle** : déjà couvert en
sources par les tests E-01 (étape 5) et par la suite historique ; côté
bundle, vérifié manuellement à l'étape 8 sur l'exécutable PyInstaller réel
(`blink2video.exe stop`, `--help`, `start --help`, etc. — section 28.13).
- **Déclenchement de la matrice CI** (exécution `32044634431`, déclenchée
manuellement via `workflow_dispatch` : le déclencheur `push` du workflow ne
vise que `main`, pas cette branche) : premier passage réellement exécuté
de ce workflow depuis l'ouverture de l'audit — chaque étape précédente
avait laissé la qualification CI « à confirmer », jamais vérifiée pour de
vrai. Deux défauts génériques révélés, aucun spécifique à une seule
plateforme :
1. **`tzdata` absent du job « caractérisation »** (`aiohttp blinkpy`
installés, jamais `tzdata`, contrairement aux jobs « chaîne vidéo » et
« bundle »). Sans effet tant que `runtime.bootstrap()` n'était appelé
qu'une fois, au tout début de `blink2video.py` ; l'étape 8 l'a rendu
paresseux (appelé depuis `accueillir()`/`executer()`/`main()`, et par
les imports déjà existants de `serve.py`/`watch.py`), ce qui a suffi à
exposer ce trou dès la première exécution réelle — 9 tests en échec sur
trois OS, tous avec la même trace. Corrigé dans `ci.yml`.
2. **Assertion trop stricte sur le délai d'onboarding** dans `tests.py`
(`test_verbes`, ajoutée au 28.10). Touché sur `bundle windows-latest` et
`chaîne vidéo macos-latest`. Deux corrections successives :
- d'abord acceptée l'un ou l'autre des deux messages de fin de délai
(port qui ne répond pas, ou authentification jamais confirmée) plutôt
qu'un seul texte exact ;
- insuffisant : un second déclenchement a montré `bundle windows-latest`
échouer encore, avec dans le journal CI un message visiblement corrompu
(« D�lai de connexion d�pass� »). Le comportement réel du programme
n'était pas en cause — seul le décodage, par ce test, de la sortie
accentuée de l'exécutable PyInstaller figé l'était. Corrigé en
cherchant « Abandon », mot ASCII commun aux deux messages, plutôt
qu'un texte accentué dont l'encodage n'est pas fiable d'une forme
d'exécution à l'autre (source vs bundle).
3. **Sans rapport avec ce chantier, signalé sans être corrigé** :
`bundle ubuntu-22.04` échoue à la récupération de secours de ffmpeg
dans `build.py` (`urllib.error.HTTPError: HTTP Error 404`) — l'URL vise
le tag `latest` du dépôt `BtbN/FFmpeg-Builds`, connu pour se déplacer.
Pré-existant, sans lien avec les étapes 3 à 9 ; laissé pour une décision
séparée sur la source de secours à utiliser. **Corrigé après coup**
(demande explicite de l'utilisateur, hors du périmètre initial de
l'étape 9) : le nom de fichier embarquait le numéro de version ffmpeg
(`n7.1`), remplacé côté BtbN par une compilation plus récente (`n9.0`)
sous le même tag `latest`, d'où le 404. `master-latest`, variante sans
numéro que BtbN publie justement pour ne pas dépendre d'un nom qui se
déplace, la remplace. Structure interne de l'archive (`<nom>/bin/ffmpeg`)
vérifiée identique avant de committer, pas seulement supposée.
Quatre déclenchements complets :
| Exécution | tzdata | délai onboarding | ffmpeg secours (ubuntu-22.04) |
|---|---|---|---|
| `32044634431` | rouge (9 jobs) | rouge (windows, macos) | rouge |
| `32051601394` | vert | rouge (windows seul, encodage bundle) | rouge |
| `32052057435` | vert | vert | rouge |
| `32052496444` | vert | vert | **vert** |
**14 jobs sur 14 verts** à la quatrième exécution : caractérisation sur les
trois OS × deux versions de Python, chaîne vidéo complète sur les trois OS,
bundle réel sur les trois OS, artefacts publiés
(`blink2video-windows-x86_64`, `blink2video-linux-x86_64`,
`blink2video-macos-arm64`). Première exécution intégralement verte de toute
la durée de cet audit.
#### Ce qui n'a pas été fait, et pourquoi ce n'était pas possible ici
- 9.10 (600 clips cloud réels), 9.14 (test réel opt-in avec secrets locaux) :
demandent un compte Blink réel, explicitement exclu de toute automatisation
par la section 12.5 et par la posture de tout ce chantier depuis l'étape 0 ;
- 9.11 (backlog USB > 10 minutes), 9.13 (crash injecté à chaque frontière
commit/rename) : demandent du matériel Blink réel ou une instrumentation
de fautes plus lourde qu'un test unitaire, non construite dans ce plan ;
- 9.4 (macOS Apple Silicon) : couvert par le runner `macos-latest` de la CI
déclenchée (Apple Silicon côté GitHub Actions), pas testé sur le Mac mini
M1 disponible par ailleurs (Scaleway) — resterait une option de test manuel
si la CI échoue spécifiquement sur cette plateforme et qu'une investigation
directe devient nécessaire ;
- 9.15/9.16/9.17 (onboarding complet : 2FA, session expirée, navigateur
indisponible, headless, autostart) : les scénarios *simulés* sont déjà
couverts par les tests E-01 de l'étape 5 (section 28.10) ; un parcours
réel avec un compte Blink reste hors CI par construction, comme 9.10/9.14.
#### Verdict et retour arrière
La partie réalisable sans infrastructure multi-OS est faite et documentée :
volumétrie à 100 000 événements sans dérive quadratique, README cohérents,
régression complète verte, bundle réel exercé sur trois OS via la CI. La
matrice CI elle-même, déclenchée et suivie jusqu'au bout (quatre exécutions,
quatre défauts génériques trouvés et corrigés en cours de route, tous
antérieurs à cette étape mais jamais révélés faute d'exécution réelle),
finit à **14 jobs verts sur 14** : caractérisation × 3 OS × 2 versions de
Python, chaîne vidéo complète × 3 OS, bundle réel × 3 OS avec artefacts
publiés. Le critère de release de la section 21.2 reste néanmoins **non
atteint dans son ensemble** : les tests avec un compte Blink réel ainsi que
les scénarios matériel/crash de 9.10-9.14 restent hors CI par construction,
et rien ne remplace un usage réel prolongé.
**Verdict : qualification locale acceptée, qualification CI multi-OS
atteinte à 14/14, qualification de release complète non atteinte** (compte
réel et scénarios matériel non couvrables par CI). Retour arrière possible
sans risque sur les correctifs de ce lot (`benchmarks/stage0_baseline.py`,
`.github/workflows/ci.yml`, `tests.py`, `build.py`) : aucun changement de
comportement de production, uniquement des références cassées et des
défauts de configuration/test/build.
### 28.15 Hors séquence — comparatif des mécanismes de direct (MJPEG / MSE / WebRTC)
#### Décision de périmètre
Demande explicite de l'utilisateur, hors plan initial : accélérer/moderniser
le visionnage du direct, projet destiné à une publication GitHub/Reddit.
Trois approches comparées sur la caméra réelle « Salon » (Blink Mini,
intérieur, wifi stable — choisie plutôt que « jardin », plus éloignée, pour
isoler la mécanique de relais du bruit wifi côté caméra) : MJPEG réglé (déjà
en production), MSE/fMP4 (remux sans réencodage), WebRTC (aiortc).
#### Mesures réelles
| | MJPEG réglé (prod) | MSE/fMP4 | WebRTC (aiortc) |
|---|---|---|---|
| 1ère image (Salon) | 3,4 s | non chronométré en continu (sessions Blink trop courtes ce jour-là) | 16,3 s |
| CPU en régime | quasi nul (0-5 %, sous-processus ffmpeg) | quasi nul par construction (remux, pas de réencodage) | quasi nul (0-1,5 %, mesuré sur tout le process Python) |
| Mémoire | ~78 Mo (ffmpeg seul) | non isolée (même process) | ~92 Mo (process entier : aiohttp+blinkpy+av+aiortc) |
| Nouvelle dépendance | aucune | PyAV (`av`) | aiortc + cryptography + aioice + pylibsrtp (venv isolé nécessaire) |
#### Travail effectué
- **MSE/fMP4** (`send_live_mse`, route `/live-mse/<camera>`) : remux direct
via PyAV (`av.open` en lecture, remux vers un conteneur MP4 fragmenté en
écriture, aucun sous-processus ffmpeg). Chaîne de codec MSE
(`avc1.PPCCLL`) lue directement dans le SPS du flux réel plutôt que
devinée — nécessaire : la caméra Salon encode en H.264 High profile
(`avc1.640028`), pas Baseline comme l'hypothèse initiale le supposait,
confirmant que la détection dynamique était le bon choix.
- **WebRTC** (`send_webrtc_offer`/`send_webrtc_stop`, aiortc) : connexion
réelle établie et confirmée avec la caméra Salon (état `connected`, vidéo
décodée côté navigateur). Bug réel trouvé et corrigé en cours de route :
`MediaPlayer()` d'aiortc ouvre et sonde le flux de façon synchrone et
bloquante ; appelé tel quel dans la coroutine partagée avec le reste de
l'appli, un flux qui traîne gèle la boucle asyncio unique dont dépendent
*aussi* le direct MJPEG, MSE et l'actualisation du système — pas que cette
requête. Corrigé en isolant l'ouverture dans un thread d'exécuteur borné
par un délai (`loop.run_in_executor` + `asyncio.wait_for`).
- **Décision utilisateur, avec les chiffres en main** : MSE retenu. WebRTC
écarté malgré la préférence initiale de l'utilisateur pour ce protocole :
son avantage (latence, franchissement NAT) sert un usage multi-pairs par
Internet, pas un spectateur unique sur une page servie en local — l'
avantage ne s'exprime pas ici, le coût (dépendance lourde, négociation
plus lente à l'usage que le MJPEG, piège de blocage découvert en direct)
reste entier. Code WebRTC entièrement retiré (serveur, routes, JS, venv
`.venv-webrtc`) après la décision, pour ne pas laisser de code mort.
- **Bouton principal « Voir en direct »** basculé de `watch()` (MJPEG) à
`watchMse()`.
- **Deux bugs réels trouvés et corrigés via des essais sur la caméra réelle
avec l'utilisateur** :
1. CSS : `.live img { width:100%; height:100%; ... }` ne visait que
`<img>`, pas `<video>` — la balise vidéo MSE n'avait donc aucune
taille définie. Étendu à `.live img, .live video`.
2. L'attribut `autoplay` seul ne déclenche pas la lecture sur une balise
`<video>` insérée via `innerHTML` dans ce navigateur de bureau
(confirmé : fonctionnait sur mobile, pas sur le PC de l'utilisateur,
avant le correctif — `readyState` atteignait 1 et les dimensions
étaient détectées, mais `paused` restait `true`). Corrigé par un appel
explicite à `video.play()` dès la première image reçue.
- **Diagnostic écarté en cours de route** : hypothèse d'une session
concurrente (appli Blink sur un iPhone) responsable des échecs
intermittents — infirmée par l'utilisateur (appli fermée, mode avion,
aucun changement). L'entrée « iPhone 15 Pro » vue dans un journal d'échec
est un résidu de l'historique de commande Blink, pas une session active.
- **Constat structurel, à noter pour la suite** : les échecs intermittents
observés toute la session (« Insufficient data for payload »,
« Connection error… possibly down or throttled », sessions à 0 octet
transmis) viennent de `blinkpy`, bibliothèque tierce qui rétro-ingénie le
protocole de direct de Blink, *pas* du code de ce projet — le téléphone de
l'utilisateur, via l'appli officielle Blink, n'a rencontré aucun de ces
échecs pendant les mêmes essais. Cela correspond très probablement au
rapport d'usage réel initial de cette session (« le direct moins fluide
que le téléphone ») : un plafond de fiabilité pré-existant, commun aux
trois approches testées, puisque toutes les trois partent du même
`camera.init_livestream()` de blinkpy. Aucune action possible côté ce
projet ; à garder en tête pour calibrer les attentes plutôt qu'à chercher
à corriger.
#### Ce qui n'a pas été fait
- Confirmation visuelle finale de MSE dans le navigateur de bureau de
l'utilisateur : les deux bugs trouvés sont corrigés et validés séparément
(dimensions détectées, lecture démarrée sur mobile), mais les sessions
Blink ont échoué avant transmission de données lors des derniers essais du
jour (instabilité `blinkpy`/cloud Blink constatée ci-dessus, pas liée au
correctif). À reconfirmer dès que Blink coopère.
- Mesure de débit MJPEG propre : les tentatives répétées de chronométrage
précis d'une session complète ont buté sur la même instabilité Blink.
#### Tests et vérifications
- MSE : remux validé de bout en bout sur média synthétique (H.264 Baseline
généré localement, mêmes réglages ffmpeg que le pipeline réel) —
conteneur de sortie relu par ffmpeg sans erreur, débit identique à
l'entrée (remux confirmé, pas de réencodage). Testé aussi en écriture vers
un tube non permanent (`os.pipe`), pour reproduire le comportement d'une
socket HTTP.
- MSE : capture réelle d'une session Salon (2,5 Mo, 17,4 s) — fichier relu
par ffmpeg sans erreur (H.264 High, 1920×1080, 29,97 im/s). Rejoué dans le
navigateur en un seul bloc (`readyState` → 1, dimensions détectées) puis
en flux morceau par morceau (identique) : la mécanique MSE elle-même est
saine, confirmé à deux reprises indépendamment de l'échec d'affichage
final (résolu depuis, voir bugs CSS/`play()` ci-dessus).
- WebRTC : chemins d'erreur propres vérifiés (caméra inconnue → 503,
aucune fuite de `MODULE_SLOT`) avant et après le correctif du blocage.
- `python -m py_compile`/`ast.parse` après chaque modification.
#### Verdict et retour arrière
MSE retenu comme direction ; code intégré (bouton principal, CSS, `.play()`
explicite). WebRTC entièrement retiré, aucune trace résiduelle (vérifié par
recherche exhaustive de `webrtc`/`aiortc`/`RTCPeerConnection` dans
`serve.py`). Retour arrière possible sans risque : le MJPEG existant
(`send_live`, route `/live/`) reste intact et fonctionnel, seul le bouton
principal de la grille de caméras pointe désormais vers MSE.
### 28.16 Hors séquence — correction du diagnostic MSE et confirmation réelle
#### Ce que 28.15 avait mal diagnostiqué
Les essais réels avec l'utilisateur, après le retrait de WebRTC, ont révélé
que l'explication « instabilité générale de `blinkpy` côté cloud, commune
aux trois approches » retenue en 28.15 était incomplète. Preuve : `/live`
(MJPEG, code non modifié) a streamé 10 Mo sans interruption en 15 secondes
sur la même caméra, au même moment où `/live-mse` échouait de façon répétée.
La vraie cause : `send_live_mse` utilisait PyAV en lecture directe du flux
TCP local que republie `blinkpy`, alors que `/live` lit ce même flux via un
sous-processus ffmpeg — et PyAV s'est montré nettement moins fiable que
ffmpeg sur ce flux précis, pour une raison non identifiée plus précisément
(hors du temps disponible pour cette session).
#### Correctif
`send_live_mse` réécrit pour utiliser un sous-processus ffmpeg (remux
`-c:v copy`, mêmes options que `/live`), au lieu de PyAV en process. Perte :
PyAV donnait l'extradata H.264 directement via son API ; sans lui, la chaîne
de codec MSE (`avc1.PPCCLL`) doit être lue en parcourant les boîtes du
segment d'initialisation MP4 renvoyé par ffmpeg (`h264_mime_codec_from_moov`,
chemin moov > trak > mdia > minf > stbl > stsd > avc1 > avcC — avcC porte
déjà profil/contraintes/niveau en clair, pas besoin de redescendre jusqu'au
SPS). Ajout de `read_mp4_init_segment` : accumule plusieurs lectures tant
qu'un avcC complet n'est pas trouvé, un seul bloc de 4 Ko (suffisant pour
MJPEG) ne l'étant pas toujours pour un moov.
Deux autres bugs réels trouvés et corrigés en cours de route, avant ce
correctif de fiabilité (déjà présents dans le code retiré/réécrit, donc
présents à nouveau ici, corrigés une fois pour toutes) :
- CSS `.live img` étendu à `.live img, .live video` (la balise vidéo MSE
n'avait aucune taille définie).
- Appel explicite à `video.play()` dès la première image reçue (l'attribut
`autoplay` seul ne suffisait pas sur une balise insérée via `innerHTML`
dans le navigateur de bureau testé, confirmé fonctionnel sur mobile avant
ce correctif déjà).
#### Validation
- Remux via sous-processus ffmpeg + `read_mp4_init_segment` +
`h264_mime_codec_from_moov` validés de bout en bout sur média synthétique
(deux sources, profils Baseline et confirmation du chemin de boîtes),
chaîne de codec exacte retrouvée dans les deux cas.
- **Confirmation réelle par l'utilisateur, dans son navigateur de bureau
normal (pas l'environnement de test automatisé)** : lecture MSE effective
sur la caméra Salon, plusieurs secondes d'image avant la fin naturelle
(courte) de la session Blink. Journal serveur correspondant :
segment initial 4096 octets, en-têtes envoyés avec `codec avc1.640028`,
1 057 942 octets transmis avant fin normale de session.
- Un faux négatif en cours de route : un premier essai « ça n'a pas marché »
de l'utilisateur testait en fait l'instance de production (port 8765,
ancien code sans MSE) plutôt que l'instance de test (port 8899) — écarté
une fois la confusion clarifiée.
#### Verdict
MSE confirmé fonctionnel de bout en bout sur caméra réelle, dans un
navigateur réel. Le plafond de fiabilité de `blinkpy` évoqué en 28.15 reste
réel (sessions Blink courtes ou en échec, indépendamment de ce projet) mais
n'explique plus, à lui seul, les échecs répétés observés avant ce correctif
: une partie venait bien de PyAV. Section 28.15 laissée telle quelle
(journal chronologique) ; cette section corrige et complète son diagnostic.
### 28.17 Hors séquence, urgent — `stop` pouvait tuer un processus sans rapport
#### Incident
En testant le direct MSE en conditions réelles, `blink2video stop` a été
invoqué à plusieurs reprises sur la machine de l'utilisateur. Une fiche
d'instance (`.blink_run/17168.json`) datait de la veille (2026-08-16) ; son
PID parent (17168) ne correspondait plus au vrai processus depuis longtemps
mort, Windows ayant entre-temps réattribué ce numéro à un processus système
sans rapport (`HPAudioAnalytics.exe`, un service HP). `stop` a tenté de le
tuer trois fois via `taskkill /F /T` (refusé par Windows, probablement un
processus protégé — sans quoi le dégât aurait été pire). **D'autres fiches
périmées, avec des PID réattribués à d'autres logiciels, ont réellement fait
fermer une session de terminal et tuer le processus de la messagerie
Signal** sur la machine de l'utilisateur, avant que le problème ne soit
identifié.
#### Cause racine
`processus_vivant(pid)` (runtime.py) ne vérifiait que l'existence d'un
numéro de processus (`tasklist`/`os.kill(pid, 0)`), jamais son identité. Un
PID est réattribué par l'OS après la mort de son propriétaire d'origine ;
une fiche d'instance jamais nettoyée (crash, coupure de courant, `stop`
manqué) devient alors un panneau indicateur pointant vers n'importe quel
autre logiciel lancé depuis. `blink_cli.arreter()` appelait
`runtime.arreter_processus(pid)` sur la seule foi de ce numéro, sans aucune
vérification d'identité avant de tuer.
#### Correctif
- **`runtime.ligne_de_commande(pid)`** (nouveau) : lit la ligne de commande
réelle d'un PID (`ps -o args=` hors Windows ; `Get-CimInstance
Win32_Process` sous Windows — remplaçant moderne de `wmic`, déprécié et en
cours de retrait des images Windows récentes).
- **`runtime.processus_correspond(pid)`** (nouveau) : vrai seulement si le
PID existe *et* que sa ligne de commande porte l'un des marqueurs attendus
de ce projet — soit `"blink2video"` (le point d'entrée, quelle que soit la
façon dont l'utilisateur l'a tapé, ou l'exécutable en bundle), soit le
dossier absolu du projet (les sous-verbes lancés depuis les sources sont
des scripts différents — serve.py, watch.py, merge_daily.py — mais tous
relancés avec un chemin absolu qui y est ancré ; voir `self_command`).
Les deux marqueurs sont nécessaires : le premier seul ratait les
sous-verbes autres que `download` (`blink2video.py download` seulement),
le second seul ratait le point d'entrée quand il est lancé avec un chemin
relatif. Vérifié empiriquement contre les 6 processus réels d'une
instance de production (`serve.py`, `watch.py`, deux `download`,
`merge_daily.py`, le point d'entrée) et contre le PID réattribué de
l'incident : les six vrais processus correspondent, le processus HP non.
- **`blink_cli.arreter()`** : vérifie `processus_correspond` sur chaque
membre (parent et enfants) *avant* tout appel à `arreter_processus` —
jamais plus seulement `processus_vivant`. Un PID vivant mais qui ne
correspond pas plus est traité comme déjà mort pour ce projet (sa fiche
est nettoyée, sans qu'on y touche), pas comme un survivant à tuer.
- **`runtime.lire_instances()`** : même bascule de `processus_vivant` à
`processus_correspond` pour décider si une fiche décrit encore une
instance réellement en cours, avant même d'atteindre `arreter()`.
- Portée volontairement limitée aux chemins qui *tuent* (`arreter`) ou
décident si une instance est « en cours » (`lire_instances`), les plus
sensibles. `verrou()`/`travail_en_cours()` gardent `processus_vivant` seul
(ils ne tuent rien, au pire une reprise de verrou légèrement tardive).
#### Tests et vérifications
- Nouveau test `test_stop_ne_tue_pas_un_pid_reattribue` : un PID vivant dont
la ligne de commande ne porte aucun marqueur du projet ne doit jamais
déclencher `arreter_processus`, quel que soit le reste — la fiche périmée
est nettoyée comme si l'arrêt avait réussi, sans qu'on y touche.
- Deux tests existants (I-13, I-14) mockaient `processus_vivant` seul ;
mis à jour pour mocker `processus_correspond`, désormais la fonction
réellement utilisée par le chemin qu'ils exercent.
- Suite complète : 120 tests, verte.
- Vérification manuelle directe sur la machine de l'utilisateur, contre
l'incident réel : `processus_correspond(17168)` (le processus HP) → faux ;
les 6 PID de l'instance de production alors en cours → vrai pour chacun.
#### Ce qui n'a pas été fait
- Pas de mécanisme de nettoyage périodique des fiches périmées indépendant
d'un `stop` explicite (une fiche laissée par un crash reste jusqu'au
prochain `stop`, comme avant ce correctif — seul ce qui se passe *pendant*
`stop` a changé).
- `verrou()`/`travail_en_cours()` non repris par ce correctif (voir portée
ci-dessus) : même défaut de principe, mais sans risque de tuer quoi que ce
soit.
#### Verdict
**Correctif de sécurité critique, vérifié empiriquement contre l'incident
réel qui l'a motivé.** Aucune perte de fonctionnalité : les 6 processus
réels d'une instance de production sont tous reconnus après correctif.
Retour arrière déconseillé : reviendrait à réintroduire un défaut qui a
déjà causé un dégât réel constaté (session de terminal fermée, processus de
messagerie tué) sur la machine de l'utilisateur.
### 28.18 Hors séquence — direct MSE instable en production, cause réelle identifiée
#### Constat
En conditions réelles de production (caméra Salon), le direct MSE affichait
bien l'image mais s'arrêtait au bout de quelques secondes, de façon
irrégulière : tantôt ~1 Mo transmis avant coupure, tantôt un échec quasi
immédiat (segment initial de 802 octets au lieu des ~4096 habituels), tantôt
rien du tout. Une reprise manuelle (clic « Arrêter » puis « Voir en
direct ») réussissait presque toujours.
#### Cause réelle
Le journal serveur pointe systématiquement vers la même ligne :
`Insufficient data for payload: 114 bytes, expected 1316`. Elle vient de
`blinkpy/livestream.py` (paquet installé, pas ce projet) : `LiveStreamServer.recv()`
lit un protocole propriétaire (en-tête 9 octets : type, séquence, longueur de
charge utile) depuis le relais Blink/Amazon ; si `target_reader.read(payload_length)`
renvoie moins que demandé, c'est que la connexion TLS vers Blink s'est
terminée avant la fin d'un paquet — `asyncio.StreamReader.read(n)` n'écourte
sa lecture que sur EOF, jamais sur un simple manque de données disponibles
tout de suite (il attend/accumule sinon). blinkpy journalise un avertissement
puis abandonne (`break`) et referme la connexion cible dans son `finally`.
Rien côté `serve.py` ne cause cette coupure : c'est le relais Blink lui-même
qui ferme la connexion en plein paquet, de façon visiblement irrégulière
(tantôt après ~1 Mo, tantôt immédiatement). Confirmé par lecture directe du
source installé (`site-packages/blinkpy/livestream.py:216`), pas par
hypothèse.
#### Deux corrections distinctes
1. **Un vrai bug trouvé en chemin.** `self.wfile.write(first)` (le tout
premier envoi, en-têtes déjà partis) n'était protégé par aucun des trois
`except` (`BrokenPipeError`, `ConnectionResetError`,
`ConnectionAbortedError`) que la boucle applique déjà à chaque bloc
suivant — vu en vrai dans le journal (`echec, ConnectionAbortedError:
[WinError 10053]`) alors qu'il s'agissait très probablement d'une fin de
connexion aussi bénigne que celles que la boucle sait déjà ignorer.
Présent à l'identique dans `send_live` (MJPEG) et `send_live_mse` :
corrigés tous les deux en factorisant l'écriture derrière un petit
`_ecrire()` local qui porte la même tolérance partout, y compris sur ce
premier bloc.
2. **Reprise automatique côté client.** `watchMse` boucle désormais sur
`connecterMse()` (un cycle connexion → flux → fin, extrait de l'ancien
corps de `watchMse`), et relance une connexion neuve (nouveau
`MediaSource`, nouvelle URL de blob) après un court délai quand le flux
s'arrête sans que l'utilisateur ait cliqué « Arrêter ». Le compteur
d'échecs ne grimpe que sur une reprise n'ayant livré aucune image ; dès
qu'une image arrive, il retombe à zéro, pour ne jamais abandonner un
direct qui fonctionne juste par à-coups. `MSE_BUDGET_TOTAL_MS` (10 min)
borne quand même la durée totale : un onglet oublié ouvert ne doit pas
solliciter indéfiniment la caméra (et sa batterie) — passé ce budget
pendant que ça fonctionnait, retour silencieux au repos, pas une erreur.
#### Pourquoi côté client plutôt que côté serveur
Recoller les fragments MP4 de deux sessions ffmpeg distinctes dans le même
`SourceBuffer` (sans repartir d'un `MediaSource` neuf) exigerait de gérer
soi-même la continuité des temps de présentation (`timestampOffset`,
numérotation `moof` qui recommence à zéro à chaque nouvelle session ffmpeg)
— complexité réelle pour un gain surtout cosmétique (un clignotement noir
d'une fraction de seconde remplace une coupure franche). Une requête HTTP
neuve par tentative reste le modèle déjà en place pour `/live` et
`/live-mse` (toute une session tient dans une seule requête) ; l'automatiser
côté client revient à faire à la machine ce que l'utilisateur faisait déjà à
la main, sans rien inventer de nouveau côté protocole.
#### Ce qui n'a pas été fait
- Aucune tentative de contourner ou stabiliser la connexion Blink elle-même
(hors de portée : c'est blinkpy et le relais Amazon, pas ce projet).
- Pas de télémétrie des reprises au-delà du texte « Reconnexion… » affiché :
suffisant pour l'usage (comprendre ce qui se passe maintenant), pas
construit pour du monitoring longue durée.
#### Tests et vérifications
- Suite complète : 120 tests (`python -m unittest discover`), verte, aucune
régression.
- Cause racine confirmée par lecture du source `blinkpy` installé (pas une
hypothèse) : la ligne de log vue par l'utilisateur existe telle quelle
dans `livestream.py`, avec le `break` + fermeture qui explique la coupure
observée.
- Reprise automatique pas encore revalidée en conditions réelles (nécessite
un nouvel essai de l'utilisateur en production) : à confirmer.
#### Verdict
Le direct MSE n'a pas de défaut de conception propre : la source de
l'instabilité est extérieure au projet (fiabilité du relais Blink), déjà
pressentie en 28.16 mais sans preuve directe jusqu'ici. Les deux correctifs
rendent l'instabilité invisible pour l'utilisateur (reprise automatique)
plutôt que de prétendre la supprimer, ce qui n'est pas possible depuis ce
projet.
### 28.19 Hors séquence — le vrai correctif : read() vs readexactly() dans blinkpy
#### Ce que 28.18 avait encore mal évalué
28.18 concluait que la source de l'instabilité était « extérieure au
projet » et qu'il n'y avait rien à faire dessus, seulement à la rendre
invisible côté client. Faux en partie : recherche complémentaire (issue/PR
amont `fronzbot/blinkpy#1232`, ouverte, non publiée sur PyPI) révèle que
`BlinkLiveStream.recv()` utilise `StreamReader.read(n)`, qui **ne garantit
pas** de lire `n` octets - il rend dès que la moindre donnée est disponible.
Un paquet vidéo coupé entre deux segments TCP (fréquent sur les paquets un
peu gros) se lit alors en plusieurs morceaux, et le premier morceau
incomplet est pris pour une connexion morte : blinkpy referme lui-même une
session pourtant saine. Ce n'est donc pas Blink qui coupe (bien que 28.18 le
présente ainsi) - c'est un vrai bug côté client, connu et documenté en
amont, avec un correctif proposé mais pas encore publié.
#### Vérification directe (pas seulement lue, reproduite)
Script isolé : instancier `BlinkLiveStream` sans passer par le protocole
réel, lui fournir un `asyncio.StreamReader` où l'en-tête (9 octets) puis un
premier morceau de charge utile (100 sur 188 octets annoncés) sont déjà
disponibles, laisser `recv()` s'exécuter, puis compléter la charge utile en
cours de route. Contre le blinkpy stock : `Insufficient data for payload:
100 bytes, expected 188` dans les logs, `client.write` jamais appelé - la
donnée est perdue. Avec le correctif : le morceau complet (188 octets)
atteint bien le client.
#### Correctif
`blink_engine.py` remplace `BlinkLiveStream.recv` par une version corrigée
(monkeypatch appliqué à l'import du module, donc actif partout où
`blink_engine` est utilisé) : mêmes règles de filtrage et de journalisation
que l'original, seules les deux lectures `read(n)` deviennent
`readexactly(n)`, encadrées d'un `try/except asyncio.IncompleteReadError`
qui ne se déclenche que sur une vraie fin de connexion. Commentaire dans le
code pointant vers blinkpy#1232 : à retirer dès qu'une version publiée de
blinkpy intègre ce correctif (une vérification de version à chaque montée
de blinkpy suffira, pas besoin de plus).
Complément découvert dans la même discussion amont : Blink n'accepte qu'une
session de direct par compte à la fois, avec un délai de libération côté
serveur si le client ne prévient pas proprement. `MSE_DELAI_RECONNEXION_MS`
(serve.py) relevé de 1,5 s à 3 s en conséquence - marge raisonnable sans
rendre la reprise perceptiblement lente, notre code appelant déjà
explicitement la commande de fin de session (`_stop_stream`) plutôt que de
compter sur le délai serveur par défaut.
#### Pourquoi un monkeypatch plutôt qu'autre chose
- Épingler une branche Git non fusionnée (`pip install git+...@branche`)
aurait été plus fragile à terme (branche renommée ou supprimée, aucune
garantie de version) qu'un correctif local de quelques lignes, documenté
et attribué.
- Vendorer une copie complète de `livestream.py` aurait dupliqué un fichier
entier pour deux lignes changées, à resynchroniser à chaque mise à jour de
blinkpy - largement disproportionné.
- Un monkeypatch ciblé, minimal, commenté et attribué à une PR amont précise
est une pratique standard en Python pour ce cas de figure précis
(dépendance avec correctif connu mais pas encore publié) : ni invention
locale, ni pari sur une branche instable.
#### Tests et vérifications
- Nouveau test `test_livestream_readexactly_reassemble_payload_fragmente`,
qui reproduit fidèlement le scénario ci-dessus (adapté d'un test de
régression proposé dans la discussion de blinkpy#1232). Vérifié qu'il
échoue bien contre le blinkpy stock (reproduction du symptôme exact vu en
production) avant d'être ajouté à la suite, pour être sûr qu'il teste
réellement quelque chose et pas un mock complaisant.
- Suite complète : 121 tests, verte.
- Reste à confirmer en conditions réelles (redémarrage de la prod
nécessaire pour charger le correctif).
#### Verdict
Contrairement à 28.18, l'instabilité n'était pas hors de portée du projet :
un vrai bug de fiabilité, dans une dépendance, avec un correctif amont connu
mais pas encore publié. Le monkeypatch documenté est le compromis correct
en attendant une version de blinkpy qui l'intègre ; la reprise automatique
côté client (28.18) reste utile en complément, pour les cas que même un
`recv()` correct ne peut pas éviter (session Blink concurrente rejetée,
compte hors de portée, etc.).
### 28.20 Hors séquence — la vraie cause de « ça repart plus » : renderLive() écrase le direct en cours
#### Constat
Même avec 28.18 (reprise automatique) et 28.19 (correctif blinkpy) en place,
un direct pouvait encore s'arrêter net après quelques secondes sans jamais
reprendre, silencieusement (aucune erreur serveur : `/api/live-error` vide
après coup). Un précédent point de session (avant cette suite de sections)
avait déjà repéré des éléments `#live-*` remplacés sous les yeux d'un direct
actif, sans en trouver la cause précise, seulement un soupçon porté sur
`heuresDePassage()`.
#### Cause réelle
`renderLive()` reconstruit tout le contenu de `#list` sans condition :
`$("list").innerHTML = system.systems.map(...).join("")`. Ça inclut la
boîte `#live-<caméra>` de la caméra en cours de visionnage, balise `<video>`
et bouton « Arrêter » compris - remplacés par un état de repos flambant
neuf. Rien n'annule proprement la session en cours (pas d'appel à
`stopWatch`, pas d'`abort()`) : la boucle de reprise de `watchMse` continue
de tourner sur des références DOM orphelines, invisible, pendant que
l'écran affiche une vignette de repos. D'où « ça s'arrête et ça ne repart
jamais » - la reprise automatique de 28.18 tournait bien, mais dans le vide.
Chemin de déclenchement : `veiller()` (boucle `setTimeout` auto-récursive)
appelle `heuresDePassage()` toutes les 60 s au repos, toutes les 3 s pendant
qu'un travail de fond tourne (`travailEnCours`). Si un nouveau clip est
adopté pendant ce passage et que la case « auto » est cochée,
`heuresDePassage()` appelle `load()`, qui appelle `render()`, qui appelle
`renderLive()`. Une instance de production fait tourner en permanence
`watch --loop 10`, deux `download --loop`, et `merge --loop 5` : un nouveau
clip pendant les quelques secondes d'un essai de direct n'a rien
d'exceptionnel, surtout avec l'intervalle resserré à 3 s pendant qu'un de
ces passages est actif.
#### Correctif
`renderLive()` : garde en tête de fonction, `if
(Object.keys(MSE_ABORT).length) return;` - tant qu'une caméra est
activement regardée (MSE), la grille entière n'est pas reconstruite. Le
reste de l'interface (compteur de travail, bouton de mise à jour, liste des
clips) continue de se rafraîchir normalement ; seule la grille de caméras
gèle, et seulement pendant qu'un direct tourne réellement (`MSE_ABORT`
reste peuplé pendant toute la boucle de reprise de `watchMse`, pas
seulement pendant qu'une image transite).
#### Ce qui n'a pas été fait
- Pas de reconstruction sélective (garder les autres caméras à jour, geler
seulement celle en cours) : plus de code pour un gain marginal - la
grille entière se rattrape dès la fin du direct.
- Pas de test automatisé pour ce correctif précis : c'est un changement
JavaScript pur, et la suite de tests de ce projet ne couvre que le côté
serveur (aucune infrastructure de test JS existante à ce jour) ; forcer
un test aurait été plus de mécanique que la ligne corrigée elle-même. À
confirmer par l'utilisateur en conditions réelles, comme le reste des
correctifs JS de cette session.
#### Tests et vérifications
- Suite complète : 121 tests, verte (changement JS pur, aucun test Python
affecté).
- Diagnostic construit sur preuve, pas hypothèse : `/api/live-error` vide
après l'essai signalé par l'utilisateur, ce qui élimine tout échec serveur
et pointe vers une cause purement côté client - lecture du code a
confirmé `renderLive()` comme unique chemin plausible.
#### Verdict
Probablement la cause dominante de l'instabilité perçue depuis le début de
cette suite de sections (28.18-28.20), plus que les soucis blinkpy déjà
corrigés : ceux-ci expliquaient des coupures ponctuelles récupérables par la
reprise automatique, celui-ci empêchait la reprise de jamais s'afficher. Les
trois correctifs restent complémentaires, pas redondants.
### 28.21 Hors sequence - confirmation reelle, plusieurs minutes sans coupure
Suite testee par l'utilisateur en production (camera Salon) apres les trois
correctifs 28.18-28.20 combines : plusieurs minutes de direct MSE en continu,
sans coupure. Cloture la serie 28.15-28.20 : les trois causes distinctes
identifiees (fiabilite blinkpy corrigee, delai de reprise ajuste, DOM du
direct protege contre renderLive) etaient bien les seules en jeu pour ce cas
d'usage. Aucune action de plus prevue sur ce sujet sauf regression rapportee.
### 28.22 Hors séquence — le vrai bug jardin : fils orphelins dans read_mp4_init_segment
#### Ce que 28.18-28.21 avaient manqué
Toute la série précédente (28.18-28.21) corrigeait des défauts réels mais
n'expliquait pas un nouveau symptôme : le direct MSE échouait sur « jardin »
(« Live view failed », Blink lui-même le signale) alors que « Salon »
tournait de façon stable depuis 28.21. Écarté d'abord comme un problème de
portée radio (wifi_strength à -82 dBm, contre -65 pour la caméra la plus
proche) - hypothèse plausible mais fausse, corrigée par l'utilisateur :
« la détection de mouvement et le rapatriement des clips marchent bien » et
« avec l'ancien code, sans MSE, le direct marchait sur jardin ».
#### Vérification directe
Test A/B immédiat, à la suite, sur la même caméra : `/live` (MJPEG, code
non modifié) réussit en 5,4 s, 200 Ko reçus sans erreur. `/live-mse` échoue
en 4 s avec le même message Blink « Live view failed ». Les deux routes
appellent exactement le même `camera.init_livestream()` : si la caméra était
hors de portée, les deux échoueraient pareil. Ce n'est donc pas la radio,
c'est le code MSE spécifiquement.
#### Cause réelle
`read_mp4_init_segment` boucle en appelant `read_with_deadline` tant que le
moov n'est pas complet. `read_with_deadline` lance un nouveau fil à chaque
appel pour porter un délai sur une lecture bloquante ; si ce délai est
dépassé, la fonction rend une valeur vide, mais **le fil, lui, continue de
tourner** - `pipe.read()` bloquant ne s'annule pas. L'appel suivant, dans la
boucle de `read_mp4_init_segment`, lance un second fil sur le même tube,
concurrent du premier, toujours vivant. Le prochain octet qui arrive peut
atterrir chez l'un ou l'autre : s'il atterrit chez le fil orphelin, il est
perdu pour la fonction actuelle, qui voit un appel vide et abandonne
(`if not chunk: break`), même si ffmpeg produit bel et bien des données.
Un seul bloc de lecture a toujours suffi pour Salon (jamais de second
appel, jamais de fil orphelin) : le bug était invisible depuis le début des
tests MSE sur cette caméra précise, malgré des dizaines d'essais. Une
caméra plus lente à répondre, comme jardin, étale son moov sur plusieurs
blocs - exactement le cas que la fonction est censée gérer d'après son
propre commentaire (« le moov peut déborder du premier bloc ») - et tombe
alors dans le bug à chaque fois.
#### Correctif
`read_mp4_init_segment` réécrit : un seul fil, lancé une fois, lit le tube
en continu et pousse chaque bloc dans une `queue.Queue` ; la boucle
d'accumulation consomme cette file avec un délai décroissant, sans jamais
créer de second lecteur concurrent sur le même tube. Motif standard
(producteur/consommateur via `queue.Queue`), rien d'inventé. `read_with_deadline`
lui-même n'est pas modifié : son unique autre usage (le premier bloc de
`/live` et l'en-tête MJPEG) est un appel isolé, jamais en boucle sur le
même tube, donc jamais exposé à ce défaut précis.
#### Ce qui n'a pas été fait
- Pas de correctif pour un éventuel fil orphelin résiduel si le délai
global expire pendant que ce fil unique attend encore : il devient
orphelin comme avant, mais un seul, jamais concurrent avec un autre sur
le même tube - sans conséquence, la fonction a déjà renoncé à ce moment-là
et le sous-processus ffmpeg est sur le point d'être arrêté par l'appelant.
- La piste `snap_picture` (réveil léger avant le direct), proposée avant
cette découverte, abandonnée : plus nécessaire une fois la vraie cause
trouvée, et rien ne prouvait qu'elle aurait aidé.
#### Tests et vérifications
- Trois nouveaux tests (`test_serve_live_view.py`, nouveau fichier, même
patron d'import sûr que `test_serve_clips_window.py`) : segment tenant
dans un seul bloc (cas déjà correct avant), segment étalé sur deux blocs
(reproduit la forme du bug, corrigé), tube fermé avant un avcC complet
(renvoie ce qui est arrivé, sans exception). Segment MP4 synthétique
construit à la main (moov > trak > mdia > minf > stbl > stsd > avc1 >
avcC), profil `avc1.640028` - la même chaîne de codec vue en vrai en
production.
- Suite complète : 124 tests, verte.
- Reproduction réelle : `/live-mse` sur jardin, avant et après correctif -
le test A/B initial (avant ce correctif) a servi à établir le diagnostic ;
confirmation après redémarrage laissée à l'utilisateur.
#### Verdict
Bug réel dans le code écrit pendant cette session (28.16, remux ffmpeg),
resté invisible pendant tous les essais sur Salon parce qu'un seul bloc de
lecture y a toujours suffi. Trouvé uniquement parce que l'utilisateur a
contesté l'explication « portée radio » avec une observation concrète
(clips et détection fonctionnent) puis un test direct (« l'ancien code
marchait sur jardin ») - sans ce test A/B immédiat, l'hypothèse radio aurait
probablement été retenue à tort.
### 28.23 Hors sequence - confirmation reelle sur jardin, apres nettoyage d'un faux negatif
Le premier retest de jardin apres 28.22 ("pas mieux") etait un faux
negatif : deux scripts de diagnostic lances pour capturer le stderr
d'ffmpeg avaient chacun ouvert une session de direct sur jardin sans
jamais la refermer proprement (script bloque sur une lecture sans vrai
delai). Blink n'acceptant qu'une session de direct par compte, tout essai
suivant echouait vite avec << Live view failed >>, correctif ou pas.
Diagnostic : tasklist compare a la fiche d'instance de production
(enfants connus), deux processus ffmpeg et deux scripts Python confirmes
orphelins par ligne de commande, tues avec confirmation explicite de
l'utilisateur.
En chemin, un vrai trou de diagnostic corrige (commit separe) : le message
d'echec ne portait que le statut Blink, jamais la trace ffmpeg quand il y
en avait une.
Une fois le nettoyage fait, nouvel essai sur jardin : succes, avc1.640028,
262 Ko recus en 19,3 s. Le correctif 28.22 (fils orphelins dans
read_mp4_init_segment) est confirme reel et suffisant ; aucune cause
supplementaire a chercher.
### 28.24 Hors sequence - le vrai dernier bug jardin : sondage ffmpeg trop court pour un moov fige
#### Contexte
Apres 28.22/28.23, jardin marchait de facon intermittente : un succes reel
confirme (avc1.640028, 262 Ko), puis deux echecs consecutifs reproduits de
facon autonome (sans l'utilisateur, via un script de verification repete
sur le serveur de production deja lance), toujours << Live view failed >>
signale par Blink, toujours en ~15,4-15,5 s - une duree remarquablement
stable d'un essai a l'autre, pas le genre de variation qu'on attend d'un
vrai probleme radio aleatoire.
#### La trace qui manquait
Le correctif de diagnostic ajoute juste avant (exposer stderr ffmpeg dans
le message d'echec) a immediatement paye : `/api/live-error` a montre,
pour la premiere fois, la vraie cause :
[mp4 @ ...] dimensions not set
[out#0/mp4 @ ...] Could not write header (incorrect codec parameters ?): Invalid argument
Un vrai echec ffmpeg, pas un << aucune donnee recue >>. Le message
<< Live view failed >> de Blink etait trompeur : la commande avait bien
atteint la camera, c'est notre propre remux qui echouait ensuite.
#### Cause reelle
`-analyzeduration 500000 -probesize 300000` (0,5 s / 300 Ko) sont
identiques entre MJPEG (`/live`) et MSE (`/live-mse`), mais la contrainte
n'est pas la meme : un conteneur MP4 a moov fige (`empty_moov`, choisi pour
demarrer vite) doit connaitre les dimensions video *avant* d'ecrire son
en-tete, en une seule fois. Le decodage MJPEG, lui, les apprend au fil de
l'eau sans jamais avoir a s'engager d'avance sur un en-tete complet. Sur
Salon (proche, reponse rapide), la fenetre de 0,5 s/300 Ko suffit toujours
a voir passer le SPS. Sur jardin (plus loin, reponse plus lente), elle ne
suffit pas systematiquement - d'ou l'intermittence, coherente avec un lien
radio marginal (l'hypothese de portee n'etait donc pas fausse en soi, elle
visait juste le mauvais symptome : ce n'est pas la camera qui ne repond
pas, c'est notre sondage qui est trop court pour capter son SPS a temps).
#### Correctif
`send_live_mse` seul (pas `/live`, qui n'a pas ce besoin) :
`-analyzeduration`/`-probesize` releves a 5 000 000 (5 s / 5 Mo). Simple
plafond, pas un minimum impose : une camera qui repond vite (Salon) n'attend
pas plus longtemps pour autant, ffmpeg s'arrete de sonder des qu'il est
confiant. Reste tres en dessous de `LIVE_FIRST_FRAME_SECONDS` (40 s), la
vraie limite de patience du code appelant.
#### Verification autonome (sans l'utilisateur)
Demande explicite de l'utilisateur pendant cette section : se donner les
moyens de tester le pipeline seul. Fait via un script repete
(curl/urllib direct, pas de navigateur - MediaSource ne s'initialise pas
dans l'onglet Chrome automatise disponible ici, confirme par un test isole
sans rapport avec le code de l'app : `sourceopen` ne se declenche jamais
meme apres plusieurs secondes d'attente sur un `MediaSource` tout nu).
Verification donc limitee a la couche donnees/HTTP, pas a l'affichage
navigateur final.
- Avant ce correctif : Salon 2/2 (avc1.640028, ~9,1 s, 320 Ko), jardin 0/2
(503 << Live view failed >>, ~15,5 s, trace ffmpeg << dimensions not set >>
les deux fois).
- Suite complete : 124 tests, verte.
- Reste a confirmer apres redemarrage : nouveau passage jardin avec le
correctif.
#### Verdict
Troisieme et (probablement) dernier maillon de la serie 28.18-28.24 : deux
vrais bugs de fiabilite (blinkpy read/readexactly, DOM ecrase par
renderLive) et un vrai reglage insuffisant (sondage ffmpeg trop court pour
un moov fige) - aucun des trois n'etait un probleme de portee radio, meme
si l'intermittence de jardin donnait cette impression de loin. Diagnostic
rendu possible uniquement par l'ajout de la trace ffmpeg au message
d'echec : sans elle, ce correctif n'aurait pas ete trouvable depuis
l'exterieur du processus serveur.
### 28.25 Hors sequence - confirmation autonome, jardin et Salon tous deux fiables
Verification complete, sans l'utilisateur, apres le correctif 28.24 :
| Camera | Tentative | Resultat |
|---|---|---|
| jardin | 1 | 200, avc1.640028, 4096 octets, 32.9s (session courte mais reussie) |
| jardin | 2 | 200, avc1.640028, 327680 octets, 24.0s |
| jardin | 3 | 200, avc1.640028, 327680 octets, 18.1s |
| Salon (controle) | 1 | 200, avc1.640028, 327680 octets, 13.0s |
4/4. La tentative 1 sur jardin, plus courte (4096 octets = juste le segment
initial), n'est pas un echec : la reprise automatique cote client (28.18)
existe justement pour ce cas, une session Blink qui se termine tot mais
proprement. Plus aucun << Live view failed >>/dimensions sur les trois
passages jardin, contre 0/2 avant le correctif.
Limite assumee de cette verification : conduite entierement en dehors du
navigateur (curl/urllib direct contre le serveur), MediaSource ne
s'initialisant pas dans l'environnement Chrome automatise disponible ici.
Prouve la fiabilite de la chaine serveur -> donnees HTTP -> segment MP4
valide de bout en bout ; ne prouve pas l'affichage navigateur final, qui
reste a confirmer par l'utilisateur.
### 28.26 Hors sequence - la vraie fuite de MODULE_SLOT : lecture bloquante sans delai reel dans la boucle d'envoi
#### Constat
Pendant les essais de l'utilisateur, un 409 persistant sur Salon, avec le
diagnostic ajoute juste avant (28.25 implicite, section precedente) montrant
un chiffre qui ne faisait que grandir : 64s, puis 555s, 618s, 621s, 624s,
627s, 630s... bien au-dela de `LIVE_MAX_SECONDS` (300s). La camera restait
allumee (bleu) chez Blink, coherent avec une session serveur toujours
active, jamais relachee.
#### Cause reelle
La boucle d'envoi principale de `/live` et `/live-mse` (apres l'envoi des
en-tetes) lisait le tube ffmpeg directement :
deadline = time.monotonic() + LIVE_MAX_SECONDS
while time.monotonic() < deadline:
chunk = process.stdout.read(16384) # bloquant, SANS delai
...
`time.monotonic() < deadline` n'est verifie qu'ENTRE deux lectures. Si un
seul appel `process.stdout.read(16384)` bloque plus longtemps que
`LIVE_MAX_SECONDS` (ffmpeg qui se tait sans jamais fermer son tube - lien
radio degrade en cours de session, pas a l'ouverture cette fois), la
verification de deadline n'a jamais l'occasion de se redeclencher :
`LIVE_MAX_SECONDS` ne borne rien dans ce cas precis. `MODULE_SLOT` reste
tenu indefiniment, tant que la lecture individuelle ne revient pas.
Meme famille de bug que 28.22 (fils orphelins dans
`read_mp4_init_segment`), mais a un autre endroit : 28.22 corrigeait la
lecture du segment d'initialisation, pas la boucle d'envoi principale qui
suit, ou le meme defaut de fond (une lecture bloquante individuelle sans
delai propre) causait cette fois une fuite de verrou plutot qu'une perte de
donnees.
#### Correctif
Nouvelle classe `LecteurTube`, generalisation du motif deja introduit en
28.22 (file d'attente + fil unique par tube) : un seul fil lit le tube en
continu pour toute la duree de la requete, `lire(delai)` distingue
explicitement une vraie fin de tube (`b""`) d'un simple delai ecoule sans
donnee (`None`) - l'appelant peut alors reboucler tant que le delai global
n'est pas epuise, sans jamais perdre de donnee ni laisser de fil orphelin.
`read_with_deadline` (l'ancienne fonction a fil-par-appel, source du meme
defaut en 28.22) retiree entierement, remplacee par `LecteurTube` partout,
y compris pour la premiere image de `/live` (MJPEG) qui l'utilisait encore.
`read_mp4_init_segment` prend maintenant un `LecteurTube` deja construit
plutot qu'un tube brut, pour que le meme fil serve ensuite a la boucle
d'envoi principale de `/live-mse` - en construire un second sur le meme
tube aurait reintroduit le probleme a un niveau superieur.
#### Tests et verifications
- Deux nouveaux tests dans `test_serve_live_view.py` :
`test_lire_rend_none_sur_delai_sans_perdre_la_donnee_qui_suit`
(reproduit precisement le defaut corrige : un tube lent rend `None` au
bout du delai teste, puis livre bien sa donnee a l'appel suivant, sans
perte) et `test_lire_rend_bytes_vides_sur_vraie_fin_de_tube` (EOF reel
distinct d'un delai ecoule).
- Les trois tests existants de `read_mp4_init_segment` adaptes a la
nouvelle signature (prend un `LecteurTube`, plus un tube brut).
- Suite complete : 126 tests, verte.
- A confirmer par l'utilisateur apres redemarrage : la session bloquee
depuis plus de 600s ne peut se liberer qu'au redemarrage du processus
(aucun mecanisme, avant ce correctif, ne pouvait la faire expirer
d'elle-meme) ; les prochaines sessions, elles, respecteront desormais
vraiment `LIVE_MAX_SECONDS`.
#### Verdict
Contrairement a l'hypothese initiale de l'utilisateur (« le code doit
reouvrir le flux qui n'est pas partageable »), la bonne reponse n'etait pas
de changer la politique d'acces (un seul direct a la fois reste correct,
Blink lui-meme ne permet qu'une session), mais de faire que la limite deja
prevue (`LIVE_MAX_SECONDS`) soit reellement appliquee. Un flux qui degrade
en cours de route se termine desormais au plus tard apres 300s, au lieu de
tenir le verrou indefiniment.
### 28.27 Hors sequence - /api/videos (page des clips) : 60s+ a cause d'une lecture de fichier entier pour verifier 64 octets
#### Contexte
L'utilisateur rapporte l'ouverture de la page (fenetre des clips) lente,
~25s, en rappelant qu'une optimisation avait deja ete demandee. Section
28.6 (17 aout 2026) avait deja diagnostique une cause de lenteur au meme
endroit general (sondage ffmpeg synchrone des clips ecartes sans duree
connue) et l'avait explicitement laissee ouverte, « a traiter separement
(mise en cache du sondage) ». Cette mise en cache a bien ete faite depuis
(`probe_duration_cached`, `EXCLUDED_DURATIONS` persiste sur disque) : verifie
en mesurant `/api/clips` seul, 0,14s. Mais `load()` cote client attend aussi
`/api/videos` en parallele (`Promise.all`), et **cet appel-la, jamais vise
par le correctif de 28.6, prenait 40 a 60+ secondes** - largement la vraie
cause du ressenti actuel, plus severe que les 25s d'origine (le volume de
clips et de videos assemblees a grossi depuis).
#### Diagnostic
Mesure directe (hors navigateur, hors serveur) de chaque etape :
- `md.probe_clip_info` seul, sur les 19 fichiers assembles (quotidiennes,
hebdomadaires, mensuelles) : 0,12-0,50s chacun, ~5s au total. Pas la cause.
- `serve.collect_videos` (la fonction reelle appelee par `/api/videos`),
meme 19 fichiers, process neuf : **60,39s**. Ecart massif avec le test
precedent alors que `collect_videos` ne fait rien de plus que boucler sur
`probe_duration` (deja mis en cache via `_DURATIONS`)... et
`md.valid_mp4()`, appele sur chaque fichier juste avant.
- Isolement fin, fichier par fichier : `valid_mp4()` seul, sur 5 fichiers
quotidiens (130 Mo a 340 Mo), passe de 0,09s a 3,5s - croissant avec la
taille du fichier, pas un temps constant pour verifier un en-tete.
#### Cause reelle
```python
def valid_mp4(path: Path) -> bool:
return path.stat().st_size > 0 and b"ftyp" in path.read_bytes()[:64]
```
`Path.read_bytes()` charge le **fichier entier** en memoire ; le `[:64]` ne
fait que decouper le resultat *apres coup*. Pour un clip de quelques Mo,
invisible. Pour une video mensuelle assemblee de 1,8 Go (le plus gros
fichier reel de l'installation), ca lit 1,8 Go depuis le disque pour
verifier 4 octets (`ftyp`) au debut du fichier - a chaque appel, sur chaque
fichier, a chaque requete `/api/videos`, sans aucun cache pour cette
verification precise.
`valid_mp4` n'est pas propre a `/api/videos` : utilisee a 14 emplacements
(`merge_daily.py`, `blink_engine.py`, `blink_models.py`, `serve.py`),
couvrant l'acquisition de clips, la fusion quotidienne/hebdomadaire/
mensuelle et la normalisation. Le meme cout invisible-sur-petit-fichier
existait potentiellement partout ou une video deja assemblee et volumineuse
est revalidee, pas seulement sur cette page.
#### Correctif
`merge_daily.valid_mp4` : lecture bornee via `path.open("rb").read(64)`
plutot que `path.read_bytes()[:64]` - ne lit jamais plus que l'en-tete
necessaire, quelle que soit la taille du fichier.
#### Tests et verifications
- Nouveau fichier `test_merge_daily_valid_mp4.py` (aucun test direct
n'existait pour cette fonction malgre ses 14 emplacements d'usage) :
fichier valide/invalide/vide/absent (comportement inchange), plus un test
par mock verifiant qu'aucune lecture totale (`Path.read_bytes`) n'a lieu -
independant de la taille reelle d'un fichier de test, donc rapide et fiable
en CI.
- Suite complete : 131 tests, verte.
- Mesure directe apres correctif : `collect_videos`, memes 19 fichiers,
process neuf : **2,66s** (contre 60,39s avant, ~23x).
- A confirmer par l'utilisateur : temps d'ouverture reel de la page apres
redemarrage.
#### Verdict
Le vrai goulot de la page des clips n'etait ni le sondage de duree (deja
corrige en 28.6) ni le nombre de fichiers (19, modeste), mais une seule
ligne relisant des fichiers entiers de plusieurs centaines de Mo a
plusieurs Go pour en verifier 64 octets - fonction generique appelee bien
au-dela de cette page. Correctif ponctuel, sans rapport avec la serie
28.18-28.26 (direct MSE), trouve en repondant a la meme demande
d'autonomie de l'utilisateur : mesurer plutot que supposer, jusqu'a isoler
la ligne exacte en cause.
### 28.28 Hors sequence - message d'etat vide trompeur, trouve lors de l'essai a froid
Rapporte par l'utilisateur pendant son test volontaire depuis zero (bundle
Windows fraichement publie, nouveau dossier, nouvelle authentification,
suite a v0.6.0) : juste apres connexion, page vide, message << Aucun clip
recupere pour l'instant. Lancez blink2video download... >>. Message ecrit
pour un usage par verbes separes, jamais mis a jour depuis que start (la
composition par defaut, cf section 17) telecharge deja en arriere-plan des
le demarrage - dire a un utilisateur de lancer une commande deja en cours
ne fait que semer le doute au pire moment (juste apres l'onboarding).
Corrige : le message decrit desormais ce qui se passe reellement (le
telechargement tourne deja, cadences USB/cloud precisees) plutot que de
demander une action inutile. Le rappel cle USB est conserve, seule cause
frequente et reelle de << jamais rien >> meme apres attente. 131 tests,
verte.
### 28.29 Hors sequence - ?login=1 restait dans l'adresse apres connexion reussie
Rapporte par l'utilisateur pendant son test a froid : une fois connecte,
l'adresse affichait toujours http://127.0.0.1:8765/?login=1. Ce parametre
ne sert qu'a ouvrir automatiquement le dialogue de connexion au tout
premier lancement (accueillir(), section 17) ; rien ne le retirait ensuite.
Consequence reelle, pas seulement cosmetique : une actualisation (F5) ou un
acces depuis un signet sur cette meme adresse rouvrait le dialogue de
connexion a chaque fois, meme avec une session deja valide.
Corrige : apres un login reussi, history.replaceState() retire le
parametre de l'adresse affichee, sans recharger la page. 131 tests, verte
(changement JS pur, comme 28.20 et 28.28 - pas d'infrastructure de test JS
dans ce projet).
Aussi trouve dans le meme echange : bouton afficher/masquer sur le champ
mot de passe du dialogue de connexion, absent jusqu'ici (signale par
l'utilisateur). Non verifie visuellement (extension Chrome deconnectee au
moment du correctif) : a confirmer par l'utilisateur.
### 28.30 Hors sequence - demarrage automatique pilotable depuis la page web
Demande de l'utilisateur pendant sa relecture post-lancement : reduire le
recours au mode CLI. Deux volets distincts identifies :
- **Autostart on/off** : faisable proprement depuis la page, fait dans ce
lot.
- **Start/stop du processus principal depuis sa propre page web** : start
impossible en principe (un serveur arrete ne peut pas servir la page qui
le redemarrerait) sans un second processus superviseur toujours actif -
juge disproportionne pour un cas rare (double-clic sur l'executable, dont
l'icone ajoutee en 28.31 le rend facile a retrouver, suffit).
Confirme par l'utilisateur. Non implemente, par choix explicite.
#### Correctif
`autostart.py` : nouvelle fonction `est_installe(quoi) -> bool`, meme
condition que chaque branche `status` existante (glob sur le raccourci
Windows / l'agent macOS / le service systemd Linux) mais sans rien imprimer
ni modifier - `appliquer("status", ...)` rend toujours 0, inutilisable tel
quel par un appelant programmatique.
`serve.py` : route `GET /api/autostart` (etat actuel), `POST /api/autostart`
(applique puis relit l'etat reel, plutot que de renvoyer ce qui a juste ete
demande - au cas ou le mecanisme de la plateforme n'ait pas vraiment pris).
Case a cocher dans l'en-tete, a cote du bouton de mise a jour.
#### Tests et verifications
- Nouveau fichier `test_autostart_est_installe.py` (aucun test n'existait
pour ce module) : 6 tests, les trois plateformes (Windows reel dans cet
environnement, macOS/Linux via mock de `Path.home`/`sys.platform`), sans
jamais toucher au vrai dossier de demarrage de la machine.
- Suite complete : 137 tests, verte.
### 28.31 Hors sequence - icone de l'application et favicon web
Icone creee par l'utilisateur (`assets/blink2video.png`,
`assets/blink2video.ico`). Le `.png` etait deja attendu par
`blink2video.spec` (`APP_ICON`, meme mecanisme que lidar2map : source PNG
portable, convertie par PyInstaller en ressource native selon la
plateforme de construction) - aucun changement de code necessaire pour
l'icone de l'executable.
Le `.ico` n'etait reference nulle part : ajoute comme favicon web.
`blink2video.spec` l'embarque desormais sous `assets/` (memes chemin de
lecture, fige ou depuis les sources : `runtime.resource_dir() / "assets" /
"blink2video.ico"`). `serve.py` : route `/favicon.ico` (lit ce fichier,
`Cache-Control` 1 semaine) et `<link rel="icon">` dans l'en-tete.
Verifie de bout en bout : depuis les sources et depuis le bundle fige
construit pour v0.6.1 (`dist/blink2video/_internal/assets/blink2video.ico`
present, route testee sur les deux, 200/image-x-icon dans les deux cas).
131 tests, verte (aucun test dedie : lecture de fichier statique, deja
couverte indirectement par les tests d'integration serveur existants).
### 28.32 Hors sequence - panneau de reglages (cadences USB/cloud, arret depuis la page web)
Suite de 28.30 : demande de regrouper les deux coches (demarrage auto,
actualisation automatique) et les cadences USB/cloud dans un panneau de
reglages accessible par une icone engrenage, pour alleger l'en-tete
principal. Ajout demande dans la foulee : mettre aussi le bouton Arret
dans ce meme panneau.
#### Le probleme technique
`stop` utilise `taskkill /F /T /PID <pid>` sous Windows, qui abat l'arbre
entier du processus cible, pas seulement les PID enregistres dans la
fiche. Si le bouton "Appliquer et redemarrer" de la page web lancait
directement "arreter puis relancer" comme sous-processus de `serve`, ce
sous-processus serait lui-meme un descendant du PID que `stop` va tuer, et
disparaitrait avant d'avoir pu relancer quoi que ce soit.
`maj.py` resout deja ce probleme pour la mise a jour (`installer()` /
`finaliser()`) : un premier temps lance le second puis rend la main tout
de suite, ce qui le detache de l'arbre avant que `stop` ne le cherche
(taskkill /T ne retrouve pas un descendant dont le parent direct a deja
disparu - limite connue de son parcours par instantane de processus
vivants). Le second temps, deja detache, peut alors arreter puis relancer
sans se faire abattre au passage.
#### Correctif
Nouveau verbe `restart` (runtime.py, VERBES), qui reprend ce meme
mecanisme en deux temps sans toucher a maj.py (dont le role reste limite
aux vraies mises a jour de version) :
- `blink_cli.redemarrer()` : sans `--finaliser`, lance le second temps
detache puis rend la main. Avec `--finaliser`, arrete (`stop`), attend
jusqu'a 20s que les instances disparaissent, puis relance `start` a neuf
sauf `--sans-relance`.
- A la difference de `update`, qui rejoue exactement les verbes enregistres
dans la fiche (memes anciennes valeurs de --loop), `restart` relance
`start` en clair : `runtime.standard()` relit alors le fichier de
reglages, donc les cadences modifiees prennent effet des le redemarrage.
`runtime.py` : la constante `STANDARD` (tuple fige) est remplacee par
`lire_cadences()` / `ecrire_cadences()` (fichier `blink_reglages.json`,
defauts 10 min USB / 1 min cloud identiques a l'ancienne constante) et
`standard()`, qui recompose la commande a chaque appel a partir du
fichier. `blink_cli.py` : les deux usages de `runtime.STANDARD` deviennent
`runtime.standard()`.
`serve.py` :
- `GET/POST /api/reglages` : lit/ecrit les cadences ; le POST valide (>= 1
minute chacune) puis lance `restart` detache (meme mecanisme que
`/api/update`, verbe different).
- `POST /api/stop` : lance `restart --sans-relance` detache.
- Panneau `<dialog id="reglages">` (meme gabarit que `#auth`) : les deux
coches existantes y sont deplacees telles quelles (memes id, aucun
changement JS necessaire pour elles) ; deux champs numeriques pour les
cadences ; bouton "Appliquer et redemarrer" (meme deroule que le bouton
de mise a jour : attendre que le serveur disparaisse puis revienne,
recharger) ; bouton "Arreter", qui affiche un etat final explicite
plutot que de laisser la page pendre sur un serveur mort.
- L'en-tete perd ses deux coches, remplacees par une icone engrenage.
#### Tests et verifications
- `test_runtime_cadences.py` (6 tests) : fichier absent/illisible -> les
defauts, ecriture puis lecture, valeurs partielles completees,
`standard()` reflete bien les cadences reglees.
- `test_blink_cli_redemarrer.py` (7 tests) : les deux temps de
`redemarrer()` entierement mockes, aucun vrai processus touche - premier
temps rend la main sans appeler `lancer`, second temps arrete puis
relance sauf `--sans-relance`, attente correctement bornee (20 passages
max).
- Suite complete : 150 tests, verte.
- Verification en conditions reelles, sur l'instance de developpement en
cours (composition `start` deja active, PID verifie avant toute
action) : `restart` execute directement en CLI a bien arrete l'ancienne
composition (6 PID confirmes morts) et relance une nouvelle en quelques
secondes, port 8765 repris. Puis verification depuis la page elle-meme :
panneau ouvert (coches et cadences correctement pre-remplies depuis
l'API), cadence USB changee a 8, "Appliquer et redemarrer" clique,
nouvelle fiche confirmee avec `--loop 8`, page rechargee proprement.
Bouton "Arreter" ensuite verifie separement : composition bien arretee
(aucune fiche, port libere), message final affiche. Application
relancee manuellement ensuite pour laisser l'instance de developpement
disponible.
### 28.33 Hors sequence - bouton Ecarter bloque par le verrou registre
Signale trois fois par l'utilisateur au cours de cette session (d'abord
net::ERR_EMPTY_RESPONSE, puis "on clique et il ne se passe rien"), avec
une hypothese precise en dernier lieu : bloque par un autre processus,
reconstruction des clips ou autre. La premiere reproduction directe
(3 requetes HTTP contre le serveur au repos) n'avait rien montre - le
probleme ne se declare qu'en presence d'une contention reelle, absente
d'un test isole.
#### Cause reelle
`POST /api/toggle` appelait `md.set_excluded(...)` en direct dans le fil
de la requete HTTP. Cette fonction (merge_daily.py) prend elle-meme
`runtime.verrou("registre", "exclusion", stale_after=60, attente=10)` :
un verrou inter-processus (fichier `.blink_registre.lock`, creation
exclusive O_CREAT|O_EXCL), le meme que celui pris par
`blink_registre.save_download_state()` a chaque ecriture du registre
depuis les boucles de telechargement (cloud toutes les minutes par
defaut). En cas de collision, `attente=10` fait une attente active
(nouvel essai chaque seconde) avant d'abandonner - **synchrone**, dans
le fil qui doit repondre au navigateur. Cote page, le clic restait donc
sans effet visible jusqu'a 10 secondes, exactement le symptome decrit.
`REGISTRE` (threading.Lock local a serve.py) n'est pas en cause : il ne
protege que deux requetes HTTP entre elles dans le meme processus, pas
la contention avec les boucles de fond qui tournent dans des processus
separes.
#### Reproduction
Verrou "registre" pris manuellement pendant 8s (simulant une ecriture de
telechargement), requete toggle envoyee pendant ce temps :
- Avant correctif : reponse en 8,93s (a attendu la liberation du verrou).
- Apres correctif : reponse en 0,002s.
Confirme aussi que l'exclusion elle-meme aboutit bien, une fois le verrou
libre : relue via /api/clips apres coup, "excluded": true.
#### Correctif
`serve.py`, route `/api/toggle` : `set_excluded()` puis `reassembler()`
deplaces dans un fil de fond (meme principe deja en place pour
`reassembler()` seul), la reponse HTTP partant immediatement. Meme
raisonnement que pour le bouton Arreter/redemarrer (28.32) : le seul
chemin surs pour ne pas faire attendre la reponse sur une ressource
partagee entre processus est de ne plus l'attendre dans ce fil-la.
Consequence acceptee : un `BusyError` eventuel (verrou toujours pris
apres 10s) n'est plus remonte au navigateur, seulement imprime cote
serveur - deja le cas pour un echec de `reassembler()`, qui ne remonte
rien non plus. Pas de nouvelle regression, meme niveau de garantie que
l'existant.
#### Tests et verifications
- D'abord verifie a la main seulement (script direct contre le serveur
reel, comme la reproduction ci-dessus) - signale par l'utilisateur
comme insuffisant : ce test ponctuel n'aurait rien dit si le correctif
avait regresse plus tard, sans qu'un quatrieme signalement ne le
revele. Corrige par un vrai test permanent.
- `test_serve_toggle_lock.py` (2 tests) : un `serve.Handler` construit
sans passer par `__init__` (qui exige une vraie socket), do_POST
n'ayant besoin que de `self.path`/`.headers`/`.rfile`, faciles a
simuler sans ouvrir de connexion. Le verrou "registre" est pris pour
de vrai par un fil concurrent pendant l'appel, la reponse doit rester
sous 1s malgre ca, et l'exclusion doit tout de meme aboutir une fois
le verrou libere (relu dans le registre, brut deplace dans
Blink_Excluded). Deuxieme test : identifiant invalide toujours
rejete tout de suite (400), sans jamais toucher au verrou.
- Le test verifie contre l'ancien code (commit precedent restaure
temporairement) : echoue bien, 5,56s au lieu de moins d'1s - preuve
qu'il aurait attrape la regression avant ce correctif, pas seulement
apres.
- Suite complete : 152 tests, verte.
- Etat reel confirme par /api/clips avant et apres sur l'instance de
developpement, clip de test remis dans son etat d'origine ensuite.
### 28.34 Hors sequence - port du serveur dans le panneau de reglages
Demande de l'utilisateur : ajouter le port au panneau, avec l'idee generale
"qu'est-ce qui manque encore ?". Different des cadences par deux points :
un changement de port casse la connexion de l'onglet en cours (il parle a
une adresse qui va s'arreter), et un port explicite tape en CLI
(`start --port 8899`) doit toujours l'emporter sur la valeur enregistree.
#### Correctif
`runtime.py` : `lire_cadences()`/`ecrire_cadences()` renommees
`lire_reglages()`/`ecrire_reglages()` (le port n'est pas une cadence),
troisieme champ "port" (defaut 8765, identique a celui de `serve --port`).
`standard()` inscrit desormais `--port <valeur>` juste apres "serve" :
les trois premiers elements du tuple forment un bloc fixe.
`blink_cli.py` : le supplement de `start` (les options tapees a la main)
se greffe apres ce bloc fixe plutot qu'apres "serve" seul
(`[*composition[:3], *supplement, *composition[3:]]`) - un `--port` tape
a la main arrive donc apres celui, deja present, de la config enregistree,
et l'emporte (argparse retient la derniere occurrence d'une option).
`serve.py` : `/api/reglages` GET/POST etendu avec le port (valide entre 1
et 65535). Panneau : nouveau fieldset "Serveur" avant celui des cadences,
bouton Appliquer commun aux deux. Cote JS, deux chemins distincts a
l'application : port inchange -> meme sondage qu'avant (meme origine,
recharge une fois l'ancien confirme mort) ; port change -> attendre la
meme confirmation de mort, PUIS un delai de grace de 3s pour laisser le
nouveau processus se lier au port, PUIS `location.href` vers la nouvelle
adresse. Un premier essai avec un delai fixe (6s) a echoue une fois en
verification reelle (redirection tentee avant que le nouveau port ne
reponde) - remplace par cette confirmation active plutot que garder un
delai plus genereux au hasard.
#### Tests et verifications
- `test_runtime_reglages.py` (anciennement test_runtime_cadences.py,
8 tests desormais) : renomme avec les fonctions, deux tests ajoutes -
les 3 premiers elements de `standard()` forment bien le bloc port fixe,
et un `--port` invente dans un supplement simule arrive bien apres le
defaut dans la sequence assemblee (condition necessaire pour qu'il
l'emporte).
- Suite complete : 154 tests, verte.
- Verifie en conditions reelles : port change de 8765 vers 8766 depuis le
panneau, fichier de reglages confirme, nouveau processus confirme en
ecoute sur 8766 (netstat). Onglet navigateur ensuite reconduit
manuellement (le delai fixe initial avait rate la fenetre). Apres
correctif (confirmation de mort + delai de grace), retour de 8766 vers
8765 verifie de bout en bout : fichier de reglages a jour, nouveau
process en ecoute sur 8765, onglet du navigateur suit automatiquement
jusqu'a la nouvelle adresse sans intervention.
### 28.35 Hors sequence - dossier de stockage dans le panneau de reglages
Demande de l'utilisateur, portee choisie explicitement via question posee
en amont : modifiable depuis la page, sans deplacer les fichiers existants
(a faire soi-meme le cas echeant). Ce reglage n'a pas la meme nature que
les precedents : `blink_reglages.json` (cadences, port) vit lui-meme dans
`app_dir()`, donc y stocker "ou se trouve app_dir()" est circulaire -
il faudrait deja connaitre le dossier pour savoir ou lire le reglage qui
le donne.
#### Correctif
`runtime.py` : nouveau fichier pointeur `blink_home.txt`, a un emplacement
fixe et connu d'avance - `_dossier_ancre()`, exactement celui ou vivaient
les donnees avant que ce reglage existe (a cote de l'executable fige, ou
des sources). `app_dir()` verifie desormais, dans l'ordre : BLINK_HOME
(inchange, priorite la plus haute), le contenu de ce pointeur, puis
`_dossier_ancre()` par defaut. Pas de fichier de config JSON ici : une
seule ligne de texte suffit, et json.dumps/loads n'apportait rien face au
risque de casser le format sur un chemin contenant un caractere a
echapper.
`lire_dossier_stockage()`/`ecrire_dossier_stockage()` : symetrie avec les
cadences/port, mais `ecrire_dossier_stockage("")` efface le pointeur
plutot que d'ecrire une valeur vide (retour explicite au defaut).
`serve.py` : `/api/reglages` etend GET/POST avec `storage_dir`. Cote
ecriture, verifie en creant vraiment le dossier (mkdir) et en y ecrivant
un fichier temoin avant d'accepter quoi que ce soit - un chemin qui a l'air
valide peut pointer vers un disque non monte ou un emplacement en lecture
seule, mieux vaut le decouvrir ici qu'au prochain demarrage, en cascade et
sans page pour le dire. Panneau : nouveau fieldset "Stockage", champ texte
(pas de selecteur de dossier : les navigateurs n'exposent pas de chemin
absolu reel pour des raisons de securite), avertissement explicite sur le
non-deplacement des donnees. Meme redemarrage que les cadences (le port
d'ecoute ne change pas ici, donc pas besoin du mecanisme de redirection
de 28.34).
#### Tests et verifications
- `test_runtime_dossier_stockage.py` (5 tests, nouveau fichier) :
`_dossier_ancre()` mockee vers un dossier temporaire - sans pointeur,
app_dir() rend l'ancre ; pointeur ecrit puis app_dir() le suit ; pointeur
vide efface le fichier et revient a l'ancre ; BLINK_HOME garde priorite
meme avec un pointeur present ; lire_dossier_stockage reflete app_dir().
- Suite complete : 159 tests, verte - aucune regression detectee sur le
reste du depot malgre la modification d'app_dir(), utilisee partout.
- Verifie en conditions reelles mais sans risque, contre l'instance de
developpement (session Blink et 156 clips reels) : POST avec la valeur
de storage_dir deja active (aucun deplacement reel), fichier pointeur
cree et confirme, redemarrage complet, session et reglages toujours
coherents ensuite (`/api/status` -> authenticated true). Puis POST avec
storage_dir vide : pointeur efface, retour confirme au dossier par
defaut, port toujours en ecoute. Le changement vers un dossier reellement
different n'a pas ete teste en direct sur cette instance (aurait rompu
la session Blink active pour de vrai) ; couvert uniquement par les tests
unitaires ci-dessus.
### 28.36 Hors sequence - horodatage optionnel dans le panneau de reglages
Demande de l'utilisateur : une coche, cochee par defaut (comportement
actuel inchange pour qui ne touche a rien). Le mecanisme bas niveau
existait deja a moitie : `build_batch_filter` acceptait deja
`font_value=None` pour omettre drawtext, utilise jusqu'ici seulement pour
les agregats hebdo/mensuel (deja horodates, ne doivent pas l'etre deux
fois). Il restait a l'exposer sur le chemin de la normalisation elle-meme.
#### Correctif
`merge_daily.py` : nouveau `--no-timestamp`. `font_path` devient
optionnel (`Path | None`) de bout en bout - `main()` (sautant
`check_drawtext_available`/`find_font`/`check_timestamp_rendering` quand
desactive, ces verifications n'ayant plus d'objet), `render_key()`
(l'empreinte de rendu distingue desormais avec/sans horodatage, sinon un
clip deja normalise horodate resterait tel quel apres desactivation du
reglage plutot que se re-encoder sans l'incrustation) et `normalize_clip()`
(passe `None` a `run_ffmpeg_batch` plutot que d'appeler
`quote_filter_path` sur un chemin absent).
`runtime.py` : quatrieme champ "timestamp" (bool, defaut true) dans
`lire_reglages()`/`ecrire_reglages()`. `standard()` ajoute
`--no-timestamp` au groupe merge quand desactive.
`serve.py` : nouveau fieldset "Video" dans le panneau, une coche. Meme
redemarrage que les cadences (aucun changement de port ici).
#### Tests et verifications
- `test_merge_daily_horodatage_optionnel.py` (5 tests, nouveau fichier) :
`render_key` ne plante pas avec `font_path=None`, la bascule
avec/sans-horodatage change bien l'empreinte (condition necessaire pour
qu'un clip deja encode se re-encode au changement de reglage), meme
police -> meme empreinte. `build_batch_filter` : aucun "drawtext" dans
le filtergraph sans police, present avec.
- `test_runtime_reglages.py` : un test de plus, `standard()` ajoute bien
`--no-timestamp` en fin de groupe merge quand le reglage est desactive.
- Suite complete : 165 tests, verte.
- Verifie en conditions reelles mais sans risque, contre l'instance de
developpement (156 clips reels deja normalises avec horodatage) :
`/api/reglages` confirme le champ "timestamp" apparait (true par
defaut), POST avec cette meme valeur (aucun changement), redemarrage
confirme, fichier de reglages et port coherents ensuite. Basculer
reellement vers `timestamp: false` n'a pas ete teste en direct sur
cette instance : au prochain passage de `merge --loop 5`, les 156 clips
existants auraient tous une empreinte perimee et se seraient tous
re-encodes pour de vrai, un travail long et une modification reelle de
l'archive existante pour une simple verification. Couvert uniquement
par les tests unitaires ci-dessus, qui exercent directement le
changement d'empreinte et l'absence de drawtext sans avoir besoin
d'un vrai passage de fusion.
### 28.37 Hors sequence - fuseau horaire dans le panneau de reglages
Suggestion faite par l'assistant a une question ouverte de l'utilisateur
("qu'est-ce qui manque encore ?"), approuvee. Affecte deux verbes a la
fois : merge (horodatage incruste dans l'image) et serve (affichage des
dates dans l'interface), tous deux figes jusqu'ici sur le defaut argparse
Europe/Paris.
#### Correctif
`runtime.py` : cinquieme champ "timezone" (str, defaut "Europe/Paris")
dans lire_reglages()/ecrire_reglages(). Le bloc fixe en tete de
standard() passe de 3 a 5 elements (serve, --port, valeur, --timezone,
valeur) ; nouvelle constante LONGUEUR_BLOC_SERVE plutot qu'un "3" ecrit
en dur des deux cotes (runtime.py et blink_cli.py), pour que les deux
readevent d'accord sans avoir a se souvenir l'un de l'autre. Le groupe
merge recoit aussi --timezone, en plus du --no-timestamp deja
conditionnel (28.36).
`serve.py` : /api/reglages valide le fuseau en instanciant reellement
ZoneInfo(valeur) avant d'accepter quoi que ce soit - un nom qui ressemble
a un fuseau IANA peut ne pas en etre un, mieux vaut le decouvrir ici qu'au
prochain demarrage. Champ texte dans le fieldset "Video" (a cote de la
coche horodatage), avec une <datalist> HTML native de fuseaux courants
(pas de composant de recherche custom : juste des <option> suggeres,
retape librement au clavier).
#### Tests et verifications
- `test_runtime_reglages.py` : trois tests ajoutes/adaptes - fuseau vide
dans le fichier retombe sur le defaut, le bloc fixe fait maintenant
5 elements (verifie via la nouvelle constante plutot qu'un chiffre en
dur dans le test), --no-timestamp reste bien en derniere position apres
--timezone dans le groupe merge.
- Suite complete : 166 tests, verte.
- Verifie en conditions reelles contre l'instance de developpement :
redemarrage confirme avec le nouveau code (le champ "timezone" apparait
dans /api/reglages, port toujours actif - la commande serve --timezone
Europe/Paris a donc bien ete acceptee par son propre argparse). Fuseau
invalide teste directement : rejete en 400 avant tout enregistrement.
Reglages actuels renvoyes en POST (aucun changement reel) : redemarrage
confirme, fichier de reglages coherent. Un vrai changement de fuseau
n'a pas ete teste en direct sur cette instance, pour la meme raison que
l'horodatage (28.36) : render_key() inclut deja l'epoch (wall_clock_epoch,
lui-meme derive du fuseau) dans son empreinte, donc changer le fuseau
perime bien celle de chaque clip deja normalise et declencherait un vrai
re-encodage massif au prochain passage de merge --loop 5 - verifie en
lisant le code plutot qu'en le declenchant pour de vrai sur l'archive
reelle.
### 28.38 Hors sequence - sourdine par camera dans le panneau de reglages
Approuve par l'utilisateur en reponse a la meme question ouverte que 28.37
("qu'est-ce qui manque encore ?"). A la difference des autres reglages de
ce lot, ne passe pas par blink_reglages.json ni par standard() : watch.py
persiste deja sa propre liste "ignored" dans .blink_watch_state.json et la
relit a chaque tour de boucle (_controler), pas seulement au demarrage -
aucun redemarrage du serveur n'est donc necessaire, contrairement a
port/stockage/horodatage/fuseau.
#### Correctif
`serve.py` : nouvelles routes /api/sourdine. GET renvoie la liste des
cameras connues et celles actuellement en sourdine ; POST recoit
{camera, ignored} et lance `watch --ignore/--unignore CAMERA` en tache de
fond (meme principe que le bouton Ecarter, 28.33 : reponse HTTP immediate,
le travail reel suit dans un fil separe). Fieldset "Alertes" ajoute au
panneau de reglages, cases a cocher generees dynamiquement, chacune postee
individuellement des le clic plutot que groupee avec le bouton Appliquer.
Bug trouve en conditions reelles le 18 aout, en mettant "Portail" (camera
hors de portee depuis un moment) en sourdine pour de vrai : le nom de
camera est sensible a la casse cote watch.py (comparaison exacte dans
compare(), "nom not in ignores" contre les cles retournees par l'API
Blink) et un premier essai avec "portail" en minuscule n'a rien coupe.
Plus genant : GET /api/sourdine ne proposait que les cameras ayant deja
produit au moins un clip (provenances(read_entries(...))) - une camera
durablement hors de portee comme Portail n'apparaissait jamais dans la
liste, donc impossible a mettre en sourdine avec la bonne casse depuis la
page elle-meme. Corrige en completant la liste avec les cameras connues du
dernier etat sauvegarde par watch.py (.blink_watch_state.json, fichier
local deja charge sur cette meme route, aucun appel reseau supplementaire).
#### Tests et verifications
- `test_serve_sourdine.py` (nouveau, 6 tests) : GET liste les cameras du
registre de clips, GET les complete avec les cameras connues de watch.py
meme sans clip (reproduit le cas Portail), GET rend la liste ignoree
telle que stockee, POST repond immediatement sans attendre le sous-
processus (runtime.lancer simule, jamais execute pour de vrai - le vrai
`watch --ignore` interroge le compte Blink en reseau, hors de propos
dans un test unitaire), POST sans camera rejete en 400 sans rien lancer,
POST --unignore transmet la bonne option.
- Suite complete : 172 tests, verte.
- Verifie en conditions reelles contre l'instance de developpement,
utilisee au quotidien : POST /api/sourdine {"camera": "jardin",
"ignored": true} puis GET confirment la sourdine posee sans redemarrage
(aucune coupure de connexion observee, contrairement a port/stockage) ;
POST ignored=false puis GET confirment le retour a l'etat initial,
instance laissee inchangee. Le bug de casse/liste incomplete decrit
ci-dessus a ete decouvert lors d'un vrai changement demande par
l'utilisateur (mettre Portail en sourdine pour de vrai, hors de portee
ce jour-la) : la premiere tentative (casse minuscule) a ete corrigee
directement dans .blink_watch_state.json, confirmee par GET
/api/sourdine, puis le correctif de code ci-dessus a ete ajoute pour que
la page elle-meme propose desormais la bonne camera avec la bonne casse.
- Note hors-sujet, constatee en tentant d'appliquer ce correctif sur
l'instance reelle sans passer par la page (directement via
`blink_cli.py restart` en ligne de commande, dans ce meme terminal) :
le redemarrage detache ne s'est pas produit, les 5 processus (serve,
watch, download x2, merge) sont restes ceux d'avant l'appel, sans
erreur ni journal. Le mecanisme de detachement (subprocess.Popen avec
start_new_session/DETACHED_PROCESS) ne survit visiblement pas a la fin
de l'appel shell dans cet environnement sandboxe, alors que le meme
mecanisme, declenche depuis la page web elle-meme (bouton Appliquer),
a deja ete verifie fiable a plusieurs reprises dans ce lot (port,
stockage, horodatage, fuseau). Aucune action corrective : c'est une
limite de l'outil utilise pour l'audit, pas du code livre.
### 28.39 Hors sequence - coches independantes jour/semaine/mois pour le merge
Approuve par l'utilisateur : "3 coches pour le merge jour/semaine/mois,
cochees par defaut". La journaliere sert de brique de base aux agregats
hebdomadaire et mensuel (build_periods les assemble a partir des videos
journalieres deja produites) : decocher "jour" seul sans desactiver les
deux autres produirait une interface trompeuse (des coches actives pour
des agregats qui ne seraient jamais reconstruits). Question posee a
l'utilisateur sur ce point precis, tranchee en faveur de : 3 coches
affichees, decocher "jour" grise et decoche automatiquement "semaine" et
"mois" dans l'interface.
#### Correctif
`merge_daily.py` : l'ancien --no-periods (qui coupait hebdomadaire et
mensuel ensemble, sans exister avant ce jour dans aucun test ni aucune
page de reglages - simple option de ligne de commande) est remplace par
deux drapeaux independants --no-weekly et --no-monthly. La boucle de
construction des agregats dans _executer() saute chaque periode
individuellement selon son propre drapeau plutot que les deux ensemble.
`runtime.py` : trois nouveaux champs dans REGLAGES_DEFAUT (merge_jour,
merge_semaine, merge_mois, tous a True par defaut) et dans
lire_reglages()/ecrire_reglages(). standard() omet entierement le verbe
merge de la composition quand merge_jour est faux (aucun agregat n'a de
sens sans base journaliere, inutile de lancer le processus pour rien) ;
quand merge_jour est vrai, --no-weekly et --no-monthly sont ajoutes au
groupe merge selon merge_semaine/merge_mois, sur le meme principe que
--no-timestamp (28.36).
`serve.py` : nouveau fieldset "Archivage" dans le panneau de reglages,
trois cases a cocher. La case "jour" desactive et decoche visuellement
les deux autres des qu'elle est decochee (appliquerDependanceMergeJour,
appelee au chargement et a chaque changement de cette case), pour que
l'interface ne promette jamais un agregat qui ne sera pas construit.
POST /api/reglages lit les trois valeurs (defaut True si absentes) et
les transmet a runtime.ecrire_reglages().
README.md et README.fr.md : tableau des options de "merge" mis a jour
(--no-weekly, --no-monthly, et --no-timestamp qui n'y figurait pas
encore depuis 28.36 - oubli comble au passage).
#### Tests et verifications
- `test_merge_daily_periodes_independantes.py` (nouveau, 6 tests) :
--no-weekly et --no-monthly acceptes independamment par parse_args,
--no-periods n'existe plus (SystemExit attendu), puis quatre tests sur
_executer() avec find_ffmpeg/load_groups/build_periods simules (aucun
appel ffmpeg reel) verifiant que chaque combinaison de drapeaux
construit exactement les periodes attendues.
- `test_runtime_reglages.py` : deux tests ajoutes - merge_jour=False omet
le verbe merge entier de la composition, merge_semaine=False et
merge_mois=False ajoutent --no-weekly et --no-monthly ensemble. Les
appels existants a ecrire_reglages() dans ce fichier et dans
test_runtime_dossier_stockage.py ont ete verifies : aucun autre ne
manquait les trois nouveaux parametres obligatoires.
- Suite complete : 180 tests, verte.
- Non verifie en conditions reelles sur l'instance de developpement : le
redemarrage necessaire pour appliquer un changement de ces reglages n'a
pas pu etre declenche depuis ce terminal (voir la note a la fin de
28.38) ; le code de decision (quelles periodes construire) est en
revanche entierement couvert par les tests ci-dessus, executes sans
mock partiel sur la logique de branchement elle-meme.
### 28.40 Hors sequence - bascule EN/FR sur la page web
Demande par l'utilisateur, sur le modele exact d'un mecanisme deja en place
dans un autre de ses projets (lidar2map, gui/app.js) : dictionnaire i18n
inline par locale + attribut data-i18n sur les noeuds statiques, fonction
t()/tf() appelee directement dans le texte genere en JS, deux boutons
FR/EN. Repris a l'identique plutot que reinvente (aucune bibliotheque
i18n ajoutee), avec un seul changement de fond : lidar2map persiste
l'override de langue cote Python (pywebview, bureau package, stockage
local ephemere selon le profil du navigateur embarque) ; blink2video est
une page web ordinaire servie par serve.py, donc localStorage suffit et
evite d'inventer un nouveau fichier de configuration cote serveur.
#### Correctif
`serve.py` uniquement (page HTML/CSS/JS generee, pas de fichier separe
dans ce projet contrairement a lidar2map). Ajouts :
- dictionnaire `I18N = { fr: {...}, en: {...} }` (une centaine de cles),
fonctions `t(cle)` (repli sur le FR puis sur la cle elle-meme si absente
- jamais de page cassee) et `tf(cle, valeurs)` (memes gabarits `{var}`
que lidar2map) ;
- `detectLang()` (navigator.language), `applyI18n()` (parcourt
[data-i18n]/[data-i18n-placeholder]/[data-i18n-title], bascule la classe
.active des boutons [data-lang-btn]), `setLang(code, persist)` ;
- attributs data-i18n poses sur tout le HTML statique (en-tete, dialogue
de connexion, panneau de reglages complet) ; chaque checkbox dont le
libelle est tague porte son texte dans un <span> separe du <input>, pour
qu'applyI18n() (qui ecrit textContent) ne supprime pas la case a cocher
elle-meme en reecrivant le texte du <label> parent ;
- tout le texte genere en JS (statuts de camera, messages d'erreur du
direct, liste des clips/videos, sourdine, panneau de reglages) appelle
t()/tf() au point de generation plutot qu'un attribut statique, exact
miroir de la distinction faite dans lidar2map entre contenu statique
(data-i18n) et contenu rendu dynamiquement (appel direct) ;
- deux boutons FR/EN dans l'en-tete (.langGroup/.btn-lang, memes noms de
classe que lidar2map, adaptes aux variables CSS de ce projet) ;
- persistance : `localStorage.setItem("lang", ...)` au clic, relu au
chargement (`localStorage.getItem("lang") || detectLang()`).
Hors perimetre, deliberement : le texte genere cote serveur Python (les
libelles de "phase" envoyes par /api/refresh en SSE, les messages
d'erreur renvoyes par les routes API) reste en francais. Le traduire
aurait exige de dupliquer un dictionnaire i18n cote Python en plus de
celui en JS, pour un gain limite (ce texte n'apparait que dans le journal
de progression et les rares cas d'erreur serveur) - non demande, et un
vrai second chantier distinct de celui-ci.
#### Bug trouve et corrige en testant au navigateur
Premiere version : l'appel initial `setLang(localStorage.getItem("lang")
|| detectLang(), false)` etait place juste apres la definition de
`setLang()`, donc tres tot dans le script - avant les declarations `let`
plus bas (MSE_ABORT, travailEnCours, actualisationLocale...). setLang()
appelle render()/renderLive(), qui referencent ces variables : appelees
avant l'execution de leur propre ligne `let`, JavaScript leve
ReferenceError ("zone morte temporelle" - contrairement a `var`, une
variable `let` existe dans la portee mais reste inaccessible jusqu'a ce
que sa ligne de declaration s'execute). La page entiere restait alors
plantee des le chargement, invisible en lisant le code seul. Corrige en
deplacant cet appel a la toute fin du script, juste avant le chargement
initial des donnees - exactement la ou lidar2map place le sien.
#### Tests et verifications
- Syntaxe JavaScript verifiee avec `node --check` sur le script extrait
de PAGE (aucun outil de test JS dans ce projet ; verification minimale
mais reelle, qui aurait immediatement signale une erreur de syntaxe si
la substitution de gabarit Python avait mal echappe un caractere).
- Bug ci-dessus trouve UNIQUEMENT par le test navigateur reel (instance
isolee, port 8799, dossier temporaire, aucun rapport avec l'instance de
developpement utilisee au quotidien) : la premiere tentative de
screenshot a expose une page plantee des le chargement, invisible dans
la lecture du code JS seul. Diagnostique via read_console_messages
(ReferenceError explicite), corrige, reteste avec succes.
- Verifie au navigateur, apres correctif : page chargee sans erreur
console ; clic sur EN bascule immediatement l'en-tete, les listes
deroulantes de vues/cameras/jours (y compris leur option "toutes
cameras"/"tous les jours", qui ne passe pas par data-i18n puisque
regeneree par fill() a chaque chargement) et l'integralite du panneau
de reglages (verifie champ par champ via l'arbre d'accessibilite) ;
clic sur FR revient au francais ; choix persiste apres un rechargement
complet de la page (localStorage).
- Suite Python complete relancee apres coup : 180 tests, verte (ce
chantier ne touche que du HTML/CSS/JS genere par une constante Python,
aucune logique serveur testable en Python n'a change).
- Instance de test isolee arretee et son dossier temporaire abandonne
apres verification ; l'instance de developpement reelle n'a pas ete
touchee par ces essais.
### 28.41 Hors sequence - selecteur de dossier natif, cadres retires, panneau elargi
Trois demandes groupees de l'utilisateur : la liste de sourdine ne montrait
pas "Portail" alors qu'il aurait du y etre seul (bug deja corrige en code
depuis 28.38, mais jamais applique sur l'instance reelle - voir plus bas) ;
retirer le cadre visuel autour des sections "Serveur" et "Stockage" (un
seul champ chacune, un encadre les alourdissait sans rien regrouper) ;
ajouter un bouton de selection de dossier plutot que la seule saisie
manuelle ; elargir le panneau de reglages.
#### Correctif
`serve.py` :
- CSS : nouvelle classe `fieldset.sansCadre` (bordure/marge/rembourrage a
zero) posee sur les fieldsets Serveur et Stockage ; `#reglages` passe de
420px a 560px de large ; nouvelle classe `.champDossier` (ligne
champ+bouton) pour le nouveau bouton "Parcourir".
- Nouvelle route GET `/api/choisir-dossier` : ouvre une boite de dialogue
systeme via `tkinter.filedialog.askdirectory()`, tkinter importe
localement dans le gestionnaire (jamais au chargement du module) pour ne
jamais peser sur un environnement sans affichage - aucun test ni route
de ce projet n'emprunte cette branche en CI Linux headless. Le choix de
passer par une boite native plutot qu'un `<input type="file"
webkitdirectory>` HTML n'est pas cosmetique : l'API File System du web
ne rend jamais de chemin absolu par conception (vie privee), seulement
un handle - inutilisable ici puisque c'est le SERVEUR qui doit ecrire ce
chemin dans blink_reglages.json. Solution valable seulement parce que
serveur et navigateur tournent sur la meme machine (outil local a poste
unique), explicitement note dans le commentaire pour qu'un futur lecteur
ne la reprenne pas telle quelle dans un contexte client/serveur distant.
- JS : bouton "Parcourir..."/"Browse..." (nouvelles cles i18n
`reglages.storageDir.browse` et `.browse.unavailable`) qui appelle cette
route et remplit `storageDir` avec le chemin recu ; en cas d'erreur (pas
d'affichage disponible, par exemple sur une machine sans session
graphique), message explicite invitant a saisir le chemin a la main - le
champ texte reste de toute facon editable, rien n'est bloque.
- Oubli corrige au passage : le bouton "Reglages" (icone engrenage) de l'en-tete n'avait
que son attribut `title` tague en i18n, pas son texte visible (seul
"Reglages" affichait toujours en francais apres bascule EN) - trouve en
testant au navigateur la mise en page ci-dessus. Corrige en ajoutant
`data-i18n="btn.reglages"`, l'emoji inclus dans la valeur traduite
elle-meme plutot que separe (un `<span>` autour du seul texte aurait
aussi marche, mais aurait ajoute un niveau sans necessite ici, l'emoji
n'ayant pas besoin d'exister hors du bouton).
#### Bug de developpement (pas du code livre) trouve en ecrivant la route
Premiere version du bloc `/api/choisir-dossier` inseree par erreur dans
`do_POST` au lieu de `do_GET` : les gestionnaires GET et POST de
`/api/autostart` se terminent tous deux par un texte strictement
identique (`self.send_json({"actif": autostart.est_installe()}); return`),
donc le repere textuel utilise pour l'insertion n'etait pas unique et a
matche la mauvaise occurrence sans qu'aucune erreur ne le signale. Trouve
immediatement par le test ecrit dans la foulee (do_GET() tombait en 404
faute de route). Deplace vers do_GET, seul endroit coherent avec le JS
(`fetch()` sans methode = GET).
#### Tests et verifications
- `test_serve_choisir_dossier.py` (nouveau, 3 tests) : tkinter simule
entierement via `sys.modules` (aucune vraie fenetre ouverte, y compris
en local) - chemin choisi renvoye tel quel, annulation (chaine vide)
renvoyee sans erreur, echec du selecteur (ex. absence d'affichage)
renvoie une erreur 500 plutot que de faire planter le serveur.
- Syntaxe JS revalidee avec `node --check` apres tous les changements.
- Suite complete : 183 tests, verte.
- Verifie au navigateur (instance isolee, port 8799) : panneau visiblement
plus large, sections Serveur et Stockage sans cadre (Cadence de lecture
reste encadree, sans regression), bouton "Browse..." present a cote du
champ dossier, bouton "Settings" (icone engrenage) bascule desormais correctement avec
la langue. Le clic reel sur "Browse..." n'a pas ete teste au navigateur
automatise (ouvrirait une vraie fenetre systeme hors de portee de cet
outil) ; couvert a la place par les tests unitaires ci-dessus, qui
exercent la route independamment de tkinter lui-meme.
- Sourdine "Portail" : le correctif de code existait depuis 28.38 mais
n'avait jamais ete applique sur l'instance reelle, faute d'un
redemarrage reussi depuis ce terminal ce jour-la (voir la note a la fin
de 28.38). Fait le lendemain, via le bouton Appliquer de la page elle-
meme plutot que ce shell (mecanisme deja verifie fiable) : redemarrage
confirme (nouvelle page FR/EN chargee automatiquement), panneau de
reglages reouvert et verifie en conditions reelles - "Alertes" liste
desormais les 4 cameras (Portail, Salon, Terrasse1, jardin, contre 2
avant), seul "Portail" coche, exactement le comportement demande.
### 28.42 Hors sequence - reformulation du panneau de reglages
Cinq ajustements de forme demandes par l'utilisateur apres avoir vu le
panneau elargi de 28.41 en conditions reelles, aucun ne touchant a la
logique deja en place.
#### Correctif
`serve.py` :
- Port du serveur : le fieldset "Serveur" separe (legende au-dessus,
champ "Port" en dessous) est remplace par une seule ligne `.champCadence`
(meme composant que USB/Cloud) : "Serveur" sert directement de libelle,
la valeur du port juste en face. La cle i18n `reglages.port` ("Port"),
devenue inutilisee, est retiree plutot que laissee morte.
- "Archivage" devient "Creation des videos temporelles par camera" ; les
trois cases passent de "Video par jour"/"Agregat hebdomadaire"/"Agregat
mensuel" a "Quotidienne"/"Hebdomadaire"/"Mensuelle". Le texte d'aide en
dessous, qui citait "jour" entre guillemets, est mis a jour pour citer
"Quotidienne" a la place - sinon il aurait fait reference a un libelle
qui n'existe plus.
- "Alertes" devient "Mise en sourdine des alertes" ; le suffixe "en
sourdine" repete apres chaque nom de camera est retire (juste le nom
desormais). La cle i18n `sourdine.muted`, devenue inutile pour un texte
qui ne variait plus que par interpolation d'un seul mot, est retiree :
`chargerSourdine()` ecrit directement `` ` ${camera}` `` plutot que de
passer par `tf()` pour un gabarit qui n'a plus rien a gabarit.
- Le bouton "Appliquer et redemarrer" devient "Appliquer" (fr) / "Apply"
(en), dans le HTML de secours et dans le dictionnaire i18n des deux
langues. Le texte explicatif juste au-dessus ("Appliquer enregistre
et redemarre...") reste inchange : c'est lui qui porte l'explication du
redemarrage, le bouton n'a plus besoin de le repeter dans son libelle.
#### Tests et verifications
- Syntaxe JS revalidee avec `node --check`.
- Suite complete : 183 tests, verte (aucun test ne portait sur ces
libelles ou cette disposition, changement purement visuel).
- Verifie au navigateur (instance isolee, port 8799) : "Serveur" et la
valeur du port bien sur une seule ligne ; "Creation des videos
temporelles par camera" / Quotidienne / Hebdomadaire / Mensuelle
affiches correctement, texte d'aide coherent avec les nouveaux
libelles ; "Mise en sourdine des alertes" avec les noms de camera seuls
(sans suffixe) ; bouton "Appliquer" seul.
### 28.43 Regression - la page bascule seule vers Direct au chargement
Signale par l'utilisateur, capture d'ecran a l'appui : le menu deroulant
affiche "Clips" mais le contenu montre la vue Direct (cartes de camera,
"Systeme arme"). Regression introduite par le deplacement du bootstrap
setLang() en 28.40, jamais remarquee alors parce que les verifications au
navigateur de 28.40/28.41/28.42 rechargeaient toutes une page dont le
choix de langue etait deja memorise en localStorage - un chemin different
de celui d'un tout premier chargement, ou `data.clips` (un tableau vide,
donc "vrai" en JavaScript) laisse passer un appel a render() plus tot que
prevu.
#### Cause
`$("view").value = "clips"` n'etait pose qu'apres l'appel a `setLang()`.
Celui-ci appelle `render()`, qui lit `$("view").value` pour decider quoi
peindre : avant que cette ligne ne s'execute, le <select> n'a pas
d'attribut `selected` et vaut donc sa premiere option, "Direct" (valeur
`live`), par defaut navigateur. `render()` y voit alors `kind === "live"`
et declenche `loadSystem()` -> `fetch("/api/system")`, un appel reseau
bien plus lent que le passage synchrone qui pose ensuite `$("view").value
= "clips"` et lance `load()` (clips). La liste de clips s'affiche donc
correctement en premier - jusqu'a ce que la reponse tardive de
`/api/system` arrive et que `loadSystem()` appelle `renderLive()` sans
condition, ecrasant le contenu deja a l'ecran. Le menu deroulant, lui,
n'a jamais bouge : d'ou l'incoherence visible entre "Clips" affiche et
Direct montre.
Une meme faiblesse latente existait deja avant 28.40, sans jamais avoir
ete declenchee au chargement : `loadSystem()`/`renderLive()` n'ont jamais
verifie que la vue Direct etait encore active au moment ou leur reponse
reseau arrive. Un utilisateur consultant Direct puis basculant tres vite
vers Clips avant la reponse de `/api/system` aurait pu subir le meme
symptome - corrige au meme endroit plutot que seulement contourne.
#### Correctif
`serve.py` : `$("view").value = "clips"` deplace avant l'appel a
`setLang()`, pour que le tout premier `render()` voie deja la bonne vue
et n'appelle jamais `loadSystem()` sans raison. `loadSystem()` verifie en
plus `$("view").value === "live"` avant de toucher au DOM, a l'entree
(message "Interrogation...") et a la sortie (`renderLive()`) : la
variable `system` reste mise en cache normalement (evite un rechargement
quand l'utilisateur bascule vraiment vers Direct plus tard), seul
l'affichage est desormais conditionne a la vue reellement active.
#### Tests et verifications
- Syntaxe JS revalidee avec `node --check`.
- Suite complete : 183 tests, verte (bug purement cote navigateur, aucune
logique Python concernee).
- Verifie au navigateur (instance isolee, port 8799, tout premier
chargement d'un profil sans localStorage prealable - le cas exact ou le
bug se produisait) : `read_network_requests` confirme qu'aucun appel a
`/api/system` n'est declenche au chargement (seulement autostart,
passages, clips, videos) ; le menu deroulant reste sur "Clips" et le
contenu correspond, sans bascule intempestive.
### 28.44 Hors sequence - panneau de reglages compacte pour capture d'ecran
Sept demandes de l'utilisateur pour que le panneau tienne a l'ecran sans
ascenseur (besoin : en faire une capture d'ecran propre). Toutes visuelles,
aucune ne touche aux donnees enregistrees ni a la logique deja en place.
#### Correctif
`serve.py` :
- "Stockage" (legende de fieldset) supprimee ; le champ dossier n'est
plus dans un fieldset separe. La cle i18n `reglages.stockage`, devenue
inutilisee, est retiree plutot que laissee morte.
- "Dossier des donnees" passe d'un label au-dessus du champ a une seule
ligne `.champDossier` (meme composant que la ligne Serveur/Port) :
label, champ, bouton Parcourir cote a cote.
- Le texte d'aide du dossier ("Ne deplace pas les clips...") passe de
paragraphe visible a infobulle (`title`) posee sur toute la ligne
`.champDossier`, via le meme attribut `data-i18n-title` deja utilise
ailleurs (ex. bouton Reglages de l'en-tete, 28.41).
- Les trois cases Quotidienne/Hebdomadaire/Mensuelle passent d'un
empilement vertical a une ligne `.ligneCoches` (flex, retour a la ligne
si necessaire) ; leur texte d'aide devient de meme une infobulle posee
sur la legende "Creation des videos temporelles par camera".
- La liste de sourdine par camera (`#sourdineListe`) recoit la meme
classe `.ligneCoches` : les cases se posent desormais en ligne avec
retour automatique, plutot qu'empilees une par ligne - pertinent ici
car leur nombre varie avec le compte Blink, contrairement aux trois
cases fixes d'archivage.
- Les trois boutons du bas (Appliquer, Arreter la surveillance,
Fermer), auparavant chacun sur sa propre ligne en pleine largeur,
rejoignent la ligne `.row` existante (renommee `.row row-boutons`)
plutot que d'ajouter un nouveau conteneur : largeur naturelle plutot
qu'egale (flex:1 aurait force le libelle le plus long, "Arreter la
surveillance des cameras", a retourner a la ligne dans son propre
bouton), espacees par `justify-content:space-between`.
- Nettoyage au passage : la classe `.sansCadre` (posee en 28.41 sur les
fieldsets Serveur et Stockage) n'a plus aucun element a cibler - Serveur
n'utilisait deja plus de fieldset depuis 28.42, et Stockage n'en a plus
du tout desormais. Ses deux regles CSS sont retirees plutot que
laissees mortes.
Bug latent trouve et corrige au passage (pas demande par l'utilisateur,
remarque en relisant le HTML de la sourdine) : `#sourdineListe` porte en
permanence l'attribut `data-i18n="sourdine.loading"` dans le DOM, y
compris une fois rempli par `chargerSourdine()` avec les vraies cases a
cocher. Un changement de langue pendant que le panneau est ouvert
declenche `applyI18n()`, qui reecrit alors `textContent` de cet element
sur la seule foi de cet attribut - effacant les cases reelles au profit
du texte "Chargement...". `setLang()` appelle desormais `chargerSourdine()`
a nouveau si le panneau est ouvert au moment de la bascule, pour
reconstruire la liste reelle immediatement plutot que de la laisser figee
jusqu'a la prochaine ouverture.
#### Tests et verifications
- Syntaxe JS revalidee avec `node --check`.
- Suite complete : 183 tests, verte (changement purement visuel, aucune
logique Python touchee).
- Verifie au navigateur (instance isolee, port 8799) : Stockage sans
legende separee, dossier sur une ligne avec bouton Parcourir ; Creation
des videos temporelles par camera avec les trois cases sur une ligne ;
Mise en sourdine des alertes ; les trois boutons du bas sur une seule
ligne, texte non tronque ni retourne a la ligne (le premier essai avec
flex:1 forcait "Arreter la surveillance des cameras" sur 3 lignes dans
son propre bouton - corrige en repassant a une largeur naturelle).
Infobulles natives (title) non capturables de maniere fiable par
l'outil de capture d'ecran automatise (rendu OS, pas DOM) : non
verifiees visuellement, mais l'attribut lui-meme est un mecanisme HTML standard
deja utilise ailleurs dans cette meme page sans probleme constate.
### 28.45 Hors sequence - dernieres compactions et icones d'en-tete
Suite de 28.44 : trois nouveaux regroupements sur une ligne (USB/Cloud,
Fuseau horaire) plus deux changements d'en-tete demandes separement au
fil de la meme session.
#### Correctif
`serve.py` :
- USB (minutes) et Cloud (minutes), auparavant deux lignes distinctes
dans le fieldset "Cadence de lecture des cameras", rejoignent une seule
ligne `.champCadenceDouble` (deux paires label+champ cote a cote,
retour a la ligne si necessaire - meme principe que `.ligneCoches` en
28.44, mais pour des paires label+valeur plutot que des cases seules).
- Fuseau horaire passe d'un label au-dessus et d'un champ pleine largeur
en dessous a une ligne `.champCadence` (meme composant que Port du
serveur). Le champ recoit une largeur propre (180px, aligne a gauche)
plutot que les 70px communs aux champs numeriques voisins : un fuseau
IANA ("America/Los_Angeles") ne tiendrait pas dans 70px.
- Le texte d'aide du bas ("Les reglages ne prennent effet qu'au
redemarrage...") passe de paragraphe visible a infobulle sur le bouton
"Appliquer", meme mecanisme que les infobulles deja posees en 28.44.
- "Serveur" devient "Port du serveur" (legende de la ligne Port).
- Icone ajoutee au bouton "Actualiser" (fleche circulaire, meme registre
visuel que l'engrenage deja pose sur "Reglages") ; "Reglages" devient
"Reglages..." (points de suspension, convention d'interface signalant
qu'un clic ouvre un dialogue plutot que d'agir directement - le meme
raisonnement qui a deja valu les siens a "Parcourir..." en 28.41).
- Nettoyage au passage : la classe `.etiquetteChamp`, qui ne ciblait plus
aucun element depuis que Stockage (28.44) puis Fuseau horaire (cette
section) ont perdu leur label empile, est retiree ; meme chose pour la
regle `#reglagesHint`, le paragraphe qu'elle stylait n'existant plus.
#### Tests et verifications
- Syntaxe JS revalidee avec `node --check`.
- Suite complete : 183 tests, verte (changement purement visuel).
- Verifie au navigateur (instance isolee, port 8799) : le panneau entier
tient desormais sur un seul ecran sans ascenseur (capture prise a
2049x910, mais la reduction de hauteur cumulee sur 28.44+28.45 - sept
paragraphes/lignes devenus infobulles ou fusionnes - devrait suffire
sur un ecran de bureau ordinaire) ; USB/Cloud confirmes sur une meme
ligne par capture zoomee ; les deux icones visibles dans l'en-tete,
"Reglages..." avec ses points de suspension.
### 28.46 Regression - bascule EN/FR inatteignable pendant la connexion
Signale par l'utilisateur en preparant un post public : la boite de
connexion s'ouvre parfois en francais (langue du systeme via
detectLang()) sans moyen de la changer, puisque les boutons FR/EN de
l'en-tete se trouvent derriere le fond assombri d'une boite modale
(showModal()) - invisibles et inatteignables tant qu'elle est ouverte.
Pas une regression de 28.40 a proprement parler (le mecanisme
fonctionne comme concu), plutot un angle mort de portee : le premier
ecran qu'un nouvel utilisateur non authentifie rencontre est
precisement celui ou la bascule de langue etait la moins accessible.
#### Correctif
`serve.py` : un second jeu de boutons FR/EN, minimal, ajoute a
l'interieur de la boite `#auth` elle-meme (coin superieur droit,
`position:absolute` sur un `dialog` desormais `position:relative`).
Meme balisage que celui de l'en-tete (`data-lang-btn`, `onclick="setLang(...)"`)
et memes classes CSS (`.langGroup`/`.btn-lang`) : `applyI18n()` et
`setLang()` n'ont pas eu besoin de changement, `document.querySelectorAll("[data-lang-btn]")`
cible deja tous les boutons de langue existants sur la page, quel que
soit leur nombre.
#### Tests et verifications
- Syntaxe JS revalidee avec `node --check`.
- Suite complete : 183 tests, verte (changement HTML/CSS pur).
- Verifie au navigateur (instance isolee, port 8799, `?login=1`) : la
boite de connexion affiche desormais son propre FR/EN ; clic sur EN
bascule immediatement toute la boite (titre, aide, champs, boutons)
ainsi que l'en-tete visible derriere le fond assombri.
### 28.47 Nettoyage - suppression de daily.py, code mort avec un bug dedans
Trouve en relisant l'integralite du code source a la demande de
l'utilisateur (aucune session precedente n'avait laisse de trace de
`blink_engine.py`/`blink_models.py`/`blink_registre.py` dans le contexte
de cette session, d'ou une premiere reponse erronee sur le mecanisme USB,
corrigee avant d'etre creusee plus loin).
#### Constat
`daily.py` portait le verbe historique "all" (watch puis download puis
merge en un seul passage), remplace par "start" (commit 50f1f2c, "'all'
disparait, 'start' porte la configuration recommandee"). Ce commit a
retire `import watch` du fichier sans retirer les deux usages de `watch`
dans `main()` (`watch.un_tour`, `watch.journal`), le rendant casse
(`NameError`) - sans consequence visible puisque plus rien ne l'appelle :
absent de `runtime.VERBES` (donc `blink2video daily` ne route nulle
part), jamais invoque par `serve.py` (verifie : `run_refresh()` appelle
`self_command("download")` puis `self_command("merge")` separement, pas
`daily.py`), absent du `.spec` de build, des workflows CI et des README.
Deux commentaires dans `serve.py` (lignes ~61 et l'ancien docstring de
`stream_refresh()`) decrivaient encore l'ancien comportement ("on lance
daily.py") alors que le code juste en dessous ne le fait plus depuis le
meme commit - corriges au passage plutot que laisses a cote d'un fichier
qui n'existe plus.
Aucun besoin de raviver ce fichier : `blink2video watch download merge`
(plusieurs verbes a la suite, deja supporte par `route()`) fait
exactement la meme chose qu'aurait fait `daily.py` corrige, sans fichier
dedie a entretenir.
#### Correctif
`daily.py` supprime (`git rm`). `serve.py` : les deux commentaires
perimes reecrits pour decrire le code reel (titres de phase de
`blink_engine.py`, mecanisme a deux `self_command` de `run_refresh()`).
#### Tests et verifications
- Suite complete : 183 tests, verte (aucun test ne referencait daily.py).
- `python docs.py --check` : les deux README restent a jour (le bloc
verbes auto-genere ne mentionnait de toute facon jamais "daily", donc
cette suppression ne pouvait pas le faire deriver).
- Recherche exhaustive avant suppression : aucune reference a "daily.py"
dans aucun fichier `.py`, aucun workflow `.github/workflows/*.yml`,
aucun `.spec` de build, en dehors des deux commentaires corriges et des
correspondances sans rapport sur "merge_daily.py".
### 28.48 Responsive - debordement horizontal sur ecran etroit
Verification demandee par l'utilisateur apres la relecture complete du
28.47 (item "responsive mobile jamais teste" de la liste d'ameliorations
proposee a cette occasion).
#### Constat
Aucune `@media` query dans toute la page (verifie par recherche
exhaustive). Les boites de dialogue elles-memes restent sures
(`width:min(560px, 92vw)` s'adapte deja a un ecran etroit), mais deux
familles de lignes internes utilisaient `flex` sans `flex-wrap`, avec des
elements a largeur fixe qui ne peuvent pas retrecir :
- `dialog .row` (les boutons du bas, Appliquer/Arreter/Fermer) : chaque
bouton pose `flex:none; white-space:nowrap` (28.44/28.45), donc rien ne
pouvait ceder de la place ni passer a la ligne.
- `.champCadence` (Port du serveur, Fuseau horaire) : le champ fuseau a
une largeur fixe de 180px (28.45, elargi expres pour un nom IANA long),
sans marge de repli sur un tres petit ecran.
`.champCadenceDouble`, `.ligneCoches` et `.champDossier` etaient deja
surs (le premier et le deuxieme ont deja `flex-wrap:wrap` depuis leur
creation, le troisieme a son champ en `flex:1`, donc capable de retrecir
plutot que de forcer un debordement).
#### Correctif
`serve.py` : `flex-wrap:wrap` ajoute a `dialog .row` et `.champCadence`.
Choix delibere de ne pas fixer une largeur minimale explicite aux
elements : les laisser passer naturellement a la ligne suffit, sans
media query ni breakpoint a maintenir.
#### Tests et verifications
Outil de capture d'ecran automatise incapable de simuler un vrai ecran
etroit dans cet environnement : `resize_window` ne change pas
`window.innerWidth` (reste bloque a 1366 quelle que soit la taille
demandee), confirme par lecture directe de cette valeur avant toute
conclusion. Contournement retenu : largeur forcee directement sur
`#reglages` via `style.width = '320px'` (element.style a la priorite la
plus haute, donc fiable independamment de tout probleme de viewport),
puis mesure des positions reelles par `getBoundingClientRect()` plutot
que par lecture visuelle de la capture d'ecran, elle-meme deformee par
un artefact sans rapport (fenetre OS reellement retrecie par
`resize_window` pendant que le rendu logique reste a 1366px, provoquant
une barre de defilement de fenetre qui n'a rien a voir avec la page).
- Premiere mesure : `flex-wrap` du bloc de boutons trouve a `nowrap`
malgre le correctif deja ecrit dans `serve.py` - l'instance de test
n'avait pas ete redemarree depuis l'edition du CSS, donc servait encore
l'ancienne page. Corrige en redemarrant l'instance avant de remesurer :
discipline de verification pour soi-meme autant que pour le code.
- Apres redemarrage : `getComputedStyle(...).flexWrap` confirme `"wrap"`
; les trois boutons se retrouvent chacun sur sa propre ligne
(`top` distincts), chacun contenu dans la largeur de la boite (aucun
`right` de bouton ne depasse le `right` de la boite, mesure avant
correctif : le bouton "Close" depassait de 38px). Capture d'ecran a
320px forces : "Time zone" passe bien sur une ligne separee de son
champ, les trois boutons s'empilent sans rien tronquer.
- Suite complete : 183 tests, verte (changement purement visuel).
### 28.49 Documentation - absence d'authentification locale rendue explicite
Question posee publiquement sur le post r/selfhosted de l'utilisateur
(installation sur un NAS Asustor multi-utilisateurs), qui a fait chercher
si le tableau de bord web protege son acces autrement que par la session
du compte Blink.
#### Constat
`serve.py` : le serveur HTTP se lie explicitement a `127.0.0.1` uniquement
(`Server(("127.0.0.1", args.port), Handler)`), jamais a `0.0.0.0` - donc
injoignable depuis le reste du reseau local, contrairement a l'avertissement
equivalent d'un projet compare informellement plus tot dans la conversation
(hors audit : "No authentication on the web dashboard. Anyone on your local
network can access it."), qui expose lui sur toutes les interfaces. Mais
aucune route
de `serve.py` n'exige de mot de passe propre a la page : la boite de
connexion HTML ne protege que l'acces au compte Blink (telechargement,
direct), pas la page elle-meme ni les videos deja assemblees. N'importe
quel autre compte ou processus tournant sur la meme machine peut donc
ouvrir le tableau de bord - un risque negligeable sur un PC personnel a
utilisateur unique, plus reel sur un NAS ou un serveur partage entre
plusieurs comptes, exactement le contexte souleve par la question.
#### Correctif
`README.md` et `README.fr.md` : un paragraphe ajoute juste apres la
promesse de confidentialite existante ("Everything runs on your machine.
Nothing is sent anywhere.") pour rendre ces deux faits explicites plutot
que de les laisser a decouvrir en lisant le code - le lien d'ecoute
localhost (protection reelle et deja en place) et l'absence de mot de
passe propre (limite reelle, a connaitre avant une installation
multi-utilisateurs).
Aucun changement de code : documentation seule, le comportement etant
deja celui decrit.
#### Tests et verifications
- `python docs.py --check` : les deux README restent a jour (le bloc
verbes auto-genere, seul concerne par ce script, n'est pas touche par
cet ajout de prose hors-bloc - meme limite de portee que notee en 28.44).
- Pas de suite de tests concernee : aucun code modifie.
### 28.50 Regression - libelles de phase serveur restes en francais malgre EN
Signale publiquement deux fois independamment sur le meme post
r/selfhosted, a quelques minutes d'intervalle : un lecteur ("is it
expected?") puis l'utilisateur lui-meme en testant le bouton Actualiser
("telechargement" affiche alors que la page est en anglais).
#### Cause
Portee deliberement exclue en 28.40 ("le texte genere cote serveur
Python... reste en francais"), decision correcte a l'epoque pour les
lignes de journal brutes, mais qui couvrait par erreur aussi les
libelles de PHASE (visibles en tres gros, juste au-dessus de la barre
de progression - pas un detail de journal qu'on ignore). Le serveur ne
peut structurellement pas connaitre la langue affichee : c'est un choix
par onglet, garde en `localStorage`, jamais envoye au serveur. Cinq
sources identifiees par recherche exhaustive de `runtime.travail(` :
`blink_engine.py` (telechargement par clip), `merge_daily.py` (deux
appels, preparation et assemblage), `maj.py` (deux appels, mise a jour).
Plus deux libelles d'etape ecrits en dur dans `run_refresh()` lui-meme
("Telechargement", "Fusion") et deux titres de section extraits par
`suivre()` depuis la sortie du sous-processus (`=== STOCKAGE LOCAL :
{hub} ===`, `=== CLOUD DE L'ABONNEMENT ===`).
#### Correctif
Plutot que de dupliquer un dictionnaire i18n cote Python (aurait exige
un second systeme de traduction a maintenir en parallele de celui deja
en JS), chaque source recoit une **cle stable optionnelle**, invariante
par langue, en plus du texte francais existant (jamais retire : sert de
repli et de texte pour qui lit un journal ou un fichier d'etat brut) :
- `runtime.travail(quoi, fait, total, cle=None)` : nouveau parametre,
retrocompatible (defaut `None`), transmis tel quel dans le JSON publie.
Les cinq appelants passent desormais une cle (`phase.download_clips`,
`phase.prepare_clips`, `phase.assemble_videos`, `phase.update_download`,
`phase.update_install`).
- `serve.py` (Python) : `run_refresh()` associe une cle a ses deux etapes
(`phase.step_download`, `phase.step_merge`) ; `suivre()` reconnait les
deux titres de section connus et ajoute `phase_key` (plus `phase_hub`
pour le nom du module, donnee utilisateur jamais traduite, passee a
part du gabarit qui l'entoure).
- `serve.py` (JS) : nouvelle fonction `libellePhase(cle, texteBrut,
valeurs)` - cle absente ou inconnue du dictionnaire courant : renvoie
`texteBrut` inchange (jamais une chaine vide, un texte non reconnu
reste un texte, juste pas traduit) ; sinon `t()`/`tf()` comme partout
ailleurs. Branchee dans `montrerTravail()` (boucle de fond, via
`/api/passages`) et dans le gestionnaire SSE de `/api/refresh` (bouton
Actualiser) - les deux chemins que les deux signalements couvraient
a eux deux.
#### Tests et verifications
- Suite unitaire complete : 183 tests, verte.
- Suite d'integration complete (`python tests.py`, vraie encodage ffmpeg,
vrai serveur HTTP, vrais sous-processus) : verte de bout en bout
("Tout est vert."), y compris son propre appel direct a
`runtime.travail()` sans `cle` (retrocompatibilite confirmee, pas
seulement supposee).
- Verifie au navigateur (instance isolee, port 8799, langue forcee EN) :
connexion SSE directe a `/api/refresh` confirme le JSON reellement
envoye (`{"phase":"Fusion","phase_key":"phase.step_merge",...}`) ;
`libellePhase()` appelee directement pour les cinq cas (clips,
mise a jour avec interpolation Mo, section USB avec nom de hub
preserve, cle absente) - chacun rend le texte attendu.
### 28.51 Le bouton Actualiser ne rafraichissait jamais l'etat des cameras
Signale par l'utilisateur : la camera "Salon" affichait une mesure
(batterie/temperature/signal) ancienne dans l'application, corrigee
seulement apres un geste de rafraichissement dans l'application Blink
officielle. Demande de trouver l'equivalent API et de le brancher sur
"Actualiser".
#### Recherche
Code source de `blinkpy` (bibliotheque tierce installee, lue directement
sur le disque plutot que devinee) :
- `Blink.refresh(force=True)` (blinkpy.py:126) appelle `get_homescreen()`
puis, pour chaque module, `sync_module.refresh(force_cache=True)`.
- `Sync.refresh()` (sync_module.py:277) appelle, pour CHAQUE camera,
`get_camera_info()` (GET `/network/{id}/camera/{id}/config`) puis
`camera.update()`.
- `Camera.update()` (camera.py:289) appelle inconditionnellement
`get_sensor_info()` (GET `/network/{id}/camera/{id}/signals`, temperature
calibree en particulier) - jamais saute, quel que soit `force_cache`.
Donc `blink.refresh(force=True)` relit deja vraiment chaque camera,
individuellement, deux appels HTTP par camera - le rafraichissement le
plus pousse que `blinkpy` expose. `system_state()` (serve.py) l'appelle
deja, mais UNIQUEMENT depuis `/api/system`, atteint seulement en ouvrant
l'onglet Direct (`loadSystem()`) - jamais depuis "Actualiser", qui ne
s'occupe que des clips (`download` puis `merge`, voir `run_refresh()`).
Rester sur l'onglet Clips et cliquer Actualiser ne rafraichissait donc
jamais l'etat des cameras, aussi longtemps qu'on le souhaitait.
Limite non levee, notee honnetement plutot que passee sous silence :
`request_camera_info`/`request_camera_sensors` sont de simples GET,
lecture de ce que le compte Blink a deja en archive, pas une commande de
reveil materiel de la camera. Rien dans `blinkpy` ne suggere l'existence
d'un appel distinct "reveiller cette camera maintenant" ; si l'application
officielle en declenche un, ce n'est pas (ou plus) expose par cette
bibliotheque communautaire (API non officielle, reverse-engineered).
#### Correctif
`serve.py` (JS) : le gestionnaire `event.done` du flux SSE de
"Actualiser" appelle desormais aussi `loadSystem(true)`, en plus de
`load()` deja present. Sans danger pour la vue actuellement affichee :
les gardes deja poses en 28.43 (`if ($("view").value === "live")` avant
tout ecriture DOM dans `loadSystem()`/`renderLive()`) s'appliquent aussi
a ce nouvel appel - si l'utilisateur regarde "Clips" au moment du clic,
seul l'etat interne (`system`) se met a jour en arriere-plan, pret pour
le prochain passage sur Direct, sans jamais ecraser la liste de clips
affichee.
#### Tests et verifications
- Syntaxe JS revalidee avec `node --check`.
- Suite complete : 183 tests, verte (changement JS pur, aucun test ne
couvre ce chemin cote navigateur).
- Non teste en conditions reelles au moment de l'ecriture : necessite de
cliquer Actualiser en restant sur l'onglet Clips, puis de verifier que
la mesure d'une camera reellement obsolete (comme le cas Salon signale)
se rafraichit - a faire sur l'instance reelle plutot que la suite
isolee, qui n'a pas de vrai compte Blink.
#### Suite (meme jour) : une vraie commande de reveil existe bien
Limite "non levee" ci-dessus revisee apres un doute exprime par
l'utilisateur ("je pense qu'il y a une vraie commande de rafraichissement,
avec l'appli on peut le faire avec un glisser vers le bas"). Recherche
elargie dans `blinkpy/api.py` (44 fonctions listees, relues au-dela des
deux GET deja identifiees) :
`request_new_image` (action "snap") et `request_new_video` (action
"record") appellent toutes deux `request_camera_action()`, qui POST une
vraie commande a Blink puis attend sa realisation via `wait_for_command()`
(poll de `request_command_status()`, jusqu'a 120 x 1s). Contrairement aux
deux GET passifs deja trouves, celle-ci reveille reellement l'appareil
physique. C'est tres probablement l'equivalent du geste de rafraichissement
de l'application officielle - la premisse de l'utilisateur etait juste,
ma conclusion precedente etait incomplete.
Cout reel cependant, deja reconnu ailleurs dans ce fichier pour la vignette
figee de repos (voir le commentaire de `send_camera_thumb()` dans
`serve.py` : "la demander reveillerait la camera et userait sa batterie a
chaque affichage") : reveiller une camera sur batterie et lui faire prendre
une photo a chaque clic n'est pas un cout equivalent a une simple lecture
cache. Decision prise avec l'utilisateur : action separee et explicite
(pas fondue dans "Actualiser"), plutot qu'un cout impose silencieusement a
chaque usage. Non implemente a ce stade - seule la recherche est actee ici ;
le code viendra dans une entree separee le jour ou il sera ecrit.
### 28.52 Plein ecran pour le direct (Fullscreen API)
Signale par un utilisateur sur Reddit (vadimus_ca) : demande d'une option
d'agrandissement pour le direct ("scaling option for live stream, 1:1 or
maximized"). Verification prealable demandee par l'utilisateur avant de
coder : les clips enregistres beneficient deja d'un plein ecran natif via
l'attribut `controls` du lecteur video du navigateur, rien a construire de
ce cote (confirme en direct). Le direct, lui, n'a ni bouton dedie ni
attribut `controls` - la balise `<img>` du flux MJPEG n'en a jamais, et la
balise `<video>` du flux MSE en est deliberement privee (un scrubber n'a
pas de sens sur un flux sans fin). Le manque est donc reel et specifique
au direct.
#### Correctif
`serve.py` :
- CSS : `.live` recoit `cursor:pointer` ; nouvelle regle
`.live:fullscreen, .live:-webkit-full-screen { aspect-ratio:auto; }`
(sans elle, le cadre garde son 16/9 fixe et se retrouve barre de bandes
noires en plein ecran au lieu de remplir l'ecran).
- JS : nouvelle fonction `toggleFullscreen(event, name)`, appelee par un
`onclick` pose sur le conteneur `.live` lui-meme - jamais recree par les
differents etats repos/direct/erreur, contrairement a son contenu, donc
elle survit a tous les changements d'etat. Un garde
(`event.target.closest("button")`) laisse passer les clics sur "Voir en
direct"/"Arreter" sans declencher le plein ecran par-dessus.
- i18n : nouvelle cle `live.fullscreen.title` (FR/EN), posee en `title` sur
le conteneur.
#### Tests et verifications
- Syntaxe Python revalidee (`py_compile`).
- Suite complete : tests verts (changement JS/CSS pur, aucun test ne
couvre ce chemin cote navigateur).
- Verifie en navigateur automatise, instance locale de test (port 8799,
donnees de camera factices injectees en console - aucune session Blink
requise pour ce test purement front-end) : le clic sur la zone `.live`
hors bouton declenche bien `requestFullscreen()` sur le bon element,
confirme par l'erreur `TypeError: not granted` remontee par l'API
elle-meme (pas une ReferenceError ni un bug de selecteur). La regle CSS
`:fullscreen` est bien presente et valide dans la feuille de style
chargee.
- Non confirme visuellement : le navigateur automatise refuse la demande
de plein ecran ("not granted"), limite de permission propre a cet
environnement CDP, meme famille que la limite `resize_window` deja
rencontree (voir 28.48) - pas un defaut du code. A confirmer par un clic
reel dans un navigateur interactif (quelques secondes).
#### Suite (meme jour) : bouton explicite plutot qu'un clic sur toute la zone
Confirme fonctionnel en vrai par l'utilisateur des la premiere version
(clic sur la zone `.live`), mais juge peu intuitif a l'usage : rien ne
signalait que la zone etait cliquable. Remplace par un bouton dedie,
visible en permanence dans le coin superieur droit du cadre ("Plein
ecran" / "Fullscreen"), sur le modele du bouton "Arreter" deja present
(meme classe `.watch`, position absolue symetrique en haut plutot qu'en
bas).
`serve.py` :
- Nouvelle fonction partagee `expandBtn(name)`, posee a la fois dans
`repos()` (donc reprise automatiquement par l'etat repos, l'etat
d'echec qui reutilise `repos()`, et l'etat arrete) et dans `watchMse()`
(etat direct actif) - visible dans tous les etats plutot que d'apparaitre
seulement en cours de lecture.
- `toggleFullscreen()` simplifiee : ne prend plus l'evenement en
parametre ; le garde anti-bouton n'a plus de raison d'etre puisque
c'est desormais le bouton lui-meme qui appelle la fonction, plus un
clic ecoute au niveau du conteneur.
- L'onclick pose sur `.live` lui-meme est retire (revert).
- Nouvelle cle i18n `live.fullscreen` (libelle du bouton, FR/EN),
distincte de `live.fullscreen.title` (infobulle) deja posee.
Trouvaille en passant, non traitee ici : `watch(name)` (version MJPEG,
serve.py, definie ~ligne 2777) n'a plus aucun appelant nulle part dans le
fichier - `repos()` appelle uniquement `watchMse()`. Code mort, meme
famille que daily.py (28.47) - a confirmer et supprimer dans une entree
separee si voulu, pas traite ici pour rester sur le sujet du bouton.
#### Tests et verifications (bouton explicite)
- Syntaxe Python revalidee (`py_compile`).
- Suite complete revalidee apres le remaniement : verte.
- Verifie en navigateur automatise (meme methode que ci-dessus, nouvelle
instance de test) : le clic precis sur le bouton (coordonnees lues via
`getBoundingClientRect()`) declenche bien `requestFullscreen()`, meme
signature `TypeError: not granted` qu'avant - mecanisme confirme, seule
la permission CDP bloque toujours la confirmation visuelle automatisee.
#### Suite (meme jour) : icone seule sans texte, et bug de traduction gelee
Deux retours de l'utilisateur apres essai reel :
1. "Arreter" et "Plein ecran" restaient dans l'ancienne langue apres bascule
FR/EN pendant un direct actif.
2. Le bouton restait etiquete "Plein ecran" une fois deja en plein ecran ;
demande de repasser en icone seule, sans texte.
Cause du (1) : `renderLive()` gele volontairement la grille tant qu'un
direct tourne (`if (Object.keys(MSE_ABORT).length) return;`, poste en
28.20 pour ne pas couper un flux en cours). `setLang()` appelle bien
`renderLive()` a chaque bascule, mais ce garde le court-circuite : les
boutons "Arreter"/"Plein ecran" deja poses survivent donc tels quels,
dans la langue ou ils ont ete crees. `applyI18n()`, qui balaie tout le
document via les attributs `data-i18n`, ne les voyait pas : ils sont
generes par JS via `t()` au moment de la creation, sans cet attribut.
`serve.py` :
- Bouton "Arreter" (`watchMse()`) : ajout de `data-i18n="watch.stop"` -
desormais repris par le balayage `applyI18n()` meme pendant le gel.
- `expandBtn()` : icone seule (glyphe Unicode U+26F6, sans texte,
`live.fullscreen` retiree du dictionnaire i18n, devenue inutile).
`title`/`aria-label` poses pour l'accessibilite (meme convention que
`passToggle`, deja ainsi).
- Nouvelle fonction `syncExpandButtons()` : relit `document.fullscreenElement`
(et son equivalent prefixe `-webkit-`), compare a chaque bouton via
`.closest(".live")`, et pose l'icone/le libelle qui correspond a l'etat
reel (icone d'entree si absent, icone de sortie sinon) - pas un simple
drapeau interne, donc jamais desynchronisee d'un vrai changement d'etat
(sortie par Echap ou par l'UI du navigateur comprise). Appelee a la fois
par l'evenement `fullscreenchange`/`webkitfullscreenchange` et par
`setLang()` (meme raison que pour `watch.stop` : le bouton peut survivre
gele a un changement de langue).
- Nouvelle cle i18n `live.fullscreen.title.exit` (FR/EN), a cote de
`live.fullscreen.title` (etat "entrer") deja postee.
- CSS `.watch.expand` : gabarit resserre pour une icone seule plutot
qu'icone+texte.
#### Tests et verifications (icone seule + traduction gelee)
- Syntaxe Python revalidee (`py_compile`).
- Suite complete revalidee : verte.
- Verifie en navigateur automatise : etat `MSE_ABORT` simule pour
reproduire le gel (direct "actif" sans navigateur reel), bascule FR to
EN pendant ce gel - "Arreter"/"Agrandir en plein ecran" deviennent bien
"Stop"/"Expand fullscreen" sur les memes elements DOM, sans passer par
un nouveau rendu de la grille (confirme : `MSE_ABORT` toujours peuple
apres la bascule, donc le gel etait reellement actif pendant le test,
pas contourne). Clic sur le bouton icone seule : meme
`TypeError: not granted` qu'avant, mecanisme inchange.
- Non teste : le changement d'icone au passage reel en/hors plein ecran
(meme limite de permission CDP que le mecanisme lui-meme, voir
ci-dessus) - verifie uniquement par lecture du code
(`syncExpandButtons()` derive toujours l'etat de
`document.fullscreenElement`, jamais d'un drapeau local qui pourrait
desynchroniser).
### 28.53 Nettoyage - suppression du direct MJPEG mort (watch()/send_live())
Signale par l'assistant en marge du bouton plein ecran (28.52) : `watch()`
(serve.py, JS) n'avait plus aucun appelant - `repos()` n'appelle que
`watchMse()`. Confirme par recherche exhaustive (grep de `watch(` sur tout
le fichier : une seule occurrence, sa propre definition) avant d'agir,
meme discipline que pour daily.py (28.47).
Cause racine identifiee via 28.15 : MJPEG, MSE et WebRTC ont ete compares
sur donnees reelles, MSE retenu. A cette occasion, "Code WebRTC entierement
retire (serveur, routes, JS, venv) apres la decision, pour ne pas laisser
de code mort" - discipline appliquee au perdant WebRTC, mais pas au
perdant MJPEG : seul le bouton principal a ete rebascule de `watch()` vers
`watchMse()` (28.15), l'ancien chemin serveur et JS est reste en place,
non appele, depuis lors. Cette suppression termine le nettoyage deja fait
une fois pour WebRTC.
#### Perimetre verifie avant suppression
Cascade complete tracee avant de couper quoi que ce soit, pour ne retirer
que ce qui est reellement exclusif au chemin MJPEG :
- `LIVE_BOUNDARY` (constante, multipart boundary) : usage exclusif a
`send_live()`. Supprimee.
- `LIVE_FIRST_FRAME_SECONDS`, `LIVE_MAX_SECONDS`, `LecteurTube`,
`self.ffmpeg` : partages avec `send_live_mse()` et/ou d'autres chemins
(collecte des clips, normalisation). Conserves.
- `LAST_LIVE_ERROR`/`/api/live-error` : verifie que `send_live_mse()`
ecrit aussi dedans (son propre bloc except, symetrique) - endpoint
partage, pas exclusif a MJPEG. Conserve.
- Recherche dans les fichiers de test (`test_*.py`) : aucune reference a
`send_live`, `/live/` ou "direct MJPEG". Rien a adapter cote tests.
#### Correctif
`serve.py` :
- JS : suppression de `function watch(name) { ... }` (repos()/echec ne
dependaient deja que de `watchMse()`).
- Python : suppression de `send_live()` (240 lignes au total avec le
reste de cette entree - chaine complete blinkpy -> ffmpeg ->
multipart/x-mixed-replace).
- Suppression de la route `if route.startswith("/live/"): self.send_live(...)`.
`/live-mse/` seule route de direct restante.
- Suppression de la constante `LIVE_BOUNDARY`, devenue inutilisee.
#### Tests et verifications
- Syntaxe Python revalidee (`py_compile`).
- Suite complete revalidee apres suppression : verte.
- `git diff --stat` : 240 suppressions nettes, aucune addition (retrait
pur, aucun comportement nouveau).
### 28.54 Hors sequence - image Docker
Demande explicite de l'utilisateur, suite a une question Reddit (Chicken-LoverYT
: "Can you release this as a docker container?"). Decision de perimetre prise
avec l'utilisateur avant d'ecrire quoi que ce soit (voir echange en amont de
cette entree) : separer code (image) et donnees (volume), pas les faire
cohabiter au meme chemin.
#### Point d'architecture (evite, deja resolu)
Hypothese de depart : `blink_auth.json`/`blink_reglages.json`/dossiers de
clips, localises via un `BASE_DIR` par fichier, exigeraient un petit
refactor multi-fichiers pour devenir deplacables (variable d'environnement)
avant de pouvoir separer code et donnees sans risquer le piege classique du
volume qui masque une image mise a jour. Verification avant d'ecrire quoi
que ce soit : `blink_auth.py`, `merge_daily.py`, `serve.py` et `watch.py`
definissent tous `BASE_DIR = runtime.app_dir()`, pas un `Path(__file__)`
independant chacun. Et `runtime.app_dir()` (runtime.py:289) gere deja une
variable d'environnement `BLINK_HOME` a cet effet precis ("utile pour une
installation en lecture seule"). Aucun changement Python necessaire : le
mecanisme existait deja, construit pour une autre raison, et convient tel
quel.
#### Risque verifie avant d'ecrire le Dockerfile
`blink_auth.json` est present, avec de vrais jetons de session, dans le
checkout local ayant servi a cette session de travail. Un `COPY . .` sans
filtrage l'aurait embarque dans l'image. `.dockerignore` reprend donc
explicitement le perimetre deja valide de `.gitignore` (memes fichiers
secrets/personnels, memes commentaires de raison), plus quelques exclusions
propres a Docker (`.git/`, tests, captures d'ecran).
#### Correctif
Nouveaux fichiers :
- `requirements.txt` : `aiohttp`, `blinkpy`, `tzdata`, `imageio-ffmpeg` -
repris tels quels du job `tester` de `ci.yml` (deja valide en usage
reel sur les trois OS), pas devine. `Pillow`/`pyinstaller` (presents
dans `build.py:PAQUETS`) exclus : verifie qu'aucun fichier du projet
n'importe PIL a l'execution, usage limite a la construction du bundle
(icone).
- `Dockerfile` : base `python:3.12-slim`, `apt-get install ffmpeg` (meme
raison que le job `tester` de `ci.yml` : le ffmpeg d'imageio-ffmpeg pour
Linux n'a pas libfreetype/drawtext, celui de la distribution l'a),
`pip install -r requirements.txt`, `COPY . .` filtre par
`.dockerignore`, `ENV BLINK_HOME=/data`, `VOLUME /data`,
`CMD python blink2video.py serve`.
- `docker-compose.yml` : port publie en `127.0.0.1:8765:8765` par defaut
(pas juste `8765:8765`, forme la plus courante dans les exemples/tutoriels
mais qui exposerait au reseau local entier une appli sans mot de passe -
voir reponse a Chicken-LoverYT). Volume nomme `blink_data` pour `/data`.
- `.dockerignore` : voir ci-dessus.
- README/README.fr : section "With Docker"/"Avec Docker" ajoutee dans
chaque encart de demarrage, meme contenu que l'encart "From source"
voisin, meme avertissement sur le port en 127.0.0.1.
#### Tests et verifications
- Docker absent de cette machine de travail (`docker --version` echoue) :
aucune verification locale possible, a la difference du reste de cette
session. Compense par un nouveau job CI (`docker`, `.github/ci.yml`) qui
construit l'image, verifie le filtre drawtext du ffmpeg installe (meme
controle que "Verifier que le bundle est autonome et sait horodater"
pour les bundles PyInstaller), demarre le conteneur avec le port publie
comme un utilisateur le ferait, et interroge la page servie. Non encore
execute au moment de l'ecriture de cette entree - a verifier sur GitHub
Actions apres le push, pas suppose reussir sur la seule lecture du code.
- `docs.py --check` : README/README.fr toujours a jour (aucun changement
dans le bloc verbes que ce fichier controle).
- Suite complete Python inchangee : verte (aucun fichier .py existant
modifie par cette entree).
#### Suite (meme jour) : premier run CI, echec reel - confirme le risque explique en amont
Job `docker` execute, echec sur "Demarrer le conteneur et interroger la
page" : le conteneur demarre (id obtenu), mais `curl` n'obtient jamais de
reponse en 20 s, `page.html` n'existe pas. Les deux autres etapes du job
(construction de l'image, controle drawtext) reussissent : le probleme est
bien reseau, pas ffmpeg ni dependances.
Cause : exactement le mecanisme deja explique a l'utilisateur avant
d'ecrire le Dockerfile (voir echange en amont de 28.54), mais non
reellement corrige a ce moment-la - seul `BLINK_HOME` avait ete cable,
pas l'adresse d'ecoute. `serve.py` lie volontairement le serveur a
`127.0.0.1`, jamais `0.0.0.0` ("cet outil deplace des fichiers, il n'a
rien a faire sur le reseau local", commentaire en place de longue date).
A l'interieur du conteneur, `127.0.0.1` designe la boucle locale DU
CONTENEUR : le reseau en pont de Docker route le port publie vers
l'interface du conteneur, jamais vers sa boucle locale, donc `curl` cote
hote ne pouvait structurellement pas atteindre le serveur, meme avec le
port correctement publie.
#### Correctif
`serve.py` : nouvelle variable d'environnement `BLINK_BIND` (defaut
"127.0.0.1", comportement inchange hors conteneur), utilisee a la place
de la valeur en dur pour l'adresse d'ecoute du serveur.
`Dockerfile` : `ENV BLINK_BIND=0.0.0.0`. La frontiere de securite ne
bouge pas pour autant : elle reste posee par la publication du port dans
`docker-compose.yml` (`127.0.0.1:8765:8765` en dur, deja en place), pas
par l'adresse d'ecoute interne au conteneur - exactement la distinction
faite a l'utilisateur avant d'ecrire le premier Dockerfile.
#### Tests et verifications
- Syntaxe Python revalidee (`py_compile`).
- Suite complete revalidee : verte.
- A verifier sur le prochain run CI (push a suivre) : le job `docker`
doit maintenant obtenir une reponse de la page. Le mecanisme de secours
(`docker logs` sur echec) n'avait lui-meme produit aucune sortie visible
dans le journal du premier run - a surveiller, pourrait signaler un
deuxieme probleme distinct si l'echec persiste apres ce correctif.
#### Suite (meme jour) : run CI corrige, succes confirme
Deuxieme run apres le correctif `BLINK_BIND` : job `docker` vert, page
servie et repondant. Le doute ci-dessus etait donc bien le seul probleme ;
pas de deuxieme cause distincte a chercher.
### 28.55 Bouton "Reveiller" - suite et cloture de 28.51
Reprise du fil laisse en suspens en 28.51 (suite) : une vraie commande de
reveil existe (`camera.snap_picture()`, POST + attente via
`wait_for_command`), decision prise avec l'utilisateur d'une action
separee et explicite plutot que fondue dans "Actualiser", a cause du cout
reel (reveille une camera sur batterie, lui fait prendre une photo,
jusqu'a 2 minutes d'attente). Implementation faite ici, apres le detour
par le plein ecran (28.52) et le nettoyage MJPEG (28.53) survenus entre
temps dans la meme conversation.
#### Correctif
`serve.py` :
- Nouvelle methode `reveiller_camera(name)` (Python