feat: 初始化 miot_x TypeScript 工作流构建器项目

This commit is contained in:
2026-05-07 20:28:08 +08:00
commit 690ef8ce83
22 changed files with 2690 additions and 0 deletions

415
.gitignore vendored Normal file
View File

@@ -0,0 +1,415 @@
### Git.gitignore ###
# Created by git for backups. To disable backups in Git:
# $ git config --global mergetool.keepBackup false
*.orig
# Created by git when using merge tools for conflicts
*.BACKUP.*
*.BASE.*
*.LOCAL.*
*.REMOTE.*
*_BACKUP_*.txt
*_BASE_*.txt
*_LOCAL_*.txt
*_REMOTE_*.txt
### Go.gitignore ###
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
### Go.patch ###
/vendor/
/Godeps/
### JetBrains+all.gitignore ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### JetBrains+all.patch ###
# Ignores the whole .idea folder and all .iml files
# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
.idea/
# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
*.iml
modules.xml
.idea/misc.xml
*.ipr
# Sonarlint plugin
.idea/sonarlint
### Linux.gitignore ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### Node.gitignore ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Test
playwright-report
test-results
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
### VisualStudioCode.gitignore ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
### VisualStudioCode.patch ###
# Ignore all local history of files
.history
### Windows.gitignore ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
### macOS.gitignore ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Python.gitignore ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Environments
.venv/
venv/
ENV/
env/
.python-version
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
# Pyre
.pyre/
# pytype
.pytype/
# Cython debug symbols
cython_debug/
# Custom
.claude
.opencode
.codex
openspec/changes/archive
temp
.agents
skills-lock.json
.worktrees
resources
# Embedfs generated
embedfs/assets/
embedfs/frontend-dist/
backend/cmd/desktop/rsrc_windows_*.syso

1
AGENTS.md Normal file
View File

@@ -0,0 +1 @@
严格遵守openspec/config.yaml中context声明的项目规范

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
严格遵守openspec/config.yaml中context声明的项目规范

120
README.md Normal file
View File

@@ -0,0 +1,120 @@
# miot_x
`miot_x` 是一个基于 Bun + TypeScript 的米家极客版工作流编排实验项目。目标是用 TypeScript 代码描述简单工作流,再编译、校验并打包成米家极客版可导入的 `.bak` 备份文件。
## 当前范围
第一版只验证“编程化开发工作流”的可行性,不做纯文本 DSL也不做完整 MIoT 元数据智能解析。
当前支持:
- 设备属性触发和事件触发,生成 `deviceInput`
- 设备属性读取条件,生成 `deviceGet`
- 设备属性写入和动作调用,生成 `deviceOutput`
- 多触发合并,生成 `signalOr`
- 简单逻辑组合,生成 `logicAnd``logicOr``logicNot`
- 延时动作,生成 `delay`
- 注释节点,生成 `nop`
- `.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 # 真实备份样本
```
## 编写规则
规则直接使用 TypeScript
```ts
import { defineDevices, defineRule } from "../../src/index.ts";
const devices = defineDevices({
corridorLight: {
did: "group.1815373077765824512",
urn: "urn:miot-spec-v2:device:light:0000A001:mijia-group3:3:0000C802",
name: "走廊筒灯",
},
corridorMotion: {
did: "blt.3.1htiptpdgco00",
urn: "urn:miot-spec-v2:device:motion-sensor:0000A014:xiaomi-pir1:2",
name: "走廊人体传感器",
},
});
export default defineRule("走廊有人开灯", ({ device, on }) =>
on(device(devices.corridorMotion).event({ siid: 2, eiid: 1008 }))
.do(device(devices.corridorLight).set({ siid: 2, piid: 1, value: true })),
);
```
第一版要求显式填写 `did``urn``siid``piid``eiid``aiid` 等 MIoT 参数。后续可以在此基础上增加语义别名。
## 打包命令
预览编译结果,不写出文件:
```bash
bun run pack -- --rules examples/rules/index.ts --devices resources/devices.json --input "resources/备份2026_4_26 19_17_42.bak" --dry-run
```
输出新的 `.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
```
默认策略是追加新规则。显式替换可使用:
```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 走廊开关灯
```
## 校验策略
工具在写出最终 `.bak` 前会检查:
- 顶层 `version``rules``variables.global`
- 规则 `id``cfg.id`
- 节点 `id/type/props/inputs/outputs/cfg`
- 节点 ID 是否只包含 `0-9a-zA-Z`
- 连接是否为 `目标节点ID.目标端口名`
- 连接目标节点和端口是否存在
- 设备 `did` 是否存在于 `devices.json`
- `deviceInput``deviceGet``deviceOutput` 的基础字段和操作符约束
## 测试
```bash
bun test
```
测试覆盖真实备份解包统计、备份打包往返、规则编译输出、备份合并和常见校验错误。
## 导入验证建议
先使用示例规则生成一个新备份文件,在米家极客版中导入并确认画布可以打开。确认简单规则能运行后,再对真实工作流进行替换。原始备份文件应始终保留,便于导入失败时回退。

26
bun.lock Normal file
View File

@@ -0,0 +1,26 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "miot_x",
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.13", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.13.tgz", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@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=="],
"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=="],
"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=="],
}
}

49
examples/rules/index.ts Normal file
View File

@@ -0,0 +1,49 @@
import { defineDevices, defineRule } from "../../src/index.ts";
const devices = defineDevices({
corridorLight: {
did: "group.1815373077765824512",
urn: "urn:miot-spec-v2:device:light:0000A001:mijia-group3:3:0000C802",
name: "走廊筒灯",
},
corridorMotion: {
did: "blt.3.1htiptpdgco00",
urn: "urn:miot-spec-v2:device:motion-sensor:0000A014:xiaomi-pir1:2",
name: "走廊人体传感器",
},
gateway: {
did: "1104758822",
urn: "urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1:3",
name: "中枢网关",
},
});
export const eventTurnOn = defineRule("示例-事件触发开灯", ({ device, on }) =>
on(device(devices.corridorMotion).event({ siid: 2, eiid: 1008 }))
.do(device(devices.corridorLight).set({ siid: 2, piid: 1, value: true })),
);
export const propertyTurnOff = defineRule("示例-属性触发关灯", ({ device, on }) =>
on(device(devices.corridorLight).propertyTrigger({ siid: 2, piid: 1, dtype: "boolean", operator: "=", value: true }))
.do(device(devices.corridorLight).set({ siid: 2, piid: 1, value: false })),
);
export const conditionBeforeAction = defineRule("示例-状态判断后动作", ({ device, on }) =>
on(device(devices.corridorMotion).event({ siid: 2, eiid: 1008 }))
.when(device(devices.corridorLight).get({ siid: 2, piid: 1, dtype: "boolean", operator: "=", value: false }))
.do(device(devices.corridorLight).set({ siid: 2, piid: 1, value: true })),
);
export const mergedTriggers = defineRule("示例-多触发合并", ({ device, onAny }) =>
onAny(
device(devices.corridorMotion).event({ siid: 2, eiid: 1008 }),
device(devices.gateway).event({ siid: 4, eiid: 1 }),
).do(device(devices.corridorLight).set({ siid: 2, piid: 2, value: 100 })),
);
export const delayedAction = defineRule("示例-延时动作", ({ delay, device, on }) =>
on(device(devices.corridorMotion).event({ siid: 2, eiid: 1008 }))
.do(delay("5s"), device(devices.corridorLight).set({ siid: 2, piid: 1, value: false })),
);
export default [eventTurnOn, propertyTurnOff, conditionBeforeAction, mergedTriggers, delayedAction];

21
openspec/config.yaml Normal file
View File

