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