@camstack/addon-export-alexa 1.2.133 → 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 +190 -10
- package/dist/export-alexa.addon.mjs +191 -10
- package/lambda-template/README.md +8 -0
- package/lambda-template/index.js +94 -30
- package/lambda-template/util.js +54 -1
- package/package.json +1 -1
|
@@ -49844,6 +49844,9 @@ function parseNonNegativeInt(s) {
|
|
|
49844
49844
|
/**
|
|
49845
49845
|
* Shared Alexa `Alexa/ErrorResponse` envelope builder.
|
|
49846
49846
|
*
|
|
49847
|
+
* Certification checks the shape: `correlationToken` (set here) and
|
|
49848
|
+
* `endpoint` (set by `withErrorEndpoint` in the router) are both required.
|
|
49849
|
+
*
|
|
49847
49850
|
* Lives in its own module so every handler (and the directive router)
|
|
49848
49851
|
* can emit its own namespace-appropriate error without circular imports.
|
|
49849
49852
|
*/
|
|
@@ -49852,8 +49855,10 @@ object({ event: object({
|
|
|
49852
49855
|
namespace: literal("Alexa"),
|
|
49853
49856
|
name: literal("ErrorResponse"),
|
|
49854
49857
|
messageId: string(),
|
|
49858
|
+
correlationToken: string().optional(),
|
|
49855
49859
|
payloadVersion: literal("3")
|
|
49856
49860
|
}),
|
|
49861
|
+
endpoint: object({ endpointId: string() }).optional(),
|
|
49857
49862
|
payload: object({
|
|
49858
49863
|
type: string(),
|
|
49859
49864
|
message: string()
|
|
@@ -49865,6 +49870,7 @@ function errorEnvelope(header, type, message) {
|
|
|
49865
49870
|
namespace: "Alexa",
|
|
49866
49871
|
name: "ErrorResponse",
|
|
49867
49872
|
messageId: header.messageId,
|
|
49873
|
+
...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
|
|
49868
49874
|
payloadVersion: "3"
|
|
49869
49875
|
},
|
|
49870
49876
|
payload: {
|
|
@@ -50225,18 +50231,43 @@ async function handleSessionDisconnected(header, endpointRaw, payloadRaw, deps,
|
|
|
50225
50231
|
},
|
|
50226
50232
|
meta: { endpointId: endpoint.endpointId }
|
|
50227
50233
|
});
|
|
50234
|
+
return sessionEvent("SessionDisconnected", header, endpoint.endpointId, parsed.data.payload.sessionId);
|
|
50235
|
+
}
|
|
50236
|
+
/**
|
|
50237
|
+
* The response to `SessionConnected` / `SessionDisconnected` is the event of
|
|
50238
|
+
* the same name with the session id (alexa-rtcsessioncontroller) — not a
|
|
50239
|
+
* generic `Alexa.Response`.
|
|
50240
|
+
*/
|
|
50241
|
+
function sessionEvent(name, header, endpointId, sessionId) {
|
|
50228
50242
|
return { event: {
|
|
50229
50243
|
header: {
|
|
50230
|
-
namespace: "Alexa",
|
|
50231
|
-
name
|
|
50244
|
+
namespace: "Alexa.RTCSessionController",
|
|
50245
|
+
name,
|
|
50232
50246
|
messageId: header.messageId,
|
|
50233
50247
|
correlationToken: header.correlationToken,
|
|
50234
50248
|
payloadVersion: "3"
|
|
50235
50249
|
},
|
|
50236
|
-
endpoint: { endpointId
|
|
50237
|
-
payload: {}
|
|
50250
|
+
endpoint: { endpointId },
|
|
50251
|
+
payload: { sessionId }
|
|
50238
50252
|
} };
|
|
50239
50253
|
}
|
|
50254
|
+
/**
|
|
50255
|
+
* `SessionConnected` — Alexa reports the media path is up. Nothing to do
|
|
50256
|
+
* hub-side (the session already exists from the offer); acknowledge it. Amazon
|
|
50257
|
+
* notes it is not always sent, so nothing may depend on it.
|
|
50258
|
+
*/
|
|
50259
|
+
function handleSessionConnected(header, endpointRaw, payloadRaw, deps) {
|
|
50260
|
+
const parsed = SessionDisconnectedDirectiveSchema.safeParse({
|
|
50261
|
+
endpoint: endpointRaw,
|
|
50262
|
+
payload: payloadRaw ?? {}
|
|
50263
|
+
});
|
|
50264
|
+
if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
|
|
50265
|
+
deps.logger.info("alexa rtc-session: SessionConnected", {
|
|
50266
|
+
tags: { sessionId: parsed.data.payload.sessionId },
|
|
50267
|
+
meta: { endpointId: parsed.data.endpoint.endpointId }
|
|
50268
|
+
});
|
|
50269
|
+
return sessionEvent("SessionConnected", header, parsed.data.endpoint.endpointId, parsed.data.payload.sessionId);
|
|
50270
|
+
}
|
|
50240
50271
|
//#endregion
|
|
50241
50272
|
//#region src/broker-client.ts
|
|
50242
50273
|
/**
|
|
@@ -50442,6 +50473,24 @@ var CAMERA_STREAM_CONTROLLER = {
|
|
|
50442
50473
|
audioCodecs: ["AAC"]
|
|
50443
50474
|
}]
|
|
50444
50475
|
};
|
|
50476
|
+
/**
|
|
50477
|
+
* `Alexa.EndpointHealth` — REQUIRED on a sensor endpoint (alexa-endpointhealth;
|
|
50478
|
+
* alexa-motionsensor: "Must also implement `Alexa.EndpointHealth`"), and every
|
|
50479
|
+
* camera endpoint may carry `Alexa.MotionSensor`. Declared on every camera so
|
|
50480
|
+
* Alexa can tell an offline camera from a broken one: `connectivity` comes
|
|
50481
|
+
* from the `device-status` cap in ReportState and is pushed as a ChangeReport
|
|
50482
|
+
* on `DeviceOnline` / `DeviceOffline` by the Event Gateway.
|
|
50483
|
+
*/
|
|
50484
|
+
var ENDPOINT_HEALTH = {
|
|
50485
|
+
type: "AlexaInterface",
|
|
50486
|
+
interface: "Alexa.EndpointHealth",
|
|
50487
|
+
version: "3",
|
|
50488
|
+
properties: {
|
|
50489
|
+
supported: [{ name: "connectivity" }],
|
|
50490
|
+
proactivelyReported: true,
|
|
50491
|
+
retrievable: true
|
|
50492
|
+
}
|
|
50493
|
+
};
|
|
50445
50494
|
var DOORBELL_EVENT_SOURCE = {
|
|
50446
50495
|
type: "AlexaInterface",
|
|
50447
50496
|
interface: "Alexa.DoorbellEventSource",
|
|
@@ -50643,7 +50692,8 @@ function buildParentCameraEndpoint(input) {
|
|
|
50643
50692
|
const capabilities = [
|
|
50644
50693
|
ALEXA_BASE_CAPABILITY,
|
|
50645
50694
|
CAMERA_STREAM_CONTROLLER,
|
|
50646
|
-
rtcSessionController(input.hasIntercom)
|
|
50695
|
+
rtcSessionController(input.hasIntercom),
|
|
50696
|
+
ENDPOINT_HEALTH
|
|
50647
50697
|
];
|
|
50648
50698
|
if (input.isDoorbell) capabilities.push(DOORBELL_EVENT_SOURCE);
|
|
50649
50699
|
if (input.hasMotion) capabilities.push(MOTION_SENSOR);
|
|
@@ -51467,6 +51517,20 @@ async function collectCameraProperties(api, deviceId, properties, timeOfSample,
|
|
|
51467
51517
|
timeOfSample,
|
|
51468
51518
|
uncertaintyInMilliseconds: 0
|
|
51469
51519
|
});
|
|
51520
|
+
const online = boolField(await api.deviceStatus.getStatus.query({ deviceId }).catch((err) => {
|
|
51521
|
+
logger.warn("alexa report-state: deviceStatus.getStatus failed — connectivity omitted", {
|
|
51522
|
+
tags: { deviceId },
|
|
51523
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
51524
|
+
});
|
|
51525
|
+
return null;
|
|
51526
|
+
}), "online");
|
|
51527
|
+
if (online !== null) properties.push({
|
|
51528
|
+
namespace: "Alexa.EndpointHealth",
|
|
51529
|
+
name: "connectivity",
|
|
51530
|
+
value: { value: online ? "OK" : "UNREACHABLE" },
|
|
51531
|
+
timeOfSample,
|
|
51532
|
+
uncertaintyInMilliseconds: 0
|
|
51533
|
+
});
|
|
51470
51534
|
const detected = boolField(await api.motion.getStatus.query({ deviceId }).catch((err) => {
|
|
51471
51535
|
logger.info("alexa report-state: motion.getStatus skipped", { meta: {
|
|
51472
51536
|
deviceId,
|
|
@@ -51849,7 +51913,37 @@ function assertDeviceScope(scopes) {
|
|
|
51849
51913
|
function requireDeviceScope(scopes, access) {
|
|
51850
51914
|
if (!scopesAllowDeviceCap(scopes, access)) throw new Error(`directive token not authorized for device ${access}`);
|
|
51851
51915
|
}
|
|
51916
|
+
/**
|
|
51917
|
+
* Give an `Alexa.ErrorResponse` the directive's endpoint when it lacks one.
|
|
51918
|
+
* Amazon requires `endpoint` on every ErrorResponse to an endpoint directive
|
|
51919
|
+
* (alexa-errorresponse); the handlers build their errors from the header
|
|
51920
|
+
* alone, so the router — which holds the endpoint for every path — adds it.
|
|
51921
|
+
*/
|
|
51922
|
+
function withErrorEndpoint(body, endpoint) {
|
|
51923
|
+
const endpointId = typeof endpoint === "object" && endpoint !== null && typeof endpoint.endpointId === "string" ? endpoint.endpointId : null;
|
|
51924
|
+
if (endpointId === null || typeof body !== "object" || body === null) return body;
|
|
51925
|
+
const event = body.event;
|
|
51926
|
+
if (typeof event !== "object" || event === null) return body;
|
|
51927
|
+
const ev = event;
|
|
51928
|
+
if (ev.header?.namespace !== "Alexa" || ev.header?.name !== "ErrorResponse") return body;
|
|
51929
|
+
if (ev.endpoint !== void 0) return body;
|
|
51930
|
+
return {
|
|
51931
|
+
...body,
|
|
51932
|
+
event: {
|
|
51933
|
+
...ev,
|
|
51934
|
+
endpoint: { endpointId }
|
|
51935
|
+
}
|
|
51936
|
+
};
|
|
51937
|
+
}
|
|
51852
51938
|
async function dispatchDirective(rawBody, deps, callerIdentity) {
|
|
51939
|
+
const result = await dispatchParsedDirective(rawBody, deps, callerIdentity);
|
|
51940
|
+
const endpoint = rawBody?.directive?.endpoint;
|
|
51941
|
+
return {
|
|
51942
|
+
status: result.status,
|
|
51943
|
+
body: withErrorEndpoint(result.body, endpoint)
|
|
51944
|
+
};
|
|
51945
|
+
}
|
|
51946
|
+
async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
|
|
51853
51947
|
const parsed = DirectiveRequestSchema.safeParse(rawBody);
|
|
51854
51948
|
if (!parsed.success) return {
|
|
51855
51949
|
status: 400,
|
|
@@ -51871,6 +51965,7 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
|
|
|
51871
51965
|
"Alexa.Discovery/Discover": "view",
|
|
51872
51966
|
"Alexa.CameraStreamController/InitializeCameraStreams": "view",
|
|
51873
51967
|
"Alexa.RTCSessionController/InitiateSessionWithOffer": "view",
|
|
51968
|
+
"Alexa.RTCSessionController/SessionConnected": "view",
|
|
51874
51969
|
"Alexa.RTCSessionController/SessionDisconnected": "view",
|
|
51875
51970
|
"Alexa.PowerController/TurnOn": "create",
|
|
51876
51971
|
"Alexa.PowerController/TurnOff": "create",
|
|
@@ -51905,6 +52000,10 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
|
|
|
51905
52000
|
status: 200,
|
|
51906
52001
|
body: await handleInitiateSessionWithOffer(header, endpoint, payload, deps.rtcSession, callerIdentity)
|
|
51907
52002
|
};
|
|
52003
|
+
if (key === "Alexa.RTCSessionController/SessionConnected") return {
|
|
52004
|
+
status: 200,
|
|
52005
|
+
body: handleSessionConnected(header, endpoint, payload, deps.rtcSession)
|
|
52006
|
+
};
|
|
51908
52007
|
if (key === "Alexa.RTCSessionController/SessionDisconnected") return {
|
|
51909
52008
|
status: 200,
|
|
51910
52009
|
body: await handleSessionDisconnected(header, endpoint, payload, deps.rtcSession, callerIdentity)
|
|
@@ -52411,10 +52510,12 @@ var DEFAULT_MOTION_CLEAR_HOLD_MS = 5e3;
|
|
|
52411
52510
|
var MotionClearHold = class {
|
|
52412
52511
|
entries = /* @__PURE__ */ new Map();
|
|
52413
52512
|
holdMs;
|
|
52513
|
+
minDetectedMs;
|
|
52414
52514
|
setTimer;
|
|
52415
52515
|
clearTimer;
|
|
52416
52516
|
constructor(options) {
|
|
52417
52517
|
this.holdMs = options.holdMs;
|
|
52518
|
+
this.minDetectedMs = options.minDetectedMs ?? 3e4;
|
|
52418
52519
|
this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
52419
52520
|
this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
52420
52521
|
}
|
|
@@ -52432,13 +52533,15 @@ var MotionClearHold = class {
|
|
|
52432
52533
|
if (entry.timer !== null) this.clearTimer(entry.timer);
|
|
52433
52534
|
this.entries.set(key, {
|
|
52434
52535
|
state: "detected",
|
|
52435
|
-
timer: null
|
|
52536
|
+
timer: null,
|
|
52537
|
+
detectedAt: entry.detectedAt
|
|
52436
52538
|
});
|
|
52437
52539
|
return false;
|
|
52438
52540
|
}
|
|
52439
52541
|
this.entries.set(key, {
|
|
52440
52542
|
state: "detected",
|
|
52441
|
-
timer: null
|
|
52543
|
+
timer: null,
|
|
52544
|
+
detectedAt: Date.now()
|
|
52442
52545
|
});
|
|
52443
52546
|
return true;
|
|
52444
52547
|
}
|
|
@@ -52459,16 +52562,21 @@ var MotionClearHold = class {
|
|
|
52459
52562
|
onCleared(key, release) {
|
|
52460
52563
|
const entry = this.entries.get(key);
|
|
52461
52564
|
if (entry !== void 0 && entry.state !== "detected") return;
|
|
52565
|
+
const detectedAt = entry?.detectedAt ?? null;
|
|
52566
|
+
const sinceDetected = detectedAt === null ? 0 : Date.now() - detectedAt;
|
|
52567
|
+
const delay = Math.max(this.holdMs, this.minDetectedMs - sinceDetected);
|
|
52462
52568
|
const timer = this.setTimer(() => {
|
|
52463
52569
|
this.entries.set(key, {
|
|
52464
52570
|
state: "idle",
|
|
52465
|
-
timer: null
|
|
52571
|
+
timer: null,
|
|
52572
|
+
detectedAt: null
|
|
52466
52573
|
});
|
|
52467
52574
|
release();
|
|
52468
|
-
},
|
|
52575
|
+
}, delay);
|
|
52469
52576
|
this.entries.set(key, {
|
|
52470
52577
|
state: "holding",
|
|
52471
|
-
timer
|
|
52578
|
+
timer,
|
|
52579
|
+
detectedAt
|
|
52472
52580
|
});
|
|
52473
52581
|
}
|
|
52474
52582
|
/** What Alexa currently believes for this key — for logs and assertions. */
|
|
@@ -52522,6 +52630,9 @@ var REFRESH_PROACTIVE_WINDOW_MS = 300 * 1e3;
|
|
|
52522
52630
|
var REFRESH_SCAN_INTERVAL_MS = 60 * 1e3;
|
|
52523
52631
|
/** After a failed legacy-grant migration, wait this long before trying again. */
|
|
52524
52632
|
var MIGRATION_RETRY_MS = 300 * 1e3;
|
|
52633
|
+
/** The interface a camera endpoint must declare for a connectivity
|
|
52634
|
+
* ChangeReport to mean anything to the Alexa cloud. */
|
|
52635
|
+
var ENDPOINT_HEALTH_INTERFACE = "Alexa.EndpointHealth";
|
|
52525
52636
|
/** The interface an endpoint must advertise for a `DoorbellPress` to survive
|
|
52526
52637
|
* the Alexa cloud. */
|
|
52527
52638
|
var DOORBELL_INTERFACE = "Alexa.DoorbellEventSource";
|
|
@@ -52553,6 +52664,10 @@ var LwaEventGateway = class {
|
|
|
52553
52664
|
unsubscribe = null;
|
|
52554
52665
|
unsubscribeMotion = null;
|
|
52555
52666
|
unsubscribeBattery = null;
|
|
52667
|
+
unsubscribeConnectivity = [];
|
|
52668
|
+
/** Last connectivity reported per `userId|endpointId` — a camera that
|
|
52669
|
+
* re-announces the same state must not become a ChangeReport stream. */
|
|
52670
|
+
lastConnectivity = /* @__PURE__ */ new Map();
|
|
52556
52671
|
refreshTimer = null;
|
|
52557
52672
|
lastSentAt = /* @__PURE__ */ new Map();
|
|
52558
52673
|
/**
|
|
@@ -52601,6 +52716,11 @@ var LwaEventGateway = class {
|
|
|
52601
52716
|
this.deps.logger.warn("alexa lwa-gateway: battery handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
52602
52717
|
});
|
|
52603
52718
|
});
|
|
52719
|
+
this.unsubscribeConnectivity = [[EventCategory.DeviceOnline, "OK"], [EventCategory.DeviceOffline, "UNREACHABLE"]].map(([category, connectivity]) => this.deps.ctx.eventBus.subscribe({ category }, (event) => {
|
|
52720
|
+
this.onConnectivityChanged(event.data, connectivity).catch((err) => {
|
|
52721
|
+
this.deps.logger.warn("alexa lwa-gateway: connectivity handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
52722
|
+
});
|
|
52723
|
+
}));
|
|
52604
52724
|
this.refreshTimer = setInterval(() => {
|
|
52605
52725
|
this.refreshExpiringTokens().catch((err) => {
|
|
52606
52726
|
this.deps.logger.warn("alexa lwa-gateway: token refresh scan crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
@@ -52615,6 +52735,9 @@ var LwaEventGateway = class {
|
|
|
52615
52735
|
this.unsubscribeMotion = null;
|
|
52616
52736
|
if (this.unsubscribeBattery) this.unsubscribeBattery();
|
|
52617
52737
|
this.unsubscribeBattery = null;
|
|
52738
|
+
for (const off of this.unsubscribeConnectivity) off();
|
|
52739
|
+
this.unsubscribeConnectivity = [];
|
|
52740
|
+
this.lastConnectivity.clear();
|
|
52618
52741
|
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
|
52619
52742
|
this.refreshTimer = null;
|
|
52620
52743
|
this.lastSentAt.clear();
|
|
@@ -52819,6 +52942,33 @@ var LwaEventGateway = class {
|
|
|
52819
52942
|
}
|
|
52820
52943
|
}
|
|
52821
52944
|
/**
|
|
52945
|
+
* A camera went offline or came back. Amazon requires the `connectivity`
|
|
52946
|
+
* ChangeReport within 3 s of the change (alexa-endpointhealth). Deduped per
|
|
52947
|
+
* account and endpoint, and skipped for an endpoint Alexa is known to hold
|
|
52948
|
+
* WITHOUT `Alexa.EndpointHealth` — the cloud would drop it (D76).
|
|
52949
|
+
*/
|
|
52950
|
+
async onConnectivityChanged(data, connectivity) {
|
|
52951
|
+
if (typeof data?.deviceId !== "number") return;
|
|
52952
|
+
if (!this.deps.listExposed().some((e) => e.deviceId === data.deviceId)) return;
|
|
52953
|
+
const signature = this.deps.getExportedSignature(data.deviceId);
|
|
52954
|
+
if (signature !== null && !signature.includes(ENDPOINT_HEALTH_INTERFACE)) return;
|
|
52955
|
+
const linked = this.deps.listLinkedAccounts();
|
|
52956
|
+
if (linked.length === 0) return;
|
|
52957
|
+
if (!this.hasProactiveCredentials()) return;
|
|
52958
|
+
const endpointId = String(data.deviceId);
|
|
52959
|
+
for (const account of linked) {
|
|
52960
|
+
const key = `${account.userId}|${endpointId}`;
|
|
52961
|
+
if (this.lastConnectivity.get(key) === connectivity) continue;
|
|
52962
|
+
this.lastConnectivity.set(key, connectivity);
|
|
52963
|
+
await this.postEventBody(account.userId, endpointId, (accessToken) => buildConnectivityChangeReportPayload({
|
|
52964
|
+
endpointId,
|
|
52965
|
+
connectivity,
|
|
52966
|
+
timestamp: Date.now(),
|
|
52967
|
+
accessToken
|
|
52968
|
+
}), `connectivity ${connectivity}`);
|
|
52969
|
+
}
|
|
52970
|
+
}
|
|
52971
|
+
/**
|
|
52822
52972
|
* Send a one-shot `DeleteReport` for arbitrary endpoint ids per linked
|
|
52823
52973
|
* account. Used to clean up stale entries Alexa still shows from prior
|
|
52824
52974
|
* syncs (different addon version, different account, or an aborted
|
|
@@ -53139,6 +53289,36 @@ function buildBatteryChangeReportPayload(opts) {
|
|
|
53139
53289
|
context: { properties: [] }
|
|
53140
53290
|
};
|
|
53141
53291
|
}
|
|
53292
|
+
function buildConnectivityChangeReportPayload(opts) {
|
|
53293
|
+
return {
|
|
53294
|
+
event: {
|
|
53295
|
+
header: {
|
|
53296
|
+
namespace: "Alexa",
|
|
53297
|
+
name: "ChangeReport",
|
|
53298
|
+
messageId: (0, node_crypto.randomUUID)(),
|
|
53299
|
+
payloadVersion: "3"
|
|
53300
|
+
},
|
|
53301
|
+
endpoint: {
|
|
53302
|
+
scope: {
|
|
53303
|
+
type: "BearerToken",
|
|
53304
|
+
token: opts.accessToken
|
|
53305
|
+
},
|
|
53306
|
+
endpointId: opts.endpointId
|
|
53307
|
+
},
|
|
53308
|
+
payload: { change: {
|
|
53309
|
+
cause: { type: "PERIODIC_POLL" },
|
|
53310
|
+
properties: [{
|
|
53311
|
+
namespace: "Alexa.EndpointHealth",
|
|
53312
|
+
name: "connectivity",
|
|
53313
|
+
value: { value: opts.connectivity },
|
|
53314
|
+
timeOfSample: new Date(opts.timestamp).toISOString(),
|
|
53315
|
+
uncertaintyInMilliseconds: 0
|
|
53316
|
+
}]
|
|
53317
|
+
} }
|
|
53318
|
+
},
|
|
53319
|
+
context: { properties: [] }
|
|
53320
|
+
};
|
|
53321
|
+
}
|
|
53142
53322
|
function buildSmartVisionObjectDetectionEvent(opts) {
|
|
53143
53323
|
const timeOfSample = new Date(opts.timestamp).toISOString();
|
|
53144
53324
|
return { event: {
|
|
@@ -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";
|
|
@@ -49817,6 +49818,9 @@ function parseNonNegativeInt(s) {
|
|
|
49817
49818
|
/**
|
|
49818
49819
|
* Shared Alexa `Alexa/ErrorResponse` envelope builder.
|
|
49819
49820
|
*
|
|
49821
|
+
* Certification checks the shape: `correlationToken` (set here) and
|
|
49822
|
+
* `endpoint` (set by `withErrorEndpoint` in the router) are both required.
|
|
49823
|
+
*
|
|
49820
49824
|
* Lives in its own module so every handler (and the directive router)
|
|
49821
49825
|
* can emit its own namespace-appropriate error without circular imports.
|
|
49822
49826
|
*/
|
|
@@ -49825,8 +49829,10 @@ object({ event: object({
|
|
|
49825
49829
|
namespace: literal("Alexa"),
|
|
49826
49830
|
name: literal("ErrorResponse"),
|
|
49827
49831
|
messageId: string(),
|
|
49832
|
+
correlationToken: string().optional(),
|
|
49828
49833
|
payloadVersion: literal("3")
|
|
49829
49834
|
}),
|
|
49835
|
+
endpoint: object({ endpointId: string() }).optional(),
|
|
49830
49836
|
payload: object({
|
|
49831
49837
|
type: string(),
|
|
49832
49838
|
message: string()
|
|
@@ -49838,6 +49844,7 @@ function errorEnvelope(header, type, message) {
|
|
|
49838
49844
|
namespace: "Alexa",
|
|
49839
49845
|
name: "ErrorResponse",
|
|
49840
49846
|
messageId: header.messageId,
|
|
49847
|
+
...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
|
|
49841
49848
|
payloadVersion: "3"
|
|
49842
49849
|
},
|
|
49843
49850
|
payload: {
|
|
@@ -50198,18 +50205,43 @@ async function handleSessionDisconnected(header, endpointRaw, payloadRaw, deps,
|
|
|
50198
50205
|
},
|
|
50199
50206
|
meta: { endpointId: endpoint.endpointId }
|
|
50200
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) {
|
|
50201
50216
|
return { event: {
|
|
50202
50217
|
header: {
|
|
50203
|
-
namespace: "Alexa",
|
|
50204
|
-
name
|
|
50218
|
+
namespace: "Alexa.RTCSessionController",
|
|
50219
|
+
name,
|
|
50205
50220
|
messageId: header.messageId,
|
|
50206
50221
|
correlationToken: header.correlationToken,
|
|
50207
50222
|
payloadVersion: "3"
|
|
50208
50223
|
},
|
|
50209
|
-
endpoint: { endpointId
|
|
50210
|
-
payload: {}
|
|
50224
|
+
endpoint: { endpointId },
|
|
50225
|
+
payload: { sessionId }
|
|
50211
50226
|
} };
|
|
50212
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
|
+
}
|
|
50213
50245
|
//#endregion
|
|
50214
50246
|
//#region src/broker-client.ts
|
|
50215
50247
|
/**
|
|
@@ -50415,6 +50447,24 @@ var CAMERA_STREAM_CONTROLLER = {
|
|
|
50415
50447
|
audioCodecs: ["AAC"]
|
|
50416
50448
|
}]
|
|
50417
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
|
+
};
|
|
50418
50468
|
var DOORBELL_EVENT_SOURCE = {
|
|
50419
50469
|
type: "AlexaInterface",
|
|
50420
50470
|
interface: "Alexa.DoorbellEventSource",
|
|
@@ -50616,7 +50666,8 @@ function buildParentCameraEndpoint(input) {
|
|
|
50616
50666
|
const capabilities = [
|
|
50617
50667
|
ALEXA_BASE_CAPABILITY,
|
|
50618
50668
|
CAMERA_STREAM_CONTROLLER,
|
|
50619
|
-
rtcSessionController(input.hasIntercom)
|
|
50669
|
+
rtcSessionController(input.hasIntercom),
|
|
50670
|
+
ENDPOINT_HEALTH
|
|
50620
50671
|
];
|
|
50621
50672
|
if (input.isDoorbell) capabilities.push(DOORBELL_EVENT_SOURCE);
|
|
50622
50673
|
if (input.hasMotion) capabilities.push(MOTION_SENSOR);
|
|
@@ -51440,6 +51491,20 @@ async function collectCameraProperties(api, deviceId, properties, timeOfSample,
|
|
|
51440
51491
|
timeOfSample,
|
|
51441
51492
|
uncertaintyInMilliseconds: 0
|
|
51442
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
|
+
});
|
|
51443
51508
|
const detected = boolField(await api.motion.getStatus.query({ deviceId }).catch((err) => {
|
|
51444
51509
|
logger.info("alexa report-state: motion.getStatus skipped", { meta: {
|
|
51445
51510
|
deviceId,
|
|
@@ -51822,7 +51887,37 @@ function assertDeviceScope(scopes) {
|
|
|
51822
51887
|
function requireDeviceScope(scopes, access) {
|
|
51823
51888
|
if (!scopesAllowDeviceCap(scopes, access)) throw new Error(`directive token not authorized for device ${access}`);
|
|
51824
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
|
+
}
|
|
51825
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) {
|
|
51826
51921
|
const parsed = DirectiveRequestSchema.safeParse(rawBody);
|
|
51827
51922
|
if (!parsed.success) return {
|
|
51828
51923
|
status: 400,
|
|
@@ -51844,6 +51939,7 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
|
|
|
51844
51939
|
"Alexa.Discovery/Discover": "view",
|
|
51845
51940
|
"Alexa.CameraStreamController/InitializeCameraStreams": "view",
|
|
51846
51941
|
"Alexa.RTCSessionController/InitiateSessionWithOffer": "view",
|
|
51942
|
+
"Alexa.RTCSessionController/SessionConnected": "view",
|
|
51847
51943
|
"Alexa.RTCSessionController/SessionDisconnected": "view",
|
|
51848
51944
|
"Alexa.PowerController/TurnOn": "create",
|
|
51849
51945
|
"Alexa.PowerController/TurnOff": "create",
|
|
@@ -51878,6 +51974,10 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
|
|
|
51878
51974
|
status: 200,
|
|
51879
51975
|
body: await handleInitiateSessionWithOffer(header, endpoint, payload, deps.rtcSession, callerIdentity)
|
|
51880
51976
|
};
|
|
51977
|
+
if (key === "Alexa.RTCSessionController/SessionConnected") return {
|
|
51978
|
+
status: 200,
|
|
51979
|
+
body: handleSessionConnected(header, endpoint, payload, deps.rtcSession)
|
|
51980
|
+
};
|
|
51881
51981
|
if (key === "Alexa.RTCSessionController/SessionDisconnected") return {
|
|
51882
51982
|
status: 200,
|
|
51883
51983
|
body: await handleSessionDisconnected(header, endpoint, payload, deps.rtcSession, callerIdentity)
|
|
@@ -52384,10 +52484,12 @@ var DEFAULT_MOTION_CLEAR_HOLD_MS = 5e3;
|
|
|
52384
52484
|
var MotionClearHold = class {
|
|
52385
52485
|
entries = /* @__PURE__ */ new Map();
|
|
52386
52486
|
holdMs;
|
|
52487
|
+
minDetectedMs;
|
|
52387
52488
|
setTimer;
|
|
52388
52489
|
clearTimer;
|
|
52389
52490
|
constructor(options) {
|
|
52390
52491
|
this.holdMs = options.holdMs;
|
|
52492
|
+
this.minDetectedMs = options.minDetectedMs ?? 3e4;
|
|
52391
52493
|
this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
52392
52494
|
this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
52393
52495
|
}
|
|
@@ -52405,13 +52507,15 @@ var MotionClearHold = class {
|
|
|
52405
52507
|
if (entry.timer !== null) this.clearTimer(entry.timer);
|
|
52406
52508
|
this.entries.set(key, {
|
|
52407
52509
|
state: "detected",
|
|
52408
|
-
timer: null
|
|
52510
|
+
timer: null,
|
|
52511
|
+
detectedAt: entry.detectedAt
|
|
52409
52512
|
});
|
|
52410
52513
|
return false;
|
|
52411
52514
|
}
|
|
52412
52515
|
this.entries.set(key, {
|
|
52413
52516
|
state: "detected",
|
|
52414
|
-
timer: null
|
|
52517
|
+
timer: null,
|
|
52518
|
+
detectedAt: Date.now()
|
|
52415
52519
|
});
|
|
52416
52520
|
return true;
|
|
52417
52521
|
}
|
|
@@ -52432,16 +52536,21 @@ var MotionClearHold = class {
|
|
|
52432
52536
|
onCleared(key, release) {
|
|
52433
52537
|
const entry = this.entries.get(key);
|
|
52434
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);
|
|
52435
52542
|
const timer = this.setTimer(() => {
|
|
52436
52543
|
this.entries.set(key, {
|
|
52437
52544
|
state: "idle",
|
|
52438
|
-
timer: null
|
|
52545
|
+
timer: null,
|
|
52546
|
+
detectedAt: null
|
|
52439
52547
|
});
|
|
52440
52548
|
release();
|
|
52441
|
-
},
|
|
52549
|
+
}, delay);
|
|
52442
52550
|
this.entries.set(key, {
|
|
52443
52551
|
state: "holding",
|
|
52444
|
-
timer
|
|
52552
|
+
timer,
|
|
52553
|
+
detectedAt
|
|
52445
52554
|
});
|
|
52446
52555
|
}
|
|
52447
52556
|
/** What Alexa currently believes for this key — for logs and assertions. */
|
|
@@ -52495,6 +52604,9 @@ var REFRESH_PROACTIVE_WINDOW_MS = 300 * 1e3;
|
|
|
52495
52604
|
var REFRESH_SCAN_INTERVAL_MS = 60 * 1e3;
|
|
52496
52605
|
/** After a failed legacy-grant migration, wait this long before trying again. */
|
|
52497
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";
|
|
52498
52610
|
/** The interface an endpoint must advertise for a `DoorbellPress` to survive
|
|
52499
52611
|
* the Alexa cloud. */
|
|
52500
52612
|
var DOORBELL_INTERFACE = "Alexa.DoorbellEventSource";
|
|
@@ -52526,6 +52638,10 @@ var LwaEventGateway = class {
|
|
|
52526
52638
|
unsubscribe = null;
|
|
52527
52639
|
unsubscribeMotion = null;
|
|
52528
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();
|
|
52529
52645
|
refreshTimer = null;
|
|
52530
52646
|
lastSentAt = /* @__PURE__ */ new Map();
|
|
52531
52647
|
/**
|
|
@@ -52574,6 +52690,11 @@ var LwaEventGateway = class {
|
|
|
52574
52690
|
this.deps.logger.warn("alexa lwa-gateway: battery handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
52575
52691
|
});
|
|
52576
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
|
+
}));
|
|
52577
52698
|
this.refreshTimer = setInterval(() => {
|
|
52578
52699
|
this.refreshExpiringTokens().catch((err) => {
|
|
52579
52700
|
this.deps.logger.warn("alexa lwa-gateway: token refresh scan crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
@@ -52588,6 +52709,9 @@ var LwaEventGateway = class {
|
|
|
52588
52709
|
this.unsubscribeMotion = null;
|
|
52589
52710
|
if (this.unsubscribeBattery) this.unsubscribeBattery();
|
|
52590
52711
|
this.unsubscribeBattery = null;
|
|
52712
|
+
for (const off of this.unsubscribeConnectivity) off();
|
|
52713
|
+
this.unsubscribeConnectivity = [];
|
|
52714
|
+
this.lastConnectivity.clear();
|
|
52591
52715
|
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
|
52592
52716
|
this.refreshTimer = null;
|
|
52593
52717
|
this.lastSentAt.clear();
|
|
@@ -52792,6 +52916,33 @@ var LwaEventGateway = class {
|
|
|
52792
52916
|
}
|
|
52793
52917
|
}
|
|
52794
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
|
+
/**
|
|
52795
52946
|
* Send a one-shot `DeleteReport` for arbitrary endpoint ids per linked
|
|
52796
52947
|
* account. Used to clean up stale entries Alexa still shows from prior
|
|
52797
52948
|
* syncs (different addon version, different account, or an aborted
|
|
@@ -53112,6 +53263,36 @@ function buildBatteryChangeReportPayload(opts) {
|
|
|
53112
53263
|
context: { properties: [] }
|
|
53113
53264
|
};
|
|
53114
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
|
+
}
|
|
53115
53296
|
function buildSmartVisionObjectDetectionEvent(opts) {
|
|
53116
53297
|
const timeOfSample = new Date(opts.timestamp).toISOString();
|
|
53117
53298
|
return { event: {
|
|
@@ -22,6 +22,14 @@ The same code runs the built-in CamStack skill and any operator's own
|
|
|
22
22
|
| `POST <FUNCTION_URL>/broker/rotate` | `Bearer <credential>` → `{ brokerCredential }`; the old one stops working. |
|
|
23
23
|
| `POST <FUNCTION_URL>/broker/revoke` | `Bearer <credential>` → deletes the link (and the refresh token). |
|
|
24
24
|
|
|
25
|
+
Errors Alexa receives follow the Smart Home contract: every `Alexa.ErrorResponse`
|
|
26
|
+
carries the directive's `correlationToken` and `endpoint`; a hub that cannot be
|
|
27
|
+
reached (network error, or a tunnel's 502–504 / 52x page) is
|
|
28
|
+
`BRIDGE_UNREACHABLE`; a hub reply that is not an Alexa event becomes
|
|
29
|
+
`INTERNAL_ERROR`; AcceptGrant failures are `Alexa.Authorization` /
|
|
30
|
+
`ACCEPT_GRANT_FAILED`. The `/authorize` page accepts only an `https://` hub URL
|
|
31
|
+
and shows its error on the page.
|
|
32
|
+
|
|
25
33
|
### The two credential pairs
|
|
26
34
|
|
|
27
35
|
| Pair | Secrets Manager id | Authenticates | Who else holds it |
|
package/lambda-template/index.js
CHANGED
|
@@ -55,6 +55,8 @@ const { createSecretSource } = require('./secrets.js')
|
|
|
55
55
|
const { createDynamoTokenStore } = require('./token-store.js')
|
|
56
56
|
const {
|
|
57
57
|
decodeBody,
|
|
58
|
+
acceptGrantErrorEnvelope,
|
|
59
|
+
completeErrorResponse,
|
|
58
60
|
decodeJwtPayload,
|
|
59
61
|
describe,
|
|
60
62
|
encodeFormUrlEncoded,
|
|
@@ -121,7 +123,12 @@ function createHandler(overrides) {
|
|
|
121
123
|
} catch (err) {
|
|
122
124
|
deps.log.error('camstack-alexa: unhandled error', describe(err))
|
|
123
125
|
if (event && event.directive && event.directive.header) {
|
|
124
|
-
return errorEnvelope(
|
|
126
|
+
return errorEnvelope(
|
|
127
|
+
event.directive.header,
|
|
128
|
+
'INTERNAL_ERROR',
|
|
129
|
+
describe(err),
|
|
130
|
+
event.directive.endpoint,
|
|
131
|
+
)
|
|
125
132
|
}
|
|
126
133
|
return jsonResponse(500, { error: 'internal_error', message: describe(err) })
|
|
127
134
|
}
|
|
@@ -164,25 +171,36 @@ function handleAuthorizePage(event) {
|
|
|
164
171
|
button { margin-top: 1rem; width: 100%; padding: 0.7rem; font-size: 0.95rem; font-weight: 500; background: #2563eb; color: white; border: none; border-radius: 0.375rem; cursor: pointer; }
|
|
165
172
|
button:hover { background: #1d4ed8; }
|
|
166
173
|
.hint { font-size: 0.8rem; color: #6b7280; margin-top: 0.5rem; }
|
|
174
|
+
.err { font-size: 0.85rem; color: #b91c1c; margin: 0.75rem 0 0; }
|
|
167
175
|
</style>
|
|
168
176
|
</head>
|
|
169
177
|
<body>
|
|
170
178
|
<div class="card">
|
|
171
179
|
<h1>Link CamStack to Alexa</h1>
|
|
172
180
|
<p>Enter the public HTTPS URL of your CamStack hub. Alexa will then ask the hub to authorize this Echo.</p>
|
|
173
|
-
<form id="f">
|
|
181
|
+
<form id="f" novalidate>
|
|
174
182
|
<label for="hubUrl">Hub URL</label>
|
|
175
183
|
<input id="hubUrl" name="hubUrl" type="url" placeholder="https://camstack.example.com" required autofocus>
|
|
176
184
|
<p class="hint">Must be reachable from the public internet (Cloudflare Tunnel, Tailscale Funnel, etc.).</p>
|
|
185
|
+
<p id="err" class="err" role="alert" hidden></p>
|
|
177
186
|
<button type="submit">Continue</button>
|
|
178
187
|
</form>
|
|
179
188
|
</div>
|
|
180
189
|
<script>
|
|
181
190
|
document.getElementById('f').addEventListener('submit', function (ev) {
|
|
182
191
|
ev.preventDefault()
|
|
183
|
-
var
|
|
184
|
-
|
|
185
|
-
var
|
|
192
|
+
var err = document.getElementById('err')
|
|
193
|
+
var hubUrl = document.getElementById('hubUrl').value.trim().replace(/\\/+$/, '')
|
|
194
|
+
var parsed = null
|
|
195
|
+
try { parsed = new URL(hubUrl) } catch (e) { parsed = null }
|
|
196
|
+
if (!parsed || parsed.protocol !== 'https:' || !parsed.hostname) {
|
|
197
|
+
err.textContent = 'Enter the full address of your hub, starting with https:// (for example https://camstack.example.com).'
|
|
198
|
+
err.hidden = false
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
err.hidden = true
|
|
202
|
+
hubUrl = parsed.origin + parsed.pathname.replace(/\\/+$/, '')
|
|
203
|
+
var qs = ${jsonForScript(qs)}
|
|
186
204
|
var url = hubUrl + '/api/oauth2/authorize' + '?' + (qs ? qs + '&' : '') + 'integration=export-alexa'
|
|
187
205
|
window.location.href = url
|
|
188
206
|
})
|
|
@@ -196,6 +214,11 @@ function handleAuthorizePage(event) {
|
|
|
196
214
|
}
|
|
197
215
|
}
|
|
198
216
|
|
|
217
|
+
/** JSON for an inline <script>: `<` escaped so the value cannot close the tag. */
|
|
218
|
+
function jsonForScript(value) {
|
|
219
|
+
return JSON.stringify(value).replace(/</g, '\\u003c')
|
|
220
|
+
}
|
|
221
|
+
|
|
199
222
|
/** Constant-time string equality that does not leak length via timing. */
|
|
200
223
|
function safeEqual(a, b) {
|
|
201
224
|
const ab = Buffer.from(String(a), 'utf8')
|
|
@@ -310,22 +333,22 @@ async function handleAcceptGrant(event, deps) {
|
|
|
310
333
|
const grantCode = payload.grant && payload.grant.code
|
|
311
334
|
const granteeToken = payload.grantee && payload.grantee.token
|
|
312
335
|
if (!grantCode || !granteeToken) {
|
|
313
|
-
return
|
|
336
|
+
return acceptGrantErrorEnvelope(header, 'payload missing grant.code or grantee.token')
|
|
314
337
|
}
|
|
315
338
|
const claims = decodeJwtPayload(granteeToken)
|
|
316
339
|
if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
|
|
317
|
-
return
|
|
340
|
+
return acceptGrantErrorEnvelope(header, 'grantee token has no hubUrl claim')
|
|
318
341
|
}
|
|
319
342
|
const redirectUri = deps.env.LAMBDA_REDIRECT_URI
|
|
320
343
|
if (!redirectUri) {
|
|
321
|
-
return
|
|
344
|
+
return acceptGrantErrorEnvelope(header, 'LAMBDA_REDIRECT_URI not set on the Lambda')
|
|
322
345
|
}
|
|
323
346
|
|
|
324
347
|
let tokens
|
|
325
348
|
try {
|
|
326
349
|
tokens = await exchangeGrantCode(deps, grantCode, redirectUri)
|
|
327
350
|
} catch (err) {
|
|
328
|
-
return
|
|
351
|
+
return acceptGrantErrorEnvelope(header, describe(err))
|
|
329
352
|
}
|
|
330
353
|
const hubUrl = normaliseHubUrl(claims.hubUrl)
|
|
331
354
|
|
|
@@ -333,9 +356,11 @@ async function handleAcceptGrant(event, deps) {
|
|
|
333
356
|
const outcome = await linkViaBroker(deps, { hubUrl, granteeToken, tokens })
|
|
334
357
|
if (outcome.kind === 'linked') return acceptGrantResponse(header)
|
|
335
358
|
if (outcome.kind !== 'hub-without-broker' || deps.env.LEGACY_HUB_FALLBACK === '0') {
|
|
336
|
-
return
|
|
359
|
+
return acceptGrantErrorEnvelope(header, outcome.message)
|
|
337
360
|
}
|
|
338
|
-
deps.log.warn(
|
|
361
|
+
deps.log.warn(
|
|
362
|
+
'accept_grant: hub has no broker route; falling back to the legacy token hand-off',
|
|
363
|
+
)
|
|
339
364
|
}
|
|
340
365
|
return deliverTokensToLegacyHub(deps, header, { hubUrl, granteeToken, tokens })
|
|
341
366
|
}
|
|
@@ -392,43 +417,82 @@ async function deliverTokensToLegacyHub(deps, header, input) {
|
|
|
392
417
|
})
|
|
393
418
|
if (!callbackResp.ok) {
|
|
394
419
|
const text = await callbackResp.text().catch(() => '')
|
|
395
|
-
return
|
|
420
|
+
return acceptGrantErrorEnvelope(
|
|
396
421
|
header,
|
|
397
|
-
'ACCEPT_GRANT_FAILED',
|
|
398
422
|
`hub callback ${callbackResp.status}: ${text.slice(0, 200)}`,
|
|
399
423
|
)
|
|
400
424
|
}
|
|
401
425
|
return acceptGrantResponse(header)
|
|
402
426
|
}
|
|
403
427
|
|
|
428
|
+
/** HTTP statuses that mean the hub itself is down or unreachable behind its tunnel. */
|
|
429
|
+
function isBridgeDownStatus(status) {
|
|
430
|
+
return status === 502 || status === 503 || status === 504 || (status >= 520 && status <= 530)
|
|
431
|
+
}
|
|
432
|
+
|
|
404
433
|
async function forwardDirective(event, deps) {
|
|
405
|
-
const
|
|
406
|
-
const
|
|
434
|
+
const directive = event.directive
|
|
435
|
+
const header = directive.header
|
|
436
|
+
const endpoint = directive.endpoint
|
|
437
|
+
const bearer = extractBearer(directive)
|
|
407
438
|
if (!bearer) {
|
|
408
|
-
return errorEnvelope(
|
|
439
|
+
return errorEnvelope(
|
|
440
|
+
header,
|
|
441
|
+
'INVALID_AUTHORIZATION_CREDENTIAL',
|
|
442
|
+
'directive has no bearer token',
|
|
443
|
+
endpoint,
|
|
444
|
+
)
|
|
409
445
|
}
|
|
410
446
|
const claims = decodeJwtPayload(bearer)
|
|
411
447
|
if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
|
|
412
|
-
return errorEnvelope(
|
|
448
|
+
return errorEnvelope(
|
|
449
|
+
header,
|
|
450
|
+
'INVALID_AUTHORIZATION_CREDENTIAL',
|
|
451
|
+
'bearer has no hubUrl claim',
|
|
452
|
+
endpoint,
|
|
453
|
+
)
|
|
413
454
|
}
|
|
414
455
|
const target = `${normaliseHubUrl(claims.hubUrl)}/addon/export-alexa/directive`
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
456
|
+
let resp
|
|
457
|
+
try {
|
|
458
|
+
resp = await deps.fetch(target, {
|
|
459
|
+
method: 'POST',
|
|
460
|
+
headers: {
|
|
461
|
+
...hubHeaders(deps, bearer),
|
|
462
|
+
// Where this skill's broker lives. A hub linked before D753 uses it to
|
|
463
|
+
// migrate its refresh token here (zero-touch); the hub only believes it
|
|
464
|
+
// on a directive whose bearer it verified.
|
|
465
|
+
...(brokerAvailable(deps) ? { [BROKER_HEADER]: deps.publicUrl } : {}),
|
|
466
|
+
},
|
|
467
|
+
body: JSON.stringify(event),
|
|
468
|
+
})
|
|
469
|
+
} catch (err) {
|
|
470
|
+
// The hub is the bridge every endpoint sits behind (alexa-errorresponse).
|
|
471
|
+
return errorEnvelope(
|
|
472
|
+
header,
|
|
473
|
+
'BRIDGE_UNREACHABLE',
|
|
474
|
+
`hub unreachable: ${describe(err)}`,
|
|
475
|
+
endpoint,
|
|
476
|
+
)
|
|
477
|
+
}
|
|
426
478
|
const text = await resp.text()
|
|
479
|
+
let parsed
|
|
427
480
|
try {
|
|
428
|
-
|
|
481
|
+
parsed = JSON.parse(text)
|
|
429
482
|
} catch {
|
|
430
|
-
|
|
483
|
+
const type = isBridgeDownStatus(resp.status) ? 'BRIDGE_UNREACHABLE' : 'INTERNAL_ERROR'
|
|
484
|
+
return errorEnvelope(header, type, `hub returned ${resp.status}, non-JSON`, endpoint)
|
|
485
|
+
}
|
|
486
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.event || !parsed.event.header) {
|
|
487
|
+
// e.g. `{ error: 'invalid_ingress_token' }` — never hand Alexa a non-event.
|
|
488
|
+
return errorEnvelope(
|
|
489
|
+
header,
|
|
490
|
+
'INTERNAL_ERROR',
|
|
491
|
+
`hub returned ${resp.status} without an Alexa event`,
|
|
492
|
+
endpoint,
|
|
493
|
+
)
|
|
431
494
|
}
|
|
495
|
+
return completeErrorResponse(parsed, directive)
|
|
432
496
|
}
|
|
433
497
|
|
|
434
498
|
function extractBearer(directive) {
|
package/lambda-template/util.js
CHANGED
|
@@ -94,20 +94,71 @@ function jsonResponse(statusCode, body) {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
/**
|
|
98
|
+
* `Alexa.ErrorResponse` for a directive. Amazon requires the directive's
|
|
99
|
+
* `correlationToken` and the target `endpoint` on it (alexa-errorresponse);
|
|
100
|
+
* both are copied when the directive carried them.
|
|
101
|
+
*/
|
|
102
|
+
function errorEnvelope(header, type, message, endpoint) {
|
|
103
|
+
const endpointId =
|
|
104
|
+
endpoint && typeof endpoint.endpointId === 'string' ? endpoint.endpointId : null
|
|
98
105
|
return {
|
|
99
106
|
event: {
|
|
100
107
|
header: {
|
|
101
108
|
namespace: 'Alexa',
|
|
102
109
|
name: 'ErrorResponse',
|
|
103
110
|
messageId: header.messageId,
|
|
111
|
+
...(typeof header.correlationToken === 'string'
|
|
112
|
+
? { correlationToken: header.correlationToken }
|
|
113
|
+
: {}),
|
|
104
114
|
payloadVersion: '3',
|
|
105
115
|
},
|
|
116
|
+
...(endpointId !== null ? { endpoint: { endpointId } } : {}),
|
|
106
117
|
payload: { type, message },
|
|
107
118
|
},
|
|
108
119
|
}
|
|
109
120
|
}
|
|
110
121
|
|
|
122
|
+
/** AcceptGrant's own error: `Alexa.Authorization/ErrorResponse` (alexa-authorization). */
|
|
123
|
+
function acceptGrantErrorEnvelope(header, message) {
|
|
124
|
+
return {
|
|
125
|
+
event: {
|
|
126
|
+
header: {
|
|
127
|
+
namespace: 'Alexa.Authorization',
|
|
128
|
+
name: 'ErrorResponse',
|
|
129
|
+
messageId: header.messageId,
|
|
130
|
+
payloadVersion: '3',
|
|
131
|
+
},
|
|
132
|
+
payload: { type: 'ACCEPT_GRANT_FAILED', message },
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Fill in what an `Alexa.ErrorResponse` from a hub may lack — a hub running an
|
|
139
|
+
* older addon omits `correlationToken` and `endpoint`. Any other body is
|
|
140
|
+
* returned as it is.
|
|
141
|
+
*/
|
|
142
|
+
function completeErrorResponse(body, directive) {
|
|
143
|
+
const ev = body && body.event
|
|
144
|
+
if (!ev || !ev.header || ev.header.name !== 'ErrorResponse' || ev.header.namespace !== 'Alexa') {
|
|
145
|
+
return body
|
|
146
|
+
}
|
|
147
|
+
const header = { ...ev.header }
|
|
148
|
+
if (
|
|
149
|
+
header.correlationToken === undefined &&
|
|
150
|
+
typeof directive.header.correlationToken === 'string'
|
|
151
|
+
) {
|
|
152
|
+
header.correlationToken = directive.header.correlationToken
|
|
153
|
+
}
|
|
154
|
+
const endpointId =
|
|
155
|
+
directive.endpoint && typeof directive.endpoint.endpointId === 'string'
|
|
156
|
+
? directive.endpoint.endpointId
|
|
157
|
+
: null
|
|
158
|
+
const endpoint = ev.endpoint || (endpointId !== null ? { endpointId } : undefined)
|
|
159
|
+
return { ...body, event: { ...ev, header, ...(endpoint ? { endpoint } : {}) } }
|
|
160
|
+
}
|
|
161
|
+
|
|
111
162
|
function describe(err) {
|
|
112
163
|
if (err instanceof Error) return err.message
|
|
113
164
|
return String(err)
|
|
@@ -124,5 +175,7 @@ module.exports = {
|
|
|
124
175
|
resolveBrokerPublicUrl,
|
|
125
176
|
jsonResponse,
|
|
126
177
|
errorEnvelope,
|
|
178
|
+
acceptGrantErrorEnvelope,
|
|
179
|
+
completeErrorResponse,
|
|
127
180
|
describe,
|
|
128
181
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-export-alexa",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.134",
|
|
4
4
|
"description": "Alexa Smart Home Skill export — hub-side OAuth provider + directive handler. The companion AWS Lambda forwards Alexa directives to this addon's /addon/export-alexa/directive endpoint.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|