@linanw/homebridge-mijia-remote-camera 0.2.1 → 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.
@@ -11,12 +11,14 @@ import { CameraAvStreamManagement } from '@matter/main/clusters/camera-av-stream
11
11
  import { WebRtcTransportRequestor } from '@matter/main/clusters/web-rtc-transport-requestor';
12
12
  import { Status, StatusResponseError, StreamUsage, VendorId, EndpointNumber } from '@matter/main/types';
13
13
  import { assertRemoteActor, hasRemoteActor, NodeSession, Invoke, DeviceCommissioner } from '@matter/main/protocol';
14
+ import { MatterPtzStepControl, MijiaPtzStepBehavior } from './matter-ptz-cluster.mjs';
14
15
 
15
16
  const LIVE = StreamUsage.LiveView;
16
17
  const NIGHT = ['off', 'on', 'auto'];
17
18
  const reject = (status, message) => { throw new StatusResponseError(message, status); };
18
19
  const bounded = (value, fallback, min, max) => Number.isFinite(Number(value))
19
20
  ? Math.min(max, Math.max(min, Math.round(Number(value)))) : fallback;
21
+ const streamCapacity = value => Number.isInteger(value) && value >= 1 && value <= 4 ? value : 2;
20
22
  const text = (value, fallback, max = 32) => Array.from(String(value || fallback)).slice(0, max).join('');
21
23
  const samePeer = (a, b) => a.fabricIndex === b.fabricIndex && a.nodeId === b.nodeId;
22
24
  const clone = value => structuredClone(value);
