0.17.0 release - improve startup and fix color temp (#324)

* Add working support for color temperature

* Lint fixes

* Fix to config json version to make it build

* Clean up and BLE constants and prepare for lightlevel UUID

* Eagerly send HA discovery, standardize colorTemp, clean up MQTT subscribe

* Fix typo in MqttClient

* Listen to HA birth messages to make devices available after HA restart

- Extract constants to common file

* Prepare for 0.17.0 release
This commit is contained in:
Victor 2025-09-12 08:36:02 +02:00 committed by GitHub
parent d801410200
commit 1766afb2e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 650 additions and 165 deletions

View file

@ -1,5 +1,21 @@
# Changelog hassio-plejd Home Assistant Plejd addon
## [0.17.0](https://github.com/icanos/hassio-plejd/tree/0.17.0) (2025-09-10)
[Full Changelog](https://github.com/icanos/hassio-plejd/compare/0.16.0...0.17.0)
**Implemented enhancements:**
- Add working support for color temperature
- Eagerly send HA discovery, standardize colorTemp, clean up MQTT subscribe
- Clean up and BLE constants and prepare for lightlevel UUID
**Fixed:**
- Fix typo in MqttClient
- Lint fixes
- Fix to config json version to make it build
## [0.16.0](https://github.com/icanos/hassio-plejd/tree/0.16.0) (2025-08-07)
[Full Changelog](https://github.com/icanos/hassio-plejd/compare/0.15.0...0.16.0)

View file

@ -257,8 +257,9 @@ class DeviceRegistry {
* @param {string} uniqueOutputId
* @param {boolean} state
* @param {number?} [dim]
* @param {number?} [color]
*/
setOutputState(uniqueOutputId, state, dim) {
setOutputState(uniqueOutputId, state, dim, color) {
const device = this.getOutputDevice(uniqueOutputId);
if (!device) {
logger.warn(
@ -268,9 +269,12 @@ class DeviceRegistry {
}
device.state = state;
if (dim && device.dimmable) {
if (typeof dim === 'number' && device.dimmable) {
device.dim = dim;
}
if (typeof color === 'number' && device.colorTemp) {
device.colorTemp = color;
}
if (Logger.shouldLog('silly')) {
logger.silly(`Updated state: ${JSON.stringify(device)}`);
}

View file

@ -1,32 +1,22 @@
const EventEmitter = require('events');
const { EventEmitter } = require('events');
const mqtt = require('mqtt');
const Configuration = require('./Configuration');
const Logger = require('./Logger');
// const startTopics = ['hass/status', 'homeassistant/status'];
const {
MQTT_TYPES,
TOPIC_TYPES,
MQTT_STATE,
DEVICE_TYPES,
AVAILABILITY,
MQTT_TOPICS,
} = require('./constants');
const logger = Logger.getLogger('plejd-mqtt');
const discoveryPrefix = 'homeassistant';
const nodeId = 'plejd';
/** @type {import('./types/Mqtt').MQTT_TYPES} */
const MQTT_TYPES = {
LIGHT: 'light',
SCENE: 'scene',
SWITCH: 'switch',
DEVICE_AUTOMATION: 'device_automation',
};
/** @type {import('./types/Mqtt').TOPIC_TYPES} */
const TOPIC_TYPES = {
CONFIG: 'config',
STATE: 'state',
AVAILABILITY: 'availability',
SET: 'set',
};
const getBaseTopic = (/** @type { string } */ uniqueId, /** @type { string } */ mqttDeviceType) =>
`${discoveryPrefix}/${mqttDeviceType}/${nodeId}/${uniqueId}`;
@ -79,8 +69,9 @@ const getOutputDeviceDiscoveryPayload = (
device.colorTempSettings &&
device.colorTempSettings.behavior === 'adjustable'
? {
min_mireds: 1000000 / device.colorTempSettings.minTemperatureLimit,
max_mireds: 1000000 / device.colorTempSettings.maxTemperatureLimit,
color_temp_kelvin: true,
min_kelvin: device.colorTempSettings.minTemperatureLimit,
max_kelvin: device.colorTempSettings.maxTemperatureLimit,
supported_color_modes: ['color_temp'],
}
: {}),
@ -94,7 +85,7 @@ const getSceneDiscoveryPayload = (
'~': getBaseTopic(sceneDevice.uniqueId, MQTT_TYPES.SCENE),
command_topic: `~/${TOPIC_TYPES.SET}`,
availability_topic: `~/${TOPIC_TYPES.AVAILABILITY}`,
payload_on: 'ON',
payload_on: MQTT_STATE.ON,
qos: 1,
retain: true, // Discovery messages should be retained to account for HA restarts
});
@ -136,13 +127,16 @@ const getSceneDeviceTriggerhDiscoveryPayload = (
},
});
const getMqttStateString = (/** @type {boolean} */ state) => (state ? 'ON' : 'OFF');
const AVAILABLITY = { ONLINE: 'online', OFFLINE: 'offline' };
const getMqttStateString = (/** @type {boolean} */ state) =>
state ? MQTT_STATE.ON : MQTT_STATE.OFF;
class MqttClient extends EventEmitter {
/** @type {import('DeviceRegistry')} */
deviceRegistry;
static STATE = MQTT_STATE;
static DEVICE_TYPES = DEVICE_TYPES;
static EVENTS = {
connected: 'connected',
stateChanged: 'stateChanged',
@ -180,25 +174,24 @@ class MqttClient extends EventEmitter {
this.client.on('connect', () => {
logger.info('Connected to MQTT.');
logger.verbose('Emitting internal MqttClient.EVENTS.connected event');
this.emit(MqttClient.EVENTS.connected);
// Testing to skip listening to HA birth messages all together
// this.client.subscribe(
// startTopics,
// {
// qos: 1,
// nl: true, // don't echo back messages sent
// rap: true, // retain as published - don't force retain = 0
// rh: 0, // Retain handling 0 presumably ignores retained messages
// },
// (err) => {
// if (err) {
// logger.error('Unable to subscribe to status topics', err);
// }
// this.emit(MqttClient.EVENTS.connected);
// },
// );
// Listen for future Home Assistant birth messages if HA is not yet started
this.client.subscribe(
[MQTT_TOPICS.STATUS],
{
qos: 1,
nl: true, // don't echo back messages sent
rap: true, // retain as published - don't force retain = 0
rh: 0, // Retain handling 0 presumably ignores retained messages
},
(err) => {
if (err) {
logger.error('Unable to subscribe to status topic', err);
}
},
);
});
this.client.on('close', () => {
@ -256,6 +249,14 @@ class MqttClient extends EventEmitter {
default:
logger.verbose(`Warning: Unknown command ${decodedTopic.command} in decoded topic`);
}
} else if (topic === MQTT_TOPICS.STATUS) {
const status = message.toString();
if (status === AVAILABILITY.ONLINE) {
logger.verbose(
'Home Assistant is online, emitting internal MqttClient.EVENTS.connected event',
);
this.emit(MqttClient.EVENTS.connected);
}
} else {
logger.verbose(
`Warning: Got unrecognized mqtt command on '${topic}': ${message.toString()}`,
@ -282,7 +283,7 @@ class MqttClient extends EventEmitter {
const mqttType = outputDevice.type === 'switch' ? MQTT_TYPES.SWITCH : MQTT_TYPES.LIGHT;
this.client.publish(
getTopicName(outputDevice.uniqueId, mqttType, 'availability'),
AVAILABLITY.OFFLINE,
AVAILABILITY.OFFLINE,
{
retain: false, // Availability messages should NOT be retained
qos: 1,
@ -294,7 +295,7 @@ class MqttClient extends EventEmitter {
allSceneDevices.forEach((sceneDevice) => {
this.client.publish(
getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.AVAILABILITY),
AVAILABLITY.OFFLINE,
AVAILABILITY.OFFLINE,
{
retain: false, // Availability messages should NOT be retained
qos: 1,
@ -342,7 +343,7 @@ class MqttClient extends EventEmitter {
// Forcefully remove retained (from us, v0.11 and before) AVAILABILITY messages
this.client.publish(
getTopicName(outputDevice.uniqueId, mqttType, TOPIC_TYPES.AVAILABLILITY),
getTopicName(outputDevice.uniqueId, mqttType, TOPIC_TYPES.AVAILABILITY),
null,
{
retain: true, // Retain true to remove previously retained message
@ -356,7 +357,7 @@ class MqttClient extends EventEmitter {
this.client.publish(
getTopicName(outputDevice.uniqueId, mqttType, TOPIC_TYPES.AVAILABILITY),
AVAILABLITY.ONLINE,
AVAILABILITY.ONLINE,
{
retain: false, // Availability messages should NOT be retained
qos: 1,
@ -430,7 +431,7 @@ class MqttClient extends EventEmitter {
this.client.publish(
getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.AVAILABILITY),
AVAILABLITY.ONLINE,
AVAILABILITY.ONLINE,
{
retain: false, // Availability messages should NOT be retained
qos: 1,
@ -457,7 +458,7 @@ class MqttClient extends EventEmitter {
/**
* @param {string} uniqueOutputId
* @param {{ state: boolean; brightness?: number; }} data
* @param {{ state: boolean; brightness?: number; color?: number}} data
*/
updateOutputState(uniqueOutputId, data) {
const device = this.deviceRegistry.getOutputDevice(uniqueOutputId);
@ -470,7 +471,7 @@ class MqttClient extends EventEmitter {
logger.verbose(
`Updating state for ${device.name}: ${data.state}${
data.brightness ? `, dim: ${data.brightness}` : ''
}`,
}${data.color ? `, color: ${data.color}K` : ''}`,
);
let payload = null;
@ -478,10 +479,19 @@ class MqttClient extends EventEmitter {
payload = getMqttStateString(data.state);
} else {
if (device.dimmable) {
payload = {
state: getMqttStateString(data.state),
brightness: data.brightness,
};
if (data.color) {
payload = {
state: getMqttStateString(data.state),
brightness: data.brightness,
color_mode: 'color_temp',
color_temp: data.color,
};
} else {
payload = {
state: getMqttStateString(data.state),
brightness: data.brightness,
};
}
} else {
payload = {
state: getMqttStateString(data.state),

View file

@ -1,4 +1,4 @@
const EventEmitter = require('events');
const { EventEmitter } = require('events');
const Configuration = require('./Configuration');
const Logger = require('./Logger');
@ -55,6 +55,15 @@ class PlejdAddon extends EventEmitter {
process.on(signal, this.processCleanupFunc);
});
// Eagerly send discovery as soon as possible
try {
logger.verbose('Eagerly sending discovery to Home Assistant.');
this.mqttClient.sendDiscoveryToHomeAssistant();
} catch (err) {
logger.error('Error in eager discovery send', err);
}
// Send discovery again on MQTT connect to ensure Home Assistant receives device info after reconnects or broker restarts.
this.mqttClient.on(MqttClient.EVENTS.connected, () => {
try {
logger.verbose('connected to mqtt.');
@ -72,7 +81,7 @@ class PlejdAddon extends EventEmitter {
try {
const { uniqueId } = device;
if (device.typeName === 'Scene') {
if (device.typeName === MqttClient.DEVICE_TYPES.SCENE) {
// we're triggering a scene, lets do that and jump out.
// since scenes aren't "real" devices.
this.sceneManager.executeScene(uniqueId);
@ -93,7 +102,7 @@ class PlejdAddon extends EventEmitter {
if (typeof command === 'string') {
// switch command
state = command === 'ON';
state = command === MqttClient.STATE.ON;
commandObj = {
state,
};
@ -106,7 +115,7 @@ class PlejdAddon extends EventEmitter {
});
} else {
// eslint-disable-next-line prefer-destructuring
state = command.state === 'ON';
state = command.state === MqttClient.STATE.ON;
commandObj = command;
}

View file

@ -4,11 +4,16 @@ const fs = require('fs');
const Configuration = require('./Configuration');
const Logger = require('./Logger');
const API_APP_ID = 'zHtVqXt8k4yFyk2QGmgp48D9xZr2G94xWYnF4dak';
const API_BASE_URL = 'https://cloud.plejd.com/parse/';
const API_LOGIN_URL = 'login';
const API_SITE_LIST_URL = 'functions/getSiteList';
const API_SITE_DETAILS_URL = 'functions/getSiteById';
const {
API: {
APP_ID: API_APP_ID,
BASE_URL: API_BASE_URL,
LOGIN_URL: API_LOGIN_URL,
SITE_LIST_URL: API_SITE_LIST_URL,
SITE_DETAILS_URL: API_SITE_DETAILS_URL,
},
DEVICE_TYPES,
} = require('./constants');
const TRAITS = {
NO_LOAD: 0, // 0b0000
@ -305,7 +310,7 @@ class PlejdApi {
return {
name: 'REL-01',
description: '1 channel relay, 3500 VA',
type: 'switch',
type: DEVICE_TYPES.SWITCH,
dimmable: false,
broadcastClicks: false,
};
@ -313,7 +318,7 @@ class PlejdApi {
return {
name: 'SPR-01',
description: 'Smart plug on/off with relay, 3500 VA',
type: 'switch',
type: DEVICE_TYPES.SWITCH,
dimmable: false,
broadcastClicks: false,
};
@ -363,7 +368,7 @@ class PlejdApi {
return {
name: 'REL-01-2P',
description: '1-channel relay with 2-pole 3500 VA',
type: 'switch',
type: DEVICE_TYPES.SWITCH,
dimmable: false,
broadcastClicks: false,
};
@ -371,7 +376,7 @@ class PlejdApi {
return {
name: 'REL-02',
description: '2-channel relay with combined 3500 VA',
type: 'switch',
type: DEVICE_TYPES.SWITCH,
dimmable: false,
broadcastClicks: false,
};
@ -512,11 +517,6 @@ class PlejdApi {
// 2. outputSettings.dimCurve NOT IN ["NonDimmable", "RelayNormal"]: Dimmable
// 3. outputSettings.predefinedLoad !== null && outputSettings.predefinedLoad.loadType === "DWN": Dimmable
const colorTemp =
outputSettings &&
outputSettings.colorTemperature &&
outputSettings.colorTemperature.behavior === 'adjustable';
try {
const decodedDeviceType = this._getDeviceType(plejdDevice);
@ -533,7 +533,7 @@ class PlejdApi {
/** @type {import('types/DeviceRegistry').OutputDevice} */
const outputDevice = {
bleOutputAddress,
colorTemp,
colorTemp: null,
colorTempSettings: outputSettings ? outputSettings.colorTemperature : null,
deviceId: device.deviceId,
dimmable,
@ -640,7 +640,7 @@ class PlejdApi {
const newDevice = {
bleOutputAddress: roomAddress,
deviceId: null,
colorTemp: false,
colorTemp: null,
dimmable,
name: room.title,
output: undefined,
@ -670,7 +670,7 @@ class PlejdApi {
/** @type {import('types/DeviceRegistry').OutputDevice} */
const newScene = {
bleOutputAddress: sceneNum,
colorTemp: false,
colorTemp: null,
deviceId: undefined,
dimmable: false,
name: scene.title,
@ -678,9 +678,9 @@ class PlejdApi {
roomId: undefined,
roomName: undefined,
state: false,
type: 'scene',
type: DEVICE_TYPES.SCENE,
typeDescription: 'A Plejd scene',
typeName: 'Scene',
typeName: DEVICE_TYPES.SCENE,
version: undefined,
uniqueId: scene.sceneId,
};

View file

@ -4,43 +4,44 @@ const xor = require('buffer-xor');
const { EventEmitter } = require('events');
const Configuration = require('./Configuration');
const constants = require('./constants');
const {
COMMANDS,
BLE,
PLEJD_UUIDS,
BLUEZ: {
SERVICE_NAME: BLUEZ_SERVICE_NAME,
ADAPTER_ID: BLUEZ_ADAPTER_ID,
DEVICE_ID: BLUEZ_DEVICE_ID,
GATT_SERVICE_ID,
GATT_CHAR_ID: GATT_CHRC_ID,
},
DBUS: { OM_INTERFACE: DBUS_OM_INTERFACE, PROP_INTERFACE: DBUS_PROP_INTERFACE },
} = require('./constants');
const Logger = require('./Logger');
const { COMMANDS } = constants;
const logger = Logger.getLogger('plejd-ble');
// UUIDs
const BLE_UUID_SUFFIX = '6085-4726-be45-040c957391b5';
const PLEJD_SERVICE = `31ba0001-${BLE_UUID_SUFFIX}`;
const DATA_UUID = `31ba0004-${BLE_UUID_SUFFIX}`;
const LAST_DATA_UUID = `31ba0005-${BLE_UUID_SUFFIX}`;
const AUTH_UUID = `31ba0009-${BLE_UUID_SUFFIX}`;
const PING_UUID = `31ba000a-${BLE_UUID_SUFFIX}`;
const { PLEJD_SERVICE, AUTH_UUID, DATA_UUID, LAST_DATA_UUID, PING_UUID } = PLEJD_UUIDS;
const { COMMANDS: BLE_COMMANDS, BROADCAST_DEVICE_ID: BLE_BROADCAST_DEVICE_ID } = BLE;
const BLE_CMD_DIM_CHANGE = 0x00c8;
const BLE_CMD_DIM2_CHANGE = 0x0098;
const BLE_CMD_STATE_CHANGE = 0x0097;
const BLE_CMD_SCENE_TRIG = 0x0021;
const BLE_CMD_TIME_UPDATE = 0x001b;
const BLE_CMD_REMOTE_CLICK = 0x0016;
// BLE commands for easier access
const {
REMOTE_CLICK: BLE_CMD_REMOTE_CLICK,
TIME_UPDATE: BLE_CMD_TIME_UPDATE,
SCENE_TRIGGER: BLE_CMD_SCENE_TRIG,
STATE_CHANGE: BLE_CMD_STATE_CHANGE,
DIM_CHANGE: BLE_CMD_DIM_CHANGE,
COLOR_CHANGE: BLE_CMD_COLOR_CHANGE,
} = BLE_COMMANDS;
const BLE_BROADCAST_DEVICE_ID = 0x01;
const BLE_REQUEST_NO_RESPONSE = 0x0110;
const BLE_REQUEST_RESPONSE = 0x0102;
// const BLE_REQUEST_READ_VALUE = 0x0103;
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 BLE_CMD_DIM2_CHANGE = 0x0098; // Dim + state update
const BLE_REQUEST_NO_RESPONSE = 0x0110; // Set value, no response.
const BLE_REQUEST_RESPONSE = 0x0102; // Request response, time for example
// const BLE_REQUEST_READ_VALUE = 0x0103; // Read value?
const PAYLOAD_POSITION_OFFSET = 5;
const DIM_LEVEL_POSITION_OFFSET = 7;
const COLOR_TEMP_POSITION_OFFSET = 9;
const delay = (timeout) =>
new Promise((resolve) => {
@ -168,11 +169,13 @@ class PlejBLEHandler extends EventEmitter {
/**
* @param {string} command
* @param {number} bleOutputAddress
* @param {number} data
* @param {number?} brightness
* @param {number?} colorTemp
*/
async sendCommand(command, bleOutputAddress, data) {
async sendCommand(command, bleOutputAddress, brightness, colorTemp) {
let payload;
let brightnessVal;
switch (command) {
case COMMANDS.TURN_ON:
payload = this._createHexPayload(bleOutputAddress, BLE_CMD_STATE_CHANGE, '01');
@ -182,18 +185,39 @@ class PlejBLEHandler extends EventEmitter {
break;
case COMMANDS.DIM:
// eslint-disable-next-line no-bitwise
brightnessVal = (data << 8) | data;
brightnessVal = (brightness << 8) | brightness;
payload = this._createHexPayload(
bleOutputAddress,
BLE_CMD_DIM2_CHANGE,
`01${brightnessVal.toString(16).padStart(4, '0')}`,
);
break;
case COMMANDS.COLOR:
// eslint-disable-next-line no-bitwise
payload = this._createHexPayload(
bleOutputAddress,
BLE_CMD_COLOR_CHANGE,
// Not clear why 030111 is used. See https://github.com/icanos/hassio-plejd/issues/163
`030111${colorTemp.toString(16).padStart(4, '0')}`,
);
break;
default:
logger.error(`Unknown command ${command}`);
throw new Error(`Unknown command ${command}`);
}
await this._write(payload);
if (command === COMMANDS.COLOR) {
// Color BLE command is not echoed back, so we manually emit the event here
const device = this.deviceRegistry.getOutputDeviceByBleOutputAddress(bleOutputAddress);
if (device) {
this.emit(PlejBLEHandler.EVENTS.commandReceived, device.uniqueId, command, {
state: 1,
color: colorTemp,
});
}
}
}
async _initDiscoveredPlejdDevice(path) {
@ -302,10 +326,12 @@ class PlejBLEHandler extends EventEmitter {
} else {
logger.info('Plejd clock updates disabled in configuration.');
}
this._startPing();
// After we've authenticated, we need to hook up the event listener
// for changes to lastData.
// After we've authenticated:
// Hook up the event listener for changes to lastData.
this.characteristics.lastDataProperties.on(
'PropertiesChanged',
(
@ -577,6 +603,7 @@ class PlejBLEHandler extends EventEmitter {
);
const encryptedData = this._encryptDecrypt(this.cryptoKey, this.plejdService.addr, payload);
await this.characteristics.data.WriteValue([...encryptedData], {});
await this._onWriteSuccess();
} catch (err) {
await this._onWriteFailed(err);
@ -850,7 +877,6 @@ class PlejBLEHandler extends EventEmitter {
const outputUniqueId = device ? device.uniqueId : null;
if (Logger.shouldLog('verbose')) {
// decoded.toString() could potentially be expensive
logger.verbose(`Raw event received: ${decoded.toString('hex')}`);
logger.verbose(
`Decoded: Device ${outputUniqueId} (BLE address ${bleOutputAddress}), cmd ${cmd.toString(
@ -869,6 +895,18 @@ class PlejBLEHandler extends EventEmitter {
command = COMMANDS.DIM;
data = { state, dim };
this.emit(PlejBLEHandler.EVENTS.commandReceived, outputUniqueId, command, data);
} else if (cmd === BLE_CMD_COLOR_CHANGE) {
const colorTempKelvin =
decoded.length > COLOR_TEMP_POSITION_OFFSET
? decoded.readUInt16BE(COLOR_TEMP_POSITION_OFFSET - 1)
: 0;
logger.debug(
`${deviceName} (${outputUniqueId}) got state+dim+color update. S: ${state}, D: ${dim}, C: ${colorTempKelvin}K`,
);
command = COMMANDS.COLOR;
this.emit(PlejBLEHandler.EVENTS.commandReceived, outputUniqueId, command, data);
} else if (cmd === BLE_CMD_STATE_CHANGE) {
logger.debug(`${deviceName} (${outputUniqueId}) got state update. S: ${state}`);
command = state ? COMMANDS.TURN_ON : COMMANDS.TURN_OFF;
@ -1006,7 +1044,8 @@ class PlejBLEHandler extends EventEmitter {
// eslint-disable-next-line class-methods-use-this
_createChallengeResponse(key, challenge) {
const intermediate = crypto.createHash('sha256').update(xor(key, challenge)).digest();
const xorResult = xor(key, challenge);
const intermediate = crypto.createHash('sha256').update(new Uint8Array(xorResult)).digest();
const part1 = intermediate.subarray(0, 16);
const part2 = intermediate.subarray(16);
@ -1022,7 +1061,7 @@ class PlejBLEHandler extends EventEmitter {
const cipher = crypto.createCipheriv('aes-128-ecb', key, '');
cipher.setAutoPadding(false);
let ct = cipher.update(buf).toString('hex');
let ct = cipher.update(new Uint8Array(buf)).toString('hex');
ct += cipher.final().toString('hex');
const ctBuf = Buffer.from(ct, 'hex');

View file

@ -1,10 +1,9 @@
const { EventEmitter } = require('events');
const Configuration = require('./Configuration');
const constants = require('./constants');
const { COMMANDS } = require('./constants');
const Logger = require('./Logger');
const PlejBLEHandler = require('./PlejdBLEHandler');
const { COMMANDS } = constants;
const logger = Logger.getLogger('device-comm');
const MAX_TRANSITION_STEPS_PER_SECOND = 5; // Could be made a setting
@ -18,7 +17,7 @@ class PlejdDeviceCommunication extends EventEmitter {
/** @type {import('./DeviceRegistry')} */
deviceRegistry;
// eslint-disable-next-line max-len
/** @type {{uniqueOutputId: string, command: string, data: any, shouldRetry: boolean, retryCount?: number}[]} */
/** @type {{uniqueOutputId: string, command: {command: keyof typeof COMMANDS, brightness: number?, color_temp: number? }, shouldRetry: boolean, retryCount?: number}[]} */
writeQueue = [];
writeQueueRef = null;
@ -79,11 +78,9 @@ class PlejdDeviceCommunication extends EventEmitter {
turnOn(uniqueOutputId, command) {
const deviceName = this.deviceRegistry.getOutputDeviceName(uniqueOutputId);
logger.info(
`Plejd got turn on command for ${deviceName} (${uniqueOutputId}), brightness ${
command.brightness
}${command.transition ? `, transition: ${command.transition}` : ''}`,
`Plejd got turn on command for ${deviceName} (${uniqueOutputId})${JSON.stringify(command)}`,
);
this._transitionTo(uniqueOutputId, command.brightness, command.transition, deviceName);
this._transitionTo(uniqueOutputId, command, deviceName);
}
turnOff(uniqueOutputId, command) {
@ -93,17 +90,27 @@ class PlejdDeviceCommunication extends EventEmitter {
command.transition ? `, transition: ${command.transition}` : ''
}`,
);
this._transitionTo(uniqueOutputId, 0, command.transition, deviceName);
this._transitionTo(uniqueOutputId, { ...command, brightness: 0 }, deviceName);
}
_bleCommandReceived(uniqueOutputId, command, data) {
try {
if (command === COMMANDS.DIM) {
if (data.dim === 0 && data.state === 1) {
data.dim = 1; // Transform BLE brightness value 0 to 1, which is the minimum MQTT brightness value
}
this.deviceRegistry.setOutputState(uniqueOutputId, data.state, data.dim);
this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, uniqueOutputId, {
state: !!data.state,
brightness: data.dim,
});
} else if (command === COMMANDS.COLOR) {
this.deviceRegistry.setOutputState(uniqueOutputId, data.state, null, data.color);
logger.verbose(`Set color state to ${data.color}. Emitting EVENTS.stateChanged`);
this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, uniqueOutputId, {
state: !!data.state,
color: data.color,
});
} else if (command === COMMANDS.TURN_ON) {
this.deviceRegistry.setOutputState(uniqueOutputId, true);
this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, uniqueOutputId, {
@ -132,7 +139,12 @@ class PlejdDeviceCommunication extends EventEmitter {
}
}
_transitionTo(uniqueOutputId, targetBrightness, transition, deviceName) {
/**
* @param {string} uniqueOutputId
* @param {{ transition: number, brightness: number, color_temp: number? } } command
* @param { string } deviceName
*/
_transitionTo(uniqueOutputId, command, deviceName) {
const device = this.deviceRegistry.getOutputDevice(uniqueOutputId);
const initialBrightness = device ? device.state && device.dim : null;
this._clearDeviceTransitionTimer(uniqueOutputId);
@ -140,11 +152,11 @@ class PlejdDeviceCommunication extends EventEmitter {
const isDimmable = this.deviceRegistry.getOutputDevice(uniqueOutputId).dimmable;
if (
transition > 1 &&
command.transition > 1 &&
isDimmable &&
(initialBrightness || initialBrightness === 0) &&
(targetBrightness || targetBrightness === 0) &&
targetBrightness !== initialBrightness
(command.brightness || command.brightness === 0) &&
command.brightness !== initialBrightness
) {
// Transition time set, known initial and target brightness
// Calculate transition interval time based on delta brightness and max steps per second
@ -152,16 +164,16 @@ class PlejdDeviceCommunication extends EventEmitter {
// 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 deltaBrightness = command.brightness - initialBrightness;
const transitionSteps = Math.min(
Math.abs(deltaBrightness),
MAX_TRANSITION_STEPS_PER_SECOND * transition,
MAX_TRANSITION_STEPS_PER_SECOND * command.transition,
);
const transitionInterval = (transition * 1000) / transitionSteps;
const transitionInterval = (command.transition * 1000) / transitionSteps;
logger.debug(
`transitioning from ${initialBrightness} to ${targetBrightness} ${
transition ? `in ${transition} seconds` : ''
`transitioning from ${initialBrightness} to ${command.brightness} ${
command.transition ? `in ${command.transition} seconds` : ''
}.`,
);
logger.verbose(
@ -176,68 +188,105 @@ class PlejdDeviceCommunication extends EventEmitter {
const tElapsedMs = new Date().getTime() - dtStart.getTime();
let tElapsed = tElapsedMs / 1000;
if (tElapsed > transition || tElapsed < 0) {
tElapsed = transition;
if (tElapsed > command.transition || tElapsed < 0) {
tElapsed = command.transition;
}
let newBrightness = Math.round(
initialBrightness + (deltaBrightness * tElapsed) / transition,
initialBrightness + (deltaBrightness * tElapsed) / command.transition,
);
if (tElapsed === transition) {
if (tElapsed === command.transition) {
nSteps++;
this._clearDeviceTransitionTimer(uniqueOutputId);
newBrightness = targetBrightness;
newBrightness = command.brightness;
logger.debug(
`Queueing finalize ${deviceName} (${uniqueOutputId}) transition from ${initialBrightness} to ${targetBrightness} in ${tElapsedMs}ms. Done steps ${nSteps}. Average interval ${
`Queueing finalize ${deviceName} (${uniqueOutputId}) transition from ${initialBrightness} to ${
command.brightness
} in ${tElapsedMs}ms. Done steps ${nSteps}. Average interval ${
tElapsedMs / (nSteps || 1)
} ms.`,
);
this._setBrightness(uniqueOutputId, newBrightness, true, deviceName);
this._setLightState(
uniqueOutputId,
{ ...command, brightness: newBrightness },
true,
deviceName,
);
} else {
nSteps++;
logger.verbose(
`Queueing dim transition for ${deviceName} (${uniqueOutputId}) to ${newBrightness}. Total queue length ${this.writeQueue.length}`,
);
this._setBrightness(uniqueOutputId, newBrightness, false, deviceName);
this._setLightState(
uniqueOutputId,
{ ...command, brightness: newBrightness },
false,
deviceName,
);
}
}, transitionInterval);
} else {
if (transition && isDimmable) {
if (command.transition && isDimmable) {
logger.debug(
`Could not transition light change. Either initial value is unknown or change is too small. Requested from ${initialBrightness} to ${targetBrightness}`,
`Could not transition light change. Either initial value is unknown or change is too small. Requested from ${initialBrightness} to ${command.brightness}`,
);
}
this._setBrightness(uniqueOutputId, targetBrightness, true, deviceName);
this._setLightState(uniqueOutputId, command, true, deviceName);
}
}
_setBrightness(unqiueOutputId, brightness, shouldRetry, deviceName) {
if (!brightness && brightness !== 0) {
/**
* @param {string} uniqueOutputId
* @param {{ brightness: number, color_temp: number? } } command
* @param { boolean } shouldRetry
* @param { string } deviceName
*/
_setLightState(uniqueOutputId, command, shouldRetry, deviceName) {
const lightCommand = {};
if (!command.brightness && command.brightness !== 0) {
logger.debug(
`Queueing turn on ${deviceName} (${unqiueOutputId}). No brightness specified, setting DIM to previous.`,
`Queueing turn on ${deviceName} (${uniqueOutputId}). No brightness specified, setting DIM to previous.`,
);
this._appendCommandToWriteQueue(unqiueOutputId, COMMANDS.TURN_ON, null, shouldRetry);
} else if (brightness <= 0) {
logger.debug(`Queueing turn off ${unqiueOutputId}`);
this._appendCommandToWriteQueue(unqiueOutputId, COMMANDS.TURN_OFF, null, shouldRetry);
lightCommand.command = COMMANDS.TURN_ON;
} else if (command.brightness <= 0) {
logger.debug(`Queueing turn off ${uniqueOutputId}`);
lightCommand.command = COMMANDS.TURN_OFF;
} else {
if (brightness > 255) {
if (command.brightness > 255) {
// eslint-disable-next-line no-param-reassign
brightness = 255;
command.brightness = 255;
}
logger.debug(`Queueing ${unqiueOutputId} set brightness to ${brightness}`);
// eslint-disable-next-line no-bitwise
this._appendCommandToWriteQueue(unqiueOutputId, COMMANDS.DIM, brightness, shouldRetry);
logger.debug(`Queueing ${uniqueOutputId} set brightness to ${command.brightness}`);
lightCommand.command = COMMANDS.DIM;
lightCommand.brightness = command.brightness;
}
if (command.color_temp) {
lightCommand.command = COMMANDS.COLOR;
lightCommand.color_temp = command.color_temp;
}
this._appendCommandToWriteQueue(
uniqueOutputId,
// @ts-ignore
lightCommand,
shouldRetry,
);
}
_appendCommandToWriteQueue(uniqueOutputId, command, data, shouldRetry) {
/**
* @param {string} uniqueOutputId
* @param {{ command: keyof typeof COMMANDS, brightness: number?, color_temp: number? } } command
* @param { boolean } shouldRetry
*/
_appendCommandToWriteQueue(uniqueOutputId, command, shouldRetry) {
this.writeQueue.unshift({
uniqueOutputId,
command,
data,
shouldRetry,
});
}
@ -260,9 +309,9 @@ class PlejdDeviceCommunication extends EventEmitter {
const device = this.deviceRegistry.getOutputDevice(queueItem.uniqueOutputId);
logger.debug(
`Write queue: Processing ${device.name} (${queueItem.uniqueOutputId}). Command ${
queueItem.command
}${queueItem.data ? ` ${queueItem.data}` : ''}. Total queue length: ${
`Write queue: Processing ${device.name} (${
queueItem.uniqueOutputId
}). Command ${JSON.stringify(queueItem.command)}. Total queue length: ${
this.writeQueue.length
}`,
);
@ -278,9 +327,10 @@ class PlejdDeviceCommunication extends EventEmitter {
/* eslint-disable no-await-in-loop */
try {
await this.plejdBleHandler.sendCommand(
queueItem.command,
queueItem.command.command,
device.bleOutputAddress,
queueItem.data,
queueItem.command.brightness,
queueItem.command.color_temp,
);
} catch (err) {
if (queueItem.shouldRetry) {

View file

@ -1,3 +1,5 @@
const { SCENE_STATES } = require('./constants');
class SceneStep {
/**
* @param {import("./types/ApiSite").SceneStep} step
@ -6,7 +8,7 @@ class SceneStep {
this.sceneId = step.sceneId;
this.deviceId = step.deviceId;
this.output = step.output;
this.state = step.state === 'On' ? 1 : 0;
this.state = step.state === SCENE_STATES.ON ? 1 : 0;
this.brightness = step.value;
}
}

View file

@ -1,6 +1,6 @@
{
"name": "Plejd",
"version": "0.16.0",
"version": "0.17.0",
"slug": "plejd",
"description": "Adds support for the Swedish home automation devices from Plejd.",
"url": "https://github.com/icanos/hassio-plejd/",

View file

@ -1,9 +1,125 @@
/** @type {import('./types/Mqtt').MQTT_TYPES} */
const MQTT_TYPES = {
LIGHT: 'light',
SCENE: 'scene',
SWITCH: 'switch',
DEVICE_AUTOMATION: 'device_automation',
SENSOR: 'sensor',
EXTENDER: 'extender',
};
/** @type {import('./types/Mqtt').TOPIC_TYPES} */
const TOPIC_TYPES = {
CONFIG: 'config',
STATE: 'state',
AVAILABILITY: 'availability',
SET: 'set',
};
const MQTT_TOPICS = {
STATUS: 'homeassistant/status',
};
const MQTT_STATE = {
ON: 'ON',
OFF: 'OFF',
};
const DEVICE_TYPES = {
SCENE: 'scene',
LIGHT: 'light',
SWITCH: 'switch',
SENSOR: 'sensor',
EXTENDER: 'extender',
};
const OUTPUT_TYPES = {
LIGHT: 'LIGHT',
};
const SCENE_STATES = {
ON: 'On',
OFF: 'Off',
};
const AVAILABILITY = {
ONLINE: 'online',
OFFLINE: 'offline',
};
const AUTOMATION_TYPES = {
TRIGGER: 'trigger',
BUTTON_SHORT_PRESS: 'button_short_press',
};
const BLUEZ = {
SERVICE_NAME: 'org.bluez',
ADAPTER_ID: 'org.bluez.Adapter1',
DEVICE_ID: 'org.bluez.Device1',
GATT_SERVICE_ID: 'org.bluez.GattService1',
GATT_CHAR_ID: 'org.bluez.GattCharacteristic1',
};
const DBUS = {
OM_INTERFACE: 'org.freedesktop.DBus.ObjectManager',
PROP_INTERFACE: 'org.freedesktop.DBus.Properties',
};
const API = {
APP_ID: 'zHtVqXt8k4yFyk2QGmgp48D9xZr2G94xWYnF4dak',
BASE_URL: 'https://cloud.plejd.com/parse/',
LOGIN_URL: 'login',
SITE_LIST_URL: 'functions/getSiteList',
SITE_DETAILS_URL: 'functions/getSiteById',
};
// BLE Protocol Constants
const BLE = {
UUID_SUFFIX: '6085-4726-be45-040c957391b5',
COMMANDS: {
REMOTE_CLICK: 0x0016,
TIME_UPDATE: 0x001b,
SCENE_TRIGGER: 0x0021,
STATE_CHANGE: 0x0097,
DIM_CHANGE: 0x00c8,
COLOR_CHANGE: 0x0420,
},
BROADCAST_DEVICE_ID: 0x01,
};
// Generate UUIDs
const PLEJD_UUIDS = {
PLEJD_SERVICE: `31ba0001-${BLE.UUID_SUFFIX}`,
LIGHTLEVEL_UUID: `31ba0003-${BLE.UUID_SUFFIX}`,
DATA_UUID: `31ba0004-${BLE.UUID_SUFFIX}`,
LAST_DATA_UUID: `31ba0005-${BLE.UUID_SUFFIX}`,
AUTH_UUID: `31ba0009-${BLE.UUID_SUFFIX}`,
PING_UUID: `31ba000a-${BLE.UUID_SUFFIX}`,
};
const COMMANDS = {
TURN_ON: 'Turn on',
TURN_OFF: 'Turn off',
DIM: 'Dim',
COLOR: 'Color',
TRIGGER_SCENE: 'Trigger scene',
BUTTON_CLICK: 'Button click',
};
module.exports = { COMMANDS };
module.exports = {
MQTT_TYPES,
TOPIC_TYPES,
MQTT_STATE,
MQTT_TOPICS,
DEVICE_TYPES,
OUTPUT_TYPES,
SCENE_STATES,
AVAILABILITY,
AUTOMATION_TYPES,
BLUEZ,
DBUS,
API,
BLE,
PLEJD_UUIDS,
COMMANDS,
};

View file

@ -6,7 +6,7 @@ export type OutputDevices = { [deviceIdAndOutput: string]: OutputDevice };
export interface OutputDevice {
bleOutputAddress: number;
colorTemp: boolean;
colorTemp: number?;
colorTempSettings?: OutputSettingColorTemperature
deviceId: string;
dim?: number;

View file

@ -3,5 +3,5 @@
export type TopicType = 'config' | 'state' | 'availability' | 'set';
export type TOPIC_TYPES = { [key: string]: TopicType };
export type MqttType = 'light' | 'scene' | 'switch' | 'device_automation';
export type MqttType = 'light' | 'scene' | 'switch' | 'device_automation' | 'sensor' | 'extender';
export type MQTT_TYPES = { [key: string]: MqttType };

118
plejd/types/constants.d.ts vendored Normal file
View file

@ -0,0 +1,118 @@
import { MqttType, TopicType } from './Mqtt';
export interface MqttTypes {
LIGHT: MqttType;
SCENE: MqttType;
SWITCH: MqttType;
DEVICE_AUTOMATION: MqttType;
SENSOR: MqttType;
EXTENDER: MqttType;
}
export interface TopicTypes {
CONFIG: TopicType;
STATE: TopicType;
AVAILABILITY: TopicType;
SET: TopicType;
}
export interface MqttState {
ON: 'ON';
OFF: 'OFF';
}
export interface DeviceTypes {
SCENE: 'Scene';
LIGHT: 'light';
SWITCH: 'switch';
SENSOR: 'sensor';
EXTENDER: 'extender';
}
export interface OutputTypes {
LIGHT: 'LIGHT';
}
export interface SceneStates {
ON: 'On';
OFF: 'Off';
}
export interface Availability {
ONLINE: 'online';
OFFLINE: 'offline';
}
export interface AutomationTypes {
TRIGGER: 'trigger';
BUTTON_SHORT_PRESS: 'button_short_press';
}
export interface BluezIds {
SERVICE_NAME: 'org.bluez';
ADAPTER_ID: 'org.bluez.Adapter1';
DEVICE_ID: 'org.bluez.Device1';
GATT_SERVICE_ID: 'org.bluez.GattService1';
GATT_CHAR_ID: 'org.bluez.GattCharacteristic1';
}
export interface DbusInterface {
OM_INTERFACE: 'org.freedesktop.DBus.ObjectManager';
PROP_INTERFACE: 'org.freedesktop.DBus.Properties';
}
export interface ApiEndpoints {
APP_ID: string;
BASE_URL: string;
LOGIN_URL: string;
SITE_LIST_URL: string;
SITE_DETAILS_URL: string;
}
export interface BleCommands {
REMOTE_CLICK: number;
TIME_UPDATE: number;
SCENE_TRIGGER: number;
STATE_CHANGE: number;
DIM_CHANGE: number;
COLOR_CHANGE: number;
}
export interface Ble {
UUID_SUFFIX: string;
COMMANDS: BleCommands;
BROADCAST_DEVICE_ID: number;
}
export interface PlejdUuids {
PLEJD_SERVICE: string;
LIGHTLEVEL_UUID: string;
DATA_UUID: string;
LAST_DATA_UUID: string;
AUTH_UUID: string;
PING_UUID: string;
}
export interface Commands {
TURN_ON: string;
TURN_OFF: string;
DIM: string;
COLOR: string;
TRIGGER_SCENE: string;
BUTTON_CLICK: string;
}
export const MQTT_TYPES: MqttTypes;
export const TOPIC_TYPES: TopicTypes;
export const MQTT_STATE: MqttState;
export const DEVICE_TYPES: DeviceTypes;
export const AVAILABILITY: Availability;
export const AUTOMATION_TYPES: AutomationTypes;
export const BLE: Ble;
export const PLEJD_UUIDS: PlejdUuids;
export const COMMANDS: Commands;
export const OUTPUT_TYPES: OutputTypes;
export const SCENE_STATES: SceneStates;
export const BLUEZ: BluezIds;
export const DBUS: DbusInterface;
export const API: ApiEndpoints;

121
plejd/types/constants.js Normal file
View file

@ -0,0 +1,121 @@
/** @type {import('./Mqtt').MQTT_TYPES} */
const MQTT_TYPES = {
LIGHT: 'light',
SCENE: 'scene',
SWITCH: 'switch',
DEVICE_AUTOMATION: 'device_automation',
SENSOR: 'sensor',
EXTENDER: 'extender',
};
/** @type {import('./Mqtt').TOPIC_TYPES} */
const TOPIC_TYPES = {
CONFIG: 'config',
STATE: 'state',
AVAILABILITY: 'availability',
SET: 'set',
};
const MQTT_STATE = {
ON: 'ON',
OFF: 'OFF',
};
const DEVICE_TYPES = {
SCENE: 'scene',
LIGHT: 'light',
SWITCH: 'switch',
SENSOR: 'sensor',
EXTENDER: 'extender',
};
const OUTPUT_TYPES = {
LIGHT: 'LIGHT',
};
const SCENE_STATES = {
ON: 'On',
OFF: 'Off',
};
const AVAILABILITY = {
ONLINE: 'online',
OFFLINE: 'offline',
};
const AUTOMATION_TYPES = {
TRIGGER: 'trigger',
BUTTON_SHORT_PRESS: 'button_short_press',
};
const BLUEZ = {
SERVICE_NAME: 'org.bluez',
ADAPTER_ID: 'org.bluez.Adapter1',
DEVICE_ID: 'org.bluez.Device1',
GATT_SERVICE_ID: 'org.bluez.GattService1',
GATT_CHAR_ID: 'org.bluez.GattCharacteristic1',
};
const DBUS = {
OM_INTERFACE: 'org.freedesktop.DBus.ObjectManager',
PROP_INTERFACE: 'org.freedesktop.DBus.Properties',
};
const API = {
APP_ID: 'zHtVqXt8k4yFyk2QGmgp48D9xZr2G94xWYnF4dak',
BASE_URL: 'https://cloud.plejd.com/parse/',
LOGIN_URL: 'login',
SITE_LIST_URL: 'functions/getSiteList',
SITE_DETAILS_URL: 'functions/getSiteById',
};
// BLE Protocol Constants
const BLE = {
UUID_SUFFIX: '6085-4726-be45-040c957391b5',
COMMANDS: {
REMOTE_CLICK: 0x0016,
TIME_UPDATE: 0x001b,
SCENE_TRIGGER: 0x0021,
STATE_CHANGE: 0x0097,
DIM_CHANGE: 0x00c8,
COLOR_CHANGE: 0x0420,
},
BROADCAST_DEVICE_ID: 0x01,
};
// Generate UUIDs
const PLEJD_UUIDS = {
PLEJD_SERVICE: `31ba0001-${BLE.UUID_SUFFIX}`,
LIGHTLEVEL_UUID: `31ba0003-${BLE.UUID_SUFFIX}`,
DATA_UUID: `31ba0004-${BLE.UUID_SUFFIX}`,
LAST_DATA_UUID: `31ba0005-${BLE.UUID_SUFFIX}`,
AUTH_UUID: `31ba0009-${BLE.UUID_SUFFIX}`,
PING_UUID: `31ba000a-${BLE.UUID_SUFFIX}`,
};
// Commands from original constants.js
const COMMANDS = {
TURN_ON: 'Turn on',
TURN_OFF: 'Turn off',
DIM: 'Dim',
COLOR: 'Color',
TRIGGER_SCENE: 'Trigger scene',
BUTTON_CLICK: 'Button click',
};
module.exports = {
MQTT_TYPES,
TOPIC_TYPES,
MQTT_STATE,
DEVICE_TYPES,
AVAILABILITY,
AUTOMATION_TYPES,
BLE,
PLEJD_UUIDS,
COMMANDS,
OUTPUT_TYPES,
SCENE_STATES,
BLUEZ,
DBUS,
API,
};