@camstack/addon-export-alexa 1.2.136 → 1.2.137
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/export-alexa.addon.js +3048 -273
- package/dist/export-alexa.addon.mjs +3048 -274
- package/package.json +2 -1
|
@@ -26,6 +26,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
26
26
|
//#endregion
|
|
27
27
|
let node_crypto = require("node:crypto");
|
|
28
28
|
node_crypto = __toESM(node_crypto, 1);
|
|
29
|
+
let sharp = require("sharp");
|
|
30
|
+
sharp = __toESM(sharp);
|
|
29
31
|
//#region ../types/dist/event-category-B_0XEcLF.mjs
|
|
30
32
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
31
33
|
EventCategory["SystemBoot"] = "system.boot";
|
|
@@ -3789,7 +3791,7 @@ function initializeContext(params) {
|
|
|
3789
3791
|
external: params?.external ?? void 0
|
|
3790
3792
|
};
|
|
3791
3793
|
}
|
|
3792
|
-
function process(schema, ctx, _params = {
|
|
3794
|
+
function process$1(schema, ctx, _params = {
|
|
3793
3795
|
path: [],
|
|
3794
3796
|
schemaPath: []
|
|
3795
3797
|
}) {
|
|
@@ -3826,7 +3828,7 @@ function process(schema, ctx, _params = {
|
|
|
3826
3828
|
const parent = schema._zod.parent;
|
|
3827
3829
|
if (parent) {
|
|
3828
3830
|
if (!result.ref) result.ref = parent;
|
|
3829
|
-
process(parent, ctx, params);
|
|
3831
|
+
process$1(parent, ctx, params);
|
|
3830
3832
|
ctx.seen.get(parent).isParent = true;
|
|
3831
3833
|
}
|
|
3832
3834
|
}
|
|
@@ -4046,7 +4048,7 @@ var createToJSONSchemaMethod = (schema, processors = {}) => (params) => {
|
|
|
4046
4048
|
...params,
|
|
4047
4049
|
processors
|
|
4048
4050
|
});
|
|
4049
|
-
process(schema, ctx);
|
|
4051
|
+
process$1(schema, ctx);
|
|
4050
4052
|
extractDefs(ctx, schema);
|
|
4051
4053
|
return finalize(ctx, schema);
|
|
4052
4054
|
};
|
|
@@ -4058,7 +4060,7 @@ var createStandardJSONSchemaMethod = (schema, io, processors = {}) => (params) =
|
|
|
4058
4060
|
io,
|
|
4059
4061
|
processors
|
|
4060
4062
|
});
|
|
4061
|
-
process(schema, ctx);
|
|
4063
|
+
process$1(schema, ctx);
|
|
4062
4064
|
extractDefs(ctx, schema);
|
|
4063
4065
|
return finalize(ctx, schema);
|
|
4064
4066
|
};
|
|
@@ -4172,7 +4174,7 @@ var arrayProcessor = (schema, ctx, _json, params) => {
|
|
|
4172
4174
|
if (typeof minimum === "number") json.minItems = minimum;
|
|
4173
4175
|
if (typeof maximum === "number") json.maxItems = maximum;
|
|
4174
4176
|
json.type = "array";
|
|
4175
|
-
json.items = process(def.element, ctx, {
|
|
4177
|
+
json.items = process$1(def.element, ctx, {
|
|
4176
4178
|
...params,
|
|
4177
4179
|
path: [...params.path, "items"]
|
|
4178
4180
|
});
|
|
@@ -4183,7 +4185,7 @@ var objectProcessor = (schema, ctx, _json, params) => {
|
|
|
4183
4185
|
json.type = "object";
|
|
4184
4186
|
json.properties = {};
|
|
4185
4187
|
const shape = def.shape;
|
|
4186
|
-
for (const key in shape) json.properties[key] = process(shape[key], ctx, {
|
|
4188
|
+
for (const key in shape) json.properties[key] = process$1(shape[key], ctx, {
|
|
4187
4189
|
...params,
|
|
4188
4190
|
path: [
|
|
4189
4191
|
...params.path,
|
|
@@ -4201,7 +4203,7 @@ var objectProcessor = (schema, ctx, _json, params) => {
|
|
|
4201
4203
|
if (def.catchall?._zod.def.type === "never") json.additionalProperties = false;
|
|
4202
4204
|
else if (!def.catchall) {
|
|
4203
4205
|
if (ctx.io === "output") json.additionalProperties = false;
|
|
4204
|
-
} else if (def.catchall) json.additionalProperties = process(def.catchall, ctx, {
|
|
4206
|
+
} else if (def.catchall) json.additionalProperties = process$1(def.catchall, ctx, {
|
|
4205
4207
|
...params,
|
|
4206
4208
|
path: [...params.path, "additionalProperties"]
|
|
4207
4209
|
});
|
|
@@ -4209,7 +4211,7 @@ var objectProcessor = (schema, ctx, _json, params) => {
|
|
|
4209
4211
|
var unionProcessor = (schema, ctx, json, params) => {
|
|
4210
4212
|
const def = schema._zod.def;
|
|
4211
4213
|
const isExclusive = def.inclusive === false;
|
|
4212
|
-
const options = def.options.map((x, i) => process(x, ctx, {
|
|
4214
|
+
const options = def.options.map((x, i) => process$1(x, ctx, {
|
|
4213
4215
|
...params,
|
|
4214
4216
|
path: [
|
|
4215
4217
|
...params.path,
|
|
@@ -4222,7 +4224,7 @@ var unionProcessor = (schema, ctx, json, params) => {
|
|
|
4222
4224
|
};
|
|
4223
4225
|
var intersectionProcessor = (schema, ctx, json, params) => {
|
|
4224
4226
|
const def = schema._zod.def;
|
|
4225
|
-
const a = process(def.left, ctx, {
|
|
4227
|
+
const a = process$1(def.left, ctx, {
|
|
4226
4228
|
...params,
|
|
4227
4229
|
path: [
|
|
4228
4230
|
...params.path,
|
|
@@ -4230,7 +4232,7 @@ var intersectionProcessor = (schema, ctx, json, params) => {
|
|
|
4230
4232
|
0
|
|
4231
4233
|
]
|
|
4232
4234
|
});
|
|
4233
|
-
const b = process(def.right, ctx, {
|
|
4235
|
+
const b = process$1(def.right, ctx, {
|
|
4234
4236
|
...params,
|
|
4235
4237
|
path: [
|
|
4236
4238
|
...params.path,
|
|
@@ -4247,7 +4249,7 @@ var tupleProcessor = (schema, ctx, _json, params) => {
|
|
|
4247
4249
|
json.type = "array";
|
|
4248
4250
|
const prefixPath = ctx.target === "draft-2020-12" ? "prefixItems" : "items";
|
|
4249
4251
|
const restPath = ctx.target === "draft-2020-12" ? "items" : ctx.target === "openapi-3.0" ? "items" : "additionalItems";
|
|
4250
|
-
const prefixItems = def.items.map((x, i) => process(x, ctx, {
|
|
4252
|
+
const prefixItems = def.items.map((x, i) => process$1(x, ctx, {
|
|
4251
4253
|
...params,
|
|
4252
4254
|
path: [
|
|
4253
4255
|
...params.path,
|
|
@@ -4255,7 +4257,7 @@ var tupleProcessor = (schema, ctx, _json, params) => {
|
|
|
4255
4257
|
i
|
|
4256
4258
|
]
|
|
4257
4259
|
}));
|
|
4258
|
-
const rest = def.rest ? process(def.rest, ctx, {
|
|
4260
|
+
const rest = def.rest ? process$1(def.rest, ctx, {
|
|
4259
4261
|
...params,
|
|
4260
4262
|
path: [
|
|
4261
4263
|
...params.path,
|
|
@@ -4286,7 +4288,7 @@ var recordProcessor = (schema, ctx, _json, params) => {
|
|
|
4286
4288
|
const keyType = def.keyType;
|
|
4287
4289
|
const patterns = keyType._zod.bag?.patterns;
|
|
4288
4290
|
if (def.mode === "loose" && patterns && patterns.size > 0) {
|
|
4289
|
-
const valueSchema = process(def.valueType, ctx, {
|
|
4291
|
+
const valueSchema = process$1(def.valueType, ctx, {
|
|
4290
4292
|
...params,
|
|
4291
4293
|
path: [
|
|
4292
4294
|
...params.path,
|
|
@@ -4297,11 +4299,11 @@ var recordProcessor = (schema, ctx, _json, params) => {
|
|
|
4297
4299
|
json.patternProperties = {};
|
|
4298
4300
|
for (const pattern of patterns) json.patternProperties[pattern.source] = valueSchema;
|
|
4299
4301
|
} else {
|
|
4300
|
-
if (ctx.target === "draft-07" || ctx.target === "draft-2020-12") json.propertyNames = process(def.keyType, ctx, {
|
|
4302
|
+
if (ctx.target === "draft-07" || ctx.target === "draft-2020-12") json.propertyNames = process$1(def.keyType, ctx, {
|
|
4301
4303
|
...params,
|
|
4302
4304
|
path: [...params.path, "propertyNames"]
|
|
4303
4305
|
});
|
|
4304
|
-
json.additionalProperties = process(def.valueType, ctx, {
|
|
4306
|
+
json.additionalProperties = process$1(def.valueType, ctx, {
|
|
4305
4307
|
...params,
|
|
4306
4308
|
path: [...params.path, "additionalProperties"]
|
|
4307
4309
|
});
|
|
@@ -4314,7 +4316,7 @@ var recordProcessor = (schema, ctx, _json, params) => {
|
|
|
4314
4316
|
};
|
|
4315
4317
|
var nullableProcessor = (schema, ctx, json, params) => {
|
|
4316
4318
|
const def = schema._zod.def;
|
|
4317
|
-
const inner = process(def.innerType, ctx, params);
|
|
4319
|
+
const inner = process$1(def.innerType, ctx, params);
|
|
4318
4320
|
const seen = ctx.seen.get(schema);
|
|
4319
4321
|
if (ctx.target === "openapi-3.0") {
|
|
4320
4322
|
seen.ref = def.innerType;
|
|
@@ -4323,27 +4325,27 @@ var nullableProcessor = (schema, ctx, json, params) => {
|
|
|
4323
4325
|
};
|
|
4324
4326
|
var nonoptionalProcessor = (schema, ctx, _json, params) => {
|
|
4325
4327
|
const def = schema._zod.def;
|
|
4326
|
-
process(def.innerType, ctx, params);
|
|
4328
|
+
process$1(def.innerType, ctx, params);
|
|
4327
4329
|
const seen = ctx.seen.get(schema);
|
|
4328
4330
|
seen.ref = def.innerType;
|
|
4329
4331
|
};
|
|
4330
4332
|
var defaultProcessor = (schema, ctx, json, params) => {
|
|
4331
4333
|
const def = schema._zod.def;
|
|
4332
|
-
process(def.innerType, ctx, params);
|
|
4334
|
+
process$1(def.innerType, ctx, params);
|
|
4333
4335
|
const seen = ctx.seen.get(schema);
|
|
4334
4336
|
seen.ref = def.innerType;
|
|
4335
4337
|
json.default = JSON.parse(JSON.stringify(def.defaultValue));
|
|
4336
4338
|
};
|
|
4337
4339
|
var prefaultProcessor = (schema, ctx, json, params) => {
|
|
4338
4340
|
const def = schema._zod.def;
|
|
4339
|
-
process(def.innerType, ctx, params);
|
|
4341
|
+
process$1(def.innerType, ctx, params);
|
|
4340
4342
|
const seen = ctx.seen.get(schema);
|
|
4341
4343
|
seen.ref = def.innerType;
|
|
4342
4344
|
if (ctx.io === "input") json._prefault = JSON.parse(JSON.stringify(def.defaultValue));
|
|
4343
4345
|
};
|
|
4344
4346
|
var catchProcessor = (schema, ctx, json, params) => {
|
|
4345
4347
|
const def = schema._zod.def;
|
|
4346
|
-
process(def.innerType, ctx, params);
|
|
4348
|
+
process$1(def.innerType, ctx, params);
|
|
4347
4349
|
const seen = ctx.seen.get(schema);
|
|
4348
4350
|
seen.ref = def.innerType;
|
|
4349
4351
|
let catchValue;
|
|
@@ -4358,26 +4360,26 @@ var pipeProcessor = (schema, ctx, _json, params) => {
|
|
|
4358
4360
|
const def = schema._zod.def;
|
|
4359
4361
|
const inIsTransform = def.in._zod.traits.has("$ZodTransform");
|
|
4360
4362
|
const innerType = ctx.io === "input" ? inIsTransform ? def.out : def.in : def.out;
|
|
4361
|
-
process(innerType, ctx, params);
|
|
4363
|
+
process$1(innerType, ctx, params);
|
|
4362
4364
|
const seen = ctx.seen.get(schema);
|
|
4363
4365
|
seen.ref = innerType;
|
|
4364
4366
|
};
|
|
4365
4367
|
var readonlyProcessor = (schema, ctx, json, params) => {
|
|
4366
4368
|
const def = schema._zod.def;
|
|
4367
|
-
process(def.innerType, ctx, params);
|
|
4369
|
+
process$1(def.innerType, ctx, params);
|
|
4368
4370
|
const seen = ctx.seen.get(schema);
|
|
4369
4371
|
seen.ref = def.innerType;
|
|
4370
4372
|
json.readOnly = true;
|
|
4371
4373
|
};
|
|
4372
4374
|
var optionalProcessor = (schema, ctx, _json, params) => {
|
|
4373
4375
|
const def = schema._zod.def;
|
|
4374
|
-
process(def.innerType, ctx, params);
|
|
4376
|
+
process$1(def.innerType, ctx, params);
|
|
4375
4377
|
const seen = ctx.seen.get(schema);
|
|
4376
4378
|
seen.ref = def.innerType;
|
|
4377
4379
|
};
|
|
4378
4380
|
var lazyProcessor = (schema, ctx, _json, params) => {
|
|
4379
4381
|
const innerType = schema._zod.innerType;
|
|
4380
|
-
process(innerType, ctx, params);
|
|
4382
|
+
process$1(innerType, ctx, params);
|
|
4381
4383
|
const seen = ctx.seen.get(schema);
|
|
4382
4384
|
seen.ref = innerType;
|
|
4383
4385
|
};
|
|
@@ -7468,9 +7470,9 @@ function errMsg(err) {
|
|
|
7468
7470
|
*/
|
|
7469
7471
|
/** The pinned jellyfin-ffmpeg release. Changing this is the version change. */
|
|
7470
7472
|
var FFMPEG_RELEASE = "8.1.2-5";
|
|
7471
|
-
var BASE = `https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFMPEG_RELEASE}`;
|
|
7473
|
+
var BASE$1 = `https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFMPEG_RELEASE}`;
|
|
7472
7474
|
function portable(target) {
|
|
7473
|
-
return `${BASE}/jellyfin-ffmpeg_${FFMPEG_RELEASE}_portable_${target}-gpl.tar.xz`;
|
|
7475
|
+
return `${BASE$1}/jellyfin-ffmpeg_${FFMPEG_RELEASE}_portable_${target}-gpl.tar.xz`;
|
|
7474
7476
|
}
|
|
7475
7477
|
portable("linux64"), portable("linuxarm64"), portable("macarm64"), portable("mac64");
|
|
7476
7478
|
/**
|
|
@@ -26352,7 +26354,7 @@ method(SendEmailInputSchema, SendEmailResultSchema, {
|
|
|
26352
26354
|
* the device-native provider (onboard capture) or from the stream-broker
|
|
26353
26355
|
* prebuffer fallback.
|
|
26354
26356
|
*/
|
|
26355
|
-
var SnapshotImageSchema = object({
|
|
26357
|
+
var SnapshotImageSchema$1 = object({
|
|
26356
26358
|
base64: string(),
|
|
26357
26359
|
contentType: string()
|
|
26358
26360
|
});
|
|
@@ -26384,7 +26386,7 @@ DeviceType.Camera, method(object({
|
|
|
26384
26386
|
* correct answer: stale but honest beats woken.
|
|
26385
26387
|
*/
|
|
26386
26388
|
force: boolean().optional()
|
|
26387
|
-
}), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
|
|
26389
|
+
}), SnapshotImageSchema$1.nullable()), method(object({ deviceId: number() }), _void(), {
|
|
26388
26390
|
kind: "mutation",
|
|
26389
26391
|
auth: "admin"
|
|
26390
26392
|
}), systemMethod(object({ deviceIds: array(number()).min(1).max(200) }), array(object({
|
|
@@ -28432,7 +28434,22 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
|
|
|
28432
28434
|
* field their hints were silently stripped by the schema and the
|
|
28433
28435
|
* adaptive ladder started blind on the client's display size.
|
|
28434
28436
|
*/
|
|
28435
|
-
hints: webrtcClientHintsSchema.optional()
|
|
28437
|
+
hints: webrtcClientHintsSchema.optional(),
|
|
28438
|
+
/**
|
|
28439
|
+
* Talk-back (D771): relay the PEER's audio to the device's speaker
|
|
28440
|
+
* through the `intercom` cap's push-talk triple. When `true` and the
|
|
28441
|
+
* offer lets the peer send audio, the answer's audio is `sendrecv`
|
|
28442
|
+
* and every inbound frame goes to `intercom.pushTalkAudio` for this
|
|
28443
|
+
* `deviceId`; absent/false answers `sendonly`, as for every viewer.
|
|
28444
|
+
*
|
|
28445
|
+
* TRUSTED-CALLER ONLY. Set by the Alexa export for an Echo on a camera
|
|
28446
|
+
* with `intercom`, when the linked user may control that camera. The
|
|
28447
|
+
* hub STRIPS it from every request-bound call (a browser), exactly as
|
|
28448
|
+
* it overwrites `relayOnly` — a viewer's talk permission is the
|
|
28449
|
+
* intercom cap's own surface (share tokens gate it there), and this
|
|
28450
|
+
* flag must not become a second door to the camera's speaker.
|
|
28451
|
+
*/
|
|
28452
|
+
talkback: boolean().optional()
|
|
28436
28453
|
}), object({
|
|
28437
28454
|
sessionId: string(),
|
|
28438
28455
|
sdpAnswer: string()
|
|
@@ -29219,7 +29236,19 @@ object({
|
|
|
29219
29236
|
lastChangedAt: number()
|
|
29220
29237
|
});
|
|
29221
29238
|
DeviceType.Sensor;
|
|
29222
|
-
|
|
29239
|
+
/**
|
|
29240
|
+
* Dimmable-light brightness control. Co-exists with `switch` on the
|
|
29241
|
+
* same device — the switch toggles on/off, this cap sets the level
|
|
29242
|
+
* applied when the light is on. Drivers map their per-vendor dim
|
|
29243
|
+
* controls to this single-method surface.
|
|
29244
|
+
*
|
|
29245
|
+
* The cap is intentionally minimal: a single `setBrightness({deviceId,
|
|
29246
|
+
* percentage})` mutation plus the auto-injected `getStatus`. Drivers
|
|
29247
|
+
* that expose richer controls (color temperature, scenes, schedules)
|
|
29248
|
+
* should surface those via the device's `getSettingsUISchema()`
|
|
29249
|
+
* instead of bloating this cap.
|
|
29250
|
+
*/
|
|
29251
|
+
var BrightnessStatusSchema = object({
|
|
29223
29252
|
/** Current level as 0..100 inclusive. Firmware-reported. */
|
|
29224
29253
|
percentage: number().min(0).max(100),
|
|
29225
29254
|
/** Ms epoch of the last operator-driven change. Useful for UI freshness. */
|
|
@@ -29844,7 +29873,19 @@ Object.values(DeviceType), method(object({
|
|
|
29844
29873
|
kind: "mutation",
|
|
29845
29874
|
auth: "admin"
|
|
29846
29875
|
}), ConsumablesStatusSchema.extend({ lastFetchedAt: number() });
|
|
29847
|
-
|
|
29876
|
+
/**
|
|
29877
|
+
* Door / window / opening / garage / valve contact sensor. Boolean
|
|
29878
|
+
* "is the entry currently open" with the timestamp of the last
|
|
29879
|
+
* transition. Drives Home Assistant `binary_sensor` entries whose
|
|
29880
|
+
* `device_class` is `door`, `window`, `opening`, `garage`, or
|
|
29881
|
+
* `garage_door` — and any future native integration that needs
|
|
29882
|
+
* the same semantics.
|
|
29883
|
+
*
|
|
29884
|
+
* Push-driven: providers update the slice on transition events from
|
|
29885
|
+
* the upstream source (HA WebSocket `state_changed`, ZWave
|
|
29886
|
+
* `notification` …). Consumers read the slice; no polling.
|
|
29887
|
+
*/
|
|
29888
|
+
var ContactStatusSchema = object({
|
|
29848
29889
|
/** True when the entry is open; false when closed. */
|
|
29849
29890
|
entryOpen: boolean(),
|
|
29850
29891
|
/** Ms epoch of the last open↔closed transition. 0 if never observed. */
|
|
@@ -30666,7 +30707,15 @@ DeviceType.Humidifier, method(object({
|
|
|
30666
30707
|
kind: "mutation",
|
|
30667
30708
|
auth: "protected"
|
|
30668
30709
|
});
|
|
30669
|
-
|
|
30710
|
+
/**
|
|
30711
|
+
* Single-metric humidity reading. Drives Home Assistant `sensor`
|
|
30712
|
+
* entries with `device_class: humidity`.
|
|
30713
|
+
*
|
|
30714
|
+
* Unit normalisation: percent. The canonical display unit (`%`) is a
|
|
30715
|
+
* descriptor constant in the UI (ROLE_DESCRIPTOR), not stored in
|
|
30716
|
+
* `sourceInfo`.
|
|
30717
|
+
*/
|
|
30718
|
+
var HumiditySensorStatusSchema = object({
|
|
30670
30719
|
/** Current relative humidity, 0..100. */
|
|
30671
30720
|
percent: number().min(0).max(100),
|
|
30672
30721
|
/** Ms epoch when the slice was last updated. */
|
|
@@ -31473,7 +31522,7 @@ method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(obje
|
|
|
31473
31522
|
kind: "mutation",
|
|
31474
31523
|
auth: "admin"
|
|
31475
31524
|
});
|
|
31476
|
-
object({
|
|
31525
|
+
var LockControlStatusSchema = object({
|
|
31477
31526
|
/** Lifecycle state of the lock. `jammed` means the motor reported
|
|
31478
31527
|
* failure to reach the target — operator intervention required. */
|
|
31479
31528
|
state: _enum([
|
|
@@ -31811,7 +31860,7 @@ authKey: string().optional() }), object({
|
|
|
31811
31860
|
/** Human-readable error when `ok: false`. */
|
|
31812
31861
|
error: string().optional()
|
|
31813
31862
|
}), { kind: "mutation" });
|
|
31814
|
-
object({
|
|
31863
|
+
var MotionStatusSchema = object({
|
|
31815
31864
|
detected: boolean(),
|
|
31816
31865
|
/** Ms epoch of the last detected-true observation. Null if never detected. */
|
|
31817
31866
|
lastDetectedAt: number().nullable(),
|
|
@@ -33356,7 +33405,7 @@ var GpsLocationSchema = object({
|
|
|
33356
33405
|
/** Reported accuracy in meters (lower = better). */
|
|
33357
33406
|
accuracyMeters: number().nonnegative()
|
|
33358
33407
|
});
|
|
33359
|
-
object({
|
|
33408
|
+
var PresenceStatusSchema = object({
|
|
33360
33409
|
/** `home` / `not_home` / any user-defined zone name. */
|
|
33361
33410
|
state: string(),
|
|
33362
33411
|
/** Optional textual location label (zone name, city, address). Null
|
|
@@ -35778,7 +35827,14 @@ DeviceType.Camera, method(object({ deviceId: number() }), StreamParamsOptionsSch
|
|
|
35778
35827
|
kind: "mutation",
|
|
35779
35828
|
auth: "admin"
|
|
35780
35829
|
}), method(object({ deviceId: number() }), unknown().nullable());
|
|
35781
|
-
|
|
35830
|
+
/**
|
|
35831
|
+
* Generic on/off switch cap for accessory children (siren, floodlight,
|
|
35832
|
+
* spotlight, PIR toggle, chime silencer, autotrack enable, …). The cap
|
|
35833
|
+
* owns ONLY the boolean state — driver-specific settings (brightness,
|
|
35834
|
+
* sensitivity, schedule, duration) live on the device's own
|
|
35835
|
+
* `getSettingsUISchema()` surface.
|
|
35836
|
+
*/
|
|
35837
|
+
var SwitchStatusSchema = object({
|
|
35782
35838
|
on: boolean(),
|
|
35783
35839
|
/** Ms epoch of the last state change. Useful for UI "X minutes ago". */
|
|
35784
35840
|
lastChangedAt: number()
|
|
@@ -36319,7 +36375,23 @@ object({
|
|
|
36319
36375
|
lastChangedAt: number()
|
|
36320
36376
|
});
|
|
36321
36377
|
DeviceType.Sensor;
|
|
36322
|
-
|
|
36378
|
+
/**
|
|
36379
|
+
* Single-metric temperature reading. Drives Home Assistant `sensor`
|
|
36380
|
+
* entries with `device_class: temperature` and any future native
|
|
36381
|
+
* thermometer.
|
|
36382
|
+
*
|
|
36383
|
+
* Unit normalisation: providers convert to Celsius before storing.
|
|
36384
|
+
* The slice value is always Celsius so cross-cap aggregators
|
|
36385
|
+
* (climate-control's `currentTemp`, energy analytics) can compose
|
|
36386
|
+
* without per-source unit fixups. The canonical display unit (`°C`) is
|
|
36387
|
+
* a descriptor constant in the UI (ROLE_DESCRIPTOR), not stored in
|
|
36388
|
+
* `sourceInfo`.
|
|
36389
|
+
*
|
|
36390
|
+
* Status `lastFetchedAt` lets staleness-aware consumers detect a
|
|
36391
|
+
* frozen feed (provider hung) distinct from a "temperature hasn't
|
|
36392
|
+
* changed" steady state.
|
|
36393
|
+
*/
|
|
36394
|
+
var TemperatureSensorStatusSchema = object({
|
|
36323
36395
|
/** Current temperature in Celsius. */
|
|
36324
36396
|
celsius: number(),
|
|
36325
36397
|
/** Ms epoch when the slice was last updated (push or poll). */
|
|
@@ -49260,6 +49332,76 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
|
|
|
49260
49332
|
DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
|
|
49261
49333
|
DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
|
|
49262
49334
|
DEFAULT_NATIVE_LEASE_SETTINGS.admission;
|
|
49335
|
+
/** Ancestor-walk bound — defence-in-depth against a corrupt parentage cycle. */
|
|
49336
|
+
var MAX_ANCESTOR_HOPS$1 = 8;
|
|
49337
|
+
/**
|
|
49338
|
+
* Does `selector` cover `device` DIRECTLY (no inheritance)? Inheritance is
|
|
49339
|
+
* layered on by the resolver/matcher, which tests this against a device's
|
|
49340
|
+
* ancestors too.
|
|
49341
|
+
*/
|
|
49342
|
+
function deviceSelectorMatches(selector, device) {
|
|
49343
|
+
switch (selector.kind) {
|
|
49344
|
+
case "all": return true;
|
|
49345
|
+
case "ids": return selector.ids.includes(device.id);
|
|
49346
|
+
case "types": return selector.types.some((t) => String(t) === device.type);
|
|
49347
|
+
case "locations": return device.location !== null && selector.locations.includes(device.location);
|
|
49348
|
+
}
|
|
49349
|
+
}
|
|
49350
|
+
/** Whether a `category:device` grant covers `access` (the broad lever). */
|
|
49351
|
+
function hasBroadDeviceGrant(scopes, access) {
|
|
49352
|
+
return scopes.some((s) => s.type === "category" && s.target === "device" && s.access.includes(access));
|
|
49353
|
+
}
|
|
49354
|
+
/** The `device`-typed scopes granting `access`. */
|
|
49355
|
+
function deviceScopesFor(scopes, access) {
|
|
49356
|
+
return scopes.filter((s) => s.type === "device" && s.access.includes(access));
|
|
49357
|
+
}
|
|
49358
|
+
/** Whether inheritance applies for a grant at `access` (view inherits, others
|
|
49359
|
+
* don't, unless the grant overrides via `includeLinked`). */
|
|
49360
|
+
function scopeInherits(scope, access) {
|
|
49361
|
+
return scope.includeLinked ?? access === "view";
|
|
49362
|
+
}
|
|
49363
|
+
/** Ancestors (parent, grandparent, …) of `deviceId` within a fleet map. */
|
|
49364
|
+
function ancestorsOf(deviceId, byId) {
|
|
49365
|
+
const out = [];
|
|
49366
|
+
let current = deviceId;
|
|
49367
|
+
for (let hop = 0; hop < MAX_ANCESTOR_HOPS$1; hop++) {
|
|
49368
|
+
const parent = current === null ? null : byId.get(current)?.parentDeviceId ?? null;
|
|
49369
|
+
if (parent === null || parent === current) break;
|
|
49370
|
+
out.push(parent);
|
|
49371
|
+
current = parent;
|
|
49372
|
+
}
|
|
49373
|
+
return out;
|
|
49374
|
+
}
|
|
49375
|
+
/**
|
|
49376
|
+
* Resolve the set of deviceIds the caller may reach at `access`, expanded
|
|
49377
|
+
* against the live `fleet`. A `category:device` grant returns the whole
|
|
49378
|
+
* fleet. Device selectors are matched per device — directly, or (when the
|
|
49379
|
+
* grant inherits) against an ancestor, so a parent grant covers its
|
|
49380
|
+
* accessories.
|
|
49381
|
+
*/
|
|
49382
|
+
function resolveViewableDeviceIds(scopes, fleet, access) {
|
|
49383
|
+
if (hasBroadDeviceGrant(scopes, access)) return new Set(fleet.map((d) => d.id));
|
|
49384
|
+
const byId = /* @__PURE__ */ new Map();
|
|
49385
|
+
for (const d of fleet) byId.set(d.id, d);
|
|
49386
|
+
const grants = deviceScopesFor(scopes, access);
|
|
49387
|
+
const out = /* @__PURE__ */ new Set();
|
|
49388
|
+
for (const d of fleet) for (const grant of grants) {
|
|
49389
|
+
if (deviceSelectorMatches(grant.selector, d)) {
|
|
49390
|
+
out.add(d.id);
|
|
49391
|
+
break;
|
|
49392
|
+
}
|
|
49393
|
+
if (scopeInherits(grant, access)) {
|
|
49394
|
+
if (ancestorsOf(d.id, byId).some((ancestorId) => {
|
|
49395
|
+
const ancestor = byId.get(ancestorId);
|
|
49396
|
+
return ancestor !== void 0 && deviceSelectorMatches(grant.selector, ancestor);
|
|
49397
|
+
})) {
|
|
49398
|
+
out.add(d.id);
|
|
49399
|
+
break;
|
|
49400
|
+
}
|
|
49401
|
+
}
|
|
49402
|
+
}
|
|
49403
|
+
return out;
|
|
49404
|
+
}
|
|
49263
49405
|
var MB = 1024 * 1024;
|
|
49264
49406
|
1024 * MB, 3072 * MB;
|
|
49265
49407
|
//#endregion
|
|
@@ -49280,17 +49422,29 @@ var MB = 1024 * 1024;
|
|
|
49280
49422
|
* `/addon/export-alexa/admin/redetect-hub-url` route.
|
|
49281
49423
|
*/
|
|
49282
49424
|
var exportAlexaActions = defineCustomActions({
|
|
49283
|
-
redetectHubUrl: customAction(object({}).optional(), object({ publicHubUrl: string() }), {
|
|
49284
|
-
|
|
49425
|
+
redetectHubUrl: customAction(object({}).optional(), object({ publicHubUrl: string() }), {
|
|
49426
|
+
kind: "mutation",
|
|
49427
|
+
auth: "admin"
|
|
49428
|
+
}),
|
|
49429
|
+
regenerateIngressToken: customAction(object({}).optional(), object({ ingressToken: string() }), {
|
|
49430
|
+
kind: "mutation",
|
|
49431
|
+
auth: "admin"
|
|
49432
|
+
}),
|
|
49285
49433
|
resyncDiscovery: customAction(object({}).optional(), object({
|
|
49286
49434
|
endpointCount: number(),
|
|
49287
49435
|
accountCount: number()
|
|
49288
|
-
}), {
|
|
49436
|
+
}), {
|
|
49437
|
+
kind: "mutation",
|
|
49438
|
+
auth: "admin"
|
|
49439
|
+
}),
|
|
49289
49440
|
purgeStaleEndpoints: customAction(object({}).optional(), object({
|
|
49290
49441
|
requested: number(),
|
|
49291
49442
|
accountCount: number(),
|
|
49292
49443
|
attemptedAccounts: number()
|
|
49293
|
-
}), {
|
|
49444
|
+
}), {
|
|
49445
|
+
kind: "mutation",
|
|
49446
|
+
auth: "admin"
|
|
49447
|
+
}),
|
|
49294
49448
|
/**
|
|
49295
49449
|
* Sever the Amazon account link from this side — the only severance path
|
|
49296
49450
|
* that exists, because the descriptor sets `refreshTokenTtlSec: 'never'`
|
|
@@ -49307,16 +49461,31 @@ var exportAlexaActions = defineCustomActions({
|
|
|
49307
49461
|
unlinkAlexaAccounts: customAction(object({}).optional(), object({
|
|
49308
49462
|
revokedSessions: number(),
|
|
49309
49463
|
removedAccounts: number()
|
|
49310
|
-
}), {
|
|
49464
|
+
}), {
|
|
49465
|
+
kind: "mutation",
|
|
49466
|
+
auth: "admin"
|
|
49467
|
+
}),
|
|
49311
49468
|
/**
|
|
49312
49469
|
* Swap the per-link credential this hub presents to the skill Lambda's
|
|
49313
49470
|
* token broker (D753) for a fresh one; the old one dies at once. Rotation
|
|
49314
49471
|
* also happens implicitly on every re-link.
|
|
49315
49472
|
*/
|
|
49473
|
+
/**
|
|
49474
|
+
* The ingress token, in clear, for an administrator who asked for it. The
|
|
49475
|
+
* one read surface that returns it: `deviceExport.getStatus` and the
|
|
49476
|
+
* settings form carry only a mask.
|
|
49477
|
+
*/
|
|
49478
|
+
revealIngressToken: customAction(object({}).optional(), object({ ingressToken: string() }), {
|
|
49479
|
+
kind: "mutation",
|
|
49480
|
+
auth: "admin"
|
|
49481
|
+
}),
|
|
49316
49482
|
rotateBrokerCredential: customAction(object({}).optional(), object({
|
|
49317
49483
|
rotated: number(),
|
|
49318
49484
|
failed: number()
|
|
49319
|
-
}), {
|
|
49485
|
+
}), {
|
|
49486
|
+
kind: "mutation",
|
|
49487
|
+
auth: "admin"
|
|
49488
|
+
})
|
|
49320
49489
|
});
|
|
49321
49490
|
//#endregion
|
|
49322
49491
|
//#region src/egress/alexa-rtc-egress.ts
|
|
@@ -49456,6 +49625,21 @@ async function profileForCamStream(input) {
|
|
|
49456
49625
|
}
|
|
49457
49626
|
//#endregion
|
|
49458
49627
|
//#region src/handlers/endpoint-id.ts
|
|
49628
|
+
/** The classes a camera publishes a detection sensor for (R1). */
|
|
49629
|
+
var DETECTION_SENSOR_CLASSES = [
|
|
49630
|
+
"person",
|
|
49631
|
+
"vehicle",
|
|
49632
|
+
"animal",
|
|
49633
|
+
"package"
|
|
49634
|
+
];
|
|
49635
|
+
var DETECTION_PREFIX = "det_";
|
|
49636
|
+
function isDetectionSensorClass(value) {
|
|
49637
|
+
return DETECTION_SENSOR_CLASSES.includes(value);
|
|
49638
|
+
}
|
|
49639
|
+
/** The endpoint id of a camera's detection sensor for one class. */
|
|
49640
|
+
function detectionSensorEndpointId(deviceId, cls) {
|
|
49641
|
+
return `${deviceId}${SEPARATOR}${DETECTION_PREFIX}${cls}`;
|
|
49642
|
+
}
|
|
49459
49643
|
var SEPARATOR = "__";
|
|
49460
49644
|
function parseCameraEndpointId(raw) {
|
|
49461
49645
|
if (!raw.includes(SEPARATOR)) {
|
|
@@ -49491,6 +49675,22 @@ function parseCameraEndpointId(raw) {
|
|
|
49491
49675
|
kind: "alarm-panel",
|
|
49492
49676
|
deviceId: parent
|
|
49493
49677
|
};
|
|
49678
|
+
if (tail === "dev") return {
|
|
49679
|
+
kind: "generic",
|
|
49680
|
+
deviceId: parent
|
|
49681
|
+
};
|
|
49682
|
+
if (tail.startsWith(DETECTION_PREFIX)) {
|
|
49683
|
+
const cls = tail.slice(4);
|
|
49684
|
+
if (isDetectionSensorClass(cls)) return {
|
|
49685
|
+
kind: "detection",
|
|
49686
|
+
deviceId: parent,
|
|
49687
|
+
detectionClass: cls
|
|
49688
|
+
};
|
|
49689
|
+
return {
|
|
49690
|
+
kind: "invalid",
|
|
49691
|
+
reason: `unknown detection class in endpointId: ${raw}`
|
|
49692
|
+
};
|
|
49693
|
+
}
|
|
49494
49694
|
const child = parseNonNegativeInt(tail);
|
|
49495
49695
|
if (child === null) return {
|
|
49496
49696
|
kind: "invalid",
|
|
@@ -49672,7 +49872,12 @@ function resolveWebrtcTarget(pref) {
|
|
|
49672
49872
|
camStreamId: pref
|
|
49673
49873
|
};
|
|
49674
49874
|
}
|
|
49675
|
-
|
|
49875
|
+
/** The cap whose push-talk triple receives the Echo's microphone. */
|
|
49876
|
+
var CAP_INTERCOM$1 = "intercom";
|
|
49877
|
+
async function deviceHasIntercom(api, deviceId) {
|
|
49878
|
+
return (await api.deviceManager.getBindings.query({ deviceId })).entries.some((e) => e.capName === CAP_INTERCOM$1);
|
|
49879
|
+
}
|
|
49880
|
+
async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, deps, _callerIdentity, grant = { mayTalk: false }) {
|
|
49676
49881
|
const parsed = InitiateSessionWithOfferDirectiveSchema.safeParse({
|
|
49677
49882
|
endpoint: endpointRaw,
|
|
49678
49883
|
payload: payloadRaw ?? {}
|
|
@@ -49781,12 +49986,21 @@ async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, d
|
|
|
49781
49986
|
log.warn("alexa rtc-session: egress acquire failed, falling back to streamPreference target", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
49782
49987
|
derivedTarget = resolveWebrtcTarget(exposedEntry?.settings?.streamPreference ?? "auto");
|
|
49783
49988
|
}
|
|
49989
|
+
const talkback = grant.mayTalk && await deviceHasIntercom(api, deviceId).catch((err) => {
|
|
49990
|
+
log.warn("alexa rtc-session: intercom lookup failed — this session is one-way audio", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
49991
|
+
return false;
|
|
49992
|
+
});
|
|
49993
|
+
log.info("alexa rtc-session: talk-back decision", { meta: {
|
|
49994
|
+
talkback,
|
|
49995
|
+
mayTalk: grant.mayTalk
|
|
49996
|
+
} });
|
|
49784
49997
|
try {
|
|
49785
49998
|
const result = await api.webrtcSession.handleOffer.mutate({
|
|
49786
49999
|
deviceId,
|
|
49787
50000
|
sdpOffer,
|
|
49788
50001
|
sessionId,
|
|
49789
50002
|
target: derivedTarget,
|
|
50003
|
+
talkback,
|
|
49790
50004
|
relayOnly: false,
|
|
49791
50005
|
nonTrickle: true,
|
|
49792
50006
|
disableTurn: true,
|
|
@@ -50041,6 +50255,111 @@ async function rotateBrokerCredential(fetchImpl, brokerUrl, credential) {
|
|
|
50041
50255
|
async function revokeBrokerCredential(fetchImpl, brokerUrl, credential) {
|
|
50042
50256
|
await call(fetchImpl, brokerUrl, "/broker/revoke", { bearer: credential }, unknown());
|
|
50043
50257
|
}
|
|
50258
|
+
/** Device types this export may publish as a generic endpoint. */
|
|
50259
|
+
var GENERIC_DEVICE_TYPES = [
|
|
50260
|
+
"light",
|
|
50261
|
+
"switch",
|
|
50262
|
+
"siren",
|
|
50263
|
+
"sensor",
|
|
50264
|
+
"lock",
|
|
50265
|
+
"presence",
|
|
50266
|
+
"thermostat",
|
|
50267
|
+
"generic"
|
|
50268
|
+
];
|
|
50269
|
+
function iface(name, property) {
|
|
50270
|
+
return {
|
|
50271
|
+
type: "AlexaInterface",
|
|
50272
|
+
interface: name,
|
|
50273
|
+
version: "3",
|
|
50274
|
+
properties: {
|
|
50275
|
+
supported: [{ name: property }],
|
|
50276
|
+
proactivelyReported: true,
|
|
50277
|
+
retrievable: true
|
|
50278
|
+
}
|
|
50279
|
+
};
|
|
50280
|
+
}
|
|
50281
|
+
var BASE = {
|
|
50282
|
+
type: "AlexaInterface",
|
|
50283
|
+
interface: "Alexa",
|
|
50284
|
+
version: "3"
|
|
50285
|
+
};
|
|
50286
|
+
var HEALTH = {
|
|
50287
|
+
type: "AlexaInterface",
|
|
50288
|
+
interface: "Alexa.EndpointHealth",
|
|
50289
|
+
version: "3.1",
|
|
50290
|
+
properties: {
|
|
50291
|
+
supported: [{ name: "connectivity" }],
|
|
50292
|
+
proactivelyReported: true,
|
|
50293
|
+
retrievable: true
|
|
50294
|
+
}
|
|
50295
|
+
};
|
|
50296
|
+
var ROWS = [
|
|
50297
|
+
{
|
|
50298
|
+
cap: "lock-control",
|
|
50299
|
+
capability: iface("Alexa.LockController", "lockState"),
|
|
50300
|
+
category: "SMARTLOCK"
|
|
50301
|
+
},
|
|
50302
|
+
{
|
|
50303
|
+
cap: "contact",
|
|
50304
|
+
capability: iface("Alexa.ContactSensor", "detectionState"),
|
|
50305
|
+
category: "CONTACT_SENSOR"
|
|
50306
|
+
},
|
|
50307
|
+
{
|
|
50308
|
+
cap: "motion",
|
|
50309
|
+
capability: iface("Alexa.MotionSensor", "detectionState"),
|
|
50310
|
+
category: "MOTION_SENSOR"
|
|
50311
|
+
},
|
|
50312
|
+
{
|
|
50313
|
+
cap: "presence",
|
|
50314
|
+
capability: iface("Alexa.MotionSensor", "detectionState"),
|
|
50315
|
+
category: "MOTION_SENSOR"
|
|
50316
|
+
},
|
|
50317
|
+
{
|
|
50318
|
+
cap: "temperature-sensor",
|
|
50319
|
+
capability: iface("Alexa.TemperatureSensor", "temperature"),
|
|
50320
|
+
category: "TEMPERATURE_SENSOR"
|
|
50321
|
+
},
|
|
50322
|
+
{
|
|
50323
|
+
cap: "humidity-sensor",
|
|
50324
|
+
capability: iface("Alexa.HumiditySensor", "relativeHumidity"),
|
|
50325
|
+
category: "TEMPERATURE_SENSOR"
|
|
50326
|
+
},
|
|
50327
|
+
{
|
|
50328
|
+
cap: "switch",
|
|
50329
|
+
capability: iface("Alexa.PowerController", "powerState"),
|
|
50330
|
+
category: "SWITCH"
|
|
50331
|
+
}
|
|
50332
|
+
];
|
|
50333
|
+
var BRIGHTNESS = iface("Alexa.BrightnessController", "brightness");
|
|
50334
|
+
/** The endpoint for a non-camera device, or null when none of its caps has an
|
|
50335
|
+
* Alexa interface. */
|
|
50336
|
+
function buildGenericDeviceEndpoint(input) {
|
|
50337
|
+
const capabilities = [BASE];
|
|
50338
|
+
const seen = /* @__PURE__ */ new Set();
|
|
50339
|
+
let category = null;
|
|
50340
|
+
for (const row of ROWS) {
|
|
50341
|
+
if (!input.capNames.includes(row.cap)) continue;
|
|
50342
|
+
if (seen.has(row.capability.interface)) continue;
|
|
50343
|
+
seen.add(row.capability.interface);
|
|
50344
|
+
capabilities.push(row.capability);
|
|
50345
|
+
category ??= row.category;
|
|
50346
|
+
}
|
|
50347
|
+
if (category === null) return null;
|
|
50348
|
+
const dimmable = input.capNames.includes("switch") && input.capNames.includes("brightness");
|
|
50349
|
+
if (dimmable) capabilities.push(BRIGHTNESS);
|
|
50350
|
+
if (category === "SWITCH" && (dimmable || input.type === "light")) category = "LIGHT";
|
|
50351
|
+
capabilities.push(HEALTH);
|
|
50352
|
+
const name = input.name.trim();
|
|
50353
|
+
return {
|
|
50354
|
+
endpointId: `${input.deviceId}__dev`,
|
|
50355
|
+
manufacturerName: "camstack",
|
|
50356
|
+
description: `CamStack ${input.type || "device"}`,
|
|
50357
|
+
friendlyName: name.length > 0 ? name : `Device ${input.deviceId}`,
|
|
50358
|
+
displayCategories: [category],
|
|
50359
|
+
capabilities,
|
|
50360
|
+
cookie: { stableId: input.stableId }
|
|
50361
|
+
};
|
|
50362
|
+
}
|
|
50044
50363
|
//#endregion
|
|
50045
50364
|
//#region src/handlers/security-panel-arm-state.ts
|
|
50046
50365
|
/**
|
|
@@ -50142,6 +50461,34 @@ var CAMERA_STREAM_CONTROLLER = {
|
|
|
50142
50461
|
audioCodecs: ["AAC"]
|
|
50143
50462
|
}]
|
|
50144
50463
|
};
|
|
50464
|
+
/**
|
|
50465
|
+
* `Alexa.EndpointHealth` v3.1 — `connectivity`, retrievable and proactive.
|
|
50466
|
+
*
|
|
50467
|
+
* Required by Amazon on every sensor endpoint (a camera with MotionSensor is
|
|
50468
|
+
* one) and for live view it is load-bearing: the first time it was declared,
|
|
50469
|
+
* the hub only ever reported a TRANSITION and ReportState never answered it,
|
|
50470
|
+
* so Alexa held no value — and the phone app refused live view on an endpoint
|
|
50471
|
+
* of unknown health while an Echo Show kept streaming. It is declared ONLY
|
|
50472
|
+
* together with what keeps a value in Alexa's hands (D770):
|
|
50473
|
+
*
|
|
50474
|
+
* - `handlers/report-state.ts` answers `connectivity` for every endpoint
|
|
50475
|
+
* that declares it (`OK` when the status read itself fails);
|
|
50476
|
+
* - `LwaEventGateway.scheduleInitialConnectivity` reports it after every
|
|
50477
|
+
* AddOrUpdateReport and every Discover;
|
|
50478
|
+
* - the gateway's online/offline ChangeReports keep it current.
|
|
50479
|
+
*
|
|
50480
|
+
* `endpoint-health.spec.ts` pins all three.
|
|
50481
|
+
*/
|
|
50482
|
+
var ENDPOINT_HEALTH = {
|
|
50483
|
+
type: "AlexaInterface",
|
|
50484
|
+
interface: "Alexa.EndpointHealth",
|
|
50485
|
+
version: "3.1",
|
|
50486
|
+
properties: {
|
|
50487
|
+
supported: [{ name: "connectivity" }],
|
|
50488
|
+
proactivelyReported: true,
|
|
50489
|
+
retrievable: true
|
|
50490
|
+
}
|
|
50491
|
+
};
|
|
50145
50492
|
var DOORBELL_EVENT_SOURCE = {
|
|
50146
50493
|
type: "AlexaInterface",
|
|
50147
50494
|
interface: "Alexa.DoorbellEventSource",
|
|
@@ -50158,6 +50505,26 @@ var MOTION_SENSOR = {
|
|
|
50158
50505
|
retrievable: true
|
|
50159
50506
|
}
|
|
50160
50507
|
};
|
|
50508
|
+
/**
|
|
50509
|
+
* `Alexa.SmartVision.SnapshotProvider` v1.1 — the camera's still in the Alexa
|
|
50510
|
+
* app (R2, D773). Available in it-IT and en-GB, unlike object detection.
|
|
50511
|
+
* Declared on every camera while the integration's "Snapshots" switch is on;
|
|
50512
|
+
* `GetSnapshot` answers with a signed, ten-minute, account-token-checked image
|
|
50513
|
+
* on the hub's public address (`handlers/snapshot-provider.ts`).
|
|
50514
|
+
*
|
|
50515
|
+
* `minRefreshIntervalInSeconds` bounds how often Alexa may ask per camera:
|
|
50516
|
+
* every ask is a snapshot read and a re-encode on the hub, and a picture of the
|
|
50517
|
+
* home handed to Amazon, so the tile refreshes at most every few minutes.
|
|
50518
|
+
*/
|
|
50519
|
+
var SMART_VISION_SNAPSHOT_PROVIDER = {
|
|
50520
|
+
type: "AlexaInterface",
|
|
50521
|
+
interface: "Alexa.SmartVision.SnapshotProvider",
|
|
50522
|
+
version: "1.1",
|
|
50523
|
+
configuration: {
|
|
50524
|
+
isAvailable: true,
|
|
50525
|
+
minRefreshIntervalInSeconds: 180
|
|
50526
|
+
}
|
|
50527
|
+
};
|
|
50161
50528
|
function rtcSessionController(fullDuplexAudio) {
|
|
50162
50529
|
return {
|
|
50163
50530
|
type: "AlexaInterface",
|
|
@@ -50166,13 +50533,15 @@ function rtcSessionController(fullDuplexAudio) {
|
|
|
50166
50533
|
configuration: { isFullDuplexAudioSupported: fullDuplexAudio }
|
|
50167
50534
|
};
|
|
50168
50535
|
}
|
|
50536
|
+
/** Proactively reported (R3, D776): `StateChangeReporter` pushes every
|
|
50537
|
+
* `switch` / `privacy-mask` / `ptz-autotrack` / `brightness` change. */
|
|
50169
50538
|
var POWER_CONTROLLER = {
|
|
50170
50539
|
type: "AlexaInterface",
|
|
50171
50540
|
interface: "Alexa.PowerController",
|
|
50172
50541
|
version: "3",
|
|
50173
50542
|
properties: {
|
|
50174
50543
|
supported: [{ name: "powerState" }],
|
|
50175
|
-
proactivelyReported:
|
|
50544
|
+
proactivelyReported: true,
|
|
50176
50545
|
retrievable: true
|
|
50177
50546
|
}
|
|
50178
50547
|
};
|
|
@@ -50182,16 +50551,6 @@ var BRIGHTNESS_CONTROLLER = {
|
|
|
50182
50551
|
version: "3",
|
|
50183
50552
|
properties: {
|
|
50184
50553
|
supported: [{ name: "brightness" }],
|
|
50185
|
-
proactivelyReported: false,
|
|
50186
|
-
retrievable: true
|
|
50187
|
-
}
|
|
50188
|
-
};
|
|
50189
|
-
var BATTERY_LEVEL_SENSOR = {
|
|
50190
|
-
type: "AlexaInterface",
|
|
50191
|
-
interface: "Alexa.BatteryLevelSensor",
|
|
50192
|
-
version: "3",
|
|
50193
|
-
properties: {
|
|
50194
|
-
supported: [{ name: "batteryLevel" }],
|
|
50195
50554
|
proactivelyReported: true,
|
|
50196
50555
|
retrievable: true
|
|
50197
50556
|
}
|
|
@@ -50364,11 +50723,12 @@ function buildParentCameraEndpoint(input) {
|
|
|
50364
50723
|
const capabilities = [
|
|
50365
50724
|
ALEXA_BASE_CAPABILITY,
|
|
50366
50725
|
CAMERA_STREAM_CONTROLLER,
|
|
50367
|
-
rtcSessionController(input.hasIntercom)
|
|
50726
|
+
rtcSessionController(input.hasIntercom),
|
|
50727
|
+
ENDPOINT_HEALTH
|
|
50368
50728
|
];
|
|
50369
50729
|
if (input.isDoorbell) capabilities.push(DOORBELL_EVENT_SOURCE);
|
|
50370
50730
|
if (input.hasMotion) capabilities.push(MOTION_SENSOR);
|
|
50371
|
-
if (input.
|
|
50731
|
+
if (input.snapshotProvider === true) capabilities.push(SMART_VISION_SNAPSHOT_PROVIDER);
|
|
50372
50732
|
const presetNames = distinctPresetNames(input.ptzPresetNames);
|
|
50373
50733
|
if (presetNames.length >= MIN_MODE_CONTROLLER_MODES) capabilities.push(ptzPresetModeController(presetNames));
|
|
50374
50734
|
if (input.hasPtz) {
|
|
@@ -50475,10 +50835,10 @@ function buildPrivacyEndpoint(input) {
|
|
|
50475
50835
|
* declared: `alarm-panel` has no field saying WHICH alarm is sounding, and
|
|
50476
50836
|
* declaring a property we would always answer `OK` on is a claim with no
|
|
50477
50837
|
* basis behind it. The reasoning is in `security-panel-arm-state.ts`.
|
|
50478
|
-
* - `proactivelyReported:
|
|
50479
|
-
*
|
|
50480
|
-
*
|
|
50481
|
-
*
|
|
50838
|
+
* - `proactivelyReported: true` (R3, D776). Amazon asks for alarms to be
|
|
50839
|
+
* reported proactively; `StateChangeReporter` pushes every `alarm-panel`
|
|
50840
|
+
* slice change (arm state and the burglary alarm). `retrievable: true` —
|
|
50841
|
+
* `Alexa/ReportState` answers too.
|
|
50482
50842
|
* - `supportedAuthorizationTypes` appears only for a panel that requires a
|
|
50483
50843
|
* code. Alexa's Disarm directive can carry a PIN; its **Arm directive
|
|
50484
50844
|
* cannot**, which is why a PIN-gated arm is refused by the handler with
|
|
@@ -50493,7 +50853,7 @@ function buildAlarmPanelEndpoint(input) {
|
|
|
50493
50853
|
version: "3",
|
|
50494
50854
|
properties: {
|
|
50495
50855
|
supported: [{ name: "armState" }, { name: "burglaryAlarm" }],
|
|
50496
|
-
proactivelyReported:
|
|
50856
|
+
proactivelyReported: true,
|
|
50497
50857
|
retrievable: true
|
|
50498
50858
|
},
|
|
50499
50859
|
configuration
|
|
@@ -50508,6 +50868,42 @@ function buildAlarmPanelEndpoint(input) {
|
|
|
50508
50868
|
cookie: { stableId: input.stableId }
|
|
50509
50869
|
};
|
|
50510
50870
|
}
|
|
50871
|
+
/** How a detection sensor is named after its camera. English, like every
|
|
50872
|
+
* other name this skill publishes; the operator can rename it in the app. */
|
|
50873
|
+
var DETECTION_SENSOR_LABELS = {
|
|
50874
|
+
person: "Person",
|
|
50875
|
+
vehicle: "Vehicle",
|
|
50876
|
+
animal: "Animal",
|
|
50877
|
+
package: "Package"
|
|
50878
|
+
};
|
|
50879
|
+
/**
|
|
50880
|
+
* One class of a camera's detections as an `Alexa.MotionSensor` endpoint —
|
|
50881
|
+
* "Front door Person" (R1, D772). It is what an it-IT / en-GB account can use
|
|
50882
|
+
* as a routine trigger ("when Front door Person detects motion → announce…"):
|
|
50883
|
+
* Amazon's object-detection interface is en-US only, MotionSensor is not.
|
|
50884
|
+
* Driven by the pipeline's tracks, not by the camera's own motion. Sensors
|
|
50885
|
+
* need `Alexa.EndpointHealth` (Amazon), reported like the camera's (D770).
|
|
50886
|
+
*/
|
|
50887
|
+
function buildDetectionSensorEndpoint(input) {
|
|
50888
|
+
const label = DETECTION_SENSOR_LABELS[input.detectionClass];
|
|
50889
|
+
const camera = input.cameraName.trim().length > 0 ? input.cameraName.trim() : `Camera ${input.deviceId}`;
|
|
50890
|
+
return {
|
|
50891
|
+
endpointId: detectionSensorEndpointId(input.deviceId, input.detectionClass),
|
|
50892
|
+
manufacturerName: "camstack",
|
|
50893
|
+
description: `CamStack ${input.detectionClass} detection`,
|
|
50894
|
+
friendlyName: `${camera} ${label}`,
|
|
50895
|
+
displayCategories: ["MOTION_SENSOR"],
|
|
50896
|
+
capabilities: [
|
|
50897
|
+
ALEXA_BASE_CAPABILITY,
|
|
50898
|
+
MOTION_SENSOR,
|
|
50899
|
+
ENDPOINT_HEALTH
|
|
50900
|
+
],
|
|
50901
|
+
cookie: {
|
|
50902
|
+
stableId: input.stableId,
|
|
50903
|
+
detectionClass: input.detectionClass
|
|
50904
|
+
}
|
|
50905
|
+
};
|
|
50906
|
+
}
|
|
50511
50907
|
//#endregion
|
|
50512
50908
|
//#region src/handlers/ptz-preset-recallable.ts
|
|
50513
50909
|
var RECALLABLE_FUNCTIONS = new Set(["home"]);
|
|
@@ -50527,12 +50923,13 @@ var CAP_PTZ = "ptz";
|
|
|
50527
50923
|
var CAP_PTZ_AUTOTRACK = "ptz-autotrack";
|
|
50528
50924
|
var CAP_SWITCH = "switch";
|
|
50529
50925
|
var CAP_BRIGHTNESS = "brightness";
|
|
50530
|
-
var CAP_BATTERY = "battery";
|
|
50531
50926
|
var CAP_PRIVACY_MASK = "privacy-mask";
|
|
50532
50927
|
var CAP_ALARM_PANEL = "alarm-panel";
|
|
50533
|
-
async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
|
|
50928
|
+
async function handleDiscovery(header, payloadRaw, deps, callerIdentity, visibility) {
|
|
50534
50929
|
if (!discoveryPayloadSchema.safeParse(payloadRaw).success) return errorEnvelope(header, "INVALID_DIRECTIVE", "Discovery payload shape unexpected");
|
|
50535
|
-
const
|
|
50930
|
+
const allExposed = await deps.listExposed();
|
|
50931
|
+
const exposed = [];
|
|
50932
|
+
for (const entry of allExposed) if (await visibility.deviceVisible(entry.deviceId)) exposed.push(entry);
|
|
50536
50933
|
const built = new Array(exposed.length).fill(null);
|
|
50537
50934
|
const queue = exposed.map((entry, index) => ({
|
|
50538
50935
|
entry,
|
|
@@ -50543,7 +50940,7 @@ async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
|
|
|
50543
50940
|
const item = queue.shift();
|
|
50544
50941
|
if (item === void 0) return;
|
|
50545
50942
|
try {
|
|
50546
|
-
built[item.index] = await
|
|
50943
|
+
built[item.index] = await deps.buildEndpoints(item.entry.deviceId);
|
|
50547
50944
|
} catch (err) {
|
|
50548
50945
|
deps.logger.warn("alexa discovery (on-demand): skipping device — build failed", {
|
|
50549
50946
|
tags: { deviceId: item.entry.deviceId },
|
|
@@ -50554,10 +50951,12 @@ async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
|
|
|
50554
50951
|
};
|
|
50555
50952
|
const lanes = Math.min(DISCOVERY_BUILD_CONCURRENCY, Math.max(1, queue.length));
|
|
50556
50953
|
await Promise.all(Array.from({ length: lanes }, lane));
|
|
50557
|
-
const endpoints = built.flatMap((b) => b ?? []);
|
|
50954
|
+
const endpoints = await visibility.filterEndpoints(built.flatMap((b) => b ?? []));
|
|
50558
50955
|
deps.logger.info("alexa discovery returned endpoints", { meta: {
|
|
50559
50956
|
count: endpoints.length,
|
|
50560
|
-
userId: callerIdentity.userId
|
|
50957
|
+
userId: callerIdentity.userId,
|
|
50958
|
+
exposedDevices: allExposed.length,
|
|
50959
|
+
visibleDevices: exposed.length
|
|
50561
50960
|
} });
|
|
50562
50961
|
return discoveryResponse(header, endpoints);
|
|
50563
50962
|
}
|
|
@@ -50569,6 +50968,26 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
|
|
|
50569
50968
|
return [];
|
|
50570
50969
|
}
|
|
50571
50970
|
const parentInfo = await loadBoundDeviceInfo(device, api);
|
|
50971
|
+
if (GENERIC_DEVICE_TYPES.includes(String(device.type)) && !parentInfo.capNames.includes(CAP_ALARM_PANEL)) {
|
|
50972
|
+
const generic = buildGenericDeviceEndpoint({
|
|
50973
|
+
deviceId: parentInfo.id,
|
|
50974
|
+
stableId: parentInfo.stableId,
|
|
50975
|
+
name: parentInfo.name,
|
|
50976
|
+
type: String(device.type),
|
|
50977
|
+
capNames: parentInfo.capNames
|
|
50978
|
+
});
|
|
50979
|
+
if (generic === null) {
|
|
50980
|
+
deps.logger.warn("alexa discovery: device has no cap Alexa has an interface for — not published", {
|
|
50981
|
+
tags: { deviceId },
|
|
50982
|
+
meta: {
|
|
50983
|
+
type: device.type,
|
|
50984
|
+
capNames: parentInfo.capNames
|
|
50985
|
+
}
|
|
50986
|
+
});
|
|
50987
|
+
return [];
|
|
50988
|
+
}
|
|
50989
|
+
return [generic];
|
|
50990
|
+
}
|
|
50572
50991
|
if (parentInfo.capNames.includes(CAP_ALARM_PANEL)) return [await buildAlarmPanelEndpointFor(parentInfo, api, deps)];
|
|
50573
50992
|
const children = await api.deviceManager.getChildren.query({ parentDeviceId: deviceId }).catch((err) => {
|
|
50574
50993
|
deps.logger.warn("alexa discovery: getChildren failed", {
|
|
@@ -50584,7 +51003,6 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
|
|
|
50584
51003
|
const hasMotion = parentInfo.capNames.includes(CAP_MOTION);
|
|
50585
51004
|
const hasAutotrack = parentInfo.capNames.includes(CAP_PTZ_AUTOTRACK);
|
|
50586
51005
|
const hasPtz = parentInfo.capNames.includes(CAP_PTZ);
|
|
50587
|
-
const hasBattery = parentInfo.capNames.includes(CAP_BATTERY);
|
|
50588
51006
|
const hasPrivacy = parentInfo.capNames.includes(CAP_PRIVACY_MASK);
|
|
50589
51007
|
deps.logger.info("alexa discovery: built feature flags", {
|
|
50590
51008
|
tags: { deviceId: parentInfo.id },
|
|
@@ -50592,7 +51010,6 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
|
|
|
50592
51010
|
name: parentInfo.name,
|
|
50593
51011
|
capNames: parentInfo.capNames,
|
|
50594
51012
|
hasMotion,
|
|
50595
|
-
hasBattery,
|
|
50596
51013
|
hasPtz,
|
|
50597
51014
|
hasAutotrack,
|
|
50598
51015
|
hasPrivacy,
|
|
@@ -50617,9 +51034,18 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
|
|
|
50617
51034
|
hasIntercom,
|
|
50618
51035
|
hasMotion,
|
|
50619
51036
|
ptzPresetNames,
|
|
50620
|
-
|
|
50621
|
-
|
|
51037
|
+
hasPtz,
|
|
51038
|
+
snapshotProvider: deps.snapshots === true
|
|
50622
51039
|
}));
|
|
51040
|
+
if (deps.detectionClasses !== void 0) {
|
|
51041
|
+
const classes = await deps.detectionClasses(parentInfo.id);
|
|
51042
|
+
for (const detectionClass of classes) out.push(buildDetectionSensorEndpoint({
|
|
51043
|
+
deviceId: parentInfo.id,
|
|
51044
|
+
stableId: parentInfo.stableId,
|
|
51045
|
+
cameraName: parentInfo.name,
|
|
51046
|
+
detectionClass
|
|
51047
|
+
}));
|
|
51048
|
+
}
|
|
50623
51049
|
if (hasAutotrack) out.push(buildAutotrackEndpoint({
|
|
50624
51050
|
parentDeviceId: parentInfo.id,
|
|
50625
51051
|
parentStableId: parentInfo.stableId,
|
|
@@ -50722,6 +51148,476 @@ function discoveryResponse(header, endpoints) {
|
|
|
50722
51148
|
payload: { endpoints }
|
|
50723
51149
|
} };
|
|
50724
51150
|
}
|
|
51151
|
+
/** Ancestor-walk bound — defence against a corrupt parentage cycle. */
|
|
51152
|
+
var MAX_ANCESTOR_HOPS = 8;
|
|
51153
|
+
function principalFromGrant(grant) {
|
|
51154
|
+
return grant.isAdmin ? {
|
|
51155
|
+
kind: "admin",
|
|
51156
|
+
userId: grant.userId
|
|
51157
|
+
} : {
|
|
51158
|
+
kind: "scoped",
|
|
51159
|
+
userId: grant.userId,
|
|
51160
|
+
scopes: grant.scopes
|
|
51161
|
+
};
|
|
51162
|
+
}
|
|
51163
|
+
/**
|
|
51164
|
+
* Does `principal` reach `chain[0]` at `access`? `chain` is the device followed
|
|
51165
|
+
* by its ancestors (parent first), each with the metadata a selector needs.
|
|
51166
|
+
* Pure — the one derivation shared by directives, discovery and the event mirror.
|
|
51167
|
+
*/
|
|
51168
|
+
function principalAllowsDevice(principal, chain, access) {
|
|
51169
|
+
if (principal.kind === "admin") return true;
|
|
51170
|
+
const target = chain[0];
|
|
51171
|
+
if (target === void 0) return false;
|
|
51172
|
+
return resolveViewableDeviceIds(principal.scopes, chain, access).has(target.id);
|
|
51173
|
+
}
|
|
51174
|
+
function endpointAccessTarget(parsed) {
|
|
51175
|
+
switch (parsed.kind) {
|
|
51176
|
+
case "camera": return {
|
|
51177
|
+
exposedDeviceId: parsed.deviceId,
|
|
51178
|
+
deviceId: parsed.deviceId,
|
|
51179
|
+
requiresParent: null,
|
|
51180
|
+
ownAccess: "view"
|
|
51181
|
+
};
|
|
51182
|
+
case "privacy":
|
|
51183
|
+
case "autotrack": return {
|
|
51184
|
+
exposedDeviceId: parsed.parentDeviceId,
|
|
51185
|
+
deviceId: parsed.parentDeviceId,
|
|
51186
|
+
requiresParent: null,
|
|
51187
|
+
ownAccess: "create"
|
|
51188
|
+
};
|
|
51189
|
+
case "child": return {
|
|
51190
|
+
exposedDeviceId: parsed.parentDeviceId,
|
|
51191
|
+
deviceId: parsed.childDeviceId,
|
|
51192
|
+
requiresParent: parsed.parentDeviceId,
|
|
51193
|
+
ownAccess: "create"
|
|
51194
|
+
};
|
|
51195
|
+
case "alarm-panel":
|
|
51196
|
+
case "detection":
|
|
51197
|
+
case "generic": return {
|
|
51198
|
+
exposedDeviceId: parsed.deviceId,
|
|
51199
|
+
deviceId: parsed.deviceId,
|
|
51200
|
+
requiresParent: null,
|
|
51201
|
+
ownAccess: "view"
|
|
51202
|
+
};
|
|
51203
|
+
case "invalid": return null;
|
|
51204
|
+
}
|
|
51205
|
+
}
|
|
51206
|
+
var AlexaAccessGate = class {
|
|
51207
|
+
deps;
|
|
51208
|
+
grants = null;
|
|
51209
|
+
grantsFlight = null;
|
|
51210
|
+
meta = /* @__PURE__ */ new Map();
|
|
51211
|
+
warnedStale = false;
|
|
51212
|
+
constructor(deps) {
|
|
51213
|
+
this.deps = deps;
|
|
51214
|
+
}
|
|
51215
|
+
now() {
|
|
51216
|
+
return this.deps.now?.() ?? Date.now();
|
|
51217
|
+
}
|
|
51218
|
+
/** Resolve the principal a link with this `userId` acts for. */
|
|
51219
|
+
async principal(userId) {
|
|
51220
|
+
const snapshot = await this.readGrants();
|
|
51221
|
+
if (snapshot === null) return {
|
|
51222
|
+
kind: "unavailable",
|
|
51223
|
+
error: "the hub user store could not be read"
|
|
51224
|
+
};
|
|
51225
|
+
const grant = snapshot.byUserId.get(userId);
|
|
51226
|
+
if (grant === void 0) return { kind: "unknown-user" };
|
|
51227
|
+
return {
|
|
51228
|
+
kind: "found",
|
|
51229
|
+
principal: principalFromGrant(grant)
|
|
51230
|
+
};
|
|
51231
|
+
}
|
|
51232
|
+
/** Does `principal` reach `deviceId` at `access`? */
|
|
51233
|
+
async allows(principal, deviceId, access) {
|
|
51234
|
+
if (principal.kind === "admin") return "allowed";
|
|
51235
|
+
const chain = await this.chainOf(deviceId);
|
|
51236
|
+
if (chain === null) return "unknown";
|
|
51237
|
+
return principalAllowsDevice(principal, chain, access) ? "allowed" : "denied";
|
|
51238
|
+
}
|
|
51239
|
+
/** The device's parent per the registry; `undefined` when unknown. */
|
|
51240
|
+
async parentOf(deviceId) {
|
|
51241
|
+
const meta = await this.metaOf(deviceId);
|
|
51242
|
+
return meta === null ? void 0 : meta.parentDeviceId;
|
|
51243
|
+
}
|
|
51244
|
+
/** Forget everything cached — for tests and an operator-driven re-sync. */
|
|
51245
|
+
invalidate() {
|
|
51246
|
+
this.grants = null;
|
|
51247
|
+
this.meta.clear();
|
|
51248
|
+
}
|
|
51249
|
+
async readGrants() {
|
|
51250
|
+
const cached = this.grants;
|
|
51251
|
+
if (cached !== null && this.now() - cached.at < 3e4) return cached;
|
|
51252
|
+
if (this.grantsFlight === null) this.grantsFlight = this.deps.listUserGrants().then((list) => {
|
|
51253
|
+
const byUserId = /* @__PURE__ */ new Map();
|
|
51254
|
+
for (const g of list) byUserId.set(g.userId, g);
|
|
51255
|
+
const snapshot = {
|
|
51256
|
+
at: this.now(),
|
|
51257
|
+
byUserId
|
|
51258
|
+
};
|
|
51259
|
+
this.grants = snapshot;
|
|
51260
|
+
this.warnedStale = false;
|
|
51261
|
+
return snapshot;
|
|
51262
|
+
}).finally(() => {
|
|
51263
|
+
this.grantsFlight = null;
|
|
51264
|
+
});
|
|
51265
|
+
try {
|
|
51266
|
+
return await this.grantsFlight;
|
|
51267
|
+
} catch (err) {
|
|
51268
|
+
if (cached !== null) {
|
|
51269
|
+
if (!this.warnedStale) {
|
|
51270
|
+
this.warnedStale = true;
|
|
51271
|
+
this.deps.logger.warn("alexa access: the hub user store could not be re-read — keeping the last good grants", { meta: {
|
|
51272
|
+
error: errMsg(err),
|
|
51273
|
+
ageMs: this.now() - cached.at
|
|
51274
|
+
} });
|
|
51275
|
+
}
|
|
51276
|
+
return cached;
|
|
51277
|
+
}
|
|
51278
|
+
this.deps.logger.warn("alexa access: the hub user store could not be read — refusing Alexa access until it can", { meta: { error: errMsg(err) } });
|
|
51279
|
+
return null;
|
|
51280
|
+
}
|
|
51281
|
+
}
|
|
51282
|
+
async metaOf(deviceId) {
|
|
51283
|
+
const cached = this.meta.get(deviceId);
|
|
51284
|
+
if (cached !== void 0 && this.now() - cached.at < 6e4) return cached.meta;
|
|
51285
|
+
try {
|
|
51286
|
+
const meta = await this.deps.getDeviceMeta(deviceId);
|
|
51287
|
+
this.meta.set(deviceId, {
|
|
51288
|
+
at: this.now(),
|
|
51289
|
+
meta
|
|
51290
|
+
});
|
|
51291
|
+
return meta;
|
|
51292
|
+
} catch (err) {
|
|
51293
|
+
this.deps.logger.debug("alexa access: device metadata read failed", {
|
|
51294
|
+
tags: { deviceId },
|
|
51295
|
+
meta: { error: errMsg(err) }
|
|
51296
|
+
});
|
|
51297
|
+
return cached?.meta ?? null;
|
|
51298
|
+
}
|
|
51299
|
+
}
|
|
51300
|
+
/** The device followed by its ancestors, or `null` if the device is unknown.
|
|
51301
|
+
* An unknown ANCESTOR ends the walk — inheritance through it is simply not
|
|
51302
|
+
* granted (fail closed). */
|
|
51303
|
+
async chainOf(deviceId) {
|
|
51304
|
+
const self = await this.metaOf(deviceId);
|
|
51305
|
+
if (self === null) return null;
|
|
51306
|
+
const chain = [self];
|
|
51307
|
+
let parent = self.parentDeviceId;
|
|
51308
|
+
for (let hop = 0; hop < MAX_ANCESTOR_HOPS && parent !== null; hop++) {
|
|
51309
|
+
if (chain.some((d) => d.id === parent)) break;
|
|
51310
|
+
const meta = await this.metaOf(parent);
|
|
51311
|
+
if (meta === null) break;
|
|
51312
|
+
chain.push(meta);
|
|
51313
|
+
parent = meta.parentDeviceId;
|
|
51314
|
+
}
|
|
51315
|
+
return chain;
|
|
51316
|
+
}
|
|
51317
|
+
};
|
|
51318
|
+
//#endregion
|
|
51319
|
+
//#region src/connectivity.ts
|
|
51320
|
+
/** The one field read off the `device-status` slice (its wire output is
|
|
51321
|
+
* untyped here). */
|
|
51322
|
+
var OnlineSchema$1 = object({ online: boolean() });
|
|
51323
|
+
async function readConnectivity(api, deviceId, logger) {
|
|
51324
|
+
try {
|
|
51325
|
+
const parsed = OnlineSchema$1.safeParse(await api.deviceStatus.getStatus.query({ deviceId }));
|
|
51326
|
+
return parsed.success && !parsed.data.online ? "UNREACHABLE" : "OK";
|
|
51327
|
+
} catch (err) {
|
|
51328
|
+
logger.debug("alexa: device-status unreadable — reporting connectivity OK", {
|
|
51329
|
+
tags: { deviceId },
|
|
51330
|
+
meta: { error: errMsg(err) }
|
|
51331
|
+
});
|
|
51332
|
+
return "OK";
|
|
51333
|
+
}
|
|
51334
|
+
}
|
|
51335
|
+
//#endregion
|
|
51336
|
+
//#region src/endpoints/validate-endpoint.ts
|
|
51337
|
+
/**
|
|
51338
|
+
* Interfaces this skill may declare, with the version Amazon documents. An
|
|
51339
|
+
* interface missing here is either not in Amazon's list (`BatteryLevelSensor`
|
|
51340
|
+
* never was) or deprecated (`MediaMetadata`, `EventDetectionSensor`).
|
|
51341
|
+
*/
|
|
51342
|
+
var ALLOWED_INTERFACE_VERSIONS = {
|
|
51343
|
+
Alexa: "3",
|
|
51344
|
+
"Alexa.CameraStreamController": "3",
|
|
51345
|
+
"Alexa.RTCSessionController": "3",
|
|
51346
|
+
"Alexa.DoorbellEventSource": "3",
|
|
51347
|
+
"Alexa.MotionSensor": "3",
|
|
51348
|
+
"Alexa.ContactSensor": "3",
|
|
51349
|
+
"Alexa.TemperatureSensor": "3",
|
|
51350
|
+
"Alexa.HumiditySensor": "3",
|
|
51351
|
+
"Alexa.EndpointHealth": "3.1",
|
|
51352
|
+
"Alexa.PowerController": "3",
|
|
51353
|
+
"Alexa.BrightnessController": "3",
|
|
51354
|
+
"Alexa.ModeController": "3",
|
|
51355
|
+
"Alexa.ToggleController": "3",
|
|
51356
|
+
"Alexa.RangeController": "3",
|
|
51357
|
+
"Alexa.SecurityPanelController": "3",
|
|
51358
|
+
"Alexa.LockController": "3",
|
|
51359
|
+
"Alexa.SmartVision.SnapshotProvider": "1.1",
|
|
51360
|
+
"Alexa.SmartVision.ObjectDetectionSensor": "1.0"
|
|
51361
|
+
};
|
|
51362
|
+
/** Interfaces that need an `instance`. */
|
|
51363
|
+
var INSTANCED = new Set([
|
|
51364
|
+
"Alexa.ModeController",
|
|
51365
|
+
"Alexa.ToggleController",
|
|
51366
|
+
"Alexa.RangeController"
|
|
51367
|
+
]);
|
|
51368
|
+
/** Sensor interfaces Amazon requires to be paired with `Alexa.EndpointHealth`. */
|
|
51369
|
+
var NEEDS_ENDPOINT_HEALTH = new Set([
|
|
51370
|
+
"Alexa.MotionSensor",
|
|
51371
|
+
"Alexa.ContactSensor",
|
|
51372
|
+
"Alexa.TemperatureSensor",
|
|
51373
|
+
"Alexa.HumiditySensor",
|
|
51374
|
+
"Alexa.LockController",
|
|
51375
|
+
"Alexa.SmartVision.ObjectDetectionSensor"
|
|
51376
|
+
]);
|
|
51377
|
+
/** `displayCategories` values from alexa-discovery (the subset a camstack
|
|
51378
|
+
* device can be). */
|
|
51379
|
+
var DISPLAY_CATEGORIES = new Set([
|
|
51380
|
+
"CAMERA",
|
|
51381
|
+
"DOORBELL",
|
|
51382
|
+
"LIGHT",
|
|
51383
|
+
"SWITCH",
|
|
51384
|
+
"SMARTPLUG",
|
|
51385
|
+
"SECURITY_PANEL",
|
|
51386
|
+
"SECURITY_SYSTEM",
|
|
51387
|
+
"MOTION_SENSOR",
|
|
51388
|
+
"CONTACT_SENSOR",
|
|
51389
|
+
"TEMPERATURE_SENSOR",
|
|
51390
|
+
"SMARTLOCK",
|
|
51391
|
+
"DOOR",
|
|
51392
|
+
"GARAGE_DOOR",
|
|
51393
|
+
"OTHER"
|
|
51394
|
+
]);
|
|
51395
|
+
var ENDPOINT_ID_PATTERN = /^[A-Za-z0-9 _\-=#;:?@&]{1,256}$/;
|
|
51396
|
+
var MAX_FRIENDLY_NAME = 256;
|
|
51397
|
+
var MAX_DESCRIPTION = 128;
|
|
51398
|
+
var MAX_MANUFACTURER = 128;
|
|
51399
|
+
var MAX_COOKIE_BYTES = 5e3;
|
|
51400
|
+
var MAX_CAPABILITIES = 100;
|
|
51401
|
+
var MIN_MODES = 2;
|
|
51402
|
+
function capabilityKey$1(c) {
|
|
51403
|
+
return c.instance === void 0 ? c.interface : `${c.interface}#${c.instance}`;
|
|
51404
|
+
}
|
|
51405
|
+
function friendlyTexts(c) {
|
|
51406
|
+
return (c.capabilityResources?.friendlyNames ?? []).map((n) => n.value.text);
|
|
51407
|
+
}
|
|
51408
|
+
function validateCapability(endpointId, c, out) {
|
|
51409
|
+
const push = (rule) => {
|
|
51410
|
+
out.push({
|
|
51411
|
+
endpointId,
|
|
51412
|
+
rule: `${capabilityKey$1(c)}: ${rule}`
|
|
51413
|
+
});
|
|
51414
|
+
};
|
|
51415
|
+
if (c.type !== "AlexaInterface") push("type must be AlexaInterface");
|
|
51416
|
+
const version = ALLOWED_INTERFACE_VERSIONS[c.interface];
|
|
51417
|
+
if (version === void 0) {
|
|
51418
|
+
push("interface is not in Amazon's interface list (or is deprecated)");
|
|
51419
|
+
return;
|
|
51420
|
+
}
|
|
51421
|
+
if (c.version !== version) push(`version must be ${version}`);
|
|
51422
|
+
if (INSTANCED.has(c.interface)) {
|
|
51423
|
+
if (c.instance === void 0 || c.instance.length === 0) push("instance is required");
|
|
51424
|
+
if (friendlyTexts(c).length === 0) push("capabilityResources.friendlyNames is required");
|
|
51425
|
+
}
|
|
51426
|
+
if (c.properties !== void 0) {
|
|
51427
|
+
if (c.properties.supported.length === 0) push("properties.supported must not be empty");
|
|
51428
|
+
}
|
|
51429
|
+
if (c.interface === "Alexa.ModeController") {
|
|
51430
|
+
const modes = c.configuration?.["supportedModes"];
|
|
51431
|
+
if (!Array.isArray(modes) || modes.length < MIN_MODES) push(`at least ${MIN_MODES} supportedModes`);
|
|
51432
|
+
else {
|
|
51433
|
+
const values = modes.map((m) => typeof m === "object" && m !== null ? String(Reflect.get(m, "value")) : "");
|
|
51434
|
+
if (new Set(values).size !== values.length) push("supportedModes values must be unique");
|
|
51435
|
+
}
|
|
51436
|
+
}
|
|
51437
|
+
if (c.interface === "Alexa.SmartVision.SnapshotProvider") {
|
|
51438
|
+
if (typeof c.configuration?.["isAvailable"] !== "boolean") push("configuration.isAvailable is required");
|
|
51439
|
+
}
|
|
51440
|
+
}
|
|
51441
|
+
/** Every rule an endpoint breaks. Empty = Amazon should accept it. */
|
|
51442
|
+
function validateAlexaEndpoint(endpoint) {
|
|
51443
|
+
const out = [];
|
|
51444
|
+
const id = endpoint.endpointId;
|
|
51445
|
+
const push = (rule) => {
|
|
51446
|
+
out.push({
|
|
51447
|
+
endpointId: id,
|
|
51448
|
+
rule
|
|
51449
|
+
});
|
|
51450
|
+
};
|
|
51451
|
+
if (!ENDPOINT_ID_PATTERN.test(id)) push("endpointId: 1-256 of [A-Za-z0-9 _-=#;:?@&]");
|
|
51452
|
+
if (endpoint.friendlyName.trim().length === 0) push("friendlyName must not be empty");
|
|
51453
|
+
if (endpoint.friendlyName.length > MAX_FRIENDLY_NAME) push("friendlyName longer than 256");
|
|
51454
|
+
if (endpoint.description.length === 0 || endpoint.description.length > MAX_DESCRIPTION) push("description must be 1-128 characters");
|
|
51455
|
+
if (endpoint.manufacturerName.length === 0 || endpoint.manufacturerName.length > MAX_MANUFACTURER) push("manufacturerName must be 1-128 characters");
|
|
51456
|
+
if (endpoint.displayCategories.length === 0) push("displayCategories must not be empty");
|
|
51457
|
+
for (const cat of endpoint.displayCategories) if (!DISPLAY_CATEGORIES.has(cat)) push(`displayCategory ${cat} is not an Amazon value`);
|
|
51458
|
+
if (endpoint.cookie !== void 0) {
|
|
51459
|
+
if (new TextEncoder().encode(JSON.stringify(endpoint.cookie)).length > MAX_COOKIE_BYTES) push("cookie larger than 5000 bytes");
|
|
51460
|
+
}
|
|
51461
|
+
const caps = endpoint.capabilities;
|
|
51462
|
+
if (caps.length > MAX_CAPABILITIES) push("more than 100 capabilities");
|
|
51463
|
+
if (!caps.some((c) => c.interface === "Alexa" && c.version === "3")) push("the base Alexa interface (version 3) is required");
|
|
51464
|
+
const keys = caps.map(capabilityKey$1);
|
|
51465
|
+
if (new Set(keys).size !== keys.length) push("a capability (interface + instance) is declared twice");
|
|
51466
|
+
const interfaces = new Set(caps.map((c) => c.interface));
|
|
51467
|
+
for (const needs of NEEDS_ENDPOINT_HEALTH) if (interfaces.has(needs) && !interfaces.has("Alexa.EndpointHealth")) push(`${needs} requires Alexa.EndpointHealth`);
|
|
51468
|
+
if (interfaces.has("Alexa.RTCSessionController") && endpoint.displayCategories[0] !== "CAMERA") push("an RTCSessionController endpoint must list CAMERA first");
|
|
51469
|
+
for (const c of caps) validateCapability(id, c, out);
|
|
51470
|
+
return out;
|
|
51471
|
+
}
|
|
51472
|
+
/** Split endpoints into the valid ones and the violations of the rest. */
|
|
51473
|
+
function partitionValidEndpoints(endpoints) {
|
|
51474
|
+
const valid = [];
|
|
51475
|
+
const violations = [];
|
|
51476
|
+
for (const e of endpoints) {
|
|
51477
|
+
const found = validateAlexaEndpoint(e);
|
|
51478
|
+
if (found.length === 0) valid.push(e);
|
|
51479
|
+
else violations.push(...found);
|
|
51480
|
+
}
|
|
51481
|
+
return {
|
|
51482
|
+
valid,
|
|
51483
|
+
violations
|
|
51484
|
+
};
|
|
51485
|
+
}
|
|
51486
|
+
/**
|
|
51487
|
+
* The runtime gate: keep the valid endpoints, and say out loud which ones were
|
|
51488
|
+
* withheld and why. Sending an invalid one risks Alexa rejecting the report —
|
|
51489
|
+
* and with it every valid endpoint beside it.
|
|
51490
|
+
*/
|
|
51491
|
+
function withholdInvalidEndpoints(endpoints, warn) {
|
|
51492
|
+
const { valid, violations } = partitionValidEndpoints(endpoints);
|
|
51493
|
+
if (violations.length > 0) warn("alexa: endpoints WITHHELD — they break an Amazon discovery rule and could make Alexa reject the whole report", violations);
|
|
51494
|
+
return valid;
|
|
51495
|
+
}
|
|
51496
|
+
//#endregion
|
|
51497
|
+
//#region src/access/endpoint-access.ts
|
|
51498
|
+
/**
|
|
51499
|
+
* May `principal` reach `endpointId` at `access`? `access` defaults to the
|
|
51500
|
+
* endpoint's own discovery access (`view` for a camera, `create` for an
|
|
51501
|
+
* actuator-only endpoint).
|
|
51502
|
+
*/
|
|
51503
|
+
async function authorizeEndpoint(endpointId, principal, deps, access) {
|
|
51504
|
+
const target = endpointAccessTarget(parseCameraEndpointId(endpointId));
|
|
51505
|
+
if (target === null) return {
|
|
51506
|
+
allowed: false,
|
|
51507
|
+
refusal: { kind: "invalid-endpoint" }
|
|
51508
|
+
};
|
|
51509
|
+
if (!deps.isExposed(target.exposedDeviceId)) return {
|
|
51510
|
+
allowed: false,
|
|
51511
|
+
refusal: {
|
|
51512
|
+
kind: "not-exposed",
|
|
51513
|
+
deviceId: target.exposedDeviceId
|
|
51514
|
+
}
|
|
51515
|
+
};
|
|
51516
|
+
if (target.requiresParent !== null) {
|
|
51517
|
+
const parent = await deps.gate.parentOf(target.deviceId);
|
|
51518
|
+
if (parent === void 0) return {
|
|
51519
|
+
allowed: false,
|
|
51520
|
+
refusal: {
|
|
51521
|
+
kind: "device-unknown",
|
|
51522
|
+
deviceId: target.deviceId
|
|
51523
|
+
}
|
|
51524
|
+
};
|
|
51525
|
+
if (parent !== target.requiresParent) return {
|
|
51526
|
+
allowed: false,
|
|
51527
|
+
refusal: {
|
|
51528
|
+
kind: "not-a-child",
|
|
51529
|
+
deviceId: target.deviceId,
|
|
51530
|
+
parentDeviceId: target.requiresParent
|
|
51531
|
+
}
|
|
51532
|
+
};
|
|
51533
|
+
}
|
|
51534
|
+
const needed = access ?? target.ownAccess;
|
|
51535
|
+
const verdict = await deps.gate.allows(principal, target.deviceId, needed);
|
|
51536
|
+
if (verdict === "allowed") return { allowed: true };
|
|
51537
|
+
if (verdict === "unknown") return {
|
|
51538
|
+
allowed: false,
|
|
51539
|
+
refusal: {
|
|
51540
|
+
kind: "device-unknown",
|
|
51541
|
+
deviceId: target.deviceId
|
|
51542
|
+
}
|
|
51543
|
+
};
|
|
51544
|
+
return {
|
|
51545
|
+
allowed: false,
|
|
51546
|
+
refusal: {
|
|
51547
|
+
kind: "not-granted",
|
|
51548
|
+
deviceId: target.deviceId,
|
|
51549
|
+
access: needed
|
|
51550
|
+
}
|
|
51551
|
+
};
|
|
51552
|
+
}
|
|
51553
|
+
/**
|
|
51554
|
+
* Interfaces on a VIEWABLE endpoint that only actuate. A user who may see a
|
|
51555
|
+
* camera but not control it is shown the camera without them, so the Alexa app
|
|
51556
|
+
* never offers a PTZ dropdown whose every use would be refused.
|
|
51557
|
+
*/
|
|
51558
|
+
var CONTROL_ONLY_INTERFACES = new Set(["Alexa.ModeController", "Alexa.ToggleController"]);
|
|
51559
|
+
/** Strip what a view-only principal may not drive. Two-way audio is driving
|
|
51560
|
+
* the camera's speaker, so it goes too. Returns a NEW endpoint. */
|
|
51561
|
+
function viewOnlyShape(endpoint) {
|
|
51562
|
+
return {
|
|
51563
|
+
...endpoint,
|
|
51564
|
+
capabilities: endpoint.capabilities.filter((c) => !CONTROL_ONLY_INTERFACES.has(c.interface)).map((c) => c.interface === "Alexa.RTCSessionController" ? {
|
|
51565
|
+
...c,
|
|
51566
|
+
configuration: {
|
|
51567
|
+
...c.configuration,
|
|
51568
|
+
isFullDuplexAudioSupported: false
|
|
51569
|
+
}
|
|
51570
|
+
} : c)
|
|
51571
|
+
};
|
|
51572
|
+
}
|
|
51573
|
+
/** Keep the endpoints `principal` may be shown, shaped to what it may drive.
|
|
51574
|
+
* Order is preserved. */
|
|
51575
|
+
async function filterEndpointsForPrincipal(endpoints, principal, deps) {
|
|
51576
|
+
const kept = [];
|
|
51577
|
+
const refused = [];
|
|
51578
|
+
for (const endpoint of endpoints) {
|
|
51579
|
+
const verdict = await authorizeEndpoint(endpoint.endpointId, principal, deps);
|
|
51580
|
+
if (!verdict.allowed) {
|
|
51581
|
+
refused.push({
|
|
51582
|
+
endpointId: endpoint.endpointId,
|
|
51583
|
+
refusal: verdict.refusal
|
|
51584
|
+
});
|
|
51585
|
+
continue;
|
|
51586
|
+
}
|
|
51587
|
+
const target = endpointAccessTarget(parseCameraEndpointId(endpoint.endpointId));
|
|
51588
|
+
if (principal.kind === "scoped" && target !== null && target.ownAccess === "view") {
|
|
51589
|
+
const control = await deps.gate.allows(principal, target.deviceId, "create");
|
|
51590
|
+
kept.push(control === "allowed" ? endpoint : viewOnlyShape(endpoint));
|
|
51591
|
+
continue;
|
|
51592
|
+
}
|
|
51593
|
+
kept.push(endpoint);
|
|
51594
|
+
}
|
|
51595
|
+
return {
|
|
51596
|
+
kept,
|
|
51597
|
+
refused
|
|
51598
|
+
};
|
|
51599
|
+
}
|
|
51600
|
+
/** The Alexa error type a refusal maps to. A device the user may not see does
|
|
51601
|
+
* not exist for that user; one it may see but not control is a permission
|
|
51602
|
+
* failure; a device the registry cannot read right now is unreachable. */
|
|
51603
|
+
function alexaErrorTypeFor(refusal) {
|
|
51604
|
+
switch (refusal.kind) {
|
|
51605
|
+
case "device-unknown": return "ENDPOINT_UNREACHABLE";
|
|
51606
|
+
case "not-granted": return refusal.access === "view" ? "NO_SUCH_ENDPOINT" : "INVALID_DIRECTIVE";
|
|
51607
|
+
case "invalid-endpoint":
|
|
51608
|
+
case "not-exposed":
|
|
51609
|
+
case "not-a-child": return "NO_SUCH_ENDPOINT";
|
|
51610
|
+
}
|
|
51611
|
+
}
|
|
51612
|
+
function describeRefusal(refusal) {
|
|
51613
|
+
switch (refusal.kind) {
|
|
51614
|
+
case "invalid-endpoint": return "the endpoint id is not one this skill publishes";
|
|
51615
|
+
case "not-exposed": return `device ${refusal.deviceId} is not exposed to Alexa`;
|
|
51616
|
+
case "not-a-child": return `device ${refusal.deviceId} is not an accessory of ${refusal.parentDeviceId}`;
|
|
51617
|
+
case "not-granted": return `the linked hub user has no ${refusal.access} grant on device ${refusal.deviceId}`;
|
|
51618
|
+
case "device-unknown": return `device ${refusal.deviceId} could not be resolved in the registry`;
|
|
51619
|
+
}
|
|
51620
|
+
}
|
|
50725
51621
|
//#endregion
|
|
50726
51622
|
//#region src/handlers/brightness-controller.ts
|
|
50727
51623
|
var SetBrightnessDirectiveSchema = object({
|
|
@@ -50744,20 +51640,21 @@ async function handleSetBrightness(header, endpointRaw, payloadRaw, deps, _calle
|
|
|
50744
51640
|
if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
|
|
50745
51641
|
const endpointId = parsed.data.endpoint.endpointId;
|
|
50746
51642
|
const parsedId = parseCameraEndpointId(endpointId);
|
|
50747
|
-
if (parsedId.kind === "autotrack" || parsedId.kind === "privacy" || parsedId.kind === "alarm-panel") return errorEnvelope(header, "INVALID_DIRECTIVE", `BrightnessController is not exposed on the ${parsedId.kind} endpoint`);
|
|
51643
|
+
if (parsedId.kind === "autotrack" || parsedId.kind === "privacy" || parsedId.kind === "alarm-panel" || parsedId.kind === "detection") return errorEnvelope(header, "INVALID_DIRECTIVE", `BrightnessController is not exposed on the ${parsedId.kind} endpoint`);
|
|
50748
51644
|
if (parsedId.kind === "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", "BrightnessController is not exposed on camera endpoints — direct the directive at the dimmable child accessory");
|
|
50749
51645
|
if (parsedId.kind === "invalid") return errorEnvelope(header, "INVALID_DIRECTIVE", parsedId.reason);
|
|
50750
51646
|
const percentage = parsed.data.payload.brightness;
|
|
50751
51647
|
const api = deps.ctx.api;
|
|
51648
|
+
const targetDeviceId = parsedId.kind === "child" ? parsedId.childDeviceId : parsedId.deviceId;
|
|
50752
51649
|
try {
|
|
50753
51650
|
await api.brightness.setBrightness.mutate({
|
|
50754
|
-
deviceId:
|
|
51651
|
+
deviceId: targetDeviceId,
|
|
50755
51652
|
percentage
|
|
50756
51653
|
});
|
|
50757
51654
|
} catch (err) {
|
|
50758
51655
|
const message = err instanceof Error ? err.message : String(err);
|
|
50759
51656
|
deps.logger.warn("alexa brightness-controller: setBrightness failed", { meta: {
|
|
50760
|
-
deviceId:
|
|
51657
|
+
deviceId: targetDeviceId,
|
|
50761
51658
|
percentage,
|
|
50762
51659
|
error: message
|
|
50763
51660
|
} });
|
|
@@ -50867,6 +51764,66 @@ async function handleInitializeCameraStreams(header, endpointRaw, payloadRaw, de
|
|
|
50867
51764
|
} };
|
|
50868
51765
|
}
|
|
50869
51766
|
//#endregion
|
|
51767
|
+
//#region src/handlers/lock-controller.ts
|
|
51768
|
+
/** The `lock-control` state as Alexa's `lockState`. */
|
|
51769
|
+
function alexaLockStateFor(state) {
|
|
51770
|
+
switch (state) {
|
|
51771
|
+
case "locked":
|
|
51772
|
+
case "locking": return "LOCKED";
|
|
51773
|
+
case "unlocked":
|
|
51774
|
+
case "unlocking": return "UNLOCKED";
|
|
51775
|
+
case "jammed": return "JAMMED";
|
|
51776
|
+
}
|
|
51777
|
+
}
|
|
51778
|
+
async function readLockState(api, deviceId) {
|
|
51779
|
+
const raw = await api.lockControl.getStatus.query({ deviceId });
|
|
51780
|
+
const parsed = LockControlStatusSchema.pick({ state: true }).safeParse(raw);
|
|
51781
|
+
return parsed.success ? alexaLockStateFor(parsed.data.state) : null;
|
|
51782
|
+
}
|
|
51783
|
+
async function handleLock(header, endpoint, deps) {
|
|
51784
|
+
if (endpoint === void 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "missing endpoint");
|
|
51785
|
+
const parsed = parseCameraEndpointId(endpoint.endpointId);
|
|
51786
|
+
if (parsed.kind !== "generic") return errorEnvelope(header, "INVALID_DIRECTIVE", "LockController is only on a lock endpoint");
|
|
51787
|
+
const lock = header.name === "Lock";
|
|
51788
|
+
const api = deps.ctx.api;
|
|
51789
|
+
try {
|
|
51790
|
+
if (lock) await api.lockControl.lock.mutate({ deviceId: parsed.deviceId });
|
|
51791
|
+
else await api.lockControl.unlock.mutate({ deviceId: parsed.deviceId });
|
|
51792
|
+
} catch (err) {
|
|
51793
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
51794
|
+
deps.logger.warn(`alexa lock-controller: ${header.name} failed`, {
|
|
51795
|
+
tags: { deviceId: parsed.deviceId },
|
|
51796
|
+
meta: { error: message }
|
|
51797
|
+
});
|
|
51798
|
+
return errorEnvelope(header, "ENDPOINT_UNREACHABLE", `${header.name} failed: ${message}`);
|
|
51799
|
+
}
|
|
51800
|
+
const state = await readLockState(api, parsed.deviceId).catch(() => null) ?? (lock ? "LOCKED" : "UNLOCKED");
|
|
51801
|
+
deps.logger.info(`alexa lock-controller: ${header.name}`, {
|
|
51802
|
+
tags: { deviceId: parsed.deviceId },
|
|
51803
|
+
meta: { lockState: state }
|
|
51804
|
+
});
|
|
51805
|
+
return {
|
|
51806
|
+
event: {
|
|
51807
|
+
header: {
|
|
51808
|
+
namespace: "Alexa",
|
|
51809
|
+
name: "Response",
|
|
51810
|
+
messageId: header.messageId,
|
|
51811
|
+
...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
|
|
51812
|
+
payloadVersion: "3"
|
|
51813
|
+
},
|
|
51814
|
+
endpoint: { endpointId: endpoint.endpointId },
|
|
51815
|
+
payload: {}
|
|
51816
|
+
},
|
|
51817
|
+
context: { properties: [{
|
|
51818
|
+
namespace: "Alexa.LockController",
|
|
51819
|
+
name: "lockState",
|
|
51820
|
+
value: state,
|
|
51821
|
+
timeOfSample: (/* @__PURE__ */ new Date()).toISOString(),
|
|
51822
|
+
uncertaintyInMilliseconds: 500
|
|
51823
|
+
}] }
|
|
51824
|
+
};
|
|
51825
|
+
}
|
|
51826
|
+
//#endregion
|
|
50870
51827
|
//#region src/handlers/power-controller.ts
|
|
50871
51828
|
var PowerDirectiveSchema = object({
|
|
50872
51829
|
header: object({
|
|
@@ -50902,6 +51859,11 @@ async function handlePower(header, endpointRaw, deps, _callerIdentity) {
|
|
|
50902
51859
|
});
|
|
50903
51860
|
else if (parsedId.kind === "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", "PowerController is not exposed on camera endpoints — direct the directive at the child accessory");
|
|
50904
51861
|
else if (parsedId.kind === "alarm-panel") return errorEnvelope(header, "INVALID_DIRECTIVE", "PowerController is not exposed on alarm-panel endpoints — use Alexa.SecurityPanelController");
|
|
51862
|
+
else if (parsedId.kind === "generic") await api.switch.setState.mutate({
|
|
51863
|
+
deviceId: parsedId.deviceId,
|
|
51864
|
+
on
|
|
51865
|
+
});
|
|
51866
|
+
else if (parsedId.kind === "detection") return errorEnvelope(header, "INVALID_DIRECTIVE", "PowerController is not exposed on a detection sensor — it only reports");
|
|
50905
51867
|
else return errorEnvelope(header, "INVALID_DIRECTIVE", parsedId.reason);
|
|
50906
51868
|
return {
|
|
50907
51869
|
event: {
|
|
@@ -51133,6 +52095,236 @@ function modeSuccessResponse(header, endpointId, instance, mode) {
|
|
|
51133
52095
|
}] }
|
|
51134
52096
|
};
|
|
51135
52097
|
}
|
|
52098
|
+
var JPEG_QUALITY = 72;
|
|
52099
|
+
var RESIZE_TIMEOUT_MS = 4e3;
|
|
52100
|
+
/** Re-encode a camera frame for Alexa. Rejects (never falls back to the
|
|
52101
|
+
* original) on failure or timeout. */
|
|
52102
|
+
async function toAlexaJpeg(source) {
|
|
52103
|
+
const work = (async () => {
|
|
52104
|
+
const meta = await (0, sharp.default)(source).metadata();
|
|
52105
|
+
const width = meta.width;
|
|
52106
|
+
const height = meta.height;
|
|
52107
|
+
if (width === void 0 || height === void 0 || width === 0 || height === 0) throw new Error("snapshot has no readable dimensions");
|
|
52108
|
+
const short = Math.min(width, height);
|
|
52109
|
+
const targetShort = short < 360 ? 360 : Math.min(short, 480);
|
|
52110
|
+
const resize = width <= height ? { width: targetShort } : { height: targetShort };
|
|
52111
|
+
const { data, info } = await (0, sharp.default)(source).rotate().resize(resize).jpeg({
|
|
52112
|
+
quality: JPEG_QUALITY,
|
|
52113
|
+
mozjpeg: false
|
|
52114
|
+
}).toBuffer({ resolveWithObject: true });
|
|
52115
|
+
return {
|
|
52116
|
+
bytes: data,
|
|
52117
|
+
width: info.width,
|
|
52118
|
+
height: info.height
|
|
52119
|
+
};
|
|
52120
|
+
})();
|
|
52121
|
+
let timer;
|
|
52122
|
+
const timeout = new Promise((_, reject) => {
|
|
52123
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error("snapshot re-encode timed out")), RESIZE_TIMEOUT_MS);
|
|
52124
|
+
});
|
|
52125
|
+
try {
|
|
52126
|
+
return await Promise.race([work, timeout]);
|
|
52127
|
+
} finally {
|
|
52128
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
52129
|
+
}
|
|
52130
|
+
}
|
|
52131
|
+
//#endregion
|
|
52132
|
+
//#region src/handlers/snapshot-provider.ts
|
|
52133
|
+
/** Leaves the rest of Amazon's 8 s for the re-encode and the Lambda hop. */
|
|
52134
|
+
var SNAPSHOT_READ_TIMEOUT_MS = 5e3;
|
|
52135
|
+
var SnapshotImageSchema = object({
|
|
52136
|
+
base64: string().min(1),
|
|
52137
|
+
contentType: string()
|
|
52138
|
+
});
|
|
52139
|
+
function snapshotError(header, type, message) {
|
|
52140
|
+
return { event: {
|
|
52141
|
+
header: {
|
|
52142
|
+
namespace: "Alexa.SmartVision.SnapshotProvider",
|
|
52143
|
+
name: "ErrorResponse",
|
|
52144
|
+
messageId: header.messageId,
|
|
52145
|
+
...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
|
|
52146
|
+
payloadVersion: "1.1"
|
|
52147
|
+
},
|
|
52148
|
+
payload: {
|
|
52149
|
+
type,
|
|
52150
|
+
message
|
|
52151
|
+
}
|
|
52152
|
+
} };
|
|
52153
|
+
}
|
|
52154
|
+
async function readFrame(ctx, deviceId) {
|
|
52155
|
+
let timer;
|
|
52156
|
+
const timeout = new Promise((resolve) => {
|
|
52157
|
+
timer = setTimeout(() => resolve(null), SNAPSHOT_READ_TIMEOUT_MS);
|
|
52158
|
+
});
|
|
52159
|
+
try {
|
|
52160
|
+
const raw = await Promise.race([ctx.api.snapshot.getSnapshot.query({ deviceId }), timeout]);
|
|
52161
|
+
const parsed = SnapshotImageSchema.safeParse(raw);
|
|
52162
|
+
return parsed.success ? Buffer.from(parsed.data.base64, "base64") : null;
|
|
52163
|
+
} finally {
|
|
52164
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
52165
|
+
}
|
|
52166
|
+
}
|
|
52167
|
+
async function handleGetSnapshot(header, endpoint, deps, caller) {
|
|
52168
|
+
if (endpoint === void 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "missing endpoint");
|
|
52169
|
+
if (!deps.enabled()) return snapshotError(header, "DISABLED_BY_USER", "snapshots are switched off on this hub");
|
|
52170
|
+
const parsed = parseCameraEndpointId(endpoint.endpointId);
|
|
52171
|
+
if (parsed.kind !== "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", "snapshots are served for camera endpoints");
|
|
52172
|
+
const deviceId = parsed.deviceId;
|
|
52173
|
+
const base = deps.publicHubUrl().replace(/\/+$/, "");
|
|
52174
|
+
if (!base.startsWith("https://")) {
|
|
52175
|
+
deps.logger.warn("alexa snapshot: no public HTTPS address — Alexa cannot fetch an image", { tags: { deviceId } });
|
|
52176
|
+
return errorEnvelope(header, "INTERNAL_ERROR", "the hub has no public HTTPS address");
|
|
52177
|
+
}
|
|
52178
|
+
const started = deps.now?.() ?? Date.now();
|
|
52179
|
+
const frame = await readFrame(deps.ctx, deviceId).catch((err) => {
|
|
52180
|
+
deps.logger.warn("alexa snapshot: snapshot read failed", {
|
|
52181
|
+
tags: { deviceId },
|
|
52182
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
52183
|
+
});
|
|
52184
|
+
return null;
|
|
52185
|
+
});
|
|
52186
|
+
if (frame === null) return errorEnvelope(header, "ENDPOINT_UNREACHABLE", `no snapshot from device ${deviceId}`);
|
|
52187
|
+
let jpeg;
|
|
52188
|
+
try {
|
|
52189
|
+
jpeg = await (deps.encode ?? toAlexaJpeg)(frame);
|
|
52190
|
+
} catch (err) {
|
|
52191
|
+
deps.logger.warn("alexa snapshot: re-encode failed — not sending the original frame", {
|
|
52192
|
+
tags: { deviceId },
|
|
52193
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
52194
|
+
});
|
|
52195
|
+
return errorEnvelope(header, "INTERNAL_ERROR", "snapshot could not be prepared");
|
|
52196
|
+
}
|
|
52197
|
+
const now = deps.now?.() ?? Date.now();
|
|
52198
|
+
const minted = deps.store.mint({
|
|
52199
|
+
deviceId,
|
|
52200
|
+
userId: caller.userId,
|
|
52201
|
+
bytes: jpeg.bytes,
|
|
52202
|
+
capturedAt: now
|
|
52203
|
+
});
|
|
52204
|
+
deps.logger.info("alexa snapshot: served", {
|
|
52205
|
+
tags: { deviceId },
|
|
52206
|
+
meta: {
|
|
52207
|
+
userId: caller.userId,
|
|
52208
|
+
bytes: jpeg.bytes.length,
|
|
52209
|
+
width: jpeg.width,
|
|
52210
|
+
height: jpeg.height,
|
|
52211
|
+
tookMs: now - started
|
|
52212
|
+
}
|
|
52213
|
+
});
|
|
52214
|
+
return { event: {
|
|
52215
|
+
header: {
|
|
52216
|
+
namespace: "Alexa.SmartVision.SnapshotProvider",
|
|
52217
|
+
name: "Snapshot",
|
|
52218
|
+
messageId: header.messageId,
|
|
52219
|
+
...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
|
|
52220
|
+
payloadVersion: "1.1"
|
|
52221
|
+
},
|
|
52222
|
+
endpoint: { endpointId: endpoint.endpointId },
|
|
52223
|
+
payload: {
|
|
52224
|
+
value: {
|
|
52225
|
+
uri: `${base}${minted.path}`,
|
|
52226
|
+
uriExpirationTime: new Date(minted.expiresAt).toISOString(),
|
|
52227
|
+
authenticationType: "ACCESS_TOKEN"
|
|
52228
|
+
},
|
|
52229
|
+
timeOfSample: new Date(now).toISOString(),
|
|
52230
|
+
uncertaintyInMilliseconds: 1e4
|
|
52231
|
+
}
|
|
52232
|
+
} };
|
|
52233
|
+
}
|
|
52234
|
+
//#endregion
|
|
52235
|
+
//#region src/handlers/generic-state.ts
|
|
52236
|
+
/**
|
|
52237
|
+
* A generic device's Alexa properties from its cap slices (R5, D775) — ONE
|
|
52238
|
+
* mapping shared by `Alexa/ReportState` (reading each cap's status) and the
|
|
52239
|
+
* proactive reports (`DeviceStateChanged` slices), so the two can never answer
|
|
52240
|
+
* differently for the same state.
|
|
52241
|
+
*/
|
|
52242
|
+
/** The caps a generic endpoint reads, in the order ReportState asks them. */
|
|
52243
|
+
var GENERIC_STATE_CAPS = [
|
|
52244
|
+
"lock-control",
|
|
52245
|
+
"contact",
|
|
52246
|
+
"motion",
|
|
52247
|
+
"presence",
|
|
52248
|
+
"temperature-sensor",
|
|
52249
|
+
"humidity-sensor",
|
|
52250
|
+
"switch",
|
|
52251
|
+
"brightness"
|
|
52252
|
+
];
|
|
52253
|
+
var detection = (detected) => ({
|
|
52254
|
+
namespace: "",
|
|
52255
|
+
name: "detectionState",
|
|
52256
|
+
value: detected ? "DETECTED" : "NOT_DETECTED"
|
|
52257
|
+
});
|
|
52258
|
+
/** The Alexa property one cap slice sets, or null (unknown cap, bad slice). */
|
|
52259
|
+
function genericPropertyFor(capName, slice) {
|
|
52260
|
+
switch (capName) {
|
|
52261
|
+
case "lock-control": {
|
|
52262
|
+
const p = LockControlStatusSchema.pick({ state: true }).safeParse(slice);
|
|
52263
|
+
return p.success ? {
|
|
52264
|
+
namespace: "Alexa.LockController",
|
|
52265
|
+
name: "lockState",
|
|
52266
|
+
value: alexaLockStateFor(p.data.state)
|
|
52267
|
+
} : null;
|
|
52268
|
+
}
|
|
52269
|
+
case "contact": {
|
|
52270
|
+
const p = ContactStatusSchema.pick({ entryOpen: true }).safeParse(slice);
|
|
52271
|
+
return p.success ? {
|
|
52272
|
+
...detection(p.data.entryOpen),
|
|
52273
|
+
namespace: "Alexa.ContactSensor"
|
|
52274
|
+
} : null;
|
|
52275
|
+
}
|
|
52276
|
+
case "motion": {
|
|
52277
|
+
const p = MotionStatusSchema.pick({ detected: true }).safeParse(slice);
|
|
52278
|
+
return p.success ? {
|
|
52279
|
+
...detection(p.data.detected),
|
|
52280
|
+
namespace: "Alexa.MotionSensor"
|
|
52281
|
+
} : null;
|
|
52282
|
+
}
|
|
52283
|
+
case "presence": {
|
|
52284
|
+
const p = PresenceStatusSchema.pick({ state: true }).safeParse(slice);
|
|
52285
|
+
return p.success ? {
|
|
52286
|
+
...detection(p.data.state === "home"),
|
|
52287
|
+
namespace: "Alexa.MotionSensor"
|
|
52288
|
+
} : null;
|
|
52289
|
+
}
|
|
52290
|
+
case "temperature-sensor": {
|
|
52291
|
+
const p = TemperatureSensorStatusSchema.pick({ celsius: true }).safeParse(slice);
|
|
52292
|
+
return p.success ? {
|
|
52293
|
+
namespace: "Alexa.TemperatureSensor",
|
|
52294
|
+
name: "temperature",
|
|
52295
|
+
value: {
|
|
52296
|
+
value: Math.round(p.data.celsius * 10) / 10,
|
|
52297
|
+
scale: "CELSIUS"
|
|
52298
|
+
}
|
|
52299
|
+
} : null;
|
|
52300
|
+
}
|
|
52301
|
+
case "humidity-sensor": {
|
|
52302
|
+
const p = HumiditySensorStatusSchema.pick({ percent: true }).safeParse(slice);
|
|
52303
|
+
return p.success ? {
|
|
52304
|
+
namespace: "Alexa.HumiditySensor",
|
|
52305
|
+
name: "relativeHumidity",
|
|
52306
|
+
value: { value: Math.max(0, Math.min(100, p.data.percent)) }
|
|
52307
|
+
} : null;
|
|
52308
|
+
}
|
|
52309
|
+
case "switch": {
|
|
52310
|
+
const p = SwitchStatusSchema.pick({ on: true }).safeParse(slice);
|
|
52311
|
+
return p.success ? {
|
|
52312
|
+
namespace: "Alexa.PowerController",
|
|
52313
|
+
name: "powerState",
|
|
52314
|
+
value: p.data.on ? "ON" : "OFF"
|
|
52315
|
+
} : null;
|
|
52316
|
+
}
|
|
52317
|
+
case "brightness": {
|
|
52318
|
+
const p = BrightnessStatusSchema.pick({ percentage: true }).safeParse(slice);
|
|
52319
|
+
return p.success ? {
|
|
52320
|
+
namespace: "Alexa.BrightnessController",
|
|
52321
|
+
name: "brightness",
|
|
52322
|
+
value: Math.max(0, Math.min(100, Math.round(p.data.percentage)))
|
|
52323
|
+
} : null;
|
|
52324
|
+
}
|
|
52325
|
+
default: return null;
|
|
52326
|
+
}
|
|
52327
|
+
}
|
|
51136
52328
|
//#endregion
|
|
51137
52329
|
//#region src/handlers/report-state.ts
|
|
51138
52330
|
async function handleReportState(header, endpointRaw, deps, _callerIdentity) {
|
|
@@ -51149,6 +52341,23 @@ async function handleReportState(header, endpointRaw, deps, _callerIdentity) {
|
|
|
51149
52341
|
else if (parsed.kind === "autotrack") await collectAutotrackProperties(api, parsed.parentDeviceId, properties, nowIso, deps.logger);
|
|
51150
52342
|
else if (parsed.kind === "child") await collectChildProperties(api, parsed.childDeviceId, properties, nowIso, deps.logger);
|
|
51151
52343
|
else if (parsed.kind === "alarm-panel") await collectAlarmPanelProperties(api, parsed.deviceId, properties, nowIso, deps.logger);
|
|
52344
|
+
else if (parsed.kind === "generic") await collectGenericProperties(api, parsed.deviceId, properties, nowIso, deps.logger);
|
|
52345
|
+
else if (parsed.kind === "detection") {
|
|
52346
|
+
properties.push({
|
|
52347
|
+
namespace: "Alexa.MotionSensor",
|
|
52348
|
+
name: "detectionState",
|
|
52349
|
+
value: deps.detectionPresent?.(parsed.deviceId, parsed.detectionClass) ? "DETECTED" : "NOT_DETECTED",
|
|
52350
|
+
timeOfSample: nowIso,
|
|
52351
|
+
uncertaintyInMilliseconds: 0
|
|
52352
|
+
});
|
|
52353
|
+
properties.push({
|
|
52354
|
+
namespace: "Alexa.EndpointHealth",
|
|
52355
|
+
name: "connectivity",
|
|
52356
|
+
value: { value: await readConnectivity(api, parsed.deviceId, deps.logger) },
|
|
52357
|
+
timeOfSample: nowIso,
|
|
52358
|
+
uncertaintyInMilliseconds: 0
|
|
52359
|
+
});
|
|
52360
|
+
}
|
|
51152
52361
|
} catch (err) {
|
|
51153
52362
|
deps.logger.warn("alexa report-state: collection failed", { meta: {
|
|
51154
52363
|
endpointId,
|
|
@@ -51170,6 +52379,39 @@ async function handleReportState(header, endpointRaw, deps, _callerIdentity) {
|
|
|
51170
52379
|
context: { properties }
|
|
51171
52380
|
};
|
|
51172
52381
|
}
|
|
52382
|
+
/**
|
|
52383
|
+
* A generic device (R5): every cap it has, read through its status, mapped by
|
|
52384
|
+
* the same function the proactive reports use. A cap the device does not have
|
|
52385
|
+
* fails its read and contributes nothing; connectivity is always answered.
|
|
52386
|
+
*/
|
|
52387
|
+
async function collectGenericProperties(api, deviceId, properties, timeOfSample, logger) {
|
|
52388
|
+
const reads = await Promise.all(GENERIC_STATE_CAPS.map(async (capName) => {
|
|
52389
|
+
const status = await api.deviceState.getCapSlice.query({
|
|
52390
|
+
deviceId,
|
|
52391
|
+
capName
|
|
52392
|
+
}).catch(() => null);
|
|
52393
|
+
return status === null ? null : genericPropertyFor(capName, status);
|
|
52394
|
+
}));
|
|
52395
|
+
const seen = /* @__PURE__ */ new Set();
|
|
52396
|
+
for (const p of reads) {
|
|
52397
|
+
if (p === null) continue;
|
|
52398
|
+
const key = `${p.namespace}.${p.name}`;
|
|
52399
|
+
if (seen.has(key)) continue;
|
|
52400
|
+
seen.add(key);
|
|
52401
|
+
properties.push({
|
|
52402
|
+
...p,
|
|
52403
|
+
timeOfSample,
|
|
52404
|
+
uncertaintyInMilliseconds: 0
|
|
52405
|
+
});
|
|
52406
|
+
}
|
|
52407
|
+
properties.push({
|
|
52408
|
+
namespace: "Alexa.EndpointHealth",
|
|
52409
|
+
name: "connectivity",
|
|
52410
|
+
value: { value: await readConnectivity(api, deviceId, logger) },
|
|
52411
|
+
timeOfSample,
|
|
52412
|
+
uncertaintyInMilliseconds: 0
|
|
52413
|
+
});
|
|
52414
|
+
}
|
|
51173
52415
|
function numberField(value, key) {
|
|
51174
52416
|
if (typeof value !== "object" || value === null) return null;
|
|
51175
52417
|
const raw = value[key];
|
|
@@ -51181,17 +52423,10 @@ function boolField(value, key) {
|
|
|
51181
52423
|
return typeof raw === "boolean" ? raw : null;
|
|
51182
52424
|
}
|
|
51183
52425
|
async function collectCameraProperties(api, deviceId, properties, timeOfSample, logger) {
|
|
51184
|
-
|
|
51185
|
-
|
|
51186
|
-
|
|
51187
|
-
|
|
51188
|
-
} });
|
|
51189
|
-
return null;
|
|
51190
|
-
}), "percentage");
|
|
51191
|
-
if (percentage !== null) properties.push({
|
|
51192
|
-
namespace: "Alexa.BatteryLevelSensor",
|
|
51193
|
-
name: "batteryLevel",
|
|
51194
|
-
value: Math.max(0, Math.min(100, Math.round(percentage))),
|
|
52426
|
+
properties.push({
|
|
52427
|
+
namespace: "Alexa.EndpointHealth",
|
|
52428
|
+
name: "connectivity",
|
|
52429
|
+
value: { value: await readConnectivity(api, deviceId, logger) },
|
|
51195
52430
|
timeOfSample,
|
|
51196
52431
|
uncertaintyInMilliseconds: 0
|
|
51197
52432
|
});
|
|
@@ -51578,6 +52813,35 @@ function requireDeviceScope(scopes, access) {
|
|
|
51578
52813
|
if (!scopesAllowDeviceCap(scopes, access)) throw new Error(`directive token not authorized for device ${access}`);
|
|
51579
52814
|
}
|
|
51580
52815
|
/**
|
|
52816
|
+
* Directives that are never refused by the access gate. `SessionDisconnected`
|
|
52817
|
+
* only tears down a session that already exists (and releases its transcode);
|
|
52818
|
+
* refusing it because the user lost access in the meantime would leak the
|
|
52819
|
+
* egress the reaper then has to find.
|
|
52820
|
+
*/
|
|
52821
|
+
var ACCESS_EXEMPT = new Set(["Alexa.RTCSessionController/SessionDisconnected"]);
|
|
52822
|
+
/**
|
|
52823
|
+
* Directives that need the device itself to answer. On a device the registry
|
|
52824
|
+
* reports OFFLINE they are refused with `ENDPOINT_UNREACHABLE` at once, rather
|
|
52825
|
+
* than spending Alexa's few-second budget on a transcode or a command that
|
|
52826
|
+
* cannot land (runbook C.2-b). `Alexa/ReportState` is NOT here: an offline
|
|
52827
|
+
* endpoint still answers, with `connectivity: UNREACHABLE` and the last known
|
|
52828
|
+
* values (alexa-endpointhealth). A status read that fails does not refuse.
|
|
52829
|
+
*/
|
|
52830
|
+
var NEEDS_REACHABLE_DEVICE = new Set([
|
|
52831
|
+
"Alexa.CameraStreamController/InitializeCameraStreams",
|
|
52832
|
+
"Alexa.RTCSessionController/InitiateSessionWithOffer",
|
|
52833
|
+
"Alexa.PowerController/TurnOn",
|
|
52834
|
+
"Alexa.PowerController/TurnOff",
|
|
52835
|
+
"Alexa.BrightnessController/SetBrightness",
|
|
52836
|
+
"Alexa.ModeController/SetMode",
|
|
52837
|
+
"Alexa.ToggleController/TurnOn",
|
|
52838
|
+
"Alexa.ToggleController/TurnOff",
|
|
52839
|
+
"Alexa.SecurityPanelController/Arm",
|
|
52840
|
+
"Alexa.SecurityPanelController/Disarm",
|
|
52841
|
+
"Alexa.LockController/Lock",
|
|
52842
|
+
"Alexa.LockController/Unlock"
|
|
52843
|
+
]);
|
|
52844
|
+
/**
|
|
51581
52845
|
* Give an `Alexa.ErrorResponse` the directive's endpoint when it lacks one.
|
|
51582
52846
|
* Amazon requires `endpoint` on every ErrorResponse to an endpoint directive
|
|
51583
52847
|
* (alexa-errorresponse); the handlers build their errors from the header
|
|
@@ -51599,6 +52863,18 @@ function withErrorEndpoint(body, endpoint) {
|
|
|
51599
52863
|
}
|
|
51600
52864
|
};
|
|
51601
52865
|
}
|
|
52866
|
+
/** The device whose online state decides whether an endpoint is reachable:
|
|
52867
|
+
* the device the directive actuates (an accessory for a child endpoint). */
|
|
52868
|
+
function reachabilityDeviceOf(endpointId) {
|
|
52869
|
+
const target = endpointAccessTarget(parseCameraEndpointId(endpointId));
|
|
52870
|
+
return target === null ? null : target.deviceId;
|
|
52871
|
+
}
|
|
52872
|
+
/** Whether the principal may CONTROL the device an endpoint addresses — the
|
|
52873
|
+
* talk-back gate (D771). */
|
|
52874
|
+
async function mayControlEndpoint(endpointId, principal, access) {
|
|
52875
|
+
if (endpointId === void 0 || principal === null) return false;
|
|
52876
|
+
return (await authorizeEndpoint(endpointId, principal, access, "create")).allowed;
|
|
52877
|
+
}
|
|
51602
52878
|
async function dispatchDirective(rawBody, deps, callerIdentity) {
|
|
51603
52879
|
const result = await dispatchParsedDirective(rawBody, deps, callerIdentity);
|
|
51604
52880
|
const endpoint = rawBody?.directive?.endpoint;
|
|
@@ -51639,7 +52915,10 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
|
|
|
51639
52915
|
"Alexa.ToggleController/TurnOff": "create",
|
|
51640
52916
|
"Alexa/ReportState": "view",
|
|
51641
52917
|
"Alexa.SecurityPanelController/Arm": "create",
|
|
51642
|
-
"Alexa.SecurityPanelController/Disarm": "create"
|
|
52918
|
+
"Alexa.SecurityPanelController/Disarm": "create",
|
|
52919
|
+
"Alexa.SmartVision.SnapshotProvider/GetSnapshot": "view",
|
|
52920
|
+
"Alexa.LockController/Lock": "create",
|
|
52921
|
+
"Alexa.LockController/Unlock": "create"
|
|
51643
52922
|
};
|
|
51644
52923
|
try {
|
|
51645
52924
|
const key = `${header.namespace}/${header.name}`;
|
|
@@ -51652,17 +52931,95 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
|
|
|
51652
52931
|
body: errorEnvelope(header, "INVALID_AUTHORIZATION_CREDENTIAL", scopeErr instanceof Error ? scopeErr.message : String(scopeErr))
|
|
51653
52932
|
};
|
|
51654
52933
|
}
|
|
51655
|
-
|
|
51656
|
-
|
|
51657
|
-
|
|
51658
|
-
|
|
52934
|
+
let principal = null;
|
|
52935
|
+
if (!ACCESS_EXEMPT.has(key)) {
|
|
52936
|
+
const lookup = await deps.access.gate.principal(callerIdentity.userId);
|
|
52937
|
+
if (lookup.kind === "unknown-user") {
|
|
52938
|
+
deps.discovery.logger.warn("alexa directive: refused — the hub user who linked this Alexa account no longer exists", { meta: {
|
|
52939
|
+
userId: callerIdentity.userId,
|
|
52940
|
+
directive: key
|
|
52941
|
+
} });
|
|
52942
|
+
return {
|
|
52943
|
+
status: 200,
|
|
52944
|
+
body: errorEnvelope(header, "INVALID_AUTHORIZATION_CREDENTIAL", "the hub user behind this account link no longer exists")
|
|
52945
|
+
};
|
|
52946
|
+
}
|
|
52947
|
+
if (lookup.kind === "unavailable") return {
|
|
52948
|
+
status: 200,
|
|
52949
|
+
body: errorEnvelope(header, "INTERNAL_ERROR", lookup.error)
|
|
52950
|
+
};
|
|
52951
|
+
principal = lookup.principal;
|
|
52952
|
+
if (key !== "Alexa.Discovery/Discover") {
|
|
52953
|
+
if (endpoint === void 0) return {
|
|
52954
|
+
status: 200,
|
|
52955
|
+
body: errorEnvelope(header, "INVALID_DIRECTIVE", "missing directive.endpoint")
|
|
52956
|
+
};
|
|
52957
|
+
const verdict = await authorizeEndpoint(endpoint.endpointId, principal, deps.access, requiredAccess);
|
|
52958
|
+
if (!verdict.allowed) {
|
|
52959
|
+
deps.discovery.logger.info("alexa directive: refused by the user access gate", { meta: {
|
|
52960
|
+
userId: callerIdentity.userId,
|
|
52961
|
+
directive: key,
|
|
52962
|
+
endpointId: endpoint.endpointId,
|
|
52963
|
+
reason: describeRefusal(verdict.refusal)
|
|
52964
|
+
} });
|
|
52965
|
+
return {
|
|
52966
|
+
status: 200,
|
|
52967
|
+
body: errorEnvelope(header, alexaErrorTypeFor(verdict.refusal), describeRefusal(verdict.refusal))
|
|
52968
|
+
};
|
|
52969
|
+
}
|
|
52970
|
+
if (NEEDS_REACHABLE_DEVICE.has(key)) {
|
|
52971
|
+
const deviceId = reachabilityDeviceOf(endpoint.endpointId);
|
|
52972
|
+
if (deviceId !== null && await readConnectivity(deps.reportState.ctx.api, deviceId, deps.reportState.logger) === "UNREACHABLE") {
|
|
52973
|
+
deps.reportState.logger.info("alexa directive: refused — the device is offline", {
|
|
52974
|
+
tags: { deviceId },
|
|
52975
|
+
meta: {
|
|
52976
|
+
directive: key,
|
|
52977
|
+
endpointId: endpoint.endpointId
|
|
52978
|
+
}
|
|
52979
|
+
});
|
|
52980
|
+
return {
|
|
52981
|
+
status: 200,
|
|
52982
|
+
body: errorEnvelope(header, "ENDPOINT_UNREACHABLE", `device ${deviceId} is offline`)
|
|
52983
|
+
};
|
|
52984
|
+
}
|
|
52985
|
+
}
|
|
52986
|
+
}
|
|
52987
|
+
}
|
|
52988
|
+
if (key === "Alexa.Discovery/Discover") {
|
|
52989
|
+
if (principal === null) throw new Error("discovery reached without a principal");
|
|
52990
|
+
const who = principal;
|
|
52991
|
+
return {
|
|
52992
|
+
status: 200,
|
|
52993
|
+
body: await handleDiscovery(header, payload, deps.discovery, callerIdentity, {
|
|
52994
|
+
deviceVisible: async (deviceId) => deps.access.isExposed(deviceId) && await deps.access.gate.allows(who, deviceId, "view") === "allowed",
|
|
52995
|
+
filterEndpoints: async (endpoints) => {
|
|
52996
|
+
const filtered = await filterEndpointsForPrincipal(endpoints, who, deps.access);
|
|
52997
|
+
if (filtered.refused.length > 0) deps.discovery.logger.info("alexa discovery: endpoints withheld from this link", { meta: {
|
|
52998
|
+
userId: callerIdentity.userId,
|
|
52999
|
+
withheld: filtered.refused.map((r) => ({
|
|
53000
|
+
endpointId: r.endpointId,
|
|
53001
|
+
reason: describeRefusal(r.refusal)
|
|
53002
|
+
}))
|
|
53003
|
+
} });
|
|
53004
|
+
const valid = withholdInvalidEndpoints(filtered.kept, (message, violations) => {
|
|
53005
|
+
deps.discovery.logger.warn(message, { meta: {
|
|
53006
|
+
userId: callerIdentity.userId,
|
|
53007
|
+
violations
|
|
53008
|
+
} });
|
|
53009
|
+
});
|
|
53010
|
+
deps.afterDiscovery(callerIdentity.userId, valid);
|
|
53011
|
+
return valid;
|
|
53012
|
+
}
|
|
53013
|
+
})
|
|
53014
|
+
};
|
|
53015
|
+
}
|
|
51659
53016
|
if (key === "Alexa.CameraStreamController/InitializeCameraStreams") return {
|
|
51660
53017
|
status: 200,
|
|
51661
53018
|
body: await handleInitializeCameraStreams(header, endpoint, payload, deps.cameraStream, callerIdentity)
|
|
51662
53019
|
};
|
|
51663
53020
|
if (key === "Alexa.RTCSessionController/InitiateSessionWithOffer") return {
|
|
51664
53021
|
status: 200,
|
|
51665
|
-
body: await handleInitiateSessionWithOffer(header, endpoint, payload, deps.rtcSession, callerIdentity)
|
|
53022
|
+
body: await handleInitiateSessionWithOffer(header, endpoint, payload, deps.rtcSession, callerIdentity, { mayTalk: await mayControlEndpoint(endpoint?.endpointId, principal, deps.access) })
|
|
51666
53023
|
};
|
|
51667
53024
|
if (key === "Alexa.RTCSessionController/SessionConnected") return {
|
|
51668
53025
|
status: 200,
|
|
@@ -51696,6 +53053,14 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
|
|
|
51696
53053
|
status: 200,
|
|
51697
53054
|
body: await handleDisarm(header, endpoint, payload, deps.securityPanel, callerIdentity)
|
|
51698
53055
|
};
|
|
53056
|
+
if (key === "Alexa.LockController/Lock" || key === "Alexa.LockController/Unlock") return {
|
|
53057
|
+
status: 200,
|
|
53058
|
+
body: await handleLock(header, endpoint, deps.lockController)
|
|
53059
|
+
};
|
|
53060
|
+
if (key === "Alexa.SmartVision.SnapshotProvider/GetSnapshot") return {
|
|
53061
|
+
status: 200,
|
|
53062
|
+
body: await handleGetSnapshot(header, endpoint, deps.snapshotProvider, callerIdentity)
|
|
53063
|
+
};
|
|
51699
53064
|
if (key === "Alexa/ReportState") return {
|
|
51700
53065
|
status: 200,
|
|
51701
53066
|
body: await handleReportState(header, endpoint, deps.reportState, callerIdentity)
|
|
@@ -51929,6 +53294,81 @@ function verifyIngressToken(expected, provided) {
|
|
|
51929
53294
|
function readIngressHeader(headers) {
|
|
51930
53295
|
for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === "x-camstack-ingress") return value;
|
|
51931
53296
|
}
|
|
53297
|
+
/** Characters of a secret a mask may keep, so an operator can tell two apart. */
|
|
53298
|
+
var MASK_TAIL = 4;
|
|
53299
|
+
/**
|
|
53300
|
+
* A secret as a READ surface may show it: a fixed run of dots and, for a value
|
|
53301
|
+
* long enough that four characters do not meaningfully shorten a guess, its
|
|
53302
|
+
* last four. Every read of this addon's status goes through this (S2) — the
|
|
53303
|
+
* value itself is handed out only by the admin-only `revealIngressToken`.
|
|
53304
|
+
*/
|
|
53305
|
+
function maskSecretTail(secret) {
|
|
53306
|
+
if (secret.length === 0) return "";
|
|
53307
|
+
if (secret.length <= MASK_TAIL * 4) return "••••";
|
|
53308
|
+
return `••••••••${secret.slice(-4)}`;
|
|
53309
|
+
}
|
|
53310
|
+
//#endregion
|
|
53311
|
+
//#region src/snapshots/snapshot-route.ts
|
|
53312
|
+
var IMAGE_FILE = /^([0-9a-f-]{36})\.jpg$/;
|
|
53313
|
+
function bearerOf(headers) {
|
|
53314
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
53315
|
+
if (key.toLowerCase() !== "authorization") continue;
|
|
53316
|
+
return /^Bearer\s+(.+)$/i.exec(value.trim())?.[1] ?? null;
|
|
53317
|
+
}
|
|
53318
|
+
return null;
|
|
53319
|
+
}
|
|
53320
|
+
function refuse(reply, status, error) {
|
|
53321
|
+
reply.code(status).type("application/json").header("cache-control", "no-store").header("content-encoding", "identity");
|
|
53322
|
+
reply.send({ error });
|
|
53323
|
+
}
|
|
53324
|
+
async function handleSnapshotFetch(req, reply, deps) {
|
|
53325
|
+
if (!deps.enabled()) {
|
|
53326
|
+
refuse(reply, 410, "snapshots are switched off");
|
|
53327
|
+
return;
|
|
53328
|
+
}
|
|
53329
|
+
const imageId = IMAGE_FILE.exec(req.params["file"] ?? "")?.[1];
|
|
53330
|
+
if (imageId === void 0) {
|
|
53331
|
+
refuse(reply, 410, "not a snapshot address");
|
|
53332
|
+
return;
|
|
53333
|
+
}
|
|
53334
|
+
const found = deps.store.lookup(imageId, req.query["exp"], req.query["sig"]);
|
|
53335
|
+
if (found.kind === "gone") {
|
|
53336
|
+
deps.logger.debug("alexa snapshot: fetch of a gone image", { meta: {
|
|
53337
|
+
imageId,
|
|
53338
|
+
reason: found.reason
|
|
53339
|
+
} });
|
|
53340
|
+
refuse(reply, 410, found.reason);
|
|
53341
|
+
return;
|
|
53342
|
+
}
|
|
53343
|
+
const image = found.image;
|
|
53344
|
+
const token = bearerOf(req.headers);
|
|
53345
|
+
const verified = token === null ? null : await deps.verifyToken(token).catch(() => null);
|
|
53346
|
+
if (verified === null) {
|
|
53347
|
+
deps.logger.warn("alexa snapshot: refused a fetch without a valid account-link token", {
|
|
53348
|
+
tags: { deviceId: image.deviceId },
|
|
53349
|
+
meta: {
|
|
53350
|
+
imageId,
|
|
53351
|
+
hadToken: token !== null
|
|
53352
|
+
}
|
|
53353
|
+
});
|
|
53354
|
+
refuse(reply, 401, "a valid Alexa account-link token is required");
|
|
53355
|
+
return;
|
|
53356
|
+
}
|
|
53357
|
+
if (verified.userId !== image.userId || !await deps.mayView(verified.userId, image.deviceId)) {
|
|
53358
|
+
deps.logger.warn("alexa snapshot: refused — the token does not belong to the user this image was made for, or that user lost the camera", {
|
|
53359
|
+
tags: { deviceId: image.deviceId },
|
|
53360
|
+
meta: {
|
|
53361
|
+
imageId,
|
|
53362
|
+
tokenUser: verified.userId,
|
|
53363
|
+
imageUser: image.userId
|
|
53364
|
+
}
|
|
53365
|
+
});
|
|
53366
|
+
refuse(reply, 403, "not allowed");
|
|
53367
|
+
return;
|
|
53368
|
+
}
|
|
53369
|
+
reply.code(200).type(image.contentType).header("cache-control", "private, no-store").header("content-encoding", "identity").header("content-length", String(image.bytes.byteLength));
|
|
53370
|
+
reply.send(image.bytes);
|
|
53371
|
+
}
|
|
51932
53372
|
//#endregion
|
|
51933
53373
|
//#region src/http-server.ts
|
|
51934
53374
|
/**
|
|
@@ -51981,6 +53421,13 @@ function buildRoutes(deps) {
|
|
|
51981
53421
|
await handleLwaTokensCallback(req, reply, deps.lwaTokensCallbackDeps);
|
|
51982
53422
|
}
|
|
51983
53423
|
},
|
|
53424
|
+
{
|
|
53425
|
+
method: "GET",
|
|
53426
|
+
path: "/snapshot/:file",
|
|
53427
|
+
access: "public",
|
|
53428
|
+
description: "Alexa SnapshotProvider image (signed, 10 min, account-link token required)",
|
|
53429
|
+
handler: (req, reply) => handleSnapshotFetch(req, reply, deps.snapshotRoute)
|
|
53430
|
+
},
|
|
51984
53431
|
{
|
|
51985
53432
|
method: "POST",
|
|
51986
53433
|
path: "/admin/redetect-hub-url",
|
|
@@ -52163,6 +53610,46 @@ function describeGrant(acc, input) {
|
|
|
52163
53610
|
return `Amazon push grant: held via the skill's token broker, valid for another ${minutes(token.expiresAt - input.now)} min (last renewed ${renewedAgo} min ago).`;
|
|
52164
53611
|
}
|
|
52165
53612
|
//#endregion
|
|
53613
|
+
//#region src/endpoint-signature.ts
|
|
53614
|
+
/**
|
|
53615
|
+
* One capability's identity as Alexa distinguishes it. A capability whose
|
|
53616
|
+
* properties are proactively reported carries a `!p` mark: Alexa's copy must
|
|
53617
|
+
* know the hub reports them, or the ChangeReports are worth nothing (R3) —
|
|
53618
|
+
* so flipping the flag is a shape change the reconcile re-pushes.
|
|
53619
|
+
*/
|
|
53620
|
+
function capabilityKey(capability) {
|
|
53621
|
+
const instance = typeof capability.instance === "string" ? capability.instance : "";
|
|
53622
|
+
const base = instance.length > 0 ? `${capability.interface}#${instance}` : capability.interface;
|
|
53623
|
+
return capability.properties?.proactivelyReported === true ? `${base}!p` : base;
|
|
53624
|
+
}
|
|
53625
|
+
/** Read back the endpoints (and their interfaces) a signature records — what
|
|
53626
|
+
* Alexa was last told about a device. */
|
|
53627
|
+
function signatureEntries(signature) {
|
|
53628
|
+
if (signature.length === 0) return [];
|
|
53629
|
+
return signature.split(";").map((entry) => {
|
|
53630
|
+
const [endpointId = "", , interfaces = ""] = entry.split("|");
|
|
53631
|
+
return {
|
|
53632
|
+
endpointId,
|
|
53633
|
+
interfaces: interfaces.split(",").filter((i) => i.length > 0).map((i) => i.replace(/!p$/, "").replace(/#.*$/, ""))
|
|
53634
|
+
};
|
|
53635
|
+
});
|
|
53636
|
+
}
|
|
53637
|
+
/** Stable, order-independent signature of ONE endpoint's advertised shape. */
|
|
53638
|
+
function endpointKey(endpoint) {
|
|
53639
|
+
const interfaces = endpoint.capabilities.map(capabilityKey).sort().join(",");
|
|
53640
|
+
const categories = [...endpoint.displayCategories].sort().join(",");
|
|
53641
|
+
return `${endpoint.endpointId}|${categories}|${interfaces}`;
|
|
53642
|
+
}
|
|
53643
|
+
/**
|
|
53644
|
+
* Stable signature of the endpoint set built for ONE device. Two builds that
|
|
53645
|
+
* would land identically in Alexa's registry produce the same string; any
|
|
53646
|
+
* added, removed or renamed interface — or a changed display category —
|
|
53647
|
+
* produces a different one.
|
|
53648
|
+
*/
|
|
53649
|
+
function endpointsSignature(endpoints) {
|
|
53650
|
+
return endpoints.map(endpointKey).sort().join(";");
|
|
53651
|
+
}
|
|
53652
|
+
//#endregion
|
|
52166
53653
|
//#region src/motion-clear-hold.ts
|
|
52167
53654
|
/**
|
|
52168
53655
|
* Default hold. Matches `RESET_DEBOUNCE_MS` in
|
|
@@ -52297,6 +53784,24 @@ var MIGRATION_RETRY_MS = 300 * 1e3;
|
|
|
52297
53784
|
/** The interface a camera endpoint must declare for a connectivity
|
|
52298
53785
|
* ChangeReport to mean anything to the Alexa cloud. */
|
|
52299
53786
|
var ENDPOINT_HEALTH_INTERFACE = "Alexa.EndpointHealth";
|
|
53787
|
+
/** Whether an exported signature (`endpoint-signature.ts`) names this
|
|
53788
|
+
* endpoint id. */
|
|
53789
|
+
function signatureNamesEndpoint(signature, endpointId) {
|
|
53790
|
+
return signature.split(";").some((entry) => entry.startsWith(`${endpointId}|`));
|
|
53791
|
+
}
|
|
53792
|
+
/** How long after a push the initial connectivity reports go out — the
|
|
53793
|
+
* AddOrUpdateReport is accepted asynchronously, and a ChangeReport for an
|
|
53794
|
+
* endpoint the cloud has not created yet is dropped. */
|
|
53795
|
+
var INITIAL_CONNECTIVITY_DELAY_MS = 5e3;
|
|
53796
|
+
/**
|
|
53797
|
+
* The device whose online state an endpoint's connectivity reports: the
|
|
53798
|
+
* exposed device the endpoint is published under (a camera's own id, a
|
|
53799
|
+
* detection sensor's camera, a generic device's id).
|
|
53800
|
+
*/
|
|
53801
|
+
function connectivityDeviceOf(endpointId) {
|
|
53802
|
+
const target = endpointAccessTarget(parseCameraEndpointId(endpointId));
|
|
53803
|
+
return target === null ? null : target.exposedDeviceId;
|
|
53804
|
+
}
|
|
52300
53805
|
/** The interface an endpoint must advertise for a `DoorbellPress` to survive
|
|
52301
53806
|
* the Alexa cloud. */
|
|
52302
53807
|
var DOORBELL_INTERFACE = "Alexa.DoorbellEventSource";
|
|
@@ -52327,11 +53832,12 @@ var LwaEventGateway = class {
|
|
|
52327
53832
|
deps;
|
|
52328
53833
|
unsubscribe = null;
|
|
52329
53834
|
unsubscribeMotion = null;
|
|
52330
|
-
unsubscribeBattery = null;
|
|
52331
53835
|
unsubscribeConnectivity = [];
|
|
52332
53836
|
/** Last connectivity reported per `userId|endpointId` — a camera that
|
|
52333
53837
|
* re-announces the same state must not become a ChangeReport stream. */
|
|
52334
53838
|
lastConnectivity = /* @__PURE__ */ new Map();
|
|
53839
|
+
/** Initial-connectivity reports waiting for the cloud to create endpoints. */
|
|
53840
|
+
pendingInitialReports = /* @__PURE__ */ new Set();
|
|
52335
53841
|
refreshTimer = null;
|
|
52336
53842
|
lastSentAt = /* @__PURE__ */ new Map();
|
|
52337
53843
|
/**
|
|
@@ -52345,11 +53851,6 @@ var LwaEventGateway = class {
|
|
|
52345
53851
|
/** Per-endpoint count of `ObjectDetection` events dropped because the
|
|
52346
53852
|
* endpoint does not declare {@link SMART_VISION_INTERFACE}. */
|
|
52347
53853
|
smartVisionDropped = /* @__PURE__ */ new Map();
|
|
52348
|
-
/** Last reported integer battery percentage per `userId|endpointId` —
|
|
52349
|
-
* battery providers may push the same level every poll cycle and
|
|
52350
|
-
* Alexa Smart Home rate-limits ChangeReports per property; dedupe
|
|
52351
|
-
* so only level *changes* (or step-grain ones) hit the gateway. */
|
|
52352
|
-
lastBatteryLevel = /* @__PURE__ */ new Map();
|
|
52353
53854
|
/** Latches the "no grant at all" warning so it's logged ONCE, not on every
|
|
52354
53855
|
* doorbell/motion event while no account has a push grant. */
|
|
52355
53856
|
warnedNoGrant = false;
|
|
@@ -52375,11 +53876,6 @@ var LwaEventGateway = class {
|
|
|
52375
53876
|
this.deps.logger.warn("alexa lwa-gateway: motion handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
52376
53877
|
});
|
|
52377
53878
|
});
|
|
52378
|
-
this.unsubscribeBattery = this.deps.ctx.eventBus.subscribe({ category: EventCategory.BatteryOnStatusChanged }, (event) => {
|
|
52379
|
-
this.onBatteryStatusChanged(event.data).catch((err) => {
|
|
52380
|
-
this.deps.logger.warn("alexa lwa-gateway: battery handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
52381
|
-
});
|
|
52382
|
-
});
|
|
52383
53879
|
this.unsubscribeConnectivity = [[EventCategory.DeviceOnline, "OK"], [EventCategory.DeviceOffline, "UNREACHABLE"]].map(([category, connectivity]) => this.deps.ctx.eventBus.subscribe({ category }, (event) => {
|
|
52384
53880
|
this.onConnectivityChanged(event.data, connectivity).catch((err) => {
|
|
52385
53881
|
this.deps.logger.warn("alexa lwa-gateway: connectivity handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
@@ -52397,17 +53893,16 @@ var LwaEventGateway = class {
|
|
|
52397
53893
|
this.unsubscribe = null;
|
|
52398
53894
|
if (this.unsubscribeMotion) this.unsubscribeMotion();
|
|
52399
53895
|
this.unsubscribeMotion = null;
|
|
52400
|
-
if (this.unsubscribeBattery) this.unsubscribeBattery();
|
|
52401
|
-
this.unsubscribeBattery = null;
|
|
52402
53896
|
for (const off of this.unsubscribeConnectivity) off();
|
|
52403
53897
|
this.unsubscribeConnectivity = [];
|
|
52404
53898
|
this.lastConnectivity.clear();
|
|
53899
|
+
for (const timer of this.pendingInitialReports) clearTimeout(timer);
|
|
53900
|
+
this.pendingInitialReports.clear();
|
|
52405
53901
|
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
|
52406
53902
|
this.refreshTimer = null;
|
|
52407
53903
|
this.lastSentAt.clear();
|
|
52408
53904
|
this.motionHold.clear();
|
|
52409
53905
|
this.smartVisionDropped.clear();
|
|
52410
|
-
this.lastBatteryLevel.clear();
|
|
52411
53906
|
this.warnedNoToken.clear();
|
|
52412
53907
|
this.warnedLegacyWaiting.clear();
|
|
52413
53908
|
this.migrationRetryAt.clear();
|
|
@@ -52439,6 +53934,7 @@ var LwaEventGateway = class {
|
|
|
52439
53934
|
}
|
|
52440
53935
|
if (!this.hasProactiveCredentials()) return;
|
|
52441
53936
|
for (const account of linked) {
|
|
53937
|
+
if (!this.mayPush(account.userId, parentDeviceId, "doorbell press")) continue;
|
|
52442
53938
|
if (!this.findToken(account.userId)) {
|
|
52443
53939
|
this.deps.logger.info("alexa lwa-gateway: no LWA token for user — skipped", {
|
|
52444
53940
|
tags: { deviceId: parentDeviceId },
|
|
@@ -52463,6 +53959,19 @@ var LwaEventGateway = class {
|
|
|
52463
53959
|
});
|
|
52464
53960
|
}
|
|
52465
53961
|
}
|
|
53962
|
+
/**
|
|
53963
|
+
* S1: the account's hub user may view `deviceId`. A refusal is logged at
|
|
53964
|
+
* debug — for a scoped user it is the EXPECTED outcome for every camera
|
|
53965
|
+
* outside the grant, many times a minute.
|
|
53966
|
+
*/
|
|
53967
|
+
mayPush(userId, deviceId, label) {
|
|
53968
|
+
if (this.deps.accountMaySee(userId, deviceId)) return true;
|
|
53969
|
+
this.deps.logger.debug(`alexa lwa-gateway: ${label} not sent to this account — its hub user may not view the device`, {
|
|
53970
|
+
tags: { deviceId },
|
|
53971
|
+
meta: { userId }
|
|
53972
|
+
});
|
|
53973
|
+
return false;
|
|
53974
|
+
}
|
|
52466
53975
|
isThrottled(key) {
|
|
52467
53976
|
const composite = `${key.userId}|${key.endpointId}`;
|
|
52468
53977
|
const last = this.lastSentAt.get(composite) ?? 0;
|
|
@@ -52485,13 +53994,57 @@ var LwaEventGateway = class {
|
|
|
52485
53994
|
async onMotionChanged(data) {
|
|
52486
53995
|
if (!this.deps.listExposed().some((e) => e.deviceId === data.deviceId)) return;
|
|
52487
53996
|
const endpointId = String(data.deviceId);
|
|
53997
|
+
await this.pushMotionState({
|
|
53998
|
+
deviceId: data.deviceId,
|
|
53999
|
+
endpointId,
|
|
54000
|
+
detected: data.detected,
|
|
54001
|
+
timestamp: data.timestamp,
|
|
54002
|
+
label: "motion",
|
|
54003
|
+
onRisingPosted: (userId) => this.postObjectDetection({
|
|
54004
|
+
userId,
|
|
54005
|
+
deviceId: data.deviceId,
|
|
54006
|
+
endpointId,
|
|
54007
|
+
timestamp: data.timestamp
|
|
54008
|
+
})
|
|
54009
|
+
});
|
|
54010
|
+
}
|
|
54011
|
+
/**
|
|
54012
|
+
* A camera's detection class appeared or cleared (R1, D772): report it on
|
|
54013
|
+
* that class's detection-sensor endpoint, with the same hold as camera
|
|
54014
|
+
* motion (instant rising edge; NOT_DETECTED only after the source went
|
|
54015
|
+
* quiet and at least 30 s after DETECTED, Amazon's MotionSensor rule).
|
|
54016
|
+
*
|
|
54017
|
+
* Posted only to an endpoint Alexa KNOWS: a ChangeReport for an endpoint the
|
|
54018
|
+
* last AddOrUpdateReport did not contain is a 202 the cloud discards (D76),
|
|
54019
|
+
* so the exported signature must name it.
|
|
54020
|
+
*/
|
|
54021
|
+
async pushDetectionSensor(opts) {
|
|
54022
|
+
if (!this.deps.listExposed().some((e) => e.deviceId === opts.deviceId)) return;
|
|
54023
|
+
const signature = this.deps.getExportedSignature(opts.deviceId);
|
|
54024
|
+
if (signature === null || !signatureNamesEndpoint(signature, opts.endpointId)) {
|
|
54025
|
+
this.deps.logger.debug("alexa lwa-gateway: detection change not sent — Alexa has not been told about this sensor endpoint", {
|
|
54026
|
+
tags: { deviceId: opts.deviceId },
|
|
54027
|
+
meta: { endpointId: opts.endpointId }
|
|
54028
|
+
});
|
|
54029
|
+
return;
|
|
54030
|
+
}
|
|
54031
|
+
await this.pushMotionState({
|
|
54032
|
+
...opts,
|
|
54033
|
+
label: "detection"
|
|
54034
|
+
});
|
|
54035
|
+
}
|
|
54036
|
+
/** The per-account MotionSensor ChangeReport loop shared by camera motion
|
|
54037
|
+
* and the detection sensors. The hold is keyed per account AND endpoint. */
|
|
54038
|
+
async pushMotionState(opts) {
|
|
54039
|
+
const { deviceId, endpointId } = opts;
|
|
52488
54040
|
const linked = this.deps.listLinkedAccounts();
|
|
52489
54041
|
if (linked.length === 0) return;
|
|
52490
54042
|
if (!this.hasProactiveCredentials()) return;
|
|
52491
54043
|
for (const account of linked) {
|
|
54044
|
+
if (!this.mayPush(account.userId, deviceId, opts.label)) continue;
|
|
52492
54045
|
if (!this.findToken(account.userId)) continue;
|
|
52493
54046
|
const key = `${account.userId}|${endpointId}`;
|
|
52494
|
-
if (!
|
|
54047
|
+
if (!opts.detected) {
|
|
52495
54048
|
this.motionHold.onCleared(key, () => {
|
|
52496
54049
|
this.postMotionChange({
|
|
52497
54050
|
userId: account.userId,
|
|
@@ -52499,8 +54052,8 @@ var LwaEventGateway = class {
|
|
|
52499
54052
|
detected: false,
|
|
52500
54053
|
timestamp: Date.now()
|
|
52501
54054
|
}).catch((err) => {
|
|
52502
|
-
this.deps.logger.warn(
|
|
52503
|
-
tags: { deviceId
|
|
54055
|
+
this.deps.logger.warn(`alexa lwa-gateway: held ${opts.label} clear failed to post`, {
|
|
54056
|
+
tags: { deviceId },
|
|
52504
54057
|
meta: {
|
|
52505
54058
|
endpointId,
|
|
52506
54059
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -52515,14 +54068,9 @@ var LwaEventGateway = class {
|
|
|
52515
54068
|
userId: account.userId,
|
|
52516
54069
|
endpointId,
|
|
52517
54070
|
detected: true,
|
|
52518
|
-
timestamp:
|
|
52519
|
-
});
|
|
52520
|
-
await this.postObjectDetection({
|
|
52521
|
-
userId: account.userId,
|
|
52522
|
-
deviceId: data.deviceId,
|
|
52523
|
-
endpointId,
|
|
52524
|
-
timestamp: data.timestamp
|
|
54071
|
+
timestamp: opts.timestamp
|
|
52525
54072
|
});
|
|
54073
|
+
await opts.onRisingPosted?.(account.userId);
|
|
52526
54074
|
}
|
|
52527
54075
|
}
|
|
52528
54076
|
async postMotionChange(opts) {
|
|
@@ -52577,59 +54125,113 @@ var LwaEventGateway = class {
|
|
|
52577
54125
|
}), `smartvision person`);
|
|
52578
54126
|
}
|
|
52579
54127
|
/**
|
|
52580
|
-
*
|
|
52581
|
-
* ChangeReport
|
|
52582
|
-
*
|
|
52583
|
-
*
|
|
52584
|
-
* cam reports every few minutes, and Alexa Smart Home rate-limits
|
|
52585
|
-
* ChangeReports anyway).
|
|
54128
|
+
* A camera went offline or came back. Amazon requires the `connectivity`
|
|
54129
|
+
* ChangeReport within 3 s of the change (alexa-endpointhealth). Deduped per
|
|
54130
|
+
* account and endpoint, and skipped for an endpoint Alexa is known to hold
|
|
54131
|
+
* WITHOUT `Alexa.EndpointHealth` — the cloud would drop it (D76).
|
|
52586
54132
|
*/
|
|
52587
|
-
async
|
|
52588
|
-
if (
|
|
52589
|
-
|
|
54133
|
+
async onConnectivityChanged(data, connectivity) {
|
|
54134
|
+
if (typeof data?.deviceId !== "number") return;
|
|
54135
|
+
await this.reportConnectivity(data.deviceId, connectivity);
|
|
54136
|
+
}
|
|
54137
|
+
/**
|
|
54138
|
+
* A device went offline or came back: report `connectivity` on EVERY
|
|
54139
|
+
* endpoint of it that Alexa knows with `Alexa.EndpointHealth` — the camera
|
|
54140
|
+
* and its detection sensors (D770). Fed by the generic `device-status`
|
|
54141
|
+
* slice (R3) and by the providers that also emit DeviceOnline/Offline;
|
|
54142
|
+
* deduplicated per account and endpoint, so the two sources never double-post.
|
|
54143
|
+
*/
|
|
54144
|
+
async reportConnectivity(deviceId, connectivity) {
|
|
54145
|
+
if (!this.deps.listExposed().some((e) => e.deviceId === deviceId)) return;
|
|
54146
|
+
const signature = this.deps.getExportedSignature(deviceId);
|
|
54147
|
+
const endpointIds = signature === null ? [String(deviceId)] : signatureEntries(signature).filter((e) => e.interfaces.includes(ENDPOINT_HEALTH_INTERFACE)).map((e) => e.endpointId);
|
|
54148
|
+
if (endpointIds.length === 0) return;
|
|
52590
54149
|
const linked = this.deps.listLinkedAccounts();
|
|
52591
54150
|
if (linked.length === 0) return;
|
|
52592
54151
|
if (!this.hasProactiveCredentials()) return;
|
|
52593
|
-
const level = Math.max(0, Math.min(100, Math.round(data.status.percentage)));
|
|
52594
54152
|
for (const account of linked) {
|
|
52595
|
-
if (!this.
|
|
52596
|
-
const
|
|
52597
|
-
|
|
52598
|
-
|
|
52599
|
-
|
|
52600
|
-
|
|
52601
|
-
|
|
52602
|
-
|
|
52603
|
-
|
|
52604
|
-
|
|
52605
|
-
|
|
54153
|
+
if (!this.mayPush(account.userId, deviceId, "connectivity")) continue;
|
|
54154
|
+
for (const endpointId of endpointIds) {
|
|
54155
|
+
const key = `${account.userId}|${endpointId}`;
|
|
54156
|
+
if (this.lastConnectivity.get(key) === connectivity) continue;
|
|
54157
|
+
this.lastConnectivity.set(key, connectivity);
|
|
54158
|
+
await this.postEventBody(account.userId, endpointId, (accessToken) => buildConnectivityChangeReportPayload({
|
|
54159
|
+
endpointId,
|
|
54160
|
+
connectivity,
|
|
54161
|
+
timestamp: Date.now(),
|
|
54162
|
+
accessToken
|
|
54163
|
+
}), `connectivity ${connectivity}`);
|
|
54164
|
+
}
|
|
52606
54165
|
}
|
|
52607
54166
|
}
|
|
52608
54167
|
/**
|
|
52609
|
-
* A
|
|
52610
|
-
*
|
|
52611
|
-
*
|
|
52612
|
-
* WITHOUT `Alexa.EndpointHealth` — the cloud would drop it (D76).
|
|
54168
|
+
* A device state change Alexa should hear about (R3, D776): the alarm
|
|
54169
|
+
* panel, a privacy/autotrack switch, an accessory. Posted only to an
|
|
54170
|
+
* endpoint Alexa knows WITH the interface the properties belong to (D76).
|
|
52613
54171
|
*/
|
|
52614
|
-
async
|
|
52615
|
-
if (
|
|
52616
|
-
|
|
52617
|
-
const
|
|
52618
|
-
if (
|
|
54172
|
+
async postStateChange(report) {
|
|
54173
|
+
if (!this.deps.listExposed().some((e) => e.deviceId === report.exposedDeviceId)) return;
|
|
54174
|
+
const signature = this.deps.getExportedSignature(report.exposedDeviceId);
|
|
54175
|
+
const entry = signature === null ? void 0 : signatureEntries(signature).find((e) => e.endpointId === report.endpointId);
|
|
54176
|
+
if (entry === void 0 || !entry.interfaces.includes(report.interfaceName)) {
|
|
54177
|
+
this.deps.logger.debug("alexa lwa-gateway: state change not sent — Alexa has not been told about this endpoint/interface", {
|
|
54178
|
+
tags: { deviceId: report.exposedDeviceId },
|
|
54179
|
+
meta: {
|
|
54180
|
+
endpointId: report.endpointId,
|
|
54181
|
+
interfaceName: report.interfaceName
|
|
54182
|
+
}
|
|
54183
|
+
});
|
|
54184
|
+
return;
|
|
54185
|
+
}
|
|
52619
54186
|
const linked = this.deps.listLinkedAccounts();
|
|
52620
54187
|
if (linked.length === 0) return;
|
|
52621
54188
|
if (!this.hasProactiveCredentials()) return;
|
|
52622
|
-
const endpointId = String(data.deviceId);
|
|
52623
54189
|
for (const account of linked) {
|
|
52624
|
-
|
|
52625
|
-
if (this.
|
|
52626
|
-
this.
|
|
52627
|
-
|
|
52628
|
-
|
|
54190
|
+
if (!this.mayPush(account.userId, report.exposedDeviceId, "state change")) continue;
|
|
54191
|
+
if (!this.findToken(account.userId)) continue;
|
|
54192
|
+
await this.postEventBody(account.userId, report.endpointId, (accessToken) => buildStateChangeReportPayload({
|
|
54193
|
+
endpointId: report.endpointId,
|
|
54194
|
+
properties: report.properties,
|
|
54195
|
+
timestamp: Date.now(),
|
|
54196
|
+
accessToken
|
|
54197
|
+
}), `state ${report.interfaceName}`);
|
|
54198
|
+
}
|
|
54199
|
+
}
|
|
54200
|
+
/**
|
|
54201
|
+
* After endpoints reach an account (AddOrUpdateReport or a Discover answer),
|
|
54202
|
+
* tell Alexa each one's connectivity, so it never holds an endpoint of
|
|
54203
|
+
* unknown health — the state in which the phone app refused live view
|
|
54204
|
+
* (D770). Delayed so the cloud has created the endpoints the report names.
|
|
54205
|
+
* Only endpoints that DECLARE `Alexa.EndpointHealth` are reported.
|
|
54206
|
+
*/
|
|
54207
|
+
scheduleInitialConnectivity(userId, endpoints) {
|
|
54208
|
+
const targets = endpoints.filter((e) => e.capabilities.some((c) => c.interface === ENDPOINT_HEALTH_INTERFACE));
|
|
54209
|
+
if (targets.length === 0) return;
|
|
54210
|
+
const timer = setTimeout(() => {
|
|
54211
|
+
this.reportInitialConnectivity(userId, targets).catch((err) => {
|
|
54212
|
+
this.deps.logger.warn("alexa lwa-gateway: initial connectivity report failed", { meta: {
|
|
54213
|
+
userId,
|
|
54214
|
+
error: err instanceof Error ? err.message : String(err)
|
|
54215
|
+
} });
|
|
54216
|
+
});
|
|
54217
|
+
}, INITIAL_CONNECTIVITY_DELAY_MS);
|
|
54218
|
+
timer.unref?.();
|
|
54219
|
+
this.pendingInitialReports.add(timer);
|
|
54220
|
+
}
|
|
54221
|
+
async reportInitialConnectivity(userId, endpoints) {
|
|
54222
|
+
if (!this.hasProactiveCredentials()) return;
|
|
54223
|
+
for (const endpoint of endpoints) {
|
|
54224
|
+
const deviceId = connectivityDeviceOf(endpoint.endpointId);
|
|
54225
|
+
if (deviceId === null) continue;
|
|
54226
|
+
if (!this.mayPush(userId, deviceId, "initial connectivity")) continue;
|
|
54227
|
+
const connectivity = await readConnectivity(this.deps.ctx.api, deviceId, this.deps.logger);
|
|
54228
|
+
this.lastConnectivity.set(`${userId}|${endpoint.endpointId}`, connectivity);
|
|
54229
|
+
await this.postEventBody(userId, endpoint.endpointId, (accessToken) => buildConnectivityChangeReportPayload({
|
|
54230
|
+
endpointId: endpoint.endpointId,
|
|
52629
54231
|
connectivity,
|
|
52630
54232
|
timestamp: Date.now(),
|
|
52631
54233
|
accessToken
|
|
52632
|
-
}), `connectivity ${connectivity}`);
|
|
54234
|
+
}), `initial connectivity ${connectivity}`);
|
|
52633
54235
|
}
|
|
52634
54236
|
}
|
|
52635
54237
|
/**
|
|
@@ -52688,14 +54290,25 @@ var LwaEventGateway = class {
|
|
|
52688
54290
|
* with a fresh header would ship a token Amazon has already rejected. */
|
|
52689
54291
|
async postEventBody(userId, endpointId, buildBody, label) {
|
|
52690
54292
|
const url = EVENT_GATEWAY_URLS[this.deps.getRegion()];
|
|
52691
|
-
const send = async (accessToken) =>
|
|
52692
|
-
|
|
52693
|
-
|
|
52694
|
-
|
|
52695
|
-
|
|
52696
|
-
|
|
52697
|
-
|
|
52698
|
-
|
|
54293
|
+
const send = async (accessToken) => {
|
|
54294
|
+
const body = JSON.stringify(buildBody(accessToken));
|
|
54295
|
+
return sendWithGatewayRetry(() => fetch(url, {
|
|
54296
|
+
method: "POST",
|
|
54297
|
+
headers: {
|
|
54298
|
+
"Content-Type": "application/json",
|
|
54299
|
+
Authorization: `Bearer ${accessToken}`
|
|
54300
|
+
},
|
|
54301
|
+
body
|
|
54302
|
+
}), (attempt, reason, delayMs) => {
|
|
54303
|
+
this.deps.logger.info(`alexa lwa-gateway: ${label} retrying`, { meta: {
|
|
54304
|
+
userId,
|
|
54305
|
+
endpointId,
|
|
54306
|
+
attempt,
|
|
54307
|
+
reason,
|
|
54308
|
+
delayMs
|
|
54309
|
+
} });
|
|
54310
|
+
});
|
|
54311
|
+
};
|
|
52699
54312
|
const initial = this.findToken(userId);
|
|
52700
54313
|
if (!initial) {
|
|
52701
54314
|
if (!this.warnedNoToken.has(userId)) {
|
|
@@ -52870,12 +54483,65 @@ var LwaEventGateway = class {
|
|
|
52870
54483
|
return false;
|
|
52871
54484
|
}
|
|
52872
54485
|
};
|
|
54486
|
+
/**
|
|
54487
|
+
* Statuses Amazon tells a sender to retry (smarthome/send-events: 429
|
|
54488
|
+
* THROTTLING, 500 INTERNAL_SERVICE, 503 SERVICE_UNAVAILABLE — "resend the
|
|
54489
|
+
* message up to three times, with at least a one-second interval").
|
|
54490
|
+
*/
|
|
54491
|
+
var RETRYABLE_GATEWAY_STATUSES = new Set([
|
|
54492
|
+
429,
|
|
54493
|
+
500,
|
|
54494
|
+
503
|
|
54495
|
+
]);
|
|
54496
|
+
/** Delays before retry 1, 2, 3 — bounded, at least one second each. */
|
|
54497
|
+
var GATEWAY_RETRY_DELAYS_MS = [
|
|
54498
|
+
1e3,
|
|
54499
|
+
2e3,
|
|
54500
|
+
4e3
|
|
54501
|
+
];
|
|
54502
|
+
/** A `Retry-After` the gateway sends is honoured up to this much. */
|
|
54503
|
+
var MAX_RETRY_AFTER_MS = 1e4;
|
|
54504
|
+
function retryAfterMs(response) {
|
|
54505
|
+
const raw = response.headers?.get?.("retry-after");
|
|
54506
|
+
if (raw === null || raw === void 0) return 0;
|
|
54507
|
+
const seconds = Number(raw);
|
|
54508
|
+
return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds * 1e3, MAX_RETRY_AFTER_MS) : 0;
|
|
54509
|
+
}
|
|
54510
|
+
/**
|
|
54511
|
+
* POST with Amazon's retry rule: a retryable status or a network failure is
|
|
54512
|
+
* re-sent up to three times, waiting 1 s / 2 s / 4 s (or the gateway's
|
|
54513
|
+
* `Retry-After`, capped). Anything else — 2xx, 401 (renewal is the caller's),
|
|
54514
|
+
* 403, 400 — is returned at once. The last answer (or the last network error)
|
|
54515
|
+
* is what the caller sees.
|
|
54516
|
+
*/
|
|
54517
|
+
async function sendWithGatewayRetry(attemptOnce, onRetry) {
|
|
54518
|
+
for (let attempt = 0;; attempt++) {
|
|
54519
|
+
let response = null;
|
|
54520
|
+
let failure = null;
|
|
54521
|
+
try {
|
|
54522
|
+
response = await attemptOnce();
|
|
54523
|
+
} catch (err) {
|
|
54524
|
+
failure = err;
|
|
54525
|
+
}
|
|
54526
|
+
const retryable = response === null || RETRYABLE_GATEWAY_STATUSES.has(response.status);
|
|
54527
|
+
const delay = GATEWAY_RETRY_DELAYS_MS[attempt];
|
|
54528
|
+
if (!retryable || delay === void 0) {
|
|
54529
|
+
if (response !== null) return response;
|
|
54530
|
+
throw failure;
|
|
54531
|
+
}
|
|
54532
|
+
const wait = response === null ? delay : Math.max(delay, retryAfterMs(response));
|
|
54533
|
+
onRetry(attempt + 1, response === null ? `network: ${failure instanceof Error ? failure.message : String(failure)}` : `status ${response.status}`, wait);
|
|
54534
|
+
await new Promise((resolve) => {
|
|
54535
|
+
setTimeout(resolve, wait).unref?.();
|
|
54536
|
+
});
|
|
54537
|
+
}
|
|
54538
|
+
}
|
|
52873
54539
|
function buildDoorbellPressPayload(opts) {
|
|
52874
54540
|
return { event: {
|
|
52875
54541
|
header: {
|
|
52876
54542
|
namespace: "Alexa.DoorbellEventSource",
|
|
52877
54543
|
name: "DoorbellPress",
|
|
52878
|
-
messageId:
|
|
54544
|
+
messageId: (0, node_crypto.randomUUID)(),
|
|
52879
54545
|
payloadVersion: "3"
|
|
52880
54546
|
},
|
|
52881
54547
|
endpoint: {
|
|
@@ -52898,7 +54564,7 @@ function buildMotionChangeReportPayload(opts) {
|
|
|
52898
54564
|
header: {
|
|
52899
54565
|
namespace: "Alexa",
|
|
52900
54566
|
name: "ChangeReport",
|
|
52901
|
-
messageId:
|
|
54567
|
+
messageId: (0, node_crypto.randomUUID)(),
|
|
52902
54568
|
payloadVersion: "3"
|
|
52903
54569
|
},
|
|
52904
54570
|
endpoint: {
|
|
@@ -52922,14 +54588,14 @@ function buildMotionChangeReportPayload(opts) {
|
|
|
52922
54588
|
context: { properties: [] }
|
|
52923
54589
|
};
|
|
52924
54590
|
}
|
|
52925
|
-
function
|
|
54591
|
+
function buildStateChangeReportPayload(opts) {
|
|
52926
54592
|
const timeOfSample = new Date(opts.timestamp).toISOString();
|
|
52927
54593
|
return {
|
|
52928
54594
|
event: {
|
|
52929
54595
|
header: {
|
|
52930
54596
|
namespace: "Alexa",
|
|
52931
54597
|
name: "ChangeReport",
|
|
52932
|
-
messageId:
|
|
54598
|
+
messageId: (0, node_crypto.randomUUID)(),
|
|
52933
54599
|
payloadVersion: "3"
|
|
52934
54600
|
},
|
|
52935
54601
|
endpoint: {
|
|
@@ -52940,14 +54606,14 @@ function buildBatteryChangeReportPayload(opts) {
|
|
|
52940
54606
|
endpointId: opts.endpointId
|
|
52941
54607
|
},
|
|
52942
54608
|
payload: { change: {
|
|
52943
|
-
cause: { type: "
|
|
52944
|
-
properties:
|
|
52945
|
-
namespace:
|
|
52946
|
-
name:
|
|
52947
|
-
value:
|
|
54609
|
+
cause: { type: "PHYSICAL_INTERACTION" },
|
|
54610
|
+
properties: opts.properties.map((p) => ({
|
|
54611
|
+
namespace: p.namespace,
|
|
54612
|
+
name: p.name,
|
|
54613
|
+
value: p.value,
|
|
52948
54614
|
timeOfSample,
|
|
52949
54615
|
uncertaintyInMilliseconds: 0
|
|
52950
|
-
}
|
|
54616
|
+
}))
|
|
52951
54617
|
} }
|
|
52952
54618
|
},
|
|
52953
54619
|
context: { properties: [] }
|
|
@@ -52989,7 +54655,7 @@ function buildSmartVisionObjectDetectionEvent(opts) {
|
|
|
52989
54655
|
header: {
|
|
52990
54656
|
namespace: "Alexa.SmartVision.ObjectDetectionSensor",
|
|
52991
54657
|
name: "ObjectDetection",
|
|
52992
|
-
messageId:
|
|
54658
|
+
messageId: (0, node_crypto.randomUUID)(),
|
|
52993
54659
|
payloadVersion: "1.0"
|
|
52994
54660
|
},
|
|
52995
54661
|
endpoint: {
|
|
@@ -53010,12 +54676,12 @@ function buildSmartVisionObjectDetectionEvent(opts) {
|
|
|
53010
54676
|
})) }
|
|
53011
54677
|
} };
|
|
53012
54678
|
}
|
|
53013
|
-
function buildAddOrUpdateReportPayload(
|
|
54679
|
+
function buildAddOrUpdateReportPayload(_userId, endpoints, accessToken) {
|
|
53014
54680
|
return { event: {
|
|
53015
54681
|
header: {
|
|
53016
54682
|
namespace: "Alexa.Discovery",
|
|
53017
54683
|
name: "AddOrUpdateReport",
|
|
53018
|
-
messageId:
|
|
54684
|
+
messageId: (0, node_crypto.randomUUID)(),
|
|
53019
54685
|
payloadVersion: "3"
|
|
53020
54686
|
},
|
|
53021
54687
|
payload: {
|
|
@@ -53027,12 +54693,12 @@ function buildAddOrUpdateReportPayload(userId, endpoints, accessToken) {
|
|
|
53027
54693
|
}
|
|
53028
54694
|
} };
|
|
53029
54695
|
}
|
|
53030
|
-
function buildDeleteReportPayload(
|
|
54696
|
+
function buildDeleteReportPayload(_userId, endpointIds, accessToken) {
|
|
53031
54697
|
return { event: {
|
|
53032
54698
|
header: {
|
|
53033
54699
|
namespace: "Alexa.Discovery",
|
|
53034
54700
|
name: "DeleteReport",
|
|
53035
|
-
messageId:
|
|
54701
|
+
messageId: (0, node_crypto.randomUUID)(),
|
|
53036
54702
|
payloadVersion: "3"
|
|
53037
54703
|
},
|
|
53038
54704
|
payload: {
|
|
@@ -53045,6 +54711,425 @@ function buildDeleteReportPayload(userId, endpointIds, accessToken) {
|
|
|
53045
54711
|
} };
|
|
53046
54712
|
}
|
|
53047
54713
|
//#endregion
|
|
54714
|
+
//#region src/access/account-visibility.ts
|
|
54715
|
+
/** Event-path gate. Unknown account ⇒ refused (fail closed). */
|
|
54716
|
+
function visibilityAllows(entry, deviceId) {
|
|
54717
|
+
if (entry === void 0) return false;
|
|
54718
|
+
if (entry.all === true) return true;
|
|
54719
|
+
return entry.view.includes(deviceId);
|
|
54720
|
+
}
|
|
54721
|
+
function visibleSet(entry, exposed) {
|
|
54722
|
+
if (entry === void 0 || entry.all === true) return new Set(exposed);
|
|
54723
|
+
return new Set(entry.view);
|
|
54724
|
+
}
|
|
54725
|
+
var AccountVisibilityMirror = class {
|
|
54726
|
+
deps;
|
|
54727
|
+
flight = null;
|
|
54728
|
+
constructor(deps) {
|
|
54729
|
+
this.deps = deps;
|
|
54730
|
+
}
|
|
54731
|
+
/** Synchronous event-path read. */
|
|
54732
|
+
allows(userId, deviceId) {
|
|
54733
|
+
return visibilityAllows(this.deps.getStored()[userId], deviceId);
|
|
54734
|
+
}
|
|
54735
|
+
/** Recompute every linked account. One pass at a time; a caller joins the
|
|
54736
|
+
* pass in flight. Returns the changes the caller must apply to Alexa. */
|
|
54737
|
+
async refresh() {
|
|
54738
|
+
if (this.flight === null) this.flight = this.refreshNow().finally(() => {
|
|
54739
|
+
this.flight = null;
|
|
54740
|
+
});
|
|
54741
|
+
return this.flight;
|
|
54742
|
+
}
|
|
54743
|
+
async refreshNow() {
|
|
54744
|
+
const exposed = this.deps.listExposedDeviceIds();
|
|
54745
|
+
const stored = this.deps.getStored();
|
|
54746
|
+
const next = {};
|
|
54747
|
+
const changes = [];
|
|
54748
|
+
for (const userId of this.deps.listLinkedUserIds()) {
|
|
54749
|
+
const prev = stored[userId];
|
|
54750
|
+
const computed = await this.compute(userId, prev, exposed);
|
|
54751
|
+
if (computed === null) {
|
|
54752
|
+
if (prev !== void 0) next[userId] = prev;
|
|
54753
|
+
continue;
|
|
54754
|
+
}
|
|
54755
|
+
next[userId] = computed.entry;
|
|
54756
|
+
if (computed.change.added.length > 0 || computed.change.removed.length > 0) changes.push(computed.change);
|
|
54757
|
+
}
|
|
54758
|
+
await this.deps.setStored(next);
|
|
54759
|
+
return changes;
|
|
54760
|
+
}
|
|
54761
|
+
async compute(userId, prev, exposed) {
|
|
54762
|
+
const lookup = await this.deps.gate.principal(userId);
|
|
54763
|
+
if (lookup.kind === "unavailable") return null;
|
|
54764
|
+
const before = visibleSet(prev, exposed);
|
|
54765
|
+
const prevPending = new Set(prev !== void 0 && prev.all !== true ? prev.pendingRemoval ?? [] : []);
|
|
54766
|
+
if (lookup.kind === "found" && lookup.principal.kind === "admin") return {
|
|
54767
|
+
entry: { all: true },
|
|
54768
|
+
change: {
|
|
54769
|
+
userId,
|
|
54770
|
+
added: prev === void 0 ? [] : exposed.filter((id) => !before.has(id)),
|
|
54771
|
+
removed: []
|
|
54772
|
+
}
|
|
54773
|
+
};
|
|
54774
|
+
const view = [];
|
|
54775
|
+
for (const deviceId of exposed) {
|
|
54776
|
+
if (lookup.kind === "unknown-user") continue;
|
|
54777
|
+
const verdict = await this.deps.gate.allows(lookup.principal, deviceId, "view");
|
|
54778
|
+
if (verdict === "allowed" || verdict === "unknown" && before.has(deviceId)) view.push(deviceId);
|
|
54779
|
+
}
|
|
54780
|
+
const viewSet = new Set(view);
|
|
54781
|
+
const added = prev === void 0 ? [] : view.filter((id) => !before.has(id));
|
|
54782
|
+
const lostNow = exposed.filter((id) => before.has(id) && !viewSet.has(id));
|
|
54783
|
+
const removed = [...prevPending].filter((id) => !viewSet.has(id));
|
|
54784
|
+
const pendingRemoval = lostNow.filter((id) => !prevPending.has(id));
|
|
54785
|
+
if (lookup.kind === "unknown-user" && prev === void 0) this.deps.logger.warn("alexa access: a linked Alexa account belongs to a hub user that no longer exists — it will be shown nothing", { meta: { userId } });
|
|
54786
|
+
return {
|
|
54787
|
+
entry: pendingRemoval.length > 0 ? {
|
|
54788
|
+
view,
|
|
54789
|
+
pendingRemoval
|
|
54790
|
+
} : { view },
|
|
54791
|
+
change: {
|
|
54792
|
+
userId,
|
|
54793
|
+
added,
|
|
54794
|
+
removed
|
|
54795
|
+
}
|
|
54796
|
+
};
|
|
54797
|
+
}
|
|
54798
|
+
};
|
|
54799
|
+
//#endregion
|
|
54800
|
+
//#region src/access/hub-access-source.ts
|
|
54801
|
+
function createHubAccessGate(ctx) {
|
|
54802
|
+
return new AlexaAccessGate({
|
|
54803
|
+
logger: ctx.logger,
|
|
54804
|
+
listUserGrants: async () => {
|
|
54805
|
+
return (await ctx.api.userManagement.listUsers.query()).map((u) => ({
|
|
54806
|
+
userId: u.id,
|
|
54807
|
+
isAdmin: u.isAdmin,
|
|
54808
|
+
scopes: u.scopes
|
|
54809
|
+
}));
|
|
54810
|
+
},
|
|
54811
|
+
getDeviceMeta: async (deviceId) => {
|
|
54812
|
+
const device = await ctx.api.deviceManager.getDevice.query({ deviceId });
|
|
54813
|
+
if (device === null) return null;
|
|
54814
|
+
return {
|
|
54815
|
+
id: device.id,
|
|
54816
|
+
type: String(device.type),
|
|
54817
|
+
location: device.location,
|
|
54818
|
+
parentDeviceId: device.parentDeviceId
|
|
54819
|
+
};
|
|
54820
|
+
}
|
|
54821
|
+
});
|
|
54822
|
+
}
|
|
54823
|
+
//#endregion
|
|
54824
|
+
//#region src/access/visibility-sync.ts
|
|
54825
|
+
async function applyVisibilityChanges(changes, target, logger) {
|
|
54826
|
+
for (const change of changes) {
|
|
54827
|
+
for (const deviceId of change.added) {
|
|
54828
|
+
const ok = await target.pushDeviceToAccount(change.userId, deviceId).catch((err) => {
|
|
54829
|
+
logger.warn("alexa access: pushing a newly granted device failed", {
|
|
54830
|
+
tags: { deviceId },
|
|
54831
|
+
meta: {
|
|
54832
|
+
userId: change.userId,
|
|
54833
|
+
error: errMsg(err)
|
|
54834
|
+
}
|
|
54835
|
+
});
|
|
54836
|
+
return false;
|
|
54837
|
+
});
|
|
54838
|
+
logger.info("alexa access: the linked user gained this device — pushed to their Alexa account", {
|
|
54839
|
+
tags: { deviceId },
|
|
54840
|
+
meta: {
|
|
54841
|
+
userId: change.userId,
|
|
54842
|
+
delivered: ok
|
|
54843
|
+
}
|
|
54844
|
+
});
|
|
54845
|
+
}
|
|
54846
|
+
for (const deviceId of change.removed) {
|
|
54847
|
+
const ok = await target.removeDeviceFromAccount(change.userId, deviceId).catch((err) => {
|
|
54848
|
+
logger.warn("alexa access: deleting a revoked device failed", {
|
|
54849
|
+
tags: { deviceId },
|
|
54850
|
+
meta: {
|
|
54851
|
+
userId: change.userId,
|
|
54852
|
+
error: errMsg(err)
|
|
54853
|
+
}
|
|
54854
|
+
});
|
|
54855
|
+
return false;
|
|
54856
|
+
});
|
|
54857
|
+
logger.warn("alexa access: the linked user lost this device (confirmed twice) — deleted from their Alexa account", {
|
|
54858
|
+
tags: { deviceId },
|
|
54859
|
+
meta: {
|
|
54860
|
+
userId: change.userId,
|
|
54861
|
+
delivered: ok
|
|
54862
|
+
}
|
|
54863
|
+
});
|
|
54864
|
+
}
|
|
54865
|
+
}
|
|
54866
|
+
}
|
|
54867
|
+
//#endregion
|
|
54868
|
+
//#region src/detection-sensors/detection-tracker.ts
|
|
54869
|
+
/**
|
|
54870
|
+
* Which detection classes are currently present on each camera (R1, D772).
|
|
54871
|
+
*
|
|
54872
|
+
* Fed by the pipeline's track lifecycle (`PipelineAnalyticsTrackLifecycle`,
|
|
54873
|
+
* `data.deviceId` — these events carry no device on their `source`) and by
|
|
54874
|
+
* package deliveries. A class is present while at least one of ITS tracks is
|
|
54875
|
+
* open: the set is per `(camera, class)` and per `trackId`, so the end of one
|
|
54876
|
+
* person's track does not clear "Person" while another person is still in
|
|
54877
|
+
* view (the HA export's per-event flip has exactly that bug).
|
|
54878
|
+
*
|
|
54879
|
+
* Events are telemetry and may be dropped (D8), and the pipeline does not
|
|
54880
|
+
* promise an `end` for every `start` (a zero-media false birth or a discarded
|
|
54881
|
+
* stale track ends silently). So {@link reconcile} replaces a camera's sets
|
|
54882
|
+
* with an authoritative read of its ACTIVE tracks; a sensor can therefore never
|
|
54883
|
+
* stay "detected" past one reconcile after its last track closed. A read that
|
|
54884
|
+
* fails is not passed in — the sets then stay as they were.
|
|
54885
|
+
*
|
|
54886
|
+
* Emits a transition per `(camera, class)` only when presence changes; the
|
|
54887
|
+
* Alexa-facing hold (≥ 30 s DETECTED before NOT_DETECTED) is the gateway's.
|
|
54888
|
+
*/
|
|
54889
|
+
/** A package delivery is an instant, not a track: it is present this long. */
|
|
54890
|
+
var PACKAGE_PULSE_MS = 1e3;
|
|
54891
|
+
/** The detection class a pipeline class name belongs to, or null. Pipeline
|
|
54892
|
+
* classes are already macros; a raw COCO label is mapped the same way the
|
|
54893
|
+
* executor maps it. */
|
|
54894
|
+
function detectionClassOf(className) {
|
|
54895
|
+
if (className === void 0) return null;
|
|
54896
|
+
const lower = className.toLowerCase();
|
|
54897
|
+
const isClass = (v) => DETECTION_SENSOR_CLASSES.includes(v);
|
|
54898
|
+
if (isClass(lower)) return lower;
|
|
54899
|
+
const mapped = COCO_TO_MACRO.mapping[lower];
|
|
54900
|
+
return mapped !== void 0 && isClass(mapped) ? mapped : null;
|
|
54901
|
+
}
|
|
54902
|
+
var DetectionTracker = class {
|
|
54903
|
+
onTransition;
|
|
54904
|
+
/** `${deviceId}|${class}` → open track ids. */
|
|
54905
|
+
open = /* @__PURE__ */ new Map();
|
|
54906
|
+
pulseTimers = /* @__PURE__ */ new Set();
|
|
54907
|
+
constructor(onTransition) {
|
|
54908
|
+
this.onTransition = onTransition;
|
|
54909
|
+
}
|
|
54910
|
+
onTrackLifecycle(event) {
|
|
54911
|
+
const cls = detectionClassOf(event.bestClassName ?? event.classes?.[0]);
|
|
54912
|
+
if (cls === null) return;
|
|
54913
|
+
if (event.phase === "end") {
|
|
54914
|
+
this.remove(event.deviceId, cls, event.trackId);
|
|
54915
|
+
for (const other of DETECTION_SENSOR_CLASSES) if (other !== cls) this.remove(event.deviceId, other, event.trackId);
|
|
54916
|
+
return;
|
|
54917
|
+
}
|
|
54918
|
+
this.add(event.deviceId, cls, event.trackId);
|
|
54919
|
+
}
|
|
54920
|
+
/** A package was delivered: "Package" is detected, as a pulse. */
|
|
54921
|
+
onPackageDelivered(deviceId, entryId) {
|
|
54922
|
+
const id = `package:${entryId}`;
|
|
54923
|
+
this.add(deviceId, "package", id);
|
|
54924
|
+
const timer = setTimeout(() => {
|
|
54925
|
+
this.pulseTimers.delete(timer);
|
|
54926
|
+
this.remove(deviceId, "package", id);
|
|
54927
|
+
}, PACKAGE_PULSE_MS);
|
|
54928
|
+
timer.unref?.();
|
|
54929
|
+
this.pulseTimers.add(timer);
|
|
54930
|
+
}
|
|
54931
|
+
/** Replace a camera's open tracks with what the pipeline says is ACTIVE. */
|
|
54932
|
+
reconcile(deviceId, active) {
|
|
54933
|
+
const truth = /* @__PURE__ */ new Map();
|
|
54934
|
+
for (const track of active) {
|
|
54935
|
+
const cls = detectionClassOf(track.className);
|
|
54936
|
+
if (cls === null) continue;
|
|
54937
|
+
const set = truth.get(cls) ?? /* @__PURE__ */ new Set();
|
|
54938
|
+
set.add(track.trackId);
|
|
54939
|
+
truth.set(cls, set);
|
|
54940
|
+
}
|
|
54941
|
+
for (const cls of DETECTION_SENSOR_CLASSES) {
|
|
54942
|
+
const key = `${deviceId}|${cls}`;
|
|
54943
|
+
const before = this.open.get(key) ?? /* @__PURE__ */ new Set();
|
|
54944
|
+
const after = truth.get(cls) ?? /* @__PURE__ */ new Set();
|
|
54945
|
+
for (const id of before) if (id.startsWith("package:")) after.add(id);
|
|
54946
|
+
this.replace(deviceId, cls, before, after);
|
|
54947
|
+
}
|
|
54948
|
+
}
|
|
54949
|
+
/** Cameras with at least one class present — the ones worth reconciling. */
|
|
54950
|
+
camerasWithPresence() {
|
|
54951
|
+
const out = /* @__PURE__ */ new Set();
|
|
54952
|
+
for (const [key, set] of this.open) if (set.size > 0) out.add(Number(key.split("|")[0]));
|
|
54953
|
+
return [...out];
|
|
54954
|
+
}
|
|
54955
|
+
isPresent(deviceId, cls) {
|
|
54956
|
+
return (this.open.get(`${deviceId}|${cls}`)?.size ?? 0) > 0;
|
|
54957
|
+
}
|
|
54958
|
+
clear() {
|
|
54959
|
+
this.open.clear();
|
|
54960
|
+
for (const t of this.pulseTimers) clearTimeout(t);
|
|
54961
|
+
this.pulseTimers.clear();
|
|
54962
|
+
}
|
|
54963
|
+
add(deviceId, cls, id) {
|
|
54964
|
+
const key = `${deviceId}|${cls}`;
|
|
54965
|
+
const set = this.open.get(key) ?? /* @__PURE__ */ new Set();
|
|
54966
|
+
const was = set.size > 0;
|
|
54967
|
+
set.add(id);
|
|
54968
|
+
this.open.set(key, set);
|
|
54969
|
+
if (!was) this.onTransition(deviceId, cls, true);
|
|
54970
|
+
}
|
|
54971
|
+
remove(deviceId, cls, id) {
|
|
54972
|
+
const set = this.open.get(`${deviceId}|${cls}`);
|
|
54973
|
+
if (set === void 0 || !set.delete(id)) return;
|
|
54974
|
+
if (set.size === 0) this.onTransition(deviceId, cls, false);
|
|
54975
|
+
}
|
|
54976
|
+
replace(deviceId, cls, before, after) {
|
|
54977
|
+
this.open.set(`${deviceId}|${cls}`, after);
|
|
54978
|
+
if (before.size > 0 && after.size === 0) this.onTransition(deviceId, cls, false);
|
|
54979
|
+
else if (before.size === 0 && after.size > 0) this.onTransition(deviceId, cls, true);
|
|
54980
|
+
}
|
|
54981
|
+
};
|
|
54982
|
+
/** The detection classes an event-kind answer offers. */
|
|
54983
|
+
function detectionClassesOfKinds(kinds) {
|
|
54984
|
+
const out = /* @__PURE__ */ new Set();
|
|
54985
|
+
for (const k of kinds) {
|
|
54986
|
+
if (k.level !== "macro") continue;
|
|
54987
|
+
if (k.category !== "detection" && k.category !== "package") continue;
|
|
54988
|
+
const cls = detectionClassOf(k.kind);
|
|
54989
|
+
if (cls !== null) out.add(cls);
|
|
54990
|
+
}
|
|
54991
|
+
return DETECTION_SENSOR_CLASSES.filter((c) => out.has(c));
|
|
54992
|
+
}
|
|
54993
|
+
function sameClasses(a, b) {
|
|
54994
|
+
return JSON.stringify(a ?? null) === JSON.stringify(b);
|
|
54995
|
+
}
|
|
54996
|
+
var DetectionClassSource = class {
|
|
54997
|
+
deps;
|
|
54998
|
+
/** Serializes the persisted map's read-modify-write: builds run four
|
|
54999
|
+
* cameras at once, and two writes computed off one snapshot would erase
|
|
55000
|
+
* each other's camera. */
|
|
55001
|
+
writeChain = Promise.resolve();
|
|
55002
|
+
constructor(deps) {
|
|
55003
|
+
this.deps = deps;
|
|
55004
|
+
}
|
|
55005
|
+
persist(deviceId, next) {
|
|
55006
|
+
const write = this.writeChain.then(() => this.deps.setStored({
|
|
55007
|
+
...this.deps.getStored(),
|
|
55008
|
+
[deviceId]: next
|
|
55009
|
+
}));
|
|
55010
|
+
this.writeChain = write.catch(() => void 0);
|
|
55011
|
+
return write;
|
|
55012
|
+
}
|
|
55013
|
+
async classesFor(deviceId) {
|
|
55014
|
+
const prev = this.deps.getStored()[deviceId];
|
|
55015
|
+
let read;
|
|
55016
|
+
try {
|
|
55017
|
+
read = detectionClassesOfKinds(await this.deps.readEventKinds(deviceId));
|
|
55018
|
+
} catch (err) {
|
|
55019
|
+
this.deps.logger.debug("alexa detection sensors: class read failed — keeping the last answer", {
|
|
55020
|
+
tags: { deviceId },
|
|
55021
|
+
meta: { error: errMsg(err) }
|
|
55022
|
+
});
|
|
55023
|
+
return prev?.classes ?? [];
|
|
55024
|
+
}
|
|
55025
|
+
const now = this.deps.now?.() ?? Date.now();
|
|
55026
|
+
const missingSince = {};
|
|
55027
|
+
const keep = new Set(read);
|
|
55028
|
+
const removed = [];
|
|
55029
|
+
for (const cls of prev?.classes ?? []) {
|
|
55030
|
+
if (keep.has(cls)) continue;
|
|
55031
|
+
const since = prev?.missingSince?.[cls] ?? now;
|
|
55032
|
+
if (now - since >= 9e5) {
|
|
55033
|
+
removed.push(cls);
|
|
55034
|
+
continue;
|
|
55035
|
+
}
|
|
55036
|
+
keep.add(cls);
|
|
55037
|
+
missingSince[cls] = since;
|
|
55038
|
+
}
|
|
55039
|
+
const classes = DETECTION_SENSOR_CLASSES.filter((c) => keep.has(c));
|
|
55040
|
+
const next = Object.keys(missingSince).length > 0 ? {
|
|
55041
|
+
classes,
|
|
55042
|
+
missingSince
|
|
55043
|
+
} : { classes };
|
|
55044
|
+
if (!sameClasses(prev, next)) {
|
|
55045
|
+
await this.persist(deviceId, next);
|
|
55046
|
+
if (removed.length > 0 || classes.some((c) => !(prev?.classes ?? []).includes(c))) this.deps.logger.info("alexa detection sensors: the camera's detection classes changed", {
|
|
55047
|
+
tags: { deviceId },
|
|
55048
|
+
meta: {
|
|
55049
|
+
classes,
|
|
55050
|
+
removed
|
|
55051
|
+
}
|
|
55052
|
+
});
|
|
55053
|
+
}
|
|
55054
|
+
return classes;
|
|
55055
|
+
}
|
|
55056
|
+
};
|
|
55057
|
+
//#endregion
|
|
55058
|
+
//#region src/detection-sensors/detection-feed.ts
|
|
55059
|
+
var DETECTION_RECONCILE_INTERVAL_MS = 6e4;
|
|
55060
|
+
var TrackLifecycleSchema = object({
|
|
55061
|
+
deviceId: number(),
|
|
55062
|
+
trackId: string(),
|
|
55063
|
+
phase: _enum([
|
|
55064
|
+
"start",
|
|
55065
|
+
"update",
|
|
55066
|
+
"end"
|
|
55067
|
+
]),
|
|
55068
|
+
bestClassName: string().optional(),
|
|
55069
|
+
classes: array(string()).optional()
|
|
55070
|
+
});
|
|
55071
|
+
var PackageDeliveredSchema = object({
|
|
55072
|
+
deviceId: number(),
|
|
55073
|
+
entryId: string()
|
|
55074
|
+
});
|
|
55075
|
+
var DetectionSensorFeed = class {
|
|
55076
|
+
deps;
|
|
55077
|
+
tracker;
|
|
55078
|
+
offs = [];
|
|
55079
|
+
timer = null;
|
|
55080
|
+
constructor(deps) {
|
|
55081
|
+
this.deps = deps;
|
|
55082
|
+
this.tracker = new DetectionTracker((deviceId, cls, present) => {
|
|
55083
|
+
this.onTransition(deviceId, cls, present);
|
|
55084
|
+
});
|
|
55085
|
+
}
|
|
55086
|
+
start() {
|
|
55087
|
+
this.offs.push(this.deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
|
|
55088
|
+
const parsed = TrackLifecycleSchema.safeParse(event.data);
|
|
55089
|
+
if (parsed.success) this.tracker.onTrackLifecycle(parsed.data);
|
|
55090
|
+
}), this.deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsPackageDelivered }, (event) => {
|
|
55091
|
+
const parsed = PackageDeliveredSchema.safeParse(event.data);
|
|
55092
|
+
if (parsed.success) this.tracker.onPackageDelivered(parsed.data.deviceId, parsed.data.entryId);
|
|
55093
|
+
}));
|
|
55094
|
+
this.timer = setInterval(() => void this.reconcile(), DETECTION_RECONCILE_INTERVAL_MS);
|
|
55095
|
+
this.timer.unref?.();
|
|
55096
|
+
}
|
|
55097
|
+
stop() {
|
|
55098
|
+
for (const off of this.offs.splice(0)) off();
|
|
55099
|
+
if (this.timer !== null) clearInterval(this.timer);
|
|
55100
|
+
this.timer = null;
|
|
55101
|
+
this.tracker.clear();
|
|
55102
|
+
}
|
|
55103
|
+
isPresent(deviceId, cls) {
|
|
55104
|
+
return this.deps.enabled() && this.tracker.isPresent(deviceId, cls);
|
|
55105
|
+
}
|
|
55106
|
+
/** One reconcile pass over every camera that shows a class present. A read
|
|
55107
|
+
* that fails changes nothing for that camera. */
|
|
55108
|
+
async reconcile() {
|
|
55109
|
+
for (const deviceId of this.tracker.camerasWithPresence()) try {
|
|
55110
|
+
this.tracker.reconcile(deviceId, await this.deps.readActiveTracks(deviceId));
|
|
55111
|
+
} catch (err) {
|
|
55112
|
+
this.deps.logger.debug("alexa detection sensors: active-track read failed — kept as is", {
|
|
55113
|
+
tags: { deviceId },
|
|
55114
|
+
meta: { error: errMsg(err) }
|
|
55115
|
+
});
|
|
55116
|
+
}
|
|
55117
|
+
}
|
|
55118
|
+
onTransition(deviceId, cls, present) {
|
|
55119
|
+
if (!this.deps.enabled() || !this.deps.isExposed(deviceId)) return;
|
|
55120
|
+
this.deps.push(deviceId, cls, present, Date.now()).catch((err) => {
|
|
55121
|
+
this.deps.logger.warn("alexa detection sensors: change report failed", {
|
|
55122
|
+
tags: { deviceId },
|
|
55123
|
+
meta: {
|
|
55124
|
+
detectionClass: cls,
|
|
55125
|
+
present,
|
|
55126
|
+
error: errMsg(err)
|
|
55127
|
+
}
|
|
55128
|
+
});
|
|
55129
|
+
});
|
|
55130
|
+
}
|
|
55131
|
+
};
|
|
55132
|
+
//#endregion
|
|
53048
55133
|
//#region src/oauth-integration.ts
|
|
53049
55134
|
/**
|
|
53050
55135
|
* The `export-alexa` OAuth client — the descriptor behind
|
|
@@ -53142,6 +55227,418 @@ function buildAlexaOauthIntegration(publicHubUrl) {
|
|
|
53142
55227
|
};
|
|
53143
55228
|
}
|
|
53144
55229
|
//#endregion
|
|
55230
|
+
//#region src/setup-block.ts
|
|
55231
|
+
function buildAlexaSetupBlock(input) {
|
|
55232
|
+
const base = input.publicHubUrl.replace(/\/+$/, "");
|
|
55233
|
+
return {
|
|
55234
|
+
fields: [
|
|
55235
|
+
...base ? [{
|
|
55236
|
+
label: "Directive endpoint",
|
|
55237
|
+
value: `${base}/addon/export-alexa/directive`
|
|
55238
|
+
}] : [],
|
|
55239
|
+
...input.ingressToken ? [{
|
|
55240
|
+
label: "Ingress token (Lambda HUB_INGRESS_TOKEN) — use \"Show ingress token\" in settings",
|
|
55241
|
+
value: maskSecretTail(input.ingressToken)
|
|
55242
|
+
}] : [],
|
|
55243
|
+
{
|
|
55244
|
+
label: "Linked Alexa accounts",
|
|
55245
|
+
value: String(input.linkedAccountCount)
|
|
55246
|
+
}
|
|
55247
|
+
],
|
|
55248
|
+
note: base ? "Account linking is handled by the core OAuth server. Use the hub's /api/oauth2/authorize endpoint with integration=export-alexa." : "Set the public hub URL on the settings form first — the directive endpoint URL is derived from it."
|
|
55249
|
+
};
|
|
55250
|
+
}
|
|
55251
|
+
/**
|
|
55252
|
+
* The ONE derivation of "a signed, expiring URL".
|
|
55253
|
+
*
|
|
55254
|
+
* This repo mints unguessable, self-expiring links in three places now — the
|
|
55255
|
+
* notification artifact plane, the Home Assistant media plane, and the snapshot
|
|
55256
|
+
* link plane. The first two grew independently and are byte-identical logic
|
|
55257
|
+
* (`hmac(secret, "<id>:<exp>")`, expiry checked before a constant-time compare),
|
|
55258
|
+
* each restating the crypto locally because **addons never import each other**.
|
|
55259
|
+
*
|
|
55260
|
+
* That reason is real, and the conclusion drawn from it was wrong. Two copies of
|
|
55261
|
+
* a signing scheme is how one of them quietly ends up with a different TTL, a
|
|
55262
|
+
* different compare, or a missing expiry check, and nothing fails until a link
|
|
55263
|
+
* that should have died keeps working. The fix is the same one D52 applies to
|
|
55264
|
+
* crop geometry: one derivation, in a place every addon may depend on. A
|
|
55265
|
+
* framework package is exactly that place — `@camstack/types/node`, off the root
|
|
55266
|
+
* entry because `node:crypto` must never be traversed by a browser bundler.
|
|
55267
|
+
*
|
|
55268
|
+
* What this module deliberately does NOT decide: the TTL, the base URL, the
|
|
55269
|
+
* shape of `id`, and the access level of the route. Those are per-plane policy
|
|
55270
|
+
* and each caller states them where a reader can see them.
|
|
55271
|
+
*/
|
|
55272
|
+
/**
|
|
55273
|
+
* The signature over `(id, expMs)`.
|
|
55274
|
+
*
|
|
55275
|
+
* `id` is whatever the plane uses to name the thing being served — an artifact
|
|
55276
|
+
* id, a track id, a `<deviceId>:<width>` pair. It is joined with `:` so a caller
|
|
55277
|
+
* must not put a `:` inside a field whose boundary matters; where a plane has
|
|
55278
|
+
* more than one field, it composes them itself and owns that ambiguity.
|
|
55279
|
+
*/
|
|
55280
|
+
function signExpiringUrl(secret, id, expMs) {
|
|
55281
|
+
return (0, node_crypto.createHmac)("sha256", secret).update(`${id}:${String(expMs)}`).digest("hex");
|
|
55282
|
+
}
|
|
55283
|
+
/**
|
|
55284
|
+
* Verify a request's `(id, exp, sig)`.
|
|
55285
|
+
*
|
|
55286
|
+
* **Expiry is checked BEFORE the compare**, so an expired link is refused
|
|
55287
|
+
* whether or not its signature is valid — a leaked URL stops working on its own
|
|
55288
|
+
* and cannot be kept alive by holding a correct signature. The compare itself is
|
|
55289
|
+
* constant-time so a public route cannot be probed for the signature byte by
|
|
55290
|
+
* byte.
|
|
55291
|
+
*/
|
|
55292
|
+
function verifyExpiringUrl(input) {
|
|
55293
|
+
const { secret, id, exp, sig, nowMs } = input;
|
|
55294
|
+
if (exp === void 0 || sig === void 0) return false;
|
|
55295
|
+
const expMs = typeof exp === "number" ? exp : Number(exp);
|
|
55296
|
+
if (!Number.isFinite(expMs)) return false;
|
|
55297
|
+
if (expMs <= nowMs) return false;
|
|
55298
|
+
const expected = signExpiringUrl(secret, id, expMs);
|
|
55299
|
+
const a = Buffer.from(expected, "utf8");
|
|
55300
|
+
const b = Buffer.from(sig, "utf8");
|
|
55301
|
+
if (a.length !== b.length) return false;
|
|
55302
|
+
return (0, node_crypto.timingSafeEqual)(a, b);
|
|
55303
|
+
}
|
|
55304
|
+
//#endregion
|
|
55305
|
+
//#region src/snapshots/snapshot-store.ts
|
|
55306
|
+
/**
|
|
55307
|
+
* The images this hub has handed to Alexa (R2, D773), each behind a signed,
|
|
55308
|
+
* short-lived URL.
|
|
55309
|
+
*
|
|
55310
|
+
* Amazon's rules for a SnapshotProvider image URI (device-apis/
|
|
55311
|
+
* alexa-smartvision-snapshotprovider): HTTPS, NOT public — Alexa presents the
|
|
55312
|
+
* user's access token — valid about ten minutes, HTTP 410 once expired, no
|
|
55313
|
+
* redirects. So every image is minted for ONE linked user and ONE device, kept
|
|
55314
|
+
* in memory until it expires, and addressed by a random id whose URL carries an
|
|
55315
|
+
* HMAC over `(imageId, exp)`. A hub restart forgets them all; Alexa then gets a
|
|
55316
|
+
* 410 and asks for a new snapshot, which is the documented recovery.
|
|
55317
|
+
*
|
|
55318
|
+
* Bounded by count and by bytes: Alexa refreshes a tile at most every
|
|
55319
|
+
* `minRefreshIntervalInSeconds` per camera, so a few hundred kilobytes per
|
|
55320
|
+
* camera is the steady state; the bound is what keeps a misbehaving client from
|
|
55321
|
+
* turning this into a memory leak.
|
|
55322
|
+
*/
|
|
55323
|
+
/** How long a minted image URL is valid — Amazon recommends ten minutes. */
|
|
55324
|
+
var SNAPSHOT_URL_TTL_MS = 10 * 6e4;
|
|
55325
|
+
var MAX_IMAGES = 128;
|
|
55326
|
+
var MAX_TOTAL_BYTES = 24 * 1024 * 1024;
|
|
55327
|
+
var SnapshotStore = class {
|
|
55328
|
+
secret;
|
|
55329
|
+
now;
|
|
55330
|
+
images = /* @__PURE__ */ new Map();
|
|
55331
|
+
totalBytes = 0;
|
|
55332
|
+
constructor(secret, now = Date.now) {
|
|
55333
|
+
this.secret = secret;
|
|
55334
|
+
this.now = now;
|
|
55335
|
+
}
|
|
55336
|
+
/** Keep `bytes` for `userId`/`deviceId` and return the signed path to it. */
|
|
55337
|
+
mint(input) {
|
|
55338
|
+
this.prune();
|
|
55339
|
+
const imageId = (0, node_crypto.randomUUID)();
|
|
55340
|
+
const expiresAt = this.now() + SNAPSHOT_URL_TTL_MS;
|
|
55341
|
+
const image = {
|
|
55342
|
+
imageId,
|
|
55343
|
+
deviceId: input.deviceId,
|
|
55344
|
+
userId: input.userId,
|
|
55345
|
+
bytes: input.bytes,
|
|
55346
|
+
contentType: "image/jpeg",
|
|
55347
|
+
capturedAt: input.capturedAt,
|
|
55348
|
+
expiresAt
|
|
55349
|
+
};
|
|
55350
|
+
this.images.set(imageId, image);
|
|
55351
|
+
this.totalBytes += image.bytes.length;
|
|
55352
|
+
this.evictOverBounds();
|
|
55353
|
+
return {
|
|
55354
|
+
imageId,
|
|
55355
|
+
expiresAt,
|
|
55356
|
+
path: `/addon/export-alexa/snapshot/${imageId}.jpg?exp=${expiresAt}&sig=${signExpiringUrl(this.secret(), imageId, expiresAt)}`
|
|
55357
|
+
};
|
|
55358
|
+
}
|
|
55359
|
+
/** Resolve a fetch of a minted URL. */
|
|
55360
|
+
lookup(imageId, exp, sig) {
|
|
55361
|
+
const now = this.now();
|
|
55362
|
+
const expMs = Number(exp);
|
|
55363
|
+
if (Number.isFinite(expMs) && expMs <= now) return {
|
|
55364
|
+
kind: "gone",
|
|
55365
|
+
reason: "expired"
|
|
55366
|
+
};
|
|
55367
|
+
if (!verifyExpiringUrl({
|
|
55368
|
+
secret: this.secret(),
|
|
55369
|
+
id: imageId,
|
|
55370
|
+
exp,
|
|
55371
|
+
sig,
|
|
55372
|
+
nowMs: now
|
|
55373
|
+
})) return {
|
|
55374
|
+
kind: "gone",
|
|
55375
|
+
reason: "bad-signature"
|
|
55376
|
+
};
|
|
55377
|
+
const image = this.images.get(imageId);
|
|
55378
|
+
if (image === void 0 || image.expiresAt <= now) return {
|
|
55379
|
+
kind: "gone",
|
|
55380
|
+
reason: "unknown-image"
|
|
55381
|
+
};
|
|
55382
|
+
return {
|
|
55383
|
+
kind: "found",
|
|
55384
|
+
image
|
|
55385
|
+
};
|
|
55386
|
+
}
|
|
55387
|
+
/** Drop everything — the integration switch was turned off. */
|
|
55388
|
+
clear() {
|
|
55389
|
+
this.images.clear();
|
|
55390
|
+
this.totalBytes = 0;
|
|
55391
|
+
}
|
|
55392
|
+
get size() {
|
|
55393
|
+
return this.images.size;
|
|
55394
|
+
}
|
|
55395
|
+
prune() {
|
|
55396
|
+
const now = this.now();
|
|
55397
|
+
for (const [id, image] of this.images) if (image.expiresAt <= now) this.drop(id, image);
|
|
55398
|
+
}
|
|
55399
|
+
evictOverBounds() {
|
|
55400
|
+
for (const [id, image] of this.images) {
|
|
55401
|
+
if (this.images.size <= MAX_IMAGES && this.totalBytes <= MAX_TOTAL_BYTES) return;
|
|
55402
|
+
this.drop(id, image);
|
|
55403
|
+
}
|
|
55404
|
+
}
|
|
55405
|
+
drop(id, image) {
|
|
55406
|
+
this.images.delete(id);
|
|
55407
|
+
this.totalBytes -= image.bytes.length;
|
|
55408
|
+
}
|
|
55409
|
+
};
|
|
55410
|
+
//#endregion
|
|
55411
|
+
//#region src/state-reports/state-change-map.ts
|
|
55412
|
+
/**
|
|
55413
|
+
* What a device's state change means for Alexa (R3, D776).
|
|
55414
|
+
*
|
|
55415
|
+
* Every cap state change on the hub arrives as ONE event —
|
|
55416
|
+
* `DeviceStateChanged {deviceId, capName, slice}` (the device-state mirror;
|
|
55417
|
+
* the per-cap `on…Changed` events the cap files declare are never emitted).
|
|
55418
|
+
* This maps one such change to the Alexa properties it changes, on the
|
|
55419
|
+
* endpoints this addon last published (`exportedEndpoints`): the alarm panel's
|
|
55420
|
+
* `armState` / `burglaryAlarm`, a privacy or autotrack switch's `powerState`,
|
|
55421
|
+
* an accessory's `powerState` / `brightness`, and — for `device-status` — the
|
|
55422
|
+
* device's connectivity.
|
|
55423
|
+
*
|
|
55424
|
+
* Pure: no I/O, so the mapping is pinned by a spec without an event bus.
|
|
55425
|
+
*/
|
|
55426
|
+
/** Every published endpoint, keyed by the device whose state drives it. */
|
|
55427
|
+
function indexByDrivingDevice(exported) {
|
|
55428
|
+
const out = /* @__PURE__ */ new Map();
|
|
55429
|
+
const add = (deviceId, entry) => {
|
|
55430
|
+
const list = out.get(deviceId) ?? [];
|
|
55431
|
+
list.push(entry);
|
|
55432
|
+
out.set(deviceId, list);
|
|
55433
|
+
};
|
|
55434
|
+
for (const [key, ids] of Object.entries(exported)) {
|
|
55435
|
+
const exposedDeviceId = Number(key);
|
|
55436
|
+
for (const endpointId of ids) {
|
|
55437
|
+
const parsed = parseCameraEndpointId(endpointId);
|
|
55438
|
+
switch (parsed.kind) {
|
|
55439
|
+
case "camera":
|
|
55440
|
+
add(parsed.deviceId, {
|
|
55441
|
+
exposedDeviceId,
|
|
55442
|
+
endpointId,
|
|
55443
|
+
kind: "camera"
|
|
55444
|
+
});
|
|
55445
|
+
break;
|
|
55446
|
+
case "privacy":
|
|
55447
|
+
case "autotrack":
|
|
55448
|
+
add(parsed.parentDeviceId, {
|
|
55449
|
+
exposedDeviceId,
|
|
55450
|
+
endpointId,
|
|
55451
|
+
kind: parsed.kind
|
|
55452
|
+
});
|
|
55453
|
+
break;
|
|
55454
|
+
case "child":
|
|
55455
|
+
add(parsed.childDeviceId, {
|
|
55456
|
+
exposedDeviceId,
|
|
55457
|
+
endpointId,
|
|
55458
|
+
kind: "child"
|
|
55459
|
+
});
|
|
55460
|
+
break;
|
|
55461
|
+
case "alarm-panel":
|
|
55462
|
+
add(parsed.deviceId, {
|
|
55463
|
+
exposedDeviceId,
|
|
55464
|
+
endpointId,
|
|
55465
|
+
kind: "alarm-panel"
|
|
55466
|
+
});
|
|
55467
|
+
break;
|
|
55468
|
+
case "detection":
|
|
55469
|
+
add(parsed.deviceId, {
|
|
55470
|
+
exposedDeviceId,
|
|
55471
|
+
endpointId,
|
|
55472
|
+
kind: "detection"
|
|
55473
|
+
});
|
|
55474
|
+
break;
|
|
55475
|
+
case "generic":
|
|
55476
|
+
add(parsed.deviceId, {
|
|
55477
|
+
exposedDeviceId,
|
|
55478
|
+
endpointId,
|
|
55479
|
+
kind: "generic"
|
|
55480
|
+
});
|
|
55481
|
+
break;
|
|
55482
|
+
case "invalid": break;
|
|
55483
|
+
}
|
|
55484
|
+
}
|
|
55485
|
+
}
|
|
55486
|
+
return out;
|
|
55487
|
+
}
|
|
55488
|
+
var EnabledSchema = object({ enabled: boolean() });
|
|
55489
|
+
var OnSchema = object({ on: boolean() });
|
|
55490
|
+
var PercentageSchema = object({ percentage: number() });
|
|
55491
|
+
var OnlineSchema = object({ online: boolean() });
|
|
55492
|
+
function power(on) {
|
|
55493
|
+
return {
|
|
55494
|
+
namespace: "Alexa.PowerController",
|
|
55495
|
+
name: "powerState",
|
|
55496
|
+
value: on ? "ON" : "OFF"
|
|
55497
|
+
};
|
|
55498
|
+
}
|
|
55499
|
+
function stateReportsFor(change, exported) {
|
|
55500
|
+
if (change.capName === "device-status") {
|
|
55501
|
+
const parsed = OnlineSchema.safeParse(change.slice);
|
|
55502
|
+
return parsed.success ? {
|
|
55503
|
+
kind: "connectivity",
|
|
55504
|
+
deviceId: change.deviceId,
|
|
55505
|
+
online: parsed.data.online
|
|
55506
|
+
} : null;
|
|
55507
|
+
}
|
|
55508
|
+
const endpoints = indexByDrivingDevice(exported).get(change.deviceId) ?? [];
|
|
55509
|
+
const reports = [];
|
|
55510
|
+
const of = (kind) => endpoints.filter((e) => e.kind === kind).map((e) => ({
|
|
55511
|
+
exposedDeviceId: e.exposedDeviceId,
|
|
55512
|
+
endpointId: e.endpointId
|
|
55513
|
+
}));
|
|
55514
|
+
const generic = genericPropertyFor(change.capName, change.slice);
|
|
55515
|
+
if (generic !== null) for (const e of of("generic")) reports.push({
|
|
55516
|
+
...e,
|
|
55517
|
+
interfaceName: generic.namespace,
|
|
55518
|
+
properties: [generic]
|
|
55519
|
+
});
|
|
55520
|
+
switch (change.capName) {
|
|
55521
|
+
case "alarm-panel": {
|
|
55522
|
+
const parsed = AlarmPanelStatusSchema.pick({
|
|
55523
|
+
state: true,
|
|
55524
|
+
targetMode: true
|
|
55525
|
+
}).safeParse(change.slice);
|
|
55526
|
+
if (!parsed.success) return null;
|
|
55527
|
+
for (const e of of("alarm-panel")) reports.push({
|
|
55528
|
+
...e,
|
|
55529
|
+
interfaceName: "Alexa.SecurityPanelController",
|
|
55530
|
+
properties: [{
|
|
55531
|
+
namespace: "Alexa.SecurityPanelController",
|
|
55532
|
+
name: "armState",
|
|
55533
|
+
value: alexaArmStateFor(parsed.data.state, parsed.data.targetMode ?? null)
|
|
55534
|
+
}, {
|
|
55535
|
+
namespace: "Alexa.SecurityPanelController",
|
|
55536
|
+
name: "burglaryAlarm",
|
|
55537
|
+
value: burglaryAlarmFor(parsed.data.state)
|
|
55538
|
+
}]
|
|
55539
|
+
});
|
|
55540
|
+
break;
|
|
55541
|
+
}
|
|
55542
|
+
case "privacy-mask":
|
|
55543
|
+
case "ptz-autotrack": {
|
|
55544
|
+
const parsed = EnabledSchema.safeParse(change.slice);
|
|
55545
|
+
if (!parsed.success) return null;
|
|
55546
|
+
const kind = change.capName === "privacy-mask" ? "privacy" : "autotrack";
|
|
55547
|
+
for (const e of of(kind)) reports.push({
|
|
55548
|
+
...e,
|
|
55549
|
+
interfaceName: "Alexa.PowerController",
|
|
55550
|
+
properties: [power(parsed.data.enabled)]
|
|
55551
|
+
});
|
|
55552
|
+
break;
|
|
55553
|
+
}
|
|
55554
|
+
case "switch": {
|
|
55555
|
+
const parsed = OnSchema.safeParse(change.slice);
|
|
55556
|
+
if (!parsed.success) break;
|
|
55557
|
+
for (const e of of("child")) reports.push({
|
|
55558
|
+
...e,
|
|
55559
|
+
interfaceName: "Alexa.PowerController",
|
|
55560
|
+
properties: [power(parsed.data.on)]
|
|
55561
|
+
});
|
|
55562
|
+
break;
|
|
55563
|
+
}
|
|
55564
|
+
case "brightness": {
|
|
55565
|
+
const parsed = PercentageSchema.safeParse(change.slice);
|
|
55566
|
+
if (!parsed.success) break;
|
|
55567
|
+
const value = Math.max(0, Math.min(100, Math.round(parsed.data.percentage)));
|
|
55568
|
+
for (const e of of("child")) reports.push({
|
|
55569
|
+
...e,
|
|
55570
|
+
interfaceName: "Alexa.BrightnessController",
|
|
55571
|
+
properties: [{
|
|
55572
|
+
namespace: "Alexa.BrightnessController",
|
|
55573
|
+
name: "brightness",
|
|
55574
|
+
value
|
|
55575
|
+
}]
|
|
55576
|
+
});
|
|
55577
|
+
break;
|
|
55578
|
+
}
|
|
55579
|
+
default: break;
|
|
55580
|
+
}
|
|
55581
|
+
return reports.length > 0 ? {
|
|
55582
|
+
kind: "reports",
|
|
55583
|
+
reports
|
|
55584
|
+
} : null;
|
|
55585
|
+
}
|
|
55586
|
+
//#endregion
|
|
55587
|
+
//#region src/state-reports/state-change-reporter.ts
|
|
55588
|
+
var DeviceStateChangedSchema = object({
|
|
55589
|
+
deviceId: number(),
|
|
55590
|
+
capName: string(),
|
|
55591
|
+
slice: record(string(), unknown())
|
|
55592
|
+
});
|
|
55593
|
+
var StateChangeReporter = class {
|
|
55594
|
+
deps;
|
|
55595
|
+
off = null;
|
|
55596
|
+
lastReported = /* @__PURE__ */ new Map();
|
|
55597
|
+
constructor(deps) {
|
|
55598
|
+
this.deps = deps;
|
|
55599
|
+
}
|
|
55600
|
+
start() {
|
|
55601
|
+
this.off = this.deps.eventBus.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
|
|
55602
|
+
const parsed = DeviceStateChangedSchema.safeParse(event.data);
|
|
55603
|
+
if (parsed.success) this.onChange(parsed.data);
|
|
55604
|
+
});
|
|
55605
|
+
}
|
|
55606
|
+
stop() {
|
|
55607
|
+
this.off?.();
|
|
55608
|
+
this.off = null;
|
|
55609
|
+
this.lastReported.clear();
|
|
55610
|
+
}
|
|
55611
|
+
async onChange(change) {
|
|
55612
|
+
const outcome = stateReportsFor(change, this.deps.getExportedEndpoints());
|
|
55613
|
+
if (outcome === null) return;
|
|
55614
|
+
try {
|
|
55615
|
+
if (outcome.kind === "connectivity") {
|
|
55616
|
+
await this.deps.reportConnectivity(outcome.deviceId, outcome.online ? "OK" : "UNREACHABLE");
|
|
55617
|
+
return;
|
|
55618
|
+
}
|
|
55619
|
+
for (const report of outcome.reports) {
|
|
55620
|
+
const key = `${report.endpointId}|${report.interfaceName}`;
|
|
55621
|
+
const value = JSON.stringify(report.properties);
|
|
55622
|
+
if (this.lastReported.get(key) === value) continue;
|
|
55623
|
+
this.lastReported.set(key, value);
|
|
55624
|
+
if (report.interfaceName === "Alexa.MotionSensor") {
|
|
55625
|
+
await this.deps.postHeldMotion(report, report.properties[0]?.value === "DETECTED");
|
|
55626
|
+
continue;
|
|
55627
|
+
}
|
|
55628
|
+
await this.deps.postStateChange(report);
|
|
55629
|
+
}
|
|
55630
|
+
} catch (err) {
|
|
55631
|
+
this.deps.logger.warn("alexa state reports: posting a state change failed", {
|
|
55632
|
+
tags: { deviceId: change.deviceId },
|
|
55633
|
+
meta: {
|
|
55634
|
+
capName: change.capName,
|
|
55635
|
+
error: errMsg(err)
|
|
55636
|
+
}
|
|
55637
|
+
});
|
|
55638
|
+
}
|
|
55639
|
+
}
|
|
55640
|
+
};
|
|
55641
|
+
//#endregion
|
|
53145
55642
|
//#region src/public-skill.ts
|
|
53146
55643
|
/**
|
|
53147
55644
|
* The built-in "public" CamStack Alexa skill.
|
|
@@ -53160,28 +55657,6 @@ function buildAlexaOauthIntegration(publicHubUrl) {
|
|
|
53160
55657
|
*/
|
|
53161
55658
|
var PUBLIC_SKILL_ID = "amzn1.ask.skill.aa85d6df-1600-4f13-bda9-babcfdc849e6";
|
|
53162
55659
|
//#endregion
|
|
53163
|
-
//#region src/endpoint-signature.ts
|
|
53164
|
-
/** One capability's identity as Alexa distinguishes it. */
|
|
53165
|
-
function capabilityKey(capability) {
|
|
53166
|
-
const instance = typeof capability.instance === "string" ? capability.instance : "";
|
|
53167
|
-
return instance.length > 0 ? `${capability.interface}#${instance}` : capability.interface;
|
|
53168
|
-
}
|
|
53169
|
-
/** Stable, order-independent signature of ONE endpoint's advertised shape. */
|
|
53170
|
-
function endpointKey(endpoint) {
|
|
53171
|
-
const interfaces = endpoint.capabilities.map(capabilityKey).sort().join(",");
|
|
53172
|
-
const categories = [...endpoint.displayCategories].sort().join(",");
|
|
53173
|
-
return `${endpoint.endpointId}|${categories}|${interfaces}`;
|
|
53174
|
-
}
|
|
53175
|
-
/**
|
|
53176
|
-
* Stable signature of the endpoint set built for ONE device. Two builds that
|
|
53177
|
-
* would land identically in Alexa's registry produce the same string; any
|
|
53178
|
-
* added, removed or renamed interface — or a changed display category —
|
|
53179
|
-
* produces a different one.
|
|
53180
|
-
*/
|
|
53181
|
-
function endpointsSignature(endpoints) {
|
|
53182
|
-
return endpoints.map(endpointKey).sort().join(";");
|
|
53183
|
-
}
|
|
53184
|
-
//#endregion
|
|
53185
55660
|
//#region src/sync-engine.ts
|
|
53186
55661
|
/**
|
|
53187
55662
|
* Devices reconciled at once in {@link SyncEngine.reconcileAllExposed}. Each
|
|
@@ -53229,8 +55704,18 @@ var SyncEngine = class {
|
|
|
53229
55704
|
};
|
|
53230
55705
|
}
|
|
53231
55706
|
const endpointIds = endpoints.map((e) => e.endpointId);
|
|
55707
|
+
const retired = (this.deps.getExportedEndpoints()[deviceId] ?? []).filter((id) => !endpointIds.includes(id) && this.deps.mayRetire(id));
|
|
53232
55708
|
const failures = [];
|
|
53233
|
-
for (const account of accounts)
|
|
55709
|
+
for (const account of accounts) {
|
|
55710
|
+
if (!await this.postFiltered(account.userId, deviceId, endpoints)) failures.push(`${account.userId}:${deviceId}`);
|
|
55711
|
+
if (retired.length > 0) {
|
|
55712
|
+
if (!await this.deps.postDelete(account.userId, retired)) failures.push(`${account.userId}:${deviceId}:delete`);
|
|
55713
|
+
}
|
|
55714
|
+
}
|
|
55715
|
+
if (retired.length > 0) this.deps.logger.info("alexa sync: retired endpoints the device no longer builds", {
|
|
55716
|
+
tags: { deviceId },
|
|
55717
|
+
meta: { retired }
|
|
55718
|
+
});
|
|
53234
55719
|
await this.recordEndpoints(deviceId, endpointIds, endpointsSignature(endpoints));
|
|
53235
55720
|
return {
|
|
53236
55721
|
deviceCount: 1,
|
|
@@ -53276,7 +55761,7 @@ var SyncEngine = class {
|
|
|
53276
55761
|
for (const { deviceId } of this.deps.listExposed()) {
|
|
53277
55762
|
const endpoints = await this.deps.buildEndpoints(deviceId);
|
|
53278
55763
|
if (endpoints.length === 0) continue;
|
|
53279
|
-
if (!await this.
|
|
55764
|
+
if (!await this.postFiltered(userId, deviceId, endpoints)) failures.push(`${userId}:${deviceId}`);
|
|
53280
55765
|
await this.recordEndpoints(deviceId, endpoints.map((e) => e.endpointId), endpointsSignature(endpoints));
|
|
53281
55766
|
deviceCount += 1;
|
|
53282
55767
|
endpointCount += endpoints.length;
|
|
@@ -53288,6 +55773,49 @@ var SyncEngine = class {
|
|
|
53288
55773
|
failures
|
|
53289
55774
|
};
|
|
53290
55775
|
}
|
|
55776
|
+
/**
|
|
55777
|
+
* Post `endpoints` to one account, cut to what that account's user may see.
|
|
55778
|
+
* Nothing visible is a success with nothing to send: an empty
|
|
55779
|
+
* AddOrUpdateReport is an Alexa protocol error.
|
|
55780
|
+
*/
|
|
55781
|
+
async postFiltered(userId, deviceId, endpoints) {
|
|
55782
|
+
const filtered = await this.deps.filterForAccount(userId, endpoints);
|
|
55783
|
+
if (filtered === null) {
|
|
55784
|
+
this.deps.logger.warn("alexa sync: the linked user's grants could not be read — this account was NOT sent the device", {
|
|
55785
|
+
tags: { deviceId },
|
|
55786
|
+
meta: { userId }
|
|
55787
|
+
});
|
|
55788
|
+
return false;
|
|
55789
|
+
}
|
|
55790
|
+
const visible = withholdInvalidEndpoints(filtered, (message, violations) => {
|
|
55791
|
+
this.deps.logger.warn(message, {
|
|
55792
|
+
tags: { deviceId },
|
|
55793
|
+
meta: {
|
|
55794
|
+
userId,
|
|
55795
|
+
violations
|
|
55796
|
+
}
|
|
55797
|
+
});
|
|
55798
|
+
});
|
|
55799
|
+
if (visible.length === 0) return true;
|
|
55800
|
+
const ok = await this.deps.postAddOrUpdate(userId, visible);
|
|
55801
|
+
if (ok) this.deps.afterPush(userId, visible);
|
|
55802
|
+
return ok;
|
|
55803
|
+
}
|
|
55804
|
+
/** A device the account's user just gained (visibility mirror). */
|
|
55805
|
+
async pushDeviceToAccount(userId, deviceId) {
|
|
55806
|
+
if (!this.deps.hasProactiveCredentials()) return false;
|
|
55807
|
+
if (!this.deps.listExposed().some((e) => e.deviceId === deviceId)) return true;
|
|
55808
|
+
const endpoints = await this.deps.buildEndpoints(deviceId);
|
|
55809
|
+
if (endpoints.length === 0) return false;
|
|
55810
|
+
return this.postFiltered(userId, deviceId, endpoints);
|
|
55811
|
+
}
|
|
55812
|
+
/** A device the account's user lost (confirmed by two reads): delete every
|
|
55813
|
+
* endpoint we ever published for it from THAT account only. */
|
|
55814
|
+
async removeDeviceFromAccount(userId, deviceId) {
|
|
55815
|
+
if (!this.deps.hasProactiveCredentials()) return false;
|
|
55816
|
+
const ids = this.deps.getExportedEndpoints()[deviceId] ?? [String(deviceId)];
|
|
55817
|
+
return this.deps.postDelete(userId, ids);
|
|
55818
|
+
}
|
|
53291
55819
|
async removeDevice(deviceId) {
|
|
53292
55820
|
const ids = this.deps.getExportedEndpoints()[deviceId] ?? [];
|
|
53293
55821
|
if (ids.length === 0) return {
|
|
@@ -53446,6 +55974,17 @@ var SyncEngine = class {
|
|
|
53446
55974
|
* provider respawn emits one `DeviceBindingsChanged` per cap; reconciling in
|
|
53447
55975
|
* the middle of that burst would read a half-registered binding set. */
|
|
53448
55976
|
var BINDINGS_RECONCILE_DEBOUNCE_MS = 5e3;
|
|
55977
|
+
/** How often each linked account's visibility is recomputed from the live
|
|
55978
|
+
* user grants (S1). Also the bound on how long an event keeps reaching a user
|
|
55979
|
+
* whose grant was just revoked. */
|
|
55980
|
+
var VISIBILITY_REFRESH_INTERVAL_MS = 6e4;
|
|
55981
|
+
/** First visibility pass after boot — soon, because until an account has an
|
|
55982
|
+
* entry its events are refused (fail closed). */
|
|
55983
|
+
var BOOT_VISIBILITY_DELAY_MS = 5e3;
|
|
55984
|
+
/** How often every exposed camera's endpoint shape is re-checked while the
|
|
55985
|
+
* detection sensors are on — a camera whose pipeline gained or (confirmed)
|
|
55986
|
+
* lost a class gets its sensors added or removed. */
|
|
55987
|
+
var DETECTION_CLASS_REFRESH_MS = 15 * 6e4;
|
|
53449
55988
|
/** Delay before the boot-time reconcile pass. Long enough for device providers
|
|
53450
55989
|
* to have registered their caps — a device whose bindings have not landed
|
|
53451
55990
|
* builds a shape we must never push to Alexa. */
|
|
@@ -53484,18 +56023,47 @@ var DEFAULT_CONFIG = {
|
|
|
53484
56023
|
availableEndpoints: [],
|
|
53485
56024
|
ingressToken: "",
|
|
53486
56025
|
enforceIngressToken: false,
|
|
53487
|
-
staleEndpointPurgeInput: ""
|
|
56026
|
+
staleEndpointPurgeInput: "",
|
|
56027
|
+
accountVisibility: {},
|
|
56028
|
+
detectionSensors: true,
|
|
56029
|
+
detectionClasses: {},
|
|
56030
|
+
snapshots: true,
|
|
56031
|
+
snapshotSigningKey: ""
|
|
53488
56032
|
};
|
|
56033
|
+
/** The one field of `oauthVerifyAccessToken`'s answer the snapshot route
|
|
56034
|
+
* reads. */
|
|
56035
|
+
var VerifiedTokenSchema = object({ userId: string() }).nullable();
|
|
53489
56036
|
/** UI filter for the Export picker. `alarm-panel` publishes an
|
|
53490
56037
|
* `Alexa.SecurityPanelController` endpoint (`endpoints/builder.ts`). */
|
|
53491
56038
|
var SUPPORTED_KINDS = [
|
|
53492
56039
|
"camera",
|
|
53493
56040
|
"doorbell",
|
|
53494
|
-
DeviceType.AlarmPanel
|
|
56041
|
+
DeviceType.AlarmPanel,
|
|
56042
|
+
DeviceType.Light,
|
|
56043
|
+
DeviceType.Switch,
|
|
56044
|
+
DeviceType.Siren,
|
|
56045
|
+
DeviceType.Sensor,
|
|
56046
|
+
DeviceType.Lock,
|
|
56047
|
+
DeviceType.Presence,
|
|
56048
|
+
DeviceType.Thermostat,
|
|
56049
|
+
DeviceType.Generic
|
|
53495
56050
|
];
|
|
53496
56051
|
var ExportAlexaAddon = class extends BaseAddon {
|
|
53497
56052
|
lwaGateway = null;
|
|
53498
56053
|
syncEngine = null;
|
|
56054
|
+
/** S1 (D767): the linking user's live grants. Built in `onInitialize`, the
|
|
56055
|
+
* first point `ctx` exists. */
|
|
56056
|
+
accessGateRef = null;
|
|
56057
|
+
visibilityRef = null;
|
|
56058
|
+
detectionClassSource = null;
|
|
56059
|
+
/** The images handed to Alexa, behind signed URLs (R2). In memory only: a
|
|
56060
|
+
* restart answers 410 and Alexa asks again. */
|
|
56061
|
+
snapshotStore = new SnapshotStore(() => this.config.snapshotSigningKey);
|
|
56062
|
+
detectionFeed = null;
|
|
56063
|
+
stateReporter = null;
|
|
56064
|
+
/** The switches whose change re-shapes every exposed endpoint, as last
|
|
56065
|
+
* applied — `onConfigChanged` runs on EVERY settings write. */
|
|
56066
|
+
appliedShapeSwitches = null;
|
|
53499
56067
|
/** Per-device debounce timers for the bindings-change discovery reconcile. */
|
|
53500
56068
|
pendingReconcileTimers = /* @__PURE__ */ new Map();
|
|
53501
56069
|
constructor() {
|
|
@@ -53503,6 +56071,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53503
56071
|
}
|
|
53504
56072
|
async onInitialize() {
|
|
53505
56073
|
if (!this.config.ingressToken) await this.updateGlobalSettings({ ingressToken: generateIngressToken() });
|
|
56074
|
+
if (!this.config.snapshotSigningKey) await this.updateGlobalSettings({ snapshotSigningKey: generateIngressToken() });
|
|
53506
56075
|
if (this.config.lwaClientSecret) {
|
|
53507
56076
|
await this.updateGlobalSettings({ lwaClientSecret: "" });
|
|
53508
56077
|
this.ctx.logger.warn("export-alexa: removed a stored LWA client secret — the hub no longer uses one; your skill Lambda holds it (D753)");
|
|
@@ -53511,6 +56080,21 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53511
56080
|
this.autoDetectPublicHubUrl().catch((err) => {
|
|
53512
56081
|
this.ctx.logger.warn("export-alexa: public hub URL auto-detect failed", { meta: { error: errMsg(err) } });
|
|
53513
56082
|
});
|
|
56083
|
+
this.accessGateRef = createHubAccessGate(this.ctx);
|
|
56084
|
+
this.detectionClassSource = new DetectionClassSource({
|
|
56085
|
+
readEventKinds: (deviceId) => this.ctx.api.pipelineAnalytics.listEventKinds.query({ deviceId }),
|
|
56086
|
+
getStored: () => this.config.detectionClasses,
|
|
56087
|
+
setStored: (next) => this.updateGlobalSettings({ detectionClasses: next }),
|
|
56088
|
+
logger: this.ctx.logger
|
|
56089
|
+
});
|
|
56090
|
+
this.visibilityRef = new AccountVisibilityMirror({
|
|
56091
|
+
gate: this.accessGateRef,
|
|
56092
|
+
listLinkedUserIds: () => this.config.linkedAccounts.map((a) => a.userId),
|
|
56093
|
+
listExposedDeviceIds: () => this.config.exposed.map((e) => e.deviceId),
|
|
56094
|
+
getStored: () => this.config.accountVisibility,
|
|
56095
|
+
setStored: (next) => this.updateGlobalSettings({ accountVisibility: next }),
|
|
56096
|
+
logger: this.ctx.logger
|
|
56097
|
+
});
|
|
53514
56098
|
const deviceExportProvider = {
|
|
53515
56099
|
getStatus: async () => ({
|
|
53516
56100
|
linkState: this.config.linkedAccounts.length > 0 ? "linked" : "unlinked",
|
|
@@ -53533,7 +56117,8 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53533
56117
|
discovery: {
|
|
53534
56118
|
ctx: this.ctx,
|
|
53535
56119
|
logger: this.ctx.logger,
|
|
53536
|
-
listExposed: async () => this.config.exposed
|
|
56120
|
+
listExposed: async () => this.config.exposed,
|
|
56121
|
+
buildEndpoints: (deviceId) => this.buildEndpointsForOneDevice(deviceId)
|
|
53537
56122
|
},
|
|
53538
56123
|
cameraStream: {
|
|
53539
56124
|
ctx: this.ctx,
|
|
@@ -53564,12 +56149,26 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53564
56149
|
},
|
|
53565
56150
|
reportState: {
|
|
53566
56151
|
ctx: this.ctx,
|
|
53567
|
-
logger: this.ctx.logger
|
|
56152
|
+
logger: this.ctx.logger,
|
|
56153
|
+
detectionPresent: (deviceId, cls) => this.detectionFeed?.isPresent(deviceId, cls) ?? false
|
|
53568
56154
|
},
|
|
53569
56155
|
securityPanel: {
|
|
53570
56156
|
ctx: this.ctx,
|
|
53571
56157
|
logger: this.ctx.logger
|
|
53572
|
-
}
|
|
56158
|
+
},
|
|
56159
|
+
lockController: {
|
|
56160
|
+
ctx: this.ctx,
|
|
56161
|
+
logger: this.ctx.logger
|
|
56162
|
+
},
|
|
56163
|
+
snapshotProvider: {
|
|
56164
|
+
ctx: this.ctx,
|
|
56165
|
+
logger: this.ctx.logger,
|
|
56166
|
+
store: this.snapshotStore,
|
|
56167
|
+
enabled: () => this.config.snapshots,
|
|
56168
|
+
publicHubUrl: () => this.config.publicHubUrl
|
|
56169
|
+
},
|
|
56170
|
+
access: this.endpointAccess,
|
|
56171
|
+
afterDiscovery: (userId, endpoints) => this.requireGateway().scheduleInitialConnectivity(userId, endpoints)
|
|
53573
56172
|
},
|
|
53574
56173
|
lwaTokensCallbackDeps: {
|
|
53575
56174
|
ctx: this.ctx,
|
|
@@ -53578,7 +56177,18 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53578
56177
|
recordLinkedAccount: (input) => this.recordLinkedAccount(input)
|
|
53579
56178
|
},
|
|
53580
56179
|
redetectPublicHubUrl: () => this.redetectPublicHubUrl(),
|
|
53581
|
-
onBrokerAdvertised: (raw) => this.rememberAdvertisedBroker(raw)
|
|
56180
|
+
onBrokerAdvertised: (raw) => this.rememberAdvertisedBroker(raw),
|
|
56181
|
+
snapshotRoute: {
|
|
56182
|
+
store: this.snapshotStore,
|
|
56183
|
+
enabled: () => this.config.snapshots,
|
|
56184
|
+
verifyToken: (token) => this.verifyAccountLinkToken(token),
|
|
56185
|
+
mayView: async (userId, deviceId) => {
|
|
56186
|
+
const lookup = await this.accessGate.principal(userId);
|
|
56187
|
+
if (lookup.kind !== "found") return false;
|
|
56188
|
+
return await this.accessGate.allows(lookup.principal, deviceId, "view") === "allowed";
|
|
56189
|
+
},
|
|
56190
|
+
logger: this.ctx.logger
|
|
56191
|
+
}
|
|
53582
56192
|
}));
|
|
53583
56193
|
const oauthIntegrationProvider = { getDescriptor: async () => buildAlexaOauthIntegration(this.config.publicHubUrl) };
|
|
53584
56194
|
this.lwaGateway = new LwaEventGateway({
|
|
@@ -53592,15 +56202,54 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53592
56202
|
getAdvertisedBrokerUrl: () => this.config.advertisedBrokerUrl || null,
|
|
53593
56203
|
getPublicHubUrl: () => this.config.publicHubUrl,
|
|
53594
56204
|
getRegion: () => this.config.region,
|
|
53595
|
-
getExportedSignature: (deviceId) => this.config.exportedSignatures[deviceId] ?? null
|
|
56205
|
+
getExportedSignature: (deviceId) => this.config.exportedSignatures[deviceId] ?? null,
|
|
56206
|
+
accountMaySee: (userId, deviceId) => this.visibility.allows(userId, deviceId)
|
|
53596
56207
|
});
|
|
53597
56208
|
this.lwaGateway.start();
|
|
56209
|
+
const gatewayForFeed = this.lwaGateway;
|
|
56210
|
+
this.detectionFeed = new DetectionSensorFeed({
|
|
56211
|
+
eventBus: this.ctx.eventBus,
|
|
56212
|
+
enabled: () => this.config.detectionSensors,
|
|
56213
|
+
isExposed: (deviceId) => this.config.exposed.some((e) => e.deviceId === deviceId),
|
|
56214
|
+
readActiveTracks: async (deviceId) => {
|
|
56215
|
+
return (await this.ctx.api.pipelineAnalytics.getActiveTracks.query({ deviceId })).map((t) => ({
|
|
56216
|
+
trackId: t.trackId,
|
|
56217
|
+
className: t.className
|
|
56218
|
+
}));
|
|
56219
|
+
},
|
|
56220
|
+
push: (deviceId, cls, detected, timestamp) => gatewayForFeed.pushDetectionSensor({
|
|
56221
|
+
deviceId,
|
|
56222
|
+
endpointId: detectionSensorEndpointId(deviceId, cls),
|
|
56223
|
+
detected,
|
|
56224
|
+
timestamp
|
|
56225
|
+
}),
|
|
56226
|
+
logger: this.ctx.logger
|
|
56227
|
+
});
|
|
56228
|
+
this.detectionFeed.start();
|
|
56229
|
+
this.appliedShapeSwitches = this.shapeSwitches();
|
|
56230
|
+
this.stateReporter = new StateChangeReporter({
|
|
56231
|
+
eventBus: this.ctx.eventBus,
|
|
56232
|
+
getExportedEndpoints: () => this.config.exportedEndpoints,
|
|
56233
|
+
reportConnectivity: (deviceId, connectivity) => gatewayForFeed.reportConnectivity(deviceId, connectivity),
|
|
56234
|
+
postStateChange: (report) => gatewayForFeed.postStateChange(report),
|
|
56235
|
+
postHeldMotion: (report, detected) => gatewayForFeed.pushDetectionSensor({
|
|
56236
|
+
deviceId: report.exposedDeviceId,
|
|
56237
|
+
endpointId: report.endpointId,
|
|
56238
|
+
detected,
|
|
56239
|
+
timestamp: Date.now()
|
|
56240
|
+
}),
|
|
56241
|
+
logger: this.ctx.logger
|
|
56242
|
+
});
|
|
56243
|
+
this.stateReporter.start();
|
|
53598
56244
|
this.syncEngine = new SyncEngine({
|
|
53599
56245
|
logger: this.ctx.logger,
|
|
53600
56246
|
listExposed: () => this.config.exposed,
|
|
53601
56247
|
listLinkedAccounts: () => this.config.linkedAccounts,
|
|
53602
56248
|
hasProactiveCredentials: () => this.config.tokens.length > 0,
|
|
53603
56249
|
buildEndpoints: (deviceId) => this.buildEndpointsForOneDevice(deviceId),
|
|
56250
|
+
filterForAccount: (userId, endpoints) => this.filterForAccount(userId, endpoints),
|
|
56251
|
+
afterPush: (userId, endpoints) => this.requireGateway().scheduleInitialConnectivity(userId, endpoints),
|
|
56252
|
+
mayRetire: (endpointId) => parseCameraEndpointId(endpointId).kind === "detection",
|
|
53604
56253
|
postAddOrUpdate: (userId, endpoints) => this.requireGateway().postReport(userId, (accessToken) => buildAddOrUpdateReportPayload(userId, endpoints, accessToken), `add/update (${endpoints.length})`),
|
|
53605
56254
|
postDelete: (userId, ids) => this.requireGateway().postReport(userId, (accessToken) => buildDeleteReportPayload(userId, ids, accessToken), `delete (${ids.length})`),
|
|
53606
56255
|
getExportedEndpoints: () => this.config.exportedEndpoints,
|
|
@@ -53610,6 +56259,8 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53610
56259
|
});
|
|
53611
56260
|
this.subscribeBindingsChangedForReconcile();
|
|
53612
56261
|
this.scheduleBootDiscoveryReconcile();
|
|
56262
|
+
this.scheduleVisibilityRefresh();
|
|
56263
|
+
this.scheduleDetectionClassRefresh();
|
|
53613
56264
|
const offRemoved = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (event) => {
|
|
53614
56265
|
const id = event.data?.deviceId;
|
|
53615
56266
|
if (typeof id !== "number") return;
|
|
@@ -53656,6 +56307,10 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53656
56307
|
actionHandlers: {
|
|
53657
56308
|
redetectHubUrl: async () => this.redetectPublicHubUrl(),
|
|
53658
56309
|
regenerateIngressToken: async () => this.regenerateIngressToken(),
|
|
56310
|
+
revealIngressToken: async () => {
|
|
56311
|
+
this.ctx.logger.info("export-alexa: an administrator revealed the ingress token");
|
|
56312
|
+
return { ingressToken: this.config.ingressToken };
|
|
56313
|
+
},
|
|
53659
56314
|
resyncDiscovery: async () => this.resyncDiscovery(),
|
|
53660
56315
|
purgeStaleEndpoints: async () => this.purgeStaleEndpoints(),
|
|
53661
56316
|
unlinkAlexaAccounts: async () => this.unlinkAlexaAccounts(),
|
|
@@ -53664,6 +56319,10 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53664
56319
|
};
|
|
53665
56320
|
}
|
|
53666
56321
|
async onShutdown() {
|
|
56322
|
+
this.detectionFeed?.stop();
|
|
56323
|
+
this.detectionFeed = null;
|
|
56324
|
+
this.stateReporter?.stop();
|
|
56325
|
+
this.stateReporter = null;
|
|
53667
56326
|
if (this.lwaGateway) {
|
|
53668
56327
|
this.lwaGateway.stop();
|
|
53669
56328
|
this.lwaGateway = null;
|
|
@@ -53684,7 +56343,10 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53684
56343
|
});
|
|
53685
56344
|
await this.updateGlobalSettings({ exposed: next });
|
|
53686
56345
|
this.ctx.logger.info("export-alexa: exposed device", { meta: { deviceId } });
|
|
53687
|
-
this.requireSyncEngine().pushDevice(deviceId).then((r) =>
|
|
56346
|
+
this.requireSyncEngine().pushDevice(deviceId).then((r) => {
|
|
56347
|
+
this.ctx.logger.info("export-alexa: synced exposed device", { meta: { ...r } });
|
|
56348
|
+
return this.refreshVisibility();
|
|
56349
|
+
}).catch((err) => {
|
|
53688
56350
|
this.ctx.logger.warn("export-alexa: expose sync failed", { meta: {
|
|
53689
56351
|
deviceId,
|
|
53690
56352
|
error: errMsg(err)
|
|
@@ -53711,12 +56373,96 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53711
56373
|
* `Discover` handler uses.
|
|
53712
56374
|
*/
|
|
53713
56375
|
async buildEndpointsForOneDevice(deviceId) {
|
|
56376
|
+
const classSource = this.detectionClassSource;
|
|
53714
56377
|
return buildEndpointsForExposedDevice(deviceId, {
|
|
53715
56378
|
ctx: this.ctx,
|
|
53716
56379
|
logger: this.ctx.logger,
|
|
53717
|
-
|
|
56380
|
+
snapshots: this.config.snapshots,
|
|
56381
|
+
...this.config.detectionSensors && classSource !== null ? { detectionClasses: (id) => classSource.classesFor(id) } : {}
|
|
56382
|
+
});
|
|
56383
|
+
}
|
|
56384
|
+
/** The settings that change every exposed endpoint's shape, as one key. */
|
|
56385
|
+
shapeSwitches() {
|
|
56386
|
+
return JSON.stringify({
|
|
56387
|
+
detectionSensors: this.config.detectionSensors,
|
|
56388
|
+
snapshots: this.config.snapshots
|
|
56389
|
+
});
|
|
56390
|
+
}
|
|
56391
|
+
/**
|
|
56392
|
+
* A shape switch was flipped: re-push every exposed device. The signature
|
|
56393
|
+
* reconcile pushes only what changed, and the push retires the endpoints a
|
|
56394
|
+
* device no longer builds (the detection sensors, when switched off).
|
|
56395
|
+
*/
|
|
56396
|
+
async onConfigChanged() {
|
|
56397
|
+
const now = this.shapeSwitches();
|
|
56398
|
+
if (this.appliedShapeSwitches === null || now === this.appliedShapeSwitches) return;
|
|
56399
|
+
this.appliedShapeSwitches = now;
|
|
56400
|
+
if (!this.config.snapshots) this.snapshotStore.clear();
|
|
56401
|
+
this.ctx.logger.info("export-alexa: an endpoint-shape switch changed — re-syncing every device", { meta: { switches: now } });
|
|
56402
|
+
const engine = this.syncEngine;
|
|
56403
|
+
if (engine === null) return;
|
|
56404
|
+
engine.reconcileAllExposed().catch((err) => {
|
|
56405
|
+
this.ctx.logger.warn("export-alexa: re-sync after a switch change failed", { meta: { error: errMsg(err) } });
|
|
56406
|
+
});
|
|
56407
|
+
}
|
|
56408
|
+
scheduleDetectionClassRefresh() {
|
|
56409
|
+
const timer = setInterval(() => {
|
|
56410
|
+
if (!this.config.detectionSensors) return;
|
|
56411
|
+
this.requireSyncEngine().reconcileAllExposed().catch((err) => {
|
|
56412
|
+
this.ctx.logger.warn("export-alexa: detection-class refresh failed", { meta: { error: errMsg(err) } });
|
|
56413
|
+
});
|
|
56414
|
+
}, DETECTION_CLASS_REFRESH_MS);
|
|
56415
|
+
timer.unref?.();
|
|
56416
|
+
this.ctx.addDisposer(async () => clearInterval(timer));
|
|
56417
|
+
}
|
|
56418
|
+
get visibility() {
|
|
56419
|
+
if (!this.visibilityRef) throw new Error("export-alexa: visibility mirror not initialized");
|
|
56420
|
+
return this.visibilityRef;
|
|
56421
|
+
}
|
|
56422
|
+
/** The endpoints one linked account may be sent (S1). `null` = its user's
|
|
56423
|
+
* grants are unreadable right now; a deleted user is sent nothing. */
|
|
56424
|
+
async filterForAccount(userId, endpoints) {
|
|
56425
|
+
const lookup = await this.accessGate.principal(userId);
|
|
56426
|
+
if (lookup.kind === "unavailable") return null;
|
|
56427
|
+
if (lookup.kind === "unknown-user") return [];
|
|
56428
|
+
return (await filterEndpointsForPrincipal(endpoints, lookup.principal, this.endpointAccess)).kept;
|
|
56429
|
+
}
|
|
56430
|
+
/** One visibility pass, applied to Alexa. Never throws. */
|
|
56431
|
+
async refreshVisibility() {
|
|
56432
|
+
try {
|
|
56433
|
+
await applyVisibilityChanges(await this.visibility.refresh(), this.requireSyncEngine(), this.ctx.logger);
|
|
56434
|
+
} catch (err) {
|
|
56435
|
+
this.ctx.logger.warn("export-alexa: visibility refresh failed", { meta: { error: errMsg(err) } });
|
|
56436
|
+
}
|
|
56437
|
+
}
|
|
56438
|
+
scheduleVisibilityRefresh() {
|
|
56439
|
+
const first = setTimeout(() => void this.refreshVisibility(), BOOT_VISIBILITY_DELAY_MS);
|
|
56440
|
+
first.unref?.();
|
|
56441
|
+
const every = setInterval(() => void this.refreshVisibility(), VISIBILITY_REFRESH_INTERVAL_MS);
|
|
56442
|
+
every.unref?.();
|
|
56443
|
+
this.ctx.addDisposer(async () => {
|
|
56444
|
+
clearTimeout(first);
|
|
56445
|
+
clearInterval(every);
|
|
53718
56446
|
});
|
|
53719
56447
|
}
|
|
56448
|
+
/** An account-link token's hub user, or null — the snapshot route's
|
|
56449
|
+
* credential check (the same verification `/directive` runs). */
|
|
56450
|
+
async verifyAccountLinkToken(token) {
|
|
56451
|
+
const raw = await this.ctx.api.userManagement.oauthVerifyAccessToken.query({ token });
|
|
56452
|
+
const parsed = VerifiedTokenSchema.safeParse(raw);
|
|
56453
|
+
return parsed.success && parsed.data !== null ? { userId: parsed.data.userId } : null;
|
|
56454
|
+
}
|
|
56455
|
+
get accessGate() {
|
|
56456
|
+
if (!this.accessGateRef) throw new Error("export-alexa: access gate not initialized");
|
|
56457
|
+
return this.accessGateRef;
|
|
56458
|
+
}
|
|
56459
|
+
/** The exposed set + the user gate, as every enforcement point reads them. */
|
|
56460
|
+
get endpointAccess() {
|
|
56461
|
+
return {
|
|
56462
|
+
gate: this.accessGate,
|
|
56463
|
+
isExposed: (deviceId) => this.config.exposed.some((e) => e.deviceId === deviceId)
|
|
56464
|
+
};
|
|
56465
|
+
}
|
|
53720
56466
|
/** Narrow the nullable `lwaGateway` for the SyncEngine post closures. It is
|
|
53721
56467
|
* constructed + started in `onInitialize` before the SyncEngine, so its
|
|
53722
56468
|
* `postReport` is always reachable when the engine pushes. */
|
|
@@ -53887,7 +56633,8 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53887
56633
|
linkedAccounts: [],
|
|
53888
56634
|
tokens: [],
|
|
53889
56635
|
exportedEndpoints: {},
|
|
53890
|
-
exportedSignatures: {}
|
|
56636
|
+
exportedSignatures: {},
|
|
56637
|
+
accountVisibility: {}
|
|
53891
56638
|
});
|
|
53892
56639
|
this.ctx.logger.warn("export-alexa: operator unlinked the Amazon account(s)", { meta: {
|
|
53893
56640
|
revokedSessions,
|
|
@@ -53930,7 +56677,10 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53930
56677
|
await this.updateGlobalSettings({ linkedAccounts: next });
|
|
53931
56678
|
const engine = this.syncEngine;
|
|
53932
56679
|
if (!engine) return;
|
|
53933
|
-
engine.pushAllToAccount(input.userId).then((r) =>
|
|
56680
|
+
engine.pushAllToAccount(input.userId).then((r) => {
|
|
56681
|
+
this.ctx.logger.info("export-alexa: pushed to newly linked account", { meta: { ...r } });
|
|
56682
|
+
return this.refreshVisibility();
|
|
56683
|
+
}).catch((err) => {
|
|
53934
56684
|
this.ctx.logger.warn("export-alexa: new-account push failed", { meta: {
|
|
53935
56685
|
userId: input.userId,
|
|
53936
56686
|
error: errMsg(err)
|
|
@@ -54131,26 +56881,11 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54131
56881
|
* gets a copy button in the panel; the secret rows are masked.
|
|
54132
56882
|
*/
|
|
54133
56883
|
buildSetupBlock() {
|
|
54134
|
-
|
|
54135
|
-
|
|
54136
|
-
|
|
54137
|
-
|
|
54138
|
-
|
|
54139
|
-
label: "Directive endpoint",
|
|
54140
|
-
value: `${base}/addon/export-alexa/directive`
|
|
54141
|
-
}] : [],
|
|
54142
|
-
...this.config.ingressToken ? [{
|
|
54143
|
-
label: "Ingress token (Lambda HUB_INGRESS_TOKEN)",
|
|
54144
|
-
value: this.config.ingressToken,
|
|
54145
|
-
secret: true
|
|
54146
|
-
}] : [],
|
|
54147
|
-
{
|
|
54148
|
-
label: "Linked Alexa accounts",
|
|
54149
|
-
value: String(linkedCount)
|
|
54150
|
-
}
|
|
54151
|
-
],
|
|
54152
|
-
note: base ? "Account linking is handled by the core OAuth server. Use the hub's /api/oauth2/authorize endpoint with integration=export-alexa." : "Set the public hub URL on the settings form first — the directive endpoint URL is derived from it."
|
|
54153
|
-
};
|
|
56884
|
+
return buildAlexaSetupBlock({
|
|
56885
|
+
publicHubUrl: this.config.publicHubUrl,
|
|
56886
|
+
ingressToken: this.config.ingressToken,
|
|
56887
|
+
linkedAccountCount: this.config.linkedAccounts.length
|
|
56888
|
+
});
|
|
54154
56889
|
}
|
|
54155
56890
|
/** Options for the "External address" picker: the cached HTTPS endpoints
|
|
54156
56891
|
* plus the current selection (so a stale/custom value is never dropped). */
|
|
@@ -54221,7 +56956,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54221
56956
|
type: "select",
|
|
54222
56957
|
key: "region",
|
|
54223
56958
|
label: "Skill region",
|
|
54224
|
-
description: "The Alexa Event Gateway this hub posts to: doorbell presses, motion,
|
|
56959
|
+
description: "The Alexa Event Gateway this hub posts to: doorbell presses, motion, camera online/offline and state changes, and the device-list updates sent when you expose or unexpose a camera. Pick the region your Amazon account is in — events sent to another region are rejected.",
|
|
54225
56960
|
options: [
|
|
54226
56961
|
{
|
|
54227
56962
|
value: "NA",
|
|
@@ -54238,6 +56973,20 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54238
56973
|
],
|
|
54239
56974
|
default: DEFAULT_CONFIG.region
|
|
54240
56975
|
}),
|
|
56976
|
+
this.field({
|
|
56977
|
+
type: "boolean",
|
|
56978
|
+
key: "detectionSensors",
|
|
56979
|
+
label: "Detection sensors",
|
|
56980
|
+
description: "Give every exposed camera one Alexa motion sensor per class its detection pipeline finds: \"<camera> Person\", \"Vehicle\", \"Animal\", \"Package\". Use them as routine triggers in the Alexa app (for example: when Front door Person detects motion, announce on the Echo devices). They report only \"detected\" — no images. Off removes them from Alexa.",
|
|
56981
|
+
default: DEFAULT_CONFIG.detectionSensors
|
|
56982
|
+
}),
|
|
56983
|
+
this.field({
|
|
56984
|
+
type: "boolean",
|
|
56985
|
+
key: "snapshots",
|
|
56986
|
+
label: "Snapshots in the Alexa app",
|
|
56987
|
+
description: "Show each exposed camera's latest still on its tile in the Alexa app. While this is on, Amazon fetches still images of your cameras (re-encoded to about 480 px, each link valid ten minutes and only with your account's token). Off removes the feature from Alexa and invalidates every link already handed out. The hub's public HTTPS address must be set.",
|
|
56988
|
+
default: DEFAULT_CONFIG.snapshots
|
|
56989
|
+
}),
|
|
54241
56990
|
...this.config.skillMode === "custom" ? [this.field({
|
|
54242
56991
|
type: "text",
|
|
54243
56992
|
key: "expectedAudience",
|
|
@@ -54249,7 +56998,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54249
56998
|
key: "__custom-skill-info",
|
|
54250
56999
|
label: "Your skill Lambda",
|
|
54251
57000
|
variant: "info",
|
|
54252
|
-
content: "Proactive events (doorbell, motion,
|
|
57001
|
+
content: "Proactive events (doorbell, motion, online/offline, device-list updates) are authorised by YOUR skill Lambda: it keeps the \"Send Alexa Events\" LWA client id/secret in SSM Parameter Store and hands this hub short-lived tokens. Nothing to paste here — see the custom-skill setup guide."
|
|
54253
57002
|
}] : [{
|
|
54254
57003
|
type: "info",
|
|
54255
57004
|
key: "__public-skill-info",
|
|
@@ -54319,16 +57068,30 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54319
57068
|
key: "__ingress-token-info",
|
|
54320
57069
|
label: "Ingress token",
|
|
54321
57070
|
variant: "info",
|
|
54322
|
-
content: "Defense-in-depth shared secret.
|
|
57071
|
+
content: "Defense-in-depth shared secret. Press \"Show ingress token\" to read it (it is never part of a status or settings read), paste it into your Alexa Lambda's HUB_INGRESS_TOKEN env, then enable \"Require ingress token\" below. Use \"Regenerate token\" to rotate (update the Lambda afterwards)."
|
|
54323
57072
|
},
|
|
54324
57073
|
{
|
|
54325
|
-
type: "button",
|
|
57074
|
+
type: "addon-action-button",
|
|
57075
|
+
addonId: "export-alexa",
|
|
57076
|
+
key: "__reveal-ingress-token",
|
|
57077
|
+
label: "Show ingress token",
|
|
57078
|
+
description: "Administrators only. Shows the current token once, here.",
|
|
57079
|
+
buttonLabel: "Show ingress token",
|
|
57080
|
+
action: "revealIngressToken",
|
|
57081
|
+
variant: "default",
|
|
57082
|
+
successMessage: "Ingress token: {ingressToken}"
|
|
57083
|
+
},
|
|
57084
|
+
{
|
|
57085
|
+
type: "addon-action-button",
|
|
57086
|
+
addonId: "export-alexa",
|
|
54326
57087
|
key: "__regenerate-ingress-token",
|
|
54327
57088
|
label: "Rotate ingress token",
|
|
54328
57089
|
description: "Generate a new ingress token, invalidating the old one. Update your Lambda's HUB_INGRESS_TOKEN afterwards.",
|
|
54329
57090
|
buttonLabel: "Regenerate token",
|
|
54330
57091
|
action: "regenerateIngressToken",
|
|
54331
|
-
variant: "default"
|
|
57092
|
+
variant: "default",
|
|
57093
|
+
successMessage: "New ingress token: {ingressToken} — paste it into HUB_INGRESS_TOKEN now.",
|
|
57094
|
+
confirmMessage: "Rotate the ingress token? With \"Require ingress token\" on, the Lambda is refused until you update HUB_INGRESS_TOKEN."
|
|
54332
57095
|
},
|
|
54333
57096
|
this.field({
|
|
54334
57097
|
type: "boolean",
|
|
@@ -54372,14 +57135,14 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54372
57135
|
}
|
|
54373
57136
|
}
|
|
54374
57137
|
async buildDeviceSettingsContribution(deviceId) {
|
|
54375
|
-
|
|
57138
|
+
const kind = await this.exportKindOf(deviceId);
|
|
57139
|
+
if (kind === null) return null;
|
|
54376
57140
|
const entry = this.findEntry(deviceId);
|
|
54377
57141
|
const settings = entry?.settings ?? DEFAULT_DEVICE_SETTINGS;
|
|
54378
57142
|
const enabled = entry !== null;
|
|
54379
57143
|
const enabledKey = `alexa:${deviceId}:enabled`;
|
|
54380
|
-
const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
|
|
54381
|
-
const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
|
|
54382
57144
|
const linkedCount = this.config.linkedAccounts.length;
|
|
57145
|
+
const statusLabel = enabled ? `Exposed${linkedCount > 0 ? `, ${linkedCount} linked Alexa account(s)` : " (no linked Alexa account yet)"}` : linkedCount > 0 ? `${linkedCount} linked Alexa account(s) — device not exposed` : "Not exposed";
|
|
54383
57146
|
return {
|
|
54384
57147
|
tabs: [{
|
|
54385
57148
|
id: "export",
|
|
@@ -54390,7 +57153,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54390
57153
|
sections: [{
|
|
54391
57154
|
id: "export-alexa",
|
|
54392
57155
|
title: "Alexa Export",
|
|
54393
|
-
description: "Surface this camera to the linked Alexa Smart Home skill.",
|
|
57156
|
+
description: kind === "camera" ? "Surface this camera to the linked Alexa Smart Home skill." : "Surface this device to the linked Alexa Smart Home skill (locks, sensors, switches and lights each become one Alexa device).",
|
|
54394
57157
|
tab: "export",
|
|
54395
57158
|
columns: 1,
|
|
54396
57159
|
order: 20,
|
|
@@ -54399,49 +57162,54 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54399
57162
|
type: "info",
|
|
54400
57163
|
key: "__alexa-status",
|
|
54401
57164
|
label: "Status",
|
|
54402
|
-
content:
|
|
57165
|
+
content: statusLabel,
|
|
54403
57166
|
variant: "info"
|
|
54404
57167
|
},
|
|
54405
57168
|
{
|
|
54406
57169
|
type: "boolean",
|
|
54407
57170
|
key: enabledKey,
|
|
54408
57171
|
label: "Expose to Alexa",
|
|
54409
|
-
description: "Toggle to mirror this
|
|
57172
|
+
description: "Toggle to mirror this device onto the Alexa Smart Home skill.",
|
|
54410
57173
|
style: "switch",
|
|
54411
57174
|
value: enabled,
|
|
54412
57175
|
immediate: true
|
|
54413
57176
|
},
|
|
54414
|
-
|
|
54415
|
-
type: "select",
|
|
54416
|
-
key: streamPreferenceKey,
|
|
54417
|
-
label: "Source stream (Alexa / Echo)",
|
|
54418
|
-
description: "Which camstack profile slot the Echo pulls. Auto = the broker picks the slot closest to 720p at session start (the Echo doesn’t adapt mid-session, so pin a specific profile if Auto picks badly on your hardware).",
|
|
54419
|
-
options: STREAM_PREFERENCE_OPTIONS,
|
|
54420
|
-
required: true,
|
|
54421
|
-
value: settings.streamPreference,
|
|
54422
|
-
showWhen: {
|
|
54423
|
-
field: enabledKey,
|
|
54424
|
-
equals: true
|
|
54425
|
-
},
|
|
54426
|
-
immediate: true
|
|
54427
|
-
},
|
|
54428
|
-
{
|
|
54429
|
-
type: "boolean",
|
|
54430
|
-
key: allowTranscodeKey,
|
|
54431
|
-
label: "Allow transcoding for Alexa",
|
|
54432
|
-
description: "Let the hub re-encode this camera for the Echo when no stream is directly usable. Off means only a stream Alexa can take as-is (H.264, 480p-1080p) is offered — cheaper, but a camera that publishes only H.265, or only a small sub-stream, will not be streamable to Alexa at all.",
|
|
54433
|
-
style: "switch",
|
|
54434
|
-
value: settings.allowTranscode !== false,
|
|
54435
|
-
showWhen: {
|
|
54436
|
-
field: enabledKey,
|
|
54437
|
-
equals: true
|
|
54438
|
-
},
|
|
54439
|
-
immediate: true
|
|
54440
|
-
}
|
|
57177
|
+
...kind === "camera" ? this.cameraOnlyExportFields(deviceId, settings, enabledKey) : []
|
|
54441
57178
|
]
|
|
54442
57179
|
}]
|
|
54443
57180
|
};
|
|
54444
57181
|
}
|
|
57182
|
+
/** The stream fields of the Export section — a camera's only. */
|
|
57183
|
+
cameraOnlyExportFields(deviceId, settings, enabledKey) {
|
|
57184
|
+
const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
|
|
57185
|
+
const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
|
|
57186
|
+
return [{
|
|
57187
|
+
type: "select",
|
|
57188
|
+
key: streamPreferenceKey,
|
|
57189
|
+
label: "Source stream (Alexa / Echo)",
|
|
57190
|
+
description: "Which camstack profile slot the Echo pulls. Auto = the broker picks the slot closest to 720p at session start (the Echo doesn’t adapt mid-session, so pin a specific profile if Auto picks badly on your hardware).",
|
|
57191
|
+
options: STREAM_PREFERENCE_OPTIONS,
|
|
57192
|
+
required: true,
|
|
57193
|
+
value: settings.streamPreference,
|
|
57194
|
+
showWhen: {
|
|
57195
|
+
field: enabledKey,
|
|
57196
|
+
equals: true
|
|
57197
|
+
},
|
|
57198
|
+
immediate: true
|
|
57199
|
+
}, {
|
|
57200
|
+
type: "boolean",
|
|
57201
|
+
key: allowTranscodeKey,
|
|
57202
|
+
label: "Allow transcoding for Alexa",
|
|
57203
|
+
description: "Let the hub re-encode this camera for the Echo when no stream is directly usable. Off means only a stream Alexa can take as-is (H.264, 480p-1080p) is offered — cheaper, but a camera that publishes only H.265, or only a small sub-stream, will not be streamable to Alexa at all.",
|
|
57204
|
+
style: "switch",
|
|
57205
|
+
value: settings.allowTranscode !== false,
|
|
57206
|
+
showWhen: {
|
|
57207
|
+
field: enabledKey,
|
|
57208
|
+
equals: true
|
|
57209
|
+
},
|
|
57210
|
+
immediate: true
|
|
57211
|
+
}];
|
|
57212
|
+
}
|
|
54445
57213
|
/**
|
|
54446
57214
|
* Apply a patch produced by the device-details "Export" panel. Flips
|
|
54447
57215
|
* the expose/unexpose lifecycle on `enabled` transitions and persists
|
|
@@ -54507,13 +57275,20 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54507
57275
|
}
|
|
54508
57276
|
return { success: true };
|
|
54509
57277
|
}
|
|
54510
|
-
|
|
57278
|
+
/**
|
|
57279
|
+
* Which Export section a device gets: the camera one (with stream
|
|
57280
|
+
* settings), the generic one (on/off only — R5, D775), or none. An
|
|
57281
|
+
* unreadable device reads as a camera, as before.
|
|
57282
|
+
*/
|
|
57283
|
+
async exportKindOf(deviceId) {
|
|
54511
57284
|
try {
|
|
54512
57285
|
const dev = await this.ctx.api.deviceManager.getDevice.query({ deviceId });
|
|
54513
|
-
if (!dev) return
|
|
54514
|
-
|
|
57286
|
+
if (!dev) return "camera";
|
|
57287
|
+
const type = String(dev.type);
|
|
57288
|
+
if (type === DeviceType.Camera) return "camera";
|
|
57289
|
+
return GENERIC_DEVICE_TYPES.includes(type) ? "generic" : null;
|
|
54515
57290
|
} catch {
|
|
54516
|
-
return
|
|
57291
|
+
return "camera";
|
|
54517
57292
|
}
|
|
54518
57293
|
}
|
|
54519
57294
|
findEntry(deviceId) {
|