@@ -28,8 +30,8 @@ function requireOwner(item, peer, kind) {
28
30
 
29
31
  /** Runtime resource accounting shared by all fabrics of one camera. Not persisted encoder state. */
30
32
  export class MatterCameraResources {
31
- constructor(maxStreams = 1) {
32
- this.maxStreams = maxStreams;
33
+ constructor(maxStreams = 2) {
34
+ this.maxStreams = streamCapacity(maxStreams);
33
35
  this.allocated = { video: new Map(), audio: new Map(), snapshot: new Map() };
34
36
  this.sessions = new Map();
35
37
  this.nextStream = 1;
@@ -186,7 +188,7 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
186
188
  const height = bounded(config.height, 360, 120, 720) & ~1;
187
189
  const fps = bounded(config.frameRate, 12, 1, 30);
188
190
  const bitRate = bounded(config.bitRate, 800000, 64000, 2000000);
189
- const maxStreams = bounded(config.maxStreams, 1, 1, 1);
191
+ const maxStreams = streamCapacity(config.maxStreams);
190
192
  const audioEnabled = config.enableAudio !== false;
191
193
  const speakerEnabled = config.enableTalkback === true;
192
194
  const nightEnabled = config.nightVision === true && typeof runtime?.setNightVision === 'function';
@@ -212,6 +214,9 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
212
214
  const resources = new MatterCameraResources(maxStreams), { allocated, sessions } = resources;
213
215
  let server, endpoint, closed = false, cachedCommissioned = false,
214
216
  nextControl = 1, lastError, applyingNightMode = false;
217
+ const ptz = new MatterPtzStepControl({ runtime, onChange: state => {
218
+ if (endpoint && !closed) return endpoint.set({ mijiaPtzStep: state });
219
+ } });
215
220
  const features = ['Video', 'Audio', 'Snapshot', ...(speakerEnabled ? ['Speaker'] : []), ...(nightEnabled ? ['NightVision'] : [])];
216
221
  const dimensions = { width, height };
217
222
  const snapshotCapabilities = [{ resolution: dimensions, maxFrameRate: 1, imageCodec: 0,
@@ -297,6 +302,15 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
297
302
  // The library default writes the passcode, manual pairing code and QR to the process log.
298
303
  initiateCommissioning() {}
299
304
  }
305
+ class MijiaPtzStepServer extends MijiaPtzStepBehavior {
306
+ // The external shared guard rejects simultaneous actors instead of queueing movement
307
+ // behind the cluster's state lock. Attribute publications use separate transactions.
308
+ static lockOnInvoke = false;
309
+ async moveStep({ direction }) {
310
+ actor(this);
311
+ await ptz.move(direction);
312
+ }
313
+ }
300
314
  class MijiaAvServer extends CameraAvStreamManagementServer.with(...features) {
301
315
  async initialize() {
302
316
  // Allocations hold runtime encoder resources and do not survive a process restart.
@@ -475,14 +489,15 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
475
489
  ...(config.listeningAddressIpv6 ? { listeningAddressIpv6: config.listeningAddressIpv6 } : {}) },
476
490
  basicInformation: { vendorName: 'linanw', vendorId: VendorId(0xfff1), productName: 'Mijia Remote Camera',
477
491
  productId: 0x8001, nodeLabel: text(name, 'Mijia Camera'), serialNumber: text(serialNumber, identifier),
478
- hardwareVersion: 1, softwareVersion: 201, softwareVersionString: '0.2.1' },
492
+ hardwareVersion: 1, softwareVersion: 300, softwareVersionString: '0.3.0' },
479
493
  });
480
494
  const audioCapabilities = { maxNumberOfChannels: 1, supportedCodecs: [0], supportedSampleRates: [48000], supportedBitDepths: [16] };
481
- endpoint = new Endpoint(CameraDevice.with(MijiaAvServer, MijiaWebRtcServer, WebRtcTransportRequestorClient), {
495
+ endpoint = new Endpoint(CameraDevice.with(MijiaAvServer, MijiaWebRtcServer, WebRtcTransportRequestorClient, MijiaPtzStepServer), {
482
496
  id: 'camera', number: EndpointNumber(1),
497
+ mijiaPtzStep: ptz.state(),
483
498
  cameraAvStreamManagement: {
484
- maxContentBufferSize: 1000000, maxNetworkBandwidth: 5000000, maxConcurrentEncoders: 1,
485
- maxEncodedPixelRate: width * height * fps,
499
+ maxContentBufferSize: 1000000, maxNetworkBandwidth: 5000000 * maxStreams, maxConcurrentEncoders: maxStreams,
500
+ maxEncodedPixelRate: width * height * fps * maxStreams,
486
501
  supportedStreamUsages: [LIVE], streamUsagePriorities: [LIVE],
487
502
  videoSensorParams: { sensorWidth: width, sensorHeight: height, maxFps: fps },
488
503
  minViewportResolution: { width: 160, height: 120 }, currentFrameRate: fps,
@@ -508,6 +523,7 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
508
523
  })().catch(() => {});
509
524
  });
510
525
  } catch (error) {
526
+ ptz.close();
511
527
  await server?.close().catch(() => {});
512
528
  throw error;
513
529
  }
@@ -521,6 +537,7 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
521
537
  async start() {
522
538
  if (closed) throw new Error('matter_closed');
523
539
  await server.start();
540
+ await ptz.refresh();
524
541
  if (nightEnabled && typeof runtime?.getNightVision === 'function') {
525
542
  const value = await runtime.getNightVision().catch(() => undefined);
526
543
  if (value) await nightChanged(value);
@@ -556,8 +573,10 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
556
573
  await Promise.allSettled([...sessions.values()].map(item => end(item, 5, true)));
557
574
  return this.status();
558
575
  },
576
+ refreshPtzCapabilities(options) { return ptz.refresh(options); },
559
577
  async close() {
560
578
  if (closed) return;
579
+ ptz.close();
561
580
  cachedCommissioned = !!server.state.commissioning?.commissioned;
562
581
  await Promise.allSettled([...sessions.values()].map(item => end(item, 2, true)));
563
582
  closed = true;
@@ -19,10 +19,10 @@ export function safeMatterStatus(value = {}) {
19
19
  const source = value.media;
20
20
  if (object(source)) {
21
21
  const media = {};
22
- for (const key of ['sessions', 'failures', 'videoPackets', 'audioPackets', 'talkPackets', 'talkBytes']) {
22
+ for (const key of ['sessions', 'failures', 'videoPackets', 'audioPackets', 'talkPackets', 'talkBytes', 'activeSessions', 'maxSessions', 'activeSources', 'videoSources', 'audioSources', 'sourceJobs', 'sourceSockets']) {
23
23
  if (count(source[key])) media[key] = source[key];
24
24
  }
25
- for (const key of ['active', 'microphoneMuted', 'speakerMuted']) if (typeof source[key] === 'boolean') media[key] = source[key];
25
+ for (const key of ['active', 'microphoneMuted', 'speakerMuted', 'talkbackActive']) if (typeof source[key] === 'boolean') media[key] = source[key];
26
26
  for (const [key, allowed] of Object.entries({"lastFailure": ["negotiation_failed", "media_not_offered", "webrtc_timeout", "webrtc_failed", "webrtc_disconnected", "media_unavailable", "udp_error", "ffmpeg_unavailable", "ffmpeg_exit", "video_timeout"], "lastFailureStage": ["remote_description", "replace_video_track", "replace_audio_track", "create_answer", "create_offer", "local_description", "remote_answer"], "lastFailureType": ["Error", "TypeError", "RangeError", "OperationError", "InvalidStateError", "InvalidModificationError", "InvalidAccessError", "NotSupportedError", "SyntaxError", "AbortError"]})) {
27
27
  if (allowed.includes(source[key])) media[key] = source[key];
28
28
  }
@@ -64,22 +64,35 @@ export class MatterMedia {
64
64
  this.config = config; this.snapshotDelegate = snapshotDelegate; this.spawnProcess = spawnProcess;
65
65
  this.connectTimeoutMs = connectTimeoutMs; this.mediaTimeoutMs = mediaTimeoutMs;
66
66
  this.microphoneMuted = config.enableAudio === false; this.speakerMuted = config.enableAudio === false || config.enableTalkback === false;
67
- 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;
68
70
  this.counters = { sessions: 0, failures: 0, videoPackets: 0, audioPackets: 0, talkPackets: 0, talkBytes: 0, state: 'idle' };
69
71
  }
70
- 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
+ }
71
83
  emit(session, state) {
72
- 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;
73
85
  try { session.onState?.(state); } catch {}
74
86
  }
75
- 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; }
76
88
  fail(session, reason = 'media_unavailable') {
77
89
  if (!this.alive(session)) return;
78
- 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);
79
91
  }
80
92
  async createSession({ id, offer, video, audio, onCandidate, onState, iceServers, iceTransportPolicy } = {}) {
81
93
  if (this.closed) throw fault('closed');
82
- 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');
83
96
  if (iceTransportPolicy !== undefined && iceTransportPolicy !== 'all') throw fault('relay_unavailable');
84
97
  if (iceServers !== undefined && (!Array.isArray(iceServers) || iceServers.length)) throw fault('ice_servers_unsupported');
85
98
  if ((typeof id !== 'string' && typeof id !== 'number') || !String(id).length || String(id).length > 160 || (!video && !audio)) throw fault('invalid_session');
@@ -95,9 +108,9 @@ export class MatterMedia {
95
108
  keyFrameInterval: integer(video.keyFrameInterval, 1000, 200, 4000) } : undefined,
96
109
  audio: audio && this.config.enableAudio !== false ? { channels: 1, bitRate: integer(audio.bitRate, 24000, 16000, 64000) } : undefined };
97
110
  if (!session.video && !session.audio) { await peer.close(); throw fault('audio_disabled'); }
98
- this.session = session; this.counters.sessions++; this.emit(session, 'connecting');
111
+ this.sessions.set(String(id), session); this.counters.sessions++; this.emit(session, 'connecting');
99
112
  session.connectTimer = timer(() => this.fail(session, 'webrtc_timeout'), this.connectTimeoutMs);
100
- session.lifeTimer = timer(() => this.closeSession(id), 30 * 60 * 1000);
113
+ session.lifeTimer = timer(() => this.closeSession(id, session), 30 * 60 * 1000);
101
114
  peer.onIceCandidate.subscribe(candidate => {
102
115
  if (!candidate || !this.alive(session) || session.candidateCount++ >= 128) return;
103
116
  try { onCandidate?.({ candidate: candidate.candidate, sdpMid: candidate.sdpMid ?? undefined, sdpMLineIndex: candidate.sdpMLineIndex ?? undefined }); } catch {}
@@ -137,8 +150,9 @@ export class MatterMedia {
137
150
  return offer !== undefined ? { answer: peer.localDescription.sdp } : { offer: peer.localDescription.sdp };
138
151
  } catch (error) {
139
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'); }
140
154
  this.negotiationFailure(session, error, reason);
141
- await this.closeSession(id); throw fault(reason);
155
+ await this.closeSession(id, session); throw fault(reason);
142
156
  }
143
157
  }
144
158
  negotiationFailure(session, error, reason = 'negotiation_failed') {
@@ -146,13 +160,13 @@ export class MatterMedia {
146
160
  this.counters.lastFailureStage = session.negotiationStage || 'create_offer';
147
161
  this.counters.lastFailureType = exceptionTypes.has(error?.name) ? error.name : 'Error';
148
162
  }
149
- getSession(id) { const session = this.session; if (!session || session.id !== id || session.closed) throw fault('unknown_session'); return session; }
163
+ getSession(id) { const session = this.sessions.get(String(id)); if (!session || session.closed) throw fault('unknown_session'); return session; }
150
164
  async provideAnswer(id, value) {
151
165
  const session = this.getSession(id); sdp(value);
152
166
  if (session.peer.signalingState !== 'have-local-offer') throw fault('invalid_session_state');
153
167
  session.negotiationStage = 'remote_answer';
154
168
  try { await session.peer.setRemoteDescription({ type: 'answer', sdp: value }); }
155
- catch (error) { this.negotiationFailure(session, error); await this.closeSession(id); throw fault('negotiation_failed'); }
169
+ catch (error) { if (!this.alive(session)) throw fault('session_closed'); this.negotiationFailure(session, error); await this.closeSession(id, session); throw fault('negotiation_failed'); }
156
170
  }
157
171
  async addCandidates(id, candidates) {
158
172
  const session = this.getSession(id);
@@ -165,83 +179,144 @@ export class MatterMedia {
165
179
  }
166
180
  session.remoteCandidates = (session.remoteCandidates || 0) + candidates.length;
167
181
  }
168
- async socket(session) {
169
- const socket = createSocket('udp4'); session.sockets.add(socket);
170
- 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'));
171
187
  await new Promise((resolve, reject) => { socket.once('error', reject); socket.bind(0, '127.0.0.1', resolve); });
172
- 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'); }
173
189
  return socket;
174
190
  }
175
- job(session, args, failure = true) {
191
+ job(owner, args, failure = true) {
176
192
  let child;
177
193
  try { child = this.spawnProcess(this.ffmpegPath, ['-hide_banner', '-nostdin', '-loglevel', 'error', ...args], { stdio: ['pipe', 'pipe', 'pipe'] }); }
178
194
  catch { throw fault('ffmpeg_unavailable'); }
179
- session.jobs.add(child); child.stdin?.on('error', () => {}); child.stdout?.on('error', () => {});
180
- child.stderr?.on('data', () => {}); child.on('error', () => { if (failure) this.fail(session, 'ffmpeg_unavailable'); });
181
- 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'); });
182
198
  return child;
183
199
  }
184
- kill(session, child) {
185
- 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);
186
202
  try { child.kill('SIGTERM'); } catch {}
187
203
  timer(() => { if (child.exitCode === null && child.signalCode === null) { try { child.kill('SIGKILL'); } catch {} } }, 1500);
188
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
+ }
189
261
  async startMedia(session) {
190
262
  if (!this.alive(session)) return;
191
263
  if ((session.video && !session.videoSender.codec) || (session.audio && !session.audioSender.codec)) throw fault('codec_unavailable');
192
- if (session.video) {
193
- const source = localUrl(await this.runtime.ensureReady({ timeoutMs: 5000 }), ['/cam.ts']);
194
- if (!this.alive(session)) return;
195
- const socket = await this.socket(session), v = session.video;
196
- session.videoDeadline = timer(() => this.fail(session, 'video_timeout'), this.mediaTimeoutMs);
197
- 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); });
198
- this.job(session, ['-rw_timeout', '8000000', '-analyzeduration', '1000000', '-probesize', '2000000', '-f', 'mpegts', '-i', source,
199
- '-map', '0:v:0', '-an', '-sn', '-dn', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-profile:v', 'baseline', '-level:v', '3.1',
200
- '-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`,
201
- '-r', String(v.frameRate), '-b:v', String(v.bitRate), '-maxrate', String(v.bitRate), '-bufsize', String(v.bitRate * 2),
202
- '-g', String(Math.max(1, Math.round(v.frameRate * v.keyFrameInterval / 1000))), '-bf', '0', '-x264-params', 'repeat-headers=1',
203
- '-payload_type', '96', '-f', 'rtp', `rtp://127.0.0.1:${socket.address().port}?pkt_size=1200`]);
204
- }
205
- if (session.audio) void this.startAudio(session);
264
+ if (session.video) this.attachSource(session, 'video');
265
+ if (session.audio) this.startAudio(session);
266
+ }
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`]);
206
280
  }
207
- async startAudio(session) {
208
- if (!this.alive(session) || this.microphoneMuted || session.audioChild || session.audioStarting || !session.audio) return;
209
- const generation = session.audioGeneration || 0; session.audioStarting = true;
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;
210
285
  try {
211
286
  const value = await this.runtime.audioSource?.();
212
- if (!this.alive(session) || this.microphoneMuted || generation !== (session.audioGeneration || 0)) return;
213
- if (!value) { this.pollAudio(session, 2000); return; }
214
- const source = audioSource(value), socket = await this.socket(session); session.audioSocket = socket;
215
- if (!this.alive(session) || this.microphoneMuted || generation !== (session.audioGeneration || 0)) {
216
- session.sockets.delete(socket); try { socket.close(); } catch {} return;
217
- }
218
- session.audioSender.replaceRTP({ sequenceNumber: 0, timestamp: 0 }, true, 960);
219
- 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); } });
220
- session.audioChild = this.job(session, ['-rw_timeout', '8000000', '-f', source.format, '-i', source.url, '-map', '0:a:0', '-vn', '-sn', '-dn',
221
- '-af', `volume=${this.microphoneVolume / 100}`, '-c:a', 'libopus', '-ar', '48000', '-ac', '1', '-application', 'lowdelay', '-frame_duration', '20',
222
- '-b:a', String(session.audio.bitRate), '-payload_type', '111', '-f', 'rtp', `rtp://127.0.0.1:${socket.address().port}?pkt_size=1200`], false);
223
- const child = session.audioChild;
224
- child.on('error', () => this.restartAudio(session)); child.once('close', () => { if (session.audioChild === child) this.restartAudio(session); });
225
- } 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); }
226
300
  finally {
227
- session.audioStarting = false;
228
- if (this.alive(session) && !this.microphoneMuted && !session.audioChild && !session.audioPoll)
229
- this.pollAudio(session, 500);
301
+ source.starting = false;
302
+ if (this.sourceAlive(source) && !this.microphoneMuted && !source.child && !source.poll) this.pollAudio(source, 500);
230
303
  }
231
304
  }
