@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.
@@ -8,6 +8,7 @@ const integer = (value, fallback, min, max) => Number.isInteger(value) && value
8
8
  const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
9
9
  const timer = (callback, ms) => { const value = setTimeout(callback, ms); value.unref(); return value; };
10
10
  const isRtp = bytes => Buffer.isBuffer(bytes) && bytes.length >= 12 && bytes.length <= 2048 && (bytes[0] >> 6) === 2;
11
+ const exceptionTypes = new Set(['Error', 'TypeError', 'RangeError', 'OperationError', 'InvalidStateError', 'InvalidModificationError', 'InvalidAccessError', 'NotSupportedError', 'SyntaxError', 'AbortError']);
11
12
  function localUrl(value, paths) {
12
13
  try {
13
14
  const url = new URL(value);
@@ -63,22 +64,35 @@ export class MatterMedia {
63
64
  this.config = config; this.snapshotDelegate = snapshotDelegate; this.spawnProcess = spawnProcess;
64
65
  this.connectTimeoutMs = connectTimeoutMs; this.mediaTimeoutMs = mediaTimeoutMs;
65
66
  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.microphoneVolume = 100; this.speakerVolume = 100; this.closed = false;
68
+ this.maxStreams = integer(config.maxStreams, 2, 1, 4);
69
+ this.sessions = new Map(); this.sources = new Map(); this.talkOwner = undefined;
67
70
  this.counters = { sessions: 0, failures: 0, videoPackets: 0, audioPackets: 0, talkPackets: 0, talkBytes: 0, state: 'idle' };
68
71
  }
69
- status() { return { ...this.counters, active: Boolean(this.session), microphoneMuted: this.microphoneMuted, speakerMuted: this.speakerMuted }; }
72
+ // Kept for consumers that inspect the original single-view transport.
73
+ get session() { return this.sessions.values().next().value; }
74
+ status() {
75
+ const sources = [...this.sources.values()];
76
+ return { ...this.counters, active: this.sessions.size > 0, activeSessions: this.sessions.size,
77
+ maxSessions: this.maxStreams, sourceJobs: sources.reduce((count, source) => count + source.jobs.size, 0),
78
+ sourceSockets: sources.reduce((count, source) => count + source.sockets.size, 0),
79
+ activeSources: sources.length, videoSources: sources.filter(source => source.kind === 'video').length,
80
+ audioSources: sources.filter(source => source.kind === 'audio').length, talkbackActive: Boolean(this.talkOwner),
81
+ microphoneMuted: this.microphoneMuted, speakerMuted: this.speakerMuted };
82
+ }
70
83
  emit(session, state) {
71
- this.counters.state = state;
84
+ this.counters.state = state === 'closed' && this.sessions.size ? ([...this.sessions.values()].some(value => value.peer.connectionState === 'connected') ? 'connected' : 'connecting') : state;
72
85
  try { session.onState?.(state); } catch {}
73
86
  }
74
- alive(session) { return this.session === session && !session.closed && !this.closed; }
87
+ alive(session) { return this.sessions.get(String(session.id)) === session && !session.closed && !this.closed; }
75
88
  fail(session, reason = 'media_unavailable') {
76
89
  if (!this.alive(session)) return;
77
- this.counters.failures++; this.counters.lastFailure = reason; this.emit(session, 'failed'); void this.closeSession(session.id);
90
+ this.counters.failures++; this.counters.lastFailure = reason; this.emit(session, 'failed'); void this.closeSession(session.id, session);
78
91
  }
79
92
  async createSession({ id, offer, video, audio, onCandidate, onState, iceServers, iceTransportPolicy } = {}) {
80
93
  if (this.closed) throw fault('closed');
81
- if (this.session) throw fault('session_busy');
94
+ if (this.sessions.has(String(id))) throw fault('duplicate_session');
95
+ if (this.sessions.size >= this.maxStreams) throw fault('session_busy');
82
96
  if (iceTransportPolicy !== undefined && iceTransportPolicy !== 'all') throw fault('relay_unavailable');
83
97
  if (iceServers !== undefined && (!Array.isArray(iceServers) || iceServers.length)) throw fault('ice_servers_unsupported');
84
98
  if ((typeof id !== 'string' && typeof id !== 'number') || !String(id).length || String(id).length > 160 || (!video && !audio)) throw fault('invalid_session');
@@ -94,9 +108,9 @@ export class MatterMedia {
94
108
  keyFrameInterval: integer(video.keyFrameInterval, 1000, 200, 4000) } : undefined,
95
109
  audio: audio && this.config.enableAudio !== false ? { channels: 1, bitRate: integer(audio.bitRate, 24000, 16000, 64000) } : undefined };
96
110
  if (!session.video && !session.audio) { await peer.close(); throw fault('audio_disabled'); }
97
- this.session = session; this.counters.sessions++; this.emit(session, 'connecting');
111
+ this.sessions.set(String(id), session); this.counters.sessions++; this.emit(session, 'connecting');
98
112
  session.connectTimer = timer(() => this.fail(session, 'webrtc_timeout'), this.connectTimeoutMs);
99
- session.lifeTimer = timer(() => this.closeSession(id), 30 * 60 * 1000);
113
+ session.lifeTimer = timer(() => this.closeSession(id, session), 30 * 60 * 1000);
100
114
  peer.onIceCandidate.subscribe(candidate => {
101
115
  if (!candidate || !this.alive(session) || session.candidateCount++ >= 128) return;
102
116
  try { onCandidate?.({ candidate: candidate.candidate, sdpMid: candidate.sdpMid ?? undefined, sdpMLineIndex: candidate.sdpMLineIndex ?? undefined }); } catch {}
@@ -113,26 +127,46 @@ export class MatterMedia {
113
127
  if (track.kind === 'audio') track.onReceiveRtp.subscribe(packet => this.receiveTalk(session, packet));
114
128
  });
115
129
  try {
116
- if (offer !== undefined) await peer.setRemoteDescription({ type: 'offer', sdp: offer });
130
+ // Install source codec constraints before applying an offer. With no local
131
+ // source werift chooses the first H264 MIME match, which can be a different
132
+ // profile from the encoded RTP source in a browser's multi-profile offer.
117
133
  for (const [kind, enabled] of [['video', session.video], ['audio', session.audio]]) {
118
134
  if (!enabled) continue;
135
+ session.negotiationStage = `replace_${kind}_track`;
119
136
  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
137
  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');
138
+ session[`${kind}Sender`] = peer.addTransceiver(track, { direction }).sender;
139
+ }
140
+ if (offer !== undefined) {
141
+ session.negotiationStage = 'remote_description'; await peer.setRemoteDescription({ type: 'offer', sdp: offer });
142
+ for (const kind of ['video', 'audio']) {
143
+ if (session[kind] && !peer.getTransceivers().some(t => t.sender === session[`${kind}Sender`] && t.mid != null && !t.stopped && !t.rejected)) throw fault('media_not_offered');
144
+ }
125
145
  }
126
- await peer.setLocalDescription(offer !== undefined ? await peer.createAnswer() : await peer.createOffer());
146
+ session.negotiationStage = offer !== undefined ? 'create_answer' : 'create_offer';
147
+ const description = offer !== undefined ? await peer.createAnswer() : await peer.createOffer();
148
+ session.negotiationStage = 'local_description'; await peer.setLocalDescription(description);
127
149
  if (!this.alive(session)) throw fault('session_closed');
128
150
  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'); }
151
+ } catch (error) {
152
+ const reason = error?.reason === 'media_not_offered' ? 'media_not_offered' : 'negotiation_failed';
153
+ if (!this.alive(session)) { await this.closeSession(id, session); throw fault('session_closed'); }
154
+ this.negotiationFailure(session, error, reason);
155
+ await this.closeSession(id, session); throw fault(reason);
156
+ }
130
157
  }
131
- getSession(id) { const session = this.session; if (!session || session.id !== id || session.closed) throw fault('unknown_session'); return session; }
158
+ negotiationFailure(session, error, reason = 'negotiation_failed') {
159
+ this.counters.failures++; this.counters.lastFailure = reason;
160
+ this.counters.lastFailureStage = session.negotiationStage || 'create_offer';
161
+ this.counters.lastFailureType = exceptionTypes.has(error?.name) ? error.name : 'Error';
162
+ }
163
+ getSession(id) { const session = this.sessions.get(String(id)); if (!session || session.closed) throw fault('unknown_session'); return session; }
132
164
  async provideAnswer(id, value) {
133
165
  const session = this.getSession(id); sdp(value);
134
166
  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'); }
167
+ session.negotiationStage = 'remote_answer';
168
+ try { await session.peer.setRemoteDescription({ type: 'answer', sdp: value }); }
169
+ catch (error) { if (!this.alive(session)) throw fault('session_closed'); this.negotiationFailure(session, error); await this.closeSession(id, session); throw fault('negotiation_failed'); }
136
170
  }
137
171
  async addCandidates(id, candidates) {
138
172
  const session = this.getSession(id);
@@ -145,83 +179,144 @@ export class MatterMedia {
145
179
  }
146
180
  session.remoteCandidates = (session.remoteCandidates || 0) + candidates.length;
147
181
  }
148
- async socket(session) {
149
- const socket = createSocket('udp4'); session.sockets.add(socket);
150
- socket.on('error', () => this.fail(session, 'udp_error'));
182
+ ownerAlive(owner) { return owner.kind ? this.sourceAlive(owner) : this.alive(owner); }
183
+ ownerFail(owner, reason) { if (owner.kind) this.failSource(owner, reason); else this.fail(owner, reason); }
184
+ async socket(owner) {
185
+ const socket = createSocket('udp4'); owner.sockets.add(socket);
186
+ socket.on('error', () => this.ownerFail(owner, 'udp_error'));
151
187
  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'); }
188
+ if (!this.ownerAlive(owner)) { owner.sockets.delete(socket); try { socket.close(); } catch {} throw fault('session_closed'); }
153
189
  return socket;
154
190
  }
155
- job(session, args, failure = true) {
191
+ job(owner, args, failure = true) {
156
192
  let child;
157
193
  try { child = this.spawnProcess(this.ffmpegPath, ['-hide_banner', '-nostdin', '-loglevel', 'error', ...args], { stdio: ['pipe', 'pipe', 'pipe'] }); }
158
194
  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'); });
195
+ owner.jobs.add(child); child.stdin?.on('error', () => {}); child.stdout?.on('error', () => {});
196
+ child.stderr?.on('data', () => {}); child.on('error', () => { if (failure) this.ownerFail(owner, 'ffmpeg_unavailable'); });
197
+ child.once('close', () => { owner.jobs.delete(child); if (failure && this.ownerAlive(owner) && !child.intentionalStop) this.ownerFail(owner, 'ffmpeg_exit'); });
162
198
  return child;
163
199
  }
164
- kill(session, child) {
165
- if (!child) return; child.intentionalStop = true; session.jobs.delete(child);
200
+ kill(owner, child) {
201
+ if (!child) return; child.intentionalStop = true; owner.jobs.delete(child);
166
202
  try { child.kill('SIGTERM'); } catch {}
167
203
  timer(() => { if (child.exitCode === null && child.signalCode === null) { try { child.kill('SIGKILL'); } catch {} } }, 1500);
168
204
  }
205
+ sourceAlive(source) { return this.sources.get(source.key) === source && !source.closed && !this.closed && source.subscribers.size > 0; }
206
+ attachSource(session, kind) {
207
+ if (!this.alive(session) || (kind === 'audio' && this.microphoneMuted)) return;
208
+ if (session[`${kind}Source`]) return session[`${kind}Source`];
209
+ const profile = kind === 'video' ? session.video : { ...session.audio, volume: this.microphoneVolume };
210
+ const key = `${kind}:${JSON.stringify(profile)}`;
211
+ let source = this.sources.get(key);
212
+ if (!source || source.closed) {
213
+ source = { key, kind, profile, subscribers: new Set(), jobs: new Set(), sockets: new Set(), generation: 0 };
214
+ this.sources.set(key, source);
215
+ }
216
+ source.subscribers.add(session); session[`${kind}Source`] = source;
217
+ if (kind === 'audio') session.audioSender.replaceRTP({ sequenceNumber: 0, timestamp: 0 }, true, 960);
218
+ if (!source.starting && !source.started) {
219
+ source.started = true;
220
+ if (kind === 'video') source.starting = this.startVideoSource(source).catch(() => this.failSource(source, 'media_unavailable'));
221
+ else void this.startAudioSource(source);
222
+ }
223
+ return source;
224
+ }
225
+ detachSource(session, kind) {
226
+ const source = session[`${kind}Source`]; session[`${kind}Source`] = undefined;
227
+ if (!source) return;
228
+ source.subscribers.delete(session);
229
+ if (!source.subscribers.size) this.closeSource(source);
230
+ }
231
+ stopSourceMedia(source) {
232
+ source.generation++; clearTimeout(source.poll); clearTimeout(source.deadline); source.poll = undefined;
233
+ source.child = undefined;
234
+ for (const child of [...source.jobs]) this.kill(source, child);
235
+ for (const socket of source.sockets) { try { socket.close(); } catch {} } source.sockets.clear();
236
+ }
237
+ closeSource(source) {
238
+ if (source.closed) return;
239
+ source.closed = true;
240
+ if (this.sources.get(source.key) === source) this.sources.delete(source.key);
241
+ this.stopSourceMedia(source);
242
+ for (const session of source.subscribers) if (session[`${source.kind}Source`] === source) session[`${source.kind}Source`] = undefined;
243
+ source.subscribers.clear();
244
+ }
245
+ failSource(source, reason = 'media_unavailable') {
246
+ if (!this.sourceAlive(source)) return;
247
+ const sessions = [...source.subscribers]; this.closeSource(source);
248
+ for (const session of sessions) this.fail(session, reason);
249
+ }
250
+ fanout(source, bytes, payloadType) {
251
+ if (!this.sourceAlive(source) || !isRtp(bytes) || (bytes[1] & 0x7f) !== payloadType || (source.kind === 'audio' && this.microphoneMuted)) return;
252
+ clearTimeout(source.deadline);
253
+ for (const session of [...source.subscribers]) {
254
+ if (!this.alive(session) || session[`${source.kind}Source`] !== source) continue;
255
+ this.counters[`${source.kind}Packets`]++;
256
+ // Each peer has independent SSRC, sequence mapping, retransmission and SRTP.
257
+ // writeRtp parses/clones its input; no mutable RTP packet crosses peers.
258
+ try { session[`${source.kind}Track`].writeRtp(bytes); } catch { this.fail(session, 'media_unavailable'); }
259
+ }
260
+ }
169
261
  async startMedia(session) {
170
262
  if (!this.alive(session)) return;
171
263
  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);
264
+ if (session.video) this.attachSource(session, 'video');
265
+ if (session.audio) this.startAudio(session);
186
266
  }
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;
267
+ async startVideoSource(source) {
268
+ const url = localUrl(await this.runtime.ensureReady({ timeoutMs: 5000 }), ['/cam.ts']);
269
+ if (!this.sourceAlive(source)) return;
270
+ const socket = await this.socket(source), v = source.profile;
271
+ if (!this.sourceAlive(source)) return;
272
+ source.deadline = timer(() => this.failSource(source, 'video_timeout'), this.mediaTimeoutMs);
273
+ socket.on('message', bytes => this.fanout(source, bytes, 96));
274
+ source.child = this.job(source, ['-rw_timeout', '8000000', '-analyzeduration', '1000000', '-probesize', '2000000', '-f', 'mpegts', '-i', url,
275
+ '-map', '0:v:0', '-an', '-sn', '-dn', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-profile:v', 'baseline', '-level:v', '3.1',
276
+ '-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`,
277
+ '-r', String(v.frameRate), '-b:v', String(v.bitRate), '-maxrate', String(v.bitRate), '-bufsize', String(v.bitRate * 2),
278
+ '-g', String(Math.max(1, Math.round(v.frameRate * v.keyFrameInterval / 1000))), '-bf', '0', '-x264-params', 'repeat-headers=1',
279
+ '-payload_type', '96', '-f', 'rtp', `rtp://127.0.0.1:${socket.address().port}?pkt_size=1200`]);
280
+ }
281
+ startAudio(session) { if (this.alive(session) && !this.microphoneMuted && session.audio) this.attachSource(session, 'audio'); }
282
+ async startAudioSource(source) {
283
+ if (!this.sourceAlive(source) || this.microphoneMuted || source.child || source.starting) return;
284
+ const generation = source.generation; source.starting = true;
190
285
  try {
191
286
  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); }
287
+ if (!this.sourceAlive(source) || this.microphoneMuted || generation !== source.generation) return;
288
+ if (!value) { this.pollAudio(source, 2000); return; }
289
+ const audio = audioSource(value), socket = await this.socket(source);
290
+ if (!this.sourceAlive(source) || this.microphoneMuted || generation !== source.generation) { source.sockets.delete(socket); try { socket.close(); } catch {} return; }
291
+ for (const session of source.subscribers) session.audioSender.replaceRTP({ sequenceNumber: 0, timestamp: 0 }, true, 960);
292
+ socket.on('message', bytes => this.fanout(source, bytes, 111));
293
+ const child = this.job(source, ['-rw_timeout', '8000000', '-f', audio.format, '-i', audio.url, '-map', '0:a:0', '-vn', '-sn', '-dn',
294
+ '-af', `volume=${source.profile.volume / 100}`, '-c:a', 'libopus', '-ar', '48000', '-ac', '1', '-application', 'lowdelay', '-frame_duration', '20',
295
+ '-b:a', String(source.profile.bitRate), '-payload_type', '111', '-f', 'rtp', `rtp://127.0.0.1:${socket.address().port}?pkt_size=1200`], false);
296
+ source.child = child;
297
+ child.on('error', () => { if (source.child === child) this.restartAudioSource(source); });
298
+ child.once('close', () => { if (source.child === child) this.restartAudioSource(source); });
299
+ } catch { if (this.sourceAlive(source)) this.pollAudio(source, 2000); }
206
300
  finally {
207
- session.audioStarting = false;
208
- if (this.alive(session) && !this.microphoneMuted && !session.audioChild && !session.audioPoll)
209
- this.pollAudio(session, 500);
301
+ source.starting = false;
302
+ if (this.sourceAlive(source) && !this.microphoneMuted && !source.child && !source.poll) this.pollAudio(source, 500);
210
303
  }
211
304
  }
212
- pollAudio(session, ms) {
213
- clearTimeout(session.audioPoll);
214
- session.audioPoll = timer(() => { session.audioPoll = undefined; void this.startAudio(session); }, ms);
305
+ pollAudio(source, ms) {
306
+ if (!this.sourceAlive(source)) return;
307
+ clearTimeout(source.poll);
308
+ source.poll = timer(() => { source.poll = undefined; void this.startAudioSource(source); }, ms);
215
309
  }
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; }
310
+ stopAudio(session) { this.detachSource(session, 'audio'); }
311
+ restartAudioSource(source) {
312
+ this.stopSourceMedia(source);
313
+ if (this.sourceAlive(source) && !this.microphoneMuted) this.pollAudio(source, 500);
220
314
  }
221
- restartAudio(session) { this.stopAudio(session); if (this.alive(session) && !this.microphoneMuted) this.pollAudio(session, 500); }
315
+ restartAudio(session) { this.stopAudio(session); if (this.alive(session) && !this.microphoneMuted && session.started) this.startAudio(session); }
222
316
  receiveTalk(session, packet) {
223
317
  if (!this.alive(session) || this.speakerMuted || !session.audio || this.config.enableTalkback === false) return;
224
- this.counters.talkPackets++;
318
+ if (this.talkOwner && this.talkOwner !== session) return;
319
+ this.talkOwner = session; this.counters.talkPackets++;
225
320
  if (!session.talk) { session.talk = { queue: [], size: 0, closed: false }; void this.startTalk(session, session.talk); }
226
321
  const talk = session.talk; clearTimeout(talk.idleTimer); talk.idleTimer = timer(() => this.stopTalk(session), 2500);
227
322
  const copy = packet.clone(); copy.header.extensions = []; copy.header.payloadType = 111;
@@ -264,14 +359,15 @@ export class MatterMedia {
264
359
  }
265
360
  stopTalk(session, expected) {
266
361
  const talk = session.talk; if (!talk || (expected && expected !== talk)) return; session.talk = undefined; talk.closed = true;
362
+ if (this.talkOwner === session) this.talkOwner = undefined;
267
363
  clearTimeout(talk.idleTimer); clearTimeout(talk.readyTimer); talk.queue.length = 0; talk.size = 0;
268
364
  this.kill(session, talk.child); talk.post?.end(); timer(() => talk.post?.destroy(), 300);
269
365
  if (talk.socket) { session.sockets.delete(talk.socket); try { talk.socket.close(); } catch {} }
270
366
  }
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); }
367
+ setMicrophoneMuted(value) { this.microphoneMuted = value === true || this.config.enableAudio === false; for (const session of this.sessions.values()) this.stopAudio(session); if (!this.microphoneMuted) for (const session of this.sessions.values()) if (session.started) this.startAudio(session); }
368
+ setSpeakerMuted(value) { this.speakerMuted = value === true || this.config.enableAudio === false || this.config.enableTalkback === false; for (const session of this.sessions.values()) this.stopTalk(session); }
369
+ setMicrophoneVolume(value) { this.microphoneVolume = clamp(integer(value, 100, 0, 100), 0, 100); for (const session of this.sessions.values()) this.stopAudio(session); if (!this.microphoneMuted) for (const session of this.sessions.values()) if (session.started) this.startAudio(session); }
370
+ setSpeakerVolume(value) { this.speakerVolume = clamp(integer(value, 100, 0, 100), 0, 100); for (const session of this.sessions.values()) this.stopTalk(session); }
275
371
  async snapshot({ width = 320, height = 180 } = {}) {
276
372
  if (this.closed) throw fault('closed');
277
373
  width = integer(width, 320, 2, 1280); height = integer(height, 180, 2, 720);
@@ -280,9 +376,10 @@ export class MatterMedia {
280
376
  if (!Buffer.isBuffer(data) || data.length > 8 * 1024 * 1024) throw fault('snapshot_invalid');
281
377
  return { data, ...jpegSize(data) };
282
378
  }
283
- async closeSession(id) {
284
- const session = this.session; if (!session || session.id !== id || session.closed) return;
285
- session.closed = true; this.session = undefined;
379
+ async closeSession(id, expected) {
380
+ const session = this.sessions.get(String(id)); if (!session || session.closed || (expected && session !== expected)) return;
381
+ session.closed = true; this.sessions.delete(String(id));
382
+ this.detachSource(session, 'video');
286
383
  for (const name of ['connectTimer', 'disconnectTimer', 'lifeTimer', 'videoDeadline']) clearTimeout(session[name]);
287
384
  this.stopAudio(session); this.stopTalk(session);
288
385
  for (const child of [...session.jobs]) this.kill(session, child);
@@ -291,6 +388,6 @@ export class MatterMedia {
291
388
  try { await session.peer.close(); } catch {}
292
389
  this.emit(session, 'closed');
293
390
  }
294
- async close() { this.closed = true; if (this.session) await this.closeSession(this.session.id); }
391
+ async close() { this.closed = true; await Promise.all([...this.sessions.keys()].map(id => this.closeSession(id))); for (const source of [...this.sources.values()]) this.closeSource(source); }
295
392
  }
296
393
  export const createMatterMedia = options => new MatterMedia(options);
@@ -0,0 +1,100 @@
1
+ import { ClusterBehavior } from '@matter/main';
2
+ import { ClusterModel } from '@matter/main/model';
3
+ import { ClusterId, ClusterType, Status, StatusResponseError } from '@matter/main/types';
4
+
5
+ // Test VID matches this development Matter camera. This is a directional step extension,
6
+ // not the standard MechanicalPan/Tilt cluster and never describes angles or zoom.
7
+ export const MIJIA_PTZ_STEP_CLUSTER_ID = ClusterId(0xfff1fc01);
8
+ export const PTZ_STEP_DIRECTIONS = Object.freeze(['up', 'down', 'left', 'right']);
9
+ const fail = (status, message) => { throw new StatusResponseError(message, status); };
10
+ const field = (name, id, type, extra = {}) => ({ tag: 'field', name, id, type, conformance: 'M', ...extra });
11
+ const attribute = (name, id, type, value) => ({ tag: 'attribute', name, id, type,
12
+ access: 'R V', conformance: 'M', default: value });
13
+
14
+ export const MijiaPtzStep = ClusterType(new ClusterModel({
15
+ name: 'MijiaPtzStep', id: MIJIA_PTZ_STEP_CLUSTER_ID, classification: 'application', children: [
16
+ { tag: 'attribute', name: 'ClusterRevision', id: 0xfffd, type: 'ClusterRevision', default: 1 },
17
+ attribute('Supported', 0, 'bool', false),
18
+ attribute('Available', 1, 'bool', false),
19
+ attribute('Mode', 2, 'ModeEnum', 0),
20
+ attribute('Directions', 3, 'DirectionBitmap', 0),
21
+ { tag: 'datatype', name: 'ModeEnum', type: 'enum8', children: [field('Step', 0)] },
22
+ { tag: 'datatype', name: 'DirectionEnum', type: 'enum8', children: PTZ_STEP_DIRECTIONS.map((name, id) =>
23
+ field(name[0].toUpperCase() + name.slice(1), id)) },
24
+ { tag: 'datatype', name: 'DirectionBitmap', type: 'map8', children: PTZ_STEP_DIRECTIONS.map((name, id) =>
25
+ ({ tag: 'field', name: name[0].toUpperCase() + name.slice(1), constraint: String(id), conformance: 'M' })) },
26
+ { tag: 'command', name: 'MoveStep', id: 0, access: 'O', conformance: 'M', direction: 'request', response: 'status',
27
+ children: [field('Direction', 0, 'DirectionEnum', { constraint: '0 to 3' })] },
28
+ ],
29
+ }));
30
+
31
+ export const MijiaPtzStepBehavior = ClusterBehavior.for(MijiaPtzStep);
32
+
33
+ export function normalizeMatterPtzCapabilities(value) {
34
+ const supported = value?.supported === true && value?.relativeStep === true;
35
+ const directions = supported && Array.isArray(value.directions)
36
+ ? PTZ_STEP_DIRECTIONS.filter(direction => value.directions.includes(direction)) : [];
37
+ return { supported: supported && directions.length > 0, available: supported && directions.length > 0 && value.available === true,
38
+ mode: 0, directions: Object.fromEntries(PTZ_STEP_DIRECTIONS.map(direction => [direction, directions.includes(direction)])) };
39
+ }
40
+
41
+ /** Shared across Matter actors so simultaneous fabrics cannot queue camera movements. */
42
+ export class MatterPtzStepControl {
43
+ constructor({ runtime, onChange } = {}) {
44
+ this.runtime = runtime; this.onChange = onChange; this.closed = false; this.busy = false;
45
+ this.revision = 0; this.selectionRevision = 0; this.capabilities = normalizeMatterPtzCapabilities();
46
+ this.publication = Promise.resolve();
47
+ }
48
+ state() { return { ...this.capabilities, directions: { ...this.capabilities.directions },
49
+ available: !this.closed && !this.busy && this.capabilities.available }; }
50
+ publish() {
51
+ // endpoint.set() transactions can acquire their locks out of invocation order. Serialize
52
+ // publications and read the latest state when each runs so a stale busy update cannot
53
+ // leave controllers permanently unavailable after the command has completed.
54
+ this.publication = this.publication.then(() => this.onChange?.(this.state())).catch(() => {});
55
+ return this.publication;
56
+ }
57
+ async refresh({ invalidate = false } = {}) {
58
+ const revision = ++this.revision;
59
+ if (invalidate) { this.selectionRevision++; this.capabilities = normalizeMatterPtzCapabilities(); this.publish(); }
60
+ if (this.closed) return this.state();
61
+ let value;
62
+ try { value = await this.runtime?.getPtzCapabilities?.(); } catch {}
63
+ if (!this.closed && revision === this.revision) {
64
+ this.capabilities = normalizeMatterPtzCapabilities(value); await this.publish();
65
+ }
66
+ return this.state();
67
+ }
68
+ async move(direction) {
69
+ if (!Number.isInteger(direction) || !PTZ_STEP_DIRECTIONS[direction]) fail(Status.ConstraintError, 'Invalid step direction');
70
+ if (this.closed) fail(Status.InvalidInState, 'Direction control is closed');
71
+ if (this.busy) fail(Status.Busy, 'Direction control is busy');
72
+ // Set the guard before any await, including an asynchronous capability lookup.
73
+ this.busy = true; this.publish();
74
+ const selectionRevision = this.selectionRevision;
75
+ try {
76
+ await this.refresh();
77
+ if (this.closed || selectionRevision !== this.selectionRevision) fail(Status.InvalidInState, 'Camera selection changed');
78
+ const state = this.capabilities;
79
+ if (!state.supported || !state.available || !state.directions[PTZ_STEP_DIRECTIONS[direction]]
80
+ || typeof this.runtime?.movePtz !== 'function') fail(Status.InvalidInState, 'Direction step is unavailable');
81
+ let reply;
82
+ try { reply = await this.runtime.movePtz(PTZ_STEP_DIRECTIONS[direction]); }
83
+ catch (error) {
84
+ if (error?.reason === 'ptz_busy' || error?.reason === 'control_busy') fail(Status.Busy, 'Direction control is busy');
85
+ if (['ptz_unsupported', 'ptz_unavailable', 'auth_in_progress', 'query_cancelled'].includes(error?.reason))
86
+ fail(Status.InvalidInState, 'Direction step is unavailable');
87
+ fail(Status.Failure, 'Direction step was not accepted');
88
+ }
89
+ if (this.closed || selectionRevision !== this.selectionRevision) fail(Status.InvalidInState, 'Camera selection changed');
90
+ if (reply?.accepted !== true) fail(Status.Failure, 'Direction step was not accepted');
91
+ } finally {
92
+ this.busy = false;
93
+ if (!this.closed) await this.refresh();
94
+ }
95
+ }
96
+ close() {
97
+ this.closed = true; this.revision++; this.selectionRevision++;
98
+ this.capabilities = normalizeMatterPtzCapabilities(); this.publish();
99
+ }
100
+ }
@@ -43,10 +43,11 @@ export class MatterService {
43
43
  if (!Number.isInteger(port) || port < 1024 || port > 65535 || typeof networkInterface !== 'string'
44
44
  || !/^[A-Za-z0-9_.:-]{1,40}$/.test(networkInterface) || typeof this.storagePath !== 'string')
45
45
  throw failure('matter_configuration_invalid');
46
+ const maxStreams = bounded(this.config.matterMaxStreams, 2, 1, 4);
46
47
  const createMedia = this.mediaFactory ?? (await import('./matter-media.mjs')).createMatterMedia;
47
48
  if (this.closed) throw failure('matter_closed');
48
49
  this.media = await createMedia({ runtime: this.runtime, ffmpegPath: this.runtime.ffmpegPath,
49
- config: this.config, snapshotDelegate: this.delegate });
50
+ config: { ...this.config, maxStreams }, snapshotDelegate: this.delegate });
50
51
  const createDevice = this.deviceFactory ?? (await import('./matter-device.mjs')).createMatterDevice;
51
52
  if (this.closed) throw failure('matter_closed');
52
53
  const selected = this.runtime.selected?.();
@@ -57,7 +58,7 @@ export class MatterService {
57
58
  height: bounded(this.config.maxHeight, 360, 120, 720) & ~1,
58
59
  frameRate: bounded(this.config.maxFPS, 12, 1, 25),
59
60
  bitRate: bounded(this.config.maxBitrate, 700, 100, 2000) * 1000,
60
- maxStreams: 1, enableAudio: this.config.enableAudio !== false,
61
+ maxStreams, enableAudio: this.config.enableAudio !== false,
61
62
  enableTalkback: this.config.enableAudio !== false && this.config.enableTalkback !== false,
62
63
  nightVision: this.config.enableNightVision !== false
63
64
  && ['chuangmi.camera.061a01', 'chuangmi.camera.061a03'].includes(selected?.model) } });
