@linanw/homebridge-mijia-remote-camera 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,7 +54,7 @@ mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz -
54
54
 
55
55
  摄像头作为独立配件发布。在 iPhone“家庭 → 添加配件 → 更多选项”中选择它,使用该摄像头启动日志中 `Please add [...] manually in Home app. Setup Code:` 后面的配对码。插件运行在子桥时,这个码可能与 Homebridge 首页的主桥配对码不同。
56
56
 
57
- 默认最高 640×360、12 fps、700 kbps,最多一路实时视频。0.1.2 起接入摄像头实际返回的 PCMA 或 Opus 音频,经 AAC-ELD 16 kHz 发送到 Apple“家庭”;双向对讲会将“家庭”回传的声音转换为摄像头协议,并通过同一远程会话发送。没有收到真实音频时不会生成替代声音,视频可独立播放。尚未接入云台、运动事件、录像回放及 HomeKit Secure Video。
57
+ 默认最高 640×360、12 fps、700 kbps。HomeKit 播放能力维持原状;Matter 默认允许两个播放窗口同时观看。0.1.2 起接入摄像头实际返回的 PCMA 或 Opus 音频,经 AAC-ELD 16 kHz 发送到 Apple“家庭”;双向对讲会将“家庭”回传的声音转换为摄像头协议,并通过同一远程会话发送。没有收到真实音频时不会生成替代声音,视频可独立播放。0.3.0 增加支持型号的上下左右单步控制;尚未接入运动事件、录像回放及 HomeKit Secure Video。
58
58
 
59
59
  ## 声音、对讲与夜视
60
60
 
@@ -64,7 +64,15 @@ mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz -
64
64
 
65
65
  夜视目前仅支持已核对 MIOT 定义的 `chuangmi.camera.061a01`/`chuangmi.camera.061a03`。插件设置页先读取设备实际状态,再选择“自动/开启/关闭”并应用;写入后会回读确认。Apple“家庭”的“夜视”开关表示允许夜视:自动模式显示为开,重复开不会把自动改为强制开启;关掉后重新打开会设置为始终开启。需要恢复自动时,请在插件设置页选择。该开关表示夜视策略,不表示红外灯此刻是否点亮。启动和轮询只读状态,不更改策略。
66
66
 
67
- ## Matter 摄像头(0.2.0,实验性)
67
+ ## 云台方向控制(0.3.0)
68
+
69
+ 插件设置页提供上下左右按钮,一次点击只发送一次有限步进,不自动重复或启动巡航。只有经过实机核对的具体型号才会启用;当前验证目标为 `chuangmi.camera.061a01`。摄像头的远程连接就绪后即可控制,不要求摄像头与 NAS 处于同一局域网。按钮提示“已接受”表示收到电机请求的回复,不表示读取到了最终位置。单步幅度由摄像头决定,成对反向操作也不保证精确返回原角度。当前不提供绝对角度、位置反馈或镜头变焦。
70
+
71
+ 需要在 Apple“家庭”中操作时,在插件设置页勾选“在 Apple家庭中显示四个云台方向开关”,保存并重启摄像头子桥。对应 `enablePtzSwitches: true`,默认关闭。每个开关打开后只发一次方向指令,随后自动回到关闭状态;关闭开关不会再次移动摄像头。无需重新配对。
72
+
73
+ MatterDesk 使用摄像头端点上的厂商方向步进接口 `MijiaPtzStep`(cluster `0xFFF1FC01`)。客户端需要先读取型号支持与当前可用状态;方向命令仅接受上、下、左、右,不提供任意设备指令入口。它没有将固定步进冒充为标准 Matter 机械角度控制;其他 Matter 客户端可能只显示视频功能。
74
+
75
+ ## Matter 摄像头(实验性)
68
76
 
69
77
  可在保留现有 HomeKit 配对的同时启用独立的 Matter Camera(设备类型 `0x0142`,端点 `1`)。它实现 Camera AV Stream Management 与 WebRTC Transport Provider,包含 H.264 视频、Opus 现场声音、双向对讲、JPEG 快照、音量/静音和支持型号的夜视模式。当前使用 matter.js 0.18.0 的 Matter 1.6.1 模型,已与 matter-server 1.5.3 控制器进行协议对接。MatterDesk Qt 客户端是当前目标客户端;客户端需要实现摄像头播放。Apple“家庭”仍使用本插件原有的 HomeKit 配件。
70
78
 
@@ -79,11 +87,11 @@ mijia-camera-setup --from /homebridge/homebridge-mijia-remote-camera-0.1.0.tgz -
79
87
  }
80
88
  ```
81
89
 
82
- 升级默认不开启 Matter。启用后,从受保护的插件设置页点击显示 Matter 配对信息,将手动配对码或 `MT:` 内容输入控制器。它与 HomeKit Setup Code 不同。已配对后增加其他控制器时可在设置页重新打开配对窗口,操作完成后可以关闭窗口。配对码和 Matter 私钥不会写入脱敏报告。Matter fabric/设备身份保存在插件私有存储的 `matter/` 内,升级和正常重启保留身份;删除该目录会使已有配对失效。
90
+ 0.2.1 修复了仅视频/仅音频会话的资源清理,并支持浏览器早到的 ICE 候选、重复启动,以及 Chromium 多 H264 profile 的正常协商。升级默认不开启 Matter。启用后,从受保护的插件设置页点击显示 Matter 配对信息,将手动配对码或 `MT:` 内容输入控制器。它与 HomeKit Setup Code 不同。已配对后增加其他控制器时可在设置页重新打开配对窗口,操作完成后可以关闭窗口。配对码和 Matter 私钥不会写入脱敏报告。Matter fabric/设备身份保存在插件私有存储的 `matter/` 内,升级和正常重启保留身份;删除该目录会使已有配对失效。
83
91
 
84
92
  这是未认证的开发设备,使用 SDK 的测试 VID/开发证书。matter-server 默认会拒绝这种证书;只在明确信任此开发设备时,使用它的 `--enable-test-net-dcl`(或 `ENABLE_TEST_NET_DCL=true`)选项。插件不冒充通过认证的产品,客户端普通启动应保留严格验证。
85
93
 
86
- Matter 信令及 WebRTC 播放目前面向同一局域网,无外部 STUN/TURN,不能据此承诺跨网 Matter 播放。远端摄像头与 NAS 可以继续处于不同网络,由原有小米远程连接取流。最多一路 Matter 实时会话,默认视频上限 640×360、12 fps、700 kbps,可使用已有 `maxWidth`/`maxHeight`/`maxFPS`/`maxBitrate` 配置调整。没有声明录像、云台、运动事件或云存储能力。Matter 和 HomeKit 共享同一个远程源,分别编码;群晖上同时播放会增加 CPU 使用。
94
+ Matter 信令及 WebRTC 播放目前面向同一局域网,无外部 STUN/TURN,不能据此承诺跨网 Matter 播放。远端摄像头与 NAS 可以继续处于不同网络,由原有小米远程连接取流。0.2.2 起默认允许两路 Matter 实时会话,可用 `matterMaxStreams` 设置 1–4 路。相同编码配置的视频与音频共享转码和上游取流,连接分别使用独立的 WebRTC/SRTP;关闭一个窗口不会停止另一个,最后一路关闭后才释放共享编码资源。不同画质需要额外编码器,会增加 CPU 用量。对讲同一时刻保留一路,先发声的窗口持有对讲,停止发送或退出后释放;多个窗口都可独立收听。默认视频上限 640×360、12 fps、700 kbps,可使用已有 `maxWidth`/`maxHeight`/`maxFPS`/`maxBitrate` 配置调整。没有声明录像、标准机械角度/数字变焦、运动事件或云存储能力;方向步进使用上文的厂商接口。Matter 和 HomeKit 共享同一个远程源,分别编码;群晖上同时播放会增加 CPU 使用。
87
95
 
88
96
  打开直播时客户端会唤起插件取流;小米中继仍可能间歇连接失败,与是否先打开米家 App 无关。连接失败时会结束当前 Matter 会话,待源恢复后在客户端重新打开。当前未完成长时间稳定性验证。
89
97
 
@@ -100,7 +108,7 @@ Matter 信令及 WebRTC 播放目前面向同一局域网,无外部 STUN/TUR
100
108
  matter/
101
109
  ```
