State Management¶
The engine includes a shared in-memory state store. Any automation can read and write state, and other automations can react to changes via state triggers. State can optionally be persisted to disk across restarts.
Setup¶
const engine = createEngine({
automationsDir: "./automations",
state: {
persist: true, // default: true — see write-behind below
filePath: "./data/state.json", // defaults to ./state.json
flushIntervalMs: 1000, // default: 1000 — write-behind debounce window
},
});
Or via environment variables:
State is always available in-memory regardless of persistence. The persist flag only controls whether it survives a restart.
STATE_PERSISTdefaults totrue(a breaking default change from earlier versions, which defaulted tofalse). The store now holds room definitions and automation enabled flags, which must survive a restart for those features to work at all (design.md D6, R14). SetSTATE_PERSIST=falseexplicitly to opt back out.
Write-behind persistence¶
Writing through to disk on every set() is not viable — state keys are
written on routine sensor traffic, and a full save rewrites the whole map
with an fsync. Instead, mutations are coalesced: a save is scheduled
flushIntervalMs after the last mutation, and multiple writes inside that
window produce one save rather than one per write.
flushIntervalMs: 0 saves on every mutation instead of scheduling — useful
for tests or for storage where the debounce isn't wanted.
Trade-off: an abrupt kill (power loss, SIGKILL) loses mutations from
the current flush window, whereas earlier versions with STATE_PERSIST=false
lost everything since boot. Raise STATE_FLUSH_MS on SD-card-backed hosts to
reduce write wear; lower it on fast storage to shrink the loss window. A
graceful shutdown (engine.stop()) always flushes synchronously regardless
of the debounce window.
Reading and writing¶
// Write — fires state triggers in other automations
// Note: set() is a no-op when the new value equals the current value
// (compared via JSON.stringify for objects, strict equality for primitives),
// so duplicate writes do not trigger spurious change events.
this.state.set<boolean>("night_mode", true);
this.state.set<number>("motion_count", 42);
this.state.set("last_motion", { room: "hallway", time: Date.now() });
// Read with optional default
const isNight = this.state.get<boolean>("night_mode", false);
const count = this.state.get<number>("motion_count", 0);
// Check existence and enumerate keys
if (this.state.has("night_mode")) { /* ... */ }
const allKeys = this.state.keys(); // string[]
// Delete
this.state.delete("temporary_flag");
Listening for any change¶
Use onAnyChange to register a global listener that fires on every state mutation — useful for audit logging or debugging:
this.state.onAnyChange((key, newValue, oldValue) => {
this.logger.debug({ key, newValue, oldValue }, "State changed");
});
Remove a global listener with offAnyChange(handler). Per-key listeners can be registered with onChange(key, handler) and removed with offChange(key, handler).
Note on object equality:
StateManagerusesJSON.stringifyto compare old and new values before firing listeners. This means that two objects with identical properties but different key-insertion order will be treated as different values and will trigger a change event spuriously. Use primitive values or consistently constructed objects for state keys that should avoid duplicate events.
State triggers¶
React to state changes from another automation using the state trigger type:
export default class NightModeReaction extends Automation {
readonly name = "night-mode-reaction";
readonly triggers: Trigger[] = [
{
type: "state",
key: "night_mode",
// Both newValue and oldValue are available in the filter
filter: (newValue, oldValue) => newValue === true && oldValue !== true,
},
];
async execute(context: TriggerContext): Promise<void> {
if (context.type !== "state") return;
this.logger.info(
{ key: context.key, value: context.newValue },
"Night mode activated",
);
this.mqtt.publishToDevice("living_room_lamp", { color_temp: 500 });
}
}
This enables clean cross-automation communication: one automation sets a flag, any number of other automations react to it independently.
Key naming conventions¶
Prefix state keys with the automation name to avoid accidental collisions:
// Good — scoped to the automation
this.state.set("motion-light:lights_on", true);
this.state.set("alarm:armed", false);
// Avoid — global keys can conflict
this.state.set("lights_on", true);
Reserved internal namespace¶
State keys beginning with the sigil prefix $internal: are reserved for
the engine's own use — currently room definitions and assignments, and
automation enabled flags (design.md D20). This namespace exists because both
of those need the same durability as ordinary state, but must not be
reachable through the same open surfaces ordinary state is: the operator
state view's full CRUD would let one DELETE wipe every room, and writing an
enabled flag as a raw value would set it without running the automation's
stop()/start(), silently decoupling what the UI reports from what is
actually still running.
Public callers cannot see or touch this namespace:
this.state.set()andthis.state.delete()throwErrorwhen given a reserved key. This is a programmer-mistake error, not a logged-and-ignored one — it is never legitimate for an automation to write there, and silently discarding the write would leave an automation that appears to work while storing nothing.GET /api/stateomits reserved keys from both the returned map and itscount.- The realtime event stream's state category never emits a reserved key.
- The operator state view (web and CLI) shows nothing beyond the filtered listing above — there is no free-text key field that could address a reserved key directly.
An automation name can never collide with the prefix: automation names
derive from kebab-case filenames and cannot begin with $, so the sigil
requires no additional validation elsewhere to stay unspoofable.
Rooms and automation enabled flags are not eligible for stateToggles.
Configured state toggles are the only allowlist-driven door from the state
store into a user-facing device, and reserved keys are never eligible for
that allowlist — configuring one is a configuration error. See
HomeKit: Bridging state toggles
and Rooms for the endpoints that manage this data instead
of raw state access.
CLI access¶
The ts-ha CLI can inspect and modify state on a running engine:
ts-ha state list # list all keys
ts-ha state get night_mode # read a single key
ts-ha state set night_mode true # set a value (fires state triggers)
ts-ha state delete temporary_flag # delete a key
See CLI Reference for full details.
Web dashboard¶
The Web UI provides a live table view of all state keys with inline editing and add/delete support.