mirror of
https://github.com/koush/scrypted.git
synced 2026-09-18 09:40:44 +01:00
498 lines
17 KiB
TypeScript
498 lines
17 KiB
TypeScript
import axios from 'axios';
|
|
import sdk, { HttpRequest, HttpRequestHandler, HttpResponse, MixinProvider, ScryptedDevice, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface } from '@scrypted/sdk';
|
|
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
|
import { AutoenableMixinProvider } from '@scrypted/common/src/autoenable-mixin-provider';
|
|
import { isSupported } from './types';
|
|
import { DiscoveryEndpoint, DiscoverEvent } from 'alexa-smarthome-ts';
|
|
import { AlexaHandler, addBattery, addOnline, addPowerSensor, capabilityHandlers, supportedTypes } from './types/common';
|
|
import { createMessageId } from './message';
|
|
|
|
const { systemManager, deviceManager } = sdk;
|
|
|
|
const client_id = "amzn1.application-oa2-client.3283807e04d8408eb44a698c10f9dd13";
|
|
const client_secret = "bed445e2b26730acd818b90e175b275f6b67b18ff8645e571c5b3e311fa75ee9";
|
|
|
|
class AlexaPlugin extends AutoenableMixinProvider implements HttpRequestHandler, MixinProvider {
|
|
storageSettings = new StorageSettings(this, {
|
|
tokenInfo: {
|
|
hide: true,
|
|
json: true,
|
|
},
|
|
syncedDevices: {
|
|
multiple: true,
|
|
hide: true,
|
|
},
|
|
});
|
|
|
|
handlers = new Map<string, AlexaHandler>();
|
|
accessToken: Promise<string>;
|
|
validAuths = new Set<string>();
|
|
|
|
constructor(nativeId?: string) {
|
|
super(nativeId);
|
|
|
|
this.handlers.set('Alexa.Authorization', this.alexaAuthorization);
|
|
this.handlers.set('Alexa.Discovery', this.alexaDiscovery);
|
|
|
|
this.syncDevices();
|
|
|
|
systemManager.listen(async (eventSource, eventDetails, eventData) => {
|
|
if (!eventSource)
|
|
return;
|
|
|
|
if (!this.storageSettings.values.syncedDevices.includes(eventSource.id))
|
|
return;
|
|
|
|
const supportedType = supportedTypes.get(eventSource.type);
|
|
if (!supportedType) {
|
|
this.console.warn(`${eventSource.name} no longer supported type?`);
|
|
return;
|
|
}
|
|
|
|
const report = await supportedType.sendEvent(eventSource, eventDetails, eventData);
|
|
let data = {
|
|
"event": {
|
|
"header": {
|
|
"messageId": createMessageId(),
|
|
"namespace": report?.namespace ?? "Alexa",
|
|
"name": report?.name ?? "ChangeReport",
|
|
"payloadVersion": "3"
|
|
},
|
|
"endpoint": {
|
|
"endpointId": eventSource.id,
|
|
"scope": undefined
|
|
},
|
|
"payload": report?.payload,
|
|
},
|
|
"context": report?.context
|
|
}
|
|
|
|
data = addOnline(data, eventSource);
|
|
data = addBattery(data, eventSource);
|
|
data = addPowerSensor(data, eventSource);
|
|
|
|
// nothing to report
|
|
if (data.context === undefined && data.event.payload === undefined)
|
|
return;
|
|
|
|
const accessToken = await this.getAccessToken();
|
|
data.event.endpoint.scope = {
|
|
"type": "BearerToken",
|
|
"token": accessToken,
|
|
};
|
|
|
|
await this.postEvent(data);
|
|
});
|
|
}
|
|
|
|
async getMixin(mixinDevice: any, mixinDeviceInterfaces: ScryptedInterface[], mixinDeviceState: { [key: string]: any; }): Promise<any> {
|
|
return mixinDevice;
|
|
}
|
|
|
|
async releaseMixin(id: string, mixinDevice: any): Promise<void> {
|
|
const device = systemManager.getDeviceById(id);
|
|
if (device.mixins?.includes(this.id)) {
|
|
return;
|
|
}
|
|
this.console.log('release mixin', id);
|
|
this.log.a(`${device.name} was removed. The Alexa plugin will reload momentarily.`);
|
|
deviceManager.requestRestart();
|
|
}
|
|
|
|
async postEvent(data: any) {
|
|
const accessToken = await this.getAccessToken();
|
|
const self = this;
|
|
|
|
return axios.post('https://api.amazonalexa.com/v3/events', data, {
|
|
headers: {
|
|
'Authorization': 'Bearer ' + accessToken,
|
|
}
|
|
}).catch(error => {
|
|
self.console.error(error?.response?.data);
|
|
|
|
if (error?.response?.status === 401 || error?.response?.status === 403) {
|
|
self.storageSettings.values.tokenInfo = undefined;
|
|
self.accessToken = undefined;
|
|
}
|
|
});
|
|
}
|
|
|
|
async syncDevices() {
|
|
const endpoints = await this.addOrUpdateReport();
|
|
await this.saveEndpoints(endpoints);
|
|
}
|
|
|
|
async addOrUpdateReport() {
|
|
const endpoints = this.getDiscoveryEndpoints();
|
|
|
|
if (!endpoints.length)
|
|
return [];
|
|
|
|
const accessToken = await this.getAccessToken();
|
|
await this.postEvent({
|
|
"event": {
|
|
"header": {
|
|
"namespace": "Alexa.Discovery",
|
|
"name": "AddOrUpdateReport",
|
|
"payloadVersion": "3",
|
|
"messageId": createMessageId(),
|
|
},
|
|
"payload": {
|
|
endpoints,
|
|
"scope": {
|
|
"type": "BearerToken",
|
|
"token": accessToken,
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return endpoints;
|
|
}
|
|
|
|
async deleteReport(...ids: string[]) {
|
|
if (!ids.length)
|
|
return;
|
|
|
|
const accessToken = await this.getAccessToken();
|
|
return this.postEvent({
|
|
"event": {
|
|
"header": {
|
|
"namespace": "Alexa.Discovery",
|
|
"name": "DeleteReport",
|
|
"messageId": createMessageId(),
|
|
"payloadVersion": "3"
|
|
},
|
|
"payload": {
|
|
"endpoints": ids.map(id => ({
|
|
"endpointId": id,
|
|
})),
|
|
"scope": {
|
|
"type": "BearerToken",
|
|
"token": accessToken,
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
async canMixin(type: ScryptedDeviceType, interfaces: string[]): Promise<string[]> {
|
|
const discovery = isSupported({
|
|
type,
|
|
interfaces,
|
|
} as any);
|
|
|
|
if (!discovery)
|
|
return;
|
|
return [];
|
|
}
|
|
|
|
getAccessToken(): Promise<string> {
|
|
if (this.accessToken)
|
|
return this.accessToken;
|
|
|
|
const { tokenInfo } = this.storageSettings.values;
|
|
|
|
if (tokenInfo === undefined) {
|
|
this.log.e("Please reauthenticate by following the directions below.");
|
|
throw new Error("'tokenInfo' is undefined");
|
|
}
|
|
|
|
const { code } = tokenInfo;
|
|
|
|
const body: Record<string, string> = {
|
|
client_id,
|
|
client_secret
|
|
};
|
|
if (code) {
|
|
body.code = code;
|
|
body.grant_type = 'authorization_code';
|
|
}
|
|
else {
|
|
const { refresh_token } = tokenInfo;
|
|
body.refresh_token = refresh_token;
|
|
body.grant_type = 'refresh_token';
|
|
}
|
|
|
|
const self = this;
|
|
|
|
const accessTokenPromise = (async () => {
|
|
const response = await axios.post('https://api.amazon.com/auth/o2/token', new URLSearchParams(body).toString(), {
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
}
|
|
}).catch(error => {
|
|
switch (error?.response?.data?.error) {
|
|
case 'invalid_client':
|
|
case 'invalid_grant':
|
|
case 'unauthorized_client':
|
|
self.console.error(error?.response?.data);
|
|
self.log.e(error?.response?.data?.error_description);
|
|
self.storageSettings.values.tokenInfo = undefined;
|
|
self.accessToken = undefined;
|
|
break;
|
|
|
|
case 'authorization_pending':
|
|
self.console.warn(error?.response?.data);
|
|
self.log.w(error?.response?.data?.error_description);
|
|
break;
|
|
|
|
case 'expired_token':
|
|
self.console.warn(error?.response?.data);
|
|
self.log.w(error?.response?.data?.error_description);
|
|
self.accessToken = undefined;
|
|
break;
|
|
|
|
default:
|
|
self.console.error(error?.response?.data);
|
|
}
|
|
throw error;
|
|
});
|
|
// expires_in is 1 hr
|
|
const { access_token, expires_in } = response.data;
|
|
this.storageSettings.values.tokenInfo = response.data;
|
|
setTimeout(() => {
|
|
if (this.accessToken === accessTokenPromise)
|
|
this.accessToken = undefined;
|
|
}, (expires_in - 300) * 1000);
|
|
return access_token;
|
|
})();
|
|
|
|
this.accessToken = accessTokenPromise;
|
|
this.accessToken.catch(() => this.accessToken = undefined);
|
|
return this.accessToken;
|
|
}
|
|
|
|
async alexaAuthorization(request: HttpRequest, response: HttpResponse) {
|
|
const json = JSON.parse(request.body);
|
|
const { grant } = json.directive.payload;
|
|
this.storageSettings.values.tokenInfo = grant;
|
|
this.accessToken = undefined;
|
|
|
|
const self = this;
|
|
let accessToken = await this.getAccessToken().catch(reason => {
|
|
self.console.error(`Failed to handle the AcceptGrant directive because ${reason}`);
|
|
|
|
this.storageSettings.values.tokenInfo = undefined;
|
|
this.accessToken = undefined;
|
|
|
|
response.send(JSON.stringify({
|
|
"event": {
|
|
"header": {
|
|
"namespace": "Alexa.Authorization",
|
|
"name": "ErrorResponse",
|
|
"messageId": createMessageId(),
|
|
"payloadVersion": "3"
|
|
},
|
|
"payload": {
|
|
"type": "ACCEPT_GRANT_FAILED",
|
|
"message": `Failed to handle the AcceptGrant directive because ${reason}`
|
|
}
|
|
}
|
|
}));
|
|
|
|
return undefined;
|
|
});
|
|
|
|
if (accessToken !== undefined) {
|
|
try {
|
|
response.send(JSON.stringify({
|
|
"event": {
|
|
"header": {
|
|
"namespace": "Alexa.Authorization",
|
|
"name": "AcceptGrant.Response",
|
|
"messageId": createMessageId(),
|
|
"payloadVersion": "3"
|
|
},
|
|
"payload": {}
|
|
}
|
|
}));
|
|
} catch (error) {
|
|
this.console.error(`AcceptGrant.Response failed because ${error}`);
|
|
|
|
this.storageSettings.values.tokenInfo = undefined;
|
|
this.accessToken = undefined;
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
createEndpoint(device: ScryptedDevice): DiscoveryEndpoint<any> {
|
|
if (!device)
|
|
return;
|
|
const discovery = isSupported(device);
|
|
if (!discovery)
|
|
return;
|
|
|
|
const ret = Object.assign({
|
|
endpointId: device.id,
|
|
manufacturerName: "Scrypted",
|
|
description: `${device.info?.manufacturer ?? 'Unknown'} ${device.info?.model ?? `device of type ${device.type}`}, connected via Scrypted`,
|
|
friendlyName: device.name,
|
|
additionalAttributes: {
|
|
manufacturer: device.info?.manufacturer || undefined,
|
|
model: device.info?.model || undefined,
|
|
serialNumber: device.info?.serialNumber || undefined,
|
|
firmwareVersion: device.info?.firmware || undefined,
|
|
//softwareVersion: device.info?.version || undefined
|
|
}
|
|
}, discovery);
|
|
|
|
let supportedEndpointHealths = [{
|
|
"name": "connectivity"
|
|
}];
|
|
// {
|
|
// "name": "radioDiagnostics"
|
|
// },
|
|
// {
|
|
// "name": "networkThroughput"
|
|
// }
|
|
|
|
if (device.interfaces.includes(ScryptedInterface.Battery)) {
|
|
supportedEndpointHealths.push({
|
|
"name": "battery"
|
|
})
|
|
}
|
|
|
|
ret.capabilities.push(
|
|
{
|
|
"type": "AlexaInterface",
|
|
"interface": "Alexa.EndpointHealth",
|
|
"version": "3.2" as any,
|
|
"properties": {
|
|
"supported": supportedEndpointHealths,
|
|
"proactivelyReported": true,
|
|
"retrievable": true
|
|
}
|
|
},
|
|
{
|
|
"type": "AlexaInterface",
|
|
"interface": "Alexa",
|
|
"version": "3"
|
|
}
|
|
);
|
|
|
|
//if (device.info?.mac !== undefined)
|
|
// ret.connections.push(
|
|
// {
|
|
// "type": "TCP_IP",
|
|
// "macAddress": device.info?.mac || undefined
|
|
// }
|
|
// );
|
|
|
|
return ret as any;
|
|
}
|
|
|
|
async saveEndpoints(endpoints: DiscoveryEndpoint<any>[]) {
|
|
const existingEndpoints: string[] = this.storageSettings.values.syncedDevices;
|
|
const newEndpoints = endpoints.map(endpoint => endpoint.endpointId);
|
|
const deleted = new Set(existingEndpoints);
|
|
|
|
for (const id of newEndpoints) {
|
|
deleted.delete(id);
|
|
}
|
|
|
|
const all = new Set([...existingEndpoints, ...newEndpoints]);
|
|
|
|
// save all the endpoints
|
|
this.storageSettings.values.syncedDevices = [...all];
|
|
|
|
// delete leftover endpoints
|
|
await this.deleteReport(...deleted);
|
|
|
|
// prune if the delete report completed successfully
|
|
this.storageSettings.values.syncedDevices = newEndpoints;
|
|
}
|
|
|
|
getDiscoveryEndpoints() {
|
|
const endpoints: DiscoveryEndpoint<any>[] = [];
|
|
|
|
for (const id of Object.keys(systemManager.getSystemState())) {
|
|
const device = systemManager.getDeviceById(id);
|
|
|
|
if (!device.mixins?.includes(this.id))
|
|
continue;
|
|
const endpoint = this.createEndpoint(device);
|
|
if (endpoint)
|
|
endpoints.push(endpoint);
|
|
}
|
|
return endpoints;
|
|
}
|
|
|
|
async alexaDiscovery(request: HttpRequest, response: HttpResponse) {
|
|
const endpoints = this.getDiscoveryEndpoints();
|
|
|
|
const ret: DiscoverEvent<any> = {
|
|
event: {
|
|
header: {
|
|
namespace: 'Alexa.Discovery',
|
|
name: 'Discover.Response',
|
|
messageId: createMessageId(),
|
|
payloadVersion: '3',
|
|
},
|
|
payload: {
|
|
endpoints,
|
|
}
|
|
}
|
|
}
|
|
|
|
response.send(JSON.stringify(ret));
|
|
|
|
this.saveEndpoints(endpoints);
|
|
}
|
|
|
|
async onRequest(request: HttpRequest, response: HttpResponse) {
|
|
const { authorization } = request.headers;
|
|
if (!this.validAuths.has(authorization)) {
|
|
try {
|
|
await axios.get('https://home.scrypted.app/_punch/getcookie', {
|
|
headers: {
|
|
'Authorization': authorization,
|
|
}
|
|
});
|
|
this.validAuths.add(authorization);
|
|
}
|
|
catch (e) {
|
|
this.console.error(`request failed due to invalid authorization`, e);
|
|
response.send(e.message, {
|
|
code: 500,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const body = JSON.parse(request.body);
|
|
const { directive } = body;
|
|
const { namespace } = directive.header;
|
|
const handler = this.handlers.get(namespace);
|
|
if (handler)
|
|
return handler.apply(this, arguments);
|
|
|
|
const capHandler = capabilityHandlers.get(namespace);
|
|
if (capHandler) {
|
|
const device = systemManager.getDeviceById(directive.endpoint.endpointId);
|
|
if (!device) {
|
|
response.send('Not Found', {
|
|
code: 404,
|
|
});
|
|
return;
|
|
}
|
|
|
|
return capHandler.apply(this, [request, response, directive, device]);
|
|
}
|
|
|
|
response.send('Not Found', {
|
|
code: 404,
|
|
});
|
|
}
|
|
catch (e) {
|
|
response.send(e.message, {
|
|
code: 500,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export default AlexaPlugin;
|