Adjust code to airbnb style guide, including eslint rules and prettier configuration for code base

This commit is contained in:
Victor Hagelbäck 2021-01-22 15:49:02 +01:00
parent 1b55cabf63
commit 281acd6ad8
23 changed files with 919 additions and 2225 deletions

1
plejd/.eslintignore Normal file
View file

@ -0,0 +1 @@
node_modules

39
plejd/.eslintrc.js Normal file
View file

@ -0,0 +1,39 @@
// const path = require('path');
// {
// "extends": ["airbnb-base", "plugin:prettier/recommended"],
// "plugins": ["prettier"],
// "rules": {
// "prettier/prettier": "error"
// }
// }
// eslint-disable-next-line no-undef
module.exports = {
root: true,
extends: [
'airbnb-base',
// 'prettier',
// 'plugin:prettier/recommended'
],
parser: 'babel-eslint',
// plugins: ['prettier'],
rules: getRules(),
};
function getRules() {
return {
// Allows modification of properties passed to functions.
// Notably used in array.forEach(e => {e.prop = val;})
'no-param-reassign': ['error', { props: false }],
// ++ operator widely used
'no-plusplus': ['off'],
// Hassio-Plejd team feals _ prefix is great for "private" variables.
// They will still be available for use from the outside
'no-underscore-dangle': ['off'],
// Allow function hoisting to improve code readability
'no-use-before-define': ['error', { functions: false, classes: true, variables: true }],
// Allow direct indexing of arrays only (array[0])
'prefer-destructuring': ['error', { array: false, object: true }],
};
}

View file

