@camstack/addon-provider-homeassistant 1.2.160 → 1.2.161

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,8 +3,219 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  //#endregion
6
- const require_dist = require("../dist-l71G4EIk.js");
6
+ const require_dist = require("../dist-C3T2aMHG.js");
7
7
  let node_crypto = require("node:crypto");
8
+ /** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
9
+ var HA_BROKER_KIND = "home-assistant";
10
+ /**
11
+ * Every Home Assistant broker currently registered, cluster-wide.
12
+ *
13
+ * Unpinned by design (see the module docblock) and filtered by `kind`, which is
14
+ * the contract check that matters: whatever mix of providers answered the
15
+ * union, only `home-assistant` brokers are ever exported to over Home
16
+ * Assistant's transport.
17
+ */
18
+ async function listHomeAssistantBrokers(query) {
19
+ return (await query({})).filter((broker) => broker.kind === HA_BROKER_KIND);
20
+ }
21
+ //#endregion
22
+ //#region src/ha-export/camera-function-writes.ts
23
+ /**
24
+ * Performs the WRITE of a camera function on the authority that owns it.
25
+ *
26
+ * `deriveCameraFunctions`/`resolveCameraFunctionWrite` (`camera-functions.ts`)
27
+ * decide; this module acts. It is the file that
28
+ * `scripts/check-switch-successor-surfaces.ts` points to as the successor of the
29
+ * deprecated pair `getCameraSwitches` / `setCameraSwitch` (D113 phase 4).
30
+ */
31
+ async function applyCameraFunctionWrite(api, deviceId, write, sourceNodeId) {
32
+ switch (write.kind) {
33
+ case "device-disabled":
34
+ await api.deviceManager.setDisabled.mutate({
35
+ deviceId,
36
+ disabled: write.disabled
37
+ });
38
+ return;
39
+ case "wrapper-binding":
40
+ await api.deviceManager.setWrapperActive.mutate({
41
+ deviceId,
42
+ capName: write.capName,
43
+ wrapperAddonId: write.wrapperAddonId,
44
+ active: write.active
45
+ });
46
+ return;
47
+ case "recording-enabled": {
48
+ const config = await api.recordingArchive.getDeviceConfig.query({ deviceId });
49
+ await api.recordingArchive.setDeviceConfig.mutate({
50
+ deviceId,
51
+ config: {
52
+ ...config,
53
+ enabled: write.enabled
54
+ }
55
+ });
56
+ return;
57
+ }
58
+ case "notification-mute":
59
+ await api.notificationRules.setDeviceMuted.mutate({
60
+ deviceId,
61
+ muted: write.muted
62
+ });
63
+ return;
64
+ case "camera-mask":
65
+ await api.privacyMask.setMask.mutate({
66
+ deviceId,
67
+ patch: { enabled: write.enabled }
68
+ });
69
+ return;
70
+ case "camera-audio":
71
+ await api.privacyMask.setAudioEnabled.mutate({
72
+ deviceId,
73
+ enabled: write.enabled
74
+ });
75
+ return;
76
+ case "broker-audio-mute":
77
+ if (sourceNodeId === null) throw new Error(`refusing an unrouted broker audio write for device ${String(deviceId)}: no source node`);
78
+ await api.streamBroker.setDeviceAudioMute.mutate({
79
+ deviceId,
80
+ muted: write.muted
81
+ }, require_dist.nodePin(sourceNodeId));
82
+ return;
83
+ }
84
+ }
85
+ //#endregion
86
+ //#region src/ha-export/camera-functions.ts
87
+ var DETECTION_CAP = "detection-pipeline";
88
+ var AUDIO_ANALYSIS_CAP = "audio-analysis";
89
+ /** The wrapper that WRITES the history: tracks, events and their media. */
90
+ var PIPELINE_ANALYTICS_CAP = "pipeline-analytics";
91
+ /** Render order: widest reach first, source before consumer. */
92
+ var ORDER = [
93
+ "stream-broker",
94
+ "object-detection",
95
+ "privacy-mask",
96
+ "device-audio",
97
+ "broker-audio",
98
+ "audio-analysis",
99
+ "event-saving",
100
+ "recording",
101
+ "notifications"
102
+ ];
103
+ var LABELS = {
104
+ "stream-broker": "Camera",
105
+ "object-detection": "Object detection & tracking",
106
+ "privacy-mask": "Privacy mask",
107
+ "device-audio": "Camera microphone",
108
+ "broker-audio": "Audio distribution",
109
+ "audio-analysis": "Audio detection & classification",
110
+ "event-saving": "Event saving",
111
+ recording: "Recording",
112
+ notifications: "Notifications"
113
+ };
114
+ /**
115
+ * Which cap stands behind each wrapper-backed switch. A map instead of a
116
+ * ternary: with three entries the ternary became a chain, and the next entry
117
+ * would have silently landed on the wrong branch.
118
+ */
119
+ var WRAPPER_CAP_BY_ID = {
120
+ "object-detection": DETECTION_CAP,
121
+ "audio-analysis": AUDIO_ANALYSIS_CAP,
122
+ "event-saving": PIPELINE_ANALYTICS_CAP
123
+ };
124
+ var UNAVAILABLE = {
125
+ available: false,
126
+ enabled: true
127
+ };
128
+ function resolveWrapper(capName, reads) {
129
+ if (reads.bindableCapNames === null || reads.wrappedCapNames === null) return UNAVAILABLE;
130
+ if (!reads.bindableCapNames.includes(capName)) return UNAVAILABLE;
131
+ if (reads.wrapperAddonIdByCap.get(capName) === void 0) return UNAVAILABLE;
132
+ return {
133
+ available: true,
134
+ enabled: reads.wrappedCapNames.includes(capName)
135
+ };
136
+ }
137
+ function resolveOne(id, reads) {
138
+ switch (id) {
139
+ case "stream-broker": return reads.deviceDisabled === null ? UNAVAILABLE : {
140
+ available: true,
141
+ enabled: !reads.deviceDisabled
142
+ };
143
+ case "object-detection": return resolveWrapper(DETECTION_CAP, reads);
144
+ case "audio-analysis": return resolveWrapper(AUDIO_ANALYSIS_CAP, reads);
145
+ case "event-saving": return resolveWrapper(PIPELINE_ANALYTICS_CAP, reads);
146
+ case "recording": return reads.recordingEnabled === null ? UNAVAILABLE : {
147
+ available: true,
148
+ enabled: reads.recordingEnabled
149
+ };
150
+ case "notifications": return reads.notificationsMuted === null ? UNAVAILABLE : {
151
+ available: true,
152
+ enabled: !reads.notificationsMuted
153
+ };
154
+ case "privacy-mask": return reads.privacy === null ? UNAVAILABLE : {
155
+ available: true,
156
+ enabled: reads.privacy.maskEnabled
157
+ };
158
+ case "device-audio": return reads.privacy === null || reads.privacy.audioEnabled === null ? UNAVAILABLE : {
159
+ available: true,
160
+ enabled: reads.privacy.audioEnabled
161
+ };
162
+ case "broker-audio": return reads.brokerAudioMuted === null ? UNAVAILABLE : {
163
+ available: true,
164
+ enabled: !reads.brokerAudioMuted
165
+ };
166
+ }
167
+ }
168
+ function deriveCameraFunctions(reads) {
169
+ return ORDER.map((id) => ({
170
+ id,
171
+ label: LABELS[id],
172
+ ...resolveOne(id, reads)
173
+ }));
174
+ }
175
+ /** `null` = not available: the write must be REFUSED, not approximated. */
176
+ function resolveCameraFunctionWrite(id, on, reads) {
177
+ if (!resolveOne(id, reads).available) return null;
178
+ switch (id) {
179
+ case "stream-broker": return {
180
+ kind: "device-disabled",
181
+ disabled: !on
182
+ };
183
+ case "object-detection":
184
+ case "audio-analysis":
185
+ case "event-saving": {
186
+ const capName = WRAPPER_CAP_BY_ID[id];
187
+ const wrapperAddonId = reads.wrapperAddonIdByCap.get(capName);
188
+ if (wrapperAddonId === void 0) return null;
189
+ return {
190
+ kind: "wrapper-binding",
191
+ capName,
192
+ wrapperAddonId,
193
+ active: on
194
+ };
195
+ }
196
+ case "recording": return {
197
+ kind: "recording-enabled",
198
+ enabled: on
199
+ };
200
+ case "notifications": return {
201
+ kind: "notification-mute",
202
+ muted: !on
203
+ };
204
+ case "privacy-mask": return {
205
+ kind: "camera-mask",
206
+ enabled: on
207
+ };
208
+ case "device-audio": return {
209
+ kind: "camera-audio",
210
+ enabled: on
211
+ };
212
+ case "broker-audio": return {
213
+ kind: "broker-audio-mute",
214
+ muted: !on
215
+ };
216
+ }
217
+ }
218
+ //#endregion
8
219
  //#region src/ha-export/topics.ts
