@camstack/addon-provider-homeassistant 1.2.44 → 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.
@@ -1,4 +1,4 @@
1
- import { At as EventCategory, C as buildAddonRouteProvider, Et as string, N as deviceExportCapability, X as oauthIntegrationCapability, a as COCO_TO_MACRO, c as FanDirectionSchema, i as CAMERA_SWITCH_ORDER, l as HvacModeSchema, o as CameraSwitchIdSchema, p as addonRoutesCapability, pt as BaseAddon, t as AlarmArmModeSchema, u as MediaPlayerRepeatSchema, vt as nodePin } from "../dist-BjB1J9mO.mjs";
1
+ import { At as EventCategory, C as buildAddonRouteProvider, Et as string, N as deviceExportCapability, X as oauthIntegrationCapability, a as COCO_TO_MACRO, c as FanDirectionSchema, i as CAMERA_SWITCH_ORDER, l as HvacModeSchema, o as CameraSwitchIdSchema, p as addonRoutesCapability, pt as BaseAddon, t as AlarmArmModeSchema, u as MediaPlayerRepeatSchema, vt as nodePin } from "../dist-Dfp98hJL.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  //#region src/ha-export/topics.ts
4
4
  /**
@@ -4297,6 +4297,58 @@ function pruneBrokers(membership, knownBrokerIds) {
4297
4297
  function enabledBrokerIds(store, knownBrokerIds) {
4298
4298
  return knownBrokerIds.filter((brokerId) => store[`exportTo:${brokerId}`] === true);
4299
4299
  }
4300
+ /** Same brokers, same order, same names — nothing for the form to re-render. */
4301
+ function sameBrokers(a, b) {
4302
+ if (a.length !== b.length) return false;
4303
+ return a.every((broker, index) => {
4304
+ const other = b[index];
4305
+ return other !== void 0 && other.id === broker.id && other.name === broker.name;
4306
+ });
4307
+ }
4308
+ /**
4309
+ * What a broker refresh persists — and, above all, what it must NOT.
4310
+ *
4311
+ * **A patch never names a key it did not change.** That reads like tidiness
4312
+ * and is not: `updateGlobalSettings` is a read-modify-write over the addon
4313
+ * store, so naming `membership` writes whatever `this.config.membership`
4314
+ * currently holds — and `this.config` is not always the store.
4315
+ * `BaseAddon.resolveConfig` falls back to the CONSTRUCTOR DEFAULTS whenever
4316
+ * the settings read answers empty, and the kernel's `readAddonStore` answers
4317
+ * empty for a FAILED read exactly as it does for a missing one, silently. A
4318
+ * boot-window read that misses therefore gives the addon `membership: {}`,
4319
+ * and the first refresh that merely re-records `knownBrokers` hands that
4320
+ * empty object to the store as if the operator had unexported everything.
4321
+ *
4322
+ * That is not a hypothetical. On 2026-08-24 the hub restarted at 21:49; the
4323
+ * export runner came up while the cap resolver was still answering
4324
+ * "no provider registered" for hub-resident caps; at 21:55:13 a refresh
4325
+ * found `knownBrokers` stale (`[]` vs `[ha_001]`) and wrote BOTH keys. The
4326
+ * two exported cameras (590 salone, 615 ingresso) were erased from the
4327
+ * store, `devices` fell from 4 to 2 (the two synthetic devices), and Home
4328
+ * Assistant kept every camera entity as a restored/unavailable orphan for
4329
+ * three days: no stream ("the hub does not export this camera"), no
4330
+ * doorbell, no camera state. Nothing logged it, because from the store's
4331
+ * point of view the addon simply asked for `{}`.
4332
+ *
4333
+ * With the keys split, that pass writes `knownBrokers` alone, the
4334
+ * `resolveConfig` that follows the write re-reads a store that still holds
4335
+ * the membership, and the next reconcile exports both cameras again.
4336
+ *
4337
+ * Identity, not deep equality, decides `membership`: `pruneBrokers` returns
4338
+ * the SAME reference when it removed nothing, which is precisely the
4339
+ * "nothing changed" signal this needs.
4340
+ *
4341
+ * @returns the patch to persist, or `null` when there is nothing to write.
4342
+ */
4343
+ function brokerRefreshPatch(current, next) {
4344
+ const brokersChanged = !sameBrokers(next.knownBrokers, current.knownBrokers);
4345
+ const membershipChanged = next.membership !== current.membership;
4346
+ if (!brokersChanged && !membershipChanged) return null;
4347
+ return {
4348
+ ...brokersChanged ? { knownBrokers: next.knownBrokers } : {},
4349
+ ...membershipChanged ? { membership: next.membership } : {}
4350
+ };
4351
+ }
4300
4352
  /**
4301
4353
  * What a linked Home Assistant NEEDS to function — one entry per thing it
4302
4354
  * actually calls with this token, each named. Not a blast radius, not a
@@ -5160,6 +5212,11 @@ function ruleEntity(ruleId) {
5160
5212
  function ruleIdFromEntity(entity, rules) {
5161
5213
  return rules.find((rule) => ruleEntity(rule.id) === entity)?.id ?? null;
5162
5214
  }
5215
+ /** The entity id of one per-rule last-trigger fact. Derived from the rule
5216
+ * ID, same identity rule as {@link ruleEntity}. */
5217
+ function ruleTriggerEntity(ruleId, suffix) {
5218
+ return `${ruleEntity(ruleId)}_last_trigger_${suffix}`;
5219
+ }
5163
5220
  function notificationCenterSpecs(input) {
5164
5221
  return [
5165
5222
  {
@@ -5200,8 +5257,10 @@ function notificationCenterSpecs(input) {
5200
5257
  *
5201
5258
  * Fixed count regardless of camera fleet size — the fan-out this design
5202
5259
  * explicitly avoids (960 entities at 30 cameras × 32 rules) lives ONLY in
5203
- * the per-rule switches below, which already existed. These six are on
5204
- * ONE synthetic device and never repeat per camera or per rule.
5260
+ * the per-rule switches and per-rule trigger facts below, which scale
5261
+ * with the RULE count only (this module has no camera dimension at all).
5262
+ * These six are on ONE synthetic device and never repeat per camera or
5263
+ * per rule.
5205
5264
  */
5206
5265
  {
5207
5266
  entity: "last_trigger_at",
@@ -5254,7 +5313,44 @@ function notificationCenterSpecs(input) {
5254
5313
  label: rule.name,
5255
5314
  writable: true,
5256
5315
  icon: "mdi:bell-cog"
5257
- }))
5316
+ })),
5317
+ ...input.rules.flatMap((rule) => [
5318
+ {
5319
+ entity: ruleTriggerEntity(rule.id, "at"),
5320
+ platform: "sensor",
5321
+ label: `${rule.name} last trigger`,
5322
+ deviceClass: "timestamp"
5323
+ },
5324
+ {
5325
+ entity: ruleTriggerEntity(rule.id, "camera"),
5326
+ platform: "sensor",
5327
+ label: `${rule.name} last trigger camera`,
5328
+ icon: "mdi:cctv"
5329
+ },
5330
+ (
5331
+ /** See {@link SyntheticLastTrigger.label}. Never blank. */
5332
+ {
5333
+ entity: ruleTriggerEntity(rule.id, "label"),
5334
+ platform: "sensor",
5335
+ label: `${rule.name} last trigger label`,
5336
+ icon: "mdi:tag-outline"
5337
+ }),
5338
+ {
5339
+ entity: ruleTriggerEntity(rule.id, "status"),
5340
+ platform: "sensor",
5341
+ label: `${rule.name} last trigger delivery status`,
5342
+ icon: "mdi:send-check-outline",
5343
+ entityCategory: "diagnostic"
5344
+ },
5345
+ (
5346
+ /** See {@link SyntheticLastTrigger.imageUrl} for the expired/
5347
+ * unreachable degrade: no value published rather than a dead link. */
5348
+ {
5349
+ entity: ruleTriggerEntity(rule.id, "image"),
5350
+ platform: "image",
5351
+ label: `${rule.name} last trigger image`
5352
+ })
5353
+ ])
5258
5354
  ];
