@camstack/addon-export-alexa 1.2.137 → 1.2.138
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 +1929 -553
- package/dist/export-alexa.addon.mjs +1930 -555
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as crypto$1 from "node:crypto";
|
|
2
|
-
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import sharp from "sharp";
|
|
4
4
|
//#region ../types/dist/event-category-B_0XEcLF.mjs
|
|
5
5
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
@@ -5372,7 +5372,7 @@ var ZodIssueCode = {
|
|
|
5372
5372
|
var ZodFirstPartyTypeKind;
|
|
5373
5373
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5374
5374
|
//#endregion
|
|
5375
|
-
//#region ../types/dist/sleep-
|
|
5375
|
+
//#region ../types/dist/sleep-B0JG7-_F.mjs
|
|
5376
5376
|
/**
|
|
5377
5377
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5378
5378
|
* window to float samples (D455).
|
|
@@ -8329,8 +8329,8 @@ function buildAddonRouteProvider(id, routes) {
|
|
|
8329
8329
|
const reply = buildCapturingReply(envelope);
|
|
8330
8330
|
const request = {
|
|
8331
8331
|
params: {
|
|
8332
|
-
...
|
|
8333
|
-
...
|
|
8332
|
+
...match.params,
|
|
8333
|
+
...input.params
|
|
8334
8334
|
},
|
|
8335
8335
|
query: input.query,
|
|
8336
8336
|
body: input.body,
|
|
@@ -15117,6 +15117,10 @@ var DeviceInfoSchema = object({
|
|
|
15117
15117
|
isCamera: boolean(),
|
|
15118
15118
|
/** Current config values — serializable snapshot */
|
|
15119
15119
|
config: record(string(), unknown()),
|
|
15120
|
+
/** Paths inside `config` the device's schema declares secret (D783) —
|
|
15121
|
+
* what the non-admin redaction withholds. Paths, never values. Absent
|
|
15122
|
+
* when the owning runner has not reported them. */
|
|
15123
|
+
secretConfigPaths: array(string()).optional(),
|
|
15120
15124
|
/** Hardware + identity blob (manufacturer / model / firmware / sn /
|
|
15121
15125
|
* uid / mac / …). Populated by drivers; editable via setMetadata. */
|
|
15122
15126
|
metadata: record(string(), unknown()).nullable().optional(),
|
|
@@ -15322,7 +15326,13 @@ var DeviceRegisterPayloadSchema = object({
|
|
|
15322
15326
|
* call (workers re-register on init), so probe-driven updates
|
|
15323
15327
|
* (e.g. PTZ flag flipping after the abilities probe) propagate. */
|
|
15324
15328
|
features: array(string()).optional(),
|
|
15325
|
-
config: record(string(), unknown())
|
|
15329
|
+
config: record(string(), unknown()),
|
|
15330
|
+
/** Config paths the device's own schema DECLARES secret
|
|
15331
|
+
* (`.meta({ secret: true })` — dotted, `[]` for array items, e.g.
|
|
15332
|
+
* `connection.password`). Re-published on every register; the non-admin
|
|
15333
|
+
* redaction withholds them at any depth (D783). Optional: an older runner
|
|
15334
|
+
* omits it and the keyword backstop applies. */
|
|
15335
|
+
secretConfigPaths: array(string()).optional()
|
|
15326
15336
|
});
|
|
15327
15337
|
var DeviceRemovePayloadSchema = object({ deviceId: number() });
|
|
15328
15338
|
var DevicePersistConfigPayloadSchema = object({
|
|
@@ -16981,7 +16991,14 @@ var LoginMethodContributionSchema = discriminatedUnion("kind", [
|
|
|
16981
16991
|
icon: string().optional(),
|
|
16982
16992
|
/** Addon-owned HTTP route the button navigates to (GET). */
|
|
16983
16993
|
startUrl: string(),
|
|
16984
|
-
stage: LoginStageEnum
|
|
16994
|
+
stage: LoginStageEnum,
|
|
16995
|
+
/**
|
|
16996
|
+
* The provider names an external identity a signed-in user can link to
|
|
16997
|
+
* their own account (D782): `auth.listLinkableProviders` /
|
|
16998
|
+
* `beginExternalLink` offer exactly the redirect methods that set this,
|
|
16999
|
+
* whatever their id. Absent / false — a login button only (magic-link).
|
|
17000
|
+
*/
|
|
17001
|
+
linkable: boolean().optional()
|
|
16985
17002
|
}),
|
|
16986
17003
|
object({
|
|
16987
17004
|
kind: literal("widget"),
|
|
@@ -21781,7 +21798,7 @@ var TrainingExportSummarySchema = object({
|
|
|
21781
21798
|
truncated: boolean(),
|
|
21782
21799
|
devices: array(TrainingExportDeviceTotalsSchema).readonly()
|
|
21783
21800
|
});
|
|
21784
|
-
var TrackSchema = object({
|
|
21801
|
+
var TrackSchema$1 = object({
|
|
21785
21802
|
trackId: string(),
|
|
21786
21803
|
deviceId: number(),
|
|
21787
21804
|
className: string(),
|
|
@@ -22394,7 +22411,13 @@ var RetrainFrameSchema = object({
|
|
|
22394
22411
|
sizeBytes: number().int(),
|
|
22395
22412
|
width: number().int(),
|
|
22396
22413
|
height: number().int(),
|
|
22397
|
-
copiedAt: number()
|
|
22414
|
+
copiedAt: number(),
|
|
22415
|
+
/** When the CAMERA captured the frame. Absent on a copy taken before D785. */
|
|
22416
|
+
capturedAt: number().optional(),
|
|
22417
|
+
/** The operator's "every subject in this frame is boxed". Detector
|
|
22418
|
+
* evaluation keeps only complete frames: an unboxed subject would count as
|
|
22419
|
+
* a false positive. */
|
|
22420
|
+
complete: boolean()
|
|
22398
22421
|
});
|
|
22399
22422
|
/** Why a copy-on-select could not be honoured — named, never a silent skip. */
|
|
22400
22423
|
var RetrainCopyRefusalSchema = _enum([
|
|
@@ -22415,6 +22438,51 @@ var RetrainFrameListSchema = object({
|
|
|
22415
22438
|
/** What the page pre-selects — the native key frame when one survives. */
|
|
22416
22439
|
autoPickMediaId: number().int().optional()
|
|
22417
22440
|
});
|
|
22441
|
+
/**
|
|
22442
|
+
* Which frames an annotated export carries. Every field narrows; an empty
|
|
22443
|
+
* filter is every annotated copy. A dataset VERSION is a frozen `frameIds`
|
|
22444
|
+
* list owned by whoever trains (the training orchestrator), so `frameIds` is
|
|
22445
|
+
* the primitive a version export uses; `batchId` re-selects what an earlier
|
|
22446
|
+
* export carried (`exportedInBatch`).
|
|
22447
|
+
*/
|
|
22448
|
+
var RetrainExportFilterSchema = object({
|
|
22449
|
+
deviceIds: array(number()).optional(),
|
|
22450
|
+
frameIds: array(string()).max(2e4).optional(),
|
|
22451
|
+
batchId: string().optional(),
|
|
22452
|
+
/** Only frames the operator marked complete (all subjects boxed). */
|
|
22453
|
+
completeOnly: boolean().optional()
|
|
22454
|
+
});
|
|
22455
|
+
/**
|
|
22456
|
+
* `frames`: the full frame copies, boxes in `annotations.json`.
|
|
22457
|
+
* `crops`: additionally one JPEG per subject, cut with the runtime detail-crop
|
|
22458
|
+
* convention — the classifier layout.
|
|
22459
|
+
*/
|
|
22460
|
+
var RetrainExportLayoutSchema = _enum(["frames", "crops"]);
|
|
22461
|
+
/**
|
|
22462
|
+
* Where `writeRetrainArchive` streams the tar: one HTTP request whose body is
|
|
22463
|
+
* the archive, sent chunk by chunk. Covers a presigned PUT and a provider
|
|
22464
|
+
* addon's data-plane upload route alike. Headers are sent verbatim and never
|
|
22465
|
+
* logged.
|
|
22466
|
+
*/
|
|
22467
|
+
var RetrainArchiveTargetSchema = object({
|
|
22468
|
+
kind: literal("http"),
|
|
22469
|
+
method: _enum(["PUT", "POST"]),
|
|
22470
|
+
url: string().url(),
|
|
22471
|
+
headers: record(string(), string()).optional()
|
|
22472
|
+
});
|
|
22473
|
+
var RetrainArchiveResultSchema = object({
|
|
22474
|
+
batchId: string(),
|
|
22475
|
+
frames: number().int(),
|
|
22476
|
+
crops: number().int(),
|
|
22477
|
+
annotations: number().int(),
|
|
22478
|
+
/** Frames planned but not shipped (unreadable copy, path too long). */
|
|
22479
|
+
missing: number().int(),
|
|
22480
|
+
bytes: number().int(),
|
|
22481
|
+
/** sha256 of the exact bytes sent. */
|
|
22482
|
+
sha256: string(),
|
|
22483
|
+
/** The target's HTTP status. */
|
|
22484
|
+
status: number().int()
|
|
22485
|
+
});
|
|
22418
22486
|
/** What the operator asked the assist to look for. */
|
|
22419
22487
|
var RetrainAssistSubjectSchema = discriminatedUnion("kind", [object({
|
|
22420
22488
|
kind: literal("package"),
|
|
@@ -22508,7 +22576,7 @@ var RecentTracksQueryInput = object({
|
|
|
22508
22576
|
});
|
|
22509
22577
|
var RecentTracksPageSchema = object({
|
|
22510
22578
|
/** Merged page, ordered by (`lastSeen` DESC, `trackId` DESC). */
|
|
22511
|
-
tracks: array(TrackSchema).readonly(),
|
|
22579
|
+
tracks: array(TrackSchema$1).readonly(),
|
|
22512
22580
|
/** Cursor for the next page, or null when this page is the last. */
|
|
22513
22581
|
nextCursor: string().nullable()
|
|
22514
22582
|
});
|
|
@@ -22982,10 +23050,10 @@ var LtsBucketSchema = object({
|
|
|
22982
23050
|
min: number(),
|
|
22983
23051
|
max: number()
|
|
22984
23052
|
});
|
|
22985
|
-
DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
|
|
23053
|
+
DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema$1).readonly()), method(object({
|
|
22986
23054
|
deviceId: number(),
|
|
22987
23055
|
trackId: string()
|
|
22988
|
-
}), TrackSchema.nullable()), method(object({
|
|
23056
|
+
}), TrackSchema$1.nullable()), method(object({
|
|
22989
23057
|
deviceId: number(),
|
|
22990
23058
|
since: number().optional(),
|
|
22991
23059
|
until: number().optional(),
|
|
@@ -23018,7 +23086,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
23018
23086
|
classes: array(string()).optional(),
|
|
23019
23087
|
/** See {@link ExcludeSourcesDoc}. */
|
|
23020
23088
|
excludeSources: array(TrackSourceSchema).optional()
|
|
23021
|
-
}), array(TrackSchema).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(object({ deviceId: number() }), _void(), {
|
|
23089
|
+
}), array(TrackSchema$1).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(object({ deviceId: number() }), _void(), {
|
|
23022
23090
|
kind: "mutation",
|
|
23023
23091
|
auth: "admin"
|
|
23024
23092
|
}), method(DeviceEventQueryInput, array(MotionEventSchema).readonly()), method(ObjectEventQueryInput, array(ObjectEventSchema).readonly()), method(DeviceEventQueryInput, array(AudioEventSchema).readonly()), method(object({ deviceId: number() }), array(EventKindDescriptorSchema).readonly()), method(object({ deviceIds: array(number()).min(1).max(200) }), array(EventKindsForDeviceSchema).readonly()), method(object({
|
|
@@ -23209,9 +23277,25 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
23209
23277
|
}), RetrainTransitionResultSchema, {
|
|
23210
23278
|
kind: "mutation",
|
|
23211
23279
|
auth: "admin"
|
|
23212
|
-
}), method(
|
|
23280
|
+
}), method(RetrainExportFilterSchema.extend({ layout: RetrainExportLayoutSchema.optional() }), object({ url: string() }), {
|
|
23213
23281
|
kind: "query",
|
|
23214
23282
|
auth: "admin"
|
|
23283
|
+
}), method(object({
|
|
23284
|
+
deviceId: number(),
|
|
23285
|
+
trackId: string(),
|
|
23286
|
+
frameId: string(),
|
|
23287
|
+
complete: boolean()
|
|
23288
|
+
}), RetrainFrameSchema, {
|
|
23289
|
+
kind: "mutation",
|
|
23290
|
+
auth: "admin"
|
|
23291
|
+
}), method(object({
|
|
23292
|
+
filter: RetrainExportFilterSchema,
|
|
23293
|
+
layout: RetrainExportLayoutSchema,
|
|
23294
|
+
target: RetrainArchiveTargetSchema
|
|
23295
|
+
}), RetrainArchiveResultSchema, {
|
|
23296
|
+
kind: "mutation",
|
|
23297
|
+
auth: "admin",
|
|
23298
|
+
timeoutMs: 60 * 6e4
|
|
23215
23299
|
}), method(object({
|
|
23216
23300
|
/** Which of the three event tables owns the row. */
|
|
23217
23301
|
ownerType: EventOwnerTypeSchema,
|
|
@@ -26327,7 +26411,7 @@ method(SendEmailInputSchema, SendEmailResultSchema, {
|
|
|
26327
26411
|
* the device-native provider (onboard capture) or from the stream-broker
|
|
26328
26412
|
* prebuffer fallback.
|
|
26329
26413
|
*/
|
|
26330
|
-
var SnapshotImageSchema$
|
|
26414
|
+
var SnapshotImageSchema$2 = object({
|
|
26331
26415
|
base64: string(),
|
|
26332
26416
|
contentType: string()
|
|
26333
26417
|
});
|
|
@@ -26359,7 +26443,7 @@ DeviceType.Camera, method(object({
|
|
|
26359
26443
|
* correct answer: stale but honest beats woken.
|
|
26360
26444
|
*/
|
|
26361
26445
|
force: boolean().optional()
|
|
26362
|
-
}), SnapshotImageSchema$
|
|
26446
|
+
}), SnapshotImageSchema$2.nullable()), method(object({ deviceId: number() }), _void(), {
|
|
26363
26447
|
kind: "mutation",
|
|
26364
26448
|
auth: "admin"
|
|
26365
26449
|
}), systemMethod(object({ deviceIds: array(number()).min(1).max(200) }), array(object({
|
|
@@ -26501,7 +26585,19 @@ var SsoBridgeClaimsSchema = object({
|
|
|
26501
26585
|
* editing a descriptor must not retroactively extend or shorten a link the
|
|
26502
26586
|
* operator already consented to.
|
|
26503
26587
|
*/
|
|
26504
|
-
refreshTtl: union([number().int().positive(), literal("never")]).optional()
|
|
26588
|
+
refreshTtl: union([number().int().positive(), literal("never")]).optional(),
|
|
26589
|
+
/**
|
|
26590
|
+
* Opaque handle of a hub-side LINK intent (D782). Set by an OIDC callback
|
|
26591
|
+
* that was started from a signed-in user's "Link" button: the finish then
|
|
26592
|
+
* links this identity to the intent's user instead of signing anyone in.
|
|
26593
|
+
* The handle carries no user id — the hub resolves it, single-use.
|
|
26594
|
+
*/
|
|
26595
|
+
linkIntent: string().optional(),
|
|
26596
|
+
/**
|
|
26597
|
+
* Verified `iss` of the id_token behind an OIDC login bridge (D782). A
|
|
26598
|
+
* subject is unique only per issuer, so a link is bound to it.
|
|
26599
|
+
*/
|
|
26600
|
+
issuer: string().optional()
|
|
26505
26601
|
});
|
|
26506
26602
|
method(object({
|
|
26507
26603
|
claims: SsoBridgeClaimsSchema,
|
|
@@ -36521,6 +36617,32 @@ var TotpStatusSchema = object({
|
|
|
36521
36617
|
/** Null when no row exists OR the row is still pending confirmation. */
|
|
36522
36618
|
confirmedAt: number().nullable()
|
|
36523
36619
|
});
|
|
36620
|
+
/**
|
|
36621
|
+
* user-management — singleton capability for user accounts, API keys,
|
|
36622
|
+
* and scoped tokens.
|
|
36623
|
+
*
|
|
36624
|
+
* v2 model: every method gated by `auth: 'admin'`. Admin-only delegation
|
|
36625
|
+
* — the subset check on `setUserScopes` / `createScopedToken` is moot
|
|
36626
|
+
* because non-admins never reach these. If/when we open non-admin
|
|
36627
|
+
* delegation, structural caller-scope subset check goes inside the
|
|
36628
|
+
* provider.
|
|
36629
|
+
*/
|
|
36630
|
+
var ExternalIdentitySchema = object({
|
|
36631
|
+
/** Login-method id of the provider instance, e.g. `auth-oidc/authentik`. */
|
|
36632
|
+
provider: string(),
|
|
36633
|
+
/** The IdP's stable subject (`sub`) for this account. */
|
|
36634
|
+
subject: string(),
|
|
36635
|
+
/** The verified `iss` of the IdP the identity was linked under — `sub` is unique only per issuer. */
|
|
36636
|
+
issuer: string(),
|
|
36637
|
+
userId: string(),
|
|
36638
|
+
/** What the IdP called the account at link time (username / email) — display only. */
|
|
36639
|
+
label: string().optional(),
|
|
36640
|
+
linkedAt: number()
|
|
36641
|
+
});
|
|
36642
|
+
var ExternalIdentityKeySchema = object({
|
|
36643
|
+
provider: string().min(1),
|
|
36644
|
+
subject: string().min(1)
|
|
36645
|
+
});
|
|
36524
36646
|
method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserInputSchema, UserSummarySchema, {
|
|
36525
36647
|
kind: "mutation",
|
|
36526
36648
|
auth: "admin",
|
|
@@ -36634,7 +36756,22 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
|
|
|
36634
36756
|
userId: string(),
|
|
36635
36757
|
username: string(),
|
|
36636
36758
|
scopes: array(TokenScopeSchema)
|
|
36637
|
-
}).nullable(), { access: "view" }), method(
|
|
36759
|
+
}).nullable(), { access: "view" }), method(object({ userId: string() }), array(ExternalIdentitySchema), { auth: "admin" }), method(ExternalIdentityKeySchema, object({
|
|
36760
|
+
userId: string(),
|
|
36761
|
+
issuer: string()
|
|
36762
|
+
}).nullable(), { auth: "admin" }), method(ExternalIdentityKeySchema.extend({
|
|
36763
|
+
userId: string(),
|
|
36764
|
+
issuer: string().min(1),
|
|
36765
|
+
label: string().optional()
|
|
36766
|
+
}), object({ success: literal(true) }), {
|
|
36767
|
+
kind: "mutation",
|
|
36768
|
+
auth: "admin",
|
|
36769
|
+
access: "create"
|
|
36770
|
+
}), method(ExternalIdentityKeySchema.extend({ userId: string() }), object({ success: boolean() }), {
|
|
36771
|
+
kind: "mutation",
|
|
36772
|
+
auth: "admin",
|
|
36773
|
+
access: "delete"
|
|
36774
|
+
}), method(_void(), array(OauthSessionSummarySchema), { auth: "admin" }), method(object({ id: string() }), object({ success: boolean() }), {
|
|
36638
36775
|
kind: "mutation",
|
|
36639
36776
|
auth: "admin",
|
|
36640
36777
|
access: "delete"
|
|
@@ -41440,6 +41577,12 @@ Object.freeze({
|
|
|
41440
41577
|
addonId: null,
|
|
41441
41578
|
access: "create"
|
|
41442
41579
|
},
|
|
41580
|
+
"pipelineAnalytics.setRetrainFrameComplete": {
|
|
41581
|
+
capName: "pipeline-analytics",
|
|
41582
|
+
capScope: "device",
|
|
41583
|
+
addonId: null,
|
|
41584
|
+
access: "create"
|
|
41585
|
+
},
|
|
41443
41586
|
"pipelineAnalytics.setTrackFlags": {
|
|
41444
41587
|
capName: "pipeline-analytics",
|
|
41445
41588
|
capScope: "device",
|
|
@@ -41476,6 +41619,12 @@ Object.freeze({
|
|
|
41476
41619
|
addonId: null,
|
|
41477
41620
|
access: "delete"
|
|
41478
41621
|
},
|
|
41622
|
+
"pipelineAnalytics.writeRetrainArchive": {
|
|
41623
|
+
capName: "pipeline-analytics",
|
|
41624
|
+
capScope: "device",
|
|
41625
|
+
addonId: null,
|
|
41626
|
+
access: "create"
|
|
41627
|
+
},
|
|
41479
41628
|
"pipelineExecutor.cacheFrameInPool": {
|
|
41480
41629
|
capName: "pipeline-executor",
|
|
41481
41630
|
capScope: "system",
|
|
@@ -43828,18 +43977,36 @@ Object.freeze({
|
|
|
43828
43977
|
addonId: null,
|
|
43829
43978
|
access: "delete"
|
|
43830
43979
|
},
|
|
43980
|
+
"userManagement.findUserByExternalIdentity": {
|
|
43981
|
+
capName: "user-management",
|
|
43982
|
+
capScope: "system",
|
|
43983
|
+
addonId: null,
|
|
43984
|
+
access: "view"
|
|
43985
|
+
},
|
|
43831
43986
|
"userManagement.getTotpStatus": {
|
|
43832
43987
|
capName: "user-management",
|
|
43833
43988
|
capScope: "system",
|
|
43834
43989
|
addonId: null,
|
|
43835
43990
|
access: "view"
|
|
43836
43991
|
},
|
|
43992
|
+
"userManagement.linkExternalIdentity": {
|
|
43993
|
+
capName: "user-management",
|
|
43994
|
+
capScope: "system",
|
|
43995
|
+
addonId: null,
|
|
43996
|
+
access: "create"
|
|
43997
|
+
},
|
|
43837
43998
|
"userManagement.listApiKeys": {
|
|
43838
43999
|
capName: "user-management",
|
|
43839
44000
|
capScope: "system",
|
|
43840
44001
|
addonId: null,
|
|
43841
44002
|
access: "view"
|
|
43842
44003
|
},
|
|
44004
|
+
"userManagement.listExternalIdentities": {
|
|
44005
|
+
capName: "user-management",
|
|
44006
|
+
capScope: "system",
|
|
44007
|
+
addonId: null,
|
|
44008
|
+
access: "view"
|
|
44009
|
+
},
|
|
43843
44010
|
"userManagement.listOauthSessions": {
|
|
43844
44011
|
capName: "user-management",
|
|
43845
44012
|
capScope: "system",
|
|
@@ -43918,6 +44085,12 @@ Object.freeze({
|
|
|
43918
44085
|
addonId: null,
|
|
43919
44086
|
access: "create"
|
|
43920
44087
|
},
|
|
44088
|
+
"userManagement.unlinkExternalIdentity": {
|
|
44089
|
+
capName: "user-management",
|
|
44090
|
+
capScope: "system",
|
|
44091
|
+
addonId: null,
|
|
44092
|
+
access: "delete"
|
|
44093
|
+
},
|
|
43921
44094
|
"userManagement.updateUser": {
|
|
43922
44095
|
capName: "user-management",
|
|
43923
44096
|
capScope: "system",
|
|
@@ -47257,6 +47430,11 @@ Object.freeze({
|
|
|
47257
47430
|
form: "single",
|
|
47258
47431
|
optional: false
|
|
47259
47432
|
}],
|
|
47433
|
+
"pipelineAnalytics.setRetrainFrameComplete": [{
|
|
47434
|
+
name: "deviceId",
|
|
47435
|
+
form: "single",
|
|
47436
|
+
optional: false
|
|
47437
|
+
}],
|
|
47260
47438
|
"pipelineAnalytics.setTrackFlags": [{
|
|
47261
47439
|
name: "deviceId",
|
|
47262
47440
|
form: "single",
|
|
@@ -49382,38 +49560,63 @@ var MB = 1024 * 1024;
|
|
|
49382
49560
|
/**
|
|
49383
49561
|
* Alexa Smart Home export — customActions catalog.
|
|
49384
49562
|
*
|
|
49385
|
-
*
|
|
49386
|
-
* `api.addons.custom.{query,mutate}({ addonId, action, input })`:
|
|
49563
|
+
* The settings page drives these through
|
|
49564
|
+
* `api.addons.custom.{query,mutate}({ addonId, action, input })`: every action
|
|
49565
|
+
* button there is an `addon-action-button`, which shows the result (or the
|
|
49566
|
+
* error) under the button, and the external-address picker is an
|
|
49567
|
+
* `addon-action-select` over `listExternalAddresses`.
|
|
49387
49568
|
*
|
|
49388
|
-
*
|
|
49389
|
-
*
|
|
49390
|
-
*
|
|
49391
|
-
*
|
|
49392
|
-
* the form-builder's button-field dispatch routes
|
|
49393
|
-
* here through the generic customAction channel.
|
|
49394
|
-
* Same detection logic as the existing
|
|
49395
|
-
* `/addon/export-alexa/admin/redetect-hub-url` route.
|
|
49569
|
+
* Every action is `auth: 'admin'` (S2). The framework default is `protected` —
|
|
49570
|
+
* any authenticated user — and at that level a viewer could rotate the token
|
|
49571
|
+
* and read the new one in the response, unlink the Amazon account, or delete
|
|
49572
|
+
* endpoints from it.
|
|
49396
49573
|
*/
|
|
49574
|
+
/** tRPC/superjson elides empty-object inputs on the wire — accept both `{}`
|
|
49575
|
+
* and `undefined` so the form-builder doesn't have to know. */
|
|
49576
|
+
var NoInput = object({}).optional();
|
|
49397
49577
|
var exportAlexaActions = defineCustomActions({
|
|
49398
|
-
|
|
49399
|
-
|
|
49400
|
-
|
|
49401
|
-
|
|
49402
|
-
|
|
49578
|
+
/**
|
|
49579
|
+
* The external addresses the connected `network-access` providers report
|
|
49580
|
+
* now, read live for the "External address" picker, plus the saved choice
|
|
49581
|
+
* when it is not among them (so it is never silently dropped from view).
|
|
49582
|
+
*/
|
|
49583
|
+
listExternalAddresses: customAction(NoInput, object({ addresses: array(object({
|
|
49584
|
+
value: string(),
|
|
49585
|
+
label: string()
|
|
49586
|
+
})).readonly() }), { auth: "admin" }),
|
|
49587
|
+
regenerateIngressToken: customAction(NoInput, object({ ingressToken: string() }), {
|
|
49403
49588
|
kind: "mutation",
|
|
49404
49589
|
auth: "admin"
|
|
49405
49590
|
}),
|
|
49406
|
-
|
|
49591
|
+
/**
|
|
49592
|
+
* Re-sync: re-send every exposed device to every linked account, and delete
|
|
49593
|
+
* from Alexa the devices still tracked but no longer exposed (an unexpose
|
|
49594
|
+
* whose DeleteReport failed). Throws when there is no linked account with a
|
|
49595
|
+
* push grant, so the button says why nothing happened.
|
|
49596
|
+
*/
|
|
49597
|
+
resyncDiscovery: customAction(NoInput, object({
|
|
49598
|
+
deviceCount: number(),
|
|
49407
49599
|
endpointCount: number(),
|
|
49408
|
-
accountCount: number()
|
|
49600
|
+
accountCount: number(),
|
|
49601
|
+
removedEndpointCount: number(),
|
|
49602
|
+
retainedEndpointCount: number(),
|
|
49603
|
+
failedCount: number()
|
|
49409
49604
|
}), {
|
|
49410
49605
|
kind: "mutation",
|
|
49411
49606
|
auth: "admin"
|
|
49412
49607
|
}),
|
|
49413
|
-
|
|
49608
|
+
/**
|
|
49609
|
+
* Send `Alexa.Discovery/DeleteReport` for endpoint ids the operator types in
|
|
49610
|
+
* the button's prompt — for a device Alexa still shows that this hub no
|
|
49611
|
+
* longer tracks. The ids are an argument, never a saved setting. Throws
|
|
49612
|
+
* unless every linked account accepted the delete; only then does the local
|
|
49613
|
+
* tracking forget those ids.
|
|
49614
|
+
*/
|
|
49615
|
+
removeAlexaEndpoints: customAction(object({ endpointIds: string() }), object({
|
|
49414
49616
|
requested: number(),
|
|
49415
49617
|
accountCount: number(),
|
|
49416
|
-
|
|
49618
|
+
deliveredAccounts: number(),
|
|
49619
|
+
failedAccounts: number()
|
|
49417
49620
|
}), {
|
|
49418
49621
|
kind: "mutation",
|
|
49419
49622
|
auth: "admin"
|
|
@@ -49431,7 +49634,7 @@ var exportAlexaActions = defineCustomActions({
|
|
|
49431
49634
|
* cleared. Revoking alone leaves the gateway pushing motion and doorbell
|
|
49432
49635
|
* events at the old Amazon account until Amazon happens to answer 403.
|
|
49433
49636
|
*/
|
|
49434
|
-
unlinkAlexaAccounts: customAction(
|
|
49637
|
+
unlinkAlexaAccounts: customAction(NoInput, object({
|
|
49435
49638
|
revokedSessions: number(),
|
|
49436
49639
|
removedAccounts: number()
|
|
49437
49640
|
}), {
|
|
@@ -49439,20 +49642,21 @@ var exportAlexaActions = defineCustomActions({
|
|
|
49439
49642
|
auth: "admin"
|
|
49440
49643
|
}),
|
|
49441
49644
|
/**
|
|
49442
|
-
* Swap the per-link credential this hub presents to the skill Lambda's
|
|
49443
|
-
* token broker (D753) for a fresh one; the old one dies at once. Rotation
|
|
49444
|
-
* also happens implicitly on every re-link.
|
|
49445
|
-
*/
|
|
49446
|
-
/**
|
|
49447
49645
|
* The ingress token, in clear, for an administrator who asked for it. The
|
|
49448
49646
|
* one read surface that returns it: `deviceExport.getStatus` and the
|
|
49449
49647
|
* settings form carry only a mask.
|
|
49450
49648
|
*/
|
|
49451
|
-
revealIngressToken: customAction(
|
|
49649
|
+
revealIngressToken: customAction(NoInput, object({ ingressToken: string() }), {
|
|
49452
49650
|
kind: "mutation",
|
|
49453
49651
|
auth: "admin"
|
|
49454
49652
|
}),
|
|
49455
|
-
|
|
49653
|
+
/**
|
|
49654
|
+
* Swap the per-link credential this hub presents to the skill Lambda's
|
|
49655
|
+
* token broker (D753) for a fresh one; the old one dies at once. Rotation
|
|
49656
|
+
* also happens implicitly on every re-link. Throws when no link is held at
|
|
49657
|
+
* a broker.
|
|
49658
|
+
*/
|
|
49659
|
+
rotateBrokerCredential: customAction(NoInput, object({
|
|
49456
49660
|
rotated: number(),
|
|
49457
49661
|
failed: number()
|
|
49458
49662
|
}), {
|
|
@@ -49737,7 +49941,10 @@ var DirectiveHeaderSchema = object({
|
|
|
49737
49941
|
name: string(),
|
|
49738
49942
|
payloadVersion: string(),
|
|
49739
49943
|
messageId: string(),
|
|
49740
|
-
correlationToken: string().optional()
|
|
49944
|
+
correlationToken: string().optional(),
|
|
49945
|
+
/** Instanced interfaces' directives carry it (`Alexa.DataController`, D779);
|
|
49946
|
+
* the response echoes it. */
|
|
49947
|
+
instance: string().optional()
|
|
49741
49948
|
});
|
|
49742
49949
|
/** Scope carries the bearer token Alexa minted via our OAuth provider. */
|
|
49743
49950
|
var BearerScopeSchema = object({
|
|
@@ -50478,6 +50685,60 @@ var MOTION_SENSOR = {
|
|
|
50478
50685
|
retrievable: true
|
|
50479
50686
|
}
|
|
50480
50687
|
};
|
|
50688
|
+
/** The only classes `Alexa.SmartVision.ObjectDetectionSensor` knows (Amazon:
|
|
50689
|
+
* `imageNetClass` is `person` or `package`; anything else is dropped). */
|
|
50690
|
+
var SMART_VISION_CLASSES$1 = ["person", "package"];
|
|
50691
|
+
/** `Alexa.DataController` instance that serves the camera's detection history. */
|
|
50692
|
+
var SMART_VISION_DATA_INSTANCE = "Camera.SmartVisionData";
|
|
50693
|
+
/**
|
|
50694
|
+
* `Alexa.SmartVision.ObjectDetectionSensor` v1.0 on the CAMERA endpoint (R4,
|
|
50695
|
+
* D779) — the per-camera event history and the "person detected"
|
|
50696
|
+
* announcements in the Alexa app. Amazon lists it for en-US only; it is built
|
|
50697
|
+
* here as an experiment to find out what an it-IT account does with it.
|
|
50698
|
+
*
|
|
50699
|
+
* The shape is Amazon's discovery example to the letter, minus the empty
|
|
50700
|
+
* `properties: {}` (the interface defines no reportable property; omission is
|
|
50701
|
+
* how this file expresses that — see `DOORBELL_EVENT_SOURCE`). No
|
|
50702
|
+
* `isAvailable` / `unavailabilityReason`: both are optional and only describe a
|
|
50703
|
+
* subscription-gated class. An earlier declaration that ALSO claimed a
|
|
50704
|
+
* retrievable `objectDetectionClasses` property (never answered by ReportState)
|
|
50705
|
+
* made Alexa reject the camera and every child — so: no properties at all.
|
|
50706
|
+
*
|
|
50707
|
+
* `classes` is `person`, plus `package` only on a camera whose pipeline can
|
|
50708
|
+
* produce package events (the same answer the detection sensors use).
|
|
50709
|
+
*/
|
|
50710
|
+
function smartVisionObjectDetectionSensor(classes) {
|
|
50711
|
+
return {
|
|
50712
|
+
type: "AlexaInterface",
|
|
50713
|
+
interface: "Alexa.SmartVision.ObjectDetectionSensor",
|
|
50714
|
+
version: "1.0",
|
|
50715
|
+
configuration: { objectDetectionConfiguration: classes.map((imageNetClass) => ({ imageNetClass })) }
|
|
50716
|
+
};
|
|
50717
|
+
}
|
|
50718
|
+
/**
|
|
50719
|
+
* `Alexa.DataController` v1.0, instance {@link SMART_VISION_DATA_INSTANCE} —
|
|
50720
|
+
* how the Alexa app reads the camera's detection history back
|
|
50721
|
+
* (`ReportData` / `DeleteData`, `handlers/data-controller.ts`). Amazon's
|
|
50722
|
+
* ObjectDetectionSensor example declares it beside the sensor; the instance
|
|
50723
|
+
* name is the one that example uses.
|
|
50724
|
+
*/
|
|
50725
|
+
var SMART_VISION_DATA_CONTROLLER = {
|
|
50726
|
+
type: "AlexaInterface",
|
|
50727
|
+
interface: "Alexa.DataController",
|
|
50728
|
+
instance: SMART_VISION_DATA_INSTANCE,
|
|
50729
|
+
version: "1.0",
|
|
50730
|
+
configuration: {
|
|
50731
|
+
targetCapability: {
|
|
50732
|
+
name: "Alexa.SmartVision.ObjectDetectionSensor",
|
|
50733
|
+
version: "1.0"
|
|
50734
|
+
},
|
|
50735
|
+
dataRetrievalSchema: {
|
|
50736
|
+
type: "JSON",
|
|
50737
|
+
schema: "SmartVisionData"
|
|
50738
|
+
},
|
|
50739
|
+
supportedAccess: ["BY_IDENTIFIER", "BY_TIMESTAMP_RANGE"]
|
|
50740
|
+
}
|
|
50741
|
+
};
|
|
50481
50742
|
/**
|
|
50482
50743
|
* `Alexa.SmartVision.SnapshotProvider` v1.1 — the camera's still in the Alexa
|
|
50483
50744
|
* app (R2, D773). Available in it-IT and en-GB, unlike object detection.
|
|
@@ -50701,6 +50962,11 @@ function buildParentCameraEndpoint(input) {
|
|
|
50701
50962
|
];
|
|
50702
50963
|
if (input.isDoorbell) capabilities.push(DOORBELL_EVENT_SOURCE);
|
|
50703
50964
|
if (input.hasMotion) capabilities.push(MOTION_SENSOR);
|
|
50965
|
+
const visionClasses = input.objectDetectionClasses ?? [];
|
|
50966
|
+
if (visionClasses.length > 0) {
|
|
50967
|
+
capabilities.push(smartVisionObjectDetectionSensor(visionClasses));
|
|
50968
|
+
capabilities.push(SMART_VISION_DATA_CONTROLLER);
|
|
50969
|
+
}
|
|
50704
50970
|
if (input.snapshotProvider === true) capabilities.push(SMART_VISION_SNAPSHOT_PROVIDER);
|
|
50705
50971
|
const presetNames = distinctPresetNames(input.ptzPresetNames);
|
|
50706
50972
|
if (presetNames.length >= MIN_MODE_CONTROLLER_MODES) capabilities.push(ptzPresetModeController(presetNames));
|
|
@@ -50998,6 +51264,7 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
|
|
|
50998
51264
|
});
|
|
50999
51265
|
return [];
|
|
51000
51266
|
});
|
|
51267
|
+
const objectDetectionClasses = deps.objectDetection === void 0 ? [] : await deps.objectDetection(parentInfo.id);
|
|
51001
51268
|
const out = [];
|
|
51002
51269
|
out.push(buildParentCameraEndpoint({
|
|
51003
51270
|
deviceId: parentInfo.id,
|
|
@@ -51008,7 +51275,8 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
|
|
|
51008
51275
|
hasMotion,
|
|
51009
51276
|
ptzPresetNames,
|
|
51010
51277
|
hasPtz,
|
|
51011
|
-
snapshotProvider: deps.snapshots === true
|
|
51278
|
+
snapshotProvider: deps.snapshots === true,
|
|
51279
|
+
objectDetectionClasses
|
|
51012
51280
|
}));
|
|
51013
51281
|
if (deps.detectionClasses !== void 0) {
|
|
51014
51282
|
const classes = await deps.detectionClasses(parentInfo.id);
|
|
@@ -51330,8 +51598,13 @@ var ALLOWED_INTERFACE_VERSIONS = {
|
|
|
51330
51598
|
"Alexa.SecurityPanelController": "3",
|
|
51331
51599
|
"Alexa.LockController": "3",
|
|
51332
51600
|
"Alexa.SmartVision.SnapshotProvider": "1.1",
|
|
51333
|
-
"Alexa.SmartVision.ObjectDetectionSensor": "1.0"
|
|
51601
|
+
"Alexa.SmartVision.ObjectDetectionSensor": "1.0",
|
|
51602
|
+
"Alexa.DataController": "1.0"
|
|
51334
51603
|
};
|
|
51604
|
+
/** `imageNetClass` values Amazon accepts (alexa-smartvision-objectdetectionsensor). */
|
|
51605
|
+
var SMART_VISION_CLASSES = new Set(["person", "package"]);
|
|
51606
|
+
/** `supportedAccess` values Amazon documents (alexa-datacontroller). */
|
|
51607
|
+
var DATA_CONTROLLER_ACCESS = new Set(["BY_IDENTIFIER", "BY_TIMESTAMP_RANGE"]);
|
|
51335
51608
|
/** Interfaces that need an `instance`. */
|
|
51336
51609
|
var INSTANCED = new Set([
|
|
51337
51610
|
"Alexa.ModeController",
|
|
@@ -51378,7 +51651,37 @@ function capabilityKey$1(c) {
|
|
|
51378
51651
|
function friendlyTexts(c) {
|
|
51379
51652
|
return (c.capabilityResources?.friendlyNames ?? []).map((n) => n.value.text);
|
|
51380
51653
|
}
|
|
51381
|
-
function
|
|
51654
|
+
function field(c, key) {
|
|
51655
|
+
return c.configuration?.[key];
|
|
51656
|
+
}
|
|
51657
|
+
function objectField(value, key) {
|
|
51658
|
+
return typeof value === "object" && value !== null ? Reflect.get(value, key) : void 0;
|
|
51659
|
+
}
|
|
51660
|
+
/** ObjectDetectionSensor (D779): a non-empty, duplicate-free list of the two
|
|
51661
|
+
* classes Amazon knows, and no reportable properties (it defines none). */
|
|
51662
|
+
function validateObjectDetection(c, push) {
|
|
51663
|
+
const list = field(c, "objectDetectionConfiguration");
|
|
51664
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
51665
|
+
push("configuration.objectDetectionConfiguration must list at least one class");
|
|
51666
|
+
return;
|
|
51667
|
+
}
|
|
51668
|
+
const classes = list.map((entry) => objectField(entry, "imageNetClass"));
|
|
51669
|
+
for (const cls of classes) if (typeof cls !== "string" || !SMART_VISION_CLASSES.has(cls)) push(`imageNetClass ${String(cls)} is not person or package`);
|
|
51670
|
+
if (new Set(classes).size !== classes.length) push("imageNetClass listed twice");
|
|
51671
|
+
if (c.properties !== void 0) push("declares properties; the interface defines none");
|
|
51672
|
+
}
|
|
51673
|
+
/** DataController (D779): an instance, a schema, a documented access mode, and
|
|
51674
|
+
* a target capability the SAME endpoint declares. */
|
|
51675
|
+
function validateDataController(c, declared, push) {
|
|
51676
|
+
if (c.instance === void 0 || c.instance.length === 0) push("instance is required");
|
|
51677
|
+
const schema = field(c, "dataRetrievalSchema");
|
|
51678
|
+
if (typeof objectField(schema, "type") !== "string" || typeof objectField(schema, "schema") !== "string") push("configuration.dataRetrievalSchema {type, schema} is required");
|
|
51679
|
+
const target = objectField(field(c, "targetCapability"), "name");
|
|
51680
|
+
if (typeof target !== "string" || !declared.has(target)) push("configuration.targetCapability must name an interface this endpoint declares");
|
|
51681
|
+
const access = field(c, "supportedAccess");
|
|
51682
|
+
if (!Array.isArray(access) || access.length === 0 || access.some((a) => typeof a !== "string" || !DATA_CONTROLLER_ACCESS.has(a))) push("configuration.supportedAccess must list BY_IDENTIFIER and/or BY_TIMESTAMP_RANGE");
|
|
51683
|
+
}
|
|
51684
|
+
function validateCapability(endpointId, c, out, declared) {
|
|
51382
51685
|
const push = (rule) => {
|
|
51383
51686
|
out.push({
|
|
51384
51687
|
endpointId,
|
|
@@ -51407,6 +51710,8 @@ function validateCapability(endpointId, c, out) {
|
|
|
51407
51710
|
if (new Set(values).size !== values.length) push("supportedModes values must be unique");
|
|
51408
51711
|
}
|
|
51409
51712
|
}
|
|
51713
|
+
if (c.interface === "Alexa.SmartVision.ObjectDetectionSensor") validateObjectDetection(c, push);
|
|
51714
|
+
if (c.interface === "Alexa.DataController") validateDataController(c, declared, push);
|
|
51410
51715
|
if (c.interface === "Alexa.SmartVision.SnapshotProvider") {
|
|
51411
51716
|
if (typeof c.configuration?.["isAvailable"] !== "boolean") push("configuration.isAvailable is required");
|
|
51412
51717
|
}
|
|
@@ -51439,7 +51744,7 @@ function validateAlexaEndpoint(endpoint) {
|
|
|
51439
51744
|
const interfaces = new Set(caps.map((c) => c.interface));
|
|
51440
51745
|
for (const needs of NEEDS_ENDPOINT_HEALTH) if (interfaces.has(needs) && !interfaces.has("Alexa.EndpointHealth")) push(`${needs} requires Alexa.EndpointHealth`);
|
|
51441
51746
|
if (interfaces.has("Alexa.RTCSessionController") && endpoint.displayCategories[0] !== "CAMERA") push("an RTCSessionController endpoint must list CAMERA first");
|
|
51442
|
-
for (const c of caps) validateCapability(id, c, out);
|
|
51747
|
+
for (const c of caps) validateCapability(id, c, out, interfaces);
|
|
51443
51748
|
return out;
|
|
51444
51749
|
}
|
|
51445
51750
|
/** Split endpoints into the valid ones and the violations of the rest. */
|
|
@@ -51737,6 +52042,543 @@ async function handleInitializeCameraStreams(header, endpointRaw, payloadRaw, de
|
|
|
51737
52042
|
} };
|
|
51738
52043
|
}
|
|
51739
52044
|
//#endregion
|
|
52045
|
+
//#region src/detection-sensors/detection-tracker.ts
|
|
52046
|
+
/**
|
|
52047
|
+
* Which detection classes are currently present on each camera (R1, D772).
|
|
52048
|
+
*
|
|
52049
|
+
* Fed by the pipeline's track lifecycle (`PipelineAnalyticsTrackLifecycle`,
|
|
52050
|
+
* `data.deviceId` — these events carry no device on their `source`) and by
|
|
52051
|
+
* package deliveries. A class is present while at least one of ITS tracks is
|
|
52052
|
+
* open: the set is per `(camera, class)` and per `trackId`, so the end of one
|
|
52053
|
+
* person's track does not clear "Person" while another person is still in
|
|
52054
|
+
* view (the HA export's per-event flip has exactly that bug).
|
|
52055
|
+
*
|
|
52056
|
+
* Events are telemetry and may be dropped (D8), and the pipeline does not
|
|
52057
|
+
* promise an `end` for every `start` (a zero-media false birth or a discarded
|
|
52058
|
+
* stale track ends silently). So {@link reconcile} replaces a camera's sets
|
|
52059
|
+
* with an authoritative read of its ACTIVE tracks; a sensor can therefore never
|
|
52060
|
+
* stay "detected" past one reconcile after its last track closed. A read that
|
|
52061
|
+
* fails is not passed in — the sets then stay as they were.
|
|
52062
|
+
*
|
|
52063
|
+
* Emits a transition per `(camera, class)` only when presence changes; the
|
|
52064
|
+
* Alexa-facing hold (≥ 30 s DETECTED before NOT_DETECTED) is the gateway's.
|
|
52065
|
+
*/
|
|
52066
|
+
/** A package delivery is an instant, not a track: it is present this long. */
|
|
52067
|
+
var PACKAGE_PULSE_MS = 1e3;
|
|
52068
|
+
/** The detection class a pipeline class name belongs to, or null. Pipeline
|
|
52069
|
+
* classes are already macros; a raw COCO label is mapped the same way the
|
|
52070
|
+
* executor maps it. */
|
|
52071
|
+
function detectionClassOf(className) {
|
|
52072
|
+
if (className === void 0) return null;
|
|
52073
|
+
const lower = className.toLowerCase();
|
|
52074
|
+
const isClass = (v) => DETECTION_SENSOR_CLASSES.includes(v);
|
|
52075
|
+
if (isClass(lower)) return lower;
|
|
52076
|
+
const mapped = COCO_TO_MACRO.mapping[lower];
|
|
52077
|
+
return mapped !== void 0 && isClass(mapped) ? mapped : null;
|
|
52078
|
+
}
|
|
52079
|
+
var DetectionTracker = class {
|
|
52080
|
+
onTransition;
|
|
52081
|
+
/** `${deviceId}|${class}` → open track ids. */
|
|
52082
|
+
open = /* @__PURE__ */ new Map();
|
|
52083
|
+
pulseTimers = /* @__PURE__ */ new Set();
|
|
52084
|
+
constructor(onTransition) {
|
|
52085
|
+
this.onTransition = onTransition;
|
|
52086
|
+
}
|
|
52087
|
+
onTrackLifecycle(event) {
|
|
52088
|
+
const cls = detectionClassOf(event.bestClassName ?? event.classes?.[0]);
|
|
52089
|
+
if (cls === null) return;
|
|
52090
|
+
if (event.phase === "end") {
|
|
52091
|
+
this.remove(event.deviceId, cls, event.trackId);
|
|
52092
|
+
for (const other of DETECTION_SENSOR_CLASSES) if (other !== cls) this.remove(event.deviceId, other, event.trackId);
|
|
52093
|
+
return;
|
|
52094
|
+
}
|
|
52095
|
+
this.add(event.deviceId, cls, event.trackId);
|
|
52096
|
+
}
|
|
52097
|
+
/** A package was delivered: "Package" is detected, as a pulse. */
|
|
52098
|
+
onPackageDelivered(deviceId, entryId) {
|
|
52099
|
+
const id = `package:${entryId}`;
|
|
52100
|
+
this.add(deviceId, "package", id);
|
|
52101
|
+
const timer = setTimeout(() => {
|
|
52102
|
+
this.pulseTimers.delete(timer);
|
|
52103
|
+
this.remove(deviceId, "package", id);
|
|
52104
|
+
}, PACKAGE_PULSE_MS);
|
|
52105
|
+
timer.unref?.();
|
|
52106
|
+
this.pulseTimers.add(timer);
|
|
52107
|
+
}
|
|
52108
|
+
/** Replace a camera's open tracks with what the pipeline says is ACTIVE. */
|
|
52109
|
+
reconcile(deviceId, active) {
|
|
52110
|
+
const truth = /* @__PURE__ */ new Map();
|
|
52111
|
+
for (const track of active) {
|
|
52112
|
+
const cls = detectionClassOf(track.className);
|
|
52113
|
+
if (cls === null) continue;
|
|
52114
|
+
const set = truth.get(cls) ?? /* @__PURE__ */ new Set();
|
|
52115
|
+
set.add(track.trackId);
|
|
52116
|
+
truth.set(cls, set);
|
|
52117
|
+
}
|
|
52118
|
+
for (const cls of DETECTION_SENSOR_CLASSES) {
|
|
52119
|
+
const key = `${deviceId}|${cls}`;
|
|
52120
|
+
const before = this.open.get(key) ?? /* @__PURE__ */ new Set();
|
|
52121
|
+
const after = truth.get(cls) ?? /* @__PURE__ */ new Set();
|
|
52122
|
+
for (const id of before) if (id.startsWith("package:")) after.add(id);
|
|
52123
|
+
this.replace(deviceId, cls, before, after);
|
|
52124
|
+
}
|
|
52125
|
+
}
|
|
52126
|
+
/** Cameras with at least one class present — the ones worth reconciling. */
|
|
52127
|
+
camerasWithPresence() {
|
|
52128
|
+
const out = /* @__PURE__ */ new Set();
|
|
52129
|
+
for (const [key, set] of this.open) if (set.size > 0) out.add(Number(key.split("|")[0]));
|
|
52130
|
+
return [...out];
|
|
52131
|
+
}
|
|
52132
|
+
isPresent(deviceId, cls) {
|
|
52133
|
+
return (this.open.get(`${deviceId}|${cls}`)?.size ?? 0) > 0;
|
|
52134
|
+
}
|
|
52135
|
+
clear() {
|
|
52136
|
+
this.open.clear();
|
|
52137
|
+
for (const t of this.pulseTimers) clearTimeout(t);
|
|
52138
|
+
this.pulseTimers.clear();
|
|
52139
|
+
}
|
|
52140
|
+
add(deviceId, cls, id) {
|
|
52141
|
+
const key = `${deviceId}|${cls}`;
|
|
52142
|
+
const set = this.open.get(key) ?? /* @__PURE__ */ new Set();
|
|
52143
|
+
const was = set.size > 0;
|
|
52144
|
+
set.add(id);
|
|
52145
|
+
this.open.set(key, set);
|
|
52146
|
+
if (!was) this.onTransition(deviceId, cls, true);
|
|
52147
|
+
}
|
|
52148
|
+
remove(deviceId, cls, id) {
|
|
52149
|
+
const set = this.open.get(`${deviceId}|${cls}`);
|
|
52150
|
+
if (set === void 0 || !set.delete(id)) return;
|
|
52151
|
+
if (set.size === 0) this.onTransition(deviceId, cls, false);
|
|
52152
|
+
}
|
|
52153
|
+
replace(deviceId, cls, before, after) {
|
|
52154
|
+
this.open.set(`${deviceId}|${cls}`, after);
|
|
52155
|
+
if (before.size > 0 && after.size === 0) this.onTransition(deviceId, cls, false);
|
|
52156
|
+
else if (before.size === 0 && after.size > 0) this.onTransition(deviceId, cls, true);
|
|
52157
|
+
}
|
|
52158
|
+
};
|
|
52159
|
+
//#endregion
|
|
52160
|
+
//#region src/vision/vision-ids.ts
|
|
52161
|
+
/**
|
|
52162
|
+
* The identifiers an `ObjectDetection` event and a `DataReport` item carry
|
|
52163
|
+
* (R4, D779).
|
|
52164
|
+
*
|
|
52165
|
+
* - `objectIdentifier`: "a UUID identifying a specific physical object" — one
|
|
52166
|
+
* per pipeline track. A UUIDv5 of `<deviceId>:<trackId>` under this hub's
|
|
52167
|
+
* namespace, so it is stable for the track's life and across restarts.
|
|
52168
|
+
* - `eventIdentifier`: Amazon requires a version-4 UUID per event in the
|
|
52169
|
+
* history. This skill posts ONE event per track, so the event id is derived
|
|
52170
|
+
* from the track too — a v4-shaped UUID whose 122 "random" bits are a
|
|
52171
|
+
* SHA-256 of the hub namespace and the track. Deriving it (instead of
|
|
52172
|
+
* `randomUUID()`) is what lets the history (`ReportData` by time range)
|
|
52173
|
+
* hand Alexa the SAME id the live event carried, without a lookup table
|
|
52174
|
+
* that would have to outlive the tracks it describes.
|
|
52175
|
+
*
|
|
52176
|
+
* The hub namespace is a random UUID generated once and stored hidden in the
|
|
52177
|
+
* addon settings, so two hubs never mint the same ids for the same track id.
|
|
52178
|
+
*/
|
|
52179
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
52180
|
+
function uuidBytes(uuid) {
|
|
52181
|
+
if (!UUID.test(uuid)) throw new Error(`not a UUID: ${uuid}`);
|
|
52182
|
+
return Buffer.from(uuid.replaceAll("-", ""), "hex");
|
|
52183
|
+
}
|
|
52184
|
+
function formatUuid(bytes) {
|
|
52185
|
+
const hex = bytes.subarray(0, 16).toString("hex");
|
|
52186
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
52187
|
+
}
|
|
52188
|
+
function stamp$1(bytes, version) {
|
|
52189
|
+
const out = Buffer.from(bytes.subarray(0, 16));
|
|
52190
|
+
out[6] = (out[6] ?? 0) & 15 | version << 4;
|
|
52191
|
+
out[8] = (out[8] ?? 0) & 63 | 128;
|
|
52192
|
+
return out;
|
|
52193
|
+
}
|
|
52194
|
+
/** RFC 4122 UUIDv5 (SHA-1, name-based). */
|
|
52195
|
+
function uuidV5(namespace, name) {
|
|
52196
|
+
return formatUuid(stamp$1(createHash("sha1").update(uuidBytes(namespace)).update(Buffer.from(name, "utf8")).digest(), 5));
|
|
52197
|
+
}
|
|
52198
|
+
/** A version-4-formatted UUID whose bits are a hash — see the file header. */
|
|
52199
|
+
function hashedUuidV4(namespace, name) {
|
|
52200
|
+
return formatUuid(stamp$1(createHash("sha256").update(uuidBytes(namespace)).update(name, "utf8").digest(), 4));
|
|
52201
|
+
}
|
|
52202
|
+
function trackName(deviceId, trackId) {
|
|
52203
|
+
return `${deviceId}:${trackId}`;
|
|
52204
|
+
}
|
|
52205
|
+
function objectIdentifierFor(namespace, deviceId, trackId) {
|
|
52206
|
+
return uuidV5(namespace, trackName(deviceId, trackId));
|
|
52207
|
+
}
|
|
52208
|
+
/** One event per track AND class: a track re-classified person → package is a
|
|
52209
|
+
* second event about the same object. */
|
|
52210
|
+
function eventIdentifierFor(namespace, deviceId, trackId, imageNetClass) {
|
|
52211
|
+
return hashedUuidV4(namespace, `event|${trackName(deviceId, trackId)}|${imageNetClass}`);
|
|
52212
|
+
}
|
|
52213
|
+
//#endregion
|
|
52214
|
+
//#region src/vision/vision-state.ts
|
|
52215
|
+
/**
|
|
52216
|
+
* Per-camera object-detection state the Alexa side owns (R4, D779), in this
|
|
52217
|
+
* addon's per-device store (`<addonId>:addon-devices`, key
|
|
52218
|
+
* {@link VISION_STATE_KEY}):
|
|
52219
|
+
*
|
|
52220
|
+
* - `classes` — what the user last chose in the Alexa app
|
|
52221
|
+
* (`SetObjectDetectionClasses`). Absent = never told = every declared class.
|
|
52222
|
+
* - `index` — eventIdentifier → track, for `ReportData` BY_IDENTIFIER. Ids are
|
|
52223
|
+
* derived from the track (`vision-ids.ts`), so the index only has to answer
|
|
52224
|
+
* the reverse direction; bounded to the newest {@link MAX_INDEX} entries.
|
|
52225
|
+
* - `hidden` — events `DeleteData` removed. Hidden from Alexa ONLY: deleting
|
|
52226
|
+
* CamStack footage is not something a voice assistant may do. Bounded.
|
|
52227
|
+
*
|
|
52228
|
+
* Reads are cached per camera; writes are serialized per camera (one
|
|
52229
|
+
* read-modify-write at a time), the same shape as `DetectionClassSource`.
|
|
52230
|
+
*/
|
|
52231
|
+
var VISION_STATE_KEY = "alexaVision";
|
|
52232
|
+
var IndexEntrySchema = object({
|
|
52233
|
+
/** eventIdentifier */
|
|
52234
|
+
e: string(),
|
|
52235
|
+
/** trackId */
|
|
52236
|
+
t: string(),
|
|
52237
|
+
/** imageNetClass */
|
|
52238
|
+
c: _enum(SMART_VISION_CLASSES$1),
|
|
52239
|
+
/** ms epoch of the detection */
|
|
52240
|
+
at: number()
|
|
52241
|
+
});
|
|
52242
|
+
var VisionStateSchema = object({
|
|
52243
|
+
classes: array(_enum(SMART_VISION_CLASSES$1)).optional(),
|
|
52244
|
+
index: array(IndexEntrySchema).default([]),
|
|
52245
|
+
hidden: array(string()).default([])
|
|
52246
|
+
});
|
|
52247
|
+
var EMPTY = {
|
|
52248
|
+
index: [],
|
|
52249
|
+
hidden: []
|
|
52250
|
+
};
|
|
52251
|
+
function parseVisionState(store) {
|
|
52252
|
+
const parsed = VisionStateSchema.safeParse(store[VISION_STATE_KEY]);
|
|
52253
|
+
return parsed.success ? parsed.data : EMPTY;
|
|
52254
|
+
}
|
|
52255
|
+
var VisionStateStore = class {
|
|
52256
|
+
deps;
|
|
52257
|
+
cache = /* @__PURE__ */ new Map();
|
|
52258
|
+
chains = /* @__PURE__ */ new Map();
|
|
52259
|
+
constructor(deps) {
|
|
52260
|
+
this.deps = deps;
|
|
52261
|
+
}
|
|
52262
|
+
async get(deviceId) {
|
|
52263
|
+
const cached = this.cache.get(deviceId);
|
|
52264
|
+
if (cached !== void 0) return cached;
|
|
52265
|
+
const state = parseVisionState(await this.deps.read(deviceId));
|
|
52266
|
+
this.cache.set(deviceId, state);
|
|
52267
|
+
return state;
|
|
52268
|
+
}
|
|
52269
|
+
/** The user's class choice from the Alexa app. */
|
|
52270
|
+
setClasses(deviceId, classes) {
|
|
52271
|
+
return this.update(deviceId, (s) => ({
|
|
52272
|
+
...s,
|
|
52273
|
+
classes: [...classes]
|
|
52274
|
+
}));
|
|
52275
|
+
}
|
|
52276
|
+
/** Record events Alexa was (or is about to be) told about. Known ids are
|
|
52277
|
+
* left alone, so re-reading the history costs no write. */
|
|
52278
|
+
async remember(deviceId, entries) {
|
|
52279
|
+
const known = new Set((await this.get(deviceId)).index.map((e) => e.e));
|
|
52280
|
+
const fresh = entries.filter((e) => !known.has(e.e));
|
|
52281
|
+
if (fresh.length === 0) return;
|
|
52282
|
+
await this.update(deviceId, (s) => {
|
|
52283
|
+
const merged = [...s.index.filter((e) => !fresh.some((f) => f.e === e.e)), ...fresh];
|
|
52284
|
+
merged.sort((a, b) => a.at - b.at);
|
|
52285
|
+
return {
|
|
52286
|
+
...s,
|
|
52287
|
+
index: merged.slice(-300)
|
|
52288
|
+
};
|
|
52289
|
+
});
|
|
52290
|
+
}
|
|
52291
|
+
async lookup(deviceId, eventIdentifier) {
|
|
52292
|
+
return (await this.get(deviceId)).index.find((e) => e.e === eventIdentifier) ?? null;
|
|
52293
|
+
}
|
|
52294
|
+
hide(deviceId, eventIdentifiers) {
|
|
52295
|
+
return this.update(deviceId, (s) => {
|
|
52296
|
+
const hidden = [...s.hidden.filter((h) => !eventIdentifiers.includes(h)), ...eventIdentifiers];
|
|
52297
|
+
return {
|
|
52298
|
+
...s,
|
|
52299
|
+
hidden: hidden.slice(-1e3)
|
|
52300
|
+
};
|
|
52301
|
+
});
|
|
52302
|
+
}
|
|
52303
|
+
/** Forget the cached copy (tests; a store written behind our back). */
|
|
52304
|
+
invalidate(deviceId) {
|
|
52305
|
+
this.cache.delete(deviceId);
|
|
52306
|
+
}
|
|
52307
|
+
update(deviceId, fn) {
|
|
52308
|
+
const run = (this.chains.get(deviceId) ?? Promise.resolve()).then(async () => {
|
|
52309
|
+
const next = fn(await this.get(deviceId));
|
|
52310
|
+
await this.deps.write(deviceId, { [VISION_STATE_KEY]: next });
|
|
52311
|
+
this.cache.set(deviceId, next);
|
|
52312
|
+
});
|
|
52313
|
+
this.chains.set(deviceId, run.catch(() => void 0));
|
|
52314
|
+
return run;
|
|
52315
|
+
}
|
|
52316
|
+
};
|
|
52317
|
+
/**
|
|
52318
|
+
* The classes a camera reports to Alexa: the ones it declares (`person`, plus
|
|
52319
|
+
* `package` where the pipeline produces it) that the user has not turned off
|
|
52320
|
+
* in the Alexa app.
|
|
52321
|
+
*/
|
|
52322
|
+
function reportableVisionClasses(declared, state) {
|
|
52323
|
+
const chosen = state.classes;
|
|
52324
|
+
return declared.filter((c) => chosen === void 0 || chosen.includes(c));
|
|
52325
|
+
}
|
|
52326
|
+
/** Rows read per round while filling a page, and rounds per page. */
|
|
52327
|
+
var FETCH_ROWS = 50;
|
|
52328
|
+
var MAX_ROUNDS = 5;
|
|
52329
|
+
/** Rows a time-range DeleteData hides at most. */
|
|
52330
|
+
var MAX_DELETE_ROWS = 500;
|
|
52331
|
+
var QuerySchema = object({
|
|
52332
|
+
ids: array(string()).max(10).optional(),
|
|
52333
|
+
fromTime: string().optional(),
|
|
52334
|
+
toTime: string().optional()
|
|
52335
|
+
});
|
|
52336
|
+
var ReportDataPayloadSchema = object({
|
|
52337
|
+
queryType: _enum([
|
|
52338
|
+
"QUERY_BY_IDENTIFIER",
|
|
52339
|
+
"QUERY_BY_TIMESTAMP_RANGE",
|
|
52340
|
+
"QUERY_ALL"
|
|
52341
|
+
]),
|
|
52342
|
+
query: QuerySchema.optional(),
|
|
52343
|
+
maxResults: number().int().optional(),
|
|
52344
|
+
nextToken: string().optional()
|
|
52345
|
+
});
|
|
52346
|
+
var DeleteDataPayloadSchema = ReportDataPayloadSchema.omit({
|
|
52347
|
+
maxResults: true,
|
|
52348
|
+
nextToken: true
|
|
52349
|
+
});
|
|
52350
|
+
var SetClassesPayloadSchema = object({ objectDetectionClasses: array(object({ imageNetClass: string() })) });
|
|
52351
|
+
var CursorSchema = object({
|
|
52352
|
+
l: number(),
|
|
52353
|
+
t: string()
|
|
52354
|
+
});
|
|
52355
|
+
function encodeCursor(cursor) {
|
|
52356
|
+
return `v1.${Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url")}`;
|
|
52357
|
+
}
|
|
52358
|
+
function decodeCursor(token) {
|
|
52359
|
+
if (!token.startsWith("v1.")) return null;
|
|
52360
|
+
try {
|
|
52361
|
+
const parsed = CursorSchema.safeParse(JSON.parse(Buffer.from(token.slice(3), "base64url").toString("utf8")));
|
|
52362
|
+
return parsed.success ? parsed.data : null;
|
|
52363
|
+
} catch {
|
|
52364
|
+
return null;
|
|
52365
|
+
}
|
|
52366
|
+
}
|
|
52367
|
+
/** Before the cursor in (`lastSeen` DESC, `trackId` DESC) order? */
|
|
52368
|
+
function isAfterCursor(row, cursor) {
|
|
52369
|
+
if (cursor === null) return true;
|
|
52370
|
+
return row.lastSeen < cursor.l || row.lastSeen === cursor.l && row.trackId < cursor.t;
|
|
52371
|
+
}
|
|
52372
|
+
function cameraOf(endpoint) {
|
|
52373
|
+
if (endpoint === void 0) return null;
|
|
52374
|
+
const parsed = parseCameraEndpointId(endpoint.endpointId);
|
|
52375
|
+
return parsed.kind === "camera" ? parsed.deviceId : null;
|
|
52376
|
+
}
|
|
52377
|
+
function isVisionClass$1(cls) {
|
|
52378
|
+
return SMART_VISION_CLASSES$1.includes(cls);
|
|
52379
|
+
}
|
|
52380
|
+
async function prepare(ctx, deviceId) {
|
|
52381
|
+
const state = await ctx.vision.get(deviceId);
|
|
52382
|
+
return {
|
|
52383
|
+
deviceId,
|
|
52384
|
+
reportable: reportableVisionClasses(await ctx.declaredClasses(deviceId), state),
|
|
52385
|
+
hidden: new Set(state.hidden)
|
|
52386
|
+
};
|
|
52387
|
+
}
|
|
52388
|
+
/** A track as a history entry, or null when it is not one this camera shows. */
|
|
52389
|
+
function entryOf(ctx, p, track) {
|
|
52390
|
+
const cls = detectionClassOf(track.className);
|
|
52391
|
+
if (!isVisionClass$1(cls) || !p.reportable.includes(cls)) return null;
|
|
52392
|
+
const e = eventIdentifierFor(ctx.namespace(), p.deviceId, track.trackId, cls);
|
|
52393
|
+
if (p.hidden.has(e)) return null;
|
|
52394
|
+
return {
|
|
52395
|
+
e,
|
|
52396
|
+
t: track.trackId,
|
|
52397
|
+
c: cls,
|
|
52398
|
+
at: track.firstSeen
|
|
52399
|
+
};
|
|
52400
|
+
}
|
|
52401
|
+
function dataItem(ctx, p, entry, userId) {
|
|
52402
|
+
return {
|
|
52403
|
+
eventIdentifier: entry.e,
|
|
52404
|
+
imageNetClass: entry.c,
|
|
52405
|
+
objectIdentifier: objectIdentifierFor(ctx.namespace(), p.deviceId, entry.t),
|
|
52406
|
+
...ctx.imagesFor(userId, p.deviceId, entry.t, entry.at)
|
|
52407
|
+
};
|
|
52408
|
+
}
|
|
52409
|
+
function rangeOf(payload) {
|
|
52410
|
+
if (payload.queryType === "QUERY_ALL") return {
|
|
52411
|
+
from: 0,
|
|
52412
|
+
to: Date.now()
|
|
52413
|
+
};
|
|
52414
|
+
const from = Date.parse(payload.query?.fromTime ?? "");
|
|
52415
|
+
const to = Date.parse(payload.query?.toTime ?? "");
|
|
52416
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || from > to) return null;
|
|
52417
|
+
return {
|
|
52418
|
+
from,
|
|
52419
|
+
to
|
|
52420
|
+
};
|
|
52421
|
+
}
|
|
52422
|
+
function dataControllerEvent(header, endpointId, name, payload) {
|
|
52423
|
+
return { event: {
|
|
52424
|
+
header: {
|
|
52425
|
+
namespace: "Alexa.DataController",
|
|
52426
|
+
name,
|
|
52427
|
+
instance: header.instance ?? "Camera.SmartVisionData",
|
|
52428
|
+
messageId: randomUUID(),
|
|
52429
|
+
...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
|
|
52430
|
+
payloadVersion: "1.0"
|
|
52431
|
+
},
|
|
52432
|
+
endpoint: { endpointId },
|
|
52433
|
+
payload
|
|
52434
|
+
} };
|
|
52435
|
+
}
|
|
52436
|
+
/** One page of the time-range history, starting after `start`. */
|
|
52437
|
+
async function rangePage(ctx, p, range, start, max) {
|
|
52438
|
+
const entries = [];
|
|
52439
|
+
let cursor = start;
|
|
52440
|
+
for (let round = 0; round < MAX_ROUNDS; round += 1) {
|
|
52441
|
+
const rows = await ctx.listTracks(p.deviceId, {
|
|
52442
|
+
since: range.from,
|
|
52443
|
+
until: cursor === null ? range.to : Math.min(cursor.l, range.to),
|
|
52444
|
+
limit: FETCH_ROWS
|
|
52445
|
+
});
|
|
52446
|
+
for (const row of rows) {
|
|
52447
|
+
if (!isAfterCursor(row, cursor)) continue;
|
|
52448
|
+
cursor = {
|
|
52449
|
+
l: row.lastSeen,
|
|
52450
|
+
t: row.trackId
|
|
52451
|
+
};
|
|
52452
|
+
const entry = entryOf(ctx, p, row);
|
|
52453
|
+
if (entry === null) continue;
|
|
52454
|
+
entries.push(entry);
|
|
52455
|
+
if (entries.length === max) return {
|
|
52456
|
+
entries,
|
|
52457
|
+
next: cursor
|
|
52458
|
+
};
|
|
52459
|
+
}
|
|
52460
|
+
if (rows.length < FETCH_ROWS) return {
|
|
52461
|
+
entries,
|
|
52462
|
+
next: null
|
|
52463
|
+
};
|
|
52464
|
+
}
|
|
52465
|
+
return {
|
|
52466
|
+
entries,
|
|
52467
|
+
next: cursor
|
|
52468
|
+
};
|
|
52469
|
+
}
|
|
52470
|
+
async function handleReportData(header, endpoint, payloadRaw, ctx, caller) {
|
|
52471
|
+
const deviceId = cameraOf(endpoint);
|
|
52472
|
+
if (deviceId === null || endpoint === void 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "event history is served for camera endpoints");
|
|
52473
|
+
const parsed = ReportDataPayloadSchema.safeParse(payloadRaw);
|
|
52474
|
+
if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", "ReportData payload shape unexpected");
|
|
52475
|
+
const payload = parsed.data;
|
|
52476
|
+
const max = Math.min(Math.max(payload.maxResults ?? 10, 1), 10);
|
|
52477
|
+
const p = await prepare(ctx, deviceId);
|
|
52478
|
+
let entries = [];
|
|
52479
|
+
let next = null;
|
|
52480
|
+
if (payload.queryType === "QUERY_BY_IDENTIFIER") for (const id of (payload.query?.ids ?? []).slice(0, max)) {
|
|
52481
|
+
const known = await ctx.vision.lookup(deviceId, id);
|
|
52482
|
+
if (known === null) continue;
|
|
52483
|
+
const track = await ctx.getTrack(deviceId, known.t);
|
|
52484
|
+
const entry = track === null ? null : entryOf(ctx, p, track);
|
|
52485
|
+
if (entry !== null && entry.e === id) entries.push(entry);
|
|
52486
|
+
}
|
|
52487
|
+
else {
|
|
52488
|
+
const range = rangeOf(payload);
|
|
52489
|
+
if (range === null) return errorEnvelope(header, "INVALID_DIRECTIVE", "fromTime/toTime missing or invalid");
|
|
52490
|
+
const start = payload.nextToken === void 0 ? null : decodeCursor(payload.nextToken);
|
|
52491
|
+
if (payload.nextToken !== void 0 && start === null) return errorEnvelope(header, "INVALID_DIRECTIVE", "nextToken not recognised");
|
|
52492
|
+
const page = await rangePage(ctx, p, range, start, max);
|
|
52493
|
+
entries = page.entries;
|
|
52494
|
+
next = page.next;
|
|
52495
|
+
await ctx.vision.remember(deviceId, entries);
|
|
52496
|
+
}
|
|
52497
|
+
ctx.logger.info("alexa data controller: history served", {
|
|
52498
|
+
tags: { deviceId },
|
|
52499
|
+
meta: {
|
|
52500
|
+
userId: caller.userId,
|
|
52501
|
+
queryType: payload.queryType,
|
|
52502
|
+
items: entries.length,
|
|
52503
|
+
more: next !== null
|
|
52504
|
+
}
|
|
52505
|
+
});
|
|
52506
|
+
return dataControllerEvent(header, endpoint.endpointId, "DataReport", {
|
|
52507
|
+
...next !== null ? { paginationContext: { nextToken: encodeCursor(next) } } : {},
|
|
52508
|
+
dataSchema: {
|
|
52509
|
+
type: "JSON",
|
|
52510
|
+
schema: "SmartVisionData"
|
|
52511
|
+
},
|
|
52512
|
+
data: entries.map((e) => dataItem(ctx, p, e, caller.userId))
|
|
52513
|
+
});
|
|
52514
|
+
}
|
|
52515
|
+
async function handleDeleteData(header, endpoint, payloadRaw, ctx, caller) {
|
|
52516
|
+
const deviceId = cameraOf(endpoint);
|
|
52517
|
+
if (deviceId === null || endpoint === void 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "event history is served for camera endpoints");
|
|
52518
|
+
const parsed = DeleteDataPayloadSchema.safeParse(payloadRaw);
|
|
52519
|
+
if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", "DeleteData payload shape unexpected");
|
|
52520
|
+
const payload = parsed.data;
|
|
52521
|
+
let ids;
|
|
52522
|
+
if (payload.queryType === "QUERY_BY_IDENTIFIER") ids = payload.query?.ids ?? [];
|
|
52523
|
+
else {
|
|
52524
|
+
const range = rangeOf(payload);
|
|
52525
|
+
if (range === null) return errorEnvelope(header, "INVALID_DIRECTIVE", "fromTime/toTime missing or invalid");
|
|
52526
|
+
const p = await prepare(ctx, deviceId);
|
|
52527
|
+
ids = (await ctx.listTracks(deviceId, {
|
|
52528
|
+
since: range.from,
|
|
52529
|
+
until: range.to,
|
|
52530
|
+
limit: MAX_DELETE_ROWS
|
|
52531
|
+
})).map((r) => entryOf(ctx, p, r)?.e).filter((e) => e !== void 0);
|
|
52532
|
+
}
|
|
52533
|
+
await ctx.vision.hide(deviceId, ids);
|
|
52534
|
+
ctx.logger.info("alexa data controller: events hidden from Alexa (CamStack keeps them)", {
|
|
52535
|
+
tags: { deviceId },
|
|
52536
|
+
meta: {
|
|
52537
|
+
userId: caller.userId,
|
|
52538
|
+
queryType: payload.queryType,
|
|
52539
|
+
hidden: ids.length
|
|
52540
|
+
}
|
|
52541
|
+
});
|
|
52542
|
+
return dataControllerEvent(header, endpoint.endpointId, "DataDeleted", {
|
|
52543
|
+
queryType: payload.queryType,
|
|
52544
|
+
...payload.query !== void 0 ? { query: payload.query } : {}
|
|
52545
|
+
});
|
|
52546
|
+
}
|
|
52547
|
+
async function handleSetObjectDetectionClasses(header, endpoint, payloadRaw, ctx) {
|
|
52548
|
+
const deviceId = cameraOf(endpoint);
|
|
52549
|
+
if (deviceId === null || endpoint === void 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "object detection lives on camera endpoints");
|
|
52550
|
+
const parsed = SetClassesPayloadSchema.safeParse(payloadRaw);
|
|
52551
|
+
if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", "SetObjectDetectionClasses payload unexpected");
|
|
52552
|
+
const wanted = new Set(parsed.data.objectDetectionClasses.map((c) => c.imageNetClass));
|
|
52553
|
+
const classes = SMART_VISION_CLASSES$1.filter((c) => wanted.has(c));
|
|
52554
|
+
await ctx.vision.setClasses(deviceId, classes);
|
|
52555
|
+
ctx.logger.info("alexa object detection: classes set from the Alexa app", {
|
|
52556
|
+
tags: { deviceId },
|
|
52557
|
+
meta: { classes }
|
|
52558
|
+
});
|
|
52559
|
+
const connectivity = await readConnectivity(ctx.ctx.api, deviceId, ctx.logger);
|
|
52560
|
+
return {
|
|
52561
|
+
event: {
|
|
52562
|
+
header: {
|
|
52563
|
+
namespace: "Alexa",
|
|
52564
|
+
name: "Response",
|
|
52565
|
+
messageId: randomUUID(),
|
|
52566
|
+
...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
|
|
52567
|
+
payloadVersion: "3"
|
|
52568
|
+
},
|
|
52569
|
+
endpoint: { endpointId: endpoint.endpointId },
|
|
52570
|
+
payload: {}
|
|
52571
|
+
},
|
|
52572
|
+
context: { properties: [{
|
|
52573
|
+
namespace: "Alexa.EndpointHealth",
|
|
52574
|
+
name: "connectivity",
|
|
52575
|
+
value: { value: connectivity },
|
|
52576
|
+
timeOfSample: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52577
|
+
uncertaintyInMilliseconds: 0
|
|
52578
|
+
}] }
|
|
52579
|
+
};
|
|
52580
|
+
}
|
|
52581
|
+
//#endregion
|
|
51740
52582
|
//#region src/handlers/lock-controller.ts
|
|
51741
52583
|
/** The `lock-control` state as Alexa's `lockState`. */
|
|
51742
52584
|
function alexaLockStateFor(state) {
|
|
@@ -52105,7 +52947,7 @@ async function toAlexaJpeg(source) {
|
|
|
52105
52947
|
//#region src/handlers/snapshot-provider.ts
|
|
52106
52948
|
/** Leaves the rest of Amazon's 8 s for the re-encode and the Lambda hop. */
|
|
52107
52949
|
var SNAPSHOT_READ_TIMEOUT_MS = 5e3;
|
|
52108
|
-
var SnapshotImageSchema = object({
|
|
52950
|
+
var SnapshotImageSchema$1 = object({
|
|
52109
52951
|
base64: string().min(1),
|
|
52110
52952
|
contentType: string()
|
|
52111
52953
|
});
|
|
@@ -52131,7 +52973,7 @@ async function readFrame(ctx, deviceId) {
|
|
|
52131
52973
|
});
|
|
52132
52974
|
try {
|
|
52133
52975
|
const raw = await Promise.race([ctx.api.snapshot.getSnapshot.query({ deviceId }), timeout]);
|
|
52134
|
-
const parsed = SnapshotImageSchema.safeParse(raw);
|
|
52976
|
+
const parsed = SnapshotImageSchema$1.safeParse(raw);
|
|
52135
52977
|
return parsed.success ? Buffer.from(parsed.data.base64, "base64") : null;
|
|
52136
52978
|
} finally {
|
|
52137
52979
|
if (timer !== void 0) clearTimeout(timer);
|
|
@@ -52890,6 +53732,9 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
|
|
|
52890
53732
|
"Alexa.SecurityPanelController/Arm": "create",
|
|
52891
53733
|
"Alexa.SecurityPanelController/Disarm": "create",
|
|
52892
53734
|
"Alexa.SmartVision.SnapshotProvider/GetSnapshot": "view",
|
|
53735
|
+
"Alexa.SmartVision.ObjectDetectionSensor/SetObjectDetectionClasses": "view",
|
|
53736
|
+
"Alexa.DataController/ReportData": "view",
|
|
53737
|
+
"Alexa.DataController/DeleteData": "view",
|
|
52893
53738
|
"Alexa.LockController/Lock": "create",
|
|
52894
53739
|
"Alexa.LockController/Unlock": "create"
|
|
52895
53740
|
};
|
|
@@ -53034,6 +53879,18 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
|
|
|
53034
53879
|
status: 200,
|
|
53035
53880
|
body: await handleGetSnapshot(header, endpoint, deps.snapshotProvider, callerIdentity)
|
|
53036
53881
|
};
|
|
53882
|
+
if (key === "Alexa.SmartVision.ObjectDetectionSensor/SetObjectDetectionClasses") return {
|
|
53883
|
+
status: 200,
|
|
53884
|
+
body: await handleSetObjectDetectionClasses(header, endpoint, payload, deps.vision)
|
|
53885
|
+
};
|
|
53886
|
+
if (key === "Alexa.DataController/ReportData") return {
|
|
53887
|
+
status: 200,
|
|
53888
|
+
body: await handleReportData(header, endpoint, payload, deps.vision, callerIdentity)
|
|
53889
|
+
};
|
|
53890
|
+
if (key === "Alexa.DataController/DeleteData") return {
|
|
53891
|
+
status: 200,
|
|
53892
|
+
body: await handleDeleteData(header, endpoint, payload, deps.vision, callerIdentity)
|
|
53893
|
+
};
|
|
53037
53894
|
if (key === "Alexa/ReportState") return {
|
|
53038
53895
|
status: 200,
|
|
53039
53896
|
body: await handleReportState(header, endpoint, deps.reportState, callerIdentity)
|
|
@@ -53339,8 +54196,17 @@ async function handleSnapshotFetch(req, reply, deps) {
|
|
|
53339
54196
|
refuse(reply, 403, "not allowed");
|
|
53340
54197
|
return;
|
|
53341
54198
|
}
|
|
53342
|
-
|
|
53343
|
-
|
|
54199
|
+
const bytes = await deps.store.bytesOf(image);
|
|
54200
|
+
if (bytes === null || bytes.byteLength === 0) {
|
|
54201
|
+
deps.logger.debug("alexa snapshot: no image to serve for this event", {
|
|
54202
|
+
tags: { deviceId: image.deviceId },
|
|
54203
|
+
meta: { imageId }
|
|
54204
|
+
});
|
|
54205
|
+
refuse(reply, 410, "no image is available for this event");
|
|
54206
|
+
return;
|
|
54207
|
+
}
|
|
54208
|
+
reply.code(200).type(image.contentType).header("cache-control", "private, no-store").header("content-encoding", "identity").header("content-length", String(bytes.byteLength));
|
|
54209
|
+
reply.send(bytes);
|
|
53344
54210
|
}
|
|
53345
54211
|
//#endregion
|
|
53346
54212
|
//#region src/http-server.ts
|
|
@@ -53780,17 +54646,14 @@ function connectivityDeviceOf(endpointId) {
|
|
|
53780
54646
|
var DOORBELL_INTERFACE = "Alexa.DoorbellEventSource";
|
|
53781
54647
|
/**
|
|
53782
54648
|
* The interface an endpoint must advertise for an `ObjectDetection` event to
|
|
53783
|
-
* survive the Alexa cloud
|
|
53784
|
-
*
|
|
53785
|
-
*
|
|
53786
|
-
*
|
|
53787
|
-
*
|
|
53788
|
-
* endpoint
|
|
53789
|
-
*
|
|
53790
|
-
*
|
|
53791
|
-
* endpoint cannot host it. This is D76 exactly — an accepted POST is not
|
|
53792
|
-
* evidence of delivery — and it is why the operator sees no events in the app
|
|
53793
|
-
* while the log reads as though they were delivered.
|
|
54649
|
+
* survive the Alexa cloud. Declared on a camera only while object detection is
|
|
54650
|
+
* on for it (R4, D779).
|
|
54651
|
+
*
|
|
54652
|
+
* Measured 2026-08-07, before the declaration existed: 223 `smartvision person
|
|
54653
|
+
* posted status=202` in 692 s on endpoint 590 alone, every one of them against
|
|
54654
|
+
* an endpoint whose discovered capability list did not contain this string. The
|
|
54655
|
+
* Event Gateway answers 202 because the ENVELOPE parsed; the cloud then drops
|
|
54656
|
+
* the event. D76: an accepted POST is not evidence of delivery.
|
|
53794
54657
|
*/
|
|
53795
54658
|
var SMART_VISION_INTERFACE = "Alexa.SmartVision.ObjectDetectionSensor";
|
|
53796
54659
|
/**
|
|
@@ -53972,13 +54835,7 @@ var LwaEventGateway = class {
|
|
|
53972
54835
|
endpointId,
|
|
53973
54836
|
detected: data.detected,
|
|
53974
54837
|
timestamp: data.timestamp,
|
|
53975
|
-
label: "motion"
|
|
53976
|
-
onRisingPosted: (userId) => this.postObjectDetection({
|
|
53977
|
-
userId,
|
|
53978
|
-
deviceId: data.deviceId,
|
|
53979
|
-
endpointId,
|
|
53980
|
-
timestamp: data.timestamp
|
|
53981
|
-
})
|
|
54838
|
+
label: "motion"
|
|
53982
54839
|
});
|
|
53983
54840
|
}
|
|
53984
54841
|
/**
|
|
@@ -54043,7 +54900,6 @@ var LwaEventGateway = class {
|
|
|
54043
54900
|
detected: true,
|
|
54044
54901
|
timestamp: opts.timestamp
|
|
54045
54902
|
});
|
|
54046
|
-
await opts.onRisingPosted?.(account.userId);
|
|
54047
54903
|
}
|
|
54048
54904
|
}
|
|
54049
54905
|
async postMotionChange(opts) {
|
|
@@ -54053,49 +54909,53 @@ var LwaEventGateway = class {
|
|
|
54053
54909
|
}), `motion ${opts.detected ? "DETECTED" : "NOT_DETECTED"}`);
|
|
54054
54910
|
}
|
|
54055
54911
|
/**
|
|
54056
|
-
*
|
|
54057
|
-
*
|
|
54058
|
-
* (`Alexa.MotionSensor` alone only feeds the account-wide Activity feed).
|
|
54912
|
+
* Post one `Alexa.SmartVision.ObjectDetectionSensor/ObjectDetection` event
|
|
54913
|
+
* (R4, D779) to every linked account whose hub user may view the camera.
|
|
54059
54914
|
*
|
|
54060
|
-
*
|
|
54061
|
-
*
|
|
54062
|
-
*
|
|
54063
|
-
*
|
|
54064
|
-
*
|
|
54065
|
-
* rate-limited gateway, and it makes the log assert a delivery that never
|
|
54066
|
-
* happened. The doorbell path learned the same lesson today and only warns
|
|
54067
|
-
* — it can, because `Alexa.DoorbellEventSource` IS built for a doorbell and
|
|
54068
|
-
* a missing one means stale discovery, i.e. a bet worth taking. Here the
|
|
54069
|
-
* interface is absent by construction, so the bet is a certain loss.
|
|
54915
|
+
* Fed by `ObjectDetectionFeed` from the pipeline's tracks — never by camera
|
|
54916
|
+
* motion. Gated on the endpoint Alexa holds DECLARING the interface (the
|
|
54917
|
+
* exported signature's entry for the camera endpoint): an event for an
|
|
54918
|
+
* endpoint without it is a 2xx the cloud discards (D76), and before D779
|
|
54919
|
+
* that was ~1 POST/s per flapping camera. The drop is logged sparsely.
|
|
54070
54920
|
*
|
|
54071
|
-
*
|
|
54072
|
-
*
|
|
54921
|
+
* Images are minted PER ACCOUNT (`imagesFor`): an image URL is checked
|
|
54922
|
+
* against the account-link token of the user it was minted for.
|
|
54073
54923
|
*/
|
|
54074
54924
|
async postObjectDetection(opts) {
|
|
54925
|
+
const endpointId = String(opts.deviceId);
|
|
54075
54926
|
const signature = this.deps.getExportedSignature(opts.deviceId);
|
|
54076
|
-
if (signature
|
|
54077
|
-
const dropped = (this.smartVisionDropped.get(
|
|
54078
|
-
this.smartVisionDropped.set(
|
|
54927
|
+
if (!(signature !== null && signatureEntries(signature).some((e) => e.endpointId === endpointId && e.interfaces.includes(SMART_VISION_INTERFACE)))) {
|
|
54928
|
+
const dropped = (this.smartVisionDropped.get(endpointId) ?? 0) + 1;
|
|
54929
|
+
this.smartVisionDropped.set(endpointId, dropped);
|
|
54079
54930
|
if (dropped === 1 || dropped % SMART_VISION_DROP_LOG_EVERY === 0) this.deps.logger.info(`alexa lwa-gateway: ObjectDetection NOT posted — the endpoint Alexa knows does not declare ${SMART_VISION_INTERFACE}, so the cloud would accept and discard it`, {
|
|
54080
54931
|
tags: { deviceId: opts.deviceId },
|
|
54081
54932
|
meta: {
|
|
54082
|
-
endpointId
|
|
54933
|
+
endpointId,
|
|
54083
54934
|
droppedSoFar: dropped,
|
|
54084
54935
|
signature
|
|
54085
54936
|
}
|
|
54086
54937
|
});
|
|
54087
54938
|
return;
|
|
54088
54939
|
}
|
|
54089
|
-
|
|
54090
|
-
|
|
54091
|
-
|
|
54092
|
-
|
|
54093
|
-
|
|
54094
|
-
|
|
54095
|
-
|
|
54096
|
-
|
|
54097
|
-
|
|
54098
|
-
|
|
54940
|
+
const linked = this.deps.listLinkedAccounts();
|
|
54941
|
+
if (linked.length === 0 || !this.hasProactiveCredentials()) return;
|
|
54942
|
+
const d = opts.detection;
|
|
54943
|
+
for (const account of linked) {
|
|
54944
|
+
if (!this.mayPush(account.userId, opts.deviceId, "object detection")) continue;
|
|
54945
|
+
const images = opts.imagesFor?.(account.userId) ?? {};
|
|
54946
|
+
await this.postEventBody(account.userId, endpointId, (accessToken) => buildSmartVisionObjectDetectionEvent({
|
|
54947
|
+
userId: account.userId,
|
|
54948
|
+
endpointId,
|
|
54949
|
+
detections: [{
|
|
54950
|
+
eventIdentifier: d.eventIdentifier,
|
|
54951
|
+
imageNetClass: d.imageNetClass,
|
|
54952
|
+
objectIdentifier: d.objectIdentifier,
|
|
54953
|
+
...images
|
|
54954
|
+
}],
|
|
54955
|
+
timestamp: d.timestamp,
|
|
54956
|
+
accessToken
|
|
54957
|
+
}), `smartvision ${d.imageNetClass} (track ${d.trackId}, images ${images.frameImageUri === void 0 ? "off" : "on"})`);
|
|
54958
|
+
}
|
|
54099
54959
|
}
|
|
54100
54960
|
/**
|
|
54101
54961
|
* A camera went offline or came back. Amazon requires the `connectivity`
|
|
@@ -54219,32 +55079,16 @@ var LwaEventGateway = class {
|
|
|
54219
55079
|
* either side get cleared.
|
|
54220
55080
|
*/
|
|
54221
55081
|
async purgeEndpoints(endpointIds) {
|
|
54222
|
-
if (endpointIds.length === 0) return {
|
|
54223
|
-
requested: 0,
|
|
54224
|
-
accountCount: 0,
|
|
54225
|
-
attemptedAccounts: 0
|
|
54226
|
-
};
|
|
54227
55082
|
const linked = this.deps.listLinkedAccounts();
|
|
54228
|
-
|
|
54229
|
-
|
|
54230
|
-
|
|
54231
|
-
attemptedAccounts: 0
|
|
54232
|
-
};
|
|
54233
|
-
if (!this.hasProactiveCredentials()) return {
|
|
54234
|
-
requested: endpointIds.length,
|
|
54235
|
-
accountCount: linked.length,
|
|
54236
|
-
attemptedAccounts: 0
|
|
54237
|
-
};
|
|
54238
|
-
let attemptedAccounts = 0;
|
|
54239
|
-
for (const account of linked) {
|
|
54240
|
-
if (!this.findToken(account.userId)) continue;
|
|
54241
|
-
await this.postEventBody(account.userId, "discovery", (accessToken) => buildDeleteReportPayload(account.userId, endpointIds, accessToken), `discovery delete (manual purge, ${endpointIds.length})`);
|
|
54242
|
-
attemptedAccounts += 1;
|
|
55083
|
+
let deliveredAccounts = 0;
|
|
55084
|
+
if (endpointIds.length > 0) {
|
|
55085
|
+
for (const account of linked) if (await this.postEventBody(account.userId, "discovery", (accessToken) => buildDeleteReportPayload(account.userId, endpointIds, accessToken), `discovery delete (manual, ${endpointIds.length})`)) deliveredAccounts += 1;
|
|
54243
55086
|
}
|
|
54244
55087
|
return {
|
|
54245
55088
|
requested: endpointIds.length,
|
|
54246
55089
|
accountCount: linked.length,
|
|
54247
|
-
|
|
55090
|
+
deliveredAccounts,
|
|
55091
|
+
failedAccounts: endpointIds.length > 0 ? linked.length - deliveredAccounts : 0
|
|
54248
55092
|
};
|
|
54249
55093
|
}
|
|
54250
55094
|
/** Public delivery used by the SyncEngine. Returns true on gateway 2xx.
|
|
@@ -54684,6 +55528,286 @@ function buildDeleteReportPayload(_userId, endpointIds, accessToken) {
|
|
|
54684
55528
|
} };
|
|
54685
55529
|
}
|
|
54686
55530
|
//#endregion
|
|
55531
|
+
//#region src/remove-endpoints.ts
|
|
55532
|
+
/** Comma / whitespace / newline separated ids → unique non-empty ids. */
|
|
55533
|
+
function parseEndpointIds(raw) {
|
|
55534
|
+
const ids = raw.split(/[\s,]+/).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
55535
|
+
return [...new Set(ids)];
|
|
55536
|
+
}
|
|
55537
|
+
/**
|
|
55538
|
+
* The tracked map (deviceId → endpoint ids last pushed) without `removed`. A
|
|
55539
|
+
* device left with no id is dropped, so no later DeleteReport re-addresses it.
|
|
55540
|
+
*/
|
|
55541
|
+
function forgetDeliveredEndpointIds(tracked, removed) {
|
|
55542
|
+
const gone = new Set(removed);
|
|
55543
|
+
const next = {};
|
|
55544
|
+
for (const [key, ids] of Object.entries(tracked)) {
|
|
55545
|
+
const remaining = ids.filter((id) => !gone.has(id));
|
|
55546
|
+
if (remaining.length > 0) next[Number(key)] = remaining;
|
|
55547
|
+
}
|
|
55548
|
+
return next;
|
|
55549
|
+
}
|
|
55550
|
+
/**
|
|
55551
|
+
* Delete the typed ids from every linked Alexa account. Throws — so the button
|
|
55552
|
+
* shows the reason — unless every account accepted the delete; only then are
|
|
55553
|
+
* the ids forgotten locally. A partial delivery keeps them tracked, so a
|
|
55554
|
+
* Re-sync or a second press can still reach the account that missed it.
|
|
55555
|
+
*/
|
|
55556
|
+
async function removeEndpointsFromAlexa(deps) {
|
|
55557
|
+
const ids = parseEndpointIds(deps.raw);
|
|
55558
|
+
if (ids.length === 0) throw new Error("No endpoint id given. Type one or more ids, e.g. 54, 54__privacy.");
|
|
55559
|
+
const result = await deps.purge(ids);
|
|
55560
|
+
if (result.accountCount === 0) throw new Error("No linked Amazon account: there is no Alexa account to remove them from.");
|
|
55561
|
+
if (result.failedAccounts > 0) throw new Error(`The delete did not reach ${result.failedAccounts} of ${result.accountCount} linked account(s); nothing was forgotten. The addon log has the gateway's answer.`);
|
|
55562
|
+
await deps.setTracked(forgetDeliveredEndpointIds(deps.getTracked(), ids));
|
|
55563
|
+
return result;
|
|
55564
|
+
}
|
|
55565
|
+
//#endregion
|
|
55566
|
+
//#region src/settings-schema.ts
|
|
55567
|
+
var ADDON_ID = "export-alexa";
|
|
55568
|
+
var CONNECTION_DESCRIPTION = [
|
|
55569
|
+
"Public skill (default): enable the \"CamStack\" skill in the Alexa app and link your account. No Amazon developer setup is needed.",
|
|
55570
|
+
"Custom skill: enter your skill id and deploy the skill Lambda from this addon's lambda-template in your own AWS account.",
|
|
55571
|
+
"Either way the skill's Lambda keeps the Amazon credentials (in a custom skill, in your SSM Parameter Store); this hub holds none. It receives short-lived event tokens from the Lambda's token broker with a credential unique to this link.",
|
|
55572
|
+
"This hub must be reachable from the internet over HTTPS (Cloudflare Tunnel, Tailscale Funnel, …). The external address below is the one the skill calls back on."
|
|
55573
|
+
].join("\n");
|
|
55574
|
+
function connectionSection(input) {
|
|
55575
|
+
const { defaults } = input;
|
|
55576
|
+
return {
|
|
55577
|
+
id: "export-alexa-connection",
|
|
55578
|
+
title: "Connection",
|
|
55579
|
+
description: CONNECTION_DESCRIPTION,
|
|
55580
|
+
columns: 1,
|
|
55581
|
+
fields: [
|
|
55582
|
+
{
|
|
55583
|
+
type: "select",
|
|
55584
|
+
key: "skillMode",
|
|
55585
|
+
label: "Alexa skill",
|
|
55586
|
+
description: "Public: the built-in CamStack skill. Custom: your own skill and your own copy of the skill Lambda.",
|
|
55587
|
+
options: [{
|
|
55588
|
+
value: "public",
|
|
55589
|
+
label: "Public (built-in CamStack skill)"
|
|
55590
|
+
}, {
|
|
55591
|
+
value: "custom",
|
|
55592
|
+
label: "Custom skill"
|
|
55593
|
+
}],
|
|
55594
|
+
default: defaults.skillMode
|
|
55595
|
+
},
|
|
55596
|
+
{
|
|
55597
|
+
type: "select",
|
|
55598
|
+
key: "region",
|
|
55599
|
+
label: "Skill region",
|
|
55600
|
+
description: "The Alexa Event Gateway this hub posts to: doorbell presses, motion, camera online/offline and state changes, and the device-list updates sent when you expose or unexpose a camera. Pick the region your Amazon account is in — events sent to another region are rejected.",
|
|
55601
|
+
options: [
|
|
55602
|
+
{
|
|
55603
|
+
value: "NA",
|
|
55604
|
+
label: "North America"
|
|
55605
|
+
},
|
|
55606
|
+
{
|
|
55607
|
+
value: "EU",
|
|
55608
|
+
label: "Europe"
|
|
55609
|
+
},
|
|
55610
|
+
{
|
|
55611
|
+
value: "FE",
|
|
55612
|
+
label: "Far East"
|
|
55613
|
+
}
|
|
55614
|
+
],
|
|
55615
|
+
default: defaults.region
|
|
55616
|
+
},
|
|
55617
|
+
...input.skillMode === "custom" ? [{
|
|
55618
|
+
type: "text",
|
|
55619
|
+
key: "expectedAudience",
|
|
55620
|
+
label: "Alexa skill id",
|
|
55621
|
+
description: "Your skill's id, format amzn1.ask.skill.xxxx, from the Alexa developer console. Directives for any other skill are refused.",
|
|
55622
|
+
default: defaults.expectedAudience
|
|
55623
|
+
}] : [],
|
|
55624
|
+
{
|
|
55625
|
+
type: "addon-action-select",
|
|
55626
|
+
key: "publicHubUrl",
|
|
55627
|
+
label: "External address",
|
|
55628
|
+
description: "The HTTPS address the skill reaches this hub on, read live from the connected remote-access providers each time this page opens. Defaults to the first one detected; a saved choice that is no longer detected stays selected until you change it.",
|
|
55629
|
+
addonId: ADDON_ID,
|
|
55630
|
+
action: "listExternalAddresses",
|
|
55631
|
+
mapOption: {
|
|
55632
|
+
value: "value",
|
|
55633
|
+
label: "label"
|
|
55634
|
+
},
|
|
55635
|
+
emptyResultsMessage: "No external HTTPS address detected. Set up remote access (Cloudflare Tunnel, Tailscale Funnel, …), then reopen this page.",
|
|
55636
|
+
default: defaults.publicHubUrl
|
|
55637
|
+
}
|
|
55638
|
+
]
|
|
55639
|
+
};
|
|
55640
|
+
}
|
|
55641
|
+
function featuresSection(input) {
|
|
55642
|
+
const { defaults } = input;
|
|
55643
|
+
return {
|
|
55644
|
+
id: "export-alexa-features",
|
|
55645
|
+
title: "Features",
|
|
55646
|
+
columns: 1,
|
|
55647
|
+
fields: [
|
|
55648
|
+
{
|
|
55649
|
+
type: "boolean",
|
|
55650
|
+
key: "detectionSensors",
|
|
55651
|
+
label: "Detection sensors",
|
|
55652
|
+
description: "Master switch for the detection sensors: Alexa motion sensors named \"<camera> Person\", \"Vehicle\", \"Animal\" or \"Package\", usable as routine triggers (for example: when Front door Package detects motion, announce on the Echo devices). Which classes each camera sends is chosen on the camera's Export tab — none by default, and only the classes its pipeline detects. They report only \"detected\", no images. Off removes every one of them from Alexa.",
|
|
55653
|
+
default: defaults.detectionSensors
|
|
55654
|
+
},
|
|
55655
|
+
{
|
|
55656
|
+
type: "boolean",
|
|
55657
|
+
key: "objectDetection",
|
|
55658
|
+
label: "Object detection (experimental)",
|
|
55659
|
+
description: "Each exposed camera reports person and package detections to Alexa as events, with an event history on the camera in the Alexa app and, where Alexa supports it, \"person detected\" announcements. Amazon documents this for US English only; it is on to find out what other languages do with it. If Alexa stops showing a camera, turn this off: the cameras are sent again without it. Images follow the Snapshots switch. Each camera can also be turned off on its Export tab.",
|
|
55660
|
+
default: defaults.objectDetection
|
|
55661
|
+
},
|
|
55662
|
+
{
|
|
55663
|
+
type: "boolean",
|
|
55664
|
+
key: "snapshots",
|
|
55665
|
+
label: "Snapshots in the Alexa app",
|
|
55666
|
+
description: "Show each exposed camera's latest still on its tile in the Alexa app. While this is on, Amazon fetches still images of your cameras (re-encoded to about 480 px, each link valid ten minutes and only with your account's token). Off removes the feature from Alexa and invalidates every link already handed out. The external address must be set.",
|
|
55667
|
+
default: defaults.snapshots
|
|
55668
|
+
}
|
|
55669
|
+
]
|
|
55670
|
+
};
|
|
55671
|
+
}
|
|
55672
|
+
function accountLinkSection(input) {
|
|
55673
|
+
return {
|
|
55674
|
+
id: "export-alexa-account",
|
|
55675
|
+
title: "Account link",
|
|
55676
|
+
columns: 1,
|
|
55677
|
+
fields: [{
|
|
55678
|
+
type: "info",
|
|
55679
|
+
key: "__alexa-link-status",
|
|
55680
|
+
label: "Status",
|
|
55681
|
+
variant: "info",
|
|
55682
|
+
content: buildLinkStatusContent({
|
|
55683
|
+
linkedAccounts: input.linkedAccounts,
|
|
55684
|
+
tokens: input.tokens,
|
|
55685
|
+
now: input.now
|
|
55686
|
+
})
|
|
55687
|
+
}, {
|
|
55688
|
+
type: "addon-action-button",
|
|
55689
|
+
addonId: ADDON_ID,
|
|
55690
|
+
key: "__unlink-alexa-accounts",
|
|
55691
|
+
label: "Unlink Amazon account",
|
|
55692
|
+
description: "Revokes every Alexa account-link session on this hub and discards the stored Amazon push grant. Live view, voice control and proactive events stop immediately. The account-linking token never expires on its own, so this is the only way to sever the link. Re-link from the Alexa app afterwards; a new link also picks up widened permissions.",
|
|
55693
|
+
buttonLabel: "Unlink now",
|
|
55694
|
+
action: "unlinkAlexaAccounts",
|
|
55695
|
+
variant: "danger",
|
|
55696
|
+
confirmMessage: "Unlink the Amazon account? Alexa loses access to your cameras immediately and you must re-link from the Alexa app to restore it.",
|
|
55697
|
+
successMessage: "Unlinked: {revokedSessions} session(s) revoked, {removedAccounts} linked account(s) removed. Re-link from the Alexa app to restore."
|
|
55698
|
+
}]
|
|
55699
|
+
};
|
|
55700
|
+
}
|
|
55701
|
+
function troubleshootingSection() {
|
|
55702
|
+
return {
|
|
55703
|
+
id: "export-alexa-troubleshooting",
|
|
55704
|
+
title: "Troubleshooting",
|
|
55705
|
+
style: "accordion",
|
|
55706
|
+
defaultCollapsed: true,
|
|
55707
|
+
columns: 1,
|
|
55708
|
+
fields: [
|
|
55709
|
+
{
|
|
55710
|
+
type: "addon-action-button",
|
|
55711
|
+
addonId: ADDON_ID,
|
|
55712
|
+
key: "__resync-discovery",
|
|
55713
|
+
label: "Re-sync devices",
|
|
55714
|
+
description: "Sends every exposed device to Alexa again, and removes from Alexa the devices you stopped exposing whose removal did not get through. Devices normally sync on their own; use this when the Alexa app disagrees with the exposed list. Needs a linked account. Safe to repeat.",
|
|
55715
|
+
buttonLabel: "Re-sync now",
|
|
55716
|
+
action: "resyncDiscovery",
|
|
55717
|
+
variant: "default",
|
|
55718
|
+
successMessage: "Sent {endpointCount} endpoint(s) of {deviceCount} device(s) to {accountCount} account(s). Removed {removedEndpointCount} endpoint(s) of unexposed devices; {retainedEndpointCount} still pending. Failed deliveries: {failedCount} (see the addon log)."
|
|
55719
|
+
},
|
|
55720
|
+
{
|
|
55721
|
+
type: "addon-action-button",
|
|
55722
|
+
addonId: ADDON_ID,
|
|
55723
|
+
key: "__remove-alexa-endpoints",
|
|
55724
|
+
label: "Remove ids from Alexa",
|
|
55725
|
+
description: "For a device the Alexa app still shows that this hub no longer knows about (left over from an earlier link or addon version). You type the Alexa endpoint ids when you press the button: a camera is its device id (\"54\"), its parts add a suffix (\"54__privacy\", \"54__98\"). Removing an endpoint also removes it from any Alexa routine that uses it.",
|
|
55726
|
+
buttonLabel: "Remove ids…",
|
|
55727
|
+
action: "removeAlexaEndpoints",
|
|
55728
|
+
variant: "danger",
|
|
55729
|
+
prompt: {
|
|
55730
|
+
param: "endpointIds",
|
|
55731
|
+
title: "Remove ids from Alexa",
|
|
55732
|
+
message: "Endpoint ids separated by commas or spaces. Each linked Alexa account is sent a delete for them.",
|
|
55733
|
+
placeholder: "54, 54__privacy"
|
|
55734
|
+
},
|
|
55735
|
+
successMessage: "Removed {requested} id(s) from {deliveredAccounts} linked account(s)."
|
|
55736
|
+
},
|
|
55737
|
+
{
|
|
55738
|
+
type: "addon-action-button",
|
|
55739
|
+
addonId: ADDON_ID,
|
|
55740
|
+
key: "__rotate-broker-credential",
|
|
55741
|
+
label: "Rotate broker credential",
|
|
55742
|
+
description: "Replaces the credential this hub uses at the skill's token broker with a fresh one; the old one stops working immediately. Use it if a backup of this hub's settings may have leaked. Re-linking also issues a fresh one.",
|
|
55743
|
+
buttonLabel: "Rotate credential",
|
|
55744
|
+
action: "rotateBrokerCredential",
|
|
55745
|
+
variant: "default",
|
|
55746
|
+
successMessage: "Rotated {rotated} credential(s); {failed} failed (see the addon log)."
|
|
55747
|
+
}
|
|
55748
|
+
]
|
|
55749
|
+
};
|
|
55750
|
+
}
|
|
55751
|
+
/** Custom skill only: the public skill's shared Lambda sends one ingress token
|
|
55752
|
+
* for every hub (`HUB_INGRESS_TOKEN`), so a per-hub token cannot apply to it. */
|
|
55753
|
+
function customSkillSecuritySection(input) {
|
|
55754
|
+
return {
|
|
55755
|
+
id: "export-alexa-custom-security",
|
|
55756
|
+
title: "Custom-skill security",
|
|
55757
|
+
style: "accordion",
|
|
55758
|
+
defaultCollapsed: true,
|
|
55759
|
+
columns: 1,
|
|
55760
|
+
fields: [
|
|
55761
|
+
{
|
|
55762
|
+
type: "info",
|
|
55763
|
+
key: "__ingress-token-info",
|
|
55764
|
+
label: "Ingress token",
|
|
55765
|
+
variant: "info",
|
|
55766
|
+
content: "An optional shared secret your skill Lambda sends on every call to this hub. Show it, put it in the Lambda's HUB_INGRESS_TOKEN environment variable, then turn on \"Require ingress token\". After a rotation, update the Lambda."
|
|
55767
|
+
},
|
|
55768
|
+
{
|
|
55769
|
+
type: "addon-action-button",
|
|
55770
|
+
addonId: ADDON_ID,
|
|
55771
|
+
key: "__reveal-ingress-token",
|
|
55772
|
+
label: "Show ingress token",
|
|
55773
|
+
description: "Administrators only. Shows the current token once, here.",
|
|
55774
|
+
buttonLabel: "Show ingress token",
|
|
55775
|
+
action: "revealIngressToken",
|
|
55776
|
+
variant: "default",
|
|
55777
|
+
successMessage: "Ingress token: {ingressToken}"
|
|
55778
|
+
},
|
|
55779
|
+
{
|
|
55780
|
+
type: "addon-action-button",
|
|
55781
|
+
addonId: ADDON_ID,
|
|
55782
|
+
key: "__regenerate-ingress-token",
|
|
55783
|
+
label: "Rotate ingress token",
|
|
55784
|
+
description: "Generates a new ingress token; the old one stops working. Update your Lambda's HUB_INGRESS_TOKEN afterwards.",
|
|
55785
|
+
buttonLabel: "Regenerate token",
|
|
55786
|
+
action: "regenerateIngressToken",
|
|
55787
|
+
variant: "default",
|
|
55788
|
+
successMessage: "New ingress token: {ingressToken} — put it in HUB_INGRESS_TOKEN now.",
|
|
55789
|
+
confirmMessage: "Rotate the ingress token? With \"Require ingress token\" on, the Lambda is refused until you update HUB_INGRESS_TOKEN."
|
|
55790
|
+
},
|
|
55791
|
+
{
|
|
55792
|
+
type: "boolean",
|
|
55793
|
+
key: "enforceIngressToken",
|
|
55794
|
+
label: "Require ingress token",
|
|
55795
|
+
description: "When on, this hub refuses skill calls without the matching token. Turn it on only after HUB_INGRESS_TOKEN is set in your Lambda.",
|
|
55796
|
+
default: input.defaults.enforceIngressToken
|
|
55797
|
+
}
|
|
55798
|
+
]
|
|
55799
|
+
};
|
|
55800
|
+
}
|
|
55801
|
+
function buildAlexaSettingsSections(input) {
|
|
55802
|
+
return [
|
|
55803
|
+
connectionSection(input),
|
|
55804
|
+
featuresSection(input),
|
|
55805
|
+
accountLinkSection(input),
|
|
55806
|
+
troubleshootingSection(),
|
|
55807
|
+
...input.skillMode === "custom" ? [customSkillSecuritySection(input)] : []
|
|
55808
|
+
];
|
|
55809
|
+
}
|
|
55810
|
+
//#endregion
|
|
54687
55811
|
//#region src/access/account-visibility.ts
|
|
54688
55812
|
/** Event-path gate. Unknown account ⇒ refused (fail closed). */
|
|
54689
55813
|
function visibilityAllows(entry, deviceId) {
|
|
@@ -54837,121 +55961,6 @@ async function applyVisibilityChanges(changes, target, logger) {
|
|
|
54837
55961
|
}
|
|
54838
55962
|
}
|
|
54839
55963
|
}
|
|
54840
|
-
//#endregion
|
|
54841
|
-
//#region src/detection-sensors/detection-tracker.ts
|
|
54842
|
-
/**
|
|
54843
|
-
* Which detection classes are currently present on each camera (R1, D772).
|
|
54844
|
-
*
|
|
54845
|
-
* Fed by the pipeline's track lifecycle (`PipelineAnalyticsTrackLifecycle`,
|
|
54846
|
-
* `data.deviceId` — these events carry no device on their `source`) and by
|
|
54847
|
-
* package deliveries. A class is present while at least one of ITS tracks is
|
|
54848
|
-
* open: the set is per `(camera, class)` and per `trackId`, so the end of one
|
|
54849
|
-
* person's track does not clear "Person" while another person is still in
|
|
54850
|
-
* view (the HA export's per-event flip has exactly that bug).
|
|
54851
|
-
*
|
|
54852
|
-
* Events are telemetry and may be dropped (D8), and the pipeline does not
|
|
54853
|
-
* promise an `end` for every `start` (a zero-media false birth or a discarded
|
|
54854
|
-
* stale track ends silently). So {@link reconcile} replaces a camera's sets
|
|
54855
|
-
* with an authoritative read of its ACTIVE tracks; a sensor can therefore never
|
|
54856
|
-
* stay "detected" past one reconcile after its last track closed. A read that
|
|
54857
|
-
* fails is not passed in — the sets then stay as they were.
|
|
54858
|
-
*
|
|
54859
|
-
* Emits a transition per `(camera, class)` only when presence changes; the
|
|
54860
|
-
* Alexa-facing hold (≥ 30 s DETECTED before NOT_DETECTED) is the gateway's.
|
|
54861
|
-
*/
|
|
54862
|
-
/** A package delivery is an instant, not a track: it is present this long. */
|
|
54863
|
-
var PACKAGE_PULSE_MS = 1e3;
|
|
54864
|
-
/** The detection class a pipeline class name belongs to, or null. Pipeline
|
|
54865
|
-
* classes are already macros; a raw COCO label is mapped the same way the
|
|
54866
|
-
* executor maps it. */
|
|
54867
|
-
function detectionClassOf(className) {
|
|
54868
|
-
if (className === void 0) return null;
|
|
54869
|
-
const lower = className.toLowerCase();
|
|
54870
|
-
const isClass = (v) => DETECTION_SENSOR_CLASSES.includes(v);
|
|
54871
|
-
if (isClass(lower)) return lower;
|
|
54872
|
-
const mapped = COCO_TO_MACRO.mapping[lower];
|
|
54873
|
-
return mapped !== void 0 && isClass(mapped) ? mapped : null;
|
|
54874
|
-
}
|
|
54875
|
-
var DetectionTracker = class {
|
|
54876
|
-
onTransition;
|
|
54877
|
-
/** `${deviceId}|${class}` → open track ids. */
|
|
54878
|
-
open = /* @__PURE__ */ new Map();
|
|
54879
|
-
pulseTimers = /* @__PURE__ */ new Set();
|
|
54880
|
-
constructor(onTransition) {
|
|
54881
|
-
this.onTransition = onTransition;
|
|
54882
|
-
}
|
|
54883
|
-
onTrackLifecycle(event) {
|
|
54884
|
-
const cls = detectionClassOf(event.bestClassName ?? event.classes?.[0]);
|
|
54885
|
-
if (cls === null) return;
|
|
54886
|
-
if (event.phase === "end") {
|
|
54887
|
-
this.remove(event.deviceId, cls, event.trackId);
|
|
54888
|
-
for (const other of DETECTION_SENSOR_CLASSES) if (other !== cls) this.remove(event.deviceId, other, event.trackId);
|
|
54889
|
-
return;
|
|
54890
|
-
}
|
|
54891
|
-
this.add(event.deviceId, cls, event.trackId);
|
|
54892
|
-
}
|
|
54893
|
-
/** A package was delivered: "Package" is detected, as a pulse. */
|
|
54894
|
-
onPackageDelivered(deviceId, entryId) {
|
|
54895
|
-
const id = `package:${entryId}`;
|
|
54896
|
-
this.add(deviceId, "package", id);
|
|
54897
|
-
const timer = setTimeout(() => {
|
|
54898
|
-
this.pulseTimers.delete(timer);
|
|
54899
|
-
this.remove(deviceId, "package", id);
|
|
54900
|
-
}, PACKAGE_PULSE_MS);
|
|
54901
|
-
timer.unref?.();
|
|
54902
|
-
this.pulseTimers.add(timer);
|
|
54903
|
-
}
|
|
54904
|
-
/** Replace a camera's open tracks with what the pipeline says is ACTIVE. */
|
|
54905
|
-
reconcile(deviceId, active) {
|
|
54906
|
-
const truth = /* @__PURE__ */ new Map();
|
|
54907
|
-
for (const track of active) {
|
|
54908
|
-
const cls = detectionClassOf(track.className);
|
|
54909
|
-
if (cls === null) continue;
|
|
54910
|
-
const set = truth.get(cls) ?? /* @__PURE__ */ new Set();
|
|
54911
|
-
set.add(track.trackId);
|
|
54912
|
-
truth.set(cls, set);
|
|
54913
|
-
}
|
|
54914
|
-
for (const cls of DETECTION_SENSOR_CLASSES) {
|
|
54915
|
-
const key = `${deviceId}|${cls}`;
|
|
54916
|
-
const before = this.open.get(key) ?? /* @__PURE__ */ new Set();
|
|
54917
|
-
const after = truth.get(cls) ?? /* @__PURE__ */ new Set();
|
|
54918
|
-
for (const id of before) if (id.startsWith("package:")) after.add(id);
|
|
54919
|
-
this.replace(deviceId, cls, before, after);
|
|
54920
|
-
}
|
|
54921
|
-
}
|
|
54922
|
-
/** Cameras with at least one class present — the ones worth reconciling. */
|
|
54923
|
-
camerasWithPresence() {
|
|
54924
|
-
const out = /* @__PURE__ */ new Set();
|
|
54925
|
-
for (const [key, set] of this.open) if (set.size > 0) out.add(Number(key.split("|")[0]));
|
|
54926
|
-
return [...out];
|
|
54927
|
-
}
|
|
54928
|
-
isPresent(deviceId, cls) {
|
|
54929
|
-
return (this.open.get(`${deviceId}|${cls}`)?.size ?? 0) > 0;
|
|
54930
|
-
}
|
|
54931
|
-
clear() {
|
|
54932
|
-
this.open.clear();
|
|
54933
|
-
for (const t of this.pulseTimers) clearTimeout(t);
|
|
54934
|
-
this.pulseTimers.clear();
|
|
54935
|
-
}
|
|
54936
|
-
add(deviceId, cls, id) {
|
|
54937
|
-
const key = `${deviceId}|${cls}`;
|
|
54938
|
-
const set = this.open.get(key) ?? /* @__PURE__ */ new Set();
|
|
54939
|
-
const was = set.size > 0;
|
|
54940
|
-
set.add(id);
|
|
54941
|
-
this.open.set(key, set);
|
|
54942
|
-
if (!was) this.onTransition(deviceId, cls, true);
|
|
54943
|
-
}
|
|
54944
|
-
remove(deviceId, cls, id) {
|
|
54945
|
-
const set = this.open.get(`${deviceId}|${cls}`);
|
|
54946
|
-
if (set === void 0 || !set.delete(id)) return;
|
|
54947
|
-
if (set.size === 0) this.onTransition(deviceId, cls, false);
|
|
54948
|
-
}
|
|
54949
|
-
replace(deviceId, cls, before, after) {
|
|
54950
|
-
this.open.set(`${deviceId}|${cls}`, after);
|
|
54951
|
-
if (before.size > 0 && after.size === 0) this.onTransition(deviceId, cls, false);
|
|
54952
|
-
else if (before.size === 0 && after.size > 0) this.onTransition(deviceId, cls, true);
|
|
54953
|
-
}
|
|
54954
|
-
};
|
|
54955
55964
|
/** The detection classes an event-kind answer offers. */
|
|
54956
55965
|
function detectionClassesOfKinds(kinds) {
|
|
54957
55966
|
const out = /* @__PURE__ */ new Set();
|
|
@@ -55030,7 +56039,7 @@ var DetectionClassSource = class {
|
|
|
55030
56039
|
//#endregion
|
|
55031
56040
|
//#region src/detection-sensors/detection-feed.ts
|
|
55032
56041
|
var DETECTION_RECONCILE_INTERVAL_MS = 6e4;
|
|
55033
|
-
var TrackLifecycleSchema = object({
|
|
56042
|
+
var TrackLifecycleSchema$1 = object({
|
|
55034
56043
|
deviceId: number(),
|
|
55035
56044
|
trackId: string(),
|
|
55036
56045
|
phase: _enum([
|
|
@@ -55058,7 +56067,7 @@ var DetectionSensorFeed = class {
|
|
|
55058
56067
|
}
|
|
55059
56068
|
start() {
|
|
55060
56069
|
this.offs.push(this.deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
|
|
55061
|
-
const parsed = TrackLifecycleSchema.safeParse(event.data);
|
|
56070
|
+
const parsed = TrackLifecycleSchema$1.safeParse(event.data);
|
|
55062
56071
|
if (parsed.success) this.tracker.onTrackLifecycle(parsed.data);
|
|
55063
56072
|
}), this.deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsPackageDelivered }, (event) => {
|
|
55064
56073
|
const parsed = PackageDeliveredSchema.safeParse(event.data);
|
|
@@ -55103,6 +56112,388 @@ var DetectionSensorFeed = class {
|
|
|
55103
56112
|
}
|
|
55104
56113
|
};
|
|
55105
56114
|
//#endregion
|
|
56115
|
+
//#region src/detection-sensors/sensor-selection.ts
|
|
56116
|
+
/** Per-device store key of the saved selection. */
|
|
56117
|
+
var SENSOR_SELECTION_KEY = "alexaDetectionSensorClasses";
|
|
56118
|
+
var LABELS = {
|
|
56119
|
+
person: "Person",
|
|
56120
|
+
vehicle: "Vehicle",
|
|
56121
|
+
animal: "Animal",
|
|
56122
|
+
package: "Package"
|
|
56123
|
+
};
|
|
56124
|
+
function isClass(value) {
|
|
56125
|
+
return typeof value === "string" && DETECTION_SENSOR_CLASSES.includes(value);
|
|
56126
|
+
}
|
|
56127
|
+
/** The saved selection in a per-device store blob; `null` = never saved. */
|
|
56128
|
+
function parseSensorSelection(store) {
|
|
56129
|
+
const raw = store[SENSOR_SELECTION_KEY];
|
|
56130
|
+
if (!Array.isArray(raw)) return null;
|
|
56131
|
+
return DETECTION_SENSOR_CLASSES.filter((c) => raw.includes(c));
|
|
56132
|
+
}
|
|
56133
|
+
/** A patch value from the multiselect, cleaned to known classes. */
|
|
56134
|
+
function sensorSelectionFromPatch(value) {
|
|
56135
|
+
if (!Array.isArray(value)) return [];
|
|
56136
|
+
const picked = new Set(value.filter(isClass));
|
|
56137
|
+
return DETECTION_SENSOR_CLASSES.filter((c) => picked.has(c));
|
|
56138
|
+
}
|
|
56139
|
+
/** The classes a camera publishes a sensor for: offered ∩ saved, in the
|
|
56140
|
+
* canonical order. Never saved ⇒ none. */
|
|
56141
|
+
function effectiveSensorClasses(available, saved) {
|
|
56142
|
+
if (saved === null) return [];
|
|
56143
|
+
return DETECTION_SENSOR_CLASSES.filter((c) => available.includes(c) && saved.includes(c));
|
|
56144
|
+
}
|
|
56145
|
+
/** The device-settings field key — namespaced per exporter (see the addon). */
|
|
56146
|
+
function sensorSelectionKey(deviceId) {
|
|
56147
|
+
return `alexa:${deviceId}:detectionSensorClasses`;
|
|
56148
|
+
}
|
|
56149
|
+
/** The camera's "Detection sensors in Alexa" field: a multiselect over the
|
|
56150
|
+
* classes the camera produces, or an info line saying why there is none. */
|
|
56151
|
+
function sensorSelectionField(input) {
|
|
56152
|
+
const key = sensorSelectionKey(input.deviceId);
|
|
56153
|
+
const showWhen = {
|
|
56154
|
+
field: input.enabledKey,
|
|
56155
|
+
equals: true
|
|
56156
|
+
};
|
|
56157
|
+
if (!input.masterOn) return {
|
|
56158
|
+
type: "info",
|
|
56159
|
+
key: `${key}:off`,
|
|
56160
|
+
label: "Detection sensors in Alexa",
|
|
56161
|
+
content: "Detection sensors are switched off for the whole Alexa integration (Alexa settings → Detection sensors).",
|
|
56162
|
+
variant: "info",
|
|
56163
|
+
showWhen
|
|
56164
|
+
};
|
|
56165
|
+
if (input.available.length === 0) return {
|
|
56166
|
+
type: "info",
|
|
56167
|
+
key: `${key}:none`,
|
|
56168
|
+
label: "Detection sensors in Alexa",
|
|
56169
|
+
content: "This camera's detection pipeline produces no person, vehicle, animal or package events, so there is nothing to send to Alexa as a sensor.",
|
|
56170
|
+
variant: "info",
|
|
56171
|
+
showWhen
|
|
56172
|
+
};
|
|
56173
|
+
return {
|
|
56174
|
+
type: "multiselect",
|
|
56175
|
+
key,
|
|
56176
|
+
label: "Detection sensors in Alexa",
|
|
56177
|
+
description: "Each class picked here becomes an Alexa motion sensor named \"<camera> <class>\", usable as a routine trigger. Only the classes this camera's pipeline detects are offered (Package needs a package zone). Removing a class deletes its sensor, and the routines built on it, from Alexa.",
|
|
56178
|
+
options: input.available.map((c) => ({
|
|
56179
|
+
value: c,
|
|
56180
|
+
label: LABELS[c]
|
|
56181
|
+
})),
|
|
56182
|
+
value: input.available.filter((c) => (input.saved ?? []).includes(c)),
|
|
56183
|
+
showWhen,
|
|
56184
|
+
immediate: true
|
|
56185
|
+
};
|
|
56186
|
+
}
|
|
56187
|
+
/** Bound on the per-track bookkeeping should `end` events be lost (D8). */
|
|
56188
|
+
var MAX_TRACKED = 5e3;
|
|
56189
|
+
var TrackLifecycleSchema = object({
|
|
56190
|
+
deviceId: number(),
|
|
56191
|
+
trackId: string(),
|
|
56192
|
+
phase: _enum([
|
|
56193
|
+
"start",
|
|
56194
|
+
"update",
|
|
56195
|
+
"end"
|
|
56196
|
+
]),
|
|
56197
|
+
bestClassName: string().optional(),
|
|
56198
|
+
classes: array(string()).optional()
|
|
56199
|
+
});
|
|
56200
|
+
function isVisionClass(cls) {
|
|
56201
|
+
return cls === "person" || cls === "package";
|
|
56202
|
+
}
|
|
56203
|
+
var ObjectDetectionFeed = class {
|
|
56204
|
+
deps;
|
|
56205
|
+
/** `${deviceId}|${trackId}|${class}` already handled → insertion time. */
|
|
56206
|
+
handled = /* @__PURE__ */ new Map();
|
|
56207
|
+
/** objectIdentifier → last event time. */
|
|
56208
|
+
lastPosted = /* @__PURE__ */ new Map();
|
|
56209
|
+
off = null;
|
|
56210
|
+
constructor(deps) {
|
|
56211
|
+
this.deps = deps;
|
|
56212
|
+
}
|
|
56213
|
+
start() {
|
|
56214
|
+
this.off = this.deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
|
|
56215
|
+
const parsed = TrackLifecycleSchema.safeParse(event.data);
|
|
56216
|
+
if (!parsed.success) return;
|
|
56217
|
+
this.onTrackLifecycle(parsed.data).catch((err) => {
|
|
56218
|
+
this.deps.logger.warn("alexa object detection: event failed", {
|
|
56219
|
+
tags: { deviceId: parsed.data.deviceId },
|
|
56220
|
+
meta: {
|
|
56221
|
+
trackId: parsed.data.trackId,
|
|
56222
|
+
error: errMsg(err)
|
|
56223
|
+
}
|
|
56224
|
+
});
|
|
56225
|
+
});
|
|
56226
|
+
});
|
|
56227
|
+
}
|
|
56228
|
+
stop() {
|
|
56229
|
+
this.off?.();
|
|
56230
|
+
this.off = null;
|
|
56231
|
+
this.handled.clear();
|
|
56232
|
+
this.lastPosted.clear();
|
|
56233
|
+
}
|
|
56234
|
+
/** Per-track entries currently held (tests, diagnostics). */
|
|
56235
|
+
trackedCount() {
|
|
56236
|
+
return this.handled.size;
|
|
56237
|
+
}
|
|
56238
|
+
async onTrackLifecycle(event) {
|
|
56239
|
+
const prefix = `${event.deviceId}|${event.trackId}|`;
|
|
56240
|
+
if (event.phase === "end") {
|
|
56241
|
+
for (const key of [...this.handled.keys()]) if (key.startsWith(prefix)) this.handled.delete(key);
|
|
56242
|
+
return;
|
|
56243
|
+
}
|
|
56244
|
+
const cls = detectionClassOf(event.bestClassName ?? event.classes?.[0]);
|
|
56245
|
+
if (!isVisionClass(cls)) return;
|
|
56246
|
+
const key = `${prefix}${cls}`;
|
|
56247
|
+
if (this.handled.has(key)) return;
|
|
56248
|
+
const now = this.deps.now?.() ?? Date.now();
|
|
56249
|
+
const namespace = this.deps.namespace();
|
|
56250
|
+
const objectIdentifier = objectIdentifierFor(namespace, event.deviceId, event.trackId);
|
|
56251
|
+
const last = this.lastPosted.get(objectIdentifier);
|
|
56252
|
+
if (last !== void 0 && now - last < 3e4) return;
|
|
56253
|
+
this.mark(key, now);
|
|
56254
|
+
if (!(await this.deps.reportableClasses(event.deviceId)).includes(cls)) return;
|
|
56255
|
+
const detection = {
|
|
56256
|
+
trackId: event.trackId,
|
|
56257
|
+
imageNetClass: cls,
|
|
56258
|
+
eventIdentifier: eventIdentifierFor(namespace, event.deviceId, event.trackId, cls),
|
|
56259
|
+
objectIdentifier,
|
|
56260
|
+
timestamp: now
|
|
56261
|
+
};
|
|
56262
|
+
this.lastPosted.set(objectIdentifier, now);
|
|
56263
|
+
this.pruneLastPosted(now);
|
|
56264
|
+
await this.deps.remember(event.deviceId, {
|
|
56265
|
+
e: detection.eventIdentifier,
|
|
56266
|
+
t: event.trackId,
|
|
56267
|
+
c: cls,
|
|
56268
|
+
at: now
|
|
56269
|
+
});
|
|
56270
|
+
await this.deps.post(event.deviceId, detection);
|
|
56271
|
+
}
|
|
56272
|
+
mark(key, now) {
|
|
56273
|
+
this.handled.set(key, now);
|
|
56274
|
+
if (this.handled.size <= MAX_TRACKED) return;
|
|
56275
|
+
const oldest = this.handled.keys().next().value;
|
|
56276
|
+
if (oldest !== void 0) this.handled.delete(oldest);
|
|
56277
|
+
}
|
|
56278
|
+
pruneLastPosted(now) {
|
|
56279
|
+
for (const [id, at] of this.lastPosted) if (now - at >= 3e4) this.lastPosted.delete(id);
|
|
56280
|
+
}
|
|
56281
|
+
};
|
|
56282
|
+
//#endregion
|
|
56283
|
+
//#region src/vision/vision-runtime.ts
|
|
56284
|
+
/** Per-device store key of the camera's object-detection toggle. */
|
|
56285
|
+
var OBJECT_DETECTION_KEY = "alexaObjectDetection";
|
|
56286
|
+
/** Clean scene first; the boxed frames never (they are annotated). */
|
|
56287
|
+
var FRAME_KINDS = [
|
|
56288
|
+
"keyFrameSmall",
|
|
56289
|
+
"sceneFrame",
|
|
56290
|
+
"keyFrame",
|
|
56291
|
+
"fullFrame"
|
|
56292
|
+
];
|
|
56293
|
+
var CROP_KINDS = [
|
|
56294
|
+
"thumbnailSmall",
|
|
56295
|
+
"thumbnail",
|
|
56296
|
+
"crop"
|
|
56297
|
+
];
|
|
56298
|
+
/** A live event whose track has no stored frame yet may show the camera's
|
|
56299
|
+
* current snapshot — but only this soon after the detection. Older history
|
|
56300
|
+
* never does: it would show a different moment. */
|
|
56301
|
+
var LIVE_FALLBACK_MS = 6e4;
|
|
56302
|
+
var MediaRowSchema = object({
|
|
56303
|
+
kind: string(),
|
|
56304
|
+
base64: string()
|
|
56305
|
+
});
|
|
56306
|
+
var SnapshotImageSchema = object({ base64: string().min(1) });
|
|
56307
|
+
var RecentTracksSchema = object({ tracks: array(object({
|
|
56308
|
+
trackId: string(),
|
|
56309
|
+
className: string(),
|
|
56310
|
+
firstSeen: number(),
|
|
56311
|
+
lastSeen: number()
|
|
56312
|
+
})) });
|
|
56313
|
+
var TrackSchema = object({
|
|
56314
|
+
trackId: string(),
|
|
56315
|
+
className: string(),
|
|
56316
|
+
firstSeen: number(),
|
|
56317
|
+
lastSeen: number()
|
|
56318
|
+
}).nullable();
|
|
56319
|
+
/** The classes a camera declares, from what its pipeline produces. */
|
|
56320
|
+
function declaredVisionClasses(available) {
|
|
56321
|
+
return available.includes("package") ? ["person", "package"] : ["person"];
|
|
56322
|
+
}
|
|
56323
|
+
/** The camera's toggle in a per-device store blob (absent = on). */
|
|
56324
|
+
function cameraObjectDetectionOn(store) {
|
|
56325
|
+
return store[OBJECT_DETECTION_KEY] !== false;
|
|
56326
|
+
}
|
|
56327
|
+
function objectDetectionKey(deviceId) {
|
|
56328
|
+
return `alexa:${deviceId}:objectDetection`;
|
|
56329
|
+
}
|
|
56330
|
+
/** The camera's "Object detection in Alexa" field. */
|
|
56331
|
+
function objectDetectionField(input) {
|
|
56332
|
+
const showWhen = {
|
|
56333
|
+
field: input.enabledKey,
|
|
56334
|
+
equals: true
|
|
56335
|
+
};
|
|
56336
|
+
if (!input.integrationOn) return {
|
|
56337
|
+
type: "info",
|
|
56338
|
+
key: `${objectDetectionKey(input.deviceId)}:off`,
|
|
56339
|
+
label: "Object detection in Alexa (experimental)",
|
|
56340
|
+
content: "Object detection is switched off for the whole Alexa integration (Alexa settings → Object detection).",
|
|
56341
|
+
variant: "info",
|
|
56342
|
+
showWhen
|
|
56343
|
+
};
|
|
56344
|
+
return {
|
|
56345
|
+
type: "boolean",
|
|
56346
|
+
key: objectDetectionKey(input.deviceId),
|
|
56347
|
+
label: "Object detection in Alexa (experimental)",
|
|
56348
|
+
description: "Send this camera's person (and, with a package zone, package) detections to Alexa as events, with their history on the camera in the Alexa app. Amazon documents this for US English only. If Alexa stops showing the camera after this is on, switch it off: the camera returns after the next re-sync.",
|
|
56349
|
+
style: "switch",
|
|
56350
|
+
value: cameraObjectDetectionOn(input.store),
|
|
56351
|
+
showWhen,
|
|
56352
|
+
immediate: true
|
|
56353
|
+
};
|
|
56354
|
+
}
|
|
56355
|
+
var AlexaVisionRuntime = class {
|
|
56356
|
+
deps;
|
|
56357
|
+
state;
|
|
56358
|
+
feed;
|
|
56359
|
+
constructor(deps) {
|
|
56360
|
+
this.deps = deps;
|
|
56361
|
+
this.state = new VisionStateStore({
|
|
56362
|
+
read: (deviceId) => this.readPrefs(deviceId),
|
|
56363
|
+
write: (deviceId, patch) => this.writePrefs(deviceId, patch)
|
|
56364
|
+
});
|
|
56365
|
+
this.feed = new ObjectDetectionFeed({
|
|
56366
|
+
eventBus: deps.ctx.eventBus,
|
|
56367
|
+
reportableClasses: (deviceId) => this.reportable(deviceId),
|
|
56368
|
+
namespace: deps.namespace,
|
|
56369
|
+
remember: (deviceId, entry) => this.state.remember(deviceId, [entry]),
|
|
56370
|
+
post: (deviceId, detection) => deps.post({
|
|
56371
|
+
deviceId,
|
|
56372
|
+
detection,
|
|
56373
|
+
imagesFor: (userId) => this.imagesFor(userId, deviceId, detection.trackId, detection.timestamp)
|
|
56374
|
+
}),
|
|
56375
|
+
logger: deps.logger
|
|
56376
|
+
});
|
|
56377
|
+
}
|
|
56378
|
+
start() {
|
|
56379
|
+
this.feed.start();
|
|
56380
|
+
}
|
|
56381
|
+
stop() {
|
|
56382
|
+
this.feed.stop();
|
|
56383
|
+
}
|
|
56384
|
+
/** This addon's per-device store for a camera; `{}` when unreadable. */
|
|
56385
|
+
async readPrefs(deviceId) {
|
|
56386
|
+
try {
|
|
56387
|
+
return await this.deps.ctx.settings?.readDeviceStore(deviceId) ?? {};
|
|
56388
|
+
} catch (err) {
|
|
56389
|
+
this.deps.logger.debug("alexa: per-device settings unreadable — using defaults", {
|
|
56390
|
+
tags: { deviceId },
|
|
56391
|
+
meta: { error: errMsg(err) }
|
|
56392
|
+
});
|
|
56393
|
+
return {};
|
|
56394
|
+
}
|
|
56395
|
+
}
|
|
56396
|
+
async writePrefs(deviceId, patch) {
|
|
56397
|
+
const settings = this.deps.ctx.settings;
|
|
56398
|
+
if (settings === void 0) throw new Error("export-alexa: no per-device settings store");
|
|
56399
|
+
await settings.writeDeviceStore(deviceId, patch);
|
|
56400
|
+
this.state.invalidate(deviceId);
|
|
56401
|
+
}
|
|
56402
|
+
/** What the endpoint build declares for a camera, from a FRESH class read. */
|
|
56403
|
+
async buildClasses(deviceId, available) {
|
|
56404
|
+
if (!this.deps.integrationOn()) return [];
|
|
56405
|
+
if (!cameraObjectDetectionOn(await this.readPrefs(deviceId))) return [];
|
|
56406
|
+
return declaredVisionClasses(available);
|
|
56407
|
+
}
|
|
56408
|
+
/** What a camera declares right now, without reading its pipeline. */
|
|
56409
|
+
async declared(deviceId) {
|
|
56410
|
+
if (!this.deps.integrationOn() || !this.deps.isExposed(deviceId)) return [];
|
|
56411
|
+
if (!cameraObjectDetectionOn(await this.readPrefs(deviceId))) return [];
|
|
56412
|
+
return declaredVisionClasses(this.deps.knownClasses(deviceId));
|
|
56413
|
+
}
|
|
56414
|
+
async reportable(deviceId) {
|
|
56415
|
+
const declared = await this.declared(deviceId);
|
|
56416
|
+
if (declared.length === 0) return [];
|
|
56417
|
+
return reportableVisionClasses(declared, await this.state.get(deviceId));
|
|
56418
|
+
}
|
|
56419
|
+
/** Fresh, per-user, ten-minute image URIs for one event — or none. */
|
|
56420
|
+
imagesFor(userId, deviceId, trackId, at) {
|
|
56421
|
+
if (!this.deps.snapshotsOn()) return {};
|
|
56422
|
+
const base = this.deps.publicHubUrl().replace(/\/+$/, "");
|
|
56423
|
+
if (!base.startsWith("https://")) return {};
|
|
56424
|
+
const frame = this.deps.store.mintLazy({
|
|
56425
|
+
deviceId,
|
|
56426
|
+
userId,
|
|
56427
|
+
capturedAt: at,
|
|
56428
|
+
load: () => this.loadImage(deviceId, trackId, FRAME_KINDS, at)
|
|
56429
|
+
});
|
|
56430
|
+
const crop = this.deps.store.mintLazy({
|
|
56431
|
+
deviceId,
|
|
56432
|
+
userId,
|
|
56433
|
+
capturedAt: at,
|
|
56434
|
+
load: () => this.loadImage(deviceId, trackId, CROP_KINDS, null)
|
|
56435
|
+
});
|
|
56436
|
+
return {
|
|
56437
|
+
frameImageUri: `${base}${frame.path}`,
|
|
56438
|
+
croppedImageUri: `${base}${crop.path}`
|
|
56439
|
+
};
|
|
56440
|
+
}
|
|
56441
|
+
/** The directive handlers' view (`handlers/data-controller.ts`). */
|
|
56442
|
+
context() {
|
|
56443
|
+
const api = this.deps.ctx.api;
|
|
56444
|
+
return {
|
|
56445
|
+
ctx: this.deps.ctx,
|
|
56446
|
+
logger: this.deps.logger,
|
|
56447
|
+
vision: this.state,
|
|
56448
|
+
declaredClasses: (deviceId) => this.declared(deviceId),
|
|
56449
|
+
namespace: this.deps.namespace,
|
|
56450
|
+
listTracks: async (deviceId, q) => {
|
|
56451
|
+
return RecentTracksSchema.parse(await api.pipelineAnalytics.listRecentTracks.query({
|
|
56452
|
+
deviceIds: [deviceId],
|
|
56453
|
+
since: q.since,
|
|
56454
|
+
until: q.until,
|
|
56455
|
+
limit: q.limit,
|
|
56456
|
+
projection: "slim"
|
|
56457
|
+
})).tracks;
|
|
56458
|
+
},
|
|
56459
|
+
getTrack: async (deviceId, trackId) => TrackSchema.parse(await api.pipelineAnalytics.getTrack.query({
|
|
56460
|
+
deviceId,
|
|
56461
|
+
trackId
|
|
56462
|
+
})),
|
|
56463
|
+
imagesFor: (userId, deviceId, trackId, at) => this.imagesFor(userId, deviceId, trackId, at)
|
|
56464
|
+
};
|
|
56465
|
+
}
|
|
56466
|
+
async loadImage(deviceId, trackId, kinds, liveFallbackAt) {
|
|
56467
|
+
const encode = this.deps.encode ?? toAlexaJpeg;
|
|
56468
|
+
try {
|
|
56469
|
+
const rows = array(MediaRowSchema).parse(await this.deps.ctx.api.pipelineAnalytics.getTrackMedia.query({
|
|
56470
|
+
trackId,
|
|
56471
|
+
deviceId,
|
|
56472
|
+
kinds: [...kinds]
|
|
56473
|
+
}));
|
|
56474
|
+
for (const kind of kinds) {
|
|
56475
|
+
const row = rows.find((r) => r.kind === kind);
|
|
56476
|
+
if (row !== void 0) return (await encode(Buffer.from(row.base64, "base64"))).bytes;
|
|
56477
|
+
}
|
|
56478
|
+
const now = this.deps.now?.() ?? Date.now();
|
|
56479
|
+
if (liveFallbackAt !== null && now - liveFallbackAt < LIVE_FALLBACK_MS) {
|
|
56480
|
+
const snap = SnapshotImageSchema.safeParse(await this.deps.ctx.api.snapshot.getSnapshot.query({ deviceId }));
|
|
56481
|
+
if (snap.success) return (await encode(Buffer.from(snap.data.base64, "base64"))).bytes;
|
|
56482
|
+
}
|
|
56483
|
+
return null;
|
|
56484
|
+
} catch (err) {
|
|
56485
|
+
this.deps.logger.warn("alexa object detection: image read failed", {
|
|
56486
|
+
tags: { deviceId },
|
|
56487
|
+
meta: {
|
|
56488
|
+
trackId,
|
|
56489
|
+
error: errMsg(err)
|
|
56490
|
+
}
|
|
56491
|
+
});
|
|
56492
|
+
return null;
|
|
56493
|
+
}
|
|
56494
|
+
}
|
|
56495
|
+
};
|
|
56496
|
+
//#endregion
|
|
55106
56497
|
//#region src/oauth-integration.ts
|
|
55107
56498
|
/**
|
|
55108
56499
|
* The `export-alexa` OAuth client — the descriptor behind
|
|
@@ -55204,20 +56595,13 @@ function buildAlexaOauthIntegration(publicHubUrl) {
|
|
|
55204
56595
|
function buildAlexaSetupBlock(input) {
|
|
55205
56596
|
const base = input.publicHubUrl.replace(/\/+$/, "");
|
|
55206
56597
|
return {
|
|
55207
|
-
fields: [
|
|
55208
|
-
|
|
55209
|
-
|
|
55210
|
-
|
|
55211
|
-
|
|
55212
|
-
|
|
55213
|
-
|
|
55214
|
-
value: maskSecretTail(input.ingressToken)
|
|
55215
|
-
}] : [],
|
|
55216
|
-
{
|
|
55217
|
-
label: "Linked Alexa accounts",
|
|
55218
|
-
value: String(input.linkedAccountCount)
|
|
55219
|
-
}
|
|
55220
|
-
],
|
|
56598
|
+
fields: [...base ? [{
|
|
56599
|
+
label: "Directive endpoint",
|
|
56600
|
+
value: `${base}/addon/export-alexa/directive`
|
|
56601
|
+
}] : [], ...input.ingressToken && input.skillMode === "custom" ? [{
|
|
56602
|
+
label: "Ingress token (Lambda HUB_INGRESS_TOKEN) — use \"Show ingress token\" in settings",
|
|
56603
|
+
value: maskSecretTail(input.ingressToken)
|
|
56604
|
+
}] : []],
|
|
55221
56605
|
note: base ? "Account linking is handled by the core OAuth server. Use the hub's /api/oauth2/authorize endpoint with integration=export-alexa." : "Set the public hub URL on the settings form first — the directive endpoint URL is derived from it."
|
|
55222
56606
|
};
|
|
55223
56607
|
}
|
|
@@ -55301,6 +56685,7 @@ var SnapshotStore = class {
|
|
|
55301
56685
|
secret;
|
|
55302
56686
|
now;
|
|
55303
56687
|
images = /* @__PURE__ */ new Map();
|
|
56688
|
+
loading = /* @__PURE__ */ new Map();
|
|
55304
56689
|
totalBytes = 0;
|
|
55305
56690
|
constructor(secret, now = Date.now) {
|
|
55306
56691
|
this.secret = secret;
|
|
@@ -55329,6 +56714,59 @@ var SnapshotStore = class {
|
|
|
55329
56714
|
path: `/addon/export-alexa/snapshot/${imageId}.jpg?exp=${expiresAt}&sig=${signExpiringUrl(this.secret(), imageId, expiresAt)}`
|
|
55330
56715
|
};
|
|
55331
56716
|
}
|
|
56717
|
+
/**
|
|
56718
|
+
* Mint a URL whose pixels are read on the first authorised fetch (R4,
|
|
56719
|
+
* D779). Costs nothing until then, so a history page can carry twenty image
|
|
56720
|
+
* URIs without reading twenty images inside Alexa's response budget. Bounded
|
|
56721
|
+
* by count like every entry; by bytes once loaded.
|
|
56722
|
+
*/
|
|
56723
|
+
mintLazy(input) {
|
|
56724
|
+
this.prune();
|
|
56725
|
+
const imageId = randomUUID();
|
|
56726
|
+
const expiresAt = this.now() + SNAPSHOT_URL_TTL_MS;
|
|
56727
|
+
this.images.set(imageId, {
|
|
56728
|
+
imageId,
|
|
56729
|
+
deviceId: input.deviceId,
|
|
56730
|
+
userId: input.userId,
|
|
56731
|
+
bytes: Buffer.alloc(0),
|
|
56732
|
+
load: input.load,
|
|
56733
|
+
contentType: "image/jpeg",
|
|
56734
|
+
capturedAt: input.capturedAt,
|
|
56735
|
+
expiresAt
|
|
56736
|
+
});
|
|
56737
|
+
this.evictOverBounds();
|
|
56738
|
+
return {
|
|
56739
|
+
imageId,
|
|
56740
|
+
expiresAt,
|
|
56741
|
+
path: `/addon/export-alexa/snapshot/${imageId}.jpg?exp=${expiresAt}&sig=${signExpiringUrl(this.secret(), imageId, expiresAt)}`
|
|
56742
|
+
};
|
|
56743
|
+
}
|
|
56744
|
+
/**
|
|
56745
|
+
* The bytes of a found image — loading a lazy one once (concurrent fetches
|
|
56746
|
+
* share the read). `null` when a lazy image has nothing to show.
|
|
56747
|
+
*/
|
|
56748
|
+
async bytesOf(image) {
|
|
56749
|
+
if (image.load === void 0) return image.bytes;
|
|
56750
|
+
let pending = this.loading.get(image.imageId);
|
|
56751
|
+
if (pending === void 0) {
|
|
56752
|
+
pending = image.load().catch(() => null);
|
|
56753
|
+
this.loading.set(image.imageId, pending);
|
|
56754
|
+
}
|
|
56755
|
+
const bytes = await pending;
|
|
56756
|
+
this.loading.delete(image.imageId);
|
|
56757
|
+
if (bytes === null) return null;
|
|
56758
|
+
const held = this.images.get(image.imageId);
|
|
56759
|
+
if (held !== void 0 && held.load !== void 0) {
|
|
56760
|
+
this.images.set(image.imageId, {
|
|
56761
|
+
...held,
|
|
56762
|
+
bytes,
|
|
56763
|
+
load: void 0
|
|
56764
|
+
});
|
|
56765
|
+
this.totalBytes += bytes.length;
|
|
56766
|
+
this.evictOverBounds();
|
|
56767
|
+
}
|
|
56768
|
+
return bytes;
|
|
56769
|
+
}
|
|
55332
56770
|
/** Resolve a fetch of a minted URL. */
|
|
55333
56771
|
lookup(imageId, exp, sig) {
|
|
55334
56772
|
const now = this.now();
|
|
@@ -55360,6 +56798,7 @@ var SnapshotStore = class {
|
|
|
55360
56798
|
/** Drop everything — the integration switch was turned off. */
|
|
55361
56799
|
clear() {
|
|
55362
56800
|
this.images.clear();
|
|
56801
|
+
this.loading.clear();
|
|
55363
56802
|
this.totalBytes = 0;
|
|
55364
56803
|
}
|
|
55365
56804
|
get size() {
|
|
@@ -55631,6 +57070,9 @@ var StateChangeReporter = class {
|
|
|
55631
57070
|
var PUBLIC_SKILL_ID = "amzn1.ask.skill.aa85d6df-1600-4f13-bda9-babcfdc849e6";
|
|
55632
57071
|
//#endregion
|
|
55633
57072
|
//#region src/sync-engine.ts
|
|
57073
|
+
function declaresObjectDetection(endpoint) {
|
|
57074
|
+
return endpoint.capabilities.some((c) => c.interface === "Alexa.SmartVision.ObjectDetectionSensor");
|
|
57075
|
+
}
|
|
55634
57076
|
/**
|
|
55635
57077
|
* Devices reconciled at once in {@link SyncEngine.reconcileAllExposed}. Each
|
|
55636
57078
|
* lane's device build is a handful of cross-process tRPC calls — latency, not
|
|
@@ -55770,9 +57212,25 @@ var SyncEngine = class {
|
|
|
55770
57212
|
});
|
|
55771
57213
|
});
|
|
55772
57214
|
if (visible.length === 0) return true;
|
|
55773
|
-
const
|
|
55774
|
-
|
|
55775
|
-
|
|
57215
|
+
const isolated = visible.filter(declaresObjectDetection);
|
|
57216
|
+
const rest = visible.filter((e) => !declaresObjectDetection(e));
|
|
57217
|
+
let allOk = true;
|
|
57218
|
+
for (const batch of [...isolated.map((e) => [e]), ...rest.length > 0 ? [rest] : []]) {
|
|
57219
|
+
const ok = await this.deps.postAddOrUpdate(userId, batch);
|
|
57220
|
+
for (const endpoint of batch) this.deps.logger.info("alexa sync: AddOrUpdateReport endpoint result", {
|
|
57221
|
+
tags: { deviceId },
|
|
57222
|
+
meta: {
|
|
57223
|
+
userId,
|
|
57224
|
+
endpointId: endpoint.endpointId,
|
|
57225
|
+
gatewayAccepted: ok,
|
|
57226
|
+
isolated: batch.length === 1 && isolated.includes(endpoint),
|
|
57227
|
+
interfaces: endpoint.capabilities.map((c) => c.instance === void 0 ? c.interface : `${c.interface}#${c.instance}`)
|
|
57228
|
+
}
|
|
57229
|
+
});
|
|
57230
|
+
if (ok) this.deps.afterPush(userId, batch);
|
|
57231
|
+
else allOk = false;
|
|
57232
|
+
}
|
|
57233
|
+
return allOk;
|
|
55776
57234
|
}
|
|
55777
57235
|
/** A device the account's user just gained (visibility mirror). */
|
|
55778
57236
|
async pushDeviceToAccount(userId, deviceId) {
|
|
@@ -55800,7 +57258,7 @@ var SyncEngine = class {
|
|
|
55800
57258
|
const accounts = this.deps.listLinkedAccounts();
|
|
55801
57259
|
const failures = [];
|
|
55802
57260
|
for (const account of accounts) if (!await this.deps.postDelete(account.userId, ids)) failures.push(`${account.userId}:${deviceId}`);
|
|
55803
|
-
await this.recordEndpoints(deviceId, [], null);
|
|
57261
|
+
if (failures.length === 0) await this.recordEndpoints(deviceId, [], null);
|
|
55804
57262
|
return {
|
|
55805
57263
|
deviceId,
|
|
55806
57264
|
endpointCount: ids.length,
|
|
@@ -55809,6 +57267,51 @@ var SyncEngine = class {
|
|
|
55809
57267
|
};
|
|
55810
57268
|
}
|
|
55811
57269
|
/**
|
|
57270
|
+
* The operator's Re-sync: re-send every exposed device (whatever the
|
|
57271
|
+
* signatures say), then delete from Alexa every device we still track but no
|
|
57272
|
+
* longer expose — an unexpose whose DeleteReport failed.
|
|
57273
|
+
*
|
|
57274
|
+
* Only devices ABSENT from the exposed list are deleted. That list is the
|
|
57275
|
+
* operator's persisted choice, not a read that can fail transiently; what an
|
|
57276
|
+
* exposed device happens not to build this time is never deleted here
|
|
57277
|
+
* (D49 / D767).
|
|
57278
|
+
*/
|
|
57279
|
+
async resync() {
|
|
57280
|
+
const push = await this.pushAllExposed();
|
|
57281
|
+
const exposed = new Set(this.deps.listExposed().map((e) => e.deviceId));
|
|
57282
|
+
const unexposed = Object.entries(this.deps.getExportedEndpoints()).map(([key, ids]) => ({
|
|
57283
|
+
deviceId: Number(key),
|
|
57284
|
+
ids
|
|
57285
|
+
})).filter((entry) => !exposed.has(entry.deviceId) && entry.ids.length > 0);
|
|
57286
|
+
const canDelete = push.accountCount > 0 && this.deps.hasProactiveCredentials();
|
|
57287
|
+
let removedEndpointCount = 0;
|
|
57288
|
+
let retainedEndpointCount = 0;
|
|
57289
|
+
let failedCount = push.failures.length;
|
|
57290
|
+
for (const { deviceId, ids } of unexposed) {
|
|
57291
|
+
if (!canDelete) {
|
|
57292
|
+
retainedEndpointCount += ids.length;
|
|
57293
|
+
continue;
|
|
57294
|
+
}
|
|
57295
|
+
const r = await this.removeDevice(deviceId);
|
|
57296
|
+
failedCount += r.failures.length;
|
|
57297
|
+
if (r.failures.length === 0) removedEndpointCount += ids.length;
|
|
57298
|
+
else retainedEndpointCount += ids.length;
|
|
57299
|
+
}
|
|
57300
|
+
if (unexposed.length > 0) this.deps.logger.info("alexa sync: re-sync cleaned up devices that are no longer exposed", { meta: {
|
|
57301
|
+
deviceIds: unexposed.map((u) => u.deviceId),
|
|
57302
|
+
removedEndpointCount,
|
|
57303
|
+
retainedEndpointCount
|
|
57304
|
+
} });
|
|
57305
|
+
return {
|
|
57306
|
+
deviceCount: push.deviceCount,
|
|
57307
|
+
endpointCount: push.endpointCount,
|
|
57308
|
+
accountCount: push.accountCount,
|
|
57309
|
+
removedEndpointCount,
|
|
57310
|
+
retainedEndpointCount,
|
|
57311
|
+
failedCount
|
|
57312
|
+
};
|
|
57313
|
+
}
|
|
57314
|
+
/**
|
|
55812
57315
|
* Re-push ONE exposed device if the endpoint shape it builds today differs
|
|
55813
57316
|
* from the shape Alexa was last told about.
|
|
55814
57317
|
*
|
|
@@ -55993,19 +57496,35 @@ var DEFAULT_CONFIG = {
|
|
|
55993
57496
|
exportedEndpoints: {},
|
|
55994
57497
|
exportedSignatures: {},
|
|
55995
57498
|
publicHubUrl: "",
|
|
55996
|
-
availableEndpoints: [],
|
|
55997
57499
|
ingressToken: "",
|
|
55998
57500
|
enforceIngressToken: false,
|
|
55999
|
-
staleEndpointPurgeInput: "",
|
|
56000
57501
|
accountVisibility: {},
|
|
56001
57502
|
detectionSensors: true,
|
|
56002
57503
|
detectionClasses: {},
|
|
56003
57504
|
snapshots: true,
|
|
56004
|
-
snapshotSigningKey: ""
|
|
57505
|
+
snapshotSigningKey: "",
|
|
57506
|
+
objectDetection: true,
|
|
57507
|
+
visionNamespace: ""
|
|
56005
57508
|
};
|
|
56006
57509
|
/** The one field of `oauthVerifyAccessToken`'s answer the snapshot route
|
|
56007
57510
|
* reads. */
|
|
56008
57511
|
var VerifiedTokenSchema = object({ userId: string() }).nullable();
|
|
57512
|
+
/**
|
|
57513
|
+
* Settings as the runtime reads them. In `skillMode: 'public'` the skill id is
|
|
57514
|
+
* the built-in skill's, and the ingress token is never required: the public
|
|
57515
|
+
* skill's Lambda is shared by every hub and sends one `HUB_INGRESS_TOKEN` for
|
|
57516
|
+
* all of them, so enforcing a per-hub token would refuse every directive. The
|
|
57517
|
+
* persisted values stay untouched, so switching back to `custom` restores
|
|
57518
|
+
* them. There is no credential to overlay any more (D753).
|
|
57519
|
+
*/
|
|
57520
|
+
function effectiveAlexaSettings(config) {
|
|
57521
|
+
if (config.skillMode !== "public") return config;
|
|
57522
|
+
return {
|
|
57523
|
+
...config,
|
|
57524
|
+
expectedAudience: PUBLIC_SKILL_ID,
|
|
57525
|
+
enforceIngressToken: false
|
|
57526
|
+
};
|
|
57527
|
+
}
|
|
56009
57528
|
/** UI filter for the Export picker. `alarm-panel` publishes an
|
|
56010
57529
|
* `Alexa.SecurityPanelController` endpoint (`endpoints/builder.ts`). */
|
|
56011
57530
|
var SUPPORTED_KINDS = [
|
|
@@ -56033,6 +57552,8 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56033
57552
|
* restart answers 410 and Alexa asks again. */
|
|
56034
57553
|
snapshotStore = new SnapshotStore(() => this.config.snapshotSigningKey);
|
|
56035
57554
|
detectionFeed = null;
|
|
57555
|
+
/** R4 (D779): object detection + event history. */
|
|
57556
|
+
visionRef = null;
|
|
56036
57557
|
stateReporter = null;
|
|
56037
57558
|
/** The switches whose change re-shapes every exposed endpoint, as last
|
|
56038
57559
|
* applied — `onConfigChanged` runs on EVERY settings write. */
|
|
@@ -56045,6 +57566,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56045
57566
|
async onInitialize() {
|
|
56046
57567
|
if (!this.config.ingressToken) await this.updateGlobalSettings({ ingressToken: generateIngressToken() });
|
|
56047
57568
|
if (!this.config.snapshotSigningKey) await this.updateGlobalSettings({ snapshotSigningKey: generateIngressToken() });
|
|
57569
|
+
if (!this.config.visionNamespace) await this.updateGlobalSettings({ visionNamespace: randomUUID() });
|
|
56048
57570
|
if (this.config.lwaClientSecret) {
|
|
56049
57571
|
await this.updateGlobalSettings({ lwaClientSecret: "" });
|
|
56050
57572
|
this.ctx.logger.warn("export-alexa: removed a stored LWA client secret — the hub no longer uses one; your skill Lambda holds it (D753)");
|
|
@@ -56068,6 +57590,18 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56068
57590
|
setStored: (next) => this.updateGlobalSettings({ accountVisibility: next }),
|
|
56069
57591
|
logger: this.ctx.logger
|
|
56070
57592
|
});
|
|
57593
|
+
this.visionRef = new AlexaVisionRuntime({
|
|
57594
|
+
ctx: this.ctx,
|
|
57595
|
+
logger: this.ctx.logger,
|
|
57596
|
+
integrationOn: () => this.config.objectDetection,
|
|
57597
|
+
snapshotsOn: () => this.config.snapshots,
|
|
57598
|
+
isExposed: (deviceId) => this.config.exposed.some((e) => e.deviceId === deviceId),
|
|
57599
|
+
knownClasses: (deviceId) => this.config.detectionClasses[deviceId]?.classes ?? [],
|
|
57600
|
+
namespace: () => this.config.visionNamespace,
|
|
57601
|
+
publicHubUrl: () => this.config.publicHubUrl,
|
|
57602
|
+
store: this.snapshotStore,
|
|
57603
|
+
post: (opts) => this.requireGateway().postObjectDetection(opts)
|
|
57604
|
+
});
|
|
56071
57605
|
const deviceExportProvider = {
|
|
56072
57606
|
getStatus: async () => ({
|
|
56073
57607
|
linkState: this.config.linkedAccounts.length > 0 ? "linked" : "unlinked",
|
|
@@ -56140,6 +57674,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56140
57674
|
enabled: () => this.config.snapshots,
|
|
56141
57675
|
publicHubUrl: () => this.config.publicHubUrl
|
|
56142
57676
|
},
|
|
57677
|
+
vision: this.vision.context(),
|
|
56143
57678
|
access: this.endpointAccess,
|
|
56144
57679
|
afterDiscovery: (userId, endpoints) => this.requireGateway().scheduleInitialConnectivity(userId, endpoints)
|
|
56145
57680
|
},
|
|
@@ -56199,6 +57734,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56199
57734
|
logger: this.ctx.logger
|
|
56200
57735
|
});
|
|
56201
57736
|
this.detectionFeed.start();
|
|
57737
|
+
this.vision.start();
|
|
56202
57738
|
this.appliedShapeSwitches = this.shapeSwitches();
|
|
56203
57739
|
this.stateReporter = new StateChangeReporter({
|
|
56204
57740
|
eventBus: this.ctx.eventBus,
|
|
@@ -56278,14 +57814,25 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56278
57814
|
],
|
|
56279
57815
|
customActions: exportAlexaActions,
|
|
56280
57816
|
actionHandlers: {
|
|
56281
|
-
|
|
57817
|
+
listExternalAddresses: async () => this.listExternalAddresses(),
|
|
56282
57818
|
regenerateIngressToken: async () => this.regenerateIngressToken(),
|
|
56283
57819
|
revealIngressToken: async () => {
|
|
56284
57820
|
this.ctx.logger.info("export-alexa: an administrator revealed the ingress token");
|
|
56285
57821
|
return { ingressToken: this.config.ingressToken };
|
|
56286
57822
|
},
|
|
56287
57823
|
resyncDiscovery: async () => this.resyncDiscovery(),
|
|
56288
|
-
|
|
57824
|
+
removeAlexaEndpoints: async ({ endpointIds }) => removeEndpointsFromAlexa({
|
|
57825
|
+
raw: endpointIds,
|
|
57826
|
+
purge: (ids) => this.requireGateway().purgeEndpoints(ids),
|
|
57827
|
+
getTracked: () => this.config.exportedEndpoints,
|
|
57828
|
+
setTracked: (next) => this.updateGlobalSettings({ exportedEndpoints: next })
|
|
57829
|
+
}).then((r) => {
|
|
57830
|
+
this.ctx.logger.info("export-alexa: operator removed endpoint ids from Alexa", { meta: {
|
|
57831
|
+
...r,
|
|
57832
|
+
endpointIds
|
|
57833
|
+
} });
|
|
57834
|
+
return r;
|
|
57835
|
+
}),
|
|
56289
57836
|
unlinkAlexaAccounts: async () => this.unlinkAlexaAccounts(),
|
|
56290
57837
|
rotateBrokerCredential: async () => this.rotateBrokerCredentials()
|
|
56291
57838
|
}
|
|
@@ -56294,6 +57841,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56294
57841
|
async onShutdown() {
|
|
56295
57842
|
this.detectionFeed?.stop();
|
|
56296
57843
|
this.detectionFeed = null;
|
|
57844
|
+
this.visionRef?.stop();
|
|
56297
57845
|
this.stateReporter?.stop();
|
|
56298
57846
|
this.stateReporter = null;
|
|
56299
57847
|
if (this.lwaGateway) {
|
|
@@ -56347,18 +57895,26 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56347
57895
|
*/
|
|
56348
57896
|
async buildEndpointsForOneDevice(deviceId) {
|
|
56349
57897
|
const classSource = this.detectionClassSource;
|
|
57898
|
+
const vision = this.vision;
|
|
57899
|
+
let available = null;
|
|
57900
|
+
const classesOf = (id) => {
|
|
57901
|
+
available ??= classSource === null ? Promise.resolve([]) : classSource.classesFor(id);
|
|
57902
|
+
return available;
|
|
57903
|
+
};
|
|
56350
57904
|
return buildEndpointsForExposedDevice(deviceId, {
|
|
56351
57905
|
ctx: this.ctx,
|
|
56352
57906
|
logger: this.ctx.logger,
|
|
56353
57907
|
snapshots: this.config.snapshots,
|
|
56354
|
-
...this.config.detectionSensors
|
|
57908
|
+
...this.config.detectionSensors ? { detectionClasses: async (id) => effectiveSensorClasses(await classesOf(id), parseSensorSelection(await vision.readPrefs(id))) } : {},
|
|
57909
|
+
...this.config.objectDetection ? { objectDetection: async (id) => vision.buildClasses(id, await classesOf(id)) } : {}
|
|
56355
57910
|
});
|
|
56356
57911
|
}
|
|
56357
57912
|
/** The settings that change every exposed endpoint's shape, as one key. */
|
|
56358
57913
|
shapeSwitches() {
|
|
56359
57914
|
return JSON.stringify({
|
|
56360
57915
|
detectionSensors: this.config.detectionSensors,
|
|
56361
|
-
snapshots: this.config.snapshots
|
|
57916
|
+
snapshots: this.config.snapshots,
|
|
57917
|
+
objectDetection: this.config.objectDetection
|
|
56362
57918
|
});
|
|
56363
57919
|
}
|
|
56364
57920
|
/**
|
|
@@ -56380,7 +57936,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56380
57936
|
}
|
|
56381
57937
|
scheduleDetectionClassRefresh() {
|
|
56382
57938
|
const timer = setInterval(() => {
|
|
56383
|
-
if (!this.config.detectionSensors) return;
|
|
57939
|
+
if (!this.config.detectionSensors && !this.config.objectDetection) return;
|
|
56384
57940
|
this.requireSyncEngine().reconcileAllExposed().catch((err) => {
|
|
56385
57941
|
this.ctx.logger.warn("export-alexa: detection-class refresh failed", { meta: { error: errMsg(err) } });
|
|
56386
57942
|
});
|
|
@@ -56388,6 +57944,10 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56388
57944
|
timer.unref?.();
|
|
56389
57945
|
this.ctx.addDisposer(async () => clearInterval(timer));
|
|
56390
57946
|
}
|
|
57947
|
+
get vision() {
|
|
57948
|
+
if (!this.visionRef) throw new Error("export-alexa: vision runtime not initialized");
|
|
57949
|
+
return this.visionRef;
|
|
57950
|
+
}
|
|
56391
57951
|
get visibility() {
|
|
56392
57952
|
if (!this.visibilityRef) throw new Error("export-alexa: visibility mirror not initialized");
|
|
56393
57953
|
return this.visibilityRef;
|
|
@@ -56512,49 +58072,17 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56512
58072
|
});
|
|
56513
58073
|
}
|
|
56514
58074
|
}
|
|
56515
|
-
async resyncDiscovery() {
|
|
56516
|
-
const r = await this.requireSyncEngine().pushAllExposed();
|
|
56517
|
-
this.ctx.logger.info("export-alexa: manual discovery re-sync", { meta: { ...r } });
|
|
56518
|
-
return {
|
|
56519
|
-
endpointCount: r.endpointCount,
|
|
56520
|
-
accountCount: r.accountCount
|
|
56521
|
-
};
|
|
56522
|
-
}
|
|
56523
58075
|
/**
|
|
56524
|
-
*
|
|
56525
|
-
*
|
|
56526
|
-
*
|
|
56527
|
-
* then clears the input field so a re-click is idempotent. Also drops
|
|
56528
|
-
* any `exportedEndpoints` device whose endpoint ids were all purged so a
|
|
56529
|
-
* later DeleteReport doesn't try to re-delete them.
|
|
58076
|
+
* The Re-sync button: every exposed device re-sent, and the devices still
|
|
58077
|
+
* tracked but no longer exposed deleted from Alexa (`SyncEngine.resync`).
|
|
58078
|
+
* Throws when nothing can be sent, so the button says why.
|
|
56530
58079
|
*/
|
|
56531
|
-
async
|
|
56532
|
-
|
|
56533
|
-
if (
|
|
56534
|
-
|
|
56535
|
-
|
|
56536
|
-
|
|
56537
|
-
};
|
|
56538
|
-
const result = await this.lwaGateway?.purgeEndpoints(ids) ?? {
|
|
56539
|
-
requested: ids.length,
|
|
56540
|
-
accountCount: 0,
|
|
56541
|
-
attemptedAccounts: 0
|
|
56542
|
-
};
|
|
56543
|
-
const purgedSet = new Set(ids);
|
|
56544
|
-
const nextEndpoints = {};
|
|
56545
|
-
for (const [deviceIdKey, endpointIds] of Object.entries(this.config.exportedEndpoints)) {
|
|
56546
|
-
const remaining = endpointIds.filter((id) => !purgedSet.has(id));
|
|
56547
|
-
if (remaining.length > 0) nextEndpoints[Number(deviceIdKey)] = remaining;
|
|
56548
|
-
}
|
|
56549
|
-
await this.updateGlobalSettings({
|
|
56550
|
-
staleEndpointPurgeInput: "",
|
|
56551
|
-
exportedEndpoints: nextEndpoints
|
|
56552
|
-
});
|
|
56553
|
-
this.ctx.logger.info("export-alexa: manual stale-endpoint purge", { meta: {
|
|
56554
|
-
...result,
|
|
56555
|
-
endpointIds: ids
|
|
56556
|
-
} });
|
|
56557
|
-
return result;
|
|
58080
|
+
async resyncDiscovery() {
|
|
58081
|
+
if (this.config.linkedAccounts.length === 0) throw new Error("No linked Amazon account. Link the skill from the Alexa app first.");
|
|
58082
|
+
if (this.config.tokens.length === 0) throw new Error("No Amazon push grant is stored for the linked account, so nothing can be sent. Re-link the skill from the Alexa app.");
|
|
58083
|
+
const r = await this.requireSyncEngine().resync();
|
|
58084
|
+
this.ctx.logger.info("export-alexa: manual discovery re-sync", { meta: { ...r } });
|
|
58085
|
+
return r;
|
|
56558
58086
|
}
|
|
56559
58087
|
/**
|
|
56560
58088
|
* Sever the Amazon account link. Backs the "Unlink Amazon account" button.
|
|
@@ -56584,7 +58112,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56584
58112
|
* Deliberately does NOT send a `DeleteReport` first. Amazon drops the
|
|
56585
58113
|
* endpoints itself when the account is unlinked, and a purge needs the very
|
|
56586
58114
|
* token we are about to discard — a best-effort push here would make the
|
|
56587
|
-
* result claim work it cannot verify. Use "
|
|
58115
|
+
* result claim work it cannot verify. Use "Remove ids from Alexa" if
|
|
56588
58116
|
* ghosts survive.
|
|
56589
58117
|
*/
|
|
56590
58118
|
async unlinkAlexaAccounts() {
|
|
@@ -56674,19 +58202,9 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56674
58202
|
});
|
|
56675
58203
|
if (missing.length > 0) this.ctx.logger.info("export-alexa: backfilled linked accounts from stored LWA tokens", { meta: { count: missing.length } });
|
|
56676
58204
|
}
|
|
56677
|
-
/**
|
|
56678
|
-
* Settings as the runtime should read them. In `skillMode: 'public'` the
|
|
56679
|
-
* skill id is swapped for the built-in CamStack skill's; the operator's own
|
|
56680
|
-
* `expectedAudience` stays untouched in the persisted config so switching
|
|
56681
|
-
* back to `custom` restores it. Everything else passes through. There is no
|
|
56682
|
-
* credential to overlay any more (D753).
|
|
56683
|
-
*/
|
|
58205
|
+
/** Settings as the runtime reads them (`effectiveAlexaSettings`). */
|
|
56684
58206
|
effectiveSettings() {
|
|
56685
|
-
|
|
56686
|
-
return {
|
|
56687
|
-
...this.config,
|
|
56688
|
-
expectedAudience: PUBLIC_SKILL_ID
|
|
56689
|
-
};
|
|
58207
|
+
return effectiveAlexaSettings(this.config);
|
|
56690
58208
|
}
|
|
56691
58209
|
/** Persist the broker origin the Lambda advertised on a verified directive. */
|
|
56692
58210
|
rememberAdvertisedBroker(raw) {
|
|
@@ -56719,6 +58237,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56719
58237
|
* broker no longer recognises is reported, not retried — re-link instead.
|
|
56720
58238
|
*/
|
|
56721
58239
|
async rotateBrokerCredentials() {
|
|
58240
|
+
if (!this.config.tokens.some(isBrokerGrant)) throw new Error("No link is held at the skill's token broker, so there is no credential to rotate. Link the skill from the Alexa app first.");
|
|
56722
58241
|
let rotated = 0;
|
|
56723
58242
|
let failed = 0;
|
|
56724
58243
|
for (const t of this.config.tokens) {
|
|
@@ -56775,7 +58294,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56775
58294
|
if (fromList.length > 0) return fromList;
|
|
56776
58295
|
const single = await this.detectFromGetStatus();
|
|
56777
58296
|
if (single.length > 0) return [single];
|
|
56778
|
-
this.ctx.logger.warn("export-alexa: no connected HTTPS remote-access endpoint — Alexa Lambda has no hub URL to route to. Set up Cloudflare Tunnel / Tailscale Funnel
|
|
58297
|
+
this.ctx.logger.warn("export-alexa: no connected HTTPS remote-access endpoint — Alexa Lambda has no hub URL to route to. Set up Cloudflare Tunnel / Tailscale Funnel; the External address picker on the Alexa page lists it once connected.");
|
|
56779
58298
|
return [];
|
|
56780
58299
|
}
|
|
56781
58300
|
/** Aggregate `listEndpoints` → every HTTPS entry's url, de-duplicated. */
|
|
@@ -56803,33 +58322,29 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56803
58322
|
}
|
|
56804
58323
|
}
|
|
56805
58324
|
/**
|
|
56806
|
-
* Boot-time best-effort:
|
|
56807
|
-
*
|
|
56808
|
-
*
|
|
58325
|
+
* Boot-time best-effort: default the external address to the first
|
|
58326
|
+
* detected endpoint when the operator hasn't chosen one. The picker reads
|
|
58327
|
+
* its options live, so nothing is cached here.
|
|
56809
58328
|
*/
|
|
56810
58329
|
async autoDetectPublicHubUrl() {
|
|
56811
|
-
|
|
56812
|
-
const
|
|
56813
|
-
|
|
56814
|
-
|
|
56815
|
-
|
|
56816
|
-
|
|
56817
|
-
} : { availableEndpoints: endpoints });
|
|
56818
|
-
if (defaulted) this.ctx.logger.info("export-alexa: defaulted public hub URL to first external address", { meta: { url: defaulted } });
|
|
58330
|
+
if (this.config.publicHubUrl) return;
|
|
58331
|
+
const defaulted = (await this.resolveEndpoints())[0];
|
|
58332
|
+
if (defaulted) {
|
|
58333
|
+
await this.updateGlobalSettings({ publicHubUrl: defaulted });
|
|
58334
|
+
this.ctx.logger.info("export-alexa: defaulted public hub URL to first external address", { meta: { url: defaulted } });
|
|
58335
|
+
}
|
|
56819
58336
|
}
|
|
56820
58337
|
/**
|
|
56821
|
-
* Re-scan the connected `network-access` providers
|
|
56822
|
-
*
|
|
56823
|
-
*
|
|
56824
|
-
* we landed on (empty
|
|
58338
|
+
* Re-scan the connected `network-access` providers and keep the
|
|
58339
|
+
* operator's current selection when it's still present (otherwise default
|
|
58340
|
+
* to the first). Runs when a tunnel connects and behind the
|
|
58341
|
+
* `/admin/redetect-hub-url` route. Returns the URL we landed on (empty
|
|
58342
|
+
* string when nothing is connected).
|
|
56825
58343
|
*/
|
|
56826
58344
|
async redetectPublicHubUrl() {
|
|
56827
58345
|
const endpoints = await this.resolveEndpoints();
|
|
56828
58346
|
const publicHubUrl = this.config.publicHubUrl.length > 0 && endpoints.includes(this.config.publicHubUrl) ? this.config.publicHubUrl : endpoints[0] ?? "";
|
|
56829
|
-
await this.updateGlobalSettings({
|
|
56830
|
-
availableEndpoints: endpoints,
|
|
56831
|
-
publicHubUrl
|
|
56832
|
-
});
|
|
58347
|
+
if (publicHubUrl !== this.config.publicHubUrl) await this.updateGlobalSettings({ publicHubUrl });
|
|
56833
58348
|
return { publicHubUrl };
|
|
56834
58349
|
}
|
|
56835
58350
|
/**
|
|
@@ -56857,224 +58372,36 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56857
58372
|
return buildAlexaSetupBlock({
|
|
56858
58373
|
publicHubUrl: this.config.publicHubUrl,
|
|
56859
58374
|
ingressToken: this.config.ingressToken,
|
|
56860
|
-
|
|
58375
|
+
skillMode: this.config.skillMode
|
|
56861
58376
|
});
|
|
56862
58377
|
}
|
|
56863
|
-
/**
|
|
56864
|
-
*
|
|
56865
|
-
|
|
56866
|
-
|
|
56867
|
-
|
|
56868
|
-
|
|
56869
|
-
const
|
|
56870
|
-
|
|
56871
|
-
|
|
58378
|
+
/**
|
|
58379
|
+
* The "External address" picker's options, read live: every HTTPS endpoint
|
|
58380
|
+
* the connected remote-access providers report now, plus the saved choice
|
|
58381
|
+
* when it is not among them, so a manual pick is never dropped from view.
|
|
58382
|
+
*/
|
|
58383
|
+
async listExternalAddresses() {
|
|
58384
|
+
const detected = await this.resolveEndpoints();
|
|
58385
|
+
const current = this.config.publicHubUrl;
|
|
58386
|
+
const addresses = detected.map((url) => ({
|
|
58387
|
+
value: url,
|
|
58388
|
+
label: url
|
|
56872
58389
|
}));
|
|
56873
|
-
return
|
|
56874
|
-
value:
|
|
56875
|
-
label:
|
|
56876
|
-
}];
|
|
58390
|
+
return { addresses: current.length > 0 && !detected.includes(current) ? [...addresses, {
|
|
58391
|
+
value: current,
|
|
58392
|
+
label: `${current} (not detected now)`
|
|
58393
|
+
}] : addresses };
|
|
56877
58394
|
}
|
|
58395
|
+
/** The settings page: see `settings-schema.ts` for its layout. The account-
|
|
58396
|
+
* linking rows live in the `device-export` setup block instead. */
|
|
56878
58397
|
globalSettingsSchema() {
|
|
56879
|
-
return
|
|
56880
|
-
|
|
56881
|
-
|
|
56882
|
-
|
|
56883
|
-
|
|
56884
|
-
|
|
56885
|
-
|
|
56886
|
-
type: "info",
|
|
56887
|
-
key: "__setup-banner",
|
|
56888
|
-
label: "Setup overview",
|
|
56889
|
-
variant: "info",
|
|
56890
|
-
content: [
|
|
56891
|
-
"Public skill (default): enable the \"CamStack\" skill in the Alexa app and link your account — no Amazon developer setup is needed. A single published skill + hosted Lambda route to this hub using its public HTTPS URL (baked into every JWT).",
|
|
56892
|
-
"The skill's Lambda keeps the Amazon credentials: this hub never holds an LWA client secret. It receives short-lived event tokens from the Lambda's token broker with a credential that is unique to this link.",
|
|
56893
|
-
"Custom skill: switch \"Alexa skill\" below to Custom, enter your skill id, and deploy the skill Lambda from this addon's lambda-template in your own AWS account (your LWA credentials go into SSM Parameter Store there, not here).",
|
|
56894
|
-
"Either way this hub must be reachable from the public internet (Cloudflare Tunnel, Tailscale Funnel, …). The \"External address\" picker selects which detected endpoint the Lambda calls back on — press \"Refresh external addresses\" after setting up a new tunnel."
|
|
56895
|
-
].join("\n")
|
|
56896
|
-
},
|
|
56897
|
-
this.field({
|
|
56898
|
-
type: "select",
|
|
56899
|
-
key: "publicHubUrl",
|
|
56900
|
-
label: "External address",
|
|
56901
|
-
description: "Which connected external-access endpoint the Alexa Lambda reaches the hub on. Defaults to the first detected. Press \"Refresh external addresses\" after setting up a new tunnel.",
|
|
56902
|
-
options: this.buildEndpointOptions(),
|
|
56903
|
-
default: DEFAULT_CONFIG.publicHubUrl
|
|
56904
|
-
}),
|
|
56905
|
-
{
|
|
56906
|
-
type: "button",
|
|
56907
|
-
key: "__redetect-hub-url",
|
|
56908
|
-
label: "External addresses",
|
|
56909
|
-
description: "Re-scan the connected external-access providers and refresh the picker above. Keeps your current selection if it is still available, otherwise defaults to the first.",
|
|
56910
|
-
buttonLabel: "Refresh external addresses",
|
|
56911
|
-
action: "redetectHubUrl",
|
|
56912
|
-
variant: "default"
|
|
56913
|
-
},
|
|
56914
|
-
this.field({
|
|
56915
|
-
type: "select",
|
|
56916
|
-
key: "skillMode",
|
|
56917
|
-
label: "Alexa skill",
|
|
56918
|
-
description: "Public: use the built-in CamStack skill — zero Amazon developer setup. Custom: bring your own skill and your own copy of the skill Lambda.",
|
|
56919
|
-
options: [{
|
|
56920
|
-
value: "public",
|
|
56921
|
-
label: "Public (built-in CamStack skill)"
|
|
56922
|
-
}, {
|
|
56923
|
-
value: "custom",
|
|
56924
|
-
label: "Custom skill"
|
|
56925
|
-
}],
|
|
56926
|
-
default: DEFAULT_CONFIG.skillMode
|
|
56927
|
-
}),
|
|
56928
|
-
this.field({
|
|
56929
|
-
type: "select",
|
|
56930
|
-
key: "region",
|
|
56931
|
-
label: "Skill region",
|
|
56932
|
-
description: "The Alexa Event Gateway this hub posts to: doorbell presses, motion, camera online/offline and state changes, and the device-list updates sent when you expose or unexpose a camera. Pick the region your Amazon account is in — events sent to another region are rejected.",
|
|
56933
|
-
options: [
|
|
56934
|
-
{
|
|
56935
|
-
value: "NA",
|
|
56936
|
-
label: "North America"
|
|
56937
|
-
},
|
|
56938
|
-
{
|
|
56939
|
-
value: "EU",
|
|
56940
|
-
label: "Europe"
|
|
56941
|
-
},
|
|
56942
|
-
{
|
|
56943
|
-
value: "FE",
|
|
56944
|
-
label: "Far East"
|
|
56945
|
-
}
|
|
56946
|
-
],
|
|
56947
|
-
default: DEFAULT_CONFIG.region
|
|
56948
|
-
}),
|
|
56949
|
-
this.field({
|
|
56950
|
-
type: "boolean",
|
|
56951
|
-
key: "detectionSensors",
|
|
56952
|
-
label: "Detection sensors",
|
|
56953
|
-
description: "Give every exposed camera one Alexa motion sensor per class its detection pipeline finds: \"<camera> Person\", \"Vehicle\", \"Animal\", \"Package\". Use them as routine triggers in the Alexa app (for example: when Front door Person detects motion, announce on the Echo devices). They report only \"detected\" — no images. Off removes them from Alexa.",
|
|
56954
|
-
default: DEFAULT_CONFIG.detectionSensors
|
|
56955
|
-
}),
|
|
56956
|
-
this.field({
|
|
56957
|
-
type: "boolean",
|
|
56958
|
-
key: "snapshots",
|
|
56959
|
-
label: "Snapshots in the Alexa app",
|
|
56960
|
-
description: "Show each exposed camera's latest still on its tile in the Alexa app. While this is on, Amazon fetches still images of your cameras (re-encoded to about 480 px, each link valid ten minutes and only with your account's token). Off removes the feature from Alexa and invalidates every link already handed out. The hub's public HTTPS address must be set.",
|
|
56961
|
-
default: DEFAULT_CONFIG.snapshots
|
|
56962
|
-
}),
|
|
56963
|
-
...this.config.skillMode === "custom" ? [this.field({
|
|
56964
|
-
type: "text",
|
|
56965
|
-
key: "expectedAudience",
|
|
56966
|
-
label: "Alexa skill id",
|
|
56967
|
-
description: "Your skill's id, format amzn1.ask.skill.xxxx. Found on the Alexa developer console; used to gate directives the next dispatch will start signing.",
|
|
56968
|
-
default: DEFAULT_CONFIG.expectedAudience
|
|
56969
|
-
}), {
|
|
56970
|
-
type: "info",
|
|
56971
|
-
key: "__custom-skill-info",
|
|
56972
|
-
label: "Your skill Lambda",
|
|
56973
|
-
variant: "info",
|
|
56974
|
-
content: "Proactive events (doorbell, motion, online/offline, device-list updates) are authorised by YOUR skill Lambda: it keeps the \"Send Alexa Events\" LWA client id/secret in SSM Parameter Store and hands this hub short-lived tokens. Nothing to paste here — see the custom-skill setup guide."
|
|
56975
|
-
}] : [{
|
|
56976
|
-
type: "info",
|
|
56977
|
-
key: "__public-skill-info",
|
|
56978
|
-
label: "Built-in skill",
|
|
56979
|
-
variant: "info",
|
|
56980
|
-
content: "Using the built-in CamStack public skill — its Amazon credentials stay in the skill's Lambda, never on this hub. Pick your region above, then just enable \"CamStack\" in the Alexa app and link your account. Switch to \"Custom skill\" above to use your own."
|
|
56981
|
-
}],
|
|
56982
|
-
{
|
|
56983
|
-
type: "info",
|
|
56984
|
-
key: "__alexa-link-status",
|
|
56985
|
-
label: "Account link",
|
|
56986
|
-
variant: "info",
|
|
56987
|
-
content: buildLinkStatusContent({
|
|
56988
|
-
linkedAccounts: this.config.linkedAccounts,
|
|
56989
|
-
tokens: this.config.tokens,
|
|
56990
|
-
now: Date.now()
|
|
56991
|
-
})
|
|
56992
|
-
},
|
|
56993
|
-
{
|
|
56994
|
-
type: "button",
|
|
56995
|
-
key: "__unlink-alexa-accounts",
|
|
56996
|
-
label: "Unlink Amazon account",
|
|
56997
|
-
description: "Revokes every active Alexa account-link session on this hub and discards the stored Amazon push grant. Live view, voice control and proactive events stop immediately. This is the ONLY way to sever the link — the account-linking token never expires on its own. Re-link afterwards from the Alexa app; the new link also picks up any widened permissions, because scopes are frozen at consent.",
|
|
56998
|
-
buttonLabel: "Unlink now",
|
|
56999
|
-
action: "unlinkAlexaAccounts",
|
|
57000
|
-
variant: "danger",
|
|
57001
|
-
confirmMessage: "Unlink the Amazon account? Alexa loses access to your cameras immediately and you must re-link from the Alexa app to restore it."
|
|
57002
|
-
},
|
|
57003
|
-
{
|
|
57004
|
-
type: "button",
|
|
57005
|
-
key: "__rotate-broker-credential",
|
|
57006
|
-
label: "Skill broker credential",
|
|
57007
|
-
description: "Replace the credential this hub uses at the skill's token broker with a fresh one. The old one stops working immediately. Use it if a backup of this hub's settings may have leaked. Re-linking from the Alexa app also issues a fresh one.",
|
|
57008
|
-
buttonLabel: "Rotate credential",
|
|
57009
|
-
action: "rotateBrokerCredential",
|
|
57010
|
-
variant: "default"
|
|
57011
|
-
},
|
|
57012
|
-
{
|
|
57013
|
-
type: "button",
|
|
57014
|
-
key: "__resync-discovery",
|
|
57015
|
-
label: "Sync devices to Alexa",
|
|
57016
|
-
description: "Re-send the exposed device list to Alexa now (AddOrUpdateReport). Devices normally sync automatically when you expose/unexpose them — use this if a camera has not appeared in the Alexa app yet. Requires a linked account.",
|
|
57017
|
-
buttonLabel: "Re-sync devices now",
|
|
57018
|
-
action: "resyncDiscovery",
|
|
57019
|
-
variant: "default"
|
|
57020
|
-
},
|
|
57021
|
-
this.field({
|
|
57022
|
-
type: "textarea",
|
|
57023
|
-
key: "staleEndpointPurgeInput",
|
|
57024
|
-
label: "Purge stale Alexa endpoints",
|
|
57025
|
-
description: "Comma / newline / whitespace separated Alexa endpoint ids to force-delete (Alexa.Discovery/DeleteReport). Use when an old camera still shows in the Alexa app even though it is not in your exposed list (left over from a previous link / addon version). Endpoint id is typically the camstack deviceId — e.g. \"54\" for the camera, \"54__privacy\" for its privacy toggle, \"54__98\" for a child accessory. Save this field first, then press the button below — the input is cleared after a successful purge.",
|
|
57026
|
-
rows: 3,
|
|
57027
|
-
default: DEFAULT_CONFIG.staleEndpointPurgeInput
|
|
57028
|
-
}),
|
|
57029
|
-
{
|
|
57030
|
-
type: "button",
|
|
57031
|
-
key: "__purge-stale-endpoints",
|
|
57032
|
-
label: "Purge endpoints",
|
|
57033
|
-
description: "Sends DeleteReport for every endpoint id listed above. Requires a linked account with proactive credentials. Check the addon log for per-account delivery outcomes.",
|
|
57034
|
-
buttonLabel: "Purge now",
|
|
57035
|
-
action: "purgeStaleEndpoints",
|
|
57036
|
-
variant: "danger",
|
|
57037
|
-
confirmMessage: "Send DeleteReport for these endpoint ids to all linked Alexa accounts? The action clears the input on success."
|
|
57038
|
-
},
|
|
57039
|
-
{
|
|
57040
|
-
type: "info",
|
|
57041
|
-
key: "__ingress-token-info",
|
|
57042
|
-
label: "Ingress token",
|
|
57043
|
-
variant: "info",
|
|
57044
|
-
content: "Defense-in-depth shared secret. Press \"Show ingress token\" to read it (it is never part of a status or settings read), paste it into your Alexa Lambda's HUB_INGRESS_TOKEN env, then enable \"Require ingress token\" below. Use \"Regenerate token\" to rotate (update the Lambda afterwards)."
|
|
57045
|
-
},
|
|
57046
|
-
{
|
|
57047
|
-
type: "addon-action-button",
|
|
57048
|
-
addonId: "export-alexa",
|
|
57049
|
-
key: "__reveal-ingress-token",
|
|
57050
|
-
label: "Show ingress token",
|
|
57051
|
-
description: "Administrators only. Shows the current token once, here.",
|
|
57052
|
-
buttonLabel: "Show ingress token",
|
|
57053
|
-
action: "revealIngressToken",
|
|
57054
|
-
variant: "default",
|
|
57055
|
-
successMessage: "Ingress token: {ingressToken}"
|
|
57056
|
-
},
|
|
57057
|
-
{
|
|
57058
|
-
type: "addon-action-button",
|
|
57059
|
-
addonId: "export-alexa",
|
|
57060
|
-
key: "__regenerate-ingress-token",
|
|
57061
|
-
label: "Rotate ingress token",
|
|
57062
|
-
description: "Generate a new ingress token, invalidating the old one. Update your Lambda's HUB_INGRESS_TOKEN afterwards.",
|
|
57063
|
-
buttonLabel: "Regenerate token",
|
|
57064
|
-
action: "regenerateIngressToken",
|
|
57065
|
-
variant: "default",
|
|
57066
|
-
successMessage: "New ingress token: {ingressToken} — paste it into HUB_INGRESS_TOKEN now.",
|
|
57067
|
-
confirmMessage: "Rotate the ingress token? With \"Require ingress token\" on, the Lambda is refused until you update HUB_INGRESS_TOKEN."
|
|
57068
|
-
},
|
|
57069
|
-
this.field({
|
|
57070
|
-
type: "boolean",
|
|
57071
|
-
key: "enforceIngressToken",
|
|
57072
|
-
label: "Require ingress token",
|
|
57073
|
-
description: "When on, /directive and the LWA callback reject calls without a matching ingress token. Turn on AFTER you have set HUB_INGRESS_TOKEN in your Lambda.",
|
|
57074
|
-
default: DEFAULT_CONFIG.enforceIngressToken
|
|
57075
|
-
})
|
|
57076
|
-
]
|
|
57077
|
-
}] });
|
|
58398
|
+
return { sections: buildAlexaSettingsSections({
|
|
58399
|
+
skillMode: this.config.skillMode,
|
|
58400
|
+
linkedAccounts: this.config.linkedAccounts,
|
|
58401
|
+
tokens: this.config.tokens,
|
|
58402
|
+
now: Date.now(),
|
|
58403
|
+
defaults: DEFAULT_CONFIG
|
|
58404
|
+
}) };
|
|
57078
58405
|
}
|
|
57079
58406
|
/**
|
|
57080
58407
|
* One sweep: ask the broker which held sessions are still alive, and release
|
|
@@ -57147,11 +58474,51 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
57147
58474
|
value: enabled,
|
|
57148
58475
|
immediate: true
|
|
57149
58476
|
},
|
|
57150
|
-
...kind === "camera" ? this.cameraOnlyExportFields(deviceId, settings, enabledKey) : []
|
|
58477
|
+
...kind === "camera" ? this.cameraOnlyExportFields(deviceId, settings, enabledKey) : [],
|
|
58478
|
+
...kind === "camera" ? await this.cameraDetectionFields(deviceId, enabledKey) : []
|
|
57151
58479
|
]
|
|
57152
58480
|
}]
|
|
57153
58481
|
};
|
|
57154
58482
|
}
|
|
58483
|
+
/**
|
|
58484
|
+
* The camera's detection fields: which classes become Alexa sensors (D778;
|
|
58485
|
+
* only the classes its pipeline produces are offered) and its object
|
|
58486
|
+
* detection toggle (D779).
|
|
58487
|
+
*/
|
|
58488
|
+
async cameraDetectionFields(deviceId, enabledKey) {
|
|
58489
|
+
const store = await this.vision.readPrefs(deviceId);
|
|
58490
|
+
return [sensorSelectionField({
|
|
58491
|
+
deviceId,
|
|
58492
|
+
available: this.detectionClassSource === null ? [] : await this.detectionClassSource.classesFor(deviceId).catch(() => []),
|
|
58493
|
+
saved: parseSensorSelection(store),
|
|
58494
|
+
enabledKey,
|
|
58495
|
+
masterOn: this.config.detectionSensors
|
|
58496
|
+
}), objectDetectionField({
|
|
58497
|
+
deviceId,
|
|
58498
|
+
store,
|
|
58499
|
+
enabledKey,
|
|
58500
|
+
integrationOn: this.config.objectDetection
|
|
58501
|
+
})];
|
|
58502
|
+
}
|
|
58503
|
+
/**
|
|
58504
|
+
* Persist the camera's detection preferences from a device-settings patch
|
|
58505
|
+
* into this addon's per-device store. True when anything changed — the
|
|
58506
|
+
* camera's Alexa shape then needs a reconcile.
|
|
58507
|
+
*/
|
|
58508
|
+
async applyDetectionPrefsPatch(deviceId, patch) {
|
|
58509
|
+
const selectionKey = sensorSelectionKey(deviceId);
|
|
58510
|
+
const odKey = objectDetectionKey(deviceId);
|
|
58511
|
+
const write = {};
|
|
58512
|
+
if (selectionKey in patch) write[SENSOR_SELECTION_KEY] = sensorSelectionFromPatch(patch[selectionKey]);
|
|
58513
|
+
if (odKey in patch) write[OBJECT_DETECTION_KEY] = Boolean(patch[odKey]);
|
|
58514
|
+
if (Object.keys(write).length === 0) return false;
|
|
58515
|
+
await this.vision.writePrefs(deviceId, write);
|
|
58516
|
+
this.ctx.logger.info("export-alexa: camera detection preferences saved", {
|
|
58517
|
+
tags: { deviceId },
|
|
58518
|
+
meta: { ...write }
|
|
58519
|
+
});
|
|
58520
|
+
return true;
|
|
58521
|
+
}
|
|
57155
58522
|
/** The stream fields of the Export section — a camera's only. */
|
|
57156
58523
|
cameraOnlyExportFields(deviceId, settings, enabledKey) {
|
|
57157
58524
|
const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
|
|
@@ -57197,6 +58564,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
57197
58564
|
const idStr = String(deviceId);
|
|
57198
58565
|
const current = this.findEntry(deviceId);
|
|
57199
58566
|
const wasEnabled = current !== null;
|
|
58567
|
+
const prefsChanged = await this.applyDetectionPrefsPatch(deviceId, patch);
|
|
57200
58568
|
const enabledKey = `alexa:${deviceId}:enabled`;
|
|
57201
58569
|
const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
|
|
57202
58570
|
const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
|
|
@@ -57221,7 +58589,14 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
57221
58589
|
const currentPref = current?.settings?.streamPreference ?? "auto";
|
|
57222
58590
|
const currentAllow = current?.settings?.allowTranscode !== false;
|
|
57223
58591
|
await this.patchEntrySettings(deviceId, nextSettings);
|
|
57224
|
-
|
|
58592
|
+
const sourceChanged = currentPref !== streamPreference || currentAllow !== allowTranscode;
|
|
58593
|
+
if (prefsChanged && !sourceChanged) this.requireSyncEngine().reconcileDevice(deviceId).catch((err) => {
|
|
58594
|
+
this.ctx.logger.warn("export-alexa: re-sync after a detection preference change failed", {
|
|
58595
|
+
tags: { deviceId },
|
|
58596
|
+
meta: { error: errMsg(err) }
|
|
58597
|
+
});
|
|
58598
|
+
});
|
|
58599
|
+
if (sourceChanged) {
|
|
57225
58600
|
this.ctx.logger.info("export-alexa: source settings changed — refreshing exposure", {
|
|
57226
58601
|
tags: { deviceId },
|
|
57227
58602
|
meta: {
|
|
@@ -57276,4 +58651,4 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
57276
58651
|
}
|
|
57277
58652
|
};
|
|
57278
58653
|
//#endregion
|
|
57279
|
-
export { ExportAlexaAddon, ExportAlexaAddon as default, ALEXA_EGRESS_PROFILE as i, releaseAlexaEgress as n, exportAlexaActions as r, acquireAlexaEgress as t };
|
|
58654
|
+
export { ExportAlexaAddon, ExportAlexaAddon as default, effectiveAlexaSettings, ALEXA_EGRESS_PROFILE as i, releaseAlexaEgress as n, exportAlexaActions as r, acquireAlexaEgress as t };
|