@@ -0,0 +1,21 @@
schema: spec-driven
context: |
- 使用中文(注释、文档、交流),面向中文开发者
- openspec文档的关键字按openspec规范使用不要翻译为中文
- **优先阅读README.md**获取项目结构与开发规范所有代码风格、命名、注解、依赖、API等规范以README为准
- 涉及模块结构、API、实体等变更时同步更新README.md
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
- 禁止创建git操作task
- 积极使用subagents精心设计并行任务节省上下文空间加速任务执行
- 优先使用提问工具对用户进行提问
rules:
proposal:
- 仔细审查每一个过往spec判断是否存在Modified Capabilities
design:
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
task:
- 一行一个任务,严禁任务内容跨行

View File

@@ -0,0 +1,111 @@
# Capability: TypeScript Workflow Authoring
## 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.
## Requirements
### Requirement: TypeScript 规则编写
系统 SHALL 允许用户在 TypeScript 文件中通过导出的 builder API 定义米家极客版工作流规则,不需要额外的纯文本 DSL 解析器。
#### Scenario: 单文件规则定义
- **WHEN** 用户在一个 TypeScript 规则文件中定义设备并导出一条规则
- **THEN** 系统 SHALL 将导出的规则定义加载为工作流编译输入
#### Scenario: 多文件规则定义
- **WHEN** 用户通过入口模块从多个 TypeScript 文件导出多条规则定义
- **THEN** 系统 SHALL 将所有导出的规则定义加载为工作流编译输入
### Requirement: 显式设备与能力引用
系统 SHALL 要求第一版工作流定义使用显式 `did``urn` 引用设备,并根据能力类型使用显式 `siid/piid/eiid/aiid` 字段引用 MIoT 能力。
#### Scenario: 设备引用包含身份字段
- **WHEN** 规则使用包含 `did``urn` 的设备引用
- **THEN** 编译器 SHALL 在生成的设备节点及其 `cfg.urn` 中使用这些值
#### Scenario: 缺少显式设备身份
- **WHEN** 规则引用的设备缺少 `did` 或缺少 `urn`
- **THEN** 编译器 MUST 失败,并给出能定位无效设备引用的诊断信息
### Requirement: 简单设备工作流节点
系统 SHALL 支持将简单设备工作流操作编译为米家极客版节点 JSON包括属性触发、事件触发、属性读取、属性写入和动作调用。
#### Scenario: 属性触发后写入属性
- **WHEN** TypeScript 规则定义设备属性触发,并在触发后执行设备属性写入动作
- **THEN** 编译器 SHALL 生成一个 `deviceInput` 节点,并通过正确模板端口连接到一个 `deviceOutput` 节点
#### Scenario: 事件触发后调用动作
- **WHEN** TypeScript 规则定义设备事件触发,并在触发后调用设备动作
- **THEN** 编译器 SHALL 生成包含 `eiid``deviceInput` 节点和包含 `aiid``deviceOutput` 节点
#### Scenario: 动作前检查属性条件
- **WHEN** TypeScript 规则定义触发器,并在动作前增加设备属性条件
- **THEN** 编译器 SHALL 生成 `deviceGet` 节点,并将满足条件的输出路径连接到下游动作
### Requirement: 简单逻辑组合
系统 SHALL 支持第一版工作流中的简单逻辑组合,包括多触发合并、逻辑与、逻辑或、逻辑非和延时。
#### Scenario: 多触发合并
- **WHEN** 规则定义多个触发器并要求它们启动同一个动作链
- **THEN** 编译器 SHALL 生成具备足够输入端口的 `signalOr` 节点,并将其输出连接到共享下游链路
#### Scenario: 逻辑条件组合
- **WHEN** 规则基于受支持的条件节点定义 `all``any``not` 条件
- **THEN** 编译器 SHALL 生成具有合法输入输出端口的 `logicAnd``logicOr``logicNot` 节点
#### Scenario: 延时动作链
- **WHEN** 规则在动作前定义延时
- **THEN** 编译器 SHALL 生成包含 `props.timeout` 和对应 `cfg` 展示字段的 `delay` 节点
### Requirement: 备份 JSON 生成
系统 SHALL 将工作流定义编译为米家极客版备份 JSON并满足已知规则与节点导入结构要求。
#### Scenario: 规则 JSON 形态
- **WHEN** 工作流规则被编译
- **THEN** 生成的规则 SHALL 包含字符串 `id`、与其匹配的 `cfg.id`、布尔值 `cfg.enable`、规则 `cfg.userData``nodes` 数组
#### Scenario: 节点 JSON 形态
- **WHEN** 任一受支持节点被生成
- **THEN** 该节点 SHALL 包含字符串 `id`、字符串 `type`、对象 `props`、对象 `inputs`、对象 `outputs`、对象 `cfg` 和整数 `cfg.version`
#### Scenario: 连接 JSON 形态
- **WHEN** 图边被输出到备份 JSON 中
- **THEN** 每条输出连接 SHALL 使用 `targetNodeId.targetPortName` 字符串格式,并指向存在的节点输入端口
### Requirement: 备份文件打包输出
系统 SHALL 使用已知文件外壳将生成的备份 JSON 打包为米家极客版 `.bak` 文件。
#### Scenario: 备份文件编码
- **WHEN** 系统写出备份文件
- **THEN** 文件 SHALL 以字节 `0x98 0x80 0x01 0x00` 开头,随后写入 raw deflate 压缩后的 UTF-8 JSON
#### Scenario: 备份往返一致
- **WHEN** 系统解包一个已生成的备份文件
- **THEN** 解包得到的 JSON SHALL 与压缩前的编译结果 JSON 一致
### Requirement: 保守备份合并
系统 SHALL 默认保留现有备份内容,只新增或显式替换用户选择的工作流规则。
#### 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`

15
package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "miot_x",
"type": "module",
"private": true,
"scripts": {
"pack": "bun run src/cli.ts",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}

70
src/backup.ts Normal file
View File

@@ -0,0 +1,70 @@
import { readFileSync, writeFileSync } from "node:fs";
import { deflateRawSync, inflateRawSync } from "node:zlib";
import type { BackupData, DeviceInventory } from "./types";
export const BACKUP_HEADER = new Uint8Array([0x98, 0x80, 0x01, 0x00]);
export interface BackupStats {
version: number;
ruleCount: number;
globalVariableCount: number;
nodeCount: number;
}
export function unpackBackup(buffer: Uint8Array): BackupData {
assertBackupHeader(buffer);
const inflated = inflateRawSync(buffer.subarray(BACKUP_HEADER.length));
const json = JSON.parse(inflated.toString("utf8")) as BackupData;
return normalizeBackup(json);
}
export function packBackup(data: BackupData): Uint8Array {
const json = JSON.stringify(normalizeBackup(data));
const compressed = deflateRawSync(Buffer.from(json, "utf8"));
const output = new Uint8Array(BACKUP_HEADER.length + compressed.length);
output.set(BACKUP_HEADER, 0);
output.set(compressed, BACKUP_HEADER.length);
return output;
}
export function readBackupFile(path: string): BackupData {
return unpackBackup(readFileSync(path));
}
export function writeBackupFile(path: string, data: BackupData): void {
writeFileSync(path, packBackup(data));
}
export function readDeviceInventoryFile(path: string): DeviceInventory {
return JSON.parse(readFileSync(path, "utf8")) as DeviceInventory;
}
export function backupStats(data: BackupData): BackupStats {
return {
version: data.version,
ruleCount: data.rules.length,
globalVariableCount: Object.keys(data.variables.global).length,
nodeCount: data.rules.reduce((sum, rule) => sum + rule.nodes.length, 0),
};
}
function assertBackupHeader(buffer: Uint8Array): void {
if (buffer.length < BACKUP_HEADER.length) {
throw new Error("备份文件过短,缺少固定头");
}
for (let index = 0; index < BACKUP_HEADER.length; index += 1) {
if (buffer[index] !== BACKUP_HEADER[index]) {
throw new Error("备份文件头不匹配,期望 0x98 0x80 0x01 0x00");
}
}
}
function normalizeBackup(data: BackupData): BackupData {
return {
version: 2,
rules: Array.isArray(data.rules) ? data.rules : [],
variables: {
global: data.variables?.global ?? {},
},
};
}

