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