hassio-plejd/plejd/PlejdApi.js

701 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const axios = require('axios').default;
const fs = require('fs');
const Configuration = require('./Configuration');
const Logger = require('./Logger');
const {
API: {
APP_ID: API_APP_ID,
BASE_URL: API_BASE_URL,
LOGIN_URL: API_LOGIN_URL,
SITE_LIST_URL: API_SITE_LIST_URL,
SITE_DETAILS_URL: API_SITE_DETAILS_URL,
},
DEVICE_TYPES,
} = require('./constants');
const TRAITS = {
NO_LOAD: 0, // 0b0000
NON_DIMMABLE: 9, // 0b1001
DIMMABLE: 11, // 0b1011
DIMMABLE_COLORTEMP: 15, // 0b1111
};
const logger = Logger.getLogger('plejd-api');
class PlejdApi {
/** @private @type {import('types/Configuration').Options} */
config;
/** @private @type {import('DeviceRegistry')} */
deviceRegistry;
/** @private @type {string} */
sessionToken;
/** @private @type {string} */
siteId;
/** @private @type {import('types/ApiSite').ApiSite} */
siteDetails;
/**
* @param {import("./DeviceRegistry")} deviceRegistry
*/
constructor(deviceRegistry) {
this.config = Configuration.getOptions();
this.deviceRegistry = deviceRegistry;
}
async init() {
logger.info('init()');
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;
}
}
}
this.deviceRegistry.setApiSite(this.siteDetails);
this.getDevices();
}
/** @returns {Promise<import('types/ApiSite').CachedSite>} */
// 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');
const cachedCopy = JSON.parse(rawData.toString());
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 {
/** @type {import('types/ApiSite').CachedSite} */
const cachedSite = {
siteId: this.siteId,
siteDetails: this.siteDetails,
sessionToken: this.sessionToken,
dtCache: new Date().toISOString(),
};
const rawData = JSON.stringify(cachedSite);
await fs.promises.writeFile('/data/cachedApiResponse.json', rawData);
} catch (err) {
logger.error('Failed to save cache of api response', err);
}
}
async login() {
logger.info('login()');
logger.info(`logging into ${this.config.site}`);
logger.debug(`sending POST to ${API_BASE_URL}${API_LOGIN_URL}`);
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(
'Server returned status 403, forbidden. Plejd service does this sometimes, despite correct credentials. Possibly throttling logins. Waiting a long time often fixes this.',
);
} 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}`);
}
}
async getSites() {
logger.info('Get all Plejd sites for account...');
logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_LIST_URL}`);
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}`);
}
}
async getSiteDetails() {
logger.info(`Get site details for ${this.siteId}...`);
logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_DETAILS_URL}`);
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));
if (!this.siteDetails.plejdMesh.cryptoKey) {
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}`);
}
}
getDevices() {
logger.info('Getting devices from site details response...');
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');
}
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;
}
return axios.create({
baseURL: API_BASE_URL,
headers,
});
}
// eslint-disable-next-line class-methods-use-this
_getDeviceType(plejdDevice) {
// Type name is also sometimes available in device.hardware.name
// (maybe only when GWY-01 is present?)
switch (parseInt(plejdDevice.hardwareId, 10)) {
case 1:
return {
name: 'DIM-01',
description: '1-channel dimmer LED, 300 VA',
type: 'light',
dimmable: true,
broadcastClicks: false,
};
case 2:
return {
name: 'DIM-02',
description: '2-channel dimmer LED, 2*100 VA',
type: 'light',
dimmable: true,
broadcastClicks: false,
};
case 3:
return {
name: 'CTR-01',
description: '1-channel relay with 0-10V output, 3500 VA',
type: 'light',
dimmable: false,
broadcastClicks: false,
};
// Gateway doesn't show up in devices list in API response
// case 4:
// return {
// name: 'GWY-01',
// description: 'Gateway to enable control via internet and integrations',
// type: 'sensor',
// dimmable: false,
// broadcastClicks: false,
// };
case 5:
return {
name: 'LED-10',
description: '1-channel LED dimmer/driver, 10 W',
type: 'light',
dimmable: true,
broadcastClicks: false,
};
case 6:
return {
name: 'WPH-01',
description:
'Wireless push button, 4 buttons. 2 channels, on and off buttons for each channel',
type: 'device_automation',
dimmable: false,
broadcastClicks: true,
};
case 7:
// Unknown, pre-release (?) version, kept for backwards compatibility. See https://github.com/icanos/hassio-plejd/issues/250
return {
name: 'REL-01',
description: '1 channel relay, 3500 VA',
type: DEVICE_TYPES.SWITCH,
dimmable: false,
broadcastClicks: false,
};
case 8:
return {
name: 'SPR-01',
description: 'Smart plug on/off with relay, 3500 VA',
type: DEVICE_TYPES.SWITCH,
dimmable: false,
broadcastClicks: false,
};
case 10:
return {
name: 'WRT-01',
description: 'Wireless rotary button',
type: 'device_automation',
dimmable: false,
broadcastClicks: true,
};
case 11:
return {
name: 'DIM-01-2P',
description: '1-channel dimmer LED with 2-pole breaking, 300 VA',
type: 'light',
dimmable: true,
broadcastClicks: false,
};
case 12:
return {
name: 'DAL-01',
description: 'Dali broadcast with dimmer and tuneable white support',
type: 'light',
dimmable: true,
colorTemp: true,
broadcastClicks: false,
};
// 13: Non-dimmable generic light
case 14:
return {
name: 'DIM-01',
description: '1-channel dimmer LED, 300 VA ("LC" hardware/chip version)',
type: 'light',
dimmable: true,
broadcastClicks: false,
};
case 15:
return {
name: 'DIM-02',
description: '2-channel dimmer LED, 2*100 VA ("LC" hardware/chip version)',
type: 'light',
dimmable: true,
broadcastClicks: false,
};
case 17:
return {
name: 'REL-01-2P',
description: '1-channel relay with 2-pole 3500 VA',
type: DEVICE_TYPES.SWITCH,
dimmable: false,
broadcastClicks: false,
};
case 18:
return {
name: 'REL-02',
description: '2-channel relay with combined 3500 VA',
type: DEVICE_TYPES.SWITCH,
dimmable: false,
broadcastClicks: false,
};
case 19:
return {
name: 'EXT-01',
description: 'Plejd mesh extender and battery backup',
type: 'extender',
dimmable: false,
broadcastClicks: false,
};
case 20:
return {
// Unknown, pre-release (?) version, kept for backwards compatibility. See https://github.com/icanos/hassio-plejd/issues/250
name: 'SPR-01',
description: 'Smart plug on/off with relay, 3500 VA',
type: 'device_automation',
dimmable: false,
broadcastClicks: false,
};
case 22:
return {
name: 'DIM-01',
description: '1-channel dimmer LED, 300 VA ("LC2" hardware/chip version 2024 Q2)',
type: 'light',
dimmable: true,
broadcastClicks: false,
};
case 36:
return {
name: 'LED-75',
description: '1-channel LED dimmer/driver with tuneable white, 10 W',
type: 'light',
dimmable: true,
colorTemp: true,
broadcastClicks: false,
};
case 40:
return {
name: 'SPD-01',
description: 'Smart plug with dimming capability, trailing edge, 100w',
type: 'light',
dimmable: true,
broadcastClicks: false,
};
case 135:
return {
name: 'OUT-02', // Specifically the hardware id is from a OUT-02-U, with outlet
description:
'OUT-02 is a smart outdoor wall luminaire with tunable white (2,2004,000K), built-in LED and dimmer. The product is available in two versions: OUT-02-U, with built-in outlet (two sockets), and OUT-02, without outlet.',
type: 'light',
dimmable: true,
colorTemp: true,
broadcastClicks: false,
};
case 167:
return {
name: 'DWN-01',
description: 'Smart tunable downlight with a built-in dimmer function, 8W',
type: 'light',
dimmable: true,
colorTemp: true,
broadcastClicks: false,
};
case 199:
return {
name: 'DWN-02',
description: 'Smart tunable downlight with a built-in dimmer function, 8W',
type: 'light',
dimmable: true,
colorTemp: true,
broadcastClicks: false,
};
// PLEASE CREATE AN ISSUE WITH THE HARDWARE ID if you own one of these devices!
// case
// return {
// name: 'OUT-01',
// description: 'Outdoor wall light with built-in LED, 2x5W',
// type: 'light',
// dimmable: true,
// broadcastClicks: false,
// };
default:
throw new Error(
`Unknown device type with hardware id ${plejdDevice.hardwareId}. --- PLEASE POST THIS AND THE NEXT LOG ROWS to https://github.com/icanos/hassio-plejd/issues/ --- `,
);
}
}
/**
* 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
* deviceAddress: {[deviceId]: bleDeviceAddress}
* * `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)
* outputAddress: {[deviceId]: {[output]: bleDeviceAddress}}
* * `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
* inputAddress: {[deviceId]: {[input]: bleDeviceAddress}}
*/
_getPlejdDevices() {
this.deviceRegistry.clearPlejdDevices();
this.siteDetails.devices.forEach((device) => {
this.deviceRegistry.addPhysicalDevice(device);
const outputSettings = this.siteDetails.outputSettings.find(
(x) => x.deviceParseId === device.objectId,
);
if (!outputSettings) {
logger.verbose(
`No outputSettings found for ${device.title} (${device.deviceId}), assuming output 0`,
);
}
const deviceOutput = outputSettings ? outputSettings.output : 0;
const outputAddress = this.siteDetails.outputAddress[device.deviceId];
if (outputAddress) {
const bleOutputAddress = outputAddress[deviceOutput];
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 || device.traits === TRAITS.DIMMABLE_COLORTEMP;
// Alternate approach looks at outputSettings.dimCurve and outputSettings.predefinedLoad
// 1. outputSettings.dimCurve === null: Not dimmable
// 2. outputSettings.dimCurve NOT IN ["NonDimmable", "RelayNormal"]: Dimmable
// 3. outputSettings.predefinedLoad !== null && outputSettings.predefinedLoad.loadType === "DWN": Dimmable
try {
const decodedDeviceType = this._getDeviceType(plejdDevice);
let loadType = decodedDeviceType.type;
if (device.outputType === 'RELAY') {
loadType = 'switch';
} else if (device.outputType === 'LIGHT') {
loadType = 'light';
}
const room = this.siteDetails.rooms.find((x) => x.roomId === device.roomId);
const roomTitle = room ? room.title : undefined;
/** @type {import('types/DeviceRegistry').OutputDevice} */
const outputDevice = {
bleOutputAddress,
colorTemp: null,
colorTempSettings: outputSettings ? outputSettings.colorTemperature : null,
deviceId: device.deviceId,
dimmable,
name: device.title,
output: deviceOutput,
roomId: device.roomId,
roomName: roomTitle,
state: undefined,
type: loadType,
typeDescription: decodedDeviceType.description,
typeName: decodedDeviceType.name,
version: plejdDevice.firmware.version,
uniqueId: uniqueOutputId,
};
this.deviceRegistry.addOutputDevice(outputDevice);
} catch (error) {
logger.error(`Error trying to create output device: ${error}`);
logger.warn(
`device (from API response) when error happened: ${JSON.stringify(device, null, 2)}`,
);
logger.warn(
`plejdDevice (from API response) when error happened: ${JSON.stringify(
plejdDevice,
null,
2,
)}`,
);
}
}
} else {
// The device does not have an output. It can be assumed to be a WPH-01, WRT-01, or EXT-01
// Filter inputSettings for available buttons
const inputSettings = this.siteDetails.inputSettings.filter(
(x) => x.deviceId === device.deviceId,
);
// 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);
try {
const decodedDeviceType = this._getDeviceType(plejdDevice);
if (decodedDeviceType.broadcastClicks) {
/** @type {import('types/DeviceRegistry').InputDevice} */
const inputDevice = {
bleInputAddress,
deviceId: device.deviceId,
name: device.title,
input: input.input,
roomId: device.roomId,
type: decodedDeviceType.type,
typeDescription: decodedDeviceType.description,
typeName: decodedDeviceType.name,
version: plejdDevice.firmware.version,
uniqueId: uniqueInputId,
};
this.deviceRegistry.addInputDevice(inputDevice);
}
} catch (error) {
logger.error(`Error trying to create input device: ${error}`);
logger.warn(
`device (from API response) when error happened: ${JSON.stringify(device, null, 2)}`,
);
logger.warn(
`plejdDevice (from API response) when error happened: ${JSON.stringify(
plejdDevice,
null,
2,
)}`,
);
}
});
}
});
}
_getRoomDevices() {
if (this.config.includeRoomsAsLights) {
logger.debug('includeRoomsAsLights is set to true, adding rooms too.');
this.siteDetails.rooms.forEach((room) => {
const { roomId } = room;
const roomAddress = this.siteDetails.roomAddress[roomId];
const deviceIdsByRoom = this.deviceRegistry.getOutputDeviceIdsByRoomId(roomId);
const dimmable =
deviceIdsByRoom &&
deviceIdsByRoom.some(
(deviceId) => this.deviceRegistry.getOutputDevice(deviceId).dimmable,
);
/** @type {import('types/DeviceRegistry').OutputDevice} */
const newDevice = {
bleOutputAddress: roomAddress,
deviceId: null,
colorTemp: null,
dimmable,
name: room.title,
output: undefined,
roomId: undefined,
roomName: undefined,
state: undefined,
type: 'light',
typeDescription: 'A Plejd room',
typeName: 'Room',
uniqueId: roomId,
version: undefined,
};
this.deviceRegistry.addOutputDevice(newDevice);
});
logger.debug('includeRoomsAsLights done.');
}
}
_getSceneDevices() {
this.deviceRegistry.clearSceneDevices();
// add scenes as switches
const scenes = [...this.siteDetails.scenes];
scenes.forEach((scene) => {
const sceneNum = this.siteDetails.sceneIndex[scene.sceneId];
/** @type {import('types/DeviceRegistry').OutputDevice} */
const newScene = {
bleOutputAddress: sceneNum,
colorTemp: null,
deviceId: undefined,
dimmable: false,
name: scene.title,
output: undefined,
roomId: undefined,
roomName: undefined,
state: false,
type: DEVICE_TYPES.SCENE,
typeDescription: 'A Plejd scene',
typeName: DEVICE_TYPES.SCENE,
version: undefined,
uniqueId: scene.sceneId,
};
this.deviceRegistry.addScene(newScene);
});
}
}
module.exports = PlejdApi;