Configuration¶
All configuration is driven by environment variables. Use a .env file in development (Bun loads it automatically) or set variables directly in your shell / container environment.
An annotated .env.example is included in the repository.
Breaking changes for upgrading operators¶
STATE_PERSIST,DEVICE_REGISTRY_PERSIST, andDEVICE_STATE_PERSISTnow default totrue.STATE_PERSISTandDEVICE_REGISTRY_PERSISTpreviously defaulted tofalse;DEVICE_STATE_PERSISTis new and defaults on. The state store holds room definitions and automation enabled flags, which must survive a restart (design.md D6, R14), and the device state store holds every device's last-known state so enumeration reports it from the first read after a restart (design.md D3). Persistence is on by default rather than opt-in. The direction of the surprise is benign — an upgraded instance now keeps data it previously lost — but it does mean a default deployment writesstate.json,device-registry.json, anddevice-state.jsonto disk where it previously did not. Migration: setSTATE_PERSIST=false,DEVICE_REGISTRY_PERSIST=false, and/orDEVICE_STATE_PERSIST=falseexplicitly if you rely on the old ephemeral-by-default behaviour.
GET /api/devicesandGET /api/devices/:nameare removed, not repurposed. They return410 Gone. Both the CLI dashboard's and the old web UI's device views degrade to reporting devices unavailable rather than crashing or rendering garbage. Migration: useGET /api/device-catalogandGET /api/device-catalog/:qualifiedIdinstead — they span every device source (Zigbee, Shelly, Nanoleaf, and state toggles), not just Zigbee. See API Reference for the qualified identifier format.
stateTogglesmoves fromHomekitServiceOptionstoEngineOptions. A source consumed by both HomeKit and the web UI cannot be configured inside one of them — state toggles would disappear from the web UI whenever HomeKit is disabled (design.md D19). PassingstateTogglesunderservices.homekit's options now throws, naming the new location. Migration:const engine = createEngine({ automationsDir: "./automations", + stateToggles: [{ stateKey: "night_mode", name: "Night Mode" }], services: { homekit: ({ logger, devices }) => new HomekitService(logger, devices, { pinCode: "031-45-154", - stateToggles: [{ stateKey: "night_mode", name: "Night Mode" }], }), }, });
HomekitServiceContextis narrower. The context object passed to ahomekitservice factory previously carriedmqtt,deviceRegistry,shelly, andstatedirectly. It now carriesdevices(the engine's aggregate device accessor) instead, sinceHomekitServicereads every device family through the shared device-source layer rather than talking to Zigbee2MQTT, MQTT, or Shelly RPC itself (design.md D2, task 6.16b). Migration:services: { - homekit: ({ http, logger, mqtt, deviceRegistry, shelly }) => - new HomekitService(mqtt, logger, deviceRegistry, shelly, state, options), + homekit: ({ http, logger, devices }) => + new HomekitService(logger, devices, options), }Importing from
packages/core/src/services/homekit-sources/is no longer supported. The per-family accessory source implementations that used to live there (state-source.tsand friends) were promoted to source-neutral device sources underpackages/core/src/device-sources/(design.md D2, D19). Migration: import the aggregate accessor (Engine.devices) or the individual sources frompackages/core/src/device-sources/instead; there is no drop-in rename, since the module boundary itself changed.
See Web UI for the phase-⑥ frontend cutover, which is a separate, additive-until-that-point change.
Security note: automation source is readable without authentication¶
HTTP_TOKEN(notWEB_UI_TOKEN— there is only one shared secret, gating both theAuthorization: Bearerheader and the web UI's session cookie) may be left empty, which is a supported deployment. When it is empty,GET /api/automations/:name/source— along with every other/api/*route — requires no authentication. Automation source routinely contains device names, hostnames, notification topics, and API keys. This is a deliberate acceptance (design.md D10, R4), not an oversight: the source endpoint carries the same trust level as the rest of the dashboard. If your automations contain anything you would not want an unauthenticated LAN client to read, setHTTP_TOKEN.
Core¶
| Variable | Default | Description |
|---|---|---|
NODE_ENV |
(unset) | Set to production to use raw JSON logging; otherwise logs are pretty-printed |
TZ |
system default | Timezone for cron schedules, e.g. Europe/Berlin |
LOG_LEVEL |
info |
Minimum log level: trace, debug, info, warn, error, fatal |
MQTT¶
| Variable | Default | Description |
|---|---|---|
MQTT_HOST |
localhost |
Hostname or IP of the MQTT broker |
MQTT_PORT |
1883 |
MQTT broker port |
ZIGBEE2MQTT_PREFIX |
zigbee2mqtt |
Topic prefix used by Zigbee2MQTT |
Automations¶
| Variable | Default | Description |
|---|---|---|
AUTOMATIONS_RECURSIVE |
false |
Scan subdirectories of automationsDir recursively |
State persistence¶
State is written through a debounced, coalesced write-behind: mutations
within one STATE_FLUSH_MS window are coalesced into a single save shortly
after the last one, rather than writing through on every set() (design.md
D6). This trades an explicit bounded loss window on abrupt termination for
avoiding an fsync-backed full-map rewrite on routine sensor traffic.
| Variable | Default | Description |
|---|---|---|
STATE_PERSIST |
true |
Save state (write-behind) and restore on startup. Defaults on because the store holds room definitions and automation enabled flags, which must survive a restart. |
STATE_FILE_PATH |
./state.json |
Path to the state persistence JSON file. The previous file is kept beside it as <path>.bak for recovery. |
STATE_FLUSH_MS |
1000 |
Milliseconds between coalesced saves. 0 saves on every mutation instead of scheduling — raise this on SD-card-backed hosts, lower it on fast storage. |
The reserved internal namespace ($internal: prefix — rooms, automation
enabled flags) lives in the same store and is subject to the same
persistence and flush interval, but is hidden from every public read and
rejects every public write. See State Management.
HTTP server¶
The HTTP server serves health probes, the debug API, webhook endpoints, and optionally the web UI. Set HTTP_PORT=0 to disable it entirely (also disables webhooks and the web UI).
| Variable | Default | Description |
|---|---|---|
HTTP_PORT |
8080 |
Port for the HTTP server. Set to 0 to disable. |
HTTP_TOKEN |
(empty) | Bearer token / session secret for /api/* and the web UI. Empty = no authentication — see the security note above. |
Web UI¶
The web UI is a separate package (@ts-ha/web-ui). It — not the core engine —
parses these variables into its plugin options; ts-ha run reads
WEB_UI_ENABLED only as a raw string gate before importing the package, and
supplies the core config's resolved HTTP_TOKEN as the plugin's auth token.
The core config schema contains no web UI section, regardless of these values.
| Variable | Default | Description |
|---|---|---|
WEB_UI_ENABLED |
false |
Enable the browser-based web UI dashboard |
WEB_UI_PATH |
/status |
URL path prefix for the web UI (must start with /) |
See Web UI for full details.
Device Registry¶
Automatically discovers all Zigbee2MQTT devices and tracks their live state. Required for device_state, device_joined, and device_left trigger types, as well as the Devices tab in the CLI dashboard and web UI.
| Variable | Default | Description |
|---|---|---|
DEVICE_REGISTRY_ENABLED |
false |
Enable automatic Zigbee2MQTT device discovery and state tracking |
DEVICE_REGISTRY_PERSIST |
true |
Persist the device list, last-known device states, and mapped capability schema to disk on shutdown and restore them on startup. Defaults on, alongside STATE_PERSIST, so the device list is available immediately on boot rather than after Zigbee2MQTT republishes. |
DEVICE_REGISTRY_FILE_PATH |
./device-registry.json |
Path to the device registry JSON persistence file. The previous snapshot is kept beside it as <path>.bak for recovery. |
See Device Registry for full details including nice names, device triggers, persistence, and the capability vocabulary.
Unified device layer (web UI / HomeKit)¶
Two device sources in the shared device layer (Engine.devices, consumed by
both the web UI and HomeKit) have no push transport and are refreshed on a
timer instead:
| Variable | Default | Description |
|---|---|---|
SHELLY_POLL_MS |
10000 |
Refresh interval, in milliseconds, for HTTP-transport Shelly devices. MQTT-transport Shelly devices are push-backed and excluded from this poll. |
NANOLEAF_POLL_MS |
10000 |
Refresh interval, in milliseconds, for the Nanoleaf device source (no push transport at all). |
These intervals also govern how long the web UI's optimistic-actuation
revert deadline waits before reverting an unconfirmed command against a
device of that source (design.md D21) — the client reads the descriptor's
own refreshIntervalMs, it does not hardcode either constant.
Device state store¶
The unified device layer's last-known state is held in a durable device state
store (packages/core/src/device-sources/device-state-store.ts, design.md D1).
Every source records each observation into it, and a descriptor's state is
seeded from it when the source has no fresher in-memory observation, so a
device's last-known state is available from the first read after a restart
instead of empty (design.md D2, D4). The store is a seed, never an
authority — a reported observation always wins over the persisted value, and
a restored record keeps its original observation time, so a stale value still
reads as last-known rather than live. Records are pruned when a device drops
out of enumeration (design.md D5), but a record is retained while its owning
source is unavailable or has not yet completed its authoritative enumeration,
so neither a source outage nor a restart race discards that device's
last-known state.
Persistence mirrors state persistence: a debounced, coalesced write-behind JSON file.
| Variable | Default | Description |
|---|---|---|
DEVICE_STATE_PERSIST |
true |
Persist last-known device state (write-behind) and restore it before any source starts. Defaults on, alongside STATE_PERSIST and DEVICE_REGISTRY_PERSIST, so enumeration reports last-known state immediately after a restart. Set to a false value to write no device-state file and start the store empty. |
DEVICE_STATE_FILE |
./device-state.json |
Path to the device state persistence JSON file. The previous file is kept beside it as <path>.bak for recovery. |
DEVICE_STATE_FLUSH_MS |
1000 |
Milliseconds between coalesced saves. 0 saves on every write instead of scheduling. A non-numeric value fails startup validation. |
The engine also exposes the store's settings programmatically via
createEngine({ deviceState: { persist, filePath, flushIntervalMs } }),
mirroring the state and deviceRegistry options — see
API Reference.
Energy monitoring¶
The engine samples the aggregate device source on a fixed interval and keeps a bounded, rolling history of the home's instantaneous power so the dashboard can render a recent trend. History is held in memory only and does not survive a restart (design.md D6/D7).
| Variable | Default | Description |
|---|---|---|
ENERGY_SAMPLE_MS |
60000 |
Interval, in milliseconds, between power-history samples. Must be a positive integer; 0 is normalized to the default so history can never be sampled at a zero interval. |
ENERGY_HISTORY_MINUTES |
1440 |
Rolling history window, in minutes. The retained power-sample count is bounded by historyMinutes / sampleIntervalMs, and the same window bounds the per-device cumulative ring the endpoint's usageWh, windowMinutes, and coveredMinutes are derived from. 0 disables history while current totals remain available. |
Weather¶
GET /api/weather returns the registered weather service's current conditions
and forecast. Because the service takes a location per call, the engine uses a
configured default location when a client supplies none, and a client may
override it per request (design.md D8).
| Variable | Default | Description |
|---|---|---|
WEATHER_LATITUDE |
(unset) | Default latitude, -90..90. Must be set together with WEATHER_LONGITUDE for a default location to be configured. |
WEATHER_LONGITUDE |
(unset) | Default longitude, -180..180. Must be set together with WEATHER_LATITUDE. |
WEATHER_FORECAST_DAYS |
3 |
Requested forecast horizon in days, clamped to a maximum of 7. |
Out-of-range coordinates fail startup validation with a descriptive error. When neither a configured default nor a client-supplied location is available, the endpoint rejects the request as a client error.
Example .env¶
TZ=Europe/Berlin
LOG_LEVEL=info
MQTT_HOST=192.168.1.10
MQTT_PORT=1883
ZIGBEE2MQTT_PREFIX=zigbee2mqtt
STATE_PERSIST=true
STATE_FILE_PATH=./data/state.json
STATE_FLUSH_MS=1000
HTTP_PORT=8080
HTTP_TOKEN=my-secret-token
WEB_UI_ENABLED=true
WEB_UI_PATH=/status
DEVICE_REGISTRY_ENABLED=true
DEVICE_REGISTRY_PERSIST=true
DEVICE_REGISTRY_FILE_PATH=./data/device-registry.json
DEVICE_STATE_PERSIST=true
DEVICE_STATE_FILE=./data/device-state.json
DEVICE_STATE_FLUSH_MS=1000
SHELLY_POLL_MS=10000
NANOLEAF_POLL_MS=10000
ENERGY_SAMPLE_MS=60000
ENERGY_HISTORY_MINUTES=1440
WEATHER_LATITUDE=52.52
WEATHER_LONGITUDE=13.405
WEATHER_FORECAST_DAYS=3