Merge pull request #166 from icanos/develop

Release v0.6.0
This commit is contained in:
Victor 2021-02-27 10:43:15 +01:00 committed by GitHub
commit 3804c63991
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1861 additions and 1349 deletions

View file

@ -23,6 +23,7 @@ module.exports = {
function getRules() {
return {
'lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }],
// Allows modification of properties passed to functions.
// Notably used in array.forEach(e => {e.prop = val;})
'no-param-reassign': ['error', { props: false }],

View file

@ -1,5 +1,39 @@
# Changelog hassio-plejd Home Assistant Plejd addon
### [0.6.1](https://github.com/icanos/hassio-plejd/tree/0.6.1) (2021-02-20)
[Full Changelog](https://github.com/icanos/hassio-plejd/compare/0.6.0...0.6.1)
**Implemented enhancements:**
- Feature Request: Support setting the Plejd Network System Clock [\#130](https://github.com/icanos/hassio-plejd/issues/130)
**Closed issues:**
- Set Plejd devices' clock hourly [\#165](https://github.com/icanos/hassio-plejd/issues/165)
### [0.6.0](https://github.com/icanos/hassio-plejd/tree/0.6.0) (2021-01-30)
[Full Changelog](https://github.com/icanos/hassio-plejd/compare/0.5.1...0.6.0)
**Implemented enhancements:**
- Code restructure testing/input/code review [\#158](https://github.com/icanos/hassio-plejd/issues/158)
- Offline mode [\#148](https://github.com/icanos/hassio-plejd/issues/148)
**Fixed bugs:**
- Brightness level incorrect with RTR-01 and WPH-01 [\#159](https://github.com/icanos/hassio-plejd/issues/159)
**Closed issues:**
- \[plejd-api\] Unable to retrieve session token response: Request failed with status code 403 Error: Request failed with status code 403 [\#162](https://github.com/icanos/hassio-plejd/issues/162)
- Can't turn on/off lights after last update [\#157](https://github.com/icanos/hassio-plejd/issues/157)
- Brightness level incorrect when changing with RTR-01 or WPH-01 [\#138](https://github.com/icanos/hassio-plejd/issues/138)
- plejd-ble reconnect attempts [\#123](https://github.com/icanos/hassio-plejd/issues/123)
- unable to retrieve session token response: Error: Request failed with status code 404 \(and 403\) [\#99](https://github.com/icanos/hassio-plejd/issues/99)
- Unable to scan BT Plejd [\#97](https://github.com/icanos/hassio-plejd/issues/97)
### [0.5.1](https://github.com/icanos/hassio-plejd/tree/0.5.1) (2021-01-30)
[Full Changelog](https://github.com/icanos/hassio-plejd/compare/0.5.0...0.5.1)
@ -19,7 +53,7 @@
**Implemented enhancements:**
- Adjust code to airbnb style guid, including eslint rules and prettier config
- Adjust code to airbnb style guide, including eslint rules and prettier config
- Updated dependencies
- Improved readme with info about installation, debugging, and logging

View file

@ -1,14 +1,52 @@
const fs = require('fs');
class Configuration {
static _config = null;
static _options = null;
static _addonInfo = null;
static getConfiguration() {
if (!Configuration._config) {
const rawData = fs.readFileSync('/data/options.json');
Configuration._config = JSON.parse(rawData);
static getOptions() {
if (!Configuration._options) {
Configuration._hydrateCache();
}
return Configuration._config;
return Configuration._options;
}
static getAddonInfo() {
if (!Configuration._addonInfo) {
Configuration._hydrateCache();
}
return Configuration._addonInfo;
}
static _hydrateCache() {
const rawData = fs.readFileSync('/data/options.json');
const config = JSON.parse(rawData);
const defaultRawData = fs.readFileSync('/plejd/config.json');
const defaultConfig = JSON.parse(defaultRawData);
Configuration._options = { ...defaultConfig.options, ...config };
Configuration._addonInfo = {
name: defaultConfig.name,
version: defaultConfig.version,
slug: defaultConfig.slug,
description: defaultConfig.description,
url: defaultConfig.url,
arch: defaultConfig.arch,
startup: defaultConfig.startup,
boot: defaultConfig.boot,
host_network: defaultConfig.host_network,
host_dbus: defaultConfig.host_dbus,
apparmor: defaultConfig.apparmor,
};
// eslint-disable-next-line no-console
console.log('Config:', {
...Configuration._options,
username: '---scrubbed---',
password: '---scrubbed---',
mqttPassword: '---scrubbed---',
});
}
}

77
plejd/DeviceRegistry.js Normal file
View file

@ -0,0 +1,77 @@
class DeviceRegistry {
apiSite;
cryptoKey = null;
deviceIdsByRoom = {};
deviceIdsBySerial = {};
// Dictionaries of [id]: device per type
plejdDevices = {};
roomDevices = {};
sceneDevices = {};
get allDevices() {
return [
...Object.values(this.plejdDevices),
...Object.values(this.roomDevices),
...Object.values(this.sceneDevices),
];
}
addPlejdDevice(device) {
this.plejdDevices[device.id] = device;
this.deviceIdsBySerial[device.serialNumber] = device.id;
if (!this.deviceIdsByRoom[device.roomId]) {
this.deviceIdsByRoom[device.roomId] = [];
}
this.deviceIdsByRoom[device.roomId].push(device.id);
}
addScene(scene) {
this.sceneDevices[scene.id] = scene;
}
setApiSite(siteDetails) {
this.apiSite = siteDetails;
}
clearPlejdDevices() {
this.plejdDevices = {};
this.deviceIdsByRoom = {};
this.deviceIdsBySerial = {};
}
addRoomDevice(device) {
this.roomDevices[device.id] = device;
}
clearRoomDevices() {
this.roomDevices = {};
}
clearSceneDevices() {
this.sceneDevices = {};
}
getDevice(deviceId) {
return this.plejdDevices[deviceId];
}
getDeviceBySerialNumber(serialNumber) {
return this.plejdDevices[this.deviceIdsBySerial[serialNumber]];
}
getDeviceName(deviceId) {
return (this.plejdDevices[deviceId] || {}).name;
}
getScene(sceneId) {
return this.sceneDevices[sceneId];
}
getSceneName(sceneId) {
return (this.sceneDevices[sceneId] || {}).name;
}
}
module.exports = DeviceRegistry;

View file

@ -9,12 +9,14 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Copy data for add-on
COPY ./config.json /plejd/
COPY ./Configuration.js /plejd/
COPY ./DeviceRegistry.js /plejd/
COPY ./Logger.js /plejd/
COPY ./main.js /plejd/
COPY ./MqttClient.js /plejd/
COPY ./package.json /plejd/
COPY ./PlejdAddon.js /plejd/
COPY ./PlejdApi.js /plejd/
COPY ./PlejdService.js /plejd/
COPY ./PlejdBLEHandler.js /plejd/
COPY ./Scene.js /plejd/
COPY ./SceneManager.js /plejd/
COPY ./SceneStep.js /plejd/

View file

@ -25,20 +25,35 @@ const logFormat = printf((info) => {
/** Winston-based logger */
class Logger {
static shouldLogLookup = {};
constructor() {
throw new Error('Please call createLogger instead');
}
static getLogLevel() {
const config = Configuration.getOptions();
// eslint-disable-next-line max-len
const level = (config.logLevel && LEVELS.find((l) => l.startsWith(config.logLevel[0].toLowerCase())))
|| 'info';
return level;
}
static shouldLog(logLevel) {
if (!Logger.shouldLogLookup[logLevel]) {
// eslint-disable-next-line max-len
Logger.shouldLogLookup[logLevel] = Logger.logLevels().levels[logLevel] <= Logger.logLevels().levels[Logger.getLogLevel()];
}
return Logger.shouldLogLookup[logLevel];
}
/** 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 level = Logger.getLogLevel();
const logger = winston.createLogger({
format: combine(

View file

@ -1,5 +1,7 @@
const EventEmitter = require('events');
const mqtt = require('mqtt');
const Configuration = require('./Configuration');
const Logger = require('./Logger');
const startTopics = ['hass/status', 'homeassistant/status'];
@ -19,6 +21,18 @@ const getAvailabilityTopic = (plug) => `${getPath(plug)}/availability`;
const getCommandTopic = (plug) => `${getPath(plug)}/set`;
const getSceneEventTopic = () => 'plejd/event/scene';
const decodeTopicRegexp = new RegExp(
/(?<prefix>[^[]+)\/(?<type>.+)\/plejd\/(?<id>.+)\/(?<command>config|state|availability|set|scene)/,
);
const decodeTopic = (topic) => {
const matches = decodeTopicRegexp.exec(topic);
if (!matches) {
return null;
}
return matches.groups;
};
const getDiscoveryPayload = (device) => ({
schema: 'json',
name: device.name,
@ -54,23 +68,25 @@ const getSwitchPayload = (device) => ({
// #endregion
class MqttClient extends EventEmitter {
constructor(mqttBroker, username, password) {
deviceRegistry;
constructor(deviceRegistry) {
super();
this.mqttBroker = mqttBroker;
this.username = username;
this.password = password;
this.deviceMap = {};
this.devices = [];
this.config = Configuration.getOptions();
this.deviceRegistry = deviceRegistry;
}
init() {
logger.info('Initializing MQTT connection for Plejd addon');
const self = this;
this.client = mqtt.connect(this.mqttBroker, {
username: this.username,
password: this.password,
this.client = mqtt.connect(this.config.mqttBroker, {
username: this.config.mqttUsername,
password: this.config.mqttPassword,
});
this.client.on('error', (err) => {
logger.warn('Error emitted from mqtt client', err);
});
this.client.on('connect', () => {
@ -81,7 +97,7 @@ class MqttClient extends EventEmitter {
logger.error('Unable to subscribe to status topics');
}
self.emit('connected');
this.emit('connected');
});
this.client.subscribe(getSubscribePath(), (err) => {
@ -93,32 +109,70 @@ class MqttClient extends EventEmitter {
this.client.on('close', () => {
logger.verbose('Warning: mqtt channel closed event, reconnecting...');
self.reconnect();
this.reconnect();
});
this.client.on('message', (topic, message) => {
// const command = message.toString();
const command = message.toString().substring(0, 1) === '{'
? JSON.parse(message.toString())
: message.toString();
if (startTopics.includes(topic)) {
logger.info('Home Assistant has started. lets do discovery.');
self.emit('connected');
} else if (topic.includes('set')) {
logger.verbose(`Got mqtt command on ${topic} - ${message}`);
const device = self.devices.find((x) => getCommandTopic(x) === topic);
this.emit('connected');
} else {
const decodedTopic = decodeTopic(topic);
if (decodedTopic) {
let device = this.deviceRegistry.getDevice(decodedTopic.id);
const messageString = message.toString();
const isJsonMessage = messageString.startsWith('{');
const command = isJsonMessage ? JSON.parse(messageString) : messageString;
if (
!isJsonMessage
&& messageString === 'ON'
&& this.deviceRegistry.getScene(decodedTopic.id)
) {
// Guess that id that got state command without dim value belongs to Scene, not Device
// This guess could very well be wrong depending on the installation...
logger.warn(
`Device id ${decodedTopic.id} belongs to both scene and device, guessing Scene is what should be set to ON.`
+ 'OFF commands still sent to device.',
);
device = this.deviceRegistry.getScene(decodedTopic.id);
}
const deviceName = device ? device.name : '';
switch (decodedTopic.command) {
case 'set':
logger.verbose(
`Got mqtt SET command for ${decodedTopic.type}, ${deviceName} (${decodedTopic.id}): ${messageString}`,
);
if (device) {
self.emit('stateChanged', device, command);
this.emit('stateChanged', device, command);
} else {
logger.warn(
`Device for topic ${topic} not found! Can happen if HA calls previously existing devices.`,
);
}
} else if (topic.includes('state')) {
logger.verbose(`State update sent over mqtt to HA ${topic} - ${message}`);
break;
case 'state':
case 'config':
case 'availability':
logger.verbose(
`Sent mqtt ${decodedTopic.command} command for ${
decodedTopic.type
}, ${deviceName} (${decodedTopic.id}). ${
decodedTopic.command === 'availability' ? messageString : ''
}`,
);
break;
default:
logger.verbose(`Warning: Unknown command ${decodedTopic.command} in decoded topic`);
}
} else {
logger.verbose(`Warning: Got unrecognized mqtt command on ${topic} - ${message}`);
logger.verbose(
`Warning: Got unrecognized mqtt command on '${topic}': ${message.toString()}`,
);
}
}
});
}
@ -128,19 +182,16 @@ class MqttClient extends EventEmitter {
}
disconnect(callback) {
this.devices.forEach((device) => {
this.deviceRegistry.allDevices.forEach((device) => {
this.client.publish(getAvailabilityTopic(device), 'offline');
});
this.client.end(callback);
}
discover(devices) {
this.devices = devices;
sendDiscoveryToHomeAssistant() {
logger.debug(`Sending discovery of ${this.deviceRegistry.allDevices.length} device(s).`);
const self = this;
logger.debug(`Sending discovery of ${devices.length} device(s).`);
devices.forEach((device) => {
this.deviceRegistry.allDevices.forEach((device) => {
logger.debug(`Sending discovery for ${device.name}`);
const payload = device.type === 'switch' ? getSwitchPayload(device) : getDiscoveryPayload(device);
@ -148,17 +199,15 @@ class MqttClient extends EventEmitter {
`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));
this.client.publish(getConfigPath(device), JSON.stringify(payload));
setTimeout(() => {
self.client.publish(getAvailabilityTopic(device), 'online');
this.client.publish(getAvailabilityTopic(device), 'online');
}, 2000);
});
}
updateState(deviceId, data) {
const device = this.devices.find((x) => x.id === deviceId);
const device = this.deviceRegistry.getDevice(deviceId);
if (!device) {
logger.warn(`Unknown device id ${deviceId} - not handled by us.`);
@ -193,9 +242,9 @@ class MqttClient extends EventEmitter {
this.client.publish(getAvailabilityTopic(device), 'online');
}
sceneTriggered(scene) {
logger.verbose(`Scene triggered: ${scene}`);
this.client.publish(getSceneEventTopic(), JSON.stringify({ scene }));
sceneTriggered(sceneId) {
logger.verbose(`Scene triggered: ${sceneId}`);
this.client.publish(getSceneEventTopic(), JSON.stringify({ scene: sceneId }));
}
}

136
plejd/PlejdAddon.js Normal file
View file

@ -0,0 +1,136 @@
const EventEmitter = require('events');
const Configuration = require('./Configuration');
const Logger = require('./Logger');
const PlejdApi = require('./PlejdApi');
// const PlejdBLE = require('./PlejdBLE');
const PlejdBLEHandler = require('./PlejdBLEHandler');
const MqttClient = require('./MqttClient');
const SceneManager = require('./SceneManager');
const DeviceRegistry = require('./DeviceRegistry');
const logger = Logger.getLogger('plejd-main');
class PlejdAddon extends EventEmitter {
bleInitTimeout;
config;
deviceRegistry;
plejdApi;
plejdBLEHandler;
mqttClient;
sceneManager;
constructor() {
super();
this.config = Configuration.getOptions();
this.deviceRegistry = new DeviceRegistry();
this.plejdApi = new PlejdApi(this.deviceRegistry);
this.plejdBLEHandler = new PlejdBLEHandler(this.deviceRegistry);
this.sceneManager = new SceneManager(this.deviceRegistry, this.plejdBLEHandler);
this.mqttClient = new MqttClient(this.deviceRegistry);
}
async init() {
logger.info('Main Plejd addon init()...');
await this.plejdApi.init();
this.sceneManager.init();
['SIGINT', 'SIGHUP', 'SIGTERM'].forEach((signal) => {
process.on(signal, () => {
this.mqttClient.disconnect(() => process.exit(0));
});
});
this.mqttClient.on('connected', () => {
try {
logger.verbose('connected to mqtt.');
this.mqttClient.sendDiscoveryToHomeAssistant();
} catch (err) {
logger.error('Error in MqttClient.connected callback in main.js', err);
}
});
// subscribe to changes from HA
this.mqttClient.on('stateChanged', (device, command) => {
try {
const deviceId = device.id;
if (device.typeName === 'Scene') {
// we're triggering a scene, lets do that and jump out.
// since scenes aren't "real" devices.
this.sceneManager.executeScene(device.id);
return;
}
let state = 'OFF';
let commandObj = {};
if (typeof command === 'string') {
// switch command
state = command;
commandObj = {
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.
this.mqttClient.updateState(deviceId, {
state: state === 'ON' ? 1 : 0,
});
} else {
// eslint-disable-next-line prefer-destructuring
state = command.state;
commandObj = command;
}
if (state === 'ON') {
this.plejdBLEHandler.turnOn(deviceId, commandObj);
} else {
this.plejdBLEHandler.turnOff(deviceId, commandObj);
}
} catch (err) {
logger.error('Error in MqttClient.stateChanged callback in main.js', err);
}
});
this.mqttClient.init();
this.plejdBLEHandler.on('connected', () => {
logger.info('Bluetooth connected. Plejd BLE up and running!');
});
this.plejdBLEHandler.on('reconnecting', () => {
logger.info('Bluetooth reconnecting...');
});
// subscribe to changes from Plejd
this.plejdBLEHandler.on('stateChanged', (deviceId, command) => {
try {
this.mqttClient.updateState(deviceId, command);
} catch (err) {
logger.error('Error in PlejdService.stateChanged callback in main.js', err);
}
});
this.plejdBLEHandler.on('sceneTriggered', (deviceId, sceneId) => {
try {
this.mqttClient.sceneTriggered(sceneId);
} catch (err) {
logger.error('Error in PlejdService.sceneTriggered callback in main.js', err);
}
});
try {
await this.plejdBLEHandler.init();
} catch (err) {
logger.error('Failed init() of BLE. Starting reconnect loop.');
await this.plejdBLEHandler.startReconnectPeriodicallyLoop();
}
logger.info('Main init done');
}
}
module.exports = PlejdAddon;

View file

@ -1,5 +1,7 @@
const axios = require('axios');
const EventEmitter = require('events');
const axios = require('axios').default;
const fs = require('fs');
const Configuration = require('./Configuration');
const Logger = require('./Logger');
const API_APP_ID = 'zHtVqXt8k4yFyk2QGmgp48D9xZr2G94xWYnF4dak';
@ -10,271 +12,207 @@ const API_SITE_DETAILS_URL = 'functions/getSiteById';
const logger = Logger.getLogger('plejd-api');
class PlejdApi extends EventEmitter {
constructor(siteName, username, password, includeRoomsAsLights) {
super();
class PlejdApi {
config;
deviceRegistry;
sessionToken;
siteId;
siteDetails;
this.includeRoomsAsLights = includeRoomsAsLights;
this.siteName = siteName;
this.username = username;
this.password = password;
this.sessionToken = '';
this.site = null;
constructor(deviceRegistry) {
this.config = Configuration.getOptions();
this.deviceRegistry = deviceRegistry;
}
login() {
logger.info('login()');
logger.info(`logging into ${this.siteName}`);
const self = this;
async init() {
logger.info('init()');
const cache = await this.getCachedCopy();
const cacheExists = cache && cache.siteId && cache.siteDetails && cache.sessionToken;
const instance = axios.create({
baseURL: API_BASE_URL,
headers: {
'X-Parse-Application-Id': API_APP_ID,
'Content-Type': 'application/json',
},
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.deviceRegistry.cryptoKey = this.siteDetails.plejdMesh.cryptoKey;
this.getDevices();
}
// 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);
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 {
const rawData = JSON.stringify({
siteId: this.siteId,
siteDetails: this.siteDetails,
sessionToken: this.sessionToken,
dtCache: new Date().toISOString(),
});
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}`);
return new Promise((resolve, reject) => {
logger.debug(`sending POST to ${API_BASE_URL}${API_LOGIN_URL}`);
instance
.post(API_LOGIN_URL, {
username: this.username,
password: this.password,
})
.then((response) => {
try {
const response = await this._getAxiosInstance().post(API_LOGIN_URL, {
username: this.config.username,
password: this.config.password,
});
logger.info('got session token response');
self.sessionToken = response.data.sessionToken;
this.sessionToken = response.data.sessionToken;
if (!self.sessionToken) {
if (!this.sessionToken) {
logger.error('No session token received');
reject(new Error('no session token received.'));
throw new Error('API: No session token received.');
}
resolve();
})
.catch((error) => {
} 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 400. probably invalid credentials, please verify.',
'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)}`);
reject(new Error(`unable to retrieve session token response: ${error}`));
});
});
throw new Error(`API: Unable to retrieve session token response: ${error}`);
}
}
getSites() {
async getSites() {
logger.info('Get all Plejd sites for account...');
const self = this;
const instance = axios.create({
baseURL: API_BASE_URL,
headers: {
'X-Parse-Application-Id': API_APP_ID,
'X-Parse-Session-Token': this.sessionToken,
'Content-Type': 'application/json',
},
});
return new Promise((resolve, reject) => {
logger.debug(`sending POST to ${API_BASE_URL}${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);
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(`error: failed to find a site named ${self.siteName}`);
reject(new Error(`failed to find a site named ${self.siteName}`));
return;
logger.error(`Failed to find a site named ${this.config.site}`);
throw new Error(`API: Failed to find a site named ${this.config.site}`);
}
resolve(site);
})
.catch((error) => {
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);
return reject(new Error(`plejd-api: unable to retrieve list of sites. error: ${error}`));
});
});
throw new Error(`API: unable to retrieve list of sites. error: ${error}`);
}
}
getSite(siteId) {
logger.info('Get site details...');
const self = this;
async getSiteDetails() {
logger.info(`Get site details for ${this.siteId}...`);
const instance = axios.create({
baseURL: API_BASE_URL,
headers: {
'X-Parse-Application-Id': API_APP_ID,
'X-Parse-Session-Token': this.sessionToken,
'Content-Type': 'application/json',
},
});
return new Promise((resolve, reject) => {
logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_DETAILS_URL}`);
instance
.post(API_SITE_DETAILS_URL, { siteId })
.then((response) => {
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) {
const msg = `no site with ID ${siteId} was found.`;
logger.error(`error: ${msg}`);
reject(msg);
return;
logger.error(`No site with ID ${this.siteId} was found.`);
throw new Error(`API: No site with ID ${this.siteId} was found.`);
}
self.site = response.data.result[0];
self.cryptoKey = self.site.plejdMesh.cryptoKey;
this.siteDetails = response.data.result[0];
resolve(self.cryptoKey);
})
.catch((error) => {
logger.error('error: unable to retrieve the crypto key. error: ', error);
return reject(new Error(`plejd-api: unable to retrieve the crypto key. error: ${error}`));
});
});
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() {
const devices = [];
logger.info('Getting devices from site details response...');
logger.verbose(JSON.stringify(this.site));
const roomDevices = {};
for (let i = 0; i < this.site.devices.length; i++) {
const device = this.site.devices[i];
const { deviceId } = device;
const settings = this.site.outputSettings.find((x) => x.deviceParseId === device.objectId);
let deviceNum = this.site.deviceAddress[deviceId];
if (settings) {
const outputs = this.site.outputAddress[deviceId];
deviceNum = outputs[settings.output];
this._getPlejdDevices();
this._getRoomDevices();
this._getSceneDevices();
}
// 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';
}
const newDevice = {
id: deviceNum,
name: device.title,
type,
typeName: name,
dimmable,
version: plejdDevice.firmware.version,
serialNumber: plejdDevice.deviceId,
_getAxiosInstance() {
const headers = {
'X-Parse-Application-Id': API_APP_ID,
'Content-Type': 'application/json',
};
if (newDevice.typeName === 'WPH-01') {
// WPH-01 is special, it has two buttons which needs to be
// registered separately.
const inputs = this.site.inputAddress[deviceId];
const first = inputs[0];
const second = inputs[1];
let switchDevice = {
id: first,
name: `${device.title} knapp vä`,
type,
typeName: name,
dimmable,
version: plejdDevice.firmware.version,
serialNumber: plejdDevice.deviceId,
};
if (roomDevices[device.roomId]) {
roomDevices[device.roomId].push(switchDevice);
} else {
roomDevices[device.roomId] = [switchDevice];
}
devices.push(switchDevice);
switchDevice = {
id: second,
name: `${device.title} knapp hö`,
type,
typeName: name,
dimmable,
version: plejdDevice.firmware.version,
serialNumber: plejdDevice.deviceId,
};
if (roomDevices[device.roomId]) {
roomDevices[device.roomId].push(switchDevice);
} else {
roomDevices[device.roomId] = [switchDevice];
}
devices.push(switchDevice);
} else {
if (roomDevices[device.roomId]) {
roomDevices[device.roomId].push(newDevice);
} else {
roomDevices[device.roomId] = [newDevice];
if (this.sessionToken) {
headers['X-Parse-Session-Token'] = this.sessionToken;
}
devices.push(newDevice);
}
}
if (this.includeRoomsAsLights) {
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;
const roomAddress = this.site.roomAddress[roomId];
const newDevice = {
id: roomAddress,
name: room.title,
type: 'light',
typeName: 'Room',
dimmable: roomDevices[roomId].filter((x) => x.dimmable).length > 0,
};
devices.push(newDevice);
}
logger.debug('includeRoomsAsLights done.');
}
// add scenes as switches
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 = {
id: sceneNum,
name: scene.title,
type: 'switch',
typeName: 'Scene',
dimmable: false,
version: '1.0',
serialNumber: scene.objectId,
};
devices.push(newScene);
}
return devices;
return axios.create({
baseURL: API_BASE_URL,
headers,
});
}
// eslint-disable-next-line class-methods-use-this
@ -324,6 +262,111 @@ class PlejdApi extends EventEmitter {
throw new Error(`Unknown device type with id ${hardwareId}`);
}
}
_getPlejdDevices() {
this.deviceRegistry.clearPlejdDevices();
this.siteDetails.devices.forEach((device) => {
const { deviceId } = device;
const settings = this.siteDetails.outputSettings.find(
(x) => x.deviceParseId === device.objectId,
);
let deviceNum = this.siteDetails.deviceAddress[deviceId];
if (settings) {
const outputs = this.siteDetails.outputAddress[deviceId];
deviceNum = outputs[settings.output];
}
// check if device is dimmable
const plejdDevice = this.siteDetails.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';
}
const newDevice = {
id: deviceNum,
name: device.title,
type,
typeName: name,
dimmable,
roomId: device.roomId,
version: plejdDevice.firmware.version,
serialNumber: plejdDevice.deviceId,
};
if (newDevice.typeName === 'WPH-01') {
// WPH-01 is special, it has two buttons which needs to be
// registered separately.
const inputs = this.siteDetails.inputAddress[deviceId];
const first = inputs[0];
const second = inputs[1];
this.deviceRegistry.addPlejdDevice({
...newDevice,
id: first,
name: `${device.title} left`,
});
this.deviceRegistry.addPlejdDevice({
...newDevice,
id: second,
name: `${device.title} right`,
});
} else {
this.deviceRegistry.addPlejdDevice(newDevice);
}
});
}
_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 newDevice = {
id: roomAddress,
name: room.title,
type: 'light',
typeName: 'Room',
dimmable: this.deviceIdsByRoom[roomId].some(
(deviceId) => this.plejdDevices[deviceId].dimmable,
),
};
this.deviceRegistry.addRoomDevice(newDevice);
});
logger.debug('includeRoomsAsLights done.');
}
}
_getSceneDevices() {
// add scenes as switches
const scenes = this.siteDetails.scenes.filter((x) => x.hiddenFromSceneList === false);
scenes.forEach((scene) => {
const sceneNum = this.siteDetails.sceneIndex[scene.sceneId];
const newScene = {
id: sceneNum,
name: scene.title,
type: 'switch',
typeName: 'Scene',
dimmable: false,
version: '1.0',
serialNumber: scene.objectId,
};
this.deviceRegistry.addScene(newScene);
});
}
}
module.exports = PlejdApi;

1030
plejd/PlejdBLEHandler.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,807 +0,0 @@
const dbus = require('dbus-next');
const crypto = require('crypto');
const xor = require('buffer-xor');
const EventEmitter = require('events');
const Logger = require('./Logger');
const logger = Logger.getLogger('plejd-ble');
// UUIDs
const PLEJD_SERVICE = '31ba0001-6085-4726-be45-040c957391b5';
const DATA_UUID = '31ba0004-6085-4726-be45-040c957391b5';
const LAST_DATA_UUID = '31ba0005-6085-4726-be45-040c957391b5';
const AUTH_UUID = '31ba0009-6085-4726-be45-040c957391b5';
const PING_UUID = '31ba000a-6085-4726-be45-040c957391b5';
const BLE_CMD_DIM_CHANGE = '00c8';
const BLE_CMD_DIM2_CHANGE = '0098';
const BLE_CMD_STATE_CHANGE = '0097';
const BLE_CMD_SCENE_TRIG = '0021';
const BLUEZ_SERVICE_NAME = 'org.bluez';
const DBUS_OM_INTERFACE = 'org.freedesktop.DBus.ObjectManager';
const DBUS_PROP_INTERFACE = 'org.freedesktop.DBus.Properties';
const BLUEZ_ADAPTER_ID = 'org.bluez.Adapter1';
const BLUEZ_DEVICE_ID = 'org.bluez.Device1';
const GATT_SERVICE_ID = 'org.bluez.GattService1';
const GATT_CHRC_ID = 'org.bluez.GattCharacteristic1';
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) {
super();
logger.info('Starting Plejd BLE, resetting all device states.');
this.cryptoKey = Buffer.from(cryptoKey.replace(/-/g, ''), 'hex');
this.sceneManager = sceneManager;
this.connectedDevice = null;
this.plejdService = null;
this.bleDevices = [];
this.bleDeviceTransitionTimers = {};
this.plejdDevices = {};
this.devices = devices;
this.connectEventHooked = false;
this.connectionTimeout = connectionTimeout;
this.writeQueueWaitTime = writeQueueWaitTime;
this.writeQueue = [];
this.writeQueueRef = null;
this.initInProgress = null;
// Holds a reference to all characteristics
this.characteristics = {
data: null,
lastData: null,
lastDataProperties: null,
auth: null,
ping: null,
};
this.bus = dbus.systemBus();
this.adapter = null;
logger.debug('wiring events and waiting for BLE interface to power up.');
this.wireEvents();
}
async init() {
if (this.objectManager) {
this.objectManager.removeAllListeners();
}
this.bleDevices = [];
this.connectedDevice = null;
this.characteristics = {
data: null,
lastData: null,
lastDataProperties: null,
auth: null,
ping: null,
};
clearInterval(this.pingRef);
clearTimeout(this.writeQueueRef);
logger.info('init()');
const bluez = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, '/');
this.objectManager = await bluez.getInterface(DBUS_OM_INTERFACE);
// We need to find the ble interface which implements the Adapter1 interface
const managedObjects = await this.objectManager.GetManagedObjects();
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 Promise.reject(new Error('Unable to find a bluetooth adapter that is compatible.'));
}
// 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) {
const proxyObject = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, path);
const device = await proxyObject.getInterface(BLUEZ_DEVICE_ID);
const connected = managedObjects[path][BLUEZ_DEVICE_ID].Connected.value;
if (connected) {
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'),
});
try {
await this.adapter.StartDiscovery();
} catch (err) {
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,
));
}
async _internalInit() {
logger.debug(`Got ${this.bleDevices.length} device(s).`);
// eslint-disable-next-line no-restricted-syntax
for (const plejd of this.bleDevices) {
/* eslint-disable no-await-in-loop */
logger.debug(`Inspecting ${plejd.path}`);
try {
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;
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);
logger.debug(`Discovered ${plejd.path} with rssi ${plejd.rssi}`);
} catch (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);
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}`);
// eslint-disable-next-line no-await-in-loop
await plejd.instance.Connect();
connectedDevice = plejd;
break;
}
} catch (err) {
logger.error('Warning: unable to connect, will retry. ', err);
}
}
setTimeout(async () => {
await this.onDeviceConnected(connectedDevice);
await this.adapter.StopDiscovery();
}, this.connectionTimeout * 1000);
}
async _getInterface(managedObjects, iface) {
const managedPaths = Object.keys(managedObjects);
// 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) {
logger.error(`Failed to get interface '${iface}'. `, err);
}
}
}
return null;
}
async onInterfacesAdded(path, interfaces) {
// const [adapter, dev, service, characteristic] = path.split('/').slice(3);
const interfaceKeys = Object.keys(interfaces);
if (interfaceKeys.indexOf(BLUEZ_DEVICE_ID) > -1) {
if (interfaces[BLUEZ_DEVICE_ID].UUIDs.value.indexOf(PLEJD_SERVICE) > -1) {
logger.debug(`Found Plejd service on ${path}`);
this.bleDevices.push({
path,
});
} else {
logger.error('Uh oh, no Plejd device!');
}
}
}
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}` : ''
}`,
);
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})${
command.transition ? `, transition: ${command.transition}` : ''
}`,
);
this._transitionTo(deviceId, 0, command.transition, deviceName);
}
_clearDeviceTransitionTimer(deviceId) {
if (this.bleDeviceTransitionTimers[deviceId]) {
clearInterval(this.bleDeviceTransitionTimers[deviceId]);
}
}
_transitionTo(deviceId, targetBrightness, transition, deviceName) {
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;
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
const deltaBrightness = targetBrightness - initialBrightness;
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`,
);
const dtStart = new Date();
let nSteps = 0;
this.bleDeviceTransitionTimers[deviceId] = setInterval(() => {
const tElapsedMs = new Date().getTime() - dtStart.getTime();
let tElapsed = tElapsedMs / 1000;
if (tElapsed > transition || tElapsed < 0) {
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.`,
);
this._setBrightness(deviceId, newBrightness, true, deviceName);
} else {
nSteps++;
logger.verbose(
`Queueing dim transition for ${deviceName} (${deviceId}) to ${newBrightness}. Total queue length ${this.writeQueue.length}`,
);
this._setBrightness(deviceId, newBrightness, false, deviceName);
}
}, transitionInterval);
} 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}`,
);
}
this._setBrightness(deviceId, targetBrightness, true, deviceName);
}
}
_setBrightness(deviceId, brightness, shouldRetry, deviceName) {
let payload = null;
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');
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) {
// 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,
});
}
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.`,
);
this.sceneManager.executeScene(sceneIndex, this);
}
async authenticate() {
logger.info('authenticate()');
try {
logger.debug('Sending challenge to device');
await this.characteristics.auth.WriteValue([0], {});
logger.debug('Reading response from device');
const challenge = await this.characteristics.auth.ReadValue({});
const response = this._createChallengeResponse(this.cryptoKey, Buffer.from(challenge));
logger.debug('Responding to authenticate');
await this.characteristics.auth.WriteValue([...response], {});
} catch (err) {
logger.error('Failed to authenticate: ', err);
}
// auth done, start ping
this.startPing();
this.startWriteQueue();
// 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.lastData.StartNotify();
}
async throttledInit(delay) {
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;
}
async write(data) {
if (!data || !this.plejdService || !this.characteristics.data) {
logger.debug('data, plejdService or characteristics not available. Cannot write()');
return false;
}
try {
logger.verbose(`Sending ${data.length} byte(s) of data to Plejd. ${data.toString('hex')}`);
const encryptedData = this._encryptDecrypt(this.cryptoKey, this.plejdService.addr, data);
await this.characteristics.data.WriteValue([...encryptedData], {});
return true;
} catch (err) {
if (err.message === 'In Progress') {
logger.debug("Write failed due to 'In progress' ", err);
} else {
logger.debug('Write failed ', err);
}
await this.throttledInit(this.connectionTimeout * 1000);
return false;
}
}
startPing() {
logger.info('startPing()');
clearInterval(this.pingRef);
this.pingRef = setInterval(async () => {
logger.silly('ping');
await this.ping();
}, 3000);
}
// eslint-disable-next-line class-methods-use-this
onPingSuccess(nr) {
logger.silly(`pong: ${nr}`);
}
async onPingFailed(error) {
logger.debug(`onPingFailed(${error})`);
logger.info('ping failed, reconnecting.');
clearInterval(this.pingRef);
return this.init().catch((err) => {
logger.error('onPingFailed exception calling init(). Will swallow error.', err);
});
}
async ping() {
logger.silly('ping()');
const ping = crypto.randomBytes(1);
let pong = null;
try {
await this.characteristics.ping.WriteValue([...ping], {});
pong = await this.characteristics.ping.ReadValue({});
} catch (err) {
logger.error('Error writing to plejd: ', err);
this.emit('pingFailed', 'write error');
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]}`);
return;
}
this.emit('pingSuccess', pong[0]);
}
startWriteQueue() {
logger.info('startWriteQueue()');
clearTimeout(this.writeQueueRef);
this.writeQueueRef = setTimeout(() => this.runWriteQueue(), this.writeQueueWaitTime);
}
async runWriteQueue() {
try {
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}`,
);
if (this.writeQueue.some((item) => item.deviceId === queueItem.deviceId)) {
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
}
}
}
}
} catch (e) {
logger.error('Error in writeQueue loop, values probably not written to Plejd', e);
}
this.writeQueueRef = setTimeout(() => this.runWriteQueue(), this.writeQueueWaitTime);
}
async _processPlejdService(path, characteristics) {
const proxyObject = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, path);
const properties = await proxyObject.getInterface(DBUS_PROP_INTERFACE);
const uuid = (await properties.Get(GATT_SERVICE_ID, 'UUID')).value;
if (uuid !== PLEJD_SERVICE) {
logger.error('not a Plejd device.');
return null;
}
const dev = (await properties.Get(GATT_SERVICE_ID, 'Device')).value;
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',
),
);
// 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);
const chUuid = (await prop.Get(GATT_CHRC_ID, 'UUID')).value;
if (chUuid === DATA_UUID) {
logger.debug('found DATA characteristic.');
this.characteristics.data = ch;
} else if (chUuid === LAST_DATA_UUID) {
logger.debug('found LAST_DATA characteristic.');
this.characteristics.lastData = ch;
this.characteristics.lastDataProperties = prop;
} else if (chUuid === AUTH_UUID) {
logger.debug('found AUTH characteristic.');
this.characteristics.auth = ch;
} else if (chUuid === PING_UUID) {
logger.debug('found PING characteristic.');
this.characteristics.ping = ch;
}
/* eslint-eslint no-await-in-loop */
}
return {
addr,
};
}
async onDeviceConnected(device) {
logger.info('onDeviceConnected()');
logger.debug(`Device: ${device}`);
if (!device) {
logger.error('Device is null. Should we break/return when this happens?');
}
const objects = await this.objectManager.GetManagedObjects();
const paths = Object.keys(objects);
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) {
characteristics.push(path);
}
}
// 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) {
const chPaths = [];
// eslint-disable-next-line no-restricted-syntax
for (const c of characteristics) {
if (c.startsWith(`${path}/`)) {
chPaths.push(c);
}
}
logger.info(`trying ${chPaths.length} characteristics`);
this.plejdService = await this._processPlejdService(path, chPaths);
if (this.plejdService) {
break;
}
}
}
if (!this.plejdService) {
logger.info("warning: wasn't able to connect to Plejd, will retry.");
this.emit('connectFailed');
return;
}
if (!this.characteristics.auth) {
logger.error('unable to enumerate characteristics.');
this.emit('connectFailed');
return;
}
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;
}
const changedKeys = Object.keys(properties);
if (changedKeys.length === 0) {
return;
}
const value = await properties.Value;
if (!value) {
return;
}
const data = value.value;
const decoded = this._encryptDecrypt(this.cryptoKey, this.plejdService.addr, data);
const deviceId = parseInt(decoded[0], 10);
// 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) {
logger.debug(`Too short raw event ignored: ${decoded.toString('hex')}`);
// ignore the notification since too small
return;
}
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}`,
);
if (cmd === BLE_CMD_DIM_CHANGE || cmd === BLE_CMD_DIM2_CHANGE) {
const dim = data2;
logger.debug(`${deviceName} (${deviceId}) got state+dim update. S: ${state}, D: ${dim}`);
this.emit('stateChanged', deviceId, {
state,
brightness: dim,
});
this.plejdDevices[deviceId] = {
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,
});
this.plejdDevices[deviceId] = {
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.`,
);
this.emit('sceneTriggered', deviceId, sceneId);
} else if (cmd === '001b') {
logger.silly('Command 001b seems to be some kind of often repeating ping/mesh data');
} else {
logger.verbose(`Command ${cmd.toString('hex')} unknown. Device ${deviceName} (${deviceId})`);
}
}
wireEvents() {
logger.info('wireEvents()');
const self = this;
this.on('pingFailed', this.onPingFailed.bind(self));
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);
const part2 = intermediate.subarray(16);
const resp = xor(part1, part2);
return resp;
}
// eslint-disable-next-line class-methods-use-this
_encryptDecrypt(key, addr, data) {
const buf = Buffer.concat([addr, addr, addr.subarray(0, 4)]);
const cipher = crypto.createCipheriv('aes-128-ecb', key, '');
cipher.setAutoPadding(false);
let ct = cipher.update(buf).toString('hex');
ct += cipher.final().toString('hex');
ct = Buffer.from(ct, 'hex');
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]);
}
return Buffer.from(output, 'ascii');
}
_getDeviceName(deviceId) {
return (this.devices.find((d) => d.id === deviceId) || {}).name;
}
// eslint-disable-next-line class-methods-use-this
_reverseBuffer(src) {
const buffer = Buffer.allocUnsafe(src.length);
for (let i = 0, j = src.length - 1; i <= j; ++i, --j) {
buffer[i] = src[j];
buffer[j] = src[i];
}
return buffer;
}
}
module.exports = PlejdService;

