Examples
Simple trigger
Let the installer compose "when this device reaches this state, do these things" from the hub's own service catalog.
A tiny rule engine. The installer picks a trigger device and one of its states, then adds a list of actions, each "device + service", with a level where the service takes one. Nothing about devices or services is hard-coded: the form asks the hub's catalog, so new domains show up without editing the app.
What it shows
preferencesas a function of the answers so far, re-run onsubmitOnChange. See Dynamic preferences.- Catalog introspection:
getDomainStateOptions,getActionDomains,getServicesForDomain,getServiceParameters. - Array rows with
filterOptionsByEntityDomainandrequiresSiblingOneOf, which only work inside rows. See Conditions and visibility. - Calling a service by name on a wrapper built from an id:
new EntityWrapper(id)[service](params).
const definition = {
name: 'Simple Trigger',
namespace: 'com.example',
description: 'When a device reaches a state, run a list of actions.',
category: 'automation',
};
// A function rather than an object: the hub calls it (inside the isolate) every time the form is
// rendered, with the answers so far, so the option lists come from the hub's live catalog.
const preferences = (values) => ({
pages: [
{
id: 'trigger',
title: 'When',
inputs: [
{ key: 'trigger', label: 'Device', type: 'entity', required: true, submitOnChange: true },
{ key: 'triggerState', label: 'Changes to', type: 'enum', required: true,
options: stateOptionsFor(values.trigger), // narrowed to the picked device's domain
visible: { input: 'trigger', operator: 'truthy' } },
],
},
{
id: 'actions',
title: 'Then',
visible: { input: 'triggerState', operator: 'truthy' },
inputs: [
{
key: 'actions',
label: 'Actions',
type: 'array',
minItems: 1,
maxItems: 10,
itemSummaryKeys: ['device', 'service'],
itemSchema: {
inputs: [
{ key: 'device', label: 'Device', type: 'entity', required: true },
{ key: 'service', label: 'Do', type: 'enum', required: true,
options: serviceOptions(), // every 'domain.service' the catalog offers
filterOptionsByEntityDomain: 'device' }, // shown per row for the picked device's domain
{ key: 'level', label: 'Level (0–100)', type: 'number', minValue: 0, maxValue: 100,
requiresSiblingOneOf: { siblingKey: 'service', values: ['light.turnOn', 'fan.setPercentage', 'cover.setCoverPosition'] } },
],
},
},
{ key: 'delaySeconds', label: 'Wait before acting (seconds)', type: 'number', default: 0, minValue: 0, maxValue: 3600 },
],
},
],
});
// 'light.on', 'lock.locked', … for the trigger's domain; everything while nothing is picked yet.
function stateOptionsFor(entityId) {
const all = getDomainStateOptions();
if (typeof entityId !== 'string') return all;
const domain = entityId.split('.')[0];
return all.filter((o) => o.value.startsWith(`${domain}.`));
}
function serviceOptions() {
const out = [];
for (const d of getActionDomains()) {
for (const s of getServicesForDomain(d.value)) {
out.push({ value: `${d.value}.${s.value}`, label: `${d.label} — ${s.label}`, description: s.description });
}
}
return out;
}
function installed() { initialize(); }
function updated() { unsubscribe(); unschedule(); initialize(); }
function initialize() {
if (!trigger || typeof triggerState !== 'string') return;
const token = triggerState.split('.')[1]; // 'binary_sensor.on' → 'on'
subscribe(trigger, { to: token }, onTrigger);
}
function onTrigger(evt) {
console.log(`${evt.entityId} → ${evt.newState.state}`);
const wait = typeof delaySeconds === 'number' ? delaySeconds : 0;
if (wait > 0) runIn(wait, runActions); // a second trigger inside the wait just re-arms
else runActions();
}
function run() { // Run button: the actions, without the trigger
runActions();
}
function runActions() {
for (const row of actions ?? []) {
if (typeof row.device !== 'string' || typeof row.service !== 'string') continue;
const [domain, service] = row.service.split('.');
const params = {};
if (typeof row.level === 'number') {
// Ask the catalog what this service calls its level, rather than hard-coding parameter names.
const numeric = getServiceParameters(domain, service).filter((p) => p.type === 'number');
const p = numeric.find((p) => p.required) ?? numeric[0];
if (p) params[p.key] = row.level;
}
new EntityWrapper(row.device)[service](params);
}
}How it works
- Two places narrow options, two mechanisms. At the top level the function does it:
stateOptionsFor(values.trigger)filters by the picked device's domain, becausefilterOptionsByEntityDomainis only honoured inside array rows. Inside the rows it is the declarative hint that filtersserviceper row. - Stored values are strings.
triggerStateis'light.on'; the row'sdeviceis an entity id andserviceis'light.turnOn'. The code splits both and builds a wrapper per action. - The catalog answers twice.
serviceOptions()builds the dropdown fromgetActionDomainsandgetServicesForDomain;runActionsasksgetServiceParametersfor the name of the numeric parameter (brightnessPercent,percentage,position) so the samelevelfield drives all three. preferencesruns during editing, not installation. It must only read (catalog calls are fine); the subscription is made ininitialize, from the storedtriggerState.
Try changing
- Add a condition page: an optional
entity+enumstate pair, checked withnew EntityWrapper(settings.conditionEntity).is(token)at the top ofrunActions. - Support numeric sensors as triggers: when the trigger's domain is
sensor, replace the state enum with a comparison and a threshold, subscribe with{}, and compare inonTriggeras in Temperature alert.