Merge pull request #164 from SweVictor/features/code-architecture
Features/code architecture
This commit is contained in:
commit
05f7aa6906
15 changed files with 1136 additions and 753 deletions
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -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
77
plejd/DeviceRegistry.js
Normal 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;
|
||||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ class Logger {
|
|||
}
|
||||
|
||||
static getLogLevel() {
|
||||
const config = Configuration.getConfiguration();
|
||||
const config = Configuration.getOptions();
|
||||
// eslint-disable-next-line max-len
|
||||
const level = (config.logLevel && LEVELS.find((l) => l.startsWith(config.logLevel[0].toLowerCase())))
|
||||
|| 'info';
|
||||
|
|
|
|||
|
|
@ -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
136
plejd/PlejdAddon.js
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -4,14 +4,17 @@ const xor = require('buffer-xor');
|
|||
const EventEmitter = require('events');
|
||||
const Logger = require('./Logger');
|
||||
|
||||
const Configuration = require('./Configuration');
|
||||
|
||||
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_UUID_SUFFIX = '6085-4726-be45-040c957391b5';
|
||||
const PLEJD_SERVICE = `31ba0001-${BLE_UUID_SUFFIX}`;
|
||||
const DATA_UUID = `31ba0004-${BLE_UUID_SUFFIX}`;
|
||||
const LAST_DATA_UUID = `31ba0005-${BLE_UUID_SUFFIX}`;
|
||||
const AUTH_UUID = `31ba0009-${BLE_UUID_SUFFIX}`;
|
||||
const PING_UUID = `31ba000a-${BLE_UUID_SUFFIX}`;
|
||||
|
||||
const BLE_CMD_DIM_CHANGE = 0xc8;
|
||||
const BLE_CMD_DIM2_CHANGE = 0x98;
|
||||
|
|
@ -30,27 +33,39 @@ 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) {
|
||||
const delay = (timeout) => new Promise((resolve) => setTimeout(resolve, timeout));
|
||||
|
||||
class PlejBLEHandler extends EventEmitter {
|
||||
adapter;
|
||||
adapterProperties;
|
||||
config;
|
||||
bleDevices = [];
|
||||
bleDeviceTransitionTimers = {};
|
||||
bus = null;
|
||||
connectedDevice = null;
|
||||
consecutiveWriteFails;
|
||||
deviceRegistry;
|
||||
discoveryTimeout = null;
|
||||
plejdService = null;
|
||||
plejdDevices = {};
|
||||
pingRef = null;
|
||||
writeQueue = [];
|
||||
writeQueueRef = null;
|
||||
reconnectInProgress = false;
|
||||
|
||||
// Refer to BLE-states.md regarding the internal BLE/bluez state machine of Bluetooth states
|
||||
// These states refer to the state machine of this file
|
||||
static STATES = ['MAIN_INIT', 'GET_ADAPTER_PROXY'];
|
||||
|
||||
static EVENTS = ['connected', 'reconnecting', 'sceneTriggered', 'stateChanged'];
|
||||
|
||||
constructor(deviceRegistry) {
|
||||
super();
|
||||
|
||||
logger.info('Starting Plejd BLE, resetting all device states.');
|
||||
logger.info('Starting Plejd BLE Handler, 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;
|
||||
this.config = Configuration.getOptions();
|
||||
this.deviceRegistry = deviceRegistry;
|
||||
|
||||
// Holds a reference to all characteristics
|
||||
this.characteristics = {
|
||||
|
|
@ -61,14 +76,29 @@ class PlejdService extends EventEmitter {
|
|||
ping: null,
|
||||
};
|
||||
|
||||
this.bus = dbus.systemBus();
|
||||
this.adapter = null;
|
||||
|
||||
logger.debug('wiring events and waiting for BLE interface to power up.');
|
||||
this.wireEvents();
|
||||
this.on('writeFailed', (error) => this.onWriteFailed(error));
|
||||
this.on('writeSuccess', () => this.onWriteSuccess());
|
||||
}
|
||||
|
||||
async init() {
|
||||
logger.info('init()');
|
||||
|
||||
this.bus = dbus.systemBus();
|
||||
this.bus.on('connect', () => {
|
||||
logger.verbose('dbus-next connected');
|
||||
});
|
||||
this.bus.on('error', (err) => {
|
||||
// Uncaught error events will show UnhandledPromiseRejection logs
|
||||
logger.verbose(`dbus-next error event: ${err.message}`);
|
||||
});
|
||||
// this.bus also has a 'message' event that gets emitted _very_ frequently
|
||||
|
||||
this.adapter = null;
|
||||
this.adapterProperties = null;
|
||||
this.consecutiveWriteFails = 0;
|
||||
|
||||
this.cryptoKey = Buffer.from(this.deviceRegistry.cryptoKey.replace(/-/g, ''), 'hex');
|
||||
|
||||
if (this.objectManager) {
|
||||
this.objectManager.removeAllListeners();
|
||||
}
|
||||
|
|
@ -84,164 +114,276 @@ class PlejdService extends EventEmitter {
|
|||
ping: null,
|
||||
};
|
||||
|
||||
clearInterval(this.pingRef);
|
||||
clearTimeout(this.writeQueueRef);
|
||||
logger.info('init()');
|
||||
await this._getInterface();
|
||||
await this._startGetPlejdDevice();
|
||||
|
||||
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];
|
||||
logger.info('BLE init done, waiting for devices.');
|
||||
}
|
||||
|
||||
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.'));
|
||||
}
|
||||
async _initDiscoveredPlejdDevice(path) {
|
||||
logger.debug(`initDiscoveredPlejdDevice(). Got ${path} device`);
|
||||
|
||||
// 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]);
|
||||
logger.debug(`Inspecting ${path}`);
|
||||
|
||||
if (interfaces.indexOf(BLUEZ_DEVICE_ID) > -1) {
|
||||
try {
|
||||
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);
|
||||
|
||||
const plejd = { path };
|
||||
|
||||
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);
|
||||
plejd.device = this.deviceRegistry.getDeviceBySerialNumber(fixedPlejdPath);
|
||||
|
||||
logger.debug(`Discovered ${plejd.path} with rssi ${plejd.rssi}`);
|
||||
logger.debug(`Discovered ${plejd.path} with rssi ${plejd.rssi}, name ${plejd.device.name}`);
|
||||
// Todo: Connect should probably be done here
|
||||
this.bleDevices.push(plejd);
|
||||
} catch (err) {
|
||||
logger.error(`Failed inspecting ${plejd.path}. `, err);
|
||||
logger.error(`Failed inspecting ${path}. `, err);
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
}
|
||||
|
||||
async _inspectDevicesDiscovered() {
|
||||
try {
|
||||
if (this.bleDevices.length === 0) {
|
||||
logger.error('Discovery timeout elapsed, no devices found. Starting reconnect loop...');
|
||||
throw new Error('Discovery timeout elapsed');
|
||||
}
|
||||
|
||||
logger.info(`Device discovery done, found ${this.bleDevices.length} Plejd devices`);
|
||||
|
||||
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 {
|
||||
logger.verbose(`Inspecting ${plejd.path}`);
|
||||
if (plejd.instance) {
|
||||
logger.info(`Connecting to ${plejd.path}`);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await plejd.instance.Connect();
|
||||
connectedDevice = plejd;
|
||||
|
||||
logger.verbose('Connected. Waiting for timeout before reading characteristics...');
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await delay(this.config.connectionTimeout * 1000);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const connectedPlejdDevice = await this._onDeviceConnected(plejd);
|
||||
if (connectedPlejdDevice) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Warning: unable to connect, will retry. ', err);
|
||||
logger.warn('Unable to connect. ', err);
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
await this.onDeviceConnected(connectedDevice);
|
||||
try {
|
||||
logger.verbose('Stopping discovery...');
|
||||
await this.adapter.StopDiscovery();
|
||||
}, this.connectionTimeout * 1000);
|
||||
logger.verbose('Stopped BLE discovery');
|
||||
} catch (err) {
|
||||
logger.error('Failed to stop discovery.', err);
|
||||
if (err.message.includes('Operation already in progress')) {
|
||||
logger.info(
|
||||
'If you continue to get "operation already in progress" error, you can try power cycling the bluetooth adapter. Get root console access, run "bluetoothctl" => "power off" => "power on" => "exit" => restart addon.',
|
||||
);
|
||||
try {
|
||||
await delay(250);
|
||||
logger.verbose('Power cycling...');
|
||||
await this._powerCycleAdapter();
|
||||
logger.verbose('Trying again...');
|
||||
await this._startGetPlejdDevice();
|
||||
} catch (errInner) {
|
||||
logger.error('Failed to retry internalInit. Starting reconnect loop', errInner);
|
||||
throw new Error('Failed to retry internalInit');
|
||||
}
|
||||
}
|
||||
logger.error('Failed to start discovery. Make sure no other add-on is currently scanning.');
|
||||
throw new Error('Failed to start discovery');
|
||||
}
|
||||
|
||||
async _getInterface(managedObjects, iface) {
|
||||
if (!this.connectedDevice) {
|
||||
logger.error('Could not connect to any Plejd device. Starting reconnect loop...');
|
||||
throw new Error('Could not connect to any Plejd device');
|
||||
}
|
||||
|
||||
logger.info(`BLE Connected to ${this.connectedDevice.name}`);
|
||||
this.emit('connected');
|
||||
|
||||
// Connected and authenticated, 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', (
|
||||
iface,
|
||||
properties,
|
||||
// invalidated (third param),
|
||||
) => this.onLastDataUpdated(iface, properties));
|
||||
this.characteristics.lastData.StartNotify();
|
||||
} catch (err) {
|
||||
// This method is run on a timer, so errors can't e re-thrown.
|
||||
// Start reconnect loop if errors occur here
|
||||
logger.debug(`Starting reconnect loop due to ${err.message}`);
|
||||
this.startReconnectPeriodicallyLoop();
|
||||
}
|
||||
}
|
||||
|
||||
async _getInterface() {
|
||||
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 managedPaths = Object.keys(managedObjects);
|
||||
|
||||
logger.verbose(`Managed paths${JSON.stringify(managedPaths, null, 2)}`);
|
||||
|
||||
// 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}`);
|
||||
if (pathInterfaces.indexOf(BLUEZ_ADAPTER_ID) > -1) {
|
||||
logger.debug(`Found BLE interface '${BLUEZ_ADAPTER_ID}' 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];
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
this.adapterProperties = await adapterObject.getInterface(DBUS_PROP_INTERFACE);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this._powerOnAdapter();
|
||||
this.adapter = adapterObject.getInterface(BLUEZ_ADAPTER_ID);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this._cleanExistingConnections(managedObjects);
|
||||
|
||||
logger.verbose(`Got adapter ${this.adapter.path}`);
|
||||
|
||||
return this.adapter;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to get interface '${iface}'. `, err);
|
||||
logger.error(`Failed to get interface '${BLUEZ_ADAPTER_ID}'. `, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
this.adapter = null;
|
||||
logger.error('Unable to find a bluetooth adapter that is compatible.');
|
||||
throw new Error('Unable to find a bluetooth adapter that is compatible.');
|
||||
}
|
||||
|
||||
async _powerCycleAdapter() {
|
||||
await this._powerOffAdapter();
|
||||
await this._powerOnAdapter();
|
||||
}
|
||||
|
||||
async _powerOnAdapter() {
|
||||
await this.adapterProperties.Set(BLUEZ_ADAPTER_ID, 'Powered', new dbus.Variant('b', 1));
|
||||
await delay(1000);
|
||||
}
|
||||
|
||||
async _powerOffAdapter() {
|
||||
await this.adapterProperties.Set(BLUEZ_ADAPTER_ID, 'Powered', new dbus.Variant('b', 0));
|
||||
await delay(1000);
|
||||
}
|
||||
|
||||
async _cleanExistingConnections(managedObjects) {
|
||||
logger.verbose(
|
||||
`Iterating ${
|
||||
Object.keys(managedObjects).length
|
||||
} BLE managedObjects looking for ${BLUEZ_DEVICE_ID}`,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const path of Object.keys(managedObjects)) {
|
||||
/* eslint-disable no-await-in-loop */
|
||||
try {
|
||||
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);
|
||||
|
||||
logger.verbose(`Found ${path}`);
|
||||
|
||||
const connected = managedObjects[path][BLUEZ_DEVICE_ID].Connected.value;
|
||||
|
||||
if (connected) {
|
||||
logger.info(`disconnecting ${path}. This can take up to 180 seconds`);
|
||||
await device.Disconnect();
|
||||
}
|
||||
|
||||
logger.verbose(`Removing ${path} from adapter.`);
|
||||
await this.adapter.RemoveDevice(path);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error handling ${path}`, err);
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
}
|
||||
|
||||
logger.verbose('All active BLE device connections cleaned up.');
|
||||
}
|
||||
|
||||
async _startGetPlejdDevice() {
|
||||
logger.verbose('Setting up interfacesAdded subscription and discovery filter');
|
||||
this.objectManager.on('InterfacesAdded', (path, interfaces) => this.onInterfacesAdded(path, interfaces));
|
||||
|
||||
this.adapter.SetDiscoveryFilter({
|
||||
UUIDs: new dbus.Variant('as', [PLEJD_SERVICE]),
|
||||
Transport: new dbus.Variant('s', 'le'),
|
||||
});
|
||||
|
||||
try {
|
||||
logger.verbose('Starting BLE discovery... This can take up to 180 seconds.');
|
||||
this._scheduleInternalInit();
|
||||
await this.adapter.StartDiscovery();
|
||||
logger.verbose('Started BLE discovery');
|
||||
} catch (err) {
|
||||
logger.error('Failed to start discovery.', err);
|
||||
if (err.message.includes('Operation already in progress')) {
|
||||
logger.info(
|
||||
'If you continue to get "operation already in progress" error, you can try power cycling the bluetooth adapter. Get root console access, run "bluetoothctl" => "power off" => "power on" => "exit" => restart addon.',
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
'Failed to start discovery. Make sure no other add-on is currently scanning.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_scheduleInternalInit() {
|
||||
clearTimeout(this.discoveryTimeout);
|
||||
this.discoveryTimeout = setTimeout(
|
||||
() => this._inspectDevicesDiscovered(),
|
||||
this.config.connectionTimeout * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
async onInterfacesAdded(path, interfaces) {
|
||||
logger.silly(`Interface added ${path}, inspecting...`);
|
||||
// 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,
|
||||
});
|
||||
|
||||
await this._initDiscoveredPlejdDevice(path);
|
||||
} else {
|
||||
logger.error('Uh oh, no Plejd device!');
|
||||
}
|
||||
} else {
|
||||
logger.silly('Not the right device id');
|
||||
}
|
||||
}
|
||||
|
||||
turnOn(deviceId, command) {
|
||||
const deviceName = this._getDeviceName(deviceId);
|
||||
const deviceName = this.deviceRegistry.getDeviceName(deviceId);
|
||||
logger.info(
|
||||
`Plejd got turn on command for ${deviceName} (${deviceId}), brightness ${command.brightness}${
|
||||
command.transition ? `, transition: ${command.transition}` : ''
|
||||
|
|
@ -251,7 +393,7 @@ class PlejdService extends EventEmitter {
|
|||
}
|
||||
|
||||
turnOff(deviceId, command) {
|
||||
const deviceName = this._getDeviceName(deviceId);
|
||||
const deviceName = this.deviceRegistry.getDeviceName(deviceId);
|
||||
logger.info(
|
||||
`Plejd got turn off command for ${deviceName} (${deviceId})${
|
||||
command.transition ? `, transition: ${command.transition}` : ''
|
||||
|
|
@ -272,7 +414,7 @@ class PlejdService extends EventEmitter {
|
|||
: null;
|
||||
this._clearDeviceTransitionTimer(deviceId);
|
||||
|
||||
const isDimmable = this.devices.find((d) => d.id === deviceId).dimmable;
|
||||
const isDimmable = this.deviceRegistry.getDevice(deviceId).dimmable;
|
||||
|
||||
if (
|
||||
transition > 1
|
||||
|
|
@ -386,14 +528,6 @@ class PlejdService extends EventEmitter {
|
|||
});
|
||||
}
|
||||
|
||||
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()');
|
||||
|
||||
|
|
@ -407,37 +541,36 @@ class PlejdService extends EventEmitter {
|
|||
await this.characteristics.auth.WriteValue([...response], {});
|
||||
} catch (err) {
|
||||
logger.error('Failed to authenticate: ', err);
|
||||
throw new Error('Failed to authenticate');
|
||||
}
|
||||
}
|
||||
|
||||
// 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 startReconnectPeriodicallyLoop() {
|
||||
logger.verbose('startReconnectPeriodicallyLoop');
|
||||
if (this.reconnectInProgress) {
|
||||
logger.debug('Reconnect already in progress. Skipping this call.');
|
||||
return;
|
||||
}
|
||||
clearInterval(this.pingRef);
|
||||
clearTimeout(this.writeQueueRef);
|
||||
this.reconnectInProgress = true;
|
||||
|
||||
async throttledInit(delay) {
|
||||
if (this.initInProgress) {
|
||||
logger.debug(
|
||||
'ThrottledInit already in progress. Skipping this call and returning existing promise.',
|
||||
);
|
||||
return this.initInProgress;
|
||||
/* eslint-disable no-await-in-loop */
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
await delay(5000);
|
||||
this.emit('reconnecting');
|
||||
logger.info('Reconnecting BLE...');
|
||||
await this.init();
|
||||
break;
|
||||
} catch (err) {
|
||||
logger.warn('Failed reconnecting.', err);
|
||||
}
|
||||
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;
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
|
||||
this.reconnectInProgress = false;
|
||||
}
|
||||
|
||||
async write(data) {
|
||||
|
|
@ -450,6 +583,7 @@ class PlejdService extends EventEmitter {
|
|||
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], {});
|
||||
await this.onWriteSuccess();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err.message === 'In Progress') {
|
||||
|
|
@ -457,7 +591,7 @@ class PlejdService extends EventEmitter {
|
|||
} else {
|
||||
logger.debug('Write failed ', err);
|
||||
}
|
||||
await this.throttledInit(this.connectionTimeout * 1000);
|
||||
await this.onWriteFailed(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -473,18 +607,34 @@ class PlejdService extends EventEmitter {
|
|||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onPingSuccess(nr) {
|
||||
logger.silly(`pong: ${nr}`);
|
||||
onWriteSuccess() {
|
||||
this.consecutiveWriteFails = 0;
|
||||
}
|
||||
|
||||
async onPingFailed(error) {
|
||||
logger.debug(`onPingFailed(${error})`);
|
||||
logger.info('ping failed, reconnecting.');
|
||||
async onWriteFailed(error) {
|
||||
this.consecutiveWriteFails++;
|
||||
logger.debug(`onWriteFailed #${this.consecutiveWriteFails} in a row.`, error);
|
||||
logger.verbose(`Error message: ${error.message}`);
|
||||
|
||||
clearInterval(this.pingRef);
|
||||
return this.init().catch((err) => {
|
||||
logger.error('onPingFailed exception calling init(). Will swallow error.', err);
|
||||
});
|
||||
let errorIndicatesDisconnected = false;
|
||||
|
||||
if (error.message.includes('error: 0x0e')) {
|
||||
logger.error("'Unlikely error' (0x0e) writing to Plejd. Will retry.", error);
|
||||
} else if (error.message.includes('Not connected')) {
|
||||
logger.error("'Not connected' writing to Plejd. Plejd device is probably disconnected.");
|
||||
errorIndicatesDisconnected = true;
|
||||
} else if (error.message.includes('Method "WriteValue" with signature')) {
|
||||
logger.error("'Method \"WriteValue\" doesn't exist'. Plejd device is probably disconnected.");
|
||||
errorIndicatesDisconnected = true;
|
||||
}
|
||||
logger.verbose(`Made it ${errorIndicatesDisconnected} || ${this.consecutiveWriteFails >= 5}`);
|
||||
|
||||
if (errorIndicatesDisconnected || this.consecutiveWriteFails >= 5) {
|
||||
logger.warn(
|
||||
`Write error indicates BLE is disconnected. Retry count ${this.consecutiveWriteFails}. Reconnecting...`,
|
||||
);
|
||||
this.startReconnectPeriodicallyLoop();
|
||||
}
|
||||
}
|
||||
|
||||
async ping() {
|
||||
|
|
@ -497,33 +647,34 @@ class PlejdService extends EventEmitter {
|
|||
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');
|
||||
logger.verbose(`Error pinging Plejd, calling onWriteFailed... ${err.message}`);
|
||||
await this.onWriteFailed(err);
|
||||
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]}`);
|
||||
logger.verbose('Plejd ping failed, pong contains wrong data. Calling onWriteFailed...');
|
||||
await this.onWriteFailed(new Error(`plejd ping failed ${ping[0]} - ${pong[0]}`));
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit('pingSuccess', pong[0]);
|
||||
logger.silly(`pong: ${pong[0]}`);
|
||||
await this.onWriteSuccess();
|
||||
}
|
||||
|
||||
startWriteQueue() {
|
||||
logger.info('startWriteQueue()');
|
||||
clearTimeout(this.writeQueueRef);
|
||||
|
||||
this.writeQueueRef = setTimeout(() => this.runWriteQueue(), this.writeQueueWaitTime);
|
||||
this.writeQueueRef = setTimeout(() => this.runWriteQueue(), this.config.writeQueueWaitTime);
|
||||
}
|
||||
|
||||
async runWriteQueue() {
|
||||
try {
|
||||
while (this.writeQueue.length > 0) {
|
||||
const queueItem = this.writeQueue.pop();
|
||||
const deviceName = this._getDeviceName(queueItem.deviceId);
|
||||
const deviceName = this.deviceRegistry.getDeviceName(queueItem.deviceId);
|
||||
logger.debug(
|
||||
`Write queue: Processing ${deviceName} (${queueItem.deviceId}). Command ${queueItem.log}. Total queue length: ${this.writeQueue.length}`,
|
||||
);
|
||||
|
|
@ -559,7 +710,7 @@ class PlejdService extends EventEmitter {
|
|||
logger.error('Error in writeQueue loop, values probably not written to Plejd', e);
|
||||
}
|
||||
|
||||
this.writeQueueRef = setTimeout(() => this.runWriteQueue(), this.writeQueueWaitTime);
|
||||
this.writeQueueRef = setTimeout(() => this.runWriteQueue(), this.config.writeQueueWaitTime);
|
||||
}
|
||||
|
||||
async _processPlejdService(path, characteristics) {
|
||||
|
|
@ -592,17 +743,17 @@ class PlejdService extends EventEmitter {
|
|||
const chUuid = (await prop.Get(GATT_CHRC_ID, 'UUID')).value;
|
||||
|
||||
if (chUuid === DATA_UUID) {
|
||||
logger.debug('found DATA characteristic.');
|
||||
logger.verbose('found DATA characteristic.');
|
||||
this.characteristics.data = ch;
|
||||
} else if (chUuid === LAST_DATA_UUID) {
|
||||
logger.debug('found LAST_DATA characteristic.');
|
||||
logger.verbose('found LAST_DATA characteristic.');
|
||||
this.characteristics.lastData = ch;
|
||||
this.characteristics.lastDataProperties = prop;
|
||||
} else if (chUuid === AUTH_UUID) {
|
||||
logger.debug('found AUTH characteristic.');
|
||||
logger.verbose('found AUTH characteristic.');
|
||||
this.characteristics.auth = ch;
|
||||
} else if (chUuid === PING_UUID) {
|
||||
logger.debug('found PING characteristic.');
|
||||
logger.verbose('found PING characteristic.');
|
||||
this.characteristics.ping = ch;
|
||||
}
|
||||
/* eslint-eslint no-await-in-loop */
|
||||
|
|
@ -613,25 +764,26 @@ class PlejdService extends EventEmitter {
|
|||
};
|
||||
}
|
||||
|
||||
async onDeviceConnected(device) {
|
||||
async _onDeviceConnected(device) {
|
||||
this.connectedDevice = null;
|
||||
logger.info('onDeviceConnected()');
|
||||
logger.debug(`Device: ${device}`);
|
||||
if (!device) {
|
||||
logger.error('Device is null. Should we break/return when this happens?');
|
||||
}
|
||||
logger.debug(`Device ${device.path}, ${JSON.stringify(device.device)}`);
|
||||
|
||||
const objects = await this.objectManager.GetManagedObjects();
|
||||
const paths = Object.keys(objects);
|
||||
const characteristics = [];
|
||||
|
||||
logger.verbose(`Iterating connected devices looking for ${GATT_CHRC_ID}`);
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const path of paths) {
|
||||
const interfaces = Object.keys(objects[path]);
|
||||
logger.verbose(`Interfaces ${path}: ${JSON.stringify(interfaces)}`);
|
||||
if (interfaces.indexOf(GATT_CHRC_ID) > -1) {
|
||||
characteristics.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
logger.verbose(`Characteristics found: ${JSON.stringify(characteristics)}`);
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const path of paths) {
|
||||
const interfaces = Object.keys(objects[path]);
|
||||
|
|
@ -644,7 +796,7 @@ class PlejdService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
logger.info(`trying ${chPaths.length} characteristics`);
|
||||
logger.verbose(`Trying ${chPaths.length} characteristics on ${path}...`);
|
||||
|
||||
this.plejdService = await this._processPlejdService(path, chPaths);
|
||||
if (this.plejdService) {
|
||||
|
|
@ -654,23 +806,25 @@ class PlejdService extends EventEmitter {
|
|||
}
|
||||
|
||||
if (!this.plejdService) {
|
||||
logger.info("warning: wasn't able to connect to Plejd, will retry.");
|
||||
this.emit('connectFailed');
|
||||
return;
|
||||
logger.warn("Wasn't able to connect to Plejd, will retry.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.characteristics.auth) {
|
||||
logger.error('unable to enumerate characteristics.');
|
||||
this.emit('connectFailed');
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('Connected device is a Plejd device with the right characteristics.');
|
||||
|
||||
this.connectedDevice = device.device;
|
||||
await this.authenticate();
|
||||
|
||||
return this.connectedDevice;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async onLastDataUpdated(iface, properties, invalidated) {
|
||||
async onLastDataUpdated(iface, properties) {
|
||||
if (iface !== GATT_CHRC_ID) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -691,7 +845,7 @@ class PlejdService extends EventEmitter {
|
|||
if (decoded.length < 5) {
|
||||
if (Logger.shouldLog('debug')) {
|
||||
// decoded.toString() could potentially be expensive
|
||||
logger.debug(`Too short raw event ignored: ${decoded.toString('hex')}`);
|
||||
logger.verbose(`Too short raw event ignored: ${decoded.toString('hex')}`);
|
||||
}
|
||||
// ignore the notification since too small
|
||||
return;
|
||||
|
|
@ -705,11 +859,13 @@ class PlejdService extends EventEmitter {
|
|||
const dim = decoded.length > 7 ? decoded.readUInt8(7) : 0;
|
||||
// Bytes 8-9 are sometimes present, what are they?
|
||||
|
||||
const deviceName = this._getDeviceName(deviceId);
|
||||
const deviceName = this.deviceRegistry.getDeviceName(deviceId);
|
||||
if (Logger.shouldLog('debug')) {
|
||||
// decoded.toString() could potentially be expensive
|
||||
logger.debug(`Raw event received: ${decoded.toString('hex')}`);
|
||||
logger.verbose(`Device ${deviceId}, cmd ${cmd.toString(16)}, state ${state}, dim ${dim}`);
|
||||
logger.verbose(`Raw event received: ${decoded.toString('hex')}`);
|
||||
logger.verbose(
|
||||
`Decoded: Device ${deviceId}, cmd ${cmd.toString(16)}, state ${state}, dim ${dim}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (cmd === BLE_CMD_DIM_CHANGE || cmd === BLE_CMD_DIM2_CHANGE) {
|
||||
|
|
@ -724,7 +880,7 @@ class PlejdService extends EventEmitter {
|
|||
state,
|
||||
dim,
|
||||
};
|
||||
logger.verbose(`All states: ${JSON.stringify(this.plejdDevices)}`);
|
||||
logger.silly(`All states: ${JSON.stringify(this.plejdDevices, null, 2)}`);
|
||||
} else if (cmd === BLE_CMD_STATE_CHANGE) {
|
||||
logger.debug(`${deviceName} (${deviceId}) got state update. S: ${state}`);
|
||||
this.emit('stateChanged', deviceId, {
|
||||
|
|
@ -734,10 +890,10 @@ class PlejdService extends EventEmitter {
|
|||
state,
|
||||
dim: 0,
|
||||
};
|
||||
logger.verbose(`All states: ${this.plejdDevices}`);
|
||||
logger.silly(`All states: ${JSON.stringify(this.plejdDevices, null, 2)}`);
|
||||
} else if (cmd === BLE_CMD_SCENE_TRIG) {
|
||||
const sceneId = state;
|
||||
const sceneName = this._getDeviceName(sceneId);
|
||||
const sceneName = this.deviceRegistry.getSceneName(sceneId);
|
||||
|
||||
logger.debug(
|
||||
`${sceneName} (${sceneId}) scene triggered (device id ${deviceId}). Name can be misleading if there is a device with the same numeric id.`,
|
||||
|
|
@ -745,7 +901,9 @@ class PlejdService extends EventEmitter {
|
|||
|
||||
this.emit('sceneTriggered', deviceId, sceneId);
|
||||
} else if (cmd === 0x1b) {
|
||||
logger.silly('Command 001b seems to be some kind of often repeating ping/mesh data');
|
||||
logger.silly(
|
||||
'Command 001b is the time of the Plejd devices command, not implemented currently',
|
||||
);
|
||||
} else {
|
||||
logger.verbose(
|
||||
`Command ${cmd.toString(16)} unknown. ${decoded.toString(
|
||||
|
|
@ -755,14 +913,6 @@ class PlejdService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
@ -794,10 +944,6 @@ class PlejdService extends EventEmitter {
|
|||
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);
|
||||
|
|
@ -811,4 +957,4 @@ class PlejdService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = PlejdService;
|
||||
module.exports = PlejBLEHandler;
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "Plejd",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0-dev",
|
||||
"slug": "plejd",
|
||||
"description": "Adds support for the Swedish home automation devices from Plejd.",
|
||||
"url": "https://github.com/icanos/hassio-plejd/",
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
"mqttUsername": "",
|
||||
"mqttPassword": "",
|
||||
"includeRoomsAsLights": false,
|
||||
"preferCachedApiResponse": false,
|
||||
"logLevel": "info",
|
||||
"connectionTimeout": 2,
|
||||
"writeQueueWaitTime": 400
|
||||
|
|
@ -30,6 +31,7 @@
|
|||
"mqttUsername": "str",
|
||||
"mqttPassword": "str",
|
||||
"includeRoomsAsLights": "bool",
|
||||
"preferCachedApiResponse": "bool",
|
||||
"logLevel": "list(error|warn|info|debug|verbose|silly)",
|
||||
"connectionTimeout": "int",
|
||||
"writeQueueWaitTime": "int"
|
||||
|
|
|
|||
154
plejd/main.js
154
plejd/main.js
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue