@linanw/homebridge-mijia-remote-camera 0.3.0 → 0.4.0
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/CHANGELOG.md +26 -0
- package/README.md +38 -25
- package/THIRD_PARTY_NOTICES.md +11 -3
- package/backend/CloudMetadata.java +386 -0
- package/backend/bridge.jar +0 -0
- package/docs/verification.md +72 -0
- package/homebridge-ui/public/index.html +98 -6
- package/homebridge-ui/server.js +43 -0
- package/lib/matter-device.mjs +24 -2
- package/lib/matter-metadata-cluster.mjs +243 -0
- package/lib/matter-service.mjs +15 -1
- package/lib/metadata-sync.mjs +192 -0
- package/lib/platform.mjs +35 -3
- package/lib/rpc.mjs +7 -5
- package/lib/runtime-assets.mjs +34 -1
- package/lib/runtime-factory.mjs +18 -2
- package/lib/runtime.mjs +157 -12
- package/lib/setup-job.mjs +49 -0
- package/npm-shrinkwrap.json +149 -167
- package/package.json +19 -7
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.4.0 — 2026-10-24
|
|
4
|
+
|
|
5
|
+
- Add 60-second selected-camera metadata polling and manual refresh for the Mi Home name, home and room. Preserve accessory identity and active media during metadata updates.
|
|
6
|
+
- Add guarded owner-only device rename and moves to an existing room in the same Mi Home home, through the settings page and the vendor Matter metadata interface. Enable writes only after the runtime capability is validated. Each action sends one write and independently confirms the result; shared homes remain read-only.
|
|
7
|
+
- Validate real Chinese device-name rename/restore and moves between two existing rooms with independent readback and unchanged credentials. Cloud name rules can remove characters; exact readback rejects an emoji-normalized result rather than reporting confirmation.
|
|
8
|
+
- Keep Apple Home name/room writeback deferred. Room creation, room renaming, moves across homes and moves to the home root are unsupported.
|
|
9
|
+
- Add asynchronous local runtime import in Homebridge UI without requiring a terminal. Keep the fixed component hashes, legal notices, storage boundary and active-owner protection.
|
|
10
|
+
- Declare Node.js 22.13.1+ / 24 and Homebridge 1.11.4+ / 2 compatibility, with Homebridge 2.4.0 as the current development dependency. Add a public-source CI matrix and verification preparation notes.
|
|
11
|
+
- Preserve Unicode metadata and the cross-language revision fingerprint even when a container configures stdout/native argument encoding as US-ASCII; use strict UTF-8 through an ASCII-only internal rename transport. Bound Matter metadata strings to 160 UTF-8 bytes without silently truncating edits.
|
|
12
|
+
|
|
13
|
+
## 0.3.0
|
|
14
|
+
|
|
15
|
+
- Add finite up/down/left/right direction steps for validated camera models, optional HomeKit direction switches and the vendor Matter direction-step interface.
|
|
16
|
+
- Retain HomeKit video, real camera audio, talkback, night vision and experimental Matter camera streaming.
|
|
17
|
+
|
|
18
|
+
## 0.2.x
|
|
19
|
+
|
|
20
|
+
- Add experimental Matter Camera with WebRTC media, commissioning storage and multiple viewers sharing compatible encoders.
|
|
21
|
+
- Improve stream cleanup, negotiation and independent talkback ownership.
|
|
22
|
+
|
|
23
|
+
## 0.1.x
|
|
24
|
+
|
|
25
|
+
- Introduce the scoped standalone Homebridge platform, official Xiaomi sign-in, home/device selection and native HomeKit camera streaming.
|
|
26
|
+
- Add real PCMA/Opus listening, HomeKit talkback, validated night-vision control and local runtime import.
|
package/README.md
CHANGED
|
@@ -6,35 +6,22 @@
|
|
|
6
6
|
|
|
7
7
|
目前后端仅实现 CS2/vendor 4。已在一台小米智能摄像机 3 Pro 云台版上验证跨网络远程取流,以及真实 Apple“家庭”的配对、预览和直播。具体测试环境见下文“验证范围”;其他型号、地区和网络环境尚未逐一验证。
|
|
8
8
|
|
|
9
|
-
要求 Linux x86_64、Node.js 22
|
|
9
|
+
要求 **Linux x86_64、Node.js 22.13.1+(22.x)或 24.x、Homebridge 1.11.4+(1.x)或 2.x**,以及支持自定义插件设置页的 Homebridge UI。适用 Synology DS1618+ 上以 host 网络运行的 Homebridge Docker。运行组件提供 glibc 和 musl 版本;ARM、macOS 和 Windows 不支持。
|
|
10
|
+
|
|
11
|
+
这是独立开发的插件,与小米、Apple 和 Homebridge 项目没有隶属关系。当前尚未获得 Homebridge Verified 或 Matter 认证。
|
|
10
12
|
|
|
11
13
|
## 安装和首次导入
|
|
12
14
|
|
|
13
15
|
公开 npm 包包含插件代码和由这些源码编译的自有 Java 适配器,**首次需要从已有的本地运行包导入组件**。运行组件不随 npm 分发,也不会自动从网上下载;详情见 `THIRD_PARTY_NOTICES.md`。目前支持指定版本的本地包 `homebridge-mijia-remote-camera-0.1.0.tgz`,已有该包的用户可按以下步骤迁移。没有该本地包时,npm 包本身不能提供视频功能。
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
已有的本地 `.tgz` 文件请保留。然后在同一个管理终端安装公开包:
|
|
17
|
+
1. 在 Homebridge UI 的“插件”页搜索完整包名 `@linanw/homebridge-mijia-remote-camera` 并安装。迁移时先停用此前连接同一摄像头的 viewer/relay 或旧插件,避免重复占用运行目录。
|
|
18
|
+
2. 将你已持有的 `homebridge-mijia-remote-camera-0.1.0.tgz` 放入 **Homebridge 持久化存储目录**,容器内通常为 `/homebridge`。也支持放在该目录内的匹配版本 `assets` 目录。插件不会提供或下载这些组件。
|
|
19
|
+
3. 打开插件设置页的“导入已有的运行包”,填写存储目录内的相对路径,例如 `homebridge-mijia-remote-camera-0.1.0.tgz`,点击“导入并校验组件”,等待结果。此过程无需终端,也无需让 Homebridge 在 TTY 中启动。绝对路径也必须位于同一存储目录内;越界路径、符号链接与不匹配的组件会被拒绝。
|
|
20
|
+
4. 点击“启用并保存配置”,按界面提示从 Homebridge 首页重新启动,再回到设置页登录。
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
npm install -g @linanw/homebridge-mijia-remote-camera
|
|
25
|
-
```
|
|
22
|
+
导入只读取运行文件、核验固定 SHA-256,并保留组件的 legal 目录;目标为 `<Homebridge storage>/mijia-remote-camera/runtime`。它不导入旧包的插件代码、凭据或配置。已有有效组件会保留;目录正被运行中的插件占用时返回 `runtime_in_use`。此时先停用平台并正常重启,待旧进程退出后再导入。损坏的已有目录也不会被覆盖,请先备份后处理。
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
```sh
|
|
30
|
-
mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz --storage /homebridge
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
导入程序只读取运行文件并核验固定 SHA-256,保留组件的 legal 目录,保存到 `/homebridge/mijia-remote-camera/runtime`。它不会导入旧包的插件代码、凭据或 Homebridge 配置;当插件正在使用该目录时,会拒绝导入。重复导入会核验并保留已有组件。已有组件损坏时会保留原目录,请先将该 `runtime` 目录移走备份后重新导入。若使用其他存储位置,请把 `--storage` 改为 Homebridge 的真实存储目录。
|
|
34
|
-
|
|
35
|
-
若提示 `runtime_in_use`,请先停用当前米家平台并重启 Homebridge,待旧进程释放目录后重试。
|
|
36
|
-
|
|
37
|
-
重新启动 Homebridge,打开“米家远程摄像头”的设置页,点击“启用并保存配置”,按提示再重启一次。也可向现有 `platforms` 数组加入:
|
|
24
|
+
也可向现有 `platforms` 数组加入基本配置:
|
|
38
25
|
|
|
39
26
|
```json
|
|
40
27
|
{
|
|
@@ -44,11 +31,19 @@ mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz -
|
|
|
44
31
|
}
|
|
45
32
|
```
|
|
46
33
|
|
|
47
|
-
保留现有 Bridge、配对 PIN、其他平台和配件配置。组件导入后无需系统 Java、Python 或新 Docker 服务。npm
|
|
34
|
+
保留现有 Bridge、配对 PIN、其他平台和配件配置。组件导入后无需系统 Java、Python 或新 Docker 服务。npm 升级会保留存储目录中的组件、会话、设备选择和配件身份。
|
|
35
|
+
|
|
36
|
+
可选的命令行迁移工具仍可使用,但首次配置优先使用上述设置页:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz --storage /homebridge
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`--storage` 必须是当前 Homebridge 的真实存储目录。公开 npm 包单独安装成功不表示具备完整视频运行条件;没有匹配且有权使用的本地组件时,请勿把本插件视为可直接工作的摄像头方案。
|
|
48
43
|
|
|
49
44
|
## 登录、选择家庭和配对
|
|
50
45
|
|
|
51
|
-
在插件设置页选择与米家 App
|
|
46
|
+
在插件设置页选择与米家 App 相同的地区,生成小米官方登录链接,在浏览器中打开并按小米页面提示确认。授权后先选择自有或共享家庭,再选择摄像头并保存。插件会连接远程中继并保持连接。
|
|
52
47
|
|
|
53
48
|
已有有效插件会话时可点击“使用已有登录,读取家庭”。取消或失败的授权保留原凭据。更换已发布的摄像头需要重启 Homebridge;同一摄像头更新登录或家庭选择可以刷新连接。
|
|
54
49
|
|
|
@@ -56,6 +51,18 @@ mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz -
|
|
|
56
51
|
|
|
57
52
|
默认最高 640×360、12 fps、700 kbps。HomeKit 播放能力维持原状;Matter 默认允许两个播放窗口同时观看。0.1.2 起接入摄像头实际返回的 PCMA 或 Opus 音频,经 AAC-ELD 16 kHz 发送到 Apple“家庭”;双向对讲会将“家庭”回传的声音转换为摄像头协议,并通过同一远程会话发送。没有收到真实音频时不会生成替代声音,视频可独立播放。0.3.0 增加支持型号的上下左右单步控制;尚未接入运动事件、录像回放及 HomeKit Secure Video。
|
|
58
53
|
|
|
54
|
+
## 米家名称与房间同步(0.4.0)
|
|
55
|
+
|
|
56
|
+
插件默认每 60 秒读取所选摄像头在米家中的名称、家庭及所属房间,也可在设置页立即刷新或暂停自动同步。米家 App 中的修改会更新插件资料、同一 HomeKit 配件的名称信息和 Matter 的厂商资料接口,无需重建配件或重启视频。Apple“家庭”可能保留它自己保存的显示名称和房间,插件不会自动重排 Apple 家庭。
|
|
57
|
+
|
|
58
|
+
设置页及支持本插件厂商接口的 Matter 客户端可修改**所选摄像头的米家设备名**,或将它移到**同一米家家庭中已存在的房间**。写回仅对当前登录账号拥有的家庭开放;共享家庭保持只读。账号无写回权限或当前运行版本未启用写回能力时,编辑按钮保持禁用。当前不创建/重命名房间,不跨家庭移动,也不把摄像头移到“未分房”。
|
|
59
|
+
|
|
60
|
+
每次修改会先刷新米家资料并检查你编辑时的版本,再发送一次写请求,随后独立回读确认。资料已经变化时保留草稿并提示冲突;超时或回读失败会显示“结果尚未确认”,请先刷新核对,避免重复提交。自动轮询只读取资料。账号、地区或设备选择改变后,旧请求和旧草稿不能写入新的选择。
|
|
61
|
+
|
|
62
|
+
米家名称最多 160 个 UTF-16 代码单元;本地传输和校验完整保留有效 Unicode,拒绝首尾空白与控制字符,不自动截短。米家云端还有自己的名称规则,可能改写或去除字符,只有按原目标权威回读一致才算确认;目标与回读名称不一致时返回 `metadata_not_applied`,不会冒称成功。实机中文改名/恢复已通过,emoji 曾被云端去除并触发上述保护。发生这种情况请先刷新核对实际名称,插件不会自动重试或回滚。Matter 厂商资料接口的字符串另受 **160 个 UTF-8 字节**限制;超出时该投影标记为资料不完整,不截断后继续写入。普通 Matter 控制器可能不显示此接口。
|
|
63
|
+
|
|
64
|
+
**Apple“家庭”内改名/移动房间再写回米家暂未开放。** 双向资料同步目前指米家 App 与插件设置页/支持的 Matter 厂商接口之间的读写;Apple 的房间分配属于另一套家庭资料。
|
|
65
|
+
|
|
59
66
|
## 声音、对讲与夜视
|
|
60
67
|
|
|
61
68
|
默认开启现场声音、双向对讲和夜视开关。升级后重启摄像头子桥即可,保留已有 HomeKit 配对。在“家庭”直播页面使用对讲按钮,并允许 iPhone 访问麦克风;松开或结束对讲后会停止向摄像头发送声音。声音静音和音量控制会作用于实际音频转发。插件设置页显示是否已收到真实摄像头声音,以及当前协议是否支持对讲。
|
|
@@ -116,7 +123,11 @@ Matter 信令及 WebRTC 播放目前面向同一局域网,无外部 STUN/TUR
|
|
|
116
123
|
|
|
117
124
|
实际设备验证使用一台小米智能摄像机 3 Pro 云台版,摄像头与 NAS 位于不同网络。Synology DS1618+ 上的 Homebridge Docker 使用 host 网络,Homebridge 2.0.2 / HAP 2.1.6;远程源为 HEVC,由 NAS 解码并转换为 HomeKit 视频。用户已确认 iOS 26.6.2 的 Apple“家庭”预览和直播正常。0.1.2 在同一台摄像头上确认收到真实 Opus 48 kHz 双声道音频并成功独立解码;HomeKit 音频发送和手机回传的对讲音频也已在实机日志中确认,摄像头扬声器的可听效果仍需现场确认。夜视已在实机通过读取、关闭、回读确认、恢复原模式的往返验证,操作约需 0.6–2.2 秒。
|
|
118
125
|
|
|
119
|
-
|
|
126
|
+
开发测试使用 Homebridge 1.11.4/2.x,当前开发依赖为 2.4.0。本地测试另覆盖 Homebridge 1/2 的原生 HAP CameraController、加密快照、视频与双向音频的 SRTP 发送/独立接收解码、夜视读取与写后回读、进程清理、授权取消与原子保存、家庭作用域、真实 Homebridge 设置页 IPC 和本地 Java 启动。
|
|
127
|
+
|
|
128
|
+
0.4.0 在所选自有家庭完成真实云端往返:中文临时名称写入、独立读取并恢复原名;移到同家庭另一个现有房间、独立读取并恢复原房间。最终名称/房间和原凭据均保持原值。云端去除 emoji 的案例未被误报为确认成功。该验证不覆盖所有名称字符、家庭权限或摄像头型号。
|
|
129
|
+
|
|
130
|
+
2026-10-24 的完整本地测试在 Node 22.22.1 和 Node 24.19.0、Homebridge 2.4.0 下分别为 325 项:324 通过、1 项仅 root 用户适用的所有权测试跳过、0 失败;包含私有组件和独立 Matter 控制器集成。无私有组件的公开源码测试另见 [验证记录](docs/verification.md),两者不能互相替代。
|
|
120
131
|
|
|
121
132
|
远程中继仍可能间歇断开并自动重连,重连期间预览和直播可能暂不可用。目前尚未完成长时间稳定性验证。
|
|
122
133
|
|
|
@@ -131,3 +142,5 @@ mijia-camera-diagnose --storage /var/lib/homebridge
|
|
|
131
142
|
`--storage` 必须是实例真实的存储目录,也可能是 `/homebridge`。输出不包含小米凭据、设备 ID 或视频地址。HomeKit 快照请求不会跳过连接重试间隔。
|
|
132
143
|
|
|
133
144
|
能在插件中收到视频、但 Apple“家庭”添加失败时,请检查摄像头日志中的独立 HomeKit 端口及 Setup Code;摄像头端口与主桥/子桥端口不同。
|
|
145
|
+
|
|
146
|
+
公开仓库与认证准备、兼容性矩阵及已知限制见 [verification.md](docs/verification.md),组件权利说明见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -5,11 +5,11 @@ contain the micam backend, third-party Java runtime, FFmpeg binary, or
|
|
|
5
5
|
ffmpeg-for-homebridge installer. The runtime manifest contains provenance and
|
|
6
6
|
SHA-256 identifiers only; it does not grant rights to the identified components.
|
|
7
7
|
The included `backend/bridge.jar` contains only the original MIT-licensed
|
|
8
|
-
`mijia.bridge
|
|
8
|
+
`mijia.bridge` adapter classes built from the supplied Java source.
|
|
9
9
|
No micam or Jackson implementation classes are included in that JAR.
|
|
10
10
|
|
|
11
|
-
The
|
|
12
|
-
machine. It does not download them or upload them to npm. Local runtime files
|
|
11
|
+
The Homebridge settings page and optional local setup command import runtime
|
|
12
|
+
files already available on the user's machine. It does not download them or upload them to npm. Local runtime files
|
|
13
13
|
are separate from the package and retain their original rights and notices.
|
|
14
14
|
|
|
15
15
|
- micam, commit `2cc9d9e7607014d1d05c17bc5676544fbb0d5d24`, with local
|
|
@@ -38,3 +38,11 @@ attestation credentials are supplied by the SDK. This package is an experimental
|
|
|
38
38
|
development device and makes no certification claim. WebRTC media encryption and
|
|
39
39
|
signaling use these libraries; FFmpeg conversion continues to use the separately
|
|
40
40
|
imported local binary described above.
|
|
41
|
+
|
|
42
|
+
The Xiaomi cloud metadata request schemas were independently established from
|
|
43
|
+
static inspection of the public official Mi Home Android application, version
|
|
44
|
+
11.8.707. No APK, DEX, decompiled implementation, Xiaomi application code, or
|
|
45
|
+
third-party application binary is included in this repository or npm package.
|
|
46
|
+
The adapter implements the narrow rename and existing-room move requests itself.
|
|
47
|
+
Using a local runtime does not establish permission to redistribute it. Users
|
|
48
|
+
must have the right to use their existing files and comply with their notices.
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
package mijia.bridge;
|
|
2
|
+
|
|
3
|
+
import com.fasterxml.jackson.databind.JsonNode;
|
|
4
|
+
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
5
|
+
import micam.cloud.Device;
|
|
6
|
+
import micam.cloud.Home;
|
|
7
|
+
import micam.cloud.HomeApiException;
|
|
8
|
+
import micam.cloud.MiCloud;
|
|
9
|
+
import micam.creds.CredentialStore;
|
|
10
|
+
import micam.creds.Credentials;
|
|
11
|
+
|
|
12
|
+
import java.io.OutputStream;
|
|
13
|
+
import java.io.PrintStream;
|
|
14
|
+
import java.math.BigInteger;
|
|
15
|
+
import java.nio.file.Files;
|
|
16
|
+
import java.nio.file.LinkOption;
|
|
17
|
+
import java.nio.file.Path;
|
|
18
|
+
import java.nio.charset.StandardCharsets;
|
|
19
|
+
import java.nio.charset.CodingErrorAction;
|
|
20
|
+
import java.nio.ByteBuffer;
|
|
21
|
+
import java.security.MessageDigest;
|
|
22
|
+
import java.util.ArrayList;
|
|
23
|
+
import java.util.Base64;
|
|
24
|
+
import java.util.HashSet;
|
|
25
|
+
import java.util.HexFormat;
|
|
26
|
+
import java.util.LinkedHashMap;
|
|
27
|
+
import java.util.List;
|
|
28
|
+
import java.util.Map;
|
|
29
|
+
import java.util.Objects;
|
|
30
|
+
import java.util.Set;
|
|
31
|
+
import java.util.TreeMap;
|
|
32
|
+
import java.util.TreeSet;
|
|
33
|
+
import java.util.function.Supplier;
|
|
34
|
+
|
|
35
|
+
/** Selected-camera metadata reads and explicit, guarded, single-dispatch cloud writes. */
|
|
36
|
+
public final class CloudMetadata {
|
|
37
|
+
private static final ObjectMapper JSON = new ObjectMapper();
|
|
38
|
+
private static final PrintStream OUTPUT = System.out;
|
|
39
|
+
private static final Set<String> REGIONS = Set.of("cn", "sg", "de", "us", "ru", "i2");
|
|
40
|
+
private static final Set<String> SAFE_REASONS = Set.of("session_expired", "authentication_required",
|
|
41
|
+
"invalid_home_selection", "invalid_home_response", "missing_home_owner", "invalid_device_response",
|
|
42
|
+
"device_pagination_incomplete", "home_request_failed", "home_request_rejected");
|
|
43
|
+
|
|
44
|
+
static final class Failure extends RuntimeException {
|
|
45
|
+
final String reason;
|
|
46
|
+
Failure(String reason) { super(reason); this.reason = reason; }
|
|
47
|
+
}
|
|
48
|
+
@FunctionalInterface
|
|
49
|
+
interface Gateway { JsonNode call(String path, Object payload); }
|
|
50
|
+
private static final class Room {
|
|
51
|
+
final String id;
|
|
52
|
+
String name;
|
|
53
|
+
final Set<String> dids = new TreeSet<>();
|
|
54
|
+
Room(String id, String name) { this.id = id; this.name = name; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public static void main(String[] args) {
|
|
58
|
+
System.setOut(new PrintStream(OutputStream.nullOutputStream()));
|
|
59
|
+
System.setErr(new PrintStream(OutputStream.nullOutputStream()));
|
|
60
|
+
int exit;
|
|
61
|
+
try { exit = run(args); }
|
|
62
|
+
catch (Failure failure) { emit(Map.of("success", false, "reason", failure.reason)); exit = auth(failure.reason) ? 2 : 7; }
|
|
63
|
+
catch (HomeApiException failure) {
|
|
64
|
+
String reason = SAFE_REASONS.contains(failure.reason()) ? failure.reason() : "metadata_unavailable";
|
|
65
|
+
emit(Map.of("success", false, "reason", reason)); exit = auth(reason) ? 2 : 7;
|
|
66
|
+
}
|
|
67
|
+
catch (Exception failure) { emit(Map.of("success", false, "reason", "metadata_unavailable")); exit = 7; }
|
|
68
|
+
System.exit(exit);
|
|
69
|
+
}
|
|
70
|
+
private static boolean auth(String reason) { return reason.equals("authentication_required") || reason.equals("session_expired"); }
|
|
71
|
+
|
|
72
|
+
static int run(String[] args) {
|
|
73
|
+
validateArgs(args);
|
|
74
|
+
Credentials credentials;
|
|
75
|
+
try { credentials = new CredentialStore().load(); }
|
|
76
|
+
catch (RuntimeException invalid) { throw new Failure("authentication_required"); }
|
|
77
|
+
// This helper never renews a login or writes credentials. Bind before any cloud request.
|
|
78
|
+
if (!credentials.hasSession() || !args[1].equals(credentials.region)
|
|
79
|
+
|| !args[5].equals(credentials.userId)) throw new Failure("authentication_required");
|
|
80
|
+
MiCloud cloud = new MiCloud(credentials);
|
|
81
|
+
Home selected = new Home(args[2], args[3], "", false);
|
|
82
|
+
Map<String, Object> value;
|
|
83
|
+
if (args[0].equals("get")) {
|
|
84
|
+
value = readMetadata(cloud::call, args[5], args[1], selected, args[4], () -> cloud.deviceList(selected));
|
|
85
|
+
} else {
|
|
86
|
+
String action = args[0].equals("rename64") ? "rename" : args[0];
|
|
87
|
+
String requested = args[0].equals("rename64") ? decodeRename(args[7]) : args[7];
|
|
88
|
+
String account = credentials.userId, region = credentials.region;
|
|
89
|
+
String security = credentials.ssecurity, token = credentials.serviceToken;
|
|
90
|
+
Runnable sessionGuard = () -> {
|
|
91
|
+
Credentials current;
|
|
92
|
+
try { current = new CredentialStore().load(); }
|
|
93
|
+
catch (RuntimeException invalid) { throw new Failure("metadata_conflict"); }
|
|
94
|
+
if (!current.hasSession() || !Objects.equals(account, current.userId) || !Objects.equals(region, current.region)
|
|
95
|
+
|| !Objects.equals(security, current.ssecurity) || !Objects.equals(token, current.serviceToken)) {
|
|
96
|
+
throw new Failure("metadata_conflict");
|
|
97
|
+
}
|
|
98
|
+
assertStoredSelection(args[1], args[2], args[3], args[4]);
|
|
99
|
+
};
|
|
100
|
+
value = writeMetadata(cloud::call, args[5], args[1], selected, args[4], () -> cloud.deviceList(selected),
|
|
101
|
+
action, args[6], requested, sessionGuard);
|
|
102
|
+
}
|
|
103
|
+
value.put("success", true);
|
|
104
|
+
emit(value);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
static void validateArgs(String[] args) {
|
|
109
|
+
if (args.length < 2 || !REGIONS.contains(args[1])
|
|
110
|
+
|| !(args[0].equals("get") && args.length == 6
|
|
111
|
+
|| Set.of("rename", "rename64", "move").contains(args[0]) && args.length == 8)) throw new Failure("invalid_request");
|
|
112
|
+
for (int index = 2; index < 6; index++) {
|
|
113
|
+
if (!args[index].matches("(0|[1-9][0-9]{0,29})")) throw new Failure("invalid_request");
|
|
114
|
+
}
|
|
115
|
+
if (args.length == 8) {
|
|
116
|
+
validateWrite(args[0].equals("rename64") ? "rename" : args[0], args[6],
|
|
117
|
+
args[0].equals("rename64") ? decodeRename(args[7]) : args[7]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** ASCII-only process transport avoids locale-dependent native argv decoding. */
|
|
122
|
+
static String decodeRename(String encoded) {
|
|
123
|
+
if (encoded == null || encoded.isEmpty() || encoded.length() > 640) throw new Failure("invalid_request");
|
|
124
|
+
try {
|
|
125
|
+
byte[] bytes = Base64.getDecoder().decode(encoded);
|
|
126
|
+
if (!Base64.getEncoder().encodeToString(bytes).equals(encoded)) throw new Failure("invalid_request");
|
|
127
|
+
return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
|
|
128
|
+
.onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString();
|
|
129
|
+
} catch (Exception invalid) { throw new Failure("invalid_request"); }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private static void validateWrite(String action, String baseline, String requested) {
|
|
133
|
+
if (!Set.of("rename", "move").contains(action) || baseline == null || !baseline.matches("[a-f0-9]{64}")
|
|
134
|
+
|| requested == null) throw new Failure("invalid_request");
|
|
135
|
+
if (action.equals("move")) {
|
|
136
|
+
if (!requested.matches("(0|[1-9][0-9]{0,29})")) throw new Failure("invalid_request");
|
|
137
|
+
} else if (requested.isEmpty() || requested.length() > 160
|
|
138
|
+
|| trimBoundary(requested.codePointAt(0)) || trimBoundary(requested.codePointBefore(requested.length()))
|
|
139
|
+
|| requested.chars().anyMatch(Character::isISOControl)
|
|
140
|
+
|| requested.codePoints().anyMatch(value -> value >= 0xd800 && value <= 0xdfff)) {
|
|
141
|
+
throw new Failure("invalid_request");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private static boolean trimBoundary(int value) {
|
|
146
|
+
return Character.isWhitespace(value) || Character.isSpaceChar(value) || value == 0xfeff;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private static void assertStoredSelection(String region, String home, String owner, String did) {
|
|
150
|
+
Path file = Path.of(System.getProperty("user.home"), "selection.json");
|
|
151
|
+
try {
|
|
152
|
+
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new Failure("metadata_conflict");
|
|
153
|
+
JsonNode selected;
|
|
154
|
+
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) {
|
|
155
|
+
byte[] bytes = input.readNBytes(16385);
|
|
156
|
+
if (bytes.length > 16384) throw new Failure("metadata_conflict");
|
|
157
|
+
selected = JSON.readTree(bytes);
|
|
158
|
+
}
|
|
159
|
+
if (selected == null || !selected.isObject() || !region.equals(selected.path("region").asText())
|
|
160
|
+
|| !home.equals(selected.path("homeId").asText()) || !owner.equals(selected.path("homeOwner").asText())
|
|
161
|
+
|| !did.equals(selected.path("deviceId").asText())) throw new Failure("metadata_conflict");
|
|
162
|
+
} catch (Exception invalid) { throw new Failure("metadata_conflict"); }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Cross-language revision guard: JSON array layout matches metadataFingerprint in the Node runtime. */
|
|
166
|
+
static String fingerprint(Map<String, Object> value) {
|
|
167
|
+
try {
|
|
168
|
+
List<Object> rooms = new ArrayList<>();
|
|
169
|
+
for (Object item : (List<?>) value.get("rooms")) {
|
|
170
|
+
Map<?, ?> room = (Map<?, ?>) item;
|
|
171
|
+
rooms.add(List.of(room.get("id"), room.get("name")));
|
|
172
|
+
}
|
|
173
|
+
// Jackson's UTF-8 byte generator escapes supplementary characters, unlike JSON.stringify.
|
|
174
|
+
// Its String generator preserves surrogate pairs; encode that result once as UTF-8.
|
|
175
|
+
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsString(List.of(
|
|
176
|
+
value.get("deviceId"), value.get("homeId"), value.get("homeOwner"), value.get("region"), value.get("name"),
|
|
177
|
+
value.get("homeName"), value.get("roomId"), value.get("roomName"), rooms)).getBytes(StandardCharsets.UTF_8)));
|
|
178
|
+
} catch (Exception invalid) { throw new Failure("metadata_unavailable"); }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Protocol fields established by static inspection of the official Mi Home 11.8.707 APK. */
|
|
182
|
+
static Map<String, Object> writeMetadata(Gateway gateway, String accountId, String region, Home selected,
|
|
183
|
+
String did, Supplier<List<Device>> deviceLookup, String action, String baseline, String requested,
|
|
184
|
+
Runnable sessionGuard) {
|
|
185
|
+
validateWrite(action, baseline, requested);
|
|
186
|
+
if (!REGIONS.contains(region) || selected == null || accountId == null || did == null
|
|
187
|
+
|| !accountId.matches("(0|[1-9][0-9]{0,29})") || !did.matches("(0|[1-9][0-9]{0,29})")) {
|
|
188
|
+
throw new Failure("invalid_request");
|
|
189
|
+
}
|
|
190
|
+
if (!accountId.equals(selected.ownerId())) throw new Failure("metadata_not_owner");
|
|
191
|
+
sessionGuard.run();
|
|
192
|
+
Map<String, Object> before = readMetadata(gateway, accountId, region, selected, did, deviceLookup);
|
|
193
|
+
if (!fingerprint(before).equals(baseline)) throw new Failure("metadata_conflict");
|
|
194
|
+
if (action.equals("move")) {
|
|
195
|
+
boolean existing = ((List<?>) before.get("rooms")).stream()
|
|
196
|
+
.anyMatch(value -> requested.equals(((Map<?, ?>) value).get("id")));
|
|
197
|
+
// Only an existing room in this exact home is supported; no home-root move or creation.
|
|
198
|
+
if (!existing || requested.equals(selected.id())) throw new Failure("invalid_request");
|
|
199
|
+
}
|
|
200
|
+
sessionGuard.run();
|
|
201
|
+
String path = action.equals("rename") ? "/device/rename" : "/homeroom/bind_device_to_room";
|
|
202
|
+
Map<String, Object> payload = action.equals("rename")
|
|
203
|
+
? Map.of("app_version", 1, "newName", requested, "did", did, "is_group", false)
|
|
204
|
+
: Map.of("id", requested, "did", did, "home_owner", new BigInteger(selected.ownerId()));
|
|
205
|
+
Map<String, Object> after;
|
|
206
|
+
// From dispatch onward, any ambiguous failure may have occurred after the server committed.
|
|
207
|
+
// Never retry a write, renew the login, or report that a timed-out mutation was not applied.
|
|
208
|
+
try {
|
|
209
|
+
JsonNode response = gateway.call(path, payload);
|
|
210
|
+
if (response == null || !response.isObject() || !response.path("code").isIntegralNumber()
|
|
211
|
+
|| !response.path("code").canConvertToInt() || response.path("code").intValue() != 0) {
|
|
212
|
+
throw new Failure("metadata_unconfirmed");
|
|
213
|
+
}
|
|
214
|
+
sessionGuard.run();
|
|
215
|
+
after = readMetadata(gateway, accountId, region, selected, did, deviceLookup);
|
|
216
|
+
sessionGuard.run();
|
|
217
|
+
} catch (RuntimeException uncertain) { throw new Failure("metadata_unconfirmed"); }
|
|
218
|
+
if (!requested.equals(after.get(action.equals("rename") ? "name" : "roomId"))) {
|
|
219
|
+
throw new Failure("metadata_not_applied");
|
|
220
|
+
}
|
|
221
|
+
after.put("confirmed", true);
|
|
222
|
+
return after;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Schema and expansion follow XiaoMi/ha_xiaomi_home miot_cloud.py get_homeinfos_async. */
|
|
226
|
+
static Map<String, Object> readMetadata(Gateway gateway, String accountId, String region, Home selected,
|
|
227
|
+
String did, Supplier<List<Device>> deviceLookup) {
|
|
228
|
+
JsonNode root = result(gateway, "/v2/homeroom/gethome", Map.of("limit", 150,
|
|
229
|
+
"fetch_share", true, "fetch_share_dev", true, "plat_form", 0, "app_ver", 9));
|
|
230
|
+
JsonNode home = null;
|
|
231
|
+
for (String source : List.of("homelist", "share_home_list")) {
|
|
232
|
+
JsonNode homes = root.get(source);
|
|
233
|
+
if (homes == null || homes.isNull()) continue;
|
|
234
|
+
if (!homes.isArray() || homes.size() > 1000) throw new Failure("invalid_home_response");
|
|
235
|
+
for (JsonNode candidate : homes) {
|
|
236
|
+
if (!candidate.isObject()) throw new Failure("invalid_home_response");
|
|
237
|
+
if (!selected.id().equals(identifier(candidate.get("id")))) continue;
|
|
238
|
+
JsonNode owner = candidate.hasNonNull("uid") ? candidate.get("uid") : candidate.get("home_owner");
|
|
239
|
+
String ownerId;
|
|
240
|
+
if (owner == null || owner.isNull()) {
|
|
241
|
+
if (source.equals("share_home_list")) throw new Failure("invalid_home_response");
|
|
242
|
+
ownerId = accountId;
|
|
243
|
+
} else ownerId = identifier(owner);
|
|
244
|
+
if (!selected.ownerId().equals(ownerId)) continue;
|
|
245
|
+
if (home != null) throw new Failure("invalid_home_response");
|
|
246
|
+
home = candidate;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (home == null) throw new Failure("invalid_home_selection");
|
|
250
|
+
String homeName = text(home.get("name"));
|
|
251
|
+
Set<String> homeDids = new TreeSet<>();
|
|
252
|
+
appendHomeDids(homeDids, home.get("dids"));
|
|
253
|
+
Map<String, Room> rooms = new TreeMap<>();
|
|
254
|
+
appendRooms(rooms, home.get("roomlist"), true);
|
|
255
|
+
Set<String> cursors = new HashSet<>();
|
|
256
|
+
JsonNode page = root;
|
|
257
|
+
int pages = 0;
|
|
258
|
+
while (more(page)) {
|
|
259
|
+
if (++pages > 100) throw new Failure("metadata_incomplete");
|
|
260
|
+
JsonNode cursorNode = page.get("max_id");
|
|
261
|
+
if (cursorNode == null || !(cursorNode.isTextual() || cursorNode.isIntegralNumber())) throw new Failure("metadata_incomplete");
|
|
262
|
+
String cursor = cursorNode.asText();
|
|
263
|
+
if (cursor.isBlank() || cursor.length() > 128 || cursor.chars().anyMatch(Character::isISOControl)
|
|
264
|
+
|| !cursors.add(cursor)) throw new Failure("metadata_incomplete");
|
|
265
|
+
page = result(gateway, "/v2/homeroom/get_dev_room_page", Map.of("start_id", cursor, "limit", 150));
|
|
266
|
+
JsonNode homes = page.get("info");
|
|
267
|
+
if (homes == null || !homes.isArray() || homes.size() > 1000) throw new Failure("metadata_incomplete");
|
|
268
|
+
for (JsonNode candidate : homes) {
|
|
269
|
+
if (!candidate.isObject()) throw new Failure("metadata_incomplete");
|
|
270
|
+
if (selected.id().equals(identifier(candidate.get("id")))) {
|
|
271
|
+
appendHomeDids(homeDids, candidate.get("dids"));
|
|
272
|
+
appendRooms(rooms, candidate.get("roomlist"), false);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
Device camera = null;
|
|
277
|
+
List<Device> devices;
|
|
278
|
+
try { devices = deviceLookup.get(); }
|
|
279
|
+
catch (HomeApiException known) { throw known; }
|
|
280
|
+
catch (RuntimeException ignored) { throw new Failure("metadata_unavailable"); }
|
|
281
|
+
if (devices == null || devices.size() > 20000) throw new Failure("invalid_device_response");
|
|
282
|
+
for (Device device : devices) {
|
|
283
|
+
if (!did.equals(device.did())) continue;
|
|
284
|
+
if (camera != null || !device.isCamera()) throw new Failure("camera_not_in_home");
|
|
285
|
+
camera = device;
|
|
286
|
+
}
|
|
287
|
+
if (camera == null) throw new Failure("camera_not_in_home");
|
|
288
|
+
String roomId = "", roomName = "";
|
|
289
|
+
int total = 0;
|
|
290
|
+
List<Map<String, Object>> outputRooms = new ArrayList<>();
|
|
291
|
+
for (Room room : rooms.values()) {
|
|
292
|
+
if (room.name == null) throw new Failure("metadata_incomplete");
|
|
293
|
+
total += room.dids.size();
|
|
294
|
+
if (total > 20000) throw new Failure("metadata_incomplete");
|
|
295
|
+
if (room.dids.contains(did)) {
|
|
296
|
+
if (!roomId.isEmpty()) throw new Failure("metadata_unavailable");
|
|
297
|
+
roomId = room.id; roomName = room.name;
|
|
298
|
+
}
|
|
299
|
+
outputRooms.add(Map.of("id", room.id, "name", room.name));
|
|
300
|
+
}
|
|
301
|
+
// Absence from every room is only "unassigned" when the home-level DID
|
|
302
|
+
// expansion explicitly includes this camera. Missing metadata is not a room move.
|
|
303
|
+
if (roomId.isEmpty() && !homeDids.contains(did)) throw new Failure("metadata_incomplete");
|
|
304
|
+
Map<String, Object> output = new LinkedHashMap<>();
|
|
305
|
+
output.put("accountId", accountId); output.put("region", region);
|
|
306
|
+
output.put("homeId", selected.id()); output.put("homeOwner", selected.ownerId()); output.put("deviceId", did);
|
|
307
|
+
output.put("name", text(JSON.valueToTree(camera.name()))); output.put("homeName", homeName);
|
|
308
|
+
output.put("roomId", roomId); output.put("roomName", roomName); output.put("rooms", outputRooms);
|
|
309
|
+
return output;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private static void appendHomeDids(Set<String> dids, JsonNode list) {
|
|
313
|
+
if (list == null || list.isNull()) return;
|
|
314
|
+
if (!list.isArray() || list.size() > 20000) throw new Failure("metadata_incomplete");
|
|
315
|
+
for (JsonNode value : list) {
|
|
316
|
+
if (!(value.isTextual() || value.isIntegralNumber())) throw new Failure("metadata_incomplete");
|
|
317
|
+
String did = value.asText();
|
|
318
|
+
if (did.isBlank() || did.length() > 128 || did.chars().anyMatch(Character::isISOControl)) throw new Failure("metadata_incomplete");
|
|
319
|
+
dids.add(did);
|
|
320
|
+
}
|
|
321
|
+
if (dids.size() > 20000) throw new Failure("metadata_incomplete");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private static void appendRooms(Map<String, Room> rooms, JsonNode list, boolean initial) {
|
|
325
|
+
if (list == null || !list.isArray()) throw new Failure("metadata_incomplete");
|
|
326
|
+
Set<String> seen = new HashSet<>();
|
|
327
|
+
for (JsonNode value : list) {
|
|
328
|
+
if (!value.isObject()) throw new Failure("metadata_incomplete");
|
|
329
|
+
String id = identifier(value.get("id"));
|
|
330
|
+
if (!seen.add(id)) throw new Failure("metadata_incomplete");
|
|
331
|
+
String name = value.has("name") ? text(value.get("name")) : null;
|
|
332
|
+
if (initial && name == null) throw new Failure("metadata_incomplete");
|
|
333
|
+
Room room = rooms.computeIfAbsent(id, key -> new Room(key, name));
|
|
334
|
+
if (name != null && room.name != null && !name.equals(room.name)) throw new Failure("metadata_incomplete");
|
|
335
|
+
if (name != null) room.name = name;
|
|
336
|
+
JsonNode dids = value.get("dids");
|
|
337
|
+
if (dids == null || !dids.isArray() || dids.size() > 20000) throw new Failure("metadata_incomplete");
|
|
338
|
+
for (JsonNode did : dids) {
|
|
339
|
+
if (!(did.isTextual() || did.isIntegralNumber())) throw new Failure("metadata_incomplete");
|
|
340
|
+
String text = did.asText();
|
|
341
|
+
if (text.isBlank() || text.length() > 128 || text.chars().anyMatch(Character::isISOControl)) throw new Failure("metadata_incomplete");
|
|
342
|
+
room.dids.add(text);
|
|
343
|
+
}
|
|
344
|
+
if (room.dids.size() > 20000 || rooms.size() > 1000) throw new Failure("metadata_incomplete");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
private static boolean more(JsonNode value) {
|
|
348
|
+
JsonNode more = value.get("has_more");
|
|
349
|
+
if (more == null || more.isNull()) return false;
|
|
350
|
+
if (more.isBoolean()) return more.booleanValue();
|
|
351
|
+
if (more.isIntegralNumber() && more.canConvertToInt() && (more.intValue() == 0 || more.intValue() == 1)) return more.intValue() == 1;
|
|
352
|
+
throw new Failure("metadata_incomplete");
|
|
353
|
+
}
|
|
354
|
+
private static String identifier(JsonNode value) {
|
|
355
|
+
if (value == null || !(value.isTextual() || value.isIntegralNumber()) || !value.asText().matches("[0-9]{1,30}")) throw new Failure("invalid_home_response");
|
|
356
|
+
return new BigInteger(value.asText()).toString();
|
|
357
|
+
}
|
|
358
|
+
private static String text(JsonNode value) {
|
|
359
|
+
if (value == null || !value.isTextual() || value.textValue().length() > 1024) throw new Failure("metadata_unavailable");
|
|
360
|
+
String text = value.textValue().replaceAll("\\p{Cc}", " ").strip();
|
|
361
|
+
return text.substring(0, Math.min(text.length(), 160));
|
|
362
|
+
}
|
|
363
|
+
private static JsonNode result(Gateway gateway, String path, Object payload) {
|
|
364
|
+
JsonNode response;
|
|
365
|
+
try { response = gateway.call(path, payload); }
|
|
366
|
+
catch (HomeApiException known) { throw known; }
|
|
367
|
+
catch (RuntimeException ignored) { throw new Failure("metadata_unavailable"); }
|
|
368
|
+
if (response == null || !response.isObject()) throw new Failure("metadata_unavailable");
|
|
369
|
+
JsonNode code = response.get("code");
|
|
370
|
+
String message = response.path("message").asText("");
|
|
371
|
+
if (code != null && code.isIntegralNumber() && code.canConvertToInt() && code.intValue() == 3
|
|
372
|
+
|| message.equals("SERVICETOKEN_EXPIRED") || message.equals("serviceToken expired")) throw new Failure("session_expired");
|
|
373
|
+
if (code == null || !code.isIntegralNumber() || !code.canConvertToInt()) throw new Failure("metadata_unavailable");
|
|
374
|
+
if (code.intValue() != 0) throw new Failure("home_request_rejected");
|
|
375
|
+
JsonNode result = response.get("result");
|
|
376
|
+
if (result == null || !result.isObject()) throw new Failure("metadata_unavailable");
|
|
377
|
+
return result;
|
|
378
|
+
}
|
|
379
|
+
private static synchronized void emit(Object value) {
|
|
380
|
+
try {
|
|
381
|
+
// Container stdout may use US-ASCII even when the process default charset is UTF-8.
|
|
382
|
+
OUTPUT.write((JSON.writeValueAsString(value) + "\n").getBytes(StandardCharsets.UTF_8));
|
|
383
|
+
OUTPUT.flush();
|
|
384
|
+
} catch (Exception ignored) { }
|
|
385
|
+
}
|
|
386
|
+
}
|
package/backend/bridge.jar
CHANGED
|
Binary file
|