refactor: 重构为单向生成架构,CLI 零参数可用

- 迁移 examples/rules/ 到 src/rules/,resources/devices.json 到 src/devices.json
- 迁移 resources/fetch-devlist.js 到 src/tools/fetch-devlist.ts 并改造为 ESM
- CLI 简化为零参数:bun run pack 自动编译并输出 dist/miot_{timestamp}.bak
- 移除备份合并能力:删除 mergeCompiledRules、RuleReplaceStrategy、dry-run、replace 参数
- 编译器简化为从零生成,不再支持 baseBackup 合并
- 新增 fetch-devices 命令用于从中枢网关更新设备清单
- 新增 bn.js、elliptic 依赖
- 更新测试路径引用,移除依赖合并逻辑的测试用例
- 更新 README.md 反映新项目结构和命令
This commit is contained in:
2026-05-08 00:03:27 +08:00
parent 690ef8ce83
commit 63dc55fa5a
15 changed files with 2133 additions and 243 deletions

View File

@@ -1,11 +1,9 @@
# miot_x
`miot_x` 是一个基于 Bun + TypeScript 的米家极客版工作流编排实验项目。目标是用 TypeScript 代码描述简单工作流,再编译、校验并打包成米家极客版可导入的 `.bak` 备份文件。
`miot_x` 是一个基于 Bun + TypeScript 的米家极客版工作流编排项目。用 TypeScript 代码描述工作流规则,编译并打包成米家极客版可导入的 `.bak` 备份文件。
## 当前范围
第一版只验证“编程化开发工作流”的可行性,不做纯文本 DSL也不做完整 MIoT 元数据智能解析。
当前支持:
- 设备属性触发和事件触发,生成 `deviceInput`
@@ -15,34 +13,38 @@
- 简单逻辑组合,生成 `logicAnd``logicOr``logicNot`
- 延时动作,生成 `delay`
- 注释节点,生成 `nop`
- `.bak` 解包、打包、合并和校验
- `.bak` 解包、打包和校验
- 从中枢网关抓取设备清单
当前不支持:
- 纯文本 DSL 或自定义 parser
- 中文能力名自动匹配,例如 `light.power.on()`
- 主动连接中枢网关或复现 websocket 会话
- 自动删除旧规则、旧变量或自动清理孤儿变量
- 完整覆盖米家极客版所有节点类型
## 项目结构
```text
src/
index.ts # public API
builder.ts # TypeScript 规则 builder
graph.ts # Graph IR 与节点 ID
nodes.ts # 米家节点模板
compiler.ts # 规则编译与备份合并
validate.ts # 结构、连接、设备和能力参数校验
backup.ts # .bak 解包与打包
cli.ts # 命令入口
examples/rules/
index.ts # 最小规则示例
*.test.ts # Bun 测试
resources/
devices.json # 设备清单
*.bak # 真实备份样本
index.ts # public API
builder.ts # TypeScript 规则 builder
graph.ts # Graph IR 与节点 ID
nodes.ts # 米家节点模板
compiler.ts # 规则编译与备份生成
validate.ts # 结构、连接、设备和能力参数校验
backup.ts # .bak 解包与打包
cli.ts # 命令入口(零参数)
devices.json # 设备清单(由 fetch-devices 工具更新)
rules/
index.ts # 个人规则
tools/
fetch-devlist.ts # 中枢网关设备抓取工具
tests/
backup.test.ts
compiler.test.ts
validate.test.ts
dist/ # 输出目录(.bak 备份文件)
```
## 编写规则
@@ -50,7 +52,7 @@ resources/
规则直接使用 TypeScript
```ts
import { defineDevices, defineRule } from "../../src/index.ts";
import { defineDevices, defineRule } from "../index.ts";
const devices = defineDevices({
corridorLight: {
@@ -75,24 +77,23 @@ export default defineRule("走廊有人开灯", ({ device, on }) =>
## 打包命令
预览编译结果,不写出文件:
编译规则并生成 `.bak` 备份文件:
```bash
bun run pack -- --rules examples/rules/index.ts --devices resources/devices.json --input "resources/备份2026_4_26 19_17_42.bak" --dry-run
bun run pack
```
输出新的 `.bak` 文件:
自动读取 `src/rules/index.ts``src/devices.json`,从零编译,输出到 `dist/miot_{timestamp}.bak`
## 更新设备清单
从中枢网关抓取最新设备清单:
```bash
bun run pack -- --rules examples/rules/index.ts --devices resources/devices.json --input "resources/备份2026_4_26 19_17_42.bak" --output dist/miot.bak
bun run fetch-devices 192.168.31.166 048889
```
默认策略是追加新规则。显式替换可使用:
```bash
bun run pack -- --rules examples/rules/index.ts --input old.bak --output new.bak --replace-id 1728570847554
bun run pack -- --rules examples/rules/index.ts --input old.bak --output new.bak --replace-name 走廊开关灯
```
`048889` 是中枢网关屏幕上显示的 6 位 passcode。抓取结果直接写入 `src/devices.json`
## 校验策略
@@ -113,7 +114,7 @@ bun run pack -- --rules examples/rules/index.ts --input old.bak --output new.bak
bun test
```
测试覆盖真实备份解包统计、备份打包往返、规则编译输出、备份合并和常见校验错误。
测试覆盖真实备份解包统计、备份打包往返、规则编译输出和常见校验错误。
## 导入验证建议

View File

