@camstack/addon-import-alexa 0.2.148 → 0.2.150

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/assets/alexa-echo.LICENSE.md +13 -0
  3. package/assets/alexa-echo.svg +1 -0
  4. package/dist/MaskShapeCanvas-B6q5RDLi.mjs +13807 -0
  5. package/dist/MotionZonesSettings-Dxo0EAzN.mjs +309 -0
  6. package/dist/PrivacyMaskSettings-DwWxpZrj.mjs +368 -0
  7. package/dist/SceneMonitorEditor-CC0dk5TR.mjs +846 -0
  8. package/dist/_stub.js +39566 -0
  9. package/dist/_virtual_mf-localSharedImportMap___mfe_internal__addon_import_alexa_page-CcQjQJmj.mjs +156 -0
  10. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.js-BmoaT3cr.mjs +25 -0
  11. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Msberbw_.mjs +26 -0
  12. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.js-Bct5qZia.mjs +25 -0
  13. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_trpc_mf_1_client__loadShare__.js-BHqOw8oh.mjs +25 -0
  14. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_trpc_mf_1_react_mf_2_query__loadShare__.js-B3qxfxBT.mjs +26 -0
  15. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react__loadShare__.js-DwXXYAlk.mjs +70 -0
  16. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-CUiVYNMY.mjs +26 -0
  17. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_2_dom__loadShare__.js-CIPmAyPi.mjs +25 -0
  18. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_2_dom_mf_1_client__loadShare__.js-tXm1x5EZ.mjs +25 -0
  19. package/dist/addon-import-alexa.css +3 -0
  20. package/dist/addon.js +2708 -149
  21. package/dist/addon.mjs +2724 -168
  22. package/dist/hostInit-CRUwUtSm.mjs +129 -0
  23. package/dist/index.js +1 -0
  24. package/dist/index.mjs +2 -2
  25. package/dist/player-overlays-DNpsEqAe.mjs +51 -0
  26. package/dist/remoteEntry.js +2 -0
  27. package/dist/remoteEntry.ssr.js +33 -0
  28. package/dist/responsive-B82v3AbM.mjs +125 -0
  29. package/dist/rolldown-runtime-HEgqtunE.mjs +20 -0
  30. package/dist/scene-monitor-copy-CPnjwzw6.mjs +227 -0
  31. package/dist/square-YTl_L1_3.mjs +45 -0
  32. package/dist/trash-2-DCHugLVt.mjs +25 -0
  33. package/dist/ui-text-B5YO4hoJ.mjs +34 -0
  34. package/dist/virtualExposes-lZT06jeg.mjs +2 -0
  35. package/dist/virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_import_alexa_page__remoteEntry_js-DMdgQOgz.mjs +2857 -0
  36. package/dist/virtual_mf-exposes-ssr___mfe_internal__addon_import_alexa_page__remoteEntry_js-CViESMdd.mjs +10 -0
  37. package/dist/virtual_mf-exposes___mfe_internal__addon_import_alexa_page__remoteEntry_js-C3qJCjtV.mjs +27 -0
  38. package/i18n/en.json +8 -1
  39. package/i18n/it.json +8 -1
  40. package/package.json +26 -4
  41. package/python/NOTICE +4 -0
  42. package/python/alexa_gateway.py +226 -0
  43. package/python/alexa_protocol.py +115 -0
  44. package/python/alexa_sequences.py +74 -0
  45. package/python/alexa_worker.py +50 -0
  46. package/python/requirements.txt +3 -0
  47. package/python/test_alexa_gateway.py +141 -0
  48. package/python/test_alexa_protocol.py +177 -0
package/dist/addon.mjs CHANGED
@@ -1,8 +1,15 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { randomBytes } from "node:crypto";
3
- import { networkInterfaces } from "node:os";
4
- import { promises } from "node:fs";
5
- import path from "node:path";
3
+ import * as os from "node:os";
4
+ import { networkInterfaces, userInfo } from "node:os";
5
+ import * as fs from "node:fs";
6
+ import { existsSync, promises } from "node:fs";
7
+ import * as path$1 from "node:path";
8
+ import path, { dirname, join } from "node:path";
9
+ import * as fs$1 from "node:fs/promises";
10
+ import { execFileSync, spawn } from "node:child_process";
11
+ import "node:zlib";
12
+ import { fileURLToPath } from "node:url";
6
13
  //#region \0rolldown/runtime.js
7
14
  var __create = Object.create;
8
15
  var __defProp = Object.defineProperty;
