@linanw/homebridge-mijia-remote-camera 0.1.2 → 0.2.1
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 +24 -0
- package/THIRD_PARTY_NOTICES.md +9 -0
- package/config.schema.json +20 -0
- package/homebridge-ui/public/index.html +78 -9
- package/homebridge-ui/server.js +6 -1
- package/lib/diagnostics.mjs +5 -0
- package/lib/matter-device.mjs +569 -0
- package/lib/matter-diagnostics.mjs +33 -0
- package/lib/matter-media.mjs +316 -0
- package/lib/matter-service.mjs +117 -0
- package/lib/platform.mjs +23 -3
- package/lib/rpc.mjs +4 -2
- package/lib/runtime-factory.mjs +5 -1
- package/npm-shrinkwrap.json +533 -17
- package/package.json +9 -4
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import { mkdir, chmod } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { ServerNode, Endpoint, Environment, StorageService, Filesystem, CommissioningServer, VariableService, Logger } from '@matter/main';
|
|
5
|
+
import { NodeJsFilesystem, FileStorageDriver, FlatFileBlobStorageDriver } from '@matter/nodejs';
|
|
6
|
+
import { CameraDevice } from '@matter/main/devices/camera';
|
|
7
|
+
import { CameraAvStreamManagementServer } from '@matter/main/behaviors/camera-av-stream-management';
|
|
8
|
+
import { WebRtcTransportProviderServer } from '@matter/main/behaviors/web-rtc-transport-provider';
|
|
9
|
+
import { WebRtcTransportRequestorClient } from '@matter/main/behaviors/web-rtc-transport-requestor';
|
|
10
|
+
import { CameraAvStreamManagement } from '@matter/main/clusters/camera-av-stream-management';
|
|
11
|
+
import { WebRtcTransportRequestor } from '@matter/main/clusters/web-rtc-transport-requestor';
|
|
12
|
+
import { Status, StatusResponseError, StreamUsage, VendorId, EndpointNumber } from '@matter/main/types';
|
|
13
|
+
import { assertRemoteActor, hasRemoteActor, NodeSession, Invoke, DeviceCommissioner } from '@matter/main/protocol';
|
|
14
|
+
|
|
15
|
+
const LIVE = StreamUsage.LiveView;
|
|
16
|
+
const NIGHT = ['off', 'on', 'auto'];
|
|
17
|
+
const reject = (status, message) => { throw new StatusResponseError(message, status); };
|
|
18
|
+
const bounded = (value, fallback, min, max) => Number.isFinite(Number(value))
|
|
19
|
+
? Math.min(max, Math.max(min, Math.round(Number(value)))) : fallback;
|
|
20
|
+
const text = (value, fallback, max = 32) => Array.from(String(value || fallback)).slice(0, max).join('');
|
|
21
|
+
const samePeer = (a, b) => a.fabricIndex === b.fabricIndex && a.nodeId === b.nodeId;
|
|
22
|
+
const clone = value => structuredClone(value);
|
|
23
|
+
|
|
24
|
+
function requireOwner(item, peer, kind) {
|
|
25
|
+
if (!item || !samePeer(item.owner, peer)) reject(Status.NotFound, `${kind} not found`);
|
|
26
|
+
return item;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Runtime resource accounting shared by all fabrics of one camera. Not persisted encoder state. */
|
|
30
|
+
export class MatterCameraResources {
|
|
31
|
+
constructor(maxStreams = 1) {
|
|
32
|
+
this.maxStreams = maxStreams;
|
|
33
|
+
this.allocated = { video: new Map(), audio: new Map(), snapshot: new Map() };
|
|
34
|
+
this.sessions = new Map();
|
|
35
|
+
this.nextStream = 1;
|
|
36
|
+
this.nextSession = 1;
|
|
37
|
+
}
|
|
38
|
+
reserve(kind, owner, stream) {
|
|
39
|
+
const map = this.allocated[kind];
|
|
40
|
+
if (!map) reject(Status.InvalidCommand, 'Unsupported stream kind');
|
|
41
|
+
if (map.size >= this.maxStreams) reject(Status.ResourceExhausted, 'Encoder capacity is allocated');
|
|
42
|
+
const id = this.nextStream;
|
|
43
|
+
this.nextStream = this.nextStream % 65534 + 1;
|
|
44
|
+
map.set(id, { owner: { ...owner }, stream: { ...clone(stream), [`${kind}StreamId`]: id, referenceCount: 0 } });
|
|
45
|
+
return id;
|
|
46
|
+
}
|
|
47
|
+
own(kind, id, owner) { return requireOwner(this.allocated[kind]?.get(id), owner, 'Stream'); }
|
|
48
|
+
deallocate(kind, id, owner) {
|
|
49
|
+
const entry = this.own(kind, id, owner);
|
|
50
|
+
if (entry.stream.referenceCount) reject(Status.InvalidInState, 'Stream is in use');
|
|
51
|
+
this.allocated[kind].delete(id);
|
|
52
|
+
}
|
|
53
|
+
select(kind, request, owner) {
|
|
54
|
+
const listKey = `${kind}Streams`, legacyKey = `${kind}StreamId`;
|
|
55
|
+
let ids;
|
|
56
|
+
if (request[listKey] !== undefined) ids = request[listKey];
|
|
57
|
+
else if (request[legacyKey] !== undefined) ids = request[legacyKey] === null ? null : [request[legacyKey]];
|
|
58
|
+
else return undefined;
|
|
59
|
+
if (ids === null) {
|
|
60
|
+
const first = [...this.allocated[kind].values()].find(entry => samePeer(entry.owner, owner));
|
|
61
|
+
if (!first) reject(Status.InvalidInState, 'Allocate stream before starting a session');
|
|
62
|
+
return first;
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(ids) || ids.length > 1) reject(Status.ConstraintError, 'One stream is supported');
|
|
65
|
+
return ids.length ? this.own(kind, ids[0], owner) : undefined;
|
|
66
|
+
}
|
|
67
|
+
openSession({ owner, peerEndpointId, video, audio }) {
|
|
68
|
+
if (this.sessions.size >= this.maxStreams) reject(Status.ResourceExhausted, 'Camera is already streaming');
|
|
69
|
+
if (!video && !audio) reject(Status.InvalidCommand, 'An allocated media stream is required');
|
|
70
|
+
for (const [kind, entry] of [['video', video], ['audio', audio]]) {
|
|
71
|
+
if (!entry) continue;
|
|
72
|
+
requireOwner(entry, owner, 'Stream');
|
|
73
|
+
if (this.allocated[kind].get(entry.stream[`${kind}StreamId`]) !== entry) reject(Status.NotFound, 'Stream not found');
|
|
74
|
+
}
|
|
75
|
+
const id = this.nextSession;
|
|
76
|
+
this.nextSession = this.nextSession % 65534 + 1;
|
|
77
|
+
const item = { id, owner: { ...owner }, peerEndpointId, video, audio, state: 'connecting', pendingCandidates: [], ready: false,
|
|
78
|
+
pendingRemoteCandidates: [], mediaCreated: false, mediaId: `${owner.fabricIndex}:${owner.nodeId}:${id}` };
|
|
79
|
+
this.sessions.set(id, item);
|
|
80
|
+
for (const entry of [video, audio]) if (entry) entry.stream.referenceCount++;
|
|
81
|
+
return item;
|
|
82
|
+
}
|
|
83
|
+
session(id, owner) { return requireOwner(this.sessions.get(id), owner, 'Session'); }
|
|
84
|
+
detachSession(item) {
|
|
85
|
+
if (this.sessions.get(item.id) !== item || item.ending) return false;
|
|
86
|
+
item.ending = true;
|
|
87
|
+
clearTimeout(item.timer);
|
|
88
|
+
item.pendingCandidates.length = 0;
|
|
89
|
+
item.pendingRemoteCandidates.length = 0;
|
|
90
|
+
this.sessions.delete(item.id);
|
|
91
|
+
for (const entry of [item.video, item.audio]) if (entry) entry.stream.referenceCount = Math.max(0, entry.stream.referenceCount - 1);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
forgetFabric(fabricIndex) {
|
|
95
|
+
const removed = [...this.sessions.values()].filter(item => item.owner.fabricIndex === fabricIndex);
|
|
96
|
+
for (const item of removed) this.detachSession(item);
|
|
97
|
+
for (const map of Object.values(this.allocated)) {
|
|
98
|
+
for (const [id, entry] of map) if (entry.owner.fabricIndex === fabricIndex) map.delete(id);
|
|
99
|
+
}
|
|
100
|
+
return removed;
|
|
101
|
+
}
|
|
102
|
+
clear() {
|
|
103
|
+
const removed = [...this.sessions.values()];
|
|
104
|
+
for (const item of removed) this.detachSession(item);
|
|
105
|
+
for (const map of Object.values(this.allocated)) map.clear();
|
|
106
|
+
return removed;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function normalizeMatterVideoAllocation(request, { width, height, frameRate, bitRate }) {
|
|
111
|
+
if (request.streamUsage !== LIVE) reject(Status.InvalidCommand, 'Only live view is supported');
|
|
112
|
+
if (request.videoCodec !== 0) reject(Status.InvalidCommand, 'Only H264 is supported');
|
|
113
|
+
for (const [min, max] of [[request.minFrameRate, request.maxFrameRate], [request.minBitRate, request.maxBitRate],
|
|
114
|
+
[request.minResolution?.width, request.maxResolution?.width], [request.minResolution?.height, request.maxResolution?.height]]) {
|
|
115
|
+
if (!Number.isFinite(min) || !Number.isFinite(max) || min < 1 || min > max) reject(Status.ConstraintError, 'Invalid allocation range');
|
|
116
|
+
}
|
|
117
|
+
if (request.minResolution.width > width || request.minResolution.height > height || request.minFrameRate > frameRate
|
|
118
|
+
|| request.minBitRate > bitRate || request.maxBitRate < 64000) reject(Status.ResourceExhausted, 'Requested stream exceeds capacity');
|
|
119
|
+
if (request.watermarkEnabled || request.osdEnabled) reject(Status.InvalidCommand, 'Overlay is unsupported');
|
|
120
|
+
if (!Number.isInteger(request.keyFrameInterval) || request.keyFrameInterval < 1000 || request.keyFrameInterval > 4000)
|
|
121
|
+
reject(Status.ConstraintError, 'Keyframe interval is outside supported range');
|
|
122
|
+
const actual = { ...clone(request), maxFrameRate: Math.min(frameRate, request.maxFrameRate),
|
|
123
|
+
maxResolution: { width: Math.min(width, request.maxResolution.width) & ~1, height: Math.min(height, request.maxResolution.height) & ~1 },
|
|
124
|
+
minBitRate: Math.max(64000, request.minBitRate), maxBitRate: Math.min(bitRate, request.maxBitRate) };
|
|
125
|
+
if (actual.maxResolution.width < 160 || actual.maxResolution.height < 120
|
|
126
|
+
|| actual.maxResolution.width < request.minResolution.width || actual.maxResolution.height < request.minResolution.height)
|
|
127
|
+
reject(Status.ResourceExhausted, 'Requested resolution is unsupported');
|
|
128
|
+
return actual;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function validateMatterTransportRequest(request) {
|
|
132
|
+
if (request.streamUsage !== undefined && request.streamUsage !== LIVE) reject(Status.InvalidCommand, 'Only live view is supported');
|
|
133
|
+
if (request.metadataEnabled) reject(Status.InvalidCommand, 'Metadata is unsupported');
|
|
134
|
+
if (request.iceTransportPolicy && request.iceTransportPolicy !== 'all') reject(Status.InvalidCommand, 'Only LAN ICE transport is supported');
|
|
135
|
+
if (request.iceServers?.length) reject(Status.InvalidCommand, 'External ICE servers are unsupported');
|
|
136
|
+
if (request.webRtcSessionId != null) reject(Status.InvalidInState, 'End the existing session before renegotiating');
|
|
137
|
+
if (!Number.isInteger(request.originatingEndpointId) || request.originatingEndpointId < 0 || request.originatingEndpointId > 65534)
|
|
138
|
+
reject(Status.InvalidCommand, 'Requestor endpoint is required');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function describeMatterSession(item) {
|
|
142
|
+
return { id: item.id, peerNodeId: item.owner.nodeId, peerEndpointId: item.peerEndpointId,
|
|
143
|
+
fabricIndex: item.owner.fabricIndex, streamUsage: LIVE, metadataEnabled: false,
|
|
144
|
+
videoStreamId: item.video?.stream.videoStreamId ?? null, audioStreamId: item.audio?.stream.audioStreamId ?? null,
|
|
145
|
+
...(item.video ? { videoStreams: [item.video.stream.videoStreamId] } : {}),
|
|
146
|
+
...(item.audio ? { audioStreams: [item.audio.stream.audioStreamId] } : {}) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Keep external encoder accounting atomic with Matter's own cluster-state transaction. */
|
|
150
|
+
export function enrollMatterSession({ resources, item, transaction, publish, start, afterRollback,
|
|
151
|
+
schedule = setImmediate }) {
|
|
152
|
+
let launchScheduled = false;
|
|
153
|
+
const rollback = () => { resources.detachSession(item); };
|
|
154
|
+
try {
|
|
155
|
+
transaction.addParticipants({
|
|
156
|
+
toString: () => `camera-session-create#${item.id}`,
|
|
157
|
+
rollback,
|
|
158
|
+
conclusion(outcome) {
|
|
159
|
+
if (outcome === 'committed' && !launchScheduled) {
|
|
160
|
+
launchScheduled = true;
|
|
161
|
+
schedule(() => { if (resources.sessions.get(item.id) === item && !item.ending) start(); });
|
|
162
|
+
} else if (outcome !== 'committed') {
|
|
163
|
+
rollback();
|
|
164
|
+
if (afterRollback) schedule(afterRollback);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
publish();
|
|
169
|
+
} catch (error) {
|
|
170
|
+
rollback();
|
|
171
|
+
// The enclosing transaction also rolls back. Restore its view immediately when synchronous validation failed.
|
|
172
|
+
try { publish(); } catch {}
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A real Matter Camera (0x0142), using the Matter 1.6.1 model in matter.js 0.18.
|
|
179
|
+
* Matter carries authenticated signaling; media is encrypted WebRTC supplied by matter-media.
|
|
180
|
+
* Pairing credentials are returned only by the explicit admin-facing pairing() method.
|
|
181
|
+
*/
|
|
182
|
+
export async function createMatterDevice({ storagePath, name, serialNumber, networkInterface, port = 5540,
|
|
183
|
+
media, runtime, config = {} } = {}) {
|
|
184
|
+
if (!storagePath || !media || typeof media.createSession !== 'function') throw new Error('matter_configuration_invalid');
|
|
185
|
+
const width = bounded(config.width, 640, 160, 1280) & ~1;
|
|
186
|
+
const height = bounded(config.height, 360, 120, 720) & ~1;
|
|
187
|
+
const fps = bounded(config.frameRate, 12, 1, 30);
|
|
188
|
+
const bitRate = bounded(config.bitRate, 800000, 64000, 2000000);
|
|
189
|
+
const maxStreams = bounded(config.maxStreams, 1, 1, 1);
|
|
190
|
+
const audioEnabled = config.enableAudio !== false;
|
|
191
|
+
const speakerEnabled = config.enableTalkback === true;
|
|
192
|
+
const nightEnabled = config.nightVision === true && typeof runtime?.setNightVision === 'function';
|
|
193
|
+
const rootDir = path.join(path.resolve(storagePath), 'matter');
|
|
194
|
+
await mkdir(rootDir, { recursive: true, mode: 0o700 });
|
|
195
|
+
await chmod(rootDir, 0o700);
|
|
196
|
+
const identifier = `camera-${createHash('sha256').update(String(serialNumber || name || 'mijia')).digest('hex').slice(0, 16)}`;
|
|
197
|
+
const env = new Environment(identifier, Environment.default);
|
|
198
|
+
// Matter's debug logs include decrypted commands (SDP and ICE credentials). Keep those out of Homebridge logs.
|
|
199
|
+
Logger.level = 'warn';
|
|
200
|
+
const vars = new VariableService(env);
|
|
201
|
+
vars.addConfigStyle(clone(Environment.default.vars.vars));
|
|
202
|
+
env.set(Filesystem, new NodeJsFilesystem(rootDir));
|
|
203
|
+
// A separate service also avoids namespace collisions with other Matter nodes in this process.
|
|
204
|
+
const storage = new StorageService(env);
|
|
205
|
+
storage.registerDriver(FileStorageDriver);
|
|
206
|
+
storage.defaultDriver = 'file';
|
|
207
|
+
storage.registerBlobDriver(FlatFileBlobStorageDriver);
|
|
208
|
+
storage.defaultBlobDriver = 'file';
|
|
209
|
+
if (networkInterface) env.vars.set('mdns.networkInterface', String(networkInterface));
|
|
210
|
+
env.vars.set('storage.lock', true);
|
|
211
|
+
|
|
212
|
+
const resources = new MatterCameraResources(maxStreams), { allocated, sessions } = resources;
|
|
213
|
+
let server, endpoint, closed = false, cachedCommissioned = false,
|
|
214
|
+
nextControl = 1, lastError, applyingNightMode = false;
|
|
215
|
+
const features = ['Video', 'Audio', 'Snapshot', ...(speakerEnabled ? ['Speaker'] : []), ...(nightEnabled ? ['NightVision'] : [])];
|
|
216
|
+
const dimensions = { width, height };
|
|
217
|
+
const snapshotCapabilities = [{ resolution: dimensions, maxFrameRate: 1, imageCodec: 0,
|
|
218
|
+
requiresEncodedPixels: false }];
|
|
219
|
+
|
|
220
|
+
function actor(behavior) {
|
|
221
|
+
assertRemoteActor(behavior.context);
|
|
222
|
+
NodeSession.assert(behavior.context.session);
|
|
223
|
+
const address = behavior.context.session.peerAddress;
|
|
224
|
+
return { fabricIndex: address.fabricIndex, nodeId: address.nodeId };
|
|
225
|
+
}
|
|
226
|
+
function live(usage) { if (usage !== LIVE) reject(Status.InvalidCommand, 'Only live view is supported'); }
|
|
227
|
+
function own(map, id, owner) {
|
|
228
|
+
return requireOwner(map.get(id), owner, 'Stream');
|
|
229
|
+
}
|
|
230
|
+
function sessionFor(behavior, id) {
|
|
231
|
+
return resources.session(id, actor(behavior));
|
|
232
|
+
}
|
|
233
|
+
function checkRange(min, max) {
|
|
234
|
+
if (!Number.isFinite(min) || !Number.isFinite(max) || min < 1 || min > max) reject(Status.ConstraintError, 'Invalid allocation range');
|
|
235
|
+
}
|
|
236
|
+
function validateResolution(request) {
|
|
237
|
+
checkRange(request.minResolution?.width, request.maxResolution?.width);
|
|
238
|
+
checkRange(request.minResolution?.height, request.maxResolution?.height);
|
|
239
|
+
if (request.minResolution.width > width || request.minResolution.height > height)
|
|
240
|
+
reject(Status.ResourceExhausted, 'Requested resolution exceeds capacity');
|
|
241
|
+
}
|
|
242
|
+
function reserve(kind, owner, stream) {
|
|
243
|
+
return resources.reserve(kind, owner, stream);
|
|
244
|
+
}
|
|
245
|
+
function streamList(kind) { return [...allocated[kind].values()].map(item => clone(item.stream)); }
|
|
246
|
+
function describeSession(item) {
|
|
247
|
+
return describeMatterSession(item);
|
|
248
|
+
}
|
|
249
|
+
async function syncState() {
|
|
250
|
+
if (!endpoint || closed) return;
|
|
251
|
+
await endpoint.set({ cameraAvStreamManagement: { allocatedVideoStreams: streamList('video'),
|
|
252
|
+
allocatedAudioStreams: streamList('audio'), allocatedSnapshotStreams: streamList('snapshot') },
|
|
253
|
+
webRtcTransportProvider: { currentSessions: [...sessions.values()].map(describeSession) } });
|
|
254
|
+
}
|
|
255
|
+
function updateInBehavior(behavior, kind) {
|
|
256
|
+
behavior.state[`allocated${kind[0].toUpperCase()}${kind.slice(1)}Streams`] = streamList(kind);
|
|
257
|
+
}
|
|
258
|
+
function beforeCommit(behavior, operation, message) {
|
|
259
|
+
// $Changing is synchronous. A phase-one participant can await the hardware and reject the Matter write
|
|
260
|
+
// before the attribute commits; an asynchronous $Changed observer would acknowledge failures as successes.
|
|
261
|
+
const controlId = nextControl++;
|
|
262
|
+
behavior.context.transaction.addParticipants({
|
|
263
|
+
role: Symbol('camera-control'), toString: () => `camera-control#${controlId}`,
|
|
264
|
+
async commit1() {
|
|
265
|
+
try { await operation(); }
|
|
266
|
+
catch { reject(Status.Failure, message); }
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
async function invokeRequestor(item, command, fields) {
|
|
271
|
+
if (closed || (command !== 'end' && !sessions.has(item.id))) return;
|
|
272
|
+
const peer = await server.peers.forAddress(item.owner);
|
|
273
|
+
const request = Invoke({ endpoint: EndpointNumber(item.peerEndpointId), cluster: WebRtcTransportRequestor, command, fields });
|
|
274
|
+
for await (const chunk of peer.interaction.invoke(request)) {
|
|
275
|
+
for (const entry of chunk) {
|
|
276
|
+
if (entry.kind === 'cmd-status' && entry.status !== Status.Success) throw new Error('matter_signaling_rejected');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async function end(item, reason = 2, notify = false, synchronize = true) {
|
|
281
|
+
if (!resources.detachSession(item)) return;
|
|
282
|
+
await media.closeSession(item.mediaId).catch(() => {});
|
|
283
|
+
if (synchronize) await syncState().catch(() => {});
|
|
284
|
+
// Stop encoders and release their references before waiting for a controller that may be offline.
|
|
285
|
+
if (notify) await invokeRequestor(item, 'end', { webRtcSessionId: item.id, reason }).catch(() => {});
|
|
286
|
+
}
|
|
287
|
+
function fail(item, code) {
|
|
288
|
+
if (sessions.get(item.id) !== item) return;
|
|
289
|
+
lastError = code;
|
|
290
|
+
void end(item, code === 'matter_session_timeout' ? 1 : 0, true);
|
|
291
|
+
}
|
|
292
|
+
function selectStream(kind, request, owner) {
|
|
293
|
+
return resources.select(kind, request, owner);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
class PrivateCommissioningServer extends CommissioningServer {
|
|
297
|
+
// The library default writes the passcode, manual pairing code and QR to the process log.
|
|
298
|
+
initiateCommissioning() {}
|
|
299
|
+
}
|
|
300
|
+
class MijiaAvServer extends CameraAvStreamManagementServer.with(...features) {
|
|
301
|
+
async initialize() {
|
|
302
|
+
// Allocations hold runtime encoder resources and do not survive a process restart.
|
|
303
|
+
this.state.allocatedVideoStreams = [];
|
|
304
|
+
this.state.allocatedAudioStreams = [];
|
|
305
|
+
this.state.allocatedSnapshotStreams = [];
|
|
306
|
+
if (!speakerEnabled && typeof media.setSpeakerMuted === 'function') await media.setSpeakerMuted(true);
|
|
307
|
+
for (const [field, method] of [['microphoneMuted', 'setMicrophoneMuted'], ['microphoneVolumeLevel', 'setMicrophoneVolume'],
|
|
308
|
+
...(speakerEnabled ? [['speakerMuted', 'setSpeakerMuted'], ['speakerVolumeLevel', 'setSpeakerVolume']] : [])]) {
|
|
309
|
+
if (typeof media[method] !== 'function') continue;
|
|
310
|
+
await media[method](this.state[field]);
|
|
311
|
+
this.reactTo(this.events[`${field}$Changing`], function (value) {
|
|
312
|
+
beforeCommit(this, () => media[method](value), 'Audio control failed');
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (nightEnabled) {
|
|
316
|
+
this.reactTo(this.events.nightVision$Changing, function (value) {
|
|
317
|
+
if (!hasRemoteActor(this.context)) return;
|
|
318
|
+
if (!NIGHT[value]) reject(Status.ConstraintError, 'Invalid night vision mode');
|
|
319
|
+
beforeCommit(this, async () => {
|
|
320
|
+
try {
|
|
321
|
+
applyingNightMode = true;
|
|
322
|
+
const reply = await runtime.setNightVision(NIGHT[value]);
|
|
323
|
+
if (reply?.mode !== NIGHT[value]) throw new Error('not_applied');
|
|
324
|
+
} finally { applyingNightMode = false; }
|
|
325
|
+
}, 'Night vision control unavailable');
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
setStreamPriorities({ streamPriorities }) {
|
|
330
|
+
if (streamPriorities.length !== 1 || streamPriorities[0] !== LIVE) reject(Status.ConstraintError, 'Only live view is supported');
|
|
331
|
+
this.state.streamUsagePriorities = [LIVE];
|
|
332
|
+
}
|
|
333
|
+
videoStreamAllocate(request) {
|
|
334
|
+
const actual = normalizeMatterVideoAllocation(request, { width, height, frameRate: fps, bitRate });
|
|
335
|
+
const id = reserve('video', actor(this), actual);
|
|
336
|
+
updateInBehavior(this, 'video');
|
|
337
|
+
return { videoStreamId: id };
|
|
338
|
+
}
|
|
339
|
+
videoStreamDeallocate({ videoStreamId }) {
|
|
340
|
+
resources.deallocate('video', videoStreamId, actor(this)); updateInBehavior(this, 'video');
|
|
341
|
+
}
|
|
342
|
+
audioStreamAllocate(request) {
|
|
343
|
+
live(request.streamUsage);
|
|
344
|
+
if (!audioEnabled) reject(Status.InvalidInState, 'Microphone is disabled');
|
|
345
|
+
if (request.audioCodec !== 0 || request.channelCount !== 1 || request.sampleRate !== 48000 || request.bitDepth !== 16
|
|
346
|
+
|| request.bitRate < 16000 || request.bitRate > 64000) reject(Status.ConstraintError, 'Unsupported Opus parameters');
|
|
347
|
+
const id = reserve('audio', actor(this), request);
|
|
348
|
+
updateInBehavior(this, 'audio'); return { audioStreamId: id };
|
|
349
|
+
}
|
|
350
|
+
audioStreamDeallocate({ audioStreamId }) {
|
|
351
|
+
resources.deallocate('audio', audioStreamId, actor(this)); updateInBehavior(this, 'audio');
|
|
352
|
+
}
|
|
353
|
+
snapshotStreamAllocate(request) {
|
|
354
|
+
if (request.imageCodec !== 0 || request.maxFrameRate > 1 || request.watermarkEnabled || request.osdEnabled)
|
|
355
|
+
reject(Status.ConstraintError, 'Unsupported snapshot parameters');
|
|
356
|
+
validateResolution(request);
|
|
357
|
+
const id = reserve('snapshot', actor(this), { imageCodec: 0, frameRate: request.maxFrameRate,
|
|
358
|
+
minResolution: request.minResolution, maxResolution: request.maxResolution, quality: request.quality,
|
|
359
|
+
encodedPixels: false, hardwareEncoder: false });
|
|
360
|
+
updateInBehavior(this, 'snapshot'); return { snapshotStreamId: id };
|
|
361
|
+
}
|
|
362
|
+
snapshotStreamDeallocate({ snapshotStreamId }) {
|
|
363
|
+
resources.deallocate('snapshot', snapshotStreamId, actor(this)); updateInBehavior(this, 'snapshot');
|
|
364
|
+
}
|
|
365
|
+
async captureSnapshot({ snapshotStreamId, requestedResolution }) {
|
|
366
|
+
if (snapshotStreamId !== null) own(allocated.snapshot, snapshotStreamId, actor(this));
|
|
367
|
+
else actor(this);
|
|
368
|
+
if (requestedResolution.width < 2 || requestedResolution.height < 2) reject(Status.ConstraintError, 'Snapshot resolution is unsupported');
|
|
369
|
+
try {
|
|
370
|
+
const result = await media.snapshot({ width: Math.min(width, requestedResolution.width),
|
|
371
|
+
height: Math.min(height, requestedResolution.height) });
|
|
372
|
+
if (!result?.data?.length || result.data.length > 1000000 || !result.width || !result.height
|
|
373
|
+
|| result.width > Math.min(width, requestedResolution.width) || result.height > Math.min(height, requestedResolution.height))
|
|
374
|
+
reject(Status.Failure, 'Snapshot unavailable');
|
|
375
|
+
return { data: result.data, imageCodec: CameraAvStreamManagement.ImageCodec.Jpeg,
|
|
376
|
+
resolution: { width: result.width, height: result.height } };
|
|
377
|
+
} catch { reject(Status.Failure, 'Snapshot unavailable'); }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
class MijiaWebRtcServer extends WebRtcTransportProviderServer {
|
|
381
|
+
initialize() { this.state.currentSessions = []; }
|
|
382
|
+
async create(request, isOffer) {
|
|
383
|
+
const owner = actor(this);
|
|
384
|
+
validateMatterTransportRequest(request);
|
|
385
|
+
const video = selectStream('video', request, owner), audio = selectStream('audio', request, owner);
|
|
386
|
+
if (!video && !audio) reject(Status.InvalidCommand, 'An allocated media stream is required');
|
|
387
|
+
if (isOffer && (typeof request.sdp !== 'string' || request.sdp.length > 65535)) reject(Status.ConstraintError, 'Invalid SDP');
|
|
388
|
+
const item = resources.openSession({ owner, peerEndpointId: request.originatingEndpointId, video, audio }), { id } = item;
|
|
389
|
+
lastError = undefined;
|
|
390
|
+
const startMedia = () => {
|
|
391
|
+
item.timer = setTimeout(() => fail(item, 'matter_session_timeout'), 30000);
|
|
392
|
+
item.timer.unref?.();
|
|
393
|
+
void (async () => {
|
|
394
|
+
if (closed || !sessions.has(id)) return;
|
|
395
|
+
await syncState();
|
|
396
|
+
if (closed || sessions.get(id) !== item || item.ending) return;
|
|
397
|
+
const v = video?.stream, a = audio?.stream;
|
|
398
|
+
const result = await media.createSession({ id: item.mediaId, ...(isOffer ? { offer: request.sdp } : {}),
|
|
399
|
+
video: v ? { id: v.videoStreamId, codec: 'h264', width: Math.min(width, v.maxResolution.width),
|
|
400
|
+
height: Math.min(height, v.maxResolution.height), frameRate: Math.min(fps, v.maxFrameRate),
|
|
401
|
+
bitRate: Math.min(bitRate, v.maxBitRate), keyFrameInterval: v.keyFrameInterval } : undefined,
|
|
402
|
+
audio: a ? { id: a.audioStreamId, codec: 'opus', sampleRate: 48000, channels: 1, bitRate: a.bitRate } : undefined,
|
|
403
|
+
iceServers: request.iceServers, iceTransportPolicy: request.iceTransportPolicy || 'all',
|
|
404
|
+
onCandidate(candidate) {
|
|
405
|
+
if (!candidate?.candidate || closed || sessions.get(id) !== item) return;
|
|
406
|
+
const normalized = { candidate: candidate.candidate, sdpMid: candidate.sdpMid ?? null,
|
|
407
|
+
sdpmLineIndex: candidate.sdpMLineIndex ?? candidate.sdpmLineIndex ?? null };
|
|
408
|
+
if (!item.ready) {
|
|
409
|
+
if (item.pendingCandidates.length < 128) item.pendingCandidates.push(normalized);
|
|
410
|
+
} else void invokeRequestor(item, 'iceCandidates', { webRtcSessionId: id, iceCandidates: [normalized] }).catch(() => fail(item, 'matter_signaling_failed'));
|
|
411
|
+
},
|
|
412
|
+
onState(state) {
|
|
413
|
+
if (closed || sessions.get(id) !== item || item.ending) return;
|
|
414
|
+
item.state = state;
|
|
415
|
+
if (state === 'connected') {
|
|
416
|
+
clearTimeout(item.timer);
|
|
417
|
+
item.timer = setTimeout(() => fail(item, 'matter_session_timeout'), 60 * 60 * 1000);
|
|
418
|
+
item.timer.unref?.();
|
|
419
|
+
} else if (state === 'failed' || state === 'closed') void end(item, state === 'failed' ? 0 : 2, true);
|
|
420
|
+
} });
|
|
421
|
+
if (sessions.get(id) !== item || closed) { await media.closeSession(item.mediaId); return; }
|
|
422
|
+
item.mediaCreated = true;
|
|
423
|
+
while (item.pendingRemoteCandidates.length) await media.addCandidates(item.mediaId, item.pendingRemoteCandidates.splice(0, 32));
|
|
424
|
+
const sdp = isOffer ? result.answer : result.offer;
|
|
425
|
+
if (typeof sdp !== 'string' || !sdp.length) throw new Error('matter_signaling_failed');
|
|
426
|
+
await invokeRequestor(item, isOffer ? 'answer' : 'offer', { webRtcSessionId: id, sdp });
|
|
427
|
+
item.ready = true;
|
|
428
|
+
if (item.pendingCandidates.length) await invokeRequestor(item, 'iceCandidates', {
|
|
429
|
+
webRtcSessionId: id, iceCandidates: item.pendingCandidates.splice(0) });
|
|
430
|
+
})().catch(() => fail(item, 'matter_signaling_failed'));
|
|
431
|
+
};
|
|
432
|
+
enrollMatterSession({ resources, item, transaction: this.context.transaction,
|
|
433
|
+
publish: () => { this.state.currentSessions = [...sessions.values()].map(describeSession); },
|
|
434
|
+
start: startMedia, afterRollback: () => {
|
|
435
|
+
void Promise.resolve(media.closeSession(item.mediaId)).catch(() => {}).then(() => syncState()).catch(() => {});
|
|
436
|
+
} });
|
|
437
|
+
const response = { webRtcSessionId: id };
|
|
438
|
+
if (!isOffer) response.deferredOffer = false;
|
|
439
|
+
if ('videoStreamId' in request) response.videoStreamId = video?.stream.videoStreamId ?? null;
|
|
440
|
+
if ('audioStreamId' in request) response.audioStreamId = audio?.stream.audioStreamId ?? null;
|
|
441
|
+
return response;
|
|
442
|
+
}
|
|
443
|
+
solicitOffer(request) { return this.create(request, false); }
|
|
444
|
+
provideOffer(request) { return this.create(request, true); }
|
|
445
|
+
async provideAnswer({ webRtcSessionId, sdp }) {
|
|
446
|
+
const item = sessionFor(this, webRtcSessionId);
|
|
447
|
+
try { await media.provideAnswer(item.mediaId, sdp); }
|
|
448
|
+
catch { reject(Status.Failure, 'Invalid WebRTC answer'); }
|
|
449
|
+
}
|
|
450
|
+
async provideIceCandidates({ webRtcSessionId, iceCandidates }) {
|
|
451
|
+
const item = sessionFor(this, webRtcSessionId);
|
|
452
|
+
if (!iceCandidates.length || iceCandidates.length > 32) reject(Status.ConstraintError, 'Invalid ICE candidates');
|
|
453
|
+
const normalized = iceCandidates.map(candidate => ({ candidate: candidate.candidate,
|
|
454
|
+
sdpMid: candidate.sdpMid ?? undefined, sdpMLineIndex: candidate.sdpmLineIndex ?? undefined }));
|
|
455
|
+
if (!item.mediaCreated) {
|
|
456
|
+
if (item.pendingRemoteCandidates.length + normalized.length > 128) reject(Status.ResourceExhausted, 'Too many pending ICE candidates');
|
|
457
|
+
item.pendingRemoteCandidates.push(...normalized); return;
|
|
458
|
+
}
|
|
459
|
+
try { await media.addCandidates(item.mediaId, normalized); }
|
|
460
|
+
catch { reject(Status.Failure, 'Invalid ICE candidate'); }
|
|
461
|
+
}
|
|
462
|
+
async endSession({ webRtcSessionId, reason }) {
|
|
463
|
+
const item = sessionFor(this, webRtcSessionId);
|
|
464
|
+
await end(item, reason, false, false);
|
|
465
|
+
this.state.currentSessions = [...sessions.values()].map(describeSession);
|
|
466
|
+
setImmediate(() => { void syncState().catch(() => {}); });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
try {
|
|
471
|
+
server = await ServerNode.create(ServerNode.RootEndpoint.with(PrivateCommissioningServer), {
|
|
472
|
+
id: identifier, environment: env,
|
|
473
|
+
network: { port, ble: false, tcp: true,
|
|
474
|
+
...(config.listeningAddressIpv4 ? { listeningAddressIpv4: config.listeningAddressIpv4 } : {}),
|
|
475
|
+
...(config.listeningAddressIpv6 ? { listeningAddressIpv6: config.listeningAddressIpv6 } : {}) },
|
|
476
|
+
basicInformation: { vendorName: 'linanw', vendorId: VendorId(0xfff1), productName: 'Mijia Remote Camera',
|
|
477
|
+
productId: 0x8001, nodeLabel: text(name, 'Mijia Camera'), serialNumber: text(serialNumber, identifier),
|
|
478
|
+
hardwareVersion: 1, softwareVersion: 201, softwareVersionString: '0.2.1' },
|
|
479
|
+
});
|
|
480
|
+
const audioCapabilities = { maxNumberOfChannels: 1, supportedCodecs: [0], supportedSampleRates: [48000], supportedBitDepths: [16] };
|
|
481
|
+
endpoint = new Endpoint(CameraDevice.with(MijiaAvServer, MijiaWebRtcServer, WebRtcTransportRequestorClient), {
|
|
482
|
+
id: 'camera', number: EndpointNumber(1),
|
|
483
|
+
cameraAvStreamManagement: {
|
|
484
|
+
maxContentBufferSize: 1000000, maxNetworkBandwidth: 5000000, maxConcurrentEncoders: 1,
|
|
485
|
+
maxEncodedPixelRate: width * height * fps,
|
|
486
|
+
supportedStreamUsages: [LIVE], streamUsagePriorities: [LIVE],
|
|
487
|
+
videoSensorParams: { sensorWidth: width, sensorHeight: height, maxFps: fps },
|
|
488
|
+
minViewportResolution: { width: 160, height: 120 }, currentFrameRate: fps,
|
|
489
|
+
rateDistortionTradeOffPoints: [{ codec: 0, resolution: dimensions, minBitRate: 64000 }],
|
|
490
|
+
viewport: { x1: 0, y1: 0, x2: width, y2: height },
|
|
491
|
+
microphoneCapabilities: audioCapabilities, microphoneMuted: !audioEnabled,
|
|
492
|
+
microphoneVolumeLevel: 100, microphoneMinLevel: 0, microphoneMaxLevel: 100,
|
|
493
|
+
snapshotCapabilities,
|
|
494
|
+
...(speakerEnabled ? { speakerCapabilities: audioCapabilities, twoWayTalkSupport: 2,
|
|
495
|
+
speakerMuted: false, speakerVolumeLevel: 100, speakerMinLevel: 0, speakerMaxLevel: 100 } : {}),
|
|
496
|
+
...(nightEnabled ? { nightVision: 2, nightVisionUsesInfrared: true } : {}),
|
|
497
|
+
}, webRtcTransportProvider: { currentSessions: [] },
|
|
498
|
+
});
|
|
499
|
+
await server.add(endpoint);
|
|
500
|
+
if (nightEnabled && runtime?.on) runtime.on('night-mode', nightChanged);
|
|
501
|
+
server.events.commissioning.fabricsChanged.on((fabricIndex, action) => {
|
|
502
|
+
if (action !== 'deleted' || closed) return;
|
|
503
|
+
void (async () => {
|
|
504
|
+
await Promise.allSettled([...sessions.values()].filter(item => item.owner.fabricIndex === fabricIndex)
|
|
505
|
+
.map(item => end(item, 2, false)));
|
|
506
|
+
resources.forgetFabric(fabricIndex);
|
|
507
|
+
await syncState();
|
|
508
|
+
})().catch(() => {});
|
|
509
|
+
});
|
|
510
|
+
} catch (error) {
|
|
511
|
+
await server?.close().catch(() => {});
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
function nightChanged(reply) {
|
|
515
|
+
const value = NIGHT.indexOf(reply?.mode);
|
|
516
|
+
if (value < 0 || closed || applyingNightMode || endpoint.state.cameraAvStreamManagement.nightVision === value) return;
|
|
517
|
+
// Backend events synchronize other controllers; avoid writing back to the camera for this local sync.
|
|
518
|
+
return endpoint.act(agent => { agent.get(MijiaAvServer).state.nightVision = value; }).catch(() => {});
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
async start() {
|
|
522
|
+
if (closed) throw new Error('matter_closed');
|
|
523
|
+
await server.start();
|
|
524
|
+
if (nightEnabled && typeof runtime?.getNightVision === 'function') {
|
|
525
|
+
const value = await runtime.getNightVision().catch(() => undefined);
|
|
526
|
+
if (value) await nightChanged(value);
|
|
527
|
+
}
|
|
528
|
+
return this.status();
|
|
529
|
+
},
|
|
530
|
+
status() {
|
|
531
|
+
return { enabled: true, running: !closed && server.lifecycle.isOnline,
|
|
532
|
+
commissioned: closed ? cachedCommissioned : !!server.state.commissioning?.commissioned,
|
|
533
|
+
commissioningWindowOpen: !closed && (server.env.maybeGet(DeviceCommissioner)?.windowStatus ?? 0) !== 0,
|
|
534
|
+
deviceType: 0x0142, endpoint: 1, activeSessions: sessions.size,
|
|
535
|
+
videoStreams: allocated.video.size, audioStreams: allocated.audio.size, snapshotStreams: allocated.snapshot.size,
|
|
536
|
+
state: closed ? 'closed' : sessions.size ? [...sessions.values()][0].state : server.lifecycle.isOnline ? 'ready' : 'stopped',
|
|
537
|
+
...(lastError ? { error: lastError } : {}) };
|
|
538
|
+
},
|
|
539
|
+
pairing() {
|
|
540
|
+
if (closed) throw new Error('matter_closed');
|
|
541
|
+
const { manualPairingCode, qrPairingCode } = server.state.commissioning.pairingCodes;
|
|
542
|
+
return { manualPairingCode, qrPairingCode };
|
|
543
|
+
},
|
|
544
|
+
async openCommissioningWindow() {
|
|
545
|
+
if (closed || !server.lifecycle.isOnline) throw new Error('matter_not_running');
|
|
546
|
+
await server.act(agent => agent.get(PrivateCommissioningServer).enterCommissionableMode());
|
|
547
|
+
return this.status();
|
|
548
|
+
},
|
|
549
|
+
async closeCommissioningWindow() {
|
|
550
|
+
if (closed || !server.lifecycle.isOnline) throw new Error('matter_not_running');
|
|
551
|
+
await server.env.get(DeviceCommissioner).endCommissioning();
|
|
552
|
+
return this.status();
|
|
553
|
+
},
|
|
554
|
+
async closeSessions() {
|
|
555
|
+
if (closed) return;
|
|
556
|
+
await Promise.allSettled([...sessions.values()].map(item => end(item, 5, true)));
|
|
557
|
+
return this.status();
|
|
558
|
+
},
|
|
559
|
+
async close() {
|
|
560
|
+
if (closed) return;
|
|
561
|
+
cachedCommissioned = !!server.state.commissioning?.commissioned;
|
|
562
|
+
await Promise.allSettled([...sessions.values()].map(item => end(item, 2, true)));
|
|
563
|
+
closed = true;
|
|
564
|
+
runtime?.off?.('night-mode', nightChanged);
|
|
565
|
+
await server.close();
|
|
566
|
+
resources.clear();
|
|
567
|
+
},
|
|
568
|
+
};
|
|
569
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const STATES = new Set(['disabled', 'starting', 'ready', 'failed', 'stopped']);
|
|
2
|
+
const REASONS = new Set(['matter_disabled', 'matter_not_ready', 'matter_start_failed', 'matter_port_in_use',
|
|
3
|
+
'matter_configuration_invalid', 'matter_pairing_unavailable', 'matter_window_failed', 'matter_closed']);
|
|
4
|
+
const count = value => Number.isSafeInteger(value) && value >= 0;
|
|
5
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
6
|
+
|
|
7
|
+
export function safeMatterStatus(value = {}) {
|
|
8
|
+
if (!object(value)) return {};
|
|
9
|
+
const result = {};
|
|
10
|
+
for (const key of ['enabled', 'running', 'commissioned', 'commissioningWindowOpen']) {
|
|
11
|
+
if (typeof value[key] === 'boolean') result[key] = value[key];
|
|
12
|
+
}
|
|
13
|
+
if (STATES.has(value.state)) result.state = value.state;
|
|
14
|
+
if (REASONS.has(value.reason)) result.reason = value.reason;
|
|
15
|
+
if (value.deviceType === 322) result.deviceType = 322;
|
|
16
|
+
for (const key of ['activeSessions', 'videoStreams', 'audioStreams', 'snapshotStreams']) {
|
|
17
|
+
if (count(value[key]) && value[key] <= 100) result[key] = value[key];
|
|
18
|
+
}
|
|
19
|
+
const source = value.media;
|
|
20
|
+
if (object(source)) {
|
|
21
|
+
const media = {};
|
|
22
|
+
for (const key of ['sessions', 'failures', 'videoPackets', 'audioPackets', 'talkPackets', 'talkBytes']) {
|
|
23
|
+
if (count(source[key])) media[key] = source[key];
|
|
24
|
+
}
|
|
25
|
+
for (const key of ['active', 'microphoneMuted', 'speakerMuted']) 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
|
+
}
|
|
29
|
+
if (Object.keys(media).length) result.media = media;
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|