@camstack/addon-export-alexa 1.2.108 → 1.2.110
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/export-alexa.addon.js +1123 -475
- package/dist/export-alexa.addon.mjs +1123 -475
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as crypto$1 from "node:crypto";
|
|
2
|
-
//#region ../types/dist/event-category-
|
|
2
|
+
//#region ../types/dist/event-category-BVDXG4tB.mjs
|
|
3
3
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
4
4
|
EventCategory["SystemBoot"] = "system.boot";
|
|
5
5
|
EventCategory["SystemAddonsReady"] = "system.addons-ready";
|
|
@@ -616,6 +616,20 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
|
616
616
|
* pull-reconcile from the provider's `getStatus` on reconnect. */
|
|
617
617
|
EventCategory["MeshNetworkChanged"] = "network.mesh.changed";
|
|
618
618
|
EventCategory["BackupCompleted"] = "backup.completed";
|
|
619
|
+
/**
|
|
620
|
+
* A whole backup RUN finished — every destination attempted, win or lose.
|
|
621
|
+
*
|
|
622
|
+
* `backup.completed` fires once per DESTINATION, which is the right grain for
|
|
623
|
+
* a progress UI and the wrong one for a notification: an operator with three
|
|
624
|
+
* destinations would be told three times. And a run where two of three
|
|
625
|
+
* destinations succeeded is not a clean success — a single "backup
|
|
626
|
+
* completed" that hid the failed one would be a lie, so the count of each is
|
|
627
|
+
* carried here and the message says both.
|
|
628
|
+
*
|
|
629
|
+
* Emitted by the backup orchestrator only after the destination loop, so a
|
|
630
|
+
* run that dies during the BUILD phase produces no completion at all.
|
|
631
|
+
*/
|
|
632
|
+
EventCategory["BackupRunCompleted"] = "backup.run-completed";
|
|
619
633
|
EventCategory["BackupRestored"] = "backup.restored";
|
|
620
634
|
EventCategory["NotificationDispatched"] = "notification.dispatched";
|
|
621
635
|
EventCategory["NotificationFailed"] = "notification.failed";
|
|
@@ -5346,7 +5360,7 @@ var ZodIssueCode = {
|
|
|
5346
5360
|
var ZodFirstPartyTypeKind;
|
|
5347
5361
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5348
5362
|
//#endregion
|
|
5349
|
-
//#region ../types/dist/sleep-
|
|
5363
|
+
//#region ../types/dist/sleep-BDR76Ykr.mjs
|
|
5350
5364
|
/**
|
|
5351
5365
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5352
5366
|
* window to float samples (D455).
|
|
@@ -7227,6 +7241,28 @@ var DeviceRole = /* @__PURE__ */ function(DeviceRole) {
|
|
|
7227
7241
|
DeviceRole["GenericNotifier"] = "generic-notifier";
|
|
7228
7242
|
return DeviceRole;
|
|
7229
7243
|
}({});
|
|
7244
|
+
/**
|
|
7245
|
+
* Identity — preserves literal types for downstream inference.
|
|
7246
|
+
*
|
|
7247
|
+
* The constraint is `Record<string, unknown>` (not `CustomActionsSpec`) so
|
|
7248
|
+
* TypeScript does not widen each entry's literal `kind`/`auth` fields to
|
|
7249
|
+
* the broader unions declared on `CustomActionSpec`'s default generics.
|
|
7250
|
+
* Shape validity is enforced separately by the `customAction(...)` helper
|
|
7251
|
+
* whose return type is already a `CustomActionSpec<...>`.
|
|
7252
|
+
*/
|
|
7253
|
+
function defineCustomActions(spec) {
|
|
7254
|
+
return spec;
|
|
7255
|
+
}
|
|
7256
|
+
function customAction(input, output, options) {
|
|
7257
|
+
return {
|
|
7258
|
+
input,
|
|
7259
|
+
output,
|
|
7260
|
+
kind: options?.kind ?? "query",
|
|
7261
|
+
auth: options?.auth ?? "protected",
|
|
7262
|
+
scope: options?.scope ?? { kind: "system" },
|
|
7263
|
+
...options?.caller ? { caller: "required" } : {}
|
|
7264
|
+
};
|
|
7265
|
+
}
|
|
7230
7266
|
var StaticDirOutputSchema = object({ staticDir: string() });
|
|
7231
7267
|
var VersionOutputSchema = object({ version: string() });
|
|
7232
7268
|
method(_void(), StaticDirOutputSchema, { auth: "admin" }), method(_void(), VersionOutputSchema, { auth: "admin" });
|
|
@@ -7533,6 +7569,28 @@ var EncodeProfileSchema = object({
|
|
|
7533
7569
|
outputArgs: array(string()).optional()
|
|
7534
7570
|
});
|
|
7535
7571
|
/**
|
|
7572
|
+
* THE target rule: which of two sizes serves a consumer better.
|
|
7573
|
+
*
|
|
7574
|
+
* "Prefer at or above the target so the consumer downscales instead of
|
|
7575
|
+
* upscaling; among those the SMALLEST, because the extra pixels are thrown
|
|
7576
|
+
* away and the decode is not; among the rest the LARGEST, because it is the
|
|
7577
|
+
* closest we can get." One sentence, and until now it was written twice — once
|
|
7578
|
+
* here in lines, once in `stream-selection.ts` in pixels, for the same two
|
|
7579
|
+
* exporters. The two agreed, which is luck, not a property.
|
|
7580
|
+
*
|
|
7581
|
+
* The `size` is whatever measure the caller ranks by: the cap ranks by LINES
|
|
7582
|
+
* (a consumer's requirement is stated in p), the RTSP picker by PIXELS (it
|
|
7583
|
+
* compares entries of possibly different aspect). The rule is the same either
|
|
7584
|
+
* way and the measure is the caller's business.
|
|
7585
|
+
*
|
|
7586
|
+
* Returns a sort delta: negative when `a` should come first.
|
|
7587
|
+
*/
|
|
7588
|
+
function compareToTarget(a, b, target) {
|
|
7589
|
+
const aReaches = a >= target;
|
|
7590
|
+
if (aReaches !== b >= target) return aReaches ? -1 : 1;
|
|
7591
|
+
return aReaches ? a - b : b - a;
|
|
7592
|
+
}
|
|
7593
|
+
/**
|
|
7536
7594
|
* The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
|
|
7537
7595
|
* Baseline because it is the one profile every consumer in this repo decodes
|
|
7538
7596
|
* (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
|
|
@@ -9447,6 +9505,47 @@ var LogChannelWindowSchema = object({
|
|
|
9447
9505
|
/** `null` = every camera. A non-empty list narrows to those numeric ids. */
|
|
9448
9506
|
deviceIds: array(number().int()).readonly().nullable()
|
|
9449
9507
|
});
|
|
9508
|
+
/**
|
|
9509
|
+
* Shared stream-selection picker for export addons.
|
|
9510
|
+
*
|
|
9511
|
+
* Both `addon-export-alexa` and `addon-export-hap` resolve the
|
|
9512
|
+
* operator's `streamPreference` setting against the broker's RTSP
|
|
9513
|
+
* entries before dialling a camera. Each addon used to roll its own:
|
|
9514
|
+
* Alexa had `pickPreferredRtspEntry` under `handlers/`, HAP had an
|
|
9515
|
+
* inline `"prefer /mid, then /high, then first"` chain inside its
|
|
9516
|
+
* camera-streams mapper. The chains drifted (Alexa honoured
|
|
9517
|
+
* `streamPreference`, HAP didn't; only Alexa got the smart
|
|
9518
|
+
* resolution-targeted auto-pick) and the cross-addon import rule
|
|
9519
|
+
* blocks deduping in either direction.
|
|
9520
|
+
*
|
|
9521
|
+
* Lifting the picker into `@camstack/types` lets every exporter call
|
|
9522
|
+
* the same code path with the consumer's target resolution baked into
|
|
9523
|
+
* the call site — Alexa passes 1280×720, HomeKit passes 1920×1080,
|
|
9524
|
+
* the picker handles the rest.
|
|
9525
|
+
*
|
|
9526
|
+
* Selection algorithm:
|
|
9527
|
+
* - pref === '<profile>' → profile-keyed entries (from
|
|
9528
|
+
* `getProfileRtspEntries`) match on `e.profile`;
|
|
9529
|
+
* legacy raw entries match on the brokerId
|
|
9530
|
+
* suffix (`brokerId` ENDS WITH `/${pref}`, shape
|
|
9531
|
+
* `${deviceId}/${camStreamId}`). Addons store the
|
|
9532
|
+
* bare profile/camStream id in their
|
|
9533
|
+
* `ExportDeviceSettings.streamPreference`. Falls
|
|
9534
|
+
* back to 'auto' when no enabled entry matches
|
|
9535
|
+
* either way.
|
|
9536
|
+
* - pref === 'auto' → when `targetResolution` is given, pick the
|
|
9537
|
+
* entry whose source resolution is closest to
|
|
9538
|
+
* it (prefer ≥ target so the consumer
|
|
9539
|
+
* downscales instead of upscaling). Without a
|
|
9540
|
+
* target, fall back to first-enabled. If no
|
|
9541
|
+
* entry advertises a resolution at all
|
|
9542
|
+
* (broker hasn't probed yet), also fall back
|
|
9543
|
+
* to first-enabled so cold start still serves.
|
|
9544
|
+
*
|
|
9545
|
+
* Returns `null` when nothing is publishable — callers fold that into
|
|
9546
|
+
* the consumer's "no stream" error (Alexa: STREAM_TYPE_UNSUPPORTED;
|
|
9547
|
+
* HAP: a thrown error inside `startFfmpegForSession`).
|
|
9548
|
+
*/
|
|
9450
9549
|
function pickPreferredRtspEntry(entries, pref, deviceId, options = {}) {
|
|
9451
9550
|
if (entries.length === 0) return null;
|
|
9452
9551
|
const prefix = `${deviceId}/`;
|
|
@@ -9476,30 +9575,23 @@ function pickPreferredRtspEntry(entries, pref, deviceId, options = {}) {
|
|
|
9476
9575
|
return toPicked(eligible[0]);
|
|
9477
9576
|
}
|
|
9478
9577
|
/**
|
|
9479
|
-
* Pick the entry whose `resolution` is closest to `target
|
|
9480
|
-
*
|
|
9481
|
-
*
|
|
9482
|
-
*
|
|
9483
|
-
*
|
|
9578
|
+
* Pick the entry whose `resolution` is closest to `target`, by the ONE target
|
|
9579
|
+
* rule ({@link compareToTarget}): prefer at or above the target so the consumer
|
|
9580
|
+
* downscales instead of upscaling; among those the smallest; among the rest the
|
|
9581
|
+
* largest.
|
|
9582
|
+
*
|
|
9583
|
+
* This function used to implement that rule itself, in pixels, while
|
|
9584
|
+
* `rankStreams` implemented it again in lines — the same sentence written twice
|
|
9585
|
+
* for the same two exporters, agreeing by luck rather than by construction.
|
|
9586
|
+
* Only the MEASURE is this picker's own: it compares whole pixel counts because
|
|
9587
|
+
* its entries can differ in aspect, where the cap's requirement is stated in p.
|
|
9484
9588
|
*/
|
|
9485
9589
|
function pickClosestResolution(entries, target) {
|
|
9486
9590
|
const targetPixels = target.width * target.height;
|
|
9487
|
-
|
|
9488
|
-
|
|
9489
|
-
|
|
9490
|
-
|
|
9491
|
-
const pixels = e.resolution.width * e.resolution.height;
|
|
9492
|
-
if (pixels >= targetPixels) {
|
|
9493
|
-
if (!bestAbove || pixels < bestAbove.pixels) bestAbove = {
|
|
9494
|
-
entry: e,
|
|
9495
|
-
pixels
|
|
9496
|
-
};
|
|
9497
|
-
} else if (!bestBelow || pixels > bestBelow.pixels) bestBelow = {
|
|
9498
|
-
entry: e,
|
|
9499
|
-
pixels
|
|
9500
|
-
};
|
|
9501
|
-
}
|
|
9502
|
-
return bestAbove?.entry ?? bestBelow?.entry;
|
|
9591
|
+
return [...entries.flatMap((entry) => entry.resolution === void 0 ? [] : [{
|
|
9592
|
+
entry,
|
|
9593
|
+
pixels: entry.resolution.width * entry.resolution.height
|
|
9594
|
+
}])].sort((a, b) => compareToTarget(a.pixels, b.pixels, targetPixels))[0]?.entry;
|
|
9503
9595
|
}
|
|
9504
9596
|
var MODEL_FORMATS = [
|
|
9505
9597
|
"onnx",
|
|
@@ -11713,6 +11805,58 @@ method(ListInputSchema, array(BrokerInfoSchema$1)), method(GetInputSchema, Broke
|
|
|
11713
11805
|
auth: "admin"
|
|
11714
11806
|
}), method(GetStateInputSchema, unknown().nullable()), method(_void(), RegistryStatusSchema);
|
|
11715
11807
|
DeviceType.Camera;
|
|
11808
|
+
/**
|
|
11809
|
+
* The signals a device can emit to WAKE its own stream.
|
|
11810
|
+
*
|
|
11811
|
+
* A camera whose stream is built on demand sleeps until something asks for it,
|
|
11812
|
+
* and "something" cannot be a consumer that is merely attached — a Frigate-style
|
|
11813
|
+
* puller holds a session open for ever, and treating that as demand would keep
|
|
11814
|
+
* a battery camera awake for ever, which is the whole thing the battery is for
|
|
11815
|
+
* (D173). So the wake has to come from the CAMERA: an event it noticed by
|
|
11816
|
+
* itself, with no stream running.
|
|
11817
|
+
*
|
|
11818
|
+
* ## The vocabulary is the PROVIDER'S, not ours
|
|
11819
|
+
*
|
|
11820
|
+
* Like `consumables`, this cap declares no vocabulary of its own. A provider
|
|
11821
|
+
* names each signal with a `code` it chooses and a `label` an operator reads.
|
|
11822
|
+
* Reolink offers motion and camera-native detection; another provider may offer
|
|
11823
|
+
* a tamper, a doorbell press, a PIR, or something no camera in this fleet has
|
|
11824
|
+
* yet. A fixed enum here would mean every new signal is a framework release.
|
|
11825
|
+
*
|
|
11826
|
+
* It is deliberately NOT derived from the caps a device already binds. Whether
|
|
11827
|
+
* a camera CAN push firmware motion is expressed by `motionSources` containing
|
|
11828
|
+
* `'onboard'`, and whether it does AI on-camera by the `native-object-detection`
|
|
11829
|
+
* binding — but both answer "what drives the detection pipeline", which is a
|
|
11830
|
+
* different question from "what may wake a sleeping stream". A camera can do
|
|
11831
|
+
* the first and not be trusted with the second, and the operator picks per
|
|
11832
|
+
* camera. Two questions, two authorities.
|
|
11833
|
+
*
|
|
11834
|
+
* ## Availability is not permission
|
|
11835
|
+
*
|
|
11836
|
+
* `listSignals` says what the device CAN emit. Whether a given signal actually
|
|
11837
|
+
* wakes the stream is the operator's per-camera choice, held by the broker
|
|
11838
|
+
* alongside the cooldown — see the stream-broker cap's wake settings. A
|
|
11839
|
+
* provider declaring a signal is not a provider enabling it.
|
|
11840
|
+
*/
|
|
11841
|
+
/** One signal a device can emit. */
|
|
11842
|
+
var StreamSignalSchema = object({
|
|
11843
|
+
/** Stable id chosen by the provider, e.g. `'motion'`, `'person'`, `'tamper'`. */
|
|
11844
|
+
code: string().min(1),
|
|
11845
|
+
/** What an operator reads in the picker. The provider's own wording. */
|
|
11846
|
+
label: string().min(1),
|
|
11847
|
+
/**
|
|
11848
|
+
* Whether the provider recommends this signal ON when a camera is first set
|
|
11849
|
+
* up. A provider knows which of its signals are cheap and reliable; an
|
|
11850
|
+
* operator should not have to discover that by trial. Reolink recommends
|
|
11851
|
+
* both of its own.
|
|
11852
|
+
*/
|
|
11853
|
+
recommended: boolean()
|
|
11854
|
+
});
|
|
11855
|
+
object({
|
|
11856
|
+
signals: array(StreamSignalSchema),
|
|
11857
|
+
lastFetchedAt: number()
|
|
11858
|
+
});
|
|
11859
|
+
Object.values(DeviceType), method(_void(), array(StreamSignalSchema).readonly());
|
|
11716
11860
|
/** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
|
|
11717
11861
|
var StreamFormatSchema = _enum([
|
|
11718
11862
|
"webrtc",
|
|
@@ -12109,6 +12253,22 @@ var EgressTranscodeSchema = object({
|
|
|
12109
12253
|
camStreamId: string().nullable()
|
|
12110
12254
|
});
|
|
12111
12255
|
method(object({
|
|
12256
|
+
deviceId: number().int().nonnegative(),
|
|
12257
|
+
/** The provider's signal code. */
|
|
12258
|
+
code: string().min(1),
|
|
12259
|
+
/** Ms epoch. Absent ⇒ now. */
|
|
12260
|
+
at: number().optional()
|
|
12261
|
+
}), object({
|
|
12262
|
+
/** Whether the broker acted on it, and if not, why. */
|
|
12263
|
+
accepted: boolean(),
|
|
12264
|
+
reason: _enum([
|
|
12265
|
+
"woke",
|
|
12266
|
+
"hold-extended",
|
|
12267
|
+
"not-enabled",
|
|
12268
|
+
"no-consumer",
|
|
12269
|
+
"unknown-code"
|
|
12270
|
+
])
|
|
12271
|
+
}), { kind: "mutation" }), method(object({
|
|
12112
12272
|
deviceId: number().int().nonnegative(),
|
|
12113
12273
|
camStreamId: string().min(1),
|
|
12114
12274
|
kind: CamStreamKindSchema,
|
|
@@ -12363,6 +12523,16 @@ var PickStreamRequirementsSchema = object({
|
|
|
12363
12523
|
acceptCodecs: array(StreamCodecSchema).readonly().optional(),
|
|
12364
12524
|
/** Minimum vertical resolution. Streams shorter than this are dropped. */
|
|
12365
12525
|
minHeight: number().int().positive().optional(),
|
|
12526
|
+
/**
|
|
12527
|
+
* Maximum vertical resolution. Streams TALLER than this are dropped.
|
|
12528
|
+
*
|
|
12529
|
+
* A consumer can have a ceiling as real as its floor: Alexa documents
|
|
12530
|
+
* 480p to 1080p, and a 4K stream is as unusable to an Echo as a 360p one.
|
|
12531
|
+
* Without this the ceiling had to be re-implemented by every caller — and
|
|
12532
|
+
* one caller implementing it privately is how a second scoring authority
|
|
12533
|
+
* gets born.
|
|
12534
|
+
*/
|
|
12535
|
+
maxHeight: number().int().positive().optional(),
|
|
12366
12536
|
/** Minimum horizontal resolution. */
|
|
12367
12537
|
minWidth: number().int().positive().optional(),
|
|
12368
12538
|
/**
|
|
@@ -12379,7 +12549,22 @@ var PickStreamRequirementsSchema = object({
|
|
|
12379
12549
|
* transcoded" guard: if the device is already serving the consumer's
|
|
12380
12550
|
* codec end-to-end, there's nothing to optimise.
|
|
12381
12551
|
*/
|
|
12382
|
-
requireSiblingCodec: array(StreamCodecSchema).readonly().optional()
|
|
12552
|
+
requireSiblingCodec: array(StreamCodecSchema).readonly().optional(),
|
|
12553
|
+
/**
|
|
12554
|
+
* Whether a stream the consumer CANNOT decode may still be picked, on the
|
|
12555
|
+
* understanding that it will be transcoded.
|
|
12556
|
+
*
|
|
12557
|
+
* Default `false` — today's behaviour, and the right one for a bypass
|
|
12558
|
+
* question ("is there a stream I can forward untouched?"). Set `true` to
|
|
12559
|
+
* ask the larger question: "what is the best stream for me, transcoding if
|
|
12560
|
+
* I must?" A stream that satisfies `acceptCodecs` always outranks one that
|
|
12561
|
+
* does not, so a passthrough is never lost to a transcode; the answer says
|
|
12562
|
+
* which it is in {@link PickedCamStreamSchema.transcodes}.
|
|
12563
|
+
*
|
|
12564
|
+
* This is what lets one picker serve both the bypass and the full source
|
|
12565
|
+
* choice, instead of a consumer scoring privately when the bypass misses.
|
|
12566
|
+
*/
|
|
12567
|
+
allowTranscode: boolean().optional()
|
|
12383
12568
|
}).readonly();
|
|
12384
12569
|
var PickStreamPreferencesSchema = object({
|
|
12385
12570
|
/**
|
|
@@ -12393,12 +12578,32 @@ var PickStreamPreferencesSchema = object({
|
|
|
12393
12578
|
* picks the tallest stream; `'lowest'` picks the shortest (used by
|
|
12394
12579
|
* memory-constrained consumers / Apple Home guest sessions).
|
|
12395
12580
|
*/
|
|
12396
|
-
resolutionPreference: _enum(["highest", "lowest"]).optional()
|
|
12581
|
+
resolutionPreference: _enum(["highest", "lowest"]).optional(),
|
|
12582
|
+
/**
|
|
12583
|
+
* The height the consumer actually wants to DELIVER.
|
|
12584
|
+
*
|
|
12585
|
+
* Not a constraint — an ordering. With it set, the SMALLEST stream at or
|
|
12586
|
+
* above the target wins, and only if nothing reaches it does the tallest
|
|
12587
|
+
* take over. Pulling 1296 lines to draw 720 on a 1280x800 Echo panel costs
|
|
12588
|
+
* a decode and buys nothing, and `resolutionPreference: 'highest'` cannot
|
|
12589
|
+
* express that: it says "as big as possible", which is a different wish.
|
|
12590
|
+
*
|
|
12591
|
+
* Ignored when absent, so every existing caller keeps its ordering.
|
|
12592
|
+
*/
|
|
12593
|
+
targetHeight: number().int().positive().optional()
|
|
12397
12594
|
}).readonly();
|
|
12398
12595
|
var PickedCamStreamSchema = object({
|
|
12399
12596
|
camStreamId: string(),
|
|
12400
12597
|
codec: string().optional(),
|
|
12401
12598
|
resolution: CamStreamResolutionSchema.optional(),
|
|
12599
|
+
/**
|
|
12600
|
+
* Whether serving this stream requires a decode + re-encode.
|
|
12601
|
+
*
|
|
12602
|
+
* `false` is a stream the consumer can take as it stands. Only ever `true`
|
|
12603
|
+
* when the caller asked for it with `allowTranscode`, so a caller that did
|
|
12604
|
+
* not ask cannot be handed a cost it never agreed to pay.
|
|
12605
|
+
*/
|
|
12606
|
+
transcodes: boolean(),
|
|
12402
12607
|
/** One-line explanation of why this stream won — for logs / debug UI. */
|
|
12403
12608
|
reason: string()
|
|
12404
12609
|
});
|
|
@@ -12516,28 +12721,6 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
|
|
|
12516
12721
|
content: string()
|
|
12517
12722
|
})) }), { auth: "admin" });
|
|
12518
12723
|
/**
|
|
12519
|
-
* Identity — preserves literal types for downstream inference.
|
|
12520
|
-
*
|
|
12521
|
-
* The constraint is `Record<string, unknown>` (not `CustomActionsSpec`) so
|
|
12522
|
-
* TypeScript does not widen each entry's literal `kind`/`auth` fields to
|
|
12523
|
-
* the broader unions declared on `CustomActionSpec`'s default generics.
|
|
12524
|
-
* Shape validity is enforced separately by the `customAction(...)` helper
|
|
12525
|
-
* whose return type is already a `CustomActionSpec<...>`.
|
|
12526
|
-
*/
|
|
12527
|
-
function defineCustomActions(spec) {
|
|
12528
|
-
return spec;
|
|
12529
|
-
}
|
|
12530
|
-
function customAction(input, output, options) {
|
|
12531
|
-
return {
|
|
12532
|
-
input,
|
|
12533
|
-
output,
|
|
12534
|
-
kind: options?.kind ?? "query",
|
|
12535
|
-
auth: options?.auth ?? "protected",
|
|
12536
|
-
scope: options?.scope ?? { kind: "system" },
|
|
12537
|
-
...options?.caller ? { caller: "required" } : {}
|
|
12538
|
-
};
|
|
12539
|
-
}
|
|
12540
|
-
/**
|
|
12541
12724
|
* `custom-model-registry` — collection cap exposing operator-registered
|
|
12542
12725
|
* custom detection models. Each provider (today: `addon-model-studio`)
|
|
12543
12726
|
* contributes a list of `CustomModelDescriptor`s; the hub auto-concatenates
|
|
@@ -16634,6 +16817,8 @@ var NcSystemEventKindSchema = _enum([
|
|
|
16634
16817
|
"device-enabled",
|
|
16635
16818
|
"device-battery-low",
|
|
16636
16819
|
"device-battery-normal",
|
|
16820
|
+
"device-consumable-low",
|
|
16821
|
+
"device-consumable-normal",
|
|
16637
16822
|
"stream-online",
|
|
16638
16823
|
"stream-offline",
|
|
16639
16824
|
"node-online",
|
|
@@ -16652,6 +16837,7 @@ var NcSystemEventKindSchema = _enum([
|
|
|
16652
16837
|
"addon-updated",
|
|
16653
16838
|
"server-updated",
|
|
16654
16839
|
"export-completed",
|
|
16840
|
+
"backup-completed",
|
|
16655
16841
|
"camera-online",
|
|
16656
16842
|
"camera-offline",
|
|
16657
16843
|
"camera-disabled",
|
|
@@ -17974,6 +18160,48 @@ var NcAlarmConfigSchema = object({
|
|
|
17974
18160
|
settings: NcAlarmSettingsSchema,
|
|
17975
18161
|
coverage: array(NcAlarmModeCoverageSchema)
|
|
17976
18162
|
});
|
|
18163
|
+
/**
|
|
18164
|
+
* ONE rule's demand on ONE camera's clip ring.
|
|
18165
|
+
*
|
|
18166
|
+
* A rule, not a camera: the broker takes the MAX over the asks that reach a
|
|
18167
|
+
* camera, so the answer stays a faithful description of the rules and the
|
|
18168
|
+
* bounding (the byte budget, the broker's own ceiling) stays where the cost
|
|
18169
|
+
* lives. A camera that appears in no ask is being told **nothing** — which is
|
|
18170
|
+
* a different thing from never having been told, and only the envelope
|
|
18171
|
+
* (`rulesLoaded`) can tell those apart.
|
|
18172
|
+
*/
|
|
18173
|
+
var NcClipRetentionAskSchema = object({
|
|
18174
|
+
/**
|
|
18175
|
+
* The camera this ask is about. **Absent = every camera**, which is what a
|
|
18176
|
+
* rule with no `conditions.devices` means: it can fire anywhere, so it is
|
|
18177
|
+
* asking everywhere.
|
|
18178
|
+
*/
|
|
18179
|
+
deviceId: number().int().nonnegative().optional(),
|
|
18180
|
+
/** Seconds of history the rule needs held for it. Never zero — a rule that
|
|
18181
|
+
* needs nothing produces no ask at all. */
|
|
18182
|
+
seconds: number().min(0).max(60),
|
|
18183
|
+
/** The profile the rule cuts from, when it named one. Absent = the cheapest
|
|
18184
|
+
* assigned, which is what the cut defaults to. */
|
|
18185
|
+
profile: CamProfileSchema.optional(),
|
|
18186
|
+
/** Who is asking — so a retention the operator did not expect names a rule. */
|
|
18187
|
+
ruleId: string(),
|
|
18188
|
+
ruleName: string(),
|
|
18189
|
+
/** Why this many seconds: the rule's own window, or the occupancy ceiling. */
|
|
18190
|
+
reason: _enum(["window", "occupancy"])
|
|
18191
|
+
});
|
|
18192
|
+
/**
|
|
18193
|
+
* The whole answer, with the one bit that keeps absence from reading as zero.
|
|
18194
|
+
*
|
|
18195
|
+
* `rulesLoaded` is false until the rule ledger has completed a load at least
|
|
18196
|
+
* once. A centre that is up but has not read its rules yet answers with an
|
|
18197
|
+
* empty ask list that means nothing at all — applying it would drop every
|
|
18198
|
+
* camera's ring to zero for the first events after a restart, which is the
|
|
18199
|
+
* footage this mechanism exists to keep.
|
|
18200
|
+
*/
|
|
18201
|
+
var NcClipRetentionPlanSchema = object({
|
|
18202
|
+
rulesLoaded: boolean(),
|
|
18203
|
+
asks: array(NcClipRetentionAskSchema).readonly()
|
|
18204
|
+
});
|
|
17977
18205
|
method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), method(object({ ruleId: string() }), object({ rule: NcRuleSchema.nullable() }), { auth: "admin" }), method(object({ rule: NcRuleInputSchema }), object({ rule: NcRuleSchema }), {
|
|
17978
18206
|
kind: "mutation",
|
|
17979
18207
|
auth: "admin",
|
|
@@ -17994,7 +18222,7 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
|
|
|
17994
18222
|
}), object({ success: literal(true) }), {
|
|
17995
18223
|
kind: "mutation",
|
|
17996
18224
|
auth: "admin"
|
|
17997
|
-
}), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
|
|
18225
|
+
}), method(object({}), NcClipRetentionPlanSchema, { auth: "admin" }), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
|
|
17998
18226
|
deviceId: number().int(),
|
|
17999
18227
|
muted: boolean()
|
|
18000
18228
|
}), object({ success: literal(true) }), {
|
|
@@ -20544,6 +20772,33 @@ var RunReplayFrameProcessorResultSchema = object({ tracks: array(object({
|
|
|
20544
20772
|
* solid is this track", cheaper than re-deriving it from a trajectory. */
|
|
20545
20773
|
framesMatched: number().int()
|
|
20546
20774
|
})).readonly() });
|
|
20775
|
+
/**
|
|
20776
|
+
* The long-term series a camera produces. A closed union so a typo cannot
|
|
20777
|
+
* invent one, and the ONE declaration of it — the addon's `AnalyticsLtsSeries`
|
|
20778
|
+
* is an alias of this, not a second list.
|
|
20779
|
+
*/
|
|
20780
|
+
var AnalyticsLtsSeriesSchema = _enum([
|
|
20781
|
+
"motion",
|
|
20782
|
+
"audio-dbfs",
|
|
20783
|
+
"battery",
|
|
20784
|
+
"occupancy"
|
|
20785
|
+
]);
|
|
20786
|
+
/**
|
|
20787
|
+
* One closed 5-minute bucket of a long-term series.
|
|
20788
|
+
*
|
|
20789
|
+
* `samples` is how many readings the bucket folded — it is the ACTIVITY for a
|
|
20790
|
+
* series like `motion`, and the confidence for one like `occupancy`. A bucket
|
|
20791
|
+
* that exists at all was measured; a bucket that is absent was not, which is a
|
|
20792
|
+
* different claim from zero and the reason this is a sparse series.
|
|
20793
|
+
*/
|
|
20794
|
+
var LtsBucketSchema = object({
|
|
20795
|
+
scope: string(),
|
|
20796
|
+
bucketStart: number().int(),
|
|
20797
|
+
samples: number().int().nonnegative(),
|
|
20798
|
+
sum: number(),
|
|
20799
|
+
min: number(),
|
|
20800
|
+
max: number()
|
|
20801
|
+
});
|
|
20547
20802
|
DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
|
|
20548
20803
|
deviceId: number(),
|
|
20549
20804
|
trackId: string()
|
|
@@ -20590,6 +20845,14 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
20590
20845
|
kinds: array(string()).optional(),
|
|
20591
20846
|
limit: number().int().min(1).max(MAX_EVENT_QUERY_LIMIT).default(DEFAULT_EVENT_QUERY_LIMIT)
|
|
20592
20847
|
}), array(SensorEventSchema).readonly()), method(KeyEventQueryInput, array(KeyEventSchema).readonly()), method(KeyEventBatchQueryInput, array(KeyEventsForDeviceSchema).readonly()), method(object({
|
|
20848
|
+
deviceId: number(),
|
|
20849
|
+
series: AnalyticsLtsSeriesSchema,
|
|
20850
|
+
/** Omit for EVERY scope of this series in the range. */
|
|
20851
|
+
scope: string().optional(),
|
|
20852
|
+
from: number(),
|
|
20853
|
+
to: number(),
|
|
20854
|
+
limit: number().int().positive().optional()
|
|
20855
|
+
}), array(LtsBucketSchema).readonly()), method(object({
|
|
20593
20856
|
deviceId: number(),
|
|
20594
20857
|
since: number(),
|
|
20595
20858
|
until: number(),
|
|
@@ -21208,6 +21471,14 @@ var MotionSourceEnum = _enum([
|
|
|
21208
21471
|
*/
|
|
21209
21472
|
var MotionSourcesSchema = array(MotionSourceEnum);
|
|
21210
21473
|
/**
|
|
21474
|
+
* Which root detectors a camera runs. Deliberately the SAME vocabulary
|
|
21475
|
+
* post-analysis already tags every detection with (`DetectionSource`) rather
|
|
21476
|
+
* than a second spelling of the same two ideas — the value an operator picks
|
|
21477
|
+
* here is the value that comes back on the track, the overlay and the debug
|
|
21478
|
+
* row.
|
|
21479
|
+
*/
|
|
21480
|
+
var DetectionSourcesSchema = array(DetectionSourceSchema);
|
|
21481
|
+
/**
|
|
21211
21482
|
* Input shape for `pipeline-runner.reportMotion` cap method. Exported
|
|
21212
21483
|
* so cap-side consumers (the orchestrator forward, the runner addon's
|
|
21213
21484
|
* cap implementation, tests) can reuse the type instead of redeclaring
|
|
@@ -21358,6 +21629,35 @@ var RunnerCameraConfigSchema = object({
|
|
|
21358
21629
|
* events; the orchestrator forwards to `reportMotion`.
|
|
21359
21630
|
*/
|
|
21360
21631
|
motionSources: MotionSourcesSchema.default(["analyzer"]),
|
|
21632
|
+
/**
|
|
21633
|
+
* WHICH root detector runs for this camera. The detection counterpart of
|
|
21634
|
+
* {@link motionSources}, same shape and same discipline: a per-camera list
|
|
21635
|
+
* of sources the system understands, vendor-agnostic, owned here.
|
|
21636
|
+
*
|
|
21637
|
+
* - `pipeline` — this runner's own root step on decoded pixels (`steps`).
|
|
21638
|
+
* - `onboard` — the CAMERA's own detector, admitted only when it is named
|
|
21639
|
+
* here. Its boxes reach post-analysis on the same
|
|
21640
|
+
* `PipelineInferenceResult` payload as the pipeline's, so an onboard-born
|
|
21641
|
+
* track gets the same tracker, the same overlay and the same detail
|
|
21642
|
+
* subtree (face / plate / embedding).
|
|
21643
|
+
*
|
|
21644
|
+
* `['onboard']` alone is the REPLACEMENT: this runner's root step does not
|
|
21645
|
+
* run, and that is where a camera with a usable onboard detector buys back
|
|
21646
|
+
* its share of the accelerator. It saves the root inference, not the decode
|
|
21647
|
+
* — the detail subtree still needs pixels to cut crops from.
|
|
21648
|
+
*
|
|
21649
|
+
* A camera whose onboard detections carry no geometry cannot serve as a root
|
|
21650
|
+
* step at all (there is nothing to track), and the cap that knows this says
|
|
21651
|
+
* so on `native-object-detection.getOptions().geometry`. On Reolink that is
|
|
21652
|
+
* every battery camera: the boxes ride a sub-stream a battery camera never
|
|
21653
|
+
* attaches, so such a camera stays on `['pipeline']` and its onboard AI is
|
|
21654
|
+
* worth exactly what it already is — a wake signal with a class.
|
|
21655
|
+
*
|
|
21656
|
+
* Defaults to `['pipeline']`, which is byte-for-byte today's behaviour: a
|
|
21657
|
+
* vendor never turns a detector on for the operator, and neither does a
|
|
21658
|
+
* default.
|
|
21659
|
+
*/
|
|
21660
|
+
detectionSources: DetectionSourcesSchema.default(["pipeline"]),
|
|
21361
21661
|
pipelineEnabled: boolean().default(true),
|
|
21362
21662
|
/** Ordered tree of video steps. Absent → runner skips video detection. */
|
|
21363
21663
|
steps: array(PipelineStepInputSchema).readonly().optional(),
|
|
@@ -21539,7 +21839,47 @@ var RunnerLocalMetricsSchema = object({
|
|
|
21539
21839
|
queueDepth: number(),
|
|
21540
21840
|
frameLazy: FrameLazyMetricsSchema.optional()
|
|
21541
21841
|
});
|
|
21842
|
+
/**
|
|
21843
|
+
* Pipeline Runner capability — runtime detection workhorse.
|
|
21844
|
+
*
|
|
21845
|
+
* One instance per node. Receives camera assignments from
|
|
21846
|
+
* `addon-pipeline-orchestrator`, subscribes to the local stream-broker for
|
|
21847
|
+
* decoded frames, drains motion + detection queues, calls the local
|
|
21848
|
+
* `motion-detection` and `pipeline-executor` capabilities, and emits typed
|
|
21849
|
+
* `pipeline.inference-result` and `detection.motion-analysis` events on
|
|
21850
|
+
* the bus.
|
|
21851
|
+
*
|
|
21852
|
+
* Distinct from `pipeline-orchestrator` (the hub-side load balancer) — the
|
|
21853
|
+
* runner has zero knowledge of other agents, no global state, and never
|
|
21854
|
+
* makes assignment decisions itself.
|
|
21855
|
+
*/
|
|
21856
|
+
/**
|
|
21857
|
+
* Why a runner declined to look at a camera out of band. Named rather than a
|
|
21858
|
+
* bare `false`: a camera asleep on battery (D173), one attached elsewhere, one
|
|
21859
|
+
* already mid-session and one already bursting are four different facts, and a
|
|
21860
|
+
* caller told only "no" cannot tell a protection from a bug.
|
|
21861
|
+
*/
|
|
21862
|
+
var OccupancyBurstRefusalSchema = _enum([
|
|
21863
|
+
"not-attached",
|
|
21864
|
+
"battery-asleep",
|
|
21865
|
+
"not-watching",
|
|
21866
|
+
"already-bursting"
|
|
21867
|
+
]);
|
|
21542
21868
|
method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mutation" }), method(object({ deviceId: number() }), object({ success: literal(true) }), { kind: "mutation" }), method(ReportMotionInputSchema, object({ success: literal(true) }), { kind: "mutation" }), method(_void(), RunnerLocalLoadSchema), method(_void(), RunnerLocalMetricsSchema), method(object({ deviceId: number() }), CameraMetricsSchema.nullable()), method(_void(), array(CameraMetricsWithDeviceIdSchema).readonly()), method(_void(), array(number()).readonly()), method(object({
|
|
21869
|
+
deviceId: number(),
|
|
21870
|
+
/** How many frames to collect. The caller sizes this against what its
|
|
21871
|
+
* own confirmation needs; the runner clamps it to the field's range. */
|
|
21872
|
+
frames: number().int().positive().optional(),
|
|
21873
|
+
/** Why, for the log. A burst nobody can attribute is a cost nobody can
|
|
21874
|
+
* defend — the periodic one at least has a timer to point at. */
|
|
21875
|
+
reason: string()
|
|
21876
|
+
}), object({
|
|
21877
|
+
started: boolean(),
|
|
21878
|
+
refusedBecause: OccupancyBurstRefusalSchema.optional()
|
|
21879
|
+
}), {
|
|
21880
|
+
auth: "admin",
|
|
21881
|
+
kind: "mutation"
|
|
21882
|
+
}), method(object({
|
|
21543
21883
|
handle: FrameHandleSchema,
|
|
21544
21884
|
bbox: NativeCropBboxSchema,
|
|
21545
21885
|
maxWidth: number().int().positive().optional(),
|
|
@@ -23909,10 +24249,28 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
|
|
|
23909
24249
|
}), boolean()), method(object({
|
|
23910
24250
|
deviceId: number().int().nonnegative(),
|
|
23911
24251
|
sessionId: string()
|
|
23912
|
-
}), object({
|
|
23913
|
-
|
|
23914
|
-
|
|
23915
|
-
|
|
24252
|
+
}), object({
|
|
24253
|
+
pendingRenegotiation: object({
|
|
24254
|
+
target: WebrtcStreamTargetSchema,
|
|
24255
|
+
epoch: number()
|
|
24256
|
+
}).nullable(),
|
|
24257
|
+
/**
|
|
24258
|
+
* Whether the session still EXISTS.
|
|
24259
|
+
*
|
|
24260
|
+
* A consumer that holds a resource for the life of a session needs to
|
|
24261
|
+
* be able to ask, because a session does not always end the way it
|
|
24262
|
+
* began. Measured on this hub 2026-09-13: an Echo that got stuck never
|
|
24263
|
+
* sent `SessionDisconnected`, so the Alexa exporter's
|
|
24264
|
+
* `releaseEgressTranscode` never ran, and a 2304x1296 HEVC to 720p
|
|
24265
|
+
* H.264 transcode kept running for NOBODY for more than twenty
|
|
24266
|
+
* minutes. The WebRTC session had logged `WebRTC session closed`
|
|
24267
|
+
* minutes earlier — the broker knew; the holder had no way to ask.
|
|
24268
|
+
*
|
|
24269
|
+
* `false` for a session id the provider has never heard of, which is
|
|
24270
|
+
* the same answer as "it ended": either way nothing is holding it up.
|
|
24271
|
+
*/
|
|
24272
|
+
alive: boolean()
|
|
24273
|
+
}));
|
|
23916
24274
|
object({
|
|
23917
24275
|
/** All accessory children of the parent. */
|
|
23918
24276
|
childDeviceIds: array(number()).readonly(),
|
|
@@ -26990,7 +27348,7 @@ var NativeDetectionSchema = object({
|
|
|
26990
27348
|
/** Firmware-provided confidence [0..1]. Reolink pushes don't carry it → undefined. */
|
|
26991
27349
|
confidence: number().min(0).max(1).optional()
|
|
26992
27350
|
});
|
|
26993
|
-
object({
|
|
27351
|
+
var NativeObjectDetectionStatusSchema = object({
|
|
26994
27352
|
/**
|
|
26995
27353
|
* Last observed instance per class. Missing entries mean the class
|
|
26996
27354
|
* is supported but nothing has been seen since the provider started.
|
|
@@ -27007,14 +27365,56 @@ object({
|
|
|
27007
27365
|
supportedClasses: array(NativeObjectClassEnum).readonly(),
|
|
27008
27366
|
/**
|
|
27009
27367
|
* Whether forwarding of onboard AI detections is enabled for this device.
|
|
27010
|
-
*
|
|
27011
|
-
*
|
|
27368
|
+
*
|
|
27369
|
+
* Cold-start OFF on every camera. A vendor does not decide which detector a
|
|
27370
|
+
* camera runs — that is the operator's choice, and it is made where every
|
|
27371
|
+
* other detection choice is made. `geometry` on
|
|
27372
|
+
* {@link NativeObjectDetectionOptionsSchema} is how the form says what the
|
|
27373
|
+
* choice would buy this particular camera.
|
|
27012
27374
|
*/
|
|
27013
27375
|
enabled: boolean()
|
|
27014
|
-
})
|
|
27376
|
+
});
|
|
27377
|
+
/**
|
|
27378
|
+
* WHEN a camera's onboard detections carry geometry.
|
|
27379
|
+
*
|
|
27380
|
+
* - `boxed` — always. The provider holds a standing channel for the boxes, so
|
|
27381
|
+
* a detection is a trackable subject whenever the camera sees one.
|
|
27382
|
+
* - `while-streaming` — only while some stream of this camera is already open.
|
|
27383
|
+
* The boxes ride the video's own side-channel rather than a feed of their
|
|
27384
|
+
* own, so they cost nothing and they exist only when something is pulling.
|
|
27385
|
+
* On Reolink this is every BATTERY camera: a persistent second feed is real
|
|
27386
|
+
* radio drain, but a camera that is awake is awake precisely because
|
|
27387
|
+
* something is in front of it, and its stream is already being pulled.
|
|
27388
|
+
* - `flag-only` — never. The firmware ships the class and nothing else; the
|
|
27389
|
+
* pipeline needs geometry to make a subject, so such a push reaches the
|
|
27390
|
+
* tracker as motion and no further.
|
|
27391
|
+
*
|
|
27392
|
+
* Reported and not inferred, because the operator's question in front of the
|
|
27393
|
+
* picker is "what do I get", and these are three different answers (D14: the
|
|
27394
|
+
* derived form is only as honest as `getOptions`).
|
|
27395
|
+
*/
|
|
27396
|
+
var NativeObjectGeometryEnum = _enum([
|
|
27397
|
+
"boxed",
|
|
27398
|
+
"while-streaming",
|
|
27399
|
+
"flag-only"
|
|
27400
|
+
]);
|
|
27401
|
+
var NativeObjectDetectionOptionsSchema = object({
|
|
27402
|
+
/** Classes this firmware can detect — the same list the status reports. */
|
|
27403
|
+
supportedClasses: array(NativeObjectClassEnum).readonly(),
|
|
27404
|
+
/** What a detection from this camera carries. */
|
|
27405
|
+
geometry: NativeObjectGeometryEnum
|
|
27406
|
+
});
|
|
27407
|
+
var NativeObjectDetectionSettingsPatchSchema = object({ enabled: boolean().optional() });
|
|
27408
|
+
NativeObjectDetectionStatusSchema.extend({
|
|
27015
27409
|
/** Required by createRuntimeStateBridge — epoch ms of last refresh. */
|
|
27016
27410
|
lastFetchedAt: number() });
|
|
27017
|
-
DeviceType.Camera, method(object({
|
|
27411
|
+
DeviceType.Camera, method(object({ deviceId: number() }), NativeObjectDetectionOptionsSchema), method(object({
|
|
27412
|
+
deviceId: number(),
|
|
27413
|
+
settings: NativeObjectDetectionSettingsPatchSchema
|
|
27414
|
+
}), _void(), {
|
|
27415
|
+
kind: "mutation",
|
|
27416
|
+
auth: "admin"
|
|
27417
|
+
}), method(object({
|
|
27018
27418
|
deviceId: number(),
|
|
27019
27419
|
enabled: boolean()
|
|
27020
27420
|
}), _void(), {
|
|
@@ -30813,7 +31213,28 @@ var PerScopeBreakdownSchema = object({
|
|
|
30813
31213
|
/** Total tracked objects in this scope (frame / zone / unzoned). */
|
|
30814
31214
|
totalObjects: number().int().nonnegative(),
|
|
30815
31215
|
/** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
|
|
30816
|
-
byClass: record(string(), number().int().nonnegative())
|
|
31216
|
+
byClass: record(string(), number().int().nonnegative()),
|
|
31217
|
+
/**
|
|
31218
|
+
* Of `totalObjects`, how many are STANDING — objects the census holds,
|
|
31219
|
+
* present but not going anywhere (a parked car, a bin, a statue).
|
|
31220
|
+
*
|
|
31221
|
+
* The operator's question about a scene has two halves and they answer
|
|
31222
|
+
* different things: "what is parked here" is stable for hours and must not
|
|
31223
|
+
* flap, while "who is walking through" is the thing an alert is about. One
|
|
31224
|
+
* number could only serve one of them, and `totalObjects` served the first
|
|
31225
|
+
* badly — a passer-by moved it and every occupancy rule saw a change.
|
|
31226
|
+
*
|
|
31227
|
+
* `standing + transient === totalObjects`, always. The total keeps its old
|
|
31228
|
+
* meaning exactly, so no rule written against it changes behaviour.
|
|
31229
|
+
*
|
|
31230
|
+
* ABSENT means UNKNOWN, never zero (D393): a snapshot from a node older than
|
|
31231
|
+
* this field cannot say, and a reader that folds absence in as 0 reports "no
|
|
31232
|
+
* parked objects here" about a camera whose lot is full.
|
|
31233
|
+
*/
|
|
31234
|
+
standingObjects: number().int().nonnegative().optional(),
|
|
31235
|
+
/** Of `totalObjects`, how many are passing through. See `standingObjects` —
|
|
31236
|
+
* same absence rule. */
|
|
31237
|
+
transientObjects: number().int().nonnegative().optional()
|
|
30817
31238
|
});
|
|
30818
31239
|
var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
|
|
30819
31240
|
zoneId: string(),
|
|
@@ -33752,12 +34173,24 @@ Object.freeze({
|
|
|
33752
34173
|
addonId: null,
|
|
33753
34174
|
access: "create"
|
|
33754
34175
|
},
|
|
34176
|
+
"nativeObjectDetection.getOptions": {
|
|
34177
|
+
capName: "native-object-detection",
|
|
34178
|
+
capScope: "device",
|
|
34179
|
+
addonId: null,
|
|
34180
|
+
access: "view"
|
|
34181
|
+
},
|
|
33755
34182
|
"nativeObjectDetection.setEnabled": {
|
|
33756
34183
|
capName: "native-object-detection",
|
|
33757
34184
|
capScope: "device",
|
|
33758
34185
|
addonId: null,
|
|
33759
34186
|
access: "create"
|
|
33760
34187
|
},
|
|
34188
|
+
"nativeObjectDetection.setSettings": {
|
|
34189
|
+
capName: "native-object-detection",
|
|
34190
|
+
capScope: "device",
|
|
34191
|
+
addonId: null,
|
|
34192
|
+
access: "create"
|
|
34193
|
+
},
|
|
33761
34194
|
"navigation.getFeatures": {
|
|
33762
34195
|
capName: "navigation",
|
|
33763
34196
|
capScope: "device",
|
|
@@ -34022,6 +34455,12 @@ Object.freeze({
|
|
|
34022
34455
|
addonId: null,
|
|
34023
34456
|
access: "view"
|
|
34024
34457
|
},
|
|
34458
|
+
"notificationRules.getClipRetentionAsks": {
|
|
34459
|
+
capName: "notification-rules",
|
|
34460
|
+
capScope: "system",
|
|
34461
|
+
addonId: null,
|
|
34462
|
+
access: "view"
|
|
34463
|
+
},
|
|
34025
34464
|
"notificationRules.getConditionCatalog": {
|
|
34026
34465
|
capName: "notification-rules",
|
|
34027
34466
|
capScope: "system",
|
|
@@ -34532,6 +34971,12 @@ Object.freeze({
|
|
|
34532
34971
|
addonId: null,
|
|
34533
34972
|
access: "create"
|
|
34534
34973
|
},
|
|
34974
|
+
"pipelineAnalytics.readLongTermSeries": {
|
|
34975
|
+
capName: "pipeline-analytics",
|
|
34976
|
+
capScope: "device",
|
|
34977
|
+
addonId: null,
|
|
34978
|
+
access: "view"
|
|
34979
|
+
},
|
|
34535
34980
|
"pipelineAnalytics.rebuildObjectEmbeddings": {
|
|
34536
34981
|
capName: "pipeline-analytics",
|
|
34537
34982
|
capScope: "device",
|
|
@@ -35204,6 +35649,12 @@ Object.freeze({
|
|
|
35204
35649
|
addonId: null,
|
|
35205
35650
|
access: "create"
|
|
35206
35651
|
},
|
|
35652
|
+
"pipelineRunner.requestOccupancyBurst": {
|
|
35653
|
+
capName: "pipeline-runner",
|
|
35654
|
+
capScope: "system",
|
|
35655
|
+
addonId: null,
|
|
35656
|
+
access: "create"
|
|
35657
|
+
},
|
|
35207
35658
|
"pipelineRunner.runDetailSubtree": {
|
|
35208
35659
|
capName: "pipeline-runner",
|
|
35209
35660
|
capScope: "system",
|
|
@@ -36416,6 +36867,12 @@ Object.freeze({
|
|
|
36416
36867
|
addonId: null,
|
|
36417
36868
|
access: "create"
|
|
36418
36869
|
},
|
|
36870
|
+
"streamBroker.reportStreamSignal": {
|
|
36871
|
+
capName: "stream-broker",
|
|
36872
|
+
capScope: "system",
|
|
36873
|
+
addonId: null,
|
|
36874
|
+
access: "create"
|
|
36875
|
+
},
|
|
36419
36876
|
"streamBroker.restartProfile": {
|
|
36420
36877
|
capName: "stream-broker",
|
|
36421
36878
|
capScope: "system",
|
|
@@ -36500,6 +36957,12 @@ Object.freeze({
|
|
|
36500
36957
|
addonId: null,
|
|
36501
36958
|
access: "create"
|
|
36502
36959
|
},
|
|
36960
|
+
"streamSignals.listSignals": {
|
|
36961
|
+
capName: "stream-signals",
|
|
36962
|
+
capScope: "device",
|
|
36963
|
+
addonId: null,
|
|
36964
|
+
access: "view"
|
|
36965
|
+
},
|
|
36503
36966
|
"switch.setState": {
|
|
36504
36967
|
capName: "switch",
|
|
36505
36968
|
capScope: "device",
|
|
@@ -37972,11 +38435,21 @@ Object.freeze({
|
|
|
37972
38435
|
form: "single",
|
|
37973
38436
|
optional: false
|
|
37974
38437
|
}],
|
|
38438
|
+
"nativeObjectDetection.getOptions": [{
|
|
38439
|
+
name: "deviceId",
|
|
38440
|
+
form: "single",
|
|
38441
|
+
optional: false
|
|
38442
|
+
}],
|
|
37975
38443
|
"nativeObjectDetection.setEnabled": [{
|
|
37976
38444
|
name: "deviceId",
|
|
37977
38445
|
form: "single",
|
|
37978
38446
|
optional: false
|
|
37979
38447
|
}],
|
|
38448
|
+
"nativeObjectDetection.setSettings": [{
|
|
38449
|
+
name: "deviceId",
|
|
38450
|
+
form: "single",
|
|
38451
|
+
optional: false
|
|
38452
|
+
}],
|
|
37980
38453
|
"navigation.getFeatures": [{
|
|
37981
38454
|
name: "deviceId",
|
|
37982
38455
|
form: "single",
|
|
@@ -38326,6 +38799,11 @@ Object.freeze({
|
|
|
38326
38799
|
form: "single",
|
|
38327
38800
|
optional: false
|
|
38328
38801
|
}],
|
|
38802
|
+
"pipelineAnalytics.readLongTermSeries": [{
|
|
38803
|
+
name: "deviceId",
|
|
38804
|
+
form: "single",
|
|
38805
|
+
optional: false
|
|
38806
|
+
}],
|
|
38329
38807
|
"pipelineAnalytics.rebuildObjectEmbeddings": [{
|
|
38330
38808
|
name: "deviceId",
|
|
38331
38809
|
form: "single",
|
|
@@ -38516,6 +38994,11 @@ Object.freeze({
|
|
|
38516
38994
|
form: "single",
|
|
38517
38995
|
optional: false
|
|
38518
38996
|
}],
|
|
38997
|
+
"pipelineRunner.requestOccupancyBurst": [{
|
|
38998
|
+
name: "deviceId",
|
|
38999
|
+
form: "single",
|
|
39000
|
+
optional: false
|
|
39001
|
+
}],
|
|
38519
39002
|
"pipelineRunner.runDetailSubtree": [{
|
|
38520
39003
|
name: "deviceId",
|
|
38521
39004
|
form: "single",
|
|
@@ -38872,6 +39355,11 @@ Object.freeze({
|
|
|
38872
39355
|
form: "single",
|
|
38873
39356
|
optional: false
|
|
38874
39357
|
}],
|
|
39358
|
+
"streamBroker.reportStreamSignal": [{
|
|
39359
|
+
name: "deviceId",
|
|
39360
|
+
form: "single",
|
|
39361
|
+
optional: false
|
|
39362
|
+
}],
|
|
38875
39363
|
"streamBroker.restartProfile": [{
|
|
38876
39364
|
name: "deviceId",
|
|
38877
39365
|
form: "single",
|
|
@@ -39702,7 +40190,7 @@ async function acquireAlexaEgress(input) {
|
|
|
39702
40190
|
profile: sourceProfile
|
|
39703
40191
|
},
|
|
39704
40192
|
encode: ALEXA_EGRESS_PROFILE,
|
|
39705
|
-
rateControl: "
|
|
40193
|
+
rateControl: "tight",
|
|
39706
40194
|
publishLocally: true,
|
|
39707
40195
|
tag: `alexa/${sessionId}`
|
|
39708
40196
|
});
|
|
@@ -39726,6 +40214,7 @@ async function acquireAlexaEgress(input) {
|
|
|
39726
40214
|
meta: {
|
|
39727
40215
|
pipelineKey: transcode.pipelineKey,
|
|
39728
40216
|
camStreamId,
|
|
40217
|
+
sourceProfile: input.sourceProfile,
|
|
39729
40218
|
encoder: transcode.encoder,
|
|
39730
40219
|
decodeHwAccel: transcode.decodeHwAccel ?? "software"
|
|
39731
40220
|
}
|
|
@@ -39759,6 +40248,512 @@ async function releaseAlexaEgress(input) {
|
|
|
39759
40248
|
}
|
|
39760
40249
|
}
|
|
39761
40250
|
//#endregion
|
|
40251
|
+
//#region src/egress/egress-handle-reaper.ts
|
|
40252
|
+
/**
|
|
40253
|
+
* Which handles should be released now.
|
|
40254
|
+
*
|
|
40255
|
+
* A session the broker says is GONE is reclaimed. A session it could not be
|
|
40256
|
+
* asked about is KEPT — a read that failed is not evidence of death, and
|
|
40257
|
+
* releasing a live session's transcode would black out a working stream to
|
|
40258
|
+
* save a process (D393, read in the direction that destroys work).
|
|
40259
|
+
*/
|
|
40260
|
+
async function reapDeadEgressHandles(input) {
|
|
40261
|
+
const dead = [];
|
|
40262
|
+
for (const handle of input.held) if (await input.isAlive(handle) === false) dead.push(handle);
|
|
40263
|
+
return dead;
|
|
40264
|
+
}
|
|
40265
|
+
/** How often the sweep runs. Well under the minute Amazon requires a session
|
|
40266
|
+
* to survive, so a genuinely dead one is not transcoding for long. */
|
|
40267
|
+
var EGRESS_REAP_INTERVAL_MS = 3e4;
|
|
40268
|
+
function logReaped(logger, handle) {
|
|
40269
|
+
logger?.warn("alexa egress: session is GONE — reclaiming the transcode it still held", {
|
|
40270
|
+
tags: { deviceId: handle.deviceId },
|
|
40271
|
+
meta: {
|
|
40272
|
+
sessionId: handle.sessionId,
|
|
40273
|
+
pipelineKey: handle.pipelineKey,
|
|
40274
|
+
reason: "no SessionDisconnected directive"
|
|
40275
|
+
}
|
|
40276
|
+
});
|
|
40277
|
+
}
|
|
40278
|
+
//#endregion
|
|
40279
|
+
//#region src/egress/alexa-source-pick.ts
|
|
40280
|
+
/** Amazon's documented ceiling. */
|
|
40281
|
+
var ALEXA_MAX_LINES = 1080;
|
|
40282
|
+
/**
|
|
40283
|
+
* Which profile slot serves `camStreamId`, or `null` when none does — a read
|
|
40284
|
+
* that failed is `null` too, and the caller keeps its previous behaviour
|
|
40285
|
+
* rather than guessing a slot from an error (D393).
|
|
40286
|
+
*/
|
|
40287
|
+
async function profileForCamStream(input) {
|
|
40288
|
+
try {
|
|
40289
|
+
const slots = await input.api.streamBroker.listAllProfileSlots.query();
|
|
40290
|
+
for (const slot of slots) {
|
|
40291
|
+
if (slot.deviceId !== input.deviceId) continue;
|
|
40292
|
+
if (slot.sourceCamStreamId !== input.camStreamId) continue;
|
|
40293
|
+
if (slot.profile === "high" || slot.profile === "mid" || slot.profile === "low") return slot.profile;
|
|
40294
|
+
}
|
|
40295
|
+
return null;
|
|
40296
|
+
} catch (err) {
|
|
40297
|
+
input.logger?.warn("alexa rtc-session: could not read the profile slots", {
|
|
40298
|
+
tags: { deviceId: input.deviceId },
|
|
40299
|
+
meta: { error: errMsg(err) }
|
|
40300
|
+
});
|
|
40301
|
+
return null;
|
|
40302
|
+
}
|
|
40303
|
+
}
|
|
40304
|
+
//#endregion
|
|
40305
|
+
//#region src/handlers/endpoint-id.ts
|
|
40306
|
+
var SEPARATOR = "__";
|
|
40307
|
+
function parseCameraEndpointId(raw) {
|
|
40308
|
+
if (!raw.includes(SEPARATOR)) {
|
|
40309
|
+
const id = parseNonNegativeInt(raw);
|
|
40310
|
+
if (id === null) return {
|
|
40311
|
+
kind: "invalid",
|
|
40312
|
+
reason: `not a numeric deviceId: ${raw}`
|
|
40313
|
+
};
|
|
40314
|
+
return {
|
|
40315
|
+
kind: "camera",
|
|
40316
|
+
deviceId: id
|
|
40317
|
+
};
|
|
40318
|
+
}
|
|
40319
|
+
const [head, tail, ...rest] = raw.split(SEPARATOR);
|
|
40320
|
+
if (head === void 0 || tail === void 0 || rest.length > 0) return {
|
|
40321
|
+
kind: "invalid",
|
|
40322
|
+
reason: `unexpected segments in endpointId: ${raw}`
|
|
40323
|
+
};
|
|
40324
|
+
const parent = parseNonNegativeInt(head);
|
|
40325
|
+
if (parent === null) return {
|
|
40326
|
+
kind: "invalid",
|
|
40327
|
+
reason: `parent segment not numeric: ${raw}`
|
|
40328
|
+
};
|
|
40329
|
+
if (tail === "autotrack") return {
|
|
40330
|
+
kind: "autotrack",
|
|
40331
|
+
parentDeviceId: parent
|
|
40332
|
+
};
|
|
40333
|
+
if (tail === "privacy") return {
|
|
40334
|
+
kind: "privacy",
|
|
40335
|
+
parentDeviceId: parent
|
|
40336
|
+
};
|
|
40337
|
+
if (tail === "alarm") return {
|
|
40338
|
+
kind: "alarm-panel",
|
|
40339
|
+
deviceId: parent
|
|
40340
|
+
};
|
|
40341
|
+
const child = parseNonNegativeInt(tail);
|
|
40342
|
+
if (child === null) return {
|
|
40343
|
+
kind: "invalid",
|
|
40344
|
+
reason: `child segment not numeric and not 'autotrack'/'privacy'/'alarm': ${raw}`
|
|
40345
|
+
};
|
|
40346
|
+
return {
|
|
40347
|
+
kind: "child",
|
|
40348
|
+
parentDeviceId: parent,
|
|
40349
|
+
childDeviceId: child
|
|
40350
|
+
};
|
|
40351
|
+
}
|
|
40352
|
+
function parseNonNegativeInt(s) {
|
|
40353
|
+
if (!/^\d+$/.test(s)) return null;
|
|
40354
|
+
const n = Number.parseInt(s, 10);
|
|
40355
|
+
if (!Number.isFinite(n) || n < 0) return null;
|
|
40356
|
+
return n;
|
|
40357
|
+
}
|
|
40358
|
+
//#endregion
|
|
40359
|
+
//#region src/handlers/error-envelope.ts
|
|
40360
|
+
/**
|
|
40361
|
+
* Shared Alexa `Alexa/ErrorResponse` envelope builder.
|
|
40362
|
+
*
|
|
40363
|
+
* Lives in its own module so every handler (and the directive router)
|
|
40364
|
+
* can emit its own namespace-appropriate error without circular imports.
|
|
40365
|
+
*/
|
|
40366
|
+
object({ event: object({
|
|
40367
|
+
header: object({
|
|
40368
|
+
namespace: literal("Alexa"),
|
|
40369
|
+
name: literal("ErrorResponse"),
|
|
40370
|
+
messageId: string(),
|
|
40371
|
+
payloadVersion: literal("3")
|
|
40372
|
+
}),
|
|
40373
|
+
payload: object({
|
|
40374
|
+
type: string(),
|
|
40375
|
+
message: string()
|
|
40376
|
+
})
|
|
40377
|
+
}) });
|
|
40378
|
+
function errorEnvelope(header, type, message) {
|
|
40379
|
+
return { event: {
|
|
40380
|
+
header: {
|
|
40381
|
+
namespace: "Alexa",
|
|
40382
|
+
name: "ErrorResponse",
|
|
40383
|
+
messageId: header.messageId,
|
|
40384
|
+
payloadVersion: "3"
|
|
40385
|
+
},
|
|
40386
|
+
payload: {
|
|
40387
|
+
type,
|
|
40388
|
+
message
|
|
40389
|
+
}
|
|
40390
|
+
} };
|
|
40391
|
+
}
|
|
40392
|
+
//#endregion
|
|
40393
|
+
//#region src/handlers/types.ts
|
|
40394
|
+
/**
|
|
40395
|
+
* Zod schemas for the subset of the Alexa Smart Home directive surface
|
|
40396
|
+
* that we actually parse hub-side. The Smart Home Skill API ships
|
|
40397
|
+
* thousands of optional fields per namespace — we only validate what
|
|
40398
|
+
* we read; everything else passes through as `unknown`.
|
|
40399
|
+
*
|
|
40400
|
+
* Reference: https://developer.amazon.com/en-US/docs/alexa/smarthome/smart-home-skill-api-message-reference.html
|
|
40401
|
+
*/
|
|
40402
|
+
/** A directive header carries namespace + name + token + correlation id. */
|
|
40403
|
+
var DirectiveHeaderSchema = object({
|
|
40404
|
+
namespace: string(),
|
|
40405
|
+
name: string(),
|
|
40406
|
+
payloadVersion: string(),
|
|
40407
|
+
messageId: string(),
|
|
40408
|
+
correlationToken: string().optional()
|
|
40409
|
+
});
|
|
40410
|
+
/** Scope carries the bearer token Alexa minted via our OAuth provider. */
|
|
40411
|
+
var BearerScopeSchema = object({
|
|
40412
|
+
type: literal("BearerToken"),
|
|
40413
|
+
token: string()
|
|
40414
|
+
});
|
|
40415
|
+
/** Endpoint reference inside a directive payload. */
|
|
40416
|
+
var EndpointRefSchema = object({
|
|
40417
|
+
endpointId: string(),
|
|
40418
|
+
scope: BearerScopeSchema.optional(),
|
|
40419
|
+
cookie: record(string(), string()).optional()
|
|
40420
|
+
});
|
|
40421
|
+
var DiscoveryDirectiveSchema = object({
|
|
40422
|
+
header: DirectiveHeaderSchema.extend({
|
|
40423
|
+
namespace: literal("Alexa.Discovery"),
|
|
40424
|
+
name: literal("Discover")
|
|
40425
|
+
}),
|
|
40426
|
+
payload: object({ scope: BearerScopeSchema })
|
|
40427
|
+
});
|
|
40428
|
+
/** Envelope wrapping a directive on the Alexa→skill wire. */
|
|
40429
|
+
var DirectiveRequestSchema = object({ directive: object({
|
|
40430
|
+
header: DirectiveHeaderSchema,
|
|
40431
|
+
endpoint: EndpointRefSchema.optional(),
|
|
40432
|
+
payload: unknown().optional()
|
|
40433
|
+
}) });
|
|
40434
|
+
var InitializeCameraStreamsPayloadSchema = object({ cameraStreams: array(object({
|
|
40435
|
+
protocol: string(),
|
|
40436
|
+
resolution: object({
|
|
40437
|
+
width: number(),
|
|
40438
|
+
height: number()
|
|
40439
|
+
}).optional(),
|
|
40440
|
+
authorizationType: string().optional(),
|
|
40441
|
+
videoCodec: string().optional(),
|
|
40442
|
+
audioCodec: string().optional()
|
|
40443
|
+
})).optional() });
|
|
40444
|
+
var InitiateSessionWithOfferPayloadSchema = object({
|
|
40445
|
+
sessionId: string(),
|
|
40446
|
+
offer: object({
|
|
40447
|
+
format: string().optional(),
|
|
40448
|
+
value: string()
|
|
40449
|
+
})
|
|
40450
|
+
});
|
|
40451
|
+
var SessionDisconnectedPayloadSchema = object({ sessionId: string() });
|
|
40452
|
+
var SetModePayloadSchema = object({ mode: string() });
|
|
40453
|
+
//#endregion
|
|
40454
|
+
//#region src/handlers/rtc-session.ts
|
|
40455
|
+
var InitiateSessionWithOfferDirectiveSchema = object({
|
|
40456
|
+
endpoint: EndpointRefSchema,
|
|
40457
|
+
payload: InitiateSessionWithOfferPayloadSchema
|
|
40458
|
+
});
|
|
40459
|
+
var SessionDisconnectedDirectiveSchema = object({
|
|
40460
|
+
endpoint: EndpointRefSchema,
|
|
40461
|
+
payload: SessionDisconnectedPayloadSchema
|
|
40462
|
+
});
|
|
40463
|
+
/**
|
|
40464
|
+
* Live egress handles, keyed by `<deviceId>/<sessionId>`.
|
|
40465
|
+
*
|
|
40466
|
+
* The primitive's refcount has no lease TTL: a holder that vanishes without
|
|
40467
|
+
* releasing leaks a child until the broker restarts (D67). This handler is a
|
|
40468
|
+
* holder, so it keeps the one piece of state that lets it not be that holder —
|
|
40469
|
+
* the `pipelineKey` its `SessionDisconnected` has to release.
|
|
40470
|
+
*
|
|
40471
|
+
* A module-level map is right here because the directive handlers are free
|
|
40472
|
+
* functions called per directive; the entry is written on a successful acquire
|
|
40473
|
+
* and deleted on the release path, both of which are the same tick as the
|
|
40474
|
+
* directive that owns it.
|
|
40475
|
+
*/
|
|
40476
|
+
var EGRESS_HANDLES = /* @__PURE__ */ new Map();
|
|
40477
|
+
function egressHandleKey(deviceId, sessionId) {
|
|
40478
|
+
return `${deviceId}/${sessionId}`;
|
|
40479
|
+
}
|
|
40480
|
+
/** Every handle currently held, for the sweep. */
|
|
40481
|
+
function heldEgressHandles() {
|
|
40482
|
+
const held = [];
|
|
40483
|
+
for (const [key, pipelineKey] of EGRESS_HANDLES) {
|
|
40484
|
+
const slash = key.indexOf("/");
|
|
40485
|
+
if (slash <= 0) continue;
|
|
40486
|
+
const deviceId = Number.parseInt(key.slice(0, slash), 10);
|
|
40487
|
+
if (!Number.isFinite(deviceId)) continue;
|
|
40488
|
+
held.push({
|
|
40489
|
+
deviceId,
|
|
40490
|
+
sessionId: key.slice(slash + 1),
|
|
40491
|
+
pipelineKey
|
|
40492
|
+
});
|
|
40493
|
+
}
|
|
40494
|
+
return held;
|
|
40495
|
+
}
|
|
40496
|
+
/** Drop a handle the sweep reclaimed, so it is not reaped twice. */
|
|
40497
|
+
function forgetEgressHandle(handle) {
|
|
40498
|
+
EGRESS_HANDLES.delete(egressHandleKey(handle.deviceId, handle.sessionId));
|
|
40499
|
+
}
|
|
40500
|
+
var WEBRTC_PROFILE_NAMES = [
|
|
40501
|
+
"high",
|
|
40502
|
+
"mid",
|
|
40503
|
+
"low"
|
|
40504
|
+
];
|
|
40505
|
+
function resolveWebrtcTarget(pref) {
|
|
40506
|
+
if (pref === "auto") return void 0;
|
|
40507
|
+
if (WEBRTC_PROFILE_NAMES.includes(pref)) return {
|
|
40508
|
+
kind: "profile",
|
|
40509
|
+
profile: pref
|
|
40510
|
+
};
|
|
40511
|
+
return {
|
|
40512
|
+
kind: "cam-stream",
|
|
40513
|
+
camStreamId: pref
|
|
40514
|
+
};
|
|
40515
|
+
}
|
|
40516
|
+
async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
|
|
40517
|
+
const parsed = InitiateSessionWithOfferDirectiveSchema.safeParse({
|
|
40518
|
+
endpoint: endpointRaw,
|
|
40519
|
+
payload: payloadRaw ?? {}
|
|
40520
|
+
});
|
|
40521
|
+
if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
|
|
40522
|
+
const endpoint = parsed.data.endpoint;
|
|
40523
|
+
const payload = parsed.data.payload;
|
|
40524
|
+
const parsedId = parseCameraEndpointId(endpoint.endpointId);
|
|
40525
|
+
if (parsedId.kind !== "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", `endpointId ${endpoint.endpointId} is not a camera`);
|
|
40526
|
+
const deviceId = parsedId.deviceId;
|
|
40527
|
+
const sessionId = payload.sessionId;
|
|
40528
|
+
const log = deps.logger.withTags({
|
|
40529
|
+
deviceId,
|
|
40530
|
+
sessionId
|
|
40531
|
+
});
|
|
40532
|
+
const sdpOffer = payload.offer.value;
|
|
40533
|
+
if (sdpOffer.length === 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "offer.value is empty");
|
|
40534
|
+
const api = deps.ctx.api;
|
|
40535
|
+
const exposedEntry = deps.listExposed().find((e) => e.deviceId === deviceId);
|
|
40536
|
+
const pinnedProfile = (() => {
|
|
40537
|
+
const pref = exposedEntry?.settings?.streamPreference;
|
|
40538
|
+
if (pref === "low" || pref === "mid" || pref === "high") return pref;
|
|
40539
|
+
})();
|
|
40540
|
+
const allowTranscode = exposedEntry?.settings?.allowTranscode !== false;
|
|
40541
|
+
const scored = pinnedProfile === void 0 ? await api.cameraStreams.pickStream.query({
|
|
40542
|
+
deviceId,
|
|
40543
|
+
requirements: {
|
|
40544
|
+
acceptCodecs: ["h264"],
|
|
40545
|
+
minHeight: 480,
|
|
40546
|
+
maxHeight: ALEXA_MAX_LINES,
|
|
40547
|
+
excludeDerived: true,
|
|
40548
|
+
allowTranscode
|
|
40549
|
+
},
|
|
40550
|
+
preferences: { targetHeight: 720 }
|
|
40551
|
+
}).catch((err) => {
|
|
40552
|
+
log.warn("alexa rtc-session: pickStream threw — falling back to the high slot", {
|
|
40553
|
+
tags: { deviceId },
|
|
40554
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
40555
|
+
});
|
|
40556
|
+
return null;
|
|
40557
|
+
}) : null;
|
|
40558
|
+
const scoredProfile = scored === null ? null : await profileForCamStream({
|
|
40559
|
+
api,
|
|
40560
|
+
deviceId,
|
|
40561
|
+
camStreamId: scored.camStreamId,
|
|
40562
|
+
logger: log
|
|
40563
|
+
});
|
|
40564
|
+
const sourceProfile = pinnedProfile ?? scoredProfile ?? "high";
|
|
40565
|
+
if (pinnedProfile === void 0) log.info("alexa rtc-session: source scored against the Echo requirements", {
|
|
40566
|
+
tags: { deviceId },
|
|
40567
|
+
meta: {
|
|
40568
|
+
chosen: sourceProfile,
|
|
40569
|
+
scored: scored === null ? "none-eligible" : scored.reason,
|
|
40570
|
+
camStreamId: scored?.camStreamId ?? null,
|
|
40571
|
+
slot: scoredProfile ?? (scored === null ? null : "no-slot"),
|
|
40572
|
+
height: scored?.resolution?.height ?? null,
|
|
40573
|
+
transcodes: scored?.transcodes ?? null,
|
|
40574
|
+
allowTranscode
|
|
40575
|
+
}
|
|
40576
|
+
});
|
|
40577
|
+
let derivedTarget;
|
|
40578
|
+
/**
|
|
40579
|
+
* The primitive's release handle, when this session acquired one. Recorded so
|
|
40580
|
+
* `SessionDisconnected` can release it: a refcount whose holder disappears is
|
|
40581
|
+
* the one gap D67 leaves open, and the least this handler can do is not BE
|
|
40582
|
+
* that holder.
|
|
40583
|
+
*/
|
|
40584
|
+
let egressPipelineKey = null;
|
|
40585
|
+
const picked = await api.cameraStreams.pickStream.query({
|
|
40586
|
+
deviceId,
|
|
40587
|
+
requirements: {
|
|
40588
|
+
acceptCodecs: ["h264"],
|
|
40589
|
+
minHeight: 480,
|
|
40590
|
+
excludeDerived: true,
|
|
40591
|
+
requireSiblingCodec: ["h265", "hevc"]
|
|
40592
|
+
}
|
|
40593
|
+
}).catch((err) => {
|
|
40594
|
+
log.warn("alexa rtc-session: cameraStreams.pickStream threw — falling back to transcode", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
40595
|
+
return null;
|
|
40596
|
+
});
|
|
40597
|
+
if (picked) {
|
|
40598
|
+
log.info("alexa rtc-session: bypassing transcode via cameraStreams.pickStream", { meta: {
|
|
40599
|
+
sourceProfile,
|
|
40600
|
+
selectedCamStreamId: picked.camStreamId,
|
|
40601
|
+
reason: picked.reason
|
|
40602
|
+
} });
|
|
40603
|
+
derivedTarget = {
|
|
40604
|
+
kind: "cam-stream",
|
|
40605
|
+
camStreamId: picked.camStreamId
|
|
40606
|
+
};
|
|
40607
|
+
} else try {
|
|
40608
|
+
const acquired = await acquireAlexaEgress({
|
|
40609
|
+
api,
|
|
40610
|
+
deviceId,
|
|
40611
|
+
sourceProfile,
|
|
40612
|
+
sessionId,
|
|
40613
|
+
logger: log
|
|
40614
|
+
});
|
|
40615
|
+
egressPipelineKey = acquired.pipelineKey;
|
|
40616
|
+
EGRESS_HANDLES.set(egressHandleKey(deviceId, sessionId), acquired.pipelineKey);
|
|
40617
|
+
derivedTarget = {
|
|
40618
|
+
kind: "cam-stream",
|
|
40619
|
+
camStreamId: acquired.camStreamId
|
|
40620
|
+
};
|
|
40621
|
+
} catch (err) {
|
|
40622
|
+
log.warn("alexa rtc-session: egress acquire failed, falling back to streamPreference target", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
40623
|
+
derivedTarget = resolveWebrtcTarget(exposedEntry?.settings?.streamPreference ?? "auto");
|
|
40624
|
+
}
|
|
40625
|
+
try {
|
|
40626
|
+
const result = await api.webrtcSession.handleOffer.mutate({
|
|
40627
|
+
deviceId,
|
|
40628
|
+
sdpOffer,
|
|
40629
|
+
sessionId,
|
|
40630
|
+
target: derivedTarget,
|
|
40631
|
+
relayOnly: false,
|
|
40632
|
+
nonTrickle: true,
|
|
40633
|
+
disableTurn: true,
|
|
40634
|
+
disableIpv6: true,
|
|
40635
|
+
consumerAttribution: {
|
|
40636
|
+
kind: "alexa",
|
|
40637
|
+
sessionId
|
|
40638
|
+
}
|
|
40639
|
+
});
|
|
40640
|
+
log.info("alexa rtc-session: AnswerGenerated", { meta: {
|
|
40641
|
+
returnedSessionId: result.sessionId,
|
|
40642
|
+
sdpAnswerLen: result.sdpAnswer.length
|
|
40643
|
+
} });
|
|
40644
|
+
return { event: {
|
|
40645
|
+
header: {
|
|
40646
|
+
namespace: "Alexa.RTCSessionController",
|
|
40647
|
+
name: "AnswerGeneratedForSession",
|
|
40648
|
+
messageId: header.messageId,
|
|
40649
|
+
correlationToken: header.correlationToken,
|
|
40650
|
+
payloadVersion: "3"
|
|
40651
|
+
},
|
|
40652
|
+
endpoint: { endpointId: endpoint.endpointId },
|
|
40653
|
+
payload: { answer: {
|
|
40654
|
+
format: payload.offer.format ?? "SDP",
|
|
40655
|
+
value: result.sdpAnswer
|
|
40656
|
+
} }
|
|
40657
|
+
} };
|
|
40658
|
+
} catch (err) {
|
|
40659
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
40660
|
+
const mSectionSummary = (() => {
|
|
40661
|
+
const lines = sdpOffer.split(/\r?\n/);
|
|
40662
|
+
const sections = [];
|
|
40663
|
+
let current = null;
|
|
40664
|
+
for (const raw of lines) {
|
|
40665
|
+
const line = raw.trim();
|
|
40666
|
+
if (line.startsWith("m=")) {
|
|
40667
|
+
if (current) sections.push(current);
|
|
40668
|
+
current = {
|
|
40669
|
+
mid: null,
|
|
40670
|
+
kind: line.split(" ")[0]?.slice(2) ?? "unknown",
|
|
40671
|
+
direction: null,
|
|
40672
|
+
codecs: []
|
|
40673
|
+
};
|
|
40674
|
+
} else if (current) {
|
|
40675
|
+
if (line.startsWith("a=mid:")) current.mid = line.slice(6);
|
|
40676
|
+
else if (/^a=(sendonly|recvonly|sendrecv|inactive)$/.test(line)) current.direction = line.slice(2);
|
|
40677
|
+
else if (line.startsWith("a=rtpmap:")) current.codecs.push(line.slice(9));
|
|
40678
|
+
}
|
|
40679
|
+
}
|
|
40680
|
+
if (current) sections.push(current);
|
|
40681
|
+
return sections;
|
|
40682
|
+
})();
|
|
40683
|
+
log.warn("alexa rtc-session: handleOffer failed", { meta: {
|
|
40684
|
+
error: message,
|
|
40685
|
+
offerLen: sdpOffer.length,
|
|
40686
|
+
mSections: mSectionSummary,
|
|
40687
|
+
offerSdp: sdpOffer
|
|
40688
|
+
} });
|
|
40689
|
+
if (egressPipelineKey !== null) {
|
|
40690
|
+
EGRESS_HANDLES.delete(egressHandleKey(deviceId, sessionId));
|
|
40691
|
+
await releaseAlexaEgress({
|
|
40692
|
+
api,
|
|
40693
|
+
deviceId,
|
|
40694
|
+
pipelineKey: egressPipelineKey,
|
|
40695
|
+
logger: log
|
|
40696
|
+
});
|
|
40697
|
+
}
|
|
40698
|
+
return errorEnvelope(header, "INTERNAL_ERROR", `webrtc-session.handleOffer failed: ${message}`);
|
|
40699
|
+
}
|
|
40700
|
+
}
|
|
40701
|
+
async function handleSessionDisconnected(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
|
|
40702
|
+
const parsed = SessionDisconnectedDirectiveSchema.safeParse({
|
|
40703
|
+
endpoint: endpointRaw,
|
|
40704
|
+
payload: payloadRaw ?? {}
|
|
40705
|
+
});
|
|
40706
|
+
if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
|
|
40707
|
+
const endpoint = parsed.data.endpoint;
|
|
40708
|
+
const parsedId = parseCameraEndpointId(endpoint.endpointId);
|
|
40709
|
+
if (parsedId.kind === "camera") {
|
|
40710
|
+
const handleKey = egressHandleKey(parsedId.deviceId, parsed.data.payload.sessionId);
|
|
40711
|
+
const pipelineKey = EGRESS_HANDLES.get(handleKey);
|
|
40712
|
+
if (pipelineKey !== void 0) {
|
|
40713
|
+
EGRESS_HANDLES.delete(handleKey);
|
|
40714
|
+
await releaseAlexaEgress({
|
|
40715
|
+
api: deps.ctx.api,
|
|
40716
|
+
deviceId: parsedId.deviceId,
|
|
40717
|
+
pipelineKey,
|
|
40718
|
+
logger: deps.logger
|
|
40719
|
+
});
|
|
40720
|
+
}
|
|
40721
|
+
try {
|
|
40722
|
+
await deps.ctx.api.webrtcSession.closeSession.mutate({
|
|
40723
|
+
deviceId: parsedId.deviceId,
|
|
40724
|
+
sessionId: parsed.data.payload.sessionId
|
|
40725
|
+
});
|
|
40726
|
+
} catch (err) {
|
|
40727
|
+
deps.logger.warn("alexa rtc-session: closeSession threw on disconnect (continuing to ack)", {
|
|
40728
|
+
tags: {
|
|
40729
|
+
deviceId: parsedId.deviceId,
|
|
40730
|
+
sessionId: parsed.data.payload.sessionId
|
|
40731
|
+
},
|
|
40732
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
40733
|
+
});
|
|
40734
|
+
}
|
|
40735
|
+
}
|
|
40736
|
+
const disconnectedDeviceId = parsedId.kind === "camera" ? parsedId.deviceId : void 0;
|
|
40737
|
+
deps.logger.info("alexa rtc-session: SessionDisconnected", {
|
|
40738
|
+
tags: {
|
|
40739
|
+
sessionId: parsed.data.payload.sessionId,
|
|
40740
|
+
...disconnectedDeviceId !== void 0 ? { deviceId: disconnectedDeviceId } : {}
|
|
40741
|
+
},
|
|
40742
|
+
meta: { endpointId: endpoint.endpointId }
|
|
40743
|
+
});
|
|
40744
|
+
return { event: {
|
|
40745
|
+
header: {
|
|
40746
|
+
namespace: "Alexa",
|
|
40747
|
+
name: "Response",
|
|
40748
|
+
messageId: header.messageId,
|
|
40749
|
+
correlationToken: header.correlationToken,
|
|
40750
|
+
payloadVersion: "3"
|
|
40751
|
+
},
|
|
40752
|
+
endpoint: { endpointId: endpoint.endpointId },
|
|
40753
|
+
payload: {}
|
|
40754
|
+
} };
|
|
40755
|
+
}
|
|
40756
|
+
//#endregion
|
|
39762
40757
|
//#region src/handlers/security-panel-arm-state.ts
|
|
39763
40758
|
/**
|
|
39764
40759
|
* The panel modes behind each Alexa arm state, PRIMARY first — the mirror of
|
|
@@ -40189,101 +41184,6 @@ function buildAlarmPanelEndpoint(input) {
|
|
|
40189
41184
|
};
|
|
40190
41185
|
}
|
|
40191
41186
|
//#endregion
|
|
40192
|
-
//#region src/handlers/error-envelope.ts
|
|
40193
|
-
/**
|
|
40194
|
-
* Shared Alexa `Alexa/ErrorResponse` envelope builder.
|
|
40195
|
-
*
|
|
40196
|
-
* Lives in its own module so every handler (and the directive router)
|
|
40197
|
-
* can emit its own namespace-appropriate error without circular imports.
|
|
40198
|
-
*/
|
|
40199
|
-
object({ event: object({
|
|
40200
|
-
header: object({
|
|
40201
|
-
namespace: literal("Alexa"),
|
|
40202
|
-
name: literal("ErrorResponse"),
|
|
40203
|
-
messageId: string(),
|
|
40204
|
-
payloadVersion: literal("3")
|
|
40205
|
-
}),
|
|
40206
|
-
payload: object({
|
|
40207
|
-
type: string(),
|
|
40208
|
-
message: string()
|
|
40209
|
-
})
|
|
40210
|
-
}) });
|
|
40211
|
-
function errorEnvelope(header, type, message) {
|
|
40212
|
-
return { event: {
|
|
40213
|
-
header: {
|
|
40214
|
-
namespace: "Alexa",
|
|
40215
|
-
name: "ErrorResponse",
|
|
40216
|
-
messageId: header.messageId,
|
|
40217
|
-
payloadVersion: "3"
|
|
40218
|
-
},
|
|
40219
|
-
payload: {
|
|
40220
|
-
type,
|
|
40221
|
-
message
|
|
40222
|
-
}
|
|
40223
|
-
} };
|
|
40224
|
-
}
|
|
40225
|
-
//#endregion
|
|
40226
|
-
//#region src/handlers/types.ts
|
|
40227
|
-
/**
|
|
40228
|
-
* Zod schemas for the subset of the Alexa Smart Home directive surface
|
|
40229
|
-
* that we actually parse hub-side. The Smart Home Skill API ships
|
|
40230
|
-
* thousands of optional fields per namespace — we only validate what
|
|
40231
|
-
* we read; everything else passes through as `unknown`.
|
|
40232
|
-
*
|
|
40233
|
-
* Reference: https://developer.amazon.com/en-US/docs/alexa/smarthome/smart-home-skill-api-message-reference.html
|
|
40234
|
-
*/
|
|
40235
|
-
/** A directive header carries namespace + name + token + correlation id. */
|
|
40236
|
-
var DirectiveHeaderSchema = object({
|
|
40237
|
-
namespace: string(),
|
|
40238
|
-
name: string(),
|
|
40239
|
-
payloadVersion: string(),
|
|
40240
|
-
messageId: string(),
|
|
40241
|
-
correlationToken: string().optional()
|
|
40242
|
-
});
|
|
40243
|
-
/** Scope carries the bearer token Alexa minted via our OAuth provider. */
|
|
40244
|
-
var BearerScopeSchema = object({
|
|
40245
|
-
type: literal("BearerToken"),
|
|
40246
|
-
token: string()
|
|
40247
|
-
});
|
|
40248
|
-
/** Endpoint reference inside a directive payload. */
|
|
40249
|
-
var EndpointRefSchema = object({
|
|
40250
|
-
endpointId: string(),
|
|
40251
|
-
scope: BearerScopeSchema.optional(),
|
|
40252
|
-
cookie: record(string(), string()).optional()
|
|
40253
|
-
});
|
|
40254
|
-
var DiscoveryDirectiveSchema = object({
|
|
40255
|
-
header: DirectiveHeaderSchema.extend({
|
|
40256
|
-
namespace: literal("Alexa.Discovery"),
|
|
40257
|
-
name: literal("Discover")
|
|
40258
|
-
}),
|
|
40259
|
-
payload: object({ scope: BearerScopeSchema })
|
|
40260
|
-
});
|
|
40261
|
-
/** Envelope wrapping a directive on the Alexa→skill wire. */
|
|
40262
|
-
var DirectiveRequestSchema = object({ directive: object({
|
|
40263
|
-
header: DirectiveHeaderSchema,
|
|
40264
|
-
endpoint: EndpointRefSchema.optional(),
|
|
40265
|
-
payload: unknown().optional()
|
|
40266
|
-
}) });
|
|
40267
|
-
var InitializeCameraStreamsPayloadSchema = object({ cameraStreams: array(object({
|
|
40268
|
-
protocol: string(),
|
|
40269
|
-
resolution: object({
|
|
40270
|
-
width: number(),
|
|
40271
|
-
height: number()
|
|
40272
|
-
}).optional(),
|
|
40273
|
-
authorizationType: string().optional(),
|
|
40274
|
-
videoCodec: string().optional(),
|
|
40275
|
-
audioCodec: string().optional()
|
|
40276
|
-
})).optional() });
|
|
40277
|
-
var InitiateSessionWithOfferPayloadSchema = object({
|
|
40278
|
-
sessionId: string(),
|
|
40279
|
-
offer: object({
|
|
40280
|
-
format: string().optional(),
|
|
40281
|
-
value: string()
|
|
40282
|
-
})
|
|
40283
|
-
});
|
|
40284
|
-
var SessionDisconnectedPayloadSchema = object({ sessionId: string() });
|
|
40285
|
-
var SetModePayloadSchema = object({ mode: string() });
|
|
40286
|
-
//#endregion
|
|
40287
41187
|
//#region src/handlers/discovery.ts
|
|
40288
41188
|
var discoveryPayloadSchema = DiscoveryDirectiveSchema.shape.payload;
|
|
40289
41189
|
/** Devices built at once in `handleDiscovery` — latency-bound tRPC reads, so
|
|
@@ -40492,60 +41392,6 @@ function discoveryResponse(header, endpoints) {
|
|
|
40492
41392
|
} };
|
|
40493
41393
|
}
|
|
40494
41394
|
//#endregion
|
|
40495
|
-
//#region src/handlers/endpoint-id.ts
|
|
40496
|
-
var SEPARATOR = "__";
|
|
40497
|
-
function parseCameraEndpointId(raw) {
|
|
40498
|
-
if (!raw.includes(SEPARATOR)) {
|
|
40499
|
-
const id = parseNonNegativeInt(raw);
|
|
40500
|
-
if (id === null) return {
|
|
40501
|
-
kind: "invalid",
|
|
40502
|
-
reason: `not a numeric deviceId: ${raw}`
|
|
40503
|
-
};
|
|
40504
|
-
return {
|
|
40505
|
-
kind: "camera",
|
|
40506
|
-
deviceId: id
|
|
40507
|
-
};
|
|
40508
|
-
}
|
|
40509
|
-
const [head, tail, ...rest] = raw.split(SEPARATOR);
|
|
40510
|
-
if (head === void 0 || tail === void 0 || rest.length > 0) return {
|
|
40511
|
-
kind: "invalid",
|
|
40512
|
-
reason: `unexpected segments in endpointId: ${raw}`
|
|
40513
|
-
};
|
|
40514
|
-
const parent = parseNonNegativeInt(head);
|
|
40515
|
-
if (parent === null) return {
|
|
40516
|
-
kind: "invalid",
|
|
40517
|
-
reason: `parent segment not numeric: ${raw}`
|
|
40518
|
-
};
|
|
40519
|
-
if (tail === "autotrack") return {
|
|
40520
|
-
kind: "autotrack",
|
|
40521
|
-
parentDeviceId: parent
|
|
40522
|
-
};
|
|
40523
|
-
if (tail === "privacy") return {
|
|
40524
|
-
kind: "privacy",
|
|
40525
|
-
parentDeviceId: parent
|
|
40526
|
-
};
|
|
40527
|
-
if (tail === "alarm") return {
|
|
40528
|
-
kind: "alarm-panel",
|
|
40529
|
-
deviceId: parent
|
|
40530
|
-
};
|
|
40531
|
-
const child = parseNonNegativeInt(tail);
|
|
40532
|
-
if (child === null) return {
|
|
40533
|
-
kind: "invalid",
|
|
40534
|
-
reason: `child segment not numeric and not 'autotrack'/'privacy'/'alarm': ${raw}`
|
|
40535
|
-
};
|
|
40536
|
-
return {
|
|
40537
|
-
kind: "child",
|
|
40538
|
-
parentDeviceId: parent,
|
|
40539
|
-
childDeviceId: child
|
|
40540
|
-
};
|
|
40541
|
-
}
|
|
40542
|
-
function parseNonNegativeInt(s) {
|
|
40543
|
-
if (!/^\d+$/.test(s)) return null;
|
|
40544
|
-
const n = Number.parseInt(s, 10);
|
|
40545
|
-
if (!Number.isFinite(n) || n < 0) return null;
|
|
40546
|
-
return n;
|
|
40547
|
-
}
|
|
40548
|
-
//#endregion
|
|
40549
41395
|
//#region src/handlers/brightness-controller.ts
|
|
40550
41396
|
var SetBrightnessDirectiveSchema = object({
|
|
40551
41397
|
header: object({
|
|
@@ -41122,254 +41968,6 @@ async function collectChildProperties(api, childDeviceId, properties, timeOfSamp
|
|
|
41122
41968
|
});
|
|
41123
41969
|
}
|
|
41124
41970
|
//#endregion
|
|
41125
|
-
//#region src/handlers/rtc-session.ts
|
|
41126
|
-
var InitiateSessionWithOfferDirectiveSchema = object({
|
|
41127
|
-
endpoint: EndpointRefSchema,
|
|
41128
|
-
payload: InitiateSessionWithOfferPayloadSchema
|
|
41129
|
-
});
|
|
41130
|
-
var SessionDisconnectedDirectiveSchema = object({
|
|
41131
|
-
endpoint: EndpointRefSchema,
|
|
41132
|
-
payload: SessionDisconnectedPayloadSchema
|
|
41133
|
-
});
|
|
41134
|
-
/**
|
|
41135
|
-
* Live egress handles, keyed by `<deviceId>/<sessionId>`.
|
|
41136
|
-
*
|
|
41137
|
-
* The primitive's refcount has no lease TTL: a holder that vanishes without
|
|
41138
|
-
* releasing leaks a child until the broker restarts (D67). This handler is a
|
|
41139
|
-
* holder, so it keeps the one piece of state that lets it not be that holder —
|
|
41140
|
-
* the `pipelineKey` its `SessionDisconnected` has to release.
|
|
41141
|
-
*
|
|
41142
|
-
* A module-level map is right here because the directive handlers are free
|
|
41143
|
-
* functions called per directive; the entry is written on a successful acquire
|
|
41144
|
-
* and deleted on the release path, both of which are the same tick as the
|
|
41145
|
-
* directive that owns it.
|
|
41146
|
-
*/
|
|
41147
|
-
var EGRESS_HANDLES = /* @__PURE__ */ new Map();
|
|
41148
|
-
function egressHandleKey(deviceId, sessionId) {
|
|
41149
|
-
return `${deviceId}/${sessionId}`;
|
|
41150
|
-
}
|
|
41151
|
-
var WEBRTC_PROFILE_NAMES = [
|
|
41152
|
-
"high",
|
|
41153
|
-
"mid",
|
|
41154
|
-
"low"
|
|
41155
|
-
];
|
|
41156
|
-
function resolveWebrtcTarget(pref) {
|
|
41157
|
-
if (pref === "auto") return void 0;
|
|
41158
|
-
if (WEBRTC_PROFILE_NAMES.includes(pref)) return {
|
|
41159
|
-
kind: "profile",
|
|
41160
|
-
profile: pref
|
|
41161
|
-
};
|
|
41162
|
-
return {
|
|
41163
|
-
kind: "cam-stream",
|
|
41164
|
-
camStreamId: pref
|
|
41165
|
-
};
|
|
41166
|
-
}
|
|
41167
|
-
async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
|
|
41168
|
-
const parsed = InitiateSessionWithOfferDirectiveSchema.safeParse({
|
|
41169
|
-
endpoint: endpointRaw,
|
|
41170
|
-
payload: payloadRaw ?? {}
|
|
41171
|
-
});
|
|
41172
|
-
if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
|
|
41173
|
-
const endpoint = parsed.data.endpoint;
|
|
41174
|
-
const payload = parsed.data.payload;
|
|
41175
|
-
const parsedId = parseCameraEndpointId(endpoint.endpointId);
|
|
41176
|
-
if (parsedId.kind !== "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", `endpointId ${endpoint.endpointId} is not a camera`);
|
|
41177
|
-
const deviceId = parsedId.deviceId;
|
|
41178
|
-
const sessionId = payload.sessionId;
|
|
41179
|
-
const log = deps.logger.withTags({
|
|
41180
|
-
deviceId,
|
|
41181
|
-
sessionId
|
|
41182
|
-
});
|
|
41183
|
-
const sdpOffer = payload.offer.value;
|
|
41184
|
-
if (sdpOffer.length === 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "offer.value is empty");
|
|
41185
|
-
const api = deps.ctx.api;
|
|
41186
|
-
const exposedEntry = deps.listExposed().find((e) => e.deviceId === deviceId);
|
|
41187
|
-
const explicitSourceProfile = exposedEntry?.settings?.sourceProfile;
|
|
41188
|
-
const mappedFromPreference = (() => {
|
|
41189
|
-
const pref = exposedEntry?.settings?.streamPreference;
|
|
41190
|
-
if (pref === "low" || pref === "mid" || pref === "high") return pref;
|
|
41191
|
-
})();
|
|
41192
|
-
const sourceProfile = explicitSourceProfile ?? mappedFromPreference ?? "high";
|
|
41193
|
-
let derivedTarget;
|
|
41194
|
-
/**
|
|
41195
|
-
* The primitive's release handle, when this session acquired one. Recorded so
|
|
41196
|
-
* `SessionDisconnected` can release it: a refcount whose holder disappears is
|
|
41197
|
-
* the one gap D67 leaves open, and the least this handler can do is not BE
|
|
41198
|
-
* that holder.
|
|
41199
|
-
*/
|
|
41200
|
-
let egressPipelineKey = null;
|
|
41201
|
-
const picked = await api.cameraStreams.pickStream.query({
|
|
41202
|
-
deviceId,
|
|
41203
|
-
requirements: {
|
|
41204
|
-
acceptCodecs: ["h264"],
|
|
41205
|
-
minHeight: 480,
|
|
41206
|
-
excludeDerived: true,
|
|
41207
|
-
requireSiblingCodec: ["h265", "hevc"]
|
|
41208
|
-
}
|
|
41209
|
-
}).catch((err) => {
|
|
41210
|
-
log.warn("alexa rtc-session: cameraStreams.pickStream threw — falling back to transcode", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
41211
|
-
return null;
|
|
41212
|
-
});
|
|
41213
|
-
if (picked) {
|
|
41214
|
-
log.info("alexa rtc-session: bypassing transcode via cameraStreams.pickStream", { meta: {
|
|
41215
|
-
sourceProfile,
|
|
41216
|
-
selectedCamStreamId: picked.camStreamId,
|
|
41217
|
-
reason: picked.reason
|
|
41218
|
-
} });
|
|
41219
|
-
derivedTarget = {
|
|
41220
|
-
kind: "cam-stream",
|
|
41221
|
-
camStreamId: picked.camStreamId
|
|
41222
|
-
};
|
|
41223
|
-
} else try {
|
|
41224
|
-
const acquired = await acquireAlexaEgress({
|
|
41225
|
-
api,
|
|
41226
|
-
deviceId,
|
|
41227
|
-
sourceProfile,
|
|
41228
|
-
sessionId,
|
|
41229
|
-
logger: log
|
|
41230
|
-
});
|
|
41231
|
-
egressPipelineKey = acquired.pipelineKey;
|
|
41232
|
-
EGRESS_HANDLES.set(egressHandleKey(deviceId, sessionId), acquired.pipelineKey);
|
|
41233
|
-
derivedTarget = {
|
|
41234
|
-
kind: "cam-stream",
|
|
41235
|
-
camStreamId: acquired.camStreamId
|
|
41236
|
-
};
|
|
41237
|
-
} catch (err) {
|
|
41238
|
-
log.warn("alexa rtc-session: egress acquire failed, falling back to streamPreference target", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
41239
|
-
derivedTarget = resolveWebrtcTarget(exposedEntry?.settings?.streamPreference ?? "auto");
|
|
41240
|
-
}
|
|
41241
|
-
try {
|
|
41242
|
-
const result = await api.webrtcSession.handleOffer.mutate({
|
|
41243
|
-
deviceId,
|
|
41244
|
-
sdpOffer,
|
|
41245
|
-
sessionId,
|
|
41246
|
-
target: derivedTarget,
|
|
41247
|
-
relayOnly: false,
|
|
41248
|
-
nonTrickle: true,
|
|
41249
|
-
disableTurn: true,
|
|
41250
|
-
disableIpv6: true,
|
|
41251
|
-
consumerAttribution: {
|
|
41252
|
-
kind: "alexa",
|
|
41253
|
-
sessionId
|
|
41254
|
-
}
|
|
41255
|
-
});
|
|
41256
|
-
log.info("alexa rtc-session: AnswerGenerated", { meta: {
|
|
41257
|
-
returnedSessionId: result.sessionId,
|
|
41258
|
-
sdpAnswerLen: result.sdpAnswer.length
|
|
41259
|
-
} });
|
|
41260
|
-
return { event: {
|
|
41261
|
-
header: {
|
|
41262
|
-
namespace: "Alexa.RTCSessionController",
|
|
41263
|
-
name: "AnswerGeneratedForSession",
|
|
41264
|
-
messageId: header.messageId,
|
|
41265
|
-
correlationToken: header.correlationToken,
|
|
41266
|
-
payloadVersion: "3"
|
|
41267
|
-
},
|
|
41268
|
-
endpoint: { endpointId: endpoint.endpointId },
|
|
41269
|
-
payload: { answer: {
|
|
41270
|
-
format: payload.offer.format ?? "SDP",
|
|
41271
|
-
value: result.sdpAnswer
|
|
41272
|
-
} }
|
|
41273
|
-
} };
|
|
41274
|
-
} catch (err) {
|
|
41275
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
41276
|
-
const mSectionSummary = (() => {
|
|
41277
|
-
const lines = sdpOffer.split(/\r?\n/);
|
|
41278
|
-
const sections = [];
|
|
41279
|
-
let current = null;
|
|
41280
|
-
for (const raw of lines) {
|
|
41281
|
-
const line = raw.trim();
|
|
41282
|
-
if (line.startsWith("m=")) {
|
|
41283
|
-
if (current) sections.push(current);
|
|
41284
|
-
current = {
|
|
41285
|
-
mid: null,
|
|
41286
|
-
kind: line.split(" ")[0]?.slice(2) ?? "unknown",
|
|
41287
|
-
direction: null,
|
|
41288
|
-
codecs: []
|
|
41289
|
-
};
|
|
41290
|
-
} else if (current) {
|
|
41291
|
-
if (line.startsWith("a=mid:")) current.mid = line.slice(6);
|
|
41292
|
-
else if (/^a=(sendonly|recvonly|sendrecv|inactive)$/.test(line)) current.direction = line.slice(2);
|
|
41293
|
-
else if (line.startsWith("a=rtpmap:")) current.codecs.push(line.slice(9));
|
|
41294
|
-
}
|
|
41295
|
-
}
|
|
41296
|
-
if (current) sections.push(current);
|
|
41297
|
-
return sections;
|
|
41298
|
-
})();
|
|
41299
|
-
log.warn("alexa rtc-session: handleOffer failed", { meta: {
|
|
41300
|
-
error: message,
|
|
41301
|
-
offerLen: sdpOffer.length,
|
|
41302
|
-
mSections: mSectionSummary,
|
|
41303
|
-
offerSdp: sdpOffer
|
|
41304
|
-
} });
|
|
41305
|
-
if (egressPipelineKey !== null) {
|
|
41306
|
-
EGRESS_HANDLES.delete(egressHandleKey(deviceId, sessionId));
|
|
41307
|
-
await releaseAlexaEgress({
|
|
41308
|
-
api,
|
|
41309
|
-
deviceId,
|
|
41310
|
-
pipelineKey: egressPipelineKey,
|
|
41311
|
-
logger: log
|
|
41312
|
-
});
|
|
41313
|
-
}
|
|
41314
|
-
return errorEnvelope(header, "INTERNAL_ERROR", `webrtc-session.handleOffer failed: ${message}`);
|
|
41315
|
-
}
|
|
41316
|
-
}
|
|
41317
|
-
async function handleSessionDisconnected(header, endpointRaw, payloadRaw, deps, _callerIdentity) {
|
|
41318
|
-
const parsed = SessionDisconnectedDirectiveSchema.safeParse({
|
|
41319
|
-
endpoint: endpointRaw,
|
|
41320
|
-
payload: payloadRaw ?? {}
|
|
41321
|
-
});
|
|
41322
|
-
if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
|
|
41323
|
-
const endpoint = parsed.data.endpoint;
|
|
41324
|
-
const parsedId = parseCameraEndpointId(endpoint.endpointId);
|
|
41325
|
-
if (parsedId.kind === "camera") {
|
|
41326
|
-
const handleKey = egressHandleKey(parsedId.deviceId, parsed.data.payload.sessionId);
|
|
41327
|
-
const pipelineKey = EGRESS_HANDLES.get(handleKey);
|
|
41328
|
-
if (pipelineKey !== void 0) {
|
|
41329
|
-
EGRESS_HANDLES.delete(handleKey);
|
|
41330
|
-
await releaseAlexaEgress({
|
|
41331
|
-
api: deps.ctx.api,
|
|
41332
|
-
deviceId: parsedId.deviceId,
|
|
41333
|
-
pipelineKey,
|
|
41334
|
-
logger: deps.logger
|
|
41335
|
-
});
|
|
41336
|
-
}
|
|
41337
|
-
try {
|
|
41338
|
-
await deps.ctx.api.webrtcSession.closeSession.mutate({
|
|
41339
|
-
deviceId: parsedId.deviceId,
|
|
41340
|
-
sessionId: parsed.data.payload.sessionId
|
|
41341
|
-
});
|
|
41342
|
-
} catch (err) {
|
|
41343
|
-
deps.logger.warn("alexa rtc-session: closeSession threw on disconnect (continuing to ack)", {
|
|
41344
|
-
tags: {
|
|
41345
|
-
deviceId: parsedId.deviceId,
|
|
41346
|
-
sessionId: parsed.data.payload.sessionId
|
|
41347
|
-
},
|
|
41348
|
-
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
41349
|
-
});
|
|
41350
|
-
}
|
|
41351
|
-
}
|
|
41352
|
-
const disconnectedDeviceId = parsedId.kind === "camera" ? parsedId.deviceId : void 0;
|
|
41353
|
-
deps.logger.info("alexa rtc-session: SessionDisconnected", {
|
|
41354
|
-
tags: {
|
|
41355
|
-
sessionId: parsed.data.payload.sessionId,
|
|
41356
|
-
...disconnectedDeviceId !== void 0 ? { deviceId: disconnectedDeviceId } : {}
|
|
41357
|
-
},
|
|
41358
|
-
meta: { endpointId: endpoint.endpointId }
|
|
41359
|
-
});
|
|
41360
|
-
return { event: {
|
|
41361
|
-
header: {
|
|
41362
|
-
namespace: "Alexa",
|
|
41363
|
-
name: "Response",
|
|
41364
|
-
messageId: header.messageId,
|
|
41365
|
-
correlationToken: header.correlationToken,
|
|
41366
|
-
payloadVersion: "3"
|
|
41367
|
-
},
|
|
41368
|
-
endpoint: { endpointId: endpoint.endpointId },
|
|
41369
|
-
payload: {}
|
|
41370
|
-
} };
|
|
41371
|
-
}
|
|
41372
|
-
//#endregion
|
|
41373
41971
|
//#region src/handlers/security-panel.ts
|
|
41374
41972
|
var ArmPayloadSchema = object({ armState: _enum([
|
|
41375
41973
|
"ARMED_STAY",
|
|
@@ -43417,6 +44015,11 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
43417
44015
|
});
|
|
43418
44016
|
});
|
|
43419
44017
|
this.ctx.addDisposer(async () => offTunnelStarted());
|
|
44018
|
+
const reaper = setInterval(() => {
|
|
44019
|
+
this.reapDeadEgressHandles();
|
|
44020
|
+
}, EGRESS_REAP_INTERVAL_MS);
|
|
44021
|
+
reaper.unref?.();
|
|
44022
|
+
this.ctx.addDisposer(async () => clearInterval(reaper));
|
|
43420
44023
|
this.ctx.logger.info("export-alexa: initialized", { meta: {
|
|
43421
44024
|
exposedCount: this.config.exposed.length,
|
|
43422
44025
|
skillMode: this.config.skillMode,
|
|
@@ -44071,6 +44674,37 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
44071
44674
|
]
|
|
44072
44675
|
}] });
|
|
44073
44676
|
}
|
|
44677
|
+
/**
|
|
44678
|
+
* One sweep: ask the broker which held sessions are still alive, and release
|
|
44679
|
+
* the transcodes the dead ones are still holding.
|
|
44680
|
+
*/
|
|
44681
|
+
async reapDeadEgressHandles() {
|
|
44682
|
+
const held = heldEgressHandles();
|
|
44683
|
+
if (held.length === 0) return;
|
|
44684
|
+
const dead = await reapDeadEgressHandles({
|
|
44685
|
+
held,
|
|
44686
|
+
isAlive: async (handle) => {
|
|
44687
|
+
try {
|
|
44688
|
+
return (await this.ctx.api.webrtcSession.getSessionState.query({
|
|
44689
|
+
deviceId: handle.deviceId,
|
|
44690
|
+
sessionId: handle.sessionId
|
|
44691
|
+
})).alive;
|
|
44692
|
+
} catch {
|
|
44693
|
+
return null;
|
|
44694
|
+
}
|
|
44695
|
+
}
|
|
44696
|
+
});
|
|
44697
|
+
for (const handle of dead) {
|
|
44698
|
+
logReaped(this.ctx.logger, handle);
|
|
44699
|
+
forgetEgressHandle(handle);
|
|
44700
|
+
await releaseAlexaEgress({
|
|
44701
|
+
api: this.ctx.api,
|
|
44702
|
+
deviceId: handle.deviceId,
|
|
44703
|
+
pipelineKey: handle.pipelineKey,
|
|
44704
|
+
logger: this.ctx.logger
|
|
44705
|
+
}).catch(() => {});
|
|
44706
|
+
}
|
|
44707
|
+
}
|
|
44074
44708
|
async buildDeviceSettingsContribution(deviceId) {
|
|
44075
44709
|
if (!await this.isCameraDevice(deviceId)) return null;
|
|
44076
44710
|
const entry = this.findEntry(deviceId);
|
|
@@ -44078,6 +44712,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
44078
44712
|
const enabled = entry !== null;
|
|
44079
44713
|
const enabledKey = `alexa:${deviceId}:enabled`;
|
|
44080
44714
|
const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
|
|
44715
|
+
const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
|
|
44081
44716
|
const linkedCount = this.config.linkedAccounts.length;
|
|
44082
44717
|
return {
|
|
44083
44718
|
tabs: [{
|
|
@@ -44123,6 +44758,19 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
44123
44758
|
equals: true
|
|
44124
44759
|
},
|
|
44125
44760
|
immediate: true
|
|
44761
|
+
},
|
|
44762
|
+
{
|
|
44763
|
+
type: "boolean",
|
|
44764
|
+
key: allowTranscodeKey,
|
|
44765
|
+
label: "Allow transcoding for Alexa",
|
|
44766
|
+
description: "Let the hub re-encode this camera for the Echo when no stream is directly usable. Off means only a stream Alexa can take as-is (H.264, 480p-1080p) is offered — cheaper, but a camera that publishes only H.265, or only a small sub-stream, will not be streamable to Alexa at all.",
|
|
44767
|
+
style: "switch",
|
|
44768
|
+
value: settings.allowTranscode !== false,
|
|
44769
|
+
showWhen: {
|
|
44770
|
+
field: enabledKey,
|
|
44771
|
+
equals: true
|
|
44772
|
+
},
|
|
44773
|
+
immediate: true
|
|
44126
44774
|
}
|
|
44127
44775
|
]
|
|
44128
44776
|
}]
|
|
@@ -44142,26 +44790,17 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
44142
44790
|
const idStr = String(deviceId);
|
|
44143
44791
|
const current = this.findEntry(deviceId);
|
|
44144
44792
|
const wasEnabled = current !== null;
|
|
44145
|
-
const sourceProfileKey = `alexa:${deviceId}:sourceProfile`;
|
|
44146
|
-
if (sourceProfileKey in patch) {
|
|
44147
|
-
if (!wasEnabled) return { success: true };
|
|
44148
|
-
const currentSettings = current?.settings ?? DEFAULT_DEVICE_SETTINGS;
|
|
44149
|
-
const sourceProfileRaw = patch[sourceProfileKey];
|
|
44150
|
-
const sourceProfile = sourceProfileRaw === "mid" ? "mid" : sourceProfileRaw === "low" ? "low" : "high";
|
|
44151
|
-
await this.patchEntrySettings(deviceId, {
|
|
44152
|
-
...currentSettings,
|
|
44153
|
-
sourceProfile
|
|
44154
|
-
});
|
|
44155
|
-
return { success: true };
|
|
44156
|
-
}
|
|
44157
44793
|
const enabledKey = `alexa:${deviceId}:enabled`;
|
|
44158
44794
|
const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
|
|
44795
|
+
const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
|
|
44159
44796
|
const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
|
|
44160
44797
|
const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
|
|
44161
44798
|
const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
|
|
44799
|
+
const allowTranscode = allowTranscodeKey in patch ? Boolean(patch[allowTranscodeKey]) : current?.settings?.allowTranscode !== false;
|
|
44162
44800
|
const nextSettings = {
|
|
44163
44801
|
...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
|
|
44164
|
-
streamPreference
|
|
44802
|
+
streamPreference,
|
|
44803
|
+
allowTranscode
|
|
44165
44804
|
};
|
|
44166
44805
|
if (!enabledValue) {
|
|
44167
44806
|
if (wasEnabled) await this.unexposeDevice(idStr);
|
|
@@ -44173,13 +44812,22 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
44173
44812
|
return { success: true };
|
|
44174
44813
|
}
|
|
44175
44814
|
const currentPref = current?.settings?.streamPreference ?? "auto";
|
|
44815
|
+
const currentAllow = current?.settings?.allowTranscode !== false;
|
|
44176
44816
|
await this.patchEntrySettings(deviceId, nextSettings);
|
|
44177
|
-
if (currentPref !== streamPreference) {
|
|
44178
|
-
this.ctx.logger.info("export-alexa:
|
|
44179
|
-
deviceId,
|
|
44180
|
-
|
|
44181
|
-
|
|
44182
|
-
|
|
44817
|
+
if (currentPref !== streamPreference || currentAllow !== allowTranscode) {
|
|
44818
|
+
this.ctx.logger.info("export-alexa: source settings changed — refreshing exposure", {
|
|
44819
|
+
tags: { deviceId },
|
|
44820
|
+
meta: {
|
|
44821
|
+
streamPreference: {
|
|
44822
|
+
from: currentPref,
|
|
44823
|
+
to: streamPreference
|
|
44824
|
+
},
|
|
44825
|
+
allowTranscode: {
|
|
44826
|
+
from: currentAllow,
|
|
44827
|
+
to: allowTranscode
|
|
44828
|
+
}
|
|
44829
|
+
}
|
|
44830
|
+
});
|
|
44183
44831
|
try {
|
|
44184
44832
|
await this.unexposeDevice(idStr);
|
|
44185
44833
|
await this.exposeDevice(idStr);
|