9
220
  /**
10
221
  * The topic grammar. Every topic in the export is built HERE.
@@ -4125,223 +4336,26 @@ async function call(slice, capName, make) {
4125
4336
  }
4126
4337
  return { ok: true };
4127
4338
  }
4128
- //#endregion
4129
- //#region src/ha-export/camera-function-writes.ts
4130
4339
  /**
4131
- * Performs the WRITE of a camera function on the authority that owns it.
4340
+ * The ONE derivation of "a signed, expiring URL".
4132
4341
  *
4133
- * `deriveCameraFunctions`/`resolveCameraFunctionWrite` (`camera-functions.ts`)
4134
- * decide; this module acts. It is the file that
4135
- * `scripts/check-switch-successor-surfaces.ts` points to as the successor of the
4136
- * deprecated pair `getCameraSwitches` / `setCameraSwitch` (D113 phase 4).
4137
- */
4138
- async function applyCameraFunctionWrite(api, deviceId, write, sourceNodeId) {
4139
- switch (write.kind) {
4140
- case "device-disabled":
4141
- await api.deviceManager.setDisabled.mutate({
4142
- deviceId,
4143
- disabled: write.disabled
4144
- });
4145
- return;
4146
- case "wrapper-binding":
4147
- await api.deviceManager.setWrapperActive.mutate({
4148
- deviceId,
4149
- capName: write.capName,
4150
- wrapperAddonId: write.wrapperAddonId,
4151
- active: write.active
4152
- });
4153
- return;
4154
- case "recording-enabled": {
4155
- const config = await api.recordingArchive.getDeviceConfig.query({ deviceId });
4156
- await api.recordingArchive.setDeviceConfig.mutate({
4157
- deviceId,
4158
- config: {
4159
- ...config,
4160
- enabled: write.enabled
4161
- }
4162
- });
4163
- return;
4164
- }
4165
- case "notification-mute":
4166
- await api.notificationRules.setDeviceMuted.mutate({
4167
- deviceId,
4168
- muted: write.muted
4169
- });
4170
- return;
4171
- case "camera-mask":
4172
- await api.privacyMask.setMask.mutate({
4173
- deviceId,
4174
- patch: { enabled: write.enabled }
4175
- });
4176
- return;
4177
- case "camera-audio":
4178
- await api.privacyMask.setAudioEnabled.mutate({
4179
- deviceId,
4180
- enabled: write.enabled
4181
- });
4182
- return;
4183
- case "broker-audio-mute":
4184
- if (sourceNodeId === null) throw new Error(`refusing an unrouted broker audio write for device ${String(deviceId)}: no source node`);
4185
- await api.streamBroker.setDeviceAudioMute.mutate({
4186
- deviceId,
4187
- muted: write.muted
4188
- }, require_dist.nodePin(sourceNodeId));
4189
- return;
4190
- }
4191
- }
4192
- //#endregion
4193
- //#region src/ha-export/camera-functions.ts
4194
- var DETECTION_CAP = "detection-pipeline";
4195
- var AUDIO_ANALYSIS_CAP = "audio-analysis";
4196
- /** The wrapper that WRITES the history: tracks, events and their media. */
4197
- var PIPELINE_ANALYTICS_CAP = "pipeline-analytics";
4198
- /** Render order: widest reach first, source before consumer. */
4199
- var ORDER = [
4200
- "stream-broker",
4201
- "object-detection",
4202
- "privacy-mask",
4203
- "device-audio",
4204
- "broker-audio",
4205
- "audio-analysis",
4206
- "event-saving",
4207
- "recording",
4208
- "notifications"
4209
- ];
4210
- var LABELS = {
4211
- "stream-broker": "Camera",
4212
- "object-detection": "Object detection & tracking",
4213
- "privacy-mask": "Privacy mask",
4214
- "device-audio": "Camera microphone",
4215
- "broker-audio": "Audio distribution",
4216
- "audio-analysis": "Audio detection & classification",
4217
- "event-saving": "Event saving",
4218
- recording: "Recording",
4219
- notifications: "Notifications"
4220
- };
4221
- /**
4222
- * Which cap stands behind each wrapper-backed switch. A map instead of a
4223
- * ternary: with three entries the ternary became a chain, and the next entry
4224
- * would have silently landed on the wrong branch.
4225
- */
4226
- var WRAPPER_CAP_BY_ID = {
4227
- "object-detection": DETECTION_CAP,
4228
- "audio-analysis": AUDIO_ANALYSIS_CAP,
4229
- "event-saving": PIPELINE_ANALYTICS_CAP
4230
- };
4231
- var UNAVAILABLE = {
4232
- available: false,
4233
- enabled: true
4234
- };
4235
- function resolveWrapper(capName, reads) {
4236
- if (reads.bindableCapNames === null || reads.wrappedCapNames === null) return UNAVAILABLE;
4237
- if (!reads.bindableCapNames.includes(capName)) return UNAVAILABLE;
4238
- if (reads.wrapperAddonIdByCap.get(capName) === void 0) return UNAVAILABLE;
4239
- return {
4240
- available: true,
4241
- enabled: reads.wrappedCapNames.includes(capName)
4242
- };
4243
- }
4244
- function resolveOne(id, reads) {
4245
- switch (id) {
4246
- case "stream-broker": return reads.deviceDisabled === null ? UNAVAILABLE : {
4247
- available: true,
4248
- enabled: !reads.deviceDisabled
4249
- };
4250
- case "object-detection": return resolveWrapper(DETECTION_CAP, reads);
4251
- case "audio-analysis": return resolveWrapper(AUDIO_ANALYSIS_CAP, reads);
4252
- case "event-saving": return resolveWrapper(PIPELINE_ANALYTICS_CAP, reads);
4253
- case "recording": return reads.recordingEnabled === null ? UNAVAILABLE : {
4254
- available: true,
4255
- enabled: reads.recordingEnabled
4256
- };
4257
- case "notifications": return reads.notificationsMuted === null ? UNAVAILABLE : {
4258
- available: true,
4259
- enabled: !reads.notificationsMuted
4260
- };
4261
- case "privacy-mask": return reads.privacy === null ? UNAVAILABLE : {
4262
- available: true,
4263
- enabled: reads.privacy.maskEnabled
4264
- };
4265
- case "device-audio": return reads.privacy === null || reads.privacy.audioEnabled === null ? UNAVAILABLE : {
4266
- available: true,
4267
- enabled: reads.privacy.audioEnabled
4268
- };
4269
- case "broker-audio": return reads.brokerAudioMuted === null ? UNAVAILABLE : {
4270
- available: true,
4271
- enabled: !reads.brokerAudioMuted
4272
- };
4273
- }
4274
- }
4275
- function deriveCameraFunctions(reads) {
4276
- return ORDER.map((id) => ({
4277
- id,
4278
- label: LABELS[id],
4279
- ...resolveOne(id, reads)
4280
- }));
4281
- }
4282
- /** `null` = not available: the write must be REFUSED, not approximated. */
4283
- function resolveCameraFunctionWrite(id, on, reads) {
4284
- if (!resolveOne(id, reads).available) return null;
4285
- switch (id) {
4286
- case "stream-broker": return {
4287
- kind: "device-disabled",
4288
- disabled: !on
4289
- };
4290
- case "object-detection":
4291
- case "audio-analysis":
4292
- case "event-saving": {
4293
- const capName = WRAPPER_CAP_BY_ID[id];
4294
- const wrapperAddonId = reads.wrapperAddonIdByCap.get(capName);
4295
- if (wrapperAddonId === void 0) return null;
4296
- return {
4297
- kind: "wrapper-binding",
4298
- capName,
4299
- wrapperAddonId,
4300
- active: on
4301
- };
4302
- }
4303
- case "recording": return {
4304
- kind: "recording-enabled",
4305
- enabled: on
4306
- };
4307
- case "notifications": return {
4308
- kind: "notification-mute",
4309
- muted: !on
4310
- };
4311
- case "privacy-mask": return {
4312
- kind: "camera-mask",
4313
- enabled: on
4314
- };
4315
- case "device-audio": return {
4316
- kind: "camera-audio",
4317
- enabled: on
4318
- };
4319
- case "broker-audio": return {
4320
- kind: "broker-audio-mute",
4321
- muted: !on
4322
- };
4323
- }
4324
- }
4325
- /**
4326
- * The ONE derivation of "a signed, expiring URL".
4327
- *
4328
- * This repo mints unguessable, self-expiring links in three places now — the
4329
- * notification artifact plane, the Home Assistant media plane, and the snapshot
4330
- * link plane. The first two grew independently and are byte-identical logic
4331
- * (`hmac(secret, "<id>:<exp>")`, expiry checked before a constant-time compare),
4332
- * each restating the crypto locally because **addons never import each other**.
4333
- *
4334
- * That reason is real, and the conclusion drawn from it was wrong. Two copies of
4335
- * a signing scheme is how one of them quietly ends up with a different TTL, a
4336
- * different compare, or a missing expiry check, and nothing fails until a link
4337
- * that should have died keeps working. The fix is the same one D52 applies to
4338
- * crop geometry: one derivation, in a place every addon may depend on. A
4339
- * framework package is exactly that place — `@camstack/types/node`, off the root
4340
- * entry because `node:crypto` must never be traversed by a browser bundler.
4341
- *
4342
- * What this module deliberately does NOT decide: the TTL, the base URL, the
4343
- * shape of `id`, and the access level of the route. Those are per-plane policy
4344
- * and each caller states them where a reader can see them.
4342
+ * This repo mints unguessable, self-expiring links in three places now — the
4343
+ * notification artifact plane, the Home Assistant media plane, and the snapshot
4344
+ * link plane. The first two grew independently and are byte-identical logic
4345
+ * (`hmac(secret, "<id>:<exp>")`, expiry checked before a constant-time compare),
4346
+ * each restating the crypto locally because **addons never import each other**.
4347
+ *
4348
+ * That reason is real, and the conclusion drawn from it was wrong. Two copies of
4349
+ * a signing scheme is how one of them quietly ends up with a different TTL, a
4350
+ * different compare, or a missing expiry check, and nothing fails until a link
4351
+ * that should have died keeps working. The fix is the same one D52 applies to
4352
+ * crop geometry: one derivation, in a place every addon may depend on. A
4353
+ * framework package is exactly that place — `@camstack/types/node`, off the root
4354
+ * entry because `node:crypto` must never be traversed by a browser bundler.
4355
+ *
4356
+ * What this module deliberately does NOT decide: the TTL, the base URL, the
4357
+ * shape of `id`, and the access level of the route. Those are per-plane policy
4358
+ * and each caller states them where a reader can see them.
4345
4359
  */
