@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.
- package/README.md +13 -5
- package/backend/Bridge.java +13 -2
- package/backend/PtzControlServer.java +223 -0
- package/backend/bridge.jar +0 -0
- package/config.schema.json +13 -0
- package/homebridge-ui/public/index.html +70 -18
- package/homebridge-ui/server.js +6 -0
- package/lib/matter-device.mjs +94 -25
- package/lib/matter-diagnostics.mjs +5 -2
- package/lib/matter-media.mjs +175 -78
- package/lib/matter-ptz-cluster.mjs +100 -0
- package/lib/matter-service.mjs +15 -3
- package/lib/platform.mjs +8 -1
- package/lib/ptz-control.mjs +59 -0
- package/lib/ptz.mjs +56 -0
- package/lib/rpc.mjs +2 -1
- package/lib/runtime-factory.mjs +3 -1
- package/lib/runtime.mjs +55 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/lib/matter-device.mjs
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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 ||
|
|
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)
|
|
372
|
-
|
|
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 (
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
item.
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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;
|