102
110
 
103
- 私有目录权限为 `0700`,凭据和选择文件为 `0600`。小米 Token 不写入 Homebridge `config.json`。设置页通过 Homebridge 的受保护接口和本机 Unix socket 通信;原始视频和音频仅监听临时的本机回环端口,不保存录音。对讲接口也仅允许本机访问。Homebridge 启动后恢复所选摄像头,关闭时清理 Java、FFmpeg 和 UDP 会话,临时网络故障会自动重连。
111
+ 私有目录权限为 `0700`,凭据和选择文件为 `0600`。小米 Token 不写入 Homebridge `config.json`。设置页通过 Homebridge 的受保护接口和本机 Unix socket 通信;原始视频和音频仅监听临时的本机回环端口,不保存录音。对讲与电机控制接口也仅允许本机访问;电机控制使用每次连接独立的私有令牌,令牌不进入设置页或诊断报告。Homebridge 启动后恢复所选摄像头,关闭时清理 Java、FFmpeg 和 UDP 会话,临时网络故障会自动重连。
104
112
 
105
113
  插件按单个账号、单个摄像头设计,按所选家庭及实际所有者查询完整设备分页。迁移前请备份 Homebridge 存储目录。
106
114
 
@@ -147,7 +147,7 @@ public final class Bridge {
147
147
  emit(Map.of("success", false, "reason", "unsupported_transport", "vendor", provision.vendorId()));
148
148
  return 1;
149
149
  }
150
- return stream(provision, port, quality, args.length == 8 && "1".equals(args[7]));
150
+ return stream(provision, port, quality, args.length == 8 && "1".equals(args[7]), device.model());
151
151
  }
152
152
 
153
153
  private <T> T request(Supplier<T> call) {
@@ -178,7 +178,7 @@ public final class Bridge {
178
178
  } catch (Exception failure) { throw new Failure(2, "authentication_required"); }
179
179
  }
180
180
 