4346
4360
  /**
4347
4361
  * The signature over `(id, expMs)`.
@@ -4531,6 +4545,23 @@ function brokerRefreshPatch(current, next) {
4531
4545
  ...membershipChanged ? { membership: next.membership } : {}
4532
4546
  };
4533
4547
  }
4548
+ //#endregion
4549
+ //#region src/ha-export/node-reads.ts
4550
+ var UNREAD = {
4551
+ footageUsedBytes: null,
4552
+ footageFreePercent: null
4553
+ };
4554
+ function footageForNode(nodeId, usage) {
4555
+ if (usage === null || usage.nodeId !== nodeId) return UNREAD;
4556
+ const freePercents = (usage.locations ?? []).filter((loc) => loc.availableBytes !== null && loc.totalBytes !== null && loc.totalBytes > 0).map((loc) => loc.availableBytes / loc.totalBytes * 100);
4557
+ return {
4558
+ footageUsedBytes: usage.totalUsedBytes,
4559
+ footageFreePercent: freePercents.length > 0 ? Math.min(...freePercents) : null
4560
+ };
4561
+ }
4562
+ function shouldReadPackageStatus(node) {
4563
+ return !node.isHub && node.isOnline;
4564
+ }
4534
4565
  /**
4535
4566
  * What a linked Home Assistant NEEDS to function — one entry per thing it
4536
4567
  * actually calls with this token, each named. Not a blast radius, not a
@@ -4821,6 +4852,54 @@ var ReachabilityReconciler = class {
4821
4852
  }
4822
4853
  };
4823
4854
  //#endregion
4855
+ //#region src/ha-export/reconcile-fingerprint.ts
4856
+ function structureFingerprint(exported) {
4857
+ return JSON.stringify([...exported.keys()].sort().map((key) => {
4858
+ const entry = exported.get(key);
4859
+ return [
4860
+ key,
4861
+ entry?.brokerIds,
4862
+ entry?.plan
4863
+ ];
4864
+ }));
4865
+ }
4866
+ /** Only an event-debounced pass with an unchanged structure may skip. */
4867
+ function shouldSkipAnnounce(reason, fingerprint, lastFingerprint) {
4868
+ return reason === "structure-changed" && fingerprint === lastFingerprint;
4869
+ }
4870
+ //#endregion
4871
+ //#region src/ha-export/rule-triggers.ts
4872
+ function reduceOne(row) {
4873
+ return {
4874
+ ruleId: row.ruleId,
4875
+ ruleName: row.ruleName,
4876
+ deviceId: row.deviceId,
4877
+ at: row.createdAt,
4878
+ label: row.subject.label ?? row.subject.className,
4879
+ status: row.status,
4880
+ artifactId: row.artifactIds?.[0] ?? null
4881
+ };
4882
+ }
4883
+ /**
4884
+ * Reduce a newest-first page of history rows.
4885
+ *
4886
+ * A rule missing from `rows` (never fired, or its last fire fell outside the
4887
+ * page this pass could afford) is simply absent from `byRule` — the caller
4888
+ * renders that as `unknown`, never as an invented value.
4889
+ */
4890
+ function reduceLastTriggers(rows) {
4891
+ const byRule = /* @__PURE__ */ new Map();
4892
+ let overall = null;
4893
+ for (const row of rows) {
4894
+ if (overall === null) overall = reduceOne(row);
4895
+ if (!byRule.has(row.ruleId)) byRule.set(row.ruleId, reduceOne(row));
4896
+ }
4897
+ return {
4898
+ overall,
4899
+ byRule
4900
+ };
4901
+ }
4902
+ //#endregion
4824
4903
  //#region src/ha-export/state-projector.ts
