@camstack/addon-export-alexa 1.2.144 → 1.2.145

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.
@@ -5422,7 +5422,7 @@ var ZodIssueCode = {
5422
5422
  var ZodFirstPartyTypeKind;
5423
5423
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5424
5424
  //#endregion
5425
- //#region ../types/dist/sleep-DRqxtc0C.mjs
5425
+ //#region ../types/dist/sleep-bwsTj221.mjs
5426
5426
  /**
5427
5427
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5428
5428
  * window to float samples (D455).
@@ -6673,7 +6673,6 @@ function createHandle(raw, opts) {
6673
6673
  const writeAddon = (patch) => write("addon", opts.addonTree(), void 0, patch, () => readStoredAddonBlob(raw), (p) => raw.writeAddonStore(p));
6674
6674
  const readAddonStoreResult = raw.readAddonStoreResult;
6675
6675
  const readAddonStoreKeyResult = raw.readAddonStoreKeyResult;
6676
- const readDeviceStoreBatch = raw.readDeviceStoreBatch;
6677
6676
  const overrides = {
6678
6677
  readAddonStore: async () => resolveRead("addon", await raw.readAddonStore()),
6679
6678
  ...readAddonStoreResult !== void 0 ? { readAddonStoreResult: async () => {
@@ -6697,12 +6696,12 @@ function createHandle(raw, opts) {
6697
6696
  };
6698
6697
  } } : {},
6699
6698
  readDeviceStore: async (deviceId) => resolveRead(`device:${deviceId}`, await raw.readDeviceStore(deviceId)),
6700
- ...readDeviceStoreBatch !== void 0 ? { readDeviceStoreBatch: async (deviceIds) => {
6701
- const rows = await readDeviceStoreBatch.call(raw, deviceIds);
6699
+ readDeviceStoreBatch: async (deviceIds) => {
6700
+ const rows = await raw.readDeviceStoreBatch(deviceIds);
6702
6701
  const out = /* @__PURE__ */ new Map();
6703
6702
  for (const [deviceId, blob] of rows) out.set(deviceId, await resolveRead(`device:${deviceId}`, blob));
6704
6703
  return out;
6705
- } } : {},
6704
+ },
6706
6705
  writeAddonStore: async (patch) => {
6707
6706
  await writeAddon(patch);
6708
6707
  },
@@ -7529,7 +7528,7 @@ var BaseAddon = class {
7529
7528
  return this._addonSecretTree;
7530
7529
  }
7531
7530
  deviceSecretTree() {
7532
- this._deviceSecretTree ??= secretTreeFromSchema(this.deviceSettingsSchema());
7531
+ this._deviceSecretTree ??= mergeSecretStatePaths(secretTreeFromSchema(this.deviceSettingsSchema()), this.secretStatePaths);
7533
7532
  return this._deviceSecretTree;
7534
7533
  }
7535
7534
  /**
@@ -7605,42 +7604,135 @@ var BaseAddon = class {
7605
7604
  this[timerOf] = timer;
7606
7605
  }
7607
7606
  /**
7608
- * Wrap `ctx.settings.readAddonStore()` with a short retry budget so a
7609
- * transient settings-store outage (mid-restart of sqlite-settings, tsx-watch
7610
- * swap, or any race where the SqliteSettingsBackend is between shutdown and
7611
- * re-initialize) doesn't propagate up into `initialize()` and leave the
7607
+ * Wrap `ctx.settings.readAddonStore()` with a retry budget so a transient
7608
+ * settings-store outage doesn't propagate up into `initialize()` and leave the
7612
7609
  * addon permanently broken.
7613
7610
  *
7614
- * Retry only the two known infra fingerprints. Anything else propagates so
7615
- * real bugs surface immediately. After the budget expires we fall back to
7616
- * `{}` (defaults) — the addon's first successful patch will rehydrate.
7611
+ * Two kinds of outage, two budgets:
7612
+ * - the store's own restart fingerprints (`SqliteSettingsBackend not
7613
+ * initialized`, `provider not available`): a short budget, then the addon
7614
+ * falls back to `{}` (defaults) - its first successful patch rehydrates;
7615
+ * - a TRANSPORT failure (the hub is restarting while this runner boots:
7616
+ * `discovery deadline exceeded`, `parent could not route`, a refused or
7617
+ * timed-out connection). Since D870 a runner's settings read THROWS instead
7618
+ * of answering `{}`, so without a budget a hub restart ends in a respawn
7619
+ * loop. A long, bounded budget; only after it the error propagates - the
7620
+ * addon never runs on defaults for a store it could not ask.
7621
+ * Anything else propagates immediately so real bugs surface.
7617
7622
  */
7618
7623
  async readAddonStoreWithRetry() {
7619
7624
  const settings = this.rawSettings();
7620
7625
  if (!settings) return {};
7621
- const delaysMs = [
7622
- 150,
7623
- 350,
7624
- 600,
7625
- 900
7626
- ];
7627
7626
  let lastErr;
7628
- for (let attempt = 0; attempt <= delaysMs.length; attempt++) try {
7627
+ for (let attempt = 0;; attempt++) try {
7629
7628
  const stored = await settings.readAddonStore() ?? {};
7630
7629
  this.settingsStoreReady = true;
7631
7630
  return stored;
7632
7631
  } catch (err) {
7633
7632
  lastErr = err;
7634
- const msg = err instanceof Error ? err.message : String(err);
7635
- if (!(msg.includes("SqliteSettingsBackend not initialized") || msg.includes("provider not available"))) throw err;
7636
- if (attempt === delaysMs.length) break;
7637
- await new Promise((r) => setTimeout(r, delaysMs[attempt]));
7633
+ const kind = classifyStoreError(err);
7634
+ if (kind === "permanent") throw err;
7635
+ const delay = (kind === "transport" ? TRANSPORT_RETRY_DELAYS_MS : RESTART_RETRY_DELAYS_MS)[attempt];
7636
+ if (delay === void 0) {
7637
+ if (kind === "transport") throw err;
7638
+ break;
7639
+ }
7640
+ await new Promise((r) => setTimeout(r, delay));
7638
7641
  }
7639
7642
  this.settingsStoreReady = false;
7640
7643
  this._ctx?.logger?.warn?.("readAddonStore: settings-store unavailable after retries — using defaults", { meta: { error: lastErr instanceof Error ? lastErr.message : String(lastErr) } });
7641
7644
  return {};
7642
7645
  }
7643
7646
  };
