2021-05-01 19:41:29 +02:00
// @ts-ignore
2021-02-01 21:22:53 +01:00
const axios = require ( 'axios' ) . default ;
2021-02-08 22:23:54 +01:00
const fs = require ( 'fs' ) ;
2021-02-01 21:22:53 +01:00
const Configuration = require ( './Configuration' ) ;
2021-01-21 21:31:37 +01:00
const Logger = require ( './Logger' ) ;
2019-12-04 11:17:06 +01:00
2021-01-22 15:49:02 +01:00
const API _APP _ID = 'zHtVqXt8k4yFyk2QGmgp48D9xZr2G94xWYnF4dak' ;
const API _BASE _URL = 'https://cloud.plejd.com/parse/' ;
const API _LOGIN _URL = 'login' ;
const API _SITE _LIST _URL = 'functions/getSiteList' ;
const API _SITE _DETAILS _URL = 'functions/getSiteById' ;
2019-12-04 11:17:06 +01:00
2021-03-29 12:51:48 +02:00
const TRAITS = {
NO _LOAD : 0 ,
NON _DIMMABLE : 9 ,
DIMMABLE : 11 ,
} ;
2021-01-22 15:49:02 +01:00
const logger = Logger . getLogger ( 'plejd-api' ) ;
2019-12-04 11:17:06 +01:00
2021-02-01 21:22:53 +01:00
class PlejdApi {
2021-03-29 12:51:48 +02:00
/** @private @type {import('types/Configuration').Options} */
2021-02-01 21:22:53 +01:00
config ;
2021-03-29 12:51:48 +02:00
/** @private @type {import('DeviceRegistry')} */
2021-02-01 21:22:53 +01:00
deviceRegistry ;
2021-03-29 12:51:48 +02:00
/** @private @type {string} */
2021-02-01 21:22:53 +01:00
sessionToken ;
2021-03-29 12:51:48 +02:00
/** @private @type {string} */
2021-02-01 21:22:53 +01:00
siteId ;
2021-03-29 12:51:48 +02:00
/** @private @type {import('types/ApiSite').ApiSite} */
2021-02-01 21:22:53 +01:00
siteDetails ;
2019-12-04 11:17:06 +01:00
2021-03-29 12:51:48 +02:00
/ * *
* @ param { import ( "./DeviceRegistry" ) } deviceRegistry
* /
2021-02-01 21:22:53 +01:00
constructor ( deviceRegistry ) {
this . config = Configuration . getOptions ( ) ;
this . deviceRegistry = deviceRegistry ;
}
2019-12-04 11:17:06 +01:00
2021-02-01 21:22:53 +01:00
async init ( ) {
logger . info ( 'init()' ) ;
2021-02-08 22:23:54 +01:00
const cache = await this . getCachedCopy ( ) ;
const cacheExists = cache && cache . siteId && cache . siteDetails && cache . sessionToken ;
logger . debug ( ` Prefer cache? ${ this . config . preferCachedApiResponse } ` ) ;
logger . debug ( ` Cache exists? ${ cacheExists ? ` Yes, created ${ cache . dtCache } ` : 'No' } ` ) ;
if ( this . config . preferCachedApiResponse && cacheExists ) {
logger . info (
` Cache preferred. Skipping api requests and setting api data to response from ${ cache . dtCache } ` ,
) ;
logger . silly ( ` Cached response: ${ JSON . stringify ( cache , null , 2 ) } ` ) ;
this . siteId = cache . siteId ;
this . siteDetails = cache . siteDetails ;
this . sessionToken = cache . sessionToken ;
} else {
try {
await this . login ( ) ;
await this . getSites ( ) ;
await this . getSiteDetails ( ) ;
this . saveCachedCopy ( ) ;
} catch ( err ) {
if ( cacheExists ) {
logger . warn ( 'Failed to get api response, using cached copy instead' ) ;
this . siteId = cache . siteId ;
this . siteDetails = cache . siteDetails ;
this . sessionToken = cache . sessionToken ;
} else {
logger . error ( 'Api request failed, no cached fallback available' , err ) ;
throw err ;
}
}
}
2021-03-31 20:04:45 +02:00
this . deviceRegistry . setApiSite ( this . siteDetails ) ;
2021-02-01 21:22:53 +01:00
this . getDevices ( ) ;
2019-12-04 11:17:06 +01:00
}
2021-03-29 12:51:48 +02:00
/** @returns {Promise<import('types/ApiSite').CachedSite>} */
2021-02-08 22:23:54 +01:00
// eslint-disable-next-line class-methods-use-this
async getCachedCopy ( ) {
logger . info ( 'Getting cached api response from disk' ) ;
try {
const rawData = await fs . promises . readFile ( '/data/cachedApiResponse.json' ) ;
2021-03-29 12:51:48 +02:00
const cachedCopy = JSON . parse ( rawData . toString ( ) ) ;
2021-02-08 22:23:54 +01:00
return cachedCopy ;
} catch ( err ) {
logger . warn ( 'No cached api response could be read. This is normal on the first run' , err ) ;
return null ;
}
}
async saveCachedCopy ( ) {
logger . info ( 'Saving cached copy' ) ;
try {
2021-03-29 12:51:48 +02:00
/** @type {import('types/ApiSite').CachedSite} */
const cachedSite = {
2021-02-08 22:23:54 +01:00
siteId : this . siteId ,
siteDetails : this . siteDetails ,
sessionToken : this . sessionToken ,
dtCache : new Date ( ) . toISOString ( ) ,
2021-03-29 12:51:48 +02:00
} ;
const rawData = JSON . stringify ( cachedSite ) ;
2021-02-08 22:23:54 +01:00
await fs . promises . writeFile ( '/data/cachedApiResponse.json' , rawData ) ;
} catch ( err ) {
logger . error ( 'Failed to save cache of api response' , err ) ;
}
}
2021-02-01 21:22:53 +01:00
async login ( ) {
2021-01-21 21:31:37 +01:00
logger . info ( 'login()' ) ;
2021-02-01 21:22:53 +01:00
logger . info ( ` logging into ${ this . config . site } ` ) ;
2019-12-04 11:17:06 +01:00
2021-02-01 21:22:53 +01:00
logger . debug ( ` sending POST to ${ API _BASE _URL } ${ API _LOGIN _URL } ` ) ;
2019-12-04 11:17:06 +01:00
2021-02-01 21:22:53 +01:00
try {
const response = await this . _getAxiosInstance ( ) . post ( API _LOGIN _URL , {
username : this . config . username ,
password : this . config . password ,
} ) ;
logger . info ( 'got session token response' ) ;
this . sessionToken = response . data . sessionToken ;
if ( ! this . sessionToken ) {
logger . error ( 'No session token received' ) ;
throw new Error ( 'API: No session token received.' ) ;
}
} catch ( error ) {
if ( error . response . status === 400 ) {
logger . error ( 'Server returned status 400. probably invalid credentials, please verify.' ) ;
} else if ( error . response . status === 403 ) {
logger . error (
2021-02-10 10:23:27 +01:00
'Server returned status 403, forbidden. Plejd service does this sometimes, despite correct credentials. Possibly throttling logins. Waiting a long time often fixes this.' ,
2021-02-01 21:22:53 +01:00
) ;
} else {
logger . error ( 'Unable to retrieve session token response: ' , error ) ;
}
logger . verbose ( ` Error details: ${ JSON . stringify ( error . response , null , 2 ) } ` ) ;
throw new Error ( ` API: Unable to retrieve session token response: ${ error } ` ) ;
}
2019-12-04 11:17:06 +01:00
}
2021-02-01 21:22:53 +01:00
async getSites ( ) {
2021-01-21 21:31:37 +01:00
logger . info ( 'Get all Plejd sites for account...' ) ;
2019-12-04 11:17:06 +01:00
2021-02-01 21:22:53 +01:00
logger . debug ( ` sending POST to ${ API _BASE _URL } ${ API _SITE _LIST _URL } ` ) ;
2019-12-04 11:17:06 +01:00
2021-02-01 21:22:53 +01:00
try {
const response = await this . _getAxiosInstance ( ) . post ( API _SITE _LIST _URL ) ;
const sites = response . data . result ;
logger . info (
` Got site list response with ${ sites . length } : ${ sites . map ( ( s ) => s . site . title ) . join ( ', ' ) } ` ,
) ;
logger . silly ( 'All sites found:' ) ;
logger . silly ( JSON . stringify ( sites , null , 2 ) ) ;
const site = sites . find ( ( x ) => x . site . title === this . config . site ) ;
if ( ! site ) {
logger . error ( ` Failed to find a site named ${ this . config . site } ` ) ;
throw new Error ( ` API: Failed to find a site named ${ this . config . site } ` ) ;
}
logger . info ( ` Site found matching configuration name ${ this . config . site } ` ) ;
logger . silly ( JSON . stringify ( site , null , 2 ) ) ;
this . siteId = site . site . siteId ;
} catch ( error ) {
logger . error ( 'error: unable to retrieve list of sites. error: ' , error ) ;
throw new Error ( ` API: unable to retrieve list of sites. error: ${ error } ` ) ;
}
2020-03-13 11:58:15 +01:00
}
2019-12-14 14:10:10 +01:00
2021-02-01 21:22:53 +01:00
async getSiteDetails ( ) {
logger . info ( ` Get site details for ${ this . siteId } ... ` ) ;
2020-03-13 11:58:15 +01:00
2021-02-01 21:22:53 +01:00
logger . debug ( ` sending POST to ${ API _BASE _URL } ${ API _SITE _DETAILS _URL } ` ) ;
2019-12-04 11:17:06 +01:00
2021-02-01 21:22:53 +01:00
try {
const response = await this . _getAxiosInstance ( ) . post ( API _SITE _DETAILS _URL , {
siteId : this . siteId ,
} ) ;
logger . info ( 'got site details response' ) ;
if ( response . data . result . length === 0 ) {
logger . error ( ` No site with ID ${ this . siteId } was found. ` ) ;
throw new Error ( ` API: No site with ID ${ this . siteId } was found. ` ) ;
}
this . siteDetails = response . data . result [ 0 ] ;
logger . info ( ` Site details for site id ${ this . siteId } found ` ) ;
logger . silly ( JSON . stringify ( this . siteDetails , null , 2 ) ) ;
2021-02-08 22:23:54 +01:00
if ( ! this . siteDetails . plejdMesh . cryptoKey ) {
2021-02-01 21:22:53 +01:00
throw new Error ( 'API: No crypto key set for site' ) ;
}
} catch ( error ) {
logger . error ( ` Unable to retrieve site details for ${ this . siteId } . error: ` , error ) ;
throw new Error ( ` API: Unable to retrieve site details. error: ${ error } ` ) ;
}
2019-12-04 11:17:06 +01:00
}
getDevices ( ) {
2021-02-01 21:22:53 +01:00
logger . info ( 'Getting devices from site details response...' ) ;
2019-12-04 11:17:06 +01:00
2021-03-29 12:51:48 +02:00
if ( this . siteDetails . gateways && this . siteDetails . gateways . length ) {
this . siteDetails . gateways . forEach ( ( gwy ) => {
logger . info ( ` Plejd gateway ' ${ gwy . title } ' found on site ` ) ;
} ) ;
} else {
logger . info ( 'No Plejd gateway found on site' ) ;
}
2021-02-01 21:22:53 +01:00
this . _getPlejdDevices ( ) ;
this . _getRoomDevices ( ) ;
this . _getSceneDevices ( ) ;
}
_getAxiosInstance ( ) {
const headers = {
'X-Parse-Application-Id' : API _APP _ID ,
'Content-Type' : 'application/json' ,
} ;
if ( this . sessionToken ) {
headers [ 'X-Parse-Session-Token' ] = this . sessionToken ;
}
2019-12-10 19:15:38 +01:00
2021-02-01 21:22:53 +01:00
return axios . create ( {
baseURL : API _BASE _URL ,
headers ,
} ) ;
}
2019-12-13 17:59:48 +01:00
2021-02-01 21:22:53 +01:00
// eslint-disable-next-line class-methods-use-this
2021-03-29 12:51:48 +02:00
_getDeviceType ( plejdDevice ) {
// Type name is also sometimes available in device.hardware.name
// (maybe only when GWY-01 is present?)
switch ( parseInt ( plejdDevice . hardwareId , 10 ) ) {
2021-02-01 21:22:53 +01:00
case 1 :
case 11 :
return { name : 'DIM-01' , type : 'light' , dimmable : true } ;
case 2 :
return { name : 'DIM-02' , type : 'light' , dimmable : true } ;
case 3 :
return { name : 'CTR-01' , type : 'light' , dimmable : false } ;
case 4 :
return { name : 'GWY-01' , type : 'sensor' , dimmable : false } ;
case 5 :
return { name : 'LED-10' , type : 'light' , dimmable : true } ;
case 6 :
2021-05-01 19:41:29 +02:00
return { name : 'WPH-01' , type : 'device_automation' , dimmable : false } ;
2021-02-01 21:22:53 +01:00
case 7 :
return { name : 'REL-01' , type : 'switch' , dimmable : false } ;
case 8 :
case 9 :
// Unknown
return { name : '-unknown-' , type : 'light' , dimmable : false } ;
case 10 :
return { name : '-unknown-' , type : 'light' , dimmable : false } ;
case 12 :
// Unknown
return { name : '-unknown-' , type : 'light' , dimmable : false } ;
case 13 :
return { name : 'Generic' , type : 'light' , dimmable : false } ;
case 14 :
case 15 :
case 16 :
// Unknown
return { name : '-unknown-' , type : 'light' , dimmable : false } ;
case 17 :
return { name : 'REL-01' , type : 'switch' , dimmable : false } ;
case 18 :
return { name : 'REL-02' , type : 'switch' , dimmable : false } ;
case 19 :
// Unknown
return { name : '-unknown-' , type : 'light' , dimmable : false } ;
case 20 :
return { name : 'SPR-01' , type : 'switch' , dimmable : false } ;
default :
2021-03-29 12:51:48 +02:00
throw new Error ( ` Unknown device type with id ${ plejdDevice . hardwareId } ` ) ;
2021-02-01 21:22:53 +01:00
}
}
2021-03-29 12:51:48 +02:00
/ * *
* Plejd API properties parsed
*
* * ` devices ` - physical Plejd devices , duplicated for devices with multiple outputs
* devices : [ { deviceId , title , objectId , ... } , { ... } ]
* * ` deviceAddress ` - BLE address of each physical device
2021-03-31 20:04:45 +02:00
* deviceAddress : { [ deviceId ] : bleDeviceAddress }
2021-03-29 12:51:48 +02:00
* * ` outputSettings ` - lots of info about load settings , also links devices to output index
* outputSettings : [ { deviceId , output , deviceParseId , ... } ] //deviceParseId === objectId above
* * ` outputAddress ` : BLE address of [ 0 ] main output and [ n ] other output ( loads )
2021-03-31 20:04:45 +02:00
* outputAddress : { [ deviceId ] : { [ output ] : bleDeviceAddress } }
2021-03-29 12:51:48 +02:00
* * ` inputSettings ` - detailed settings for inputs ( buttons , RTR - 01 , ... ) , scenes triggered , ...
* inputSettings : [ { deviceId , input , ... } ] //deviceParseId === objectId above
* * ` inputAddress ` - Links inputs to what BLE device they control , or 255 for unassigned / scene
2021-03-31 20:04:45 +02:00
* inputAddress : { [ deviceId ] : { [ input ] : bleDeviceAddress } }
2021-03-29 12:51:48 +02:00
* /
2021-02-01 21:22:53 +01:00
_getPlejdDevices ( ) {
this . deviceRegistry . clearPlejdDevices ( ) ;
this . siteDetails . devices . forEach ( ( device ) => {
2021-03-31 20:04:45 +02:00
this . deviceRegistry . addPhysicalDevice ( device ) ;
2021-03-29 12:51:48 +02:00
const outputSettings = this . siteDetails . outputSettings . find (
2021-02-01 21:22:53 +01:00
( x ) => x . deviceParseId === device . objectId ,
) ;
2021-03-31 23:28:25 +02:00
if ( ! outputSettings ) {
logger . verbose (
` No outputSettings found for ${ device . title } ( ${ device . deviceId } ), assuming output 0 ` ,
) ;
}
2021-04-01 13:19:02 +02:00
const deviceOutput = outputSettings ? outputSettings . output : 0 ;
2021-04-28 20:07:53 +02:00
const outputAddress = this . siteDetails . outputAddress [ device . deviceId ] ;
2021-03-31 23:28:25 +02:00
2021-04-28 20:07:53 +02:00
if ( outputAddress ) {
const bleOutputAddress = outputAddress [ deviceOutput ] ;
2019-12-10 22:01:12 +01:00
2021-04-28 20:07:53 +02:00
if ( device . traits === TRAITS . NO _LOAD ) {
logger . warn (
` Device ${ device . title } ( ${ device . deviceId } ) has no load configured and will be excluded ` ,
) ;
} else {
const uniqueOutputId = this . deviceRegistry . getUniqueOutputId (
device . deviceId ,
deviceOutput ,
) ;
const plejdDevice = this . siteDetails . plejdDevices . find (
( x ) => x . deviceId === device . deviceId ,
) ;
const dimmable = device . traits === TRAITS . DIMMABLE ;
// dimmable = settings.dimCurve !== 'NonDimmable';
2021-05-03 09:45:57 +02:00
const { name : typeName , type : deviceType } = this . _getDeviceType ( plejdDevice ) ;
let loadType = deviceType ;
if ( device . outputType === 'RELAY' ) {
loadType = 'switch' ;
} else if ( device . outputType === 'LIGHT' ) {
loadType = 'light' ;
}
2021-04-28 20:07:53 +02:00
/** @type {import('types/DeviceRegistry').OutputDevice} */
const outputDevice = {
bleOutputAddress ,
deviceId : device . deviceId ,
dimmable ,
hiddenFromRoomList : device . hiddenFromRoomList ,
hiddenFromIntegrations : device . hiddenFromIntegrations ,
name : device . title ,
output : deviceOutput ,
roomId : device . roomId ,
state : undefined ,
2021-05-03 09:45:57 +02:00
type : loadType ,
2021-04-28 20:07:53 +02:00
typeName ,
version : plejdDevice . firmware . version ,
uniqueId : uniqueOutputId ,
} ;
this . deviceRegistry . addOutputDevice ( outputDevice ) ;
}
2021-05-01 19:41:29 +02:00
} else {
// The device does not have an output. It can be assumed to be a WPH-01 or a WRT-01
// Filter inputSettings for available buttons
const inputSettings = this . siteDetails . inputSettings . filter (
( x ) => x . deviceId === device . deviceId && ( x . buttonType == 'DirectionUp' ) || ( x . buttonType == 'DirectionDown' ) ) ;
// For each found button, register the device as an inputDevice
inputSettings . forEach ( ( input ) => {
const bleInputAddress = this . siteDetails . deviceAddress [ input . deviceId ] ;
logger . verbose (
` Found input device ( ${ input . deviceId } ), with input ${ input . input } having BLE address ( ${ bleInputAddress } ) ` ,
) ;
const plejdDevice = this . siteDetails . plejdDevices . find (
( x ) => x . deviceId === device . deviceId ,
) ;
const uniqueInputId = this . deviceRegistry . getUniqueInputId (
device . deviceId ,
input . input ,
) ;
const { name : typeName , type } = this . _getDeviceType ( plejdDevice ) ;
/** @type {import('types/DeviceRegistry').InputDevice} */
const inputDevice = {
bleOutputAddress : bleInputAddress ,
deviceId : device . deviceId ,
name : device . title ,
input : input . input ,
roomId : device . roomId ,
type ,
typeName ,
version : plejdDevice . firmware . version ,
uniqueId : uniqueInputId ,
} ;
this . deviceRegistry . addInputDevice ( inputDevice ) ;
} ) ;
} ;
2021-02-01 21:22:53 +01:00
} ) ;
}
2019-12-04 11:17:06 +01:00
2021-02-01 21:22:53 +01:00
_getRoomDevices ( ) {
if ( this . config . includeRoomsAsLights ) {
2021-01-21 21:31:37 +01:00
logger . debug ( 'includeRoomsAsLights is set to true, adding rooms too.' ) ;
2021-02-01 21:22:53 +01:00
this . siteDetails . rooms . forEach ( ( room ) => {
2021-01-22 15:49:02 +01:00
const { roomId } = room ;
2021-02-01 21:22:53 +01:00
const roomAddress = this . siteDetails . roomAddress [ roomId ] ;
2019-12-13 17:59:48 +01:00
2021-03-29 12:51:48 +02:00
const deviceIdsByRoom = this . deviceRegistry . getOutputDeviceIdsByRoomId ( roomId ) ;
2021-02-27 16:41:05 +01:00
const dimmable = deviceIdsByRoom
2021-03-29 12:51:48 +02:00
&& deviceIdsByRoom . some (
( deviceId ) => this . deviceRegistry . getOutputDevice ( deviceId ) . dimmable ,
) ;
2021-02-27 16:41:05 +01:00
2021-03-29 12:51:48 +02:00
/** @type {import('types/DeviceRegistry').OutputDevice} */
2019-12-13 17:59:48 +01:00
const newDevice = {
2021-03-31 20:04:45 +02:00
bleOutputAddress : roomAddress ,
2021-03-29 12:51:48 +02:00
deviceId : null ,
dimmable ,
hiddenFromRoomList : false ,
hiddenFromIntegrations : false ,
2019-12-13 17:59:48 +01:00
name : room . title ,
2021-03-29 12:51:48 +02:00
output : undefined ,
roomId ,
state : undefined ,
2019-12-13 17:59:48 +01:00
type : 'light' ,
typeName : 'Room' ,
2021-03-29 12:51:48 +02:00
uniqueId : roomId ,
version : undefined ,
2019-12-13 17:59:48 +01:00
} ;
2020-01-17 15:00:54 +00:00
2021-03-29 12:51:48 +02:00
this . deviceRegistry . addOutputDevice ( newDevice ) ;
2021-02-01 21:22:53 +01:00
} ) ;
2021-01-21 21:31:37 +01:00
logger . debug ( 'includeRoomsAsLights done.' ) ;
2019-12-13 17:59:48 +01:00
}
2021-02-01 21:22:53 +01:00
}
2019-12-13 17:59:48 +01:00
2021-02-01 21:22:53 +01:00
_getSceneDevices ( ) {
2021-03-31 20:04:45 +02:00
this . deviceRegistry . clearSceneDevices ( ) ;
2020-02-29 15:54:08 +00:00
// add scenes as switches
2021-02-01 21:22:53 +01:00
const scenes = this . siteDetails . scenes . filter ( ( x ) => x . hiddenFromSceneList === false ) ;
2020-02-29 15:54:08 +00:00
2021-02-01 21:22:53 +01:00
scenes . forEach ( ( scene ) => {
const sceneNum = this . siteDetails . sceneIndex [ scene . sceneId ] ;
2021-03-29 12:51:48 +02:00
/** @type {import('types/DeviceRegistry').OutputDevice} */
2020-02-29 15:54:08 +00:00
const newScene = {
2021-03-31 20:04:45 +02:00
bleOutputAddress : sceneNum ,
2021-03-29 12:51:48 +02:00
deviceId : undefined ,
dimmable : false ,
hiddenFromSceneList : scene . hiddenFromSceneList ,
2020-02-29 15:54:08 +00:00
name : scene . title ,
2021-03-29 12:51:48 +02:00
output : undefined ,
roomId : undefined ,
state : false ,
2021-04-01 13:19:02 +02:00
type : 'scene' ,
2020-02-29 15:54:08 +00:00
typeName : 'Scene' ,
2021-03-29 12:51:48 +02:00
version : undefined ,
uniqueId : scene . sceneId ,
2020-02-29 15:54:08 +00:00
} ;
2021-02-01 21:22:53 +01:00
this . deviceRegistry . addScene ( newScene ) ;
} ) ;
2019-12-13 14:13:00 +01:00
}
2019-12-04 11:17:06 +01:00
}
2021-01-22 15:49:02 +01:00
module . exports = PlejdApi ;