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