171
src/builder.ts Normal file
View File

@@ -0,0 +1,171 @@
import type {
ActionExpr,
ConditionExpr,
DelayActionExpr,
DeviceActionParams,
DeviceGetParams,
DeviceRef,
DeviceSetParams,
EventTriggerParams,
PropertyTriggerParams,
RuleDefinition,
RuleOptions,
TriggerExpr,
WorkflowExpr,
} from "./types";
export type RuleFactory = (ctx: RuleBuilderContext) => WorkflowExpr;
export interface RuleBuilderContext {
device: typeof device;
on: typeof on;
onAny: typeof onAny;
all: typeof all;
any: typeof any;
not: typeof not;
delay: typeof delay;
nop: typeof nop;
}
export function defineDevice(deviceRef: DeviceRef): DeviceRef {
if (!deviceRef.did || !deviceRef.urn) {
throw new Error("设备定义必须包含 did 和 urn");
}
return { ...deviceRef };
}
export function defineDevices<T extends Record<string, DeviceRef>>(deviceMap: T): T {
for (const [key, value] of Object.entries(deviceMap)) {
if (!value.did || !value.urn) {
throw new Error(`设备 ${key} 必须包含 did 和 urn`);
}
}
return deviceMap;
}
export function defineRule(name: string, factory: RuleFactory, options: RuleOptions = {}): RuleDefinition {
const ctx = createRuleContext();
const workflow = factory(ctx);
return {
kind: "rule-definition",
name,
id: options.id,
enable: options.enable ?? true,
replace: options.replace,
workflow,
};
}
export function createRuleContext(): RuleBuilderContext {
return { device, on, onAny, all, any, not, delay, nop };
}
export function device(deviceRef: DeviceRef): DeviceBuilder {
return new DeviceBuilder(defineDevice(deviceRef));
}
export class DeviceBuilder {
constructor(private readonly deviceRef: DeviceRef) {}
propertyTrigger(params: PropertyTriggerParams): TriggerExpr {
return { kind: "device-property-trigger", device: this.deviceRef, params };
}
event(params: EventTriggerParams): TriggerExpr {
return { kind: "device-event-trigger", device: this.deviceRef, params };
}
get(params: DeviceGetParams): ConditionExpr {
return { kind: "device-get-condition", device: this.deviceRef, params };
}
set(params: DeviceSetParams): ActionExpr {
return { kind: "device-set-action", device: this.deviceRef, params };
}
action(params: DeviceActionParams): ActionExpr {
return { kind: "device-call-action", device: this.deviceRef, params };
}
}
export function on(trigger: TriggerExpr): TriggerChainBuilder {
return new TriggerChainBuilder([trigger]);
}
export function onAny(...triggers: TriggerExpr[]): TriggerChainBuilder {
if (triggers.length === 0) {
throw new Error("onAny 至少需要一个触发器");
}
return new TriggerChainBuilder(triggers);
}
export function all(...conditions: ConditionExpr[]): ConditionExpr {
if (conditions.length === 0) {
throw new Error("all 至少需要一个条件");
}
return { kind: "logic-condition", op: "all", conditions };
}
export function any(...conditions: ConditionExpr[]): ConditionExpr {
if (conditions.length === 0) {
throw new Error("any 至少需要一个条件");
}
return { kind: "logic-condition", op: "any", conditions };
}
export function not(condition: ConditionExpr): ConditionExpr {
return { kind: "logic-condition", op: "not", conditions: [condition] };
}
export function delay(duration: number | string): DelayActionExpr {
const parsed = parseDelay(duration);
return { kind: "delay-action", ...parsed };
}
export function nop(text = ""): ActionExpr {
return { kind: "nop-action", text };
}
export class TriggerChainBuilder {
constructor(private readonly triggers: TriggerExpr[], private readonly condition?: ConditionExpr) {}
when(condition: ConditionExpr): TriggerChainBuilder {
return new TriggerChainBuilder(this.triggers, condition);
}
do(...actions: ActionExpr[]): WorkflowExpr {
if (actions.length === 0) {
throw new Error("do 至少需要一个动作");
}
return {
kind: "workflow",
triggers: this.triggers,
condition: this.condition,
actions,
};
}
}
function parseDelay(duration: number | string): Pick<DelayActionExpr, "timeout" | "unit" | "value"> {
if (typeof duration === "number") {
if (!Number.isInteger(duration) || duration <= 0) {
throw new Error("delay 数值必须是大于 0 的整数毫秒");
}
return { timeout: duration, unit: "ms", value: duration };
}
const matched = duration.trim().match(/^(\d+)(ms|s|min)$/);
if (!matched) {
throw new Error("delay 字符串格式必须是 100ms、5s 或 2min");
}
const amountText = matched[1];
const unit = matched[2] as "ms" | "s" | "min";
const value = Number(amountText);
if (!Number.isInteger(value) || value <= 0) {
throw new Error("delay 时间必须是大于 0 的整数");
}
const timeout = unit === "ms" ? value : unit === "s" ? value * 1000 : value * 60_000;
return { timeout, unit, value };
}

125
src/cli.ts Normal file
View File

