dsh-miloco-bridge 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/cordis.patch.yml +79 -2
- package/docs/ARCHITECTURE.md +11 -4
- package/docs/INSTALL.md +21 -10
- package/docs/TOOLS.md +1 -1
- package/docs/VERIFICATION.md +2 -2
- package/lib/client.js +27 -6
- package/lib/deploy.js +236 -38
- package/lib/github-release.js +162 -0
- package/lib/index.js +11 -1
- package/lib/settings-api.js +85 -1
- package/lib/tools.js +62 -4
- package/package.json +1 -1
- package/skills/miloco-devices/SKILL.md +3 -2
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ dsh plugin --profile web add github:exa160/dsh-miloco-bridge
|
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
50
|
# 发布包
|
|
51
|
-
dsh plugin --profile web add file:dist/dsh-miloco-bridge-0.1.
|
|
51
|
+
dsh plugin --profile web add file:dist/dsh-miloco-bridge-0.1.2.tgz
|
|
52
52
|
|
|
53
53
|
# 本地开发(link)
|
|
54
54
|
./install.sh --profile web
|
|
@@ -102,7 +102,7 @@ systemctl --user restart dsh-web.service
|
|
|
102
102
|
## 开发
|
|
103
103
|
|
|
104
104
|
```bash
|
|
105
|
-
npm test #
|
|
105
|
+
npm test # 76 项测试
|
|
106
106
|
npm run selftest # 离线自检
|
|
107
107
|
npm run verify:readonly # 真实 Miloco 只读接口
|
|
108
108
|
npm run pack:bundle # 生成 dist/*.tgz
|
package/cordis.patch.yml
CHANGED
|
@@ -53,10 +53,22 @@
|
|
|
53
53
|
minLevel: "all"
|
|
54
54
|
deploy:
|
|
55
55
|
repo: "https://github.com/XiaoMi/xiaomi-miloco"
|
|
56
|
+
# 一键部署下载官方 release install.sh,不再 clone 源码。
|
|
56
57
|
# 留空 = $DSH_HOME/plugin-data/dsh-miloco-bridge/xiaomi-miloco
|
|
57
|
-
#
|
|
58
|
+
# (release 部署不创建该目录;仅保留给旧源码部署/卸载清理)
|
|
58
59
|
sourceDir: ""
|
|
59
|
-
|
|
60
|
+
# --skip-openclaw 阻止上游安装 OpenClaw 插件;--agent-platform=openclaw
|
|
61
|
+
# 只用于通过上游非交互式平台检测,实际由 DSH adapter 接管。
|
|
62
|
+
installArgs: ["--skip-openclaw", "--agent-platform=openclaw", "--lang=zh"]
|
|
63
|
+
# 直连 GitHub 失败时按顺序尝试这些镜像(直接拼接原 URL)。
|
|
64
|
+
githubMirrors:
|
|
65
|
+
- "https://gh-proxy.com/"
|
|
66
|
+
- "https://gh-proxy.org/"
|
|
67
|
+
# release 包默认从第一个镜像下载;留空 = 自动拼接 releases/download。
|
|
68
|
+
downloadSite: ""
|
|
69
|
+
# 额外的 uv 索引,不覆盖用户默认索引;PyPI 带 upload-time 元数据,
|
|
70
|
+
# 部分国内镜像缺少该元数据会触发安装器 exclude-newer 解析失败。
|
|
71
|
+
uvIndex: "https://pypi.org/simple"
|
|
60
72
|
# 设置页里不展示任何「DSH 默认模型」配置项。模型下拉只有
|
|
61
73
|
# 「跟随该对话」+ 实际模型;不选模型就完全不动会话的模型选择。
|
|
62
74
|
# ------------------------------------------------------------------
|
|
@@ -83,3 +95,68 @@
|
|
|
83
95
|
overlayFile: ""
|
|
84
96
|
stateFile: ""
|
|
85
97
|
cwd: ""
|
|
98
|
+
# 保留标准 DSH Agent 的工具面;子插件只在 miloco 预设内生效。
|
|
99
|
+
- id: tool-bash
|
|
100
|
+
name: '@deepseek-ai/dsh-tool-bash'
|
|
101
|
+
disabled: !!js process.platform === 'win32'
|
|
102
|
+
- id: tool-pwsh
|
|
103
|
+
name: '@deepseek-ai/dsh-tool-pwsh'
|
|
104
|
+
disabled: !!js process.platform !== 'win32'
|
|
105
|
+
- id: tool-fs
|
|
106
|
+
name: '@deepseek-ai/dsh-tool-fs'
|
|
107
|
+
- id: tool-fs-search
|
|
108
|
+
name: '@deepseek-ai/dsh-tool-fs-search'
|
|
109
|
+
config:
|
|
110
|
+
sampleOverCapGlobResults: false
|
|
111
|
+
- id: tool-jobs
|
|
112
|
+
name: '@deepseek-ai/dsh-tool-jobs'
|
|
113
|
+
- id: tool-schedule
|
|
114
|
+
name: '@deepseek-ai/dsh-tool-schedule'
|
|
115
|
+
- id: skill-filesystem
|
|
116
|
+
name: '@deepseek-ai/dsh-skill-filesystem'
|
|
117
|
+
- id: tool-skill
|
|
118
|
+
name: '@deepseek-ai/dsh-tool-skill'
|
|
119
|
+
- id: command-goal
|
|
120
|
+
name: '@deepseek-ai/dsh-command-goal'
|
|
121
|
+
- id: tool-goal
|
|
122
|
+
name: '@deepseek-ai/dsh-tool-goal'
|
|
123
|
+
- id: tool-ask-user
|
|
124
|
+
name: '@deepseek-ai/dsh-tool-ask-user'
|
|
125
|
+
- id: tool-todo
|
|
126
|
+
name: '@deepseek-ai/dsh-tool-todo'
|
|
127
|
+
config:
|
|
128
|
+
allowParallelInProgress: true
|
|
129
|
+
- id: tool-web
|
|
130
|
+
name: '@deepseek-ai/dsh-tool-web'
|
|
131
|
+
config:
|
|
132
|
+
fetch: true
|
|
133
|
+
searchTimeoutMs: 60000
|
|
134
|
+
- id: present
|
|
135
|
+
name: '@deepseek-ai/dsh-tool-present'
|
|
136
|
+
- id: delegation
|
|
137
|
+
name: cordis:group
|
|
138
|
+
group: true
|
|
139
|
+
isolate:
|
|
140
|
+
workflowEngine: true
|
|
141
|
+
config:
|
|
142
|
+
- id: tool-subagent-control
|
|
143
|
+
name: '@deepseek-ai/dsh-tool-subagent-control'
|
|
144
|
+
- id: tool-subagent-list-agents
|
|
145
|
+
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
|
146
|
+
- id: tool-subagent
|
|
147
|
+
name: '@deepseek-ai/dsh-tool-subagent'
|
|
148
|
+
config:
|
|
149
|
+
provider: spawn
|
|
150
|
+
toolName: subagent
|
|
151
|
+
modelSelectionSettings: true
|
|
152
|
+
- id: tool-subagent-fork
|
|
153
|
+
name: '@deepseek-ai/dsh-tool-subagent'
|
|
154
|
+
config:
|
|
155
|
+
provider: fork
|
|
156
|
+
toolName: subagent_fork
|
|
157
|
+
- id: workflow-ptc
|
|
158
|
+
name: '@deepseek-ai/dsh-workflow-ptc'
|
|
159
|
+
config:
|
|
160
|
+
provider: spawn
|
|
161
|
+
- id: tool-workflow
|
|
162
|
+
name: '@deepseek-ai/dsh-tool-workflow'
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -36,7 +36,7 @@ lib/
|
|
|
36
36
|
├── miloco-client.js Miloco HTTP 客户端(连接优先级、envelope 解包)
|
|
37
37
|
├── push.js 入站推送:鉴权、目标解析、会话恢复、等待 turn
|
|
38
38
|
├── settings-api.js 设置 API 路由
|
|
39
|
-
├── deploy.js 一键部署(启动已有 /
|
|
39
|
+
├── deploy.js 一键部署(启动已有 / 下载 release 安装器 / 写 adapter)
|
|
40
40
|
├── uninstall.js 卸载 Miloco(停服务 / 移除 adapter / 可选删数据或源码)
|
|
41
41
|
├── perception.js 感知事件优先级解析与阈值过滤
|
|
42
42
|
├── client.js 浏览器设置页(无构建步骤)
|
|
@@ -194,13 +194,20 @@ push.perception.minLevel = all | low | medium | high
|
|
|
194
194
|
|
|
195
195
|
1. 健康检查 `127.0.0.1:1810`;
|
|
196
196
|
2. 已装 Miloco → `miloco-cli service start`;
|
|
197
|
-
3. 未装 →
|
|
198
|
-
4.
|
|
197
|
+
3. 未装 → 下载官方 release `install.sh`(直连失败时走 GitHub 镜像);
|
|
198
|
+
4. 以 headless agent 模式运行 `install.sh --agent-prepare` + `--agent-finish`
|
|
199
|
+
(`--skip-openclaw --agent-platform=openclaw --lang=zh`);
|
|
199
200
|
5. 启动服务并等待健康;
|
|
200
201
|
6. 安装 adapter,写令牌;
|
|
201
202
|
7. 写 overlay 的连接与推送令牌。
|
|
202
203
|
|
|
203
|
-
所有子进程用 `spawn(command, args)`,不做 shell
|
|
204
|
+
所有子进程用 `spawn(command, args)`,不做 shell 字符串拼接。
|
|
205
|
+
`--agent-platform=openclaw` 只用于通过上游平台的非交互检测,
|
|
206
|
+
`--skip-openclaw` 保证不安装 OpenClaw 插件,实际由 DSH adapter 接管。
|
|
207
|
+
|
|
208
|
+
镜像模式的一键升级复用同一 release 安装器:下载 → `--agent-prepare` →
|
|
209
|
+
`--agent-finish` → 启动服务 → 写 adapter/webhook。升级状态与日志独立于部署,
|
|
210
|
+
日志在 `$MILOCO_HOME/log/dsh-miloco-upgrade.log`;命令参数和子进程输出都会脱敏。
|
|
204
211
|
|
|
205
212
|
## 9. 安全边界
|
|
206
213
|
|
package/docs/INSTALL.md
CHANGED
|
@@ -33,14 +33,14 @@ npm 新包可能处于审核期;若 `dsh plugin add dsh-miloco-bridge` 暂时
|
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
35
|
npm run pack:bundle
|
|
36
|
-
# dist/dsh-miloco-bridge-0.1.
|
|
37
|
-
dsh plugin --profile web add "file:/path/to/dist/dsh-miloco-bridge-0.1.
|
|
36
|
+
# dist/dsh-miloco-bridge-0.1.2.tgz
|
|
37
|
+
dsh plugin --profile web add "file:/path/to/dist/dsh-miloco-bridge-0.1.2.tgz"
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
或:
|
|
41
41
|
|
|
42
42
|
```bash
|
|
43
|
-
./install.sh --profile web --bundle dist/dsh-miloco-bridge-0.1.
|
|
43
|
+
./install.sh --profile web --bundle dist/dsh-miloco-bridge-0.1.2.tgz
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
### 1.3 手动配置
|
|
@@ -94,16 +94,17 @@ install.sh
|
|
|
94
94
|
3. 点「一键部署 Miloco」;
|
|
95
95
|
4. 面板会实时显示部署日志。流程:
|
|
96
96
|
- 若检测到已安装的 `miloco-cli` 与 `$MILOCO_HOME/config.json`,先尝试 `service start`;
|
|
97
|
-
-
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
- 否则下载官方 release `install.sh`(GitHub 直连失败时走 `gh-proxy.com` / `gh-proxy.org`);
|
|
98
|
+
- 以 headless agent 模式运行 `install.sh --agent-prepare --skip-openclaw --agent-platform=openclaw --lang=zh`
|
|
99
|
+
和 `install.sh --agent-finish ...`(`openclaw` 只用于通过上游平台检测,不会安装 OpenClaw 插件);
|
|
100
|
+
- 安装器使用 `UV_CACHE_DIR=$MILOCO_HOME/.uv-cache`、额外 `UV_INDEX`(默认 PyPI)
|
|
101
|
+
和镜像下载 release 包(`MILOCO_DOWNLOAD_URL`,默认第一个 GitHub 镜像);
|
|
100
102
|
- 启动后端并等待 `127.0.0.1:1810` 健康;
|
|
101
103
|
- 安装本插件的 adapter,生成/复用推送令牌,写入 overlay。
|
|
102
104
|
5. 部署完成后按面板提示绑定米家账号、开启摄像头感知。
|
|
103
105
|
|
|
104
|
-
>
|
|
105
|
-
>
|
|
106
|
-
> 或其它 DSH 进程用户不可写的路径;部署前会显式检查并给出中文错误。
|
|
106
|
+
> **MILOCO_HOME 必须可写**:默认 `~/.miloco`,部署前会显式检查并给出中文错误。
|
|
107
|
+
> release 部署不再创建源码目录;`deploy.sourceDir` 仅用于旧源码部署或卸载清理。
|
|
107
108
|
|
|
108
109
|
> 一键部署会运行上游安装器,可能安装 Python 依赖并占用较多磁盘。
|
|
109
110
|
> 请先确认你接受 Xiaomi Miloco 的许可证与资源开销。
|
|
@@ -272,10 +273,20 @@ systemctl --user restart dsh-web.service
|
|
|
272
273
|
|
|
273
274
|
```bash
|
|
274
275
|
npm run pack:bundle
|
|
275
|
-
dsh plugin --profile web add "file:dist/dsh-miloco-bridge-0.1.
|
|
276
|
+
dsh plugin --profile web add "file:dist/dsh-miloco-bridge-0.1.2.tgz"
|
|
276
277
|
systemctl --user restart dsh-web.service
|
|
277
278
|
```
|
|
278
279
|
|
|
280
|
+
### 8.3 Miloco 版本更新
|
|
281
|
+
|
|
282
|
+
「运行状态 → 检查更新」按顺序检查:Miloco 后端 → 直连 GitHub → GitHub 镜像。
|
|
283
|
+
|
|
284
|
+
- 后端能访问 GitHub:走 Miloco 原有一键升级;
|
|
285
|
+
- 后端不可达但镜像可用:由插件下载 release 安装器并执行 headless 升级
|
|
286
|
+
(`--agent-prepare` + `--agent-finish`,继续使用 `MILOCO_HOME` 和镜像下载);
|
|
287
|
+
- 源码/dev 部署不提供一键升级;
|
|
288
|
+
- 升级过程写入 `$MILOCO_HOME/log/dsh-miloco-upgrade.log`。
|
|
289
|
+
|
|
279
290
|
卸载插件:
|
|
280
291
|
|
|
281
292
|
```bash
|
package/docs/TOOLS.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|---|---|---|---|
|
|
13
13
|
| `miloco_status` | — | `/api/admin/status` + `/api/perception/engine/status` | 运行状态、账号、引擎、数据库 |
|
|
14
14
|
| `miloco_capabilities` | — | — | 当前模式与已注册工具 |
|
|
15
|
-
| `miloco_device_list` | `room
|
|
15
|
+
| `miloco_device_list` | `room?`, `name?`, `online_only?`, `limit?`, `offset?`, `include?[]` | `/api/miot/device_list` | 默认 `did/name/online/room_name/model/home_id`,默认最多 500 台;`include` 可取 `icon/local_ip/home_name/order_time/lan_online/sub_devices` |
|
|
16
16
|
| `miloco_device_spec` | `did` | `/api/miot/devices/{did}/spec` | SPEC:iid、access、枚举、范围 |
|
|
17
17
|
| `miloco_device_status` | `did`, `iid?` | `/api/miot/devices/{did}/status` | 当前属性值 |
|
|
18
18
|
| `miloco_device_history` | — | `/api/miot/device_history` | 最近访问的 iid |
|
package/docs/VERIFICATION.md
CHANGED
|
@@ -220,7 +220,7 @@ dsh-miloco-bridge: push delivery failed ... # 仅在失败时
|
|
|
220
220
|
|
|
221
221
|
```bash
|
|
222
222
|
npm run pack:bundle
|
|
223
|
-
tar -tzf dist/dsh-miloco-bridge-0.1.
|
|
223
|
+
tar -tzf dist/dsh-miloco-bridge-0.1.2.tgz | head -30
|
|
224
224
|
```
|
|
225
225
|
|
|
226
226
|
预期包含:`package/lib/*`、`package/skills/*/SKILL.md`、`package/adapter/dsh_adapter.py`、
|
|
@@ -230,7 +230,7 @@ tar -tzf dist/dsh-miloco-bridge-0.1.1.tgz | head -30
|
|
|
230
230
|
|
|
231
231
|
```bash
|
|
232
232
|
DSH_HOME=/tmp/dsh-miloco-bundle-verify \
|
|
233
|
-
dsh plugin --profile web add "file:$PWD/dist/dsh-miloco-bridge-0.1.
|
|
233
|
+
dsh plugin --profile web add "file:$PWD/dist/dsh-miloco-bridge-0.1.2.tgz"
|
|
234
234
|
```
|
|
235
235
|
|
|
236
236
|
---
|
package/lib/client.js
CHANGED
|
@@ -755,6 +755,8 @@ window.__ModuleLoader__.load({
|
|
|
755
755
|
h('div', { className: 'muted' },
|
|
756
756
|
backend.error || `连不上 ${connection.baseUrl || '(未配置地址)'}。`
|
|
757
757
|
+ '插件本身可以独立安装;部署或连接之后才会出现设备与感知能力。'),
|
|
758
|
+
h('div', { className: 'muted' },
|
|
759
|
+
'一键部署会下载官方 release 安装器(支持 GitHub 镜像),不会在本机编译源码。'),
|
|
758
760
|
h('div', { className: 'row' },
|
|
759
761
|
h('button', { type: 'button', className: 'primary', disabled: busy, onClick: deploy },
|
|
760
762
|
busy ? '部署中…' : '一键部署 Miloco'),
|
|
@@ -926,14 +928,19 @@ window.__ModuleLoader__.load({
|
|
|
926
928
|
const upgradeInfo = versionInfo?.upgrade ?? null
|
|
927
929
|
const currentVersion = upgradeInfo?.current || versionData?.version || ''
|
|
928
930
|
const latestVersion = upgradeInfo?.latest || ''
|
|
929
|
-
const deployKind = upgradeInfo?.deploy_kind
|
|
931
|
+
const deployKind = upgradeInfo?.deploy_kind
|
|
932
|
+
|| (currentVersion && /\.dev\d*|\+/.test(currentVersion) ? 'dev' : '')
|
|
930
933
|
const hasUpdate = Boolean(upgradeInfo?.has_update)
|
|
931
934
|
const isDevDeployment = deployKind === 'dev'
|
|
935
|
+
const upgradeSource = upgradeInfo?.source || 'backend'
|
|
936
|
+
const mirrorCheck = upgradeSource === 'mirror'
|
|
937
|
+
const upgradeUnavailable = !upgradeInfo || upgradeInfo.reachable === false
|
|
932
938
|
const releaseUrl = upgradeInfo?.release_url || ''
|
|
933
939
|
const sourceRoot = versionInfo?.sourceRoot || deployState.sourceDir || ''
|
|
934
940
|
const gitDirty = Boolean(versionData?.git?.dirty)
|
|
935
941
|
const commitShort = versionData?.git?.commit_short || ''
|
|
936
942
|
const upgradePhase = upgradeStatus?.phase || ''
|
|
943
|
+
const upgradeLogPath = upgradeStatus?.logPath || ''
|
|
937
944
|
const upgradePhaseLabels = {
|
|
938
945
|
idle: '空闲',
|
|
939
946
|
starting: '准备中',
|
|
@@ -952,9 +959,11 @@ window.__ModuleLoader__.load({
|
|
|
952
959
|
? ['', versionBusy ? '读取中' : '未知']
|
|
953
960
|
: isDevDeployment
|
|
954
961
|
? ['', '源码部署']
|
|
955
|
-
:
|
|
956
|
-
? ['warn', '
|
|
957
|
-
:
|
|
962
|
+
: upgradeUnavailable
|
|
963
|
+
? ['warn', '检查失败']
|
|
964
|
+
: hasUpdate
|
|
965
|
+
? ['warn', '有新版本']
|
|
966
|
+
: ['ok', '已是最新']
|
|
958
967
|
|
|
959
968
|
cards.push(h('div', { className: 'card', key: 'runtime' },
|
|
960
969
|
h('div', { className: 'cardHead' },
|
|
@@ -1027,12 +1036,18 @@ window.__ModuleLoader__.load({
|
|
|
1027
1036
|
latestVersion
|
|
1028
1037
|
? h('span', null, `最新正式版 ${latestVersion}`)
|
|
1029
1038
|
: null,
|
|
1039
|
+
mirrorCheck
|
|
1040
|
+
? h('span', {
|
|
1041
|
+
className: 'muted',
|
|
1042
|
+
title: 'GitHub 直连不可用,已通过镜像检查',
|
|
1043
|
+
}, '镜像')
|
|
1044
|
+
: null,
|
|
1030
1045
|
releaseUrl
|
|
1031
1046
|
? h('a', {
|
|
1032
1047
|
href: releaseUrl,
|
|
1033
1048
|
target: '_blank',
|
|
1034
1049
|
rel: 'noopener noreferrer',
|
|
1035
|
-
title: '
|
|
1050
|
+
title: '查看发布说明',
|
|
1036
1051
|
}, '发布页')
|
|
1037
1052
|
: null,
|
|
1038
1053
|
isDevDeployment && devUpdateCommand
|
|
@@ -1050,6 +1065,12 @@ window.__ModuleLoader__.load({
|
|
|
1050
1065
|
disabled: versionBusy,
|
|
1051
1066
|
onClick: () => loadVersion(true),
|
|
1052
1067
|
}, versionBusy ? '检查中…' : '检查更新'),
|
|
1068
|
+
upgradeUnavailable
|
|
1069
|
+
? h('span', {
|
|
1070
|
+
className: 'versionDirty',
|
|
1071
|
+
title: upgradeInfo?.error || 'GitHub 与镜像均不可达',
|
|
1072
|
+
}, '更新检查不可用')
|
|
1073
|
+
: null,
|
|
1053
1074
|
isDevDeployment && gitDirty
|
|
1054
1075
|
? h('span', {
|
|
1055
1076
|
className: 'versionDirty',
|
|
@@ -1083,7 +1104,7 @@ window.__ModuleLoader__.load({
|
|
|
1083
1104
|
}, '复制命令')))
|
|
1084
1105
|
: null,
|
|
1085
1106
|
upgradePhase === 'failed'
|
|
1086
|
-
? h('div', { className: 'muted versionNote' }, '
|
|
1107
|
+
? h('div', { className: 'muted versionNote' }, `上次升级失败${upgradeLogPath ? `,可查看 ${upgradeLogPath}` : ',可查看 Miloco 的 upgrade.log'} 后重试。`)
|
|
1087
1108
|
: null,
|
|
1088
1109
|
upgradeConfirmOpen
|
|
1089
1110
|
? h('div', { className: 'danger' },
|
package/lib/deploy.js
CHANGED
|
@@ -5,15 +5,15 @@
|
|
|
5
5
|
* reach a backend it can:
|
|
6
6
|
*
|
|
7
7
|
* - start an already-installed Miloco (`miloco-cli service start`), or
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
8
|
+
* - download the official release installer and run it without OpenClaw
|
|
9
|
+
* (`install.sh --skip-openclaw`), then install this package's adapter.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* The release installer is downloaded directly from GitHub first and then
|
|
12
|
+
* through the configured mirrors. It is a self-contained script and downloads
|
|
13
|
+
* its own release assets through the mirrors embedded in its manifest.
|
|
14
14
|
*
|
|
15
|
-
* This module never vendors upstream code: the
|
|
16
|
-
* runtime, on the owner's machine, after
|
|
15
|
+
* This module never vendors upstream code: the release installer download and
|
|
16
|
+
* install happen at runtime, on the owner's machine, after a button press.
|
|
17
17
|
*
|
|
18
18
|
* @module dsh-miloco-bridge/deploy
|
|
19
19
|
*/
|
|
@@ -21,11 +21,17 @@
|
|
|
21
21
|
import { spawn } from 'node:child_process'
|
|
22
22
|
import { randomBytes } from 'node:crypto'
|
|
23
23
|
import { constants, existsSync } from 'node:fs'
|
|
24
|
-
import { access, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
24
|
+
import { access, chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
25
25
|
import { homedir } from 'node:os'
|
|
26
26
|
import { dirname, join } from 'node:path'
|
|
27
27
|
import { fileURLToPath } from 'node:url'
|
|
28
28
|
import { readMilocoConfig } from './miloco-client.js'
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_GITHUB_MIRRORS,
|
|
31
|
+
normalizeRepo,
|
|
32
|
+
releaseInstallerUrls,
|
|
33
|
+
viaMirror,
|
|
34
|
+
} from './github-release.js'
|
|
29
35
|
|
|
30
36
|
/** Upstream source repository used when no local checkout exists. */
|
|
31
37
|
export const MILOCO_REPO = 'https://github.com/XiaoMi/xiaomi-miloco'
|
|
@@ -48,6 +54,46 @@ function expandHome(value) {
|
|
|
48
54
|
return text
|
|
49
55
|
}
|
|
50
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Mask credentials in command logs.
|
|
59
|
+
* @param {string[]} args - Command arguments.
|
|
60
|
+
* @returns {string[]} Arguments safe to log.
|
|
61
|
+
*/
|
|
62
|
+
export function redactCommandArgs(args) {
|
|
63
|
+
const out = [...args]
|
|
64
|
+
const secretFlags = new Set(['agent.auth_bearer', 'auth_bearer', '--token', '--bearer'])
|
|
65
|
+
for (let index = 0; index < out.length - 1; index += 1) {
|
|
66
|
+
if (secretFlags.has(out[index])) out[index + 1] = '<redacted>'
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Force release-installer arguments.
|
|
73
|
+
*
|
|
74
|
+
* The old source flow used `--dev`; a release installer rejects it. OpenClaw is
|
|
75
|
+
* used only to satisfy upstream platform detection, while `--skip-openclaw`
|
|
76
|
+
* prevents its plugin from being installed.
|
|
77
|
+
* @param {string[]} [raw] - Configured arguments.
|
|
78
|
+
* @returns {string[]} Safe release-installer arguments.
|
|
79
|
+
*/
|
|
80
|
+
function normalizeInstallArgs(raw) {
|
|
81
|
+
const args = Array.isArray(raw) ? raw.map(String) : []
|
|
82
|
+
const filtered = args.filter(arg =>
|
|
83
|
+
arg !== '--dev'
|
|
84
|
+
&& !arg.startsWith('--dev=')
|
|
85
|
+
&& !arg.startsWith('--agent-prepare')
|
|
86
|
+
&& !arg.startsWith('--agent-finish'))
|
|
87
|
+
if (!filtered.includes('--skip-openclaw')) filtered.push('--skip-openclaw')
|
|
88
|
+
if (!filtered.some(arg => arg === '--agent-platform' || arg.startsWith('--agent-platform='))) {
|
|
89
|
+
filtered.push('--agent-platform=openclaw')
|
|
90
|
+
}
|
|
91
|
+
if (!filtered.some(arg => arg === '--lang' || arg.startsWith('--lang='))) {
|
|
92
|
+
filtered.push('--lang=zh')
|
|
93
|
+
}
|
|
94
|
+
return filtered
|
|
95
|
+
}
|
|
96
|
+
|
|
51
97
|
/**
|
|
52
98
|
* Create a directory and prove the DSH process can write to it.
|
|
53
99
|
* @param {string} dir - Directory path.
|
|
@@ -85,22 +131,45 @@ export function createDeployService(options) {
|
|
|
85
131
|
)
|
|
86
132
|
const milocoHome = expandHome(config.home || '~/.miloco')
|
|
87
133
|
const repo = String(deployConfig.repo || MILOCO_REPO)
|
|
88
|
-
const
|
|
89
|
-
? deployConfig.
|
|
90
|
-
:
|
|
134
|
+
const githubMirrors = Array.isArray(deployConfig.githubMirrors) && deployConfig.githubMirrors.length
|
|
135
|
+
? deployConfig.githubMirrors.map(String)
|
|
136
|
+
: DEFAULT_GITHUB_MIRRORS
|
|
137
|
+
const installArgs = normalizeInstallArgs(deployConfig.installArgs)
|
|
138
|
+
const repoUrl = `https://github.com/${normalizeRepo(repo)}`
|
|
139
|
+
const downloadSite = String(deployConfig.downloadSite || '').trim()
|
|
140
|
+
|| (githubMirrors[0] ? viaMirror(githubMirrors[0], `${repoUrl}/releases/download`) : '')
|
|
141
|
+
const installEnv = {
|
|
142
|
+
UV_CACHE_DIR: join(milocoHome, '.uv-cache'),
|
|
143
|
+
UV_INDEX: String(deployConfig.uvIndex || 'https://pypi.org/simple'),
|
|
144
|
+
...(downloadSite ? { MILOCO_DOWNLOAD_URL: downloadSite } : {}),
|
|
145
|
+
}
|
|
91
146
|
const logPath = expandHome(deployConfig.logPath || join(milocoHome, 'log', 'dsh-miloco-deploy.log'))
|
|
147
|
+
const upgradeLogPath = expandHome(
|
|
148
|
+
deployConfig.upgradeLogPath || join(milocoHome, 'log', 'dsh-miloco-upgrade.log'),
|
|
149
|
+
)
|
|
92
150
|
|
|
93
151
|
/** @type {{running: boolean, step: string, startedAt: string | null, exitCode: number | null}} */
|
|
94
152
|
const state = { running: false, step: 'idle', startedAt: null, exitCode: null }
|
|
153
|
+
/** @type {{running: boolean, step: string, startedAt: string | null, exitCode: number | null}} */
|
|
154
|
+
const upgradeState = { running: false, step: 'idle', startedAt: null, exitCode: null }
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Write a line into a log file.
|
|
158
|
+
* @param {string} path - Log path.
|
|
159
|
+
* @param {string} text - The line.
|
|
160
|
+
*/
|
|
161
|
+
async function logTo(path, text) {
|
|
162
|
+
const stamp = new Date().toISOString().replace('T', ' ').slice(0, 19)
|
|
163
|
+
await mkdir(dirname(path), { recursive: true })
|
|
164
|
+
await writeFile(path, `[${stamp}] ${text}\n`, { flag: 'a' })
|
|
165
|
+
}
|
|
95
166
|
|
|
96
167
|
/**
|
|
97
168
|
* Write a line into the deployment log.
|
|
98
169
|
* @param {string} text - The line.
|
|
99
170
|
*/
|
|
100
171
|
async function logLine(text) {
|
|
101
|
-
|
|
102
|
-
await mkdir(dirname(logPath), { recursive: true })
|
|
103
|
-
await writeFile(logPath, `[${stamp}] ${text}\n`, { flag: 'a' })
|
|
172
|
+
return logTo(logPath, text)
|
|
104
173
|
}
|
|
105
174
|
|
|
106
175
|
/**
|
|
@@ -112,16 +181,27 @@ export function createDeployService(options) {
|
|
|
112
181
|
*/
|
|
113
182
|
function run(command, args, extra = {}) {
|
|
114
183
|
return new Promise(resolve => {
|
|
115
|
-
|
|
184
|
+
const secrets = (extra.secrets ?? []).filter(Boolean)
|
|
185
|
+
const sanitize = value => secrets.reduce(
|
|
186
|
+
(text, secret) => text.split(secret).join('<redacted>'),
|
|
187
|
+
String(value),
|
|
188
|
+
)
|
|
189
|
+
const commandLog = extra.logPath || logPath
|
|
190
|
+
void logTo(commandLog, `$ ${command} ${redactCommandArgs(args).join(' ')}`)
|
|
116
191
|
const child = spawn(command, args, {
|
|
117
192
|
cwd: extra.cwd,
|
|
118
|
-
env: {
|
|
193
|
+
env: {
|
|
194
|
+
...process.env,
|
|
195
|
+
PATH: [join(homedir(), '.local', 'bin'), process.env.PATH].filter(Boolean).join(':'),
|
|
196
|
+
MILOCO_HOME: milocoHome,
|
|
197
|
+
...(extra.env ?? {}),
|
|
198
|
+
},
|
|
119
199
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
120
200
|
})
|
|
121
201
|
const pump = stream => {
|
|
122
202
|
stream.on('data', chunk => {
|
|
123
|
-
for (const line of
|
|
124
|
-
if (line.trim() !== '') void
|
|
203
|
+
for (const line of sanitize(chunk).split('\n')) {
|
|
204
|
+
if (line.trim() !== '') void logTo(commandLog, line.trim())
|
|
125
205
|
}
|
|
126
206
|
})
|
|
127
207
|
}
|
|
@@ -191,6 +271,37 @@ export function createDeployService(options) {
|
|
|
191
271
|
return false
|
|
192
272
|
}
|
|
193
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Download the self-contained official release installer.
|
|
276
|
+
* @returns {Promise<string>} Path to the installer script.
|
|
277
|
+
*/
|
|
278
|
+
async function downloadReleaseInstaller(logTarget = logPath) {
|
|
279
|
+
const target = join(milocoHome, '.dsh-release-install.sh')
|
|
280
|
+
const urls = releaseInstallerUrls({ repo, mirrors: githubMirrors })
|
|
281
|
+
const failures = []
|
|
282
|
+
for (const url of urls) {
|
|
283
|
+
await logTo(logTarget, `downloading release installer: ${url}`)
|
|
284
|
+
try {
|
|
285
|
+
const response = await fetch(url, {
|
|
286
|
+
headers: { 'user-agent': 'dsh-miloco-bridge' },
|
|
287
|
+
redirect: 'follow',
|
|
288
|
+
signal: AbortSignal.timeout(30_000),
|
|
289
|
+
})
|
|
290
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
291
|
+
const text = await response.text()
|
|
292
|
+
if (!text.startsWith('#!')) throw new Error('响应不是 shell 安装脚本')
|
|
293
|
+
await writeFile(target, text, { encoding: 'utf8', mode: 0o700 })
|
|
294
|
+
await chmod(target, 0o700)
|
|
295
|
+
return target
|
|
296
|
+
} catch (error) {
|
|
297
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
298
|
+
failures.push(`${url}: ${message}`)
|
|
299
|
+
await logTo(logTarget, `download failed: ${message}`)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
throw new Error(`无法下载 Miloco release 安装器:${failures.join('; ')}`)
|
|
303
|
+
}
|
|
304
|
+
|
|
194
305
|
/**
|
|
195
306
|
* Resolve the effective Miloco URL/token from `$MILOCO_HOME/config.json`.
|
|
196
307
|
* @returns {Promise<{baseUrl: string, token: string}>} Connection settings.
|
|
@@ -244,10 +355,13 @@ export function createDeployService(options) {
|
|
|
244
355
|
async function wireMiloco(opts) {
|
|
245
356
|
const webhook = `${opts.origin}/miloco/webhook`
|
|
246
357
|
if (opts.cli) {
|
|
247
|
-
await run(opts.cli, ['config', 'set', 'agent.platform', 'dsh'], { cwd:
|
|
248
|
-
await run(opts.cli, ['config', 'set', 'agent.webhook_url', webhook], { cwd:
|
|
249
|
-
await run(opts.cli, ['config', 'set', 'agent.auth_bearer', opts.token], {
|
|
250
|
-
|
|
358
|
+
await run(opts.cli, ['config', 'set', 'agent.platform', 'dsh'], { cwd: milocoHome })
|
|
359
|
+
await run(opts.cli, ['config', 'set', 'agent.webhook_url', webhook], { cwd: milocoHome })
|
|
360
|
+
await run(opts.cli, ['config', 'set', 'agent.auth_bearer', opts.token], {
|
|
361
|
+
cwd: milocoHome,
|
|
362
|
+
secrets: [opts.token],
|
|
363
|
+
})
|
|
364
|
+
await run(opts.cli, ['service', 'restart'], { cwd: milocoHome })
|
|
251
365
|
}
|
|
252
366
|
if (options.overlay) {
|
|
253
367
|
const current = await options.overlay.current()
|
|
@@ -279,7 +393,6 @@ export function createDeployService(options) {
|
|
|
279
393
|
void (async () => {
|
|
280
394
|
try {
|
|
281
395
|
await ensureWritableDir(milocoHome, 'MILOCO_HOME')
|
|
282
|
-
await ensureWritableDir(dirname(sourceDir), 'deploy.sourceDir 的父目录')
|
|
283
396
|
await logLine('=== dsh-miloco-bridge: 一键部署 Miloco 开始 ===')
|
|
284
397
|
|
|
285
398
|
if (await healthy()) {
|
|
@@ -290,28 +403,28 @@ export function createDeployService(options) {
|
|
|
290
403
|
if (cli && existsSync(join(milocoHome, 'config.json'))) {
|
|
291
404
|
state.step = 'starting-existing'
|
|
292
405
|
await logLine(`检测到已安装的 Miloco,尝试启动:${cli}`)
|
|
293
|
-
await run(cli, ['service', 'start'], { cwd:
|
|
406
|
+
await run(cli, ['service', 'start'], { cwd: milocoHome })
|
|
294
407
|
await waitHealthy(DEFAULT_MILOCO_URL, 45_000)
|
|
295
408
|
}
|
|
296
409
|
|
|
297
410
|
if (!await healthy()) {
|
|
298
|
-
state.step = '
|
|
299
|
-
|
|
300
|
-
if (!/^https:\/\//i.test(repo)) {
|
|
301
|
-
throw new Error('deploy.repo 必须是 https:// 地址')
|
|
302
|
-
}
|
|
303
|
-
await mkdir(dirname(sourceDir), { recursive: true })
|
|
304
|
-
await logLine(`cloning ${repo} -> ${sourceDir}`)
|
|
305
|
-
const clone = await run('git', ['clone', '--depth', '1', repo, sourceDir], { cwd: homedir() })
|
|
306
|
-
if (clone !== 0) throw new Error(`git clone 失败(exit ${clone})`)
|
|
307
|
-
}
|
|
411
|
+
state.step = 'downloading-installer'
|
|
412
|
+
const installer = await downloadReleaseInstaller()
|
|
308
413
|
state.step = 'installing'
|
|
309
|
-
const
|
|
310
|
-
|
|
414
|
+
const prepare = await run('bash', [installer, '--agent-prepare', ...installArgs], {
|
|
415
|
+
cwd: milocoHome,
|
|
416
|
+
env: installEnv,
|
|
417
|
+
})
|
|
418
|
+
if (prepare !== 0) throw new Error(`Miloco release 安装器 prepare 返回 ${prepare}`)
|
|
419
|
+
const finish = await run('bash', [installer, '--agent-finish', ...installArgs], {
|
|
420
|
+
cwd: milocoHome,
|
|
421
|
+
env: installEnv,
|
|
422
|
+
})
|
|
423
|
+
if (finish !== 0) throw new Error(`Miloco release 安装器 finish 返回 ${finish}`)
|
|
311
424
|
state.step = 'starting-service'
|
|
312
425
|
const cliAfter = await findMilocoCli()
|
|
313
|
-
if (cliAfter) await run(cliAfter, ['service', 'start'], { cwd:
|
|
314
|
-
if (!await waitHealthy(DEFAULT_MILOCO_URL,
|
|
426
|
+
if (cliAfter) await run(cliAfter, ['service', 'start'], { cwd: milocoHome })
|
|
427
|
+
if (!await waitHealthy(DEFAULT_MILOCO_URL, 120_000)) {
|
|
315
428
|
throw new Error('Miloco 安装完成但没有在 127.0.0.1:1810 上响应')
|
|
316
429
|
}
|
|
317
430
|
}
|
|
@@ -346,6 +459,88 @@ export function createDeployService(options) {
|
|
|
346
459
|
return { started: true, logPath }
|
|
347
460
|
}
|
|
348
461
|
|
|
462
|
+
/**
|
|
463
|
+
* Run a mirror-backed upgrade through the same release installer as deploy.
|
|
464
|
+
* @param {{origin?: string}} [opts] - Request origin.
|
|
465
|
+
* @returns {Promise<{started: boolean, reason?: string, logPath?: string}>} Outcome.
|
|
466
|
+
*/
|
|
467
|
+
async function startUpgrade(opts = {}) {
|
|
468
|
+
if (upgradeState.running) {
|
|
469
|
+
return { started: false, reason: 'already-running', logPath: upgradeLogPath }
|
|
470
|
+
}
|
|
471
|
+
if (state.running) {
|
|
472
|
+
return { started: false, reason: 'deploy-running', logPath: upgradeLogPath }
|
|
473
|
+
}
|
|
474
|
+
upgradeState.running = true
|
|
475
|
+
upgradeState.step = 'starting'
|
|
476
|
+
upgradeState.startedAt = new Date().toISOString()
|
|
477
|
+
upgradeState.exitCode = null
|
|
478
|
+
const origin = String(opts.origin || '').replace(/\/+$/, '') || 'http://127.0.0.1:3080'
|
|
479
|
+
|
|
480
|
+
void (async () => {
|
|
481
|
+
try {
|
|
482
|
+
await ensureWritableDir(milocoHome, 'MILOCO_HOME')
|
|
483
|
+
await logTo(upgradeLogPath, '=== Miloco 升级开始 ===')
|
|
484
|
+
upgradeState.step = 'downloading-installer'
|
|
485
|
+
const installer = await downloadReleaseInstaller(upgradeLogPath)
|
|
486
|
+
upgradeState.step = 'installing'
|
|
487
|
+
const installOpts = { cwd: milocoHome, env: installEnv, logPath: upgradeLogPath }
|
|
488
|
+
const prepare = await run('bash', [installer, '--agent-prepare', ...installArgs], installOpts)
|
|
489
|
+
if (prepare !== 0) throw new Error(`Miloco release 安装器 prepare 返回 ${prepare}`)
|
|
490
|
+
const finish = await run('bash', [installer, '--agent-finish', ...installArgs], installOpts)
|
|
491
|
+
if (finish !== 0) throw new Error(`Miloco release 安装器 finish 返回 ${finish}`)
|
|
492
|
+
upgradeState.step = 'starting-service'
|
|
493
|
+
const cli = await findMilocoCli()
|
|
494
|
+
if (cli) {
|
|
495
|
+
await run(cli, ['service', 'start'], { cwd: milocoHome, logPath: upgradeLogPath })
|
|
496
|
+
}
|
|
497
|
+
if (!await waitHealthy(DEFAULT_MILOCO_URL, 120_000)) {
|
|
498
|
+
throw new Error('Miloco 升级后没有在 127.0.0.1:1810 上响应')
|
|
499
|
+
}
|
|
500
|
+
upgradeState.step = 'wiring'
|
|
501
|
+
const connection = await localConnection()
|
|
502
|
+
const fileConfig = await readMilocoConfig(milocoHome)
|
|
503
|
+
const existingToken = String(fileConfig?.agent?.auth_bearer ?? '').trim()
|
|
504
|
+
const pushToken = existingToken || randomBytes(32).toString('base64url')
|
|
505
|
+
await installAdapter({ origin, token: pushToken })
|
|
506
|
+
await wireMiloco({ cli, origin, token: pushToken, connection })
|
|
507
|
+
upgradeState.step = 'done'
|
|
508
|
+
upgradeState.exitCode = 0
|
|
509
|
+
await logTo(upgradeLogPath, '=== 升级完成 ===')
|
|
510
|
+
} catch (error) {
|
|
511
|
+
upgradeState.step = 'failed'
|
|
512
|
+
upgradeState.exitCode = 1
|
|
513
|
+
await logTo(upgradeLogPath, `升级异常:${error instanceof Error ? error.message : String(error)}`)
|
|
514
|
+
} finally {
|
|
515
|
+
upgradeState.running = false
|
|
516
|
+
}
|
|
517
|
+
})()
|
|
518
|
+
|
|
519
|
+
return { started: true, logPath: upgradeLogPath }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* @returns {any} Upgrade state for the settings API.
|
|
524
|
+
*/
|
|
525
|
+
function upgradeStateView() {
|
|
526
|
+
return { ...upgradeState, logPath: upgradeLogPath, milocoHome }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Read the tail of the upgrade log.
|
|
531
|
+
* @param {number} [lines] - Number of trailing lines.
|
|
532
|
+
* @returns {Promise<string>} The log tail.
|
|
533
|
+
*/
|
|
534
|
+
async function upgradeTail(lines = 60) {
|
|
535
|
+
try {
|
|
536
|
+
const text = await readFile(upgradeLogPath, 'utf8')
|
|
537
|
+
const all = text.split('\n').filter(line => line.trim() !== '')
|
|
538
|
+
return all.slice(-lines).join('\n')
|
|
539
|
+
} catch {
|
|
540
|
+
return ''
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
349
544
|
/**
|
|
350
545
|
* Read the tail of the deployment log.
|
|
351
546
|
* @param {number} [lines] - Number of trailing lines.
|
|
@@ -371,5 +566,8 @@ export function createDeployService(options) {
|
|
|
371
566
|
}),
|
|
372
567
|
tail,
|
|
373
568
|
start,
|
|
569
|
+
upgradeState: upgradeStateView,
|
|
570
|
+
upgradeTail,
|
|
571
|
+
startUpgrade,
|
|
374
572
|
}
|
|
375
573
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub release lookup with mirror fallback.
|
|
3
|
+
*
|
|
4
|
+
* Miloco only publishes on GitHub. Direct GitHub access is preferred; when it
|
|
5
|
+
* is unavailable, the same API/installer URLs are requested through public
|
|
6
|
+
* pull-through mirrors.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-miloco-bridge/github-release
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_GITHUB_REPO = 'XiaoMi/xiaomi-miloco'
|
|
12
|
+
export const DEFAULT_GITHUB_API = 'https://api.github.com'
|
|
13
|
+
export const DEFAULT_GITHUB_MIRRORS = [
|
|
14
|
+
'https://gh-proxy.com/',
|
|
15
|
+
'https://gh-proxy.org/',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Normalise an owner/repo or GitHub URL to `owner/repo`.
|
|
20
|
+
* @param {string} [repo] - Repository URL or slug.
|
|
21
|
+
* @returns {string} Repository slug.
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeRepo(repo) {
|
|
24
|
+
const text = String(repo ?? '').trim().replace(/\/+$/, '').replace(/\.git$/i, '')
|
|
25
|
+
const match = /^(?:https?:\/\/github\.com\/)?([^/\s]+\/[^/\s]+)$/i.exec(text)
|
|
26
|
+
return match ? match[1] : DEFAULT_GITHUB_REPO
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Prefix a URL with a mirror, or expand a `{url}` template.
|
|
31
|
+
* @param {string} mirror - Mirror base or template.
|
|
32
|
+
* @param {string} url - Original URL.
|
|
33
|
+
* @returns {string} Mirrored URL.
|
|
34
|
+
*/
|
|
35
|
+
export function viaMirror(mirror, url) {
|
|
36
|
+
const base = String(mirror ?? '').trim()
|
|
37
|
+
if (!base) return url
|
|
38
|
+
if (base.includes('{url}')) return base.replaceAll('{url}', url)
|
|
39
|
+
return `${base.replace(/\/+$/, '')}/${url}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build direct + mirrored URLs for the latest-release API.
|
|
44
|
+
* @param {{repo?: string, apiBase?: string, mirrors?: string[]}} [opts] - Options.
|
|
45
|
+
* @returns {string[]} Candidate URLs, direct first.
|
|
46
|
+
*/
|
|
47
|
+
export function releaseApiUrls(opts = {}) {
|
|
48
|
+
const repo = normalizeRepo(opts.repo)
|
|
49
|
+
const apiBase = String(opts.apiBase || DEFAULT_GITHUB_API).replace(/\/+$/, '')
|
|
50
|
+
const direct = `${apiBase}/repos/${repo}/releases/latest`
|
|
51
|
+
const mirrors = (opts.mirrors ?? DEFAULT_GITHUB_MIRRORS).map(mirror => viaMirror(mirror, direct))
|
|
52
|
+
return [...new Set([direct, ...mirrors])]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build direct + mirrored URLs for the release installer script.
|
|
57
|
+
* @param {{repo?: string, mirrors?: string[]}} [opts] - Options.
|
|
58
|
+
* @returns {string[]} Candidate URLs, direct first.
|
|
59
|
+
*/
|
|
60
|
+
export function releaseInstallerUrls(opts = {}) {
|
|
61
|
+
const repo = normalizeRepo(opts.repo)
|
|
62
|
+
const direct = `https://github.com/${repo}/releases/latest/download/install.sh`
|
|
63
|
+
const mirrors = (opts.mirrors ?? DEFAULT_GITHUB_MIRRORS).map(mirror => viaMirror(mirror, direct))
|
|
64
|
+
return [...new Set([direct, ...mirrors])]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fetch the latest release, trying direct GitHub then mirrors.
|
|
69
|
+
* @param {{repo?: string, apiBase?: string, mirrors?: string[], timeoutMs?: number, fetchImpl?: typeof fetch}} [opts] - Options.
|
|
70
|
+
* @returns {Promise<{tag: string, html_url: string, source: 'github'|'mirror', mirror: string|null, release_url: string, request_url: string}>} Latest release.
|
|
71
|
+
*/
|
|
72
|
+
export async function fetchLatestRelease(opts = {}) {
|
|
73
|
+
const doFetch = opts.fetchImpl ?? fetch
|
|
74
|
+
const timeoutMs = Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 ? opts.timeoutMs : 6_000
|
|
75
|
+
const urls = releaseApiUrls(opts)
|
|
76
|
+
const errors = []
|
|
77
|
+
for (const [index, url] of urls.entries()) {
|
|
78
|
+
try {
|
|
79
|
+
const response = await doFetch(url, {
|
|
80
|
+
headers: {
|
|
81
|
+
accept: 'application/vnd.github+json',
|
|
82
|
+
'user-agent': 'dsh-miloco-bridge',
|
|
83
|
+
},
|
|
84
|
+
redirect: 'follow',
|
|
85
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
86
|
+
})
|
|
87
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
88
|
+
const body = await response.json()
|
|
89
|
+
const tag = String(body?.tag_name ?? '').trim()
|
|
90
|
+
if (!tag) throw new Error('响应缺少 tag_name')
|
|
91
|
+
const htmlUrl = String(body?.html_url ?? '').trim()
|
|
92
|
+
const mirrored = index > 0
|
|
93
|
+
const mirror = mirrored ? (opts.mirrors ?? DEFAULT_GITHUB_MIRRORS)[index - 1] : null
|
|
94
|
+
return {
|
|
95
|
+
tag,
|
|
96
|
+
html_url: htmlUrl,
|
|
97
|
+
source: mirrored ? 'mirror' : 'github',
|
|
98
|
+
mirror,
|
|
99
|
+
release_url: mirrored && htmlUrl ? viaMirror(mirror, htmlUrl) : htmlUrl,
|
|
100
|
+
request_url: url,
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
errors.push(`${url}: ${error instanceof Error ? error.message : String(error)}`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
throw new Error(`最新版本查询失败:${errors.join('; ')}`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Parse a CalVer/PEP440-style release string.
|
|
111
|
+
* @param {string} value - Version string.
|
|
112
|
+
* @returns {{parts: number[], post: number, dev: number|null}|null} Parsed version.
|
|
113
|
+
*/
|
|
114
|
+
export function parseReleaseVersion(value) {
|
|
115
|
+
const text = String(value ?? '').trim().replace(/^v/i, '').split('+')[0]
|
|
116
|
+
const match = /^(\d+(?:\.\d+)*)(?:\.post(\d+))?(?:\.dev(\d+))?$/.exec(text)
|
|
117
|
+
if (!match) return null
|
|
118
|
+
return {
|
|
119
|
+
parts: match[1].split('.').map(Number),
|
|
120
|
+
post: Number(match[2] ?? 0),
|
|
121
|
+
dev: match[3] === undefined ? null : Number(match[3]),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Normalise a release tag for display.
|
|
127
|
+
* @param {string} value - Tag or version.
|
|
128
|
+
* @returns {string} Version without a leading `v`.
|
|
129
|
+
*/
|
|
130
|
+
export function normalizeReleaseVersion(value) {
|
|
131
|
+
return String(value ?? '').trim().replace(/^v/i, '')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether `latest` is strictly newer than `current`.
|
|
136
|
+
* Dev builds and unparseable versions are treated conservatively as "no update".
|
|
137
|
+
* @param {string} current - Current version.
|
|
138
|
+
* @param {string} latest - Latest release tag.
|
|
139
|
+
* @returns {boolean} Whether an update is available.
|
|
140
|
+
*/
|
|
141
|
+
export function isNewerRelease(current, latest) {
|
|
142
|
+
const left = parseReleaseVersion(current)
|
|
143
|
+
const right = parseReleaseVersion(latest)
|
|
144
|
+
if (!left || !right) return false
|
|
145
|
+
if (left.dev !== null) return false
|
|
146
|
+
const length = Math.max(left.parts.length, right.parts.length)
|
|
147
|
+
for (let index = 0; index < length; index += 1) {
|
|
148
|
+
const a = left.parts[index] ?? 0
|
|
149
|
+
const b = right.parts[index] ?? 0
|
|
150
|
+
if (b !== a) return b > a
|
|
151
|
+
}
|
|
152
|
+
return right.post > left.post
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Infer release/dev deployment kind from a package version.
|
|
157
|
+
* @param {string} version - Package version.
|
|
158
|
+
* @returns {'release'|'dev'} Deployment kind.
|
|
159
|
+
*/
|
|
160
|
+
export function inferDeployKind(version) {
|
|
161
|
+
return /\.dev\d*|\+/.test(String(version ?? '')) ? 'dev' : 'release'
|
|
162
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -96,7 +96,17 @@ export function normalizeConfig(raw) {
|
|
|
96
96
|
: join(resolveDshHome(), 'plugin-data', 'dsh-miloco-bridge', 'xiaomi-miloco'),
|
|
97
97
|
installArgs: Array.isArray(deployRaw.installArgs)
|
|
98
98
|
? deployRaw.installArgs.map(String)
|
|
99
|
-
: ['--
|
|
99
|
+
: ['--skip-openclaw', '--agent-platform=openclaw', '--lang=zh'],
|
|
100
|
+
githubMirrors: Array.isArray(deployRaw.githubMirrors)
|
|
101
|
+
? deployRaw.githubMirrors.map(String).filter(Boolean)
|
|
102
|
+
: ['https://gh-proxy.com/', 'https://gh-proxy.org/'],
|
|
103
|
+
githubApi: typeof deployRaw.githubApi === 'string' && deployRaw.githubApi
|
|
104
|
+
? deployRaw.githubApi
|
|
105
|
+
: 'https://api.github.com',
|
|
106
|
+
downloadSite: typeof deployRaw.downloadSite === 'string' ? deployRaw.downloadSite : '',
|
|
107
|
+
uvIndex: typeof deployRaw.uvIndex === 'string' && deployRaw.uvIndex
|
|
108
|
+
? deployRaw.uvIndex
|
|
109
|
+
: 'https://pypi.org/simple',
|
|
100
110
|
logPath: typeof deployRaw.logPath === 'string' ? deployRaw.logPath : '',
|
|
101
111
|
},
|
|
102
112
|
}
|
package/lib/settings-api.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { access } from 'node:fs/promises'
|
|
24
24
|
import { dirname, join } from 'node:path'
|
|
25
|
+
import { fetchLatestRelease, inferDeployKind, isNewerRelease, normalizeReleaseVersion } from './github-release.js'
|
|
25
26
|
import { MilocoError, readMilocoConfig } from './miloco-client.js'
|
|
26
27
|
import { normalizePerceptionLevel, PERCEPTION_LEVELS } from './perception.js'
|
|
27
28
|
import { AGENT_MODES, describeMode, readSessions } from './state.js'
|
|
@@ -212,6 +213,8 @@ export function registerSettingsApi(options) {
|
|
|
212
213
|
const { ctx, config, client, overlay } = options
|
|
213
214
|
const log = options.logger ?? ctx.logger
|
|
214
215
|
const disposers = []
|
|
216
|
+
/** @type {null | {at: number, source: string, has_update: boolean, current: string, latest: string}} */
|
|
217
|
+
let lastUpgradeCheck = null
|
|
215
218
|
|
|
216
219
|
/**
|
|
217
220
|
* Register one exact route.
|
|
@@ -485,7 +488,7 @@ export function registerSettingsApi(options) {
|
|
|
485
488
|
: (config.deploy?.sourceDir || '')
|
|
486
489
|
|
|
487
490
|
try {
|
|
488
|
-
const [version,
|
|
491
|
+
const [version, backendUpgrade, upgradeStatus] = await Promise.all([
|
|
489
492
|
client.get('/api/admin/version', { signal: AbortSignal.timeout(8_000) }),
|
|
490
493
|
client.get('/api/admin/upgrade/check', {
|
|
491
494
|
query: { force: force ? 'true' : 'false' },
|
|
@@ -493,6 +496,55 @@ export function registerSettingsApi(options) {
|
|
|
493
496
|
}).catch(() => null),
|
|
494
497
|
client.get('/api/admin/upgrade/status', { signal: AbortSignal.timeout(8_000) }).catch(() => null),
|
|
495
498
|
])
|
|
499
|
+
let upgrade = backendUpgrade
|
|
500
|
+
if (!upgrade || upgrade.reachable === false) {
|
|
501
|
+
const current = String(upgrade?.current || version?.version || '')
|
|
502
|
+
const deployKind = upgrade?.deploy_kind || inferDeployKind(current)
|
|
503
|
+
try {
|
|
504
|
+
const latest = await fetchLatestRelease({
|
|
505
|
+
repo: config.deploy?.repo,
|
|
506
|
+
apiBase: config.deploy?.githubApi,
|
|
507
|
+
mirrors: config.deploy?.githubMirrors,
|
|
508
|
+
timeoutMs: 6_000,
|
|
509
|
+
})
|
|
510
|
+
upgrade = {
|
|
511
|
+
current,
|
|
512
|
+
latest: normalizeReleaseVersion(latest.tag),
|
|
513
|
+
has_update: Boolean(current) && deployKind === 'release'
|
|
514
|
+
&& isNewerRelease(current, latest.tag),
|
|
515
|
+
deploy_kind: deployKind,
|
|
516
|
+
release_url: latest.release_url || latest.html_url,
|
|
517
|
+
reachable: true,
|
|
518
|
+
source: latest.source,
|
|
519
|
+
mirror: latest.mirror || null,
|
|
520
|
+
checked_at: Math.floor(Date.now() / 1000),
|
|
521
|
+
}
|
|
522
|
+
} catch (fallbackError) {
|
|
523
|
+
const message = fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
|
|
524
|
+
upgrade = {
|
|
525
|
+
...(upgrade ?? {}),
|
|
526
|
+
current,
|
|
527
|
+
latest: null,
|
|
528
|
+
has_update: false,
|
|
529
|
+
deploy_kind: deployKind,
|
|
530
|
+
release_url: null,
|
|
531
|
+
reachable: false,
|
|
532
|
+
source: 'none',
|
|
533
|
+
error: message,
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
} else {
|
|
537
|
+
upgrade = { ...upgrade, source: upgrade.source || 'backend' }
|
|
538
|
+
}
|
|
539
|
+
if (upgrade) {
|
|
540
|
+
lastUpgradeCheck = {
|
|
541
|
+
at: Date.now(),
|
|
542
|
+
source: upgrade.source || 'backend',
|
|
543
|
+
has_update: Boolean(upgrade.has_update),
|
|
544
|
+
current: String(upgrade.current || ''),
|
|
545
|
+
latest: String(upgrade.latest || ''),
|
|
546
|
+
}
|
|
547
|
+
}
|
|
496
548
|
sendJson(res, 200, {
|
|
497
549
|
ok: true,
|
|
498
550
|
reachable: true,
|
|
@@ -521,6 +573,24 @@ export function registerSettingsApi(options) {
|
|
|
521
573
|
}
|
|
522
574
|
const body = await mutateBody(req, res)
|
|
523
575
|
if (body === null) return
|
|
576
|
+
const recent = lastUpgradeCheck && Date.now() - lastUpgradeCheck.at < 10 * 60_000
|
|
577
|
+
const pluginUpgrade = recent
|
|
578
|
+
&& lastUpgradeCheck.source !== 'backend'
|
|
579
|
+
&& lastUpgradeCheck.has_update
|
|
580
|
+
&& typeof options.deploy?.startUpgrade === 'function'
|
|
581
|
+
if (pluginUpgrade) {
|
|
582
|
+
const proto = String(req.headers['x-forwarded-proto'] || 'http').split(',')[0].trim()
|
|
583
|
+
const origin = typeof req.headers.origin === 'string' && req.headers.origin
|
|
584
|
+
? req.headers.origin
|
|
585
|
+
: `${proto}://${req.headers.host ?? '127.0.0.1:3080'}`
|
|
586
|
+
const outcome = await options.deploy.startUpgrade({ origin })
|
|
587
|
+
if (!outcome.started) {
|
|
588
|
+
sendJson(res, 409, { ok: false, error: outcome.reason || '升级未启动', ...outcome })
|
|
589
|
+
return
|
|
590
|
+
}
|
|
591
|
+
sendJson(res, 200, { ok: true, source: 'plugin', ...outcome })
|
|
592
|
+
return
|
|
593
|
+
}
|
|
524
594
|
// Upstream only allows /upgrade/run for release deployments. It performs
|
|
525
595
|
// its own has_update check; surface its 400 reason verbatim.
|
|
526
596
|
try {
|
|
@@ -541,6 +611,20 @@ export function registerSettingsApi(options) {
|
|
|
541
611
|
sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
542
612
|
return
|
|
543
613
|
}
|
|
614
|
+
const pluginState = options.deploy?.upgradeState?.()
|
|
615
|
+
if (pluginState && pluginState.step !== 'idle') {
|
|
616
|
+
const phaseMap = {
|
|
617
|
+
'downloading-installer': 'downloading',
|
|
618
|
+
'starting-service': 'restarting',
|
|
619
|
+
wiring: 'installing',
|
|
620
|
+
}
|
|
621
|
+
sendJson(res, 200, {
|
|
622
|
+
ok: true,
|
|
623
|
+
source: 'plugin',
|
|
624
|
+
status: { ...pluginState, phase: phaseMap[pluginState.step] || pluginState.step },
|
|
625
|
+
})
|
|
626
|
+
return
|
|
627
|
+
}
|
|
544
628
|
try {
|
|
545
629
|
const status = await client.get('/api/admin/upgrade/status', {
|
|
546
630
|
signal: AbortSignal.timeout(8_000),
|
package/lib/tools.js
CHANGED
|
@@ -23,6 +23,16 @@ import { MilocoError } from './miloco-client.js'
|
|
|
23
23
|
import { PERCEPTION_LEVELS } from './perception.js'
|
|
24
24
|
import { noteSession } from './state.js'
|
|
25
25
|
|
|
26
|
+
/** Extra device fields that callers may request explicitly. */
|
|
27
|
+
const DEVICE_LIST_EXTRA_FIELDS = [
|
|
28
|
+
'icon',
|
|
29
|
+
'local_ip',
|
|
30
|
+
'home_name',
|
|
31
|
+
'order_time',
|
|
32
|
+
'lan_online',
|
|
33
|
+
'sub_devices',
|
|
34
|
+
]
|
|
35
|
+
|
|
26
36
|
/**
|
|
27
37
|
* Serialise a canonical tool value as readable JSON text.
|
|
28
38
|
* @param {unknown} value - Value to serialise.
|
|
@@ -134,15 +144,63 @@ export function createMilocoTools(options) {
|
|
|
134
144
|
|
|
135
145
|
tools.push(defineMilocoTool({
|
|
136
146
|
name: 'miloco_device_list',
|
|
137
|
-
description: '
|
|
147
|
+
description: '列出米家设备。默认返回 did、名称、在线状态、房间、型号和 home_id;'
|
|
148
|
+
+ '可用 include 显式取回 icon/local_ip 等额外字段。默认最多 500 台,超过时用 offset 翻页。',
|
|
138
149
|
parameters: {
|
|
139
|
-
room: { type: 'string', description: '按房间名过滤(本地匹配 room_name
|
|
150
|
+
room: { type: 'string', description: '按房间名过滤(本地匹配 room_name)' },
|
|
151
|
+
name: { type: 'string', description: '按设备名过滤(本地子串匹配,忽略大小写)' },
|
|
152
|
+
online_only: { type: 'boolean', description: '只返回在线设备' },
|
|
153
|
+
limit: {
|
|
154
|
+
type: 'integer',
|
|
155
|
+
minimum: 1,
|
|
156
|
+
maximum: 500,
|
|
157
|
+
description: '最多返回多少台,默认 500',
|
|
158
|
+
},
|
|
159
|
+
offset: { type: 'integer', minimum: 0, description: '从第几台开始,默认 0' },
|
|
160
|
+
include: {
|
|
161
|
+
type: 'array',
|
|
162
|
+
items: { type: 'string', enum: DEVICE_LIST_EXTRA_FIELDS },
|
|
163
|
+
description: '额外字段:icon / local_ip / home_name / order_time / lan_online / sub_devices',
|
|
164
|
+
},
|
|
140
165
|
},
|
|
141
166
|
async run(args, exec) {
|
|
142
167
|
const devices = await call('GET', '/api/miot/device_list', { signal: exec?.signal })
|
|
168
|
+
const list = Array.isArray(devices) ? devices : []
|
|
143
169
|
const room = typeof args.room === 'string' ? args.room.trim().toLowerCase() : ''
|
|
144
|
-
|
|
145
|
-
|
|
170
|
+
const name = typeof args.name === 'string' ? args.name.trim().toLowerCase() : ''
|
|
171
|
+
const onlineOnly = args.online_only === true
|
|
172
|
+
const includes = Array.isArray(args.include)
|
|
173
|
+
? [...new Set(args.include.filter(field => typeof field === 'string'))]
|
|
174
|
+
: []
|
|
175
|
+
for (const field of includes) {
|
|
176
|
+
if (!DEVICE_LIST_EXTRA_FIELDS.includes(field)) throw new Error(`include 不支持字段:${field}`)
|
|
177
|
+
}
|
|
178
|
+
const limit = args.limit === undefined ? 500 : Number(args.limit)
|
|
179
|
+
const offset = args.offset === undefined ? 0 : Number(args.offset)
|
|
180
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500) throw new Error('limit 必须是 1~500 的整数')
|
|
181
|
+
if (!Number.isInteger(offset) || offset < 0) throw new Error('offset 必须是非负整数')
|
|
182
|
+
const filtered = list.filter((device) => {
|
|
183
|
+
if (!device || typeof device !== 'object') return false
|
|
184
|
+
if (onlineOnly && device.online !== true) return false
|
|
185
|
+
if (room && !String(device.room_name ?? device.room ?? '').toLowerCase().includes(room)) return false
|
|
186
|
+
if (name && !String(device.name ?? '').toLowerCase().includes(name)) return false
|
|
187
|
+
return true
|
|
188
|
+
})
|
|
189
|
+
const page = filtered.slice(offset, offset + limit).map((device) => {
|
|
190
|
+
const row = {
|
|
191
|
+
did: device.did ?? '',
|
|
192
|
+
name: device.name ?? '',
|
|
193
|
+
online: device.online === true,
|
|
194
|
+
room_name: device.room_name ?? '',
|
|
195
|
+
model: device.model ?? '',
|
|
196
|
+
home_id: device.home_id ?? '',
|
|
197
|
+
}
|
|
198
|
+
for (const field of includes) {
|
|
199
|
+
if (Object.prototype.hasOwnProperty.call(device, field)) row[field] = device[field]
|
|
200
|
+
}
|
|
201
|
+
return row
|
|
202
|
+
})
|
|
203
|
+
return { total: filtered.length, returned: page.length, offset, limit, devices: page }
|
|
146
204
|
},
|
|
147
205
|
}))
|
|
148
206
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-miloco-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Unofficial DSH bridge for Xiaomi Miloco: an isolated Agent preset for Miloco tools, native tools/skills, Web settings, perception-priority filtering, and Miloco -> DSH push.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -12,7 +12,7 @@ whenToUse: 用户说“开/关/调/查某台设备”“客厅多少度”“执
|
|
|
12
12
|
|
|
13
13
|
| 目的 | 工具 |
|
|
14
14
|
|---|---|
|
|
15
|
-
| 找设备 did | `miloco_device_list(room?)` |
|
|
15
|
+
| 找设备 did | `miloco_device_list(room?, online_only?, name?, include?)` |
|
|
16
16
|
| 读 SPEC / iid | `miloco_device_spec(did)` |
|
|
17
17
|
| 读当前属性 | `miloco_device_status(did, iid?)` |
|
|
18
18
|
| 控制设备(写) | `miloco_device_control(...)` |
|
|
@@ -21,7 +21,7 @@ whenToUse: 用户说“开/关/调/查某台设备”“客厅多少度”“执
|
|
|
21
21
|
|
|
22
22
|
## 查询流程
|
|
23
23
|
|
|
24
|
-
1. `miloco_device_list` 拿 did
|
|
24
|
+
1. `miloco_device_list` 拿 did(结果在 `devices` 数组里)。名字相同或跨房间时用 `name` / `room` / `online_only` 先缩小范围,不猜。
|
|
25
25
|
2. `miloco_device_status` 读状态。传 `iid` 读单项,不传读全部。
|
|
26
26
|
3. 回答里带上设备名、房间、关键数值和时间(若后端返回)。
|
|
27
27
|
|
|
@@ -42,6 +42,7 @@ whenToUse: 用户说“开/关/调/查某台设备”“客厅多少度”“执
|
|
|
42
42
|
- 门锁、燃气阀、摄像头开关、厨房电器等有物理风险的操作,先说明将要做什么并取得确认。
|
|
43
43
|
- 不要把“感知事件里的 did”当成控制目标:事件 did 通常是摄像头/传感器。
|
|
44
44
|
- 不确定设备是否存在时重新 `miloco_device_list` 或 `miloco_home_info(refresh=true)`,禁止编造 did。
|
|
45
|
+
- 需要 `icon`、`local_ip` 等默认裁剪字段时,用 `include=[...]` 显式请求,不要为了一个字段重拉全量原始对象。
|
|
45
46
|
|
|
46
47
|
## 场景
|
|
47
48
|
|