@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.
@@ -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;
@@ -75,7 +77,7 @@ export class MatterCameraResources {
75
77
  const id = this.nextSession;
76
78
  this.nextSession = this.nextSession % 65534 + 1;
77
79
  const item = { id, owner: { ...owner }, peerEndpointId, video, audio, state: 'connecting', pendingCandidates: [], ready: false,
78
- mediaId: `${owner.fabricIndex}:${owner.nodeId}:${id}` };
80
+ pendingRemoteCandidates: [], mediaCreated: false, mediaId: `${owner.fabricIndex}:${owner.nodeId}:${id}` };
79
81
  this.sessions.set(id, item);
80
82
  for (const entry of [video, audio]) if (entry) entry.stream.referenceCount++;
81
83
  return item;
@@ -85,6 +87,8 @@ export class MatterCameraResources {
85
87
  if (this.sessions.get(item.id) !== item || item.ending) return false;
86
88
  item.ending = true;
87
89
  clearTimeout(item.timer);
90
+ item.pendingCandidates.length = 0;
91
+ item.pendingRemoteCandidates.length = 0;
88
92
  this.sessions.delete(item.id);
89
93
  for (const entry of [item.video, item.audio]) if (entry) entry.stream.referenceCount = Math.max(0, entry.stream.referenceCount - 1);
90
94
  return true;
@@ -136,6 +140,42 @@ export function validateMatterTransportRequest(request) {
136
140
  reject(Status.InvalidCommand, 'Requestor endpoint is required');
137
141
  }
138
142
 
143
+ export function describeMatterSession(item) {
144
+ return { id: item.id, peerNodeId: item.owner.nodeId, peerEndpointId: item.peerEndpointId,
145
+ fabricIndex: item.owner.fabricIndex, streamUsage: LIVE, metadataEnabled: false,
146
+ videoStreamId: item.video?.stream.videoStreamId ?? null, audioStreamId: item.audio?.stream.audioStreamId ?? null,
147
+ ...(item.video ? { videoStreams: [item.video.stream.videoStreamId] } : {}),
148
+ ...(item.audio ? { audioStreams: [item.audio.stream.audioStreamId] } : {}) };
149
+ }
150
+
151
+ /** Keep external encoder accounting atomic with Matter's own cluster-state transaction. */
152
+ export function enrollMatterSession({ resources, item, transaction, publish, start, afterRollback,
153
+ schedule = setImmediate }) {
154
+ let launchScheduled = false;
155
+ const rollback = () => { resources.detachSession(item); };
156
+ try {
157
+ transaction.addParticipants({
158
+ toString: () => `camera-session-create#${item.id}`,
159
+ rollback,
160
+ conclusion(outcome) {
161
+ if (outcome === 'committed' && !launchScheduled) {
162
+ launchScheduled = true;
163
+ schedule(() => { if (resources.sessions.get(item.id) === item && !item.ending) start(); });
164
+ } else if (outcome !== 'committed') {
165
+ rollback();
166
+ if (afterRollback) schedule(afterRollback);
167
+ }
168
+ },
169
+ });
170
+ publish();
171
+ } catch (error) {
172
+ rollback();
173
+ // The enclosing transaction also rolls back. Restore its view immediately when synchronous validation failed.
174
+ try { publish(); } catch {}
175
+ throw error;
176
+ }
177
+ }
178
+
139
179
  /**
140
180
  * A real Matter Camera (0x0142), using the Matter 1.6.1 model in matter.js 0.18.
141
181
  * Matter carries authenticated signaling; media is encrypted WebRTC supplied by matter-media.
@@ -148,7 +188,7 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
148
188
  const height = bounded(config.height, 360, 120, 720) & ~1;
149
189
  const fps = bounded(config.frameRate, 12, 1, 30);
150
190
  const bitRate = bounded(config.bitRate, 800000, 64000, 2000000);
151
- const maxStreams = bounded(config.maxStreams, 1, 1, 1);
191
+ const maxStreams = streamCapacity(config.maxStreams);
152
192
  const audioEnabled = config.enableAudio !== false;
153
193
  const speakerEnabled = config.enableTalkback === true;
154
194
  const nightEnabled = config.nightVision === true && typeof runtime?.setNightVision === 'function';
@@ -174,6 +214,9 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
174
214
  const resources = new MatterCameraResources(maxStreams), { allocated, sessions } = resources;
175
215
  let server, endpoint, closed = false, cachedCommissioned = false,
176
216
  nextControl = 1, lastError, applyingNightMode = false;
217
+ const ptz = new MatterPtzStepControl({ runtime, onChange: state => {
218
+ if (endpoint && !closed) return endpoint.set({ mijiaPtzStep: state });
219
+ } });
177
220
  const features = ['Video', 'Audio', 'Snapshot', ...(speakerEnabled ? ['Speaker'] : []), ...(nightEnabled ? ['NightVision'] : [])];
178
221
  const dimensions = { width, height };
179
222
  const snapshotCapabilities = [{ resolution: dimensions, maxFrameRate: 1, imageCodec: 0,
@@ -206,11 +249,7 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
206
249
  }
207
250
  function streamList(kind) { return [...allocated[kind].values()].map(item => clone(item.stream)); }
208
251
  function describeSession(item) {
209
- return { id: item.id, peerNodeId: item.owner.nodeId, peerEndpointId: item.peerEndpointId,
210
- fabricIndex: item.owner.fabricIndex, streamUsage: LIVE, metadataEnabled: false,
211
- videoStreamId: item.video?.stream.videoStreamId ?? null, audioStreamId: item.audio?.stream.audioStreamId ?? null,
212
- videoStreams: item.video ? [item.video.stream.videoStreamId] : [],
213
- audioStreams: item.audio ? [item.audio.stream.audioStreamId] : [] };
252
+ return describeMatterSession(item);
214
253
  }
215
254
  async function syncState() {
216
255
  if (!endpoint || closed) return;
@@ -251,6 +290,7 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
251
290
  if (notify) await invokeRequestor(item, 'end', { webRtcSessionId: item.id, reason }).catch(() => {});
252
291
  }
253
292
  function fail(item, code) {
293
+ if (sessions.get(item.id) !== item) return;
254
294
  lastError = code;
255
295
  void end(item, code === 'matter_session_timeout' ? 1 : 0, true);
256
296
  }
@@ -262,6 +302,15 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
262
302
  // The library default writes the passcode, manual pairing code and QR to the process log.
263
303
  initiateCommissioning() {}
264
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
+ }
265
314
  class MijiaAvServer extends CameraAvStreamManagementServer.with(...features) {
266
315
  async initialize() {
267
316
  // Allocations hold runtime encoder resources and do not survive a process restart.
@@ -352,11 +401,13 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
352
401
  if (isOffer && (typeof request.sdp !== 'string' || request.sdp.length > 65535)) reject(Status.ConstraintError, 'Invalid SDP');
353
402
  const item = resources.openSession({ owner, peerEndpointId: request.originatingEndpointId, video, audio }), { id } = item;
354
403
  lastError = undefined;
355
- this.state.currentSessions = [...sessions.values()].map(describeSession);
356
- // Commit the response before sending the camera-initiated Requestor commands.
357
- setImmediate(() => { void (async () => {
404
+ const startMedia = () => {
405
+ item.timer = setTimeout(() => fail(item, 'matter_session_timeout'), 30000);
406
+ item.timer.unref?.();
407
+ void (async () => {
358
408
  if (closed || !sessions.has(id)) return;
359
409
  await syncState();
410
+ if (closed || sessions.get(id) !== item || item.ending) return;
360
411
  const v = video?.stream, a = audio?.stream;
361
412
  const result = await media.createSession({ id: item.mediaId, ...(isOffer ? { offer: request.sdp } : {}),
362
413
  video: v ? { id: v.videoStreamId, codec: 'h264', width: Math.min(width, v.maxResolution.width),
@@ -365,13 +416,15 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
365
416
  audio: a ? { id: a.audioStreamId, codec: 'opus', sampleRate: 48000, channels: 1, bitRate: a.bitRate } : undefined,
366
417
  iceServers: request.iceServers, iceTransportPolicy: request.iceTransportPolicy || 'all',
367
418
  onCandidate(candidate) {
368
- if (!candidate?.candidate || closed || !sessions.has(id)) return;
419
+ if (!candidate?.candidate || closed || sessions.get(id) !== item) return;
369
420
  const normalized = { candidate: candidate.candidate, sdpMid: candidate.sdpMid ?? null,
370
421
  sdpmLineIndex: candidate.sdpMLineIndex ?? candidate.sdpmLineIndex ?? null };
371
- if (!item.ready) item.pendingCandidates.push(normalized);
372
- else void invokeRequestor(item, 'iceCandidates', { webRtcSessionId: id, iceCandidates: [normalized] }).catch(() => fail(item, 'matter_signaling_failed'));
422
+ if (!item.ready) {
423
+ if (item.pendingCandidates.length < 128) item.pendingCandidates.push(normalized);
424
+ } else void invokeRequestor(item, 'iceCandidates', { webRtcSessionId: id, iceCandidates: [normalized] }).catch(() => fail(item, 'matter_signaling_failed'));
373
425
  },
374
426
  onState(state) {
427
+ if (closed || sessions.get(id) !== item || item.ending) return;
375
428
  item.state = state;
376
429
  if (state === 'connected') {
377
430
  clearTimeout(item.timer);
@@ -379,16 +432,22 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
379
432
  item.timer.unref?.();
380
433
  } else if (state === 'failed' || state === 'closed') void end(item, state === 'failed' ? 0 : 2, true);
381
434
  } });
382
- if (!sessions.has(id) || closed) { await media.closeSession(item.mediaId); return; }
435
+ if (sessions.get(id) !== item || closed) { await media.closeSession(item.mediaId); return; }
436
+ item.mediaCreated = true;
437
+ while (item.pendingRemoteCandidates.length) await media.addCandidates(item.mediaId, item.pendingRemoteCandidates.splice(0, 32));
383
438
  const sdp = isOffer ? result.answer : result.offer;
384
439
  if (typeof sdp !== 'string' || !sdp.length) throw new Error('matter_signaling_failed');
385
440
  await invokeRequestor(item, isOffer ? 'answer' : 'offer', { webRtcSessionId: id, sdp });
386
441
  item.ready = true;
387
442
  if (item.pendingCandidates.length) await invokeRequestor(item, 'iceCandidates', {
388
443
  webRtcSessionId: id, iceCandidates: item.pendingCandidates.splice(0) });
389
- })().catch(() => fail(item, 'matter_signaling_failed')); });
390
- item.timer = setTimeout(() => fail(item, 'matter_session_timeout'), 30000);
391
- item.timer.unref?.();
444
+ })().catch(() => fail(item, 'matter_signaling_failed'));
445
+ };
446
+ enrollMatterSession({ resources, item, transaction: this.context.transaction,
447
+ publish: () => { this.state.currentSessions = [...sessions.values()].map(describeSession); },
448
+ start: startMedia, afterRollback: () => {
449
+ void Promise.resolve(media.closeSession(item.mediaId)).catch(() => {}).then(() => syncState()).catch(() => {});
450
+ } });
392
451
  const response = { webRtcSessionId: id };
393
452
  if (!isOffer) response.deferredOffer = false;
394
453
  if ('videoStreamId' in request) response.videoStreamId = video?.stream.videoStreamId ?? null;
@@ -405,8 +464,13 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
405
464
  async provideIceCandidates({ webRtcSessionId, iceCandidates }) {
406
465
  const item = sessionFor(this, webRtcSessionId);
407
466
  if (!iceCandidates.length || iceCandidates.length > 32) reject(Status.ConstraintError, 'Invalid ICE candidates');
408
- try { await media.addCandidates(item.mediaId, iceCandidates.map(candidate => ({ candidate: candidate.candidate,
409
- sdpMid: candidate.sdpMid ?? undefined, sdpMLineIndex: candidate.sdpmLineIndex ?? undefined }))); }
467
+ const normalized = iceCandidates.map(candidate => ({ candidate: candidate.candidate,
468
+ sdpMid: candidate.sdpMid ?? undefined, sdpMLineIndex: candidate.sdpmLineIndex ?? undefined }));
469
+ if (!item.mediaCreated) {
470
+ if (item.pendingRemoteCandidates.length + normalized.length > 128) reject(Status.ResourceExhausted, 'Too many pending ICE candidates');
471
+ item.pendingRemoteCandidates.push(...normalized); return;
472
+ }
473
+ try { await media.addCandidates(item.mediaId, normalized); }
410
474
  catch { reject(Status.Failure, 'Invalid ICE candidate'); }
411
475
  }
412
476
  async endSession({ webRtcSessionId, reason }) {
@@ -425,14 +489,15 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
425
489
  ...(config.listeningAddressIpv6 ? { listeningAddressIpv6: config.listeningAddressIpv6 } : {}) },
426
490
  basicInformation: { vendorName: 'linanw', vendorId: VendorId(0xfff1), productName: 'Mijia Remote Camera',
427
491
  productId: 0x8001, nodeLabel: text(name, 'Mijia Camera'), serialNumber: text(serialNumber, identifier),
428
- hardwareVersion: 1, softwareVersion: 200, softwareVersionString: '0.2.0' },
492
+ hardwareVersion: 1, softwareVersion: 300, softwareVersionString: '0.3.0' },
429
493
  });
430
494
  const audioCapabilities = { maxNumberOfChannels: 1, supportedCodecs: [0], supportedSampleRates: [48000], supportedBitDepths: [16] };
431
- endpoint = new Endpoint(CameraDevice.with(MijiaAvServer, MijiaWebRtcServer, WebRtcTransportRequestorClient), {
495
+ endpoint = new Endpoint(CameraDevice.with(MijiaAvServer, MijiaWebRtcServer, WebRtcTransportRequestorClient, MijiaPtzStepServer), {
432
496
  id: 'camera', number: EndpointNumber(1),
497
+ mijiaPtzStep: ptz.state(),
433
498
  cameraAvStreamManagement: {
434
- maxContentBufferSize: 1000000, maxNetworkBandwidth: 5000000, maxConcurrentEncoders: 1,
435
- maxEncodedPixelRate: width * height * fps,
499
+ maxContentBufferSize: 1000000, maxNetworkBandwidth: 5000000 * maxStreams, maxConcurrentEncoders: maxStreams,
500
+ maxEncodedPixelRate: width * height * fps * maxStreams,
436
501
  supportedStreamUsages: [LIVE], streamUsagePriorities: [LIVE],
437
502
  videoSensorParams: { sensorWidth: width, sensorHeight: height, maxFps: fps },
438
503
  minViewportResolution: { width: 160, height: 120 }, currentFrameRate: fps,
@@ -458,6 +523,7 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
458
523
  })().catch(() => {});
459
524
  });
460
525
  } catch (error) {
526
+ ptz.close();
461
527
  await server?.close().catch(() => {});
462
528
  throw error;
463
529
  }
@@ -471,6 +537,7 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
471
537
  async start() {
472
538
  if (closed) throw new Error('matter_closed');
473
539
  await server.start();
540
+ await ptz.refresh();
474
541
  if (nightEnabled && typeof runtime?.getNightVision === 'function') {
475
542
  const value = await runtime.getNightVision().catch(() => undefined);
476
543
  if (value) await nightChanged(value);
@@ -506,8 +573,10 @@ export async function createMatterDevice({ storagePath, name, serialNumber, netw
506
573
  await Promise.allSettled([...sessions.values()].map(item => end(item, 5, true)));
507
574
  return this.status();
508
575
  },
576
+ refreshPtzCapabilities(options) { return ptz.refresh(options); },
509
577
  async close() {
510
578
  if (closed) return;
579
+ ptz.close();
511
580
  cachedCommissioned = !!server.state.commissioning?.commissioned;
512
581
  await Promise.allSettled([...sessions.values()].map(item => end(item, 2, true)));
513
582
  closed = true;
@@ -19,10 +19,13 @@ 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
+ 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
+ if (allowed.includes(source[key])) media[key] = source[key];
28
+ }
26
29
  if (Object.keys(media).length) result.media = media;
27
30
  }
28
31
  return result;