@@ -4,6 +4,10 @@
"workspaces": {
"": {
"name": "miot_x",
"dependencies": {
"bn.js": "^5.2.1",
"elliptic": "^6.6.1",
},
"devDependencies": {
"@types/bun": "latest",
},
@@ -17,10 +21,28 @@
"@types/node": ["@types/node@25.6.0", "https://registry.npmmirror.com/@types/node/-/node-25.6.0.tgz", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"bn.js": ["bn.js@5.2.3", "https://registry.npmmirror.com/bn.js/-/bn.js-5.2.3.tgz", {}, "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w=="],
"brorand": ["brorand@1.1.0", "https://registry.npmmirror.com/brorand/-/brorand-1.1.0.tgz", {}, "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w=="],
"bun-types": ["bun-types@1.3.13", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.13.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"elliptic": ["elliptic@6.6.1", "https://registry.npmmirror.com/elliptic/-/elliptic-6.6.1.tgz", { "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", "hash.js": "^1.0.0", "hmac-drbg": "^1.0.1", "inherits": "^2.0.4", "minimalistic-assert": "^1.0.1", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g=="],
"hash.js": ["hash.js@1.1.7", "https://registry.npmmirror.com/hash.js/-/hash.js-1.1.7.tgz", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="],
"hmac-drbg": ["hmac-drbg@1.0.1", "https://registry.npmmirror.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz", { "dependencies": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg=="],
"inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"minimalistic-assert": ["minimalistic-assert@1.0.1", "https://registry.npmmirror.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
"minimalistic-crypto-utils": ["minimalistic-crypto-utils@1.0.1", "https://registry.npmmirror.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", {}, "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg=="],
"typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.19.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"elliptic/bn.js": ["bn.js@4.12.3", "https://registry.npmmirror.com/bn.js/-/bn.js-4.12.3.tgz", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
}
}

View File

@@ -0,0 +1,22 @@
# Capability: Fetch Devices Tool
## Purpose
Provide a standalone CLI tool to fetch device lists from a Mi Home central hub gateway via ECJPAKE-encrypted WebSocket connection and write the result to `src/devices.json`.
## Requirements
### Requirement: 设备清单抓取工具
系统 SHALL 提供独立的 CLI 工具,通过 ECJPAKE 加密连接中枢网关 WebSocket 接口,调用 `getDevList` API 获取设备清单,并将结果直接写入 `src/devices.json`
#### Scenario: 通过 IP 和 passcode 抓取设备
- **WHEN** 用户执行 `bun run fetch-devices <ip> <passcode>` 命令
- **THEN** 系统 SHALL 连接 `ws://<ip>/centrallinkws/`,完成 ECJPAKE 密钥协商和加密通道建立,调用 `getDevList` API将结果以 `{ fetchedAt, url, count, devList }` 格式写入 `src/devices.json`
#### Scenario: passcode 格式校验
- **WHEN** 用户输入的 passcode 不是 6 位数字
- **THEN** 系统 SHALL 报错并退出,不写入文件
#### Scenario: 连接失败处理
- **WHEN** 网关连接超时或 WebSocket 连接失败
- **THEN** 系统 SHALL 报错并退出,不写入文件,不修改已有的 `src/devices.json`

View File

@@ -2,7 +2,7 @@
## Purpose
Allow users to author 米家极客版 (Mi Home Geek Edition) workflow rules using TypeScript files and a builder API, compile them into backup JSON, and package them as `.bak` files — with validation diagnostics and conservative merge semantics.
Allow users to author 米家极客版 (Mi Home Geek Edition) workflow rules using TypeScript files and a builder API, compile them into backup JSON, and package them as `.bak` files — with validation diagnostics.
## Requirements
@@ -74,7 +74,7 @@ Allow users to author 米家极客版 (Mi Home Geek Edition) workflow rules usin
- **THEN** 每条输出连接 SHALL 使用 `targetNodeId.targetPortName` 字符串格式,并指向存在的节点输入端口
### Requirement: 备份文件打包输出
系统 SHALL 使用已知文件外壳将生成的备份 JSON 打包为米家极客版 `.bak` 文件。
系统 SHALL 使用已知文件外壳将生成的备份 JSON 打包为米家极客版 `.bak` 文件CLI 不需要任何参数即可完成全流程
#### Scenario: 备份文件编码
- **WHEN** 系统写出备份文件
@@ -84,28 +84,10 @@ Allow users to author 米家极客版 (Mi Home Geek Edition) workflow rules usin
- **WHEN** 系统解包一个已生成的备份文件
- **THEN** 解包得到的 JSON SHALL 与压缩前的编译结果 JSON 一致
### Requirement: 保守备份合并
系统 SHALL 默认保留现有备份内容,只新增或显式替换用户选择的工作流规则。
#### Scenario: 零参数 CLI 编译
- **WHEN** 用户执行 `bun run pack` 且不传入任何参数
- **THEN** 系统 SHALL 自动从 `src/rules/index.ts` 加载规则定义,从 `src/devices.json` 加载设备清单,从零编译生成备份,并写入 `dist/miot_{timestamp}.bak`
#### Scenario: 追加生成规则
- **WHEN** 用户基于现有备份编译新规则且未指定替换策略
- **THEN** 系统 SHALL 保留所有已有规则,并追加生成的规则
#### Scenario: 按显式身份替换规则
- **WHEN** 用户指定生成规则按 ID 或唯一名称替换已有规则
- **THEN** 系统 SHALL 只替换匹配规则,并保持无关规则不变
### Requirement: 校验诊断
系统 SHALL 在写出最终 `.bak` 前校验生成的工作流,并为无效结构报告可操作的诊断信息。
#### Scenario: 非法节点 ID
- **WHEN** 生成或用户指定的节点 ID 包含 `0-9a-zA-Z` 之外的字符
- **THEN** 校验 MUST 失败,并给出能定位非法节点的诊断信息
#### Scenario: 非法连接目标
- **WHEN** 生成连接指向不存在的节点或不存在的输入端口
- **THEN** 校验 MUST 失败,并给出能定位非法连接的诊断信息
#### Scenario: 设备资源中不存在的设备
- **WHEN** 工作流引用的 `did` 不存在于配置的 `devices.json` 资源中
- **THEN** 校验 MUST 根据所选严格模式失败或警告,且诊断信息 SHALL 包含缺失的 `did`
#### Scenario: 输出文件命名
- **WHEN** 系统写出备份文件
- **THEN** 文件名 SHALL 使用 `miot_{yyyyMMdd_HHmmss}.bak` 格式,位于 `dist/` 目录下

View File

@@ -4,8 +4,13 @@
"private": true,
"scripts": {
"pack": "bun run src/cli.ts",
"fetch-devices": "bun run src/tools/fetch-devlist.ts",
"test": "bun test"
},
"dependencies": {
"bn.js": "^5.2.1",
"elliptic": "^6.6.1"
},
"devDependencies": {
"@types/bun": "latest"
},

View File

@@ -51,7 +51,6 @@ export function defineRule(name: string, factory: RuleFactory, options: RuleOpti
name,
id: options.id,
enable: options.enable ?? true,
replace: options.replace,
workflow,
};
}

View File

@@ -1,101 +1,42 @@
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { readBackupFile, readDeviceInventoryFile, writeBackupFile } from "./backup";
import { resolve } from "node:path";
import { writeBackupFile } from "./backup";
import { collectRuleDefinitions, compileWorkflow } from "./compiler";
import type { Diagnostic, RuleDefinition, RuleReplaceStrategy } from "./types";
import type { Diagnostic } from "./types";
interface CliOptions {
rules?: string;
input?: string;
output?: string;
devices?: string;
dryRun: boolean;
replace?: RuleReplaceStrategy;
}
const srcDir = import.meta.dir;
export async function runCli(argv = process.argv.slice(2)): Promise<void> {
const options = parseArgs(argv);
if (!options.rules) {
throw new Error("缺少 --rules <path> 规则入口");
}
const rulesModule = await import(pathToFileURL(resolve(options.rules)).href) as Record<string, unknown>;
const definitions = applyReplaceStrategy(collectRuleDefinitions(rulesModule), options.replace);
async function runCli(): Promise<void> {
const rulesModule = await import(resolve(srcDir, "rules", "index.ts"));
const definitions = collectRuleDefinitions(rulesModule as Record<string, unknown>);
if (definitions.length === 0) {
throw new Error("规则入口没有导出任何 defineRule 结果");
}
const baseBackup = options.input ? readBackupFile(options.input) : undefined;
const devices = options.devices ? readDeviceInventoryFile(options.devices) : undefined;
const result = compileWorkflow(definitions, baseBackup, { devices, deviceStrict: true });
printSummary(result.summary.addedRules, result.summary.nodeCount, result.summary.deviceDids, result.diagnostics, options);
const devices = (await import("./devices.json")).default;
const result = compileWorkflow(definitions, { devices, deviceStrict: true });
const errors = result.diagnostics.filter((diagnostic) => diagnostic.severity === "error");
printSummary(result.summary.addedRules, result.summary.nodeCount, result.summary.deviceDids, result.diagnostics);
const errors = result.diagnostics.filter((diagnostic: Diagnostic) => diagnostic.severity === "error");
if (errors.length > 0) {
throw new Error("编译校验失败,未写出备份文件");
}
if (!options.dryRun) {
if (!options.output) {
throw new Error("缺少 --output <path> 输出备份路径;如只想预览请使用 --dry-run");
}
mkdirSync(dirname(resolve(options.output)), { recursive: true });
writeBackupFile(options.output, result.backup);
console.log(`输出备份: ${options.output}`);
}
}
function parseArgs(argv: string[]): CliOptions {
const options: CliOptions = { dryRun: false };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--dry-run") {
options.dryRun = true;
continue;
}
if (arg === "--rules") {
options.rules = readArgValue(argv, ++index, arg);
continue;
}
if (arg === "--input") {
options.input = readArgValue(argv, ++index, arg);
continue;
}
if (arg === "--output") {
options.output = readArgValue(argv, ++index, arg);
continue;
}
if (arg === "--devices") {
options.devices = readArgValue(argv, ++index, arg);
continue;
}
if (arg === "--replace-id") {
options.replace = { mode: "replaceById", id: readArgValue(argv, ++index, arg) };
continue;
}
if (arg === "--replace-name") {
options.replace = { mode: "replaceByName", name: readArgValue(argv, ++index, arg) };
continue;
}
throw new Error(`未知参数: ${arg}`);
}
return options;
}
function readArgValue(argv: string[], index: number, name: string): string {
const value = argv[index];
if (!value) {
throw new Error(`${name} 缺少参数值`);
}
return value;
}
function applyReplaceStrategy(definitions: RuleDefinition[], replace?: RuleReplaceStrategy): RuleDefinition[] {
if (!replace) {
return definitions;
}
return definitions.map((definition) => ({ ...definition, replace }));
const now = new Date();
const timestamp = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
"_",
String(now.getHours()).padStart(2, "0"),
String(now.getMinutes()).padStart(2, "0"),
String(now.getSeconds()).padStart(2, "0"),
].join("");
const outputPath = resolve(srcDir, "..", "dist", `miot_${timestamp}.bak`);
mkdirSync(resolve(srcDir, "..", "dist"), { recursive: true });
writeBackupFile(outputPath, result.backup);
console.log(`输出备份: ${outputPath}`);
}
function printSummary(
@@ -103,15 +44,12 @@ function printSummary(
nodeCount: number,
deviceDids: string[],
diagnostics: Diagnostic[],
options: CliOptions,
): void {
console.log("编译摘要:");
console.log(`规则数量: ${ruleNames.length}`);
console.log(`规则名称: ${ruleNames.join(", ")}`);
console.log(`节点数量: ${nodeCount}`);
console.log(`设备引用: ${deviceDids.length > 0 ? deviceDids.join(", ") : "无"}`);
console.log(`输入备份: ${options.input ?? "空备份"}`);
console.log(`输出备份: ${options.dryRun ? "dry-run 未写出" : options.output ?? "未指定"}`);
for (const diagnostic of diagnostics) {
console.log(`${diagnostic.severity.toUpperCase()} ${diagnostic.code}: ${diagnostic.message}`);
}

View File

@@ -21,7 +21,6 @@ import type {
CompileOptions,
CompileResult,
ConditionExpr,
Diagnostic,
Graph,
GraphNode,
RuleDefinition,
@@ -60,14 +59,16 @@ export function compileRule(definition: RuleDefinition, options: CompileOptions
export function compileWorkflow(
definitions: RuleDefinition[],
baseBackup?: BackupData,
options: CompileOptions = {},
): CompileResult {
const compiled = compileRules(definitions, options);
const mergeDiagnostics: Diagnostic[] = [];
const backup = mergeCompiledRules(baseBackup, compiled, mergeDiagnostics);
const diagnostics = [...mergeDiagnostics, ...validateBackup(backup, options)];
const summary = summarizeCompile(compiled, mergeDiagnostics);
const backup: BackupData = {
version: 2,
rules: compiled.map((item) => item.rule),
variables: { global: {} },
};
const diagnostics = validateBackup(backup, options);
const summary = summarizeCompile(compiled);
return {
backup,
rules: compiled.map((item) => item.rule),
@@ -76,60 +77,6 @@ export function compileWorkflow(
};
}
export function mergeCompiledRules(
baseBackup: BackupData | undefined,
compiled: CompiledRule[],
diagnostics: Diagnostic[] = [],
): BackupData {
const next: BackupData = cloneBackup(
baseBackup ?? {
version: 2,
rules: [],
variables: { global: {} },
},
);
for (const item of compiled) {
const strategy = item.definition.replace ?? { mode: "append" as const };
if (strategy.mode === "append") {
next.rules.push(item.rule);
continue;
}
if (strategy.mode === "replaceById") {
const index = next.rules.findIndex((rule) => rule.id === strategy.id);
if (index >= 0) {
next.rules[index] = item.rule;
} else {
diagnostics.push({
severity: "warning",
code: "replace-id-not-found",
message: `未找到 ID 为 ${strategy.id} 的规则,已改为追加 ${item.definition.name}`,
});
next.rules.push(item.rule);
}
continue;
}
const name = strategy.name ?? item.definition.name;
const matches = next.rules
.map((rule, index) => ({ rule, index }))
.filter(({ rule }) => rule.cfg.userData.name === name);
if (matches.length === 1) {
next.rules[matches[0]!.index] = item.rule;
} else {
diagnostics.push({
severity: "warning",
code: "replace-name-not-unique",
message: `规则名称 ${name} 匹配数量为 ${matches.length},已改为追加 ${item.definition.name}`,
});
next.rules.push(item.rule);
}
}
return next;
}
export function collectRuleDefinitions(moduleExports: Record<string, unknown>): RuleDefinition[] {
const definitions: RuleDefinition[] = [];
const seen = new Set<RuleDefinition>();
@@ -310,13 +257,9 @@ function isRuleDefinition(value: unknown): value is RuleDefinition {
return typeof value === "object" && value !== null && (value as { kind?: unknown }).kind === "rule-definition";
}
function summarizeCompile(compiled: CompiledRule[], diagnostics: Diagnostic[]) {
const replacedRules = diagnostics
.filter((diagnostic) => diagnostic.code.startsWith("replace-"))
.map((diagnostic) => diagnostic.message);
function summarizeCompile(compiled: CompiledRule[]) {
return {
addedRules: compiled.map((item) => item.rule.cfg.userData.name),
replacedRules,
nodeCount: compiled.reduce((sum, item) => sum + item.rule.nodes.length, 0),
deviceDids: collectDeviceDids(compiled),
};
@@ -335,10 +278,6 @@ function collectDeviceDids(compiled: CompiledRule[]): string[] {
return [...dids].sort();
}
function cloneBackup(backup: BackupData): BackupData {
return cloneJson(backup);
}
function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}

1378
src/devices.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -12,8 +12,8 @@ export {
any,
not,
} from "./builder";
export { BACKUP_HEADER, backupStats, packBackup, readBackupFile, readDeviceInventoryFile, unpackBackup, writeBackupFile } from "./backup";
export { collectRuleDefinitions, compileRule, compileRules, compileWorkflow, mergeCompiledRules } from "./compiler";
export { BACKUP_HEADER, backupStats, packBackup, readBackupFile, unpackBackup, writeBackupFile } from "./backup";
export { collectRuleDefinitions, compileRule, compileRules, compileWorkflow } from "./compiler";
export { assertValidBackup, validateBackup } from "./validate";
export type {
ActionExpr,

View File

@@ -1,4 +1,4 @@
import { defineDevices, defineRule } from "../../src/index.ts";
import { defineDevices, defineRule } from "../index.ts";
const devices = defineDevices({
corridorLight: {

620
src/tools/fetch-devlist.ts Normal file
View File

@@ -0,0 +1,620 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { deflateRawSync, inflateRawSync } from "node:zlib";
import { writeFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import BN from "bn.js";
import { ec as EllipticEC } from "elliptic";
const DATA_TYPE = {
PROTOCOL_LIST: 1,
SELECTED_PROTOCOL: 2,
SESSION_KEY_EXCHANGE: 3,
ERROR: 4,
DATA: 5,
ECJPAKE_ROUND_ONE: 32,
ECJPAKE_ROUND_TWO: 33,
} as const;
function usage(exitCode = 0) {
const script = "bun run fetch-devices";
const text = [
`Usage: ${script} <ip> <passcode>`,
"",
"Examples:",
` ${script} 192.168.31.166 048889`,
"",
"Notes:",
" - The passcode is read only from argv and is not written to disk.",
" - Output is written to src/devices.json.",
].join("\n");
(exitCode === 0 ? console.log : console.error)(text);
process.exit(exitCode);
}
function normalizeUrl(input: string): string {
let url: URL;
if (/^wss?:\/\//i.test(input)) {
url = new URL(input);
} else if (/^https?:\/\//i.test(input)) {
url = new URL(input);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
} else {
url = new URL(`ws://${input.replace(/\/+$/, "")}`);
}
if (!url.pathname.endsWith("/centrallinkws/")) {
url.pathname = `${url.pathname.replace(/\/+$/, "")}/centrallinkws/`;
}
return url.toString();
}
function concatBytes(...parts: Array<Buffer | Uint8Array>): Buffer {
return Buffer.concat(parts.map((part) => Buffer.from(part)));
}
function bnBytes(bn: BN, length = 32): Buffer {
return Buffer.from(bn.toArray("be", length));
}
function sha256Bytes(buf: Buffer | Uint8Array): Buffer {
return createHash("sha256").update(Buffer.from(buf)).digest();
}
class ECJPAKE {
role: string;
peerRole: string;
secret: BN;
curveName: string;
serverRoundTwoPrefix = 22;
failed = false;
wroteRoundOne = false;
readRoundOneDone = false;
wroteRoundTwo = false;
readRoundTwoDone = false;
x1: ReturnType<InstanceType<typeof EllipticEC>["genKeyPair"]> | null = null;
x2: ReturnType<InstanceType<typeof EllipticEC>["genKeyPair"]> | null = null;
peerX1: ReturnType<InstanceType<typeof EllipticEC>["keyFromPublic"]>["pub"] | null = null;
peerX2: ReturnType<InstanceType<typeof EllipticEC>["keyFromPublic"]>["pub"] | null = null;
constructor({ role, secret }: { role: string; secret: string }) {
if (role !== "server" && role !== "client") {
throw new TypeError('role must be "client" or "server"');
}
if (typeof secret !== "string") {
throw new TypeError("secret must be a string");
}
this.role = role;
this.peerRole = role === "client" ? "server" : "client";
this.secret = new BN(Buffer.from(secret, "utf8"));
this.curveName = "secp256k1";
}
writeRoundOne(): Buffer {
this.assertUsable();
if (this.wroteRoundOne || this.wroteRoundTwo || this.readRoundTwoDone) {
throw new Error("Wrong step");
}
this.wroteRoundOne = true;
const ec = new EllipticEC(this.curveName);
this.x1 = ec.genKeyPair();
this.x2 = ec.genKeyPair();
const zkp1 = this.createZkp(ec, ec.g, this.x1.getPublic(), this.x1.getPrivate(), this.role);
const zkp2 = this.createZkp(ec, ec.g, this.x2.getPublic(), this.x2.getPrivate(), this.role);
return concatBytes(
this.encodePublic(this.x1.getPublic()),
this.encodeZkp(zkp1),
this.encodePublic(this.x2.getPublic()),
this.encodeZkp(zkp2),
);
}
readRoundOne(payload: Uint8Array): void {
this.assertUsable();
if (this.readRoundOneDone || this.wroteRoundTwo || this.readRoundTwoDone) {
throw new Error("Wrong step");
}
this.readRoundOneDone = true;
const ec = new EllipticEC(this.curveName);
let offset = 0;
const peerX1Len = payload[offset++]!;
const peerX1Wire = payload.slice(offset, offset + peerX1Len);
offset += peerX1Len;
this.peerX1 = ec.keyFromPublic(Buffer.from(peerX1Wire)).getPublic();
const zkp1Length = 1 + payload[offset]! + 1 + payload[offset + 1 + payload[offset]!]!;
const zkp1 = this.decodeZkp(payload.slice(offset, offset + zkp1Length));
offset += zkp1Length;
if (!this.verifyZkp(ec, ec.g, this.peerX1!, zkp1.V, zkp1.r, this.peerRole)) {
this.failed = true;
throw new Error("ECJPAKE round one failed");
}
const peerX2Len = payload[offset++]!;
const peerX2Wire = payload.slice(offset, offset + peerX2Len);
offset += peerX2Len;
this.peerX2 = ec.keyFromPublic(Buffer.from(peerX2Wire)).getPublic();
const zkp2Length = 1 + payload[offset]! + 1 + payload[offset + 1 + payload[offset]!]!;
const zkp2 = this.decodeZkp(payload.slice(offset, offset + zkp2Length));
if (!this.verifyZkp(ec, ec.g, this.peerX2!, zkp2.V, zkp2.r, this.peerRole)) {
this.failed = true;
throw new Error("ECJPAKE round one failed");
}
}
writeRoundTwo(): Buffer {
this.assertUsable();
if (this.wroteRoundTwo || !this.wroteRoundOne || !this.readRoundOneDone) {
throw new Error("Wrong step");
}
this.wroteRoundTwo = true;
const ec = new EllipticEC(this.curveName);
const generator = this.x1!.getPublic().add(this.peerX1!).add(this.peerX2!);
ec.g = generator;
const n = new BN(randomBytes(16)).mul(ec.n).add(this.secret);
const s = this.x2!.getPrivate().mul(n).umod(ec.n);
const publicKey = generator.mul(s);
const zkp = this.createZkp(ec, generator, publicKey, s, this.role);
const encodedPublic = this.encodePublic(publicKey);
const encodedZkp = this.encodeZkp(zkp);
if (this.role === "server") {
return concatBytes(this.encodeServerRoundTwoPrefix(), encodedPublic, encodedZkp);
}
return concatBytes(encodedPublic, encodedZkp);
}
readRoundTwo(payload: Uint8Array): Buffer {
this.assertUsable();
if (this.readRoundTwoDone || !this.wroteRoundOne || !this.readRoundOneDone) {
throw new Error("Wrong step");
}
this.readRoundTwoDone = true;
const ec = new EllipticEC(this.curveName);
const generator = this.x1!.getPublic().add(this.x2!.getPublic()).add(this.peerX1!);
let offset = this.role === "client" ? 3 : 0;
const publicLen = payload[offset++]!;
const publicWire = payload.slice(offset, offset + publicLen);
offset += publicLen;
const peerPublic = ec.keyFromPublic(Buffer.from(publicWire)).getPublic();
const zkpLength = 1 + payload[offset]! + 1 + payload[offset + 1 + payload[offset]!]!;
const zkp = this.decodeZkp(payload.slice(offset, offset + zkpLength));
if (!this.verifyZkp(ec, generator, peerPublic, zkp.V, zkp.r, this.peerRole)) {
this.failed = true;
throw new Error("ECJPAKE round two failed");
}
const n = new BN(randomBytes(16)).mul(ec.n).add(this.secret);
const m = this.x2!.getPrivate().mul(n).umod(ec.n);
const sharedPoint = peerPublic.add(this.peerX2!.mul(m).neg()).mul(this.x2!.getPrivate());
return sha256Bytes(bnBytes(sharedPoint.getX(), 32));
}
assertUsable(): void {
if (this.failed) {
throw new Error("Reusing failed ECJPAKE context is insecure.");
}
}
encodePointForHash(point: InstanceType<typeof EllipticEC>["point"]): Buffer {
const out = Buffer.alloc(69);
out.writeUInt32BE(65, 0);
out[4] = 4;
bnBytes(point.getX(), 32).copy(out, 5);
bnBytes(point.getY(), 32).copy(out, 37);
return out;
}
encodePublic(point: InstanceType<typeof EllipticEC>["point"]): Buffer {
const out = Buffer.alloc(66);
out[0] = 65;
out[1] = 4;
bnBytes(point.getX(), 32).copy(out, 2);
bnBytes(point.getY(), 32).copy(out, 34);
return out;
}
encodeServerRoundTwoPrefix(): Buffer {
const out = Buffer.alloc(3);
out[0] = 3;
out.writeUInt16BE(this.serverRoundTwoPrefix, 1);
return out;
}
zkpChallenge(
generator: InstanceType<typeof EllipticEC>["point"],
v: InstanceType<typeof EllipticEC>["point"],
x: InstanceType<typeof EllipticEC>["point"],
signerId: string,
order: BN,
): BN {
const id = Buffer.from(signerId, "utf8");
const len = Buffer.alloc(4);
len.writeUInt32BE(id.length, 0);
const hashInput = concatBytes(
this.encodePointForHash(generator),
this.encodePointForHash(v),
this.encodePointForHash(x),
len,
id,
);
return new BN(sha256Bytes(hashInput).toString("hex"), "hex", "be").umod(order);
}
createZkp(
ec: InstanceType<typeof EllipticEC>,
generator: InstanceType<typeof EllipticEC>["point"],
publicKey: InstanceType<typeof EllipticEC>["point"],
privateKey: BN,
signerId: string,
): { V: InstanceType<typeof EllipticEC>["point"]; r: BN } {
const zkpEc = new EllipticEC(this.curveName);
zkpEc.g = generator;
const vKey = zkpEc.genKeyPair();
const challenge = this.zkpChallenge(zkpEc.g, vKey.getPublic(), publicKey, signerId, zkpEc.n);
const r = vKey.getPrivate().sub(challenge.mul(privateKey)).umod(zkpEc.n);
return { V: vKey.getPublic(), r };
}
encodeZkp(zkp: { V: InstanceType<typeof EllipticEC>["point"]; r: BN }): Buffer {
const out = Buffer.alloc(99);
this.encodePublic(zkp.V).copy(out, 0);
out[66] = 32;
bnBytes(zkp.r, 32).copy(out, 67);
return out;
}
decodeZkp(payload: Uint8Array): { V: InstanceType<typeof EllipticEC>["point"]; r: BN } {
const ec = new EllipticEC(this.curveName);
const publicLen = payload[0]!;
const publicWire = payload.slice(1, 1 + publicLen);
const rLenOffset = 1 + publicLen;
const rLen = payload[rLenOffset]!;
const rBytes = payload.slice(rLenOffset + 1, rLenOffset + 1 + rLen);
return {
V: ec.keyFromPublic(Buffer.from(publicWire)).getPublic(),
r: new BN(Buffer.from(rBytes)),
};
}
verifyZkp(
ec: InstanceType<typeof EllipticEC>,
generator: InstanceType<typeof EllipticEC>["point"],
publicKey: InstanceType<typeof EllipticEC>["point"],
v: InstanceType<typeof EllipticEC>["point"],
r: BN,
signerId: string,
): boolean {
const verifyEc = new EllipticEC(this.curveName);
verifyEc.g = generator;
const challenge = this.zkpChallenge(generator, v, publicKey, signerId, verifyEc.n);
return publicKey.mul(challenge).add(generator.mul(r)).eq(v);
}
}
class CounterGcm {
key: Buffer;
salt: Buffer;
selfCounter = 1;
peerCounter = 0;
constructor(key: Buffer, salt: Buffer) {
this.key = Buffer.from(key);
this.salt = Buffer.from(salt);
if (this.key.length !== 16) throw new Error("AES key must be 16 bytes");
if (this.salt.length !== 8) throw new Error("AES salt must be 8 bytes");
}
iv(counter: number): Buffer {
const iv = Buffer.alloc(12);
this.salt.copy(iv, 0);
iv.writeUInt32LE(counter, 8);
return iv;
}
encrypt(plain: Buffer | Uint8Array): Buffer {
const counter = this.selfCounter++;
const cipher = createCipheriv("aes-128-gcm", this.key, this.iv(counter), {
authTagLength: 16,
});
const ciphertext = Buffer.concat([cipher.update(Buffer.from(plain)), cipher.final()]);
const tag = cipher.getAuthTag();
const out = Buffer.alloc(4 + ciphertext.length + tag.length);
out.writeUInt32LE(counter, 0);
ciphertext.copy(out, 4);
tag.copy(out, 4 + ciphertext.length);
return out;
}
decrypt(frame: Buffer | Uint8Array): Buffer {
const input = Buffer.from(frame);
const counter = input.readUInt32LE(0);
if (counter <= this.peerCounter) {
throw new Error(`Replay or out-of-order frame: ${counter}`);
}
this.peerCounter = counter;
const tag = input.subarray(input.length - 16);
const ciphertext = input.subarray(4, input.length - 16);
const decipher = createDecipheriv("aes-128-gcm", this.key, this.iv(counter), {
authTagLength: 16,
});
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
}
function pack(type: number, payload: Buffer = Buffer.alloc(0)): Buffer {
return Buffer.concat([Buffer.from([type]), Buffer.from(payload)]);
}
function compressJson(obj: unknown): Buffer {
const raw = Buffer.from(JSON.stringify(obj), "utf8");
const deflated = deflateRawSync(raw);
const out = Buffer.alloc(4 + deflated.length);
out.writeUInt32LE(raw.length, 0);
deflated.copy(out, 4);
return out;
}
function decompressJson(payload: Buffer | Uint8Array): unknown {
const input = Buffer.from(payload);
const expectedLength = input.readUInt32LE(0);
const raw = inflateRawSync(input.subarray(4));
if (raw.length !== expectedLength) {
throw new Error(`Inflated JSON length mismatch: ${raw.length} != ${expectedLength}`);
}
return JSON.parse(raw.toString("utf8"));
}
async function wsDataToBuffer(data: unknown): Promise<Buffer> {
if (typeof data === "string") {
throw new Error("Unexpected websocket text frame");
}
if (data instanceof ArrayBuffer) return Buffer.from(data);
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
}
if (data && typeof (data as Blob).arrayBuffer === "function") {
return Buffer.from(await (data as Blob).arrayBuffer());
}
return Buffer.from(data as ArrayBufferView);
}
class GatewayClient {
url: string;
passcode: string;
stage = "init";
ecjpake: ECJPAKE | null = null;
sessionCipher: CounterGcm | null = null;
outCipher: CounterGcm | null = null;
inCipher: CounterGcm | null = null;
ready = false;
nextId = 0;
pending = new Map<string, { resolve: (value: unknown) => void; reject: (reason: Error) => void; timeout: ReturnType<typeof setTimeout> }>();
ws!: WebSocket;
constructor(url: string, passcode: string) {
this.url = url;
this.passcode = passcode;
}
async connect(): Promise<void> {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("WebSocket connect timeout")), 10000);
this.ws = new WebSocket(this.url);
this.ws.binaryType = "arraybuffer";
this.ws.onopen = () => {
clearTimeout(timeout);
this.sendProtocolList();
};
this.ws.onerror = () => {
clearTimeout(timeout);
reject(new Error(`WebSocket error while connecting to ${this.url}`));
};
this.ws.onclose = (event) => {
if (!this.ready) {
clearTimeout(timeout);
reject(new Error(`WebSocket closed before ready: ${event.code} ${event.reason}`));
}
};
this.ws.onmessage = async (event) => {
try {
this.handleMessage(await wsDataToBuffer(event.data));
if (this.ready) resolve();
} catch (err) {
reject(err);
}
};
});
}
close(): void {
try {
this.ws?.close();
} catch {}
}
send(buf: Buffer): void {
this.ws.send(Buffer.from(buf));
}
log(message: string): void {
if (process.env.MIOT_TRACE === "1") console.error(`[trace] ${message}`);
}
sendProtocolList(): void {
this.stage = "protocol-list";
this.log("send PROTOCOL_LIST");
this.send(pack(DATA_TYPE.PROTOCOL_LIST, Buffer.from(JSON.stringify(["passcode"]))));
}
handleMessage(frame: Buffer): void {
const type = frame[0]!;
const payload = frame.subarray(1);
this.log(`recv type=${type} len=${frame.length}`);
switch (type) {
case DATA_TYPE.SELECTED_PROTOCOL:
return this.handleSelectedProtocol(payload);
case DATA_TYPE.ECJPAKE_ROUND_ONE:
return this.handleRoundOne(payload);
case DATA_TYPE.ECJPAKE_ROUND_TWO:
return this.handleRoundTwo(payload);
case DATA_TYPE.SESSION_KEY_EXCHANGE:
return this.handleSessionKeyExchange(payload);
case DATA_TYPE.DATA:
return this.handleData(payload);
case DATA_TYPE.ERROR:
throw new Error(`Gateway returned ERROR frame during ${this.stage}`);
default:
throw new Error(`Unexpected frame type: ${type}`);
}
}
handleSelectedProtocol(payload: Buffer): void {
this.stage = "selected-protocol";
const selected = JSON.parse(payload.toString("utf8")) as { protocol: string };
if (selected.protocol !== "passcode") {
throw new Error(`Gateway selected unsupported protocol: ${selected.protocol}`);
}
this.ecjpake = new ECJPAKE({ role: "client", secret: this.passcode });
this.stage = "ecjpake-round-one";
this.log("send ECJPAKE_ROUND_ONE");
this.send(pack(DATA_TYPE.ECJPAKE_ROUND_ONE, this.ecjpake.writeRoundOne()));
}
handleRoundOne(payload: Buffer): void {
this.stage = "ecjpake-round-two";
this.ecjpake!.readRoundOne(new Uint8Array(payload));
this.log("send ECJPAKE_ROUND_TWO");
this.send(pack(DATA_TYPE.ECJPAKE_ROUND_TWO, this.ecjpake!.writeRoundTwo()));
}
handleRoundTwo(payload: Buffer): void {
this.stage = "session-key-exchange";
const shared = Buffer.from(this.ecjpake!.readRoundTwo(new Uint8Array(payload)));
this.sessionCipher = new CounterGcm(shared.subarray(0, 16), shared.subarray(16, 24));
const localMaterial = randomBytes(24);
this.outCipher = new CounterGcm(localMaterial.subarray(0, 16), localMaterial.subarray(16, 24));
this.log("send SESSION_KEY_EXCHANGE");
this.send(pack(DATA_TYPE.SESSION_KEY_EXCHANGE, this.sessionCipher.encrypt(localMaterial)));
}
handleSessionKeyExchange(payload: Buffer): void {
this.stage = "secure-session";
const remoteMaterial = this.sessionCipher!.decrypt(payload);
this.inCipher = new CounterGcm(remoteMaterial.subarray(0, 16), remoteMaterial.subarray(16, 24));
this.ready = true;
this.log("secure session ready");
}
handleData(payload: Buffer): void {
const rpc = decompressJson(this.inCipher!.decrypt(payload)) as { id: number; error?: { message: string }; result?: unknown };
const key = String(rpc.id);
const pending = this.pending.get(key);
if (!pending) return;
clearTimeout(pending.timeout);
this.pending.delete(key);
if ("error" in rpc && rpc.error) {
pending.reject(new Error(rpc.error?.message || JSON.stringify(rpc.error)));
} else {
pending.resolve(rpc.result);
}
}
async callAPI(method: string, params: Record<string, unknown> = {}, timeoutMs = 10000): Promise<unknown> {
if (!this.ready) throw new Error("Secure session is not ready");
this.stage = `api:${method}`;
const id = this.nextId++;
const rpc = {
jsonrpc: "2.0",
id,
method: `/api/${method}`,
params,
};
const encrypted = this.outCipher!.encrypt(compressJson(rpc));
this.send(pack(DATA_TYPE.DATA, encrypted));
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pending.delete(String(id));
reject(new Error(`API timeout: ${method}`));
}, timeoutMs);
this.pending.set(String(id), { resolve, reject, timeout });
});
}
}
function getOutputPath(): string {
return resolve(dirname(import.meta.file), "..", "devices.json");
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes("-h") || args.includes("--help")) usage(0);
if (args[0] === "--self-test") {
const client = new ECJPAKE({ role: "client", secret: "000000" });
const server = new ECJPAKE({ role: "server", secret: "000000" });
const clientRoundOne = client.writeRoundOne();
const serverRoundOne = server.writeRoundOne();
if (clientRoundOne.length !== 330 || serverRoundOne.length !== 330) {
throw new Error(
`Unexpected ECJPAKE round-one lengths: ${clientRoundOne.length}, ${serverRoundOne.length}`,
);
}
client.readRoundOne(serverRoundOne);
server.readRoundOne(clientRoundOne);
const clientRoundTwo = client.writeRoundTwo();
const serverRoundTwo = server.writeRoundTwo();
if (clientRoundTwo.length !== 165 || serverRoundTwo.length !== 168) {
throw new Error(
`Unexpected ECJPAKE round-two lengths: ${clientRoundTwo.length}, ${serverRoundTwo.length}`,
);
}
const clientKey = Buffer.from(client.readRoundTwo(serverRoundTwo));
const serverKey = Buffer.from(server.readRoundTwo(clientRoundTwo));
if (clientKey.length !== 32 || !clientKey.equals(serverKey)) {
throw new Error("ECJPAKE self-test shared key mismatch");
}
console.error("Self-test OK");
return;
}
if (args.length !== 2) usage(1);
const url = normalizeUrl(args[0]!);
const passcode = args[1]!;
if (!/^\d{6}$/.test(passcode)) {
throw new Error("Passcode must be a 6-digit string");
}
const outputPath = getOutputPath();
console.error(`Connecting to ${url}`);
const client = new GatewayClient(url, passcode);
try {
await client.connect();
console.error("Secure session established");
const result = (await client.callAPI("getDevList", {}, 10000)) as { devList?: Record<string, unknown> };
const devList = result.devList || {};
const payload = {
fetchedAt: new Date().toISOString(),
url,
count: Object.keys(devList).length,
devList,
};
writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`);
console.error(`已更新设备清单: ${outputPath} (${Object.keys(devList).length} 台设备)`);
} finally {
client.close();
}
}
main().catch((err: unknown) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
});

View File

@@ -220,19 +220,12 @@ export interface RuleDefinition {
name: string;
id?: string;
enable: boolean;
replace?: RuleReplaceStrategy;
workflow: WorkflowExpr;
}
export type RuleReplaceStrategy =
| { mode: "append" }
| { mode: "replaceById"; id: string }
| { mode: "replaceByName"; name?: string };
export interface RuleOptions {
id?: string;
enable?: boolean;
replace?: RuleReplaceStrategy;
}
export interface GraphNode {
@@ -288,10 +281,8 @@ export interface CompileResult {
export interface CompileSummary {
addedRules: string[];
replacedRules: string[];
nodeCount: number;
deviceDids: string[];
outputPath?: string;
}
export type DiagnosticSeverity = "error" | "warning";

View File

@@ -1,10 +1,10 @@
import { describe, expect, test } from "bun:test";
import { readDeviceInventoryFile, readBackupFile } from "../src/index.ts";
import { readBackupFile } from "../src/index.ts";
import { collectRuleDefinitions, compileRule, compileWorkflow } from "../src/compiler.ts";
import examples, { conditionBeforeAction, eventTurnOn, mergedTriggers } from "../examples/rules/index.ts";
import examples, { conditionBeforeAction, eventTurnOn, mergedTriggers } from "../src/rules/index.ts";
const now = 1_760_000_000_000;
const devices = readDeviceInventoryFile("resources/devices.json");
const devices = (await import("../src/devices.json")).default;
describe("规则编译", () => {
test("单条事件触发开灯规则生成稳定 JSON", () => {
@@ -79,16 +79,8 @@ describe("规则编译", () => {
});
test("模块导出收集支持默认数组和命名规则", async () => {
const moduleExports = await import("../examples/rules/index.ts");
const moduleExports = await import("../src/rules/index.ts");
const definitions = collectRuleDefinitions(moduleExports);
expect(definitions.map((definition) => definition.name)).toEqual(examples.map((definition) => definition.name));
});
test("编译并追加到真实备份时保留原有规则", () => {
const base = readBackupFile("resources/备份2026_4_26 19_17_42.bak");
const result = compileWorkflow([eventTurnOn], base, { now, devices });
expect(result.diagnostics.filter((diagnostic) => diagnostic.severity === "error")).toEqual([]);
expect(result.backup.rules).toHaveLength(base.rules.length + 1);
expect(result.backup.rules.at(-1)?.cfg.userData.name).toBe("示例-事件触发开灯");
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { compileRule, readDeviceInventoryFile, validateBackup } from "../src/index.ts";
import { eventTurnOn } from "../examples/rules/index.ts";
import { compileRule, validateBackup } from "../src/index.ts";
import { eventTurnOn } from "../src/rules/index.ts";
const now = 1_760_000_000_000;
@@ -25,9 +25,10 @@ describe("备份校验", () => {
expect(diagnostics.filter((diagnostic) => diagnostic.code === "device-not-found")).toHaveLength(2);
});
test("真实设备清单可通过设备校验", () => {
test("真实设备清单可通过设备校验", async () => {
const backup = backupFromRule();
const diagnostics = validateBackup(backup, { devices: readDeviceInventoryFile("resources/devices.json") });
const devices = (await import("../src/devices.json")).default;
const diagnostics = validateBackup(backup, { devices });
expect(diagnostics.filter((diagnostic) => diagnostic.severity === "error")).toEqual([]);
});
});