@ajclarkson/homerun

中文文档
0.1.0发布于 2026/11/08MITTypeScriptES Module构建来源已验证近 30 天下载 2978

TypeScript automation framework for Home Assistant

原始使用文档
文档目录11 节
  1. The problem
  2. How it works
  3. Example
  4. Testing
  5. Triggers and actions
  6. Observability
  7. Declared reasons, catalogue and history
  8. Running
  9. HTTP API
  10. Architecture
  11. License

Homerun

Typed, testable Home Assistant automations in TypeScript.

Status: pre-1.0. The API may change. Builds are published to npm as @ajclarkson/homerun (edge tag, on every push to main) and as a Docker image, ghcr.io/ajclarkson/homerun:edge.

Homerun is a framework that replaces HA automations and Node-RED flows with pure functions. Every automation is a context builder and a reducer — no side effects, no hidden state, no YAML. The pipeline handles observability, error isolation, and action execution.


The problem

Home Assistant automations are YAML. Node-RED flows are JSON blobs. Both are hard to test, hard to review, and impossible to type-check. A bad deploy silently breaks your lights at 2am.

Homerun brings the same discipline you'd apply to application code: pure functions, typed inputs, and a test suite that runs in milliseconds.


How it works

Every automation follows the same pipeline:

Event → context() → reduce() → actions[]

context() reads from the HA state cache and returns a typed snapshot — or aborts early if a required input is missing. It is the only place that touches external state.

reduce() is a pure function. It receives the context and returns a decision with a list of declarative actions. No async, no side effects, no HA calls.

The pipeline handles everything else: correlation IDs, observability events, error isolation, and action execution.


Example

import { defineAutomation, abort, requireState, requireNumericState } from '@ajclarkson/homerun';

export default defineAutomation({
  id: 'kitchen:lighting',
  location: 'kitchen',
  subsystem: 'lighting',

  triggers: [
    { type: 'state_changed', entity: 'binary_sensor.kitchen_motion' },
    { type: 'on_start' },
  ],

  context(state) {
    if (requireState(state, 'input_boolean.kitchen_lights_enabled') !== 'on') {
      return abort('automation_disabled');
    }
    return {
      motion: requireState(state, 'binary_sensor.kitchen_motion') === 'on',
      lux: requireNumericState(state, 'sensor.kitchen_lux'),
      luxThreshold: requireNumericState(state, 'input_number.kitchen_lux_threshold'),
    };
  },

  reduce(ctx) {
    const shouldLight = ctx.motion && ctx.lux < ctx.luxThreshold;
    return {
      decision: shouldLight ? 'lights_on' : 'lights_off',
      actions: [
        {
          type: 'ha.call_service',
          domain: 'light',
          service: shouldLight ? 'turn_on' : 'turn_off',
          target: { entity_id: 'light.kitchen_ceiling' },
        },
      ],
    };
  },
});

requireState and requireNumericState abort the run, with the missing entity recorded, if an entity is unavailable or non-numeric, so a dead sensor never silently becomes a wrong decision. The decision's conditions default to the full context, so the published event explains itself.

Files like this go in your automations directory; see Consumer setup.


Testing

Because reduce() is a pure function, tests are just function calls. To test the whole automation without a Home Assistant connection, use the test helpers:

import { describe, it, expect } from 'vitest';
import { testAutomation } from '@ajclarkson/homerun/testing';
import kitchenLights from './kitchen-lights';

describe('kitchen lighting', () => {
  it('turns lights on when motion is detected below the lux threshold', () => {
    const result = testAutomation(kitchenLights, {
      event: { type: 'state_changed', entity_id: 'binary_sensor.kitchen_motion', new_state: { state: 'on' } },
      state: {
        'input_boolean.kitchen_lights_enabled': { state: 'on' },
        'binary_sensor.kitchen_motion': { state: 'on' },
        'sensor.kitchen_lux': { state: '20' },
        'input_number.kitchen_lux_threshold': { state: '40' },
      },
    });
    expect(result.decision).toBe('lights_on');
    expect(result.actions[0]).toMatchObject({ service: 'turn_on' });
  });
});

No mocking, no HA connection, no async. testAbort and testUnavailable cover the abort paths. See Automation API.


Triggers and actions

