Recipe Developer Guide¶
How to create a new recipe for Sowel.
Architecture¶
A recipe is a reusable automation template. Users instantiate recipes with parameters (slots) to create running automation instances. Recipes are registered at engine startup and exposed via the REST API.
Recipe class (definition)
-> RecipeManager.register()
-> GET /api/v1/recipes -> UI shows available recipes
-> User creates instance with params
-> RecipeManager.createInstance() -> validate() -> start()
-> Recipe subscribes to EventBus events and reacts
Creating a Recipe¶
1. Create the file¶
Create src/recipes/<recipe-name>.ts extending the Recipe base class:
import type { RecipeSlotDef, RecipeLangPack } from "../shared/types.js";
import { Recipe, type RecipeContext } from "./engine/recipe.js";
export class MyRecipe extends Recipe {
readonly id = "my-recipe";
readonly name = "My Recipe"; // English (fallback)
readonly description = "What it does"; // English (fallback)
readonly slots: RecipeSlotDef[] = [
// ...see Slots section below
];
override readonly i18n: Record<string, RecipeLangPack> = {
// ...see Translations section below
};
validate(params: Record<string, unknown>, ctx: RecipeContext): void {
// Throw if params are invalid
}
start(params: Record<string, unknown>, ctx: RecipeContext): void {
// Subscribe to events, start timers
}
stop(): void {
// Unsubscribe events, clear timers (must be idempotent)
}
}
2. Register in index.ts¶
3. Write tests¶
Create src/recipes/<recipe-name>.test.ts. Follow the pattern in motion-light.test.ts:
- In-memory SQLite with migrations
- Fake timers (
vi.useFakeTimers()) - Mock integration registry capturing MQTT publishes
- Test validation, event handling, timer behavior, cleanup
Slots¶
Slots define the parameters users configure when creating an 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
};
}
Equipment slot scope: crossZone and includeDescendants¶
By default, an equipment slot's picker is filtered to equipments that live in the recipe's zone. Two constraints widen that set:
| Constraint | Effect |
|---|---|
crossZone |
Lets the user pick an equipment from any zone in the system. Useful for triggers like "the gate" that semantically belong to a different zone than the action. |
includeDescendants |
Widens the candidate set to the recipe zone plus all descendant zones. Useful when the actuators (e.g. lights) live in subzones rather than directly in zone. |
The two flags are independent: crossZone ignores zone scope entirely, while includeDescendants keeps the zone-rooted scope but recursively includes children. A picker with both set behaves like crossZone alone.
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`
},
},
];
Common slot patterns:
| Slot type | UI control | Value format |
|---|---|---|
zone |
Auto-filled | Zone UUID |
equipment |
Dropdown/check | Equipment UUID (or UUID[] if list) |
duration |
Numeric + min | "10m", "30s", "1h" |
number |
Numeric input | Numeric value |
time |
Time picker | "HH:MM" string (24h) |
boolean |
Toggle | true / false |
select |
Dropdown | chosen option value (string) |
select slot (spec 126). Provide options: { value: string; label: string }[]; label is the English fallback. Per-language option labels live in the recipe's i18n under slots[<id>].options[<value>]. The chosen option's value is stored as the param. Use it for a small closed list of named choices (e.g. "fixed time / sunrise / sunset").
Conditional visibility (spec 126). Any slot can declare hiddenWhen: { slot: "<otherSlotId>", equals: <value | value[]> }. The recipe form hides that slot (removes it from the layout, keeping the remaining fields aligned) when the referenced sibling's effective value (its param, or its defaultValue when untouched) matches. Pairs naturally with a select: e.g. a fixed-time picker hiddenWhen the kind select is ["sunrise", "sunset"], and an offset hiddenWhen it is "time", so only the relevant field shows.
Translations (i18n)¶
Translations travel with the recipe, not in the platform locale files. This allows recipes to be hot-loaded without modifying fr.json/en.json.
How it works¶
Each recipe defines an i18n record mapping language codes to translated names, descriptions, and slot labels:
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
};
Type definitions¶
interface RecipeLangPack {
name: string;
description: string;
slots?: Record<string, RecipeSlotI18n>; // Keyed by slot id
}
interface RecipeSlotI18n {
name: string;
description: string;
}
Resolution in the UI¶
The frontend uses helpers from 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
Fallback chain: i18n[lang].name -> recipe.name (English embedded in class).
Adding a new language¶
Add a new key to the i18n record in your recipe class. No platform files to modify.
RecipeContext¶
The ctx object injected into validate() and start() provides:
| Property | Type | Purpose |
|---|---|---|
eventBus |
EventBus |
Subscribe to typed events |
equipmentManager |
EquipmentManager |
Query equipment state, execute orders |
zoneManager |
ZoneManager |
Query zone definitions |
zoneAggregator |
ZoneAggregator |
Query aggregated zone data |
state |
RecipeStateStore |
Persist key-value state (survives restart, auto-notifies UI on mutations) |
log(msg, level?) |
function | Write to recipe execution log |
Shared Helpers¶
Reusable utilities in src/recipes/engine/:
| Module | Exports |
|---|---|
duration.ts |
parseDuration(value), formatDuration(ms) |
light-helpers.ts |
isAnyLightOn(), turnOnLights(), turnOffLights(), setLightsBrightness() |
ctx.helpers also exposes getSunlight(): { sunrise, sunset, isDaylight } (spec 126) — the current sun times ("HH:MM", offsets applied) for sun-aware scheduling. Pair it with the sunlight.changed event to re-sync across days; fields are null when sun times are not yet computed or no home coordinates are configured.
Event Bus Events¶
Key events recipes typically subscribe to:
| Event | Payload |
|---|---|
zone.data.changed |
{ zoneId, aggregatedData: { motion, luminosity, ... } } |
equipment.data.changed |
{ equipmentId, alias, value, category } |
sunlight.changed |
(no payload) — sun times recomputed (new day / daylight transition); read via ctx.helpers.getSunlight() |
Lifecycle¶
- Registration:
recipeManager.register(MyRecipe)-- creates a sample instance to extract metadata - Instantiation: User creates via API ->
validate()-> persisted to SQLite ->start() - Restore: On engine restart, enabled instances are loaded from DB and
start()is called - Update:
stop()-> update params in DB ->validate()->start()with new params - Delete:
stop()-> removed from DB (cascades to state + logs)
Existing Recipes¶
| ID | Description |
|---|---|
motion-light |
Turn on lights on motion, off after timeout |
switch-light |
Toggle lights on button press, optional failsafe timer |
Checklist¶
- [ ] Recipe class extends
Recipewith id, name, description, slots, i18n - [ ]
validate()checks all params, throws on error - [ ]
start()subscribes to events, stores unsubs - [ ]
stop()clears all timers and unsubscribes (idempotent) - [ ] Registered in
src/index.ts - [ ] Tests written and passing
- [ ]
npx tsc --noEmitpasses - [ ] French translations in
i18nrecord