@camstack/addon-provider-homeassistant 1.2.650 → 1.2.652

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.
@@ -24,7 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  enumerable: true
25
25
  }) : target, mod));
26
26
  //#endregion
27
- const require_ha_broker_source = require("../ha-broker-source-DUj9pUWr.js");
27
+ const require_ha_broker_source = require("../ha-broker-source-D3ScsO0T.js");
28
28
  let node_crypto = require("node:crypto");
29
29
  require("node:zlib");
30
30
  let node_fs = require("node:fs");
@@ -3522,7 +3522,11 @@ var CAPS_NOT_EXPORTED = [
3522
3522
  },
3523
3523
  {
3524
3524
  cap: "pet-fountain",
3525
- reason: "A fountain is BUILT here from the loose entities Home Assistant already holds (a pump switch, two number boxes, a button), so exporting it back would publish a second copy of entities that already exist there. A dedicated mapping can follow when an ecosystem asks for the built device."
3525
+ reason: "A fountain is BUILT here from the loose entities Home Assistant already holds (a pump switch, a button, sensors), so exporting it back would publish a second copy of entities that already exist there. A dedicated mapping can follow when an ecosystem asks for the built device."
3526
+ },
3527
+ {
3528
+ cap: "pump-cycle",
3529
+ reason: "The pump duty cycle of a fountain BUILT here from a cycle switch and two number boxes Home Assistant already holds, so exporting it back would publish a second copy of entities that already exist there."
3526
3530
  },