232
- pollAudio(session, ms) {
233
- clearTimeout(session.audioPoll);
234
- 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);
235
309
  }
236
- stopAudio(session) {
237
- session.audioGeneration = (session.audioGeneration || 0) + 1; clearTimeout(session.audioPoll); session.audioPoll = undefined;
238
- this.kill(session, session.audioChild); session.audioChild = undefined;
239
- 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);
240
314
  }
241
- 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); }
242
316
  receiveTalk(session, packet) {
243
317
  if (!this.alive(session) || this.speakerMuted || !session.audio || this.config.enableTalkback === false) return;
244
- this.counters.talkPackets++;
318
+ if (this.talkOwner && this.talkOwner !== session) return;
319
+ this.talkOwner = session; this.counters.talkPackets++;
245
320
  if (!session.talk) { session.talk = { queue: [], size: 0, closed: false }; void this.startTalk(session, session.talk); }
246
321
  const talk = session.talk; clearTimeout(talk.idleTimer); talk.idleTimer = timer(() => this.stopTalk(session), 2500);
247
322
  const copy = packet.clone(); copy.header.extensions = []; copy.header.payloadType = 111;
@@ -284,14 +359,15 @@ export class MatterMedia {
284
359
  }
285
360
  stopTalk(session, expected) {
286
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;
287
363
  clearTimeout(talk.idleTimer); clearTimeout(talk.readyTimer); talk.queue.length = 0; talk.size = 0;
288
364
  this.kill(session, talk.child); talk.post?.end(); timer(() => talk.post?.destroy(), 300);
289
365
  if (talk.socket) { session.sockets.delete(talk.socket); try { talk.socket.close(); } catch {} }
290
366
  }
291
- 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); } }
292
- setSpeakerMuted(value) { this.speakerMuted = value === true || this.config.enableAudio === false || this.config.enableTalkback === false; if (this.session) this.stopTalk(this.session); }
293
- setMicrophoneVolume(value) { this.microphoneVolume = clamp(integer(value, 100, 0, 100), 0, 100); if (this.session) this.restartAudio(this.session); }
294
- 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); }
295
371
  async snapshot({ width = 320, height = 180 } = {}) {
296
372
  if (this.closed) throw fault('closed');
297
373
  width = integer(width, 320, 2, 1280); height = integer(height, 180, 2, 720);
@@ -300,9 +376,10 @@ export class MatterMedia {
300
376
  if (!Buffer.isBuffer(data) || data.length > 8 * 1024 * 1024) throw fault('snapshot_invalid');
301
377
  return { data, ...jpegSize(data) };
302
378
  }
303
- async closeSession(id) {
304
- const session = this.session; if (!session || session.id !== id || session.closed) return;
305
- 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');
306
383
  for (const name of ['connectTimer', 'disconnectTimer', 'lifeTimer', 'videoDeadline']) clearTimeout(session[name]);
307
384
  this.stopAudio(session); this.stopTalk(session);
308
385
  for (const child of [...session.jobs]) this.kill(session, child);
@@ -311,6 +388,6 @@ export class MatterMedia {
311
388
  try { await session.peer.close(); } catch {}
312
389
  this.emit(session, 'closed');
313
390
  }
314
- 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); }
315
392
  }
316
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() {