Architecture¶
Overview¶
TypeScript Home Automation is a single-process engine that bridges MQTT messages, scheduled jobs, HTTP webhooks, and shared state into typed TypeScript automation classes.
┌───────────────────────────────────────────────────────────────┐
│ Automation Engine │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Motion │ │ Temp │ │ Remote │ │ Schedule │ ... │
│ │ Light │ │ Alert │ │ Control │ │ Report │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬────┘ │
│ │ │ │ │ │
│ ┌────▼─────────────▼─────────────▼──────────────▼────────┐ │
│ │ AutomationManager │ │
│ └──┬──────┬──────┬──────┬──────┬──────┬──────┬───────┬───┘ │
│ │ │ │ │ │ │ │ │ │
│ ┌──▼──┐ ┌─▼──┐ ┌─▼──┐ ┌─▼────┐ ┌─▼───┐ ┌──▼──┐ ┌──▼─┐ ┌─▼──┐ │
│ │MQTT │ │Cron│ │HTTP│ │Shelly│ │Nano │ │State│ │Ntfy│ │Wthr│ │
│ └──┬──┘ └────┘ └────┘ └──────┘ │leaf │ └──┬──┘ └────┘ └────┘ │
│ │ └─────┘ │ │
│ ┌──▼──────────────┐ ┌──────────────▼──────────┐ │
│ │ HTTP Server │ │ Log Buffer (ring) │ │
│ │ /healthz │ │ 2500 entries │ │
│ │ /readyz │ └─────────────────────────┘ │
│ │ /webhook/* │ │
│ │ /debug/* │ │
│ │ /status (Hono) │ ← path is configurable via WEB_UI_PATH │
│ └─────────────────┘ │
└──────┬────────────────────────────────────────────────────────┘
│
┌────▼──────┐ ┌───────────────┐
│ Mosquitto │◄────►│ Zigbee2MQTT │
│ Broker │ └───────────────┘
└───────────┘
Core structure¶
The repository is a Bun workspace of four packages; @ts-ha/core is organised
into subfolders by responsibility:
| Folder | Contents |
|---|---|
packages/core/src/ (flat) |
engine.ts, automation.ts, automation-manager.ts — the glue layer |
packages/core/src/mqtt/ |
mqtt-service.ts, mqtt-utils.ts |
packages/core/src/http/ |
http-server.ts, http-client.ts |
packages/core/src/scheduling/ |
cron-scheduler.ts |
packages/core/src/state/ |
state-manager.ts |
packages/core/src/logging/ |
log-buffer.ts |
packages/core/src/services/ |
shelly-service.ts, nanoleaf-service.ts, ntfy-notification-service.ts, open-meteo-service.ts, openweathermap-service.ts, homekit-service.ts, homekit-accessory-factory.ts, service-plugin.ts, service-registry.ts |
packages/core/src/devices/ |
aqara-h1-automation.ts, ikea-styrbar-automation.ts, ikea-rodret-automation.ts |
packages/core/src/device-sources/ |
device-source.ts, the per-source implementations (Zigbee, Shelly, Nanoleaf, state toggles), and device-state-store.ts — the unified device layer and its durable last-known state |
packages/core/src/zigbee/ |
device-registry.ts — Zigbee2MQTT device discovery and state tracking |
packages/shared/src/ |
@ts-ha/shared contracts, constants, and pure helpers |
packages/web-ui/src/ |
@ts-ha/web-ui Hono routes, WebUiService plugin, React + Tailwind frontend, compiled asset constants |
Core components¶
createEngine()¶
A factory function (not a class) that wires all services together and returns an Engine object with:
- Lifecycle:
start(),stop() - Services:
mqtt,http,state,deviceRegistry, plus any services registered via theservicesmap (e.g.shelly,nanoleaf,notifications,weather, or custom services) - Internals (advanced):
config,logger,manager
The start() call loads automation files, registers triggers, connects to MQTT, and starts the HTTP server.
AutomationManager¶
Discovers and loads automation files from automationsDir at startup. For each automation it:
- Creates a child pino logger scoped with
{ automation: name } - Calls
_inject()to provide services (mqtt, state, http, logger, config, services registry, deviceRegistry) - Calls
onStart() - Registers all triggers with the appropriate service
On shutdown, calls onStop() on every automation in reverse registration order.
MqttService¶
A thin wrapper around the mqtt package. Maintains a single connection to the broker and multiplexes subscriptions across automations. Uses the mqtt-utils.ts wildcard matching implementation to route messages to the correct automation handlers.
CronScheduler¶
Wraps the cron package. Each { type: "cron" } trigger registers a job that fires execute() on schedule. All jobs are stopped on engine shutdown.
StateManager¶
An in-memory Map<string, unknown> protected by a typed API. When set() is called, it notifies all registered state-trigger listeners synchronously before returning. Optionally persists to a JSON file on shutdown and restores on startup.
LogBuffer¶
A circular ring buffer (default 1000 entries) that receives every pino log line as a newline-delimited JSON string via pino's multistream. Each entry is parsed and stored as a LogEntry object. query() serves the GET /api/logs endpoint and the CLI; subscribe(listener) additionally feeds the realtime event stream's log category (design.md D32).
Notification is deferred off the synchronous write() call via setImmediate — write() is reached by every logger.* call in the engine through pino's sink, so running fan-out (including network writes to connected SSE clients) synchronously inside it would put stream delivery on the hot logging path (design.md D32, R9, mirroring the automation execution context's own async-context requirement). Deferring only the stack is not sufficient on its own: a log entry produced by the delivery path's own failure would otherwise re-enter the same cycle across turns rather than the same call stack. The event stream's delivery path (fan-out, per-connection buffering, the fell-behind signal, payload serialisation, failing-client isolation) therefore logs through a second, stdout-only pino instance rather than the primary logger, cutting the cycle rather than merely deferring it. Stream lifecycle events (a connection accepted or closed, a subscription registered) are not part of the delivery path and remain on the primary logger, visible in the log view — narrowing the stdout-only logger to exactly the delivery path is what keeps that narrower scope honest, since a wider one would make the log view blind to the one subsystem whose failure also breaks the log view. This means an SSE delivery failure is intentionally absent from the log view (it is on stdout only); a stream lifecycle event is not.
HttpServer¶
A Bun.serve()-based HTTP server handling:
/healthz,/readyz— health probes (always unauthenticated)/webhook/*— webhook trigger dispatch (optionally authenticated)/api/*— automations, state, logs, device catalog, rooms, and the realtime event stream — authenticated whenHTTP_TOKENis set (see API Reference for the full route table){WEB_UI_PATH}/*(default:/status/*) — routes registered by the@ts-ha/web-uipackage'sWebUiService(ServicePlugin) when the web UI is enabled: the HTML shell, compiled assets, and login/logout. The/api/*routes above are not nested under this path — see Web UI
On every connection to GET /api/events, the server first sends a
per-connection initial device snapshot — one device_appeared event per device,
each carrying its full descriptor, from the aggregate's total enumeration
(including hidden devices, matching the GET /api/device-catalog snapshot) —
before any later device event. Subsequent device events remain incremental
deltas (design.md D6).
ShellyService¶
Maintains a Map<string, ShellyDevice> of device name → registration (transport, host/topicPrefix, type). Each method routes to httpRpc() (HTTP GET against the device's /rpc/<Method> endpoint via the shared HttpClient) or mqttRpc() (JSON-RPC published to <topicPrefix>/rpc, correlated by request id on a single shared <src>/rpc subscription) based on the target device's fixed-at-registration transport field — no fallback between transports. Typed response interfaces are provided for switch and cover status.
NanoleafService¶
Maintains a Map<string, NanoleafDevice> of registered panels. Makes HTTP requests to the Nanoleaf OpenAPI (local API, no cloud). Pairing is handled separately by the CLI nanoleaf pair command.
DeviceRegistry¶
Subscribes to {prefix}/bridge/devices (a retained Zigbee2MQTT topic) to build a device list, and to {prefix}/bridge/event to react to joins and departures in real time. Maintains a per-device MQTT subscription for each tracked device to track live state — incoming payloads are merged on top of the last-known state. Exposes device metadata, merged state snapshots, human-readable nice names, and change/join/leave listeners to automations. Enabled via DEVICE_REGISTRY_ENABLED=true; exposed as engine.deviceRegistry (null when disabled).
DeviceStateStore¶
A durable, unified store of every device's last-known state and the observation
that produced it, keyed by qualified id
(packages/core/src/device-sources/device-state-store.ts, design.md D1). It is
loaded before any device source starts and persisted write-behind with a
debounced flush, mirroring StateManager, so enumeration reports last-known
state from the first read after a restart instead of empty. It 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 (design.md D4). Records are
pruned when a device drops out of enumeration (design.md D5). Configured via
DEVICE_STATE_*; see Configuration.
HttpClient¶
A simple wrapper around the global fetch with structured pino logging of every request and response. Shared across all services that need HTTP.
HomekitService¶
Runs a HAP-NodeJS bridge inside the engine process. On onStart() it iterates all devices already known to the DeviceRegistry and creates a HAP accessory for each one (via homekit-accessory-factory.ts), then subscribes to device-added, device-removed, and per-device state-change events to keep accessories in sync at runtime. Implements registerRoutes() to expose GET /api/homekit/status on the shared Hono app. Requires DEVICE_REGISTRY_ENABLED=true; logs a warning and skips startup when the registry is absent.
Data flow: MQTT trigger¶
Zigbee2MQTT publishes to "zigbee2mqtt/hallway_sensor"
→ MqttService.onMessage()
→ topicMatches("zigbee2mqtt/hallway_sensor", trigger.topic)
→ payload filter (if defined)
→ automation.execute({ type: "mqtt", topic, payload })
→ automation logic runs
→ may call this.mqtt.publishToDevice(), this.state.set(), etc.
→ pino logs to stdout + LogBuffer simultaneously via multistream
Data flow: state trigger¶
automationA.execute() calls this.state.set("night_mode", true)
→ StateManager.set("night_mode", true)
→ notifies all registered listeners for "night_mode"
→ automationB.execute({ type: "state", key: "night_mode", newValue: true, oldValue: false })
→ (synchronous, same event loop tick)
Logging¶
Pino is configured with a multistream:
- Stream 1: stdout — pretty-printed in development (
NODE_ENV !== "production"), raw newline-delimited JSON in production - Stream 2:
LogBuffer— the same JSON lines stored in the ring buffer for API queries
Every service and automation uses a child logger scoped with a service or automation binding, which appears on every log line from that component.
Module boundaries¶
The framework is split into four independently published workspace packages:
| Package | Contents | Published |
|---|---|---|
@ts-ha/shared |
Domain/wire contracts, contract constants, pure helpers | Yes |
@ts-ha/core |
Engine, services, device sources, HTTP server, MQTT, state, scheduling | Yes |
@ts-ha/web-ui |
Hono routes, WebUiService plugin, React app, compiled assets |
Yes |
@ts-ha/cli |
DebugClient, ts-ha binary, OpenTUI dashboard, ts-ha run |
Yes |
Dependency direction is one-way: shared ← core, shared ← web-ui,
cli → {core, shared}. web-ui imports nothing from core (it satisfies
the service-plugin contract structurally); core never references web-ui.
See AGENTS.md (Workspace Layout) and scripts/guard-deps.ts, which enforce
this boundary.