@camstack/addon-provider-homeassistant 1.2.650 → 1.2.651

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.651] - 2026-11-02
11
+
12
+ ### Changed
13
+
14
+ - A Home Assistant restart no longer parks the link for 10 minutes: a 404 must persist for a minute before the component counts as missing, and a suspended link is now logged and reported on every reconcile
15
+
10
16
  ## [1.2.650] - 2026-11-02
11
17
 
12
18
  ### Changed
package/dist/addon.js CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_ha_broker_source = require("./ha-broker-source-DUj9pUWr.js");
2
+ const require_ha_broker_source = require("./ha-broker-source-D4_TtF2V.js");
3
3
  let node_crypto = require("node:crypto");
4
4
  let node_zlib = require("node:zlib");
5
5
  //#region src/ha-admin-link.ts
package/dist/addon.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as humiditySensorCapability, $t as discriminatedUnion, A as brokerCapability, At as tamperCapability, C as alarmPanelCapability, Ct as runningFromMediaPlayer, D as bestLocationMatch, E as batteryCapability, Et as scriptRunnerCapability, F as carbonMonoxideCapability, Ft as vacuumControlCapability, G as deviceAdoptionCapability, Gt as defineEntryKind, H as controlCapability, Ht as baseUnitOf, I as climateControlCapability, It as valveCapability, J as eventEmitterCapability, Kt as defineRecordKinds, L as colorCapability, Lt as vibrationCapability, M as buildDeviceAdminUrl, Mt as tryConvertDelta, N as buttonCapability, Nt as tryConvertUnit, O as binaryCapability, Ot as smokeCapability, P as calendarSourceCapability, Pt as updateCapability, Q as humidifierCapability, Qt as boolean, R as connectivityCapability, Rt as waterHeaterCapability, S as airQualitySensorCapability, St as runningFromLawnMower, T as automationControlCapability, Tt as runningStateCapability, U as coverCapability, Ut as createEvent, V as contactCapability, Vt as RecordSecretUnavailableError, W as deviceAdminLinkCapability, X as floodCapability, Xt as _enum, Y as fanControlCapability, Z as gasCapability, Zt as array, a as BaseDevice, an as union, at as motionCapability, b as accessoriesCapability, bt as resolveAttachmentUrls, cn as DeviceFeature, ct as notifierCapability, dn as errMsg, dt as parseAdminBase, en as literal, et as imageCapability, fn as EventCategory, ft as powerMeterCapability, gt as pressureSensorCapability, ht as presenceCapability, in as string, it as mediaPlayerCapability, j as buildAddonRouteProvider, jt as temperatureSensorCapability, k as brightnessCapability, kt as switchCapability, ln as DeviceRole, lt as numericSensorCapability, mt as prepareNotification, nn as object, nt as lawnMowerControlCapability, o as BaseDeviceProvider, on as unknown, ot as normalizeUnit, p as EnumSensorDateTimeFormatSchema, q as enumSensorCapability, qt as entryBody, rn as record, rt as lockControlCapability, sn as url, st as notificationOutputCapability, t as HA_BROKER_KIND, tn as number, tt as ingestReading, un as DeviceType, v as RUNNING_STATE_COLD, vt as publicEndpointReaderFrom, w as ambientLightSensorCapability, wt as runningFromVacuumState, x as addonRoutesCapability, y as TargetSchema, yt as quantityFromUnit, zt as weatherCapability } from "./ha-broker-source-B2v2dnPe.mjs";
1
+ import { $ as humiditySensorCapability, $t as discriminatedUnion, A as brokerCapability, At as tamperCapability, C as alarmPanelCapability, Ct as runningFromMediaPlayer, D as bestLocationMatch, E as batteryCapability, Et as scriptRunnerCapability, F as carbonMonoxideCapability, Ft as vacuumControlCapability, G as deviceAdoptionCapability, Gt as defineEntryKind, H as controlCapability, Ht as baseUnitOf, I as climateControlCapability, It as valveCapability, J as eventEmitterCapability, Kt as defineRecordKinds, L as colorCapability, Lt as vibrationCapability, M as buildDeviceAdminUrl, Mt as tryConvertDelta, N as buttonCapability, Nt as tryConvertUnit, O as binaryCapability, Ot as smokeCapability, P as calendarSourceCapability, Pt as updateCapability, Q as humidifierCapability, Qt as boolean, R as connectivityCapability, Rt as waterHeaterCapability, S as airQualitySensorCapability, St as runningFromLawnMower, T as automationControlCapability, Tt as runningStateCapability, U as coverCapability, Ut as createEvent, V as contactCapability, Vt as RecordSecretUnavailableError, W as deviceAdminLinkCapability, X as floodCapability, Xt as _enum, Y as fanControlCapability, Z as gasCapability, Zt as array, a as BaseDevice, an as union, at as motionCapability, b as accessoriesCapability, bt as resolveAttachmentUrls, cn as DeviceFeature, ct as notifierCapability, dn as errMsg, dt as parseAdminBase, en as literal, et as imageCapability, fn as EventCategory, ft as powerMeterCapability, gt as pressureSensorCapability, ht as presenceCapability, in as string, it as mediaPlayerCapability, j as buildAddonRouteProvider, jt as temperatureSensorCapability, k as brightnessCapability, kt as switchCapability, ln as DeviceRole, lt as numericSensorCapability, mt as prepareNotification, nn as object, nt as lawnMowerControlCapability, o as BaseDeviceProvider, on as unknown, ot as normalizeUnit, p as EnumSensorDateTimeFormatSchema, q as enumSensorCapability, qt as entryBody, rn as record, rt as lockControlCapability, sn as url, st as notificationOutputCapability, t as HA_BROKER_KIND, tn as number, tt as ingestReading, un as DeviceType, v as RUNNING_STATE_COLD, vt as publicEndpointReaderFrom, w as ambientLightSensorCapability, wt as runningFromVacuumState, x as addonRoutesCapability, y as TargetSchema, yt as quantityFromUnit, zt as weatherCapability } from "./ha-broker-source-CCmZ9I6P.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { brotliCompressSync, deflateSync, gzipSync } from "node:zlib";
4
4
  //#region src/ha-admin-link.ts
