@linanw/homebridge-mijia-remote-camera 0.2.1 → 0.4.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/CHANGELOG.md +26 -0
- package/README.md +50 -29
- package/THIRD_PARTY_NOTICES.md +11 -3
- package/backend/Bridge.java +13 -2
- package/backend/CloudMetadata.java +386 -0
- package/backend/PtzControlServer.java +223 -0
- package/backend/bridge.jar +0 -0
- package/config.schema.json +13 -0
- package/docs/verification.md +72 -0
- package/homebridge-ui/public/index.html +159 -19
- package/homebridge-ui/server.js +49 -0
- package/lib/matter-device.mjs +48 -7
- package/lib/matter-diagnostics.mjs +2 -2
- package/lib/matter-media.mjs +149 -72
- package/lib/matter-metadata-cluster.mjs +243 -0
- package/lib/matter-ptz-cluster.mjs +100 -0
- package/lib/matter-service.mjs +29 -3
- package/lib/metadata-sync.mjs +192 -0
- package/lib/platform.mjs +42 -3
- package/lib/ptz-control.mjs +59 -0
- package/lib/ptz.mjs +56 -0
- package/lib/rpc.mjs +8 -5
- package/lib/runtime-assets.mjs +34 -1
- package/lib/runtime-factory.mjs +20 -2
- package/lib/runtime.mjs +210 -13
- package/lib/setup-job.mjs +49 -0
- package/npm-shrinkwrap.json +149 -167
- package/package.json +19 -7
|
@@ -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
|
+
}
|
package/backend/bridge.jar
CHANGED
|
Binary file
|
package/config.schema.json
CHANGED
|
@@ -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
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Homebridge verification preparation
|
|
2
|
+
|
|
3
|
+
Reviewed against the [current official criteria](https://github.com/homebridge/plugins/wiki/Verified-Plugins) on 2026-10-24. **This plugin has not been verified by the Homebridge team.** This document records preparation and remaining review points; it is not an approval or a guarantee that the request will be accepted.
|
|
4
|
+
|
|
5
|
+
## Compatibility and distribution
|
|
6
|
+
|
|
7
|
+
| Area | Declared scope |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| npm name | `@linanw/homebridge-mijia-remote-camera` |
|
|
10
|
+
| Platform | Dynamic platform `MijiaRemoteCamera`; external native HAP camera; optional independently registered Matter camera |
|
|
11
|
+
| Target release | 0.4.0; public npm and the GitHub release must match before requesting review |
|
|
12
|
+
| Node.js | `^22.13.1 || ^24.0.0` |
|
|
13
|
+
| Homebridge | `^1.11.4 || ^2.0.0`; current development dependency 2.4.0 |
|
|
14
|
+
| OS / CPU | Linux x64 only; pinned local runtime contains glibc and musl variants |
|
|
15
|
+
| Current device validation | One Xiaomi Smart Camera 3 Pro PTZ, CS2/vendor 4; other models are not promised |
|
|
16
|
+
| Initial configuration | Homebridge Plugin Settings GUI; asynchronous local component import, official Xiaomi login, home and camera selection |
|
|
17
|
+
| Runtime components | Existing, matching user-owned local components; no acquisition service, bundled vendor runtime, or automatic download |
|
|
18
|
+
|
|
19
|
+
The current criteria specify the supported Node LTS lines, currently 22 and 24. They do not state a universal OS/CPU support requirement. **Linux x64 remains a material limitation for the reviewers to assess**, especially for Raspberry Pi and other ARM users. A successful npm installation without the local components does not provide working video.
|
|
20
|
+
|
|
21
|
+
## Criteria and evidence to present
|
|
22
|
+
|
|
23
|
+
| Official requirement | Implementation / review evidence |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| Dynamic platform and a distinct purpose | Registered platform with a single selected external camera; the comparison below explains the remote Xiaomi transport use case |
|
|
26
|
+
| Public npm and GitHub source with Issues enabled | Public npm package and GitHub repository exist; the matching 0.4.0 source, npm release and GitHub release notes still need publication before submission |
|
|
27
|
+
| Supported Node LTS, clean installation and no unconfigured startup | Node 22/24 public-source CI matrix; no platform instance or camera is created without configuration; clean tarball installation still needs final release verification |
|
|
28
|
+
| No system-changing postinstall or TTY setup | No plugin lifecycle install scripts; local import and account setup are in Homebridge UI; CLI tools are optional |
|
|
29
|
+
| Settings GUI | Schema plus protected custom settings page |
|
|
30
|
+
| No analytics or tracking | No author-operated analytics service; Xiaomi account, cloud metadata and remote-camera services are necessary network dependencies |
|
|
31
|
+
| Files confined to Homebridge storage | Private credentials, selection, local runtime and Matter identity under the instance storage directory; inspect clean-runtime writes during final review |
|
|
32
|
+
| Errors handled by the plugin | Bounded public errors, caught background work and shutdown failures; source tests cover safe failure paths |
|
|
33
|
+
|
|
34
|
+
The official [checker implementation](https://github.com/homebridge/plugins/blob/latest/src/plugin-checks/workspace/index.ts) also checks package metadata, dependency placement, configuration, repository/release availability and runtime behavior. `homebridge` is a development dependency; HAP is obtained from the host. HTTPS homepage/issues and both `supports-hap` and `supports-matter` describe the actual transports. A production `npm audit --omit=dev --json` on 2026-10-24 reported **0 vulnerabilities**. This local audit does not replace the Homebridge team's checks.
|
|
35
|
+
|
|
36
|
+
## Distinction from related verified plugins
|
|
37
|
+
|
|
38
|
+
The comparison uses the [official verified list](https://github.com/homebridge/plugins/blob/latest/verified-plugins.json) and the projects' own documentation. It explains a separate use case and does not claim broader feature coverage than those projects.
|
|
39
|
+
|
|
40
|
+
| Project | Published capability and difference |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| [Camera FFmpeg](https://github.com/homebridge-plugins/homebridge-camera-ffmpeg) | Its configuration requires an FFmpeg input source, usually an existing camera URL. This plugin obtains the selected supported Xiaomi camera's remote P2P source through account/home selection instead of requiring the user to supply that URL. |
|
|
43
|
+
| [camera.ui](https://github.com/SeydX/homebridge-camera-ui) | Its documentation targets cameras exposing RTSP and includes recording, HSV and motion features. This plugin targets the Xiaomi remote transport and does not implement HSV or recording. |
|
|
44
|
+
| [homebridge-miot](https://github.com/merdok/homebridge-miot) | A broad Xiaomi MIOT device-control platform that explicitly lists a camera category. Its current [CameraAccessory](https://github.com/merdok/homebridge-miot/blob/main/lib/modules/camera/CameraAccessory.js) builds a power switch, while [CameraDevice](https://github.com/merdok/homebridge-miot/blob/main/lib/modules/camera/CameraDevice.js) monitors MIOT power/indicator/storage properties; those classes do not implement a HomeKit streaming delegate. Our request specifically adds the tested remote camera video/audio/talkback transport and scoped metadata, rather than claiming that homebridge-miot has no camera support. |
|
|
45
|
+
|
|
46
|
+
A related [homebridge-xiaomi-go2rtc project](https://github.com/moguennouni/homebridge-xiaomi-go2rtc) now documents local Xiaomi camera streaming and controls, automatic go2rtc provisioning and broader server architectures. It was not present in the official verified list checked for this preparation. Its overlapping functionality must still be disclosed; acceptance of a distinct use case is the Homebridge team's decision.
|
|
47
|
+
|
|
48
|
+
## Scope that must remain explicit
|
|
49
|
+
|
|
50
|
+
- The npm package excludes micam, its native components, JRE and FFmpeg. The known local backend repository has no license, and the known FFmpeg build enables `nonfree`. No redistribution rights are asserted. See [third-party notices](../THIRD_PARTY_NOTICES.md).
|
|
51
|
+
- The GUI importer accepts only already-held matching files inside Homebridge storage, verifies fixed hashes and preserves legal files. It solves terminal-free import; it does not solve lawful acquisition for a new user without components. Reviewers may regard that remaining setup limitation as blocking.
|
|
52
|
+
- Mi Home metadata polls every 60 seconds. Confirmed owner-only edits cover the device name and moves to existing rooms of the same home; cloud-side name normalization can reject exact confirmation even for locally valid Unicode; the runtime must expose the validated write capability before editing is enabled. Shared homes are read-only. There is no room rename/create or cross-home move.
|
|
53
|
+
- Apple Home's own name and room edits are not written back to Mi Home. Matter metadata uses a vendor interface and a 160-byte UTF-8 bound; controllers must explicitly support it. Neither of these features is a universal HomeKit/Matter room sync promise.
|
|
54
|
+
- Matter remains an experimental development device using SDK test attestation. Homebridge verification, if granted, would not confer Matter product certification.
|
|
55
|
+
|
|
56
|
+
## Validation and release checklist
|
|
57
|
+
|
|
58
|
+
The CI workflow tests public-source behavior with Node 22.13.1, the current 22.x and 24.x releases against Homebridge 1.11.4 and 2.4.0. It runs `npm test` without proprietary runtime assets; codec-dependent HomeKit media and the full-runtime UI IPC fixture explicitly report skipped tests when their required components are absent. Java/vendor protocol fixtures and real AAC-ELD/media checks require the separately held components; execute those locally and report their results separately. The workflow must actually pass on the public repository before describing CI as passed.
|
|
59
|
+
|
|
60
|
+
The final local full suite, with the held runtime and an independent Matter controller integration, passed on **Node 22.22.1 and Node 24.19.0 / Homebridge 2.4.0: 325 tests, 324 passed, 1 root-only ownership test skipped, 0 failed on each Node version**. Real-account validation changed a Chinese camera name and restored it, moved the camera between two existing rooms of its owned home and restored the original room, and independently read back each result. The original name/room and credentials were preserved at completion. An emoji-containing target was normalized by the cloud, correctly returned `metadata_not_applied`, and was restored; the plugin does not promise arbitrary cloud name characters or automatic rollback.
|
|
61
|
+
|
|
62
|
+
A local isolated source copy with no `assets/`, using Node 22.22.1 and Homebridge 2.4.0, ran the unfiltered `npm test`: **325 tests, 295 passed, 30 explicitly skipped, 0 failed**. It reused installed public dependencies, so it is not evidence of a fresh registry installation or of all six CI combinations. The four affected native-media/UI test files also ran with the held runtime: **40 passed, 0 skipped**; without assets the same subset reported **35 passed, 5 skipped**. No real account writes were performed by these tests.
|
|
63
|
+
|
|
64
|
+
- [ ] Publish the reviewed source to the intended public GitHub repository, enable Issues, and verify all package URLs resolve.
|
|
65
|
+
- [x] Complete changed-value Chinese rename/restore and existing-room round-trip validation with independent reads, original values restored and credentials unchanged. Confirm exact readback rejects a cloud-normalized emoji result; run the final local suite on Node 22 and 24.
|
|
66
|
+
- [ ] Run the public-source CI matrix and a clean packed-package install; inspect startup without configuration, stop/restart and network failure behavior.
|
|
67
|
+
- [ ] Inspect the exact npm tarball and Git tree for credentials, imported assets, APK/DEX/decompiled code and third-party binaries. Only the project's own Java adapter JAR is intended for distribution.
|
|
68
|
+
- [ ] Publish npm 0.4.0 and a matching GitHub release with release notes; ensure the default branch package version matches npm latest.
|
|
69
|
+
- [ ] Refresh the production audit and official criteria/checker findings, then provide the limitations and comparison to the reviewers.
|
|
70
|
+
- [ ] Submit the official verification request and address review feedback. Add a Verified badge only after the Homebridge team grants verification.
|
|
71
|
+
|
|
72
|
+
The [official request form](https://github.com/homebridge/plugins/issues/new?template=1_verification-request.yml) requires the npm name, public repository and six Yes/No declarations. A roughly 100×100 PNG icon is optional and must respect licensing/trademarks. Include setup restrictions and test evidence in More Information. Use the [current template](https://github.com/homebridge/plugins/blob/latest/.github/ISSUE_TEMPLATE/1_verification-request.yml); repository transfer or a package scope change is not required for this request. The project team performs the review described in the [program documentation](https://github.com/homebridge/plugins#plugin-verification).
|