@camstack/addon-provider-homeassistant 1.2.639 → 1.2.640

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.
@@ -1,23 +1,9 @@
1
- import { $t as object, Bt as baseUnitOf, Gt as isReadingQuantity, I as consumableFormOf, Jt as array, Kt as nodePin, L as consumableKindOf, O as buildAddonRouteProvider, Rt as BaseAddon, U as deviceExportCapability, a as COCO_TO_MACRO, an as EventCategory, c as ConsumableItemSchema, d as FanDirectionSchema, f as HvacModeSchema, ht as runExportedAction, i as CAMERA_SWITCH_ORDER, l as ConsumableStatusSchema, m as PLUG_APPLIANCE_TYPES, o as CameraSwitchIdSchema, p as MediaPlayerRepeatSchema, pt as pruneDeletedEntities, rn as unknown, s as ConsumableFormSchema, st as oauthIntegrationCapability, t as AlarmArmModeSchema, tn as string, v as addonRoutesCapability, xt as sendToExportedTarget } from "../dist-Cvz76AD1.mjs";
1
+ import { B as consumableKindOf, Jt as isReadingQuantity, K as deviceExportCapability, Ut as baseUnitOf, Vt as BaseAddon, Yt as nodePin, Zt as array, _ as PLUG_APPLIANCE_TYPES, c as COCO_TO_MACRO, cn as EventCategory, d as ConsumableItemSchema, f as ConsumableStatusSchema, g as MediaPlayerRepeatSchema, gt as pruneDeletedEntities, h as HvacModeSchema, i as AlarmArmModeSchema, in as string, j as buildAddonRouteProvider, l as CameraSwitchIdSchema, m as FanDirectionSchema, nn as object, on as unknown, r as listHomeAssistantBrokers, s as CAMERA_SWITCH_ORDER, u as ConsumableFormSchema, ut as oauthIntegrationCapability, vt as runExportedAction, wt as sendToExportedTarget, x as addonRoutesCapability, z as consumableFormOf } from "../ha-broker-source-FHFpUlgq.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  import "node:zlib";
4
4
  import "node:fs";
5
5
  import "node:path";
6
6
  import "node:child_process";
7
- /** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
8
- var HA_BROKER_KIND = "home-assistant";
9
- /**
10
- * Every Home Assistant broker currently registered, cluster-wide.
11
- *
12
- * Unpinned by design (see the module docblock) and filtered by `kind`, which is
13
- * the contract check that matters: whatever mix of providers answered the
14
- * union, only `home-assistant` brokers are ever exported to over Home
15
- * Assistant's transport.
16
- */
17
- async function listHomeAssistantBrokers(query) {
18
- return (await query({})).filter((broker) => broker.kind === HA_BROKER_KIND);
19
- }
20
- //#endregion
21
7
  //#region src/ha-export/camera-function-writes.ts
