mirror of
https://github.com/koush/scrypted.git
synced 2026-08-01 19:00:37 +01:00
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:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
68
plugins/alexa/src/types/fan/handlers.ts
Normal file
68
plugins/alexa/src/types/fan/handlers.ts
Normal 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);
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
112
plugins/alexa/src/types/securitysystem/handlers.ts
Normal file
112
plugins/alexa/src/types/securitysystem/handlers.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user