5259
5355
  }
5260
5356
  function serverSpecs(input) {
@@ -5428,6 +5524,39 @@ function syntheticPlans(input) {
5428
5524
  return [plan(NOTIFICATION_CENTER_STABLE_ID, "Notification Center", notificationCenterSpecs(input)), plan(SERVER_STABLE_ID, "CamStack Server", serverSpecs(input))];
5429
5525
  }
5430
5526
  /**
5527
+ * The four state facts plus the picture, shared by the GLOBAL `last_trigger_*`
5528
+ * entities and every per-rule `rule_<slug>_last_trigger_*` one. `entityFor` is
5529
+ * the SAME function that named the entity in `notificationCenterSpecs` — a
5530
+ * `sensor` whose declared `state_topic` and its published topic come from two
5531
+ * different expressions is exactly the class of bug
5532
+ * `camera-entity-feeds.spec.ts` exists to catch on the camera side.
5533
+ */
5534
+ function projectTriggerFacts(deviceKey, entityFor, t) {
5535
+ const values = [
5536
+ {
5537
+ topic: stateTopic(deviceKey, entityFor("at")),
5538
+ value: new Date(t.at).toISOString()
5539
+ },
5540
+ {
5541
+ topic: stateTopic(deviceKey, entityFor("camera")),
5542
+ value: t.deviceName
5543
+ },
5544
+ {
5545
+ topic: stateTopic(deviceKey, entityFor("label")),
5546
+ value: t.label
5547
+ },
5548
+ {
5549
+ topic: stateTopic(deviceKey, entityFor("status")),
5550
+ value: t.status
5551
+ }
5552
+ ];
5553
+ if (t.imageUrl !== null) values.push({
5554
+ topic: stateTopic(deviceKey, entityFor("image")),
5555
+ value: t.imageUrl
5556
+ });
5557
+ return values;
5558
+ }
5559
+ /**
5431
5560
  * Every synthetic value, for the reconcile to push.
5432
5561
  *
5433
5562
  * Uptime is published as an ISO timestamp of the BOOT INSTANT, not as a
@@ -5494,27 +5623,16 @@ function projectSynthetic(input, nowMs) {
5494
5623
  }
5495
5624
  ];
5496
5625
  if (input.lastTrigger !== null) {
5497
- const t = input.lastTrigger;
5498
5626
  values.push({
5499
- topic: stateTopic(nc, "last_trigger_at"),
5500
- value: new Date(t.at).toISOString()
5501
- }, {
5502
5627
  topic: stateTopic(nc, "last_trigger_rule"),
5503
- value: t.ruleName
5504
- }, {
5505
- topic: stateTopic(nc, "last_trigger_camera"),
5506
- value: t.deviceName
5507
- }, {
5508
- topic: stateTopic(nc, "last_trigger_label"),
5509
- value: t.label
5510
- }, {
5511
- topic: stateTopic(nc, "last_trigger_status"),
5512
- value: t.status
5513
- });
5514
- if (t.imageUrl !== null) values.push({
5515
- topic: stateTopic(nc, "last_trigger_image"),
5516
- value: t.imageUrl
5628
+ value: input.lastTrigger.ruleName
5517
5629
  });
5630
+ values.push(...projectTriggerFacts(nc, (suffix) => `last_trigger_${suffix}`, input.lastTrigger));
5631
+ }
5632
+ for (const rule of input.rules) {
5633
+ const t = input.lastTriggerByRule.get(rule.id);
5634
+ if (t === void 0) continue;
5635
+ values.push(...projectTriggerFacts(nc, (suffix) => ruleTriggerEntity(rule.id, suffix), t));
5518
5636
  }
5519
5637
  if (input.alertsLastTitle !== null) values.push({
5520
5638
  topic: stateTopic(srv, "alerts_last_title"),
@@ -5566,6 +5684,38 @@ function projectSynthetic(input, nowMs) {
5566
5684
  return values;
5567
5685
  }
5568
5686
  //#endregion
5687
+ //#region src/ha-export/rule-triggers.ts
5688
+ function reduceOne(row) {
5689
+ return {
5690
+ ruleId: row.ruleId,
5691
+ ruleName: row.ruleName,
5692
+ deviceId: row.deviceId,
5693
+ at: row.createdAt,
5694
+ label: row.subject.label ?? row.subject.className,
5695
+ status: row.status,
5696
+ artifactId: row.artifactIds?.[0] ?? null
5697
+ };
5698
+ }
5699
+ /**
5700
+ * Reduce a newest-first page of history rows.
5701
+ *
5702
+ * A rule missing from `rows` (never fired, or its last fire fell outside the
5703
+ * page this pass could afford) is simply absent from `byRule` — the caller
5704
+ * renders that as `unknown`, never as an invented value.
5705
+ */
5706
+ function reduceLastTriggers(rows) {
5707
+ const byRule = /* @__PURE__ */ new Map();
5708
+ let overall = null;
5709
+ for (const row of rows) {
5710
+ if (overall === null) overall = reduceOne(row);
5711
+ if (!byRule.has(row.ruleId)) byRule.set(row.ruleId, reduceOne(row));
5712
+ }
5713
+ return {
5714
+ overall,
5715
+ byRule
5716
+ };
5717
+ }
5718
+ //#endregion
5569
5719
  //#region src/ha-export/reconcile-fingerprint.ts
5570
5720
  function structureFingerprint(exported) {
5571
5721
  return JSON.stringify([...exported.keys()].sort().map((key) => {
@@ -6445,14 +6595,16 @@ var HaExportAddon = class extends BaseAddon {
6445
6595
  }).catch(() => null);
6446
6596
  const server = await this.ctx.api.serverManagement.getServerPackageStatus.query().catch(() => null);
6447
6597
  const alerts = await this.readActiveAlerts();
6448
- const lastTrigger = await this.buildLastTrigger(byId);
6449
6598
  if (rules === null && topology === null && addons === null && server === null) return null;
6450
6599
  this.syntheticRules = rules ?? this.syntheticRules;
6600
+ const effectiveRules = this.syntheticRules;
6601
+ const { lastTrigger, lastTriggerByRule } = await this.buildLastTrigger(byId, effectiveRules);
6451
6602
  const { nodes, oldestFootageMs } = await this.buildSyntheticNodes(topology ?? []);
6452
6603
  return {
6453
- rules: rules ?? this.syntheticRules,
6604
+ rules: effectiveRules,
6454
6605
  snoozed: anySnoozed,
6455
6606
  lastTrigger,
6607
+ lastTriggerByRule,
6456
6608
  nodes,
6457
6609
  addonsRunning: addons?.running ?? 0,
6458
6610
  addonsFailed: addons?.failed ?? 0,
@@ -6466,41 +6618,78 @@ var HaExportAddon = class extends BaseAddon {
6466
6618
  };
6467
6619
  }
6468
6620
  /**
6469
- * The most recent notification-rule trigger "conoscere gli ultimi
6470
- * trigger, le ultime immagini... e da quale label" (operator, 2026-08-25).
6621
+ * The most recent notification-rule trigger, GLOBAL and PER RULE —
6622
+ * "conoscere gli ultimi trigger, le ultime immagini... e da quale label"
6623
+ * (operator, 2026-08-25), extended to "l'ultimo trigger PER REGOLA, con
6624
+ * immagine e metadati" (operator, 2026-08-27).
6625
+ *
6626
+ * ONE `getHistory` read serves BOTH: `reduceLastTriggers`
6627
+ * (`rule-triggers.ts`) reduces the same newest-first page into "the very
6628
+ * first row" (global) and "the first row per rule id" (per rule) — never
6629
+ * a query per rule, which is exactly the shape a previous session spent
6630
+ * hours removing from the events path. `NC_HISTORY_LIMIT_MAX` (the cap's
6631
+ * own ceiling) is used rather than the global feature's old `limit: 1`,
6632
+ * so a page wide enough to find every rule's last trigger is fetched in
6633
+ * this one call.
6471
6634
  *
6472
- * `getHistory` is already newest-first (§3.2), so `limit: 1` is the whole
6473
- * question. Best-effort like every other synthetic source here: a failed
6474
- * read costs only these six entities, never the rest of the device.
6635
+ * `rules` bounds the per-rule work to rules that still exist: a deleted
6636
+ * rule's row may still be in the page, but this never mints a signed URL
6637
+ * for it. `resolveArtifactUrl` calls this pass makes are therefore bounded
6638
+ * by the RULE count (~15 measured live), never by fleet size or history
6639
+ * depth — and de-duplicated by artefact id, since two rules matching the
6640
+ * same evaluated record can share one indexed still.
6641
+ *
6642
+ * Best-effort like every other synthetic source here: a failed read costs
6643
+ * only these entities, never the rest of the device.
6475
6644
  */
6476
- async buildLastTrigger(byId) {
6645
+ async buildLastTrigger(byId, rules) {
6646
+ const NONE = {
6647
+ lastTrigger: null,
6648
+ lastTriggerByRule: /* @__PURE__ */ new Map()
6649
+ };
6477
6650
  try {
6478
- const { entries } = await this.ctx.api.notificationRules.getHistory.query({ filter: { limit: 1 } });
6479
- const entry = entries[0];
6480
- if (entry === void 0) return null;
6481
- const label = entry.subject.label ?? entry.subject.className;
6482
- const artifactId = entry.artifactIds?.[0];
6483
- const imageUrl = artifactId === void 0 ? null : await this.ctx.api.notificationRules.resolveArtifactUrl.query({ artifactId }).then((r) => r.url).catch((err) => {
6484
- this.ctx.logger.debug("ha-export: could not resolve the last trigger image url", {
6485
- tags: { deviceId: entry.deviceId },
6486
- meta: {
6487
- artifactId,
6488
- error: errMsg(err)
6489
- }
6651
+ const { entries } = await this.ctx.api.notificationRules.getHistory.query({ filter: { limit: 500 } });
6652
+ const { overall, byRule } = reduceLastTriggers(entries);
6653
+ const urlCache = /* @__PURE__ */ new Map();
6654
+ const resolveUrl = async (artifactId, deviceId) => {
6655
+ if (artifactId === null) return null;
6656
+ const cached = urlCache.get(artifactId);
6657
+ if (cached !== void 0) return cached;
6658
+ const url = await this.ctx.api.notificationRules.resolveArtifactUrl.query({ artifactId }).then((r) => r.url).catch((err) => {
6659
+ this.ctx.logger.debug("ha-export: could not resolve a trigger image url", {
6660
+ tags: { deviceId },
6661
+ meta: {
6662
+ artifactId,
6663
+ error: errMsg(err)
6664
+ }
6665
+ });
6666
+ return null;
6490
6667
  });
6491
- return null;
6668
+ urlCache.set(artifactId, url);
6669
+ return url;
6670
+ };
6671
+ const toSynthetic = async (t) => ({
6672
+ at: t.at,
6673
+ ruleName: t.ruleName,
6674
+ deviceName: byId.get(t.deviceId)?.name ?? `camera ${t.deviceId}`,
6675
+ label: t.label,
6676
+ status: t.status,
6677
+ imageUrl: await resolveUrl(t.artifactId, t.deviceId)
6492
6678
  });
6679
+ const lastTrigger = overall === null ? null : await toSynthetic(overall);
6680
+ const lastTriggerByRule = /* @__PURE__ */ new Map();
6681
+ for (const rule of rules) {
6682
+ const reduced = byRule.get(rule.id);
6683
+ if (reduced === void 0) continue;
6684
+ lastTriggerByRule.set(rule.id, await toSynthetic(reduced));
6685
+ }
6493
6686
  return {
6494
- at: entry.createdAt,
6495
- ruleName: entry.ruleName,
6496
- deviceName: byId.get(entry.deviceId)?.name ?? `camera ${entry.deviceId}`,
6497
- label,
6498
- status: entry.status,
6499
- imageUrl
6687
+ lastTrigger,
6688
+ lastTriggerByRule
6500
6689
  };
6501
6690
  } catch (err) {
6502
- this.ctx.logger.warn("ha-export: could not read the last notification trigger", { meta: { error: errMsg(err) } });
6503
- return null;
6691
+ this.ctx.logger.warn("ha-export: could not read the last notification triggers", { meta: { error: errMsg(err) } });
6692
+ return NONE;
6504
6693
  }
6505
6694
  }
6506
6695
  /**
@@ -6977,10 +7166,24 @@ var HaExportAddon = class extends BaseAddon {
6977
7166
  */
6978
7167
  const pruned = haBrokers.length === 0 ? this.config.membership : pruneBrokers(this.config.membership, haBrokers.map((broker) => broker.id));
6979
7168
  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) } });
6980
- if (pruned !== this.config.membership || !sameBrokers(known, this.config.knownBrokers)) await this.updateGlobalSettings({
7169
+ /**
7170
+ * ONE key per thing that actually changed — see `brokerRefreshPatch`.
7171
+ *
7172
+ * This used to write `knownBrokers` and `membership` together whenever
7173
+ * EITHER differed. A boot-window settings read that answers empty leaves
7174
+ * `this.config` on its constructor defaults, and that joint write then
7175
+ * persisted `membership: {}` over the operator's real export list on a
7176
+ * pass whose only news was the broker roster. It cost this hub three days
7177
+ * of Home Assistant: 2026-08-24 21:55:13.
7178
+ */
7179
+ const patch = brokerRefreshPatch({
7180
+ knownBrokers: this.config.knownBrokers,
7181
+ membership: this.config.membership
7182
+ }, {
6981
7183
  knownBrokers: known,
6982
7184
  membership: pruned
6983
7185
  });
7186
+ if (patch !== null) await this.updateGlobalSettings(patch);
6984
7187
  await this.refreshEnabledBrokers(known);
6985
7188
  const wanted = new Set(this.enabledBrokerIds());
6986
7189
  for (const [brokerId, link] of this.links) {
@@ -7856,13 +8059,6 @@ function randomSecret() {
7856
8059
  crypto.getRandomValues(bytes);
7857
8060
  return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
7858
8061
  }
7859
- function sameBrokers(a, b) {
7860
- if (a.length !== b.length) return false;
7861
- return a.every((broker, index) => {
7862
- const other = b[index];
7863
- return other !== void 0 && other.id === broker.id && other.name === broker.name;
7864
- });
7865
- }
7866
8062
  function toSnapshot(raw) {
7867
8063
  if (raw === null || raw === void 0 || typeof raw !== "object") return {};
7868
8064
  const out = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homeassistant",
3
- "version": "1.2.44",
3
+ "version": "1.2.45",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",