@camstack/addon-provider-homeassistant 1.2.43 → 1.2.45

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.
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  //#endregion
6
- const require_dist = require("../dist-DiprHh6i.js");
6
+ const require_dist = require("../dist-BXcwGSn_.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  //#region src/ha-export/topics.ts
9
9
  /**
@@ -4302,6 +4302,58 @@ function pruneBrokers(membership, knownBrokerIds) {
4302
4302
  function enabledBrokerIds(store, knownBrokerIds) {
4303
4303
  return knownBrokerIds.filter((brokerId) => store[`exportTo:${brokerId}`] === true);
4304
4304
  }
4305
+ /** Same brokers, same order, same names — nothing for the form to re-render. */
4306
+ function sameBrokers(a, b) {
4307
+ if (a.length !== b.length) return false;
4308
+ return a.every((broker, index) => {
4309
+ const other = b[index];
4310
+ return other !== void 0 && other.id === broker.id && other.name === broker.name;
4311
+ });
4312
+ }
4313
+ /**
4314
+ * What a broker refresh persists — and, above all, what it must NOT.
4315
+ *
4316
+ * **A patch never names a key it did not change.** That reads like tidiness
4317
+ * and is not: `updateGlobalSettings` is a read-modify-write over the addon
4318
+ * store, so naming `membership` writes whatever `this.config.membership`
4319
+ * currently holds — and `this.config` is not always the store.
4320
+ * `BaseAddon.resolveConfig` falls back to the CONSTRUCTOR DEFAULTS whenever
4321
+ * the settings read answers empty, and the kernel's `readAddonStore` answers
4322
+ * empty for a FAILED read exactly as it does for a missing one, silently. A
4323
+ * boot-window read that misses therefore gives the addon `membership: {}`,
4324
+ * and the first refresh that merely re-records `knownBrokers` hands that
4325
+ * empty object to the store as if the operator had unexported everything.
4326
+ *
4327
+ * That is not a hypothetical. On 2026-08-24 the hub restarted at 21:49; the
4328
+ * export runner came up while the cap resolver was still answering
4329
+ * "no provider registered" for hub-resident caps; at 21:55:13 a refresh
4330
+ * found `knownBrokers` stale (`[]` vs `[ha_001]`) and wrote BOTH keys. The
4331
+ * two exported cameras (590 salone, 615 ingresso) were erased from the
4332
+ * store, `devices` fell from 4 to 2 (the two synthetic devices), and Home
4333
+ * Assistant kept every camera entity as a restored/unavailable orphan for
4334
+ * three days: no stream ("the hub does not export this camera"), no
4335
+ * doorbell, no camera state. Nothing logged it, because from the store's
4336
+ * point of view the addon simply asked for `{}`.
4337
+ *
4338
+ * With the keys split, that pass writes `knownBrokers` alone, the
4339
+ * `resolveConfig` that follows the write re-reads a store that still holds
4340
+ * the membership, and the next reconcile exports both cameras again.
4341
+ *
4342
+ * Identity, not deep equality, decides `membership`: `pruneBrokers` returns
4343
+ * the SAME reference when it removed nothing, which is precisely the
4344
+ * "nothing changed" signal this needs.
4345
+ *
4346
+ * @returns the patch to persist, or `null` when there is nothing to write.
4347
+ */
4348
+ function brokerRefreshPatch(current, next) {
4349
+ const brokersChanged = !sameBrokers(next.knownBrokers, current.knownBrokers);
4350
+ const membershipChanged = next.membership !== current.membership;
4351
+ if (!brokersChanged && !membershipChanged) return null;
4352
+ return {
4353
+ ...brokersChanged ? { knownBrokers: next.knownBrokers } : {},
4354
+ ...membershipChanged ? { membership: next.membership } : {}
4355
+ };
4356
+ }
4305
4357
  /**
4306
4358
  * What a linked Home Assistant NEEDS to function — one entry per thing it
4307
4359
  * actually calls with this token, each named. Not a blast radius, not a
@@ -5165,6 +5217,11 @@ function ruleEntity(ruleId) {
5165
5217
  function ruleIdFromEntity(entity, rules) {
5166
5218
  return rules.find((rule) => ruleEntity(rule.id) === entity)?.id ?? null;
5167
5219
  }
5220
+ /** The entity id of one per-rule last-trigger fact. Derived from the rule
5221
+ * ID, same identity rule as {@link ruleEntity}. */
5222
+ function ruleTriggerEntity(ruleId, suffix) {
5223
+ return `${ruleEntity(ruleId)}_last_trigger_${suffix}`;
5224
+ }
5168
5225
  function notificationCenterSpecs(input) {
5169
5226
  return [
5170
5227
  {
@@ -5195,13 +5252,110 @@ function notificationCenterSpecs(input) {
5195
5252
  writable: true,
5196
5253
  options: SNOOZE_OPTIONS
5197
5254
  }),
5255
+ (
5256
+ /**
5257
+ * The last trigger, six entities: five facts plus its picture — "conoscere
5258
+ * gli ultimi trigger, le ultime immagini... e da quale label" (operator,
5259
+ * 2026-08-25). ENABLED by default like every other entity on this device
5260
+ * (see {@link buildComponents}'s docblock): a rule the operator wrote is a
5261
+ * rule they want to watch fire.
5262
+ *
5263
+ * Fixed count regardless of camera fleet size — the fan-out this design
5264
+ * explicitly avoids (960 entities at 30 cameras × 32 rules) lives ONLY in
5265
+ * the per-rule switches and per-rule trigger facts below, which scale
5266
+ * with the RULE count only (this module has no camera dimension at all).
5267
+ * These six are on ONE synthetic device and never repeat per camera or
5268
+ * per rule.
5269
+ */
5270
+ {
5271
+ entity: "last_trigger_at",
5272
+ platform: "sensor",
5273
+ label: "Last trigger",
5274
+ deviceClass: "timestamp"
5275
+ }),
5276
+ {
5277
+ entity: "last_trigger_rule",
5278
+ platform: "sensor",
5279
+ label: "Last trigger rule",
5280
+ icon: "mdi:bell-ring-outline"
5281
+ },
5282
+ {
5283
+ entity: "last_trigger_camera",
5284
+ platform: "sensor",
5285
+ label: "Last trigger camera",
5286
+ icon: "mdi:cctv"
5287
+ },
5288
+ (
5289
+ /** The identified label, or the detected class when none was resolved —
5290
+ * see {@link SyntheticLastTrigger.label}. Never blank. */
5291
+ {
5292
+ entity: "last_trigger_label",
5293
+ platform: "sensor",
5294
+ label: "Last trigger label",
5295
+ icon: "mdi:tag-outline"
5296
+ }),
5297
+ (
5298
+ /** `pending` / `sent` / `dead` — the outbox row's own status, straight
5299
+ * from `getHistory`, never a second delivery ledger. */
5300
+ {
5301
+ entity: "last_trigger_status",
5302
+ platform: "sensor",
5303
+ label: "Last trigger delivery status",
5304
+ icon: "mdi:send-check-outline",
5305
+ entityCategory: "diagnostic"
5306
+ }),
5307
+ (
5308
+ /** See {@link SyntheticLastTrigger.imageUrl} for the expired/unreachable
5309
+ * degrade: no value is published rather than a link that 404s. */
5310
+ {
5311
+ entity: "last_trigger_image",
5312
+ platform: "image",
5313
+ label: "Last trigger image"
5314
+ }),
5198
5315
  ...input.rules.map((rule) => ({
5199
5316
  entity: ruleEntity(rule.id),
5200
5317
  platform: "switch",
5201
5318
  label: rule.name,
5202
5319
  writable: true,
5203
5320
  icon: "mdi:bell-cog"
5204
- }))
5321
+ })),
5322
+ ...input.rules.flatMap((rule) => [
5323
+ {
5324
+ entity: ruleTriggerEntity(rule.id, "at"),
5325
+ platform: "sensor",
5326
+ label: `${rule.name} last trigger`,
5327
+ deviceClass: "timestamp"
5328
+ },
5329
+ {
5330
+ entity: ruleTriggerEntity(rule.id, "camera"),
5331
+ platform: "sensor",
5332
+ label: `${rule.name} last trigger camera`,
5333
+ icon: "mdi:cctv"
5334
+ },
5335
+ (
5336
+ /** See {@link SyntheticLastTrigger.label}. Never blank. */
5337
+ {
5338
+ entity: ruleTriggerEntity(rule.id, "label"),
5339
+ platform: "sensor",
5340
+ label: `${rule.name} last trigger label`,
5341
+ icon: "mdi:tag-outline"
5342
+ }),
5343
+ {
5344
+ entity: ruleTriggerEntity(rule.id, "status"),
5345
+ platform: "sensor",
5346
+ label: `${rule.name} last trigger delivery status`,
5347
+ icon: "mdi:send-check-outline",
5348
+ entityCategory: "diagnostic"
5349
+ },
5350
+ (
5351
+ /** See {@link SyntheticLastTrigger.imageUrl} for the expired/
5352
+ * unreachable degrade: no value published rather than a dead link. */
5353
+ {
5354
+ entity: ruleTriggerEntity(rule.id, "image"),
5355
+ platform: "image",
5356
+ label: `${rule.name} last trigger image`
5357
+ })
5358
+ ])
5205
5359
  ];