@@ -12974,6 +12974,73 @@ var NodeColorIdSchema = _enum([
12974
12974
  "node-g",
12975
12975
  "node-h"
12976
12976
  ]);
12977
+ //#endregion
12978
+ //#region ../types/dist/index.mjs
12979
+ var SMTP_INBOUND_DEFAULT_PORT = 2525;
12980
+ var SMTP_INBOUND_DEFAULT_DOMAIN = "camstack.local";
12981
+ var SMTP_INBOUND_DEFAULT_MAX_MESSAGE_BYTES = 25 * 1024 * 1024;
12982
+ /** The text excerpt a message carries on the bus. */
12983
+ var SMTP_INBOUND_TEXT_MAX_CHARS = 4096;
12984
+ var PortSchema = number().int().min(1).max(65535);
12985
+ /** A recipient local-part: what stands before the `@`. */
12986
+ var SmtpRecipientSchema = string().min(1).max(64).regex(/^[A-Za-z0-9._+-]+$/);
12987
+ object({
12988
+ port: PortSchema.default(SMTP_INBOUND_DEFAULT_PORT),
12989
+ bindHost: string().min(1).default("0.0.0.0"),
12990
+ domain: string().min(1).default(SMTP_INBOUND_DEFAULT_DOMAIN),
12991
+ requireAuth: boolean().default(true),
12992
+ username: string().default(""),
12993
+ password: string().default("").meta({ secret: true }),
12994
+ starttls: boolean().default(false),
12995
+ maxMessageBytes: number().int().positive().default(SMTP_INBOUND_DEFAULT_MAX_MESSAGE_BYTES)
12996
+ });
12997
+ object({
12998
+ host: string().min(1).nullable(),
12999
+ hostReason: string().nullable(),
13000
+ port: PortSchema,
13001
+ domain: string().min(1),
13002
+ requireAuth: boolean(),
13003
+ username: string(),
13004
+ password: string(),
13005
+ starttls: boolean()
13006
+ });
13007
+ object({
13008
+ recipients: array(SmtpRecipientSchema).min(1),
13009
+ /**
13010
+ * Who is subscribing. A second `subscribe` with the same id REPLACES that subscriber's
13011
+ * recipients (the way a consumer that restarted, or lost a device, declares its set again);
13012
+ * a recipient held by another subscriber is refused with `smtp-inbound/recipient-claimed`.
13013
+ * Absent, the subscriber is anonymous and unique.
13014
+ */
13015
+ subscriberId: string().min(1).max(64).optional()
13016
+ });
13017
+ var SmtpInboundAttachmentSchema = object({
13018
+ filename: string(),
13019
+ contentType: string(),
13020
+ bytes: number().int().nonnegative()
13021
+ });
13022
+ /** The payload of one `broker.message` (the key is the recipient local-part). */
13023
+ var SmtpInboundMessageSchema = object({
13024
+ /** Monotonic per mailbox; a gap tells a consumer it missed an event. */
13025
+ seq: number().int().positive(),
13026
+ messageId: string(),
13027
+ receivedAt: number().int().nonnegative(),
13028
+ from: string(),
13029
+ to: string(),
13030
+ subject: string(),
13031
+ text: string().max(SMTP_INBOUND_TEXT_MAX_CHARS),
13032
+ attachments: array(SmtpInboundAttachmentSchema)
13033
+ });
13034
+ object({
13035
+ /** The newest seq issued so far (0 before any mail). */
13036
+ lastSeq: number().int().nonnegative(),
13037
+ /** The oldest seq still retained; messages below it are gone. */
13038
+ oldestSeq: number().int().nonnegative(),
13039
+ messages: array(object({
13040
+ key: string(),
13041
+ message: SmtpInboundMessageSchema
13042
+ }))
13043
+ });
12977
13044
  new Set([
12978
13045
  "track",
12979
13046
  "face",
@@ -55900,6 +55967,11 @@ var IntegrationInstanceSchema = object({
55900
55967
  name: string(),
55901
55968
  /** The addon's own rule (an URL is present, a client id is present...). */
55902
55969
  configured: boolean(),
55970
+ /**
55971
+ * The instance's own on/off switch (D996): always present, derived from the record body
55972
+ * (`enabled !== false`, so a row written before the field existed is on).
55973
+ */
55974
+ enabled: boolean(),
55903
55975
  /** Values for the instance form; a secret is a `SecretFieldView`, never text. */
55904
55976
  formValues: record(string(), unknown()),
55905
55977
  status: IntegrationInstanceStatusSchema.optional()
@@ -55919,6 +55991,12 @@ method(_void(), IntegrationInstanceDescriptionSchema, { auth: "admin" }), method
55919
55991
  }), method(IntegrationInstancePatchSchema, object({ ok: literal(true) }), {
55920
55992
  kind: "mutation",
55921
55993
  auth: "admin"
55994
+ }), method(object({
55995
+ id: IntegrationInstanceIdSchema,
55996
+ enabled: boolean()
55997
+ }), object({ ok: literal(true) }), {
55998
+ kind: "mutation",
55999
+ auth: "admin"
55922
56000
  }), method(object({ id: IntegrationInstanceIdSchema }), object({ ok: literal(true) }), {
55923
56001
  kind: "mutation",
55924
56002
  auth: "admin"
@@ -85446,6 +85524,13 @@ Object.freeze({
85446
85524
  access: "view",
85447
85525
  role: "admin"
85448
85526
  },
85527
+ "integrationInstances.setInstanceEnabled": {
85528
+ capName: "integration-instances",
85529
+ capScope: "system",
85530
+ addonId: null,
85531
+ access: "create",
85532
+ role: "admin"
85533
+ },
85449
85534
  "integrationInstances.testInstance": {
85450
85535
  capName: "integration-instances",
85451
85536
  capScope: "system",
@@ -12975,6 +12975,73 @@ var NodeColorIdSchema = _enum([
12975
12975
  "node-g",
12976
12976
  "node-h"
12977
12977
  ]);
12978
+ //#endregion
12979
+ //#region ../types/dist/index.mjs
12980
+ var SMTP_INBOUND_DEFAULT_PORT = 2525;
12981
+ var SMTP_INBOUND_DEFAULT_DOMAIN = "camstack.local";
12982
+ var SMTP_INBOUND_DEFAULT_MAX_MESSAGE_BYTES = 25 * 1024 * 1024;
12983
+ /** The text excerpt a message carries on the bus. */
12984
+ var SMTP_INBOUND_TEXT_MAX_CHARS = 4096;
12985
+ var PortSchema = number().int().min(1).max(65535);
12986
+ /** A recipient local-part: what stands before the `@`. */
12987
+ var SmtpRecipientSchema = string().min(1).max(64).regex(/^[A-Za-z0-9._+-]+$/);
12988
+ object({
12989
+ port: PortSchema.default(SMTP_INBOUND_DEFAULT_PORT),
12990
+ bindHost: string().min(1).default("0.0.0.0"),
12991
+ domain: string().min(1).default(SMTP_INBOUND_DEFAULT_DOMAIN),
12992
+ requireAuth: boolean().default(true),
12993
+ username: string().default(""),
12994
+ password: string().default("").meta({ secret: true }),
12995
+ starttls: boolean().default(false),
12996
+ maxMessageBytes: number().int().positive().default(SMTP_INBOUND_DEFAULT_MAX_MESSAGE_BYTES)
12997
+ });
12998
+ object({
12999
+ host: string().min(1).nullable(),
13000
+ hostReason: string().nullable(),
13001
+ port: PortSchema,
13002
+ domain: string().min(1),
13003
+ requireAuth: boolean(),
13004
+ username: string(),
13005
+ password: string(),
13006
+ starttls: boolean()
13007
+ });
13008
+ object({
13009
+ recipients: array(SmtpRecipientSchema).min(1),
13010
+ /**
13011
+ * Who is subscribing. A second `subscribe` with the same id REPLACES that subscriber's
13012
+ * recipients (the way a consumer that restarted, or lost a device, declares its set again);
13013
+ * a recipient held by another subscriber is refused with `smtp-inbound/recipient-claimed`.
13014
+ * Absent, the subscriber is anonymous and unique.
13015
+ */
13016
+ subscriberId: string().min(1).max(64).optional()
13017
+ });
13018
+ var SmtpInboundAttachmentSchema = object({
13019
+ filename: string(),
13020
+ contentType: string(),
13021
+ bytes: number().int().nonnegative()
13022
+ });
13023
+ /** The payload of one `broker.message` (the key is the recipient local-part). */
13024
+ var SmtpInboundMessageSchema = object({
13025
+ /** Monotonic per mailbox; a gap tells a consumer it missed an event. */
13026
+ seq: number().int().positive(),
13027
+ messageId: string(),
13028
+ receivedAt: number().int().nonnegative(),
13029
+ from: string(),
13030
+ to: string(),
13031
+ subject: string(),
13032
+ text: string().max(SMTP_INBOUND_TEXT_MAX_CHARS),
13033
+ attachments: array(SmtpInboundAttachmentSchema)
13034
+ });
13035
+ object({
13036
+ /** The newest seq issued so far (0 before any mail). */
13037
+ lastSeq: number().int().nonnegative(),
13038
+ /** The oldest seq still retained; messages below it are gone. */
13039
+ oldestSeq: number().int().nonnegative(),
13040
+ messages: array(object({
13041
+ key: string(),
13042
+ message: SmtpInboundMessageSchema
13043
+ }))
13044
+ });
12978
13045
  new Set([
12979
13046
  "track",
12980
13047
  "face",
@@ -55901,6 +55968,11 @@ var IntegrationInstanceSchema = object({
55901
55968
  name: string(),
55902
55969
  /** The addon's own rule (an URL is present, a client id is present...). */
55903
55970
  configured: boolean(),
55971
+ /**
55972
+ * The instance's own on/off switch (D996): always present, derived from the record body
55973
+ * (`enabled !== false`, so a row written before the field existed is on).
55974
+ */
55975
+ enabled: boolean(),
55904
55976
  /** Values for the instance form; a secret is a `SecretFieldView`, never text. */
55905
55977
  formValues: record(string(), unknown()),
55906
55978
  status: IntegrationInstanceStatusSchema.optional()
@@ -55920,6 +55992,12 @@ method(_void(), IntegrationInstanceDescriptionSchema, { auth: "admin" }), method
55920
55992
  }), method(IntegrationInstancePatchSchema, object({ ok: literal(true) }), {
55921
55993
  kind: "mutation",
55922
55994
  auth: "admin"
55995
+ }), method(object({
55996
+ id: IntegrationInstanceIdSchema,
55997
+ enabled: boolean()
55998
+ }), object({ ok: literal(true) }), {
55999
+ kind: "mutation",
56000
+ auth: "admin"
55923
56001
  }), method(object({ id: IntegrationInstanceIdSchema }), object({ ok: literal(true) }), {
55924
56002
  kind: "mutation",
55925
56003
  auth: "admin"
@@ -85447,6 +85525,13 @@ Object.freeze({
85447
85525
  access: "view",
85448
85526
  role: "admin"
85449
85527
  },
85528
+ "integrationInstances.setInstanceEnabled": {
85529
+ capName: "integration-instances",
85530
+ capScope: "system",
85531
+ addonId: null,
85532
+ access: "create",
85533
+ role: "admin"
85534
+ },
85450
85535
  "integrationInstances.testInstance": {
85451
85536
  capName: "integration-instances",
85452
85537
  capScope: "system",
@@ -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-D4_TtF2V.js");
28
28
  let node_crypto = require("node:crypto");
29
29
  require("node:zlib");
30
30
  let node_fs = require("node:fs");
@@ -5491,6 +5491,10 @@ var PushClient = class {
5491
5491
  disposed = false;
5492
5492
  lastFailure = null;
5493
5493
  suspendedUntil = 0;
5494
+ /** When the current unbroken streak of 404s began; 0 when there is none. */
5495
+ first404At = 0;
5496
+ /** A suspension was entered and not yet logged as left. */
5497
+ suspensionLogged = false;
5494
5498
  constructor(options) {
5495
5499
  this.transport = options.transport;
5496
5500
  this.logger = options.logger;
@@ -5509,9 +5513,20 @@ var PushClient = class {
5509
5513
  get suspended() {
5510
5514
  return this.suspendedUntil > Date.now();
5511
5515
  }
5516
+ /** Epoch ms of the next probe while suspended; 0 otherwise. */
5517
+ get recheckAt() {
5518
+ return this.suspended ? this.suspendedUntil : 0;
5519
+ }
5512
5520
  /** Probe again at once — the operator changed something. */
5513
5521
  resetBackoff() {
5514
5522
  this.suspendedUntil = 0;
5523
+ this.first404At = 0;
5524
+ this.logSuspensionEnded("reset by a settings change");
5525
+ }
5526
+ logSuspensionEnded(why) {
5527
+ if (!this.suspensionLogged) return;
5528
+ this.suspensionLogged = false;
5529
+ this.logger.info(`ha-export: push link no longer suspended (${why}), probing again`, { meta: { brokerId: this.brokerId } });
5515
5530
  }
5516
5531
  /** Messages discarded because HA did not answer. Never silent. */
5517
5532
  get droppedCount() {
@@ -5592,8 +5607,13 @@ var PushClient = class {
5592
5607
  this.dropped += message.type === "batch" ? message.items.length : 1;
5593
5608
  return false;
5594
5609
  }
5610
+ if (this.suspendedUntil !== 0 && !this.suspended) {
5611
+ this.suspendedUntil = 0;
5612
+ this.logSuspensionEnded("recheck time reached");
5613
+ }
5595
5614
  try {
5596
5615
  await this.transport(message);
5616
+ this.first404At = 0;
5597
5617
  this.onReachable();
5598
5618
  return true;
5599
5619
  } catch (err) {
@@ -5620,6 +5640,7 @@ var PushClient = class {
5620
5640
  this.available = true;
5621
5641
  this.lastFailure = null;
5622
5642
  this.suspendedUntil = 0;
5643
+ this.logSuspensionEnded("Home Assistant answered");
5623
5644
  /**
5624
5645
  * The link came back. Drop the dedup cache so the reconcile that
5625
5646
  * follows re-pushes every value: HA may have restarted and lost its
@@ -5657,11 +5678,29 @@ var PushClient = class {
5657
5678
  */
5658
5679
  onUnreachable(message, err) {
5659
5680
  this.available = false;
5660
- const failure = describePushFailure(err);
5681
+ const now = Date.now();
5682
+ const described = describePushFailure(err);
5683
+ let failure = described;
5684
+ if (described.kind === "component-missing") {
5685
+ if (this.first404At === 0) this.first404At = now;
5686
+ if (now - this.first404At < 6e4) failure = {
5687
+ kind: "other",
5688
+ permanent: false,
5689
+ text: "HTTP 404 — Home Assistant has not registered the CamStack endpoint (yet); retrying"
5690
+ };
5691
+ } else this.first404At = 0;
5661
5692
  this.lastFailure = failure;
5662
- if (failure.permanent) this.suspendedUntil = Date.now() + COMPONENT_RECHECK_MS;
5663
5693
  this.dropped += message.type === "batch" ? message.items.length : 1;
5664
- const now = Date.now();
5694
+ if (failure.permanent) {
5695
+ this.suspendedUntil = now + COMPONENT_RECHECK_MS;
5696
+ this.suspensionLogged = true;
5697
+ this.logger.warn(`ha-export: push link SUSPENDED (${failure.text}); next probe at ${new Date(this.suspendedUntil).toISOString()}`, { meta: {
5698
+ brokerId: this.brokerId,
5699
+ failureKind: failure.kind,
5700
+ recheckAt: this.suspendedUntil,
5701
+ recheckInMs: COMPONENT_RECHECK_MS
5702
+ } });
5703
+ }
5665
5704
  if (now - this.droppedLoggedAt < DROP_LOG_INTERVAL_MS) return;
5666
5705
  this.droppedLoggedAt = now;
5667
5706
  this.logger.warn(`ha-export: push to Home Assistant failed (${failure.text}), messages dropped`, { meta: {
@@ -5673,6 +5712,19 @@ var PushClient = class {
5673
5712
  } });
5674
5713
  }
5675
5714
  };
5715
+ //#endregion
5716
+ //#region src/ha-export/link-gaps.ts
5717
+ /** A parked link is reported, never silently excluded. */
5718
+ function summariseLinkGaps(links) {
5719
+ return {
5720
+ failing: links.filter((l) => !l.healthy && !l.suspended).map((l) => l.brokerId),
5721
+ suspended: links.filter((l) => l.suspended).map((l) => ({
5722
+ brokerId: l.brokerId,
5723
+ recheckAt: l.recheckAt
5724
+ })),
5725
+ anyLive: links.some((l) => !l.suspended)
5726
+ };
5727
+ }
5676
5728
  var ReachabilityReconciler = class {
5677
5729
  options;
5678
5730
  minIntervalMs;
@@ -8144,13 +8196,21 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
8144
8196
  const delivered = await this.announce(exported, fullResync);
8145
8197
  await this.pushFullState(exported, snapshots);
8146
8198
  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) {
8199
+ const gaps = summariseLinkGaps([...this.links.entries()].map(([brokerId, link]) => ({
8200
+ brokerId,
8201
+ healthy: link.client.healthy,
8202
+ suspended: link.client.suspended,
8203
+ recheckAt: link.client.recheckAt
8204
+ })));
8205
+ if (gaps.failing.length > 0 || !delivered && gaps.anyLive) {
8150
8206
  this.resync.markGap("push-not-delivered");
8151
8207
  this.lastStructureFingerprint = void 0;
8152
8208
  this.ctx.logger.warn("ha-export: a push was not delivered, the next pass is a full resync");
8153
8209
  }
8210
+ if (gaps.suspended.length > 0) this.ctx.logger.warn("ha-export: push links suspended, Home Assistant receives nothing", { meta: { suspended: gaps.suspended.map((s) => ({
8211
+ brokerId: s.brokerId,
8212
+ recheckAt: new Date(s.recheckAt).toISOString()
8213
+ })) } });
8154
8214
  this.ctx.logger.info("ha-export: reconciled", { meta: {
8155
8215
  reason,
8156
8216
  fullResync,
@@ -8158,6 +8218,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
8158
8218
  devices: exported.size,
8159
8219
  entities: entityCount,
8160
8220
  unclassifiedCaps: this.unclassified.length,
8221
+ suspendedLinks: gaps.suspended.length,
8161
8222
  durationMs: Date.now() - startedAt
8162
8223
  } });
8163
8224
  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 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-CCmZ9I6P.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  import "node:zlib";
4
4
  import "node:fs";
@@ -5456,6 +5456,10 @@ var PushClient = class {
5456
5456
  disposed = false;
5457
5457
  lastFailure = null;
5458
5458
  suspendedUntil = 0;
5459
+ /** When the current unbroken streak of 404s began; 0 when there is none. */
5460
+ first404At = 0;
5461
+ /** A suspension was entered and not yet logged as left. */
5462
+ suspensionLogged = false;
5459
5463
  constructor(options) {
5460
5464
  this.transport = options.transport;
5461
5465
  this.logger = options.logger;
@@ -5474,9 +5478,20 @@ var PushClient = class {
5474
5478
  get suspended() {
5475
5479
  return this.suspendedUntil > Date.now();
5476
5480
  }
5481
+ /** Epoch ms of the next probe while suspended; 0 otherwise. */
5482
+ get recheckAt() {
5483
+ return this.suspended ? this.suspendedUntil : 0;
5484
+ }
5477
5485
  /** Probe again at once — the operator changed something. */
5478
5486
  resetBackoff() {
5479
5487
  this.suspendedUntil = 0;
5488
+ this.first404At = 0;
5489
+ this.logSuspensionEnded("reset by a settings change");
5490
+ }
5491
+ logSuspensionEnded(why) {
5492
+ if (!this.suspensionLogged) return;
5493
+ this.suspensionLogged = false;
5494
+ this.logger.info(`ha-export: push link no longer suspended (${why}), probing again`, { meta: { brokerId: this.brokerId } });
5480
5495
  }
5481
5496
  /** Messages discarded because HA did not answer. Never silent. */
5482
5497
  get droppedCount() {
@@ -5557,8 +5572,13 @@ var PushClient = class {
5557
5572
  this.dropped += message.type === "batch" ? message.items.length : 1;
5558
5573
  return false;
5559
5574
  }
5575
+ if (this.suspendedUntil !== 0 && !this.suspended) {
5576
+ this.suspendedUntil = 0;
5577
+ this.logSuspensionEnded("recheck time reached");
5578
+ }
5560
5579
  try {
5561
5580
  await this.transport(message);
5581
+ this.first404At = 0;
5562
5582
  this.onReachable();
5563
5583
  return true;
5564
5584
  } catch (err) {
@@ -5585,6 +5605,7 @@ var PushClient = class {
5585
5605
  this.available = true;
5586
5606
  this.lastFailure = null;
5587
5607
  this.suspendedUntil = 0;
5608
+ this.logSuspensionEnded("Home Assistant answered");
5588
5609
  /**
5589
5610
  * The link came back. Drop the dedup cache so the reconcile that
5590
5611
  * follows re-pushes every value: HA may have restarted and lost its
@@ -5622,11 +5643,29 @@ var PushClient = class {
5622
5643
  */
5623
5644
  onUnreachable(message, err) {
5624
5645
  this.available = false;
5625
- const failure = describePushFailure(err);
5646
+ const now = Date.now();
5647
+ const described = describePushFailure(err);
5648
+ let failure = described;
5649
+ if (described.kind === "component-missing") {
5650
+ if (this.first404At === 0) this.first404At = now;
5651
+ if (now - this.first404At < 6e4) failure = {
5652
+ kind: "other",
5653
+ permanent: false,
5654
+ text: "HTTP 404 — Home Assistant has not registered the CamStack endpoint (yet); retrying"
5655
+ };
5656
+ } else this.first404At = 0;
5626
5657
  this.lastFailure = failure;
5627
- if (failure.permanent) this.suspendedUntil = Date.now() + COMPONENT_RECHECK_MS;
5628
5658
  this.dropped += message.type === "batch" ? message.items.length : 1;
5629
- const now = Date.now();
5659
+ if (failure.permanent) {
5660
+ this.suspendedUntil = now + COMPONENT_RECHECK_MS;
5661
+ this.suspensionLogged = true;
5662
+ this.logger.warn(`ha-export: push link SUSPENDED (${failure.text}); next probe at ${new Date(this.suspendedUntil).toISOString()}`, { meta: {
5663
+ brokerId: this.brokerId,
5664
+ failureKind: failure.kind,
5665
+ recheckAt: this.suspendedUntil,
5666
+ recheckInMs: COMPONENT_RECHECK_MS
5667
+ } });
5668
+ }
5630
5669
  if (now - this.droppedLoggedAt < DROP_LOG_INTERVAL_MS) return;
5631
5670
  this.droppedLoggedAt = now;
5632
5671
  this.logger.warn(`ha-export: push to Home Assistant failed (${failure.text}), messages dropped`, { meta: {
@@ -5638,6 +5677,19 @@ var PushClient = class {
5638
5677
  } });
5639
5678
  }
5640
5679
  };
5680
+ //#endregion
5681
+ //#region src/ha-export/link-gaps.ts
5682
+ /** A parked link is reported, never silently excluded. */
5683
+ function summariseLinkGaps(links) {
5684
+ return {
5685
+ failing: links.filter((l) => !l.healthy && !l.suspended).map((l) => l.brokerId),
5686
+ suspended: links.filter((l) => l.suspended).map((l) => ({
5687
+ brokerId: l.brokerId,
5688
+ recheckAt: l.recheckAt
5689
+ })),
5690
+ anyLive: links.some((l) => !l.suspended)
5691
+ };
5692
+ }
5641
5693
  var ReachabilityReconciler = class {
5642
5694
  options;
5643
5695
  minIntervalMs;
@@ -8109,13 +8161,21 @@ var HaExportAddon = class extends BaseAddon {
8109
8161
  const delivered = await this.announce(exported, fullResync);
8110
8162
  await this.pushFullState(exported, snapshots);
8111
8163
  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) {
8164
+ const gaps = summariseLinkGaps([...this.links.entries()].map(([brokerId, link]) => ({
8165
+ brokerId,
8166
+ healthy: link.client.healthy,
8167
+ suspended: link.client.suspended,
8168
+ recheckAt: link.client.recheckAt
8169
+ })));
8170
+ if (gaps.failing.length > 0 || !delivered && gaps.anyLive) {
8115
8171
  this.resync.markGap("push-not-delivered");
8116
8172
  this.lastStructureFingerprint = void 0;
8117
8173
  this.ctx.logger.warn("ha-export: a push was not delivered, the next pass is a full resync");
8118
8174
  }
8175
+ if (gaps.suspended.length > 0) this.ctx.logger.warn("ha-export: push links suspended, Home Assistant receives nothing", { meta: { suspended: gaps.suspended.map((s) => ({
8176
+ brokerId: s.brokerId,
8177
+ recheckAt: new Date(s.recheckAt).toISOString()
8178
+ })) } });
8119
8179
  this.ctx.logger.info("ha-export: reconciled", { meta: {
8120
8180
  reason,
8121
8181
  fullResync,
@@ -8123,6 +8183,7 @@ var HaExportAddon = class extends BaseAddon {
8123
8183
  devices: exported.size,
8124
8184
  entities: entityCount,
8125
8185
  unclassifiedCaps: this.unclassified.length,
8186
+ suspendedLinks: gaps.suspended.length,
8126
8187
  durationMs: Date.now() - startedAt
8127
8188
  } });
8128
8189
  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.651",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",
@@ -127,7 +127,7 @@
127
127
  "description": "Device provider (import) + native entity export (push). Two addons, one bundle, separate processes."
128
128
  },
129
129
  "requires": {
130
- "server": ">=1.2.740"
130
+ "server": ">=1.2.741"
131
131
  }
132
132
  },
133
133
  "files": [