@@ -67,8 +68,17 @@ export class MatterService {
67
68
  this.state = 'ready';
68
69
  this.statusListener = value => {
69
70
  if (value?.ready !== true) void Promise.resolve().then(() => this.device?.closeSessions?.()).catch(() => {});
71
+ void Promise.resolve().then(() => this.device?.refreshPtzCapabilities?.()).catch(() => {});
72
+ };
73
+ this.selectionListener = () => {
74
+ try { void Promise.resolve(this.device?.refreshPtzCapabilities?.({ invalidate: true })).catch(() => {}); } catch {}
75
+ };
76
+ this.ptzListener = () => {
77
+ void Promise.resolve().then(() => this.device?.refreshPtzCapabilities?.()).catch(() => {});
70
78
  };
71
79
  this.runtime.on?.('status', this.statusListener);
80
+ this.runtime.on?.('selection', this.selectionListener);
81
+ this.runtime.on?.('ptz-capabilities', this.ptzListener);
72
82
  return this.status();
73
83
  } catch (error) {
74
84
  await this._release();
@@ -102,7 +112,9 @@ export class MatterService {
102
112
  }
103
113
  async _release() {
104
114
  if (this.statusListener) this.runtime.off?.('status', this.statusListener);
105
- this.statusListener = undefined;
115
+ if (this.selectionListener) this.runtime.off?.('selection', this.selectionListener);
116
+ if (this.ptzListener) this.runtime.off?.('ptz-capabilities', this.ptzListener);
117
+ this.statusListener = this.selectionListener = this.ptzListener = undefined;
106
118
  for (const value of [this.device, this.media]) { try { await value?.close?.(); } catch {} }
107
119
  }
108
120
  close() {
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 { configurePtz } from './ptz.mjs';
5
6
  import { MatterService } from './matter-service.mjs';
6
7
 
7
8
  export const PLUGIN_NAME = '@linanw/homebridge-mijia-remote-camera';
@@ -69,6 +70,7 @@ export class MijiaRemoteCameraPlatform {
69
70
  if (!selected || !numeric(selected.deviceId) || !numeric(selected.homeId) || !numeric(selected.homeOwner)) {
70
71
  this.delegate?.shutdown();
71
72
  this.nightVision?.shutdown?.();
73
+ this.ptz?.shutdown?.();
72
74
  await this.matter?.close?.();
73
75
  this.log.info('请打开插件设置,登录米家并选择家庭和摄像头。');
74
76
  return;
@@ -76,6 +78,7 @@ export class MijiaRemoteCameraPlatform {
76
78
  if (this.deviceId && this.deviceId !== selected.deviceId) {
77
79
  this.delegate?.shutdown();
78
80
  this.nightVision?.shutdown?.();
81
+ this.ptz?.shutdown?.();
79
82
  await this.matter?.close?.();
80
83
  this.log.info('已保存新的摄像头选择,请重启 Homebridge 使配件变更生效。');
81
84
  return;
@@ -95,7 +98,7 @@ export class MijiaRemoteCameraPlatform {
95
98
  ?.setCharacteristic(this.api.hap.Characteristic.Manufacturer, 'Xiaomi')
96
99
  .setCharacteristic(this.api.hap.Characteristic.Model, label(selected.model || '米家远程摄像头'))
97
100
  .setCharacteristic(this.api.hap.Characteristic.SerialNumber, `MiCam-${opaqueId}`)
98
- .setCharacteristic(this.api.hap.Characteristic.FirmwareRevision, '0.2.0');
101
+ .setCharacteristic(this.api.hap.Characteristic.FirmwareRevision, '0.3.0');
99
102
  this.delegate = this.delegateFactory({ api: this.api, log: this.log, runtime: this.runtime,
100
103
  config: this.config, ffmpegPath: this.runtime.ffmpegPath });
101
104
  accessory.configureController(this.delegate.controller);
@@ -103,6 +106,9 @@ export class MijiaRemoteCameraPlatform {
103
106
  if (this.delegate.talkbackEnabled) this.delegate.controller.setSpeakerMuted(false);
104
107
  if (this.config.enableNightVision !== false) this.nightVision = configureNightVision({ api: this.api,
105
108
  accessory, runtime: this.runtime, model: selected.model, name: '夜视', log: this.log });
109
+ if (this.config.enablePtzSwitches === true) this.ptz = await configurePtz({ api: this.api,
110
+ accessory, runtime: this.runtime, selection: selected, log: this.log });
111
+ if (this.stopping) { this.ptz?.shutdown?.(); this.nightVision?.shutdown?.(); this.delegate?.shutdown(); return; }
106
112
  this.api.publishExternalAccessories(PLUGIN_NAME, [accessory]);
107
113
  this.accessory = accessory;
108
114
  this.deviceId = selected.deviceId;
@@ -120,6 +126,7 @@ export class MijiaRemoteCameraPlatform {
120
126
  this.stopping = true;
121
127
  this.delegate?.shutdown();
122
128
  this.nightVision?.shutdown?.();
129
+ this.ptz?.shutdown?.();
123
130
  this.runtime?.off?.('selection', this.selectionListener);
124
131
  await Promise.allSettled([this.matter?.close?.(), this.runtime?.stop?.()]);
125
132
  await this.runtime?.close?.();