@linanw/homebridge-mijia-remote-camera 0.0.0-stage → 0.1.1

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 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,95 @@
1
- # Temporary Holding Version
1
+ # 米家远程摄像头:独立 Homebridge 插件
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 插件:`@linanw/homebridge-mijia-remote-camera`,平台:`MijiaRemoteCamera`。
4
+
5
+ 登录、家庭选择、远程摄像头连接、快照和 HomeKit 视频由插件管理,无需独立的 viewer / relay 容器或另一个摄像头插件。摄像头可以位于其他网络。
6
+
7
+ 目前后端仅实现 CS2/vendor 4。已在一台小米智能摄像机 3 Pro 云台版上验证跨网络远程取流,以及真实 Apple“家庭”的配对、预览和直播。具体测试环境见下文“验证范围”;其他型号、地区和网络环境尚未逐一验证。
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 包包含插件代码和由这些源码编译的自有 Java 适配器,**首次需要从已有的本地运行包导入组件**。运行组件不随 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“家庭 → 添加配件 → 更多选项”中选择它,使用该摄像头启动日志中 `Please add [...] manually in Home app. Setup Code:` 后面的配对码。插件运行在子桥时,这个码可能与 Homebridge 首页的主桥配对码不同。
56
+
57
+ 默认最高 640×360、12 fps、700 kbps,最多一路实时视频。当前提供实时视频和快照,未接入声音、对讲、云台、运动事件、录像回放及 HomeKit Secure Video。
58
+
59
+ 为兼容 Apple“家庭”的媒体能力协商,插件默认公布 AAC-ELD 16 kHz 音频能力和静音 Microphone 服务;实际不采集或发送摄像头声音。家庭中可能显示麦克风控件,目前仍只提供视频。
60
+
61
+ ## 数据和运行管理
62
+
63
+ 会话、设备选择和导入的组件保存在:
64
+
65
+ ```text
66
+ <Homebridge storage>/mijia-remote-camera/
67
+ .micam/credentials.json
68
+ selection.json
69
+ region.json
70
+ runtime/
71
+ ```
72
+
73
+ 私有目录权限为 `0700`,凭据和选择文件为 `0600`。小米 Token 不写入 Homebridge `config.json`。设置页通过 Homebridge 的受保护接口和本机 Unix socket 通信;原始视频仅监听临时的本机回环端口。Homebridge 启动后恢复所选摄像头,关闭时清理 Java、FFmpeg 和 UDP 会话,临时网络故障会自动重连。
74
+
75
+ 插件按单个账号、单个摄像头设计,按所选家庭及实际所有者查询完整设备分页。迁移前请备份 Homebridge 存储目录。
76
+
77
+ ## 验证范围
78
+
79
+ 实际设备验证使用一台小米智能摄像机 3 Pro 云台版,摄像头与 NAS 位于不同网络。Synology DS1618+ 上的 Homebridge Docker 使用 host 网络,Homebridge 2.0.2 / HAP 2.1.6;远程源为 HEVC,由 NAS 解码并转换为 HomeKit 视频。用户已确认 iOS 26.6.2 的 Apple“家庭”预览和直播正常。
80
+
81
+ 本地测试另覆盖 Homebridge 1/2 的原生 HAP CameraController、加密快照与 SRTP 发送/独立接收解码、进程清理、授权取消与原子保存、家庭作用域、真实 Homebridge 设置页 IPC 和本地 Java 启动。
82
+
83
+ 远程中继仍可能间歇断开并自动重连,重连期间预览和直播可能暂不可用。目前尚未完成长时间稳定性验证。
84
+
85
+ ## 连接诊断
86
+
87
+ 设置页可展开“连接诊断”,查看连接阶段、重试次数、上次失败原因及 HomeKit 快照/直播请求计数。“已收到摄像头视频”表示米家视频源已就绪;快照和直播计数则显示 HomeKit 是否已请求媒体。也可在 Homebridge 终端运行:
88
+
89
+ ```sh
90
+ mijia-camera-diagnose --storage /var/lib/homebridge
91
+ ```
92
+
93
+ `--storage` 必须是实例真实的存储目录,也可能是 `/homebridge`。输出不包含小米凭据、设备 ID 或视频地址。HomeKit 快照请求不会跳过连接重试间隔。
94
+
95
+ 能在插件中收到视频、但 Apple“家庭”添加失败时,请检查摄像头日志中的独立 HomeKit 端口及 Setup Code;摄像头端口与主桥/子桥端口不同。
@@ -0,0 +1,31 @@
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, third-party 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
+ The included `backend/bridge.jar` contains only the original MIT-licensed
8
+ `mijia.bridge.Bridge` adapter classes built from the supplied Java source.
9
+ No micam or Jackson implementation classes are included in that JAR.
10
+
11
+ The local setup command imports runtime files already available on the user's
12
+ machine. It does not download them or upload them to npm. Local runtime files
13
+ are separate from the package and retain their original rights and notices.
14
+
15
+ - micam, commit `2cc9d9e7607014d1d05c17bc5676544fbb0d5d24`, with local
16
+ authentication and home-selection fixes. Source:
17
+ https://github.com/eugeneRover/micam . The pinned repository has no license.
18
+ No redistribution permission is asserted by this package.
19
+ - Eclipse Temurin / OpenJDK 21, Linux x64 glibc and musl. Source:
20
+ https://github.com/adoptium/containers . The importer retains all existing
21
+ runtime legal notices. Image digests and file hashes are in
22
+ `runtime-manifest.json`.
23
+ - The known local runtime includes FFmpeg for Homebridge 2.2.2 / FFmpeg 8.0,
24
+ built with `--enable-nonfree`. That binary is excluded from this npm package.
25
+ Source/build information: https://github.com/homebridge/ffmpeg-for-homebridge .
26
+ - npm resolves `@homebridge/plugin-ui-utils` (MIT), `tough-cookie` (BSD-3-Clause),
27
+ `tar` (BlueOak-1.0.0), and their dependencies separately, under their respective
28
+ licenses. They are not bundled in the published tarball.
29
+
30
+ The HomeKit streaming delegate uses Homebridge's public HAP API and requires
31
+ neither `homebridge-camera-ffmpeg` nor an independent relay service.
@@ -0,0 +1,263 @@
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.LongSupplier;
33
+ import java.util.function.Supplier;
34
+
35
+ /** Machine interface for Homebridge. No terminal prompts or raw upstream logs. */
36
+ public final class Bridge {
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", "ru", "de", "sg", "us", "i2");
40
+ private final CredentialStore store = new CredentialStore();
41
+ private Credentials credentials;
42
+ private MiCloud cloud;
43
+ private boolean renewed;
44
+
45
+ private static final class Failure extends RuntimeException {
46
+ final int exit;
47
+ final String reason;
48
+ Failure(int exit, String reason) { super(reason); this.exit = exit; this.reason = reason; }
49
+ }
50
+
51
+ enum Stage {
52
+ DEVICE_LOOKUP("device_lookup"), PROVISIONING("provisioning"), RELAY_CONNECT("relay_connect"),
53
+ AUTHENTICATION("authentication"), WAITING_VIDEO("waiting_video");
54
+ final String value;
55
+ Stage(String value) { this.value = value; }
56
+ }
57
+
58
+ /** The first keyframe deadline is independent of subsequent non-keyframe traffic. */
59
+ static final class VideoWatchdog {
60
+ private final LongSupplier clock;
61
+ private final long started;
62
+ private final long packetTimeout;
63
+ private final long keyframeTimeout;
64
+ private long lastVideo;
65
+ private boolean receivedKeyframe;
66
+
67
+ VideoWatchdog(LongSupplier clock, Duration packetTimeout, Duration keyframeTimeout) {
68
+ this.clock = clock;
69
+ this.packetTimeout = packetTimeout.toNanos();
70
+ this.keyframeTimeout = keyframeTimeout.toNanos();
71
+ this.started = clock.getAsLong();
72
+ this.lastVideo = started;
73
+ }
74
+
75
+ void observe(boolean video, boolean keyframe) {
76
+ long now = clock.getAsLong();
77
+ if (now - lastVideo >= packetTimeout || (!receivedKeyframe && now - started >= keyframeTimeout)) {
78
+ throw new Failure(5, "no_video");
79
+ }
80
+ if (video) {
81
+ lastVideo = now;
82
+ if (keyframe) receivedKeyframe = true;
83
+ }
84
+ }
85
+ }
86
+
87
+ public static void main(String[] args) {
88
+ // Even an accidental upstream println cannot contaminate the IPC channel.
89
+ System.setOut(new PrintStream(OutputStream.nullOutputStream()));
90
+ System.setErr(new PrintStream(OutputStream.nullOutputStream()));
91
+ int exit;
92
+ try { exit = new Bridge().run(args); }
93
+ catch (Failure failure) { emit(Map.of("success", false, "reason", failure.reason)); exit = failure.exit; }
94
+ catch (HomeApiException failure) {
95
+ emit(Map.of("success", false, "reason", safeReason(failure.reason()),
96
+ "code", failure.code() == null ? 0 : failure.code()));
97
+ exit = "session_expired".equals(failure.reason()) ? 2 : 7;
98
+ }
99
+ catch (Exception failure) { emit(Map.of("success", false, "reason", "backend_failed")); exit = 3; }
100
+ System.exit(exit);
101
+ }
102
+
103
+ private int run(String[] args) throws IOException {
104
+ if (args.length < 2 || !REGIONS.contains(args[1])) throw new Failure(7, "invalid_request");
105
+ String action = args[0];
106
+ if (!Set.of("homes", "devices", "stream").contains(action)) throw new Failure(7, "invalid_request");
107
+ if (!(action.equals("homes") && args.length == 2)
108
+ && !(action.equals("devices") && args.length == 4)
109
+ && !(action.equals("stream") && args.length == 7)) throw new Failure(7, "invalid_request");
110
+ if (args.length >= 4) { identifier(args[2]); identifier(args[3]); }
111
+ if (action.equals("stream")) { identifier(args[4]); number(args[5], 1024, 65535); number(args[6], 0, 3); }
112
+ try { credentials = store.load(); }
113
+ catch (RuntimeException invalid) { throw new Failure(2, "authentication_required"); }
114
+ credentials.region = args[1];
115
+ cloud = new MiCloud(credentials);
116
+ if (!credentials.hasSession()) renew();
117
+ if (action.equals("homes") && args.length == 2) {
118
+ List<Map<String, Object>> homes = request(cloud::homeList).stream().map(home -> Map.<String, Object>of(
119
+ "id", home.id(), "ownerId", home.ownerId(), "name", home.name(), "shared", home.shared())).toList();
120
+ emit(Map.of("success", true, "homes", homes));
121
+ return 0;
122
+ }
123
+ if (args.length < 4) throw new Failure(7, "invalid_request");
124
+ Home home = new Home(identifier(args[2]), identifier(args[3]), "", false);
125
+ if (action.equals("stream")) progress(Stage.DEVICE_LOOKUP);
126
+ List<Device> cameras = request(() -> cloud.deviceList(home)).stream().filter(Device::isCamera).toList();
127
+ if (action.equals("devices") && args.length == 4) {
128
+ emit(Map.of("success", true, "devices", cameras.stream().map(device -> Map.<String, Object>of(
129
+ "deviceId", device.did(), "name", label(device.name()), "model", label(device.model()),
130
+ "online", device.online())).toList()));
131
+ return 0;
132
+ }
133
+ if (!action.equals("stream") || args.length != 7) throw new Failure(7, "invalid_request");
134
+ String did = identifier(args[4]);
135
+ int port = number(args[5], 1024, 65535);
136
+ int quality = number(args[6], 0, 3);
137
+ Device device = cameras.stream().filter(camera -> did.equals(camera.did())).findFirst()
138
+ .orElseThrow(() -> new Failure(7, "camera_not_in_home"));
139
+ if (!device.online()) throw new Failure(4, "camera_offline");
140
+ progress(Stage.PROVISIONING);
141
+ Provisioning provision = request(() -> cloud.missGetVendor(did));
142
+ if (!provision.isCs2()) {
143
+ emit(Map.of("success", false, "reason", "unsupported_transport", "vendor", provision.vendorId()));
144
+ return 1;
145
+ }
146
+ return stream(provision, port, quality);
147
+ }
148
+
149
+ private <T> T request(Supplier<T> call) {
150
+ try { return call.get(); }
151
+ catch (HomeApiException failure) {
152
+ if (!"session_expired".equals(failure.reason()) || renewed) throw failure;
153
+ renew();
154
+ return call.get();
155
+ }
156
+ }
157
+
158
+ private void renew() {
159
+ if (renewed || !credentials.canRenew()) throw new Failure(2, "authentication_required");
160
+ renewed = true;
161
+ try {
162
+ cloud.login(null);
163
+ // Keep the original file intact until a complete renewal succeeds.
164
+ if (!credentials.hasSession()) throw new Failure(2, "authentication_required");
165
+ Path file = store.path();
166
+ Files.createDirectories(file.getParent());
167
+ Files.setPosixFilePermissions(file.getParent(), PosixFilePermissions.fromString("rwx------"));
168
+ Path temp = file.resolveSibling(".credentials-java-" + UUID.randomUUID() + ".tmp");
169
+ try {
170
+ Files.createFile(temp, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
171
+ JSON.writeValue(temp.toFile(), credentials);
172
+ Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
173
+ } finally { Files.deleteIfExists(temp); }
174
+ } catch (Exception failure) { throw new Failure(2, "authentication_required"); }
175
+ }
176
+
177
+ private int stream(Provisioning provision, int port, int quality) {
178
+ Resources resources = new Resources();
179
+ AtomicBoolean running = new AtomicBoolean(true);
180
+ Thread hook = new Thread(() -> { running.set(false); resources.close(); }, "mijia-bridge-stop");
181
+ Runtime.getRuntime().addShutdownHook(hook);
182
+ try {
183
+ progress(Stage.RELAY_CONNECT, provision.vendorId());
184
+ resources.relay = new RelaySession(Uid.parse(provision.p2pId()), provision.servers(), null);
185
+ RelaySession.Established established = resources.relay.connect(Duration.ofSeconds(90));
186
+ resources.connection = new PpppConnection(resources.relay.socket(), established.relay(), null);
187
+ resources.connection.start();
188
+ resources.camera = new MissClient(resources.connection);
189
+ progress(Stage.AUTHENTICATION, provision.vendorId());
190
+ resources.camera.authenticate(provision.clientPublic(), provision.sign(), Duration.ofSeconds(15));
191
+ resources.camera.useSessionKey(MissCrypto.sessionKey(provision.clientPrivate(), provision.devicePublic()));
192
+ progress(Stage.WAITING_VIDEO, provision.vendorId());
193
+ resources.camera.startVideo(quality, false);
194
+ VideoWatchdog watchdog = new VideoWatchdog(System::nanoTime, Duration.ofSeconds(10), Duration.ofSeconds(15));
195
+ while (running.get()) {
196
+ MediaPacket frame = resources.camera.nextMedia(Duration.ofSeconds(1));
197
+ watchdog.observe(frame != null && frame.isVideo(), frame != null && frame.isVideo() && frame.isKeyframe());
198
+ if (frame == null || !frame.isVideo()) {
199
+ continue;
200
+ }
201
+ if (resources.server == null) {
202
+ if (!frame.isKeyframe()) continue;
203
+ resources.server = new HttpStreamServer(port, "/cam.ts", null);
204
+ resources.server.start();
205
+ emit(Map.of("success", true, "event", "ready", "port", port,
206
+ "videoReceived", true, "codec", frame.codecName()));
207
+ }
208
+ resources.server.publish(frame);
209
+ }
210
+ return 0;
211
+ } catch (java.net.BindException failure) { throw new Failure(6, "port_in_use"); }
212
+ catch (Failure failure) { throw failure; }
213
+ catch (Exception failure) { throw new Failure(3, "connection_failed"); }
214
+ finally {
215
+ resources.close();
216
+ try { Runtime.getRuntime().removeShutdownHook(hook); }
217
+ catch (IllegalStateException ignored) { }
218
+ }
219
+ }
220
+
221
+ private static final class Resources {
222
+ volatile RelaySession relay;
223
+ volatile PpppConnection connection;
224
+ volatile MissClient camera;
225
+ volatile HttpStreamServer server;
226
+ synchronized void close() {
227
+ if (camera != null) { try { camera.stopVideo(); } catch (Exception ignored) { } camera = null; }
228
+ if (server != null) { try { server.close(); } catch (Exception ignored) { } server = null; }
229
+ if (connection != null) { try { connection.close(); } catch (Exception ignored) { } connection = null; }
230
+ if (relay != null) { try { relay.close(); } catch (Exception ignored) { } relay = null; }
231
+ }
232
+ }
233
+
234
+ private static String identifier(String value) {
235
+ if (value == null || !value.matches("[0-9]{1,30}")) throw new Failure(7, "invalid_request");
236
+ return new java.math.BigInteger(value).toString();
237
+ }
238
+ private static int number(String value, int min, int max) {
239
+ try { int parsed = Integer.parseInt(value); if (parsed >= min && parsed <= max) return parsed; }
240
+ catch (NumberFormatException ignored) { }
241
+ throw new Failure(7, "invalid_request");
242
+ }
243
+ private static String label(String value) {
244
+ if (value == null) return "";
245
+ String cleaned = value.replaceAll("[\\p{Cc}]", " ");
246
+ return cleaned.length() > 160 ? cleaned.substring(0, 160) : cleaned;
247
+ }
248
+ private static String safeReason(String value) {
249
+ return Set.of("session_expired", "invalid_home_selection", "invalid_home_response", "missing_home_owner",
250
+ "invalid_device_response", "device_pagination_incomplete", "home_request_failed", "home_request_rejected")
251
+ .contains(value) ? value : "discovery_failed";
252
+ }
253
+ static void progress(Stage stage) {
254
+ emit(Map.of("success", true, "event", "progress", "stage", stage.value));
255
+ }
256
+ static void progress(Stage stage, int vendor) {
257
+ emit(Map.of("success", true, "event", "progress", "stage", stage.value, "vendor", vendor));
258
+ }
259
+ private static synchronized void emit(Object value) {
260
+ try { OUTPUT.println(JSON.writeValueAsString(value)); OUTPUT.flush(); }
261
+ catch (Exception ignored) { }
262
+ }
263
+ }
Binary file
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { createRpcClient } from '../lib/rpc.mjs';
6
+ import { safeDiagnostics } from '../lib/diagnostics.mjs';
7
+
8
+ const args = process.argv.slice(2);
9
+ if (args.length && (args.length !== 2 || args[0] !== '--storage' || !args[1] || args[1].startsWith('--'))) {
10
+ console.error('用法: mijia-camera-diagnose --storage <Homebridge 数据目录>');
11
+ process.exitCode = 2;
12
+ } else {
13
+ const storage = resolve(args[1] || process.env.HOMEBRIDGE_STORAGE_PATH || process.env.UIX_STORAGE_PATH || join(homedir(), '.homebridge'));
14
+ try {
15
+ const metadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
16
+ const state = await createRpcClient(join(storage, 'mijia-remote-camera')).request('status', {});
17
+ console.log(JSON.stringify({ pluginVersion: metadata.version, ...safeDiagnostics(state) }, null, 2));
18
+ } catch {
19
+ console.error('无法读取运行状态。请确认 Homebridge 已启动,且 --storage 指向其实际数据目录。');
20
+ process.exitCode = 1;
21
+ }
22
+ }
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
+ }