7647
+ /** The settings store's own restart fingerprints. */
7648
+ var RESTART_RETRY_DELAYS_MS = [
7649
+ 150,
7650
+ 350,
7651
+ 600,
7652
+ 900
7653
+ ];
7654
+ /** A hub restarting under a booting runner: ~100 s in total. */
7655
+ var TRANSPORT_RETRY_DELAYS_MS = [
7656
+ 150,
7657
+ 350,
7658
+ 600,
7659
+ 900,
7660
+ 2e3,
7661
+ 5e3,
7662
+ 1e4,
7663
+ 2e4,
7664
+ 3e4,
7665
+ 3e4
7666
+ ];
7667
+ var RESTART_FINGERPRINTS = ["SqliteSettingsBackend not initialized", "provider not available"];
7668
+ /** Node / socket error codes of a connection that did not happen or broke. */
7669
+ var TRANSPORT_CODES = new Set([
7670
+ "ECONNREFUSED",
7671
+ "ECONNRESET",
7672
+ "ETIMEDOUT",
7673
+ "EPIPE",
7674
+ "EHOSTUNREACH",
7675
+ "ENETUNREACH"
7676
+ ]);
7677
+ /** tRPC error codes that mean "the other side is not answering now", not "your call is wrong". */
7678
+ var UNAVAILABLE_TRPC_CODES = new Set([
7679
+ "TIMEOUT",
7680
+ "SERVICE_UNAVAILABLE",
7681
+ "BAD_GATEWAY",
7682
+ "GATEWAY_TIMEOUT"
7683
+ ]);
7684
+ /** The exact phrases OUR transport raises when it cannot reach the hub. */
7685
+ var TRANSPORT_PHRASES = [
7686
+ "discovery deadline exceeded",
7687
+ "parent could not route child unowned cap call",
7688
+ "UDS request timed out after",
7689
+ "channel closed"
7690
+ ];
7691
+ /** Moleculer error `type`s of a call that could not be served right now. */
7692
+ var MOLECULER_TRANSIENT_TYPES = new Set([
7693
+ "REQUEST_TIMEOUT",
7694
+ "SERVICE_NOT_FOUND",
7695
+ "SERVICE_NOT_AVAILABLE",
7696
+ "REQUEST_REJECTED",
7697
+ "BROKER_DISCONNECTED"
7698
+ ]);
7699
+ function stringProp(value, key) {
7700
+ if (typeof value !== "object" || value === null) return void 0;
7701
+ const found = Reflect.get(value, key);
7702
+ return typeof found === "string" ? found : void 0;
7703
+ }
7704
+ /** The code an error carries: its own, its cause's, or a tRPC `data.code`. */
7705
+ function errorCode(err) {
7706
+ const out = {};
7707
+ const cause = typeof err === "object" && err !== null ? Reflect.get(err, "cause") : void 0;
7708
+ const data = typeof err === "object" && err !== null ? Reflect.get(err, "data") : void 0;
7709
+ const node = stringProp(err, "code") ?? stringProp(cause, "code");
7710
+ if (node !== void 0) out.node = node;
7711
+ const trpc = stringProp(data, "code");
7712
+ if (trpc !== void 0) out.trpc = trpc;
7713
+ return out;
7714
+ }
7715
+ /**
7716
+ * Is a settings read failure worth retrying, and with which budget? Only
7717
+ * EXPLICIT signals count: a socket error code, a tRPC "unavailable" code, or
7718
+ * one of the exact phrases our own transport raises. A message that merely
7719
+ * contains "timed out" or "unreachable" is not evidence of anything, and
7720
+ * `settings store not wired` is a wiring bug that retrying cannot fix.
7721
+ */
7722
+ function classifyStoreError(err) {
7723
+ const message = err instanceof Error ? err.message : String(err);
7724
+ if (RESTART_FINGERPRINTS.some((f) => message.includes(f))) return "restart";
7725
+ const code = errorCode(err);
7726
+ if (code.node !== void 0 && TRANSPORT_CODES.has(code.node)) return "transport";
7727
+ if (code.trpc !== void 0 && UNAVAILABLE_TRPC_CODES.has(code.trpc)) return "transport";
7728
+ if (typeof err === "object" && err !== null) {
7729
+ if (Reflect.get(err, "retryable") === true) return "transport";
7730
+ const type = stringProp(err, "type");
7731
+ if (type !== void 0 && MOLECULER_TRANSIENT_TYPES.has(type)) return "transport";
7732
+ }
7733
+ if (TRANSPORT_PHRASES.some((f) => message.includes(f))) return "transport";
7734
+ return "permanent";
7735
+ }
7644
7736
  /**
7645
7737
  * Collect the keys of every field marked `perNode: true`, recursing into
7646
7738
  * layout containers (`group` fields and `sub-tabs` tabs) the same way
@@ -8217,7 +8309,7 @@ object({
8217
8309
  params: record(string(), union([string(), number()])).optional()
8218
8310
  });
8219
8311
  //#endregion
8220
- //#region ../types/dist/err-msg-DLerKiVO.mjs
8312
+ //#region ../types/dist/err-msg--2RrYiLO.mjs
8221
8313
  var DeviceType = /* @__PURE__ */ function(DeviceType) {
8222
8314
  DeviceType["Camera"] = "camera";
8223
8315
  DeviceType["Hub"] = "hub";
@@ -8486,6 +8578,9 @@ var DeviceRole = /* @__PURE__ */ function(DeviceRole) {
8486
8578
  /** Virtual HA toggle (input_boolean.*) — distinguishable from a
8487
8579
  * real Switch device for UI rendering / export adapters. */
8488
8580
  DeviceRole["BinaryHelper"] = "binary-helper";
8581
+ /** A smart plug: a `switch` whose source says it is an outlet (HA
8582
+ * `device_class: outlet`). The Alexa export shows it as a SMARTPLUG. */
8583
+ DeviceRole["Outlet"] = "outlet";
8489
8584
  /** Generic motion / occupancy / moving event source. Distinct from
8490
8585
  * the camera accessory PirSensor role: that one is a camera child;
8491
8586
  * this is a standalone HA / 3rd-party motion sensor. */
@@ -8563,7 +8658,7 @@ function errMsg(err) {
8563
8658
  return String(err);
8564
8659
  }
8565
8660
  //#endregion
8566
- //#region ../types/dist/composition-EQ9Ok0Ee.mjs
8661
+ //#region ../types/dist/composition-DOwQB_Ua.mjs
8567
8662
  /**
8568
8663
  * The ffmpeg build every CamStack node downloads, per platform and arch.
8569
8664
  *
@@ -12024,11 +12119,10 @@ var ClipModelMetaSchema = object({
12024
12119
  /**
12025
12120
  * The `vector-store` index this model's vectors live in. ONE feature space
12026
12121
  * per index: two models share an index only when their vectors are ranked
12027
- * against each other, which for CLIP means never across families. MobileCLIP
12028
- * S1 and S2 share `object-clip` for history (separated by the row's
12029
- * `modelId`); any other model gets its own.
12122
+ * against each other, which for CLIP means never across families. Every
12123
+ * model gets its own.
12030
12124
  */
12031
- vectorIndex: string().regex(/^object-clip(-[a-z0-9][a-z0-9-]*)?$/),
12125
+ vectorIndex: string().regex(/^object-clip-[a-z0-9][a-z0-9-]*$/),
12032
12126
  /**
12033
12127
  * The text encoder's tokenizer, a declared SIBLING file of its onnx. The name
12034
12128
  * must be unique per tokenizer: every model's siblings land in ONE flat
@@ -16169,6 +16263,28 @@ id: union([string(), number()]) }), {
16169
16263
  kind: "mutation",
16170
16264
  auth: "admin"
16171
16265
  });
16266
+ /** One table of the settings database. */
16267
+ var StorageTableSchema = object({
16268
+ /** The table's full name, e.g. `recorder:addon-settings` or `addon-device-settings`. */
16269
+ name: string(),
16270
+ /** The owner prefix of a scoped name (`recorder` for `recorder:addon-settings`), else null. */
16271
+ owner: string().nullable(),
16272
+ rows: number().int().nonnegative(),
16273
+ /** Bytes the table's pages occupy (`dbstat`); null when the engine cannot measure. */
16274
+ bytes: number().int().nonnegative().nullable(),
16275
+ /** Declared by an addon since this engine process started (a running addon claims it). */
16276
+ declaredThisBoot: boolean()
16277
+ });
16278
+ method(object({ ownerPrefix: string().min(1).optional() }), object({ tables: array(StorageTableSchema) }), { auth: "admin" }), method(object({
16279
+ collection: string().min(1),
16280
+ expectRows: number().int().nonnegative()
16281
+ }), object({
16282
+ dropped: literal(true),
16283
+ rows: number().int().nonnegative()
16284
+ }), {
16285
+ kind: "mutation",
16286
+ auth: "admin"
16287
+ });
16172
16288
  /**
16173
16289
  * `keychain` — the hub's encrypted store of secret values (D787).
16174
16290
  *
@@ -16710,6 +16826,16 @@ method(object({}), KeychainStatusSchema, { auth: "admin" }), method(object({}),
16710
16826
  kind: "mutation",
16711
16827
  auth: "admin",
16712
16828
  caller: "required"
16829
+ }), method(object({
16830
+ ref: string().min(1),
16831
+ value: string().min(1)
16832
+ }), object({
16833
+ ref: string(),
16834
+ version: number().int()
16835
+ }), {
16836
+ kind: "mutation",
16837
+ auth: "admin",
16838
+ caller: "required"
16713
16839
  }), method(object({ refs: array(string().min(1)).max(1e3) }), object({
16714
16840
  deleted: array(string()).readonly(),
16715
16841
  kept: array(object({
@@ -53167,7 +53293,7 @@ var HvacModeSchema = _enum([
53167
53293
  "fan_only",
53168
53294
  "dry"
53169
53295
  ]);
53170
- object({
53296
+ var ClimateControlStatusSchema = object({
53171
53297
  /** Active HVAC mode. */
53172
53298
  mode: HvacModeSchema,
53173
53299
  /** Available HVAC modes the device accepts. Subset of HvacMode. */
@@ -53208,6 +53334,12 @@ object({
53208
53334
  /** Horizontal louver swing on/off. Null when the device has no
53209
53335
  * horizontal swing surface (gate with `DeviceFeature.ClimateSwingHorizontal`). */
53210
53336
  swingHorizontal: boolean().nullable(),
53337
+ /** Lowest setpoint the device accepts, Celsius. Absent / null = not reported. */
53338
+ minTemp: number().nullable().optional(),
53339
+ /** Highest setpoint the device accepts, Celsius. Absent / null = not reported. */
53340
+ maxTemp: number().nullable().optional(),
53341
+ /** Setpoint granularity, Celsius (0.5, 1). Absent / null = not reported. */
53342
+ tempStep: number().positive().nullable().optional(),
53211
53343
  /** Ms epoch when the slice was last updated (push or command). */
53212
53344
  lastFetchedAt: number()
53213
53345
  });
@@ -55048,7 +55180,19 @@ var TlsStatusSchema = object({
55048
55180
  sans: array(string()),
55049
55181
  caCertPem: string().nullable(),
55050
55182
  reissueError: string().nullable(),
55051
- restartRequired: boolean()
55183
+ restartRequired: boolean(),
55184
+ /** An uploaded certificate is served but no CA is kept for it and it is not publicly signed (D873). */
55185
+ caMissing: boolean(),
55186
+ /** Does the hub's own generated CA carry a critical name constraint? `null` while it has none. */
55187
+ localCaConstrained: boolean().nullable(),
55188
+ /** ISO time the generated CA was replaced by a constrained one; devices must re-download it. */
55189
+ caReplacedAt: string().nullable(),
55190
+ /** The uploaded certificate kept on disk, selectable again after "Revert to generated". */
55191
+ storedUpload: object({
55192
+ leafFingerprintSha256: string(),
55193
+ validTo: string(),
55194
+ hasCa: boolean()
55195
+ }).nullable()
55052
55196
  });
55053
55197
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
55054
55198
  /**
@@ -55104,6 +55248,9 @@ method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(obje
55104
55248
  }), method(_void(), object({ pem: string() })), method(_void(), TlsStatusSchema, {
55105
55249
  kind: "mutation",
55106
55250
  auth: "admin"
55251
+ }), method(_void(), TlsStatusSchema, {
55252
+ kind: "mutation",
55253
+ auth: "admin"
55107
55254
  });
55108
55255
  var LockControlStatusSchema = object({
55109
55256
  /** Lifecycle state of the lock. `jammed` means the motor reported
@@ -64143,6 +64290,12 @@ Object.freeze({
64143
64290
  addonId: null,
64144
64291
  access: "create"
64145
64292
  },
64293
+ "keychainAdmin.importSecret": {
64294
+ capName: "keychain-admin",
64295
+ capScope: "system",
64296
+ addonId: null,
64297
+ access: "create"
64298
+ },
64146
64299
  "keychainAdmin.listAudit": {
64147
64300
  capName: "keychain-admin",
64148
64301
  capScope: "system",
@@ -64539,6 +64692,12 @@ Object.freeze({
64539
64692
  addonId: null,
64540
64693
  access: "create"
64541
64694
  },
64695
+ "localNetwork.useStoredUploadedCertificate": {
64696
+ capName: "local-network",
64697
+ capScope: "system",
64698
+ addonId: null,
64699
+ access: "create"
64700
+ },
64542
64701
  "lockControl.getStatus": {
64543
64702
  capName: "lock-control",
64544
64703
  capScope: "device",
@@ -67923,6 +68082,18 @@ Object.freeze({
67923
68082
  addonId: null,
67924
68083
  access: "create"
67925
68084
  },
68085
+ "storageAdmin.dropCollection": {
68086
+ capName: "storage-admin",
68087
+ capScope: "system",
68088
+ addonId: null,
68089
+ access: "delete"
68090
+ },
68091
+ "storageAdmin.listTables": {
68092
+ capName: "storage-admin",
68093
+ capScope: "system",
68094
+ addonId: null,
68095
+ access: "view"
68096
+ },
67926
68097
  "storageEvictable.evict": {
67927
68098
  capName: "storage-evictable",
67928
68099
  capScope: "system",
@@ -74027,6 +74198,778 @@ async function revokeBrokerCredential(fetchImpl, brokerUrl, credential) {
74027
74198
  await call(fetchImpl, brokerUrl, "/broker/revoke", { bearer: credential }, unknown());
74028
74199
  }
74029
74200
  //#endregion
74201
+ //#region src/endpoints/adapters/climate-model.ts
74202
+ /**
74203
+ * What a `climate-control` device is to Alexa (D874) — pure, so the discovery
74204
+ * shape, the slice mapping and the directive handlers share one definition.
74205
+ *
74206
+ * `Alexa.ThermostatController` 3.1 targetSetpoint, lowerSetpoint /
74207
+ * upperSetpoint (a device with a range),
74208
+ * thermostatMode HEAT / COOL / AUTO / ECO /
74209
+ * OFF
74210
+ * `Alexa.TemperatureSensor` the current temperature
74211
+ * `Alexa.PowerController` when the device can be turned off
74212
+ * `Alexa.ModeController` Climate.Mode only when the device has a mode
74213
+ * Amazon's thermostat vocabulary lacks
74214
+ * (dry, fan_only)
74215
+ * `Alexa.ModeController` Climate.FanSpeed the device's own fan modes
74216
+ *
74217
+ * Amazon's `thermostatMode` has no value for `dry` / `fan_only`, and a CUSTOM
74218
+ * mode is not a documented way to carry them: they travel through the
74219
+ * `Climate.Mode` ModeController, and `thermostatMode` is simply not reported
74220
+ * while the device is in one of them.
74221
+ *
74222
+ * Temperatures are Celsius throughout (the cap's contract); Alexa converts for
74223
+ * the account.
74224
+ */
74225
+ var INSTANCE_MODE = "Climate.Mode";
74226
+ var INSTANCE_FAN = "Climate.FanSpeed";
74227
+ var FAN_PREFIX = "Fan.";
74228
+ var DEFAULT_TEMP_STEP = .5;
74229
+ var FEATURE_DUAL_SETPOINT = "climate-dual-setpoint";
74230
+ var ALEXA_ECO_PRESET = "eco";
74231
+ var SINGLE_SETPOINT_MODES = [
74232
+ "heat",
74233
+ "cool",
74234
+ "auto",
74235
+ "dry"
74236
+ ];
74237
+ /** What TurnOn switches to, best first. */
74238
+ var TURN_ON_PREFERENCE = [
74239
+ "auto",
74240
+ "heat_cool",
74241
+ "cool",
74242
+ "heat"
74243
+ ];
74244
+ var SliceShape$1 = ClimateControlStatusSchema.pick({
74245
+ mode: true,
74246
+ availableModes: true,
74247
+ fanMode: true,
74248
+ availableFanModes: true,
74249
+ preset: true,
74250
+ availablePresets: true,
74251
+ target: true,
74252
+ targetHigh: true,
74253
+ targetLow: true,
74254
+ currentTemp: true,
74255
+ minTemp: true,
74256
+ maxTemp: true,
74257
+ tempStep: true
74258
+ });
74259
+ /** The slice fields the adapter reads, or null when the slice is not a climate one. */
74260
+ function readClimateSlice(slice) {
74261
+ const parsed = SliceShape$1.safeParse(slice);
74262
+ return parsed.success ? parsed.data : null;
74263
+ }
74264
+ function boundsOf(slice) {
74265
+ const min = slice.minTemp ?? 7;
74266
+ const max = slice.maxTemp ?? 35;
74267
+ const step = slice.tempStep ?? .5;
74268
+ return min < max ? {
74269
+ min,
74270
+ max,
74271
+ step
74272
+ } : {
74273
+ min: 7,
74274
+ max: 35,
74275
+ step: DEFAULT_TEMP_STEP
74276
+ };
74277
+ }
74278
+ /** One decimal, the precision Alexa's own conversions keep. */
74279
+ function round1(value) {
74280
+ return Math.round(value * 10) / 10;
74281
+ }
74282
+ /**
74283
+ * Snap a setpoint to the device's step (anchored at its minimum), then clamp:
74284
+ * a maximum off the step grid (max 30.2, step 0.5) must not snap above itself.
74285
+ */
74286
+ function snapToStep(value, bounds) {
74287
+ const snapped = bounds.min + Math.round((value - bounds.min) / bounds.step) * bounds.step;
74288
+ return round1(Math.min(bounds.max, Math.max(bounds.min, snapped)));
74289
+ }
74290
+ function temperatureValue(celsius) {
74291
+ return {
74292
+ value: round1(celsius),
74293
+ scale: "CELSIUS"
74294
+ };
74295
+ }
74296
+ function hasMode(slice, mode) {
74297
+ return slice !== null && slice.availableModes.includes(mode);
74298
+ }
74299
+ /** Dual setpoints (a heat/cool range). */
74300
+ function supportsDual(slice, features) {
74301
+ return slice === null ? features.includes(FEATURE_DUAL_SETPOINT) : hasMode(slice, "heat_cool");
74302
+ }
74303
+ /** A single setpoint (everything but a range-only device). */
74304
+ function supportsSingle(slice) {
74305
+ return slice === null || slice.availableModes.length === 0 ? true : slice.availableModes.some((m) => SINGLE_SETPOINT_MODES.includes(m));
74306
+ }
74307
+ /** The device's own name for its `eco` preset, if it has one. */
74308
+ function ecoPresetOf(slice) {
74309
+ return slice?.availablePresets.find((p) => p.toLowerCase() === ALEXA_ECO_PRESET) ?? null;
74310
+ }
74311
+ /** Amazon thermostat modes the device can be put in, in a stable order. */
74312
+ function supportedThermostatModes(slice) {
74313
+ if (slice === null || slice.availableModes.length === 0) return [
74314
+ "HEAT",
74315
+ "COOL",
74316
+ "AUTO",
74317
+ "OFF"
74318
+ ];
74319
+ const out = [];
74320
+ if (hasMode(slice, "heat")) out.push("HEAT");
74321
+ if (hasMode(slice, "cool")) out.push("COOL");
74322
+ if (hasMode(slice, "auto") || hasMode(slice, "heat_cool")) out.push("AUTO");
74323
+ if (ecoPresetOf(slice) !== null && out.length > 0) out.push("ECO");
74324
+ if (hasMode(slice, "off")) out.push("OFF");
74325
+ return out;
74326
+ }
74327
+ /** The HVAC mode TurnOn / leaving OFF switches to. */
74328
+ function onModeOf(slice) {
74329
+ return TURN_ON_PREFERENCE.find((m) => hasMode(slice, m)) ?? null;
74330
+ }
74331
+ function canPowerOff(slice) {
74332
+ return slice === null || slice.availableModes.length === 0 || hasMode(slice, "off");
74333
+ }
74334
+ /** A device with a mode Amazon's thermostat vocabulary lacks. */
74335
+ function hasExtraModes(slice) {
74336
+ return hasMode(slice, "dry") || hasMode(slice, "fan_only");
74337
+ }
74338
+ var MODE_VALUE = {
74339
+ heat: "Mode.Heat",
74340
+ cool: "Mode.Cool",
74341
+ auto: "Mode.Auto",
74342
+ dry: "Mode.Dry",
74343
+ fan_only: "Mode.Fan",
74344
+ off: "Mode.Off"
74345
+ };
74346
+ var MODE_NAMES = {
74347
+ "Mode.Heat": ["Riscaldamento", "Heating"],
74348
+ "Mode.Cool": ["Raffrescamento", "Cooling"],
74349
+ "Mode.Auto": ["Automatico", "Auto"],
74350
+ "Mode.Dry": ["Deumidificazione", "Dry"],
74351
+ "Mode.Fan": ["Ventilazione", "Fan only"],
74352
+ "Mode.Off": ["Spento", "Off"]
74353
+ };
74354
+ /** The ModeController value a HVAC mode reports as (a range is "auto"). */
74355
+ function modeValueOf(mode) {
74356
+ return mode === "heat_cool" ? MODE_VALUE.auto : MODE_VALUE[mode];
74357
+ }
74358
+ /** The HVAC mode a ModeController value asks for, among those the device has. */
74359
+ function hvacModeForValue(value, slice) {
74360
+ if (value === MODE_VALUE.auto) return hasMode(slice, "auto") ? "auto" : hasMode(slice, "heat_cool") ? "heat_cool" : null;
74361
+ const mode = Object.entries(MODE_VALUE).find(([, v]) => v === value)?.[0];
74362
+ if (mode === void 0) return null;
74363
+ const candidate = SliceShape$1.shape.mode.safeParse(mode);
74364
+ return candidate.success && hasMode(slice, candidate.data) ? candidate.data : null;
74365
+ }
74366
+ /** The ModeController values the device offers, in a stable order. */
74367
+ function modeValuesOf(slice) {
74368
+ return [
74369
+ "heat",
74370
+ "cool",
74371
+ "auto",
74372
+ "dry",
74373
+ "fan_only",
74374
+ "off"
74375
+ ].filter((m) => m === "auto" ? hasMode(slice, "auto") || hasMode(slice, "heat_cool") : hasMode(slice, m)).map((m) => MODE_VALUE[m]);
74376
+ }
74377
+ function textNames(it, en) {
74378
+ return [{
74379
+ "@type": "text",
74380
+ value: {
74381
+ text: it,
74382
+ locale: "it-IT"
74383
+ }
74384
+ }, {
74385
+ "@type": "text",
74386
+ value: {
74387
+ text: en,
74388
+ locale: "en-US"
74389
+ }
74390
+ }];
74391
+ }
74392
+ function modeCapability(slice) {
74393
+ const values = modeValuesOf(slice);
74394
+ if (values.length < 2) return null;
74395
+ return {
74396
+ type: "AlexaInterface",
74397
+ interface: "Alexa.ModeController",
74398
+ version: "3",
74399
+ instance: INSTANCE_MODE,
74400
+ capabilityResources: { friendlyNames: [{
74401
+ "@type": "asset",
74402
+ value: { assetId: "Alexa.Setting.Mode" }
74403
+ }, ...textNames("Modalità", "Mode")] },
74404
+ properties: {
74405
+ supported: [{ name: "mode" }],
74406
+ proactivelyReported: true,
74407
+ retrievable: true
74408
+ },
74409
+ configuration: {
74410
+ ordered: false,
74411
+ supportedModes: values.map((value) => {
74412
+ const [it, en] = MODE_NAMES[value] ?? [value, value];
74413
+ return {
74414
+ value,
74415
+ modeResources: { friendlyNames: textNames(it, en) }
74416
+ };
74417
+ })
74418
+ }
74419
+ };
74420
+ }
74421
+ var FAN_ASSETS = {
74422
+ low: "Alexa.Value.Low",
74423
+ medium: "Alexa.Value.Medium",
74424
+ high: "Alexa.Value.High"
74425
+ };
74426
+ var FAN_TEXTS = {
74427
+ auto: ["Automatico", "Auto"],
74428
+ quiet: ["Silenzioso", "Quiet"],
74429
+ turbo: ["Turbo", "Turbo"],
74430
+ on: ["Acceso", "On"],
74431
+ off: ["Spento", "Off"],
74432
+ middle: ["Medio", "Medium"],
74433
+ mediumlow: ["Medio basso", "Medium low"],
74434
+ mediumhigh: ["Medio alto", "Medium high"],
74435
+ medium_low: ["Medio basso", "Medium low"],
74436
+ medium_high: ["Medio alto", "Medium high"]
74437
+ };
74438
+ function capitalize(word) {
74439
+ return word.charAt(0).toUpperCase() + word.slice(1);
74440
+ }
74441
+ /** The ModeController value of a vendor fan mode (`medium_high` -> `Fan.MediumHigh`). */
74442
+ function fanValueOf(fanMode) {
74443
+ const words = fanMode.split(/[^A-Za-z0-9]+/).filter((w) => w.length > 0);
74444
+ return words.length === 0 ? null : `${FAN_PREFIX}${words.map(capitalize).join("")}`;
74445
+ }
74446
+ /** The fan modes with a usable, unique value: `[value, deviceFanMode]`. */
74447
+ function fanModesOf(slice) {
74448
+ const seen = /* @__PURE__ */ new Set();
74449
+ const out = [];
74450
+ for (const fanMode of slice.availableFanModes) {
74451
+ const value = fanValueOf(fanMode);
74452
+ if (value === null || seen.has(value)) continue;
74453
+ seen.add(value);
74454
+ out.push([value, fanMode]);
74455
+ }
74456
+ return out;
74457
+ }
74458
+ function hasFanModes(slice) {
74459
+ return slice !== null && fanModesOf(slice).length >= 2;
74460
+ }
74461
+ function fanFriendlyNames(fanMode) {
74462
+ const key = fanMode.toLowerCase();
74463
+ const asset = FAN_ASSETS[key];
74464
+ if (asset !== void 0) return [{
74465
+ "@type": "asset",
74466
+ value: { assetId: asset }
74467
+ }];
74468
+ const [it, en] = FAN_TEXTS[key] ?? [fanMode.replace(/_/g, " "), fanMode.replace(/_/g, " ")];
74469
+ return textNames(it, en);
74470
+ }
74471
+ function fanCapability(slice) {
74472
+ const modes = fanModesOf(slice);
74473
+ if (modes.length < 2) return null;
74474
+ return {
74475
+ type: "AlexaInterface",
74476
+ interface: "Alexa.ModeController",
74477
+ version: "3",
74478
+ instance: INSTANCE_FAN,
74479
+ capabilityResources: { friendlyNames: [{
74480
+ "@type": "asset",
74481
+ value: { assetId: "Alexa.Setting.FanSpeed" }
74482
+ }, ...textNames("Velocità ventola", "Fan speed")] },
74483
+ properties: {
74484
+ supported: [{ name: "mode" }],
74485
+ proactivelyReported: true,
74486
+ retrievable: true
74487
+ },
74488
+ configuration: {
74489
+ ordered: false,
74490
+ supportedModes: modes.map(([value, fanMode]) => ({
74491
+ value,
74492
+ modeResources: { friendlyNames: fanFriendlyNames(fanMode) }
74493
+ }))
74494
+ }
74495
+ };
74496
+ }
74497
+ function propertiesBlock(names) {
74498
+ return {
74499
+ supported: names.map((name) => ({ name })),
74500
+ proactivelyReported: true,
74501
+ retrievable: true
74502
+ };
74503
+ }
74504
+ function thermostatCapability(slice, modes, features) {
74505
+ return {
74506
+ type: "AlexaInterface",
74507
+ interface: "Alexa.ThermostatController",
74508
+ version: "3.1",
74509
+ properties: propertiesBlock([
74510
+ ...supportsSingle(slice) ? ["targetSetpoint"] : [],
74511
+ ...supportsDual(slice, features) ? ["lowerSetpoint", "upperSetpoint"] : [],
74512
+ "thermostatMode"
74513
+ ]),
74514
+ configuration: {
74515
+ supportedModes: modes,
74516
+ supportsScheduling: false
74517
+ }
74518
+ };
74519
+ }
74520
+ /** A thermostat is a THERMOSTAT, any other climate device an air conditioner. */
74521
+ function climateCategoryFor(deviceType) {
74522
+ return deviceType === "thermostat" ? "THERMOSTAT" : "AIR_CONDITIONER";
74523
+ }
74524
+ function describeClimate(device) {
74525
+ const slice = readClimateSlice(device.slices["climate-control"]);
74526
+ const modes = supportedThermostatModes(slice);
74527
+ if (modes.length === 0) return {
74528
+ kind: "skip",
74529
+ reason: "climate: the device offers no mode Alexa has a thermostat value for"
74530
+ };
74531
+ const extras = [];
74532
+ if (slice !== null) {
74533
+ const mode = hasExtraModes(slice) ? modeCapability(slice) : null;
74534
+ const fan = hasFanModes(slice) ? fanCapability(slice) : null;
74535
+ if (mode !== null) extras.push(mode);
74536
+ if (fan !== null) extras.push(fan);
74537
+ }
74538
+ return {
74539
+ kind: "publish",
74540
+ capabilities: [
74541
+ thermostatCapability(slice, modes, device.features),
74542
+ {
74543
+ type: "AlexaInterface",
74544
+ interface: "Alexa.TemperatureSensor",
74545
+ version: "3",
74546
+ properties: propertiesBlock(["temperature"])
74547
+ },
74548
+ ...canPowerOff(slice) ? [{
74549
+ type: "AlexaInterface",
74550
+ interface: "Alexa.PowerController",
74551
+ version: "3",
74552
+ properties: propertiesBlock(["powerState"])
74553
+ }] : [],
74554
+ ...extras
74555
+ ],
74556
+ category: climateCategoryFor(device.type)
74557
+ };
74558
+ }
74559
+ /** Amazon's `thermostatMode` for the state, or null while in a mode it lacks. */
74560
+ function thermostatModeOf(slice) {
74561
+ if (slice.mode === "off") return "OFF";
74562
+ const eco = ecoPresetOf(slice);
74563
+ if (eco !== null && slice.preset === eco) return "ECO";
74564
+ switch (slice.mode) {
74565
+ case "heat": return "HEAT";
74566
+ case "cool": return "COOL";
74567
+ case "auto":
74568
+ case "heat_cool": return "AUTO";
74569
+ default: return null;
74570
+ }
74571
+ }
74572
+ function climateProperties(slice) {
74573
+ const s = readClimateSlice(slice);
74574
+ if (s === null) return [];
74575
+ const out = [];
74576
+ const thermostat = (name, value) => {
74577
+ out.push({
74578
+ namespace: "Alexa.ThermostatController",
74579
+ name,
74580
+ value
74581
+ });
74582
+ };
74583
+ if (hasMode(s, "heat_cool") && s.targetLow !== null && s.targetHigh !== null && s.mode === "heat_cool") {
74584
+ thermostat("lowerSetpoint", temperatureValue(s.targetLow ?? 0));
74585
+ thermostat("upperSetpoint", temperatureValue(s.targetHigh ?? 0));
74586
+ } else if (s.target !== null && supportsSingle(s)) thermostat("targetSetpoint", temperatureValue(s.target));
74587
+ const mode = thermostatModeOf(s);
74588
+ if (mode !== null) thermostat("thermostatMode", mode);
74589
+ if (s.currentTemp !== null) out.push({
74590
+ namespace: "Alexa.TemperatureSensor",
74591
+ name: "temperature",
74592
+ value: temperatureValue(s.currentTemp)
74593
+ });
74594
+ if (canPowerOff(s)) out.push({
74595
+ namespace: "Alexa.PowerController",
74596
+ name: "powerState",
74597
+ value: s.mode === "off" ? "OFF" : "ON"
74598
+ });
74599
+ if (hasExtraModes(s) && modeValuesOf(s).length >= 2) out.push({
74600
+ namespace: "Alexa.ModeController",
74601
+ instance: INSTANCE_MODE,
74602
+ name: "mode",
74603
+ value: modeValueOf(s.mode)
74604
+ });
74605
+ const fan = fanValueOf(s.fanMode);
74606
+ if (hasFanModes(s) && fan !== null && fanModesOf(s).some(([value]) => value === fan)) out.push({
74607
+ namespace: "Alexa.ModeController",
74608
+ instance: INSTANCE_FAN,
74609
+ name: "mode",
74610
+ value: fan
74611
+ });
74612
+ return out;
74613
+ }
74614
+ //#endregion
74615
+ //#region src/handlers/climate-controller.ts
74616
+ var CAP_CLIMATE = "climate-control";
74617
+ var CAP_SWITCH$1 = "switch";
74618
+ var UNCERTAINTY_MS = 500;
74619
+ var PRESET_NONE = "none";
74620
+ var ScaleSchema = _enum([
74621
+ "CELSIUS",
74622
+ "FAHRENHEIT",
74623
+ "KELVIN"
74624
+ ]);
74625
+ var TemperatureSchema = object({
74626
+ value: number(),
74627
+ scale: ScaleSchema
74628
+ });
74629
+ var SetTemperaturePayload = object({
74630
+ targetSetpoint: TemperatureSchema.optional(),
74631
+ lowerSetpoint: TemperatureSchema.optional(),
74632
+ upperSetpoint: TemperatureSchema.optional()
74633
+ });
74634
+ var AdjustTemperaturePayload = object({ targetSetpointDelta: TemperatureSchema });
74635
+ var ThermostatModePayload = object({ thermostatMode: union([string(), object({ value: string() })]) });
74636
+ var ModePayload = object({ mode: string() });
74637
+ function isRefusal(outcome) {
74638
+ return "refusal" in outcome;
74639
+ }
74640
+ function toCelsius(t) {
74641
+ if (t.scale === "FAHRENHEIT") return (t.value - 32) * 5 / 9;
74642
+ if (t.scale === "KELVIN") return t.value - 273.15;
74643
+ return t.value;
74644
+ }
74645
+ /** A DELTA converts without the scale's offset. */
74646
+ function deltaToCelsius(t) {
74647
+ return t.scale === "FAHRENHEIT" ? t.value * 5 / 9 : t.value;
74648
+ }
74649
+ function withPayload(envelope, extra) {
74650
+ return { event: {
74651
+ ...envelope.event,
74652
+ payload: {
74653
+ ...envelope.event.payload,
74654
+ ...extra
74655
+ }
74656
+ } };
74657
+ }
74658
+ function outOfRange$1(header, bounds) {
74659
+ return { refusal: withPayload(errorEnvelope(header, "TEMPERATURE_VALUE_OUT_OF_RANGE", `the setpoint must be between ${bounds.min} and ${bounds.max} C`), { validRange: {
74660
+ minimumValue: temperatureValue(bounds.min),
74661
+ maximumValue: temperatureValue(bounds.max)
74662
+ } }) };
74663
+ }
74664
+ function tooClose(header, bounds) {
74665
+ return { refusal: withPayload(errorEnvelope(header, "REQUESTED_SETPOINTS_TOO_CLOSE", "lowerSetpoint must be below upperSetpoint"), { minimumTemperatureDelta: temperatureValue(bounds.step) }) };
74666
+ }
74667
+ function refuse$1(header, type, message) {
74668
+ return { refusal: errorEnvelope(header, type, message) };
74669
+ }
74670
+ function inRange(celsius, bounds) {
74671
+ const v = round1(celsius);
74672
+ return v >= bounds.min && v <= bounds.max;
74673
+ }
74674
+ var THERMOSTAT_PROPERTIES = new Set([
74675
+ "targetSetpoint",
74676
+ "lowerSetpoint",
74677
+ "upperSetpoint",
74678
+ "thermostatMode"
74679
+ ]);
74680
+ function thermostatProperty(property) {
74681
+ return property.namespace === "Alexa.ThermostatController" && THERMOSTAT_PROPERTIES.has(property.name);
74682
+ }
74683
+ function commandsFor(api, deviceId) {
74684
+ return {
74685
+ setMode: (mode) => api.climateControl.setMode.mutate({
74686
+ deviceId,
74687
+ mode
74688
+ }),
74689
+ setPreset: (preset) => api.climateControl.setPreset.mutate({
74690
+ deviceId,
74691
+ preset
74692
+ }),
74693
+ setFanMode: (fanMode) => api.climateControl.setFanMode.mutate({
74694
+ deviceId,
74695
+ fanMode
74696
+ }),
74697
+ setTarget: (target) => api.climateControl.setTarget.mutate({
74698
+ deviceId,
74699
+ target
74700
+ }),
74701
+ setTargetRange: (targetLow, targetHigh) => api.climateControl.setTargetRange.mutate({
74702
+ deviceId,
74703
+ targetLow,
74704
+ targetHigh
74705
+ })
74706
+ };
74707
+ }
74708
+ function planRange(header, slice, cmd, low, high) {
74709
+ const bounds = boundsOf(slice);
74710
+ if (!supportsDual(slice, [])) return refuse$1(header, "DUAL_SETPOINTS_UNSUPPORTED", "this thermostat has a single setpoint");
74711
+ if (!inRange(low, bounds) || !inRange(high, bounds)) return outOfRange$1(header, bounds);
74712
+ const targetLow = snapToStep(low, bounds);
74713
+ const targetHigh = snapToStep(high, bounds);
74714
+ if (targetHigh - targetLow < bounds.step) return tooClose(header, bounds);
74715
+ return {
74716
+ commands: [...slice.mode === "heat_cool" ? [] : [() => cmd.setMode("heat_cool")], () => cmd.setTargetRange(targetLow, targetHigh)],
74717
+ projected: {
74718
+ ...slice,
74719
+ mode: "heat_cool",
74720
+ targetLow,
74721
+ targetHigh
74722
+ },
74723
+ keep: thermostatProperty
74724
+ };
74725
+ }
74726
+ function planSingle(header, slice, cmd, celsius) {
74727
+ const bounds = boundsOf(slice);
74728
+ if (!supportsSingle(slice) || slice.mode === "heat_cool") return refuse$1(header, "UNWILLING_TO_SET_VALUE", "the thermostat is in range mode: set a lower and an upper setpoint");
74729
+ if (!inRange(celsius, bounds)) return outOfRange$1(header, bounds);
74730
+ const target = snapToStep(celsius, bounds);
74731
+ return {
74732
+ commands: [() => cmd.setTarget(target)],
74733
+ projected: {
74734
+ ...slice,
74735
+ target
74736
+ },
74737
+ keep: thermostatProperty
74738
+ };
74739
+ }
74740
+ function planSetTemperature(c, slice, cmd) {
74741
+ const parsed = SetTemperaturePayload.safeParse(c.payload);
74742
+ if (!parsed.success) return refuse$1(c.header, "INVALID_DIRECTIVE", parsed.error.message);
74743
+ const { targetSetpoint, lowerSetpoint, upperSetpoint } = parsed.data;
74744
+ if (targetSetpoint !== void 0 && lowerSetpoint !== void 0 && upperSetpoint !== void 0) return refuse$1(c.header, "TRIPLE_SETPOINTS_UNSUPPORTED", "a target AND a range were requested");
74745
+ if (lowerSetpoint !== void 0 && upperSetpoint !== void 0 && targetSetpoint === void 0) return planRange(c.header, slice, cmd, toCelsius(lowerSetpoint), toCelsius(upperSetpoint));
74746
+ if (targetSetpoint !== void 0 && lowerSetpoint === void 0 && upperSetpoint === void 0) return planSingle(c.header, slice, cmd, toCelsius(targetSetpoint));
74747
+ return refuse$1(c.header, "INVALID_DIRECTIVE", "give a targetSetpoint, or a lower and an upper");
74748
+ }
74749
+ function planAdjust(c, slice, cmd) {
74750
+ const parsed = AdjustTemperaturePayload.safeParse(c.payload);
74751
+ if (!parsed.success) return refuse$1(c.header, "INVALID_DIRECTIVE", parsed.error.message);
74752
+ const delta = deltaToCelsius(parsed.data.targetSetpointDelta);
74753
+ if (slice.mode === "heat_cool" && slice.targetLow !== null && slice.targetHigh !== null) return planRange(c.header, slice, cmd, slice.targetLow + delta, slice.targetHigh + delta);
74754
+ if (slice.target === null) return refuse$1(c.header, "ENDPOINT_UNREACHABLE", "the current setpoint is unknown, so it cannot be adjusted");
74755
+ return planSingle(c.header, slice, cmd, slice.target + delta);
74756
+ }
74757
+ var ECO_FROM_OFF_MESSAGE = "the thermostat has no mode to leave OFF into";
74758
+ function hvacModeFor(requested, slice) {
74759
+ const has = (m) => slice.availableModes.includes(m);
74760
+ switch (requested) {
74761
+ case "HEAT": return has("heat") ? "heat" : null;
74762
+ case "COOL": return has("cool") ? "cool" : null;
74763
+ case "AUTO": return has("auto") ? "auto" : has("heat_cool") ? "heat_cool" : null;
74764
+ case "OFF": return has("off") ? "off" : null;
74765
+ default: return null;
74766
+ }
74767
+ }
74768
+ /** The preset that clears `eco` (HA and Gree both call it `none`), if the device has one. */
74769
+ function presetClearing(slice) {
74770
+ return slice.availablePresets.find((p) => p.toLowerCase() === PRESET_NONE) ?? null;
74771
+ }
74772
+ function planEco(c, slice, cmd) {
74773
+ const eco = ecoPresetOf(slice);
74774
+ if (eco === null) return refuse$1(c.header, "UNSUPPORTED_THERMOSTAT_MODE", "ECO is not supported");
74775
+ const wake = slice.mode === "off" ? onModeOf(slice) : null;
74776
+ if (slice.mode === "off" && wake === null) return refuse$1(c.header, "UNSUPPORTED_THERMOSTAT_MODE", ECO_FROM_OFF_MESSAGE);
74777
+ return {
74778
+ commands: [...wake === null ? [] : [() => cmd.setMode(wake)], () => cmd.setPreset(eco)],
74779
+ projected: {
74780
+ ...slice,
74781
+ mode: wake ?? slice.mode,
74782
+ preset: eco
74783
+ },
74784
+ keep: thermostatProperty
74785
+ };
74786
+ }
74787
+ function planSetThermostatMode(c, slice, cmd) {
74788
+ const parsed = ThermostatModePayload.safeParse(c.payload);
74789
+ if (!parsed.success) return refuse$1(c.header, "INVALID_DIRECTIVE", parsed.error.message);
74790
+ const raw = parsed.data.thermostatMode;
74791
+ const requested = (typeof raw === "string" ? raw : raw.value).toUpperCase();
74792
+ if (requested === "ECO") return planEco(c, slice, cmd);
74793
+ const mode = hvacModeFor(requested, slice);
74794
+ if (mode === null) return refuse$1(c.header, "UNSUPPORTED_THERMOSTAT_MODE", `${requested} is not a mode this thermostat has`);
74795
+ const eco = ecoPresetOf(slice);
74796
+ const clear = eco !== null && slice.preset === eco ? presetClearing(slice) : null;
74797
+ return {
74798
+ commands: [...clear === null ? [] : [() => cmd.setPreset(clear)], () => cmd.setMode(mode)],
74799
+ projected: {
74800
+ ...slice,
74801
+ mode,
74802
+ preset: clear ?? slice.preset
74803
+ },
74804
+ keep: thermostatProperty
74805
+ };
74806
+ }
74807
+ function planPower(c, slice, cmd) {
74808
+ const turnOn = c.header.name === "TurnOn";
74809
+ const keep = (p) => p.namespace === "Alexa.PowerController";
74810
+ if (turnOn) {
74811
+ if (slice.mode !== "off") return {
74812
+ commands: [],
74813
+ projected: slice,
74814
+ keep
74815
+ };
74816
+ const mode = onModeOf(slice);
74817
+ if (mode === null) return refuse$1(c.header, "INVALID_DIRECTIVE", "no mode to turn on into");
74818
+ return {
74819
+ commands: [() => cmd.setMode(mode)],
74820
+ projected: {
74821
+ ...slice,
74822
+ mode
74823
+ },
74824
+ keep
74825
+ };
74826
+ }
74827
+ if (!slice.availableModes.includes("off")) return refuse$1(c.header, "INVALID_DIRECTIVE", "this device cannot be turned off");
74828
+ if (slice.mode === "off") return {
74829
+ commands: [],
74830
+ projected: slice,
74831
+ keep
74832
+ };
74833
+ return {
74834
+ commands: [() => cmd.setMode("off")],
74835
+ projected: {
74836
+ ...slice,
74837
+ mode: "off"
74838
+ },
74839
+ keep
74840
+ };
74841
+ }
74842
+ function planModeController(c, slice, cmd) {
74843
+ const parsed = ModePayload.safeParse(c.payload);
74844
+ const instance = c.header.instance;
74845
+ if (!parsed.success || instance === void 0) return refuse$1(c.header, "INVALID_DIRECTIVE", "a mode and an instance are required");
74846
+ const keep = (p) => p.instance === instance;
74847
+ if (instance === "Climate.Mode") {
74848
+ const mode = hvacModeForValue(parsed.data.mode, slice);
74849
+ if (mode === null) return refuse$1(c.header, "INVALID_VALUE", `unknown mode ${parsed.data.mode}`);
74850
+ return {
74851
+ commands: [() => cmd.setMode(mode)],
74852
+ projected: {
74853
+ ...slice,
74854
+ mode
74855
+ },
74856
+ keep
74857
+ };
74858
+ }
74859
+ if (instance === "Climate.FanSpeed") {
74860
+ const fan = fanModesOf(slice).find(([value]) => value === parsed.data.mode);
74861
+ if (fan === void 0) return refuse$1(c.header, "INVALID_VALUE", `unknown fan mode ${parsed.data.mode}`);
74862
+ return {
74863
+ commands: [() => cmd.setFanMode(fan[1])],
74864
+ projected: {
74865
+ ...slice,
74866
+ fanMode: fan[1]
74867
+ },
74868
+ keep
74869
+ };
74870
+ }
74871
+ return refuse$1(c.header, "INVALID_DIRECTIVE", `unknown instance ${instance}`);
74872
+ }
74873
+ function plan(c, slice, cmd) {
74874
+ switch (`${c.header.namespace}/${c.header.name}`) {
74875
+ case "Alexa.ThermostatController/SetTargetTemperature": return planSetTemperature(c, slice, cmd);
74876
+ case "Alexa.ThermostatController/AdjustTargetTemperature": return planAdjust(c, slice, cmd);
74877
+ case "Alexa.ThermostatController/SetThermostatMode": return planSetThermostatMode(c, slice, cmd);
74878
+ case "Alexa.PowerController/TurnOn":
74879
+ case "Alexa.PowerController/TurnOff": return planPower(c, slice, cmd);
74880
+ case "Alexa.ModeController/SetMode": return planModeController(c, slice, cmd);
74881
+ default: return refuse$1(c.header, "INVALID_DIRECTIVE", `${c.header.namespace}/${c.header.name}`);
74882
+ }
74883
+ }
74884
+ async function readSlice$1(c, deps) {
74885
+ try {
74886
+ return readClimateSlice(await deps.ctx.api.deviceState.getCapSlice.query({
74887
+ deviceId: c.deviceId,
74888
+ capName: CAP_CLIMATE
74889
+ }));
74890
+ } catch (err) {
74891
+ deps.logger.warn("alexa climate-controller: the climate state could not be read", {
74892
+ tags: { deviceId: c.deviceId },
74893
+ meta: { error: err instanceof Error ? err.message : String(err) }
74894
+ });
74895
+ return null;
74896
+ }
74897
+ }
74898
+ function successResponse$1(c, properties) {
74899
+ const now = (/* @__PURE__ */ new Date()).toISOString();
74900
+ return {
74901
+ event: {
74902
+ header: {
74903
+ namespace: "Alexa",
74904
+ name: "Response",
74905
+ messageId: c.header.messageId,
74906
+ ...c.header.correlationToken !== void 0 ? { correlationToken: c.header.correlationToken } : {},
74907
+ payloadVersion: "3"
74908
+ },
74909
+ endpoint: { endpointId: c.endpoint.endpointId },
74910
+ payload: {}
74911
+ },
74912
+ context: { properties: properties.map((p) => ({
74913
+ ...p,
74914
+ timeOfSample: now,
74915
+ uncertaintyInMilliseconds: UNCERTAINTY_MS
74916
+ })) }
74917
+ };
74918
+ }
74919
+ /** The one handler behind every climate directive. */
74920
+ async function handleClimateDirective(c) {
74921
+ const deps = c.deps.climateController;
74922
+ const slice = await readSlice$1(c, deps);
74923
+ if (slice === null) return errorEnvelope(c.header, "ENDPOINT_UNREACHABLE", "the climate state could not be read");
74924
+ const outcome = plan(c, slice, commandsFor(deps.ctx.api, c.deviceId));
74925
+ if (isRefusal(outcome)) return outcome.refusal;
74926
+ try {
74927
+ for (const command of outcome.commands) await command();
74928
+ } catch (err) {
74929
+ deps.logger.warn("alexa climate-controller: the climate command failed", {
74930
+ tags: { deviceId: c.deviceId },
74931
+ meta: {
74932
+ directive: c.header.name,
74933
+ error: err instanceof Error ? err.message : String(err)
74934
+ }
74935
+ });
74936
+ return errorEnvelope(c.header, "ENDPOINT_UNREACHABLE", "the device did not accept the command");
74937
+ }
74938
+ deps.logger.info(`alexa climate-controller: ${c.header.name}`, {
74939
+ tags: { deviceId: c.deviceId },
74940
+ meta: { commands: outcome.commands.length }
74941
+ });
74942
+ return successResponse$1(c, climateProperties(outcome.projected).filter(outcome.keep));
74943
+ }
74944
+ /**
74945
+ * Last answer per device, per deps instance: a failed bindings read changes
74946
+ * nothing (AGENTS D49) — a thermostat's TurnOff must not fall through to the
74947
+ * switch handler because one `getBindings` call failed.
74948
+ */
74949
+ var lastPowerClaim = /* @__PURE__ */ new WeakMap();
74950
+ /** PowerController on a climate endpoint, not on a switch: decided by the bound caps. */
74951
+ async function handlesPower(c) {
74952
+ const deps = c.deps.climateController;
74953
+ const known = lastPowerClaim.get(deps) ?? /* @__PURE__ */ new Map();
74954
+ lastPowerClaim.set(deps, known);
74955
+ try {
74956
+ const caps = (await deps.ctx.api.deviceManager.getBindings.query({ deviceId: c.deviceId })).entries.map((e) => e.capName);
74957
+ const claim = caps.includes(CAP_CLIMATE) && !caps.includes(CAP_SWITCH$1);
74958
+ known.set(c.deviceId, claim);
74959
+ return claim;
74960
+ } catch (err) {
74961
+ const previous = known.get(c.deviceId);
74962
+ deps.logger.warn("alexa climate-controller: the bound caps could not be read", {
74963
+ tags: { deviceId: c.deviceId },
74964
+ meta: {
74965
+ error: err instanceof Error ? err.message : String(err),
74966
+ decidedFrom: previous === void 0 ? "no previous answer: not claimed" : "previous answer"
74967
+ }
74968
+ });
74969
+ return previous ?? false;
74970
+ }
74971
+ }
74972
+ //#endregion
74030
74973
  //#region src/endpoints/adapters/shared.ts
74031
74974
  /** The base `Alexa` interface every endpoint declares. */
74032
74975
  var BASE = {
@@ -74077,6 +75020,88 @@ function numericValueField(value) {
74077
75020
  return typeof raw === "number" ? raw : null;
74078
75021
  }
74079
75022
  //#endregion
75023
+ //#region src/endpoints/adapters/temperature.ts
75024
+ /** A temperature change under half a degree is not worth a ChangeReport. */
75025
+ var TEMPERATURE_DEADBAND_CELSIUS = .5;
75026
+ /** A numeric sensor reports at most once a minute per property (D869). */
75027
+ var SENSOR_MIN_INTERVAL_MS = 6e4;
75028
+ /** `temperature-sensor` → `Alexa.TemperatureSensor` (TEMPERATURE_SENSOR). */
75029
+ var temperatureAdapter = {
75030
+ cap: "temperature-sensor",
75031
+ stateCaps: ["temperature-sensor"],
75032
+ discoveryReads: [],
75033
+ describe: () => ({
75034
+ kind: "publish",
75035
+ capabilities: [iface("Alexa.TemperatureSensor", "temperature")],
75036
+ category: "TEMPERATURE_SENSOR"
75037
+ }),
75038
+ properties: (_cap, slice) => {
75039
+ const p = TemperatureSensorStatusSchema.pick({ celsius: true }).safeParse(slice);
75040
+ return p.success ? [{
75041
+ namespace: "Alexa.TemperatureSensor",
75042
+ name: "temperature",
75043
+ value: {
75044
+ value: Math.round(p.data.celsius * 10) / 10,
75045
+ scale: "CELSIUS"
75046
+ }
75047
+ }] : [];
75048
+ },
75049
+ directives: [],
75050
+ shaping: () => ({
75051
+ deadband: TEMPERATURE_DEADBAND_CELSIUS,
75052
+ minIntervalMs: SENSOR_MIN_INTERVAL_MS,
75053
+ numericOf: numericValueField
75054
+ })
75055
+ };
75056
+ //#endregion
75057
+ //#region src/endpoints/adapters/climate.ts
75058
+ var OWN_INSTANCES = new Set([INSTANCE_MODE, INSTANCE_FAN]);
75059
+ var THERMOSTAT_DIRECTIVES = [
75060
+ "Alexa.ThermostatController/SetTargetTemperature",
75061
+ "Alexa.ThermostatController/AdjustTargetTemperature",
75062
+ "Alexa.ThermostatController/SetThermostatMode"
75063
+ ].map((key) => ({
75064
+ key,
75065
+ needsReachableDevice: true,
75066
+ handle: handleClimateDirective
75067
+ }));
75068
+ var POWER_DIRECTIVES$1 = ["Alexa.PowerController/TurnOn", "Alexa.PowerController/TurnOff"].map((key) => ({
75069
+ key,
75070
+ needsReachableDevice: true,
75071
+ handle: handleClimateDirective,
75072
+ claims: handlesPower
75073
+ }));
75074
+ var MODE_DIRECTIVE = {
75075
+ key: "Alexa.ModeController/SetMode",
75076
+ needsReachableDevice: true,
75077
+ handle: handleClimateDirective,
75078
+ claims: async (c) => c.header.instance !== void 0 && OWN_INSTANCES.has(c.header.instance)
75079
+ };
75080
+ /**
75081
+ * `climate-control` -> a thermostat (Home Assistant) or an air conditioner
75082
+ * (Gree) (D874): `Alexa.ThermostatController` 3.1 with the current temperature,
75083
+ * power, and the modes Amazon's vocabulary lacks and the fan mode as
75084
+ * ModeControllers. The current temperature is shaped like any temperature
75085
+ * sensor; setpoints, mode and fan report on every change.
75086
+ */
75087
+ var climateAdapter = {
75088
+ cap: "climate-control",
75089
+ stateCaps: ["climate-control"],
75090
+ discoveryReads: ["climate-control"],
75091
+ describe: describeClimate,
75092
+ properties: (_cap, slice) => climateProperties(slice),
75093
+ directives: [
75094
+ ...THERMOSTAT_DIRECTIVES,
75095
+ ...POWER_DIRECTIVES$1,
75096
+ MODE_DIRECTIVE
75097
+ ],
75098
+ shaping: (property) => property.namespace === "Alexa.TemperatureSensor" ? {
75099
+ deadband: TEMPERATURE_DEADBAND_CELSIUS,
75100
+ minIntervalMs: SENSOR_MIN_INTERVAL_MS,
75101
+ numericOf: numericValueField
75102
+ } : null
75103
+ };
75104
+ //#endregion
74080
75105
  //#region src/endpoints/adapters/contact.ts
74081
75106
  /** `contact` → `Alexa.ContactSensor` (CONTACT_SENSOR). Amazon: DETECTED = open. */
74082
75107
  var contactAdapter = {
@@ -74741,7 +75766,8 @@ var coverAdapter = {
74741
75766
  ].map((key) => ({
74742
75767
  key,
74743
75768
  needsReachableDevice: true,
74744
- handle: handleCoverDirective
75769
+ handle: handleCoverDirective,
75770
+ claims: async (c) => c.header.instance?.startsWith("Blind.") ?? false
74745
75771
  })),
74746
75772
  shaping: (property) => property.namespace === "Alexa.RangeController" ? {
74747
75773
  deadband: 1,
@@ -74749,40 +75775,6 @@ var coverAdapter = {
74749
75775
  numericOf: plainNumber
74750
75776
  } : { debounceMs: COVER_DEBOUNCE_MS }
74751
75777
  };
74752
- //#endregion
74753
- //#region src/endpoints/adapters/temperature.ts
74754
- /** A temperature change under half a degree is not worth a ChangeReport. */
74755
- var TEMPERATURE_DEADBAND_CELSIUS = .5;
74756
- /** A numeric sensor reports at most once a minute per property (D869). */
74757
- var SENSOR_MIN_INTERVAL_MS = 6e4;
74758
- /** `temperature-sensor` → `Alexa.TemperatureSensor` (TEMPERATURE_SENSOR). */
74759
- var temperatureAdapter = {
74760
- cap: "temperature-sensor",
74761
- stateCaps: ["temperature-sensor"],
74762
- discoveryReads: [],
74763
- describe: () => ({
74764
- kind: "publish",
74765
- capabilities: [iface("Alexa.TemperatureSensor", "temperature")],
74766
- category: "TEMPERATURE_SENSOR"
74767
- }),
74768
- properties: (_cap, slice) => {
74769
- const p = TemperatureSensorStatusSchema.pick({ celsius: true }).safeParse(slice);
74770
- return p.success ? [{
74771
- namespace: "Alexa.TemperatureSensor",
74772
- name: "temperature",
74773
- value: {
74774
- value: Math.round(p.data.celsius * 10) / 10,
74775
- scale: "CELSIUS"
74776
- }
74777
- }] : [];
74778
- },
74779
- directives: [],
74780
- shaping: () => ({
74781
- deadband: TEMPERATURE_DEADBAND_CELSIUS,
74782
- minIntervalMs: SENSOR_MIN_INTERVAL_MS,
74783
- numericOf: numericValueField
74784
- })
74785
- };
74786
75778
  /** `humidity-sensor` → `Alexa.HumiditySensor` (TEMPERATURE_SENSOR). */
74787
75779
  var humidityAdapter = {
74788
75780
  cap: "humidity-sensor",
@@ -75081,6 +76073,11 @@ var BRIGHTNESS_DIRECTIVE = {
75081
76073
  needsReachableDevice: true,
75082
76074
  handle: (c) => handleSetBrightness(c.header, c.endpoint, c.payload, c.deps.brightnessController, c.identity)
75083
76075
  };
76076
+ /** An outlet (D875: its source says so, e.g. HA `device_class: outlet`) is a
76077
+ * smart plug; every other switch is a SWITCH. */
76078
+ function switchCategory(device) {
76079
+ return device.role === DeviceRole.Outlet ? "SMARTPLUG" : "SWITCH";
76080
+ }
75084
76081
  //#endregion
75085
76082
  //#region src/endpoints/adapters/index.ts
75086
76083
  var GENERIC_ADAPTERS = [
@@ -75088,6 +76085,7 @@ var GENERIC_ADAPTERS = [
75088
76085
  contactAdapter,
75089
76086
  motionAdapter,
75090
76087
  presenceAdapter,
76088
+ climateAdapter,
75091
76089
  temperatureAdapter,
75092
76090
  humidityAdapter,
75093
76091
  {
@@ -75099,7 +76097,7 @@ var GENERIC_ADAPTERS = [
75099
76097
  return {
75100
76098
  kind: "publish",
75101
76099
  capabilities: [iface("Alexa.PowerController", "powerState"), ...dimmable ? [iface("Alexa.BrightnessController", "brightness")] : []],
75102
- category: dimmable || device.type === "light" ? "LIGHT" : "SWITCH"
76100
+ category: dimmable || device.type === "light" ? "LIGHT" : switchCategory(device)
75103
76101
  };
75104
76102
  },
75105
76103
  properties: (capName, slice) => {
@@ -75145,12 +76143,19 @@ function shapingFor(capName, property) {
75145
76143
  function discoveryReadCaps(capNames) {
75146
76144
  return [...new Set(GENERIC_ADAPTERS.filter((a) => capNames.includes(a.cap)).flatMap((a) => a.discoveryReads))];
75147
76145
  }
75148
- /** A directive an adapter handles on a generic endpoint, by `Namespace/Name`. */
75149
- function genericDirective(key) {
75150
- for (const adapter of GENERIC_ADAPTERS) {
75151
- const found = adapter.directives.find((d) => d.key === key);
75152
- if (found !== void 0) return found;
75153
- }
76146
+ /** Every directive an adapter declares for `Namespace/Name`, in registry order. */
76147
+ function genericDirectives(key) {
76148
+ return GENERIC_ADAPTERS.flatMap((a) => a.directives.filter((d) => d.key === key));
76149
+ }
76150
+ /**
76151
+ * The directive that serves this endpoint: of the adapters declaring the key,
76152
+ * in registry order, the first that claims it (two caps may answer one key -
76153
+ * power on a switch and on a thermostat). Null = nobody claims it.
76154
+ */
76155
+ async function resolveGenericDirective(context) {
76156
+ const key = `${context.header.namespace}/${context.header.name}`;
76157
+ for (const directive of genericDirectives(key)) if (directive.claims === void 0 || await directive.claims(context)) return directive;
76158
+ return null;
75154
76159
  }
75155
76160
  /** Every directive key the registry handles. */
75156
76161
  var GENERIC_DIRECTIVE_KEYS = [...new Set(GENERIC_ADAPTERS.flatMap((a) => a.directives.map((d) => d.key)))];
@@ -75192,9 +76197,9 @@ function planGenericDevice(device) {
75192
76197
  category
75193
76198
  };
75194
76199
  }
75195
- /** Device types this export may publish as a generic endpoint. A `thermostat`
75196
- * is listed so discovery can say WHY it is not published (no climate adapter
75197
- * yet, D869) instead of calling it a device with no mappable cap. */
76200
+ /** Device types this export may publish as a generic endpoint. `thermostat`
76201
+ * (Home Assistant) and `climate` (an air conditioner, e.g. Gree) both carry
76202
+ * the `climate-control` cap (D874). */
75198
76203
  var GENERIC_DEVICE_TYPES = [
75199
76204
  "light",
75200
76205
  "switch",
@@ -75203,11 +76208,13 @@ var GENERIC_DEVICE_TYPES = [
75203
76208
  "lock",
75204
76209
  "presence",
75205
76210
  "thermostat",
76211
+ "climate",
75206
76212
  "cover",
75207
76213
  "generic"
75208
76214
  ];
75209
76215
  function toEndpoint(input, plan) {
75210
- const name = input.name.trim();
76216
+ const override = input.alexaName?.trim() ?? "";
76217
+ const name = override.length > 0 ? override : input.name.trim();
75211
76218
  return {
75212
76219
  endpointId: `${input.deviceId}__dev`,
75213
76220
  manufacturerName: "camstack",
@@ -75218,13 +76225,7 @@ function toEndpoint(input, plan) {
75218
76225
  cookie: { stableId: input.stableId }
75219
76226
  };
75220
76227
  }
75221
- /** Why a climate device is withheld until its adapter exists. */
75222
- var THERMOSTAT_SKIP_REASON = "thermostat: Alexa climate control (Alexa.ThermostatController) has no adapter yet - not published until it does (D869, P3)";
75223
76228
  function planGenericEndpoint(input) {
75224
- if (input.type === "thermostat" && input.capNames.includes("climate-control")) return {
75225
- kind: "skip",
75226
- reasons: [THERMOSTAT_SKIP_REASON]
75227
- };
75228
76229
  const plan = planGenericDevice({
75229
76230
  deviceId: input.deviceId,
75230
76231
  type: input.type,
@@ -75240,6 +76241,74 @@ function planGenericEndpoint(input) {
75240
76241
  return plan;
75241
76242
  }
75242
76243
  //#endregion
76244
+ //#region src/endpoints/alexa-name.ts
76245
+ /**
76246
+ * The per-device "Alexa name" (D875): what Alexa calls the device, when it
76247
+ * should not be the device's own name.
76248
+ *
76249
+ * Stored under {@link ALEXA_NAME_KEY} in this addon's per-device settings store
76250
+ * (D870) and used as the endpoint `friendlyName`. It never reaches the endpoint
76251
+ * id (`<deviceId>__dev`), so renaming updates the SAME Alexa device on the next
76252
+ * AddOrUpdateReport instead of creating a duplicate.
76253
+ *
76254
+ * Amazon's friendlyName rule (Alexa.Discovery objects, endpoint object): "Up
76255
+ * to 256 alphanumeric characters and spaces. Don't include special characters
76256
+ * or punctuation." Letters of any script count as alphanumeric, so an Italian
76257
+ * name keeps its accents. The validator rejects what Amazon would reject, with
76258
+ * a message the operator can act on.
76259
+ */
76260
+ /** Per-device store key of the saved Alexa name. */
76261
+ var ALEXA_NAME_KEY = "alexaName";
76262
+ /** The device-settings form field key, namespaced per exporter (see the addon). */
76263
+ function alexaNameKey(deviceId) {
76264
+ return `alexa:${deviceId}:alexaName`;
76265
+ }
76266
+ /** Letters (any script, with their combining marks), digits and spaces. */
76267
+ var ALLOWED_PATTERN = /^[\p{L}\p{M}\p{N} ]+$/u;
76268
+ /**
76269
+ * Validate an operator-typed Alexa name. Blank clears the override
76270
+ * (`name: null`, the device name is used).
76271
+ */
76272
+ function validateAlexaName(raw) {
76273
+ if (raw === null || raw === void 0) return {
76274
+ ok: true,
76275
+ name: null
76276
+ };
76277
+ if (typeof raw !== "string") return {
76278
+ ok: false,
76279
+ error: "The Alexa name must be text."
76280
+ };
76281
+ const name = raw.trim().replace(/\s+/g, " ");
76282
+ if (name.length === 0) return {
76283
+ ok: true,
76284
+ name: null
76285
+ };
76286
+ if (name.length > 256) return {
76287
+ ok: false,
76288
+ error: `The Alexa name is ${name.length} characters; Amazon allows at most 256.`
76289
+ };
76290
+ if (!ALLOWED_PATTERN.test(name)) return {
76291
+ ok: false,
76292
+ error: "The Alexa name may contain only letters, digits and spaces (Amazon rejects punctuation and special characters)."
76293
+ };
76294
+ return {
76295
+ ok: true,
76296
+ name
76297
+ };
76298
+ }
76299
+ var StoredNameSchema = string();
76300
+ /**
76301
+ * The saved Alexa name in a per-device store blob, or null (never saved, or a
76302
+ * stored value that no longer validates: the device name is used rather than
76303
+ * pushing a name Amazon would refuse).
76304
+ */
76305
+ function readAlexaName(store) {
76306
+ const parsed = StoredNameSchema.safeParse(store[ALEXA_NAME_KEY]);
76307
+ if (!parsed.success) return null;
76308
+ const check = validateAlexaName(parsed.data);
76309
+ return check.ok ? check.name : null;
76310
+ }
76311
+ //#endregion
75243
76312
  //#region src/handlers/security-panel-arm-state.ts
75244
76313
  /**
75245
76314
  * The panel modes behind each Alexa arm state, PRIMARY first — the mirror of
@@ -75913,6 +76982,7 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
75913
76982
  deviceId: parentInfo.id,
75914
76983
  stableId: parentInfo.stableId,
75915
76984
  name: parentInfo.name,
76985
+ alexaName: deps.alexaName === void 0 ? null : await deps.alexaName(parentInfo.id),
75916
76986
  type: String(device.type),
75917
76987
  capNames: parentInfo.capNames,
75918
76988
  role: parentInfo.role,
@@ -75971,11 +77041,12 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
75971
77041
  return [];
75972
77042
  });
75973
77043
  const objectDetectionClasses = deps.objectDetection === void 0 ? [] : await deps.objectDetection(parentInfo.id);
77044
+ const cameraName = (deps.alexaName === void 0 ? null : await deps.alexaName(parentInfo.id)) ?? parentInfo.name;
75974
77045
  const out = [];
75975
77046
  out.push(buildParentCameraEndpoint({
75976
77047
  deviceId: parentInfo.id,
75977
77048
  stableId: parentInfo.stableId,
75978
- friendlyName: parentInfo.name,
77049
+ friendlyName: cameraName,
75979
77050
  isDoorbell,
75980
77051
  hasIntercom,
75981
77052
  hasMotion,
@@ -75989,7 +77060,7 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
75989
77060
  for (const detectionClass of classes) out.push(buildDetectionSensorEndpoint({
75990
77061
  deviceId: parentInfo.id,
75991
77062
  stableId: parentInfo.stableId,
75992
- cameraName: parentInfo.name,
77063
+ cameraName,
75993
77064
  detectionClass
75994
77065
  }));
75995
77066
  }
@@ -76483,6 +77554,36 @@ function validateInstanced(c, push) {
76483
77554
  if (c.semantics.stateMappings !== void 0) validateStateMappings(c, c.semantics.stateMappings, push, validMode);
76484
77555
  }
76485
77556
  //#endregion
77557
+ //#region src/endpoints/validate-thermostat.ts
77558
+ var PROPERTIES = new Set([
77559
+ "targetSetpoint",
77560
+ "lowerSetpoint",
77561
+ "upperSetpoint",
77562
+ "thermostatMode"
77563
+ ]);
77564
+ var MODES = new Set([
77565
+ "AUTO",
77566
+ "COOL",
77567
+ "HEAT",
77568
+ "ECO",
77569
+ "OFF"
77570
+ ]);
77571
+ function validateThermostat(c, push) {
77572
+ const supported = new Set((c.properties?.supported ?? []).map((p) => p.name));
77573
+ for (const name of supported) if (!PROPERTIES.has(name)) push(`properties.supported ${name} is not a thermostat property`);
77574
+ if (!supported.has("thermostatMode")) push("properties.supported must include thermostatMode");
77575
+ if (supported.has("lowerSetpoint") !== supported.has("upperSetpoint")) push("lowerSetpoint and upperSetpoint are declared together");
77576
+ if (!supported.has("targetSetpoint") && !supported.has("lowerSetpoint")) push("at least one setpoint property is required");
77577
+ const modes = c.configuration?.["supportedModes"];
77578
+ if (!Array.isArray(modes) || modes.length === 0) push("configuration.supportedModes must list at least one mode");
77579
+ else {
77580
+ for (const mode of modes) if (typeof mode !== "string" || !MODES.has(mode)) push(`supportedModes ${String(mode)} is not AUTO, COOL, HEAT, ECO or OFF`);
77581
+ if (new Set(modes).size !== modes.length) push("supportedModes values must be unique");
77582
+ }
77583
+ const scheduling = c.configuration?.["supportsScheduling"];
77584
+ if (scheduling !== void 0 && typeof scheduling !== "boolean") push("configuration.supportsScheduling must be a boolean");
77585
+ }
77586
+ //#endregion
76486
77587
  //#region src/endpoints/validate-endpoint.ts
76487
77588
  /**
76488
77589
  * Interfaces this skill may declare, with the version Amazon documents. An
@@ -76506,6 +77607,7 @@ var ALLOWED_INTERFACE_VERSIONS = {
76506
77607
  "Alexa.RangeController": "3",
76507
77608
  "Alexa.SecurityPanelController": "3",
76508
77609
  "Alexa.LockController": "3",
77610
+ "Alexa.ThermostatController": "3.1",
76509
77611
  "Alexa.SmartVision.SnapshotProvider": "1.1",
76510
77612
  "Alexa.SmartVision.ObjectDetectionSensor": "1.0",
76511
77613
  "Alexa.DataController": "1.0"
@@ -76545,6 +77647,8 @@ var DISPLAY_CATEGORIES = new Set([
76545
77647
  "SMARTLOCK",
76546
77648
  "INTERIOR_BLIND",
76547
77649
  "EXTERIOR_BLIND",
77650
+ "THERMOSTAT",
77651
+ "AIR_CONDITIONER",
76548
77652
  "DOOR",
76549
77653
  "GATE",
76550
77654
  "GARAGE_DOOR",
@@ -76623,6 +77727,7 @@ function validateCapability(endpointId, c, out, declared) {
76623
77727
  }
76624
77728
  }
76625
77729
  validateInstanced(c, push);
77730
+ if (c.interface === "Alexa.ThermostatController") validateThermostat(c, push);
76626
77731
  if (c.interface === "Alexa.SmartVision.ObjectDetectionSensor") validateObjectDetection(c, push);
76627
77732
  if (c.interface === "Alexa.DataController") validateDataController(c, declared, push);
76628
77733
  if (c.interface === "Alexa.SmartVision.SnapshotProvider") {
@@ -78553,20 +79658,27 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
78553
79658
  status: 200,
78554
79659
  body: await handleSessionDisconnected(header, endpoint, payload, deps.rtcSession, callerIdentity)
78555
79660
  };
78556
- const adapterDirective = genericDirective(key);
78557
- if (adapterDirective !== void 0 && endpoint !== void 0) {
79661
+ if (genericDirectives(key).length > 0 && endpoint !== void 0) {
78558
79662
  const target = parseCameraEndpointId(endpoint.endpointId);
78559
- if (target.kind === "generic") return {
78560
- status: 200,
78561
- body: await adapterDirective.handle({
79663
+ if (target.kind === "generic") {
79664
+ const directiveContext = {
78562
79665
  header,
78563
79666
  endpoint,
78564
79667
  payload,
78565
79668
  deviceId: target.deviceId,
78566
79669
  deps,
78567
79670
  identity: callerIdentity
78568
- })
78569
- };
79671
+ };
79672
+ const adapterDirective = await resolveGenericDirective(directiveContext);
79673
+ if (adapterDirective === null) return {
79674
+ status: 200,
79675
+ body: errorEnvelope(header, "INVALID_DIRECTIVE", `${key}: no handler for this endpoint`)
79676
+ };
79677
+ return {
79678
+ status: 200,
79679
+ body: await adapterDirective.handle(directiveContext)
79680
+ };
79681
+ }
78570
79682
  }
78571
79683
  if (key === "Alexa.ModeController/SetMode") return {
78572
79684
  status: 200,
@@ -82709,6 +83821,7 @@ var SUPPORTED_KINDS = [
82709
83821
  DeviceType.Lock,
82710
83822
  DeviceType.Presence,
82711
83823
  DeviceType.Thermostat,
83824
+ DeviceType.Climate,
82712
83825
  DeviceType.Cover,
82713
83826
  DeviceType.Generic
82714
83827
  ];
@@ -82847,6 +83960,10 @@ var ExportAlexaAddon = class extends BaseAddon {
82847
83960
  ctx: this.ctx,
82848
83961
  logger: this.ctx.logger
82849
83962
  },
83963
+ climateController: {
83964
+ ctx: this.ctx,
83965
+ logger: this.ctx.logger
83966
+ },
82850
83967
  coverController: {
82851
83968
  ctx: this.ctx,
82852
83969
  logger: this.ctx.logger,
@@ -83103,6 +84220,7 @@ var ExportAlexaAddon = class extends BaseAddon {
83103
84220
  logger: this.ctx.logger,
83104
84221
  snapshots: this.config.snapshots,
83105
84222
  ...this.config.detectionSensors ? { detectionClasses: async (id) => effectiveSensorClasses(await classesOf(id), parseSensorSelection(await vision.readPrefs(id))) } : {},
84223
+ alexaName: async (id) => readAlexaName(await vision.readPrefs(id)),
83106
84224
  ...this.config.objectDetection ? { objectDetection: async (id) => vision.buildClasses(id, await classesOf(id)) } : {}
83107
84225
  });
83108
84226
  }
@@ -83670,12 +84788,28 @@ var ExportAlexaAddon = class extends BaseAddon {
83670
84788
  value: enabled,
83671
84789
  immediate: true
83672
84790
  },
84791
+ await this.alexaNameField(deviceId, enabledKey),
83673
84792
  ...kind === "camera" ? this.cameraOnlyExportFields(deviceId, settings, enabledKey) : [],
83674
84793
  ...kind === "camera" ? await this.cameraDetectionFields(deviceId, enabledKey) : []
83675
84794
  ]
83676
84795
  }]
83677
84796
  };
83678
84797
  }
84798
+ /** D875: the "Alexa name" field, with the validation error of the saved value. */
84799
+ async alexaNameField(deviceId, enabledKey) {
84800
+ const saved = readAlexaName(await this.vision.readPrefs(deviceId));
84801
+ return {
84802
+ type: "text",
84803
+ key: alexaNameKey(deviceId),
84804
+ label: "Alexa name",
84805
+ description: `What Alexa calls this device. Leave empty to use the device name. Letters, digits and spaces only (no punctuation), up to 256 characters. Renaming updates the same Alexa device; it does not create a duplicate.`,
84806
+ value: saved ?? "",
84807
+ showWhen: {
84808
+ field: enabledKey,
84809
+ equals: true
84810
+ }
84811
+ };
84812
+ }
83679
84813
  /**
83680
84814
  * The camera's detection fields: which classes become Alexa sensors (D778;
83681
84815
  * only the classes its pipeline produces are offered) and its object
@@ -83697,6 +84831,24 @@ var ExportAlexaAddon = class extends BaseAddon {
83697
84831
  })];
83698
84832
  }
83699
84833
  /**
84834
+ * D875: save the Alexa name from a device-settings patch. A name Amazon would
84835
+ * refuse throws BEFORE anything is written, so the form shows the reason and
84836
+ * the old name stays. True when the saved name changed.
84837
+ */
84838
+ async applyAlexaNamePatch(deviceId, patch) {
84839
+ const key = alexaNameKey(deviceId);
84840
+ if (!(key in patch)) return false;
84841
+ const check = validateAlexaName(patch[key]);
84842
+ if (!check.ok) throw new Error(`export-alexa: ${check.error}`);
84843
+ if (readAlexaName(await this.vision.readPrefs(deviceId)) === check.name) return false;
84844
+ await this.vision.writePrefs(deviceId, { [ALEXA_NAME_KEY]: check.name ?? void 0 });
84845
+ this.ctx.logger.info("export-alexa: Alexa name saved", {
84846
+ tags: { deviceId },
84847
+ meta: { name: check.name }
84848
+ });
84849
+ return true;
84850
+ }
84851
+ /**
83700
84852
  * Persist the camera's detection preferences from a device-settings patch
83701
84853
  * into this addon's per-device store. True when anything changed — the
83702
84854
  * camera's Alexa shape then needs a reconcile.
@@ -83761,6 +84913,7 @@ var ExportAlexaAddon = class extends BaseAddon {
83761
84913
  const current = this.findEntry(deviceId);
83762
84914
  const wasEnabled = current !== null;
83763
84915
  const prefsChanged = await this.applyDetectionPrefsPatch(deviceId, patch);
84916
+ const nameChanged = await this.applyAlexaNamePatch(deviceId, patch);
83764
84917
  const enabledKey = `alexa:${deviceId}:enabled`;
83765
84918
  const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
83766
84919
  const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
@@ -83786,6 +84939,12 @@ var ExportAlexaAddon = class extends BaseAddon {
83786
84939
  const currentAllow = current?.settings?.allowTranscode !== false;
83787
84940
  await this.patchEntrySettings(deviceId, nextSettings);
83788
84941
  const sourceChanged = currentPref !== streamPreference || currentAllow !== allowTranscode;
84942
+ if (nameChanged && !sourceChanged) this.requireSyncEngine().pushDevice(deviceId).catch((err) => {
84943
+ this.ctx.logger.warn("export-alexa: push after an Alexa name change failed", {
84944
+ tags: { deviceId },
84945
+ meta: { error: errMsg(err) }
84946
+ });
84947
+ });
83789
84948
  if (prefsChanged && !sourceChanged) this.requireSyncEngine().reconcileDevice(deviceId).catch((err) => {
83790
84949
  this.ctx.logger.warn("export-alexa: re-sync after a detection preference change failed", {
83791
84950
  tags: { deviceId },