@@ -0,0 +1,125 @@
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { readBackupFile, readDeviceInventoryFile, writeBackupFile } from "./backup";
import { collectRuleDefinitions, compileWorkflow } from "./compiler";
import type { Diagnostic, RuleDefinition, RuleReplaceStrategy } from "./types";
interface CliOptions {
rules?: string;
input?: string;
output?: string;
devices?: string;
dryRun: boolean;
replace?: RuleReplaceStrategy;
}
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);
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 errors = result.diagnostics.filter((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 }));
}
function printSummary(
ruleNames: string[],
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}`);
}
}
if (import.meta.main) {
runCli().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}

344
src/compiler.ts Normal file
View File

@@ -0,0 +1,344 @@
import { addEdge, addNode, applyLayeredLayout, createEmptyGraph, NodeIdGenerator, type OutputRef } from "./graph";
import {
createDelayNode,
createDeviceActionNode,
createDeviceEventInputNode,
createDeviceGetNode,
createDevicePropertyInputNode,
createDeviceSetNode,
createLogicAndNode,
createLogicNotNode,
createLogicOrNode,
createNopNode,
createSignalOrNode,
} from "./nodes";
import { validateBackup } from "./validate";
import type {
ActionExpr,
BackupData,
BackupNode,
BackupRule,
CompileOptions,
CompileResult,
ConditionExpr,
Diagnostic,
Graph,
GraphNode,
RuleDefinition,
TriggerExpr,
} from "./types";
export interface CompiledRule {
definition: RuleDefinition;
graph: Graph;
rule: BackupRule;
}
export function compileRules(definitions: RuleDefinition[], options: CompileOptions = {}): CompiledRule[] {
return definitions.map((definition, index) => compileRule(definition, options, index));
}
export function compileRule(definition: RuleDefinition, options: CompileOptions = {}, index = 0): CompiledRule {
const ruleId = definition.id ?? String((options.now ?? Date.now()) + index);
const graph = createEmptyGraph(definition.name, ruleId, definition.enable);
const ids = new NodeIdGenerator();
const triggerOut = compileTriggers(graph, ids, definition.workflow.triggers);
const conditionResult = definition.workflow.condition
? compileCondition(graph, ids, definition.workflow.condition, triggerOut.output, triggerOut.nextLayer)
: { trueOut: triggerOut.output, nextLayer: triggerOut.nextLayer };
compileActions(graph, ids, definition.workflow.actions, conditionResult.trueOut, conditionResult.nextLayer);
applyLayeredLayout(graph);
return {
definition,
graph,
rule: graphToRule(graph, options.now ?? Date.now()),
};
}
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);
return {
backup,
rules: compiled.map((item) => item.rule),
diagnostics,
summary,
};
}
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>();
const values = [moduleExports.default, ...Object.entries(moduleExports).filter(([key]) => key !== "default").map(([, value]) => value)];
for (const value of values) {
for (const definition of flattenRuleExport(value)) {
if (!seen.has(definition)) {
seen.add(definition);
definitions.push(definition);
}
}
}
return definitions;
}
interface TriggerCompileResult {
output: OutputRef;
nextLayer: number;
}
interface ConditionCompileResult {
trueOut: OutputRef;
falseOut?: OutputRef;
nextLayer: number;
}
function compileTriggers(graph: Graph, ids: NodeIdGenerator, triggers: TriggerExpr[]): TriggerCompileResult {
if (triggers.length === 0) {
throw new Error("规则至少需要一个触发器");
}
const outputs = triggers.map((trigger) => {
const node = addNode(graph, triggerNode(ids, trigger, 0));
return { nodeId: node.id, port: "output" };
});
if (outputs.length === 1) {
return { output: outputs[0]!, nextLayer: 1 };
}
const signalOr = addNode(graph, createSignalOrNode(ids.next("signalOr"), outputs.length, 1));
outputs.forEach((output, index) => addEdge(graph, output, signalOr.id, `input${index}`));
return { output: { nodeId: signalOr.id, port: "output" }, nextLayer: 2 };
}
function triggerNode(ids: NodeIdGenerator, trigger: TriggerExpr, layer: number): GraphNode {
if (trigger.kind === "device-property-trigger") {
return createDevicePropertyInputNode(ids.next("deviceInput"), trigger.device, trigger.params, layer);
}
return createDeviceEventInputNode(ids.next("deviceInput"), trigger.device, trigger.params, layer);
}
function compileCondition(
graph: Graph,
ids: NodeIdGenerator,
condition: ConditionExpr,
input: OutputRef,
layer: number,
): ConditionCompileResult {
if (condition.kind === "device-get-condition") {
const node = addNode(graph, createDeviceGetNode(ids.next("deviceGet"), condition.device, condition.params, layer));
addEdge(graph, input, node.id, "input");
return {
trueOut: { nodeId: node.id, port: "output" },
falseOut: { nodeId: node.id, port: "output2" },
nextLayer: layer + 1,
};
}
if (condition.op === "not") {
const child = compileCondition(graph, ids, condition.conditions[0]!, input, layer);
const node = addNode(graph, createLogicNotNode(ids.next("logicNot"), child.nextLayer));
addEdge(graph, child.trueOut, node.id, "input");
return {
trueOut: { nodeId: node.id, port: "output" },
falseOut: child.trueOut,
nextLayer: child.nextLayer + 1,
};
}
const childResults = condition.conditions.map((child) => compileCondition(graph, ids, child, input, layer));
const logicLayer = Math.max(...childResults.map((item) => item.nextLayer));
const node = addNode(
graph,
condition.op === "all"
? createLogicAndNode(ids.next("logicAnd"), childResults.length, logicLayer)
: createLogicOrNode(ids.next("logicOr"), childResults.length, logicLayer),
);
childResults.forEach((child, index) => addEdge(graph, child.trueOut, node.id, `input${index}`));
return {
trueOut: { nodeId: node.id, port: "output" },
nextLayer: logicLayer + 1,
};
}
function compileActions(graph: Graph, ids: NodeIdGenerator, actions: ActionExpr[], input: OutputRef, startLayer: number): void {
let current = input;
let layer = startLayer;
for (const action of actions) {
if (action.kind === "nop-action") {
addNode(graph, createNopNode(ids.next("nop"), action.text, layer));
continue;
}
if (action.kind === "delay-action") {
const node = addNode(graph, createDelayNode(ids.next("delay"), action.timeout, action.unit, action.value, layer));
addEdge(graph, current, node.id, "input");
current = { nodeId: node.id, port: "output" };
layer += 1;
continue;
}
if (action.kind === "device-set-action") {
const node = addNode(graph, createDeviceSetNode(ids.next("deviceOutput"), action.device, action.params, layer));
addEdge(graph, current, node.id, "trigger");
current = { nodeId: node.id, port: "output" };
layer += 1;
continue;
}
const node = addNode(graph, createDeviceActionNode(ids.next("deviceOutput"), action.device, action.params, layer));
addEdge(graph, current, node.id, "trigger");
current = { nodeId: node.id, port: "output" };
layer += 1;
}
}
function graphToRule(graph: Graph, now: number): BackupRule {
const nodeById = new Map(graph.nodes.map((node) => [node.id, graphNodeToBackupNode(node)]));
for (const edge of graph.edges) {
const from = nodeById.get(edge.fromNodeId);
if (!from) {
continue;
}
const outputs = from.outputs[edge.fromPort] ?? [];
outputs.push(`${edge.toNodeId}.${edge.toPort}`);
from.outputs[edge.fromPort] = outputs;
}
return {
id: graph.ruleId,
cfg: {
id: graph.ruleId,
userData: {
name: graph.ruleName,
transform: { x: 0, y: 0, scale: 1, rotate: 0 },
lastUpdateTime: now,
version: 0,
},
uiType: "test",
enable: graph.enable,
},
nodes: graph.nodes.map((node) => nodeById.get(node.id)!),
};
}
function graphNodeToBackupNode(node: GraphNode): BackupNode {
return {
id: node.id,
type: node.type,
props: cloneJson(node.props),
inputs: { ...node.inputs },
outputs: Object.fromEntries(node.outputPorts.map((port) => [port, []])),
cfg: cloneJson(node.cfg),
};
}
function flattenRuleExport(value: unknown): RuleDefinition[] {
if (Array.isArray(value)) {
return value.flatMap(flattenRuleExport);
}
if (isRuleDefinition(value)) {
return [value];
}
return [];
}
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);
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),
};
}
function collectDeviceDids(compiled: CompiledRule[]): string[] {
const dids = new Set<string>();
for (const item of compiled) {
for (const node of item.rule.nodes) {
const did = node.props.did;
if (typeof did === "string") {
dids.add(did);
}
}
}
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;
}

78
src/graph.ts Normal file
View File

@@ -0,0 +1,78 @@
import { positionFor } from "./nodes";
import type { Graph, GraphEdge, GraphNode, SupportedNodeType } from "./types";
export interface OutputRef {
nodeId: string;
port: string;
}
export class NodeIdGenerator {
private readonly counters = new Map<string, number>();
private readonly used = new Set<string>();
next(type: SupportedNodeType): string {
const prefix = nodePrefix(type);
const current = this.counters.get(prefix) ?? 0;
let index = current + 1;
let id = `${prefix}${index.toString(36)}`;
while (this.used.has(id)) {
index += 1;
id = `${prefix}${index.toString(36)}`;
}
this.counters.set(prefix, index);
this.used.add(id);
return id;
}
}
export function createEmptyGraph(ruleName: string, ruleId: string, enable: boolean): Graph {
return { ruleName, ruleId, enable, nodes: [], edges: [] };
}
export function addNode(graph: Graph, node: GraphNode): GraphNode {
graph.nodes.push(node);
return node;
}
export function addEdge(graph: Graph, from: OutputRef, toNodeId: string, toPort: string): GraphEdge {
const edge: GraphEdge = {
fromNodeId: from.nodeId,
fromPort: from.port,
toNodeId,
toPort,
};
graph.edges.push(edge);
return edge;
}
export function applyLayeredLayout(graph: Graph): void {
const layerCounts = new Map<number, number>();
for (const node of graph.nodes) {
const index = layerCounts.get(node.layer) ?? 0;
layerCounts.set(node.layer, index + 1);
node.cfg.pos = positionFor(node.type, node.layer, index);
}
}
function nodePrefix(type: SupportedNodeType): string {
switch (type) {
case "deviceInput":
return "DI";
case "deviceOutput":
return "DO";
case "deviceGet":
return "DG";
case "signalOr":
return "SO";
case "logicAnd":
return "LA";
case "logicOr":
return "LO";
case "logicNot":
return "LN";
case "delay":
return "DL";
case "nop":
return "NP";
}
}

41
src/index.ts Normal file
View File

@@ -0,0 +1,41 @@
export {
createRuleContext,
defineDevice,
defineDevices,
defineRule,
delay,
device,
nop,
on,
onAny,
all,
any,
not,
} from "./builder";
export { BACKUP_HEADER, backupStats, packBackup, readBackupFile, readDeviceInventoryFile, unpackBackup, writeBackupFile } from "./backup";
export { collectRuleDefinitions, compileRule, compileRules, compileWorkflow, mergeCompiledRules } from "./compiler";
export { assertValidBackup, validateBackup } from "./validate";
export type {
ActionExpr,
BackupData,
BackupNode,
BackupRule,
CompileOptions,
CompileResult,
ConditionExpr,
DeviceActionParams,
DeviceGetParams,
DeviceInventory,
DeviceRef,
DeviceSetParams,
Diagnostic,
EventTriggerParams,
Graph,
GraphEdge,
GraphNode,
PropertyTriggerParams,
RuleDefinition,
RuleOptions,
TriggerExpr,
WorkflowExpr,
} from "./types";

322
src/nodes.ts Normal file
View File

@@ -0,0 +1,322 @@
import type {
ActionInput,
ComparisonOperator,
DeviceActionParams,
DeviceGetParams,
DeviceRef,
DeviceSetParams,
EventArgument,
EventTriggerParams,
GraphNode,
JsonObject,
JsonPrimitive,
MiotDType,
NodePosition,
PropertyTriggerParams,
SupportedNodeType,
} from "./types";
export function createDevicePropertyInputNode(
id: string,
device: DeviceRef,
params: PropertyTriggerParams,
layer: number,
): GraphNode {
return createNode({
id,
type: "deviceInput",
layer,
props: {
did: device.did,
siid: params.siid,
piid: params.piid,
preload: params.preload ?? true,
...comparisonProps(params),
},
inputs: {},
outputPorts: ["output"],
urn: device.urn,
});
}
export function createDeviceEventInputNode(
id: string,
device: DeviceRef,
params: EventTriggerParams,
layer: number,
): GraphNode {
return createNode({
id,
type: "deviceInput",
layer,
props: {
did: device.did,
siid: params.siid,
eiid: params.eiid,
...(params.arguments ? { arguments: params.arguments.map(eventArgumentProps) } : {}),
},
inputs: {},
outputPorts: ["output"],
urn: device.urn,
});
}
export function createDeviceGetNode(id: string, device: DeviceRef, params: DeviceGetParams, layer: number): GraphNode {
return createNode({
id,
type: "deviceGet",
layer,
props: {
did: device.did,
siid: params.siid,
piid: params.piid,
...comparisonProps(params),
},
inputs: { input: null },
outputPorts: ["output", "output2"],
urn: device.urn,
});
}
export function createDeviceSetNode(id: string, device: DeviceRef, params: DeviceSetParams, layer: number): GraphNode {
return createNode({
id,
type: "deviceOutput",
layer,
props: {
did: device.did,
siid: params.siid,
piid: params.piid,
value: params.value,
},
inputs: { trigger: null },
outputPorts: ["output"],
urn: device.urn,
});
}
export function createDeviceActionNode(id: string, device: DeviceRef, params: DeviceActionParams, layer: number): GraphNode {
return createNode({
id,
type: "deviceOutput",
layer,
props: {
did: device.did,
siid: params.siid,
aiid: params.aiid,
ins: (params.ins ?? []).map(actionInputProps),
},
inputs: { trigger: null },
outputPorts: ["output"],
urn: device.urn,
});
}
export function createSignalOrNode(id: string, inputCount: number, layer: number): GraphNode {
return createNode({
id,
type: "signalOr",
layer,
props: {},
inputs: numberedInputs(inputCount),
outputPorts: ["output"],
});
}
export function createLogicAndNode(id: string, inputCount: number, layer: number): GraphNode {
return createNode({
id,
type: "logicAnd",
layer,
props: {},
inputs: numberedInputs(inputCount),
outputPorts: ["output"],
});
}
export function createLogicOrNode(id: string, inputCount: number, layer: number): GraphNode {
return createNode({
id,
type: "logicOr",
layer,
props: {},
inputs: numberedInputs(inputCount),
outputPorts: ["output"],
});
}
export function createLogicNotNode(id: string, layer: number): GraphNode {
return createNode({
id,
type: "logicNot",
layer,
props: {},
inputs: { input: null },
outputPorts: ["output"],
});
}
export function createDelayNode(
id: string,
timeout: number,
unit: "ms" | "s" | "min",
value: number,
layer: number,
): GraphNode {
return createNode({
id,
type: "delay",
layer,
props: { timeout },
inputs: { input: null },
outputPorts: ["output"],
cfgExtra: { unit, value },
});
}
export function createNopNode(id: string, text: string, layer: number): GraphNode {
return createNode({
id,
type: "nop",
layer,
props: {},
inputs: {},
outputPorts: ["output"],
cfgExtra: {
contents: [{ insert: text }],
background: "#D9B8FF",
},
});
}
export function supportedInputPorts(type: SupportedNodeType, inputCount = 2): string[] {
switch (type) {
case "deviceInput":
case "nop":
return [];
case "deviceOutput":
return ["trigger"];
case "deviceGet":
case "delay":
return ["input"];
case "logicNot":
return ["input"];
case "signalOr":
case "logicAnd":
case "logicOr":
return Array.from({ length: inputCount }, (_, index) => `input${index}`);
}
}
interface CreateNodeOptions {
id: string;
type: SupportedNodeType;
layer: number;
props: JsonObject;
inputs: Record<string, JsonPrimitive>;
outputPorts: string[];
urn?: string;
cfgExtra?: JsonObject;
}
function createNode(options: CreateNodeOptions): GraphNode {
return {
id: options.id,
type: options.type,
props: options.props,
inputs: options.inputs,
outputPorts: options.outputPorts,
cfg: {
...options.cfgExtra,
...(options.urn ? { urn: options.urn } : {}),
name: options.type,
version: 1,
pos: positionFor(options.type, options.layer, 0),
},
layer: options.layer,
};
}
export function positionFor(type: SupportedNodeType, layer: number, index: number): NodePosition {
const size = nodeSize(type);
return {
x: 240 + layer * 560,
y: 180 + index * 220,
width: size.width,
height: size.height,
};
}
function nodeSize(type: SupportedNodeType): { width: number; height: number } {
switch (type) {
case "deviceInput":
return { width: 450, height: 206 };
case "deviceOutput":
return { width: 528, height: 164 };
case "deviceGet":
return { width: 450, height: 206 };
case "delay":
return { width: 288, height: 112 };
case "nop":
return { width: 260, height: 160 };
case "signalOr":
case "logicAnd":
case "logicOr":
return { width: 160, height: 180 };
case "logicNot":
return { width: 160, height: 140 };
}
}
function numberedInputs(count: number): Record<string, null> {
return Object.fromEntries(Array.from({ length: count }, (_, index) => [`input${index}`, null]));
}
function comparisonProps(params: {
dtype: MiotDType;
operator: ComparisonOperator;
value?: JsonPrimitive | JsonPrimitive[];
v1?: JsonPrimitive | JsonPrimitive[];
v2?: JsonPrimitive;
}): JsonObject {
const v1 = params.v1 ?? params.value;
const props: JsonObject = {
dtype: params.dtype,
operator: params.operator,
};
if (v1 !== undefined) {
props.v1 = v1;
}
if (params.v2 !== undefined) {
props.v2 = params.v2;
}
return props;
}
function eventArgumentProps(argument: EventArgument): JsonObject {
const props: JsonObject = {
piid: argument.piid,
dtype: argument.dtype,
};
if (argument.operator) {
props.operator = argument.operator;
const v1 = argument.v1 ?? argument.value;
if (v1 !== undefined) {
props.v1 = v1;
}
}
if (argument.v2 !== undefined) {
props.v2 = argument.v2;
}
return props;
}
function actionInputProps(input: ActionInput): JsonObject {
const props: JsonObject = {};
for (const [key, value] of Object.entries(input)) {
if (value !== undefined) {
props[key] = value as JsonPrimitive;
}
}
return props;
}

309
src/types.ts Normal file
View File

@@ -0,0 +1,309 @@
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
export type JsonObject = { [key: string]: JsonValue };
export type MiotDType = "int" | "float" | "boolean" | "string";
export type VariableDType = "number" | "boolean" | "string";
export type ComparisonOperator = ">=" | "<=" | "=" | "!=" | ">" | "<" | "between" | "include";
export interface BackupData {
version: 2;
rules: BackupRule[];
variables: BackupVariables;
}
export interface BackupVariables {
global: Record<string, BackupVariable>;
}
export interface BackupVariable {
type: "number" | "string";
value: string | number;
userData?: {
name?: string;
};
}
export interface BackupRule {
id: string;
cfg: BackupRuleCfg;
nodes: BackupNode[];
}
export interface BackupRuleCfg {
id: string;
userData: {
name: string;
transform: Transform;
lastUpdateTime: number;
version: number;
[key: string]: unknown;
};
uiType: string;
enable: boolean;
[key: string]: unknown;
}
export interface Transform {
x: number;
y: number;
scale: number;
rotate: number;
}
export interface BackupNode {
id: string;
type: SupportedNodeType | string;
props: JsonObject;
inputs: Record<string, JsonPrimitive>;
outputs: Record<string, string[]>;
cfg: NodeCfg;
}
export interface NodeCfg {
version: number;
pos?: NodePosition;
name?: string;
urn?: string;
[key: string]: unknown;
}
export interface NodePosition {
x: number;
y: number;
width: number;
height: number;
[key: string]: number;
}
export type SupportedNodeType =
| "deviceInput"
| "deviceOutput"
| "deviceGet"
| "signalOr"
| "logicAnd"
| "logicOr"
| "logicNot"
| "delay"
| "nop";
export interface DeviceRef {
did: string;
urn: string;
name?: string;
}
export interface EventArgument {
piid: number;
dtype: MiotDType;
operator?: ComparisonOperator;
value?: JsonPrimitive | JsonPrimitive[];
v1?: JsonPrimitive | JsonPrimitive[];
v2?: JsonPrimitive;
}
export interface PropertyTriggerParams {
siid: number;
piid: number;
dtype: MiotDType;
operator: ComparisonOperator;
value?: JsonPrimitive | JsonPrimitive[];
v1?: JsonPrimitive | JsonPrimitive[];
v2?: JsonPrimitive;
preload?: boolean;
}
export interface EventTriggerParams {
siid: number;
eiid: number;
arguments?: EventArgument[];
}
export interface DeviceGetParams {
siid: number;
piid: number;
dtype: MiotDType;
operator: ComparisonOperator;
value?: JsonPrimitive | JsonPrimitive[];
v1?: JsonPrimitive | JsonPrimitive[];
v2?: JsonPrimitive;
}
export interface DeviceSetParams {
siid: number;
piid: number;
value: JsonValue;
}
export interface ActionInput {
piid?: number;
value?: JsonValue;
id?: string;
scope?: "global";
dtype?: VariableDType;
min?: number;
max?: number;
step?: number;
}
export interface DeviceActionParams {
siid: number;
aiid: number;
ins?: ActionInput[];
}
export type TriggerExpr = DevicePropertyTriggerExpr | DeviceEventTriggerExpr;
export interface DevicePropertyTriggerExpr {
kind: "device-property-trigger";
device: DeviceRef;
params: PropertyTriggerParams;
}
export interface DeviceEventTriggerExpr {
kind: "device-event-trigger";
device: DeviceRef;
params: EventTriggerParams;
}
export type ConditionExpr = DeviceGetConditionExpr | LogicConditionExpr;
export interface DeviceGetConditionExpr {
kind: "device-get-condition";
device: DeviceRef;
params: DeviceGetParams;
}
export interface LogicConditionExpr {
kind: "logic-condition";
op: "all" | "any" | "not";
conditions: ConditionExpr[];
}
export type ActionExpr = DeviceSetActionExpr | DeviceCallActionExpr | DelayActionExpr | NopActionExpr;
export interface DeviceSetActionExpr {
kind: "device-set-action";
device: DeviceRef;
params: DeviceSetParams;
}
export interface DeviceCallActionExpr {
kind: "device-call-action";
device: DeviceRef;
params: DeviceActionParams;
}
export interface DelayActionExpr {
kind: "delay-action";
timeout: number;
unit: DelayUnit;
value: number;
}
export interface NopActionExpr {
kind: "nop-action";
text: string;
}
export type DelayUnit = "ms" | "s" | "min";
export interface WorkflowExpr {
kind: "workflow";
triggers: TriggerExpr[];
condition?: ConditionExpr;
actions: ActionExpr[];
}
export interface RuleDefinition {
kind: "rule-definition";
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 {
id: string;
type: SupportedNodeType;
props: JsonObject;
inputs: Record<string, JsonPrimitive>;
outputPorts: string[];
cfg: NodeCfg;
layer: number;
}
export interface GraphEdge {
fromNodeId: string;
fromPort: string;
toNodeId: string;
toPort: string;
}
export interface Graph {
ruleName: string;
ruleId: string;
enable: boolean;
nodes: GraphNode[];
edges: GraphEdge[];
}
export interface CompileOptions {
now?: number;
devices?: DeviceInventory;
deviceStrict?: boolean;
}
export interface DeviceInventory {
devList: Record<string, DeviceInventoryItem>;
}
export interface DeviceInventoryItem {
did?: string;
urn?: string;
name?: string;
roomName?: string;
online?: boolean;
[key: string]: JsonValue | undefined;
}
export interface CompileResult {
backup: BackupData;
rules: BackupRule[];
diagnostics: Diagnostic[];
summary: CompileSummary;
}
export interface CompileSummary {
addedRules: string[];
replacedRules: string[];
nodeCount: number;
deviceDids: string[];
outputPath?: string;
}
export type DiagnosticSeverity = "error" | "warning";
export interface Diagnostic {
severity: DiagnosticSeverity;
code: string;
message: string;
path?: string;
}
export interface ValidateOptions {
devices?: DeviceInventory;
deviceStrict?: boolean;
}

279
src/validate.ts Normal file
View File

@@ -0,0 +1,279 @@
import type { BackupData, BackupNode, Diagnostic, MiotDType, ValidateOptions } from "./types";
const NODE_ID_PATTERN = /^[0-9a-zA-Z]+$/;
const CONNECTION_PATTERN = /^([0-9a-zA-Z]+)\.([0-9a-zA-Z]+)$/;
export function validateBackup(data: BackupData, options: ValidateOptions = {}): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
validateTopLevel(data, diagnostics);
data.rules.forEach((rule, ruleIndex) => {
const rulePath = `rules[${ruleIndex}]`;
if (rule.cfg?.id !== rule.id) {
diagnostics.push(error("rule-id-mismatch", `${rulePath}.cfg.id 必须等于 rule.id`, `${rulePath}.cfg.id`));
}
if (typeof rule.cfg?.enable !== "boolean") {
diagnostics.push(error("rule-enable-invalid", `${rulePath}.cfg.enable 必须是布尔值`, `${rulePath}.cfg.enable`));
}
validateNodes(rule.nodes, `${rulePath}.nodes`, diagnostics, options);
});
return diagnostics;
}
export function assertValidBackup(data: BackupData, options: ValidateOptions = {}): void {
const diagnostics = validateBackup(data, options).filter((diagnostic) => diagnostic.severity === "error");
if (diagnostics.length > 0) {
throw new Error(diagnostics.map((diagnostic) => diagnostic.message).join("\n"));
}
}
function validateTopLevel(data: BackupData, diagnostics: Diagnostic[]): void {
if (data.version !== 2) {
diagnostics.push(error("backup-version-invalid", "顶层 version 必须等于 2", "version"));
}
if (!Array.isArray(data.rules)) {
diagnostics.push(error("backup-rules-invalid", "顶层 rules 必须是数组", "rules"));
}
if (!data.variables || typeof data.variables.global !== "object") {
diagnostics.push(error("backup-variables-invalid", "顶层 variables.global 必须是对象", "variables.global"));
}
}
function validateNodes(
nodes: BackupNode[],
path: string,
diagnostics: Diagnostic[],
options: ValidateOptions,
): void {
const nodeById = new Map<string, BackupNode>();
nodes.forEach((node, index) => {
const nodePath = `${path}[${index}]`;
validateNodeShape(node, nodePath, diagnostics);
if (typeof node.id === "string") {
if (nodeById.has(node.id)) {
diagnostics.push(error("node-id-duplicate", `节点 ID ${node.id} 重复`, `${nodePath}.id`));
}
nodeById.set(node.id, node);
}
});
nodes.forEach((node, index) => {
const nodePath = `${path}[${index}]`;
validateNodePorts(node, nodePath, diagnostics);
validateNodeProps(node, nodePath, diagnostics);
validateDeviceReference(node, nodePath, diagnostics, options);
validateConnections(node, nodePath, nodeById, diagnostics);
});
}
function validateNodeShape(node: BackupNode, path: string, diagnostics: Diagnostic[]): void {
if (typeof node.id !== "string") {
diagnostics.push(error("node-id-missing", `${path}.id 必须是字符串`, `${path}.id`));
} else if (!NODE_ID_PATTERN.test(node.id)) {
diagnostics.push(error("node-id-invalid", `节点 ID ${node.id} 只能包含 0-9a-zA-Z`, `${path}.id`));
}
if (typeof node.type !== "string") {
diagnostics.push(error("node-type-missing", `${path}.type 必须是字符串`, `${path}.type`));
}
if (!isPlainObject(node.props)) {
diagnostics.push(error("node-props-invalid", `${path}.props 必须是对象`, `${path}.props`));
}
if (!isPlainObject(node.inputs)) {
diagnostics.push(error("node-inputs-invalid", `${path}.inputs 必须是对象`, `${path}.inputs`));
}
if (!isPlainObject(node.outputs)) {
diagnostics.push(error("node-outputs-invalid", `${path}.outputs 必须是对象`, `${path}.outputs`));
}
if (!isPlainObject(node.cfg)) {
diagnostics.push(error("node-cfg-invalid", `${path}.cfg 必须是对象`, `${path}.cfg`));
} else if (!Number.isInteger(node.cfg.version)) {
diagnostics.push(error("node-cfg-version-invalid", `${path}.cfg.version 必须是整数`, `${path}.cfg.version`));
}
}
function validateNodePorts(node: BackupNode, path: string, diagnostics: Diagnostic[]): void {
for (const [port, connections] of Object.entries(node.outputs)) {
if (!Array.isArray(connections)) {
diagnostics.push(error("node-output-invalid", `${path}.outputs.${port} 必须是数组`, `${path}.outputs.${port}`));
}
}
if (node.type === "deviceOutput" && !("trigger" in node.inputs)) {
diagnostics.push(error("device-output-trigger-missing", `${path}.inputs.trigger 缺失`, `${path}.inputs.trigger`));
}
if (node.type === "deviceGet") {
for (const port of ["input", "output", "output2"]) {
if (port === "input" && !(port in node.inputs)) {
diagnostics.push(error("device-get-input-missing", `${path}.inputs.input 缺失`, `${path}.inputs.input`));
}
if (port !== "input" && !(port in node.outputs)) {
diagnostics.push(error("device-get-output-missing", `${path}.outputs.${port} 缺失`, `${path}.outputs.${port}`));
}
}
}
}
function validateConnections(
node: BackupNode,
path: string,
nodeById: Map<string, BackupNode>,
diagnostics: Diagnostic[],
): void {
for (const [port, connections] of Object.entries(node.outputs)) {
if (!Array.isArray(connections)) {
continue;
}
connections.forEach((connection, index) => {
const connectionPath = `${path}.outputs.${port}[${index}]`;
if (typeof connection !== "string") {
diagnostics.push(error("connection-not-string", `${connectionPath} 必须是字符串`, connectionPath));
return;
}
const matched = connection.match(CONNECTION_PATTERN);
if (!matched) {
diagnostics.push(error("connection-format-invalid", `${connectionPath} 必须是 目标节点ID.目标端口名`, connectionPath));
return;
}
const targetNodeId = matched[1]!;
const targetPort = matched[2]!;
const targetNode = nodeById.get(targetNodeId);
if (!targetNode) {
diagnostics.push(error("connection-node-missing", `${connectionPath} 指向不存在的节点 ${targetNodeId}`, connectionPath));
return;
}
if (!(targetPort in targetNode.inputs)) {
diagnostics.push(error("connection-port-missing", `${connectionPath} 指向不存在的输入端口 ${targetNodeId}.${targetPort}`, connectionPath));
}
});
}
}
function validateDeviceReference(
node: BackupNode,
path: string,
diagnostics: Diagnostic[],
options: ValidateOptions,
): void {
const did = node.props.did;
if (typeof did !== "string" || !options.devices) {
return;
}
if (!options.devices.devList[did]) {
const severity = options.deviceStrict === false ? "warning" : "error";
diagnostics.push({
severity,
code: "device-not-found",
message: `${path}.props.did 引用的设备 ${did} 不存在于 devices.json`,
path: `${path}.props.did`,
});
}
}
function validateNodeProps(node: BackupNode, path: string, diagnostics: Diagnostic[]): void {
if (node.type === "deviceInput") {
validateDeviceInputProps(node, path, diagnostics);
} else if (node.type === "deviceGet") {
validateDeviceGetProps(node, path, diagnostics);
} else if (node.type === "deviceOutput") {
validateDeviceOutputProps(node, path, diagnostics);
} else if (node.type === "delay") {
if (!Number.isInteger(node.props.timeout) || Number(node.props.timeout) <= 0) {
diagnostics.push(error("delay-timeout-invalid", `${path}.props.timeout 必须是大于 0 的整数`, `${path}.props.timeout`));
}
}
}
function validateDeviceInputProps(node: BackupNode, path: string, diagnostics: Diagnostic[]): void {
requireString(node.props.did, `${path}.props.did`, diagnostics);
requireNumber(node.props.siid, `${path}.props.siid`, diagnostics);
if (typeof node.props.piid === "number") {
validateComparison(node.props.dtype, node.props.operator, node.props.v1, node.props.v2, path, diagnostics);
} else if (typeof node.props.eiid !== "number") {
diagnostics.push(error("device-input-capability-missing", `${path}.props 必须包含 piid 或 eiid`, `${path}.props`));
}
}
function validateDeviceGetProps(node: BackupNode, path: string, diagnostics: Diagnostic[]): void {
requireString(node.props.did, `${path}.props.did`, diagnostics);
requireNumber(node.props.siid, `${path}.props.siid`, diagnostics);
requireNumber(node.props.piid, `${path}.props.piid`, diagnostics);
validateComparison(node.props.dtype, node.props.operator, node.props.v1, node.props.v2, path, diagnostics);
}
function validateDeviceOutputProps(node: BackupNode, path: string, diagnostics: Diagnostic[]): void {
requireString(node.props.did, `${path}.props.did`, diagnostics);
requireNumber(node.props.siid, `${path}.props.siid`, diagnostics);
const hasPropertyWrite = typeof node.props.piid === "number";
const hasActionCall = typeof node.props.aiid === "number";
if (!hasPropertyWrite && !hasActionCall) {
diagnostics.push(error("device-output-capability-missing", `${path}.props 必须包含 piid 或 aiid`, `${path}.props`));
}
if (hasPropertyWrite && !("value" in node.props) && !("id" in node.props && "scope" in node.props && "dtype" in node.props)) {
diagnostics.push(error("device-output-value-missing", `${path}.props 属性写入必须包含 value 或变量绑定字段`, `${path}.props`));
}
}
function validateComparison(
dtypeValue: unknown,
operatorValue: unknown,
v1: unknown,
v2: unknown,
path: string,
diagnostics: Diagnostic[],
): void {
if (!isMiotDType(dtypeValue)) {
diagnostics.push(error("comparison-dtype-invalid", `${path}.props.dtype 不合法`, `${path}.props.dtype`));
return;
}
if (typeof operatorValue !== "string") {
diagnostics.push(error("comparison-operator-missing", `${path}.props.operator 必须是字符串`, `${path}.props.operator`));
return;
}
const allowed = allowedOperators(dtypeValue);
if (!allowed.includes(operatorValue)) {
diagnostics.push(error("comparison-operator-invalid", `${path}.props.operator 不适用于 dtype ${dtypeValue}`, `${path}.props.operator`));
}
if (operatorValue === "between" && (v1 === undefined || v2 === undefined)) {
diagnostics.push(error("comparison-between-invalid", `${path}.props 使用 between 时必须包含 v1 和 v2`, `${path}.props`));
} else if (operatorValue === "include" && !Array.isArray(v1)) {
diagnostics.push(error("comparison-include-invalid", `${path}.props 使用 include 时 v1 必须是数组`, `${path}.props.v1`));
} else if (operatorValue !== "between" && v1 === undefined) {
diagnostics.push(error("comparison-v1-missing", `${path}.props.operator 存在时必须包含 v1`, `${path}.props.v1`));
}
}
function allowedOperators(dtype: MiotDType): string[] {
if (dtype === "int") {
return [">=", "<=", "=", "!=", ">", "<", "between", "include"];
}
if (dtype === "float") {
return [">", "<", "between"];
}
if (dtype === "boolean" || dtype === "string") {
return ["="];
}
return [];
}
function isMiotDType(value: unknown): value is MiotDType {
return value === "int" || value === "float" || value === "boolean" || value === "string";
}
function requireString(value: unknown, path: string, diagnostics: Diagnostic[]): void {
if (typeof value !== "string") {
diagnostics.push(error("field-string-required", `${path} 必须是字符串`, path));
}
}
function requireNumber(value: unknown, path: string, diagnostics: Diagnostic[]): void {
if (typeof value !== "number") {
diagnostics.push(error("field-number-required", `${path} 必须是数字`, path));
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function error(code: string, message: string, path?: string): Diagnostic {
return { severity: "error", code, message, path };
}

27
tests/backup.test.ts Normal file
View File

@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test";
import { BACKUP_HEADER, backupStats, packBackup, readBackupFile, unpackBackup } from "../src/index.ts";
const sampleBackupPath = "resources/备份2026_4_26 19_17_42.bak";
describe("备份文件处理", () => {
test("真实备份可以解包并统计", () => {
const backup = readBackupFile(sampleBackupPath);
expect(backupStats(backup)).toEqual({
version: 2,
ruleCount: 15,
globalVariableCount: 25,
nodeCount: 267,
});
});
test("打包结果使用固定头并可往返解包", () => {
const backup = readBackupFile(sampleBackupPath);
const packed = packBackup(backup);
expect([...packed.subarray(0, 4)]).toEqual([...BACKUP_HEADER]);
expect(unpackBackup(packed)).toEqual(backup);
});
test("非法文件头会报错", () => {
expect(() => unpackBackup(new Uint8Array([0, 1, 2, 3, 4]))).toThrow("备份文件头不匹配");
});
});

94
tests/compiler.test.ts Normal file
View File

@@ -0,0 +1,94 @@
import { describe, expect, test } from "bun:test";
import { readDeviceInventoryFile, readBackupFile } from "../src/index.ts";
import { collectRuleDefinitions, compileRule, compileWorkflow } from "../src/compiler.ts";
import examples, { conditionBeforeAction, eventTurnOn, mergedTriggers } from "../examples/rules/index.ts";
const now = 1_760_000_000_000;
const devices = readDeviceInventoryFile("resources/devices.json");
describe("规则编译", () => {
test("单条事件触发开灯规则生成稳定 JSON", () => {
const { rule } = compileRule(eventTurnOn, { now });
expect(rule).toEqual({
id: String(now),
cfg: {
id: String(now),
userData: {
name: "示例-事件触发开灯",
transform: { x: 0, y: 0, scale: 1, rotate: 0 },
lastUpdateTime: now,
version: 0,
},
uiType: "test",
enable: true,
},
nodes: [
{
id: "DI1",
type: "deviceInput",
props: {
did: "blt.3.1htiptpdgco00",
siid: 2,
eiid: 1008,
},
inputs: {},
outputs: { output: ["DO1.trigger"] },
cfg: {
urn: "urn:miot-spec-v2:device:motion-sensor:0000A014:xiaomi-pir1:2",
name: "deviceInput",
version: 1,
pos: { x: 240, y: 180, width: 450, height: 206 },
},
},
{
id: "DO1",
type: "deviceOutput",
props: {
did: "group.1815373077765824512",
siid: 2,
piid: 1,
value: true,
},
inputs: { trigger: null },
outputs: { output: [] },
cfg: {
urn: "urn:miot-spec-v2:device:light:0000A001:mijia-group3:3:0000C802",
name: "deviceOutput",
version: 1,
pos: { x: 800, y: 180, width: 528, height: 164 },
},
},
],
});
});
test("状态判断后动作会生成 deviceGet 并连接满足路径", () => {
const { rule } = compileRule(conditionBeforeAction, { now });
expect(rule.nodes.map((node) => node.type)).toEqual(["deviceInput", "deviceGet", "deviceOutput"]);
expect(rule.nodes[0]!.outputs.output).toEqual(["DG1.input"]);
expect(rule.nodes[1]!.outputs.output).toEqual(["DO1.trigger"]);
expect(rule.nodes[1]!.outputs.output2).toEqual([]);
});
test("多个触发器会生成 signalOr 合并节点", () => {
const { rule } = compileRule(mergedTriggers, { now });
expect(rule.nodes.map((node) => node.type)).toEqual(["deviceInput", "deviceInput", "signalOr", "deviceOutput"]);
expect(rule.nodes[0]!.outputs.output).toEqual(["SO1.input0"]);
expect(rule.nodes[1]!.outputs.output).toEqual(["SO1.input1"]);
expect(rule.nodes[2]!.outputs.output).toEqual(["DO1.trigger"]);
});
test("模块导出收集支持默认数组和命名规则", async () => {
const moduleExports = await import("../examples/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("示例-事件触发开灯");
});
});

41
tests/validate.test.ts Normal file
View File

@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import { compileRule, readDeviceInventoryFile, validateBackup } from "../src/index.ts";
import { eventTurnOn } from "../examples/rules/index.ts";
const now = 1_760_000_000_000;
describe("备份校验", () => {
test("非法节点 ID 会失败", () => {
const backup = backupFromRule();
backup.rules[0]!.nodes[0]!.id = "bad-id";
const codes = validateBackup(backup).map((diagnostic) => diagnostic.code);
expect(codes).toContain("node-id-invalid");
});
test("非法连接目标会失败", () => {
const backup = backupFromRule();
backup.rules[0]!.nodes[0]!.outputs.output = ["DO1.missing"];
const codes = validateBackup(backup).map((diagnostic) => diagnostic.code);
expect(codes).toContain("connection-port-missing");
});
test("缺失设备会按严格模式报错", () => {
const backup = backupFromRule();
const diagnostics = validateBackup(backup, { devices: { devList: {} } });
expect(diagnostics.filter((diagnostic) => diagnostic.code === "device-not-found")).toHaveLength(2);
});
test("真实设备清单可通过设备校验", () => {
const backup = backupFromRule();
const diagnostics = validateBackup(backup, { devices: readDeviceInventoryFile("resources/devices.json") });
expect(diagnostics.filter((diagnostic) => diagnostic.severity === "error")).toEqual([]);
});
});
function backupFromRule() {
return {
version: 2 as const,
rules: [compileRule(eventTurnOn, { now }).rule],
variables: { global: {} },
};
}

30
tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}