@camstack/addon-provider-homeassistant 1.2.160 → 1.2.162

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-WoiCiuhv.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)`.
@@ -4400,6 +4414,16 @@ function verifyExpiringUrl(input) {
4400
4414
  * an expired link is refused whether or not its signature is valid, so a leaked
4401
4415
  * URL stops working on its own.
4402
4416
  */
4417
+ /**
4418
+ * The base a media link is minted on when the operator left the addon's own
4419
+ * field empty: the hub's `internal` role (D734) — the address flagged
4420
+ * "Internal" on the Network page, else the detected LAN address. Home
4421
+ * Assistant is a LAN device pulling from the hub, so never the public
4422
+ * endpoint, and never loopback (`null` when nothing qualifies).
4423
+ */
4424
+ function haMediaBaseUrl(book) {
4425
+ return book.roles.internal.resolved;
4426
+ }
4403
4427
  /** How long a minted link stays valid. Long enough for HA to render it. */
4404
4428
  var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
4405
4429
  function signMedia(secret, id, expMs) {
@@ -4531,6 +4555,23 @@ function brokerRefreshPatch(current, next) {
4531
4555
  ...membershipChanged ? { membership: next.membership } : {}
4532
4556
  };
4533
4557
  }
4558
+ //#endregion
4559
+ //#region src/ha-export/node-reads.ts
4560
+ var UNREAD = {
4561
+ footageUsedBytes: null,
4562
+ footageFreePercent: null
4563
+ };
4564
+ function footageForNode(nodeId, usage) {
4565
+ if (usage === null || usage.nodeId !== nodeId) return UNREAD;
4566
+ const freePercents = (usage.locations ?? []).filter((loc) => loc.availableBytes !== null && loc.totalBytes !== null && loc.totalBytes > 0).map((loc) => loc.availableBytes / loc.totalBytes * 100);
4567
+ return {
4568
+ footageUsedBytes: usage.totalUsedBytes,
4569
+ footageFreePercent: freePercents.length > 0 ? Math.min(...freePercents) : null
4570
+ };
4571
+ }
4572
+ function shouldReadPackageStatus(node) {
4573
+ return !node.isHub && node.isOnline;
4574
+ }
4534
4575
  /**
4535
4576
  * What a linked Home Assistant NEEDS to function — one entry per thing it
4536
4577
  * actually calls with this token, each named. Not a blast radius, not a
@@ -4821,6 +4862,54 @@ var ReachabilityReconciler = class {
4821
4862
  }
4822
4863
  };
4823
4864
  //#endregion
4865
+ //#region src/ha-export/reconcile-fingerprint.ts
4866
+ function structureFingerprint(exported) {
4867
+ return JSON.stringify([...exported.keys()].sort().map((key) => {
4868
+ const entry = exported.get(key);
4869
+ return [
4870
+ key,
4871
+ entry?.brokerIds,
4872
+ entry?.plan
4873
+ ];
4874
+ }));
4875
+ }
4876
+ /** Only an event-debounced pass with an unchanged structure may skip. */
4877
+ function shouldSkipAnnounce(reason, fingerprint, lastFingerprint) {
4878
+ return reason === "structure-changed" && fingerprint === lastFingerprint;
4879
+ }
4880
+ //#endregion
4881
+ //#region src/ha-export/rule-triggers.ts
4882
+ function reduceOne(row) {
4883
+ return {
4884
+ ruleId: row.ruleId,
4885
+ ruleName: row.ruleName,
4886
+ deviceId: row.deviceId,
4887
+ at: row.createdAt,
4888
+ label: row.subject.label ?? row.subject.className,
4889
+ status: row.status,
4890
+ artifactId: row.artifactIds?.[0] ?? null
4891
+ };
4892
+ }
4893
+ /**
4894
+ * Reduce a newest-first page of history rows.
4895
+ *
4896
+ * A rule missing from `rows` (never fired, or its last fire fell outside the
4897
+ * page this pass could afford) is simply absent from `byRule` — the caller
4898
+ * renders that as `unknown`, never as an invented value.
4899
+ */
4900
+ function reduceLastTriggers(rows) {
4901
+ const byRule = /* @__PURE__ */ new Map();
4902
+ let overall = null;
4903
+ for (const row of rows) {
4904
+ if (overall === null) overall = reduceOne(row);
4905
+ if (!byRule.has(row.ruleId)) byRule.set(row.ruleId, reduceOne(row));
4906
+ }
4907
+ return {
4908
+ overall,
4909
+ byRule
4910
+ };
4911
+ }
4912
+ //#endregion
4824
4913
  //#region src/ha-export/state-projector.ts
4825
4914
  /**
4826
4915
  * Pipeline / notification-center output → `{topic, value}`.
@@ -5869,84 +5958,6 @@ function projectSynthetic(input, nowMs) {
5869
5958
  return values;
5870
5959
  }
5871
5960
  //#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
5961
  //#region src/ha-export/ha-export.addon.ts
5951
5962
  /**
5952
5963
  * Native Home Assistant export — a custom component fed by HTTP push.
@@ -5996,8 +6007,6 @@ var DOORBELL_PULSE_MS = 5e3;
5996
6007
  * instant. Year 2000 predates every device this addon exports.
5997
6008
  */
5998
6009
  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
6010
  /** One PTZ button press, as a relative move. */
6002
6011
  var PTZ_MOVE_BY_DIRECTION = {
6003
6012
  up: { tilt: .1 },
@@ -7885,14 +7894,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7885
7894
  return;
7886
7895
  }
7887
7896
  try {
7888
- const { endpoints } = await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port: HUB_API_PORT });
7889
- /**
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.
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;
7897
+ this.mediaBaseUrl = haMediaBaseUrl(await this.ctx.api.localNetwork.getAddresses.query());
7896
7898
  } catch (err) {
7897
7899
  this.mediaBaseUrl = null;
7898
7900
  this.ctx.logger.debug("ha-export: could not resolve a media base URL", { meta: { error: errMsg(err) } });