@linanw/homebridge-mijia-remote-camera 0.1.2 → 0.2.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.
@@ -0,0 +1,296 @@
1
+ import { RTCPeerConnection, MediaStreamTrack, RTCRtpCodecParameters } from 'werift';
2
+ import { createSocket } from 'node:dgram';
3
+ import { spawn } from 'node:child_process';
4
+ import { request as httpRequest } from 'node:http';
5
+
6
+ const fault = reason => Object.assign(new Error(reason), { reason });
7
+ const integer = (value, fallback, min, max) => Number.isInteger(value) && value >= min && value <= max ? value : fallback;
8
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
9
+ const timer = (callback, ms) => { const value = setTimeout(callback, ms); value.unref(); return value; };
10
+ const isRtp = bytes => Buffer.isBuffer(bytes) && bytes.length >= 12 && bytes.length <= 2048 && (bytes[0] >> 6) === 2;
11
+ function localUrl(value, paths) {
12
+ try {
13
+ const url = new URL(value);
14
+ if (url.protocol !== 'http:' || !['127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password
15
+ || url.search || url.hash || !paths.includes(url.pathname) || !/^\d+$/.test(url.port) || +url.port < 1024 || +url.port > 65535) throw 0;
16
+ return url.href;
17
+ } catch { throw fault('source_invalid'); }
18
+ }
19
+ function audioSource(value, talk = false) {
20
+ const url = localUrl(value?.url, talk ? ['/talk.pcm', '/talk.ogg'] : ['/audio.wav', '/audio.ogg']);
21
+ const format = url.endsWith('.pcm') ? 's16le' : url.endsWith('.ogg') ? 'ogg' : 'wav';
22
+ if ((value.format && value.format !== format) || ![8000, 16000, 24000, 48000].includes(value.sampleRate) || ![1, 2].includes(value.channels)
23
+ || (talk && (format === 's16le' ? value.sampleRate !== 8000 || value.channels !== 1 : value.sampleRate !== 48000 || value.channels !== 2))) throw fault('audio_source_invalid');
24
+ return { url, format, sampleRate: value.sampleRate, channels: value.channels };
25
+ }
26
+ function sdp(value) {
27
+ if (typeof value !== 'string' || value.length > 65536 || !value.startsWith('v=0') || !value.includes('a=fingerprint:') || !value.includes('a=ice-ufrag:')) throw fault('invalid_sdp');
28
+ return value;
29
+ }
30
+ function jpegSize(data) {
31
+ if (data.length < 10 || data[0] !== 0xff || data[1] !== 0xd8) throw fault('snapshot_invalid');
32
+ for (let offset = 2; offset + 4 <= data.length;) {
33
+ if (data[offset++] !== 0xff) throw fault('snapshot_invalid');
34
+ while (offset < data.length && data[offset] === 0xff) offset++;
35
+ if (offset + 3 > data.length) break;
36
+ const marker = data[offset++];
37
+ if (marker === 0xd9 || marker === 0xda) break;
38
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue;
39
+ const length = data.readUInt16BE(offset);
40
+ if (length < 2 || offset + length > data.length) break;
41
+ if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker) && length >= 8) {
42
+ const height = data.readUInt16BE(offset + 3), width = data.readUInt16BE(offset + 5);
43
+ if (width && height && width <= 4096 && height <= 4096) return { width, height };
44
+ break;
45
+ }
46
+ offset += length;
47
+ }
48
+ throw fault('snapshot_invalid');
49
+ }
50
+ function codecConfig() {
51
+ return { video: [new RTCRtpCodecParameters({ mimeType: 'video/H264', clockRate: 90000, payloadType: 96,
52
+ parameters: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
53
+ rtcpFeedback: [{ type: 'nack' }, { type: 'nack', parameter: 'pli' }] })],
54
+ audio: [new RTCRtpCodecParameters({ mimeType: 'audio/opus', clockRate: 48000, channels: 2, payloadType: 111,
55
+ parameters: 'minptime=10;useinbandfec=1;stereo=0;sprop-stereo=0' })] };
56
+ }
57
+
58
+ /** Packet-oriented WebRTC transport. The bundled FFmpeg owns media coding; only
59
+ * loopback UDP carries clear RTP. ICE, DTLS, SRTP and retransmission use werift. */
60
+ export class MatterMedia {
61
+ constructor({ runtime, ffmpegPath, config = {}, snapshotDelegate, spawnProcess = spawn, connectTimeoutMs = 20000, mediaTimeoutMs = 15000 } = {}) {
62
+ this.runtime = runtime; this.ffmpegPath = ffmpegPath || runtime?.ffmpegPath || config.ffmpegPath || 'ffmpeg';
63
+ this.config = config; this.snapshotDelegate = snapshotDelegate; this.spawnProcess = spawnProcess;
64
+ this.connectTimeoutMs = connectTimeoutMs; this.mediaTimeoutMs = mediaTimeoutMs;
65
+ this.microphoneMuted = config.enableAudio === false; this.speakerMuted = config.enableAudio === false || config.enableTalkback === false;
66
+ this.microphoneVolume = 100; this.speakerVolume = 100; this.closed = false; this.session = undefined;
67
+ this.counters = { sessions: 0, failures: 0, videoPackets: 0, audioPackets: 0, talkPackets: 0, talkBytes: 0, state: 'idle' };
68
+ }
69
+ status() { return { ...this.counters, active: Boolean(this.session), microphoneMuted: this.microphoneMuted, speakerMuted: this.speakerMuted }; }
70
+ emit(session, state) {
71
+ this.counters.state = state;
72
+ try { session.onState?.(state); } catch {}
73
+ }
74
+ alive(session) { return this.session === session && !session.closed && !this.closed; }
75
+ fail(session, reason = 'media_unavailable') {
76
+ if (!this.alive(session)) return;
77
+ this.counters.failures++; this.counters.lastFailure = reason; this.emit(session, 'failed'); void this.closeSession(session.id);
78
+ }
79
+ async createSession({ id, offer, video, audio, onCandidate, onState, iceServers, iceTransportPolicy } = {}) {
80
+ if (this.closed) throw fault('closed');
81
+ if (this.session) throw fault('session_busy');
82
+ if (iceTransportPolicy !== undefined && iceTransportPolicy !== 'all') throw fault('relay_unavailable');
83
+ if (iceServers !== undefined && (!Array.isArray(iceServers) || iceServers.length)) throw fault('ice_servers_unsupported');
84
+ if ((typeof id !== 'string' && typeof id !== 'number') || !String(id).length || String(id).length > 160 || (!video && !audio)) throw fault('invalid_session');
85
+ if ((video && (typeof video !== 'object' || (video.codec !== undefined && video.codec !== 'h264')))
86
+ || (audio && (typeof audio !== 'object' || (audio.codec !== undefined && audio.codec !== 'opus')))) throw fault('unsupported_codec');
87
+ if (offer !== undefined) sdp(offer);
88
+ const codecs = codecConfig();
89
+ const peer = new RTCPeerConnection({ codecs, bundlePolicy: 'max-bundle', iceServers: [], iceUseTcp: false,
90
+ iceAdditionalHostAddresses: this.config.matterLoopbackIce === true ? ['127.0.0.1'] : undefined });
91
+ const session = { id, peer, onCandidate, onState, sockets: new Set(), jobs: new Set(), candidateCount: 0,
92
+ video: video ? { width: integer(video.width, 640, 2, 1280), height: integer(video.height, 360, 2, 720),
93
+ frameRate: integer(video.frameRate, 15, 1, 30), bitRate: integer(video.bitRate, 512000, 64000, 2000000),
94
+ keyFrameInterval: integer(video.keyFrameInterval, 1000, 200, 4000) } : undefined,
95
+ audio: audio && this.config.enableAudio !== false ? { channels: 1, bitRate: integer(audio.bitRate, 24000, 16000, 64000) } : undefined };
96
+ if (!session.video && !session.audio) { await peer.close(); throw fault('audio_disabled'); }
97
+ this.session = session; this.counters.sessions++; this.emit(session, 'connecting');
98
+ session.connectTimer = timer(() => this.fail(session, 'webrtc_timeout'), this.connectTimeoutMs);
99
+ session.lifeTimer = timer(() => this.closeSession(id), 30 * 60 * 1000);
100
+ peer.onIceCandidate.subscribe(candidate => {
101
+ if (!candidate || !this.alive(session) || session.candidateCount++ >= 128) return;
102
+ try { onCandidate?.({ candidate: candidate.candidate, sdpMid: candidate.sdpMid ?? undefined, sdpMLineIndex: candidate.sdpMLineIndex ?? undefined }); } catch {}
103
+ });
104
+ peer.connectionStateChange.subscribe(state => {
105
+ if (!this.alive(session)) return;
106
+ if (state === 'connected') {
107
+ clearTimeout(session.connectTimer); clearTimeout(session.disconnectTimer); this.emit(session, 'connected');
108
+ if (!session.started) { session.started = true; void this.startMedia(session).catch(() => this.fail(session)); }
109
+ } else if (state === 'failed' || state === 'closed') this.fail(session, 'webrtc_failed');
110
+ else if (state === 'disconnected') { clearTimeout(session.disconnectTimer); session.disconnectTimer = timer(() => this.fail(session, 'webrtc_disconnected'), 5000); }
111
+ });
112
+ peer.onTrack.subscribe(track => {
113
+ if (track.kind === 'audio') track.onReceiveRtp.subscribe(packet => this.receiveTalk(session, packet));
114
+ });
115
+ try {
116
+ if (offer !== undefined) await peer.setRemoteDescription({ type: 'offer', sdp: offer });
117
+ for (const [kind, enabled] of [['video', session.video], ['audio', session.audio]]) {
118
+ if (!enabled) continue;
119
+ const track = new MediaStreamTrack({ kind, codec: codecs[kind][0] }); session[`${kind}Track`] = track;
120
+ const existing = offer !== undefined ? peer.getTransceivers().find(t => t.kind === kind && !t.stopped) : undefined;
121
+ const direction = kind === 'audio' && this.config.enableTalkback !== false ? 'sendrecv' : 'sendonly';
122
+ if (existing) { await existing.sender.replaceTrack(track); existing.direction = direction; session[`${kind}Sender`] = existing.sender; }
123
+ else if (offer === undefined) session[`${kind}Sender`] = peer.addTransceiver(track, { direction }).sender;
124
+ else throw fault('media_not_offered');
125
+ }
126
+ await peer.setLocalDescription(offer !== undefined ? await peer.createAnswer() : await peer.createOffer());
127
+ if (!this.alive(session)) throw fault('session_closed');
128
+ return offer !== undefined ? { answer: peer.localDescription.sdp } : { offer: peer.localDescription.sdp };
129
+ } catch (error) { await this.closeSession(id); throw fault(error?.reason === 'media_not_offered' ? 'media_not_offered' : 'negotiation_failed'); }
130
+ }
131
+ getSession(id) { const session = this.session; if (!session || session.id !== id || session.closed) throw fault('unknown_session'); return session; }
132
+ async provideAnswer(id, value) {
133
+ const session = this.getSession(id); sdp(value);
134
+ if (session.peer.signalingState !== 'have-local-offer') throw fault('invalid_session_state');
135
+ try { await session.peer.setRemoteDescription({ type: 'answer', sdp: value }); } catch { this.fail(session, 'negotiation_failed'); throw fault('negotiation_failed'); }
136
+ }
137
+ async addCandidates(id, candidates) {
138
+ const session = this.getSession(id);
139
+ if (!Array.isArray(candidates) || candidates.length > 32 || (session.remoteCandidates || 0) + candidates.length > 128) throw fault('invalid_candidates');
140
+ for (const value of candidates) {
141
+ if (!value || typeof value.candidate !== 'string' || value.candidate.length > 2048
142
+ || (value.candidate && !value.candidate.startsWith('candidate:')) || (value.sdpMid != null && typeof value.sdpMid !== 'string')) throw fault('invalid_candidates');
143
+ try { await session.peer.addIceCandidate({ candidate: value.candidate, sdpMid: value.sdpMid ?? undefined, sdpMLineIndex: value.sdpMLineIndex ?? value.sdpmLineIndex }); }
144
+ catch { throw fault('invalid_candidates'); }
145
+ }
146
+ session.remoteCandidates = (session.remoteCandidates || 0) + candidates.length;
147
+ }
148
+ async socket(session) {
149
+ const socket = createSocket('udp4'); session.sockets.add(socket);
150
+ socket.on('error', () => this.fail(session, 'udp_error'));
151
+ await new Promise((resolve, reject) => { socket.once('error', reject); socket.bind(0, '127.0.0.1', resolve); });
152
+ if (!this.alive(session)) { socket.close(); throw fault('session_closed'); }
153
+ return socket;
154
+ }
155
+ job(session, args, failure = true) {
156
+ let child;
157
+ try { child = this.spawnProcess(this.ffmpegPath, ['-hide_banner', '-nostdin', '-loglevel', 'error', ...args], { stdio: ['pipe', 'pipe', 'pipe'] }); }
158
+ catch { throw fault('ffmpeg_unavailable'); }
159
+ session.jobs.add(child); child.stdin?.on('error', () => {}); child.stdout?.on('error', () => {});
160
+ child.stderr?.on('data', () => {}); child.on('error', () => { if (failure) this.fail(session, 'ffmpeg_unavailable'); });
161
+ child.once('close', () => { session.jobs.delete(child); if (failure && this.alive(session) && !child.intentionalStop) this.fail(session, 'ffmpeg_exit'); });
162
+ return child;
163
+ }
164
+ kill(session, child) {
165
+ if (!child) return; child.intentionalStop = true; session.jobs.delete(child);
166
+ try { child.kill('SIGTERM'); } catch {}
167
+ timer(() => { if (child.exitCode === null && child.signalCode === null) { try { child.kill('SIGKILL'); } catch {} } }, 1500);
168
+ }
169
+ async startMedia(session) {
170
+ if (!this.alive(session)) return;
171
+ if ((session.video && !session.videoSender.codec) || (session.audio && !session.audioSender.codec)) throw fault('codec_unavailable');
172
+ if (session.video) {
173
+ const source = localUrl(await this.runtime.ensureReady({ timeoutMs: 5000 }), ['/cam.ts']);
174
+ if (!this.alive(session)) return;
175
+ const socket = await this.socket(session), v = session.video;
176
+ session.videoDeadline = timer(() => this.fail(session, 'video_timeout'), this.mediaTimeoutMs);
177
+ socket.on('message', bytes => { if (!this.alive(session) || !isRtp(bytes) || (bytes[1] & 0x7f) !== 96) return; clearTimeout(session.videoDeadline); this.counters.videoPackets++; session.videoTrack.writeRtp(bytes); });
178
+ this.job(session, ['-rw_timeout', '8000000', '-analyzeduration', '1000000', '-probesize', '2000000', '-f', 'mpegts', '-i', source,
179
+ '-map', '0:v:0', '-an', '-sn', '-dn', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-profile:v', 'baseline', '-level:v', '3.1',
180
+ '-preset', 'ultrafast', '-tune', 'zerolatency', '-threads', '2', '-vf', `scale=${v.width}:${v.height}:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,pad=${v.width}:${v.height}:(ow-iw)/2:(oh-ih)/2`,
181
+ '-r', String(v.frameRate), '-b:v', String(v.bitRate), '-maxrate', String(v.bitRate), '-bufsize', String(v.bitRate * 2),
182
+ '-g', String(Math.max(1, Math.round(v.frameRate * v.keyFrameInterval / 1000))), '-bf', '0', '-x264-params', 'repeat-headers=1',
183
+ '-payload_type', '96', '-f', 'rtp', `rtp://127.0.0.1:${socket.address().port}?pkt_size=1200`]);
184
+ }
185
+ if (session.audio) void this.startAudio(session);
186
+ }
187
+ async startAudio(session) {
188
+ if (!this.alive(session) || this.microphoneMuted || session.audioChild || session.audioStarting || !session.audio) return;
189
+ const generation = session.audioGeneration || 0; session.audioStarting = true;
190
+ try {
191
+ const value = await this.runtime.audioSource?.();
192
+ if (!this.alive(session) || this.microphoneMuted || generation !== (session.audioGeneration || 0)) return;
193
+ if (!value) { this.pollAudio(session, 2000); return; }
194
+ const source = audioSource(value), socket = await this.socket(session); session.audioSocket = socket;
195
+ if (!this.alive(session) || this.microphoneMuted || generation !== (session.audioGeneration || 0)) {
196
+ session.sockets.delete(socket); try { socket.close(); } catch {} return;
197
+ }
198
+ session.audioSender.replaceRTP({ sequenceNumber: 0, timestamp: 0 }, true, 960);
199
+ socket.on('message', bytes => { if (this.alive(session) && !this.microphoneMuted && generation === (session.audioGeneration || 0) && isRtp(bytes) && (bytes[1] & 0x7f) === 111) { this.counters.audioPackets++; session.audioTrack.writeRtp(bytes); } });
200
+ session.audioChild = this.job(session, ['-rw_timeout', '8000000', '-f', source.format, '-i', source.url, '-map', '0:a:0', '-vn', '-sn', '-dn',
201
+ '-af', `volume=${this.microphoneVolume / 100}`, '-c:a', 'libopus', '-ar', '48000', '-ac', '1', '-application', 'lowdelay', '-frame_duration', '20',
202
+ '-b:a', String(session.audio.bitRate), '-payload_type', '111', '-f', 'rtp', `rtp://127.0.0.1:${socket.address().port}?pkt_size=1200`], false);
203
+ const child = session.audioChild;
204
+ child.on('error', () => this.restartAudio(session)); child.once('close', () => { if (session.audioChild === child) this.restartAudio(session); });
205
+ } catch { if (this.alive(session)) this.pollAudio(session, 2000); }
206
+ finally {
207
+ session.audioStarting = false;
208
+ if (this.alive(session) && !this.microphoneMuted && !session.audioChild && !session.audioPoll)
209
+ this.pollAudio(session, 500);
210
+ }
211
+ }
212
+ pollAudio(session, ms) {
213
+ clearTimeout(session.audioPoll);
214
+ session.audioPoll = timer(() => { session.audioPoll = undefined; void this.startAudio(session); }, ms);
215
+ }
216
+ stopAudio(session) {
217
+ session.audioGeneration = (session.audioGeneration || 0) + 1; clearTimeout(session.audioPoll); session.audioPoll = undefined;
218
+ this.kill(session, session.audioChild); session.audioChild = undefined;
219
+ if (session.audioSocket) { session.sockets.delete(session.audioSocket); try { session.audioSocket.close(); } catch {} session.audioSocket = undefined; }
220
+ }
221
+ restartAudio(session) { this.stopAudio(session); if (this.alive(session) && !this.microphoneMuted) this.pollAudio(session, 500); }
222
+ receiveTalk(session, packet) {
223
+ if (!this.alive(session) || this.speakerMuted || !session.audio || this.config.enableTalkback === false) return;
224
+ this.counters.talkPackets++;
225
+ if (!session.talk) { session.talk = { queue: [], size: 0, closed: false }; void this.startTalk(session, session.talk); }
226
+ const talk = session.talk; clearTimeout(talk.idleTimer); talk.idleTimer = timer(() => this.stopTalk(session), 2500);
227
+ const copy = packet.clone(); copy.header.extensions = []; copy.header.payloadType = 111;
228
+ const bytes = copy.serialize();
229
+ if (talk.ready && talk.socket) talk.socket.send(bytes, talk.port, '127.0.0.1', () => {});
230
+ else if (talk.size + bytes.length <= 65536) { talk.queue.push(bytes); talk.size += bytes.length; }
231
+ else this.stopTalk(session);
232
+ }
233
+ async startTalk(session, talk) {
234
+ try {
235
+ const value = await this.runtime.talkSource?.(); if (!value) throw fault('talkback_unavailable');
236
+ const source = audioSource(value, true); if (!this.alive(session) || talk.closed || this.speakerMuted) return;
237
+ const reservation = await this.socket(session); talk.port = reservation.address().port;
238
+ session.sockets.delete(reservation); await new Promise(resolve => reservation.close(resolve));
239
+ talk.socket = await this.socket(session);
240
+ if (talk.closed || this.speakerMuted) { session.sockets.delete(talk.socket); talk.socket.close(); return; }
241
+ const description = `v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=Talk\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\nm=audio ${talk.port} RTP/AVP 111\r\na=rtpmap:111 opus/48000/2\r\n`;
242
+ const output = source.format === 's16le' ? ['-c:a', 'pcm_s16le', '-ar', '8000', '-ac', '1', '-f', 's16le']
243
+ : ['-c:a', 'libopus', '-application', 'lowdelay', '-frame_duration', '40', '-ar', '48000', '-ac', '2', '-f', 'ogg', '-page_duration', '40000'];
244
+ talk.child = this.job(session, ['-protocol_whitelist', 'file,pipe,udp,rtp', '-analyzeduration', '0', '-probesize', '32768', '-f', 'sdp', '-i', 'pipe:0',
245
+ '-map', '0:a:0', '-vn', '-sn', '-dn', '-af', `volume=${this.speakerVolume / 100}`, ...output, 'pipe:1'], false);
246
+ talk.child.stdin.end(description);
247
+ talk.post = httpRequest(source.url, { method: 'POST', headers: { 'Content-Type': source.format === 'ogg' ? 'audio/ogg' : 'application/octet-stream' } }, response => {
248
+ if (response.statusCode !== 200) this.stopTalk(session, talk); response.resume();
249
+ });
250
+ talk.post.on('error', () => this.stopTalk(session, talk)); talk.post.setTimeout(10000, () => this.stopTalk(session, talk));
251
+ talk.child.stdout.on('data', bytes => {
252
+ if (talk.closed || !this.alive(session) || this.speakerMuted) return;
253
+ if (talk.post.writableLength > 262144) { this.stopTalk(session, talk); return; }
254
+ this.counters.talkBytes += bytes.length;
255
+ if (!talk.post.write(bytes)) { talk.child.stdout.pause(); talk.post.once('drain', () => { if (!talk.closed) talk.child.stdout.resume(); }); }
256
+ });
257
+ talk.child.on('error', () => this.stopTalk(session, talk)); talk.child.once('close', () => { if (!talk.closed) this.stopTalk(session, talk); });
258
+ talk.readyTimer = timer(() => {
259
+ if (!this.alive(session) || talk.closed || this.speakerMuted) return;
260
+ talk.ready = true; for (const bytes of talk.queue) talk.socket.send(bytes, talk.port, '127.0.0.1', () => {});
261
+ talk.queue.length = 0; talk.size = 0;
262
+ }, 200);
263
+ } catch { this.stopTalk(session, talk); }
264
+ }
265
+ stopTalk(session, expected) {
266
+ const talk = session.talk; if (!talk || (expected && expected !== talk)) return; session.talk = undefined; talk.closed = true;
267
+ clearTimeout(talk.idleTimer); clearTimeout(talk.readyTimer); talk.queue.length = 0; talk.size = 0;
268
+ this.kill(session, talk.child); talk.post?.end(); timer(() => talk.post?.destroy(), 300);
269
+ if (talk.socket) { session.sockets.delete(talk.socket); try { talk.socket.close(); } catch {} }
270
+ }
271
+ setMicrophoneMuted(value) { this.microphoneMuted = value === true || this.config.enableAudio === false; if (this.session) { this.stopAudio(this.session); if (!this.microphoneMuted && this.session.started) void this.startAudio(this.session); } }
272
+ setSpeakerMuted(value) { this.speakerMuted = value === true || this.config.enableAudio === false || this.config.enableTalkback === false; if (this.session) this.stopTalk(this.session); }
273
+ setMicrophoneVolume(value) { this.microphoneVolume = clamp(integer(value, 100, 0, 100), 0, 100); if (this.session) this.restartAudio(this.session); }
274
+ setSpeakerVolume(value) { this.speakerVolume = clamp(integer(value, 100, 0, 100), 0, 100); if (this.session) this.stopTalk(this.session); }
275
+ async snapshot({ width = 320, height = 180 } = {}) {
276
+ if (this.closed) throw fault('closed');
277
+ width = integer(width, 320, 2, 1280); height = integer(height, 180, 2, 720);
278
+ if (!this.snapshotDelegate?.handleSnapshotRequest) throw fault('snapshot_unavailable');
279
+ const data = await new Promise((resolve, reject) => this.snapshotDelegate.handleSnapshotRequest({ width, height }, (error, data) => error ? reject(fault('snapshot_unavailable')) : resolve(data)));
280
+ if (!Buffer.isBuffer(data) || data.length > 8 * 1024 * 1024) throw fault('snapshot_invalid');
281
+ return { data, ...jpegSize(data) };
282
+ }
283
+ async closeSession(id) {
284
+ const session = this.session; if (!session || session.id !== id || session.closed) return;
285
+ session.closed = true; this.session = undefined;
286
+ for (const name of ['connectTimer', 'disconnectTimer', 'lifeTimer', 'videoDeadline']) clearTimeout(session[name]);
287
+ this.stopAudio(session); this.stopTalk(session);
288
+ for (const child of [...session.jobs]) this.kill(session, child);
289
+ for (const socket of session.sockets) { try { socket.close(); } catch {} } session.sockets.clear();
290
+ session.videoTrack?.stop(); session.audioTrack?.stop();
291
+ try { await session.peer.close(); } catch {}
292
+ this.emit(session, 'closed');
293
+ }
294
+ async close() { this.closed = true; if (this.session) await this.closeSession(this.session.id); }
295
+ }
296
+ export const createMatterMedia = options => new MatterMedia(options);
@@ -0,0 +1,117 @@
1
+ import { createHash } from 'node:crypto';
2
+ import path from 'node:path';
3
+
4
+ import { safeMatterStatus } from './matter-diagnostics.mjs';
5
+ export { safeMatterStatus } from './matter-diagnostics.mjs';
6
+ const REASONS = new Set(['matter_disabled', 'matter_not_ready', 'matter_start_failed', 'matter_port_in_use',
7
+ 'matter_configuration_invalid', 'matter_pairing_unavailable', 'matter_window_failed', 'matter_closed']);
8
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
9
+ const bounded = (value, fallback, min, max) => Number.isInteger(value) && value >= min && value <= max ? value : fallback;
10
+ const failure = reason => Object.assign(new Error(REASONS.has(reason) ? reason : 'matter_start_failed'), {
11
+ reason: REASONS.has(reason) ? reason : 'matter_start_failed',
12
+ });
13
+
14
+ /** Optional Matter endpoint. Its failure never prevents the existing HomeKit accessory from starting. */
15
+ export class MatterService {
16
+ constructor({ runtime, delegate, config = {}, storagePath, name, serialNumber, deviceFactory, mediaFactory } = {}) {
17
+ this.runtime = runtime; this.delegate = delegate; this.config = config;
18
+ this.storagePath = storagePath; this.name = name; this.serialNumber = serialNumber;
19
+ this.deviceFactory = deviceFactory; this.mediaFactory = mediaFactory;
20
+ this.enabled = config.enableMatter === true; this.state = this.enabled ? 'stopped' : 'disabled';
21
+ this.reason = this.enabled ? undefined : 'matter_disabled'; this.closed = false;
22
+ }
23
+ status() {
24
+ let raw = {}, media;
25
+ try { raw = this.device?.status?.() ?? {}; } catch {}
26
+ try { media = this.media?.status?.(); } catch {}
27
+ return safeMatterStatus({ ...raw, enabled: this.enabled, running: this.state === 'ready' && raw.running === true,
28
+ state: this.state, reason: this.reason, media });
29
+ }
30
+ start() {
31
+ if (this.closed) return Promise.reject(failure('matter_closed'));
32
+ if (!this.enabled) return Promise.resolve(this.status());
33
+ if (this.starting) return this.starting;
34
+ if (this.state === 'ready') return Promise.resolve(this.status());
35
+ this.starting = this._start().finally(() => { this.starting = undefined; });
36
+ return this.starting;
37
+ }
38
+ async _start() {
39
+ this.state = 'starting'; this.reason = undefined;
40
+ try {
41
+ const port = this.config.matterPort ?? 5540;
42
+ const networkInterface = this.config.matterInterface ?? 'eth0';
43
+ if (!Number.isInteger(port) || port < 1024 || port > 65535 || typeof networkInterface !== 'string'
44
+ || !/^[A-Za-z0-9_.:-]{1,40}$/.test(networkInterface) || typeof this.storagePath !== 'string')
45
+ throw failure('matter_configuration_invalid');
46
+ const createMedia = this.mediaFactory ?? (await import('./matter-media.mjs')).createMatterMedia;
47
+ if (this.closed) throw failure('matter_closed');
48
+ this.media = await createMedia({ runtime: this.runtime, ffmpegPath: this.runtime.ffmpegPath,
49
+ config: this.config, snapshotDelegate: this.delegate });
50
+ const createDevice = this.deviceFactory ?? (await import('./matter-device.mjs')).createMatterDevice;
51
+ if (this.closed) throw failure('matter_closed');
52
+ const selected = this.runtime.selected?.();
53
+ const serialNumber = this.serialNumber || `MiCam-${createHash('sha256').update(String(selected?.deviceId || '')).digest('hex').slice(0, 16)}`;
54
+ this.device = await createDevice({ storagePath: path.resolve(this.storagePath), name: this.name,
55
+ serialNumber, networkInterface, port, runtime: this.runtime, media: this.media,
56
+ config: { width: bounded(this.config.maxWidth, 640, 160, 1280) & ~1,
57
+ height: bounded(this.config.maxHeight, 360, 120, 720) & ~1,
58
+ frameRate: bounded(this.config.maxFPS, 12, 1, 25),
59
+ bitRate: bounded(this.config.maxBitrate, 700, 100, 2000) * 1000,
60
+ maxStreams: 1, enableAudio: this.config.enableAudio !== false,
61
+ enableTalkback: this.config.enableAudio !== false && this.config.enableTalkback !== false,
62
+ nightVision: this.config.enableNightVision !== false
63
+ && ['chuangmi.camera.061a01', 'chuangmi.camera.061a03'].includes(selected?.model) } });
64
+ if (this.closed) throw failure('matter_closed');
65
+ await this.device.start();
66
+ if (this.closed) throw failure('matter_closed');
67
+ this.state = 'ready';
68
+ this.statusListener = value => {
69
+ if (value?.ready !== true) void Promise.resolve().then(() => this.device?.closeSessions?.()).catch(() => {});
70
+ };
71
+ this.runtime.on?.('status', this.statusListener);
72
+ return this.status();
73
+ } catch (error) {
74
+ await this._release();
75
+ this.device = this.media = undefined;
76
+ const reason = REASONS.has(error?.reason) ? error.reason
77
+ : error?.code === 'EADDRINUSE' ? 'matter_port_in_use' : 'matter_start_failed';
78
+ this.state = this.closed ? 'stopped' : 'failed'; this.reason = reason;
79
+ throw failure(reason);
80
+ }
81
+ }
82
+ pairing() {
83
+ if (!this.enabled) throw failure('matter_disabled');
84
+ if (this.closed || this.state !== 'ready' || !this.device) throw failure('matter_not_ready');
85
+ const value = this.device.pairing();
86
+ if (!object(value) || typeof value.manualPairingCode !== 'string' || !/^(?:\d{11}|\d{21})$/.test(value.manualPairingCode)
87
+ || typeof value.qrPairingCode !== 'string' || !/^MT:[0-9A-Z.:-]{10,250}$/.test(value.qrPairingCode))
88
+ throw failure('matter_pairing_unavailable');
89
+ return { manualPairingCode: value.manualPairingCode, qrPairingCode: value.qrPairingCode };
90
+ }
91
+ async openCommissioningWindow() {
92
+ if (!this.enabled) throw failure('matter_disabled');
93
+ if (this.closed || this.state !== 'ready' || !this.device) throw failure('matter_not_ready');
94
+ try { await this.device.openCommissioningWindow(); return this.status(); }
95
+ catch { throw failure('matter_window_failed'); }
96
+ }
97
+ async closeCommissioningWindow() {
98
+ if (!this.enabled) throw failure('matter_disabled');
99
+ if (this.closed || this.state !== 'ready' || !this.device?.closeCommissioningWindow) throw failure('matter_not_ready');
100
+ try { await this.device.closeCommissioningWindow(); return this.status(); }
101
+ catch { throw failure('matter_window_failed'); }
102
+ }
103
+ async _release() {
104
+ if (this.statusListener) this.runtime.off?.('status', this.statusListener);
105
+ this.statusListener = undefined;
106
+ for (const value of [this.device, this.media]) { try { await value?.close?.(); } catch {} }
107
+ }
108
+ close() {
109
+ if (this.closing) return this.closing;
110
+ this.closed = true;
111
+ return this.closing = (async () => {
112
+ await this.starting?.catch(() => {});
113
+ await this._release();
114
+ this.device = this.media = undefined; this.state = 'stopped';
115
+ })();
116
+ }
117
+ }
package/lib/platform.mjs CHANGED
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import path from 'node:path';
3
3
  import { MijiaCameraDelegate } from './camera.mjs';