4825
4904
  /**
4826
4905
  * Pipeline / notification-center output → `{topic, value}`.
@@ -5869,84 +5948,6 @@ function projectSynthetic(input, nowMs) {
5869
5948
  return values;
5870
5949
  }
5871
5950
  //#endregion
5872
- //#region src/ha-export/node-reads.ts
5873
- var UNREAD = {
5874
- footageUsedBytes: null,
5875
- footageFreePercent: null
5876
- };
5877
- function footageForNode(nodeId, usage) {
5878
- if (usage === null || usage.nodeId !== nodeId) return UNREAD;
5879
- const freePercents = (usage.locations ?? []).filter((loc) => loc.availableBytes !== null && loc.totalBytes !== null && loc.totalBytes > 0).map((loc) => loc.availableBytes / loc.totalBytes * 100);
5880
- return {
5881
- footageUsedBytes: usage.totalUsedBytes,
5882
- footageFreePercent: freePercents.length > 0 ? Math.min(...freePercents) : null
5883
- };
5884
- }
5885
- function shouldReadPackageStatus(node) {
5886
- return !node.isHub && node.isOnline;
5887
- }
5888
- //#endregion
5889
- //#region src/ha-export/rule-triggers.ts
5890
- function reduceOne(row) {
5891
- return {
5892
- ruleId: row.ruleId,
5893
- ruleName: row.ruleName,
5894
- deviceId: row.deviceId,
5895
- at: row.createdAt,
5896
- label: row.subject.label ?? row.subject.className,
5897
- status: row.status,
5898
- artifactId: row.artifactIds?.[0] ?? null
5899
- };
5900
- }
5901
- /**
5902
- * Reduce a newest-first page of history rows.
5903
- *
5904
- * A rule missing from `rows` (never fired, or its last fire fell outside the
5905
- * page this pass could afford) is simply absent from `byRule` — the caller
5906
- * renders that as `unknown`, never as an invented value.
5907
- */
5908
- function reduceLastTriggers(rows) {
5909
- const byRule = /* @__PURE__ */ new Map();
5910
- let overall = null;
5911
- for (const row of rows) {
5912
- if (overall === null) overall = reduceOne(row);
5913
- if (!byRule.has(row.ruleId)) byRule.set(row.ruleId, reduceOne(row));
5914
- }
5915
- return {
5916
- overall,
5917
- byRule
5918
- };
5919
- }
5920
- //#endregion
5921
- //#region src/ha-export/reconcile-fingerprint.ts
5922
- function structureFingerprint(exported) {
5923
- return JSON.stringify([...exported.keys()].sort().map((key) => {
5924
- const entry = exported.get(key);
5925
- return [
5926
- key,
5927
- entry?.brokerIds,
5928
- entry?.plan
5929
- ];
5930
- }));
5931
- }
5932
- /** Only an event-debounced pass with an unchanged structure may skip. */
5933
- function shouldSkipAnnounce(reason, fingerprint, lastFingerprint) {
5934
- return reason === "structure-changed" && fingerprint === lastFingerprint;
5935
- }
5936
- /** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
5937
- var HA_BROKER_KIND = "home-assistant";
5938
- /**
5939
- * Every Home Assistant broker currently registered, cluster-wide.
5940
- *
5941
- * Unpinned by design (see the module docblock) and filtered by `kind`, which is
5942
- * the contract check that matters: whatever mix of providers answered the
5943
- * union, only `home-assistant` brokers are ever exported to over Home
5944
- * Assistant's transport.
5945
- */
5946
- async function listHomeAssistantBrokers(query) {
5947
- return (await query({})).filter((broker) => broker.kind === HA_BROKER_KIND);
5948
- }
5949
- //#endregion
5950
5951
  //#region src/ha-export/ha-export.addon.ts
