From 690ef8ce835566be6158973c4530b1929169d445 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Thu, 7 May 2026 20:28:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96=20miot=5Fx?= =?UTF-8?q?=20TypeScript=20=E5=B7=A5=E4=BD=9C=E6=B5=81=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E5=99=A8=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 415 ++++++++++++++++++ AGENTS.md | 1 + CLAUDE.md | 1 + README.md | 120 +++++ bun.lock | 26 ++ examples/rules/index.ts | 49 +++ openspec/config.yaml | 21 + .../typescript-workflow-authoring/spec.md | 111 +++++ package.json | 15 + src/backup.ts | 70 +++ src/builder.ts | 171 ++++++++ src/cli.ts | 125 ++++++ src/compiler.ts | 344 +++++++++++++++ src/graph.ts | 78 ++++ src/index.ts | 41 ++ src/nodes.ts | 322 ++++++++++++++ src/types.ts | 309 +++++++++++++ src/validate.ts | 279 ++++++++++++ tests/backup.test.ts | 27 ++ tests/compiler.test.ts | 94 ++++ tests/validate.test.ts | 41 ++ tsconfig.json | 30 ++ 22 files changed, 2690 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 bun.lock create mode 100644 examples/rules/index.ts create mode 100644 openspec/config.yaml create mode 100644 openspec/specs/typescript-workflow-authoring/spec.md create mode 100644 package.json create mode 100644 src/backup.ts create mode 100644 src/builder.ts create mode 100644 src/cli.ts create mode 100644 src/compiler.ts create mode 100644 src/graph.ts create mode 100644 src/index.ts create mode 100644 src/nodes.ts create mode 100644 src/types.ts create mode 100644 src/validate.ts create mode 100644 tests/backup.test.ts create mode 100644 tests/compiler.test.ts create mode 100644 tests/validate.test.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dad8bc9 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..874fa8e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +严格遵守openspec/config.yaml中context声明的项目规范 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..874fa8e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +严格遵守openspec/config.yaml中context声明的项目规范 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..39a93fe --- /dev/null +++ b/README.md @@ -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 +``` + +测试覆盖真实备份解包统计、备份打包往返、规则编译输出、备份合并和常见校验错误。 + +## 导入验证建议 + +先使用示例规则生成一个新备份文件,在米家极客版中导入并确认画布可以打开。确认简单规则能运行后,再对真实工作流进行替换。原始备份文件应始终保留,便于导入失败时回退。 diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..d199f58 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/examples/rules/index.ts b/examples/rules/index.ts new file mode 100644 index 0000000..2c7041f --- /dev/null +++ b/examples/rules/index.ts @@ -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]; diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..95b0c16 --- /dev/null +++ b/openspec/config.yaml @@ -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: + - 一行一个任务,严禁任务内容跨行 diff --git a/openspec/specs/typescript-workflow-authoring/spec.md b/openspec/specs/typescript-workflow-authoring/spec.md new file mode 100644 index 0000000..70a1fb2 --- /dev/null +++ b/openspec/specs/typescript-workflow-authoring/spec.md @@ -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` diff --git a/package.json b/package.json new file mode 100644 index 0000000..8262213 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/backup.ts b/src/backup.ts new file mode 100644 index 0000000..09ee399 --- /dev/null +++ b/src/backup.ts @@ -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 ?? {}, + }, + }; +} diff --git a/src/builder.ts b/src/builder.ts new file mode 100644 index 0000000..8fd16e7 --- /dev/null +++ b/src/builder.ts @@ -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>(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 { + 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 }; +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..a21a5af --- /dev/null +++ b/src/cli.ts @@ -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 { + const options = parseArgs(argv); + if (!options.rules) { + throw new Error("缺少 --rules 规则入口"); + } + + const rulesModule = await import(pathToFileURL(resolve(options.rules)).href) as Record; + 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 输出备份路径;如只想预览请使用 --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; + }); +} diff --git a/src/compiler.ts b/src/compiler.ts new file mode 100644 index 0000000..1fe0ec0 --- /dev/null +++ b/src/compiler.ts @@ -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): RuleDefinition[] { + const definitions: RuleDefinition[] = []; + const seen = new Set(); + 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(); + 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(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} diff --git a/src/graph.ts b/src/graph.ts new file mode 100644 index 0000000..3160e13 --- /dev/null +++ b/src/graph.ts @@ -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(); + private readonly used = new Set(); + + 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(); + 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"; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3918009 --- /dev/null +++ b/src/index.ts @@ -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"; diff --git a/src/nodes.ts b/src/nodes.ts new file mode 100644 index 0000000..abbb031 --- /dev/null +++ b/src/nodes.ts @@ -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; + 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 { + 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; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..58da6c4 --- /dev/null +++ b/src/types.ts @@ -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; +} + +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; + outputs: Record; + 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; + 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; +} + +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; +} diff --git a/src/validate.ts b/src/validate.ts new file mode 100644 index 0000000..817636e --- /dev/null +++ b/src/validate.ts @@ -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(); + 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, + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function error(code: string, message: string, path?: string): Diagnostic { + return { severity: "error", code, message, path }; +} diff --git a/tests/backup.test.ts b/tests/backup.test.ts new file mode 100644 index 0000000..2a2f76f --- /dev/null +++ b/tests/backup.test.ts @@ -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("备份文件头不匹配"); + }); +}); diff --git a/tests/compiler.test.ts b/tests/compiler.test.ts new file mode 100644 index 0000000..c2fd8e5 --- /dev/null +++ b/tests/compiler.test.ts @@ -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("示例-事件触发开灯"); + }); +}); diff --git a/tests/validate.test.ts b/tests/validate.test.ts new file mode 100644 index 0000000..15d490f --- /dev/null +++ b/tests/validate.test.ts @@ -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: {} }, + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b2e7497 --- /dev/null +++ b/tsconfig.json @@ -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 + } +}