22
8
  /**
23
9
  * Performs the WRITE of a camera function on the authority that owns it.
@@ -5213,12 +5199,125 @@ var HOME_ASSISTANT_OAUTH_INTEGRATION = {
5213
5199
  refreshTokenTtlSec: 365 * 24 * 60 * 60
5214
5200
  };
5215
5201
  //#endregion
5202
+ //#region src/ha-export/push-failure.ts
5203
+ /**
5204
+ * Why a push to Home Assistant failed, in words an operator can act on.
5205
+ *
5206
+ * The reason used to live only in the log line's `meta.error`, which a log
5207
+ * viewer that shows the message alone never displays: an operator saw
5208
+ * "push failed, messages dropped" every 30 s for hours with nothing to act on.
5209
+ */
5210
+ /** Where the custom component registers its push view inside HA. */
5211
+ var PUSH_PATH = "/api/camstack/push";
5212
+ var MAX_BODY_CHARS = 200;
5213
+ /** A non-2xx answer from Home Assistant's push view. */
5214
+ var PushHttpError = class extends Error {
5215
+ status;
5216
+ body;
5217
+ constructor(status, body) {
5218
+ super(`Home Assistant answered ${String(status)}`);
5219
+ this.status = status;
5220
+ this.body = body;
5221
+ this.name = "PushHttpError";
5222
+ }
5223
+ };
5224
+ /** First 200 chars of a response body, with any bearer token removed. */
5225
+ function scrubBody(body) {
5226
+ return body.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]").slice(0, MAX_BODY_CHARS);
5227
+ }
5228
+ var NETWORK_CODES = new Set([
5229
+ "ECONNREFUSED",
5230
+ "ENOTFOUND",
5231
+ "ETIMEDOUT",
5232
+ "ECONNRESET",
5233
+ "EHOSTUNREACH",
5234
+ "ENETUNREACH",
5235
+ "EAI_AGAIN",
5236
+ "UND_ERR_CONNECT_TIMEOUT"
5237
+ ]);
5238
+ function networkCode(err) {
5239
+ for (let current = err, depth = 0; current instanceof Error && depth < 4; depth += 1) {
5240
+ const code = Reflect.get(current, "code");
5241
+ if (typeof code === "string" && NETWORK_CODES.has(code)) return code;
5242
+ current = current.cause;
5243
+ }
5244
+ }
5245
+ function describePushFailure(err) {
5246
+ if (err instanceof PushHttpError) {
5247
+ const http = `HTTP ${String(err.status)}`;
5248
+ if (err.status === 404) return {
5249
+ kind: "component-missing",
5250
+ permanent: true,
5251
+ text: `${http} — Home Assistant has no CamStack integration installed (the ${PUSH_PATH} endpoint is missing). Install it from HACS, or turn off Home Assistant export`
5252
+ };
5253
+ if (err.status === 401 || err.status === 403) return {
5254
+ kind: "token-rejected",
5255
+ permanent: false,
5256
+ text: `${http} — Home Assistant rejected the access token (revoked, or the user lacks rights); create a new long-lived token`
5257
+ };
5258
+ if (err.status === 413) return {
5259
+ kind: "too-large",
5260
+ permanent: false,
5261
+ text: `${http} — the batch is too large for Home Assistant or a proxy in front of it`
5262
+ };
5263
+ const body = scrubBody(err.body);
5264
+ return {
5265
+ kind: "other",
5266
+ permanent: false,
5267
+ text: body.length > 0 ? `${http} — ${body}` : http
5268
+ };
5269
+ }
5270
+ const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
5271
+ const code = networkCode(err);
5272
+ const fetchFailed = err instanceof TypeError && err.message === "fetch failed";
5273
+ if (timedOut || code !== void 0 || fetchFailed) return {
5274
+ kind: "unreachable",
5275
+ permanent: false,
5276
+ text: `Home Assistant is unreachable (${timedOut ? "timed out" : code ?? "network error"}) — check the address and that it is running`
5277
+ };
5278
+ return {
5279
+ kind: "other",
5280
+ permanent: false,
5281
+ text: err instanceof Error ? err.message : String(err)
5282
+ };
5283
+ }
5284
+ /**
5285
+ * POST one message to Home Assistant's push view.
5286
+ *
5287
+ * Authenticated with the broker's OWN long-lived token: the component
5288
+ * registers its view behind HA auth, so there is no second shared secret
5289
+ * to configure or rotate. A non-2xx throws `PushHttpError` carrying the first
5290
+ * 200 chars of the body (never the request headers), which is how the push
5291
+ * client learns why the instance is not taking the push.
5292
+ */
5293
+ async function postToHomeAssistant(baseUrl, token, message) {
5294
+ const response = await fetch(`${baseUrl.replace(/\/+$/, "")}${PUSH_PATH}`, {
5295
+ method: "POST",
5296
+ headers: {
5297
+ authorization: `Bearer ${token}`,
5298
+ "content-type": "application/json"
5299
+ },
5300
+ body: JSON.stringify(message),
5301
+ signal: AbortSignal.timeout(1e4)
5302
+ });
5303
+ if (!response.ok) {
5304
+ const body = await response.text().catch(() => "");
5305
+ throw new PushHttpError(response.status, scrubBody(body));
5306
+ }
5307
+ }
5308
+ //#endregion
5216
5309
  //#region src/ha-export/push-client.ts