5951
5952
  /**
5952
5953
  * Native Home Assistant export — a custom component fed by HTTP push.
@@ -5996,8 +5997,6 @@ var DOORBELL_PULSE_MS = 5e3;
5996
5997
  * instant. Year 2000 predates every device this addon exports.
5997
5998
  */
5998
5999
  var MIN_PLAUSIBLE_TIMESTAMP_MS = Date.UTC(2e3, 0, 1);
5999
- /** The hub's API port — where the data plane is reverse-proxied. */
6000
- var HUB_API_PORT = 4443;
6001
6000
  /** One PTZ button press, as a relative move. */
6002
6001
  var PTZ_MOVE_BY_DIRECTION = {
6003
6002
  up: { tilt: .1 },
@@ -7885,14 +7884,15 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7885
7884
  return;
7886
7885
  }
7887
7886
  try {
7888
- const { endpoints } = await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port: HUB_API_PORT });
7889
7887
  /**
7890
- * Lowest priority wins, loopback never: a link Home Assistant
7891
- * cannot open is worse than no image, because it renders as a
7892
- * broken entity rather than an empty one.
7888
+ * Home Assistant is a LAN device pulling from the hub: the `agents`
7889
+ * role (the address flagged "Agents" on the Network page, else the
7890
+ * detected LAN address), never loopback — a link Home Assistant
7891
+ * cannot open is worse than no image, because it renders as a broken
7892
+ * entity rather than an empty one. Ranked by `local-network`, never here.
7893
7893
  */
7894
- const usable = [...endpoints].filter((endpoint) => endpoint.kind !== "loopback" && endpoint.plausible).sort((a, b) => a.priority - b.priority)[0];
7895
- this.mediaBaseUrl = usable?.baseUrl ?? null;
7894
+ const { roles } = await this.ctx.api.localNetwork.getAddresses.query();
7895
+ this.mediaBaseUrl = roles.agents.resolved;
7896
7896
  } catch (err) {
7897
7897
  this.mediaBaseUrl = null;
7898
7898
  this.ctx.logger.debug("ha-export: could not resolve a media base URL", { meta: { error: errMsg(err) } });