View file

@ -67,7 +67,7 @@ Browse your Hass.io installation using a tool that allows you to manage files, f
### Install older versions or developemnt version
To install older versions, follow the "Manual Installation" instructions above, but copy the code from [one of the releases](https://github.com/icanos/hassio-plejd/releases). To test new functionality you can download the development version, available in the [develop branch](https://github.com/icanos/hassio-plejd/tree/feature/develop).
To install older versions, follow the "Manual Installation" instructions above, but copy the code from [one of the releases](https://github.com/icanos/hassio-plejd/releases). To test new functionality you can download the development version, available in the [develop branch](https://github.com/icanos/hassio-plejd/tree/develop).
### IMPORTANT INFORMATION
@ -122,7 +122,7 @@ The above is used to notify the add-on when Home Assistant has started successfu
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. |
@ -130,11 +130,12 @@ The plugin needs you to configure some settings before working. You find these o
| 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. |
| updatePlejdClock | Hourly update Plejd devices' clock if out of sync. Clock is used for time-based scenes. Not recommended if you have a Plejd gateway. Clock updates may flicker scene-controlled devices. |
| logLevel | Minimim log level. Supported values are `error`, `warn`, `info`, `debug`, `verbose`, `silly` with increasing amount of logging. Do not log more than `info` for production purposes. |
| 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. |
| writeQueueWaitTime | Wait time between message sent to Plejd over BLE, defaults to 400. If that doesn't work, try changing the value higher in steps of 50. |
## Having issues to get the addon working?
## Troubleshooting
If you're having issues to get the addon working, there are a few things you can look into:

View file

@ -1,48 +1,54 @@
/* eslint-disable max-classes-per-file */
const EventEmitter = require('events');
const Logger = require('./Logger');
const Scene = require('./Scene');
const logger = Logger.getLogger('scene-manager');
class SceneManager extends EventEmitter {
constructor(site, devices) {
deviceRegistry;
plejdBle;
scenes;
constructor(deviceRegistry, plejdBle) {
super();
this.site = site;
this.scenes = [];
this.devices = devices;
this.init();
this.deviceRegistry = deviceRegistry;
this.plejdBle = plejdBle;
this.scenes = {};
}
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));
}
const scenes = this.deviceRegistry.apiSite.scenes.filter(
(x) => x.hiddenFromSceneList === false,
);
this.scenes = {};
scenes.forEach((scene) => {
const idx = this.deviceRegistry.apiSite.sceneIndex[scene.sceneId];
this.scenes[idx] = new Scene(idx, scene, this.deviceRegistry.apiSite.sceneSteps);
});
}
executeScene(sceneIndex, ble) {
const scene = this.scenes.find((x) => x.id === sceneIndex);
executeScene(sceneId) {
const scene = this.scenes[sceneId];
if (!scene) {
logger.info(`Scene with id ${sceneId} not found`);
logger.verbose(`Scenes: ${JSON.stringify(this.scenes, null, 2)}`);
return;
}
// eslint-disable-next-line no-restricted-syntax
for (const step of scene.steps) {
const device = this.devices.find((x) => x.serialNumber === step.deviceId);
scene.steps.forEach((step) => {
const device = this.deviceRegistry.getDeviceBySerialNumber(step.deviceId);
if (device) {
if (device.dimmable && step.state) {
ble.turnOn(device.id, { brightness: step.brightness });
this.plejdBle.turnOn(device.id, { brightness: step.brightness });
} else if (!device.dimmable && step.state) {
ble.turnOn(device.id, {});
this.plejdBle.turnOn(device.id, {});
} else if (!step.state) {
ble.turnOff(device.id, {});
}
this.plejdBle.turnOff(device.id, {});
}
}
});
}
}
module.exports = SceneManager;
/* eslint-disable */

