Architecture de ha-pronote-ng¶
Ce document décrit l'architecture réelle de ha-pronote-ng, telle qu'elle est
implémentée dans custom_components/pronote_ng/. Il est écrit pour un lecteur
qui n'a jamais ouvert ce dépôt et qui doit pouvoir, à la fin, expliquer non
seulement où les choses se trouvent mais pourquoi elles sont arrangées
ainsi.
La source de vérité est le code. Les documents de conception —
SPECIFICATION.md,
annexe-a-entites.md,
annexe-b-rate-limit.md et la
revue contradictoire — ont produit les décisions
qui suivent, mais l'implémentation les a corrigés en plusieurs endroits. Les
divergences relevées sont rassemblées à la fin, dans
« Écarts avec la spécification ».
Un mot de vocabulaire, parce que la confusion coûte cher : le dépôt s'appelle
ha-pronote-ng et l'intégration s'affiche sous le nom Pronote NG. Le
domaine Home Assistant s'appelle
pronote_ng, et seulement lui. Ce choix est délibéré
(DOMAIN, custom_components/pronote_ng/const.py) : une autre intégration PRONOTE
déjà installée peut occuper le domaine pronote, et deux composants
personnalisés qui réclament le même domaine ne cohabitent pas — Home Assistant
en charge un et ignore l'autre. Garder un domaine distinct permet d'essayer
cette intégration sans désinstaller d'abord ce dont on dépend.
Table des matières¶
- Objet de l'intégration
- Vue d'ensemble et insertion dans Home Assistant
- Le limiteur de débit
- L'ordonnanceur
- La session
- La passerelle
- Le modèle de données
- Un cycle de collecte, de bout en bout
- Le flow de configuration
- Sécurité et vie privée
- La qualité
- Écarts avec la spécification
1. Objet de l'intégration¶
ha-pronote-ng expose dans Home Assistant les données PRONOTE d'un compte parent
ou élève : emploi du temps, devoirs, notes, absences, évaluations, actualités,
messagerie, menus de cantine, équipe pédagogique et bulletins des périodes
closes.
L'objectif directeur, énoncé dans l'en-tête de custom_components/pronote_ng/__init__.py,
n'est pas « afficher PRONOTE » mais rendre les automatisations simples à
écrire, avec un critère de succès précis : toute automatisation scolaire
ordinaire doit pouvoir s'écrire sans template Jinja. Cet objectif est ce qui
explique la forme de presque tout le reste — des états numériques plutôt que
du texte formaté, des binary_sensor pour les prédicats, des entités event
pour les changements, des device triggers nommés, et huit blueprints livrés
en français et en anglais dans blueprints/automation/pronote_ng/{fr,en}/.
La contrainte structurante, elle, vient d'ailleurs. PRONOTE ne publie aucune
limite de débit : il applique des sanctions, et la plus coûteuse d'entre elles
— la suspension d'adresse — vise une adresse IP et non un compte, se
déclenche sur des échecs de connexion répétés, et n'est documentée nulle part
(l'en-tête de custom_components/pronote_ng/ratelimit.py). Tout le dimensionnement de
cette intégration découle de ce fait : une seule voie vers le réseau, un
budget compté, et une comptabilité de l'authentification séparée de celle des
requêtes.
La dépendance amont est pronotepy==2.15.7, épinglée exactement
(custom_components/pronote_ng/manifest.json). Le pin n'est pas de la
prudence rituelle : la moitié des décisions de la passerelle et du client
durci reposent sur des détails de comportement vérifiés dans cette version
précise, et un déplacement de version demande de relire deux fichiers.
2. Vue d'ensemble et insertion dans Home Assistant¶
2.1 Les couches¶
Le code se lit en quatre couches, de la plus abstraite à la plus contrainte.
La voie vers le réseau n'est plus la passerelle Pronote : c'est un
connecteur (spécification connecteurs §3.1). Auth, transport, budget et
décodage vivent dedans. Au-dessus, l'ordonnanceur, le delta, les coordinateurs
et les plateformes ne voient qu'une couture ; tiers.py ne fait plus que
collect + instantané + delta.
Le cœur de domaine ne connaît ni un protocole scolaire ni Home Assistant.
Il contient l'ordonnanceur (scheduler.py), le détecteur de delta
(delta.py), les DTO gelés (models.py), les constantes (const.py),
la lecture et l'estimation des options (options.py) et la normalisation
d'URL (urls.py). Ces modules prennent des horloges injectables et répondent
à des questions ; c'est ce qui les rend testables à 100 % sans réseau et sans
instance Home Assistant. Les DTO restent ceux de Pronote — le pivot interne
n'est pas un second modèle « école » (spécification connecteurs §3.2). Le
pivot cartes, lui, est le JSON d'attributs de
l'annexe A §2.1.
L'orchestration relie le cœur au monde sans importer un protocole :
account.py possède le battement de cœur et la boucle de collecte, tiers.py
demande un palier au connecteur puis publie l'instantané, coordinator.py
détient les instantanés, login_guard.py conserve l'état punitif Pronote qui
doit survivre aux objets qui l'ont ouvert. Une entry Ecoledirecte ne construit
ni SerialExecutor, ni SessionManager, ni le RateLimiter Pronote.
La couture SchoolConnector est la voie réseau. build_connector()
lit source sur l'entrée (absente = Pronote, spécification connecteurs §3.6)
et rend une implémentation. Un connecteur = un compte d'une source ; deux
sources dans le foyer = deux entries. Les capacités (tiers, writes,
services) commandent ce qui est collecté, exposé en entité et proposé dans
les options (spécification connecteurs §3.4).
- Pronote —
PronoteConnectorpossède encore toute la pile historique :session.py,ratelimit.py(annexe B : compteur chiffré,Erreur.G, 5–7 requêtes par login),gateway.pyethardened_client.py. Ces deux derniers restent les seuls à importer des types de donnéespronotepy. - Ecoledirecte —
EcoledirecteConnectorpossède son limiteur (ed_limiter.py), un client aiohttp calqué sur le handshakeecoledirecte_api0.3.0 (ed_client.py) et le mapping vers les DTO (ed_mapping.py). Seulconnectors/ecoledirecte/voit le JSON Aplim. Le paquet PyPIecoledirecten'est pas une dépendance : sonbackoffrelancerait un login hors admission, et son journal DEBUG fuit le jeton (spécification connecteurs §7.1). La version d'API est la constanteECOLEDIRECTE_API_VERSIONdansed_client.py; le workflowecoledirecte-watchla compare chaque semaine àAPIVERSIONlu dans le sdist ou la wheel PyPI, échoue s'ils divergent, et ouvre une pull request qui met à jour la constante. Un 517 en production, c'est cette sonde en retard, pas un « réessayer ».
Les deux limiteurs isolent leurs compteurs. Ils partagent la forme de
décision (admit / TierDeferred / LimiterState), les clés d'options et
la politique d'heures creuses, jamais les octets ni le garde-fou de flow
(spécification connecteurs §3.3). Ecoledirecte v1 ne collecte que
TIMETABLE, HOMEWORK, MARKS et ATTENDANCE ; SESSION n'est jamais
passé à async_collect (spécification connecteurs §3.5).
La couche Home Assistant : __init__.py (setup et teardown), les sept
plateformes d'entités, config_flow.py et flow_login.py, services.py,
diagnostics.py, et les trois modules de device automation. Les services
Pronote-only parlent à l'implémentation Pronote, pas au protocole commun :
une entry Ecoledirecte n'a rien à leur offrir.
flowchart TB
subgraph HA["Home Assistant"]
CE["Config entry et runtime_data"]
PLAT["Plateformes sensor, binary_sensor, calendar, todo, event, image, button"]
SVC["Services Pronote-only et device automations"]
DIAG["Diagnostics et repairs"]
REG["Device et entity registry"]
end
subgraph ORCH["Orchestration · ni protocole"]
ACC["account.py · battement de coeur, batch, deltas"]
TIERS["tiers.py · collect, snapshot, delta"]
COORD["coordinator.py · un coordinateur par palier"]
GUARD["login_guard.py · etat punitif Pronote hors entree"]
end
subgraph CORE["Coeur de domaine"]
SCHED["scheduler.py · echeances, pas de minuteurs"]
DELTA["delta.py · detection de changement"]
MODELS["models.py · DTO geles, pivot interne"]
OPT["options.py · lecture et estimation du budget"]
end
subgraph SEAM["Couture SchoolConnector · la voie reseau"]
FACT["build_connector · source ou Pronote"]
SC["SchoolConnector"]
end
subgraph PR["PronoteConnector"]
SESS["session.py · SessionManager et SerialExecutor"]
RL["ratelimit.py · annexe B"]
GW["gateway.py · decodage vers DTO"]
HC["hardened_client.py · client durci et transport"]
end
subgraph ED["EcoledirecteConnector"]
EDLIM["ed_limiter.py"]
EDHTTP["ed_client.py · handshake 0.3.0"]
EDMAP["ed_mapping.py"]
end
PY["pronotepy 2.15.7"]
NETP["Serveur PRONOTE de l etablissement"]
NETE["api.ecoledirecte.com"]
CE --> ACC
PLAT --> COORD
SVC --> PR
DIAG --> ACC
PLAT --> REG
ACC --> TIERS
ACC --> SCHED
ACC --> DELTA
ACC --> COORD
ACC --> GUARD
ACC --> OPT
ACC --> FACT
FACT --> SC
TIERS --> SC
SC --> PR
SC --> ED
SESS --> RL
SESS --> HC
GW --> MODELS
ED --> MODELS
GW --> PY
HC --> PY
PY --> NETP
EDHTTP --> NETE
COORD --> MODELS
classDef boundary fill:#fde68a,stroke:#b45309,color:#111
class GW,HC,EDHTTP,EDMAP boundary
La frontière pronotepy reste le trait le plus important du connecteur
Pronote. Elle existe pour trois raisons concrètes, énoncées dans
l'en-tête de custom_components/pronote_ng/models.py :
- un objet
pronotepylu après la fermeture de sa session lèveErreur.G = 22; - plusieurs attributs amont sont paresseux —
Information.content,Attachment.data,Lesson.content,Period.grades,Discussion.messages— et appellent le réseau au moment de la lecture, donc potentiellement depuis la boucle d'événements et hors de tout budget ; - une entité qui détiendrait une référence au client maintiendrait vivant tout le graphe d'objets, session comprise.
Le cas qui mérite d'être nommé est ClientInfo._cache(), qui poste
directement via communication.post en contournant ClientBase.post et — sur
un compte parent — la signature membre qui dit de quel enfant il s'agit.
Lire ClientInfo.address, .email, .phone ou .ine_number depuis n'importe
où déclenche donc un appel non budgété et possiblement mal attribué. C'est
la meilleure justification de cette frontière-là : elle transforme un piège
d'attribution en une impossibilité de construction. Côté Ecoledirecte, le
même principe s'applique sans pronotepy : le JSON Aplim s'arrête au
mapping, et aucun jeton ne franchit la mémoire du client.
2.2 Le cycle de vie de l'entrée de configuration¶
async_setup_entry (custom_components/pronote_ng/__init__.py) suit un
ordre qui n'est pas indifférent.
- Construction de
PronoteAccount. Le constructeur assemble la passerelle avec le fuseau de l'établissement, le limiteur, l'ordonnanceur, l'exécuteur sériel, le gestionnaire de session, le détecteur de delta et un coordinateur par palier —Tier.SESSIONinclus, qui ne coûte aucune requête mais dont les entités ont besoin de quelque chose à quoi s'abonner. - Restauration de l'état hérité :
limiter.import_state(...)etscheduler.import_state(...)relisent ce que l'instance précédente a déposé danshass.data(account.py). C'est ce qui empêche une boucle de retry de setup d'effacer une pénalité — voir §3.7. account.async_setup(): une connexion, la lecture de la forme du compte (enfants, périodes, période courante) à zéro requête supplémentaire, puis l'armement du battement de cœur et une première collecte immédiate.- Classement des échecs. C'est ici que le vocabulaire d'erreurs de
session.pyet deconnectors/errors.pyse traduit en vocabulaire Home Assistant (§7.2) :InvalidCredentials,MfaRequired,ConnectorCredentialsErroretConnectorChallengeRequireddeviennentConfigEntryAuthFailed, donc un flow de ré-authentification ;BootstrapFailedetConnectorTransportErrorouvrent la réparationbootstrap_failedpuisConfigEntryNotReady;AccountUnreadable,ConnectorUndecodableErroret unConnectorErrornu ouvrentaccount_unreadablepuisConfigEntryNotReady;LoginRefused,LoginRefusedByLimiter,IntegrationFault,ConnectorSessionExpiredError,PronoteAPIError,TimeoutErroretOSErrordeviennentConfigEntryNotReadysans réparation. La position de ce dernier bras au-dessus de l'attrape-toutConnectorErrorn'est pas cosmétique : un jeton de session expiré y ouvrait sinon une carte demandant un rapport de bug — qu'aucun chemin de code ne referme — pour une condition réparée au réessai suivant, le connecteur ayant déjà oublié ses jetons. La distinction compte : répondre « identifiants invalides » à un serveur qui répond mal envoie l'utilisateur ressaisir un mot de passe correct. entry.runtime_data = account, et aussihass.data[DOMAIN][entry_id]. Le second n'est pas une redondance : les device automations et les services résolvent un compte depuis undevice_idsans avoir l'entrée sous la main.- Le device compte est créé ici, avant le forwarding des plateformes
(
async_setup_entry,__init__.py). Chaque device enfant déclarevia_devicesur lui, et les sept plateformes sont forwardées en parallèle : au premier démarrage, les devices enfants étaient créés avant l'existence de leur parent, ce que Home Assistant signale par « references a non existing via_device » et sanctionne en aplatissant l'arbre. Le défaut se réparait au redémarrage suivant, ce qui est exactement pourquoi il avait survécu à la revue. - Forwarding des plateformes, enregistrement des services, écoute des changements d'options.
Le déchargement (async_unload_entry, __init__.py, et
PronoteAccount.async_unload, account.py) fait trois choses dans cet
ordre : arrêter le battement, exporter l'état de l'ordonnanceur et du
limiteur vers hass.data, puis fermer la session. Et il ne joint jamais le
fil de travail depuis la boucle d'événements : le transport pronotepy peut
être au milieu d'une requête, et joindre gèlerait le rechargement. Un fil qui
fuit vaut mieux qu'une instance figée.
Un changement d'options provoque un rechargement de l'entrée
(async_reload_entry, __init__.py) et non une reconfiguration en
place. C'est le comportement de Home Assistant, et c'est pourquoi la
préservation des échéances passe par export_state / import_state et non
par FetchScheduler.reconfigure — voir §4.4.
2.3 Ce qui est exposé¶
| Surface | Où | Contenu |
|---|---|---|
| Plateformes | sensor, binary_sensor, calendar, todo, event, image, button (PLATFORMS, __init__.py) |
15 capteurs primitifs, 17 capteurs « compteur + liste », 8 séries d'historique par période close, 8 capteurs de diagnostic, 9 binary_sensor métier plus un throttled, 3 calendriers, 1 liste de devoirs, 9 entités event, 1 image, 2 boutons |
| Services | services.py, services.yaml |
refresh, get_ical_url, get_identity, mark_homework_done, mark_information_read, send_message, generate_timetable_pdf, get_rate_limit_status |
| Device automations | device_trigger.py, device_condition.py, device_action.py |
14 déclencheurs, 10 conditions, 4 actions |
| Registres | _account_device / _student_device, entity.py |
un device par compte, un device par enfant en via_device |
| Diagnostics | diagnostics.py |
téléchargement par entrée et par device |
| Repairs | async_open_credentials_issue et les trois autres, account.py |
daily_cap_near, invalid_credentials, bootstrap_failed, account_unreadable, mfa_required |
Les devices sont la clé de lisibilité des automatisations. « Quand un cours est annulé » doit se poser à propos de quelqu'un : un device par enfant sous un device parent qui porte l'établissement est ce qui rend les device triggers compréhensibles dans l'éditeur. Les entités de diagnostic, elles, sont rattachées au device compte et non à un enfant, parce que le budget est partagé entre les enfants d'un même compte — le serveur voit une session et une adresse IP, quel que soit le nombre d'enfants suivis.
3. Le limiteur de débit¶
custom_components/pronote_ng/ratelimit.py est le cœur du design. Il est
aussi le module le plus long après sensor.py et gateway.py, et le seul dont
le docstring d'ouverture énumère les propriétés porteuses avant le premier
import — parce que chacune d'elles corrige un défaut réel trouvé en revue,
non un risque hypothétique.
3.1 Ce qui est puni, et par quoi¶
Le tableau que le module reproduit en tête de fichier
(ratelimit.py) est ce qui explique sa forme :
| Sanction | Déclencheur | Gravité |
|---|---|---|
| Session cassée | deux appels concurrents désynchronisent le compteur de requêtes chiffré | immédiate, récupérable |
Erreur.G = 8 ou 10 |
session expirée par inactivité | bénigne |
Erreur.G = 25 |
trop de requêtes d'autorisation | attendre, ne pas insister |
| Suspension d'adresse | connexions échouées répétées | coûteuse, et non documentée |
Seule la dernière fait vraiment mal, et elle ne vise pas les appels : elle vise les connexions échouées. Un limiteur qui ne compterait que des requêtes manquerait sa cible. C'est pour cela que l'authentification a sa propre comptabilité ici, et non parce que c'est plus propre.
3.2 Trois couches pour les appels¶
Les trois couches s'appliquent dans l'ordre et un appel doit franchir les
trois (RateLimiter.check, ratelimit.py).
Couche 1 — espacement minimal. min_request_interval secondes entre deux
requêtes, mesurées sur une horloge monotone (_spacing_wait,
ratelimit.py). Défaut : 1,0 s, réglable de 0,2 à 10 s.
Couche 2 — seau à jetons. Capacité burst_size, remplissage continu à
max_requests_per_hour / 3600 jeton par seconde (_refill,
ratelimit.py). Défauts : 20 jetons, 240 requêtes/heure. Le seau
démarre plein, pour ne pas pénaliser la première salve du simple fait que
l'intégration vient de démarrer. Et il est autorisé à devenir négatif : un
découvert n'est pas écrêté à zéro, sinon chaque dépassement était pardonné, une
salve d'appels coûteux ne coûtait rien à rembourser et le débit horaire ne
signifiait plus rien (_record_requests, ratelimit.py).
Couche 3 — plafond journalier. max_requests_per_day, réinitialisé au
changement de date locale, et seulement vers l'avant (_roll_day,
ratelimit.py). Défaut : 2000 requêtes, contre une consommation
nominale d'environ 180 par jour — un facteur onze, assumé comme filet de
sécurité contre un défaut logiciel et non comme contrainte d'exploitation.
Reculer la date — correction NTP, machine mal réglée au démarrage — adopte la
nouvelle date sans rien remettre à zéro : réinitialiser sur tout changement
de date re-accordait le budget entier et le plafond de connexions à chaque
recul d'horloge. Le seau, lui, est laissé tranquille dans les deux cas : il
lisse l'heure et n'a pas d'opinion sur la date.
Le plafond journalier ne refuse pas tout d'un coup : il sacrifie par
priorité (_SHED_THRESHOLD, ratelimit.py).
| Priorité | Seuil d'abandon | Paliers concernés |
|---|---|---|
CRITICAL |
inf — jamais abandonné |
session |
HIGH |
1,0 | timetable, homework |
NORMAL |
0,8 | news, marks, attendance |
LOW |
0,6 | discussions, evaluations, menus, static, history |
Le seuil de CRITICAL est infini, et non un grand nombre. La raison est
arithmétique : calls_today n'a pas de plafond, donc tout seuil fini est
atteignable — un utilisateur qui règle max_requests_per_day à son plancher
documenté de 100 franchit 2,0 dès le premier jour — et l'atteindre refuserait
la connexion dont tous les autres paliers dépendent.
Un quatrième seuil, CAP_WARNING_FRACTION = 0.8, n'abandonne rien : il ouvre
un repair issue informatif. Le plafond journalier n'est jamais silencieux,
parce que des données qui vieillissent sans erreur au journal sont le symptôme
le plus difficile à diagnostiquer qui existe.
3.3 Deux compteurs pour les connexions¶
may_login (ratelimit.py) applique les mêmes couches qu'un appel
ordinaire, avec exactement une exception : une connexion n'est jamais
sacrifiée par le plafond journalier, parce que refuser de se connecter laisse
toutes les entités périmées sans issue. Elle reste espacée, reste tirée du seau
et reste comptée — une connexion coûte cinq à sept requêtes HTTP, et un budget
qui ne les verrait pas décrirait une autre intégration que celle qui tourne.
À cela s'ajoutent deux compteurs propres :
logins_today, plafonné parmax_logins_per_day, défaut 24. Pas 120 : le design attend une à trois connexions par jour, et un plafond de 120 ne pourrait pas attraper le défaut pour lequel il existe.failed_logins_last_hour, plafonné parmax_failed_logins_per_hour, défaut 3. C'est le garde-fou de l'adresse IP. Atteint, il ouvre uncredentials_holdd'une heure.
Deux détails de ces compteurs sont porteurs.
Le premier est la classification de l'échec, et c'est ce qu'il y avait de
plus important à ne pas rater dans ce module. Un mot de passe erroné ne lève
pas PronoteAPIError : le déchiffrement du challenge échoue et _login
lève CryptoError, ou bien _login renvoie False parce que la clé cle est
absente de la réponse Authentification. Branché sur la mauvaise exception, le
compteur censé protéger l'adresse ne se serait jamais incrémenté — le garde-fou
aurait existé dans la documentation et pas dans le code qui tourne. C'est ce
que l'énumération LoginOutcome (ratelimit.py) nomme explicitement,
avec six issues : SUCCESS, BAD_CREDENTIALS, TRANSPORT, BOOTSTRAP,
MFA_REQUIRED, UNDECODABLE.
Le second est que toute tentative compte, réussie ou non. Ne compter que les succès laissait un compte défaillant tenter sans borne pendant que le compteur censé l'arrêter restait à zéro.
Enfin, les requêtes d'une connexion sont imputées à la clé
LOGIN_COST_KEY = "login" et non à str(Tier.SESSION)
(ratelimit.py). Le palier session ne coûte rien — périodes, classe
et établissement arrivent avec la connexion — donc imputer là les cinq à sept
requêtes faisait lire le seul palier gratuit comme la chose la plus chère que
l'intégration fasse, sur un attribut de diagnostic qu'on demande aux
utilisateurs de consulter pour régler leur budget. Et login n'est pas un
palier : il n'est donc jamais candidat au sacrifice et n'apparaît pas dans
TIER_PRIORITY.
3.4 L'admission plutôt que la sortie¶
C'est la décision centrale du module, et elle mérite d'être énoncée précisément.
La décision, l'attente et le débit ont lieu tous les trois sous un unique
asyncio.Lock, avant que la requête ne parte (RateLimiter.call,
ratelimit.py) :
async with self._admission:
verdict = self.check(tier, priority, cost=float(cost))
if not verdict.allowed:
raise TierDeferred(verdict.reason or DeferReason.HOURLY_BUDGET, verdict.wait)
if verdict.wait > 0:
await sleeper(verdict.wait)
self.commit(tier, cost)
result = await fn()
Débité à la sortie, le comportement était le suivant : deux appelants — un
batch programmé et un service invoqué pendant ce batch — lisaient le même
_last_call, calculaient la même attente, la dormaient ensemble et tiraient à
la même milliseconde. Le résultat est le compteur de requêtes désynchronisé de
la première ligne du tableau du §3.1, produit par la couche même qui existe
pour l'empêcher. Charger à l'admission, sous le verrou, est ce qui transforme
la couche 1 d'une intention en une garantie.
La même logique vaut pour les connexions (RateLimiter.login,
ratelimit.py), avec une seconde raison qui lui est propre : une
annulation pendant la poignée de main — entrée déchargée, sauvegarde
d'options, arrêt — lève CancelledError à travers tous les except de
session.py, donc note_login n'était jamais atteint alors que le fil de
travail achevait quand même la connexion. Cinq à sept requêtes atteignaient
l'établissement et n'apparaissaient dans aucun compteur.
Corollaire assumé : rien n'est jamais remboursé. La requête est partie au
moment où quoi que ce soit peut échouer, et un limiteur qui ne compterait que
les succès laisserait une boucle défaillante tourner sans borne
(commit, ratelimit.py).
3.5 La réconciliation GatewayResult.calls ↔ limiteur¶
Le coût demandé au seau doit être le coût débité. Demander un jeton en
prélevant six laissait le palier history vider quatre fois la capacité de
salve par heure sans aucune attente, ce qui supprime exactement le lissage pour
lequel la couche 2 existe.
Mais le coût a priori déclaré par l'appelant n'est pas toujours le coût réel.
Chaque fonction de la passerelle renvoie donc un GatewayResult qui porte
facts et calls (gateway.py), et SessionManager._reconcile
(session.py) débite la différence via RateLimiter.reconcile
(ratelimit.py) :
sequenceDiagram
autonumber
participant T as tiers.py
participant S as SessionManager
participant L as RateLimiter
participant G as gateway.py
T->>S: run tier, priority, fn, cost=1
S->>L: call tier, priority, fn, cost=1
Note over L: sous _admission · check, attente, commit de 1
L->>G: fn client
G-->>L: GatewayResult facts, calls=4
L-->>S: resultat
S->>L: reconcile tier, charged=1, actual=4
Note over L: debite 3 requetes supplementaires
S-->>T: GatewayResult
C'est ce qui rend GatewayResult.calls porteur et non décoratif. Sans
cela, le limiteur ne voyait jamais que le coût déclaré, et un accès accidentel
à une propriété paresseuse — Information.content, Period.grades,
Discussion.messages — plaçait de vraies requêtes que l'espacement, le seau
et le plafond journalier manquaient tous les trois, la vérité n'apparaissant
que dans un champ de diagnostic que personne ne compare.
La réconciliation est asymétrique : un appel qui coûte moins que déclaré n'est pas remboursé. Le coût déclaré est déjà sorti du seau, et un limiteur qui rendrait du budget ferait d'une déclaration optimiste une autorisation de salve.
Le cas concret est le palier discussions : tiers.py déclare
cost=1, mais gateway.discussions peut développer jusqu'à
MAX_DISCUSSION_EXPANSIONS = 3 fils et renvoyer calls=4. La réconciliation
est la seule chose qui empêche ces trois requêtes d'être invisibles.
3.6 Heures creuses, maintiens et repli exponentiel¶
Heures creuses. Fenêtre [quiet_end, quiet_start[ active, donc silencieuse
de 22:00 à 06:00 par défaut, traversée de minuit gérée explicitement
(_in_quiet_hours, ratelimit.py). Un batch commencé hors heures
creuses peut les finir : begin_batch / end_batch marquent la frontière et
BATCH_GRACE_SECONDS = 300 borne l'exemption, sinon un batch bloqué la
tiendrait ouverte toute la nuit (ratelimit.py). Seul CRITICAL
ignore les heures creuses.
quiet_seconds_between (ratelimit.py) existe pour une raison
inattendue et importante : la péremption doit exclure la nuit. Avec les
heures creuses activées — le défaut — une pause de huit heures dépasse
stale_after fois l'intervalle de tous les paliers rapides ; sans cette
exclusion, l'emploi du temps, les devoirs, les actualités et la messagerie
passaient unavailable à 06:00 chaque matin. Une entité indisponible
déclenche des automatisations à faux, ce qui en fait la pire façon disponible
de dire « rien ne s'est passé cette nuit ».
Le calcul de durée passe par _elapsed (ratelimit.py), qui
convertit en UTC avant de soustraire : retrancher deux datetime conscients
partageant le même objet tzinfo fait sauter à CPython la recherche
d'offset, donc « 01:30 jusqu'à 06:00 » valait quatre heures et demie le jour du
passage à l'heure d'été là où trois heures et demie s'écoulent réellement.
Maintiens. Un maintien (hold) refuse tout appel pendant une durée. Quatre
raisons, hiérarchisées par _HOLD_SEVERITY (ratelimit.py) :
BACKOFF (1) < MFA_HOLD (2) < BOOTSTRAP_HOLD (3) <
CREDENTIALS_HOLD (4). Un maintien n'est jamais remplacé par un moins
grave : sans cet ordre, une seule erreur de transport dégradait un maintien
d'une heure en un repli de trente secondes, et le succès suivant l'effaçait —
le maintien s'évaporait donc purement et simplement.
Le maintien MFA mérite un mot. Quand PRONOTE réclame le code à deux facteurs
que cette intégration refuse délibérément de stocker, aucune quantité de
tentatives ne peut réussir, et chacune coûte cinq à sept requêtes. Laissé sans
borne, cela produisait des milliers de tentatives par jour — précisément le
geste qui fait suspendre une adresse. C'est donc un maintien et non un
simple drapeau, et sa seule sortie est reset_after_reauth(), c'est-à-dire un
humain qui fournit le code (ratelimit.py).
Repli exponentiel. min(backoff_max, base × 2^(échecs-1)) × (0,5 + alea)
(backoff_delay, ratelimit.py), défauts 30 s de base et 3600 s de
plafond. Le full jitter évite que plusieurs instances Home Assistant du même
établissement se resynchronisent sur le même créneau après une panne. L'exposant
est écrêté avant le décalage à _MAX_BACKOFF_EXPONENT = 40, parce que
calculer 2**1024 d'abord lève OverflowError — ce qui transformait un repli
profond en plantage.
retry_delay() (ratelimit.py) existe séparément, et sa raison est
subtile : backoff_delay() vaut zéro tant qu'il n'y a pas eu d'échecs
consécutifs, ce qui est exactement le cas d'une connexion refusée — mauvais
identifiants, code réclamé, bootstrap illisible laissent tous ce compteur à
zéro. Reporter de zéro seconde rend le palier dû au tick suivant, et dix
paliers tentent alors dix connexions par tick : le geste de connexions
échouées répétées qui est la seule sanction contre laquelle on ne peut pas
ruser.
Symétriquement, note_success() remet le compteur d'échecs entièrement à
zéro, pas progressivement : un serveur qui répond une fois est un serveur qui
fonctionne. Mais il ne lève pas le drapeau throttled de sa propre autorité —
_maybe_clear_throttled (ratelimit.py) vérifie qu'aucun maintien
n'est actif, que la fraction du plafond est sous le plus bas des seuils de
sacrifice et que le plafond de connexions n'est pas atteint. L'effacer
inconditionnellement à chaque succès rendait le plafond journalier silencieux :
un seul appel HIGH réussi remettait le drapeau à zéro pendant que les paliers
LOW et NORMAL étaient encore abandonnés, et binary_sensor correspondant
repassait à off tous les quarts d'heure.
3.7 L'état qui doit survivre à l'objet¶
PronoteAccount construit son propre RateLimiter dans __init__. Home
Assistant répond à un ConfigEntryNotReady par un retry avec un repli plafonné
à quatre-vingts secondes. Chaque tentative construisait donc un limiteur qui
n'avait jamais entendu parler du maintien d'une heure que la tentative
précédente venait d'ouvrir : un établissement en panne un week-end se voyait
demander une connexion fraîche toutes les quatre-vingts secondes pendant
soixante-et-une heures, contre un plafond configuré de vingt-quatre par jour,
pendant que calls_today rapportait cinq — parce que chaque rapporteur était
un objet différent.
export_state / import_state (ratelimit.py) et le magasin
login_guard.limiter_state_store (login_guard.py) sont la réponse.
Trois choix y sont explicites :
- les valeurs monotones voyagent telles quelles, ce qui est correct parce que
le magasin vit dans
hass.dataet meurt avec le processus ; - l'horloge murale ne voyage que sous forme de date, pour qu'importer l'état d'hier ne puisse pas ressusciter les compteurs d'hier ;
- la moitié punitive — connexions échouées, échecs consécutifs, maintien en cours — traverse la frontière du jour. Un maintien est une promesse de ne pas marteler un serveur qui vient de refuser, et minuit n'est pas une preuve que quoi que ce soit a changé.
import_state est délibérément tolérant et ne peut rien lever : une clé absente
garde le défaut neuf, un état malformé est ignoré. Le pire cas d'ignorer un
transfert est une connexion de plus ; le pire cas d'une exception serait une
entrée qui ne démarre pas.
Le second occupant de login_guard.py est le garde du flow de
configuration (login_guard.py) : un limiteur par instance Home
Assistant, et non par entrée, parce que ce qui est protégé est l'adresse.
La conséquence est divulguée plutôt que masquée : une connexion de flow et une
connexion de setup sont comptées par deux compteurs distincts, donc le
garde-fou borne chacun séparément à max_failed_logins_per_hour au lieu de
borner leur somme.
3.8 Les réglages, et leurs bornes¶
Tous validés dans OPTION_RANGES (const.py) et relus par
options.build_rate_limit_config.
| Option | Défaut | Bornes |
|---|---|---|
min_request_interval |
1,0 s | 0,2 – 10 |
max_requests_per_hour |
240 | 30 – 2000 |
burst_size |
20 | 5 – 100 |
max_requests_per_day |
2000 | 100 – 20000 |
max_wait |
60 s | 5 – 300 |
max_logins_per_day |
24 | 5 – 500 |
max_failed_logins_per_hour |
3 | 1 – 10 |
credentials_hold |
3600 s | 300 – 86400 |
bootstrap_hold |
3600 s | 600 – 86400 |
backoff_base |
30 s | 5 – 300 |
backoff_max |
3600 s | 60 – 21600 |
quiet_hours_enabled |
vrai | — |
quiet_start / quiet_end |
22:00 / 06:00 | — |
max_wait est la frontière entre « attendre » et « reporter » : au-delà, le
palier est différé et conserve son instantané, plutôt que d'immobiliser la
boucle.
4. L'ordonnanceur¶
custom_components/pronote_ng/scheduler.py répond à une seule question : que
faut-il collecter maintenant, et dans quel ordre ? Comme le limiteur, il ne
connaît ni PRONOTE ni Home Assistant et prend une horloge injectable.
4.1 Un seul battement, et des échéances¶
Des minuteurs indépendants finissent par coïncider et produisent des salves.
Il y a donc exactement un battement de cœur — le master tick, armé par
async_track_time_interval dans PronoteAccount.async_setup
(account.py), toutes les cinq minutes par défaut, réglable de 1 à 60.
Il demande à l'ordonnanceur quels paliers sont dus, et le batch les exécute
dans une seule session, espacés par le limiteur.
Le point important est que chaque palier porte une échéance et non un minuteur. Trois propriétés en découlent, qu'aucun jeu de minuteurs ne donne :
- La granularité du battement ne fixe pas le budget. Le budget est fixé
par les échéances. Augmenter la cadence du battement ne coûte donc rien —
ce qui est dit explicitement dans
next_due_in(scheduler.py) — alors qu'avec des minuteurs, la fréquence du déclencheur est la fréquence des requêtes. - Un palier différé n'est jamais abandonné.
defer(scheduler.py) déplace sonnot_beforeet conserve son instantané précédent, donc ses entités gardent leur valeur. Et le report est borné par l'intervalle du palier lui-même : un limiteur qui annonce « réessayez à minuit » ne doit pas faire taire un palier de quinze minutes pendant onze heures si le budget se libère plus tôt. - Réduire un intervalle ne déclenche pas de collecte immédiate. L'échéance
suivante est recalculée depuis la dernière collecte, pas depuis le
moment où l'option a changé (
reconfigure,scheduler.py). Le docstring est honnête sur la portée de cette garantie : elle est plus faible que « raccourcir un intervalle ne déclenche jamais un batch immédiat », qui est simplement faux — un palier collecté il y a quarante minutes sur un intervalle de soixante est dû dès que l'intervalle passe à quinze, et il doit l'être. Ce que la préservation achète, c'est que le changement soit mesuré depuis l'histoire réelle et non depuis la sauvegarde.
Un second verrou complète le dispositif côté compte : _tick_lock
(account.py). Si le batch précédent tourne encore, le tick est
sauté, pas mis en file. C'est correct parce que les échéances n'ont pas
bougé — le tick suivant reprendra les mêmes paliers — alors que mettre en file
laisserait un serveur lent construire un arriéré qui arriverait ensuite sous
la forme exacte de la salve que le battement unique existe pour empêcher.
4.2 Les dix paliers et leur coût¶
Tier (const.py) énumère onze valeurs : dix paliers de données, plus
SESSION qui n'en est pas un. SESSION porte les faits qui arrivent avec la
connexion elle-même — périodes, classe, établissement, lus dans func_options
et parametres_utilisateur, déjà en mémoire une fois authentifié — et coûte
donc zéro appel.
flowchart LR
subgraph CRIT["CRITICAL · jamais sacrifie"]
SESSION["session · 0 req · gratuit avec la connexion"]
end
subgraph HIGH["HIGH · sacrifie a 100 pct du plafond"]
TT["timetable · 15 min · 2 req"]
HW["homework · 30 min · 1 req"]
end
subgraph NORM["NORMAL · sacrifie a 80 pct"]
NEWS["news · 60 min · 1 req"]
MARKS["marks · 180 min · 2 req"]
ATT["attendance · 360 min · 1 req"]
end
subgraph LOW["LOW · sacrifie a 60 pct"]
DISC["discussions · 60 min · 2 req en moyenne, jusqu a 4"]
EVAL["evaluations · 720 min · 1 req"]
MENUS["menus · 1440 min · 1.14 req"]
STATIC["static · 1440 min · 1 req"]
HIST["history · 1440 min · 4 req par periode close"]
end
TICK["master tick · 5 min par defaut"]
TICK --> CRIT
TICK --> HIGH
TICK --> NORM
TICK --> LOW
Le détail des coûts, tel que le code les déclare et les dépense :
| Palier | Intervalle | Priorité | Coût déclaré | Coût réel possible | Requête protocolaire |
|---|---|---|---|---|---|
session |
— | CRITICAL |
0 | 0 | aucune, lu en mémoire |
timetable |
15 min | HIGH |
2 | 2, ou moins en bordure d'année scolaire | PageEmploiDuTemps 16, une par semaine |
homework |
30 min | HIGH |
1 | 1 | PageCahierDeTexte 88, l'année entière |
news |
60 min | NORMAL |
1 | 1 | PageActualites 8 |
discussions |
60 min | LOW |
1 | 1 à 4 | ListeMessagerie 131, plus ListeMessages par fil développé |
marks |
180 min | NORMAL |
2 | 2 | DernieresNotes 198, plus PageBulletins 13 |
attendance |
360 min | NORMAL |
1 | 1 | PagePresence 19 |
evaluations |
720 min | LOW |
1 | 1 | DernieresEvaluations 201 |
menus |
1440 min | LOW |
1 | 1 ou 2 | PageMenus 10, par semaine |
static |
1440 min | LOW |
1 | 1 | équipe pédagogique |
history |
1440 min | LOW |
4 par période close | idem | les quatre onglets ci-dessus, par période |
Plusieurs de ces chiffres sont le résultat d'un comptage sur le code amont, et non d'une déduction :
timetableà 2.Client.lessons()bouclefor week in range(...)et postePageEmploiDuTempsune fois par semaine, puis filtre côté client. Demander « aujourd'hui et demain » coûte donc exactement la même requête que demander la semaine entière — ce qui a fait replier le palier « semaine » dans celui-ci. Le palier a longtemps coûté 1,14 : une semaine, plus la suivante le seul jour où demain la franchissait, c'est-à-dire le dimanche. Le lundi restait donc inconnu pendant tout le week-end. L'horizon est maintenant les deux semaines à chaque collecte, et le coût déclaré est l'ordre de grandeur haut : la passerelle écarte une semaine hors année scolaire (gateway.PronoteGateway._timetable_weeks) et dépense alors moins que déclaré, ce que le limiteur ne rembourse pas — délibérément.marksà 2, avec quatre jeux de données pour une requête. En amont,Period.grades,.averages,.overall_averageet.class_overall_averagere-postent chacunDernieresNotesavec le même corps. La passerelle poste une fois et décode les quatre (gateway.py). Le second appel est le bulletin de la période courante.attendanceà 1, avec trois jeux de données.PagePresencerenvoie absences, retards et punitions dans une seule listelisteAbsences, discriminées par le champGvalant 13, 14 ou 41. En amont, les trois propriétés re-postent la même requête et lisent la même liste.discussionsà 2 en moyenne. La liste des fils est une requête. Les corps de messages ne le sont pas : lireDiscussion.messagesposteListeMessagesà chaque lecture, donc développer tous les fils coûterait une requête chacun — dix fils sur un palier horaire font environ 176 requêtes par jour, ce qui double à peu près le budget entier. Seuls les fils dont le compteur de non-lus a augmenté sont développés, plafonnés àMAX_DISCUSSION_EXPANSIONS = 3par cycle (gateway.py).historyà 4 par période close.DernieresNotes198,PageBulletins,PagePresence19 etDernieresEvaluations201 — soit huit requêtes pour deux périodes closes, ce queREQUESTS_PER_BATCHreflète (options.py). Une période close ne peut pas changer, donc la relire toutes les trois heures dépenserait des requêtes sur un résultat constant : c'est pourquoi ce palier est quotidien, et pourquoi il n'émet aucun événement.
Un mécanisme fin mérite d'être signalé sur discussions : un fil que la
passerelle n'a pas développé conserve son ancien compteur de non-lus
(tiers.py). Enregistrer le nouveau compteur pour tous les fils
signifiait qu'un fil poussé au-delà du plafond de développement ne redevenait
jamais éligible — son compteur ne remontait plus jamais depuis la valeur
stockée — et tous les messages qu'il recevait ensuite arrivaient avec
author: null et created: null.
4.3 L'ordre de service, et le bouton¶
due() (scheduler.py) rend les paliers dus par priorité
décroissante, puis par retard décroissant, puis par nom. L'ordre compte
quand le budget se resserre : le limiteur sacrifie par priorité, donc servir
dans l'ordre de priorité garantit que ceux qui tombent sont ceux que la table
de sacrifice a désignés.
Le bouton de rafraîchissement et le service refresh n'appellent rien : ils
élèvent la priorité des paliers demandés pour le prochain battement
(request, scheduler.py), puis réveillent le tick
(account.async_request_tick, account.py). Un bouton pressé dix fois
en une minute coûte un batch, pas dix, parce que la deuxième pression
trouve les paliers déjà demandés et le tick déjà en cours.
Le détail du calcul de l'élévation vaut la peine d'être lu
(scheduler.py) : un boost remonte le palier d'un rang, plancher
juste au-dessous de CRITICAL. Lui donner le rang −1 le faisait surclasser
le palier session, donc un bouton de rafraîchissement sur history — six
requêtes, priorité LOW — était servi le premier, vidait le seau et
l'allocation max_wait, et timetable, de priorité HIGH, était ensuite
différé : l'inverse exact de l'ordre de sacrifice. Le bouton demande un
laissez-passer de priorité, pas la suprématie.
Aucun bouton n'offre de forcer une reconnexion (button.py) :
c'est le seul geste capable de faire suspendre une adresse, donc il n'est pas
offert.
4.4 Survivre à un rechargement¶
Une sauvegarde d'options recharge l'entrée, et un rechargement construit un
ordonnanceur neuf dont chaque last_collected vaut None — ce qui rend les
dix paliers immédiatement dus. export_state / import_state
(scheduler.py) transportent les instants de dernière collecte à
travers le rechargement, via hass.data sous une clé propre
(account._saved_schedule, account.py) : c'est de l'état d'exécution
qui n'a rien à faire sur disque, et il n'a de sens que dans un seul processus
puisque les valeurs sont monotones.
import_state écarte une valeur venue du futur : cela signifie que
l'origine monotone a bougé — un redémarrage plutôt qu'un rechargement — donc
la valeur ne doit pas être crue.
Le docstring de export_state note l'histoire de cette correction :
préserver last_collected dans reconfigure était la bonne idée visant la
mauvaise couture, parce que rien n'appelait jamais reconfigure — Home
Assistant recharge, il ne reconfigure pas.
4.5 La péremption¶
is_stale (scheduler.py) est la frontière du régime de fraîcheur :
en dessous de stale_after fois l'intervalle du palier, une entité garde sa
valeur et signale son âge ; au-delà — ou sans donnée du tout — elle devient
unavailable. Défaut : stale_after = 6, réglable de 2 à 48.
Le paramètre excused retranche le temps que le compte n'a délibérément pas
collecté, en pratique les heures creuses. Ce n'est pas une commodité : voir
§3.6. Et PronoteAccount.is_stale (account.py) ajoute une exception
— le palier session n'est jamais périmé, parce qu'il porte ce que la
connexion a fourni (l'enfant, la classe, la liste des périodes), qui change une
fois par année scolaire et non sur un intervalle.
5. La session¶
Trois modules se partagent le cycle de vie de la session :
session.py (propriété du client et politique de reconnexion), account.py
(orchestration) et login_guard.py (l'état punitif qui doit survivre aux
objets).
5.1 Sérialisation : un fil, deux verrous¶
Le protocole numérote ses requêtes avec un compteur chiffré :
_Communication.post chiffre request_number, l'écrit aussi dans le chemin de
l'URL, et avance de deux. Deux appels concurrents sur une même session
désynchronisent ce compteur et cassent la session. Et
hass.async_async_add_executor_job utilise un pool de fils partagé, donc deux
travaux lancés ensemble sont concurrents.
D'où : un ThreadPoolExecutor(max_workers=1) et un asyncio.Lock par
entrée de configuration (SerialExecutor, session.py).
Le verrou couvre plus que l'appel, pour une raison précise. set_child()
mute le client : il réécrit
parametres_utilisateur["dataSec"]["data"]["ressource"], qui est le même
emplacement que Client.lessons() lit pour construire son corps de requête
(hardened_client.py). L'unité de travail atomique est donc le couple
(enfant, palier) et jamais le palier seul — sinon un compte parent publie
l'emploi du temps d'un enfant sous les entités de l'autre, silencieusement.
C'est ce que _bind (session.py) matérialise : sélection de
l'enfant, appel de l'onglet et construction du DTO dans une seule fermeture
indivisible.
Un second verrou, _gate (session.py), est tenu à travers
« choisir un client, attendre le budget, appeler, réessayer » comme un tout.
Sans lui, un service invoqué pendant un tick capturait un client, attendait son
espacement, et se réveillait pour poster sur un client qu'un retry concurrent
sur Erreur.G = 10 avait déjà fermé — produisant un échantillon d'expiration
fallacieux, une troisième connexion et un palier en échec à partir d'un seul
chevauchement.
L'arrêt de l'exécuteur n'appelle jamais shutdown(wait=True) depuis la
boucle d'événements. Un fil peut être au milieu d'une requête au moment du
déchargement, et le joindre gèlerait le rechargement de Home Assistant.
Enfin, SerialExecutor.run impose une échéance en plus du timeout HTTP :
read_timeout × CALL_TIMEOUT_FACTOR, soit 180 s par défaut. Elle n'est pas
redondante : elle borne le seul cas que le régime de péremption déguiserait,
un appel qui ne revient jamais, dont le symptôme est des entités qui
vieillissent en silence. Le docstring est précis sur ce qui se passe ensuite,
parce que la supposition évidente est fausse : la fonction abandonnée continue
de tourner dans le worker, mais le pool n'a qu'un fil, donc la soumission
suivante ne tourne pas à côté d'elle — elle attend dans la file du pool et
y consomme sa propre échéance. Rien ne peut donc désynchroniser le compteur
chiffré par ce chemin. Le coût réel est un blocage, ce qui est pourquoi
l'appelant marque le compte comme défaillant au lieu de réessayer.
5.2 La stratégie de session¶
Deux modes sont offerts en option d'entrée
(SessionStrategy, const.py) :
LAZY— le défaut et la valeur recommandée : garder la session, et ne se reconnecter que lorsque le serveur dit qu'elle a expiré. Quels codes le disent estSESSION_EXPIRED_CODESet non un nombre unique : voir § 5.4.PER_BATCH— une connexion par batch. C'est ce que la spécification v1 prescrivait, conservé comme échappatoire explicite pour un établissement dont la politique serait inhabituelle et qu'on préfère épingler plutôt que découvrir.
La défendabilité de LAZY repose sur un argument de dominance, et il faut
l'énoncer précisément parce qu'une version approximative est fausse.
LAZY n'est pas gratuit dans le mauvais cas. Si le délai d'inactivité de
l'établissement est plus court que l'intervalle du palier le plus rapide,
chaque batch dépense une requête perdue à découvrir que la session est morte
avant de se reconnecter — soit une requête par batch de plus que
PER_BATCH, et non l'égalité.
Ce qui borne ce mauvais cas est la dégradation : trois expirations
consécutives (SHORT_TIMEOUT_EVIDENCE = 3, session.py) font conclure
au module que le délai est court, et il se reconnecte alors avec anticipation.
La perte est donc de trois requêtes, une fois, puis d'une par jour venant de la
sonde qui garde la conclusion falsifiable
(PROBE_INTERVAL_SECONDS = 86400). Sous cette borne, LAZY n'est jamais
significativement pire pour aucune valeur du paramètre inconnu, et il est
spectaculairement meilleur pour la valeur commune.
Trois, et pas un : une expiration peut être un redémarrage de serveur, deux peuvent être une coïncidence.
La dégradation ne fonctionne que si un succès ne la défait pas à la légère.
Lever la conclusion exige une contre-preuve — une session qui a survécu à
un intervalle d'inactivité au moins aussi long que le plus court de ceux qui
avaient tué une session — et non simplement le fait que l'appel suivant une
connexion fraîche réussisse, ce qui est toujours le cas
(_note_survival, session.py). Sans cela, la conclusion ne survivait
pas à un seul appel et la stratégie oscillait indéfiniment, payant une requête
supplémentaire à chaque batch.
La mesure elle-même est un minimum, pas une moyenne
(SessionLifetime.observed_minutes, models.py) : une expiration
courte prouve que le délai peut être aussi court, tandis qu'une longue prouve
seulement qu'il n'a pas été éprouvé. Et les intervalles inférieurs à
MIN_LIFETIME_SAMPLE_SECONDS = 60 sont comptés comme expirations mais pas
enregistrés comme échantillons de durée de vie : le premier appel après une
connexion peut échouer en Erreur.G = 10 pour des raisons étrangères à un
délai d'inactivité, et un tel échantillon épinglerait la durée mesurée près de
zéro pour le reste de l'exécution.
Un piège de comptabilité mérite d'être mentionné, parce qu'il a coûté cher :
PER_BATCH signifie une connexion par batch. Comparer la stratégie seule
rendait _reconnect_required() vrai à chaque run(), et run() est appelé une
fois par couple (enfant, palier) — donc un tick d'un compte à deux enfants
avec trois périodes closes demandait 36 connexions complètes contre un
plafond de 24, et l'intégration mourait avant la fin de son premier batch. D'où
_session_batch_id (session.py).
5.3 Le cycle de vie, en machine à états¶
stateDiagram-v2
[*] --> Setup
state Setup {
[*] --> Probe
Probe --> Shape : connexion reussie
Shape --> [*] : enfants, periodes, periode courante
}
Setup --> Connectee : runtime_data assigne, plateformes forwardees
Setup --> Reauth : InvalidCredentials ou MfaRequired
Setup --> NotReady : Bootstrap, Unreadable, LoginRefused, PronoteAPIError, OSError
state Connectee {
[*] --> Ouverte
Ouverte --> Ouverte : appel reussi, note_success
Ouverte --> Expiree : Erreur G egal 10
Expiree --> Ouverte : reconnexion immediate et rejeu de l appel
Ouverte --> Degradee : trois expirations consecutives
Degradee --> Ouverte : contre preuve, gap superieur au minimum mesure
Degradee --> Degradee : sonde quotidienne
}
Connectee --> Penalite : maintien ouvert par le limiteur
state Penalite {
[*] --> Backoff
Backoff : erreur de transport ou Erreur G egal 25
MfaHold : PIN reclame, aucune tentative ne peut reussir
BootstrapHold : page de bootstrap sans bloc Start ou reponse indecodable
CredentialsHold : trois connexions echouees dans l heure
}
Penalite --> Connectee : maintien expire, ou note_success sur un backoff
Penalite --> Reauth : MfaHold ou CredentialsHold vus par l utilisateur
Reauth --> Setup : reset_after_reauth puis rechargement de l entree
NotReady --> Setup : retry Home Assistant, repli plafonne a 80 s
Connectee --> Dechargee : async_unload_entry
Dechargee : etat du limiteur et de l ordonnanceur exportes vers hass.data
Dechargee --> [*]
Les transitions de Penalite vers Connectee ne sont pas symétriques : un
Backoff est levé par le premier succès (note_success), tandis qu'un
MfaHold ou un CredentialsHold attend soit l'expiration de sa durée, soit un
geste humain. reset_after_reauth() (ratelimit.py) efface les
compteurs d'échecs, les échecs consécutifs et le maintien courant, et c'est la
seule sortie du maintien MFA — ce qui est correct, puisque ce maintien
existe précisément parce qu'une personne doit agir.
clear_login_penalties (login_guard.py) nettoie les trois
détenteurs de cet état : le garde du flow, l'état sauvegardé pour l'entrée en
réparation, et le compte vivant si l'entrée se trouve chargée.
5.4 La gestion des erreurs protocolaires¶
_handle_protocol_error (session.py) traite trois familles de codes
Erreur.G et rien d'autre.
G = 8 ou G = 10, session expirée (SESSION_EXPIRED_CODES). La seule
erreur qui vaille la peine d'être réessayée, et la seule raison pour laquelle
la stratégie paresseuse est mesurable. Le module enregistre l'échantillon de
durée de vie, ferme le client, en construit un neuf et rejoue l'appel — à
travers le limiteur, donc en payant le coût.
Les deux codes, et non le seul que documente la spécification : un
établissement réel a répondu 8, avec pour Erreur.Titre « La page a
expiré ! », c'est-à-dire le même énoncé en d'autres mots. pronotepy n'a pas
de libellé pour 8 et le rend en « Unknown error from pronote: 8 » — une
chaîne, pas une classification ; le code, lui, est bien porté par l'exception.
N'avoir reconnu que 10 a coûté sept heures de données figées, et la forme de
cette panne est ce qui justifie le reste de ce paragraphe : la session était
morte côté serveur alors que is_open répondait encore vrai, aucune branche
n'atteignait donc _reopen, chaque tentative échouait, chaque échec creusait
le repli exponentiel, et aucun appel ne pouvait réussir pour le remettre à
zéro. Ce n'est pas une pause qui se résorbe : c'est un puits, dont seul un
redémarrage sort l'instance.
Le rejeu est borné à un (_replay_on_a_fresh_session), et cette borne est
ce qui rend l'élargissement de l'ensemble prudent plutôt que téméraire. Chaque
code de l'ensemble est une affirmation sur ce que le serveur voulait dire, et
une affirmation peut être fausse ailleurs. Réessayé sans plafond, un code mal
classé ne coûte pas un capteur périmé : il coûte une connexion par palier et
par tick — dix paliers contre un plafond de vingt-quatre épuisent la journée
en trois ticks puis continuent — c'est-à-dire exactement le geste des
connexions échouées répétées, la seule sanction que l'annexe B § 1 déclare
incontournable. Un second refus sur une session ouverte à l'instant part donc
au repli.
Une session sans succès depuis une heure est abandonnée
(PRESUMED_DEAD_AFTER_SECONDS). C'est la forme générale du correctif
précédent, pour le prochain code que personne n'a encore vu. is_open répond
« est-ce que je détiens un client », un fait sur ce processus qui n'énonce rien
sur le serveur. Un code inconnu, un serveur qui cesse de répondre sans le dire,
un établissement qui invalide les sessions selon son propre calendrier : tout
cela est indistinguable d'ici, et tout cela est rattrapé en refusant de faire
confiance à une session qui n'a pas fonctionné depuis une heure. Une heure est
dérivée et non devinée — le plafond de connexions par défaut est de 24 par
jour, soit une par heure, donc un seuil au-dessus de 3600 s ne peut jamais
être la raison pour laquelle ce plafond est atteint. Le contrôle est
paresseux, au prochain travail à faire : une nuit d'heures calmes provoque une
connexion supplémentaire à 06:00, pas une par heure.
G = 22, objet d'une session précédente. Reconnue par type et non par
code. _Communication.post lève ExpiredObject sur sa propre branche,
avant celle qui attache pronote_error_code, donc cette erreur arrive sans
code du tout. Ne tester que le code rendait toute cette branche morte, et un
G = 22 tombait dans le repli générique — immobilisant un serveur parfaitement
sain parce que nous avions laissé fuir un objet à travers la frontière de
session. Ce cas est journalisé comme un bug de cette intégration, et il est
relevé : la frontière DTO l'exclut par construction, donc son apparition est un
défaut à signaler et jamais un incident réseau.
G = 25, trop de requêtes d'autorisation. Va au repli et ne provoque
aucune reconnexion. C'est précisément ce que le client durci existe pour
empêcher : ClientBase.post en amont répondrait à une sanction sur les requêtes
d'autorisation… par une requête d'autorisation.
Tout autre PronoteAPIError déclenche note_failure() et est relevé tel quel.
Les autres familles d'erreurs sont classées à la connexion (_login,
session.py), et le classement importe plus qu'il n'y paraît :
| Exception amont | Issue LoginOutcome |
Conséquence |
|---|---|---|
MFAError |
MFA_REQUIRED |
maintien, puis flow de ré-authentification demandant le PIN |
CryptoError |
BAD_CREDENTIALS |
compte contre le garde-fou IP |
logged_in is False |
BAD_CREDENTIALS |
idem — clé cle absente de la réponse |
BootstrapUnavailable |
BOOTSTRAP |
maintien d'une heure, repair sans cause nommée |
DataError, ValueError, KeyError, TypeError, IndexError |
UNDECODABLE |
maintien de type bootstrap ; ne touche jamais le garde-fou IP |
PronoteAPIError |
TRANSPORT |
relevée, devient ConfigEntryNotReady |
OSError (dont requests.Timeout) |
TRANSPORT |
repli exponentiel |
ChildNotFound |
— | IntegrationFault, jamais imputé au repli du serveur |
Deux pièges sont documentés dans le code et valent d'être répétés.
_TRANSPORT_ERRORS vaut (OSError,) et non (TimeoutError, ...) : aucune des
défaillances que requests lève n'est un PronoteAPIError, et — le piège —
aucune n'est un TimeoutError, parce que requests.Timeout hérite de
OSError. Donc except TimeoutError n'attrapait rien, et une école en
panne un week-end produisait un GET de bootstrap non compté toutes les cinq
minutes, sans aucun repli, pendant que calls_today rapportait zéro.
ChildNotFound est un PronoteAPIError en amont, donc sans traitement séparé
il ouvrait un maintien de repli sur un serveur parfaitement sain parce que
cette intégration avait demandé un identifiant d'enfant qui n'est plus sur le
compte.
5.5 La rotation des identifiants¶
En mode QR code, PRONOTE renvoie un jetonConnexionAppliMobile frais à
chaque Authentification et pronotepy écrase self.password avec lui. Ne
pas le re-persister signifie perdre l'accès au démarrage suivant.
_persist_rotated_credentials (session.py) met à jour la mémoire
puis appelle le rappel de persistance, et PronoteAccount._async_persist_credentials
(account.py) écrit dans l'entrée de configuration.
Un échec d'écriture est bruyant. L'avaler silencieusement laissait le serveur avec le nouveau jeton, la mémoire avec le nouveau jeton, et le stockage avec un jeton mort — avec pour seul symptôme, au redémarrage suivant, un QR code à rescanner.
C'est aussi une raison pour laquelle la stratégie paresseuse compte au-delà de l'arithmétique : à une connexion par jour, il y a une fenêtre par jour dans laquelle un arrêt brutal entre la rotation côté serveur et l'écriture locale laisse un jeton mort. À soixante-quatre connexions par jour, il y en avait soixante-quatre.
6. La passerelle¶
custom_components/pronote_ng/gateway.py est l'adaptateur : une fonction
publique par appel protocolaire, des DTO gelés en sortie. Avec
hardened_client.py, c'est le seul module à importer les types de données
pronotepy, donc le seul à relire lors d'un changement de version et le seul
point à doubler dans les tests.
6.1 Deux règles de décodage¶
Dédupliquer sans réimplémenter. La spécification v1 concluait qu'il fallait
décoder les réponses brutes à la main, ce qui contredisait son propre refus de
reprendre la dette de décodage amont — puis la reprenait. Or les classes de
données de pronotepy acceptent le dictionnaire nu : Average(json),
Absence(json), Delay(json), Evaluation(json), Report(data), et
Lesson(client, json) / Punishment(client, json) avec un client. La forme est
donc : un post() brut par onglet, les sous-listes confiées aux classes
amont, puis conversion en DTO gelés. On obtient la déduplication et les
correctifs amont.
Une exception : Grade. Deux raisons indépendantes, toutes deux
consignées dans le docstring d'ouverture (gateway.py). D'abord,
Grade.__init__ résout self.period à travers
Util.get(Period.instances, id=p)[0], et Period.instances est un attribut de
classe jamais vidé — le seul lecteur de ce registre dans tout
dataClasses.py. L'utiliser nous lierait à un global qu'on ne peut ni garder
(il fuit un client mort par période) ni vider ([0] sur une liste vide lève
IndexError, enveloppé en ParsingError, faisant échouer tout le batch de
notes). Ensuite, Util.grade_parse a déjà remplacé |1…|8 par
"Absent", "Dispense"… donc Grade.grade est déjà lossy, et décoder
note.V nous-mêmes est la seule route vers la sentinelle brute dont
GradeStatus a besoin. C'est une exception bornée, pas une politique — et
Average est la seconde, forcée par un conflit frontal avec la tolérance par
entrée.
Ce couplage a une conséquence non évidente : la confinement du registre de
périodes dans hardened_client._ConfinedRegistry est sûr précisément parce
que la passerelle décode les notes elle-même et ne construit donc jamais de
pronotepy.Grade. Si un changement futur se met à utiliser pronotepy.Grade,
il faudra revisiter le confinement en même temps.
6.2 La tolérance par entrée¶
C'est la propriété la plus visible de ce module en exploitation : une entrée illisible coûte cette entrée, jamais le palier entier.
Elle est nécessaire parce que pronotepy construit ses listes dans des
compréhensions avec des résolveurs stricts : un seul élément servi sans un
champ optionnel levait ParsingError depuis l'intérieur de la compréhension et
faisait échouer tout le palier — emportant avec lui la liste de devoirs, le
calendrier et les deux capteurs correspondants.
Le mécanisme est un tuple partagé, _ENTRY_ERRORS (gateway.py), et
chaque membre y est pour une raison nommée :
| Exception | Pourquoi |
|---|---|
ParsingError |
l'enveloppe amont, levée par Object._resolver quand un champ strict manque |
DataError |
son parent, et pas un PronoteAPIError, donc jamais vu par la gestion d'erreurs protocolaires |
ValueError |
strptime sur une date écrite à la façon de l'établissement, float() sur ce qui n'est pas un nombre |
KeyError |
une lambda de conversion indexant une clé qui a bougé ; le résolveur amont ne garde que le chemin, jamais le corps du convertisseur |
IndexError |
Util.grade_parse fait grade_translate[int(s[1]) - 1] sur une table de huit, donc une future sentinelle \|9 indexe hors bornes |
TypeError |
un segment de chemin valant null plutôt qu'absent, que la garde KeyError amont ne couvre pas |
ZeroDivisionError |
Lesson.__init__ calcule place % (len(end_times) - 1), donc un établissement publiant une seule entrée ListeHeuresFin divise par zéro |
Le tuple est partagé parce que IndexError était présent sur deux décodeurs et
absent de cinq.
Sept décodeurs l'utilisent,
et l'un d'eux va plus loin. Pour l'emploi du temps, quand pronotepy refuse
l'entrée, le JSON brut est décodé directement au lieu que le créneau soit
abandonné (_lesson_raw, gateway.py). La cause la plus fréquente est
la plus fâcheuse : Lesson.__init__ résout ListeContenus sans
strict=False, et un créneau sans contenu publié — ce qui est précisément la
façon dont une annulation arrive souvent — n'a pas de ListeContenus du
tout. Abandonner ces entrées signifiait que l'événement pour lequel cette
intégration existe, lesson_canceled, ne pouvait pas se déclencher pour elles.
6.3 Absent n'est pas vide¶
La contrepartie exacte de la tolérance par entrée est _required_list
(gateway.py), qui distingue « la clé est absente » de « la liste est
vide » et refuse la première.
C'est le pire mode de défaillance possible d'une passerelle, et le raisonnement
mérite d'être cité : si PRONOTE renommait ListeCours, alors
_get(...) or [] renvoyait un emploi du temps vide, le palier rapportait un
succès, l'instantané était remplacé, le binary_sensor « jour de classe »
passait à off et l'automatisation de réveil cessait simplement de se
déclencher — sans rien au journal, et sans que la péremption ne se déclenche
jamais, puisque la collecte avait réussi.
Lever à la place fait échouer le palier, ce qui conserve l'instantané précédent, le marque comme daté et finit par ouvrir une réparation. « Je sais, mais c'est vieux » est une réponse récupérable ; « il n'y a pas de cours cette semaine » parce qu'un champ a été renommé ne l'est pas.
Les collections déclarées obligatoires sont celles qu'un parent lit pour agir :
ListeCours (emploi du temps), ListeTravauxAFaire (devoirs), listeDevoirs
et listeServices (notes et moyennes), listeAbsences (présence). Le critère
est explicite : « pas d'absences » est la réponse sur laquelle un parent agit,
et elle doit signifier que l'école l'a dite.
6.4 Le client durci¶
hardened_client.py n'est pas une option mais un prérequis, pour quatre
comportements amont vérifiés contre pronotepy 2.15.7 et énumérés en tête de
fichier (hardened_client.py) :
- Il se ré-authentifie derrière le dos de l'appelant.
ClientBase.postattrape tous lesPronoteAPIErrorsaufExpiredObject, appelleself.refresh()—session.close(), un GET sur la page HTML,FonctionParametres,Identification,Authentification,ParametresUtilisateur— puis rejoue l'appel. Un appel budgété peut donc coûter six requêtes réseau, ce qui rend fausse l'affirmation « le limiteur est la seule voie vers la passerelle ». Pire,Erreur.G = 25est unPronoteAPIErrorordinaire, donc il provoque exactement la classe de requêtes queG = 25punit. ParentClient.postduplique ce gestionnaire sans la garde de récursion.ClientBase.postse protège (if self._refreshing: raise e),ParentClient.postnon. Un serveur qui erre pendant le_login()derefresh()produit une récursion non bornée de connexions complètes — sur un compte parent, c'est-à-dire le cas d'usage central de ce projet.refresh()perd silencieusement la sélection d'enfant. Il appelle_login(), qui réécrit la ressource avec celle du parent, etParentClientne reconstruit_selected_childque dans__init__. Après un rafraîchissement automatique,post()estampille encore le bonmembremaislessons()envoie la ressource du parent dans le corps.- Il n'y a aucun timeout HTTP.
grep -rn timeoutsur le paquet ne renvoie rien : ni le GET de bootstrap ni les POST ne passenttimeout=. Un serveur qui accepte la connexion et ne répond jamais immobilise le fil unique indéfiniment, leasyncio.Lockn'est jamais relâché, et le régime de péremption déguise un interblocage permanent en réseau lent.
HardenedClient (hardened_client.py) répond point par point :
post() poste une fois et ne se ré-authentifie jamais, estampillant
lui-même la signature membre de l'enfant sélectionné ; refresh() lève
NotImplementedError — le gestionnaire de session jette le client et en
construit un neuf, ce qui redérive la liste d'enfants par construction ;
keep_alive() lève également, parce que le maintien actif ne serait rentable
qu'en dessous d'un intervalle de 110 secondes et qu'aucun palier n'y va ; et le
transport est remplacé par _TimeoutSession, une requests.Session qui injecte
un défaut (connect_timeout, read_timeout) dans request().
Une classe unique gère l'élève et le parent, plutôt que d'hériter de
ParentClient — c'est la sous-classe qui porte le bug de récursion et celui de
l'enfant perdu, et son refresh() n'est pas récupérable.
Deux détails d'hygiène complètent le tableau. BootstrapUnavailable
(hardened_client.py) ne dit rien de la cause : pronotepy décide
qu'une adresse est bannie avec if "IP" in html, soit deux majuscules
n'importe où dans la page — donc « Espace IP » dans un pied de page d'école,
une classe CSS, ou les lettres dans SKIP, ZIP ou EQUIPE déclenchent la
détection. Construire un maintien d'une heure et un message « explicite » sur
ce signal aurait annoncé à des parents que leur adresse personnelle était
bannie à cause d'un pied de page. Un état que l'intégration ne peut pas établir
de façon fiable ne doit pas exister dans son vocabulaire : c'est pourquoi
LimiterState ne contient pas d'ip_suspended (const.py) et pourquoi
la réparation énumère les causes possibles sans en choisir aucune.
Et _ConfinedRegistry / _period_registry_confined
(hardened_client.py) confine dataClasses.Period.instances, cet
attribut de classe global au processus qui n'est jamais vidé. Sans lui, chaque
connexion fuit : chaque Period reste dans l'ensemble pour toujours et
maintient vivant son _client, donc un client mort, une requests.Session
morte et son pool de sockets. Le retrait se fait par différence, jamais par
vidage, et sous un verrou de processus qui ne garde que l'arithmétique
d'ensembles — quelques microsecondes. Le tenir à travers tout
ClientBase.__init__, c'est-à-dire à travers une connexion, transformait chaque
release_client en attente bloquante sur l'aller-retour réseau d'une autre
entrée ; appelé depuis la boucle d'événements, cela figeait Home Assistant.
6.5 Les lectures à la demande¶
Trois fonctions de la passerelle sont appelées par des services et jamais
par un palier : ical_url, identity et timetable_pdf_url
(gateway.py). La raison est de sécurité, non de coût — voir §10.
profile_picture est un cas intermédiaire : lue paresseusement une fois par
rechargement par l'entité image, sous le verrou et après set_child,
parce que lire ClientInfo.profile_picture comme une propriété court-circuite
ClientBase.post et la signature membre — un compte parent afficherait donc
le visage du mauvais enfant.
Le cas identity est le plus instructif : la lecture passe par
client.communication.post avec une ressource explicite, et non par
client.post, qui estampille le titulaire du compte comme membre. Sur un
compte parent, membre renvoie la date de naissance, l'adresse e-mail, le
numéro de téléphone et le numéro INE du parent — que le service rendait
ensuite attribués à l'enfant. C'est une divulgation par mauvaise attribution
portant précisément sur les champs que §10 tient hors de la machine à états.
7. Le modèle de données¶
7.1 Des DTO gelés¶
Tous les objets de models.py sont des @dataclass(frozen=True, slots=True).
frozen pour qu'un instantané publié ne puisse pas être muté par un
consommateur ; slots parce que ces objets se comptent par centaines et que
l'empreinte compte.
Chaque datetime y est conscient du fuseau, et la conversion a lieu une
seule fois, dans la passerelle, avec le fuseau de l'établissement
(PronoteGateway._instant / _aware, gateway.py). Les dates PRONOTE
sont du texte local naïf sans offset, et deux des six formes de
Util.date_parse complètent la partie manquante avec aujourd'hui ; comparer
cela à l'horloge d'une machine réglée en UTC — le défaut d'un conteneur —
produit des décalages silencieux et donc des automatisations qui se déclenchent
à la mauvaise heure. _instant et _aware sont deux méthodes distinctes pour
que les appelants qui ne peuvent pas recevoir None n'aient pas à écrire une
branche inatteignable.
Deux DTO portent des champs qui demandent une justification, et l'ont.
Lesson.num est le champ P du protocole, et il porte la déduplication :
PageEmploiDuTemps renvoie plusieurs entrées pour le même créneau — l'original
et ses remplacements — et le docstring amont dit que pour une même heure de
cours, le plus grand num est celui affiché sur PRONOTE. La spécification v1
déclarait num sans dire à quoi il servait, ce qui équivalait à ne pas
l'utiliser.
Lesson.end_inferred existe parce que end n'est pas toujours transmis :
quand DateDuCoursFin est absent, pronotepy le calcule via Util.place2time,
dont le commentaire amont dit « might be wrong… works with demo ». Tout dépend
de end — réveil, « en cours », le calendrier, la fin des cours — donc le
drapeau permet à une entité de se taire plutôt que d'affirmer une heure
douteuse.
Grade porte une invariante vérifiée à l'exécution
(models.py) : value et status sont mutuellement exclusifs, et
__post_init__ lève si les deux sont présents. C'est une promesse que le
système de types ne peut pas faire et qu'aucun appelant ne vérifiait. La
passerelle est le seul producteur, donc cela ne peut se déclencher que sur un
bug de la passerelle — c'est-à-dire précisément quand il vaut la peine
d'échouer bruyamment, puisque le contrat de l'entité « dernière note » avec tous
les déclencheurs numeric_state en aval repose sur cette exclusivité.
Snapshot[T] (models.py) enveloppe les faits avec l'instant obtenu,
le palier, le coût réel et l'identifiant de l'élève. Le point porteur est dans
son docstring : une collecte en échec ne remplace pas l'instantané
précédent.
7.2 Les coordinateurs¶
Un coordinateur par palier (PronoteTierCoordinator, coordinator.py),
avec update_interval=None. Il n'y a pas de _async_update_data : ce
coordinateur n'a jamais le droit d'aller chercher quoi que ce soit lui-même. Un
coordinateur qui pourrait le faire serait une seconde voie vers le réseau, et
le limiteur en détient le monopole.
Les données sont indexées par élève, parce qu'une entrée de configuration est un compte, et qu'un compte parent porte plusieurs enfants qui partagent une session, un budget et une adresse IP.
Deux méthodes, et leur asymétrie est le mécanisme :
publish(student_id, snapshot)fusionne l'instantané d'un élève en laissant les autres intacts, puis appelleasync_set_updated_data. C'est le seul écrivain, et il n'est appelé qu'après un succès — voilà comment la règle « une collecte en échec ne remplace jamais un bon instantané » est appliquée : par construction, pas par vérification.note_failure(error)fait basculerlast_update_successsans journaliser : un tick en échec ne doit pas imprimer une erreur par coordinateur (un par palier). La règle Silverlog-when-unavailablevit sur la session : un INFO quand PRONOTE ne répond plus, un INFO quand il répond à nouveau (session.py,_note_unreachable/_note_reachable).self.datareste exactement ce qu'il était.
always_update=False évite les écritures d'état pour des données identiques.
7.3 De l'instantané à l'état d'une entité¶
PronoteEntity (entity.py) est la classe de base. La chaîne est
courte et vaut la peine d'être lue :
coordinator du palier
-> snapshot_for(student.id) # entity.py
-> snapshot.data # entity.py, la propriete `facts`
-> description.value_fn(facts, account)
Aucune propriété d'entité ne touche le réseau. Chaque plateforme lit
self.facts et renvoie None si l'instantané est absent.
L'unique_id est f"{entry_id}_{student.id}_{key}" (entity.py), et les
trois composantes sont nécessaires. L'entry_id y est parce que l'unique_id
de l'entrée est le compte, donc deux entrées suivant le même enfant sont
légitimes — un compte mère et un compte père, ou un compte parent à côté de
celui de l'enfant. Et student.id est le numéro de ressource PRONOTE N, qui
n'est unique qu'à l'intérieur de la base d'un établissement. Sans ce préfixe,
le registre ne rejette pas la seconde entrée : il re-pointe l'entité
existante vers elle, déplaçant l'entité sur l'autre device et cassant
silencieusement toutes les automatisations qui la référençaient. La clé finale
est une clé fonctionnelle stable — jamais un libellé, un nom de période ou un
rang : une automatisation qui casse en septembre parce qu'un capteur a été
renommé est une régression même si aucun code n'a échoué.
La disponibilité (entity.py) ne consulte volontairement pas
coordinator.last_update_success. Une entité est unavailable uniquement si
elle n'a jamais eu de données, ou si account.is_stale(tier). Laisser un échec
transitoire basculer la disponibilité est exactement le déclenchement
fallacieux que le régime de fraîcheur existe pour éviter.
Les attributs communs sont fetched_at et stale. Et toutes les listes sont
non enregistrées : _unrecorded_attributes combine
UNRECORDED_LIST_ATTRIBUTES (const.py) et fetched_at, parce que les
listes PRONOTE dépassent la limite de 16 Kio d'attributs du recorder et que
l'historique qui vaut la peine d'être gardé est le compteur, pas la charge
utile. PronoteAccountEntity est un frère et non une sous-classe, donc
cette déclaration y est répétée — l'oubli faisait enregistrer by_tier et
tiers_due à chaque écriture d'état.
Le corollaire est plus contre-intuitif et a coûté une version : une
sous-classe, elle, ne redéclare jamais. Home Assistant ne réunit pas ces
ensembles le long d'une hiérarchie —
Entity.__init_subclass__ calcule
_entity_component_unrecorded_attributes | cls._unrecorded_attributes et le
terme de droite est résolu par recherche d'attribut ordinaire, donc une
déclaration sur une sous-classe remplace celle du parent. PronoteSensor a
déclaré un unique nom en v0.0.22, en croyant l'ajouter : les dix-huit attributs
de liste de tous les capteurs sont retournés à l'enregistreur, et les adresses
signées des pièces jointes ont été mesurées dans l'historique. Un nom nouveau
s'ajoute donc à UNRECORDED_LIST_ATTRIBUTES, et un test refuse toute
déclaration de sous-classe qui ne couvre pas cette constante.
ClockDrivenMixin (entity.py) traite le cas des entités dont l'état
dépend de l'heure et pas seulement des données : « en cours », « absent en
ce moment », « prochain cours », « réveil ». Elles arment un rappel ponctuel à
leur prochaine transition connue via async_track_point_in_time, plutôt que de
changer d'état au rythme de leur palier — c'est-à-dire jusqu'à quinze minutes en
retard, ce qui sur un capteur de réveil supprime tout l'intérêt du capteur.
7.4 La détection de delta¶
delta.py implémente deux règles, parce qu'une seule ne peut pas faire le
travail.
La spécification v1 en avait une : « delta sur l'identifiant N, jamais sur le
contenu ». Elle est juste pour la plupart des collections et incapable
d'implémenter l'événement le plus utile de l'ensemble, le cours modifié, pour
deux raisons vérifiées : un changement de salle garde le même N et ne produit
donc aucun delta d'identifiant ; et un remplacement arrive avec un N neuf
pendant que l'entrée originale est encore présente, donc il se lit comme un
ajout — et previous_start / previous_classroom n'ont alors aucune
source.
La règle se dédouble donc :
Collections en ajout seul — notes, devoirs, actualités, absences, retards, punitions, messages, évaluations — gardent la règle de l'identifiant telle quelle. Un libellé qu'un professeur corrige ne doit pas réveiller la maison.
L'emploi du temps compare un tuple de champs sur liste blanche, par
identifiant de cours, sur la semaine non dédupliquée.
Lesson.change_signature (models.py) contient canceled, status,
frozenset(classrooms), frozenset(teachers), start, end — et exclut
memo, background_color et le contenu du cours, si bien qu'un mémo corrigé
ne déclenche rien, ce que la règle unique cherchait en réalité à acheter.
Les deux moitiés de cette phrase étaient fausses dans la première implémentation, et fausses d'une façon qui ne produisait aucune erreur :
- elle indexait sur le créneau (jour plus position dans la journée). Comme
le jour fait partie de la clé, un cours déplacé ne pouvait jamais être vu
comme déplacé —
lesson_movedétait structurellement inatteignable — tandis qu'un changement de la grille horaire de l'établissement, les sonneries décalées de cinq minutes, déplaçaitstartpour tous les cours de tous les créneaux d'un coup et déclenchait unlesson_movedpour la semaine entière ; - elle tournait sur la semaine dédupliquée. Or lors d'un remplacement,
PRONOTE sert l'entrée originale avec
estAnnuleplus un remplacement aunumsupérieur, et la déduplication garde le remplacement — donc l'entrée qui portait l'annulation avait été écartée une couche plus bas, et l'annulation était inobservable.
C'est pour cela que TimetableFacts porte deux collections : lessons
dédupliquée pour l'affichage, et all_lessons triée mais non dédupliquée pour
le détecteur.
Les comparaisons de salles et de professeurs se font sur des ensembles et
non des séquences : rien dans le protocole n'ordonne ListeSalles ni
ListeProfesseurs, et PRONOTE les réordonne effectivement entre deux réponses.
En tuples, un cours co-enseigné émettait lesson_changed sur un sous-ensemble
arbitraire de collections, chaque nuit, pour le reste de l'année.
Un changement peut légitimement être plusieurs choses à la fois — un cours
déplacé et mis dans une autre salle — donc _lesson_events
(delta.py) émet un événement par aspect réellement modifié plutôt que
d'en élire un. Et il n'y a plus de repli « si rien n'a correspondu,
appelons ça une annulation » : chaque branche de la signature a maintenant son
propre nom, donc ce repli ne pouvait se déclencher que pour une transition sur
laquelle il avait tort — et la transition pour laquelle il se déclenchait
réellement était une annulation levée, qu'il rapportait comme
lesson_canceled. C'est ce que l'existence de EVENT_LESSON_RESTORED
(const.py) corrige : une automatisation qui notifie « pas de cours en
première heure, dors » se déclenchait le matin où le cours était rétabli.
Deux garanties générales complètent le dispositif. Le premier instantané
d'un palier après un démarrage ou un rechargement n'émet rien — d'où le
None distinct de la liste vide dans _new_ids (delta.py), parce que
« rien de neuf » et « c'est le premier instantané » doivent conduire à des
comportements différents et qu'un appelant incapable de les distinguer rejouera
le trimestre. Et huit notes nouvelles produisent huit événements, pas un
événement agrégé : agréger est trivial à refaire pour l'utilisateur, séparer ne
l'est pas.
Un plafond, _NEW_THREAD_CAP = 3, borne les événements message_received
qu'un fil peut émettre à sa première apparition : un fil devient visible
pour la première fois pour d'autres raisons qu'un message qui arrive — une
étiquette déplacée, une restauration depuis la corbeille, un établissement qui
désarchive — et traiter tout son compteur de non-lus comme des arrivées
rejouait le fil.
Les événements sont diffusés sur le bus Home Assistant sous le signal
SIGNAL_DELTA, défini et émis par account.py — device_trigger.py et
event.py l'importent de là, et non de const.py, ce qui est l'exception à la
règle « une constante partagée vit dans const.py ». Les neuf entités event
s'y abonnent en filtrant sur entry_id, student_id et entity_key. Le détecteur
ne conserve que le matériel d'identité dont il a besoin, jamais les instantanés
eux-mêmes, pour qu'un trimestre de notes ne siège pas deux fois en mémoire.
8. Un cycle de collecte, de bout en bout¶
Le diagramme suivant suit un palier unique, pour un élève unique, depuis l'échéance jusqu'à l'état de l'entité — en faisant apparaître le passage par le limiteur, la réconciliation et l'ordre entre publication et événement.
sequenceDiagram
autonumber
participant HA as Home Assistant
participant A as PronoteAccount
participant SC as FetchScheduler
participant RL as RateLimiter
participant SM as SessionManager
participant EX as SerialExecutor
participant GW as PronoteGateway
participant HC as HardenedClient
participant CO as Coordinateur du palier
participant E as Entite
participant EV as Entite event
HA->>A: master tick, toutes les 5 min
A->>A: _tick_lock deja pris ? si oui, on saute
A->>SC: due
SC-->>A: paliers dus, par priorite decroissante
A->>RL: begin_batch
A->>SM: begin_batch
loop pour chaque palier du, puis chaque eleve
A->>SM: run tier, priority, fn, student_id, cost
Note over SM: _gate tenu sur choisir, attendre, appeler, reessayer
SM->>SM: _ensure_client · connexion si la politique l exige
SM->>RL: call tier, priority, fn, cost
alt refuse
RL-->>SM: TierDeferred reason, retry_after
SM-->>A: TierDeferred
A->>SC: defer tier, retry_after
Note over CO: instantane conserve, entites inchangees
else admis
Note over RL: sous _admission · check, attente, commit du cout
RL->>EX: run closure
EX->>HC: set_child student_id puis post onglet
HC-->>EX: reponse brute
EX->>GW: decodage entree par entree, tolerant
GW-->>RL: GatewayResult facts, calls reels
RL->>RL: note_success
RL-->>SM: GatewayResult
SM->>RL: reconcile tier, charged, actual
SM-->>A: GatewayResult
A->>CO: publish student_id, snapshot
CO->>E: async_set_updated_data
E->>E: available, native_value, attributs recalcules
A->>A: delta detecte les changements
end
end
A->>RL: end_batch
A->>SC: mark_collected tier
A->>EV: bus async_fire SIGNAL_DELTA, une fois par changement
Note over EV,E: les evenements partent APRES la publication
A->>HA: _async_sync_issues · ouvre ou ferme daily_cap_near
Trois points de l'ordonnancement ne sont pas fortuits, et sont énoncés dans
account.py :
- Les paliers sont servis par priorité, parce que le limiteur sacrifie par priorité et que servir dans le désordre laisserait tomber les mauvais.
- Une collecte réussie publie son instantané puis lance le détecteur, si bien qu'un événement ne se déclenche jamais pour des données que les entités ne détiennent pas encore. Une automatisation qui réagit à « une note est arrivée » trouve le capteur qui la porte déjà à jour.
- Une collecte différée déplace son échéance et garde son instantané ; une collecte en échec fait basculer le drapeau de succès du coordinateur et garde son instantané aussi. « Je sais, mais c'est vieux » bat « je ne sais plus » sur des données scolaires.
Le traitement des exceptions dans _async_collect (account.py)
distingue trois familles. TierDeferred reporte. Les refus
d'authentification — LoginRefused, InvalidCredentials, MfaRequired,
BootstrapFailed, AccountUnreadable, IntegrationFault — sont le problème du
compte et non du palier : ils ouvrent une réparation et arrêtent le batch au
lieu d'être réessayés palier par palier. Tout le reste est attrapé au sommet
d'un cycle, journalisé, et reporté avec retry_delay() — jamais
backoff_delay(), pour la raison donnée au §3.6.
9. Le flow de configuration¶
PRONOTE offre trois entrées, donc le flow en offre trois. Ce ne sont pas des variantes d'un formulaire : l'enrôlement par QR code produit un jeton rotatif et demande un UUID d'appareil, le mode identifiants non, et l'ENT demande un fournisseur choisi dans une liste. Chacun a donc son étape, plutôt qu'un formulaire unique avec des champs parfois ignorés.
flowchart TD
START["async_step_user · menu a trois entrees"]
START --> QR["async_step_qr_code · payload du QR, PIN du QR, PIN de compte optionnel"]
START --> CRED["async_step_credentials · URL, identifiant, mot de passe"]
START --> ENT["async_step_ent · URL, identifiant, mot de passe, fournisseur ENT"]
QR --> TRY["_async_try_login"]
CRED --> TRY
ENT --> TRY
TRY --> PROBE["_async_probe · login_guard.login puis executor puis flow_login.probe_account"]
PROBE -->|"succes"| UID["async_set_unique_id · URL plus identifiant PRONOTE"]
PROBE -->|"invalid_auth, mfa_required, bootstrap_failed, invalid_qr, rate_limited, cannot_connect"| RESHOW["_async_reshow · meme formulaire, avec erreurs"]
RESHOW --> TRY
UID --> ABORT{"unique_id deja configure ?"}
ABORT -->|"oui"| STOP["abandon · deja configure"]
ABORT -->|"non"| MULTI{"plus d un enfant ?"}
MULTI -->|"oui"| KIDS["async_step_children · selection multiple"]
MULTI -->|"non"| CREATE
KIDS --> CREATE["_async_create · retire qr_payload, qr_pin, account_pin, account_id"]
CREATE --> ENTRY["entree creee · children CONSERVE"]
RA["async_step_reauth · declenche par ConfigEntryAuthFailed"]
RA --> RAC["async_step_reauth_confirm · nouveau mot de passe et ou PIN"]
RAC --> CLEAR["clear_login_penalties AVANT la tentative"]
CLEAR --> PROBE2["_async_probe"]
PROBE2 -->|"succes"| UPD["async_update_reload_and_abort · PIN retire a la sortie"]
PROBE2 -->|"echec"| RAC
OPTS["PronoteOptionsFlow · init, general, tiers, rate_limit"]
OPTS --> EST["estimate_daily_requests affiche a chaque etape"]
EST --> RELOAD["sauvegarde puis async_reload_entry"]
Quatre choses sont porteuses dans ce flow.
Le PIN à deux facteurs n'est jamais stocké. Il est demandé, utilisé pour la
connexion qui en a besoin, et abandonné. _async_create
(config_flow.py) exclut qr_payload, qr_pin, account_pin et
account_id de l'entrée créée ; le chemin de ré-authentification retire de
même account_pin à la sortie (config_flow.py), si bien que le PIN
n'existe dans l'entrée que le temps d'un rechargement. Corollaire assumé : un
secret qu'on refuse de garder est un secret qu'on doit savoir redemander,
ce qui est la raison d'être du flow de ré-authentification.
Toute connexion du flow passe par le limiteur. C'est
_async_probe (config_flow.py) qui l'assure, via
login_guard(hass).login(...). L'omission comptait plus qu'il n'y paraît :
c'est le seul chemin de connexion qu'un humain peut répéter à volonté, puisque
le formulaire de ré-authentification se réaffiche à chaque échec. Un parent
convaincu que le mot de passe est bon pouvait le soumettre vingt fois en une
minute ; aucune de ces tentatives n'atteignait may_login, aucune n'était
comptée par note_login, et le garde-fou de trois tentatives — la défense
contre la seule sanction que PRONOTE applique à une adresse et non à un compte
— ne les voyait jamais.
Le coût déclaré tient compte du doublement structurel de l'enrôlement QR :
qrcode_login construit un client — dont le constructeur se connecte — poste
PageInfosPerso 49, puis appelle token_login avec les identifiants exportés.
Ce sont deux vraies connexions contre le serveur, donc le flow déclare
REQUESTS_PER_LOGIN * 2, et note_login(counts_against_guard=False) exempte ce
doublement délibéré du garde-fou plutôt que de lui laisser consommer les deux
tiers d'un budget de trois tentatives (ratelimit.py).
La ré-authentification efface les pénalités avant la tentative. Un geste
humain délibéré, avec la correction en main, n'est pas un retry automatique et
mérite une ardoise vierge. Et c'est la seule sortie du maintien MFA, ce qui est
correct puisque ce maintien existe précisément parce qu'une personne doit agir.
Le commentaire du code note que cela ne se produisait pas du tout :
reset_after_reauth n'avait aucun appelant hors des tests du limiteur, si bien
que le maintien MFA, dont la seule sortie est un humain fournissant le PIN, ne
pouvait pas être quitté par un humain fournissant le PIN.
L'unique_id de l'entrée est l'URL de l'établissement plus l'identifiant
PRONOTE du compte (flow_login.py). Pas l'identifiant de connexion :
une connexion ENT et une connexion directe pour le même compte utilisent des
identifiants différents. Pas le jeton non plus, puisqu'il tourne à chaque
connexion.
Un dernier détail, parce qu'il illustre la classe de bugs que ce dépôt
poursuit : children est délibérément exclu de la fusion du résultat de
sonde, et délibérément inclus dans l'entrée créée. Le probe renvoie
children sous forme de paires (id, nom), et CONF_CHILDREN — la même
chaîne — est où la sélection de l'utilisateur est stockée quelques lignes plus
bas. Fusionner les paires puis retirer la clé à la sortie jetait la sélection
avec elles : un parent qui suivait délibérément un enfant sur deux en obtenait
deux quand même, silencieusement, avec le budget de requêtes du second.
flow_login.py existe séparément de config_flow.py pour une raison qui n'est
pas cosmétique : le flow ne doit pas importer pronotepy au niveau du
module, sinon une dépendance cassée transforme « l'intégration ne peut pas se
connecter » en « l'intégration ne peut pas être ajoutée du tout ». Tous les
imports amont y sont locaux à la fonction.
Le flow d'options a trois sections — general, tiers, rate_limit — et
affiche à chaque étape une estimation du budget quotidien calculée par
estimate_daily_requests (options.py). Ce n'est pas de la
décoration : un réglage dont on ne peut pas voir la conséquence se règle au
hasard. L'estimation utilise la même fonction que celle qui a produit les
chiffres de l'annexe, elle plafonne le nombre de connexions par
max_logins_per_day — sinon le cas dégénéré d'un délai serveur de cinq minutes
annonçait 64 connexions par jour contre un maximum configuré de 24, une
estimation que l'intégration ne pourrait pas produire même en essayant — et
elle compte une connexion à REQUESTS_PER_LOGIN_WORST_CASE = 7 et non à 5,
parce qu'une estimation dont on peut être déçu est pire que pas d'estimation.
10. Sécurité et vie privée¶
10.1 L'inventaire des secrets¶
| Secret | Où il vit | Persisté ? |
|---|---|---|
| Mot de passe, ou jeton d'application mobile | entry.data["password"], en clair dans .storage de Home Assistant |
oui — le jeton tourne à chaque Authentification et doit être réécrit |
| Identifiant de connexion | entry.data["username"] |
oui |
| UUID d'appareil | entry.data[uuid] |
oui — il ne doit pas changer entre deux connexions |
client_identifier |
entry.data |
oui |
PIN à deux facteurs (account_pin) |
mémoire du flow, puis entry.data le temps d'un rechargement |
non |
| Payload et PIN du QR code | mémoire du flow uniquement | non — usage unique, PRONOTE l'invalide à l'enrôlement |
| URL iCal | réponse de service, le temps d'un script | non, jamais |
| Bloc d'identité — date et lieu de naissance, e-mail, téléphone, adresse, numéro INE, tuteurs | réponse de service | non, jamais |
| URL du PDF d'emploi du temps | réponse de service | non, jamais |
Jeton de session h de pronotepy |
à l'intérieur du client, dans le fil de travail | non |
10.2 Pourquoi aucun n'entre dans un état d'entité¶
La règle est énoncée dans services.py : un secret est renvoyé, jamais
stocké. get_ical_url, get_identity et generate_timetable_pdf sont des
services SupportsResponse.ONLY.
L'URL iCal est le cas le plus net. Quiconque la détient lit l'emploi du temps complet d'un enfant sans identifiant et sans mot de passe. Comme état, elle atterrirait dans la base du recorder, donc dans chaque sauvegarde, dans les captures d'écran et dans les rapports de bug. Comme réponse de service, elle existe le temps d'une exécution de script.
C'est pour cela que le palier static ne la collecte plus :
tiers.py et gateway.py le disent tous deux. La collecter
sur un rythme mettrait un porteur d'authentification autonome dans le magasin
d'instantanés — une structure de longue durée dont la finalité est d'être
versée dans un rapport de diagnostic.
Le même raisonnement s'applique à un endroit moins évident : les pièces jointes
aux devoirs ne portent que des noms, jamais d'URL (gateway.py).
Attachment.url interpole client.attributes["h"], le jeton de session vivant,
donc la garder écrirait une URL porteuse d'un identifiant dans l'instantané,
dans chaque ligne du recorder et dans le téléchargement de diagnostic — et elle
serait morte au moment où quelqu'un cliquerait dessus, puisque le jeton change à
la connexion suivante.
De même, _information_dict omet volontairement content : lire
Information.content est un accès paresseux qui poste.
10.3 Diagnostics : l'absence par construction, la redaction en filet¶
diagnostics.py est explicite : la redaction est un filet de sécurité
et non le mécanisme. Le mécanisme est que les secrets n'entrent jamais dans un
instantané, donc il n'y a rien à trouver dans ce fichier.
Ce qui est dans l'entrée de configuration est masqué explicitement :
username, password, uuid, client_identifier, account_pin,
qr_payload, qr_pin (TO_REDACT, diagnostics.py). L'entrée
account_pin y figure « par précaution » — le PIN n'est jamais persisté, donc
cette clé ne devrait jamais correspondre à rien ; si elle correspond un jour,
c'est le bug.
pronote_url reste : il identifie l'établissement, ce qui est exactement ce
dont un rapport de bug a besoin, et il n'accorde rien par lui-même.
Deux mesures complètent le tableau.
Empreintes tronquées des identifiants d'élève. _short_hash
(diagnostics.py et account.py) applique blake2s avec
digest_size=4. Une empreinte reste utile dans un rapport de bug — « les deux
enfants montrent le même palier en échec » — sans donner à personne le matériel
pour rejouer une session. C'est aussi ce que le diagnostic par device utilise, à
côté d'un rapport qui ne décrit que la forme de chaque instantané : quand il
a été récupéré, combien de requêtes il a coûté, combien d'éléments il contient
(_counts, diagnostics.py) — et pas son contenu. Une note, le
commentaire d'un professeur sur un bulletin ou le corps d'un message n'ont rien
à faire dans un fichier que les gens collent dans un ticket public.
URL publique retaillée. urls.public_url (urls.py) ne garde que le
schéma, l'hôte et le chemin : pas de chaîne de requête, pas de fragment, pas
d'informations d'authentification user:password@. La raison est concrète : ce
que les parents collent n'est très souvent pas une URL de page nue. Les
établissements envoient des liens profonds, les portails ENT renvoient à travers
une authentification unique avec un ticket dans l'URL, et un parent qui copie
depuis la barre d'adresse après s'être connecté copie le paramètre de session
qui s'y trouve. Garder cette chaîne signifiait qu'un paramètre de session
pouvait voyager dans l'entrée de configuration, de là dans les substitutions
d'un repair issue — écrites dans .storage et rendues dans le panneau
Réparations — et de là dans le téléchargement de diagnostic, le fichier qu'on
demande explicitement aux utilisateurs de joindre à un ticket public.
Avec des valeurs manifestement fictives, la transformation ressemble à ceci :
| Ce que l'utilisateur colle | Ce qui est stocké |
|---|---|
https://demo.example.invalid/pronote/parent.html?login=true&ticket=ST-000 |
https://demo.example.invalid/pronote/parent.html |
https://compte-factice:secret-factice@demo.example.invalid/pronote/ |
https://demo.example.invalid/pronote/ |
https://demo.example.invalid:8443/pronote/eleve.html#page=1 |
https://demo.example.invalid:8443/pronote/eleve.html |
Le port non standard est conservé — c'est netloc et non hostname qui est
repris, parce que certains établissements en publient un — tandis que les
informations d'authentification, la chaîne de requête et le fragment
disparaissent. Et si la chaîne ne s'analyse pas comme une URL, elle est rendue
telle quelle : refuser une adresse que l'utilisateur voit dans son navigateur
serait pire que d'en stocker une bizarre, puisque la tentative de connexion
échouera avec le message du serveur, qui est un meilleur diagnostic que le
nôtre.
L'adresse est donc retaillée à la frontière, une fois
(config_flow.py), et seule la forme retaillée est stockée, affichée ou
rapportée. diagnostics.py la retaille une seconde fois plutôt que de
lui faire confiance : une entrée créée avant que le flow ne fasse cela — ou
restaurée depuis une sauvegarde, ou éditée à la main dans .storage — peut
encore porter le lien profond qu'une école a envoyé. url_host réduit encore
davantage pour les cas qui n'ont besoin de dire que quel établissement.
10.4 Le journal¶
manifest.json ne porte aucune clé loggers, et c'est vérifié en CI
(scripts/check_manifest.py). Activer le logger pronotepy en DEBUG fait
écrire à pronoteAPI.py la charge utile de la requête en clair, identifiants
compris. Une exigence de ce type ne vaut rien si rien ne l'applique, parce
qu'une entrée loggers est exactement le genre de ligne qu'on ajoute en
déboguant et qu'on oublie de retirer.
Le corollaire pratique : le diagnostic de cette intégration se fait par les
entités de diagnostic, le service get_rate_limit_status — qui lit tout en
mémoire et coûte zéro requête, donc peut être interrogé depuis un tableau de
bord — et le téléchargement de diagnostic. Jamais par le journal amont.
10.5 Les écritures sont désactivées par défaut¶
Cocher un devoir, marquer une actualité comme lue et envoyer un message
modifient ce que l'établissement voit. Les trois sont refusés à moins que
l'utilisateur n'ait activé les opérations d'écriture
(OPT_WRITE_OPERATIONS_ENABLED, défaut faux), et refusés avec une
explication plutôt qu'ignorés (_require_writes, services.py).
La liste de devoirs suit la même règle jusque dans son interface :
todo.py n'annonce UPDATE_TODO_ITEM que si les écritures sont activées,
parce qu'une case à cocher qui refuse est pire qu'une liste visiblement en
lecture seule.
Un test dédié tient cette ligne : tests/test_no_secret_in_state.py installe
l'intégration avec des sentinelles connues, exécute un cycle de collecte réel,
puis balaie quatre surfaces — états et attributs, téléchargement de
diagnostic, réponses de service, et le journal capturé en DEBUG. Les sentinelles
sont délibérément invraisemblables plutôt que réalistes : un test qui
chercherait un jeton d'allure réaliste tenterait quelqu'un d'y coller un vrai,
et ce fichier est publié.
11. La qualité¶
11.1 Les portails¶
Quatre portails, tous appliqués par .github/workflows/validate.yml.
ruff, en check et en format --check. Le jeu de règles est large
délibérément — 40 familles sélectionnées, dont S (bandit), BLE (except
aveugle), DTZ (datetimes naïfs, porteur ici pour les raisons du §7.1), FBT
(piège booléen), TRY, SLF, T20 — avec l'argument que le projet démarre
propre et qu'il n'y a donc aucune raison d'ouvrir un registre d'exemptions. Les
dérogations existantes sont peu nombreuses et motivées ligne par ligne dans
pyproject.toml. Deux d'entre elles disent quelque chose de l'architecture :
SLF001 — accès à un membre privé — n'est levé que pour gateway.py et
hardened_client.py, ce qui est la concentration de la surface amont rendue
exécutable par l'outillage.
mypy --strict sur custom_components/pronote_ng seulement, avec
warn_unreachable et warn_unused_ignores. Trois dérogations par module :
pronotepy.* (qui livre py.typed mais importe autoslot.Slots sous un
type: ignore), autoslot.*, et homeassistant.* pour implicit_reexport —
le cœur publie son API publique à travers des espaces de noms sans __all__, et
sa propre CI n'applique pas no_implicit_reexport contre eux.
pytest avec asyncio_mode = "auto", --strict-markers,
--strict-config, et filterwarnings = ["error::DeprecationWarning:custom_components.pronote_ng.*"]
— une dépréciation dans notre propre code est une erreur, une dépréciation
ailleurs ne l'est pas. Plus de 400 tests, répartis sur treize fichiers.
La couverture, appliquée par scripts/check_coverage.py, qui sort en code
non nul :
- 80 % en global, lignes et branches — le script prend le minimum des deux ;
- 100 % sur quatre modules :
ratelimit.py,scheduler.py,gateway.py,delta.py.
Le choix de ces quatre est motivé, et le motif est le même pour chacun : ce sont les modules où une erreur ne produit aucun symptôme visible à l'exécution. Un limiteur qui laisse passer un appel, un ordonnanceur qui saute un palier, une passerelle qui décode mal en silence, un détecteur qui rate un changement — rien de tout cela ne lève, rien n'apparaît au journal, et le seul signe est une donnée fausse ou absente que personne ne compare. Un seuil qui n'échoue pas n'est pas un seuil.
Deux portails supplémentaires vivent hors de validate.yml :
.github/workflows/hassfest.yml (validation officielle Home Assistant),
.github/workflows/hacs.yml (validation HACS, y compris une exécution
hebdomadaire pour qu'un changement de règle amont apparaisse sans push), et
.github/workflows/release.yml, qui vérifie que manifest.json déclare
exactement la version du tag — une intégration HACS dont le manifest diverge du
tag s'installe une fois et ne se met plus jamais à jour.
Un dernier, qui ne garde rien mais surveille :
.github/workflows/pronotepy-watch.yml compare une fois par semaine l'épingle
pronotepy à ce que PyPI publie et, si l'amont est devant, ouvre une pull
request qui relève l'épingle dans ses deux déclarations, avec la date de
publication et les sujets de commit entre les deux étiquettes amont
(spécification §11.1.1). Il existe parce qu'une version épinglée sans veille est
une dette dont l'échéance a été une panne totale de connexion : le correctif
amont était public depuis six jours et son sujet de commit nommait la cause.
Rien n'y fusionne automatiquement — relever l'épingle oblige à relire chaque
divergence documentée dans hardened_client.py, et c'est le travail que la
proposition demande.
La matrice de test comporte deux lignes : la référence épinglée
(pytest-homeassistant-custom-component==0.13.363, qui porte Home Assistant
2026.9.0) qui porte le portail de couverture, et une ligne flottante
latest qui attrape une rupture amont tôt sans bloquer dessus.
Le choix de l'épingle n'est pas « une version récente » mais exactement le
plancher déclaré dans hacs.json. C'est la seule façon de tester ce que l'on
annonce : épingler plus récent laisserait le plancher lui-même inexercé, et un
plancher qu'on n'exécute jamais est une affirmation invérifiable adressée à
l'utilisateur au moment de l'installation. La ligne latest couvre l'autre
bout de l'intervalle.
11.2 Pourquoi la suite tourne sous Linux, WSL ou Docker¶
pytest_homeassistant_custom_component — plus exactement le harnais de test de
Home Assistant qu'il embarque — importe fcntl, qui n'existe pas sous
Windows. Le module ne peut donc pas être chargé du tout sur cette plateforme,
et pas seulement par ce que ce projet en fait.
la fixture account de tests/conftest.py traite cela de la seule façon honnête : le plugin est
enregistré conditionnellement (HAS_HASS_HARNESS = sys.platform != "win32"),
avec un message d'erreur qui nomme la cause et la solution — « exécutez-les sous
Linux, WSL ou Docker, comme le fait la CI ». Un contributeur sous Windows peut
alors exécuter la moitié de la suite qui n'a aucune dépendance Home Assistant :
le limiteur, l'ordonnanceur, la passerelle, le détecteur de delta, les DTO et
l'estimateur de budget.
Ce découpage n'affaiblit aucun portail, et c'est la propriété qui le rend acceptable : cette moitié est exactement celle qui porte le portail à 100 %. Les quatre modules critiques ne dépendent ni de Home Assistant ni du réseau, par construction — ils prennent des horloges injectables et répondent à des questions. La CI, elle, tourne sur Linux et charge tout.
11.3 Les coutures de test¶
Trois décisions de conception existent en partie pour rendre les tests possibles, et il vaut la peine de les nommer parce qu'elles ressemblent autrement à de la sur-ingénierie.
Deux horloges injectables partout. RateLimiter, FetchScheduler,
SessionManager et PronoteGateway prennent une horloge monotone clock et
une horloge murale now en argument de constructeur, plutôt que d'appeler
time.monotonic et dt_util.now eux-mêmes. tests/clock.py exploite cette
couture : un test avance le temps d'une heure en une instruction, sans
freezegun et sans dormir, et toute branche dépendant du temps écoulé devient
atteignable. Les deux horloges avancent ensemble par défaut, et les faire
diverger — une correction NTP reculant l'horloge murale sans bouger la
monotone — est exactement ce qui rend testable la branche « recul » de
_roll_day.
rng et sleep sont des coutures pour la même raison, résolues dans le
constructeur du limiteur plutôt qu'à chaque site d'appel, si bien qu'un test
construit un limiteur avec un générateur figé et un dormeur simulé, et tous
les chemins deviennent atteignables sans patcher un global de module.
Le coût est renvoyé, pas déduit. GatewayResult.calls rend possible le test
de contrat par palier : un accès accidentel à une propriété double le coût sans
que rien ne casse, et c'est la régression que ce projet existe pour empêcher.
Les invariantes sont vérifiées par des tests, pas par des journaux.
L'exemple le plus net est dans _lesson_events : chaque composante de
Lesson.change_signature doit correspondre à une branche d'événement, et cette
invariante est vérifiée dans tests/test_delta.py plutôt que gardée par un log
d'exécution que personne ne lit. Ajouter un champ à la signature sans ajouter la
branche fait échouer la CI, ce qui est le moment où cela peut encore être
corrigé à bon compte.
tests/fixtures/protocol.py fournit des charges utiles écrites à la main, et le
pyproject.toml explique pourquoi elles sont des datetimes naïfs : PRONOTE
transmet des heures locales sans offset, donc y attacher un fuseau testerait une
forme que le serveur n'envoie jamais. La passerelle est le seul endroit où un
fuseau est attaché, et c'est ce que tests/test_gateway.py vérifie.
11.4 L'internationalisation¶
Rien de visible par l'utilisateur n'est codé en dur dans
custom_components/ : les libellés vivent dans strings.json (source anglaise)
et translations/{en,fr}.json. Les trois fichiers, plus services.yaml, sont
générés depuis une table unique par scripts/build_translations.py. Le
raisonnement est explicite : l'exigence est qu'un test vérifie que toute clé
présente dans une langue existe dans les autres avec les mêmes substitutions, et
maintenir trois fichiers à la main garde ce test durablement rouge pour des
raisons sans intérêt. Le générer depuis une table fait du test un vrai contrôle
sur la table.
12. Écarts avec la spécification¶
La source de vérité est le code. Ce qui suit relève les endroits où le dépôt tel qu'il est diverge de ce que les documents de conception annoncent, ou bien où une exigence est écrite mais pas appliquée. Les écarts sont classés par nature, parce qu'ils ne demandent pas la même chose au lecteur : certains sont des corrections délibérées que le code documente, d'autres des exigences écrites que rien n'applique, d'autres encore de simples documents périmés.
12.0 Ce que ce chapitre ne contient pas¶
Sur l'essentiel, code et spécification concordent, et il vaut la peine de le
dire pour que la liste qui suit garde ses proportions. Concordent exactement :
les treize réglages du limiteur et leurs bornes, les trois seuils de sacrifice
(100 / 80 / 60 %) et le seuil d'avertissement à 80 %, la formule de repli et sa
gigue pleine, les dix paliers et leurs intervalles, les onze identifiants
d'onglet protocolaire, les trois codes Erreur.G traités, stale_after = 6,
les timeouts 30 s / 60 s et le facteur trois de détection d'interblocage, les
quinze capteurs primitifs et dix-sept capteurs de liste, les huit séries
d'historique indexées par période, les dix capteurs binaires — y compris le
retrait des trois « jumeaux » — les neuf entités de diagnostic, les huit
services et leurs quatre réponses SupportsResponse.ONLY, l'arithmétique de
budget à environ 180 requêtes de données par jour, et les portails de qualité
(80 % global, 100 % sur quatre modules).
12.1 Le domaine annoncé est pronote, le domaine réel est pronote_ng¶
SPECIFICATION.md déclare en en-tête le domaine pronote et présente le
renommage comme « une décision de fin de parcours, pas de début », puisqu'il
changerait tous les entity_id. L'annexe A nomme d'ailleurs les services
pronote.refresh, pronote.get_ical_url, etc. Le §1.3 de la spécification est
par ailleurs autocontradictoire sur ce point — « domaine pronote et non
pronote » — ce qui a tout l'air d'un artefact de recherche-remplacement.
Le code a tranché dans l'autre sens et l'a motivé
(DOMAIN, const.py) : le domaine est pronote_ng, les services sont
pronote_ng.*, et les blueprints vivent sous
blueprints/automation/pronote_ng/ et non blueprints/automation/pronote/.
Le renommage n'a pas eu lieu, délibérément : une autre intégration PRONOTE déjà
installée peut occuper pronote, et deux composants personnalisés réclamant le
même domaine ne cohabitent pas. Garder pronote_ng permet d'essayer cette
intégration sans d'abord désinstaller celle dont on dépend — ce qui est
précisément l'objectif de cohabitation que la spécification énonçait, atteint
par le moyen inverse de celui qu'elle prescrivait.
Le dépôt, lui, s'appelle ha-pronote-ng, et c'est sous le nom Pronote NG
que hacs.json et le champ name du manifest présentent l'intégration. Le
domaine est le seul endroit où la forme soulignée apparaît.
12.2 Le confinement de pronotepy n'est pas total¶
L'affirmation « gateway.py est le seul module à importer pronotepy »
(gateway.py) est vraie pour les types de données, et fausse pour les
exceptions. L'inventaire réel :
| Module | Ce qu'il importe |
|---|---|
gateway.py |
dataClasses, DataError, ParsingError |
hardened_client.py |
pronotepy, dataClasses, pronoteAPI, PronoteAPIError |
session.py |
ChildNotFound, CryptoError, DataError, ExpiredObject, MFAError, PronoteAPIError ; et pronotepy.ent à la ligne 870 |
__init__.py |
PronoteAPIError |
config_flow.py |
pronotepy.ent — import local, pour la liste des fournisseurs |
flow_login.py |
CryptoError, ENTLoginError, MFAError, PronoteAPIError, QRCodeDecryptError ; et pronotepy.ent |
La formulation exacte que le code soutient est donc : aucun objet de données
pronotepy ne franchit la frontière, et seuls gateway.py et
hardened_client.py en construisent ou en lisent. Les classes
d'exception et la résolution du fournisseur ENT traversent, et c'est
inévitable : session.py doit classer CryptoError contre PronoteAPIError
pour que le garde-fou IP fonctionne du tout (§5.4), et un module qui ne
connaîtrait pas ces types ne pourrait pas le faire. Cette traversée est bornée,
sans état, et sans risque de Erreur.G = 22 — mais elle mérite d'être décrite
exactement, parce que « un seul fichier à relire lors d'un changement de
version » est faux : il y en a deux à relire pour les données, et quatre à
vérifier pour les exceptions.
12.3 La règle de détection de changement de l'emploi du temps est inversée¶
C'est l'écart le plus substantiel, et il est assumé et documenté par le
code (delta.py).
La spécification (§2.2.1) prescrit deux étapes obligatoires dans cet ordre :
dédoublonner par créneau en gardant le num maximal, puis comparer une liste
blanche (canceled, status, classroom, teachers, start, end) sur la clé de
créneau (date, place, subject_id) prise dans le JSON brut, et
explicitement pas sur N.
Le code fait exactement l'inverse sur les deux points, pour des raisons vérifiées :
- il indexe sur
N(delta.py) et non sur une clé de créneau, parce qu'une clé contenant le jour rendlesson_movedstructurellement inatteignable — un cours déplacé change de clé et se lit comme deux entrées distinctes — tandis qu'un décalage de la grille horaire de l'établissement déplaçaitstartpour tous les cours de tous les créneaux et déclenchait unlesson_movedpour la semaine entière ; - il tourne sur la semaine non dédoublonnée (
facts.all_lessons), parce que sur un remplacement PRONOTE sert l'entrée originale avecestAnnuleplus un remplacement aunumsupérieur, et le dédoublonnage garde le remplacement — donc l'entrée portant l'annulation avait été écartée une couche plus bas et l'annulation était inobservable ; subject_idest retiré de la clé de dédoublonnage, qui vaut(date, place)(models.py) : l'inclure défaisait le dédoublonnage qu'il sert, puisqu'une substitution — le cas pour lequel la règle est écrite — est justement deux entrées sur un créneau avec des matières différentes ;- la liste blanche compare
frozenset(classrooms)etfrozenset(teachers)et non unclassroomunique et une séquence de professeurs, parce que rien dans le protocole n'ordonneListeSallesniListeProfesseurset que PRONOTE les réordonne : en tuples, un cours co-enseigné émettaitlesson_changedchaque nuit pour le reste de l'année.
Le dédoublonnage lui-même reste bien dans la passerelle
(gateway.deduplicate_lessons), comme la spécification l'exige. C'est
uniquement la couche sur laquelle le détecteur travaille qui change : la
passerelle expose les deux vues, lessons dédoublonnée pour l'affichage et
all_lessons pour le delta.
12.4 Les types d'événement du cours modifié sont six et non quatre — divergence close¶
Cette divergence est résolue dans le même sens que la §12.9 : c'est l'annexe qui a bougé, parce que c'est le code qui avait raison.
L'annexe A §4 ne déclarait que quatre event_types sur
event.<élève>_cours_modifie : lesson_canceled, lesson_moved,
room_changed, teacher_changed. Le code en déclare six
(LESSON_EVENT_TYPES, const.py), et le tableau de l'annexe les porte
désormais tous — avec, au passage, les douze attributs de contexte réellement
émis par _lesson_context au lieu de sept. Les deux qui manquaient :
lesson_restored— une annulation levée, le cours est rétabli. Son absence est ce qui faisait déclencherlesson_canceledpour l'événement exactement opposé par le repli « si rien d'autre n'a correspondu, appelons ça une annulation » : une automatisation qui notifie « pas de cours en première heure, dors » se déclenchait le matin où le cours revenait (const.py) ;lesson_status_changed— seul le libelléStatuta bougé, sans drapeau, sans horaire, sans salle et sans professeur. PRONOTE utilise ce champ pour des situations où il ne pose pasestAnnule— « Prof. absent », « Cours dépl. » — donc le changement est réel et vaut d'être rapporté ; il n'est simplement pas une annulation, ce qu'il était rapporté comme étant (const.py).
Corollaire côté automatisations : TRIGGER_TYPES (device_trigger.py) expose quatorze
types de déclencheur, dont ces deux-là.
Ce qui reste à surveiller n'est plus l'annexe mais la duplication : la même
liste vit maintenant dans const.py, dans le tableau du § 4.10 du guide et dans
celui du § 4 de l'annexe A. Un septième type ajouté au code sans toucher aux
deux documents recrée exactement l'écart qu'on vient de fermer.
12.5 Le plafond de connexions compte les tentatives, pas les succès¶
La spécification et l'annexe B §3 décrivent max_logins_per_day comme un
plafond sur les connexions réussies. Le code compte les tentatives
(RateLimiter.logins_today, ratelimit.py), et le dit :
Attempts, not successes. The cap bounds how much authentication traffic this integration generates, and a failed attempt costs the server exactly as much as a successful one.
L'argument est solide et va dans le sens de l'objectif du plafond : ce qui doit être borné est le trafic d'authentification envoyé à l'établissement, et une tentative qui échoue coûte au serveur exactement ce qu'une réussie coûte. Mais c'est bien un écart, et il déplace la sémantique du réglage : le plafond de 24 est un plafond de 24 tentatives, donc plus strict que celui que la spécification décrit.
12.6 Le README.md décrit un état dépassé¶
README.md annonce en encadré : « État : spécification. Aucun code n'est
encore écrit. Ce dépôt contient pour l'instant la conception détaillée de
l'intégration. » Le dépôt contient environ 14 000 lignes de code de production
sur trente modules, plus de 400 tests, quatre workflows CI et quatorze
blueprints — les
jalons M1 à M7 de la spécification §12 sont donc tous atteints ou en cours,
alors que le document d'entrée du dépôt affirme qu'aucun ne l'est.
Le même fichier annonce « MIT (à créer avant la première publication) » alors
que LICENSE existe et que la spécification §11.1 faisait de son existence un
prérequis de publication : l'exigence est satisfaite, la note ne l'a pas suivi.
12.7 MAX_INTERVAL_FOR_EVENT_TIERS est déclaré mais jamais appliqué¶
La spécification §5.2 exige que « l'interface d'options refuse timetable
au-delà d'une heure et le signale », avec un argument juste : un cours annulé
annoncé quatre heures plus tard ne sert plus. const.py déclare la
table correspondante — MAX_INTERVAL_FOR_EVENT_TIERS = {Tier.TIMETABLE: 60} —
et reprend l'argument.
Cette constante n'a aucun lecteur. Le flow d'options borne les dix
intervalles par TIER_INTERVAL_RANGE = (1.0, 1440.0)
(config_flow.py) et options.tier_intervals les écrête contre la même
table globale. Rien n'empêche donc de régler timetable à 1440 minutes, ce qui
rend event.<élève>_cours_modifie structurellement inutile et supprime en
silence l'événement pour lequel l'intégration existe. L'exigence est écrite en
prose, déclarée en constante, et appliquée nulle part.
Quatre autres constantes sont déclarées sans lecteur, avec des conséquences
moindres : CONFIGURABLE_TIERS, CONF_ACCOUNT_KIND, FUNC_TEACHING_STAFF et
FUNC_DISCUSSIONS (les deux derniers parce que la passerelle passe par
client.get_teaching_staff() et client.discussions() plutôt que par un post
brut).
12.8 L'option history_periods n'est offerte nulle part¶
const.py déclare OPT_HISTORY_PERIODS = "history_periods" et
DEFAULT_HISTORY_PERIODS = 0 (« 0 signifie toutes les périodes closes »).
tiers.py la lit — mais par une chaîne littérale, "history_periods",
et non par la constante :
Et config_flow.py ne l'expose dans aucune de ses trois sections d'options,
alors que la spécification §7.3 la range explicitement dans la section
« Contenu » aux côtés de establishment_timezone, homework_horizon,
wake_margin et write_operations_enabled — les quatre autres, elles, sont
bien offertes. L'option est donc fonctionnelle mais inatteignable depuis
l'interface : seul un utilisateur qui édite .storage à la main peut la régler.
C'est aussi le seul endroit du dépôt où une clé d'option est écrite en dur au
lieu de passer par sa constante, ce qui prive le couple d'une vérification de
cohérence.
Sa sémantique diverge par ailleurs entre les deux documents et le code. Le code
pose DEFAULT_HISTORY_PERIODS = 0 avec le commentaire « 0 signifie toutes les
périodes closes », et tiers.py traite bien 0 comme « aucune limite »
en ne tronquant la liste que si limit est vrai. La table de sensibilité de
l'annexe B §5.6, elle, attribue à history_periods = 0 un effet de −8
requêtes, c'est-à-dire la suppression complète du palier — l'interprétation
opposée. Comme l'option n'est de toute façon pas atteignable, l'ambiguïté n'a
aucune conséquence observable aujourd'hui, mais il faudra la trancher avant de
l'exposer.
12.9 Le client durci n'hérite pas de ParentClient — divergence close¶
Cette divergence est résolue, et elle a été résolue dans le sens inverse de celui qu'on attend d'ordinaire : c'est la spécification qui a bougé, parce que c'est le code qui avait raison.
La spécification §3.6 nommait une sous-classe
_NoRefreshParentClient(pronotepy.ParentClient) surchargeant post(). Le code
implémente HardenedClient(pronotepy.Client) — une classe unique qui gère
l'élève et le parent — et l'écart n'était pas une commodité : ParentClient
est précisément la sous-classe qui porte le bug de récursion non bornée et celui
de l'enfant perdu. En hériter puis les neutraliser par surcharge laisse une
méthode d'amont ajoutée plus tard les réintroduire sans que rien ne le signale ;
réimplémenter la surface parent — liste d'enfants, set_child(), signature
membre — sur Client les rend structurellement inatteignables plutôt que
corrigés. Le §3.6 énonce désormais cette exigence, avec l'argument, et interdit
explicitement la forme par héritage.
Le confinement de Period.instances, que la spécification plaçait dans cette
même sous-classe, vit dans un objet et un gestionnaire de contexte séparés
(_ConfinedRegistry / _period_registry_confined) — ce qui permet de le poser
aussi sur le chemin d'enrôlement QR du flux de configuration, où il n'y a pas de
sous-classe du tout.
12.10 La stratégie de session est devenue une option, avec une dégradation mesurée¶
La spécification §6.5 décrit une stratégie, la reconnexion paresseuse, accompagnée d'un argument de dominance : si le délai mesuré est inférieur à l'intervalle du palier le plus rapide, la stratégie « dégénère exactement en la conception v1 ».
Le code transforme cet argument en deux mécanismes distincts. D'une part,
SessionStrategy est une option d'entrée à deux valeurs — LAZY par défaut,
PER_BATCH conservé comme échappatoire explicite pour un établissement dont on
préfère épingler la politique plutôt que la découvrir (const.py).
D'autre part, la dégénérescence n'est plus seulement un argument : c'est
effective_strategy, une machine réelle déclenchée par trois expirations
consécutives et rendue falsifiable par une sonde quotidienne
(session.py), dont la lévée exige une contre-preuve. C'est une
extension de la spécification plutôt qu'une contradiction, mais elle ajoute une
option de configuration que le document ne prévoyait pas.
12.11 Les fixtures de test sont écrites à la main, pas enregistrées¶
La spécification §11 exige des « fixtures protocolaires enregistrées et
anonymisées », versionnées dans tests/fixtures/, et en fait la première
tâche du jalon M1.
Le dépôt contient bien tests/fixtures/protocol.py et tests/fixtures/client.py,
mais les charges utiles y sont écrites à la main : tests/test_gateway.py
le dit en toutes lettres — « the fake client answers from hand-written payloads.
See tests/fixtures/ for why none of them came off a real server. »
L'objectif de la spécification — aucun test ne touche le réseau, aucune donnée
réelle d'élève dans le dépôt — est donc atteint, et même plus strictement,
puisqu'il n'y a jamais eu de réponse réelle à anonymiser. Mais le moyen prescrit
n'est pas celui employé, et la conséquence mérite d'être connue : les fixtures
reflètent la compréhension du protocole qu'a l'auteur, pas ce qu'un
établissement envoie réellement. C'est le point où le portail de couverture à
100 % sur gateway.py est le plus fragile, parce qu'une forme de réponse que
personne n'a imaginée n'a pas de test qui la couvre.
12.12 « Docker » n'existe pas dans le dépôt¶
Le dépôt ne contient ni Dockerfile, ni docker-compose.yml, ni devcontainer.
La seule mention de Docker est un message d'erreur dans tests/conftest.py
qui suggère « Linux, WSL ou Docker, comme le fait la CI » — et la CI, elle,
tourne directement sur ubuntu-latest avec actions/setup-python, pas dans un
conteneur.
La contrainte technique est réelle et correctement documentée : le harnais
importe fcntl, donc la suite complète ne peut pas tourner sous Windows. Mais
l'exécution « dans Docker » est une façon possible de contourner cela pour
un contributeur sous Windows, pas un dispositif présent dans le dépôt ni le
mécanisme employé par la CI. La formulation exacte est celle du §11.2.
La spécification, de son côté, ne mentionne Docker nulle part : ni image de
test, ni devcontainer, ni docker-compose. Elle exige seulement qu'aucun test
ne touche le réseau, ce que le dépôt respecte.
12.13 La version visée et la version testée coïncident désormais¶
Cette divergence est résolue, et la façon dont elle l'a été vaut d'être notée : c'est un cas où l'écart documentaire cachait deux vrais défauts.
SPECIFICATION.md déclare en en-tête viser Home Assistant 2026.9 ou
supérieur. Le dépôt déclare maintenant le même plancher — 2026.9.0 dans
hacs.json — et requirements_test.txt l'épingle exactement :
pytest-homeassistant-custom-component==0.13.363 embarque Home Assistant
2026.9.0. Les trois chiffres sont le même, la matrice exécute celui-là, et la
ligne flottante latest couvre l'autre bout de l'intervalle.
Le plancher était auparavant à 2026.2, puis à 2026.8. Chaque montée a été faite
en exécutant le socle visé, pas en modifiant le chiffre — et les deux fois
la seule exécution a trouvé un défaut produit qu'aucun portail ne voyait :
DeviceInfo.via_device (le tuple, retiré au profit de via_device_id), qui
faisait échouer l'ajout de six plateformes sur sept, et l'amplification par dix
des demandes de rafraîchissement du planificateur. C'est la leçon du §11 sous sa
forme la plus courte : un plancher qu'on ne lance pas est une affirmation, pas
un plancher.
Un effet de bord du plancher mérite d'être retenu : à partir de 2026.3, le
dossier brand/ local est servi par le frontend (voir le commentaire de
BRAND_LOGO_URL dans const.py), donc toute instance supportée affiche la
marque nativement. C'est ce qui rend envisageable le retrait de l'image
markdown de la description de la première étape du flux, sous réserve d'une
vérification visuelle.
12.14 Sept blueprints, pas six¶
La spécification §2.3 énumère six blueprints. Le dépôt en livre sept par
langue, soit quatorze fichiers dans blueprints/automation/pronote_ng/{fr,en}/ :
wake_up_alarm, lesson_canceled, homework_reminder, new_grade,
absence_alert, canteen_menu — les six prévus — plus new_message, qui
couvre l'arrivée d'un message de la messagerie ou d'une actualité publiée par
l'établissement.
C'est un ajout, pas une divergence de conception : il exploite les déclencheurs
message_received et information_added qui existaient déjà au catalogue, et
la spécification exige un test par blueprint, ce qui vaut pour le septième
comme pour les six autres.
12.15 Divergences que le code a déjà consignées¶
Ces écarts-là ne sont pas des défauts : le code a corrigé la spécification et l'a dit. Ils sont listés ici parce qu'un lecteur qui part des documents de conception les rencontrera.
- La stratégie de session est inversée. La spécification v1 fermait la
session entre les batches, sur un modèle de coût faux trois fois de suite.
Le défaut est maintenant
LAZY, avec l'argument de dominance et sa dégradation bornée (§5.2).PER_BATCHreste offert comme échappatoire. - La règle de détection de changement est dédoublée. La règle unique
« delta sur
N» de la v1 ne pouvait pas implémenter le cours modifié (§7.4). - Le client durci est un prérequis, pas une amélioration (§6.4).
- Les paliers sont passés de treize à dix, en repliant la semaine d'emploi
du temps dans le palier
timetable— puisqueClient.lessons()facture par semaine — et en fusionnant les trois jeux de données dePagePresence. - Deux identifiants d'onglet de la v1 étaient faux et sont corrigés avec un
avertissement pour qu'un futur lecteur ne les « répare » pas en arrière
(
FUNC_NEWS,const.py) : la lecture du fil d'actualités estPageActualiteset nonSaisieActualites, qui est l'écriture ; la liste des discussions estListeMessagerie. - Le budget par défaut est passé d'environ 423 à environ 180 requêtes par jour, et le plafond journalier est fixé à 2000 comme filet de sécurité contre un défaut logiciel, soit un facteur onze.
- Le palier
historycoûte 8 requêtes et non 6. L'annexe déduisait trois requêtes par période close ; il en faut quatre, parce queDernieresEvaluations201 y manquait (REQUESTS_PER_BATCH,options.py). Compter depuis le code bat compter depuis la prose. À noter que l'annexe a depuis été corrigée et affiche bien 8, avec les quatre onglets nommés : le commentaire du code qui reproche à l'annexe d'avoir déduit 6 est donc lui-même périmé, et c'est la seule contradiction interne encore ouverte entre les deux documents. - Le plafond de connexions journalières est 24 et non 120 : le design
attend une à trois connexions par jour, et 120 ne pourrait pas attraper le
défaut pour lequel le plafond existe (
const.py). LimiterStatene contient pas d'ip_suspended, et la réparation de bootstrap ne nomme aucune cause, parce queif "IP" in htmlne peut pas en établir une (§6.4).- Le palier
discussionsétait budgété à une requête ; la spécification n'avait pas anticipé queDiscussion.messagesposte à chaque lecture. Le développement sélectif plafonné à trois fils est la réponse (§4.2). - Le palier
staticne collecte plus l'URL iCal, pour la raison de §10.2.