5206
5360
  }
5207
5361
  function serverSpecs(input) {
@@ -5375,6 +5529,39 @@ function syntheticPlans(input) {
5375
5529
  return [plan(NOTIFICATION_CENTER_STABLE_ID, "Notification Center", notificationCenterSpecs(input)), plan(SERVER_STABLE_ID, "CamStack Server", serverSpecs(input))];
5376
5530
  }
5377
5531
  /**
5532
+ * The four state facts plus the picture, shared by the GLOBAL `last_trigger_*`
5533
+ * entities and every per-rule `rule_<slug>_last_trigger_*` one. `entityFor` is
5534
+ * the SAME function that named the entity in `notificationCenterSpecs` — a
5535
+ * `sensor` whose declared `state_topic` and its published topic come from two
5536
+ * different expressions is exactly the class of bug
5537
+ * `camera-entity-feeds.spec.ts` exists to catch on the camera side.
5538
+ */
5539
+ function projectTriggerFacts(deviceKey, entityFor, t) {
5540
+ const values = [
5541
+ {
5542
+ topic: stateTopic(deviceKey, entityFor("at")),
5543
+ value: new Date(t.at).toISOString()
5544
+ },
5545
+ {
5546
+ topic: stateTopic(deviceKey, entityFor("camera")),
5547
+ value: t.deviceName
5548
+ },
5549
+ {
5550
+ topic: stateTopic(deviceKey, entityFor("label")),
5551
+ value: t.label
5552
+ },
5553
+ {
5554
+ topic: stateTopic(deviceKey, entityFor("status")),
5555
+ value: t.status
5556
+ }
5557
+ ];
5558
+ if (t.imageUrl !== null) values.push({
5559
+ topic: stateTopic(deviceKey, entityFor("image")),
5560
+ value: t.imageUrl
5561
+ });
5562
+ return values;
5563
+ }
5564
+ /**
5378
5565
  * Every synthetic value, for the reconcile to push.
5379
5566
  *
5380
5567
  * Uptime is published as an ISO timestamp of the BOOT INSTANT, not as a
@@ -5440,6 +5627,18 @@ function projectSynthetic(input, nowMs) {
5440
5627
  value: String(input.alertsActiveCount)
5441
5628
  }
5442
5629
  ];
5630
+ if (input.lastTrigger !== null) {
5631
+ values.push({
5632
+ topic: stateTopic(nc, "last_trigger_rule"),
5633
+ value: input.lastTrigger.ruleName
5634
+ });
5635
+ values.push(...projectTriggerFacts(nc, (suffix) => `last_trigger_${suffix}`, input.lastTrigger));
5636
+ }
5637
+ for (const rule of input.rules) {
5638
+ const t = input.lastTriggerByRule.get(rule.id);
5639
+ if (t === void 0) continue;
5640
+ values.push(...projectTriggerFacts(nc, (suffix) => ruleTriggerEntity(rule.id, suffix), t));
5641
+ }
5443
5642
  if (input.alertsLastTitle !== null) values.push({
5444
5643
  topic: stateTopic(srv, "alerts_last_title"),
5445
5644
  value: input.alertsLastTitle
@@ -5490,6 +5689,38 @@ function projectSynthetic(input, nowMs) {
5490
5689
  return values;
5491
5690
  }
5492
5691
  //#endregion
5692
+ //#region src/ha-export/rule-triggers.ts
5693
+ function reduceOne(row) {
5694
+ return {
5695
+ ruleId: row.ruleId,
5696
+ ruleName: row.ruleName,
5697
+ deviceId: row.deviceId,
5698
+ at: row.createdAt,
5699
+ label: row.subject.label ?? row.subject.className,
5700
+ status: row.status,
5701
+ artifactId: row.artifactIds?.[0] ?? null
5702
+ };
5703
+ }
5704
+ /**
5705
+ * Reduce a newest-first page of history rows.
5706
+ *
5707
+ * A rule missing from `rows` (never fired, or its last fire fell outside the
5708
+ * page this pass could afford) is simply absent from `byRule` — the caller
5709
+ * renders that as `unknown`, never as an invented value.
5710
+ */
5711
+ function reduceLastTriggers(rows) {
5712
+ const byRule = /* @__PURE__ */ new Map();
5713
+ let overall = null;
5714
+ for (const row of rows) {
5715
+ if (overall === null) overall = reduceOne(row);
5716
+ if (!byRule.has(row.ruleId)) byRule.set(row.ruleId, reduceOne(row));
5717
+ }
5718
+ return {
5719
+ overall,
5720
+ byRule
5721
+ };
5722
+ }
5723
+ //#endregion
5493
5724
  //#region src/ha-export/reconcile-fingerprint.ts
5494
5725
  function structureFingerprint(exported) {
5495
5726
  return JSON.stringify([...exported.keys()].sort().map((key) => {
@@ -6259,7 +6490,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6259
6490
  * `brokerIds` is every enabled broker, because they belong to the server
6260
6491
  * and not to a membership the operator picks per camera.
6261
6492
  */
6262
- const synthetic = await this.buildSynthetic(snoozes);
6493
+ const synthetic = await this.buildSynthetic(snoozes, byId);
6263
6494
  if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
6264
6495
  exported.set(plan.deviceKey, {
6265
6496
  plan,
@@ -6342,7 +6573,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6342
6573
  * Returns `null` only when NOTHING answered, so the devices are not
6343
6574
  * announced empty on a hub that is still booting.
6344
6575
  */
6345
- async buildSynthetic(snoozes) {
6576
+ async buildSynthetic(snoozes, byId) {
6346
6577
  const rules = await this.ctx.api.notificationRules.listRules.query({}).then((r) => r.rules.map((rule) => ({
6347
6578
  id: rule.id,
6348
6579
  name: rule.name,
@@ -6371,10 +6602,14 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6371
6602
  const alerts = await this.readActiveAlerts();
6372
6603
  if (rules === null && topology === null && addons === null && server === null) return null;
6373
6604
  this.syntheticRules = rules ?? this.syntheticRules;
6605
+ const effectiveRules = this.syntheticRules;
6606
+ const { lastTrigger, lastTriggerByRule } = await this.buildLastTrigger(byId, effectiveRules);
6374
6607
  const { nodes, oldestFootageMs } = await this.buildSyntheticNodes(topology ?? []);
6375
6608
  return {
6376
- rules: rules ?? this.syntheticRules,
6609
+ rules: effectiveRules,
6377
6610
  snoozed: anySnoozed,
6611
+ lastTrigger,
6612
+ lastTriggerByRule,
6378
6613
  nodes,
6379
6614
  addonsRunning: addons?.running ?? 0,
6380
6615
  addonsFailed: addons?.failed ?? 0,
@@ -6388,6 +6623,81 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6388
6623
  };
6389
6624
  }
6390
6625
  /**
6626
+ * The most recent notification-rule trigger, GLOBAL and PER RULE —
6627
+ * "conoscere gli ultimi trigger, le ultime immagini... e da quale label"
6628
+ * (operator, 2026-08-25), extended to "l'ultimo trigger PER REGOLA, con
6629
+ * immagine e metadati" (operator, 2026-08-27).
6630
+ *
6631
+ * ONE `getHistory` read serves BOTH: `reduceLastTriggers`
6632
+ * (`rule-triggers.ts`) reduces the same newest-first page into "the very
6633
+ * first row" (global) and "the first row per rule id" (per rule) — never
6634
+ * a query per rule, which is exactly the shape a previous session spent
6635
+ * hours removing from the events path. `NC_HISTORY_LIMIT_MAX` (the cap's
6636
+ * own ceiling) is used rather than the global feature's old `limit: 1`,
6637
+ * so a page wide enough to find every rule's last trigger is fetched in
6638
+ * this one call.
6639
+ *
6640
+ * `rules` bounds the per-rule work to rules that still exist: a deleted
6641
+ * rule's row may still be in the page, but this never mints a signed URL
6642
+ * for it. `resolveArtifactUrl` calls this pass makes are therefore bounded
6643
+ * by the RULE count (~15 measured live), never by fleet size or history
6644
+ * depth — and de-duplicated by artefact id, since two rules matching the
6645
+ * same evaluated record can share one indexed still.
6646
+ *
6647
+ * Best-effort like every other synthetic source here: a failed read costs
6648
+ * only these entities, never the rest of the device.
6649
+ */
6650
+ async buildLastTrigger(byId, rules) {
6651
+ const NONE = {
6652
+ lastTrigger: null,
6653
+ lastTriggerByRule: /* @__PURE__ */ new Map()
6654
+ };
6655
+ try {
6656
+ const { entries } = await this.ctx.api.notificationRules.getHistory.query({ filter: { limit: 500 } });
6657
+ const { overall, byRule } = reduceLastTriggers(entries);
6658
+ const urlCache = /* @__PURE__ */ new Map();
6659
+ const resolveUrl = async (artifactId, deviceId) => {
6660
+ if (artifactId === null) return null;
6661
+ const cached = urlCache.get(artifactId);
6662
+ if (cached !== void 0) return cached;
6663
+ const url = await this.ctx.api.notificationRules.resolveArtifactUrl.query({ artifactId }).then((r) => r.url).catch((err) => {
6664
+ this.ctx.logger.debug("ha-export: could not resolve a trigger image url", {
6665
+ tags: { deviceId },
6666
+ meta: {
6667
+ artifactId,
6668
+ error: errMsg(err)
6669
+ }
6670
+ });
6671
+ return null;
6672
+ });
6673
+ urlCache.set(artifactId, url);
6674
+ return url;
6675
+ };
6676
+ const toSynthetic = async (t) => ({
6677
+ at: t.at,
6678
+ ruleName: t.ruleName,
6679
+ deviceName: byId.get(t.deviceId)?.name ?? `camera ${t.deviceId}`,
6680
+ label: t.label,
6681
+ status: t.status,
6682
+ imageUrl: await resolveUrl(t.artifactId, t.deviceId)
6683
+ });
6684
+ const lastTrigger = overall === null ? null : await toSynthetic(overall);
6685
+ const lastTriggerByRule = /* @__PURE__ */ new Map();
6686
+ for (const rule of rules) {
6687
+ const reduced = byRule.get(rule.id);
6688
+ if (reduced === void 0) continue;
6689
+ lastTriggerByRule.set(rule.id, await toSynthetic(reduced));
6690
+ }
6691
+ return {
6692
+ lastTrigger,
6693
+ lastTriggerByRule
6694
+ };
6695
+ } catch (err) {
6696
+ this.ctx.logger.warn("ha-export: could not read the last notification triggers", { meta: { error: errMsg(err) } });
6697
+ return NONE;
6698
+ }
6699
+ }
6700
+ /**
6391
6701
  * `alerts.list` — the conditions nobody said (Task D1). Best-effort: a
6392
6702
  * failed read yields "nothing active", not a fabricated absence dressed up
6393
6703
  * as a clean one — this addon logs the drop rather than pretending the
@@ -6861,10 +7171,24 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6861
7171
  */
6862
7172
  const pruned = haBrokers.length === 0 ? this.config.membership : pruneBrokers(this.config.membership, haBrokers.map((broker) => broker.id));
6863
7173
  if (haBrokers.length === 0 && Object.keys(this.config.membership).length > 0) this.ctx.logger.warn("ha-export: no Home Assistant broker answered — keeping the export membership rather than pruning it", { meta: { brokers: Object.keys(this.config.membership) } });
6864
- if (pruned !== this.config.membership || !sameBrokers(known, this.config.knownBrokers)) await this.updateGlobalSettings({
7174
+ /**
7175
+ * ONE key per thing that actually changed — see `brokerRefreshPatch`.
7176
+ *
7177
+ * This used to write `knownBrokers` and `membership` together whenever
7178
+ * EITHER differed. A boot-window settings read that answers empty leaves
7179
+ * `this.config` on its constructor defaults, and that joint write then
7180
+ * persisted `membership: {}` over the operator's real export list on a
7181
+ * pass whose only news was the broker roster. It cost this hub three days
7182
+ * of Home Assistant: 2026-08-24 21:55:13.
7183
+ */
7184
+ const patch = brokerRefreshPatch({
7185
+ knownBrokers: this.config.knownBrokers,
7186
+ membership: this.config.membership
7187
+ }, {
6865
7188
  knownBrokers: known,
6866
7189
  membership: pruned
6867
7190
  });
7191
+ if (patch !== null) await this.updateGlobalSettings(patch);
6868
7192
  await this.refreshEnabledBrokers(known);
6869
7193
  const wanted = new Set(this.enabledBrokerIds());
6870
7194
  for (const [brokerId, link] of this.links) {
@@ -7740,13 +8064,6 @@ function randomSecret() {
7740
8064
  crypto.getRandomValues(bytes);
7741
8065
  return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
7742
8066
  }
7743
- function sameBrokers(a, b) {
7744
- if (a.length !== b.length) return false;
7745
- return a.every((broker, index) => {
7746
- const other = b[index];
7747
- return other !== void 0 && other.id === broker.id && other.name === broker.name;
7748
- });
7749
- }
7750
8067
  function toSnapshot(raw) {
7751
8068
  if (raw === null || raw === void 0 || typeof raw !== "object") return {};
7752
8069
  const out = {};