@camstack/addon-export-alexa 1.2.135 → 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 +3113 -275
- package/dist/export-alexa.addon.mjs +3113 -276
- package/package.json +2 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as crypto$1 from "node:crypto";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import sharp from "sharp";
|
|
3
4
|
//#region ../types/dist/event-category-B_0XEcLF.mjs
|
|
4
5
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
5
6
|
EventCategory["SystemBoot"] = "system.boot";
|
|
@@ -3763,7 +3764,7 @@ function initializeContext(params) {
|
|
|
3763
3764
|
external: params?.external ?? void 0
|
|
3764
3765
|
};
|
|
3765
3766
|
}
|
|
3766
|
-
function process(schema, ctx, _params = {
|
|
3767
|
+
function process$1(schema, ctx, _params = {
|
|
3767
3768
|
path: [],
|
|
3768
3769
|
schemaPath: []
|
|
3769
3770
|
}) {
|
|
@@ -3800,7 +3801,7 @@ function process(schema, ctx, _params = {
|
|
|
3800
3801
|
const parent = schema._zod.parent;
|
|
3801
3802
|
if (parent) {
|
|
3802
3803
|
if (!result.ref) result.ref = parent;
|
|
3803
|
-
process(parent, ctx, params);
|
|
3804
|
+
process$1(parent, ctx, params);
|
|
3804
3805
|
ctx.seen.get(parent).isParent = true;
|
|
3805
3806
|
}
|
|
3806
3807
|
}
|
|
@@ -4020,7 +4021,7 @@ var createToJSONSchemaMethod = (schema, processors = {}) => (params) => {
|
|
|
4020
4021
|
...params,
|
|
4021
4022
|
processors
|
|
4022
4023
|
});
|
|
4023
|
-
process(schema, ctx);
|
|
4024
|
+
process$1(schema, ctx);
|
|
4024
4025
|
extractDefs(ctx, schema);
|
|
4025
4026
|
return finalize(ctx, schema);
|
|
4026
4027
|
};
|
|
@@ -4032,7 +4033,7 @@ var createStandardJSONSchemaMethod = (schema, io, processors = {}) => (params) =
|
|
|
4032
4033
|
io,
|
|
4033
4034
|
processors
|
|
4034
4035
|
});
|
|
4035
|
-
process(schema, ctx);
|
|
4036
|
+
process$1(schema, ctx);
|
|
4036
4037
|
extractDefs(ctx, schema);
|
|
4037
4038
|
return finalize(ctx, schema);
|
|
4038
4039
|
};
|
|
@@ -4146,7 +4147,7 @@ var arrayProcessor = (schema, ctx, _json, params) => {
|
|
|
4146
4147
|
if (typeof minimum === "number") json.minItems = minimum;
|
|
4147
4148
|
if (typeof maximum === "number") json.maxItems = maximum;
|
|
4148
4149
|
json.type = "array";
|
|
4149
|
-
json.items = process(def.element, ctx, {
|
|
4150
|
+
json.items = process$1(def.element, ctx, {
|
|
4150
4151
|
...params,
|
|
4151
4152
|
path: [...params.path, "items"]
|
|
4152
4153
|
});
|
|
@@ -4157,7 +4158,7 @@ var objectProcessor = (schema, ctx, _json, params) => {
|
|
|
4157
4158
|
json.type = "object";
|
|
4158
4159
|
json.properties = {};
|
|
4159
4160
|
const shape = def.shape;
|
|
4160
|
-
for (const key in shape) json.properties[key] = process(shape[key], ctx, {
|
|
4161
|
+
for (const key in shape) json.properties[key] = process$1(shape[key], ctx, {
|
|
4161
4162
|
...params,
|
|
4162
4163
|
path: [
|
|
4163
4164
|
...params.path,
|
|
@@ -4175,7 +4176,7 @@ var objectProcessor = (schema, ctx, _json, params) => {
|
|
|
4175
4176
|
if (def.catchall?._zod.def.type === "never") json.additionalProperties = false;
|
|
4176
4177
|
else if (!def.catchall) {
|
|
4177
4178
|
if (ctx.io === "output") json.additionalProperties = false;
|
|
4178
|
-
} else if (def.catchall) json.additionalProperties = process(def.catchall, ctx, {
|
|
4179
|
+
} else if (def.catchall) json.additionalProperties = process$1(def.catchall, ctx, {
|
|
4179
4180
|
...params,
|
|
4180
4181
|
path: [...params.path, "additionalProperties"]
|
|
4181
4182
|
});
|
|
@@ -4183,7 +4184,7 @@ var objectProcessor = (schema, ctx, _json, params) => {
|
|
|
4183
4184
|
var unionProcessor = (schema, ctx, json, params) => {
|
|
4184
4185
|
const def = schema._zod.def;
|
|
4185
4186
|
const isExclusive = def.inclusive === false;
|
|
4186
|
-
const options = def.options.map((x, i) => process(x, ctx, {
|
|
4187
|
+
const options = def.options.map((x, i) => process$1(x, ctx, {
|
|
4187
4188
|
...params,
|
|
4188
4189
|
path: [
|
|
4189
4190
|
...params.path,
|
|
@@ -4196,7 +4197,7 @@ var unionProcessor = (schema, ctx, json, params) => {
|
|
|
4196
4197
|
};
|
|
4197
4198
|
var intersectionProcessor = (schema, ctx, json, params) => {
|
|
4198
4199
|
const def = schema._zod.def;
|
|
4199
|
-
const a = process(def.left, ctx, {
|
|
4200
|
+
const a = process$1(def.left, ctx, {
|
|
4200
4201
|
...params,
|
|
4201
4202
|
path: [
|
|
4202
4203
|
...params.path,
|
|
@@ -4204,7 +4205,7 @@ var intersectionProcessor = (schema, ctx, json, params) => {
|
|
|
4204
4205
|
0
|
|
4205
4206
|
]
|
|
4206
4207
|
});
|
|
4207
|
-
const b = process(def.right, ctx, {
|
|
4208
|
+
const b = process$1(def.right, ctx, {
|
|
4208
4209
|
...params,
|
|
4209
4210
|
path: [
|
|
4210
4211
|
...params.path,
|
|
@@ -4221,7 +4222,7 @@ var tupleProcessor = (schema, ctx, _json, params) => {
|
|
|
4221
4222
|
json.type = "array";
|
|
4222
4223
|
const prefixPath = ctx.target === "draft-2020-12" ? "prefixItems" : "items";
|
|
4223
4224
|
const restPath = ctx.target === "draft-2020-12" ? "items" : ctx.target === "openapi-3.0" ? "items" : "additionalItems";
|
|
4224
|
-
const prefixItems = def.items.map((x, i) => process(x, ctx, {
|
|
4225
|
+
const prefixItems = def.items.map((x, i) => process$1(x, ctx, {
|
|
4225
4226
|
...params,
|
|
4226
4227
|
path: [
|
|
4227
4228
|
...params.path,
|
|
@@ -4229,7 +4230,7 @@ var tupleProcessor = (schema, ctx, _json, params) => {
|
|
|
4229
4230
|
i
|
|
4230
4231
|
]
|
|
4231
4232
|
}));
|
|
4232
|
-
const rest = def.rest ? process(def.rest, ctx, {
|
|
4233
|
+
const rest = def.rest ? process$1(def.rest, ctx, {
|
|
4233
4234
|
...params,
|
|
4234
4235
|
path: [
|
|
4235
4236
|
...params.path,
|
|
@@ -4260,7 +4261,7 @@ var recordProcessor = (schema, ctx, _json, params) => {
|
|
|
4260
4261
|
const keyType = def.keyType;
|
|
4261
4262
|
const patterns = keyType._zod.bag?.patterns;
|
|
4262
4263
|
if (def.mode === "loose" && patterns && patterns.size > 0) {
|
|
4263
|
-
const valueSchema = process(def.valueType, ctx, {
|
|
4264
|
+
const valueSchema = process$1(def.valueType, ctx, {
|
|
4264
4265
|
...params,
|
|
4265
4266
|
path: [
|
|
4266
4267
|
...params.path,
|
|
@@ -4271,11 +4272,11 @@ var recordProcessor = (schema, ctx, _json, params) => {
|
|
|
4271
4272
|
json.patternProperties = {};
|
|
4272
4273
|
for (const pattern of patterns) json.patternProperties[pattern.source] = valueSchema;
|
|
4273
4274
|
} else {
|
|
4274
|
-
if (ctx.target === "draft-07" || ctx.target === "draft-2020-12") json.propertyNames = process(def.keyType, ctx, {
|
|
4275
|
+
if (ctx.target === "draft-07" || ctx.target === "draft-2020-12") json.propertyNames = process$1(def.keyType, ctx, {
|
|
4275
4276
|
...params,
|
|
4276
4277
|
path: [...params.path, "propertyNames"]
|
|
4277
4278
|
});
|
|
4278
|
-
json.additionalProperties = process(def.valueType, ctx, {
|
|
4279
|
+
json.additionalProperties = process$1(def.valueType, ctx, {
|
|
4279
4280
|
...params,
|
|
4280
4281
|
path: [...params.path, "additionalProperties"]
|
|
4281
4282
|
});
|
|
@@ -4288,7 +4289,7 @@ var recordProcessor = (schema, ctx, _json, params) => {
|
|
|
4288
4289
|
};
|
|
4289
4290
|
var nullableProcessor = (schema, ctx, json, params) => {
|
|
4290
4291
|
const def = schema._zod.def;
|
|
4291
|
-
const inner = process(def.innerType, ctx, params);
|
|
4292
|
+
const inner = process$1(def.innerType, ctx, params);
|
|
4292
4293
|
const seen = ctx.seen.get(schema);
|
|
4293
4294
|
if (ctx.target === "openapi-3.0") {
|
|
4294
4295
|
seen.ref = def.innerType;
|
|
@@ -4297,27 +4298,27 @@ var nullableProcessor = (schema, ctx, json, params) => {
|
|
|
4297
4298
|
};
|
|
4298
4299
|
var nonoptionalProcessor = (schema, ctx, _json, params) => {
|
|
4299
4300
|
const def = schema._zod.def;
|
|
4300
|
-
process(def.innerType, ctx, params);
|
|
4301
|
+
process$1(def.innerType, ctx, params);
|
|
4301
4302
|
const seen = ctx.seen.get(schema);
|
|
4302
4303
|
seen.ref = def.innerType;
|
|
4303
4304
|
};
|
|
4304
4305
|
var defaultProcessor = (schema, ctx, json, params) => {
|
|
4305
4306
|
const def = schema._zod.def;
|
|
4306
|
-
process(def.innerType, ctx, params);
|
|
4307
|
+
process$1(def.innerType, ctx, params);
|
|
4307
4308
|
const seen = ctx.seen.get(schema);
|
|
4308
4309
|
seen.ref = def.innerType;
|
|
4309
4310
|
json.default = JSON.parse(JSON.stringify(def.defaultValue));
|
|
4310
4311
|
};
|
|
4311
4312
|
var prefaultProcessor = (schema, ctx, json, params) => {
|
|
4312
4313
|
const def = schema._zod.def;
|
|
4313
|
-
process(def.innerType, ctx, params);
|
|
4314
|
+
process$1(def.innerType, ctx, params);
|
|
4314
4315
|
const seen = ctx.seen.get(schema);
|
|
4315
4316
|
seen.ref = def.innerType;
|
|
4316
4317
|
if (ctx.io === "input") json._prefault = JSON.parse(JSON.stringify(def.defaultValue));
|
|
4317
4318
|
};
|
|
4318
4319
|
var catchProcessor = (schema, ctx, json, params) => {
|
|
4319
4320
|
const def = schema._zod.def;
|
|
4320
|
-
process(def.innerType, ctx, params);
|
|
4321
|
+
process$1(def.innerType, ctx, params);
|
|
4321
4322
|
const seen = ctx.seen.get(schema);
|
|
4322
4323
|
seen.ref = def.innerType;
|
|
4323
4324
|
let catchValue;
|
|
@@ -4332,26 +4333,26 @@ var pipeProcessor = (schema, ctx, _json, params) => {
|
|
|
4332
4333
|
const def = schema._zod.def;
|
|
4333
4334
|
const inIsTransform = def.in._zod.traits.has("$ZodTransform");
|
|
4334
4335
|
const innerType = ctx.io === "input" ? inIsTransform ? def.out : def.in : def.out;
|
|
4335
|
-
process(innerType, ctx, params);
|
|
4336
|
+
process$1(innerType, ctx, params);
|
|
4336
4337
|
const seen = ctx.seen.get(schema);
|
|
4337
4338
|
seen.ref = innerType;
|
|
4338
4339
|
};
|
|
4339
4340
|
var readonlyProcessor = (schema, ctx, json, params) => {
|
|
4340
4341
|
const def = schema._zod.def;
|
|
4341
|
-
process(def.innerType, ctx, params);
|
|
4342
|
+
process$1(def.innerType, ctx, params);
|
|
4342
4343
|
const seen = ctx.seen.get(schema);
|
|
4343
4344
|
seen.ref = def.innerType;
|
|
4344
4345
|
json.readOnly = true;
|
|
4345
4346
|
};
|
|
4346
4347
|
var optionalProcessor = (schema, ctx, _json, params) => {
|
|
4347
4348
|
const def = schema._zod.def;
|
|
4348
|
-
process(def.innerType, ctx, params);
|
|
4349
|
+
process$1(def.innerType, ctx, params);
|
|
4349
4350
|
const seen = ctx.seen.get(schema);
|
|
4350
4351
|
seen.ref = def.innerType;
|
|
4351
4352
|
};
|
|
4352
4353
|
var lazyProcessor = (schema, ctx, _json, params) => {
|
|
4353
4354
|
const innerType = schema._zod.innerType;
|
|
4354
|
-
process(innerType, ctx, params);
|
|
4355
|
+
process$1(innerType, ctx, params);
|
|
4355
4356
|
const seen = ctx.seen.get(schema);
|
|
4356
4357
|
seen.ref = innerType;
|
|
4357
4358
|
};
|
|
@@ -7442,9 +7443,9 @@ function errMsg(err) {
|
|
|
7442
7443
|
*/
|
|
7443
7444
|
/** The pinned jellyfin-ffmpeg release. Changing this is the version change. */
|
|
7444
7445
|
var FFMPEG_RELEASE = "8.1.2-5";
|
|
7445
|
-
var BASE = `https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFMPEG_RELEASE}`;
|
|
7446
|
+
var BASE$1 = `https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFMPEG_RELEASE}`;
|
|
7446
7447
|
function portable(target) {
|
|
7447
|
-
return `${BASE}/jellyfin-ffmpeg_${FFMPEG_RELEASE}_portable_${target}-gpl.tar.xz`;
|
|
7448
|
+
return `${BASE$1}/jellyfin-ffmpeg_${FFMPEG_RELEASE}_portable_${target}-gpl.tar.xz`;
|
|
7448
7449
|
}
|
|
7449
7450
|
portable("linux64"), portable("linuxarm64"), portable("macarm64"), portable("mac64");
|
|
7450
7451
|
/**
|
|
@@ -26326,7 +26327,7 @@ method(SendEmailInputSchema, SendEmailResultSchema, {
|
|
|
26326
26327
|
* the device-native provider (onboard capture) or from the stream-broker
|
|
26327
26328
|
* prebuffer fallback.
|
|
26328
26329
|
*/
|
|
26329
|
-
var SnapshotImageSchema = object({
|
|
26330
|
+
var SnapshotImageSchema$1 = object({
|
|
26330
26331
|
base64: string(),
|
|
26331
26332
|
contentType: string()
|
|
26332
26333
|
});
|
|
@@ -26358,7 +26359,7 @@ DeviceType.Camera, method(object({
|
|
|
26358
26359
|
* correct answer: stale but honest beats woken.
|
|
26359
26360
|
*/
|
|
26360
26361
|
force: boolean().optional()
|
|
26361
|
-
}), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
|
|
26362
|
+
}), SnapshotImageSchema$1.nullable()), method(object({ deviceId: number() }), _void(), {
|
|
26362
26363
|
kind: "mutation",
|
|
26363
26364
|
auth: "admin"
|
|
26364
26365
|
}), systemMethod(object({ deviceIds: array(number()).min(1).max(200) }), array(object({
|
|
@@ -28406,7 +28407,22 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
|
|
|
28406
28407
|
* field their hints were silently stripped by the schema and the
|
|
28407
28408
|
* adaptive ladder started blind on the client's display size.
|
|
28408
28409
|
*/
|
|
28409
|
-
hints: webrtcClientHintsSchema.optional()
|
|
28410
|
+
hints: webrtcClientHintsSchema.optional(),
|
|
28411
|
+
/**
|
|
28412
|
+
* Talk-back (D771): relay the PEER's audio to the device's speaker
|
|
28413
|
+
* through the `intercom` cap's push-talk triple. When `true` and the
|
|
28414
|
+
* offer lets the peer send audio, the answer's audio is `sendrecv`
|
|
28415
|
+
* and every inbound frame goes to `intercom.pushTalkAudio` for this
|
|
28416
|
+
* `deviceId`; absent/false answers `sendonly`, as for every viewer.
|
|
28417
|
+
*
|
|
28418
|
+
* TRUSTED-CALLER ONLY. Set by the Alexa export for an Echo on a camera
|
|
28419
|
+
* with `intercom`, when the linked user may control that camera. The
|
|
28420
|
+
* hub STRIPS it from every request-bound call (a browser), exactly as
|
|
28421
|
+
* it overwrites `relayOnly` — a viewer's talk permission is the
|
|
28422
|
+
* intercom cap's own surface (share tokens gate it there), and this
|
|
28423
|
+
* flag must not become a second door to the camera's speaker.
|
|
28424
|
+
*/
|
|
28425
|
+
talkback: boolean().optional()
|
|
28410
28426
|
}), object({
|
|
28411
28427
|
sessionId: string(),
|
|
28412
28428
|
sdpAnswer: string()
|
|
@@ -28708,6 +28724,59 @@ var AddonAutoUpdateSchema = ChannelWithInheritSchema;
|
|
|
28708
28724
|
var RestartAddonResultSchema = unknown();
|
|
28709
28725
|
var InstallPackageResultSchema = unknown();
|
|
28710
28726
|
var ReloadPackagesResultSchema = unknown();
|
|
28727
|
+
/**
|
|
28728
|
+
* What an uninstall did on ONE node.
|
|
28729
|
+
* - `uninstalled` — removed: runner(s) stopped, caps gone, code deleted.
|
|
28730
|
+
* - `not-installed` — the node did not have it (nothing to do).
|
|
28731
|
+
* - `refused` — protected / bootstrap package; nothing touched.
|
|
28732
|
+
* - `failed` — attempted and did not complete; `error` says why.
|
|
28733
|
+
* - `pending-reconnect` — the node is OFFLINE: its record was pruned so the
|
|
28734
|
+
* back-fill never returns the package, and the stale-addon sweep removes the
|
|
28735
|
+
* copy when the node registers again (only once the hub no longer has it).
|
|
28736
|
+
*/
|
|
28737
|
+
var UninstallNodeOutcomeSchema = _enum([
|
|
28738
|
+
"uninstalled",
|
|
28739
|
+
"not-installed",
|
|
28740
|
+
"refused",
|
|
28741
|
+
"failed",
|
|
28742
|
+
"pending-reconnect"
|
|
28743
|
+
]);
|
|
28744
|
+
var UninstallNodeResultSchema = object({
|
|
28745
|
+
nodeId: string(),
|
|
28746
|
+
outcome: UninstallNodeOutcomeSchema,
|
|
28747
|
+
error: string().optional(),
|
|
28748
|
+
addonIds: array(string()).readonly(),
|
|
28749
|
+
runnersStopped: array(string()).readonly(),
|
|
28750
|
+
warnings: array(string()).readonly()
|
|
28751
|
+
});
|
|
28752
|
+
var UninstallPackageResultSchema = object({
|
|
28753
|
+
/** Every targeted node ended `uninstalled`, `not-installed` or `pending-reconnect`. */
|
|
28754
|
+
success: boolean(),
|
|
28755
|
+
packageName: string(),
|
|
28756
|
+
results: array(UninstallNodeResultSchema).readonly()
|
|
28757
|
+
});
|
|
28758
|
+
/**
|
|
28759
|
+
* `unreachable` lists online agents whose status read FAILED — absence from
|
|
28760
|
+
* `installations` is only "not installed" for a node that answered (D315).
|
|
28761
|
+
*/
|
|
28762
|
+
var PackageInstallationsSchema = object({
|
|
28763
|
+
installations: array(object({
|
|
28764
|
+
packageName: string(),
|
|
28765
|
+
nodeId: string(),
|
|
28766
|
+
nodeName: string(),
|
|
28767
|
+
isHub: boolean(),
|
|
28768
|
+
isOnline: boolean(),
|
|
28769
|
+
version: string().nullable(),
|
|
28770
|
+
addonIds: array(string()).readonly(),
|
|
28771
|
+
source: _enum(["live", "recorded"]),
|
|
28772
|
+
/** The hub installs this package too. `false` on an agent row = an orphan. */
|
|
28773
|
+
onHub: boolean()
|
|
28774
|
+
})).readonly(),
|
|
28775
|
+
unreachable: array(object({
|
|
28776
|
+
nodeId: string(),
|
|
28777
|
+
error: string()
|
|
28778
|
+
})).readonly()
|
|
28779
|
+
});
|
|
28711
28780
|
var BulkUpdateItemStatusSchema = _enum([
|
|
28712
28781
|
"queued",
|
|
28713
28782
|
"updating",
|
|
@@ -28790,10 +28859,14 @@ method(_void(), array(AddonListItemSchema).readonly()), method(object({
|
|
|
28790
28859
|
}), method(object({ packageName: string() }), InstallPackageResultSchema, {
|
|
28791
28860
|
kind: "mutation",
|
|
28792
28861
|
auth: "admin"
|
|
28793
|
-
}), method(_void(), boolean()), method(_void(), array(unknown()).readonly()), method(object({
|
|
28862
|
+
}), method(_void(), boolean()), method(_void(), array(unknown()).readonly()), method(object({
|
|
28863
|
+
packageName: string().min(1),
|
|
28864
|
+
nodeId: string().optional(),
|
|
28865
|
+
everywhere: boolean().optional()
|
|
28866
|
+
}), UninstallPackageResultSchema, {
|
|
28794
28867
|
kind: "mutation",
|
|
28795
28868
|
auth: "admin"
|
|
28796
|
-
}), method(_void(), ReloadPackagesResultSchema, {
|
|
28869
|
+
}), method(object({ packageName: string().optional() }), PackageInstallationsSchema, { auth: "admin" }), method(_void(), ReloadPackagesResultSchema, {
|
|
28797
28870
|
kind: "mutation",
|
|
28798
28871
|
auth: "admin"
|
|
28799
28872
|
}), method(object({ query: string().optional() }), array(SearchResultSchema)), method(object({ nodeId: string().optional() }), array(PackageUpdateSchema).readonly(), { auth: "admin" }), method(object({
|
|
@@ -29136,7 +29209,19 @@ object({
|
|
|
29136
29209
|
lastChangedAt: number()
|
|
29137
29210
|
});
|
|
29138
29211
|
DeviceType.Sensor;
|
|
29139
|
-
|
|
29212
|
+
/**
|
|
29213
|
+
* Dimmable-light brightness control. Co-exists with `switch` on the
|
|
29214
|
+
* same device — the switch toggles on/off, this cap sets the level
|
|
29215
|
+
* applied when the light is on. Drivers map their per-vendor dim
|
|
29216
|
+
* controls to this single-method surface.
|
|
29217
|
+
*
|
|
29218
|
+
* The cap is intentionally minimal: a single `setBrightness({deviceId,
|
|
29219
|
+
* percentage})` mutation plus the auto-injected `getStatus`. Drivers
|
|
29220
|
+
* that expose richer controls (color temperature, scenes, schedules)
|
|
29221
|
+
* should surface those via the device's `getSettingsUISchema()`
|
|
29222
|
+
* instead of bloating this cap.
|
|
29223
|
+
*/
|
|
29224
|
+
var BrightnessStatusSchema = object({
|
|
29140
29225
|
/** Current level as 0..100 inclusive. Firmware-reported. */
|
|
29141
29226
|
percentage: number().min(0).max(100),
|
|
29142
29227
|
/** Ms epoch of the last operator-driven change. Useful for UI freshness. */
|
|
@@ -29761,7 +29846,19 @@ Object.values(DeviceType), method(object({
|
|
|
29761
29846
|
kind: "mutation",
|
|
29762
29847
|
auth: "admin"
|
|
29763
29848
|
}), ConsumablesStatusSchema.extend({ lastFetchedAt: number() });
|
|
29764
|
-
|
|
29849
|
+
/**
|
|
29850
|
+
* Door / window / opening / garage / valve contact sensor. Boolean
|
|
29851
|
+
* "is the entry currently open" with the timestamp of the last
|
|
29852
|
+
* transition. Drives Home Assistant `binary_sensor` entries whose
|
|
29853
|
+
* `device_class` is `door`, `window`, `opening`, `garage`, or
|
|
29854
|
+
* `garage_door` — and any future native integration that needs
|
|
29855
|
+
* the same semantics.
|
|
29856
|
+
*
|
|
29857
|
+
* Push-driven: providers update the slice on transition events from
|
|
29858
|
+
* the upstream source (HA WebSocket `state_changed`, ZWave
|
|
29859
|
+
* `notification` …). Consumers read the slice; no polling.
|
|
29860
|
+
*/
|
|
29861
|
+
var ContactStatusSchema = object({
|
|
29765
29862
|
/** True when the entry is open; false when closed. */
|
|
29766
29863
|
entryOpen: boolean(),
|
|
29767
29864
|
/** Ms epoch of the last open↔closed transition. 0 if never observed. */
|
|
@@ -30583,7 +30680,15 @@ DeviceType.Humidifier, method(object({
|
|
|
30583
30680
|
kind: "mutation",
|
|
30584
30681
|
auth: "protected"
|
|
30585
30682
|
});
|
|
30586
|
-
|
|
30683
|
+
/**
|
|
30684
|
+
* Single-metric humidity reading. Drives Home Assistant `sensor`
|
|
30685
|
+
* entries with `device_class: humidity`.
|
|
30686
|
+
*
|
|
30687
|
+
* Unit normalisation: percent. The canonical display unit (`%`) is a
|
|
30688
|
+
* descriptor constant in the UI (ROLE_DESCRIPTOR), not stored in
|
|
30689
|
+
* `sourceInfo`.
|
|
30690
|
+
*/
|
|
30691
|
+
var HumiditySensorStatusSchema = object({
|
|
30587
30692
|
/** Current relative humidity, 0..100. */
|
|
30588
30693
|
percent: number().min(0).max(100),
|
|
30589
30694
|
/** Ms epoch when the slice was last updated. */
|
|
@@ -31390,7 +31495,7 @@ method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(obje
|
|
|
31390
31495
|
kind: "mutation",
|
|
31391
31496
|
auth: "admin"
|
|
31392
31497
|
});
|
|
31393
|
-
object({
|
|
31498
|
+
var LockControlStatusSchema = object({
|
|
31394
31499
|
/** Lifecycle state of the lock. `jammed` means the motor reported
|
|
31395
31500
|
* failure to reach the target — operator intervention required. */
|
|
31396
31501
|
state: _enum([
|
|
@@ -31728,7 +31833,7 @@ authKey: string().optional() }), object({
|
|
|
31728
31833
|
/** Human-readable error when `ok: false`. */
|
|
31729
31834
|
error: string().optional()
|
|
31730
31835
|
}), { kind: "mutation" });
|
|
31731
|
-
object({
|
|
31836
|
+
var MotionStatusSchema = object({
|
|
31732
31837
|
detected: boolean(),
|
|
31733
31838
|
/** Ms epoch of the last detected-true observation. Null if never detected. */
|
|
31734
31839
|
lastDetectedAt: number().nullable(),
|
|
@@ -33273,7 +33378,7 @@ var GpsLocationSchema = object({
|
|
|
33273
33378
|
/** Reported accuracy in meters (lower = better). */
|
|
33274
33379
|
accuracyMeters: number().nonnegative()
|
|
33275
33380
|
});
|
|
33276
|
-
object({
|
|
33381
|
+
var PresenceStatusSchema = object({
|
|
33277
33382
|
/** `home` / `not_home` / any user-defined zone name. */
|
|
33278
33383
|
state: string(),
|
|
33279
33384
|
/** Optional textual location label (zone name, city, address). Null
|
|
@@ -35695,7 +35800,14 @@ DeviceType.Camera, method(object({ deviceId: number() }), StreamParamsOptionsSch
|
|
|
35695
35800
|
kind: "mutation",
|
|
35696
35801
|
auth: "admin"
|
|
35697
35802
|
}), method(object({ deviceId: number() }), unknown().nullable());
|
|
35698
|
-
|
|
35803
|
+
/**
|
|
35804
|
+
* Generic on/off switch cap for accessory children (siren, floodlight,
|
|
35805
|
+
* spotlight, PIR toggle, chime silencer, autotrack enable, …). The cap
|
|
35806
|
+
* owns ONLY the boolean state — driver-specific settings (brightness,
|
|
35807
|
+
* sensitivity, schedule, duration) live on the device's own
|
|
35808
|
+
* `getSettingsUISchema()` surface.
|
|
35809
|
+
*/
|
|
35810
|
+
var SwitchStatusSchema = object({
|
|
35699
35811
|
on: boolean(),
|
|
35700
35812
|
/** Ms epoch of the last state change. Useful for UI "X minutes ago". */
|
|
35701
35813
|
lastChangedAt: number()
|
|
@@ -36236,7 +36348,23 @@ object({
|
|
|
36236
36348
|
lastChangedAt: number()
|
|
36237
36349
|
});
|
|
36238
36350
|
DeviceType.Sensor;
|
|
36239
|
-
|
|
36351
|
+
/**
|
|
36352
|
+
* Single-metric temperature reading. Drives Home Assistant `sensor`
|
|
36353
|
+
* entries with `device_class: temperature` and any future native
|
|
36354
|
+
* thermometer.
|
|
36355
|
+
*
|
|
36356
|
+
* Unit normalisation: providers convert to Celsius before storing.
|
|
36357
|
+
* The slice value is always Celsius so cross-cap aggregators
|
|
36358
|
+
* (climate-control's `currentTemp`, energy analytics) can compose
|
|
36359
|
+
* without per-source unit fixups. The canonical display unit (`°C`) is
|
|
36360
|
+
* a descriptor constant in the UI (ROLE_DESCRIPTOR), not stored in
|
|
36361
|
+
* `sourceInfo`.
|
|
36362
|
+
*
|
|
36363
|
+
* Status `lastFetchedAt` lets staleness-aware consumers detect a
|
|
36364
|
+
* frozen feed (provider hung) distinct from a "temperature hasn't
|
|
36365
|
+
* changed" steady state.
|
|
36366
|
+
*/
|
|
36367
|
+
var TemperatureSensorStatusSchema = object({
|
|
36240
36368
|
/** Current temperature in Celsius. */
|
|
36241
36369
|
celsius: number(),
|
|
36242
36370
|
/** Ms epoch when the slice was last updated (push or poll). */
|
|
@@ -37082,6 +37210,12 @@ Object.freeze({
|
|
|
37082
37210
|
addonId: null,
|
|
37083
37211
|
access: "view"
|
|
37084
37212
|
},
|
|
37213
|
+
"addons.listInstallations": {
|
|
37214
|
+
capName: "addons",
|
|
37215
|
+
capScope: "system",
|
|
37216
|
+
addonId: null,
|
|
37217
|
+
access: "view"
|
|
37218
|
+
},
|
|
37085
37219
|
"addons.listJobs": {
|
|
37086
37220
|
capName: "addons",
|
|
37087
37221
|
capScope: "system",
|
|
@@ -49171,6 +49305,76 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
|
|
|
49171
49305
|
DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
|
|
49172
49306
|
DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
|
|
49173
49307
|
DEFAULT_NATIVE_LEASE_SETTINGS.admission;
|
|
49308
|
+
/** Ancestor-walk bound — defence-in-depth against a corrupt parentage cycle. */
|
|
49309
|
+
var MAX_ANCESTOR_HOPS$1 = 8;
|
|
49310
|
+
/**
|
|
49311
|
+
* Does `selector` cover `device` DIRECTLY (no inheritance)? Inheritance is
|
|
49312
|
+
* layered on by the resolver/matcher, which tests this against a device's
|
|
49313
|
+
* ancestors too.
|
|
49314
|
+
*/
|
|
49315
|
+
function deviceSelectorMatches(selector, device) {
|
|
49316
|
+
switch (selector.kind) {
|
|
49317
|
+
case "all": return true;
|
|
49318
|
+
case "ids": return selector.ids.includes(device.id);
|
|
49319
|
+
case "types": return selector.types.some((t) => String(t) === device.type);
|
|
49320
|
+
case "locations": return device.location !== null && selector.locations.includes(device.location);
|
|
49321
|
+
}
|
|
49322
|
+
}
|
|
49323
|
+
/** Whether a `category:device` grant covers `access` (the broad lever). */
|
|
49324
|
+
function hasBroadDeviceGrant(scopes, access) {
|
|
49325
|
+
return scopes.some((s) => s.type === "category" && s.target === "device" && s.access.includes(access));
|
|
49326
|
+
}
|
|
49327
|
+
/** The `device`-typed scopes granting `access`. */
|
|
49328
|
+
function deviceScopesFor(scopes, access) {
|
|
49329
|
+
return scopes.filter((s) => s.type === "device" && s.access.includes(access));
|
|
49330
|
+
}
|
|
49331
|
+
/** Whether inheritance applies for a grant at `access` (view inherits, others
|
|
49332
|
+
* don't, unless the grant overrides via `includeLinked`). */
|
|
49333
|
+
function scopeInherits(scope, access) {
|
|
49334
|
+
return scope.includeLinked ?? access === "view";
|
|
49335
|
+
}
|
|
49336
|
+
/** Ancestors (parent, grandparent, …) of `deviceId` within a fleet map. */
|
|
49337
|
+
function ancestorsOf(deviceId, byId) {
|
|
49338
|
+
const out = [];
|
|
49339
|
+
let current = deviceId;
|
|
49340
|
+
for (let hop = 0; hop < MAX_ANCESTOR_HOPS$1; hop++) {
|
|
49341
|
+
const parent = current === null ? null : byId.get(current)?.parentDeviceId ?? null;
|
|
49342
|
+
if (parent === null || parent === current) break;
|
|
49343
|
+
out.push(parent);
|
|
49344
|
+
current = parent;
|
|
49345
|
+
}
|
|
49346
|
+
return out;
|
|
49347
|
+
}
|
|
49348
|
+
/**
|
|
49349
|
+
* Resolve the set of deviceIds the caller may reach at `access`, expanded
|
|
49350
|
+
* against the live `fleet`. A `category:device` grant returns the whole
|
|
49351
|
+
* fleet. Device selectors are matched per device — directly, or (when the
|
|
49352
|
+
* grant inherits) against an ancestor, so a parent grant covers its
|
|
49353
|
+
* accessories.
|
|
49354
|
+
*/
|
|
49355
|
+
function resolveViewableDeviceIds(scopes, fleet, access) {
|
|
49356
|
+
if (hasBroadDeviceGrant(scopes, access)) return new Set(fleet.map((d) => d.id));
|
|
49357
|
+
const byId = /* @__PURE__ */ new Map();
|
|
49358
|
+
for (const d of fleet) byId.set(d.id, d);
|
|
49359
|
+
const grants = deviceScopesFor(scopes, access);
|
|
49360
|
+
const out = /* @__PURE__ */ new Set();
|
|
49361
|
+
for (const d of fleet) for (const grant of grants) {
|
|
49362
|
+
if (deviceSelectorMatches(grant.selector, d)) {
|
|
49363
|
+
out.add(d.id);
|
|
49364
|
+
break;
|
|
49365
|
+
}
|
|
49366
|
+
if (scopeInherits(grant, access)) {
|
|
49367
|
+
if (ancestorsOf(d.id, byId).some((ancestorId) => {
|
|
49368
|
+
const ancestor = byId.get(ancestorId);
|
|
49369
|
+
return ancestor !== void 0 && deviceSelectorMatches(grant.selector, ancestor);
|
|
49370
|
+
})) {
|
|
49371
|
+
out.add(d.id);
|
|
49372
|
+
break;
|
|
49373
|
+
}
|
|
49374
|
+
}
|
|
49375
|
+
}
|
|
49376
|
+
return out;
|
|
49377
|
+
}
|
|
49174
49378
|
var MB = 1024 * 1024;
|
|
49175
49379
|
1024 * MB, 3072 * MB;
|
|
49176
49380
|
//#endregion
|
|
@@ -49191,17 +49395,29 @@ var MB = 1024 * 1024;
|
|
|
49191
49395
|
* `/addon/export-alexa/admin/redetect-hub-url` route.
|
|
49192
49396
|
*/
|
|
49193
49397
|
var exportAlexaActions = defineCustomActions({
|
|
49194
|
-
redetectHubUrl: customAction(object({}).optional(), object({ publicHubUrl: string() }), {
|
|
49195
|
-
|
|
49398
|
+
redetectHubUrl: customAction(object({}).optional(), object({ publicHubUrl: string() }), {
|
|
49399
|
+
kind: "mutation",
|
|
49400
|
+
auth: "admin"
|
|
49401
|
+
}),
|
|
49402
|
+
regenerateIngressToken: customAction(object({}).optional(), object({ ingressToken: string() }), {
|
|
49403
|
+
kind: "mutation",
|
|
49404
|
+
auth: "admin"
|
|
49405
|
+
}),
|
|
49196
49406
|
resyncDiscovery: customAction(object({}).optional(), object({
|
|
49197
49407
|
endpointCount: number(),
|
|
49198
49408
|
accountCount: number()
|
|
49199
|
-
}), {
|
|
49409
|
+
}), {
|
|
49410
|
+
kind: "mutation",
|
|
49411
|
+
auth: "admin"
|
|
49412
|
+
}),
|
|
49200
49413
|
purgeStaleEndpoints: customAction(object({}).optional(), object({
|
|
49201
49414
|
requested: number(),
|
|
49202
49415
|
accountCount: number(),
|
|
49203
49416
|
attemptedAccounts: number()
|
|
49204
|
-
}), {
|
|
49417
|
+
}), {
|
|
49418
|
+
kind: "mutation",
|
|
49419
|
+
auth: "admin"
|
|
49420
|
+
}),
|
|
49205
49421
|
/**
|
|
49206
49422
|
* Sever the Amazon account link from this side — the only severance path
|
|
49207
49423
|
* that exists, because the descriptor sets `refreshTokenTtlSec: 'never'`
|
|
@@ -49218,16 +49434,31 @@ var exportAlexaActions = defineCustomActions({
|
|
|
49218
49434
|
unlinkAlexaAccounts: customAction(object({}).optional(), object({
|
|
49219
49435
|
revokedSessions: number(),
|
|
49220
49436
|
removedAccounts: number()
|
|
49221
|
-
}), {
|
|
49437
|
+
}), {
|
|
49438
|
+
kind: "mutation",
|
|
49439
|
+
auth: "admin"
|
|
49440
|
+
}),
|
|
49222
49441
|
/**
|
|
49223
49442
|
* Swap the per-link credential this hub presents to the skill Lambda's
|
|
49224
49443
|
* token broker (D753) for a fresh one; the old one dies at once. Rotation
|
|
49225
49444
|
* also happens implicitly on every re-link.
|
|
49226
49445
|
*/
|
|
49446
|
+
/**
|
|
49447
|
+
* The ingress token, in clear, for an administrator who asked for it. The
|
|
49448
|
+
* one read surface that returns it: `deviceExport.getStatus` and the
|
|
49449
|
+
* settings form carry only a mask.
|
|
49450
|
+
*/
|
|
49451
|
+
revealIngressToken: customAction(object({}).optional(), object({ ingressToken: string() }), {
|
|
49452
|
+
kind: "mutation",
|
|
49453
|
+
auth: "admin"
|
|
49454
|
+
}),
|
|
49227
49455
|
rotateBrokerCredential: customAction(object({}).optional(), object({
|
|
49228
49456
|
rotated: number(),
|
|
49229
49457
|
failed: number()
|
|
49230
|
-
}), {
|
|
49458
|
+
}), {
|
|
49459
|
+
kind: "mutation",
|
|
49460
|
+
auth: "admin"
|
|
49461
|
+
})
|
|
49231
49462
|
});
|
|
49232
49463
|
//#endregion
|
|
49233
49464
|
//#region src/egress/alexa-rtc-egress.ts
|
|
@@ -49367,6 +49598,21 @@ async function profileForCamStream(input) {
|
|
|
49367
49598
|
}
|
|
49368
49599
|
//#endregion
|
|
49369
49600
|
//#region src/handlers/endpoint-id.ts
|
|
49601
|
+
/** The classes a camera publishes a detection sensor for (R1). */
|
|
49602
|
+
var DETECTION_SENSOR_CLASSES = [
|
|
49603
|
+
"person",
|
|
49604
|
+
"vehicle",
|
|
49605
|
+
"animal",
|
|
49606
|
+
"package"
|
|
49607
|
+
];
|
|
49608
|
+
var DETECTION_PREFIX = "det_";
|
|
49609
|
+
function isDetectionSensorClass(value) {
|
|
49610
|
+
return DETECTION_SENSOR_CLASSES.includes(value);
|
|
49611
|
+
}
|
|
49612
|
+
/** The endpoint id of a camera's detection sensor for one class. */
|
|
49613
|
+
function detectionSensorEndpointId(deviceId, cls) {
|
|
49614
|
+
return `${deviceId}${SEPARATOR}${DETECTION_PREFIX}${cls}`;
|
|
49615
|
+
}
|
|
49370
49616
|
var SEPARATOR = "__";
|
|
49371
49617
|
function parseCameraEndpointId(raw) {
|
|
49372
49618
|
if (!raw.includes(SEPARATOR)) {
|
|
@@ -49402,6 +49648,22 @@ function parseCameraEndpointId(raw) {
|
|
|
49402
49648
|
kind: "alarm-panel",
|
|
49403
49649
|
deviceId: parent
|
|
49404
49650
|
};
|
|
49651
|
+
if (tail === "dev") return {
|
|
49652
|
+
kind: "generic",
|
|
49653
|
+
deviceId: parent
|
|
49654
|
+
};
|
|
49655
|
+
if (tail.startsWith(DETECTION_PREFIX)) {
|
|
49656
|
+
const cls = tail.slice(4);
|
|
49657
|
+
if (isDetectionSensorClass(cls)) return {
|
|
49658
|
+
kind: "detection",
|
|
49659
|
+
deviceId: parent,
|
|
49660
|
+
detectionClass: cls
|
|
49661
|
+
};
|
|
49662
|
+
return {
|
|
49663
|
+
kind: "invalid",
|
|
49664
|
+
reason: `unknown detection class in endpointId: ${raw}`
|
|
49665
|
+
};
|
|
49666
|
+
}
|
|
49405
49667
|
const child = parseNonNegativeInt(tail);
|
|
49406
49668
|
if (child === null) return {
|
|
49407
49669
|
kind: "invalid",
|
|
@@ -49583,7 +49845,12 @@ function resolveWebrtcTarget(pref) {
|
|
|
49583
49845
|
camStreamId: pref
|
|
49584
49846
|
};
|
|
49585
49847
|
}
|
|
49586
|
-
|
|
49848
|
+
/** The cap whose push-talk triple receives the Echo's microphone. */
|
|
49849
|
+
var CAP_INTERCOM$1 = "intercom";
|
|
49850
|
+
async function deviceHasIntercom(api, deviceId) {
|
|
49851
|
+
return (await api.deviceManager.getBindings.query({ deviceId })).entries.some((e) => e.capName === CAP_INTERCOM$1);
|
|
49852
|
+
}
|
|
49853
|
+
async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, deps, _callerIdentity, grant = { mayTalk: false }) {
|
|
49587
49854
|
const parsed = InitiateSessionWithOfferDirectiveSchema.safeParse({
|
|
49588
49855
|
endpoint: endpointRaw,
|
|
49589
49856
|
payload: payloadRaw ?? {}
|
|
@@ -49692,12 +49959,21 @@ async function handleInitiateSessionWithOffer(header, endpointRaw, payloadRaw, d
|
|
|
49692
49959
|
log.warn("alexa rtc-session: egress acquire failed, falling back to streamPreference target", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
49693
49960
|
derivedTarget = resolveWebrtcTarget(exposedEntry?.settings?.streamPreference ?? "auto");
|
|
49694
49961
|
}
|
|
49962
|
+
const talkback = grant.mayTalk && await deviceHasIntercom(api, deviceId).catch((err) => {
|
|
49963
|
+
log.warn("alexa rtc-session: intercom lookup failed — this session is one-way audio", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
49964
|
+
return false;
|
|
49965
|
+
});
|
|
49966
|
+
log.info("alexa rtc-session: talk-back decision", { meta: {
|
|
49967
|
+
talkback,
|
|
49968
|
+
mayTalk: grant.mayTalk
|
|
49969
|
+
} });
|
|
49695
49970
|
try {
|
|
49696
49971
|
const result = await api.webrtcSession.handleOffer.mutate({
|
|
49697
49972
|
deviceId,
|
|
49698
49973
|
sdpOffer,
|
|
49699
49974
|
sessionId,
|
|
49700
49975
|
target: derivedTarget,
|
|
49976
|
+
talkback,
|
|
49701
49977
|
relayOnly: false,
|
|
49702
49978
|
nonTrickle: true,
|
|
49703
49979
|
disableTurn: true,
|
|
@@ -49952,6 +50228,111 @@ async function rotateBrokerCredential(fetchImpl, brokerUrl, credential) {
|
|
|
49952
50228
|
async function revokeBrokerCredential(fetchImpl, brokerUrl, credential) {
|
|
49953
50229
|
await call(fetchImpl, brokerUrl, "/broker/revoke", { bearer: credential }, unknown());
|
|
49954
50230
|
}
|
|
50231
|
+
/** Device types this export may publish as a generic endpoint. */
|
|
50232
|
+
var GENERIC_DEVICE_TYPES = [
|
|
50233
|
+
"light",
|
|
50234
|
+
"switch",
|
|
50235
|
+
"siren",
|
|
50236
|
+
"sensor",
|
|
50237
|
+
"lock",
|
|
50238
|
+
"presence",
|
|
50239
|
+
"thermostat",
|
|
50240
|
+
"generic"
|
|
50241
|
+
];
|
|
50242
|
+
function iface(name, property) {
|
|
50243
|
+
return {
|
|
50244
|
+
type: "AlexaInterface",
|
|
50245
|
+
interface: name,
|
|
50246
|
+
version: "3",
|
|
50247
|
+
properties: {
|
|
50248
|
+
supported: [{ name: property }],
|
|
50249
|
+
proactivelyReported: true,
|
|
50250
|
+
retrievable: true
|
|
50251
|
+
}
|
|
50252
|
+
};
|
|
50253
|
+
}
|
|
50254
|
+
var BASE = {
|
|
50255
|
+
type: "AlexaInterface",
|
|
50256
|
+
interface: "Alexa",
|
|
50257
|
+
version: "3"
|
|
50258
|
+
};
|
|
50259
|
+
var HEALTH = {
|
|
50260
|
+
type: "AlexaInterface",
|
|
50261
|
+
interface: "Alexa.EndpointHealth",
|
|
50262
|
+
version: "3.1",
|
|
50263
|
+
properties: {
|
|
50264
|
+
supported: [{ name: "connectivity" }],
|
|
50265
|
+
proactivelyReported: true,
|
|
50266
|
+
retrievable: true
|
|
50267
|
+
}
|
|
50268
|
+
};
|
|
50269
|
+
var ROWS = [
|
|
50270
|
+
{
|
|
50271
|
+
cap: "lock-control",
|
|
50272
|
+
capability: iface("Alexa.LockController", "lockState"),
|
|
50273
|
+
category: "SMARTLOCK"
|
|
50274
|
+
},
|
|
50275
|
+
{
|
|
50276
|
+
cap: "contact",
|
|
50277
|
+
capability: iface("Alexa.ContactSensor", "detectionState"),
|
|
50278
|
+
category: "CONTACT_SENSOR"
|
|
50279
|
+
},
|
|
50280
|
+
{
|
|
50281
|
+
cap: "motion",
|
|
50282
|
+
capability: iface("Alexa.MotionSensor", "detectionState"),
|
|
50283
|
+
category: "MOTION_SENSOR"
|
|
50284
|
+
},
|
|
50285
|
+
{
|
|
50286
|
+
cap: "presence",
|
|
50287
|
+
capability: iface("Alexa.MotionSensor", "detectionState"),
|
|
50288
|
+
category: "MOTION_SENSOR"
|
|
50289
|
+
},
|
|
50290
|
+
{
|
|
50291
|
+
cap: "temperature-sensor",
|
|
50292
|
+
capability: iface("Alexa.TemperatureSensor", "temperature"),
|
|
50293
|
+
category: "TEMPERATURE_SENSOR"
|
|
50294
|
+
},
|
|
50295
|
+
{
|
|
50296
|
+
cap: "humidity-sensor",
|
|
50297
|
+
capability: iface("Alexa.HumiditySensor", "relativeHumidity"),
|
|
50298
|
+
category: "TEMPERATURE_SENSOR"
|
|
50299
|
+
},
|
|
50300
|
+
{
|
|
50301
|
+
cap: "switch",
|
|
50302
|
+
capability: iface("Alexa.PowerController", "powerState"),
|
|
50303
|
+
category: "SWITCH"
|
|
50304
|
+
}
|
|
50305
|
+
];
|
|
50306
|
+
var BRIGHTNESS = iface("Alexa.BrightnessController", "brightness");
|
|
50307
|
+
/** The endpoint for a non-camera device, or null when none of its caps has an
|
|
50308
|
+
* Alexa interface. */
|
|
50309
|
+
function buildGenericDeviceEndpoint(input) {
|
|
50310
|
+
const capabilities = [BASE];
|
|
50311
|
+
const seen = /* @__PURE__ */ new Set();
|
|
50312
|
+
let category = null;
|
|
50313
|
+
for (const row of ROWS) {
|
|
50314
|
+
if (!input.capNames.includes(row.cap)) continue;
|
|
50315
|
+
if (seen.has(row.capability.interface)) continue;
|
|
50316
|
+
seen.add(row.capability.interface);
|
|
50317
|
+
capabilities.push(row.capability);
|
|
50318
|
+
category ??= row.category;
|
|
50319
|
+
}
|
|
50320
|
+
if (category === null) return null;
|
|
50321
|
+
const dimmable = input.capNames.includes("switch") && input.capNames.includes("brightness");
|
|
50322
|
+
if (dimmable) capabilities.push(BRIGHTNESS);
|
|
50323
|
+
if (category === "SWITCH" && (dimmable || input.type === "light")) category = "LIGHT";
|
|
50324
|
+
capabilities.push(HEALTH);
|
|
50325
|
+
const name = input.name.trim();
|
|
50326
|
+
return {
|
|
50327
|
+
endpointId: `${input.deviceId}__dev`,
|
|
50328
|
+
manufacturerName: "camstack",
|
|
50329
|
+
description: `CamStack ${input.type || "device"}`,
|
|
50330
|
+
friendlyName: name.length > 0 ? name : `Device ${input.deviceId}`,
|
|
50331
|
+
displayCategories: [category],
|
|
50332
|
+
capabilities,
|
|
50333
|
+
cookie: { stableId: input.stableId }
|
|
50334
|
+
};
|
|
50335
|
+
}
|
|
49955
50336
|
//#endregion
|
|
49956
50337
|
//#region src/handlers/security-panel-arm-state.ts
|
|
49957
50338
|
/**
|
|
@@ -50053,6 +50434,34 @@ var CAMERA_STREAM_CONTROLLER = {
|
|
|
50053
50434
|
audioCodecs: ["AAC"]
|
|
50054
50435
|
}]
|
|
50055
50436
|
};
|
|
50437
|
+
/**
|
|
50438
|
+
* `Alexa.EndpointHealth` v3.1 — `connectivity`, retrievable and proactive.
|
|
50439
|
+
*
|
|
50440
|
+
* Required by Amazon on every sensor endpoint (a camera with MotionSensor is
|
|
50441
|
+
* one) and for live view it is load-bearing: the first time it was declared,
|
|
50442
|
+
* the hub only ever reported a TRANSITION and ReportState never answered it,
|
|
50443
|
+
* so Alexa held no value — and the phone app refused live view on an endpoint
|
|
50444
|
+
* of unknown health while an Echo Show kept streaming. It is declared ONLY
|
|
50445
|
+
* together with what keeps a value in Alexa's hands (D770):
|
|
50446
|
+
*
|
|
50447
|
+
* - `handlers/report-state.ts` answers `connectivity` for every endpoint
|
|
50448
|
+
* that declares it (`OK` when the status read itself fails);
|
|
50449
|
+
* - `LwaEventGateway.scheduleInitialConnectivity` reports it after every
|
|
50450
|
+
* AddOrUpdateReport and every Discover;
|
|
50451
|
+
* - the gateway's online/offline ChangeReports keep it current.
|
|
50452
|
+
*
|
|
50453
|
+
* `endpoint-health.spec.ts` pins all three.
|
|
50454
|
+
*/
|
|
50455
|
+
var ENDPOINT_HEALTH = {
|
|
50456
|
+
type: "AlexaInterface",
|
|
50457
|
+
interface: "Alexa.EndpointHealth",
|
|
50458
|
+
version: "3.1",
|
|
50459
|
+
properties: {
|
|
50460
|
+
supported: [{ name: "connectivity" }],
|
|
50461
|
+
proactivelyReported: true,
|
|
50462
|
+
retrievable: true
|
|
50463
|
+
}
|
|
50464
|
+
};
|
|
50056
50465
|
var DOORBELL_EVENT_SOURCE = {
|
|
50057
50466
|
type: "AlexaInterface",
|
|
50058
50467
|
interface: "Alexa.DoorbellEventSource",
|
|
@@ -50069,6 +50478,26 @@ var MOTION_SENSOR = {
|
|
|
50069
50478
|
retrievable: true
|
|
50070
50479
|
}
|
|
50071
50480
|
};
|
|
50481
|
+
/**
|
|
50482
|
+
* `Alexa.SmartVision.SnapshotProvider` v1.1 — the camera's still in the Alexa
|
|
50483
|
+
* app (R2, D773). Available in it-IT and en-GB, unlike object detection.
|
|
50484
|
+
* Declared on every camera while the integration's "Snapshots" switch is on;
|
|
50485
|
+
* `GetSnapshot` answers with a signed, ten-minute, account-token-checked image
|
|
50486
|
+
* on the hub's public address (`handlers/snapshot-provider.ts`).
|
|
50487
|
+
*
|
|
50488
|
+
* `minRefreshIntervalInSeconds` bounds how often Alexa may ask per camera:
|
|
50489
|
+
* every ask is a snapshot read and a re-encode on the hub, and a picture of the
|
|
50490
|
+
* home handed to Amazon, so the tile refreshes at most every few minutes.
|
|
50491
|
+
*/
|
|
50492
|
+
var SMART_VISION_SNAPSHOT_PROVIDER = {
|
|
50493
|
+
type: "AlexaInterface",
|
|
50494
|
+
interface: "Alexa.SmartVision.SnapshotProvider",
|
|
50495
|
+
version: "1.1",
|
|
50496
|
+
configuration: {
|
|
50497
|
+
isAvailable: true,
|
|
50498
|
+
minRefreshIntervalInSeconds: 180
|
|
50499
|
+
}
|
|
50500
|
+
};
|
|
50072
50501
|
function rtcSessionController(fullDuplexAudio) {
|
|
50073
50502
|
return {
|
|
50074
50503
|
type: "AlexaInterface",
|
|
@@ -50077,13 +50506,15 @@ function rtcSessionController(fullDuplexAudio) {
|
|
|
50077
50506
|
configuration: { isFullDuplexAudioSupported: fullDuplexAudio }
|
|
50078
50507
|
};
|
|
50079
50508
|
}
|
|
50509
|
+
/** Proactively reported (R3, D776): `StateChangeReporter` pushes every
|
|
50510
|
+
* `switch` / `privacy-mask` / `ptz-autotrack` / `brightness` change. */
|
|
50080
50511
|
var POWER_CONTROLLER = {
|
|
50081
50512
|
type: "AlexaInterface",
|
|
50082
50513
|
interface: "Alexa.PowerController",
|
|
50083
50514
|
version: "3",
|
|
50084
50515
|
properties: {
|
|
50085
50516
|
supported: [{ name: "powerState" }],
|
|
50086
|
-
proactivelyReported:
|
|
50517
|
+
proactivelyReported: true,
|
|
50087
50518
|
retrievable: true
|
|
50088
50519
|
}
|
|
50089
50520
|
};
|
|
@@ -50093,16 +50524,6 @@ var BRIGHTNESS_CONTROLLER = {
|
|
|
50093
50524
|
version: "3",
|
|
50094
50525
|
properties: {
|
|
50095
50526
|
supported: [{ name: "brightness" }],
|
|
50096
|
-
proactivelyReported: false,
|
|
50097
|
-
retrievable: true
|
|
50098
|
-
}
|
|
50099
|
-
};
|
|
50100
|
-
var BATTERY_LEVEL_SENSOR = {
|
|
50101
|
-
type: "AlexaInterface",
|
|
50102
|
-
interface: "Alexa.BatteryLevelSensor",
|
|
50103
|
-
version: "3",
|
|
50104
|
-
properties: {
|
|
50105
|
-
supported: [{ name: "batteryLevel" }],
|
|
50106
50527
|
proactivelyReported: true,
|
|
50107
50528
|
retrievable: true
|
|
50108
50529
|
}
|
|
@@ -50275,11 +50696,12 @@ function buildParentCameraEndpoint(input) {
|
|
|
50275
50696
|
const capabilities = [
|
|
50276
50697
|
ALEXA_BASE_CAPABILITY,
|
|
50277
50698
|
CAMERA_STREAM_CONTROLLER,
|
|
50278
|
-
rtcSessionController(input.hasIntercom)
|
|
50699
|
+
rtcSessionController(input.hasIntercom),
|
|
50700
|
+
ENDPOINT_HEALTH
|
|
50279
50701
|
];
|
|
50280
50702
|
if (input.isDoorbell) capabilities.push(DOORBELL_EVENT_SOURCE);
|
|
50281
50703
|
if (input.hasMotion) capabilities.push(MOTION_SENSOR);
|
|
50282
|
-
if (input.
|
|
50704
|
+
if (input.snapshotProvider === true) capabilities.push(SMART_VISION_SNAPSHOT_PROVIDER);
|
|
50283
50705
|
const presetNames = distinctPresetNames(input.ptzPresetNames);
|
|
50284
50706
|
if (presetNames.length >= MIN_MODE_CONTROLLER_MODES) capabilities.push(ptzPresetModeController(presetNames));
|
|
50285
50707
|
if (input.hasPtz) {
|
|
@@ -50386,10 +50808,10 @@ function buildPrivacyEndpoint(input) {
|
|
|
50386
50808
|
* declared: `alarm-panel` has no field saying WHICH alarm is sounding, and
|
|
50387
50809
|
* declaring a property we would always answer `OK` on is a claim with no
|
|
50388
50810
|
* basis behind it. The reasoning is in `security-panel-arm-state.ts`.
|
|
50389
|
-
* - `proactivelyReported:
|
|
50390
|
-
*
|
|
50391
|
-
*
|
|
50392
|
-
*
|
|
50811
|
+
* - `proactivelyReported: true` (R3, D776). Amazon asks for alarms to be
|
|
50812
|
+
* reported proactively; `StateChangeReporter` pushes every `alarm-panel`
|
|
50813
|
+
* slice change (arm state and the burglary alarm). `retrievable: true` —
|
|
50814
|
+
* `Alexa/ReportState` answers too.
|
|
50393
50815
|
* - `supportedAuthorizationTypes` appears only for a panel that requires a
|
|
50394
50816
|
* code. Alexa's Disarm directive can carry a PIN; its **Arm directive
|
|
50395
50817
|
* cannot**, which is why a PIN-gated arm is refused by the handler with
|
|
@@ -50404,7 +50826,7 @@ function buildAlarmPanelEndpoint(input) {
|
|
|
50404
50826
|
version: "3",
|
|
50405
50827
|
properties: {
|
|
50406
50828
|
supported: [{ name: "armState" }, { name: "burglaryAlarm" }],
|
|
50407
|
-
proactivelyReported:
|
|
50829
|
+
proactivelyReported: true,
|
|
50408
50830
|
retrievable: true
|
|
50409
50831
|
},
|
|
50410
50832
|
configuration
|
|
@@ -50419,6 +50841,42 @@ function buildAlarmPanelEndpoint(input) {
|
|
|
50419
50841
|
cookie: { stableId: input.stableId }
|
|
50420
50842
|
};
|
|
50421
50843
|
}
|
|
50844
|
+
/** How a detection sensor is named after its camera. English, like every
|
|
50845
|
+
* other name this skill publishes; the operator can rename it in the app. */
|
|
50846
|
+
var DETECTION_SENSOR_LABELS = {
|
|
50847
|
+
person: "Person",
|
|
50848
|
+
vehicle: "Vehicle",
|
|
50849
|
+
animal: "Animal",
|
|
50850
|
+
package: "Package"
|
|
50851
|
+
};
|
|
50852
|
+
/**
|
|
50853
|
+
* One class of a camera's detections as an `Alexa.MotionSensor` endpoint —
|
|
50854
|
+
* "Front door Person" (R1, D772). It is what an it-IT / en-GB account can use
|
|
50855
|
+
* as a routine trigger ("when Front door Person detects motion → announce…"):
|
|
50856
|
+
* Amazon's object-detection interface is en-US only, MotionSensor is not.
|
|
50857
|
+
* Driven by the pipeline's tracks, not by the camera's own motion. Sensors
|
|
50858
|
+
* need `Alexa.EndpointHealth` (Amazon), reported like the camera's (D770).
|
|
50859
|
+
*/
|
|
50860
|
+
function buildDetectionSensorEndpoint(input) {
|
|
50861
|
+
const label = DETECTION_SENSOR_LABELS[input.detectionClass];
|
|
50862
|
+
const camera = input.cameraName.trim().length > 0 ? input.cameraName.trim() : `Camera ${input.deviceId}`;
|
|
50863
|
+
return {
|
|
50864
|
+
endpointId: detectionSensorEndpointId(input.deviceId, input.detectionClass),
|
|
50865
|
+
manufacturerName: "camstack",
|
|
50866
|
+
description: `CamStack ${input.detectionClass} detection`,
|
|
50867
|
+
friendlyName: `${camera} ${label}`,
|
|
50868
|
+
displayCategories: ["MOTION_SENSOR"],
|
|
50869
|
+
capabilities: [
|
|
50870
|
+
ALEXA_BASE_CAPABILITY,
|
|
50871
|
+
MOTION_SENSOR,
|
|
50872
|
+
ENDPOINT_HEALTH
|
|
50873
|
+
],
|
|
50874
|
+
cookie: {
|
|
50875
|
+
stableId: input.stableId,
|
|
50876
|
+
detectionClass: input.detectionClass
|
|
50877
|
+
}
|
|
50878
|
+
};
|
|
50879
|
+
}
|
|
50422
50880
|
//#endregion
|
|
50423
50881
|
//#region src/handlers/ptz-preset-recallable.ts
|
|
50424
50882
|
var RECALLABLE_FUNCTIONS = new Set(["home"]);
|
|
@@ -50438,12 +50896,13 @@ var CAP_PTZ = "ptz";
|
|
|
50438
50896
|
var CAP_PTZ_AUTOTRACK = "ptz-autotrack";
|
|
50439
50897
|
var CAP_SWITCH = "switch";
|
|
50440
50898
|
var CAP_BRIGHTNESS = "brightness";
|
|
50441
|
-
var CAP_BATTERY = "battery";
|
|
50442
50899
|
var CAP_PRIVACY_MASK = "privacy-mask";
|
|
50443
50900
|
var CAP_ALARM_PANEL = "alarm-panel";
|
|
50444
|
-
async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
|
|
50901
|
+
async function handleDiscovery(header, payloadRaw, deps, callerIdentity, visibility) {
|
|
50445
50902
|
if (!discoveryPayloadSchema.safeParse(payloadRaw).success) return errorEnvelope(header, "INVALID_DIRECTIVE", "Discovery payload shape unexpected");
|
|
50446
|
-
const
|
|
50903
|
+
const allExposed = await deps.listExposed();
|
|
50904
|
+
const exposed = [];
|
|
50905
|
+
for (const entry of allExposed) if (await visibility.deviceVisible(entry.deviceId)) exposed.push(entry);
|
|
50447
50906
|
const built = new Array(exposed.length).fill(null);
|
|
50448
50907
|
const queue = exposed.map((entry, index) => ({
|
|
50449
50908
|
entry,
|
|
@@ -50454,7 +50913,7 @@ async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
|
|
|
50454
50913
|
const item = queue.shift();
|
|
50455
50914
|
if (item === void 0) return;
|
|
50456
50915
|
try {
|
|
50457
|
-
built[item.index] = await
|
|
50916
|
+
built[item.index] = await deps.buildEndpoints(item.entry.deviceId);
|
|
50458
50917
|
} catch (err) {
|
|
50459
50918
|
deps.logger.warn("alexa discovery (on-demand): skipping device — build failed", {
|
|
50460
50919
|
tags: { deviceId: item.entry.deviceId },
|
|
@@ -50465,10 +50924,12 @@ async function handleDiscovery(header, payloadRaw, deps, callerIdentity) {
|
|
|
50465
50924
|
};
|
|
50466
50925
|
const lanes = Math.min(DISCOVERY_BUILD_CONCURRENCY, Math.max(1, queue.length));
|
|
50467
50926
|
await Promise.all(Array.from({ length: lanes }, lane));
|
|
50468
|
-
const endpoints = built.flatMap((b) => b ?? []);
|
|
50927
|
+
const endpoints = await visibility.filterEndpoints(built.flatMap((b) => b ?? []));
|
|
50469
50928
|
deps.logger.info("alexa discovery returned endpoints", { meta: {
|
|
50470
50929
|
count: endpoints.length,
|
|
50471
|
-
userId: callerIdentity.userId
|
|
50930
|
+
userId: callerIdentity.userId,
|
|
50931
|
+
exposedDevices: allExposed.length,
|
|
50932
|
+
visibleDevices: exposed.length
|
|
50472
50933
|
} });
|
|
50473
50934
|
return discoveryResponse(header, endpoints);
|
|
50474
50935
|
}
|
|
@@ -50480,6 +50941,26 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
|
|
|
50480
50941
|
return [];
|
|
50481
50942
|
}
|
|
50482
50943
|
const parentInfo = await loadBoundDeviceInfo(device, api);
|
|
50944
|
+
if (GENERIC_DEVICE_TYPES.includes(String(device.type)) && !parentInfo.capNames.includes(CAP_ALARM_PANEL)) {
|
|
50945
|
+
const generic = buildGenericDeviceEndpoint({
|
|
50946
|
+
deviceId: parentInfo.id,
|
|
50947
|
+
stableId: parentInfo.stableId,
|
|
50948
|
+
name: parentInfo.name,
|
|
50949
|
+
type: String(device.type),
|
|
50950
|
+
capNames: parentInfo.capNames
|
|
50951
|
+
});
|
|
50952
|
+
if (generic === null) {
|
|
50953
|
+
deps.logger.warn("alexa discovery: device has no cap Alexa has an interface for — not published", {
|
|
50954
|
+
tags: { deviceId },
|
|
50955
|
+
meta: {
|
|
50956
|
+
type: device.type,
|
|
50957
|
+
capNames: parentInfo.capNames
|
|
50958
|
+
}
|
|
50959
|
+
});
|
|
50960
|
+
return [];
|
|
50961
|
+
}
|
|
50962
|
+
return [generic];
|
|
50963
|
+
}
|
|
50483
50964
|
if (parentInfo.capNames.includes(CAP_ALARM_PANEL)) return [await buildAlarmPanelEndpointFor(parentInfo, api, deps)];
|
|
50484
50965
|
const children = await api.deviceManager.getChildren.query({ parentDeviceId: deviceId }).catch((err) => {
|
|
50485
50966
|
deps.logger.warn("alexa discovery: getChildren failed", {
|
|
@@ -50495,7 +50976,6 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
|
|
|
50495
50976
|
const hasMotion = parentInfo.capNames.includes(CAP_MOTION);
|
|
50496
50977
|
const hasAutotrack = parentInfo.capNames.includes(CAP_PTZ_AUTOTRACK);
|
|
50497
50978
|
const hasPtz = parentInfo.capNames.includes(CAP_PTZ);
|
|
50498
|
-
const hasBattery = parentInfo.capNames.includes(CAP_BATTERY);
|
|
50499
50979
|
const hasPrivacy = parentInfo.capNames.includes(CAP_PRIVACY_MASK);
|
|
50500
50980
|
deps.logger.info("alexa discovery: built feature flags", {
|
|
50501
50981
|
tags: { deviceId: parentInfo.id },
|
|
@@ -50503,7 +50983,6 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
|
|
|
50503
50983
|
name: parentInfo.name,
|
|
50504
50984
|
capNames: parentInfo.capNames,
|
|
50505
50985
|
hasMotion,
|
|
50506
|
-
hasBattery,
|
|
50507
50986
|
hasPtz,
|
|
50508
50987
|
hasAutotrack,
|
|
50509
50988
|
hasPrivacy,
|
|
@@ -50528,9 +51007,18 @@ async function buildEndpointsForExposedDevice(deviceId, deps) {
|
|
|
50528
51007
|
hasIntercom,
|
|
50529
51008
|
hasMotion,
|
|
50530
51009
|
ptzPresetNames,
|
|
50531
|
-
|
|
50532
|
-
|
|
51010
|
+
hasPtz,
|
|
51011
|
+
snapshotProvider: deps.snapshots === true
|
|
50533
51012
|
}));
|
|
51013
|
+
if (deps.detectionClasses !== void 0) {
|
|
51014
|
+
const classes = await deps.detectionClasses(parentInfo.id);
|
|
51015
|
+
for (const detectionClass of classes) out.push(buildDetectionSensorEndpoint({
|
|
51016
|
+
deviceId: parentInfo.id,
|
|
51017
|
+
stableId: parentInfo.stableId,
|
|
51018
|
+
cameraName: parentInfo.name,
|
|
51019
|
+
detectionClass
|
|
51020
|
+
}));
|
|
51021
|
+
}
|
|
50534
51022
|
if (hasAutotrack) out.push(buildAutotrackEndpoint({
|
|
50535
51023
|
parentDeviceId: parentInfo.id,
|
|
50536
51024
|
parentStableId: parentInfo.stableId,
|
|
@@ -50633,6 +51121,476 @@ function discoveryResponse(header, endpoints) {
|
|
|
50633
51121
|
payload: { endpoints }
|
|
50634
51122
|
} };
|
|
50635
51123
|
}
|
|
51124
|
+
/** Ancestor-walk bound — defence against a corrupt parentage cycle. */
|
|
51125
|
+
var MAX_ANCESTOR_HOPS = 8;
|
|
51126
|
+
function principalFromGrant(grant) {
|
|
51127
|
+
return grant.isAdmin ? {
|
|
51128
|
+
kind: "admin",
|
|
51129
|
+
userId: grant.userId
|
|
51130
|
+
} : {
|
|
51131
|
+
kind: "scoped",
|
|
51132
|
+
userId: grant.userId,
|
|
51133
|
+
scopes: grant.scopes
|
|
51134
|
+
};
|
|
51135
|
+
}
|
|
51136
|
+
/**
|
|
51137
|
+
* Does `principal` reach `chain[0]` at `access`? `chain` is the device followed
|
|
51138
|
+
* by its ancestors (parent first), each with the metadata a selector needs.
|
|
51139
|
+
* Pure — the one derivation shared by directives, discovery and the event mirror.
|
|
51140
|
+
*/
|
|
51141
|
+
function principalAllowsDevice(principal, chain, access) {
|
|
51142
|
+
if (principal.kind === "admin") return true;
|
|
51143
|
+
const target = chain[0];
|
|
51144
|
+
if (target === void 0) return false;
|
|
51145
|
+
return resolveViewableDeviceIds(principal.scopes, chain, access).has(target.id);
|
|
51146
|
+
}
|
|
51147
|
+
function endpointAccessTarget(parsed) {
|
|
51148
|
+
switch (parsed.kind) {
|
|
51149
|
+
case "camera": return {
|
|
51150
|
+
exposedDeviceId: parsed.deviceId,
|
|
51151
|
+
deviceId: parsed.deviceId,
|
|
51152
|
+
requiresParent: null,
|
|
51153
|
+
ownAccess: "view"
|
|
51154
|
+
};
|
|
51155
|
+
case "privacy":
|
|
51156
|
+
case "autotrack": return {
|
|
51157
|
+
exposedDeviceId: parsed.parentDeviceId,
|
|
51158
|
+
deviceId: parsed.parentDeviceId,
|
|
51159
|
+
requiresParent: null,
|
|
51160
|
+
ownAccess: "create"
|
|
51161
|
+
};
|
|
51162
|
+
case "child": return {
|
|
51163
|
+
exposedDeviceId: parsed.parentDeviceId,
|
|
51164
|
+
deviceId: parsed.childDeviceId,
|
|
51165
|
+
requiresParent: parsed.parentDeviceId,
|
|
51166
|
+
ownAccess: "create"
|
|
51167
|
+
};
|
|
51168
|
+
case "alarm-panel":
|
|
51169
|
+
case "detection":
|
|
51170
|
+
case "generic": return {
|
|
51171
|
+
exposedDeviceId: parsed.deviceId,
|
|
51172
|
+
deviceId: parsed.deviceId,
|
|
51173
|
+
requiresParent: null,
|
|
51174
|
+
ownAccess: "view"
|
|
51175
|
+
};
|
|
51176
|
+
case "invalid": return null;
|
|
51177
|
+
}
|
|
51178
|
+
}
|
|
51179
|
+
var AlexaAccessGate = class {
|
|
51180
|
+
deps;
|
|
51181
|
+
grants = null;
|
|
51182
|
+
grantsFlight = null;
|
|
51183
|
+
meta = /* @__PURE__ */ new Map();
|
|
51184
|
+
warnedStale = false;
|
|
51185
|
+
constructor(deps) {
|
|
51186
|
+
this.deps = deps;
|
|
51187
|
+
}
|
|
51188
|
+
now() {
|
|
51189
|
+
return this.deps.now?.() ?? Date.now();
|
|
51190
|
+
}
|
|
51191
|
+
/** Resolve the principal a link with this `userId` acts for. */
|
|
51192
|
+
async principal(userId) {
|
|
51193
|
+
const snapshot = await this.readGrants();
|
|
51194
|
+
if (snapshot === null) return {
|
|
51195
|
+
kind: "unavailable",
|
|
51196
|
+
error: "the hub user store could not be read"
|
|
51197
|
+
};
|
|
51198
|
+
const grant = snapshot.byUserId.get(userId);
|
|
51199
|
+
if (grant === void 0) return { kind: "unknown-user" };
|
|
51200
|
+
return {
|
|
51201
|
+
kind: "found",
|
|
51202
|
+
principal: principalFromGrant(grant)
|
|
51203
|
+
};
|
|
51204
|
+
}
|
|
51205
|
+
/** Does `principal` reach `deviceId` at `access`? */
|
|
51206
|
+
async allows(principal, deviceId, access) {
|
|
51207
|
+
if (principal.kind === "admin") return "allowed";
|
|
51208
|
+
const chain = await this.chainOf(deviceId);
|
|
51209
|
+
if (chain === null) return "unknown";
|
|
51210
|
+
return principalAllowsDevice(principal, chain, access) ? "allowed" : "denied";
|
|
51211
|
+
}
|
|
51212
|
+
/** The device's parent per the registry; `undefined` when unknown. */
|
|
51213
|
+
async parentOf(deviceId) {
|
|
51214
|
+
const meta = await this.metaOf(deviceId);
|
|
51215
|
+
return meta === null ? void 0 : meta.parentDeviceId;
|
|
51216
|
+
}
|
|
51217
|
+
/** Forget everything cached — for tests and an operator-driven re-sync. */
|
|
51218
|
+
invalidate() {
|
|
51219
|
+
this.grants = null;
|
|
51220
|
+
this.meta.clear();
|
|
51221
|
+
}
|
|
51222
|
+
async readGrants() {
|
|
51223
|
+
const cached = this.grants;
|
|
51224
|
+
if (cached !== null && this.now() - cached.at < 3e4) return cached;
|
|
51225
|
+
if (this.grantsFlight === null) this.grantsFlight = this.deps.listUserGrants().then((list) => {
|
|
51226
|
+
const byUserId = /* @__PURE__ */ new Map();
|
|
51227
|
+
for (const g of list) byUserId.set(g.userId, g);
|
|
51228
|
+
const snapshot = {
|
|
51229
|
+
at: this.now(),
|
|
51230
|
+
byUserId
|
|
51231
|
+
};
|
|
51232
|
+
this.grants = snapshot;
|
|
51233
|
+
this.warnedStale = false;
|
|
51234
|
+
return snapshot;
|
|
51235
|
+
}).finally(() => {
|
|
51236
|
+
this.grantsFlight = null;
|
|
51237
|
+
});
|
|
51238
|
+
try {
|
|
51239
|
+
return await this.grantsFlight;
|
|
51240
|
+
} catch (err) {
|
|
51241
|
+
if (cached !== null) {
|
|
51242
|
+
if (!this.warnedStale) {
|
|
51243
|
+
this.warnedStale = true;
|
|
51244
|
+
this.deps.logger.warn("alexa access: the hub user store could not be re-read — keeping the last good grants", { meta: {
|
|
51245
|
+
error: errMsg(err),
|
|
51246
|
+
ageMs: this.now() - cached.at
|
|
51247
|
+
} });
|
|
51248
|
+
}
|
|
51249
|
+
return cached;
|
|
51250
|
+
}
|
|
51251
|
+
this.deps.logger.warn("alexa access: the hub user store could not be read — refusing Alexa access until it can", { meta: { error: errMsg(err) } });
|
|
51252
|
+
return null;
|
|
51253
|
+
}
|
|
51254
|
+
}
|
|
51255
|
+
async metaOf(deviceId) {
|
|
51256
|
+
const cached = this.meta.get(deviceId);
|
|
51257
|
+
if (cached !== void 0 && this.now() - cached.at < 6e4) return cached.meta;
|
|
51258
|
+
try {
|
|
51259
|
+
const meta = await this.deps.getDeviceMeta(deviceId);
|
|
51260
|
+
this.meta.set(deviceId, {
|
|
51261
|
+
at: this.now(),
|
|
51262
|
+
meta
|
|
51263
|
+
});
|
|
51264
|
+
return meta;
|
|
51265
|
+
} catch (err) {
|
|
51266
|
+
this.deps.logger.debug("alexa access: device metadata read failed", {
|
|
51267
|
+
tags: { deviceId },
|
|
51268
|
+
meta: { error: errMsg(err) }
|
|
51269
|
+
});
|
|
51270
|
+
return cached?.meta ?? null;
|
|
51271
|
+
}
|
|
51272
|
+
}
|
|
51273
|
+
/** The device followed by its ancestors, or `null` if the device is unknown.
|
|
51274
|
+
* An unknown ANCESTOR ends the walk — inheritance through it is simply not
|
|
51275
|
+
* granted (fail closed). */
|
|
51276
|
+
async chainOf(deviceId) {
|
|
51277
|
+
const self = await this.metaOf(deviceId);
|
|
51278
|
+
if (self === null) return null;
|
|
51279
|
+
const chain = [self];
|
|
51280
|
+
let parent = self.parentDeviceId;
|
|
51281
|
+
for (let hop = 0; hop < MAX_ANCESTOR_HOPS && parent !== null; hop++) {
|
|
51282
|
+
if (chain.some((d) => d.id === parent)) break;
|
|
51283
|
+
const meta = await this.metaOf(parent);
|
|
51284
|
+
if (meta === null) break;
|
|
51285
|
+
chain.push(meta);
|
|
51286
|
+
parent = meta.parentDeviceId;
|
|
51287
|
+
}
|
|
51288
|
+
return chain;
|
|
51289
|
+
}
|
|
51290
|
+
};
|
|
51291
|
+
//#endregion
|
|
51292
|
+
//#region src/connectivity.ts
|
|
51293
|
+
/** The one field read off the `device-status` slice (its wire output is
|
|
51294
|
+
* untyped here). */
|
|
51295
|
+
var OnlineSchema$1 = object({ online: boolean() });
|
|
51296
|
+
async function readConnectivity(api, deviceId, logger) {
|
|
51297
|
+
try {
|
|
51298
|
+
const parsed = OnlineSchema$1.safeParse(await api.deviceStatus.getStatus.query({ deviceId }));
|
|
51299
|
+
return parsed.success && !parsed.data.online ? "UNREACHABLE" : "OK";
|
|
51300
|
+
} catch (err) {
|
|
51301
|
+
logger.debug("alexa: device-status unreadable — reporting connectivity OK", {
|
|
51302
|
+
tags: { deviceId },
|
|
51303
|
+
meta: { error: errMsg(err) }
|
|
51304
|
+
});
|
|
51305
|
+
return "OK";
|
|
51306
|
+
}
|
|
51307
|
+
}
|
|
51308
|
+
//#endregion
|
|
51309
|
+
//#region src/endpoints/validate-endpoint.ts
|
|
51310
|
+
/**
|
|
51311
|
+
* Interfaces this skill may declare, with the version Amazon documents. An
|
|
51312
|
+
* interface missing here is either not in Amazon's list (`BatteryLevelSensor`
|
|
51313
|
+
* never was) or deprecated (`MediaMetadata`, `EventDetectionSensor`).
|
|
51314
|
+
*/
|
|
51315
|
+
var ALLOWED_INTERFACE_VERSIONS = {
|
|
51316
|
+
Alexa: "3",
|
|
51317
|
+
"Alexa.CameraStreamController": "3",
|
|
51318
|
+
"Alexa.RTCSessionController": "3",
|
|
51319
|
+
"Alexa.DoorbellEventSource": "3",
|
|
51320
|
+
"Alexa.MotionSensor": "3",
|
|
51321
|
+
"Alexa.ContactSensor": "3",
|
|
51322
|
+
"Alexa.TemperatureSensor": "3",
|
|
51323
|
+
"Alexa.HumiditySensor": "3",
|
|
51324
|
+
"Alexa.EndpointHealth": "3.1",
|
|
51325
|
+
"Alexa.PowerController": "3",
|
|
51326
|
+
"Alexa.BrightnessController": "3",
|
|
51327
|
+
"Alexa.ModeController": "3",
|
|
51328
|
+
"Alexa.ToggleController": "3",
|
|
51329
|
+
"Alexa.RangeController": "3",
|
|
51330
|
+
"Alexa.SecurityPanelController": "3",
|
|
51331
|
+
"Alexa.LockController": "3",
|
|
51332
|
+
"Alexa.SmartVision.SnapshotProvider": "1.1",
|
|
51333
|
+
"Alexa.SmartVision.ObjectDetectionSensor": "1.0"
|
|
51334
|
+
};
|
|
51335
|
+
/** Interfaces that need an `instance`. */
|
|
51336
|
+
var INSTANCED = new Set([
|
|
51337
|
+
"Alexa.ModeController",
|
|
51338
|
+
"Alexa.ToggleController",
|
|
51339
|
+
"Alexa.RangeController"
|
|
51340
|
+
]);
|
|
51341
|
+
/** Sensor interfaces Amazon requires to be paired with `Alexa.EndpointHealth`. */
|
|
51342
|
+
var NEEDS_ENDPOINT_HEALTH = new Set([
|
|
51343
|
+
"Alexa.MotionSensor",
|
|
51344
|
+
"Alexa.ContactSensor",
|
|
51345
|
+
"Alexa.TemperatureSensor",
|
|
51346
|
+
"Alexa.HumiditySensor",
|
|
51347
|
+
"Alexa.LockController",
|
|
51348
|
+
"Alexa.SmartVision.ObjectDetectionSensor"
|
|
51349
|
+
]);
|
|
51350
|
+
/** `displayCategories` values from alexa-discovery (the subset a camstack
|
|
51351
|
+
* device can be). */
|
|
51352
|
+
var DISPLAY_CATEGORIES = new Set([
|
|
51353
|
+
"CAMERA",
|
|
51354
|
+
"DOORBELL",
|
|
51355
|
+
"LIGHT",
|
|
51356
|
+
"SWITCH",
|
|
51357
|
+
"SMARTPLUG",
|
|
51358
|
+
"SECURITY_PANEL",
|
|
51359
|
+
"SECURITY_SYSTEM",
|
|
51360
|
+
"MOTION_SENSOR",
|
|
51361
|
+
"CONTACT_SENSOR",
|
|
51362
|
+
"TEMPERATURE_SENSOR",
|
|
51363
|
+
"SMARTLOCK",
|
|
51364
|
+
"DOOR",
|
|
51365
|
+
"GARAGE_DOOR",
|
|
51366
|
+
"OTHER"
|
|
51367
|
+
]);
|
|
51368
|
+
var ENDPOINT_ID_PATTERN = /^[A-Za-z0-9 _\-=#;:?@&]{1,256}$/;
|
|
51369
|
+
var MAX_FRIENDLY_NAME = 256;
|
|
51370
|
+
var MAX_DESCRIPTION = 128;
|
|
51371
|
+
var MAX_MANUFACTURER = 128;
|
|
51372
|
+
var MAX_COOKIE_BYTES = 5e3;
|
|
51373
|
+
var MAX_CAPABILITIES = 100;
|
|
51374
|
+
var MIN_MODES = 2;
|
|
51375
|
+
function capabilityKey$1(c) {
|
|
51376
|
+
return c.instance === void 0 ? c.interface : `${c.interface}#${c.instance}`;
|
|
51377
|
+
}
|
|
51378
|
+
function friendlyTexts(c) {
|
|
51379
|
+
return (c.capabilityResources?.friendlyNames ?? []).map((n) => n.value.text);
|
|
51380
|
+
}
|
|
51381
|
+
function validateCapability(endpointId, c, out) {
|
|
51382
|
+
const push = (rule) => {
|
|
51383
|
+
out.push({
|
|
51384
|
+
endpointId,
|
|
51385
|
+
rule: `${capabilityKey$1(c)}: ${rule}`
|
|
51386
|
+
});
|
|
51387
|
+
};
|
|
51388
|
+
if (c.type !== "AlexaInterface") push("type must be AlexaInterface");
|
|
51389
|
+
const version = ALLOWED_INTERFACE_VERSIONS[c.interface];
|
|
51390
|
+
if (version === void 0) {
|
|
51391
|
+
push("interface is not in Amazon's interface list (or is deprecated)");
|
|
51392
|
+
return;
|
|
51393
|
+
}
|
|
51394
|
+
if (c.version !== version) push(`version must be ${version}`);
|
|
51395
|
+
if (INSTANCED.has(c.interface)) {
|
|
51396
|
+
if (c.instance === void 0 || c.instance.length === 0) push("instance is required");
|
|
51397
|
+
if (friendlyTexts(c).length === 0) push("capabilityResources.friendlyNames is required");
|
|
51398
|
+
}
|
|
51399
|
+
if (c.properties !== void 0) {
|
|
51400
|
+
if (c.properties.supported.length === 0) push("properties.supported must not be empty");
|
|
51401
|
+
}
|
|
51402
|
+
if (c.interface === "Alexa.ModeController") {
|
|
51403
|
+
const modes = c.configuration?.["supportedModes"];
|
|
51404
|
+
if (!Array.isArray(modes) || modes.length < MIN_MODES) push(`at least ${MIN_MODES} supportedModes`);
|
|
51405
|
+
else {
|
|
51406
|
+
const values = modes.map((m) => typeof m === "object" && m !== null ? String(Reflect.get(m, "value")) : "");
|
|
51407
|
+
if (new Set(values).size !== values.length) push("supportedModes values must be unique");
|
|
51408
|
+
}
|
|
51409
|
+
}
|
|
51410
|
+
if (c.interface === "Alexa.SmartVision.SnapshotProvider") {
|
|
51411
|
+
if (typeof c.configuration?.["isAvailable"] !== "boolean") push("configuration.isAvailable is required");
|
|
51412
|
+
}
|
|
51413
|
+
}
|
|
51414
|
+
/** Every rule an endpoint breaks. Empty = Amazon should accept it. */
|
|
51415
|
+
function validateAlexaEndpoint(endpoint) {
|
|
51416
|
+
const out = [];
|
|
51417
|
+
const id = endpoint.endpointId;
|
|
51418
|
+
const push = (rule) => {
|
|
51419
|
+
out.push({
|
|
51420
|
+
endpointId: id,
|
|
51421
|
+
rule
|
|
51422
|
+
});
|
|
51423
|
+
};
|
|
51424
|
+
if (!ENDPOINT_ID_PATTERN.test(id)) push("endpointId: 1-256 of [A-Za-z0-9 _-=#;:?@&]");
|
|
51425
|
+
if (endpoint.friendlyName.trim().length === 0) push("friendlyName must not be empty");
|
|
51426
|
+
if (endpoint.friendlyName.length > MAX_FRIENDLY_NAME) push("friendlyName longer than 256");
|
|
51427
|
+
if (endpoint.description.length === 0 || endpoint.description.length > MAX_DESCRIPTION) push("description must be 1-128 characters");
|
|
51428
|
+
if (endpoint.manufacturerName.length === 0 || endpoint.manufacturerName.length > MAX_MANUFACTURER) push("manufacturerName must be 1-128 characters");
|
|
51429
|
+
if (endpoint.displayCategories.length === 0) push("displayCategories must not be empty");
|
|
51430
|
+
for (const cat of endpoint.displayCategories) if (!DISPLAY_CATEGORIES.has(cat)) push(`displayCategory ${cat} is not an Amazon value`);
|
|
51431
|
+
if (endpoint.cookie !== void 0) {
|
|
51432
|
+
if (new TextEncoder().encode(JSON.stringify(endpoint.cookie)).length > MAX_COOKIE_BYTES) push("cookie larger than 5000 bytes");
|
|
51433
|
+
}
|
|
51434
|
+
const caps = endpoint.capabilities;
|
|
51435
|
+
if (caps.length > MAX_CAPABILITIES) push("more than 100 capabilities");
|
|
51436
|
+
if (!caps.some((c) => c.interface === "Alexa" && c.version === "3")) push("the base Alexa interface (version 3) is required");
|
|
51437
|
+
const keys = caps.map(capabilityKey$1);
|
|
51438
|
+
if (new Set(keys).size !== keys.length) push("a capability (interface + instance) is declared twice");
|
|
51439
|
+
const interfaces = new Set(caps.map((c) => c.interface));
|
|
51440
|
+
for (const needs of NEEDS_ENDPOINT_HEALTH) if (interfaces.has(needs) && !interfaces.has("Alexa.EndpointHealth")) push(`${needs} requires Alexa.EndpointHealth`);
|
|
51441
|
+
if (interfaces.has("Alexa.RTCSessionController") && endpoint.displayCategories[0] !== "CAMERA") push("an RTCSessionController endpoint must list CAMERA first");
|
|
51442
|
+
for (const c of caps) validateCapability(id, c, out);
|
|
51443
|
+
return out;
|
|
51444
|
+
}
|
|
51445
|
+
/** Split endpoints into the valid ones and the violations of the rest. */
|
|
51446
|
+
function partitionValidEndpoints(endpoints) {
|
|
51447
|
+
const valid = [];
|
|
51448
|
+
const violations = [];
|
|
51449
|
+
for (const e of endpoints) {
|
|
51450
|
+
const found = validateAlexaEndpoint(e);
|
|
51451
|
+
if (found.length === 0) valid.push(e);
|
|
51452
|
+
else violations.push(...found);
|
|
51453
|
+
}
|
|
51454
|
+
return {
|
|
51455
|
+
valid,
|
|
51456
|
+
violations
|
|
51457
|
+
};
|
|
51458
|
+
}
|
|
51459
|
+
/**
|
|
51460
|
+
* The runtime gate: keep the valid endpoints, and say out loud which ones were
|
|
51461
|
+
* withheld and why. Sending an invalid one risks Alexa rejecting the report —
|
|
51462
|
+
* and with it every valid endpoint beside it.
|
|
51463
|
+
*/
|
|
51464
|
+
function withholdInvalidEndpoints(endpoints, warn) {
|
|
51465
|
+
const { valid, violations } = partitionValidEndpoints(endpoints);
|
|
51466
|
+
if (violations.length > 0) warn("alexa: endpoints WITHHELD — they break an Amazon discovery rule and could make Alexa reject the whole report", violations);
|
|
51467
|
+
return valid;
|
|
51468
|
+
}
|
|
51469
|
+
//#endregion
|
|
51470
|
+
//#region src/access/endpoint-access.ts
|
|
51471
|
+
/**
|
|
51472
|
+
* May `principal` reach `endpointId` at `access`? `access` defaults to the
|
|
51473
|
+
* endpoint's own discovery access (`view` for a camera, `create` for an
|
|
51474
|
+
* actuator-only endpoint).
|
|
51475
|
+
*/
|
|
51476
|
+
async function authorizeEndpoint(endpointId, principal, deps, access) {
|
|
51477
|
+
const target = endpointAccessTarget(parseCameraEndpointId(endpointId));
|
|
51478
|
+
if (target === null) return {
|
|
51479
|
+
allowed: false,
|
|
51480
|
+
refusal: { kind: "invalid-endpoint" }
|
|
51481
|
+
};
|
|
51482
|
+
if (!deps.isExposed(target.exposedDeviceId)) return {
|
|
51483
|
+
allowed: false,
|
|
51484
|
+
refusal: {
|
|
51485
|
+
kind: "not-exposed",
|
|
51486
|
+
deviceId: target.exposedDeviceId
|
|
51487
|
+
}
|
|
51488
|
+
};
|
|
51489
|
+
if (target.requiresParent !== null) {
|
|
51490
|
+
const parent = await deps.gate.parentOf(target.deviceId);
|
|
51491
|
+
if (parent === void 0) return {
|
|
51492
|
+
allowed: false,
|
|
51493
|
+
refusal: {
|
|
51494
|
+
kind: "device-unknown",
|
|
51495
|
+
deviceId: target.deviceId
|
|
51496
|
+
}
|
|
51497
|
+
};
|
|
51498
|
+
if (parent !== target.requiresParent) return {
|
|
51499
|
+
allowed: false,
|
|
51500
|
+
refusal: {
|
|
51501
|
+
kind: "not-a-child",
|
|
51502
|
+
deviceId: target.deviceId,
|
|
51503
|
+
parentDeviceId: target.requiresParent
|
|
51504
|
+
}
|
|
51505
|
+
};
|
|
51506
|
+
}
|
|
51507
|
+
const needed = access ?? target.ownAccess;
|
|
51508
|
+
const verdict = await deps.gate.allows(principal, target.deviceId, needed);
|
|
51509
|
+
if (verdict === "allowed") return { allowed: true };
|
|
51510
|
+
if (verdict === "unknown") return {
|
|
51511
|
+
allowed: false,
|
|
51512
|
+
refusal: {
|
|
51513
|
+
kind: "device-unknown",
|
|
51514
|
+
deviceId: target.deviceId
|
|
51515
|
+
}
|
|
51516
|
+
};
|
|
51517
|
+
return {
|
|
51518
|
+
allowed: false,
|
|
51519
|
+
refusal: {
|
|
51520
|
+
kind: "not-granted",
|
|
51521
|
+
deviceId: target.deviceId,
|
|
51522
|
+
access: needed
|
|
51523
|
+
}
|
|
51524
|
+
};
|
|
51525
|
+
}
|
|
51526
|
+
/**
|
|
51527
|
+
* Interfaces on a VIEWABLE endpoint that only actuate. A user who may see a
|
|
51528
|
+
* camera but not control it is shown the camera without them, so the Alexa app
|
|
51529
|
+
* never offers a PTZ dropdown whose every use would be refused.
|
|
51530
|
+
*/
|
|
51531
|
+
var CONTROL_ONLY_INTERFACES = new Set(["Alexa.ModeController", "Alexa.ToggleController"]);
|
|
51532
|
+
/** Strip what a view-only principal may not drive. Two-way audio is driving
|
|
51533
|
+
* the camera's speaker, so it goes too. Returns a NEW endpoint. */
|
|
51534
|
+
function viewOnlyShape(endpoint) {
|
|
51535
|
+
return {
|
|
51536
|
+
...endpoint,
|
|
51537
|
+
capabilities: endpoint.capabilities.filter((c) => !CONTROL_ONLY_INTERFACES.has(c.interface)).map((c) => c.interface === "Alexa.RTCSessionController" ? {
|
|
51538
|
+
...c,
|
|
51539
|
+
configuration: {
|
|
51540
|
+
...c.configuration,
|
|
51541
|
+
isFullDuplexAudioSupported: false
|
|
51542
|
+
}
|
|
51543
|
+
} : c)
|
|
51544
|
+
};
|
|
51545
|
+
}
|
|
51546
|
+
/** Keep the endpoints `principal` may be shown, shaped to what it may drive.
|
|
51547
|
+
* Order is preserved. */
|
|
51548
|
+
async function filterEndpointsForPrincipal(endpoints, principal, deps) {
|
|
51549
|
+
const kept = [];
|
|
51550
|
+
const refused = [];
|
|
51551
|
+
for (const endpoint of endpoints) {
|
|
51552
|
+
const verdict = await authorizeEndpoint(endpoint.endpointId, principal, deps);
|
|
51553
|
+
if (!verdict.allowed) {
|
|
51554
|
+
refused.push({
|
|
51555
|
+
endpointId: endpoint.endpointId,
|
|
51556
|
+
refusal: verdict.refusal
|
|
51557
|
+
});
|
|
51558
|
+
continue;
|
|
51559
|
+
}
|
|
51560
|
+
const target = endpointAccessTarget(parseCameraEndpointId(endpoint.endpointId));
|
|
51561
|
+
if (principal.kind === "scoped" && target !== null && target.ownAccess === "view") {
|
|
51562
|
+
const control = await deps.gate.allows(principal, target.deviceId, "create");
|
|
51563
|
+
kept.push(control === "allowed" ? endpoint : viewOnlyShape(endpoint));
|
|
51564
|
+
continue;
|
|
51565
|
+
}
|
|
51566
|
+
kept.push(endpoint);
|
|
51567
|
+
}
|
|
51568
|
+
return {
|
|
51569
|
+
kept,
|
|
51570
|
+
refused
|
|
51571
|
+
};
|
|
51572
|
+
}
|
|
51573
|
+
/** The Alexa error type a refusal maps to. A device the user may not see does
|
|
51574
|
+
* not exist for that user; one it may see but not control is a permission
|
|
51575
|
+
* failure; a device the registry cannot read right now is unreachable. */
|
|
51576
|
+
function alexaErrorTypeFor(refusal) {
|
|
51577
|
+
switch (refusal.kind) {
|
|
51578
|
+
case "device-unknown": return "ENDPOINT_UNREACHABLE";
|
|
51579
|
+
case "not-granted": return refusal.access === "view" ? "NO_SUCH_ENDPOINT" : "INVALID_DIRECTIVE";
|
|
51580
|
+
case "invalid-endpoint":
|
|
51581
|
+
case "not-exposed":
|
|
51582
|
+
case "not-a-child": return "NO_SUCH_ENDPOINT";
|
|
51583
|
+
}
|
|
51584
|
+
}
|
|
51585
|
+
function describeRefusal(refusal) {
|
|
51586
|
+
switch (refusal.kind) {
|
|
51587
|
+
case "invalid-endpoint": return "the endpoint id is not one this skill publishes";
|
|
51588
|
+
case "not-exposed": return `device ${refusal.deviceId} is not exposed to Alexa`;
|
|
51589
|
+
case "not-a-child": return `device ${refusal.deviceId} is not an accessory of ${refusal.parentDeviceId}`;
|
|
51590
|
+
case "not-granted": return `the linked hub user has no ${refusal.access} grant on device ${refusal.deviceId}`;
|
|
51591
|
+
case "device-unknown": return `device ${refusal.deviceId} could not be resolved in the registry`;
|
|
51592
|
+
}
|
|
51593
|
+
}
|
|
50636
51594
|
//#endregion
|
|
50637
51595
|
//#region src/handlers/brightness-controller.ts
|
|
50638
51596
|
var SetBrightnessDirectiveSchema = object({
|
|
@@ -50655,20 +51613,21 @@ async function handleSetBrightness(header, endpointRaw, payloadRaw, deps, _calle
|
|
|
50655
51613
|
if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
|
|
50656
51614
|
const endpointId = parsed.data.endpoint.endpointId;
|
|
50657
51615
|
const parsedId = parseCameraEndpointId(endpointId);
|
|
50658
|
-
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`);
|
|
51616
|
+
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`);
|
|
50659
51617
|
if (parsedId.kind === "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", "BrightnessController is not exposed on camera endpoints — direct the directive at the dimmable child accessory");
|
|
50660
51618
|
if (parsedId.kind === "invalid") return errorEnvelope(header, "INVALID_DIRECTIVE", parsedId.reason);
|
|
50661
51619
|
const percentage = parsed.data.payload.brightness;
|
|
50662
51620
|
const api = deps.ctx.api;
|
|
51621
|
+
const targetDeviceId = parsedId.kind === "child" ? parsedId.childDeviceId : parsedId.deviceId;
|
|
50663
51622
|
try {
|
|
50664
51623
|
await api.brightness.setBrightness.mutate({
|
|
50665
|
-
deviceId:
|
|
51624
|
+
deviceId: targetDeviceId,
|
|
50666
51625
|
percentage
|
|
50667
51626
|
});
|
|
50668
51627
|
} catch (err) {
|
|
50669
51628
|
const message = err instanceof Error ? err.message : String(err);
|
|
50670
51629
|
deps.logger.warn("alexa brightness-controller: setBrightness failed", { meta: {
|
|
50671
|
-
deviceId:
|
|
51630
|
+
deviceId: targetDeviceId,
|
|
50672
51631
|
percentage,
|
|
50673
51632
|
error: message
|
|
50674
51633
|
} });
|
|
@@ -50778,6 +51737,66 @@ async function handleInitializeCameraStreams(header, endpointRaw, payloadRaw, de
|
|
|
50778
51737
|
} };
|
|
50779
51738
|
}
|
|
50780
51739
|
//#endregion
|
|
51740
|
+
//#region src/handlers/lock-controller.ts
|
|
51741
|
+
/** The `lock-control` state as Alexa's `lockState`. */
|
|
51742
|
+
function alexaLockStateFor(state) {
|
|
51743
|
+
switch (state) {
|
|
51744
|
+
case "locked":
|
|
51745
|
+
case "locking": return "LOCKED";
|
|
51746
|
+
case "unlocked":
|
|
51747
|
+
case "unlocking": return "UNLOCKED";
|
|
51748
|
+
case "jammed": return "JAMMED";
|
|
51749
|
+
}
|
|
51750
|
+
}
|
|
51751
|
+
async function readLockState(api, deviceId) {
|
|
51752
|
+
const raw = await api.lockControl.getStatus.query({ deviceId });
|
|
51753
|
+
const parsed = LockControlStatusSchema.pick({ state: true }).safeParse(raw);
|
|
51754
|
+
return parsed.success ? alexaLockStateFor(parsed.data.state) : null;
|
|
51755
|
+
}
|
|
51756
|
+
async function handleLock(header, endpoint, deps) {
|
|
51757
|
+
if (endpoint === void 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "missing endpoint");
|
|
51758
|
+
const parsed = parseCameraEndpointId(endpoint.endpointId);
|
|
51759
|
+
if (parsed.kind !== "generic") return errorEnvelope(header, "INVALID_DIRECTIVE", "LockController is only on a lock endpoint");
|
|
51760
|
+
const lock = header.name === "Lock";
|
|
51761
|
+
const api = deps.ctx.api;
|
|
51762
|
+
try {
|
|
51763
|
+
if (lock) await api.lockControl.lock.mutate({ deviceId: parsed.deviceId });
|
|
51764
|
+
else await api.lockControl.unlock.mutate({ deviceId: parsed.deviceId });
|
|
51765
|
+
} catch (err) {
|
|
51766
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
51767
|
+
deps.logger.warn(`alexa lock-controller: ${header.name} failed`, {
|
|
51768
|
+
tags: { deviceId: parsed.deviceId },
|
|
51769
|
+
meta: { error: message }
|
|
51770
|
+
});
|
|
51771
|
+
return errorEnvelope(header, "ENDPOINT_UNREACHABLE", `${header.name} failed: ${message}`);
|
|
51772
|
+
}
|
|
51773
|
+
const state = await readLockState(api, parsed.deviceId).catch(() => null) ?? (lock ? "LOCKED" : "UNLOCKED");
|
|
51774
|
+
deps.logger.info(`alexa lock-controller: ${header.name}`, {
|
|
51775
|
+
tags: { deviceId: parsed.deviceId },
|
|
51776
|
+
meta: { lockState: state }
|
|
51777
|
+
});
|
|
51778
|
+
return {
|
|
51779
|
+
event: {
|
|
51780
|
+
header: {
|
|
51781
|
+
namespace: "Alexa",
|
|
51782
|
+
name: "Response",
|
|
51783
|
+
messageId: header.messageId,
|
|
51784
|
+
...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
|
|
51785
|
+
payloadVersion: "3"
|
|
51786
|
+
},
|
|
51787
|
+
endpoint: { endpointId: endpoint.endpointId },
|
|
51788
|
+
payload: {}
|
|
51789
|
+
},
|
|
51790
|
+
context: { properties: [{
|
|
51791
|
+
namespace: "Alexa.LockController",
|
|
51792
|
+
name: "lockState",
|
|
51793
|
+
value: state,
|
|
51794
|
+
timeOfSample: (/* @__PURE__ */ new Date()).toISOString(),
|
|
51795
|
+
uncertaintyInMilliseconds: 500
|
|
51796
|
+
}] }
|
|
51797
|
+
};
|
|
51798
|
+
}
|
|
51799
|
+
//#endregion
|
|
50781
51800
|
//#region src/handlers/power-controller.ts
|
|
50782
51801
|
var PowerDirectiveSchema = object({
|
|
50783
51802
|
header: object({
|
|
@@ -50813,6 +51832,11 @@ async function handlePower(header, endpointRaw, deps, _callerIdentity) {
|
|
|
50813
51832
|
});
|
|
50814
51833
|
else if (parsedId.kind === "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", "PowerController is not exposed on camera endpoints — direct the directive at the child accessory");
|
|
50815
51834
|
else if (parsedId.kind === "alarm-panel") return errorEnvelope(header, "INVALID_DIRECTIVE", "PowerController is not exposed on alarm-panel endpoints — use Alexa.SecurityPanelController");
|
|
51835
|
+
else if (parsedId.kind === "generic") await api.switch.setState.mutate({
|
|
51836
|
+
deviceId: parsedId.deviceId,
|
|
51837
|
+
on
|
|
51838
|
+
});
|
|
51839
|
+
else if (parsedId.kind === "detection") return errorEnvelope(header, "INVALID_DIRECTIVE", "PowerController is not exposed on a detection sensor — it only reports");
|
|
50816
51840
|
else return errorEnvelope(header, "INVALID_DIRECTIVE", parsedId.reason);
|
|
50817
51841
|
return {
|
|
50818
51842
|
event: {
|
|
@@ -51044,6 +52068,236 @@ function modeSuccessResponse(header, endpointId, instance, mode) {
|
|
|
51044
52068
|
}] }
|
|
51045
52069
|
};
|
|
51046
52070
|
}
|
|
52071
|
+
var JPEG_QUALITY = 72;
|
|
52072
|
+
var RESIZE_TIMEOUT_MS = 4e3;
|
|
52073
|
+
/** Re-encode a camera frame for Alexa. Rejects (never falls back to the
|
|
52074
|
+
* original) on failure or timeout. */
|
|
52075
|
+
async function toAlexaJpeg(source) {
|
|
52076
|
+
const work = (async () => {
|
|
52077
|
+
const meta = await sharp(source).metadata();
|
|
52078
|
+
const width = meta.width;
|
|
52079
|
+
const height = meta.height;
|
|
52080
|
+
if (width === void 0 || height === void 0 || width === 0 || height === 0) throw new Error("snapshot has no readable dimensions");
|
|
52081
|
+
const short = Math.min(width, height);
|
|
52082
|
+
const targetShort = short < 360 ? 360 : Math.min(short, 480);
|
|
52083
|
+
const resize = width <= height ? { width: targetShort } : { height: targetShort };
|
|
52084
|
+
const { data, info } = await sharp(source).rotate().resize(resize).jpeg({
|
|
52085
|
+
quality: JPEG_QUALITY,
|
|
52086
|
+
mozjpeg: false
|
|
52087
|
+
}).toBuffer({ resolveWithObject: true });
|
|
52088
|
+
return {
|
|
52089
|
+
bytes: data,
|
|
52090
|
+
width: info.width,
|
|
52091
|
+
height: info.height
|
|
52092
|
+
};
|
|
52093
|
+
})();
|
|
52094
|
+
let timer;
|
|
52095
|
+
const timeout = new Promise((_, reject) => {
|
|
52096
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error("snapshot re-encode timed out")), RESIZE_TIMEOUT_MS);
|
|
52097
|
+
});
|
|
52098
|
+
try {
|
|
52099
|
+
return await Promise.race([work, timeout]);
|
|
52100
|
+
} finally {
|
|
52101
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
52102
|
+
}
|
|
52103
|
+
}
|
|
52104
|
+
//#endregion
|
|
52105
|
+
//#region src/handlers/snapshot-provider.ts
|
|
52106
|
+
/** Leaves the rest of Amazon's 8 s for the re-encode and the Lambda hop. */
|
|
52107
|
+
var SNAPSHOT_READ_TIMEOUT_MS = 5e3;
|
|
52108
|
+
var SnapshotImageSchema = object({
|
|
52109
|
+
base64: string().min(1),
|
|
52110
|
+
contentType: string()
|
|
52111
|
+
});
|
|
52112
|
+
function snapshotError(header, type, message) {
|
|
52113
|
+
return { event: {
|
|
52114
|
+
header: {
|
|
52115
|
+
namespace: "Alexa.SmartVision.SnapshotProvider",
|
|
52116
|
+
name: "ErrorResponse",
|
|
52117
|
+
messageId: header.messageId,
|
|
52118
|
+
...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
|
|
52119
|
+
payloadVersion: "1.1"
|
|
52120
|
+
},
|
|
52121
|
+
payload: {
|
|
52122
|
+
type,
|
|
52123
|
+
message
|
|
52124
|
+
}
|
|
52125
|
+
} };
|
|
52126
|
+
}
|
|
52127
|
+
async function readFrame(ctx, deviceId) {
|
|
52128
|
+
let timer;
|
|
52129
|
+
const timeout = new Promise((resolve) => {
|
|
52130
|
+
timer = setTimeout(() => resolve(null), SNAPSHOT_READ_TIMEOUT_MS);
|
|
52131
|
+
});
|
|
52132
|
+
try {
|
|
52133
|
+
const raw = await Promise.race([ctx.api.snapshot.getSnapshot.query({ deviceId }), timeout]);
|
|
52134
|
+
const parsed = SnapshotImageSchema.safeParse(raw);
|
|
52135
|
+
return parsed.success ? Buffer.from(parsed.data.base64, "base64") : null;
|
|
52136
|
+
} finally {
|
|
52137
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
52138
|
+
}
|
|
52139
|
+
}
|
|
52140
|
+
async function handleGetSnapshot(header, endpoint, deps, caller) {
|
|
52141
|
+
if (endpoint === void 0) return errorEnvelope(header, "INVALID_DIRECTIVE", "missing endpoint");
|
|
52142
|
+
if (!deps.enabled()) return snapshotError(header, "DISABLED_BY_USER", "snapshots are switched off on this hub");
|
|
52143
|
+
const parsed = parseCameraEndpointId(endpoint.endpointId);
|
|
52144
|
+
if (parsed.kind !== "camera") return errorEnvelope(header, "INVALID_DIRECTIVE", "snapshots are served for camera endpoints");
|
|
52145
|
+
const deviceId = parsed.deviceId;
|
|
52146
|
+
const base = deps.publicHubUrl().replace(/\/+$/, "");
|
|
52147
|
+
if (!base.startsWith("https://")) {
|
|
52148
|
+
deps.logger.warn("alexa snapshot: no public HTTPS address — Alexa cannot fetch an image", { tags: { deviceId } });
|
|
52149
|
+
return errorEnvelope(header, "INTERNAL_ERROR", "the hub has no public HTTPS address");
|
|
52150
|
+
}
|
|
52151
|
+
const started = deps.now?.() ?? Date.now();
|
|
52152
|
+
const frame = await readFrame(deps.ctx, deviceId).catch((err) => {
|
|
52153
|
+
deps.logger.warn("alexa snapshot: snapshot read failed", {
|
|
52154
|
+
tags: { deviceId },
|
|
52155
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
52156
|
+
});
|
|
52157
|
+
return null;
|
|
52158
|
+
});
|
|
52159
|
+
if (frame === null) return errorEnvelope(header, "ENDPOINT_UNREACHABLE", `no snapshot from device ${deviceId}`);
|
|
52160
|
+
let jpeg;
|
|
52161
|
+
try {
|
|
52162
|
+
jpeg = await (deps.encode ?? toAlexaJpeg)(frame);
|
|
52163
|
+
} catch (err) {
|
|
52164
|
+
deps.logger.warn("alexa snapshot: re-encode failed — not sending the original frame", {
|
|
52165
|
+
tags: { deviceId },
|
|
52166
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
52167
|
+
});
|
|
52168
|
+
return errorEnvelope(header, "INTERNAL_ERROR", "snapshot could not be prepared");
|
|
52169
|
+
}
|
|
52170
|
+
const now = deps.now?.() ?? Date.now();
|
|
52171
|
+
const minted = deps.store.mint({
|
|
52172
|
+
deviceId,
|
|
52173
|
+
userId: caller.userId,
|
|
52174
|
+
bytes: jpeg.bytes,
|
|
52175
|
+
capturedAt: now
|
|
52176
|
+
});
|
|
52177
|
+
deps.logger.info("alexa snapshot: served", {
|
|
52178
|
+
tags: { deviceId },
|
|
52179
|
+
meta: {
|
|
52180
|
+
userId: caller.userId,
|
|
52181
|
+
bytes: jpeg.bytes.length,
|
|
52182
|
+
width: jpeg.width,
|
|
52183
|
+
height: jpeg.height,
|
|
52184
|
+
tookMs: now - started
|
|
52185
|
+
}
|
|
52186
|
+
});
|
|
52187
|
+
return { event: {
|
|
52188
|
+
header: {
|
|
52189
|
+
namespace: "Alexa.SmartVision.SnapshotProvider",
|
|
52190
|
+
name: "Snapshot",
|
|
52191
|
+
messageId: header.messageId,
|
|
52192
|
+
...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
|
|
52193
|
+
payloadVersion: "1.1"
|
|
52194
|
+
},
|
|
52195
|
+
endpoint: { endpointId: endpoint.endpointId },
|
|
52196
|
+
payload: {
|
|
52197
|
+
value: {
|
|
52198
|
+
uri: `${base}${minted.path}`,
|
|
52199
|
+
uriExpirationTime: new Date(minted.expiresAt).toISOString(),
|
|
52200
|
+
authenticationType: "ACCESS_TOKEN"
|
|
52201
|
+
},
|
|
52202
|
+
timeOfSample: new Date(now).toISOString(),
|
|
52203
|
+
uncertaintyInMilliseconds: 1e4
|
|
52204
|
+
}
|
|
52205
|
+
} };
|
|
52206
|
+
}
|
|
52207
|
+
//#endregion
|
|
52208
|
+
//#region src/handlers/generic-state.ts
|
|
52209
|
+
/**
|
|
52210
|
+
* A generic device's Alexa properties from its cap slices (R5, D775) — ONE
|
|
52211
|
+
* mapping shared by `Alexa/ReportState` (reading each cap's status) and the
|
|
52212
|
+
* proactive reports (`DeviceStateChanged` slices), so the two can never answer
|
|
52213
|
+
* differently for the same state.
|
|
52214
|
+
*/
|
|
52215
|
+
/** The caps a generic endpoint reads, in the order ReportState asks them. */
|
|
52216
|
+
var GENERIC_STATE_CAPS = [
|
|
52217
|
+
"lock-control",
|
|
52218
|
+
"contact",
|
|
52219
|
+
"motion",
|
|
52220
|
+
"presence",
|
|
52221
|
+
"temperature-sensor",
|
|
52222
|
+
"humidity-sensor",
|
|
52223
|
+
"switch",
|
|
52224
|
+
"brightness"
|
|
52225
|
+
];
|
|
52226
|
+
var detection = (detected) => ({
|
|
52227
|
+
namespace: "",
|
|
52228
|
+
name: "detectionState",
|
|
52229
|
+
value: detected ? "DETECTED" : "NOT_DETECTED"
|
|
52230
|
+
});
|
|
52231
|
+
/** The Alexa property one cap slice sets, or null (unknown cap, bad slice). */
|
|
52232
|
+
function genericPropertyFor(capName, slice) {
|
|
52233
|
+
switch (capName) {
|
|
52234
|
+
case "lock-control": {
|
|
52235
|
+
const p = LockControlStatusSchema.pick({ state: true }).safeParse(slice);
|
|
52236
|
+
return p.success ? {
|
|
52237
|
+
namespace: "Alexa.LockController",
|
|
52238
|
+
name: "lockState",
|
|
52239
|
+
value: alexaLockStateFor(p.data.state)
|
|
52240
|
+
} : null;
|
|
52241
|
+
}
|
|
52242
|
+
case "contact": {
|
|
52243
|
+
const p = ContactStatusSchema.pick({ entryOpen: true }).safeParse(slice);
|
|
52244
|
+
return p.success ? {
|
|
52245
|
+
...detection(p.data.entryOpen),
|
|
52246
|
+
namespace: "Alexa.ContactSensor"
|
|
52247
|
+
} : null;
|
|
52248
|
+
}
|
|
52249
|
+
case "motion": {
|
|
52250
|
+
const p = MotionStatusSchema.pick({ detected: true }).safeParse(slice);
|
|
52251
|
+
return p.success ? {
|
|
52252
|
+
...detection(p.data.detected),
|
|
52253
|
+
namespace: "Alexa.MotionSensor"
|
|
52254
|
+
} : null;
|
|
52255
|
+
}
|
|
52256
|
+
case "presence": {
|
|
52257
|
+
const p = PresenceStatusSchema.pick({ state: true }).safeParse(slice);
|
|
52258
|
+
return p.success ? {
|
|
52259
|
+
...detection(p.data.state === "home"),
|
|
52260
|
+
namespace: "Alexa.MotionSensor"
|
|
52261
|
+
} : null;
|
|
52262
|
+
}
|
|
52263
|
+
case "temperature-sensor": {
|
|
52264
|
+
const p = TemperatureSensorStatusSchema.pick({ celsius: true }).safeParse(slice);
|
|
52265
|
+
return p.success ? {
|
|
52266
|
+
namespace: "Alexa.TemperatureSensor",
|
|
52267
|
+
name: "temperature",
|
|
52268
|
+
value: {
|
|
52269
|
+
value: Math.round(p.data.celsius * 10) / 10,
|
|
52270
|
+
scale: "CELSIUS"
|
|
52271
|
+
}
|
|
52272
|
+
} : null;
|
|
52273
|
+
}
|
|
52274
|
+
case "humidity-sensor": {
|
|
52275
|
+
const p = HumiditySensorStatusSchema.pick({ percent: true }).safeParse(slice);
|
|
52276
|
+
return p.success ? {
|
|
52277
|
+
namespace: "Alexa.HumiditySensor",
|
|
52278
|
+
name: "relativeHumidity",
|
|
52279
|
+
value: { value: Math.max(0, Math.min(100, p.data.percent)) }
|
|
52280
|
+
} : null;
|
|
52281
|
+
}
|
|
52282
|
+
case "switch": {
|
|
52283
|
+
const p = SwitchStatusSchema.pick({ on: true }).safeParse(slice);
|
|
52284
|
+
return p.success ? {
|
|
52285
|
+
namespace: "Alexa.PowerController",
|
|
52286
|
+
name: "powerState",
|
|
52287
|
+
value: p.data.on ? "ON" : "OFF"
|
|
52288
|
+
} : null;
|
|
52289
|
+
}
|
|
52290
|
+
case "brightness": {
|
|
52291
|
+
const p = BrightnessStatusSchema.pick({ percentage: true }).safeParse(slice);
|
|
52292
|
+
return p.success ? {
|
|
52293
|
+
namespace: "Alexa.BrightnessController",
|
|
52294
|
+
name: "brightness",
|
|
52295
|
+
value: Math.max(0, Math.min(100, Math.round(p.data.percentage)))
|
|
52296
|
+
} : null;
|
|
52297
|
+
}
|
|
52298
|
+
default: return null;
|
|
52299
|
+
}
|
|
52300
|
+
}
|
|
51047
52301
|
//#endregion
|
|
51048
52302
|
//#region src/handlers/report-state.ts
|
|
51049
52303
|
async function handleReportState(header, endpointRaw, deps, _callerIdentity) {
|
|
@@ -51060,6 +52314,23 @@ async function handleReportState(header, endpointRaw, deps, _callerIdentity) {
|
|
|
51060
52314
|
else if (parsed.kind === "autotrack") await collectAutotrackProperties(api, parsed.parentDeviceId, properties, nowIso, deps.logger);
|
|
51061
52315
|
else if (parsed.kind === "child") await collectChildProperties(api, parsed.childDeviceId, properties, nowIso, deps.logger);
|
|
51062
52316
|
else if (parsed.kind === "alarm-panel") await collectAlarmPanelProperties(api, parsed.deviceId, properties, nowIso, deps.logger);
|
|
52317
|
+
else if (parsed.kind === "generic") await collectGenericProperties(api, parsed.deviceId, properties, nowIso, deps.logger);
|
|
52318
|
+
else if (parsed.kind === "detection") {
|
|
52319
|
+
properties.push({
|
|
52320
|
+
namespace: "Alexa.MotionSensor",
|
|
52321
|
+
name: "detectionState",
|
|
52322
|
+
value: deps.detectionPresent?.(parsed.deviceId, parsed.detectionClass) ? "DETECTED" : "NOT_DETECTED",
|
|
52323
|
+
timeOfSample: nowIso,
|
|
52324
|
+
uncertaintyInMilliseconds: 0
|
|
52325
|
+
});
|
|
52326
|
+
properties.push({
|
|
52327
|
+
namespace: "Alexa.EndpointHealth",
|
|
52328
|
+
name: "connectivity",
|
|
52329
|
+
value: { value: await readConnectivity(api, parsed.deviceId, deps.logger) },
|
|
52330
|
+
timeOfSample: nowIso,
|
|
52331
|
+
uncertaintyInMilliseconds: 0
|
|
52332
|
+
});
|
|
52333
|
+
}
|
|
51063
52334
|
} catch (err) {
|
|
51064
52335
|
deps.logger.warn("alexa report-state: collection failed", { meta: {
|
|
51065
52336
|
endpointId,
|
|
@@ -51081,6 +52352,39 @@ async function handleReportState(header, endpointRaw, deps, _callerIdentity) {
|
|
|
51081
52352
|
context: { properties }
|
|
51082
52353
|
};
|
|
51083
52354
|
}
|
|
52355
|
+
/**
|
|
52356
|
+
* A generic device (R5): every cap it has, read through its status, mapped by
|
|
52357
|
+
* the same function the proactive reports use. A cap the device does not have
|
|
52358
|
+
* fails its read and contributes nothing; connectivity is always answered.
|
|
52359
|
+
*/
|
|
52360
|
+
async function collectGenericProperties(api, deviceId, properties, timeOfSample, logger) {
|
|
52361
|
+
const reads = await Promise.all(GENERIC_STATE_CAPS.map(async (capName) => {
|
|
52362
|
+
const status = await api.deviceState.getCapSlice.query({
|
|
52363
|
+
deviceId,
|
|
52364
|
+
capName
|
|
52365
|
+
}).catch(() => null);
|
|
52366
|
+
return status === null ? null : genericPropertyFor(capName, status);
|
|
52367
|
+
}));
|
|
52368
|
+
const seen = /* @__PURE__ */ new Set();
|
|
52369
|
+
for (const p of reads) {
|
|
52370
|
+
if (p === null) continue;
|
|
52371
|
+
const key = `${p.namespace}.${p.name}`;
|
|
52372
|
+
if (seen.has(key)) continue;
|
|
52373
|
+
seen.add(key);
|
|
52374
|
+
properties.push({
|
|
52375
|
+
...p,
|
|
52376
|
+
timeOfSample,
|
|
52377
|
+
uncertaintyInMilliseconds: 0
|
|
52378
|
+
});
|
|
52379
|
+
}
|
|
52380
|
+
properties.push({
|
|
52381
|
+
namespace: "Alexa.EndpointHealth",
|
|
52382
|
+
name: "connectivity",
|
|
52383
|
+
value: { value: await readConnectivity(api, deviceId, logger) },
|
|
52384
|
+
timeOfSample,
|
|
52385
|
+
uncertaintyInMilliseconds: 0
|
|
52386
|
+
});
|
|
52387
|
+
}
|
|
51084
52388
|
function numberField(value, key) {
|
|
51085
52389
|
if (typeof value !== "object" || value === null) return null;
|
|
51086
52390
|
const raw = value[key];
|
|
@@ -51092,17 +52396,10 @@ function boolField(value, key) {
|
|
|
51092
52396
|
return typeof raw === "boolean" ? raw : null;
|
|
51093
52397
|
}
|
|
51094
52398
|
async function collectCameraProperties(api, deviceId, properties, timeOfSample, logger) {
|
|
51095
|
-
|
|
51096
|
-
|
|
51097
|
-
|
|
51098
|
-
|
|
51099
|
-
} });
|
|
51100
|
-
return null;
|
|
51101
|
-
}), "percentage");
|
|
51102
|
-
if (percentage !== null) properties.push({
|
|
51103
|
-
namespace: "Alexa.BatteryLevelSensor",
|
|
51104
|
-
name: "batteryLevel",
|
|
51105
|
-
value: Math.max(0, Math.min(100, Math.round(percentage))),
|
|
52399
|
+
properties.push({
|
|
52400
|
+
namespace: "Alexa.EndpointHealth",
|
|
52401
|
+
name: "connectivity",
|
|
52402
|
+
value: { value: await readConnectivity(api, deviceId, logger) },
|
|
51106
52403
|
timeOfSample,
|
|
51107
52404
|
uncertaintyInMilliseconds: 0
|
|
51108
52405
|
});
|
|
@@ -51489,6 +52786,35 @@ function requireDeviceScope(scopes, access) {
|
|
|
51489
52786
|
if (!scopesAllowDeviceCap(scopes, access)) throw new Error(`directive token not authorized for device ${access}`);
|
|
51490
52787
|
}
|
|
51491
52788
|
/**
|
|
52789
|
+
* Directives that are never refused by the access gate. `SessionDisconnected`
|
|
52790
|
+
* only tears down a session that already exists (and releases its transcode);
|
|
52791
|
+
* refusing it because the user lost access in the meantime would leak the
|
|
52792
|
+
* egress the reaper then has to find.
|
|
52793
|
+
*/
|
|
52794
|
+
var ACCESS_EXEMPT = new Set(["Alexa.RTCSessionController/SessionDisconnected"]);
|
|
52795
|
+
/**
|
|
52796
|
+
* Directives that need the device itself to answer. On a device the registry
|
|
52797
|
+
* reports OFFLINE they are refused with `ENDPOINT_UNREACHABLE` at once, rather
|
|
52798
|
+
* than spending Alexa's few-second budget on a transcode or a command that
|
|
52799
|
+
* cannot land (runbook C.2-b). `Alexa/ReportState` is NOT here: an offline
|
|
52800
|
+
* endpoint still answers, with `connectivity: UNREACHABLE` and the last known
|
|
52801
|
+
* values (alexa-endpointhealth). A status read that fails does not refuse.
|
|
52802
|
+
*/
|
|
52803
|
+
var NEEDS_REACHABLE_DEVICE = new Set([
|
|
52804
|
+
"Alexa.CameraStreamController/InitializeCameraStreams",
|
|
52805
|
+
"Alexa.RTCSessionController/InitiateSessionWithOffer",
|
|
52806
|
+
"Alexa.PowerController/TurnOn",
|
|
52807
|
+
"Alexa.PowerController/TurnOff",
|
|
52808
|
+
"Alexa.BrightnessController/SetBrightness",
|
|
52809
|
+
"Alexa.ModeController/SetMode",
|
|
52810
|
+
"Alexa.ToggleController/TurnOn",
|
|
52811
|
+
"Alexa.ToggleController/TurnOff",
|
|
52812
|
+
"Alexa.SecurityPanelController/Arm",
|
|
52813
|
+
"Alexa.SecurityPanelController/Disarm",
|
|
52814
|
+
"Alexa.LockController/Lock",
|
|
52815
|
+
"Alexa.LockController/Unlock"
|
|
52816
|
+
]);
|
|
52817
|
+
/**
|
|
51492
52818
|
* Give an `Alexa.ErrorResponse` the directive's endpoint when it lacks one.
|
|
51493
52819
|
* Amazon requires `endpoint` on every ErrorResponse to an endpoint directive
|
|
51494
52820
|
* (alexa-errorresponse); the handlers build their errors from the header
|
|
@@ -51510,6 +52836,18 @@ function withErrorEndpoint(body, endpoint) {
|
|
|
51510
52836
|
}
|
|
51511
52837
|
};
|
|
51512
52838
|
}
|
|
52839
|
+
/** The device whose online state decides whether an endpoint is reachable:
|
|
52840
|
+
* the device the directive actuates (an accessory for a child endpoint). */
|
|
52841
|
+
function reachabilityDeviceOf(endpointId) {
|
|
52842
|
+
const target = endpointAccessTarget(parseCameraEndpointId(endpointId));
|
|
52843
|
+
return target === null ? null : target.deviceId;
|
|
52844
|
+
}
|
|
52845
|
+
/** Whether the principal may CONTROL the device an endpoint addresses — the
|
|
52846
|
+
* talk-back gate (D771). */
|
|
52847
|
+
async function mayControlEndpoint(endpointId, principal, access) {
|
|
52848
|
+
if (endpointId === void 0 || principal === null) return false;
|
|
52849
|
+
return (await authorizeEndpoint(endpointId, principal, access, "create")).allowed;
|
|
52850
|
+
}
|
|
51513
52851
|
async function dispatchDirective(rawBody, deps, callerIdentity) {
|
|
51514
52852
|
const result = await dispatchParsedDirective(rawBody, deps, callerIdentity);
|
|
51515
52853
|
const endpoint = rawBody?.directive?.endpoint;
|
|
@@ -51550,7 +52888,10 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
|
|
|
51550
52888
|
"Alexa.ToggleController/TurnOff": "create",
|
|
51551
52889
|
"Alexa/ReportState": "view",
|
|
51552
52890
|
"Alexa.SecurityPanelController/Arm": "create",
|
|
51553
|
-
"Alexa.SecurityPanelController/Disarm": "create"
|
|
52891
|
+
"Alexa.SecurityPanelController/Disarm": "create",
|
|
52892
|
+
"Alexa.SmartVision.SnapshotProvider/GetSnapshot": "view",
|
|
52893
|
+
"Alexa.LockController/Lock": "create",
|
|
52894
|
+
"Alexa.LockController/Unlock": "create"
|
|
51554
52895
|
};
|
|
51555
52896
|
try {
|
|
51556
52897
|
const key = `${header.namespace}/${header.name}`;
|
|
@@ -51563,17 +52904,95 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
|
|
|
51563
52904
|
body: errorEnvelope(header, "INVALID_AUTHORIZATION_CREDENTIAL", scopeErr instanceof Error ? scopeErr.message : String(scopeErr))
|
|
51564
52905
|
};
|
|
51565
52906
|
}
|
|
51566
|
-
|
|
51567
|
-
|
|
51568
|
-
|
|
51569
|
-
|
|
52907
|
+
let principal = null;
|
|
52908
|
+
if (!ACCESS_EXEMPT.has(key)) {
|
|
52909
|
+
const lookup = await deps.access.gate.principal(callerIdentity.userId);
|
|
52910
|
+
if (lookup.kind === "unknown-user") {
|
|
52911
|
+
deps.discovery.logger.warn("alexa directive: refused — the hub user who linked this Alexa account no longer exists", { meta: {
|
|
52912
|
+
userId: callerIdentity.userId,
|
|
52913
|
+
directive: key
|
|
52914
|
+
} });
|
|
52915
|
+
return {
|
|
52916
|
+
status: 200,
|
|
52917
|
+
body: errorEnvelope(header, "INVALID_AUTHORIZATION_CREDENTIAL", "the hub user behind this account link no longer exists")
|
|
52918
|
+
};
|
|
52919
|
+
}
|
|
52920
|
+
if (lookup.kind === "unavailable") return {
|
|
52921
|
+
status: 200,
|
|
52922
|
+
body: errorEnvelope(header, "INTERNAL_ERROR", lookup.error)
|
|
52923
|
+
};
|
|
52924
|
+
principal = lookup.principal;
|
|
52925
|
+
if (key !== "Alexa.Discovery/Discover") {
|
|
52926
|
+
if (endpoint === void 0) return {
|
|
52927
|
+
status: 200,
|
|
52928
|
+
body: errorEnvelope(header, "INVALID_DIRECTIVE", "missing directive.endpoint")
|
|
52929
|
+
};
|
|
52930
|
+
const verdict = await authorizeEndpoint(endpoint.endpointId, principal, deps.access, requiredAccess);
|
|
52931
|
+
if (!verdict.allowed) {
|
|
52932
|
+
deps.discovery.logger.info("alexa directive: refused by the user access gate", { meta: {
|
|
52933
|
+
userId: callerIdentity.userId,
|
|
52934
|
+
directive: key,
|
|
52935
|
+
endpointId: endpoint.endpointId,
|
|
52936
|
+
reason: describeRefusal(verdict.refusal)
|
|
52937
|
+
} });
|
|
52938
|
+
return {
|
|
52939
|
+
status: 200,
|
|
52940
|
+
body: errorEnvelope(header, alexaErrorTypeFor(verdict.refusal), describeRefusal(verdict.refusal))
|
|
52941
|
+
};
|
|
52942
|
+
}
|
|
52943
|
+
if (NEEDS_REACHABLE_DEVICE.has(key)) {
|
|
52944
|
+
const deviceId = reachabilityDeviceOf(endpoint.endpointId);
|
|
52945
|
+
if (deviceId !== null && await readConnectivity(deps.reportState.ctx.api, deviceId, deps.reportState.logger) === "UNREACHABLE") {
|
|
52946
|
+
deps.reportState.logger.info("alexa directive: refused — the device is offline", {
|
|
52947
|
+
tags: { deviceId },
|
|
52948
|
+
meta: {
|
|
52949
|
+
directive: key,
|
|
52950
|
+
endpointId: endpoint.endpointId
|
|
52951
|
+
}
|
|
52952
|
+
});
|
|
52953
|
+
return {
|
|
52954
|
+
status: 200,
|
|
52955
|
+
body: errorEnvelope(header, "ENDPOINT_UNREACHABLE", `device ${deviceId} is offline`)
|
|
52956
|
+
};
|
|
52957
|
+
}
|
|
52958
|
+
}
|
|
52959
|
+
}
|
|
52960
|
+
}
|
|
52961
|
+
if (key === "Alexa.Discovery/Discover") {
|
|
52962
|
+
if (principal === null) throw new Error("discovery reached without a principal");
|
|
52963
|
+
const who = principal;
|
|
52964
|
+
return {
|
|
52965
|
+
status: 200,
|
|
52966
|
+
body: await handleDiscovery(header, payload, deps.discovery, callerIdentity, {
|
|
52967
|
+
deviceVisible: async (deviceId) => deps.access.isExposed(deviceId) && await deps.access.gate.allows(who, deviceId, "view") === "allowed",
|
|
52968
|
+
filterEndpoints: async (endpoints) => {
|
|
52969
|
+
const filtered = await filterEndpointsForPrincipal(endpoints, who, deps.access);
|
|
52970
|
+
if (filtered.refused.length > 0) deps.discovery.logger.info("alexa discovery: endpoints withheld from this link", { meta: {
|
|
52971
|
+
userId: callerIdentity.userId,
|
|
52972
|
+
withheld: filtered.refused.map((r) => ({
|
|
52973
|
+
endpointId: r.endpointId,
|
|
52974
|
+
reason: describeRefusal(r.refusal)
|
|
52975
|
+
}))
|
|
52976
|
+
} });
|
|
52977
|
+
const valid = withholdInvalidEndpoints(filtered.kept, (message, violations) => {
|
|
52978
|
+
deps.discovery.logger.warn(message, { meta: {
|
|
52979
|
+
userId: callerIdentity.userId,
|
|
52980
|
+
violations
|
|
52981
|
+
} });
|
|
52982
|
+
});
|
|
52983
|
+
deps.afterDiscovery(callerIdentity.userId, valid);
|
|
52984
|
+
return valid;
|
|
52985
|
+
}
|
|
52986
|
+
})
|
|
52987
|
+
};
|
|
52988
|
+
}
|
|
51570
52989
|
if (key === "Alexa.CameraStreamController/InitializeCameraStreams") return {
|
|
51571
52990
|
status: 200,
|
|
51572
52991
|
body: await handleInitializeCameraStreams(header, endpoint, payload, deps.cameraStream, callerIdentity)
|
|
51573
52992
|
};
|
|
51574
52993
|
if (key === "Alexa.RTCSessionController/InitiateSessionWithOffer") return {
|
|
51575
52994
|
status: 200,
|
|
51576
|
-
body: await handleInitiateSessionWithOffer(header, endpoint, payload, deps.rtcSession, callerIdentity)
|
|
52995
|
+
body: await handleInitiateSessionWithOffer(header, endpoint, payload, deps.rtcSession, callerIdentity, { mayTalk: await mayControlEndpoint(endpoint?.endpointId, principal, deps.access) })
|
|
51577
52996
|
};
|
|
51578
52997
|
if (key === "Alexa.RTCSessionController/SessionConnected") return {
|
|
51579
52998
|
status: 200,
|
|
@@ -51607,6 +53026,14 @@ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
|
|
|
51607
53026
|
status: 200,
|
|
51608
53027
|
body: await handleDisarm(header, endpoint, payload, deps.securityPanel, callerIdentity)
|
|
51609
53028
|
};
|
|
53029
|
+
if (key === "Alexa.LockController/Lock" || key === "Alexa.LockController/Unlock") return {
|
|
53030
|
+
status: 200,
|
|
53031
|
+
body: await handleLock(header, endpoint, deps.lockController)
|
|
53032
|
+
};
|
|
53033
|
+
if (key === "Alexa.SmartVision.SnapshotProvider/GetSnapshot") return {
|
|
53034
|
+
status: 200,
|
|
53035
|
+
body: await handleGetSnapshot(header, endpoint, deps.snapshotProvider, callerIdentity)
|
|
53036
|
+
};
|
|
51610
53037
|
if (key === "Alexa/ReportState") return {
|
|
51611
53038
|
status: 200,
|
|
51612
53039
|
body: await handleReportState(header, endpoint, deps.reportState, callerIdentity)
|
|
@@ -51840,6 +53267,81 @@ function verifyIngressToken(expected, provided) {
|
|
|
51840
53267
|
function readIngressHeader(headers) {
|
|
51841
53268
|
for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === "x-camstack-ingress") return value;
|
|
51842
53269
|
}
|
|
53270
|
+
/** Characters of a secret a mask may keep, so an operator can tell two apart. */
|
|
53271
|
+
var MASK_TAIL = 4;
|
|
53272
|
+
/**
|
|
53273
|
+
* A secret as a READ surface may show it: a fixed run of dots and, for a value
|
|
53274
|
+
* long enough that four characters do not meaningfully shorten a guess, its
|
|
53275
|
+
* last four. Every read of this addon's status goes through this (S2) — the
|
|
53276
|
+
* value itself is handed out only by the admin-only `revealIngressToken`.
|
|
53277
|
+
*/
|
|
53278
|
+
function maskSecretTail(secret) {
|
|
53279
|
+
if (secret.length === 0) return "";
|
|
53280
|
+
if (secret.length <= MASK_TAIL * 4) return "••••";
|
|
53281
|
+
return `••••••••${secret.slice(-4)}`;
|
|
53282
|
+
}
|
|
53283
|
+
//#endregion
|
|
53284
|
+
//#region src/snapshots/snapshot-route.ts
|
|
53285
|
+
var IMAGE_FILE = /^([0-9a-f-]{36})\.jpg$/;
|
|
53286
|
+
function bearerOf(headers) {
|
|
53287
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
53288
|
+
if (key.toLowerCase() !== "authorization") continue;
|
|
53289
|
+
return /^Bearer\s+(.+)$/i.exec(value.trim())?.[1] ?? null;
|
|
53290
|
+
}
|
|
53291
|
+
return null;
|
|
53292
|
+
}
|
|
53293
|
+
function refuse(reply, status, error) {
|
|
53294
|
+
reply.code(status).type("application/json").header("cache-control", "no-store").header("content-encoding", "identity");
|
|
53295
|
+
reply.send({ error });
|
|
53296
|
+
}
|
|
53297
|
+
async function handleSnapshotFetch(req, reply, deps) {
|
|
53298
|
+
if (!deps.enabled()) {
|
|
53299
|
+
refuse(reply, 410, "snapshots are switched off");
|
|
53300
|
+
return;
|
|
53301
|
+
}
|
|
53302
|
+
const imageId = IMAGE_FILE.exec(req.params["file"] ?? "")?.[1];
|
|
53303
|
+
if (imageId === void 0) {
|
|
53304
|
+
refuse(reply, 410, "not a snapshot address");
|
|
53305
|
+
return;
|
|
53306
|
+
}
|
|
53307
|
+
const found = deps.store.lookup(imageId, req.query["exp"], req.query["sig"]);
|
|
53308
|
+
if (found.kind === "gone") {
|
|
53309
|
+
deps.logger.debug("alexa snapshot: fetch of a gone image", { meta: {
|
|
53310
|
+
imageId,
|
|
53311
|
+
reason: found.reason
|
|
53312
|
+
} });
|
|
53313
|
+
refuse(reply, 410, found.reason);
|
|
53314
|
+
return;
|
|
53315
|
+
}
|
|
53316
|
+
const image = found.image;
|
|
53317
|
+
const token = bearerOf(req.headers);
|
|
53318
|
+
const verified = token === null ? null : await deps.verifyToken(token).catch(() => null);
|
|
53319
|
+
if (verified === null) {
|
|
53320
|
+
deps.logger.warn("alexa snapshot: refused a fetch without a valid account-link token", {
|
|
53321
|
+
tags: { deviceId: image.deviceId },
|
|
53322
|
+
meta: {
|
|
53323
|
+
imageId,
|
|
53324
|
+
hadToken: token !== null
|
|
53325
|
+
}
|
|
53326
|
+
});
|
|
53327
|
+
refuse(reply, 401, "a valid Alexa account-link token is required");
|
|
53328
|
+
return;
|
|
53329
|
+
}
|
|
53330
|
+
if (verified.userId !== image.userId || !await deps.mayView(verified.userId, image.deviceId)) {
|
|
53331
|
+
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", {
|
|
53332
|
+
tags: { deviceId: image.deviceId },
|
|
53333
|
+
meta: {
|
|
53334
|
+
imageId,
|
|
53335
|
+
tokenUser: verified.userId,
|
|
53336
|
+
imageUser: image.userId
|
|
53337
|
+
}
|
|
53338
|
+
});
|
|
53339
|
+
refuse(reply, 403, "not allowed");
|
|
53340
|
+
return;
|
|
53341
|
+
}
|
|
53342
|
+
reply.code(200).type(image.contentType).header("cache-control", "private, no-store").header("content-encoding", "identity").header("content-length", String(image.bytes.byteLength));
|
|
53343
|
+
reply.send(image.bytes);
|
|
53344
|
+
}
|
|
51843
53345
|
//#endregion
|
|
51844
53346
|
//#region src/http-server.ts
|
|
51845
53347
|
/**
|
|
@@ -51892,6 +53394,13 @@ function buildRoutes(deps) {
|
|
|
51892
53394
|
await handleLwaTokensCallback(req, reply, deps.lwaTokensCallbackDeps);
|
|
51893
53395
|
}
|
|
51894
53396
|
},
|
|
53397
|
+
{
|
|
53398
|
+
method: "GET",
|
|
53399
|
+
path: "/snapshot/:file",
|
|
53400
|
+
access: "public",
|
|
53401
|
+
description: "Alexa SnapshotProvider image (signed, 10 min, account-link token required)",
|
|
53402
|
+
handler: (req, reply) => handleSnapshotFetch(req, reply, deps.snapshotRoute)
|
|
53403
|
+
},
|
|
51895
53404
|
{
|
|
51896
53405
|
method: "POST",
|
|
51897
53406
|
path: "/admin/redetect-hub-url",
|
|
@@ -52074,6 +53583,46 @@ function describeGrant(acc, input) {
|
|
|
52074
53583
|
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).`;
|
|
52075
53584
|
}
|
|
52076
53585
|
//#endregion
|
|
53586
|
+
//#region src/endpoint-signature.ts
|
|
53587
|
+
/**
|
|
53588
|
+
* One capability's identity as Alexa distinguishes it. A capability whose
|
|
53589
|
+
* properties are proactively reported carries a `!p` mark: Alexa's copy must
|
|
53590
|
+
* know the hub reports them, or the ChangeReports are worth nothing (R3) —
|
|
53591
|
+
* so flipping the flag is a shape change the reconcile re-pushes.
|
|
53592
|
+
*/
|
|
53593
|
+
function capabilityKey(capability) {
|
|
53594
|
+
const instance = typeof capability.instance === "string" ? capability.instance : "";
|
|
53595
|
+
const base = instance.length > 0 ? `${capability.interface}#${instance}` : capability.interface;
|
|
53596
|
+
return capability.properties?.proactivelyReported === true ? `${base}!p` : base;
|
|
53597
|
+
}
|
|
53598
|
+
/** Read back the endpoints (and their interfaces) a signature records — what
|
|
53599
|
+
* Alexa was last told about a device. */
|
|
53600
|
+
function signatureEntries(signature) {
|
|
53601
|
+
if (signature.length === 0) return [];
|
|
53602
|
+
return signature.split(";").map((entry) => {
|
|
53603
|
+
const [endpointId = "", , interfaces = ""] = entry.split("|");
|
|
53604
|
+
return {
|
|
53605
|
+
endpointId,
|
|
53606
|
+
interfaces: interfaces.split(",").filter((i) => i.length > 0).map((i) => i.replace(/!p$/, "").replace(/#.*$/, ""))
|
|
53607
|
+
};
|
|
53608
|
+
});
|
|
53609
|
+
}
|
|
53610
|
+
/** Stable, order-independent signature of ONE endpoint's advertised shape. */
|
|
53611
|
+
function endpointKey(endpoint) {
|
|
53612
|
+
const interfaces = endpoint.capabilities.map(capabilityKey).sort().join(",");
|
|
53613
|
+
const categories = [...endpoint.displayCategories].sort().join(",");
|
|
53614
|
+
return `${endpoint.endpointId}|${categories}|${interfaces}`;
|
|
53615
|
+
}
|
|
53616
|
+
/**
|
|
53617
|
+
* Stable signature of the endpoint set built for ONE device. Two builds that
|
|
53618
|
+
* would land identically in Alexa's registry produce the same string; any
|
|
53619
|
+
* added, removed or renamed interface — or a changed display category —
|
|
53620
|
+
* produces a different one.
|
|
53621
|
+
*/
|
|
53622
|
+
function endpointsSignature(endpoints) {
|
|
53623
|
+
return endpoints.map(endpointKey).sort().join(";");
|
|
53624
|
+
}
|
|
53625
|
+
//#endregion
|
|
52077
53626
|
//#region src/motion-clear-hold.ts
|
|
52078
53627
|
/**
|
|
52079
53628
|
* Default hold. Matches `RESET_DEBOUNCE_MS` in
|
|
@@ -52208,6 +53757,24 @@ var MIGRATION_RETRY_MS = 300 * 1e3;
|
|
|
52208
53757
|
/** The interface a camera endpoint must declare for a connectivity
|
|
52209
53758
|
* ChangeReport to mean anything to the Alexa cloud. */
|
|
52210
53759
|
var ENDPOINT_HEALTH_INTERFACE = "Alexa.EndpointHealth";
|
|
53760
|
+
/** Whether an exported signature (`endpoint-signature.ts`) names this
|
|
53761
|
+
* endpoint id. */
|
|
53762
|
+
function signatureNamesEndpoint(signature, endpointId) {
|
|
53763
|
+
return signature.split(";").some((entry) => entry.startsWith(`${endpointId}|`));
|
|
53764
|
+
}
|
|
53765
|
+
/** How long after a push the initial connectivity reports go out — the
|
|
53766
|
+
* AddOrUpdateReport is accepted asynchronously, and a ChangeReport for an
|
|
53767
|
+
* endpoint the cloud has not created yet is dropped. */
|
|
53768
|
+
var INITIAL_CONNECTIVITY_DELAY_MS = 5e3;
|
|
53769
|
+
/**
|
|
53770
|
+
* The device whose online state an endpoint's connectivity reports: the
|
|
53771
|
+
* exposed device the endpoint is published under (a camera's own id, a
|
|
53772
|
+
* detection sensor's camera, a generic device's id).
|
|
53773
|
+
*/
|
|
53774
|
+
function connectivityDeviceOf(endpointId) {
|
|
53775
|
+
const target = endpointAccessTarget(parseCameraEndpointId(endpointId));
|
|
53776
|
+
return target === null ? null : target.exposedDeviceId;
|
|
53777
|
+
}
|
|
52211
53778
|
/** The interface an endpoint must advertise for a `DoorbellPress` to survive
|
|
52212
53779
|
* the Alexa cloud. */
|
|
52213
53780
|
var DOORBELL_INTERFACE = "Alexa.DoorbellEventSource";
|
|
@@ -52238,11 +53805,12 @@ var LwaEventGateway = class {
|
|
|
52238
53805
|
deps;
|
|
52239
53806
|
unsubscribe = null;
|
|
52240
53807
|
unsubscribeMotion = null;
|
|
52241
|
-
unsubscribeBattery = null;
|
|
52242
53808
|
unsubscribeConnectivity = [];
|
|
52243
53809
|
/** Last connectivity reported per `userId|endpointId` — a camera that
|
|
52244
53810
|
* re-announces the same state must not become a ChangeReport stream. */
|
|
52245
53811
|
lastConnectivity = /* @__PURE__ */ new Map();
|
|
53812
|
+
/** Initial-connectivity reports waiting for the cloud to create endpoints. */
|
|
53813
|
+
pendingInitialReports = /* @__PURE__ */ new Set();
|
|
52246
53814
|
refreshTimer = null;
|
|
52247
53815
|
lastSentAt = /* @__PURE__ */ new Map();
|
|
52248
53816
|
/**
|
|
@@ -52256,11 +53824,6 @@ var LwaEventGateway = class {
|
|
|
52256
53824
|
/** Per-endpoint count of `ObjectDetection` events dropped because the
|
|
52257
53825
|
* endpoint does not declare {@link SMART_VISION_INTERFACE}. */
|
|
52258
53826
|
smartVisionDropped = /* @__PURE__ */ new Map();
|
|
52259
|
-
/** Last reported integer battery percentage per `userId|endpointId` —
|
|
52260
|
-
* battery providers may push the same level every poll cycle and
|
|
52261
|
-
* Alexa Smart Home rate-limits ChangeReports per property; dedupe
|
|
52262
|
-
* so only level *changes* (or step-grain ones) hit the gateway. */
|
|
52263
|
-
lastBatteryLevel = /* @__PURE__ */ new Map();
|
|
52264
53827
|
/** Latches the "no grant at all" warning so it's logged ONCE, not on every
|
|
52265
53828
|
* doorbell/motion event while no account has a push grant. */
|
|
52266
53829
|
warnedNoGrant = false;
|
|
@@ -52286,11 +53849,6 @@ var LwaEventGateway = class {
|
|
|
52286
53849
|
this.deps.logger.warn("alexa lwa-gateway: motion handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
52287
53850
|
});
|
|
52288
53851
|
});
|
|
52289
|
-
this.unsubscribeBattery = this.deps.ctx.eventBus.subscribe({ category: EventCategory.BatteryOnStatusChanged }, (event) => {
|
|
52290
|
-
this.onBatteryStatusChanged(event.data).catch((err) => {
|
|
52291
|
-
this.deps.logger.warn("alexa lwa-gateway: battery handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
52292
|
-
});
|
|
52293
|
-
});
|
|
52294
53852
|
this.unsubscribeConnectivity = [[EventCategory.DeviceOnline, "OK"], [EventCategory.DeviceOffline, "UNREACHABLE"]].map(([category, connectivity]) => this.deps.ctx.eventBus.subscribe({ category }, (event) => {
|
|
52295
53853
|
this.onConnectivityChanged(event.data, connectivity).catch((err) => {
|
|
52296
53854
|
this.deps.logger.warn("alexa lwa-gateway: connectivity handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
@@ -52308,17 +53866,16 @@ var LwaEventGateway = class {
|
|
|
52308
53866
|
this.unsubscribe = null;
|
|
52309
53867
|
if (this.unsubscribeMotion) this.unsubscribeMotion();
|
|
52310
53868
|
this.unsubscribeMotion = null;
|
|
52311
|
-
if (this.unsubscribeBattery) this.unsubscribeBattery();
|
|
52312
|
-
this.unsubscribeBattery = null;
|
|
52313
53869
|
for (const off of this.unsubscribeConnectivity) off();
|
|
52314
53870
|
this.unsubscribeConnectivity = [];
|
|
52315
53871
|
this.lastConnectivity.clear();
|
|
53872
|
+
for (const timer of this.pendingInitialReports) clearTimeout(timer);
|
|
53873
|
+
this.pendingInitialReports.clear();
|
|
52316
53874
|
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
|
52317
53875
|
this.refreshTimer = null;
|
|
52318
53876
|
this.lastSentAt.clear();
|
|
52319
53877
|
this.motionHold.clear();
|
|
52320
53878
|
this.smartVisionDropped.clear();
|
|
52321
|
-
this.lastBatteryLevel.clear();
|
|
52322
53879
|
this.warnedNoToken.clear();
|
|
52323
53880
|
this.warnedLegacyWaiting.clear();
|
|
52324
53881
|
this.migrationRetryAt.clear();
|
|
@@ -52350,6 +53907,7 @@ var LwaEventGateway = class {
|
|
|
52350
53907
|
}
|
|
52351
53908
|
if (!this.hasProactiveCredentials()) return;
|
|
52352
53909
|
for (const account of linked) {
|
|
53910
|
+
if (!this.mayPush(account.userId, parentDeviceId, "doorbell press")) continue;
|
|
52353
53911
|
if (!this.findToken(account.userId)) {
|
|
52354
53912
|
this.deps.logger.info("alexa lwa-gateway: no LWA token for user — skipped", {
|
|
52355
53913
|
tags: { deviceId: parentDeviceId },
|
|
@@ -52374,6 +53932,19 @@ var LwaEventGateway = class {
|
|
|
52374
53932
|
});
|
|
52375
53933
|
}
|
|
52376
53934
|
}
|
|
53935
|
+
/**
|
|
53936
|
+
* S1: the account's hub user may view `deviceId`. A refusal is logged at
|
|
53937
|
+
* debug — for a scoped user it is the EXPECTED outcome for every camera
|
|
53938
|
+
* outside the grant, many times a minute.
|
|
53939
|
+
*/
|
|
53940
|
+
mayPush(userId, deviceId, label) {
|
|
53941
|
+
if (this.deps.accountMaySee(userId, deviceId)) return true;
|
|
53942
|
+
this.deps.logger.debug(`alexa lwa-gateway: ${label} not sent to this account — its hub user may not view the device`, {
|
|
53943
|
+
tags: { deviceId },
|
|
53944
|
+
meta: { userId }
|
|
53945
|
+
});
|
|
53946
|
+
return false;
|
|
53947
|
+
}
|
|
52377
53948
|
isThrottled(key) {
|
|
52378
53949
|
const composite = `${key.userId}|${key.endpointId}`;
|
|
52379
53950
|
const last = this.lastSentAt.get(composite) ?? 0;
|
|
@@ -52396,13 +53967,57 @@ var LwaEventGateway = class {
|
|
|
52396
53967
|
async onMotionChanged(data) {
|
|
52397
53968
|
if (!this.deps.listExposed().some((e) => e.deviceId === data.deviceId)) return;
|
|
52398
53969
|
const endpointId = String(data.deviceId);
|
|
53970
|
+
await this.pushMotionState({
|
|
53971
|
+
deviceId: data.deviceId,
|
|
53972
|
+
endpointId,
|
|
53973
|
+
detected: data.detected,
|
|
53974
|
+
timestamp: data.timestamp,
|
|
53975
|
+
label: "motion",
|
|
53976
|
+
onRisingPosted: (userId) => this.postObjectDetection({
|
|
53977
|
+
userId,
|
|
53978
|
+
deviceId: data.deviceId,
|
|
53979
|
+
endpointId,
|
|
53980
|
+
timestamp: data.timestamp
|
|
53981
|
+
})
|
|
53982
|
+
});
|
|
53983
|
+
}
|
|
53984
|
+
/**
|
|
53985
|
+
* A camera's detection class appeared or cleared (R1, D772): report it on
|
|
53986
|
+
* that class's detection-sensor endpoint, with the same hold as camera
|
|
53987
|
+
* motion (instant rising edge; NOT_DETECTED only after the source went
|
|
53988
|
+
* quiet and at least 30 s after DETECTED, Amazon's MotionSensor rule).
|
|
53989
|
+
*
|
|
53990
|
+
* Posted only to an endpoint Alexa KNOWS: a ChangeReport for an endpoint the
|
|
53991
|
+
* last AddOrUpdateReport did not contain is a 202 the cloud discards (D76),
|
|
53992
|
+
* so the exported signature must name it.
|
|
53993
|
+
*/
|
|
53994
|
+
async pushDetectionSensor(opts) {
|
|
53995
|
+
if (!this.deps.listExposed().some((e) => e.deviceId === opts.deviceId)) return;
|
|
53996
|
+
const signature = this.deps.getExportedSignature(opts.deviceId);
|
|
53997
|
+
if (signature === null || !signatureNamesEndpoint(signature, opts.endpointId)) {
|
|
53998
|
+
this.deps.logger.debug("alexa lwa-gateway: detection change not sent — Alexa has not been told about this sensor endpoint", {
|
|
53999
|
+
tags: { deviceId: opts.deviceId },
|
|
54000
|
+
meta: { endpointId: opts.endpointId }
|
|
54001
|
+
});
|
|
54002
|
+
return;
|
|
54003
|
+
}
|
|
54004
|
+
await this.pushMotionState({
|
|
54005
|
+
...opts,
|
|
54006
|
+
label: "detection"
|
|
54007
|
+
});
|
|
54008
|
+
}
|
|
54009
|
+
/** The per-account MotionSensor ChangeReport loop shared by camera motion
|
|
54010
|
+
* and the detection sensors. The hold is keyed per account AND endpoint. */
|
|
54011
|
+
async pushMotionState(opts) {
|
|
54012
|
+
const { deviceId, endpointId } = opts;
|
|
52399
54013
|
const linked = this.deps.listLinkedAccounts();
|
|
52400
54014
|
if (linked.length === 0) return;
|
|
52401
54015
|
if (!this.hasProactiveCredentials()) return;
|
|
52402
54016
|
for (const account of linked) {
|
|
54017
|
+
if (!this.mayPush(account.userId, deviceId, opts.label)) continue;
|
|
52403
54018
|
if (!this.findToken(account.userId)) continue;
|
|
52404
54019
|
const key = `${account.userId}|${endpointId}`;
|
|
52405
|
-
if (!
|
|
54020
|
+
if (!opts.detected) {
|
|
52406
54021
|
this.motionHold.onCleared(key, () => {
|
|
52407
54022
|
this.postMotionChange({
|
|
52408
54023
|
userId: account.userId,
|
|
@@ -52410,8 +54025,8 @@ var LwaEventGateway = class {
|
|
|
52410
54025
|
detected: false,
|
|
52411
54026
|
timestamp: Date.now()
|
|
52412
54027
|
}).catch((err) => {
|
|
52413
|
-
this.deps.logger.warn(
|
|
52414
|
-
tags: { deviceId
|
|
54028
|
+
this.deps.logger.warn(`alexa lwa-gateway: held ${opts.label} clear failed to post`, {
|
|
54029
|
+
tags: { deviceId },
|
|
52415
54030
|
meta: {
|
|
52416
54031
|
endpointId,
|
|
52417
54032
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -52426,14 +54041,9 @@ var LwaEventGateway = class {
|
|
|
52426
54041
|
userId: account.userId,
|
|
52427
54042
|
endpointId,
|
|
52428
54043
|
detected: true,
|
|
52429
|
-
timestamp:
|
|
52430
|
-
});
|
|
52431
|
-
await this.postObjectDetection({
|
|
52432
|
-
userId: account.userId,
|
|
52433
|
-
deviceId: data.deviceId,
|
|
52434
|
-
endpointId,
|
|
52435
|
-
timestamp: data.timestamp
|
|
54044
|
+
timestamp: opts.timestamp
|
|
52436
54045
|
});
|
|
54046
|
+
await opts.onRisingPosted?.(account.userId);
|
|
52437
54047
|
}
|
|
52438
54048
|
}
|
|
52439
54049
|
async postMotionChange(opts) {
|
|
@@ -52488,59 +54098,113 @@ var LwaEventGateway = class {
|
|
|
52488
54098
|
}), `smartvision person`);
|
|
52489
54099
|
}
|
|
52490
54100
|
/**
|
|
52491
|
-
*
|
|
52492
|
-
* ChangeReport
|
|
52493
|
-
*
|
|
52494
|
-
*
|
|
52495
|
-
* cam reports every few minutes, and Alexa Smart Home rate-limits
|
|
52496
|
-
* ChangeReports anyway).
|
|
54101
|
+
* A camera went offline or came back. Amazon requires the `connectivity`
|
|
54102
|
+
* ChangeReport within 3 s of the change (alexa-endpointhealth). Deduped per
|
|
54103
|
+
* account and endpoint, and skipped for an endpoint Alexa is known to hold
|
|
54104
|
+
* WITHOUT `Alexa.EndpointHealth` — the cloud would drop it (D76).
|
|
52497
54105
|
*/
|
|
52498
|
-
async
|
|
52499
|
-
if (
|
|
52500
|
-
|
|
54106
|
+
async onConnectivityChanged(data, connectivity) {
|
|
54107
|
+
if (typeof data?.deviceId !== "number") return;
|
|
54108
|
+
await this.reportConnectivity(data.deviceId, connectivity);
|
|
54109
|
+
}
|
|
54110
|
+
/**
|
|
54111
|
+
* A device went offline or came back: report `connectivity` on EVERY
|
|
54112
|
+
* endpoint of it that Alexa knows with `Alexa.EndpointHealth` — the camera
|
|
54113
|
+
* and its detection sensors (D770). Fed by the generic `device-status`
|
|
54114
|
+
* slice (R3) and by the providers that also emit DeviceOnline/Offline;
|
|
54115
|
+
* deduplicated per account and endpoint, so the two sources never double-post.
|
|
54116
|
+
*/
|
|
54117
|
+
async reportConnectivity(deviceId, connectivity) {
|
|
54118
|
+
if (!this.deps.listExposed().some((e) => e.deviceId === deviceId)) return;
|
|
54119
|
+
const signature = this.deps.getExportedSignature(deviceId);
|
|
54120
|
+
const endpointIds = signature === null ? [String(deviceId)] : signatureEntries(signature).filter((e) => e.interfaces.includes(ENDPOINT_HEALTH_INTERFACE)).map((e) => e.endpointId);
|
|
54121
|
+
if (endpointIds.length === 0) return;
|
|
52501
54122
|
const linked = this.deps.listLinkedAccounts();
|
|
52502
54123
|
if (linked.length === 0) return;
|
|
52503
54124
|
if (!this.hasProactiveCredentials()) return;
|
|
52504
|
-
const level = Math.max(0, Math.min(100, Math.round(data.status.percentage)));
|
|
52505
54125
|
for (const account of linked) {
|
|
52506
|
-
if (!this.
|
|
52507
|
-
const
|
|
52508
|
-
|
|
52509
|
-
|
|
52510
|
-
|
|
52511
|
-
|
|
52512
|
-
|
|
52513
|
-
|
|
52514
|
-
|
|
52515
|
-
|
|
52516
|
-
|
|
54126
|
+
if (!this.mayPush(account.userId, deviceId, "connectivity")) continue;
|
|
54127
|
+
for (const endpointId of endpointIds) {
|
|
54128
|
+
const key = `${account.userId}|${endpointId}`;
|
|
54129
|
+
if (this.lastConnectivity.get(key) === connectivity) continue;
|
|
54130
|
+
this.lastConnectivity.set(key, connectivity);
|
|
54131
|
+
await this.postEventBody(account.userId, endpointId, (accessToken) => buildConnectivityChangeReportPayload({
|
|
54132
|
+
endpointId,
|
|
54133
|
+
connectivity,
|
|
54134
|
+
timestamp: Date.now(),
|
|
54135
|
+
accessToken
|
|
54136
|
+
}), `connectivity ${connectivity}`);
|
|
54137
|
+
}
|
|
52517
54138
|
}
|
|
52518
54139
|
}
|
|
52519
54140
|
/**
|
|
52520
|
-
* A
|
|
52521
|
-
*
|
|
52522
|
-
*
|
|
52523
|
-
* WITHOUT `Alexa.EndpointHealth` — the cloud would drop it (D76).
|
|
54141
|
+
* A device state change Alexa should hear about (R3, D776): the alarm
|
|
54142
|
+
* panel, a privacy/autotrack switch, an accessory. Posted only to an
|
|
54143
|
+
* endpoint Alexa knows WITH the interface the properties belong to (D76).
|
|
52524
54144
|
*/
|
|
52525
|
-
async
|
|
52526
|
-
if (
|
|
52527
|
-
|
|
52528
|
-
const
|
|
52529
|
-
if (
|
|
54145
|
+
async postStateChange(report) {
|
|
54146
|
+
if (!this.deps.listExposed().some((e) => e.deviceId === report.exposedDeviceId)) return;
|
|
54147
|
+
const signature = this.deps.getExportedSignature(report.exposedDeviceId);
|
|
54148
|
+
const entry = signature === null ? void 0 : signatureEntries(signature).find((e) => e.endpointId === report.endpointId);
|
|
54149
|
+
if (entry === void 0 || !entry.interfaces.includes(report.interfaceName)) {
|
|
54150
|
+
this.deps.logger.debug("alexa lwa-gateway: state change not sent — Alexa has not been told about this endpoint/interface", {
|
|
54151
|
+
tags: { deviceId: report.exposedDeviceId },
|
|
54152
|
+
meta: {
|
|
54153
|
+
endpointId: report.endpointId,
|
|
54154
|
+
interfaceName: report.interfaceName
|
|
54155
|
+
}
|
|
54156
|
+
});
|
|
54157
|
+
return;
|
|
54158
|
+
}
|
|
52530
54159
|
const linked = this.deps.listLinkedAccounts();
|
|
52531
54160
|
if (linked.length === 0) return;
|
|
52532
54161
|
if (!this.hasProactiveCredentials()) return;
|
|
52533
|
-
const endpointId = String(data.deviceId);
|
|
52534
54162
|
for (const account of linked) {
|
|
52535
|
-
|
|
52536
|
-
if (this.
|
|
52537
|
-
this.
|
|
52538
|
-
|
|
52539
|
-
|
|
54163
|
+
if (!this.mayPush(account.userId, report.exposedDeviceId, "state change")) continue;
|
|
54164
|
+
if (!this.findToken(account.userId)) continue;
|
|
54165
|
+
await this.postEventBody(account.userId, report.endpointId, (accessToken) => buildStateChangeReportPayload({
|
|
54166
|
+
endpointId: report.endpointId,
|
|
54167
|
+
properties: report.properties,
|
|
54168
|
+
timestamp: Date.now(),
|
|
54169
|
+
accessToken
|
|
54170
|
+
}), `state ${report.interfaceName}`);
|
|
54171
|
+
}
|
|
54172
|
+
}
|
|
54173
|
+
/**
|
|
54174
|
+
* After endpoints reach an account (AddOrUpdateReport or a Discover answer),
|
|
54175
|
+
* tell Alexa each one's connectivity, so it never holds an endpoint of
|
|
54176
|
+
* unknown health — the state in which the phone app refused live view
|
|
54177
|
+
* (D770). Delayed so the cloud has created the endpoints the report names.
|
|
54178
|
+
* Only endpoints that DECLARE `Alexa.EndpointHealth` are reported.
|
|
54179
|
+
*/
|
|
54180
|
+
scheduleInitialConnectivity(userId, endpoints) {
|
|
54181
|
+
const targets = endpoints.filter((e) => e.capabilities.some((c) => c.interface === ENDPOINT_HEALTH_INTERFACE));
|
|
54182
|
+
if (targets.length === 0) return;
|
|
54183
|
+
const timer = setTimeout(() => {
|
|
54184
|
+
this.reportInitialConnectivity(userId, targets).catch((err) => {
|
|
54185
|
+
this.deps.logger.warn("alexa lwa-gateway: initial connectivity report failed", { meta: {
|
|
54186
|
+
userId,
|
|
54187
|
+
error: err instanceof Error ? err.message : String(err)
|
|
54188
|
+
} });
|
|
54189
|
+
});
|
|
54190
|
+
}, INITIAL_CONNECTIVITY_DELAY_MS);
|
|
54191
|
+
timer.unref?.();
|
|
54192
|
+
this.pendingInitialReports.add(timer);
|
|
54193
|
+
}
|
|
54194
|
+
async reportInitialConnectivity(userId, endpoints) {
|
|
54195
|
+
if (!this.hasProactiveCredentials()) return;
|
|
54196
|
+
for (const endpoint of endpoints) {
|
|
54197
|
+
const deviceId = connectivityDeviceOf(endpoint.endpointId);
|
|
54198
|
+
if (deviceId === null) continue;
|
|
54199
|
+
if (!this.mayPush(userId, deviceId, "initial connectivity")) continue;
|
|
54200
|
+
const connectivity = await readConnectivity(this.deps.ctx.api, deviceId, this.deps.logger);
|
|
54201
|
+
this.lastConnectivity.set(`${userId}|${endpoint.endpointId}`, connectivity);
|
|
54202
|
+
await this.postEventBody(userId, endpoint.endpointId, (accessToken) => buildConnectivityChangeReportPayload({
|
|
54203
|
+
endpointId: endpoint.endpointId,
|
|
52540
54204
|
connectivity,
|
|
52541
54205
|
timestamp: Date.now(),
|
|
52542
54206
|
accessToken
|
|
52543
|
-
}), `connectivity ${connectivity}`);
|
|
54207
|
+
}), `initial connectivity ${connectivity}`);
|
|
52544
54208
|
}
|
|
52545
54209
|
}
|
|
52546
54210
|
/**
|
|
@@ -52599,14 +54263,25 @@ var LwaEventGateway = class {
|
|
|
52599
54263
|
* with a fresh header would ship a token Amazon has already rejected. */
|
|
52600
54264
|
async postEventBody(userId, endpointId, buildBody, label) {
|
|
52601
54265
|
const url = EVENT_GATEWAY_URLS[this.deps.getRegion()];
|
|
52602
|
-
const send = async (accessToken) =>
|
|
52603
|
-
|
|
52604
|
-
|
|
52605
|
-
|
|
52606
|
-
|
|
52607
|
-
|
|
52608
|
-
|
|
52609
|
-
|
|
54266
|
+
const send = async (accessToken) => {
|
|
54267
|
+
const body = JSON.stringify(buildBody(accessToken));
|
|
54268
|
+
return sendWithGatewayRetry(() => fetch(url, {
|
|
54269
|
+
method: "POST",
|
|
54270
|
+
headers: {
|
|
54271
|
+
"Content-Type": "application/json",
|
|
54272
|
+
Authorization: `Bearer ${accessToken}`
|
|
54273
|
+
},
|
|
54274
|
+
body
|
|
54275
|
+
}), (attempt, reason, delayMs) => {
|
|
54276
|
+
this.deps.logger.info(`alexa lwa-gateway: ${label} retrying`, { meta: {
|
|
54277
|
+
userId,
|
|
54278
|
+
endpointId,
|
|
54279
|
+
attempt,
|
|
54280
|
+
reason,
|
|
54281
|
+
delayMs
|
|
54282
|
+
} });
|
|
54283
|
+
});
|
|
54284
|
+
};
|
|
52610
54285
|
const initial = this.findToken(userId);
|
|
52611
54286
|
if (!initial) {
|
|
52612
54287
|
if (!this.warnedNoToken.has(userId)) {
|
|
@@ -52781,12 +54456,65 @@ var LwaEventGateway = class {
|
|
|
52781
54456
|
return false;
|
|
52782
54457
|
}
|
|
52783
54458
|
};
|
|
54459
|
+
/**
|
|
54460
|
+
* Statuses Amazon tells a sender to retry (smarthome/send-events: 429
|
|
54461
|
+
* THROTTLING, 500 INTERNAL_SERVICE, 503 SERVICE_UNAVAILABLE — "resend the
|
|
54462
|
+
* message up to three times, with at least a one-second interval").
|
|
54463
|
+
*/
|
|
54464
|
+
var RETRYABLE_GATEWAY_STATUSES = new Set([
|
|
54465
|
+
429,
|
|
54466
|
+
500,
|
|
54467
|
+
503
|
|
54468
|
+
]);
|
|
54469
|
+
/** Delays before retry 1, 2, 3 — bounded, at least one second each. */
|
|
54470
|
+
var GATEWAY_RETRY_DELAYS_MS = [
|
|
54471
|
+
1e3,
|
|
54472
|
+
2e3,
|
|
54473
|
+
4e3
|
|
54474
|
+
];
|
|
54475
|
+
/** A `Retry-After` the gateway sends is honoured up to this much. */
|
|
54476
|
+
var MAX_RETRY_AFTER_MS = 1e4;
|
|
54477
|
+
function retryAfterMs(response) {
|
|
54478
|
+
const raw = response.headers?.get?.("retry-after");
|
|
54479
|
+
if (raw === null || raw === void 0) return 0;
|
|
54480
|
+
const seconds = Number(raw);
|
|
54481
|
+
return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds * 1e3, MAX_RETRY_AFTER_MS) : 0;
|
|
54482
|
+
}
|
|
54483
|
+
/**
|
|
54484
|
+
* POST with Amazon's retry rule: a retryable status or a network failure is
|
|
54485
|
+
* re-sent up to three times, waiting 1 s / 2 s / 4 s (or the gateway's
|
|
54486
|
+
* `Retry-After`, capped). Anything else — 2xx, 401 (renewal is the caller's),
|
|
54487
|
+
* 403, 400 — is returned at once. The last answer (or the last network error)
|
|
54488
|
+
* is what the caller sees.
|
|
54489
|
+
*/
|
|
54490
|
+
async function sendWithGatewayRetry(attemptOnce, onRetry) {
|
|
54491
|
+
for (let attempt = 0;; attempt++) {
|
|
54492
|
+
let response = null;
|
|
54493
|
+
let failure = null;
|
|
54494
|
+
try {
|
|
54495
|
+
response = await attemptOnce();
|
|
54496
|
+
} catch (err) {
|
|
54497
|
+
failure = err;
|
|
54498
|
+
}
|
|
54499
|
+
const retryable = response === null || RETRYABLE_GATEWAY_STATUSES.has(response.status);
|
|
54500
|
+
const delay = GATEWAY_RETRY_DELAYS_MS[attempt];
|
|
54501
|
+
if (!retryable || delay === void 0) {
|
|
54502
|
+
if (response !== null) return response;
|
|
54503
|
+
throw failure;
|
|
54504
|
+
}
|
|
54505
|
+
const wait = response === null ? delay : Math.max(delay, retryAfterMs(response));
|
|
54506
|
+
onRetry(attempt + 1, response === null ? `network: ${failure instanceof Error ? failure.message : String(failure)}` : `status ${response.status}`, wait);
|
|
54507
|
+
await new Promise((resolve) => {
|
|
54508
|
+
setTimeout(resolve, wait).unref?.();
|
|
54509
|
+
});
|
|
54510
|
+
}
|
|
54511
|
+
}
|
|
52784
54512
|
function buildDoorbellPressPayload(opts) {
|
|
52785
54513
|
return { event: {
|
|
52786
54514
|
header: {
|
|
52787
54515
|
namespace: "Alexa.DoorbellEventSource",
|
|
52788
54516
|
name: "DoorbellPress",
|
|
52789
|
-
messageId:
|
|
54517
|
+
messageId: randomUUID(),
|
|
52790
54518
|
payloadVersion: "3"
|
|
52791
54519
|
},
|
|
52792
54520
|
endpoint: {
|
|
@@ -52809,7 +54537,7 @@ function buildMotionChangeReportPayload(opts) {
|
|
|
52809
54537
|
header: {
|
|
52810
54538
|
namespace: "Alexa",
|
|
52811
54539
|
name: "ChangeReport",
|
|
52812
|
-
messageId:
|
|
54540
|
+
messageId: randomUUID(),
|
|
52813
54541
|
payloadVersion: "3"
|
|
52814
54542
|
},
|
|
52815
54543
|
endpoint: {
|
|
@@ -52833,14 +54561,14 @@ function buildMotionChangeReportPayload(opts) {
|
|
|
52833
54561
|
context: { properties: [] }
|
|
52834
54562
|
};
|
|
52835
54563
|
}
|
|
52836
|
-
function
|
|
54564
|
+
function buildStateChangeReportPayload(opts) {
|
|
52837
54565
|
const timeOfSample = new Date(opts.timestamp).toISOString();
|
|
52838
54566
|
return {
|
|
52839
54567
|
event: {
|
|
52840
54568
|
header: {
|
|
52841
54569
|
namespace: "Alexa",
|
|
52842
54570
|
name: "ChangeReport",
|
|
52843
|
-
messageId:
|
|
54571
|
+
messageId: randomUUID(),
|
|
52844
54572
|
payloadVersion: "3"
|
|
52845
54573
|
},
|
|
52846
54574
|
endpoint: {
|
|
@@ -52851,14 +54579,14 @@ function buildBatteryChangeReportPayload(opts) {
|
|
|
52851
54579
|
endpointId: opts.endpointId
|
|
52852
54580
|
},
|
|
52853
54581
|
payload: { change: {
|
|
52854
|
-
cause: { type: "
|
|
52855
|
-
properties:
|
|
52856
|
-
namespace:
|
|
52857
|
-
name:
|
|
52858
|
-
value:
|
|
54582
|
+
cause: { type: "PHYSICAL_INTERACTION" },
|
|
54583
|
+
properties: opts.properties.map((p) => ({
|
|
54584
|
+
namespace: p.namespace,
|
|
54585
|
+
name: p.name,
|
|
54586
|
+
value: p.value,
|
|
52859
54587
|
timeOfSample,
|
|
52860
54588
|
uncertaintyInMilliseconds: 0
|
|
52861
|
-
}
|
|
54589
|
+
}))
|
|
52862
54590
|
} }
|
|
52863
54591
|
},
|
|
52864
54592
|
context: { properties: [] }
|
|
@@ -52900,7 +54628,7 @@ function buildSmartVisionObjectDetectionEvent(opts) {
|
|
|
52900
54628
|
header: {
|
|
52901
54629
|
namespace: "Alexa.SmartVision.ObjectDetectionSensor",
|
|
52902
54630
|
name: "ObjectDetection",
|
|
52903
|
-
messageId:
|
|
54631
|
+
messageId: randomUUID(),
|
|
52904
54632
|
payloadVersion: "1.0"
|
|
52905
54633
|
},
|
|
52906
54634
|
endpoint: {
|
|
@@ -52921,12 +54649,12 @@ function buildSmartVisionObjectDetectionEvent(opts) {
|
|
|
52921
54649
|
})) }
|
|
52922
54650
|
} };
|
|
52923
54651
|
}
|
|
52924
|
-
function buildAddOrUpdateReportPayload(
|
|
54652
|
+
function buildAddOrUpdateReportPayload(_userId, endpoints, accessToken) {
|
|
52925
54653
|
return { event: {
|
|
52926
54654
|
header: {
|
|
52927
54655
|
namespace: "Alexa.Discovery",
|
|
52928
54656
|
name: "AddOrUpdateReport",
|
|
52929
|
-
messageId:
|
|
54657
|
+
messageId: randomUUID(),
|
|
52930
54658
|
payloadVersion: "3"
|
|
52931
54659
|
},
|
|
52932
54660
|
payload: {
|
|
@@ -52938,12 +54666,12 @@ function buildAddOrUpdateReportPayload(userId, endpoints, accessToken) {
|
|
|
52938
54666
|
}
|
|
52939
54667
|
} };
|
|
52940
54668
|
}
|
|
52941
|
-
function buildDeleteReportPayload(
|
|
54669
|
+
function buildDeleteReportPayload(_userId, endpointIds, accessToken) {
|
|
52942
54670
|
return { event: {
|
|
52943
54671
|
header: {
|
|
52944
54672
|
namespace: "Alexa.Discovery",
|
|
52945
54673
|
name: "DeleteReport",
|
|
52946
|
-
messageId:
|
|
54674
|
+
messageId: randomUUID(),
|
|
52947
54675
|
payloadVersion: "3"
|
|
52948
54676
|
},
|
|
52949
54677
|
payload: {
|
|
@@ -52956,6 +54684,425 @@ function buildDeleteReportPayload(userId, endpointIds, accessToken) {
|
|
|
52956
54684
|
} };
|
|
52957
54685
|
}
|
|
52958
54686
|
//#endregion
|
|
54687
|
+
//#region src/access/account-visibility.ts
|
|
54688
|
+
/** Event-path gate. Unknown account ⇒ refused (fail closed). */
|
|
54689
|
+
function visibilityAllows(entry, deviceId) {
|
|
54690
|
+
if (entry === void 0) return false;
|
|
54691
|
+
if (entry.all === true) return true;
|
|
54692
|
+
return entry.view.includes(deviceId);
|
|
54693
|
+
}
|
|
54694
|
+
function visibleSet(entry, exposed) {
|
|
54695
|
+
if (entry === void 0 || entry.all === true) return new Set(exposed);
|
|
54696
|
+
return new Set(entry.view);
|
|
54697
|
+
}
|
|
54698
|
+
var AccountVisibilityMirror = class {
|
|
54699
|
+
deps;
|
|
54700
|
+
flight = null;
|
|
54701
|
+
constructor(deps) {
|
|
54702
|
+
this.deps = deps;
|
|
54703
|
+
}
|
|
54704
|
+
/** Synchronous event-path read. */
|
|
54705
|
+
allows(userId, deviceId) {
|
|
54706
|
+
return visibilityAllows(this.deps.getStored()[userId], deviceId);
|
|
54707
|
+
}
|
|
54708
|
+
/** Recompute every linked account. One pass at a time; a caller joins the
|
|
54709
|
+
* pass in flight. Returns the changes the caller must apply to Alexa. */
|
|
54710
|
+
async refresh() {
|
|
54711
|
+
if (this.flight === null) this.flight = this.refreshNow().finally(() => {
|
|
54712
|
+
this.flight = null;
|
|
54713
|
+
});
|
|
54714
|
+
return this.flight;
|
|
54715
|
+
}
|
|
54716
|
+
async refreshNow() {
|
|
54717
|
+
const exposed = this.deps.listExposedDeviceIds();
|
|
54718
|
+
const stored = this.deps.getStored();
|
|
54719
|
+
const next = {};
|
|
54720
|
+
const changes = [];
|
|
54721
|
+
for (const userId of this.deps.listLinkedUserIds()) {
|
|
54722
|
+
const prev = stored[userId];
|
|
54723
|
+
const computed = await this.compute(userId, prev, exposed);
|
|
54724
|
+
if (computed === null) {
|
|
54725
|
+
if (prev !== void 0) next[userId] = prev;
|
|
54726
|
+
continue;
|
|
54727
|
+
}
|
|
54728
|
+
next[userId] = computed.entry;
|
|
54729
|
+
if (computed.change.added.length > 0 || computed.change.removed.length > 0) changes.push(computed.change);
|
|
54730
|
+
}
|
|
54731
|
+
await this.deps.setStored(next);
|
|
54732
|
+
return changes;
|
|
54733
|
+
}
|
|
54734
|
+
async compute(userId, prev, exposed) {
|
|
54735
|
+
const lookup = await this.deps.gate.principal(userId);
|
|
54736
|
+
if (lookup.kind === "unavailable") return null;
|
|
54737
|
+
const before = visibleSet(prev, exposed);
|
|
54738
|
+
const prevPending = new Set(prev !== void 0 && prev.all !== true ? prev.pendingRemoval ?? [] : []);
|
|
54739
|
+
if (lookup.kind === "found" && lookup.principal.kind === "admin") return {
|
|
54740
|
+
entry: { all: true },
|
|
54741
|
+
change: {
|
|
54742
|
+
userId,
|
|
54743
|
+
added: prev === void 0 ? [] : exposed.filter((id) => !before.has(id)),
|
|
54744
|
+
removed: []
|
|
54745
|
+
}
|
|
54746
|
+
};
|
|
54747
|
+
const view = [];
|
|
54748
|
+
for (const deviceId of exposed) {
|
|
54749
|
+
if (lookup.kind === "unknown-user") continue;
|
|
54750
|
+
const verdict = await this.deps.gate.allows(lookup.principal, deviceId, "view");
|
|
54751
|
+
if (verdict === "allowed" || verdict === "unknown" && before.has(deviceId)) view.push(deviceId);
|
|
54752
|
+
}
|
|
54753
|
+
const viewSet = new Set(view);
|
|
54754
|
+
const added = prev === void 0 ? [] : view.filter((id) => !before.has(id));
|
|
54755
|
+
const lostNow = exposed.filter((id) => before.has(id) && !viewSet.has(id));
|
|
54756
|
+
const removed = [...prevPending].filter((id) => !viewSet.has(id));
|
|
54757
|
+
const pendingRemoval = lostNow.filter((id) => !prevPending.has(id));
|
|
54758
|
+
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 } });
|
|
54759
|
+
return {
|
|
54760
|
+
entry: pendingRemoval.length > 0 ? {
|
|
54761
|
+
view,
|
|
54762
|
+
pendingRemoval
|
|
54763
|
+
} : { view },
|
|
54764
|
+
change: {
|
|
54765
|
+
userId,
|
|
54766
|
+
added,
|
|
54767
|
+
removed
|
|
54768
|
+
}
|
|
54769
|
+
};
|
|
54770
|
+
}
|
|
54771
|
+
};
|
|
54772
|
+
//#endregion
|
|
54773
|
+
//#region src/access/hub-access-source.ts
|
|
54774
|
+
function createHubAccessGate(ctx) {
|
|
54775
|
+
return new AlexaAccessGate({
|
|
54776
|
+
logger: ctx.logger,
|
|
54777
|
+
listUserGrants: async () => {
|
|
54778
|
+
return (await ctx.api.userManagement.listUsers.query()).map((u) => ({
|
|
54779
|
+
userId: u.id,
|
|
54780
|
+
isAdmin: u.isAdmin,
|
|
54781
|
+
scopes: u.scopes
|
|
54782
|
+
}));
|
|
54783
|
+
},
|
|
54784
|
+
getDeviceMeta: async (deviceId) => {
|
|
54785
|
+
const device = await ctx.api.deviceManager.getDevice.query({ deviceId });
|
|
54786
|
+
if (device === null) return null;
|
|
54787
|
+
return {
|
|
54788
|
+
id: device.id,
|
|
54789
|
+
type: String(device.type),
|
|
54790
|
+
location: device.location,
|
|
54791
|
+
parentDeviceId: device.parentDeviceId
|
|
54792
|
+
};
|
|
54793
|
+
}
|
|
54794
|
+
});
|
|
54795
|
+
}
|
|
54796
|
+
//#endregion
|
|
54797
|
+
//#region src/access/visibility-sync.ts
|
|
54798
|
+
async function applyVisibilityChanges(changes, target, logger) {
|
|
54799
|
+
for (const change of changes) {
|
|
54800
|
+
for (const deviceId of change.added) {
|
|
54801
|
+
const ok = await target.pushDeviceToAccount(change.userId, deviceId).catch((err) => {
|
|
54802
|
+
logger.warn("alexa access: pushing a newly granted device failed", {
|
|
54803
|
+
tags: { deviceId },
|
|
54804
|
+
meta: {
|
|
54805
|
+
userId: change.userId,
|
|
54806
|
+
error: errMsg(err)
|
|
54807
|
+
}
|
|
54808
|
+
});
|
|
54809
|
+
return false;
|
|
54810
|
+
});
|
|
54811
|
+
logger.info("alexa access: the linked user gained this device — pushed to their Alexa account", {
|
|
54812
|
+
tags: { deviceId },
|
|
54813
|
+
meta: {
|
|
54814
|
+
userId: change.userId,
|
|
54815
|
+
delivered: ok
|
|
54816
|
+
}
|
|
54817
|
+
});
|
|
54818
|
+
}
|
|
54819
|
+
for (const deviceId of change.removed) {
|
|
54820
|
+
const ok = await target.removeDeviceFromAccount(change.userId, deviceId).catch((err) => {
|
|
54821
|
+
logger.warn("alexa access: deleting a revoked device failed", {
|
|
54822
|
+
tags: { deviceId },
|
|
54823
|
+
meta: {
|
|
54824
|
+
userId: change.userId,
|
|
54825
|
+
error: errMsg(err)
|
|
54826
|
+
}
|
|
54827
|
+
});
|
|
54828
|
+
return false;
|
|
54829
|
+
});
|
|
54830
|
+
logger.warn("alexa access: the linked user lost this device (confirmed twice) — deleted from their Alexa account", {
|
|
54831
|
+
tags: { deviceId },
|
|
54832
|
+
meta: {
|
|
54833
|
+
userId: change.userId,
|
|
54834
|
+
delivered: ok
|
|
54835
|
+
}
|
|
54836
|
+
});
|
|
54837
|
+
}
|
|
54838
|
+
}
|
|
54839
|
+
}
|
|
54840
|
+
//#endregion
|
|
54841
|
+
//#region src/detection-sensors/detection-tracker.ts
|
|
54842
|
+
/**
|
|
54843
|
+
* Which detection classes are currently present on each camera (R1, D772).
|
|
54844
|
+
*
|
|
54845
|
+
* Fed by the pipeline's track lifecycle (`PipelineAnalyticsTrackLifecycle`,
|
|
54846
|
+
* `data.deviceId` — these events carry no device on their `source`) and by
|
|
54847
|
+
* package deliveries. A class is present while at least one of ITS tracks is
|
|
54848
|
+
* open: the set is per `(camera, class)` and per `trackId`, so the end of one
|
|
54849
|
+
* person's track does not clear "Person" while another person is still in
|
|
54850
|
+
* view (the HA export's per-event flip has exactly that bug).
|
|
54851
|
+
*
|
|
54852
|
+
* Events are telemetry and may be dropped (D8), and the pipeline does not
|
|
54853
|
+
* promise an `end` for every `start` (a zero-media false birth or a discarded
|
|
54854
|
+
* stale track ends silently). So {@link reconcile} replaces a camera's sets
|
|
54855
|
+
* with an authoritative read of its ACTIVE tracks; a sensor can therefore never
|
|
54856
|
+
* stay "detected" past one reconcile after its last track closed. A read that
|
|
54857
|
+
* fails is not passed in — the sets then stay as they were.
|
|
54858
|
+
*
|
|
54859
|
+
* Emits a transition per `(camera, class)` only when presence changes; the
|
|
54860
|
+
* Alexa-facing hold (≥ 30 s DETECTED before NOT_DETECTED) is the gateway's.
|
|
54861
|
+
*/
|
|
54862
|
+
/** A package delivery is an instant, not a track: it is present this long. */
|
|
54863
|
+
var PACKAGE_PULSE_MS = 1e3;
|
|
54864
|
+
/** The detection class a pipeline class name belongs to, or null. Pipeline
|
|
54865
|
+
* classes are already macros; a raw COCO label is mapped the same way the
|
|
54866
|
+
* executor maps it. */
|
|
54867
|
+
function detectionClassOf(className) {
|
|
54868
|
+
if (className === void 0) return null;
|
|
54869
|
+
const lower = className.toLowerCase();
|
|
54870
|
+
const isClass = (v) => DETECTION_SENSOR_CLASSES.includes(v);
|
|
54871
|
+
if (isClass(lower)) return lower;
|
|
54872
|
+
const mapped = COCO_TO_MACRO.mapping[lower];
|
|
54873
|
+
return mapped !== void 0 && isClass(mapped) ? mapped : null;
|
|
54874
|
+
}
|
|
54875
|
+
var DetectionTracker = class {
|
|
54876
|
+
onTransition;
|
|
54877
|
+
/** `${deviceId}|${class}` → open track ids. */
|
|
54878
|
+
open = /* @__PURE__ */ new Map();
|
|
54879
|
+
pulseTimers = /* @__PURE__ */ new Set();
|
|
54880
|
+
constructor(onTransition) {
|
|
54881
|
+
this.onTransition = onTransition;
|
|
54882
|
+
}
|
|
54883
|
+
onTrackLifecycle(event) {
|
|
54884
|
+
const cls = detectionClassOf(event.bestClassName ?? event.classes?.[0]);
|
|
54885
|
+
if (cls === null) return;
|
|
54886
|
+
if (event.phase === "end") {
|
|
54887
|
+
this.remove(event.deviceId, cls, event.trackId);
|
|
54888
|
+
for (const other of DETECTION_SENSOR_CLASSES) if (other !== cls) this.remove(event.deviceId, other, event.trackId);
|
|
54889
|
+
return;
|
|
54890
|
+
}
|
|
54891
|
+
this.add(event.deviceId, cls, event.trackId);
|
|
54892
|
+
}
|
|
54893
|
+
/** A package was delivered: "Package" is detected, as a pulse. */
|
|
54894
|
+
onPackageDelivered(deviceId, entryId) {
|
|
54895
|
+
const id = `package:${entryId}`;
|
|
54896
|
+
this.add(deviceId, "package", id);
|
|
54897
|
+
const timer = setTimeout(() => {
|
|
54898
|
+
this.pulseTimers.delete(timer);
|
|
54899
|
+
this.remove(deviceId, "package", id);
|
|
54900
|
+
}, PACKAGE_PULSE_MS);
|
|
54901
|
+
timer.unref?.();
|
|
54902
|
+
this.pulseTimers.add(timer);
|
|
54903
|
+
}
|
|
54904
|
+
/** Replace a camera's open tracks with what the pipeline says is ACTIVE. */
|
|
54905
|
+
reconcile(deviceId, active) {
|
|
54906
|
+
const truth = /* @__PURE__ */ new Map();
|
|
54907
|
+
for (const track of active) {
|
|
54908
|
+
const cls = detectionClassOf(track.className);
|
|
54909
|
+
if (cls === null) continue;
|
|
54910
|
+
const set = truth.get(cls) ?? /* @__PURE__ */ new Set();
|
|
54911
|
+
set.add(track.trackId);
|
|
54912
|
+
truth.set(cls, set);
|
|
54913
|
+
}
|
|
54914
|
+
for (const cls of DETECTION_SENSOR_CLASSES) {
|
|
54915
|
+
const key = `${deviceId}|${cls}`;
|
|
54916
|
+
const before = this.open.get(key) ?? /* @__PURE__ */ new Set();
|
|
54917
|
+
const after = truth.get(cls) ?? /* @__PURE__ */ new Set();
|
|
54918
|
+
for (const id of before) if (id.startsWith("package:")) after.add(id);
|
|
54919
|
+
this.replace(deviceId, cls, before, after);
|
|
54920
|
+
}
|
|
54921
|
+
}
|
|
54922
|
+
/** Cameras with at least one class present — the ones worth reconciling. */
|
|
54923
|
+
camerasWithPresence() {
|
|
54924
|
+
const out = /* @__PURE__ */ new Set();
|
|
54925
|
+
for (const [key, set] of this.open) if (set.size > 0) out.add(Number(key.split("|")[0]));
|
|
54926
|
+
return [...out];
|
|
54927
|
+
}
|
|
54928
|
+
isPresent(deviceId, cls) {
|
|
54929
|
+
return (this.open.get(`${deviceId}|${cls}`)?.size ?? 0) > 0;
|
|
54930
|
+
}
|
|
54931
|
+
clear() {
|
|
54932
|
+
this.open.clear();
|
|
54933
|
+
for (const t of this.pulseTimers) clearTimeout(t);
|
|
54934
|
+
this.pulseTimers.clear();
|
|
54935
|
+
}
|
|
54936
|
+
add(deviceId, cls, id) {
|
|
54937
|
+
const key = `${deviceId}|${cls}`;
|
|
54938
|
+
const set = this.open.get(key) ?? /* @__PURE__ */ new Set();
|
|
54939
|
+
const was = set.size > 0;
|
|
54940
|
+
set.add(id);
|
|
54941
|
+
this.open.set(key, set);
|
|
54942
|
+
if (!was) this.onTransition(deviceId, cls, true);
|
|
54943
|
+
}
|
|
54944
|
+
remove(deviceId, cls, id) {
|
|
54945
|
+
const set = this.open.get(`${deviceId}|${cls}`);
|
|
54946
|
+
if (set === void 0 || !set.delete(id)) return;
|
|
54947
|
+
if (set.size === 0) this.onTransition(deviceId, cls, false);
|
|
54948
|
+
}
|
|
54949
|
+
replace(deviceId, cls, before, after) {
|
|
54950
|
+
this.open.set(`${deviceId}|${cls}`, after);
|
|
54951
|
+
if (before.size > 0 && after.size === 0) this.onTransition(deviceId, cls, false);
|
|
54952
|
+
else if (before.size === 0 && after.size > 0) this.onTransition(deviceId, cls, true);
|
|
54953
|
+
}
|
|
54954
|
+
};
|
|
54955
|
+
/** The detection classes an event-kind answer offers. */
|
|
54956
|
+
function detectionClassesOfKinds(kinds) {
|
|
54957
|
+
const out = /* @__PURE__ */ new Set();
|
|
54958
|
+
for (const k of kinds) {
|
|
54959
|
+
if (k.level !== "macro") continue;
|
|
54960
|
+
if (k.category !== "detection" && k.category !== "package") continue;
|
|
54961
|
+
const cls = detectionClassOf(k.kind);
|
|
54962
|
+
if (cls !== null) out.add(cls);
|
|
54963
|
+
}
|
|
54964
|
+
return DETECTION_SENSOR_CLASSES.filter((c) => out.has(c));
|
|
54965
|
+
}
|
|
54966
|
+
function sameClasses(a, b) {
|
|
54967
|
+
return JSON.stringify(a ?? null) === JSON.stringify(b);
|
|
54968
|
+
}
|
|
54969
|
+
var DetectionClassSource = class {
|
|
54970
|
+
deps;
|
|
54971
|
+
/** Serializes the persisted map's read-modify-write: builds run four
|
|
54972
|
+
* cameras at once, and two writes computed off one snapshot would erase
|
|
54973
|
+
* each other's camera. */
|
|
54974
|
+
writeChain = Promise.resolve();
|
|
54975
|
+
constructor(deps) {
|
|
54976
|
+
this.deps = deps;
|
|
54977
|
+
}
|
|
54978
|
+
persist(deviceId, next) {
|
|
54979
|
+
const write = this.writeChain.then(() => this.deps.setStored({
|
|
54980
|
+
...this.deps.getStored(),
|
|
54981
|
+
[deviceId]: next
|
|
54982
|
+
}));
|
|
54983
|
+
this.writeChain = write.catch(() => void 0);
|
|
54984
|
+
return write;
|
|
54985
|
+
}
|
|
54986
|
+
async classesFor(deviceId) {
|
|
54987
|
+
const prev = this.deps.getStored()[deviceId];
|
|
54988
|
+
let read;
|
|
54989
|
+
try {
|
|
54990
|
+
read = detectionClassesOfKinds(await this.deps.readEventKinds(deviceId));
|
|
54991
|
+
} catch (err) {
|
|
54992
|
+
this.deps.logger.debug("alexa detection sensors: class read failed — keeping the last answer", {
|
|
54993
|
+
tags: { deviceId },
|
|
54994
|
+
meta: { error: errMsg(err) }
|
|
54995
|
+
});
|
|
54996
|
+
return prev?.classes ?? [];
|
|
54997
|
+
}
|
|
54998
|
+
const now = this.deps.now?.() ?? Date.now();
|
|
54999
|
+
const missingSince = {};
|
|
55000
|
+
const keep = new Set(read);
|
|
55001
|
+
const removed = [];
|
|
55002
|
+
for (const cls of prev?.classes ?? []) {
|
|
55003
|
+
if (keep.has(cls)) continue;
|
|
55004
|
+
const since = prev?.missingSince?.[cls] ?? now;
|
|
55005
|
+
if (now - since >= 9e5) {
|
|
55006
|
+
removed.push(cls);
|
|
55007
|
+
continue;
|
|
55008
|
+
}
|
|
55009
|
+
keep.add(cls);
|
|
55010
|
+
missingSince[cls] = since;
|
|
55011
|
+
}
|
|
55012
|
+
const classes = DETECTION_SENSOR_CLASSES.filter((c) => keep.has(c));
|
|
55013
|
+
const next = Object.keys(missingSince).length > 0 ? {
|
|
55014
|
+
classes,
|
|
55015
|
+
missingSince
|
|
55016
|
+
} : { classes };
|
|
55017
|
+
if (!sameClasses(prev, next)) {
|
|
55018
|
+
await this.persist(deviceId, next);
|
|
55019
|
+
if (removed.length > 0 || classes.some((c) => !(prev?.classes ?? []).includes(c))) this.deps.logger.info("alexa detection sensors: the camera's detection classes changed", {
|
|
55020
|
+
tags: { deviceId },
|
|
55021
|
+
meta: {
|
|
55022
|
+
classes,
|
|
55023
|
+
removed
|
|
55024
|
+
}
|
|
55025
|
+
});
|
|
55026
|
+
}
|
|
55027
|
+
return classes;
|
|
55028
|
+
}
|
|
55029
|
+
};
|
|
55030
|
+
//#endregion
|
|
55031
|
+
//#region src/detection-sensors/detection-feed.ts
|
|
55032
|
+
var DETECTION_RECONCILE_INTERVAL_MS = 6e4;
|
|
55033
|
+
var TrackLifecycleSchema = object({
|
|
55034
|
+
deviceId: number(),
|
|
55035
|
+
trackId: string(),
|
|
55036
|
+
phase: _enum([
|
|
55037
|
+
"start",
|
|
55038
|
+
"update",
|
|
55039
|
+
"end"
|
|
55040
|
+
]),
|
|
55041
|
+
bestClassName: string().optional(),
|
|
55042
|
+
classes: array(string()).optional()
|
|
55043
|
+
});
|
|
55044
|
+
var PackageDeliveredSchema = object({
|
|
55045
|
+
deviceId: number(),
|
|
55046
|
+
entryId: string()
|
|
55047
|
+
});
|
|
55048
|
+
var DetectionSensorFeed = class {
|
|
55049
|
+
deps;
|
|
55050
|
+
tracker;
|
|
55051
|
+
offs = [];
|
|
55052
|
+
timer = null;
|
|
55053
|
+
constructor(deps) {
|
|
55054
|
+
this.deps = deps;
|
|
55055
|
+
this.tracker = new DetectionTracker((deviceId, cls, present) => {
|
|
55056
|
+
this.onTransition(deviceId, cls, present);
|
|
55057
|
+
});
|
|
55058
|
+
}
|
|
55059
|
+
start() {
|
|
55060
|
+
this.offs.push(this.deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
|
|
55061
|
+
const parsed = TrackLifecycleSchema.safeParse(event.data);
|
|
55062
|
+
if (parsed.success) this.tracker.onTrackLifecycle(parsed.data);
|
|
55063
|
+
}), this.deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsPackageDelivered }, (event) => {
|
|
55064
|
+
const parsed = PackageDeliveredSchema.safeParse(event.data);
|
|
55065
|
+
if (parsed.success) this.tracker.onPackageDelivered(parsed.data.deviceId, parsed.data.entryId);
|
|
55066
|
+
}));
|
|
55067
|
+
this.timer = setInterval(() => void this.reconcile(), DETECTION_RECONCILE_INTERVAL_MS);
|
|
55068
|
+
this.timer.unref?.();
|
|
55069
|
+
}
|
|
55070
|
+
stop() {
|
|
55071
|
+
for (const off of this.offs.splice(0)) off();
|
|
55072
|
+
if (this.timer !== null) clearInterval(this.timer);
|
|
55073
|
+
this.timer = null;
|
|
55074
|
+
this.tracker.clear();
|
|
55075
|
+
}
|
|
55076
|
+
isPresent(deviceId, cls) {
|
|
55077
|
+
return this.deps.enabled() && this.tracker.isPresent(deviceId, cls);
|
|
55078
|
+
}
|
|
55079
|
+
/** One reconcile pass over every camera that shows a class present. A read
|
|
55080
|
+
* that fails changes nothing for that camera. */
|
|
55081
|
+
async reconcile() {
|
|
55082
|
+
for (const deviceId of this.tracker.camerasWithPresence()) try {
|
|
55083
|
+
this.tracker.reconcile(deviceId, await this.deps.readActiveTracks(deviceId));
|
|
55084
|
+
} catch (err) {
|
|
55085
|
+
this.deps.logger.debug("alexa detection sensors: active-track read failed — kept as is", {
|
|
55086
|
+
tags: { deviceId },
|
|
55087
|
+
meta: { error: errMsg(err) }
|
|
55088
|
+
});
|
|
55089
|
+
}
|
|
55090
|
+
}
|
|
55091
|
+
onTransition(deviceId, cls, present) {
|
|
55092
|
+
if (!this.deps.enabled() || !this.deps.isExposed(deviceId)) return;
|
|
55093
|
+
this.deps.push(deviceId, cls, present, Date.now()).catch((err) => {
|
|
55094
|
+
this.deps.logger.warn("alexa detection sensors: change report failed", {
|
|
55095
|
+
tags: { deviceId },
|
|
55096
|
+
meta: {
|
|
55097
|
+
detectionClass: cls,
|
|
55098
|
+
present,
|
|
55099
|
+
error: errMsg(err)
|
|
55100
|
+
}
|
|
55101
|
+
});
|
|
55102
|
+
});
|
|
55103
|
+
}
|
|
55104
|
+
};
|
|
55105
|
+
//#endregion
|
|
52959
55106
|
//#region src/oauth-integration.ts
|
|
52960
55107
|
/**
|
|
52961
55108
|
* The `export-alexa` OAuth client — the descriptor behind
|
|
@@ -53053,6 +55200,418 @@ function buildAlexaOauthIntegration(publicHubUrl) {
|
|
|
53053
55200
|
};
|
|
53054
55201
|
}
|
|
53055
55202
|
//#endregion
|
|
55203
|
+
//#region src/setup-block.ts
|
|
55204
|
+
function buildAlexaSetupBlock(input) {
|
|
55205
|
+
const base = input.publicHubUrl.replace(/\/+$/, "");
|
|
55206
|
+
return {
|
|
55207
|
+
fields: [
|
|
55208
|
+
...base ? [{
|
|
55209
|
+
label: "Directive endpoint",
|
|
55210
|
+
value: `${base}/addon/export-alexa/directive`
|
|
55211
|
+
}] : [],
|
|
55212
|
+
...input.ingressToken ? [{
|
|
55213
|
+
label: "Ingress token (Lambda HUB_INGRESS_TOKEN) — use \"Show ingress token\" in settings",
|
|
55214
|
+
value: maskSecretTail(input.ingressToken)
|
|
55215
|
+
}] : [],
|
|
55216
|
+
{
|
|
55217
|
+
label: "Linked Alexa accounts",
|
|
55218
|
+
value: String(input.linkedAccountCount)
|
|
55219
|
+
}
|
|
55220
|
+
],
|
|
55221
|
+
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."
|
|
55222
|
+
};
|
|
55223
|
+
}
|
|
55224
|
+
/**
|
|
55225
|
+
* The ONE derivation of "a signed, expiring URL".
|
|
55226
|
+
*
|
|
55227
|
+
* This repo mints unguessable, self-expiring links in three places now — the
|
|
55228
|
+
* notification artifact plane, the Home Assistant media plane, and the snapshot
|
|
55229
|
+
* link plane. The first two grew independently and are byte-identical logic
|
|
55230
|
+
* (`hmac(secret, "<id>:<exp>")`, expiry checked before a constant-time compare),
|
|
55231
|
+
* each restating the crypto locally because **addons never import each other**.
|
|
55232
|
+
*
|
|
55233
|
+
* That reason is real, and the conclusion drawn from it was wrong. Two copies of
|
|
55234
|
+
* a signing scheme is how one of them quietly ends up with a different TTL, a
|
|
55235
|
+
* different compare, or a missing expiry check, and nothing fails until a link
|
|
55236
|
+
* that should have died keeps working. The fix is the same one D52 applies to
|
|
55237
|
+
* crop geometry: one derivation, in a place every addon may depend on. A
|
|
55238
|
+
* framework package is exactly that place — `@camstack/types/node`, off the root
|
|
55239
|
+
* entry because `node:crypto` must never be traversed by a browser bundler.
|
|
55240
|
+
*
|
|
55241
|
+
* What this module deliberately does NOT decide: the TTL, the base URL, the
|
|
55242
|
+
* shape of `id`, and the access level of the route. Those are per-plane policy
|
|
55243
|
+
* and each caller states them where a reader can see them.
|
|
55244
|
+
*/
|
|
55245
|
+
/**
|
|
55246
|
+
* The signature over `(id, expMs)`.
|
|
55247
|
+
*
|
|
55248
|
+
* `id` is whatever the plane uses to name the thing being served — an artifact
|
|
55249
|
+
* id, a track id, a `<deviceId>:<width>` pair. It is joined with `:` so a caller
|
|
55250
|
+
* must not put a `:` inside a field whose boundary matters; where a plane has
|
|
55251
|
+
* more than one field, it composes them itself and owns that ambiguity.
|
|
55252
|
+
*/
|
|
55253
|
+
function signExpiringUrl(secret, id, expMs) {
|
|
55254
|
+
return createHmac("sha256", secret).update(`${id}:${String(expMs)}`).digest("hex");
|
|
55255
|
+
}
|
|
55256
|
+
/**
|
|
55257
|
+
* Verify a request's `(id, exp, sig)`.
|
|
55258
|
+
*
|
|
55259
|
+
* **Expiry is checked BEFORE the compare**, so an expired link is refused
|
|
55260
|
+
* whether or not its signature is valid — a leaked URL stops working on its own
|
|
55261
|
+
* and cannot be kept alive by holding a correct signature. The compare itself is
|
|
55262
|
+
* constant-time so a public route cannot be probed for the signature byte by
|
|
55263
|
+
* byte.
|
|
55264
|
+
*/
|
|
55265
|
+
function verifyExpiringUrl(input) {
|
|
55266
|
+
const { secret, id, exp, sig, nowMs } = input;
|
|
55267
|
+
if (exp === void 0 || sig === void 0) return false;
|
|
55268
|
+
const expMs = typeof exp === "number" ? exp : Number(exp);
|
|
55269
|
+
if (!Number.isFinite(expMs)) return false;
|
|
55270
|
+
if (expMs <= nowMs) return false;
|
|
55271
|
+
const expected = signExpiringUrl(secret, id, expMs);
|
|
55272
|
+
const a = Buffer.from(expected, "utf8");
|
|
55273
|
+
const b = Buffer.from(sig, "utf8");
|
|
55274
|
+
if (a.length !== b.length) return false;
|
|
55275
|
+
return timingSafeEqual(a, b);
|
|
55276
|
+
}
|
|
55277
|
+
//#endregion
|
|
55278
|
+
//#region src/snapshots/snapshot-store.ts
|
|
55279
|
+
/**
|
|
55280
|
+
* The images this hub has handed to Alexa (R2, D773), each behind a signed,
|
|
55281
|
+
* short-lived URL.
|
|
55282
|
+
*
|
|
55283
|
+
* Amazon's rules for a SnapshotProvider image URI (device-apis/
|
|
55284
|
+
* alexa-smartvision-snapshotprovider): HTTPS, NOT public — Alexa presents the
|
|
55285
|
+
* user's access token — valid about ten minutes, HTTP 410 once expired, no
|
|
55286
|
+
* redirects. So every image is minted for ONE linked user and ONE device, kept
|
|
55287
|
+
* in memory until it expires, and addressed by a random id whose URL carries an
|
|
55288
|
+
* HMAC over `(imageId, exp)`. A hub restart forgets them all; Alexa then gets a
|
|
55289
|
+
* 410 and asks for a new snapshot, which is the documented recovery.
|
|
55290
|
+
*
|
|
55291
|
+
* Bounded by count and by bytes: Alexa refreshes a tile at most every
|
|
55292
|
+
* `minRefreshIntervalInSeconds` per camera, so a few hundred kilobytes per
|
|
55293
|
+
* camera is the steady state; the bound is what keeps a misbehaving client from
|
|
55294
|
+
* turning this into a memory leak.
|
|
55295
|
+
*/
|
|
55296
|
+
/** How long a minted image URL is valid — Amazon recommends ten minutes. */
|
|
55297
|
+
var SNAPSHOT_URL_TTL_MS = 10 * 6e4;
|
|
55298
|
+
var MAX_IMAGES = 128;
|
|
55299
|
+
var MAX_TOTAL_BYTES = 24 * 1024 * 1024;
|
|
55300
|
+
var SnapshotStore = class {
|
|
55301
|
+
secret;
|
|
55302
|
+
now;
|
|
55303
|
+
images = /* @__PURE__ */ new Map();
|
|
55304
|
+
totalBytes = 0;
|
|
55305
|
+
constructor(secret, now = Date.now) {
|
|
55306
|
+
this.secret = secret;
|
|
55307
|
+
this.now = now;
|
|
55308
|
+
}
|
|
55309
|
+
/** Keep `bytes` for `userId`/`deviceId` and return the signed path to it. */
|
|
55310
|
+
mint(input) {
|
|
55311
|
+
this.prune();
|
|
55312
|
+
const imageId = randomUUID();
|
|
55313
|
+
const expiresAt = this.now() + SNAPSHOT_URL_TTL_MS;
|
|
55314
|
+
const image = {
|
|
55315
|
+
imageId,
|
|
55316
|
+
deviceId: input.deviceId,
|
|
55317
|
+
userId: input.userId,
|
|
55318
|
+
bytes: input.bytes,
|
|
55319
|
+
contentType: "image/jpeg",
|
|
55320
|
+
capturedAt: input.capturedAt,
|
|
55321
|
+
expiresAt
|
|
55322
|
+
};
|
|
55323
|
+
this.images.set(imageId, image);
|
|
55324
|
+
this.totalBytes += image.bytes.length;
|
|
55325
|
+
this.evictOverBounds();
|
|
55326
|
+
return {
|
|
55327
|
+
imageId,
|
|
55328
|
+
expiresAt,
|
|
55329
|
+
path: `/addon/export-alexa/snapshot/${imageId}.jpg?exp=${expiresAt}&sig=${signExpiringUrl(this.secret(), imageId, expiresAt)}`
|
|
55330
|
+
};
|
|
55331
|
+
}
|
|
55332
|
+
/** Resolve a fetch of a minted URL. */
|
|
55333
|
+
lookup(imageId, exp, sig) {
|
|
55334
|
+
const now = this.now();
|
|
55335
|
+
const expMs = Number(exp);
|
|
55336
|
+
if (Number.isFinite(expMs) && expMs <= now) return {
|
|
55337
|
+
kind: "gone",
|
|
55338
|
+
reason: "expired"
|
|
55339
|
+
};
|
|
55340
|
+
if (!verifyExpiringUrl({
|
|
55341
|
+
secret: this.secret(),
|
|
55342
|
+
id: imageId,
|
|
55343
|
+
exp,
|
|
55344
|
+
sig,
|
|
55345
|
+
nowMs: now
|
|
55346
|
+
})) return {
|
|
55347
|
+
kind: "gone",
|
|
55348
|
+
reason: "bad-signature"
|
|
55349
|
+
};
|
|
55350
|
+
const image = this.images.get(imageId);
|
|
55351
|
+
if (image === void 0 || image.expiresAt <= now) return {
|
|
55352
|
+
kind: "gone",
|
|
55353
|
+
reason: "unknown-image"
|
|
55354
|
+
};
|
|
55355
|
+
return {
|
|
55356
|
+
kind: "found",
|
|
55357
|
+
image
|
|
55358
|
+
};
|
|
55359
|
+
}
|
|
55360
|
+
/** Drop everything — the integration switch was turned off. */
|
|
55361
|
+
clear() {
|
|
55362
|
+
this.images.clear();
|
|
55363
|
+
this.totalBytes = 0;
|
|
55364
|
+
}
|
|
55365
|
+
get size() {
|
|
55366
|
+
return this.images.size;
|
|
55367
|
+
}
|
|
55368
|
+
prune() {
|
|
55369
|
+
const now = this.now();
|
|
55370
|
+
for (const [id, image] of this.images) if (image.expiresAt <= now) this.drop(id, image);
|
|
55371
|
+
}
|
|
55372
|
+
evictOverBounds() {
|
|
55373
|
+
for (const [id, image] of this.images) {
|
|
55374
|
+
if (this.images.size <= MAX_IMAGES && this.totalBytes <= MAX_TOTAL_BYTES) return;
|
|
55375
|
+
this.drop(id, image);
|
|
55376
|
+
}
|
|
55377
|
+
}
|
|
55378
|
+
drop(id, image) {
|
|
55379
|
+
this.images.delete(id);
|
|
55380
|
+
this.totalBytes -= image.bytes.length;
|
|
55381
|
+
}
|
|
55382
|
+
};
|
|
55383
|
+
//#endregion
|
|
55384
|
+
//#region src/state-reports/state-change-map.ts
|
|
55385
|
+
/**
|
|
55386
|
+
* What a device's state change means for Alexa (R3, D776).
|
|
55387
|
+
*
|
|
55388
|
+
* Every cap state change on the hub arrives as ONE event —
|
|
55389
|
+
* `DeviceStateChanged {deviceId, capName, slice}` (the device-state mirror;
|
|
55390
|
+
* the per-cap `on…Changed` events the cap files declare are never emitted).
|
|
55391
|
+
* This maps one such change to the Alexa properties it changes, on the
|
|
55392
|
+
* endpoints this addon last published (`exportedEndpoints`): the alarm panel's
|
|
55393
|
+
* `armState` / `burglaryAlarm`, a privacy or autotrack switch's `powerState`,
|
|
55394
|
+
* an accessory's `powerState` / `brightness`, and — for `device-status` — the
|
|
55395
|
+
* device's connectivity.
|
|
55396
|
+
*
|
|
55397
|
+
* Pure: no I/O, so the mapping is pinned by a spec without an event bus.
|
|
55398
|
+
*/
|
|
55399
|
+
/** Every published endpoint, keyed by the device whose state drives it. */
|
|
55400
|
+
function indexByDrivingDevice(exported) {
|
|
55401
|
+
const out = /* @__PURE__ */ new Map();
|
|
55402
|
+
const add = (deviceId, entry) => {
|
|
55403
|
+
const list = out.get(deviceId) ?? [];
|
|
55404
|
+
list.push(entry);
|
|
55405
|
+
out.set(deviceId, list);
|
|
55406
|
+
};
|
|
55407
|
+
for (const [key, ids] of Object.entries(exported)) {
|
|
55408
|
+
const exposedDeviceId = Number(key);
|
|
55409
|
+
for (const endpointId of ids) {
|
|
55410
|
+
const parsed = parseCameraEndpointId(endpointId);
|
|
55411
|
+
switch (parsed.kind) {
|
|
55412
|
+
case "camera":
|
|
55413
|
+
add(parsed.deviceId, {
|
|
55414
|
+
exposedDeviceId,
|
|
55415
|
+
endpointId,
|
|
55416
|
+
kind: "camera"
|
|
55417
|
+
});
|
|
55418
|
+
break;
|
|
55419
|
+
case "privacy":
|
|
55420
|
+
case "autotrack":
|
|
55421
|
+
add(parsed.parentDeviceId, {
|
|
55422
|
+
exposedDeviceId,
|
|
55423
|
+
endpointId,
|
|
55424
|
+
kind: parsed.kind
|
|
55425
|
+
});
|
|
55426
|
+
break;
|
|
55427
|
+
case "child":
|
|
55428
|
+
add(parsed.childDeviceId, {
|
|
55429
|
+
exposedDeviceId,
|
|
55430
|
+
endpointId,
|
|
55431
|
+
kind: "child"
|
|
55432
|
+
});
|
|
55433
|
+
break;
|
|
55434
|
+
case "alarm-panel":
|
|
55435
|
+
add(parsed.deviceId, {
|
|
55436
|
+
exposedDeviceId,
|
|
55437
|
+
endpointId,
|
|
55438
|
+
kind: "alarm-panel"
|
|
55439
|
+
});
|
|
55440
|
+
break;
|
|
55441
|
+
case "detection":
|
|
55442
|
+
add(parsed.deviceId, {
|
|
55443
|
+
exposedDeviceId,
|
|
55444
|
+
endpointId,
|
|
55445
|
+
kind: "detection"
|
|
55446
|
+
});
|
|
55447
|
+
break;
|
|
55448
|
+
case "generic":
|
|
55449
|
+
add(parsed.deviceId, {
|
|
55450
|
+
exposedDeviceId,
|
|
55451
|
+
endpointId,
|
|
55452
|
+
kind: "generic"
|
|
55453
|
+
});
|
|
55454
|
+
break;
|
|
55455
|
+
case "invalid": break;
|
|
55456
|
+
}
|
|
55457
|
+
}
|
|
55458
|
+
}
|
|
55459
|
+
return out;
|
|
55460
|
+
}
|
|
55461
|
+
var EnabledSchema = object({ enabled: boolean() });
|
|
55462
|
+
var OnSchema = object({ on: boolean() });
|
|
55463
|
+
var PercentageSchema = object({ percentage: number() });
|
|
55464
|
+
var OnlineSchema = object({ online: boolean() });
|
|
55465
|
+
function power(on) {
|
|
55466
|
+
return {
|
|
55467
|
+
namespace: "Alexa.PowerController",
|
|
55468
|
+
name: "powerState",
|
|
55469
|
+
value: on ? "ON" : "OFF"
|
|
55470
|
+
};
|
|
55471
|
+
}
|
|
55472
|
+
function stateReportsFor(change, exported) {
|
|
55473
|
+
if (change.capName === "device-status") {
|
|
55474
|
+
const parsed = OnlineSchema.safeParse(change.slice);
|
|
55475
|
+
return parsed.success ? {
|
|
55476
|
+
kind: "connectivity",
|
|
55477
|
+
deviceId: change.deviceId,
|
|
55478
|
+
online: parsed.data.online
|
|
55479
|
+
} : null;
|
|
55480
|
+
}
|
|
55481
|
+
const endpoints = indexByDrivingDevice(exported).get(change.deviceId) ?? [];
|
|
55482
|
+
const reports = [];
|
|
55483
|
+
const of = (kind) => endpoints.filter((e) => e.kind === kind).map((e) => ({
|
|
55484
|
+
exposedDeviceId: e.exposedDeviceId,
|
|
55485
|
+
endpointId: e.endpointId
|
|
55486
|
+
}));
|
|
55487
|
+
const generic = genericPropertyFor(change.capName, change.slice);
|
|
55488
|
+
if (generic !== null) for (const e of of("generic")) reports.push({
|
|
55489
|
+
...e,
|
|
55490
|
+
interfaceName: generic.namespace,
|
|
55491
|
+
properties: [generic]
|
|
55492
|
+
});
|
|
55493
|
+
switch (change.capName) {
|
|
55494
|
+
case "alarm-panel": {
|
|
55495
|
+
const parsed = AlarmPanelStatusSchema.pick({
|
|
55496
|
+
state: true,
|
|
55497
|
+
targetMode: true
|
|
55498
|
+
}).safeParse(change.slice);
|
|
55499
|
+
if (!parsed.success) return null;
|
|
55500
|
+
for (const e of of("alarm-panel")) reports.push({
|
|
55501
|
+
...e,
|
|
55502
|
+
interfaceName: "Alexa.SecurityPanelController",
|
|
55503
|
+
properties: [{
|
|
55504
|
+
namespace: "Alexa.SecurityPanelController",
|
|
55505
|
+
name: "armState",
|
|
55506
|
+
value: alexaArmStateFor(parsed.data.state, parsed.data.targetMode ?? null)
|
|
55507
|
+
}, {
|
|
55508
|
+
namespace: "Alexa.SecurityPanelController",
|
|
55509
|
+
name: "burglaryAlarm",
|
|
55510
|
+
value: burglaryAlarmFor(parsed.data.state)
|
|
55511
|
+
}]
|
|
55512
|
+
});
|
|
55513
|
+
break;
|
|
55514
|
+
}
|
|
55515
|
+
case "privacy-mask":
|
|
55516
|
+
case "ptz-autotrack": {
|
|
55517
|
+
const parsed = EnabledSchema.safeParse(change.slice);
|
|
55518
|
+
if (!parsed.success) return null;
|
|
55519
|
+
const kind = change.capName === "privacy-mask" ? "privacy" : "autotrack";
|
|
55520
|
+
for (const e of of(kind)) reports.push({
|
|
55521
|
+
...e,
|
|
55522
|
+
interfaceName: "Alexa.PowerController",
|
|
55523
|
+
properties: [power(parsed.data.enabled)]
|
|
55524
|
+
});
|
|
55525
|
+
break;
|
|
55526
|
+
}
|
|
55527
|
+
case "switch": {
|
|
55528
|
+
const parsed = OnSchema.safeParse(change.slice);
|
|
55529
|
+
if (!parsed.success) break;
|
|
55530
|
+
for (const e of of("child")) reports.push({
|
|
55531
|
+
...e,
|
|
55532
|
+
interfaceName: "Alexa.PowerController",
|
|
55533
|
+
properties: [power(parsed.data.on)]
|
|
55534
|
+
});
|
|
55535
|
+
break;
|
|
55536
|
+
}
|
|
55537
|
+
case "brightness": {
|
|
55538
|
+
const parsed = PercentageSchema.safeParse(change.slice);
|
|
55539
|
+
if (!parsed.success) break;
|
|
55540
|
+
const value = Math.max(0, Math.min(100, Math.round(parsed.data.percentage)));
|
|
55541
|
+
for (const e of of("child")) reports.push({
|
|
55542
|
+
...e,
|
|
55543
|
+
interfaceName: "Alexa.BrightnessController",
|
|
55544
|
+
properties: [{
|
|
55545
|
+
namespace: "Alexa.BrightnessController",
|
|
55546
|
+
name: "brightness",
|
|
55547
|
+
value
|
|
55548
|
+
}]
|
|
55549
|
+
});
|
|
55550
|
+
break;
|
|
55551
|
+
}
|
|
55552
|
+
default: break;
|
|
55553
|
+
}
|
|
55554
|
+
return reports.length > 0 ? {
|
|
55555
|
+
kind: "reports",
|
|
55556
|
+
reports
|
|
55557
|
+
} : null;
|
|
55558
|
+
}
|
|
55559
|
+
//#endregion
|
|
55560
|
+
//#region src/state-reports/state-change-reporter.ts
|
|
55561
|
+
var DeviceStateChangedSchema = object({
|
|
55562
|
+
deviceId: number(),
|
|
55563
|
+
capName: string(),
|
|
55564
|
+
slice: record(string(), unknown())
|
|
55565
|
+
});
|
|
55566
|
+
var StateChangeReporter = class {
|
|
55567
|
+
deps;
|
|
55568
|
+
off = null;
|
|
55569
|
+
lastReported = /* @__PURE__ */ new Map();
|
|
55570
|
+
constructor(deps) {
|
|
55571
|
+
this.deps = deps;
|
|
55572
|
+
}
|
|
55573
|
+
start() {
|
|
55574
|
+
this.off = this.deps.eventBus.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
|
|
55575
|
+
const parsed = DeviceStateChangedSchema.safeParse(event.data);
|
|
55576
|
+
if (parsed.success) this.onChange(parsed.data);
|
|
55577
|
+
});
|
|
55578
|
+
}
|
|
55579
|
+
stop() {
|
|
55580
|
+
this.off?.();
|
|
55581
|
+
this.off = null;
|
|
55582
|
+
this.lastReported.clear();
|
|
55583
|
+
}
|
|
55584
|
+
async onChange(change) {
|
|
55585
|
+
const outcome = stateReportsFor(change, this.deps.getExportedEndpoints());
|
|
55586
|
+
if (outcome === null) return;
|
|
55587
|
+
try {
|
|
55588
|
+
if (outcome.kind === "connectivity") {
|
|
55589
|
+
await this.deps.reportConnectivity(outcome.deviceId, outcome.online ? "OK" : "UNREACHABLE");
|
|
55590
|
+
return;
|
|
55591
|
+
}
|
|
55592
|
+
for (const report of outcome.reports) {
|
|
55593
|
+
const key = `${report.endpointId}|${report.interfaceName}`;
|
|
55594
|
+
const value = JSON.stringify(report.properties);
|
|
55595
|
+
if (this.lastReported.get(key) === value) continue;
|
|
55596
|
+
this.lastReported.set(key, value);
|
|
55597
|
+
if (report.interfaceName === "Alexa.MotionSensor") {
|
|
55598
|
+
await this.deps.postHeldMotion(report, report.properties[0]?.value === "DETECTED");
|
|
55599
|
+
continue;
|
|
55600
|
+
}
|
|
55601
|
+
await this.deps.postStateChange(report);
|
|
55602
|
+
}
|
|
55603
|
+
} catch (err) {
|
|
55604
|
+
this.deps.logger.warn("alexa state reports: posting a state change failed", {
|
|
55605
|
+
tags: { deviceId: change.deviceId },
|
|
55606
|
+
meta: {
|
|
55607
|
+
capName: change.capName,
|
|
55608
|
+
error: errMsg(err)
|
|
55609
|
+
}
|
|
55610
|
+
});
|
|
55611
|
+
}
|
|
55612
|
+
}
|
|
55613
|
+
};
|
|
55614
|
+
//#endregion
|
|
53056
55615
|
//#region src/public-skill.ts
|
|
53057
55616
|
/**
|
|
53058
55617
|
* The built-in "public" CamStack Alexa skill.
|
|
@@ -53071,28 +55630,6 @@ function buildAlexaOauthIntegration(publicHubUrl) {
|
|
|
53071
55630
|
*/
|
|
53072
55631
|
var PUBLIC_SKILL_ID = "amzn1.ask.skill.aa85d6df-1600-4f13-bda9-babcfdc849e6";
|
|
53073
55632
|
//#endregion
|
|
53074
|
-
//#region src/endpoint-signature.ts
|
|
53075
|
-
/** One capability's identity as Alexa distinguishes it. */
|
|
53076
|
-
function capabilityKey(capability) {
|
|
53077
|
-
const instance = typeof capability.instance === "string" ? capability.instance : "";
|
|
53078
|
-
return instance.length > 0 ? `${capability.interface}#${instance}` : capability.interface;
|
|
53079
|
-
}
|
|
53080
|
-
/** Stable, order-independent signature of ONE endpoint's advertised shape. */
|
|
53081
|
-
function endpointKey(endpoint) {
|
|
53082
|
-
const interfaces = endpoint.capabilities.map(capabilityKey).sort().join(",");
|
|
53083
|
-
const categories = [...endpoint.displayCategories].sort().join(",");
|
|
53084
|
-
return `${endpoint.endpointId}|${categories}|${interfaces}`;
|
|
53085
|
-
}
|
|
53086
|
-
/**
|
|
53087
|
-
* Stable signature of the endpoint set built for ONE device. Two builds that
|
|
53088
|
-
* would land identically in Alexa's registry produce the same string; any
|
|
53089
|
-
* added, removed or renamed interface — or a changed display category —
|
|
53090
|
-
* produces a different one.
|
|
53091
|
-
*/
|
|
53092
|
-
function endpointsSignature(endpoints) {
|
|
53093
|
-
return endpoints.map(endpointKey).sort().join(";");
|
|
53094
|
-
}
|
|
53095
|
-
//#endregion
|
|
53096
55633
|
//#region src/sync-engine.ts
|
|
53097
55634
|
/**
|
|
53098
55635
|
* Devices reconciled at once in {@link SyncEngine.reconcileAllExposed}. Each
|
|
@@ -53140,8 +55677,18 @@ var SyncEngine = class {
|
|
|
53140
55677
|
};
|
|
53141
55678
|
}
|
|
53142
55679
|
const endpointIds = endpoints.map((e) => e.endpointId);
|
|
55680
|
+
const retired = (this.deps.getExportedEndpoints()[deviceId] ?? []).filter((id) => !endpointIds.includes(id) && this.deps.mayRetire(id));
|
|
53143
55681
|
const failures = [];
|
|
53144
|
-
for (const account of accounts)
|
|
55682
|
+
for (const account of accounts) {
|
|
55683
|
+
if (!await this.postFiltered(account.userId, deviceId, endpoints)) failures.push(`${account.userId}:${deviceId}`);
|
|
55684
|
+
if (retired.length > 0) {
|
|
55685
|
+
if (!await this.deps.postDelete(account.userId, retired)) failures.push(`${account.userId}:${deviceId}:delete`);
|
|
55686
|
+
}
|
|
55687
|
+
}
|
|
55688
|
+
if (retired.length > 0) this.deps.logger.info("alexa sync: retired endpoints the device no longer builds", {
|
|
55689
|
+
tags: { deviceId },
|
|
55690
|
+
meta: { retired }
|
|
55691
|
+
});
|
|
53145
55692
|
await this.recordEndpoints(deviceId, endpointIds, endpointsSignature(endpoints));
|
|
53146
55693
|
return {
|
|
53147
55694
|
deviceCount: 1,
|
|
@@ -53187,7 +55734,7 @@ var SyncEngine = class {
|
|
|
53187
55734
|
for (const { deviceId } of this.deps.listExposed()) {
|
|
53188
55735
|
const endpoints = await this.deps.buildEndpoints(deviceId);
|
|
53189
55736
|
if (endpoints.length === 0) continue;
|
|
53190
|
-
if (!await this.
|
|
55737
|
+
if (!await this.postFiltered(userId, deviceId, endpoints)) failures.push(`${userId}:${deviceId}`);
|
|
53191
55738
|
await this.recordEndpoints(deviceId, endpoints.map((e) => e.endpointId), endpointsSignature(endpoints));
|
|
53192
55739
|
deviceCount += 1;
|
|
53193
55740
|
endpointCount += endpoints.length;
|
|
@@ -53199,6 +55746,49 @@ var SyncEngine = class {
|
|
|
53199
55746
|
failures
|
|
53200
55747
|
};
|
|
53201
55748
|
}
|
|
55749
|
+
/**
|
|
55750
|
+
* Post `endpoints` to one account, cut to what that account's user may see.
|
|
55751
|
+
* Nothing visible is a success with nothing to send: an empty
|
|
55752
|
+
* AddOrUpdateReport is an Alexa protocol error.
|
|
55753
|
+
*/
|
|
55754
|
+
async postFiltered(userId, deviceId, endpoints) {
|
|
55755
|
+
const filtered = await this.deps.filterForAccount(userId, endpoints);
|
|
55756
|
+
if (filtered === null) {
|
|
55757
|
+
this.deps.logger.warn("alexa sync: the linked user's grants could not be read — this account was NOT sent the device", {
|
|
55758
|
+
tags: { deviceId },
|
|
55759
|
+
meta: { userId }
|
|
55760
|
+
});
|
|
55761
|
+
return false;
|
|
55762
|
+
}
|
|
55763
|
+
const visible = withholdInvalidEndpoints(filtered, (message, violations) => {
|
|
55764
|
+
this.deps.logger.warn(message, {
|
|
55765
|
+
tags: { deviceId },
|
|
55766
|
+
meta: {
|
|
55767
|
+
userId,
|
|
55768
|
+
violations
|
|
55769
|
+
}
|
|
55770
|
+
});
|
|
55771
|
+
});
|
|
55772
|
+
if (visible.length === 0) return true;
|
|
55773
|
+
const ok = await this.deps.postAddOrUpdate(userId, visible);
|
|
55774
|
+
if (ok) this.deps.afterPush(userId, visible);
|
|
55775
|
+
return ok;
|
|
55776
|
+
}
|
|
55777
|
+
/** A device the account's user just gained (visibility mirror). */
|
|
55778
|
+
async pushDeviceToAccount(userId, deviceId) {
|
|
55779
|
+
if (!this.deps.hasProactiveCredentials()) return false;
|
|
55780
|
+
if (!this.deps.listExposed().some((e) => e.deviceId === deviceId)) return true;
|
|
55781
|
+
const endpoints = await this.deps.buildEndpoints(deviceId);
|
|
55782
|
+
if (endpoints.length === 0) return false;
|
|
55783
|
+
return this.postFiltered(userId, deviceId, endpoints);
|
|
55784
|
+
}
|
|
55785
|
+
/** A device the account's user lost (confirmed by two reads): delete every
|
|
55786
|
+
* endpoint we ever published for it from THAT account only. */
|
|
55787
|
+
async removeDeviceFromAccount(userId, deviceId) {
|
|
55788
|
+
if (!this.deps.hasProactiveCredentials()) return false;
|
|
55789
|
+
const ids = this.deps.getExportedEndpoints()[deviceId] ?? [String(deviceId)];
|
|
55790
|
+
return this.deps.postDelete(userId, ids);
|
|
55791
|
+
}
|
|
53202
55792
|
async removeDevice(deviceId) {
|
|
53203
55793
|
const ids = this.deps.getExportedEndpoints()[deviceId] ?? [];
|
|
53204
55794
|
if (ids.length === 0) return {
|
|
@@ -53357,6 +55947,17 @@ var SyncEngine = class {
|
|
|
53357
55947
|
* provider respawn emits one `DeviceBindingsChanged` per cap; reconciling in
|
|
53358
55948
|
* the middle of that burst would read a half-registered binding set. */
|
|
53359
55949
|
var BINDINGS_RECONCILE_DEBOUNCE_MS = 5e3;
|
|
55950
|
+
/** How often each linked account's visibility is recomputed from the live
|
|
55951
|
+
* user grants (S1). Also the bound on how long an event keeps reaching a user
|
|
55952
|
+
* whose grant was just revoked. */
|
|
55953
|
+
var VISIBILITY_REFRESH_INTERVAL_MS = 6e4;
|
|
55954
|
+
/** First visibility pass after boot — soon, because until an account has an
|
|
55955
|
+
* entry its events are refused (fail closed). */
|
|
55956
|
+
var BOOT_VISIBILITY_DELAY_MS = 5e3;
|
|
55957
|
+
/** How often every exposed camera's endpoint shape is re-checked while the
|
|
55958
|
+
* detection sensors are on — a camera whose pipeline gained or (confirmed)
|
|
55959
|
+
* lost a class gets its sensors added or removed. */
|
|
55960
|
+
var DETECTION_CLASS_REFRESH_MS = 15 * 6e4;
|
|
53360
55961
|
/** Delay before the boot-time reconcile pass. Long enough for device providers
|
|
53361
55962
|
* to have registered their caps — a device whose bindings have not landed
|
|
53362
55963
|
* builds a shape we must never push to Alexa. */
|
|
@@ -53395,18 +55996,47 @@ var DEFAULT_CONFIG = {
|
|
|
53395
55996
|
availableEndpoints: [],
|
|
53396
55997
|
ingressToken: "",
|
|
53397
55998
|
enforceIngressToken: false,
|
|
53398
|
-
staleEndpointPurgeInput: ""
|
|
55999
|
+
staleEndpointPurgeInput: "",
|
|
56000
|
+
accountVisibility: {},
|
|
56001
|
+
detectionSensors: true,
|
|
56002
|
+
detectionClasses: {},
|
|
56003
|
+
snapshots: true,
|
|
56004
|
+
snapshotSigningKey: ""
|
|
53399
56005
|
};
|
|
56006
|
+
/** The one field of `oauthVerifyAccessToken`'s answer the snapshot route
|
|
56007
|
+
* reads. */
|
|
56008
|
+
var VerifiedTokenSchema = object({ userId: string() }).nullable();
|
|
53400
56009
|
/** UI filter for the Export picker. `alarm-panel` publishes an
|
|
53401
56010
|
* `Alexa.SecurityPanelController` endpoint (`endpoints/builder.ts`). */
|
|
53402
56011
|
var SUPPORTED_KINDS = [
|
|
53403
56012
|
"camera",
|
|
53404
56013
|
"doorbell",
|
|
53405
|
-
DeviceType.AlarmPanel
|
|
56014
|
+
DeviceType.AlarmPanel,
|
|
56015
|
+
DeviceType.Light,
|
|
56016
|
+
DeviceType.Switch,
|
|
56017
|
+
DeviceType.Siren,
|
|
56018
|
+
DeviceType.Sensor,
|
|
56019
|
+
DeviceType.Lock,
|
|
56020
|
+
DeviceType.Presence,
|
|
56021
|
+
DeviceType.Thermostat,
|
|
56022
|
+
DeviceType.Generic
|
|
53406
56023
|
];
|
|
53407
56024
|
var ExportAlexaAddon = class extends BaseAddon {
|
|
53408
56025
|
lwaGateway = null;
|
|
53409
56026
|
syncEngine = null;
|
|
56027
|
+
/** S1 (D767): the linking user's live grants. Built in `onInitialize`, the
|
|
56028
|
+
* first point `ctx` exists. */
|
|
56029
|
+
accessGateRef = null;
|
|
56030
|
+
visibilityRef = null;
|
|
56031
|
+
detectionClassSource = null;
|
|
56032
|
+
/** The images handed to Alexa, behind signed URLs (R2). In memory only: a
|
|
56033
|
+
* restart answers 410 and Alexa asks again. */
|
|
56034
|
+
snapshotStore = new SnapshotStore(() => this.config.snapshotSigningKey);
|
|
56035
|
+
detectionFeed = null;
|
|
56036
|
+
stateReporter = null;
|
|
56037
|
+
/** The switches whose change re-shapes every exposed endpoint, as last
|
|
56038
|
+
* applied — `onConfigChanged` runs on EVERY settings write. */
|
|
56039
|
+
appliedShapeSwitches = null;
|
|
53410
56040
|
/** Per-device debounce timers for the bindings-change discovery reconcile. */
|
|
53411
56041
|
pendingReconcileTimers = /* @__PURE__ */ new Map();
|
|
53412
56042
|
constructor() {
|
|
@@ -53414,6 +56044,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53414
56044
|
}
|
|
53415
56045
|
async onInitialize() {
|
|
53416
56046
|
if (!this.config.ingressToken) await this.updateGlobalSettings({ ingressToken: generateIngressToken() });
|
|
56047
|
+
if (!this.config.snapshotSigningKey) await this.updateGlobalSettings({ snapshotSigningKey: generateIngressToken() });
|
|
53417
56048
|
if (this.config.lwaClientSecret) {
|
|
53418
56049
|
await this.updateGlobalSettings({ lwaClientSecret: "" });
|
|
53419
56050
|
this.ctx.logger.warn("export-alexa: removed a stored LWA client secret — the hub no longer uses one; your skill Lambda holds it (D753)");
|
|
@@ -53422,6 +56053,21 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53422
56053
|
this.autoDetectPublicHubUrl().catch((err) => {
|
|
53423
56054
|
this.ctx.logger.warn("export-alexa: public hub URL auto-detect failed", { meta: { error: errMsg(err) } });
|
|
53424
56055
|
});
|
|
56056
|
+
this.accessGateRef = createHubAccessGate(this.ctx);
|
|
56057
|
+
this.detectionClassSource = new DetectionClassSource({
|
|
56058
|
+
readEventKinds: (deviceId) => this.ctx.api.pipelineAnalytics.listEventKinds.query({ deviceId }),
|
|
56059
|
+
getStored: () => this.config.detectionClasses,
|
|
56060
|
+
setStored: (next) => this.updateGlobalSettings({ detectionClasses: next }),
|
|
56061
|
+
logger: this.ctx.logger
|
|
56062
|
+
});
|
|
56063
|
+
this.visibilityRef = new AccountVisibilityMirror({
|
|
56064
|
+
gate: this.accessGateRef,
|
|
56065
|
+
listLinkedUserIds: () => this.config.linkedAccounts.map((a) => a.userId),
|
|
56066
|
+
listExposedDeviceIds: () => this.config.exposed.map((e) => e.deviceId),
|
|
56067
|
+
getStored: () => this.config.accountVisibility,
|
|
56068
|
+
setStored: (next) => this.updateGlobalSettings({ accountVisibility: next }),
|
|
56069
|
+
logger: this.ctx.logger
|
|
56070
|
+
});
|
|
53425
56071
|
const deviceExportProvider = {
|
|
53426
56072
|
getStatus: async () => ({
|
|
53427
56073
|
linkState: this.config.linkedAccounts.length > 0 ? "linked" : "unlinked",
|
|
@@ -53444,7 +56090,8 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53444
56090
|
discovery: {
|
|
53445
56091
|
ctx: this.ctx,
|
|
53446
56092
|
logger: this.ctx.logger,
|
|
53447
|
-
listExposed: async () => this.config.exposed
|
|
56093
|
+
listExposed: async () => this.config.exposed,
|
|
56094
|
+
buildEndpoints: (deviceId) => this.buildEndpointsForOneDevice(deviceId)
|
|
53448
56095
|
},
|
|
53449
56096
|
cameraStream: {
|
|
53450
56097
|
ctx: this.ctx,
|
|
@@ -53475,12 +56122,26 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53475
56122
|
},
|
|
53476
56123
|
reportState: {
|
|
53477
56124
|
ctx: this.ctx,
|
|
53478
|
-
logger: this.ctx.logger
|
|
56125
|
+
logger: this.ctx.logger,
|
|
56126
|
+
detectionPresent: (deviceId, cls) => this.detectionFeed?.isPresent(deviceId, cls) ?? false
|
|
53479
56127
|
},
|
|
53480
56128
|
securityPanel: {
|
|
53481
56129
|
ctx: this.ctx,
|
|
53482
56130
|
logger: this.ctx.logger
|
|
53483
|
-
}
|
|
56131
|
+
},
|
|
56132
|
+
lockController: {
|
|
56133
|
+
ctx: this.ctx,
|
|
56134
|
+
logger: this.ctx.logger
|
|
56135
|
+
},
|
|
56136
|
+
snapshotProvider: {
|
|
56137
|
+
ctx: this.ctx,
|
|
56138
|
+
logger: this.ctx.logger,
|
|
56139
|
+
store: this.snapshotStore,
|
|
56140
|
+
enabled: () => this.config.snapshots,
|
|
56141
|
+
publicHubUrl: () => this.config.publicHubUrl
|
|
56142
|
+
},
|
|
56143
|
+
access: this.endpointAccess,
|
|
56144
|
+
afterDiscovery: (userId, endpoints) => this.requireGateway().scheduleInitialConnectivity(userId, endpoints)
|
|
53484
56145
|
},
|
|
53485
56146
|
lwaTokensCallbackDeps: {
|
|
53486
56147
|
ctx: this.ctx,
|
|
@@ -53489,7 +56150,18 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53489
56150
|
recordLinkedAccount: (input) => this.recordLinkedAccount(input)
|
|
53490
56151
|
},
|
|
53491
56152
|
redetectPublicHubUrl: () => this.redetectPublicHubUrl(),
|
|
53492
|
-
onBrokerAdvertised: (raw) => this.rememberAdvertisedBroker(raw)
|
|
56153
|
+
onBrokerAdvertised: (raw) => this.rememberAdvertisedBroker(raw),
|
|
56154
|
+
snapshotRoute: {
|
|
56155
|
+
store: this.snapshotStore,
|
|
56156
|
+
enabled: () => this.config.snapshots,
|
|
56157
|
+
verifyToken: (token) => this.verifyAccountLinkToken(token),
|
|
56158
|
+
mayView: async (userId, deviceId) => {
|
|
56159
|
+
const lookup = await this.accessGate.principal(userId);
|
|
56160
|
+
if (lookup.kind !== "found") return false;
|
|
56161
|
+
return await this.accessGate.allows(lookup.principal, deviceId, "view") === "allowed";
|
|
56162
|
+
},
|
|
56163
|
+
logger: this.ctx.logger
|
|
56164
|
+
}
|
|
53493
56165
|
}));
|
|
53494
56166
|
const oauthIntegrationProvider = { getDescriptor: async () => buildAlexaOauthIntegration(this.config.publicHubUrl) };
|
|
53495
56167
|
this.lwaGateway = new LwaEventGateway({
|
|
@@ -53503,15 +56175,54 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53503
56175
|
getAdvertisedBrokerUrl: () => this.config.advertisedBrokerUrl || null,
|
|
53504
56176
|
getPublicHubUrl: () => this.config.publicHubUrl,
|
|
53505
56177
|
getRegion: () => this.config.region,
|
|
53506
|
-
getExportedSignature: (deviceId) => this.config.exportedSignatures[deviceId] ?? null
|
|
56178
|
+
getExportedSignature: (deviceId) => this.config.exportedSignatures[deviceId] ?? null,
|
|
56179
|
+
accountMaySee: (userId, deviceId) => this.visibility.allows(userId, deviceId)
|
|
53507
56180
|
});
|
|
53508
56181
|
this.lwaGateway.start();
|
|
56182
|
+
const gatewayForFeed = this.lwaGateway;
|
|
56183
|
+
this.detectionFeed = new DetectionSensorFeed({
|
|
56184
|
+
eventBus: this.ctx.eventBus,
|
|
56185
|
+
enabled: () => this.config.detectionSensors,
|
|
56186
|
+
isExposed: (deviceId) => this.config.exposed.some((e) => e.deviceId === deviceId),
|
|
56187
|
+
readActiveTracks: async (deviceId) => {
|
|
56188
|
+
return (await this.ctx.api.pipelineAnalytics.getActiveTracks.query({ deviceId })).map((t) => ({
|
|
56189
|
+
trackId: t.trackId,
|
|
56190
|
+
className: t.className
|
|
56191
|
+
}));
|
|
56192
|
+
},
|
|
56193
|
+
push: (deviceId, cls, detected, timestamp) => gatewayForFeed.pushDetectionSensor({
|
|
56194
|
+
deviceId,
|
|
56195
|
+
endpointId: detectionSensorEndpointId(deviceId, cls),
|
|
56196
|
+
detected,
|
|
56197
|
+
timestamp
|
|
56198
|
+
}),
|
|
56199
|
+
logger: this.ctx.logger
|
|
56200
|
+
});
|
|
56201
|
+
this.detectionFeed.start();
|
|
56202
|
+
this.appliedShapeSwitches = this.shapeSwitches();
|
|
56203
|
+
this.stateReporter = new StateChangeReporter({
|
|
56204
|
+
eventBus: this.ctx.eventBus,
|
|
56205
|
+
getExportedEndpoints: () => this.config.exportedEndpoints,
|
|
56206
|
+
reportConnectivity: (deviceId, connectivity) => gatewayForFeed.reportConnectivity(deviceId, connectivity),
|
|
56207
|
+
postStateChange: (report) => gatewayForFeed.postStateChange(report),
|
|
56208
|
+
postHeldMotion: (report, detected) => gatewayForFeed.pushDetectionSensor({
|
|
56209
|
+
deviceId: report.exposedDeviceId,
|
|
56210
|
+
endpointId: report.endpointId,
|
|
56211
|
+
detected,
|
|
56212
|
+
timestamp: Date.now()
|
|
56213
|
+
}),
|
|
56214
|
+
logger: this.ctx.logger
|
|
56215
|
+
});
|
|
56216
|
+
this.stateReporter.start();
|
|
53509
56217
|
this.syncEngine = new SyncEngine({
|
|
53510
56218
|
logger: this.ctx.logger,
|
|
53511
56219
|
listExposed: () => this.config.exposed,
|
|
53512
56220
|
listLinkedAccounts: () => this.config.linkedAccounts,
|
|
53513
56221
|
hasProactiveCredentials: () => this.config.tokens.length > 0,
|
|
53514
56222
|
buildEndpoints: (deviceId) => this.buildEndpointsForOneDevice(deviceId),
|
|
56223
|
+
filterForAccount: (userId, endpoints) => this.filterForAccount(userId, endpoints),
|
|
56224
|
+
afterPush: (userId, endpoints) => this.requireGateway().scheduleInitialConnectivity(userId, endpoints),
|
|
56225
|
+
mayRetire: (endpointId) => parseCameraEndpointId(endpointId).kind === "detection",
|
|
53515
56226
|
postAddOrUpdate: (userId, endpoints) => this.requireGateway().postReport(userId, (accessToken) => buildAddOrUpdateReportPayload(userId, endpoints, accessToken), `add/update (${endpoints.length})`),
|
|
53516
56227
|
postDelete: (userId, ids) => this.requireGateway().postReport(userId, (accessToken) => buildDeleteReportPayload(userId, ids, accessToken), `delete (${ids.length})`),
|
|
53517
56228
|
getExportedEndpoints: () => this.config.exportedEndpoints,
|
|
@@ -53521,6 +56232,8 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53521
56232
|
});
|
|
53522
56233
|
this.subscribeBindingsChangedForReconcile();
|
|
53523
56234
|
this.scheduleBootDiscoveryReconcile();
|
|
56235
|
+
this.scheduleVisibilityRefresh();
|
|
56236
|
+
this.scheduleDetectionClassRefresh();
|
|
53524
56237
|
const offRemoved = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (event) => {
|
|
53525
56238
|
const id = event.data?.deviceId;
|
|
53526
56239
|
if (typeof id !== "number") return;
|
|
@@ -53567,6 +56280,10 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53567
56280
|
actionHandlers: {
|
|
53568
56281
|
redetectHubUrl: async () => this.redetectPublicHubUrl(),
|
|
53569
56282
|
regenerateIngressToken: async () => this.regenerateIngressToken(),
|
|
56283
|
+
revealIngressToken: async () => {
|
|
56284
|
+
this.ctx.logger.info("export-alexa: an administrator revealed the ingress token");
|
|
56285
|
+
return { ingressToken: this.config.ingressToken };
|
|
56286
|
+
},
|
|
53570
56287
|
resyncDiscovery: async () => this.resyncDiscovery(),
|
|
53571
56288
|
purgeStaleEndpoints: async () => this.purgeStaleEndpoints(),
|
|
53572
56289
|
unlinkAlexaAccounts: async () => this.unlinkAlexaAccounts(),
|
|
@@ -53575,6 +56292,10 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53575
56292
|
};
|
|
53576
56293
|
}
|
|
53577
56294
|
async onShutdown() {
|
|
56295
|
+
this.detectionFeed?.stop();
|
|
56296
|
+
this.detectionFeed = null;
|
|
56297
|
+
this.stateReporter?.stop();
|
|
56298
|
+
this.stateReporter = null;
|
|
53578
56299
|
if (this.lwaGateway) {
|
|
53579
56300
|
this.lwaGateway.stop();
|
|
53580
56301
|
this.lwaGateway = null;
|
|
@@ -53595,7 +56316,10 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53595
56316
|
});
|
|
53596
56317
|
await this.updateGlobalSettings({ exposed: next });
|
|
53597
56318
|
this.ctx.logger.info("export-alexa: exposed device", { meta: { deviceId } });
|
|
53598
|
-
this.requireSyncEngine().pushDevice(deviceId).then((r) =>
|
|
56319
|
+
this.requireSyncEngine().pushDevice(deviceId).then((r) => {
|
|
56320
|
+
this.ctx.logger.info("export-alexa: synced exposed device", { meta: { ...r } });
|
|
56321
|
+
return this.refreshVisibility();
|
|
56322
|
+
}).catch((err) => {
|
|
53599
56323
|
this.ctx.logger.warn("export-alexa: expose sync failed", { meta: {
|
|
53600
56324
|
deviceId,
|
|
53601
56325
|
error: errMsg(err)
|
|
@@ -53622,12 +56346,96 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53622
56346
|
* `Discover` handler uses.
|
|
53623
56347
|
*/
|
|
53624
56348
|
async buildEndpointsForOneDevice(deviceId) {
|
|
56349
|
+
const classSource = this.detectionClassSource;
|
|
53625
56350
|
return buildEndpointsForExposedDevice(deviceId, {
|
|
53626
56351
|
ctx: this.ctx,
|
|
53627
56352
|
logger: this.ctx.logger,
|
|
53628
|
-
|
|
56353
|
+
snapshots: this.config.snapshots,
|
|
56354
|
+
...this.config.detectionSensors && classSource !== null ? { detectionClasses: (id) => classSource.classesFor(id) } : {}
|
|
56355
|
+
});
|
|
56356
|
+
}
|
|
56357
|
+
/** The settings that change every exposed endpoint's shape, as one key. */
|
|
56358
|
+
shapeSwitches() {
|
|
56359
|
+
return JSON.stringify({
|
|
56360
|
+
detectionSensors: this.config.detectionSensors,
|
|
56361
|
+
snapshots: this.config.snapshots
|
|
56362
|
+
});
|
|
56363
|
+
}
|
|
56364
|
+
/**
|
|
56365
|
+
* A shape switch was flipped: re-push every exposed device. The signature
|
|
56366
|
+
* reconcile pushes only what changed, and the push retires the endpoints a
|
|
56367
|
+
* device no longer builds (the detection sensors, when switched off).
|
|
56368
|
+
*/
|
|
56369
|
+
async onConfigChanged() {
|
|
56370
|
+
const now = this.shapeSwitches();
|
|
56371
|
+
if (this.appliedShapeSwitches === null || now === this.appliedShapeSwitches) return;
|
|
56372
|
+
this.appliedShapeSwitches = now;
|
|
56373
|
+
if (!this.config.snapshots) this.snapshotStore.clear();
|
|
56374
|
+
this.ctx.logger.info("export-alexa: an endpoint-shape switch changed — re-syncing every device", { meta: { switches: now } });
|
|
56375
|
+
const engine = this.syncEngine;
|
|
56376
|
+
if (engine === null) return;
|
|
56377
|
+
engine.reconcileAllExposed().catch((err) => {
|
|
56378
|
+
this.ctx.logger.warn("export-alexa: re-sync after a switch change failed", { meta: { error: errMsg(err) } });
|
|
56379
|
+
});
|
|
56380
|
+
}
|
|
56381
|
+
scheduleDetectionClassRefresh() {
|
|
56382
|
+
const timer = setInterval(() => {
|
|
56383
|
+
if (!this.config.detectionSensors) return;
|
|
56384
|
+
this.requireSyncEngine().reconcileAllExposed().catch((err) => {
|
|
56385
|
+
this.ctx.logger.warn("export-alexa: detection-class refresh failed", { meta: { error: errMsg(err) } });
|
|
56386
|
+
});
|
|
56387
|
+
}, DETECTION_CLASS_REFRESH_MS);
|
|
56388
|
+
timer.unref?.();
|
|
56389
|
+
this.ctx.addDisposer(async () => clearInterval(timer));
|
|
56390
|
+
}
|
|
56391
|
+
get visibility() {
|
|
56392
|
+
if (!this.visibilityRef) throw new Error("export-alexa: visibility mirror not initialized");
|
|
56393
|
+
return this.visibilityRef;
|
|
56394
|
+
}
|
|
56395
|
+
/** The endpoints one linked account may be sent (S1). `null` = its user's
|
|
56396
|
+
* grants are unreadable right now; a deleted user is sent nothing. */
|
|
56397
|
+
async filterForAccount(userId, endpoints) {
|
|
56398
|
+
const lookup = await this.accessGate.principal(userId);
|
|
56399
|
+
if (lookup.kind === "unavailable") return null;
|
|
56400
|
+
if (lookup.kind === "unknown-user") return [];
|
|
56401
|
+
return (await filterEndpointsForPrincipal(endpoints, lookup.principal, this.endpointAccess)).kept;
|
|
56402
|
+
}
|
|
56403
|
+
/** One visibility pass, applied to Alexa. Never throws. */
|
|
56404
|
+
async refreshVisibility() {
|
|
56405
|
+
try {
|
|
56406
|
+
await applyVisibilityChanges(await this.visibility.refresh(), this.requireSyncEngine(), this.ctx.logger);
|
|
56407
|
+
} catch (err) {
|
|
56408
|
+
this.ctx.logger.warn("export-alexa: visibility refresh failed", { meta: { error: errMsg(err) } });
|
|
56409
|
+
}
|
|
56410
|
+
}
|
|
56411
|
+
scheduleVisibilityRefresh() {
|
|
56412
|
+
const first = setTimeout(() => void this.refreshVisibility(), BOOT_VISIBILITY_DELAY_MS);
|
|
56413
|
+
first.unref?.();
|
|
56414
|
+
const every = setInterval(() => void this.refreshVisibility(), VISIBILITY_REFRESH_INTERVAL_MS);
|
|
56415
|
+
every.unref?.();
|
|
56416
|
+
this.ctx.addDisposer(async () => {
|
|
56417
|
+
clearTimeout(first);
|
|
56418
|
+
clearInterval(every);
|
|
53629
56419
|
});
|
|
53630
56420
|
}
|
|
56421
|
+
/** An account-link token's hub user, or null — the snapshot route's
|
|
56422
|
+
* credential check (the same verification `/directive` runs). */
|
|
56423
|
+
async verifyAccountLinkToken(token) {
|
|
56424
|
+
const raw = await this.ctx.api.userManagement.oauthVerifyAccessToken.query({ token });
|
|
56425
|
+
const parsed = VerifiedTokenSchema.safeParse(raw);
|
|
56426
|
+
return parsed.success && parsed.data !== null ? { userId: parsed.data.userId } : null;
|
|
56427
|
+
}
|
|
56428
|
+
get accessGate() {
|
|
56429
|
+
if (!this.accessGateRef) throw new Error("export-alexa: access gate not initialized");
|
|
56430
|
+
return this.accessGateRef;
|
|
56431
|
+
}
|
|
56432
|
+
/** The exposed set + the user gate, as every enforcement point reads them. */
|
|
56433
|
+
get endpointAccess() {
|
|
56434
|
+
return {
|
|
56435
|
+
gate: this.accessGate,
|
|
56436
|
+
isExposed: (deviceId) => this.config.exposed.some((e) => e.deviceId === deviceId)
|
|
56437
|
+
};
|
|
56438
|
+
}
|
|
53631
56439
|
/** Narrow the nullable `lwaGateway` for the SyncEngine post closures. It is
|
|
53632
56440
|
* constructed + started in `onInitialize` before the SyncEngine, so its
|
|
53633
56441
|
* `postReport` is always reachable when the engine pushes. */
|
|
@@ -53798,7 +56606,8 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53798
56606
|
linkedAccounts: [],
|
|
53799
56607
|
tokens: [],
|
|
53800
56608
|
exportedEndpoints: {},
|
|
53801
|
-
exportedSignatures: {}
|
|
56609
|
+
exportedSignatures: {},
|
|
56610
|
+
accountVisibility: {}
|
|
53802
56611
|
});
|
|
53803
56612
|
this.ctx.logger.warn("export-alexa: operator unlinked the Amazon account(s)", { meta: {
|
|
53804
56613
|
revokedSessions,
|
|
@@ -53841,7 +56650,10 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53841
56650
|
await this.updateGlobalSettings({ linkedAccounts: next });
|
|
53842
56651
|
const engine = this.syncEngine;
|
|
53843
56652
|
if (!engine) return;
|
|
53844
|
-
engine.pushAllToAccount(input.userId).then((r) =>
|
|
56653
|
+
engine.pushAllToAccount(input.userId).then((r) => {
|
|
56654
|
+
this.ctx.logger.info("export-alexa: pushed to newly linked account", { meta: { ...r } });
|
|
56655
|
+
return this.refreshVisibility();
|
|
56656
|
+
}).catch((err) => {
|
|
53845
56657
|
this.ctx.logger.warn("export-alexa: new-account push failed", { meta: {
|
|
53846
56658
|
userId: input.userId,
|
|
53847
56659
|
error: errMsg(err)
|
|
@@ -54042,26 +56854,11 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54042
56854
|
* gets a copy button in the panel; the secret rows are masked.
|
|
54043
56855
|
*/
|
|
54044
56856
|
buildSetupBlock() {
|
|
54045
|
-
|
|
54046
|
-
|
|
54047
|
-
|
|
54048
|
-
|
|
54049
|
-
|
|
54050
|
-
label: "Directive endpoint",
|
|
54051
|
-
value: `${base}/addon/export-alexa/directive`
|
|
54052
|
-
}] : [],
|
|
54053
|
-
...this.config.ingressToken ? [{
|
|
54054
|
-
label: "Ingress token (Lambda HUB_INGRESS_TOKEN)",
|
|
54055
|
-
value: this.config.ingressToken,
|
|
54056
|
-
secret: true
|
|
54057
|
-
}] : [],
|
|
54058
|
-
{
|
|
54059
|
-
label: "Linked Alexa accounts",
|
|
54060
|
-
value: String(linkedCount)
|
|
54061
|
-
}
|
|
54062
|
-
],
|
|
54063
|
-
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."
|
|
54064
|
-
};
|
|
56857
|
+
return buildAlexaSetupBlock({
|
|
56858
|
+
publicHubUrl: this.config.publicHubUrl,
|
|
56859
|
+
ingressToken: this.config.ingressToken,
|
|
56860
|
+
linkedAccountCount: this.config.linkedAccounts.length
|
|
56861
|
+
});
|
|
54065
56862
|
}
|
|
54066
56863
|
/** Options for the "External address" picker: the cached HTTPS endpoints
|
|
54067
56864
|
* plus the current selection (so a stale/custom value is never dropped). */
|
|
@@ -54132,7 +56929,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54132
56929
|
type: "select",
|
|
54133
56930
|
key: "region",
|
|
54134
56931
|
label: "Skill region",
|
|
54135
|
-
description: "The Alexa Event Gateway this hub posts to: doorbell presses, motion,
|
|
56932
|
+
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.",
|
|
54136
56933
|
options: [
|
|
54137
56934
|
{
|
|
54138
56935
|
value: "NA",
|
|
@@ -54149,6 +56946,20 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54149
56946
|
],
|
|
54150
56947
|
default: DEFAULT_CONFIG.region
|
|
54151
56948
|
}),
|
|
56949
|
+
this.field({
|
|
56950
|
+
type: "boolean",
|
|
56951
|
+
key: "detectionSensors",
|
|
56952
|
+
label: "Detection sensors",
|
|
56953
|
+
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.",
|
|
56954
|
+
default: DEFAULT_CONFIG.detectionSensors
|
|
56955
|
+
}),
|
|
56956
|
+
this.field({
|
|
56957
|
+
type: "boolean",
|
|
56958
|
+
key: "snapshots",
|
|
56959
|
+
label: "Snapshots in the Alexa app",
|
|
56960
|
+
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.",
|
|
56961
|
+
default: DEFAULT_CONFIG.snapshots
|
|
56962
|
+
}),
|
|
54152
56963
|
...this.config.skillMode === "custom" ? [this.field({
|
|
54153
56964
|
type: "text",
|
|
54154
56965
|
key: "expectedAudience",
|
|
@@ -54160,7 +56971,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54160
56971
|
key: "__custom-skill-info",
|
|
54161
56972
|
label: "Your skill Lambda",
|
|
54162
56973
|
variant: "info",
|
|
54163
|
-
content: "Proactive events (doorbell, motion,
|
|
56974
|
+
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."
|
|
54164
56975
|
}] : [{
|
|
54165
56976
|
type: "info",
|
|
54166
56977
|
key: "__public-skill-info",
|
|
@@ -54230,16 +57041,30 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54230
57041
|
key: "__ingress-token-info",
|
|
54231
57042
|
label: "Ingress token",
|
|
54232
57043
|
variant: "info",
|
|
54233
|
-
content: "Defense-in-depth shared secret.
|
|
57044
|
+
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)."
|
|
54234
57045
|
},
|
|
54235
57046
|
{
|
|
54236
|
-
type: "button",
|
|
57047
|
+
type: "addon-action-button",
|
|
57048
|
+
addonId: "export-alexa",
|
|
57049
|
+
key: "__reveal-ingress-token",
|
|
57050
|
+
label: "Show ingress token",
|
|
57051
|
+
description: "Administrators only. Shows the current token once, here.",
|
|
57052
|
+
buttonLabel: "Show ingress token",
|
|
57053
|
+
action: "revealIngressToken",
|
|
57054
|
+
variant: "default",
|
|
57055
|
+
successMessage: "Ingress token: {ingressToken}"
|
|
57056
|
+
},
|
|
57057
|
+
{
|
|
57058
|
+
type: "addon-action-button",
|
|
57059
|
+
addonId: "export-alexa",
|
|
54237
57060
|
key: "__regenerate-ingress-token",
|
|
54238
57061
|
label: "Rotate ingress token",
|
|
54239
57062
|
description: "Generate a new ingress token, invalidating the old one. Update your Lambda's HUB_INGRESS_TOKEN afterwards.",
|
|
54240
57063
|
buttonLabel: "Regenerate token",
|
|
54241
57064
|
action: "regenerateIngressToken",
|
|
54242
|
-
variant: "default"
|
|
57065
|
+
variant: "default",
|
|
57066
|
+
successMessage: "New ingress token: {ingressToken} — paste it into HUB_INGRESS_TOKEN now.",
|
|
57067
|
+
confirmMessage: "Rotate the ingress token? With \"Require ingress token\" on, the Lambda is refused until you update HUB_INGRESS_TOKEN."
|
|
54243
57068
|
},
|
|
54244
57069
|
this.field({
|
|
54245
57070
|
type: "boolean",
|
|
@@ -54283,14 +57108,14 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54283
57108
|
}
|
|
54284
57109
|
}
|
|
54285
57110
|
async buildDeviceSettingsContribution(deviceId) {
|
|
54286
|
-
|
|
57111
|
+
const kind = await this.exportKindOf(deviceId);
|
|
57112
|
+
if (kind === null) return null;
|
|
54287
57113
|
const entry = this.findEntry(deviceId);
|
|
54288
57114
|
const settings = entry?.settings ?? DEFAULT_DEVICE_SETTINGS;
|
|
54289
57115
|
const enabled = entry !== null;
|
|
54290
57116
|
const enabledKey = `alexa:${deviceId}:enabled`;
|
|
54291
|
-
const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
|
|
54292
|
-
const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
|
|
54293
57117
|
const linkedCount = this.config.linkedAccounts.length;
|
|
57118
|
+
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";
|
|
54294
57119
|
return {
|
|
54295
57120
|
tabs: [{
|
|
54296
57121
|
id: "export",
|
|
@@ -54301,7 +57126,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54301
57126
|
sections: [{
|
|
54302
57127
|
id: "export-alexa",
|
|
54303
57128
|
title: "Alexa Export",
|
|
54304
|
-
description: "Surface this camera to the linked Alexa Smart Home skill.",
|
|
57129
|
+
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).",
|
|
54305
57130
|
tab: "export",
|
|
54306
57131
|
columns: 1,
|
|
54307
57132
|
order: 20,
|
|
@@ -54310,49 +57135,54 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54310
57135
|
type: "info",
|
|
54311
57136
|
key: "__alexa-status",
|
|
54312
57137
|
label: "Status",
|
|
54313
|
-
content:
|
|
57138
|
+
content: statusLabel,
|
|
54314
57139
|
variant: "info"
|
|
54315
57140
|
},
|
|
54316
57141
|
{
|
|
54317
57142
|
type: "boolean",
|
|
54318
57143
|
key: enabledKey,
|
|
54319
57144
|
label: "Expose to Alexa",
|
|
54320
|
-
description: "Toggle to mirror this
|
|
57145
|
+
description: "Toggle to mirror this device onto the Alexa Smart Home skill.",
|
|
54321
57146
|
style: "switch",
|
|
54322
57147
|
value: enabled,
|
|
54323
57148
|
immediate: true
|
|
54324
57149
|
},
|
|
54325
|
-
|
|
54326
|
-
type: "select",
|
|
54327
|
-
key: streamPreferenceKey,
|
|
54328
|
-
label: "Source stream (Alexa / Echo)",
|
|
54329
|
-
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).",
|
|
54330
|
-
options: STREAM_PREFERENCE_OPTIONS,
|
|
54331
|
-
required: true,
|
|
54332
|
-
value: settings.streamPreference,
|
|
54333
|
-
showWhen: {
|
|
54334
|
-
field: enabledKey,
|
|
54335
|
-
equals: true
|
|
54336
|
-
},
|
|
54337
|
-
immediate: true
|
|
54338
|
-
},
|
|
54339
|
-
{
|
|
54340
|
-
type: "boolean",
|
|
54341
|
-
key: allowTranscodeKey,
|
|
54342
|
-
label: "Allow transcoding for Alexa",
|
|
54343
|
-
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.",
|
|
54344
|
-
style: "switch",
|
|
54345
|
-
value: settings.allowTranscode !== false,
|
|
54346
|
-
showWhen: {
|
|
54347
|
-
field: enabledKey,
|
|
54348
|
-
equals: true
|
|
54349
|
-
},
|
|
54350
|
-
immediate: true
|
|
54351
|
-
}
|
|
57150
|
+
...kind === "camera" ? this.cameraOnlyExportFields(deviceId, settings, enabledKey) : []
|
|
54352
57151
|
]
|
|
54353
57152
|
}]
|
|
54354
57153
|
};
|
|
54355
57154
|
}
|
|
57155
|
+
/** The stream fields of the Export section — a camera's only. */
|
|
57156
|
+
cameraOnlyExportFields(deviceId, settings, enabledKey) {
|
|
57157
|
+
const streamPreferenceKey = `alexa:${deviceId}:streamPreference`;
|
|
57158
|
+
const allowTranscodeKey = `alexa:${deviceId}:allowTranscode`;
|
|
57159
|
+
return [{
|
|
57160
|
+
type: "select",
|
|
57161
|
+
key: streamPreferenceKey,
|
|
57162
|
+
label: "Source stream (Alexa / Echo)",
|
|
57163
|
+
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).",
|
|
57164
|
+
options: STREAM_PREFERENCE_OPTIONS,
|
|
57165
|
+
required: true,
|
|
57166
|
+
value: settings.streamPreference,
|
|
57167
|
+
showWhen: {
|
|
57168
|
+
field: enabledKey,
|
|
57169
|
+
equals: true
|
|
57170
|
+
},
|
|
57171
|
+
immediate: true
|
|
57172
|
+
}, {
|
|
57173
|
+
type: "boolean",
|
|
57174
|
+
key: allowTranscodeKey,
|
|
57175
|
+
label: "Allow transcoding for Alexa",
|
|
57176
|
+
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.",
|
|
57177
|
+
style: "switch",
|
|
57178
|
+
value: settings.allowTranscode !== false,
|
|
57179
|
+
showWhen: {
|
|
57180
|
+
field: enabledKey,
|
|
57181
|
+
equals: true
|
|
57182
|
+
},
|
|
57183
|
+
immediate: true
|
|
57184
|
+
}];
|
|
57185
|
+
}
|
|
54356
57186
|
/**
|
|
54357
57187
|
* Apply a patch produced by the device-details "Export" panel. Flips
|
|
54358
57188
|
* the expose/unexpose lifecycle on `enabled` transitions and persists
|
|
@@ -54418,13 +57248,20 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54418
57248
|
}
|
|
54419
57249
|
return { success: true };
|
|
54420
57250
|
}
|
|
54421
|
-
|
|
57251
|
+
/**
|
|
57252
|
+
* Which Export section a device gets: the camera one (with stream
|
|
57253
|
+
* settings), the generic one (on/off only — R5, D775), or none. An
|
|
57254
|
+
* unreadable device reads as a camera, as before.
|
|
57255
|
+
*/
|
|
57256
|
+
async exportKindOf(deviceId) {
|
|
54422
57257
|
try {
|
|
54423
57258
|
const dev = await this.ctx.api.deviceManager.getDevice.query({ deviceId });
|
|
54424
|
-
if (!dev) return
|
|
54425
|
-
|
|
57259
|
+
if (!dev) return "camera";
|
|
57260
|
+
const type = String(dev.type);
|
|
57261
|
+
if (type === DeviceType.Camera) return "camera";
|
|
57262
|
+
return GENERIC_DEVICE_TYPES.includes(type) ? "generic" : null;
|
|
54426
57263
|
} catch {
|
|
54427
|
-
return
|
|
57264
|
+
return "camera";
|
|
54428
57265
|
}
|
|
54429
57266
|
}
|
|
54430
57267
|
findEntry(deviceId) {
|