commit
3804c63991
17 changed files with 1861 additions and 1349 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,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
|
||||
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
if (device) {
|
||||
self.emit('stateChanged', device, command);
|
||||
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) {
|
||||
this.emit('stateChanged', device, command);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Device for topic ${topic} not found! Can happen if HA calls previously existing devices.`,
|
||||
);
|
||||
}
|
||||
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.warn(
|
||||
`Device for topic ${topic} not found! Can happen if HA calls previously existing devices.`,
|
||||
logger.verbose(
|
||||
`Warning: Got unrecognized mqtt command on '${topic}': ${message.toString()}`,
|
||||
);
|
||||
}
|
||||
} else if (topic.includes('state')) {
|
||||
logger.verbose(`State update sent over mqtt to HA ${topic} - ${message}`);
|
||||
} else {
|
||||
logger.verbose(`Warning: Got unrecognized mqtt command on ${topic} - ${message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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() {
|
||||
async init() {
|
||||
logger.info('init()');
|
||||
const cache = await this.getCachedCopy();
|
||||
const cacheExists = cache && cache.siteId && cache.siteDetails && cache.sessionToken;
|
||||
|
||||
logger.debug(`Prefer cache? ${this.config.preferCachedApiResponse}`);
|
||||
logger.debug(`Cache exists? ${cacheExists ? `Yes, created ${cache.dtCache}` : 'No'}`);
|
||||
|
||||
if (this.config.preferCachedApiResponse && cacheExists) {
|
||||
logger.info(
|
||||
`Cache preferred. Skipping api requests and setting api data to response from ${cache.dtCache}`,
|
||||
);
|
||||
logger.silly(`Cached response: ${JSON.stringify(cache, null, 2)}`);
|
||||
this.siteId = cache.siteId;
|
||||
this.siteDetails = cache.siteDetails;
|
||||
this.sessionToken = cache.sessionToken;
|
||||
} else {
|
||||
try {
|
||||
await this.login();
|
||||
await this.getSites();
|
||||
await this.getSiteDetails();
|
||||
this.saveCachedCopy();
|
||||
} catch (err) {
|
||||
if (cacheExists) {
|
||||
logger.warn('Failed to get api response, using cached copy instead');
|
||||
this.siteId = cache.siteId;
|
||||
this.siteDetails = cache.siteDetails;
|
||||
this.sessionToken = cache.sessionToken;
|
||||
} else {
|
||||
logger.error('Api request failed, no cached fallback available', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.deviceRegistry.setApiSite(this.siteDetails);
|
||||
this.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.siteName}`);
|
||||
const self = this;
|
||||
logger.info(`logging into ${this.config.site}`);
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': API_APP_ID,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
logger.debug(`sending POST to ${API_BASE_URL}${API_LOGIN_URL}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
logger.debug(`sending POST to ${API_BASE_URL}${API_LOGIN_URL}`);
|
||||
try {
|
||||
const response = await this._getAxiosInstance().post(API_LOGIN_URL, {
|
||||
username: this.config.username,
|
||||
password: this.config.password,
|
||||
});
|
||||
|
||||
instance
|
||||
.post(API_LOGIN_URL, {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
})
|
||||
.then((response) => {
|
||||
logger.info('got session token response');
|
||||
self.sessionToken = response.data.sessionToken;
|
||||
logger.info('got session token response');
|
||||
this.sessionToken = response.data.sessionToken;
|
||||
|
||||
if (!self.sessionToken) {
|
||||
logger.error('No session token received');
|
||||
reject(new Error('no session token received.'));
|
||||
}
|
||||
if (!this.sessionToken) {
|
||||
logger.error('No session token received');
|
||||
throw new Error('API: No session token received.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.response.status === 400) {
|
||||
logger.error('Server returned status 400. probably invalid credentials, please verify.');
|
||||
} else if (error.response.status === 403) {
|
||||
logger.error(
|
||||
'Server returned status 403, forbidden. Plejd service does this sometimes, despite correct credentials. Possibly throttling logins. Waiting a long time often fixes this.',
|
||||
);
|
||||
} else {
|
||||
logger.error('Unable to retrieve session token response: ', error);
|
||||
}
|
||||
logger.verbose(`Error details: ${JSON.stringify(error.response, null, 2)}`);
|
||||
|
||||
resolve();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response.status === 400) {
|
||||
logger.error(
|
||||
'Server returned status 400. probably invalid credentials, please verify.',
|
||||
);
|
||||
} else {
|
||||
logger.error('Unable to retrieve session token response: ', error);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_LIST_URL}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_LIST_URL}`);
|
||||
try {
|
||||
const response = await this._getAxiosInstance().post(API_SITE_LIST_URL);
|
||||
|
||||
instance
|
||||
.post(API_SITE_LIST_URL)
|
||||
.then((response) => {
|
||||
logger.info('got site list response');
|
||||
const site = response.data.result.find((x) => x.site.title === self.siteName);
|
||||
const 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));
|
||||
|
||||
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;
|
||||
}
|
||||
const site = sites.find((x) => x.site.title === this.config.site);
|
||||
|
||||
resolve(site);
|
||||
})
|
||||
.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}`));
|
||||
});
|
||||
});
|
||||
if (!site) {
|
||||
logger.error(`Failed to find a site named ${this.config.site}`);
|
||||
throw new Error(`API: Failed to find a site named ${this.config.site}`);
|
||||
}
|
||||
|
||||
logger.info(`Site found matching configuration name ${this.config.site}`);
|
||||
logger.silly(JSON.stringify(site, null, 2));
|
||||
this.siteId = site.site.siteId;
|
||||
} catch (error) {
|
||||
logger.error('error: unable to retrieve list of sites. error: ', error);
|
||||
throw new Error(`API: unable to retrieve list of sites. error: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_DETAILS_URL}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_DETAILS_URL}`);
|
||||
try {
|
||||
const response = await this._getAxiosInstance().post(API_SITE_DETAILS_URL, {
|
||||
siteId: this.siteId,
|
||||
});
|
||||
|
||||
instance
|
||||
.post(API_SITE_DETAILS_URL, { siteId })
|
||||
.then((response) => {
|
||||
logger.info('got site details response');
|
||||
if (response.data.result.length === 0) {
|
||||
const msg = `no site with ID ${siteId} was found.`;
|
||||
logger.error(`error: ${msg}`);
|
||||
reject(msg);
|
||||
return;
|
||||
}
|
||||
logger.info('got site details response');
|
||||
|
||||
self.site = response.data.result[0];
|
||||
self.cryptoKey = self.site.plejdMesh.cryptoKey;
|
||||
if (response.data.result.length === 0) {
|
||||
logger.error(`No site with ID ${this.siteId} was found.`);
|
||||
throw new Error(`API: No site with ID ${this.siteId} was found.`);
|
||||
}
|
||||
|
||||
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}`));
|
||||
});
|
||||
});
|
||||
this.siteDetails = response.data.result[0];
|
||||
|
||||
logger.info(`Site details for site id ${this.siteId} found`);
|
||||
logger.silly(JSON.stringify(this.siteDetails, null, 2));
|
||||
|
||||
if (!this.siteDetails.plejdMesh.cryptoKey) {
|
||||
throw new Error('API: No crypto key set for site');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Unable to retrieve site details for ${this.siteId}. error: `, error);
|
||||
throw new Error(`API: Unable to retrieve site details. error: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
getDevices() {
|
||||
const devices = [];
|
||||
logger.info('Getting devices from site details response...');
|
||||
|
||||
logger.verbose(JSON.stringify(this.site));
|
||||
this._getPlejdDevices();
|
||||
this._getRoomDevices();
|
||||
this._getSceneDevices();
|
||||
}
|
||||
|
||||
const roomDevices = {};
|
||||
_getAxiosInstance() {
|
||||
const headers = {
|
||||
'X-Parse-Application-Id': API_APP_ID,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
devices.push(newDevice);
|
||||
}
|
||||
if (this.sessionToken) {
|
||||
headers['X-Parse-Session-Token'] = this.sessionToken;
|
||||
}
|
||||
|
||||
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
1030
plejd/PlejdBLEHandler.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -121,20 +121,21 @@ 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. |
|
||||
| mqttBroker | URL of the MQTT Broker, eg. mqtt://localhost |
|
||||
| mqttUsername | Username of the MQTT broker |
|
||||
| mqttPassword | Password of the MQTT broker |
|
||||
| includeRoomsAsLights | Adds all rooms as lights, making it possible to turn on/off lights by room instead. Setting this to false will ignore all rooms. |
|
||||
| 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. |
|
||||
| Parameter | Value |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| site | Name of your Plejd site, the name is displayed in the Plejd app (top bar). |
|
||||
| username | Username of your Plejd account, this is used to fetch the crypto key and devices from the Plejd API. |
|
||||
| password | Password of your Plejd account, this is used to fetch the crypto key and devices from the Plejd API. |
|
||||
| mqttBroker | URL of the MQTT Broker, eg. mqtt://localhost |
|
||||
| mqttUsername | Username of the MQTT broker |
|
||||
| mqttPassword | Password of the MQTT broker |
|
||||
| includeRoomsAsLights | Adds all rooms as lights, making it possible to turn on/off lights by room instead. Setting this to false will ignore all rooms. |
|
||||
| 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.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"
|
||||
|
|
|
|||
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}`);
|
||||
try {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Starting Plejd addon and reading configuration...');
|
||||
|
||||
const config = Configuration.getConfiguration();
|
||||
const addonInfo = Configuration.getAddonInfo();
|
||||
const logger = Logger.getLogger('plejd-main');
|
||||
|
||||
if (!config.connectionTimeout) {
|
||||
config.connectionTimeout = 2;
|
||||
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) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Catastrophic error. Resetting entire addon in 1 minute', err);
|
||||
setTimeout(() => main(), 60000);
|
||||
}
|
||||
|
||||
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);
|
||||
} catch (err) {
|
||||
logger.error('Error in MqttClient.connected callback in main.js', err);
|
||||
}
|
||||
});
|
||||
|
||||
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