@linanw/homebridge-mijia-remote-camera 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +76 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/backend/Bridge.java +216 -0
- package/bin/setup.mjs +29 -0
- package/config.schema.json +74 -0
- package/homebridge-ui/public/index.html +68 -0
- package/homebridge-ui/server.js +88 -0
- package/index.mjs +2 -0
- package/lib/auth.mjs +281 -0
- package/lib/camera.mjs +294 -0
- package/lib/platform.mjs +98 -0
- package/lib/process-lock.mjs +101 -0
- package/lib/rpc.mjs +117 -0
- package/lib/runtime-assets.mjs +265 -0
- package/lib/runtime-factory.mjs +104 -0
- package/lib/runtime.mjs +356 -0
- package/npm-shrinkwrap.json +671 -0
- package/package.json +55 -4
- package/runtime-manifest.json +250 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 linanw
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,77 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 米家远程摄像头:独立 Homebridge 插件
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
插件:`@linanw/homebridge-mijia-remote-camera`,平台:`MijiaRemoteCamera`。
|
|
4
|
+
|
|
5
|
+
登录、家庭选择、远程摄像头连接、快照和 HomeKit 视频由插件管理,无需独立的 viewer / relay 容器或另一个摄像头插件。摄像头可以位于其他网络。
|
|
6
|
+
|
|
7
|
+
这是实验性接入,目前后端仅实现 CS2/vendor 4。**尚未在小米智能摄像机 3 Pro 云台版上验证实际出画面,也未完成真实 Apple 家庭配对。** npm 安装成功不代表该型号受支持。
|
|
8
|
+
|
|
9
|
+
要求 Linux x86_64、Node.js 22 或 24、Homebridge 1.9+ 或 2.x,以及支持自定义插件设置页的 Homebridge UI。适用 Synology DS1618+ 上以 host 网络运行的 Homebridge Docker。支持 glibc 和 musl;其他架构不支持。
|
|
10
|
+
|
|
11
|
+
## 安装和首次导入
|
|
12
|
+
|
|
13
|
+
公开 npm 包仅包含插件代码,**首次需要从已有的本地运行包导入组件**。运行组件不随 npm 分发,也不会自动从网上下载;详情见 `THIRD_PARTY_NOTICES.md`。目前支持指定版本的本地包 `homebridge-mijia-remote-camera-0.1.0.tgz`,已有该包的用户可按以下步骤迁移。没有该本地包时,npm 包本身不能提供视频功能。
|
|
14
|
+
|
|
15
|
+
先停止此前连接这台摄像头的 `viewer` / `relay` 容器。若已经安装旧的无 scope 插件,先在 Homebridge 管理界面的终端内卸载并重启 Homebridge,释放旧进程:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm uninstall -g homebridge-mijia-remote-camera
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
已有的本地 `.tgz` 文件请保留。然后在同一个管理终端安装公开包:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install -g @linanw/homebridge-mijia-remote-camera
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
将已有的 `homebridge-mijia-remote-camera-0.1.0.tgz` 放入 Homebridge 持久化目录,容器内通常为 `/homebridge`。在首次启用新插件之前执行一次:
|
|
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` 数组加入:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"platform": "MijiaRemoteCamera",
|
|
42
|
+
"name": "米家远程摄像头",
|
|
43
|
+
"region": "cn"
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
保留现有 Bridge、配对 PIN、其他平台和配件配置。组件导入后无需系统 Java、Python 或新 Docker 服务。npm 升级会保留存储目录中的组件、会话和设备选择;兼容旧插件的配件 UUID。
|
|
48
|
+
|
|
49
|
+
## 登录、选择家庭和配对
|
|
50
|
+
|
|
51
|
+
在插件设置页选择与米家 App 相同的地区,生成小米官方登录链接,在已登录的 Edge 中打开并确认。授权后先选择自有或共享家庭,再选择摄像头并保存。插件会连接远程中继并保持连接。
|
|
52
|
+
|
|
53
|
+
已有有效插件会话时可点击“使用已有登录,读取家庭”。取消或失败的授权保留原凭据。更换已发布的摄像头需要重启 Homebridge;同一摄像头更新登录或家庭选择可以刷新连接。
|
|
54
|
+
|
|
55
|
+
摄像头作为独立配件发布。在 iPhone“家庭 → 添加配件 → 更多选项”中选择它,使用现有 Homebridge 的配对 PIN 添加。
|
|
56
|
+
|
|
57
|
+
默认最高 640×360、12 fps、700 kbps,最多一路实时视频。当前提供实时视频和快照,未接入声音、对讲、云台、运动事件、录像回放及 HomeKit Secure Video。
|
|
58
|
+
|
|
59
|
+
## 数据和运行管理
|
|
60
|
+
|
|
61
|
+
会话、设备选择和导入的组件保存在:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
<Homebridge storage>/mijia-remote-camera/
|
|
65
|
+
.micam/credentials.json
|
|
66
|
+
selection.json
|
|
67
|
+
region.json
|
|
68
|
+
runtime/
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
私有目录权限为 `0700`,凭据和选择文件为 `0600`。小米 Token 不写入 Homebridge `config.json`。设置页通过 Homebridge 的受保护接口和本机 Unix socket 通信;原始视频仅监听临时的本机回环端口。Homebridge 启动后恢复所选摄像头,关闭时清理 Java、FFmpeg 和 UDP 会话,临时网络故障会自动重连。
|
|
72
|
+
|
|
73
|
+
插件按单个账号、单个摄像头设计,按所选家庭及实际所有者查询完整设备分页。迁移前请备份 Homebridge 存储目录。
|
|
74
|
+
|
|
75
|
+
## 验证范围
|
|
76
|
+
|
|
77
|
+
已验证原生 HAP CameraController、合成视频快照与 SRTP 发送/独立接收解码、进程清理、授权取消与原子保存、家庭作用域、真实 Homebridge 设置页 IPC 和本地 Java 启动。实际摄像头取流和 Apple 家庭配对仍需设备端验证。
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Distribution and components
|
|
2
|
+
|
|
3
|
+
The code distributed in this npm package is licensed under MIT. It does not
|
|
4
|
+
contain the micam backend, compiled bridge, Java runtime, FFmpeg binary, or
|
|
5
|
+
ffmpeg-for-homebridge installer. The runtime manifest contains provenance and
|
|
6
|
+
SHA-256 identifiers only; it does not grant rights to the identified components.
|
|
7
|
+
|
|
8
|
+
The local setup command imports runtime files already available on the user's
|
|
9
|
+
machine. It does not download them or upload them to npm. Local runtime files
|
|
10
|
+
are separate from the package and retain their original rights and notices.
|
|
11
|
+
|
|
12
|
+
- micam, commit `2cc9d9e7607014d1d05c17bc5676544fbb0d5d24`, with local
|
|
13
|
+
authentication and home-selection fixes. Source:
|
|
14
|
+
https://github.com/eugeneRover/micam . The pinned repository has no license.
|
|
15
|
+
No redistribution permission is asserted by this package.
|
|
16
|
+
- Eclipse Temurin / OpenJDK 21, Linux x64 glibc and musl. Source:
|
|
17
|
+
https://github.com/adoptium/containers . The importer retains all existing
|
|
18
|
+
runtime legal notices. Image digests and file hashes are in
|
|
19
|
+
`runtime-manifest.json`.
|
|
20
|
+
- The known local runtime includes FFmpeg for Homebridge 2.2.2 / FFmpeg 8.0,
|
|
21
|
+
built with `--enable-nonfree`. That binary is excluded from this npm package.
|
|
22
|
+
Source/build information: https://github.com/homebridge/ffmpeg-for-homebridge .
|
|
23
|
+
- npm resolves `@homebridge/plugin-ui-utils` (MIT), `tough-cookie` (BSD-3-Clause),
|
|
24
|
+
`tar` (BlueOak-1.0.0), and their dependencies separately, under their respective
|
|
25
|
+
licenses. They are not bundled in the published tarball.
|
|
26
|
+
|
|
27
|
+
The HomeKit streaming delegate uses Homebridge's public HAP API and requires
|
|
28
|
+
neither `homebridge-camera-ffmpeg` nor an independent relay service.
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
package mijia.bridge;
|
|
2
|
+
|
|
3
|
+
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
4
|
+
import micam.cloud.Device;
|
|
5
|
+
import micam.cloud.Home;
|
|
6
|
+
import micam.cloud.HomeApiException;
|
|
7
|
+
import micam.cloud.MiCloud;
|
|
8
|
+
import micam.cloud.Provisioning;
|
|
9
|
+
import micam.creds.CredentialStore;
|
|
10
|
+
import micam.creds.Credentials;
|
|
11
|
+
import micam.miss.MediaPacket;
|
|
12
|
+
import micam.miss.MissClient;
|
|
13
|
+
import micam.miss.MissCrypto;
|
|
14
|
+
import micam.pppp.PpppConnection;
|
|
15
|
+
import micam.pppp.RelaySession;
|
|
16
|
+
import micam.pppp.Uid;
|
|
17
|
+
import micam.server.HttpStreamServer;
|
|
18
|
+
|
|
19
|
+
import java.io.IOException;
|
|
20
|
+
import java.io.OutputStream;
|
|
21
|
+
import java.io.PrintStream;
|
|
22
|
+
import java.nio.file.Files;
|
|
23
|
+
import java.nio.file.Path;
|
|
24
|
+
import java.nio.file.StandardCopyOption;
|
|
25
|
+
import java.nio.file.attribute.PosixFilePermissions;
|
|
26
|
+
import java.time.Duration;
|
|
27
|
+
import java.util.List;
|
|
28
|
+
import java.util.Map;
|
|
29
|
+
import java.util.Set;
|
|
30
|
+
import java.util.UUID;
|
|
31
|
+
import java.util.concurrent.atomic.AtomicBoolean;
|
|
32
|
+
import java.util.function.Supplier;
|
|
33
|
+
|
|
34
|
+
/** Private machine interface for Homebridge. No terminal prompts or raw upstream logs. */
|
|
35
|
+
public final class Bridge {
|
|
36
|
+
private static final ObjectMapper JSON = new ObjectMapper();
|
|
37
|
+
private static final PrintStream OUTPUT = System.out;
|
|
38
|
+
private static final Set<String> REGIONS = Set.of("cn", "ru", "de", "sg", "us", "i2");
|
|
39
|
+
private final CredentialStore store = new CredentialStore();
|
|
40
|
+
private Credentials credentials;
|
|
41
|
+
private MiCloud cloud;
|
|
42
|
+
private boolean renewed;
|
|
43
|
+
|
|
44
|
+
private static final class Failure extends RuntimeException {
|
|
45
|
+
final int exit;
|
|
46
|
+
final String reason;
|
|
47
|
+
Failure(int exit, String reason) { this.exit = exit; this.reason = reason; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public static void main(String[] args) {
|
|
51
|
+
// Even an accidental upstream println cannot contaminate the IPC channel.
|
|
52
|
+
System.setOut(new PrintStream(OutputStream.nullOutputStream()));
|
|
53
|
+
System.setErr(new PrintStream(OutputStream.nullOutputStream()));
|
|
54
|
+
int exit;
|
|
55
|
+
try { exit = new Bridge().run(args); }
|
|
56
|
+
catch (Failure failure) { emit(Map.of("success", false, "reason", failure.reason)); exit = failure.exit; }
|
|
57
|
+
catch (HomeApiException failure) {
|
|
58
|
+
emit(Map.of("success", false, "reason", safeReason(failure.reason()),
|
|
59
|
+
"code", failure.code() == null ? 0 : failure.code()));
|
|
60
|
+
exit = "session_expired".equals(failure.reason()) ? 2 : 7;
|
|
61
|
+
}
|
|
62
|
+
catch (Exception failure) { emit(Map.of("success", false, "reason", "backend_failed")); exit = 3; }
|
|
63
|
+
System.exit(exit);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private int run(String[] args) throws IOException {
|
|
67
|
+
if (args.length < 2 || !REGIONS.contains(args[1])) throw new Failure(7, "invalid_request");
|
|
68
|
+
String action = args[0];
|
|
69
|
+
if (!Set.of("homes", "devices", "stream").contains(action)) throw new Failure(7, "invalid_request");
|
|
70
|
+
if (!(action.equals("homes") && args.length == 2)
|
|
71
|
+
&& !(action.equals("devices") && args.length == 4)
|
|
72
|
+
&& !(action.equals("stream") && args.length == 7)) throw new Failure(7, "invalid_request");
|
|
73
|
+
if (args.length >= 4) { identifier(args[2]); identifier(args[3]); }
|
|
74
|
+
if (action.equals("stream")) { identifier(args[4]); number(args[5], 1024, 65535); number(args[6], 0, 3); }
|
|
75
|
+
try { credentials = store.load(); }
|
|
76
|
+
catch (RuntimeException invalid) { throw new Failure(2, "authentication_required"); }
|
|
77
|
+
credentials.region = args[1];
|
|
78
|
+
cloud = new MiCloud(credentials);
|
|
79
|
+
if (!credentials.hasSession()) renew();
|
|
80
|
+
if (action.equals("homes") && args.length == 2) {
|
|
81
|
+
List<Map<String, Object>> homes = request(cloud::homeList).stream().map(home -> Map.<String, Object>of(
|
|
82
|
+
"id", home.id(), "ownerId", home.ownerId(), "name", home.name(), "shared", home.shared())).toList();
|
|
83
|
+
emit(Map.of("success", true, "homes", homes));
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
if (args.length < 4) throw new Failure(7, "invalid_request");
|
|
87
|
+
Home home = new Home(identifier(args[2]), identifier(args[3]), "", false);
|
|
88
|
+
List<Device> cameras = request(() -> cloud.deviceList(home)).stream().filter(Device::isCamera).toList();
|
|
89
|
+
if (action.equals("devices") && args.length == 4) {
|
|
90
|
+
emit(Map.of("success", true, "devices", cameras.stream().map(device -> Map.<String, Object>of(
|
|
91
|
+
"deviceId", device.did(), "name", label(device.name()), "model", label(device.model()),
|
|
92
|
+
"online", device.online())).toList()));
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
if (!action.equals("stream") || args.length != 7) throw new Failure(7, "invalid_request");
|
|
96
|
+
String did = identifier(args[4]);
|
|
97
|
+
int port = number(args[5], 1024, 65535);
|
|
98
|
+
int quality = number(args[6], 0, 3);
|
|
99
|
+
Device device = cameras.stream().filter(camera -> did.equals(camera.did())).findFirst()
|
|
100
|
+
.orElseThrow(() -> new Failure(7, "camera_not_in_home"));
|
|
101
|
+
if (!device.online()) throw new Failure(4, "camera_offline");
|
|
102
|
+
Provisioning provision = request(() -> cloud.missGetVendor(did));
|
|
103
|
+
if (!provision.isCs2()) {
|
|
104
|
+
emit(Map.of("success", false, "reason", "unsupported_transport", "vendor", provision.vendorId()));
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
return stream(provision, port, quality);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private <T> T request(Supplier<T> call) {
|
|
111
|
+
try { return call.get(); }
|
|
112
|
+
catch (HomeApiException failure) {
|
|
113
|
+
if (!"session_expired".equals(failure.reason()) || renewed) throw failure;
|
|
114
|
+
renew();
|
|
115
|
+
return call.get();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private void renew() {
|
|
120
|
+
if (renewed || !credentials.canRenew()) throw new Failure(2, "authentication_required");
|
|
121
|
+
renewed = true;
|
|
122
|
+
try {
|
|
123
|
+
cloud.login(null);
|
|
124
|
+
// Keep the original file intact until a complete renewal succeeds.
|
|
125
|
+
if (!credentials.hasSession()) throw new Failure(2, "authentication_required");
|
|
126
|
+
Path file = store.path();
|
|
127
|
+
Files.createDirectories(file.getParent());
|
|
128
|
+
Files.setPosixFilePermissions(file.getParent(), PosixFilePermissions.fromString("rwx------"));
|
|
129
|
+
Path temp = file.resolveSibling(".credentials-java-" + UUID.randomUUID() + ".tmp");
|
|
130
|
+
try {
|
|
131
|
+
Files.createFile(temp, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
|
|
132
|
+
JSON.writeValue(temp.toFile(), credentials);
|
|
133
|
+
Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
|
134
|
+
} finally { Files.deleteIfExists(temp); }
|
|
135
|
+
} catch (Exception failure) { throw new Failure(2, "authentication_required"); }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private int stream(Provisioning provision, int port, int quality) {
|
|
139
|
+
Resources resources = new Resources();
|
|
140
|
+
AtomicBoolean running = new AtomicBoolean(true);
|
|
141
|
+
Thread hook = new Thread(() -> { running.set(false); resources.close(); }, "mijia-bridge-stop");
|
|
142
|
+
Runtime.getRuntime().addShutdownHook(hook);
|
|
143
|
+
try {
|
|
144
|
+
resources.relay = new RelaySession(Uid.parse(provision.p2pId()), provision.servers(), null);
|
|
145
|
+
RelaySession.Established established = resources.relay.connect(Duration.ofSeconds(90));
|
|
146
|
+
resources.connection = new PpppConnection(resources.relay.socket(), established.relay(), null);
|
|
147
|
+
resources.connection.start();
|
|
148
|
+
resources.camera = new MissClient(resources.connection);
|
|
149
|
+
resources.camera.authenticate(provision.clientPublic(), provision.sign(), Duration.ofSeconds(15));
|
|
150
|
+
resources.camera.useSessionKey(MissCrypto.sessionKey(provision.clientPrivate(), provision.devicePublic()));
|
|
151
|
+
resources.camera.startVideo(quality, false);
|
|
152
|
+
long lastVideo = System.nanoTime();
|
|
153
|
+
while (running.get()) {
|
|
154
|
+
MediaPacket frame = resources.camera.nextMedia(Duration.ofSeconds(1));
|
|
155
|
+
if (frame == null || !frame.isVideo()) {
|
|
156
|
+
if (System.nanoTime() - lastVideo > Duration.ofSeconds(10).toNanos()) throw new Failure(5, "no_video");
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
lastVideo = System.nanoTime();
|
|
160
|
+
if (resources.server == null) {
|
|
161
|
+
if (!frame.isKeyframe()) continue;
|
|
162
|
+
resources.server = new HttpStreamServer(port, "/cam.ts", null);
|
|
163
|
+
resources.server.start();
|
|
164
|
+
emit(Map.of("success", true, "event", "ready", "port", port,
|
|
165
|
+
"videoReceived", true, "codec", frame.codecName()));
|
|
166
|
+
}
|
|
167
|
+
resources.server.publish(frame);
|
|
168
|
+
}
|
|
169
|
+
return 0;
|
|
170
|
+
} catch (java.net.BindException failure) { throw new Failure(6, "port_in_use"); }
|
|
171
|
+
catch (Failure failure) { throw failure; }
|
|
172
|
+
catch (Exception failure) { throw new Failure(3, "connection_failed"); }
|
|
173
|
+
finally {
|
|
174
|
+
resources.close();
|
|
175
|
+
try { Runtime.getRuntime().removeShutdownHook(hook); }
|
|
176
|
+
catch (IllegalStateException ignored) { }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private static final class Resources {
|
|
181
|
+
volatile RelaySession relay;
|
|
182
|
+
volatile PpppConnection connection;
|
|
183
|
+
volatile MissClient camera;
|
|
184
|
+
volatile HttpStreamServer server;
|
|
185
|
+
synchronized void close() {
|
|
186
|
+
if (camera != null) { try { camera.stopVideo(); } catch (Exception ignored) { } camera = null; }
|
|
187
|
+
if (server != null) { try { server.close(); } catch (Exception ignored) { } server = null; }
|
|
188
|
+
if (connection != null) { try { connection.close(); } catch (Exception ignored) { } connection = null; }
|
|
189
|
+
if (relay != null) { try { relay.close(); } catch (Exception ignored) { } relay = null; }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private static String identifier(String value) {
|
|
194
|
+
if (value == null || !value.matches("[0-9]{1,30}")) throw new Failure(7, "invalid_request");
|
|
195
|
+
return new java.math.BigInteger(value).toString();
|
|
196
|
+
}
|
|
197
|
+
private static int number(String value, int min, int max) {
|
|
198
|
+
try { int parsed = Integer.parseInt(value); if (parsed >= min && parsed <= max) return parsed; }
|
|
199
|
+
catch (NumberFormatException ignored) { }
|
|
200
|
+
throw new Failure(7, "invalid_request");
|
|
201
|
+
}
|
|
202
|
+
private static String label(String value) {
|
|
203
|
+
if (value == null) return "";
|
|
204
|
+
String cleaned = value.replaceAll("[\\p{Cc}]", " ");
|
|
205
|
+
return cleaned.length() > 160 ? cleaned.substring(0, 160) : cleaned;
|
|
206
|
+
}
|
|
207
|
+
private static String safeReason(String value) {
|
|
208
|
+
return Set.of("session_expired", "invalid_home_selection", "invalid_home_response", "missing_home_owner",
|
|
209
|
+
"invalid_device_response", "device_pagination_incomplete", "home_request_failed", "home_request_rejected")
|
|
210
|
+
.contains(value) ? value : "discovery_failed";
|
|
211
|
+
}
|
|
212
|
+
private static synchronized void emit(Object value) {
|
|
213
|
+
try { OUTPUT.println(JSON.writeValueAsString(value)); OUTPUT.flush(); }
|
|
214
|
+
catch (Exception ignored) { }
|
|
215
|
+
}
|
|
216
|
+
}
|
package/bin/setup.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { importRuntimeAssets } from '../lib/runtime-assets.mjs';
|
|
5
|
+
|
|
6
|
+
const usage = '用法: mijia-camera-setup --from <已有的本地 .tgz 或 assets 目录> --storage <Homebridge 数据目录>\n例如: mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz --storage /homebridge';
|
|
7
|
+
const values = {};
|
|
8
|
+
let valid = true;
|
|
9
|
+
for (let index = 2; index < process.argv.length; index++) {
|
|
10
|
+
const key = process.argv[index];
|
|
11
|
+
if (!['--from', '--storage'].includes(key) || !process.argv[index + 1] || process.argv[index + 1].startsWith('--') || values[key]) { valid = false; break; }
|
|
12
|
+
values[key] = process.argv[++index];
|
|
13
|
+
}
|
|
14
|
+
if (!valid || !values['--from']) {
|
|
15
|
+
console.error(usage);
|
|
16
|
+
process.exitCode = 2;
|
|
17
|
+
} else {
|
|
18
|
+
try {
|
|
19
|
+
await importRuntimeAssets({ source: resolve(values['--from']),
|
|
20
|
+
homebridgeStoragePath: resolve(values['--storage'] || process.env.UIX_STORAGE_PATH || process.env.HOMEBRIDGE_STORAGE_PATH || `${homedir()}/.homebridge`) });
|
|
21
|
+
console.log('运行组件已从本地导入并完成校验。请重启 Homebridge,然后打开插件设置登录米家。');
|
|
22
|
+
} catch (error) {
|
|
23
|
+
const messages = { runtime_in_use: 'Homebridge 正在使用此插件。请先停用插件并重启 Homebridge,再重新导入。',
|
|
24
|
+
runtime_components_missing: '本地安装包缺少运行组件。', runtime_components_invalid: '组件校验失败,请使用此前提供的完整安装包。',
|
|
25
|
+
storage_failed: '无法写入 Homebridge 数据目录,请检查目录和运行用户。' };
|
|
26
|
+
console.error(messages[error.reason] || '导入失败,请检查本地安装包路径和 Homebridge 数据目录。');
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "MijiaRemoteCamera",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"customUi": true,
|
|
6
|
+
"customUiPath": "./homebridge-ui",
|
|
7
|
+
"headerDisplay": "首次先从已有本地运行包导入组件,再登录小米账号并选择家庭和摄像头。",
|
|
8
|
+
"schema": {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"properties": {
|
|
11
|
+
"name": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"title": "插件名称",
|
|
14
|
+
"default": "米家远程摄像头"
|
|
15
|
+
},
|
|
16
|
+
"platform": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"default": "MijiaRemoteCamera"
|
|
19
|
+
},
|
|
20
|
+
"region": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"title": "米家地区",
|
|
23
|
+
"default": "cn",
|
|
24
|
+
"enum": [
|
|
25
|
+
"cn",
|
|
26
|
+
"sg",
|
|
27
|
+
"de",
|
|
28
|
+
"us",
|
|
29
|
+
"ru",
|
|
30
|
+
"i2"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"cameraName": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"title": "摄像头显示名称",
|
|
36
|
+
"default": "米家 3 Pro",
|
|
37
|
+
"maxLength": 64
|
|
38
|
+
},
|
|
39
|
+
"quality": {
|
|
40
|
+
"type": "integer",
|
|
41
|
+
"title": "米家画质",
|
|
42
|
+
"default": 1,
|
|
43
|
+
"minimum": 0,
|
|
44
|
+
"maximum": 3
|
|
45
|
+
},
|
|
46
|
+
"maxWidth": {
|
|
47
|
+
"type": "integer",
|
|
48
|
+
"default": 640,
|
|
49
|
+
"minimum": 160,
|
|
50
|
+
"maximum": 1280,
|
|
51
|
+
"multipleOf": 2
|
|
52
|
+
},
|
|
53
|
+
"maxHeight": {
|
|
54
|
+
"type": "integer",
|
|
55
|
+
"default": 360,
|
|
56
|
+
"minimum": 120,
|
|
57
|
+
"maximum": 720,
|
|
58
|
+
"multipleOf": 2
|
|
59
|
+
},
|
|
60
|
+
"maxFPS": {
|
|
61
|
+
"type": "integer",
|
|
62
|
+
"default": 12,
|
|
63
|
+
"minimum": 1,
|
|
64
|
+
"maximum": 25
|
|
65
|
+
},
|
|
66
|
+
"maxBitrate": {
|
|
67
|
+
"type": "integer",
|
|
68
|
+
"default": 700,
|
|
69
|
+
"minimum": 100,
|
|
70
|
+
"maximum": 2000
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
5
|
+
<title>米家远程摄像头</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root{color-scheme:light dark;--ink:#1d2b32;--muted:#65767d;--line:#dce4e4;--panel:#f4f8f7;--accent:#087e72;--accent-soft:#e5f3ed}
|
|
8
|
+
*{box-sizing:border-box}body{margin:0;padding:24px;font:14px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:transparent}main{max-width:760px;margin:auto}.eyebrow{font-size:11px;letter-spacing:.16em;color:var(--accent);font-weight:700}h1{font-size:28px;letter-spacing:-.03em;line-height:1.3;margin:7px 0 10px}p{margin:0 0 14px}.intro{color:var(--muted);max-width:600px}.status{display:flex;gap:10px;align-items:center;padding:12px 15px;border:1px solid var(--line);border-radius:12px;margin:22px 0;color:var(--muted)}.dot{height:8px;width:8px;flex:0 0 8px;border-radius:50%;background:#aab9bb}.status[data-live=true] .dot{background:var(--accent)}section{border:1px solid var(--line);border-radius:15px;padding:20px;margin:16px 0}h2{font-size:17px;margin:0 0 14px}.step{font-size:11px;color:var(--accent);font-weight:700;letter-spacing:.1em;margin-bottom:4px}label{display:block;font-size:12px;font-weight:600;margin:14px 0 6px}select,input{width:100%;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:transparent;color:var(--ink);font:inherit}button,.button{border:0;border-radius:8px;padding:10px 15px;background:var(--accent);color:white;font:inherit;font-size:13px;font-weight:600;line-height:1.4;cursor:pointer;text-decoration:none;display:inline-block}button.secondary{background:var(--panel);color:var(--ink);border:1px solid var(--line)}button:disabled{opacity:.4;cursor:default}.row{display:flex;gap:9px;flex-wrap:wrap;align-items:center;margin-top:16px}.small{color:var(--muted);font-size:12px}.note{background:var(--panel);border-radius:9px;padding:12px 14px;margin-top:16px}.linkbox{background:var(--accent-soft);border-radius:10px;padding:14px;margin-top:15px}.linkbox p{margin:0 0 10px}.hide{display:none!important}.columns{display:grid;grid-template-columns:1fr 1fr;gap:14px}.error{color:#ad3d2e;white-space:pre-line}#authState{margin-top:12px}.footer{padding:12px 2px 22px}.footer p{margin:0 0 7px}@media(max-width:550px){body{padding:14px}.columns{grid-template-columns:1fr}section{padding:16px}}@media(prefers-color-scheme:dark){:root{--ink:#e5efec;--muted:#a1b5af;--line:#334943;--panel:#22352f;--accent:#24a58e;--accent-soft:#1d3b31}}
|
|
9
|
+
</style>
|
|
10
|
+
</head>
|
|
11
|
+
<body><main>
|
|
12
|
+
<div class="eyebrow">MIJIA · HOMEBRIDGE</div>
|
|
13
|
+
<h1>米家远程摄像头</h1>
|
|
14
|
+
<p class="intro">登录你的小米账号,选择家庭和摄像头。视频由此插件连接米家远程中继,再接入 Apple 家庭。</p>
|
|
15
|
+
<div id="runtimeStatus" class="status"><span class="dot"></span><span id="runtimeText">正在检查插件状态…</span></div>
|
|
16
|
+
<section id="components" class="hide"><div class="step">首次设置 · 本地组件</div><h2>导入已有的运行包</h2><p class="small">公开 npm 包仅包含插件代码。先将已有的 <code>homebridge-mijia-remote-camera-0.1.0.tgz</code> 放入 Homebridge 容器的 <code>/homebridge</code>,在管理界面的终端执行:</p><pre style="white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px">mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz --storage /homebridge</pre><p class="small">完成后重启 Homebridge。使用其他存储位置时请调整路径。导入程序仅读取本地运行文件,不读取旧包的账号信息。</p></section>
|
|
17
|
+
<section id="bootstrap" class="hide"><div class="step">首次设置</div><h2>先启用插件</h2><p class="small">保存基本配置后,在 Homebridge 首页重新启动。然后回到这里登录小米账号。</p><button id="enable">启用并保存配置</button><div class="row"><button id="check" class="secondary">已重新启动,检查连接</button></div><p id="bootstrapText" class="small" style="margin-top:12px"></p></section>
|
|
18
|
+
<section><div class="step">01 · 小米账号</div><h2>通过官方页面登录</h2>
|
|
19
|
+
<label for="region">米家 App 的地区</label><select id="region"><option value="cn">中国大陆</option><option value="sg">新加坡</option><option value="de">欧洲(德国)</option><option value="us">美国</option><option value="ru">俄罗斯</option><option value="i2">印度</option></select>
|
|
20
|
+
<div class="row"><button id="login">生成登录链接</button><button id="cancel" class="secondary hide">取消</button><button id="load" class="secondary">使用已有登录,读取家庭</button></div>
|
|
21
|
+
<div id="loginLink" class="linkbox hide"><p>在已登录小米账号的浏览器中打开此官方页面,并完成确认。</p><a id="officialLogin" class="button" target="_blank" rel="noopener noreferrer">打开小米官方登录</a><p class="small" style="margin-top:10px">此链接只供本次授权,不要转发。无需复制密码或 Cookie。</p></div>
|
|
22
|
+
<div id="authState" class="small"></div>
|
|
23
|
+
</section>
|
|
24
|
+
<section><div class="step">02 · 选择设备</div><h2>家庭与摄像头</h2><div class="columns"><div><label for="homes">家庭</label><select id="homes" disabled><option>先读取家庭列表</option></select></div><div><label for="devices">摄像头</label><select id="devices" disabled><option>先选择家庭</option></select></div></div>
|
|
25
|
+
<label for="cameraName">Apple 家庭中的名称</label><input id="cameraName" maxlength="64" placeholder="选择摄像头后填写" disabled>
|
|
26
|
+
<div class="row"><button id="save" disabled>使用这台摄像头</button></div><p id="selectionState" class="small" style="margin-top:12px"></p>
|
|
27
|
+
</section>
|
|
28
|
+
<div id="error" class="error" role="alert"></div>
|
|
29
|
+
<div class="footer small"><p>首次连接可能需要一两分钟。当前只提供实时视频和快照。</p><p>配件发布后,在“Apple 家庭 → 添加配件 → 更多选项”中选择摄像头,使用 Homebridge 配对 PIN 添加。</p><p>登录数据只保存在 Homebridge 的私有插件目录,不写入插件配置。</p></div>
|
|
30
|
+
</main>
|
|
31
|
+
<script>
|
|
32
|
+
(() => {
|
|
33
|
+
const $ = id => document.getElementById(id);
|
|
34
|
+
const regions = ['cn','sg','de','us','ru','i2'];
|
|
35
|
+
let homes=[], devices=[], loginId, pollTimer, statusTimer, configs=[], connected=false, authBusy=false, regionTouched=false, regionNeedsLogin=false, runtimeRegion='cn';
|
|
36
|
+
const messages = { plugin_not_running:'插件尚未启动。请先保存配置并重新启动 Homebridge。', auth_required:'需要完成小米登录。',
|
|
37
|
+
unauthenticated:'需要完成小米登录。', network_error:'连接小米服务失败,请稍后重试。', request_timeout:'请求超时,请稍后重试。',
|
|
38
|
+
login_expired:'登录链接已过期,请重新生成。', login_not_approved:'小米授权未完成,请重新尝试。', login_cancelled:'本次登录已取消。',
|
|
39
|
+
incomplete_session:'未取得完整的米家会话,请重新授权。', unsafe_url:'小米返回了未支持的登录地址,请重新尝试。',
|
|
40
|
+
credentials_write_failed:'无法保存登录数据,请检查 Homebridge 插件目录权限。', home_request_rejected:'家庭列表请求被拒绝,请检查账号权限与地区。',
|
|
41
|
+
home_request_failed:'无法读取家庭或设备列表,请检查网络与登录状态。', session_expired:'小米会话已过期,请重新登录。',
|
|
42
|
+
unsupported_protocol:'当前摄像头的远程传输协议尚不支持。', offline:'米家报告摄像头离线。', selection_missing:'请选择家庭和摄像头。',
|
|
43
|
+
backend_unavailable:'插件后端暂不可用,请查看 Homebridge 状态。', runtime_unavailable:'插件连接暂不可用,请重新启动 Homebridge。', authentication_required:'小米登录需要重新授权,请生成官方登录链接。', camera_offline:'米家报告摄像头离线。', camera_not_in_home:'所选家庭中未找到此摄像头,请重新选择。', unsupported_transport:'当前摄像头的远程协议尚不支持。', no_video:'摄像头尚未返回视频,请检查电源与网络。', connect_timeout:'连接摄像头超时,正在等待重连。', backend_failed:'取流后端未能启动,请查看插件状态。', storage_failed:'无法访问插件私有目录,请检查权限。', runtime_in_use:'另一个插件进程正在使用此目录。', query_timeout:'读取家庭或设备超时,请重试。', not_selected:'请登录并选择摄像头。', auth_in_progress:'正在进行小米授权,请完成或取消后继续。' };
|
|
44
|
+
function reason(error){return error?.error?.reason || error?.requestError?.reason || error?.reason || error?.message;}
|
|
45
|
+
function showError(error){const key=reason(error); $('error').textContent=messages[key] || '操作未完成,请检查插件状态后重试。'; if(key==='plugin_not_running')$('bootstrap').classList.remove('hide');}
|
|
46
|
+
function clearError(){$('error').textContent='';}
|
|
47
|
+
async function request(path,payload={}){return homebridge.request(path,payload);}
|
|
48
|
+
function options(select,items,render,empty){select.replaceChildren(); if(!items.length){const option=document.createElement('option');option.textContent=empty;select.append(option);select.disabled=true;return;} items.forEach((item,index)=>{const option=document.createElement('option');option.value=String(index);option.textContent=render(item);select.append(option)});select.disabled=false;}
|
|
49
|
+
function chosenHome(){return homes[Number($('homes').value)];}
|
|
50
|
+
function chosenDevice(){return devices[Number($('devices').value)];}
|
|
51
|
+
function setLoginBusy(busy){authBusy=busy;$('login').disabled=busy||!connected; $('load').disabled=busy||!connected||regionNeedsLogin; $('region').disabled=busy; $('cancel').classList.toggle('hide',!busy);}
|
|
52
|
+
function hideLoginLink(){$('loginLink').classList.add('hide');$('officialLogin').removeAttribute('href');}
|
|
53
|
+
async function status(){try{const value=await request('/status');connected=true;$('components').classList.add('hide');$('bootstrap').classList.add('hide');if(regions.includes(value.region)){runtimeRegion=value.region;if(!regionTouched&&!authBusy)$('region').value=runtimeRegion;}regionNeedsLogin=$('region').value!==runtimeRegion;setLoginBusy(authBusy);const ready=value.ready===true||value.state==='ready';$('runtimeStatus').dataset.live=String(ready);const states={idle:'请登录并选择摄像头。',unconfigured:'请登录并选择摄像头。',connecting:'正在连接摄像头…',starting:'正在连接摄像头…',ready:value.videoReceived?'已收到摄像头视频。':'转播端口已就绪,等待摄像头首帧。',retrying:'连接中断,正在等待重连。',paused:'连接已暂停,请选择摄像头启动。',blocked:'连接需要处理。'};$('runtimeText').textContent=messages[value.reason]||states[value.state]||'插件已运行,可以登录并选择摄像头。';}catch(error){connected=false;$('login').disabled=true;$('load').disabled=true;$('runtimeText').textContent='等待插件启动';showError(error);}}
|
|
54
|
+
async function loadHomes(){clearError();$('selectionState').textContent='正在读取家庭…';try{const response=await request('/homes');homes=Array.isArray(response)?response:(response.homes||[]);options($('homes'),homes,home=>(home.name||'未命名家庭')+(home.shared?' · 共享家庭':''),'当前地区没有可访问的家庭');$('selectionState').textContent=homes.length?'请选择目标家庭。':'请检查地区及账号的家庭访问权限。';devices=[];options($('devices'),[],()=>'', '先选择家庭');$('save').disabled=true;$('cameraName').disabled=true;if(homes.length)await loadDevices();}catch(error){$('selectionState').textContent='';showError(error);}}
|
|
55
|
+
async function loadDevices(){const home=chosenHome();if(!home)return;clearError();$('selectionState').textContent='正在读取此家庭的摄像头…';$('save').disabled=true;try{const response=await request('/devices',{homeId:String(home.id),homeOwner:String(home.ownerId)});devices=Array.isArray(response)?response:(response.devices||[]);options($('devices'),devices,device=>(device.name||device.model||'摄像头')+(device.online===false?' · 离线':''),'此家庭没有摄像头');$('selectionState').textContent='';deviceChanged();}catch(error){devices=[];options($('devices'),[],()=>'', '读取设备失败');$('selectionState').textContent='';showError(error);}}
|
|
56
|
+
function deviceChanged(){const device=chosenDevice();$('cameraName').disabled=!device;$('save').disabled=!device;$('cameraName').value=device?(device.name||'米家摄像头'):'';}
|
|
57
|
+
async function pollAuth(){try{const value=await request('/auth/status',{loginId});if(value.state==='waiting'||value.state==='starting'){$('authState').textContent=`等待你在小米页面确认,剩余 ${value.remainingSeconds||0} 秒。`;pollTimer=setTimeout(pollAuth,1200);return;}setLoginBusy(false);hideLoginLink();if(value.success||value.state==='complete'){$('authState').textContent='登录成功。正在读取家庭列表…';await status();await loadHomes();}else $('authState').textContent=messages[value.reason]||'本次登录未完成,可重新生成链接。';}catch(error){setLoginBusy(false);hideLoginLink();showError(error);}}
|
|
58
|
+
$('login').onclick=async()=>{clearError();hideLoginLink();clearTimeout(pollTimer);setLoginBusy(true);$('authState').textContent='正在生成官方登录链接…';try{const value=await request('/auth/start',{region:$('region').value});const url=new URL(value.loginUrl);if(url.protocol!=='https:'||!(url.hostname==='mi.com'||url.hostname.endsWith('.mi.com')||url.hostname==='xiaomi.com'||url.hostname.endsWith('.xiaomi.com')))throw Error('unsafe_url');loginId=value.loginId;$('officialLogin').href=url.href;$('loginLink').classList.remove('hide');$('authState').textContent='请打开链接完成授权。';pollTimer=setTimeout(pollAuth,1000);}catch(error){setLoginBusy(false);showError(error);$('authState').textContent='';}};
|
|
59
|
+
$('cancel').onclick=async()=>{clearTimeout(pollTimer);try{await request('/auth/cancel',{loginId});}catch(error){showError(error);}hideLoginLink();setLoginBusy(false);$('authState').textContent='已取消。已有登录数据保留。';};
|
|
60
|
+
$('region').onchange=()=>{regionTouched=true;regionNeedsLogin=$('region').value!==runtimeRegion;setLoginBusy(authBusy);$('authState').textContent=regionNeedsLogin?'已切换地区,请生成新登录链接后读取家庭。':'';}; $('load').onclick=loadHomes; $('homes').onchange=loadDevices; $('devices').onchange=deviceChanged;
|
|
61
|
+
$('save').onclick=async()=>{const home=chosenHome(),device=chosenDevice();if(!home||!device)return;clearError();$('save').disabled=true;try{const result=await request('/select',{homeId:String(home.id),homeOwner:String(home.ownerId),deviceId:String(device.deviceId||device.did),name:$('cameraName').value.trim()||device.name,model:device.model,region:$('region').value});$('selectionState').textContent=result.restartRequired?'已保存新的摄像头。请重启 Homebridge 使配件变更生效。':'选择已保存,正在连接摄像头。配对时请使用 Homebridge PIN。';await status();}catch(error){showError(error);}finally{$('save').disabled=false;}};
|
|
62
|
+
$('enable').onclick=async()=>{clearError();try{if(!configs.length){configs=[{platform:'MijiaRemoteCamera',name:'米家远程摄像头',region:$('region').value}];await homebridge.updatePluginConfig(configs);}await homebridge.savePluginConfig();$('bootstrapText').textContent='已保存。请在 Homebridge 首页重新启动,再回到这里检查连接。';}catch(error){showError(error);}};
|
|
63
|
+
$('check').onclick=status;
|
|
64
|
+
async function init(){try{homebridge.hideSchemaForm();try{const components=await request('/components');if(components.ready===false)$('components').classList.remove('hide');}catch{}configs=await homebridge.getPluginConfig();if(!Array.isArray(configs))configs=[];const region=configs[0]?.region;if(regions.includes(region))$('region').value=region;if(!configs.length)$('bootstrap').classList.remove('hide');await status();try{const pending=await request('/auth/status');if(pending.state==='waiting'){loginId=pending.loginId;setLoginBusy(true);$('authState').textContent='先前的授权仍在等待确认。也可取消后生成新链接。';pollTimer=setTimeout(pollAuth,1000);}}catch{}statusTimer=setInterval(status,5000);}catch(error){showError(error);}}
|
|
65
|
+
window.addEventListener('beforeunload',()=>{clearInterval(statusTimer);clearTimeout(pollTimer);});
|
|
66
|
+
init();
|
|
67
|
+
})();
|
|
68
|
+
</script></body></html>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { HomebridgePluginUiServer, RequestError } from '@homebridge/plugin-ui-utils';
|
|
5
|
+
|
|
6
|
+
const SAFE_REASONS = new Set(['plugin_not_running', 'invalid_request', 'unauthenticated', 'auth_required',
|
|
7
|
+
'home_request_failed', 'home_request_rejected', 'session_expired', 'invalid_home_selection',
|
|
8
|
+
'invalid_selection', 'selection_missing', 'not_ready', 'backend_unavailable', 'invalid_region',
|
|
9
|
+
'unsafe_url', 'network_error', 'request_timeout', 'unexpected_response', 'login_expired',
|
|
10
|
+
'login_cancelled', 'login_not_approved', 'incomplete_session', 'credentials_write_failed',
|
|
11
|
+
'runtime_unavailable', 'unknown_login', 'probe_failed', 'unsupported_protocol', 'offline',
|
|
12
|
+
'operation_failed', 'runtime_locked', 'authentication_required', 'camera_offline', 'camera_not_in_home',
|
|
13
|
+
'unsupported_transport', 'no_video', 'connect_timeout', 'backend_failed', 'storage_failed',
|
|
14
|
+
'runtime_in_use', 'query_timeout', 'unsupported_platform', 'bundled_runtime_missing', 'not_selected', 'auth_in_progress',
|
|
15
|
+
'runtime_components_missing', 'runtime_components_invalid']);
|
|
16
|
+
|
|
17
|
+
function failureReason(error) {
|
|
18
|
+
if (['ENOENT', 'ECONNREFUSED'].includes(error?.code)) return 'plugin_not_running';
|
|
19
|
+
if (error?.code === 'ETIMEDOUT') return 'request_timeout';
|
|
20
|
+
if (SAFE_REASONS.has(error?.reason)) return error.reason;
|
|
21
|
+
if (SAFE_REASONS.has(error?.message)) return error.message;
|
|
22
|
+
return 'operation_failed';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function id(value) {
|
|
26
|
+
if (typeof value !== 'string' || !/^[0-9]{1,30}$/.test(value)) throw new Error('invalid_request');
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
function label(value, fallback) {
|
|
30
|
+
return typeof value === 'string' && value.trim() && value.length <= 128 && !/[\x00-\x1f\x7f]/.test(value) ? value.trim() : fallback;
|
|
31
|
+
}
|
|
32
|
+
function loginId(value) {
|
|
33
|
+
if (value === undefined) return undefined;
|
|
34
|
+
if (typeof value !== 'string' || !/^[a-z0-9-]{1,64}$/i.test(value)) throw new Error('invalid_request');
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Pure proxy: the active Homebridge plugin owns all auth jobs and Java processes. */
|
|
39
|
+
export function createUiHandlers(runtime) {
|
|
40
|
+
const routes = {
|
|
41
|
+
'/auth/start': payload => {
|
|
42
|
+
const region = payload?.region ?? 'cn';
|
|
43
|
+
if (!['cn', 'de', 'us', 'ru', 'sg', 'i2'].includes(region)) throw new Error('invalid_region');
|
|
44
|
+
return runtime.request('auth.start', { region });
|
|
45
|
+
},
|
|
46
|
+
'/auth/status': payload => runtime.request('auth.status', { loginId: loginId(payload?.loginId) }),
|
|
47
|
+
'/auth/cancel': payload => runtime.request('auth.cancel', { loginId: loginId(payload?.loginId) }),
|
|
48
|
+
'/homes': () => runtime.request('homes', {}),
|
|
49
|
+
'/devices': payload => runtime.request('devices', { homeId: id(payload?.homeId), homeOwner: id(payload?.homeOwner) }),
|
|
50
|
+
'/select': payload => runtime.request('select', { homeId: id(payload?.homeId), homeOwner: id(payload?.homeOwner),
|
|
51
|
+
deviceId: id(payload?.deviceId), name: label(payload?.name, '米家摄像头'), model: label(payload?.model, 'Xiaomi Camera'),
|
|
52
|
+
region: ['cn', 'de', 'us', 'ru', 'sg', 'i2'].includes(payload?.region) ? payload.region : 'cn' }),
|
|
53
|
+
'/status': () => runtime.request('status', {}),
|
|
54
|
+
'/resume': () => runtime.request('resume', {}),
|
|
55
|
+
};
|
|
56
|
+
return Object.fromEntries(Object.entries(routes).map(([path, handle]) => [path, async payload => {
|
|
57
|
+
try { return await handle(payload); }
|
|
58
|
+
catch (error) { throw new RequestError('request_failed', { reason: failureReason(error) }); }
|
|
59
|
+
}]));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function runUiServer() {
|
|
63
|
+
const { createRpcClient } = await import('../lib/rpc.mjs');
|
|
64
|
+
const { resolveRuntimeAssets } = await import('../lib/runtime-assets.mjs');
|
|
65
|
+
class MijiaPluginUiServer extends HomebridgePluginUiServer {
|
|
66
|
+
constructor() {
|
|
67
|
+
super();
|
|
68
|
+
const storagePath = this.homebridgeStoragePath;
|
|
69
|
+
if (!storagePath) throw new Error('plugin_not_running');
|
|
70
|
+
const client = createRpcClient(join(storagePath, 'mijia-remote-camera'));
|
|
71
|
+
for (const [path, handler] of Object.entries(createUiHandlers(client))) this.onRequest(path, handler);
|
|
72
|
+
this.onRequest('/components', async () => {
|
|
73
|
+
try {
|
|
74
|
+
await resolveRuntimeAssets({ storagePath: join(storagePath, 'mijia-remote-camera') });
|
|
75
|
+
return { ready: true };
|
|
76
|
+
} catch (error) { return { ready: false, reason: failureReason(error) }; }
|
|
77
|
+
});
|
|
78
|
+
this.ready();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return new MijiaPluginUiServer();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let entrypoint = false;
|
|
85
|
+
try { entrypoint = Boolean(process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])); } catch {}
|
|
86
|
+
if (entrypoint) {
|
|
87
|
+
runUiServer().catch(() => { process.stderr.write('Mijia plugin UI could not start.\n'); process.exitCode = 1; });
|
|
88
|
+
}
|
package/index.mjs
ADDED