@camstack/addon-import-alexa 0.2.148 → 0.2.149

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 (44) hide show
  1. package/dist/MaskShapeCanvas-B6q5RDLi.mjs +13807 -0
  2. package/dist/MotionZonesSettings-BD57OAIs.mjs +309 -0
  3. package/dist/PrivacyMaskSettings-ZxdPEelf.mjs +368 -0
  4. package/dist/SceneMonitorEditor--KqtKX1B.mjs +846 -0
  5. package/dist/_stub.js +39665 -0
  6. package/dist/_virtual_mf-localSharedImportMap___mfe_internal__addon_import_alexa_page-CdRL7bFZ.mjs +156 -0
  7. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.js-BmoaT3cr.mjs +25 -0
  8. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-I8eNxvdp.mjs +26 -0
  9. 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
  10. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_trpc_mf_1_client__loadShare__.js-BHqOw8oh.mjs +25 -0
  11. 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
  12. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react__loadShare__.js-DwXXYAlk.mjs +70 -0
  13. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-CUiVYNMY.mjs +26 -0
  14. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_2_dom__loadShare__.js-CIPmAyPi.mjs +25 -0
  15. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_2_dom_mf_1_client__loadShare__.js-tXm1x5EZ.mjs +25 -0
  16. package/dist/addon-import-alexa.css +3 -0
  17. package/dist/addon.js +2211 -160
  18. package/dist/addon.mjs +2227 -179
  19. package/dist/hostInit-_k3o3MHN.mjs +129 -0
  20. package/dist/index.js +1 -0
  21. package/dist/index.mjs +2 -2
  22. package/dist/player-overlays-CJBC2I_E.mjs +51 -0
  23. package/dist/remoteEntry.js +2 -0
  24. package/dist/remoteEntry.ssr.js +33 -0
  25. package/dist/responsive-N99niSJu.mjs +125 -0
  26. package/dist/rolldown-runtime-HEgqtunE.mjs +20 -0
  27. package/dist/scene-monitor-copy-DeuPVYmS.mjs +227 -0
  28. package/dist/square-CnLcG0Zc.mjs +45 -0
  29. package/dist/trash-2-BzimUQsl.mjs +25 -0
  30. package/dist/ui-text-B5YO4hoJ.mjs +34 -0
  31. package/dist/virtualExposes-lZT06jeg.mjs +2 -0
  32. package/dist/virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_import_alexa_page__remoteEntry_js-CsdMNhUS.mjs +2857 -0
  33. package/dist/virtual_mf-exposes-ssr___mfe_internal__addon_import_alexa_page__remoteEntry_js-CViESMdd.mjs +10 -0
  34. package/dist/virtual_mf-exposes___mfe_internal__addon_import_alexa_page__remoteEntry_js-C3qJCjtV.mjs +27 -0
  35. package/i18n/en.json +6 -1
  36. package/i18n/it.json +6 -1
  37. package/package.json +26 -4
  38. package/python/NOTICE +4 -0
  39. package/python/alexa_gateway.py +199 -0
  40. package/python/alexa_protocol.py +110 -0
  41. package/python/alexa_sequences.py +74 -0
  42. package/python/alexa_worker.py +50 -0
  43. package/python/requirements.txt +3 -0
  44. package/python/test_alexa_protocol.py +168 -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_url = require("node:url");
45
+ let node_fs_promises = require("node:fs/promises");
46
+ node_fs_promises = __toESM(node_fs_promises, 1);
47
+ let node_child_process = require("node:child_process");
48
+ require("node:zlib");
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-0sD_LHLI.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" });
@@ -9017,6 +9047,7 @@ var CAP_NAMES = Object.freeze([
9017
9047
  "login-method",
9018
9048
  "media-player",
9019
9049
  "mesh-network",
9050
+ "meter",
9020
9051
  "metrics-provider",
9021
9052
  "model-convert",
9022
9053
  "model-distributor",
@@ -10074,6 +10105,10 @@ Object.freeze({
10074
10105
  ]
10075
10106
  }
10076
10107
  ],
10108
+ "meter": [{
10109
+ "path": "value",
10110
+ "valueType": "number"
10111
+ }],
10077
10112
  "motion": [
10078
10113
  {
10079
10114
  "path": "detected",
@@ -10817,7 +10852,7 @@ object({
10817
10852
  params: record(string(), union([string(), number()])).optional()
10818
10853
  });
10819
10854
  //#endregion
10820
- //#region ../types/dist/err-msg-BXKzRBwg.mjs
10855
+ //#region ../types/dist/err-msg-DIorGYeC.mjs
10821
10856
  var DeviceType = /* @__PURE__ */ function(DeviceType) {
10822
10857
  DeviceType["Camera"] = "camera";
10823
10858
  DeviceType["Hub"] = "hub";
@@ -10911,8 +10946,14 @@ var DeviceType = /* @__PURE__ */ function(DeviceType) {
10911
10946
  DeviceType["PetFeeder"] = "pet-feeder";
10912
10947
  /** Built-in dishwasher — program lifecycle, door / remote-start gates,
10913
10948
  * salt and rinse-aid levels. Installed with the `dishwasher` cap.
10914
- * Sources: native Bosch Home Connect, reusable by similar integrations. */
10949
+ * Sources: native Bosch Home Connect, reusable by similar integrations.
10950
+ * A plug-built dishwasher carries running-state and power instead. */
10915
10951
  DeviceType["Dishwasher"] = "dishwasher";
10952
+ /** Washing machine. A plug-built one is running while its power stays
10953
+ * above a threshold. No program cap — that surface is the dishwasher's. */
10954
+ DeviceType["Washer"] = "washer";
10955
+ /** Tumble dryer. Same plug-built running surface as {@link Washer}. */
10956
+ DeviceType["Dryer"] = "dryer";
10916
10957
  return DeviceType;
10917
10958
  }({});
10918
10959
  var DeviceFeature = /* @__PURE__ */ function(DeviceFeature) {
@@ -11170,7 +11211,7 @@ function errMsg(err) {
11170
11211
  return String(err);
11171
11212
  }
11172
11213
  //#endregion
11173
- //#region ../types/dist/composition-OhpuespA.mjs
11214
+ //#region ../types/dist/composition-CpETZ4HL.mjs
11174
11215
  /**
11175
11216
  * The ffmpeg build every CamStack node downloads, per platform and arch.
11176
11217
  *
@@ -11926,6 +11967,97 @@ function validateExpressionSource(src) {
11926
11967
  return null;
11927
11968
  }
11928
11969
  /**
11970
+ * One power reading becomes a washer, dryer or dishwasher (D944 helper).
11971
+ *
11972
+ * Standby of the control board is under 5 W; a motor or a heater is far
11973
+ * above that, so 5 W is the line. A door lock or a short pump spike is not
11974
+ * a cycle, so running starts only after 30 s above the line.
11975
+ *
11976
+ * A wash, dry or dishwasher pause (soak, heat-wait, heater off between
11977
+ * bursts) lasts several minutes. Ending the cycle on the first dip would
11978
+ * report "finished" in the middle of the program, so the stop dwell is
11979
+ * 10 minutes on every type. The number is stored per type so one machine
11980
+ * can move later without moving the others.
11981
+ */
11982
+ var APPLIANCE_TEMPLATE_ID = "appliance";
11983
+ var APPLIANCE_KINDS = [
11984
+ "washer",
11985
+ "dryer",
11986
+ "dishwasher"
11987
+ ];
11988
+ DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher;
11989
+ /**
11990
+ * Statistics and integrals over a history read (D944).
11991
+ *
11992
+ * Pure: the same points always give the same answer, so a restart recomputes
11993
+ * rather than trusting a buffer. A gauge mean is time-weighted (value × ms),
11994
+ * never the mean of the samples. A hole with no held value is not integrated
11995
+ * as zero. A truncated read is not published.
11996
+ */
11997
+ var STATISTIC_STATS = [
11998
+ "min",
11999
+ "max",
12000
+ "mean",
12001
+ "sum",
12002
+ "count",
12003
+ "change"
12004
+ ];
12005
+ /**
12006
+ * The range a statistics or integral helper asks history for (D944).
12007
+ *
12008
+ * Calendar edges are the hub's timezone, never UTC midnight: a day is 23, 24
12009
+ * or 25 hours across a DST change. A tier is eligible only when its retention
12010
+ * is strictly longer than the longest that range can be. Forever qualifies.
12011
+ * Equal to a tier's retention is a refusal, not a short answer.
12012
+ */
12013
+ var MINUTE_MS = 6e4;
12014
+ var HOUR_MS = 60 * MINUTE_MS;
12015
+ var DAY_MS = 24 * HOUR_MS;
12016
+ var ROLLING_SPANS = [
12017
+ "1h",
12018
+ "24h",
12019
+ "2d",
12020
+ "7d",
12021
+ "30d",
12022
+ "custom"
12023
+ ];
12024
+ var CALENDAR_PERIODS = [
12025
+ "today",
12026
+ "yesterday",
12027
+ "this-week",
12028
+ "this-month"
12029
+ ];
12030
+ var RESET_PERIODS = [
12031
+ "never",
12032
+ "daily",
12033
+ "weekly",
12034
+ "monthly"
12035
+ ];
12036
+ 5 * MINUTE_MS;
12037
+ 24 * HOUR_MS, 2 * DAY_MS, 7 * DAY_MS, 30 * DAY_MS;
12038
+ 7 * DAY_MS, 30 * DAY_MS, 365 * DAY_MS;
12039
+ var StatisticWindowSchema = discriminatedUnion("kind", [object({
12040
+ kind: literal("rolling"),
12041
+ span: _enum(ROLLING_SPANS),
12042
+ customMinutes: number().int().min(5).optional()
12043
+ }).check((payload) => {
12044
+ if (payload.value.span === "custom" && payload.value.customMinutes === void 0) payload.issues.push({
12045
+ code: "custom",
12046
+ message: "a custom range needs a duration in minutes",
12047
+ path: ["customMinutes"],
12048
+ input: payload.value
12049
+ });
12050
+ if (payload.value.span !== "custom" && payload.value.customMinutes !== void 0) payload.issues.push({
12051
+ code: "custom",
12052
+ message: "only a custom range carries a duration",
12053
+ path: ["customMinutes"],
12054
+ input: payload.value
12055
+ });
12056
+ }), object({
12057
+ kind: literal("calendar"),
12058
+ period: _enum(CALENDAR_PERIODS)
12059
+ })]);
12060
+ /**
11929
12061
  * A composition source identity: `(addonId, stableId)`, never a numeric device
11930
12062
  * id. Split out so helper schemas can name it without a cycle through
11931
12063
  * `composition.ts` (D659).
@@ -11981,14 +12113,37 @@ var HelperMemberSchema = object({
11981
12113
  cap: string().min(1),
11982
12114
  fieldPath: string().min(1)
11983
12115
  });
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
- })]);
12116
+ var HelperSpecSchema = discriminatedUnion("kind", [
12117
+ object({
12118
+ kind: literal("group"),
12119
+ templateId: GroupTemplateIdSchema,
12120
+ aggregate: AggregateReducerSchema,
12121
+ members: array(HelperMemberSchema).min(1).max(64),
12122
+ /** Actuator groups only. Readings leave this unset. */
12123
+ active: ActiveCriterionSchema.optional()
12124
+ }),
12125
+ object({
12126
+ kind: literal("appliance"),
12127
+ templateId: literal(APPLIANCE_TEMPLATE_ID),
12128
+ appliance: _enum(APPLIANCE_KINDS),
12129
+ thresholdWatts: number().positive(),
12130
+ startDwellMs: number().int().nonnegative(),
12131
+ stopDwellMs: number().int().nonnegative(),
12132
+ member: HelperMemberSchema
12133
+ }),
12134
+ object({
12135
+ kind: literal("statistic"),
12136
+ stat: _enum(STATISTIC_STATS),
12137
+ window: StatisticWindowSchema,
12138
+ source: HelperMemberSchema
12139
+ }),
12140
+ object({
12141
+ kind: literal("integral"),
12142
+ reset: _enum(RESET_PERIODS),
12143
+ output: _enum(["energy", "meter"]),
12144
+ source: HelperMemberSchema
12145
+ })
12146
+ ]);
11992
12147
  /** A composed device's stableId is this prefix plus the block id. */
11993
12148
  var COMPOSED_DEVICE_STABLE_ID_PREFIX = "composed-";
11994
12149
  /** Longest snippet a `code` source may carry (slice 4). */
@@ -12062,6 +12217,26 @@ var CompositionFieldSourceSchema = discriminatedUnion("kind", [
12062
12217
  "omit",
12063
12218
  "offline"
12064
12219
  ])
12220
+ }),
12221
+ object({
12222
+ kind: literal("series"),
12223
+ read: CompositionFieldReadSchema,
12224
+ /** `value` is the statistic or the integral. The others are the gap counts. */
12225
+ publish: _enum([
12226
+ "value",
12227
+ "included",
12228
+ "excluded",
12229
+ "total"
12230
+ ]),
12231
+ /**
12232
+ * What an unanswered `value` does. Counts ignore this. `offline` leaves the
12233
+ * device unseeded until history answers, so a restart cannot publish 0.
12234
+ */
12235
+ empty: _enum([
12236
+ "null",
12237
+ "omit",
12238
+ "offline"
12239
+ ])
12065
12240
  })
12066
12241
  ]);
