@camstack/addon-provider-frigate 0.1.2 → 0.1.4
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/dist/addon.d.mts +14 -0
- package/dist/addon.d.ts +14 -0
- package/dist/addon.js +1000 -0
- package/dist/addon.js.map +1 -0
- package/dist/addon.mjs +7 -0
- package/dist/addon.mjs.map +1 -0
- package/dist/chunk-4YBCO3RD.mjs +978 -0
- package/dist/chunk-4YBCO3RD.mjs.map +1 -0
- package/dist/index.d.mts +3 -13
- package/dist/index.d.ts +3 -13
- package/dist/index.mjs +7 -969
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
// src/frigate-api.ts
|
|
2
|
+
var FrigateApiClient = class {
|
|
3
|
+
constructor(config) {
|
|
4
|
+
this.config = config;
|
|
5
|
+
this.baseUrl = (config.baseUrl ?? "").replace(/\/+$/, "");
|
|
6
|
+
}
|
|
7
|
+
baseUrl;
|
|
8
|
+
authToken = null;
|
|
9
|
+
authHeader = null;
|
|
10
|
+
async authenticate() {
|
|
11
|
+
const { username, password } = this.config;
|
|
12
|
+
if (!username || !password) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(`${this.baseUrl}/api/login`, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "Content-Type": "application/json" },
|
|
19
|
+
body: JSON.stringify({ user: username, password })
|
|
20
|
+
});
|
|
21
|
+
if (response.ok) {
|
|
22
|
+
const cookies = response.headers.get("set-cookie");
|
|
23
|
+
if (cookies) {
|
|
24
|
+
this.authToken = cookies;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
}
|
|
30
|
+
this.authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
|
31
|
+
}
|
|
32
|
+
buildHeaders() {
|
|
33
|
+
const headers = {};
|
|
34
|
+
if (this.authToken) {
|
|
35
|
+
headers["Cookie"] = this.authToken;
|
|
36
|
+
} else if (this.authHeader) {
|
|
37
|
+
headers["Authorization"] = this.authHeader;
|
|
38
|
+
}
|
|
39
|
+
return headers;
|
|
40
|
+
}
|
|
41
|
+
async request(path, options) {
|
|
42
|
+
if ((this.config.username || this.config.password) && !this.authToken && !this.authHeader) {
|
|
43
|
+
await this.authenticate();
|
|
44
|
+
}
|
|
45
|
+
const url = `${this.baseUrl}${path}`;
|
|
46
|
+
const response = await fetch(url, {
|
|
47
|
+
...options,
|
|
48
|
+
headers: {
|
|
49
|
+
...this.buildHeaders(),
|
|
50
|
+
...options?.headers
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
throw new Error(`Frigate API error: ${response.status} ${response.statusText} for ${path}`);
|
|
55
|
+
}
|
|
56
|
+
return response.json();
|
|
57
|
+
}
|
|
58
|
+
async requestBuffer(path) {
|
|
59
|
+
if ((this.config.username || this.config.password) && !this.authToken && !this.authHeader) {
|
|
60
|
+
await this.authenticate();
|
|
61
|
+
}
|
|
62
|
+
const url = `${this.baseUrl}${path}`;
|
|
63
|
+
const response = await fetch(url, {
|
|
64
|
+
headers: this.buildHeaders()
|
|
65
|
+
});
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
throw new Error(`Frigate API error: ${response.status} ${response.statusText} for ${path}`);
|
|
68
|
+
}
|
|
69
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
70
|
+
return Buffer.from(arrayBuffer);
|
|
71
|
+
}
|
|
72
|
+
async requestText(path, options) {
|
|
73
|
+
if ((this.config.username || this.config.password) && !this.authToken && !this.authHeader) {
|
|
74
|
+
await this.authenticate();
|
|
75
|
+
}
|
|
76
|
+
const url = `${this.baseUrl}${path}`;
|
|
77
|
+
const response = await fetch(url, {
|
|
78
|
+
...options,
|
|
79
|
+
headers: {
|
|
80
|
+
...this.buildHeaders(),
|
|
81
|
+
...options?.headers
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
throw new Error(`Frigate API error: ${response.status} ${response.statusText} for ${path}`);
|
|
86
|
+
}
|
|
87
|
+
return response.text();
|
|
88
|
+
}
|
|
89
|
+
// --- Config & Discovery ---
|
|
90
|
+
async getConfig() {
|
|
91
|
+
return this.request("/api/config");
|
|
92
|
+
}
|
|
93
|
+
async getGo2rtcStreams() {
|
|
94
|
+
return this.request("/api/go2rtc/streams");
|
|
95
|
+
}
|
|
96
|
+
// --- Events ---
|
|
97
|
+
async getEvents(params) {
|
|
98
|
+
const query = buildQueryString(params);
|
|
99
|
+
return this.request(`/api/events${query}`);
|
|
100
|
+
}
|
|
101
|
+
async getReviewItems(params) {
|
|
102
|
+
const query = buildQueryString(params);
|
|
103
|
+
return this.request(`/api/review${query}`);
|
|
104
|
+
}
|
|
105
|
+
// --- Recordings ---
|
|
106
|
+
async getRecordings(camera, after, before) {
|
|
107
|
+
const query = buildQueryString({ after, before });
|
|
108
|
+
return this.request(`/api/${encodeURIComponent(camera)}/recordings${query}`);
|
|
109
|
+
}
|
|
110
|
+
async getMotionActivity(params) {
|
|
111
|
+
const query = buildQueryString(params);
|
|
112
|
+
return this.request(`/api/motion_activity${query}`);
|
|
113
|
+
}
|
|
114
|
+
// --- Media ---
|
|
115
|
+
async getLatestSnapshot(camera) {
|
|
116
|
+
return this.requestBuffer(`/api/${encodeURIComponent(camera)}/latest.jpg`);
|
|
117
|
+
}
|
|
118
|
+
async getEventThumbnail(eventId) {
|
|
119
|
+
return this.requestBuffer(`/api/events/${encodeURIComponent(eventId)}/thumbnail.jpg`);
|
|
120
|
+
}
|
|
121
|
+
async getEventSnapshot(eventId) {
|
|
122
|
+
return this.requestBuffer(`/api/events/${encodeURIComponent(eventId)}/snapshot.jpg`);
|
|
123
|
+
}
|
|
124
|
+
async getEventClip(eventId) {
|
|
125
|
+
return this.requestBuffer(`/api/events/${encodeURIComponent(eventId)}/clip.mp4`);
|
|
126
|
+
}
|
|
127
|
+
async getRecordingThumbnail(camera, timestampSec) {
|
|
128
|
+
return this.requestBuffer(
|
|
129
|
+
`/api/${encodeURIComponent(camera)}/recordings/thumbnail-${timestampSec}.jpg`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
// --- WebRTC ---
|
|
133
|
+
async proxyWhepSdp(streamName, sdpOffer) {
|
|
134
|
+
return this.requestText(`/api/go2rtc/webrtc?src=${encodeURIComponent(streamName)}`, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: { "Content-Type": "application/sdp" },
|
|
137
|
+
body: sdpOffer
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
// --- PTZ ---
|
|
141
|
+
async getPtzInfo(camera) {
|
|
142
|
+
try {
|
|
143
|
+
return await this.request(
|
|
144
|
+
`/api/${encodeURIComponent(camera)}/ptz/info`
|
|
145
|
+
);
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async ptzCommand(camera, command, params) {
|
|
151
|
+
const query = buildQueryString({ ...params, command });
|
|
152
|
+
await this.request(`/api/${encodeURIComponent(camera)}/ptz${query}`);
|
|
153
|
+
}
|
|
154
|
+
// --- Test Connection ---
|
|
155
|
+
async testConnection() {
|
|
156
|
+
try {
|
|
157
|
+
const config = await this.getConfig();
|
|
158
|
+
const cameraNames = Object.keys(config.cameras ?? {});
|
|
159
|
+
return {
|
|
160
|
+
success: true,
|
|
161
|
+
version: config.version,
|
|
162
|
+
cameraCount: cameraNames.length
|
|
163
|
+
};
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return {
|
|
166
|
+
success: false,
|
|
167
|
+
error: error instanceof Error ? error.message : String(error)
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
function buildQueryString(params) {
|
|
173
|
+
const entries = Object.entries(params).filter(
|
|
174
|
+
([, v]) => v !== void 0 && v !== null
|
|
175
|
+
);
|
|
176
|
+
if (entries.length === 0) {
|
|
177
|
+
return "";
|
|
178
|
+
}
|
|
179
|
+
const searchParams = new URLSearchParams();
|
|
180
|
+
for (const [key, value] of entries) {
|
|
181
|
+
searchParams.set(key, String(value));
|
|
182
|
+
}
|
|
183
|
+
return `?${searchParams.toString()}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// src/frigate-mqtt.ts
|
|
187
|
+
import { connect } from "mqtt";
|
|
188
|
+
var FrigateMqttClient = class {
|
|
189
|
+
constructor(config, cameraResolutions) {
|
|
190
|
+
this.config = config;
|
|
191
|
+
this.cameraResolutions = cameraResolutions;
|
|
192
|
+
}
|
|
193
|
+
client = null;
|
|
194
|
+
listeners = /* @__PURE__ */ new Set();
|
|
195
|
+
eventCount = 0;
|
|
196
|
+
async connect() {
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
const { brokerUrl, username, password, topicPrefix } = this.config;
|
|
199
|
+
this.client = connect(brokerUrl, {
|
|
200
|
+
username,
|
|
201
|
+
password,
|
|
202
|
+
reconnectPeriod: 5e3,
|
|
203
|
+
connectTimeout: 1e4
|
|
204
|
+
});
|
|
205
|
+
this.client.on("connect", () => {
|
|
206
|
+
const prefix = topicPrefix;
|
|
207
|
+
this.client.subscribe([
|
|
208
|
+
`${prefix}/events`,
|
|
209
|
+
`${prefix}/reviews`,
|
|
210
|
+
`${prefix}/+/motion/state`,
|
|
211
|
+
`${prefix}/+/audio/+`
|
|
212
|
+
]);
|
|
213
|
+
resolve();
|
|
214
|
+
});
|
|
215
|
+
this.client.on("error", (err) => {
|
|
216
|
+
reject(err);
|
|
217
|
+
});
|
|
218
|
+
this.client.on("message", (topic, payload) => {
|
|
219
|
+
const event = parseMqttMessage(
|
|
220
|
+
topic,
|
|
221
|
+
payload,
|
|
222
|
+
topicPrefix,
|
|
223
|
+
this.cameraResolutions
|
|
224
|
+
);
|
|
225
|
+
if (event) {
|
|
226
|
+
this.eventCount++;
|
|
227
|
+
for (const listener of this.listeners) {
|
|
228
|
+
listener(event);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
async disconnect() {
|
|
235
|
+
if (this.client) {
|
|
236
|
+
await this.client.endAsync();
|
|
237
|
+
this.client = null;
|
|
238
|
+
}
|
|
239
|
+
this.listeners.clear();
|
|
240
|
+
}
|
|
241
|
+
subscribe(callback) {
|
|
242
|
+
this.listeners.add(callback);
|
|
243
|
+
return () => {
|
|
244
|
+
this.listeners.delete(callback);
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
get status() {
|
|
248
|
+
return {
|
|
249
|
+
connected: this.client?.connected ?? false,
|
|
250
|
+
eventCount: this.eventCount
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
function parseMqttMessage(topic, payload, prefix, resolutions) {
|
|
255
|
+
const topicParts = topic.split("/");
|
|
256
|
+
const prefixParts = prefix.split("/");
|
|
257
|
+
const relative = topicParts.slice(prefixParts.length);
|
|
258
|
+
if (relative.length === 1 && relative[0] === "events") {
|
|
259
|
+
return parseDetectionEvent(payload, resolutions);
|
|
260
|
+
}
|
|
261
|
+
if (relative.length === 1 && relative[0] === "reviews") {
|
|
262
|
+
return parseReviewEvent(payload);
|
|
263
|
+
}
|
|
264
|
+
if (relative.length === 3 && relative[1] === "motion" && relative[2] === "state") {
|
|
265
|
+
const camera = relative[0];
|
|
266
|
+
return parseMotionEvent(camera, payload);
|
|
267
|
+
}
|
|
268
|
+
if (relative.length === 3 && relative[1] === "audio") {
|
|
269
|
+
const camera = relative[0];
|
|
270
|
+
const metric = relative[2];
|
|
271
|
+
return parseAudioEvent(camera, metric, payload);
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
function parseDetectionEvent(payload, resolutions) {
|
|
276
|
+
try {
|
|
277
|
+
const parsed = JSON.parse(payload.toString());
|
|
278
|
+
const state = parsed.after ?? parsed.before;
|
|
279
|
+
if (!state?.camera || !state?.label) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
const data = {
|
|
283
|
+
eventType: parsed.type ?? "update",
|
|
284
|
+
id: state.id,
|
|
285
|
+
label: state.label,
|
|
286
|
+
subLabel: state.sub_label,
|
|
287
|
+
score: state.top_score,
|
|
288
|
+
startTime: state.start_time,
|
|
289
|
+
endTime: state.end_time,
|
|
290
|
+
zones: state.current_zones,
|
|
291
|
+
hasSnapshot: state.has_snapshot,
|
|
292
|
+
hasClip: state.has_clip
|
|
293
|
+
};
|
|
294
|
+
if (state.box) {
|
|
295
|
+
const resolution = resolutions.get(state.camera);
|
|
296
|
+
if (resolution) {
|
|
297
|
+
data.boundingBox = normalizeBoundingBox(state.box, resolution.width, resolution.height);
|
|
298
|
+
} else {
|
|
299
|
+
data.rawBox = state.box;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
type: "detection",
|
|
304
|
+
camera: state.camera,
|
|
305
|
+
timestamp: Date.now(),
|
|
306
|
+
data
|
|
307
|
+
};
|
|
308
|
+
} catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function parseReviewEvent(payload) {
|
|
313
|
+
try {
|
|
314
|
+
const parsed = JSON.parse(payload.toString());
|
|
315
|
+
const state = parsed.after ?? parsed.before;
|
|
316
|
+
if (!state?.camera) {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
type: "review",
|
|
321
|
+
camera: state.camera,
|
|
322
|
+
timestamp: Date.now(),
|
|
323
|
+
data: {
|
|
324
|
+
eventType: parsed.type ?? "update",
|
|
325
|
+
id: state.id,
|
|
326
|
+
severity: state.severity,
|
|
327
|
+
startTime: state.start_time,
|
|
328
|
+
endTime: state.end_time,
|
|
329
|
+
objects: state.data?.objects,
|
|
330
|
+
zones: state.data?.zones,
|
|
331
|
+
subLabels: state.data?.sub_labels
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function parseMotionEvent(camera, payload) {
|
|
339
|
+
const value = payload.toString().trim();
|
|
340
|
+
const active = value === "ON";
|
|
341
|
+
return {
|
|
342
|
+
type: "motion",
|
|
343
|
+
camera,
|
|
344
|
+
timestamp: Date.now(),
|
|
345
|
+
data: { active }
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function parseAudioEvent(camera, metric, payload) {
|
|
349
|
+
const raw = payload.toString().trim();
|
|
350
|
+
const value = parseFloat(raw);
|
|
351
|
+
if (isNaN(value)) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
type: "audio",
|
|
356
|
+
camera,
|
|
357
|
+
timestamp: Date.now(),
|
|
358
|
+
data: { metric, value }
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
function normalizeBoundingBox(box, width, height) {
|
|
362
|
+
const [yMin, xMin, yMax, xMax] = box;
|
|
363
|
+
return {
|
|
364
|
+
x: xMin / width,
|
|
365
|
+
y: yMin / height,
|
|
366
|
+
w: (xMax - xMin) / width,
|
|
367
|
+
h: (yMax - yMin) / height
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// src/frigate-device.ts
|
|
372
|
+
import { DeviceType } from "@camstack/types";
|
|
373
|
+
var FrigateDevice = class {
|
|
374
|
+
constructor(config, api, ctx) {
|
|
375
|
+
this.config = config;
|
|
376
|
+
this.api = api;
|
|
377
|
+
this.id = `${config.providerId}/${config.cameraName}`;
|
|
378
|
+
this.name = config.cameraName;
|
|
379
|
+
this.providerId = config.providerId;
|
|
380
|
+
this.ctx = ctx;
|
|
381
|
+
const caps = ["camera", "events", "recording", "motionSensor", "objectDetector"];
|
|
382
|
+
if (config.audioEnabled) caps.push("audioDetector");
|
|
383
|
+
this.capabilities = caps;
|
|
384
|
+
this.capabilityMap.set("camera", this.createCamera());
|
|
385
|
+
this.capabilityMap.set("motionSensor", this.createMotionSensor());
|
|
386
|
+
this.capabilityMap.set("objectDetector", this.createObjectDetector());
|
|
387
|
+
this.capabilityMap.set("events", this.createEvents());
|
|
388
|
+
this.capabilityMap.set("recording", this.createRecording());
|
|
389
|
+
if (config.audioEnabled) {
|
|
390
|
+
this.capabilityMap.set("audioDetector", this.createAudioDetector());
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
id;
|
|
394
|
+
name;
|
|
395
|
+
providerId;
|
|
396
|
+
type = DeviceType.Camera;
|
|
397
|
+
capabilities;
|
|
398
|
+
capabilityMap = /* @__PURE__ */ new Map();
|
|
399
|
+
ctx;
|
|
400
|
+
getCapability(cap) {
|
|
401
|
+
return this.capabilityMap.get(cap) ?? null;
|
|
402
|
+
}
|
|
403
|
+
hasCapability(cap) {
|
|
404
|
+
return this.capabilityMap.has(cap);
|
|
405
|
+
}
|
|
406
|
+
getState() {
|
|
407
|
+
return { online: true };
|
|
408
|
+
}
|
|
409
|
+
getMetadata() {
|
|
410
|
+
return { manufacturer: "Frigate NVR" };
|
|
411
|
+
}
|
|
412
|
+
// --- Capability Factories ---
|
|
413
|
+
createCamera() {
|
|
414
|
+
const { api, config } = this;
|
|
415
|
+
return {
|
|
416
|
+
kind: "camera",
|
|
417
|
+
async getSnapshot() {
|
|
418
|
+
return api.getLatestSnapshot(config.cameraName);
|
|
419
|
+
},
|
|
420
|
+
async getStreamOptions() {
|
|
421
|
+
return config.streams.map((s) => ({
|
|
422
|
+
...s,
|
|
423
|
+
url: s.url ?? `rtsp://${config.frigateHost}:8554/${s.id}`
|
|
424
|
+
}));
|
|
425
|
+
},
|
|
426
|
+
async getStreamUrl(option) {
|
|
427
|
+
return `/api/go2rtc/webrtc?src=${encodeURIComponent(option.id)}`;
|
|
428
|
+
},
|
|
429
|
+
getConnectionMode() {
|
|
430
|
+
return "on-demand";
|
|
431
|
+
},
|
|
432
|
+
async setConnectionMode() {
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
createMotionSensor() {
|
|
437
|
+
let motionActive = false;
|
|
438
|
+
return {
|
|
439
|
+
kind: "motionSensor",
|
|
440
|
+
isMotionDetected() {
|
|
441
|
+
return motionActive;
|
|
442
|
+
},
|
|
443
|
+
subscribe() {
|
|
444
|
+
return () => {
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
// real-time via MQTT through provider
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
createObjectDetector() {
|
|
451
|
+
const zones = [];
|
|
452
|
+
const lines = [];
|
|
453
|
+
return {
|
|
454
|
+
kind: "objectDetector",
|
|
455
|
+
getLabels() {
|
|
456
|
+
return [];
|
|
457
|
+
},
|
|
458
|
+
onDetections() {
|
|
459
|
+
return () => {
|
|
460
|
+
};
|
|
461
|
+
},
|
|
462
|
+
// Zone management
|
|
463
|
+
async getZones() {
|
|
464
|
+
return zones;
|
|
465
|
+
},
|
|
466
|
+
async addZone(zone) {
|
|
467
|
+
zones.push(zone);
|
|
468
|
+
},
|
|
469
|
+
async removeZone(id) {
|
|
470
|
+
const i = zones.findIndex((z) => z.id === id);
|
|
471
|
+
if (i >= 0) zones.splice(i, 1);
|
|
472
|
+
},
|
|
473
|
+
async updateZone(id, partial) {
|
|
474
|
+
const z = zones.find((z2) => z2.id === id);
|
|
475
|
+
if (z) Object.assign(z, partial);
|
|
476
|
+
},
|
|
477
|
+
// Line management
|
|
478
|
+
async getLines() {
|
|
479
|
+
return lines;
|
|
480
|
+
},
|
|
481
|
+
async addLine(line) {
|
|
482
|
+
lines.push(line);
|
|
483
|
+
},
|
|
484
|
+
async removeLine(id) {
|
|
485
|
+
const i = lines.findIndex((l) => l.id === id);
|
|
486
|
+
if (i >= 0) lines.splice(i, 1);
|
|
487
|
+
},
|
|
488
|
+
// Active tracking (populated by MQTT events in real-time)
|
|
489
|
+
getActiveDetections() {
|
|
490
|
+
return [];
|
|
491
|
+
},
|
|
492
|
+
getZoneDetections() {
|
|
493
|
+
return [];
|
|
494
|
+
},
|
|
495
|
+
getStationaryDetections() {
|
|
496
|
+
return [];
|
|
497
|
+
},
|
|
498
|
+
getMovingDetections() {
|
|
499
|
+
return [];
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
createEvents() {
|
|
504
|
+
const { api, config } = this;
|
|
505
|
+
return {
|
|
506
|
+
kind: "events",
|
|
507
|
+
async getEvents(query) {
|
|
508
|
+
const raw = await api.getEvents({
|
|
509
|
+
cameras: config.cameraName,
|
|
510
|
+
after: query.since,
|
|
511
|
+
before: query.until,
|
|
512
|
+
limit: query.limit,
|
|
513
|
+
labels: query.detectionClasses?.join(",")
|
|
514
|
+
});
|
|
515
|
+
const events = raw.map((e) => ({
|
|
516
|
+
id: e.id,
|
|
517
|
+
type: "detection",
|
|
518
|
+
timestamp: e.start_time * 1e3,
|
|
519
|
+
// Frigate uses seconds, we use ms
|
|
520
|
+
endTimestamp: e.end_time ? e.end_time * 1e3 : void 0,
|
|
521
|
+
label: e.label,
|
|
522
|
+
score: e.top_score,
|
|
523
|
+
hasClip: e.has_clip,
|
|
524
|
+
hasSnapshot: e.has_snapshot,
|
|
525
|
+
thumbnailUrl: `/api/events/${e.id}/thumbnail.jpg`
|
|
526
|
+
}));
|
|
527
|
+
return { events, total: events.length };
|
|
528
|
+
},
|
|
529
|
+
async getEventThumbnail(eventId) {
|
|
530
|
+
try {
|
|
531
|
+
return await api.getEventThumbnail(eventId);
|
|
532
|
+
} catch {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
async getEventSnapshot(eventId) {
|
|
537
|
+
try {
|
|
538
|
+
return await api.getEventSnapshot(eventId);
|
|
539
|
+
} catch {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
async getEventClipUrl(eventId) {
|
|
544
|
+
return `/api/events/${encodeURIComponent(eventId)}/clip.mp4`;
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
createRecording() {
|
|
549
|
+
const { api, config } = this;
|
|
550
|
+
return {
|
|
551
|
+
kind: "recording",
|
|
552
|
+
async getSegments(range) {
|
|
553
|
+
const recs = await api.getRecordings(config.cameraName, range.since, range.until);
|
|
554
|
+
return recs.map((r) => ({
|
|
555
|
+
id: r.id ?? `${config.cameraName}-${r.start_time}`,
|
|
556
|
+
startTime: r.start_time * 1e3,
|
|
557
|
+
endTime: r.end_time * 1e3,
|
|
558
|
+
duration: r.duration
|
|
559
|
+
}));
|
|
560
|
+
},
|
|
561
|
+
async getPlaybackUrl(startTime, endTime) {
|
|
562
|
+
const startSec = Math.floor(startTime / 1e3);
|
|
563
|
+
const endSec = Math.floor(endTime / 1e3);
|
|
564
|
+
return `/api/${encodeURIComponent(config.cameraName)}/start/${startSec}/end/${endSec}/clip.mp4`;
|
|
565
|
+
},
|
|
566
|
+
async getThumbnailAt(timestampMs) {
|
|
567
|
+
try {
|
|
568
|
+
return await api.getRecordingThumbnail(config.cameraName, Math.floor(timestampMs / 1e3));
|
|
569
|
+
} catch {
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
createAudioDetector() {
|
|
576
|
+
return {
|
|
577
|
+
kind: "audioDetector",
|
|
578
|
+
getAudioLevel() {
|
|
579
|
+
return 0;
|
|
580
|
+
},
|
|
581
|
+
subscribe() {
|
|
582
|
+
return () => {
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
// real-time via MQTT through provider
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
// src/element-config-store.ts
|
|
591
|
+
var ElementConfigStore = class {
|
|
592
|
+
constructor(elementId, storage) {
|
|
593
|
+
this.elementId = elementId;
|
|
594
|
+
this.storage = storage;
|
|
595
|
+
}
|
|
596
|
+
cache = {};
|
|
597
|
+
listeners = /* @__PURE__ */ new Set();
|
|
598
|
+
loaded = false;
|
|
599
|
+
/** Load config from storage into cache. Called once on first access. */
|
|
600
|
+
async ensureLoaded() {
|
|
601
|
+
if (this.loaded) return;
|
|
602
|
+
if (!this.storage.structured) {
|
|
603
|
+
this.loaded = true;
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
const records = await this.storage.structured.query("config", {
|
|
608
|
+
where: { id: this.elementId },
|
|
609
|
+
limit: 1
|
|
610
|
+
});
|
|
611
|
+
if (records.length > 0) {
|
|
612
|
+
this.cache = records[0].data ?? {};
|
|
613
|
+
}
|
|
614
|
+
} catch {
|
|
615
|
+
}
|
|
616
|
+
this.loaded = true;
|
|
617
|
+
}
|
|
618
|
+
getAll() {
|
|
619
|
+
return { ...this.cache };
|
|
620
|
+
}
|
|
621
|
+
get(key) {
|
|
622
|
+
const parts = key.split(".");
|
|
623
|
+
let current = this.cache;
|
|
624
|
+
for (const part of parts) {
|
|
625
|
+
if (current == null || typeof current !== "object") return void 0;
|
|
626
|
+
current = current[part];
|
|
627
|
+
}
|
|
628
|
+
return current;
|
|
629
|
+
}
|
|
630
|
+
async set(key, value) {
|
|
631
|
+
await this.ensureLoaded();
|
|
632
|
+
setNestedValue(this.cache, key, value);
|
|
633
|
+
await this.persist();
|
|
634
|
+
this.notifyListeners();
|
|
635
|
+
}
|
|
636
|
+
async setAll(config) {
|
|
637
|
+
await this.ensureLoaded();
|
|
638
|
+
this.cache = { ...config };
|
|
639
|
+
await this.persist();
|
|
640
|
+
this.notifyListeners();
|
|
641
|
+
}
|
|
642
|
+
onChange(callback) {
|
|
643
|
+
this.listeners.add(callback);
|
|
644
|
+
return () => {
|
|
645
|
+
this.listeners.delete(callback);
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
/** Initialize from storage — called by ContextFactory after creation */
|
|
649
|
+
async load() {
|
|
650
|
+
await this.ensureLoaded();
|
|
651
|
+
}
|
|
652
|
+
/** Initialize with default values (doesn't overwrite existing) */
|
|
653
|
+
async loadDefaults(defaults) {
|
|
654
|
+
await this.ensureLoaded();
|
|
655
|
+
if (Object.keys(this.cache).length === 0) {
|
|
656
|
+
this.cache = { ...defaults };
|
|
657
|
+
await this.persist();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
async persist() {
|
|
661
|
+
if (!this.storage.structured) return;
|
|
662
|
+
try {
|
|
663
|
+
const existing = await this.storage.structured.query("config", {
|
|
664
|
+
where: { id: this.elementId },
|
|
665
|
+
limit: 1
|
|
666
|
+
});
|
|
667
|
+
if (existing.length > 0) {
|
|
668
|
+
await this.storage.structured.update("config", this.elementId, this.cache);
|
|
669
|
+
} else {
|
|
670
|
+
await this.storage.structured.insert({
|
|
671
|
+
collection: "config",
|
|
672
|
+
id: this.elementId,
|
|
673
|
+
data: this.cache
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
} catch {
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
notifyListeners() {
|
|
680
|
+
const snapshot = this.getAll();
|
|
681
|
+
for (const listener of this.listeners) {
|
|
682
|
+
try {
|
|
683
|
+
listener(snapshot);
|
|
684
|
+
} catch {
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
function setNestedValue(obj, path, value) {
|
|
690
|
+
const parts = path.split(".");
|
|
691
|
+
let current = obj;
|
|
692
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
693
|
+
const part = parts[i];
|
|
694
|
+
if (!(part in current) || typeof current[part] !== "object" || current[part] === null) {
|
|
695
|
+
current[part] = {};
|
|
696
|
+
}
|
|
697
|
+
current = current[part];
|
|
698
|
+
}
|
|
699
|
+
current[parts[parts.length - 1]] = value;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// src/frigate-provider.ts
|
|
703
|
+
var ADOPTED_DEVICES_KEY = "adoptedDevices";
|
|
704
|
+
var FrigateProvider = class {
|
|
705
|
+
constructor(config, ctx) {
|
|
706
|
+
this.config = config;
|
|
707
|
+
this.id = config.id;
|
|
708
|
+
this.name = config.name;
|
|
709
|
+
this.ctx = ctx;
|
|
710
|
+
this.api = new FrigateApiClient({
|
|
711
|
+
baseUrl: config.url,
|
|
712
|
+
username: config.username,
|
|
713
|
+
password: config.password
|
|
714
|
+
});
|
|
715
|
+
this.frigateHost = new URL(config.url).hostname;
|
|
716
|
+
}
|
|
717
|
+
id;
|
|
718
|
+
type = "frigate";
|
|
719
|
+
name;
|
|
720
|
+
discoveryMode = "auto";
|
|
721
|
+
ctx;
|
|
722
|
+
api;
|
|
723
|
+
mqtt = null;
|
|
724
|
+
devices = [];
|
|
725
|
+
liveEventListeners = /* @__PURE__ */ new Set();
|
|
726
|
+
mqttUnsubscribe;
|
|
727
|
+
/** Cached Frigate config, refreshed on start() and discoverDevices() */
|
|
728
|
+
frigateConfig = null;
|
|
729
|
+
go2rtcStreams = {};
|
|
730
|
+
frigateHost;
|
|
731
|
+
async start() {
|
|
732
|
+
this.ctx.logger.info(`Starting Frigate provider: ${this.config.url}`);
|
|
733
|
+
this.frigateConfig = await this.api.getConfig();
|
|
734
|
+
this.go2rtcStreams = await this.api.getGo2rtcStreams();
|
|
735
|
+
const resolutions = /* @__PURE__ */ new Map();
|
|
736
|
+
for (const [name, cam] of Object.entries(this.frigateConfig.cameras)) {
|
|
737
|
+
if (cam.detect) {
|
|
738
|
+
resolutions.set(name, { width: cam.detect.width, height: cam.detect.height });
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
const adoptedIds = this.getAdoptedDeviceIds();
|
|
742
|
+
if (adoptedIds.length > 0) {
|
|
743
|
+
const devices = [];
|
|
744
|
+
for (const cameraName of adoptedIds) {
|
|
745
|
+
const cam = this.frigateConfig.cameras[cameraName];
|
|
746
|
+
if (!cam || cam.enabled === false) {
|
|
747
|
+
this.ctx.logger.warn(`Adopted camera "${cameraName}" no longer available in Frigate config, skipping`);
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
devices.push(this.createDeviceFromCamera(cameraName, cam));
|
|
751
|
+
}
|
|
752
|
+
this.devices = devices;
|
|
753
|
+
this.ctx.logger.info(`Loaded ${devices.length} adopted cameras`);
|
|
754
|
+
} else {
|
|
755
|
+
this.ctx.logger.info("No adopted cameras yet \u2014 use discoverDevices() + adoptDevice() to import cameras");
|
|
756
|
+
}
|
|
757
|
+
if (this.config.mqtt?.brokerUrl) {
|
|
758
|
+
this.mqtt = new FrigateMqttClient(
|
|
759
|
+
{
|
|
760
|
+
brokerUrl: this.config.mqtt.brokerUrl,
|
|
761
|
+
username: this.config.mqtt.username,
|
|
762
|
+
password: this.config.mqtt.password,
|
|
763
|
+
topicPrefix: this.config.mqtt.topicPrefix ?? "frigate"
|
|
764
|
+
},
|
|
765
|
+
resolutions
|
|
766
|
+
);
|
|
767
|
+
await this.mqtt.connect();
|
|
768
|
+
this.mqttUnsubscribe = this.mqtt.subscribe((event) => {
|
|
769
|
+
for (const listener of this.liveEventListeners) {
|
|
770
|
+
listener(event);
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
this.ctx.logger.info("MQTT connected");
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
async stop() {
|
|
777
|
+
this.ctx.logger.info("Stopping Frigate provider");
|
|
778
|
+
this.mqttUnsubscribe?.();
|
|
779
|
+
this.mqttUnsubscribe = void 0;
|
|
780
|
+
await this.mqtt?.disconnect();
|
|
781
|
+
this.mqtt = null;
|
|
782
|
+
this.devices = [];
|
|
783
|
+
this.frigateConfig = null;
|
|
784
|
+
this.go2rtcStreams = {};
|
|
785
|
+
}
|
|
786
|
+
getStatus() {
|
|
787
|
+
return {
|
|
788
|
+
connected: this.mqtt?.status.connected ?? false,
|
|
789
|
+
deviceCount: this.devices.length
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
async discoverDevices() {
|
|
793
|
+
this.frigateConfig = await this.api.getConfig();
|
|
794
|
+
this.go2rtcStreams = await this.api.getGo2rtcStreams();
|
|
795
|
+
const adoptedIds = this.getAdoptedDeviceIds();
|
|
796
|
+
const discovered = [];
|
|
797
|
+
for (const [name, cam] of Object.entries(this.frigateConfig.cameras)) {
|
|
798
|
+
if (cam.enabled === false) continue;
|
|
799
|
+
const caps = ["camera", "events", "recording", "motionSensor", "objectDetector"];
|
|
800
|
+
if (cam.audio?.enabled) caps.push("audioDetector");
|
|
801
|
+
discovered.push({
|
|
802
|
+
externalId: name,
|
|
803
|
+
name,
|
|
804
|
+
type: "camera",
|
|
805
|
+
capabilities: caps,
|
|
806
|
+
metadata: { manufacturer: "Frigate NVR" }
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
return discovered;
|
|
810
|
+
}
|
|
811
|
+
async adoptDevice(externalId, _config) {
|
|
812
|
+
if (!this.frigateConfig) {
|
|
813
|
+
this.frigateConfig = await this.api.getConfig();
|
|
814
|
+
this.go2rtcStreams = await this.api.getGo2rtcStreams();
|
|
815
|
+
}
|
|
816
|
+
const cam = this.frigateConfig.cameras[externalId];
|
|
817
|
+
if (!cam) {
|
|
818
|
+
throw new Error(`Camera "${externalId}" not found in Frigate config`);
|
|
819
|
+
}
|
|
820
|
+
if (cam.enabled === false) {
|
|
821
|
+
throw new Error(`Camera "${externalId}" is disabled in Frigate config`);
|
|
822
|
+
}
|
|
823
|
+
const existing = this.devices.find((d) => d.name === externalId);
|
|
824
|
+
if (existing) {
|
|
825
|
+
this.ctx.logger.info(`Camera "${externalId}" is already adopted`);
|
|
826
|
+
return existing;
|
|
827
|
+
}
|
|
828
|
+
const device = this.createDeviceFromCamera(externalId, cam);
|
|
829
|
+
this.devices = [...this.devices, device];
|
|
830
|
+
await this.persistAdoptedDeviceId(externalId);
|
|
831
|
+
this.ctx.logger.info(`Adopted camera "${externalId}" (total: ${this.devices.length})`);
|
|
832
|
+
return device;
|
|
833
|
+
}
|
|
834
|
+
getDevices() {
|
|
835
|
+
return [...this.devices];
|
|
836
|
+
}
|
|
837
|
+
subscribeLiveEvents(callback) {
|
|
838
|
+
this.liveEventListeners.add(callback);
|
|
839
|
+
return () => {
|
|
840
|
+
this.liveEventListeners.delete(callback);
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
// --- Private helpers ---
|
|
844
|
+
createDeviceFromCamera(cameraName, cam) {
|
|
845
|
+
const streams = buildStreamOptions(cameraName, this.go2rtcStreams);
|
|
846
|
+
const deviceId = `${this.id}/${cameraName}`;
|
|
847
|
+
const deviceCtx = {
|
|
848
|
+
id: `device:${deviceId}`,
|
|
849
|
+
logger: this.ctx.logger.child(cameraName),
|
|
850
|
+
eventBus: this.ctx.eventBus,
|
|
851
|
+
storage: this.ctx.storage,
|
|
852
|
+
config: new ElementConfigStore(`device:${deviceId}`, this.ctx.storage)
|
|
853
|
+
};
|
|
854
|
+
return new FrigateDevice(
|
|
855
|
+
{
|
|
856
|
+
cameraName,
|
|
857
|
+
providerId: this.id,
|
|
858
|
+
detectWidth: cam.detect?.width ?? 1920,
|
|
859
|
+
detectHeight: cam.detect?.height ?? 1080,
|
|
860
|
+
audioEnabled: cam.audio?.enabled ?? false,
|
|
861
|
+
recordEnabled: cam.record?.enabled ?? false,
|
|
862
|
+
ptzEnabled: false,
|
|
863
|
+
streams,
|
|
864
|
+
frigateHost: this.frigateHost
|
|
865
|
+
},
|
|
866
|
+
this.api,
|
|
867
|
+
deviceCtx
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
getAdoptedDeviceIds() {
|
|
871
|
+
return this.ctx.config.get(ADOPTED_DEVICES_KEY) ?? [];
|
|
872
|
+
}
|
|
873
|
+
async persistAdoptedDeviceId(cameraName) {
|
|
874
|
+
const current = this.getAdoptedDeviceIds();
|
|
875
|
+
if (current.includes(cameraName)) return;
|
|
876
|
+
await this.ctx.config.set(ADOPTED_DEVICES_KEY, [...current, cameraName]);
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
function buildStreamOptions(cameraName, go2rtcStreams) {
|
|
880
|
+
const streams = [];
|
|
881
|
+
for (const streamName of Object.keys(go2rtcStreams)) {
|
|
882
|
+
if (!streamName.startsWith(cameraName)) continue;
|
|
883
|
+
const isSub = streamName.includes("sub") || streamName.includes("ext") || streamName.includes("medium");
|
|
884
|
+
streams.push({ id: streamName, label: streamName, protocol: "rtsp", quality: isSub ? "sub" : "main" });
|
|
885
|
+
}
|
|
886
|
+
return streams;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// src/addon.ts
|
|
890
|
+
var FrigateProviderAddon = class {
|
|
891
|
+
manifest = {
|
|
892
|
+
id: "provider-frigate",
|
|
893
|
+
name: "Frigate NVR Provider",
|
|
894
|
+
version: "0.1.0",
|
|
895
|
+
description: "Integrazione con Frigate NVR per camere e detection",
|
|
896
|
+
capabilities: ["device-provider"]
|
|
897
|
+
};
|
|
898
|
+
provider = null;
|
|
899
|
+
async initialize(context) {
|
|
900
|
+
const config = context.addonConfig;
|
|
901
|
+
if (!config.url) {
|
|
902
|
+
context.logger.warn("Frigate provider: no URL configured, skipping initialization");
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
const providerConfig = {
|
|
906
|
+
id: config.id ?? "frigate-default",
|
|
907
|
+
name: config.name ?? "Frigate NVR",
|
|
908
|
+
url: config.url,
|
|
909
|
+
username: config.username,
|
|
910
|
+
password: config.password,
|
|
911
|
+
mqtt: config.mqtt
|
|
912
|
+
};
|
|
913
|
+
this.provider = new FrigateProvider(providerConfig, {
|
|
914
|
+
id: context.id,
|
|
915
|
+
logger: context.logger,
|
|
916
|
+
eventBus: context.eventBus,
|
|
917
|
+
storage: context.storage,
|
|
918
|
+
config: context.config
|
|
919
|
+
});
|
|
920
|
+
context.logger.info("Frigate provider addon initialized");
|
|
921
|
+
}
|
|
922
|
+
async shutdown() {
|
|
923
|
+
await this.provider?.stop();
|
|
924
|
+
this.provider = null;
|
|
925
|
+
}
|
|
926
|
+
getCapabilityProvider(name) {
|
|
927
|
+
if (name === "device-provider" && this.provider) {
|
|
928
|
+
return this.provider;
|
|
929
|
+
}
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
932
|
+
getConfigSchema() {
|
|
933
|
+
return {
|
|
934
|
+
sections: [
|
|
935
|
+
{
|
|
936
|
+
id: "connection",
|
|
937
|
+
title: "Frigate Connection",
|
|
938
|
+
description: "Configure the connection to your Frigate NVR instance",
|
|
939
|
+
columns: 2,
|
|
940
|
+
fields: [
|
|
941
|
+
{ type: "text", key: "url", label: "Frigate URL", required: true, placeholder: "http://frigate:5000" },
|
|
942
|
+
{ type: "text", key: "name", label: "Display Name", placeholder: "Frigate NVR" },
|
|
943
|
+
{ type: "text", key: "username", label: "Username" },
|
|
944
|
+
{ type: "password", key: "password", label: "Password", showToggle: true }
|
|
945
|
+
]
|
|
946
|
+
},
|
|
947
|
+
{
|
|
948
|
+
id: "mqtt",
|
|
949
|
+
title: "MQTT Settings",
|
|
950
|
+
description: "Optional: Connect to Frigate MQTT for real-time events",
|
|
951
|
+
style: "accordion",
|
|
952
|
+
defaultCollapsed: true,
|
|
953
|
+
columns: 2,
|
|
954
|
+
fields: [
|
|
955
|
+
{ type: "text", key: "mqtt.brokerUrl", label: "MQTT Broker URL", placeholder: "mqtt://broker:1883" },
|
|
956
|
+
{ type: "text", key: "mqtt.topicPrefix", label: "Topic Prefix", placeholder: "frigate" },
|
|
957
|
+
{ type: "text", key: "mqtt.username", label: "MQTT Username" },
|
|
958
|
+
{ type: "password", key: "mqtt.password", label: "MQTT Password", showToggle: true }
|
|
959
|
+
]
|
|
960
|
+
}
|
|
961
|
+
]
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
getConfig() {
|
|
965
|
+
return {};
|
|
966
|
+
}
|
|
967
|
+
async onConfigChange(_config) {
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
export {
|
|
972
|
+
FrigateApiClient,
|
|
973
|
+
FrigateMqttClient,
|
|
974
|
+
FrigateDevice,
|
|
975
|
+
FrigateProvider,
|
|
976
|
+
FrigateProviderAddon
|
|
977
|
+
};
|
|
978
|
+
//# sourceMappingURL=chunk-4YBCO3RD.mjs.map
|