181
- private int stream(Provisioning provision, int port, int quality, boolean audioEnabled) {
181
+ private int stream(Provisioning provision, int port, int quality, boolean audioEnabled, String model) {
182
182
  Resources resources = new Resources();
183
183
  AtomicBoolean running = new AtomicBoolean(true);
184
184
  Thread hook = new Thread(() -> { running.set(false); resources.close(); }, "mijia-bridge-stop");
@@ -194,6 +194,15 @@ public final class Bridge {
194
194
  resources.camera.authenticate(provision.clientPublic(), provision.sign(), Duration.ofSeconds(15));
195
195
  resources.sessionKey = MissCrypto.sessionKey(provision.clientPrivate(), provision.devicePublic());
196
196
  resources.camera.useSessionKey(resources.sessionKey);
197
+ try {
198
+ resources.ptz = new PtzControlServer(resources.camera, resources.sessionKey, model,
199
+ store.path().resolveSibling("ptz-control.json"));
200
+ resources.ptz.start();
201
+ emit(Map.of("success", true, "event", "control_ready", "controlPort", resources.ptz.port(), "controlToken", resources.ptz.token()));
202
+ } catch (IOException | RuntimeException failure) {
203
+ if (resources.ptz != null) resources.ptz.close(); resources.ptz = null;
204
+ emit(Map.of("success", true, "event", "control_unavailable"));
205
+ }
197
206
  progress(Stage.WAITING_VIDEO, provision.vendorId());
198
207
  resources.camera.startVideo(quality, audioEnabled);
199
208
  VideoWatchdog watchdog = new VideoWatchdog(System::nanoTime, Duration.ofSeconds(10), Duration.ofSeconds(15));
@@ -275,8 +284,10 @@ public final class Bridge {
275
284
  volatile HttpStreamServer server;
276
285
  volatile AudioStreamServer audio;
277
286
  volatile Talkback talkback;
287
+ volatile PtzControlServer ptz;
278
288
  volatile byte[] sessionKey;
279
289
  synchronized void close() {
290
+ if (ptz != null) { try { ptz.close(); } catch (Exception ignored) { } ptz = null; }
280
291
  if (audio != null) { try { audio.close(); } catch (Exception ignored) { } audio = null; }
281
292
  if (talkback != null) { try { talkback.close(); } catch (Exception ignored) { } talkback = null; }
282
293
  if (camera != null) { try { camera.stopVideo(); } catch (Exception ignored) { } camera = null; }
@@ -0,0 +1,223 @@
1
+ package mijia.bridge;
2
+
3
+ import com.fasterxml.jackson.databind.JsonNode;
4
+ import com.fasterxml.jackson.databind.ObjectMapper;
5
+ import com.sun.net.httpserver.HttpExchange;
6
+ import com.sun.net.httpserver.HttpServer;
7
+ import micam.miss.MissClient;
8
+ import micam.miss.MissCrypto;
9
+
10
+ import java.io.IOException;
11
+ import java.net.InetAddress;
12
+ import java.net.InetSocketAddress;
13
+ import java.nio.ByteBuffer;
14
+ import java.nio.ByteOrder;
15
+ import java.nio.charset.StandardCharsets;
16
+ import java.nio.file.Files;
17
+ import java.nio.file.Path;
18
+ import java.nio.file.StandardCopyOption;
19
+ import java.nio.file.attribute.PosixFilePermissions;
20
+ import java.security.MessageDigest;
21
+ import java.security.SecureRandom;
22
+ import java.time.Duration;
23
+ import java.util.Arrays;
24
+ import java.util.HexFormat;
25
+ import java.util.LinkedHashMap;
26
+ import java.util.Map;
27
+ import java.util.Set;
28
+ import java.util.UUID;
29
+ import java.util.concurrent.ArrayBlockingQueue;
30
+ import java.util.concurrent.CompletableFuture;
31
+ import java.util.concurrent.Executors;
32
+ import java.util.concurrent.ScheduledExecutorService;
33
+ import java.util.concurrent.ThreadPoolExecutor;
34
+ import java.util.concurrent.TimeUnit;
35
+ import java.util.concurrent.atomic.AtomicBoolean;
36
+
37
+ /** Bounded single-step motor control on the existing encrypted MISS session. No startup movement. */
38
+ final class PtzControlServer implements AutoCloseable {
39
+ private static final ObjectMapper JSON = new ObjectMapper()
40
+ .enable(com.fasterxml.jackson.core.JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
41
+ .enable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
42
+ private static final int MOTOR_REQUEST = 0x112, MOTOR_RESPONSE = 0x113;
43
+ private static final Set<String> PROBE_MODELS = Set.of("chuangmi.camera.061a01");
44
+ // Physically verified on this exact model. One command is a finite step; no precise angle is implied.
45
+ private static final Map<String, Map<String, Integer>> PROFILES = Map.of("chuangmi.camera.061a01",
46
+ Map.of("left", 1, "right", 2, "up", 3, "down", 4));
47
+ private static final long COOLDOWN_NANOS = TimeUnit.MILLISECONDS.toNanos(1800);
48
+ interface Session {
49
+ void send(int command, String body);
50
+ MissClient.Response receive(Duration timeout);
51
+ }
52
+ private final Session session;
53
+ private final byte[] key;
54
+ private final String model, token;
55
+ private final Path descriptor;
56
+ private final HttpServer server;
57
+ private final ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 2, 0, TimeUnit.SECONDS,
58
+ new ArrayBlockingQueue<>(2), runnable -> { Thread thread = new Thread(runnable, "mijia-ptz-http"); thread.setDaemon(true); return thread; });
59
+ private final ScheduledExecutorService deadlines = Executors.newSingleThreadScheduledExecutor(runnable -> {
60
+ Thread thread = new Thread(runnable, "mijia-ptz-deadlines"); thread.setDaemon(true); return thread;
61
+ });
62
+ private final AtomicBoolean busy = new AtomicBoolean();
63
+ private volatile boolean closed;
64
+ private volatile CompletableFuture<Map<String, Object>> pending;
65
+ private long lastCommand = Long.MIN_VALUE;
66
+ private Thread reader;
67
+
68
+ PtzControlServer(MissClient camera, byte[] key, String model, Path descriptor) throws IOException {
69
+ this(new Session() {
70
+ public void send(int command, String body) { camera.sendEncrypted(command, body); }
71
+ public MissClient.Response receive(Duration timeout) { return camera.awaitResponse(timeout); }
72
+ }, key, model, descriptor);
73
+ }
74
+ PtzControlServer(Session session, byte[] key, String model, Path descriptor) throws IOException {
75
+ if (session == null || key == null || key.length != 32 || model == null) throw new IOException("ptz_unavailable");
76
+ this.session = session; this.key = Arrays.copyOf(key, key.length); this.model = model; this.descriptor = descriptor;
77
+ byte[] random = new byte[32]; new SecureRandom().nextBytes(random); token = HexFormat.of().formatHex(random); Arrays.fill(random, (byte) 0);
78
+ server = HttpServer.create(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0), 4);
79
+ server.setExecutor(executor); server.createContext("/", this::handle);
80
+ }
81
+ int port() { return server.getAddress().getPort(); }
82
+ String token() { return token; }
83
+ static boolean supported(String model) { return PROFILES.containsKey(model); }
84
+ void start() throws IOException {
85
+ reader = new Thread(this::readLoop, "mijia-ptz-commands"); reader.setDaemon(true); reader.start();
86
+ try { writeDescriptor(); server.start(); } catch (IOException | RuntimeException error) { close(); throw error; }
87
+ }
88
+ private void writeDescriptor() throws IOException {
89
+ if (descriptor == null) return;
90
+ Files.createDirectories(descriptor.getParent());
91
+ Files.setPosixFilePermissions(descriptor.getParent(), PosixFilePermissions.fromString("rwx------"));
92
+ Path temporary = descriptor.resolveSibling(".ptz-control-" + UUID.randomUUID() + ".tmp");
93
+ try {
94
+ Files.createFile(temporary, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
95
+ JSON.writeValue(temporary.toFile(), Map.of("version", 1, "port", port(), "token", token, "model", model,
96
+ "pid", ProcessHandle.current().pid(), "createdAt", System.currentTimeMillis()));
97
+ Files.move(temporary, descriptor, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
98
+ } finally { Files.deleteIfExists(temporary); }
99
+ }
100
+ private void handle(HttpExchange exchange) throws IOException {
101
+ String host = exchange.getRequestHeaders().getFirst("Host"), authorization = exchange.getRequestHeaders().getFirst("Authorization");
102
+ if (closed || !exchange.getRemoteAddress().getAddress().isLoopbackAddress() || !("127.0.0.1:" + port()).equals(host)
103
+ || exchange.getRequestURI().getRawQuery() != null || exchange.getRequestHeaders().containsKey("Origin")
104
+ || authorization == null || !MessageDigest.isEqual(("Bearer " + token).getBytes(StandardCharsets.US_ASCII), authorization.getBytes(StandardCharsets.UTF_8))) {
105
+ respond(exchange, 403, Map.of("reason", "ptz_unavailable")); return;
106
+ }
107
+ String route = exchange.getRequestURI().getRawPath();
108
+ if (!exchange.getRequestMethod().equals("POST") || !(route.equals("/ptz/probe") || route.equals("/ptz/move"))) {
109
+ respond(exchange, 404, Map.of("reason", "invalid_request")); return;
110
+ }
111
+ int length;
112
+ try { length = Integer.parseInt(exchange.getRequestHeaders().getFirst("Content-Length")); }
113
+ catch (Exception error) { respond(exchange, 400, Map.of("reason", "invalid_request")); return; }
114
+ if (length < 2 || length > 128 || !"application/json".equals(exchange.getRequestHeaders().getFirst("Content-Type"))) {
115
+ respond(exchange, 400, Map.of("reason", "invalid_request")); return;
116
+ }
117
+ var timeout = deadlines.schedule(exchange::close, 3, TimeUnit.SECONDS);
118
+ try {
119
+ byte[] bytes = exchange.getRequestBody().readNBytes(length);
120
+ JsonNode payload;
121
+ try { payload = bytes.length == length ? JSON.readTree(bytes) : null; } finally { Arrays.fill(bytes, (byte) 0); }
122
+ if (payload == null || !payload.isObject() || payload.size() != 1) { respond(exchange, 400, Map.of("reason", "invalid_request")); return; }
123
+ int operation;
124
+ if (route.equals("/ptz/probe")) {
125
+ if (!PROBE_MODELS.contains(model) || !payload.path("operation").isIntegralNumber() || !payload.path("operation").canConvertToInt()) {
126
+ respond(exchange, 400, Map.of("reason", "ptz_unsupported")); return;
127
+ }
128
+ operation = payload.path("operation").intValue();
129
+ } else {
130
+ String direction = payload.path("direction").asText("");
131
+ Map<String, Integer> profile = PROFILES.get(model);
132
+ if (profile == null) { respond(exchange, 409, Map.of("reason", "ptz_unsupported")); return; }
133
+ operation = profile.getOrDefault(direction, 0);
134
+ }
135
+ if (operation < 1 || operation > 4) { respond(exchange, 400, Map.of("reason", "invalid_request")); return; }
136
+ Map<String, Object> result = probeStep(operation);
137
+ if (route.equals("/ptz/move")) {
138
+ if (!Integer.valueOf(0).equals(result.get("ret"))) throw new ControlFailure("ptz_rejected");
139
+ result = Map.of("accepted", true, "direction", payload.path("direction").asText(), "movement", "step");
140
+ }
141
+ respond(exchange, 200, result);
142
+ } catch (ControlFailure error) { respond(exchange, error.reason.equals("ptz_busy") ? 429 : 503, Map.of("reason", error.reason)); }
143
+ catch (Exception error) { respond(exchange, 503, Map.of("reason", "ptz_unavailable")); }
144
+ finally { timeout.cancel(false); exchange.close(); }
145
+ }
146
+ Map<String, Object> probeStep(int operation) {
147
+ if (operation < 1 || operation > 4) throw new ControlFailure("invalid_request");
148
+ if (closed) throw new ControlFailure("ptz_unavailable");
149
+ if (!busy.compareAndSet(false, true)) throw new ControlFailure("ptz_busy");
150
+ CompletableFuture<Map<String, Object>> response = new CompletableFuture<>();
151
+ try {
152
+ synchronized (this) {
153
+ if (closed) throw new ControlFailure("ptz_unavailable");
154
+ long now = System.nanoTime();
155
+ if (lastCommand != Long.MIN_VALUE && now - lastCommand < COOLDOWN_NANOS) throw new ControlFailure("ptz_busy");
156
+ lastCommand = now; pending = response;
157
+ session.send(MOTOR_REQUEST, "{\"operation\":" + operation + "}");
158
+ }
159
+ try {
160
+ Map<String, Object> result = new LinkedHashMap<>(response.get(2, TimeUnit.SECONDS));
161
+ if (closed) throw new ControlFailure("ptz_unavailable");
162
+ result.put("sent", true); result.put("responded", true); result.put("operation", operation);
163
+ return result;
164
+ } catch (java.util.concurrent.TimeoutException timeout) { lastCommand = System.nanoTime(); throw new ControlFailure("ptz_timeout"); }
165
+ catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new ControlFailure("ptz_unavailable"); }
166
+ catch (java.util.concurrent.ExecutionException error) { throw new ControlFailure("ptz_unavailable"); }
167
+ } finally { if (pending == response) pending = null; busy.set(false); }
168
+ }
169
+ private void readLoop() {
170
+ while (!closed) {
171
+ try {
172
+ MissClient.Response response = session.receive(Duration.ofMillis(100));
173
+ if (response == null) continue;
174
+ Map<String, Object> safe = motorResponse(response, key);
175
+ CompletableFuture<Map<String, Object>> waiter = pending;
176
+ if (safe != null && waiter != null) waiter.complete(safe);
177
+ } catch (RuntimeException ignored) {
178
+ try { Thread.sleep(20); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); return; }
179
+ }
180
+ }
181
+ }
182
+ static Map<String, Object> motorResponse(MissClient.Response response, byte[] key) {
183
+ if (response == null || response.command() != MissClient.CMD_ENCODED || response.body() == null || response.body().length > 4108) return null;
184
+ byte[] plain = null;
185
+ try {
186
+ int command = response.command();
187
+ if (command == MissClient.CMD_ENCODED) {
188
+ if (response.body().length < 12) return null;
189
+ plain = MissCrypto.decrypt(key, response.body());
190
+ if (plain.length < 4) return null;
191
+ command = ByteBuffer.wrap(plain).order(ByteOrder.BIG_ENDIAN).getInt();
192
+ } else plain = Arrays.copyOf(response.body(), response.body().length);
193
+ if (command != MOTOR_RESPONSE) return null;
194
+ int offset = response.command() == MissClient.CMD_ENCODED ? 4 : 0, length = plain.length - offset;
195
+ while (length > 0 && plain[offset + length - 1] == 0) --length;
196
+ JsonNode value = JSON.readTree(Arrays.copyOfRange(plain, offset, offset + length));
197
+ if (value == null || !value.isObject()) return null;
198
+ Map<String, Object> result = new LinkedHashMap<>();
199
+ for (String name : Set.of("ret", "angle", "elevation", "xAngle", "yAngle", "xStep", "yStep")) {
200
+ JsonNode number = value.get(name);
201
+ if (number != null && number.isIntegralNumber() && number.canConvertToInt() && Math.abs((long) number.intValue()) <= 36000)
202
+ result.put(name, number.intValue());
203
+ }
204
+ return result.isEmpty() ? null : result;
205
+ } catch (Exception ignored) { return null; }
206
+ finally { if (plain != null) Arrays.fill(plain, (byte) 0); }
207
+ }
208
+ private static void respond(HttpExchange exchange, int status, Map<String, Object> value) throws IOException {
209
+ byte[] body = JSON.writeValueAsBytes(value);
210
+ exchange.getResponseHeaders().set("Content-Type", "application/json"); exchange.getResponseHeaders().set("Cache-Control", "no-store");
211
+ exchange.sendResponseHeaders(status, body.length);
212
+ try (var output = exchange.getResponseBody()) { output.write(body); } finally { Arrays.fill(body, (byte) 0); }
213
+ }
214
+ static final class ControlFailure extends RuntimeException { final String reason; ControlFailure(String reason) { super(reason); this.reason = reason; } }
215
+ @Override public synchronized void close() {
216
+ if (closed) return; closed = true;
217
+ CompletableFuture<Map<String, Object>> waiter = pending; if (waiter != null) waiter.completeExceptionally(new ControlFailure("ptz_unavailable"));
218
+ server.stop(0); executor.shutdownNow(); deadlines.shutdownNow(); if (reader != null) reader.interrupt(); Arrays.fill(key, (byte) 0);
219
+ if (descriptor != null) try {
220
+ JsonNode value = JSON.readTree(descriptor.toFile()); if (token.equals(value.path("token").asText())) Files.deleteIfExists(descriptor);
221
+ } catch (Exception ignored) { }
222
+ }
223
+ }
Binary file
@@ -84,6 +84,11 @@
84
84
  "title": "在家庭中显示夜视开关",
85
85
  "default": true
86
86
  },
87
+ "enablePtzSwitches": {
88
+ "type": "boolean",
89
+ "title": "在家庭中显示四个云台方向开关",
90
+ "default": false
91
+ },
87
92
  "enableMatter": {
88
93
  "type": "boolean",
89
94
  "title": "启用 Matter 摄像头(开发设备)",
@@ -103,6 +108,14 @@
103
108
  "minLength": 1,
104
109
  "maxLength": 40,
105
110
  "pattern": "^[A-Za-z0-9_.:-]+$"
111
+ },
112
+ "matterMaxStreams": {
113
+ "type": "integer",
114
+ "title": "Matter 同时播放窗口数",
115
+ "description": "默认两个窗口可同时播放;相同画质共享转码,关闭一个窗口不会中断另一个。最多四路;不同画质会增加 CPU 用量。",
116
+ "default": 2,
117
+ "minimum": 1,
118
+ "maximum": 4
106
119
  }
107
120
  }
108
121
  }
@@ -5,6 +5,7 @@
5
5
  <title>米家远程摄像头</title>
6
6
  <style>
7
7
  :root{color-scheme:light dark;--ink:#1d2b32;--muted:#65767d;--line:#dce4e4;--panel:#f4f8f7;--accent:#087e72;--accent-soft:#e5f3ed}
8
+ .ptz-pad{display:grid;grid-template-columns:repeat(3,minmax(70px,110px));gap:8px;justify-content:center;margin:18px 0}.ptz-pad button{min-height:44px}#ptzUp{grid-column:2}#ptzLeft{grid-row:2;grid-column:1}.ptz-center{grid-row:2;grid-column:2;text-align:center;align-self:center}#ptzRight{grid-row:2;grid-column:3}#ptzDown{grid-row:3;grid-column:2}
8
9
  *{box-sizing:border-box}body{margin:0;padding:24px;font:14px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:transparent}main{max-width:760px;margin:auto}.eyebrow{font-size:11px;letter-spacing:.16em;color:var(--accent);font-weight:700}h1{font-size:28px;letter-spacing:-.03em;line-height:1.3;margin:7px 0 10px}p{margin:0 0 14px}.intro{color:var(--muted);max-width:600px}.status{display:flex;gap:10px;align-items:center;padding:12px 15px;border:1px solid var(--line);border-radius:12px;margin:22px 0;color:var(--muted)}.dot{height:8px;width:8px;flex:0 0 8px;border-radius:50%;background:#aab9bb}.status[data-live=true] .dot{background:var(--accent)}section{border:1px solid var(--line);border-radius:15px;padding:20px;margin:16px 0}h2{font-size:17px;margin:0 0 14px}.step{font-size:11px;color:var(--accent);font-weight:700;letter-spacing:.1em;margin-bottom:4px}label{display:block;font-size:12px;font-weight:600;margin:14px 0 6px}select,input{width:100%;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:transparent;color:var(--ink);font:inherit}button,.button{border:0;border-radius:8px;padding:10px 15px;background:var(--accent);color:white;font:inherit;font-size:13px;font-weight:600;line-height:1.4;cursor:pointer;text-decoration:none;display:inline-block}button.secondary{background:var(--panel);color:var(--ink);border:1px solid var(--line)}button:disabled{opacity:.4;cursor:default}.row{display:flex;gap:9px;flex-wrap:wrap;align-items:center;margin-top:16px}.small{color:var(--muted);font-size:12px}.note{background:var(--panel);border-radius:9px;padding:12px 14px;margin-top:16px}.linkbox{background:var(--accent-soft);border-radius:10px;padding:14px;margin-top:15px}.linkbox p{margin:0 0 10px}.hide{display:none!important}.columns{display:grid;grid-template-columns:1fr 1fr;gap:14px}.error{color:#ad3d2e;white-space:pre-line}#authState{margin-top:12px}.footer{padding:12px 2px 22px}.footer p{margin:0 0 7px}@media(max-width:550px){body{padding:14px}.columns{grid-template-columns:1fr}section{padding:16px}}@media(prefers-color-scheme:dark){:root{--ink:#e5efec;--muted:#a1b5af;--line:#334943;--panel:#22352f;--accent:#24a58e;--accent-soft:#1d3b31}}
9
10
  </style>
10
11
  </head>
@@ -35,7 +36,22 @@
35
36
  <label><input id="enableNightVision" type="checkbox" style="width:auto"> 在 Apple“家庭”中显示夜视开关</label>
36
37
  <div class="row"><button id="saveFeatures" class="secondary">保存功能配置</button></div><p id="featureState" class="small" aria-live="polite" style="margin-top:12px">功能配置保存后需重启摄像头子桥。对讲时,在“家庭”直播页面使用对讲按钮。</p>
37
38
  </section>
38
- <section id="matterSection"><div class="step">04 · MATTER</div><h2>Matter 摄像头 · 开发设备</h2>
39
+ <section id="ptzSection"><div class="step">04 · 云台</div><h2>调整摄像头方向</h2>
40
+ <p class="small">单击方向按钮移动一步。当前不提供角度定位或镜头变焦。</p>
41
+ <div class="ptz-pad" role="group" aria-label="摄像头云台方向">
42
+ <button id="ptzUp" class="secondary" aria-label="云台向上" disabled>↑ 向上</button>
43
+ <button id="ptzLeft" class="secondary" aria-label="云台向左" disabled>← 向左</button>
44
+ <span class="small ptz-center" aria-hidden="true">单步</span>
45
+ <button id="ptzRight" class="secondary" aria-label="云台向右" disabled>向右 →</button>
46
+ <button id="ptzDown" class="secondary" aria-label="云台向下" disabled>↓ 向下</button>
47
+ </div>
48
+ <p id="ptzState" class="small" role="status" aria-live="polite">正在检查云台支持…</p>
49
+ <div class="row"><button id="ptzRead" class="secondary">检查云台连接</button></div>
50
+ <label><input id="enablePtzSwitches" type="checkbox" style="width:auto"> 在 Apple“家庭”中显示四个云台方向开关</label>
51
+ <div class="row"><button id="savePtzFeatures" class="secondary">保存方向开关配置</button></div><p id="ptzFeatureState" class="small" aria-live="polite" style="margin-top:12px"></p>
52
+ <p class="small">方向开关打开后发送一次移动指令,随后自动回到关闭状态。保存后重启摄像头子桥生效。</p>
53
+ </section>
54
+ <section id="matterSection"><div class="step">05 · MATTER</div><h2>Matter 摄像头 · 开发设备</h2>
39
55
  <p class="small">可在支持 Matter 摄像头的控制器中测试实时视频、声音与对讲。MatterDesk 配对时需允许开发证书;当前不承诺 Apple“家庭”支持 Matter 摄像头。</p>
40
56
  <label><input id="enableMatter" type="checkbox" style="width:auto"> 启用独立 Matter 摄像头</label>
41
57
  <div class="columns"><div><label for="matterPort">Matter TCP/UDP 端口</label><input id="matterPort" type="number" min="1024" max="65535" step="1" value="5540"></div><div><label for="matterInterface">局域网网卡</label><input id="matterInterface" maxlength="40" value="eth0" autocomplete="off" spellcheck="false"></div></div>
@@ -60,7 +76,7 @@
60
76
  credentials_write_failed:'无法保存登录数据,请检查 Homebridge 插件目录权限。', home_request_rejected:'家庭列表请求被拒绝,请检查账号权限与地区。',
61
77
  home_request_failed:'无法读取家庭或设备列表,请检查网络与登录状态。', session_expired:'小米会话已过期,请重新登录。',
62
78
  unsupported_protocol:'当前摄像头的远程传输协议尚不支持。', offline:'米家报告摄像头离线。', selection_missing:'请选择家庭和摄像头。',
63
- 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:'正在进行小米授权,请完成或取消后继续。', night_vision_unsupported:'此型号暂不支持夜视控制。', night_vision_unavailable:'暂时无法读取夜视状态,请重试。', night_vision_rejected:'摄像头拒绝修改夜视模式。', night_vision_not_applied:'摄像头尚未确认新模式,请重新读取。', night_vision_timeout:'夜视请求超时,请重新读取。' };
79
+ 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:'正在进行小米授权,请完成或取消后继续。', night_vision_unsupported:'此型号暂不支持夜视控制。', night_vision_unavailable:'暂时无法读取夜视状态,请重试。', night_vision_rejected:'摄像头拒绝修改夜视模式。', night_vision_not_applied:'摄像头尚未确认新模式,请重新读取。', night_vision_timeout:'夜视请求超时,请重新读取。', ptz_unsupported:'此型号尚未验证云台控制。', ptz_unavailable:'云台远程连接尚未就绪。', ptz_rejected:'摄像头未接受云台指令。', ptz_timeout:'云台回复超时,请检查画面后再操作。', ptz_busy:'云台正在处理上一条指令,请稍后再试。' };
64
80
  const states = new Set(['idle', 'starting', 'ready', 'retrying', 'blocked', 'stopped', 'paused']);
65
81
  const stages = new Set(['device_lookup', 'provisioning', 'relay_connect', 'authentication', 'waiting_video']);
66
82
  const reasons = new Set(['not_selected', 'starting', 'ready', 'stopped', 'retrying', 'connect_timeout',
@@ -98,8 +114,12 @@
98
114
  if(value.deviceType===322)result.deviceType=322;
99
115
  for(const key of ['activeSessions','videoStreams','audioStreams','snapshotStreams'])if(count(value[key])&&value[key]<=100)result[key]=value[key];
100
116
  if(record(value.media)){
101
- const media={};for(const key of ['sessions','failures','videoPackets','audioPackets','talkPackets','talkBytes'])if(count(value.media[key]))media[key]=value.media[key];
102
- for(const key of ['active','microphoneMuted','speakerMuted'])if(typeof value.media[key]==='boolean')media[key]=value.media[key];
117
+ const media={};for(const key of ['sessions','failures','videoPackets','audioPackets','talkPackets','talkBytes','activeSessions','maxSessions','activeSources','videoSources','audioSources','sourceJobs','sourceSockets'])if(count(value.media[key]))media[key]=value.media[key];
118
+ for(const key of ['active','microphoneMuted','speakerMuted','talkbackActive'])if(typeof value.media[key]==='boolean')media[key]=value.media[key];
119
+ const source=value.media;
120
+ for (const [key, allowed] of Object.entries({"lastFailure": ["negotiation_failed", "media_not_offered", "webrtc_timeout", "webrtc_failed", "webrtc_disconnected", "media_unavailable", "udp_error", "ffmpeg_unavailable", "ffmpeg_exit", "video_timeout"], "lastFailureStage": ["remote_description", "replace_video_track", "replace_audio_track", "create_answer", "create_offer", "local_description", "remote_answer"], "lastFailureType": ["Error", "TypeError", "RangeError", "OperationError", "InvalidStateError", "InvalidModificationError", "InvalidAccessError", "NotSupportedError", "SyntaxError", "AbortError"]})) {
121
+ if (allowed.includes(source[key])) media[key] = source[key];
122
+ }
103
123
  if(Object.keys(media).length)result.media=media;
104
124
  }
105
125
  return result;
@@ -243,21 +263,21 @@
243
263
  function options(select,items,render,empty){select.replaceChildren(); if(!items.length){const option=document.createElement('option');option.textContent=empty;select.append(option);select.disabled=true;return;} items.forEach((item,index)=>{const option=document.createElement('option');option.value=String(index);option.textContent=render(item);select.append(option)});select.disabled=false;}
244
264
  function chosenHome(){return homes[Number($('homes').value)];}
245
265
  function chosenDevice(){return devices[Number($('devices').value)];}
246
- function setLoginBusy(busy){authBusy=busy;$('login').disabled=busy||!connected; $('load').disabled=busy||!connected||regionNeedsLogin; $('region').disabled=busy; $('cancel').classList.toggle('hide',!busy);renderMatter();}
266
+ function setLoginBusy(busy){authBusy=busy;$('login').disabled=busy||!connected; $('load').disabled=busy||!connected||regionNeedsLogin; $('region').disabled=busy; $('cancel').classList.toggle('hide',!busy);renderMatter();renderPtz();}
247
267
  function hideLoginLink(){$('loginLink').classList.add('hide');$('officialLogin').removeAttribute('href');}
248
- 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;matterValue=safeMatterStatus(value.matter);setLoginBusy(authBusy);diagnosticValue=safeDiagnostics(value);statusReceivedAt=Date.now();$('diagnosticsReport').textContent=JSON.stringify(diagnosticValue,null,2);$('copyDiagnostics').disabled=false;renderRuntime();renderMatter();}catch(error){connected=false;matterValue={};clearMatterPairing();renderMatter();$('login').disabled=true;$('load').disabled=true;$('runtimeStatus').dataset.live='false';$('runtimeText').textContent='等待插件启动';$('connectionDetail').textContent='';$('mediaDetail').textContent='';showError(error);}}
268
+ 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;matterValue=safeMatterStatus(value.matter);setLoginBusy(authBusy);diagnosticValue=safeDiagnostics(value);statusReceivedAt=Date.now();$('diagnosticsReport').textContent=JSON.stringify(diagnosticValue,null,2);$('copyDiagnostics').disabled=false;renderRuntime();renderMatter();await readPtzCapabilities();}catch(error){connected=false;resetPtz();matterValue={};clearMatterPairing();renderMatter();$('login').disabled=true;$('load').disabled=true;$('runtimeStatus').dataset.live='false';$('runtimeText').textContent='等待插件启动';$('connectionDetail').textContent='';$('mediaDetail').textContent='';showError(error);}}
249
269
  $('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='浏览器无法自动复制,请手动选中上方诊断报告并复制。';}};
250
- async function loadHomes(){clearMatterPairing();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);}}
251
- async function loadDevices(){const home=chosenHome();if(!home)return;clearMatterPairing();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);}}
252
- function deviceChanged(){clearMatterPairing();const device=chosenDevice();$('cameraName').disabled=!device;$('save').disabled=!device;$('cameraName').value=device?(device.name||'米家摄像头'):'';}
270
+ async function loadHomes(){resetPtz(true);clearMatterPairing();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);}}
271
+ async function loadDevices(){const home=chosenHome();if(!home)return;resetPtz(true);clearMatterPairing();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);}}
272
+ function deviceChanged(){resetPtz(true);clearMatterPairing();const device=chosenDevice();$('cameraName').disabled=!device;$('save').disabled=!device;$('cameraName').value=device?(device.name||'米家摄像头'):'';}
253
273
  async function pollAuth(){try{const value=await request('/auth/status',{loginId});if(value.state==='waiting'||value.state==='starting'){$('authState').textContent=`等待你在小米页面确认,剩余 ${value.remainingSeconds||0} 秒。`;pollTimer=setTimeout(pollAuth,1200);return;}setLoginBusy(false);hideLoginLink();if(value.success||value.state==='complete'){$('authState').textContent='登录成功。正在读取家庭列表…';await status();await loadHomes();}else $('authState').textContent=messages[value.reason]||'本次登录未完成,可重新生成链接。';}catch(error){setLoginBusy(false);hideLoginLink();showError(error);}}