View file

@ -1,6 +1,6 @@
{
"name": "Plejd",
"version": "0.5.1",
"version": "0.6.1",
"slug": "plejd",
"description": "Adds support for the Swedish home automation devices from Plejd.",
"url": "https://github.com/icanos/hassio-plejd/",
@ -18,6 +18,8 @@
"mqttUsername": "",
"mqttPassword": "",
"includeRoomsAsLights": false,
"preferCachedApiResponse": false,
"updatePlejdClock": false,
"logLevel": "info",
"connectionTimeout": 2,
"writeQueueWaitTime": 400
@ -30,6 +32,8 @@
"mqttUsername": "str",
"mqttPassword": "str",
"includeRoomsAsLights": "bool",
"preferCachedApiResponse": "bool",
"updatePlejdClock": "bool",
"logLevel": "list(error|warn|info|debug|verbose|silly)",
"connectionTimeout": "int",
"writeQueueWaitTime": "int"

View file

@ -1,144 +1,28 @@
const PlejdApi = require('./PlejdApi');
const MqttClient = require('./MqttClient');
const Logger = require('./Logger');
const PlejdService = require('./PlejdService');
const SceneManager = require('./SceneManager');
const Configuration = require('./Configuration');
const logger = Logger.getLogger('plejd-main');
const version = '0.5.1';
const Logger = require('./Logger');
const PlejdAddon = require('./PlejdAddon');
async function main() {
logger.info(`Starting Plejd add-on v. ${version}`);
const config = Configuration.getConfiguration();
if (!config.connectionTimeout) {
config.connectionTimeout = 2;
}
const plejdApi = new PlejdApi(
config.site,
config.username,
config.password,
config.includeRoomsAsLights,
);
const client = new MqttClient(config.mqttBroker, config.mqttUsername, config.mqttPassword);
['SIGINT', 'SIGHUP', 'SIGTERM'].forEach((signal) => {
process.on(signal, () => {
client.disconnect(() => process.exit(0));
});
});
plejdApi.login().then(() => {
// load all sites and find the one that we want (from config)
plejdApi.getSites().then((site) => {
// load the site and retrieve the crypto key
plejdApi.getSite(site.site.siteId).then((cryptoKey) => {
// parse all devices from the API
const devices = plejdApi.getDevices();
client.on('connected', () => {
try {
logger.verbose('connected to mqtt.');
client.discover(devices);
// eslint-disable-next-line no-console
console.log('Starting Plejd addon and reading configuration...');
const addonInfo = Configuration.getAddonInfo();
const logger = Logger.getLogger('plejd-main');
logger.info(`Plejd add-on, version ${addonInfo.version}`);
logger.verbose(`Addon info: ${JSON.stringify(addonInfo)}`);
const addon = new PlejdAddon();
await addon.init();
logger.info('main() finished');
} catch (err) {
logger.error('Error in MqttClient.connected callback in main.js', err);
// eslint-disable-next-line no-console
console.log('Catastrophic error. Resetting entire addon in 1 minute', err);
setTimeout(() => main(), 60000);
}
});
client.init();
// init the BLE interface
const sceneManager = new SceneManager(plejdApi.site, devices);
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(() => {
plejd
.init()
.catch((e) => logger.error('Error in init() from connectFailed in main.js', e));
}, 10000);
});
plejd.init();
plejd.on('authenticated', () => {
logger.verbose('plejd: connected via bluetooth.');
});
// subscribe to changes from Plejd
plejd.on('stateChanged', (deviceId, command) => {
try {
client.updateState(deviceId, command);
} catch (err) {
logger.error('Error in PlejdService.stateChanged callback in main.js', err);
}
});
plejd.on('sceneTriggered', (deviceId, scene) => {
try {
client.sceneTriggered(scene);
} catch (err) {
logger.error('Error in PlejdService.sceneTriggered callback in main.js', err);
}
});
// subscribe to changes from HA
client.on('stateChanged', (device, command) => {
try {
const deviceId = device.id;
if (device.typeName === 'Scene') {
// we're triggering a scene, lets do that and jump out.
// since scenes aren't "real" devices.
plejd.triggerScene(device.id);
return;
}
let state = 'OFF';
let commandObj = {};
if (typeof command === 'string') {
// switch command
state = command;
commandObj = {
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,
});
} else {
// eslint-disable-next-line prefer-destructuring
state = command.state;
commandObj = command;
}
if (state === 'ON') {
plejd.turnOn(deviceId, commandObj);
} else {
plejd.turnOff(deviceId, commandObj);
}
} catch (err) {
logger.error('Error in MqttClient.stateChanged callback in main.js', err);
}
});
});
});
});
}
main();

View file

@ -1,13 +1,12 @@
{
"dependencies": {
"@abandonware/bluetooth-hci-socket": "0.5.3-3",
"@abandonware/bluetooth-hci-socket": "~0.5.3-7",
"axios": "~0.21.1",
"buffer-xor": "~2.0.2",
"dbus-next": "~0.9.1",
"fs": "0.0.1-security",
"jspack": "~0.0.4",
"mqtt": "~3.0.0",
"sleep": "~6.1.0",
"winston": "~3.3.3"
},
"devDependencies": {

View file

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