5217
5310
  /** How long state updates accumulate before one POST carries them all. */
5218
5311
  var BATCH_FLUSH_MS = 1e3;
5219
5312
  /** Availability cadence. The component's timeout is a multiple of this. */
5220
5313
  var HEARTBEAT_INTERVAL_MS = 3e4;
5221
5314
  var IMAGE_SIGNAL_PREFIX = "__image_updated__:";
5315
+ /**
5316
+ * How long a permanent failure (a missing component) is left alone before the
5317
+ * next probe. A 404 does not heal by retrying; it heals when the operator
5318
+ * installs the component, which the settings-change reset picks up at once.
5319
+ */
5320
+ var COMPONENT_RECHECK_MS = 10 * 6e4;
5222
5321
  /** Rate limit for the "HA is not answering" warning. */
5223
5322
  var DROP_LOG_INTERVAL_MS = 3e4;
5224
5323
  var PushClient = class {
@@ -5235,6 +5334,8 @@ var PushClient = class {
5235
5334
  dropped = 0;
5236
5335
  droppedLoggedAt = 0;
5237
5336
  disposed = false;
5337
+ lastFailure = null;
5338
+ suspendedUntil = 0;
5238
5339
  constructor(options) {
5239
5340
  this.transport = options.transport;
5240
5341
  this.logger = options.logger;
@@ -5245,6 +5346,18 @@ var PushClient = class {
5245
5346
  get healthy() {
5246
5347
  return this.available;
5247
5348
  }
5349
+ /** Why the last push failed; `null` while the link is healthy. */
5350
+ get failure() {
5351
+ return this.lastFailure;
5352
+ }
5353
+ /** A permanent failure is being left alone until the next probe. */
5354
+ get suspended() {
5355
+ return this.suspendedUntil > Date.now();
5356
+ }
5357
+ /** Probe again at once — the operator changed something. */
5358
+ resetBackoff() {
5359
+ this.suspendedUntil = 0;
5360
+ }
5248
5361
  /** Messages discarded because HA did not answer. Never silent. */
5249
5362
  get droppedCount() {
5250
5363
  return this.dropped;
@@ -5320,11 +5433,29 @@ var PushClient = class {
5320
5433
  }
5321
5434
  /** `true` when Home Assistant took the message. */
5322
5435
  async send(message) {
5436
+ if (this.suspended) {
5437
+ this.dropped += message.type === "batch" ? message.items.length : 1;
5438
+ return false;
5439
+ }
5323
5440
  try {
5324
5441
  await this.transport(message);
5325
5442
  this.onReachable();
5326
5443
  return true;
5327
5444
  } catch (err) {
5445
+ if (err instanceof PushHttpError && err.status === 413 && message.type === "batch") {
5446
+ const half = Math.ceil(message.items.length / 2);
5447
+ if (message.items.length > 1) {
5448
+ const first = await this.send({
5449
+ type: "batch",
5450
+ items: message.items.slice(0, half)
5451
+ });
5452
+ const second = await this.send({
5453
+ type: "batch",
5454
+ items: message.items.slice(half)
5455
+ });
5456
+ return first && second;
5457
+ }
5458
+ }
5328
5459
  this.onUnreachable(message, err);
5329
5460
  return false;
5330
5461
  }
@@ -5332,6 +5463,8 @@ var PushClient = class {
5332
5463
  onReachable() {
5333
5464
  if (this.available) return;
5334
5465
  this.available = true;
5466
+ this.lastFailure = null;
5467
+ this.suspendedUntil = 0;
5335
5468
  /**
5336
5469
  * The link came back. Drop the dedup cache so the reconcile that
5337
5470
  * follows re-pushes every value: HA may have restarted and lost its
@@ -5369,14 +5502,18 @@ var PushClient = class {
5369
5502
  */
5370
5503
  onUnreachable(message, err) {
5371
5504
  this.available = false;
5505
+ const failure = describePushFailure(err);
5506
+ this.lastFailure = failure;
5507
+ if (failure.permanent) this.suspendedUntil = Date.now() + COMPONENT_RECHECK_MS;
5372
5508
  this.dropped += message.type === "batch" ? message.items.length : 1;
5373
5509
  const now = Date.now();
5374
5510
  if (now - this.droppedLoggedAt < DROP_LOG_INTERVAL_MS) return;
5375
5511
  this.droppedLoggedAt = now;
5376
- this.logger.warn("ha-export: push to Home Assistant failed, messages dropped", { meta: {
5512
+ this.logger.warn(`ha-export: push to Home Assistant failed (${failure.text}), messages dropped`, { meta: {
5377
5513
  brokerId: this.brokerId,
5378
5514
  dropped: this.dropped,
5379
5515
  messageType: message.type,
5516
+ failureKind: failure.kind,
5380
5517
  error: err instanceof Error ? err.message : String(err)
5381
5518
  } });
5382
5519
  }
@@ -6942,8 +7079,6 @@ var SUPPORTED_DEVICE_KINDS = [
6942
7079
  * one switch per broker over the single membership store.
6943
7080
  */
6944
7081
  var ADDON_ID = "homeassistant-export";
6945
- /** Where the custom component registers its push view inside HA. */
6946
- var PUSH_PATH = "/api/camstack/push";
6947
7082
  var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
6948
7083
  /** How long `doorbell_pressed` stays on before its OFF edge is published. */
6949
7084
  var DOORBELL_PULSE_MS = 5e3;
@@ -7166,6 +7301,7 @@ var HaExportAddon = class extends BaseAddon {
7166
7301
  ];
7167
7302
  }
7168
7303
  async onConfigChanged() {
7304
+ for (const link of this.links.values()) link.client.resetBackoff();
7169
7305
  await this.reconcile("config-changed");
7170
7306
  this.startTimer();
7171
7307
  }
@@ -7848,7 +7984,9 @@ var HaExportAddon = class extends BaseAddon {
7848
7984
  const delivered = await this.announce(exported, fullResync);
7849
7985
  await this.pushFullState(exported, snapshots);
7850
7986
  if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
7851
- if (!delivered || [...this.links.values()].some((link) => !link.client.healthy)) {
7987
+ const failing = [...this.links.values()].filter((link) => !link.client.healthy && !link.client.suspended);
7988
+ const anyLive = [...this.links.values()].some((link) => !link.client.suspended);
7989
+ if (failing.length > 0 || !delivered && anyLive) {
7852
7990
  this.resync.markGap("push-not-delivered");
7853
7991
  this.lastStructureFingerprint = void 0;
7854
7992
  this.ctx.logger.warn("ha-export: a push was not delivered, the next pass is a full resync");
@@ -9190,7 +9328,7 @@ var HaExportAddon = class extends BaseAddon {
9190
9328
  id: "ha-export-brokers",
9191
9329
  title: "Home Assistant instances",
9192
9330
  titleKey: "provider-homeassistant:globalSettings.s_ha-export-brokers.title",
9193
- description: brokerFields.length > 0 ? "An instance that is off receives nothing; its exported devices resume when it is turned back on." : "No Home Assistant broker is registered yet.",
9331
+ description: brokerFields.length > 0 ? "An instance that is off receives nothing; its exported devices resume when it is turned back on. Export needs the CamStack custom component installed in Home Assistant (HACS)." : "No Home Assistant broker is registered yet.",
9194
9332
  columns: 1,
9195
9333
  fields: brokerFields.length > 0 ? brokerFields : [{
9196
9334
  type: "info",
@@ -9324,16 +9462,21 @@ var HaExportAddon = class extends BaseAddon {
9324
9462
  buildStatus() {
9325
9463
  const enabled = this.enabledBrokerIds();
9326
9464
  const healthy = [...this.links.values()].filter((link) => link.client.healthy).length;
9465
+ const linkFailure = [...this.links.values()].map((link) => link.client.failure).filter((failure) => failure !== null)[0]?.text;
9327
9466
  return {
9328
9467
  linkState: enabled.length === 0 ? "unlinked" : healthy === this.links.size ? "linked" : "error",
9329
9468
  exposedDeviceCount: exposedDeviceIds(this.config.membership, enabled).length,
9330
- ...this.lastError !== void 0 ? { error: this.lastError } : {},
9469
+ ...linkFailure !== void 0 ? { error: linkFailure } : this.lastError !== void 0 ? { error: this.lastError } : {},
9331
9470
  setup: {
9332
9471
  fields: [
9333
9472
  {
9334
9473
  label: "Home Assistant instances",
9335
9474
  value: `${healthy} / ${this.links.size} reachable`
9336
9475
  },
9476
+ {
9477
+ label: "Last push failure",
9478
+ value: linkFailure ?? "none"
9479
+ },
9337
9480
  {
9338
9481
  label: "Entities pushed",
9339
9482
  value: String(this.entityCount)
@@ -9368,26 +9511,6 @@ var HaExportAddon = class extends BaseAddon {
9368
9511
  * CAPABILITIES, not its type, and restricting the picker by type is what
9369
9512
  * confined the previous exporter to cameras.
9370
9513
  */
9371
- /**
9372
- * POST one message to Home Assistant's push view.
9373
- *
9374
- * Authenticated with the broker's OWN long-lived token: the component
9375
- * registers its view behind HA auth, so there is no second shared secret
9376
- * to configure or rotate. A non-2xx throws, which is how the push client
9377
- * learns the instance is unreachable.
9378
- */
9379
- async function postToHomeAssistant(baseUrl, token, message) {
9380
- const response = await fetch(`${baseUrl.replace(/\/+$/, "")}${PUSH_PATH}`, {
9381
- method: "POST",
9382
- headers: {
9383
- authorization: `Bearer ${token}`,
9384
- "content-type": "application/json"
9385
- },
9386
- body: JSON.stringify(message),
9387
- signal: AbortSignal.timeout(1e4)
9388
- });
9389
- if (!response.ok) throw new Error(`Home Assistant answered ${response.status}`);
9390
- }
9391
9514
  function randomSecret() {
9392
9515
  const bytes = new Uint8Array(32);
9393
9516
  crypto.getRandomValues(bytes);
package/i18n/en.json CHANGED
@@ -22,7 +22,7 @@
22
22
  "contribution.s_ha-export.title": "Home Assistant",
23
23
  "haConfig.s_connection.title": "Home Assistant notify",
24
24
  "haConfig.s_connection.service.label": "Notify service",
25
- "haConfig.s_connection.service.description": "The part after `notify.` (e.g. `mobile_app_pixel_7`); use Discover to list them.",
25
+ "haConfig.s_connection.service.description": "Loaded from the broker once it is picked. If the list does not load, type the part after `notify.`.",
26
26
  "haConfig.s_connection.brokerId.label": "Broker",
27
- "haConfig.s_connection.brokerId.description": "Optional; blank uses the only configured broker."
27
+ "haConfig.s_connection.brokerId.description": "The Home Assistant connection the notification goes through."
28
28
  }
package/i18n/it.json CHANGED
@@ -22,7 +22,7 @@
22
22
  "contribution.s_ha-export.title": "Home Assistant",
23
23
  "haConfig.s_connection.title": "Notifica Home Assistant",
24
24
  "haConfig.s_connection.service.label": "Servizio di notifica",
25
- "haConfig.s_connection.service.description": "La parte dopo `notify.` (es. `mobile_app_pixel_7`); usa Discover per elencarli.",
25
+ "haConfig.s_connection.service.description": "Caricato dal broker una volta scelto. Se l'elenco non si carica, scrivi la parte dopo `notify.`.",
26
26
  "haConfig.s_connection.brokerId.label": "Broker",
27
- "haConfig.s_connection.brokerId.description": "Facoltativo; se vuoto usa l'unico broker configurato."
27
+ "haConfig.s_connection.brokerId.description": "La connessione Home Assistant attraverso cui passa la notifica."
28
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homeassistant",
3
- "version": "1.2.639",
3
+ "version": "1.2.640",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",