@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.
@@ -5393,7 +5393,7 @@ var ZodIssueCode = {
5393
5393
  var ZodFirstPartyTypeKind;
5394
5394
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5395
5395
  //#endregion
5396
- //#region ../types/dist/sleep-DRqxtc0C.mjs
5396
+ //#region ../types/dist/sleep-bwsTj221.mjs
5397
5397
  /**
5398
5398
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5399
5399
  * window to float samples (D455).
@@ -6644,7 +6644,6 @@ function createHandle(raw, opts) {
6644
6644
  const writeAddon = (patch) => write("addon", opts.addonTree(), void 0, patch, () => readStoredAddonBlob(raw), (p) => raw.writeAddonStore(p));
6645
6645
  const readAddonStoreResult = raw.readAddonStoreResult;
6646
6646
  const readAddonStoreKeyResult = raw.readAddonStoreKeyResult;
6647
- const readDeviceStoreBatch = raw.readDeviceStoreBatch;
6648
6647
  const overrides = {
6649
6648
  readAddonStore: async () => resolveRead("addon", await raw.readAddonStore()),
6650
6649
  ...readAddonStoreResult !== void 0 ? { readAddonStoreResult: async () => {
@@ -6668,12 +6667,12 @@ function createHandle(raw, opts) {
6668
6667
  };
6669
6668
  } } : {},
6670
6669
  readDeviceStore: async (deviceId) => resolveRead(`device:${deviceId}`, await raw.readDeviceStore(deviceId)),
6671
- ...readDeviceStoreBatch !== void 0 ? { readDeviceStoreBatch: async (deviceIds) => {
6672
- const rows = await readDeviceStoreBatch.call(raw, deviceIds);
6670
+ readDeviceStoreBatch: async (deviceIds) => {
6671
+ const rows = await raw.readDeviceStoreBatch(deviceIds);
6673
6672
  const out = /* @__PURE__ */ new Map();
6674
6673
  for (const [deviceId, blob] of rows) out.set(deviceId, await resolveRead(`device:${deviceId}`, blob));
6675
6674
  return out;
6676
- } } : {},
6675
+ },
6677
6676
  writeAddonStore: async (patch) => {
6678
6677
  await writeAddon(patch);
6679
6678
  },
@@ -7500,7 +7499,7 @@ var BaseAddon = class {
7500
7499
  return this._addonSecretTree;
7501
7500
  }
7502
7501
  deviceSecretTree() {
7503
- this._deviceSecretTree ??= secretTreeFromSchema(this.deviceSettingsSchema());
7502
+ this._deviceSecretTree ??= mergeSecretStatePaths(secretTreeFromSchema(this.deviceSettingsSchema()), this.secretStatePaths);
7504
7503
  return this._deviceSecretTree;
7505
7504
  }
7506
7505
  /**
@@ -7576,42 +7575,135 @@ var BaseAddon = class {
7576
7575
  this[timerOf] = timer;
7577
7576
  }
7578
7577
  /**
7579
- * Wrap `ctx.settings.readAddonStore()` with a short retry budget so a
7580
- * transient settings-store outage (mid-restart of sqlite-settings, tsx-watch
7581
- * swap, or any race where the SqliteSettingsBackend is between shutdown and
7582
- * re-initialize) doesn't propagate up into `initialize()` and leave the
7578
+ * Wrap `ctx.settings.readAddonStore()` with a retry budget so a transient
7579
+ * settings-store outage doesn't propagate up into `initialize()` and leave the
7583
7580
  * addon permanently broken.
7584
7581
  *
7585
- * Retry only the two known infra fingerprints. Anything else propagates so
7586
- * real bugs surface immediately. After the budget expires we fall back to
7587
- * `{}` (defaults) — the addon's first successful patch will rehydrate.
7582
+ * Two kinds of outage, two budgets:
7583
+ * - the store's own restart fingerprints (`SqliteSettingsBackend not
7584
+ * initialized`, `provider not available`): a short budget, then the addon
7585
+ * falls back to `{}` (defaults) - its first successful patch rehydrates;
7586
+ * - a TRANSPORT failure (the hub is restarting while this runner boots:
7587
+ * `discovery deadline exceeded`, `parent could not route`, a refused or
7588
+ * timed-out connection). Since D870 a runner's settings read THROWS instead
7589
+ * of answering `{}`, so without a budget a hub restart ends in a respawn
7590
+ * loop. A long, bounded budget; only after it the error propagates - the
7591
+ * addon never runs on defaults for a store it could not ask.
7592
+ * Anything else propagates immediately so real bugs surface.
7588
7593
  */
7589
7594
  async readAddonStoreWithRetry() {
7590
7595
  const settings = this.rawSettings();
7591
7596
  if (!settings) return {};
7592
- const delaysMs = [
7593
- 150,
7594
- 350,
7595
- 600,
7596
- 900
7597
- ];
7598
7597
  let lastErr;
7599
- for (let attempt = 0; attempt <= delaysMs.length; attempt++) try {
7598
+ for (let attempt = 0;; attempt++) try {
7600
7599
  const stored = await settings.readAddonStore() ?? {};
7601
7600
  this.settingsStoreReady = true;
7602
7601
  return stored;
7603
7602
  } catch (err) {
7604
7603
  lastErr = err;
7605
- const msg = err instanceof Error ? err.message : String(err);
7606
- if (!(msg.includes("SqliteSettingsBackend not initialized") || msg.includes("provider not available"))) throw err;
7607
- if (attempt === delaysMs.length) break;
7608
- await new Promise((r) => setTimeout(r, delaysMs[attempt]));
7604
+ const kind = classifyStoreError(err);
7605
+ if (kind === "permanent") throw err;
7606
+ const delay = (kind === "transport" ? TRANSPORT_RETRY_DELAYS_MS : RESTART_RETRY_DELAYS_MS)[attempt];
7607
+ if (delay === void 0) {
7608
+ if (kind === "transport") throw err;
7609
+ break;
7610
+ }
7611
+ await new Promise((r) => setTimeout(r, delay));
7609
7612
  }
7610
7613
  this.settingsStoreReady = false;
7611
7614
  this._ctx?.logger?.warn?.("readAddonStore: settings-store unavailable after retries — using defaults", { meta: { error: lastErr instanceof Error ? lastErr.message : String(lastErr) } });
7612
7615
  return {};
7613
7616
  }
7614
7617
  };