TriggerFires when
state_changedAn entity's state or attributes change. Filter with to; delay with duration.
scheduleA cron expression fires.
on_startThe system is ready and the state cache is fully populated.
timer_expiredA named timer set by a previous timer.start action expires.
buttonA button entity emits a single_press, double_press, or hold gesture.
mqtt_inA message arrives on a subscribed MQTT topic.
ActionEffect
ha.call_serviceCalls a Home Assistant service
mqtt.publishPublishes to an MQTT topic
timer.startStarts (or restarts) a named timer
timer.cancelCancels a named timer

Actions are returned from reduce() as a plain array; the runtime executes them after the decision event is published. Full types and semantics: Automation API.


Observability

Every pipeline run publishes an event to MQTT — whether it decided, aborted, or threw — and every action publishes its own start and result events. A correlation_id minted at the event source lets you trace a HA state change through to the service call that resulted from it.

{
  "schema": "home.events.v2",
  "correlation_id": "b3d2f1a0-...",
  "root_correlation_id": "b3d2f1a0-...",
  "automation_id": "kitchen:lighting",
  "location": "kitchen",
  "subsystem": "lighting",
  "event_type": "decision",
  "trigger": { "type": "state_changed", "entity_id": "binary_sensor.kitchen_motion", "to": "on" },
  "decision": "lights_on",
  "conditions": { "motion": true, "lux": 20, "luxThreshold": 40 },
  "actions": [{ "type": "ha.call_service", "domain": "light", "service": "turn_on" }],
  "hasAction": true,
  "timestamp": "2026-06-23T18:00:00.000Z"
}

Schema, topics, command-ack tracking and Prometheus metrics: Observability.


Declared reasons, catalogue and history

By default a decision's reason is a free string. An automation can instead declare the reasons it may give, so a consumer (a dashboard, a notifier) can explain what happened without parsing strings. Everything here is opt-in: an automation without reasons behaves and publishes exactly as before.

export default defineAutomation({
  id: 'kitchen:lighting',
  location: 'kitchen',
  subsystem: 'lighting',
  triggers: [{ type: 'state_changed', entity: 'binary_sensor.kitchen_motion' }],
  reasons: {
    lights_on: { outcome: 'acted' },
    lux_high: { outcome: 'declined', facts: ['lux', 'threshold'] },
    startup: { outcome: 'ignored' },
  },
  context: (state) => ({ lux: requireNumericState(state, 'sensor.kitchen_lux') }),
  reduce: ({ lux }) =>
    lux > 40
      ? { decision: 'no_action', reason: 'lux_high', facts: { lux, threshold: 40 }, actions: [] }
      : { decision: 'turn_on', reason: 'lights_on', actions: [kitchenLightsOn] },
});
  • outcome is acted (the rule changed something), declined (it chose not to act and a person would want to know why) or ignored (noise: startup, already in the right state). It is declared, never inferred from actions.
  • facts are the typed values a sentence needs: at most 8 keys per reason, each a string, finite number, boolean or null. They are separate from conditions on purpose: conditions is a debug dump of the whole context that may change shape freely, facts is the small, named, checked set a consumer can depend on.
  • Declared reasons are enforced three times. At compile time reason is the union of declared keys and facts must carry exactly that reason's keys (an extra key is only caught at run time, as TypeScript does not check excess properties on returned objects). At load time defineAutomation throws on a bad outcome, fact list or reason name (letters, digits and underscores only: reasons carry no free text). At run time an undeclared reason, or an undeclared, missing or non-primitive fact, never stops the actions: the decision is published without outcome or facts (the undeclared shape) and a separate declaration_error event is published to homerun/events and logged, with problem (undeclared_reason, missing_fact, undeclared_fact or invalid_fact_value) and, when identifier-shaped, reason and fact. Observability must never prevent a real action. Automations that declare nothing cannot return facts (a compile error). defineAutomation and the registry (including hot reload) both reject a bad declaration, and the reserved location dev, at load.
  • The decision event gains optional outcome and facts, only for declaring automations.

Catalogue. The declarations are published retained on homerun/catalog (home.catalog.v1) at startup and after a reload, and each entry's reasons is added to GET /automations. Only automations that declare reasons appear. The catalogue is published on every startup, empty if nothing declares reasons, so a retained copy is never stale. A consumer can check its wording table against it.

History. Set history in the configuration (off by default) to keep a ring buffer of the events worth asking "what happened here?" about:

history:
  size: 20        # per location, 1 to 200
  houseSize: 30   # house-wide, 1 to 200