@ -1,15 +1,15 @@
const fs = require('fs');
class Configuration {
static _config = null;
static getConfiguration() {
if (!Configuration._config) {
const rawData = fs.readFileSync('/data/options.json');
Configuration._config = JSON.parse(rawData);
}
return Configuration._config;
static _config = null;
static getConfiguration() {
if (!Configuration._config) {
const rawData = fs.readFileSync('/data/options.json');
Configuration._config = JSON.parse(rawData);
}
return Configuration._config;
}
}
module.exports = Configuration;

View file

@ -1,115 +1,116 @@
const winston = require("winston");
const { colorize, combine, label, printf, timestamp } = winston.format;
const winston = require('winston');
const Configuration = require("./Configuration");
const {
colorize, combine, label, printf, timestamp,
} = winston.format;
const LEVELS = ["error", "warn", "info", "debug", "verbose", "silly"];
const Configuration = require('./Configuration');
const logFormat = printf(info => {
if(info.stack) {
return `${info.timestamp} ${info.level} [${info.label}] ${info.message}\n${info.stack}`;
}
return `${info.timestamp} ${info.level} [${info.label}] ${info.message}`;
const LEVELS = ['error', 'warn', 'info', 'debug', 'verbose', 'silly'];
const logFormat = printf((info) => {
if (info.stack) {
return `${info.timestamp} ${info.level} [${info.label}] ${info.message}\n${info.stack}`;
}
return `${info.timestamp} ${info.level} [${info.label}] ${info.message}`;
});
/** Winston-based logger */
class Logger {
constructor () {
throw new Error("Please call createLogger instead");
}
constructor() {
throw new Error('Please call createLogger instead');
}
/** Created logger will follow Winston createLogger, but
* - add module name to logger
* - swap debug/verbose levels and omit http to mimic HA standard
* Levels (in order): error, warn, info, debug, verbose, silly
* */
static getLogger(moduleName) {
const config = Configuration.getConfiguration();
const level = (config.logLevel && LEVELS.find(l => l.startsWith(config.logLevel[0].toLowerCase()))) || "info";
/** Created logger will follow Winston createLogger, but
* - add module name to logger
* - swap debug/verbose levels and omit http to mimic HA standard
* Levels (in order): error, warn, info, debug, verbose, silly
* */
static getLogger(moduleName) {
const config = Configuration.getConfiguration();
// eslint-disable-next-line max-len
const level = (config.logLevel && LEVELS.find((l) => l.startsWith(config.logLevel[0].toLowerCase())))
|| 'info';
const logger = winston.createLogger({
format: combine(
winston.format(info => {
switch (info.level) {
case "warning":
info.level = "WRN";
break;
case "verbose":
info.level = "VRB";
break;
case "debug":
info.level = "DBG";
break;
default:
info.level = info.level.substring(0,3).toUpperCase()
}
return info;
})(),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
label({ label: moduleName}),
colorize(),
logFormat,
),
level: level,
levels: Logger.logLevels().levels,
transports: [
new winston.transports.Console(),
]
});
winston.addColors(Logger.logLevels().colors);
if (moduleName === "plejd-main") {
logger.log(level, `Log level set to ${level}`);
}
return logger;
}
static logLevels() {
// Default (npm) levels
// levels = {
// error: 0,
// warn: 1,
// info: 2,
// http: 3,
// verbose: 4,
// debug: 5,
// silly: 6
// }
// colors = {
// error: 'red',
// warn: 'yellow',
// info: 'green',
// http: 'green',
// verbose: 'cyan',
// debug: 'blue',
// silly: 'magenta'
// };
// Mimic HA standard below
// Debug/verbose swapped compared to npm levels, http omitted
return {
levels: {
error: 0,
warn: 1,
info: 2,
debug: 3,
verbose: 4,
silly: 6
},
colors: {
error: 'red',
warn: 'yellow',
info: 'green',
debug: 'cyan',
verbose: 'blue',
silly: 'magenta'
const logger = winston.createLogger({
format: combine(
winston.format((info) => {
switch (info.level) {
case 'warning':
info.level = 'WRN';
break;
case 'verbose':
info.level = 'VRB';
break;
case 'debug':
info.level = 'DBG';
break;
default:
info.level = info.level.substring(0, 3).toUpperCase();
}
};
return info;
})(),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
label({ label: moduleName }),
colorize(),
logFormat,
),
level,
levels: Logger.logLevels().levels,
transports: [new winston.transports.Console()],
});
winston.addColors(Logger.logLevels().colors);
if (moduleName === 'plejd-main') {
logger.log(level, `Log level set to ${level}`);
}
return logger;
}
static logLevels() {
// Default (npm) levels
// levels = {
// error: 0,
// warn: 1,
// info: 2,
// http: 3,
// verbose: 4,
// debug: 5,
// silly: 6
// }
// colors = {
// error: 'red',
// warn: 'yellow',
// info: 'green',
// http: 'green',
// verbose: 'cyan',
// debug: 'blue',
// silly: 'magenta'
// };
// Mimic HA standard below
// Debug/verbose swapped compared to npm levels, http omitted
return {
levels: {
error: 0,
warn: 1,
info: 2,
debug: 3,
verbose: 4,
silly: 6,
},
colors: {
error: 'red',
warn: 'yellow',
info: 'green',
debug: 'cyan',
verbose: 'blue',
silly: 'magenta',
},
};
}
}
module.exports = Logger;

View file

@ -4,7 +4,7 @@ const Logger = require('./Logger');
const startTopic = 'hass/status';
const logger = Logger.getLogger("plejd-mqtt");
const logger = Logger.getLogger('plejd-mqtt');
// #region discovery
@ -12,14 +12,13 @@ const discoveryPrefix = 'homeassistant';
const nodeId = 'plejd';
const getSubscribePath = () => `${discoveryPrefix}/+/${nodeId}/#`;
const getPath = ({ id, type }) =>
`${discoveryPrefix}/${type}/${nodeId}/${id}`;
const getConfigPath = plug => `${getPath(plug)}/config`;
const getStateTopic = plug => `${getPath(plug)}/state`;
const getCommandTopic = plug => `${getPath(plug)}/set`;
const getSceneEventTopic = () => `plejd/event/scene`;
const getPath = ({ id, type }) => `${discoveryPrefix}/${type}/${nodeId}/${id}`;
const getConfigPath = (plug) => `${getPath(plug)}/config`;
const getStateTopic = (plug) => `${getPath(plug)}/state`;
const getCommandTopic = (plug) => `${getPath(plug)}/set`;
const getSceneEventTopic = () => 'plejd/event/scene';
const getDiscoveryPayload = device => ({
const getDiscoveryPayload = (device) => ({
schema: 'json',
name: device.name,
unique_id: `light.plejd.${device.name.toLowerCase().replace(/ /g, '')}`,
@ -28,26 +27,26 @@ const getDiscoveryPayload = device => ({
optimistic: false,
brightness: `${device.dimmable}`,
device: {
identifiers: device.serialNumber + '_' + device.id,
identifiers: `${device.serialNumber}_${device.id}`,
manufacturer: 'Plejd',
model: device.typeName,
name: device.name,
sw_version: device.version
}
sw_version: device.version,
},
});
const getSwitchPayload = device => ({
const getSwitchPayload = (device) => ({
name: device.name,
state_topic: getStateTopic(device),
command_topic: getCommandTopic(device),
optimistic: false,
device: {
identifiers: device.serialNumber + '_' + device.id,
identifiers: `${device.serialNumber}_${device.id}`,
manufacturer: 'Plejd',
model: device.typeName,
name: device.name,
sw_version: device.version
}
sw_version: device.version,
},
});
// #endregion
@ -64,12 +63,12 @@ class MqttClient extends EventEmitter {
}
init() {
logger.info("Initializing MQTT connection for Plejd addon");
logger.info('Initializing MQTT connection for Plejd addon');
const self = this;
this.client = mqtt.connect(this.mqttBroker, {
username: this.username,
password: this.password
password: this.password,
});
this.client.on('connect', () => {
@ -88,7 +87,6 @@ class MqttClient extends EventEmitter {
logger.error('Unable to subscribe to control topics');
}
});
});
this.client.on('close', () => {
@ -97,21 +95,19 @@ class MqttClient extends EventEmitter {
});
this.client.on('message', (topic, message) => {
//const command = message.toString();
const command = message.toString().substring(0, 1) === '{'
// const command = message.toString();
const command = message.toString().substring(0, 1) === '{'
? JSON.parse(message.toString())
: message.toString();
if (topic === startTopic) {
logger.info('Home Assistant has started. lets do discovery.');
self.emit('connected');
}
else if (topic.includes('set')) {
} else if (topic.includes('set')) {
logger.verbose(`Got mqtt command on ${topic} - ${message}`);
const device = self.devices.find(x => getCommandTopic(x) === topic);
const device = self.devices.find((x) => getCommandTopic(x) === topic);
self.emit('stateChanged', device, command);
}
else {
} else {
logger.verbose(`Warning: Got unrecognized mqtt command on ${topic} - ${message}`);
}
});
@ -130,20 +126,19 @@ class MqttClient extends EventEmitter {
devices.forEach((device) => {
logger.debug(`Sending discovery for ${device.name}`);
let payload = device.type === 'switch' ? getSwitchPayload(device) : getDiscoveryPayload(device);
logger.info(`Discovered ${device.type} (${device.typeName}) named ${device.name} with PID ${device.id}.`);
const payload = device.type === 'switch' ? getSwitchPayload(device) : getDiscoveryPayload(device);
logger.info(
`Discovered ${device.type} (${device.typeName}) named ${device.name} with PID ${device.id}.`,
);
self.deviceMap[device.id] = payload.unique_id;
self.client.publish(
getConfigPath(device),
JSON.stringify(payload)
);
self.client.publish(getConfigPath(device), JSON.stringify(payload));
});
}
updateState(deviceId, data) {
const device = this.devices.find(x => x.id === deviceId);
const device = this.devices.find((x) => x.id === deviceId);
if (!device) {
logger.warn(`Unknown device id ${deviceId} - not handled by us.`);
@ -155,36 +150,28 @@ class MqttClient extends EventEmitter {
if (device.type === 'switch') {
payload = data.state === 1 ? 'ON' : 'OFF';
}
else {
} else {
if (device.dimmable) {
payload = {
state: data.state === 1 ? 'ON' : 'OFF',
brightness: data.brightness
}
}
else {
brightness: data.brightness,
};
} else {
payload = {
state: data.state === 1 ? 'ON' : 'OFF'
}
state: data.state === 1 ? 'ON' : 'OFF',
};
}
payload = JSON.stringify(payload);
}
this.client.publish(
getStateTopic(device),
payload
);
this.client.publish(getStateTopic(device), payload);
}
sceneTriggered(scene) {
logger.verbose(`Scene triggered: ${scene}`);
this.client.publish(
getSceneEventTopic(),
JSON.stringify({ scene: scene })
);
this.client.publish(getSceneEventTopic(), JSON.stringify({ scene }));
}
}
module.exports = { MqttClient };
module.exports = MqttClient;

View file

@ -2,13 +2,13 @@ const axios = require('axios');
const EventEmitter = require('events');
const Logger = require('./Logger');
API_APP_ID = 'zHtVqXt8k4yFyk2QGmgp48D9xZr2G94xWYnF4dak';
API_BASE_URL = 'https://cloud.plejd.com/parse/';
API_LOGIN_URL = 'login';
API_SITE_LIST_URL = 'functions/getSiteList';
API_SITE_DETAILS_URL = 'functions/getSiteById';
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';
const logger = Logger.getLogger("plejd-api");
const logger = Logger.getLogger('plejd-api');
class PlejdApi extends EventEmitter {
constructor(siteName, username, password, includeRoomsAsLights) {
@ -25,25 +25,24 @@ class PlejdApi extends EventEmitter {
login() {
logger.info('login()');
logger.info('logging into ' + this.siteName);
logger.info(`logging into ${this.siteName}`);
const self = this;
const instance = axios.create({
baseURL: API_BASE_URL,
headers: {
'X-Parse-Application-Id': API_APP_ID,
'Content-Type': 'application/json'
}
'Content-Type': 'application/json',
},
});
return new Promise((resolve, reject) => {
logger.debug('sending POST to ' + API_BASE_URL + API_LOGIN_URL);
logger.debug(`sending POST to ${API_BASE_URL}${API_LOGIN_URL}`);
instance.post(
API_LOGIN_URL,
{
'username': this.username,
'password': this.password
instance
.post(API_LOGIN_URL, {
username: this.username,
password: this.password,
})
.then((response) => {
logger.info('got session token response');
@ -51,20 +50,21 @@ class PlejdApi extends EventEmitter {
if (!self.sessionToken) {
logger.error('No session token received');
reject('no session token received.');
reject(new Error('no session token received.'));
}
resolve();
})
.catch((error) => {
if (error.response.status === 400) {
logger.error('Server returned status 400. probably invalid credentials, please verify.');
}
else {
logger.error(
'Server returned status 400. probably invalid credentials, please verify.',
);
} else {
logger.error('Unable to retrieve session token response: ', error);
}
reject('unable to retrieve session token response: ' + error);
reject(new Error(`unable to retrieve session token response: ${error}`));
});
});
}
@ -78,21 +78,22 @@ class PlejdApi extends EventEmitter {
headers: {
'X-Parse-Application-Id': API_APP_ID,
'X-Parse-Session-Token': this.sessionToken,
'Content-Type': 'application/json'
}
'Content-Type': 'application/json',
},
});
return new Promise((resolve, reject) => {
logger.debug('sending POST to ' + API_BASE_URL + API_SITE_LIST_URL);
logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_LIST_URL}`);
instance.post(API_SITE_LIST_URL)
instance
.post(API_SITE_LIST_URL)
.then((response) => {
logger.info('got site list response');
const site = response.data.result.find(x => x.site.title == self.siteName);
const site = response.data.result.find((x) => x.site.title === self.siteName);
if (!site) {
logger.error('error: failed to find a site named ' + self.siteName);
reject('failed to find a site named ' + self.siteName);
logger.error(`error: failed to find a site named ${self.siteName}`);
reject(new Error(`failed to find a site named ${self.siteName}`));
return;
}
@ -100,13 +101,13 @@ class PlejdApi extends EventEmitter {
})
.catch((error) => {
logger.error('error: unable to retrieve list of sites. error: ', error);
return reject('plejd-api: unable to retrieve list of sites. error: ' + error);
return reject(new Error(`plejd-api: unable to retrieve list of sites. error: ${error}`));
});
});
}
getSite(siteId) {
logger.info(`Get site details...`);
logger.info('Get site details...');
const self = this;
const instance = axios.create({
@ -114,19 +115,20 @@ class PlejdApi extends EventEmitter {
headers: {
'X-Parse-Application-Id': API_APP_ID,
'X-Parse-Session-Token': this.sessionToken,
'Content-Type': 'application/json'
}
'Content-Type': 'application/json',
},
});
return new Promise((resolve, reject) => {
logger.debug('sending POST to ' + API_BASE_URL + API_SITE_DETAILS_URL);
logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_DETAILS_URL}`);
instance.post(API_SITE_DETAILS_URL, { siteId: siteId })
instance
.post(API_SITE_DETAILS_URL, { siteId })
.then((response) => {
logger.info('got site details response');
if (response.data.result.length === 0) {
const msg = 'no site with ID ' + siteId + ' was found.';
logger.error('error: ' + msg);
const msg = `no site with ID ${siteId} was found.`;
logger.error(`error: ${msg}`);
reject(msg);
return;
}
@ -138,13 +140,13 @@ class PlejdApi extends EventEmitter {
})
.catch((error) => {
logger.error('error: unable to retrieve the crypto key. error: ', error);
return reject('plejd-api: unable to retrieve the crypto key. error: ' + error);
return reject(new Error(`plejd-api: unable to retrieve the crypto key. error: ${error}`));
});
});
}
getDevices() {
let devices = [];
const devices = [];
logger.verbose(JSON.stringify(this.site));
@ -152,9 +154,9 @@ class PlejdApi extends EventEmitter {
for (let i = 0; i < this.site.devices.length; i++) {
const device = this.site.devices[i];
const deviceId = device.deviceId;
const { deviceId } = device;
const settings = this.site.outputSettings.find(x => x.deviceParseId == device.objectId);
const settings = this.site.outputSettings.find((x) => x.deviceParseId === device.objectId);
let deviceNum = this.site.deviceAddress[deviceId];
if (settings) {
@ -162,22 +164,24 @@ class PlejdApi extends EventEmitter {
deviceNum = outputs[settings.output];
}
// check if device is dimmable
const plejdDevice = this.site.plejdDevices.find(x => x.deviceId == deviceId);
let { name, type, dimmable } = this._getDeviceType(plejdDevice.hardwareId);
// check if device is dimmable
const plejdDevice = this.site.plejdDevices.find((x) => x.deviceId === deviceId);
const deviceType = this._getDeviceType(plejdDevice.hardwareId);
const { name, type } = deviceType;
let { dimmable } = deviceType;
if (settings) {
dimmable = settings.dimCurve != 'NonDimmable';
dimmable = settings.dimCurve !== 'NonDimmable';
}
const newDevice = {
id: deviceNum,
name: device.title,
type: type,
type,
typeName: name,
dimmable: dimmable,
dimmable,
version: plejdDevice.firmware.version,
serialNumber: plejdDevice.deviceId
serialNumber: plejdDevice.deviceId,
};
if (newDevice.typeName === 'WPH-01') {
@ -189,45 +193,41 @@ class PlejdApi extends EventEmitter {
let switchDevice = {
id: first,
name: device.title + ' knapp vä',
type: type,
name: `${device.title} knapp vä`,
type,
typeName: name,
dimmable: dimmable,
dimmable,
version: plejdDevice.firmware.version,
serialNumber: plejdDevice.deviceId
serialNumber: plejdDevice.deviceId,
};
if (roomDevices[device.roomId]) {
roomDevices[device.roomId].push(switchDevice);
}
else {
} else {
roomDevices[device.roomId] = [switchDevice];
}
devices.push(switchDevice);
switchDevice = {
id: second,
name: device.title + ' knapp hö',
type: type,
name: `${device.title} knapp hö`,
type,
typeName: name,
dimmable: dimmable,
dimmable,
version: plejdDevice.firmware.version,
serialNumber: plejdDevice.deviceId
serialNumber: plejdDevice.deviceId,
};
if (roomDevices[device.roomId]) {
roomDevices[device.roomId].push(switchDevice);
}
else {
} else {
roomDevices[device.roomId] = [switchDevice];
}
devices.push(switchDevice);
}
else {
} else {
if (roomDevices[device.roomId]) {
roomDevices[device.roomId].push(newDevice);
}
else {
} else {
roomDevices[device.roomId] = [newDevice];
}
@ -239,7 +239,7 @@ class PlejdApi extends EventEmitter {
logger.debug('includeRoomsAsLights is set to true, adding rooms too.');
for (let i = 0; i < this.site.rooms.length; i++) {
const room = this.site.rooms[i];
const roomId = room.roomId;
const { roomId } = room;
const roomAddress = this.site.roomAddress[roomId];
const newDevice = {
@ -247,7 +247,7 @@ class PlejdApi extends EventEmitter {
name: room.title,
type: 'light',
typeName: 'Room',
dimmable: roomDevices[roomId].filter(x => x.dimmable).length > 0
dimmable: roomDevices[roomId].filter((x) => x.dimmable).length > 0,
};
devices.push(newDevice);
@ -256,8 +256,9 @@ class PlejdApi extends EventEmitter {
}
// add scenes as switches
const scenes = this.site.scenes.filter(x => x.hiddenFromSceneList == false);
const scenes = this.site.scenes.filter((x) => x.hiddenFromSceneList === false);
// eslint-disable-next-line no-restricted-syntax
for (const scene of scenes) {
const sceneNum = this.site.sceneIndex[scene.sceneId];
const newScene = {
@ -267,7 +268,7 @@ class PlejdApi extends EventEmitter {
typeName: 'Scene',
dimmable: false,
version: '1.0',
serialNumber: scene.objectId
serialNumber: scene.objectId,
};
devices.push(newScene);
@ -276,50 +277,53 @@ class PlejdApi extends EventEmitter {
return devices;
}
// eslint-disable-next-line class-methods-use-this
_getDeviceType(hardwareId) {
switch (parseInt(hardwareId)) {
switch (parseInt(hardwareId, 10)) {
case 1:
case 11:
return { name: "DIM-01", type: 'light', dimmable: true };
return { name: 'DIM-01', type: 'light', dimmable: true };
case 2:
return { name: "DIM-02", type: 'light', dimmable: true };
return { name: 'DIM-02', type: 'light', dimmable: true };
case 3:
return { name: "CTR-01", type: 'light', dimmable: false };
return { name: 'CTR-01', type: 'light', dimmable: false };
case 4:
return { name: "GWY-01", type: 'sensor', dimmable: false };
return { name: 'GWY-01', type: 'sensor', dimmable: false };
case 5:
return { name: "LED-10", type: 'light', dimmable: true };
return { name: 'LED-10', type: 'light', dimmable: true };
case 6:
return { name: "WPH-01", type: 'switch', dimmable: false };
return { name: 'WPH-01', type: 'switch', dimmable: false };
case 7:
return { name: "REL-01", type: 'switch', dimmable: false };
return { name: 'REL-01', type: 'switch', dimmable: false };
case 8:
case 9:
// Unknown
return { name: "-unknown-", type: 'light', dimmable: false };
return { name: '-unknown-', type: 'light', dimmable: false };
case 10:
return { name: "-unknown-", type: 'light', dimmable: false };
return { name: '-unknown-', type: 'light', dimmable: false };
case 12:
// Unknown
return { name: "-unknown-", type: 'light', dimmable: false };
return { name: '-unknown-', type: 'light', dimmable: false };
case 13:
return { name: "Generic", type: 'light', dimmable: false };
return { name: 'Generic', type: 'light', dimmable: false };
case 14:
case 15:
case 16:
// Unknown
return { name: "-unknown-", type: 'light', dimmable: false };
return { name: '-unknown-', type: 'light', dimmable: false };
case 17:
return { name: "REL-01", type: 'switch', dimmable: false };
return { name: 'REL-01', type: 'switch', dimmable: false };
case 18:
return { name: "REL-02", type: 'switch', dimmable: false };
return { name: 'REL-02', type: 'switch', dimmable: false };
case 19:
// Unknown
return { name: "-unknown-", type: 'light', dimmable: false };
return { name: '-unknown-', type: 'light', dimmable: false };
case 20:
return { name: "SPR-01", type: 'switch', dimmable: false };
return { name: 'SPR-01', type: 'switch', dimmable: false };
default:
throw new Error(`Unknown device type with id ${hardwareId}`);
}
}
}
module.exports = { PlejdApi };
module.exports = PlejdApi;

View file

@ -4,8 +4,7 @@ const xor = require('buffer-xor');
const EventEmitter = require('events');
const Logger = require('./Logger');
const logger = Logger.getLogger("plejd-ble");
const logger = Logger.getLogger('plejd-ble');
// UUIDs
const PLEJD_SERVICE = '31ba0001-6085-4726-be45-040c957391b5';
@ -32,7 +31,7 @@ const MAX_TRANSITION_STEPS_PER_SECOND = 5; // Could be made a setting
const MAX_RETRY_COUNT = 5; // Could be made a setting
class PlejdService extends EventEmitter {
constructor(cryptoKey, devices, sceneManager, connectionTimeout, writeQueueWaitTime, keepAlive = false) {
constructor(cryptoKey, devices, sceneManager, connectionTimeout, writeQueueWaitTime) {
super();
logger.info('Starting Plejd BLE, resetting all device states.');
@ -59,7 +58,7 @@ class PlejdService extends EventEmitter {
lastData: null,
lastDataProperties: null,
auth: null,
ping: null
ping: null,
};
this.bus = dbus.systemBus();
@ -82,7 +81,7 @@ class PlejdService extends EventEmitter {
lastData: null,
lastDataProperties: null,
auth: null,
ping: null
ping: null,
};
clearInterval(this.pingRef);
@ -94,18 +93,20 @@ class PlejdService extends EventEmitter {
// We need to find the ble interface which implements the Adapter1 interface
const managedObjects = await this.objectManager.GetManagedObjects();
let result = await this._getInterface(managedObjects, BLUEZ_ADAPTER_ID);
const result = await this._getInterface(managedObjects, BLUEZ_ADAPTER_ID);
if (result) {
this.adapter = result[1];
}
if (!this.adapter) {
logger.error('unable to find a bluetooth adapter that is compatible.');
return;
logger.error('Unable to find a bluetooth adapter that is compatible.');
return Promise.reject(new Error('Unable to find a bluetooth adapter that is compatible.'));
}
for (let path of Object.keys(managedObjects)) {
// eslint-disable-next-line no-restricted-syntax
for (const path of Object.keys(managedObjects)) {
/* eslint-disable no-await-in-loop */
const interfaces = Object.keys(managedObjects[path]);
if (interfaces.indexOf(BLUEZ_DEVICE_ID) > -1) {
@ -115,70 +116,81 @@ class PlejdService extends EventEmitter {
const connected = managedObjects[path][BLUEZ_DEVICE_ID].Connected.value;
if (connected) {
logger.info('disconnecting ' + path);
logger.info(`disconnecting ${path}`);
await device.Disconnect();
}
await this.adapter.RemoveDevice(path);
}
/* eslint-enable no-await-in-loop */
}
this.objectManager.on('InterfacesAdded', this.onInterfacesAdded.bind(this));
this.adapter.SetDiscoveryFilter({
'UUIDs': new dbus.Variant('as', [PLEJD_SERVICE]),
'Transport': new dbus.Variant('s', 'le')
UUIDs: new dbus.Variant('as', [PLEJD_SERVICE]),
Transport: new dbus.Variant('s', 'le'),
});
try {
await this.adapter.StartDiscovery();
} catch (err) {
logger.error('failed to start discovery. Make sure no other add-on is currently scanning.');
return;
logger.error('Failed to start discovery. Make sure no other add-on is currently scanning.');
return Promise.reject(
new Error('Failed to start discovery. Make sure no other add-on is currently scanning.'),
);
}
return new Promise(resolve =>
setTimeout(() => resolve(
this._internalInit().catch((err) => { logger.error('InternalInit exception! Will rethrow.', err); throw err; })
), this.connectionTimeout * 1000
)
);
return new Promise((resolve) => setTimeout(
() => resolve(
this._internalInit().catch((err) => {
logger.error('InternalInit exception! Will rethrow.', err);
throw err;
}),
),
this.connectionTimeout * 1000,
));
}
async _internalInit() {
logger.debug(`Got ${this.bleDevices.length} device(s).`);
// eslint-disable-next-line no-restricted-syntax
for (const plejd of this.bleDevices) {
logger.debug(`Inspecting ${plejd['path']}`);
/* eslint-disable no-await-in-loop */
logger.debug(`Inspecting ${plejd.path}`);
try {
const proxyObject = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, plejd['path']);
const proxyObject = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, plejd.path);
const device = await proxyObject.getInterface(BLUEZ_DEVICE_ID);
const properties = await proxyObject.getInterface(DBUS_PROP_INTERFACE);
plejd['rssi'] = (await properties.Get(BLUEZ_DEVICE_ID, 'RSSI')).value;
plejd['instance'] = device;
plejd.rssi = (await properties.Get(BLUEZ_DEVICE_ID, 'RSSI')).value;
plejd.instance = device;
const segments = plejd['path'].split('/');
const segments = plejd.path.split('/');
let fixedPlejdPath = segments[segments.length - 1].replace('dev_', '');
fixedPlejdPath = fixedPlejdPath.replace(/_/g, '');
plejd['device'] = this.devices.find(x => x.serialNumber === fixedPlejdPath);
plejd.device = this.devices.find((x) => x.serialNumber === fixedPlejdPath);
logger.debug(`Discovered ${plejd['path']} with rssi ${plejd['rssi']}`);
logger.debug(`Discovered ${plejd.path} with rssi ${plejd.rssi}`);
} catch (err) {
logger.error(`Failed inspecting ${plejd['path']}. `, err);
logger.error(`Failed inspecting ${plejd.path}. `, err);
}
/* eslint-enable no-await-in-loop */
}
const sortedDevices = this.bleDevices.sort((a, b) => b['rssi'] - a['rssi']);
const sortedDevices = this.bleDevices.sort((a, b) => b.rssi - a.rssi);
let connectedDevice = null;
// eslint-disable-next-line no-restricted-syntax
for (const plejd of sortedDevices) {
try {
if (plejd['instance']) {
logger.info(`Connecting to ${plejd['path']}`);
await plejd['instance'].Connect();
if (plejd.instance) {
logger.info(`Connecting to ${plejd.path}`);
// eslint-disable-next-line no-await-in-loop
await plejd.instance.Connect();
connectedDevice = plejd;
break
break;
}
} catch (err) {
logger.error('Warning: unable to connect, will retry. ', err);
@ -194,11 +206,13 @@ class PlejdService extends EventEmitter {
async _getInterface(managedObjects, iface) {
const managedPaths = Object.keys(managedObjects);
for (let path of managedPaths) {
// eslint-disable-next-line no-restricted-syntax
for (const path of managedPaths) {
const pathInterfaces = Object.keys(managedObjects[path]);
if (pathInterfaces.indexOf(iface) > -1) {
logger.debug(`Found BLE interface '${iface}' at ${path}`);
try {
// eslint-disable-next-line no-await-in-loop
const adapterObject = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, path);
return [path, adapterObject.getInterface(iface), adapterObject];
} catch (err) {
@ -215,10 +229,10 @@ class PlejdService extends EventEmitter {
const interfaceKeys = Object.keys(interfaces);
if (interfaceKeys.indexOf(BLUEZ_DEVICE_ID) > -1) {
if (interfaces[BLUEZ_DEVICE_ID]['UUIDs'].value.indexOf(PLEJD_SERVICE) > -1) {
if (interfaces[BLUEZ_DEVICE_ID].UUIDs.value.indexOf(PLEJD_SERVICE) > -1) {
logger.debug(`Found Plejd service on ${path}`);
this.bleDevices.push({
'path': path
path,
});
} else {
logger.error('Uh oh, no Plejd device!');
@ -228,17 +242,24 @@ class PlejdService extends EventEmitter {
turnOn(deviceId, command) {
const deviceName = this._getDeviceName(deviceId);
logger.info(`Plejd got turn on command for ${deviceName} (${deviceId}), brightness ${command.brightness}${command.transition ? `, transition: ${command.transition}` : ''}`);
logger.info(
`Plejd got turn on command for ${deviceName} (${deviceId}), brightness ${command.brightness}${
command.transition ? `, transition: ${command.transition}` : ''
}`,
);
this._transitionTo(deviceId, command.brightness, command.transition, deviceName);
}
turnOff(deviceId, command) {
const deviceName = this._getDeviceName(deviceId);
logger.info(`Plejd got turn off command for ${deviceName} (${deviceId}), brightness ${command.brightness}${command.transition ? `, transition: ${command.transition}` : ''}`);
logger.info(
`Plejd got turn off command for ${deviceName} (${deviceId}), brightness ${
command.brightness
}${command.transition ? `, transition: ${command.transition}` : ''}`,
);
this._transitionTo(deviceId, 0, command.transition, deviceName);
}
_clearDeviceTransitionTimer(deviceId) {
if (this.bleDeviceTransitionTimers[deviceId]) {
clearInterval(this.bleDeviceTransitionTimers[deviceId]);
@ -246,55 +267,81 @@ class PlejdService extends EventEmitter {
}
_transitionTo(deviceId, targetBrightness, transition, deviceName) {
const initialBrightness = this.plejdDevices[deviceId] ? this.plejdDevices[deviceId].state && this.plejdDevices[deviceId].dim : null;
const initialBrightness = this.plejdDevices[deviceId]
? this.plejdDevices[deviceId].state && this.plejdDevices[deviceId].dim
: null;
this._clearDeviceTransitionTimer(deviceId);
const isDimmable = this.devices.find(d => d.id === deviceId).dimmable;
const isDimmable = this.devices.find((d) => d.id === deviceId).dimmable;
if (transition > 1 && isDimmable && (initialBrightness || initialBrightness === 0) && (targetBrightness || targetBrightness === 0) && targetBrightness !== initialBrightness) {
if (
transition > 1
&& isDimmable
&& (initialBrightness || initialBrightness === 0)
&& (targetBrightness || targetBrightness === 0)
&& targetBrightness !== initialBrightness
) {
// Transition time set, known initial and target brightness
// Calculate transition interval time based on delta brightness and max steps per second
// During transition, measure actual transition interval time and adjust stepping continously
// If transition <= 1 second, Plejd will do a better job than we can in transitioning so transitioning will be skipped
// If transition <= 1 second, Plejd will do a better job
// than we can in transitioning so transitioning will be skipped
const deltaBrightness = targetBrightness - initialBrightness;
const transitionSteps = Math.min(Math.abs(deltaBrightness), MAX_TRANSITION_STEPS_PER_SECOND * transition);
const transitionInterval = transition * 1000 / transitionSteps;
const transitionSteps = Math.min(
Math.abs(deltaBrightness),
MAX_TRANSITION_STEPS_PER_SECOND * transition,
);
const transitionInterval = (transition * 1000) / transitionSteps;
logger.debug(`transitioning from ${initialBrightness} to ${targetBrightness} ${transition ? 'in ' + transition + ' seconds' : ''}.`);
logger.verbose(`delta brightness ${deltaBrightness}, steps ${transitionSteps}, interval ${transitionInterval} ms`);
logger.debug(
`transitioning from ${initialBrightness} to ${targetBrightness} ${
transition ? `in ${transition} seconds` : ''
}.`,
);
logger.verbose(
`delta brightness ${deltaBrightness}, steps ${transitionSteps}, interval ${transitionInterval} ms`,
);
const dtStart = new Date();
let nSteps = 0;
this.bleDeviceTransitionTimers[deviceId] = setInterval(() => {
let tElapsedMs = new Date().getTime() - dtStart.getTime();
const tElapsedMs = new Date().getTime() - dtStart.getTime();
let tElapsed = tElapsedMs / 1000;
if (tElapsed > transition || tElapsed < 0) {
tElapsed = transition;
}
let newBrightness = parseInt(initialBrightness + deltaBrightness * tElapsed / transition);
let newBrightness = Math.round(
initialBrightness + (deltaBrightness * tElapsed) / transition,
);
if (tElapsed === transition) {
nSteps++;
this._clearDeviceTransitionTimer(deviceId);
newBrightness = targetBrightness;
logger.debug(`Queueing finalize ${deviceName} (${deviceId}) transition from ${initialBrightness} to ${targetBrightness} in ${tElapsedMs}ms. Done steps ${nSteps}. Average interval ${tElapsedMs / (nSteps || 1)} ms.`);
logger.debug(
`Queueing finalize ${deviceName} (${deviceId}) transition from ${initialBrightness} to ${targetBrightness} in ${tElapsedMs}ms. Done steps ${nSteps}. Average interval ${
tElapsedMs / (nSteps || 1)
} ms.`,
);
this._setBrightness(deviceId, newBrightness, true, deviceName);
} else {
nSteps++;
logger.verbose(`Queueing dim transition for ${deviceName} (${deviceId}) to ${newBrightness}. Total queue length ${this.writeQueue.length}`);
logger.verbose(
`Queueing dim transition for ${deviceName} (${deviceId}) to ${newBrightness}. Total queue length ${this.writeQueue.length}`,
);
this._setBrightness(deviceId, newBrightness, false, deviceName);
}
}, transitionInterval);
}
else {
} else {
if (transition && isDimmable) {
logger.debug(`Could not transition light change. Either initial value is unknown or change is too small. Requested from ${initialBrightness} to ${targetBrightness}`)
logger.debug(
`Could not transition light change. Either initial value is unknown or change is too small. Requested from ${initialBrightness} to ${targetBrightness}`,
);
}
this._setBrightness(deviceId, targetBrightness, true, deviceName);
}
@ -305,33 +352,45 @@ class PlejdService extends EventEmitter {
let log = '';
if (!brightness && brightness !== 0) {
logger.debug(`Queueing turn on ${deviceName} (${deviceId}). No brightness specified, setting DIM to previous.`);
payload = Buffer.from((deviceId).toString(16).padStart(2, '0') + '0110009701', 'hex');
logger.debug(
`Queueing turn on ${deviceName} (${deviceId}). No brightness specified, setting DIM to previous.`,
);
payload = Buffer.from(`${deviceId.toString(16).padStart(2, '0')}0110009701`, 'hex');
log = 'ON';
}
else {
if (brightness <= 0) {
logger.debug(`Queueing turn off ${deviceId}`);
payload = Buffer.from((deviceId).toString(16).padStart(2, '0') + '0110009700', 'hex');
log = 'OFF';
}
else {
if (brightness > 255) {
brightness = 255;
}
logger.debug(`Queueing ${deviceId} set brightness to ${brightness}`);
const brightnessVal = (brightness << 8) | brightness;
payload = Buffer.from((deviceId).toString(16).padStart(2, '0') + '0110009801' + (brightnessVal).toString(16).padStart(4, '0'), 'hex');
log = `DIM ${brightness}`;
} else if (brightness <= 0) {
logger.debug(`Queueing turn off ${deviceId}`);
payload = Buffer.from(`${deviceId.toString(16).padStart(2, '0')}0110009700`, 'hex');
log = 'OFF';
} else {
if (brightness > 255) {
// eslint-disable-next-line no-param-reassign
brightness = 255;
}
logger.debug(`Queueing ${deviceId} set brightness to ${brightness}`);
// eslint-disable-next-line no-bitwise
const brightnessVal = (brightness << 8) | brightness;
payload = Buffer.from(
`${deviceId.toString(16).padStart(2, '0')}0110009801${brightnessVal
.toString(16)
.padStart(4, '0')}`,
'hex',
);
log = `DIM ${brightness}`;
}
this.writeQueue.unshift({deviceId, log, shouldRetry, payload});
this.writeQueue.unshift({
deviceId,
log,
shouldRetry,
payload,
});
}
triggerScene(sceneIndex) {
const sceneName = this._getDeviceName(sceneIndex);
logger.info(`Triggering scene ${sceneName} (${sceneIndex}). Scene name might be misleading if there is a device with the same numeric id.`);
logger.info(
`Triggering scene ${sceneName} (${sceneIndex}). Scene name might be misleading if there is a device with the same numeric id.`,
);
this.sceneManager.executeScene(sceneIndex, this);
}
@ -356,27 +415,35 @@ class PlejdService extends EventEmitter {
// After we've authenticated, we need to hook up the event listener
// for changes to lastData.
this.characteristics.lastDataProperties.on('PropertiesChanged', this.onLastDataUpdated.bind(this));
this.characteristics.lastDataProperties.on(
'PropertiesChanged',
this.onLastDataUpdated.bind(this),
);
this.characteristics.lastData.StartNotify();
}
async throttledInit(delay) {
if(this.initInProgress){
logger.debug('ThrottledInit already in progress. Skipping this call and returning existing promise.')
if (this.initInProgress) {
logger.debug(
'ThrottledInit already in progress. Skipping this call and returning existing promise.',
);
return this.initInProgress;
}
this.initInProgress = new Promise((resolve) => setTimeout(async () => {
const result = await this.init().catch((err) => { logger.error('TrottledInit exception calling init(). Will re-throw.', err); throw err; });
this.initInProgress = null;
resolve(result)
}, delay))
return this.initInProgress;
this.initInProgress = new Promise((resolve) => setTimeout(async () => {
const result = await this.init().catch((err) => {
logger.error('TrottledInit exception calling init(). Will re-throw.', err);
throw err;
});
this.initInProgress = null;
resolve(result);
}, delay));
return this.initInProgress;
}
async write(data) {
if (!data || !this.plejdService || !this.characteristics.data) {
logger.debug('data, plejdService or characteristics not available. Cannot write()');
return;
return false;
}
try {
@ -386,7 +453,7 @@ class PlejdService extends EventEmitter {
return true;
} catch (err) {
if (err.message === 'In Progress') {
logger.debug('Write failed due to \'In progress\' ', err);
logger.debug("Write failed due to 'In progress' ", err);
} else {
logger.debug('Write failed ', err);
}
@ -405,22 +472,25 @@ class PlejdService extends EventEmitter {
}, 3000);
}
// eslint-disable-next-line class-methods-use-this
onPingSuccess(nr) {
logger.silly('pong: ' + nr);
logger.silly(`pong: ${nr}`);
}
async onPingFailed(error) {
logger.debug('onPingFailed(' + error + ')');
logger.debug(`onPingFailed(${error})`);
logger.info('ping failed, reconnecting.');
clearInterval(this.pingRef);
await this.init();
return this.init().catch((err) => {
logger.error('onPingFailed exception calling init(). Will swallow error.', err);
});
}
async ping() {
logger.silly('ping()');
var ping = crypto.randomBytes(1);
const ping = crypto.randomBytes(1);
let pong = null;
try {
@ -432,9 +502,10 @@ class PlejdService extends EventEmitter {
return;
}
// eslint-disable-next-line no-bitwise
if (((ping[0] + 1) & 0xff) !== pong[0]) {
logger.error('Plejd ping failed');
this.emit('pingFailed', 'plejd ping failed ' + ping[0] + ' - ' + pong[0]);
this.emit('pingFailed', `plejd ping failed ${ping[0]} - ${pong[0]}`);
return;
}
@ -453,26 +524,34 @@ class PlejdService extends EventEmitter {
while (this.writeQueue.length > 0) {
const queueItem = this.writeQueue.pop();
const deviceName = this._getDeviceName(queueItem.deviceId);
logger.debug(`Write queue: Processing ${deviceName} (${queueItem.deviceId}). Command ${queueItem.log}. Total queue length: ${this.writeQueue.length}`);
logger.debug(
`Write queue: Processing ${deviceName} (${queueItem.deviceId}). Command ${queueItem.log}. Total queue length: ${this.writeQueue.length}`,
);
if (this.writeQueue.some((item) => item.deviceId === queueItem.deviceId)) {
logger.verbose(`Skipping ${deviceName} (${queueItem.deviceId}) ${queueItem.log} due to more recent command in queue.`);
continue; // Skip commands if new ones exist for the same deviceId, but still process all messages in order
}
const success = await this.write(queueItem.payload);
if (!success && queueItem.shouldRetry) {
queueItem.retryCount = (queueItem.retryCount || 0) + 1;
logger.debug(`Will retry command, count failed so far ${queueItem.retryCount}`);
if (queueItem.retryCount <= MAX_RETRY_COUNT) {
this.writeQueue.push(queueItem); // Add back to top of queue to be processed next;
}
else {
logger.error(`Write queue: Exceeed max retry count (${MAX_RETRY_COUNT}) for ${deviceName} (${queueItem.deviceId}). Command ${queueItem.log} failed.`);
break;
}
if (queueItem.retryCount > 1) {
break; // First retry directly, consecutive after writeQueueWaitTime ms
logger.verbose(
`Skipping ${deviceName} (${queueItem.deviceId}) `
+ `${queueItem.log} due to more recent command in queue.`,
);
// Skip commands if new ones exist for the same deviceId
// still process all messages in order
} else {
// eslint-disable-next-line no-await-in-loop
const success = await this.write(queueItem.payload);
if (!success && queueItem.shouldRetry) {
queueItem.retryCount = (queueItem.retryCount || 0) + 1;
logger.debug(`Will retry command, count failed so far ${queueItem.retryCount}`);
if (queueItem.retryCount <= MAX_RETRY_COUNT) {
this.writeQueue.push(queueItem); // Add back to top of queue to be processed next;
} else {
logger.error(
`Write queue: Exceeed max retry count (${MAX_RETRY_COUNT}) for ${deviceName} (${queueItem.deviceId}). Command ${queueItem.log} failed.`,
);
break;
}
if (queueItem.retryCount > 1) {
break; // First retry directly, consecutive after writeQueueWaitTime ms
}
}
}
}
@ -485,7 +564,6 @@ class PlejdService extends EventEmitter {
async _processPlejdService(path, characteristics) {
const proxyObject = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, path);
const service = await proxyObject.getInterface(GATT_SERVICE_ID);
const properties = await proxyObject.getInterface(DBUS_PROP_INTERFACE);
const uuid = (await properties.Get(GATT_SERVICE_ID, 'UUID')).value;
@ -498,15 +576,12 @@ class PlejdService extends EventEmitter {
const regex = /dev_([0-9A-F_]+)$/;
const dirtyAddr = regex.exec(dev);
const addr = this._reverseBuffer(
Buffer.from(
String(dirtyAddr[1])
.replace(/\-/g, '')
.replace(/\_/g, '')
.replace(/\:/g, ''), 'hex'
)
Buffer.from(String(dirtyAddr[1]).replace(/-/g, '').replace(/_/g, '').replace(/:/g, ''), 'hex'),
);
// eslint-disable-next-line no-restricted-syntax
for (const chPath of characteristics) {
/* eslint-disable no-await-in-loop */
const chProxyObject = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, chPath);
const ch = await chProxyObject.getInterface(GATT_CHRC_ID);
const prop = await chProxyObject.getInterface(DBUS_PROP_INTERFACE);
@ -527,10 +602,11 @@ class PlejdService extends EventEmitter {
logger.debug('found PING characteristic.');
this.characteristics.ping = ch;
}
/* eslint-eslint no-await-in-loop */
}
return {
addr: addr
addr,
};
}
@ -543,8 +619,9 @@ class PlejdService extends EventEmitter {
const objects = await this.objectManager.GetManagedObjects();
const paths = Object.keys(objects);
let characteristics = [];
const characteristics = [];
// eslint-disable-next-line no-restricted-syntax
for (const path of paths) {
const interfaces = Object.keys(objects[path]);
if (interfaces.indexOf(GATT_CHRC_ID) > -1) {
@ -552,17 +629,19 @@ class PlejdService extends EventEmitter {
}
}
// eslint-disable-next-line no-restricted-syntax
for (const path of paths) {
const interfaces = Object.keys(objects[path]);
if (interfaces.indexOf(GATT_SERVICE_ID) > -1) {
let chPaths = [];
const chPaths = [];
// eslint-disable-next-line no-restricted-syntax
for (const c of characteristics) {
if (c.startsWith(path + '/')) {
if (c.startsWith(`${path}/`)) {
chPaths.push(c);
}
}
logger.info('trying ' + chPaths.length + ' characteristics');
logger.info(`trying ${chPaths.length} characteristics`);
this.plejdService = await this._processPlejdService(path, chPaths);
if (this.plejdService) {
@ -572,7 +651,7 @@ class PlejdService extends EventEmitter {
}
if (!this.plejdService) {
logger.info('warning: wasn\'t able to connect to Plejd, will retry.');
logger.info("warning: wasn't able to connect to Plejd, will retry.");
this.emit('connectFailed');
return;
}
@ -583,10 +662,11 @@ class PlejdService extends EventEmitter {
return;
}
this.connectedDevice = device['device'];
this.connectedDevice = device.device;
await this.authenticate();
}
// eslint-disable-next-line no-unused-vars
async onLastDataUpdated(iface, properties, invalidated) {
if (iface !== GATT_CHRC_ID) {
return;
@ -597,7 +677,7 @@ class PlejdService extends EventEmitter {
return;
}
const value = await properties['Value'];
const value = await properties.Value;
if (!value) {
return;
}
@ -609,6 +689,7 @@ class PlejdService extends EventEmitter {
// What is bytes 2-3?
const cmd = decoded.toString('hex', 3, 5);
const state = parseInt(decoded.toString('hex', 5, 6), 10); // Overflows for command 0x001b, scene command
// eslint-disable-next-line no-bitwise
const data2 = parseInt(decoded.toString('hex', 6, 8), 16) >> 8;
if (decoded.length < 5) {
@ -619,7 +700,9 @@ class PlejdService extends EventEmitter {
const deviceName = this._getDeviceName(deviceId);
logger.verbose(`Raw event received: ${decoded.toString('hex')}`);
logger.verbose(`Device ${deviceId}, cmd ${cmd.toString('hex')}, state ${state}, dim/data2 ${data2}`);
logger.verbose(
`Device ${deviceId}, cmd ${cmd.toString('hex')}, state ${state}, dim/data2 ${data2}`,
);
if (cmd === BLE_CMD_DIM_CHANGE || cmd === BLE_CMD_DIM2_CHANGE) {
const dim = data2;
@ -627,37 +710,37 @@ class PlejdService extends EventEmitter {
logger.debug(`${deviceName} (${deviceId}) got state+dim update. S: ${state}, D: ${dim}`);
this.emit('stateChanged', deviceId, {
state: state,
brightness: dim
state,
brightness: dim,
});
this.plejdDevices[deviceId] = {
state: state,
dim: dim
state,
dim,
};
logger.verbose(`All states: ${JSON.stringify(this.plejdDevices)}`);
} else if (cmd === BLE_CMD_STATE_CHANGE) {
logger.debug(`${deviceName} (${deviceId}) got state update. S: ${state}`);
this.emit('stateChanged', deviceId, {
state: state
state,
});
this.plejdDevices[deviceId] = {
state: state,
dim: 0
state,
dim: 0,
};
logger.verbose(`All states: ${this.plejdDevices}`);
} else if (cmd === BLE_CMD_SCENE_TRIG) {
const sceneId = parseInt(decoded.toString('hex', 5, 6), 16);
const sceneName = this._getDeviceName(sceneId);
logger.debug(`${sceneName} (${sceneId}) scene triggered (device id ${deviceId}). Name can be misleading if there is a device with the same numeric id.`);
logger.debug(
`${sceneName} (${sceneId}) scene triggered (device id ${deviceId}). Name can be misleading if there is a device with the same numeric id.`,
);
this.emit('sceneTriggered', deviceId, sceneId);
}
else if (cmd === '001b') {
} else if (cmd === '001b') {
logger.silly('Command 001b seems to be some kind of often repeating ping/mesh data');
}
else {
} else {
logger.verbose(`Command ${cmd.toString('hex')} unknown. Device ${deviceName} (${deviceId})`);
}
}
@ -670,6 +753,7 @@ class PlejdService extends EventEmitter {
this.on('pingSuccess', this.onPingSuccess.bind(self));
}
// eslint-disable-next-line class-methods-use-this
_createChallengeResponse(key, challenge) {
const intermediate = crypto.createHash('sha256').update(xor(key, challenge)).digest();
const part1 = intermediate.subarray(0, 16);
@ -680,18 +764,20 @@ class PlejdService extends EventEmitter {
return resp;
}
// eslint-disable-next-line class-methods-use-this
_encryptDecrypt(key, addr, data) {
var buf = Buffer.concat([addr, addr, addr.subarray(0, 4)]);
const buf = Buffer.concat([addr, addr, addr.subarray(0, 4)]);
var cipher = crypto.createCipheriv('aes-128-ecb', key, '');
const cipher = crypto.createCipheriv('aes-128-ecb', key, '');
cipher.setAutoPadding(false);
var ct = cipher.update(buf).toString('hex');
let ct = cipher.update(buf).toString('hex');
ct += cipher.final().toString('hex');
ct = Buffer.from(ct, 'hex');
var output = '';
for (var i = 0, length = data.length; i < length; i++) {
let output = '';
for (let i = 0, { length } = data; i < length; i++) {
// eslint-disable-next-line no-bitwise
output += String.fromCharCode(data[i] ^ ct[i % 16]);
}
@ -699,18 +785,19 @@ class PlejdService extends EventEmitter {
}
_getDeviceName(deviceId) {
return (this.devices.find(d => d.id === deviceId) || {}).name;
return (this.devices.find((d) => d.id === deviceId) || {}).name;
}
// eslint-disable-next-line class-methods-use-this
_reverseBuffer(src) {
var buffer = Buffer.allocUnsafe(src.length)
const buffer = Buffer.allocUnsafe(src.length);
for (var i = 0, j = src.length - 1; i <= j; ++i, --j) {
buffer[i] = src[j]
buffer[j] = src[i]
for (let i = 0, j = src.length - 1; i <= j; ++i, --j) {
buffer[i] = src[j];
buffer[j] = src[i];
}
return buffer
return buffer;
}
}

View file

@ -1,4 +1,5 @@
# Hass.io Plejd add-on
Hass.io add-on for Plejd home automation devices. Gives you the ability to control the Plejd home automation devices through Home Assistant.
It uses MQTT to communicate with Home Assistant and supports auto discovery of the devices in range.
@ -15,54 +16,68 @@ I am in no way affiliated with Plejd and am solely doing this as a hobby project
[![Gitter](https://badges.gitter.im/hassio-plejd/community.svg)](https://gitter.im/hassio-plejd/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
## Getting started
To get started, make sure that the following requirements are met:
### Requirements
* A Bluetooth device (BLE), for eg. the built-in device in Raspberry Pi 4.
* An MQTT broker (the Mosquitto Hass.io add-on works perfectly well).
- A Bluetooth device (BLE), for eg. the built-in device in Raspberry Pi 4.
- An MQTT broker (the Mosquitto Hass.io add-on works perfectly well).
### Tested on
The add-on has been tested on the following platforms:
* Mac OS Catalina 10.15.1 with Node v. 13.2.0
* Raspberry Pi 4 with Hass.io
- Mac OS Catalina 10.15.1 with Node v. 13.2.0
- Raspberry Pi 4 with Hass.io
#### Tested Plejd devices
* DIM-01
* DIM-02
* LED-10
* CTR-01
* REL-01
* REL-02
- DIM-01
- DIM-02
- LED-10
- CTR-01
- REL-01
- REL-02
### Easy Installation
Browse to your Home Assistant installation in a web browser and click on `Hass.io` in the navigation bar to the left.
* Open the Home Assistant web console and click `Hass.io` in the menu on the left side.
* Click on `Add-on Store` in the top navigation bar of that page.
* Paste the URL to this repo https://github.com/icanos/hassio-plejd.git in the `Add new repository by URL` field and hit `Add`.
* Scroll down and you should find a Plejd add-on that can be installed. Open that and install.
* Enjoy!
- Open the Home Assistant web console and click `Hass.io` in the menu on the left side.
- Click on `Add-on Store` in the top navigation bar of that page.
- Paste the URL to this repo https://github.com/icanos/hassio-plejd.git in the `Add new repository by URL` field and hit `Add`.
- Scroll down and you should find a Plejd add-on that can be installed. Open that and install.
- Enjoy!
### Manual Installation
Browse your Hass.io installation using a tool that allows you to manage files, for eg. SMB or an SFTP client etc.
* Open the `/addon` directory
* Create a new folder named `hassio-plejd`
* Copy all files from this repository into that newly created one.
* Open the Home Assistant web console and click `Hass.io` in the menu on the left side.
* Click on `Add-on Store` in the top navigation bar of that page.
* Click on the refresh button in the upper right corner.
* A new Local Add-on should appear named Plejd. Open that and install.
* Enjoy!
- Open the `/addon` directory
- Create a new folder named `hassio-plejd`
- Copy all files from this repository into that newly created one.
- Open the Home Assistant web console and click `Hass.io` in the menu on the left side.
- Click on `Add-on Store` in the top navigation bar of that page.
- Click on the refresh button in the upper right corner.
- A new Local Add-on should appear named Plejd. Open that and install.
- Enjoy!
### NOTE
When starting the add-on, the log displays this message:
```
parse error: Expected string key before ':' at line 1, column 4
[08:56:24] ERROR: Unknown HTTP error occured
```
However, the add-on still works as expected and this is something I'm looking into, but not with that much effort yet though.
### Configuration
You need to add the following to your `configuration.yaml` file:
```
mqtt:
broker: [point to your broker IP eg. 'mqtt://localhost']
@ -70,84 +85,102 @@ mqtt:
password: !secret mqtt_password
discovery: true
discovery_prefix: homeassistant
birth_message:
birth_message:
topic: 'hass/status'
payload: 'online'
will_message:
will_message:
topic: 'hass/status'
payload: 'offline'
```
The above is used to notify the add-on when Home Assistant has started successfully and let the add-on send the discovery response (containing all devices).
The plugin needs you to configure some settings before working. You find these on the Add-on page after you've installed it.
Parameter | Value
--- | ---
site | Name of your Plejd site, the name is displayed in the Plejd app (top bar).
username | Username of your Plejd account, this is used to fetch the crypto key and devices from the Plejd API.
password | Password of your Plejd account, this is used to fetch the crypto key and devices from the Plejd API.
mqttBroker | URL of the MQTT Broker, eg. mqtt://localhost
mqttUsername | Username of the MQTT broker
mqttPassword | Password of the MQTT broker
includeRoomsAsLights | Adds all rooms as lights, making it possible to turn on/off lights by room instead. Setting this to false will ignore all rooms. *Added in v. 5*.
connectionTimeout | Number of seconds to wait when scanning and connecting. Might need to be tweaked on platforms other than RPi 4. Defaults to: 2 seconds.
| Parameter | Value |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| site | Name of your Plejd site, the name is displayed in the Plejd app (top bar). |
| username | Username of your Plejd account, this is used to fetch the crypto key and devices from the Plejd API. |
| password | Password of your Plejd account, this is used to fetch the crypto key and devices from the Plejd API. |
| mqttBroker | URL of the MQTT Broker, eg. mqtt://localhost |
| mqttUsername | Username of the MQTT broker |
| mqttPassword | Password of the MQTT broker |
| includeRoomsAsLights | Adds all rooms as lights, making it possible to turn on/off lights by room instead. Setting this to false will ignore all rooms. _Added in v. 5_. |
| connectionTimeout | Number of seconds to wait when scanning and connecting. Might need to be tweaked on platforms other than RPi 4. Defaults to: 2 seconds. |
## I want voice control!
With the Google Home integration in Home Assistant, you can get voice control for your Plejd lights right away, check this out for more information:
https://www.home-assistant.io/integrations/google_assistant/
### I don't want voice, I want HomeKit!
Check this out for more information on how you can get your Plejd lights controlled using HomeKit:
https://www.home-assistant.io/integrations/homekit/
## Changelog
*v 0.3.4*:
* NEW: `connectionTimeout` configuration parameter to enable tweaking of wait time on connection, usable for RPi 3B+.
* FIX: Reworked some logging to get better understanding of what happens.
*v 0.3.0*:
* NEW: New BLE manager, DBus instead of noble
* FIX: Adding entities as devices now as well
* FIX: Bug fixes
_v 0.3.4_:
*v 0.2.8*:
* FIX: Reset characteristic state on disconnect
- NEW: `connectionTimeout` configuration parameter to enable tweaking of wait time on connection, usable for RPi 3B+.
- FIX: Reworked some logging to get better understanding of what happens.
*v 0.2.7*:
* FIX: Added exception handling to unsubscribing lastData characteristic if already disconnected
_v 0.3.0_:
*v 0.2.6*:
* FIX: Added null check to remove listeners for characteristics
- NEW: New BLE manager, DBus instead of noble
- FIX: Adding entities as devices now as well
- FIX: Bug fixes
*v 0.2.5*:
* FIX: Invalid scene id in events/scene message
_v 0.2.8_:
*v 0.2.4*:
* Stability improvements
- FIX: Reset characteristic state on disconnect
*v 0.2.3*:
* FIX: Container build error fix
_v 0.2.7_:
*v 0.2.2*:
* Stability improvements
- FIX: Added exception handling to unsubscribing lastData characteristic if already disconnected
*v 0.2.1*:
* Stability improvements
_v 0.2.6_:
*v 0.2.0*:
* Stability improvements
* Bugfixes
- FIX: Added null check to remove listeners for characteristics
*v 0.1.1*:
* FIX: Fixed missing reference on startup, preventing add-on from starting
_v 0.2.5_:
*v 0.1.0*:
* NEW: Rewrote the BLE integration for more stability
* FIX: discovery wasn't always sent
- FIX: Invalid scene id in events/scene message
*previous*:
* FIX: bug preventing add-on from building
* NEW: Added support for Plejd devices with multiple outputs (such as DIM-02)
_v 0.2.4_:
- Stability improvements
_v 0.2.3_:
- FIX: Container build error fix
_v 0.2.2_:
- Stability improvements
_v 0.2.1_:
- Stability improvements
_v 0.2.0_:
- Stability improvements
- Bugfixes
_v 0.1.1_:
- FIX: Fixed missing reference on startup, preventing add-on from starting
_v 0.1.0_:
- NEW: Rewrote the BLE integration for more stability
- FIX: discovery wasn't always sent
_previous_:
- FIX: bug preventing add-on from building
- NEW: Added support for Plejd devices with multiple outputs (such as DIM-02)
## License

19
plejd/Scene.js Normal file
View file

@ -0,0 +1,19 @@
const SceneStep = require('./SceneStep');
class Scene {
constructor(idx, scene, steps) {
this.id = idx;
this.title = scene.title;
this.sceneId = scene.sceneId;
const sceneSteps = steps.filter((x) => x.sceneId === scene.sceneId);
this.steps = [];
// eslint-disable-next-line no-restricted-syntax
for (const step of sceneSteps) {
this.steps.push(new SceneStep(step));
}
}
}
module.exports = Scene;

48
plejd/SceneManager.js Normal file
View file

@ -0,0 +1,48 @@
/* eslint-disable max-classes-per-file */
const EventEmitter = require('events');
const Scene = require('./Scene');
class SceneManager extends EventEmitter {
constructor(site, devices) {
super();
this.site = site;
this.scenes = [];
this.devices = devices;
this.init();
}
init() {
const scenes = this.site.scenes.filter((x) => x.hiddenFromSceneList === false);
// eslint-disable-next-line no-restricted-syntax
for (const scene of scenes) {
const idx = this.site.sceneIndex[scene.sceneId];
this.scenes.push(new Scene(idx, scene, this.site.sceneSteps));
}
}
executeScene(sceneIndex, ble) {
const scene = this.scenes.find((x) => x.id === sceneIndex);
if (!scene) {
return;
}
// eslint-disable-next-line no-restricted-syntax
for (const step of scene.steps) {
const device = this.devices.find((x) => x.serialNumber === step.deviceId);
if (device) {
if (device.dimmable && step.state) {
ble.turnOn(device.id, { brightness: step.brightness });
} else if (!device.dimmable && step.state) {
ble.turnOn(device.id, {});
} else if (!step.state) {
ble.turnOff(device.id, {});
}
}
}
}
}
module.exports = SceneManager;
/* eslint-disable */

10
plejd/SceneStep.js Normal file
View file

@ -0,0 +1,10 @@
class SceneStep {
constructor(step) {
this.sceneId = step.sceneId;
this.deviceId = step.deviceId;
this.state = step.state === 'On' ? 1 : 0;
this.brightness = step.value;
}
}
module.exports = SceneStep;

View file

@ -4,13 +4,7 @@
"slug": "plejd",
"description": "Adds support for the Swedish home automation devices from Plejd.",
"url": "https://github.com/icanos/hassio-plejd/",
"arch": [
"armhf",
"armv7",
"aarch64",
"amd64",
"i386"
],
"arch": ["armhf", "armv7", "aarch64", "amd64", "i386"],
"startup": "application",
"boot": "auto",
"host_network": true,

View file

@ -1,15 +1,14 @@
const api = require('./api');
const mqtt = require('./mqtt');
const PlejdApi = require('./PlejdApi');
const MqttClient = require('./MqttClient');
const Logger = require('./Logger');
const PlejdService = require('./ble.bluez');
const SceneManager = require('./scene.manager');
const Configuration = require("./Configuration");
const PlejdService = require('./PlejdService');
const SceneManager = require('./SceneManager');
const Configuration = require('./Configuration');
const logger = Logger.getLogger("plejd-main");
const logger = Logger.getLogger('plejd-main');
const version = "0.4.8";
const version = '0.4.8';
async function main() {
logger.info(`Starting Plejd add-on v. ${version}`);
@ -20,8 +19,13 @@ async function main() {
config.connectionTimeout = 2;
}
const plejdApi = new api.PlejdApi(config.site, config.username, config.password, config.includeRoomsAsLights);
const client = new mqtt.MqttClient(config.mqttBroker, config.mqttUsername, config.mqttPassword);
const plejdApi = new PlejdApi(
config.site,
config.username,
config.password,
config.includeRoomsAsLights,
);
const client = new MqttClient(config.mqttBroker, config.mqttUsername, config.mqttPassword);
plejdApi.login().then(() => {
// load all sites and find the one that we want (from config)
@ -40,7 +44,13 @@ async function main() {
// init the BLE interface
const sceneManager = new SceneManager(plejdApi.site, devices);
const plejd = new PlejdService(cryptoKey, devices, sceneManager, config.connectionTimeout, config.writeQueueWaitTime, true);
const plejd = new PlejdService(
cryptoKey,
devices,
sceneManager,
config.connectionTimeout,
config.writeQueueWaitTime,
);
plejd.on('connectFailed', () => {
logger.verbose('Were unable to connect, will retry connection in 10 seconds.');
setTimeout(() => {
@ -81,16 +91,17 @@ async function main() {
// switch command
state = command;
commandObj = {
state: state
state,
};
// since the switch doesn't get any updates on whether it's on or not,
// we fake this by directly send the updateState back to HA in order for
// it to change state.
client.updateState(deviceId, {
state: state === 'ON' ? 1 : 0
state: state === 'ON' ? 1 : 0,
});
} else {
// eslint-disable-next-line prefer-destructuring
state = command.state;
commandObj = command;
}

1545
plejd/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,5 +9,23 @@
"mqtt": "~3.0.0",
"sleep": "~6.1.0",
"winston": "~3.3.3"
},
"devDependencies": {
"babel-eslint": "~10.1.0",
"eslint": "~7.18.0",
"eslint-config-airbnb": "~18.2.1",
"eslint-config-prettier": "~7.2.0",
"eslint-plugin-import": "~2.22.1",
"eslint-plugin-prettier": "~3.3.1",
"prettier": "~2.2.1"
},
"scripts": {
"lint": "prettier \"../*.{js*,md}\" --check & eslint **/*.js",
"lint:fix": "prettier .. --check --write & eslint **/*.js --fix",
"lint:prettier:fix": "npm run lint:prettier --write",
"lint:errors": "npm run lint:prettier & npm run lint:styles --quiet & npm run lint:types & npm run lint:scripts --quiet",
"lint:errors:fix": "npm run lint:prettier --write & npm run lint:scripts --quiet --fix",
"lint:prettier": "prettier --check \"**/*.js\"",
"lint:scripts": "eslint --config ./.eslintrc.js \"**/*.js\""
}
}

View file

@ -1,71 +0,0 @@
const EventEmitter = require('events');
class SceneManager extends EventEmitter {
constructor(site, devices) {
super();
this.site = site;
this.scenes = [];
this.devices = devices;
this.init();
}
init() {
const scenes = this.site.scenes.filter(x => x.hiddenFromSceneList == false);
for (const scene of scenes) {
const idx = this.site.sceneIndex[scene.sceneId];
this.scenes.push(new Scene(idx, scene, this.site.sceneSteps));
}
}
executeScene(sceneIndex, ble) {
const scene = this.scenes.find(x => x.id === sceneIndex);
if (!scene) {
return;
}
for (const step of scene.steps) {
const device = this.devices.find(x => x.serialNumber === step.deviceId);
if (!device) {
continue;
}
if (device.dimmable && step.state) {
ble.turnOn(device.id, { brightness: step.brightness });
}
else if (!device.dimmable && step.state) {
ble.turnOn(device.id, {});
}
else if (!step.state) {
ble.turnOff(device.id, {});
}
}
}
}
class Scene {
constructor(idx, scene, steps) {
this.id = idx;
this.title = scene.title;
this.sceneId = scene.sceneId;
const sceneSteps = steps.filter(x => x.sceneId === scene.sceneId);
this.steps = [];
for (const step of sceneSteps) {
this.steps.push(new SceneStep(step));
}
}
}
class SceneStep {
constructor(step) {
this.sceneId = step.sceneId;
this.deviceId = step.deviceId;
this.state = step.state === 'On' ? 1 : 0;
this.brightness = step.value;
}
}
module.exports = SceneManager;

View file

@ -1,6 +1,6 @@
const PlejdService = require('../ble.bluez');
const PlejdService = require('../PlejdService');
const cryptoKey = '';
const plejd = new PlejdService(cryptoKey, true);
plejd.init();
plejd.init();

View file

@ -1,8 +0,0 @@
const PlejdService = require('../ble');
const plejd = new PlejdService('todo-insert-crypto-key', true);
plejd.on('authenticated', () => {
plejd.disconnect();
console.log('ok, done! disconnected.');
});
plejd.scan();