@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.
- package/README.md +12 -4
- 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 +66 -18
- package/homebridge-ui/server.js +6 -0
- package/lib/matter-device.mjs +26 -7
- package/lib/matter-diagnostics.mjs +2 -2
- package/lib/matter-media.mjs +149 -72
- 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;
|
|
@@ -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 =
|
|
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:
|
|
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:
|
|
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
|
}
|
package/lib/matter-media.mjs
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
180
|
-
child.stderr?.on('data', () => {}); child.on('error', () => { if (failure) this.
|
|
181
|
-
child.once('close', () => {
|
|
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(
|
|
185
|
-
if (!child) return; child.intentionalStop = true;
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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.
|
|
213
|
-
if (!value) { this.pollAudio(
|
|
214
|
-
const
|
|
215
|
-
if (!this.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
228
|
-
if (this.
|
|
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(
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
238
|
-
this.
|
|
239
|
-
if (
|
|
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.
|
|
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.
|
|
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;
|
|
292
|
-
setSpeakerMuted(value) { this.speakerMuted = value === true || this.config.enableAudio === false || this.config.enableTalkback === false;
|
|
293
|
-
setMicrophoneVolume(value) { this.microphoneVolume = clamp(integer(value, 100, 0, 100), 0, 100);
|
|
294
|
-
setSpeakerVolume(value) { this.speakerVolume = clamp(integer(value, 100, 0, 100), 0, 100);
|
|
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.
|
|
305
|
-
session.closed = true; this.
|
|
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;
|
|
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
|
+
}
|
package/lib/matter-service.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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() {
|