commit
3804c63991
17 changed files with 1861 additions and 1349 deletions
|
|
@ -23,6 +23,7 @@ module.exports = {
|
||||||
|
|
||||||
function getRules() {
|
function getRules() {
|
||||||
return {
|
return {
|
||||||
|
'lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }],
|
||||||
// Allows modification of properties passed to functions.
|
// Allows modification of properties passed to functions.
|
||||||
// Notably used in array.forEach(e => {e.prop = val;})
|
// Notably used in array.forEach(e => {e.prop = val;})
|
||||||
'no-param-reassign': ['error', { props: false }],
|
'no-param-reassign': ['error', { props: false }],
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,39 @@
|
||||||
# Changelog hassio-plejd Home Assistant Plejd addon
|
# 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)
|
### [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)
|
[Full Changelog](https://github.com/icanos/hassio-plejd/compare/0.5.0...0.5.1)
|
||||||
|
|
@ -19,7 +53,7 @@
|
||||||
|
|
||||||
**Implemented enhancements:**
|
**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
|
- Updated dependencies
|
||||||
- Improved readme with info about installation, debugging, and logging
|
- Improved readme with info about installation, debugging, and logging
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,52 @@
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
class Configuration {
|
class Configuration {
|
||||||
static _config = null;
|
static _options = null;
|
||||||
|
static _addonInfo = null;
|
||||||
|
|
||||||
static getConfiguration() {
|
static getOptions() {
|
||||||
if (!Configuration._config) {
|
if (!Configuration._options) {
|
||||||
const rawData = fs.readFileSync('/data/options.json');
|
Configuration._hydrateCache();
|
||||||
Configuration._config = JSON.parse(rawData);
|
|
||||||
}
|
}
|
||||||
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;
|
||||||
152
plejd/Dockerfile
152
plejd/Dockerfile
|
|
@ -1,75 +1,77 @@
|
||||||
ARG BUILD_FROM=hassioaddons/base:8.0.6
|
ARG BUILD_FROM=hassioaddons/base:8.0.6
|
||||||
FROM $BUILD_FROM
|
FROM $BUILD_FROM
|
||||||
|
|
||||||
ENV LANG C.UTF-8
|
ENV LANG C.UTF-8
|
||||||
|
|
||||||
# Set shell
|
# Set shell
|
||||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||||
|
|
||||||
# Copy data for add-on
|
# Copy data for add-on
|
||||||
COPY ./config.json /plejd/
|
COPY ./config.json /plejd/
|
||||||
COPY ./Configuration.js /plejd/
|
COPY ./Configuration.js /plejd/
|
||||||
COPY ./Logger.js /plejd/
|
COPY ./DeviceRegistry.js /plejd/
|
||||||
COPY ./main.js /plejd/
|
COPY ./Logger.js /plejd/
|
||||||
COPY ./MqttClient.js /plejd/
|
COPY ./main.js /plejd/
|
||||||
COPY ./package.json /plejd/
|
COPY ./MqttClient.js /plejd/
|
||||||
COPY ./PlejdApi.js /plejd/
|
COPY ./package.json /plejd/
|
||||||
COPY ./PlejdService.js /plejd/
|
COPY ./PlejdAddon.js /plejd/
|
||||||
COPY ./Scene.js /plejd/
|
COPY ./PlejdApi.js /plejd/
|
||||||
COPY ./SceneManager.js /plejd/
|
COPY ./PlejdBLEHandler.js /plejd/
|
||||||
COPY ./SceneStep.js /plejd/
|
COPY ./Scene.js /plejd/
|
||||||
|
COPY ./SceneManager.js /plejd/
|
||||||
ARG BUILD_ARCH
|
COPY ./SceneStep.js /plejd/
|
||||||
|
|
||||||
# Install Node
|
ARG BUILD_ARCH
|
||||||
RUN apk add --no-cache jq
|
|
||||||
RUN \
|
# Install Node
|
||||||
apk add --no-cache --virtual .build-dependencies \
|
RUN apk add --no-cache jq
|
||||||
g++ \
|
RUN \
|
||||||
gcc \
|
apk add --no-cache --virtual .build-dependencies \
|
||||||
libc-dev \
|
g++ \
|
||||||
linux-headers \
|
gcc \
|
||||||
make \
|
libc-dev \
|
||||||
python3 \
|
linux-headers \
|
||||||
bluez \
|
make \
|
||||||
eudev-dev \
|
python3 \
|
||||||
\
|
bluez \
|
||||||
&& apk add --no-cache \
|
eudev-dev \
|
||||||
git \
|
\
|
||||||
nodejs \
|
&& apk add --no-cache \
|
||||||
npm \
|
git \
|
||||||
dbus-dev \
|
nodejs \
|
||||||
glib-dev \
|
npm \
|
||||||
\
|
dbus-dev \
|
||||||
&& npm config set unsafe-perm true
|
glib-dev \
|
||||||
|
\
|
||||||
WORKDIR /plejd
|
&& npm config set unsafe-perm true
|
||||||
RUN npm install \
|
|
||||||
--no-audit \
|
WORKDIR /plejd
|
||||||
--no-update-notifier \
|
RUN npm install \
|
||||||
--unsafe-perm
|
--no-audit \
|
||||||
|
--no-update-notifier \
|
||||||
# Copy root filesystem
|
--unsafe-perm
|
||||||
COPY rootfs /
|
|
||||||
|
# Copy root filesystem
|
||||||
# Build arguments
|
COPY rootfs /
|
||||||
ARG BUILD_DATE
|
|
||||||
ARG BUILD_REF
|
# Build arguments
|
||||||
ARG BUILD_VERSION
|
ARG BUILD_DATE
|
||||||
|
ARG BUILD_REF
|
||||||
# Labels
|
ARG BUILD_VERSION
|
||||||
LABEL \
|
|
||||||
io.hass.name="Plejd" \
|
# Labels
|
||||||
io.hass.description="Adds support for the Swedish home automation devices from Plejd." \
|
LABEL \
|
||||||
io.hass.arch="${BUILD_ARCH}" \
|
io.hass.name="Plejd" \
|
||||||
io.hass.type="addon" \
|
io.hass.description="Adds support for the Swedish home automation devices from Plejd." \
|
||||||
io.hass.version=${BUILD_VERSION} \
|
io.hass.arch="${BUILD_ARCH}" \
|
||||||
maintainer="Marcus Westin <marcus@sekurbit.se>" \
|
io.hass.type="addon" \
|
||||||
org.label-schema.description="Adds support for the Swedish home automation devices from Plejd." \
|
io.hass.version=${BUILD_VERSION} \
|
||||||
org.label-schema.build-date=${BUILD_DATE} \
|
maintainer="Marcus Westin <marcus@sekurbit.se>" \
|
||||||
org.label-schema.name="Plejd" \
|
org.label-schema.description="Adds support for the Swedish home automation devices from Plejd." \
|
||||||
org.label-schema.schema-version="1.0" \
|
org.label-schema.build-date=${BUILD_DATE} \
|
||||||
org.label-schema.usage="https://github.com/icanos/hassio-plejd/tree/master/README.md" \
|
org.label-schema.name="Plejd" \
|
||||||
org.label-schema.vcs-ref=${BUILD_REF} \
|
org.label-schema.schema-version="1.0" \
|
||||||
org.label-schema.vcs-url="https://github.com/icanos/hassio-plejd"
|
org.label-schema.usage="https://github.com/icanos/hassio-plejd/tree/master/README.md" \
|
||||||
|
org.label-schema.vcs-ref=${BUILD_REF} \
|
||||||
|
org.label-schema.vcs-url="https://github.com/icanos/hassio-plejd"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,20 +25,35 @@ const logFormat = printf((info) => {
|
||||||
|
|
||||||
/** Winston-based logger */
|
/** Winston-based logger */
|
||||||
class Logger {
|
class Logger {
|
||||||
|
static shouldLogLookup = {};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
throw new Error('Please call createLogger instead');
|
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
|
/** Created logger will follow Winston createLogger, but
|
||||||
* - add module name to logger
|
* - add module name to logger
|
||||||
* - swap debug/verbose levels and omit http to mimic HA standard
|
* - swap debug/verbose levels and omit http to mimic HA standard
|
||||||
* Levels (in order): error, warn, info, debug, verbose, silly
|
* Levels (in order): error, warn, info, debug, verbose, silly
|
||||||
* */
|
* */
|
||||||
static getLogger(moduleName) {
|
static getLogger(moduleName) {
|
||||||
const config = Configuration.getConfiguration();
|
const level = Logger.getLogLevel();
|
||||||
// eslint-disable-next-line max-len
|
|
||||||
const level = (config.logLevel && LEVELS.find((l) => l.startsWith(config.logLevel[0].toLowerCase())))
|
|
||||||
|| 'info';
|
|
||||||
|
|
||||||
const logger = winston.createLogger({
|
const logger = winston.createLogger({
|
||||||
format: combine(
|
format: combine(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const mqtt = require('mqtt');
|
const mqtt = require('mqtt');
|
||||||
|
|
||||||
|
const Configuration = require('./Configuration');
|
||||||
const Logger = require('./Logger');
|
const Logger = require('./Logger');
|
||||||
|
|
||||||
const startTopics = ['hass/status', 'homeassistant/status'];
|
const startTopics = ['hass/status', 'homeassistant/status'];
|
||||||
|
|
@ -19,6 +21,18 @@ const getAvailabilityTopic = (plug) => `${getPath(plug)}/availability`;
|
||||||
const getCommandTopic = (plug) => `${getPath(plug)}/set`;
|
const getCommandTopic = (plug) => `${getPath(plug)}/set`;
|
||||||
const getSceneEventTopic = () => 'plejd/event/scene';
|
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) => ({
|
const getDiscoveryPayload = (device) => ({
|
||||||
schema: 'json',
|
schema: 'json',
|
||||||
name: device.name,
|
name: device.name,
|
||||||
|
|
@ -54,23 +68,25 @@ const getSwitchPayload = (device) => ({
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
class MqttClient extends EventEmitter {
|
class MqttClient extends EventEmitter {
|
||||||
constructor(mqttBroker, username, password) {
|
deviceRegistry;
|
||||||
|
|
||||||
|
constructor(deviceRegistry) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.mqttBroker = mqttBroker;
|
this.config = Configuration.getOptions();
|
||||||
this.username = username;
|
this.deviceRegistry = deviceRegistry;
|
||||||
this.password = password;
|
|
||||||
this.deviceMap = {};
|
|
||||||
this.devices = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
logger.info('Initializing MQTT connection for Plejd addon');
|
logger.info('Initializing MQTT connection for Plejd addon');
|
||||||
const self = this;
|
|
||||||
|
|
||||||
this.client = mqtt.connect(this.mqttBroker, {
|
this.client = mqtt.connect(this.config.mqttBroker, {
|
||||||
username: this.username,
|
username: this.config.mqttUsername,
|
||||||
password: this.password,
|
password: this.config.mqttPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('error', (err) => {
|
||||||
|
logger.warn('Error emitted from mqtt client', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client.on('connect', () => {
|
this.client.on('connect', () => {
|
||||||
|
|
@ -81,7 +97,7 @@ class MqttClient extends EventEmitter {
|
||||||
logger.error('Unable to subscribe to status topics');
|
logger.error('Unable to subscribe to status topics');
|
||||||
}
|
}
|
||||||
|
|
||||||
self.emit('connected');
|
this.emit('connected');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client.subscribe(getSubscribePath(), (err) => {
|
this.client.subscribe(getSubscribePath(), (err) => {
|
||||||
|
|
@ -93,32 +109,70 @@ class MqttClient extends EventEmitter {
|
||||||
|
|
||||||
this.client.on('close', () => {
|
this.client.on('close', () => {
|
||||||
logger.verbose('Warning: mqtt channel closed event, reconnecting...');
|
logger.verbose('Warning: mqtt channel closed event, reconnecting...');
|
||||||
self.reconnect();
|
this.reconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client.on('message', (topic, message) => {
|
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)) {
|
if (startTopics.includes(topic)) {
|
||||||
logger.info('Home Assistant has started. lets do discovery.');
|
logger.info('Home Assistant has started. lets do discovery.');
|
||||||
self.emit('connected');
|
this.emit('connected');
|
||||||
} else if (topic.includes('set')) {
|
} else {
|
||||||
logger.verbose(`Got mqtt command on ${topic} - ${message}`);
|
const decodedTopic = decodeTopic(topic);
|
||||||
const device = self.devices.find((x) => getCommandTopic(x) === topic);
|
if (decodedTopic) {
|
||||||
if (device) {
|
let device = this.deviceRegistry.getDevice(decodedTopic.id);
|
||||||
self.emit('stateChanged', device, command);
|
|
||||||
|
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 {
|
} else {
|
||||||
logger.warn(
|
logger.verbose(
|
||||||
`Device for topic ${topic} not found! Can happen if HA calls previously existing devices.`,
|
`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) {
|
disconnect(callback) {
|
||||||
this.devices.forEach((device) => {
|
this.deviceRegistry.allDevices.forEach((device) => {
|
||||||
this.client.publish(getAvailabilityTopic(device), 'offline');
|
this.client.publish(getAvailabilityTopic(device), 'offline');
|
||||||
});
|
});
|
||||||
this.client.end(callback);
|
this.client.end(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
discover(devices) {
|
sendDiscoveryToHomeAssistant() {
|
||||||
this.devices = devices;
|
logger.debug(`Sending discovery of ${this.deviceRegistry.allDevices.length} device(s).`);
|
||||||
|
|
||||||
const self = this;
|
this.deviceRegistry.allDevices.forEach((device) => {
|
||||||
logger.debug(`Sending discovery of ${devices.length} device(s).`);
|
|
||||||
|
|
||||||
devices.forEach((device) => {
|
|
||||||
logger.debug(`Sending discovery for ${device.name}`);
|
logger.debug(`Sending discovery for ${device.name}`);
|
||||||
|
|
||||||
const payload = device.type === 'switch' ? getSwitchPayload(device) : getDiscoveryPayload(device);
|
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}.`,
|
`Discovered ${device.type} (${device.typeName}) named ${device.name} with PID ${device.id}.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
self.deviceMap[device.id] = payload.unique_id;
|
this.client.publish(getConfigPath(device), JSON.stringify(payload));
|
||||||
|
|
||||||
self.client.publish(getConfigPath(device), JSON.stringify(payload));
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
self.client.publish(getAvailabilityTopic(device), 'online');
|
this.client.publish(getAvailabilityTopic(device), 'online');
|
||||||
}, 2000);
|
}, 2000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateState(deviceId, data) {
|
updateState(deviceId, data) {
|
||||||
const device = this.devices.find((x) => x.id === deviceId);
|
const device = this.deviceRegistry.getDevice(deviceId);
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
logger.warn(`Unknown device id ${deviceId} - not handled by us.`);
|
logger.warn(`Unknown device id ${deviceId} - not handled by us.`);
|
||||||
|
|
@ -193,9 +242,9 @@ class MqttClient extends EventEmitter {
|
||||||
this.client.publish(getAvailabilityTopic(device), 'online');
|
this.client.publish(getAvailabilityTopic(device), 'online');
|
||||||
}
|
}
|
||||||
|
|
||||||
sceneTriggered(scene) {
|
sceneTriggered(sceneId) {
|
||||||
logger.verbose(`Scene triggered: ${scene}`);
|
logger.verbose(`Scene triggered: ${sceneId}`);
|
||||||
this.client.publish(getSceneEventTopic(), JSON.stringify({ scene }));
|
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 axios = require('axios').default;
|
||||||
const EventEmitter = require('events');
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const Configuration = require('./Configuration');
|
||||||
const Logger = require('./Logger');
|
const Logger = require('./Logger');
|
||||||
|
|
||||||
const API_APP_ID = 'zHtVqXt8k4yFyk2QGmgp48D9xZr2G94xWYnF4dak';
|
const API_APP_ID = 'zHtVqXt8k4yFyk2QGmgp48D9xZr2G94xWYnF4dak';
|
||||||
|
|
@ -10,271 +12,207 @@ const API_SITE_DETAILS_URL = 'functions/getSiteById';
|
||||||
|
|
||||||
const logger = Logger.getLogger('plejd-api');
|
const logger = Logger.getLogger('plejd-api');
|
||||||
|
|
||||||
class PlejdApi extends EventEmitter {
|
class PlejdApi {
|
||||||
constructor(siteName, username, password, includeRoomsAsLights) {
|
config;
|
||||||
super();
|
deviceRegistry;
|
||||||
|
sessionToken;
|
||||||
|
siteId;
|
||||||
|
siteDetails;
|
||||||
|
|
||||||
this.includeRoomsAsLights = includeRoomsAsLights;
|
constructor(deviceRegistry) {
|
||||||
this.siteName = siteName;
|
this.config = Configuration.getOptions();
|
||||||
this.username = username;
|
this.deviceRegistry = deviceRegistry;
|
||||||
this.password = password;
|
|
||||||
|
|
||||||
this.sessionToken = '';
|
|
||||||
this.site = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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('login()');
|
||||||
logger.info(`logging into ${this.siteName}`);
|
logger.info(`logging into ${this.config.site}`);
|
||||||
const self = this;
|
|
||||||
|
|
||||||
const instance = axios.create({
|
logger.debug(`sending POST to ${API_BASE_URL}${API_LOGIN_URL}`);
|
||||||
baseURL: API_BASE_URL,
|
|
||||||
headers: {
|
|
||||||
'X-Parse-Application-Id': API_APP_ID,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
try {
|
||||||
logger.debug(`sending POST to ${API_BASE_URL}${API_LOGIN_URL}`);
|
const response = await this._getAxiosInstance().post(API_LOGIN_URL, {
|
||||||
|
username: this.config.username,
|
||||||
|
password: this.config.password,
|
||||||
|
});
|
||||||
|
|
||||||
instance
|
logger.info('got session token response');
|
||||||
.post(API_LOGIN_URL, {
|
this.sessionToken = response.data.sessionToken;
|
||||||
username: this.username,
|
|
||||||
password: this.password,
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
logger.info('got session token response');
|
|
||||||
self.sessionToken = response.data.sessionToken;
|
|
||||||
|
|
||||||
if (!self.sessionToken) {
|
if (!this.sessionToken) {
|
||||||
logger.error('No session token received');
|
logger.error('No session token received');
|
||||||
reject(new 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();
|
throw new Error(`API: Unable to retrieve session token response: ${error}`);
|
||||||
})
|
}
|
||||||
.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}`));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getSites() {
|
async getSites() {
|
||||||
logger.info('Get all Plejd sites for account...');
|
logger.info('Get all Plejd sites for account...');
|
||||||
const self = this;
|
|
||||||
|
|
||||||
const instance = axios.create({
|
logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_LIST_URL}`);
|
||||||
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) => {
|
try {
|
||||||
logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_LIST_URL}`);
|
const response = await this._getAxiosInstance().post(API_SITE_LIST_URL);
|
||||||
|
|
||||||
instance
|
const sites = response.data.result;
|
||||||
.post(API_SITE_LIST_URL)
|
logger.info(
|
||||||
.then((response) => {
|
`Got site list response with ${sites.length}: ${sites.map((s) => s.site.title).join(', ')}`,
|
||||||
logger.info('got site list response');
|
);
|
||||||
const site = response.data.result.find((x) => x.site.title === self.siteName);
|
logger.silly('All sites found:');
|
||||||
|
logger.silly(JSON.stringify(sites, null, 2));
|
||||||
|
|
||||||
if (!site) {
|
const site = sites.find((x) => x.site.title === this.config.site);
|
||||||
logger.error(`error: failed to find a site named ${self.siteName}`);
|
|
||||||
reject(new Error(`failed to find a site named ${self.siteName}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(site);
|
if (!site) {
|
||||||
})
|
logger.error(`Failed to find a site named ${this.config.site}`);
|
||||||
.catch((error) => {
|
throw new Error(`API: Failed to find a site named ${this.config.site}`);
|
||||||
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}`));
|
|
||||||
});
|
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) {
|
async getSiteDetails() {
|
||||||
logger.info('Get site details...');
|
logger.info(`Get site details for ${this.siteId}...`);
|
||||||
const self = this;
|
|
||||||
|
|
||||||
const instance = axios.create({
|
logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_DETAILS_URL}`);
|
||||||
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) => {
|
try {
|
||||||
logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_DETAILS_URL}`);
|
const response = await this._getAxiosInstance().post(API_SITE_DETAILS_URL, {
|
||||||
|
siteId: this.siteId,
|
||||||
|
});
|
||||||
|
|
||||||
instance
|
logger.info('got site details response');
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.site = response.data.result[0];
|
if (response.data.result.length === 0) {
|
||||||
self.cryptoKey = self.site.plejdMesh.cryptoKey;
|
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);
|
this.siteDetails = response.data.result[0];
|
||||||
})
|
|
||||||
.catch((error) => {
|
logger.info(`Site details for site id ${this.siteId} found`);
|
||||||
logger.error('error: unable to retrieve the crypto key. error: ', error);
|
logger.silly(JSON.stringify(this.siteDetails, null, 2));
|
||||||
return reject(new Error(`plejd-api: unable to retrieve the crypto key. error: ${error}`));
|
|
||||||
});
|
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() {
|
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++) {
|
if (this.sessionToken) {
|
||||||
const device = this.site.devices[i];
|
headers['X-Parse-Session-Token'] = this.sessionToken;
|
||||||
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.includeRoomsAsLights) {
|
return axios.create({
|
||||||
logger.debug('includeRoomsAsLights is set to true, adding rooms too.');
|
baseURL: API_BASE_URL,
|
||||||
for (let i = 0; i < this.site.rooms.length; i++) {
|
headers,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line class-methods-use-this
|
// 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}`);
|
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;
|
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
|
### 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
|
### 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.
|
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 |
|
| Parameter | Value |
|
||||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| site | Name of your Plejd site, the name is displayed in the Plejd app (top bar). |
|
| 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. |
|
| 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. |
|
| 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 |
|
| mqttBroker | URL of the MQTT Broker, eg. mqtt://localhost |
|
||||||
| mqttUsername | Username of the MQTT broker |
|
| mqttUsername | Username of the MQTT broker |
|
||||||
| mqttPassword | Password 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. |
|
| 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. |
|
| 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. |
|
||||||
| 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. |
|
| 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. |
|
||||||
| 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. |
|
| 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:
|
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 EventEmitter = require('events');
|
||||||
|
const Logger = require('./Logger');
|
||||||
const Scene = require('./Scene');
|
const Scene = require('./Scene');
|
||||||
|
|
||||||
|
const logger = Logger.getLogger('scene-manager');
|
||||||
class SceneManager extends EventEmitter {
|
class SceneManager extends EventEmitter {
|
||||||
constructor(site, devices) {
|
deviceRegistry;
|
||||||
|
plejdBle;
|
||||||
|
scenes;
|
||||||
|
|
||||||
|
constructor(deviceRegistry, plejdBle) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.site = site;
|
this.deviceRegistry = deviceRegistry;
|
||||||
this.scenes = [];
|
this.plejdBle = plejdBle;
|
||||||
this.devices = devices;
|
this.scenes = {};
|
||||||
|
|
||||||
this.init();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
const scenes = this.site.scenes.filter((x) => x.hiddenFromSceneList === false);
|
const scenes = this.deviceRegistry.apiSite.scenes.filter(
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
(x) => x.hiddenFromSceneList === false,
|
||||||
for (const scene of scenes) {
|
);
|
||||||
const idx = this.site.sceneIndex[scene.sceneId];
|
|
||||||
this.scenes.push(new Scene(idx, scene, this.site.sceneSteps));
|
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) {
|
executeScene(sceneId) {
|
||||||
const scene = this.scenes.find((x) => x.id === sceneIndex);
|
const scene = this.scenes[sceneId];
|
||||||
if (!scene) {
|
if (!scene) {
|
||||||
|
logger.info(`Scene with id ${sceneId} not found`);
|
||||||
|
logger.verbose(`Scenes: ${JSON.stringify(this.scenes, null, 2)}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
scene.steps.forEach((step) => {
|
||||||
for (const step of scene.steps) {
|
const device = this.deviceRegistry.getDeviceBySerialNumber(step.deviceId);
|
||||||
const device = this.devices.find((x) => x.serialNumber === step.deviceId);
|
|
||||||
if (device) {
|
if (device) {
|
||||||
if (device.dimmable && step.state) {
|
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) {
|
} else if (!device.dimmable && step.state) {
|
||||||
ble.turnOn(device.id, {});
|
this.plejdBle.turnOn(device.id, {});
|
||||||
} else if (!step.state) {
|
} else if (!step.state) {
|
||||||
ble.turnOff(device.id, {});
|
this.plejdBle.turnOff(device.id, {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = SceneManager;
|
module.exports = SceneManager;
|
||||||
/* eslint-disable */
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "Plejd",
|
"name": "Plejd",
|
||||||
"version": "0.5.1",
|
"version": "0.6.1",
|
||||||
"slug": "plejd",
|
"slug": "plejd",
|
||||||
"description": "Adds support for the Swedish home automation devices from Plejd.",
|
"description": "Adds support for the Swedish home automation devices from Plejd.",
|
||||||
"url": "https://github.com/icanos/hassio-plejd/",
|
"url": "https://github.com/icanos/hassio-plejd/",
|
||||||
|
|
@ -18,6 +18,8 @@
|
||||||
"mqttUsername": "",
|
"mqttUsername": "",
|
||||||
"mqttPassword": "",
|
"mqttPassword": "",
|
||||||
"includeRoomsAsLights": false,
|
"includeRoomsAsLights": false,
|
||||||
|
"preferCachedApiResponse": false,
|
||||||
|
"updatePlejdClock": false,
|
||||||
"logLevel": "info",
|
"logLevel": "info",
|
||||||
"connectionTimeout": 2,
|
"connectionTimeout": 2,
|
||||||
"writeQueueWaitTime": 400
|
"writeQueueWaitTime": 400
|
||||||
|
|
@ -30,6 +32,8 @@
|
||||||
"mqttUsername": "str",
|
"mqttUsername": "str",
|
||||||
"mqttPassword": "str",
|
"mqttPassword": "str",
|
||||||
"includeRoomsAsLights": "bool",
|
"includeRoomsAsLights": "bool",
|
||||||
|
"preferCachedApiResponse": "bool",
|
||||||
|
"updatePlejdClock": "bool",
|
||||||
"logLevel": "list(error|warn|info|debug|verbose|silly)",
|
"logLevel": "list(error|warn|info|debug|verbose|silly)",
|
||||||
"connectionTimeout": "int",
|
"connectionTimeout": "int",
|
||||||
"writeQueueWaitTime": "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 Configuration = require('./Configuration');
|
||||||
|
const Logger = require('./Logger');
|
||||||
const logger = Logger.getLogger('plejd-main');
|
const PlejdAddon = require('./PlejdAddon');
|
||||||
|
|
||||||
const version = '0.5.1';
|
|
||||||
|
|
||||||
async function main() {
|
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) {
|
logger.info(`Plejd add-on, version ${addonInfo.version}`);
|
||||||
config.connectionTimeout = 2;
|
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();
|
main();
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@abandonware/bluetooth-hci-socket": "0.5.3-3",
|
"@abandonware/bluetooth-hci-socket": "~0.5.3-7",
|
||||||
"axios": "~0.21.1",
|
"axios": "~0.21.1",
|
||||||
"buffer-xor": "~2.0.2",
|
"buffer-xor": "~2.0.2",
|
||||||
"dbus-next": "~0.9.1",
|
"dbus-next": "~0.9.1",
|
||||||
"fs": "0.0.1-security",
|
"fs": "0.0.1-security",
|
||||||
"jspack": "~0.0.4",
|
"jspack": "~0.0.4",
|
||||||
"mqtt": "~3.0.0",
|
"mqtt": "~3.0.0",
|
||||||
"sleep": "~6.1.0",
|
|
||||||
"winston": "~3.3.3"
|
"winston": "~3.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const PlejdService = require('../PlejdService');
|
const PlejdBLE = require('../PlejdBLEHandler');
|
||||||
|
|
||||||
const cryptoKey = '';
|
const cryptoKey = '';
|
||||||
|
|
||||||
const plejd = new PlejdService(cryptoKey, true);
|
const plejd = new PlejdBLE(cryptoKey, true);
|
||||||
plejd.init();
|
plejd.init();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue