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