Each stored entry is republished, newest first, retained on homerun/{location}/history and homerun/history (home.history.v1). Remembered: declared acted and declined decisions (or, for undeclared automations, decisions with an action), aborts other than disabled, and failed actions (a failed action_result or an action_ack_timeout). Entries are structured only: reason keys, facts, a trigger summary, and a detail record (abort_kind/entity, or failure/action/entity). Retained republishes are coalesced (250ms) with the latest state always published, and flushed on shutdown. On startup the buffers are re-seeded from the retained topics, so history survives a restart; a malformed retained message is logged and that buffer starts empty. Seeding waits 1s for the broker to deliver the retained messages. dev is reserved as a location name (rejected at registration). Both the catalogue and history follow events.enabled: when it is false, neither is published (nor history seeded or kept), like the rest of the MQTT stream. In dry-run mode every one of these topics moves under homerun/dev/.

The JSON Schemas for both payloads are in schemas/ and published with the package. Every payload is validated against them before it is published.


Running

npm install @ajclarkson/homerun

Homerun reads a configuration.yaml (path from HOMERUN_CONFIG) with your Home Assistant URL and token, MQTT broker and automations directory. Secrets can live in a secrets.yaml beside it and be referenced with !secret. configuration.yaml.example shows every option.

Set options.dry_run: true to run the full pipeline — context, reduce, observability — without executing any actions.

Setting up your own automations repo, generating entity types from your Home Assistant, and running the runtime are covered in Consumer setup.

To work on the framework itself, clone this repo and use npm run dev (run from source, hot-reloads) and npm test.


HTTP API

The server exposes an HTTP API on port 7070 by default (server.port in the config).

MethodPathDescription
GET/health/liveAlways returns 200 { status: "live" } — used for liveness probes.
GET/health/readyReturns 200 when the HA state cache is populated and automations are loaded, 503 while starting. Response includes entities, automations counts, and dry_run: true if running in dry-run mode.
GET/automationsLists all registered automations with their id, location, subsystem, and trigger types, plus reasons for those that declare them.
POST/automations/:id/triggerManually fires an on_start event for the given automation.
POST/reloadRescans the automations directory and hot-reloads changed files.
GET/eventsServer-sent event stream of all pipeline decisions and action events in real time.

Architecture

HAClient ──────────────────────────────────────┐
  └─ state_changed (per entity, with corr. ID) │
                                               ▼
Scheduler (cron / on_start) ──────► TriggerEngine.dispatch()
TimerManager (setTimeout)  ──────►        │
                                          ▼
                                   matchAndFire()
                                          │
                              ┌───────────▼──────────┐
                              │    Pipeline Runner    │
                              │  context → reduce     │
                              │  → validate → fanout  │
                              └──────────┬────────────┘
                                         │
                          ┌──────────────┴──────────────┐
                          ▼                             ▼
                   Observability                  ActionRuntime
                 (MQTT events)           (HA calls / timers / MQTT)

License

MIT

版本与发布记录

@ajclarkson/homerun 共发布 119 个版本,首次发布于 2026/07/11;过去 12 个月发布了 119 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.1.0latest2026/11/08今天
0.1.0-edge.f10ba12edge2026/11/08今天
0.1.0-edge.710bb4a2026/11/071 天前
0.1.0-edge.826524a2026/11/026 天前
0.0.1-edge.0a3c3ff2026/11/026 天前
0.0.1-edge.8c93a272026/11/026 天前

关于 @ajclarkson/homerun 的常见问题

@ajclarkson/homerun 是什么?

TypeScript automation framework for Home Assistant

如何安装 @ajclarkson/homerun?

在项目目录运行 npm install @ajclarkson/homerun,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 homerun、homerun-generate-ha-types、homerun-generate-ha-services,可以用 npx homerun 直接运行。

@ajclarkson/homerun 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@ajclarkson/homerun 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@ajclarkson/homerun 可以商用吗?

@ajclarkson/homerun 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@ajclarkson/homerun 的下载量有多少?

根据 npm 官方下载统计,@ajclarkson/homerun 在 2026/10/05 – 2026/11/03 这 30 天内被下载 2,978 次,日均约 99 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@ajclarkson/homerun 还在维护吗?

最新版本 0.1.0 发布于 2026/11/08(今天);至今共发布 119 个版本,其中过去 12 个月发布了 119 个;首次发布于 2026/07/11。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。