From ade34b7da67d806b200e036ee802920891dd723b Mon Sep 17 00:00:00 2001 From: Nick Berardi Date: Mon, 29 Jun 2026 22:47:34 -0400 Subject: [PATCH] fix(alexa): v3 API compliance, fan speed, security panel, and camera streaming improvements (#2083) * fix(alexa): v3 API compliance fixes, fan speed, and security panel control Bring the Alexa Smart Home integration in line with the current v3 API. Tier 1 (malformed responses / missing handlers): - BrightnessController: report brightness under the Alexa.BrightnessController namespace instead of Alexa.PowerController so Set/AdjustBrightness responses match the advertised capability. - SmartVision ObjectDetection: flatten the doubly-nested payload.events array. - SecurityPanelController: add Arm/Disarm directive handlers (previously advertised but unhandled, silently falling through to a fake success), wired to armSecuritySystem/disarmSecuritySystem. Enforce the spec rule rejecting a direct ARMED_AWAY -> other armed-state transition with AUTHORIZATION_REQUIRED. Drop the advertised FOUR_DIGIT_PIN authorization, which Scrypted cannot honor. - Auth failures now return an Alexa INVALID_AUTHORIZATION_CREDENTIAL ErrorResponse instead of a bare HTTP 500 that Alexa retries and surfaces as a generic skill error. API modernization: - EndpointHealth: downgrade to the documented v3.1 / connectivity-only shape and stop emitting the undocumented battery property (and 3.2 version), which risks the whole capability being rejected. - Fan: add Alexa.RangeController (Fan.Speed, 0-100%) for devices implementing the Fan interface, alongside the existing PowerController on/off. Cleanups: - addAccessToken: initialize event.endpoint as an object, not an array. - Hoist getArmState to a shared export instead of duplicating it. Bump version to 0.4.0. * fix(alexa): improve camera stream quality and connectivity Tune the WebRTC negotiation for Alexa camera sessions. - Resolution: raise the proxied stream cap from 720p to 1080p. The previous 1280x720 screen hint forced the transcoder into medium-resolution mode for every endpoint, so even an Echo Show 15 or Fire TV only received 720p. 1080p lets larger displays render sharply while the transcoder still clamps width and falls back to 720p when H.264 High can't be negotiated. We cap rather than send the uncapped source, since Alexa's directive carries no display hint and a 4K stream would waste bandwidth on smaller Echo devices. - Two-way audio: only advertise isFullDuplexAudioSupported when the camera implements the Intercom interface. Advertising full duplex on a one-way camera makes Alexa set up a return mic path that goes nowhere. - TURN: stop unconditionally disabling TURN, and expose a "Use TURN Servers" plugin setting (on by default). Alexa sessions are proxied and often cross NATs where a TURN relay is the only path that connects; combined with disabled trickle ICE (all candidates in the initial SDP), omitting the relay candidate left NAT-blocked sessions with no fallback. When enabled, TURN usage defers to the WebRTC plugin's own setting, matching the Google Home integration; disabling it force-disables TURN for Alexa sessions only. Bump version to 0.5.0. --- plugins/alexa/package.json | 2 +- plugins/alexa/src/common.ts | 41 +--- plugins/alexa/src/main.ts | 53 +++-- .../alexa/src/types/camera/capabilities.ts | 14 +- plugins/alexa/src/types/camera/handlers.ts | 28 ++- plugins/alexa/src/types/fan.ts | 202 +++++++++++++----- plugins/alexa/src/types/fan/handlers.ts | 68 ++++++ plugins/alexa/src/types/index.ts | 4 +- plugins/alexa/src/types/light/handlers.ts | 3 +- plugins/alexa/src/types/securitysystem.ts | 12 +- .../src/types/securitysystem/handlers.ts | 112 ++++++++++ 11 files changed, 399 insertions(+), 140 deletions(-) create mode 100644 plugins/alexa/src/types/fan/handlers.ts create mode 100644 plugins/alexa/src/types/securitysystem/handlers.ts diff --git a/plugins/alexa/package.json b/plugins/alexa/package.json index 2e705ad3f..867d71665 100644 --- a/plugins/alexa/package.json +++ b/plugins/alexa/package.json @@ -1,6 +1,6 @@ { "name": "@scrypted/alexa", - "version": "0.3.7", + "version": "0.5.0", "scripts": { "scrypted-setup-project": "scrypted-setup-project", "prescrypted-setup-project": "scrypted-package-json", diff --git a/plugins/alexa/src/common.ts b/plugins/alexa/src/common.ts index a4a1d64fd..c561e8951 100644 --- a/plugins/alexa/src/common.ts +++ b/plugins/alexa/src/common.ts @@ -1,4 +1,4 @@ -import { Battery, Online, PowerSensor, ScryptedDevice, ScryptedInterface, HttpResponse } from "@scrypted/sdk"; +import { Online, ScryptedDevice, ScryptedInterface, HttpResponse } from "@scrypted/sdk"; import { v4 as createMessageId } from 'uuid'; export interface AlexaHttpResponse extends HttpResponse { @@ -31,44 +31,6 @@ export function addOnline(data: any, device: ScryptedDevice & Online) : any { return data; } -export function addBattery(data: any, device: ScryptedDevice & Battery) : any { - if (!device.interfaces.includes(ScryptedInterface.Battery)) - return data; - - if (data.context === undefined) - data.context = {}; - - if (data.context.properties === undefined) - data.context.properties = []; - - const lowPower = device.batteryLevel < 20; - let health = undefined; - - if (lowPower) { - health = { - "state": "WARNING", - "reasons": [ - "LOW_CHARGE" - ] - }; - } - - data.context.properties.push( - { - "namespace": "Alexa.EndpointHealth", - "name": "battery", - "value": { - health, - "levelPercentage": device.batteryLevel, - }, - "timeOfSample": new Date().toISOString(), - "uncertaintyInMilliseconds": 0 - } - ); - - return data; -} - export function authErrorResponse(errorType: string, errorMessage: string, directive: any): any { const { header } = directive; const data = { @@ -124,7 +86,6 @@ export function mirroredResponse (directive: any): any { } export function sendDeviceResponse(data: any, response: any, device: ScryptedDevice) { - data = addBattery(data, device); data = addOnline(data, device); response.send(data); diff --git a/plugins/alexa/src/main.ts b/plugins/alexa/src/main.ts index 50a713949..fba0cca82 100644 --- a/plugins/alexa/src/main.ts +++ b/plugins/alexa/src/main.ts @@ -1,11 +1,12 @@ import axios from 'axios'; import sdk, { HttpRequest, HttpRequestHandler, MixinProvider, ScryptedDevice, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, EventDetails, Setting, SettingValue, Settings, HttpResponseOptions, HttpResponse } from '@scrypted/sdk'; import { StorageSettings } from '@scrypted/sdk/storage-settings'; -import { addBattery, addOnline, deviceErrorResponse, mirroredResponse, authErrorResponse, AlexaHttpResponse } from './common'; +import { addOnline, deviceErrorResponse, mirroredResponse, authErrorResponse, AlexaHttpResponse } from './common'; import { supportedTypes } from './types'; import { v4 as createMessageId } from 'uuid'; import { ChangeReport, Discovery, DiscoveryEndpoint } from './alexa'; import { alexaHandlers, alexaDeviceHandlers } from './handlers'; +import { setUseTurnServer } from './types/camera/handlers'; const { systemManager, deviceManager } = sdk; @@ -59,6 +60,15 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi type: 'boolean', defaultValue: false, }, + useTurnServer: { + title: "Use TURN Servers", + description: "Allow camera streams to relay through a TURN server when a direct connection can't be established. Alexa streams cross networks and usually need this. Disable only if your server is directly reachable and you want to force peer-to-peer connections.", + type: 'boolean', + defaultValue: true, + onPut(oldValue: boolean, newValue: boolean) { + setUseTurnServer(newValue); + } + }, }); accessToken: Promise; @@ -69,6 +79,7 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi super(nativeId); DEBUG = this.storageSettings.values.debug ?? false; + setUseTurnServer(this.storageSettings.values.useTurnServer); alexaHandlers.set('Alexa.Authorization/AcceptGrant', this.onAlexaAuthorization); alexaHandlers.set('Alexa.Discovery/Discover', this.onDiscoverEndpoints); @@ -180,10 +191,6 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi report = {}; } - if (!report && eventDetails.eventInterface === ScryptedInterface.Battery) { - report = {}; - } - if (!report) { debug(`${eventDetails.eventInterface}.${eventDetails.property} not supported for device ${eventSource.type}`); return; @@ -208,7 +215,6 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi } as ChangeReport; data = addOnline(data, eventSource); - data = addBattery(data, eventSource); // nothing to report if (data.context === undefined && data.event.payload === undefined) @@ -226,7 +232,7 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi data.event = {}; if (data.event.endpoint === undefined) - data.event.endpoint = []; + data.event.endpoint = {}; data.event.endpoint.scope = { "type": "BearerToken", @@ -556,31 +562,22 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi let supportedEndpointHealths: any[] = []; + // The current Alexa.EndpointHealth spec (v3.1) only defines the connectivity property. + // The battery/radioDiagnostics/networkThroughput properties from older drafts are no + // longer documented; declaring them (or a 3.2 version) risks Alexa rejecting the entire + // capability. https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-endpointhealth.html if (device.interfaces.includes(ScryptedInterface.Online)) { supportedEndpointHealths.push({ "name": "connectivity" }); } - // { - // "name": "radioDiagnostics" - // }, - // { - // "name": "networkThroughput" - // } - - if (device.interfaces.includes(ScryptedInterface.Battery)) { - supportedEndpointHealths.push({ - "name": "battery" - }) - } - if (supportedEndpointHealths.length > 0) { data.capabilities.push( { "type": "AlexaInterface", "interface": "Alexa.EndpointHealth", - "version": "3.2", + "version": "3.1", "properties": { "supported": supportedEndpointHealths, "proactivelyReported": true, @@ -612,6 +609,10 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi async onRequest(request: HttpRequest, rawResponse: HttpResponse) { const response = new HttpResponseLoggingImpl(rawResponse, this.console); + const body = JSON.parse(request.body); + const { directive } = body; + const { namespace, name } = directive.header; + const { authorization } = request.headers; if (!this.validAuths.has(authorization)) { try { @@ -637,17 +638,13 @@ class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, Mixi } catch (e) { this.console.error(`request failed due to invalid authorization`, e); - response.send(e.message, { - code: 500, - }); + // Alexa expects a v3 ErrorResponse rather than a raw HTTP error, otherwise + // the directive is retried and surfaces to the user as a generic skill failure. + response.send(authErrorResponse("INVALID_AUTHORIZATION_CREDENTIAL", `Unable to authorize the request: ${e.message}`, directive)); return; } } - const body = JSON.parse(request.body); - const { directive } = body; - const { namespace, name } = directive.header; - const mapName = `${namespace}/${name}`; debug("received directive from alexa", mapName, body); diff --git a/plugins/alexa/src/types/camera/capabilities.ts b/plugins/alexa/src/types/camera/capabilities.ts index a488c4f5e..8d9c1edd4 100644 --- a/plugins/alexa/src/types/camera/capabilities.ts +++ b/plugins/alexa/src/types/camera/capabilities.ts @@ -63,14 +63,14 @@ export async function sendCameraEvent (eventSource: ScryptedDevice & MotionSenso name: 'ObjectDetection' }, payload: { - "events": [eventData.detections.map(detection => { + "events": eventData.detections.map(detection => { let event = { "eventIdentifier": eventData.eventId, "imageNetClass": detection.className, "timeOfSample": new Date(eventData.timestamp).toISOString(), "uncertaintyInMilliseconds": 500 }; - + if (detection.id) { event["objectIdentifier"] = detection.id; } @@ -80,7 +80,7 @@ export async function sendCameraEvent (eventSource: ScryptedDevice & MotionSenso } return event; - })] + }) } } } as Partial; @@ -114,13 +114,19 @@ export async function sendCameraEvent (eventSource: ScryptedDevice & MotionSenso }; export async function getCameraCapabilities(device: ScryptedDevice): Promise { + // Only advertise full-duplex (two-way) audio when the camera can actually receive audio, + // i.e. it implements the Intercom interface. Advertising full duplex on a one-way camera + // makes Alexa set up a return mic path that goes nowhere, degrading the connect experience. + // Half-duplex cameras send an 'a=sendonly' answer during negotiation. + const isFullDuplexAudioSupported = device.interfaces.includes(ScryptedInterface.Intercom); + const capabilities = [ { "type": "AlexaInterface", "interface": "Alexa.RTCSessionController", "version": "3", "configuration": { - "isFullDuplexAudioSupported": true, + isFullDuplexAudioSupported, } } as DiscoveryCapability ]; diff --git a/plugins/alexa/src/types/camera/handlers.ts b/plugins/alexa/src/types/camera/handlers.ts index d319465e8..739a95fe6 100644 --- a/plugins/alexa/src/types/camera/handlers.ts +++ b/plugins/alexa/src/types/camera/handlers.ts @@ -6,6 +6,14 @@ import { alexaDeviceHandlers } from "../../handlers"; import { Response, WebRTCAnswerGeneratedForSessionEvent, WebRTCSessionConnectedEvent, WebRTCSessionDisconnectedEvent } from '../../alexa' import { Deferred } from '@scrypted/common/src/deferred'; +// Whether Alexa camera sessions are allowed to use TURN relays. Set by the plugin from its +// storage settings (see main.ts). When true, TURN usage defers to the WebRTC plugin's own +// "Use TURN Servers" setting; when false, TURN is force-disabled for Alexa sessions only. +export let useTurnServer = true; +export function setUseTurnServer(value: boolean) { + useTurnServer = value ?? true; +} + export class AlexaSignalingSession implements RTCSignalingSession { constructor(public response: AlexaHttpResponse, public directive: any) { this.options = this.createOptions(); @@ -28,12 +36,24 @@ export class AlexaSignalingSession implements RTCSignalingSession { sdp: this.directive.payload.offer.value, }, disableTrickle: true, - disableTurn: true, - // this could be a low resolution screen, no way of knowning, so never send a 1080p stream + // Alexa sessions are proxied (Scrypted's server negotiates with the Alexa cloud) and + // frequently cross NATs, where a TURN relay is the only path that connects. Since + // trickle ICE is disabled, all candidates ship in the initial SDP, so omitting the + // relay candidate leaves a NAT-blocked session with no fallback. By default we leave + // TURN enabled, deferring to the WebRTC plugin's own "Use TURN Servers" setting (the + // same behavior as the Google Home integration). Disabling it here force-disables TURN + // for Alexa sessions only. + disableTurn: !useTurnServer, + // Alexa's InitiateSessionWithOffer directive carries no display hint (only sessionId + // and the SDP offer), so we cap rather than match the endpoint. 1080p lets larger + // displays (Echo Show 15, Fire TV) render sharply while the transcoder still clamps + // width and falls back to 720p for sessions that can't negotiate H.264 High. We avoid + // an uncapped (e.g. 4K) source stream, which would waste bandwidth and slow the + // connection on smaller Echo devices. screen: { devicePixelRatio: 1, - width: 1280, - height: 720 + width: 1920, + height: 1080 } }; diff --git a/plugins/alexa/src/types/fan.ts b/plugins/alexa/src/types/fan.ts index 4ab6372d4..74386e037 100644 --- a/plugins/alexa/src/types/fan.ts +++ b/plugins/alexa/src/types/fan.ts @@ -1,71 +1,167 @@ -import { OnOff, ScryptedDevice, ScryptedDeviceType, ScryptedInterface } from "@scrypted/sdk"; -import { DiscoveryEndpoint, ChangeReport, Report, Property, ChangePayload, DiscoveryCapability } from "../alexa"; +import { Fan, OnOff, ScryptedDevice, ScryptedDeviceType, ScryptedInterface } from "@scrypted/sdk"; +import { DiscoveryEndpoint, ChangeReport, Report, Property, ChangePayload, DiscoveryCapability, StateReport } from "../alexa"; import { supportedTypes } from "."; +// Alexa models the fan speed as a 0-100 percentage range, matching the convention used by the +// HomeKit plugin. The actual device speed is derived from the device's maxSpeed. +export function fanSpeedToRangeValue(device: ScryptedDevice & Fan): number { + const speed = device.fan?.speed; + if (!speed) + return 0; + const maxSpeed = device.fan?.maxSpeed; + if (!maxSpeed) + return 100; + return Math.abs(Math.round(speed / maxSpeed * 100)); +} + +export function rangeValueToFanSpeed(device: ScryptedDevice & Fan, rangeValue: number): number { + const maxSpeed = device.fan?.maxSpeed; + return maxSpeed + ? Math.round(rangeValue / 100 * maxSpeed) + : (rangeValue ? 1 : 0); +} + supportedTypes.set(ScryptedDeviceType.Fan, { async discover(device: ScryptedDevice): Promise> { - if (!device.interfaces.includes(ScryptedInterface.OnOff)) + if (!device.interfaces.includes(ScryptedInterface.OnOff) && !device.interfaces.includes(ScryptedInterface.Fan)) return; const capabilities: DiscoveryCapability[] = []; - capabilities.push({ - "type": "AlexaInterface", - "interface": "Alexa.PowerController", - "version": "3", - "properties": { - "supported": [ - { - "name": "powerState" - } - ], - "proactivelyReported": true, - "retrievable": true - } - }); + + if (device.interfaces.includes(ScryptedInterface.OnOff)) { + capabilities.push({ + "type": "AlexaInterface", + "interface": "Alexa.PowerController", + "version": "3", + "properties": { + "supported": [ + { + "name": "powerState" + } + ], + "proactivelyReported": true, + "retrievable": true + } + }); + } + + if (device.interfaces.includes(ScryptedInterface.Fan)) { + capabilities.push({ + "type": "AlexaInterface", + "interface": "Alexa.RangeController", + "instance": "Fan.Speed", + "version": "3", + "properties": { + "supported": [ + { + "name": "rangeValue" + } + ], + "proactivelyReported": true, + "retrievable": true + }, + "capabilityResources": { + "friendlyNames": [ + { + "@type": "asset", + "value": { + "assetId": "Alexa.Setting.FanSpeed" + } + } + ] + }, + "configuration": { + "supportedRange": { + "minimumValue": 0, + "maximumValue": 100, + "precision": 10 + }, + "unitOfMeasure": "Alexa.Unit.Percent" + } + } as DiscoveryCapability); + } return { displayCategories: ['FAN'], capabilities } }, - async sendReport(eventSource: ScryptedDevice & OnOff): Promise> { - return { + async sendReport(eventSource: ScryptedDevice & OnOff & Fan): Promise> { + let data = { context: { - "properties": [ - { - "namespace": "Alexa.PowerController", - "name": "powerState", - "value": eventSource.on ? "ON" : "OFF", - "timeOfSample": new Date().toISOString(), - "uncertaintyInMilliseconds": 0 - } as Property - ] + properties: [] } - }; - }, - async sendEvent(eventSource: ScryptedDevice & OnOff, eventDetails, eventData): Promise> { - if (eventDetails.eventInterface !== ScryptedInterface.OnOff) - return undefined; + } as Partial; - return { - event: { - payload: { - change: { - cause: { - type: "PHYSICAL_INTERACTION" - }, - properties: [ - { - "namespace": "Alexa.PowerController", - "name": "powerState", - "value": eventData ? "ON" : "OFF", - "timeOfSample": new Date().toISOString(), - "uncertaintyInMilliseconds": 0 - } as Property - ] - } - } as ChangePayload, - } - } as Partial; + if (eventSource.interfaces.includes(ScryptedInterface.OnOff)) { + data.context.properties.push({ + "namespace": "Alexa.PowerController", + "name": "powerState", + "value": eventSource.on ? "ON" : "OFF", + "timeOfSample": new Date().toISOString(), + "uncertaintyInMilliseconds": 0 + } as Property); + } + + if (eventSource.interfaces.includes(ScryptedInterface.Fan)) { + data.context.properties.push({ + "namespace": "Alexa.RangeController", + "instance": "Fan.Speed", + "name": "rangeValue", + "value": fanSpeedToRangeValue(eventSource), + "timeOfSample": new Date().toISOString(), + "uncertaintyInMilliseconds": 0 + } as Property); + } + + return data; + }, + async sendEvent(eventSource: ScryptedDevice & OnOff & Fan, eventDetails, eventData): Promise> { + if (eventDetails.eventInterface === ScryptedInterface.OnOff) + return { + event: { + payload: { + change: { + cause: { + type: "PHYSICAL_INTERACTION" + }, + properties: [ + { + "namespace": "Alexa.PowerController", + "name": "powerState", + "value": eventData ? "ON" : "OFF", + "timeOfSample": new Date(eventDetails.eventTime).toISOString(), + "uncertaintyInMilliseconds": 0 + } as Property + ] + } + } as ChangePayload, + } + } as Partial; + + if (eventDetails.eventInterface === ScryptedInterface.Fan) + return { + event: { + payload: { + change: { + cause: { + type: "PHYSICAL_INTERACTION" + }, + properties: [ + { + "namespace": "Alexa.RangeController", + "instance": "Fan.Speed", + "name": "rangeValue", + "value": fanSpeedToRangeValue(eventSource), + "timeOfSample": new Date(eventDetails.eventTime).toISOString(), + "uncertaintyInMilliseconds": 0 + } as Property + ] + } + } as ChangePayload, + } + } as Partial; + + return undefined; } }); diff --git a/plugins/alexa/src/types/fan/handlers.ts b/plugins/alexa/src/types/fan/handlers.ts new file mode 100644 index 000000000..2e2af45d0 --- /dev/null +++ b/plugins/alexa/src/types/fan/handlers.ts @@ -0,0 +1,68 @@ +import { Fan, ScryptedDevice } from "@scrypted/sdk"; +import { supportedTypes } from ".."; +import { sendDeviceResponse } from "../../common"; +import { v4 as createMessageId } from 'uuid'; +import { alexaDeviceHandlers } from "../../handlers"; +import { Directive, Response } from "../../alexa"; +import { fanSpeedToRangeValue, rangeValueToFanSpeed } from "../fan"; + +function commonRangeResponse(header, endpoint, payload, response, device: ScryptedDevice & Fan) { + const data = { + "event": { + "header": { + "namespace": "Alexa", + "name": "Response", + "messageId": createMessageId(), + "correlationToken": header.correlationToken, + "payloadVersion": "3" + }, + endpoint, + payload + }, + "context": { + "properties": [ + { + "namespace": "Alexa.RangeController", + "instance": "Fan.Speed", + "name": "rangeValue", + "value": fanSpeedToRangeValue(device), + "timeOfSample": new Date().toISOString(), + "uncertaintyInMilliseconds": 500 + } + ] + } + }; + + sendDeviceResponse(data, response, device); +} + +alexaDeviceHandlers.set('Alexa.RangeController/SetRangeValue', async (request, response, directive: Directive, device: ScryptedDevice & Fan) => { + const supportedType = supportedTypes.get(device.type); + if (!supportedType) + return; + + const { header, endpoint, payload } = directive; + const rangeValue = Math.max(0, Math.min(100, (payload as any).rangeValue)); + + await device.setFan({ + speed: rangeValueToFanSpeed(device, rangeValue), + }); + + commonRangeResponse(header, endpoint, payload, response, device); +}); + +alexaDeviceHandlers.set('Alexa.RangeController/AdjustRangeValue', async (request, response, directive: Directive, device: ScryptedDevice & Fan) => { + const supportedType = supportedTypes.get(device.type); + if (!supportedType) + return; + + const { header, endpoint, payload } = directive; + const current = fanSpeedToRangeValue(device); + const rangeValue = Math.max(0, Math.min(100, current + (payload as any).rangeValueDelta)); + + await device.setFan({ + speed: rangeValueToFanSpeed(device, rangeValue), + }); + + commonRangeResponse(header, endpoint, payload, response, device); +}); diff --git a/plugins/alexa/src/types/index.ts b/plugins/alexa/src/types/index.ts index 8ad2886d4..f9da9342b 100644 --- a/plugins/alexa/src/types/index.ts +++ b/plugins/alexa/src/types/index.ts @@ -16,10 +16,12 @@ import './camera/handlers'; import './light'; import './light/handlers' import './fan'; +import './fan/handlers'; import './doorbell'; import './garagedoor'; import './outlet'; import './switch'; import './switch/handlers'; import './sensor'; -import './securitysystem'; \ No newline at end of file +import './securitysystem'; +import './securitysystem/handlers'; \ No newline at end of file diff --git a/plugins/alexa/src/types/light/handlers.ts b/plugins/alexa/src/types/light/handlers.ts index 230f9586a..a3fe9b901 100644 --- a/plugins/alexa/src/types/light/handlers.ts +++ b/plugins/alexa/src/types/light/handlers.ts @@ -4,7 +4,6 @@ import { deviceErrorResponse, sendDeviceResponse } from "../../common"; import { v4 as createMessageId } from 'uuid'; import { alexaDeviceHandlers } from "../../handlers"; import { Directive, Response } from "../../alexa"; -import { error } from "console"; function commonBrightnessResponse(header, endpoint, payload, response, device: ScryptedDevice & Brightness) { const data : Response = { @@ -16,7 +15,7 @@ function commonBrightnessResponse(header, endpoint, payload, response, device: S "context": { "properties": [ { - "namespace": "Alexa.PowerController", + "namespace": "Alexa.BrightnessController", "name": "brightness", "value": device.brightness, "timeOfSample": new Date().toISOString(), diff --git a/plugins/alexa/src/types/securitysystem.ts b/plugins/alexa/src/types/securitysystem.ts index 96afd1cc3..a7c903d7f 100644 --- a/plugins/alexa/src/types/securitysystem.ts +++ b/plugins/alexa/src/types/securitysystem.ts @@ -2,7 +2,7 @@ import { EventDetails, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, Se import { DiscoveryEndpoint, DiscoveryCapability, ChangeReport, Report, StateReport, DisplayCategory, ChangePayload, Property } from "../alexa"; import { supportedTypes } from "."; -function getArmState(mode: SecuritySystemMode): string { +export function getArmState(mode: SecuritySystemMode): string { switch(mode) { case SecuritySystemMode.AwayArmed: return 'ARMED_AWAY'; @@ -54,12 +54,10 @@ supportedTypes.set(ScryptedDeviceType.SecuritySystem, { return { "value": getArmState(mode) } - }), - "supportedAuthorizationTypes": [ - { - "type": "FOUR_DIGIT_PIN" - } - ] + }) + // Scrypted's SecuritySystem interface has no PIN concept, so we do not + // advertise supportedAuthorizationTypes. Advertising FOUR_DIGIT_PIN would + // cause Alexa to send a PIN we have no way to validate. } } as DiscoveryCapability ); diff --git a/plugins/alexa/src/types/securitysystem/handlers.ts b/plugins/alexa/src/types/securitysystem/handlers.ts new file mode 100644 index 000000000..fcfe480c5 --- /dev/null +++ b/plugins/alexa/src/types/securitysystem/handlers.ts @@ -0,0 +1,112 @@ +import { ScryptedDevice, SecuritySystem, SecuritySystemMode } from "@scrypted/sdk"; +import { supportedTypes } from ".."; +import { deviceErrorResponse, sendDeviceResponse } from "../../common"; +import { v4 as createMessageId } from 'uuid'; +import { alexaDeviceHandlers } from "../../handlers"; +import { Directive, Response } from "../../alexa"; +import { getArmState } from "../securitysystem"; + +function getSecuritySystemMode(armState: string): SecuritySystemMode { + switch (armState) { + case 'ARMED_AWAY': + return SecuritySystemMode.AwayArmed; + case 'ARMED_STAY': + return SecuritySystemMode.HomeArmed; + case 'ARMED_NIGHT': + return SecuritySystemMode.NightArmed; + case 'DISARMED': + return SecuritySystemMode.Disarmed; + } +} + +alexaDeviceHandlers.set('Alexa.SecurityPanelController/Arm', async (request, response, directive: Directive, device: ScryptedDevice & SecuritySystem) => { + const supportedType = supportedTypes.get(device.type); + if (!supportedType) + return; + + const { header, endpoint, payload } = directive; + const targetState = (payload as any).armState as string; + const targetMode = getSecuritySystemMode(targetState); + + if (targetMode === undefined) { + response.send(deviceErrorResponse("INVALID_VALUE", `Unsupported arm state: ${targetState}`, directive)); + return; + } + + // Alexa requires that the system not transition directly from ARMED_AWAY to another + // armed state. The user must disarm first. + // https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-securitypanelcontroller.html + const currentMode = device.securitySystemState?.mode; + if (currentMode === SecuritySystemMode.AwayArmed + && targetMode !== SecuritySystemMode.Disarmed + && targetMode !== SecuritySystemMode.AwayArmed) { + response.send(deviceErrorResponse("AUTHORIZATION_REQUIRED", "The security system must be disarmed before changing to another armed state.", directive)); + return; + } + + await device.armSecuritySystem(targetMode); + + const data = { + "event": { + "header": { + "namespace": "Alexa.SecurityPanelController", + "name": "Arm.Response", + "messageId": createMessageId(), + "correlationToken": header.correlationToken, + "payloadVersion": "3" + }, + endpoint, + "payload": {} + }, + "context": { + "properties": [ + { + "namespace": "Alexa.SecurityPanelController", + "name": "armState", + "value": getArmState(targetMode), + "timeOfSample": new Date().toISOString(), + "uncertaintyInMilliseconds": 0 + } + ] + } + }; + + sendDeviceResponse(data, response, device); +}); + +alexaDeviceHandlers.set('Alexa.SecurityPanelController/Disarm', async (request, response, directive: Directive, device: ScryptedDevice & SecuritySystem) => { + const supportedType = supportedTypes.get(device.type); + if (!supportedType) + return; + + const { header, endpoint, payload } = directive; + + await device.disarmSecuritySystem(); + + const data: Response = { + "event": { + "header": { + "namespace": "Alexa", + "name": "Response", + "messageId": createMessageId(), + "correlationToken": header.correlationToken, + "payloadVersion": "3" + }, + endpoint, + payload + }, + "context": { + "properties": [ + { + "namespace": "Alexa.SecurityPanelController", + "name": "armState", + "value": "DISARMED", + "timeOfSample": new Date().toISOString(), + "uncertaintyInMilliseconds": 0 + } + ] + } + }; + + sendDeviceResponse(data, response, device); +});