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.
This commit is contained in:
Nick Berardi
2026-06-29 22:47:34 -04:00
committed by GitHub
parent baef85054c
commit ade34b7da6
11 changed files with 399 additions and 140 deletions

View File

@@ -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",

View File

@@ -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);

View File

@@ -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<string>;
@@ -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);

View File

@@ -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<ObjectDetectionEvent>;
@@ -114,13 +114,19 @@ export async function sendCameraEvent (eventSource: ScryptedDevice & MotionSenso
};
export async function getCameraCapabilities(device: ScryptedDevice): Promise<DiscoveryCapability[]> {
// 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
];

View File

@@ -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
}
};

View File

@@ -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<Partial<DiscoveryEndpoint>> {
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<Partial<Report>> {
return {
async sendReport(eventSource: ScryptedDevice & OnOff & Fan): Promise<Partial<Report>> {
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<Partial<Report>> {
if (eventDetails.eventInterface !== ScryptedInterface.OnOff)
return undefined;
} as Partial<StateReport>;
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<ChangeReport>;
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<Partial<Report>> {
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<ChangeReport>;
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<ChangeReport>;
return undefined;
}
});

View File

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

View File

@@ -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';
import './securitysystem';
import './securitysystem/handlers';

View File

@@ -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(),

View File

@@ -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
);

View File

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