@linanw/homebridge-mijia-remote-camera 0.1.0 → 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/README.md +22 -4
- package/THIRD_PARTY_NOTICES.md +4 -1
- package/backend/Bridge.java +52 -5
- package/backend/bridge.jar +0 -0
- package/bin/diagnose.mjs +22 -0
- package/homebridge-ui/public/index.html +114 -8
- package/lib/camera.mjs +223 -54
- package/lib/diagnostics.mjs +78 -0
- package/lib/platform.mjs +3 -1
- package/lib/runtime-factory.mjs +7 -2
- package/lib/runtime.mjs +50 -5
- package/npm-shrinkwrap.json +4 -3
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
登录、家庭选择、远程摄像头连接、快照和 HomeKit 视频由插件管理,无需独立的 viewer / relay 容器或另一个摄像头插件。摄像头可以位于其他网络。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
目前后端仅实现 CS2/vendor 4。已在一台小米智能摄像机 3 Pro 云台版上验证跨网络远程取流,以及真实 Apple“家庭”的配对、预览和直播。具体测试环境见下文“验证范围”;其他型号、地区和网络环境尚未逐一验证。
|
|
8
8
|
|
|
9
9
|
要求 Linux x86_64、Node.js 22 或 24、Homebridge 1.9+ 或 2.x,以及支持自定义插件设置页的 Homebridge UI。适用 Synology DS1618+ 上以 host 网络运行的 Homebridge Docker。支持 glibc 和 musl;其他架构不支持。
|
|
10
10
|
|
|
11
11
|
## 安装和首次导入
|
|
12
12
|
|
|
13
|
-
公开 npm
|
|
13
|
+
公开 npm 包包含插件代码和由这些源码编译的自有 Java 适配器,**首次需要从已有的本地运行包导入组件**。运行组件不随 npm 分发,也不会自动从网上下载;详情见 `THIRD_PARTY_NOTICES.md`。目前支持指定版本的本地包 `homebridge-mijia-remote-camera-0.1.0.tgz`,已有该包的用户可按以下步骤迁移。没有该本地包时,npm 包本身不能提供视频功能。
|
|
14
14
|
|
|
15
15
|
先停止此前连接这台摄像头的 `viewer` / `relay` 容器。若已经安装旧的无 scope 插件,先在 Homebridge 管理界面的终端内卸载并重启 Homebridge,释放旧进程:
|
|
16
16
|
|
|
@@ -52,10 +52,12 @@ mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz -
|
|
|
52
52
|
|
|
53
53
|
已有有效插件会话时可点击“使用已有登录,读取家庭”。取消或失败的授权保留原凭据。更换已发布的摄像头需要重启 Homebridge;同一摄像头更新登录或家庭选择可以刷新连接。
|
|
54
54
|
|
|
55
|
-
摄像头作为独立配件发布。在 iPhone“家庭 → 添加配件 →
|
|
55
|
+
摄像头作为独立配件发布。在 iPhone“家庭 → 添加配件 → 更多选项”中选择它,使用该摄像头启动日志中 `Please add [...] manually in Home app. Setup Code:` 后面的配对码。插件运行在子桥时,这个码可能与 Homebridge 首页的主桥配对码不同。
|
|
56
56
|
|
|
57
57
|
默认最高 640×360、12 fps、700 kbps,最多一路实时视频。当前提供实时视频和快照,未接入声音、对讲、云台、运动事件、录像回放及 HomeKit Secure Video。
|
|
58
58
|
|
|
59
|
+
为兼容 Apple“家庭”的媒体能力协商,插件默认公布 AAC-ELD 16 kHz 音频能力和静音 Microphone 服务;实际不采集或发送摄像头声音。家庭中可能显示麦克风控件,目前仍只提供视频。
|
|
60
|
+
|
|
59
61
|
## 数据和运行管理
|
|
60
62
|
|
|
61
63
|
会话、设备选择和导入的组件保存在:
|
|
@@ -74,4 +76,20 @@ mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz -
|
|
|
74
76
|
|
|
75
77
|
## 验证范围
|
|
76
78
|
|
|
77
|
-
|
|
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;摄像头端口与主桥/子桥端口不同。
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# Distribution and components
|
|
2
2
|
|
|
3
3
|
The code distributed in this npm package is licensed under MIT. It does not
|
|
4
|
-
contain the micam backend,
|
|
4
|
+
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
|
+
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.
|
|
7
10
|
|
|
8
11
|
The local setup command imports runtime files already available on the user's
|
|
9
12
|
machine. It does not download them or upload them to npm. Local runtime files
|
package/backend/Bridge.java
CHANGED
|
@@ -29,9 +29,10 @@ import java.util.Map;
|
|
|
29
29
|
import java.util.Set;
|
|
30
30
|
import java.util.UUID;
|
|
31
31
|
import java.util.concurrent.atomic.AtomicBoolean;
|
|
32
|
+
import java.util.function.LongSupplier;
|
|
32
33
|
import java.util.function.Supplier;
|
|
33
34
|
|
|
34
|
-
/**
|
|
35
|
+
/** Machine interface for Homebridge. No terminal prompts or raw upstream logs. */
|
|
35
36
|
public final class Bridge {
|
|
36
37
|
private static final ObjectMapper JSON = new ObjectMapper();
|
|
37
38
|
private static final PrintStream OUTPUT = System.out;
|
|
@@ -44,7 +45,43 @@ public final class Bridge {
|
|
|
44
45
|
private static final class Failure extends RuntimeException {
|
|
45
46
|
final int exit;
|
|
46
47
|
final String reason;
|
|
47
|
-
Failure(int exit, String reason) { this.exit = exit; this.reason = 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
|
+
}
|
|
48
85
|
}
|
|
49
86
|
|
|
50
87
|
public static void main(String[] args) {
|
|
@@ -85,6 +122,7 @@ public final class Bridge {
|
|
|
85
122
|
}
|
|
86
123
|
if (args.length < 4) throw new Failure(7, "invalid_request");
|
|
87
124
|
Home home = new Home(identifier(args[2]), identifier(args[3]), "", false);
|
|
125
|
+
if (action.equals("stream")) progress(Stage.DEVICE_LOOKUP);
|
|
88
126
|
List<Device> cameras = request(() -> cloud.deviceList(home)).stream().filter(Device::isCamera).toList();
|
|
89
127
|
if (action.equals("devices") && args.length == 4) {
|
|
90
128
|
emit(Map.of("success", true, "devices", cameras.stream().map(device -> Map.<String, Object>of(
|
|
@@ -99,6 +137,7 @@ public final class Bridge {
|
|
|
99
137
|
Device device = cameras.stream().filter(camera -> did.equals(camera.did())).findFirst()
|
|
100
138
|
.orElseThrow(() -> new Failure(7, "camera_not_in_home"));
|
|
101
139
|
if (!device.online()) throw new Failure(4, "camera_offline");
|
|
140
|
+
progress(Stage.PROVISIONING);
|
|
102
141
|
Provisioning provision = request(() -> cloud.missGetVendor(did));
|
|
103
142
|
if (!provision.isCs2()) {
|
|
104
143
|
emit(Map.of("success", false, "reason", "unsupported_transport", "vendor", provision.vendorId()));
|
|
@@ -141,22 +180,24 @@ public final class Bridge {
|
|
|
141
180
|
Thread hook = new Thread(() -> { running.set(false); resources.close(); }, "mijia-bridge-stop");
|
|
142
181
|
Runtime.getRuntime().addShutdownHook(hook);
|
|
143
182
|
try {
|
|
183
|
+
progress(Stage.RELAY_CONNECT, provision.vendorId());
|
|
144
184
|
resources.relay = new RelaySession(Uid.parse(provision.p2pId()), provision.servers(), null);
|
|
145
185
|
RelaySession.Established established = resources.relay.connect(Duration.ofSeconds(90));
|
|
146
186
|
resources.connection = new PpppConnection(resources.relay.socket(), established.relay(), null);
|
|
147
187
|
resources.connection.start();
|
|
148
188
|
resources.camera = new MissClient(resources.connection);
|
|
189
|
+
progress(Stage.AUTHENTICATION, provision.vendorId());
|
|
149
190
|
resources.camera.authenticate(provision.clientPublic(), provision.sign(), Duration.ofSeconds(15));
|
|
150
191
|
resources.camera.useSessionKey(MissCrypto.sessionKey(provision.clientPrivate(), provision.devicePublic()));
|
|
192
|
+
progress(Stage.WAITING_VIDEO, provision.vendorId());
|
|
151
193
|
resources.camera.startVideo(quality, false);
|
|
152
|
-
|
|
194
|
+
VideoWatchdog watchdog = new VideoWatchdog(System::nanoTime, Duration.ofSeconds(10), Duration.ofSeconds(15));
|
|
153
195
|
while (running.get()) {
|
|
154
196
|
MediaPacket frame = resources.camera.nextMedia(Duration.ofSeconds(1));
|
|
197
|
+
watchdog.observe(frame != null && frame.isVideo(), frame != null && frame.isVideo() && frame.isKeyframe());
|
|
155
198
|
if (frame == null || !frame.isVideo()) {
|
|
156
|
-
if (System.nanoTime() - lastVideo > Duration.ofSeconds(10).toNanos()) throw new Failure(5, "no_video");
|
|
157
199
|
continue;
|
|
158
200
|
}
|
|
159
|
-
lastVideo = System.nanoTime();
|
|
160
201
|
if (resources.server == null) {
|
|
161
202
|
if (!frame.isKeyframe()) continue;
|
|
162
203
|
resources.server = new HttpStreamServer(port, "/cam.ts", null);
|
|
@@ -209,6 +250,12 @@ public final class Bridge {
|
|
|
209
250
|
"invalid_device_response", "device_pagination_incomplete", "home_request_failed", "home_request_rejected")
|
|
210
251
|
.contains(value) ? value : "discovery_failed";
|
|
211
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
|
+
}
|
|
212
259
|
private static synchronized void emit(Object value) {
|
|
213
260
|
try { OUTPUT.println(JSON.writeValueAsString(value)); OUTPUT.flush(); }
|
|
214
261
|
catch (Exception ignored) { }
|
|
Binary file
|
package/bin/diagnose.mjs
ADDED
|
@@ -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
|
+
}
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
<div class="eyebrow">MIJIA · HOMEBRIDGE</div>
|
|
13
13
|
<h1>米家远程摄像头</h1>
|
|
14
14
|
<p class="intro">登录你的小米账号,选择家庭和摄像头。视频由此插件连接米家远程中继,再接入 Apple 家庭。</p>
|
|
15
|
-
<div id="runtimeStatus" class="status"><span class="dot"></span><span id="runtimeText">正在检查插件状态…</span></div>
|
|
15
|
+
<div id="runtimeStatus" class="status"><span class="dot"></span><div><span id="runtimeText">正在检查插件状态…</span><div id="connectionDetail" class="small"></div><div id="mediaDetail" class="small"></div></div></div>
|
|
16
|
+
<details style="margin:-8px 0 20px"><summary style="cursor:pointer;color:var(--muted)">连接诊断</summary><p class="small" style="margin-top:10px">以下报告只包含连接阶段和状态,可复制用于排查。</p><pre id="diagnosticsReport" style="white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;padding:12px;background:var(--panel);border-radius:8px">尚未读取到连接状态。</pre><button id="copyDiagnostics" class="secondary" disabled>复制脱敏诊断</button><p id="diagnosticsHint" class="small" aria-live="polite" style="margin-top:8px"></p></details>
|
|
16
17
|
<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
18
|
<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
19
|
<section><div class="step">01 · 小米账号</div><h2>通过官方页面登录</h2>
|
|
@@ -26,13 +27,13 @@
|
|
|
26
27
|
<div class="row"><button id="save" disabled>使用这台摄像头</button></div><p id="selectionState" class="small" style="margin-top:12px"></p>
|
|
27
28
|
</section>
|
|
28
29
|
<div id="error" class="error" role="alert"></div>
|
|
29
|
-
<div class="footer small"><p>首次连接可能需要一两分钟。当前只提供实时视频和快照。</p><p>配件发布后,在“Apple 家庭 → 添加配件 →
|
|
30
|
+
<div class="footer small"><p>首次连接可能需要一两分钟。当前只提供实时视频和快照。</p><p>配件发布后,在“Apple 家庭 → 添加配件 → 更多选项”中选择摄像头,使用摄像头启动日志中的 Setup Code 添加;子桥配对码可能与主桥不同。</p><p>登录数据只保存在 Homebridge 的私有插件目录,不写入插件配置。</p></div>
|
|
30
31
|
</main>
|
|
31
32
|
<script>
|
|
32
33
|
(() => {
|
|
33
34
|
const $ = id => document.getElementById(id);
|
|
34
35
|
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
|
+
let homes=[], devices=[], loginId, pollTimer, statusTimer, clockTimer, configs=[], connected=false, authBusy=false, regionTouched=false, regionNeedsLogin=false, runtimeRegion='cn', diagnosticValue={}, statusReceivedAt=0;
|
|
36
37
|
const messages = { plugin_not_running:'插件尚未启动。请先保存配置并重新启动 Homebridge。', auth_required:'需要完成小米登录。',
|
|
37
38
|
unauthenticated:'需要完成小米登录。', network_error:'连接小米服务失败,请稍后重试。', request_timeout:'请求超时,请稍后重试。',
|
|
38
39
|
login_expired:'登录链接已过期,请重新生成。', login_not_approved:'小米授权未完成,请重新尝试。', login_cancelled:'本次登录已取消。',
|
|
@@ -40,7 +41,111 @@
|
|
|
40
41
|
credentials_write_failed:'无法保存登录数据,请检查 Homebridge 插件目录权限。', home_request_rejected:'家庭列表请求被拒绝,请检查账号权限与地区。',
|
|
41
42
|
home_request_failed:'无法读取家庭或设备列表,请检查网络与登录状态。', session_expired:'小米会话已过期,请重新登录。',
|
|
42
43
|
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
|
+
backend_unavailable:'插件后端暂不可用,请查看 Homebridge 状态。', runtime_unavailable:'插件连接暂不可用,请重新启动 Homebridge。', authentication_required:'小米登录需要重新授权,请生成官方登录链接。', camera_offline:'米家报告摄像头离线。', camera_not_in_home:'所选家庭中未找到此摄像头,请重新选择。', unsupported_transport:'当前摄像头的远程协议尚不支持。', no_video:'摄像头尚未返回视频,请检查电源与网络。', connect_timeout:'连接摄像头超时,正在等待重连。', connection_failed:'远程连接未能建立,请检查摄像头与 Homebridge 的网络。', backend_failed:'取流后端未能启动,请查看插件状态。', storage_failed:'无法访问插件私有目录,请检查权限。', runtime_in_use:'另一个插件进程正在使用此目录。', query_timeout:'读取家庭或设备超时,请重试。', not_selected:'请登录并选择摄像头。', auth_in_progress:'正在进行小米授权,请完成或取消后继续。' };
|
|
45
|
+
const diagnosticStates=new Set(['idle','starting','ready','retrying','blocked','stopped','paused']);
|
|
46
|
+
const diagnosticStages=new Set(['device_lookup','provisioning','relay_connect','authentication','waiting_video']);
|
|
47
|
+
const diagnosticReasons=new Set(['not_selected','starting','ready','stopped','retrying','connect_timeout','authentication_required','session_expired','camera_offline','camera_not_in_home','unsupported_transport','invalid_request','invalid_home_selection','invalid_home_response','missing_home_owner','invalid_device_response','device_pagination_incomplete','home_request_failed','home_request_rejected','discovery_failed','backend_failed','connection_failed','no_video','port_in_use','runtime_in_use','runtime_closed','runtime_unavailable','query_timeout','query_cancelled','invalid_backend_response','storage_failed','auth_in_progress']);
|
|
48
|
+
const mediaReasons = new Set(['media_unavailable', 'closed', 'source_not_ready', 'source_timeout', 'source_invalid',
|
|
49
|
+
'snapshot_busy', 'snapshot_timeout', 'snapshot_too_large', 'snapshot_invalid', 'ffmpeg_missing', 'ffmpeg_permission',
|
|
50
|
+
'ffmpeg_spawn', 'ffmpeg_exit', 'ffmpeg_cpu_unsupported', 'input_timeout', 'input_unavailable', 'input_invalid',
|
|
51
|
+
'decode_failed', 'encoder_unavailable', 'encode_failed', 'udp_unreachable', 'udp_in_use', 'udp_error',
|
|
52
|
+
'invalid_prepare', 'invalid_stream', 'start_timeout', 'prepare_timeout', 'receiver_timeout', 'stream_stopped', 'stream_reset']);
|
|
53
|
+
const mediaEvents = new Set(['snapshot_requested', 'snapshot_success', 'snapshot_failure', 'stream_prepared',
|
|
54
|
+
'stream_start', 'stream_first_frame', 'stream_failure', 'stream_stop', 'stream_rtcp_first']);
|
|
55
|
+
const mediaSignals = new Set(['SIGTERM', 'SIGKILL', 'SIGILL', 'SIGABRT', 'SIGSEGV', 'SIGBUS', 'SIGFPE']);
|
|
56
|
+
const mediaStreamStates = new Set(['idle', 'prepared', 'starting', 'active', 'stopped']);
|
|
57
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
58
|
+
const count = value => Number.isSafeInteger(value) && value >= 0;
|
|
59
|
+
const timestamp = value => value === null || (typeof value === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value));
|
|
60
|
+
|
|
61
|
+
function safeMediaDiagnostics(value) {
|
|
62
|
+
const result = {};
|
|
63
|
+
for (const key of ['activeStreams', 'activeJobs']) if (count(value[key])) result[key] = value[key];
|
|
64
|
+
for (const [kind, counters, timestamps] of [
|
|
65
|
+
['snapshot', ['requests', 'successes', 'failures', 'lastBytes'], ['lastRequestedAt', 'lastCompletedAt', 'lastFailureAt']],
|
|
66
|
+
['stream', ['prepared', 'starts', 'firstFrames', 'failures', 'stops', 'rtcpPackets'],
|
|
67
|
+
['lastPreparedAt', 'lastStartedAt', 'lastFirstFrameAt', 'lastStoppedAt', 'lastRtcpAt', 'lastFailureAt']],
|
|
68
|
+
]) {
|
|
69
|
+
const source = value[kind];
|
|
70
|
+
if (!record(source)) continue;
|
|
71
|
+
const section = {};
|
|
72
|
+
for (const key of counters) if (count(source[key])) section[key] = source[key];
|
|
73
|
+
for (const key of timestamps) if (timestamp(source[key])) section[key] = source[key];
|
|
74
|
+
if (source.lastFailure === null || mediaReasons.has(source.lastFailure)) section.lastFailure = source.lastFailure;
|
|
75
|
+
if (kind === 'snapshot' && (source.lastElapsedMs === null || count(source.lastElapsedMs))) section.lastElapsedMs = source.lastElapsedMs;
|
|
76
|
+
if (kind === 'stream' && mediaStreamStates.has(source.state)) section.state = source.state;
|
|
77
|
+
if (Object.keys(section).length) result[kind] = section;
|
|
78
|
+
}
|
|
79
|
+
if (value.lastEvent === null) result.lastEvent = null;
|
|
80
|
+
else if (record(value.lastEvent) && mediaEvents.has(value.lastEvent.type)
|
|
81
|
+
&& typeof value.lastEvent.at === 'string' && timestamp(value.lastEvent.at)) {
|
|
82
|
+
const source = value.lastEvent, event = { type: source.type, at: source.at };
|
|
83
|
+
for (const key of ['width', 'height', 'bytes', 'elapsedMs', 'rtcpPackets']) if (count(source[key])) event[key] = source[key];
|
|
84
|
+
if (mediaReasons.has(source.reason)) event.reason = source.reason;
|
|
85
|
+
if (count(source.exitCode) && source.exitCode <= 65535) event.exitCode = source.exitCode;
|
|
86
|
+
if (mediaSignals.has(source.signal)) event.signal = source.signal;
|
|
87
|
+
if (typeof source.cached === 'boolean') event.cached = source.cached;
|
|
88
|
+
result.lastEvent = event;
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Keep the shareable report in step with lib/diagnostics.mjs; never serialize the raw RPC response.
|
|
94
|
+
function safeDiagnostics(value = {}) {
|
|
95
|
+
const result = {};
|
|
96
|
+
if (!record(value)) return result;
|
|
97
|
+
if (diagnosticStates.has(value.state)) result.state = value.state;
|
|
98
|
+
if (diagnosticReasons.has(value.reason)) result.reason = value.reason;
|
|
99
|
+
if (diagnosticStages.has(value.stage)) result.stage = value.stage;
|
|
100
|
+
for (const key of ['ready', 'videoReceived', 'paused', 'selected', 'authenticated']) {
|
|
101
|
+
if (typeof value[key] === 'boolean') result[key] = value[key];
|
|
102
|
+
}
|
|
103
|
+
if (['cn', 'sg', 'de', 'us', 'ru', 'i2'].includes(value.region)) result.region = value.region;
|
|
104
|
+
for (const key of ['attempt', 'connectingElapsedSeconds', 'retryInSeconds', 'vendor']) {
|
|
105
|
+
if (Number.isSafeInteger(value[key]) && value[key] >= 0 && value[key] <= (key === 'vendor' ? 100 : 1000000000)) result[key] = value[key];
|
|
106
|
+
}
|
|
107
|
+
if (value.lastFailure === null || diagnosticReasons.has(value.lastFailure)) result.lastFailure = value.lastFailure;
|
|
108
|
+
for (const key of ['connectingSince', 'retryAt', 'lastFailureAt']) {
|
|
109
|
+
if (timestamp(value[key])) result[key] = value[key];
|
|
110
|
+
}
|
|
111
|
+
if (value.media === null) result.media = null;
|
|
112
|
+
else if (record(value.media)) {
|
|
113
|
+
const media = safeMediaDiagnostics(value.media);
|
|
114
|
+
if (Object.keys(media).length) result.media = media;
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
const stageLabels={device_lookup:'正在核对摄像头',provisioning:'正在获取远程连接信息',relay_connect:'正在连接远程中继',authentication:'正在验证摄像头会话',waiting_video:'正在等待摄像头首个关键帧'};
|
|
119
|
+
function renderRuntime(){
|
|
120
|
+
const value=diagnosticValue, since=Math.max(0,Math.floor((Date.now()-statusReceivedAt)/1000));
|
|
121
|
+
const live=value.ready===true&&value.videoReceived===true;
|
|
122
|
+
$('runtimeStatus').dataset.live=String(live);
|
|
123
|
+
const detail=[];
|
|
124
|
+
if(value.state==='starting'){
|
|
125
|
+
$('runtimeText').textContent=stageLabels[value.stage]||'正在连接摄像头…';
|
|
126
|
+
if(value.attempt>0)detail.push(`正在尝试第 ${value.attempt} 次`);
|
|
127
|
+
if(Number.isSafeInteger(value.connectingElapsedSeconds))detail.push(`已等待 ${value.connectingElapsedSeconds+since} 秒`);
|
|
128
|
+
}else if(value.state==='retrying'){
|
|
129
|
+
const remaining=Number.isSafeInteger(value.retryInSeconds)?Math.max(0,value.retryInSeconds-since):0;
|
|
130
|
+
$('runtimeText').textContent=remaining>0?`连接中断,将在 ${remaining} 秒后重试。`:'连接中断,即将重试。';
|
|
131
|
+
if(value.attempt>0)detail.push(`第 ${value.attempt} 次尝试未完成`);
|
|
132
|
+
}else if(live){
|
|
133
|
+
$('runtimeText').textContent='已收到摄像头视频。';
|
|
134
|
+
if(value.attempt>0)detail.push(`第 ${value.attempt} 次尝试已连接`);
|
|
135
|
+
}else if(value.ready===true||value.state==='ready'){
|
|
136
|
+
$('runtimeText').textContent='等待摄像头首帧。';
|
|
137
|
+
}else{
|
|
138
|
+
const states={idle:'请登录并选择摄像头。',stopped:'连接已停止。',paused:'连接已暂停,请选择摄像头启动。',blocked:'连接需要处理。'};
|
|
139
|
+
$('runtimeText').textContent=messages[value.reason]||states[value.state]||'插件已运行,可以登录并选择摄像头。';
|
|
140
|
+
if(value.attempt>0)detail.push(`已尝试 ${value.attempt} 次`);
|
|
141
|
+
}
|
|
142
|
+
if(value.lastFailure)detail.push(`上次连接:${messages[value.lastFailure]||'未能完成,请查看连接诊断。'}`);
|
|
143
|
+
$('connectionDetail').textContent=detail.join(' · ');
|
|
144
|
+
const media=[];
|
|
145
|
+
if(Number.isSafeInteger(value.media?.snapshot?.requests))media.push(`快照 ${value.media.snapshot.requests} 次`);
|
|
146
|
+
if(Number.isSafeInteger(value.media?.stream?.starts))media.push(`直播 ${value.media.stream.starts} 次`);
|
|
147
|
+
$('mediaDetail').textContent=media.length?'HomeKit 媒体请求:'+media.join(' · '):'';
|
|
148
|
+
}
|
|
44
149
|
function reason(error){return error?.error?.reason || error?.requestError?.reason || error?.reason || error?.message;}
|
|
45
150
|
function showError(error){const key=reason(error); $('error').textContent=messages[key] || '操作未完成,请检查插件状态后重试。'; if(key==='plugin_not_running')$('bootstrap').classList.remove('hide');}
|
|
46
151
|
function clearError(){$('error').textContent='';}
|
|
@@ -50,7 +155,8 @@
|
|
|
50
155
|
function chosenDevice(){return devices[Number($('devices').value)];}
|
|
51
156
|
function setLoginBusy(busy){authBusy=busy;$('login').disabled=busy||!connected; $('load').disabled=busy||!connected||regionNeedsLogin; $('region').disabled=busy; $('cancel').classList.toggle('hide',!busy);}
|
|
52
157
|
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);
|
|
158
|
+
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);diagnosticValue=safeDiagnostics(value);statusReceivedAt=Date.now();$('diagnosticsReport').textContent=JSON.stringify(diagnosticValue,null,2);$('copyDiagnostics').disabled=false;renderRuntime();}catch(error){connected=false;$('login').disabled=true;$('load').disabled=true;$('runtimeStatus').dataset.live='false';$('runtimeText').textContent='等待插件启动';$('connectionDetail').textContent='';$('mediaDetail').textContent='';showError(error);}}
|
|
159
|
+
$('copyDiagnostics').onclick=async()=>{try{if(typeof navigator==='undefined'||typeof navigator.clipboard?.writeText!=='function')throw Error('clipboard_unavailable');await navigator.clipboard.writeText($('diagnosticsReport').textContent);$('diagnosticsHint').textContent='已复制脱敏诊断。';}catch{$('diagnosticsHint').textContent='浏览器无法自动复制,请手动选中上方诊断报告并复制。';}};
|
|
54
160
|
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
161
|
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
162
|
function deviceChanged(){const device=chosenDevice();$('cameraName').disabled=!device;$('save').disabled=!device;$('cameraName').value=device?(device.name||'米家摄像头'):'';}
|
|
@@ -58,11 +164,11 @@
|
|
|
58
164
|
$('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
165
|
$('cancel').onclick=async()=>{clearTimeout(pollTimer);try{await request('/auth/cancel',{loginId});}catch(error){showError(error);}hideLoginLink();setLoginBusy(false);$('authState').textContent='已取消。已有登录数据保留。';};
|
|
60
166
|
$('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 使配件变更生效。':'
|
|
167
|
+
$('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 使配件变更生效。':'选择已保存,正在连接摄像头。配对码见摄像头启动日志中的 Setup Code。';await status();}catch(error){showError(error);}finally{$('save').disabled=false;}};
|
|
62
168
|
$('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
169
|
$('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);});
|
|
170
|
+
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);clockTimer=setInterval(()=>{if(connected)renderRuntime();},1000);}catch(error){showError(error);}}
|
|
171
|
+
window.addEventListener('beforeunload',()=>{clearInterval(statusTimer);clearInterval(clockTimer);clearTimeout(pollTimer);});
|
|
66
172
|
init();
|
|
67
173
|
})();
|
|
68
174
|
</script></body></html>
|
package/lib/camera.mjs
CHANGED
|
@@ -2,7 +2,47 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { createSocket } from 'node:dgram';
|
|
3
3
|
import { isIP } from 'node:net';
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const MEDIA_REASONS = new Set(['media_unavailable', 'closed', 'source_not_ready', 'source_timeout', 'source_invalid',
|
|
6
|
+
'snapshot_busy', 'snapshot_timeout', 'snapshot_too_large', 'snapshot_invalid', 'ffmpeg_missing', 'ffmpeg_permission',
|
|
7
|
+
'ffmpeg_spawn', 'ffmpeg_exit', 'ffmpeg_cpu_unsupported', 'input_timeout', 'input_unavailable', 'input_invalid',
|
|
8
|
+
'decode_failed', 'encoder_unavailable', 'encode_failed', 'udp_unreachable', 'udp_in_use', 'udp_error',
|
|
9
|
+
'invalid_prepare', 'invalid_stream', 'start_timeout', 'prepare_timeout', 'receiver_timeout', 'stream_stopped', 'stream_reset']);
|
|
10
|
+
const SIGNALS = new Set(['SIGTERM', 'SIGKILL', 'SIGILL', 'SIGABRT', 'SIGSEGV', 'SIGBUS', 'SIGFPE']);
|
|
11
|
+
const EVENTS = new Set(['snapshot_requested', 'snapshot_success', 'snapshot_failure', 'stream_prepared',
|
|
12
|
+
'stream_start', 'stream_first_frame', 'stream_failure', 'stream_stop', 'stream_rtcp_first']);
|
|
13
|
+
const safeReason = (value, fallback = 'media_unavailable') => MEDIA_REASONS.has(value) ? value : fallback;
|
|
14
|
+
const safeExit = value => Number.isInteger(value) && value >= 0 && value <= 65535 ? value : null;
|
|
15
|
+
const safeSignal = value => SIGNALS.has(value) ? value : null;
|
|
16
|
+
const error = (reason, code, signal) => Object.assign(new Error('摄像头暂不可用,请在插件设置中检查米家连接。'),
|
|
17
|
+
{ reason: safeReason(reason), exitCode: safeExit(code), signal: safeSignal(signal) });
|
|
18
|
+
const elapsed = started => Math.max(0, Math.floor(performance.now() - started));
|
|
19
|
+
function spawnReason(failure) {
|
|
20
|
+
return ({ ENOENT: 'ffmpeg_missing', EACCES: 'ffmpeg_permission', EPERM: 'ffmpeg_permission' })[failure?.code] || 'ffmpeg_spawn';
|
|
21
|
+
}
|
|
22
|
+
function stderrClassifier() {
|
|
23
|
+
let tail = Buffer.alloc(0), reason;
|
|
24
|
+
const patterns = [
|
|
25
|
+
[/Unknown encoder|Encoder .*not found/i, 'encoder_unavailable'],
|
|
26
|
+
[/Permission denied/i, 'ffmpeg_permission'],
|
|
27
|
+
[/Connection timed out|Operation timed out/i, 'input_timeout'],
|
|
28
|
+
[/Connection refused|Server returned [45]\d\d|HTTP error [45]\d\d/i, 'input_unavailable'],
|
|
29
|
+
[/Error while decoding|Invalid NAL|Error parsing NAL/i, 'decode_failed'],
|
|
30
|
+
[/Invalid data found|Could not find codec parameters|could not find.*codec/i, 'input_invalid'],
|
|
31
|
+
[/Error while opening encoder|Error initializing.*encoder|Error setting.*profile/i, 'encode_failed'],
|
|
32
|
+
[/Network is unreachable|No route to host/i, 'udp_unreachable'],
|
|
33
|
+
[/Address already in use/i, 'udp_in_use'],
|
|
34
|
+
];
|
|
35
|
+
return {
|
|
36
|
+
consume(chunk) {
|
|
37
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk.subarray(-8192) : Buffer.from(String(chunk).slice(-8192)).subarray(-8192);
|
|
38
|
+
tail = Buffer.concat([tail, bytes]).subarray(-8192);
|
|
39
|
+
const value = tail.toString('utf8');
|
|
40
|
+
for (const [pattern, category] of patterns) if (pattern.test(value)) { reason = category; break; }
|
|
41
|
+
},
|
|
42
|
+
failure(code, signal) { return signal === 'SIGILL' ? 'ffmpeg_cpu_unsupported' : reason || 'ffmpeg_exit'; },
|
|
43
|
+
clear() { tail = Buffer.alloc(0); },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
6
46
|
const once = callback => {
|
|
7
47
|
let called = false;
|
|
8
48
|
return (...args) => { if (!called) { called = true; callback(...args); } };
|
|
@@ -15,9 +55,9 @@ function safeSource(value) {
|
|
|
15
55
|
const url = new URL(value);
|
|
16
56
|
if (url.protocol !== 'http:' || !['127.0.0.1', '[::1]'].includes(url.hostname)
|
|
17
57
|
|| url.username || url.password || url.search || url.hash || url.pathname !== '/cam.ts'
|
|
18
|
-
|| !/^\d+$/.test(url.port) || +url.port < 1024 || +url.port > 65535) throw error();
|
|
58
|
+
|| !/^\d+$/.test(url.port) || +url.port < 1024 || +url.port > 65535) throw error('source_invalid');
|
|
19
59
|
return url.href;
|
|
20
|
-
} catch { throw error(); }
|
|
60
|
+
} catch { throw error('source_invalid'); }
|
|
21
61
|
}
|
|
22
62
|
function sourceArgs(source) {
|
|
23
63
|
return ['-hide_banner', '-nostdin', '-loglevel', 'error', '-rw_timeout', '8000000',
|
|
@@ -29,12 +69,25 @@ function resize(width, height) {
|
|
|
29
69
|
|
|
30
70
|
/** Native Homebridge/HAP streaming delegate. All transport keys remain in memory. */
|
|
31
71
|
export class MijiaCameraDelegate {
|
|
32
|
-
constructor({ api, log, runtime, config = {}, ffmpegPath, spawnProcess = spawn
|
|
72
|
+
constructor({ api, log, runtime, config = {}, ffmpegPath, spawnProcess = spawn, onMediaEvent,
|
|
73
|
+
startDeadlineMs = 7500, snapshotTimeoutMs = 10000, audioCompatibility = true }) {
|
|
33
74
|
this.api = api;
|
|
75
|
+
this.audioCompatibility = audioCompatibility === true;
|
|
34
76
|
this.log = log;
|
|
35
77
|
this.runtime = runtime;
|
|
36
78
|
this.ffmpegPath = ffmpegPath || config.ffmpegPath || 'ffmpeg';
|
|
37
79
|
this.spawnProcess = spawnProcess;
|
|
80
|
+
this.onMediaEvent = typeof onMediaEvent === 'function' ? onMediaEvent : undefined;
|
|
81
|
+
this.startDeadlineMs = number(startDeadlineMs, 7500, 1, 7500);
|
|
82
|
+
this.snapshotTimeoutMs = number(snapshotTimeoutMs, 10000, 1, 10000);
|
|
83
|
+
this.media = {
|
|
84
|
+
snapshot: { requests: 0, successes: 0, failures: 0, lastRequestedAt: null, lastCompletedAt: null,
|
|
85
|
+
lastElapsedMs: null, lastBytes: 0, lastFailure: null, lastFailureAt: null },
|
|
86
|
+
stream: { prepared: 0, starts: 0, firstFrames: 0, failures: 0, stops: 0, rtcpPackets: 0,
|
|
87
|
+
lastPreparedAt: null, lastStartedAt: null, lastFirstFrameAt: null, lastStoppedAt: null,
|
|
88
|
+
lastRtcpAt: null, lastFailure: null, lastFailureAt: null },
|
|
89
|
+
lastEvent: null,
|
|
90
|
+
};
|
|
38
91
|
this.limits = {
|
|
39
92
|
width: number(config.maxWidth, 640, 160, 1920),
|
|
40
93
|
height: number(config.maxHeight, 360, 120, 1080),
|
|
@@ -45,11 +98,13 @@ export class MijiaCameraDelegate {
|
|
|
45
98
|
this.jobs = new Set();
|
|
46
99
|
this.snapshots = new Map();
|
|
47
100
|
this.closed = false;
|
|
101
|
+
// Negotiate one-way AAC-ELD for Home compatibility; camera audio remains disabled.
|
|
48
102
|
this.controller = new api.hap.CameraController({
|
|
49
103
|
cameraStreamCount: 1,
|
|
50
104
|
delegate: this,
|
|
51
105
|
streamingOptions: {
|
|
52
106
|
supportedCryptoSuites: [0],
|
|
107
|
+
...(this.audioCompatibility ? { audio: { twoWayAudio: false, codecs: [{ type: 'AAC-eld', samplerate: 16 }] } } : {}),
|
|
53
108
|
video: {
|
|
54
109
|
codec: { profiles: [0, 1, 2], levels: [0, 1, 2] },
|
|
55
110
|
resolutions: [[320, 180, 30], [320, 240, 15], [320, 240, 30],
|
|
@@ -59,34 +114,76 @@ export class MijiaCameraDelegate {
|
|
|
59
114
|
});
|
|
60
115
|
}
|
|
61
116
|
|
|
117
|
+
mediaEvent(type, details = {}) {
|
|
118
|
+
if (!EVENTS.has(type)) return;
|
|
119
|
+
const value = { type, at: new Date().toISOString() };
|
|
120
|
+
for (const key of ['width', 'height', 'bytes', 'elapsedMs', 'rtcpPackets']) {
|
|
121
|
+
if (Number.isSafeInteger(details[key]) && details[key] >= 0) value[key] = details[key];
|
|
122
|
+
}
|
|
123
|
+
if (MEDIA_REASONS.has(details.reason)) value.reason = details.reason;
|
|
124
|
+
if (safeExit(details.exitCode) !== null) value.exitCode = details.exitCode;
|
|
125
|
+
if (safeSignal(details.signal)) value.signal = details.signal;
|
|
126
|
+
if (typeof details.cached === 'boolean') value.cached = details.cached;
|
|
127
|
+
const snapshot = this.media.snapshot, stream = this.media.stream;
|
|
128
|
+
const increment = (object, key) => { object[key] = Math.min(Number.MAX_SAFE_INTEGER, object[key] + 1); };
|
|
129
|
+
if (type === 'snapshot_requested') { increment(snapshot, 'requests'); snapshot.lastRequestedAt = value.at; }
|
|
130
|
+
if (type === 'snapshot_success' || type === 'snapshot_failure') {
|
|
131
|
+
increment(snapshot, type === 'snapshot_success' ? 'successes' : 'failures');
|
|
132
|
+
snapshot.lastCompletedAt = value.at; snapshot.lastElapsedMs = value.elapsedMs ?? null;
|
|
133
|
+
snapshot.lastBytes = type === 'snapshot_success' ? value.bytes ?? 0 : 0;
|
|
134
|
+
if (type === 'snapshot_failure') { snapshot.lastFailure = value.reason ?? 'media_unavailable'; snapshot.lastFailureAt = value.at; }
|
|
135
|
+
}
|
|
136
|
+
const transitions = { stream_prepared: ['prepared', 'lastPreparedAt'], stream_start: ['starts', 'lastStartedAt'],
|
|
137
|
+
stream_first_frame: ['firstFrames', 'lastFirstFrameAt'], stream_stop: ['stops', 'lastStoppedAt'] };
|
|
138
|
+
if (transitions[type]) { const [count, timestamp] = transitions[type]; increment(stream, count); stream[timestamp] = value.at; }
|
|
139
|
+
if (type === 'stream_failure') { increment(stream, 'failures'); stream.lastFailure = value.reason ?? 'media_unavailable'; stream.lastFailureAt = value.at; }
|
|
140
|
+
this.media.lastEvent = Object.freeze(value);
|
|
141
|
+
try { Promise.resolve(this.onMediaEvent?.({ ...value })).catch(() => {}); } catch {}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
getMediaDiagnostics() {
|
|
145
|
+
const session = this.sessions.values().next().value;
|
|
146
|
+
return { snapshot: { ...this.media.snapshot }, stream: { ...this.media.stream, state: session?.state || 'idle' },
|
|
147
|
+
activeStreams: this.sessions.size, activeJobs: this.jobs.size,
|
|
148
|
+
lastEvent: this.media.lastEvent ? { ...this.media.lastEvent } : null };
|
|
149
|
+
}
|
|
150
|
+
|
|
62
151
|
async source() {
|
|
63
|
-
if (this.closed) throw error();
|
|
152
|
+
if (this.closed) throw error('closed');
|
|
64
153
|
let timer;
|
|
65
154
|
try {
|
|
66
155
|
const source = await Promise.race([
|
|
67
156
|
this.runtime.ensureReady({ timeoutMs: 3000 }),
|
|
68
|
-
new Promise((_, reject) => { timer = setTimeout(() => reject(error()), 3200); timer.unref(); }),
|
|
157
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(error('source_timeout')), 3200); timer.unref(); }),
|
|
69
158
|
]);
|
|
70
|
-
if (this.closed) throw error();
|
|
159
|
+
if (this.closed) throw error('closed');
|
|
71
160
|
return safeSource(source);
|
|
161
|
+
} catch (failure) {
|
|
162
|
+
throw error(safeReason(failure?.reason, 'source_not_ready'));
|
|
72
163
|
} finally { clearTimeout(timer); }
|
|
73
164
|
}
|
|
74
165
|
|
|
75
166
|
handleSnapshotRequest(request, callback) {
|
|
76
|
-
const done = once(callback);
|
|
77
167
|
const width = Math.min(number(request.width, 320, 2, 4096), this.limits.width);
|
|
78
168
|
const height = Math.min(number(request.height, 180, 2, 4096), this.limits.height);
|
|
169
|
+
const started = performance.now();
|
|
170
|
+
this.mediaEvent('snapshot_requested', { width, height });
|
|
171
|
+
const done = once((failure, buffer, cached = false) => {
|
|
172
|
+
this.mediaEvent(failure ? 'snapshot_failure' : 'snapshot_success', { width, height, elapsedMs: elapsed(started), cached,
|
|
173
|
+
...(failure ? { reason: safeReason(failure?.reason), exitCode: failure?.exitCode, signal: failure?.signal } : { bytes: buffer.length }) });
|
|
174
|
+
callback(failure ? error(failure?.reason) : undefined, buffer);
|
|
175
|
+
});
|
|
79
176
|
const key = `${width}x${height}`;
|
|
80
177
|
const existing = this.snapshots.get(key);
|
|
81
178
|
if (existing && existing.expires > Date.now()) {
|
|
82
|
-
existing.promise.then(buffer => done(undefined, buffer
|
|
179
|
+
existing.promise.then(buffer => done(undefined, buffer, existing.expires !== Infinity), failure => done(failure));
|
|
83
180
|
return;
|
|
84
181
|
}
|
|
85
182
|
for (const [cachedKey, entry] of this.snapshots) {
|
|
86
183
|
if (entry.expires <= Date.now()) { clearTimeout(entry.timer); this.snapshots.delete(cachedKey); }
|
|
87
184
|
}
|
|
88
185
|
if ([...this.snapshots.values()].filter(entry => entry.expires === Infinity).length >= 2) {
|
|
89
|
-
done(error()); return;
|
|
186
|
+
done(error('snapshot_busy')); return;
|
|
90
187
|
}
|
|
91
188
|
if (this.snapshots.size >= 4) {
|
|
92
189
|
const staleKey = [...this.snapshots].find(([, entry]) => entry.expires !== Infinity)?.[0];
|
|
@@ -105,7 +202,7 @@ export class MijiaCameraDelegate {
|
|
|
105
202
|
}, 3000);
|
|
106
203
|
entry.timer.unref();
|
|
107
204
|
}, () => { if (this.snapshots.get(key) === entry) this.snapshots.delete(key); });
|
|
108
|
-
promise.then(buffer => done(undefined, buffer),
|
|
205
|
+
promise.then(buffer => done(undefined, buffer), failure => done(failure));
|
|
109
206
|
}
|
|
110
207
|
|
|
111
208
|
async snapshot(width, height) {
|
|
@@ -114,69 +211,101 @@ export class MijiaCameraDelegate {
|
|
|
114
211
|
let process;
|
|
115
212
|
let output = Buffer.alloc(0);
|
|
116
213
|
let timer;
|
|
214
|
+
const diagnostic = stderrClassifier();
|
|
117
215
|
const finish = once((failure, buffer) => {
|
|
118
216
|
clearTimeout(timer);
|
|
217
|
+
diagnostic.clear();
|
|
119
218
|
if (process) this.jobs.delete(process);
|
|
120
|
-
failure ? reject(
|
|
219
|
+
failure ? reject(failure) : resolve(buffer);
|
|
121
220
|
});
|
|
122
221
|
try {
|
|
123
222
|
process = this.spawnProcess(this.ffmpegPath, [
|
|
124
223
|
...sourceArgs(source), '-map', '0:v:0', '-an', '-sn', '-dn',
|
|
125
224
|
'-vf', resize(width, height), '-frames:v', '1', '-threads', '1',
|
|
126
225
|
'-f', 'image2pipe', '-vcodec', 'mjpeg', 'pipe:1',
|
|
127
|
-
], { stdio: ['ignore', 'pipe', '
|
|
226
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
128
227
|
this.jobs.add(process);
|
|
228
|
+
process.stderr?.on('data', data => diagnostic.consume(data));
|
|
129
229
|
process.stdout.on('data', data => {
|
|
130
230
|
if (output.length + data.length > 8 * 1024 * 1024) {
|
|
231
|
+
finish(error('snapshot_too_large'));
|
|
131
232
|
this.kill(process);
|
|
132
|
-
finish(error());
|
|
133
233
|
} else output = Buffer.concat([output, data]);
|
|
134
234
|
});
|
|
135
|
-
process.once('error',
|
|
136
|
-
process.once('close', code => {
|
|
235
|
+
process.once('error', failure => finish(error(spawnReason(failure))));
|
|
236
|
+
process.once('close', (code, signal) => {
|
|
137
237
|
const jpeg = code === 0 && output.length > 4 && output[0] === 0xff && output[1] === 0xd8;
|
|
138
|
-
finish(jpeg ? undefined : error(), output);
|
|
238
|
+
finish(jpeg ? undefined : error(code === 0 ? 'snapshot_invalid' : diagnostic.failure(code, signal), code, signal), output);
|
|
139
239
|
});
|
|
140
|
-
timer = setTimeout(() => { this.kill(process);
|
|
240
|
+
timer = setTimeout(() => { finish(error('snapshot_timeout')); this.kill(process); }, this.snapshotTimeoutMs);
|
|
141
241
|
timer.unref();
|
|
142
|
-
} catch { finish(error()); }
|
|
242
|
+
} catch (failure) { finish(error(spawnReason(failure))); }
|
|
143
243
|
});
|
|
144
244
|
}
|
|
145
245
|
|
|
146
246
|
prepareStream(request, callback) {
|
|
147
247
|
const done = once(callback);
|
|
148
|
-
const source = request.video;
|
|
248
|
+
const source = request.video, audio = request.audio;
|
|
249
|
+
const validAudio = !this.audioCompatibility || (audio && Number.isInteger(audio.port) && audio.port >= 1 && audio.port <= 65535
|
|
250
|
+
&& audio.srtpCryptoSuite === 0 && Buffer.isBuffer(audio.srtp_key) && audio.srtp_key.length === 16
|
|
251
|
+
&& Buffer.isBuffer(audio.srtp_salt) && audio.srtp_salt.length === 14);
|
|
149
252
|
if (this.closed || typeof request.sessionID !== 'string' || request.sessionID.length > 128
|
|
150
253
|
|| this.sessions.size >= 1 || this.sessions.has(request.sessionID)
|
|
151
254
|
|| ![4, 6].includes(isIP(request.targetAddress))
|
|
152
255
|
|| (request.addressVersion === 'ipv6') !== (isIP(request.targetAddress) === 6)
|
|
153
|
-
|| !source || !Number.isInteger(source.port) || source.port < 1 || source.port > 65535
|
|
256
|
+
|| !validAudio || !source || !Number.isInteger(source.port) || source.port < 1 || source.port > 65535
|
|
154
257
|
|| source.srtpCryptoSuite !== 0 || !Buffer.isBuffer(source.srtp_key) || source.srtp_key.length !== 16
|
|
155
258
|
|| !Buffer.isBuffer(source.srtp_salt) || source.srtp_salt.length !== 14) {
|
|
156
|
-
|
|
259
|
+
this.mediaEvent('stream_failure', { reason: 'invalid_prepare' });
|
|
260
|
+
done(error('invalid_prepare'));
|
|
157
261
|
return;
|
|
158
262
|
}
|
|
159
|
-
|
|
263
|
+
let socket, audioSocket;
|
|
264
|
+
try {
|
|
265
|
+
const family = request.addressVersion === 'ipv6' ? 'udp6' : 'udp4';
|
|
266
|
+
socket = createSocket(family);
|
|
267
|
+
if (this.audioCompatibility) audioSocket = createSocket(family);
|
|
268
|
+
} catch {
|
|
269
|
+
try { socket?.close(); } catch {}
|
|
270
|
+
this.mediaEvent('stream_failure', { reason: 'udp_error' }); done(error('udp_error')); return;
|
|
271
|
+
}
|
|
160
272
|
const session = {
|
|
161
|
-
id: request.sessionID, socket, address: request.targetAddress, port: source.port,
|
|
273
|
+
id: request.sessionID, socket, audioSocket, finishPrepare: done, address: request.targetAddress, port: source.port,
|
|
162
274
|
crypto: Buffer.concat([source.srtp_key, source.srtp_salt]),
|
|
163
275
|
key: Buffer.from(source.srtp_key), salt: Buffer.from(source.srtp_salt),
|
|
164
276
|
ssrc: this.api.hap.CameraController.generateSynchronisationSource(), state: 'prepared',
|
|
277
|
+
rtcpPackets: 0,
|
|
278
|
+
...(audioSocket ? { audioKey: Buffer.from(audio.srtp_key), audioSalt: Buffer.from(audio.srtp_salt),
|
|
279
|
+
audioSsrc: this.api.hap.CameraController.generateSynchronisationSource() } : {}),
|
|
165
280
|
};
|
|
166
281
|
this.sessions.set(session.id, session);
|
|
167
|
-
|
|
282
|
+
const socketError = () => { this.failStream(session, 'udp_error'); done(error('udp_error')); };
|
|
283
|
+
socket.on('error', socketError);
|
|
284
|
+
audioSocket?.on('error', socketError);
|
|
168
285
|
socket.on('message', (_buffer, remote) => {
|
|
169
|
-
if (session.state === 'active' && remote.address === session.address)
|
|
286
|
+
if (session.state === 'active' && remote.address === session.address) {
|
|
287
|
+
session.rtcpPackets = Math.min(Number.MAX_SAFE_INTEGER, session.rtcpPackets + 1);
|
|
288
|
+
this.media.stream.rtcpPackets = Math.min(Number.MAX_SAFE_INTEGER, this.media.stream.rtcpPackets + 1);
|
|
289
|
+
this.media.stream.lastRtcpAt = new Date().toISOString();
|
|
290
|
+
if (session.rtcpPackets === 1) this.mediaEvent('stream_rtcp_first', { rtcpPackets: 1 });
|
|
291
|
+
this.keepAlive(session);
|
|
292
|
+
}
|
|
170
293
|
});
|
|
171
|
-
|
|
294
|
+
let bindings = audioSocket ? 2 : 1;
|
|
295
|
+
const bound = () => {
|
|
296
|
+
if (--bindings > 0) return;
|
|
172
297
|
if (!this.sessions.has(session.id) || this.closed) { this.stopStream(session.id); done(error()); return; }
|
|
173
298
|
session.timer = setTimeout(() => this.expire(session.id), 15000);
|
|
174
299
|
session.timer.unref();
|
|
300
|
+
this.mediaEvent('stream_prepared');
|
|
175
301
|
done(undefined, { video: {
|
|
176
302
|
port: socket.address().port, ssrc: session.ssrc,
|
|
177
303
|
srtp_key: Buffer.from(session.key), srtp_salt: Buffer.from(session.salt),
|
|
178
|
-
}
|
|
179
|
-
|
|
304
|
+
}, ...(audioSocket ? { audio: { port: audioSocket.address().port, ssrc: session.audioSsrc,
|
|
305
|
+
srtp_key: Buffer.from(session.audioKey), srtp_salt: Buffer.from(session.audioSalt) } } : {}) });
|
|
306
|
+
};
|
|
307
|
+
try { socket.bind(0, bound); audioSocket?.bind(0, bound); }
|
|
308
|
+
catch { socketError(); }
|
|
180
309
|
}
|
|
181
310
|
|
|
182
311
|
handleStreamRequest(request, callback) {
|
|
@@ -186,19 +315,43 @@ export class MijiaCameraDelegate {
|
|
|
186
315
|
this.sessions.has(request.sessionID) ? done() : done(error());
|
|
187
316
|
return;
|
|
188
317
|
}
|
|
189
|
-
if (request.type !== 'start') { done(error()); return; }
|
|
190
|
-
this.startStream(request, done).catch(() =>
|
|
318
|
+
if (request.type !== 'start') { this.mediaEvent('stream_failure', { reason: 'invalid_stream' }); done(error('invalid_stream')); return; }
|
|
319
|
+
this.startStream(request, done).catch(() => {
|
|
320
|
+
const session = this.sessions.get(request.sessionID);
|
|
321
|
+
if (session) this.failStream(session, 'media_unavailable');
|
|
322
|
+
done(error());
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
failStream(session, reason, details = {}) {
|
|
327
|
+
if (this.sessions.get(session.id) !== session) return;
|
|
328
|
+
const safe = safeReason(reason);
|
|
329
|
+
this.mediaEvent('stream_failure', { ...details, reason: safe,
|
|
330
|
+
...(session.started === undefined ? {} : { elapsedMs: elapsed(session.started) }) });
|
|
331
|
+
this.stopStream(session.id, safe);
|
|
332
|
+
session.finishStart?.(error(safe));
|
|
333
|
+
try { this.controller.forceStopStreamingSession(session.id); } catch {}
|
|
191
334
|
}
|
|
192
335
|
|
|
193
336
|
async startStream(request, callback) {
|
|
194
337
|
const session = this.sessions.get(request.sessionID);
|
|
195
|
-
|
|
338
|
+
const done = once(callback);
|
|
339
|
+
if (!session || session.state !== 'prepared') {
|
|
340
|
+
this.mediaEvent('stream_failure', { reason: 'invalid_stream' }); done(error('invalid_stream')); return;
|
|
341
|
+
}
|
|
196
342
|
session.state = 'starting';
|
|
343
|
+
session.started = performance.now();
|
|
344
|
+
session.deadline = session.started + this.startDeadlineMs;
|
|
345
|
+
session.finishStart = done;
|
|
346
|
+
clearTimeout(session.timer);
|
|
347
|
+
session.timer = setTimeout(() => this.failStream(session, 'start_timeout'), this.startDeadlineMs);
|
|
348
|
+
session.timer.unref();
|
|
349
|
+
this.mediaEvent('stream_start');
|
|
197
350
|
let source;
|
|
198
351
|
try { source = await this.source(); }
|
|
199
|
-
catch { this.
|
|
200
|
-
if (this.sessions.get(session.id) !== session || this.closed) {
|
|
201
|
-
|
|
352
|
+
catch (failure) { this.failStream(session, safeReason(failure?.reason, 'source_not_ready')); return; }
|
|
353
|
+
if (this.sessions.get(session.id) !== session || this.closed) { done(error('stream_stopped')); return; }
|
|
354
|
+
if (performance.now() >= session.deadline) { this.failStream(session, 'start_timeout'); return; }
|
|
202
355
|
const video = request.video || {};
|
|
203
356
|
const width = Math.min(number(video.width, 640, 2, 4096), this.limits.width);
|
|
204
357
|
const height = Math.min(number(video.height, 360, 2, 4096), this.limits.height);
|
|
@@ -217,31 +370,40 @@ export class MijiaCameraDelegate {
|
|
|
217
370
|
'-srtp_out_suite', 'AES_CM_128_HMAC_SHA1_80', '-srtp_out_params', session.crypto.toString('base64'),
|
|
218
371
|
'-progress', 'pipe:1', '-nostats', target];
|
|
219
372
|
let process;
|
|
373
|
+
const diagnostic = stderrClassifier();
|
|
374
|
+
let progress = '';
|
|
220
375
|
try {
|
|
221
|
-
process = this.spawnProcess(this.ffmpegPath, args, { stdio: ['ignore', 'pipe', '
|
|
376
|
+
process = this.spawnProcess(this.ffmpegPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
222
377
|
session.process = process;
|
|
223
378
|
this.jobs.add(process);
|
|
224
|
-
|
|
225
|
-
this.stopStream(session.id); callback(error());
|
|
226
|
-
}, 10000);
|
|
227
|
-
session.timer.unref();
|
|
379
|
+
process.stderr?.on('data', data => diagnostic.consume(data));
|
|
228
380
|
process.stdout.on('data', data => {
|
|
229
|
-
if (session.state
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
381
|
+
if (this.sessions.get(session.id) !== session || session.state !== 'starting') return;
|
|
382
|
+
if (performance.now() >= session.deadline) { this.failStream(session, 'start_timeout'); return; }
|
|
383
|
+
const bytes = Buffer.isBuffer(data) ? data.subarray(-8192) : Buffer.from(String(data).slice(-8192)).subarray(-8192);
|
|
384
|
+
progress += bytes.toString('utf8');
|
|
385
|
+
if (progress.length > 16384) { this.failStream(session, 'media_unavailable'); return; }
|
|
386
|
+
let newline;
|
|
387
|
+
while ((newline = progress.indexOf('\n')) >= 0) {
|
|
388
|
+
const line = progress.slice(0, newline).trim(); progress = progress.slice(newline + 1);
|
|
389
|
+
if (/^frame=\s*[1-9]\d*$/.test(line)) {
|
|
390
|
+
session.state = 'active'; progress = '';
|
|
391
|
+
this.mediaEvent('stream_first_frame', { elapsedMs: elapsed(session.started) });
|
|
392
|
+
this.keepAlive(session);
|
|
393
|
+
done();
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
233
396
|
}
|
|
234
397
|
});
|
|
235
|
-
process.once('error',
|
|
236
|
-
process.once('close', () => {
|
|
398
|
+
process.once('error', failure => this.failStream(session, spawnReason(failure)));
|
|
399
|
+
process.once('close', (code, signal) => {
|
|
237
400
|
this.jobs.delete(process);
|
|
238
401
|
if (this.sessions.get(session.id) === session) {
|
|
239
|
-
this.
|
|
240
|
-
callback(error());
|
|
241
|
-
try { this.controller.forceStopStreamingSession(session.id); } catch {}
|
|
402
|
+
this.failStream(session, diagnostic.failure(code, signal), { exitCode: code, signal });
|
|
242
403
|
}
|
|
404
|
+
diagnostic.clear();
|
|
243
405
|
});
|
|
244
|
-
} catch { this.
|
|
406
|
+
} catch (failure) { this.failStream(session, spawnReason(failure)); }
|
|
245
407
|
}
|
|
246
408
|
|
|
247
409
|
keepAlive(session) {
|
|
@@ -251,8 +413,8 @@ export class MijiaCameraDelegate {
|
|
|
251
413
|
}
|
|
252
414
|
|
|
253
415
|
expire(id) {
|
|
254
|
-
this.
|
|
255
|
-
|
|
416
|
+
const session = this.sessions.get(id);
|
|
417
|
+
if (session) this.failStream(session, session.state === 'prepared' ? 'prepare_timeout' : 'receiver_timeout');
|
|
256
418
|
}
|
|
257
419
|
|
|
258
420
|
kill(process) {
|
|
@@ -263,19 +425,26 @@ export class MijiaCameraDelegate {
|
|
|
263
425
|
process.once('close', () => clearTimeout(timer));
|
|
264
426
|
}
|
|
265
427
|
|
|
266
|
-
stopStream(id) {
|
|
428
|
+
stopStream(id, reason = 'stream_stopped') {
|
|
267
429
|
const session = this.sessions.get(id);
|
|
268
430
|
if (!session) return;
|
|
269
431
|
this.sessions.delete(id);
|
|
432
|
+
const starting = session.state === 'starting';
|
|
433
|
+
session.state = 'stopped';
|
|
270
434
|
clearTimeout(session.timer);
|
|
271
435
|
try { session.socket.close(); } catch {}
|
|
436
|
+
try { session.audioSocket?.close(); } catch {}
|
|
272
437
|
this.kill(session.process);
|
|
273
438
|
if (session.process) this.jobs.delete(session.process);
|
|
274
439
|
session.crypto.fill(0); session.key.fill(0); session.salt.fill(0);
|
|
440
|
+
session.audioKey?.fill(0); session.audioSalt?.fill(0);
|
|
441
|
+
this.mediaEvent('stream_stop', { reason: safeReason(reason), rtcpPackets: session.rtcpPackets });
|
|
442
|
+
session.finishPrepare?.(error(reason));
|
|
443
|
+
if (starting) session.finishStart?.(error(reason));
|
|
275
444
|
}
|
|
276
445
|
|
|
277
446
|
reset() {
|
|
278
|
-
for (const id of [...this.sessions.keys()]) this.stopStream(id);
|
|
447
|
+
for (const id of [...this.sessions.keys()]) this.stopStream(id, 'stream_reset');
|
|
279
448
|
for (const process of this.jobs) this.kill(process);
|
|
280
449
|
this.jobs.clear();
|
|
281
450
|
for (const entry of this.snapshots.values()) clearTimeout(entry.timer);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const states = new Set(['idle', 'starting', 'ready', 'retrying', 'blocked', 'stopped', 'paused']);
|
|
2
|
+
const stages = new Set(['device_lookup', 'provisioning', 'relay_connect', 'authentication', 'waiting_video']);
|
|
3
|
+
const reasons = new Set(['not_selected', 'starting', 'ready', 'stopped', 'retrying', 'connect_timeout',
|
|
4
|
+
'authentication_required', 'session_expired', 'camera_offline', 'camera_not_in_home', 'unsupported_transport',
|
|
5
|
+
'invalid_request', 'invalid_home_selection', 'invalid_home_response', 'missing_home_owner', 'invalid_device_response',
|
|
6
|
+
'device_pagination_incomplete', 'home_request_failed', 'home_request_rejected', 'discovery_failed',
|
|
7
|
+
'backend_failed', 'connection_failed', 'no_video', 'port_in_use', 'runtime_in_use', 'runtime_closed',
|
|
8
|
+
'runtime_unavailable', 'query_timeout', 'query_cancelled', 'invalid_backend_response', 'storage_failed', 'auth_in_progress']);
|
|
9
|
+
const mediaReasons = new Set(['media_unavailable', 'closed', 'source_not_ready', 'source_timeout', 'source_invalid',
|
|
10
|
+
'snapshot_busy', 'snapshot_timeout', 'snapshot_too_large', 'snapshot_invalid', 'ffmpeg_missing', 'ffmpeg_permission',
|
|
11
|
+
'ffmpeg_spawn', 'ffmpeg_exit', 'ffmpeg_cpu_unsupported', 'input_timeout', 'input_unavailable', 'input_invalid',
|
|
12
|
+
'decode_failed', 'encoder_unavailable', 'encode_failed', 'udp_unreachable', 'udp_in_use', 'udp_error',
|
|
13
|
+
'invalid_prepare', 'invalid_stream', 'start_timeout', 'prepare_timeout', 'receiver_timeout', 'stream_stopped', 'stream_reset']);
|
|
14
|
+
const mediaEvents = new Set(['snapshot_requested', 'snapshot_success', 'snapshot_failure', 'stream_prepared',
|
|
15
|
+
'stream_start', 'stream_first_frame', 'stream_failure', 'stream_stop', 'stream_rtcp_first']);
|
|
16
|
+
const mediaSignals = new Set(['SIGTERM', 'SIGKILL', 'SIGILL', 'SIGABRT', 'SIGSEGV', 'SIGBUS', 'SIGFPE']);
|
|
17
|
+
const mediaStreamStates = new Set(['idle', 'prepared', 'starting', 'active', 'stopped']);
|
|
18
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
19
|
+
const count = value => Number.isSafeInteger(value) && value >= 0;
|
|
20
|
+
const timestamp = value => value === null || (typeof value === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value));
|
|
21
|
+
|
|
22
|
+
function safeMediaDiagnostics(value) {
|
|
23
|
+
const result = {};
|
|
24
|
+
for (const key of ['activeStreams', 'activeJobs']) if (count(value[key])) result[key] = value[key];
|
|
25
|
+
for (const [kind, counters, timestamps] of [
|
|
26
|
+
['snapshot', ['requests', 'successes', 'failures', 'lastBytes'], ['lastRequestedAt', 'lastCompletedAt', 'lastFailureAt']],
|
|
27
|
+
['stream', ['prepared', 'starts', 'firstFrames', 'failures', 'stops', 'rtcpPackets'],
|
|
28
|
+
['lastPreparedAt', 'lastStartedAt', 'lastFirstFrameAt', 'lastStoppedAt', 'lastRtcpAt', 'lastFailureAt']],
|
|
29
|
+
]) {
|
|
30
|
+
const source = value[kind];
|
|
31
|
+
if (!record(source)) continue;
|
|
32
|
+
const section = {};
|
|
33
|
+
for (const key of counters) if (count(source[key])) section[key] = source[key];
|
|
34
|
+
for (const key of timestamps) if (timestamp(source[key])) section[key] = source[key];
|
|
35
|
+
if (source.lastFailure === null || mediaReasons.has(source.lastFailure)) section.lastFailure = source.lastFailure;
|
|
36
|
+
if (kind === 'snapshot' && (source.lastElapsedMs === null || count(source.lastElapsedMs))) section.lastElapsedMs = source.lastElapsedMs;
|
|
37
|
+
if (kind === 'stream' && mediaStreamStates.has(source.state)) section.state = source.state;
|
|
38
|
+
if (Object.keys(section).length) result[kind] = section;
|
|
39
|
+
}
|
|
40
|
+
if (value.lastEvent === null) result.lastEvent = null;
|
|
41
|
+
else if (record(value.lastEvent) && mediaEvents.has(value.lastEvent.type)
|
|
42
|
+
&& typeof value.lastEvent.at === 'string' && timestamp(value.lastEvent.at)) {
|
|
43
|
+
const source = value.lastEvent, event = { type: source.type, at: source.at };
|
|
44
|
+
for (const key of ['width', 'height', 'bytes', 'elapsedMs', 'rtcpPackets']) if (count(source[key])) event[key] = source[key];
|
|
45
|
+
if (mediaReasons.has(source.reason)) event.reason = source.reason;
|
|
46
|
+
if (count(source.exitCode) && source.exitCode <= 65535) event.exitCode = source.exitCode;
|
|
47
|
+
if (mediaSignals.has(source.signal)) event.signal = source.signal;
|
|
48
|
+
if (typeof source.cached === 'boolean') event.cached = source.cached;
|
|
49
|
+
result.lastEvent = event;
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A deliberately small report suitable for sharing during connection troubleshooting. */
|
|
55
|
+
export function safeDiagnostics(value = {}) {
|
|
56
|
+
const result = {};
|
|
57
|
+
if (!record(value)) return result;
|
|
58
|
+
if (states.has(value.state)) result.state = value.state;
|
|
59
|
+
if (reasons.has(value.reason)) result.reason = value.reason;
|
|
60
|
+
if (stages.has(value.stage)) result.stage = value.stage;
|
|
61
|
+
for (const key of ['ready', 'videoReceived', 'paused', 'selected', 'authenticated']) {
|
|
62
|
+
if (typeof value[key] === 'boolean') result[key] = value[key];
|
|
63
|
+
}
|
|
64
|
+
if (['cn', 'sg', 'de', 'us', 'ru', 'i2'].includes(value.region)) result.region = value.region;
|
|
65
|
+
for (const key of ['attempt', 'connectingElapsedSeconds', 'retryInSeconds', 'vendor']) {
|
|
66
|
+
if (Number.isSafeInteger(value[key]) && value[key] >= 0 && value[key] <= (key === 'vendor' ? 100 : 1000000000)) result[key] = value[key];
|
|
67
|
+
}
|
|
68
|
+
if (value.lastFailure === null || reasons.has(value.lastFailure)) result.lastFailure = value.lastFailure;
|
|
69
|
+
for (const key of ['connectingSince', 'retryAt', 'lastFailureAt']) {
|
|
70
|
+
if (timestamp(value[key])) result[key] = value[key];
|
|
71
|
+
}
|
|
72
|
+
if (value.media === null) result.media = null;
|
|
73
|
+
else if (record(value.media)) {
|
|
74
|
+
const media = safeMediaDiagnostics(value.media);
|
|
75
|
+
if (Object.keys(media).length) result.media = media;
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
package/lib/platform.mjs
CHANGED
|
@@ -39,6 +39,7 @@ export class MijiaRemoteCameraPlatform {
|
|
|
39
39
|
storagePath: path.join(this.api.user.storagePath(), 'mijia-remote-camera'),
|
|
40
40
|
config: this.config, log: this.log,
|
|
41
41
|
});
|
|
42
|
+
this.runtime.mediaDiagnostics = () => this.delegate?.getMediaDiagnostics?.();
|
|
42
43
|
if (this.stopping) { await this.runtime.stop(); await this.runtime.close?.(); return; }
|
|
43
44
|
this.selectionListener = () => {
|
|
44
45
|
this.refreshQueue = this.refreshQueue.then(() => this.refresh()).catch(() => {
|
|
@@ -77,10 +78,11 @@ export class MijiaRemoteCameraPlatform {
|
|
|
77
78
|
?.setCharacteristic(this.api.hap.Characteristic.Manufacturer, 'Xiaomi')
|
|
78
79
|
.setCharacteristic(this.api.hap.Characteristic.Model, label(selected.model || '米家远程摄像头'))
|
|
79
80
|
.setCharacteristic(this.api.hap.Characteristic.SerialNumber, `MiCam-${opaqueId}`)
|
|
80
|
-
.setCharacteristic(this.api.hap.Characteristic.FirmwareRevision, '0.1.
|
|
81
|
+
.setCharacteristic(this.api.hap.Characteristic.FirmwareRevision, '0.1.1');
|
|
81
82
|
this.delegate = this.delegateFactory({ api: this.api, log: this.log, runtime: this.runtime,
|
|
82
83
|
config: this.config, ffmpegPath: this.runtime.ffmpegPath });
|
|
83
84
|
accessory.configureController(this.delegate.controller);
|
|
85
|
+
if (this.delegate.audioCompatibility) this.delegate.controller.setMicrophoneMuted(true);
|
|
84
86
|
this.api.publishExternalAccessories(PLUGIN_NAME, [accessory]);
|
|
85
87
|
this.accessory = accessory;
|
|
86
88
|
this.deviceId = selected.deviceId;
|
package/lib/runtime-factory.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
1
2
|
import { RuntimeManager } from './runtime.mjs';
|
|
2
3
|
import { BrowserLoginManager } from './auth.mjs';
|
|
3
4
|
import { startRpcServer } from './rpc.mjs';
|
|
@@ -12,7 +13,7 @@ export async function createRuntime({ storagePath, config = {}, log }) {
|
|
|
12
13
|
const runtime = new RuntimeManager({
|
|
13
14
|
storagePath, config, log, region: config.region || 'cn',
|
|
14
15
|
quality: config.quality ?? 1,
|
|
15
|
-
javaPath: assets.javaPath, jarPath: assets.jarPath, bridgePath:
|
|
16
|
+
javaPath: assets.javaPath, jarPath: assets.jarPath, bridgePath: fileURLToPath(new URL('../backend/bridge.jar', import.meta.url)),
|
|
16
17
|
});
|
|
17
18
|
runtime.ffmpegPath = assets.ffmpegPath;
|
|
18
19
|
await runtime.open();
|
|
@@ -44,7 +45,11 @@ export async function attachServices(runtime, auth, storagePath) {
|
|
|
44
45
|
let rpc;
|
|
45
46
|
try {
|
|
46
47
|
rpc = await startRpcServer(storagePath, async (method, payload) => {
|
|
47
|
-
if (method === 'status')
|
|
48
|
+
if (method === 'status') {
|
|
49
|
+
const status = runtime.status();
|
|
50
|
+
const media = runtime.mediaDiagnostics?.();
|
|
51
|
+
return media ? { ...status, media } : status;
|
|
52
|
+
}
|
|
48
53
|
if (authorizing && ['homes', 'devices', 'select', 'resume'].includes(method)) throw failure('auth_in_progress');
|
|
49
54
|
if (method === 'homes') return runtime.discoverHomes();
|
|
50
55
|
if (method === 'devices') return runtime.discoverDevices(payload.homeId, payload.homeOwner);
|
package/lib/runtime.mjs
CHANGED
|
@@ -16,6 +16,7 @@ const REASONS = new Set(['not_selected', 'starting', 'ready', 'stopped', 'retryi
|
|
|
16
16
|
'runtime_unavailable', 'query_timeout', 'query_cancelled', 'invalid_backend_response', 'storage_failed']);
|
|
17
17
|
REASONS.add('auth_in_progress');
|
|
18
18
|
const TRANSIENT_CODES = new Set([0, 3, 4, 5, 6]);
|
|
19
|
+
const STAGES = ['device_lookup', 'provisioning', 'relay_connect', 'authentication', 'waiting_video'];
|
|
19
20
|
|
|
20
21
|
export class RuntimeError extends Error {
|
|
21
22
|
constructor(reason, code) {
|
|
@@ -101,6 +102,8 @@ export class RuntimeManager extends EventEmitter {
|
|
|
101
102
|
this.children = new Set();
|
|
102
103
|
this.wantRunning = false; this.closed = false; this.paused = false; this.token = null; this.opening = null;
|
|
103
104
|
this.attempt = 0; this.generation = 0; this.starting = null; this.port = null;
|
|
105
|
+
this.connectionAttempt = 0; this.connectingSince = null; this.retryAt = null;
|
|
106
|
+
this.lastFailure = null; this.lastFailureAt = null; this.stage = null; this.stageIndex = -1;
|
|
104
107
|
this.queryChain = Promise.resolve();
|
|
105
108
|
this.exitHandler = () => {
|
|
106
109
|
for (const child of this.children) {
|
|
@@ -141,17 +144,35 @@ export class RuntimeManager extends EventEmitter {
|
|
|
141
144
|
}
|
|
142
145
|
status() {
|
|
143
146
|
const credentials = readJson(this.credentialsFile);
|
|
147
|
+
const now = Date.now();
|
|
144
148
|
const report = { state: this.state, reason: this.reason, ready: this.ready, videoReceived: this.videoReceived,
|
|
145
149
|
region: this.region, paused: this.paused, selected: Boolean(this.selected()), authenticated: ['ssecurity', 'serviceToken', 'userId']
|
|
146
|
-
.every(key => typeof credentials?.[key] === 'string' && credentials[key].trim())
|
|
150
|
+
.every(key => typeof credentials?.[key] === 'string' && credentials[key].trim()),
|
|
151
|
+
attempt: this.connectionAttempt, stage: this.stage,
|
|
152
|
+
connectingSince: this.connectingSince === null ? null : new Date(this.connectingSince).toISOString(),
|
|
153
|
+
connectingElapsedSeconds: this.connectingSince === null ? 0 : Math.max(0, Math.floor((now - this.connectingSince) / 1000)),
|
|
154
|
+
retryAt: this.retryAt === null ? null : new Date(this.retryAt).toISOString(),
|
|
155
|
+
retryInSeconds: this.retryAt === null ? 0 : Math.max(0, Math.ceil((this.retryAt - now) / 1000)),
|
|
156
|
+
lastFailure: this.lastFailure,
|
|
157
|
+
lastFailureAt: this.lastFailureAt === null ? null : new Date(this.lastFailureAt).toISOString() };
|
|
147
158
|
if (this.ready && this.port) report.sourceUrl = `http://127.0.0.1:${this.port}/cam.ts`;
|
|
148
159
|
if (Number.isInteger(this.vendor)) report.vendor = this.vendor;
|
|
149
160
|
return report;
|
|
150
161
|
}
|
|
151
162
|
_state(state, reason, extra = {}) {
|
|
152
163
|
this.state = state; this.reason = REASONS.has(reason) ? reason : 'backend_failed';
|
|
164
|
+
if (state !== 'starting') { this.connectingSince = null; this.stage = null; this.stageIndex = -1; }
|
|
165
|
+
if (state !== 'retrying') this.retryAt = null;
|
|
166
|
+
if (state === 'retrying' || state === 'blocked') {
|
|
167
|
+
this.lastFailure = this.reason; this.lastFailureAt = Date.now();
|
|
168
|
+
}
|
|
153
169
|
Object.assign(this, extra); this.emit('status', this.status());
|
|
154
170
|
}
|
|
171
|
+
_resetConnectionDiagnostics() {
|
|
172
|
+
this.attempt = 0; this.connectionAttempt = 0; this.connectingSince = null; this.retryAt = null;
|
|
173
|
+
this.lastFailure = null; this.lastFailureAt = null; this.stage = null; this.stageIndex = -1;
|
|
174
|
+
this.vendor = undefined;
|
|
175
|
+
}
|
|
155
176
|
async setRegion(region) {
|
|
156
177
|
if (!REGIONS.has(region)) throw new RuntimeError('invalid_request');
|
|
157
178
|
await this.open(); await this.stop(); this.region = region;
|
|
@@ -249,6 +270,7 @@ export class RuntimeManager extends EventEmitter {
|
|
|
249
270
|
this.wantRunning = true;
|
|
250
271
|
clearTimeout(this.retryTimer); this.retryTimer = null;
|
|
251
272
|
const generation = ++this.generation;
|
|
273
|
+
this.connectionAttempt++; this.connectingSince = Date.now(); this.stage = null; this.stageIndex = -1;
|
|
252
274
|
this._state('starting', 'starting', { ready: false, videoReceived: false, vendor: undefined });
|
|
253
275
|
const promise = this._start(selection, generation);
|
|
254
276
|
this.starting = promise;
|
|
@@ -282,6 +304,13 @@ export class RuntimeManager extends EventEmitter {
|
|
|
282
304
|
if (value?.success === false) {
|
|
283
305
|
eventFailure = REASONS.has(value.reason) ? value.reason : 'backend_failed';
|
|
284
306
|
if (Number.isInteger(value.vendor) && value.vendor >= 0 && value.vendor <= 100) this.vendor = value.vendor;
|
|
307
|
+
} else if (value?.event === 'progress' && value.success === true && this.state === 'starting') {
|
|
308
|
+
const index = STAGES.indexOf(value.stage);
|
|
309
|
+
if (index >= 0 && index >= this.stageIndex) {
|
|
310
|
+
this.stage = value.stage; this.stageIndex = index;
|
|
311
|
+
if (Number.isInteger(value.vendor) && value.vendor >= 0 && value.vendor <= 100) this.vendor = value.vendor;
|
|
312
|
+
this.emit('status', this.status());
|
|
313
|
+
}
|
|
285
314
|
} else if (value?.event === 'ready' && value.success === true && value.videoReceived === true
|
|
286
315
|
&& value.port === this.port && ['H264', 'H265'].includes(value.codec)) {
|
|
287
316
|
clearTimeout(bootTimer); if (this.bootTimer === bootTimer) this.bootTimer = null;
|
|
@@ -304,9 +333,10 @@ export class RuntimeManager extends EventEmitter {
|
|
|
304
333
|
this.ready = false; fail(reason);
|
|
305
334
|
if (timedOut || TRANSIENT_CODES.has(code)) {
|
|
306
335
|
if (readyAt !== null && Date.now() - readyAt >= 60000) this.attempt = 0;
|
|
307
|
-
this._state('retrying', reason, { ready: false });
|
|
308
336
|
const delay = this.retryDelays[Math.min(this.attempt++, this.retryDelays.length - 1)];
|
|
309
|
-
this.
|
|
337
|
+
this.retryAt = Date.now() + delay;
|
|
338
|
+
this.retryTimer = setTimeout(() => { this.retryTimer = null; this.retryAt = null; if (this.wantRunning && !this.closed) void this.start().catch(() => {}); }, delay);
|
|
339
|
+
this._state('retrying', reason, { ready: false });
|
|
310
340
|
} else this._state('blocked', reason, { ready: false });
|
|
311
341
|
});
|
|
312
342
|
});
|
|
@@ -315,11 +345,25 @@ export class RuntimeManager extends EventEmitter {
|
|
|
315
345
|
if (this.paused) throw new RuntimeError('auth_in_progress');
|
|
316
346
|
if (this.ready && this.child) return `http://127.0.0.1:${this.port}/cam.ts`;
|
|
317
347
|
if (this.state === 'blocked') throw new RuntimeError(this.reason);
|
|
318
|
-
|
|
348
|
+
let ready, cleanup = () => {};
|
|
349
|
+
if (this.state === 'retrying' && this.retryTimer) {
|
|
350
|
+
// HomeKit requests may wait for the existing retry, but cannot shorten its backoff.
|
|
351
|
+
const generation = this.generation;
|
|
352
|
+
ready = new Promise((resolve, reject) => {
|
|
353
|
+
const status = () => {
|
|
354
|
+
if (this.paused) reject(new RuntimeError('auth_in_progress'));
|
|
355
|
+
else if (this.ready && this.child) resolve(`http://127.0.0.1:${this.port}/cam.ts`);
|
|
356
|
+
else if (this.state === 'blocked' || (this.state === 'retrying' && generation !== this.generation)) reject(new RuntimeError(this.reason));
|
|
357
|
+
else if (!this.wantRunning || this.closed) reject(new RuntimeError('stopped'));
|
|
358
|
+
};
|
|
359
|
+
this.on('status', status);
|
|
360
|
+
cleanup = () => this.off('status', status);
|
|
361
|
+
});
|
|
362
|
+
} else ready = this.start();
|
|
319
363
|
let timer;
|
|
320
364
|
try {
|
|
321
365
|
return await Promise.race([ready, new Promise((_, reject) => { timer = setTimeout(() => reject(new RuntimeError('starting')), Math.min(Math.max(timeoutMs, 1), this.connectTimeoutMs)); })]);
|
|
322
|
-
} finally { clearTimeout(timer); }
|
|
366
|
+
} finally { clearTimeout(timer); cleanup(); }
|
|
323
367
|
}
|
|
324
368
|
async stop() {
|
|
325
369
|
this.wantRunning = false; this.generation++;
|
|
@@ -328,6 +372,7 @@ export class RuntimeManager extends EventEmitter {
|
|
|
328
372
|
const helpers = [...this.helpers];
|
|
329
373
|
await Promise.all([this.terminateProcess(child), ...helpers.map(helper => this.terminateProcess(helper))]);
|
|
330
374
|
this.starting = null;
|
|
375
|
+
this._resetConnectionDiagnostics();
|
|
331
376
|
this._state('stopped', 'stopped', { ready: false, videoReceived: false });
|
|
332
377
|
}
|
|
333
378
|
async pause() {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linanw/homebridge-mijia-remote-camera",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@linanw/homebridge-mijia-remote-camera",
|
|
9
|
-
"version": "0.1.
|
|
9
|
+
"version": "0.1.1",
|
|
10
10
|
"cpu": [
|
|
11
11
|
"x64"
|
|
12
12
|
],
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"tough-cookie": "6.0.0"
|
|
21
21
|
},
|
|
22
22
|
"bin": {
|
|
23
|
-
"mijia-camera-setup": "bin/setup.mjs"
|
|
23
|
+
"mijia-camera-setup": "bin/setup.mjs",
|
|
24
|
+
"mijia-camera-diagnose": "bin/diagnose.mjs"
|
|
24
25
|
},
|
|
25
26
|
"devDependencies": {
|
|
26
27
|
"homebridge": "1.11.0"
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linanw/homebridge-mijia-remote-camera",
|
|
3
3
|
"displayName": "米家远程摄像头",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.1",
|
|
5
5
|
"description": "Standalone Xiaomi remote camera plugin with browser login and home selection",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "linanw",
|
|
@@ -38,10 +38,12 @@
|
|
|
38
38
|
"README.md",
|
|
39
39
|
"THIRD_PARTY_NOTICES.md",
|
|
40
40
|
"LICENSE",
|
|
41
|
-
"npm-shrinkwrap.json"
|
|
41
|
+
"npm-shrinkwrap.json",
|
|
42
|
+
"backend/bridge.jar"
|
|
42
43
|
],
|
|
43
44
|
"bin": {
|
|
44
|
-
"mijia-camera-setup": "bin/setup.mjs"
|
|
45
|
+
"mijia-camera-setup": "bin/setup.mjs",
|
|
46
|
+
"mijia-camera-diagnose": "bin/diagnose.mjs"
|
|
45
47
|
},
|
|
46
48
|
"scripts": {
|
|
47
49
|
"test": "node --test test/*.test.mjs"
|