@@ -5614,7 +5621,7 @@ var ZodIssueCode = {
5614
5621
  var ZodFirstPartyTypeKind;
5615
5622
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5616
5623
  //#endregion
5617
- //#region ../types/dist/sleep-bykKrTgu.mjs
5624
+ //#region ../types/dist/sleep--jXS9hWo.mjs
5618
5625
  /**
5619
5626
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5620
5627
  * window to float samples (D455).
@@ -8852,6 +8859,28 @@ function event(data) {
8852
8859
  var StaticDirOutputSchema$1 = object({ staticDir: string() });
8853
8860
  var VersionOutputSchema$1 = object({ version: string() });
8854
8861
  method(_void(), StaticDirOutputSchema$1, { auth: "admin" }), method(_void(), VersionOutputSchema$1, { auth: "admin" });
8862
+ /**
8863
+ * Identity — preserves literal types for downstream inference.
8864
+ *
8865
+ * The constraint is `Record<string, unknown>` (not `CustomActionsSpec`) so
8866
+ * TypeScript does not widen each entry's literal `kind`/`auth` fields to
8867
+ * the broader unions declared on `CustomActionSpec`'s default generics.
8868
+ * Shape validity is enforced separately by the `customAction(...)` helper
8869
+ * whose return type is already a `CustomActionSpec<...>`.
8870
+ */
8871
+ function defineCustomActions(spec) {
8872
+ return spec;
8873
+ }
8874
+ function customAction(input, output, options) {
8875
+ return {
8876
+ input,
8877
+ output,
8878
+ kind: options?.kind ?? "query",
8879
+ auth: options?.auth ?? "protected",
8880
+ scope: options?.scope ?? { kind: "system" },
8881
+ ...options?.caller ? { caller: "required" } : {}
8882
+ };
8883
+ }
8855
8884
  var StaticDirOutputSchema = object({ staticDir: string() });
8856
8885
  var VersionOutputSchema = object({ version: string() });
8857
8886
  method(_void(), StaticDirOutputSchema, { auth: "admin" }), method(_void(), VersionOutputSchema, { auth: "admin" });
@@ -8995,6 +9024,7 @@ var CAP_NAMES = Object.freeze([
8995
9024
  "filesystem-browse",
8996
9025
  "flood",
8997
9026
  "gas",
9027
+ "group-active",
8998
9028
  "group-commands",
8999
9029
  "group-status",
9000
9030
  "heating-valve",
@@ -9017,6 +9047,7 @@ var CAP_NAMES = Object.freeze([
9017
9047
  "login-method",
9018
9048
  "media-player",
9019
9049
  "mesh-network",
9050
+ "meter",
9020
9051
  "metrics-provider",
9021
9052
  "model-convert",
9022
9053
  "model-distributor",
@@ -9815,6 +9846,10 @@ Object.freeze({
9815
9846
  "path": "detected",
9816
9847
  "valueType": "boolean"
9817
9848
  }],
9849
+ "group-active": [{
9850
+ "path": "active",
9851
+ "valueType": "boolean"
9852
+ }],
9818
9853
  "group-status": [
9819
9854
  {
9820
9855
  "path": "included",
@@ -10074,6 +10109,10 @@ Object.freeze({
10074
10109
  ]
10075
10110
  }
10076
10111
  ],
10112
+ "meter": [{
10113
+ "path": "value",
10114
+ "valueType": "number"
10115
+ }],
10077
10116
  "motion": [
10078
10117
  {
10079
10118
  "path": "detected",
@@ -10817,7 +10856,7 @@ object({
10817
10856
  params: record(string(), union([string(), number()])).optional()
10818
10857
  });
10819
10858
  //#endregion
10820
- //#region ../types/dist/err-msg-BXKzRBwg.mjs
10859
+ //#region ../types/dist/err-msg-DIorGYeC.mjs
10821
10860
  var DeviceType = /* @__PURE__ */ function(DeviceType) {
10822
10861
  DeviceType["Camera"] = "camera";
10823
10862
  DeviceType["Hub"] = "hub";
@@ -10911,8 +10950,14 @@ var DeviceType = /* @__PURE__ */ function(DeviceType) {
10911
10950
  DeviceType["PetFeeder"] = "pet-feeder";
10912
10951
  /** Built-in dishwasher — program lifecycle, door / remote-start gates,
10913
10952
  * salt and rinse-aid levels. Installed with the `dishwasher` cap.
10914
- * Sources: native Bosch Home Connect, reusable by similar integrations. */
10953
+ * Sources: native Bosch Home Connect, reusable by similar integrations.
10954
+ * A plug-built dishwasher carries running-state and power instead. */
10915
10955
  DeviceType["Dishwasher"] = "dishwasher";
10956
+ /** Washing machine. A plug-built one is running while its power stays
10957
+ * above a threshold. No program cap — that surface is the dishwasher's. */
10958
+ DeviceType["Washer"] = "washer";
10959
+ /** Tumble dryer. Same plug-built running surface as {@link Washer}. */
10960
+ DeviceType["Dryer"] = "dryer";
10916
10961
  return DeviceType;
10917
10962
  }({});
10918
10963
  var DeviceFeature = /* @__PURE__ */ function(DeviceFeature) {
@@ -11170,7 +11215,7 @@ function errMsg(err) {
11170
11215
  return String(err);
11171
11216
  }
11172
11217
  //#endregion
11173
- //#region ../types/dist/composition-OhpuespA.mjs
11218
+ //#region ../types/dist/composition-Bb8iwHEJ.mjs
11174
11219
  /**
11175
11220
  * The ffmpeg build every CamStack node downloads, per platform and arch.
11176
11221
  *
@@ -11485,6 +11530,13 @@ var table = {
11485
11530
  apply: () => {
11486
11531
  throw new ExpressionEvalError("latchedAt: stateful evaluation context is required");
11487
11532
  }
11533
+ },
11534
+ schmitt: {
11535
+ minArgs: 3,
11536
+ maxArgs: 5,
11537
+ apply: () => {
11538
+ throw new ExpressionEvalError("schmitt: stateful evaluation context is required");
11539
+ }
11488
11540
  }
11489
11541
  };
11490
11542
  Object.freeze(Object.assign(Object.create(null), table));
@@ -11926,6 +11978,99 @@ function validateExpressionSource(src) {
11926
11978
  return null;
11927
11979
  }
11928
11980
  /**
11981
+ * One power reading becomes a washer, dryer or dishwasher (D944 helper).
11982
+ *
11983
+ * Standby of the control board is under 5 W; a motor or a heater is far
11984
+ * above that, so 5 W is the line. A door lock or a short pump spike is not
11985
+ * a cycle, so running starts only after 30 s above the line.
11986
+ *
11987
+ * A wash, dry or dishwasher pause (soak, heat-wait, heater off between
11988
+ * bursts) lasts several minutes. Ending the cycle on the first dip would
11989
+ * report "finished" in the middle of the program, so the stop dwell is
11990
+ * 10 minutes on every type. The number is stored per type so one machine
11991
+ * can move later without moving the others.
11992
+ */
11993
+ var APPLIANCE_TEMPLATE_ID = "appliance";
11994
+ var APPLIANCE_KINDS = [
11995
+ "washer",
11996
+ "dryer",
11997
+ "dishwasher"
11998
+ ];
11999
+ DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher;
12000
+ /**
12001
+ * Statistics and integrals over a history read (D944).
12002
+ *
12003
+ * Pure: the same points always give the same answer, so a restart recomputes
12004
+ * rather than trusting a buffer. A gauge mean is time-weighted (value × ms),
12005
+ * never the mean of the samples. A hole with no held value is not integrated
12006
+ * as zero. A truncated read is not published.
12007
+ */
12008
+ var STATISTIC_STATS = [
12009
+ "min",
12010
+ "max",
12011
+ "mean",
12012
+ "sum",
12013
+ "count",
12014
+ "change"
12015
+ ];
12016
+ /**
12017
+ * The range a statistics or integral helper asks history for (D944).
12018
+ *
12019
+ * Calendar edges are the hub's timezone, never UTC midnight: a day is 23, 24
12020
+ * or 25 hours across a DST change. A tier is eligible only when its retention
12021
+ * is strictly longer than the longest that range can be. Forever qualifies.
12022
+ * Equal to a tier's retention is a refusal, not a short answer.
12023
+ */
12024
+ var MINUTE_MS = 6e4;
12025
+ var HOUR_MS = 60 * MINUTE_MS;
12026
+ var DAY_MS = 24 * HOUR_MS;
12027
+ var ROLLING_SPANS = [
12028
+ "1h",
12029
+ "24h",
12030
+ "2d",
12031
+ "7d",
12032
+ "30d",
12033
+ "custom"
12034
+ ];
12035
+ var CALENDAR_PERIODS = [
12036
+ "today",
12037
+ "yesterday",
12038
+ "this-week",
12039
+ "this-month"
12040
+ ];
12041
+ var RESET_PERIODS = [
12042
+ "never",
12043
+ "daily",
12044
+ "weekly",
12045
+ "monthly"
12046
+ ];
12047
+ 5 * MINUTE_MS;
12048
+ 24 * HOUR_MS, 2 * DAY_MS, 7 * DAY_MS, 30 * DAY_MS;
12049
+ 7 * DAY_MS, 30 * DAY_MS, 365 * DAY_MS;
12050
+ var rollingWindowObject = object({
12051
+ kind: literal("rolling"),
12052
+ span: _enum(ROLLING_SPANS),
12053
+ customMinutes: number().int().min(5).optional()
12054
+ }).check((payload) => {
12055
+ if (payload.value.span === "custom" && payload.value.customMinutes === void 0) payload.issues.push({
12056
+ code: "custom",
12057
+ message: "a custom range needs a duration in minutes",
12058
+ path: ["customMinutes"],
12059
+ input: payload.value
12060
+ });
12061
+ if (payload.value.span !== "custom" && payload.value.customMinutes !== void 0) payload.issues.push({
12062
+ code: "custom",
12063
+ message: "only a custom range carries a duration",
12064
+ path: ["customMinutes"],
12065
+ input: payload.value
12066
+ });
12067
+ });
12068
+ var RollingWindowSchema = rollingWindowObject;
12069
+ var StatisticWindowSchema = discriminatedUnion("kind", [rollingWindowObject, object({
12070
+ kind: literal("calendar"),
12071
+ period: _enum(CALENDAR_PERIODS)
12072
+ })]);
12073
+ /**
11929
12074
  * A composition source identity: `(addonId, stableId)`, never a numeric device
11930
12075
  * id. Split out so helper schemas can name it without a cycle through
11931
12076
  * `composition.ts` (D659).
@@ -11951,7 +12096,13 @@ var GROUP_TEMPLATE_IDS = [
11951
12096
  "occupancy",
11952
12097
  "binary",
11953
12098
  "switch",
11954
- "light"
12099
+ "light",
12100
+ "fan",
12101
+ "cover",
12102
+ "valve",
12103
+ "lock",
12104
+ "media-player",
12105
+ "climate"
11955
12106
  ];
11956
12107
  var NUMERIC_AGGREGATES = [
11957
12108
  "mean",
@@ -11981,14 +12132,73 @@ var HelperMemberSchema = object({
11981
12132
  cap: string().min(1),
11982
12133
  fieldPath: string().min(1)
11983
12134
  });
11984
- var HelperSpecSchema = discriminatedUnion("kind", [object({
11985
- kind: literal("group"),
11986
- templateId: GroupTemplateIdSchema,
11987
- aggregate: AggregateReducerSchema,
11988
- members: array(HelperMemberSchema).min(1).max(64),
11989
- /** Actuator groups only. Readings leave this unset. */
11990
- active: ActiveCriterionSchema.optional()
11991
- })]);
12135
+ var HelperSpecSchema = discriminatedUnion("kind", [
12136
+ object({
12137
+ kind: literal("group"),
12138
+ templateId: GroupTemplateIdSchema,
12139
+ aggregate: AggregateReducerSchema,
12140
+ members: array(HelperMemberSchema).min(1).max(64),
12141
+ /** Actuator groups only. Readings leave this unset. */
12142
+ active: ActiveCriterionSchema.optional()
12143
+ }),
12144
+ object({
12145
+ kind: literal("appliance"),
12146
+ templateId: literal(APPLIANCE_TEMPLATE_ID),
12147
+ appliance: _enum(APPLIANCE_KINDS),
12148
+ thresholdWatts: number().positive(),
12149
+ startDwellMs: number().int().nonnegative(),
12150
+ stopDwellMs: number().int().nonnegative(),
12151
+ member: HelperMemberSchema
12152
+ }),
12153
+ object({
12154
+ kind: literal("statistic"),
12155
+ stat: _enum(STATISTIC_STATS),
12156
+ window: StatisticWindowSchema,
12157
+ source: HelperMemberSchema
12158
+ }),
12159
+ object({
12160
+ kind: literal("integral"),
12161
+ reset: _enum(RESET_PERIODS),
12162
+ output: _enum(["energy", "meter"]),
12163
+ source: HelperMemberSchema
12164
+ }),
12165
+ object({
12166
+ kind: literal("threshold"),
12167
+ source: HelperMemberSchema,
12168
+ high: number(),
12169
+ low: number(),
12170
+ onForMs: number().int().nonnegative(),
12171
+ offForMs: number().int().nonnegative()
12172
+ }),
12173
+ object({
12174
+ kind: literal("arithmetic"),
12175
+ op: _enum([
12176
+ "sum",
12177
+ "difference",
12178
+ "ratio"
12179
+ ]),
12180
+ inputs: array(HelperMemberSchema).min(2).max(64)
12181
+ }),
12182
+ object({
12183
+ kind: literal("expression"),
12184
+ expr: string().min(1),
12185
+ inputs: array(object({
12186
+ name: string().min(1),
12187
+ member: HelperMemberSchema
12188
+ })).min(1).max(64),
12189
+ output: _enum(["number", "boolean"])
12190
+ }),
12191
+ object({
12192
+ kind: literal("derivative"),
12193
+ window: RollingWindowSchema,
12194
+ per: _enum([
12195
+ "second",
12196
+ "minute",
12197
+ "hour"
12198
+ ]),
12199
+ source: HelperMemberSchema
12200
+ })
12201
+ ]);
11992
12202
  /** A composed device's stableId is this prefix plus the block id. */
11993
12203
  var COMPOSED_DEVICE_STABLE_ID_PREFIX = "composed-";
11994
12204
  /** Longest snippet a `code` source may carry (slice 4). */
@@ -12049,6 +12259,12 @@ var CompositionFieldSourceSchema = discriminatedUnion("kind", [
12049
12259
  "all"
12050
12260
  ]),
12051
12261
  reads: array(CompositionFieldReadSchema).min(1).max(64),
12262
+ /**
12263
+ * Set when the reads are an enum state, not a yes/no: the listed states
12264
+ * count as active (a cover is `open`). A state outside the list is inactive;
12265
+ * a read that is not a state at all is left out. Only `any` / `all` use it.
12266
+ */
12267
+ activeWhen: array(string().min(1)).min(1).optional(),
12052
12268
  /** `value` is the reading; the others are the member counts. */
12053
12269
  publish: _enum([
12054
12270
  "value",
@@ -12062,6 +12278,26 @@ var CompositionFieldSourceSchema = discriminatedUnion("kind", [
12062
12278
  "omit",
12063
12279
  "offline"
12064
12280
  ])
12281
+ }),
12282
+ object({
12283
+ kind: literal("series"),
12284
+ read: CompositionFieldReadSchema,
12285
+ /** `value` is the statistic or the integral. The others are the gap counts. */
12286
+ publish: _enum([
12287
+ "value",
12288
+ "included",
12289
+ "excluded",
12290
+ "total"
12291
+ ]),
12292
+ /**
12293
+ * What an unanswered `value` does. Counts ignore this. `offline` leaves the
12294
+ * device unseeded until history answers, so a restart cannot publish 0.
12295
+ */
12296
+ empty: _enum([
12297
+ "null",
12298
+ "omit",
12299
+ "offline"
12300
+ ])
12065
12301
  })
12066
12302
  ]);
12067
12303
  var CompositionCommandTargetSchema = discriminatedUnion("kind", [object({
@@ -15822,7 +16058,24 @@ var AddonPageDeclarationSchema = object({
15822
16058
  /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
15823
16059
  sectionLabel: string().optional()
15824
16060
  });
15825
- method(_void(), array(AddonPageDeclarationSchema).readonly(), { auth: "admin" });
16061
+ var addonPagesSourceCapability = {
16062
+ name: "addon-pages-source",
16063
+ scope: "system",
16064
+ mode: "collection",
16065
+ internal: true,
16066
+ methods: {
16067
+ /**
16068
+ * `internal: true` did not gate the mount (see the 2026-08-26 note on
16069
+ * `data-store-provider`) — this was reachable on the AppRouter by ANY
16070
+ * authenticated session at the default `auth: 'protected'`. The only
16071
+ * consumer, the `addon-pages-aggregator` builtin, reads it via
16072
+ * `ctx.capabilities.getCollection('addon-pages-source')` — LOCAL-PROCESS
16073
+ * only, never tRPC (per `ctx.capabilities` seeing only the local
16074
+ * process). The public, enriched listing admin-ui actually consumes is
16075
+ * the separate `addon-pages` cap, unaffected by this change.
16076
+ */
16077
+ listPages: method(_void(), array(AddonPageDeclarationSchema).readonly(), { auth: "admin" }) }
16078
+ };
15826
16079
  var AddonHttpRouteSchema = object({
15827
16080
  method: _enum([
15828
16081
  "GET",
@@ -18511,7 +18764,8 @@ var CompositionProblemSchema = object({
18511
18764
  "native-capability-replace",
18512
18765
  "mixed-quantity",
18513
18766
  "helper-mismatch",
18514
- "member-constraint"
18767
+ "member-constraint",
18768
+ "retention"
18515
18769
  ]),
18516
18770
  path: string(),
18517
18771
  message: string()
@@ -18745,13 +18999,17 @@ Object.freeze({
18745
18999
  latchedAt: {
18746
19000
  kind: "fixed",
18747
19001
  result: ["number", "null"]
19002
+ },
19003
+ schmitt: {
19004
+ kind: "fixed",
19005
+ result: ["boolean", "null"]
18748
19006
  }
18749
19007
  });
18750
19008
  /**
18751
19009
  * One catalog row per base reading a group helper can build (D944 §3).
18752
19010
  * Data, not a form default written into the block.
18753
19011
  */
18754
- var NOW = {
19012
+ var NOW$2 = {
18755
19013
  fieldPath: "lastFetchedAt",
18756
19014
  expr: "now"
18757
19015
  };
@@ -18765,6 +19023,8 @@ function numeric(input) {
18765
19023
  family: "numeric",
18766
19024
  deviceType: DeviceType.Sensor,
18767
19025
  memberDeviceType: DeviceType.Sensor,
19026
+ memberDeviceTypes: [DeviceType.Sensor],
19027
+ activeWhen: void 0,
18768
19028
  commandCaps: [],
18769
19029
  baseUnit: baseUnitOf(input.quantity),
18770
19030
  aggregates: NUMERIC_AGGREGATES,
@@ -18780,6 +19040,8 @@ function binary(input) {
18780
19040
  family: "binary",
18781
19041
  deviceType: DeviceType.Sensor,
18782
19042
  memberDeviceType: DeviceType.Sensor,
19043
+ memberDeviceTypes: [DeviceType.Sensor],
19044
+ activeWhen: void 0,
18783
19045
  commandCaps: [],
18784
19046
  quantity: "none",
18785
19047
  baseUnit: void 0,
@@ -18797,7 +19059,7 @@ var GROUP_TEMPLATES = [
18797
19059
  quantity: "temperature",
18798
19060
  defaultAggregate: "mean",
18799
19061
  empty: "null",
18800
- companions: [NOW]
19062
+ companions: [NOW$2]
18801
19063
  }),
18802
19064
  numeric({
18803
19065
  id: "humidity",
@@ -18808,7 +19070,7 @@ var GROUP_TEMPLATES = [
18808
19070
  quantity: "humidity",
18809
19071
  defaultAggregate: "mean",
18810
19072
  empty: "null",
18811
- companions: [NOW]
19073
+ companions: [NOW$2]
18812
19074
  }),
18813
19075
  numeric({
18814
19076
  id: "pressure",
@@ -18819,7 +19081,7 @@ var GROUP_TEMPLATES = [
18819
19081
  quantity: "pressure",
18820
19082
  defaultAggregate: "mean",
18821
19083
  empty: "null",
18822
- companions: [NOW]
19084
+ companions: [NOW$2]
18823
19085
  }),
18824
19086
  numeric({
18825
19087
  id: "illuminance",
@@ -18830,7 +19092,7 @@ var GROUP_TEMPLATES = [
18830
19092
  quantity: "illuminance",
18831
19093
  defaultAggregate: "mean",
18832
19094
  empty: "null",
18833
- companions: [NOW]
19095
+ companions: [NOW$2]
18834
19096
  }),
18835
19097
  numeric({
18836
19098
  id: "power",
@@ -18841,7 +19103,7 @@ var GROUP_TEMPLATES = [
18841
19103
  quantity: "power",
18842
19104
  defaultAggregate: "sum",
18843
19105
  empty: "omit",
18844
- companions: [NOW]
19106
+ companions: [NOW$2]
18845
19107
  }),
18846
19108
  numeric({
18847
19109
  id: "energy",
@@ -18852,7 +19114,7 @@ var GROUP_TEMPLATES = [
18852
19114
  quantity: "energy",
18853
19115
  defaultAggregate: "sum",
18854
19116
  empty: "omit",
18855
- companions: [NOW]
19117
+ companions: [NOW$2]
18856
19118
  }),
18857
19119
  numeric({
18858
19120
  id: "battery",
@@ -18887,7 +19149,7 @@ var GROUP_TEMPLATES = [
18887
19149
  quantity: "device",
18888
19150
  defaultAggregate: "mean",
18889
19151
  empty: "null",
18890
- companions: [NOW]
19152
+ companions: [NOW$2]
18891
19153
  }),
18892
19154
  binary({
18893
19155
  id: "motion",
@@ -18981,6 +19243,11 @@ var GROUP_TEMPLATES = [
18981
19243
  summary: "On when at least one switch is on, or when all of them are. Offline and unknown members are left out of that decision, never read as off, and still receive the command.",
18982
19244
  deviceType: DeviceType.Switch,
18983
19245
  outputCap: "switch",
19246
+ outputField: "on",
19247
+ member: {
19248
+ cap: "switch",
19249
+ fieldPath: "on"
19250
+ },
18984
19251
  commandCaps: ["switch"]
18985
19252
  }),
18986
19253
  actuator({
@@ -18988,34 +19255,136 @@ var GROUP_TEMPLATES = [
18988
19255
  summary: "On when at least one light is on, or when all of them are. Brightness and colour are offered only when every member has them. A command is not sent to a member that lacks it.",
18989
19256
  deviceType: DeviceType.Light,
18990
19257
  outputCap: "switch",
19258
+ outputField: "on",
19259
+ member: {
19260
+ cap: "switch",
19261
+ fieldPath: "on"
19262
+ },
18991
19263
  commandCaps: [
18992
19264
  "switch",
18993
19265
  "brightness",
18994
19266
  "color"
18995
19267
  ]
19268
+ }),
19269
+ actuator({
19270
+ id: "fan",
19271
+ summary: "On when at least one fan is on, or when all of them are. Speed, preset, direction and oscillation are offered only when every fan has them. A command is not sent to a fan that lacks it.",
19272
+ deviceType: DeviceType.Fan,
19273
+ outputCap: "switch",
19274
+ outputField: "on",
19275
+ member: {
19276
+ cap: "switch",
19277
+ fieldPath: "on"
19278
+ },
19279
+ commandCaps: ["switch", "fan-control"]
19280
+ }),
19281
+ actuator({
19282
+ id: "cover",
19283
+ summary: "Open when at least one cover is open or part-open, or only when all of them are. Position and tilt are offered only when every cover has them. A cover that reports nothing is left out, never read as closed.",
19284
+ deviceType: DeviceType.Cover,
19285
+ outputCap: "group-active",
19286
+ outputField: "active",
19287
+ member: {
19288
+ cap: "cover",
19289
+ fieldPath: "state"
19290
+ },
19291
+ activeWhen: [
19292
+ "open",
19293
+ "opening",
19294
+ "stopped"
19295
+ ],
19296
+ commandCaps: ["cover"]
19297
+ }),
19298
+ actuator({
19299
+ id: "valve",
19300
+ summary: "Open when at least one valve is open or part-open, or only when all of them are. Position is offered only when every valve has it. A valve that reports nothing is left out, never read as closed.",
19301
+ deviceType: DeviceType.Valve,
19302
+ outputCap: "group-active",
19303
+ outputField: "active",
19304
+ member: {
19305
+ cap: "valve",
19306
+ fieldPath: "state"
19307
+ },
19308
+ activeWhen: [
19309
+ "open",
19310
+ "opening",
19311
+ "stopped"
19312
+ ],
19313
+ commandCaps: ["valve"]
19314
+ }),
19315
+ actuator({
19316
+ id: "lock",
19317
+ summary: "Locked when at least one lock is locked, or only when all of them are. A jammed or moving lock is not locked. Locking is one tap; unlocking the group needs a confirmation every time, and nothing is sent without it.",
19318
+ deviceType: DeviceType.Lock,
19319
+ outputCap: "group-active",
19320
+ outputField: "active",
19321
+ member: {
19322
+ cap: "lock-control",
19323
+ fieldPath: "state"
19324
+ },
19325
+ activeWhen: ["locked"],
19326
+ commandCaps: ["lock-control"]
19327
+ }),
19328
+ actuator({
19329
+ id: "media-player",
19330
+ summary: "Playing when at least one player is playing, or only when all of them are. Volume is offered only when every player has it. A player that reports nothing is left out, never read as stopped.",
19331
+ deviceType: DeviceType.MediaPlayer,
19332
+ outputCap: "group-active",
19333
+ outputField: "active",
19334
+ member: {
19335
+ cap: "media-player",
19336
+ fieldPath: "state"
19337
+ },
19338
+ activeWhen: ["playing", "buffering"],
19339
+ commandCaps: ["media-player"]
19340
+ }),
19341
+ actuator({
19342
+ id: "climate",
19343
+ summary: "Running when at least one thermostat or air conditioner is not off, or only when all of them are. Target temperature and fan are offered only when every device has them. A device that reports nothing is left out, never read as off.",
19344
+ deviceType: DeviceType.Thermostat,
19345
+ memberDeviceTypes: [DeviceType.Thermostat, DeviceType.Climate],
19346
+ outputCap: "group-active",
19347
+ outputField: "active",
19348
+ member: {
19349
+ cap: "climate-control",
19350
+ fieldPath: "mode"
19351
+ },
19352
+ activeWhen: [
19353
+ "heat",
19354
+ "cool",
19355
+ "auto",
19356
+ "heat_cool",
19357
+ "fan_only",
19358
+ "dry"
19359
+ ],
19360
+ commandCaps: ["climate-control"]
18996
19361
  })
18997
19362
  ];
18998
19363
  function actuator(input) {
18999
19364
  return {
19000
- ...input,
19365
+ id: input.id,
19366
+ summary: input.summary,
19367
+ deviceType: input.deviceType,
19368
+ outputCap: input.outputCap,
19369
+ outputField: input.outputField,
19370
+ commandCaps: input.commandCaps,
19001
19371
  family: "actuator",
19002
19372
  role: void 0,
19003
19373
  memberDeviceType: input.deviceType,
19004
- outputField: "on",
19374
+ memberDeviceTypes: input.memberDeviceTypes ?? [input.deviceType],
19375
+ activeWhen: input.activeWhen,
19005
19376
  quantity: "none",
19006
19377
  baseUnit: void 0,
19007
19378
  aggregates: BINARY_AGGREGATES,
19008
19379
  defaultAggregate: "any",
19009
19380
  empty: "offline",
19010
- accept: [{
19011
- cap: "switch",
19012
- fieldPath: "on"
19013
- }],
19381
+ accept: [input.member],
19014
19382
  companions: [CHANGED]
19015
19383
  };
19016
19384
  }
19017
19385
  new Map(GROUP_TEMPLATES.map((template) => [template.id, template]));
19018
19386
  DeviceFeature.LightColorRgb, DeviceFeature.LightColorHsv, DeviceFeature.LightColorMired;
19387
+ DeviceType.Camera;
19019
19388
  `${COMPOSED_DEVICE_STABLE_ID_PREFIX}`;
19020
19389
  /**
19021
19390
  * What a composition may BUILD on a device type (G2, D676): the caps a new
@@ -44581,57 +44950,79 @@ var ProbeTargetConfigResultSchema = object({
44581
44950
  stateInstance: string().optional(),
44582
44951
  error: string().optional()
44583
44952
  });
44584
- method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSchema), { auth: "admin" }), method(object({ targetId: string() }), TargetSchema, { auth: "admin" }), method(object({
44585
- kind: string(),
44586
- config: record(string(), unknown()).optional(),
44587
- /**
44588
- * When editing / duplicating, the blank password field never round-trips
44589
- * the stored token. Pass the source target id so the provider can restore
44590
- * secrets server-side before dialling (same merge as upsert).
44591
- */
44592
- targetId: string().optional()
44593
- }), array(DiscoveredTargetSchema), { auth: "admin" }), method(object({
44594
- kind: string(),
44595
- config: record(string(), unknown()),
44596
- /** See `discoverTargets.targetId` — restore stored secrets for a blank password. */
44597
- targetId: string().optional()
44598
- }), ProbeTargetConfigResultSchema, {
44599
- kind: "mutation",
44600
- auth: "admin"
44601
- }), method(object({
44602
- targetId: string(),
44603
- notification: NotificationSchema
44604
- }), SendResultSchema, {
44605
- kind: "mutation",
44606
- auth: "admin"
44607
- }), method(object({
44608
- targetId: string(),
44609
- sample: NotificationSchema.optional()
44610
- }), TestResultSchema, {
44611
- kind: "mutation",
44612
- auth: "admin"
44613
- }), method(object({
44614
- target: TargetSchema,
44615
- /**
44616
- * Duplicate flow: create a NEW target id while copying secrets from an
44617
- * existing row. The UI never holds the stored token; blank / redacted
44618
- * password fields are restored from this source (D634 still applies if
44619
- * destinations diverge without a re-typed secret).
44620
- */
44621
- cloneSecretsFrom: string().optional()
44622
- }), TargetSchema, {
44623
- kind: "mutation",
44624
- auth: "admin"
44625
- }), method(object({ targetId: string() }), _void(), {
44626
- kind: "mutation",
44627
- auth: "admin"
44628
- }), method(object({
44629
- targetId: string(),
44630
- enabled: boolean()
44631
- }), _void(), {
44632
- kind: "mutation",
44633
- auth: "admin"
44634
- });
44953
+ var notificationOutputCapability = {
44954
+ name: "notification-output",
44955
+ scope: "system",
44956
+ mode: "collection",
44957
+ methods: {
44958
+ listTargetKinds: method(object({}), array(TargetKindSchema)),
44959
+ listTargets: method(object({}), array(TargetSchema), { auth: "admin" }),
44960
+ /**
44961
+ * One target WITH secrets — admin edit / duplicate hydration only.
44962
+ * `listTargets` stays redacted; the editor loads this so password fields
44963
+ * are filled (masked in the input) and a save can round-trip the token
44964
+ * when a destination changes (Duplicate with a new chatId, etc.).
44965
+ */
44966
+ getTarget: method(object({ targetId: string() }), TargetSchema, { auth: "admin" }),
44967
+ discoverTargets: method(object({
44968
+ kind: string(),
44969
+ config: record(string(), unknown()).optional(),
44970
+ /**
44971
+ * When editing / duplicating, the blank password field never round-trips
44972
+ * the stored token. Pass the source target id so the provider can restore
44973
+ * secrets server-side before dialling (same merge as upsert).
44974
+ */
44975
+ targetId: string().optional()
44976
+ }), array(DiscoveredTargetSchema), { auth: "admin" }),
44977
+ probeTargetConfig: method(object({
44978
+ kind: string(),
44979
+ config: record(string(), unknown()),
44980
+ /** See `discoverTargets.targetId` — restore stored secrets for a blank password. */
44981
+ targetId: string().optional()
44982
+ }), ProbeTargetConfigResultSchema, {
44983
+ kind: "mutation",
44984
+ auth: "admin"
44985
+ }),
44986
+ send: method(object({
44987
+ targetId: string(),
44988
+ notification: NotificationSchema
44989
+ }), SendResultSchema, {
44990
+ kind: "mutation",
44991
+ auth: "admin"
44992
+ }),
44993
+ testTarget: method(object({
44994
+ targetId: string(),
44995
+ sample: NotificationSchema.optional()
44996
+ }), TestResultSchema, {
44997
+ kind: "mutation",
44998
+ auth: "admin"
44999
+ }),
45000
+ upsertTarget: method(object({
45001
+ target: TargetSchema,
45002
+ /**
45003
+ * Duplicate flow: create a NEW target id while copying secrets from an
45004
+ * existing row. The UI never holds the stored token; blank / redacted
45005
+ * password fields are restored from this source (D634 still applies if
45006
+ * destinations diverge without a re-typed secret).
45007
+ */
45008
+ cloneSecretsFrom: string().optional()
45009
+ }), TargetSchema, {
45010
+ kind: "mutation",
45011
+ auth: "admin"
45012
+ }),
45013
+ deleteTarget: method(object({ targetId: string() }), _void(), {
45014
+ kind: "mutation",
45015
+ auth: "admin"
45016
+ }),
45017
+ setTargetEnabled: method(object({
45018
+ targetId: string(),
45019
+ enabled: boolean()
45020
+ }), _void(), {
45021
+ kind: "mutation",
45022
+ auth: "admin"
45023
+ })
45024
+ }
45025
+ };
44635
45026
  new Set([
44636
45027
  {
44637
45028
  id: "person",
@@ -46926,7 +47317,20 @@ var NcRuleInputSchema = object({
46926
47317
  * save by the hub's grouping guard). `.optional()`, never `.default()`: the addon cap
46927
47318
  * path does not run Zod defaults.
46928
47319
  */
46929
- groupingSec: number().int().min(5).max(60).optional()
47320
+ groupingSec: number().int().min(5).max(60).optional(),
47321
+ /**
47322
+ * ASK ME TO CONFIRM DETECTIONS (#243): a DETECTION notification of this rule
47323
+ * carries "Correct" / "Wrong" buttons (and, where the channel has room, a
47324
+ * "Wrong, it is a <class>" choice). A tap marks the track for retraining, the
47325
+ * same mark the Library writes, and records whose notification it was and
47326
+ * when. A channel with no buttons gets the same two choices as links.
47327
+ *
47328
+ * `.optional()`, never `.default()` (a Zod default does not run on the addon
47329
+ * cap path): absent and `false` both mean off, which is what every rule
47330
+ * authored before this field meant. An editor turns it off by writing
47331
+ * `false`, so it needs no `clear` key.
47332
+ */
47333
+ askVerdict: boolean().optional()
46930
47334
  });
46931
47335
  /**
46932
47336
  * Partial patch for `updateRule` — any subset of the input fields, plus the
@@ -60523,7 +60927,17 @@ var groupCommandsCapability = {
60523
60927
  scope: "device",
60524
60928
  deviceNative: true,
60525
60929
  mode: "singleton",
60526
- deviceTypes: [DeviceType.Switch, DeviceType.Light],
60930
+ deviceTypes: [
60931
+ DeviceType.Switch,
60932
+ DeviceType.Light,
60933
+ DeviceType.Fan,
60934
+ DeviceType.Cover,
60935
+ DeviceType.Valve,
60936
+ DeviceType.Lock,
60937
+ DeviceType.MediaPlayer,
60938
+ DeviceType.Thermostat,
60939
+ DeviceType.Climate
60940
+ ],
60527
60941
  methods: {},
60528
60942
  status: {
60529
60943
  schema: GroupCommandsSchema,
@@ -60541,6 +60955,49 @@ var groupCommandsCapability = {
60541
60955
  history: "none"
60542
60956
  };
60543
60957
  /**
60958
+ * Whether a helper actuator group counts as active (D944): at least one
60959
+ * included member is, or every included member is. What "active" means is the
60960
+ * type's: a cover is open, a lock is locked, a media player is playing, a
60961
+ * climate device is not off. A group of lights, switches and fans reports
60962
+ * `switch.on` instead; this cap is for the types whose own state is not a
60963
+ * yes/no. Unknown members are left out (`group-status` counts them) and an
60964
+ * empty included set takes the device offline, never `active: false`.
60965
+ */
60966
+ var GroupActiveSchema = object({
60967
+ active: boolean().meta({ label: "Active" }),
60968
+ /** Ms epoch when the slice was last updated. */
60969
+ lastChangedAt: number()
60970
+ });
60971
+ var groupActiveCapability = {
60972
+ name: "group-active",
60973
+ scope: "device",
60974
+ deviceNative: true,
60975
+ mode: "singleton",
60976
+ deviceTypes: [
60977
+ DeviceType.Cover,
60978
+ DeviceType.Valve,
60979
+ DeviceType.Lock,
60980
+ DeviceType.MediaPlayer,
60981
+ DeviceType.Climate,
60982
+ DeviceType.Thermostat
60983
+ ],
60984
+ methods: {},
60985
+ status: {
60986
+ schema: GroupActiveSchema,
60987
+ kind: "push"
60988
+ },
60989
+ runtimeState: GroupActiveSchema,
60990
+ /**
60991
+ * Runtime-state durability: **session** — recomputed from the members on the
60992
+ * next source event; a restored value would outlive the members it named.
60993
+ *
60994
+ * See `RuntimeStateDurability`. Enforced by
60995
+ * `scripts/check-runtime-state-durability.ts`.
60996
+ */
60997
+ durability: "session",
60998
+ history: "none"
60999
+ };
61000
+ /**
60544
61001
  * How many members of a helper group answered (D944). Counts are integers,
60545
61002
  * not measurements: zero included is a real count, never a stand-in reading.
60546
61003
  * The reading itself stays on the output capability. History is none — the
@@ -60571,7 +61028,14 @@ var groupStatusCapability = {
60571
61028
  deviceTypes: [
60572
61029
  DeviceType.Sensor,
60573
61030
  DeviceType.Switch,
60574
- DeviceType.Light
61031
+ DeviceType.Light,
61032
+ DeviceType.Fan,
61033
+ DeviceType.Cover,
61034
+ DeviceType.Valve,
61035
+ DeviceType.Lock,
61036
+ DeviceType.MediaPlayer,
61037
+ DeviceType.Thermostat,
61038
+ DeviceType.Climate
60575
61039
  ],
60576
61040
  methods: {},
60577
61041
  status: {
@@ -61844,7 +62308,14 @@ var lockControlCapability = {
61844
62308
  }),
61845
62309
  unlock: method(object({
61846
62310
  deviceId: number().int().nonnegative(),
61847
- code: string().min(1).optional()
62311
+ code: string().min(1).optional(),
62312
+ /**
62313
+ * Honoured by a helper lock GROUP only (D944): unlocking several doors
62314
+ * with one call is refused unless the caller says it was confirmed. A
62315
+ * single lock ignores it. The refusal lives in the callee, so no
62316
+ * automation or API caller reaches the doors by skipping a dialog.
62317
+ */
62318
+ confirm: boolean().optional()
61848
62319
  }), _void(), {
61849
62320
  kind: "mutation",
61850
62321
  auth: "protected"
@@ -61881,6 +62352,44 @@ var lockControlCapability = {
61881
62352
  volatileStateFields: ["lastChangedAt"]
61882
62353
  };
61883
62354
  /**
62355
+ * A monotonic total between resets (D944). Energy uses `power-meter.kwhTotal`
62356
+ * so dashboards keep one series; every other instantaneous reading lands here.
62357
+ * `value` is the time integral in the source quantity's accumulated base
62358
+ * (base-unit seconds, or kWh on the power meter). `null` is a gap, never zero.
62359
+ */
62360
+ var MeterStatusSchema = object({
62361
+ value: number().nullable().meta(readingMeta("device", { label: "Total" })),
62362
+ lastFetchedAt: number()
62363
+ });
62364
+ var meterCapability = {
62365
+ name: "meter",
62366
+ scope: "device",
62367
+ deviceNative: true,
62368
+ mode: "singleton",
62369
+ deviceTypes: [DeviceType.Sensor],
62370
+ methods: {},
62371
+ status: {
62372
+ schema: MeterStatusSchema,
62373
+ kind: "push"
62374
+ },
62375
+ runtimeState: MeterStatusSchema,
62376
+ /**
62377
+ * Runtime-state durability: **restored** — the total is a cache of the
62378
+ * history integral. A restart shows it only until the history pass
62379
+ * recomputes the open period; it is not an in-memory buffer.
62380
+ *
62381
+ * See `RuntimeStateDurability`. Enforced by
62382
+ * `scripts/check-runtime-state-durability.ts`.
62383
+ */
62384
+ durability: "restored",
62385
+ history: { fields: { value: {
62386
+ kind: "counter",
62387
+ deadband: .001,
62388
+ minIntervalMs: 6e4
62389
+ } } },
62390
+ volatileStateFields: ["lastFetchedAt"]
62391
+ };
62392
+ /**
61884
62393
  * Media-player cap. Models HA `media_player.*` (Sonos, Chromecast,
61885
62394
  * Apple TV, Spotify, Roku, generic OTT receivers, …) on
61886
62395
  * `DeviceType.MediaPlayer`. Disjoint from the camera streaming
@@ -64407,7 +64916,12 @@ var powerMeterCapability = {
64407
64916
  scope: "device",
64408
64917
  deviceNative: true,
64409
64918
  mode: "singleton",
64410
- deviceTypes: [DeviceType.Sensor],
64919
+ deviceTypes: [
64920
+ DeviceType.Sensor,
64921
+ DeviceType.Washer,
64922
+ DeviceType.Dryer,
64923
+ DeviceType.Dishwasher
64924
+ ],
64411
64925
  methods: {},
64412
64926
  status: {
64413
64927
  schema: PowerMeterStatusSchema,
@@ -65145,6 +65659,8 @@ var runningCustomize = {
65145
65659
  stateField: "running"
65146
65660
  },
65147
65661
  deviceTypes: [
65662
+ DeviceType.Washer,
65663
+ DeviceType.Dryer,
65148
65664
  DeviceType.Dishwasher,
65149
65665
  DeviceType.Vacuum,
65150
65666
  DeviceType.LawnMower,
@@ -67634,7 +68150,8 @@ var switchCapability = {
67634
68150
  deviceTypes: [
67635
68151
  DeviceType.Switch,
67636
68152
  DeviceType.Siren,
67637
- DeviceType.Light
68153
+ DeviceType.Light,
68154
+ DeviceType.Fan
67638
68155
  ],
67639
68156
  methods: { setState: method(object({
67640
68157
  deviceId: number(),
@@ -69847,8 +70364,9 @@ method(object({}), array(TrainingBaseModelSchema).readonly(), { auth: "admin" })
69847
70364
  kind: "mutation",
69848
70365
  auth: "admin"
69849
70366
  });
70367
+ DeviceFeature.FanSpeed, DeviceFeature.FanPreset, DeviceFeature.FanDirection, DeviceFeature.FanOscillating, DeviceFeature.CoverPositionable, DeviceFeature.CoverTilt, DeviceFeature.ValvePositionable, DeviceFeature.MediaPlayerVolume, DeviceFeature.ClimateFanMode;
69850
70368
  SOURCE_CAPS.filter((cap) => cap !== "switch" && cap !== "motion");
69851
- DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.PetFeeder, DeviceType.Container, DeviceType.Thermostat, DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.Dishwasher, DeviceType.Camera, DeviceType.Camera, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.MediaPlayer, DeviceType.WaterHeater, DeviceType.Humidifier, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.PetFeeder;
70369
+ DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.PetFeeder, DeviceType.Container, DeviceType.Thermostat, DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer, DeviceType.Camera, DeviceType.Camera, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.MediaPlayer, DeviceType.WaterHeater, DeviceType.Humidifier, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.PetFeeder;
69852
70370
  String.raw`rose\(active(\d+), changed(\d+), (\d+(?:\.\d+)?)\)`;
69853
70371
  DeviceRole.BinarySensor, DeviceRole.MotionSensor;
69854
70372
  new Set(CAP_NAMES);
@@ -70177,6 +70695,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
70177
70695
  featureProbe: featureProbeCapability,
70178
70696
  flood: floodCapability,
70179
70697
  gas: gasCapability,
70698
+ groupActive: groupActiveCapability,
70180
70699
  groupCommands: groupCommandsCapability,
70181
70700
  groupStatus: groupStatusCapability,
70182
70701
  heatingValve: heatingValveCapability,
@@ -70188,6 +70707,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
70188
70707
  lawnMowerControl: lawnMowerControlCapability,
70189
70708
  lockControl: lockControlCapability,
70190
70709
  mediaPlayer: mediaPlayerCapability,
70710
+ meter: meterCapability,
70191
70711
  motion: motionCapability,
70192
70712
  motionTrigger: motionTriggerCapability,
70193
70713
  motionZones: motionZonesCapability,
@@ -71447,7 +71967,7 @@ var BaseDeviceProvider = class extends BaseAddon {
71447
71967
  return toDeviceSummary(device, this.addonId);
71448
71968
  }
71449
71969
  };
71450
- DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.Dishwasher;
71970
+ DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer;
71451
71971
  new Set(Object.values(DeviceType));
71452
71972
  Object.freeze({
71453
71973
  "accessories.getStatus": {
@@ -74670,6 +75190,13 @@ Object.freeze({
74670
75190
  access: "view",
74671
75191
  role: "viewer"
74672
75192
  },
75193
+ "groupActive.getStatus": {
75194
+ capName: "group-active",
75195
+ capScope: "device",
75196
+ addonId: null,
75197
+ access: "view",
75198
+ role: "viewer"
75199
+ },
74673
75200
  "groupCommands.getStatus": {
74674
75201
  capName: "group-commands",
74675
75202
  capScope: "device",
@@ -75636,6 +76163,13 @@ Object.freeze({
75636
76163
  access: "create",
75637
76164
  role: "admin"
75638
76165
  },
76166
+ "meter.getStatus": {
76167
+ capName: "meter",
76168
+ capScope: "device",
76169
+ addonId: null,
76170
+ access: "view",
76171
+ role: "viewer"
76172
+ },
75639
76173
  "metricsProvider.collectSnapshot": {
75640
76174
  capName: "metrics-provider",
75641
76175
  capScope: "system",
@@ -83734,7 +84268,39 @@ var APPLIANCE_CAP_VOCABULARY = {
83734
84268
  phrase: t("When {device} finishes", "Quando {device} finisce"),
83735
84269
  icon: "square"
83736
84270
  }
83737
- }
84271
+ },
84272
+ overrides: { byType: {
84273
+ [DeviceType.Washer]: {
84274
+ state: { running: {
84275
+ whenTrue: t("When the washing machine starts", "Quando la lavatrice inizia"),
84276
+ whenFalse: t("When the washing machine has finished", "Quando la lavatrice ha finito")
84277
+ } },
84278
+ events: {
84279
+ onStarted: { phrase: t("When the washing machine starts", "Quando la lavatrice inizia") },
84280
+ onFinished: { phrase: t("When the washing machine has finished", "Quando la lavatrice ha finito") }
84281
+ }
84282
+ },
84283
+ [DeviceType.Dryer]: {
84284
+ state: { running: {
84285
+ whenTrue: t("When the dryer starts", "Quando l'asciugatrice inizia"),
84286
+ whenFalse: t("When the dryer has finished", "Quando l'asciugatrice ha finito")
84287
+ } },
84288
+ events: {
84289
+ onStarted: { phrase: t("When the dryer starts", "Quando l'asciugatrice inizia") },
84290
+ onFinished: { phrase: t("When the dryer has finished", "Quando l'asciugatrice ha finito") }
84291
+ }
84292
+ },
84293
+ [DeviceType.Dishwasher]: {
84294
+ state: { running: {
84295
+ whenTrue: t("When the dishwasher starts", "Quando la lavastoviglie inizia"),
84296
+ whenFalse: t("When the dishwasher has finished", "Quando la lavastoviglie ha finito")
84297
+ } },
84298
+ events: {
84299
+ onStarted: { phrase: t("When the dishwasher starts", "Quando la lavastoviglie inizia") },
84300
+ onFinished: { phrase: t("When the dishwasher has finished", "Quando la lavastoviglie ha finito") }
84301
+ }
84302
+ }
84303
+ } }
83738
84304
  },
83739
84305
  update: {
83740
84306
  scope: "device",
@@ -84506,6 +85072,10 @@ var SENSOR_CAP_VOCABULARY = {
84506
85072
  icon: "flame"
84507
85073
  }]
84508
85074
  },
85075
+ "group-active": {
85076
+ scope: "device",
85077
+ noun: t("Group state", "Stato del gruppo")
85078
+ },
84509
85079
  "group-commands": {
84510
85080
  scope: "device",
84511
85081
  noun: t("Group command", "Comando di gruppo")
@@ -84562,6 +85132,17 @@ var SENSOR_CAP_VOCABULARY = {
84562
85132
  }],
84563
85133
  events: { onMotionChanged: { notOperatorFacing: "the `detected` state trigger phrases both edges, from the mirror that survives a dropped event (D49)" } }
84564
85134
  },
85135
+ meter: {
85136
+ scope: "device",
85137
+ noun: t("Meter", "Contatore"),
85138
+ state: [{
85139
+ kind: "numeric",
85140
+ field: "value",
85141
+ quantity: t("the total", "il totale"),
85142
+ label: t("Total", "Totale"),
85143
+ icon: "gauge"
85144
+ }]
85145
+ },
84565
85146
  "numeric-sensor": {
84566
85147
  scope: "device",
84567
85148
  noun: t("Sensor", "Sensore"),
@@ -85034,8 +85615,47 @@ function projectAction(cap, action) {
85034
85615
  };
85035
85616
  }
85036
85617
  Object.entries(CAP_VOCABULARY_BY_NAME).flatMap(([cap, entry]) => entry.scope === "device" ? (entry.actions ?? []).map((a) => projectAction(cap, a)) : []);
85037
- DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.Dishwasher;
85618
+ DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer;
85038
85619
  DeviceFeature.BatteryOperated;
85620
+ DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher;
85621
+ var HA_AS = "a power sensor (device_class power). Home Assistant has no laundry domain. running-state stays unexported until a null reading can be published as unavailable.";
85622
+ var HOMEKIT_REASON = "HomeKit has no laundry-appliance service. The export tab would pair an accessory with nothing on it: running-state is withheld until a null reading can be unavailable, and power is not a HomeKit service this export honours.";
85623
+ var ALEXA_REASON = "Alexa has no laundry category in this export, and neither power nor running has an Alexa interface.";
85624
+ var GOOGLE_REASON = "Google WASHER, DRYER and DISHWASHER require RunCycle. A power threshold is not that trait, and this export does not publish RunCycle.";
85625
+ var MATTER_REASON = "No Matter export is built. The closest device types are Laundry Washer (0x0073) and Laundry Dryer (0x0074); a power threshold is not the Laundry Washer Mode cluster.";
85626
+ function withheld(reason) {
85627
+ return {
85628
+ [DeviceType.Washer]: {
85629
+ exported: false,
85630
+ reason
85631
+ },
85632
+ [DeviceType.Dryer]: {
85633
+ exported: false,
85634
+ reason
85635
+ },
85636
+ [DeviceType.Dishwasher]: {
85637
+ exported: false,
85638
+ reason
85639
+ }
85640
+ };
85641
+ }
85642
+ function offered(received) {
85643
+ return {
85644
+ [DeviceType.Washer]: {
85645
+ exported: true,
85646
+ as: received
85647
+ },
85648
+ [DeviceType.Dryer]: {
85649
+ exported: true,
85650
+ as: received
85651
+ },
85652
+ [DeviceType.Dishwasher]: {
85653
+ exported: true,
85654
+ as: received
85655
+ }
85656
+ };
85657
+ }
85658
+ offered(HA_AS), withheld(HOMEKIT_REASON), withheld(ALEXA_REASON), withheld(GOOGLE_REASON), withheld(MATTER_REASON);
85039
85659
  Object.freeze({
85040
85660
  "accessories.getStatus": [{
85041
85661
  name: "deviceId",
@@ -86002,6 +86622,11 @@ Object.freeze({
86002
86622
  form: "single",
86003
86623
  optional: false
86004
86624
  }],
86625
+ "groupActive.getStatus": [{
86626
+ name: "deviceId",
86627
+ form: "single",
86628
+ optional: false
86629
+ }],
86005
86630
  "groupCommands.getStatus": [{
86006
86631
  name: "deviceId",
86007
86632
  form: "single",
@@ -86212,6 +86837,11 @@ Object.freeze({
86212
86837
  form: "single",
86213
86838
  optional: false
86214
86839
  }],
86840
+ "meter.getStatus": [{
86841
+ name: "deviceId",
86842
+ form: "single",
86843
+ optional: false
86844
+ }],
86215
86845
  "motion.getStatus": [{
86216
86846
  name: "deviceId",
86217
86847
  form: "single",
@@ -87819,7 +88449,6 @@ Object.freeze({
87819
88449
  "network-access": "ingress",
87820
88450
  "smtp-provider": "email"
87821
88451
  });
87822
- DeviceType.Camera;
87823
88452
  /**
87824
88453
  * A saved Customize assignment (D936). The schema is the type: callers use
87825
88454
  * `z.infer`, they do not restate the shape.
@@ -87965,6 +88594,213 @@ var NC_AUDIO_DEFAULTS = {
87965
88594
  samplingSeconds: 10
87966
88595
  };
87967
88596
  new Set(["devices", "classes"]);
88597
+ /** Strip a small, safe subset of Markdown down to plain text. */
88598
+ function markdownToText(md) {
88599
+ return md.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/(\*\*|__)(.*?)\1/g, "$2").replace(/(\*|_)(.*?)\1/g, "$2").replace(/`([^`]*)`/g, "$1").replace(/^\s{0,3}#{1,6}\s*/gm, "").replace(/^\s{0,3}>\s?/gm, "").replace(/^\s{0,3}[-*+]\s+/gm, "").replace(/^\s{0,3}\d+\.\s+/gm, "").trim();
88600
+ }
88601
+ var HTML_ENTITIES = [
88602
+ [/&nbsp;/g, " "],
88603
+ [/&amp;/g, "&"],
88604
+ [/&lt;/g, "<"],
88605
+ [/&gt;/g, ">"],
88606
+ [/&quot;/g, "\""],
88607
+ [/&#39;/g, "'"]
88608
+ ];
88609
+ /** Strip HTML tags, decode a few entities, map block/br elements to newlines. */
88610
+ function htmlToText(html) {
88611
+ let out = html.replace(/<\s*br\s*\/?\s*>/gi, "\n").replace(/<\s*\/\s*(p|div|li|h[1-6])\s*>/gi, "\n").replace(/<[^>]+>/g, "");
88612
+ for (const [re, rep] of HTML_ENTITIES) out = out.replace(re, rep);
88613
+ return out.replace(/\n{3,}/g, "\n\n").trim();
88614
+ }
88615
+ var HTML_ESCAPES = [
88616
+ [/&/g, "&amp;"],
88617
+ [/</g, "&lt;"],
88618
+ [/>/g, "&gt;"],
88619
+ [/"/g, "&quot;"],
88620
+ [/'/g, "&#39;"]
88621
+ ];
88622
+ function escapeHtml(text) {
88623
+ let out = text;
88624
+ for (const [re, rep] of HTML_ESCAPES) out = out.replace(re, rep);
88625
+ return out;
88626
+ }
88627
+ /** Plain text → HTML-lite (escape + newlines to <br>). */
88628
+ function textToHtml(text) {
88629
+ return escapeHtml(text).replace(/\n/g, "<br>");
88630
+ }
88631
+ /** Markdown → HTML-lite (bold/italic/code/links; used e.g. for Pushover). */
88632
+ function markdownToHtmlLite(md) {
88633
+ return escapeHtml(md).replace(/(\*\*|__)(.*?)\1/g, "<b>$2</b>").replace(/(\*|_)(.*?)\1/g, "<i>$2</i>").replace(/`([^`]*)`/g, "<code>$1</code>").replace(/\[([^\]]*)\]\(([^)]*)\)/g, "<a href=\"$2\">$1</a>").replace(/\n/g, "<br>");
88634
+ }
88635
+ /**
88636
+ * Preference order when the source format is NOT supported — first supported
88637
+ * entry wins. Encodes the Apprise-style "degrade DOWN, but preserve rendering
88638
+ * where a richer target exists" policy:
88639
+ * - markdown → html (html-lite) before text (strip)
88640
+ * - html → text before markdown
88641
+ * - text → markdown before html (text is valid markdown)
88642
+ */
88643
+ var DEGRADE_ORDER = {
88644
+ text: [
88645
+ "text",
88646
+ "markdown",
88647
+ "html"
88648
+ ],
88649
+ markdown: [
88650
+ "markdown",
88651
+ "html",
88652
+ "text"
88653
+ ],
88654
+ html: [
88655
+ "html",
88656
+ "text",
88657
+ "markdown"
88658
+ ]
88659
+ };
88660
+ /**
88661
+ * Pick the best target format the kind supports for a given source format.
88662
+ *
88663
+ * TOLERANT of a source outside the union. `NotificationSchema.format` carries
88664
+ * `.default('text')`, but that default only runs where the schema is PARSED —
88665
+ * the tRPC router. A cap call arriving from another addon (`ctx.api`) skips
88666
+ * that parse, so `format` reaches here `undefined`, and the bare
88667
+ * `DEGRADE_ORDER[source]` threw `is not iterable`. That broke the one promise
88668
+ * the degrade path makes ("never throws — degradation is reported, never
88669
+ * surfaced as an error") and dead-lettered every Notification-Center delivery
88670
+ * for a day (2026-07-30). An unknown source degrades like `text`.
88671
+ */
88672
+ function resolveFormat(source, supported) {
88673
+ for (const candidate of DEGRADE_ORDER[source] ?? DEGRADE_ORDER.text) if (supported.includes(candidate)) return candidate;
88674
+ return supported[0] ?? "text";
88675
+ }
88676
+ /** Transcode a body between formats. */
88677
+ function transcodeBody(body, from, to) {
88678
+ if (from === to) return body;
88679
+ if (to === "text") {
88680
+ if (from === "markdown") return markdownToText(body);
88681
+ if (from === "html") return htmlToText(body);
88682
+ }
88683
+ if (to === "html") {
88684
+ if (from === "text") return textToHtml(body);
88685
+ if (from === "markdown") return markdownToHtmlLite(body);
88686
+ }
88687
+ if (to === "markdown") {
88688
+ if (from === "text") return body;
88689
+ if (from === "html") return htmlToText(body);
88690
+ }
88691
+ return body;
88692
+ }
88693
+ function toResolvedLevel(level) {
88694
+ return {
88695
+ id: level.id,
88696
+ ordinal: level.ordinal,
88697
+ ...level.flags ? { flags: level.flags } : {},
88698
+ ...level.requires ? { requires: level.requires } : {}
88699
+ };
88700
+ }
88701
+ function resolveLevel(levels, n) {
88702
+ if (levels.length === 0) return null;
88703
+ if (n.level !== void 0) {
88704
+ const explicit = levels.find((l) => l.id === n.level);
88705
+ if (explicit) return toResolvedLevel(explicit);
88706
+ }
88707
+ const withOrdinal = levels.filter((l) => l.ordinal !== null);
88708
+ if (withOrdinal.length === 0) return toResolvedLevel(levels[0]);
88709
+ const priority = n.priority;
88710
+ return toResolvedLevel([...withOrdinal].sort((a, b) => {
88711
+ const da = Math.abs((a.ordinal ?? 0) - priority);
88712
+ const db = Math.abs((b.ordinal ?? 0) - priority);
88713
+ if (da !== db) return da - db;
88714
+ return (b.ordinal ?? 0) - (a.ordinal ?? 0);
88715
+ })[0]);
88716
+ }
88717
+ function splitBody(body, maxLen) {
88718
+ if (!Number.isFinite(maxLen) || maxLen <= 0 || body.length <= maxLen) return [body];
88719
+ const parts = [];
88720
+ for (let i = 0; i < body.length; i += maxLen) parts.push(body.slice(i, i + maxLen));
88721
+ return parts;
88722
+ }
88723
+ /**
88724
+ * Prepare a canonical notification for one kind. Never throws — degradation is
88725
+ * always reported in `renderedAs`, never surfaced as an error.
88726
+ */
88727
+ function prepareNotification(caps, n) {
88728
+ const dropped = [];
88729
+ const sourceFormat = n.format;
88730
+ const format = resolveFormat(sourceFormat, caps.format);
88731
+ if (format !== sourceFormat) dropped.push("format");
88732
+ const transcoded = transcodeBody(n.body, sourceFormat, format);
88733
+ const level = resolveLevel(caps.levels, n);
88734
+ const inAttachments = n.attachments ?? [];
88735
+ const attachments = [];
88736
+ for (const att of inAttachments) {
88737
+ if (!caps.attachments.mediaTypes.includes(att.mediaType)) {
88738
+ dropped.push(`attachment:${att.mediaType}`);
88739
+ continue;
88740
+ }
88741
+ if (caps.attachments.mode === "url" && att.url === void 0) {
88742
+ dropped.push("attachment:noUrl");
88743
+ continue;
88744
+ }
88745
+ if (att.bytes !== void 0 && caps.attachments.maxBytes !== void 0 && att.bytes.length > caps.attachments.maxBytes) {
88746
+ dropped.push("attachment:maxBytes");
88747
+ continue;
88748
+ }
88749
+ if (attachments.length >= caps.attachments.max) {
88750
+ dropped.push("attachment:max");
88751
+ continue;
88752
+ }
88753
+ const needsFetch = caps.attachments.mode === "bytes" && att.bytes === void 0 && att.url !== void 0;
88754
+ attachments.push({
88755
+ mediaType: att.mediaType,
88756
+ ...att.url !== void 0 ? { url: att.url } : {},
88757
+ ...att.bytes !== void 0 ? { bytes: att.bytes } : {},
88758
+ ...att.mime !== void 0 ? { mime: att.mime } : {},
88759
+ ...att.name !== void 0 ? { name: att.name } : {},
88760
+ needsFetch
88761
+ });
88762
+ }
88763
+ const inActions = n.actions ?? [];
88764
+ const kept = inActions.slice(0, Math.max(0, caps.actions));
88765
+ if (inActions.length > kept.length) dropped.push("actions");
88766
+ const iconsSupported = caps.actionIcons === true;
88767
+ if (!iconsSupported && kept.some((a) => a.icon !== void 0)) dropped.push("actionIcons");
88768
+ const actions = iconsSupported ? kept : kept.map(({ icon: _icon, ...rest }) => rest);
88769
+ let clickUrl = null;
88770
+ if (n.clickUrl !== void 0) if (caps.clickUrl) clickUrl = n.clickUrl;
88771
+ else dropped.push("clickUrl");
88772
+ let sound = null;
88773
+ if (n.sound !== void 0) if (caps.sound) sound = n.sound;
88774
+ else dropped.push("sound");
88775
+ let ttl = null;
88776
+ if (n.ttl !== void 0) if (caps.ttl) ttl = n.ttl;
88777
+ else dropped.push("ttl");
88778
+ const tag = n.tag ?? null;
88779
+ const bodyParts = splitBody(transcoded, caps.bodyMaxLen);
88780
+ const truncated = bodyParts.length > 1;
88781
+ const renderedAs = {
88782
+ level: level?.id ?? "",
88783
+ format,
88784
+ attachmentsSent: attachments.length,
88785
+ actionsSent: actions.length,
88786
+ truncated,
88787
+ dropped
88788
+ };
88789
+ return {
88790
+ body: bodyParts[0] ?? "",
88791
+ ...n.title !== void 0 ? { title: n.title } : {},
88792
+ format,
88793
+ level,
88794
+ attachments,
88795
+ actions,
88796
+ clickUrl,
88797
+ sound,
88798
+ ttl,
88799
+ tag,
88800
+ bodyParts,
88801
+ renderedAs
88802
+ };
88803
+ }
87968
88804
  new Set(NC_LEGACY_SYSTEM_EVENT_KINDS);
87969
88805
  /**
87970
88806
  * Every filter, for the callers that must cover all of them (guards, tests).
@@ -89186,12 +90022,62 @@ var alexaProxyLoginSchema = object({
89186
90022
  proxyHost: string().default("").describe("Host/IP the browser uses to reach the login proxy (blank = auto-detect)"),
89187
90023
  proxyPort: preprocess((v) => v === "" || v === null ? void 0 : v, number().int().min(1).max(65535).default(3456)).describe("Port the local Amazon-login proxy listens on (must be reachable)")
89188
90024
  });
90025
+ /**
90026
+ * Echo speech settings. Speech signs in as an import broker, so the targets
90027
+ * are what lives here. `email`, `password`, `region` and `session` are the OLD
90028
+ * standalone login: they are read once to migrate it onto the single broker
90029
+ * (see `speak/migration.ts`) and written back empty.
90030
+ */
90031
+ var alexaSpeakSettingsSchema = object({
90032
+ email: string().default(""),
90033
+ password: string().default("").describe("Legacy Amazon password").meta({ secret: true }),
90034
+ region: _enum([
90035
+ "NA",
90036
+ "EU",
90037
+ "FE"
90038
+ ]).default("EU"),
90039
+ session: string().default("").describe("Legacy Alexa session blob").meta({ secret: true }),
90040
+ targets: array(object({
90041
+ id: string(),
90042
+ name: string(),
90043
+ enabled: boolean(),
90044
+ /** The broker (Amazon account) the Echo belongs to; '' until one is chosen. */
90045
+ brokerId: string().default(""),
90046
+ echoId: string(),
90047
+ mode: _enum(["speak", "announce"]),
90048
+ volume: number(),
90049
+ respectDnd: boolean()
90050
+ })).default([])
90051
+ });
90052
+ function isRecord$3(value) {
90053
+ return typeof value === "object" && value !== null && !Array.isArray(value);
90054
+ }
90055
+ /** Resolved settings. A keychain marker (an object) is an empty secret, not a password. */
90056
+ function readSpeakSettings(config) {
90057
+ const raw = isRecord$3(config.speak) ? config.speak : {};
90058
+ const region = raw["region"];
90059
+ const targets = raw["targets"];
90060
+ return alexaSpeakSettingsSchema.parse({
90061
+ email: typeof raw["email"] === "string" ? raw["email"] : "",
90062
+ password: typeof raw["password"] === "string" ? raw["password"] : "",
90063
+ session: typeof raw["session"] === "string" ? raw["session"] : "",
90064
+ ...region === "NA" || region === "EU" || region === "FE" ? { region } : {},
90065
+ ...Array.isArray(targets) ? { targets } : {}
90066
+ });
90067
+ }
89189
90068
  /** Top-level addon config — broker entries + the proxy-login settings. */
89190
90069
  var alexaAddonConfigSchema = object({
89191
90070
  brokers: array(AlexaBrokerEntrySchema).default([]),
89192
90071
  proxyLogin: alexaProxyLoginSchema.default({
89193
90072
  proxyHost: "",
89194
90073
  proxyPort: 3456
90074
+ }),
90075
+ speak: alexaSpeakSettingsSchema.default({
90076
+ email: "",
90077
+ password: "",
90078
+ region: "EU",
90079
+ session: "",
90080
+ targets: []
89195
90081
  })
89196
90082
  });
89197
90083
  /** Narrow an `unknown` to a shallow plain-object record (cast-free boundary). */
@@ -112965,17 +113851,17 @@ var require_view = /* @__PURE__ */ __commonJSMin(((exports, module) => {
112965
113851
  * @private
112966
113852
  */
112967
113853
  var debug = require_src$2()("express:view");
112968
- var path$7 = __require("path");
112969
- var fs$3 = __require("fs");
113854
+ var path$8 = __require("path");
113855
+ var fs$5 = __require("fs");
112970
113856
  /**
112971
113857
  * Module variables.
112972
113858
  * @private
112973
113859
  */
112974
- var dirname = path$7.dirname;
112975
- var basename = path$7.basename;
112976
- var extname = path$7.extname;
112977
- var join = path$7.join;
112978
- var resolve = path$7.resolve;
113860
+ var dirname = path$8.dirname;
113861
+ var basename = path$8.basename;
113862
+ var extname = path$8.extname;
113863
+ var join = path$8.join;
113864
+ var resolve = path$8.resolve;
112979
113865
  /**
112980
113866
  * Module exports.
112981
113867
  * @public
@@ -113072,7 +113958,7 @@ var require_view = /* @__PURE__ */ __commonJSMin(((exports, module) => {
113072
113958
  function tryStat(path) {
113073
113959
  debug("stat \"%s\"", path);
113074
113960
  try {
113075
- return fs$3.statSync(path);
113961
+ return fs$5.statSync(path);
113076
113962
  } catch (e) {
113077
113963
  return;
113078
113964
  }
@@ -115242,7 +116128,7 @@ var init_types = __esmMin((() => {
115242
116128
  //#region ../../node_modules/send/node_modules/mime/mime.js
115243
116129
  var require_mime = /* @__PURE__ */ __commonJSMin(((exports, module) => {
115244
116130
  __require("path");
115245
- var fs$2 = __require("fs");
116131
+ var fs$4 = __require("fs");
115246
116132
  function Mime() {
115247
116133
  this.types = Object.create(null);
115248
116134
  this.extensions = Object.create(null);
@@ -115277,7 +116163,7 @@ var require_mime = /* @__PURE__ */ __commonJSMin(((exports, module) => {
115277
116163
  Mime.prototype.load = function(file) {
115278
116164
  this._loading = file;
115279
116165
  var map = {};
115280
- fs$2.readFileSync(file, "ascii").split(/[\r\n]+/).forEach(function(line) {
116166
+ fs$4.readFileSync(file, "ascii").split(/[\r\n]+/).forEach(function(line) {
115281
116167
  var fields = line.replace(/\s*#.*|^\s*|\s*$/g, "").split(/\s+/);
115282
116168
  map[fields.shift()] = fields;
115283
116169
  });
@@ -115553,12 +116439,12 @@ var require_send = /* @__PURE__ */ __commonJSMin(((exports, module) => {
115553
116439
  var escapeHtml = require_escape_html();
115554
116440
  var etag = require_etag();
115555
116441
  var fresh = require_fresh();
115556
- var fs$1 = __require("fs");
116442
+ var fs$3 = __require("fs");
115557
116443
  var mime = require_mime();
115558
116444
  var ms = require_ms();
115559
116445
  var onFinished = require_on_finished();
115560
116446
  var parseRange = require_range_parser();
115561
- var path$6 = __require("path");
116447
+ var path$7 = __require("path");
115562
116448
  var statuses = require_statuses();
115563
116449
  var Stream = __require("stream");
115564
116450
  var util$4 = __require("util");
@@ -115566,11 +116452,11 @@ var require_send = /* @__PURE__ */ __commonJSMin(((exports, module) => {
115566
116452
  * Path function references.
115567
116453
  * @private
115568
116454
  */
115569
- var extname = path$6.extname;
115570
- var join = path$6.join;
115571
- var normalize = path$6.normalize;
115572
- var resolve = path$6.resolve;
115573
- var sep = path$6.sep;
116455
+ var extname = path$7.extname;
116456
+ var join = path$7.join;
116457
+ var normalize = path$7.normalize;
116458
+ var resolve = path$7.resolve;
116459
+ var sep = path$7.sep;
115574
116460
  /**
115575
116461
  * Regular expression for identifying a bytes Range header.
115576
116462
  * @private
@@ -116018,7 +116904,7 @@ var require_send = /* @__PURE__ */ __commonJSMin(((exports, module) => {
116018
116904
  var i = 0;
116019
116905
  var self = this;
116020
116906
  debug("stat \"%s\"", path);
116021
- fs$1.stat(path, function onstat(err, stat) {
116907
+ fs$3.stat(path, function onstat(err, stat) {
116022
116908
  if (err && err.code === "ENOENT" && !extname(path) && path[path.length - 1] !== sep) return next(err);
116023
116909
  if (err) return self.onStatError(err);
116024
116910
  if (stat.isDirectory()) return self.redirect(path);
@@ -116029,7 +116915,7 @@ var require_send = /* @__PURE__ */ __commonJSMin(((exports, module) => {
116029
116915
  if (self._extensions.length <= i) return err ? self.onStatError(err) : self.error(404);
116030
116916
  var p = path + "." + self._extensions[i++];
116031
116917
  debug("stat \"%s\"", p);
116032
- fs$1.stat(p, function(err, stat) {
116918
+ fs$3.stat(p, function(err, stat) {
116033
116919
  if (err) return next(err);
116034
116920
  if (stat.isDirectory()) return next();
116035
116921
  self.emit("file", p, stat);
@@ -116053,7 +116939,7 @@ var require_send = /* @__PURE__ */ __commonJSMin(((exports, module) => {
116053
116939
  }
116054
116940
  var p = join(path, self._index[i]);
116055
116941
  debug("stat \"%s\"", p);
116056
- fs$1.stat(p, function(err, stat) {
116942
+ fs$3.stat(p, function(err, stat) {
116057
116943
  if (err) return next(err);
116058
116944
  if (stat.isDirectory()) return next();
116059
116945
  self.emit("file", p, stat);
@@ -116072,7 +116958,7 @@ var require_send = /* @__PURE__ */ __commonJSMin(((exports, module) => {
116072
116958
  SendStream.prototype.stream = function stream(path, options) {
116073
116959
  var self = this;
116074
116960
  var res = this.res;
116075
- var stream = fs$1.createReadStream(path, options);
116961
+ var stream = fs$3.createReadStream(path, options);
116076
116962
  this.emit("stream", stream);
116077
116963
  stream.pipe(res);
116078
116964
  function cleanup() {
@@ -117512,7 +118398,7 @@ var require_application = /* @__PURE__ */ __commonJSMin(((exports, module) => {
117512
118398
  var deprecate = require_depd()("express");
117513
118399
  var flatten = require_array_flatten();
117514
118400
  var merge = require_utils_merge();
117515
- var resolve$1 = __require("path").resolve;
118401
+ var resolve$2 = __require("path").resolve;
117516
118402
  var setPrototypeOf = require_setprototypeof();
117517
118403
  /**
117518
118404
  * Module variables.
@@ -117575,7 +118461,7 @@ var require_application = /* @__PURE__ */ __commonJSMin(((exports, module) => {
117575
118461
  this.mountpath = "/";
117576
118462
  this.locals.settings = this.settings;
117577
118463
  this.set("view", View);
117578
- this.set("views", resolve$1("views"));
118464
+ this.set("views", resolve$2("views"));
117579
118465
  this.set("jsonp callback name", "callback");
117580
118466
  if (env === "production") this.enable("view cache");
117581
118467
  Object.defineProperty(this, "router", { get: function() {
@@ -119593,7 +120479,7 @@ var require_response = /* @__PURE__ */ __commonJSMin(((exports, module) => {
119593
120479
  var http$3 = __require("http");
119594
120480
  var isAbsolute = require_utils$2().isAbsolute;
119595
120481
  var onFinished = require_on_finished();
119596
- var path$5 = __require("path");
120482
+ var path$6 = __require("path");
119597
120483
  var statuses = require_statuses();
119598
120484
  var merge = require_utils_merge();
119599
120485
  var sign = require_cookie_signature().sign;
@@ -119602,9 +120488,9 @@ var require_response = /* @__PURE__ */ __commonJSMin(((exports, module) => {
119602
120488
  var setCharset = require_utils$2().setCharset;
119603
120489
  var cookie = require_cookie();
119604
120490
  var send = require_send();
119605
- var extname = path$5.extname;
120491
+ var extname = path$6.extname;
119606
120492
  var mime = send.mime;
119607
- var resolve = path$5.resolve;
120493
+ var resolve = path$6.resolve;
119608
120494
  var vary = require_vary();
119609
120495
  /**
119610
120496
  * Response prototype.
@@ -120436,7 +121322,7 @@ var require_serve_static = /* @__PURE__ */ __commonJSMin(((exports, module) => {
120436
121322
  var encodeUrl = require_encodeurl();
120437
121323
  var escapeHtml = require_escape_html();
120438
121324
  var parseUrl = require_parseurl();
120439
- var resolve = __require("path").resolve;
121325
+ var resolve$1 = __require("path").resolve;
120440
121326
  var send = require_send();
120441
121327
  var url$6 = __require("url");
120442
121328
  /**
@@ -120460,7 +121346,7 @@ var require_serve_static = /* @__PURE__ */ __commonJSMin(((exports, module) => {
120460
121346
  var setHeaders = opts.setHeaders;
120461
121347
  if (setHeaders && typeof setHeaders !== "function") throw new TypeError("option setHeaders must be function");
120462
121348
  opts.maxage = opts.maxage || opts.maxAge || 0;
120463
- opts.root = resolve(root);
121349
+ opts.root = resolve$1(root);
120464
121350
  var onDirectory = redirect ? createRedirectDirectoryListener() : createNotFoundDirectoryListener();
120465
121351
  return function serveStatic(req, res, next) {
120466
121352
  if (req.method !== "GET" && req.method !== "HEAD") {
@@ -121586,7 +122472,7 @@ var require_has_flag = /* @__PURE__ */ __commonJSMin(((exports, module) => {
121586
122472
  //#endregion
121587
122473
  //#region ../../node_modules/supports-color/index.js
121588
122474
  var require_supports_color = /* @__PURE__ */ __commonJSMin(((exports, module) => {
121589
- var os$2 = __require("os");
122475
+ var os$3 = __require("os");
121590
122476
  var tty$1 = __require("tty");
121591
122477
  var hasFlag = require_has_flag();
121592
122478
  var { env } = process;
@@ -121622,7 +122508,7 @@ var require_supports_color = /* @__PURE__ */ __commonJSMin(((exports, module) =>
121622
122508
  const min = forceColor || 0;
121623
122509
  if (env.TERM === "dumb") return min;
121624
122510
  if (process.platform === "win32") {
121625
- const osRelease = os$2.release().split(".");
122511
+ const osRelease = os$3.release().split(".");
121626
122512
  if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) return Number(osRelease[2]) >= 14931 ? 3 : 2;
121627
122513
  return 1;
121628
122514
  }
@@ -124129,7 +125015,7 @@ var require_braces = /* @__PURE__ */ __commonJSMin(((exports, module) => {
124129
125015
  //#endregion
124130
125016
  //#region ../../node_modules/micromatch/node_modules/picomatch/lib/constants.js
124131
125017
  var require_constants = /* @__PURE__ */ __commonJSMin(((exports, module) => {
124132
- var path$4 = __require("path");
125018
+ var path$5 = __require("path");
124133
125019
  var WIN_SLASH = "\\\\/";
124134
125020
  var WIN_NO_SLASH = `[^${WIN_SLASH}]`;
124135
125021
  var DEFAULT_MAX_EXTGLOB_RECURSION = 0;
@@ -124254,7 +125140,7 @@ var require_constants = /* @__PURE__ */ __commonJSMin(((exports, module) => {
124254
125140
  CHAR_UNDERSCORE: 95,
124255
125141
  CHAR_VERTICAL_LINE: 124,
124256
125142
  CHAR_ZERO_WIDTH_NOBREAK_SPACE: 65279,
124257
- SEP: path$4.sep,
125143
+ SEP: path$5.sep,
124258
125144
  /**
124259
125145
  * Create EXTGLOB_CHARS
124260
125146
  */
@@ -124298,7 +125184,7 @@ var require_constants = /* @__PURE__ */ __commonJSMin(((exports, module) => {
124298
125184
  //#endregion
124299
125185
  //#region ../../node_modules/micromatch/node_modules/picomatch/lib/utils.js
124300
125186
  var require_utils = /* @__PURE__ */ __commonJSMin(((exports) => {
124301
- var path$3 = __require("path");
125187
+ var path$4 = __require("path");
124302
125188
  var win32 = process.platform === "win32";
124303
125189
  var { REGEX_BACKSLASH, REGEX_REMOVE_BACKSLASH, REGEX_SPECIAL_CHARS, REGEX_SPECIAL_CHARS_GLOBAL } = require_constants();
124304
125190
  exports.isObject = (val) => val !== null && typeof val === "object" && !Array.isArray(val);
@@ -124318,7 +125204,7 @@ var require_utils = /* @__PURE__ */ __commonJSMin(((exports) => {
124318
125204
  };
124319
125205
  exports.isWindows = (options) => {
124320
125206
  if (options && typeof options.windows === "boolean") return options.windows;
124321
- return win32 === true || path$3.sep === "\\";
125207
+ return win32 === true || path$4.sep === "\\";
124322
125208
  };
124323
125209
  exports.escapeLast = (input, char, lastIdx) => {
124324
125210
  const idx = input.lastIndexOf(char, lastIdx);
@@ -125688,7 +126574,7 @@ var require_parse = /* @__PURE__ */ __commonJSMin(((exports, module) => {
125688
126574
  //#endregion
125689
126575
  //#region ../../node_modules/micromatch/node_modules/picomatch/lib/picomatch.js
125690
126576
  var require_picomatch$1 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
125691
- var path$2 = __require("path");
126577
+ var path$3 = __require("path");
125692
126578
  var scan = require_scan();
125693
126579
  var parse = require_parse();
125694
126580
  var utils = require_utils();
@@ -125827,7 +126713,7 @@ var require_picomatch$1 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
125827
126713
  * @api public
125828
126714
  */
125829
126715
  picomatch.matchBase = (input, glob, options, posix = utils.isWindows(options)) => {
125830
- return (glob instanceof RegExp ? glob : picomatch.makeRe(glob, options)).test(path$2.basename(input));
126716
+ return (glob instanceof RegExp ? glob : picomatch.makeRe(glob, options)).test(path$3.basename(input));
125831
126717
  };
125832
126718
  /**
125833
126719
  * Returns true if **any** of the given glob `patterns` match the specified `string`.
@@ -126912,8 +127798,8 @@ var require_proxy = /* @__PURE__ */ __commonJSMin(((exports, module) => {
126912
127798
  var proxy = require_dist().createProxyMiddleware;
126913
127799
  var querystring$2 = __require("querystring");
126914
127800
  var cookieTools = require_cookie$1();
126915
- var fs = __require("fs");
126916
- var path$1 = __require("path");
127801
+ var fs$2 = __require("fs");
127802
+ var path$2 = __require("path");
126917
127803
  var crypto$1 = __require("crypto");
126918
127804
  var FORMERDATA_STORE_VERSION = 4;
126919
127805
  function addCookies(Cookie, headers) {
@@ -126944,11 +127830,11 @@ var require_proxy = /* @__PURE__ */ __commonJSMin(((exports, module) => {
126944
127830
  }
126945
127831
  function initAmazonProxy(_options, callbackCookie, callbackListening) {
126946
127832
  const initialCookies = {};
126947
- const formerDataStorePath = _options.formerDataStorePath || path$1.join(__dirname, "formerDataStore.json");
127833
+ const formerDataStorePath = _options.formerDataStorePath || path$2.join(__dirname, "formerDataStore.json");
126948
127834
  let formerDataStoreValid = false;
126949
127835
  if (!_options.formerRegistrationData) try {
126950
- if (fs.existsSync(formerDataStorePath)) {
126951
- const formerDataStore = JSON.parse(fs.readFileSync(path$1.join(__dirname, "formerDataStore.json"), "utf8"));
127836
+ if (fs$2.existsSync(formerDataStorePath)) {
127837
+ const formerDataStore = JSON.parse(fs$2.readFileSync(path$2.join(__dirname, "formerDataStore.json"), "utf8"));
126952
127838
  if (typeof formerDataStore === "object" && formerDataStore.storeVersion === FORMERDATA_STORE_VERSION) {
126953
127839
  _options.formerRegistrationData = _options.formerRegistrationData || {};
126954
127840
  _options.formerRegistrationData.frc = _options.formerRegistrationData.frc || formerDataStore.frc;
@@ -126989,7 +127875,7 @@ var require_proxy = /* @__PURE__ */ __commonJSMin(((exports, module) => {
126989
127875
  "map-md": initialCookies["map-md"],
126990
127876
  "frc": initialCookies.frc
126991
127877
  };
126992
- fs.writeFileSync(formerDataStorePath, JSON.stringify(formerDataStore), "utf8");
127878
+ fs$2.writeFileSync(formerDataStorePath, JSON.stringify(formerDataStore), "utf8");
126993
127879
  } catch (_err) {}
126994
127880
  function base64URLEncode(str) {
126995
127881
  return str.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
@@ -127199,7 +128085,7 @@ var require_alexa_cookie = /* @__PURE__ */ __commonJSMin(((exports, module) => {
127199
128085
  var https$1 = __require("https");
127200
128086
  var querystring$1 = __require("querystring");
127201
128087
  var url = __require("url");
127202
- var os$1 = __require("os");
128088
+ var os$2 = __require("os");
127203
128089
  var cookieTools = require_cookie$1();
127204
128090
  var amazonProxy = require_proxy();
127205
128091
  var defaultAmazonPage = "amazon.de";
@@ -127312,7 +128198,7 @@ var require_alexa_cookie = /* @__PURE__ */ __commonJSMin(((exports, module) => {
127312
128198
  else if (amazonDomain !== "com") _options.baseAmazonPageHandle = "";
127313
128199
  else _options.baseAmazonPageHandle = "";
127314
128200
  }
127315
- if (!_options.userAgent) if (os$1.platform() === "win32") _options.userAgent = defaultUserAgent;
128201
+ if (!_options.userAgent) if (os$2.platform() === "win32") _options.userAgent = defaultUserAgent;
127316
128202
  else _options.userAgent = defaultUserAgentLinux;
127317
128203
  _options.logger && _options.logger(`Alexa-Cookie: Use as User-Agent: ${_options.userAgent}`);
127318
128204
  _options.acceptLanguage = _options.acceptLanguage || defaultAcceptLanguage;
@@ -127836,7 +128722,7 @@ var require_alexa_cookie = /* @__PURE__ */ __commonJSMin(((exports, module) => {
127836
128722
  var import_alexa_remote = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
127837
128723
  var https = __require("https");
127838
128724
  var querystring = __require("querystring");
127839
- var os = __require("os");
128725
+ var os$1 = __require("os");
127840
128726
  var extend = require_extend();
127841
128727
  var AlexaHttp2Push = require_alexa_http2push();
127842
128728
  var { v1: uuidv1 } = require_cjs();
@@ -127981,7 +128867,7 @@ var import_alexa_remote = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin
127981
128867
  init(cookie, callback) {
127982
128868
  if (typeof cookie === "object") {
127983
128869
  this._options = cookie;
127984
- if (!this._options.userAgent) if (os.platform() === "win32") this._options.userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36";
128870
+ if (!this._options.userAgent) if (os$1.platform() === "win32") this._options.userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36";
127985
128871
  else this._options.userAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36";
127986
128872
  if (this._options.apiUserAgentPostfix === void 0) this._options.apiUserAgentPostfix = `AlexaRemote/${__require(fsPath.join(__dirname, "package.json")).version}`;
127987
128873
  this._options.amazonPage = this._options.amazonPage || "amazon.de";
@@ -131284,15 +132170,15 @@ function parseCookieMaterial(raw) {
131284
132170
  }
131285
132171
  }
131286
132172
  /** True when `value` is a non-null, non-array plain object (cast-free guard). */
131287
- function isRecord$1(value) {
132173
+ function isRecord$2(value) {
131288
132174
  return typeof value === "object" && value !== null && !Array.isArray(value);
131289
132175
  }
131290
132176
  /** Narrow a loaded JSON file to its `cookieData` (string | object), or null. */
131291
132177
  function readCookieData(json) {
131292
- if (!isRecord$1(json)) return null;
132178
+ if (!isRecord$2(json)) return null;
131293
132179
  const value = json["cookieData"];
131294
132180
  if (typeof value === "string") return value;
131295
- if (isRecord$1(value)) return value;
132181
+ if (isRecord$2(value)) return value;
131296
132182
  return null;
131297
132183
  }
131298
132184
  //#endregion
@@ -132267,7 +133153,7 @@ function mergeAlexaMotionStatus(cached, live, nowMs) {
132267
133153
  };
132268
133154
  }
132269
133155
  /** True when `v` is a non-null, non-array object (cast-free guard). */
132270
- function isRecord(v) {
133156
+ function isRecord$1(v) {
132271
133157
  return typeof v === "object" && v !== null && !Array.isArray(v);
132272
133158
  }
132273
133159
  /**
@@ -132276,7 +133162,7 @@ function isRecord(v) {
132276
133162
  */
132277
133163
  function decodeTemperatureCelsius(value) {
132278
133164
  if (typeof value === "number") return Number.isFinite(value) ? value : null;
132279
- if (!isRecord(value)) return null;
133165
+ if (!isRecord$1(value)) return null;
132280
133166
  const raw = value["value"];
132281
133167
  const n = typeof raw === "number" ? raw : Number(raw);
132282
133168
  if (!Number.isFinite(n)) return null;
@@ -132287,7 +133173,7 @@ function decodeTemperatureCelsius(value) {
132287
133173
  function decodeHumidityPercent(value) {
132288
133174
  const direct = decodePercentage(value);
132289
133175
  if (direct !== null) return direct;
132290
- if (!isRecord(value)) return null;
133176
+ if (!isRecord$1(value)) return null;
132291
133177
  return decodePercentage(value["value"]);
132292
133178
  }
132293
133179
  //#endregion
@@ -132623,6 +133509,11 @@ var alexaBrokersEntryKind = defineEntryKind("brokers", {
132623
133509
  }),
132624
133510
  writeTrigger: "an operator links or removes an Alexa account broker"
132625
133511
  });
133512
+ var alexaSpeakSessionsEntryKind = defineEntryKind("speak-sessions", {
133513
+ title: "Alexa Echo speech sessions",
133514
+ schema: entryBody({ session: string().default("").meta({ secret: true }) }),
133515
+ writeTrigger: "an Echo worker signs in as an Alexa account broker"
133516
+ });
132626
133517
  //#endregion
132627
133518
  //#region src/alexa-broker-store.ts
132628
133519
  function rowToEntry(id, data) {
@@ -132679,13 +133570,1627 @@ var AlexaBrokerStore = class AlexaBrokerStore {
132679
133570
  }
132680
133571
  };
132681
133572
  //#endregion
133573
+ //#region src/speak/constants.ts
133574
+ /** Echo speech limits and the worker restart bound (same window as CrashSupervisor). */
133575
+ var ALEXA_SPEAK_ADDON_ID = "import-alexa";
133576
+ var ALEXA_SPEAK_KIND = "alexa-echo";
133577
+ var DEVICE_REFRESH_MS = 5 * 6e4;
133578
+ /** Where the addon serves the Alexa mark; `listTargetKinds` stamps it as `iconUrl`. */
133579
+ var ALEXA_SPEAK_ICON_URL = `/addon/${ALEXA_SPEAK_ADDON_ID}/icons/${ALEXA_SPEAK_KIND}`;
133580
+ //#endregion
133581
+ //#region src/speak/worker-protocol.ts
133582
+ /** Line-delimited JSON the long-lived Python worker speaks. One object per line. */
133583
+ var EchoDeviceSchema = object({
133584
+ id: string().min(1),
133585
+ name: string(),
133586
+ family: string(),
133587
+ online: boolean(),
133588
+ kind: _enum(["device", "group"]),
133589
+ dnd: boolean(),
133590
+ deviceType: string(),
133591
+ /** The room the account files the device under, when Amazon names one. */
133592
+ room: string().optional()
133593
+ });
133594
+ var LoginStatusSchema = _enum(["ok", "failed"]);
133595
+ var SessionStatusSchema = object({
133596
+ loggedIn: boolean(),
133597
+ ageSec: number().int().nonnegative(),
133598
+ lastSuccessAt: number().int().nullable()
133599
+ });
133600
+ var TargetDeliverySchema = object({
133601
+ targetId: string(),
133602
+ ok: boolean(),
133603
+ error: string().optional()
133604
+ });
133605
+ var WorkerLineSchema = union([object({
133606
+ id: string(),
133607
+ ok: literal(true),
133608
+ result: unknown().optional()
133609
+ }), object({
133610
+ id: string(),
133611
+ ok: literal(false),
133612
+ error: string()
133613
+ })]);
133614
+ function requestLine(id, request) {
133615
+ return `${JSON.stringify({
133616
+ id,
133617
+ ...request
133618
+ })}\n`;
133619
+ }
133620
+ function parseLoginResult(result) {
133621
+ const parsed = object({
133622
+ status: LoginStatusSchema,
133623
+ error: string().optional()
133624
+ }).safeParse(result);
133625
+ if (!parsed.success) return {
133626
+ status: "failed",
133627
+ error: "worker returned an unexpected login result"
133628
+ };
133629
+ return parsed.data;
133630
+ }
133631
+ function parseDevices(result) {
133632
+ const parsed = object({ devices: array(EchoDeviceSchema) }).safeParse(result);
133633
+ if (!parsed.success) throw new Error("worker returned an unexpected device list");
133634
+ return parsed.data.devices;
133635
+ }
133636
+ function parseDeliveries(result) {
133637
+ const parsed = object({ outcomes: array(TargetDeliverySchema) }).safeParse(result);
133638
+ if (!parsed.success) throw new Error("worker returned an unexpected delivery list");
133639
+ return parsed.data.outcomes;
133640
+ }
133641
+ function parseSessionStatus(result) {
133642
+ const parsed = SessionStatusSchema.safeParse(result);
133643
+ if (!parsed.success) throw new Error("worker returned an unexpected session status");
133644
+ return parsed.data;
133645
+ }
133646
+ //#endregion
133647
+ //#region src/speak/actions.ts
133648
+ var BrokerEchoStateSchema = _enum([
133649
+ "starting",
133650
+ "ready",
133651
+ "broker-not-signed-in",
133652
+ "failed"
133653
+ ]);
133654
+ var SpeakStatusSchema = object({
133655
+ brokers: array(object({
133656
+ id: string(),
133657
+ name: string(),
133658
+ state: BrokerEchoStateSchema,
133659
+ error: string().optional(),
133660
+ devices: array(EchoDeviceSchema),
133661
+ session: SessionStatusSchema
133662
+ })),
133663
+ /** Targets that name no Amazon account yet (they cannot speak until one is chosen). */
133664
+ unassignedTargets: number().int().nonnegative(),
133665
+ /** What the old standalone login left behind, named; '' when nothing is pending. */
133666
+ notice: _enum([
133667
+ "",
133668
+ "no-broker",
133669
+ "choose-broker"
133670
+ ])
133671
+ });
133672
+ var alexaSpeakActions = defineCustomActions({
133673
+ speakStatus: customAction(object({}), SpeakStatusSchema, {
133674
+ kind: "mutation",
133675
+ auth: "admin"
133676
+ }),
133677
+ sendTest: customAction(object({
133678
+ brokerId: string().min(1),
133679
+ echoIds: array(string().min(1)).min(1),
133680
+ mode: _enum(["speak", "announce"]),
133681
+ volume: number().int().min(0).max(100),
133682
+ text: string().min(1).max(450)
133683
+ }), object({ outcomes: array(object({
133684
+ echoId: string(),
133685
+ ok: boolean(),
133686
+ error: string().optional()
133687
+ })) }), {
133688
+ kind: "mutation",
133689
+ auth: "admin"
133690
+ })
133691
+ });
133692
+ //#endregion
133693
+ //#region src/speak/broker-seed.ts
133694
+ /**
133695
+ * What the Echo worker needs from an import broker to act as the same Amazon
133696
+ * account: the refresh token of the broker's own device registration. The
133697
+ * broker keeps that registration fresh (it persists `cookieData` after every
133698
+ * refresh), so the freshest copy is the persisted session file, then the
133699
+ * stored cookie blob, then a bare refresh token. A raw cookie string carries
133700
+ * no refresh token and gives no seed.
133701
+ */
133702
+ /** The registration name alexapy sends when the broker did not record one. */
133703
+ var DEFAULT_DEVICE_APP_NAME = "Alexa Media Player";
133704
+ function isRecord(value) {
133705
+ return typeof value === "object" && value !== null && !Array.isArray(value);
133706
+ }
133707
+ /** The registration blob alexa-remote2 hands back, from an object or its JSON text. */
133708
+ function asRegistration(value) {
133709
+ if (isRecord(value)) return value;
133710
+ if (typeof value !== "string") return null;
133711
+ const trimmed = value.trim();
133712
+ if (!trimmed.startsWith("{")) return null;
133713
+ try {
133714
+ const parsed = JSON.parse(trimmed);
133715
+ return isRecord(parsed) ? parsed : null;
133716
+ } catch {
133717
+ return null;
133718
+ }
133719
+ }
133720
+ function seedFromRegistration(value) {
133721
+ const registration = asRegistration(value);
133722
+ if (registration === null) return null;
133723
+ const token = registration["refreshToken"];
133724
+ if (typeof token !== "string" || token.length === 0) return null;
133725
+ const name = registration["deviceAppName"];
133726
+ return {
133727
+ refreshToken: token,
133728
+ deviceAppName: typeof name === "string" && name.length > 0 ? name : DEFAULT_DEVICE_APP_NAME
133729
+ };
133730
+ }
133731
+ async function readPersistedRegistration(dataDir, brokerId) {
133732
+ if (dataDir === void 0) return null;
133733
+ try {
133734
+ const raw = await fs$1.readFile(path$1.join(dataDir, `${brokerId}.${SESSION_FILE}`), "utf8");
133735
+ const json = JSON.parse(raw);
133736
+ return isRecord(json) ? json["cookieData"] : null;
133737
+ } catch {
133738
+ return null;
133739
+ }
133740
+ }
133741
+ async function readBrokerSeed(dataDir, entry) {
133742
+ const persisted = seedFromRegistration(await readPersistedRegistration(dataDir, entry.id));
133743
+ if (persisted !== null) return persisted;
133744
+ const stored = seedFromRegistration(entry.connection.cookie);
133745
+ if (stored !== null) return stored;
133746
+ const bare = entry.connection.refreshToken;
133747
+ if (bare.length > 0) return {
133748
+ refreshToken: bare,
133749
+ deviceAppName: DEFAULT_DEVICE_APP_NAME
133750
+ };
133751
+ return null;
133752
+ }
133753
+ /** Amazon domain to the region the worker uses for the spoken-sequence locale. */
133754
+ function regionOfDomain(domain) {
133755
+ const tail = domain.toLowerCase().replace(/^(www|alexa)\./, "");
133756
+ if (tail === "amazon.com") return "NA";
133757
+ if (tail === "amazon.co.jp" || tail === "amazon.com.au" || tail === "amazon.in") return "FE";
133758
+ return "EU";
133759
+ }
133760
+ process.platform, process.env["SystemRoot"];
133761
+ /**
133762
+ * Files only their owner may read: the one place a secret-bearing file is
133763
+ * restricted, on every OS.
133764
+ *
133765
+ * `mode: 0o600` is a POSIX idea. On Windows Node maps it to the read-only
133766
+ * attribute and nothing else, and `chmod` is a no-op for permissions, so a
133767
+ * "0600" `agent.json` (the mesh private key) would have
133768
+ * been readable by every local user. Windows has its own tool for this:
133769
+ * `icacls`. This module removes the inherited ACL entries and grants access to
133770
+ * the current user and SYSTEM only — the Windows reading of "0600".
133771
+ *
133772
+ * ZERO imports beyond Node built-ins, on purpose: the Electron shell bundles
133773
+ * this single file (see `packages/desktop/tsup.config.ts`) instead of the
133774
+ * whole `@camstack/types` barrel.
133775
+ *
133776
+ * Failure is NOT swallowed. A secret file that could not be restricted is a
133777
+ * secret file the caller must know about, so `OwnerOnlyError` propagates; the
133778
+ * write order below makes sure nothing sensitive is in the file yet when that
133779
+ * happens (the temp file is empty until it has been restricted).
133780
+ *
133781
+ * Out of scope on purpose: files that are only tidy, not secret (the job
133782
+ * journal, the addon manifest) keep a plain `mode`, since one `icacls` per
133783
+ * write would be a process spawn on a hot path.
133784
+ */
133785
+ /** `*S-1-5-18` is SYSTEM in every locale — a name like "SYSTEM" is not. */
133786
+ var WINDOWS_SYSTEM_SID = "*S-1-5-18";
133787
+ var FILE_MODE = 384;
133788
+ var DIR_MODE = 448;
133789
+ var OwnerOnlyError = class extends Error {
133790
+ target;
133791
+ constructor(target, detail) {
133792
+ super(`could not restrict ${target} to its owner: ${detail}`);
133793
+ this.target = target;
133794
+ this.name = "OwnerOnlyError";
133795
+ }
133796
+ };
133797
+ function currentPrincipal() {
133798
+ const user = process.env["USERNAME"] ?? userInfo().username;
133799
+ const domain = process.env["USERDOMAIN"];
133800
+ return domain !== void 0 && domain.length > 0 ? `${domain}\\${user}` : user;
133801
+ }
133802
+ /** Resolved per call, not at import: a platform stubbed by a test must be seen. */
133803
+ function resolveDeps(overrides) {
133804
+ return {
133805
+ platform: process.platform,
133806
+ exec: (file, args) => {
133807
+ execFileSync(file, [...args], {
133808
+ stdio: "pipe",
133809
+ windowsHide: true
133810
+ });
133811
+ },
133812
+ principal: currentPrincipal,
133813
+ systemRoot: process.env["SystemRoot"],
133814
+ ...overrides
133815
+ };
133816
+ }
133817
+ /** The icacls argv for one path: no inheritance, owner + SYSTEM only. */
133818
+ function icaclsArgs(target, principal, isDirectory) {
133819
+ const inherit = isDirectory ? "(OI)(CI)" : "";
133820
+ return [
133821
+ target,
133822
+ "/inheritance:r",
133823
+ "/grant:r",
133824
+ `${principal}:${inherit}F`,
133825
+ "/grant:r",
133826
+ `${WINDOWS_SYSTEM_SID}:${inherit}F`
133827
+ ];
133828
+ }
133829
+ function restrictWindows(target, isDirectory, deps) {
133830
+ const icacls = path$1.win32.join(deps.systemRoot ?? "C:\\Windows", "System32", "icacls.exe");
133831
+ try {
133832
+ deps.exec(icacls, icaclsArgs(target, deps.principal(), isDirectory));
133833
+ } catch (err) {
133834
+ throw new OwnerOnlyError(target, err instanceof Error ? err.message : String(err));
133835
+ }
133836
+ }
133837
+ /** Restrict an existing file to its owner (chmod 0600 / owner+SYSTEM ACL). */
133838
+ function restrictFileToOwnerSync(target, overrides = {}) {
133839
+ const deps = resolveDeps(overrides);
133840
+ if (deps.platform === "win32") {
133841
+ restrictWindows(target, false, deps);
133842
+ return;
133843
+ }
133844
+ fs.chmodSync(target, FILE_MODE);
133845
+ }
133846
+ /** Restrict an existing directory to its owner (chmod 0700 / owner+SYSTEM, inherited). */
133847
+ function restrictDirToOwnerSync(target, overrides = {}) {
133848
+ const deps = resolveDeps(overrides);
133849
+ if (deps.platform === "win32") {
133850
+ restrictWindows(target, true, deps);
133851
+ return;
133852
+ }
133853
+ fs.chmodSync(target, DIR_MODE);
133854
+ }
133855
+ /**
133856
+ * Atomically write a secret-bearing file that only its owner can read.
133857
+ *
133858
+ * Order matters: the temp file is created EMPTY, restricted, and only then
133859
+ * given its content, so there is no moment at which the secret exists under a
133860
+ * permissive ACL. The rename carries the (already restricted) ACL with it.
133861
+ */
133862
+ function writeOwnerOnlyFileSync(target, data, overrides = {}) {
133863
+ fs.mkdirSync(path$1.dirname(target), { recursive: true });
133864
+ const tmp = `${target}.tmp.${process.pid}`;
133865
+ fs.writeFileSync(tmp, "", { mode: FILE_MODE });
133866
+ try {
133867
+ restrictFileToOwnerSync(tmp, overrides);
133868
+ fs.writeFileSync(tmp, data);
133869
+ fs.renameSync(tmp, target);
133870
+ } catch (err) {
133871
+ fs.rmSync(tmp, { force: true });
133872
+ throw err;
133873
+ }
133874
+ }
133875
+ //#endregion
133876
+ //#region src/speak/secret-file.ts
133877
+ /**
133878
+ * Hand a refresh token or a saved Amazon session to the Python worker as an
133879
+ * owner-only file. The path travels on stdin. The bytes never travel on
133880
+ * argv or in the environment, and they are removed when the call settles.
133881
+ */
133882
+ var REDACTED = "‹redacted›";
133883
+ function decodeSession(sessionB64) {
133884
+ if (sessionB64.length === 0) return Buffer.alloc(0);
133885
+ return Buffer.from(sessionB64, "base64");
133886
+ }
133887
+ async function readSessionB64(sessionPath) {
133888
+ const bytes = await fs$1.readFile(sessionPath);
133889
+ if (bytes.length === 0) return "";
133890
+ return bytes.toString("base64");
133891
+ }
133892
+ /**
133893
+ * Create a 0700 directory, write the refresh token and the session 0600, run
133894
+ * `body`, then delete the directory whether `body` succeeded or threw.
133895
+ * Returns the session file as base64 so the caller can store it in the keychain.
133896
+ */
133897
+ async function withSpeakSecretFiles(input, body) {
133898
+ const dir = await fs$1.mkdtemp(path$1.join(os.tmpdir(), "camstack-alexa-"));
133899
+ const paths = {
133900
+ dir,
133901
+ refreshTokenPath: path$1.join(dir, "refresh-token"),
133902
+ sessionPath: path$1.join(dir, "session")
133903
+ };
133904
+ try {
133905
+ restrictDirToOwnerSync(dir);
133906
+ writeOwnerOnlyFileSync(paths.refreshTokenPath, input.refreshToken);
133907
+ writeOwnerOnlyFileSync(paths.sessionPath, decodeSession(input.sessionB64));
133908
+ await body(paths);
133909
+ return { sessionB64: await readSessionB64(paths.sessionPath) };
133910
+ } finally {
133911
+ await fs$1.rm(dir, {
133912
+ recursive: true,
133913
+ force: true
133914
+ });
133915
+ }
133916
+ }
133917
+ /** Replace every occurrence of each non-empty secret. Used before a log line is emitted. */
133918
+ function scrubSecrets(text, secrets) {
133919
+ let out = text;
133920
+ for (const secret of secrets) {
133921
+ if (secret.length === 0) continue;
133922
+ out = out.split(secret).join(REDACTED);
133923
+ }
133924
+ return out;
133925
+ }
133926
+ //#endregion
133927
+ //#region src/speak/broker-echo.ts
133928
+ var BrokerEcho = class {
133929
+ deps;
133930
+ currentState = "starting";
133931
+ currentError = null;
133932
+ currentDevices = [];
133933
+ constructor(deps) {
133934
+ this.deps = deps;
133935
+ }
133936
+ get id() {
133937
+ return this.deps.brokerId;
133938
+ }
133939
+ get name() {
133940
+ return this.deps.brokerName;
133941
+ }
133942
+ get state() {
133943
+ return this.currentState;
133944
+ }
133945
+ get error() {
133946
+ return this.currentError;
133947
+ }
133948
+ get devices() {
133949
+ return this.currentDevices;
133950
+ }
133951
+ stop() {
133952
+ this.deps.worker.stop();
133953
+ }
133954
+ /** Sign the worker in as the broker's account. Never throws; the outcome is `state`. */
133955
+ async signIn() {
133956
+ const { brokerId, worker, sessions, logger } = this.deps;
133957
+ const tags = { tags: { brokerId } };
133958
+ let secrets = [];
133959
+ try {
133960
+ const seed = await this.deps.readSeed();
133961
+ const stored = await sessions.get(brokerId);
133962
+ if (seed === null && stored.length === 0) {
133963
+ this.currentState = "broker-not-signed-in";
133964
+ this.currentError = null;
133965
+ logger.warn("alexa speak: the broker has no sign-in to share yet", tags);
133966
+ return;
133967
+ }
133968
+ secrets = seed === null ? [] : [seed.refreshToken];
133969
+ worker.noteSecrets(secrets);
133970
+ let outcome = parseLoginResult({
133971
+ status: "failed",
133972
+ error: "worker gave no answer"
133973
+ });
133974
+ const held = await withSpeakSecretFiles({
133975
+ refreshToken: seed?.refreshToken ?? "",
133976
+ sessionB64: stored
133977
+ }, async (paths) => {
133978
+ outcome = parseLoginResult(await worker.request({
133979
+ op: "login_broker",
133980
+ label: brokerId,
133981
+ refreshTokenPath: seed === null ? "" : paths.refreshTokenPath,
133982
+ deviceAppName: seed?.deviceAppName ?? "",
133983
+ domain: this.deps.domain,
133984
+ sessionPath: paths.sessionPath,
133985
+ region: regionOfDomain(this.deps.domain)
133986
+ }));
133987
+ });
133988
+ if (held.sessionB64.length > 0 && held.sessionB64 !== stored) await sessions.put(brokerId, held.sessionB64);
133989
+ this.currentError = outcome.status === "ok" ? null : outcome.error ?? "sign-in failed";
133990
+ this.currentState = outcome.status === "ok" ? "ready" : "failed";
133991
+ if (outcome.status !== "ok") {
133992
+ logger.warn("alexa speak: sign-in as the broker failed", {
133993
+ ...tags,
133994
+ meta: { error: this.currentError }
133995
+ });
133996
+ return;
133997
+ }
133998
+ await this.refreshDevices();
133999
+ } catch (err) {
134000
+ this.currentState = "failed";
134001
+ this.currentError = scrubSecrets(err instanceof Error ? err.message : String(err), secrets);
134002
+ logger.warn("alexa speak: sign-in as the broker failed", {
134003
+ ...tags,
134004
+ meta: { error: this.currentError }
134005
+ });
134006
+ }
134007
+ }
134008
+ /** A session that is not ready tries to sign in again before it gives up. */
134009
+ async ensureReady() {
134010
+ if (this.currentState !== "ready") await this.signIn();
134011
+ return this.currentState === "ready";
134012
+ }
134013
+ async refreshDevices() {
134014
+ if (this.currentState !== "ready") return;
134015
+ try {
134016
+ this.currentDevices = [...parseDevices(await this.deps.worker.request({ op: "devices" }))];
134017
+ } catch (err) {
134018
+ this.deps.logger.warn("alexa speak: device refresh failed", {
134019
+ tags: { brokerId: this.deps.brokerId },
134020
+ meta: { error: err instanceof Error ? err.message : String(err) }
134021
+ });
134022
+ }
134023
+ }
134024
+ async utter(op, targets, text, volume) {
134025
+ if (!await this.ensureReady()) throw new Error(this.notReadyReason());
134026
+ return parseDeliveries(await this.deps.worker.request({
134027
+ op,
134028
+ targets,
134029
+ text,
134030
+ volume
134031
+ }));
134032
+ }
134033
+ async session() {
134034
+ if (this.deps.worker.failed) return {
134035
+ loggedIn: false,
134036
+ ageSec: 0,
134037
+ lastSuccessAt: null
134038
+ };
134039
+ try {
134040
+ return parseSessionStatus(await this.deps.worker.request({ op: "session_status" }));
134041
+ } catch {
134042
+ return {
134043
+ loggedIn: this.currentState === "ready",
134044
+ ageSec: 0,
134045
+ lastSuccessAt: null
134046
+ };
134047
+ }
134048
+ }
134049
+ notReadyReason() {
134050
+ if (this.currentState === "broker-not-signed-in") return `account '${this.deps.brokerName}' is not signed in on its broker`;
134051
+ return this.currentError ?? "alexa session is not ready";
134052
+ }
134053
+ };
134054
+ //#endregion
134055
+ //#region src/speak/migration.ts
134056
+ function legacyLoginOf(settings) {
134057
+ return {
134058
+ email: settings.email,
134059
+ password: settings.password,
134060
+ session: settings.session
134061
+ };
134062
+ }
134063
+ function planLegacyMigration(legacy, brokerIds, targets) {
134064
+ const present = legacy.email.length > 0 || legacy.password.length > 0 || legacy.session.length > 0;
134065
+ const only = brokerIds.length === 1 ? brokerIds[0] ?? null : null;
134066
+ if (!present || only === null) return {
134067
+ present,
134068
+ attachTo: null,
134069
+ targets
134070
+ };
134071
+ return {
134072
+ present,
134073
+ attachTo: legacy.session.length > 0 ? only : null,
134074
+ targets: targets.map((row) => row.brokerId.length > 0 ? row : {
134075
+ ...row,
134076
+ brokerId: only
134077
+ })
134078
+ };
134079
+ }
134080
+ /** What the page must tell the operator about targets that name no account. */
134081
+ function noticeFor(targets, brokerIds) {
134082
+ if (!targets.some((row) => row.brokerId.length === 0)) return "";
134083
+ if (brokerIds.length === 0) return "no-broker";
134084
+ return brokerIds.length > 1 ? "choose-broker" : "";
134085
+ }
134086
+ function unassignedCount(targets) {
134087
+ return targets.filter((row) => row.brokerId.length === 0).length;
134088
+ }
134089
+ //#endregion
134090
+ //#region src/speak/rate-limit.ts
134091
+ /**
134092
+ * Per-Echo admission. A burst is dropped here, not queued: a queue would be
134093
+ * minutes of speech long after the event.
134094
+ */
134095
+ function emptySpeechLedger() {
134096
+ return { byEcho: {} };
134097
+ }
134098
+ /**
134099
+ * Identical text inside the collapse window is one utterance.
134100
+ * A different text inside the gap is refused until the gap elapses.
134101
+ * Admitted calls replace that Echo's memory; refusals leave it as it was.
134102
+ */
134103
+ function admitSpeech(ledger, echoId, text, now) {
134104
+ const previous = ledger.byEcho[echoId];
134105
+ if (previous !== void 0 && previous.lastText === text && now - previous.lastSpokenAt < 6e4) return {
134106
+ decision: "collapsed",
134107
+ ledger
134108
+ };
134109
+ if (previous !== void 0 && now - previous.lastSpokenAt < 2e4) return {
134110
+ decision: "rate_limited",
134111
+ ledger
134112
+ };
134113
+ return {
134114
+ decision: "admit",
134115
+ ledger: { byEcho: {
134116
+ ...ledger.byEcho,
134117
+ [echoId]: {
134118
+ lastSpokenAt: now,
134119
+ lastText: text
134120
+ }
134121
+ } }
134122
+ };
134123
+ }
134124
+ //#endregion
134125
+ //#region src/speak/speech-text.ts
134126
+ var ALEXA_SPEECH_CAPS = {
134127
+ attachments: {
134128
+ mediaTypes: [],
134129
+ mode: "url",
134130
+ max: 0
134131
+ },
134132
+ actions: 0,
134133
+ actionIcons: false,
134134
+ levels: [{
134135
+ id: "default",
134136
+ label: "Default",
134137
+ ordinal: 3
134138
+ }],
134139
+ format: ["text"],
134140
+ clickUrl: false,
134141
+ sound: false,
134142
+ ttl: false,
134143
+ bodyMaxLen: 450
134144
+ };
134145
+ function collapseWhitespace(value) {
134146
+ return value.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/[ \t]{2,}/g, " ").trim();
134147
+ }
134148
+ /** Markup that survived the degrade engine must not be read aloud. */
134149
+ function toPlainSpeech(value) {
134150
+ return collapseWhitespace(htmlToText(markdownToText(value)));
134151
+ }
134152
+ /**
134153
+ * Title then body, as one sentence, never longer than {@link SPEECH_MAX_CHARS}.
134154
+ * An empty result is an empty string — the caller refuses to send it.
134155
+ */
134156
+ function speechFromNotification(notification) {
134157
+ const prepared = prepareNotification(ALEXA_SPEECH_CAPS, notification);
134158
+ const title = prepared.title !== void 0 ? toPlainSpeech(prepared.title) : "";
134159
+ const body = toPlainSpeech(prepared.body);
134160
+ const joined = title.length > 0 && body.length > 0 ? `${title}. ${body}` : title || body;
134161
+ const text = joined.length > 450 ? joined.slice(0, 450).trimEnd() : joined;
134162
+ const truncated = prepared.renderedAs.truncated || text.length < joined.length;
134163
+ return {
134164
+ text,
134165
+ renderedAs: {
134166
+ ...prepared.renderedAs,
134167
+ truncated,
134168
+ format: "text"
134169
+ }
134170
+ };
134171
+ }
134172
+ //#endregion
134173
+ //#region src/speak/provider.ts
134174
+ function quietLogger$1() {
134175
+ const logger = {
134176
+ debug() {},
134177
+ info() {},
134178
+ warn() {},
134179
+ error() {},
134180
+ child() {
134181
+ return logger;
134182
+ },
134183
+ withTags() {
134184
+ return logger;
134185
+ }
134186
+ };
134187
+ return logger;
134188
+ }
134189
+ function readString(config, key) {
134190
+ const value = config[key];
134191
+ return typeof value === "string" ? value : "";
134192
+ }
134193
+ function readMode(config) {
134194
+ return config["mode"] === "announce" ? "announce" : "speak";
134195
+ }
134196
+ function readVolume(config) {
134197
+ const value = config["volume"];
134198
+ if (typeof value !== "number" || !Number.isFinite(value)) return 40;
134199
+ return Math.max(0, Math.min(100, Math.round(value)));
134200
+ }
134201
+ function respectDnd(config) {
134202
+ return config["respectDnd"] !== false;
134203
+ }
134204
+ /** "Kitchen · Living room · ECHO" — the name first, then what tells two Echoes apart. */
134205
+ function echoOptionLabel(device) {
134206
+ const kind = device.kind === "group" ? "Group" : device.family;
134207
+ const parts = [device.name.length > 0 ? device.name : device.id];
134208
+ if (device.room !== void 0 && device.room.length > 0) parts.push(device.room);
134209
+ if (kind.length > 0) parts.push(kind);
134210
+ return parts.join(" · ");
134211
+ }
134212
+ function echoField(devices, showWhen) {
134213
+ return {
134214
+ type: "select",
134215
+ key: "echoId",
134216
+ label: "Echo",
134217
+ labelKey: "import-alexa:echoTarget.s_echo.echoId.label",
134218
+ description: devices.length === 0 ? "No Echo found yet. Sign the Amazon account in on Integrations, Alexa." : void 0,
134219
+ descriptionKey: devices.length === 0 ? "import-alexa:echoTarget.s_echo.echoId.empty" : void 0,
134220
+ options: devices.map((device) => ({
134221
+ value: device.id,
134222
+ label: echoOptionLabel(device)
134223
+ })),
134224
+ ...showWhen === void 0 ? {} : { showWhen }
134225
+ };
134226
+ }
134227
+ /**
134228
+ * The two pickers are not `required`: a required select shows its first option
134229
+ * while the form still holds no value, so a save would drop the choice. Left
134230
+ * optional they open on "Select", and `upsertTarget` refuses an empty pick.
134231
+ *
134232
+ * The Echo picker is built from the accounts' own device lists on every call,
134233
+ * so a new Echo shows up without a deploy. With one account there is no account
134234
+ * field; with several, each account's Echoes appear when that account is chosen.
134235
+ */
134236
+ function configSchema(brokers) {
134237
+ const first = brokers[0];
134238
+ return { sections: [{
134239
+ id: "echo",
134240
+ title: "Echo",
134241
+ titleKey: "import-alexa:echoTarget.s_echo.title",
134242
+ fields: [
134243
+ ...brokers.length > 1 ? [{
134244
+ type: "select",
134245
+ key: "brokerId",
134246
+ label: "Amazon account",
134247
+ labelKey: "import-alexa:echoTarget.s_echo.brokerId.label",
134248
+ options: brokers.map((broker) => ({
134249
+ value: broker.id,
134250
+ label: broker.name
134251
+ }))
134252
+ }, ...brokers.map((broker) => echoField(broker.devices, {
134253
+ field: "brokerId",
134254
+ equals: broker.id
134255
+ }))] : [echoField(first?.devices ?? [])],
134256
+ {
134257
+ type: "select",
134258
+ key: "mode",
134259
+ label: "Mode",
134260
+ labelKey: "import-alexa:echoTarget.s_echo.mode.label",
134261
+ default: "speak",
134262
+ options: [{
134263
+ value: "speak",
134264
+ label: "Speak (quiet hours apply)"
134265
+ }, {
134266
+ value: "announce",
134267
+ label: "Announce (chime, then speech)"
134268
+ }]
134269
+ },
134270
+ {
134271
+ type: "number",
134272
+ key: "volume",
134273
+ label: "Volume",
134274
+ labelKey: "import-alexa:echoTarget.s_echo.volume.label",
134275
+ min: 0,
134276
+ max: 100,
134277
+ default: 40
134278
+ },
134279
+ {
134280
+ type: "boolean",
134281
+ key: "respectDnd",
134282
+ label: "Respect Do Not Disturb",
134283
+ labelKey: "import-alexa:echoTarget.s_echo.respectDnd.label",
134284
+ default: true
134285
+ }
134286
+ ]
134287
+ }] };
134288
+ }
134289
+ /** The Alexa target kind as the Add target form sees it, for the accounts as they are now. */
134290
+ function alexaTargetKind(brokers) {
134291
+ return {
134292
+ kind: ALEXA_SPEAK_KIND,
134293
+ label: "Alexa",
134294
+ icon: "megaphone",
134295
+ iconUrl: ALEXA_SPEAK_ICON_URL,
134296
+ iconMediaType: "image/svg+xml",
134297
+ addonId: ALEXA_SPEAK_ADDON_ID,
134298
+ supportsDiscovery: true,
134299
+ destinationKeys: ["brokerId", "echoId"],
134300
+ configSchema: configSchema(brokers),
134301
+ caps: {
134302
+ attachments: {
134303
+ mediaTypes: [],
134304
+ mode: "url",
134305
+ max: 0
134306
+ },
134307
+ actions: 0,
134308
+ actionIcons: false,
134309
+ levels: [{
134310
+ id: "default",
134311
+ label: "Default",
134312
+ ordinal: 3
134313
+ }],
134314
+ format: ["text"],
134315
+ clickUrl: false,
134316
+ sound: false,
134317
+ ttl: false,
134318
+ bodyMaxLen: 450
134319
+ }
134320
+ };
134321
+ }
134322
+ function discoveredFrom(broker, device) {
134323
+ return {
134324
+ kind: ALEXA_SPEAK_KIND,
134325
+ suggestedName: device.name.length > 0 ? device.name : device.id,
134326
+ config: {
134327
+ brokerId: broker.id,
134328
+ echoId: device.id,
134329
+ mode: "speak",
134330
+ volume: 40,
134331
+ respectDnd: true,
134332
+ family: device.family,
134333
+ deviceKind: device.kind
134334
+ }
134335
+ };
134336
+ }
134337
+ function createAlexaNotificationProvider(ports) {
134338
+ const logger = ports.logger ?? quietLogger$1();
134339
+ let targets = ports.loadTargets();
134340
+ let ledger = emptySpeechLedger();
134341
+ let deliveries = [];
134342
+ function record(row) {
134343
+ deliveries = [...deliveries, row];
134344
+ }
134345
+ /**
134346
+ * The account a target speaks through: the one it names; else the only one
134347
+ * there is; else the one whose Echoes include the target's Echo.
134348
+ */
134349
+ function brokerFor(config) {
134350
+ const all = ports.brokers();
134351
+ const named = readString(config, "brokerId");
134352
+ if (named.length > 0) return all.find((broker) => broker.id === named);
134353
+ if (all.length === 1) return all[0];
134354
+ const echoId = readString(config, "echoId");
134355
+ const owners = all.filter((broker) => broker.devices.some((device) => device.id === echoId));
134356
+ return owners.length === 1 ? owners[0] : void 0;
134357
+ }
134358
+ function persist(next) {
134359
+ targets = next;
134360
+ ports.saveTargets(next);
134361
+ }
134362
+ async function deliver(target, notification) {
134363
+ const speech = speechFromNotification(notification);
134364
+ const echoId = readString(target.config, "echoId");
134365
+ const deviceId = notification.deviceId;
134366
+ const tags = deviceId !== void 0 ? { tags: { deviceId } } : {};
134367
+ if (echoId.length === 0) {
134368
+ record({
134369
+ targetId: target.id,
134370
+ outcome: "failed",
134371
+ at: ports.now(),
134372
+ error: "target has no echo id"
134373
+ });
134374
+ logger.warn("alexa speak dropped: target has no echo id", {
134375
+ ...tags,
134376
+ meta: { targetId: target.id }
134377
+ });
134378
+ return {
134379
+ success: false,
134380
+ error: "target has no echo id",
134381
+ renderedAs: speech.renderedAs
134382
+ };
134383
+ }
134384
+ if (speech.text.length === 0) {
134385
+ record({
134386
+ targetId: target.id,
134387
+ outcome: "empty",
134388
+ at: ports.now()
134389
+ });
134390
+ logger.info("alexa speak dropped: empty text", {
134391
+ ...tags,
134392
+ meta: {
134393
+ targetId: target.id,
134394
+ echoId
134395
+ }
134396
+ });
134397
+ return {
134398
+ success: true,
134399
+ renderedAs: speech.renderedAs
134400
+ };
134401
+ }
134402
+ const broker = brokerFor(target.config);
134403
+ if (broker === void 0) {
134404
+ const error = "target has no Amazon account";
134405
+ record({
134406
+ targetId: target.id,
134407
+ outcome: "failed",
134408
+ at: ports.now(),
134409
+ error
134410
+ });
134411
+ logger.warn("alexa speak dropped: target names no Amazon account", {
134412
+ ...tags,
134413
+ meta: {
134414
+ targetId: target.id,
134415
+ echoId
134416
+ }
134417
+ });
134418
+ return {
134419
+ success: false,
134420
+ error,
134421
+ renderedAs: speech.renderedAs
134422
+ };
134423
+ }
134424
+ const device = broker.devices.find((row) => row.id === echoId);
134425
+ if (device !== void 0 && respectDnd(target.config) && device.dnd) {
134426
+ record({
134427
+ targetId: target.id,
134428
+ outcome: "dnd",
134429
+ at: ports.now()
134430
+ });
134431
+ logger.info("alexa speak skipped: do not disturb", {
134432
+ ...tags,
134433
+ meta: {
134434
+ targetId: target.id,
134435
+ echoId
134436
+ }
134437
+ });
134438
+ return {
134439
+ success: true,
134440
+ renderedAs: speech.renderedAs
134441
+ };
134442
+ }
134443
+ if (device !== void 0 && !device.online) {
134444
+ record({
134445
+ targetId: target.id,
134446
+ outcome: "offline",
134447
+ at: ports.now()
134448
+ });
134449
+ logger.info("alexa speak not delivered: echo offline", {
134450
+ ...tags,
134451
+ meta: {
134452
+ targetId: target.id,
134453
+ echoId
134454
+ }
134455
+ });
134456
+ return {
134457
+ success: false,
134458
+ error: "echo offline",
134459
+ renderedAs: speech.renderedAs
134460
+ };
134461
+ }
134462
+ const admitted = admitSpeech(ledger, echoId, speech.text, ports.now());
134463
+ ledger = admitted.ledger;
134464
+ if (admitted.decision !== "admit") {
134465
+ record({
134466
+ targetId: target.id,
134467
+ outcome: admitted.decision,
134468
+ at: ports.now()
134469
+ });
134470
+ logger.info("alexa speak skipped", {
134471
+ ...tags,
134472
+ meta: {
134473
+ targetId: target.id,
134474
+ echoId,
134475
+ outcome: admitted.decision
134476
+ }
134477
+ });
134478
+ return {
134479
+ success: true,
134480
+ renderedAs: speech.renderedAs
134481
+ };
134482
+ }
134483
+ const mode = readMode(target.config);
134484
+ try {
134485
+ const outcomes = await ports.speak({
134486
+ brokerId: broker.id,
134487
+ mode,
134488
+ targets: [echoId],
134489
+ text: speech.text,
134490
+ volume: readVolume(target.config)
134491
+ });
134492
+ const one = outcomes.find((row) => row.targetId === echoId) ?? outcomes[0];
134493
+ if (one === void 0 || !one.ok) {
134494
+ const error = one?.error ?? "speak failed";
134495
+ record({
134496
+ targetId: target.id,
134497
+ outcome: "failed",
134498
+ at: ports.now(),
134499
+ error
134500
+ });
134501
+ logger.warn("alexa speak failed", {
134502
+ ...tags,
134503
+ meta: {
134504
+ targetId: target.id,
134505
+ echoId,
134506
+ error
134507
+ }
134508
+ });
134509
+ return {
134510
+ success: false,
134511
+ error,
134512
+ renderedAs: speech.renderedAs
134513
+ };
134514
+ }
134515
+ const outcome = mode === "announce" ? "announced" : "spoken";
134516
+ record({
134517
+ targetId: target.id,
134518
+ outcome,
134519
+ at: ports.now()
134520
+ });
134521
+ logger.info("alexa speak delivered", {
134522
+ ...tags,
134523
+ meta: {
134524
+ targetId: target.id,
134525
+ echoId,
134526
+ outcome
134527
+ }
134528
+ });
134529
+ return {
134530
+ success: true,
134531
+ renderedAs: speech.renderedAs
134532
+ };
134533
+ } catch (err) {
134534
+ const error = err instanceof Error ? err.message : String(err);
134535
+ record({
134536
+ targetId: target.id,
134537
+ outcome: "failed",
134538
+ at: ports.now(),
134539
+ error
134540
+ });
134541
+ logger.warn("alexa speak failed", {
134542
+ ...tags,
134543
+ meta: {
134544
+ targetId: target.id,
134545
+ echoId,
134546
+ error
134547
+ }
134548
+ });
134549
+ return {
134550
+ success: false,
134551
+ error,
134552
+ renderedAs: speech.renderedAs
134553
+ };
134554
+ }
134555
+ }
134556
+ return {
134557
+ get deliveries() {
134558
+ return deliveries;
134559
+ },
134560
+ async listTargetKinds() {
134561
+ return [alexaTargetKind(ports.brokers())];
134562
+ },
134563
+ async listTargets() {
134564
+ return [...targets];
134565
+ },
134566
+ async getTarget(input) {
134567
+ const found = targets.find((target) => target.id === input.targetId);
134568
+ if (found === void 0) throw new Error(`target not found: ${input.targetId}`);
134569
+ return found;
134570
+ },
134571
+ async discoverTargets() {
134572
+ const taken = new Set(targets.flatMap((row) => {
134573
+ const owner = brokerFor(row.config);
134574
+ return owner === void 0 ? [] : [`${owner.id}/${readString(row.config, "echoId")}`];
134575
+ }));
134576
+ return ports.brokers().flatMap((broker) => broker.devices.filter((device) => !taken.has(`${broker.id}/${device.id}`)).map((device) => discoveredFrom(broker, device)));
134577
+ },
134578
+ async probeTargetConfig(input) {
134579
+ const broker = brokerFor(input.config);
134580
+ return broker !== void 0 && broker.ready ? {
134581
+ ok: true,
134582
+ resolvedUrl: "alexa://session"
134583
+ } : {
134584
+ ok: false,
134585
+ resolvedUrl: "alexa://session",
134586
+ error: "the Amazon account is not signed in on its broker"
134587
+ };
134588
+ },
134589
+ async send(input) {
134590
+ const target = targets.find((row) => row.id === input.targetId);
134591
+ if (target === void 0) return {
134592
+ success: false,
134593
+ error: `target not found: ${input.targetId}`
134594
+ };
134595
+ if (!target.enabled) return {
134596
+ success: false,
134597
+ error: "target disabled"
134598
+ };
134599
+ return deliver(target, input.notification);
134600
+ },
134601
+ async testTarget(input) {
134602
+ const found = targets.find((row) => row.id === input.targetId);
134603
+ if (found === void 0) return {
134604
+ success: false,
134605
+ error: `target not found: ${input.targetId}`
134606
+ };
134607
+ if (!found.enabled) return {
134608
+ success: false,
134609
+ error: "target disabled"
134610
+ };
134611
+ return deliver(found, input.sample ?? {
134612
+ body: "This is a CamStack test notification.",
134613
+ title: "CamStack",
134614
+ format: "text",
134615
+ priority: 3
134616
+ });
134617
+ },
134618
+ async upsertTarget(input) {
134619
+ const owner = brokerFor(input.target.config);
134620
+ if (readString(input.target.config, "echoId").length === 0) throw new Error("Pick the Echo that should speak");
134621
+ if (owner === void 0) throw new Error("Pick the Amazon account this Echo belongs to");
134622
+ const pinned = readString(input.target.config, "brokerId").length === 0 ? {
134623
+ ...input.target.config,
134624
+ brokerId: owner.id
134625
+ } : input.target.config;
134626
+ const target = {
134627
+ ...input.target,
134628
+ config: pinned,
134629
+ addonId: ALEXA_SPEAK_ADDON_ID,
134630
+ kind: ALEXA_SPEAK_KIND
134631
+ };
134632
+ persist([...targets.filter((row) => row.id !== target.id), target]);
134633
+ return target;
134634
+ },
134635
+ async deleteTarget(input) {
134636
+ persist(targets.filter((row) => row.id !== input.targetId));
134637
+ },
134638
+ async setTargetEnabled(input) {
134639
+ persist(targets.map((row) => row.id === input.targetId ? {
134640
+ ...row,
134641
+ enabled: input.enabled
134642
+ } : row));
134643
+ }
134644
+ };
134645
+ }
134646
+ //#endregion
134647
+ //#region src/speak/python-paths.ts
134648
+ /** Locate bundled `python/alexa_worker.py` from dist/ or from src/ under test. */
134649
+ function resolveAlexaWorkerScript(fromModuleUrl = import.meta.url) {
134650
+ const here = dirname(fileURLToPath(fromModuleUrl));
134651
+ const candidates = [
134652
+ join(here, "../python/alexa_worker.py"),
134653
+ join(here, "../../python/alexa_worker.py"),
134654
+ join(here, "../../../python/alexa_worker.py")
134655
+ ];
134656
+ for (const candidate of candidates) if (existsSync(candidate)) return candidate;
134657
+ throw new Error(`import-alexa: alexa_worker.py not found. Searched:\n${candidates.join("\n")}`);
134658
+ }
134659
+ //#endregion
134660
+ //#region src/speak/worker-client.ts
134661
+ /**
134662
+ * One Python process per broker, for the life of the addon. Requests are one JSON line
134663
+ * on stdin; responses are one JSON line on stdout, matched by id.
134664
+ * A crash restarts the process until the same bound CrashSupervisor uses,
134665
+ * then requests fail instead of looping.
134666
+ */
134667
+ /** Pure: a new timestamp list, never a mutated one. */
134668
+ function recordWorkerCrash(times, now, windowMs, maxCrashes) {
134669
+ const cutoff = now - windowMs;
134670
+ const recent = [...times.filter((t) => t >= cutoff), now];
134671
+ if (recent.length >= maxCrashes) return {
134672
+ action: "failed",
134673
+ times: recent
134674
+ };
134675
+ return {
134676
+ action: "respawn",
134677
+ times: recent
134678
+ };
134679
+ }
134680
+ function quietLogger() {
134681
+ const logger = {
134682
+ debug() {},
134683
+ info() {},
134684
+ warn() {},
134685
+ error() {},
134686
+ child() {
134687
+ return logger;
134688
+ },
134689
+ withTags() {
134690
+ return logger;
134691
+ }
134692
+ };
134693
+ return logger;
134694
+ }
134695
+ function wrapChild(child) {
134696
+ const stdin = child.stdin;
134697
+ const stdout = child.stdout;
134698
+ const stderr = child.stderr;
134699
+ if (stdin === null || stdout === null || stderr === null) throw new Error("alexa worker spawn did not provide pipes");
134700
+ return {
134701
+ stdin: { write(chunk) {
134702
+ stdin.write(chunk);
134703
+ } },
134704
+ stdout: { on(event, listener) {
134705
+ stdout.on(event, listener);
134706
+ } },
134707
+ stderr: { on(event, listener) {
134708
+ stderr.on(event, listener);
134709
+ } },
134710
+ on(event, listener) {
134711
+ if (event === "close") child.on("close", listener);
134712
+ else child.on("error", listener);
134713
+ },
134714
+ kill(signal) {
134715
+ child.kill(signal);
134716
+ }
134717
+ };
134718
+ }
134719
+ function defaultSpawn(pythonPath, scriptPath, env) {
134720
+ return wrapChild(spawn(pythonPath, [scriptPath], {
134721
+ stdio: [
134722
+ "pipe",
134723
+ "pipe",
134724
+ "pipe"
134725
+ ],
134726
+ env
134727
+ }));
134728
+ }
134729
+ function startAlexaWorker(deps) {
134730
+ const logger = deps.logger ?? quietLogger();
134731
+ const now = deps.now ?? (() => Date.now());
134732
+ const schedule = deps.schedule ?? ((fn, delayMs) => {
134733
+ setTimeout(fn, delayMs);
134734
+ });
134735
+ const spawnChild = deps.spawnChild ?? defaultSpawn;
134736
+ const maxCrashes = deps.maxCrashes ?? 5;
134737
+ const windowMs = deps.windowMs ?? 6e4;
134738
+ const env = deps.env ?? process.env;
134739
+ let child = null;
134740
+ let buffer = "";
134741
+ let nextId = 1;
134742
+ let stopped = false;
134743
+ let failed = false;
134744
+ let crashTimes = [];
134745
+ let secrets = [];
134746
+ let pending = null;
134747
+ const queue = [];
134748
+ function scrub(text) {
134749
+ return scrubSecrets(text, secrets);
134750
+ }
134751
+ function failPending(err) {
134752
+ const current = pending;
134753
+ pending = null;
134754
+ current?.reject(err);
134755
+ }
134756
+ function attach(spawned) {
134757
+ child = spawned;
134758
+ buffer = "";
134759
+ let settled = false;
134760
+ const finish = (code) => {
134761
+ if (settled || child !== spawned) return;
134762
+ settled = true;
134763
+ onExit(code);
134764
+ };
134765
+ spawned.stdout.on("data", (chunk) => {
134766
+ buffer += chunk.toString("utf8");
134767
+ const lines = buffer.split("\n");
134768
+ buffer = lines.pop() ?? "";
134769
+ for (const line of lines) {
134770
+ if (line.trim().length === 0) continue;
134771
+ acceptLine(line);
134772
+ }
134773
+ });
134774
+ spawned.stderr.on("data", (chunk) => {
134775
+ const text = scrub(chunk.toString("utf8")).trim();
134776
+ if (text.length === 0) return;
134777
+ logger.warn("alexa worker stderr", { meta: { text: text.slice(0, 500) } });
134778
+ });
134779
+ spawned.on("error", (err) => {
134780
+ logger.warn("alexa worker spawn error", { meta: { error: scrub(err.message) } });
134781
+ finish(null);
134782
+ });
134783
+ spawned.on("close", (code) => {
134784
+ finish(code);
134785
+ });
134786
+ }
134787
+ function acceptLine(line) {
134788
+ let parsed;
134789
+ try {
134790
+ parsed = JSON.parse(line);
134791
+ } catch (err) {
134792
+ logger.warn("alexa worker non-JSON line", { meta: { error: err instanceof Error ? err.message : String(err) } });
134793
+ return;
134794
+ }
134795
+ const checked = WorkerLineSchema.safeParse(parsed);
134796
+ if (!checked.success) {
134797
+ logger.warn("alexa worker line failed schema", { meta: { error: checked.error.message } });
134798
+ return;
134799
+ }
134800
+ const message = checked.data;
134801
+ if (pending === null || pending.id !== message.id) {
134802
+ logger.warn("alexa worker line for an unknown request", { meta: { id: message.id } });
134803
+ return;
134804
+ }
134805
+ const current = pending;
134806
+ pending = null;
134807
+ if (message.ok) current.resolve(message.result);
134808
+ else current.reject(new Error(scrub(message.error)));
134809
+ pump();
134810
+ }
134811
+ function onExit(code) {
134812
+ child = null;
134813
+ if (stopped) {
134814
+ failPending(/* @__PURE__ */ new Error("alexa worker stopped"));
134815
+ return;
134816
+ }
134817
+ failPending(/* @__PURE__ */ new Error(`alexa worker exited (${code ?? "null"})`));
134818
+ const decision = recordWorkerCrash(crashTimes, now(), windowMs, maxCrashes);
134819
+ crashTimes = decision.times;
134820
+ if (decision.action === "failed") {
134821
+ failed = true;
134822
+ logger.error("alexa worker restart bound reached", { meta: { crashes: crashTimes.length } });
134823
+ const waiting = queue.splice(0, queue.length);
134824
+ for (const item of waiting) item.pending.reject(/* @__PURE__ */ new Error("alexa worker restart bound reached"));
134825
+ return;
134826
+ }
134827
+ logger.warn("alexa worker exited; restarting", { meta: {
134828
+ code,
134829
+ crashes: crashTimes.length
134830
+ } });
134831
+ schedule(() => {
134832
+ if (stopped || failed) return;
134833
+ launch();
134834
+ pump();
134835
+ }, Math.min(3e4, 500 * 2 ** Math.min(6, crashTimes.length - 1)));
134836
+ }
134837
+ function launch() {
134838
+ if (stopped || failed) return;
134839
+ attach(spawnChild(deps.pythonPath, deps.scriptPath, env));
134840
+ }
134841
+ function pump() {
134842
+ if (pending !== null || child === null || failed || stopped) return;
134843
+ const next = queue.shift();
134844
+ if (next === void 0) return;
134845
+ pending = next.pending;
134846
+ child.stdin.write(requestLine(next.pending.id, next.request));
134847
+ }
134848
+ function request(body) {
134849
+ if (failed) return Promise.reject(/* @__PURE__ */ new Error("alexa worker restart bound reached"));
134850
+ if (stopped) return Promise.reject(/* @__PURE__ */ new Error("alexa worker stopped"));
134851
+ const id = String(nextId);
134852
+ nextId += 1;
134853
+ return new Promise((resolve, reject) => {
134854
+ queue.push({
134855
+ request: body,
134856
+ pending: {
134857
+ id,
134858
+ resolve,
134859
+ reject
134860
+ }
134861
+ });
134862
+ if (child === null) launch();
134863
+ pump();
134864
+ });
134865
+ }
134866
+ return {
134867
+ request,
134868
+ noteSecrets(next) {
134869
+ secrets = [...next];
134870
+ },
134871
+ stop() {
134872
+ stopped = true;
134873
+ const current = child;
134874
+ child = null;
134875
+ current?.kill("SIGTERM");
134876
+ failPending(/* @__PURE__ */ new Error("alexa worker stopped"));
134877
+ const waiting = queue.splice(0, queue.length);
134878
+ for (const item of waiting) item.pending.reject(/* @__PURE__ */ new Error("alexa worker stopped"));
134879
+ },
134880
+ get failed() {
134881
+ return failed;
134882
+ },
134883
+ get crashesInWindow() {
134884
+ return crashTimes.length;
134885
+ }
134886
+ };
134887
+ }
134888
+ //#endregion
134889
+ //#region src/speak/controller.ts
134890
+ function targetsFrom(settings) {
134891
+ return settings.targets.map((row) => ({
134892
+ id: row.id,
134893
+ name: row.name,
134894
+ kind: ALEXA_SPEAK_KIND,
134895
+ addonId: ALEXA_SPEAK_ADDON_ID,
134896
+ enabled: row.enabled,
134897
+ config: {
134898
+ ...row.brokerId.length > 0 ? { brokerId: row.brokerId } : {},
134899
+ echoId: row.echoId,
134900
+ mode: row.mode,
134901
+ volume: row.volume,
134902
+ respectDnd: row.respectDnd
134903
+ }
134904
+ }));
134905
+ }
134906
+ function rowsFrom(targets) {
134907
+ return targets.map((target) => ({
134908
+ id: target.id,
134909
+ name: target.name,
134910
+ enabled: target.enabled,
134911
+ brokerId: typeof target.config["brokerId"] === "string" ? target.config["brokerId"] : "",
134912
+ echoId: typeof target.config["echoId"] === "string" ? target.config["echoId"] : "",
134913
+ mode: target.config["mode"] === "announce" ? "announce" : "speak",
134914
+ volume: typeof target.config["volume"] === "number" ? target.config["volume"] : 40,
134915
+ respectDnd: target.config["respectDnd"] !== false
134916
+ }));
134917
+ }
134918
+ var AlexaSpeakController = class {
134919
+ host;
134920
+ provider;
134921
+ settings;
134922
+ pythonPath = null;
134923
+ echoes = /* @__PURE__ */ new Map();
134924
+ timer = null;
134925
+ queue = Promise.resolve();
134926
+ now;
134927
+ constructor(host) {
134928
+ this.host = host;
134929
+ this.now = host.now ?? (() => Date.now());
134930
+ this.settings = readSpeakSettings(host.readConfig());
134931
+ this.provider = createAlexaNotificationProvider({
134932
+ brokers: () => this.brokerViews(),
134933
+ now: this.now,
134934
+ logger: host.logger,
134935
+ loadTargets: () => targetsFrom(this.settings),
134936
+ saveTargets: (targets) => {
134937
+ this.settings = {
134938
+ ...this.settings,
134939
+ targets: [...rowsFrom(targets)]
134940
+ };
134941
+ this.persist();
134942
+ },
134943
+ speak: (command) => this.utter(command.brokerId, command.mode, command.targets, command.text, command.volume)
134944
+ });
134945
+ }
134946
+ async start() {
134947
+ this.pythonPath = await this.host.ensurePython();
134948
+ if (this.pythonPath === null) {
134949
+ this.host.logger.warn("alexa speak: embedded Python unavailable; Echo speech will not run");
134950
+ return;
134951
+ }
134952
+ await this.migrateLegacyLogin();
134953
+ await this.brokersChanged();
134954
+ this.timer = setInterval(() => {
134955
+ this.refreshAll();
134956
+ }, DEVICE_REFRESH_MS);
134957
+ }
134958
+ stop() {
134959
+ if (this.timer !== null) clearInterval(this.timer);
134960
+ this.timer = null;
134961
+ for (const echo of this.echoes.values()) echo.stop();
134962
+ this.echoes.clear();
134963
+ }
134964
+ /** The addon calls this whenever its list of brokers may have changed. */
134965
+ brokersChanged() {
134966
+ this.queue = this.queue.then(() => this.reconcile()).catch(() => void 0);
134967
+ return this.queue;
134968
+ }
134969
+ handlers() {
134970
+ return {
134971
+ speakStatus: async () => this.status(),
134972
+ sendTest: async (input) => this.sendTest(input)
134973
+ };
134974
+ }
134975
+ brokerViews() {
134976
+ return this.host.listBrokers().map((ref) => {
134977
+ const echo = this.echoes.get(ref.id);
134978
+ return {
134979
+ id: ref.id,
134980
+ name: ref.name,
134981
+ ready: echo?.state === "ready",
134982
+ devices: echo?.devices ?? []
134983
+ };
134984
+ });
134985
+ }
134986
+ async persist() {
134987
+ try {
134988
+ await this.host.writeSpeak(this.settings);
134989
+ } catch (err) {
134990
+ this.host.logger.warn("alexa speak: could not store settings", { meta: { error: err instanceof Error ? err.message : String(err) } });
134991
+ }
134992
+ }
134993
+ /**
134994
+ * Drain the old standalone login once. With exactly one broker its jar and
134995
+ * its targets move onto that broker; otherwise the jar is dropped and the
134996
+ * targets wait for the operator to choose (the status names that state).
134997
+ */
134998
+ async migrateLegacyLogin() {
134999
+ const brokers = this.host.listBrokers();
135000
+ const plan = planLegacyMigration(legacyLoginOf(this.settings), brokers.map((ref) => ref.id), this.settings.targets);
135001
+ if (!plan.present) return;
135002
+ if (plan.attachTo !== null) await this.host.sessions.put(plan.attachTo, this.settings.session);
135003
+ this.settings = {
135004
+ ...this.settings,
135005
+ email: "",
135006
+ password: "",
135007
+ session: "",
135008
+ targets: [...plan.targets]
135009
+ };
135010
+ await this.persist();
135011
+ await this.host.clearLegacySecrets();
135012
+ this.host.logger.info("alexa speak: the old standalone login was retired", { meta: {
135013
+ attachedTo: plan.attachTo ?? "none",
135014
+ brokers: brokers.length,
135015
+ targets: plan.targets.length
135016
+ } });
135017
+ }
135018
+ makeWorker(brokerId, pythonPath) {
135019
+ if (this.host.startWorker !== void 0) return this.host.startWorker(brokerId, pythonPath);
135020
+ return startAlexaWorker({
135021
+ pythonPath,
135022
+ scriptPath: resolveAlexaWorkerScript(),
135023
+ logger: this.host.logger.withTags({ brokerId }),
135024
+ now: this.now
135025
+ });
135026
+ }
135027
+ async reconcile() {
135028
+ const pythonPath = this.pythonPath;
135029
+ if (pythonPath === null) return;
135030
+ const refs = this.host.listBrokers();
135031
+ const live = new Set(refs.map((ref) => ref.id));
135032
+ for (const [id, echo] of this.echoes) {
135033
+ if (live.has(id)) continue;
135034
+ echo.stop();
135035
+ this.echoes.delete(id);
135036
+ await this.host.sessions.delete(id).catch((err) => {
135037
+ this.host.logger.warn("alexa speak: could not drop the session of a removed account", {
135038
+ tags: { brokerId: id },
135039
+ meta: { error: err instanceof Error ? err.message : String(err) }
135040
+ });
135041
+ });
135042
+ }
135043
+ const fresh = refs.filter((ref) => !this.echoes.has(ref.id));
135044
+ for (const ref of fresh) this.echoes.set(ref.id, new BrokerEcho({
135045
+ brokerId: ref.id,
135046
+ brokerName: ref.name,
135047
+ domain: ref.amazonDomain,
135048
+ worker: this.makeWorker(ref.id, pythonPath),
135049
+ readSeed: () => this.host.readSeed(ref.id),
135050
+ sessions: this.host.sessions,
135051
+ logger: this.host.logger
135052
+ }));
135053
+ await Promise.all(refs.map(async (ref) => {
135054
+ const echo = this.echoes.get(ref.id);
135055
+ if (echo !== void 0 && (fresh.includes(ref) || echo.state !== "ready")) await echo.signIn();
135056
+ }));
135057
+ }
135058
+ async refreshAll() {
135059
+ for (const echo of this.echoes.values()) if (echo.state === "ready") await echo.refreshDevices();
135060
+ else await echo.signIn();
135061
+ }
135062
+ async utter(brokerId, mode, targets, text, volume) {
135063
+ const echo = this.echoes.get(brokerId);
135064
+ if (echo === void 0) throw new Error("no Echo session for this Amazon account");
135065
+ return echo.utter(mode === "announce" ? "announce" : "speak", targets, text, volume);
135066
+ }
135067
+ async status() {
135068
+ const brokers = this.host.listBrokers();
135069
+ const rows = await Promise.all(brokers.map(async (ref) => {
135070
+ const echo = this.echoes.get(ref.id);
135071
+ return {
135072
+ id: ref.id,
135073
+ name: ref.name,
135074
+ state: echo?.state ?? "failed",
135075
+ ...echo === void 0 ? { error: "embedded Python unavailable" } : echo.error === null ? {} : { error: echo.error },
135076
+ devices: [...echo?.devices ?? []],
135077
+ session: echo === void 0 ? {
135078
+ loggedIn: false,
135079
+ ageSec: 0,
135080
+ lastSuccessAt: null
135081
+ } : await echo.session()
135082
+ };
135083
+ }));
135084
+ const targets = this.settings.targets;
135085
+ const ids = brokers.map((ref) => ref.id);
135086
+ return {
135087
+ brokers: rows,
135088
+ unassignedTargets: unassignedCount(targets),
135089
+ notice: noticeFor(targets, ids)
135090
+ };
135091
+ }
135092
+ async sendTest(input) {
135093
+ const outcomes = await this.utter(input.brokerId, input.mode, input.echoIds, input.text, input.volume);
135094
+ const known = new Map((this.echoes.get(input.brokerId)?.devices ?? []).map((device) => [device.id, device]));
135095
+ const next = [...this.settings.targets];
135096
+ for (const echoId of input.echoIds) {
135097
+ if (next.some((row) => row.echoId === echoId && row.brokerId === input.brokerId)) continue;
135098
+ next.push({
135099
+ id: `echo-${input.brokerId}-${echoId}`,
135100
+ name: known.get(echoId)?.name ?? echoId,
135101
+ enabled: true,
135102
+ brokerId: input.brokerId,
135103
+ echoId,
135104
+ mode: input.mode,
135105
+ volume: input.volume,
135106
+ respectDnd: true
135107
+ });
135108
+ }
135109
+ this.settings = {
135110
+ ...this.settings,
135111
+ targets: next
135112
+ };
135113
+ await this.persist();
135114
+ return { outcomes: outcomes.map((row) => ({
135115
+ echoId: row.targetId,
135116
+ ok: row.ok,
135117
+ ...row.error !== void 0 ? { error: row.error } : {}
135118
+ })) };
135119
+ }
135120
+ };
135121
+ //#endregion
135122
+ //#region src/speak/icon-routes.ts
135123
+ var ALEXA_ICON_SVG = "<svg role=\"img\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#00CAFF\"><title>Amazon Alexa</title><path d=\"M12 0C5.37 0 0 5.37 0 12C0 18.09 4.53 23.11 10.4 23.9V21.5A1.59 1.59 0 0 0 9.32 19.97A8.41 8.41 0 0 1 3.6 11.8A8.37 8.37 0 0 1 12.09 3.6A8.4 8.4 0 0 1 20.4 12.31L20.39 12.38A8.68 8.68 0 0 1 20.36 12.76C20.36 12.83 20.35 12.9 20.34 12.96C20.34 13.04 20.33 13.12 20.32 13.19L20.3 13.29C19.27 20.07 10.45 23.87 10.4 23.9C10.92 23.97 11.46 24 12 24C18.63 24 24 18.63 24 12S18.63 0 12 0Z\"/></svg>";
135124
+ /** One day; the mark is immutable per addon version. */
135125
+ var CACHE_CONTROL = "public, max-age=86400, immutable";
135126
+ function speakIconRoutes() {
135127
+ return [{
135128
+ method: "GET",
135129
+ path: `/icons/${ALEXA_SPEAK_KIND}`,
135130
+ access: "public",
135131
+ description: "Alexa brand mark (image/svg+xml) for the Alexa notification target.",
135132
+ handler: async (_request, reply) => {
135133
+ reply.code(200).type("image/svg+xml").header("cache-control", CACHE_CONTROL).send(ALEXA_ICON_SVG);
135134
+ }
135135
+ }];
135136
+ }
135137
+ //#endregion
135138
+ //#region src/speak/session-store.ts
135139
+ /** The Echo worker's cookie jar per broker, in the records store (secret field, keychain-backed). */
135140
+ var RecordsSpeakSessionStore = class RecordsSpeakSessionStore {
135141
+ kind;
135142
+ constructor(kind) {
135143
+ this.kind = kind;
135144
+ }
135145
+ static of(records) {
135146
+ return new RecordsSpeakSessionStore(records.kind(alexaSpeakSessionsEntryKind));
135147
+ }
135148
+ async get(brokerId) {
135149
+ try {
135150
+ const resolved = await this.kind.getResolved(brokerId);
135151
+ return resolved === null ? "" : resolved.data.session;
135152
+ } catch (err) {
135153
+ if (err instanceof RecordSecretUnavailableError) return "";
135154
+ throw err;
135155
+ }
135156
+ }
135157
+ async put(brokerId, sessionB64) {
135158
+ await this.kind.put({
135159
+ id: brokerId,
135160
+ data: {
135161
+ name: brokerId,
135162
+ session: sessionB64
135163
+ }
135164
+ });
135165
+ }
135166
+ async delete(brokerId) {
135167
+ await this.kind.delete(brokerId);
135168
+ }
135169
+ };
135170
+ //#endregion
132682
135171
  //#region src/addon.ts
135172
+ var SPEAK_PAGE = {
135173
+ id: "speak",
135174
+ label: "Speak on Echo",
135175
+ icon: "megaphone",
135176
+ path: "/addon/import-alexa",
135177
+ remoteName: "addon_import_alexa_page",
135178
+ bundle: "remoteEntry.js",
135179
+ section: "administration"
135180
+ };
132683
135181
  /** Default config — a fresh install starts with no accounts. */
132684
135182
  var DEFAULTS = {
132685
135183
  brokers: [],
132686
135184
  proxyLogin: {
132687
135185
  proxyHost: "",
132688
135186
  proxyPort: 3456
135187
+ },
135188
+ speak: {
135189
+ email: "",
135190
+ password: "",
135191
+ region: "EU",
135192
+ session: "",
135193
+ targets: []
132689
135194
  }
132690
135195
  };
132691
135196
  /** Device types an imported Alexa appliance can materialise as. */
@@ -132727,6 +135232,8 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
132727
135232
  proxyLogin = null;
132728
135233
  brokerStore = null;
132729
135234
  activeBrokers = [];
135235
+ speak = null;
135236
+ secretStatePaths = ["speak.password", "speak.session"];
132730
135237
  constructor() {
132731
135238
  super({ ...DEFAULTS });
132732
135239
  }
@@ -132734,7 +135241,10 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
132734
135241
  const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
132735
135242
  const records = this.ctx.records;
132736
135243
  if (records === void 0) throw new Error("import-alexa: ctx.records is required (hub-only addon)");
132737
- await records.declare({ brokers: alexaBrokersEntryKind });
135244
+ await records.declare({
135245
+ brokers: alexaBrokersEntryKind,
135246
+ speakSessions: alexaSpeakSessionsEntryKind
135247
+ });
132738
135248
  this.brokerStore = AlexaBrokerStore.of(records);
132739
135249
  this.activeBrokers = await this.loadPersistedBrokers();
132740
135250
  this.registry = new AlexaBrokerRegistry(this.ctx.logger, {
@@ -132775,26 +135285,71 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
132775
135285
  this.ctx.logger.info("Alexa: import addon initialised", { meta: { brokerCount: this.activeBrokers.length } });
132776
135286
  await this.reconcileIntegrationsToBrokers();
132777
135287
  this.subscribeIntegrationLifecycle();
132778
- const loginRoutes = buildAlexaLoginRoutes({
135288
+ const loginRoutes = [...buildAlexaLoginRoutes({
132779
135289
  logger: this.ctx.logger,
132780
135290
  proxyLogin: this.proxyLogin,
132781
135291
  commitCookie: (input) => this.commitCapturedCookie(input)
132782
- });
132783
- return [
132784
- ...regs,
132785
- {
132786
- capability: brokerCapability,
132787
- provider: this.buildBrokerProvider()
135292
+ }), ...speakIconRoutes()];
135293
+ const speak = new AlexaSpeakController({
135294
+ logger: this.ctx.logger.child("speak"),
135295
+ readConfig: () => this.config,
135296
+ writeSpeak: async (next) => {
135297
+ await this.updateGlobalSettings({ speak: next });
132788
135298
  },
132789
- {
132790
- capability: deviceAdoptionCapability,
132791
- provider: this.buildAdoptionProvider()
135299
+ listBrokers: () => this.activeBrokers.map((entry) => ({
135300
+ id: entry.id,
135301
+ name: entry.name,
135302
+ amazonDomain: entry.connection.amazonDomain
135303
+ })),
135304
+ readSeed: async (brokerId) => {
135305
+ const entry = this.activeBrokers.find((row) => row.id === brokerId);
135306
+ return entry === void 0 ? null : readBrokerSeed(this.ctx.dataDir, entry);
132792
135307
  },
132793
- {
132794
- capability: addonRoutesCapability,
132795
- provider: buildAddonRouteProvider("import-alexa", loginRoutes)
132796
- }
132797
- ];
135308
+ sessions: RecordsSpeakSessionStore.of(records),
135309
+ clearLegacySecrets: async () => {
135310
+ await this.updateGlobalSettingsAsOperator({ speak: {
135311
+ ...this.config.speak,
135312
+ password: SECRET_CLEAR,
135313
+ session: SECRET_CLEAR
135314
+ } });
135315
+ },
135316
+ ensurePython: () => this.ctx.deps.ensurePython()
135317
+ });
135318
+ this.speak = speak;
135319
+ speak.start().catch((err) => {
135320
+ this.ctx.logger.warn("alexa speak: worker did not start", { meta: { error: err instanceof Error ? err.message : String(err) } });
135321
+ });
135322
+ const pages = {
135323
+ id: this.addonId,
135324
+ listPages: () => [SPEAK_PAGE]
135325
+ };
135326
+ return {
135327
+ providers: [
135328
+ ...regs,
135329
+ {
135330
+ capability: brokerCapability,
135331
+ provider: this.buildBrokerProvider()
135332
+ },
135333
+ {
135334
+ capability: deviceAdoptionCapability,
135335
+ provider: this.buildAdoptionProvider()
135336
+ },
135337
+ {
135338
+ capability: addonRoutesCapability,
135339
+ provider: buildAddonRouteProvider("import-alexa", loginRoutes)
135340
+ },
135341
+ {
135342
+ capability: notificationOutputCapability,
135343
+ provider: speak.provider
135344
+ },
135345
+ {
135346
+ capability: addonPagesSourceCapability,
135347
+ provider: pages
135348
+ }
135349
+ ],
135350
+ customActions: alexaSpeakActions,
135351
+ actionHandlers: speak.handlers()
135352
+ };
132798
135353
  }
132799
135354
  async loadPersistedBrokers() {
132800
135355
  const store = this.brokerStore;
@@ -132807,11 +135362,9 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
132807
135362
  async persistBrokerEntries(entries) {
132808
135363
  this.activeBrokers = [...entries];
132809
135364
  const store = this.brokerStore;
132810
- if (store !== null) {
132811
- await store.replaceAll(entries);
132812
- return;
132813
- }
132814
- await this.updateGlobalSettings({ brokers: [...entries] });
135365
+ if (store !== null) await store.replaceAll(entries);
135366
+ else await this.updateGlobalSettings({ brokers: [...entries] });
135367
+ this.speak?.brokersChanged();
132815
135368
  }
132816
135369
  async onConfigChanged() {
132817
135370
  const reg = this.registry;
@@ -132857,8 +135410,11 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
132857
135410
  } });
132858
135411
  }
132859
135412
  }
135413
+ this.speak?.brokersChanged();
132860
135414
  }
132861
135415
  async onShutdown() {
135416
+ this.speak?.stop();
135417
+ this.speak = null;
132862
135418
  alexaCommands.clear();
132863
135419
  try {
132864
135420
  await this.proxyLogin?.dispose();
@@ -133163,4 +135719,4 @@ function capsEqual(a, b) {
133163
135719
  return true;
133164
135720
  }
133165
135721
  //#endregion
133166
- export { AlexaImportAddon, AlexaClientNotWiredError as a, alexaAddonConfigSchema as c, connectionToCredentials as d, settingsToAlexaConnection as f, AlexaAuthError as i, alexaConnectionSchema as l, mappableAppliance as n, StubAlexaClient as o, mapAlexaCapabilitiesToCamStack as r, createAlexaClient as s, buildAlexaApplianceCandidate as t, buildConnectionFormSchema as u };
135722
+ export { AlexaImportAddon, AlexaClientNotWiredError as a, alexaAddonConfigSchema as c, alexaSpeakActions as customActions, connectionToCredentials as d, settingsToAlexaConnection as f, AlexaAuthError as i, alexaConnectionSchema as l, mappableAppliance as n, StubAlexaClient as o, mapAlexaCapabilitiesToCamStack as r, createAlexaClient as s, buildAlexaApplianceCandidate as t, buildConnectionFormSchema as u };