initial commit
This commit is contained in:
parent
b43bc6c4e2
commit
fe52e0901c
12 changed files with 2288 additions and 0 deletions
67
Dockerfile
Normal file
67
Dockerfile
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
ARG BUILD_FROM=hassioaddons/base:5.0.2
|
||||||
|
FROM $BUILD_FROM
|
||||||
|
|
||||||
|
ENV LANG C.UTF-8
|
||||||
|
|
||||||
|
# Set shell
|
||||||
|
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||||
|
|
||||||
|
# Copy data for add-on
|
||||||
|
COPY ./api.js /plejd/
|
||||||
|
COPY ./config.json /plejd/
|
||||||
|
COPY ./main.js /plejd/
|
||||||
|
COPY ./mqtt.js /plejd/
|
||||||
|
COPY ./package.json /plejd/
|
||||||
|
COPY ./plejd.js /plejd/
|
||||||
|
|
||||||
|
ARG BUILD_ARCH
|
||||||
|
|
||||||
|
# Install Node
|
||||||
|
RUN apk add --no-cache jq
|
||||||
|
RUN \
|
||||||
|
apk add --no-cache --virtual .build-dependencies \
|
||||||
|
g++=8.3.0-r0 \
|
||||||
|
gcc=8.3.0-r0 \
|
||||||
|
libc-dev=0.7.1-r0 \
|
||||||
|
linux-headers=4.19.36-r0 \
|
||||||
|
make=4.2.1-r2 \
|
||||||
|
python=2.7.16-r1 \
|
||||||
|
bluez=5.50-r3 \
|
||||||
|
eudev-dev=3.2.8-r0 \
|
||||||
|
\
|
||||||
|
&& apk add --no-cache \
|
||||||
|
git=2.22.0-r0 \
|
||||||
|
nodejs=10.16.3-r0 \
|
||||||
|
npm=10.16.3-r0 \
|
||||||
|
\
|
||||||
|
&& npm config set unsafe-perm true
|
||||||
|
|
||||||
|
WORKDIR /plejd
|
||||||
|
RUN npm install \
|
||||||
|
--no-audit \
|
||||||
|
--no-update-notifier \
|
||||||
|
--unsafe-perm
|
||||||
|
|
||||||
|
# Copy root filesystem
|
||||||
|
COPY rootfs /
|
||||||
|
|
||||||
|
# Build arguments
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG BUILD_REF
|
||||||
|
ARG BUILD_VERSION
|
||||||
|
|
||||||
|
# Labels
|
||||||
|
LABEL \
|
||||||
|
io.hass.name="Plejd" \
|
||||||
|
io.hass.description="Adds support for the Swedish home automation devices from Plejd." \
|
||||||
|
io.hass.arch="${BUILD_ARCH}" \
|
||||||
|
io.hass.type="addon" \
|
||||||
|
io.hass.version=${BUILD_VERSION} \
|
||||||
|
maintainer="Marcus Westin <marcus@sekurbit.se>" \
|
||||||
|
org.label-schema.description="Adds support for the Swedish home automation devices from Plejd." \
|
||||||
|
org.label-schema.build-date=${BUILD_DATE} \
|
||||||
|
org.label-schema.name="Plejd" \
|
||||||
|
org.label-schema.schema-version="1.0" \
|
||||||
|
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"
|
||||||
102
api.js
Normal file
102
api.js
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
const axios = require('axios');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const _ = require('lodash');
|
||||||
|
|
||||||
|
API_APP_ID = 'zHtVqXt8k4yFyk2QGmgp48D9xZr2G94xWYnF4dak';
|
||||||
|
API_BASE_URL = 'https://cloud.plejd.com/parse/';
|
||||||
|
API_LOGIN_URL = 'login';
|
||||||
|
API_SITES_URL = 'functions/getSites';
|
||||||
|
|
||||||
|
// #region logging
|
||||||
|
const debug = '';
|
||||||
|
|
||||||
|
const getLogger = () => {
|
||||||
|
const consoleLogger = msg => console.log('plejd-api', msg);
|
||||||
|
if (debug === 'console') {
|
||||||
|
return consoleLogger;
|
||||||
|
}
|
||||||
|
return _.noop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = getLogger();
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
class PlejdApi extends EventEmitter {
|
||||||
|
constructor(siteName, username, password) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.siteName = siteName;
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
|
||||||
|
this.sessionToken = '';
|
||||||
|
this.site = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
login() {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
const instance = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'X-Parse-Application-Id': API_APP_ID,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.post(
|
||||||
|
API_LOGIN_URL,
|
||||||
|
{
|
||||||
|
'username': this.username,
|
||||||
|
'password': this.password
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
self.sessionToken = response.data.sessionToken;
|
||||||
|
self.emit('loggedIn');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCryptoKey(callback) {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
const instance = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'X-Parse-Application-Id': API_APP_ID,
|
||||||
|
'X-Parse-Session-Token': this.sessionToken,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.post(API_SITES_URL)
|
||||||
|
.then((response) => {
|
||||||
|
self.site = response.data.result.find(x => x.site.title == self.siteName);
|
||||||
|
self.cryptoKey = self.site.plejdMesh.cryptoKey;
|
||||||
|
|
||||||
|
callback(self.cryptoKey);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger('unable to retrieve the crypto key. error: ' + error);
|
||||||
|
return Promise.reject('unable to retrieve the crypto key. error: ' + error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getDevices() {
|
||||||
|
let devices = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < this.site.devices.length; i++) {
|
||||||
|
let device = this.site.devices[i];
|
||||||
|
let deviceId = device.deviceId;
|
||||||
|
|
||||||
|
devices.push({
|
||||||
|
id: this.site.deviceAddress[deviceId],
|
||||||
|
name: device.title,
|
||||||
|
type: 'light'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PlejdApi };
|
||||||
11
build.json
Normal file
11
build.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"squash": false,
|
||||||
|
"build_from": {
|
||||||
|
"aarch64": "hassioaddons/base-aarch64:5.0.2",
|
||||||
|
"amd64": "hassioaddons/base-amd64:5.0.2",
|
||||||
|
"armhf": "hassioaddons/base-armhf:5.0.2",
|
||||||
|
"armv7": "hassioaddons/base-armv7:5.0.2",
|
||||||
|
"i386": "hassioaddons/base-i386:5.0.2"
|
||||||
|
},
|
||||||
|
"args": {}
|
||||||
|
}
|
||||||
41
config.json
Normal file
41
config.json
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"name": "Plejd",
|
||||||
|
"version": "2",
|
||||||
|
"slug": "plejd",
|
||||||
|
"description": "Adds support for the Swedish home automation devices from Plejd.",
|
||||||
|
"arch": [
|
||||||
|
"armhf",
|
||||||
|
"armv7",
|
||||||
|
"aarch64",
|
||||||
|
"amd64",
|
||||||
|
"i386"
|
||||||
|
],
|
||||||
|
"startup": "application",
|
||||||
|
"boot": "auto",
|
||||||
|
"auto_uart": true,
|
||||||
|
"gpio": true,
|
||||||
|
"privileged": [
|
||||||
|
"SYS_RAWIO"
|
||||||
|
],
|
||||||
|
"devices": [
|
||||||
|
"/dev/mem:/dev/mem:rwm"
|
||||||
|
],
|
||||||
|
"apparmor": false,
|
||||||
|
"host_network": true,
|
||||||
|
"options": {
|
||||||
|
"site": "Default Site",
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
"mqttBroker": "mqtt://",
|
||||||
|
"mqttUsername": "",
|
||||||
|
"mqttPassword": ""
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"site": "str",
|
||||||
|
"username": "str",
|
||||||
|
"password": "str",
|
||||||
|
"mqttBroker": "str",
|
||||||
|
"mqttUsername": "str",
|
||||||
|
"mqttPassword": "str"
|
||||||
|
}
|
||||||
|
}
|
||||||
68
main.js
Normal file
68
main.js
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
const plejd = require('./plejd');
|
||||||
|
const api = require('./api');
|
||||||
|
const mqtt = require('./mqtt');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const rawData = fs.readFileSync('/data/plejd.json');
|
||||||
|
const config = JSON.parse(rawData);
|
||||||
|
|
||||||
|
const plejdApi = new api.PlejdApi(config.site, config.username, config.password);
|
||||||
|
const client = new mqtt.MqttClient(config.mqttBroker, config.mqttUsername, config.mqttPassword);
|
||||||
|
|
||||||
|
plejdApi.once('loggedIn', () => {
|
||||||
|
plejdApi.getCryptoKey((cryptoKey) => {
|
||||||
|
const devices = plejdApi.getDevices();
|
||||||
|
|
||||||
|
client.once('connected', () => {
|
||||||
|
console.log('plejd-mqtt: connected to mqtt.');
|
||||||
|
client.discover(devices);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.init();
|
||||||
|
|
||||||
|
// init the BLE interface
|
||||||
|
const controller = new plejd.Controller(cryptoKey, true);
|
||||||
|
controller.on('scanComplete', async (peripherals) => {
|
||||||
|
await controller.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.on('connected', () => {
|
||||||
|
console.log('plejd: connected via bluetooth.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// subscribe to changes from Plejd
|
||||||
|
controller.on('stateChanged', (deviceId, state) => {
|
||||||
|
client.updateState(deviceId, state);
|
||||||
|
});
|
||||||
|
controller.on('dimChanged', (deviceId, state, dim) => {
|
||||||
|
client.updateState(deviceId, state);
|
||||||
|
client.updateBrightness(deviceId, dim);
|
||||||
|
});
|
||||||
|
|
||||||
|
// subscribe to changes from HA
|
||||||
|
client.on('stateChanged', (deviceId, state) => {
|
||||||
|
if (state) {
|
||||||
|
controller.turnOn(deviceId);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
controller.turnOff(deviceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
client.on('brightnessChanged', (deviceId, brightness) => {
|
||||||
|
if (brightness > 0) {
|
||||||
|
controller.turnOn(deviceId, brightness);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
controller.turnOff(deviceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.init();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
plejdApi.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
170
mqtt.js
Normal file
170
mqtt.js
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const mqtt = require('mqtt');
|
||||||
|
const _ = require('lodash');
|
||||||
|
|
||||||
|
const startTopic = 'hass/status';
|
||||||
|
|
||||||
|
// #region logging
|
||||||
|
const debug = '';
|
||||||
|
|
||||||
|
const getLogger = () => {
|
||||||
|
const consoleLogger = msg => console.log('plejd-mqtt', msg);
|
||||||
|
if (debug === 'console') {
|
||||||
|
return consoleLogger;
|
||||||
|
}
|
||||||
|
return _.noop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = getLogger();
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region discovery
|
||||||
|
|
||||||
|
const discoveryPrefix = 'homeassistant';
|
||||||
|
const nodeId = 'plejd';
|
||||||
|
|
||||||
|
const getSubscribePath = () => `${discoveryPrefix}/+/${nodeId}/#`;
|
||||||
|
const getPath = ({ id, type }) =>
|
||||||
|
`${discoveryPrefix}/${type}/${nodeId}/${id}`;
|
||||||
|
const getConfigPath = plug => `${getPath(plug)}/config`;
|
||||||
|
const getAvailabilityTopic = plug => `${getPath(plug)}/availability`;
|
||||||
|
const getStateTopic = plug => `${getPath(plug)}/state`;
|
||||||
|
const getBrightnessCommandTopic = plug => `${getPath(plug)}/setBrightness`;
|
||||||
|
const getBrightnessTopic = plug => `${getPath(plug)}/brightness`;
|
||||||
|
const getCommandTopic = plug => `${getPath(plug)}/set`;
|
||||||
|
|
||||||
|
const getDiscoveryPayload = device => ({
|
||||||
|
name: device.name,
|
||||||
|
unique_id: `light.plejd.${device.name.toLowerCase().replace(/ /g, '')}`,
|
||||||
|
state_topic: getStateTopic(device),
|
||||||
|
command_topic: getCommandTopic(device),
|
||||||
|
brightness_command_topic: getBrightnessCommandTopic(device),
|
||||||
|
brightness_state_topic: getBrightnessTopic(device),
|
||||||
|
payload_on: 1,
|
||||||
|
payload_off: 0,
|
||||||
|
optimistic: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
class MqttClient extends EventEmitter {
|
||||||
|
constructor(mqttBroker, username, password) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.mqttBroker = mqttBroker;
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
this.deviceMap = {};
|
||||||
|
this.devices = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
this.client = mqtt.connect(this.mqttBroker, {
|
||||||
|
username: this.username,
|
||||||
|
password: this.password
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('connect', () => {
|
||||||
|
logger('connected to MQTT.');
|
||||||
|
|
||||||
|
this.client.subscribe(startTopic, (err) => {
|
||||||
|
if (err) {
|
||||||
|
logger('error: unable to subscribe to ' + startTopic);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.emit('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.subscribe(getSubscribePath(), (err) => {
|
||||||
|
if (err) {
|
||||||
|
logger('error: unable to subscribe to control topics');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('close', () => {
|
||||||
|
self.reconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('message', (topic, message) => {
|
||||||
|
const command = message.toString();
|
||||||
|
|
||||||
|
if (topic === startTopic) {
|
||||||
|
logger('home assistant has started. lets do discovery.');
|
||||||
|
self.emit('connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_.includes(topic, 'setBrightness')) {
|
||||||
|
const device = self.devices.find(x => getBrightnessCommandTopic(x) === topic);
|
||||||
|
logger('got brightness update for ' + device.name + ' with brightness: ' + command);
|
||||||
|
|
||||||
|
self.emit('brightnessChanged', device.id, parseInt(command));
|
||||||
|
}
|
||||||
|
else if (_.includes(topic, 'set') && _.includes(['0', '1'], command)) {
|
||||||
|
const device = self.devices.find(x => getCommandTopic(x) === topic);
|
||||||
|
logger('got state update for ' + device.name + ' with state: ' + command);
|
||||||
|
|
||||||
|
self.emit('stateChanged', device.id, parseInt(command));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnect() {
|
||||||
|
this.client.reconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
discover(devices) {
|
||||||
|
this.devices = devices;
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
logger('sending discovery of ' + devices.length + ' device(s).');
|
||||||
|
|
||||||
|
devices.forEach((device) => {
|
||||||
|
logger(`sending discovery for ${device.name}`);
|
||||||
|
|
||||||
|
const payload = getDiscoveryPayload(device);
|
||||||
|
self.deviceMap[device.id] = payload.unique_id;
|
||||||
|
|
||||||
|
self.client.publish(
|
||||||
|
getConfigPath(device),
|
||||||
|
JSON.stringify(payload)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState(deviceId, state) {
|
||||||
|
const device = this.devices.find(x => x.id === deviceId);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
logger('error: ' + deviceId + ' is not handled by us.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger('updating state for ' + device.name + ': ' + state);
|
||||||
|
|
||||||
|
this.client.publish(
|
||||||
|
getStateTopic(device),
|
||||||
|
state.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBrightness(deviceId, brightness) {
|
||||||
|
const device = this.devices.find(x => x.id === deviceId);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
logger('error: ' + deviceId + ' is not handled by us.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger('updating brightness for ' + device.name + ': ' + brightness);
|
||||||
|
|
||||||
|
this.client.publish(
|
||||||
|
getBrightnessTopic(device),
|
||||||
|
brightness.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { MqttClient };
|
||||||
1333
package-lock.json
generated
Normal file
1333
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
12
package.json
Normal file
12
package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@abandonware/noble": "^1.9.2-5",
|
||||||
|
"@abandonware/bluetooth-hci-socket": "0.5.3-3",
|
||||||
|
"axios": "^0.19.0",
|
||||||
|
"buffer-xor": "^2.0.2",
|
||||||
|
"fs": "0.0.1-security",
|
||||||
|
"jspack": "0.0.4",
|
||||||
|
"lodash": "^4.17.15",
|
||||||
|
"mqtt": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
437
plejd.js
Normal file
437
plejd.js
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
const noble = require('@abandonware/noble');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const xor = require('buffer-xor');
|
||||||
|
const _ = require('lodash');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
|
let debug = '';
|
||||||
|
|
||||||
|
const getLogger = () => {
|
||||||
|
const consoleLogger = msg => console.log('plejd', msg);
|
||||||
|
if (debug === 'console') {
|
||||||
|
return consoleLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
// > /dev/null
|
||||||
|
return _.noop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = getLogger();
|
||||||
|
|
||||||
|
// UUIDs
|
||||||
|
const PLEJD_SERVICE = "31ba000160854726be45040c957391b5"
|
||||||
|
const DATA_UUID = "31ba000460854726be45040c957391b5"
|
||||||
|
const LAST_DATA_UUID = "31ba000560854726be45040c957391b5"
|
||||||
|
const AUTH_UUID = "31ba000960854726be45040c957391b5"
|
||||||
|
const PING_UUID = "31ba000a60854726be45040c957391b5"
|
||||||
|
|
||||||
|
class Controller extends EventEmitter {
|
||||||
|
constructor(cryptoKey, keepAlive = false) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.cryptoKey = Buffer.from(cryptoKey.replace(/-/g, ''), 'hex');
|
||||||
|
this.peripheral = null;
|
||||||
|
this.peripheral_address = null;
|
||||||
|
|
||||||
|
this.isScanning = false;
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.isConnected = false;
|
||||||
|
this.keepAlive = keepAlive;
|
||||||
|
this.writeQueue = [];
|
||||||
|
this.peripherals = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
noble.on('stateChange', async (state) => {
|
||||||
|
logger('ble state changed: ' + state);
|
||||||
|
|
||||||
|
if (state === 'poweredOn') {
|
||||||
|
await this.scan();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
noble.on('discover', (peripheral) => {
|
||||||
|
logger('found ' + peripheral.advertisement.localName + ' with addr ' + peripheral.address);
|
||||||
|
if (peripheral.advertisement.localName === 'P mesh') {
|
||||||
|
self.peripherals.push(peripheral);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async scan() {
|
||||||
|
const self = this;
|
||||||
|
this.isScanning = true;
|
||||||
|
noble.startScanning([PLEJD_SERVICE]);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
noble.stopScanning();
|
||||||
|
this.isScanning = false;
|
||||||
|
|
||||||
|
self.peripherals.sort((a, b) => a.rssi > b.rssi);
|
||||||
|
this.emit('scanComplete', self.peripherals);
|
||||||
|
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
if (this.isScanning) {
|
||||||
|
logger('already scanning, waiting.');
|
||||||
|
|
||||||
|
setTimeout(this.connect(), 1000);
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.peripherals.length) {
|
||||||
|
await this.scan();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnecting = true;
|
||||||
|
|
||||||
|
let idx = 0;
|
||||||
|
return await this._internalConnect(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _internalConnect(idx) {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
if (idx >= this.peripherals.length) {
|
||||||
|
logger('reached end of list.');
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger('connecting to Plejd device');
|
||||||
|
try {
|
||||||
|
this.peripherals[idx].connect(async (err) => {
|
||||||
|
if (err) {
|
||||||
|
logger('error: failed to connect to Plejd device: ' + err);
|
||||||
|
return await self._internalConnect(idx + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.peripheral = self.peripherals[idx];
|
||||||
|
logger('connected to Plejd device with addr ' + self.peripheral.address);
|
||||||
|
|
||||||
|
self.peripheral_address = self._reverseBuffer(Buffer.from(String(self.peripheral.address).replace(/\-/g, '').replace(/\:/g, ''), 'hex'));
|
||||||
|
|
||||||
|
logger('discovering services and characteristics');
|
||||||
|
await self.peripheral.discoverSomeServicesAndCharacteristics([PLEJD_SERVICE], [], async (err, services, characteristics) => {
|
||||||
|
if (err) {
|
||||||
|
logger('error: failed to discover services: ' + err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
characteristics.forEach((ch) => {
|
||||||
|
if (DATA_UUID == ch.uuid) {
|
||||||
|
logger('found DATA characteristic.');
|
||||||
|
self.dataCharacteristic = ch;
|
||||||
|
}
|
||||||
|
else if (LAST_DATA_UUID == ch.uuid) {
|
||||||
|
logger('found LAST_DATA characteristic.');
|
||||||
|
self.lastDataCharacteristic = ch;
|
||||||
|
}
|
||||||
|
else if (AUTH_UUID == ch.uuid) {
|
||||||
|
logger('found AUTH characteristic.');
|
||||||
|
self.authCharacteristic = ch;
|
||||||
|
}
|
||||||
|
else if (PING_UUID == ch.uuid) {
|
||||||
|
logger('found PING characteristic.');
|
||||||
|
self.pingCharacteristic = ch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.dataCharacteristic
|
||||||
|
&& this.lastDataCharacteristic
|
||||||
|
&& this.authCharacteristic
|
||||||
|
&& this.pingCharacteristic) {
|
||||||
|
|
||||||
|
this.on('authenticated', () => {
|
||||||
|
logger('Plejd is connected and authenticated.');
|
||||||
|
|
||||||
|
if (self.keepAlive) {
|
||||||
|
self.startPing();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.subscribe();
|
||||||
|
|
||||||
|
self.emit('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.authenticate();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
this.isConnecting = false;
|
||||||
|
logger('error: failed to authenticate: ' + error);
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnected = true;
|
||||||
|
this.isConnecting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(true);
|
||||||
|
//});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
this.isConnecting = false;
|
||||||
|
|
||||||
|
logger('error: failed to connect to Plejd device: ' + error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe() {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
self.lastDataCharacteristic.subscribe((err) => {
|
||||||
|
if (err) {
|
||||||
|
logger('error: couldnt subscribe to notification characteristic.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribe to last data event
|
||||||
|
self.lastDataCharacteristic.on('data', (data, isNotification) => {
|
||||||
|
const decoded = self._encryptDecrypt(self.cryptoKey, self.peripheral_address, data);
|
||||||
|
|
||||||
|
let state = 0;
|
||||||
|
let dim = 0;
|
||||||
|
let device = parseInt(decoded[0], 10);
|
||||||
|
|
||||||
|
if (decoded.toString('hex', 3, 5) === '00c8' || decoded.toString('hex', 3, 5) === '0098') {
|
||||||
|
state = parseInt(decoded.toString('hex', 5, 6), 10);
|
||||||
|
dim = parseInt(decoded.toString('hex', 6, 8), 16) >> 8;
|
||||||
|
|
||||||
|
logger('d: ' + device + ' got state+dim update: ' + state + ' - ' + dim);
|
||||||
|
this.emit('dimChanged', device, state, dim);
|
||||||
|
}
|
||||||
|
else if (decoded.toString('hex', 3, 5) === '0097') {
|
||||||
|
state = parseInt(decoded.toString('hex', 5, 6), 10);
|
||||||
|
logger('d: ' + device + ' got state update: ' + state);
|
||||||
|
this.emit('stateChanged', device, state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect() {
|
||||||
|
logger('disconnecting from Plejd');
|
||||||
|
|
||||||
|
if (this.isConnected) {
|
||||||
|
clearInterval(this.pingRef);
|
||||||
|
|
||||||
|
if (this.peripheral) {
|
||||||
|
try {
|
||||||
|
await this.peripheral.disconnect();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
logger('error: unable to disconnect from Plejd: ' + error);
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnected = false;
|
||||||
|
logger('disconnected from Plejd');
|
||||||
|
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
clearInterval(this.pingRef);
|
||||||
|
this.isConnected = false;
|
||||||
|
logger('disconnected from Plejd');
|
||||||
|
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
turnOn(id, brightness) {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
logger('error: not connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger('turning on ' + id + ' at brightness ' + brightness);
|
||||||
|
|
||||||
|
var payload;
|
||||||
|
|
||||||
|
if (!brightness) {
|
||||||
|
payload = Buffer.from((id).toString(16).padStart(2, '0') + '0110009701', 'hex');
|
||||||
|
} else {
|
||||||
|
brightness = brightness << 8 | brightness;
|
||||||
|
payload = Buffer.from((id).toString(16).padStart(2, '0') + '0110009801' + (brightness).toString(16).padStart(4, '0'), 'hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.write(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
turnOff(id) {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
logger('error: not connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger('turning off ' + id);
|
||||||
|
|
||||||
|
var payload = Buffer.from((id).toString(16).padStart(2, '0') + '0110009700', 'hex');
|
||||||
|
this.write(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
startPing() {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
clearInterval(this.pingRef);
|
||||||
|
logger('starting ping');
|
||||||
|
this.pingRef = setInterval(async () => {
|
||||||
|
logger('ping');
|
||||||
|
if (self.isConnected) {
|
||||||
|
self.plejdPing(async (pingOk) => {
|
||||||
|
|
||||||
|
if (!pingOk) {
|
||||||
|
logger('ping failed');
|
||||||
|
await self.disconnect();
|
||||||
|
await self.connect();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
logger('pong');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await self.disconnect();
|
||||||
|
await self.connect();
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
plejdPing(callback) {
|
||||||
|
var ping = crypto.randomBytes(1);
|
||||||
|
|
||||||
|
this.pingCharacteristic.write(ping, false, (err) => {
|
||||||
|
if (err) {
|
||||||
|
logger('error: unable to send ping: ' + err);
|
||||||
|
callback(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pingCharacteristic.read((err, data) => {
|
||||||
|
if (err) {
|
||||||
|
logger('error: unable to read ping: ' + err);
|
||||||
|
callback(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (((ping[0] + 1) & 0xff) !== data[0]) {
|
||||||
|
callback(false);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
callback(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticate() {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
logger('authenticating connection');
|
||||||
|
this.authCharacteristic.write(Buffer.from([0]), false, (err) => {
|
||||||
|
if (err) {
|
||||||
|
logger('error: failed to authenticate: ' + err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.authCharacteristic.read(async (err2, data) => {
|
||||||
|
if (err2) {
|
||||||
|
logger('error: challenge request failed: ' + err2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp = self._challengeResponse(self.cryptoKey, data);
|
||||||
|
|
||||||
|
this.authCharacteristic.write(resp, false, (err3) => {
|
||||||
|
if (err3) {
|
||||||
|
logger('error: challenge failed: ' + err2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('authenticated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(data) {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.isConnecting) {
|
||||||
|
this.writeQueue.push(data);
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.keepAlive) {
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataCharacteristic.write(this._encryptDecrypt(this.cryptoKey, this.peripheral_address, data), false);
|
||||||
|
|
||||||
|
let writeData;
|
||||||
|
while ((writeData = this.writeQueue.shift()) !== undefined) {
|
||||||
|
await this.dataCharacteristic.write(this._encryptDecrypt(this.cryptoKey, this.peripheral_address, writeData), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.keepAlive) {
|
||||||
|
clearTimeout(this.disconnectIntervalRef);
|
||||||
|
this.disconnectIntervalRef = setTimeout(async () => {
|
||||||
|
await self.disconnect();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
logger('error when writing to plejd: ' + error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_challengeResponse(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
_encryptDecrypt(key, addr, data) {
|
||||||
|
var buf = Buffer.concat([addr, addr, addr.subarray(0, 4)]);
|
||||||
|
|
||||||
|
var cipher = crypto.createCipheriv("aes-128-ecb", key, '');
|
||||||
|
cipher.setAutoPadding(false);
|
||||||
|
|
||||||
|
var ct = cipher.update(buf).toString('hex');
|
||||||
|
ct += cipher.final().toString('hex');
|
||||||
|
ct = Buffer.from(ct, 'hex');
|
||||||
|
|
||||||
|
var output = "";
|
||||||
|
for (var i = 0, length = data.length; i < length; i++) {
|
||||||
|
output += String.fromCharCode(data[i] ^ ct[i % 16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(output, 'ascii');
|
||||||
|
}
|
||||||
|
|
||||||
|
_reverseBuffer(src) {
|
||||||
|
var buffer = Buffer.allocUnsafe(src.length)
|
||||||
|
|
||||||
|
for (var i = 0, j = src.length - 1; i <= j; ++i, --j) {
|
||||||
|
buffer[i] = src[j]
|
||||||
|
buffer[j] = src[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Controller };
|
||||||
8
rootfs/etc/services.d/plejd/finish
Normal file
8
rootfs/etc/services.d/plejd/finish
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
#!/usr/bin/execlineb -S0
|
||||||
|
# ==============================================================================
|
||||||
|
# Community Hass.io Add-ons: Plejd
|
||||||
|
# ==============================================================================
|
||||||
|
if -n { s6-test $# -ne 0 }
|
||||||
|
if -n { s6-test ${1} -eq 256 }
|
||||||
|
|
||||||
|
s6-svscanctl -t /var/run/s6/services
|
||||||
14
rootfs/etc/services.d/plejd/run
Normal file
14
rootfs/etc/services.d/plejd/run
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/usr/bin/with-contenv bashio
|
||||||
|
# ==============================================================================
|
||||||
|
# Community Hass.io Add-ons: Plejd
|
||||||
|
# Runs the Plejd service
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
bashio::log.info 'Starting the Plejd service...'
|
||||||
|
|
||||||
|
# Change working directory
|
||||||
|
cd /plejd || bashio::exit.nok 'Unable to change working directory'
|
||||||
|
|
||||||
|
# Run the Plejd service
|
||||||
|
chmod +x /usr/bin/plejd.sh
|
||||||
|
exec /usr/bin/plejd.sh
|
||||||
25
rootfs/usr/bin/plejd.sh
Normal file
25
rootfs/usr/bin/plejd.sh
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
#!/usr/bin/with-contenv bashio
|
||||||
|
|
||||||
|
CONFIG_PATH=/data/options.json
|
||||||
|
|
||||||
|
SITE=$(jq --raw-output ".site" $CONFIG_PATH)
|
||||||
|
USERNAME=$(jq --raw-output ".username" $CONFIG_PATH)
|
||||||
|
PASSWORD=$(jq --raw-output ".password" $CONFIG_PATH)
|
||||||
|
MQTTBROKER=$(jq --raw-output ".mqttBroker" $CONFIG_PATH)
|
||||||
|
MQTTUSERNAME=$(jq --raw-output ".mqttUsername" $CONFIG_PATH)
|
||||||
|
MQTTPASSWORD=$(jq --raw-output ".mqttPassword" $CONFIG_PATH)
|
||||||
|
|
||||||
|
PLEJD_PATH=/data/plejd.json
|
||||||
|
PLEJD_CONFIG="{
|
||||||
|
\"site\": \"$SITE\",
|
||||||
|
\"username\": \"$USERNAME\",
|
||||||
|
\"password\": \"$PASSWORD\",
|
||||||
|
\"mqttBroker\": \"$MQTTBROKER\",
|
||||||
|
\"mqttUsername\": \"$MQTTUSERNAME\",
|
||||||
|
\"mqttPassword\": \"$MQTTPASSWORD\"
|
||||||
|
}
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "$PLEJD_CONFIG" > $PLEJD_PATH
|
||||||
|
|
||||||
|
exec node /plejd/main.js
|
||||||
Loading…
Add table
Add a link
Reference in a new issue