@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as crypto$1 from "node:crypto";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
//#region ../types/dist/event-category-BGmz_oxW.mjs
|
|
3
4
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
4
5
|
EventCategory["SystemBoot"] = "system.boot";
|
|
@@ -5379,7 +5380,7 @@ var ZodIssueCode = {
|
|
|
5379
5380
|
var ZodFirstPartyTypeKind;
|
|
5380
5381
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5381
5382
|
//#endregion
|
|
5382
|
-
//#region ../types/dist/sleep-
|
|
5383
|
+
//#region ../types/dist/sleep-DAJ9Er57.mjs
|
|
5383
5384
|
/**
|
|
5384
5385
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5385
5386
|
* window to float samples (D455).
|
|
@@ -23996,148 +23997,17 @@ method(_void(), array(PipelineEngineChoiceSchema)), method(_void(), PipelineEngi
|
|
|
23996
23997
|
settings: record(string(), unknown()).optional()
|
|
23997
23998
|
}), AudioTestResultSchema, { kind: "mutation" }), method(_void(), ConfigUISchemaNullableBridge);
|
|
23998
23999
|
/**
|
|
23999
|
-
* Wire shapes of the Load balancing surface (D748): the session placement
|
|
24000
|
-
* policy, the atomic share write, the observed placement statistics, the
|
|
24001
|
-
* hardware share suggestion and the stale device pins.
|
|
24002
|
-
*/
|
|
24003
|
-
/**
|
|
24004
|
-
* How a new detection session picks its node and device.
|
|
24005
|
-
* - `least-loaded` — the historical rule: lowest `attached / weight`, a
|
|
24006
|
-
* constant node order at a tie (D37). An idle node always ties at 0, so a
|
|
24007
|
-
* weight only bites once a node already holds a session.
|
|
24008
|
-
* - `share` — the share of PARALLEL sessions is held to the weights, and a
|
|
24009
|
-
* full or drowning node yields to a free one (D748).
|
|
24010
|
-
*/
|
|
24011
|
-
var SessionPlacementPolicySchema = _enum(["least-loaded", "share"]);
|
|
24012
|
-
var LoadBalancingSharesInputSchema = object({
|
|
24013
|
-
/** nodeId → share (> 0). A node left out keeps its weight. */
|
|
24014
|
-
nodes: record(string(), number().positive()).default({}),
|
|
24015
|
-
/** nodeId → deviceKey → share (> 0). A device left out keeps its weight. */
|
|
24016
|
-
devices: record(string(), record(string(), number().positive())).default({})
|
|
24017
|
-
});
|
|
24018
|
-
var PlacementStatsInputSchema = object({
|
|
24019
|
-
/** Window the observed shares are averaged over (default 1 h, max 24 h). */
|
|
24020
|
-
windowMs: number().int().min(6e4).max(1440 * 60 * 1e3).default(3600 * 1e3) });
|
|
24021
|
-
var PlacementDeviceStatsSchema = object({
|
|
24022
|
-
deviceKey: string(),
|
|
24023
|
-
/** Sessions running on this device now. */
|
|
24024
|
-
sessionsNow: number(),
|
|
24025
|
-
/** Sessions started on this device inside the window. */
|
|
24026
|
-
sessionsStarted: number(),
|
|
24027
|
-
/** Time-weighted share of the node's parallel sessions over the window (0–1); null = no session ran. */
|
|
24028
|
-
observedShare: number().nullable()
|
|
24029
|
-
});
|
|
24030
|
-
var PlacementNodeStatsSchema = object({
|
|
24031
|
-
nodeId: string(),
|
|
24032
|
-
sessionsNow: number(),
|
|
24033
|
-
sessionsStarted: number(),
|
|
24034
|
-
/** Time-weighted share of the cluster's parallel sessions over the window (0–1); null = none ran. */
|
|
24035
|
-
observedShare: number().nullable(),
|
|
24036
|
-
/** Most sessions this node ran at once inside the window. */
|
|
24037
|
-
peakSessions: number(),
|
|
24038
|
-
/** Sessions a share election handed elsewhere because this node was full or drowning. */
|
|
24039
|
-
yieldsAway: number(),
|
|
24040
|
-
devices: array(PlacementDeviceStatsSchema).readonly()
|
|
24041
|
-
});
|
|
24042
|
-
/** A camera's device pin that the session path cannot honour. */
|
|
24043
|
-
var StaleDevicePinSchema = object({
|
|
24044
|
-
deviceId: number(),
|
|
24045
|
-
deviceKey: string(),
|
|
24046
|
-
/** `disabled` — the key exists on some node but is not enabled anywhere; `absent` — no node has it. */
|
|
24047
|
-
reason: _enum(["disabled", "absent"])
|
|
24048
|
-
});
|
|
24049
|
-
var PlacementStatsSchema = object({
|
|
24050
|
-
policy: SessionPlacementPolicySchema,
|
|
24051
|
-
windowMs: number(),
|
|
24052
|
-
/** How much of the window was actually observed (the hub keeps it in memory). */
|
|
24053
|
-
observedForMs: number(),
|
|
24054
|
-
sessionsNow: number(),
|
|
24055
|
-
/** Most parallel sessions across the cluster inside the window. */
|
|
24056
|
-
clusterPeakSessions: number(),
|
|
24057
|
-
nodes: array(PlacementNodeStatsSchema).readonly(),
|
|
24058
|
-
stalePins: array(StaleDevicePinSchema).readonly()
|
|
24059
|
-
});
|
|
24060
|
-
var SuggestSharesInputSchema = object({ headroom: number().min(.3).max(.5).default(.4) });
|
|
24061
|
-
var SuggestedDeviceShareSchema = object({
|
|
24062
|
-
deviceKey: string(),
|
|
24063
|
-
/** Share of the node (0–100, rounded). */
|
|
24064
|
-
share: number(),
|
|
24065
|
-
modelId: string().nullable(),
|
|
24066
|
-
/** Inferences per second at that model, from the measured table. */
|
|
24067
|
-
fps: number().nullable(),
|
|
24068
|
-
measured: boolean(),
|
|
24069
|
-
/** Advisory session cap: ⌊fps × (1 − headroom) / camera fps⌋. Never enforced. */
|
|
24070
|
-
suggestedMaxSessions: number().nullable()
|
|
24071
|
-
});
|
|
24072
|
-
var SuggestedNodeShareSchema = object({
|
|
24073
|
-
nodeId: string(),
|
|
24074
|
-
/** Share of the cluster (0–100, rounded); null = node skipped (offline, unknown hardware). */
|
|
24075
|
-
share: number().nullable(),
|
|
24076
|
-
/** Parallel sessions the node sustains at the suggested models with the headroom kept free. */
|
|
24077
|
-
capacitySessions: number(),
|
|
24078
|
-
devices: array(SuggestedDeviceShareSchema).readonly(),
|
|
24079
|
-
skippedReason: string().nullable()
|
|
24080
|
-
});
|
|
24081
|
-
var SuggestSharesResultSchema = object({
|
|
24082
|
-
headroom: number(),
|
|
24083
|
-
/** Camera detection rate the capacity is counted in (the fastest camera's fps). */
|
|
24084
|
-
cameraFps: number(),
|
|
24085
|
-
/** How many share → model → capacity rounds ran before the shares stopped moving (≤ 3). */
|
|
24086
|
-
iterations: number(),
|
|
24087
|
-
nodes: array(SuggestedNodeShareSchema).readonly()
|
|
24088
|
-
});
|
|
24089
|
-
/**
|
|
24090
|
-
* Where the node and device shares come from (D750).
|
|
24091
|
-
* - `auto` — computed by the system from the cluster's measured capacity at
|
|
24092
|
-
* the Auto tune models, and recomputed whenever a node or device joins,
|
|
24093
|
-
* leaves, is switched on or off, or changes model.
|
|
24094
|
-
* - `manual` — the operator's stored sliders. A node or device that cannot
|
|
24095
|
-
* take sessions gives its share to the others in proportion; the stored
|
|
24096
|
-
* values are never rewritten for that.
|
|
24097
|
-
*/
|
|
24098
|
-
var ShareSourceSchema = _enum(["auto", "manual"]);
|
|
24099
|
-
var SetShareSourceInputSchema = object({ source: ShareSourceSchema });
|
|
24100
|
-
object({
|
|
24101
|
-
nodes: record(string(), number()),
|
|
24102
|
-
devices: record(string(), record(string(), number()))
|
|
24103
|
-
});
|
|
24104
|
-
var EffectiveDeviceShareSchema = object({
|
|
24105
|
-
deviceKey: string(),
|
|
24106
|
-
/** Can take sessions now (enabled, available, can run the root). */
|
|
24107
|
-
eligible: boolean(),
|
|
24108
|
-
/** The share the source assigns, as a percentage of the node's devices. */
|
|
24109
|
-
configuredPct: number(),
|
|
24110
|
-
/** The share actually used, after an ineligible device's share went to the others. */
|
|
24111
|
-
effectivePct: number()
|
|
24112
|
-
});
|
|
24113
|
-
var EffectiveNodeShareSchema = object({
|
|
24114
|
-
nodeId: string(),
|
|
24115
|
-
eligible: boolean(),
|
|
24116
|
-
configuredPct: number(),
|
|
24117
|
-
effectivePct: number(),
|
|
24118
|
-
devices: array(EffectiveDeviceShareSchema).readonly()
|
|
24119
|
-
});
|
|
24120
|
-
var EffectiveSharesSchema = object({
|
|
24121
|
-
source: ShareSourceSchema,
|
|
24122
|
-
policy: SessionPlacementPolicySchema,
|
|
24123
|
-
/** `auto` with no computed answer yet: the stored sliders are used meanwhile. */
|
|
24124
|
-
autoPending: boolean(),
|
|
24125
|
-
/** When the automatic shares were last computed (ms epoch); null = never. */
|
|
24126
|
-
computedAt: number().nullable(),
|
|
24127
|
-
/** What made the system recompute them last. */
|
|
24128
|
-
trigger: string().nullable(),
|
|
24129
|
-
nodes: array(EffectiveNodeShareSchema).readonly(),
|
|
24130
|
-
/** The relative weights the election reads (before eligibility). */
|
|
24131
|
-
nodeWeights: record(string(), number()),
|
|
24132
|
-
deviceWeights: record(string(), record(string(), number()))
|
|
24133
|
-
});
|
|
24134
|
-
/**
|
|
24135
24000
|
* The wire shapes of the Auto tune plan and apply (D745), used by the
|
|
24136
24001
|
* `pipeline-orchestrator` cap. The recommender's own types
|
|
24137
|
-
* (`recommend.ts
|
|
24002
|
+
* (`recommend.ts`) are the TS face of the same data; these
|
|
24138
24003
|
* schemas are what crosses the cap boundary.
|
|
24139
24004
|
*/
|
|
24140
24005
|
var ModelFormatSchema$1 = _enum(MODEL_FORMATS);
|
|
24006
|
+
/** The operator's range for it. */
|
|
24007
|
+
var MIN_SESSIONS_PER_DEVICE_RANGE = {
|
|
24008
|
+
min: 1,
|
|
24009
|
+
max: 8
|
|
24010
|
+
};
|
|
24141
24011
|
var HardwareClassIdSchema = _enum([
|
|
24142
24012
|
"intel-core-ultra",
|
|
24143
24013
|
"intel-n-series",
|
|
@@ -24155,8 +24025,9 @@ var AcceleratorKindSchema = _enum([
|
|
|
24155
24025
|
]);
|
|
24156
24026
|
var RecommendationReasonSchema = discriminatedUnion("kind", [
|
|
24157
24027
|
object({
|
|
24158
|
-
kind: literal("
|
|
24159
|
-
|
|
24028
|
+
kind: literal("capacity"),
|
|
24029
|
+
sessions: number(),
|
|
24030
|
+
minSessions: number()
|
|
24160
24031
|
}),
|
|
24161
24032
|
object({
|
|
24162
24033
|
kind: literal("tie-faster"),
|
|
@@ -24171,16 +24042,13 @@ var RecommendationReasonSchema = discriminatedUnion("kind", [
|
|
|
24171
24042
|
}),
|
|
24172
24043
|
object({
|
|
24173
24044
|
kind: literal("no-fit-lightest"),
|
|
24174
|
-
|
|
24045
|
+
sessions: number(),
|
|
24046
|
+
minSessions: number()
|
|
24175
24047
|
}),
|
|
24176
24048
|
object({
|
|
24177
24049
|
kind: literal("derived-latency"),
|
|
24178
24050
|
modelId: string()
|
|
24179
24051
|
}),
|
|
24180
|
-
object({
|
|
24181
|
-
kind: literal("estimated-load"),
|
|
24182
|
-
observedForMs: number().nullable()
|
|
24183
|
-
}),
|
|
24184
24052
|
object({ kind: literal("cpu-device") }),
|
|
24185
24053
|
object({ kind: literal("coral-untouched") }),
|
|
24186
24054
|
object({ kind: literal("device-disabled") }),
|
|
@@ -24196,28 +24064,13 @@ var RecommendationReasonSchema = discriminatedUnion("kind", [
|
|
|
24196
24064
|
format: ModelFormatSchema$1
|
|
24197
24065
|
})
|
|
24198
24066
|
]);
|
|
24199
|
-
var DeviceDemandSchema = object({
|
|
24200
|
-
nodeId: string(),
|
|
24201
|
-
deviceKey: string(),
|
|
24202
|
-
demandFps: number(),
|
|
24203
|
-
maxCameraFps: number(),
|
|
24204
|
-
alwaysOnCameras: number(),
|
|
24205
|
-
onMotionSessions: number(),
|
|
24206
|
-
onMotionSource: _enum([
|
|
24207
|
-
"observed-24h",
|
|
24208
|
-
"estimate",
|
|
24209
|
-
"none"
|
|
24210
|
-
]),
|
|
24211
|
-
observedForMs: number().nullable(),
|
|
24212
|
-
/** How on-motion sessions were charged: per-device observation, or split by share (D748). */
|
|
24213
|
-
placement: SessionPlacementPolicySchema
|
|
24214
|
-
});
|
|
24215
24067
|
var DeviceModelChoiceSchema = object({
|
|
24216
24068
|
modelId: string(),
|
|
24217
24069
|
p50Ms: number(),
|
|
24218
24070
|
fps: number(),
|
|
24219
24071
|
measured: boolean(),
|
|
24220
|
-
|
|
24072
|
+
/** Parallel sessions at the camera rate with the headroom kept free: ⌊fps × (1 − headroom) / camera fps⌋. */
|
|
24073
|
+
capacitySessions: number(),
|
|
24221
24074
|
personRecall: number()
|
|
24222
24075
|
});
|
|
24223
24076
|
var DeviceRecommendationSchema = object({
|
|
@@ -24225,7 +24078,6 @@ var DeviceRecommendationSchema = object({
|
|
|
24225
24078
|
accelerator: AcceleratorKindSchema.nullable(),
|
|
24226
24079
|
enabled: boolean(),
|
|
24227
24080
|
available: boolean(),
|
|
24228
|
-
demand: DeviceDemandSchema.nullable(),
|
|
24229
24081
|
chosen: DeviceModelChoiceSchema.nullable(),
|
|
24230
24082
|
reasons: array(RecommendationReasonSchema).readonly()
|
|
24231
24083
|
});
|
|
@@ -24286,20 +24138,20 @@ var AutoTuneOptionsSchema = object({
|
|
|
24286
24138
|
/** Share of each accelerator's throughput kept free (0.3–0.5). */
|
|
24287
24139
|
headroom: number().min(.3).max(.5).default(.4),
|
|
24288
24140
|
/**
|
|
24289
|
-
*
|
|
24290
|
-
*
|
|
24291
|
-
*
|
|
24292
|
-
* is stored.
|
|
24141
|
+
* The least number of parallel sessions every device must sustain at the
|
|
24142
|
+
* camera rate with the headroom kept free (D755). The most accurate model
|
|
24143
|
+
* that reaches it wins.
|
|
24293
24144
|
*/
|
|
24294
|
-
|
|
24145
|
+
minSessionsPerDevice: number().int().min(MIN_SESSIONS_PER_DEVICE_RANGE.min).max(MIN_SESSIONS_PER_DEVICE_RANGE.max).default(2)
|
|
24295
24146
|
});
|
|
24296
24147
|
var AutoTunePlanSchema = object({
|
|
24297
24148
|
/** Hash of everything the apply will write — the apply refuses a different one. */
|
|
24298
24149
|
planHash: string(),
|
|
24299
24150
|
family: literal("yolov9"),
|
|
24300
24151
|
headroom: number(),
|
|
24301
|
-
|
|
24302
|
-
|
|
24152
|
+
minSessionsPerDevice: number(),
|
|
24153
|
+
/** The detection rate a session is counted at (the fastest camera running the pipeline root). */
|
|
24154
|
+
cameraFps: number(),
|
|
24303
24155
|
generatedAt: number(),
|
|
24304
24156
|
nodes: array(NodeRecommendationSchema).readonly(),
|
|
24305
24157
|
nodeChanges: array(NodeDeviceChangeSchema).readonly(),
|
|
@@ -24309,7 +24161,8 @@ var AutoTunePlanSchema = object({
|
|
|
24309
24161
|
});
|
|
24310
24162
|
var AutoTuneApplyInputSchema = object({
|
|
24311
24163
|
planHash: string(),
|
|
24312
|
-
headroom: number().min(.3).max(.5).default(.4)
|
|
24164
|
+
headroom: number().min(.3).max(.5).default(.4),
|
|
24165
|
+
minSessionsPerDevice: number().int().min(MIN_SESSIONS_PER_DEVICE_RANGE.min).max(MIN_SESSIONS_PER_DEVICE_RANGE.max).default(2)
|
|
24313
24166
|
});
|
|
24314
24167
|
var AutoTunePhaseResultSchema = object({
|
|
24315
24168
|
phase: _enum([
|
|
@@ -24341,6 +24194,177 @@ var AutoTuneApplyResultSchema = discriminatedUnion("kind", [
|
|
|
24341
24194
|
reason: _enum(["apply-in-progress"])
|
|
24342
24195
|
})
|
|
24343
24196
|
]);
|
|
24197
|
+
/**
|
|
24198
|
+
* Wire shapes of the Load balancing surface (D748): the session placement
|
|
24199
|
+
* policy, the atomic share write, the observed placement statistics, the
|
|
24200
|
+
* hardware share suggestion and the stale device pins.
|
|
24201
|
+
*/
|
|
24202
|
+
/**
|
|
24203
|
+
* How a new detection session picks its node and device.
|
|
24204
|
+
* - `least-loaded` — the historical rule: lowest `attached / weight`, a
|
|
24205
|
+
* constant node order at a tie (D37). An idle node always ties at 0, so a
|
|
24206
|
+
* weight only bites once a node already holds a session.
|
|
24207
|
+
* - `share` — the share of PARALLEL sessions is held to the weights, and a
|
|
24208
|
+
* full or drowning node yields to a free one (D748).
|
|
24209
|
+
*/
|
|
24210
|
+
var SessionPlacementPolicySchema = _enum(["least-loaded", "share"]);
|
|
24211
|
+
var LoadBalancingSharesInputSchema = object({
|
|
24212
|
+
/** nodeId → share (> 0). A node left out keeps its weight. */
|
|
24213
|
+
nodes: record(string(), number().positive()).default({}),
|
|
24214
|
+
/** nodeId → deviceKey → share (> 0). A device left out keeps its weight. */
|
|
24215
|
+
devices: record(string(), record(string(), number().positive())).default({})
|
|
24216
|
+
});
|
|
24217
|
+
var PlacementStatsInputSchema = object({
|
|
24218
|
+
/** Window the observed shares are averaged over (default 1 h, max 24 h). */
|
|
24219
|
+
windowMs: number().int().min(6e4).max(1440 * 60 * 1e3).default(3600 * 1e3) });
|
|
24220
|
+
var PlacementDeviceStatsSchema = object({
|
|
24221
|
+
deviceKey: string(),
|
|
24222
|
+
/** Sessions running on this device now. */
|
|
24223
|
+
sessionsNow: number(),
|
|
24224
|
+
/** Sessions started on this device inside the window. */
|
|
24225
|
+
sessionsStarted: number(),
|
|
24226
|
+
/** Time-weighted share of the node's parallel sessions over the window (0–1); null = no session ran. */
|
|
24227
|
+
observedShare: number().nullable()
|
|
24228
|
+
});
|
|
24229
|
+
var PlacementNodeStatsSchema = object({
|
|
24230
|
+
nodeId: string(),
|
|
24231
|
+
sessionsNow: number(),
|
|
24232
|
+
sessionsStarted: number(),
|
|
24233
|
+
/** Time-weighted share of the cluster's parallel sessions over the window (0–1); null = none ran. */
|
|
24234
|
+
observedShare: number().nullable(),
|
|
24235
|
+
/** Most sessions this node ran at once inside the window. */
|
|
24236
|
+
peakSessions: number(),
|
|
24237
|
+
/** Sessions a share election handed elsewhere because this node was full or drowning. */
|
|
24238
|
+
yieldsAway: number(),
|
|
24239
|
+
devices: array(PlacementDeviceStatsSchema).readonly()
|
|
24240
|
+
});
|
|
24241
|
+
/** A camera's device pin that the session path cannot honour. */
|
|
24242
|
+
var StaleDevicePinSchema = object({
|
|
24243
|
+
deviceId: number(),
|
|
24244
|
+
deviceKey: string(),
|
|
24245
|
+
/** `disabled` — the key exists on some node but is not enabled anywhere; `absent` — no node has it. */
|
|
24246
|
+
reason: _enum(["disabled", "absent"])
|
|
24247
|
+
});
|
|
24248
|
+
var PlacementStatsSchema = object({
|
|
24249
|
+
policy: SessionPlacementPolicySchema,
|
|
24250
|
+
windowMs: number(),
|
|
24251
|
+
/** How much of the window was actually observed (the hub keeps it in memory). */
|
|
24252
|
+
observedForMs: number(),
|
|
24253
|
+
sessionsNow: number(),
|
|
24254
|
+
/** Most parallel sessions across the cluster inside the window. */
|
|
24255
|
+
clusterPeakSessions: number(),
|
|
24256
|
+
nodes: array(PlacementNodeStatsSchema).readonly(),
|
|
24257
|
+
stalePins: array(StaleDevicePinSchema).readonly()
|
|
24258
|
+
});
|
|
24259
|
+
/**
|
|
24260
|
+
* Where an automatic share's capacity comes from (D755).
|
|
24261
|
+
* - `hardware` — the static capacity of each device at its Auto tune model:
|
|
24262
|
+
* the measured latency table for the node's hardware class.
|
|
24263
|
+
* - `benchmark` — the node's own measured capacity (node benchmark results).
|
|
24264
|
+
* A node with no results falls back to `hardware`, and says so.
|
|
24265
|
+
*/
|
|
24266
|
+
var CapacitySourceSchema = _enum(["hardware", "benchmark"]);
|
|
24267
|
+
/** Why a node under the `benchmark` source was sized from hardware instead. */
|
|
24268
|
+
var CapacityFallbackSchema = _enum(["no-benchmark-results", "partial-benchmark-results"]);
|
|
24269
|
+
var SuggestSharesInputSchema = object({
|
|
24270
|
+
headroom: number().min(.3).max(.5).default(.4),
|
|
24271
|
+
/** Default `hardware`. */
|
|
24272
|
+
source: CapacitySourceSchema.default("hardware")
|
|
24273
|
+
});
|
|
24274
|
+
var SuggestedDeviceShareSchema = object({
|
|
24275
|
+
deviceKey: string(),
|
|
24276
|
+
/** Share of the node (0–100, rounded). */
|
|
24277
|
+
share: number(),
|
|
24278
|
+
modelId: string().nullable(),
|
|
24279
|
+
/** Inferences per second at that model, from the measured table; null when sized by a benchmark. */
|
|
24280
|
+
fps: number().nullable(),
|
|
24281
|
+
/** False for a table cell derived from another chip; true for a measured cell or a benchmark. */
|
|
24282
|
+
measured: boolean(),
|
|
24283
|
+
/**
|
|
24284
|
+
* Advisory session cap: ⌊fps × (1 − headroom) / camera fps⌋ from the table,
|
|
24285
|
+
* or the benchmark's measured max sessions. Never enforced.
|
|
24286
|
+
*/
|
|
24287
|
+
suggestedMaxSessions: number().nullable()
|
|
24288
|
+
});
|
|
24289
|
+
var SuggestedNodeShareSchema = object({
|
|
24290
|
+
nodeId: string(),
|
|
24291
|
+
/** Share of the cluster (0–100, rounded); null = node skipped (offline, unknown hardware). */
|
|
24292
|
+
share: number().nullable(),
|
|
24293
|
+
/** Parallel sessions the node sustains at the suggested models with the headroom kept free. */
|
|
24294
|
+
capacitySessions: number(),
|
|
24295
|
+
devices: array(SuggestedDeviceShareSchema).readonly(),
|
|
24296
|
+
skippedReason: string().nullable(),
|
|
24297
|
+
/** What this node's capacity was read from; null when the node is skipped. */
|
|
24298
|
+
capacitySource: CapacitySourceSchema.nullable(),
|
|
24299
|
+
/** Set when the `benchmark` source was asked for and this node used `hardware`. */
|
|
24300
|
+
capacityFallback: CapacityFallbackSchema.nullable()
|
|
24301
|
+
});
|
|
24302
|
+
var SuggestSharesResultSchema = object({
|
|
24303
|
+
headroom: number(),
|
|
24304
|
+
/** The source asked for; each node says what it actually used. */
|
|
24305
|
+
source: CapacitySourceSchema,
|
|
24306
|
+
/** Camera detection rate the capacity is counted in (the fastest camera's fps). */
|
|
24307
|
+
cameraFps: number(),
|
|
24308
|
+
/** The Auto tune capacity floor the models were picked at. */
|
|
24309
|
+
minSessionsPerDevice: number(),
|
|
24310
|
+
nodes: array(SuggestedNodeShareSchema).readonly()
|
|
24311
|
+
});
|
|
24312
|
+
/**
|
|
24313
|
+
* Where the node and device shares come from (D750, D755).
|
|
24314
|
+
* - `hardware` (default) — computed by the system from each node's static
|
|
24315
|
+
* capacity (its accelerators at their Auto tune models, from the measured
|
|
24316
|
+
* latency table), recomputed whenever a node or device joins, leaves, is
|
|
24317
|
+
* switched on or off, or changes model. No load input.
|
|
24318
|
+
* - `benchmark` — the same, from each node's measured capacity (node
|
|
24319
|
+
* benchmark results); a node without results falls back to `hardware`.
|
|
24320
|
+
* - `manual` — the operator's stored sliders.
|
|
24321
|
+
* Either way a node or device that cannot take sessions gives its share to
|
|
24322
|
+
* the others in proportion; the stored values are never rewritten for that.
|
|
24323
|
+
*/
|
|
24324
|
+
var ShareSourceSchema = _enum([
|
|
24325
|
+
"hardware",
|
|
24326
|
+
"benchmark",
|
|
24327
|
+
"manual"
|
|
24328
|
+
]);
|
|
24329
|
+
var SetShareSourceInputSchema = object({ source: ShareSourceSchema });
|
|
24330
|
+
object({
|
|
24331
|
+
nodes: record(string(), number()),
|
|
24332
|
+
devices: record(string(), record(string(), number()))
|
|
24333
|
+
});
|
|
24334
|
+
var EffectiveDeviceShareSchema = object({
|
|
24335
|
+
deviceKey: string(),
|
|
24336
|
+
/** Can take sessions now (enabled, available, can run the root). */
|
|
24337
|
+
eligible: boolean(),
|
|
24338
|
+
/** The share the source assigns, as a percentage of the node's devices. */
|
|
24339
|
+
configuredPct: number(),
|
|
24340
|
+
/** The share actually used, after an ineligible device's share went to the others. */
|
|
24341
|
+
effectivePct: number()
|
|
24342
|
+
});
|
|
24343
|
+
var EffectiveNodeShareSchema = object({
|
|
24344
|
+
nodeId: string(),
|
|
24345
|
+
eligible: boolean(),
|
|
24346
|
+
configuredPct: number(),
|
|
24347
|
+
effectivePct: number(),
|
|
24348
|
+
/** Automatic sources: what this node's share was computed from; null under `manual` or before the first answer. */
|
|
24349
|
+
capacitySource: CapacitySourceSchema.nullable(),
|
|
24350
|
+
/** Automatic `benchmark`: why this node used `hardware` instead. */
|
|
24351
|
+
capacityFallback: CapacityFallbackSchema.nullable(),
|
|
24352
|
+
devices: array(EffectiveDeviceShareSchema).readonly()
|
|
24353
|
+
});
|
|
24354
|
+
var EffectiveSharesSchema = object({
|
|
24355
|
+
source: ShareSourceSchema,
|
|
24356
|
+
policy: SessionPlacementPolicySchema,
|
|
24357
|
+
/** An automatic source with no computed answer yet: the stored sliders are used meanwhile. */
|
|
24358
|
+
autoPending: boolean(),
|
|
24359
|
+
/** When the automatic shares were last computed (ms epoch); null = never. */
|
|
24360
|
+
computedAt: number().nullable(),
|
|
24361
|
+
/** What made the system recompute them last. */
|
|
24362
|
+
trigger: string().nullable(),
|
|
24363
|
+
nodes: array(EffectiveNodeShareSchema).readonly(),
|
|
24364
|
+
/** The relative weights the election reads (before eligibility). */
|
|
24365
|
+
nodeWeights: record(string(), number()),
|
|
24366
|
+
deviceWeights: record(string(), record(string(), number()))
|
|
24367
|
+
});
|
|
24344
24368
|
object({
|
|
24345
24369
|
activeCameras: number(),
|
|
24346
24370
|
throttledCameras: number(),
|
|
@@ -25520,8 +25544,10 @@ var CameraStepOverridePatchSchema = object({
|
|
|
25520
25544
|
modelId: string().optional(),
|
|
25521
25545
|
settings: record(string(), unknown()).readonly().optional()
|
|
25522
25546
|
});
|
|
25547
|
+
var CameraStepSettingsSchema = record(string(), record(string(), unknown()).readonly());
|
|
25523
25548
|
var CameraPipelineSettingsSchema = object({
|
|
25524
25549
|
stepToggles: record(string(), boolean()).optional(),
|
|
25550
|
+
stepSettings: CameraStepSettingsSchema.optional(),
|
|
25525
25551
|
stepOverridesByDevice: record(string(), record(string(), record(string(), CameraStepOverridePatchSchema))).optional(),
|
|
25526
25552
|
pipelineByAgent: record(string(), CameraPipelineForAgentSchema).optional()
|
|
25527
25553
|
});
|
|
@@ -26085,6 +26111,13 @@ method(object({
|
|
|
26085
26111
|
}), object({ success: literal(true) }), {
|
|
26086
26112
|
kind: "mutation",
|
|
26087
26113
|
auth: "admin"
|
|
26114
|
+
}), method(object({
|
|
26115
|
+
deviceId: number(),
|
|
26116
|
+
addonId: string(),
|
|
26117
|
+
settings: record(string(), unknown()).nullable()
|
|
26118
|
+
}), object({ success: literal(true) }), {
|
|
26119
|
+
kind: "mutation",
|
|
26120
|
+
auth: "admin"
|
|
26088
26121
|
}), method(object({
|
|
26089
26122
|
deviceId: number(),
|
|
26090
26123
|
agentNodeId: string(),
|
|
@@ -42160,6 +42193,12 @@ Object.freeze({
|
|
|
42160
42193
|
addonId: null,
|
|
42161
42194
|
access: "create"
|
|
42162
42195
|
},
|
|
42196
|
+
"pipelineOrchestrator.setCameraStepSettings": {
|
|
42197
|
+
capName: "pipeline-orchestrator",
|
|
42198
|
+
capScope: "system",
|
|
42199
|
+
addonId: null,
|
|
42200
|
+
access: "create"
|
|
42201
|
+
},
|
|
42163
42202
|
"pipelineOrchestrator.setCameraStepToggle": {
|
|
42164
42203
|
capName: "pipeline-orchestrator",
|
|
42165
42204
|
capScope: "system",
|
|
@@ -47608,6 +47647,11 @@ Object.freeze({
|
|
|
47608
47647
|
form: "single",
|
|
47609
47648
|
optional: false
|
|
47610
47649
|
}],
|
|
47650
|
+
"pipelineOrchestrator.setCameraStepSettings": [{
|
|
47651
|
+
name: "deviceId",
|
|
47652
|
+
form: "single",
|
|
47653
|
+
optional: false
|
|
47654
|
+
}],
|
|
47611
47655
|
"pipelineOrchestrator.setCameraStepToggle": [{
|
|
47612
47656
|
name: "deviceId",
|
|
47613
47657
|
form: "single",
|
|
@@ -49568,6 +49612,15 @@ var exportAlexaActions = defineCustomActions({
|
|
|
49568
49612
|
unlinkAlexaAccounts: customAction(object({}).optional(), object({
|
|
49569
49613
|
revokedSessions: number(),
|
|
49570
49614
|
removedAccounts: number()
|
|
49615
|
+
}), { kind: "mutation" }),
|
|
49616
|
+
/**
|
|
49617
|
+
* Swap the per-link credential this hub presents to the skill Lambda's
|
|
49618
|
+
* token broker (D753) for a fresh one; the old one dies at once. Rotation
|
|
49619
|
+
* also happens implicitly on every re-link.
|
|
49620
|
+
*/
|
|
49621
|
+
rotateBrokerCredential: customAction(object({}).optional(), object({
|
|
49622
|
+
rotated: number(),
|
|
49623
|
+
failed: number()
|
|
49571
49624
|
}), { kind: "mutation" })
|
|
49572
49625
|
});
|
|
49573
49626
|
//#endregion
|
|
@@ -49765,6 +49818,9 @@ function parseNonNegativeInt(s) {
|
|
|
49765
49818
|
/**
|
|
49766
49819
|
* Shared Alexa `Alexa/ErrorResponse` envelope builder.
|
|
49767
49820
|
*
|
|
49821
|
+
* Certification checks the shape: `correlationToken` (set here) and
|
|
49822
|
+
* `endpoint` (set by `withErrorEndpoint` in the router) are both required.
|
|
49823
|
+
*
|
|
49768
49824
|
* Lives in its own module so every handler (and the directive router)
|
|
49769
49825
|
* can emit its own namespace-appropriate error without circular imports.
|
|
49770
49826
|
*/
|
|
@@ -49773,8 +49829,10 @@ object({ event: object({
|
|
|
49773
49829
|
namespace: literal("Alexa"),
|
|
49774
49830
|
name: literal("ErrorResponse"),
|
|
49775
49831
|
messageId: string(),
|
|
49832
|
+
correlationToken: string().optional(),
|
|
49776
49833
|
payloadVersion: literal("3")
|
|
49777
49834
|
}),
|
|
49835
|
+
endpoint: object({ endpointId: string() }).optional(),
|
|
49778
49836
|
payload: object({
|
|
49779
49837
|
type: string(),
|
|
49780
49838
|
message: string()
|
|
@@ -49786,6 +49844,7 @@ function errorEnvelope(header, type, message) {
|
|
|
49786
49844
|
namespace: "Alexa",
|
|
49787
49845
|
name: "ErrorResponse",
|
|
49788
49846
|
messageId: header.messageId,
|
|
49847
|
+
...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
|
|
49789
49848
|
payloadVersion: "3"
|
|
49790
49849
|
},
|
|
49791
49850
|
payload: {
|
|
@@ -50146,18 +50205,147 @@ async function handleSessionDisconnected(header, endpointRaw, payloadRaw, deps,
|
|
|
50146
50205
|
},
|
|
50147
50206
|
meta: { endpointId: endpoint.endpointId }
|
|
50148
50207
|
});
|
|
50208
|
+
return sessionEvent("SessionDisconnected", header, endpoint.endpointId, parsed.data.payload.sessionId);
|
|
50209
|
+
}
|
|
50210
|
+
/**
|
|
50211
|
+
* The response to `SessionConnected` / `SessionDisconnected` is the event of
|
|
50212
|
+
* the same name with the session id (alexa-rtcsessioncontroller) — not a
|
|
50213
|
+
* generic `Alexa.Response`.
|
|
50214
|
+
*/
|
|
50215
|
+
function sessionEvent(name, header, endpointId, sessionId) {
|
|
50149
50216
|
return { event: {
|
|
50150
50217
|
header: {
|
|
50151
|
-
namespace: "Alexa",
|
|
50152
|
-
name
|
|
50218
|
+
namespace: "Alexa.RTCSessionController",
|
|
50219
|
+
name,
|
|
50153
50220
|
messageId: header.messageId,
|
|
50154
50221
|
correlationToken: header.correlationToken,
|
|
50155
50222
|
payloadVersion: "3"
|
|
50156
50223
|
},
|
|
50157
|
-
endpoint: { endpointId
|
|
50158
|
-
payload: {}
|
|
50224
|
+
endpoint: { endpointId },
|
|
50225
|
+
payload: { sessionId }
|
|
50159
50226
|
} };
|
|
50160
50227
|
}
|
|
50228
|
+
/**
|
|
50229
|
+
* `SessionConnected` — Alexa reports the media path is up. Nothing to do
|
|
50230
|
+
* hub-side (the session already exists from the offer); acknowledge it. Amazon
|
|
50231
|
+
* notes it is not always sent, so nothing may depend on it.
|
|
50232
|
+
*/
|
|
50233
|
+
function handleSessionConnected(header, endpointRaw, payloadRaw, deps) {
|
|
50234
|
+
const parsed = SessionDisconnectedDirectiveSchema.safeParse({
|
|
50235
|
+
endpoint: endpointRaw,
|
|
50236
|
+
payload: payloadRaw ?? {}
|
|
50237
|
+
});
|
|
50238
|
+
if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
|
|
50239
|
+
deps.logger.info("alexa rtc-session: SessionConnected", {
|
|
50240
|
+
tags: { sessionId: parsed.data.payload.sessionId },
|
|
50241
|
+
meta: { endpointId: parsed.data.endpoint.endpointId }
|
|
50242
|
+
});
|
|
50243
|
+
return sessionEvent("SessionConnected", header, parsed.data.endpoint.endpointId, parsed.data.payload.sessionId);
|
|
50244
|
+
}
|
|
50245
|
+
//#endregion
|
|
50246
|
+
//#region src/broker-client.ts
|
|
50247
|
+
/**
|
|
50248
|
+
* The hub's side of the skill's token broker (D753).
|
|
50249
|
+
*
|
|
50250
|
+
* The hub holds NO Login With Amazon client secret. To push a proactive event
|
|
50251
|
+
* it needs an LWA access token for the linked Amazon account, and it gets one
|
|
50252
|
+
* from the skill's Lambda (the broker) by presenting the per-link credential
|
|
50253
|
+
* the Lambda minted at link time (`csb1.<linkId>.<secret>`). The Lambda keeps
|
|
50254
|
+
* the refresh token and the client secret; the hub keeps a credential that
|
|
50255
|
+
* opens exactly one link and can be revoked or rotated.
|
|
50256
|
+
*
|
|
50257
|
+
* Every function here is a single HTTPS call with a timeout and a Zod-parsed
|
|
50258
|
+
* answer. Errors carry a `kind` so the gateway can tell "re-link needed" from
|
|
50259
|
+
* "try again later"; their messages never contain a token or a credential.
|
|
50260
|
+
*/
|
|
50261
|
+
var BROKER_TIMEOUT_MS = 8e3;
|
|
50262
|
+
/** Origin-only, HTTPS-only. Anything else is refused before a credential is sent. */
|
|
50263
|
+
function normaliseBrokerUrl(raw) {
|
|
50264
|
+
if (typeof raw !== "string" || raw.length === 0 || raw.length > 512) return null;
|
|
50265
|
+
try {
|
|
50266
|
+
const u = new URL(raw);
|
|
50267
|
+
if (u.protocol !== "https:" || u.username || u.password) return null;
|
|
50268
|
+
return u.origin;
|
|
50269
|
+
} catch {
|
|
50270
|
+
return null;
|
|
50271
|
+
}
|
|
50272
|
+
}
|
|
50273
|
+
var BrokerError = class extends Error {
|
|
50274
|
+
kind;
|
|
50275
|
+
status;
|
|
50276
|
+
constructor(kind, message, status) {
|
|
50277
|
+
super(message);
|
|
50278
|
+
this.kind = kind;
|
|
50279
|
+
this.status = status;
|
|
50280
|
+
this.name = "BrokerError";
|
|
50281
|
+
}
|
|
50282
|
+
};
|
|
50283
|
+
var AccessTokenSchema = object({
|
|
50284
|
+
accessToken: string().min(1),
|
|
50285
|
+
expiresAt: number().int().positive()
|
|
50286
|
+
});
|
|
50287
|
+
var MigrationSchema = AccessTokenSchema.extend({
|
|
50288
|
+
brokerCredential: string().min(1),
|
|
50289
|
+
brokerUrl: string().min(1)
|
|
50290
|
+
});
|
|
50291
|
+
var RotationSchema = object({ brokerCredential: string().min(1) });
|
|
50292
|
+
function kindForStatus(status) {
|
|
50293
|
+
if (status === 401) return "unauthorized";
|
|
50294
|
+
if (status === 410) return "revoked";
|
|
50295
|
+
if (status === 400 || status === 403 || status === 404 || status === 405) return "rejected";
|
|
50296
|
+
return "unavailable";
|
|
50297
|
+
}
|
|
50298
|
+
async function call(fetchImpl, brokerUrl, path, init, schema) {
|
|
50299
|
+
const origin = normaliseBrokerUrl(brokerUrl);
|
|
50300
|
+
if (!origin) throw new BrokerError("rejected", "broker URL is not an https origin");
|
|
50301
|
+
let response;
|
|
50302
|
+
try {
|
|
50303
|
+
response = await fetchImpl(`${origin}${path}`, {
|
|
50304
|
+
method: "POST",
|
|
50305
|
+
headers: {
|
|
50306
|
+
"content-type": "application/json",
|
|
50307
|
+
...init.bearer ? { authorization: `Bearer ${init.bearer}` } : {}
|
|
50308
|
+
},
|
|
50309
|
+
body: JSON.stringify(init.body ?? {}),
|
|
50310
|
+
signal: AbortSignal.timeout(BROKER_TIMEOUT_MS)
|
|
50311
|
+
});
|
|
50312
|
+
} catch (err) {
|
|
50313
|
+
throw new BrokerError("unavailable", `broker ${path} unreachable: ${err instanceof Error ? err.message : String(err)}`);
|
|
50314
|
+
}
|
|
50315
|
+
const text = await response.text().catch(() => "");
|
|
50316
|
+
let json = null;
|
|
50317
|
+
try {
|
|
50318
|
+
json = text ? JSON.parse(text) : null;
|
|
50319
|
+
} catch {
|
|
50320
|
+
json = null;
|
|
50321
|
+
}
|
|
50322
|
+
if (!response.ok) {
|
|
50323
|
+
const code = json && typeof json === "object" && "error" in json && typeof json.error === "string" ? json.error : "error";
|
|
50324
|
+
throw new BrokerError(kindForStatus(response.status), `broker ${path} ${response.status} ${code}`, response.status);
|
|
50325
|
+
}
|
|
50326
|
+
const parsed = schema.safeParse(json);
|
|
50327
|
+
if (!parsed.success) throw new BrokerError("unavailable", `broker ${path} answered an unexpected shape`);
|
|
50328
|
+
return parsed.data;
|
|
50329
|
+
}
|
|
50330
|
+
/** A fresh (or still-valid) Event Gateway access token for this link. */
|
|
50331
|
+
function fetchBrokerAccessToken(fetchImpl, brokerUrl, credential) {
|
|
50332
|
+
return call(fetchImpl, brokerUrl, "/broker/token", { bearer: credential }, AccessTokenSchema);
|
|
50333
|
+
}
|
|
50334
|
+
/**
|
|
50335
|
+
* Hand a pre-D753 refresh token to the broker in exchange for a link. The
|
|
50336
|
+
* caller deletes its own copy of the refresh token on success.
|
|
50337
|
+
*/
|
|
50338
|
+
function migrateLegacyGrant(fetchImpl, brokerUrl, input) {
|
|
50339
|
+
return call(fetchImpl, brokerUrl, "/broker/migrate", { body: input }, MigrationSchema);
|
|
50340
|
+
}
|
|
50341
|
+
/** Swap the credential's secret; the old one stops working at once. */
|
|
50342
|
+
async function rotateBrokerCredential(fetchImpl, brokerUrl, credential) {
|
|
50343
|
+
return (await call(fetchImpl, brokerUrl, "/broker/rotate", { bearer: credential }, RotationSchema)).brokerCredential;
|
|
50344
|
+
}
|
|
50345
|
+
/** Delete the link at the broker (the refresh token goes with it). */
|
|
50346
|
+
async function revokeBrokerCredential(fetchImpl, brokerUrl, credential) {
|
|
50347
|
+
await call(fetchImpl, brokerUrl, "/broker/revoke", { bearer: credential }, unknown());
|
|
50348
|
+
}
|
|
50161
50349
|
//#endregion
|
|
50162
50350
|
//#region src/handlers/security-panel-arm-state.ts
|
|
50163
50351
|
/**
|
|
@@ -50259,6 +50447,24 @@ var CAMERA_STREAM_CONTROLLER = {
|
|
|
50259
50447
|
audioCodecs: ["AAC"]
|
|
50260
50448
|
}]
|
|
50261
50449
|
};
|
|
50450
|
+
/**
|
|
50451
|
+
* `Alexa.EndpointHealth` — REQUIRED on a sensor endpoint (alexa-endpointhealth;
|
|
50452
|
+
* alexa-motionsensor: "Must also implement `Alexa.EndpointHealth`"), and every
|
|
50453
|
+
* camera endpoint may carry `Alexa.MotionSensor`. Declared on every camera so
|
|
50454
|
+
* Alexa can tell an offline camera from a broken one: `connectivity` comes
|
|
50455
|
+
* from the `device-status` cap in ReportState and is pushed as a ChangeReport
|
|
50456
|
+
* on `DeviceOnline` / `DeviceOffline` by the Event Gateway.
|
|
50457
|
+
*/
|
|
50458
|
+
var ENDPOINT_HEALTH = {
|
|
50459
|
+
type: "AlexaInterface",
|
|
50460
|
+
interface: "Alexa.EndpointHealth",
|
|
50461
|
+
version: "3",
|
|
50462
|
+
properties: {
|
|
50463
|
+
supported: [{ name: "connectivity" }],
|
|
50464
|
+
proactivelyReported: true,
|
|
50465
|
+
retrievable: true
|
|
50466
|
+
}
|
|
50467
|
+
};
|
|
50262
50468
|
var DOORBELL_EVENT_SOURCE = {
|
|
50263
50469
|
type: "AlexaInterface",
|
|
50264
50470
|
interface: "Alexa.DoorbellEventSource",
|
|
@@ -50460,7 +50666,8 @@ function buildParentCameraEndpoint(input) {
|
|
|
50460
50666
|
const capabilities = [
|
|
50461
50667
|
ALEXA_BASE_CAPABILITY,
|
|
50462
50668
|
CAMERA_STREAM_CONTROLLER,
|
|
50463
|
-
rtcSessionController(input.hasIntercom)
|
|
50669
|
+
rtcSessionController(input.hasIntercom),
|
|
50670
|
+
ENDPOINT_HEALTH
|
|
50464
50671
|
];
|
|
50465
50672
|
if (input.isDoorbell) capabilities.push(DOORBELL_EVENT_SOURCE);
|
|
50466
50673
|
if (input.hasMotion) capabilities.push(MOTION_SENSOR);
|
|
@@ -51284,6 +51491,20 @@ async function collectCameraProperties(api, deviceId, properties, timeOfSample,
|
|
|
51284
51491
|
timeOfSample,
|
|
51285
51492
|
uncertaintyInMilliseconds: 0
|
|
51286
51493
|
});
|
|
51494
|
+
const online = boolField(await api.deviceStatus.getStatus.query({ deviceId }).catch((err) => {
|
|
51495
|
+
logger.warn("alexa report-state: deviceStatus.getStatus failed — connectivity omitted", {
|
|
51496
|
+
tags: { deviceId },
|
|
51497
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
51498
|
+
});
|
|
51499
|
+
return null;
|
|
51500
|
+
}), "online");
|
|
51501
|
+
if (online !== null) properties.push({
|
|
51502
|
+
namespace: "Alexa.EndpointHealth",
|
|
51503
|
+
name: "connectivity",
|
|
51504
|
+
value: { value: online ? "OK" : "UNREACHABLE" },
|
|
51505
|
+
timeOfSample,
|
|
51506
|
+
uncertaintyInMilliseconds: 0
|
|
51507
|
+
});
|
|
51287
51508
|
const detected = boolField(await api.motion.getStatus.query({ deviceId }).catch((err) => {
|
|
51288
51509
|
logger.info("alexa report-state: motion.getStatus skipped", { meta: {
|
|
51289
51510
|
deviceId,
|
|
@@ -51666,7 +51887,37 @@ function assertDeviceScope(scopes) {
|
|
|
51666
51887
|
function requireDeviceScope(scopes, access) {
|
|
51667
51888
|
if (!scopesAllowDeviceCap(scopes, access)) throw new Error(`directive token not authorized for device ${access}`);
|
|
51668
51889
|
}
|
|
51890
|
+
/**
|
|
51891
|
+
* Give an `Alexa.ErrorResponse` the directive's endpoint when it lacks one.
|
|
51892
|
+
* Amazon requires `endpoint` on every ErrorResponse to an endpoint directive
|
|
51893
|
+
* (alexa-errorresponse); the handlers build their errors from the header
|
|
51894
|
+
* alone, so the router — which holds the endpoint for every path — adds it.
|
|
51895
|
+
*/
|
|
51896
|
+
function withErrorEndpoint(body, endpoint) {
|
|
51897
|
+
const endpointId = typeof endpoint === "object" && endpoint !== null && typeof endpoint.endpointId === "string" ? endpoint.endpointId : null;
|
|
51898
|
+
if (endpointId === null || typeof body !== "object" || body === null) return body;
|
|
51899
|
+
const event = body.event;
|
|
51900
|
+
if (typeof event !== "object" || event === null) return body;
|
|
51901
|
+
const ev = event;
|
|
51902
|
+
if (ev.header?.namespace !== "Alexa" || ev.header?.name !== "ErrorResponse") return body;
|
|
51903
|
+
if (ev.endpoint !== void 0) return body;
|
|
51904
|
+
return {
|
|
51905
|
+
...body,
|
|
51906
|
+
event: {
|
|
51907
|
+
...ev,
|
|
51908
|
+
endpoint: { endpointId }
|
|
51909
|
+
}
|
|
51910
|
+
};
|
|
51911
|
+
}
|
|
51669
51912
|
async function dispatchDirective(rawBody, deps, callerIdentity) {
|
|
51913
|
+
const result = await dispatchParsedDirective(rawBody, deps, callerIdentity);
|
|
51914
|
+
const endpoint = rawBody?.directive?.endpoint;
|
|
51915
|
+
return {
|
|
51916
|
+
status: result.status,
|
|
51917
|
+
body: withErrorEndpoint(result.body, endpoint)
|
|
51918
|
+
};
|
|
51919
|
+
}
|
|
51920
|
+
async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
|
|
51670
51921
|
const parsed = DirectiveRequestSchema.safeParse(rawBody);
|
|
51671
51922
|
if (!parsed.success) return {
|
|
51672
51923
|
status: 400,
|
|
@@ -51688,6 +51939,7 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
|
|
|
51688
51939
|
"Alexa.Discovery/Discover": "view",
|
|
51689
51940
|
"Alexa.CameraStreamController/InitializeCameraStreams": "view",
|
|
51690
51941
|
"Alexa.RTCSessionController/InitiateSessionWithOffer": "view",
|
|
51942
|
+
"Alexa.RTCSessionController/SessionConnected": "view",
|
|
51691
51943
|
"Alexa.RTCSessionController/SessionDisconnected": "view",
|
|
51692
51944
|
"Alexa.PowerController/TurnOn": "create",
|
|
51693
51945
|
"Alexa.PowerController/TurnOff": "create",
|
|
@@ -51722,6 +51974,10 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
|
|
|
51722
51974
|
status: 200,
|
|
51723
51975
|
body: await handleInitiateSessionWithOffer(header, endpoint, payload, deps.rtcSession, callerIdentity)
|
|
51724
51976
|
};
|
|
51977
|
+
if (key === "Alexa.RTCSessionController/SessionConnected") return {
|
|
51978
|
+
status: 200,
|
|
51979
|
+
body: handleSessionConnected(header, endpoint, payload, deps.rtcSession)
|
|
51980
|
+
};
|
|
51725
51981
|
if (key === "Alexa.RTCSessionController/SessionDisconnected") return {
|
|
51726
51982
|
status: 200,
|
|
51727
51983
|
body: await handleSessionDisconnected(header, endpoint, payload, deps.rtcSession, callerIdentity)
|
|
@@ -51768,8 +52024,12 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
|
|
|
51768
52024
|
//#endregion
|
|
51769
52025
|
//#region src/handlers/lwa-tokens-callback.ts
|
|
51770
52026
|
/**
|
|
51771
|
-
* `POST /addon/export-alexa/oauth/lwa-tokens` —
|
|
51772
|
-
* Lambda invokes after
|
|
52027
|
+
* `POST /addon/export-alexa/oauth/lwa-tokens` — the LEGACY (pre-D753)
|
|
52028
|
+
* callback a Lambda without the token broker invokes after the LWA grant
|
|
52029
|
+
* exchange. It hands this hub the refresh token itself. Kept so an old Lambda
|
|
52030
|
+
* keeps linking during the cut-over; the hub cannot renew such a grant (it
|
|
52031
|
+
* holds no client secret) and migrates it to the broker instead. The D753
|
|
52032
|
+
* Lambda calls `/oauth/broker-link` (`broker-link-callback.ts`).
|
|
51773
52033
|
*
|
|
51774
52034
|
* Auth: Bearer `<granteeToken>` where the grantee token is the OAuth
|
|
51775
52035
|
* access token (kind=`oauth-access`) issued by the hub's core OAuth
|
|
@@ -51822,7 +52082,7 @@ async function handleLwaTokensCallback(request, reply, deps) {
|
|
|
51822
52082
|
userId,
|
|
51823
52083
|
username
|
|
51824
52084
|
});
|
|
51825
|
-
deps.logger.
|
|
52085
|
+
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: {
|
|
51826
52086
|
userId,
|
|
51827
52087
|
expiresInMs: body.expiresAt - Date.now()
|
|
51828
52088
|
} });
|
|
@@ -51859,6 +52119,78 @@ function pickHeader(headers, name) {
|
|
|
51859
52119
|
return null;
|
|
51860
52120
|
}
|
|
51861
52121
|
//#endregion
|
|
52122
|
+
//#region src/handlers/broker-link-callback.ts
|
|
52123
|
+
/**
|
|
52124
|
+
* `POST /addon/export-alexa/oauth/broker-link` — the skill Lambda tells this
|
|
52125
|
+
* hub that an Amazon account was linked (D753).
|
|
52126
|
+
*
|
|
52127
|
+
* The Lambda has already exchanged the AcceptGrant code with Amazon and kept
|
|
52128
|
+
* the refresh token. It gives this hub:
|
|
52129
|
+
* - `brokerUrl` where to ask for renewed access tokens,
|
|
52130
|
+
* - `brokerCredential` this link's credential at the broker,
|
|
52131
|
+
* - `lwaAccessToken` / `expiresAt` the first Event Gateway token.
|
|
52132
|
+
* No refresh token and no client secret reach the hub.
|
|
52133
|
+
*
|
|
52134
|
+
* Auth: identical to the legacy `/oauth/lwa-tokens` callback — Bearer
|
|
52135
|
+
* `<granteeToken>`, the hub's own `oauth-access` token Amazon presented with
|
|
52136
|
+
* the AcceptGrant, verified by `oauthVerifyAccessToken` (and the ingress-token
|
|
52137
|
+
* guard in `http-server.ts`). A Lambda that does not know this route gets 404
|
|
52138
|
+
* and falls back to the legacy callback, which is how the cut-over stays
|
|
52139
|
+
* zero-downtime in both directions.
|
|
52140
|
+
*
|
|
52141
|
+
* Errors: 400 `invalid_body` / `invalid_broker_url`, 401 `unauthorized`.
|
|
52142
|
+
*/
|
|
52143
|
+
var BrokerLinkBodySchema = object({
|
|
52144
|
+
brokerUrl: string().min(1).max(512),
|
|
52145
|
+
brokerCredential: string().min(1).max(512),
|
|
52146
|
+
lwaAccessToken: string().min(1),
|
|
52147
|
+
expiresAt: number().int().nonnegative()
|
|
52148
|
+
});
|
|
52149
|
+
async function handleBrokerLinkCallback(request, reply, deps) {
|
|
52150
|
+
const parsed = BrokerLinkBodySchema.safeParse(request.body);
|
|
52151
|
+
if (!parsed.success) {
|
|
52152
|
+
reply.code(400).send({
|
|
52153
|
+
error: "invalid_body",
|
|
52154
|
+
message: parsed.error.message
|
|
52155
|
+
});
|
|
52156
|
+
return;
|
|
52157
|
+
}
|
|
52158
|
+
const body = parsed.data;
|
|
52159
|
+
const brokerUrl = normaliseBrokerUrl(body.brokerUrl);
|
|
52160
|
+
if (!brokerUrl) {
|
|
52161
|
+
reply.code(400).send({
|
|
52162
|
+
error: "invalid_broker_url",
|
|
52163
|
+
message: "brokerUrl must be an https origin"
|
|
52164
|
+
});
|
|
52165
|
+
return;
|
|
52166
|
+
}
|
|
52167
|
+
const caller = await resolveCaller(request, deps);
|
|
52168
|
+
if (!caller) {
|
|
52169
|
+
deps.logger.warn("alexa broker-link: unauthorized", { meta: { hasBearer: pickHeader(request.headers, "authorization") !== null } });
|
|
52170
|
+
reply.code(401).send({
|
|
52171
|
+
error: "unauthorized",
|
|
52172
|
+
message: "callback requires Bearer <granteeToken> (oauth-access token)"
|
|
52173
|
+
});
|
|
52174
|
+
return;
|
|
52175
|
+
}
|
|
52176
|
+
await deps.upsertToken({
|
|
52177
|
+
userId: caller.userId,
|
|
52178
|
+
accessToken: body.lwaAccessToken,
|
|
52179
|
+
expiresAt: body.expiresAt,
|
|
52180
|
+
brokerUrl,
|
|
52181
|
+
brokerCredential: body.brokerCredential
|
|
52182
|
+
});
|
|
52183
|
+
await deps.recordLinkedAccount({
|
|
52184
|
+
userId: caller.userId,
|
|
52185
|
+
username: caller.username
|
|
52186
|
+
});
|
|
52187
|
+
deps.logger.info("alexa broker-link: linked — push grant held by the skill broker", { meta: {
|
|
52188
|
+
userId: caller.userId,
|
|
52189
|
+
expiresInMs: body.expiresAt - Date.now()
|
|
52190
|
+
} });
|
|
52191
|
+
reply.code(200).send({ ok: true });
|
|
52192
|
+
}
|
|
52193
|
+
//#endregion
|
|
51862
52194
|
//#region src/ingress-token.ts
|
|
51863
52195
|
/**
|
|
51864
52196
|
* Ingress token — a rotatable shared secret that the user-owned Alexa Lambda
|
|
@@ -51916,7 +52248,8 @@ function readIngressHeader(headers) {
|
|
|
51916
52248
|
*
|
|
51917
52249
|
* Public URLs (assuming the hub is reachable as `https://<hub>`):
|
|
51918
52250
|
* POST /addon/export-alexa/directive
|
|
51919
|
-
* POST /addon/export-alexa/oauth/
|
|
52251
|
+
* POST /addon/export-alexa/oauth/broker-link (Lambda → hub, D753 broker link)
|
|
52252
|
+
* POST /addon/export-alexa/oauth/lwa-tokens (Lambda → hub, legacy pre-D753)
|
|
51920
52253
|
*
|
|
51921
52254
|
* OAuth account-linking is handled by the core `/oauth2/*` server
|
|
51922
52255
|
* (integration=export-alexa). This addon registers an `oauth-integration`
|
|
@@ -51927,6 +52260,8 @@ var OauthVerifiedResultSchema = object({
|
|
|
51927
52260
|
username: string(),
|
|
51928
52261
|
scopes: array(TokenScopeSchema)
|
|
51929
52262
|
}).nullable();
|
|
52263
|
+
/** Header the D753 Lambda sends its broker origin on. */
|
|
52264
|
+
var BROKER_HEADER = "x-camstack-broker";
|
|
51930
52265
|
function buildRoutes(deps) {
|
|
51931
52266
|
return [
|
|
51932
52267
|
{
|
|
@@ -51936,6 +52271,16 @@ function buildRoutes(deps) {
|
|
|
51936
52271
|
description: "Alexa Smart Home directive ingress (forwarded by the user-owned Lambda)",
|
|
51937
52272
|
handler: (req, reply) => handleDirective(req, reply, deps)
|
|
51938
52273
|
},
|
|
52274
|
+
{
|
|
52275
|
+
method: "POST",
|
|
52276
|
+
path: "/oauth/broker-link",
|
|
52277
|
+
access: "public",
|
|
52278
|
+
description: "Lambda → hub broker link (D753): broker URL + per-link credential + first access token; bearer-authenticated via oauth-access token",
|
|
52279
|
+
handler: async (req, reply) => {
|
|
52280
|
+
if (ingressRejected(req, reply, deps, "alexa broker-link")) return;
|
|
52281
|
+
await handleBrokerLinkCallback(req, reply, deps.lwaTokensCallbackDeps);
|
|
52282
|
+
}
|
|
52283
|
+
},
|
|
51939
52284
|
{
|
|
51940
52285
|
method: "POST",
|
|
51941
52286
|
path: "/oauth/lwa-tokens",
|
|
@@ -52046,6 +52391,8 @@ async function handleDirective(request, reply, deps) {
|
|
|
52046
52391
|
reply.code(200).send(preParseErrorEnvelope("INVALID_AUTHORIZATION_CREDENTIAL", "token does not grant access to device capabilities"));
|
|
52047
52392
|
return;
|
|
52048
52393
|
}
|
|
52394
|
+
const advertised = readHeader(request.headers, BROKER_HEADER);
|
|
52395
|
+
if (advertised && deps.onBrokerAdvertised) deps.onBrokerAdvertised(advertised);
|
|
52049
52396
|
const callerIdentity = {
|
|
52050
52397
|
userId: verified.userId,
|
|
52051
52398
|
username: verified.username,
|
|
@@ -52054,6 +52401,19 @@ async function handleDirective(request, reply, deps) {
|
|
|
52054
52401
|
const result = await dispatchDirective(request.body, deps.directiveDeps, callerIdentity);
|
|
52055
52402
|
reply.code(result.status).send(result.body);
|
|
52056
52403
|
}
|
|
52404
|
+
function readHeader(headers, name) {
|
|
52405
|
+
for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === name) return value;
|
|
52406
|
+
}
|
|
52407
|
+
//#endregion
|
|
52408
|
+
//#region src/types.ts
|
|
52409
|
+
var DEFAULT_DEVICE_SETTINGS = {
|
|
52410
|
+
streamPreference: "auto",
|
|
52411
|
+
sourceProfile: "high"
|
|
52412
|
+
};
|
|
52413
|
+
/** A grant the hub can renew through the broker. */
|
|
52414
|
+
function isBrokerGrant(t) {
|
|
52415
|
+
return typeof t.brokerUrl === "string" && typeof t.brokerCredential === "string";
|
|
52416
|
+
}
|
|
52057
52417
|
/** `listOauthSessions` returns every session for every integration. */
|
|
52058
52418
|
var OauthSessionListSchema = array(object({
|
|
52059
52419
|
id: string(),
|
|
@@ -52102,15 +52462,15 @@ function buildLinkStatusContent(input) {
|
|
|
52102
52462
|
lines.push(`• ${acc.username} — linked ${stamp(acc.linkedAt)}`);
|
|
52103
52463
|
lines.push(` ${describeGrant(acc, input)}`);
|
|
52104
52464
|
}
|
|
52105
|
-
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.");
|
|
52106
52465
|
return lines.join("\n");
|
|
52107
52466
|
}
|
|
52108
52467
|
function describeGrant(acc, input) {
|
|
52109
52468
|
const token = input.tokens.find((t) => t.userId === acc.userId);
|
|
52110
52469
|
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.";
|
|
52111
52470
|
const renewedAgo = minutes(input.now - token.receivedAt);
|
|
52112
|
-
if (token.expiresAt <= input.now
|
|
52113
|
-
return `Amazon push grant:
|
|
52471
|
+
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.`;
|
|
52472
|
+
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".`;
|
|
52473
|
+
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).`;
|
|
52114
52474
|
}
|
|
52115
52475
|
//#endregion
|
|
52116
52476
|
//#region src/motion-clear-hold.ts
|
|
@@ -52124,10 +52484,12 @@ var DEFAULT_MOTION_CLEAR_HOLD_MS = 5e3;
|
|
|
52124
52484
|
var MotionClearHold = class {
|
|
52125
52485
|
entries = /* @__PURE__ */ new Map();
|
|
52126
52486
|
holdMs;
|
|
52487
|
+
minDetectedMs;
|
|
52127
52488
|
setTimer;
|
|
52128
52489
|
clearTimer;
|
|
52129
52490
|
constructor(options) {
|
|
52130
52491
|
this.holdMs = options.holdMs;
|
|
52492
|
+
this.minDetectedMs = options.minDetectedMs ?? 3e4;
|
|
52131
52493
|
this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
52132
52494
|
this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
52133
52495
|
}
|
|
@@ -52145,13 +52507,15 @@ var MotionClearHold = class {
|
|
|
52145
52507
|
if (entry.timer !== null) this.clearTimer(entry.timer);
|
|
52146
52508
|
this.entries.set(key, {
|
|
52147
52509
|
state: "detected",
|
|
52148
|
-
timer: null
|
|
52510
|
+
timer: null,
|
|
52511
|
+
detectedAt: entry.detectedAt
|
|
52149
52512
|
});
|
|
52150
52513
|
return false;
|
|
52151
52514
|
}
|
|
52152
52515
|
this.entries.set(key, {
|
|
52153
52516
|
state: "detected",
|
|
52154
|
-
timer: null
|
|
52517
|
+
timer: null,
|
|
52518
|
+
detectedAt: Date.now()
|
|
52155
52519
|
});
|
|
52156
52520
|
return true;
|
|
52157
52521
|
}
|
|
@@ -52172,16 +52536,21 @@ var MotionClearHold = class {
|
|
|
52172
52536
|
onCleared(key, release) {
|
|
52173
52537
|
const entry = this.entries.get(key);
|
|
52174
52538
|
if (entry !== void 0 && entry.state !== "detected") return;
|
|
52539
|
+
const detectedAt = entry?.detectedAt ?? null;
|
|
52540
|
+
const sinceDetected = detectedAt === null ? 0 : Date.now() - detectedAt;
|
|
52541
|
+
const delay = Math.max(this.holdMs, this.minDetectedMs - sinceDetected);
|
|
52175
52542
|
const timer = this.setTimer(() => {
|
|
52176
52543
|
this.entries.set(key, {
|
|
52177
52544
|
state: "idle",
|
|
52178
|
-
timer: null
|
|
52545
|
+
timer: null,
|
|
52546
|
+
detectedAt: null
|
|
52179
52547
|
});
|
|
52180
52548
|
release();
|
|
52181
|
-
},
|
|
52549
|
+
}, delay);
|
|
52182
52550
|
this.entries.set(key, {
|
|
52183
52551
|
state: "holding",
|
|
52184
|
-
timer
|
|
52552
|
+
timer,
|
|
52553
|
+
detectedAt
|
|
52185
52554
|
});
|
|
52186
52555
|
}
|
|
52187
52556
|
/** What Alexa currently believes for this key — for logs and assertions. */
|
|
@@ -52211,15 +52580,16 @@ var MotionClearHold = class {
|
|
|
52211
52580
|
* exposed.
|
|
52212
52581
|
* 3. For each linked Alexa account, POST a `DoorbellEventSource/
|
|
52213
52582
|
* DoorbellPress` payload to `api.amazonalexa.com/v3/events`.
|
|
52214
|
-
* 4. On 401,
|
|
52215
|
-
*
|
|
52583
|
+
* 4. On 401, ask the skill's token broker for a fresh access token and
|
|
52584
|
+
* retry once.
|
|
52216
52585
|
*
|
|
52217
|
-
*
|
|
52218
|
-
*
|
|
52219
|
-
* this
|
|
52220
|
-
* `
|
|
52221
|
-
*
|
|
52222
|
-
*
|
|
52586
|
+
* This hub holds NO Login With Amazon client secret (D753). The skill's
|
|
52587
|
+
* Lambda exchanges the AcceptGrant code, keeps the refresh token and the
|
|
52588
|
+
* secret, and hands this hub short-lived access tokens against a per-link
|
|
52589
|
+
* credential (`broker-client.ts`). A grant delivered by a pre-D753 Lambda
|
|
52590
|
+
* (with a refresh token) cannot be renewed here; it is migrated to the broker
|
|
52591
|
+
* as soon as the broker's URL is known, and the refresh token is deleted.
|
|
52592
|
+
* When no grant is stored we log + skip without erroring.
|
|
52223
52593
|
*
|
|
52224
52594
|
* Throttling: Alexa requires at most one DoorbellPress per endpoint
|
|
52225
52595
|
* every 30s. We enforce this per (userId, endpointId) tuple in-memory.
|
|
@@ -52229,10 +52599,14 @@ var EVENT_GATEWAY_URLS = {
|
|
|
52229
52599
|
EU: "https://api.eu.amazonalexa.com/v3/events",
|
|
52230
52600
|
FE: "https://api.fe.amazonalexa.com/v3/events"
|
|
52231
52601
|
};
|
|
52232
|
-
var LWA_TOKEN_URL = "https://api.amazon.com/auth/o2/token";
|
|
52233
52602
|
var THROTTLE_WINDOW_MS = 3e4;
|
|
52234
52603
|
var REFRESH_PROACTIVE_WINDOW_MS = 300 * 1e3;
|
|
52235
52604
|
var REFRESH_SCAN_INTERVAL_MS = 60 * 1e3;
|
|
52605
|
+
/** After a failed legacy-grant migration, wait this long before trying again. */
|
|
52606
|
+
var MIGRATION_RETRY_MS = 300 * 1e3;
|
|
52607
|
+
/** The interface a camera endpoint must declare for a connectivity
|
|
52608
|
+
* ChangeReport to mean anything to the Alexa cloud. */
|
|
52609
|
+
var ENDPOINT_HEALTH_INTERFACE = "Alexa.EndpointHealth";
|
|
52236
52610
|
/** The interface an endpoint must advertise for a `DoorbellPress` to survive
|
|
52237
52611
|
* the Alexa cloud. */
|
|
52238
52612
|
var DOORBELL_INTERFACE = "Alexa.DoorbellEventSource";
|
|
@@ -52264,6 +52638,10 @@ var LwaEventGateway = class {
|
|
|
52264
52638
|
unsubscribe = null;
|
|
52265
52639
|
unsubscribeMotion = null;
|
|
52266
52640
|
unsubscribeBattery = null;
|
|
52641
|
+
unsubscribeConnectivity = [];
|
|
52642
|
+
/** Last connectivity reported per `userId|endpointId` — a camera that
|
|
52643
|
+
* re-announces the same state must not become a ChangeReport stream. */
|
|
52644
|
+
lastConnectivity = /* @__PURE__ */ new Map();
|
|
52267
52645
|
refreshTimer = null;
|
|
52268
52646
|
lastSentAt = /* @__PURE__ */ new Map();
|
|
52269
52647
|
/**
|
|
@@ -52282,9 +52660,13 @@ var LwaEventGateway = class {
|
|
|
52282
52660
|
* Alexa Smart Home rate-limits ChangeReports per property; dedupe
|
|
52283
52661
|
* so only level *changes* (or step-grain ones) hit the gateway. */
|
|
52284
52662
|
lastBatteryLevel = /* @__PURE__ */ new Map();
|
|
52285
|
-
/** Latches the "no
|
|
52286
|
-
*
|
|
52287
|
-
|
|
52663
|
+
/** Latches the "no grant at all" warning so it's logged ONCE, not on every
|
|
52664
|
+
* doorbell/motion event while no account has a push grant. */
|
|
52665
|
+
warnedNoGrant = false;
|
|
52666
|
+
/** Users whose legacy grant is waiting for the broker URL (warned once). */
|
|
52667
|
+
warnedLegacyWaiting = /* @__PURE__ */ new Set();
|
|
52668
|
+
/** Per-user earliest next migration attempt after a failure. */
|
|
52669
|
+
migrationRetryAt = /* @__PURE__ */ new Map();
|
|
52288
52670
|
/** Users we have already reported as having no stored Amazon push grant.
|
|
52289
52671
|
* Cleared for a user the moment a grant is found again, so a re-link
|
|
52290
52672
|
* re-arms the warning instead of muting it for the process's lifetime. */
|
|
@@ -52308,6 +52690,11 @@ var LwaEventGateway = class {
|
|
|
52308
52690
|
this.deps.logger.warn("alexa lwa-gateway: battery handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
52309
52691
|
});
|
|
52310
52692
|
});
|
|
52693
|
+
this.unsubscribeConnectivity = [[EventCategory.DeviceOnline, "OK"], [EventCategory.DeviceOffline, "UNREACHABLE"]].map(([category, connectivity]) => this.deps.ctx.eventBus.subscribe({ category }, (event) => {
|
|
52694
|
+
this.onConnectivityChanged(event.data, connectivity).catch((err) => {
|
|
52695
|
+
this.deps.logger.warn("alexa lwa-gateway: connectivity handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
52696
|
+
});
|
|
52697
|
+
}));
|
|
52311
52698
|
this.refreshTimer = setInterval(() => {
|
|
52312
52699
|
this.refreshExpiringTokens().catch((err) => {
|
|
52313
52700
|
this.deps.logger.warn("alexa lwa-gateway: token refresh scan crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
@@ -52322,6 +52709,9 @@ var LwaEventGateway = class {
|
|
|
52322
52709
|
this.unsubscribeMotion = null;
|
|
52323
52710
|
if (this.unsubscribeBattery) this.unsubscribeBattery();
|
|
52324
52711
|
this.unsubscribeBattery = null;
|
|
52712
|
+
for (const off of this.unsubscribeConnectivity) off();
|
|
52713
|
+
this.unsubscribeConnectivity = [];
|
|
52714
|
+
this.lastConnectivity.clear();
|
|
52325
52715
|
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
|
52326
52716
|
this.refreshTimer = null;
|
|
52327
52717
|
this.lastSentAt.clear();
|
|
@@ -52329,6 +52719,8 @@ var LwaEventGateway = class {
|
|
|
52329
52719
|
this.smartVisionDropped.clear();
|
|
52330
52720
|
this.lastBatteryLevel.clear();
|
|
52331
52721
|
this.warnedNoToken.clear();
|
|
52722
|
+
this.warnedLegacyWaiting.clear();
|
|
52723
|
+
this.migrationRetryAt.clear();
|
|
52332
52724
|
}
|
|
52333
52725
|
async onDoorbellPress(data) {
|
|
52334
52726
|
const childDevice = await this.deps.ctx.api.deviceManager.getDevice.query({ deviceId: data.deviceId }).catch(() => null);
|
|
@@ -52524,6 +52916,33 @@ var LwaEventGateway = class {
|
|
|
52524
52916
|
}
|
|
52525
52917
|
}
|
|
52526
52918
|
/**
|
|
52919
|
+
* A camera went offline or came back. Amazon requires the `connectivity`
|
|
52920
|
+
* ChangeReport within 3 s of the change (alexa-endpointhealth). Deduped per
|
|
52921
|
+
* account and endpoint, and skipped for an endpoint Alexa is known to hold
|
|
52922
|
+
* WITHOUT `Alexa.EndpointHealth` — the cloud would drop it (D76).
|
|
52923
|
+
*/
|
|
52924
|
+
async onConnectivityChanged(data, connectivity) {
|
|
52925
|
+
if (typeof data?.deviceId !== "number") return;
|
|
52926
|
+
if (!this.deps.listExposed().some((e) => e.deviceId === data.deviceId)) return;
|
|
52927
|
+
const signature = this.deps.getExportedSignature(data.deviceId);
|
|
52928
|
+
if (signature !== null && !signature.includes(ENDPOINT_HEALTH_INTERFACE)) return;
|
|
52929
|
+
const linked = this.deps.listLinkedAccounts();
|
|
52930
|
+
if (linked.length === 0) return;
|
|
52931
|
+
if (!this.hasProactiveCredentials()) return;
|
|
52932
|
+
const endpointId = String(data.deviceId);
|
|
52933
|
+
for (const account of linked) {
|
|
52934
|
+
const key = `${account.userId}|${endpointId}`;
|
|
52935
|
+
if (this.lastConnectivity.get(key) === connectivity) continue;
|
|
52936
|
+
this.lastConnectivity.set(key, connectivity);
|
|
52937
|
+
await this.postEventBody(account.userId, endpointId, (accessToken) => buildConnectivityChangeReportPayload({
|
|
52938
|
+
endpointId,
|
|
52939
|
+
connectivity,
|
|
52940
|
+
timestamp: Date.now(),
|
|
52941
|
+
accessToken
|
|
52942
|
+
}), `connectivity ${connectivity}`);
|
|
52943
|
+
}
|
|
52944
|
+
}
|
|
52945
|
+
/**
|
|
52527
52946
|
* Send a one-shot `DeleteReport` for arbitrary endpoint ids per linked
|
|
52528
52947
|
* account. Used to clean up stale entries Alexa still shows from prior
|
|
52529
52948
|
* syncs (different addon version, different account, or an aborted
|
|
@@ -52640,12 +53059,15 @@ var LwaEventGateway = class {
|
|
|
52640
53059
|
} });
|
|
52641
53060
|
return true;
|
|
52642
53061
|
}
|
|
53062
|
+
get fetchImpl() {
|
|
53063
|
+
return this.deps.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
53064
|
+
}
|
|
52643
53065
|
async refreshExpiringTokens() {
|
|
52644
53066
|
if (!this.hasProactiveCredentials()) return;
|
|
52645
53067
|
const tokens = this.deps.listTokens();
|
|
52646
53068
|
const now = Date.now();
|
|
52647
53069
|
for (const token of tokens) {
|
|
52648
|
-
if (token.expiresAt - now
|
|
53070
|
+
if (!(!isBrokerGrant(token) || token.expiresAt - now <= REFRESH_PROACTIVE_WINDOW_MS)) continue;
|
|
52649
53071
|
await this.refreshAccessToken(token.userId).catch((err) => {
|
|
52650
53072
|
this.deps.logger.warn("alexa lwa-gateway: proactive refresh failed", { meta: {
|
|
52651
53073
|
userId: token.userId,
|
|
@@ -52654,61 +53076,106 @@ var LwaEventGateway = class {
|
|
|
52654
53076
|
});
|
|
52655
53077
|
}
|
|
52656
53078
|
}
|
|
53079
|
+
/** A fresh access token for `userId`, or null. Never throws for broker errors. */
|
|
52657
53080
|
async refreshAccessToken(userId) {
|
|
52658
|
-
const creds = this.deps.getLwaCredentials();
|
|
52659
|
-
if (!creds) {
|
|
52660
|
-
this.deps.logger.warn("alexa lwa-gateway: LWA client credentials not configured", { meta: { userId } });
|
|
52661
|
-
return null;
|
|
52662
|
-
}
|
|
52663
53081
|
const existing = this.findToken(userId);
|
|
52664
53082
|
if (!existing) return null;
|
|
52665
|
-
|
|
52666
|
-
|
|
52667
|
-
|
|
52668
|
-
|
|
52669
|
-
params.set("client_secret", creds.clientSecret);
|
|
52670
|
-
const response = await fetch(LWA_TOKEN_URL, {
|
|
52671
|
-
method: "POST",
|
|
52672
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
52673
|
-
body: params.toString()
|
|
52674
|
-
});
|
|
52675
|
-
if (!response.ok) {
|
|
52676
|
-
const text = await response.text().catch(() => "");
|
|
52677
|
-
this.deps.logger.warn("alexa lwa-gateway: LWA refresh non-OK", { meta: {
|
|
53083
|
+
if (!isBrokerGrant(existing)) return this.migrateLegacyGrant(existing);
|
|
53084
|
+
try {
|
|
53085
|
+
const fresh = await fetchBrokerAccessToken(this.fetchImpl, existing.brokerUrl, existing.brokerCredential);
|
|
53086
|
+
await this.deps.upsertToken({
|
|
52678
53087
|
userId,
|
|
52679
|
-
|
|
52680
|
-
|
|
53088
|
+
accessToken: fresh.accessToken,
|
|
53089
|
+
expiresAt: fresh.expiresAt,
|
|
53090
|
+
brokerUrl: existing.brokerUrl,
|
|
53091
|
+
brokerCredential: existing.brokerCredential
|
|
53092
|
+
});
|
|
53093
|
+
return fresh.accessToken;
|
|
53094
|
+
} catch (err) {
|
|
53095
|
+
if (err instanceof BrokerError && (err.kind === "unauthorized" || err.kind === "revoked")) {
|
|
53096
|
+
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: {
|
|
53097
|
+
userId,
|
|
53098
|
+
kind: err.kind
|
|
53099
|
+
} });
|
|
53100
|
+
await this.deps.removeLinkedAccount(userId).catch(() => {});
|
|
53101
|
+
return null;
|
|
53102
|
+
}
|
|
53103
|
+
this.deps.logger.warn("alexa lwa-gateway: broker renewal failed — will retry", { meta: {
|
|
53104
|
+
userId,
|
|
53105
|
+
error: err instanceof Error ? err.message : String(err)
|
|
52681
53106
|
} });
|
|
52682
53107
|
return null;
|
|
52683
53108
|
}
|
|
52684
|
-
|
|
52685
|
-
|
|
52686
|
-
|
|
53109
|
+
}
|
|
53110
|
+
/**
|
|
53111
|
+
* Hand a pre-D753 refresh token to the skill's broker and keep the link it
|
|
53112
|
+
* returns instead. Zero-touch: the broker URL arrives on the next verified
|
|
53113
|
+
* directive. On success the refresh token is gone from this hub.
|
|
53114
|
+
*/
|
|
53115
|
+
async migrateLegacyGrant(entry) {
|
|
53116
|
+
const userId = entry.userId;
|
|
53117
|
+
if (!entry.refreshToken) return null;
|
|
53118
|
+
const brokerUrl = this.deps.getAdvertisedBrokerUrl();
|
|
53119
|
+
if (!brokerUrl) {
|
|
53120
|
+
if (!this.warnedLegacyWaiting.has(userId)) {
|
|
53121
|
+
this.warnedLegacyWaiting.add(userId);
|
|
53122
|
+
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: {
|
|
53123
|
+
userId,
|
|
53124
|
+
expiresInMs: entry.expiresAt - Date.now()
|
|
53125
|
+
} });
|
|
53126
|
+
}
|
|
53127
|
+
return null;
|
|
53128
|
+
}
|
|
53129
|
+
const retryAt = this.migrationRetryAt.get(userId) ?? 0;
|
|
53130
|
+
if (Date.now() < retryAt) return null;
|
|
53131
|
+
try {
|
|
53132
|
+
const migrated = await migrateLegacyGrant(this.fetchImpl, brokerUrl, {
|
|
53133
|
+
refreshToken: entry.refreshToken,
|
|
53134
|
+
...this.deps.getPublicHubUrl ? { hubUrl: this.deps.getPublicHubUrl() } : {}
|
|
53135
|
+
});
|
|
53136
|
+
await this.deps.upsertToken({
|
|
53137
|
+
userId,
|
|
53138
|
+
accessToken: migrated.accessToken,
|
|
53139
|
+
expiresAt: migrated.expiresAt,
|
|
53140
|
+
brokerUrl: normaliseBrokerUrl(migrated.brokerUrl) ?? brokerUrl,
|
|
53141
|
+
brokerCredential: migrated.brokerCredential
|
|
53142
|
+
});
|
|
53143
|
+
this.migrationRetryAt.delete(userId);
|
|
53144
|
+
this.warnedLegacyWaiting.delete(userId);
|
|
53145
|
+
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 } });
|
|
53146
|
+
return migrated.accessToken;
|
|
53147
|
+
} catch (err) {
|
|
53148
|
+
this.migrationRetryAt.set(userId, Date.now() + MIGRATION_RETRY_MS);
|
|
53149
|
+
const kind = err instanceof BrokerError ? err.kind : "unavailable";
|
|
53150
|
+
const status = err instanceof BrokerError ? err.status : void 0;
|
|
53151
|
+
if (kind === "rejected" && status === 400) {
|
|
53152
|
+
this.deps.logger.warn("alexa lwa-gateway: Amazon no longer honours this push grant — re-link the skill in the Alexa app", { meta: { userId } });
|
|
53153
|
+
await this.deps.removeLinkedAccount(userId).catch(() => {});
|
|
53154
|
+
return null;
|
|
53155
|
+
}
|
|
53156
|
+
this.deps.logger.warn("alexa lwa-gateway: migrating the push grant to the broker failed", { meta: {
|
|
53157
|
+
userId,
|
|
53158
|
+
error: err instanceof Error ? err.message : String(err)
|
|
53159
|
+
} });
|
|
52687
53160
|
return null;
|
|
52688
53161
|
}
|
|
52689
|
-
const refreshTokenNext = typeof parsed.refresh_token === "string" ? parsed.refresh_token : existing.refreshToken;
|
|
52690
|
-
await this.deps.upsertToken({
|
|
52691
|
-
userId,
|
|
52692
|
-
accessToken: parsed.access_token,
|
|
52693
|
-
refreshToken: refreshTokenNext,
|
|
52694
|
-
expiresAt: Date.now() + parsed.expires_in * 1e3
|
|
52695
|
-
});
|
|
52696
|
-
return parsed.access_token;
|
|
52697
53162
|
}
|
|
52698
53163
|
findToken(userId) {
|
|
52699
53164
|
return this.deps.listTokens().find((t) => t.userId === userId) ?? null;
|
|
52700
53165
|
}
|
|
52701
53166
|
/**
|
|
52702
|
-
* Proactive push
|
|
52703
|
-
*
|
|
52704
|
-
*
|
|
52705
|
-
* cannot refresh) — skip it and warn ONCE instead of once per event.
|
|
53167
|
+
* Proactive push needs a stored grant for at least one account. With none,
|
|
53168
|
+
* pushing is futile — skip it and warn ONCE instead of once per event.
|
|
53169
|
+
* (Per-account absence is reported in `postEventBody`.)
|
|
52706
53170
|
*/
|
|
52707
53171
|
hasProactiveCredentials() {
|
|
52708
|
-
if (this.deps.
|
|
52709
|
-
|
|
52710
|
-
|
|
52711
|
-
|
|
53172
|
+
if (this.deps.listTokens().length > 0) {
|
|
53173
|
+
this.warnedNoGrant = false;
|
|
53174
|
+
return true;
|
|
53175
|
+
}
|
|
53176
|
+
if (!this.warnedNoGrant) {
|
|
53177
|
+
this.warnedNoGrant = true;
|
|
53178
|
+
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");
|
|
52712
53179
|
}
|
|
52713
53180
|
return false;
|
|
52714
53181
|
}
|
|
@@ -52796,6 +53263,36 @@ function buildBatteryChangeReportPayload(opts) {
|
|
|
52796
53263
|
context: { properties: [] }
|
|
52797
53264
|
};
|
|
52798
53265
|
}
|
|
53266
|
+
function buildConnectivityChangeReportPayload(opts) {
|
|
53267
|
+
return {
|
|
53268
|
+
event: {
|
|
53269
|
+
header: {
|
|
53270
|
+
namespace: "Alexa",
|
|
53271
|
+
name: "ChangeReport",
|
|
53272
|
+
messageId: randomUUID(),
|
|
53273
|
+
payloadVersion: "3"
|
|
53274
|
+
},
|
|
53275
|
+
endpoint: {
|
|
53276
|
+
scope: {
|
|
53277
|
+
type: "BearerToken",
|
|
53278
|
+
token: opts.accessToken
|
|
53279
|
+
},
|
|
53280
|
+
endpointId: opts.endpointId
|
|
53281
|
+
},
|
|
53282
|
+
payload: { change: {
|
|
53283
|
+
cause: { type: "PERIODIC_POLL" },
|
|
53284
|
+
properties: [{
|
|
53285
|
+
namespace: "Alexa.EndpointHealth",
|
|
53286
|
+
name: "connectivity",
|
|
53287
|
+
value: { value: opts.connectivity },
|
|
53288
|
+
timeOfSample: new Date(opts.timestamp).toISOString(),
|
|
53289
|
+
uncertaintyInMilliseconds: 0
|
|
53290
|
+
}]
|
|
53291
|
+
} }
|
|
53292
|
+
},
|
|
53293
|
+
context: { properties: [] }
|
|
53294
|
+
};
|
|
53295
|
+
}
|
|
52799
53296
|
function buildSmartVisionObjectDetectionEvent(opts) {
|
|
52800
53297
|
const timeOfSample = new Date(opts.timestamp).toISOString();
|
|
52801
53298
|
return { event: {
|
|
@@ -52957,23 +53454,21 @@ function buildAlexaOauthIntegration(publicHubUrl) {
|
|
|
52957
53454
|
//#endregion
|
|
52958
53455
|
//#region src/public-skill.ts
|
|
52959
53456
|
/**
|
|
52960
|
-
*
|
|
53457
|
+
* The built-in "public" CamStack Alexa skill.
|
|
52961
53458
|
*
|
|
52962
|
-
*
|
|
52963
|
-
*
|
|
52964
|
-
*
|
|
52965
|
-
*
|
|
52966
|
-
*
|
|
52967
|
-
*
|
|
52968
|
-
*
|
|
52969
|
-
*
|
|
53459
|
+
* Only its skill id lives here — an identifier, not a credential. Until D753
|
|
53460
|
+
* this file also carried the skill's Login With Amazon "Send Alexa Events"
|
|
53461
|
+
* client id and SECRET, on the theory that a per-account refresh token was the
|
|
53462
|
+
* real gate. The package is public on npm, so that secret was published with
|
|
53463
|
+
* every version and has to be rotated. Since D753 the secret lives only in the
|
|
53464
|
+
* skill's Lambda (AWS Secrets Manager); the hub gets short-lived Event Gateway
|
|
53465
|
+
* tokens from the Lambda's broker (`broker-client.ts`) with a per-link
|
|
53466
|
+
* credential. `scripts/check-no-client-secrets.ts` keeps it that way.
|
|
52970
53467
|
*
|
|
52971
|
-
* Operators who run their OWN skill switch to `skillMode: 'custom'` and
|
|
52972
|
-
*
|
|
53468
|
+
* Operators who run their OWN skill switch to `skillMode: 'custom'` and deploy
|
|
53469
|
+
* the same Lambda (`lambda-template/`) with their own secrets.
|
|
52973
53470
|
*/
|
|
52974
53471
|
var PUBLIC_SKILL_ID = "amzn1.ask.skill.aa85d6df-1600-4f13-bda9-babcfdc849e6";
|
|
52975
|
-
var PUBLIC_LWA_CLIENT_ID = "amzn1.application-oa2-client.0033e4a5b8614b11a609f5eae7cbda82";
|
|
52976
|
-
var PUBLIC_LWA_CLIENT_SECRET = "amzn1.oa2-cs.v1.82f852bc5d4a7fb906d670d36f936abd3011ac8c229d066b22a57a9f38488f1e";
|
|
52977
53472
|
//#endregion
|
|
52978
53473
|
//#region src/endpoint-signature.ts
|
|
52979
53474
|
/** One capability's identity as Alexa distinguishes it. */
|
|
@@ -53220,12 +53715,6 @@ var SyncEngine = class {
|
|
|
53220
53715
|
}
|
|
53221
53716
|
};
|
|
53222
53717
|
//#endregion
|
|
53223
|
-
//#region src/types.ts
|
|
53224
|
-
var DEFAULT_DEVICE_SETTINGS = {
|
|
53225
|
-
streamPreference: "auto",
|
|
53226
|
-
sourceProfile: "high"
|
|
53227
|
-
};
|
|
53228
|
-
//#endregion
|
|
53229
53718
|
//#region src/export-alexa.addon.ts
|
|
53230
53719
|
/**
|
|
53231
53720
|
* Alexa Smart Home Skill export addon — hub-side.
|
|
@@ -53294,6 +53783,7 @@ var DEFAULT_CONFIG = {
|
|
|
53294
53783
|
expectedAudience: "",
|
|
53295
53784
|
lwaClientId: "",
|
|
53296
53785
|
lwaClientSecret: "",
|
|
53786
|
+
advertisedBrokerUrl: "",
|
|
53297
53787
|
region: "NA",
|
|
53298
53788
|
exposed: [],
|
|
53299
53789
|
linkedAccounts: [],
|
|
@@ -53323,6 +53813,10 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53323
53813
|
}
|
|
53324
53814
|
async onInitialize() {
|
|
53325
53815
|
if (!this.config.ingressToken) await this.updateGlobalSettings({ ingressToken: generateIngressToken() });
|
|
53816
|
+
if (this.config.lwaClientSecret) {
|
|
53817
|
+
await this.updateGlobalSettings({ lwaClientSecret: "" });
|
|
53818
|
+
this.ctx.logger.warn("export-alexa: removed a stored LWA client secret — the hub no longer uses one; your skill Lambda holds it (D753)");
|
|
53819
|
+
}
|
|
53326
53820
|
await this.backfillLinkedAccounts();
|
|
53327
53821
|
this.autoDetectPublicHubUrl().catch((err) => {
|
|
53328
53822
|
this.ctx.logger.warn("export-alexa: public hub URL auto-detect failed", { meta: { error: errMsg(err) } });
|
|
@@ -53393,7 +53887,8 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53393
53887
|
upsertToken: (input) => this.upsertToken(input),
|
|
53394
53888
|
recordLinkedAccount: (input) => this.recordLinkedAccount(input)
|
|
53395
53889
|
},
|
|
53396
|
-
redetectPublicHubUrl: () => this.redetectPublicHubUrl()
|
|
53890
|
+
redetectPublicHubUrl: () => this.redetectPublicHubUrl(),
|
|
53891
|
+
onBrokerAdvertised: (raw) => this.rememberAdvertisedBroker(raw)
|
|
53397
53892
|
}));
|
|
53398
53893
|
const oauthIntegrationProvider = { getDescriptor: async () => buildAlexaOauthIntegration(this.config.publicHubUrl) };
|
|
53399
53894
|
this.lwaGateway = new LwaEventGateway({
|
|
@@ -53404,7 +53899,8 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53404
53899
|
listTokens: () => this.config.tokens,
|
|
53405
53900
|
upsertToken: (input) => this.upsertToken(input),
|
|
53406
53901
|
removeLinkedAccount: (userId) => this.removeLinkedAccount(userId),
|
|
53407
|
-
|
|
53902
|
+
getAdvertisedBrokerUrl: () => this.config.advertisedBrokerUrl || null,
|
|
53903
|
+
getPublicHubUrl: () => this.config.publicHubUrl,
|
|
53408
53904
|
getRegion: () => this.config.region,
|
|
53409
53905
|
getExportedSignature: (deviceId) => this.config.exportedSignatures[deviceId] ?? null
|
|
53410
53906
|
});
|
|
@@ -53413,7 +53909,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53413
53909
|
logger: this.ctx.logger,
|
|
53414
53910
|
listExposed: () => this.config.exposed,
|
|
53415
53911
|
listLinkedAccounts: () => this.config.linkedAccounts,
|
|
53416
|
-
hasProactiveCredentials: () => this.
|
|
53912
|
+
hasProactiveCredentials: () => this.config.tokens.length > 0,
|
|
53417
53913
|
buildEndpoints: (deviceId) => this.buildEndpointsForOneDevice(deviceId),
|
|
53418
53914
|
postAddOrUpdate: (userId, endpoints) => this.requireGateway().postReport(userId, (accessToken) => buildAddOrUpdateReportPayload(userId, endpoints, accessToken), `add/update (${endpoints.length})`),
|
|
53419
53915
|
postDelete: (userId, ids) => this.requireGateway().postReport(userId, (accessToken) => buildDeleteReportPayload(userId, ids, accessToken), `delete (${ids.length})`),
|
|
@@ -53448,7 +53944,8 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53448
53944
|
exposedCount: this.config.exposed.length,
|
|
53449
53945
|
skillMode: this.config.skillMode,
|
|
53450
53946
|
skillIdSet: this.effectiveSettings().expectedAudience.length > 0,
|
|
53451
|
-
|
|
53947
|
+
pushGrants: this.config.tokens.length,
|
|
53948
|
+
brokerGrants: this.config.tokens.filter(isBrokerGrant).length
|
|
53452
53949
|
} });
|
|
53453
53950
|
return {
|
|
53454
53951
|
providers: [
|
|
@@ -53471,7 +53968,8 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53471
53968
|
regenerateIngressToken: async () => this.regenerateIngressToken(),
|
|
53472
53969
|
resyncDiscovery: async () => this.resyncDiscovery(),
|
|
53473
53970
|
purgeStaleEndpoints: async () => this.purgeStaleEndpoints(),
|
|
53474
|
-
unlinkAlexaAccounts: async () => this.unlinkAlexaAccounts()
|
|
53971
|
+
unlinkAlexaAccounts: async () => this.unlinkAlexaAccounts(),
|
|
53972
|
+
rotateBrokerCredential: async () => this.rotateBrokerCredentials()
|
|
53475
53973
|
}
|
|
53476
53974
|
};
|
|
53477
53975
|
}
|
|
@@ -53693,6 +54191,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53693
54191
|
}
|
|
53694
54192
|
this.ctx.logger.warn("export-alexa: revokeOauthSession reported failure — the session may already be gone", { meta: { sessionId: id } });
|
|
53695
54193
|
}
|
|
54194
|
+
await this.revokeAtBroker(this.config.tokens);
|
|
53696
54195
|
const removedAccounts = this.config.linkedAccounts.length;
|
|
53697
54196
|
await this.updateGlobalSettings({
|
|
53698
54197
|
linkedAccounts: [],
|
|
@@ -53714,9 +54213,11 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53714
54213
|
const entry = {
|
|
53715
54214
|
userId: input.userId,
|
|
53716
54215
|
accessToken: input.accessToken,
|
|
53717
|
-
refreshToken: input.refreshToken,
|
|
53718
54216
|
expiresAt: input.expiresAt,
|
|
53719
|
-
receivedAt: Date.now()
|
|
54217
|
+
receivedAt: Date.now(),
|
|
54218
|
+
...input.refreshToken ? { refreshToken: input.refreshToken } : {},
|
|
54219
|
+
...input.brokerUrl ? { brokerUrl: input.brokerUrl } : {},
|
|
54220
|
+
...input.brokerCredential ? { brokerCredential: input.brokerCredential } : {}
|
|
53720
54221
|
};
|
|
53721
54222
|
const next = this.config.tokens.filter((t) => t.userId !== input.userId);
|
|
53722
54223
|
next.push(entry);
|
|
@@ -53762,26 +54263,79 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53762
54263
|
}
|
|
53763
54264
|
/**
|
|
53764
54265
|
* Settings as the runtime should read them. In `skillMode: 'public'` the
|
|
53765
|
-
* skill id
|
|
53766
|
-
*
|
|
53767
|
-
*
|
|
53768
|
-
*
|
|
54266
|
+
* skill id is swapped for the built-in CamStack skill's; the operator's own
|
|
54267
|
+
* `expectedAudience` stays untouched in the persisted config so switching
|
|
54268
|
+
* back to `custom` restores it. Everything else passes through. There is no
|
|
54269
|
+
* credential to overlay any more (D753).
|
|
53769
54270
|
*/
|
|
53770
54271
|
effectiveSettings() {
|
|
53771
54272
|
if (this.config.skillMode !== "public") return this.config;
|
|
53772
54273
|
return {
|
|
53773
54274
|
...this.config,
|
|
53774
|
-
expectedAudience: PUBLIC_SKILL_ID
|
|
53775
|
-
lwaClientId: PUBLIC_LWA_CLIENT_ID,
|
|
53776
|
-
lwaClientSecret: PUBLIC_LWA_CLIENT_SECRET
|
|
54275
|
+
expectedAudience: PUBLIC_SKILL_ID
|
|
53777
54276
|
};
|
|
53778
54277
|
}
|
|
53779
|
-
|
|
53780
|
-
|
|
53781
|
-
|
|
54278
|
+
/** Persist the broker origin the Lambda advertised on a verified directive. */
|
|
54279
|
+
rememberAdvertisedBroker(raw) {
|
|
54280
|
+
const url = normaliseBrokerUrl(raw);
|
|
54281
|
+
if (!url) {
|
|
54282
|
+
this.ctx.logger.warn("export-alexa: ignored a broker advertisement that is not an https origin");
|
|
54283
|
+
return;
|
|
54284
|
+
}
|
|
54285
|
+
if (url === this.config.advertisedBrokerUrl) return;
|
|
54286
|
+
this.updateGlobalSettings({ advertisedBrokerUrl: url }).then(() => {
|
|
54287
|
+
this.ctx.logger.info("export-alexa: the skill Lambda advertised its token broker", { meta: { brokerUrl: url } });
|
|
54288
|
+
}).catch((err) => {
|
|
54289
|
+
this.ctx.logger.warn("export-alexa: could not store the advertised broker URL", { meta: { error: errMsg(err) } });
|
|
54290
|
+
});
|
|
54291
|
+
}
|
|
54292
|
+
async revokeAtBroker(tokens) {
|
|
54293
|
+
for (const t of tokens) {
|
|
54294
|
+
if (!isBrokerGrant(t)) continue;
|
|
54295
|
+
await revokeBrokerCredential(fetch, t.brokerUrl, t.brokerCredential).catch((err) => {
|
|
54296
|
+
this.ctx.logger.warn("export-alexa: could not delete the link at the skill broker — it expires there on its own", { meta: {
|
|
54297
|
+
userId: t.userId,
|
|
54298
|
+
error: errMsg(err)
|
|
54299
|
+
} });
|
|
54300
|
+
});
|
|
54301
|
+
}
|
|
54302
|
+
}
|
|
54303
|
+
/**
|
|
54304
|
+
* Swap every broker credential this hub holds for a fresh one. The old
|
|
54305
|
+
* credential stops working at the broker immediately. A credential the
|
|
54306
|
+
* broker no longer recognises is reported, not retried — re-link instead.
|
|
54307
|
+
*/
|
|
54308
|
+
async rotateBrokerCredentials() {
|
|
54309
|
+
let rotated = 0;
|
|
54310
|
+
let failed = 0;
|
|
54311
|
+
for (const t of this.config.tokens) {
|
|
54312
|
+
if (!isBrokerGrant(t)) continue;
|
|
54313
|
+
try {
|
|
54314
|
+
const next = await rotateBrokerCredential(fetch, t.brokerUrl, t.brokerCredential);
|
|
54315
|
+
await this.upsertToken({
|
|
54316
|
+
userId: t.userId,
|
|
54317
|
+
accessToken: t.accessToken,
|
|
54318
|
+
expiresAt: t.expiresAt,
|
|
54319
|
+
brokerUrl: t.brokerUrl,
|
|
54320
|
+
brokerCredential: next
|
|
54321
|
+
});
|
|
54322
|
+
rotated += 1;
|
|
54323
|
+
} catch (err) {
|
|
54324
|
+
failed += 1;
|
|
54325
|
+
this.ctx.logger.warn("export-alexa: broker credential rotation failed", { meta: {
|
|
54326
|
+
userId: t.userId,
|
|
54327
|
+
kind: err instanceof BrokerError ? err.kind : "unavailable",
|
|
54328
|
+
error: errMsg(err)
|
|
54329
|
+
} });
|
|
54330
|
+
}
|
|
54331
|
+
}
|
|
54332
|
+
this.ctx.logger.info("export-alexa: broker credentials rotated", { meta: {
|
|
54333
|
+
rotated,
|
|
54334
|
+
failed
|
|
54335
|
+
} });
|
|
53782
54336
|
return {
|
|
53783
|
-
|
|
53784
|
-
|
|
54337
|
+
rotated,
|
|
54338
|
+
failed
|
|
53785
54339
|
};
|
|
53786
54340
|
}
|
|
53787
54341
|
/**
|
|
@@ -53936,8 +54490,9 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53936
54490
|
label: "Setup overview",
|
|
53937
54491
|
variant: "info",
|
|
53938
54492
|
content: [
|
|
53939
|
-
"Public skill (default): enable the \"CamStack\" skill in the Alexa app and link your account —
|
|
53940
|
-
"
|
|
54493
|
+
"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).",
|
|
54494
|
+
"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.",
|
|
54495
|
+
"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).",
|
|
53941
54496
|
"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."
|
|
53942
54497
|
].join("\n")
|
|
53943
54498
|
},
|
|
@@ -53962,7 +54517,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53962
54517
|
type: "select",
|
|
53963
54518
|
key: "skillMode",
|
|
53964
54519
|
label: "Alexa skill",
|
|
53965
|
-
description: "Public: use the built-in CamStack skill — zero Amazon developer setup. Custom: bring your own skill and
|
|
54520
|
+
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.",
|
|
53966
54521
|
options: [{
|
|
53967
54522
|
value: "public",
|
|
53968
54523
|
label: "Public (built-in CamStack skill)"
|
|
@@ -53993,35 +54548,24 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
53993
54548
|
],
|
|
53994
54549
|
default: DEFAULT_CONFIG.region
|
|
53995
54550
|
}),
|
|
53996
|
-
...this.config.skillMode === "custom" ? [
|
|
53997
|
-
|
|
53998
|
-
|
|
53999
|
-
|
|
54000
|
-
|
|
54001
|
-
|
|
54002
|
-
|
|
54003
|
-
|
|
54004
|
-
|
|
54005
|
-
|
|
54006
|
-
|
|
54007
|
-
|
|
54008
|
-
|
|
54009
|
-
default: DEFAULT_CONFIG.lwaClientId
|
|
54010
|
-
}),
|
|
54011
|
-
this.field({
|
|
54012
|
-
type: "password",
|
|
54013
|
-
key: "lwaClientSecret",
|
|
54014
|
-
label: "LWA client secret (proactive events)",
|
|
54015
|
-
description: "Paired with LWA client id. Treat as sensitive.",
|
|
54016
|
-
showToggle: true,
|
|
54017
|
-
default: DEFAULT_CONFIG.lwaClientSecret
|
|
54018
|
-
})
|
|
54019
|
-
] : [{
|
|
54551
|
+
...this.config.skillMode === "custom" ? [this.field({
|
|
54552
|
+
type: "text",
|
|
54553
|
+
key: "expectedAudience",
|
|
54554
|
+
label: "Alexa skill id",
|
|
54555
|
+
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.",
|
|
54556
|
+
default: DEFAULT_CONFIG.expectedAudience
|
|
54557
|
+
}), {
|
|
54558
|
+
type: "info",
|
|
54559
|
+
key: "__custom-skill-info",
|
|
54560
|
+
label: "Your skill Lambda",
|
|
54561
|
+
variant: "info",
|
|
54562
|
+
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."
|
|
54563
|
+
}] : [{
|
|
54020
54564
|
type: "info",
|
|
54021
54565
|
key: "__public-skill-info",
|
|
54022
54566
|
label: "Built-in skill",
|
|
54023
54567
|
variant: "info",
|
|
54024
|
-
content: "Using the built-in CamStack public skill —
|
|
54568
|
+
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."
|
|
54025
54569
|
}],
|
|
54026
54570
|
{
|
|
54027
54571
|
type: "info",
|
|
@@ -54031,7 +54575,6 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54031
54575
|
content: buildLinkStatusContent({
|
|
54032
54576
|
linkedAccounts: this.config.linkedAccounts,
|
|
54033
54577
|
tokens: this.config.tokens,
|
|
54034
|
-
hasLwaCredentials: this.lwaCredentials() !== null,
|
|
54035
54578
|
now: Date.now()
|
|
54036
54579
|
})
|
|
54037
54580
|
},
|
|
@@ -54045,6 +54588,15 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
54045
54588
|
variant: "danger",
|
|
54046
54589
|
confirmMessage: "Unlink the Amazon account? Alexa loses access to your cameras immediately and you must re-link from the Alexa app to restore it."
|
|
54047
54590
|
},
|
|
54591
|
+
{
|
|
54592
|
+
type: "button",
|
|
54593
|
+
key: "__rotate-broker-credential",
|
|
54594
|
+
label: "Skill broker credential",
|
|
54595
|
+
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.",
|
|
54596
|
+
buttonLabel: "Rotate credential",
|
|
54597
|
+
action: "rotateBrokerCredential",
|
|
54598
|
+
variant: "default"
|
|
54599
|
+
},
|
|
54048
54600
|
{
|
|
54049
54601
|
type: "button",
|
|
54050
54602
|
key: "__resync-discovery",
|