@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.
- package/dist/addon.js +61 -12
- package/dist/addon.mjs +61 -12
- package/dist/{dist-H458ct4y.mjs → dist-Cw91Z1Ew.mjs} +305 -71
- package/dist/{dist-l71G4EIk.js → dist-WoiCiuhv.js} +305 -71
- package/dist/ha-export/ha-export.addon.js +306 -304
- package/dist/ha-export/ha-export.addon.mjs +306 -304
- package/package.json +1 -1
|
@@ -3,8 +3,219 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
//#endregion
|
|
6
|
-
const require_dist = require("../dist-
|
|
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
|
-
*
|
|
4340
|
+
* The ONE derivation of "a signed, expiring URL".
|
|
4132
4341
|
*
|
|
4133
|
-
*
|
|
4134
|
-
*
|
|
4135
|
-
*
|
|
4136
|
-
*
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
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
|
-
|
|
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) } });
|