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); +});