@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 CHANGED
@@ -4,13 +4,13 @@
4
4
 
5
5
  登录、家庭选择、远程摄像头连接、快照和 HomeKit 视频由插件管理,无需独立的 viewer / relay 容器或另一个摄像头插件。摄像头可以位于其他网络。
6
6
 
7
- 这是实验性接入,目前后端仅实现 CS2/vendor 4。**尚未在小米智能摄像机 3 Pro 云台版上验证实际出画面,也未完成真实 Apple 家庭配对。** npm 安装成功不代表该型号受支持。
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 包仅包含插件代码,**首次需要从已有的本地运行包导入组件**。运行组件不随 npm 分发,也不会自动从网上下载;详情见 `THIRD_PARTY_NOTICES.md`。目前支持指定版本的本地包 `homebridge-mijia-remote-camera-0.1.0.tgz`,已有该包的用户可按以下步骤迁移。没有该本地包时,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“家庭 → 添加配件 → 更多选项”中选择它,使用现有 Homebridge 的配对 PIN 添加。
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
- 已验证原生 HAP CameraController、合成视频快照与 SRTP 发送/独立接收解码、进程清理、授权取消与原子保存、家庭作用域、真实 Homebridge 设置页 IPC 和本地 Java 启动。实际摄像头取流和 Apple 家庭配对仍需设备端验证。
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;摄像头端口与主桥/子桥端口不同。
@@ -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, compiled bridge, Java runtime, FFmpeg binary, or
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
@@ -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
- /** Private machine interface for Homebridge. No terminal prompts or raw upstream logs. */
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
- long lastVideo = System.nanoTime();
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
@@ -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 家庭 → 添加配件 → 更多选项”中选择摄像头,使用 Homebridge 配对 PIN 添加。</p><p>登录数据只保存在 Homebridge 的私有插件目录,不写入插件配置。</p></div>
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);const ready=value.ready===true||value.state==='ready';$('runtimeStatus').dataset.live=String(ready);const states={idle:'请登录并选择摄像头。',unconfigured:'请登录并选择摄像头。',connecting:'正在连接摄像头…',starting:'正在连接摄像头…',ready:value.videoReceived?'已收到摄像头视频。':'转播端口已就绪,等待摄像头首帧。',retrying:'连接中断,正在等待重连。',paused:'连接已暂停,请选择摄像头启动。',blocked:'连接需要处理。'};$('runtimeText').textContent=messages[value.reason]||states[value.state]||'插件已运行,可以登录并选择摄像头。';}catch(error){connected=false;$('login').disabled=true;$('load').disabled=true;$('runtimeText').textContent='等待插件启动';showError(error);}}
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 使配件变更生效。':'选择已保存,正在连接摄像头。配对时请使用 Homebridge PIN。';await status();}catch(error){showError(error);}finally{$('save').disabled=false;}};
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 error = () => new Error('摄像头暂不可用,请在插件设置中检查米家连接。');
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), () => done(error()));
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), () => done(error()));
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(error()) : resolve(buffer);
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', 'ignore'] });
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', () => finish(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); finish(error()); }, 10000);
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
- done(error());
259
+ this.mediaEvent('stream_failure', { reason: 'invalid_prepare' });
260
+ done(error('invalid_prepare'));
157
261
  return;
158
262
  }
159
- const socket = createSocket(request.addressVersion === 'ipv6' ? 'udp6' : 'udp4');
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
- socket.on('error', () => { this.stopStream(session.id); done(error()); });
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) this.keepAlive(session);
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
- socket.bind(0, () => {
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(() => done(error()));
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
- if (!session || session.state !== 'prepared') { callback(error()); return; }
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.stopStream(session.id); callback(error()); return; }
200
- if (this.sessions.get(session.id) !== session || this.closed) { callback(error()); return; }
201
- clearTimeout(session.timer);
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', 'ignore'] });
376
+ process = this.spawnProcess(this.ffmpegPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
222
377
  session.process = process;
223
378
  this.jobs.add(process);
224
- session.timer = setTimeout(() => {
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 === 'starting' && /frame=\s*[1-9]\d*/.test(data.toString())) {
230
- session.state = 'active';
231
- this.keepAlive(session);
232
- callback();
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', () => { this.stopStream(session.id); callback(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.stopStream(session.id);
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.stopStream(session.id); callback(error()); }
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.stopStream(id);
255
- try { this.controller.forceStopStreamingSession(id); } catch {}
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.0');
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;
@@ -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: assets.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') return runtime.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.retryTimer = setTimeout(() => { this.retryTimer = null; if (this.wantRunning && !this.closed) void this.start().catch(() => {}); }, delay);
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
- const ready = this.start();
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() {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@linanw/homebridge-mijia-remote-camera",
3
- "version": "0.1.0",
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.0",
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.0",
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"