254
274
  $('login').onclick=async()=>{resetNight();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='';}};
255
275
  $('cancel').onclick=async()=>{clearTimeout(pollTimer);try{await request('/auth/cancel',{loginId});}catch(error){showError(error);}hideLoginLink();setLoginBusy(false);$('authState').textContent='已取消。已有登录数据保留。';};
256
276
  $('region').onchange=()=>{resetNight();regionTouched=true;regionNeedsLogin=$('region').value!==runtimeRegion;setLoginBusy(authBusy);$('authState').textContent=regionNeedsLogin?'已切换地区,请生成新登录链接后读取家庭。':'';}; $('load').onclick=loadHomes; $('homes').onchange=loadDevices; $('devices').onchange=deviceChanged;
257
- $('save').onclick=async()=>{const home=chosenHome(),device=chosenDevice();if(!home||!device)return;clearMatterPairing();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});resetNight();$('selectionState').textContent=result.restartRequired?'已保存新的摄像头。请重启 Homebridge 使配件变更生效。':'选择已保存,正在连接摄像头。配对码见摄像头启动日志中的 Setup Code。';await status();}catch(error){showError(error);}finally{$('save').disabled=false;}};
277
+ $('save').onclick=async()=>{const home=chosenHome(),device=chosenDevice();if(!home||!device)return;resetPtz(true);clearMatterPairing();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});resetNight();ptzSelectionDirty=Boolean(result.restartRequired);$('selectionState').textContent=result.restartRequired?'已保存新的摄像头。请重启 Homebridge 使配件变更生效。':'选择已保存,正在连接摄像头。配对码见摄像头启动日志中的 Setup Code。';await status();}catch(error){showError(error);}finally{$('save').disabled=false;}};
258
278
  $('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);}};
259
279
  $('check').onclick=status;
260
- function resetNight(){clearMatterPairing();++nightGeneration;$('nightMode').disabled=true;$('nightSave').disabled=true;$('nightState').textContent='读取摄像头实际模式后可修改。';}
280
+ function resetNight(){resetPtz();clearMatterPairing();++nightGeneration;$('nightMode').disabled=true;$('nightSave').disabled=true;$('nightState').textContent='读取摄像头实际模式后可修改。';}
261
281
  async function nightRequest(write=false){
262
282
  if(nightBusy)return;nightBusy=true;const generation=nightGeneration;$('nightRead').disabled=true;$('nightSave').disabled=true;$('nightMode').disabled=true;
263
283
  $('nightState').textContent=write?'正在修改并核对摄像头模式…':'正在读取摄像头实际模式…';
@@ -271,12 +291,14 @@
271
291
  }
272
292
  $('nightRead').onclick=()=>nightRequest();$('nightSave').onclick=()=>nightRequest(true);
273
293
  $('enableAudio').onchange=()=>{$('enableTalkback').disabled=!$('enableAudio').checked;};
274
- $('saveFeatures').onclick=async()=>{try{
294
+ let featureSaving=false;
295
+ $('saveFeatures').onclick=async()=>{if(featureSaving)return;featureSaving=true;$('saveFeatures').disabled=$('savePtzFeatures').disabled=true;$('featureState').textContent=$('ptzFeatureState').textContent='正在保存…';try{
275
296
  const next={...(configs[0]||{platform:'MijiaRemoteCamera',name:'米家远程摄像头',region:$('region').value})};
276
- for(const key of ['enableAudio','enableTalkback','enableNightVision'])next[key]=$(key).checked;
277
- configs=[next,...configs.slice(1)];await homebridge.updatePluginConfig(configs);await homebridge.savePluginConfig();
278
- $('featureState').textContent='配置已保存,请重启摄像头子桥以应用。';
279
- }catch(error){showError(error);}};
297
+ for(const key of ['enableAudio','enableTalkback','enableNightVision','enablePtzSwitches'])next[key]=$(key).checked;
298
+ const updated=[next,...configs.slice(1)];await homebridge.updatePluginConfig(updated);await homebridge.savePluginConfig();configs=updated;
299
+ $('featureState').textContent=$('ptzFeatureState').textContent='配置已保存,请重启摄像头子桥以应用。';
300
+ }catch(error){$('featureState').textContent=$('ptzFeatureState').textContent='配置未能保存,请重试。';showError(error);}finally{featureSaving=false;$('saveFeatures').disabled=$('savePtzFeatures').disabled=false;}};
301
+ $('savePtzFeatures').onclick=$('saveFeatures').onclick;
280
302
  for(const key of ['enableMatter','matterPort','matterInterface'])$(key).onchange=()=>{matterDirty=true;clearMatterPairing();renderMatter();};
281
303
  $('saveMatter').onclick=async()=>{clearMatterPairing();try{
282
304
  const port=Number($('matterPort').value), networkInterface=$('matterInterface').value.trim();
@@ -296,8 +318,38 @@
296
318
  finally{matterBusy=false;renderMatter();}
297
319
  }
298
320
  $('matterPairing').onclick=()=>matterAction('pairing');$('matterOpen').onclick=()=>matterAction('open-window');$('matterClose').onclick=()=>matterAction('close-window');
299
- 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=[];for(const key of ['enableAudio','enableTalkback','enableNightVision'])$(key).checked=configs[0]?.[key]!==false;$('enableTalkback').disabled=!$('enableAudio').checked;$('enableMatter').checked=configs[0]?.enableMatter===true;$('matterPort').value=String(Number.isInteger(configs[0]?.matterPort)?configs[0].matterPort:5540);$('matterInterface').value=typeof configs[0]?.matterInterface==='string'?configs[0].matterInterface:'eth0';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);}}
300
- window.addEventListener('beforeunload',()=>{clearMatterPairing();clearInterval(statusTimer);clearInterval(clockTimer);clearTimeout(pollTimer);});
321
+ const ptzDirections=['up','down','left','right'];
322
+ const ptzLabels={up:'向上',down:'向下',left:'向左',right:'向右'};
323
+ let ptzValue={},ptzBusy=false,ptzReading=false,ptzGeneration=0,ptzSelectionDirty=false;
324
+ function ptzReady(){return connected&&!authBusy&&!regionNeedsLogin&&!ptzSelectionDirty&&ptzValue.supported===true&&ptzValue.available===true&&ptzValue.relativeStep===true;}
325
+ function renderPtz(){
326
+ for(const direction of ptzDirections)$('ptz'+direction[0].toUpperCase()+direction.slice(1)).disabled=!ptzReady()||ptzBusy;
327
+ $('ptzRead').disabled=!connected||authBusy||regionNeedsLogin||ptzBusy||ptzReading||ptzSelectionDirty;
328
+ }
329
+ function resetPtz(selectionDirty=false){++ptzGeneration;ptzValue={};if(selectionDirty)ptzSelectionDirty=true;$('ptzState').textContent=ptzSelectionDirty?'请先保存摄像头选择,再检查云台连接。':'请检查云台连接。';renderPtz();}
330
+ async function readPtzCapabilities(){
331
+ if(!connected||authBusy||regionNeedsLogin||ptzReading||ptzBusy||ptzSelectionDirty){renderPtz();return;}
332
+ const generation=ptzGeneration;ptzReading=true;renderPtz();
333
+ try{const value=await request('/ptz/capabilities');if(generation!==ptzGeneration)return;
334
+ const supported=value?.supported===true&&value?.relativeStep===true&&Array.isArray(value.directions)&&ptzDirections.every(d=>value.directions.includes(d));
335
+ ptzValue={supported,available:supported&&value.available===true,relativeStep:supported};
336
+ $('ptzState').textContent=!supported?'此型号尚未验证云台控制。':!ptzValue.available?'等待摄像头云台远程连接…':'云台已就绪,单击方向按钮移动一步。';
337
+ }catch(error){if(generation!==ptzGeneration)return;ptzValue={};$('ptzState').textContent=messages[reason(error)]||'云台状态读取失败,请重试。';}
338
+ finally{ptzReading=false;renderPtz();}
339
+ }
340
+ async function movePtz(direction){
341
+ if(!ptzDirections.includes(direction)||!ptzReady()||ptzBusy)return;
342
+ const generation=ptzGeneration;ptzBusy=true;renderPtz();$('ptzState').textContent='正在发送'+ptzLabels[direction]+'单步指令…';
343
+ try{const value=await request('/ptz/move',{direction});if(generation!==ptzGeneration)return;
344
+ if(value?.accepted!==true||value.direction!==direction)throw Error('ptz_rejected');
345
+ $('ptzState').textContent='摄像头已接受'+ptzLabels[direction]+'单步指令。';
346
+ }catch(error){if(generation!==ptzGeneration)return;$('ptzState').textContent=messages[reason(error)]||'云台操作未完成,请重试。';}
347
+ finally{ptzBusy=false;renderPtz();}
348
+ }
349
+ for(const direction of ptzDirections)$('ptz'+direction[0].toUpperCase()+direction.slice(1)).onclick=()=>movePtz(direction);
350
+ $('ptzRead').onclick=readPtzCapabilities;
351
+ 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=[];for(const key of ['enableAudio','enableTalkback','enableNightVision'])$(key).checked=configs[0]?.[key]!==false;$('enablePtzSwitches').checked=configs[0]?.enablePtzSwitches===true;$('enableTalkback').disabled=!$('enableAudio').checked;$('enableMatter').checked=configs[0]?.enableMatter===true;$('matterPort').value=String(Number.isInteger(configs[0]?.matterPort)?configs[0].matterPort:5540);$('matterInterface').value=typeof configs[0]?.matterInterface==='string'?configs[0].matterInterface:'eth0';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);}}
352
+ window.addEventListener('beforeunload',()=>{resetPtz();clearMatterPairing();clearInterval(statusTimer);clearInterval(clockTimer);clearTimeout(pollTimer);});
301
353
  init();
