全垒打
TypeScript中的打字可测试的家庭助理自动化.
状况:1.0前. API可能会改变. 构建发布为 npm
@ajclarkson/homerun(单位:千美元)edge标签,每推一次main和作为多克的形象,ghcr.io/ajclarkson/homerun:edge。 。 。 .
Homerun是一个以纯函数取代HA自动化和节点-RED流量的框架. 每个自动化都是上下文构建器和还原器——没有副作用,没有隐藏状态,没有YAML. 管线处理可观察性,错位隔离,动作执行.
问题
家庭助理自动化是YAML. 节点-RED流量为JSON blobs. 两者都难以测试,难以审查,也无法进行打字检查. 凌晨2点,一个糟糕的部署 静静地打破了你的灯.
Homerun带来了与应用代码相同的学科:纯函数,打入输入,以及以毫秒为单位运行的测试套件.
如何运作
每个自动化都遵循同样的管道:
Event → context() → reduce() → actions[]
context() 从 HA 状态缓存中读取并返回打入的快照——如果缺少所需的输入,则提前中止。 这是唯一触及外部国家的地方.
reduce() 是一个纯函数。 它收到上下文,并发回附有声明行动清单的决定。 没有Aync 没有副作用 没有HA的电话.
输油管 处理其他所有事项:关联ID、可观察事件、错误隔离和动作执行.
示例
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 财务报告和审定财务报表 requireNumericState 中止运行,如果一个实体不存在或非数字存在,则将丢失的实体记录下来,因此一个已死传感器永远不会沉默地变成一个错误的决定. 决定 conditions 默认到完整上下文,所以已发布的事件会解释自己.
这样的文件会进入您的自动化目录; 见 消费者设置。 。 。 .
测试
因为 reduce() 是纯函数,测试只是函数调用。 在没有家庭助理连接的情况下测试整个自动化,使用测试助手:
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' });
});
});
没有嘲弄,没有HA连接,没有Aync. testAbort 财务报告和审定财务报表 testUnavailable 覆盖中止路径。 见 自动化 API。 。 。 .
触发和行动
| 触发 | 当火 |
|---|---|
state_changed | 实体的状态或属性变化. 过滤器为 to; 延迟使用 duration。 。 。 . |
schedule | 疯狂的表达火. |
on_start | 系统准备就绪,国家缓存人员已满员. |
timer_expired | 前一个定时器 timer.start 动作已过期. |
button | 按钮实体发出 a single_press, (中文(简体) ), double_press,或 hold 手势. |
mqtt_in | 发送到 MQTT 主题的消息 . |
| 行动 | 效果 |
|---|---|
ha.call_service | 家庭助理服务 |
mqtt.publish | 发布到 MQTT 主题 |
timer.start | 启动( 或重新启动) 一个命名的计时器 |
timer.cancel | 取消命名的计时器 |
从 reduce() 作为平面数组;运行时间在决定事件发布后执行. 完整类型和语义 : 自动化 API。 。 。 .
观察性
每个管道运行都会向MQTT发布一个事件——无论是决定,中止,还是扔出——每个动作都会发布自己的起步和结果事件. A 类 correlation_id 在事件源头做薄荷 让你追踪到一个HA状态的变化 到服务电话 由此产生.
{
"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,主题,指令包跟踪和Prometheus 度量衡: 观察性。 。 。 .
宣布的理由、目录和历史
默认的决定 reason 是一个自由字符串。 相反,自动化可以宣布它可能给出的原因,因此消费者(一个仪表板,一个通知器)可以在不解析字符串的情况下解释发生的事情. 这里的一切都是选入的:没有自动化的 reasons 行为和出版都和以前一样.
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实值acted(规则改变了什么),declined(它选择不采取行动,一个人会想知道原因)或ignored(噪声:起步,已入正态). 它被宣布,从来没有从actions。 。 。 .facts是输入的句子需要的值:每个理由最多8个按键,每个按字符串、有限数字、布尔或null它们与conditions目的:conditions是整个上下文的调试堆放 可能自由改变形状,facts是消费者可以依赖的小型、 命名、 检查的套件.- 宣布的理由执行三次。 编译时间
reason已声明的密钥和facts必须准确携带该理由的密钥(额外密钥只在运行时被捕获,因为TypeScript不检查返回对象的多余属性)。 装入时间defineAutomation丢入不良结果、事实列表或理由名称(字母、数字和下划线:理由不包含自由文本)。 在运行时,一个未申报的理由,或一个未申报、失踪或非原始事实,从未停止行动:决定是在没有公布的情况下公布的outcome或者说facts和单独的形状declaration_error事件发布到homerun/events并登录,与problem(单位:千美元)undeclared_reason, (中文(简体) ),missing_fact, (中文(简体) ),undeclared_fact或者说invalid_fact_value当标识形状时,reason财务报告和审定财务报表fact观察力绝不能阻止真正的行动。 宣布无一物不能返回的自动化facts(一个编译错误).defineAutomation和登记处(包括热重装)均拒绝错误声明,并拒绝保留地点dev在装货. - 决定事件获得可选
outcome财务报告和审定财务报表facts,只用于宣布自动化.
目录. 声明保留于 homerun/catalog (单位:千美元)home.catalog.v1) 启动时和重新装入后,每个条目的 reasons 添加到 GET /automations。只有宣布原因的自动化设备才会出现。 该目录在每次启动时都会被公布,如果无内容则会空出原因,所以保留的副本永远不会被搁置. 消费者可以对照其措辞表检查.
历史. 设定 history 在配置中(默认关闭)保留一个环缓冲器来显示值得询问的事件“这里发生了什么?”
history:
size: 20 # per location, 1 to 200
houseSize: 30 # house-wide, 1 to 200
每个存储条目重新发布,最先更新,保留在 homerun/{location}/history 财务报告和审定财务报表 homerun/history (单位:千美元)home.history.v1) (中文(简体) ). 记得:已宣布 acted 财务报告和审定财务报表 declined (或,对于未宣布的自动化、有行动的决定),除 disabled,则失败动作(失败) action_result 或一个 action_ack_timeout) (中文(简体) ). 条目只结构化:理由键、事实、a trigger 概要和a detail 记录abort_kind编号 :entity,或 failure编号 :action编号 :entity。 留存的再版是和最新状态( 250ms) 相接的, 并被冲出关闭状态 。 启动时, 缓冲器会从保留的主题中重新播种, 因此历史可以幸存到重启中; 保存错误的信息会被记录下来, 而缓冲器开始为空 。 种子等待 1s 由经纪人发送保留的信息. dev 作为地点名称保留(登记时被拒)。 书目和历史都遵循 events.enabled: 当是时 false,既不是出版(也不是原始历史,也不是保存),与MQTT流的其他部分一样。 在干跑模式下,每个话题都移动到下面 homerun/dev/。 。 。 .
两种有效载荷的JSON Schemas装入 schemas/ 并随包出版. 每一个有效载荷在公布之前都要对他们进行验证.
运行
npm install @ajclarkson/homerun
家常读作 configuration.yaml (路径从 HOMERUN_CONFIG)与您的家用助理 URL 和令牌, MQTT 经纪人和自动化目录. 秘密可以生活在一个 secrets.yaml 旁边,并被引用 !secret。 。 。 . configuration.yaml.example 显示每个选项.
设定 options.dry_run: true 在不采取任何行动的情况下运行整个管道——环境、减少、可观察性.
设置您的自动回放, 从您的家庭助理中生成实体类型, 运行运行时间包含在 消费者设置。 。 。 .
要研究框架本身 复制并使用 npm run dev (从源头跑出,热量重载)和 npm test。 。 。 .
HTTP API 密码
服务器在端口显示 HTTP API 7070 默认(server.port 在配置中.
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /health/live | 总是返回 200 { status: "live" } 用于活性探测器. |
GET | /health/ready | 回返 200 当HA状态的缓存被装入,自动化设备被装入时, 503 开始的时候。 反应包括 entities, (中文(简体) ), automations 计数,以及 dry_run: true 如果运行在干燥模式中. |
GET | /automations | 列出所有注册自动化及其 id, (中文(简体) ), location, (中文(简体) ), subsystem类型,加 reasons 给那些宣布他们有罪的人. |
POST | /automations/:id/trigger | 手动点火 on_start 用于给定自动化的事件. |
POST | /reload | 重新扫描自动化目录并重载已更改的文件 . |
GET | /events | 所有编审中的决定和行动活动的服务器发送事件流实时. |
建筑
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)
许可证
麻省理工学院