feat: 初始化 miot_x TypeScript 工作流构建器项目
This commit is contained in:
415
.gitignore
vendored
Normal file
415
.gitignore
vendored
Normal 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
|
||||
120
README.md
Normal file
120
README.md
Normal 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
26
bun.lock
Normal 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
49
examples/rules/index.ts
Normal 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
21
openspec/config.yaml
Normal 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:
|
||||
- 一行一个任务,严禁任务内容跨行
|
||||
111
openspec/specs/typescript-workflow-authoring/spec.md
Normal file
111
openspec/specs/typescript-workflow-authoring/spec.md
Normal 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
15
package.json
Normal 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
70
src/backup.ts
Normal 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
171
src/builder.ts
Normal 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
125
src/cli.ts
Normal 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
344
src/compiler.ts
Normal 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
78
src/graph.ts
Normal 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
41
src/index.ts
Normal 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
322
src/nodes.ts
Normal 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
309
src/types.ts
Normal 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
279
src/validate.ts
Normal 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
27
tests/backup.test.ts
Normal 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
94
tests/compiler.test.ts
Normal 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
41
tests/validate.test.ts
Normal 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
30
tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user