302
354
  })();
303
355
  </script></body></html>
@@ -14,6 +14,7 @@ const SAFE_REASONS = new Set(['plugin_not_running', 'invalid_request', 'unauthen
14
14
  'runtime_in_use', 'query_timeout', 'unsupported_platform', 'bundled_runtime_missing', 'not_selected', 'auth_in_progress',
15
15
  'runtime_components_missing', 'runtime_components_invalid', 'night_vision_unsupported', 'night_vision_unavailable',
16
16
  'night_vision_rejected', 'night_vision_not_applied', 'night_vision_timeout',
17
+ 'ptz_unsupported', 'ptz_unavailable', 'ptz_rejected', 'ptz_timeout', 'ptz_busy',
17
18
  'matter_disabled', 'matter_not_ready', 'matter_start_failed', 'matter_port_in_use', 'matter_configuration_invalid',
18
19
  'matter_pairing_unavailable', 'matter_window_failed', 'matter_closed']);
19
20
 
@@ -62,6 +63,11 @@ export function createUiHandlers(runtime) {
62
63
  if (!['auto', 'on', 'off'].includes(payload?.mode)) throw new Error('invalid_request');
63
64
  return runtime.request('night.set', { mode: payload.mode });
64
65
  },
66
+ '/ptz/capabilities': () => runtime.request('ptz.capabilities', {}),
67
+ '/ptz/move': payload => {
68
+ if (!['up', 'down', 'left', 'right'].includes(payload?.direction)) throw new Error('invalid_request');
69
+ return runtime.request('ptz.move', { direction: payload.direction });
70
+ },
65
71
  '/resume': () => runtime.request('resume', {}),
66
72
  };
67
73
  return Object.fromEntries(Object.entries(routes).map(([path, handle]) => [path, async payload => {