7618
+ /** The settings store's own restart fingerprints. */
7619
+ var RESTART_RETRY_DELAYS_MS = [
7620
+ 150,
7621
+ 350,
7622
+ 600,
7623
+ 900
7624
+ ];
7625
+ /** A hub restarting under a booting runner: ~100 s in total. */
7626
+ var TRANSPORT_RETRY_DELAYS_MS = [
7627
+ 150,
7628
+ 350,
7629
+ 600,
7630
+ 900,
7631
+ 2e3,
7632
+ 5e3,
7633
+ 1e4,
7634
+ 2e4,
7635
+ 3e4,
7636
+ 3e4
7637
+ ];
7638
+ var RESTART_FINGERPRINTS = ["SqliteSettingsBackend not initialized", "provider not available"];
7639
+ /** Node / socket error codes of a connection that did not happen or broke. */
7640
+ var TRANSPORT_CODES = new Set([
7641
+ "ECONNREFUSED",
7642
+ "ECONNRESET",
7643
+ "ETIMEDOUT",
7644
+ "EPIPE",
7645
+ "EHOSTUNREACH",
7646
+ "ENETUNREACH"
7647
+ ]);
7648
+ /** tRPC error codes that mean "the other side is not answering now", not "your call is wrong". */
7649
+ var UNAVAILABLE_TRPC_CODES = new Set([
7650
+ "TIMEOUT",
7651
+ "SERVICE_UNAVAILABLE",
7652
+ "BAD_GATEWAY",
7653
+ "GATEWAY_TIMEOUT"
7654
+ ]);
7655
+ /** The exact phrases OUR transport raises when it cannot reach the hub. */
7656
+ var TRANSPORT_PHRASES = [
7657
+ "discovery deadline exceeded",
7658
+ "parent could not route child unowned cap call",
7659
+ "UDS request timed out after",
7660
+ "channel closed"
7661
+ ];
7662
+ /** Moleculer error `type`s of a call that could not be served right now. */
7663
+ var MOLECULER_TRANSIENT_TYPES = new Set([
7664
+ "REQUEST_TIMEOUT",
7665
+ "SERVICE_NOT_FOUND",
7666
+ "SERVICE_NOT_AVAILABLE",
7667
+ "REQUEST_REJECTED",
7668
+ "BROKER_DISCONNECTED"
7669
+ ]);
7670
+ function stringProp(value, key) {
7671
+ if (typeof value !== "object" || value === null) return void 0;
7672
+ const found = Reflect.get(value, key);
7673
+ return typeof found === "string" ? found : void 0;
7674
+ }
7675
+ /** The code an error carries: its own, its cause's, or a tRPC `data.code`. */
7676
+ function errorCode(err) {
7677
+ const out = {};
7678
+ const cause = typeof err === "object" && err !== null ? Reflect.get(err, "cause") : void 0;
7679
+ const data = typeof err === "object" && err !== null ? Reflect.get(err, "data") : void 0;
7680
+ const node = stringProp(err, "code") ?? stringProp(cause, "code");
7681
+ if (node !== void 0) out.node = node;
7682
+ const trpc = stringProp(data, "code");
7683
+ if (trpc !== void 0) out.trpc = trpc;
7684
+ return out;
7685
+ }
7686
+ /**
7687
+ * Is a settings read failure worth retrying, and with which budget? Only
7688
+ * EXPLICIT signals count: a socket error code, a tRPC "unavailable" code, or
7689
+ * one of the exact phrases our own transport raises. A message that merely
7690
+ * contains "timed out" or "unreachable" is not evidence of anything, and
7691
+ * `settings store not wired` is a wiring bug that retrying cannot fix.
7692
+ */
7693
+ function classifyStoreError(err) {
7694
+ const message = err instanceof Error ? err.message : String(err);
7695
+ if (RESTART_FINGERPRINTS.some((f) => message.includes(f))) return "restart";
7696
+ const code = errorCode(err);
7697
+ if (code.node !== void 0 && TRANSPORT_CODES.has(code.node)) return "transport";
7698
+ if (code.trpc !== void 0 && UNAVAILABLE_TRPC_CODES.has(code.trpc)) return "transport";
7699
+ if (typeof err === "object" && err !== null) {
7700
+ if (Reflect.get(err, "retryable") === true) return "transport";
7701
+ const type = stringProp(err, "type");
7702
+ if (type !== void 0 && MOLECULER_TRANSIENT_TYPES.has(type)) return "transport";
7703
+ }
7704
+ if (TRANSPORT_PHRASES.some((f) => message.includes(f))) return "transport";
7705
+ return "permanent";
7706
+ }
7615
7707
  /**
7616
7708
  * Collect the keys of every field marked `perNode: true`, recursing into
7617
7709
  * layout containers (`group` fields and `sub-tabs` tabs) the same way
@@ -8188,7 +8280,7 @@ object({
8188
8280
  params: record(string(), union([string(), number()])).optional()
8189
8281
  });
8190
8282
  //#endregion
8191
- //#region ../types/dist/err-msg-DLerKiVO.mjs
8283
+ //#region ../types/dist/err-msg--2RrYiLO.mjs
8192
8284
  var DeviceType = /* @__PURE__ */ function(DeviceType) {
8193
8285
  DeviceType["Camera"] = "camera";
8194
8286
  DeviceType["Hub"] = "hub";
@@ -8457,6 +8549,9 @@ var DeviceRole = /* @__PURE__ */ function(DeviceRole) {
8457
8549
  /** Virtual HA toggle (input_boolean.*) — distinguishable from a
8458
8550
  * real Switch device for UI rendering / export adapters. */
8459
8551
  DeviceRole["BinaryHelper"] = "binary-helper";
8552
+ /** A smart plug: a `switch` whose source says it is an outlet (HA
8553
+ * `device_class: outlet`). The Alexa export shows it as a SMARTPLUG. */
8554
+ DeviceRole["Outlet"] = "outlet";
8460
8555
  /** Generic motion / occupancy / moving event source. Distinct from
8461
8556
  * the camera accessory PirSensor role: that one is a camera child;
8462
8557
  * this is a standalone HA / 3rd-party motion sensor. */
@@ -8534,7 +8629,7 @@ function errMsg(err) {
8534
8629
  return String(err);
8535
8630
  }
8536
8631
  //#endregion
8537
- //#region ../types/dist/composition-EQ9Ok0Ee.mjs
8632
+ //#region ../types/dist/composition-DOwQB_Ua.mjs
8538
8633
  /**
8539
8634
  * The ffmpeg build every CamStack node downloads, per platform and arch.
8540
8635
  *
@@ -11995,11 +12090,10 @@ var ClipModelMetaSchema = object({
11995
12090
  /**
11996
12091
  * The `vector-store` index this model's vectors live in. ONE feature space
11997
12092
  * per index: two models share an index only when their vectors are ranked
11998
- * against each other, which for CLIP means never across families. MobileCLIP
11999
- * S1 and S2 share `object-clip` for history (separated by the row's
12000
- * `modelId`); any other model gets its own.
12093
+ * against each other, which for CLIP means never across families. Every
12094
+ * model gets its own.
12001
12095
  */
12002
- vectorIndex: string().regex(/^object-clip(-[a-z0-9][a-z0-9-]*)?$/),
12096
+ vectorIndex: string().regex(/^object-clip-[a-z0-9][a-z0-9-]*$/),
12003
12097
  /**
12004
12098
  * The text encoder's tokenizer, a declared SIBLING file of its onnx. The name
12005
12099
  * must be unique per tokenizer: every model's siblings land in ONE flat
@@ -16140,6 +16234,28 @@ id: union([string(), number()]) }), {
16140
16234
  kind: "mutation",
16141
16235
  auth: "admin"
16142
16236
  });
16237
+ /** One table of the settings database. */
16238
+ var StorageTableSchema = object({
16239
+ /** The table's full name, e.g. `recorder:addon-settings` or `addon-device-settings`. */
16240
+ name: string(),
16241
+ /** The owner prefix of a scoped name (`recorder` for `recorder:addon-settings`), else null. */
16242
+ owner: string().nullable(),
16243
+ rows: number().int().nonnegative(),
16244
+ /** Bytes the table's pages occupy (`dbstat`); null when the engine cannot measure. */
16245
+ bytes: number().int().nonnegative().nullable(),
16246
+ /** Declared by an addon since this engine process started (a running addon claims it). */
16247
+ declaredThisBoot: boolean()
16248
+ });
16249
+ method(object({ ownerPrefix: string().min(1).optional() }), object({ tables: array(StorageTableSchema) }), { auth: "admin" }), method(object({
16250
+ collection: string().min(1),
16251
+ expectRows: number().int().nonnegative()
16252
+ }), object({
16253
+ dropped: literal(true),
16254
+ rows: number().int().nonnegative()
16255
+ }), {
16256
+ kind: "mutation",
16257
+ auth: "admin"
16258
+ });
16143
16259
  /**
16144
16260
  * `keychain` — the hub's encrypted store of secret values (D787).
16145
16261
  *
@@ -16681,6 +16797,16 @@ method(object({}), KeychainStatusSchema, { auth: "admin" }), method(object({}),
16681
16797
  kind: "mutation",
16682
16798
  auth: "admin",
16683
16799
  caller: "required"
16800
+ }), method(object({
16801
+ ref: string().min(1),
16802
+ value: string().min(1)
16803
+ }), object({
16804
+ ref: string(),
16805
+ version: number().int()
16806
+ }), {
16807
+ kind: "mutation",
16808
+ auth: "admin",
16809
+ caller: "required"
16684
16810
  }), method(object({ refs: array(string().min(1)).max(1e3) }), object({
16685
16811
  deleted: array(string()).readonly(),
16686
16812
  kept: array(object({
@@ -53138,7 +53264,7 @@ var HvacModeSchema = _enum([
53138
53264
  "fan_only",
53139
53265
  "dry"
53140
53266
  ]);
53141
- object({
53267
+ var ClimateControlStatusSchema = object({
53142
53268
  /** Active HVAC mode. */
53143
53269
  mode: HvacModeSchema,
53144
53270
  /** Available HVAC modes the device accepts. Subset of HvacMode. */
@@ -53179,6 +53305,12 @@ object({
53179
53305
  /** Horizontal louver swing on/off. Null when the device has no
53180
53306
  * horizontal swing surface (gate with `DeviceFeature.ClimateSwingHorizontal`). */
53181
53307
  swingHorizontal: boolean().nullable(),
53308
+ /** Lowest setpoint the device accepts, Celsius. Absent / null = not reported. */
53309
+ minTemp: number().nullable().optional(),
53310
+ /** Highest setpoint the device accepts, Celsius. Absent / null = not reported. */
53311
+ maxTemp: number().nullable().optional(),
53312
+ /** Setpoint granularity, Celsius (0.5, 1). Absent / null = not reported. */
53313
+ tempStep: number().positive().nullable().optional(),
53182
53314
  /** Ms epoch when the slice was last updated (push or command). */
53183
53315
  lastFetchedAt: number()
53184
53316
  });
@@ -55019,7 +55151,19 @@ var TlsStatusSchema = object({
55019
55151
  sans: array(string()),
55020
55152
  caCertPem: string().nullable(),
55021
55153
  reissueError: string().nullable(),
55022
- restartRequired: boolean()
55154
+ restartRequired: boolean(),
55155
+ /** An uploaded certificate is served but no CA is kept for it and it is not publicly signed (D873). */
55156
+ caMissing: boolean(),
55157
+ /** Does the hub's own generated CA carry a critical name constraint? `null` while it has none. */
55158
+ localCaConstrained: boolean().nullable(),
55159
+ /** ISO time the generated CA was replaced by a constrained one; devices must re-download it. */
55160
+ caReplacedAt: string().nullable(),
55161
+ /** The uploaded certificate kept on disk, selectable again after "Revert to generated". */
55162
+ storedUpload: object({
55163
+ leafFingerprintSha256: string(),
55164
+ validTo: string(),
55165
+ hasCa: boolean()
55166
+ }).nullable()
55023
55167
  });
55024
55168
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
55025
55169
  /**
@@ -55075,6 +55219,9 @@ method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(obje
55075
55219
  }), method(_void(), object({ pem: string() })), method(_void(), TlsStatusSchema, {
55076
55220
  kind: "mutation",
55077
55221
  auth: "admin"
55222
+ }), method(_void(), TlsStatusSchema, {
55223
+ kind: "mutation",
55224
+ auth: "admin"
55078
55225
  });
55079
55226
  var LockControlStatusSchema = object({
55080
55227
  /** Lifecycle state of the lock. `jammed` means the motor reported
@@ -64114,6 +64261,12 @@ Object.freeze({
64114
64261
  addonId: null,
64115
64262
  access: "create"
64116
64263
  },
64264
+ "keychainAdmin.importSecret": {
64265
+ capName: "keychain-admin",
64266
+ capScope: "system",
64267
+ addonId: null,
64268
+ access: "create"
64269
+ },
64117
64270
  "keychainAdmin.listAudit": {
64118
64271
  capName: "keychain-admin",
64119
64272
  capScope: "system",
@@ -64510,6 +64663,12 @@ Object.freeze({
64510
64663
  addonId: null,
64511
64664
  access: "create"
64512
64665
  },
64666
+ "localNetwork.useStoredUploadedCertificate": {
64667
+ capName: "local-network",
64668
+ capScope: "system",
64669
+ addonId: null,
64670
+ access: "create"
64671
+ },
64513
64672
  "lockControl.getStatus": {
64514
64673
  capName: "lock-control",
64515
64674
  capScope: "device",
@@ -67894,6 +68053,18 @@ Object.freeze({
67894
68053
  addonId: null,
67895
68054
  access: "create"
67896
68055
  },
68056
+ "storageAdmin.dropCollection": {
68057
+ capName: "storage-admin",
68058
+ capScope: "system",
68059
+ addonId: null,
68060
+ access: "delete"
68061
+ },
68062
+ "storageAdmin.listTables": {
68063
+ capName: "storage-admin",
68064
+ capScope: "system",
68065
+ addonId: null,
68066
+ access: "view"
68067
+ },
67897
68068
  "storageEvictable.evict": {
67898
68069
  capName: "storage-evictable",
67899
68070
  capScope: "system",
@@ -73998,6 +74169,778 @@ async function revokeBrokerCredential(fetchImpl, brokerUrl, credential) {
73998
74169
  await call(fetchImpl, brokerUrl, "/broker/revoke", { bearer: credential }, unknown());
73999
74170
  }
74000
74171
  //#endregion
74172
+ //#region src/endpoints/adapters/climate-model.ts
74173
+ /**
74174
+ * What a `climate-control` device is to Alexa (D874) — pure, so the discovery
74175
+ * shape, the slice mapping and the directive handlers share one definition.
74176
+ *
74177
+ * `Alexa.ThermostatController` 3.1 targetSetpoint, lowerSetpoint /
74178
+ * upperSetpoint (a device with a range),
74179
+ * thermostatMode HEAT / COOL / AUTO / ECO /
74180
+ * OFF
74181
+ * `Alexa.TemperatureSensor` the current temperature
74182
+ * `Alexa.PowerController` when the device can be turned off
74183
+ * `Alexa.ModeController` Climate.Mode only when the device has a mode
74184
+ * Amazon's thermostat vocabulary lacks
74185
+ * (dry, fan_only)
74186
+ * `Alexa.ModeController` Climate.FanSpeed the device's own fan modes
74187
+ *
74188
+ * Amazon's `thermostatMode` has no value for `dry` / `fan_only`, and a CUSTOM
74189
+ * mode is not a documented way to carry them: they travel through the
74190
+ * `Climate.Mode` ModeController, and `thermostatMode` is simply not reported
74191
+ * while the device is in one of them.
74192
+ *
74193
+ * Temperatures are Celsius throughout (the cap's contract); Alexa converts for
74194
+ * the account.
74195
+ */
74196
+ var INSTANCE_MODE = "Climate.Mode";
74197
+ var INSTANCE_FAN = "Climate.FanSpeed";
74198
+ var FAN_PREFIX = "Fan.";
74199
+ var DEFAULT_TEMP_STEP = .5;
74200
+ var FEATURE_DUAL_SETPOINT = "climate-dual-setpoint";
74201
+ var ALEXA_ECO_PRESET = "eco";
74202
+ var SINGLE_SETPOINT_MODES = [
74203
+ "heat",
74204
+ "cool",
74205
+ "auto",
74206
+ "dry"
74207
+ ];
74208
+ /** What TurnOn switches to, best first. */
74209
+ var TURN_ON_PREFERENCE = [
74210
+ "auto",
74211
+ "heat_cool",
74212
+ "cool",
74213
+ "heat"
74214
+ ];
74215
+ var SliceShape$1 = ClimateControlStatusSchema.pick({
74216
+ mode: true,
74217
+ availableModes: true,
74218
+ fanMode: true,
74219
+ availableFanModes: true,
74220
+ preset: true,
74221
+ availablePresets: true,
74222
+ target: true,
74223
+ targetHigh: true,
74224
+ targetLow: true,
74225
+ currentTemp: true,
74226
+ minTemp: true,
74227
+ maxTemp: true,
74228
+ tempStep: true
74229
+ });
74230
+ /** The slice fields the adapter reads, or null when the slice is not a climate one. */
74231
+ function readClimateSlice(slice) {
74232
+ const parsed = SliceShape$1.safeParse(slice);
74233
+ return parsed.success ? parsed.data : null;
74234
+ }
74235
+ function boundsOf(slice) {
74236
+ const min = slice.minTemp ?? 7;
74237
+ const max = slice.maxTemp ?? 35;
74238
+ const step = slice.tempStep ?? .5;
74239
+ return min < max ? {
74240
+ min,
74241
+ max,
74242
+ step
74243
+ } : {
74244
+ min: 7,
74245
+ max: 35,
74246
+ step: DEFAULT_TEMP_STEP
74247
+ };
74248
+ }
74249
+ /** One decimal, the precision Alexa's own conversions keep. */
74250
+ function round1(value) {
74251
+ return Math.round(value * 10) / 10;
74252
+ }
74253
+ /**
74254
+ * Snap a setpoint to the device's step (anchored at its minimum), then clamp:
74255
+ * a maximum off the step grid (max 30.2, step 0.5) must not snap above itself.
74256
+ */
74257
+ function snapToStep(value, bounds) {
74258
+ const snapped = bounds.min + Math.round((value - bounds.min) / bounds.step) * bounds.step;
74259
+ return round1(Math.min(bounds.max, Math.max(bounds.min, snapped)));
74260
+ }
74261
+ function temperatureValue(celsius) {
74262
+ return {
74263
+ value: round1(celsius),
74264
+ scale: "CELSIUS"
74265
+ };
74266
+ }
74267
+ function hasMode(slice, mode) {
74268
+ return slice !== null && slice.availableModes.includes(mode);
74269
+ }
74270
+ /** Dual setpoints (a heat/cool range). */
74271
+ function supportsDual(slice, features) {
74272
+ return slice === null ? features.includes(FEATURE_DUAL_SETPOINT) : hasMode(slice, "heat_cool");
74273
+ }
74274
+ /** A single setpoint (everything but a range-only device). */
74275
+ function supportsSingle(slice) {
74276
+ return slice === null || slice.availableModes.length === 0 ? true : slice.availableModes.some((m) => SINGLE_SETPOINT_MODES.includes(m));
74277
+ }
74278
+ /** The device's own name for its `eco` preset, if it has one. */
74279
+ function ecoPresetOf(slice) {
74280
+ return slice?.availablePresets.find((p) => p.toLowerCase() === ALEXA_ECO_PRESET) ?? null;
74281
+ }
74282
+ /** Amazon thermostat modes the device can be put in, in a stable order. */
74283
+ function supportedThermostatModes(slice) {
74284
+ if (slice === null || slice.availableModes.length === 0) return [
74285
+ "HEAT",
74286
+ "COOL",
74287
+ "AUTO",
74288
+ "OFF"
74289
+ ];
74290
+ const out = [];
74291
+ if (hasMode(slice, "heat")) out.push("HEAT");
74292
+ if (hasMode(slice, "cool")) out.push("COOL");
74293
+ if (hasMode(slice, "auto") || hasMode(slice, "heat_cool")) out.push("AUTO");
74294
+ if (ecoPresetOf(slice) !== null && out.length > 0) out.push("ECO");
74295
+ if (hasMode(slice, "off")) out.push("OFF");
74296
+ return out;
74297
+ }
74298
+ /** The HVAC mode TurnOn / leaving OFF switches to. */
74299
+ function onModeOf(slice) {
74300
+ return TURN_ON_PREFERENCE.find((m) => hasMode(slice, m)) ?? null;
74301
+ }
74302
+ function canPowerOff(slice) {
74303
+ return slice === null || slice.availableModes.length === 0 || hasMode(slice, "off");
74304
+ }
74305
+ /** A device with a mode Amazon's thermostat vocabulary lacks. */
74306
+ function hasExtraModes(slice) {
74307
+ return hasMode(slice, "dry") || hasMode(slice, "fan_only");
74308
+ }
74309
+ var MODE_VALUE = {
74310
+ heat: "Mode.Heat",
74311
+ cool: "Mode.Cool",
74312
+ auto: "Mode.Auto",
74313
+ dry: "Mode.Dry",
74314
+ fan_only: "Mode.Fan",
74315
+ off: "Mode.Off"
74316
+ };
74317
+ var MODE_NAMES = {
74318
+ "Mode.Heat": ["Riscaldamento", "Heating"],
74319
+ "Mode.Cool": ["Raffrescamento", "Cooling"],
74320
+ "Mode.Auto": ["Automatico", "Auto"],
74321
+ "Mode.Dry": ["Deumidificazione", "Dry"],
74322
+ "Mode.Fan": ["Ventilazione", "Fan only"],
74323
+ "Mode.Off": ["Spento", "Off"]
74324
+ };
74325
+ /** The ModeController value a HVAC mode reports as (a range is "auto"). */
74326
+ function modeValueOf(mode) {
74327
+ return mode === "heat_cool" ? MODE_VALUE.auto : MODE_VALUE[mode];
74328
+ }
74329
+ /** The HVAC mode a ModeController value asks for, among those the device has. */
74330
+ function hvacModeForValue(value, slice) {
74331
+ if (value === MODE_VALUE.auto) return hasMode(slice, "auto") ? "auto" : hasMode(slice, "heat_cool") ? "heat_cool" : null;
74332
+ const mode = Object.entries(MODE_VALUE).find(([, v]) => v === value)?.[0];
74333
+ if (mode === void 0) return null;
74334
+ const candidate = SliceShape$1.shape.mode.safeParse(mode);
74335
+ return candidate.success && hasMode(slice, candidate.data) ? candidate.data : null;
74336
+ }
74337
+ /** The ModeController values the device offers, in a stable order. */
74338
+ function modeValuesOf(slice) {
74339
+ return [
74340
+ "heat",
74341
+ "cool",
74342
+ "auto",
74343
+ "dry",
74344
+ "fan_only",
74345
+ "off"
74346
+ ].filter((m) => m === "auto" ? hasMode(slice, "auto") || hasMode(slice, "heat_cool") : hasMode(slice, m)).map((m) => MODE_VALUE[m]);
74347
+ }
74348
+ function textNames(it, en) {
74349
+ return [{
74350
+ "@type": "text",
74351
+ value: {
74352
+ text: it,
74353
+ locale: "it-IT"
74354
+ }
74355
+ }, {
74356
+ "@type": "text",
74357
+ value: {
74358
+ text: en,
74359
+ locale: "en-US"
74360
+ }
74361
+ }];
74362
+ }
74363
+ function modeCapability(slice) {
74364
+ const values = modeValuesOf(slice);
74365
+ if (values.length < 2) return null;
74366
+ return {
74367
+ type: "AlexaInterface",
74368
+ interface: "Alexa.ModeController",
74369
+ version: "3",
74370
+ instance: INSTANCE_MODE,
74371
+ capabilityResources: { friendlyNames: [{
74372
+ "@type": "asset",
74373
+ value: { assetId: "Alexa.Setting.Mode" }
74374
+ }, ...textNames("Modalità", "Mode")] },
74375
+ properties: {
74376
+ supported: [{ name: "mode" }],
74377
+ proactivelyReported: true,
74378
+ retrievable: true
74379
+ },
74380
+ configuration: {
74381
+ ordered: false,
74382
+ supportedModes: values.map((value) => {
74383
+ const [it, en] = MODE_NAMES[value] ?? [value, value];
74384
+ return {
74385
+ value,
74386
+ modeResources: { friendlyNames: textNames(it, en) }
74387
+ };
74388
+ })
74389
+ }
74390
+ };
74391
+ }
74392
+ var FAN_ASSETS = {
74393
+ low: "Alexa.Value.Low",
74394
+ medium: "Alexa.Value.Medium",
74395
+ high: "Alexa.Value.High"
74396
+ };
74397
+ var FAN_TEXTS = {
74398
+ auto: ["Automatico", "Auto"],
74399
+ quiet: ["Silenzioso", "Quiet"],
74400
+ turbo: ["Turbo", "Turbo"],
74401
+ on: ["Acceso", "On"],
74402
+ off: ["Spento", "Off"],
74403
+ middle: ["Medio", "Medium"],
74404
+ mediumlow: ["Medio basso", "Medium low"],
74405
+ mediumhigh: ["Medio alto", "Medium high"],
74406
+ medium_low: ["Medio basso", "Medium low"],
74407
+ medium_high: ["Medio alto", "Medium high"]
74408
+ };
74409
+ function capitalize(word) {
74410
+ return word.charAt(0).toUpperCase() + word.slice(1);
74411
+ }
74412
+ /** The ModeController value of a vendor fan mode (`medium_high` -> `Fan.MediumHigh`). */
74413
+ function fanValueOf(fanMode) {
74414
+ const words = fanMode.split(/[^A-Za-z0-9]+/).filter((w) => w.length > 0);
74415
+ return words.length === 0 ? null : `${FAN_PREFIX}${words.map(capitalize).join("")}`;
74416
+ }
74417
+ /** The fan modes with a usable, unique value: `[value, deviceFanMode]`. */
74418
+ function fanModesOf(slice) {
74419
+ const seen = /* @__PURE__ */ new Set();
74420
+ const out = [];
74421
+ for (const fanMode of slice.availableFanModes) {
74422
+ const value = fanValueOf(fanMode);
74423
+ if (value === null || seen.has(value)) continue;
74424
+ seen.add(value);
74425
+ out.push([value, fanMode]);
74426
+ }
74427
+ return out;
74428
+ }
74429
+ function hasFanModes(slice) {
74430
+ return slice !== null && fanModesOf(slice).length >= 2;
74431
+ }
74432
+ function fanFriendlyNames(fanMode) {
74433
+ const key = fanMode.toLowerCase();
74434
+ const asset = FAN_ASSETS[key];
74435
+ if (asset !== void 0) return [{
74436
+ "@type": "asset",
74437
+ value: { assetId: asset }
74438
+ }];
74439
+ const [it, en] = FAN_TEXTS[key] ?? [fanMode.replace(/_/g, " "), fanMode.replace(/_/g, " ")];
74440
+ return textNames(it, en);
74441
+ }
74442
+ function fanCapability(slice) {
74443
+ const modes = fanModesOf(slice);
74444
+ if (modes.length < 2) return null;
74445
+ return {
74446
+ type: "AlexaInterface",
74447
+ interface: "Alexa.ModeController",
74448
+ version: "3",
74449
+ instance: INSTANCE_FAN,
74450
+ capabilityResources: { friendlyNames: [{
74451
+ "@type": "asset",
74452
+ value: { assetId: "Alexa.Setting.FanSpeed" }
74453
+ }, ...textNames("Velocità ventola", "Fan speed")] },
74454
+ properties: {
74455
+ supported: [{ name: "mode" }],
74456
+ proactivelyReported: true,
74457
+ retrievable: true
74458
+ },
74459
+ configuration: {
74460
+ ordered: false,
74461
+ supportedModes: modes.map(([value, fanMode]) => ({
74462
+ value,
74463
+ modeResources: { friendlyNames: fanFriendlyNames(fanMode) }
74464
+ }))
74465
+ }
74466
+ };
74467
+ }
74468
+ function propertiesBlock(names) {
74469
+ return {
74470
+ supported: names.map((name) => ({ name })),
74471
+ proactivelyReported: true,
74472
+ retrievable: true
74473
+ };
74474
+ }
74475
+ function thermostatCapability(slice, modes, features) {
74476
+ return {
74477
+ type: "AlexaInterface",
74478
+ interface: "Alexa.ThermostatController",
74479
+ version: "3.1",
74480
+ properties: propertiesBlock([
74481
+ ...supportsSingle(slice) ? ["targetSetpoint"] : [],
74482
+ ...supportsDual(slice, features) ? ["lowerSetpoint", "upperSetpoint"] : [],
74483
+ "thermostatMode"
74484
+ ]),
74485
+ configuration: {
74486
+ supportedModes: modes,
74487
+ supportsScheduling: false
74488
+ }
74489
+ };
74490
+ }
74491
+ /** A thermostat is a THERMOSTAT, any other climate device an air conditioner. */
74492
+ function climateCategoryFor(deviceType) {
74493
+ return deviceType === "thermostat" ? "THERMOSTAT" : "AIR_CONDITIONER";
74494
+ }
74495
+ function describeClimate(device) {
74496
+ const slice = readClimateSlice(device.slices["climate-control"]);
74497
+ const modes = supportedThermostatModes(slice);
74498
+ if (modes.length === 0) return {
74499
+ kind: "skip",
74500
+ reason: "climate: the device offers no mode Alexa has a thermostat value for"
74501
+ };
74502
+ const extras = [];
74503
+ if (slice !== null) {
74504
+ const mode = hasExtraModes(slice) ? modeCapability(slice) : null;
74505
+ const fan = hasFanModes(slice) ? fanCapability(slice) : null;
74506
+ if (mode !== null) extras.push(mode);
74507
+ if (fan !== null) extras.push(fan);
74508
+ }
74509
+ return {
74510
+ kind: "publish",
74511
+ capabilities: [
74512
+ thermostatCapability(slice, modes, device.features),
74513
+ {
74514
+ type: "AlexaInterface",
74515
+ interface: "Alexa.TemperatureSensor",
74516
+ version: "3",
74517
+ properties: propertiesBlock(["temperature"])
74518
+ },
74519
+ ...canPowerOff(slice) ? [{
74520
+ type: "AlexaInterface",
74521
+ interface: "Alexa.PowerController",
74522
+ version: "3",
74523
+ properties: propertiesBlock(["powerState"])
74524
+ }] : [],
74525
+ ...extras
74526
+ ],
74527
+ category: climateCategoryFor(device.type)
74528
+ };
74529
+ }
74530
+ /** Amazon's `thermostatMode` for the state, or null while in a mode it lacks. */
74531
+ function thermostatModeOf(slice) {
74532
+ if (slice.mode === "off") return "OFF";
74533
+ const eco = ecoPresetOf(slice);
74534
+ if (eco !== null && slice.preset === eco) return "ECO";
74535
+ switch (slice.mode) {
74536
+ case "heat": return "HEAT";
74537
+ case "cool": return "COOL";
74538
+ case "auto":
74539
+ case "heat_cool": return "AUTO";
74540
+ default: return null;
74541
+ }
74542
+ }
74543
+ function climateProperties(slice) {
74544
+ const s = readClimateSlice(slice);
74545
+ if (s === null) return [];
74546
+ const out = [];
74547
+ const thermostat = (name, value) => {
74548
+ out.push({
74549
+ namespace: "Alexa.ThermostatController",
74550
+ name,
74551
+ value
74552
+ });
74553
+ };
74554
+ if (hasMode(s, "heat_cool") && s.targetLow !== null && s.targetHigh !== null && s.mode === "heat_cool") {
74555
+ thermostat("lowerSetpoint", temperatureValue(s.targetLow ?? 0));
74556
+ thermostat("upperSetpoint", temperatureValue(s.targetHigh ?? 0));
74557
+ } else if (s.target !== null && supportsSingle(s)) thermostat("targetSetpoint", temperatureValue(s.target));
74558
+ const mode = thermostatModeOf(s);
74559
+ if (mode !== null) thermostat("thermostatMode", mode);
74560
+ if (s.currentTemp !== null) out.push({
74561
+ namespace: "Alexa.TemperatureSensor",
74562
+ name: "temperature",
74563
+ value: temperatureValue(s.currentTemp)
74564
+ });
74565
+ if (canPowerOff(s)) out.push({
74566
+ namespace: "Alexa.PowerController",
74567
+ name: "powerState",
74568
+ value: s.mode === "off" ? "OFF" : "ON"
74569
+ });
74570
+ if (hasExtraModes(s) && modeValuesOf(s).length >= 2) out.push({
74571
+ namespace: "Alexa.ModeController",
74572
+ instance: INSTANCE_MODE,
74573
+ name: "mode",
74574
+ value: modeValueOf(s.mode)
74575
+ });
74576
+ const fan = fanValueOf(s.fanMode);
74577
+ if (hasFanModes(s) && fan !== null && fanModesOf(s).some(([value]) => value === fan)) out.push({
74578
+ namespace: "Alexa.ModeController",
74579
+ instance: INSTANCE_FAN,
74580
+ name: "mode",
74581
+ value: fan
74582
+ });
74583
+ return out;
74584
+ }
74585
+ //#endregion
74586
+ //#region src/handlers/climate-controller.ts
74587
+ var CAP_CLIMATE = "climate-control";
74588
+ var CAP_SWITCH$1 = "switch";
74589
+ var UNCERTAINTY_MS = 500;
74590
+ var PRESET_NONE = "none";
74591
+ var ScaleSchema = _enum([
74592
+ "CELSIUS",
74593
+ "FAHRENHEIT",
74594
+ "KELVIN"
74595
+ ]);
74596
+ var TemperatureSchema = object({
74597
+ value: number(),
74598
+ scale: ScaleSchema
74599
+ });
74600
+ var SetTemperaturePayload = object({
74601
+ targetSetpoint: TemperatureSchema.optional(),
74602
+ lowerSetpoint: TemperatureSchema.optional(),
74603
+ upperSetpoint: TemperatureSchema.optional()
74604
+ });
74605
+ var AdjustTemperaturePayload = object({ targetSetpointDelta: TemperatureSchema });
74606
+ var ThermostatModePayload = object({ thermostatMode: union([string(), object({ value: string() })]) });
74607
+ var ModePayload = object({ mode: string() });
74608
+ function isRefusal(outcome) {
74609
+ return "refusal" in outcome;
74610
+ }
74611
+ function toCelsius(t) {
74612
+ if (t.scale === "FAHRENHEIT") return (t.value - 32) * 5 / 9;
74613
+ if (t.scale === "KELVIN") return t.value - 273.15;
74614
+ return t.value;
74615
+ }
74616
+ /** A DELTA converts without the scale's offset. */
74617
+ function deltaToCelsius(t) {
74618
+ return t.scale === "FAHRENHEIT" ? t.value * 5 / 9 : t.value;
74619
+ }
74620
+ function withPayload(envelope, extra) {
74621
+ return { event: {
74622
+ ...envelope.event,
74623
+ payload: {
74624
+ ...envelope.event.payload,
74625
+ ...extra
74626
+ }
74627
+ } };
74628
+ }
74629
+ function outOfRange$1(header, bounds) {
74630
+ return { refusal: withPayload(errorEnvelope(header, "TEMPERATURE_VALUE_OUT_OF_RANGE", `the setpoint must be between ${bounds.min} and ${bounds.max} C`), { validRange: {
74631
+ minimumValue: temperatureValue(bounds.min),
74632
+ maximumValue: temperatureValue(bounds.max)
74633
+ } }) };
74634
+ }
74635
+ function tooClose(header, bounds) {
74636
+ return { refusal: withPayload(errorEnvelope(header, "REQUESTED_SETPOINTS_TOO_CLOSE", "lowerSetpoint must be below upperSetpoint"), { minimumTemperatureDelta: temperatureValue(bounds.step) }) };
74637
+ }
74638
+ function refuse$1(header, type, message) {
74639
+ return { refusal: errorEnvelope(header, type, message) };
74640
+ }
74641
+ function inRange(celsius, bounds) {
74642
+ const v = round1(celsius);
74643
+ return v >= bounds.min && v <= bounds.max;
74644
+ }
74645
+ var THERMOSTAT_PROPERTIES = new Set([
74646
+ "targetSetpoint",
74647
+ "lowerSetpoint",
74648
+ "upperSetpoint",
74649
+ "thermostatMode"
74650
+ ]);
74651
+ function thermostatProperty(property) {
74652
+ return property.namespace === "Alexa.ThermostatController" && THERMOSTAT_PROPERTIES.has(property.name);
74653
+ }
74654
+ function commandsFor(api, deviceId) {
74655
+ return {
74656
+ setMode: (mode) => api.climateControl.setMode.mutate({
74657
+ deviceId,
74658
+ mode
74659
+ }),
74660
+ setPreset: (preset) => api.climateControl.setPreset.mutate({
74661
+ deviceId,
74662
+ preset
74663
+ }),
74664
+ setFanMode: (fanMode) => api.climateControl.setFanMode.mutate({
74665
+ deviceId,
74666
+ fanMode
74667
+ }),
74668
+ setTarget: (target) => api.climateControl.setTarget.mutate({
74669
+ deviceId,
74670
+ target
74671
+ }),
74672
+ setTargetRange: (targetLow, targetHigh) => api.climateControl.setTargetRange.mutate({
74673
+ deviceId,
74674
+ targetLow,
74675
+ targetHigh
74676
+ })
74677
+ };
74678
+ }
74679
+ function planRange(header, slice, cmd, low, high) {
74680
+ const bounds = boundsOf(slice);
74681
+ if (!supportsDual(slice, [])) return refuse$1(header, "DUAL_SETPOINTS_UNSUPPORTED", "this thermostat has a single setpoint");
74682
+ if (!inRange(low, bounds) || !inRange(high, bounds)) return outOfRange$1(header, bounds);
74683
+ const targetLow = snapToStep(low, bounds);
74684
+ const targetHigh = snapToStep(high, bounds);
74685
+ if (targetHigh - targetLow < bounds.step) return tooClose(header, bounds);
74686
+ return {
74687
+ commands: [...slice.mode === "heat_cool" ? [] : [() => cmd.setMode("heat_cool")], () => cmd.setTargetRange(targetLow, targetHigh)],
74688
+ projected: {
74689
+ ...slice,
74690
+ mode: "heat_cool",
74691
+ targetLow,
74692
+ targetHigh
74693
+ },
74694
+ keep: thermostatProperty
74695
+ };
74696
+ }
74697
+ function planSingle(header, slice, cmd, celsius) {
74698
+ const bounds = boundsOf(slice);
74699
+ 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");
74700
+ if (!inRange(celsius, bounds)) return outOfRange$1(header, bounds);
74701
+ const target = snapToStep(celsius, bounds);
74702
+ return {
74703
+ commands: [() => cmd.setTarget(target)],
74704
+ projected: {
74705
+ ...slice,
74706
+ target
74707
+ },
74708
+ keep: thermostatProperty
74709
+ };
74710
+ }
74711
+ function planSetTemperature(c, slice, cmd) {
74712
+ const parsed = SetTemperaturePayload.safeParse(c.payload);
74713
+ if (!parsed.success) return refuse$1(c.header, "INVALID_DIRECTIVE", parsed.error.message);
74714
+ const { targetSetpoint, lowerSetpoint, upperSetpoint } = parsed.data;
74715
+ 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");
74716
+ if (lowerSetpoint !== void 0 && upperSetpoint !== void 0 && targetSetpoint === void 0) return planRange(c.header, slice, cmd, toCelsius(lowerSetpoint), toCelsius(upperSetpoint));
74717
+ if (targetSetpoint !== void 0 && lowerSetpoint === void 0 && upperSetpoint === void 0) return planSingle(c.header, slice, cmd, toCelsius(targetSetpoint));
74718
+ return refuse$1(c.header, "INVALID_DIRECTIVE", "give a targetSetpoint, or a lower and an upper");
74719
+ }
74720
+ function planAdjust(c, slice, cmd) {
74721
+ const parsed = AdjustTemperaturePayload.safeParse(c.payload);
74722
+ if (!parsed.success) return refuse$1(c.header, "INVALID_DIRECTIVE", parsed.error.message);
74723
+ const delta = deltaToCelsius(parsed.data.targetSetpointDelta);
74724
+ if (slice.mode === "heat_cool" && slice.targetLow !== null && slice.targetHigh !== null) return planRange(c.header, slice, cmd, slice.targetLow + delta, slice.targetHigh + delta);
74725
+ if (slice.target === null) return refuse$1(c.header, "ENDPOINT_UNREACHABLE", "the current setpoint is unknown, so it cannot be adjusted");
74726
+ return planSingle(c.header, slice, cmd, slice.target + delta);
74727
+ }
74728
+ var ECO_FROM_OFF_MESSAGE = "the thermostat has no mode to leave OFF into";
74729
+ function hvacModeFor(requested, slice) {
74730
+ const has = (m) => slice.availableModes.includes(m);
74731
+ switch (requested) {
74732
+ case "HEAT": return has("heat") ? "heat" : null;
74733
+ case "COOL": return has("cool") ? "cool" : null;
74734
+ case "AUTO": return has("auto") ? "auto" : has("heat_cool") ? "heat_cool" : null;
74735
+ case "OFF": return has("off") ? "off" : null;
74736
+ default: return null;
74737
+ }
74738
+ }
74739
+ /** The preset that clears `eco` (HA and Gree both call it `none`), if the device has one. */
74740
+ function presetClearing(slice) {
74741
+ return slice.availablePresets.find((p) => p.toLowerCase() === PRESET_NONE) ?? null;
74742
+ }
74743
+ function planEco(c, slice, cmd) {
74744
+ const eco = ecoPresetOf(slice);
74745
+ if (eco === null) return refuse$1(c.header, "UNSUPPORTED_THERMOSTAT_MODE", "ECO is not supported");
74746
+ const wake = slice.mode === "off" ? onModeOf(slice) : null;
74747
+ if (slice.mode === "off" && wake === null) return refuse$1(c.header, "UNSUPPORTED_THERMOSTAT_MODE", ECO_FROM_OFF_MESSAGE);
74748
+ return {
74749
+ commands: [...wake === null ? [] : [() => cmd.setMode(wake)], () => cmd.setPreset(eco)],
74750
+ projected: {
74751
+ ...slice,
74752
+ mode: wake ?? slice.mode,
74753
+ preset: eco
74754
+ },
74755
+ keep: thermostatProperty
74756
+ };
74757
+ }
74758
+ function planSetThermostatMode(c, slice, cmd) {
74759
+ const parsed = ThermostatModePayload.safeParse(c.payload);
74760
+ if (!parsed.success) return refuse$1(c.header, "INVALID_DIRECTIVE", parsed.error.message);
74761
+ const raw = parsed.data.thermostatMode;
74762
+ const requested = (typeof raw === "string" ? raw : raw.value).toUpperCase();
74763
+ if (requested === "ECO") return planEco(c, slice, cmd);
74764
+ const mode = hvacModeFor(requested, slice);
74765
+ if (mode === null) return refuse$1(c.header, "UNSUPPORTED_THERMOSTAT_MODE", `${requested} is not a mode this thermostat has`);
74766
+ const eco = ecoPresetOf(slice);
74767
+ const clear = eco !== null && slice.preset === eco ? presetClearing(slice) : null;
74768
+ return {
74769
+ commands: [...clear === null ? [] : [() => cmd.setPreset(clear)], () => cmd.setMode(mode)],
74770
+ projected: {
74771
+ ...slice,
74772
+ mode,
74773
+ preset: clear ?? slice.preset
74774
+ },
74775
+ keep: thermostatProperty
74776
+ };
74777
+ }
74778
+ function planPower(c, slice, cmd) {
74779
+ const turnOn = c.header.name === "TurnOn";
74780
+ const keep = (p) => p.namespace === "Alexa.PowerController";
74781
+ if (turnOn) {
74782
+ if (slice.mode !== "off") return {
74783
+ commands: [],
74784
+ projected: slice,
74785
+ keep
74786
+ };
74787
+ const mode = onModeOf(slice);
74788
+ if (mode === null) return refuse$1(c.header, "INVALID_DIRECTIVE", "no mode to turn on into");
74789
+ return {
74790
+ commands: [() => cmd.setMode(mode)],
74791
+ projected: {
74792
+ ...slice,
74793
+ mode
74794
+ },
74795
+ keep
74796
+ };
74797
+ }
74798
+ if (!slice.availableModes.includes("off")) return refuse$1(c.header, "INVALID_DIRECTIVE", "this device cannot be turned off");
74799
+ if (slice.mode === "off") return {
74800
+ commands: [],
74801
+ projected: slice,
74802
+ keep
74803
+ };
74804
+ return {
74805
+ commands: [() => cmd.setMode("off")],
74806
+ projected: {
74807
+ ...slice,
74808
+ mode: "off"
74809
+ },
74810
+ keep
74811
+ };
74812
+ }
74813
+ function planModeController(c, slice, cmd) {
74814
+ const parsed = ModePayload.safeParse(c.payload);
74815
+ const instance = c.header.instance;
74816
+ if (!parsed.success || instance === void 0) return refuse$1(c.header, "INVALID_DIRECTIVE", "a mode and an instance are required");
74817
+ const keep = (p) => p.instance === instance;
74818
+ if (instance === "Climate.Mode") {
74819
+ const mode = hvacModeForValue(parsed.data.mode, slice);
74820
+ if (mode === null) return refuse$1(c.header, "INVALID_VALUE", `unknown mode ${parsed.data.mode}`);
74821
+ return {
74822
+ commands: [() => cmd.setMode(mode)],
74823
+ projected: {
74824
+ ...slice,
74825
+ mode
74826
+ },
74827
+ keep
74828
+ };
74829
+ }
74830
+ if (instance === "Climate.FanSpeed") {
74831
+ const fan = fanModesOf(slice).find(([value]) => value === parsed.data.mode);
74832
+ if (fan === void 0) return refuse$1(c.header, "INVALID_VALUE", `unknown fan mode ${parsed.data.mode}`);
74833
+ return {
74834
+ commands: [() => cmd.setFanMode(fan[1])],
74835
+ projected: {
74836
+ ...slice,
74837
+ fanMode: fan[1]
74838
+ },
74839
+ keep
74840
+ };
74841
+ }
74842
+ return refuse$1(c.header, "INVALID_DIRECTIVE", `unknown instance ${instance}`);
74843
+ }
74844
+ function plan(c, slice, cmd) {
74845
+ switch (`${c.header.namespace}/${c.header.name}`) {
74846
+ case "Alexa.ThermostatController/SetTargetTemperature": return planSetTemperature(c, slice, cmd);
74847
+ case "Alexa.ThermostatController/AdjustTargetTemperature": return planAdjust(c, slice, cmd);
74848
+ case "Alexa.ThermostatController/SetThermostatMode": return planSetThermostatMode(c, slice, cmd);
74849
+ case "Alexa.PowerController/TurnOn":
74850
+ case "Alexa.PowerController/TurnOff": return planPower(c, slice, cmd);
74851
+ case "Alexa.ModeController/SetMode": return planModeController(c, slice, cmd);
74852
+ default: return refuse$1(c.header, "INVALID_DIRECTIVE", `${c.header.namespace}/${c.header.name}`);
74853
+ }
74854
+ }
74855
+ async function readSlice$1(c, deps) {
74856
+ try {
74857
+ return readClimateSlice(await deps.ctx.api.deviceState.getCapSlice.query({
74858
+ deviceId: c.deviceId,
74859
+ capName: CAP_CLIMATE
74860
+ }));
74861
+ } catch (err) {
74862
+ deps.logger.warn("alexa climate-controller: the climate state could not be read", {
74863
+ tags: { deviceId: c.deviceId },
74864
+ meta: { error: err instanceof Error ? err.message : String(err) }
74865
+ });
74866
+ return null;
74867
+ }
74868
+ }
74869
+ function successResponse$1(c, properties) {
74870
+ const now = (/* @__PURE__ */ new Date()).toISOString();
74871
+ return {
74872
+ event: {
74873
+ header: {
74874
+ namespace: "Alexa",
74875
+ name: "Response",
74876
+ messageId: c.header.messageId,
74877
+ ...c.header.correlationToken !== void 0 ? { correlationToken: c.header.correlationToken } : {},
74878
+ payloadVersion: "3"
74879
+ },
74880
+ endpoint: { endpointId: c.endpoint.endpointId },
74881
+ payload: {}
74882
+ },
74883
+ context: { properties: properties.map((p) => ({
74884
+ ...p,
74885
+ timeOfSample: now,
74886
+ uncertaintyInMilliseconds: UNCERTAINTY_MS
74887
+ })) }
74888
+ };
74889
+ }
74890
+ /** The one handler behind every climate directive. */
74891
+ async function handleClimateDirective(c) {
74892
+ const deps = c.deps.climateController;
74893
+ const slice = await readSlice$1(c, deps);
74894
+ if (slice === null) return errorEnvelope(c.header, "ENDPOINT_UNREACHABLE", "the climate state could not be read");
74895
+ const outcome = plan(c, slice, commandsFor(deps.ctx.api, c.deviceId));
74896
+ if (isRefusal(outcome)) return outcome.refusal;
74897
+ try {
74898
+ for (const command of outcome.commands) await command();
74899
+ } catch (err) {
74900
+ deps.logger.warn("alexa climate-controller: the climate command failed", {
74901
+ tags: { deviceId: c.deviceId },
74902
+ meta: {
74903
+ directive: c.header.name,
74904
+ error: err instanceof Error ? err.message : String(err)
74905
+ }
74906
+ });
74907
+ return errorEnvelope(c.header, "ENDPOINT_UNREACHABLE", "the device did not accept the command");
74908
+ }
74909
+ deps.logger.info(`alexa climate-controller: ${c.header.name}`, {
74910
+ tags: { deviceId: c.deviceId },
74911
+ meta: { commands: outcome.commands.length }
74912
+ });
74913
+ return successResponse$1(c, climateProperties(outcome.projected).filter(outcome.keep));
74914
+ }
74915
+ /**
74916
+ * Last answer per device, per deps instance: a failed bindings read changes
74917
+ * nothing (AGENTS D49) — a thermostat's TurnOff must not fall through to the
74918
+ * switch handler because one `getBindings` call failed.
74919
+ */
74920
+ var lastPowerClaim = /* @__PURE__ */ new WeakMap();
74921
+ /** PowerController on a climate endpoint, not on a switch: decided by the bound caps. */
74922
+ async function handlesPower(c) {
74923
+ const deps = c.deps.climateController;
74924
+ const known = lastPowerClaim.get(deps) ?? /* @__PURE__ */ new Map();
74925
+ lastPowerClaim.set(deps, known);
74926
+ try {
74927
+ const caps = (await deps.ctx.api.deviceManager.getBindings.query({ deviceId: c.deviceId })).entries.map((e) => e.capName);
74928
+ const claim = caps.includes(CAP_CLIMATE) && !caps.includes(CAP_SWITCH$1);
74929
+ known.set(c.deviceId, claim);
74930
+ return claim;
74931
+ } catch (err) {
74932
+ const previous = known.get(c.deviceId);
74933
+ deps.logger.warn("alexa climate-controller: the bound caps could not be read", {
74934
+ tags: { deviceId: c.deviceId },
74935
+ meta: {
74936
+ error: err instanceof Error ? err.message : String(err),
74937
+ decidedFrom: previous === void 0 ? "no previous answer: not claimed" : "previous answer"
74938
+ }
74939
+ });
74940
+ return previous ?? false;
74941
+ }
74942
+ }
74943
+ //#endregion
74001
74944
  //#region src/endpoints/adapters/shared.ts
74002
74945
  /** The base `Alexa` interface every endpoint declares. */
74003
74946
  var BASE = {
@@ -74048,6 +74991,88 @@ function numericValueField(value) {
74048
74991
  return typeof raw === "number" ? raw : null;
74049
74992
  }
74050
74993
  //#endregion
74994
+ //#region src/endpoints/adapters/temperature.ts
74995
+ /** A temperature change under half a degree is not worth a ChangeReport. */
74996
+ var TEMPERATURE_DEADBAND_CELSIUS = .5;
74997
+ /** A numeric sensor reports at most once a minute per property (D869). */
74998
+ var SENSOR_MIN_INTERVAL_MS = 6e4;
74999
+ /** `temperature-sensor` → `Alexa.TemperatureSensor` (TEMPERATURE_SENSOR). */
75000
+ var temperatureAdapter = {
75001
+ cap: "temperature-sensor",
75002
+ stateCaps: ["temperature-sensor"],
75003
+ discoveryReads: [],
75004
+ describe: () => ({
75005
+ kind: "publish",
75006
+ capabilities: [iface("Alexa.TemperatureSensor", "temperature")],
75007
+ category: "TEMPERATURE_SENSOR"
75008
+ }),
75009
+ properties: (_cap, slice) => {
75010
+ const p = TemperatureSensorStatusSchema.pick({ celsius: true }).safeParse(slice);
75011
+ return p.success ? [{
75012
+ namespace: "Alexa.TemperatureSensor",
75013
+ name: "temperature",
75014
+ value: {
75015
+ value: Math.round(p.data.celsius * 10) / 10,
75016
+ scale: "CELSIUS"
75017
+ }
75018
+ }] : [];
75019
+ },
75020
+ directives: [],
75021
+ shaping: () => ({
75022
+ deadband: TEMPERATURE_DEADBAND_CELSIUS,
75023
+ minIntervalMs: SENSOR_MIN_INTERVAL_MS,
75024
+ numericOf: numericValueField
75025
+ })
75026
+ };
75027
+ //#endregion
75028
+ //#region src/endpoints/adapters/climate.ts
75029
+ var OWN_INSTANCES = new Set([INSTANCE_MODE, INSTANCE_FAN]);
75030
+ var THERMOSTAT_DIRECTIVES = [
75031
+ "Alexa.ThermostatController/SetTargetTemperature",
75032
+ "Alexa.ThermostatController/AdjustTargetTemperature",
75033
+ "Alexa.ThermostatController/SetThermostatMode"
75034
+ ].map((key) => ({
75035
+ key,
75036
+ needsReachableDevice: true,
75037
+ handle: handleClimateDirective
75038
+ }));
75039
+ var POWER_DIRECTIVES$1 = ["Alexa.PowerController/TurnOn", "Alexa.PowerController/TurnOff"].map((key) => ({
75040
+ key,
75041
+ needsReachableDevice: true,
75042
+ handle: handleClimateDirective,
75043
+ claims: handlesPower
75044
+ }));
75045
+ var MODE_DIRECTIVE = {
75046
+ key: "Alexa.ModeController/SetMode",
75047
+ needsReachableDevice: true,
75048
+ handle: handleClimateDirective,
75049
+ claims: async (c) => c.header.instance !== void 0 && OWN_INSTANCES.has(c.header.instance)
75050
+ };
75051
+ /**
75052
+ * `climate-control` -> a thermostat (Home Assistant) or an air conditioner
75053
+ * (Gree) (D874): `Alexa.ThermostatController` 3.1 with the current temperature,
75054
+ * power, and the modes Amazon's vocabulary lacks and the fan mode as
75055
+ * ModeControllers. The current temperature is shaped like any temperature
75056
+ * sensor; setpoints, mode and fan report on every change.
75057
+ */
75058
+ var climateAdapter = {
75059
+ cap: "climate-control",
75060
+ stateCaps: ["climate-control"],
75061
+ discoveryReads: ["climate-control"],
75062
+ describe: describeClimate,
75063
+ properties: (_cap, slice) => climateProperties(slice),
75064
+ directives: [
75065
+ ...THERMOSTAT_DIRECTIVES,
75066
+ ...POWER_DIRECTIVES$1,
75067
+ MODE_DIRECTIVE
75068
+ ],
75069
+ shaping: (property) => property.namespace === "Alexa.TemperatureSensor" ? {
75070
+ deadband: TEMPERATURE_DEADBAND_CELSIUS,
75071
+ minIntervalMs: SENSOR_MIN_INTERVAL_MS,
75072
+ numericOf: numericValueField
75073
+ } : null
75074
+ };
75075
+ //#endregion
74051
75076
  //#region src/endpoints/adapters/contact.ts
74052
75077
  /** `contact` → `Alexa.ContactSensor` (CONTACT_SENSOR). Amazon: DETECTED = open. */
74053
75078
  var contactAdapter = {
@@ -74712,7 +75737,8 @@ var coverAdapter = {
74712
75737
  ].map((key) => ({
74713
75738
  key,
74714
75739
  needsReachableDevice: true,
74715
- handle: handleCoverDirective
75740
+ handle: handleCoverDirective,
75741
+ claims: async (c) => c.header.instance?.startsWith("Blind.") ?? false
74716
75742
  })),
74717
75743
  shaping: (property) => property.namespace === "Alexa.RangeController" ? {
74718
75744
  deadband: 1,
@@ -74720,40 +75746,6 @@ var coverAdapter = {
74720
75746
  numericOf: plainNumber
74721
75747
  } : { debounceMs: COVER_DEBOUNCE_MS }
74722
75748
  };
74723
- //#endregion
74724
- //#region src/endpoints/adapters/temperature.ts
74725
- /** A temperature change under half a degree is not worth a ChangeReport. */
74726
- var TEMPERATURE_DEADBAND_CELSIUS = .5;
74727
- /** A numeric sensor reports at most once a minute per property (D869). */
74728
- var SENSOR_MIN_INTERVAL_MS = 6e4;
74729
- /** `temperature-sensor` → `Alexa.TemperatureSensor` (TEMPERATURE_SENSOR). */
74730
- var temperatureAdapter = {
74731
- cap: "temperature-sensor",
74732
- stateCaps: ["temperature-sensor"],
74733
- discoveryReads: [],
74734
- describe: () => ({
74735
- kind: "publish",
74736
- capabilities: [iface("Alexa.TemperatureSensor", "temperature")],
74737
- category: "TEMPERATURE_SENSOR"
74738
- }),
74739
- properties: (_cap, slice) => {
74740
- const p = TemperatureSensorStatusSchema.pick({ celsius: true }).safeParse(slice);
74741
- return p.success ? [{
74742
- namespace: "Alexa.TemperatureSensor",
74743
- name: "temperature",
74744
- value: {
74745
- value: Math.round(p.data.celsius * 10) / 10,
74746
- scale: "CELSIUS"
74747
- }
74748
- }] : [];
74749
- },
74750
- directives: [],
74751
- shaping: () => ({
74752
- deadband: TEMPERATURE_DEADBAND_CELSIUS,
74753
- minIntervalMs: SENSOR_MIN_INTERVAL_MS,
74754
- numericOf: numericValueField
74755
- })
74756
- };
74757
75749
  /** `humidity-sensor` → `Alexa.HumiditySensor` (TEMPERATURE_SENSOR). */
74758
75750
  var humidityAdapter = {
74759
75751
  cap: "humidity-sensor",
@@ -75052,6 +76044,11 @@ var BRIGHTNESS_DIRECTIVE = {
75052
76044
  needsReachableDevice: true,
75053
76045
  handle: (c) => handleSetBrightness(c.header, c.endpoint, c.payload, c.deps.brightnessController, c.identity)
75054
76046
  };
76047
+ /** An outlet (D875: its source says so, e.g. HA `device_class: outlet`) is a
76048
+ * smart plug; every other switch is a SWITCH. */
76049
+ function switchCategory(device) {
76050
+ return device.role === DeviceRole.Outlet ? "SMARTPLUG" : "SWITCH";
76051
+ }
75055
76052
  //#endregion
75056
76053
  //#region src/endpoints/adapters/index.ts
75057
76054
  var GENERIC_ADAPTERS = [
@@ -75059,6 +76056,7 @@ var GENERIC_ADAPTERS = [
75059
76056
  contactAdapter,
75060
76057
  motionAdapter,
75061
76058
  presenceAdapter,
76059
+ climateAdapter,
75062
76060
  temperatureAdapter,
75063
76061
  humidityAdapter,
75064
76062
  {
@@ -75070,7 +76068,7 @@ var GENERIC_ADAPTERS = [
75070
76068
  return {
75071
76069
  kind: "publish",
75072
76070
  capabilities: [iface("Alexa.PowerController", "powerState"), ...dimmable ? [iface("Alexa.BrightnessController", "brightness")] : []],
75073
- category: dimmable || device.type === "light" ? "LIGHT" : "SWITCH"
76071
+ category: dimmable || device.type === "light" ? "LIGHT" : switchCategory(device)
75074
76072
  };
75075
76073
  },
75076
76074
  properties: (capName, slice) => {
@@ -75116,12 +76114,19 @@ function shapingFor(capName, property) {
75116
76114
  function discoveryReadCaps(capNames) {
75117
76115
  return [...new Set(GENERIC_ADAPTERS.filter((a) => capNames.includes(a.cap)).flatMap((a) => a.discoveryReads))];
75118
76116
  }
75119
- /** A directive an adapter handles on a generic endpoint, by `Namespace/Name`. */
75120
- function genericDirective(key) {
75121
- for (const adapter of GENERIC_ADAPTERS) {
75122
- const found = adapter.directives.find((d) => d.key === key);
75123
- if (found !== void 0) return found;
75124
- }
76117
+ /** Every directive an adapter declares for `Namespace/Name`, in registry order. */
76118
+ function genericDirectives(key) {
76119
+ return GENERIC_ADAPTERS.flatMap((a) => a.directives.filter((d) => d.key === key));
76120
+ }
76121
+ /**
76122
+ * The directive that serves this endpoint: of the adapters declaring the key,
76123
+ * in registry order, the first that claims it (two caps may answer one key -
76124
+ * power on a switch and on a thermostat). Null = nobody claims it.
76125
+ */
76126
+ async function resolveGenericDirective(context) {
76127
+ const key = `${context.header.namespace}/${context.header.name}`;
76128
+ for (const directive of genericDirectives(key)) if (directive.claims === void 0 || await directive.claims(context)) return directive;
76129
+ return null;
75125
76130
  }
75126
76131
  /** Every directive key the registry handles. */
75127
76132
  var GENERIC_DIRECTIVE_KEYS = [...new Set(GENERIC_ADAPTERS.flatMap((a) => a.directives.map((d) => d.key)))];
@@ -75163,9 +76168,9 @@ function planGenericDevice(device) {
75163
76168
  category
75164
76169
  };
75165
76170
  }
75166
- /** Device types this export may publish as a generic endpoint. A `thermostat`
75167
- * is listed so discovery can say WHY it is not published (no climate adapter
75168
- * yet, D869) instead of calling it a device with no mappable cap. */
76171
+ /** Device types this export may publish as a generic endpoint. `thermostat`
76172
+ * (Home Assistant) and `climate` (an air conditioner, e.g. Gree) both carry
76173
+ * the `climate-control` cap (D874). */
75169
76174
  var GENERIC_DEVICE_TYPES = [
75170
76175
  "light",
75171
76176
  "switch",
@@ -75174,11 +76179,13 @@ var GENERIC_DEVICE_TYPES = [
75174
76179
  "lock",
75175
76180
  "presence",
75176
76181
  "thermostat",
76182
+ "climate",
75177
76183
  "cover",
75178
76184
  "generic"
75179
76185
  ];
75180
76186
  function toEndpoint(input, plan) {
75181
- const name = input.name.trim();
76187
+ const override = input.alexaName?.trim() ?? "";
76188
+ const name = override.length > 0 ? override : input.name.trim();
75182
76189
  return {
75183
76190
  endpointId: `${input.deviceId}__dev`,
75184
76191
  manufacturerName: "camstack",
@@ -75189,13 +76196,7 @@ function toEndpoint(input, plan) {
75189
76196
  cookie: { stableId: input.stableId }
75190
76197
  };
75191
76198
  }
75192
- /** Why a climate device is withheld until its adapter exists. */
75193
- var THERMOSTAT_SKIP_REASON = "thermostat: Alexa climate control (Alexa.ThermostatController) has no adapter yet - not published until it does (D869, P3)";
75194
76199
  function planGenericEndpoint(input) {
75195
- if (input.type === "thermostat" && input.capNames.includes("climate-control")) return {
75196
- kind: "skip",
75197
- reasons: [THERMOSTAT_SKIP_REASON]
75198
- };
75199
76200
  const plan = planGenericDevice({
75200
76201
  deviceId: input.deviceId,
75201
76202
  type: input.type,
@@ -75211,6 +76212,74 @@ function planGenericEndpoint(input) {
75211
76212
  return plan;
75212
76213
  }
75213
76214
  //#endregion
76215
+ //#region src/endpoints/alexa-name.ts
76216
+ /**
76217
+ * The per-device "Alexa name" (D875): what Alexa calls the device, when it
76218
+ * should not be the device's own name.
76219
+ *
76220
+ * Stored under {@link ALEXA_NAME_KEY} in this addon's per-device settings store
76221
+ * (D870) and used as the endpoint `friendlyName`. It never reaches the endpoint
76222
+ * id (`<deviceId>__dev`), so renaming updates the SAME Alexa device on the next
76223
+ * AddOrUpdateReport instead of creating a duplicate.
76224
+ *
76225
+ * Amazon's friendlyName rule (Alexa.Discovery objects, endpoint object): "Up
76226
+ * to 256 alphanumeric characters and spaces. Don't include special characters
76227
+ * or punctuation." Letters of any script count as alphanumeric, so an Italian
76228
+ * name keeps its accents. The validator rejects what Amazon would reject, with
76229
+ * a message the operator can act on.
76230
+ */
76231
+ /** Per-device store key of the saved Alexa name. */
76232
+ var ALEXA_NAME_KEY = "alexaName";
76233
+ /** The device-settings form field key, namespaced per exporter (see the addon). */
76234
+ function alexaNameKey(deviceId) {
76235
+ return `alexa:${deviceId}:alexaName`;
76236
+ }
76237
+ /** Letters (any script, with their combining marks), digits and spaces. */
76238
+ var ALLOWED_PATTERN = /^[\p{L}\p{M}\p{N} ]+$/u;
76239
+ /**
76240
+ * Validate an operator-typed Alexa name. Blank clears the override
76241
+ * (`name: null`, the device name is used).
76242
+ */
76243
+ function validateAlexaName(raw) {
76244
+ if (raw === null || raw === void 0) return {
76245
+ ok: true,
76246
+ name: null
76247
+ };
76248
+ if (typeof raw !== "string") return {
76249
+ ok: false,
76250
+ error: "The Alexa name must be text."
76251
+ };
76252
+ const name = raw.trim().replace(/\s+/g, " ");
76253
+ if (name.length === 0) return {
76254
+ ok: true,
76255
+ name: null
76256
+ };
76257
+ if (name.length > 256) return {
76258
+ ok: false,
76259
+ error: `The Alexa name is ${name.length} characters; Amazon allows at most 256.`
76260
+ };
76261
+ if (!ALLOWED_PATTERN.test(name)) return {
76262
+ ok: false,
76263
+ error: "The Alexa name may contain only letters, digits and spaces (Amazon rejects punctuation and special characters)."
76264
+ };
76265
+ return {
76266
+ ok: true,
76267
+ name
76268
+ };
76269
+ }
76270
+ var StoredNameSchema = string();
76271
+ /**
76272
+ * The saved Alexa name in a per-device store blob, or null (never saved, or a
76273
+ * stored value that no longer validates: the device name is used rather than
76274
+ * pushing a name Amazon would refuse).
76275
+ */
76276
+ function readAlexaName(store) {
76277
+ const parsed = StoredNameSchema.safeParse(store[ALEXA_NAME_KEY]);
76278
+ if (!parsed.success) return null;
76279
+ const check = validateAlexaName(parsed.data);
76280
+ return check.ok ? check.name : null;
76281
+ }
76282
+ //#endregion
75214
76283
  //#region src/handlers/security-panel-arm-state.ts
75215
76284
  /**
75216
76285
  * The panel modes behind each Alexa arm state, PRIMARY first — the mirror of
@@ -75884,6 +76953,7 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
75884
76953
  deviceId: parentInfo.id,
75885
76954
  stableId: parentInfo.stableId,
75886
76955
  name: parentInfo.name,
76956
+ alexaName: deps.alexaName === void 0 ? null : await deps.alexaName(parentInfo.id),
75887
76957
  type: String(device.type),
75888
76958
  capNames: parentInfo.capNames,
75889
76959
  role: parentInfo.role,
@@ -75942,11 +77012,12 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
75942
77012
  return [];
75943
77013
  });
75944
77014
  const objectDetectionClasses = deps.objectDetection === void 0 ? [] : await deps.objectDetection(parentInfo.id);
77015
+ const cameraName = (deps.alexaName === void 0 ? null : await deps.alexaName(parentInfo.id)) ?? parentInfo.name;
75945
77016
  const out = [];
75946
77017
  out.push(buildParentCameraEndpoint({
75947
77018
  deviceId: parentInfo.id,
75948
77019
  stableId: parentInfo.stableId,
75949
- friendlyName: parentInfo.name,
77020
+ friendlyName: cameraName,
75950
77021
  isDoorbell,
75951
77022
  hasIntercom,
75952
77023
  hasMotion,
@@ -75960,7 +77031,7 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
75960
77031
  for (const detectionClass of classes) out.push(buildDetectionSensorEndpoint({
75961
77032
  deviceId: parentInfo.id,
75962
77033
  stableId: parentInfo.stableId,
75963
- cameraName: parentInfo.name,
77034
+ cameraName,
75964
77035
  detectionClass
75965
77036
  }));
75966
77037
  }
@@ -76454,6 +77525,36 @@ function validateInstanced(c, push) {
76454
77525
  if (c.semantics.stateMappings !== void 0) validateStateMappings(c, c.semantics.stateMappings, push, validMode);
76455
77526
  }
76456
77527
  //#endregion
77528
+ //#region src/endpoints/validate-thermostat.ts
77529
+ var PROPERTIES = new Set([
77530
+ "targetSetpoint",
77531
+ "lowerSetpoint",
77532
+ "upperSetpoint",
77533
+ "thermostatMode"
77534
+ ]);
77535
+ var MODES = new Set([
77536
+ "AUTO",
77537
+ "COOL",
77538
+ "HEAT",
77539
+ "ECO",
77540
+ "OFF"
77541
+ ]);
77542
+ function validateThermostat(c, push) {
77543
+ const supported = new Set((c.properties?.supported ?? []).map((p) => p.name));
77544
+ for (const name of supported) if (!PROPERTIES.has(name)) push(`properties.supported ${name} is not a thermostat property`);
77545
+ if (!supported.has("thermostatMode")) push("properties.supported must include thermostatMode");
77546
+ if (supported.has("lowerSetpoint") !== supported.has("upperSetpoint")) push("lowerSetpoint and upperSetpoint are declared together");
77547
+ if (!supported.has("targetSetpoint") && !supported.has("lowerSetpoint")) push("at least one setpoint property is required");
77548
+ const modes = c.configuration?.["supportedModes"];
77549
+ if (!Array.isArray(modes) || modes.length === 0) push("configuration.supportedModes must list at least one mode");
77550
+ else {
77551
+ for (const mode of modes) if (typeof mode !== "string" || !MODES.has(mode)) push(`supportedModes ${String(mode)} is not AUTO, COOL, HEAT, ECO or OFF`);
77552
+ if (new Set(modes).size !== modes.length) push("supportedModes values must be unique");
77553
+ }
77554
+ const scheduling = c.configuration?.["supportsScheduling"];
77555
+ if (scheduling !== void 0 && typeof scheduling !== "boolean") push("configuration.supportsScheduling must be a boolean");
77556
+ }
77557
+ //#endregion
76457
77558
  //#region src/endpoints/validate-endpoint.ts
76458
77559
  /**
76459
77560
  * Interfaces this skill may declare, with the version Amazon documents. An
@@ -76477,6 +77578,7 @@ var ALLOWED_INTERFACE_VERSIONS = {
76477
77578
  "Alexa.RangeController": "3",
76478
77579
  "Alexa.SecurityPanelController": "3",
76479
77580
  "Alexa.LockController": "3",
77581
+ "Alexa.ThermostatController": "3.1",
76480
77582
  "Alexa.SmartVision.SnapshotProvider": "1.1",
76481
77583
  "Alexa.SmartVision.ObjectDetectionSensor": "1.0",
76482
77584
  "Alexa.DataController": "1.0"
@@ -76516,6 +77618,8 @@ var DISPLAY_CATEGORIES = new Set([
76516
77618
  "SMARTLOCK",
76517
77619
  "INTERIOR_BLIND",
76518
77620
  "EXTERIOR_BLIND",
77621
+ "THERMOSTAT",
77622
+ "AIR_CONDITIONER",
76519
77623
  "DOOR",
76520
77624
  "GATE",
76521
77625
  "GARAGE_DOOR",
@@ -76594,6 +77698,7 @@ function validateCapability(endpointId, c, out, declared) {
76594
77698
  }
76595
77699
  }
76596
77700
  validateInstanced(c, push);
77701
+ if (c.interface === "Alexa.ThermostatController") validateThermostat(c, push);
76597
77702
  if (c.interface === "Alexa.SmartVision.ObjectDetectionSensor") validateObjectDetection(c, push);
76598
77703
  if (c.interface === "Alexa.DataController") validateDataController(c, declared, push);
76599
77704
  if (c.interface === "Alexa.SmartVision.SnapshotProvider") {
@@ -78524,20 +79629,27 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
78524
79629
  status: 200,
78525
79630
  body: await handleSessionDisconnected(header, endpoint, payload, deps.rtcSession, callerIdentity)
78526
79631
  };
78527
- const adapterDirective = genericDirective(key);
78528
- if (adapterDirective !== void 0 && endpoint !== void 0) {
79632
+ if (genericDirectives(key).length > 0 && endpoint !== void 0) {
78529
79633
  const target = parseCameraEndpointId(endpoint.endpointId);
78530
- if (target.kind === "generic") return {
78531
- status: 200,
78532
- body: await adapterDirective.handle({
79634
+ if (target.kind === "generic") {
79635
+ const directiveContext = {
78533
79636
  header,
78534
79637
  endpoint,
78535
79638
  payload,
78536
79639
  deviceId: target.deviceId,
78537
79640
  deps,
78538
79641
  identity: callerIdentity
78539
- })
78540
- };
79642
+ };
79643
+ const adapterDirective = await resolveGenericDirective(directiveContext);
79644
+ if (adapterDirective === null) return {
79645
+ status: 200,
79646
+ body: errorEnvelope(header, "INVALID_DIRECTIVE", `${key}: no handler for this endpoint`)
79647
+ };
79648
+ return {
79649
+ status: 200,
79650
+ body: await adapterDirective.handle(directiveContext)
79651
+ };
79652
+ }
78541
79653
  }
78542
79654
  if (key === "Alexa.ModeController/SetMode") return {
78543
79655
  status: 200,
@@ -82680,6 +83792,7 @@ var SUPPORTED_KINDS = [
82680
83792
  DeviceType.Lock,
82681
83793
  DeviceType.Presence,
82682
83794
  DeviceType.Thermostat,
83795
+ DeviceType.Climate,
82683
83796
  DeviceType.Cover,
82684
83797
  DeviceType.Generic
82685
83798
  ];
@@ -82818,6 +83931,10 @@ var ExportAlexaAddon = class extends BaseAddon {
82818
83931
  ctx: this.ctx,
82819
83932
  logger: this.ctx.logger
82820
83933
  },
83934
+ climateController: {
83935
+ ctx: this.ctx,
83936
+ logger: this.ctx.logger
83937
+ },
82821
83938
  coverController: {
82822
83939
  ctx: this.ctx,
82823
83940
  logger: this.ctx.logger,
@@ -83074,6 +84191,7 @@ var ExportAlexaAddon = class extends BaseAddon {
83074
84191
  logger: this.ctx.logger,
83075
84192
  snapshots: this.config.snapshots,
83076
84193
  ...this.config.detectionSensors ? { detectionClasses: async (id) => effectiveSensorClasses(await classesOf(id), parseSensorSelection(await vision.readPrefs(id))) } : {},
84194
+ alexaName: async (id) => readAlexaName(await vision.readPrefs(id)),
83077
84195
  ...this.config.objectDetection ? { objectDetection: async (id) => vision.buildClasses(id, await classesOf(id)) } : {}
83078
84196
  });
83079
84197
  }
@@ -83641,12 +84759,28 @@ var ExportAlexaAddon = class extends BaseAddon {
83641
84759
  value: enabled,
83642
84760
  immediate: true
83643
84761
  },
84762
+ await this.alexaNameField(deviceId, enabledKey),
83644
84763
  ...kind === "camera" ? this.cameraOnlyExportFields(deviceId, settings, enabledKey) : [],
83645
84764
  ...kind === "camera" ? await this.cameraDetectionFields(deviceId, enabledKey) : []
83646
84765
  ]
83647
84766
  }]
83648
84767
  };
83649
84768
  }
84769
+ /** D875: the "Alexa name" field, with the validation error of the saved value. */
84770
+ async alexaNameField(deviceId, enabledKey) {
84771
+ const saved = readAlexaName(await this.vision.readPrefs(deviceId));
84772
+ return {
84773
+ type: "text",
84774
+ key: alexaNameKey(deviceId),
84775
+ label: "Alexa name",
84776
+ 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.`,
84777
+ value: saved ?? "",
84778
+ showWhen: {
84779
+ field: enabledKey,
84780
+ equals: true
84781
+ }
84782
+ };
84783
+ }
83650
84784
  /**
83651
84785
  * The camera's detection fields: which classes become Alexa sensors (D778;
83652
84786
  * only the classes its pipeline produces are offered) and its object
@@ -83668,6 +84802,24 @@ var ExportAlexaAddon = class extends BaseAddon {
83668
84802
  })];
83669
84803
  }
83670
84804
  /**
84805
+ * D875: save the Alexa name from a device-settings patch. A name Amazon would
84806
+ * refuse throws BEFORE anything is written, so the form shows the reason and
84807
+ * the old name stays. True when the saved name changed.
84808
+ */
84809
+ async applyAlexaNamePatch(deviceId, patch) {
84810
+ const key = alexaNameKey(deviceId);
84811
+ if (!(key in patch)) return false;
84812
+ const check = validateAlexaName(patch[key]);
84813
+ if (!check.ok) throw new Error(`export-alexa: ${check.error}`);
84814
+ if (readAlexaName(await this.vision.readPrefs(deviceId)) === check.name) return false;
84815
+ await this.vision.writePrefs(deviceId, { [ALEXA_NAME_KEY]: check.name ?? void 0 });
84816
+ this.ctx.logger.info("export-alexa: Alexa name saved", {
84817
+ tags: { deviceId },
84818
+ meta: { name: check.name }
84819
+ });
84820
+ return true;
84821
+ }
84822
+ /**
83671
84823
  * Persist the camera's detection preferences from a device-settings patch
83672
84824
  * into this addon's per-device store. True when anything changed — the
83673
84825
  * camera's Alexa shape then needs a reconcile.
@@ -83732,6 +84884,7 @@ var ExportAlexaAddon = class extends BaseAddon {
83732
84884
  const current = this.findEntry(deviceId);
83733
84885
  const wasEnabled = current !== null;
83734
84886
  const prefsChanged = await this.applyDetectionPrefsPatch(deviceId, patch);
84887
+ const nameChanged = await this.applyAlexaNamePatch(deviceId, patch);
83735
84888
  const enabledKey = `alexa:${deviceId}:enabled`;
83736
84889
  const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
83737
84890
  const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
@@ -83757,6 +84910,12 @@ var ExportAlexaAddon = class extends BaseAddon {
83757
84910
  const currentAllow = current?.settings?.allowTranscode !== false;
83758
84911
  await this.patchEntrySettings(deviceId, nextSettings);
83759
84912
  const sourceChanged = currentPref !== streamPreference || currentAllow !== allowTranscode;
84913
+ if (nameChanged && !sourceChanged) this.requireSyncEngine().pushDevice(deviceId).catch((err) => {
84914
+ this.ctx.logger.warn("export-alexa: push after an Alexa name change failed", {
84915
+ tags: { deviceId },
84916
+ meta: { error: errMsg(err) }
84917
+ });
84918
+ });
83760
84919
  if (prefsChanged && !sourceChanged) this.requireSyncEngine().reconcileDevice(deviceId).catch((err) => {
83761
84920
  this.ctx.logger.warn("export-alexa: re-sync after a detection preference change failed", {
83762
84921
  tags: { deviceId },