4
4
  import { configureNightVision } from './night-vision.mjs';
5
+ import { MatterService } from './matter-service.mjs';
5
6
 
6
7
  export const PLUGIN_NAME = '@linanw/homebridge-mijia-remote-camera';
7
8
  export const PLATFORM_NAME = 'MijiaRemoteCamera';
@@ -12,12 +13,13 @@ const label = value => typeof value === 'string' && value.trim()
12
13
  ? value.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 64).trim() : '米家摄像头';
13
14
 
14
15
  export class MijiaRemoteCameraPlatform {
15
- constructor(log, config, api, { runtimeFactory, delegateFactory } = {}) {
16
+ constructor(log, config, api, { runtimeFactory, delegateFactory, matterServiceFactory } = {}) {
16
17
  this.log = log;
17
18
  this.config = config || {};
18
19
  this.api = api;
19
20
  this.runtimeFactory = runtimeFactory;
20
21
  this.delegateFactory = delegateFactory || (options => new MijiaCameraDelegate(options));
22
+ this.matterServiceFactory = matterServiceFactory || (options => new MatterService(options));
21
23
  this.refreshQueue = Promise.resolve();
22
24
  this.stopping = false;
23
25
  api.on('didFinishLaunching', () => {
@@ -41,6 +43,15 @@ export class MijiaRemoteCameraPlatform {
41
43
  config: this.config, log: this.log,
42
44
  });
43
45
  this.runtime.mediaDiagnostics = () => this.delegate?.getMediaDiagnostics?.();
46
+ this.runtime.matterStatus = () => this.matter?.status?.() ?? { enabled: this.config.enableMatter === true,
47
+ running: false, state: this.config.enableMatter === true ? 'stopped' : 'disabled' };
48
+ const matterOperation = method => async () => {
49
+ if (!this.matter) throw Object.assign(new Error('matter_not_ready'), { reason: 'matter_not_ready' });
50
+ return this.matter[method]();
51
+ };
52
+ this.runtime.matterPairing = matterOperation('pairing');
53
+ this.runtime.matterOpenWindow = matterOperation('openCommissioningWindow');
54
+ this.runtime.matterCloseWindow = matterOperation('closeCommissioningWindow');
44
55
  if (this.stopping) { await this.runtime.stop(); await this.runtime.close?.(); return; }
45
56
  this.selectionListener = () => {
46
57
  this.refreshQueue = this.refreshQueue.then(() => this.refresh()).catch(() => {
@@ -54,15 +65,18 @@ export class MijiaRemoteCameraPlatform {
54
65
  async refresh() {
55
66
  if (this.stopping) return;
56
67
  const selected = await this.runtime.selected();
68
+ if (this.stopping) return;
57
69
  if (!selected || !numeric(selected.deviceId) || !numeric(selected.homeId) || !numeric(selected.homeOwner)) {
58
70
  this.delegate?.shutdown();
59
71
  this.nightVision?.shutdown?.();
72
+ await this.matter?.close?.();
60
73
  this.log.info('请打开插件设置,登录米家并选择家庭和摄像头。');
61
74
  return;
62
75
  }
63
76
  if (this.deviceId && this.deviceId !== selected.deviceId) {
64
77
  this.delegate?.shutdown();
65
78
  this.nightVision?.shutdown?.();
79
+ await this.matter?.close?.();
66
80
  this.log.info('已保存新的摄像头选择,请重启 Homebridge 使配件变更生效。');
67
81
  return;
68
82
  }
@@ -81,7 +95,7 @@ export class MijiaRemoteCameraPlatform {
81
95
  ?.setCharacteristic(this.api.hap.Characteristic.Manufacturer, 'Xiaomi')
82
96
  .setCharacteristic(this.api.hap.Characteristic.Model, label(selected.model || '米家远程摄像头'))
83
97
  .setCharacteristic(this.api.hap.Characteristic.SerialNumber, `MiCam-${opaqueId}`)
84
- .setCharacteristic(this.api.hap.Characteristic.FirmwareRevision, '0.1.2');
98
+ .setCharacteristic(this.api.hap.Characteristic.FirmwareRevision, '0.2.0');
85
99
  this.delegate = this.delegateFactory({ api: this.api, log: this.log, runtime: this.runtime,
86
100
  config: this.config, ffmpegPath: this.runtime.ffmpegPath });
87
101
  accessory.configureController(this.delegate.controller);
@@ -93,6 +107,12 @@ export class MijiaRemoteCameraPlatform {
93
107
  this.accessory = accessory;
94
108
  this.deviceId = selected.deviceId;
95
109
  this.log.info('米家摄像头已发布,请在 Apple 家庭中添加此配件。');
110
+ this.matter = this.matterServiceFactory({ runtime: this.runtime, delegate: this.delegate, config: this.config,
111
+ storagePath: path.join(this.api.user.storagePath(), 'mijia-remote-camera'), name,
112
+ serialNumber: `MiCam-${opaqueId}` });
113
+ this.matterStart = Promise.resolve().then(() => this.stopping ? undefined : this.matter.start()).catch(() => {
114
+ this.log.warn('Matter 摄像头未能启动,请在插件设置中查看 Matter 状态。');
115
+ });
96
116
  }
97
117
 
98
118
  async shutdown() {
@@ -101,7 +121,7 @@ export class MijiaRemoteCameraPlatform {
101
121
  this.delegate?.shutdown();
102
122
  this.nightVision?.shutdown?.();
103
123
  this.runtime?.off?.('selection', this.selectionListener);
104
- await this.runtime?.stop?.();
124
+ await Promise.allSettled([this.matter?.close?.(), this.runtime?.stop?.()]);
105
125
  await this.runtime?.close?.();
106
126
  }
107
127
  }
package/lib/rpc.mjs CHANGED
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { StringDecoder } from 'node:string_decoder';
6
6
 
7
- const METHODS = new Set(['status', 'homes', 'devices', 'select', 'pause', 'resume', 'auth.start', 'auth.status', 'auth.cancel', 'night.get', 'night.set']);
7
+ const METHODS = new Set(['status', 'homes', 'devices', 'select', 'pause', 'resume', 'auth.start', 'auth.status', 'auth.cancel', 'night.get', 'night.set', 'matter.pairing', 'matter.openWindow', 'matter.closeWindow']);
8
8
  const MAX_REQUEST = 32768;
9
9
  const MAX_RESPONSE = 1048576;
10
10
  const REASONS = new Set(['operation_failed', 'invalid_request', 'response_too_large', 'invalid_response', 'plugin_not_running',
@@ -16,7 +16,9 @@ const REASONS = new Set(['operation_failed', 'invalid_request', 'response_too_la
16
16
  'runtime_unavailable', 'query_timeout', 'query_cancelled', 'invalid_backend_response', 'storage_failed', 'auth_in_progress',
17
17
  'invalid_region', 'unsafe_url', 'network_error', 'request_timeout', 'unexpected_response', 'login_expired', 'login_cancelled',
18
18
  'login_not_approved', 'incomplete_session', 'credentials_write_failed', 'unknown_login',
19
- 'night_vision_unsupported', 'night_vision_unavailable', 'night_vision_rejected', 'night_vision_not_applied', 'night_vision_timeout']);
19
+ 'night_vision_unsupported', 'night_vision_unavailable', 'night_vision_rejected', 'night_vision_not_applied', 'night_vision_timeout',
20
+ 'matter_disabled', 'matter_not_ready', 'matter_start_failed', 'matter_port_in_use', 'matter_configuration_invalid',
21
+ 'matter_pairing_unavailable', 'matter_window_failed', 'matter_closed']);
20
22
 
21
23
  export function socketPath(storagePath) {
22
24
  const normal = join(resolve(storagePath), 'control.sock');
@@ -50,8 +50,12 @@ export async function attachServices(runtime, auth, storagePath) {
50
50
  if (method === 'status') {
51
51
  const status = runtime.status();
52
52
  const media = runtime.mediaDiagnostics?.();
53
- return media ? { ...status, media } : status;
53
+ const matter = runtime.matterStatus?.();
54
+ return { ...status, ...(media ? { media } : {}), ...(matter ? { matter } : {}) };
54
55
  }
56
+ if (method === 'matter.pairing') return runtime.matterPairing?.() ?? Promise.reject(failure('matter_not_ready'));
57
+ if (method === 'matter.openWindow') return runtime.matterOpenWindow?.() ?? Promise.reject(failure('matter_not_ready'));
58
+ if (method === 'matter.closeWindow') return runtime.matterCloseWindow?.() ?? Promise.reject(failure('matter_not_ready'));
55
59
  if (authorizing && ['homes', 'devices', 'select', 'resume', 'night.get', 'night.set'].includes(method)) throw failure('auth_in_progress');
56
60
  if (method === 'night.get') return runtime.getNightVision();
57
61
  if (method === 'night.set') return runtime.setNightVision(payload.mode);