Guide de développement de recettes¶
Comment créer une nouvelle recette pour Sowel.
Architecture¶
Une recette est un modèle d'automatisation réutilisable. Les utilisateurs instancient les recettes avec des paramètres (slots) pour créer des instances d'automatisation en cours d'exécution.
Depuis les specs 053/054, les recettes sont des packages externes dans leurs propres dépôts GitHub (par ex. mchacher/sowel-recipe-schedule-on-off) ; rien de spécifique à une recette ne vit dans le repo Sowel. Un package de recette embarque un manifest.json (type: "recipe") et un dist/index.js compilé exportant une factory createRecipe(). Le RecipeLoader l'importe au démarrage et enregistre la définition retournée.
Package de recette (repo GitHub, release sowel-recipe-<id>-<version>.tar.gz)
-> PackageManager installe -> RecipeLoader importe dist/index.js
-> createRecipe(): RecipeDefinition -> RecipeManager.registerExternal()
-> GET /api/v1/recipes -> l'UI liste les recettes disponibles
-> L'utilisateur crée une instance avec des params
-> validate() -> createInstance() retourne { stop }
-> La recette s'abonne aux événements de l'EventBus et réagit
La distribution suit les mêmes conventions que les plugins d'intégration (tarball de release, entrée de registre ou source personnelle) ; voir plugin-development.md. Pour votre propre instance, la boucle la plus rapide est une source personnelle (spec 136) : ajoutez votre dépôt sur la page Plugins, installez via la confirmation TOFU, publiez une release par itération. Le repo Sowel fournit aussi un skill Claude Code, sowel-recipe-dev, qui déroule tout ce guide.
Créer une recette¶
1. Créer le dépôt du package¶
Nommage : sowel-recipe-<id>. Structure :
sowel-recipe-<id>/
manifest.json # id, type: "recipe", name, version, icon, repo, i18n, sowelVersion
package.json # "type": "module", scripts build / test
tsconfig.json # module + moduleResolution: "NodeNext", outDir dist
src/index.ts # exporte createRecipe()
src/index.test.ts # vitest
Le champ repo du manifest doit être égal au dépôt GitHub qui sert le package, et l'id ne doit pas entrer en collision avec une entrée du registre (les deux sont vérifiés à l'installation depuis la spec 136).
2. Exporter la factory¶
Les packages de recettes n'importent jamais le cœur de Sowel : ils recopient les quelques types nécessaires (depuis src/shared/types.ts : RecipeDefinition, RecipeSlotDef, RecipeInstanceHandle) et exportent une factory :
export function createRecipe(): RecipeDefinition {
return {
id: "my-recipe",
name: "My Recipe", // anglais (fallback)
description: "What it does",
slots: [
// ...voir la section Slots
],
i18n: {
// ...voir la section Traductions
},
validate(params, ctx) {
// Throw avec un message clair si les params sont invalides
},
createInstance(params, ctx) {
// S'abonner aux événements, armer les timers...
return {
stop() {
// Annuler chaque timer, tout désabonner (doit être idempotent)
},
};
},
};
}
RecipeManager appelle createInstance() par instance en cours ; le stop() du handle retourné est invoqué à la désactivation, à la modification des params (stop -> validate -> createInstance), à la mise à jour de la recette, et à l'arrêt du moteur.
3. Écrire des tests¶
Créez src/index.test.ts dans le dépôt de la recette (vitest). Suivez le pattern de sowel-recipe-schedule-on-off :
- Un faux
ctx(log, state, eventBus avec capture, accès aux équipements) - Faux timers (
vi.useFakeTimers()) - Test de la validation, du traitement des événements, du comportement des timers, et du nettoyage complet par
stop()
Slots¶
Les slots définissent les paramètres que les utilisateurs configurent à la création d'une instance.
interface RecipeSlotDef {
id: string; // Unique within recipe (e.g. "lights", "timeout")
name: string; // English label (fallback)
description: string; // English description (fallback)
type: "zone" | "equipment" | "number" | "duration" | "time" | "boolean";
required: boolean;
list?: boolean; // Allow multiple values (equipment lists)
defaultValue?: unknown;
constraints?: {
equipmentType?: EquipmentType | EquipmentType[]; // Filter equipment selector
min?: number;
max?: number;
crossZone?: boolean; // Allow picking equipments from any zone
includeDescendants?: boolean; // Widen candidates to descendant zones
};
}
Portée d'un slot equipment : crossZone et includeDescendants¶
Par défaut, le picker d'un slot equipment est filtré sur les équipements qui vivent dans la zone de la recette. Deux contraintes élargissent cet ensemble :
| Contrainte | Effet |
|---|---|
crossZone |
Permet à l'utilisateur de choisir un équipement depuis n'importe quelle zone du système. Utile pour des triggers comme "le portail" qui appartient sémantiquement à une zone différente de l'action. |
includeDescendants |
Élargit l'ensemble candidat à la zone de la recette plus toutes les zones descendantes. Utile quand les actionneurs (par ex. les lumières) vivent dans des sous-zones plutôt que directement dans zone. |
Les deux flags sont indépendants : crossZone ignore complètement la portée de zone, alors que includeDescendants conserve la portée enracinée à la zone mais inclut récursivement les enfants. Un picker avec les deux activés se comporte comme crossZone seul.
slots: RecipeSlotDef[] = [
{ id: "zone", name: "Zone", description: "...", type: "zone", required: true },
{
id: "trigger",
name: "Trigger equipment",
description: "Equipment whose state change fires the recipe",
type: "equipment",
required: true,
constraints: { crossZone: true }, // can be in another zone
},
{
id: "lights",
name: "Lights",
description: "Lights to turn on",
type: "equipment",
required: true,
list: true,
constraints: {
equipmentType: ["light_onoff", "light_dimmable"],
includeDescendants: true, // lights may live in subzones of `zone`
},
},
];
Patterns courants de slot :
| Slot type | Contrôle UI | Format de valeur |
|---|---|---|
zone |
Auto-rempli | UUID de zone |
equipment |
Liste/cases | UUID d'équipement (ou UUID[] si list) |
duration |
Numérique + min | "10m", "30s", "1h" |
number |
Saisie numérique | Valeur numérique |
time |
Sélecteur d'heure | Chaîne "HH:MM" (24 h) |
boolean |
Bascule | true / false |
Traductions (i18n)¶
Les traductions voyagent avec la recette, pas dans les fichiers de locale de la plateforme. Cela permet de hot-loader des recettes sans modifier fr.json/en.json.
Comment ça marche¶
Chaque recette définit un record i18n qui mappe les codes de langue à des noms, descriptions et libellés de slot traduits :
override readonly i18n: Record<string, RecipeLangPack> = {
fr: {
name: "Ma recette",
description: "Ce qu'elle fait",
slots: {
lights: { name: "Lumieres", description: "Lumieres a controler" },
timeout: { name: "Delai", description: "Delai avant extinction" },
},
},
// Add more languages as needed
};
Définitions de types¶
interface RecipeLangPack {
name: string;
description: string;
slots?: Record<string, RecipeSlotI18n>; // Keyed by slot id
}
interface RecipeSlotI18n {
name: string;
description: string;
}
Résolution dans l'UI¶
Le frontend utilise des helpers depuis ui/src/lib/recipe-i18n.ts :
recipeName(recipe, lang); // Recipe name with fallback
recipeDescription(recipe, lang); // Recipe description with fallback
recipeSlotName(recipe, slot, lang); // Slot name with fallback
recipeSlotDescription(recipe, slot, lang); // Slot description with fallback
Chaîne de fallback : i18n[lang].name -> recipe.name (anglais embarqué dans la classe).
Ajouter une nouvelle langue¶
Ajoutez une nouvelle clé au record i18n dans la classe de votre recette. Aucun fichier de plateforme à modifier.
Tuile de tableau de bord (spec 169)¶
Une recette peut proposer une tuile sur le tableau de bord, à côté des widgets d'équipement sur lesquels elle agit. C'est un opt-in : une définition sans tile n'apparaît jamais dans le sélecteur de widgets et ne peut pas être épinglée. La plupart des recettes n'ont rien à montrer d'un coup d'œil et ne doivent rien déclarer.
export function createRecipe(): RecipeDefinition {
return {
id: "delivery-gate",
// ...
actions: [{ id: "set_mode", type: "cycle", stateKey: "mode", options: [...] }],
tile: {
icon: "Truck", // clé du jeu d'icônes de tuile (ci-dessous)
summaryKey: "summary", // valeur par défaut, à omettre
countdownKey: "timerExpiresAt", // valeur par défaut, à omettre
actions: ["set_mode"], // celles de vos actions qui deviennent des boutons
confirm: true, // cette tuile actionne du physique (spec 171)
confirmParam: "confirmFromDashboard", // ...sauf si l'utilisateur en décide autrement
confirmFrom: "gate", // ...ou sauf si l'équipement lui-même a une réponse
},
};
}
Ce que la tuile affiche¶
Tout vient de l'état de l'instance que votre recette écrit avec ctx.state.set(), et chaque élément est facultatif : une clé absente de votre état n'affiche rien, plutôt qu'un emplacement vide.
| Élément | Source | Remarques |
|---|---|---|
| Icône | tile.icon |
Jeu fermé : ChefHat, Clock, DoorClosed, Droplets, Fan, Flame, Lightbulb, Snowflake, Sun, Thermometer, Timer, Truck, Waves, Zap. Une clé inconnue retombe sur ChefHat. |
| Titre | le libellé du widget, sinon le nom localisé de votre recette | L'utilisateur peut renommer une tuile ; son libellé l'emporte. |
| Ligne d'état | state[tile.summaryKey ?? "summary"] |
Une chaîne courte. Restez sous ~40 caractères : une tuile fait ~240 px de large. |
| Décompte | state[tile.countdownKey ?? "timerExpiresAt"] |
Un instant ISO-8601. L'UI le décompte à la seconde et le masque une fois l'échéance passée. |
| Boutons | les ids de tile.actions, croisés avec vos actions |
Rendus avec la même pastille que sur la ligne de recette. Un id que vous ne déclarez pas dans actions est ignoré. |
Ce sont les trois mêmes clés d'état que la ligne de recette rend déjà sur la page de zone : une recette qui les publie obtient une présentation cohérente sur les deux surfaces.
// Dans createInstance — ce qui rend la tuile vivante.
ctx.state.set("summary", `Ouvert pour le livreur — refermeture à ${hhmm}`);
ctx.state.set("timerExpiresAt", new Date(Date.now() + holdMs).toISOString());
ctx.state.set("mode", "short"); // la stateKey que lit votre action de type cycle
Publiez la valeur de repos de la stateKey d'une action cycle dès le début de createInstance, et pas seulement quand il se passe quelque chose : le bouton ne s'affiche pas tant que sa clé d'état est absente, donc une tuile dont la recette est au repos n'aurait aucun bouton du tout.
Un clic sur la tuile déclenche son bouton (spec 171)¶
Quand une tuile n'affiche qu'un seul bouton, un clic n'importe où sur la carte le déclenche — même cycle, même valeur suivante que la pastille, qui reste en place pour qui préfère viser. Avec deux boutons la carte reste inerte : elle devrait deviner lequel vous vouliez. Idem pour une tuile sans aucun bouton, une instance désactivée, et un tableau de bord en mode édition.
confirm: true déclare que déclencher cette tuile actionne quelque chose de physique — un portail, une porte, une pompe. Sur mobile, la carte ouvre alors un panneau « glisser pour confirmer » qui nomme la position vers laquelle elle s'apprête à basculer, au lieu d'agir sur une simple tape ; sur ordinateur elle agit directement, un clic à la souris étant assez délibéré. La pastille, elle, n'est jamais protégée : une cible de 10 px est déjà une visée, et c'est le choix qu'avait fait la spec 146 pour les équipements de type portail.
confirmParam désigne l'un de vos slots boolean et confie ce choix à l'utilisateur — l'équivalent, pour une recette, de la case « confirmation avant action » que porte un équipement de type portail. Ce que répond l'instance l'emporte ; confirm n'est que la valeur par défaut pour une instance à qui on n'a jamais posé la question, si bien qu'ajouter le slot à une recette existante ne retire jamais la protection en silence aux instances déjà en service.
confirmFrom est celui vers lequel se tourner en premier. Il désigne l'un de vos slots equipment — celui que le bouton unique de la tuile actionne. Quand ce slot se résout, c'est la « Confirmation avant action » (spec 146) de cet équipement qui décide, et confirm comme confirmParam ne sont pas consultés du tout.
Ce n'est pas un détail de priorité, c'est tout l'intérêt : sans lui, un même portail physique se voit poser la question à trois endroits, et deux d'entre eux peuvent se contredire. Quelqu'un active la protection sur son équipement Portail et obtient quand même une tuile de recette qui agit sur une tape. Avec confirmFrom, la réponse est donnée une fois, sur l'équipement, et toutes les surfaces qui l'actionnent posent la même question.
Seule votre recette sait si une telle dérivation a un sens — une action qui touche plusieurs équipements, aucun directement, ou qui fait plus que l'ordre de l'équipement, ne peut rien dériver — c'est pourquoi il s'agit d'une déclaration et non de quelque chose que le cœur devine. Si vous ne désignez aucun slot, ou si le slot ne se résout pas (l'utilisateur l'a laissé vide, l'équipement a été supprimé), confirmParam puis confirm décident comme avant. Un slot qui ne se résout pas n'est jamais lu comme « ne pas demander ».
// Un slot que l'utilisateur peut décocher, et une tuile qui le lit.
slots: [
{
id: "confirmFromDashboard",
name: "Confirmation avant d'agir depuis le tableau de bord",
description: "Sur téléphone, demande un glissement avant que la tuile n'ouvre le portail.",
type: "boolean",
required: false,
defaultValue: true,
},
],
tile: { icon: "Truck", actions: ["set_mode"], confirm: true, confirmParam: "confirmFromDashboard" },
// Mieux, quand le bouton de la tuile actionne un équipement que votre recette
// prend déjà en slot : l'utilisateur répond une fois, sur le portail, pour
// toutes les surfaces.
slots: [{ id: "gate", name: "Portail", type: "equipment", required: true, /* ... */ }],
tile: { icon: "Truck", actions: ["set_mode"], confirm: true, confirmFrom: "gate" },
Déclarez confirm sur une tuile qui ouvre quelque chose, laissez les trois de côté pour une tuile qui choisit un mode de confort. Un cœur antérieur à 1.66 ignore ces champs, comme il ignore toute partie d'un tile qu'il ne connaît pas.
Règles à connaître¶
- La tuile suit l'instance en direct via l'événement
recipe.instance.state.changed— rien à déclarer, aucun polling. - Une instance désactivée s'affiche grisée, sans ses boutons. Elle n'est pas masquée : celui qui a désactivé une recette doit voir pourquoi la tuile s'est tue.
- Retirer
tiledans une version ultérieure ne supprime pas le widget de l'utilisateur : il s'affiche comme indisponible. Le retrait est donc un changement visible, qui mérite une note de version. - La tuile montre un état ; elle ne configure rien. Les paramètres restent sur la page de zone.
RecipeContext¶
L'objet ctx injecté dans validate() et createInstance() fournit :
| Propriété | Type | Usage |
|---|---|---|
eventBus |
EventBus |
S'abonner aux événements typés |
equipmentManager |
EquipmentManager |
Interroger l'état d'un équipement, exécuter des ordres |
zoneManager |
ZoneManager |
Interroger les définitions de zones |
zoneAggregator |
ZoneAggregator |
Interroger les données agrégées de zone |
state |
RecipeStateStore |
Persister un état clé-valeur (survit au redémarrage, auto-notifie l'UI sur mutations) |
log(msg, level?) |
fonction | Écrire dans le journal d'exécution de la recette |
Helpers partagés¶
Les packages de recettes accèdent aux utilitaires partagés via ctx.helpers (interface RecipeHelpers dans src/shared/types.ts) :
| Helper | Rôle |
|---|---|
parseDuration(value), formatDuration(ms) |
Durées au format "10m" / "30s" |
isAnyLightOn(), turnOnLights(), turnOffLights(), setLightsBrightness() |
Orchestration de lumières par ids d'équipements |
getSunlight() |
Programmation solaire (spec 126), voir ci-dessous |
getTariff() |
Programmation tarifaire (spec 138), voir ci-dessous |
getSunlight(): { sunrise, sunset, isDaylight } retourne les heures de soleil courantes ("HH:MM", offsets spec 023 appliqués). À coupler avec l'événement sunlight.changed pour se resynchroniser d'un jour à l'autre ; les champs sont null tant que les heures ne sont pas calculées ou sans coordonnées maison.
getTariff() — heures creuses, lecture seule¶
getTariff(): { configured, offPeakToday, isOffPeakNow } retourne le planning HP/HC configuré dans Réglages → Administration → Tarif énergie, pour qu'une recette de délestage (chauffe-eau, pompe de piscine, recharge VE) n'ait pas à redemander des heures que l'instance connaît déjà.
const tariff = ctx.helpers.getTariff();
if (tariff.configured && tariff.offPeakToday.length > 0) {
// [{ start: "22:00", end: "06:00", tariff: "hc" }, ...] — un créneau dont
// `end` n'est pas après `start` passe minuit.
const { start, end } = tariff.offPeakToday[0];
} else {
// Rien de configuré — repli sur les créneaux propres à la recette.
}
Trois propriétés sur lesquelles s'appuyer :
- Lecture seule par construction. Chaque appel construit un objet neuf copié depuis le cache du
TariffClassifier. Une recette ne peut ni atteindre ni muter le planning sur lequel tourne la facturation d'énergie, et il n'existe aucun setter. - Les prix ne sont pas exposés. Savoir quand l'énergie est bon marché suffit pour placer une charge ; le prix est une donnée commerciale, et un package de recette est du code tiers qui peut republier ce qu'on lui confie.
offPeakTodayporte le planning et rien d'autre. - Toujours une réponse pour le cas non configuré.
configuredvautfalsesur une instance neuve ou dont le propriétaire n'a jamais rempli la page tarif. Prévoir le repli sur les créneaux propres à la recette plutôt que de refuser de fonctionner.
offPeakToday reflète le jour de semaine local courant : un planning qui ne couvre que les jours ouvrés donne une liste vide le dimanche. Relire la valeur plutôt que la mettre en cache au démarrage.
Événements de l'Event Bus¶
Événements clés auxquels les recettes s'abonnent typiquement :
| Event | Payload |
|---|---|
zone.data.changed |
{ zoneId, aggregatedData: { motion, luminosity, ... } } |
equipment.data.changed |
{ equipmentId, alias, value, category } |
sunlight.changed |
(sans payload) : heures de soleil recalculées (nouveau jour / transition) ; lire via ctx.helpers.getSunlight() |
Cycle de vie¶
- Chargement :
RecipeLoader.loadAll()importe chaque package installé et activé (dist/index.js) et enregistre la définition decreateRecipe()viaRecipeManager.registerExternal() - Instanciation : l'utilisateur crée via l'API →
validate()→ persisté en SQLite →createInstance() - Restauration : au redémarrage moteur, les instances activées sont chargées depuis la DB et
createInstance()est appelé - Mise à jour des params :
stop()→ mise à jour des params en DB →validate()→createInstance()avec les nouveaux params - Mise à jour de la recette : nouvelle version du package installée → définition ré-enregistrée → les instances en cours sont redémarrées pour exécuter la nouvelle version (issue #349)
- Suppression :
stop()→ retiré de la DB (cascade sur state + logs)
Recettes existantes¶
Le catalogue vivant est plugins/registry.json dans le repo Sowel (chaque entrée "type": "recipe", un dépôt GitHub par recette). Bons exemples à copier :
| Dépôt | Illustre |
|---|---|
sowel-recipe-schedule-on-off |
Timers, bornes solaires (getSunlight), slots select, i18n, tests |
sowel-recipe-state-watch |
Surveillance générique d'une clé de donnée avec alarme |
sowel-recipe-motion-light |
Pattern classique capteur vers actionneur avec délai |
Checklist¶
- [ ] Dépôt externe
sowel-recipe-<id>avecmanifest.json(type: "recipe",repoégal au dépôt GitHub) etdist/index.jsexportantcreateRecipe() - [ ] La définition porte id, name, description, slots, i18n (FR + EN)
- [ ]
validate()vérifie tous les params, lève une erreur si invalide - [ ]
createInstance()s'abonne aux événements, stocke les unsubs ; déclencheurs protégés contre les re-déclenchements (equipment.data.changedpeut re-émettre une valeur inchangée : mémoriser la dernière valeur vue et ne réagir qu'aux vraies transitions) - [ ]
stop()annule tous les timers et désabonne (idempotent) - [ ] Tests écrits et passants dans le dépôt de la recette (
npm test),npm run buildpropre - [ ] Tarball
sowel-recipe-<id>-<version>.tar.gzattaché à la release GitHubv<version>, version du manifest égale au tag - [ ] Installée et testée sur une instance réelle via une source personnelle (spec 136) ou une entrée de registre