Cette page s'adresse à quelqu'un qui reprend le projet — ou à son auteur dans six
mois.
Application web unique, servie par un seul processus Uvicorn. Pas de SPA, pas
d'étape de compilation JavaScript, pas de service séparé.
navigateur
│ HTML complet, puis fragments HTML (HTMX)
▼
┌──────────────────────────────────────────────────────────┐
│ web/ écrans : routes + gabarits Jinja │ ← présentation
│ api/ endpoints JSON (plannings, santé) │
├──────────────────────────────────────────────────────────┤
│ services/ toute la logique métier │ ← décisions
│ sync, plannings, controls, validation, │
│ generator, simulation, deployment, │
│ periods, grid, sensors, accounts, backup, │
│ reset, journal, dashboard, connectivity │
├──────────────────────────────────────────────────────────┤
│ ha/ dialogue Home Assistant │ ← extérieur
│ client, guard, models, errors, simulator │
│ models/ tables SQLAlchemy │ ← état
└──────────────────────────────────────────────────────────┘
│ │
▼ ▼
SQLite / PostgreSQL Home Assistant (REST + WebSocket)
Règle de dépendance : web/ et api/ appellent services/ ; services/
appelle ha/ et models/. Jamais l'inverse. Un écran ne construit pas de YAML
et n'ouvre pas de connexion : il demande à un service.
Conséquence pratique : la logique se teste sans écran et sans réseau. Le
générateur est un module pur, le validateur aussi — on lui passe le constat déjà
formulé (« cette entité ne répond pas »), il ne sait pas interroger la base.
| Module | Rôle |
|---|---|
services/generator.py |
traduit une période en automatisations. Identifiants déterministes (UUID v5) pour que les diffs restent lisibles |
services/validation.py |
quatorze règles de cohérence, trois niveaux |
services/simulation.py |
rejoue une journée pour en déduire l'état de chaque équipement heure par heure |
services/grid.py |
mise en page pure de la grille : 76 créneaux de 15 minutes, de 5 h à minuit |
ha/guard.py |
le garde-fou du préfixe. Toute écriture et toute suppression passent par lui |
ha/simulator.py |
une fausse instance Home Assistant complète, REST et WebSocket |
runtime_config.py |
réglages modifiables depuis l'interface, persistés hors base, secrets chiffrés |
timeutils.py |
la doctrine du temps : heures d'horloge d'un côté, instants de l'autre |
16 tables, 3 migrations Alembic.
Matériel — le miroir de Home Assistant et le référentiel métier
entities · equipments
Planning — ce qu'on dessine
plannings · day_templates · planning_weekday_assignments · steps · actions
Contrôle — les routines environnementales
control_rules · control_conditions · control_actions · planning_controls
Application dans le temps
periods · day_overrides · deployments
Exploitation
users · audit_logs
Equipment.entity_id n'est pas une clé étrangère. Le lien estEquipment.label est ce queentity_id n'est qu'un détail technique affiché enkind.Action.enabled = false conserve la ligne, visible et documentée, maisStep.time, Period.date_start etDayOverride.day sont des heures et dates d'horloge, stockées en clair :created_at,last_seen, last_login) sont des instants, conservés en UTC via un typePas seulement par le code applicatif :
Une étape à 07:15 sur le jour-type SEMAINE, dans une période du 15 au 28 septembre :
- id: scnstudio_1fee426f50dc5306ab0846cfe26d179a
alias: '[SCN] Absence vacances / SEMAINE / 07:15 / Réveil chambre 1'
description: Généré par Scénario Studio — ne pas éditer à la main
mode: single
trigger:
- platform: time
at: '07:15:00'
condition:
- condition: template
value_template: '{{ ''2026-09-15'' <= now().strftime(''%Y-%m-%d'') <= ''2026-09-28'' }}'
- condition: time
weekday: [mon, tue, thu, fri]
action:
- delay:
seconds: '{{ range(0, 420) | random }}'
- service: cover.open_cover
target:
entity_id: cover.volet_chambre1
Trois détails qui ont demandé de la réflexion :
18:45:00 nonVoir Routines de contrôle.
| Règle | Tenue par | Vérifiée par |
|---|---|---|
| Ne jamais toucher une automatisation étrangère | ha/guard.py : préfixe scnstudio_ et alias [SCN], contrôlés avant chaque écriture et chaque suppression |
un test prouve qu'une suppression d'identifiant non préfixé lève une erreur |
Jamais de set_cover_position |
aucune action du domaine ne l'exprime | test sur le YAML produit |
| Une seule période dans Home Assistant | le déploiement retire les autres avant d'écrire | index unique partiel, et tests de bascule |
| Un appel HA échoué n'écrase pas le déploiement précédent | enregistré en PENDING, puis confirmé ou marqué en échec |
tests avec une instance qui refuse |
| Les heures restent des heures d'horloge | type Time pour les plannings, UtcDateTime pour les instants |
test dédié au changement d'heure |
| Une action désactivée reste visible mais n'est pas générée | enabled=false conservé, filtré à la génération |
tests |
| Autorisation fermée par défaut | le rôle requis est déduit du chemin et de la méthode | tests par rôle |
| Aucun secret dans le dépôt | .env ignoré, secrets du fichier de réglages chiffrés |
— |
Python 3.12. Aucune étape de compilation front, aucun paquet npm.
| Brique | Rôle | Pourquoi celle-là |
|---|---|---|
| FastAPI | routage, injection de dépendances, validation | l'injection sert à passer session et client HA aux routes, ce qui rend les tests remplaçables sans bricolage |
| Uvicorn | serveur ASGI | un seul processus à lancer |
| SQLAlchemy 2 (asyncio) | ORM et requêtes | Mapped[] typé, contrôle fin des stratégies de chargement — indispensable en asynchrone |
| Alembic | migrations | env.py asynchrone, mode « batch » pour SQLite |
| aiosqlite / asyncpg | pilotes | SQLite pour un usage personnel, PostgreSQL quand plusieurs personnes écrivent |
| Pydantic 2 / pydantic-settings | schémas d'API et configuration | la configuration est validée au démarrage, pas découverte en cours de route |
| httpx | API REST de Home Assistant | son MockTransport permet de simuler une instance entière sans réseau |
| websockets | API WebSocket de Home Assistant | les registres — pièces, appareils — ne sont accessibles que là |
| Authlib | OpenID Connect (Keycloak) | Authorization Code + PKCE, URLs issues de la découverte automatique |
| cryptography | chiffrement des secrets persistés | clé dérivée par HKDF depuis SECRET_KEY, chiffrement Fernet |
| Jinja2 | gabarits HTML | rendu côté serveur |
| HTMX | fragments HTML sans SPA | le serveur renvoie du HTML ; pas d'état dupliqué entre client et serveur |
| Alpine.js | interactions locales | panneaux, filtres, glisser-déposer |
| Tailwind (CDN) | styles | classes utilitaires, sans build |
| PyYAML | production du YAML | avec un représenteur maison pour le quotage des heures |
| itsdangerous | signature du cookie de session | — |
| pytest / pytest-asyncio | tests | asyncio_mode=auto |
| ruff / mypy | lint, format, typage | mypy non strict mais check_untyped_defs |
Le mot de passe local est haché avec scrypt de la bibliothèque standard
(N = 2¹⁵), paramètres inscrits dans l'empreinte. Aucune dépendance
supplémentaire pour cela.
1014 tests, et une exigence tenue depuis le début : la suite tourne sans
instance Home Assistant, sans base préexistante et sans fichier de
configuration.
MockTransport de httpx et un faux WebSocket. Elle contient les pièges quitest_aucune_derive_entre_modeles_et_migrations, échoue dès qu'un modèle change.env déroutés vers des chemins jetables.make check # ruff + mypy + pytest
make fmt # formatage et corrections automatiques
make test # tests seuls
git clone https://gitlab.ev1.fr/ev1/domodrive.git
cd domodrive
make install # crée .venv et installe le projet
cp .env.example .env # puis renseigner les valeurs
make migrate # crée la base SQLite
make seed # jeu de données de démonstration (facultatif)
make run # http://127.0.0.1:8000
make help liste les commandes disponibles.
https://gitlab.ev1.fr/ev1/domodrive — dépôt privé, l'accès se demande à son
propriétaire.
Documentation Domodrive 1.0.0 — copie conforme du répertoire docs/ du dépôt gitlab.ev1.fr/ev1/domodrive (dépôt privé).