@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
|
@@ -5399,7 +5399,7 @@ var ZodIssueCode = {
|
|
|
5399
5399
|
var ZodFirstPartyTypeKind;
|
|
5400
5400
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5401
5401
|
//#endregion
|
|
5402
|
-
//#region ../types/dist/sleep-
|
|
5402
|
+
//#region ../types/dist/sleep-B0JG7-_F.mjs
|
|
5403
5403
|
/**
|
|
5404
5404
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5405
5405
|
* window to float samples (D455).
|
|
@@ -8356,8 +8356,8 @@ function buildAddonRouteProvider(id, routes) {
|
|
|
8356
8356
|
const reply = buildCapturingReply(envelope);
|
|
8357
8357
|
const request = {
|
|
8358
8358
|
params: {
|
|
8359
|
-
...
|
|
8360
|
-
...
|
|
8359
|
+
...match.params,
|
|
8360
|
+
...input.params
|
|
8361
8361
|
},
|
|
8362
8362
|
query: input.query,
|
|
8363
8363
|
body: input.body,
|
|
@@ -15144,6 +15144,10 @@ var DeviceInfoSchema = object({
|
|
|
15144
15144
|
isCamera: boolean(),
|
|
15145
15145
|
/** Current config values — serializable snapshot */
|
|
15146
15146
|
config: record(string(), unknown()),
|
|
15147
|
+
/** Paths inside `config` the device's schema declares secret (D783) —
|
|
15148
|
+
* what the non-admin redaction withholds. Paths, never values. Absent
|
|
15149
|
+
* when the owning runner has not reported them. */
|
|
15150
|
+
secretConfigPaths: array(string()).optional(),
|
|
15147
15151
|
/** Hardware + identity blob (manufacturer / model / firmware / sn /
|
|
15148
15152
|
* uid / mac / …). Populated by drivers; editable via setMetadata. */
|
|
15149
15153
|
metadata: record(string(), unknown()).nullable().optional(),
|
|
@@ -15349,7 +15353,13 @@ var DeviceRegisterPayloadSchema = object({
|
|
|
15349
15353
|
* call (workers re-register on init), so probe-driven updates
|
|
15350
15354
|
* (e.g. PTZ flag flipping after the abilities probe) propagate. */
|
|
15351
15355
|
features: array(string()).optional(),
|
|
15352
|
-
config: record(string(), unknown())
|
|
15356
|
+
config: record(string(), unknown()),
|
|
15357
|
+
/** Config paths the device's own schema DECLARES secret
|
|
15358
|
+
* (`.meta({ secret: true })` — dotted, `[]` for array items, e.g.
|
|
15359
|
+
* `connection.password`). Re-published on every register; the non-admin
|
|
15360
|
+
* redaction withholds them at any depth (D783). Optional: an older runner
|
|
15361
|
+
* omits it and the keyword backstop applies. */
|
|
15362
|
+
secretConfigPaths: array(string()).optional()
|
|
15353
15363
|
});
|
|
15354
15364
|
var DeviceRemovePayloadSchema = object({ deviceId: number() });
|
|
15355
15365
|
var DevicePersistConfigPayloadSchema = object({
|
|
@@ -17008,7 +17018,14 @@ var LoginMethodContributionSchema = discriminatedUnion("kind", [
|
|
|
17008
17018
|
icon: string().optional(),
|
|
17009
17019
|
/** Addon-owned HTTP route the button navigates to (GET). */
|
|
17010
17020
|
startUrl: string(),
|
|
17011
|
-
stage: LoginStageEnum
|
|
17021
|
+
stage: LoginStageEnum,
|
|
17022
|
+
/**
|
|
17023
|
+
* The provider names an external identity a signed-in user can link to
|
|
17024
|
+
* their own account (D782): `auth.listLinkableProviders` /
|
|
17025
|
+
* `beginExternalLink` offer exactly the redirect methods that set this,
|
|
17026
|
+
* whatever their id. Absent / false — a login button only (magic-link).
|
|
17027
|
+
*/
|
|
17028
|
+
linkable: boolean().optional()
|
|
17012
17029
|
}),
|
|
17013
17030
|
object({
|
|
17014
17031
|
kind: literal("widget"),
|
|
@@ -21808,7 +21825,7 @@ var TrainingExportSummarySchema = object({
|
|
|
21808
21825
|
truncated: boolean(),
|
|
21809
21826
|
devices: array(TrainingExportDeviceTotalsSchema).readonly()
|
|
21810
21827
|
});
|
|
21811
|
-
var TrackSchema = object({
|
|
21828
|
+
var TrackSchema$1 = object({
|
|
21812
21829
|
trackId: string(),
|
|
21813
21830
|
deviceId: number(),
|
|
21814
21831
|
className: string(),
|
|
@@ -22421,7 +22438,13 @@ var RetrainFrameSchema = object({
|
|
|
22421
22438
|
sizeBytes: number().int(),
|
|
22422
22439
|
width: number().int(),
|
|
22423
22440
|
height: number().int(),
|
|
22424
|
-
copiedAt: number()
|
|
22441
|
+
copiedAt: number(),
|
|
22442
|
+
/** When the CAMERA captured the frame. Absent on a copy taken before D785. */
|
|
22443
|
+
capturedAt: number().optional(),
|
|
22444
|
+
/** The operator's "every subject in this frame is boxed". Detector
|
|
22445
|
+
* evaluation keeps only complete frames: an unboxed subject would count as
|
|
22446
|
+
* a false positive. */
|
|
22447
|
+
complete: boolean()
|
|
22425
22448
|
});
|
|
22426
22449
|
/** Why a copy-on-select could not be honoured — named, never a silent skip. */
|
|
22427
22450
|
var RetrainCopyRefusalSchema = _enum([
|
|
@@ -22442,6 +22465,51 @@ var RetrainFrameListSchema = object({
|
|
|
22442
22465
|
/** What the page pre-selects — the native key frame when one survives. */
|
|
22443
22466
|
autoPickMediaId: number().int().optional()
|
|
22444
22467
|
});
|
|
22468
|
+
/**
|
|
22469
|
+
* Which frames an annotated export carries. Every field narrows; an empty
|
|
22470
|
+
* filter is every annotated copy. A dataset VERSION is a frozen `frameIds`
|
|
22471
|
+
* list owned by whoever trains (the training orchestrator), so `frameIds` is
|
|
22472
|
+
* the primitive a version export uses; `batchId` re-selects what an earlier
|
|
22473
|
+
* export carried (`exportedInBatch`).
|
|
22474
|
+
*/
|
|
22475
|
+
var RetrainExportFilterSchema = object({
|
|
22476
|
+
deviceIds: array(number()).optional(),
|
|
22477
|
+
frameIds: array(string()).max(2e4).optional(),
|
|
22478
|
+
batchId: string().optional(),
|
|
22479
|
+
/** Only frames the operator marked complete (all subjects boxed). */
|
|
22480
|
+
completeOnly: boolean().optional()
|
|
22481
|
+
});
|
|
22482
|
+
/**
|
|
22483
|
+
* `frames`: the full frame copies, boxes in `annotations.json`.
|
|
22484
|
+
* `crops`: additionally one JPEG per subject, cut with the runtime detail-crop
|
|
22485
|
+
* convention — the classifier layout.
|
|
22486
|
+
*/
|
|
22487
|
+
var RetrainExportLayoutSchema = _enum(["frames", "crops"]);
|
|
22488
|
+
/**
|
|
22489
|
+
* Where `writeRetrainArchive` streams the tar: one HTTP request whose body is
|
|
22490
|
+
* the archive, sent chunk by chunk. Covers a presigned PUT and a provider
|
|
22491
|
+
* addon's data-plane upload route alike. Headers are sent verbatim and never
|
|
22492
|
+
* logged.
|
|
22493
|
+
*/
|
|
22494
|
+
var RetrainArchiveTargetSchema = object({
|
|
22495
|
+
kind: literal("http"),
|
|
22496
|
+
method: _enum(["PUT", "POST"]),
|
|
22497
|
+
url: string().url(),
|
|
22498
|
+
headers: record(string(), string()).optional()
|
|
22499
|
+
});
|
|
22500
|
+
var RetrainArchiveResultSchema = object({
|
|
22501
|
+
batchId: string(),
|
|
22502
|
+
frames: number().int(),
|
|
22503
|
+
crops: number().int(),
|
|
22504
|
+
annotations: number().int(),
|
|
22505
|
+
/** Frames planned but not shipped (unreadable copy, path too long). */
|
|
22506
|
+
missing: number().int(),
|
|
22507
|
+
bytes: number().int(),
|
|
22508
|
+
/** sha256 of the exact bytes sent. */
|
|
22509
|
+
sha256: string(),
|
|
22510
|
+
/** The target's HTTP status. */
|
|
22511
|
+
status: number().int()
|
|
22512
|
+
});
|
|
22445
22513
|
/** What the operator asked the assist to look for. */
|
|
22446
22514
|
var RetrainAssistSubjectSchema = discriminatedUnion("kind", [object({
|
|
22447
22515
|
kind: literal("package"),
|
|
@@ -22535,7 +22603,7 @@ var RecentTracksQueryInput = object({
|
|
|
22535
22603
|
});
|
|
22536
22604
|
var RecentTracksPageSchema = object({
|
|
22537
22605
|
/** Merged page, ordered by (`lastSeen` DESC, `trackId` DESC). */
|
|
22538
|
-
tracks: array(TrackSchema).readonly(),
|
|
22606
|
+
tracks: array(TrackSchema$1).readonly(),
|
|
22539
22607
|
/** Cursor for the next page, or null when this page is the last. */
|
|
22540
22608
|
nextCursor: string().nullable()
|
|
22541
22609
|
});
|
|
@@ -23009,10 +23077,10 @@ var LtsBucketSchema = object({
|
|
|
23009
23077
|
min: number(),
|
|
23010
23078
|
max: number()
|
|
23011
23079
|
});
|
|
23012
|
-
DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
|
|
23080
|
+
DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema$1).readonly()), method(object({
|
|
23013
23081
|
deviceId: number(),
|
|
23014
23082
|
trackId: string()
|
|
23015
|
-
}), TrackSchema.nullable()), method(object({
|
|
23083
|
+
}), TrackSchema$1.nullable()), method(object({
|
|
23016
23084
|
deviceId: number(),
|
|
23017
23085
|
since: number().optional(),
|
|
23018
23086
|
until: number().optional(),
|
|
@@ -23045,7 +23113,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
23045
23113
|
classes: array(string()).optional(),
|
|
23046
23114
|
/** See {@link ExcludeSourcesDoc}. */
|
|
23047
23115
|
excludeSources: array(TrackSourceSchema).optional()
|
|
23048
|
-
}), array(TrackSchema).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(object({ deviceId: number() }), _void(), {
|
|
23116
|
+
}), array(TrackSchema$1).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(object({ deviceId: number() }), _void(), {
|
|
23049
23117
|
kind: "mutation",
|
|
23050
23118
|
auth: "admin"
|
|
23051
23119
|
}), 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({
|
|
@@ -23236,9 +23304,25 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
23236
23304
|
}), RetrainTransitionResultSchema, {
|
|
23237
23305
|
kind: "mutation",
|
|
23238
23306
|
auth: "admin"
|
|
23239
|
-
}), method(
|
|
23307
|
+
}), method(RetrainExportFilterSchema.extend({ layout: RetrainExportLayoutSchema.optional() }), object({ url: string() }), {
|
|
23240
23308
|
kind: "query",
|
|
23241
23309
|
auth: "admin"
|
|
23310
|
+
}), method(object({
|
|
23311
|
+
deviceId: number(),
|
|
23312
|
+
trackId: string(),
|
|
23313
|
+
frameId: string(),
|
|
23314
|
+
complete: boolean()
|
|
23315
|
+
}), RetrainFrameSchema, {
|
|
23316
|
+
kind: "mutation",
|
|
23317
|
+
auth: "admin"
|
|
23318
|
+
}), method(object({
|
|
23319
|
+
filter: RetrainExportFilterSchema,
|
|
23320
|
+
layout: RetrainExportLayoutSchema,
|
|
23321
|
+
target: RetrainArchiveTargetSchema
|
|
23322
|
+
}), RetrainArchiveResultSchema, {
|
|
23323
|
+
kind: "mutation",
|
|
23324
|
+
auth: "admin",
|
|
23325
|
+
timeoutMs: 60 * 6e4
|
|
23242
23326
|
}), method(object({
|
|
23243
23327
|
/** Which of the three event tables owns the row. */
|
|
23244
23328
|
ownerType: EventOwnerTypeSchema,
|
|
@@ -26354,7 +26438,7 @@ method(SendEmailInputSchema, SendEmailResultSchema, {
|
|
|
26354
26438
|
* the device-native provider (onboard capture) or from the stream-broker
|
|
26355
26439
|
* prebuffer fallback.
|
|
26356
26440
|
*/
|
|
26357
|
-
var SnapshotImageSchema$
|
|
26441
|
+
var SnapshotImageSchema$2 = object({
|
|
26358
26442
|
base64: string(),
|
|
26359
26443
|
contentType: string()
|
|
26360
26444
|
});
|
|
@@ -26386,7 +26470,7 @@ DeviceType.Camera, method(object({
|
|
|
26386
26470
|
* correct answer: stale but honest beats woken.
|
|
26387
26471
|
*/
|
|
26388
26472
|
force: boolean().optional()
|
|
26389
|
-
}), SnapshotImageSchema$
|
|
26473
|
+
}), SnapshotImageSchema$2.nullable()), method(object({ deviceId: number() }), _void(), {
|
|
26390
26474
|
kind: "mutation",
|
|
26391
26475
|
auth: "admin"
|
|
26392
26476
|
}), systemMethod(object({ deviceIds: array(number()).min(1).max(200) }), array(object({
|
|
@@ -26528,7 +26612,19 @@ var SsoBridgeClaimsSchema = object({
|
|
|
26528
26612
|
* editing a descriptor must not retroactively extend or shorten a link the
|
|
26529
26613
|
* operator already consented to.
|
|
26530
26614
|
*/
|
|
26531
|
-
refreshTtl: union([number().int().positive(), literal("never")]).optional()
|
|
26615
|
+
refreshTtl: union([number().int().positive(), literal("never")]).optional(),
|
|
26616
|
+
/**
|
|
26617
|
+
* Opaque handle of a hub-side LINK intent (D782). Set by an OIDC callback
|
|
26618
|
+
* that was started from a signed-in user's "Link" button: the finish then
|
|
26619
|
+
* links this identity to the intent's user instead of signing anyone in.
|
|
26620
|
+
* The handle carries no user id — the hub resolves it, single-use.
|
|
26621
|
+
*/
|
|
26622
|
+
linkIntent: string().optional(),
|
|
26623
|
+
/**
|
|
26624
|
+
* Verified `iss` of the id_token behind an OIDC login bridge (D782). A
|
|
26625
|
+
* subject is unique only per issuer, so a link is bound to it.
|
|
26626
|
+
*/
|
|
26627
|
+
issuer: string().optional()
|
|
26532
26628
|
});
|
|
26533
26629
|
method(object({
|
|
26534
26630
|
claims: SsoBridgeClaimsSchema,
|
|
@@ -36548,6 +36644,32 @@ var TotpStatusSchema = object({
|
|
|
36548
36644
|
/** Null when no row exists OR the row is still pending confirmation. */
|
|
36549
36645
|
confirmedAt: number().nullable()
|
|
36550
36646
|
});
|
|
36647
|
+
/**
|
|
36648
|
+
* user-management — singleton capability for user accounts, API keys,
|
|
36649
|
+
* and scoped tokens.
|
|
36650
|
+
*
|
|
36651
|
+
* v2 model: every method gated by `auth: 'admin'`. Admin-only delegation
|
|
36652
|
+
* — the subset check on `setUserScopes` / `createScopedToken` is moot
|
|
36653
|
+
* because non-admins never reach these. If/when we open non-admin
|
|
36654
|
+
* delegation, structural caller-scope subset check goes inside the
|
|
36655
|
+
* provider.
|
|
36656
|
+
*/
|
|
36657
|
+
var ExternalIdentitySchema = object({
|
|
36658
|
+
/** Login-method id of the provider instance, e.g. `auth-oidc/authentik`. */
|
|
36659
|
+
provider: string(),
|
|
36660
|
+
/** The IdP's stable subject (`sub`) for this account. */
|
|
36661
|
+
subject: string(),
|
|
36662
|
+
/** The verified `iss` of the IdP the identity was linked under — `sub` is unique only per issuer. */
|
|
36663
|
+
issuer: string(),
|
|
36664
|
+
userId: string(),
|
|
36665
|
+
/** What the IdP called the account at link time (username / email) — display only. */
|
|
36666
|
+
label: string().optional(),
|
|
36667
|
+
linkedAt: number()
|
|
36668
|
+
});
|
|
36669
|
+
var ExternalIdentityKeySchema = object({
|
|
36670
|
+
provider: string().min(1),
|
|
36671
|
+
subject: string().min(1)
|
|
36672
|
+
});
|
|
36551
36673
|
method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserInputSchema, UserSummarySchema, {
|
|
36552
36674
|
kind: "mutation",
|
|
36553
36675
|
auth: "admin",
|
|
@@ -36661,7 +36783,22 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
|
|
|
36661
36783
|
userId: string(),
|
|
36662
36784
|
username: string(),
|
|
36663
36785
|
scopes: array(TokenScopeSchema)
|
|
36664
|
-
}).nullable(), { access: "view" }), method(
|
|
36786
|
+
}).nullable(), { access: "view" }), method(object({ userId: string() }), array(ExternalIdentitySchema), { auth: "admin" }), method(ExternalIdentityKeySchema, object({
|
|
36787
|
+
userId: string(),
|
|
36788
|
+
issuer: string()
|
|
36789
|
+
}).nullable(), { auth: "admin" }), method(ExternalIdentityKeySchema.extend({
|
|
36790
|
+
userId: string(),
|
|
36791
|
+
issuer: string().min(1),
|
|
36792
|
+
label: string().optional()
|
|
36793
|
+
}), object({ success: literal(true) }), {
|
|
36794
|
+
kind: "mutation",
|
|
36795
|
+
auth: "admin",
|
|
36796
|
+
access: "create"
|
|
36797
|
+
}), method(ExternalIdentityKeySchema.extend({ userId: string() }), object({ success: boolean() }), {
|
|
36798
|
+
kind: "mutation",
|
|
36799
|
+
auth: "admin",
|
|
36800
|
+
access: "delete"
|
|
36801
|
+
}), method(_void(), array(OauthSessionSummarySchema), { auth: "admin" }), method(object({ id: string() }), object({ success: boolean() }), {
|
|
36665
36802
|
kind: "mutation",
|
|
36666
36803
|
auth: "admin",
|
|
36667
36804
|
access: "delete"
|
|
@@ -41467,6 +41604,12 @@ Object.freeze({
|
|
|
41467
41604
|
addonId: null,
|
|
41468
41605
|
access: "create"
|
|
41469
41606
|
},
|
|
41607
|
+
"pipelineAnalytics.setRetrainFrameComplete": {
|
|
41608
|
+
capName: "pipeline-analytics",
|
|
41609
|
+
capScope: "device",
|
|
41610
|
+
addonId: null,
|
|
41611
|
+
access: "create"
|
|
41612
|
+
},
|
|
41470
41613
|
"pipelineAnalytics.setTrackFlags": {
|
|
41471
41614
|
capName: "pipeline-analytics",
|
|
41472
41615
|
capScope: "device",
|
|
@@ -41503,6 +41646,12 @@ Object.freeze({
|
|
|
41503
41646
|
addonId: null,
|
|
41504
41647
|
access: "delete"
|
|
41505
41648
|
},
|
|
41649
|
+
"pipelineAnalytics.writeRetrainArchive": {
|
|
41650
|
+
capName: "pipeline-analytics",
|
|
41651
|
+
capScope: "device",
|
|
41652
|
+
addonId: null,
|
|
41653
|
+
access: "create"
|
|
41654
|
+
},
|
|
41506
41655
|
"pipelineExecutor.cacheFrameInPool": {
|
|
41507
41656
|
capName: "pipeline-executor",
|
|
41508
41657
|
capScope: "system",
|
|
@@ -43855,18 +44004,36 @@ Object.freeze({
|
|
|
43855
44004
|
addonId: null,
|
|
43856
44005
|
access: "delete"
|
|
43857
44006
|
},
|
|
44007
|
+
"userManagement.findUserByExternalIdentity": {
|
|
44008
|
+
capName: "user-management",
|
|
44009
|
+
capScope: "system",
|
|
44010
|
+
addonId: null,
|
|
44011
|
+
access: "view"
|
|
44012
|
+
},
|
|
43858
44013
|
"userManagement.getTotpStatus": {
|
|
43859
44014
|
capName: "user-management",
|
|
43860
44015
|
capScope: "system",
|
|
43861
44016
|
addonId: null,
|
|
43862
44017
|
access: "view"
|
|
43863
44018
|
},
|
|
44019
|
+
"userManagement.linkExternalIdentity": {
|
|
44020
|
+
capName: "user-management",
|
|
44021
|
+
capScope: "system",
|
|
44022
|
+
addonId: null,
|
|
44023
|
+
access: "create"
|
|
44024
|
+
},
|
|
43864
44025
|
"userManagement.listApiKeys": {
|
|
43865
44026
|
capName: "user-management",
|
|
43866
44027
|
capScope: "system",
|
|
43867
44028
|
addonId: null,
|
|
43868
44029
|
access: "view"
|
|
43869
44030
|
},
|
|
44031
|
+
"userManagement.listExternalIdentities": {
|
|
44032
|
+
capName: "user-management",
|
|
44033
|
+
capScope: "system",
|
|
44034
|
+
addonId: null,
|
|
44035
|
+
access: "view"
|
|
44036
|
+
},
|
|
43870
44037
|
"userManagement.listOauthSessions": {
|
|
43871
44038
|
capName: "user-management",
|
|
43872
44039
|
capScope: "system",
|
|
@@ -43945,6 +44112,12 @@ Object.freeze({
|
|
|
43945
44112
|
addonId: null,
|
|
43946
44113
|
access: "create"
|
|
43947
44114
|
},
|
|
44115
|
+
"userManagement.unlinkExternalIdentity": {
|
|
44116
|
+
capName: "user-management",
|
|
44117
|
+
capScope: "system",
|
|
44118
|
+
addonId: null,
|
|
44119
|
+
access: "delete"
|
|
44120
|
+
},
|
|
43948
44121
|
"userManagement.updateUser": {
|
|
43949
44122
|
capName: "user-management",
|
|
43950
44123
|
capScope: "system",
|
|
@@ -47284,6 +47457,11 @@ Object.freeze({
|
|
|
47284
47457
|
form: "single",
|
|
47285
47458
|
optional: false
|
|
47286
47459
|
}],
|
|
47460
|
+
"pipelineAnalytics.setRetrainFrameComplete": [{
|
|
47461
|
+
name: "deviceId",
|
|
47462
|
+
form: "single",
|
|
47463
|
+
optional: false
|
|
47464
|
+
}],
|
|
47287
47465
|
"pipelineAnalytics.setTrackFlags": [{
|
|
47288
47466
|
name: "deviceId",
|
|
47289
47467
|
form: "single",
|
|
@@ -49409,38 +49587,63 @@ var MB = 1024 * 1024;
|
|
|
49409
49587
|
/**
|
|
49410
49588
|
* Alexa Smart Home export — customActions catalog.
|
|
49411
49589
|
*
|
|
49412
|
-
*
|
|
49413
|
-
* `api.addons.custom.{query,mutate}({ addonId, action, input })`:
|
|
49590
|
+
* The settings page drives these through
|
|
49591
|
+
* `api.addons.custom.{query,mutate}({ addonId, action, input })`: every action
|
|
49592
|
+
* button there is an `addon-action-button`, which shows the result (or the
|
|
49593
|
+
* error) under the button, and the external-address picker is an
|
|
49594
|
+
* `addon-action-select` over `listExternalAddresses`.
|
|
49414
49595
|
*
|
|
49415
|
-
*
|
|
49416
|
-
*
|
|
49417
|
-
*
|
|
49418
|
-
*
|
|
49419
|
-
* the form-builder's button-field dispatch routes
|
|
49420
|
-
* here through the generic customAction channel.
|
|
49421
|
-
* Same detection logic as the existing
|
|
49422
|
-
* `/addon/export-alexa/admin/redetect-hub-url` route.
|
|
49596
|
+
* Every action is `auth: 'admin'` (S2). The framework default is `protected` —
|
|
49597
|
+
* any authenticated user — and at that level a viewer could rotate the token
|
|
49598
|
+
* and read the new one in the response, unlink the Amazon account, or delete
|
|
49599
|
+
* endpoints from it.
|
|
49423
49600
|
*/
|
|
49601
|
+
/** tRPC/superjson elides empty-object inputs on the wire — accept both `{}`
|
|
49602
|
+
* and `undefined` so the form-builder doesn't have to know. */
|
|
49603
|
+
var NoInput = object({}).optional();
|
|
49424
49604
|
var exportAlexaActions = defineCustomActions({
|
|
49425
|
-
|
|
49426
|
-
|
|
49427
|
-
|
|
49428
|
-
|
|
49429
|
-
|
|
49605
|
+
/**
|
|
49606
|
+
* The external addresses the connected `network-access` providers report
|
|
49607
|
+
* now, read live for the "External address" picker, plus the saved choice
|
|
49608
|
+
* when it is not among them (so it is never silently dropped from view).
|
|
49609
|
+
*/
|
|
49610
|
+
listExternalAddresses: customAction(NoInput, object({ addresses: array(object({
|
|
49611
|
+
value: string(),
|
|
49612
|
+
label: string()
|
|
49613
|
+
})).readonly() }), { auth: "admin" }),
|
|
49614
|
+
regenerateIngressToken: customAction(NoInput, object({ ingressToken: string() }), {
|
|
49430
49615
|
kind: "mutation",
|
|
49431
49616
|
auth: "admin"
|
|
49432
49617
|
}),
|
|
49433
|
-
|
|
49618
|
+
/**
|
|
49619
|
+
* Re-sync: re-send every exposed device to every linked account, and delete
|
|
49620
|
+
* from Alexa the devices still tracked but no longer exposed (an unexpose
|
|
49621
|
+
* whose DeleteReport failed). Throws when there is no linked account with a
|
|
49622
|
+
* push grant, so the button says why nothing happened.
|
|
49623
|
+
*/
|
|
49624
|
+
resyncDiscovery: customAction(NoInput, object({
|
|
49625
|
+
deviceCount: number(),
|
|
49434
49626
|
endpointCount: number(),
|
|
49435
|
-
accountCount: number()
|
|
49627
|
+
accountCount: number(),
|
|
49628
|
+
removedEndpointCount: number(),
|
|
49629
|
+
retainedEndpointCount: number(),
|
|
49630
|
+
failedCount: number()
|
|
49436
49631
|
}), {
|
|
49437
49632
|
kind: "mutation",
|
|
49438
49633
|
auth: "admin"
|
|
49439
49634
|
}),
|
|
49440
|
-
|
|
49635
|
+
/**
|
|
49636
|
+
* Send `Alexa.Discovery/DeleteReport` for endpoint ids the operator types in
|
|
49637
|
+
* the button's prompt — for a device Alexa still shows that this hub no
|
|
49638
|
+
* longer tracks. The ids are an argument, never a saved setting. Throws
|
|
49639
|
+
* unless every linked account accepted the delete; only then does the local
|
|
49640
|
+
* tracking forget those ids.
|
|
49641
|
+
*/
|
|
49642
|
+
removeAlexaEndpoints: customAction(object({ endpointIds: string() }), object({
|
|
49441
49643
|
requested: number(),
|
|
49442
49644
|
accountCount: number(),
|
|
49443
|
-
|
|
49645
|
+
deliveredAccounts: number(),
|
|
49646
|
+
failedAccounts: number()
|
|
49444
49647
|
}), {
|
|
49445
49648
|
kind: "mutation",
|
|
49446
49649
|
auth: "admin"
|
|
@@ -49458,7 +49661,7 @@ var exportAlexaActions = defineCustomActions({
|
|
|
49458
49661
|
* cleared. Revoking alone leaves the gateway pushing motion and doorbell
|
|
49459
49662
|
* events at the old Amazon account until Amazon happens to answer 403.
|
|
49460
49663
|
*/
|
|
49461
|
-
unlinkAlexaAccounts: customAction(
|
|
49664
|
+
unlinkAlexaAccounts: customAction(NoInput, object({
|
|
49462
49665
|
revokedSessions: number(),
|
|
49463
49666
|
removedAccounts: number()
|
|
49464
49667
|
}), {
|
|
@@ -49466,20 +49669,21 @@ var exportAlexaActions = defineCustomActions({
|
|
|
49466
49669
|
auth: "admin"
|
|
49467
49670
|
}),
|
|
49468
49671
|
/**
|
|
49469
|
-
* Swap the per-link credential this hub presents to the skill Lambda's
|
|
49470
|
-
* token broker (D753) for a fresh one; the old one dies at once. Rotation
|
|
49471
|
-
* also happens implicitly on every re-link.
|
|
49472
|
-
*/
|
|
49473
|
-
/**
|
|
49474
49672
|
* The ingress token, in clear, for an administrator who asked for it. The
|
|
49475
49673
|
* one read surface that returns it: `deviceExport.getStatus` and the
|
|
49476
49674
|
* settings form carry only a mask.
|
|
49477
49675
|
*/
|
|
49478
|
-
revealIngressToken: customAction(
|
|
49676
|
+
revealIngressToken: customAction(NoInput, object({ ingressToken: string() }), {
|
|
49479
49677
|
kind: "mutation",
|
|
49480
49678
|
auth: "admin"
|
|
49481
49679
|
}),
|
|
49482
|
-
|
|
49680
|
+
/**
|
|
49681
|
+
* Swap the per-link credential this hub presents to the skill Lambda's
|
|
49682
|
+
* token broker (D753) for a fresh one; the old one dies at once. Rotation
|
|
49683
|
+
* also happens implicitly on every re-link. Throws when no link is held at
|
|
49684
|
+
* a broker.
|
|
49685
|
+
*/
|
|
49686
|
+
rotateBrokerCredential: customAction(NoInput, object({
|
|
49483
49687
|
rotated: number(),
|
|
49484
49688
|
failed: number()
|
|
49485
49689
|
}), {
|
|
@@ -49764,7 +49968,10 @@ var DirectiveHeaderSchema = object({
|
|
|
49764
49968
|
name: string(),
|
|
49765
49969
|
payloadVersion: string(),
|
|
49766
49970
|
messageId: string(),
|
|
49767
|
-
correlationToken: string().optional()
|
|
49971
|
+
correlationToken: string().optional(),
|
|
49972
|
+
/** Instanced interfaces' directives carry it (`Alexa.DataController`, D779);
|
|
49973
|
+
* the response echoes it. */
|
|
49974
|
+
instance: string().optional()
|
|
49768
49975
|
});
|
|
49769
49976
|
/** Scope carries the bearer token Alexa minted via our OAuth provider. */
|
|
49770
49977
|
var BearerScopeSchema = object({
|
|
@@ -50505,6 +50712,60 @@ var MOTION_SENSOR = {
|
|
|
50505
50712
|
retrievable: true
|
|
50506
50713
|
}
|
|
50507
50714
|
};
|
|
50715
|
+
/** The only classes `Alexa.SmartVision.ObjectDetectionSensor` knows (Amazon:
|
|
50716
|
+
* `imageNetClass` is `person` or `package`; anything else is dropped). */
|
|
50717
|
+
var SMART_VISION_CLASSES$1 = ["person", "package"];
|
|
50718
|
+
/** `Alexa.DataController` instance that serves the camera's detection history. */
|
|
50719
|
+
var SMART_VISION_DATA_INSTANCE = "Camera.SmartVisionData";
|
|
50720
|
+
/**
|
|
50721
|
+
* `Alexa.SmartVision.ObjectDetectionSensor` v1.0 on the CAMERA endpoint (R4,
|
|
50722
|
+
* D779) — the per-camera event history and the "person detected"
|
|
50723
|
+
* announcements in the Alexa app. Amazon lists it for en-US only; it is built
|
|
50724
|
+
* here as an experiment to find out what an it-IT account does with it.
|
|
50725
|
+
*
|
|
50726
|
+
* The shape is Amazon's discovery example to the letter, minus the empty
|
|
50727
|
+
* `properties: {}` (the interface defines no reportable property; omission is
|
|
50728
|
+
* how this file expresses that — see `DOORBELL_EVENT_SOURCE`). No
|
|
50729
|
+
* `isAvailable` / `unavailabilityReason`: both are optional and only describe a
|
|
50730
|
+
* subscription-gated class. An earlier declaration that ALSO claimed a
|
|
50731
|
+
* retrievable `objectDetectionClasses` property (never answered by ReportState)
|
|
50732
|
+
* made Alexa reject the camera and every child — so: no properties at all.
|
|
50733
|
+
*
|
|
50734
|
+
* `classes` is `person`, plus `package` only on a camera whose pipeline can
|
|
50735
|
+
* produce package events (the same answer the detection sensors use).
|
|
50736
|
+
*/
|
|
50737
|
+
function smartVisionObjectDetectionSensor(classes) {
|
|
50738
|
+
return {
|
|
50739
|
+
type: "AlexaInterface",
|
|
50740
|
+
interface: "Alexa.SmartVision.ObjectDetectionSensor",
|
|
50741
|
+
version: "1.0",
|
|
50742
|
+
configuration: { objectDetectionConfiguration: classes.map((imageNetClass) => ({ imageNetClass })) }
|
|
50743
|
+
};
|
|
50744
|
+
}
|
|
50745
|
+
/**
|
|
50746
|
+
* `Alexa.DataController` v1.0, instance {@link SMART_VISION_DATA_INSTANCE} —
|
|
50747
|
+
* how the Alexa app reads the camera's detection history back
|
|
50748
|
+
* (`ReportData` / `DeleteData`, `handlers/data-controller.ts`). Amazon's
|
|
50749
|
+
* ObjectDetectionSensor example declares it beside the sensor; the instance
|
|
50750
|
+
* name is the one that example uses.
|
|
50751
|
+
*/
|
|
50752
|
+
var SMART_VISION_DATA_CONTROLLER = {
|
|
50753
|
+
type: "AlexaInterface",
|
|
50754
|
+
interface: "Alexa.DataController",
|
|
50755
|
+
instance: SMART_VISION_DATA_INSTANCE,
|
|
50756
|
+
version: "1.0",
|
|
50757
|
+
configuration: {
|
|
50758
|
+
targetCapability: {
|
|
50759
|
+
name: "Alexa.SmartVision.ObjectDetectionSensor",
|
|
50760
|
+
version: "1.0"
|
|
50761
|
+
},
|
|
50762
|
+
dataRetrievalSchema: {
|
|
50763
|
+
type: "JSON",
|
|
50764
|
+
schema: "SmartVisionData"
|
|
50765
|
+
},
|
|
50766
|
+
supportedAccess: ["BY_IDENTIFIER", "BY_TIMESTAMP_RANGE"]
|
|
50767
|
+
}
|
|
50768
|
+
};
|
|
50508
50769
|
/**
|
|
50509
50770
|
* `Alexa.SmartVision.SnapshotProvider` v1.1 — the camera's still in the Alexa
|
|
50510
50771
|
* app (R2, D773). Available in it-IT and en-GB, unlike object detection.
|
|
@@ -50728,6 +50989,11 @@ function buildParentCameraEndpoint(input) {
|
|
|
50728
50989
|
];
|
|
50729
50990
|
if (input.isDoorbell) capabilities.push(DOORBELL_EVENT_SOURCE);
|
|
50730
50991
|
if (input.hasMotion) capabilities.push(MOTION_SENSOR);
|
|
50992
|
+
const visionClasses = input.objectDetectionClasses ?? [];
|
|
50993
|
+
if (visionClasses.length > 0) {
|
|
50994
|
+
capabilities.push(smartVisionObjectDetectionSensor(visionClasses));
|
|
50995
|
+
capabilities.push(SMART_VISION_DATA_CONTROLLER);
|
|
50996
|
+
}
|
|
50731
50997
|
if (input.snapshotProvider === true) capabilities.push(SMART_VISION_SNAPSHOT_PROVIDER);
|
|
50732
50998
|
const presetNames = distinctPresetNames(input.ptzPresetNames);
|
|
50733
50999
|
if (presetNames.length >= MIN_MODE_CONTROLLER_MODES) capabilities.push(ptzPresetModeController(presetNames));
|
|
@@ -51025,6 +51291,7 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
|
|
|
51025
51291
|
});
|
|
51026
51292
|
return [];
|
|
51027
51293
|
});
|
|
51294
|
+
const objectDetectionClasses = deps.objectDetection === void 0 ? [] : await deps.objectDetection(parentInfo.id);
|
|
51028
51295
|
const out = [];
|
|
51029
51296
|
out.push(buildParentCameraEndpoint({
|
|
51030
51297
|
deviceId: parentInfo.id,
|
|
@@ -51035,7 +51302,8 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
|
|
|
51035
51302
|
hasMotion,
|
|
51036
51303
|
ptzPresetNames,
|
|
51037
51304
|
hasPtz,
|
|
51038
|
-
snapshotProvider: deps.snapshots === true
|
|
51305
|
+
snapshotProvider: deps.snapshots === true,
|
|
51306
|
+
objectDetectionClasses
|
|
51039
51307
|
}));
|
|
51040
51308
|
if (deps.detectionClasses !== void 0) {
|
|
51041
51309
|
const classes = await deps.detectionClasses(parentInfo.id);
|
|
@@ -51357,8 +51625,13 @@ var ALLOWED_INTERFACE_VERSIONS = {
|
|
|
51357
51625
|
"Alexa.SecurityPanelController": "3",
|
|
51358
51626
|
"Alexa.LockController": "3",
|
|
51359
51627
|
"Alexa.SmartVision.SnapshotProvider": "1.1",
|
|
51360
|
-
"Alexa.SmartVision.ObjectDetectionSensor": "1.0"
|
|
51628
|
+
"Alexa.SmartVision.ObjectDetectionSensor": "1.0",
|
|
51629
|
+
"Alexa.DataController": "1.0"
|
|
51361
51630
|
};
|
|
51631
|
+
/** `imageNetClass` values Amazon accepts (alexa-smartvision-objectdetectionsensor). */
|
|
51632
|
+
var SMART_VISION_CLASSES = new Set(["person", "package"]);
|
|
51633
|
+
/** `supportedAccess` values Amazon documents (alexa-datacontroller). */
|
|
51634
|
+
var DATA_CONTROLLER_ACCESS = new Set(["BY_IDENTIFIER", "BY_TIMESTAMP_RANGE"]);
|
|
51362
51635
|
/** Interfaces that need an `instance`. */
|
|
51363
51636
|
var INSTANCED = new Set([
|
|
51364
51637
|
"Alexa.ModeController",
|
|
@@ -51405,7 +51678,37 @@ function capabilityKey$1(c) {
|
|
|
51405
51678
|
function friendlyTexts(c) {
|
|
51406
51679
|
return (c.capabilityResources?.friendlyNames ?? []).map((n) => n.value.text);
|
|
51407
51680
|
}
|
|
51408
|
-
function
|
|
51681
|
+
function field(c, key) {
|
|
51682
|
+
return c.configuration?.[key];
|
|
51683
|
+
}
|
|
51684
|
+
function objectField(value, key) {
|
|
51685
|
+
return typeof value === "object" && value !== null ? Reflect.get(value, key) : void 0;
|
|
51686
|
+
}
|
|
51687
|
+
/** ObjectDetectionSensor (D779): a non-empty, duplicate-free list of the two
|
|
51688
|
+
* classes Amazon knows, and no reportable properties (it defines none). */
|
|
51689
|
+
function validateObjectDetection(c, push) {
|
|
51690
|
+
const list = field(c, "objectDetectionConfiguration");
|
|
51691
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
51692
|
+
push("configuration.objectDetectionConfiguration must list at least one class");
|
|
51693
|
+
return;
|
|
51694
|
+
}
|
|
51695
|
+
const classes = list.map((entry) => objectField(entry, "imageNetClass"));
|
|
51696
|
+
for (const cls of classes) if (typeof cls !== "string" || !SMART_VISION_CLASSES.has(cls)) push(`imageNetClass ${String(cls)} is not person or package`);
|
|
51697
|
+
if (new Set(classes).size !== classes.length) push("imageNetClass listed twice");
|
|
51698
|
+
if (c.properties !== void 0) push("declares properties; the interface defines none");
|
|
51699
|
+
}
|
|
51700
|
+
/** DataController (D779): an instance, a schema, a documented access mode, and
|
|
51701
|
+
* a target capability the SAME endpoint declares. */
|
|
51702
|
+
function validateDataController(c, declared, push) {
|
|
51703
|
+
if (c.instance === void 0 || c.instance.length === 0) push("instance is required");
|
|
51704
|
+
const schema = field(c, "dataRetrievalSchema");
|
|
51705
|
+
if (typeof objectField(schema, "type") !== "string" || typeof objectField(schema, "schema") !== "string") push("configuration.dataRetrievalSchema {type, schema} is required");
|
|
51706
|
+
const target = objectField(field(c, "targetCapability"), "name");
|
|
51707
|
+
if (typeof target !== "string" || !declared.has(target)) push("configuration.targetCapability must name an interface this endpoint declares");
|
|
51708
|
+
const access = field(c, "supportedAccess");
|
|
51709
|
+
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");
|
|
51710
|
+
}
|
|
51711
|
+
function validateCapability(endpointId, c, out, declared) {
|
|
51409
51712
|
const push = (rule) => {
|
|
51410
51713
|
out.push({
|
|
51411
51714
|
endpointId,
|
|
@@ -51434,6 +51737,8 @@ function validateCapability(endpointId, c, out) {
|
|
|
51434
51737
|
if (new Set(values).size !== values.length) push("supportedModes values must be unique");
|
|
51435
51738
|
}
|
|
51436
51739
|
}
|
|
51740
|
+
if (c.interface === "Alexa.SmartVision.ObjectDetectionSensor") validateObjectDetection(c, push);
|
|
51741
|
+
if (c.interface === "Alexa.DataController") validateDataController(c, declared, push);
|
|
51437
51742
|
if (c.interface === "Alexa.SmartVision.SnapshotProvider") {
|
|
51438
51743
|
if (typeof c.configuration?.["isAvailable"] !== "boolean") push("configuration.isAvailable is required");
|
|
51439
51744
|
}
|
|
@@ -51466,7 +51771,7 @@ function validateAlexaEndpoint(endpoint) {
|
|
|
51466
51771
|
const interfaces = new Set(caps.map((c) => c.interface));
|
|
51467
51772
|
for (const needs of NEEDS_ENDPOINT_HEALTH) if (interfaces.has(needs) && !interfaces.has("Alexa.EndpointHealth")) push(`${needs} requires Alexa.EndpointHealth`);
|
|
51468
51773
|
if (interfaces.has("Alexa.RTCSessionController") && endpoint.displayCategories[0] !== "CAMERA") push("an RTCSessionController endpoint must list CAMERA first");
|
|
51469
|
-
for (const c of caps) validateCapability(id, c, out);
|
|
51774
|
+
for (const c of caps) validateCapability(id, c, out, interfaces);
|
|
51470
51775
|
return out;
|
|
51471
51776
|
}
|
|
51472
51777
|
/** Split endpoints into the valid ones and the violations of the rest. */
|
|
@@ -51764,6 +52069,543 @@ async function handleInitializeCameraStreams(header, endpointRaw, payloadRaw, de
|
|
|
51764
52069
|
} };
|
|
51765
52070
|
}
|
|
51766
52071
|
//#endregion
|
|
52072
|
+
//#region src/detection-sensors/detection-tracker.ts
|
|
52073
|
+
/**
|
|
52074
|
+
* Which detection classes are currently present on each camera (R1, D772).
|
|
52075
|
+
*
|
|
52076
|
+
* Fed by the pipeline's track lifecycle (`PipelineAnalyticsTrackLifecycle`,
|
|
52077
|
+
* `data.deviceId` — these events carry no device on their `source`) and by
|
|
52078
|
+
* package deliveries. A class is present while at least one of ITS tracks is
|
|
52079
|
+
* open: the set is per `(camera, class)` and per `trackId`, so the end of one
|
|
52080
|
+
* person's track does not clear "Person" while another person is still in
|
|
52081
|
+
* view (the HA export's per-event flip has exactly that bug).
|
|
52082
|
+
*
|
|
52083
|
+
* Events are telemetry and may be dropped (D8), and the pipeline does not
|
|
52084
|
+
* promise an `end` for every `start` (a zero-media false birth or a discarded
|
|
52085
|
+
* stale track ends silently). So {@link reconcile} replaces a camera's sets
|
|
52086
|
+
* with an authoritative read of its ACTIVE tracks; a sensor can therefore never
|
|
52087
|
+
* stay "detected" past one reconcile after its last track closed. A read that
|
|
52088
|
+
* fails is not passed in — the sets then stay as they were.
|
|
52089
|
+
*
|
|
52090
|
+
* Emits a transition per `(camera, class)` only when presence changes; the
|
|
52091
|
+
* Alexa-facing hold (≥ 30 s DETECTED before NOT_DETECTED) is the gateway's.
|
|
52092
|
+
*/
|
|
52093
|
+
/** A package delivery is an instant, not a track: it is present this long. */
|
|
52094
|
+
var PACKAGE_PULSE_MS = 1e3;
|
|
52095
|
+
/** The detection class a pipeline class name belongs to, or null. Pipeline
|
|
52096
|
+
* classes are already macros; a raw COCO label is mapped the same way the
|
|
52097
|
+
* executor maps it. */
|
|
52098
|
+
function detectionClassOf(className) {
|
|
52099
|
+
if (className === void 0) return null;
|
|
52100
|
+
const lower = className.toLowerCase();
|
|
52101
|
+
const isClass = (v) => DETECTION_SENSOR_CLASSES.includes(v);
|
|
52102
|
+
if (isClass(lower)) return lower;
|
|
52103
|
+
const mapped = COCO_TO_MACRO.mapping[lower];
|
|
52104
|
+
return mapped !== void 0 && isClass(mapped) ? mapped : null;
|
|
52105
|
+
}
|
|
52106
|
+
var DetectionTracker = class {
|
|
52107
|
+
onTransition;
|
|
52108
|
+
/** `${deviceId}|${class}` → open track ids. */
|
|
52109
|
+
open = /* @__PURE__ */ new Map();
|
|
52110
|
+
pulseTimers = /* @__PURE__ */ new Set();
|
|
52111
|
+
constructor(onTransition) {
|
|
52112
|
+
this.onTransition = onTransition;
|
|
52113
|
+
}
|
|
52114
|
+
onTrackLifecycle(event) {
|
|
52115
|
+
const cls = detectionClassOf(event.bestClassName ?? event.classes?.[0]);
|
|
52116
|
+
if (cls === null) return;
|
|
52117
|
+
if (event.phase === "end") {
|
|
52118
|
+
this.remove(event.deviceId, cls, event.trackId);
|
|
52119
|
+
for (const other of DETECTION_SENSOR_CLASSES) if (other !== cls) this.remove(event.deviceId, other, event.trackId);
|
|
52120
|
+
return;
|
|
52121
|
+
}
|
|
52122
|
+
this.add(event.deviceId, cls, event.trackId);
|
|
52123
|
+
}
|
|
52124
|
+
/** A package was delivered: "Package" is detected, as a pulse. */
|
|
52125
|
+
onPackageDelivered(deviceId, entryId) {
|
|
52126
|
+
const id = `package:${entryId}`;
|
|
52127
|
+
this.add(deviceId, "package", id);
|
|
52128
|
+
const timer = setTimeout(() => {
|
|
52129
|
+
this.pulseTimers.delete(timer);
|
|
52130
|
+
this.remove(deviceId, "package", id);
|
|
52131
|
+
}, PACKAGE_PULSE_MS);
|
|
52132
|
+
timer.unref?.();
|
|
52133
|
+
this.pulseTimers.add(timer);
|
|
52134
|
+
}
|
|
52135
|
+
/** Replace a camera's open tracks with what the pipeline says is ACTIVE. */
|
|
52136
|
+
reconcile(deviceId, active) {
|
|
52137
|
+
const truth = /* @__PURE__ */ new Map();
|
|
52138
|
+
for (const track of active) {
|
|
52139
|
+
const cls = detectionClassOf(track.className);
|
|
52140
|
+
if (cls === null) continue;
|
|
52141
|
+
const set = truth.get(cls) ?? /* @__PURE__ */ new Set();
|
|
52142
|
+
set.add(track.trackId);
|
|
52143
|
+
truth.set(cls, set);
|
|
52144
|
+
}
|
|
52145
|
+
for (const cls of DETECTION_SENSOR_CLASSES) {
|
|
52146
|
+
const key = `${deviceId}|${cls}`;
|
|
52147
|
+
const before = this.open.get(key) ?? /* @__PURE__ */ new Set();
|
|
52148
|
+
const after = truth.get(cls) ?? /* @__PURE__ */ new Set();
|
|
52149
|
+
for (const id of before) if (id.startsWith("package:")) after.add(id);
|
|
52150
|
+
this.replace(deviceId, cls, before, after);
|
|
52151
|
+
}
|
|
52152
|
+
}
|
|
52153
|
+
/** Cameras with at least one class present — the ones worth reconciling. */
|
|
52154
|
+
camerasWithPresence() {
|
|
52155
|
+
const out = /* @__PURE__ */ new Set();
|
|
52156
|
+
for (const [key, set] of this.open) if (set.size > 0) out.add(Number(key.split("|")[0]));
|
|
52157
|
+
return [...out];
|
|
52158
|
+
}
|
|
52159
|
+
isPresent(deviceId, cls) {
|
|
52160
|
+
return (this.open.get(`${deviceId}|${cls}`)?.size ?? 0) > 0;
|
|
52161
|
+
}
|
|
52162
|
+
clear() {
|
|
52163
|
+
this.open.clear();
|
|
52164
|
+
for (const t of this.pulseTimers) clearTimeout(t);
|
|
52165
|
+
this.pulseTimers.clear();
|
|
52166
|
+
}
|
|
52167
|
+
add(deviceId, cls, id) {
|
|
52168
|
+
const key = `${deviceId}|${cls}`;
|
|
52169
|
+
const set = this.open.get(key) ?? /* @__PURE__ */ new Set();
|
|
52170
|
+
const was = set.size > 0;
|
|
52171
|
+
set.add(id);
|
|
52172
|
+
this.open.set(key, set);
|
|
52173
|
+
if (!was) this.onTransition(deviceId, cls, true);
|
|
52174
|
+
}
|
|
52175
|
+
remove(deviceId, cls, id) {
|
|
52176
|
+
const set = this.open.get(`${deviceId}|${cls}`);
|
|
52177
|
+
if (set === void 0 || !set.delete(id)) return;
|
|
52178
|
+
if (set.size === 0) this.onTransition(deviceId, cls, false);
|
|
52179
|
+
}
|
|
52180
|
+
replace(deviceId, cls, before, after) {
|
|
52181
|
+
this.open.set(`${deviceId}|${cls}`, after);
|
|
52182
|
+
if (before.size > 0 && after.size === 0) this.onTransition(deviceId, cls, false);
|
|
52183
|
+
else if (before.size === 0 && after.size > 0) this.onTransition(deviceId, cls, true);
|
|
52184
|
+
}
|
|
52185
|
+
};
|
|
52186
|
+
//#endregion
|
|
52187
|
+
//#region src/vision/vision-ids.ts
|
|
52188
|
+
/**
|
|
52189
|
+
* The identifiers an `ObjectDetection` event and a `DataReport` item carry
|
|
52190
|
+
* (R4, D779).
|
|
52191
|
+
*
|
|
52192
|
+
* - `objectIdentifier`: "a UUID identifying a specific physical object" — one
|
|
52193
|
+
* per pipeline track. A UUIDv5 of `<deviceId>:<trackId>` under this hub's
|
|
52194
|
+
* namespace, so it is stable for the track's life and across restarts.
|
|
52195
|
+
* - `eventIdentifier`: Amazon requires a version-4 UUID per event in the
|
|
52196
|
+
* history. This skill posts ONE event per track, so the event id is derived
|
|
52197
|
+
* from the track too — a v4-shaped UUID whose 122 "random" bits are a
|
|
52198
|
+
* SHA-256 of the hub namespace and the track. Deriving it (instead of
|
|
52199
|
+
* `randomUUID()`) is what lets the history (`ReportData` by time range)
|
|
52200
|
+
* hand Alexa the SAME id the live event carried, without a lookup table
|
|
52201
|
+
* that would have to outlive the tracks it describes.
|
|
52202
|
+
*
|
|
52203
|
+
* The hub namespace is a random UUID generated once and stored hidden in the
|
|
52204
|
+
* addon settings, so two hubs never mint the same ids for the same track id.
|
|
52205
|
+
*/
|
|
52206
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
52207
|
+
function uuidBytes(uuid) {
|
|
52208
|
+
if (!UUID.test(uuid)) throw new Error(`not a UUID: ${uuid}`);
|
|
52209
|
+
return Buffer.from(uuid.replaceAll("-", ""), "hex");
|
|
52210
|
+
}
|
|
52211
|
+
function formatUuid(bytes) {
|
|
52212
|
+
const hex = bytes.subarray(0, 16).toString("hex");
|
|
52213
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
52214
|
+
}
|
|
52215
|
+
function stamp$1(bytes, version) {
|
|
52216
|
+
const out = Buffer.from(bytes.subarray(0, 16));
|
|
52217
|
+
out[6] = (out[6] ?? 0) & 15 | version << 4;
|
|
52218
|
+
out[8] = (out[8] ?? 0) & 63 | 128;
|
|
52219
|
+
return out;
|
|
52220
|
+
}
|
|
52221
|
+
/** RFC 4122 UUIDv5 (SHA-1, name-based). */
|
|
52222
|
+
function uuidV5(namespace, name) {
|
|
52223
|
+
return formatUuid(stamp$1((0, node_crypto.createHash)("sha1").update(uuidBytes(namespace)).update(Buffer.from(name, "utf8")).digest(), 5));
|
|
52224
|
+
}
|
|
52225
|
+
/** A version-4-formatted UUID whose bits are a hash — see the file header. */
|
|
52226
|
+
function hashedUuidV4(namespace, name) {
|
|
52227
|
+
return formatUuid(stamp$1((0, node_crypto.createHash)("sha256").update(uuidBytes(namespace)).update(name, "utf8").digest(), 4));
|
|
52228
|
+
}
|
|
52229
|
+
function trackName(deviceId, trackId) {
|
|
52230
|
+
return `${deviceId}:${trackId}`;
|
|
52231
|
+
}
|
|
52232
|
+
function objectIdentifierFor(namespace, deviceId, trackId) {
|
|
52233
|
+
return uuidV5(namespace, trackName(deviceId, trackId));
|
|
52234
|
+
}
|
|
52235
|
+
/** One event per track AND class: a track re-classified person → package is a
|
|
52236
|
+
* second event about the same object. */
|
|
52237
|
+
function eventIdentifierFor(namespace, deviceId, trackId, imageNetClass) {
|
|
52238
|
+
return hashedUuidV4(namespace, `event|${trackName(deviceId, trackId)}|${imageNetClass}`);
|
|
52239
|
+
}
|
|
52240
|
+
//#endregion
|
|
52241
|
+
//#region src/vision/vision-state.ts
|
|
52242
|
+
/**
|
|
52243
|
+
* Per-camera object-detection state the Alexa side owns (R4, D779), in this
|
|
52244
|
+
* addon's per-device store (`<addonId>:addon-devices`, key
|
|
52245
|
+
* {@link VISION_STATE_KEY}):
|
|
52246
|
+
*
|
|
52247
|
+
* - `classes` — what the user last chose in the Alexa app
|
|
52248
|
+
* (`SetObjectDetectionClasses`). Absent = never told = every declared class.
|
|
52249
|
+
* - `index` — eventIdentifier → track, for `ReportData` BY_IDENTIFIER. Ids are
|
|
52250
|
+
* derived from the track (`vision-ids.ts`), so the index only has to answer
|
|
52251
|
+
* the reverse direction; bounded to the newest {@link MAX_INDEX} entries.
|
|
52252
|
+
* - `hidden` — events `DeleteData` removed. Hidden from Alexa ONLY: deleting
|
|
52253
|
+
* CamStack footage is not something a voice assistant may do. Bounded.
|
|
52254
|
+
*
|
|
52255
|
+
* Reads are cached per camera; writes are serialized per camera (one
|
|
52256
|
+
* read-modify-write at a time), the same shape as `DetectionClassSource`.
|
|
52257
|
+
*/
|
|
52258
|
+
var VISION_STATE_KEY = "alexaVision";
|
|
52259
|
+
var IndexEntrySchema = object({
|
|
52260
|
+
/** eventIdentifier */
|
|
52261
|
+
e: string(),
|
|
52262
|
+
/** trackId */
|
|
52263
|
+
t: string(),
|
|
52264
|
+
/** imageNetClass */
|
|
52265
|
+
c: _enum(SMART_VISION_CLASSES$1),
|
|
52266
|
+
/** ms epoch of the detection */
|
|
52267
|
+
at: number()
|
|
52268
|
+
});
|
|
52269
|
+
var VisionStateSchema = object({
|
|
52270
|
+
classes: array(_enum(SMART_VISION_CLASSES$1)).optional(),
|
|
52271
|
+
index: array(IndexEntrySchema).default([]),
|
|
52272
|
+
hidden: array(string()).default([])
|
|
52273
|
+
});
|
|
52274
|
+
var EMPTY = {
|
|
52275
|
+
index: [],
|
|
52276
|
+
hidden: []
|
|
52277
|
+
};
|
|
52278
|
+
function parseVisionState(store) {
|
|
52279
|
+
const parsed = VisionStateSchema.safeParse(store[VISION_STATE_KEY]);
|
|
52280
|
+
return parsed.success ? parsed.data : EMPTY;
|
|
52281
|
+
}
|
|
52282
|
+
var VisionStateStore = class {
|
|
52283
|
+
deps;
|
|
52284
|
+
cache = /* @__PURE__ */ new Map();
|
|
52285
|
+
chains = /* @__PURE__ */ new Map();
|
|
52286
|
+
constructor(deps) {
|
|
52287
|
+
this.deps = deps;
|
|
52288
|
+
}
|
|
52289
|
+
async get(deviceId) {
|
|
52290
|
+
const cached = this.cache.get(deviceId);
|
|
52291
|
+
if (cached !== void 0) return cached;
|
|
52292
|
+
const state = parseVisionState(await this.deps.read(deviceId));
|
|
52293
|
+
this.cache.set(deviceId, state);
|
|
52294
|
+
return state;
|
|
52295
|
+
}
|
|
52296
|
+
/** The user's class choice from the Alexa app. */
|
|
52297
|
+
setClasses(deviceId, classes) {
|
|
52298
|
+
return this.update(deviceId, (s) => ({
|
|
52299
|
+
...s,
|
|
52300
|
+
classes: [...classes]
|
|
52301
|
+
}));
|
|
52302
|
+
}
|
|
52303
|
+
/** Record events Alexa was (or is about to be) told about. Known ids are
|
|
52304
|
+
* left alone, so re-reading the history costs no write. */
|
|
52305
|
+
async remember(deviceId, entries) {
|
|
52306
|
+
const known = new Set((await this.get(deviceId)).index.map((e) => e.e));
|
|
52307
|
+
const fresh = entries.filter((e) => !known.has(e.e));
|
|
52308
|
+
if (fresh.length === 0) return;
|
|
52309
|
+
await this.update(deviceId, (s) => {
|
|
52310
|
+
const merged = [...s.index.filter((e) => !fresh.some((f) => f.e === e.e)), ...fresh];
|
|
52311
|
+
merged.sort((a, b) => a.at - b.at);
|
|
52312
|
+
return {
|
|
52313
|
+
...s,
|
|
52314
|
+
index: merged.slice(-300)
|
|
52315
|
+
};
|
|
52316
|
+
});
|
|
52317
|
+
}
|
|
52318
|
+
async lookup(deviceId, eventIdentifier) {
|
|
52319
|
+
return (await this.get(deviceId)).index.find((e) => e.e === eventIdentifier) ?? null;
|
|
52320
|
+
}
|
|
52321
|
+
hide(deviceId, eventIdentifiers) {
|
|
52322
|
+
return this.update(deviceId, (s) => {
|
|
52323
|
+
const hidden = [...s.hidden.filter((h) => !eventIdentifiers.includes(h)), ...eventIdentifiers];
|
|
52324
|
+
return {
|
|
52325
|
+
...s,
|
|
52326
|
+
hidden: hidden.slice(-1e3)
|
|
52327
|
+
};
|
|
52328
|
+
});
|
|
52329
|
+
}
|
|
52330
|
+
/** Forget the cached copy (tests; a store written behind our back). */
|
|
52331
|
+
invalidate(deviceId) {
|
|
52332
|
+
this.cache.delete(deviceId);
|
|
52333
|
+
}
|
|
52334
|
+
update(deviceId, fn) {
|
|
52335
|
+
const run = (this.chains.get(deviceId) ?? Promise.resolve()).then(async () => {
|
|
52336
|
+
const next = fn(await this.get(deviceId));
|
|
52337
|
+
await this.deps.write(deviceId, { [VISION_STATE_KEY]: next });
|
|
52338
|
+
this.cache.set(deviceId, next);
|
|
52339
|
+
});
|
|
52340
|
+
this.chains.set(deviceId, run.catch(() => void 0));
|
|
52341
|
+
return run;
|
|
52342
|
+
}
|
|
52343
|
+
};
|
|
52344
|
+
/**
|
|
52345
|
+
* The classes a camera reports to Alexa: the ones it declares (`person`, plus
|
|
52346
|
+
* `package` where the pipeline produces it) that the user has not turned off
|
|
52347
|
+
* in the Alexa app.
|
|
52348
|
+
*/
|
|
52349
|
+
function reportableVisionClasses(declared, state) {
|
|
52350
|
+
const chosen = state.classes;
|
|
52351
|
+
return declared.filter((c) => chosen === void 0 || chosen.includes(c));
|
|
52352
|
+
}
|
|
52353
|
+
/** Rows read per round while filling a page, and rounds per page. */
|
|
52354
|
+
var FETCH_ROWS = 50;
|
|
52355
|
+
var MAX_ROUNDS = 5;
|
|
52356
|
+
/** Rows a time-range DeleteData hides at most. */
|
|
52357
|
+
var MAX_DELETE_ROWS = 500;
|
|
52358
|
+
var QuerySchema = object({
|
|
52359
|
+
ids: array(string()).max(10).optional(),
|
|
52360
|
+
fromTime: string().optional(),
|
|
52361
|
+
toTime: string().optional()
|
|
52362
|
+
});
|
|
52363
|
+
var ReportDataPayloadSchema = object({
|
|
52364
|
+
queryType: _enum([
|
|
52365
|
+
"QUERY_BY_IDENTIFIER",
|
|
52366
|
+
"QUERY_BY_TIMESTAMP_RANGE",
|
|
52367
|
+
"QUERY_ALL"
|
|
52368
|
+
]),
|
|
52369
|
+
query: QuerySchema.optional(),
|
|
52370
|
+
maxResults: number().int().optional(),
|
|
52371
|
+
nextToken: string().optional()
|
|
52372
|
+
});
|
|
52373
|
+
var DeleteDataPayloadSchema = ReportDataPayloadSchema.omit({
|
|
52374
|
+
maxResults: true,
|
|
52375
|
+
nextToken: true
|
|
52376
|
+
});
|
|
52377
|
+
var SetClassesPayloadSchema = object({ objectDetectionClasses: array(object({ imageNetClass: string() })) });
|
|
52378
|
+
var CursorSchema = object({
|
|
52379
|
+
l: number(),
|
|
52380
|
+
t: string()
|
|
52381
|
+
});
|
|
52382
|
+
function encodeCursor(cursor) {
|
|
52383
|
+
return `v1.${Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url")}`;
|
|
52384
|
+
}
|
|
52385
|
+
function decodeCursor(token) {
|
|
52386
|
+
if (!token.startsWith("v1.")) return null;
|
|
52387
|
+
try {
|
|
52388
|
+
const parsed = CursorSchema.safeParse(JSON.parse(Buffer.from(token.slice(3), "base64url").toString("utf8")));
|
|
52389
|
+
return parsed.success ? parsed.data : null;
|
|
52390
|
+
} catch {
|
|
52391
|
+
return null;
|
|
52392
|
+
}
|
|
52393
|
+
}
|
|
52394
|
+
/** Before the cursor in (`lastSeen` DESC, `trackId` DESC) order? */
|
|
52395
|
+
function isAfterCursor(row, cursor) {
|
|
52396
|
+
if (cursor === null) return true;
|
|
52397
|
+
return row.lastSeen < cursor.l || row.lastSeen === cursor.l && row.trackId < cursor.t;
|
|
52398
|
+
}
|
|
52399
|
+
function cameraOf(endpoint) {
|
|
52400
|
+
if (endpoint === void 0) return null;
|
|
52401
|
+
const parsed = parseCameraEndpointId(endpoint.endpointId);
|
|
52402
|
+
return parsed.kind === "camera" ? parsed.deviceId : null;
|
|
52403
|
+
}
|
|
52404
|
+
function isVisionClass$1(cls) {
|
|
52405
|
+
return SMART_VISION_CLASSES$1.includes(cls);
|
|
52406
|
+
}
|
|
52407
|
+
async function prepare(ctx, deviceId) {
|
|
52408
|
+
const state = await ctx.vision.get(deviceId);
|
|
52409
|
+
return {
|
|
52410
|
+
deviceId,
|
|
52411
|
+
reportable: reportableVisionClasses(await ctx.declaredClasses(deviceId), state),
|
|
52412
|
+
hidden: new Set(state.hidden)
|
|
52413
|
+
};
|
|
52414
|
+
}
|
|
52415
|
+
/** A track as a history entry, or null when it is not one this camera shows. */
|
|
52416
|
+
function entryOf(ctx, p, track) {
|
|
52417
|
+
const cls = detectionClassOf(track.className);
|
|
52418
|
+
if (!isVisionClass$1(cls) || !p.reportable.includes(cls)) return null;
|
|
52419
|
+
const e = eventIdentifierFor(ctx.namespace(), p.deviceId, track.trackId, cls);
|
|
52420
|
+
if (p.hidden.has(e)) return null;
|
|
52421
|
+
return {
|
|
52422
|
+
e,
|
|
52423
|
+
t: track.trackId,
|
|
52424
|
+
c: cls,
|
|
52425
|
+
at: track.firstSeen
|
|
52426
|
+
};
|
|
52427
|
+
}
|
|
52428
|
+
function dataItem(ctx, p, entry, userId) {
|
|
52429
|
+
return {
|
|
52430
|
+
eventIdentifier: entry.e,
|
|
52431
|
+
imageNetClass: entry.c,
|
|
52432
|
+
objectIdentifier: objectIdentifierFor(ctx.namespace(), p.deviceId, entry.t),
|
|
52433
|
+
...ctx.imagesFor(userId, p.deviceId, entry.t, entry.at)
|
|
52434
|
+
};
|
|
52435
|
+
}
|
|
52436
|
+
function rangeOf(payload) {
|
|
52437
|
+
if (payload.queryType === "QUERY_ALL") return {
|
|
52438
|
+
from: 0,
|
|
52439
|
+
to: Date.now()
|
|
52440
|
+
};
|
|
52441
|
+
const from = Date.parse(payload.query?.fromTime ?? "");
|
|
52442
|
+
const to = Date.parse(payload.query?.toTime ?? "");
|
|
52443
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || from > to) return null;
|
|
52444
|
+
return {
|
|
52445
|
+
from,
|
|
52446
|
+
to
|
|
52447
|
+
};
|
|
52448
|
+
}
|
|
52449
|
+
function dataControllerEvent(header, endpointId, name, payload) {
|
|
52450
|
+
return { event: {
|
|
52451
|
+
header: {
|
|
52452
|
+
namespace: "Alexa.DataController",
|
|
52453
|
+
name,
|
|
52454
|
+
instance: header.instance ?? "Camera.SmartVisionData",
|
|
52455
|
+
messageId: (0, node_crypto.randomUUID)(),
|
|
52456
|
+
...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
|
|
52457
|
+
payloadVersion: "1.0"
|
|
52458
|
+
},
|
|
52459
|
+
endpoint: { endpointId },
|
|
52460
|
+
payload
|
|
52461
|
+
} };
|
|
52462
|
+
}
|
|
52463
|
+
/** One page of the time-range history, starting after `start`. */
|
|
52464
|
+
async function rangePage(ctx, p, range, start, max) {
|
|
52465
|
+
const entries = [];
|
|
52466
|
+
let cursor = start;
|
|
52467
|
+
for (let round = 0; round < MAX_ROUNDS; round += 1) {
|
|
52468
|
+
const rows = await ctx.listTracks(p.deviceId, {
|
|
52469
|
+
since: range.from,
|
|
52470
|
+
until: cursor === null ? range.to : Math.min(cursor.l, range.to),
|
|
52471
|
+
limit: FETCH_ROWS
|
|
52472
|
+
});
|
|
52473
|
+
for (const row of rows) {
|
|
52474
|
+
if (!isAfterCursor(row, cursor)) continue;
|
|
52475
|
+
cursor = {
|
|
52476
|
+
l: row.lastSeen,
|
|
52477
|
+
t: row.trackId
|
|
52478
|
+
};
|
|
52479
|
+
const entry = entryOf(ctx, p, row);
|
|
52480
|
+
if (entry === null) continue;
|
|
52481
|
+
entries.push(entry);
|
|
52482
|
+
if (entries.length === max) return {
|
|
52483
|
+
entries,
|
|
52484
|
+
next: cursor
|
|
52485
|
+
};
|
|
52486
|
+
}
|
|
52487
|
+
if (rows.length < FETCH_ROWS) return {
|
|
52488
|
+
entries,
|
|
52489
|
+
next: null
|
|
52490
|
+
};
|
|
52491
|
+
}
|
|
52492
|
+
return {
|
|
52493
|
+
entries,
|
|
52494
|
+
next: cursor
|
|
52495
|
+
};
|
|
52496
|
+
}
|
|
52497
|
+
async function handleReportData(header, endpoint, payloadRaw, ctx, caller) {
|
|
52498
|
+
const deviceId = cameraOf(endpoint);
|
|
52499
|
+
if (deviceId === null || endpoint === void 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "event history is served for camera endpoints");
|
|
52500
|
+
const parsed = ReportDataPayloadSchema.safeParse(payloadRaw);
|
|
52501
|
+
if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", "ReportData payload shape unexpected");
|
|
52502
|
+
const payload = parsed.data;
|
|
52503
|
+
const max = Math.min(Math.max(payload.maxResults ?? 10, 1), 10);
|
|
52504
|
+
const p = await prepare(ctx, deviceId);
|
|
52505
|
+
let entries = [];
|
|
52506
|
+
let next = null;
|
|
52507
|
+
if (payload.queryType === "QUERY_BY_IDENTIFIER") for (const id of (payload.query?.ids ?? []).slice(0, max)) {
|
|
52508
|
+
const known = await ctx.vision.lookup(deviceId, id);
|
|
52509
|
+
if (known === null) continue;
|
|
52510
|
+
const track = await ctx.getTrack(deviceId, known.t);
|
|
52511
|
+
const entry = track === null ? null : entryOf(ctx, p, track);
|
|
52512
|
+
if (entry !== null && entry.e === id) entries.push(entry);
|
|
52513
|
+
}
|
|
52514
|
+
else {
|
|
52515
|
+
const range = rangeOf(payload);
|
|
52516
|
+
if (range === null) return errorEnvelope(header, "INVALID_DIRECTIVE", "fromTime/toTime missing or invalid");
|
|
52517
|
+
const start = payload.nextToken === void 0 ? null : decodeCursor(payload.nextToken);
|
|
52518
|
+
if (payload.nextToken !== void 0 && start === null) return errorEnvelope(header, "INVALID_DIRECTIVE", "nextToken not recognised");
|
|
52519
|
+
const page = await rangePage(ctx, p, range, start, max);
|
|
52520
|
+
entries = page.entries;
|
|
52521
|
+
next = page.next;
|
|
52522
|
+
await ctx.vision.remember(deviceId, entries);
|
|
52523
|
+
}
|
|
52524
|
+
ctx.logger.info("alexa data controller: history served", {
|
|
52525
|
+
tags: { deviceId },
|
|
52526
|
+
meta: {
|
|
52527
|
+
userId: caller.userId,
|
|
52528
|
+
queryType: payload.queryType,
|
|
52529
|
+
items: entries.length,
|
|
52530
|
+
more: next !== null
|
|
52531
|
+
}
|
|
52532
|
+
});
|
|
52533
|
+
return dataControllerEvent(header, endpoint.endpointId, "DataReport", {
|
|
52534
|
+
...next !== null ? { paginationContext: { nextToken: encodeCursor(next) } } : {},
|
|
52535
|
+
dataSchema: {
|
|
52536
|
+
type: "JSON",
|
|
52537
|
+
schema: "SmartVisionData"
|
|
52538
|
+
},
|
|
52539
|
+
data: entries.map((e) => dataItem(ctx, p, e, caller.userId))
|
|
52540
|
+
});
|
|
52541
|
+
}
|
|
52542
|
+
async function handleDeleteData(header, endpoint, payloadRaw, ctx, caller) {
|
|
52543
|
+
const deviceId = cameraOf(endpoint);
|
|
52544
|
+
if (deviceId === null || endpoint === void 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "event history is served for camera endpoints");
|
|
52545
|
+
const parsed = DeleteDataPayloadSchema.safeParse(payloadRaw);
|
|
52546
|
+
if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", "DeleteData payload shape unexpected");
|
|
52547
|
+
const payload = parsed.data;
|
|
52548
|
+
let ids;
|
|
52549
|
+
if (payload.queryType === "QUERY_BY_IDENTIFIER") ids = payload.query?.ids ?? [];
|
|
52550
|
+
else {
|
|
52551
|
+
const range = rangeOf(payload);
|
|
52552
|
+
if (range === null) return errorEnvelope(header, "INVALID_DIRECTIVE", "fromTime/toTime missing or invalid");
|
|
52553
|
+
const p = await prepare(ctx, deviceId);
|
|
52554
|
+
ids = (await ctx.listTracks(deviceId, {
|
|
52555
|
+
since: range.from,
|
|
52556
|
+
until: range.to,
|
|
52557
|
+
limit: MAX_DELETE_ROWS
|
|
52558
|
+
})).map((r) => entryOf(ctx, p, r)?.e).filter((e) => e !== void 0);
|
|
52559
|
+
}
|
|
52560
|
+
await ctx.vision.hide(deviceId, ids);
|
|
52561
|
+
ctx.logger.info("alexa data controller: events hidden from Alexa (CamStack keeps them)", {
|
|
52562
|
+
tags: { deviceId },
|
|
52563
|
+
meta: {
|
|
52564
|
+
userId: caller.userId,
|
|
52565
|
+
queryType: payload.queryType,
|
|
52566
|
+
hidden: ids.length
|
|
52567
|
+
}
|
|
52568
|
+
});
|
|
52569
|
+
return dataControllerEvent(header, endpoint.endpointId, "DataDeleted", {
|
|
52570
|
+
queryType: payload.queryType,
|
|
52571
|
+
...payload.query !== void 0 ? { query: payload.query } : {}
|
|
52572
|
+
});
|
|
52573
|
+
}
|
|
52574
|
+
async function handleSetObjectDetectionClasses(header, endpoint, payloadRaw, ctx) {
|
|
52575
|
+
const deviceId = cameraOf(endpoint);
|
|
52576
|
+
if (deviceId === null || endpoint === void 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "object detection lives on camera endpoints");
|
|
52577
|
+
const parsed = SetClassesPayloadSchema.safeParse(payloadRaw);
|
|
52578
|
+
if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", "SetObjectDetectionClasses payload unexpected");
|
|
52579
|
+
const wanted = new Set(parsed.data.objectDetectionClasses.map((c) => c.imageNetClass));
|
|
52580
|
+
const classes = SMART_VISION_CLASSES$1.filter((c) => wanted.has(c));
|
|
52581
|
+
await ctx.vision.setClasses(deviceId, classes);
|
|
52582
|
+
ctx.logger.info("alexa object detection: classes set from the Alexa app", {
|
|
52583
|
+
tags: { deviceId },
|
|
52584
|
+
meta: { classes }
|
|
52585
|
+
});
|
|
52586
|
+
const connectivity = await readConnectivity(ctx.ctx.api, deviceId, ctx.logger);
|
|
52587
|
+
return {
|
|
52588
|
+
event: {
|
|
52589
|
+
header: {
|
|
52590
|
+
namespace: "Alexa",
|
|
52591
|
+
name: "Response",
|
|
52592
|
+
messageId: (0, node_crypto.randomUUID)(),
|
|
52593
|
+
...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
|
|
52594
|
+
payloadVersion: "3"
|
|
52595
|
+
},
|
|
52596
|
+
endpoint: { endpointId: endpoint.endpointId },
|
|
52597
|
+
payload: {}
|
|
52598
|
+
},
|
|
52599
|
+
context: { properties: [{
|
|
52600
|
+
namespace: "Alexa.EndpointHealth",
|
|
52601
|
+
name: "connectivity",
|
|
52602
|
+
value: { value: connectivity },
|
|
52603
|
+
timeOfSample: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52604
|
+
uncertaintyInMilliseconds: 0
|
|
52605
|
+
}] }
|
|
52606
|
+
};
|
|
52607
|
+
}
|
|
52608
|
+
//#endregion
|
|
51767
52609
|
//#region src/handlers/lock-controller.ts
|
|
51768
52610
|
/** The `lock-control` state as Alexa's `lockState`. */
|
|
51769
52611
|
function alexaLockStateFor(state) {
|
|
@@ -52132,7 +52974,7 @@ async function toAlexaJpeg(source) {
|
|
|
52132
52974
|
//#region src/handlers/snapshot-provider.ts
|
|
52133
52975
|
/** Leaves the rest of Amazon's 8 s for the re-encode and the Lambda hop. */
|
|
52134
52976
|
var SNAPSHOT_READ_TIMEOUT_MS = 5e3;
|
|
52135
|
-
var SnapshotImageSchema = object({
|
|
52977
|
+
var SnapshotImageSchema$1 = object({
|
|
52136
52978
|
base64: string().min(1),
|
|
52137
52979
|
contentType: string()
|
|
52138
52980
|
});
|
|
@@ -52158,7 +53000,7 @@ async function readFrame(ctx, deviceId) {
|
|
|
52158
53000
|
});
|
|
52159
53001
|
try {
|
|
52160
53002
|
const raw = await Promise.race([ctx.api.snapshot.getSnapshot.query({ deviceId }), timeout]);
|
|
52161
|
-
const parsed = SnapshotImageSchema.safeParse(raw);
|
|
53003
|
+
const parsed = SnapshotImageSchema$1.safeParse(raw);
|
|
52162
53004
|
return parsed.success ? Buffer.from(parsed.data.base64, "base64") : null;
|
|
52163
53005
|
} finally {
|
|
52164
53006
|
if (timer !== void 0) clearTimeout(timer);
|
|
@@ -52917,6 +53759,9 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
|
|
|
52917
53759
|
"Alexa.SecurityPanelController/Arm": "create",
|
|
52918
53760
|
"Alexa.SecurityPanelController/Disarm": "create",
|
|
52919
53761
|
"Alexa.SmartVision.SnapshotProvider/GetSnapshot": "view",
|
|
53762
|
+
"Alexa.SmartVision.ObjectDetectionSensor/SetObjectDetectionClasses": "view",
|
|
53763
|
+
"Alexa.DataController/ReportData": "view",
|
|
53764
|
+
"Alexa.DataController/DeleteData": "view",
|
|
52920
53765
|
"Alexa.LockController/Lock": "create",
|
|
52921
53766
|
"Alexa.LockController/Unlock": "create"
|
|
52922
53767
|
};
|
|
@@ -53061,6 +53906,18 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
|
|
|
53061
53906
|
status: 200,
|
|
53062
53907
|
body: await handleGetSnapshot(header, endpoint, deps.snapshotProvider, callerIdentity)
|
|
53063
53908
|
};
|
|
53909
|
+
if (key === "Alexa.SmartVision.ObjectDetectionSensor/SetObjectDetectionClasses") return {
|
|
53910
|
+
status: 200,
|
|
53911
|
+
body: await handleSetObjectDetectionClasses(header, endpoint, payload, deps.vision)
|
|
53912
|
+
};
|
|
53913
|
+
if (key === "Alexa.DataController/ReportData") return {
|
|
53914
|
+
status: 200,
|
|
53915
|
+
body: await handleReportData(header, endpoint, payload, deps.vision, callerIdentity)
|
|
53916
|
+
};
|
|
53917
|
+
if (key === "Alexa.DataController/DeleteData") return {
|
|
53918
|
+
status: 200,
|
|
53919
|
+
body: await handleDeleteData(header, endpoint, payload, deps.vision, callerIdentity)
|
|
53920
|
+
};
|
|
53064
53921
|
if (key === "Alexa/ReportState") return {
|
|
53065
53922
|
status: 200,
|
|
53066
53923
|
body: await handleReportState(header, endpoint, deps.reportState, callerIdentity)
|
|
@@ -53366,8 +54223,17 @@ async function handleSnapshotFetch(req, reply, deps) {
|
|
|
53366
54223
|
refuse(reply, 403, "not allowed");
|
|
53367
54224
|
return;
|
|
53368
54225
|
}
|
|
53369
|
-
|
|
53370
|
-
|
|
54226
|
+
const bytes = await deps.store.bytesOf(image);
|
|
54227
|
+
if (bytes === null || bytes.byteLength === 0) {
|
|
54228
|
+
deps.logger.debug("alexa snapshot: no image to serve for this event", {
|
|
54229
|
+
tags: { deviceId: image.deviceId },
|
|
54230
|
+
meta: { imageId }
|
|
54231
|
+
});
|
|
54232
|
+
refuse(reply, 410, "no image is available for this event");
|
|
54233
|
+
return;
|
|
54234
|
+
}
|
|
54235
|
+
reply.code(200).type(image.contentType).header("cache-control", "private, no-store").header("content-encoding", "identity").header("content-length", String(bytes.byteLength));
|
|
54236
|
+
reply.send(bytes);
|
|
53371
54237
|
}
|
|
53372
54238
|
//#endregion
|
|
53373
54239
|
//#region src/http-server.ts
|
|
@@ -53807,17 +54673,14 @@ function connectivityDeviceOf(endpointId) {
|
|
|
53807
54673
|
var DOORBELL_INTERFACE = "Alexa.DoorbellEventSource";
|
|
53808
54674
|
/**
|
|
53809
54675
|
* The interface an endpoint must advertise for an `ObjectDetection` event to
|
|
53810
|
-
* survive the Alexa cloud
|
|
53811
|
-
*
|
|
53812
|
-
*
|
|
53813
|
-
*
|
|
53814
|
-
*
|
|
53815
|
-
* endpoint
|
|
53816
|
-
*
|
|
53817
|
-
*
|
|
53818
|
-
* endpoint cannot host it. This is D76 exactly — an accepted POST is not
|
|
53819
|
-
* evidence of delivery — and it is why the operator sees no events in the app
|
|
53820
|
-
* while the log reads as though they were delivered.
|
|
54676
|
+
* survive the Alexa cloud. Declared on a camera only while object detection is
|
|
54677
|
+
* on for it (R4, D779).
|
|
54678
|
+
*
|
|
54679
|
+
* Measured 2026-08-07, before the declaration existed: 223 `smartvision person
|
|
54680
|
+
* posted status=202` in 692 s on endpoint 590 alone, every one of them against
|
|
54681
|
+
* an endpoint whose discovered capability list did not contain this string. The
|
|
54682
|
+
* Event Gateway answers 202 because the ENVELOPE parsed; the cloud then drops
|
|
54683
|
+
* the event. D76: an accepted POST is not evidence of delivery.
|
|
53821
54684
|
*/
|
|
53822
54685
|
var SMART_VISION_INTERFACE = "Alexa.SmartVision.ObjectDetectionSensor";
|
|
53823
54686
|
/**
|
|
@@ -53999,13 +54862,7 @@ var LwaEventGateway = class {
|
|
|
53999
54862
|
endpointId,
|
|
54000
54863
|
detected: data.detected,
|
|
54001
54864
|
timestamp: data.timestamp,
|
|
54002
|
-
label: "motion"
|
|
54003
|
-
onRisingPosted: (userId) => this.postObjectDetection({
|
|
54004
|
-
userId,
|
|
54005
|
-
deviceId: data.deviceId,
|
|
54006
|
-
endpointId,
|
|
54007
|
-
timestamp: data.timestamp
|
|
54008
|
-
})
|
|
54865
|
+
label: "motion"
|
|
54009
54866
|
});
|
|
54010
54867
|
}
|
|
54011
54868
|
/**
|
|
@@ -54070,7 +54927,6 @@ var LwaEventGateway = class {
|
|
|
54070
54927
|
detected: true,
|
|
54071
54928
|
timestamp: opts.timestamp
|
|
54072
54929
|
});
|
|
54073
|
-
await opts.onRisingPosted?.(account.userId);
|
|
54074
54930
|
}
|
|
54075
54931
|
}
|
|
54076
54932
|
async postMotionChange(opts) {
|
|
@@ -54080,49 +54936,53 @@ var LwaEventGateway = class {
|
|
|
54080
54936
|
}), `motion ${opts.detected ? "DETECTED" : "NOT_DETECTED"}`);
|
|
54081
54937
|
}
|
|
54082
54938
|
/**
|
|
54083
|
-
*
|
|
54084
|
-
*
|
|
54085
|
-
* (`Alexa.MotionSensor` alone only feeds the account-wide Activity feed).
|
|
54939
|
+
* Post one `Alexa.SmartVision.ObjectDetectionSensor/ObjectDetection` event
|
|
54940
|
+
* (R4, D779) to every linked account whose hub user may view the camera.
|
|
54086
54941
|
*
|
|
54087
|
-
*
|
|
54088
|
-
*
|
|
54089
|
-
*
|
|
54090
|
-
*
|
|
54091
|
-
*
|
|
54092
|
-
* rate-limited gateway, and it makes the log assert a delivery that never
|
|
54093
|
-
* happened. The doorbell path learned the same lesson today and only warns
|
|
54094
|
-
* — it can, because `Alexa.DoorbellEventSource` IS built for a doorbell and
|
|
54095
|
-
* a missing one means stale discovery, i.e. a bet worth taking. Here the
|
|
54096
|
-
* interface is absent by construction, so the bet is a certain loss.
|
|
54942
|
+
* Fed by `ObjectDetectionFeed` from the pipeline's tracks — never by camera
|
|
54943
|
+
* motion. Gated on the endpoint Alexa holds DECLARING the interface (the
|
|
54944
|
+
* exported signature's entry for the camera endpoint): an event for an
|
|
54945
|
+
* endpoint without it is a 2xx the cloud discards (D76), and before D779
|
|
54946
|
+
* that was ~1 POST/s per flapping camera. The drop is logged sparsely.
|
|
54097
54947
|
*
|
|
54098
|
-
*
|
|
54099
|
-
*
|
|
54948
|
+
* Images are minted PER ACCOUNT (`imagesFor`): an image URL is checked
|
|
54949
|
+
* against the account-link token of the user it was minted for.
|
|
54100
54950
|
*/
|
|
54101
54951
|
async postObjectDetection(opts) {
|
|
54952
|
+
const endpointId = String(opts.deviceId);
|
|
54102
54953
|
const signature = this.deps.getExportedSignature(opts.deviceId);
|
|
54103
|
-
if (signature
|
|
54104
|
-
const dropped = (this.smartVisionDropped.get(
|
|
54105
|
-
this.smartVisionDropped.set(
|
|
54954
|
+
if (!(signature !== null && signatureEntries(signature).some((e) => e.endpointId === endpointId && e.interfaces.includes(SMART_VISION_INTERFACE)))) {
|
|
54955
|
+
const dropped = (this.smartVisionDropped.get(endpointId) ?? 0) + 1;
|
|
54956
|
+
this.smartVisionDropped.set(endpointId, dropped);
|
|
54106
54957
|
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`, {
|
|
54107
54958
|
tags: { deviceId: opts.deviceId },
|
|
54108
54959
|
meta: {
|
|
54109
|
-
endpointId
|
|
54960
|
+
endpointId,
|
|
54110
54961
|
droppedSoFar: dropped,
|
|
54111
54962
|
signature
|
|
54112
54963
|
}
|
|
54113
54964
|
});
|
|
54114
54965
|
return;
|
|
54115
54966
|
}
|
|
54116
|
-
|
|
54117
|
-
|
|
54118
|
-
|
|
54119
|
-
|
|
54120
|
-
|
|
54121
|
-
|
|
54122
|
-
|
|
54123
|
-
|
|
54124
|
-
|
|
54125
|
-
|
|
54967
|
+
const linked = this.deps.listLinkedAccounts();
|
|
54968
|
+
if (linked.length === 0 || !this.hasProactiveCredentials()) return;
|
|
54969
|
+
const d = opts.detection;
|
|
54970
|
+
for (const account of linked) {
|
|
54971
|
+
if (!this.mayPush(account.userId, opts.deviceId, "object detection")) continue;
|
|
54972
|
+
const images = opts.imagesFor?.(account.userId) ?? {};
|
|
54973
|
+
await this.postEventBody(account.userId, endpointId, (accessToken) => buildSmartVisionObjectDetectionEvent({
|
|
54974
|
+
userId: account.userId,
|
|
54975
|
+
endpointId,
|
|
54976
|
+
detections: [{
|
|
54977
|
+
eventIdentifier: d.eventIdentifier,
|
|
54978
|
+
imageNetClass: d.imageNetClass,
|
|
54979
|
+
objectIdentifier: d.objectIdentifier,
|
|
54980
|
+
...images
|
|
54981
|
+
}],
|
|
54982
|
+
timestamp: d.timestamp,
|
|
54983
|
+
accessToken
|
|
54984
|
+
}), `smartvision ${d.imageNetClass} (track ${d.trackId}, images ${images.frameImageUri === void 0 ? "off" : "on"})`);
|
|
54985
|
+
}
|
|
54126
54986
|
}
|
|
54127
54987
|
/**
|
|
54128
54988
|
* A camera went offline or came back. Amazon requires the `connectivity`
|
|
@@ -54246,32 +55106,16 @@ var LwaEventGateway = class {
|
|
|
54246
55106
|
* either side get cleared.
|
|
54247
55107
|
*/
|
|
54248
55108
|
async purgeEndpoints(endpointIds) {
|
|
54249
|
-
if (endpointIds.length === 0) return {
|
|
54250
|
-
requested: 0,
|
|
54251
|
-
accountCount: 0,
|
|
54252
|
-
attemptedAccounts: 0
|
|
54253
|
-
};
|
|
54254
55109
|
const linked = this.deps.listLinkedAccounts();
|
|
54255
|
-
|
|
54256
|
-
|
|
54257
|
-
|
|
54258
|
-
attemptedAccounts: 0
|
|
54259
|
-
};
|
|
54260
|
-
if (!this.hasProactiveCredentials()) return {
|
|
54261
|
-
requested: endpointIds.length,
|
|
54262
|
-
accountCount: linked.length,
|
|
54263
|
-
attemptedAccounts: 0
|
|
54264
|
-
};
|
|
54265
|
-
let attemptedAccounts = 0;
|
|
54266
|
-
for (const account of linked) {
|
|
54267
|
-
if (!this.findToken(account.userId)) continue;
|
|
54268
|
-
await this.postEventBody(account.userId, "discovery", (accessToken) => buildDeleteReportPayload(account.userId, endpointIds, accessToken), `discovery delete (manual purge, ${endpointIds.length})`);
|
|
54269
|
-
attemptedAccounts += 1;
|
|
55110
|
+
let deliveredAccounts = 0;
|
|
55111
|
+
if (endpointIds.length > 0) {
|
|
55112
|
+
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;
|
|
54270
55113
|
}
|
|
54271
55114
|
return {
|
|
54272
55115
|
requested: endpointIds.length,
|
|
54273
55116
|
accountCount: linked.length,
|
|
54274
|
-
|
|
55117
|
+
deliveredAccounts,
|
|
55118
|
+
failedAccounts: endpointIds.length > 0 ? linked.length - deliveredAccounts : 0
|
|
54275
55119
|
};
|
|
54276
55120
|
}
|
|
54277
55121
|
/** Public delivery used by the SyncEngine. Returns true on gateway 2xx.
|
|
@@ -54711,6 +55555,286 @@ function buildDeleteReportPayload(_userId, endpointIds, accessToken) {
|
|
|
54711
55555
|
} };
|
|
54712
55556
|
}
|
|
54713
55557
|
//#endregion
|
|
55558
|
+
//#region src/remove-endpoints.ts
|
|
55559
|
+
/** Comma / whitespace / newline separated ids → unique non-empty ids. */
|
|
55560
|
+
function parseEndpointIds(raw) {
|
|
55561
|
+
const ids = raw.split(/[\s,]+/).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
55562
|
+
return [...new Set(ids)];
|
|
55563
|
+
}
|
|
55564
|
+
/**
|
|
55565
|
+
* The tracked map (deviceId → endpoint ids last pushed) without `removed`. A
|
|
55566
|
+
* device left with no id is dropped, so no later DeleteReport re-addresses it.
|
|
55567
|
+
*/
|
|
55568
|
+
function forgetDeliveredEndpointIds(tracked, removed) {
|
|
55569
|
+
const gone = new Set(removed);
|
|
55570
|
+
const next = {};
|
|
55571
|
+
for (const [key, ids] of Object.entries(tracked)) {
|
|
55572
|
+
const remaining = ids.filter((id) => !gone.has(id));
|
|
55573
|
+
if (remaining.length > 0) next[Number(key)] = remaining;
|
|
55574
|
+
}
|
|
55575
|
+
return next;
|
|
55576
|
+
}
|
|
55577
|
+
/**
|
|
55578
|
+
* Delete the typed ids from every linked Alexa account. Throws — so the button
|
|
55579
|
+
* shows the reason — unless every account accepted the delete; only then are
|
|
55580
|
+
* the ids forgotten locally. A partial delivery keeps them tracked, so a
|
|
55581
|
+
* Re-sync or a second press can still reach the account that missed it.
|
|
55582
|
+
*/
|
|
55583
|
+
async function removeEndpointsFromAlexa(deps) {
|
|
55584
|
+
const ids = parseEndpointIds(deps.raw);
|
|
55585
|
+
if (ids.length === 0) throw new Error("No endpoint id given. Type one or more ids, e.g. 54, 54__privacy.");
|
|
55586
|
+
const result = await deps.purge(ids);
|
|
55587
|
+
if (result.accountCount === 0) throw new Error("No linked Amazon account: there is no Alexa account to remove them from.");
|
|
55588
|
+
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.`);
|
|
55589
|
+
await deps.setTracked(forgetDeliveredEndpointIds(deps.getTracked(), ids));
|
|
55590
|
+
return result;
|
|
55591
|
+
}
|
|
55592
|
+
//#endregion
|
|
55593
|
+
//#region src/settings-schema.ts
|
|
55594
|
+
var ADDON_ID = "export-alexa";
|
|
55595
|
+
var CONNECTION_DESCRIPTION = [
|
|
55596
|
+
"Public skill (default): enable the \"CamStack\" skill in the Alexa app and link your account. No Amazon developer setup is needed.",
|
|
55597
|
+
"Custom skill: enter your skill id and deploy the skill Lambda from this addon's lambda-template in your own AWS account.",
|
|
55598
|
+
"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.",
|
|
55599
|
+
"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."
|
|
55600
|
+
].join("\n");
|
|
55601
|
+
function connectionSection(input) {
|
|
55602
|
+
const { defaults } = input;
|
|
55603
|
+
return {
|
|
55604
|
+
id: "export-alexa-connection",
|
|
55605
|
+
title: "Connection",
|
|
55606
|
+
description: CONNECTION_DESCRIPTION,
|
|
55607
|
+
columns: 1,
|
|
55608
|
+
fields: [
|
|
55609
|
+
{
|
|
55610
|
+
type: "select",
|
|
55611
|
+
key: "skillMode",
|
|
55612
|
+
label: "Alexa skill",
|
|
55613
|
+
description: "Public: the built-in CamStack skill. Custom: your own skill and your own copy of the skill Lambda.",
|
|
55614
|
+
options: [{
|
|
55615
|
+
value: "public",
|
|
55616
|
+
label: "Public (built-in CamStack skill)"
|
|
55617
|
+
}, {
|
|
55618
|
+
value: "custom",
|
|
55619
|
+
label: "Custom skill"
|
|
55620
|
+
}],
|
|
55621
|
+
default: defaults.skillMode
|
|
55622
|
+
},
|
|
55623
|
+
{
|
|
55624
|
+
type: "select",
|
|
55625
|
+
key: "region",
|
|
55626
|
+
label: "Skill region",
|
|
55627
|
+
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.",
|
|
55628
|
+
options: [
|
|
55629
|
+
{
|
|
55630
|
+
value: "NA",
|
|
55631
|
+
label: "North America"
|
|
55632
|
+
},
|
|
55633
|
+
{
|
|
55634
|
+
value: "EU",
|
|
55635
|
+
label: "Europe"
|
|
55636
|
+
},
|
|
55637
|
+
{
|
|
55638
|
+
value: "FE",
|
|
55639
|
+
label: "Far East"
|
|
55640
|
+
}
|
|
55641
|
+
],
|
|
55642
|
+
default: defaults.region
|
|
55643
|
+
},
|
|
55644
|
+
...input.skillMode === "custom" ? [{
|
|
55645
|
+
type: "text",
|
|
55646
|
+
key: "expectedAudience",
|
|
55647
|
+
label: "Alexa skill id",
|
|
55648
|
+
description: "Your skill's id, format amzn1.ask.skill.xxxx, from the Alexa developer console. Directives for any other skill are refused.",
|
|
55649
|
+
default: defaults.expectedAudience
|
|
55650
|
+
}] : [],
|
|
55651
|
+
{
|
|
55652
|
+
type: "addon-action-select",
|
|
55653
|
+
key: "publicHubUrl",
|
|
55654
|
+
label: "External address",
|
|
55655
|
+
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.",
|
|
55656
|
+
addonId: ADDON_ID,
|
|
55657
|
+
action: "listExternalAddresses",
|
|
55658
|
+
mapOption: {
|
|
55659
|
+
value: "value",
|
|
55660
|
+
label: "label"
|
|
55661
|
+
},
|
|
55662
|
+
emptyResultsMessage: "No external HTTPS address detected. Set up remote access (Cloudflare Tunnel, Tailscale Funnel, …), then reopen this page.",
|
|
55663
|
+
default: defaults.publicHubUrl
|
|
55664
|
+
}
|
|
55665
|
+
]
|
|
55666
|
+
};
|
|
55667
|
+
}
|
|
55668
|
+
function featuresSection(input) {
|
|
55669
|
+
const { defaults } = input;
|
|
55670
|
+
return {
|
|
55671
|
+
id: "export-alexa-features",
|
|
55672
|
+
title: "Features",
|
|
55673
|
+
columns: 1,
|
|
55674
|
+
fields: [
|
|
55675
|
+
{
|
|
55676
|
+
type: "boolean",
|
|
55677
|
+
key: "detectionSensors",
|
|
55678
|
+
label: "Detection sensors",
|
|
55679
|
+
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.",
|
|
55680
|
+
default: defaults.detectionSensors
|
|
55681
|
+
},
|
|
55682
|
+
{
|
|
55683
|
+
type: "boolean",
|
|
55684
|
+
key: "objectDetection",
|
|
55685
|
+
label: "Object detection (experimental)",
|
|
55686
|
+
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.",
|
|
55687
|
+
default: defaults.objectDetection
|
|
55688
|
+
},
|
|
55689
|
+
{
|
|
55690
|
+
type: "boolean",
|
|
55691
|
+
key: "snapshots",
|
|
55692
|
+
label: "Snapshots in the Alexa app",
|
|
55693
|
+
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.",
|
|
55694
|
+
default: defaults.snapshots
|
|
55695
|
+
}
|
|
55696
|
+
]
|
|
55697
|
+
};
|
|
55698
|
+
}
|
|
55699
|
+
function accountLinkSection(input) {
|
|
55700
|
+
return {
|
|
55701
|
+
id: "export-alexa-account",
|
|
55702
|
+
title: "Account link",
|
|
55703
|
+
columns: 1,
|
|
55704
|
+
fields: [{
|
|
55705
|
+
type: "info",
|
|
55706
|
+
key: "__alexa-link-status",
|
|
55707
|
+
label: "Status",
|
|
55708
|
+
variant: "info",
|
|
55709
|
+
content: buildLinkStatusContent({
|
|
55710
|
+
linkedAccounts: input.linkedAccounts,
|
|
55711
|
+
tokens: input.tokens,
|
|
55712
|
+
now: input.now
|
|
55713
|
+
})
|
|
55714
|
+
}, {
|
|
55715
|
+
type: "addon-action-button",
|
|
55716
|
+
addonId: ADDON_ID,
|
|
55717
|
+
key: "__unlink-alexa-accounts",
|
|
55718
|
+
label: "Unlink Amazon account",
|
|
55719
|
+
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.",
|
|
55720
|
+
buttonLabel: "Unlink now",
|
|
55721
|
+
action: "unlinkAlexaAccounts",
|
|
55722
|
+
variant: "danger",
|
|
55723
|
+
confirmMessage: "Unlink the Amazon account? Alexa loses access to your cameras immediately and you must re-link from the Alexa app to restore it.",
|
|
55724
|
+
successMessage: "Unlinked: {revokedSessions} session(s) revoked, {removedAccounts} linked account(s) removed. Re-link from the Alexa app to restore."
|
|
55725
|
+
}]
|
|
55726
|
+
};
|
|
55727
|
+
}
|
|
55728
|
+
function troubleshootingSection() {
|
|
55729
|
+
return {
|
|
55730
|
+
id: "export-alexa-troubleshooting",
|
|
55731
|
+
title: "Troubleshooting",
|
|
55732
|
+
style: "accordion",
|
|
55733
|
+
defaultCollapsed: true,
|
|
55734
|
+
columns: 1,
|
|
55735
|
+
fields: [
|
|
55736
|
+
{
|
|
55737
|
+
type: "addon-action-button",
|
|
55738
|
+
addonId: ADDON_ID,
|
|
55739
|
+
key: "__resync-discovery",
|
|
55740
|
+
label: "Re-sync devices",
|
|
55741
|
+
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.",
|
|
55742
|
+
buttonLabel: "Re-sync now",
|
|
55743
|
+
action: "resyncDiscovery",
|
|
55744
|
+
variant: "default",
|
|
55745
|
+
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)."
|
|
55746
|
+
},
|
|
55747
|
+
{
|
|
55748
|
+
type: "addon-action-button",
|
|
55749
|
+
addonId: ADDON_ID,
|
|
55750
|
+
key: "__remove-alexa-endpoints",
|
|
55751
|
+
label: "Remove ids from Alexa",
|
|
55752
|
+
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.",
|
|
55753
|
+
buttonLabel: "Remove ids…",
|
|
55754
|
+
action: "removeAlexaEndpoints",
|
|
55755
|
+
variant: "danger",
|
|
55756
|
+
prompt: {
|
|
55757
|
+
param: "endpointIds",
|
|
55758
|
+
title: "Remove ids from Alexa",
|
|
55759
|
+
message: "Endpoint ids separated by commas or spaces. Each linked Alexa account is sent a delete for them.",
|
|
55760
|
+
placeholder: "54, 54__privacy"
|
|
55761
|
+
},
|
|
55762
|
+
successMessage: "Removed {requested} id(s) from {deliveredAccounts} linked account(s)."
|
|
55763
|
+
},
|
|
55764
|
+
{
|
|
55765
|
+
type: "addon-action-button",
|
|
55766
|
+
addonId: ADDON_ID,
|
|
55767
|
+
key: "__rotate-broker-credential",
|
|
55768
|
+
label: "Rotate broker credential",
|
|
55769
|
+
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.",
|
|
55770
|
+
buttonLabel: "Rotate credential",
|
|
55771
|
+
action: "rotateBrokerCredential",
|
|
55772
|
+
variant: "default",
|
|
55773
|
+
successMessage: "Rotated {rotated} credential(s); {failed} failed (see the addon log)."
|
|
55774
|
+
}
|
|
55775
|
+
]
|
|
55776
|
+
};
|
|
55777
|
+
}
|
|
55778
|
+
/** Custom skill only: the public skill's shared Lambda sends one ingress token
|
|
55779
|
+
* for every hub (`HUB_INGRESS_TOKEN`), so a per-hub token cannot apply to it. */
|
|
55780
|
+
function customSkillSecuritySection(input) {
|
|
55781
|
+
return {
|
|
55782
|
+
id: "export-alexa-custom-security",
|
|
55783
|
+
title: "Custom-skill security",
|
|
55784
|
+
style: "accordion",
|
|
55785
|
+
defaultCollapsed: true,
|
|
55786
|
+
columns: 1,
|
|
55787
|
+
fields: [
|
|
55788
|
+
{
|
|
55789
|
+
type: "info",
|
|
55790
|
+
key: "__ingress-token-info",
|
|
55791
|
+
label: "Ingress token",
|
|
55792
|
+
variant: "info",
|
|
55793
|
+
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."
|
|
55794
|
+
},
|
|
55795
|
+
{
|
|
55796
|
+
type: "addon-action-button",
|
|
55797
|
+
addonId: ADDON_ID,
|
|
55798
|
+
key: "__reveal-ingress-token",
|
|
55799
|
+
label: "Show ingress token",
|
|
55800
|
+
description: "Administrators only. Shows the current token once, here.",
|
|
55801
|
+
buttonLabel: "Show ingress token",
|
|
55802
|
+
action: "revealIngressToken",
|
|
55803
|
+
variant: "default",
|
|
55804
|
+
successMessage: "Ingress token: {ingressToken}"
|
|
55805
|
+
},
|
|
55806
|
+
{
|
|
55807
|
+
type: "addon-action-button",
|
|
55808
|
+
addonId: ADDON_ID,
|
|
55809
|
+
key: "__regenerate-ingress-token",
|
|
55810
|
+
label: "Rotate ingress token",
|
|
55811
|
+
description: "Generates a new ingress token; the old one stops working. Update your Lambda's HUB_INGRESS_TOKEN afterwards.",
|
|
55812
|
+
buttonLabel: "Regenerate token",
|
|
55813
|
+
action: "regenerateIngressToken",
|
|
55814
|
+
variant: "default",
|
|
55815
|
+
successMessage: "New ingress token: {ingressToken} — put it in HUB_INGRESS_TOKEN now.",
|
|
55816
|
+
confirmMessage: "Rotate the ingress token? With \"Require ingress token\" on, the Lambda is refused until you update HUB_INGRESS_TOKEN."
|
|
55817
|
+
},
|
|
55818
|
+
{
|
|
55819
|
+
type: "boolean",
|
|
55820
|
+
key: "enforceIngressToken",
|
|
55821
|
+
label: "Require ingress token",
|
|
55822
|
+
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.",
|
|
55823
|
+
default: input.defaults.enforceIngressToken
|
|
55824
|
+
}
|
|
55825
|
+
]
|
|
55826
|
+
};
|
|
55827
|
+
}
|
|
55828
|
+
function buildAlexaSettingsSections(input) {
|
|
55829
|
+
return [
|
|
55830
|
+
connectionSection(input),
|
|
55831
|
+
featuresSection(input),
|
|
55832
|
+
accountLinkSection(input),
|
|
55833
|
+
troubleshootingSection(),
|
|
55834
|
+
...input.skillMode === "custom" ? [customSkillSecuritySection(input)] : []
|
|
55835
|
+
];
|
|
55836
|
+
}
|
|
55837
|
+
//#endregion
|
|
54714
55838
|
//#region src/access/account-visibility.ts
|
|
54715
55839
|
/** Event-path gate. Unknown account ⇒ refused (fail closed). */
|
|
54716
55840
|
function visibilityAllows(entry, deviceId) {
|
|
@@ -54864,121 +55988,6 @@ async function applyVisibilityChanges(changes, target, logger) {
|
|
|
54864
55988
|
}
|
|
54865
55989
|
}
|
|
54866
55990
|
}
|
|
54867
|
-
//#endregion
|
|
54868
|
-
//#region src/detection-sensors/detection-tracker.ts
|
|
54869
|
-
/**
|
|
54870
|
-
* Which detection classes are currently present on each camera (R1, D772).
|
|
54871
|
-
*
|
|
54872
|
-
* Fed by the pipeline's track lifecycle (`PipelineAnalyticsTrackLifecycle`,
|
|
54873
|
-
* `data.deviceId` — these events carry no device on their `source`) and by
|
|
54874
|
-
* package deliveries. A class is present while at least one of ITS tracks is
|
|
54875
|
-
* open: the set is per `(camera, class)` and per `trackId`, so the end of one
|
|
54876
|
-
* person's track does not clear "Person" while another person is still in
|
|
54877
|
-
* view (the HA export's per-event flip has exactly that bug).
|
|
54878
|
-
*
|
|
54879
|
-
* Events are telemetry and may be dropped (D8), and the pipeline does not
|
|
54880
|
-
* promise an `end` for every `start` (a zero-media false birth or a discarded
|
|
54881
|
-
* stale track ends silently). So {@link reconcile} replaces a camera's sets
|
|
54882
|
-
* with an authoritative read of its ACTIVE tracks; a sensor can therefore never
|
|
54883
|
-
* stay "detected" past one reconcile after its last track closed. A read that
|
|
54884
|
-
* fails is not passed in — the sets then stay as they were.
|
|
54885
|
-
*
|
|
54886
|
-
* Emits a transition per `(camera, class)` only when presence changes; the
|
|
54887
|
-
* Alexa-facing hold (≥ 30 s DETECTED before NOT_DETECTED) is the gateway's.
|
|
54888
|
-
*/
|
|
54889
|
-
/** A package delivery is an instant, not a track: it is present this long. */
|
|
54890
|
-
var PACKAGE_PULSE_MS = 1e3;
|
|
54891
|
-
/** The detection class a pipeline class name belongs to, or null. Pipeline
|
|
54892
|
-
* classes are already macros; a raw COCO label is mapped the same way the
|
|
54893
|
-
* executor maps it. */
|
|
54894
|
-
function detectionClassOf(className) {
|
|
54895
|
-
if (className === void 0) return null;
|
|
54896
|
-
const lower = className.toLowerCase();
|
|
54897
|
-
const isClass = (v) => DETECTION_SENSOR_CLASSES.includes(v);
|
|
54898
|
-
if (isClass(lower)) return lower;
|
|
54899
|
-
const mapped = COCO_TO_MACRO.mapping[lower];
|
|
54900
|
-
return mapped !== void 0 && isClass(mapped) ? mapped : null;
|
|
54901
|
-
}
|
|
54902
|
-
var DetectionTracker = class {
|
|
54903
|
-
onTransition;
|
|
54904
|
-
/** `${deviceId}|${class}` → open track ids. */
|
|
54905
|
-
open = /* @__PURE__ */ new Map();
|
|
54906
|
-
pulseTimers = /* @__PURE__ */ new Set();
|
|
54907
|
-
constructor(onTransition) {
|
|
54908
|
-
this.onTransition = onTransition;
|
|
54909
|
-
}
|
|
54910
|
-
onTrackLifecycle(event) {
|
|
54911
|
-
const cls = detectionClassOf(event.bestClassName ?? event.classes?.[0]);
|
|
54912
|
-
if (cls === null) return;
|
|
54913
|
-
if (event.phase === "end") {
|
|
54914
|
-
this.remove(event.deviceId, cls, event.trackId);
|
|
54915
|
-
for (const other of DETECTION_SENSOR_CLASSES) if (other !== cls) this.remove(event.deviceId, other, event.trackId);
|
|
54916
|
-
return;
|
|
54917
|
-
}
|
|
54918
|
-
this.add(event.deviceId, cls, event.trackId);
|
|
54919
|
-
}
|
|
54920
|
-
/** A package was delivered: "Package" is detected, as a pulse. */
|
|
54921
|
-
onPackageDelivered(deviceId, entryId) {
|
|
54922
|
-
const id = `package:${entryId}`;
|
|
54923
|
-
this.add(deviceId, "package", id);
|
|
54924
|
-
const timer = setTimeout(() => {
|
|
54925
|
-
this.pulseTimers.delete(timer);
|
|
54926
|
-
this.remove(deviceId, "package", id);
|
|
54927
|
-
}, PACKAGE_PULSE_MS);
|
|
54928
|
-
timer.unref?.();
|
|
54929
|
-
this.pulseTimers.add(timer);
|
|
54930
|
-
}
|
|
54931
|
-
/** Replace a camera's open tracks with what the pipeline says is ACTIVE. */
|
|
54932
|
-
reconcile(deviceId, active) {
|
|
54933
|
-
const truth = /* @__PURE__ */ new Map();
|
|
54934
|
-
for (const track of active) {
|
|
54935
|
-
const cls = detectionClassOf(track.className);
|
|
54936
|
-
if (cls === null) continue;
|
|
54937
|
-
const set = truth.get(cls) ?? /* @__PURE__ */ new Set();
|
|
54938
|
-
set.add(track.trackId);
|
|
54939
|
-
truth.set(cls, set);
|
|
54940
|
-
}
|
|
54941
|
-
for (const cls of DETECTION_SENSOR_CLASSES) {
|
|
54942
|
-
const key = `${deviceId}|${cls}`;
|
|
54943
|
-
const before = this.open.get(key) ?? /* @__PURE__ */ new Set();
|
|
54944
|
-
const after = truth.get(cls) ?? /* @__PURE__ */ new Set();
|
|
54945
|
-
for (const id of before) if (id.startsWith("package:")) after.add(id);
|
|
54946
|
-
this.replace(deviceId, cls, before, after);
|
|
54947
|
-
}
|
|
54948
|
-
}
|
|
54949
|
-
/** Cameras with at least one class present — the ones worth reconciling. */
|
|
54950
|
-
camerasWithPresence() {
|
|
54951
|
-
const out = /* @__PURE__ */ new Set();
|
|
54952
|
-
for (const [key, set] of this.open) if (set.size > 0) out.add(Number(key.split("|")[0]));
|
|
54953
|
-
return [...out];
|
|
54954
|
-
}
|
|
54955
|
-
isPresent(deviceId, cls) {
|
|
54956
|
-
return (this.open.get(`${deviceId}|${cls}`)?.size ?? 0) > 0;
|
|
54957
|
-
}
|
|
54958
|
-
clear() {
|
|
54959
|
-
this.open.clear();
|
|
54960
|
-
for (const t of this.pulseTimers) clearTimeout(t);
|
|
54961
|
-
this.pulseTimers.clear();
|
|
54962
|
-
}
|
|
54963
|
-
add(deviceId, cls, id) {
|
|
54964
|
-
const key = `${deviceId}|${cls}`;
|
|
54965
|
-
const set = this.open.get(key) ?? /* @__PURE__ */ new Set();
|
|
54966
|
-
const was = set.size > 0;
|
|
54967
|
-
set.add(id);
|
|
54968
|
-
this.open.set(key, set);
|
|
54969
|
-
if (!was) this.onTransition(deviceId, cls, true);
|
|
54970
|
-
}
|
|
54971
|
-
remove(deviceId, cls, id) {
|
|
54972
|
-
const set = this.open.get(`${deviceId}|${cls}`);
|
|
54973
|
-
if (set === void 0 || !set.delete(id)) return;
|
|
54974
|
-
if (set.size === 0) this.onTransition(deviceId, cls, false);
|
|
54975
|
-
}
|
|
54976
|
-
replace(deviceId, cls, before, after) {
|
|
54977
|
-
this.open.set(`${deviceId}|${cls}`, after);
|
|
54978
|
-
if (before.size > 0 && after.size === 0) this.onTransition(deviceId, cls, false);
|
|
54979
|
-
else if (before.size === 0 && after.size > 0) this.onTransition(deviceId, cls, true);
|
|
54980
|
-
}
|
|
54981
|
-
};
|
|
54982
55991
|
/** The detection classes an event-kind answer offers. */
|
|
54983
55992
|
function detectionClassesOfKinds(kinds) {
|
|
54984
55993
|
const out = /* @__PURE__ */ new Set();
|
|
@@ -55057,7 +56066,7 @@ var DetectionClassSource = class {
|
|
|
55057
56066
|
//#endregion
|
|
55058
56067
|
//#region src/detection-sensors/detection-feed.ts
|
|
55059
56068
|
var DETECTION_RECONCILE_INTERVAL_MS = 6e4;
|
|
55060
|
-
var TrackLifecycleSchema = object({
|
|
56069
|
+
var TrackLifecycleSchema$1 = object({
|
|
55061
56070
|
deviceId: number(),
|
|
55062
56071
|
trackId: string(),
|
|
55063
56072
|
phase: _enum([
|
|
@@ -55085,7 +56094,7 @@ var DetectionSensorFeed = class {
|
|
|
55085
56094
|
}
|
|
55086
56095
|
start() {
|
|
55087
56096
|
this.offs.push(this.deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
|
|
55088
|
-
const parsed = TrackLifecycleSchema.safeParse(event.data);
|
|
56097
|
+
const parsed = TrackLifecycleSchema$1.safeParse(event.data);
|
|
55089
56098
|
if (parsed.success) this.tracker.onTrackLifecycle(parsed.data);
|
|
55090
56099
|
}), this.deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsPackageDelivered }, (event) => {
|
|
55091
56100
|
const parsed = PackageDeliveredSchema.safeParse(event.data);
|
|
@@ -55130,6 +56139,388 @@ var DetectionSensorFeed = class {
|
|
|
55130
56139
|
}
|
|
55131
56140
|
};
|
|
55132
56141
|
//#endregion
|
|
56142
|
+
//#region src/detection-sensors/sensor-selection.ts
|
|
56143
|
+
/** Per-device store key of the saved selection. */
|
|
56144
|
+
var SENSOR_SELECTION_KEY = "alexaDetectionSensorClasses";
|
|
56145
|
+
var LABELS = {
|
|
56146
|
+
person: "Person",
|
|
56147
|
+
vehicle: "Vehicle",
|
|
56148
|
+
animal: "Animal",
|
|
56149
|
+
package: "Package"
|
|
56150
|
+
};
|
|
56151
|
+
function isClass(value) {
|
|
56152
|
+
return typeof value === "string" && DETECTION_SENSOR_CLASSES.includes(value);
|
|
56153
|
+
}
|
|
56154
|
+
/** The saved selection in a per-device store blob; `null` = never saved. */
|
|
56155
|
+
function parseSensorSelection(store) {
|
|
56156
|
+
const raw = store[SENSOR_SELECTION_KEY];
|
|
56157
|
+
if (!Array.isArray(raw)) return null;
|
|
56158
|
+
return DETECTION_SENSOR_CLASSES.filter((c) => raw.includes(c));
|
|
56159
|
+
}
|
|
56160
|
+
/** A patch value from the multiselect, cleaned to known classes. */
|
|
56161
|
+
function sensorSelectionFromPatch(value) {
|
|
56162
|
+
if (!Array.isArray(value)) return [];
|
|
56163
|
+
const picked = new Set(value.filter(isClass));
|
|
56164
|
+
return DETECTION_SENSOR_CLASSES.filter((c) => picked.has(c));
|
|
56165
|
+
}
|
|
56166
|
+
/** The classes a camera publishes a sensor for: offered ∩ saved, in the
|
|
56167
|
+
* canonical order. Never saved ⇒ none. */
|
|
56168
|
+
function effectiveSensorClasses(available, saved) {
|
|
56169
|
+
if (saved === null) return [];
|
|
56170
|
+
return DETECTION_SENSOR_CLASSES.filter((c) => available.includes(c) && saved.includes(c));
|
|
56171
|
+
}
|
|
56172
|
+
/** The device-settings field key — namespaced per exporter (see the addon). */
|
|
56173
|
+
function sensorSelectionKey(deviceId) {
|
|
56174
|
+
return `alexa:${deviceId}:detectionSensorClasses`;
|
|
56175
|
+
}
|
|
56176
|
+
/** The camera's "Detection sensors in Alexa" field: a multiselect over the
|
|
56177
|
+
* classes the camera produces, or an info line saying why there is none. */
|
|
56178
|
+
function sensorSelectionField(input) {
|
|
56179
|
+
const key = sensorSelectionKey(input.deviceId);
|
|
56180
|
+
const showWhen = {
|
|
56181
|
+
field: input.enabledKey,
|
|
56182
|
+
equals: true
|
|
56183
|
+
};
|
|
56184
|
+
if (!input.masterOn) return {
|
|
56185
|
+
type: "info",
|
|
56186
|
+
key: `${key}:off`,
|
|
56187
|
+
label: "Detection sensors in Alexa",
|
|
56188
|
+
content: "Detection sensors are switched off for the whole Alexa integration (Alexa settings → Detection sensors).",
|
|
56189
|
+
variant: "info",
|
|
56190
|
+
showWhen
|
|
56191
|
+
};
|
|
56192
|
+
if (input.available.length === 0) return {
|
|
56193
|
+
type: "info",
|
|
56194
|
+
key: `${key}:none`,
|
|
56195
|
+
label: "Detection sensors in Alexa",
|
|
56196
|
+
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.",
|
|
56197
|
+
variant: "info",
|
|
56198
|
+
showWhen
|
|
56199
|
+
};
|
|
56200
|
+
return {
|
|
56201
|
+
type: "multiselect",
|
|
56202
|
+
key,
|
|
56203
|
+
label: "Detection sensors in Alexa",
|
|
56204
|
+
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.",
|
|
56205
|
+
options: input.available.map((c) => ({
|
|
56206
|
+
value: c,
|
|
56207
|
+
label: LABELS[c]
|
|
56208
|
+
})),
|
|
56209
|
+
value: input.available.filter((c) => (input.saved ?? []).includes(c)),
|
|
56210
|
+
showWhen,
|
|
56211
|
+
immediate: true
|
|
56212
|
+
};
|
|
56213
|
+
}
|
|
56214
|
+
/** Bound on the per-track bookkeeping should `end` events be lost (D8). */
|
|
56215
|
+
var MAX_TRACKED = 5e3;
|
|
56216
|
+
var TrackLifecycleSchema = object({
|
|
56217
|
+
deviceId: number(),
|
|
56218
|
+
trackId: string(),
|
|
56219
|
+
phase: _enum([
|
|
56220
|
+
"start",
|
|
56221
|
+
"update",
|
|
56222
|
+
"end"
|
|
56223
|
+
]),
|
|
56224
|
+
bestClassName: string().optional(),
|
|
56225
|
+
classes: array(string()).optional()
|
|
56226
|
+
});
|
|
56227
|
+
function isVisionClass(cls) {
|
|
56228
|
+
return cls === "person" || cls === "package";
|
|
56229
|
+
}
|
|
56230
|
+
var ObjectDetectionFeed = class {
|
|
56231
|
+
deps;
|
|
56232
|
+
/** `${deviceId}|${trackId}|${class}` already handled → insertion time. */
|
|
56233
|
+
handled = /* @__PURE__ */ new Map();
|
|
56234
|
+
/** objectIdentifier → last event time. */
|
|
56235
|
+
lastPosted = /* @__PURE__ */ new Map();
|
|
56236
|
+
off = null;
|
|
56237
|
+
constructor(deps) {
|
|
56238
|
+
this.deps = deps;
|
|
56239
|
+
}
|
|
56240
|
+
start() {
|
|
56241
|
+
this.off = this.deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
|
|
56242
|
+
const parsed = TrackLifecycleSchema.safeParse(event.data);
|
|
56243
|
+
if (!parsed.success) return;
|
|
56244
|
+
this.onTrackLifecycle(parsed.data).catch((err) => {
|
|
56245
|
+
this.deps.logger.warn("alexa object detection: event failed", {
|
|
56246
|
+
tags: { deviceId: parsed.data.deviceId },
|
|
56247
|
+
meta: {
|
|
56248
|
+
trackId: parsed.data.trackId,
|
|
56249
|
+
error: errMsg(err)
|
|
56250
|
+
}
|
|
56251
|
+
});
|
|
56252
|
+
});
|
|
56253
|
+
});
|
|
56254
|
+
}
|
|
56255
|
+
stop() {
|
|
56256
|
+
this.off?.();
|
|
56257
|
+
this.off = null;
|
|
56258
|
+
this.handled.clear();
|
|
56259
|
+
this.lastPosted.clear();
|
|
56260
|
+
}
|
|
56261
|
+
/** Per-track entries currently held (tests, diagnostics). */
|
|
56262
|
+
trackedCount() {
|
|
56263
|
+
return this.handled.size;
|
|
56264
|
+
}
|
|
56265
|
+
async onTrackLifecycle(event) {
|
|
56266
|
+
const prefix = `${event.deviceId}|${event.trackId}|`;
|
|
56267
|
+
if (event.phase === "end") {
|
|
56268
|
+
for (const key of [...this.handled.keys()]) if (key.startsWith(prefix)) this.handled.delete(key);
|
|
56269
|
+
return;
|
|
56270
|
+
}
|
|
56271
|
+
const cls = detectionClassOf(event.bestClassName ?? event.classes?.[0]);
|
|
56272
|
+
if (!isVisionClass(cls)) return;
|
|
56273
|
+
const key = `${prefix}${cls}`;
|
|
56274
|
+
if (this.handled.has(key)) return;
|
|
56275
|
+
const now = this.deps.now?.() ?? Date.now();
|
|
56276
|
+
const namespace = this.deps.namespace();
|
|
56277
|
+
const objectIdentifier = objectIdentifierFor(namespace, event.deviceId, event.trackId);
|
|
56278
|
+
const last = this.lastPosted.get(objectIdentifier);
|
|
56279
|
+
if (last !== void 0 && now - last < 3e4) return;
|
|
56280
|
+
this.mark(key, now);
|
|
56281
|
+
if (!(await this.deps.reportableClasses(event.deviceId)).includes(cls)) return;
|
|
56282
|
+
const detection = {
|
|
56283
|
+
trackId: event.trackId,
|
|
56284
|
+
imageNetClass: cls,
|
|
56285
|
+
eventIdentifier: eventIdentifierFor(namespace, event.deviceId, event.trackId, cls),
|
|
56286
|
+
objectIdentifier,
|
|
56287
|
+
timestamp: now
|
|
56288
|
+
};
|
|
56289
|
+
this.lastPosted.set(objectIdentifier, now);
|
|
56290
|
+
this.pruneLastPosted(now);
|
|
56291
|
+
await this.deps.remember(event.deviceId, {
|
|
56292
|
+
e: detection.eventIdentifier,
|
|
56293
|
+
t: event.trackId,
|
|
56294
|
+
c: cls,
|
|
56295
|
+
at: now
|
|
56296
|
+
});
|
|
56297
|
+
await this.deps.post(event.deviceId, detection);
|
|
56298
|
+
}
|
|
56299
|
+
mark(key, now) {
|
|
56300
|
+
this.handled.set(key, now);
|
|
56301
|
+
if (this.handled.size <= MAX_TRACKED) return;
|
|
56302
|
+
const oldest = this.handled.keys().next().value;
|
|
56303
|
+
if (oldest !== void 0) this.handled.delete(oldest);
|
|
56304
|
+
}
|
|
56305
|
+
pruneLastPosted(now) {
|
|
56306
|
+
for (const [id, at] of this.lastPosted) if (now - at >= 3e4) this.lastPosted.delete(id);
|
|
56307
|
+
}
|
|
56308
|
+
};
|
|
56309
|
+
//#endregion
|
|
56310
|
+
//#region src/vision/vision-runtime.ts
|
|
56311
|
+
/** Per-device store key of the camera's object-detection toggle. */
|
|
56312
|
+
var OBJECT_DETECTION_KEY = "alexaObjectDetection";
|
|
56313
|
+
/** Clean scene first; the boxed frames never (they are annotated). */
|
|
56314
|
+
var FRAME_KINDS = [
|
|
56315
|
+
"keyFrameSmall",
|
|
56316
|
+
"sceneFrame",
|
|
56317
|
+
"keyFrame",
|
|
56318
|
+
"fullFrame"
|
|
56319
|
+
];
|
|
56320
|
+
var CROP_KINDS = [
|
|
56321
|
+
"thumbnailSmall",
|
|
56322
|
+
"thumbnail",
|
|
56323
|
+
"crop"
|
|
56324
|
+
];
|
|
56325
|
+
/** A live event whose track has no stored frame yet may show the camera's
|
|
56326
|
+
* current snapshot — but only this soon after the detection. Older history
|
|
56327
|
+
* never does: it would show a different moment. */
|
|
56328
|
+
var LIVE_FALLBACK_MS = 6e4;
|
|
56329
|
+
var MediaRowSchema = object({
|
|
56330
|
+
kind: string(),
|
|
56331
|
+
base64: string()
|
|
56332
|
+
});
|
|
56333
|
+
var SnapshotImageSchema = object({ base64: string().min(1) });
|
|
56334
|
+
var RecentTracksSchema = object({ tracks: array(object({
|
|
56335
|
+
trackId: string(),
|
|
56336
|
+
className: string(),
|
|
56337
|
+
firstSeen: number(),
|
|
56338
|
+
lastSeen: number()
|
|
56339
|
+
})) });
|
|
56340
|
+
var TrackSchema = object({
|
|
56341
|
+
trackId: string(),
|
|
56342
|
+
className: string(),
|
|
56343
|
+
firstSeen: number(),
|
|
56344
|
+
lastSeen: number()
|
|
56345
|
+
}).nullable();
|
|
56346
|
+
/** The classes a camera declares, from what its pipeline produces. */
|
|
56347
|
+
function declaredVisionClasses(available) {
|
|
56348
|
+
return available.includes("package") ? ["person", "package"] : ["person"];
|
|
56349
|
+
}
|
|
56350
|
+
/** The camera's toggle in a per-device store blob (absent = on). */
|
|
56351
|
+
function cameraObjectDetectionOn(store) {
|
|
56352
|
+
return store[OBJECT_DETECTION_KEY] !== false;
|
|
56353
|
+
}
|
|
56354
|
+
function objectDetectionKey(deviceId) {
|
|
56355
|
+
return `alexa:${deviceId}:objectDetection`;
|
|
56356
|
+
}
|
|
56357
|
+
/** The camera's "Object detection in Alexa" field. */
|
|
56358
|
+
function objectDetectionField(input) {
|
|
56359
|
+
const showWhen = {
|
|
56360
|
+
field: input.enabledKey,
|
|
56361
|
+
equals: true
|
|
56362
|
+
};
|
|
56363
|
+
if (!input.integrationOn) return {
|
|
56364
|
+
type: "info",
|
|
56365
|
+
key: `${objectDetectionKey(input.deviceId)}:off`,
|
|
56366
|
+
label: "Object detection in Alexa (experimental)",
|
|
56367
|
+
content: "Object detection is switched off for the whole Alexa integration (Alexa settings → Object detection).",
|
|
56368
|
+
variant: "info",
|
|
56369
|
+
showWhen
|
|
56370
|
+
};
|
|
56371
|
+
return {
|
|
56372
|
+
type: "boolean",
|
|
56373
|
+
key: objectDetectionKey(input.deviceId),
|
|
56374
|
+
label: "Object detection in Alexa (experimental)",
|
|
56375
|
+
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.",
|
|
56376
|
+
style: "switch",
|
|
56377
|
+
value: cameraObjectDetectionOn(input.store),
|
|
56378
|
+
showWhen,
|
|
56379
|
+
immediate: true
|
|
56380
|
+
};
|
|
56381
|
+
}
|
|
56382
|
+
var AlexaVisionRuntime = class {
|
|
56383
|
+
deps;
|
|
56384
|
+
state;
|
|
56385
|
+
feed;
|
|
56386
|
+
constructor(deps) {
|
|
56387
|
+
this.deps = deps;
|
|
56388
|
+
this.state = new VisionStateStore({
|
|
56389
|
+
read: (deviceId) => this.readPrefs(deviceId),
|
|
56390
|
+
write: (deviceId, patch) => this.writePrefs(deviceId, patch)
|
|
56391
|
+
});
|
|
56392
|
+
this.feed = new ObjectDetectionFeed({
|
|
56393
|
+
eventBus: deps.ctx.eventBus,
|
|
56394
|
+
reportableClasses: (deviceId) => this.reportable(deviceId),
|
|
56395
|
+
namespace: deps.namespace,
|
|
56396
|
+
remember: (deviceId, entry) => this.state.remember(deviceId, [entry]),
|
|
56397
|
+
post: (deviceId, detection) => deps.post({
|
|
56398
|
+
deviceId,
|
|
56399
|
+
detection,
|
|
56400
|
+
imagesFor: (userId) => this.imagesFor(userId, deviceId, detection.trackId, detection.timestamp)
|
|
56401
|
+
}),
|
|
56402
|
+
logger: deps.logger
|
|
56403
|
+
});
|
|
56404
|
+
}
|
|
56405
|
+
start() {
|
|
56406
|
+
this.feed.start();
|
|
56407
|
+
}
|
|
56408
|
+
stop() {
|
|
56409
|
+
this.feed.stop();
|
|
56410
|
+
}
|
|
56411
|
+
/** This addon's per-device store for a camera; `{}` when unreadable. */
|
|
56412
|
+
async readPrefs(deviceId) {
|
|
56413
|
+
try {
|
|
56414
|
+
return await this.deps.ctx.settings?.readDeviceStore(deviceId) ?? {};
|
|
56415
|
+
} catch (err) {
|
|
56416
|
+
this.deps.logger.debug("alexa: per-device settings unreadable — using defaults", {
|
|
56417
|
+
tags: { deviceId },
|
|
56418
|
+
meta: { error: errMsg(err) }
|
|
56419
|
+
});
|
|
56420
|
+
return {};
|
|
56421
|
+
}
|
|
56422
|
+
}
|
|
56423
|
+
async writePrefs(deviceId, patch) {
|
|
56424
|
+
const settings = this.deps.ctx.settings;
|
|
56425
|
+
if (settings === void 0) throw new Error("export-alexa: no per-device settings store");
|
|
56426
|
+
await settings.writeDeviceStore(deviceId, patch);
|
|
56427
|
+
this.state.invalidate(deviceId);
|
|
56428
|
+
}
|
|
56429
|
+
/** What the endpoint build declares for a camera, from a FRESH class read. */
|
|
56430
|
+
async buildClasses(deviceId, available) {
|
|
56431
|
+
if (!this.deps.integrationOn()) return [];
|
|
56432
|
+
if (!cameraObjectDetectionOn(await this.readPrefs(deviceId))) return [];
|
|
56433
|
+
return declaredVisionClasses(available);
|
|
56434
|
+
}
|
|
56435
|
+
/** What a camera declares right now, without reading its pipeline. */
|
|
56436
|
+
async declared(deviceId) {
|
|
56437
|
+
if (!this.deps.integrationOn() || !this.deps.isExposed(deviceId)) return [];
|
|
56438
|
+
if (!cameraObjectDetectionOn(await this.readPrefs(deviceId))) return [];
|
|
56439
|
+
return declaredVisionClasses(this.deps.knownClasses(deviceId));
|
|
56440
|
+
}
|
|
56441
|
+
async reportable(deviceId) {
|
|
56442
|
+
const declared = await this.declared(deviceId);
|
|
56443
|
+
if (declared.length === 0) return [];
|
|
56444
|
+
return reportableVisionClasses(declared, await this.state.get(deviceId));
|
|
56445
|
+
}
|
|
56446
|
+
/** Fresh, per-user, ten-minute image URIs for one event — or none. */
|
|
56447
|
+
imagesFor(userId, deviceId, trackId, at) {
|
|
56448
|
+
if (!this.deps.snapshotsOn()) return {};
|
|
56449
|
+
const base = this.deps.publicHubUrl().replace(/\/+$/, "");
|
|
56450
|
+
if (!base.startsWith("https://")) return {};
|
|
56451
|
+
const frame = this.deps.store.mintLazy({
|
|
56452
|
+
deviceId,
|
|
56453
|
+
userId,
|
|
56454
|
+
capturedAt: at,
|
|
56455
|
+
load: () => this.loadImage(deviceId, trackId, FRAME_KINDS, at)
|
|
56456
|
+
});
|
|
56457
|
+
const crop = this.deps.store.mintLazy({
|
|
56458
|
+
deviceId,
|
|
56459
|
+
userId,
|
|
56460
|
+
capturedAt: at,
|
|
56461
|
+
load: () => this.loadImage(deviceId, trackId, CROP_KINDS, null)
|
|
56462
|
+
});
|
|
56463
|
+
return {
|
|
56464
|
+
frameImageUri: `${base}${frame.path}`,
|
|
56465
|
+
croppedImageUri: `${base}${crop.path}`
|
|
56466
|
+
};
|
|
56467
|
+
}
|
|
56468
|
+
/** The directive handlers' view (`handlers/data-controller.ts`). */
|
|
56469
|
+
context() {
|
|
56470
|
+
const api = this.deps.ctx.api;
|
|
56471
|
+
return {
|
|
56472
|
+
ctx: this.deps.ctx,
|
|
56473
|
+
logger: this.deps.logger,
|
|
56474
|
+
vision: this.state,
|
|
56475
|
+
declaredClasses: (deviceId) => this.declared(deviceId),
|
|
56476
|
+
namespace: this.deps.namespace,
|
|
56477
|
+
listTracks: async (deviceId, q) => {
|
|
56478
|
+
return RecentTracksSchema.parse(await api.pipelineAnalytics.listRecentTracks.query({
|
|
56479
|
+
deviceIds: [deviceId],
|
|
56480
|
+
since: q.since,
|
|
56481
|
+
until: q.until,
|
|
56482
|
+
limit: q.limit,
|
|
56483
|
+
projection: "slim"
|
|
56484
|
+
})).tracks;
|
|
56485
|
+
},
|
|
56486
|
+
getTrack: async (deviceId, trackId) => TrackSchema.parse(await api.pipelineAnalytics.getTrack.query({
|
|
56487
|
+
deviceId,
|
|
56488
|
+
trackId
|
|
56489
|
+
})),
|
|
56490
|
+
imagesFor: (userId, deviceId, trackId, at) => this.imagesFor(userId, deviceId, trackId, at)
|
|
56491
|
+
};
|
|
56492
|
+
}
|
|
56493
|
+
async loadImage(deviceId, trackId, kinds, liveFallbackAt) {
|
|
56494
|
+
const encode = this.deps.encode ?? toAlexaJpeg;
|
|
56495
|
+
try {
|
|
56496
|
+
const rows = array(MediaRowSchema).parse(await this.deps.ctx.api.pipelineAnalytics.getTrackMedia.query({
|
|
56497
|
+
trackId,
|
|
56498
|
+
deviceId,
|
|
56499
|
+
kinds: [...kinds]
|
|
56500
|
+
}));
|
|
56501
|
+
for (const kind of kinds) {
|
|
56502
|
+
const row = rows.find((r) => r.kind === kind);
|
|
56503
|
+
if (row !== void 0) return (await encode(Buffer.from(row.base64, "base64"))).bytes;
|
|
56504
|
+
}
|
|
56505
|
+
const now = this.deps.now?.() ?? Date.now();
|
|
56506
|
+
if (liveFallbackAt !== null && now - liveFallbackAt < LIVE_FALLBACK_MS) {
|
|
56507
|
+
const snap = SnapshotImageSchema.safeParse(await this.deps.ctx.api.snapshot.getSnapshot.query({ deviceId }));
|
|
56508
|
+
if (snap.success) return (await encode(Buffer.from(snap.data.base64, "base64"))).bytes;
|
|
56509
|
+
}
|
|
56510
|
+
return null;
|
|
56511
|
+
} catch (err) {
|
|
56512
|
+
this.deps.logger.warn("alexa object detection: image read failed", {
|
|
56513
|
+
tags: { deviceId },
|
|
56514
|
+
meta: {
|
|
56515
|
+
trackId,
|
|
56516
|
+
error: errMsg(err)
|
|
56517
|
+
}
|
|
56518
|
+
});
|
|
56519
|
+
return null;
|
|
56520
|
+
}
|
|
56521
|
+
}
|
|
56522
|
+
};
|
|
56523
|
+
//#endregion
|
|
55133
56524
|
//#region src/oauth-integration.ts
|
|
55134
56525
|
/**
|
|
55135
56526
|
* The `export-alexa` OAuth client — the descriptor behind
|
|
@@ -55231,20 +56622,13 @@ function buildAlexaOauthIntegration(publicHubUrl) {
|
|
|
55231
56622
|
function buildAlexaSetupBlock(input) {
|
|
55232
56623
|
const base = input.publicHubUrl.replace(/\/+$/, "");
|
|
55233
56624
|
return {
|
|
55234
|
-
fields: [
|
|
55235
|
-
|
|
55236
|
-
|
|
55237
|
-
|
|
55238
|
-
|
|
55239
|
-
|
|
55240
|
-
|
|
55241
|
-
value: maskSecretTail(input.ingressToken)
|
|
55242
|
-
}] : [],
|
|
55243
|
-
{
|
|
55244
|
-
label: "Linked Alexa accounts",
|
|
55245
|
-
value: String(input.linkedAccountCount)
|
|
55246
|
-
}
|
|
55247
|
-
],
|
|
56625
|
+
fields: [...base ? [{
|
|
56626
|
+
label: "Directive endpoint",
|
|
56627
|
+
value: `${base}/addon/export-alexa/directive`
|
|
56628
|
+
}] : [], ...input.ingressToken && input.skillMode === "custom" ? [{
|
|
56629
|
+
label: "Ingress token (Lambda HUB_INGRESS_TOKEN) — use \"Show ingress token\" in settings",
|
|
56630
|
+
value: maskSecretTail(input.ingressToken)
|
|
56631
|
+
}] : []],
|
|
55248
56632
|
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."
|
|
55249
56633
|
};
|
|
55250
56634
|
}
|
|
@@ -55328,6 +56712,7 @@ var SnapshotStore = class {
|
|
|
55328
56712
|
secret;
|
|
55329
56713
|
now;
|
|
55330
56714
|
images = /* @__PURE__ */ new Map();
|
|
56715
|
+
loading = /* @__PURE__ */ new Map();
|
|
55331
56716
|
totalBytes = 0;
|
|
55332
56717
|
constructor(secret, now = Date.now) {
|
|
55333
56718
|
this.secret = secret;
|
|
@@ -55356,6 +56741,59 @@ var SnapshotStore = class {
|
|
|
55356
56741
|
path: `/addon/export-alexa/snapshot/${imageId}.jpg?exp=${expiresAt}&sig=${signExpiringUrl(this.secret(), imageId, expiresAt)}`
|
|
55357
56742
|
};
|
|
55358
56743
|
}
|
|
56744
|
+
/**
|
|
56745
|
+
* Mint a URL whose pixels are read on the first authorised fetch (R4,
|
|
56746
|
+
* D779). Costs nothing until then, so a history page can carry twenty image
|
|
56747
|
+
* URIs without reading twenty images inside Alexa's response budget. Bounded
|
|
56748
|
+
* by count like every entry; by bytes once loaded.
|
|
56749
|
+
*/
|
|
56750
|
+
mintLazy(input) {
|
|
56751
|
+
this.prune();
|
|
56752
|
+
const imageId = (0, node_crypto.randomUUID)();
|
|
56753
|
+
const expiresAt = this.now() + SNAPSHOT_URL_TTL_MS;
|
|
56754
|
+
this.images.set(imageId, {
|
|
56755
|
+
imageId,
|
|
56756
|
+
deviceId: input.deviceId,
|
|
56757
|
+
userId: input.userId,
|
|
56758
|
+
bytes: Buffer.alloc(0),
|
|
56759
|
+
load: input.load,
|
|
56760
|
+
contentType: "image/jpeg",
|
|
56761
|
+
capturedAt: input.capturedAt,
|
|
56762
|
+
expiresAt
|
|
56763
|
+
});
|
|
56764
|
+
this.evictOverBounds();
|
|
56765
|
+
return {
|
|
56766
|
+
imageId,
|
|
56767
|
+
expiresAt,
|
|
56768
|
+
path: `/addon/export-alexa/snapshot/${imageId}.jpg?exp=${expiresAt}&sig=${signExpiringUrl(this.secret(), imageId, expiresAt)}`
|
|
56769
|
+
};
|
|
56770
|
+
}
|
|
56771
|
+
/**
|
|
56772
|
+
* The bytes of a found image — loading a lazy one once (concurrent fetches
|
|
56773
|
+
* share the read). `null` when a lazy image has nothing to show.
|
|
56774
|
+
*/
|
|
56775
|
+
async bytesOf(image) {
|
|
56776
|
+
if (image.load === void 0) return image.bytes;
|
|
56777
|
+
let pending = this.loading.get(image.imageId);
|
|
56778
|
+
if (pending === void 0) {
|
|
56779
|
+
pending = image.load().catch(() => null);
|
|
56780
|
+
this.loading.set(image.imageId, pending);
|
|
56781
|
+
}
|
|
56782
|
+
const bytes = await pending;
|
|
56783
|
+
this.loading.delete(image.imageId);
|
|
56784
|
+
if (bytes === null) return null;
|
|
56785
|
+
const held = this.images.get(image.imageId);
|
|
56786
|
+
if (held !== void 0 && held.load !== void 0) {
|
|
56787
|
+
this.images.set(image.imageId, {
|
|
56788
|
+
...held,
|
|
56789
|
+
bytes,
|
|
56790
|
+
load: void 0
|
|
56791
|
+
});
|
|
56792
|
+
this.totalBytes += bytes.length;
|
|
56793
|
+
this.evictOverBounds();
|
|
56794
|
+
}
|
|
56795
|
+
return bytes;
|
|
56796
|
+
}
|
|
55359
56797
|
/** Resolve a fetch of a minted URL. */
|
|
55360
56798
|
lookup(imageId, exp, sig) {
|
|
55361
56799
|
const now = this.now();
|
|
@@ -55387,6 +56825,7 @@ var SnapshotStore = class {
|
|
|
55387
56825
|
/** Drop everything — the integration switch was turned off. */
|
|
55388
56826
|
clear() {
|
|
55389
56827
|
this.images.clear();
|
|
56828
|
+
this.loading.clear();
|
|
55390
56829
|
this.totalBytes = 0;
|
|
55391
56830
|
}
|
|
55392
56831
|
get size() {
|
|
@@ -55658,6 +57097,9 @@ var StateChangeReporter = class {
|
|
|
55658
57097
|
var PUBLIC_SKILL_ID = "amzn1.ask.skill.aa85d6df-1600-4f13-bda9-babcfdc849e6";
|
|
55659
57098
|
//#endregion
|
|
55660
57099
|
//#region src/sync-engine.ts
|
|
57100
|
+
function declaresObjectDetection(endpoint) {
|
|
57101
|
+
return endpoint.capabilities.some((c) => c.interface === "Alexa.SmartVision.ObjectDetectionSensor");
|
|
57102
|
+
}
|
|
55661
57103
|
/**
|
|
55662
57104
|
* Devices reconciled at once in {@link SyncEngine.reconcileAllExposed}. Each
|
|
55663
57105
|
* lane's device build is a handful of cross-process tRPC calls — latency, not
|
|
@@ -55797,9 +57239,25 @@ var SyncEngine = class {
|
|
|
55797
57239
|
});
|
|
55798
57240
|
});
|
|
55799
57241
|
if (visible.length === 0) return true;
|
|
55800
|
-
const
|
|
55801
|
-
|
|
55802
|
-
|
|
57242
|
+
const isolated = visible.filter(declaresObjectDetection);
|
|
57243
|
+
const rest = visible.filter((e) => !declaresObjectDetection(e));
|
|
57244
|
+
let allOk = true;
|
|
57245
|
+
for (const batch of [...isolated.map((e) => [e]), ...rest.length > 0 ? [rest] : []]) {
|
|
57246
|
+
const ok = await this.deps.postAddOrUpdate(userId, batch);
|
|
57247
|
+
for (const endpoint of batch) this.deps.logger.info("alexa sync: AddOrUpdateReport endpoint result", {
|
|
57248
|
+
tags: { deviceId },
|
|
57249
|
+
meta: {
|
|
57250
|
+
userId,
|
|
57251
|
+
endpointId: endpoint.endpointId,
|
|
57252
|
+
gatewayAccepted: ok,
|
|
57253
|
+
isolated: batch.length === 1 && isolated.includes(endpoint),
|
|
57254
|
+
interfaces: endpoint.capabilities.map((c) => c.instance === void 0 ? c.interface : `${c.interface}#${c.instance}`)
|
|
57255
|
+
}
|
|
57256
|
+
});
|
|
57257
|
+
if (ok) this.deps.afterPush(userId, batch);
|
|
57258
|
+
else allOk = false;
|
|
57259
|
+
}
|
|
57260
|
+
return allOk;
|
|
55803
57261
|
}
|
|
55804
57262
|
/** A device the account's user just gained (visibility mirror). */
|
|
55805
57263
|
async pushDeviceToAccount(userId, deviceId) {
|
|
@@ -55827,7 +57285,7 @@ var SyncEngine = class {
|
|
|
55827
57285
|
const accounts = this.deps.listLinkedAccounts();
|
|
55828
57286
|
const failures = [];
|
|
55829
57287
|
for (const account of accounts) if (!await this.deps.postDelete(account.userId, ids)) failures.push(`${account.userId}:${deviceId}`);
|
|
55830
|
-
await this.recordEndpoints(deviceId, [], null);
|
|
57288
|
+
if (failures.length === 0) await this.recordEndpoints(deviceId, [], null);
|
|
55831
57289
|
return {
|
|
55832
57290
|
deviceId,
|
|
55833
57291
|
endpointCount: ids.length,
|
|
@@ -55836,6 +57294,51 @@ var SyncEngine = class {
|
|
|
55836
57294
|
};
|
|
55837
57295
|
}
|
|
55838
57296
|
/**
|
|
57297
|
+
* The operator's Re-sync: re-send every exposed device (whatever the
|
|
57298
|
+
* signatures say), then delete from Alexa every device we still track but no
|
|
57299
|
+
* longer expose — an unexpose whose DeleteReport failed.
|
|
57300
|
+
*
|
|
57301
|
+
* Only devices ABSENT from the exposed list are deleted. That list is the
|
|
57302
|
+
* operator's persisted choice, not a read that can fail transiently; what an
|
|
57303
|
+
* exposed device happens not to build this time is never deleted here
|
|
57304
|
+
* (D49 / D767).
|
|
57305
|
+
*/
|
|
57306
|
+
async resync() {
|
|
57307
|
+
const push = await this.pushAllExposed();
|
|
57308
|
+
const exposed = new Set(this.deps.listExposed().map((e) => e.deviceId));
|
|
57309
|
+
const unexposed = Object.entries(this.deps.getExportedEndpoints()).map(([key, ids]) => ({
|
|
57310
|
+
deviceId: Number(key),
|
|
57311
|
+
ids
|
|
57312
|
+
})).filter((entry) => !exposed.has(entry.deviceId) && entry.ids.length > 0);
|
|
57313
|
+
const canDelete = push.accountCount > 0 && this.deps.hasProactiveCredentials();
|
|
57314
|
+
let removedEndpointCount = 0;
|
|
57315
|
+
let retainedEndpointCount = 0;
|
|
57316
|
+
let failedCount = push.failures.length;
|
|
57317
|
+
for (const { deviceId, ids } of unexposed) {
|
|
57318
|
+
if (!canDelete) {
|
|
57319
|
+
retainedEndpointCount += ids.length;
|
|
57320
|
+
continue;
|
|
57321
|
+
}
|
|
57322
|
+
const r = await this.removeDevice(deviceId);
|
|
57323
|
+
failedCount += r.failures.length;
|
|
57324
|
+
if (r.failures.length === 0) removedEndpointCount += ids.length;
|
|
57325
|
+
else retainedEndpointCount += ids.length;
|
|
57326
|
+
}
|
|
57327
|
+
if (unexposed.length > 0) this.deps.logger.info("alexa sync: re-sync cleaned up devices that are no longer exposed", { meta: {
|
|
57328
|
+
deviceIds: unexposed.map((u) => u.deviceId),
|
|
57329
|
+
removedEndpointCount,
|
|
57330
|
+
retainedEndpointCount
|
|
57331
|
+
} });
|
|
57332
|
+
return {
|
|
57333
|
+
deviceCount: push.deviceCount,
|
|
57334
|
+
endpointCount: push.endpointCount,
|
|
57335
|
+
accountCount: push.accountCount,
|
|
57336
|
+
removedEndpointCount,
|
|
57337
|
+
retainedEndpointCount,
|
|
57338
|
+
failedCount
|
|
57339
|
+
};
|
|
57340
|
+
}
|
|
57341
|
+
/**
|
|
55839
57342
|
* Re-push ONE exposed device if the endpoint shape it builds today differs
|
|
55840
57343
|
* from the shape Alexa was last told about.
|
|
55841
57344
|
*
|
|
@@ -56020,19 +57523,35 @@ var DEFAULT_CONFIG = {
|
|
|
56020
57523
|
exportedEndpoints: {},
|
|
56021
57524
|
exportedSignatures: {},
|
|
56022
57525
|
publicHubUrl: "",
|
|
56023
|
-
availableEndpoints: [],
|
|
56024
57526
|
ingressToken: "",
|
|
56025
57527
|
enforceIngressToken: false,
|
|
56026
|
-
staleEndpointPurgeInput: "",
|
|
56027
57528
|
accountVisibility: {},
|
|
56028
57529
|
detectionSensors: true,
|
|
56029
57530
|
detectionClasses: {},
|
|
56030
57531
|
snapshots: true,
|
|
56031
|
-
snapshotSigningKey: ""
|
|
57532
|
+
snapshotSigningKey: "",
|
|
57533
|
+
objectDetection: true,
|
|
57534
|
+
visionNamespace: ""
|
|
56032
57535
|
};
|
|
56033
57536
|
/** The one field of `oauthVerifyAccessToken`'s answer the snapshot route
|
|
56034
57537
|
* reads. */
|
|
56035
57538
|
var VerifiedTokenSchema = object({ userId: string() }).nullable();
|
|
57539
|
+
/**
|
|
57540
|
+
* Settings as the runtime reads them. In `skillMode: 'public'` the skill id is
|
|
57541
|
+
* the built-in skill's, and the ingress token is never required: the public
|
|
57542
|
+
* skill's Lambda is shared by every hub and sends one `HUB_INGRESS_TOKEN` for
|
|
57543
|
+
* all of them, so enforcing a per-hub token would refuse every directive. The
|
|
57544
|
+
* persisted values stay untouched, so switching back to `custom` restores
|
|
57545
|
+
* them. There is no credential to overlay any more (D753).
|
|
57546
|
+
*/
|
|
57547
|
+
function effectiveAlexaSettings(config) {
|
|
57548
|
+
if (config.skillMode !== "public") return config;
|
|
57549
|
+
return {
|
|
57550
|
+
...config,
|
|
57551
|
+
expectedAudience: PUBLIC_SKILL_ID,
|
|
57552
|
+
enforceIngressToken: false
|
|
57553
|
+
};
|
|
57554
|
+
}
|
|
56036
57555
|
/** UI filter for the Export picker. `alarm-panel` publishes an
|
|
56037
57556
|
* `Alexa.SecurityPanelController` endpoint (`endpoints/builder.ts`). */
|
|
56038
57557
|
var SUPPORTED_KINDS = [
|
|
@@ -56060,6 +57579,8 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56060
57579
|
* restart answers 410 and Alexa asks again. */
|
|
56061
57580
|
snapshotStore = new SnapshotStore(() => this.config.snapshotSigningKey);
|
|
56062
57581
|
detectionFeed = null;
|
|
57582
|
+
/** R4 (D779): object detection + event history. */
|
|
57583
|
+
visionRef = null;
|
|
56063
57584
|
stateReporter = null;
|
|
56064
57585
|
/** The switches whose change re-shapes every exposed endpoint, as last
|
|
56065
57586
|
* applied — `onConfigChanged` runs on EVERY settings write. */
|
|
@@ -56072,6 +57593,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56072
57593
|
async onInitialize() {
|
|
56073
57594
|
if (!this.config.ingressToken) await this.updateGlobalSettings({ ingressToken: generateIngressToken() });
|
|
56074
57595
|
if (!this.config.snapshotSigningKey) await this.updateGlobalSettings({ snapshotSigningKey: generateIngressToken() });
|
|
57596
|
+
if (!this.config.visionNamespace) await this.updateGlobalSettings({ visionNamespace: (0, node_crypto.randomUUID)() });
|
|
56075
57597
|
if (this.config.lwaClientSecret) {
|
|
56076
57598
|
await this.updateGlobalSettings({ lwaClientSecret: "" });
|
|
56077
57599
|
this.ctx.logger.warn("export-alexa: removed a stored LWA client secret — the hub no longer uses one; your skill Lambda holds it (D753)");
|
|
@@ -56095,6 +57617,18 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56095
57617
|
setStored: (next) => this.updateGlobalSettings({ accountVisibility: next }),
|
|
56096
57618
|
logger: this.ctx.logger
|
|
56097
57619
|
});
|
|
57620
|
+
this.visionRef = new AlexaVisionRuntime({
|
|
57621
|
+
ctx: this.ctx,
|
|
57622
|
+
logger: this.ctx.logger,
|
|
57623
|
+
integrationOn: () => this.config.objectDetection,
|
|
57624
|
+
snapshotsOn: () => this.config.snapshots,
|
|
57625
|
+
isExposed: (deviceId) => this.config.exposed.some((e) => e.deviceId === deviceId),
|
|
57626
|
+
knownClasses: (deviceId) => this.config.detectionClasses[deviceId]?.classes ?? [],
|
|
57627
|
+
namespace: () => this.config.visionNamespace,
|
|
57628
|
+
publicHubUrl: () => this.config.publicHubUrl,
|
|
57629
|
+
store: this.snapshotStore,
|
|
57630
|
+
post: (opts) => this.requireGateway().postObjectDetection(opts)
|
|
57631
|
+
});
|
|
56098
57632
|
const deviceExportProvider = {
|
|
56099
57633
|
getStatus: async () => ({
|
|
56100
57634
|
linkState: this.config.linkedAccounts.length > 0 ? "linked" : "unlinked",
|
|
@@ -56167,6 +57701,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56167
57701
|
enabled: () => this.config.snapshots,
|
|
56168
57702
|
publicHubUrl: () => this.config.publicHubUrl
|
|
56169
57703
|
},
|
|
57704
|
+
vision: this.vision.context(),
|
|
56170
57705
|
access: this.endpointAccess,
|
|
56171
57706
|
afterDiscovery: (userId, endpoints) => this.requireGateway().scheduleInitialConnectivity(userId, endpoints)
|
|
56172
57707
|
},
|
|
@@ -56226,6 +57761,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56226
57761
|
logger: this.ctx.logger
|
|
56227
57762
|
});
|
|
56228
57763
|
this.detectionFeed.start();
|
|
57764
|
+
this.vision.start();
|
|
56229
57765
|
this.appliedShapeSwitches = this.shapeSwitches();
|
|
56230
57766
|
this.stateReporter = new StateChangeReporter({
|
|
56231
57767
|
eventBus: this.ctx.eventBus,
|
|
@@ -56305,14 +57841,25 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56305
57841
|
],
|
|
56306
57842
|
customActions: exportAlexaActions,
|
|
56307
57843
|
actionHandlers: {
|
|
56308
|
-
|
|
57844
|
+
listExternalAddresses: async () => this.listExternalAddresses(),
|
|
56309
57845
|
regenerateIngressToken: async () => this.regenerateIngressToken(),
|
|
56310
57846
|
revealIngressToken: async () => {
|
|
56311
57847
|
this.ctx.logger.info("export-alexa: an administrator revealed the ingress token");
|
|
56312
57848
|
return { ingressToken: this.config.ingressToken };
|
|
56313
57849
|
},
|
|
56314
57850
|
resyncDiscovery: async () => this.resyncDiscovery(),
|
|
56315
|
-
|
|
57851
|
+
removeAlexaEndpoints: async ({ endpointIds }) => removeEndpointsFromAlexa({
|
|
57852
|
+
raw: endpointIds,
|
|
57853
|
+
purge: (ids) => this.requireGateway().purgeEndpoints(ids),
|
|
57854
|
+
getTracked: () => this.config.exportedEndpoints,
|
|
57855
|
+
setTracked: (next) => this.updateGlobalSettings({ exportedEndpoints: next })
|
|
57856
|
+
}).then((r) => {
|
|
57857
|
+
this.ctx.logger.info("export-alexa: operator removed endpoint ids from Alexa", { meta: {
|
|
57858
|
+
...r,
|
|
57859
|
+
endpointIds
|
|
57860
|
+
} });
|
|
57861
|
+
return r;
|
|
57862
|
+
}),
|
|
56316
57863
|
unlinkAlexaAccounts: async () => this.unlinkAlexaAccounts(),
|
|
56317
57864
|
rotateBrokerCredential: async () => this.rotateBrokerCredentials()
|
|
56318
57865
|
}
|
|
@@ -56321,6 +57868,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56321
57868
|
async onShutdown() {
|
|
56322
57869
|
this.detectionFeed?.stop();
|
|
56323
57870
|
this.detectionFeed = null;
|
|
57871
|
+
this.visionRef?.stop();
|
|
56324
57872
|
this.stateReporter?.stop();
|
|
56325
57873
|
this.stateReporter = null;
|
|
56326
57874
|
if (this.lwaGateway) {
|
|
@@ -56374,18 +57922,26 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56374
57922
|
*/
|
|
56375
57923
|
async buildEndpointsForOneDevice(deviceId) {
|
|
56376
57924
|
const classSource = this.detectionClassSource;
|
|
57925
|
+
const vision = this.vision;
|
|
57926
|
+
let available = null;
|
|
57927
|
+
const classesOf = (id) => {
|
|
57928
|
+
available ??= classSource === null ? Promise.resolve([]) : classSource.classesFor(id);
|
|
57929
|
+
return available;
|
|
57930
|
+
};
|
|
56377
57931
|
return buildEndpointsForExposedDevice(deviceId, {
|
|
56378
57932
|
ctx: this.ctx,
|
|
56379
57933
|
logger: this.ctx.logger,
|
|
56380
57934
|
snapshots: this.config.snapshots,
|
|
56381
|
-
...this.config.detectionSensors
|
|
57935
|
+
...this.config.detectionSensors ? { detectionClasses: async (id) => effectiveSensorClasses(await classesOf(id), parseSensorSelection(await vision.readPrefs(id))) } : {},
|
|
57936
|
+
...this.config.objectDetection ? { objectDetection: async (id) => vision.buildClasses(id, await classesOf(id)) } : {}
|
|
56382
57937
|
});
|
|
56383
57938
|
}
|
|
56384
57939
|
/** The settings that change every exposed endpoint's shape, as one key. */
|
|
56385
57940
|
shapeSwitches() {
|
|
56386
57941
|
return JSON.stringify({
|
|
56387
57942
|
detectionSensors: this.config.detectionSensors,
|
|
56388
|
-
snapshots: this.config.snapshots
|
|
57943
|
+
snapshots: this.config.snapshots,
|
|
57944
|
+
objectDetection: this.config.objectDetection
|
|
56389
57945
|
});
|
|
56390
57946
|
}
|
|
56391
57947
|
/**
|
|
@@ -56407,7 +57963,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56407
57963
|
}
|
|
56408
57964
|
scheduleDetectionClassRefresh() {
|
|
56409
57965
|
const timer = setInterval(() => {
|
|
56410
|
-
if (!this.config.detectionSensors) return;
|
|
57966
|
+
if (!this.config.detectionSensors && !this.config.objectDetection) return;
|
|
56411
57967
|
this.requireSyncEngine().reconcileAllExposed().catch((err) => {
|
|
56412
57968
|
this.ctx.logger.warn("export-alexa: detection-class refresh failed", { meta: { error: errMsg(err) } });
|
|
56413
57969
|
});
|
|
@@ -56415,6 +57971,10 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56415
57971
|
timer.unref?.();
|
|
56416
57972
|
this.ctx.addDisposer(async () => clearInterval(timer));
|
|
56417
57973
|
}
|
|
57974
|
+
get vision() {
|
|
57975
|
+
if (!this.visionRef) throw new Error("export-alexa: vision runtime not initialized");
|
|
57976
|
+
return this.visionRef;
|
|
57977
|
+
}
|
|
56418
57978
|
get visibility() {
|
|
56419
57979
|
if (!this.visibilityRef) throw new Error("export-alexa: visibility mirror not initialized");
|
|
56420
57980
|
return this.visibilityRef;
|
|
@@ -56539,49 +58099,17 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56539
58099
|
});
|
|
56540
58100
|
}
|
|
56541
58101
|
}
|
|
56542
|
-
async resyncDiscovery() {
|
|
56543
|
-
const r = await this.requireSyncEngine().pushAllExposed();
|
|
56544
|
-
this.ctx.logger.info("export-alexa: manual discovery re-sync", { meta: { ...r } });
|
|
56545
|
-
return {
|
|
56546
|
-
endpointCount: r.endpointCount,
|
|
56547
|
-
accountCount: r.accountCount
|
|
56548
|
-
};
|
|
56549
|
-
}
|
|
56550
58102
|
/**
|
|
56551
|
-
*
|
|
56552
|
-
*
|
|
56553
|
-
*
|
|
56554
|
-
* then clears the input field so a re-click is idempotent. Also drops
|
|
56555
|
-
* any `exportedEndpoints` device whose endpoint ids were all purged so a
|
|
56556
|
-
* later DeleteReport doesn't try to re-delete them.
|
|
58103
|
+
* The Re-sync button: every exposed device re-sent, and the devices still
|
|
58104
|
+
* tracked but no longer exposed deleted from Alexa (`SyncEngine.resync`).
|
|
58105
|
+
* Throws when nothing can be sent, so the button says why.
|
|
56557
58106
|
*/
|
|
56558
|
-
async
|
|
56559
|
-
|
|
56560
|
-
if (
|
|
56561
|
-
|
|
56562
|
-
|
|
56563
|
-
|
|
56564
|
-
};
|
|
56565
|
-
const result = await this.lwaGateway?.purgeEndpoints(ids) ?? {
|
|
56566
|
-
requested: ids.length,
|
|
56567
|
-
accountCount: 0,
|
|
56568
|
-
attemptedAccounts: 0
|
|
56569
|
-
};
|
|
56570
|
-
const purgedSet = new Set(ids);
|
|
56571
|
-
const nextEndpoints = {};
|
|
56572
|
-
for (const [deviceIdKey, endpointIds] of Object.entries(this.config.exportedEndpoints)) {
|
|
56573
|
-
const remaining = endpointIds.filter((id) => !purgedSet.has(id));
|
|
56574
|
-
if (remaining.length > 0) nextEndpoints[Number(deviceIdKey)] = remaining;
|
|
56575
|
-
}
|
|
56576
|
-
await this.updateGlobalSettings({
|
|
56577
|
-
staleEndpointPurgeInput: "",
|
|
56578
|
-
exportedEndpoints: nextEndpoints
|
|
56579
|
-
});
|
|
56580
|
-
this.ctx.logger.info("export-alexa: manual stale-endpoint purge", { meta: {
|
|
56581
|
-
...result,
|
|
56582
|
-
endpointIds: ids
|
|
56583
|
-
} });
|
|
56584
|
-
return result;
|
|
58107
|
+
async resyncDiscovery() {
|
|
58108
|
+
if (this.config.linkedAccounts.length === 0) throw new Error("No linked Amazon account. Link the skill from the Alexa app first.");
|
|
58109
|
+
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.");
|
|
58110
|
+
const r = await this.requireSyncEngine().resync();
|
|
58111
|
+
this.ctx.logger.info("export-alexa: manual discovery re-sync", { meta: { ...r } });
|
|
58112
|
+
return r;
|
|
56585
58113
|
}
|
|
56586
58114
|
/**
|
|
56587
58115
|
* Sever the Amazon account link. Backs the "Unlink Amazon account" button.
|
|
@@ -56611,7 +58139,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56611
58139
|
* Deliberately does NOT send a `DeleteReport` first. Amazon drops the
|
|
56612
58140
|
* endpoints itself when the account is unlinked, and a purge needs the very
|
|
56613
58141
|
* token we are about to discard — a best-effort push here would make the
|
|
56614
|
-
* result claim work it cannot verify. Use "
|
|
58142
|
+
* result claim work it cannot verify. Use "Remove ids from Alexa" if
|
|
56615
58143
|
* ghosts survive.
|
|
56616
58144
|
*/
|
|
56617
58145
|
async unlinkAlexaAccounts() {
|
|
@@ -56701,19 +58229,9 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56701
58229
|
});
|
|
56702
58230
|
if (missing.length > 0) this.ctx.logger.info("export-alexa: backfilled linked accounts from stored LWA tokens", { meta: { count: missing.length } });
|
|
56703
58231
|
}
|
|
56704
|
-
/**
|
|
56705
|
-
* Settings as the runtime should read them. In `skillMode: 'public'` the
|
|
56706
|
-
* skill id is swapped for the built-in CamStack skill's; the operator's own
|
|
56707
|
-
* `expectedAudience` stays untouched in the persisted config so switching
|
|
56708
|
-
* back to `custom` restores it. Everything else passes through. There is no
|
|
56709
|
-
* credential to overlay any more (D753).
|
|
56710
|
-
*/
|
|
58232
|
+
/** Settings as the runtime reads them (`effectiveAlexaSettings`). */
|
|
56711
58233
|
effectiveSettings() {
|
|
56712
|
-
|
|
56713
|
-
return {
|
|
56714
|
-
...this.config,
|
|
56715
|
-
expectedAudience: PUBLIC_SKILL_ID
|
|
56716
|
-
};
|
|
58234
|
+
return effectiveAlexaSettings(this.config);
|
|
56717
58235
|
}
|
|
56718
58236
|
/** Persist the broker origin the Lambda advertised on a verified directive. */
|
|
56719
58237
|
rememberAdvertisedBroker(raw) {
|
|
@@ -56746,6 +58264,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56746
58264
|
* broker no longer recognises is reported, not retried — re-link instead.
|
|
56747
58265
|
*/
|
|
56748
58266
|
async rotateBrokerCredentials() {
|
|
58267
|
+
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.");
|
|
56749
58268
|
let rotated = 0;
|
|
56750
58269
|
let failed = 0;
|
|
56751
58270
|
for (const t of this.config.tokens) {
|
|
@@ -56802,7 +58321,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56802
58321
|
if (fromList.length > 0) return fromList;
|
|
56803
58322
|
const single = await this.detectFromGetStatus();
|
|
56804
58323
|
if (single.length > 0) return [single];
|
|
56805
|
-
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
|
|
58324
|
+
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.");
|
|
56806
58325
|
return [];
|
|
56807
58326
|
}
|
|
56808
58327
|
/** Aggregate `listEndpoints` → every HTTPS entry's url, de-duplicated. */
|
|
@@ -56830,33 +58349,29 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56830
58349
|
}
|
|
56831
58350
|
}
|
|
56832
58351
|
/**
|
|
56833
|
-
* Boot-time best-effort:
|
|
56834
|
-
*
|
|
56835
|
-
*
|
|
58352
|
+
* Boot-time best-effort: default the external address to the first
|
|
58353
|
+
* detected endpoint when the operator hasn't chosen one. The picker reads
|
|
58354
|
+
* its options live, so nothing is cached here.
|
|
56836
58355
|
*/
|
|
56837
58356
|
async autoDetectPublicHubUrl() {
|
|
56838
|
-
|
|
56839
|
-
const
|
|
56840
|
-
|
|
56841
|
-
|
|
56842
|
-
|
|
56843
|
-
|
|
56844
|
-
} : { availableEndpoints: endpoints });
|
|
56845
|
-
if (defaulted) this.ctx.logger.info("export-alexa: defaulted public hub URL to first external address", { meta: { url: defaulted } });
|
|
58357
|
+
if (this.config.publicHubUrl) return;
|
|
58358
|
+
const defaulted = (await this.resolveEndpoints())[0];
|
|
58359
|
+
if (defaulted) {
|
|
58360
|
+
await this.updateGlobalSettings({ publicHubUrl: defaulted });
|
|
58361
|
+
this.ctx.logger.info("export-alexa: defaulted public hub URL to first external address", { meta: { url: defaulted } });
|
|
58362
|
+
}
|
|
56846
58363
|
}
|
|
56847
58364
|
/**
|
|
56848
|
-
* Re-scan the connected `network-access` providers
|
|
56849
|
-
*
|
|
56850
|
-
*
|
|
56851
|
-
* we landed on (empty
|
|
58365
|
+
* Re-scan the connected `network-access` providers and keep the
|
|
58366
|
+
* operator's current selection when it's still present (otherwise default
|
|
58367
|
+
* to the first). Runs when a tunnel connects and behind the
|
|
58368
|
+
* `/admin/redetect-hub-url` route. Returns the URL we landed on (empty
|
|
58369
|
+
* string when nothing is connected).
|
|
56852
58370
|
*/
|
|
56853
58371
|
async redetectPublicHubUrl() {
|
|
56854
58372
|
const endpoints = await this.resolveEndpoints();
|
|
56855
58373
|
const publicHubUrl = this.config.publicHubUrl.length > 0 && endpoints.includes(this.config.publicHubUrl) ? this.config.publicHubUrl : endpoints[0] ?? "";
|
|
56856
|
-
await this.updateGlobalSettings({
|
|
56857
|
-
availableEndpoints: endpoints,
|
|
56858
|
-
publicHubUrl
|
|
56859
|
-
});
|
|
58374
|
+
if (publicHubUrl !== this.config.publicHubUrl) await this.updateGlobalSettings({ publicHubUrl });
|
|
56860
58375
|
return { publicHubUrl };
|
|
56861
58376
|
}
|
|
56862
58377
|
/**
|
|
@@ -56884,224 +58399,36 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
56884
58399
|
return buildAlexaSetupBlock({
|
|
56885
58400
|
publicHubUrl: this.config.publicHubUrl,
|
|
56886
58401
|
ingressToken: this.config.ingressToken,
|
|
56887
|
-
|
|
58402
|
+
skillMode: this.config.skillMode
|
|
56888
58403
|
});
|
|
56889
58404
|
}
|
|
56890
|
-
/**
|
|
56891
|
-
*
|
|
56892
|
-
|
|
56893
|
-
|
|
56894
|
-
|
|
56895
|
-
|
|
56896
|
-
const
|
|
56897
|
-
|
|
56898
|
-
|
|
58405
|
+
/**
|
|
58406
|
+
* The "External address" picker's options, read live: every HTTPS endpoint
|
|
58407
|
+
* the connected remote-access providers report now, plus the saved choice
|
|
58408
|
+
* when it is not among them, so a manual pick is never dropped from view.
|
|
58409
|
+
*/
|
|
58410
|
+
async listExternalAddresses() {
|
|
58411
|
+
const detected = await this.resolveEndpoints();
|
|
58412
|
+
const current = this.config.publicHubUrl;
|
|
58413
|
+
const addresses = detected.map((url) => ({
|
|
58414
|
+
value: url,
|
|
58415
|
+
label: url
|
|
56899
58416
|
}));
|
|
56900
|
-
return
|
|
56901
|
-
value:
|
|
56902
|
-
label:
|
|
56903
|
-
}];
|
|
58417
|
+
return { addresses: current.length > 0 && !detected.includes(current) ? [...addresses, {
|
|
58418
|
+
value: current,
|
|
58419
|
+
label: `${current} (not detected now)`
|
|
58420
|
+
}] : addresses };
|
|
56904
58421
|
}
|
|
58422
|
+
/** The settings page: see `settings-schema.ts` for its layout. The account-
|
|
58423
|
+
* linking rows live in the `device-export` setup block instead. */
|
|
56905
58424
|
globalSettingsSchema() {
|
|
56906
|
-
return
|
|
56907
|
-
|
|
56908
|
-
|
|
56909
|
-
|
|
56910
|
-
|
|
56911
|
-
|
|
56912
|
-
|
|
56913
|
-
type: "info",
|
|
56914
|
-
key: "__setup-banner",
|
|
56915
|
-
label: "Setup overview",
|
|
56916
|
-
variant: "info",
|
|
56917
|
-
content: [
|
|
56918
|
-
"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).",
|
|
56919
|
-
"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.",
|
|
56920
|
-
"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).",
|
|
56921
|
-
"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."
|
|
56922
|
-
].join("\n")
|
|
56923
|
-
},
|
|
56924
|
-
this.field({
|
|
56925
|
-
type: "select",
|
|
56926
|
-
key: "publicHubUrl",
|
|
56927
|
-
label: "External address",
|
|
56928
|
-
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.",
|
|
56929
|
-
options: this.buildEndpointOptions(),
|
|
56930
|
-
default: DEFAULT_CONFIG.publicHubUrl
|
|
56931
|
-
}),
|
|
56932
|
-
{
|
|
56933
|
-
type: "button",
|
|
56934
|
-
key: "__redetect-hub-url",
|
|
56935
|
-
label: "External addresses",
|
|
56936
|
-
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.",
|
|
56937
|
-
buttonLabel: "Refresh external addresses",
|
|
56938
|
-
action: "redetectHubUrl",
|
|
56939
|
-
variant: "default"
|
|
56940
|
-
},
|
|
56941
|
-
this.field({
|
|
56942
|
-
type: "select",
|
|
56943
|
-
key: "skillMode",
|
|
56944
|
-
label: "Alexa skill",
|
|
56945
|
-
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.",
|
|
56946
|
-
options: [{
|
|
56947
|
-
value: "public",
|
|
56948
|
-
label: "Public (built-in CamStack skill)"
|
|
56949
|
-
}, {
|
|
56950
|
-
value: "custom",
|
|
56951
|
-
label: "Custom skill"
|
|
56952
|
-
}],
|
|
56953
|
-
default: DEFAULT_CONFIG.skillMode
|
|
56954
|
-
}),
|
|
56955
|
-
this.field({
|
|
56956
|
-
type: "select",
|
|
56957
|
-
key: "region",
|
|
56958
|
-
label: "Skill region",
|
|
56959
|
-
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.",
|
|
56960
|
-
options: [
|
|
56961
|
-
{
|
|
56962
|
-
value: "NA",
|
|
56963
|
-
label: "North America"
|
|
56964
|
-
},
|
|
56965
|
-
{
|
|
56966
|
-
value: "EU",
|
|
56967
|
-
label: "Europe"
|
|
56968
|
-
},
|
|
56969
|
-
{
|
|
56970
|
-
value: "FE",
|
|
56971
|
-
label: "Far East"
|
|
56972
|
-
}
|
|
56973
|
-
],
|
|
56974
|
-
default: DEFAULT_CONFIG.region
|
|
56975
|
-
}),
|
|
56976
|
-
this.field({
|
|
56977
|
-
type: "boolean",
|
|
56978
|
-
key: "detectionSensors",
|
|
56979
|
-
label: "Detection sensors",
|
|
56980
|
-
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.",
|
|
56981
|
-
default: DEFAULT_CONFIG.detectionSensors
|
|
56982
|
-
}),
|
|
56983
|
-
this.field({
|
|
56984
|
-
type: "boolean",
|
|
56985
|
-
key: "snapshots",
|
|
56986
|
-
label: "Snapshots in the Alexa app",
|
|
56987
|
-
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.",
|
|
56988
|
-
default: DEFAULT_CONFIG.snapshots
|
|
56989
|
-
}),
|
|
56990
|
-
...this.config.skillMode === "custom" ? [this.field({
|
|
56991
|
-
type: "text",
|
|
56992
|
-
key: "expectedAudience",
|
|
56993
|
-
label: "Alexa skill id",
|
|
56994
|
-
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.",
|
|
56995
|
-
default: DEFAULT_CONFIG.expectedAudience
|
|
56996
|
-
}), {
|
|
56997
|
-
type: "info",
|
|
56998
|
-
key: "__custom-skill-info",
|
|
56999
|
-
label: "Your skill Lambda",
|
|
57000
|
-
variant: "info",
|
|
57001
|
-
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."
|
|
57002
|
-
}] : [{
|
|
57003
|
-
type: "info",
|
|
57004
|
-
key: "__public-skill-info",
|
|
57005
|
-
label: "Built-in skill",
|
|
57006
|
-
variant: "info",
|
|
57007
|
-
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."
|
|
57008
|
-
}],
|
|
57009
|
-
{
|
|
57010
|
-
type: "info",
|
|
57011
|
-
key: "__alexa-link-status",
|
|
57012
|
-
label: "Account link",
|
|
57013
|
-
variant: "info",
|
|
57014
|
-
content: buildLinkStatusContent({
|
|
57015
|
-
linkedAccounts: this.config.linkedAccounts,
|
|
57016
|
-
tokens: this.config.tokens,
|
|
57017
|
-
now: Date.now()
|
|
57018
|
-
})
|
|
57019
|
-
},
|
|
57020
|
-
{
|
|
57021
|
-
type: "button",
|
|
57022
|
-
key: "__unlink-alexa-accounts",
|
|
57023
|
-
label: "Unlink Amazon account",
|
|
57024
|
-
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.",
|
|
57025
|
-
buttonLabel: "Unlink now",
|
|
57026
|
-
action: "unlinkAlexaAccounts",
|
|
57027
|
-
variant: "danger",
|
|
57028
|
-
confirmMessage: "Unlink the Amazon account? Alexa loses access to your cameras immediately and you must re-link from the Alexa app to restore it."
|
|
57029
|
-
},
|
|
57030
|
-
{
|
|
57031
|
-
type: "button",
|
|
57032
|
-
key: "__rotate-broker-credential",
|
|
57033
|
-
label: "Skill broker credential",
|
|
57034
|
-
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.",
|
|
57035
|
-
buttonLabel: "Rotate credential",
|
|
57036
|
-
action: "rotateBrokerCredential",
|
|
57037
|
-
variant: "default"
|
|
57038
|
-
},
|
|
57039
|
-
{
|
|
57040
|
-
type: "button",
|
|
57041
|
-
key: "__resync-discovery",
|
|
57042
|
-
label: "Sync devices to Alexa",
|
|
57043
|
-
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.",
|
|
57044
|
-
buttonLabel: "Re-sync devices now",
|
|
57045
|
-
action: "resyncDiscovery",
|
|
57046
|
-
variant: "default"
|
|
57047
|
-
},
|
|
57048
|
-
this.field({
|
|
57049
|
-
type: "textarea",
|
|
57050
|
-
key: "staleEndpointPurgeInput",
|
|
57051
|
-
label: "Purge stale Alexa endpoints",
|
|
57052
|
-
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.",
|
|
57053
|
-
rows: 3,
|
|
57054
|
-
default: DEFAULT_CONFIG.staleEndpointPurgeInput
|
|
57055
|
-
}),
|
|
57056
|
-
{
|
|
57057
|
-
type: "button",
|
|
57058
|
-
key: "__purge-stale-endpoints",
|
|
57059
|
-
label: "Purge endpoints",
|
|
57060
|
-
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.",
|
|
57061
|
-
buttonLabel: "Purge now",
|
|
57062
|
-
action: "purgeStaleEndpoints",
|
|
57063
|
-
variant: "danger",
|
|
57064
|
-
confirmMessage: "Send DeleteReport for these endpoint ids to all linked Alexa accounts? The action clears the input on success."
|
|
57065
|
-
},
|
|
57066
|
-
{
|
|
57067
|
-
type: "info",
|
|
57068
|
-
key: "__ingress-token-info",
|
|
57069
|
-
label: "Ingress token",
|
|
57070
|
-
variant: "info",
|
|
57071
|
-
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)."
|
|
57072
|
-
},
|
|
57073
|
-
{
|
|
57074
|
-
type: "addon-action-button",
|
|
57075
|
-
addonId: "export-alexa",
|
|
57076
|
-
key: "__reveal-ingress-token",
|
|
57077
|
-
label: "Show ingress token",
|
|
57078
|
-
description: "Administrators only. Shows the current token once, here.",
|
|
57079
|
-
buttonLabel: "Show ingress token",
|
|
57080
|
-
action: "revealIngressToken",
|
|
57081
|
-
variant: "default",
|
|
57082
|
-
successMessage: "Ingress token: {ingressToken}"
|
|
57083
|
-
},
|
|
57084
|
-
{
|
|
57085
|
-
type: "addon-action-button",
|
|
57086
|
-
addonId: "export-alexa",
|
|
57087
|
-
key: "__regenerate-ingress-token",
|
|
57088
|
-
label: "Rotate ingress token",
|
|
57089
|
-
description: "Generate a new ingress token, invalidating the old one. Update your Lambda's HUB_INGRESS_TOKEN afterwards.",
|
|
57090
|
-
buttonLabel: "Regenerate token",
|
|
57091
|
-
action: "regenerateIngressToken",
|
|
57092
|
-
variant: "default",
|
|
57093
|
-
successMessage: "New ingress token: {ingressToken} — paste it into HUB_INGRESS_TOKEN now.",
|
|
57094
|
-
confirmMessage: "Rotate the ingress token? With \"Require ingress token\" on, the Lambda is refused until you update HUB_INGRESS_TOKEN."
|
|
57095
|
-
},
|
|
57096
|
-
this.field({
|
|
57097
|
-
type: "boolean",
|
|
57098
|
-
key: "enforceIngressToken",
|
|
57099
|
-
label: "Require ingress token",
|
|
57100
|
-
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.",
|
|
57101
|
-
default: DEFAULT_CONFIG.enforceIngressToken
|
|
57102
|
-
})
|
|
57103
|
-
]
|
|
57104
|
-
}] });
|
|
58425
|
+
return { sections: buildAlexaSettingsSections({
|
|
58426
|
+
skillMode: this.config.skillMode,
|
|
58427
|
+
linkedAccounts: this.config.linkedAccounts,
|
|
58428
|
+
tokens: this.config.tokens,
|
|
58429
|
+
now: Date.now(),
|
|
58430
|
+
defaults: DEFAULT_CONFIG
|
|
58431
|
+
}) };
|
|
57105
58432
|
}
|
|
57106
58433
|
/**
|
|
57107
58434
|
* One sweep: ask the broker which held sessions are still alive, and release
|
|
@@ -57174,11 +58501,51 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
57174
58501
|
value: enabled,
|
|
57175
58502
|
immediate: true
|
|
57176
58503
|
},
|
|
57177
|
-
...kind === "camera" ? this.cameraOnlyExportFields(deviceId, settings, enabledKey) : []
|
|
58504
|
+
...kind === "camera" ? this.cameraOnlyExportFields(deviceId, settings, enabledKey) : [],
|
|
58505
|
+
...kind === "camera" ? await this.cameraDetectionFields(deviceId, enabledKey) : []
|
|
57178
58506
|
]
|
|
57179
58507
|
}]
|
|
57180
58508
|
};
|
|
57181
58509
|
}
|
|
58510
|
+
/**
|
|
58511
|
+
* The camera's detection fields: which classes become Alexa sensors (D778;
|
|
58512
|
+
* only the classes its pipeline produces are offered) and its object
|
|
58513
|
+
* detection toggle (D779).
|
|
58514
|
+
*/
|
|
58515
|
+
async cameraDetectionFields(deviceId, enabledKey) {
|
|
58516
|
+
const store = await this.vision.readPrefs(deviceId);
|
|
58517
|
+
return [sensorSelectionField({
|
|
58518
|
+
deviceId,
|
|
58519
|
+
available: this.detectionClassSource === null ? [] : await this.detectionClassSource.classesFor(deviceId).catch(() => []),
|
|
58520
|
+
saved: parseSensorSelection(store),
|
|
58521
|
+
enabledKey,
|
|
58522
|
+
masterOn: this.config.detectionSensors
|
|
58523
|
+
}), objectDetectionField({
|
|
58524
|
+
deviceId,
|
|
58525
|
+
store,
|
|
58526
|
+
enabledKey,
|
|
58527
|
+
integrationOn: this.config.objectDetection
|
|
58528
|
+
})];
|
|
58529
|
+
}
|
|
58530
|
+
/**
|
|
58531
|
+
* Persist the camera's detection preferences from a device-settings patch
|
|
58532
|
+
* into this addon's per-device store. True when anything changed — the
|
|
58533
|
+
* camera's Alexa shape then needs a reconcile.
|
|
58534
|
+
*/
|
|
58535
|
+
async applyDetectionPrefsPatch(deviceId, patch) {
|
|
58536
|
+
const selectionKey = sensorSelectionKey(deviceId);
|
|
58537
|
+
const odKey = objectDetectionKey(deviceId);
|
|
58538
|
+
const write = {};
|
|
58539
|
+
if (selectionKey in patch) write[SENSOR_SELECTION_KEY] = sensorSelectionFromPatch(patch[selectionKey]);
|
|
58540
|
+
if (odKey in patch) write[OBJECT_DETECTION_KEY] = Boolean(patch[odKey]);
|
|
58541
|
+
if (Object.keys(write).length === 0) return false;
|
|
58542
|
+
await this.vision.writePrefs(deviceId, write);
|
|
58543
|
+
this.ctx.logger.info("export-alexa: camera detection preferences saved", {
|
|
58544
|
+
tags: { deviceId },
|
|
58545
|
+
meta: { ...write }
|
|
58546
|
+
});
|
|
58547
|
+
return true;
|
|
58548
|
+
}
|
|
57182
58549
|
/** The stream fields of the Export section — a camera's only. */
|
|
57183
58550
|
cameraOnlyExportFields(deviceId, settings, enabledKey) {
|
|
57184
58551
|
const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
|
|
@@ -57224,6 +58591,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
57224
58591
|
const idStr = String(deviceId);
|
|
57225
58592
|
const current = this.findEntry(deviceId);
|
|
57226
58593
|
const wasEnabled = current !== null;
|
|
58594
|
+
const prefsChanged = await this.applyDetectionPrefsPatch(deviceId, patch);
|
|
57227
58595
|
const enabledKey = `alexa:${deviceId}:enabled`;
|
|
57228
58596
|
const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
|
|
57229
58597
|
const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
|
|
@@ -57248,7 +58616,14 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
57248
58616
|
const currentPref = current?.settings?.streamPreference ?? "auto";
|
|
57249
58617
|
const currentAllow = current?.settings?.allowTranscode !== false;
|
|
57250
58618
|
await this.patchEntrySettings(deviceId, nextSettings);
|
|
57251
|
-
|
|
58619
|
+
const sourceChanged = currentPref !== streamPreference || currentAllow !== allowTranscode;
|
|
58620
|
+
if (prefsChanged && !sourceChanged) this.requireSyncEngine().reconcileDevice(deviceId).catch((err) => {
|
|
58621
|
+
this.ctx.logger.warn("export-alexa: re-sync after a detection preference change failed", {
|
|
58622
|
+
tags: { deviceId },
|
|
58623
|
+
meta: { error: errMsg(err) }
|
|
58624
|
+
});
|
|
58625
|
+
});
|
|
58626
|
+
if (sourceChanged) {
|
|
57252
58627
|
this.ctx.logger.info("export-alexa: source settings changed — refreshing exposure", {
|
|
57253
58628
|
tags: { deviceId },
|
|
57254
58629
|
meta: {
|
|
@@ -57307,5 +58682,6 @@ exports.ALEXA_EGRESS_PROFILE = ALEXA_EGRESS_PROFILE;
|
|
|
57307
58682
|
exports.ExportAlexaAddon = ExportAlexaAddon;
|
|
57308
58683
|
exports.default = ExportAlexaAddon;
|
|
57309
58684
|
exports.acquireAlexaEgress = acquireAlexaEgress;
|
|
58685
|
+
exports.effectiveAlexaSettings = effectiveAlexaSettings;
|
|
57310
58686
|
exports.exportAlexaActions = exportAlexaActions;
|
|
57311
58687
|
exports.releaseAlexaEgress = releaseAlexaEgress;
|