12067
12242
  var CompositionCommandTargetSchema = discriminatedUnion("kind", [object({
@@ -15822,7 +15997,24 @@ var AddonPageDeclarationSchema = object({
15822
15997
  /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
15823
15998
  sectionLabel: string().optional()
15824
15999
  });
15825
- method(_void(), array(AddonPageDeclarationSchema).readonly(), { auth: "admin" });
16000
+ var addonPagesSourceCapability = {
16001
+ name: "addon-pages-source",
16002
+ scope: "system",
16003
+ mode: "collection",
16004
+ internal: true,
16005
+ methods: {
16006
+ /**
16007
+ * `internal: true` did not gate the mount (see the 2026-08-26 note on
16008
+ * `data-store-provider`) — this was reachable on the AppRouter by ANY
16009
+ * authenticated session at the default `auth: 'protected'`. The only
16010
+ * consumer, the `addon-pages-aggregator` builtin, reads it via
16011
+ * `ctx.capabilities.getCollection('addon-pages-source')` — LOCAL-PROCESS
16012
+ * only, never tRPC (per `ctx.capabilities` seeing only the local
16013
+ * process). The public, enriched listing admin-ui actually consumes is
16014
+ * the separate `addon-pages` cap, unaffected by this change.
16015
+ */
16016
+ listPages: method(_void(), array(AddonPageDeclarationSchema).readonly(), { auth: "admin" }) }
16017
+ };
15826
16018
  var AddonHttpRouteSchema = object({
15827
16019
  method: _enum([
15828
16020
  "GET",
@@ -18511,7 +18703,8 @@ var CompositionProblemSchema = object({
18511
18703
  "native-capability-replace",
18512
18704
  "mixed-quantity",
18513
18705
  "helper-mismatch",
18514
- "member-constraint"
18706
+ "member-constraint",
18707
+ "retention"
18515
18708
  ]),
18516
18709
  path: string(),
18517
18710
  message: string()
@@ -18751,7 +18944,7 @@ Object.freeze({
18751
18944
  * One catalog row per base reading a group helper can build (D944 §3).
18752
18945
  * Data, not a form default written into the block.
18753
18946
  */
18754
- var NOW = {
18947
+ var NOW$1 = {
18755
18948
  fieldPath: "lastFetchedAt",
18756
18949
  expr: "now"
18757
18950
  };
@@ -18797,7 +18990,7 @@ var GROUP_TEMPLATES = [
18797
18990
  quantity: "temperature",
18798
18991
  defaultAggregate: "mean",
18799
18992
  empty: "null",
18800
- companions: [NOW]
18993
+ companions: [NOW$1]
18801
18994
  }),
18802
18995
  numeric({
18803
18996
  id: "humidity",
@@ -18808,7 +19001,7 @@ var GROUP_TEMPLATES = [
18808
19001
  quantity: "humidity",
18809
19002
  defaultAggregate: "mean",
18810
19003
  empty: "null",
18811
- companions: [NOW]
19004
+ companions: [NOW$1]
18812
19005
  }),
18813
19006
  numeric({
18814
19007
  id: "pressure",
@@ -18819,7 +19012,7 @@ var GROUP_TEMPLATES = [
18819
19012
  quantity: "pressure",
18820
19013
  defaultAggregate: "mean",
18821
19014
  empty: "null",
18822
- companions: [NOW]
19015
+ companions: [NOW$1]
18823
19016
  }),
18824
19017
  numeric({
18825
19018
  id: "illuminance",
@@ -18830,7 +19023,7 @@ var GROUP_TEMPLATES = [
18830
19023
  quantity: "illuminance",
18831
19024
  defaultAggregate: "mean",
18832
19025
  empty: "null",
18833
- companions: [NOW]
19026
+ companions: [NOW$1]
18834
19027
  }),
18835
19028
  numeric({
18836
19029
  id: "power",
@@ -18841,7 +19034,7 @@ var GROUP_TEMPLATES = [
18841
19034
  quantity: "power",
18842
19035
  defaultAggregate: "sum",
18843
19036
  empty: "omit",
18844
- companions: [NOW]
19037
+ companions: [NOW$1]
18845
19038
  }),
18846
19039
  numeric({
18847
19040
  id: "energy",
@@ -18852,7 +19045,7 @@ var GROUP_TEMPLATES = [
18852
19045
  quantity: "energy",
18853
19046
  defaultAggregate: "sum",
18854
19047
  empty: "omit",
18855
- companions: [NOW]
19048
+ companions: [NOW$1]
18856
19049
  }),
18857
19050
  numeric({
18858
19051
  id: "battery",
@@ -18887,7 +19080,7 @@ var GROUP_TEMPLATES = [
18887
19080
  quantity: "device",
18888
19081
  defaultAggregate: "mean",
18889
19082
  empty: "null",
18890
- companions: [NOW]
19083
+ companions: [NOW$1]
18891
19084
  }),
18892
19085
  binary({
18893
19086
  id: "motion",
@@ -19016,6 +19209,7 @@ function actuator(input) {
19016
19209
  }
19017
19210
  new Map(GROUP_TEMPLATES.map((template) => [template.id, template]));
19018
19211
  DeviceFeature.LightColorRgb, DeviceFeature.LightColorHsv, DeviceFeature.LightColorMired;
19212
+ DeviceType.Camera;
19019
19213
  `${COMPOSED_DEVICE_STABLE_ID_PREFIX}`;
19020
19214
  /**
19021
19215
  * What a composition may BUILD on a device type (G2, D676): the caps a new
@@ -44581,57 +44775,79 @@ var ProbeTargetConfigResultSchema = object({
44581
44775
  stateInstance: string().optional(),
44582
44776
  error: string().optional()
44583
44777
  });
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
- });
44778
+ var notificationOutputCapability = {
44779
+ name: "notification-output",
44780
+ scope: "system",
44781
+ mode: "collection",
44782
+ methods: {
44783
+ listTargetKinds: method(object({}), array(TargetKindSchema)),
44784
+ listTargets: method(object({}), array(TargetSchema), { auth: "admin" }),
44785
+ /**
44786
+ * One target WITH secrets — admin edit / duplicate hydration only.
44787
+ * `listTargets` stays redacted; the editor loads this so password fields
44788
+ * are filled (masked in the input) and a save can round-trip the token
44789
+ * when a destination changes (Duplicate with a new chatId, etc.).
44790
+ */
44791
+ getTarget: method(object({ targetId: string() }), TargetSchema, { auth: "admin" }),
44792
+ discoverTargets: method(object({
44793
+ kind: string(),
44794
+ config: record(string(), unknown()).optional(),
44795
+ /**
44796
+ * When editing / duplicating, the blank password field never round-trips
44797
+ * the stored token. Pass the source target id so the provider can restore
44798
+ * secrets server-side before dialling (same merge as upsert).
44799
+ */
44800
+ targetId: string().optional()
44801
+ }), array(DiscoveredTargetSchema), { auth: "admin" }),
44802
+ probeTargetConfig: method(object({
44803
+ kind: string(),
44804
+ config: record(string(), unknown()),
44805
+ /** See `discoverTargets.targetId` — restore stored secrets for a blank password. */
44806
+ targetId: string().optional()
44807
+ }), ProbeTargetConfigResultSchema, {
44808
+ kind: "mutation",
44809
+ auth: "admin"
44810
+ }),
44811
+ send: method(object({
44812
+ targetId: string(),
44813
+ notification: NotificationSchema
44814
+ }), SendResultSchema, {
44815
+ kind: "mutation",
44816
+ auth: "admin"
44817
+ }),
44818
+ testTarget: method(object({
44819
+ targetId: string(),
44820
+ sample: NotificationSchema.optional()
44821
+ }), TestResultSchema, {
44822
+ kind: "mutation",
44823
+ auth: "admin"
44824
+ }),
44825
+ upsertTarget: method(object({
44826
+ target: TargetSchema,
44827
+ /**
44828
+ * Duplicate flow: create a NEW target id while copying secrets from an
44829
+ * existing row. The UI never holds the stored token; blank / redacted
44830
+ * password fields are restored from this source (D634 still applies if
44831
+ * destinations diverge without a re-typed secret).
44832
+ */
44833
+ cloneSecretsFrom: string().optional()
44834
+ }), TargetSchema, {
44835
+ kind: "mutation",
44836
+ auth: "admin"
44837
+ }),
44838
+ deleteTarget: method(object({ targetId: string() }), _void(), {
44839
+ kind: "mutation",
44840
+ auth: "admin"
44841
+ }),
44842
+ setTargetEnabled: method(object({
44843
+ targetId: string(),
44844
+ enabled: boolean()
44845
+ }), _void(), {
44846
+ kind: "mutation",
44847
+ auth: "admin"
44848
+ })
44849
+ }
44850
+ };
44635
44851
  new Set([
44636
44852
  {
44637
44853
  id: "person",
@@ -61881,6 +62097,44 @@ var lockControlCapability = {
61881
62097
  volatileStateFields: ["lastChangedAt"]
61882
62098
  };
61883
62099
  /**
62100
+ * A monotonic total between resets (D944). Energy uses `power-meter.kwhTotal`
62101
+ * so dashboards keep one series; every other instantaneous reading lands here.
62102
+ * `value` is the time integral in the source quantity's accumulated base
62103
+ * (base-unit seconds, or kWh on the power meter). `null` is a gap, never zero.
62104
+ */
62105
+ var MeterStatusSchema = object({
62106
+ value: number().nullable().meta(readingMeta("device", { label: "Total" })),
62107
+ lastFetchedAt: number()
62108
+ });
62109
+ var meterCapability = {
62110
+ name: "meter",
62111
+ scope: "device",
62112
+ deviceNative: true,
62113
+ mode: "singleton",
62114
+ deviceTypes: [DeviceType.Sensor],
62115
+ methods: {},
62116
+ status: {
62117
+ schema: MeterStatusSchema,
62118
+ kind: "push"
62119
+ },
62120
+ runtimeState: MeterStatusSchema,
62121
+ /**
62122
+ * Runtime-state durability: **restored** — the total is a cache of the
62123
+ * history integral. A restart shows it only until the history pass
62124
+ * recomputes the open period; it is not an in-memory buffer.
62125
+ *
62126
+ * See `RuntimeStateDurability`. Enforced by
62127
+ * `scripts/check-runtime-state-durability.ts`.
62128
+ */
62129
+ durability: "restored",
62130
+ history: { fields: { value: {
62131
+ kind: "counter",
62132
+ deadband: .001,
62133
+ minIntervalMs: 6e4
62134
+ } } },
62135
+ volatileStateFields: ["lastFetchedAt"]
62136
+ };
62137
+ /**
61884
62138
  * Media-player cap. Models HA `media_player.*` (Sonos, Chromecast,
61885
62139
  * Apple TV, Spotify, Roku, generic OTT receivers, …) on
61886
62140
  * `DeviceType.MediaPlayer`. Disjoint from the camera streaming
@@ -64407,7 +64661,12 @@ var powerMeterCapability = {
64407
64661
  scope: "device",
64408
64662
  deviceNative: true,
64409
64663
  mode: "singleton",
64410
- deviceTypes: [DeviceType.Sensor],
64664
+ deviceTypes: [
64665
+ DeviceType.Sensor,
64666
+ DeviceType.Washer,
64667
+ DeviceType.Dryer,
64668
+ DeviceType.Dishwasher
64669
+ ],
64411
64670
  methods: {},
64412
64671
  status: {
64413
64672
  schema: PowerMeterStatusSchema,
@@ -65145,6 +65404,8 @@ var runningCustomize = {
65145
65404
  stateField: "running"
65146
65405
  },
65147
65406
  deviceTypes: [
65407
+ DeviceType.Washer,
65408
+ DeviceType.Dryer,
65148
65409
  DeviceType.Dishwasher,
65149
65410
  DeviceType.Vacuum,
65150
65411
  DeviceType.LawnMower,
@@ -69848,7 +70109,7 @@ method(object({}), array(TrainingBaseModelSchema).readonly(), { auth: "admin" })
69848
70109
  auth: "admin"
69849
70110
  });
69850
70111
  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;
70112
+ 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
70113
  String.raw`rose\(active(\d+), changed(\d+), (\d+(?:\.\d+)?)\)`;
69853
70114
  DeviceRole.BinarySensor, DeviceRole.MotionSensor;
69854
70115
  new Set(CAP_NAMES);
@@ -70188,6 +70449,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
70188
70449
  lawnMowerControl: lawnMowerControlCapability,
70189
70450
  lockControl: lockControlCapability,
70190
70451
  mediaPlayer: mediaPlayerCapability,
70452
+ meter: meterCapability,
70191
70453
  motion: motionCapability,
70192
70454
  motionTrigger: motionTriggerCapability,
70193
70455
  motionZones: motionZonesCapability,
@@ -71447,7 +71709,7 @@ var BaseDeviceProvider = class extends BaseAddon {
71447
71709
  return toDeviceSummary(device, this.addonId);
71448
71710
  }
71449
71711
  };
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;
71712
+ 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
71713
  new Set(Object.values(DeviceType));
71452
71714
  Object.freeze({
71453
71715
  "accessories.getStatus": {
@@ -75636,6 +75898,13 @@ Object.freeze({
75636
75898
  access: "create",
75637
75899
  role: "admin"
75638
75900
  },
75901
+ "meter.getStatus": {
75902
+ capName: "meter",
75903
+ capScope: "device",
75904
+ addonId: null,
75905
+ access: "view",
75906
+ role: "viewer"
75907
+ },
75639
75908
  "metricsProvider.collectSnapshot": {
75640
75909
  capName: "metrics-provider",
75641
75910
  capScope: "system",
@@ -83734,7 +84003,39 @@ var APPLIANCE_CAP_VOCABULARY = {
83734
84003
  phrase: t("When {device} finishes", "Quando {device} finisce"),
83735
84004
  icon: "square"
83736
84005
  }
83737
- }
84006
+ },
84007
+ overrides: { byType: {
84008
+ [DeviceType.Washer]: {
84009
+ state: { running: {
84010
+ whenTrue: t("When the washing machine starts", "Quando la lavatrice inizia"),
84011
+ whenFalse: t("When the washing machine has finished", "Quando la lavatrice ha finito")
84012
+ } },
84013
+ events: {
84014
+ onStarted: { phrase: t("When the washing machine starts", "Quando la lavatrice inizia") },
84015
+ onFinished: { phrase: t("When the washing machine has finished", "Quando la lavatrice ha finito") }
84016
+ }
84017
+ },
84018
+ [DeviceType.Dryer]: {
84019
+ state: { running: {
84020
+ whenTrue: t("When the dryer starts", "Quando l'asciugatrice inizia"),
84021
+ whenFalse: t("When the dryer has finished", "Quando l'asciugatrice ha finito")
84022
+ } },
84023
+ events: {
84024
+ onStarted: { phrase: t("When the dryer starts", "Quando l'asciugatrice inizia") },
84025
+ onFinished: { phrase: t("When the dryer has finished", "Quando l'asciugatrice ha finito") }
84026
+ }
84027
+ },
84028
+ [DeviceType.Dishwasher]: {
84029
+ state: { running: {
84030
+ whenTrue: t("When the dishwasher starts", "Quando la lavastoviglie inizia"),
84031
+ whenFalse: t("When the dishwasher has finished", "Quando la lavastoviglie ha finito")
84032
+ } },
84033
+ events: {
84034
+ onStarted: { phrase: t("When the dishwasher starts", "Quando la lavastoviglie inizia") },
84035
+ onFinished: { phrase: t("When the dishwasher has finished", "Quando la lavastoviglie ha finito") }
84036
+ }
84037
+ }
84038
+ } }
83738
84039
  },
83739
84040
  update: {
83740
84041
  scope: "device",
@@ -84562,6 +84863,17 @@ var SENSOR_CAP_VOCABULARY = {
84562
84863
  }],
84563
84864
  events: { onMotionChanged: { notOperatorFacing: "the `detected` state trigger phrases both edges, from the mirror that survives a dropped event (D49)" } }
84564
84865
  },
84866
+ meter: {
84867
+ scope: "device",
84868
+ noun: t("Meter", "Contatore"),
84869
+ state: [{
84870
+ kind: "numeric",
84871
+ field: "value",
84872
+ quantity: t("the total", "il totale"),
84873
+ label: t("Total", "Totale"),
84874
+ icon: "gauge"
84875
+ }]
84876
+ },
84565
84877
  "numeric-sensor": {
84566
84878
  scope: "device",
84567
84879
  noun: t("Sensor", "Sensore"),
@@ -85034,8 +85346,47 @@ function projectAction(cap, action) {
85034
85346
  };
85035
85347
  }
85036
85348
  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;
85349
+ 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
85350
  DeviceFeature.BatteryOperated;
85351
+ DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher;
85352
+ 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.";
85353
+ 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.";
85354
+ var ALEXA_REASON = "Alexa has no laundry category in this export, and neither power nor running has an Alexa interface.";
85355
+ var GOOGLE_REASON = "Google WASHER, DRYER and DISHWASHER require RunCycle. A power threshold is not that trait, and this export does not publish RunCycle.";
85356
+ 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.";
85357
+ function withheld(reason) {
85358
+ return {
85359
+ [DeviceType.Washer]: {
85360
+ exported: false,
85361
+ reason
85362
+ },
85363
+ [DeviceType.Dryer]: {
85364
+ exported: false,
85365
+ reason
85366
+ },
85367
+ [DeviceType.Dishwasher]: {
85368
+ exported: false,
85369
+ reason
85370
+ }
85371
+ };
85372
+ }
85373
+ function offered(received) {
85374
+ return {
85375
+ [DeviceType.Washer]: {
85376
+ exported: true,
85377
+ as: received
85378
+ },
85379
+ [DeviceType.Dryer]: {
85380
+ exported: true,
85381
+ as: received
85382
+ },
85383
+ [DeviceType.Dishwasher]: {
85384
+ exported: true,
85385
+ as: received
85386
+ }
85387
+ };
85388
+ }
85389
+ offered(HA_AS), withheld(HOMEKIT_REASON), withheld(ALEXA_REASON), withheld(GOOGLE_REASON), withheld(MATTER_REASON);
85039
85390
  Object.freeze({
85040
85391
  "accessories.getStatus": [{
85041
85392
  name: "deviceId",
@@ -86212,6 +86563,11 @@ Object.freeze({
86212
86563
  form: "single",
86213
86564
  optional: false
86214
86565
  }],
86566
+ "meter.getStatus": [{
86567
+ name: "deviceId",
86568
+ form: "single",
86569
+ optional: false
86570
+ }],
86215
86571
  "motion.getStatus": [{
86216
86572
  name: "deviceId",
86217
86573
  form: "single",
@@ -87819,7 +88175,6 @@ Object.freeze({
87819
88175
  "network-access": "ingress",
87820
88176
  "smtp-provider": "email"
87821
88177
  });
87822
- DeviceType.Camera;
87823
88178
  /**
87824
88179
  * A saved Customize assignment (D936). The schema is the type: callers use
87825
88180
  * `z.infer`, they do not restate the shape.
@@ -87965,6 +88320,213 @@ var NC_AUDIO_DEFAULTS = {
87965
88320
  samplingSeconds: 10
87966
88321
  };
87967
88322
  new Set(["devices", "classes"]);
88323
+ /** Strip a small, safe subset of Markdown down to plain text. */
88324
+ function markdownToText(md) {
88325
+ 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();
88326
+ }
88327
+ var HTML_ENTITIES = [
88328
+ [/&nbsp;/g, " "],
88329
+ [/&amp;/g, "&"],
88330
+ [/&lt;/g, "<"],
88331
+ [/&gt;/g, ">"],
88332
+ [/&quot;/g, "\""],
88333
+ [/&#39;/g, "'"]
88334
+ ];
88335
+ /** Strip HTML tags, decode a few entities, map block/br elements to newlines. */
88336
+ function htmlToText(html) {
88337
+ let out = html.replace(/<\s*br\s*\/?\s*>/gi, "\n").replace(/<\s*\/\s*(p|div|li|h[1-6])\s*>/gi, "\n").replace(/<[^>]+>/g, "");
88338
+ for (const [re, rep] of HTML_ENTITIES) out = out.replace(re, rep);
88339
+ return out.replace(/\n{3,}/g, "\n\n").trim();
88340
+ }
88341
+ var HTML_ESCAPES = [
88342
+ [/&/g, "&amp;"],
88343
+ [/</g, "&lt;"],
88344
+ [/>/g, "&gt;"],
88345
+ [/"/g, "&quot;"],
88346
+ [/'/g, "&#39;"]
88347
+ ];
88348
+ function escapeHtml(text) {
88349
+ let out = text;
88350
+ for (const [re, rep] of HTML_ESCAPES) out = out.replace(re, rep);
88351
+ return out;
88352
+ }
88353
+ /** Plain text → HTML-lite (escape + newlines to <br>). */
88354
+ function textToHtml(text) {
88355
+ return escapeHtml(text).replace(/\n/g, "<br>");
88356
+ }
88357
+ /** Markdown → HTML-lite (bold/italic/code/links; used e.g. for Pushover). */
88358
+ function markdownToHtmlLite(md) {
88359
+ 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>");
88360
+ }
88361
+ /**
88362
+ * Preference order when the source format is NOT supported — first supported
88363
+ * entry wins. Encodes the Apprise-style "degrade DOWN, but preserve rendering
88364
+ * where a richer target exists" policy:
88365
+ * - markdown → html (html-lite) before text (strip)
88366
+ * - html → text before markdown
88367
+ * - text → markdown before html (text is valid markdown)
88368
+ */
88369
+ var DEGRADE_ORDER = {
88370
+ text: [
88371
+ "text",
88372
+ "markdown",
88373
+ "html"
88374
+ ],
88375
+ markdown: [
88376
+ "markdown",
88377
+ "html",
88378
+ "text"
88379
+ ],
88380
+ html: [
88381
+ "html",
88382
+ "text",
88383
+ "markdown"
88384
+ ]
88385
+ };
88386
+ /**
88387
+ * Pick the best target format the kind supports for a given source format.
88388
+ *
88389
+ * TOLERANT of a source outside the union. `NotificationSchema.format` carries
88390
+ * `.default('text')`, but that default only runs where the schema is PARSED —
88391
+ * the tRPC router. A cap call arriving from another addon (`ctx.api`) skips
88392
+ * that parse, so `format` reaches here `undefined`, and the bare
88393
+ * `DEGRADE_ORDER[source]` threw `is not iterable`. That broke the one promise
88394
+ * the degrade path makes ("never throws — degradation is reported, never
88395
+ * surfaced as an error") and dead-lettered every Notification-Center delivery
88396
+ * for a day (2026-07-30). An unknown source degrades like `text`.
88397
+ */
88398
+ function resolveFormat(source, supported) {
88399
+ for (const candidate of DEGRADE_ORDER[source] ?? DEGRADE_ORDER.text) if (supported.includes(candidate)) return candidate;
88400
+ return supported[0] ?? "text";
88401
+ }
88402
+ /** Transcode a body between formats. */
88403
+ function transcodeBody(body, from, to) {
88404
+ if (from === to) return body;
88405
+ if (to === "text") {
88406
+ if (from === "markdown") return markdownToText(body);
88407
+ if (from === "html") return htmlToText(body);
88408
+ }
88409
+ if (to === "html") {
88410
+ if (from === "text") return textToHtml(body);
88411
+ if (from === "markdown") return markdownToHtmlLite(body);
88412
+ }
88413
+ if (to === "markdown") {
88414
+ if (from === "text") return body;
88415
+ if (from === "html") return htmlToText(body);
88416
+ }
88417
+ return body;
88418
+ }
88419
+ function toResolvedLevel(level) {
88420
+ return {
88421
+ id: level.id,
88422
+ ordinal: level.ordinal,
88423
+ ...level.flags ? { flags: level.flags } : {},
88424
+ ...level.requires ? { requires: level.requires } : {}
88425
+ };
88426
+ }
88427
+ function resolveLevel(levels, n) {
88428
+ if (levels.length === 0) return null;
88429
+ if (n.level !== void 0) {
88430
+ const explicit = levels.find((l) => l.id === n.level);
88431
+ if (explicit) return toResolvedLevel(explicit);
88432
+ }
88433
+ const withOrdinal = levels.filter((l) => l.ordinal !== null);
88434
+ if (withOrdinal.length === 0) return toResolvedLevel(levels[0]);
88435
+ const priority = n.priority;
88436
+ return toResolvedLevel([...withOrdinal].sort((a, b) => {
88437
+ const da = Math.abs((a.ordinal ?? 0) - priority);
88438
+ const db = Math.abs((b.ordinal ?? 0) - priority);
88439
+ if (da !== db) return da - db;
88440
+ return (b.ordinal ?? 0) - (a.ordinal ?? 0);
88441
+ })[0]);
88442
+ }
88443
+ function splitBody(body, maxLen) {
88444
+ if (!Number.isFinite(maxLen) || maxLen <= 0 || body.length <= maxLen) return [body];
88445
+ const parts = [];
88446
+ for (let i = 0; i < body.length; i += maxLen) parts.push(body.slice(i, i + maxLen));
88447
+ return parts;
88448
+ }
88449
+ /**
88450
+ * Prepare a canonical notification for one kind. Never throws — degradation is
88451
+ * always reported in `renderedAs`, never surfaced as an error.
88452
+ */
88453
+ function prepareNotification(caps, n) {
88454
+ const dropped = [];
88455
+ const sourceFormat = n.format;
88456
+ const format = resolveFormat(sourceFormat, caps.format);
88457
+ if (format !== sourceFormat) dropped.push("format");
88458
+ const transcoded = transcodeBody(n.body, sourceFormat, format);
88459
+ const level = resolveLevel(caps.levels, n);
88460
+ const inAttachments = n.attachments ?? [];
88461
+ const attachments = [];
88462
+ for (const att of inAttachments) {
88463
+ if (!caps.attachments.mediaTypes.includes(att.mediaType)) {
88464
+ dropped.push(`attachment:${att.mediaType}`);
88465
+ continue;
88466
+ }
88467
+ if (caps.attachments.mode === "url" && att.url === void 0) {
88468
+ dropped.push("attachment:noUrl");
88469
+ continue;
88470
+ }
88471
+ if (att.bytes !== void 0 && caps.attachments.maxBytes !== void 0 && att.bytes.length > caps.attachments.maxBytes) {
88472
+ dropped.push("attachment:maxBytes");
88473
+ continue;
88474
+ }
88475
+ if (attachments.length >= caps.attachments.max) {
88476
+ dropped.push("attachment:max");
88477
+ continue;
88478
+ }
88479
+ const needsFetch = caps.attachments.mode === "bytes" && att.bytes === void 0 && att.url !== void 0;
88480
+ attachments.push({
88481
+ mediaType: att.mediaType,
88482
+ ...att.url !== void 0 ? { url: att.url } : {},
88483
+ ...att.bytes !== void 0 ? { bytes: att.bytes } : {},
88484
+ ...att.mime !== void 0 ? { mime: att.mime } : {},
88485
+ ...att.name !== void 0 ? { name: att.name } : {},
88486
+ needsFetch
88487
+ });
88488
+ }
88489
+ const inActions = n.actions ?? [];
88490
+ const kept = inActions.slice(0, Math.max(0, caps.actions));
88491
+ if (inActions.length > kept.length) dropped.push("actions");
88492
+ const iconsSupported = caps.actionIcons === true;
88493
+ if (!iconsSupported && kept.some((a) => a.icon !== void 0)) dropped.push("actionIcons");
88494
+ const actions = iconsSupported ? kept : kept.map(({ icon: _icon, ...rest }) => rest);
88495
+ let clickUrl = null;
88496
+ if (n.clickUrl !== void 0) if (caps.clickUrl) clickUrl = n.clickUrl;
88497
+ else dropped.push("clickUrl");
88498
+ let sound = null;
88499
+ if (n.sound !== void 0) if (caps.sound) sound = n.sound;
88500
+ else dropped.push("sound");
88501
+ let ttl = null;
88502
+ if (n.ttl !== void 0) if (caps.ttl) ttl = n.ttl;
88503
+ else dropped.push("ttl");
88504
+ const tag = n.tag ?? null;
88505
+ const bodyParts = splitBody(transcoded, caps.bodyMaxLen);
88506
+ const truncated = bodyParts.length > 1;
88507
+ const renderedAs = {
88508
+ level: level?.id ?? "",
88509
+ format,
88510
+ attachmentsSent: attachments.length,
88511
+ actionsSent: actions.length,
88512
+ truncated,
88513
+ dropped
88514
+ };
88515
+ return {
88516
+ body: bodyParts[0] ?? "",
88517
+ ...n.title !== void 0 ? { title: n.title } : {},
88518
+ format,
88519
+ level,
88520
+ attachments,
88521
+ actions,
88522
+ clickUrl,
88523
+ sound,
88524
+ ttl,
88525
+ tag,
88526
+ bodyParts,
88527
+ renderedAs
88528
+ };
88529
+ }
87968
88530
  new Set(NC_LEGACY_SYSTEM_EVENT_KINDS);
87969
88531
  /**
87970
88532
  * Every filter, for the callers that must cover all of them (guards, tests).
@@ -89186,12 +89748,55 @@ var alexaProxyLoginSchema = object({
89186
89748
  proxyHost: string().default("").describe("Host/IP the browser uses to reach the login proxy (blank = auto-detect)"),
89187
89749
  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
89750
  });
89751
+ /** Amazon account used to speak on Echo devices. The session blob is the cookie jar. */
89752
+ var alexaSpeakSettingsSchema = object({
89753
+ email: string().default(""),
89754
+ password: string().default("").describe("Amazon password").meta({ secret: true }),
89755
+ region: _enum([
89756
+ "NA",
89757
+ "EU",
89758
+ "FE"
89759
+ ]).default("EU"),
89760
+ session: string().default("").describe("Alexa session blob").meta({ secret: true }),
89761
+ targets: array(object({
89762
+ id: string(),
89763
+ name: string(),
89764
+ enabled: boolean(),
89765
+ echoId: string(),
89766
+ mode: _enum(["speak", "announce"]),
89767
+ volume: number(),
89768
+ respectDnd: boolean()
89769
+ })).default([])
89770
+ });
89771
+ function isRecord$2(value) {
89772
+ return typeof value === "object" && value !== null && !Array.isArray(value);
89773
+ }
89774
+ /** Resolved settings. A keychain marker (an object) is an empty secret, not a password. */
89775
+ function readSpeakSettings(config) {
89776
+ const raw = isRecord$2(config.speak) ? config.speak : {};
89777
+ const region = raw["region"];
89778
+ const targets = raw["targets"];
89779
+ return alexaSpeakSettingsSchema.parse({
89780
+ email: typeof raw["email"] === "string" ? raw["email"] : "",
89781
+ password: typeof raw["password"] === "string" ? raw["password"] : "",
89782
+ session: typeof raw["session"] === "string" ? raw["session"] : "",
89783
+ ...region === "NA" || region === "EU" || region === "FE" ? { region } : {},
89784
+ ...Array.isArray(targets) ? { targets } : {}
89785
+ });
89786
+ }
89189
89787
  /** Top-level addon config — broker entries + the proxy-login settings. */
89190
89788
  var alexaAddonConfigSchema = object({
89191
89789
  brokers: array(AlexaBrokerEntrySchema).default([]),
89192
89790
  proxyLogin: alexaProxyLoginSchema.default({
89193
89791
  proxyHost: "",
89194
89792
  proxyPort: 3456
89793
+ }),
89794
+ speak: alexaSpeakSettingsSchema.default({
89795
+ email: "",
89796
+ password: "",
89797
+ region: "EU",
89798
+ session: "",
89799
+ targets: []
89195
89800
  })
89196
89801
  });
89197
89802
  /** Narrow an `unknown` to a shallow plain-object record (cast-free boundary). */
@@ -112965,17 +113570,17 @@ var require_view = /* @__PURE__ */ __commonJSMin(((exports, module) => {
112965
113570
  * @private
112966
113571
  */
112967
113572
  var debug = require_src$2()("express:view");
112968
- var path$7 = require("path");
113573
+ var path$8 = require("path");
112969
113574
  var fs$4 = require("fs");
112970
113575
  /**
112971
113576
  * Module variables.
112972
113577
  * @private
112973
113578
  */
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;
113579
+ var dirname = path$8.dirname;
113580
+ var basename = path$8.basename;
113581
+ var extname = path$8.extname;
113582
+ var join = path$8.join;
113583
+ var resolve = path$8.resolve;
112979
113584
  /**
112980
113585
  * Module exports.
112981
113586
  * @public
@@ -113136,7 +113741,7 @@ var require_content_disposition = /* @__PURE__ */ __commonJSMin(((exports, modul
113136
113741
  * Module dependencies.
113137
113742
  * @private
113138
113743
  */
113139
- var basename = require("path").basename;
113744
+ var basename$1 = require("path").basename;
113140
113745
  var Buffer = require_safe_buffer().Buffer;
113141
113746
  /**
113142
113747
  * RegExp to match non attr-char, *after* encodeURIComponent (i.e. not including "%")
@@ -113259,9 +113864,9 @@ var require_content_disposition = /* @__PURE__ */ __commonJSMin(((exports, modul
113259
113864
  if (fallback === void 0) fallback = true;
113260
113865
  if (typeof fallback !== "string" && typeof fallback !== "boolean") throw new TypeError("fallback must be a string or boolean");
113261
113866
  if (typeof fallback === "string" && NON_LATIN1_REGEXP.test(fallback)) throw new TypeError("fallback must be ISO-8859-1 string");
113262
- var name = basename(filename);
113867
+ var name = basename$1(filename);
113263
113868
  var isQuotedString = TEXT_REGEXP.test(name);
113264
- var fallbackName = typeof fallback !== "string" ? fallback && getlatin1(name) : basename(fallback);
113869
+ var fallbackName = typeof fallback !== "string" ? fallback && getlatin1(name) : basename$1(fallback);
113265
113870
  var hasFallback = typeof fallbackName === "string" && fallbackName !== name;
113266
113871
  if (hasFallback || !isQuotedString || HEX_ESCAPE_REGEXP.test(name)) params["filename*"] = name;
113267
113872
  if (isQuotedString || hasFallback) params.filename = hasFallback ? fallbackName : name;
@@ -115558,7 +116163,7 @@ var require_send = /* @__PURE__ */ __commonJSMin(((exports, module) => {
115558
116163
  var ms = require_ms();
115559
116164
  var onFinished = require_on_finished();
115560
116165
  var parseRange = require_range_parser();
115561
- var path$6 = require("path");
116166
+ var path$7 = require("path");
115562
116167
  var statuses = require_statuses();
115563
116168
  var Stream = require("stream");
115564
116169
  var util$4 = require("util");
@@ -115566,11 +116171,11 @@ var require_send = /* @__PURE__ */ __commonJSMin(((exports, module) => {
115566
116171
  * Path function references.
115567
116172
  * @private
115568
116173
  */
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;
116174
+ var extname = path$7.extname;
116175
+ var join = path$7.join;
116176
+ var normalize = path$7.normalize;
116177
+ var resolve = path$7.resolve;
116178
+ var sep = path$7.sep;
115574
116179
  /**
115575
116180
  * Regular expression for identifying a bytes Range header.
115576
116181
  * @private
@@ -117512,7 +118117,7 @@ var require_application = /* @__PURE__ */ __commonJSMin(((exports, module) => {
117512
118117
  var deprecate = require_depd()("express");
117513
118118
  var flatten = require_array_flatten();
117514
118119
  var merge = require_utils_merge();
117515
- var resolve$1 = require("path").resolve;
118120
+ var resolve$2 = require("path").resolve;
117516
118121
  var setPrototypeOf = require_setprototypeof();
117517
118122
  /**
117518
118123
  * Module variables.
@@ -117575,7 +118180,7 @@ var require_application = /* @__PURE__ */ __commonJSMin(((exports, module) => {
117575
118180
  this.mountpath = "/";
117576
118181
  this.locals.settings = this.settings;
117577
118182
  this.set("view", View);
117578
- this.set("views", resolve$1("views"));
118183
+ this.set("views", resolve$2("views"));
117579
118184
  this.set("jsonp callback name", "callback");
117580
118185
  if (env === "production") this.enable("view cache");
117581
118186
  Object.defineProperty(this, "router", { get: function() {
@@ -119593,7 +120198,7 @@ var require_response = /* @__PURE__ */ __commonJSMin(((exports, module) => {
119593
120198
  var http$3 = require("http");
119594
120199
  var isAbsolute = require_utils$2().isAbsolute;
119595
120200
  var onFinished = require_on_finished();
119596
- var path$5 = require("path");
120201
+ var path$6 = require("path");
119597
120202
  var statuses = require_statuses();
119598
120203
  var merge = require_utils_merge();
119599
120204
  var sign = require_cookie_signature().sign;
@@ -119602,9 +120207,9 @@ var require_response = /* @__PURE__ */ __commonJSMin(((exports, module) => {
119602
120207
  var setCharset = require_utils$2().setCharset;
119603
120208
  var cookie = require_cookie();
119604
120209
  var send = require_send();
119605
- var extname = path$5.extname;
120210
+ var extname = path$6.extname;
119606
120211
  var mime = send.mime;
119607
- var resolve = path$5.resolve;
120212
+ var resolve = path$6.resolve;
119608
120213
  var vary = require_vary();
119609
120214
  /**
119610
120215
  * Response prototype.
@@ -120436,7 +121041,7 @@ var require_serve_static = /* @__PURE__ */ __commonJSMin(((exports, module) => {
120436
121041
  var encodeUrl = require_encodeurl();
120437
121042
  var escapeHtml = require_escape_html();
120438
121043
  var parseUrl = require_parseurl();
120439
- var resolve = require("path").resolve;
121044
+ var resolve$1 = require("path").resolve;
120440
121045
  var send = require_send();
120441
121046
  var url$6 = require("url");
120442
121047
  /**
@@ -120460,7 +121065,7 @@ var require_serve_static = /* @__PURE__ */ __commonJSMin(((exports, module) => {
120460
121065
  var setHeaders = opts.setHeaders;
120461
121066
  if (setHeaders && typeof setHeaders !== "function") throw new TypeError("option setHeaders must be function");
120462
121067
  opts.maxage = opts.maxage || opts.maxAge || 0;
120463
- opts.root = resolve(root);
121068
+ opts.root = resolve$1(root);
120464
121069
  var onDirectory = redirect ? createRedirectDirectoryListener() : createNotFoundDirectoryListener();
120465
121070
  return function serveStatic(req, res, next) {
120466
121071
  if (req.method !== "GET" && req.method !== "HEAD") {
@@ -124129,7 +124734,7 @@ var require_braces = /* @__PURE__ */ __commonJSMin(((exports, module) => {
124129
124734
  //#endregion
124130
124735
  //#region ../../node_modules/micromatch/node_modules/picomatch/lib/constants.js
124131
124736
  var require_constants = /* @__PURE__ */ __commonJSMin(((exports, module) => {
124132
- var path$4 = require("path");
124737
+ var path$5 = require("path");
124133
124738
  var WIN_SLASH = "\\\\/";
124134
124739
  var WIN_NO_SLASH = `[^${WIN_SLASH}]`;
124135
124740
  var DEFAULT_MAX_EXTGLOB_RECURSION = 0;
@@ -124254,7 +124859,7 @@ var require_constants = /* @__PURE__ */ __commonJSMin(((exports, module) => {
124254
124859
  CHAR_UNDERSCORE: 95,
124255
124860
  CHAR_VERTICAL_LINE: 124,
124256
124861
  CHAR_ZERO_WIDTH_NOBREAK_SPACE: 65279,
124257
- SEP: path$4.sep,
124862
+ SEP: path$5.sep,
124258
124863
  /**
124259
124864
  * Create EXTGLOB_CHARS
124260
124865
  */
@@ -124298,7 +124903,7 @@ var require_constants = /* @__PURE__ */ __commonJSMin(((exports, module) => {
124298
124903
  //#endregion
124299
124904
  //#region ../../node_modules/micromatch/node_modules/picomatch/lib/utils.js
124300
124905
  var require_utils = /* @__PURE__ */ __commonJSMin(((exports) => {
124301
- var path$3 = require("path");
124906
+ var path$4 = require("path");
124302
124907
  var win32 = process.platform === "win32";
124303
124908
  var { REGEX_BACKSLASH, REGEX_REMOVE_BACKSLASH, REGEX_SPECIAL_CHARS, REGEX_SPECIAL_CHARS_GLOBAL } = require_constants();
124304
124909
  exports.isObject = (val) => val !== null && typeof val === "object" && !Array.isArray(val);
@@ -124318,7 +124923,7 @@ var require_utils = /* @__PURE__ */ __commonJSMin(((exports) => {
124318
124923
  };
124319
124924
  exports.isWindows = (options) => {
124320
124925
  if (options && typeof options.windows === "boolean") return options.windows;
124321
- return win32 === true || path$3.sep === "\\";
124926
+ return win32 === true || path$4.sep === "\\";
124322
124927
  };
124323
124928
  exports.escapeLast = (input, char, lastIdx) => {
124324
124929
  const idx = input.lastIndexOf(char, lastIdx);
@@ -125688,7 +126293,7 @@ var require_parse = /* @__PURE__ */ __commonJSMin(((exports, module) => {
125688
126293
  //#endregion
125689
126294
  //#region ../../node_modules/micromatch/node_modules/picomatch/lib/picomatch.js
125690
126295
  var require_picomatch$1 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
125691
- var path$2 = require("path");
126296
+ var path$3 = require("path");
125692
126297
  var scan = require_scan();
125693
126298
  var parse = require_parse();
125694
126299
  var utils = require_utils();
@@ -125827,7 +126432,7 @@ var require_picomatch$1 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
125827
126432
  * @api public
125828
126433
  */
125829
126434
  picomatch.matchBase = (input, glob, options, posix = utils.isWindows(options)) => {
125830
- return (glob instanceof RegExp ? glob : picomatch.makeRe(glob, options)).test(path$2.basename(input));
126435
+ return (glob instanceof RegExp ? glob : picomatch.makeRe(glob, options)).test(path$3.basename(input));
125831
126436
  };
125832
126437
  /**
125833
126438
  * Returns true if **any** of the given glob `patterns` match the specified `string`.
@@ -126913,7 +127518,7 @@ var require_proxy = /* @__PURE__ */ __commonJSMin(((exports, module) => {
126913
127518
  var querystring$2 = require("querystring");
126914
127519
  var cookieTools = require_cookie$1();
126915
127520
  var fs$1 = require("fs");
126916
- var path$1 = require("path");
127521
+ var path$2 = require("path");
126917
127522
  var crypto$1 = require("crypto");
126918
127523
  var FORMERDATA_STORE_VERSION = 4;
126919
127524
  function addCookies(Cookie, headers) {
@@ -126944,11 +127549,11 @@ var require_proxy = /* @__PURE__ */ __commonJSMin(((exports, module) => {
126944
127549
  }
126945
127550
  function initAmazonProxy(_options, callbackCookie, callbackListening) {
126946
127551
  const initialCookies = {};
126947
- const formerDataStorePath = _options.formerDataStorePath || path$1.join(__dirname, "formerDataStore.json");
127552
+ const formerDataStorePath = _options.formerDataStorePath || path$2.join(__dirname, "formerDataStore.json");
126948
127553
  let formerDataStoreValid = false;
126949
127554
  if (!_options.formerRegistrationData) try {
126950
127555
  if (fs$1.existsSync(formerDataStorePath)) {
126951
- const formerDataStore = JSON.parse(fs$1.readFileSync(path$1.join(__dirname, "formerDataStore.json"), "utf8"));
127556
+ const formerDataStore = JSON.parse(fs$1.readFileSync(path$2.join(__dirname, "formerDataStore.json"), "utf8"));
126952
127557
  if (typeof formerDataStore === "object" && formerDataStore.storeVersion === FORMERDATA_STORE_VERSION) {
126953
127558
  _options.formerRegistrationData = _options.formerRegistrationData || {};
126954
127559
  _options.formerRegistrationData.frc = _options.formerRegistrationData.frc || formerDataStore.frc;
@@ -132679,13 +133284,1365 @@ var AlexaBrokerStore = class AlexaBrokerStore {
132679
133284
  }
132680
133285
  };
132681
133286
  //#endregion
133287
+ //#region src/speak/constants.ts
133288
+ /** Echo speech limits and the worker restart bound (same window as CrashSupervisor). */
133289
+ var ALEXA_SPEAK_ADDON_ID = "import-alexa";
133290
+ var ALEXA_SPEAK_KIND = "alexa-echo";
133291
+ var DEVICE_REFRESH_MS = 5 * 6e4;
133292
+ var OTP_ALERT_ID = "import-alexa.speak-otp-required";
133293
+ var ALEXA_REGIONS = [
133294
+ "NA",
133295
+ "EU",
133296
+ "FE"
133297
+ ];
133298
+ //#endregion
133299
+ //#region src/speak/worker-protocol.ts
133300
+ /** Line-delimited JSON the long-lived Python worker speaks. One object per line. */
133301
+ var EchoDeviceSchema = object({
133302
+ id: string().min(1),
133303
+ name: string(),
133304
+ family: string(),
133305
+ online: boolean(),
133306
+ kind: _enum(["device", "group"]),
133307
+ dnd: boolean(),
133308
+ deviceType: string()
133309
+ });
133310
+ var LoginStatusSchema = _enum([
133311
+ "ok",
133312
+ "otp_needed",
133313
+ "captcha",
133314
+ "failed"
133315
+ ]);
133316
+ var SessionStatusSchema = object({
133317
+ loggedIn: boolean(),
133318
+ ageSec: number().int().nonnegative(),
133319
+ lastSuccessAt: number().int().nullable()
133320
+ });
133321
+ var TargetDeliverySchema = object({
133322
+ targetId: string(),
133323
+ ok: boolean(),
133324
+ error: string().optional()
133325
+ });
133326
+ var WorkerLineSchema = union([object({
133327
+ id: string(),
133328
+ ok: literal(true),
133329
+ result: unknown().optional()
133330
+ }), object({
133331
+ id: string(),
133332
+ ok: literal(false),
133333
+ error: string()
133334
+ })]);
133335
+ function requestLine(id, request) {
133336
+ return `${JSON.stringify({
133337
+ id,
133338
+ ...request
133339
+ })}\n`;
133340
+ }
133341
+ function parseLoginResult(result) {
133342
+ const parsed = object({
133343
+ status: LoginStatusSchema,
133344
+ error: string().optional()
133345
+ }).safeParse(result);
133346
+ if (!parsed.success) return {
133347
+ status: "failed",
133348
+ error: "worker returned an unexpected login result"
133349
+ };
133350
+ return parsed.data;
133351
+ }
133352
+ function parseDevices(result) {
133353
+ const parsed = object({ devices: array(EchoDeviceSchema) }).safeParse(result);
133354
+ if (!parsed.success) throw new Error("worker returned an unexpected device list");
133355
+ return parsed.data.devices;
133356
+ }
133357
+ function parseDeliveries(result) {
133358
+ const parsed = object({ outcomes: array(TargetDeliverySchema) }).safeParse(result);
133359
+ if (!parsed.success) throw new Error("worker returned an unexpected delivery list");
133360
+ return parsed.data.outcomes;
133361
+ }
133362
+ function parseSessionStatus(result) {
133363
+ const parsed = SessionStatusSchema.safeParse(result);
133364
+ if (!parsed.success) throw new Error("worker returned an unexpected session status");
133365
+ return parsed.data;
133366
+ }
133367
+ //#endregion
133368
+ //#region src/speak/actions.ts
133369
+ var SpeakStatusSchema = object({
133370
+ phase: _enum([
133371
+ "sign-in",
133372
+ "otp",
133373
+ "captcha",
133374
+ "devices"
133375
+ ]),
133376
+ email: string(),
133377
+ region: _enum(ALEXA_REGIONS),
133378
+ devices: array(EchoDeviceSchema),
133379
+ session: SessionStatusSchema
133380
+ });
133381
+ var LoginResultSchema = object({
133382
+ status: LoginStatusSchema,
133383
+ error: string().optional()
133384
+ });
133385
+ var alexaSpeakActions = defineCustomActions({
133386
+ speakStatus: customAction(object({}), SpeakStatusSchema, {
133387
+ kind: "mutation",
133388
+ auth: "admin"
133389
+ }),
133390
+ loginStart: customAction(object({
133391
+ email: string().min(1),
133392
+ password: string().min(1).meta({ secret: true }),
133393
+ region: _enum(ALEXA_REGIONS)
133394
+ }), LoginResultSchema, {
133395
+ kind: "mutation",
133396
+ auth: "admin"
133397
+ }),
133398
+ loginOtp: customAction(object({ code: string().min(1).max(12) }), LoginResultSchema, {
133399
+ kind: "mutation",
133400
+ auth: "admin"
133401
+ }),
133402
+ sendTest: customAction(object({
133403
+ echoIds: array(string().min(1)).min(1),
133404
+ mode: _enum(["speak", "announce"]),
133405
+ volume: number().int().min(0).max(100),
133406
+ text: string().min(1).max(450)
133407
+ }), object({ outcomes: array(object({
133408
+ echoId: string(),
133409
+ ok: boolean(),
133410
+ error: string().optional()
133411
+ })) }), {
133412
+ kind: "mutation",
133413
+ auth: "admin"
133414
+ })
133415
+ });
133416
+ //#endregion
133417
+ //#region src/speak/otp-alert.ts
133418
+ /**
133419
+ * A login that needs a new one-time code raises ONE operator alert.
133420
+ * Further failures in the same episode do not raise another.
133421
+ */
133422
+ function buildOtpAlert(now, reason) {
133423
+ const otp = reason === "otp";
133424
+ return {
133425
+ id: OTP_ALERT_ID,
133426
+ category: EventCategory.AddonError,
133427
+ severity: "error",
133428
+ title: otp ? "Alexa needs a new sign-in code" : "Alexa sign-in needs a captcha",
133429
+ message: otp ? "Amazon asked for a one-time code before Echo devices can speak. Open Alexa → Speak on Echo and enter the code." : "Amazon asked for a captcha. Wait a few minutes, then sign in again on Alexa → Speak on Echo.",
133430
+ action: {
133431
+ label: "Sign in",
133432
+ href: "/addon/import-alexa",
133433
+ navigation: "app"
133434
+ },
133435
+ status: "active",
133436
+ read: false,
133437
+ createdAt: now,
133438
+ updatedAt: now,
133439
+ source: {
133440
+ type: "addon",
133441
+ id: "import-alexa"
133442
+ },
133443
+ metadata: { episodeKey: reason }
133444
+ };
133445
+ }
133446
+ var OtpAttention = class {
133447
+ ports;
133448
+ open = null;
133449
+ constructor(ports) {
133450
+ this.ports = ports;
133451
+ }
133452
+ /** First need in an episode emits the alert. A repeat does not. */
133453
+ async needed(reason) {
133454
+ if (this.open !== null) return "already-open";
133455
+ this.open = reason;
133456
+ await this.ports.emit(buildOtpAlert(this.ports.now(), reason));
133457
+ return "raised";
133458
+ }
133459
+ async cleared() {
133460
+ if (this.open === null) return;
133461
+ this.open = null;
133462
+ await this.ports.resolve(OTP_ALERT_ID);
133463
+ }
133464
+ };
133465
+ //#endregion
133466
+ //#region src/speak/rate-limit.ts
133467
+ /**
133468
+ * Per-Echo admission. A burst is dropped here, not queued: a queue would be
133469
+ * minutes of speech long after the event.
133470
+ */
133471
+ function emptySpeechLedger() {
133472
+ return { byEcho: {} };
133473
+ }
133474
+ /**
133475
+ * Identical text inside the collapse window is one utterance.
133476
+ * A different text inside the gap is refused until the gap elapses.
133477
+ * Admitted calls replace that Echo's memory; refusals leave it as it was.
133478
+ */
133479
+ function admitSpeech(ledger, echoId, text, now) {
133480
+ const previous = ledger.byEcho[echoId];
133481
+ if (previous !== void 0 && previous.lastText === text && now - previous.lastSpokenAt < 6e4) return {
133482
+ decision: "collapsed",
133483
+ ledger
133484
+ };
133485
+ if (previous !== void 0 && now - previous.lastSpokenAt < 2e4) return {
133486
+ decision: "rate_limited",
133487
+ ledger
133488
+ };
133489
+ return {
133490
+ decision: "admit",
133491
+ ledger: { byEcho: {
133492
+ ...ledger.byEcho,
133493
+ [echoId]: {
133494
+ lastSpokenAt: now,
133495
+ lastText: text
133496
+ }
133497
+ } }
133498
+ };
133499
+ }
133500
+ //#endregion
133501
+ //#region src/speak/speech-text.ts
133502
+ var ALEXA_SPEECH_CAPS = {
133503
+ attachments: {
133504
+ mediaTypes: [],
133505
+ mode: "url",
133506
+ max: 0
133507
+ },
133508
+ actions: 0,
133509
+ actionIcons: false,
133510
+ levels: [{
133511
+ id: "default",
133512
+ label: "Default",
133513
+ ordinal: 3
133514
+ }],
133515
+ format: ["text"],
133516
+ clickUrl: false,
133517
+ sound: false,
133518
+ ttl: false,
133519
+ bodyMaxLen: 450
133520
+ };
133521
+ function collapseWhitespace(value) {
133522
+ return value.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/[ \t]{2,}/g, " ").trim();
133523
+ }
133524
+ /** Markup that survived the degrade engine must not be read aloud. */
133525
+ function toPlainSpeech(value) {
133526
+ return collapseWhitespace(htmlToText(markdownToText(value)));
133527
+ }
133528
+ /**
133529
+ * Title then body, as one sentence, never longer than {@link SPEECH_MAX_CHARS}.
133530
+ * An empty result is an empty string — the caller refuses to send it.
133531
+ */
133532
+ function speechFromNotification(notification) {
133533
+ const prepared = prepareNotification(ALEXA_SPEECH_CAPS, notification);
133534
+ const title = prepared.title !== void 0 ? toPlainSpeech(prepared.title) : "";
133535
+ const body = toPlainSpeech(prepared.body);
133536
+ const joined = title.length > 0 && body.length > 0 ? `${title}. ${body}` : title || body;
133537
+ const text = joined.length > 450 ? joined.slice(0, 450).trimEnd() : joined;
133538
+ const truncated = prepared.renderedAs.truncated || text.length < joined.length;
133539
+ return {
133540
+ text,
133541
+ renderedAs: {
133542
+ ...prepared.renderedAs,
133543
+ truncated,
133544
+ format: "text"
133545
+ }
133546
+ };
133547
+ }
133548
+ //#endregion
133549
+ //#region src/speak/provider.ts
133550
+ function quietLogger$1() {
133551
+ const logger = {
133552
+ debug() {},
133553
+ info() {},
133554
+ warn() {},
133555
+ error() {},
133556
+ child() {
133557
+ return logger;
133558
+ },
133559
+ withTags() {
133560
+ return logger;
133561
+ }
133562
+ };
133563
+ return logger;
133564
+ }
133565
+ function readString(config, key) {
133566
+ const value = config[key];
133567
+ return typeof value === "string" ? value : "";
133568
+ }
133569
+ function readMode(config) {
133570
+ return config["mode"] === "announce" ? "announce" : "speak";
133571
+ }
133572
+ function readVolume(config) {
133573
+ const value = config["volume"];
133574
+ if (typeof value !== "number" || !Number.isFinite(value)) return 40;
133575
+ return Math.max(0, Math.min(100, Math.round(value)));
133576
+ }
133577
+ function respectDnd(config) {
133578
+ return config["respectDnd"] !== false;
133579
+ }
133580
+ function configSchema() {
133581
+ return { sections: [{
133582
+ id: "echo",
133583
+ title: "Echo",
133584
+ titleKey: "import-alexa:echoTarget.s_echo.title",
133585
+ fields: [
133586
+ {
133587
+ type: "text",
133588
+ key: "echoId",
133589
+ label: "Echo id",
133590
+ labelKey: "import-alexa:echoTarget.s_echo.echoId.label",
133591
+ required: true
133592
+ },
133593
+ {
133594
+ type: "select",
133595
+ key: "mode",
133596
+ label: "Mode",
133597
+ labelKey: "import-alexa:echoTarget.s_echo.mode.label",
133598
+ default: "speak",
133599
+ options: [{
133600
+ value: "speak",
133601
+ label: "Speak (quiet hours apply)"
133602
+ }, {
133603
+ value: "announce",
133604
+ label: "Announce (chime, then speech)"
133605
+ }]
133606
+ },
133607
+ {
133608
+ type: "number",
133609
+ key: "volume",
133610
+ label: "Volume",
133611
+ labelKey: "import-alexa:echoTarget.s_echo.volume.label",
133612
+ min: 0,
133613
+ max: 100,
133614
+ default: 40
133615
+ },
133616
+ {
133617
+ type: "boolean",
133618
+ key: "respectDnd",
133619
+ label: "Respect Do Not Disturb",
133620
+ labelKey: "import-alexa:echoTarget.s_echo.respectDnd.label",
133621
+ default: true
133622
+ }
133623
+ ]
133624
+ }] };
133625
+ }
133626
+ function kindDescriptor() {
133627
+ return {
133628
+ kind: ALEXA_SPEAK_KIND,
133629
+ label: "Alexa",
133630
+ icon: "megaphone",
133631
+ addonId: ALEXA_SPEAK_ADDON_ID,
133632
+ supportsDiscovery: true,
133633
+ destinationKeys: ["echoId"],
133634
+ configSchema: configSchema(),
133635
+ caps: {
133636
+ attachments: {
133637
+ mediaTypes: [],
133638
+ mode: "url",
133639
+ max: 0
133640
+ },
133641
+ actions: 0,
133642
+ actionIcons: false,
133643
+ levels: [{
133644
+ id: "default",
133645
+ label: "Default",
133646
+ ordinal: 3
133647
+ }],
133648
+ format: ["text"],
133649
+ clickUrl: false,
133650
+ sound: false,
133651
+ ttl: false,
133652
+ bodyMaxLen: 450
133653
+ }
133654
+ };
133655
+ }
133656
+ function discoveredFrom(device) {
133657
+ return {
133658
+ kind: ALEXA_SPEAK_KIND,
133659
+ suggestedName: device.name.length > 0 ? device.name : device.id,
133660
+ config: {
133661
+ echoId: device.id,
133662
+ mode: "speak",
133663
+ volume: 40,
133664
+ respectDnd: true,
133665
+ family: device.family,
133666
+ deviceKind: device.kind
133667
+ }
133668
+ };
133669
+ }
133670
+ function createAlexaNotificationProvider(ports) {
133671
+ const logger = ports.logger ?? quietLogger$1();
133672
+ let targets = ports.loadTargets();
133673
+ let ledger = emptySpeechLedger();
133674
+ let deliveries = [];
133675
+ function record(row) {
133676
+ deliveries = [...deliveries, row];
133677
+ }
133678
+ function deviceFor(echoId) {
133679
+ return ports.devices().find((device) => device.id === echoId);
133680
+ }
133681
+ function persist(next) {
133682
+ targets = next;
133683
+ ports.saveTargets(next);
133684
+ }
133685
+ async function deliver(target, notification) {
133686
+ const speech = speechFromNotification(notification);
133687
+ const echoId = readString(target.config, "echoId");
133688
+ const deviceId = notification.deviceId;
133689
+ const tags = deviceId !== void 0 ? { tags: { deviceId } } : {};
133690
+ if (echoId.length === 0) {
133691
+ record({
133692
+ targetId: target.id,
133693
+ outcome: "failed",
133694
+ at: ports.now(),
133695
+ error: "target has no echo id"
133696
+ });
133697
+ logger.warn("alexa speak dropped: target has no echo id", {
133698
+ ...tags,
133699
+ meta: { targetId: target.id }
133700
+ });
133701
+ return {
133702
+ success: false,
133703
+ error: "target has no echo id",
133704
+ renderedAs: speech.renderedAs
133705
+ };
133706
+ }
133707
+ if (speech.text.length === 0) {
133708
+ record({
133709
+ targetId: target.id,
133710
+ outcome: "empty",
133711
+ at: ports.now()
133712
+ });
133713
+ logger.info("alexa speak dropped: empty text", {
133714
+ ...tags,
133715
+ meta: {
133716
+ targetId: target.id,
133717
+ echoId
133718
+ }
133719
+ });
133720
+ return {
133721
+ success: true,
133722
+ renderedAs: speech.renderedAs
133723
+ };
133724
+ }
133725
+ const device = deviceFor(echoId);
133726
+ if (device !== void 0 && respectDnd(target.config) && device.dnd) {
133727
+ record({
133728
+ targetId: target.id,
133729
+ outcome: "dnd",
133730
+ at: ports.now()
133731
+ });
133732
+ logger.info("alexa speak skipped: do not disturb", {
133733
+ ...tags,
133734
+ meta: {
133735
+ targetId: target.id,
133736
+ echoId
133737
+ }
133738
+ });
133739
+ return {
133740
+ success: true,
133741
+ renderedAs: speech.renderedAs
133742
+ };
133743
+ }
133744
+ if (device !== void 0 && !device.online) {
133745
+ record({
133746
+ targetId: target.id,
133747
+ outcome: "offline",
133748
+ at: ports.now()
133749
+ });
133750
+ logger.info("alexa speak not delivered: echo offline", {
133751
+ ...tags,
133752
+ meta: {
133753
+ targetId: target.id,
133754
+ echoId
133755
+ }
133756
+ });
133757
+ return {
133758
+ success: false,
133759
+ error: "echo offline",
133760
+ renderedAs: speech.renderedAs
133761
+ };
133762
+ }
133763
+ const admitted = admitSpeech(ledger, echoId, speech.text, ports.now());
133764
+ ledger = admitted.ledger;
133765
+ if (admitted.decision !== "admit") {
133766
+ record({
133767
+ targetId: target.id,
133768
+ outcome: admitted.decision,
133769
+ at: ports.now()
133770
+ });
133771
+ logger.info("alexa speak skipped", {
133772
+ ...tags,
133773
+ meta: {
133774
+ targetId: target.id,
133775
+ echoId,
133776
+ outcome: admitted.decision
133777
+ }
133778
+ });
133779
+ return {
133780
+ success: true,
133781
+ renderedAs: speech.renderedAs
133782
+ };
133783
+ }
133784
+ const mode = readMode(target.config);
133785
+ try {
133786
+ const outcomes = await ports.speak({
133787
+ mode,
133788
+ targets: [echoId],
133789
+ text: speech.text,
133790
+ volume: readVolume(target.config)
133791
+ });
133792
+ const one = outcomes.find((row) => row.targetId === echoId) ?? outcomes[0];
133793
+ if (one === void 0 || !one.ok) {
133794
+ const error = one?.error ?? "speak failed";
133795
+ record({
133796
+ targetId: target.id,
133797
+ outcome: "failed",
133798
+ at: ports.now(),
133799
+ error
133800
+ });
133801
+ logger.warn("alexa speak failed", {
133802
+ ...tags,
133803
+ meta: {
133804
+ targetId: target.id,
133805
+ echoId,
133806
+ error
133807
+ }
133808
+ });
133809
+ return {
133810
+ success: false,
133811
+ error,
133812
+ renderedAs: speech.renderedAs
133813
+ };
133814
+ }
133815
+ const outcome = mode === "announce" ? "announced" : "spoken";
133816
+ record({
133817
+ targetId: target.id,
133818
+ outcome,
133819
+ at: ports.now()
133820
+ });
133821
+ logger.info("alexa speak delivered", {
133822
+ ...tags,
133823
+ meta: {
133824
+ targetId: target.id,
133825
+ echoId,
133826
+ outcome
133827
+ }
133828
+ });
133829
+ return {
133830
+ success: true,
133831
+ renderedAs: speech.renderedAs
133832
+ };
133833
+ } catch (err) {
133834
+ const error = err instanceof Error ? err.message : String(err);
133835
+ record({
133836
+ targetId: target.id,
133837
+ outcome: "failed",
133838
+ at: ports.now(),
133839
+ error
133840
+ });
133841
+ logger.warn("alexa speak failed", {
133842
+ ...tags,
133843
+ meta: {
133844
+ targetId: target.id,
133845
+ echoId,
133846
+ error
133847
+ }
133848
+ });
133849
+ return {
133850
+ success: false,
133851
+ error,
133852
+ renderedAs: speech.renderedAs
133853
+ };
133854
+ }
133855
+ }
133856
+ return {
133857
+ get deliveries() {
133858
+ return deliveries;
133859
+ },
133860
+ async listTargetKinds() {
133861
+ return [kindDescriptor()];
133862
+ },
133863
+ async listTargets() {
133864
+ return [...targets];
133865
+ },
133866
+ async getTarget(input) {
133867
+ const found = targets.find((target) => target.id === input.targetId);
133868
+ if (found === void 0) throw new Error(`target not found: ${input.targetId}`);
133869
+ return found;
133870
+ },
133871
+ async discoverTargets() {
133872
+ return ports.devices().map(discoveredFrom);
133873
+ },
133874
+ async probeTargetConfig() {
133875
+ return ports.loggedIn() ? {
133876
+ ok: true,
133877
+ resolvedUrl: "alexa://session"
133878
+ } : {
133879
+ ok: false,
133880
+ resolvedUrl: "alexa://session",
133881
+ error: "not signed in"
133882
+ };
133883
+ },
133884
+ async send(input) {
133885
+ const target = targets.find((row) => row.id === input.targetId);
133886
+ if (target === void 0) return {
133887
+ success: false,
133888
+ error: `target not found: ${input.targetId}`
133889
+ };
133890
+ if (!target.enabled) return {
133891
+ success: false,
133892
+ error: "target disabled"
133893
+ };
133894
+ return deliver(target, input.notification);
133895
+ },
133896
+ async testTarget(input) {
133897
+ const found = targets.find((row) => row.id === input.targetId);
133898
+ if (found === void 0) return {
133899
+ success: false,
133900
+ error: `target not found: ${input.targetId}`
133901
+ };
133902
+ if (!found.enabled) return {
133903
+ success: false,
133904
+ error: "target disabled"
133905
+ };
133906
+ return deliver(found, input.sample ?? {
133907
+ body: "This is a CamStack test notification.",
133908
+ title: "CamStack",
133909
+ format: "text",
133910
+ priority: 3
133911
+ });
133912
+ },
133913
+ async upsertTarget(input) {
133914
+ const target = {
133915
+ ...input.target,
133916
+ addonId: ALEXA_SPEAK_ADDON_ID,
133917
+ kind: ALEXA_SPEAK_KIND
133918
+ };
133919
+ persist([...targets.filter((row) => row.id !== target.id), target]);
133920
+ return target;
133921
+ },
133922
+ async deleteTarget(input) {
133923
+ persist(targets.filter((row) => row.id !== input.targetId));
133924
+ },
133925
+ async setTargetEnabled(input) {
133926
+ persist(targets.map((row) => row.id === input.targetId ? {
133927
+ ...row,
133928
+ enabled: input.enabled
133929
+ } : row));
133930
+ }
133931
+ };
133932
+ }
133933
+ //#endregion
133934
+ //#region src/speak/python-paths.ts
133935
+ /** Locate bundled `python/alexa_worker.py` from dist/ or from src/ under test. */
133936
+ function resolveAlexaWorkerScript(fromModuleUrl = require("url").pathToFileURL(__filename).href) {
133937
+ const here = (0, node_path.dirname)((0, node_url.fileURLToPath)(fromModuleUrl));
133938
+ const candidates = [
133939
+ (0, node_path.join)(here, "../python/alexa_worker.py"),
133940
+ (0, node_path.join)(here, "../../python/alexa_worker.py"),
133941
+ (0, node_path.join)(here, "../../../python/alexa_worker.py")
133942
+ ];
133943
+ for (const candidate of candidates) if ((0, node_fs.existsSync)(candidate)) return candidate;
133944
+ throw new Error(`import-alexa: alexa_worker.py not found. Searched:\n${candidates.join("\n")}`);
133945
+ }
133946
+ process.platform, process.env["SystemRoot"];
133947
+ /**
133948
+ * Files only their owner may read: the one place a secret-bearing file is
133949
+ * restricted, on every OS.
133950
+ *
133951
+ * `mode: 0o600` is a POSIX idea. On Windows Node maps it to the read-only
133952
+ * attribute and nothing else, and `chmod` is a no-op for permissions, so a
133953
+ * "0600" `agent.json` (the mesh private key) would have
133954
+ * been readable by every local user. Windows has its own tool for this:
133955
+ * `icacls`. This module removes the inherited ACL entries and grants access to
133956
+ * the current user and SYSTEM only — the Windows reading of "0600".
133957
+ *
133958
+ * ZERO imports beyond Node built-ins, on purpose: the Electron shell bundles
133959
+ * this single file (see `packages/desktop/tsup.config.ts`) instead of the
133960
+ * whole `@camstack/types` barrel.
133961
+ *
133962
+ * Failure is NOT swallowed. A secret file that could not be restricted is a
133963
+ * secret file the caller must know about, so `OwnerOnlyError` propagates; the
133964
+ * write order below makes sure nothing sensitive is in the file yet when that
133965
+ * happens (the temp file is empty until it has been restricted).
133966
+ *
133967
+ * Out of scope on purpose: files that are only tidy, not secret (the job
133968
+ * journal, the addon manifest) keep a plain `mode`, since one `icacls` per
133969
+ * write would be a process spawn on a hot path.
133970
+ */
133971
+ /** `*S-1-5-18` is SYSTEM in every locale — a name like "SYSTEM" is not. */
133972
+ var WINDOWS_SYSTEM_SID = "*S-1-5-18";
133973
+ var FILE_MODE = 384;
133974
+ var DIR_MODE = 448;
133975
+ var OwnerOnlyError = class extends Error {
133976
+ target;
133977
+ constructor(target, detail) {
133978
+ super(`could not restrict ${target} to its owner: ${detail}`);
133979
+ this.target = target;
133980
+ this.name = "OwnerOnlyError";
133981
+ }
133982
+ };
133983
+ function currentPrincipal() {
133984
+ const user = process.env["USERNAME"] ?? (0, node_os.userInfo)().username;
133985
+ const domain = process.env["USERDOMAIN"];
133986
+ return domain !== void 0 && domain.length > 0 ? `${domain}\\${user}` : user;
133987
+ }
133988
+ /** Resolved per call, not at import: a platform stubbed by a test must be seen. */
133989
+ function resolveDeps(overrides) {
133990
+ return {
133991
+ platform: process.platform,
133992
+ exec: (file, args) => {
133993
+ (0, node_child_process.execFileSync)(file, [...args], {
133994
+ stdio: "pipe",
133995
+ windowsHide: true
133996
+ });
133997
+ },
133998
+ principal: currentPrincipal,
133999
+ systemRoot: process.env["SystemRoot"],
134000
+ ...overrides
134001
+ };
134002
+ }
134003
+ /** The icacls argv for one path: no inheritance, owner + SYSTEM only. */
134004
+ function icaclsArgs(target, principal, isDirectory) {
134005
+ const inherit = isDirectory ? "(OI)(CI)" : "";
134006
+ return [
134007
+ target,
134008
+ "/inheritance:r",
134009
+ "/grant:r",
134010
+ `${principal}:${inherit}F`,
134011
+ "/grant:r",
134012
+ `${WINDOWS_SYSTEM_SID}:${inherit}F`
134013
+ ];
134014
+ }
134015
+ function restrictWindows(target, isDirectory, deps) {
134016
+ const icacls = node_path$1.win32.join(deps.systemRoot ?? "C:\\Windows", "System32", "icacls.exe");
134017
+ try {
134018
+ deps.exec(icacls, icaclsArgs(target, deps.principal(), isDirectory));
134019
+ } catch (err) {
134020
+ throw new OwnerOnlyError(target, err instanceof Error ? err.message : String(err));
134021
+ }
134022
+ }
134023
+ /** Restrict an existing file to its owner (chmod 0600 / owner+SYSTEM ACL). */
134024
+ function restrictFileToOwnerSync(target, overrides = {}) {
134025
+ const deps = resolveDeps(overrides);
134026
+ if (deps.platform === "win32") {
134027
+ restrictWindows(target, false, deps);
134028
+ return;
134029
+ }
134030
+ node_fs.chmodSync(target, FILE_MODE);
134031
+ }
134032
+ /** Restrict an existing directory to its owner (chmod 0700 / owner+SYSTEM, inherited). */
134033
+ function restrictDirToOwnerSync(target, overrides = {}) {
134034
+ const deps = resolveDeps(overrides);
134035
+ if (deps.platform === "win32") {
134036
+ restrictWindows(target, true, deps);
134037
+ return;
134038
+ }
134039
+ node_fs.chmodSync(target, DIR_MODE);
134040
+ }
134041
+ /**
134042
+ * Atomically write a secret-bearing file that only its owner can read.
134043
+ *
134044
+ * Order matters: the temp file is created EMPTY, restricted, and only then
134045
+ * given its content, so there is no moment at which the secret exists under a
134046
+ * permissive ACL. The rename carries the (already restricted) ACL with it.
134047
+ */
134048
+ function writeOwnerOnlyFileSync(target, data, overrides = {}) {
134049
+ node_fs.mkdirSync(node_path$1.dirname(target), { recursive: true });
134050
+ const tmp = `${target}.tmp.${process.pid}`;
134051
+ node_fs.writeFileSync(tmp, "", { mode: FILE_MODE });
134052
+ try {
134053
+ restrictFileToOwnerSync(tmp, overrides);
134054
+ node_fs.writeFileSync(tmp, data);
134055
+ node_fs.renameSync(tmp, target);
134056
+ } catch (err) {
134057
+ node_fs.rmSync(tmp, { force: true });
134058
+ throw err;
134059
+ }
134060
+ }
134061
+ //#endregion
134062
+ //#region src/speak/secret-file.ts
134063
+ /**
134064
+ * Hand a password or a saved Amazon session to the Python worker as an
134065
+ * owner-only file. The path travels on stdin. The bytes never travel on
134066
+ * argv or in the environment, and they are removed when the call settles.
134067
+ */
134068
+ var REDACTED = "‹redacted›";
134069
+ function decodeSession(sessionB64) {
134070
+ if (sessionB64.length === 0) return Buffer.alloc(0);
134071
+ return Buffer.from(sessionB64, "base64");
134072
+ }
134073
+ async function readSessionB64(sessionPath) {
134074
+ const bytes = await node_fs_promises.readFile(sessionPath);
134075
+ if (bytes.length === 0) return "";
134076
+ return bytes.toString("base64");
134077
+ }
134078
+ /**
134079
+ * Create a 0700 directory, write the password and the session 0600, run
134080
+ * `body`, then delete the directory whether `body` succeeded or threw.
134081
+ * Returns the session file as base64 so the caller can store it in the keychain.
134082
+ */
134083
+ async function withSpeakSecretFiles(input, body) {
134084
+ const dir = await node_fs_promises.mkdtemp(node_path.join(node_os.tmpdir(), "camstack-alexa-"));
134085
+ const paths = {
134086
+ dir,
134087
+ passwordPath: node_path.join(dir, "password"),
134088
+ sessionPath: node_path.join(dir, "session")
134089
+ };
134090
+ try {
134091
+ restrictDirToOwnerSync(dir);
134092
+ writeOwnerOnlyFileSync(paths.passwordPath, input.password);
134093
+ writeOwnerOnlyFileSync(paths.sessionPath, decodeSession(input.sessionB64));
134094
+ await body(paths);
134095
+ return { sessionB64: await readSessionB64(paths.sessionPath) };
134096
+ } finally {
134097
+ await node_fs_promises.rm(dir, {
134098
+ recursive: true,
134099
+ force: true
134100
+ });
134101
+ }
134102
+ }
134103
+ /** Replace every occurrence of each non-empty secret. Used before a log line is emitted. */
134104
+ function scrubSecrets(text, secrets) {
134105
+ let out = text;
134106
+ for (const secret of secrets) {
134107
+ if (secret.length === 0) continue;
134108
+ out = out.split(secret).join(REDACTED);
134109
+ }
134110
+ return out;
134111
+ }
134112
+ //#endregion
134113
+ //#region src/speak/setup-flow.ts
134114
+ /** Where the next screen is. A failed password stays on sign-in. A failed code stays on the code step. */
134115
+ function reduceSetup(phase, event) {
134116
+ if (event.type === "reset") return "sign-in";
134117
+ if (event.status === "ok") return "devices";
134118
+ if (event.status === "otp_needed") return "otp";
134119
+ if (event.status === "captcha") return "captcha";
134120
+ return phase;
134121
+ }
134122
+ //#endregion
134123
+ //#region src/speak/worker-client.ts
134124
+ /**
134125
+ * One Python process for the life of the addon. Requests are one JSON line
134126
+ * on stdin; responses are one JSON line on stdout, matched by id.
134127
+ * A crash restarts the process until the same bound CrashSupervisor uses,
134128
+ * then requests fail instead of looping.
134129
+ */
134130
+ /** Pure: a new timestamp list, never a mutated one. */
134131
+ function recordWorkerCrash(times, now, windowMs, maxCrashes) {
134132
+ const cutoff = now - windowMs;
134133
+ const recent = [...times.filter((t) => t >= cutoff), now];
134134
+ if (recent.length >= maxCrashes) return {
134135
+ action: "failed",
134136
+ times: recent
134137
+ };
134138
+ return {
134139
+ action: "respawn",
134140
+ times: recent
134141
+ };
134142
+ }
134143
+ function quietLogger() {
134144
+ const logger = {
134145
+ debug() {},
134146
+ info() {},
134147
+ warn() {},
134148
+ error() {},
134149
+ child() {
134150
+ return logger;
134151
+ },
134152
+ withTags() {
134153
+ return logger;
134154
+ }
134155
+ };
134156
+ return logger;
134157
+ }
134158
+ function wrapChild(child) {
134159
+ const stdin = child.stdin;
134160
+ const stdout = child.stdout;
134161
+ const stderr = child.stderr;
134162
+ if (stdin === null || stdout === null || stderr === null) throw new Error("alexa worker spawn did not provide pipes");
134163
+ return {
134164
+ stdin: { write(chunk) {
134165
+ stdin.write(chunk);
134166
+ } },
134167
+ stdout: { on(event, listener) {
134168
+ stdout.on(event, listener);
134169
+ } },
134170
+ stderr: { on(event, listener) {
134171
+ stderr.on(event, listener);
134172
+ } },
134173
+ on(event, listener) {
134174
+ if (event === "close") child.on("close", listener);
134175
+ else child.on("error", listener);
134176
+ },
134177
+ kill(signal) {
134178
+ child.kill(signal);
134179
+ }
134180
+ };
134181
+ }
134182
+ function defaultSpawn(pythonPath, scriptPath, env) {
134183
+ return wrapChild((0, node_child_process.spawn)(pythonPath, [scriptPath], {
134184
+ stdio: [
134185
+ "pipe",
134186
+ "pipe",
134187
+ "pipe"
134188
+ ],
134189
+ env
134190
+ }));
134191
+ }
134192
+ function startAlexaWorker(deps) {
134193
+ const logger = deps.logger ?? quietLogger();
134194
+ const now = deps.now ?? (() => Date.now());
134195
+ const schedule = deps.schedule ?? ((fn, delayMs) => {
134196
+ setTimeout(fn, delayMs);
134197
+ });
134198
+ const spawnChild = deps.spawnChild ?? defaultSpawn;
134199
+ const maxCrashes = deps.maxCrashes ?? 5;
134200
+ const windowMs = deps.windowMs ?? 6e4;
134201
+ const env = deps.env ?? process.env;
134202
+ let child = null;
134203
+ let buffer = "";
134204
+ let nextId = 1;
134205
+ let stopped = false;
134206
+ let failed = false;
134207
+ let crashTimes = [];
134208
+ let secrets = [];
134209
+ let pending = null;
134210
+ const queue = [];
134211
+ function scrub(text) {
134212
+ return scrubSecrets(text, secrets);
134213
+ }
134214
+ function failPending(err) {
134215
+ const current = pending;
134216
+ pending = null;
134217
+ current?.reject(err);
134218
+ }
134219
+ function attach(spawned) {
134220
+ child = spawned;
134221
+ buffer = "";
134222
+ let settled = false;
134223
+ const finish = (code) => {
134224
+ if (settled || child !== spawned) return;
134225
+ settled = true;
134226
+ onExit(code);
134227
+ };
134228
+ spawned.stdout.on("data", (chunk) => {
134229
+ buffer += chunk.toString("utf8");
134230
+ const lines = buffer.split("\n");
134231
+ buffer = lines.pop() ?? "";
134232
+ for (const line of lines) {
134233
+ if (line.trim().length === 0) continue;
134234
+ acceptLine(line);
134235
+ }
134236
+ });
134237
+ spawned.stderr.on("data", (chunk) => {
134238
+ const text = scrub(chunk.toString("utf8")).trim();
134239
+ if (text.length === 0) return;
134240
+ logger.warn("alexa worker stderr", { meta: { text: text.slice(0, 500) } });
134241
+ });
134242
+ spawned.on("error", (err) => {
134243
+ logger.warn("alexa worker spawn error", { meta: { error: scrub(err.message) } });
134244
+ finish(null);
134245
+ });
134246
+ spawned.on("close", (code) => {
134247
+ finish(code);
134248
+ });
134249
+ }
134250
+ function acceptLine(line) {
134251
+ let parsed;
134252
+ try {
134253
+ parsed = JSON.parse(line);
134254
+ } catch (err) {
134255
+ logger.warn("alexa worker non-JSON line", { meta: { error: err instanceof Error ? err.message : String(err) } });
134256
+ return;
134257
+ }
134258
+ const checked = WorkerLineSchema.safeParse(parsed);
134259
+ if (!checked.success) {
134260
+ logger.warn("alexa worker line failed schema", { meta: { error: checked.error.message } });
134261
+ return;
134262
+ }
134263
+ const message = checked.data;
134264
+ if (pending === null || pending.id !== message.id) {
134265
+ logger.warn("alexa worker line for an unknown request", { meta: { id: message.id } });
134266
+ return;
134267
+ }
134268
+ const current = pending;
134269
+ pending = null;
134270
+ if (message.ok) current.resolve(message.result);
134271
+ else current.reject(new Error(scrub(message.error)));
134272
+ pump();
134273
+ }
134274
+ function onExit(code) {
134275
+ child = null;
134276
+ if (stopped) {
134277
+ failPending(/* @__PURE__ */ new Error("alexa worker stopped"));
134278
+ return;
134279
+ }
134280
+ failPending(/* @__PURE__ */ new Error(`alexa worker exited (${code ?? "null"})`));
134281
+ const decision = recordWorkerCrash(crashTimes, now(), windowMs, maxCrashes);
134282
+ crashTimes = decision.times;
134283
+ if (decision.action === "failed") {
134284
+ failed = true;
134285
+ logger.error("alexa worker restart bound reached", { meta: { crashes: crashTimes.length } });
134286
+ const waiting = queue.splice(0, queue.length);
134287
+ for (const item of waiting) item.pending.reject(/* @__PURE__ */ new Error("alexa worker restart bound reached"));
134288
+ return;
134289
+ }
134290
+ logger.warn("alexa worker exited; restarting", { meta: {
134291
+ code,
134292
+ crashes: crashTimes.length
134293
+ } });
134294
+ schedule(() => {
134295
+ if (stopped || failed) return;
134296
+ launch();
134297
+ pump();
134298
+ }, Math.min(3e4, 500 * 2 ** Math.min(6, crashTimes.length - 1)));
134299
+ }
134300
+ function launch() {
134301
+ if (stopped || failed) return;
134302
+ attach(spawnChild(deps.pythonPath, deps.scriptPath, env));
134303
+ }
134304
+ function pump() {
134305
+ if (pending !== null || child === null || failed || stopped) return;
134306
+ const next = queue.shift();
134307
+ if (next === void 0) return;
134308
+ pending = next.pending;
134309
+ child.stdin.write(requestLine(next.pending.id, next.request));
134310
+ }
134311
+ function request(body) {
134312
+ if (failed) return Promise.reject(/* @__PURE__ */ new Error("alexa worker restart bound reached"));
134313
+ if (stopped) return Promise.reject(/* @__PURE__ */ new Error("alexa worker stopped"));
134314
+ const id = String(nextId);
134315
+ nextId += 1;
134316
+ return new Promise((resolve, reject) => {
134317
+ queue.push({
134318
+ request: body,
134319
+ pending: {
134320
+ id,
134321
+ resolve,
134322
+ reject
134323
+ }
134324
+ });
134325
+ if (child === null) launch();
134326
+ pump();
134327
+ });
134328
+ }
134329
+ return {
134330
+ request,
134331
+ noteSecrets(next) {
134332
+ secrets = [...next];
134333
+ },
134334
+ stop() {
134335
+ stopped = true;
134336
+ const current = child;
134337
+ child = null;
134338
+ current?.kill("SIGTERM");
134339
+ failPending(/* @__PURE__ */ new Error("alexa worker stopped"));
134340
+ const waiting = queue.splice(0, queue.length);
134341
+ for (const item of waiting) item.pending.reject(/* @__PURE__ */ new Error("alexa worker stopped"));
134342
+ },
134343
+ get failed() {
134344
+ return failed;
134345
+ },
134346
+ get crashesInWindow() {
134347
+ return crashTimes.length;
134348
+ }
134349
+ };
134350
+ }
134351
+ //#endregion
134352
+ //#region src/speak/controller.ts
134353
+ /**
134354
+ * Owns the one Python worker, the keychain session, the Echo cache and the
134355
+ * notification provider. A login that needs a code raises one alert.
134356
+ */
134357
+ function targetsFrom(settings) {
134358
+ return settings.targets.map((row) => ({
134359
+ id: row.id,
134360
+ name: row.name,
134361
+ kind: ALEXA_SPEAK_KIND,
134362
+ addonId: ALEXA_SPEAK_ADDON_ID,
134363
+ enabled: row.enabled,
134364
+ config: {
134365
+ echoId: row.echoId,
134366
+ mode: row.mode,
134367
+ volume: row.volume,
134368
+ respectDnd: row.respectDnd
134369
+ }
134370
+ }));
134371
+ }
134372
+ function withTargets(settings, targets) {
134373
+ return {
134374
+ ...settings,
134375
+ targets: targets.map((target) => ({
134376
+ id: target.id,
134377
+ name: target.name,
134378
+ enabled: target.enabled,
134379
+ echoId: typeof target.config["echoId"] === "string" ? target.config["echoId"] : "",
134380
+ mode: target.config["mode"] === "announce" ? "announce" : "speak",
134381
+ volume: typeof target.config["volume"] === "number" ? target.config["volume"] : 40,
134382
+ respectDnd: target.config["respectDnd"] !== false
134383
+ }))
134384
+ };
134385
+ }
134386
+ function usablePassword(value) {
134387
+ if (value.length === 0 || isSecretMarker(value)) return "";
134388
+ return value;
134389
+ }
134390
+ var AlexaSpeakController = class {
134391
+ host;
134392
+ provider;
134393
+ settings;
134394
+ worker = null;
134395
+ devices = [];
134396
+ phase = "sign-in";
134397
+ timer = null;
134398
+ attention;
134399
+ now;
134400
+ constructor(host) {
134401
+ this.host = host;
134402
+ this.now = host.now ?? (() => Date.now());
134403
+ this.settings = readSpeakSettings(host.readConfig());
134404
+ this.phase = this.settings.session.length > 0 ? "devices" : "sign-in";
134405
+ this.attention = new OtpAttention({
134406
+ now: this.now,
134407
+ emit: (alert) => host.emitAlert(alert),
134408
+ resolve: (id) => host.resolveAlert(id)
134409
+ });
134410
+ this.provider = createAlexaNotificationProvider({
134411
+ devices: () => this.devices,
134412
+ loggedIn: () => this.phase === "devices",
134413
+ now: this.now,
134414
+ logger: host.logger,
134415
+ loadTargets: () => targetsFrom(this.settings),
134416
+ saveTargets: (targets) => {
134417
+ this.settings = withTargets(this.settings, targets);
134418
+ this.persist();
134419
+ },
134420
+ speak: (command) => this.utter(command.mode, command.targets, command.text, command.volume)
134421
+ });
134422
+ }
134423
+ async start() {
134424
+ const pythonPath = await this.host.ensurePython();
134425
+ if (pythonPath === null) {
134426
+ this.host.logger.warn("alexa speak: embedded Python unavailable; Echo speech will not run");
134427
+ return;
134428
+ }
134429
+ this.worker = startAlexaWorker({
134430
+ pythonPath,
134431
+ scriptPath: resolveAlexaWorkerScript(),
134432
+ logger: this.host.logger,
134433
+ now: this.now
134434
+ });
134435
+ this.noteSecrets();
134436
+ if (this.settings.session.length > 0) await this.loginWithFiles().catch((err) => {
134437
+ this.host.logger.warn("alexa speak: stored session did not restore", { meta: { error: err instanceof Error ? err.message : String(err) } });
134438
+ });
134439
+ this.timer = setInterval(() => {
134440
+ this.refreshDevices();
134441
+ }, DEVICE_REFRESH_MS);
134442
+ }
134443
+ stop() {
134444
+ if (this.timer !== null) clearInterval(this.timer);
134445
+ this.timer = null;
134446
+ this.worker?.stop();
134447
+ this.worker = null;
134448
+ }
134449
+ handlers() {
134450
+ return {
134451
+ speakStatus: async () => this.status(),
134452
+ loginStart: async (input) => {
134453
+ this.settings = {
134454
+ ...this.settings,
134455
+ email: input.email,
134456
+ password: input.password,
134457
+ region: input.region
134458
+ };
134459
+ await this.persist();
134460
+ this.noteSecrets();
134461
+ return this.loginWithFiles();
134462
+ },
134463
+ loginOtp: async (input) => this.otpWithFiles(input.code),
134464
+ sendTest: async (input) => this.sendTest(input)
134465
+ };
134466
+ }
134467
+ noteSecrets() {
134468
+ const password = usablePassword(this.settings.password);
134469
+ this.worker?.noteSecrets(password.length > 0 ? [password] : []);
134470
+ }
134471
+ async persist() {
134472
+ try {
134473
+ await this.host.writeSpeak(this.settings);
134474
+ } catch (err) {
134475
+ this.host.logger.warn("alexa speak: could not store settings", { meta: { error: err instanceof Error ? err.message : String(err) } });
134476
+ }
134477
+ }
134478
+ workerRequest(request) {
134479
+ const worker = this.worker;
134480
+ if (worker === null || worker.failed) return Promise.reject(/* @__PURE__ */ new Error("alexa worker is not running"));
134481
+ return worker.request(request);
134482
+ }
134483
+ async loginWithFiles() {
134484
+ let outcome = {
134485
+ status: "failed",
134486
+ error: "alexa worker is not running"
134487
+ };
134488
+ const held = await withSpeakSecretFiles({
134489
+ password: usablePassword(this.settings.password),
134490
+ sessionB64: this.settings.session
134491
+ }, async (paths) => {
134492
+ outcome = await this.applyLogin(await this.loginRequest(paths));
134493
+ });
134494
+ if (held.sessionB64 !== this.settings.session) {
134495
+ this.settings = {
134496
+ ...this.settings,
134497
+ session: held.sessionB64
134498
+ };
134499
+ await this.persist();
134500
+ }
134501
+ return outcome;
134502
+ }
134503
+ async otpWithFiles(code) {
134504
+ let outcome = {
134505
+ status: "failed",
134506
+ error: "alexa worker is not running"
134507
+ };
134508
+ const held = await withSpeakSecretFiles({
134509
+ password: "",
134510
+ sessionB64: this.settings.session
134511
+ }, async (paths) => {
134512
+ outcome = await this.applyLogin(await this.workerRequest({
134513
+ op: "login_otp",
134514
+ code,
134515
+ sessionPath: paths.sessionPath
134516
+ }));
134517
+ });
134518
+ if (held.sessionB64 !== this.settings.session) {
134519
+ this.settings = {
134520
+ ...this.settings,
134521
+ session: held.sessionB64
134522
+ };
134523
+ await this.persist();
134524
+ }
134525
+ return outcome;
134526
+ }
134527
+ loginRequest(paths) {
134528
+ return this.workerRequest({
134529
+ op: "login_start",
134530
+ email: this.settings.email,
134531
+ passwordPath: paths.passwordPath,
134532
+ sessionPath: paths.sessionPath,
134533
+ region: this.settings.region
134534
+ });
134535
+ }
134536
+ async applyLogin(raw) {
134537
+ const outcome = parseLoginResult(raw);
134538
+ this.phase = reduceSetup(this.phase, {
134539
+ type: "login",
134540
+ status: outcome.status
134541
+ });
134542
+ if (outcome.status === "otp_needed") await this.attention.needed("otp");
134543
+ else if (outcome.status === "captcha") await this.attention.needed("captcha");
134544
+ else if (outcome.status === "ok") {
134545
+ await this.attention.cleared();
134546
+ await this.refreshDevices();
134547
+ }
134548
+ return outcome;
134549
+ }
134550
+ async refreshDevices() {
134551
+ if (this.worker === null || this.phase !== "devices") return;
134552
+ try {
134553
+ this.devices = [...parseDevices(await this.workerRequest({ op: "devices" }))];
134554
+ } catch (err) {
134555
+ this.host.logger.warn("alexa speak: device refresh failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
134556
+ }
134557
+ }
134558
+ async utter(mode, targets, text, volume) {
134559
+ const op = mode === "announce" ? "announce" : "speak";
134560
+ return parseDeliveries(await this.workerRequest({
134561
+ op,
134562
+ targets,
134563
+ text,
134564
+ volume
134565
+ }));
134566
+ }
134567
+ async session() {
134568
+ if (this.worker === null) return {
134569
+ loggedIn: false,
134570
+ ageSec: 0,
134571
+ lastSuccessAt: null
134572
+ };
134573
+ try {
134574
+ return parseSessionStatus(await this.workerRequest({ op: "session_status" }));
134575
+ } catch {
134576
+ return {
134577
+ loggedIn: this.phase === "devices",
134578
+ ageSec: 0,
134579
+ lastSuccessAt: null
134580
+ };
134581
+ }
134582
+ }
134583
+ async status() {
134584
+ return {
134585
+ phase: this.phase,
134586
+ email: this.settings.email,
134587
+ region: this.settings.region,
134588
+ devices: [...this.devices],
134589
+ session: await this.session()
134590
+ };
134591
+ }
134592
+ async sendTest(input) {
134593
+ const outcomes = await this.utter(input.mode, input.echoIds, input.text, input.volume);
134594
+ const known = new Map(this.devices.map((device) => [device.id, device]));
134595
+ const next = [...targetsFrom(this.settings)];
134596
+ for (const echoId of input.echoIds) {
134597
+ if (next.some((row) => row.config["echoId"] === echoId)) continue;
134598
+ const device = known.get(echoId);
134599
+ next.push({
134600
+ id: `echo-${echoId}`,
134601
+ name: device?.name ?? echoId,
134602
+ kind: ALEXA_SPEAK_KIND,
134603
+ addonId: ALEXA_SPEAK_ADDON_ID,
134604
+ enabled: true,
134605
+ config: {
134606
+ echoId,
134607
+ mode: input.mode,
134608
+ volume: input.volume,
134609
+ respectDnd: true
134610
+ }
134611
+ });
134612
+ }
134613
+ this.settings = withTargets(this.settings, next);
134614
+ await this.persist();
134615
+ return { outcomes: outcomes.map((row) => ({
134616
+ echoId: row.targetId,
134617
+ ok: row.ok,
134618
+ ...row.error !== void 0 ? { error: row.error } : {}
134619
+ })) };
134620
+ }
134621
+ };
134622
+ //#endregion
132682
134623
  //#region src/addon.ts
134624
+ var SPEAK_PAGE = {
134625
+ id: "speak",
134626
+ label: "Speak on Echo",
134627
+ icon: "megaphone",
134628
+ path: "/addon/import-alexa",
134629
+ remoteName: "addon_import_alexa_page",
134630
+ bundle: "remoteEntry.js",
134631
+ section: "administration"
134632
+ };
132683
134633
  /** Default config — a fresh install starts with no accounts. */
132684
134634
  var DEFAULTS = {
132685
134635
  brokers: [],
132686
134636
  proxyLogin: {
132687
134637
  proxyHost: "",
132688
134638
  proxyPort: 3456
134639
+ },
134640
+ speak: {
134641
+ email: "",
134642
+ password: "",
134643
+ region: "EU",
134644
+ session: "",
134645
+ targets: []
132689
134646
  }
132690
134647
  };
132691
134648
  /** Device types an imported Alexa appliance can materialise as. */
@@ -132727,6 +134684,8 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
132727
134684
  proxyLogin = null;
132728
134685
  brokerStore = null;
132729
134686
  activeBrokers = [];
134687
+ speak = null;
134688
+ secretStatePaths = ["speak.password", "speak.session"];
132730
134689
  constructor() {
132731
134690
  super({ ...DEFAULTS });
132732
134691
  }
@@ -132780,21 +134739,62 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
132780
134739
  proxyLogin: this.proxyLogin,
132781
134740
  commitCookie: (input) => this.commitCapturedCookie(input)
132782
134741
  });
132783
- return [
132784
- ...regs,
132785
- {
132786
- capability: brokerCapability,
132787
- provider: this.buildBrokerProvider()
134742
+ const speak = new AlexaSpeakController({
134743
+ logger: this.ctx.logger.child("speak"),
134744
+ readConfig: () => this.config,
134745
+ writeSpeak: async (next) => {
134746
+ await this.updateGlobalSettings({ speak: next });
132788
134747
  },
132789
- {
132790
- capability: deviceAdoptionCapability,
132791
- provider: this.buildAdoptionProvider()
134748
+ emitAlert: async (alert) => {
134749
+ const emit = this.ctx.api.alerts?.emit;
134750
+ if (emit === void 0) {
134751
+ this.ctx.logger.warn("alexa speak: OTP alert skipped — alerts capability missing");
134752
+ return;
134753
+ }
134754
+ await emit.mutate(alert);
132792
134755
  },
132793
- {
132794
- capability: addonRoutesCapability,
132795
- provider: buildAddonRouteProvider("import-alexa", loginRoutes)
132796
- }
132797
- ];
134756
+ resolveAlert: async (alertId) => {
134757
+ const dismiss = this.ctx.api.alerts?.dismiss;
134758
+ if (dismiss === void 0) return;
134759
+ await dismiss.mutate({ alertId });
134760
+ },
134761
+ ensurePython: () => this.ctx.deps.ensurePython()
134762
+ });
134763
+ this.speak = speak;
134764
+ speak.start().catch((err) => {
134765
+ this.ctx.logger.warn("alexa speak: worker did not start", { meta: { error: err instanceof Error ? err.message : String(err) } });
134766
+ });
134767
+ const pages = {
134768
+ id: this.addonId,
134769
+ listPages: () => [SPEAK_PAGE]
134770
+ };
134771
+ return {
134772
+ providers: [
134773
+ ...regs,
134774
+ {
134775
+ capability: brokerCapability,
134776
+ provider: this.buildBrokerProvider()
134777
+ },
134778
+ {
134779
+ capability: deviceAdoptionCapability,
134780
+ provider: this.buildAdoptionProvider()
134781
+ },
134782
+ {
134783
+ capability: addonRoutesCapability,
134784
+ provider: buildAddonRouteProvider("import-alexa", loginRoutes)
134785
+ },
134786
+ {
134787
+ capability: notificationOutputCapability,
134788
+ provider: speak.provider
134789
+ },
134790
+ {
134791
+ capability: addonPagesSourceCapability,
134792
+ provider: pages
134793
+ }
134794
+ ],
134795
+ customActions: alexaSpeakActions,
134796
+ actionHandlers: speak.handlers()
134797
+ };
132798
134798
  }
132799
134799
  async loadPersistedBrokers() {
132800
134800
  const store = this.brokerStore;
@@ -132859,6 +134859,8 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
132859
134859
  }
132860
134860
  }
132861
134861
  async onShutdown() {
134862
+ this.speak?.stop();
134863
+ this.speak = null;
132862
134864
  alexaCommands.clear();
132863
134865
  try {
132864
134866
  await this.proxyLogin?.dispose();
@@ -133093,43 +135095,90 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
133093
135095
  });
133094
135096
  }
133095
135097
  globalSettingsSchema() {
133096
- return this.schema({ sections: [{
133097
- id: "alexa-account",
133098
- title: "Amazon Alexa account",
133099
- titleKey: "import-alexa:globalSettings.s_alexa-account.title",
133100
- description: "Alexa accounts are managed in the External systems → Brokers tab.",
133101
- descriptionKey: "import-alexa:globalSettings.s_alexa-account.description",
133102
- columns: 1,
133103
- fields: [{
133104
- type: "info",
133105
- key: "accountHelp",
133106
- label: "Accounts are managed separately",
133107
- labelKey: "import-alexa:globalSettings.s_alexa-account.accountHelp.label",
133108
- content: "This integration links to an Amazon Alexa account broker. Add, edit, or remove accounts from External systems → Brokers, then adopt the imported devices. The integration only stores a reference to its broker. NOTE: this is an unofficial integration that Amazon can change or break without notice."
133109
- }]
133110
- }, {
133111
- id: "alexa-proxy-login",
133112
- title: "Interactive login (proxy)",
133113
- titleKey: "import-alexa:globalSettings.s_alexa-proxy-login.title",
133114
- description: "The \"Login to Amazon\" button in onboarding opens a local proxy that captures the cookie after you complete the real Amazon login (incl. 2FA). Because Amazon URLs are rewritten to this exact host:port, it must be a directly-reachable address — leave the host blank to auto-detect the server IP, and make sure the port is reachable from the browser you log in from.",
133115
- descriptionKey: "import-alexa:globalSettings.s_alexa-proxy-login.description",
133116
- columns: 2,
133117
- fields: [{
133118
- type: "text",
133119
- key: "proxyLogin.proxyHost",
133120
- label: "Login proxy host/IP (blank = auto-detect)",
133121
- labelKey: "import-alexa:globalSettings.s_alexa-proxy-login.proxyLogin_proxyHost.label",
133122
- placeholder: "auto-detect"
133123
- }, {
133124
- type: "number",
133125
- key: "proxyLogin.proxyPort",
133126
- label: "Login proxy port",
133127
- labelKey: "import-alexa:globalSettings.s_alexa-proxy-login.proxyLogin_proxyPort.label",
133128
- min: 1,
133129
- max: 65535,
133130
- default: 3456
133131
- }]
133132
- }] });
135098
+ return this.schema({ sections: [
135099
+ {
135100
+ id: "alexa-account",
135101
+ title: "Amazon Alexa account",
135102
+ titleKey: "import-alexa:globalSettings.s_alexa-account.title",
135103
+ description: "Alexa accounts are managed in the External systems → Brokers tab.",
135104
+ descriptionKey: "import-alexa:globalSettings.s_alexa-account.description",
135105
+ columns: 1,
135106
+ fields: [{
135107
+ type: "info",
135108
+ key: "accountHelp",
135109
+ label: "Accounts are managed separately",
135110
+ labelKey: "import-alexa:globalSettings.s_alexa-account.accountHelp.label",
135111
+ content: "This integration links to an Amazon Alexa account broker. Add, edit, or remove accounts from External systems → Brokers, then adopt the imported devices. The integration only stores a reference to its broker. NOTE: this is an unofficial integration that Amazon can change or break without notice."
135112
+ }]
135113
+ },
135114
+ {
135115
+ id: "alexa-proxy-login",
135116
+ title: "Interactive login (proxy)",
135117
+ titleKey: "import-alexa:globalSettings.s_alexa-proxy-login.title",
135118
+ description: "The \"Login to Amazon\" button in onboarding opens a local proxy that captures the cookie after you complete the real Amazon login (incl. 2FA). Because Amazon URLs are rewritten to this exact host:port, it must be a directly-reachable address — leave the host blank to auto-detect the server IP, and make sure the port is reachable from the browser you log in from.",
135119
+ descriptionKey: "import-alexa:globalSettings.s_alexa-proxy-login.description",
135120
+ columns: 2,
135121
+ fields: [{
135122
+ type: "text",
135123
+ key: "proxyLogin.proxyHost",
135124
+ label: "Login proxy host/IP (blank = auto-detect)",
135125
+ labelKey: "import-alexa:globalSettings.s_alexa-proxy-login.proxyLogin_proxyHost.label",
135126
+ placeholder: "auto-detect"
135127
+ }, {
135128
+ type: "number",
135129
+ key: "proxyLogin.proxyPort",
135130
+ label: "Login proxy port",
135131
+ labelKey: "import-alexa:globalSettings.s_alexa-proxy-login.proxyLogin_proxyPort.label",
135132
+ min: 1,
135133
+ max: 65535,
135134
+ default: 3456
135135
+ }]
135136
+ },
135137
+ {
135138
+ id: "alexa-speak",
135139
+ title: "Speak on Echo",
135140
+ titleKey: "import-alexa:globalSettings.s_alexa-speak.title",
135141
+ description: "Sign in on the Speak on Echo page, pick the devices, and use them as a notification target. The text is the rule template, read aloud.",
135142
+ descriptionKey: "import-alexa:globalSettings.s_alexa-speak.description",
135143
+ columns: 1,
135144
+ fields: [
135145
+ {
135146
+ type: "text",
135147
+ key: "speak.email",
135148
+ label: "Amazon email",
135149
+ labelKey: "import-alexa:globalSettings.s_alexa-speak.speak_email.label",
135150
+ inputType: "email"
135151
+ },
135152
+ {
135153
+ type: "password",
135154
+ key: "speak.password",
135155
+ label: "Amazon password",
135156
+ labelKey: "import-alexa:globalSettings.s_alexa-speak.speak_password.label"
135157
+ },
135158
+ {
135159
+ type: "select",
135160
+ key: "speak.region",
135161
+ label: "Region",
135162
+ labelKey: "import-alexa:globalSettings.s_alexa-speak.speak_region.label",
135163
+ default: "EU",
135164
+ options: [
135165
+ {
135166
+ value: "NA",
135167
+ label: "North America"
135168
+ },
135169
+ {
135170
+ value: "EU",
135171
+ label: "Europe"
135172
+ },
135173
+ {
135174
+ value: "FE",
135175
+ label: "Far East"
135176
+ }
135177
+ ]
135178
+ }
135179
+ ]
135180
+ }
135181
+ ] });
133133
135182
  }
133134
135183
  async supportsManualCreation() {
133135
135184
  return false;
@@ -133170,10 +135219,12 @@ exports.StubAlexaClient = StubAlexaClient;
133170
135219
  exports.__toCommonJS = __toCommonJS;
133171
135220
  exports.alexaAddonConfigSchema = alexaAddonConfigSchema;
133172
135221
  exports.alexaConnectionSchema = alexaConnectionSchema;
135222
+ exports.alexaSpeakActions = alexaSpeakActions;
133173
135223
  exports.buildAlexaApplianceCandidate = buildAlexaApplianceCandidate;
133174
135224
  exports.buildConnectionFormSchema = buildConnectionFormSchema;
133175
135225
  exports.connectionToCredentials = connectionToCredentials;
133176
135226
  exports.createAlexaClient = createAlexaClient;
135227
+ exports.customActions = alexaSpeakActions;
133177
135228
  exports.mapAlexaCapabilitiesToCamStack = mapAlexaCapabilitiesToCamStack;
133178
135229
  exports.mappableAppliance = mappableAppliance;
133179
135230
  exports.settingsToAlexaConnection = settingsToAlexaConnection;