@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.
@@ -5399,7 +5399,7 @@ var ZodIssueCode = {
5399
5399
  var ZodFirstPartyTypeKind;
5400
5400
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5401
5401
  //#endregion
5402
- //#region ../types/dist/sleep-BNeXenvr.mjs
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
- ...input.params,
8360
- ...match.params
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(object({ deviceIds: array(number()).optional() }), object({ url: string() }), {
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$1 = object({
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$1.nullable()), method(object({ deviceId: number() }), _void(), {
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(_void(), array(OauthSessionSummarySchema), { auth: "admin" }), method(object({ id: string() }), object({ success: boolean() }), {
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
- * Exposes addon operations the settings form drives via
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
- * redetectHubUrl — force-refresh the hub's public HTTPS URL from the
49416
- * connected `network-access` provider, clobbering any
49417
- * manual override. Backs the "Re-detect public hub
49418
- * URL" `type: 'button'` field on the settings form;
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
- redetectHubUrl: customAction(object({}).optional(), object({ publicHubUrl: string() }), {
49426
- kind: "mutation",
49427
- auth: "admin"
49428
- }),
49429
- regenerateIngressToken: customAction(object({}).optional(), object({ ingressToken: string() }), {
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
- resyncDiscovery: customAction(object({}).optional(), object({
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
- purgeStaleEndpoints: customAction(object({}).optional(), object({
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
- attemptedAccounts: number()
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(object({}).optional(), object({
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(object({}).optional(), object({ ingressToken: string() }), {
49676
+ revealIngressToken: customAction(NoInput, object({ ingressToken: string() }), {
49479
49677
  kind: "mutation",
49480
49678
  auth: "admin"
49481
49679
  }),
49482
- rotateBrokerCredential: customAction(object({}).optional(), object({
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 validateCapability(endpointId, c, out) {
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
- reply.code(200).type(image.contentType).header("cache-control", "private, no-store").header("content-encoding", "identity").header("content-length", String(image.bytes.byteLength));
53370
- reply.send(image.bytes);
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 — and which `buildParentCameraEndpoint` does NOT
53811
- * currently declare (the two `capabilities.push(SMART_VISION_*)` lines are
53812
- * commented out there, deliberately).
53813
- *
53814
- * Measured 2026-08-07: 223 `smartvision person posted status=202` in 692 s on
53815
- * endpoint 590 alone, every one of them against an endpoint whose discovered
53816
- * capability list has never contained this string. The Event Gateway answers
53817
- * 202 because the ENVELOPE parsed; the cloud then drops the event because the
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
- * Pair a reported DETECTED with a SmartVision `ObjectDetection` event — the
54084
- * event that populates the per-camera Events box in the Alexa app
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
- * **Gated on the endpoint actually declaring the interface, and today that
54088
- * gate is closed.** `buildParentCameraEndpoint` does not push
54089
- * `SMART_VISION_OBJECT_DETECTION_SENSOR`, so every one of these events was
54090
- * being accepted by the gateway (202) and discarded by the cloud. Posting
54091
- * into that is not harmless: it is ~1 POST/s per flapping camera against a
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
- * Re-opening this gate is a design item, not a toggle: see
54099
- * `docs/design/2026-08-07-alexa-events-and-hksv-assessment.md`.
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 === null || !signature.includes(SMART_VISION_INTERFACE)) {
54104
- const dropped = (this.smartVisionDropped.get(opts.endpointId) ?? 0) + 1;
54105
- this.smartVisionDropped.set(opts.endpointId, dropped);
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: opts.endpointId,
54960
+ endpointId,
54110
54961
  droppedSoFar: dropped,
54111
54962
  signature
54112
54963
  }
54113
54964
  });
54114
54965
  return;
54115
54966
  }
54116
- await this.postEventBody(opts.userId, opts.endpointId, (accessToken) => buildSmartVisionObjectDetectionEvent({
54117
- userId: opts.userId,
54118
- endpointId: opts.endpointId,
54119
- detections: [{
54120
- eventIdentifier: `${opts.endpointId}-${opts.timestamp}`,
54121
- imageNetClass: "person"
54122
- }],
54123
- timestamp: opts.timestamp,
54124
- accessToken
54125
- }), `smartvision person`);
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
- if (linked.length === 0) return {
54256
- requested: endpointIds.length,
54257
- accountCount: 0,
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
- attemptedAccounts
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
- ...base ? [{
55236
- label: "Directive endpoint",
55237
- value: `${base}/addon/export-alexa/directive`
55238
- }] : [],
55239
- ...input.ingressToken ? [{
55240
- label: "Ingress token (Lambda HUB_INGRESS_TOKEN) — use \"Show ingress token\" in settings",
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 ok = await this.deps.postAddOrUpdate(userId, visible);
55801
- if (ok) this.deps.afterPush(userId, visible);
55802
- return ok;
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
- redetectHubUrl: async () => this.redetectPublicHubUrl(),
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
- purgeStaleEndpoints: async () => this.purgeStaleEndpoints(),
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 && classSource !== null ? { detectionClasses: (id) => classSource.classesFor(id) } : {}
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
- * Manual "Purge stale Alexa endpoints" button. Reads the comma/newline/
56552
- * whitespace-separated id list the operator pasted into
56553
- * `staleEndpointPurgeInput`, POSTs a `DeleteReport` per linked account,
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 purgeStaleEndpoints() {
56559
- const ids = this.config.staleEndpointPurgeInput.split(/[\s,]+/).map((s) => s.trim()).filter((s) => s.length > 0);
56560
- if (ids.length === 0) return {
56561
- requested: 0,
56562
- accountCount: 0,
56563
- attemptedAccounts: 0
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 "Purge stale Alexa endpoints" if
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
- if (this.config.skillMode !== "public") return this.config;
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 and press \"Refresh external addresses\" on the Alexa addon page.");
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: refresh the cached endpoint list that backs the
56834
- * "External address" picker, and default the selection to the first
56835
- * endpoint only when the operator hasn't already chosen one.
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
- const endpoints = await this.resolveEndpoints();
56839
- const first = endpoints[0];
56840
- const defaulted = !this.config.publicHubUrl && first ? first : void 0;
56841
- await this.updateGlobalSettings(defaulted ? {
56842
- availableEndpoints: endpoints,
56843
- publicHubUrl: defaulted
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, refresh the cached
56849
- * endpoint list for the picker, and keep the operator's current selection
56850
- * when it's still present (otherwise default to the first). Returns the URL
56851
- * we landed on (empty string when nothing is connected).
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
- linkedAccountCount: this.config.linkedAccounts.length
58402
+ skillMode: this.config.skillMode
56888
58403
  });
56889
58404
  }
56890
- /** Options for the "External address" picker: the cached HTTPS endpoints
56891
- * plus the current selection (so a stale/custom value is never dropped). */
56892
- buildEndpointOptions() {
56893
- const urls = /* @__PURE__ */ new Set();
56894
- for (const u of this.config.availableEndpoints) if (u.length > 0) urls.add(u);
56895
- if (this.config.publicHubUrl.length > 0) urls.add(this.config.publicHubUrl);
56896
- const opts = [...urls].map((u) => ({
56897
- value: u,
56898
- label: u
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 opts.length > 0 ? opts : [{
56901
- value: "",
56902
- label: "(no external address detected — set up remote-access, then Refresh)"
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 this.schema({ sections: [{
56907
- id: "export-alexa",
56908
- title: "Alexa Smart Home export",
56909
- description: "Hub-side OAuth provider + Smart Home directive endpoint. Pairs with a user-owned AWS Lambda that forwards Alexa directives to this addon.",
56910
- columns: 1,
56911
- fields: [
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
- if (currentPref !== streamPreference || currentAllow !== allowTranscode) {
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;