3527
3531
  {
3528
3532
  cap: "ir-transmitter",
@@ -5491,6 +5495,10 @@ var PushClient = class {
5491
5495
  disposed = false;
5492
5496
  lastFailure = null;
5493
5497
  suspendedUntil = 0;
5498
+ /** When the current unbroken streak of 404s began; 0 when there is none. */
5499
+ first404At = 0;
5500
+ /** A suspension was entered and not yet logged as left. */
5501
+ suspensionLogged = false;
5494
5502
  constructor(options) {
5495
5503
  this.transport = options.transport;
5496
5504
  this.logger = options.logger;
@@ -5509,9 +5517,20 @@ var PushClient = class {
5509
5517
  get suspended() {
5510
5518
  return this.suspendedUntil > Date.now();
5511
5519
  }
5520
+ /** Epoch ms of the next probe while suspended; 0 otherwise. */
5521
+ get recheckAt() {
5522
+ return this.suspended ? this.suspendedUntil : 0;
5523
+ }
5512
5524
  /** Probe again at once — the operator changed something. */
5513
5525
  resetBackoff() {
5514
5526
  this.suspendedUntil = 0;
5527
+ this.first404At = 0;
5528
+ this.logSuspensionEnded("reset by a settings change");
5529
+ }
5530
+ logSuspensionEnded(why) {
5531
+ if (!this.suspensionLogged) return;
5532
+ this.suspensionLogged = false;
5533
+ this.logger.info(`ha-export: push link no longer suspended (${why}), probing again`, { meta: { brokerId: this.brokerId } });
5515
5534
  }
5516
5535
  /** Messages discarded because HA did not answer. Never silent. */
5517
5536
  get droppedCount() {
@@ -5592,8 +5611,13 @@ var PushClient = class {
5592
5611
  this.dropped += message.type === "batch" ? message.items.length : 1;
5593
5612
  return false;
5594
5613
  }
5614
+ if (this.suspendedUntil !== 0 && !this.suspended) {
5615
+ this.suspendedUntil = 0;
5616
+ this.logSuspensionEnded("recheck time reached");
5617
+ }
5595
5618
  try {
5596
5619
  await this.transport(message);
5620
+ this.first404At = 0;
5597
5621
  this.onReachable();
5598
5622
  return true;
5599
5623
  } catch (err) {
@@ -5620,6 +5644,7 @@ var PushClient = class {
5620
5644
  this.available = true;
5621
5645
  this.lastFailure = null;
5622
5646
  this.suspendedUntil = 0;
5647
+ this.logSuspensionEnded("Home Assistant answered");
5623
5648
  /**
5624
5649
  * The link came back. Drop the dedup cache so the reconcile that
5625
5650
  * follows re-pushes every value: HA may have restarted and lost its
@@ -5657,11 +5682,29 @@ var PushClient = class {
5657
5682
  */
5658
5683
  onUnreachable(message, err) {
5659
5684
  this.available = false;
5660
- const failure = describePushFailure(err);
5685
+ const now = Date.now();
5686
+ const described = describePushFailure(err);
5687
+ let failure = described;
5688
+ if (described.kind === "component-missing") {
5689
+ if (this.first404At === 0) this.first404At = now;
5690
+ if (now - this.first404At < 6e4) failure = {
5691
+ kind: "other",
5692
+ permanent: false,
5693
+ text: "HTTP 404 — Home Assistant has not registered the CamStack endpoint (yet); retrying"
5694
+ };
5695
+ } else this.first404At = 0;
5661
5696
  this.lastFailure = failure;
5662
- if (failure.permanent) this.suspendedUntil = Date.now() + COMPONENT_RECHECK_MS;
5663
5697
  this.dropped += message.type === "batch" ? message.items.length : 1;
5664
- const now = Date.now();
5698
+ if (failure.permanent) {
5699
+ this.suspendedUntil = now + COMPONENT_RECHECK_MS;
5700
+ this.suspensionLogged = true;
5701
+ this.logger.warn(`ha-export: push link SUSPENDED (${failure.text}); next probe at ${new Date(this.suspendedUntil).toISOString()}`, { meta: {
5702
+ brokerId: this.brokerId,
5703
+ failureKind: failure.kind,
5704
+ recheckAt: this.suspendedUntil,
5705
+ recheckInMs: COMPONENT_RECHECK_MS
5706
+ } });
5707
+ }
5665
5708
  if (now - this.droppedLoggedAt < DROP_LOG_INTERVAL_MS) return;
5666
5709
  this.droppedLoggedAt = now;
5667
5710
  this.logger.warn(`ha-export: push to Home Assistant failed (${failure.text}), messages dropped`, { meta: {
@@ -5673,6 +5716,19 @@ var PushClient = class {
5673
5716
  } });
5674
5717
  }
5675
5718
  };
5719
+ //#endregion
5720
+ //#region src/ha-export/link-gaps.ts
5721
+ /** A parked link is reported, never silently excluded. */
5722
+ function summariseLinkGaps(links) {
5723
+ return {
5724
+ failing: links.filter((l) => !l.healthy && !l.suspended).map((l) => l.brokerId),
5725
+ suspended: links.filter((l) => l.suspended).map((l) => ({
5726
+ brokerId: l.brokerId,
5727
+ recheckAt: l.recheckAt
5728
+ })),
5729
+ anyLive: links.some((l) => !l.suspended)
5730
+ };
5731
+ }
5676
5732
  var ReachabilityReconciler = class {
5677
5733
  options;
5678
5734
  minIntervalMs;
@@ -8144,13 +8200,21 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
8144
8200
  const delivered = await this.announce(exported, fullResync);
8145
8201
  await this.pushFullState(exported, snapshots);
8146
8202
  if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
8147
- const failing = [...this.links.values()].filter((link) => !link.client.healthy && !link.client.suspended);
8148
- const anyLive = [...this.links.values()].some((link) => !link.client.suspended);
8149
- if (failing.length > 0 || !delivered && anyLive) {
8203
+ const gaps = summariseLinkGaps([...this.links.entries()].map(([brokerId, link]) => ({
8204
+ brokerId,
8205
+ healthy: link.client.healthy,
8206
+ suspended: link.client.suspended,
8207
+ recheckAt: link.client.recheckAt
8208
+ })));
8209
+ if (gaps.failing.length > 0 || !delivered && gaps.anyLive) {
8150
8210
  this.resync.markGap("push-not-delivered");
8151
8211
  this.lastStructureFingerprint = void 0;
8152
8212
  this.ctx.logger.warn("ha-export: a push was not delivered, the next pass is a full resync");
8153
8213
  }
8214
+ if (gaps.suspended.length > 0) this.ctx.logger.warn("ha-export: push links suspended, Home Assistant receives nothing", { meta: { suspended: gaps.suspended.map((s) => ({
8215
+ brokerId: s.brokerId,
8216
+ recheckAt: new Date(s.recheckAt).toISOString()
8217
+ })) } });
8154
8218
  this.ctx.logger.info("ha-export: reconciled", { meta: {
8155
8219
  reason,
8156
8220
  fullResync,
@@ -8158,6 +8222,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
8158
8222
  devices: exported.size,
8159
8223
  entities: entityCount,
8160
8224
  unclassifiedCaps: this.unclassified.length,
8225
+ suspendedLinks: gaps.suspended.length,
8161
8226
  durationMs: Date.now() - startedAt
8162
8227
  } });
8163
8228
  if (this.unclassified.length > 0) this.ctx.logger.warn("ha-export: capabilities in neither the map nor the exclusion list", { meta: { caps: this.unclassified } });
@@ -1,4 +1,4 @@
1
- import { B as consumableKindOf, Bt as BaseAddon, Dt as sendToExportedTarget, Ht as baseUnitOf, Jt as isReadingQuantity, K as deviceExportCapability, Wt as defineAddonMigrations, Yt as nodePin, Zt as array, _ as PLUG_APPLIANCE_TYPES, _t as pruneDeletedEntities, c as COCO_TO_MACRO, d as ConsumableItemSchema, f as ConsumableStatusSchema, fn as EventCategory, g as MediaPlayerRepeatSchema, h as HvacModeSchema, i as AlarmArmModeSchema, in as string, j as buildAddonRouteProvider, l as CameraSwitchIdSchema, m as FanDirectionSchema, nn as object, on as unknown, pt as preconditionOf, r as listHomeAssistantBrokers, s as CAMERA_SWITCH_ORDER, u as ConsumableFormSchema, ut as oauthIntegrationCapability, x as addonRoutesCapability, xt as runExportedAction, z as consumableFormOf } from "../ha-broker-source-B2v2dnPe.mjs";
1
+ import { B as consumableFormOf, Gt as defineAddonMigrations, M as buildAddonRouteProvider, Ot as sendToExportedTarget, Qt as array, St as runExportedAction, Ut as baseUnitOf, V as consumableKindOf, Vt as BaseAddon, Xt as nodePin, Yt as isReadingQuantity, _ as PLUG_APPLIANCE_TYPES, an as string, c as COCO_TO_MACRO, d as ConsumableItemSchema, dt as oauthIntegrationCapability, f as ConsumableStatusSchema, g as MediaPlayerRepeatSchema, h as HvacModeSchema, i as AlarmArmModeSchema, l as CameraSwitchIdSchema, m as FanDirectionSchema, mt as preconditionOf, pn as EventCategory, q as deviceExportCapability, r as listHomeAssistantBrokers, rn as object, s as CAMERA_SWITCH_ORDER, sn as unknown, u as ConsumableFormSchema, vt as pruneDeletedEntities, x as addonRoutesCapability } from "../ha-broker-source-CBWl6Wat.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  import "node:zlib";
4
4
  import "node:fs";
@@ -3487,7 +3487,11 @@ var CAPS_NOT_EXPORTED = [
3487
3487
  },
3488
3488
  {
3489
3489
  cap: "pet-fountain",
3490
- reason: "A fountain is BUILT here from the loose entities Home Assistant already holds (a pump switch, two number boxes, a button), so exporting it back would publish a second copy of entities that already exist there. A dedicated mapping can follow when an ecosystem asks for the built device."
3490
+ reason: "A fountain is BUILT here from the loose entities Home Assistant already holds (a pump switch, a button, sensors), so exporting it back would publish a second copy of entities that already exist there. A dedicated mapping can follow when an ecosystem asks for the built device."
3491
+ },
3492
+ {
3493
+ cap: "pump-cycle",
3494
+ reason: "The pump duty cycle of a fountain BUILT here from a cycle switch and two number boxes Home Assistant already holds, so exporting it back would publish a second copy of entities that already exist there."
3491
3495
  },
3492
3496
  {
3493
3497
  cap: "ir-transmitter",
@@ -5456,6 +5460,10 @@ var PushClient = class {
5456
5460
  disposed = false;
5457
5461
  lastFailure = null;
5458
5462
  suspendedUntil = 0;
5463
+ /** When the current unbroken streak of 404s began; 0 when there is none. */
5464
+ first404At = 0;
5465
+ /** A suspension was entered and not yet logged as left. */
5466
+ suspensionLogged = false;
5459
5467
  constructor(options) {
5460
5468
  this.transport = options.transport;
5461
5469
  this.logger = options.logger;
@@ -5474,9 +5482,20 @@ var PushClient = class {
5474
5482
  get suspended() {
5475
5483
  return this.suspendedUntil > Date.now();
5476
5484
  }
5485
+ /** Epoch ms of the next probe while suspended; 0 otherwise. */
5486
+ get recheckAt() {
5487
+ return this.suspended ? this.suspendedUntil : 0;
5488
+ }
5477
5489
  /** Probe again at once — the operator changed something. */
5478
5490
  resetBackoff() {
5479
5491
  this.suspendedUntil = 0;
5492
+ this.first404At = 0;
5493
+ this.logSuspensionEnded("reset by a settings change");
5494
+ }
5495
+ logSuspensionEnded(why) {
5496
+ if (!this.suspensionLogged) return;
5497
+ this.suspensionLogged = false;
5498
+ this.logger.info(`ha-export: push link no longer suspended (${why}), probing again`, { meta: { brokerId: this.brokerId } });
5480
5499
  }
5481
5500
  /** Messages discarded because HA did not answer. Never silent. */
5482
5501
  get droppedCount() {
@@ -5557,8 +5576,13 @@ var PushClient = class {
5557
5576
  this.dropped += message.type === "batch" ? message.items.length : 1;
5558
5577
  return false;
5559
5578
  }
5579
+ if (this.suspendedUntil !== 0 && !this.suspended) {
5580
+ this.suspendedUntil = 0;
5581
+ this.logSuspensionEnded("recheck time reached");
5582
+ }
5560
5583
  try {
5561
5584
  await this.transport(message);
5585
+ this.first404At = 0;
5562
5586
  this.onReachable();
5563
5587
  return true;
5564
5588
  } catch (err) {
@@ -5585,6 +5609,7 @@ var PushClient = class {
5585
5609
  this.available = true;
5586
5610
  this.lastFailure = null;
5587
5611
  this.suspendedUntil = 0;
5612
+ this.logSuspensionEnded("Home Assistant answered");
5588
5613
  /**
5589
5614
  * The link came back. Drop the dedup cache so the reconcile that
5590
5615
  * follows re-pushes every value: HA may have restarted and lost its
@@ -5622,11 +5647,29 @@ var PushClient = class {
5622
5647
  */
5623
5648
  onUnreachable(message, err) {
5624
5649
  this.available = false;
5625
- const failure = describePushFailure(err);
5650
+ const now = Date.now();
5651
+ const described = describePushFailure(err);
5652
+ let failure = described;
5653
+ if (described.kind === "component-missing") {
5654
+ if (this.first404At === 0) this.first404At = now;
5655
+ if (now - this.first404At < 6e4) failure = {
5656
+ kind: "other",
5657
+ permanent: false,
5658
+ text: "HTTP 404 — Home Assistant has not registered the CamStack endpoint (yet); retrying"
5659
+ };
5660
+ } else this.first404At = 0;
5626
5661
  this.lastFailure = failure;
5627
- if (failure.permanent) this.suspendedUntil = Date.now() + COMPONENT_RECHECK_MS;
5628
5662
  this.dropped += message.type === "batch" ? message.items.length : 1;
5629
- const now = Date.now();
5663
+ if (failure.permanent) {
5664
+ this.suspendedUntil = now + COMPONENT_RECHECK_MS;
5665
+ this.suspensionLogged = true;
5666
+ this.logger.warn(`ha-export: push link SUSPENDED (${failure.text}); next probe at ${new Date(this.suspendedUntil).toISOString()}`, { meta: {
5667
+ brokerId: this.brokerId,
5668
+ failureKind: failure.kind,
5669
+ recheckAt: this.suspendedUntil,
5670
+ recheckInMs: COMPONENT_RECHECK_MS
5671
+ } });
5672
+ }
5630
5673
  if (now - this.droppedLoggedAt < DROP_LOG_INTERVAL_MS) return;
5631
5674
  this.droppedLoggedAt = now;
5632
5675
  this.logger.warn(`ha-export: push to Home Assistant failed (${failure.text}), messages dropped`, { meta: {
@@ -5638,6 +5681,19 @@ var PushClient = class {
5638
5681
  } });
5639
5682
  }
5640
5683
  };
5684
+ //#endregion
5685
+ //#region src/ha-export/link-gaps.ts
5686
+ /** A parked link is reported, never silently excluded. */
5687
+ function summariseLinkGaps(links) {
5688
+ return {
5689
+ failing: links.filter((l) => !l.healthy && !l.suspended).map((l) => l.brokerId),
5690
+ suspended: links.filter((l) => l.suspended).map((l) => ({
5691
+ brokerId: l.brokerId,
5692
+ recheckAt: l.recheckAt
5693
+ })),
5694
+ anyLive: links.some((l) => !l.suspended)
5695
+ };
5696
+ }
5641
5697
  var ReachabilityReconciler = class {
5642
5698
  options;
5643
5699
  minIntervalMs;
@@ -8109,13 +8165,21 @@ var HaExportAddon = class extends BaseAddon {
8109
8165
  const delivered = await this.announce(exported, fullResync);
8110
8166
  await this.pushFullState(exported, snapshots);
8111
8167
  if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
8112
- const failing = [...this.links.values()].filter((link) => !link.client.healthy && !link.client.suspended);
8113
- const anyLive = [...this.links.values()].some((link) => !link.client.suspended);
8114
- if (failing.length > 0 || !delivered && anyLive) {
8168
+ const gaps = summariseLinkGaps([...this.links.entries()].map(([brokerId, link]) => ({
8169
+ brokerId,
8170
+ healthy: link.client.healthy,
8171
+ suspended: link.client.suspended,
8172
+ recheckAt: link.client.recheckAt
8173
+ })));
8174
+ if (gaps.failing.length > 0 || !delivered && gaps.anyLive) {
8115
8175
  this.resync.markGap("push-not-delivered");
8116
8176
  this.lastStructureFingerprint = void 0;
8117
8177
  this.ctx.logger.warn("ha-export: a push was not delivered, the next pass is a full resync");
8118
8178
  }
8179
+ if (gaps.suspended.length > 0) this.ctx.logger.warn("ha-export: push links suspended, Home Assistant receives nothing", { meta: { suspended: gaps.suspended.map((s) => ({
8180
+ brokerId: s.brokerId,
8181
+ recheckAt: new Date(s.recheckAt).toISOString()
8182
+ })) } });
8119
8183
  this.ctx.logger.info("ha-export: reconciled", { meta: {
8120
8184
  reason,
8121
8185
  fullResync,
@@ -8123,6 +8187,7 @@ var HaExportAddon = class extends BaseAddon {
8123
8187
  devices: exported.size,
8124
8188
  entities: entityCount,
8125
8189
  unclassifiedCaps: this.unclassified.length,
8190
+ suspendedLinks: gaps.suspended.length,
8126
8191
  durationMs: Date.now() - startedAt
8127
8192
  } });
8128
8193
  if (this.unclassified.length > 0) this.ctx.logger.warn("ha-export: capabilities in neither the map nor the exclusion list", { meta: { caps: this.unclassified } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homeassistant",
3
- "version": "1.2.650",
3
+ "version": "1.2.652",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",
@@ -55,6 +55,9 @@
55
55
  {
56
56
  "name": "broker"
57
57
  },
58
+ {
59
+ "name": "board-key-source"
60
+ },
58
61
  {
59
62
  "name": "device-adoption"
60
63
  },
@@ -127,7 +130,7 @@
127
130
  "description": "Device provider (import) + native entity export (push). Two addons, one bundle, separate processes."
128
131
  },
129
132
  "requires": {
130
- "server": ">=1.2.740"
133
+ "server": ">=1.2.742"
131
134
  }
132
135
  },
133
136
  "files": [