Vue d'ensemble de l'architecture¶
Ce document décrit l'architecture technique de Sowel : la stack technique, la structure du projet, le pipeline réactif, les concepts métier clés, et le design system.
Stack technique¶
Backend¶
| Technologie | Rôle |
|---|---|
| Node.js 24+ | Runtime |
| TypeScript (strict mode) | Langage |
| Fastify | Framework HTTP |
| SQLite (better-sqlite3) | Base de données principale (API synchrone, mode WAL) |
| InfluxDB 2.x | Stockage de séries temporelles (historique, énergie) |
| ws | Serveur WebSocket |
| mqtt.js | Client MQTT pour les intégrations device |
| pino | Logs JSON structurés |
Frontend¶
| Technologie | Rôle |
|---|---|
| React 18+ | Framework UI |
| TypeScript | Langage |
| Vite | Build tool et serveur de développement |
| Tailwind CSS | Styling (utility classes uniquement, pas de CSS personnalisé) |
| Zustand | Gestion d'état |
| Lucide React | Bibliothèque d'icônes (stroke 1.5px) |
Infrastructure¶
| Technologie | Rôle |
|---|---|
| Docker + docker-compose | Déploiement conteneurisé |
| PM2 | Gestion de processus (production) |
Concepts métier clés¶
| Terme | Rôle |
|---|---|
| Device | Matériel physique, auto-découvert depuis les intégrations. Expose des Data brutes et des Orders. |
| Equipment | Unité fonctionnelle visible par l'utilisateur. Se lie à un ou plusieurs Devices. Peut avoir des Data calculées et dispatcher des Orders. |
| Zone | Regroupement spatial (arbre imbriquable). Auto-agrège les Data des Equipments (motion=OR, temperature=AVG, lightsOn=COUNT, etc.). |
| Scenario | Règle d'automatisation : trigger(s) -> condition(s) -> action(s). |
| Recipe | Modèle de Scenario réutilisable avec slots de paramètres typés. |
| Mode | État nommé (par ex. "Night", "Away") avec impacts au niveau zone. Activable manuellement, par calendrier, ou par appui sur un bouton. |
Principe directeur : un Device est ce qui est sur le réseau. Un Equipment est ce qui est dans la pièce.
Pipeline réactif¶
Le flux de données central est entièrement piloté par les événements. Chaque message d'intégration se propage à travers toute la stack :
Integration message (MQTT, cloud API poll, etc.)
-> Integration Plugin (receives + parses)
-> Device Manager (updates DeviceData)
-> Event Bus: "device.data.updated"
-> Equipment Manager (re-evaluates bindings + computed Data)
-> Event Bus: "equipment.data.changed"
-> Zone Manager (re-evaluates aggregations)
-> Event Bus: "zone.data.changed"
-> Recipe Engine (evaluates triggers -> conditions -> actions)
-> Actions may emit Orders -> Integration Plugin -> device
-> MQTT Publish Service (outbound to external brokers, with optional on-change filter)
-> Notification Publish Service (Telegram, etc.)
-> WebSocket pushes to UI clients
Event Bus¶
Le bus d'événements est un EventEmitter typé qui utilise les unions discriminées TypeScript (type EngineEvent). C'est la colonne vertébrale qui relie tous les managers. Règles clés :
- Tous les handlers doivent être non bloquants et ne jamais lever d'exception.
- Les événements sont batchés (intervalle de 200 ms) avant d'être envoyés aux clients WebSocket.
- Les événements de données à haute fréquence (
device.data.updated,equipment.data.changed,zone.data.changed) sont dédupliqués par batch : seule la dernière valeur par clé est envoyée.
Types d'événements¶
| Event | Payload | Quand |
|---|---|---|
device.discovered |
device: Device |
Nouveau device trouvé |
device.removed |
deviceId, deviceName |
Device supprimé |
device.status_changed |
deviceId, deviceName, status |
Online/offline |
device.data.updated |
deviceId, deviceName, dataId, key, value, previous |
Changement de propriété |
equipment.data.changed |
equipmentId, key, value, previous |
Donnée liée modifiée |
equipment.order.executed |
equipmentId, orderAlias, value, source? |
Ordre dispatché |
zone.data.changed |
zoneId, key, value, previous |
Donnée agrégée modifiée |
system.started |
-- | Démarrage moteur terminé |
system.integration.connected |
integrationId |
Intégration connectée |
system.integration.disconnected |
integrationId |
Intégration déconnectée |
settings.changed |
keys |
Réglages mis à jour |
mode.activated |
mode details | Mode activé |
mode.deactivated |
mode details | Mode désactivé |
recipe.state_changed |
instance details | État de recette modifié |
activity.added |
item: ActivityItem |
Nouvel item d'activité bufferisé (spec 101) |
Structure du projet¶
sowel/
├── src/
│ ├── index.ts # Entry point
│ ├── config.ts # Env config loading
│ ├── core/ # event-bus, database (SQLite), influx, logger, settings-manager
│ ├── integrations/ # Integration plugins (zigbee2mqtt, panasonic-cc, mcz-maestro, ...)
│ ├── plugins/ # Plugin manager (third-party plugin loading)
│ ├── devices/ # Device manager, auto-discovery, category inference
│ ├── equipments/ # Equipment manager, bindings, computed engine, order dispatcher
│ ├── energy/ # Energy aggregator, tariff classifier (HP/HC)
│ ├── zones/ # Zone manager, auto-aggregation engine
│ ├── modes/ # Mode manager, calendar manager
│ ├── recipes/ # Recipe engine, built-in recipes (motion-light, switch-light)
│ ├── buttons/ # Button action bindings (physical button -> mode/order)
│ ├── charts/ # Saved chart configurations
│ ├── history/ # InfluxDB history writer and query helpers
│ ├── mqtt-publishers/ # Outbound MQTT publishing (broker manager, publisher manager, on-change filter)
│ ├── notifications/ # Notification channels (Telegram, etc.)
│ ├── ai/ # LLM integration (Claude/OpenAI/Ollama) -- V1.0+
│ ├── auth/ # JWT + API tokens, middleware, first-run setup
│ ├── users/ # User CRUD, preferences
│ ├── api/ # Fastify server, WebSocket handler, route files
│ │ ├── server.ts # Server setup and route registration
│ │ ├── websocket.ts # WebSocket handler with topic subscriptions
│ │ └── routes/ # One file per domain (auth, devices, zones, etc.)
│ └── shared/ # types.ts (all interfaces), constants.ts
├── ui/ # React frontend (separate Vite project)
│ └── src/
│ ├── store/ # Zustand stores (devices, equipments, zones, WebSocket)
│ ├── components/ # By domain: dashboard/, devices/, equipments/, zones/, scenarios/
│ ├── pages/ # Dashboard, Devices, Equipments, Zones, Scenarios, Settings
│ └── i18n/ # Internationalization (en.json, fr.json)
├── plugins/ # Third-party plugin install directory
├── recipes/ # Built-in Recipe JSON templates
├── migrations/ # SQLite migration SQL files
├── specs/ # Feature specifications (XXX-version-name/)
└── scripts/ # Maintenance & diagnostic scripts
├── energy/ # InfluxDB energy backfill, diagnostic, admin
└── logs/ # Log retrieval via API
Architecture Plugin V2 (actuelle)¶
Depuis la spec 053, toutes les intégrations et recettes sont des plugins distribués via GitHub. Plus rien n'est intégré nativement, une installation Sowel fraîche n'a aucun plugin et les télécharge à la demande depuis un registre.
Services centraux¶
| Service | Fichier | Rôle |
|---|---|---|
| PackageManager | src/packages/package-manager.ts |
Télécharge, installe, met à jour et supprime les packages (intégrations + recettes). Récupère les manifests depuis les releases GitHub. Tient l'état en base dans la table plugins. |
| PluginLoader | src/plugins/plugin-loader.ts |
Loader spécifique aux intégrations. Importe l'entrée JS du plugin (dist/index.js), appelle createPlugin, enregistre dans IntegrationRegistry. Auto-télécharge les fichiers du plugin au démarrage si manquants (par ex. après restauration d'un backup). |
| RecipeLoader | src/recipes/recipe-loader.ts |
Loader spécifique aux recettes. Même modèle que PluginLoader mais pour les paquets de recettes. |
| IntegrationRegistry | src/integrations/integration-registry.ts |
Registre runtime des intégrations connectées. Gère les start/stop avec staggering (pour éviter les appels simultanés aux APIs cloud). |
Modèle de distribution¶
Les plugins vivent dans des repos GitHub séparés (par ex. mchacher/sowel-plugin-zigbee2mqtt). Chaque release embarque un tarball pré-compilé. Le registre, la liste des packages disponibles, est récupéré depuis :
- Distant :
https://raw.githubusercontent.com/mchacher/sowel/main/plugins/registry.json(TTL de cache 1 h) - Fallback :
plugins/registry.jsonlocal livré dans l'image Docker
Flux d'installation :
- L'utilisateur clique sur "Installer" dans l'UI Admin → Plugins
- PackageManager appelle l'API releases GitHub du repo du plugin
- Télécharge le tarball de la dernière release et le manifest
- Extrait dans
plugins/<id>/sur le volumesowel-plugins - Insère une ligne dans la table SQLite
plugins - PluginLoader importe l'entrée et enregistre l'intégration
Le plugins/registry.json sur main est la source de vérité pour la liste officielle. N'importe quel utilisateur peut pointer vers son propre fork.
Niveaux de confiance (specs 089 + 136)¶
Trois niveaux de confiance déterminent d'où un package peut provenir et quelle ancre d'intégrité le protège :
| Niveau | Source de vérité | Ancre d'intégrité | Friction à l'installation |
|---|---|---|---|
| Officiel | Registre central, owner whitelisté | SHA256 du registre + revue mainteneur | Aucune |
| Communautaire | Registre central, owner tiers | SHA256 du registre + revue de la PR | Modal de confirmation unique |
| Personnel | Dépôt GitHub ajouté par l'admin (136) | SHA256 épinglé (TOFU) | Confirmation à l'installation et à chaque MAJ |
Les sources personnelles (spec 136) permettent à un admin d'enregistrer ses propres dépôts GitHub publics comme sources de plugins, sans passer par le registre central. PersonalSourceManager (src/packages/personal-sources.ts) possède la table plugin_sources et une vue en cache de la dernière release de chaque dépôt. La confiance est au premier usage : l'installation télécharge le tarball, affiche son SHA256 dans un modal de confirmation, puis épingle le hash dans plugins.pinned_sha256. Les mises à jour dont le tarball diffère du hash épinglé exigent une nouvelle confirmation ; les restaurations de backup revérifient les téléchargements contre le hash épinglé. Tout le durcissement de la spec 089 (vérification du hash, flags tar, refus des symlinks sortants) s'applique à l'identique, plus des contrôles propres au niveau personnel : le repo du manifest doit correspondre à la source, l'id du plugin ne doit pas masquer un id du registre, et la compatibilité sowelVersion est vérifiée. Les packages installés portent plugins.source (registry ou personal) ; la détection de version des packages personnels lit les releases du dépôt source, jamais le registre.
Format du manifest plugin¶
Chaque plugin embarque un manifest.json avec id, type (integration ou recipe), name, description, icon (nom Lucide), author, repo, version, tags. Voir plugin-development.md pour la spec complète.
Cycle de vie d'une intégration¶
- Load :
PluginLoader.loadAll()parcourt la tableplugins, importe chaque entrée activée, appellecreatePlugin(deps), enregistre dansIntegrationRegistry. - Start :
IntegrationRegistry.startAll()démarre les plugins séquentiellement avec de petits délais. Lestart()de chaque plugin se connecte, découvre les devices, démarre le polling. Le retour destart()ne signifie pas que le plugin est joignable : les connexions MQTT et les authentifications cloud se terminent ensuite, de façon asynchrone. - Connected : le registre échantillonne le
getStatus()de chaque plugin et émetsystem.integration.connected/system.integration.disconnectedà chaque transition. C'est le signal du moteur lui-même, indépendant de ce qu'un plugin choisit d'émettre, et c'est lui qui rend observable le retour d'une intégration (voir Livraison des ordres ci-dessous). - Runtime : le plugin pousse les données via
deviceManager.updateDeviceData(). Les ordres sortent viaplugin.executeOrder(). - Stop :
stop()annule les timers, ferme les connexions. - Update : Unload →
PackageManager.updateFiles()→ reload. - Uninstall : Unload →
PackageManager.removeFiles().
Les réglages d'intégration sont stockés en SQLite dans settings sous integration.<id>.<key>, configurés depuis l'UI.
Livraison des ordres quand une intégration est injoignable¶
Un ordre envoyé vers une intégration déconnectée n'atteint jamais le fil. Plutôt que d'être jeté avec une ligne de log, il est retenu par le tracker de confirmation d'ordre (spec 141) et rejoué une fois quand cette intégration se connecte, dans une fenêtre courte : une commande planifiée rejouée longtemps après son créneau serait pire que celle qui a été perdue. L'appelant reçoit toujours la même erreur qu'avant.
Les instances de recettes sont la raison pour laquelle cela se produisait à chaque redémarrage : elles démarrent tout à la fin du boot, derrière une attente bornée sur la connexion des intégrations, car une instance évalue et envoie des ordres dès son démarrage. L'attente est plafonnée pour qu'une intégration cloud injoignable ne bloque pas tous les automatismes, et ce qui passe malgré tout est rattrapé par la rétention décrite ci-dessus. L'API et la liste des recettes n'attendent pas ; seul l'état des instances en cours d'exécution attend.
Écosystème actuel des plugins officiels¶
| Plugin | Repo | Type |
|---|---|---|
zigbee2mqtt |
mchacher/sowel-plugin-zigbee2mqtt |
integration |
lora2mqtt |
mchacher/sowel-plugin-lora2mqtt |
integration |
panasonic_cc |
mchacher/sowel-plugin-panasonic-cc |
integration |
mcz_maestro |
mchacher/sowel-plugin-mcz-maestro |
integration |
legrand_control |
mchacher/sowel-plugin-legrand-control |
integration |
legrand_energy |
mchacher/sowel-plugin-legrand-energy |
integration |
netatmo_weather |
mchacher/sowel-plugin-netatmo-weather |
integration |
netatmo-security |
mchacher/sowel-plugin-netatmo-security |
integration |
weather-forecast |
mchacher/sowel-plugin-weather-forecast |
integration |
smartthings |
mchacher/sowel-plugin-smartthings |
integration |
motion-light |
mchacher/sowel-recipe-motion-light |
recipe |
motion-light-dimmable |
mchacher/sowel-recipe-motion-light-dimmable |
recipe |
switch-light |
mchacher/sowel-recipe-switch-light |
recipe |
presence-heater |
mchacher/sowel-recipe-presence-heater |
recipe |
presence-thermostat |
mchacher/sowel-recipe-presence-thermostat |
recipe |
state-watch |
mchacher/sowel-recipe-state-watch |
recipe |
state-trigger-light |
mchacher/sowel-recipe-state-trigger-light |
recipe |
La liste live est dans plugins/registry.json à la racine du repo.
Architecture base de données¶
SQLite¶
- Bibliothèque :
better-sqlite3avec API volontairement synchrone (rapide, pas de surcharge de callbacks). - Mode WAL :
PRAGMA journal_mode=WALpour la lecture/écriture concurrente. - Migrations : fichiers SQL dans
migrations/exécutés automatiquement au démarrage. - Transactions : utilisées pour les opérations en lot.
- IDs : UUID v4 via
crypto.randomUUID(). - Dates : format ISO 8601 partout.
InfluxDB¶
Les données d'énergie et d'historique transitent dans un pipeline multi-buckets :
sowel (raw) -- 7-day retention -- raw data points
| task: sowel-energy-sum-hourly (every: 1h, lookback: -7h)
sowel-energy-hourly -- 2-year retention -- hourly sums
| task: sowel-energy-sum-daily (every: 1d, lookback: -2d)
sowel-energy-daily -- 10-year retention -- daily sums
Des buckets downsamplés supplémentaires (sowel-hourly, sowel-daily) existent pour les séries temporelles non énergétiques.
InfluxDB est obligatoire : Sowel se connecte au démarrage et auto-crée les buckets, les tâches de downsampling, et les tâches d'agrégation énergétique.
Où l'effet d'un ordre est observé¶
Un executeOrder qui réussit signifie que l'ordre a atteint l'intégration, pas que l'appareil a agi : le tracker guette donc l'apparition de la valeur ordonnée et alerte si elle ne vient pas. La lecture qu'il surveille est résolue dans cet ordre : une liaison de donnée portant l'alias de l'ordre sur l'appareil même auquel l'ordre a été envoyé ; à défaut, la liaison sur un autre appareil, si son type peut rapporter la valeur ordonnée ; à défaut, la donnée propre de l'appareil ciblé sous la clé de l'ordre, là où un thermostat cloud publie un état que personne n'a lié. Quand aucune des trois n'existe, l'ordre reste suivi et rejoué, mais il sort de la surface d'alarme : une alarme que rien ne peut résoudre est du bruit.
La règle du milieu existe parce qu'un alias n'est pas un vocabulaire. Sur un appareil sous-compté, power désigne à la fois le booléen envoyé à l'appareil et la puissance lue par une pince, et comparer les deux ne peut jamais être vrai (issue #901).
Les deltas d'énergie sont cumulés, jamais échantillonnés¶
Un binding energy porte un delta additif (Wh depuis le tick précédent), pas une mesure. La déduplication qui protège les autres catégories (deadband, intervalle minimum de 30 s) détruirait silencieusement de l'énergie : les compteurs ont des cadences très différentes — un Shelly EM émet un tick par minute, un Tuya PJ-1203A en émet ~30, dont un seul porte le saut de compteur de 10 Wh.
Le HistoryWriter cumule donc les ticks energy temps réel sur la minute et écrit un point unique aligné sur le début de minute, découpage HP/HC compris. Le SelfConsumptionWriter cumule de la même façon la minute appairée Réseau + Solaire et en dérive autoconso / injection / consommation foyer.
Un seul écrivain par série¶
Les deux writers ferment leur bucket de minute sur des déclencheurs différents : le HistoryWriter par binding, le SelfConsumptionWriter au premier tick de la minute suivante venant de l'un ou l'autre compteur. Partager une série entre eux serait donc une course au dernier écrivain, arbitrée par le compteur qui émet le premier. La propriété des séries energy / energy_hp / energy_hc du compteur réseau est de ce fait exclusive :
- Avec un
energy_production_meterconfiguré, leSelfConsumptionWriteren est le seul écrivain (sémantique foyer), et leHistoryWriterignore exactement ces trois alias sur lemain_energy_meter. Il continue d'écrire tout le reste : puissance, tension,energy_forward/energy_reverse, tous les sous-compteurs, et l'énergie propre du compteur solaire. - Réseau seul (pas de compteur de production), le
SelfConsumptionWriterest inerte et leHistoryWriterécrit l'énergie réseau cumulée brute, comme avant.
La propriété suit le cache d'équipements : ajouter ou retirer le compteur de production à chaud transfère la série sans redémarrage.
Deux conséquences à connaître :
- Toute minute ayant vu un tick réseau est écrite. Une minute sans tick solaire n'est pas un trou : c'est un import réseau pur (
autoconso = 0). Seule une minute solaire-seule est abandonnée — l'injection est indéfinie sans le côté réseau. - Les ticks porteurs d'un
sourceTimestampexplicite (plugins publiant des fenêtres historiques alignées, ex. fenêtres 30 min Netatmo/Legrand) sont déjà agrégés : leHistoryWriterles écrit tels quels et le découpage HP/HC est classifié sur 30 minutes, et non sur les 60 s d'un bucket temps réel.
Authentification et autorisation¶
- Mots de passe : bcrypt (cost 12).
- JWT : HS256 via
jsonwebtoken. TTL access token : 15 min. TTL refresh token : 30 jours. - Tokens d'API : préfixe
swl_, hash SHA-256 stocké, généré viacrypto.randomBytes(32). Préfixes legacywch_etcbl_aussi acceptés. - Middleware d'auth : tente d'abord le décodage JWT, puis le lookup de token d'API.
- Rôles :
admin>standard>viewer(permissions hiérarchiques). - Setup au premier démarrage :
POST /api/v1/auth/setupcrée le premier utilisateur admin.
Architecture frontend¶
Gestion d'état¶
- Stores Zustand par domaine : devices, equipments, zones, modes, recipes, etc.
- Les stores sont mis à jour en temps réel par les événements WebSocket.
- WebSocket auto-reconnect avec récupération d'état (incrémentale ou complète).
Styling¶
- Tailwind CSS utility classes uniquement, pas de fichiers CSS personnalisés.
- Design responsive mobile-first (breakpoints : 640 px, 1024 px).
- Dark mode via la stratégie
classde Tailwind, indispensable pour un usage de tableau de bord la nuit.
Internationalisation¶
- Anglais et français pris en charge.
- Fichiers de locale :
ui/src/i18n/locales/en.json,ui/src/i18n/locales/fr.json. - Les traductions de recettes voyagent avec la classe de la recette (voir le champ
i18n), pas dans les fichiers de locale de la plateforme.
Design system¶
| Propriété | Valeur |
|---|---|
| Police body | Inter |
| Police mono | JetBrains Mono (valeurs, logs) |
| Couleur primaire | #1A4F6E (ocean blue), hover : #13405A, light : #E6F0F6 |
| Couleur accent | #D4963F (amber), hover : #BB8232 |
| Espacement de base | 4px |
| Border radius | 6px (boutons), 10px (cartes), 14px (modales) |
| Taille de police body | 14px (tableau de bord dense) |
| Valeurs de données | 28px (lisibles d'un coup d'œil) |
| Icônes | Lucide React, stroke 1.5px |
Backup et restauration¶
Les backups capturent l'état complet du système dans une seule archive ZIP et le restaurent atomiquement.
Service¶
BackupManager dans src/backup/backup-manager.ts est le service central. Il est appelé par :
- Routes HTTP
GET/POST /api/v1/backup(export/import manuel) - UpdateManager (backup automatique pré-update, voir la section auto-update)
- Routes de backup local
GET /api/v1/backup/local,POST /api/v1/backup/restore-local
Format d'archive¶
Un ZIP de backup contient :
| Entrée | Contenu |
|---|---|
sowel-backup.json |
Export SQLite en JSON, structuré par table (format version 2) |
influx-raw.lp |
Données InfluxDB brutes en line protocol (7 derniers jours) |
influx-hourly.lp |
Données horaires downsamplées (90 derniers jours) |
influx-daily.lp |
Données journalières downsamplées (5 dernières années) |
influx-energy-hourly.lp |
Sommes énergétiques horaires (2 dernières années) |
influx-energy-daily.lp |
Sommes énergétiques journalières (10 dernières années) |
data/* |
Tous les fichiers non DB de data/ (secrets de tokens, etc.), scannés dynamiquement au regard de la liste blanche de restauration, hors .db, .pid, .log et hors marqueurs locaux .instance-id / .shadow-target |
L'export JSON SQLite couvre une liste sélectionnée de tables (constante BACKUP_TABLES dans backup-manager.ts) dans l'ordre des dépendances (parents d'abord pour la restauration).
Le marqueur .instance-id est exclu volontairement, dans les deux sens : il décrit le déploiement en cours d'exécution, et une archive qui l'embarquerait donnerait à l'instance qui restaure l'identité de la machine d'où viennent les données, désarmant le garde-fou de restauration de l'issue #401. Voir la section « Restaurer un backup d'un autre déploiement » de deployment.fr.md.
À la restauration, les entrées data/ passent par une liste blanche d'extensions (spec 089 C2) : tout ce qui n'est pas une extension de données ou d'image connue est refusé, pour qu'une archive ne puisse pas glisser un script ou un module natif dans le répertoire de données. Un nom de fichier commençant par un point compte comme sa propre extension, c'est ainsi que .jwt-secret et .influx-token figurent dans cette liste et que tout autre fichier caché est refusé. Un nom sans aucune extension est refusé également (issue #829).
Le scan d'export applique la même liste blanche, si bien qu'une archive ne peut jamais contenir une entrée que sa propre restauration écarterait. C'est cette propriété que suppose la note de risque de la spec 089 ; sans elle, un fichier pouvait être archivé puis disparaître silencieusement à la restauration. Ce qui est laissé de côté est nommé dans le log d'export, et une restauration renvoie filesSkipped à côté de filesRestored, pour qu'une restauration partielle soit visible plutôt que muette.
Backups locaux (data/backups/)¶
Distinct de l'export manuel, BackupManager.exportToFile() écrit les backups dans data/backups/sowel-backup-<name>.zip sur le volume persistant. Utilisé par :
- UpdateManager avant tout self-update :
data/backups/sowel-backup-pre-v<version>-<timestamp>.zip - Rotation via
rotateLocalBackups(keep), conserve uniquement les N fichiers les plus récents
L'UI (Admin → Backup) liste les backups locaux et propose une restauration en un clic via POST /api/v1/backup/restore-local { filename }.
Flux de restauration¶
- Valide la structure du ZIP et le schéma JSON
- Désactive les contraintes FK (hors transaction, limitation SQLite)
- Supprime toutes les lignes en ordre inverse des dépendances (enfants d'abord)
- Insère les nouvelles lignes en ordre parent-first
- Exécute
PRAGMA foreign_key_check, abandonne la transaction si violations - S'assure que les buckets InfluxDB existent (
influxClient.ensureBuckets()etensureEnergyBuckets()) - POST chaque fichier
.lpvers InfluxDB/api/v2/writepar batches de 5000 lignes - Restaure les fichiers de données dynamiques
- Répond
restartRequired: true, l'utilisateur doit redémarrer Sowel pour recharger l'état
Voir la spec 060 pour le dernier design de backup et src/backup/backup-manager.ts pour l'implémentation.
Auto-update (spec 060)¶
Sowel peut se mettre à jour depuis l'UI quand il tourne sous docker compose. Le design contourne le paradoxe "le processus se tue lui-même" via un pattern de container helper (similaire à Watchtower).
Détection¶
VersionChecker dans src/core/version-checker.ts interroge https://api.github.com/repos/mchacher/sowel/releases/latest toutes les heures (et aussi à T+10s après le boot). Quand un semver plus récent est trouvé, il émet system.update.available sur l'EventBus, qui est diffusé aux clients UI via WebSocket. L'UI affiche un badge en temps réel. Un bouton manuel "Check now" appelle POST /api/v1/system/version/check qui force un poll immédiat.
GET /api/v1/system/version retourne { current, latest, updateAvailable, releaseUrl, dockerAvailable, composeManaged }. composeManaged est dérivé des labels du conteneur en cours (com.docker.compose.*) ; s'ils sont absents, l'auto-update est désactivé avec un tooltip.
Flux de mise à jour¶
UpdateManager dans src/core/update-manager.ts orchestre la mise à jour :
- Backup pré-update via
backupManager.exportToFile()→data/backups/sowel-backup-pre-v<X>-<ts>.zip - Rotation des backups (conserve les 3 plus récents)
- Détection du contexte compose depuis les labels du conteneur courant :
com.docker.compose.project.working_dir,com.docker.compose.project,com.docker.compose.service - Spawn d'un container helper via dockerode :
- Image :
docker:25-cli(intègredocker compose) - Mounts :
/var/run/docker.sock+ le compose working dir comme/workdir - Cmd :
sh -c "sleep 5 && docker compose pull <service> && docker compose up -d <service>" AutoRemove: true- Retour immédiat de l'API, le helper survit à la mort de Sowel
- L'UI affiche un overlay ("Updating...") pendant le swap, poll
/system/versiontoutes les 3 s - Au changement de version →
window.location.reload()
Pourquoi un helper ? Appeler dockerode.stop() sur le conteneur en cours depuis le processus en cours tue le runtime Node via SIGTERM avant que la séquence remove/create/start puisse s'exécuter. Le helper est un processus séparé dans un conteneur séparé qui survit au swap.
Prérequis sur l'hôte :
/var/run/docker.sockmonté dans le conteneur sowel- Le compose working dir doit être accessible depuis le système de fichiers de l'hôte (n'importe quel chemin de bind mount fonctionne, Sowel le lit depuis les labels du conteneur)
docker compose updoit utiliser un nom de fichier standarddocker-compose.yml/compose.yml(les noms non standards nécessitent-f, pas géré actuellement)
CI/CD et releases (spec 055)¶
Workflow GitHub Actions¶
.github/workflows/release.yml se déclenche sur les tags poussés correspondant à v*. Il exécute :
- job verify-release-notes : fait échouer le workflow avant tout build si le commit taggé n'a pas d'ancre de release notes (spec 108)
- job ci : typecheck, lint, tests (backend + UI)
- jobs docker : build
linux/amd64etlinux/arm64avec Buildx en parallèle, chacun poussant son propre tag (:<version>, et:<version>-arm64plus:latest-arm64pour le job arm64) - job promote-manifest : fusionne les deux en un
:<version>multi-architecture, puis fait pointer:latestdessus - job release : crée la GitHub Release avec des notes auto-générées
- job arm64-required : fait échouer le run quand la release a été retenue, pour qu'un échec arm64 ne soit pas juste un job grisé sur un run vert
- job prune-ghcr : conserve les 30 versions GHCR les plus récentes après une release réussie, sans jamais toucher aux tags flottants
:latest
Le build Docker est multi-architecture : amd64 et arm64 sont construits dans des jobs parallèles puis fusionnés en un seul manifeste, de sorte que docker pull résout la bonne image.
L'ordre des trois dernières étapes est une exigence de correction, pas de confort. UpdateChecker interroge la GitHub Release et non le manifeste GHCR : une Release publiée alors que :<version> est encore amd64-only annoncerait une mise à jour à tous les Raspberry Pi puis ferait échouer leur docker pull. promote-manifest et release sont donc conditionnés à la réussite du build arm64, et :latest n'est publié que par promote-manifest, jamais par le job amd64. Un échec arm64 laisse :<version> poussé en amd64-only et retient à la fois :latest et la Release : rien n'annonce une version que la moitié du parc ne peut pas récupérer. La récupération passe par Re-run all jobs sur ce run, jamais par un dispatch du workflow sur le tag : le chemin dispatch résout la version depuis test_tag, donc il construit des images de test et ne publie rien sous la version du tag.
Script de release¶
scripts/release.sh <version> :
- Valide le format semver et un working tree propre
- Vérifie que
package.jsonetui/package.jsonsont déjà à cette version, et sort sinon. Il ne les bump pas : c'est une PR normale qui le fait, et elle est mergée avant le tag - Tag
vX.Y.Zet pousse le tag vers origin - GitHub Actions prend le relais
Il ne lance aucune validation de son côté et ne commite pas. La protection de branche impose que le bump de version et les release notes passent d'abord par une PR.
Une skill Claude Code emballe ça dans .claude/skills/sowel-release/SKILL.md.
Image Docker (Dockerfile)¶
Build multi-étages :
- backend-build : Node 24,
tscbackend - ui-build : Node 24, build Vite UI
- runtime : Debian Trixie (pour Python 3.13), Node 24 installé via NodeSource, Python 3.13 + venv pour les plugins qui en ont besoin (par ex. Panasonic CC),
better-sqlite3recompilé pour la plateforme
L'image runtime fait environ 950 Mo non compressée (environ 210 Mo de contenu). L'exigence Python 3.13 vient du plugin Panasonic CC qui a besoin d'une syntaxe f-string indisponible en Python 3.11.
Activity Buffer (spec 101)¶
src/activity/activity-buffer.ts garde les 7 derniers jours d'événements moteur (filtrés et enrichis pour la vue zone, plafonnés à 2000 items en mémoire), persistés dans la table activity_log et rechargés au démarrage. Il alimente le panneau Activité dans la vue zone (guide utilisateur).
Flux d'événements¶
- Le buffer souscrit à un sous-ensemble curé d'
EngineEvents :equipment.order.executed,equipment.data.changed(filtré sur la catégorie de bindingmotion,water_leak,smoke),recipe.instance.started/stopped/error,mode.activated/deactivated,sunlight.changed,system.alarm.raised,system.alarm.resolved. Une résolution est classée dans la zone portée par son événement, à défaut dans celle de la levée vue par le buffer (globale s'il n'a ni l'une ni l'autre, par exemple une alarme levée avant un redémarrage). - Pour chaque événement, il résout le nom d'équipement / de recette et le
zoneIdpertinent via les managers (equipment, recipe, zone, sunlight), puis construit unActivityItem. - Il pousse l'item dans le ring buffer (cap par count, purge des entrées dépassant le TTL) et émet un événement
activity.addedsur le bus. - La couche WebSocket diffuse cet événement aux clients abonnés au topic
activity. Les clients bootstrapent également viaGET /api/v1/activityau montage.
Attribution des sources¶
executeOrder() accepte un 4e argument optionnel source de type OrderSource. Le SDK des recettes expose un closure ctx.dispatchOrder() par instance qui pré-lie le source de la recette, ce qui évite aux helpers internes d'avoir à threader source eux-mêmes. Les modes, les bindings de boutons et les routes API passent le source inline. Les plugins de recettes externes continuent de fonctionner sans attribution (dégradation gracieuse).
Empreinte mémoire¶
À environ 400 octets par item, 2000 items représentent environ 800 Ko, soit moins de 0,3 % du RSS d'un conteneur Sowel typique. Contrairement au ring buffer des logs, le fil survit au redémarrage : il est adossé à SQLite (spec 147).
Logging¶
Stratégie¶
Logs JSON structurés pino avec sortie multistream (voir src/core/logger.ts) :
- Ring buffer : buffer circulaire en mémoire pour le viewer de logs de l'UI (capture toujours au niveau debug)
- stdout : JSON brut en production (capté par les Docker logs), pino-pretty en développement
- Transport fichier : en production uniquement, via
pino-rollversdata/logs/sowel.<yyyy-MM-dd>.N.log, rotation journalière, conserve 14 fichiers (la rétention couvre aussi les fichiers laissés par les conteneurs précédents)
Emplacement des fichiers de log¶
/app/data/logs/sowel.<yyyy-MM-dd>.N.log à l'intérieur du conteneur (sur le volume sowel-data). Un jour calendaire correspond à un fichier prévisible, même après redémarrages et auto-mises à jour. Survit à la recréation du conteneur, indispensable pour l'investigation post-incident après une auto-mise à jour.
Exemple de récupération :
docker exec sowel sh -c 'cat /app/data/logs/sowel.2026-04-11.1.log | grep -E "2026-04-11T07:" | grep error'
Avant la v1.39, les fichiers étaient nommés sowel.N.log avec un numéro de rotation choisi par processus : plusieurs redémarrages dans la journée éclataient les plages horaires entre fichiers, et un même fichier pouvait couvrir plusieurs mois. Pour investiguer un incident antérieur au changement de format, grepper TOUS les fichiers sowel.*.log plutôt que de se fier à un seul. Les anciens fichiers numérotés sont invisibles pour la nouvelle rétention : le moteur les purge automatiquement au démarrage dès qu'ils ont plus de 14 jours, aucune action manuelle n'est nécessaire.
Conseils sur les niveaux de log¶
| Niveau | Usage |
|---|---|
fatal |
Crash de processus imminent |
error |
Opération échouée, le moteur continue (toujours avec { err }) |
warn |
Dégradation auto-réparable (reconnexion, données obsolètes) |
info |
Événements métier significatifs, un par opération |
debug |
Détail de dépannage pour les développeurs |
trace |
Chemin chaud à haut volume (chaque événement, chaque message MQTT) |
Conventions :
- Chaque module crée un child logger avec
{ module: "module-name" } - Contexte structuré comme premier argument objet :
logger.info({ deviceId, status }, "Device status changed") - Mots de passe/tokens/secrets sont auto-redactés par la config pino
- N'utilisez jamais
console.*, ça contourne le ring buffer, la rotation des fichiers et la redaction
Helpers de récupération¶
- Depuis l'UI : page Admin → Logs (lit le ring buffer)
- Via API :
GET /api/v1/logs?module=X&level=Y&limit=N(ring buffer uniquement, perdu au redémarrage) - Depuis le fichier :
docker execdans/app/data/logs/sowel.*.log(persistant) - Script helper :
scripts/logs/fetch-logs.py <module> <level> <limit>avec les variables d'envSOWEL_URL+SOWEL_PASSWORD
Gestion des fuseaux horaires (spec 061)¶
La logique backend de Sowel dépend fortement de l'heure locale : créneaux cron du calendrier (croner), classification tarifaire HP/HC énergie, frontières de jour énergétique, affichage des levers/couchers de soleil, notifications. Tout utilise les méthodes natives Date qui dépendent de process.env.TZ.
Stratégie de détection¶
Au démarrage, src/core/timezone.ts détermine le fuseau horaire avec cette priorité :
- Variable d'env
TZ: si elle est définie dansdocker-compose.ymlou dans l'env de l'hôte, Sowel la respecte (l'override explicite l'emporte) home.latitude/home.longitude: si elles sont configurées dans les Réglages, Sowel les passe àtz-lookuppour dériver le nom IANA du fuseau (par ex.Europe/Paris)- Fallback UTC : avec un log WARN bruyant invitant l'utilisateur à configurer une localisation maison
process.env.TZ est défini avant createLogger() dans src/index.ts. C'est crucial : le premier appel new Date() de pino met en cache le TZ dans V8, et les changements de process.env.TZ après ça n'ont aucun effet sur les méthodes Date.prototype déjà chargées. Voir la séquence de boot dans src/index.ts.
Redémarrage requis après changement de localisation¶
Node met le TZ en cache à la première utilisation. Si l'utilisateur change home.latitude / home.longitude dans les Réglages au runtime :
- La route des réglages logge un warn et émet
system.restart_requiredsur l'EventBus - L'UI reçoit l'événement via WebSocket et affiche
RestartToastavec un bouton "Redémarrer maintenant" - Cliquer sur le bouton appelle
POST /api/v1/system/restartqui spawne un container helperdocker:25-cli(même pattern que la spec 060 self-update) qui exécutedocker compose up -d sowel - Le helper survit à la mort de Sowel et recrée le conteneur, qui prend la nouvelle env et ré-exécute
detectTimezone()avec les nouvelles coordonnées - L'
UpdateOverlayexistant recharge l'UI à la reconnexion WS
Exposition du TZ dans l'UI¶
GET /api/v1/system/timezoneretourne{ tz, source, offsetHours }à tout utilisateur authentifiéui/src/store/useTimezone.tsmet le résultat en cache dans un store Zustand, fetché une fois au montage de l'app depuisAppLayout.tsx- La section Réglages → Maison affiche le TZ en lecture seule avec son label de source (auto / env / fallback)
- La
CurrentTimePilldans le bandeau d'en-tête affiche l'heure de la maison (pas l'heure locale du navigateur), calculée viaIntl.DateTimeFormat(undefined, { timeZone: tz, ... }), utile quand on accède à Sowel depuis un appareil dans un autre fuseau
Voir la spec 061 sur github.com/mchacher/sowel/tree/main/specs/061-timezone-from-home-location.
Variables d'environnement¶
Tous les réglages sont optionnels avec des valeurs par défaut raisonnables, Sowel tourne sans configuration prête à l'emploi. Surchargez via .env au besoin :
| Variable | Défaut | Notes |
|---|---|---|
SQLITE_PATH |
./data/sowel.db |
Chemin de la base SQLite |
API_PORT |
3000 |
Port du serveur HTTP |
API_HOST |
0.0.0.0 |
Adresse de bind |
JWT_SECRET |
auto-généré | Persisté dans data/.jwt-secret au premier lancement |
JWT_ACCESS_TTL |
900 |
TTL de l'access token en secondes (15 min) |
JWT_REFRESH_TTL |
2592000 |
TTL du refresh token en secondes (30 jours) |
LOG_LEVEL |
info |
Niveau de log pino |
CORS_ORIGINS |
* |
Origines autorisées séparées par des virgules |
INFLUX_URL |
http://localhost:8086 |
URL InfluxDB 2.x |
INFLUX_TOKEN |
auto-généré | Persisté dans data/.influx-token au premier lancement |
INFLUX_ORG |
sowel |
Organisation InfluxDB |
INFLUX_BUCKET |
sowel |
Bucket principal InfluxDB |
TZ |
défaut système (UTC en Docker) | Fuseau IANA. À fixer explicitement dans docker-compose pour fiabiliser la logique horaire. |
Les réglages d'intégration (MQTT, identifiants cloud, intervalles de polling) sont configurés depuis l'UI, pas depuis .env.