@camstack/addon-export-alexa 1.2.132 → 1.2.134
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 +858 -307
- package/dist/export-alexa.addon.mjs +859 -307
- package/lambda-template/README.md +166 -103
- package/lambda-template/aws.js +37 -0
- package/lambda-template/broker.js +382 -0
- package/lambda-template/http.js +174 -0
- package/lambda-template/index.js +330 -379
- package/lambda-template/secrets.js +105 -0
- package/lambda-template/token-store.js +142 -0
- package/lambda-template/util.js +181 -0
- package/package.json +1 -1
|
@@ -5406,7 +5406,7 @@ var ZodIssueCode = {
|
|
|
5406
5406
|
var ZodFirstPartyTypeKind;
|
|
5407
5407
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5408
5408
|
//#endregion
|
|
5409
|
-
//#region ../types/dist/sleep-
|
|
5409
|
+
//#region ../types/dist/sleep-DAJ9Er57.mjs
|
|
5410
5410
|
/**
|
|
5411
5411
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5412
5412
|
* window to float samples (D455).
|
|
@@ -24023,148 +24023,17 @@ method(_void(), array(PipelineEngineChoiceSchema)), method(_void(), PipelineEngi
|
|
|
24023
24023
|
settings: record(string(), unknown()).optional()
|
|
24024
24024
|
}), AudioTestResultSchema, { kind: "mutation" }), method(_void(), ConfigUISchemaNullableBridge);
|
|
24025
24025
|
/**
|
|
24026
|
-
* Wire shapes of the Load balancing surface (D748): the session placement
|
|
24027
|
-
* policy, the atomic share write, the observed placement statistics, the
|
|
24028
|
-
* hardware share suggestion and the stale device pins.
|
|
24029
|
-
*/
|
|
24030
|
-
/**
|
|
24031
|
-
* How a new detection session picks its node and device.
|
|
24032
|
-
* - `least-loaded` — the historical rule: lowest `attached / weight`, a
|
|
24033
|
-
* constant node order at a tie (D37). An idle node always ties at 0, so a
|
|
24034
|
-
* weight only bites once a node already holds a session.
|
|
24035
|
-
* - `share` — the share of PARALLEL sessions is held to the weights, and a
|
|
24036
|
-
* full or drowning node yields to a free one (D748).
|
|
24037
|
-
*/
|
|
24038
|
-
var SessionPlacementPolicySchema = _enum(["least-loaded", "share"]);
|
|
24039
|
-
var LoadBalancingSharesInputSchema = object({
|
|
24040
|
-
/** nodeId → share (> 0). A node left out keeps its weight. */
|
|
24041
|
-
nodes: record(string(), number().positive()).default({}),
|
|
24042
|
-
/** nodeId → deviceKey → share (> 0). A device left out keeps its weight. */
|
|
24043
|
-
devices: record(string(), record(string(), number().positive())).default({})
|
|
24044
|
-
});
|
|
24045
|
-
var PlacementStatsInputSchema = object({
|
|
24046
|
-
/** Window the observed shares are averaged over (default 1 h, max 24 h). */
|
|
24047
|
-
windowMs: number().int().min(6e4).max(1440 * 60 * 1e3).default(3600 * 1e3) });
|
|
24048
|
-
var PlacementDeviceStatsSchema = object({
|
|
24049
|
-
deviceKey: string(),
|
|
24050
|
-
/** Sessions running on this device now. */
|
|
24051
|
-
sessionsNow: number(),
|
|
24052
|
-
/** Sessions started on this device inside the window. */
|
|
24053
|
-
sessionsStarted: number(),
|
|
24054
|
-
/** Time-weighted share of the node's parallel sessions over the window (0–1); null = no session ran. */
|
|
24055
|
-
observedShare: number().nullable()
|
|
24056
|
-
});
|
|
24057
|
-
var PlacementNodeStatsSchema = object({
|
|
24058
|
-
nodeId: string(),
|
|
24059
|
-
sessionsNow: number(),
|
|
24060
|
-
sessionsStarted: number(),
|
|
24061
|
-
/** Time-weighted share of the cluster's parallel sessions over the window (0–1); null = none ran. */
|
|
24062
|
-
observedShare: number().nullable(),
|
|
24063
|
-
/** Most sessions this node ran at once inside the window. */
|
|
24064
|
-
peakSessions: number(),
|
|
24065
|
-
/** Sessions a share election handed elsewhere because this node was full or drowning. */
|
|
24066
|
-
yieldsAway: number(),
|
|
24067
|
-
devices: array(PlacementDeviceStatsSchema).readonly()
|
|
24068
|
-
});
|
|
24069
|
-
/** A camera's device pin that the session path cannot honour. */
|
|
24070
|
-
var StaleDevicePinSchema = object({
|
|
24071
|
-
deviceId: number(),
|
|
24072
|
-
deviceKey: string(),
|
|
24073
|
-
/** `disabled` — the key exists on some node but is not enabled anywhere; `absent` — no node has it. */
|
|
24074
|
-
reason: _enum(["disabled", "absent"])
|
|
24075
|
-
});
|
|
24076
|
-
var PlacementStatsSchema = object({
|
|
24077
|
-
policy: SessionPlacementPolicySchema,
|
|
24078
|
-
windowMs: number(),
|
|
24079
|
-
/** How much of the window was actually observed (the hub keeps it in memory). */
|
|
24080
|
-
observedForMs: number(),
|
|
24081
|
-
sessionsNow: number(),
|
|
24082
|
-
/** Most parallel sessions across the cluster inside the window. */
|
|
24083
|
-
clusterPeakSessions: number(),
|
|
24084
|
-
nodes: array(PlacementNodeStatsSchema).readonly(),
|
|
24085
|
-
stalePins: array(StaleDevicePinSchema).readonly()
|
|
24086
|
-
});
|
|
24087
|
-
var SuggestSharesInputSchema = object({ headroom: number().min(.3).max(.5).default(.4) });
|
|
24088
|
-
var SuggestedDeviceShareSchema = object({
|
|
24089
|
-
deviceKey: string(),
|
|
24090
|
-
/** Share of the node (0–100, rounded). */
|
|
24091
|
-
share: number(),
|
|
24092
|
-
modelId: string().nullable(),
|
|
24093
|
-
/** Inferences per second at that model, from the measured table. */
|
|
24094
|
-
fps: number().nullable(),
|
|
24095
|
-
measured: boolean(),
|
|
24096
|
-
/** Advisory session cap: ⌊fps × (1 − headroom) / camera fps⌋. Never enforced. */
|
|
24097
|
-
suggestedMaxSessions: number().nullable()
|
|
24098
|
-
});
|
|
24099
|
-
var SuggestedNodeShareSchema = object({
|
|
24100
|
-
nodeId: string(),
|
|
24101
|
-
/** Share of the cluster (0–100, rounded); null = node skipped (offline, unknown hardware). */
|
|
24102
|
-
share: number().nullable(),
|
|
24103
|
-
/** Parallel sessions the node sustains at the suggested models with the headroom kept free. */
|
|
24104
|
-
capacitySessions: number(),
|
|
24105
|
-
devices: array(SuggestedDeviceShareSchema).readonly(),
|
|
24106
|
-
skippedReason: string().nullable()
|
|
24107
|
-
});
|
|
24108
|
-
var SuggestSharesResultSchema = object({
|
|
24109
|
-
headroom: number(),
|
|
24110
|
-
/** Camera detection rate the capacity is counted in (the fastest camera's fps). */
|
|
24111
|
-
cameraFps: number(),
|
|
24112
|
-
/** How many share → model → capacity rounds ran before the shares stopped moving (≤ 3). */
|
|
24113
|
-
iterations: number(),
|
|
24114
|
-
nodes: array(SuggestedNodeShareSchema).readonly()
|
|
24115
|
-
});
|
|
24116
|
-
/**
|
|
24117
|
-
* Where the node and device shares come from (D750).
|
|
24118
|
-
* - `auto` — computed by the system from the cluster's measured capacity at
|
|
24119
|
-
* the Auto tune models, and recomputed whenever a node or device joins,
|
|
24120
|
-
* leaves, is switched on or off, or changes model.
|
|
24121
|
-
* - `manual` — the operator's stored sliders. A node or device that cannot
|
|
24122
|
-
* take sessions gives its share to the others in proportion; the stored
|
|
24123
|
-
* values are never rewritten for that.
|
|
24124
|
-
*/
|
|
24125
|
-
var ShareSourceSchema = _enum(["auto", "manual"]);
|
|
24126
|
-
var SetShareSourceInputSchema = object({ source: ShareSourceSchema });
|
|
24127
|
-
object({
|
|
24128
|
-
nodes: record(string(), number()),
|
|
24129
|
-
devices: record(string(), record(string(), number()))
|
|
24130
|
-
});
|
|
24131
|
-
var EffectiveDeviceShareSchema = object({
|
|
24132
|
-
deviceKey: string(),
|
|
24133
|
-
/** Can take sessions now (enabled, available, can run the root). */
|
|
24134
|
-
eligible: boolean(),
|
|
24135
|
-
/** The share the source assigns, as a percentage of the node's devices. */
|
|
24136
|
-
configuredPct: number(),
|
|
24137
|
-
/** The share actually used, after an ineligible device's share went to the others. */
|
|
24138
|
-
effectivePct: number()
|
|
24139
|
-
});
|
|
24140
|
-
var EffectiveNodeShareSchema = object({
|
|
24141
|
-
nodeId: string(),
|
|
24142
|
-
eligible: boolean(),
|
|
24143
|
-
configuredPct: number(),
|
|
24144
|
-
effectivePct: number(),
|
|
24145
|
-
devices: array(EffectiveDeviceShareSchema).readonly()
|
|
24146
|
-
});
|
|
24147
|
-
var EffectiveSharesSchema = object({
|
|
24148
|
-
source: ShareSourceSchema,
|
|
24149
|
-
policy: SessionPlacementPolicySchema,
|
|
24150
|
-
/** `auto` with no computed answer yet: the stored sliders are used meanwhile. */
|
|
24151
|
-
autoPending: boolean(),
|
|
24152
|
-
/** When the automatic shares were last computed (ms epoch); null = never. */
|
|
24153
|
-
computedAt: number().nullable(),
|
|
24154
|
-
/** What made the system recompute them last. */
|
|
24155
|
-
trigger: string().nullable(),
|
|
24156
|
-
nodes: array(EffectiveNodeShareSchema).readonly(),
|
|
24157
|
-
/** The relative weights the election reads (before eligibility). */
|
|
24158
|
-
nodeWeights: record(string(), number()),
|
|
24159
|
-
deviceWeights: record(string(), record(string(), number()))
|
|
24160
|
-
});
|
|
24161
|
-
/**
|
|
24162
24026
|
* The wire shapes of the Auto tune plan and apply (D745), used by the
|
|
24163
24027
|
* `pipeline-orchestrator` cap. The recommender's own types
|
|
24164
|
-
* (`recommend.ts
|
|
24028
|
+
* (`recommend.ts`) are the TS face of the same data; these
|
|
24165
24029
|
* schemas are what crosses the cap boundary.
|
|
24166
24030
|
*/
|
|
24167
24031
|
var ModelFormatSchema$1 = _enum(MODEL_FORMATS);
|
|
24032
|
+
/** The operator's range for it. */
|
|
24033
|
+
var MIN_SESSIONS_PER_DEVICE_RANGE = {
|
|
24034
|
+
min: 1,
|
|
24035
|
+
max: 8
|
|
24036
|
+
};
|
|
24168
24037
|
var HardwareClassIdSchema = _enum([
|
|
24169
24038
|
"intel-core-ultra",
|
|
24170
24039
|
"intel-n-series",
|
|
@@ -24182,8 +24051,9 @@ var AcceleratorKindSchema = _enum([
|
|
|
24182
24051
|
]);
|
|
24183
24052
|
var RecommendationReasonSchema = discriminatedUnion("kind", [
|
|
24184
24053
|
object({
|
|
24185
|
-
kind: literal("
|
|
24186
|
-
|
|
24054
|
+
kind: literal("capacity"),
|
|
24055
|
+
sessions: number(),
|
|
24056
|
+
minSessions: number()
|
|
24187
24057
|
}),
|
|
24188
24058
|
object({
|
|
24189
24059
|
kind: literal("tie-faster"),
|
|
@@ -24198,16 +24068,13 @@ var RecommendationReasonSchema = discriminatedUnion("kind", [
|
|
|
24198
24068
|
}),
|
|
24199
24069
|
object({
|
|
24200
24070
|
kind: literal("no-fit-lightest"),
|
|
24201
|
-
|
|
24071
|
+
sessions: number(),
|
|
24072
|
+
minSessions: number()
|
|
24202
24073
|
}),
|
|
24203
24074
|
object({
|
|
24204
24075
|
kind: literal("derived-latency"),
|
|
24205
24076
|
modelId: string()
|
|
24206
24077
|
}),
|
|
24207
|
-
object({
|
|
24208
|
-
kind: literal("estimated-load"),
|
|
24209
|
-
observedForMs: number().nullable()
|
|
24210
|
-
}),
|
|
24211
24078
|
object({ kind: literal("cpu-device") }),
|
|
24212
24079
|
object({ kind: literal("coral-untouched") }),
|
|
24213
24080
|
object({ kind: literal("device-disabled") }),
|
|
@@ -24223,28 +24090,13 @@ var RecommendationReasonSchema = discriminatedUnion("kind", [
|
|
|
24223
24090
|
format: ModelFormatSchema$1
|
|
24224
24091
|
})
|
|
24225
24092
|
]);
|
|
24226
|
-
var DeviceDemandSchema = object({
|
|
24227
|
-
nodeId: string(),
|
|
24228
|
-
deviceKey: string(),
|
|
24229
|
-
demandFps: number(),
|
|
24230
|
-
maxCameraFps: number(),
|
|
24231
|
-
alwaysOnCameras: number(),
|
|
24232
|
-
onMotionSessions: number(),
|
|
24233
|
-
onMotionSource: _enum([
|
|
24234
|
-
"observed-24h",
|
|
24235
|
-
"estimate",
|
|
24236
|
-
"none"
|
|
24237
|
-
]),
|
|
24238
|
-
observedForMs: number().nullable(),
|
|
24239
|
-
/** How on-motion sessions were charged: per-device observation, or split by share (D748). */
|
|
24240
|
-
placement: SessionPlacementPolicySchema
|
|
24241
|
-
});
|
|
24242
24093
|
var DeviceModelChoiceSchema = object({
|
|
24243
24094
|
modelId: string(),
|
|
24244
24095
|
p50Ms: number(),
|
|
24245
24096
|
fps: number(),
|
|
24246
24097
|
measured: boolean(),
|
|
24247
|
-
|
|
24098
|
+
/** Parallel sessions at the camera rate with the headroom kept free: ⌊fps × (1 − headroom) / camera fps⌋. */
|
|
24099
|
+
capacitySessions: number(),
|
|
24248
24100
|
personRecall: number()
|
|
24249
24101
|
});
|
|
24250
24102
|
var DeviceRecommendationSchema = object({
|
|
@@ -24252,7 +24104,6 @@ var DeviceRecommendationSchema = object({
|
|
|
24252
24104
|
accelerator: AcceleratorKindSchema.nullable(),
|
|
24253
24105
|
enabled: boolean(),
|
|
24254
24106
|
available: boolean(),
|
|
24255
|
-
demand: DeviceDemandSchema.nullable(),
|
|
24256
24107
|
chosen: DeviceModelChoiceSchema.nullable(),
|
|
24257
24108
|
reasons: array(RecommendationReasonSchema).readonly()
|
|
24258
24109
|
});
|
|
@@ -24313,20 +24164,20 @@ var AutoTuneOptionsSchema = object({
|
|
|
24313
24164
|
/** Share of each accelerator's throughput kept free (0.3–0.5). */
|
|
24314
24165
|
headroom: number().min(.3).max(.5).default(.4),
|
|
24315
24166
|
/**
|
|
24316
|
-
*
|
|
24317
|
-
*
|
|
24318
|
-
*
|
|
24319
|
-
* is stored.
|
|
24167
|
+
* The least number of parallel sessions every device must sustain at the
|
|
24168
|
+
* camera rate with the headroom kept free (D755). The most accurate model
|
|
24169
|
+
* that reaches it wins.
|
|
24320
24170
|
*/
|
|
24321
|
-
|
|
24171
|
+
minSessionsPerDevice: number().int().min(MIN_SESSIONS_PER_DEVICE_RANGE.min).max(MIN_SESSIONS_PER_DEVICE_RANGE.max).default(2)
|
|
24322
24172
|
});
|
|
24323
24173
|
var AutoTunePlanSchema = object({
|
|
24324
24174
|
/** Hash of everything the apply will write — the apply refuses a different one. */
|
|
24325
24175
|
planHash: string(),
|
|
24326
24176
|
family: literal("yolov9"),
|
|
24327
24177
|
headroom: number(),
|
|
24328
|
-
|
|
24329
|
-
|
|
24178
|
+
minSessionsPerDevice: number(),
|
|
24179
|
+
/** The detection rate a session is counted at (the fastest camera running the pipeline root). */
|
|
24180
|
+
cameraFps: number(),
|
|
24330
24181
|
generatedAt: number(),
|
|
24331
24182
|
nodes: array(NodeRecommendationSchema).readonly(),
|
|
24332
24183
|
nodeChanges: array(NodeDeviceChangeSchema).readonly(),
|
|
@@ -24336,7 +24187,8 @@ var AutoTunePlanSchema = object({
|
|
|
24336
24187
|
});
|
|
24337
24188
|
var AutoTuneApplyInputSchema = object({
|
|
24338
24189
|
planHash: string(),
|
|
24339
|
-
headroom: number().min(.3).max(.5).default(.4)
|
|
24190
|
+
headroom: number().min(.3).max(.5).default(.4),
|
|
24191
|
+
minSessionsPerDevice: number().int().min(MIN_SESSIONS_PER_DEVICE_RANGE.min).max(MIN_SESSIONS_PER_DEVICE_RANGE.max).default(2)
|
|
24340
24192
|
});
|
|
24341
24193
|
var AutoTunePhaseResultSchema = object({
|
|
24342
24194
|
phase: _enum([
|
|
@@ -24368,6 +24220,177 @@ var AutoTuneApplyResultSchema = discriminatedUnion("kind", [
|
|
|
24368
24220
|
reason: _enum(["apply-in-progress"])
|
|
24369
24221
|
})
|
|
24370
24222
|
]);
|
|
24223
|
+
/**
|
|
24224
|
+
* Wire shapes of the Load balancing surface (D748): the session placement
|
|
24225
|
+
* policy, the atomic share write, the observed placement statistics, the
|
|
24226
|
+
* hardware share suggestion and the stale device pins.
|
|
24227
|
+
*/
|
|
24228
|
+
/**
|
|
24229
|
+
* How a new detection session picks its node and device.
|
|
24230
|
+
* - `least-loaded` — the historical rule: lowest `attached / weight`, a
|
|
24231
|
+
* constant node order at a tie (D37). An idle node always ties at 0, so a
|
|
24232
|
+
* weight only bites once a node already holds a session.
|
|
24233
|
+
* - `share` — the share of PARALLEL sessions is held to the weights, and a
|
|
24234
|
+
* full or drowning node yields to a free one (D748).
|
|
24235
|
+
*/
|
|
24236
|
+
var SessionPlacementPolicySchema = _enum(["least-loaded", "share"]);
|
|
24237
|
+
var LoadBalancingSharesInputSchema = object({
|
|
24238
|
+
/** nodeId → share (> 0). A node left out keeps its weight. */
|
|
24239
|
+
nodes: record(string(), number().positive()).default({}),
|
|
24240
|
+
/** nodeId → deviceKey → share (> 0). A device left out keeps its weight. */
|
|
24241
|
+
devices: record(string(), record(string(), number().positive())).default({})
|
|
24242
|
+
});
|
|
24243
|
+
var PlacementStatsInputSchema = object({
|
|
24244
|
+
/** Window the observed shares are averaged over (default 1 h, max 24 h). */
|
|
24245
|
+
windowMs: number().int().min(6e4).max(1440 * 60 * 1e3).default(3600 * 1e3) });
|
|
24246
|
+
var PlacementDeviceStatsSchema = object({
|
|
24247
|
+
deviceKey: string(),
|
|
24248
|
+
/** Sessions running on this device now. */
|
|
24249
|
+
sessionsNow: number(),
|
|
24250
|
+
/** Sessions started on this device inside the window. */
|
|
24251
|
+
sessionsStarted: number(),
|
|
24252
|
+
/** Time-weighted share of the node's parallel sessions over the window (0–1); null = no session ran. */
|
|
24253
|
+
observedShare: number().nullable()
|
|
24254
|
+
});
|
|
24255
|
+
var PlacementNodeStatsSchema = object({
|
|
24256
|
+
nodeId: string(),
|
|
24257
|
+
sessionsNow: number(),
|
|
24258
|
+
sessionsStarted: number(),
|
|
24259
|
+
/** Time-weighted share of the cluster's parallel sessions over the window (0–1); null = none ran. */
|
|
24260
|
+
observedShare: number().nullable(),
|
|
24261
|
+
/** Most sessions this node ran at once inside the window. */
|
|
24262
|
+
peakSessions: number(),
|
|
24263
|
+
/** Sessions a share election handed elsewhere because this node was full or drowning. */
|
|
24264
|
+
yieldsAway: number(),
|
|
24265
|
+
devices: array(PlacementDeviceStatsSchema).readonly()
|
|
24266
|
+
});
|
|
24267
|
+
/** A camera's device pin that the session path cannot honour. */
|
|
24268
|
+
var StaleDevicePinSchema = object({
|
|
24269
|
+
deviceId: number(),
|
|
24270
|
+
deviceKey: string(),
|
|
24271
|
+
/** `disabled` — the key exists on some node but is not enabled anywhere; `absent` — no node has it. */
|
|
24272
|
+
reason: _enum(["disabled", "absent"])
|
|
24273
|
+
});
|
|
24274
|
+
var PlacementStatsSchema = object({
|
|
24275
|
+
policy: SessionPlacementPolicySchema,
|
|
24276
|
+
windowMs: number(),
|
|
24277
|
+
/** How much of the window was actually observed (the hub keeps it in memory). */
|
|
24278
|
+
observedForMs: number(),
|
|
24279
|
+
sessionsNow: number(),
|
|
24280
|
+
/** Most parallel sessions across the cluster inside the window. */
|
|
24281
|
+
clusterPeakSessions: number(),
|
|
24282
|
+
nodes: array(PlacementNodeStatsSchema).readonly(),
|
|
24283
|
+
stalePins: array(StaleDevicePinSchema).readonly()
|
|
24284
|
+
});
|
|
24285
|
+
/**
|
|
24286
|
+
* Where an automatic share's capacity comes from (D755).
|
|
24287
|
+
* - `hardware` — the static capacity of each device at its Auto tune model:
|
|
24288
|
+
* the measured latency table for the node's hardware class.
|
|
24289
|
+
* - `benchmark` — the node's own measured capacity (node benchmark results).
|
|
24290
|
+
* A node with no results falls back to `hardware`, and says so.
|
|
24291
|
+
*/
|
|
24292
|
+
var CapacitySourceSchema = _enum(["hardware", "benchmark"]);
|
|
24293
|
+
/** Why a node under the `benchmark` source was sized from hardware instead. */
|
|
24294
|
+
var CapacityFallbackSchema = _enum(["no-benchmark-results", "partial-benchmark-results"]);
|
|
24295
|
+
var SuggestSharesInputSchema = object({
|
|
24296
|
+
headroom: number().min(.3).max(.5).default(.4),
|
|
24297
|
+
/** Default `hardware`. */
|
|
24298
|
+
source: CapacitySourceSchema.default("hardware")
|
|
24299
|
+
});
|
|
24300
|
+
var SuggestedDeviceShareSchema = object({
|
|
24301
|
+
deviceKey: string(),
|
|
24302
|
+
/** Share of the node (0–100, rounded). */
|
|
24303
|
+
share: number(),
|
|
24304
|
+
modelId: string().nullable(),
|
|
24305
|
+
/** Inferences per second at that model, from the measured table; null when sized by a benchmark. */
|
|
24306
|
+
fps: number().nullable(),
|
|
24307
|
+
/** False for a table cell derived from another chip; true for a measured cell or a benchmark. */
|
|
24308
|
+
measured: boolean(),
|
|
24309
|
+
/**
|
|
24310
|
+
* Advisory session cap: ⌊fps × (1 − headroom) / camera fps⌋ from the table,
|
|
24311
|
+
* or the benchmark's measured max sessions. Never enforced.
|
|
24312
|
+
*/
|
|
24313
|
+
suggestedMaxSessions: number().nullable()
|
|
24314
|
+
});
|
|
24315
|
+
var SuggestedNodeShareSchema = object({
|
|
24316
|
+
nodeId: string(),
|
|
24317
|
+
/** Share of the cluster (0–100, rounded); null = node skipped (offline, unknown hardware). */
|
|
24318
|
+
share: number().nullable(),
|
|
24319
|
+
/** Parallel sessions the node sustains at the suggested models with the headroom kept free. */
|
|
24320
|
+
capacitySessions: number(),
|
|
24321
|
+
devices: array(SuggestedDeviceShareSchema).readonly(),
|
|
24322
|
+
skippedReason: string().nullable(),
|
|
24323
|
+
/** What this node's capacity was read from; null when the node is skipped. */
|
|
24324
|
+
capacitySource: CapacitySourceSchema.nullable(),
|
|
24325
|
+
/** Set when the `benchmark` source was asked for and this node used `hardware`. */
|
|
24326
|
+
capacityFallback: CapacityFallbackSchema.nullable()
|
|
24327
|
+
});
|
|
24328
|
+
var SuggestSharesResultSchema = object({
|
|
24329
|
+
headroom: number(),
|
|
24330
|
+
/** The source asked for; each node says what it actually used. */
|
|
24331
|
+
source: CapacitySourceSchema,
|
|
24332
|
+
/** Camera detection rate the capacity is counted in (the fastest camera's fps). */
|
|
24333
|
+
cameraFps: number(),
|
|
24334
|
+
/** The Auto tune capacity floor the models were picked at. */
|
|
24335
|
+
minSessionsPerDevice: number(),
|
|
24336
|
+
nodes: array(SuggestedNodeShareSchema).readonly()
|
|
24337
|
+
});
|
|
24338
|
+
/**
|
|
24339
|
+
* Where the node and device shares come from (D750, D755).
|
|
24340
|
+
* - `hardware` (default) — computed by the system from each node's static
|
|
24341
|
+
* capacity (its accelerators at their Auto tune models, from the measured
|
|
24342
|
+
* latency table), recomputed whenever a node or device joins, leaves, is
|
|
24343
|
+
* switched on or off, or changes model. No load input.
|
|
24344
|
+
* - `benchmark` — the same, from each node's measured capacity (node
|
|
24345
|
+
* benchmark results); a node without results falls back to `hardware`.
|
|
24346
|
+
* - `manual` — the operator's stored sliders.
|
|
24347
|
+
* Either way a node or device that cannot take sessions gives its share to
|
|
24348
|
+
* the others in proportion; the stored values are never rewritten for that.
|
|
24349
|
+
*/
|
|
24350
|
+
var ShareSourceSchema = _enum([
|
|
24351
|
+
"hardware",
|
|
24352
|
+
"benchmark",
|
|
24353
|
+
"manual"
|
|
24354
|
+
]);
|
|
24355
|
+
var SetShareSourceInputSchema = object({ source: ShareSourceSchema });
|
|
24356
|
+
object({
|
|
24357
|
+
nodes: record(string(), number()),
|
|
24358
|
+
devices: record(string(), record(string(), number()))
|
|
24359
|
+
});
|
|
24360
|
+
var EffectiveDeviceShareSchema = object({
|
|
24361
|
+
deviceKey: string(),
|
|
24362
|
+
/** Can take sessions now (enabled, available, can run the root). */
|
|
24363
|
+
eligible: boolean(),
|
|
24364
|
+
/** The share the source assigns, as a percentage of the node's devices. */
|
|
24365
|
+
configuredPct: number(),
|
|
24366
|
+
/** The share actually used, after an ineligible device's share went to the others. */
|
|
24367
|
+
effectivePct: number()
|
|
24368
|
+
});
|
|
24369
|
+
var EffectiveNodeShareSchema = object({
|
|
24370
|
+
nodeId: string(),
|
|
24371
|
+
eligible: boolean(),
|
|
24372
|
+
configuredPct: number(),
|
|
24373
|
+
effectivePct: number(),
|
|
24374
|
+
/** Automatic sources: what this node's share was computed from; null under `manual` or before the first answer. */
|
|
24375
|
+
capacitySource: CapacitySourceSchema.nullable(),
|
|
24376
|
+
/** Automatic `benchmark`: why this node used `hardware` instead. */
|
|
24377
|
+
capacityFallback: CapacityFallbackSchema.nullable(),
|
|
24378
|
+
devices: array(EffectiveDeviceShareSchema).readonly()
|
|
24379
|
+
});
|
|
24380
|
+
var EffectiveSharesSchema = object({
|
|
24381
|
+
source: ShareSourceSchema,
|
|
24382
|
+
policy: SessionPlacementPolicySchema,
|
|
24383
|
+
/** An automatic source with no computed answer yet: the stored sliders are used meanwhile. */
|
|
24384
|
+
autoPending: boolean(),
|
|
24385
|
+
/** When the automatic shares were last computed (ms epoch); null = never. */
|
|
24386
|
+
computedAt: number().nullable(),
|
|
24387
|
+
/** What made the system recompute them last. */
|
|
24388
|
+
trigger: string().nullable(),
|
|
24389
|
+
nodes: array(EffectiveNodeShareSchema).readonly(),
|
|
24390
|
+
/** The relative weights the election reads (before eligibility). */
|
|
24391
|
+
nodeWeights: record(string(), number()),
|
|
24392
|
+
deviceWeights: record(string(), record(string(), number()))
|
|
24393
|
+
});
|
|
24371
24394
|
object({
|
|
24372
24395
|
activeCameras: number(),
|
|
24373
24396
|
throttledCameras: number(),
|
|
@@ -25547,8 +25570,10 @@ var CameraStepOverridePatchSchema = object({
|
|
|
25547
25570
|
modelId: string().optional(),
|
|
25548
25571
|
settings: record(string(), unknown()).readonly().optional()
|
|
25549
25572
|
});
|
|
25573
|
+
var CameraStepSettingsSchema = record(string(), record(string(), unknown()).readonly());
|
|
25550
25574
|
var CameraPipelineSettingsSchema = object({
|
|
25551
25575
|
stepToggles: record(string(), boolean()).optional(),
|
|
25576
|
+
stepSettings: CameraStepSettingsSchema.optional(),
|
|
25552
25577
|
stepOverridesByDevice: record(string(), record(string(), record(string(), CameraStepOverridePatchSchema))).optional(),
|
|
25553
25578
|
pipelineByAgent: record(string(), CameraPipelineForAgentSchema).optional()
|
|
25554
25579
|
});
|
|
@@ -26112,6 +26137,13 @@ method(object({
|
|
|
26112
26137
|
}), object({ success: literal(true) }), {
|
|
26113
26138
|
kind: "mutation",
|
|
26114
26139
|
auth: "admin"
|
|
26140
|
+
}), method(object({
|
|
26141
|
+
deviceId: number(),
|
|
26142
|
+
addonId: string(),
|
|
26143
|
+
settings: record(string(), unknown()).nullable()
|
|
26144
|
+
}), object({ success: literal(true) }), {
|
|
26145
|
+
kind: "mutation",
|
|
26146
|
+
auth: "admin"
|
|
26115
26147
|
}), method(object({
|
|
26116
26148
|
deviceId: number(),
|
|
26117
26149
|
agentNodeId: string(),
|
|
@@ -42187,6 +42219,12 @@ Object.freeze({
|
|
|
42187
42219
|
addonId: null,
|
|
42188
42220
|
access: "create"
|
|
42189
42221
|
},
|
|
42222
|
+
"pipelineOrchestrator.setCameraStepSettings": {
|
|
42223
|
+
capName: "pipeline-orchestrator",
|
|
42224
|
+
capScope: "system",
|
|
42225
|
+
addonId: null,
|
|
42226
|
+
access: "create"
|
|
42227
|
+
},
|
|
42190
42228
|
"pipelineOrchestrator.setCameraStepToggle": {
|
|
42191
42229
|
capName: "pipeline-orchestrator",
|
|
42192
42230
|
capScope: "system",
|
|
@@ -47635,6 +47673,11 @@ Object.freeze({
|
|
|
47635
47673
|
form: "single",
|
|
47636
47674
|
optional: false
|
|
47637
47675
|
}],
|
|
47676
|
+
"pipelineOrchestrator.setCameraStepSettings": [{
|
|
47677
|
+
name: "deviceId",
|
|
47678
|
+
form: "single",
|
|
47679
|
+
optional: false
|
|
47680
|
+
}],
|
|
47638
47681
|
"pipelineOrchestrator.setCameraStepToggle": [{
|
|
47639
47682
|
name: "deviceId",
|
|
47640
47683
|
form: "single",
|
|
@@ -49595,6 +49638,15 @@ var exportAlexaActions = defineCustomActions({
|
|
|
49595
49638
|
unlinkAlexaAccounts: customAction(object({}).optional(), object({
|
|
49596
49639
|
revokedSessions: number(),
|
|
49597
49640
|
removedAccounts: number()
|
|
49641
|
+
}), { kind: "mutation" }),
|
|
49642
|
+
/**
|
|
49643
|
+
* Swap the per-link credential this hub presents to the skill Lambda's
|
|
49644
|
+
* token broker (D753) for a fresh one; the old one dies at once. Rotation
|
|
49645
|
+
* also happens implicitly on every re-link.
|
|
49646
|
+
*/
|
|
49647
|
+
rotateBrokerCredential: customAction(object({}).optional(), object({
|
|
49648
|
+
rotated: number(),
|
|
49649
|
+
failed: number()
|
|
49598
49650
|
}), { kind: "mutation" })
|
|
49599
49651
|
});
|
|
49600
49652
|
//#endregion
|
|
@@ -49792,6 +49844,9 @@ function parseNonNegativeInt(s) {
|
|
|
49792
49844
|
/**
|
|
49793
49845
|
* Shared Alexa `Alexa/ErrorResponse` envelope builder.
|
|
49794
49846
|
*
|
|
49847
|
+
* Certification checks the shape: `correlationToken` (set here) and
|
|
49848
|
+
* `endpoint` (set by `withErrorEndpoint` in the router) are both required.
|
|
49849
|
+
*
|
|
49795
49850
|
* Lives in its own module so every handler (and the directive router)
|
|
49796
49851
|
* can emit its own namespace-appropriate error without circular imports.
|
|
49797
49852
|
*/
|
|
@@ -49800,8 +49855,10 @@ object({ event: object({
|
|
|
49800
49855
|
namespace: literal("Alexa"),
|
|
49801
49856
|
name: literal("ErrorResponse"),
|
|
49802
49857
|
messageId: string(),
|
|
49858
|
+
correlationToken: string().optional(),
|
|
49803
49859
|
payloadVersion: literal("3")
|
|
49804
49860
|
}),
|
|
49861
|
+
endpoint: object({ endpointId: string() }).optional(),
|
|
49805
49862
|
payload: object({
|
|
49806
49863
|
type: string(),
|
|
49807
49864
|
message: string()
|
|
@@ -49813,6 +49870,7 @@ function errorEnvelope(header, type, message) {
|
|
|
49813
49870
|
namespace: "Alexa",
|
|
49814
49871
|
name: "ErrorResponse",
|
|
49815
49872
|
messageId: header.messageId,
|
|
49873
|
+
...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
|
|
49816
49874
|
payloadVersion: "3"
|
|
49817
49875
|
},
|
|
49818
49876
|
payload: {
|
|
@@ -50173,18 +50231,147 @@ async function handleSessionDisconnected(header, endpointRaw, payloadRaw, deps,
|
|
|
50173
50231
|
},
|
|
50174
50232
|
meta: { endpointId: endpoint.endpointId }
|
|
50175
50233
|
});
|
|
50234
|
+
return sessionEvent("SessionDisconnected", header, endpoint.endpointId, parsed.data.payload.sessionId);
|
|
50235
|
+
}
|
|
50236
|
+
/**
|
|
50237
|
+
* The response to `SessionConnected` / `SessionDisconnected` is the event of
|
|
50238
|
+
* the same name with the session id (alexa-rtcsessioncontroller) — not a
|
|
50239
|
+
* generic `Alexa.Response`.
|
|
50240
|
+
*/
|
|
50241
|
+
function sessionEvent(name, header, endpointId, sessionId) {
|
|
50176
50242
|
return { event: {
|
|
50177
50243
|
header: {
|
|
50178
|
-
namespace: "Alexa",
|
|
50179
|
-
name
|
|
50244
|
+
namespace: "Alexa.RTCSessionController",
|
|
50245
|
+
name,
|
|
50180
50246
|
messageId: header.messageId,
|
|
50181
50247
|
correlationToken: header.correlationToken,
|
|
50182
50248
|
payloadVersion: "3"
|
|
50183
50249
|
},
|
|
50184
|
-
endpoint: { endpointId
|
|
50185
|
-
payload: {}
|
|
50250
|
+
endpoint: { endpointId },
|
|
50251
|
+
payload: { sessionId }
|
|
50186
50252
|
} };
|
|
50187
50253
|
}
|
|
50254
|
+
/**
|
|
50255
|
+
* `SessionConnected` — Alexa reports the media path is up. Nothing to do
|
|
50256
|
+
* hub-side (the session already exists from the offer); acknowledge it. Amazon
|
|
50257
|
+
* notes it is not always sent, so nothing may depend on it.
|
|
50258
|
+
*/
|
|
50259
|
+
function handleSessionConnected(header, endpointRaw, payloadRaw, deps) {
|
|
50260
|
+
const parsed = SessionDisconnectedDirectiveSchema.safeParse({
|
|
50261
|
+
endpoint: endpointRaw,
|
|
50262
|
+
payload: payloadRaw ?? {}
|
|
50263
|
+
});
|
|
50264
|
+
if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
|
|
50265
|
+
deps.logger.info("alexa rtc-session: SessionConnected", {
|
|
50266
|
+
tags: { sessionId: parsed.data.payload.sessionId },
|
|
50267
|
+
meta: { endpointId: parsed.data.endpoint.endpointId }
|
|
50268
|
+
});
|
|
50269
|
+
return sessionEvent("SessionConnected", header, parsed.data.endpoint.endpointId, parsed.data.payload.sessionId);
|
|
50270
|
+
}
|
|
50271
|
+
//#endregion
|
|
50272
|
+
//#region src/broker-client.ts
|
|
50273
|
+
/**
|
|
50274
|
+
* The hub's side of the skill's token broker (D753).
|
|
50275
|
+
*
|
|
50276
|
+
* The hub holds NO Login With Amazon client secret. To push a proactive event
|
|
50277
|
+
* it needs an LWA access token for the linked Amazon account, and it gets one
|
|
50278
|
+
* from the skill's Lambda (the broker) by presenting the per-link credential
|
|
50279
|
+
* the Lambda minted at link time (`csb1.<linkId>.<secret>`). The Lambda keeps
|
|
50280
|
+
* the refresh token and the client secret; the hub keeps a credential that
|
|
50281
|
+
* opens exactly one link and can be revoked or rotated.
|
|
50282
|
+
*
|
|
50283
|
+
* Every function here is a single HTTPS call with a timeout and a Zod-parsed
|
|
50284
|
+
* answer. Errors carry a `kind` so the gateway can tell "re-link needed" from
|
|
50285
|
+
* "try again later"; their messages never contain a token or a credential.
|
|
50286
|
+
*/
|
|
50287
|
+
var BROKER_TIMEOUT_MS = 8e3;
|
|
50288
|
+
/** Origin-only, HTTPS-only. Anything else is refused before a credential is sent. */
|
|
50289
|
+
function normaliseBrokerUrl(raw) {
|
|
50290
|
+
if (typeof raw !== "string" || raw.length === 0 || raw.length > 512) return null;
|
|
50291
|
+
try {
|
|
50292
|
+
const u = new URL(raw);
|
|
50293
|
+
if (u.protocol !== "https:" || u.username || u.password) return null;
|
|
50294
|
+
return u.origin;
|
|
50295
|
+
} catch {
|
|
50296
|
+
return null;
|
|
50297
|
+
}
|
|
50298
|
+
}
|
|
50299
|
+
var BrokerError = class extends Error {
|
|
50300
|
+
kind;
|
|
50301
|
+
status;
|
|
50302
|
+
constructor(kind, message, status) {
|
|
50303
|
+
super(message);
|
|
50304
|
+
this.kind = kind;
|
|
50305
|
+
this.status = status;
|
|
50306
|
+
this.name = "BrokerError";
|
|
50307
|
+
}
|
|
50308
|
+
};
|
|
50309
|
+
var AccessTokenSchema = object({
|
|
50310
|
+
accessToken: string().min(1),
|
|
50311
|
+
expiresAt: number().int().positive()
|
|
50312
|
+
});
|
|
50313
|
+
var MigrationSchema = AccessTokenSchema.extend({
|
|
50314
|
+
brokerCredential: string().min(1),
|
|
50315
|
+
brokerUrl: string().min(1)
|
|
50316
|
+
});
|
|
50317
|
+
var RotationSchema = object({ brokerCredential: string().min(1) });
|
|
50318
|
+
function kindForStatus(status) {
|
|
50319
|
+
if (status === 401) return "unauthorized";
|
|
50320
|
+
if (status === 410) return "revoked";
|
|
50321
|
+
if (status === 400 || status === 403 || status === 404 || status === 405) return "rejected";
|
|
50322
|
+
return "unavailable";
|
|
50323
|
+
}
|
|
50324
|
+
async function call(fetchImpl, brokerUrl, path, init, schema) {
|
|
50325
|
+
const origin = normaliseBrokerUrl(brokerUrl);
|
|
50326
|
+
if (!origin) throw new BrokerError("rejected", "broker URL is not an https origin");
|
|
50327
|
+
let response;
|
|
50328
|
+
try {
|
|
50329
|
+
response = await fetchImpl(`${origin}${path}`, {
|
|
50330
|
+
method: "POST",
|
|
50331
|
+
headers: {
|
|
50332
|
+
"content-type": "application/json",
|
|
50333
|
+
...init.bearer ? { authorization: `Bearer ${init.bearer}` } : {}
|
|
50334
|
+
},
|
|
50335
|
+
body: JSON.stringify(init.body ?? {}),
|
|
50336
|
+
signal: AbortSignal.timeout(BROKER_TIMEOUT_MS)
|
|
50337
|
+
});
|
|
50338
|
+
} catch (err) {
|
|
50339
|
+
throw new BrokerError("unavailable", `broker ${path} unreachable: ${err instanceof Error ? err.message : String(err)}`);
|
|
50340
|
+
}
|
|
50341
|
+
const text = await response.text().catch(() => "");
|
|
50342
|
+
let json = null;
|
|
50343
|
+
try {
|
|
50344
|
+
json = text ? JSON.parse(text) : null;
|
|
50345
|
+
} catch {
|
|
50346
|
+
json = null;
|
|
50347
|
+
}
|
|
50348
|
+
if (!response.ok) {
|
|
50349
|
+
const code = json && typeof json === "object" && "error" in json && typeof json.error === "string" ? json.error : "error";
|
|
50350
|
+
throw new BrokerError(kindForStatus(response.status), `broker ${path} ${response.status} ${code}`, response.status);
|
|
50351
|
+
}
|
|
50352
|
+
const parsed = schema.safeParse(json);
|
|
50353
|
+
if (!parsed.success) throw new BrokerError("unavailable", `broker ${path} answered an unexpected shape`);
|
|
50354
|
+
return parsed.data;
|
|
50355
|
+
}
|
|
50356
|
+
/** A fresh (or still-valid) Event Gateway access token for this link. */
|
|
50357
|
+
function fetchBrokerAccessToken(fetchImpl, brokerUrl, credential) {
|
|
50358
|
+
return call(fetchImpl, brokerUrl, "/broker/token", { bearer: credential }, AccessTokenSchema);
|
|
50359
|
+
}
|
|
50360
|
+
/**
|
|
50361
|
+
* Hand a pre-D753 refresh token to the broker in exchange for a link. The
|
|
50362
|
+
* caller deletes its own copy of the refresh token on success.
|
|
50363
|
+
*/
|
|
50364
|
+
function migrateLegacyGrant(fetchImpl, brokerUrl, input) {
|
|
50365
|
+
return call(fetchImpl, brokerUrl, "/broker/migrate", { body: input }, MigrationSchema);
|
|
50366
|
+
}
|
|
50367
|
+
/** Swap the credential's secret; the old one stops working at once. */
|
|
50368
|
+
async function rotateBrokerCredential(fetchImpl, brokerUrl, credential) {
|
|
50369
|
+
return (await call(fetchImpl, brokerUrl, "/broker/rotate", { bearer: credential }, RotationSchema)).brokerCredential;
|
|
50370
|
+
}
|
|
50371
|
+
/** Delete the link at the broker (the refresh token goes with it). */
|
|
50372
|
+
async function revokeBrokerCredential(fetchImpl, brokerUrl, credential) {
|
|
50373
|
+
await call(fetchImpl, brokerUrl, "/broker/revoke", { bearer: credential }, unknown());
|
|
50374
|
+
}
|
|
50188
50375
|
//#endregion
|
|
50189
50376
|
//#region src/handlers/security-panel-arm-state.ts
|
|
50190
50377
|
/**
|
|
@@ -50286,6 +50473,24 @@ var CAMERA_STREAM_CONTROLLER = {
|
|
|
50286
50473
|
audioCodecs: ["AAC"]
|
|
50287
50474
|
}]
|
|
50288
50475
|
};
|
|
50476
|
+
/**
|
|
50477
|
+
* `Alexa.EndpointHealth` — REQUIRED on a sensor endpoint (alexa-endpointhealth;
|
|
50478
|
+
* alexa-motionsensor: "Must also implement `Alexa.EndpointHealth`"), and every
|
|
50479
|
+
* camera endpoint may carry `Alexa.MotionSensor`. Declared on every camera so
|
|
50480
|
+
* Alexa can tell an offline camera from a broken one: `connectivity` comes
|
|
50481
|
+
* from the `device-status` cap in ReportState and is pushed as a ChangeReport
|
|
50482
|
+
* on `DeviceOnline` / `DeviceOffline` by the Event Gateway.
|
|
50483
|
+
*/
|
|
50484
|
+
var ENDPOINT_HEALTH = {
|
|
50485
|
+
type: "AlexaInterface",
|
|
50486
|
+
interface: "Alexa.EndpointHealth",
|
|
50487
|
+
version: "3",
|
|
50488
|
+
properties: {
|
|
50489
|
+
supported: [{ name: "connectivity" }],
|
|
50490
|
+
proactivelyReported: true,
|
|
50491
|
+
retrievable: true
|
|
50492
|
+
}
|
|
50493
|
+
};
|
|
50289
50494
|
var DOORBELL_EVENT_SOURCE = {
|
|
50290
50495
|
type: "AlexaInterface",
|
|
50291
50496
|
interface: "Alexa.DoorbellEventSource",
|
|
@@ -50487,7 +50692,8 @@ function buildParentCameraEndpoint(input) {
|
|
|
50487
50692
|
const capabilities = [
|
|
50488
50693
|
ALEXA_BASE_CAPABILITY,
|
|
50489
50694
|
CAMERA_STREAM_CONTROLLER,
|
|
50490
|
-
rtcSessionController(input.hasIntercom)
|
|
50695
|
+
rtcSessionController(input.hasIntercom),
|
|
50696
|
+
ENDPOINT_HEALTH
|
|
50491
50697
|
];
|
|
50492
50698
|
if (input.isDoorbell) capabilities.push(DOORBELL_EVENT_SOURCE);
|
|
50493
50699
|
if (input.hasMotion) capabilities.push(MOTION_SENSOR);
|
|
@@ -51311,6 +51517,20 @@ async function collectCameraProperties(api, deviceId, properties, timeOfSample,
|
|
|
51311
51517
|
timeOfSample,
|
|
51312
51518
|
uncertaintyInMilliseconds: 0
|
|
51313
51519
|
});
|
|
51520
|
+
const online = boolField(await api.deviceStatus.getStatus.query({ deviceId }).catch((err) => {
|
|
51521
|
+
logger.warn("alexa report-state: deviceStatus.getStatus failed — connectivity omitted", {
|
|
51522
|
+
tags: { deviceId },
|
|
51523
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
51524
|
+
});
|
|
51525
|
+
return null;
|
|
51526
|
+
}), "online");
|
|
51527
|
+
if (online !== null) properties.push({
|
|
51528
|
+
namespace: "Alexa.EndpointHealth",
|
|
51529
|
+
name: "connectivity",
|
|
51530
|
+
value: { value: online ? "OK" : "UNREACHABLE" },
|
|
51531
|
+
timeOfSample,
|
|
51532
|
+
uncertaintyInMilliseconds: 0
|
|
51533
|
+
});
|
|
51314
51534
|
const detected = boolField(await api.motion.getStatus.query({ deviceId }).catch((err) => {
|
|
51315
51535
|
logger.info("alexa report-state: motion.getStatus skipped", { meta: {
|
|
51316
51536
|
deviceId,
|
|
@@ -51693,7 +51913,37 @@ function assertDeviceScope(scopes) {
|
|
|
51693
51913
|
function requireDeviceScope(scopes, access) {
|
|
51694
51914
|
if (!scopesAllowDeviceCap(scopes, access)) throw new Error(`directive token not authorized for device ${access}`);
|
|
51695
51915
|
}
|
|
51916
|
+
/**
|
|
51917
|
+
* Give an `Alexa.ErrorResponse` the directive's endpoint when it lacks one.
|
|
51918
|
+
* Amazon requires `endpoint` on every ErrorResponse to an endpoint directive
|
|
51919
|
+
* (alexa-errorresponse); the handlers build their errors from the header
|
|
51920
|
+
* alone, so the router — which holds the endpoint for every path — adds it.
|
|
51921
|
+
*/
|
|
51922
|
+
function withErrorEndpoint(body, endpoint) {
|
|
51923
|
+
const endpointId = typeof endpoint === "object" && endpoint !== null && typeof endpoint.endpointId === "string" ? endpoint.endpointId : null;
|
|
51924
|
+
if (endpointId === null || typeof body !== "object" || body === null) return body;
|
|
51925
|
+
const event = body.event;
|
|
51926
|
+
if (typeof event !== "object" || event === null) return body;
|
|
51927
|
+
const ev = event;
|
|
51928
|
+
if (ev.header?.namespace !== "Alexa" || ev.header?.name !== "ErrorResponse") return body;
|
|
51929
|
+
if (ev.endpoint !== void 0) return body;
|
|
51930
|
+
return {
|
|
51931
|
+
...body,
|
|
51932
|
+
event: {
|
|
51933
|
+
...ev,
|
|
51934
|
+
endpoint: { endpointId }
|
|
51935
|
+
}
|
|
51936
|
+
};
|
|
51937
|
+
}
|
|
51696
51938
|
async function dispatchDirective(rawBody, deps, callerIdentity) {
|
|
51939
|
+
const result = await dispatchParsedDirective(rawBody, deps, callerIdentity);
|
|
51940
|
+
const endpoint = rawBody?.directive?.endpoint;
|
|
51941
|
+
return {
|
|
51942
|
+
status: result.status,
|
|
51943
|
+
body: withErrorEndpoint(result.body, endpoint)
|
|
51944
|
+
};
|
|
51945
|
+
}
|
|
51946
|
+
async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
|
|
51697
51947
|
const parsed = DirectiveRequestSchema.safeParse(rawBody);
|
|
51698
51948
|
if (!parsed.success) return {
|
|
51699
51949
|
status: 400,
|
|
@@ -51715,6 +51965,7 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
|
|
|
51715
51965
|
"Alexa.Discovery/Discover": "view",
|
|
51716
51966
|
"Alexa.CameraStreamController/InitializeCameraStreams": "view",
|
|
51717
51967
|
"Alexa.RTCSessionController/InitiateSessionWithOffer": "view",
|
|
51968
|
+
"Alexa.RTCSessionController/SessionConnected": "view",
|
|
51718
51969
|
"Alexa.RTCSessionController/SessionDisconnected": "view",
|
|
51719
51970
|
"Alexa.PowerController/TurnOn": "create",
|
|
51720
51971
|
"Alexa.PowerController/TurnOff": "create",
|
|
@@ -51749,6 +52000,10 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
|
|
|
51749
52000
|
status: 200,
|
|
51750
52001
|
body: await handleInitiateSessionWithOffer(header, endpoint, payload, deps.rtcSession, callerIdentity)
|
|
51751
52002
|
};
|
|
52003
|
+
if (key === "Alexa.RTCSessionController/SessionConnected") return {
|
|
52004
|
+
status: 200,
|
|
52005
|
+
body: handleSessionConnected(header, endpoint, payload, deps.rtcSession)
|
|
52006
|
+
};
|
|
51752
52007
|
if (key === "Alexa.RTCSessionController/SessionDisconnected") return {
|
|
51753
52008
|
status: 200,
|
|
51754
52009
|
body: await handleSessionDisconnected(header, endpoint, payload, deps.rtcSession, callerIdentity)
|
|
@@ -51795,8 +52050,12 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
|
|
|
51795
52050
|
//#endregion
|
|
51796
52051
|
//#region src/handlers/lwa-tokens-callback.ts
|
|
51797
52052
|
/**
|
|
51798
|
-
* `POST /addon/export-alexa/oauth/lwa-tokens` —
|
|
51799
|
-
* Lambda invokes after
|
|
52053
|
+
* `POST /addon/export-alexa/oauth/lwa-tokens` — the LEGACY (pre-D753)
|
|
52054
|
+
* callback a Lambda without the token broker invokes after the LWA grant
|
|
52055
|
+
* exchange. It hands this hub the refresh token itself. Kept so an old Lambda
|
|
52056
|
+
* keeps linking during the cut-over; the hub cannot renew such a grant (it
|
|
52057
|
+
* holds no client secret) and migrates it to the broker instead. The D753
|
|
52058
|
+
* Lambda calls `/oauth/broker-link` (`broker-link-callback.ts`).
|
|
51800
52059
|
*
|
|
51801
52060
|
* Auth: Bearer `<granteeToken>` where the grantee token is the OAuth
|
|
51802
52061
|
* access token (kind=`oauth-access`) issued by the hub's core OAuth
|
|
@@ -51849,7 +52108,7 @@ async function handleLwaTokensCallback(request, reply, deps) {
|
|
|
51849
52108
|
userId,
|
|
51850
52109
|
username
|
|
51851
52110
|
});
|
|
51852
|
-
deps.logger.
|
|
52111
|
+
deps.logger.warn("alexa lwa-tokens-callback: linked through a Lambda WITHOUT the token broker — this hub holds no LWA secret, so the grant cannot be renewed here; update the skill Lambda (D753). It migrates automatically once the Lambda advertises its broker.", { meta: {
|
|
51853
52112
|
userId,
|
|
51854
52113
|
expiresInMs: body.expiresAt - Date.now()
|
|
51855
52114
|
} });
|
|
@@ -51886,6 +52145,78 @@ function pickHeader(headers, name) {
|
|
|
51886
52145
|
return null;
|
|
51887
52146
|
}
|
|
51888
52147
|
//#endregion
|
|
52148
|
+
//#region src/handlers/broker-link-callback.ts
|
|
52149
|
+
/**
|
|
52150
|
+
* `POST /addon/export-alexa/oauth/broker-link` — the skill Lambda tells this
|
|
52151
|
+
* hub that an Amazon account was linked (D753).
|
|
52152
|
+
*
|
|
52153
|
+
* The Lambda has already exchanged the AcceptGrant code with Amazon and kept
|
|
52154
|
+
* the refresh token. It gives this hub:
|
|
52155
|
+
* - `brokerUrl` where to ask for renewed access tokens,
|
|
52156
|
+
* - `brokerCredential` this link's credential at the broker,
|
|
52157
|
+
* - `lwaAccessToken` / `expiresAt` the first Event Gateway token.
|
|
52158
|
+
* No refresh token and no client secret reach the hub.
|
|
52159
|
+
*
|
|
52160
|
+
* Auth: identical to the legacy `/oauth/lwa-tokens` callback — Bearer
|
|
52161
|
+
* `<granteeToken>`, the hub's own `oauth-access` token Amazon presented with
|
|
52162
|
+
* the AcceptGrant, verified by `oauthVerifyAccessToken` (and the ingress-token
|
|
52163
|
+
* guard in `http-server.ts`). A Lambda that does not know this route gets 404
|
|
52164
|
+
* and falls back to the legacy callback, which is how the cut-over stays
|
|
52165
|
+
* zero-downtime in both directions.
|
|
52166
|
+
*
|
|
52167
|
+
* Errors: 400 `invalid_body` / `invalid_broker_url`, 401 `unauthorized`.
|
|
52168
|
+
*/
|
|
52169
|
+
var BrokerLinkBodySchema = object({
|
|
52170
|
+
brokerUrl: string().min(1).max(512),
|
|
52171
|
+
brokerCredential: string().min(1).max(512),
|
|
52172
|
+
lwaAccessToken: string().min(1),
|
|
52173
|
+
expiresAt: number().int().nonnegative()
|
|
52174
|
+
});
|
|
52175
|
+
async function handleBrokerLinkCallback(request, reply, deps) {
|
|
52176
|
+
const parsed = BrokerLinkBodySchema.safeParse(request.body);
|
|
52177
|
+
if (!parsed.success) {
|
|
52178
|
+
reply.code(400).send({
|
|
52179
|
+
error: "invalid_body",
|
|
52180
|
+
message: parsed.error.message
|
|
52181
|
+
});
|
|
52182
|
+
return;
|
|
52183
|
+
}
|
|
52184
|
+
const body = parsed.data;
|
|
52185
|
+
const brokerUrl = normaliseBrokerUrl(body.brokerUrl);
|
|
52186
|
+
if (!brokerUrl) {
|
|
52187
|
+
reply.code(400).send({
|
|
52188
|
+
error: "invalid_broker_url",
|
|
52189
|
+
message: "brokerUrl must be an https origin"
|
|
52190
|
+
});
|
|
52191
|
+
return;
|
|
52192
|
+
}
|
|
52193
|
+
const caller = await resolveCaller(request, deps);
|
|
52194
|
+
if (!caller) {
|
|
52195
|
+
deps.logger.warn("alexa broker-link: unauthorized", { meta: { hasBearer: pickHeader(request.headers, "authorization") !== null } });
|
|
52196
|
+
reply.code(401).send({
|
|
52197
|
+
error: "unauthorized",
|
|
52198
|
+
message: "callback requires Bearer <granteeToken> (oauth-access token)"
|
|
52199
|
+
});
|
|
52200
|
+
return;
|
|
52201
|
+
}
|
|
52202
|
+
await deps.upsertToken({
|
|
52203
|
+
userId: caller.userId,
|
|
52204
|
+
accessToken: body.lwaAccessToken,
|
|
52205
|
+
expiresAt: body.expiresAt,
|
|
52206
|
+
brokerUrl,
|
|
52207
|
+
brokerCredential: body.brokerCredential
|
|
52208
|
+
});
|
|
52209
|
+
await deps.recordLinkedAccount({
|
|
52210
|
+
userId: caller.userId,
|
|
52211
|
+
username: caller.username
|
|
52212
|
+
});
|
|
52213
|
+
deps.logger.info("alexa broker-link: linked — push grant held by the skill broker", { meta: {
|
|
52214
|
+
userId: caller.userId,
|
|
52215
|
+
expiresInMs: body.expiresAt - Date.now()
|
|
52216
|
+
} });
|
|
52217
|
+
reply.code(200).send({ ok: true });
|
|
52218
|
+
}
|
|
52219
|
+
//#endregion
|
|
51889
52220
|
//#region src/ingress-token.ts
|
|
51890
52221
|
/**
|
|
51891
52222
|
* Ingress token — a rotatable shared secret that the user-owned Alexa Lambda
|
|
@@ -51943,7 +52274,8 @@ function readIngressHeader(headers) {
|
|
|
51943
52274
|
*
|
|
51944
52275
|
* Public URLs (assuming the hub is reachable as `https://<hub>`):
|
|
51945
52276
|
* POST /addon/export-alexa/directive
|
|
51946
|
-
* POST /addon/export-alexa/oauth/
|
|
52277
|
+
* POST /addon/export-alexa/oauth/broker-link (Lambda → hub, D753 broker link)
|
|
52278
|
+
* POST /addon/export-alexa/oauth/lwa-tokens (Lambda → hub, legacy pre-D753)
|
|
51947
52279
|
*
|
|
51948
52280
|
* OAuth account-linking is handled by the core `/oauth2/*` server
|
|
51949
52281
|
* (integration=export-alexa). This addon registers an `oauth-integration`
|
|
@@ -51954,6 +52286,8 @@ var OauthVerifiedResultSchema = object({
|
|
|
51954
52286
|
username: string(),
|
|
51955
52287
|
scopes: array(TokenScopeSchema)
|
|
51956
52288
|
}).nullable();
|
|
52289
|
+
/** Header the D753 Lambda sends its broker origin on. */
|
|
52290
|
+
var BROKER_HEADER = "x-camstack-broker";
|
|
51957
52291
|
function buildRoutes(deps) {
|
|
51958
52292
|
return [
|
|
51959
52293
|
{
|
|
@@ -51963,6 +52297,16 @@ function buildRoutes(deps) {
|
|
|
51963
52297
|
description: "Alexa Smart Home directive ingress (forwarded by the user-owned Lambda)",
|
|
51964
52298
|
handler: (req, reply) => handleDirective(req, reply, deps)
|
|
51965
52299
|
},
|
|
52300
|
+
{
|
|
52301
|
+
method: "POST",
|
|
52302
|
+
path: "/oauth/broker-link",
|
|
52303
|
+
access: "public",
|
|
52304
|
+
description: "Lambda → hub broker link (D753): broker URL + per-link credential + first access token; bearer-authenticated via oauth-access token",
|
|
52305
|
+
handler: async (req, reply) => {
|
|
52306
|
+
if (ingressRejected(req, reply, deps, "alexa broker-link")) return;
|
|
52307
|
+
await handleBrokerLinkCallback(req, reply, deps.lwaTokensCallbackDeps);
|
|
52308
|
+
}
|
|
52309
|
+
},
|
|
51966
52310
|
{
|
|
51967
52311
|
method: "POST",
|
|
51968
52312
|
path: "/oauth/lwa-tokens",
|
|
@@ -52073,6 +52417,8 @@ async function handleDirective(request, reply, deps) {
|
|
|
52073
52417
|
reply.code(200).send(preParseErrorEnvelope("INVALID_AUTHORIZATION_CREDENTIAL", "token does not grant access to device capabilities"));
|
|
52074
52418
|
return;
|
|
52075
52419
|
}
|
|
52420
|
+
const advertised = readHeader(request.headers, BROKER_HEADER);
|
|
52421
|
+
if (advertised && deps.onBrokerAdvertised) deps.onBrokerAdvertised(advertised);
|
|
52076
52422
|
const callerIdentity = {
|
|
52077
52423
|
userId: verified.userId,
|
|
52078
52424
|
username: verified.username,
|
|
@@ -52081,6 +52427,19 @@ async function handleDirective(request, reply, deps) {
|
|
|
52081
52427
|
const result = await dispatchDirective(request.body, deps.directiveDeps, callerIdentity);
|
|
52082
52428
|
reply.code(result.status).send(result.body);
|
|
52083
52429
|
}
|
|
52430
|
+
function readHeader(headers, name) {
|
|
52431
|
+
for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === name) return value;
|
|
52432
|
+
}
|
|
52433
|
+
//#endregion
|
|
52434
|
+
//#region src/types.ts
|
|
52435
|
+
var DEFAULT_DEVICE_SETTINGS = {
|
|
52436
|
+
streamPreference: "auto",
|
|
52437
|
+
sourceProfile: "high"
|
|
52438
|
+
};
|
|
52439
|
+
/** A grant the hub can renew through the broker. */
|
|
52440
|
+
function isBrokerGrant(t) {
|
|
52441
|
+
return typeof t.brokerUrl === "string" && typeof t.brokerCredential === "string";
|
|
52442
|
+
}
|
|
52084
52443
|
/** `listOauthSessions` returns every session for every integration. */
|
|
52085
52444
|
var OauthSessionListSchema = array(object({
|
|
52086
52445
|
id: string(),
|
|
@@ -52129,15 +52488,15 @@ function buildLinkStatusContent(input) {
|
|
|
52129
52488
|
lines.push(`• ${acc.username} — linked ${stamp(acc.linkedAt)}`);
|
|
52130
52489
|
lines.push(` ${describeGrant(acc, input)}`);
|
|
52131
52490
|
}
|
|
52132
|
-
if (!input.hasLwaCredentials) lines.push("Proactive push is DISABLED — no LWA client id/secret is configured, so motion, doorbell and discovery reports are all skipped regardless of the grants above.");
|
|
52133
52491
|
return lines.join("\n");
|
|
52134
52492
|
}
|
|
52135
52493
|
function describeGrant(acc, input) {
|
|
52136
52494
|
const token = input.tokens.find((t) => t.userId === acc.userId);
|
|
52137
52495
|
if (!token) return "Amazon push grant: MISSING — proactive events (motion, doorbell, discovery) cannot be delivered for this account. Re-link the skill in the Alexa app to re-issue it.";
|
|
52138
52496
|
const renewedAgo = minutes(input.now - token.receivedAt);
|
|
52139
|
-
if (token.expiresAt <= input.now
|
|
52140
|
-
return `Amazon push grant:
|
|
52497
|
+
if (!isBrokerGrant(token)) return `Amazon push grant: LEGACY — delivered by a skill Lambda without the token broker, and this hub holds no LWA secret to renew it (D753); ${token.expiresAt <= input.now ? `its token EXPIRED ${minutes(input.now - token.expiresAt)} min ago, so proactive events are NOT reaching Alexa` : `its token is valid for another ${minutes(token.expiresAt - input.now)} min`}. It moves to the skill's broker automatically on the next Alexa request; if it does not, update the skill Lambda or re-link.`;
|
|
52498
|
+
if (token.expiresAt <= input.now) return `Amazon push grant: EXPIRED ${minutes(input.now - token.expiresAt)} min ago (last renewed ${renewedAgo} min ago). The gateway renews ${minutes(RENEWAL_LEAD_MS)} min before expiry through the skill's token broker, so renewal is failing and proactive events are NOT reaching Alexa — check the addon log for "broker renewal failed".`;
|
|
52499
|
+
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).`;
|
|
52141
52500
|
}
|
|
52142
52501
|
//#endregion
|
|
52143
52502
|
//#region src/motion-clear-hold.ts
|
|
@@ -52151,10 +52510,12 @@ var DEFAULT_MOTION_CLEAR_HOLD_MS = 5e3;
|
|
|
52151
52510
|
var MotionClearHold = class {
|
|
52152
52511
|
entries = /* @__PURE__ */ new Map();
|
|
52153
52512
|
holdMs;
|
|
52513
|
+
minDetectedMs;
|
|
52154
52514
|
setTimer;
|
|
52155
52515
|
clearTimer;
|
|
52156
52516
|
constructor(options) {
|
|
52157
52517
|
this.holdMs = options.holdMs;
|
|
52518
|
+
this.minDetectedMs = options.minDetectedMs ?? 3e4;
|
|
52158
52519
|
this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
52159
52520
|
this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
52160
52521
|
}
|
|
@@ -52172,13 +52533,15 @@ var MotionClearHold = class {
|
|
|
52172
52533
|
if (entry.timer !== null) this.clearTimer(entry.timer);
|
|
52173
52534
|
this.entries.set(key, {
|
|
52174
52535
|
state: "detected",
|
|
52175
|
-
timer: null
|
|
52536
|
+
timer: null,
|
|
52537
|
+
detectedAt: entry.detectedAt
|
|
52176
52538
|
});
|
|
52177
52539
|
return false;
|
|
52178
52540
|
}
|
|
52179
52541
|
this.entries.set(key, {
|
|
52180
52542
|
state: "detected",
|
|
52181
|
-
timer: null
|
|
52543
|
+
timer: null,
|
|
52544
|
+
detectedAt: Date.now()
|
|
52182
52545
|
});
|
|
52183
52546
|
return true;
|
|
52184
52547
|
}
|
|
@@ -52199,16 +52562,21 @@ var MotionClearHold = class {
|
|
|
52199
52562
|
onCleared(key, release) {
|
|
52200
52563
|
const entry = this.entries.get(key);
|
|
52201
52564
|
if (entry !== void 0 && entry.state !== "detected") return;
|
|
52565
|
+
const detectedAt = entry?.detectedAt ?? null;
|
|
52566
|
+
const sinceDetected = detectedAt === null ? 0 : Date.now() - detectedAt;
|
|
52567
|
+
const delay = Math.max(this.holdMs, this.minDetectedMs - sinceDetected);
|
|
52202
52568
|
const timer = this.setTimer(() => {
|
|
52203
52569
|
this.entries.set(key, {
|
|
52204
52570
|
state: "idle",
|
|
52205
|
-
timer: null
|
|
52571
|
+
timer: null,
|
|
52572
|
+
detectedAt: null
|
|
52206
52573
|
});
|
|
52207
52574
|
release();
|
|
52208
|
-
},
|
|
52575
|
+
}, delay);
|
|
52209
52576
|
this.entries.set(key, {
|
|
52210
52577
|
state: "holding",
|
|
52211
|
-
timer
|
|
52578
|
+
timer,
|
|
52579
|
+
detectedAt
|
|
52212
52580
|
});
|
|
52213
52581
|
}
|
|
52214
52582
|
/** What Alexa currently believes for this key — for logs and assertions. */
|
|
@@ -52238,15 +52606,16 @@ var MotionClearHold = class {
|
|
|
52238
52606
|
* exposed.
|
|
52239
52607
|
* 3. For each linked Alexa account, POST a `DoorbellEventSource/
|
|
52240
52608
|
* DoorbellPress` payload to `api.amazonalexa.com/v3/events`.
|
|
52241
|
-
* 4. On 401,
|
|
52242
|
-
*
|
|
52609
|
+
* 4. On 401, ask the skill's token broker for a fresh access token and
|
|
52610
|
+
* retry once.
|
|
52243
52611
|
*
|
|
52244
|
-
*
|
|
52245
|
-
*
|
|
52246
|
-
* this
|
|
52247
|
-
* `
|
|
52248
|
-
*
|
|
52249
|
-
*
|
|
52612
|
+
* This hub holds NO Login With Amazon client secret (D753). The skill's
|
|
52613
|
+
* Lambda exchanges the AcceptGrant code, keeps the refresh token and the
|
|
52614
|
+
* secret, and hands this hub short-lived access tokens against a per-link
|
|
52615
|
+
* credential (`broker-client.ts`). A grant delivered by a pre-D753 Lambda
|
|
52616
|
+
* (with a refresh token) cannot be renewed here; it is migrated to the broker
|
|
52617
|
+
* as soon as the broker's URL is known, and the refresh token is deleted.
|
|
52618
|
+
* When no grant is stored we log + skip without erroring.
|
|
52250
52619
|
*
|
|
52251
52620
|
* Throttling: Alexa requires at most one DoorbellPress per endpoint
|
|
52252
52621
|
* every 30s. We enforce this per (userId, endpointId) tuple in-memory.
|
|
@@ -52256,10 +52625,14 @@ var EVENT_GATEWAY_URLS = {
|
|
|
52256
52625
|
EU: "https://api.eu.amazonalexa.com/v3/events",
|
|
52257
52626
|
FE: "https://api.fe.amazonalexa.com/v3/events"
|
|
52258
52627
|
};
|
|
52259
|
-
var LWA_TOKEN_URL = "https://api.amazon.com/auth/o2/token";
|
|
52260
52628
|
var THROTTLE_WINDOW_MS = 3e4;
|
|
52261
52629
|
var REFRESH_PROACTIVE_WINDOW_MS = 300 * 1e3;
|
|
52262
52630
|
var REFRESH_SCAN_INTERVAL_MS = 60 * 1e3;
|
|
52631
|
+
/** After a failed legacy-grant migration, wait this long before trying again. */
|
|
52632
|
+
var MIGRATION_RETRY_MS = 300 * 1e3;
|
|
52633
|
+
/** The interface a camera endpoint must declare for a connectivity
|
|
52634
|
+
* ChangeReport to mean anything to the Alexa cloud. */
|
|
52635
|
+
var ENDPOINT_HEALTH_INTERFACE = "Alexa.EndpointHealth";
|
|
52263
52636
|
/** The interface an endpoint must advertise for a `DoorbellPress` to survive
|
|
52264
52637
|
* the Alexa cloud. */
|
|
52265
52638
|
var DOORBELL_INTERFACE = "Alexa.DoorbellEventSource";
|
|
@@ -52291,6 +52664,10 @@ var LwaEventGateway = class {
|
|
|
52291
52664
|
unsubscribe = null;
|
|
52292
52665
|
unsubscribeMotion = null;
|
|
52293
52666
|
unsubscribeBattery = null;
|
|
52667
|
+
unsubscribeConnectivity = [];
|
|
52668
|
+
/** Last connectivity reported per `userId|endpointId` — a camera that
|
|
52669
|
+
* re-announces the same state must not become a ChangeReport stream. */
|
|
52670
|
+
lastConnectivity = /* @__PURE__ */ new Map();
|
|
52294
52671
|
refreshTimer = null;
|
|
52295
52672
|
lastSentAt = /* @__PURE__ */ new Map();
|
|
52296
52673
|
/**
|
|
@@ -52309,9 +52686,13 @@ var LwaEventGateway = class {
|
|
|
52309
52686
|
* Alexa Smart Home rate-limits ChangeReports per property; dedupe
|
|
52310
52687
|
* so only level *changes* (or step-grain ones) hit the gateway. */
|
|
52311
52688
|
lastBatteryLevel = /* @__PURE__ */ new Map();
|
|
52312
|
-
/** Latches the "no
|
|
52313
|
-
*
|
|
52314
|
-
|
|
52689
|
+
/** Latches the "no grant at all" warning so it's logged ONCE, not on every
|
|
52690
|
+
* doorbell/motion event while no account has a push grant. */
|
|
52691
|
+
warnedNoGrant = false;
|
|
52692
|
+
/** Users whose legacy grant is waiting for the broker URL (warned once). */
|
|
52693
|
+
warnedLegacyWaiting = /* @__PURE__ */ new Set();
|
|
52694
|
+
/** Per-user earliest next migration attempt after a failure. */
|
|
52695
|
+
migrationRetryAt = /* @__PURE__ */ new Map();
|
|
52315
52696
|
/** Users we have already reported as having no stored Amazon push grant.
|
|
52316
52697
|
* Cleared for a user the moment a grant is found again, so a re-link
|
|
52317
52698
|
* re-arms the warning instead of muting it for the process's lifetime. */
|
|
@@ -52335,6 +52716,11 @@ var LwaEventGateway = class {
|
|
|
52335
52716
|
this.deps.logger.warn("alexa lwa-gateway: battery handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
52336
52717
|
});
|
|
52337
52718
|
});
|
|
52719
|
+
this.unsubscribeConnectivity = [[EventCategory.DeviceOnline, "OK"], [EventCategory.DeviceOffline, "UNREACHABLE"]].map(([category, connectivity]) => this.deps.ctx.eventBus.subscribe({ category }, (event) => {
|
|
52720
|
+
this.onConnectivityChanged(event.data, connectivity).catch((err) => {
|
|
52721
|
+
this.deps.logger.warn("alexa lwa-gateway: connectivity handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
52722
|
+
});
|
|
52723
|
+
}));
|
|
52338
52724
|
this.refreshTimer = setInterval(() => {
|
|
52339
52725
|
this.refreshExpiringTokens().catch((err) => {
|
|
52340
52726
|
this.deps.logger.warn("alexa lwa-gateway: token refresh scan crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
@@ -52349,6 +52735,9 @@ var LwaEventGateway = class {
|
|
|
52349
52735
|
this.unsubscribeMotion = null;
|
|
52350
52736
|
if (this.unsubscribeBattery) this.unsubscribeBattery();
|
|
52351
52737
|
this.unsubscribeBattery = null;
|
|
52738
|
+
for (const off of this.unsubscribeConnectivity) off();
|
|
52739
|
+
this.unsubscribeConnectivity = [];
|
|
52740
|
+
this.lastConnectivity.clear();
|
|
52352
52741
|
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
|
52353
52742
|
this.refreshTimer = null;
|
|
52354
52743
|
this.lastSentAt.clear();
|
|
@@ -52356,6 +52745,8 @@ var LwaEventGateway = class {
|
|
|
52356
52745
|
this.smartVisionDropped.clear();
|
|
52357
52746
|
this.lastBatteryLevel.clear();
|
|
52358
52747
|
this.warnedNoToken.clear();
|
|
52748
|
+
this.warnedLegacyWaiting.clear();
|
|
52749
|
+
this.migrationRetryAt.clear();
|
|
52359
52750
|
}
|
|
52360
52751
|
async onDoorbellPress(data) {
|
|
52361
52752
|
const childDevice = await this.deps.ctx.api.deviceManager.getDevice.query({ deviceId: data.deviceId }).catch(() => null);
|
|
@@ -52551,6 +52942,33 @@ var LwaEventGateway = class {
|
|
|
52551
52942
|
}
|
|
52552
52943
|
}
|
|
52553
52944
|
/**
|
|
52945
|
+
* A camera went offline or came back. Amazon requires the `connectivity`
|
|
52946
|
+
* ChangeReport within 3 s of the change (alexa-endpointhealth). Deduped per
|
|
52947
|
+
* account and endpoint, and skipped for an endpoint Alexa is known to hold
|
|
52948
|
+
* WITHOUT `Alexa.EndpointHealth` — the cloud would drop it (D76).
|
|
52949
|
+
*/
|
|
52950
|
+
async onConnectivityChanged(data, connectivity) {
|
|
52951
|
+
if (typeof data?.deviceId !== "number") return;
|
|
52952
|
+
if (!this.deps.listExposed().some((e) => e.deviceId === data.deviceId)) return;
|
|
52953
|
+
const signature = this.deps.getExportedSignature(data.deviceId);
|
|
52954
|
+
if (signature !== null && !signature.includes(ENDPOINT_HEALTH_INTERFACE)) return;
|
|
52955
|
+
const linked = this.deps.listLinkedAccounts();
|
|
52956
|
+
if (linked.length === 0) return;
|
|
52957
|
+
if (!this.hasProactiveCredentials()) return;
|
|
52958
|
+
const endpointId = String(data.deviceId);
|
|
52959
|
+
for (const account of linked) {
|
|
52960
|
+
const key = `${account.userId}|${endpointId}`;
|
|
52961
|
+
if (this.lastConnectivity.get(key) === connectivity) continue;
|
|
52962
|
+
this.lastConnectivity.set(key, connectivity);
|
|
52963
|
+
await this.postEventBody(account.userId, endpointId, (accessToken) => buildConnectivityChangeReportPayload({
|
|
52964
|
+
endpointId,
|
|
52965
|
+
connectivity,
|
|
52966
|
+
timestamp: Date.now(),
|
|
52967
|
+
accessToken
|
|
52968
|
+
}), `connectivity ${connectivity}`);
|
|
52969
|
+
}
|
|
52970
|
+
}
|
|
52971
|
+
/**
|
|
52554
52972
|
* Send a one-shot `DeleteReport` for arbitrary endpoint ids per linked
|
|
52555
52973
|
* account. Used to clean up stale entries Alexa still shows from prior
|
|
52556
52974
|
* syncs (different addon version, different account, or an aborted
|
|
@@ -52667,12 +53085,15 @@ var LwaEventGateway = class {
|
|
|
52667
53085
|
} });
|
|
52668
53086
|
return true;
|
|
52669
53087
|
}
|
|
53088
|
+
get fetchImpl() {
|
|
53089
|
+
return this.deps.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
53090
|
+
}
|
|
52670
53091
|
async refreshExpiringTokens() {
|
|
52671
53092
|
if (!this.hasProactiveCredentials()) return;
|
|
52672
53093
|
const tokens = this.deps.listTokens();
|
|
52673
53094
|
const now = Date.now();
|
|
52674
53095
|
for (const token of tokens) {
|
|
52675
|
-
if (token.expiresAt - now
|
|
53096
|
+
if (!(!isBrokerGrant(token) || token.expiresAt - now <= REFRESH_PROACTIVE_WINDOW_MS)) continue;
|
|
52676
53097
|
await this.refreshAccessToken(token.userId).catch((err) => {
|
|
52677
53098
|
this.deps.logger.warn("alexa lwa-gateway: proactive refresh failed", { meta: {
|
|
52678
53099
|
userId: token.userId,
|
|
@@ -52681,61 +53102,106 @@ var LwaEventGateway = class {
|
|
|
52681
53102
|
});
|
|
52682
53103
|
}
|
|
52683
53104
|
}
|
|
53105
|
+
/** A fresh access token for `userId`, or null. Never throws for broker errors. */
|
|
52684
53106
|
async refreshAccessToken(userId) {
|
|
52685
|
-
const creds = this.deps.getLwaCredentials();
|
|
52686
|
-
if (!creds) {
|
|
52687
|
-
this.deps.logger.warn("alexa lwa-gateway: LWA client credentials not configured", { meta: { userId } });
|
|
52688
|
-
return null;
|
|
52689
|
-
}
|
|
52690
53107
|
const existing = this.findToken(userId);
|
|
52691
53108
|
if (!existing) return null;
|
|
52692
|
-
|
|
52693
|
-
|
|
52694
|
-
|
|
52695
|
-
|
|
52696
|
-
params.set("client_secret", creds.clientSecret);
|
|
52697
|
-
const response = await fetch(LWA_TOKEN_URL, {
|
|
52698
|
-
method: "POST",
|
|
52699
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
52700
|
-
body: params.toString()
|
|
52701
|
-
});
|
|
52702
|
-
if (!response.ok) {
|
|
52703
|
-
const text = await response.text().catch(() => "");
|
|
52704
|
-
this.deps.logger.warn("alexa lwa-gateway: LWA refresh non-OK", { meta: {
|
|
53109
|
+
if (!isBrokerGrant(existing)) return this.migrateLegacyGrant(existing);
|
|
53110
|
+
try {
|
|
53111
|
+
const fresh = await fetchBrokerAccessToken(this.fetchImpl, existing.brokerUrl, existing.brokerCredential);
|
|
53112
|
+
await this.deps.upsertToken({
|
|
52705
53113
|
userId,
|
|
52706
|
-
|
|
52707
|
-
|
|
53114
|
+
accessToken: fresh.accessToken,
|
|
53115
|
+
expiresAt: fresh.expiresAt,
|
|
53116
|
+
brokerUrl: existing.brokerUrl,
|
|
53117
|
+
brokerCredential: existing.brokerCredential
|
|
53118
|
+
});
|
|
53119
|
+
return fresh.accessToken;
|
|
53120
|
+
} catch (err) {
|
|
53121
|
+
if (err instanceof BrokerError && (err.kind === "unauthorized" || err.kind === "revoked")) {
|
|
53122
|
+
this.deps.logger.warn("alexa lwa-gateway: the skill broker no longer recognises this link — proactive events stop; re-link the skill in the Alexa app", { meta: {
|
|
53123
|
+
userId,
|
|
53124
|
+
kind: err.kind
|
|
53125
|
+
} });
|
|
53126
|
+
await this.deps.removeLinkedAccount(userId).catch(() => {});
|
|
53127
|
+
return null;
|
|
53128
|
+
}
|
|
53129
|
+
this.deps.logger.warn("alexa lwa-gateway: broker renewal failed — will retry", { meta: {
|
|
53130
|
+
userId,
|
|
53131
|
+
error: err instanceof Error ? err.message : String(err)
|
|
52708
53132
|
} });
|
|
52709
53133
|
return null;
|
|
52710
53134
|
}
|
|
52711
|
-
|
|
52712
|
-
|
|
52713
|
-
|
|
53135
|
+
}
|
|
53136
|
+
/**
|
|
53137
|
+
* Hand a pre-D753 refresh token to the skill's broker and keep the link it
|
|
53138
|
+
* returns instead. Zero-touch: the broker URL arrives on the next verified
|
|
53139
|
+
* directive. On success the refresh token is gone from this hub.
|
|
53140
|
+
*/
|
|
53141
|
+
async migrateLegacyGrant(entry) {
|
|
53142
|
+
const userId = entry.userId;
|
|
53143
|
+
if (!entry.refreshToken) return null;
|
|
53144
|
+
const brokerUrl = this.deps.getAdvertisedBrokerUrl();
|
|
53145
|
+
if (!brokerUrl) {
|
|
53146
|
+
if (!this.warnedLegacyWaiting.has(userId)) {
|
|
53147
|
+
this.warnedLegacyWaiting.add(userId);
|
|
53148
|
+
this.deps.logger.warn("alexa lwa-gateway: this Amazon push grant predates the skill broker and this hub holds no LWA secret to renew it; it migrates automatically once the skill Lambda advertises its broker (next Alexa directive). Until then events stop when the current token expires.", { meta: {
|
|
53149
|
+
userId,
|
|
53150
|
+
expiresInMs: entry.expiresAt - Date.now()
|
|
53151
|
+
} });
|
|
53152
|
+
}
|
|
53153
|
+
return null;
|
|
53154
|
+
}
|
|
53155
|
+
const retryAt = this.migrationRetryAt.get(userId) ?? 0;
|
|
53156
|
+
if (Date.now() < retryAt) return null;
|
|
53157
|
+
try {
|
|
53158
|
+
const migrated = await migrateLegacyGrant(this.fetchImpl, brokerUrl, {
|
|
53159
|
+
refreshToken: entry.refreshToken,
|
|
53160
|
+
...this.deps.getPublicHubUrl ? { hubUrl: this.deps.getPublicHubUrl() } : {}
|
|
53161
|
+
});
|
|
53162
|
+
await this.deps.upsertToken({
|
|
53163
|
+
userId,
|
|
53164
|
+
accessToken: migrated.accessToken,
|
|
53165
|
+
expiresAt: migrated.expiresAt,
|
|
53166
|
+
brokerUrl: normaliseBrokerUrl(migrated.brokerUrl) ?? brokerUrl,
|
|
53167
|
+
brokerCredential: migrated.brokerCredential
|
|
53168
|
+
});
|
|
53169
|
+
this.migrationRetryAt.delete(userId);
|
|
53170
|
+
this.warnedLegacyWaiting.delete(userId);
|
|
53171
|
+
this.deps.logger.info("alexa lwa-gateway: migrated the Amazon push grant to the skill broker; the refresh token was deleted from this hub", { meta: { userId } });
|
|
53172
|
+
return migrated.accessToken;
|
|
53173
|
+
} catch (err) {
|
|
53174
|
+
this.migrationRetryAt.set(userId, Date.now() + MIGRATION_RETRY_MS);
|
|
53175
|
+
const kind = err instanceof BrokerError ? err.kind : "unavailable";
|
|
53176
|
+
const status = err instanceof BrokerError ? err.status : void 0;
|
|
53177
|
+
if (kind === "rejected" && status === 400) {
|
|
53178
|
+
this.deps.logger.warn("alexa lwa-gateway: Amazon no longer honours this push grant — re-link the skill in the Alexa app", { meta: { userId } });
|
|
53179
|
+
await this.deps.removeLinkedAccount(userId).catch(() => {});
|
|
53180
|
+
return null;
|
|
53181
|
+
}
|
|
53182
|
+
this.deps.logger.warn("alexa lwa-gateway: migrating the push grant to the broker failed", { meta: {
|
|
53183
|
+
userId,
|
|
53184
|
+
error: err instanceof Error ? err.message : String(err)
|
|
53185
|
+
} });
|
|
52714
53186
|
return null;
|
|
52715
53187
|
}
|
|
52716
|
-
const refreshTokenNext = typeof parsed.refresh_token === "string" ? parsed.refresh_token : existing.refreshToken;
|
|
52717
|
-
await this.deps.upsertToken({
|
|
52718
|
-
userId,
|
|
52719
|
-
accessToken: parsed.access_token,
|
|
52720
|
-
refreshToken: refreshTokenNext,
|
|
52721
|
-
expiresAt: Date.now() + parsed.expires_in * 1e3
|
|
52722
|
-
});
|
|
52723
|
-
return parsed.access_token;
|
|
52724
53188
|
}
|
|
52725
53189
|
findToken(userId) {
|
|
52726
53190
|
return this.deps.listTokens().find((t) => t.userId === userId) ?? null;
|
|
52727
53191
|
}
|
|
52728
53192
|
/**
|
|
52729
|
-
* Proactive push
|
|
52730
|
-
*
|
|
52731
|
-
*
|
|
52732
|
-
* cannot refresh) — skip it and warn ONCE instead of once per event.
|
|
53193
|
+
* Proactive push needs a stored grant for at least one account. With none,
|
|
53194
|
+
* pushing is futile — skip it and warn ONCE instead of once per event.
|
|
53195
|
+
* (Per-account absence is reported in `postEventBody`.)
|
|
52733
53196
|
*/
|
|
52734
53197
|
hasProactiveCredentials() {
|
|
52735
|
-
if (this.deps.
|
|
52736
|
-
|
|
52737
|
-
|
|
52738
|
-
|
|
53198
|
+
if (this.deps.listTokens().length > 0) {
|
|
53199
|
+
this.warnedNoGrant = false;
|
|
53200
|
+
return true;
|
|
53201
|
+
}
|
|
53202
|
+
if (!this.warnedNoGrant) {
|
|
53203
|
+
this.warnedNoGrant = true;
|
|
53204
|
+
this.deps.logger.warn("alexa lwa-gateway: proactive events (doorbell/motion/discovery) skipped — no Amazon push grant is stored; link the skill in the Alexa app");
|
|
52739
53205
|
}
|
|
52740
53206
|
return false;
|
|
52741
53207
|
}
|
|
@@ -52823,6 +53289,36 @@ function buildBatteryChangeReportPayload(opts) {
|
|
|
52823
53289
|
context: { properties: [] }
|
|
52824
53290
|
};
|
|
52825
53291
|
}
|
|
53292
|
+
function buildConnectivityChangeReportPayload(opts) {
|
|
53293
|
+
return {
|
|
53294
|
+
event: {
|
|
53295
|
+
header: {
|
|
53296
|
+
namespace: "Alexa",
|
|
53297
|
+
name: "ChangeReport",
|
|
53298
|
+
messageId: (0, node_crypto.randomUUID)(),
|
|
53299
|
+
payloadVersion: "3"
|
|
53300
|
+
},
|
|
53301
|
+
endpoint: {
|
|
53302
|
+
scope: {
|
|
53303
|
+
type: "BearerToken",
|
|
53304
|
+
token: opts.accessToken
|
|
53305
|
+
},
|
|
53306
|
+
endpointId: opts.endpointId
|
|
53307
|
+
},
|
|
53308
|
+
payload: { change: {
|
|
53309
|
+
cause: { type: "PERIODIC_POLL" },
|
|
53310
|
+
properties: [{
|
|
53311
|
+
namespace: "Alexa.EndpointHealth",
|
|
53312
|
+
name: "connectivity",
|
|
53313
|
+
value: { value: opts.connectivity },
|
|
53314
|
+
timeOfSample: new Date(opts.timestamp).toISOString(),
|
|
53315
|
+
uncertaintyInMilliseconds: 0
|
|
53316
|
+
}]
|
|
53317
|
+
} }
|
|
53318
|
+
},
|
|
53319
|
+
context: { properties: [] }
|
|
53320
|
+
};
|
|
53321
|
+
}
|
|
52826
53322
|
function buildSmartVisionObjectDetectionEvent(opts) {
|
|
52827
53323
|
const timeOfSample = new Date(opts.timestamp).toISOString();
|
|
52828
53324
|
return { event: {
|
|
@@ -52984,23 +53480,21 @@ function buildAlexaOauthIntegration(publicHubUrl) {
|
|
|
52984
53480
|
//#endregion
|
|
52985
53481
|
//#region src/public-skill.ts
|
|
52986
53482
|
/**
|
|
52987
|
-
*
|
|
53483
|
+
* The built-in "public" CamStack Alexa skill.
|
|
52988
53484
|
*
|
|
52989
|
-
*
|
|
52990
|
-
*
|
|
52991
|
-
*
|
|
52992
|
-
*
|
|
52993
|
-
*
|
|
52994
|
-
*
|
|
52995
|
-
*
|
|
52996
|
-
*
|
|
53485
|
+
* Only its skill id lives here — an identifier, not a credential. Until D753
|
|
53486
|
+
* this file also carried the skill's Login With Amazon "Send Alexa Events"
|
|
53487
|
+
* client id and SECRET, on the theory that a per-account refresh token was the
|
|
53488
|
+
* real gate. The package is public on npm, so that secret was published with
|
|
53489
|
+
* every version and has to be rotated. Since D753 the secret lives only in the
|
|
53490
|
+
* skill's Lambda (AWS Secrets Manager); the hub gets short-lived Event Gateway
|
|
53491
|
+
* tokens from the Lambda's broker (`broker-client.ts`) with a per-link
|
|
53492
|
+
* credential. `scripts/check-no-client-secrets.ts` keeps it that way.
|
|
52997
53493
|
*
|
|
52998
|
-
* Operators who run their OWN skill switch to `skillMode: 'custom'` and
|
|
52999
|
-
*
|
|
53494
|
+
* Operators who run their OWN skill switch to `skillMode: 'custom'` and deploy
|
|
53495
|
+
* the same Lambda (`lambda-template/`) with their own secrets.
|
|
53000
53496
|
*/
|
|
53001
53497
|
var PUBLIC_SKILL_ID = "amzn1.ask.skill.aa85d6df-1600-4f13-bda9-babcfdc849e6";
|
|
53002
|
-
var PUBLIC_LWA_CLIENT_ID = "amzn1.application-oa2-client.0033e4a5b8614b11a609f5eae7cbda82";
|
|
53003
|
-
var PUBLIC_LWA_CLIENT_SECRET = "amzn1.oa2-cs.v1.82f852bc5d4a7fb906d670d36f936abd3011ac8c229d066b22a57a9f38488f1e";
|
|
53004
53498
|
//#endregion
|
|
53005
53499
|
//#region src/endpoint-signature.ts
|
|
53006
53500
|
/** One capability's identity as Alexa distinguishes it. */
|
|
@@ -53247,12 +53741,6 @@ var SyncEngine = class {
|
|
|
53247
53741
|
}
|
|
53248
53742
|
};
|
|
53249
53743
|
//#endregion
|
|
53250
|
-
//#region src/types.ts
|
|
53251
|
-
var DEFAULT_DEVICE_SETTINGS = {
|
|
53252
|
-
streamPreference: "auto",
|
|
53253
|
-
sourceProfile: "high"
|
|
53254
|
-
};
|
|
53255
|
-
//#endregion
|
|
53256
53744
|
//#region src/export-alexa.addon.ts
|
|
53257
53745
|
/**
|
|
53258
53746
|
* Alexa Smart Home Skill export addon — hub-side.
|
|
@@ -53321,6 +53809,7 @@ var DEFAULT_CONFIG = {
|
|
|
53321
53809
|
expectedAudience: "",
|
|
53322
53810
|
lwaClientId: "",
|
|
53323
53811
|
lwaClientSecret: "",
|
|
53812
|
+
advertisedBrokerUrl: "",
|
|
53324
53813
|
region: "NA",
|
|
53325
53814
|
exposed: [],
|
|
53326
53815
|
linkedAccounts: [],
|
|
@@ -53350,6 +53839,10 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53350
53839
|
}
|
|
53351
53840
|
async onInitialize() {
|
|
53352
53841
|
if (!this.config.ingressToken) await this.updateGlobalSettings({ ingressToken: generateIngressToken() });
|
|
53842
|
+
if (this.config.lwaClientSecret) {
|
|
53843
|
+
await this.updateGlobalSettings({ lwaClientSecret: "" });
|
|
53844
|
+
this.ctx.logger.warn("export-alexa: removed a stored LWA client secret — the hub no longer uses one; your skill Lambda holds it (D753)");
|
|
53845
|
+
}
|
|
53353
53846
|
await this.backfillLinkedAccounts();
|
|
53354
53847
|
this.autoDetectPublicHubUrl().catch((err) => {
|
|
53355
53848
|
this.ctx.logger.warn("export-alexa: public hub URL auto-detect failed", { meta: { error: errMsg(err) } });
|
|
@@ -53420,7 +53913,8 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53420
53913
|
upsertToken: (input) => this.upsertToken(input),
|
|
53421
53914
|
recordLinkedAccount: (input) => this.recordLinkedAccount(input)
|
|
53422
53915
|
},
|
|
53423
|
-
redetectPublicHubUrl: () => this.redetectPublicHubUrl()
|
|
53916
|
+
redetectPublicHubUrl: () => this.redetectPublicHubUrl(),
|
|
53917
|
+
onBrokerAdvertised: (raw) => this.rememberAdvertisedBroker(raw)
|
|
53424
53918
|
}));
|
|
53425
53919
|
const oauthIntegrationProvider = { getDescriptor: async () => buildAlexaOauthIntegration(this.config.publicHubUrl) };
|
|
53426
53920
|
this.lwaGateway = new LwaEventGateway({
|
|
@@ -53431,7 +53925,8 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53431
53925
|
listTokens: () => this.config.tokens,
|
|
53432
53926
|
upsertToken: (input) => this.upsertToken(input),
|
|
53433
53927
|
removeLinkedAccount: (userId) => this.removeLinkedAccount(userId),
|
|
53434
|
-
|
|
53928
|
+
getAdvertisedBrokerUrl: () => this.config.advertisedBrokerUrl || null,
|
|
53929
|
+
getPublicHubUrl: () => this.config.publicHubUrl,
|
|
53435
53930
|
getRegion: () => this.config.region,
|
|
53436
53931
|
getExportedSignature: (deviceId) => this.config.exportedSignatures[deviceId] ?? null
|
|
53437
53932
|
});
|
|
@@ -53440,7 +53935,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53440
53935
|
logger: this.ctx.logger,
|
|
53441
53936
|
listExposed: () => this.config.exposed,
|
|
53442
53937
|
listLinkedAccounts: () => this.config.linkedAccounts,
|
|
53443
|
-
hasProactiveCredentials: () => this.
|
|
53938
|
+
hasProactiveCredentials: () => this.config.tokens.length > 0,
|
|
53444
53939
|
buildEndpoints: (deviceId) => this.buildEndpointsForOneDevice(deviceId),
|
|
53445
53940
|
postAddOrUpdate: (userId, endpoints) => this.requireGateway().postReport(userId, (accessToken) => buildAddOrUpdateReportPayload(userId, endpoints, accessToken), `add/update (${endpoints.length})`),
|
|
53446
53941
|
postDelete: (userId, ids) => this.requireGateway().postReport(userId, (accessToken) => buildDeleteReportPayload(userId, ids, accessToken), `delete (${ids.length})`),
|
|
@@ -53475,7 +53970,8 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53475
53970
|
exposedCount: this.config.exposed.length,
|
|
53476
53971
|
skillMode: this.config.skillMode,
|
|
53477
53972
|
skillIdSet: this.effectiveSettings().expectedAudience.length > 0,
|
|
53478
|
-
|
|
53973
|
+
pushGrants: this.config.tokens.length,
|
|
53974
|
+
brokerGrants: this.config.tokens.filter(isBrokerGrant).length
|
|
53479
53975
|
} });
|
|
53480
53976
|
return {
|
|
53481
53977
|
providers: [
|
|
@@ -53498,7 +53994,8 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53498
53994
|
regenerateIngressToken: async () => this.regenerateIngressToken(),
|
|
53499
53995
|
resyncDiscovery: async () => this.resyncDiscovery(),
|
|
53500
53996
|
purgeStaleEndpoints: async () => this.purgeStaleEndpoints(),
|
|
53501
|
-
unlinkAlexaAccounts: async () => this.unlinkAlexaAccounts()
|
|
53997
|
+
unlinkAlexaAccounts: async () => this.unlinkAlexaAccounts(),
|
|
53998
|
+
rotateBrokerCredential: async () => this.rotateBrokerCredentials()
|
|
53502
53999
|
}
|
|
53503
54000
|
};
|
|
53504
54001
|
}
|
|
@@ -53720,6 +54217,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53720
54217
|
}
|
|
53721
54218
|
this.ctx.logger.warn("export-alexa: revokeOauthSession reported failure — the session may already be gone", { meta: { sessionId: id } });
|
|
53722
54219
|
}
|
|
54220
|
+
await this.revokeAtBroker(this.config.tokens);
|
|
53723
54221
|
const removedAccounts = this.config.linkedAccounts.length;
|
|
53724
54222
|
await this.updateGlobalSettings({
|
|
53725
54223
|
linkedAccounts: [],
|
|
@@ -53741,9 +54239,11 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53741
54239
|
const entry = {
|
|
53742
54240
|
userId: input.userId,
|
|
53743
54241
|
accessToken: input.accessToken,
|
|
53744
|
-
refreshToken: input.refreshToken,
|
|
53745
54242
|
expiresAt: input.expiresAt,
|
|
53746
|
-
receivedAt: Date.now()
|
|
54243
|
+
receivedAt: Date.now(),
|
|
54244
|
+
...input.refreshToken ? { refreshToken: input.refreshToken } : {},
|
|
54245
|
+
...input.brokerUrl ? { brokerUrl: input.brokerUrl } : {},
|
|
54246
|
+
...input.brokerCredential ? { brokerCredential: input.brokerCredential } : {}
|
|
53747
54247
|
};
|
|
53748
54248
|
const next = this.config.tokens.filter((t) => t.userId !== input.userId);
|
|
53749
54249
|
next.push(entry);
|
|
@@ -53789,26 +54289,79 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53789
54289
|
}
|
|
53790
54290
|
/**
|
|
53791
54291
|
* Settings as the runtime should read them. In `skillMode: 'public'` the
|
|
53792
|
-
* skill id
|
|
53793
|
-
*
|
|
53794
|
-
*
|
|
53795
|
-
*
|
|
54292
|
+
* skill id is swapped for the built-in CamStack skill's; the operator's own
|
|
54293
|
+
* `expectedAudience` stays untouched in the persisted config so switching
|
|
54294
|
+
* back to `custom` restores it. Everything else passes through. There is no
|
|
54295
|
+
* credential to overlay any more (D753).
|
|
53796
54296
|
*/
|
|
53797
54297
|
effectiveSettings() {
|
|
53798
54298
|
if (this.config.skillMode !== "public") return this.config;
|
|
53799
54299
|
return {
|
|
53800
54300
|
...this.config,
|
|
53801
|
-
expectedAudience: PUBLIC_SKILL_ID
|
|
53802
|
-
lwaClientId: PUBLIC_LWA_CLIENT_ID,
|
|
53803
|
-
lwaClientSecret: PUBLIC_LWA_CLIENT_SECRET
|
|
54301
|
+
expectedAudience: PUBLIC_SKILL_ID
|
|
53804
54302
|
};
|
|
53805
54303
|
}
|
|
53806
|
-
|
|
53807
|
-
|
|
53808
|
-
|
|
54304
|
+
/** Persist the broker origin the Lambda advertised on a verified directive. */
|
|
54305
|
+
rememberAdvertisedBroker(raw) {
|
|
54306
|
+
const url = normaliseBrokerUrl(raw);
|
|
54307
|
+
if (!url) {
|
|
54308
|
+
this.ctx.logger.warn("export-alexa: ignored a broker advertisement that is not an https origin");
|
|
54309
|
+
return;
|
|
54310
|
+
}
|
|
54311
|
+
if (url === this.config.advertisedBrokerUrl) return;
|
|
54312
|
+
this.updateGlobalSettings({ advertisedBrokerUrl: url }).then(() => {
|
|
54313
|
+
this.ctx.logger.info("export-alexa: the skill Lambda advertised its token broker", { meta: { brokerUrl: url } });
|
|
54314
|
+
}).catch((err) => {
|
|
54315
|
+
this.ctx.logger.warn("export-alexa: could not store the advertised broker URL", { meta: { error: errMsg(err) } });
|
|
54316
|
+
});
|
|
54317
|
+
}
|
|
54318
|
+
async revokeAtBroker(tokens) {
|
|
54319
|
+
for (const t of tokens) {
|
|
54320
|
+
if (!isBrokerGrant(t)) continue;
|
|
54321
|
+
await revokeBrokerCredential(fetch, t.brokerUrl, t.brokerCredential).catch((err) => {
|
|
54322
|
+
this.ctx.logger.warn("export-alexa: could not delete the link at the skill broker — it expires there on its own", { meta: {
|
|
54323
|
+
userId: t.userId,
|
|
54324
|
+
error: errMsg(err)
|
|
54325
|
+
} });
|
|
54326
|
+
});
|
|
54327
|
+
}
|
|
54328
|
+
}
|
|
54329
|
+
/**
|
|
54330
|
+
* Swap every broker credential this hub holds for a fresh one. The old
|
|
54331
|
+
* credential stops working at the broker immediately. A credential the
|
|
54332
|
+
* broker no longer recognises is reported, not retried — re-link instead.
|
|
54333
|
+
*/
|
|
54334
|
+
async rotateBrokerCredentials() {
|
|
54335
|
+
let rotated = 0;
|
|
54336
|
+
let failed = 0;
|
|
54337
|
+
for (const t of this.config.tokens) {
|
|
54338
|
+
if (!isBrokerGrant(t)) continue;
|
|
54339
|
+
try {
|
|
54340
|
+
const next = await rotateBrokerCredential(fetch, t.brokerUrl, t.brokerCredential);
|
|
54341
|
+
await this.upsertToken({
|
|
54342
|
+
userId: t.userId,
|
|
54343
|
+
accessToken: t.accessToken,
|
|
54344
|
+
expiresAt: t.expiresAt,
|
|
54345
|
+
brokerUrl: t.brokerUrl,
|
|
54346
|
+
brokerCredential: next
|
|
54347
|
+
});
|
|
54348
|
+
rotated += 1;
|
|
54349
|
+
} catch (err) {
|
|
54350
|
+
failed += 1;
|
|
54351
|
+
this.ctx.logger.warn("export-alexa: broker credential rotation failed", { meta: {
|
|
54352
|
+
userId: t.userId,
|
|
54353
|
+
kind: err instanceof BrokerError ? err.kind : "unavailable",
|
|
54354
|
+
error: errMsg(err)
|
|
54355
|
+
} });
|
|
54356
|
+
}
|
|
54357
|
+
}
|
|
54358
|
+
this.ctx.logger.info("export-alexa: broker credentials rotated", { meta: {
|
|
54359
|
+
rotated,
|
|
54360
|
+
failed
|
|
54361
|
+
} });
|
|
53809
54362
|
return {
|
|
53810
|
-
|
|
53811
|
-
|
|
54363
|
+
rotated,
|
|
54364
|
+
failed
|
|
53812
54365
|
};
|
|
53813
54366
|
}
|
|
53814
54367
|
/**
|
|
@@ -53963,8 +54516,9 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53963
54516
|
label: "Setup overview",
|
|
53964
54517
|
variant: "info",
|
|
53965
54518
|
content: [
|
|
53966
|
-
"Public skill (default): enable the \"CamStack\" skill in the Alexa app and link your account —
|
|
53967
|
-
"
|
|
54519
|
+
"Public skill (default): enable the \"CamStack\" skill in the Alexa app and link your account — no Amazon developer setup is needed. A single published skill + hosted Lambda route to this hub using its public HTTPS URL (baked into every JWT).",
|
|
54520
|
+
"The skill's Lambda keeps the Amazon credentials: this hub never holds an LWA client secret. It receives short-lived event tokens from the Lambda's token broker with a credential that is unique to this link.",
|
|
54521
|
+
"Custom skill: switch \"Alexa skill\" below to Custom, enter your skill id, and deploy the skill Lambda from this addon's lambda-template in your own AWS account (your LWA credentials go into AWS Secrets Manager there, not here).",
|
|
53968
54522
|
"Either way this hub must be reachable from the public internet (Cloudflare Tunnel, Tailscale Funnel, …). The \"External address\" picker selects which detected endpoint the Lambda calls back on — press \"Refresh external addresses\" after setting up a new tunnel."
|
|
53969
54523
|
].join("\n")
|
|
53970
54524
|
},
|
|
@@ -53989,7 +54543,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53989
54543
|
type: "select",
|
|
53990
54544
|
key: "skillMode",
|
|
53991
54545
|
label: "Alexa skill",
|
|
53992
|
-
description: "Public: use the built-in CamStack skill — zero Amazon developer setup. Custom: bring your own skill and
|
|
54546
|
+
description: "Public: use the built-in CamStack skill — zero Amazon developer setup. Custom: bring your own skill and your own copy of the skill Lambda.",
|
|
53993
54547
|
options: [{
|
|
53994
54548
|
value: "public",
|
|
53995
54549
|
label: "Public (built-in CamStack skill)"
|
|
@@ -54020,35 +54574,24 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54020
54574
|
],
|
|
54021
54575
|
default: DEFAULT_CONFIG.region
|
|
54022
54576
|
}),
|
|
54023
|
-
...this.config.skillMode === "custom" ? [
|
|
54024
|
-
|
|
54025
|
-
|
|
54026
|
-
|
|
54027
|
-
|
|
54028
|
-
|
|
54029
|
-
|
|
54030
|
-
|
|
54031
|
-
|
|
54032
|
-
|
|
54033
|
-
|
|
54034
|
-
|
|
54035
|
-
|
|
54036
|
-
default: DEFAULT_CONFIG.lwaClientId
|
|
54037
|
-
}),
|
|
54038
|
-
this.field({
|
|
54039
|
-
type: "password",
|
|
54040
|
-
key: "lwaClientSecret",
|
|
54041
|
-
label: "LWA client secret (proactive events)",
|
|
54042
|
-
description: "Paired with LWA client id. Treat as sensitive.",
|
|
54043
|
-
showToggle: true,
|
|
54044
|
-
default: DEFAULT_CONFIG.lwaClientSecret
|
|
54045
|
-
})
|
|
54046
|
-
] : [{
|
|
54577
|
+
...this.config.skillMode === "custom" ? [this.field({
|
|
54578
|
+
type: "text",
|
|
54579
|
+
key: "expectedAudience",
|
|
54580
|
+
label: "Alexa skill id",
|
|
54581
|
+
description: "Your skill's id, format amzn1.ask.skill.xxxx. Found on the Alexa developer console; used to gate directives the next dispatch will start signing.",
|
|
54582
|
+
default: DEFAULT_CONFIG.expectedAudience
|
|
54583
|
+
}), {
|
|
54584
|
+
type: "info",
|
|
54585
|
+
key: "__custom-skill-info",
|
|
54586
|
+
label: "Your skill Lambda",
|
|
54587
|
+
variant: "info",
|
|
54588
|
+
content: "Proactive events (doorbell, motion, battery, device-list updates) are authorised by YOUR skill Lambda: it keeps the \"Send Alexa Events\" LWA client id/secret in AWS Secrets Manager and hands this hub short-lived tokens. Nothing to paste here — see the custom-skill setup guide."
|
|
54589
|
+
}] : [{
|
|
54047
54590
|
type: "info",
|
|
54048
54591
|
key: "__public-skill-info",
|
|
54049
54592
|
label: "Built-in skill",
|
|
54050
54593
|
variant: "info",
|
|
54051
|
-
content: "Using the built-in CamStack public skill —
|
|
54594
|
+
content: "Using the built-in CamStack public skill — its Amazon credentials stay in the skill's Lambda, never on this hub. Pick your region above, then just enable \"CamStack\" in the Alexa app and link your account. Switch to \"Custom skill\" above to use your own."
|
|
54052
54595
|
}],
|
|
54053
54596
|
{
|
|
54054
54597
|
type: "info",
|
|
@@ -54058,7 +54601,6 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54058
54601
|
content: buildLinkStatusContent({
|
|
54059
54602
|
linkedAccounts: this.config.linkedAccounts,
|
|
54060
54603
|
tokens: this.config.tokens,
|
|
54061
|
-
hasLwaCredentials: this.lwaCredentials() !== null,
|
|
54062
54604
|
now: Date.now()
|
|
54063
54605
|
})
|
|
54064
54606
|
},
|
|
@@ -54072,6 +54614,15 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54072
54614
|
variant: "danger",
|
|
54073
54615
|
confirmMessage: "Unlink the Amazon account? Alexa loses access to your cameras immediately and you must re-link from the Alexa app to restore it."
|
|
54074
54616
|
},
|
|
54617
|
+
{
|
|
54618
|
+
type: "button",
|
|
54619
|
+
key: "__rotate-broker-credential",
|
|
54620
|
+
label: "Skill broker credential",
|
|
54621
|
+
description: "Replace the credential this hub uses at the skill's token broker with a fresh one. The old one stops working immediately. Use it if a backup of this hub's settings may have leaked. Re-linking from the Alexa app also issues a fresh one.",
|
|
54622
|
+
buttonLabel: "Rotate credential",
|
|
54623
|
+
action: "rotateBrokerCredential",
|
|
54624
|
+
variant: "default"
|
|
54625
|
+
},
|
|
54075
54626
|
{
|
|
54076
54627
|
type: "button",
|
|
54077
54628
|
key: "__resync-discovery",
|