From 70caf3680465ce50b1a6439f919c96a78d86d1f2 Mon Sep 17 00:00:00 2001 From: doomsday616 Date: Wed, 22 Jul 2026 01:55:43 +0800 Subject: [PATCH 1/4] feat: restore variable lifecycle tools --- src/core/index.ts | 2 +- src/core/tools/variable.test.ts | 45 ++++++++ src/core/tools/variable.ts | 47 ++++++++ src/mcp/tools/variable.test.ts | 9 ++ src/mcp/tools/variable.ts | 199 +++++++++++++++++++++++++++++++- 5 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 src/core/tools/variable.test.ts create mode 100644 src/mcp/tools/variable.test.ts diff --git a/src/core/index.ts b/src/core/index.ts index 892c9ea..c800f23 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -11,7 +11,7 @@ export type { GatewayManager } from './gateway/manager'; // 工具函数 export { getDevices, getDevice } from './tools/device'; export { getGraphs, getGraph, createGraph, updateGraph, deleteGraph, toggleGraph } from './tools/graph'; -export { getVariables, setVariable } from './tools/variable'; +export { getVariables, setVariable, createVariable, deleteVariable, getVariableValue, getVariableConfig } from './tools/variable'; export { callGatewayApi, READ_ONLY_GATEWAY_METHODS } from './tools/misc'; export { validateGraphCapabilitiesWithGateway } from './tools/capabilityValidation'; export { validateGraph, layoutNodes } from './tools/base'; diff --git a/src/core/tools/variable.test.ts b/src/core/tools/variable.test.ts new file mode 100644 index 0000000..dc50f63 --- /dev/null +++ b/src/core/tools/variable.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import type { GatewayClient } from '../gateway/client'; +import { createVariable, deleteVariable, getVariableConfig, getVariableValue } from './variable'; + +async function main(): Promise { + const calls: Array<{ method: string; params: unknown; timeout: number }> = []; + const gateway = { + async callApi(method: string, params: unknown, timeout: number): Promise { + calls.push({ method, params, timeout }); + if (method === 'getVarValue') return { value: 7 }; + if (method === 'getVarConfig') return { type: 'number' }; + return undefined; + }, + } as unknown as GatewayClient; + + assert.equal((await createVariable(gateway, 'probe1', 'number', 0, 'Probe')).success, true); + assert.equal((await deleteVariable(gateway, 'probe1')).success, true); + assert.deepEqual(await getVariableValue(gateway, 'probe1'), { + success: true, + data: { id: 'probe1', scope: 'global', value: { value: 7 } }, + }); + assert.deepEqual(await getVariableConfig(gateway, 'probe1'), { + success: true, + data: { id: 'probe1', scope: 'global', config: { type: 'number' } }, + }); + assert.deepEqual(calls, [ + { method: 'createVar', params: { scope: 'global', id: 'probe1', type: 'number', value: 0, name: 'Probe' }, timeout: 10000 }, + { method: 'deleteVar', params: { scope: 'global', id: 'probe1' }, timeout: 10000 }, + { method: 'getVarValue', params: { scope: 'global', id: 'probe1' }, timeout: 10000 }, + { method: 'getVarConfig', params: { scope: 'global', id: 'probe1' }, timeout: 10000 }, + ]); + + const failingGateway = { + async callApi(): Promise { + throw new Error('gateway failed'); + }, + } as unknown as GatewayClient; + const failure = await createVariable(failingGateway, 'probe2', 'number', 0); + assert.equal(failure.success, false); + assert.match('error' in failure ? failure.error : '', /gateway failed/); + + console.log('variable lifecycle tests passed'); +} + +void main(); diff --git a/src/core/tools/variable.ts b/src/core/tools/variable.ts index 629fac1..7f32a07 100644 --- a/src/core/tools/variable.ts +++ b/src/core/tools/variable.ts @@ -23,3 +23,50 @@ export async function setVariable(gateway: GatewayClient, id: string, value: str return { success: false, error: `设置变量值失败: ${error}` }; } } + +/** + * 创建变量。 + * ⚠️ 网关要求 id 必须是纯字母数字(不能含下划线/连字符),否则返回 "Invalid id format"。 + */ +export async function createVariable( + gateway: GatewayClient, + id: string, + type: 'number' | 'string', + value: number | string, + name?: string, + scope: string = 'global' +): Promise { + try { + await gateway.callApi('createVar', { scope, id, type, value, name: name ?? id }, 10000); + return { success: true, message: `变量 ${id} 创建成功` }; + } catch (error) { + return { success: false, error: `创建变量失败: ${error}` }; + } +} + +export async function deleteVariable(gateway: GatewayClient, id: string, scope: string = 'global'): Promise { + try { + await gateway.callApi('deleteVar', { scope, id }, 10000); + return { success: true, message: `变量 ${id} 删除成功` }; + } catch (error) { + return { success: false, error: `删除变量失败: ${error}` }; + } +} + +export async function getVariableValue(gateway: GatewayClient, id: string, scope: string = 'global'): Promise { + try { + const value = await gateway.callApi('getVarValue', { scope, id }, 10000); + return { success: true, data: { id, scope, value } }; + } catch (error) { + return { success: false, error: `获取变量值失败: ${error}` }; + } +} + +export async function getVariableConfig(gateway: GatewayClient, id: string, scope: string = 'global'): Promise { + try { + const config = await gateway.callApi('getVarConfig', { scope, id }, 10000); + return { success: true, data: { id, scope, config } }; + } catch (error) { + return { success: false, error: `获取变量配置失败: ${error}` }; + } +} diff --git a/src/mcp/tools/variable.test.ts b/src/mcp/tools/variable.test.ts new file mode 100644 index 0000000..d235188 --- /dev/null +++ b/src/mcp/tools/variable.test.ts @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import { CreateVariableInputSchema } from './variable'; + +assert.equal(CreateVariableInputSchema.safeParse({ id: 'valid123', type: 'number', value: 0 }).success, true); +assert.equal(CreateVariableInputSchema.safeParse({ id: 'valid123', type: 'string', value: 'ready' }).success, true); +assert.equal(CreateVariableInputSchema.safeParse({ id: 'invalid_id', type: 'number', value: 0 }).success, false); +assert.equal(CreateVariableInputSchema.safeParse({ id: 'valid123', type: 'number', value: '0' }).success, false); + +console.log('variable MCP schema tests passed'); diff --git a/src/mcp/tools/variable.ts b/src/mcp/tools/variable.ts index d62d43e..4553ec3 100644 --- a/src/mcp/tools/variable.ts +++ b/src/mcp/tools/variable.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { GatewayManager } from "../../core/gateway/manager.js"; -import { getVariables, setVariable } from "../../core/index.js"; +import { getVariables, setVariable, createVariable, deleteVariable, getVariableValue, getVariableConfig } from "../../core/index.js"; import { ResponseFormatSchema, formatJson, @@ -13,6 +13,17 @@ import { formatVariableListMarkdown, } from "../utils.js"; +export const CreateVariableInputSchema = z.object({ + id: z.string().regex(/^[a-zA-Z0-9]+$/, "id 必须是纯字母数字,不能含下划线/连字符").describe("变量ID,纯字母数字"), + type: z.enum(["number", "string"]).describe("变量类型"), + value: z.union([z.string(), z.number()]).describe("初始值"), + name: z.string().optional().describe("显示名称,可选,默认与 id 相同"), + scope: z.string().default("global").describe("变量作用域,默认 global"), +}).refine( + ({ type, value }) => typeof value === type, + { message: "value 必须与 type 匹配", path: ["value"] } +); + export function registerVariableTools( server: McpServer, gatewayManager: GatewayManager @@ -121,4 +132,190 @@ Args: } } ); + + // ==================== mijia_create_variable ==================== + server.registerTool( + "mijia_create_variable", + { + title: "创建变量", + description: `创建一个新的自动化变量。 + +⚠️ 网关要求 id 必须是纯字母数字(不能含下划线、连字符或中文),否则返回 "Invalid id format"。 + +Args: + - id (string): 变量ID,纯字母数字 + - type (string): "number" 或 "string" + - value (string | number): 初始值 + - name (string, optional): 显示名称(可含中文),默认与 id 相同 + - scope (string, optional): 变量作用域,默认 "global"`, + inputSchema: CreateVariableInputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async ({ id, type, value, name, scope = "global" }) => { + try { + gatewayManager.ensureConnected(); + const result = await createVariable(gatewayManager.gateway!, id, type, value, name, scope); + + if (!result.success) { + return { + content: [{ type: "text", text: handleError(new Error(result.error), "create_variable") }], + isError: true, + }; + } + + return { + content: [{ type: "text", text: result.message || `变量 ${id} 创建成功` }], + structuredContent: { success: true, id, type, value, scope }, + }; + } catch (error) { + return { + content: [{ type: "text", text: handleError(error, "create_variable") }], + isError: true, + }; + } + } + ); + + // ==================== mijia_delete_variable ==================== + server.registerTool( + "mijia_delete_variable", + { + title: "删除变量", + description: `删除一个自动化变量。 + +Args: + - id (string): 变量ID + - scope (string, optional): 变量作用域,默认 "global"`, + inputSchema: z.object({ + id: z.string().describe("变量ID"), + scope: z.string().default("global").describe("变量作用域,默认 global"), + }), + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ id, scope = "global" }) => { + try { + gatewayManager.ensureConnected(); + const result = await deleteVariable(gatewayManager.gateway!, id, scope); + + if (!result.success) { + return { + content: [{ type: "text", text: handleError(new Error(result.error), "delete_variable") }], + isError: true, + }; + } + + return { + content: [{ type: "text", text: result.message || `变量 ${id} 删除成功` }], + structuredContent: { success: true, id, scope }, + }; + } catch (error) { + return { + content: [{ type: "text", text: handleError(error, "delete_variable") }], + isError: true, + }; + } + } + ); + + // ==================== mijia_get_variable_value ==================== + server.registerTool( + "mijia_get_variable_value", + { + title: "获取变量当前值", + description: `获取单个变量的当前值。 + +Args: + - id (string): 变量ID + - scope (string, optional): 变量作用域,默认 "global"`, + inputSchema: z.object({ + id: z.string().describe("变量ID"), + scope: z.string().default("global").describe("变量作用域,默认 global"), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ id, scope = "global" }) => { + try { + gatewayManager.ensureConnected(); + const result = await getVariableValue(gatewayManager.gateway!, id, scope); + + if (!result.success) { + return { + content: [{ type: "text", text: handleError(new Error(result.error), "get_variable_value") }], + isError: true, + }; + } + + return { + content: [{ type: "text", text: formatJson(result.data) }], + structuredContent: result.data as Record, + }; + } catch (error) { + return { + content: [{ type: "text", text: handleError(error, "get_variable_value") }], + isError: true, + }; + } + } + ); + + // ==================== mijia_get_variable_config ==================== + server.registerTool( + "mijia_get_variable_config", + { + title: "获取变量配置", + description: `获取单个变量的配置(类型、显示名称等)。 + +Args: + - id (string): 变量ID + - scope (string, optional): 变量作用域,默认 "global"`, + inputSchema: z.object({ + id: z.string().describe("变量ID"), + scope: z.string().default("global").describe("变量作用域,默认 global"), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ id, scope = "global" }) => { + try { + gatewayManager.ensureConnected(); + const result = await getVariableConfig(gatewayManager.gateway!, id, scope); + + if (!result.success) { + return { + content: [{ type: "text", text: handleError(new Error(result.error), "get_variable_config") }], + isError: true, + }; + } + + return { + content: [{ type: "text", text: formatJson(result.data) }], + structuredContent: result.data as Record, + }; + } catch (error) { + return { + content: [{ type: "text", text: handleError(error, "get_variable_config") }], + isError: true, + }; + } + } + ); } From e367e62f699e25fe51b85a863631b113af9dbd0c Mon Sep 17 00:00:00 2001 From: doomsday616 Date: Wed, 22 Jul 2026 02:09:58 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E7=BD=91?= =?UTF-8?q?=E5=85=B3=E8=83=BD=E5=8A=9B=E5=8F=91=E7=8E=B0=E4=B8=8E=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E6=8E=92=E9=9A=9C=E6=8C=87=E5=8D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/mijia-automation/SKILL.md | 37 ++++++++++++- .../gateway-capability-discovery.md | 53 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 .agents/skills/mijia-automation/references/gateway-capability-discovery.md diff --git a/.agents/skills/mijia-automation/SKILL.md b/.agents/skills/mijia-automation/SKILL.md index 5ae4c02..723eb73 100644 --- a/.agents/skills/mijia-automation/SKILL.md +++ b/.agents/skills/mijia-automation/SKILL.md @@ -1,13 +1,46 @@ --- name: mijia-automation -description: 米家自动化极客版规则创建指南。当用户想要创建智能场景、设备联动、定时任务、条件触发等自动化规则时使用此skill。 +description: 米家自动化极客版规则与变量管理指南。当用户想要创建智能场景、设备联动、定时任务、条件触发,或创建、读取、修改、删除自动化变量时使用此skill。 metadata: author: oh-my-sage - version: "3.4" + version: "3.5" --- # 米家自动化规则创建 +## 变量生命周期能力 + +变量管理分为三层,不能把其中一层的限制误判成网关不支持: + +| 层级 | 能力与限制 | +|------|------------| +| 网关 API | 支持 `createVar`、`deleteVar`、`getVarValue`、`getVarConfig`、`setVarValue` | +| 专用 MCP 工具 | 使用 `mijia_create_variable`、`mijia_delete_variable`、`mijia_get_variable_value`、`mijia_get_variable_config`、`mijia_set_variable` | +| 通用原始 API 工具 | `mijia_call_api` 故意只允许只读方法;写方法被拒绝不代表网关没有写能力 | + +关键规则: + +- 新建变量必须调用 `mijia_create_variable`;`mijia_set_variable` 只修改已存在变量,不会自动创建。 +- `varSetNumber`、`varSetString`、`deviceInputSetVar` 和 `deviceGetSetVar` 只写已存在变量,不能用作变量创建器。 +- 变量 ID 必须匹配 `^[a-zA-Z0-9]+$`,不能含下划线、连字符或中文;显示名称 `name` 可以包含中文。 +- `type` 只能是 `number` 或 `string`,初始值和后续值必须与类型一致。 +- 删除变量前检查所有规则引用;删除是不可恢复操作。 + +### 工具缺失或写入失败时 + +按以下顺序判断,不要直接宣布“网关无法读写变量”: + +1. 查看当前 MCP 工具列表是否包含上述专用变量工具。 +2. 工具缺失时检查运行中的 MCP 是否为旧构建;源码新增工具后必须重新构建并重启 MCP,当前进程不会动态注册新工具。 +3. 检查 `src/core/tools/variable.ts`、`src/mcp/tools/variable.ts` 和实际运行的 `dist`,确认功能是未实现、未构建还是未加载。 +4. `mijia_set_variable` 返回变量不存在时,改用 `mijia_create_variable`,不要尝试用规则节点自动创建。 +5. `mijia_call_api` 拒绝 `createVar` 等写方法时,改用专用工具,不要放宽通用工具的只读白名单。 +6. 若怀疑功能曾存在但被回归删除,检查 Git 历史或会话中的真实工具调用记录,再下结论。 + +实机验证过的生命周期:创建临时变量 -> 读取配置和值 -> 修改 -> 回读 -> 删除 -> 再次读取确认不存在。创建或恢复变量工具后应完整执行一次该流程,并清理临时变量。 + +发现网关尚未封装的新能力时,读取 [网关能力发现方法](references/gateway-capability-discovery.md)。不要靠猜测 API 名称,也不要因为当前 MCP 没有工具就判定网关不支持。 + ## 规则结构 ```json diff --git a/.agents/skills/mijia-automation/references/gateway-capability-discovery.md b/.agents/skills/mijia-automation/references/gateway-capability-discovery.md new file mode 100644 index 0000000..cf747da --- /dev/null +++ b/.agents/skills/mijia-automation/references/gateway-capability-discovery.md @@ -0,0 +1,53 @@ +# 网关能力发现方法 + +这套流程用于发现网关已经支持、但当前 Core/MCP 尚未封装的能力。目标是形成可复核的证据链,而不是试猜接口名。 + +## 证据优先级 + +1. **米家前端参考代码**:搜索仓库 `ref/` 中前端 bundle 实际调用的方法名、参数和响应处理。 +2. **项目现有封装**:搜索 `src/core/` 和 `src/mcp/`,确认该方法是已封装、部分封装还是完全缺失。 +3. **真实规则与设备能力**:读取现有规则和 `mijia_get_device`,从网关已保存对象反推节点结构、URN、字段和值域。 +4. **安全的原始 API 验证**:只读方法可通过 `mijia_call_api` 验证。写入、删除和未知方法不能通过放宽白名单试探。 +5. **专用工具的最小可逆实验**:确认写 API 后先实现类型化 Core 包装和专用 MCP 工具,再用临时对象验证完整生命周期并清理。 + +`getApiList` 在部分网关固件上可能报内部错误,不能把它当成唯一的能力清单。猜测的 API 返回“方法不存在”也只能否定该名称,不能否定整类能力。 + +## 从证据到实现 + +1. 在 `ref/` 搜索功能关键词及候选方法名,例如 `createVar`、`deleteVar`、`getVarConfig`、`getVarValue`。 +2. 搜索所有 `gateway.callApi(...)` 和 `server.registerTool(...)`,列出现有 API 与 MCP 工具。 +3. 对两份清单做差集,确认缺口,不要重复实现已有功能。 +4. 从前端调用点提取真实参数结构,不凭方法名猜参数。 +5. 在 `src/core/tools/` 添加最小包装,遵循 `{ success, data/error }` 返回约定。 +6. 在 `src/mcp/tools/` 注册专用工具并做输入校验。高风险写操作不要加入通用 `mijia_call_api` 白名单。 +7. 导出新函数、运行类型检查和 MCP 构建,然后重启 MCP。工具列表在进程启动时固定,旧进程看不到新注册工具。 +8. 用临时对象执行创建、读取配置、读取值、修改、回读、删除、确认不存在的完整测试。 +9. 把实测约束写入 Schema、测试和 skill,避免后续模型重复试错。 + +## 变量能力的发现实例 + +历史实现遵循了上述路径: + +1. 在米家前端参考 JS 中找到 `createVar`、`deleteVar`、`getVarConfig`、`getVarValue`、`setVarConfig` 和 `setVarValue`。 +2. 对比代码发现 MCP 当时只封装了变量列表和 `setVarValue`,因此确认是封装缺口,而不是网关缺少变量能力。 +3. 新增 Core 包装和 `mijia_create_variable`、`mijia_delete_variable`、`mijia_get_variable_config`、`mijia_get_variable_value`。 +4. 编译并重启 MCP 后,第一次使用带下划线的临时 ID 返回 `Invalid id format`。 +5. 改用纯字母数字 ID 后创建成功,随后删除成功,由此确认变量 ID 必须匹配 `^[a-zA-Z0-9]+$`。 +6. 后续完整生命周期测试再次确认创建、读取、修改和删除均可用。 + +这里最重要的结论不是某个变量 API,而是诊断边界: + +- 当前工具列表只说明当前 MCP 进程暴露了什么。 +- `mijia_call_api` 白名单只说明通用维护入口允许什么。 +- 规则节点失败只说明节点运行语义,不代表底层配置 API 不存在。 +- 网关能力需要由前端调用证据、源码实现和真实网关实验共同确认。 + +## 回归排查 + +如果某项能力以前成功、现在工具消失: + +1. 查 Git 历史和当前分支,确认代码是否在整理其他 PR 时被误删。 +2. 对比 `src` 与实际运行的 `dist`,确认是否只缺构建。 +3. 重启 MCP,排除旧进程仍在使用旧工具清单。 +4. 若普通日志不足以证明历史行为,可查询 OpenCode SQLite 会话中的原始 tool part;成功调用的输入和输出比文字总结更可靠。 +5. 恢复后补自动测试和 skill 说明,不只恢复代码。 From 0543a149205c532c324d5c6ff415cb17aac7c9a4 Mon Sep 17 00:00:00 2001 From: doomsday616 Date: Wed, 22 Jul 2026 02:46:55 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E5=AF=B9=E9=BD=90=E6=9E=81=E5=AE=A2?= =?UTF-8?q?=E7=89=88=E5=8F=98=E9=87=8F=E4=B8=8E=E8=B5=8B=E5=80=BC=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/mijia-automation/SKILL.md | 5 ++-- .../references/mijia-complete-reference.md | 7 ++--- src/core/tools/base.test.ts | 28 +++++++++++++++++++ src/core/tools/base.ts | 6 +++- src/core/tools/variable.test.ts | 2 +- src/core/tools/variable.ts | 2 +- 6 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 src/core/tools/base.test.ts diff --git a/.agents/skills/mijia-automation/SKILL.md b/.agents/skills/mijia-automation/SKILL.md index 723eb73..2bbe53e 100644 --- a/.agents/skills/mijia-automation/SKILL.md +++ b/.agents/skills/mijia-automation/SKILL.md @@ -24,6 +24,7 @@ metadata: - `varSetNumber`、`varSetString`、`deviceInputSetVar` 和 `deviceGetSetVar` 只写已存在变量,不能用作变量创建器。 - 变量 ID 必须匹配 `^[a-zA-Z0-9]+$`,不能含下划线、连字符或中文;显示名称 `name` 可以包含中文。 - `type` 只能是 `number` 或 `string`,初始值和后续值必须与类型一致。 +- `createVar` 的显示名称必须放在 `userData: { name }`,不能传顶层 `name`。缺少 `userData.name` 时变量虽可按 ID 读取,但极客版 UI 的变量选择器不会显示它。 - 删除变量前检查所有规则引用;删除是不可恢复操作。 ### 工具缺失或写入失败时 @@ -266,9 +267,9 @@ metadata: ### deviceGetSetVar - 查询设备赋值 ```json -{"id":"$ID","type":"deviceGetSetVar","cfg":{"urn":"$URN","name":"deviceGetSetVar","version":1},"props":{"did":"$DID","siid":$SIID,"piid":$PIID,"dtype":"number","scope":"global","id":"$VAR_ID"},"inputs":{"input":null},"outputs":{"output":["$NEXT1.trigger"],"output2":["$NEXT2.trigger"]}} +{"id":"$ID","type":"deviceGetSetVar","cfg":{"urn":"$URN","name":"deviceGetSetVar","version":1},"props":{"did":"$DID","siid":$SIID,"piid":$PIID,"dtype":"number","scope":"global","id":"$VAR_ID"},"inputs":{"input":null},"outputs":{"output":["$NEXT.trigger"]}} ``` -⚠️ 同 deviceGet,`inputs` 用 `input`,outputs 必须有 `output` 和 `output2`。 +⚠️ `inputs` 用 `input`。极客版 UI 实测只生成 `outputs.output`,没有 `output2`;不要套用 `deviceGet` 的双输出结构。 ### varChange - 变量值更新时触发(state 节点) ```json diff --git a/.agents/skills/mijia-automation/references/mijia-complete-reference.md b/.agents/skills/mijia-automation/references/mijia-complete-reference.md index e79b41d..a021ce3 100644 --- a/.agents/skills/mijia-automation/references/mijia-complete-reference.md +++ b/.agents/skills/mijia-automation/references/mijia-complete-reference.md @@ -1173,13 +1173,12 @@ deviceInput → condition1 "id": "var_brightness" }, "inputs": {"input": null}, - "outputs": { - "output": ["nextNode.trigger"], - "output2": ["fallbackNode.trigger"] - } + "outputs": {"output": ["nextNode.trigger"]} } ``` +极客版 UI 实测该卡片只有一个 `output`,查询成功并完成赋值后触发;它没有 `output2`,不要套用 `deviceGet` 的双分支结构。 + --- ### varChange — 变量值更新时触发 diff --git a/src/core/tools/base.test.ts b/src/core/tools/base.test.ts new file mode 100644 index 0000000..4e43d20 --- /dev/null +++ b/src/core/tools/base.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import type { Graph } from '../types/graph'; +import { validateGraph } from './base'; + +const graph: Graph = { + id: 'test', + nodes: [{ + id: 'queryvar', + type: 'deviceGetSetVar', + cfg: { name: 'deviceGetSetVar', version: 1 }, + props: { did: 'device', siid: 2, piid: 1, dtype: 'number', id: 'value1', scope: 'global' }, + inputs: { input: null }, + outputs: { output: [] }, + }], + cfg: { + id: 'test', + enable: true, + uiType: 'graph', + userData: { + name: 'test', + lastUpdateTime: 0, + transform: { x: 0, y: 0, scale: 1, rotate: 0 }, + }, + }, +}; + +assert.deepEqual(validateGraph(graph), []); +console.log('graph structure tests passed'); diff --git a/src/core/tools/base.ts b/src/core/tools/base.ts index 8671cc9..f05ec1a 100644 --- a/src/core/tools/base.ts +++ b/src/core/tools/base.ts @@ -9,7 +9,7 @@ const STATE_NODE_TYPES = new Set([ 'timeRange', 'alarmClock', 'onLoad', 'deviceInputSetVar', 'varChange', ]); -const DUAL_OUTPUT_TYPES = new Set(['deviceGet', 'varGet', 'deviceGetSetVar']); +const DUAL_OUTPUT_TYPES = new Set(['deviceGet', 'varGet']); const STATE_CONDITION_INPUT_TYPES = new Set(['condition', 'logicOr', 'logicAnd', 'logicNot']); @@ -149,6 +149,10 @@ export function validateGraph(graph: Graph): ValidationError[] { if (!('output2' in o)) errors.push({ nodeId: node.id, type: 'missing_output2', level: 'error', message: `${node.type} 必须声明 outputs.output2` }); } + if (node.type === 'deviceGetSetVar' && !('output' in (node.outputs || {}))) { + errors.push({ nodeId: node.id, type: 'missing_output', level: 'error', message: 'deviceGetSetVar 必须声明 outputs.output' }); + } + if (node.type === 'delay' && !('input' in (node.inputs || {}))) { errors.push({ nodeId: node.id, type: 'delay_wrong_input', level: 'error', message: 'delay inputs 必须用 "input",不是 "trigger"' }); } diff --git a/src/core/tools/variable.test.ts b/src/core/tools/variable.test.ts index dc50f63..e008792 100644 --- a/src/core/tools/variable.test.ts +++ b/src/core/tools/variable.test.ts @@ -24,7 +24,7 @@ async function main(): Promise { data: { id: 'probe1', scope: 'global', config: { type: 'number' } }, }); assert.deepEqual(calls, [ - { method: 'createVar', params: { scope: 'global', id: 'probe1', type: 'number', value: 0, name: 'Probe' }, timeout: 10000 }, + { method: 'createVar', params: { scope: 'global', id: 'probe1', type: 'number', value: 0, userData: { name: 'Probe' } }, timeout: 10000 }, { method: 'deleteVar', params: { scope: 'global', id: 'probe1' }, timeout: 10000 }, { method: 'getVarValue', params: { scope: 'global', id: 'probe1' }, timeout: 10000 }, { method: 'getVarConfig', params: { scope: 'global', id: 'probe1' }, timeout: 10000 }, diff --git a/src/core/tools/variable.ts b/src/core/tools/variable.ts index 7f32a07..64a362b 100644 --- a/src/core/tools/variable.ts +++ b/src/core/tools/variable.ts @@ -37,7 +37,7 @@ export async function createVariable( scope: string = 'global' ): Promise { try { - await gateway.callApi('createVar', { scope, id, type, value, name: name ?? id }, 10000); + await gateway.callApi('createVar', { scope, id, type, value, userData: { name: name ?? id } }, 10000); return { success: true, message: `变量 ${id} 创建成功` }; } catch (error) { return { success: false, error: `创建变量失败: ${error}` }; From cc9d7c81a4cc7bc4d2d23057e84e730af408ed35 Mon Sep 17 00:00:00 2001 From: doomsday616 Date: Wed, 22 Jul 2026 03:20:55 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=B9=B6=E6=8E=A5=E5=85=A5=E6=A0=87=E5=87=86?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/tools/base.test.ts | 28 ---------- src/core/tools/variable.ts | 14 +++-- src/core/types/index.ts | 2 + src/mcp/tools/variable.test.ts | 9 ---- src/mcp/tools/variable.ts | 2 +- tests/core/base.test.ts | 10 ++++ tests/core/device.test.ts | 9 ++-- .../tools => tests/core}/variable.test.ts | 54 +++++++++++++------ tests/mcp/variable.test.ts | 11 ++++ 9 files changed, 76 insertions(+), 63 deletions(-) delete mode 100644 src/core/tools/base.test.ts delete mode 100644 src/mcp/tools/variable.test.ts rename {src/core/tools => tests/core}/variable.test.ts (53%) create mode 100644 tests/mcp/variable.test.ts diff --git a/src/core/tools/base.test.ts b/src/core/tools/base.test.ts deleted file mode 100644 index 4e43d20..0000000 --- a/src/core/tools/base.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import assert from 'node:assert/strict'; -import type { Graph } from '../types/graph'; -import { validateGraph } from './base'; - -const graph: Graph = { - id: 'test', - nodes: [{ - id: 'queryvar', - type: 'deviceGetSetVar', - cfg: { name: 'deviceGetSetVar', version: 1 }, - props: { did: 'device', siid: 2, piid: 1, dtype: 'number', id: 'value1', scope: 'global' }, - inputs: { input: null }, - outputs: { output: [] }, - }], - cfg: { - id: 'test', - enable: true, - uiType: 'graph', - userData: { - name: 'test', - lastUpdateTime: 0, - transform: { x: 0, y: 0, scale: 1, rotate: 0 }, - }, - }, -}; - -assert.deepEqual(validateGraph(graph), []); -console.log('graph structure tests passed'); diff --git a/src/core/tools/variable.ts b/src/core/tools/variable.ts index 64a362b..6d0e6c9 100644 --- a/src/core/tools/variable.ts +++ b/src/core/tools/variable.ts @@ -8,8 +8,11 @@ import type { ToolResponse } from '../types'; export async function getVariables(gateway: GatewayClient, scope: string = 'global'): Promise> { try { - const variables = await gateway.callApi('getVarList', { scope }, 10000); - return { success: true, data: Array.isArray(variables) ? variables : [] }; + const variables = await gateway.callApi>('getVarList', { scope }, 10000); + const data = Array.isArray(variables) + ? variables + : Object.entries(variables || {}).map(([id, variable]) => ({ ...variable, id, scope })); + return { success: true, data }; } catch (error) { return { success: false, error: `获取变量列表失败: ${error}` }; } @@ -37,7 +40,8 @@ export async function createVariable( scope: string = 'global' ): Promise { try { - await gateway.callApi('createVar', { scope, id, type, value, userData: { name: name ?? id } }, 10000); + const displayName = name?.trim() || id; + await gateway.callApi('createVar', { scope, id, type, value, userData: { name: displayName } }, 10000); return { success: true, message: `变量 ${id} 创建成功` }; } catch (error) { return { success: false, error: `创建变量失败: ${error}` }; @@ -55,8 +59,8 @@ export async function deleteVariable(gateway: GatewayClient, id: string, scope: export async function getVariableValue(gateway: GatewayClient, id: string, scope: string = 'global'): Promise { try { - const value = await gateway.callApi('getVarValue', { scope, id }, 10000); - return { success: true, data: { id, scope, value } }; + const result = await gateway.callApi<{ value: string | number }>('getVarValue', { scope, id }, 10000); + return { success: true, data: { id, scope, value: result.value } }; } catch (error) { return { success: false, error: `获取变量值失败: ${error}` }; } diff --git a/src/core/types/index.ts b/src/core/types/index.ts index 6f0288c..22b0821 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -20,6 +20,8 @@ export type ToolResponse = ToolResult | ToolError; /** 变量 */ export interface Variable { + id?: string; + scope?: string; type: 'number' | 'string'; value: number | string; userData: { diff --git a/src/mcp/tools/variable.test.ts b/src/mcp/tools/variable.test.ts deleted file mode 100644 index d235188..0000000 --- a/src/mcp/tools/variable.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import assert from 'node:assert/strict'; -import { CreateVariableInputSchema } from './variable'; - -assert.equal(CreateVariableInputSchema.safeParse({ id: 'valid123', type: 'number', value: 0 }).success, true); -assert.equal(CreateVariableInputSchema.safeParse({ id: 'valid123', type: 'string', value: 'ready' }).success, true); -assert.equal(CreateVariableInputSchema.safeParse({ id: 'invalid_id', type: 'number', value: 0 }).success, false); -assert.equal(CreateVariableInputSchema.safeParse({ id: 'valid123', type: 'number', value: '0' }).success, false); - -console.log('variable MCP schema tests passed'); diff --git a/src/mcp/tools/variable.ts b/src/mcp/tools/variable.ts index 4553ec3..6114ed5 100644 --- a/src/mcp/tools/variable.ts +++ b/src/mcp/tools/variable.ts @@ -17,7 +17,7 @@ export const CreateVariableInputSchema = z.object({ id: z.string().regex(/^[a-zA-Z0-9]+$/, "id 必须是纯字母数字,不能含下划线/连字符").describe("变量ID,纯字母数字"), type: z.enum(["number", "string"]).describe("变量类型"), value: z.union([z.string(), z.number()]).describe("初始值"), - name: z.string().optional().describe("显示名称,可选,默认与 id 相同"), + name: z.string().trim().min(1, "name 不能为空").optional().describe("显示名称,可选,默认与 id 相同"), scope: z.string().default("global").describe("变量作用域,默认 global"), }).refine( ({ type, value }) => typeof value === type, diff --git a/tests/core/base.test.ts b/tests/core/base.test.ts index 4048ad6..d54d8ac 100644 --- a/tests/core/base.test.ts +++ b/tests/core/base.test.ts @@ -101,3 +101,13 @@ test('output2 连接 state 节点仍然报错,连接 event 输入不报该错 assert.equal(validateGraph(invalid).some((error) => error.type === 'output2_to_state' && error.level === 'error'), true); assert.equal(validateGraph(valid).some((error) => error.type === 'output2_to_state'), false); }); + +test('deviceGetSetVar 接受极客版 UI 生成的单 output 结构', () => { + const value = graph([ + node('queryvar', 'deviceGetSetVar', { input: null }, { output: [] }, { + did: 'device', siid: 2, piid: 1, dtype: 'number', id: 'value1', scope: 'global', + }), + ]); + + assert.deepEqual(validateGraph(value), []); +}); diff --git a/tests/core/device.test.ts b/tests/core/device.test.ts index 9f36580..d6c18f6 100644 --- a/tests/core/device.test.ts +++ b/tests/core/device.test.ts @@ -41,18 +41,19 @@ test('解析同一 service 的事件参数并为缺失属性提供 fallback', as const event = result.data?.[0].triggers?.find((trigger) => trigger.type === 'event'); assert.deepEqual(event?.arguments?.[0], { + siid: 3, piid: 1, - desc: 'button-id', + desc: 'remote-control-button-id', dtype: 'uint8', - range: undefined, + access: [], list: [{ value: 1, description: 'left' }, { value: 2, description: 'right' }], }); assert.deepEqual(event?.arguments?.[1], { + siid: 3, piid: 99, desc: 'Property 99', dtype: 'unknown', - range: undefined, - list: undefined, + access: [], }); } finally { globalThis.fetch = originalFetch; diff --git a/src/core/tools/variable.test.ts b/tests/core/variable.test.ts similarity index 53% rename from src/core/tools/variable.test.ts rename to tests/core/variable.test.ts index e008792..bd29788 100644 --- a/src/core/tools/variable.test.ts +++ b/tests/core/variable.test.ts @@ -1,45 +1,67 @@ import assert from 'node:assert/strict'; -import type { GatewayClient } from '../gateway/client'; -import { createVariable, deleteVariable, getVariableConfig, getVariableValue } from './variable'; +import test from 'node:test'; +import type { GatewayClient } from '../../src/core/gateway/client'; +import { createVariable, deleteVariable, getVariableConfig, getVariables, getVariableValue } from '../../src/core/tools/variable'; -async function main(): Promise { +test('变量生命周期 API 使用网关真实参数和响应结构', async () => { const calls: Array<{ method: string; params: unknown; timeout: number }> = []; const gateway = { async callApi(method: string, params: unknown, timeout: number): Promise { calls.push({ method, params, timeout }); + if (method === 'getVarList') return { + probe1: { type: 'number', value: 7, userData: { name: 'Probe' } }, + }; if (method === 'getVarValue') return { value: 7 }; - if (method === 'getVarConfig') return { type: 'number' }; + if (method === 'getVarConfig') return { type: 'number', userData: { name: 'Probe' } }; return undefined; }, } as unknown as GatewayClient; - assert.equal((await createVariable(gateway, 'probe1', 'number', 0, 'Probe')).success, true); - assert.equal((await deleteVariable(gateway, 'probe1')).success, true); + assert.equal((await createVariable(gateway, 'probe1', 'number', 0, ' Probe ')).success, true); + assert.deepEqual(await getVariables(gateway), { + success: true, + data: [{ id: 'probe1', scope: 'global', type: 'number', value: 7, userData: { name: 'Probe' } }], + }); assert.deepEqual(await getVariableValue(gateway, 'probe1'), { success: true, - data: { id: 'probe1', scope: 'global', value: { value: 7 } }, + data: { id: 'probe1', scope: 'global', value: 7 }, }); assert.deepEqual(await getVariableConfig(gateway, 'probe1'), { success: true, - data: { id: 'probe1', scope: 'global', config: { type: 'number' } }, + data: { id: 'probe1', scope: 'global', config: { type: 'number', userData: { name: 'Probe' } } }, }); + assert.equal((await deleteVariable(gateway, 'probe1')).success, true); assert.deepEqual(calls, [ { method: 'createVar', params: { scope: 'global', id: 'probe1', type: 'number', value: 0, userData: { name: 'Probe' } }, timeout: 10000 }, - { method: 'deleteVar', params: { scope: 'global', id: 'probe1' }, timeout: 10000 }, + { method: 'getVarList', params: { scope: 'global' }, timeout: 10000 }, { method: 'getVarValue', params: { scope: 'global', id: 'probe1' }, timeout: 10000 }, { method: 'getVarConfig', params: { scope: 'global', id: 'probe1' }, timeout: 10000 }, + { method: 'deleteVar', params: { scope: 'global', id: 'probe1' }, timeout: 10000 }, ]); +}); + +test('空白显示名称在 Core 层回退到变量 ID', async () => { + let params: unknown; + const gateway = { + async callApi(_method: string, input: unknown): Promise { + params = input; + }, + } as unknown as GatewayClient; + + await createVariable(gateway, 'probe2', 'string', '', ' '); + assert.deepEqual(params, { + scope: 'global', id: 'probe2', type: 'string', value: '', userData: { name: 'probe2' }, + }); +}); - const failingGateway = { +test('网关异常转为失败响应', async () => { + const gateway = { async callApi(): Promise { throw new Error('gateway failed'); }, } as unknown as GatewayClient; - const failure = await createVariable(failingGateway, 'probe2', 'number', 0); + + const failure = await createVariable(gateway, 'probe3', 'number', 0); assert.equal(failure.success, false); assert.match('error' in failure ? failure.error : '', /gateway failed/); - - console.log('variable lifecycle tests passed'); -} - -void main(); +}); diff --git a/tests/mcp/variable.test.ts b/tests/mcp/variable.test.ts new file mode 100644 index 0000000..0db1476 --- /dev/null +++ b/tests/mcp/variable.test.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { CreateVariableInputSchema } from '../../src/mcp/tools/variable'; + +test('创建变量 Schema 校验 ID、类型和值', () => { + assert.equal(CreateVariableInputSchema.safeParse({ id: 'valid123', type: 'number', value: 0 }).success, true); + assert.equal(CreateVariableInputSchema.safeParse({ id: 'valid123', type: 'string', value: 'ready' }).success, true); + assert.equal(CreateVariableInputSchema.safeParse({ id: 'invalid_id', type: 'number', value: 0 }).success, false); + assert.equal(CreateVariableInputSchema.safeParse({ id: 'valid123', type: 'number', value: '0' }).success, false); + assert.equal(CreateVariableInputSchema.safeParse({ id: 'valid123', type: 'number', value: 0, name: ' ' }).success, false); +});