Merge pull request #127 from SweVictor/feature/improved-transitions

Improve transitioning of brightness
This commit is contained in:
Marcus Westin 2021-01-11 14:21:30 +01:00 committed by GitHub
commit b02e33369e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 120 additions and 76 deletions

View file

@ -103,6 +103,19 @@ includeRoomsAsLights | Adds all rooms as lights, making it possible to turn on/o
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. 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. 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.
## Transitions
Transitions from Home Assistant are supported (for dimmable devices) when transition is longer than 1 second. Plejd will do a bit of internal transitioning (default soft start is 0.1 seconds).
This implementation will transition each device independently, meaning that brightness change might be choppy if transitioning many devices at once or a changing brightness a lot in a limited time. Hassio-plejd's communication channel seems to handle a few updates per second, this is the combined value for all devices.
Transition points will be skipped if the queue of messages to be sent is over a certain threshold, by default equal to the number of devices in the system. Total transition time is prioritized rather than smoothness.
Recommendations
* Only transition a few devices at a time when possible
* Expect 5-10 brightness changes per second, meaning 5 devices => 1-2 updates per device per second
* ... meaning that SLOW transitions will work well (wake-up light, gradually fade over a minute, ...), but quick ones will only work well for few devices or small relative changes in brightness
* When experiencing choppy quick transitions, turn transitioning off and let the Plejd hardware do the work instead
## I want voice control! ## I want voice control!
With the Google Home integration in Home Assistant, you can get voice control for your Plejd lights right away, check this out for more information: With the Google Home integration in Home Assistant, you can get voice control for your Plejd lights right away, check this out for more information:
https://www.home-assistant.io/integrations/google_assistant/ https://www.home-assistant.io/integrations/google_assistant/

View file

@ -7,7 +7,7 @@ const EventEmitter = require('events');
let debug = ''; let debug = '';
const getLogger = () => { const getLogger = () => {
const consoleLogger = msg => console.log('plejd-ble', msg); const consoleLogger = (...msg) => console.log('plejd-ble', ...msg);
if (debug === 'console') { if (debug === 'console') {
return consoleLogger; return consoleLogger;
} }
@ -39,6 +39,9 @@ const BLUEZ_DEVICE_ID = 'org.bluez.Device1';
const GATT_SERVICE_ID = 'org.bluez.GattService1'; const GATT_SERVICE_ID = 'org.bluez.GattService1';
const GATT_CHRC_ID = 'org.bluez.GattCharacteristic1'; const GATT_CHRC_ID = 'org.bluez.GattCharacteristic1';
const MAX_TRANSITION_STEPS_PER_SECOND = 5; // Could be made a setting
const MAX_WRITEQUEUE_LENGTH_TARGET = 0; // Could be made a setting. 0 => queue length = numDevices => 1 command pending per device max
class PlejdService extends EventEmitter { class PlejdService extends EventEmitter {
constructor(cryptoKey, devices, sceneManager, connectionTimeout, writeQueueWaitTime, keepAlive = false) { constructor(cryptoKey, devices, sceneManager, connectionTimeout, writeQueueWaitTime, keepAlive = false) {
super(); super();
@ -49,6 +52,7 @@ class PlejdService extends EventEmitter {
this.connectedDevice = null; this.connectedDevice = null;
this.plejdService = null; this.plejdService = null;
this.bleDevices = []; this.bleDevices = [];
this.bleDeviceTransitionTimers = {};
this.plejdDevices = {}; this.plejdDevices = {};
this.devices = devices; this.devices = devices;
this.connectEventHooked = false; this.connectEventHooked = false;
@ -57,6 +61,9 @@ class PlejdService extends EventEmitter {
this.writeQueue = []; this.writeQueue = [];
this.writeQueueRef = null; this.writeQueueRef = null;
this.maxQueueLengthTarget = MAX_WRITEQUEUE_LENGTH_TARGET || this.devices.length || 5;
logger('Max global transition queue length target', this.maxQueueLengthTarget)
// Holds a reference to all characteristics // Holds a reference to all characteristics
this.characteristics = { this.characteristics = {
data: null, data: null,
@ -79,7 +86,7 @@ class PlejdService extends EventEmitter {
} }
this.connectedDevice = null; this.connectedDevice = null;
this.bleDevices = [];
this.characteristics = { this.characteristics = {
data: null, data: null,
lastData: null, lastData: null,
@ -146,10 +153,10 @@ class PlejdService extends EventEmitter {
} }
async _internalInit() { async _internalInit() {
logger('got ' + this.bleDevices.length + ' device(s).'); logger('got ', this.bleDevices.length, ' device(s).');
for (const plejd of this.bleDevices) { for (const plejd of this.bleDevices) {
logger('inspecting ' + plejd['path']); logger('inspecting ', plejd['path']);
try { try {
const proxyObject = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, plejd['path']); const proxyObject = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, plejd['path']);
@ -164,7 +171,7 @@ class PlejdService extends EventEmitter {
fixedPlejdPath = fixedPlejdPath.replace(/_/g, ''); fixedPlejdPath = fixedPlejdPath.replace(/_/g, '');
plejd['device'] = this.devices.find(x => x.serialNumber === fixedPlejdPath); plejd['device'] = this.devices.find(x => x.serialNumber === fixedPlejdPath);
logger('discovered ' + plejd['path'] + ' with rssi ' + plejd['rssi']); logger('discovered ', plejd['path'] + ' with rssi ' + plejd['rssi']);
} catch (err) { } catch (err) {
console.log('plejd-ble: failed inspecting ' + plejd['path'] + ' error: ' + err); console.log('plejd-ble: failed inspecting ' + plejd['path'] + ' error: ' + err);
} }
@ -198,7 +205,7 @@ class PlejdService extends EventEmitter {
for (let path of managedPaths) { for (let path of managedPaths) {
const pathInterfaces = Object.keys(managedObjects[path]); const pathInterfaces = Object.keys(managedObjects[path]);
if (pathInterfaces.indexOf(iface) > -1) { if (pathInterfaces.indexOf(iface) > -1) {
logger('found ble interface \'' + iface + '\' at ' + path); logger('found ble interface \'', iface, '\' at ', path);
try { try {
const adapterObject = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, path); const adapterObject = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, path);
return [path, adapterObject.getInterface(iface), adapterObject]; return [path, adapterObject.getInterface(iface), adapterObject];
@ -212,12 +219,12 @@ class PlejdService extends EventEmitter {
} }
async onInterfacesAdded(path, interfaces) { async onInterfacesAdded(path, interfaces) {
const [adapter, dev, service, characteristic] = path.split('/').slice(3); // const [adapter, dev, service, characteristic] = path.split('/').slice(3);
const interfaceKeys = Object.keys(interfaces); const interfaceKeys = Object.keys(interfaces);
if (interfaceKeys.indexOf(BLUEZ_DEVICE_ID) > -1) { if (interfaceKeys.indexOf(BLUEZ_DEVICE_ID) > -1) {
if (interfaces[BLUEZ_DEVICE_ID]['UUIDs'].value.indexOf(PLEJD_SERVICE) > -1) { if (interfaces[BLUEZ_DEVICE_ID]['UUIDs'].value.indexOf(PLEJD_SERVICE) > -1) {
logger('found Plejd service on ' + path); logger('found Plejd service on ', path);
this.bleDevices.push({ this.bleDevices.push({
'path': path 'path': path
}); });
@ -236,89 +243,113 @@ class PlejdService extends EventEmitter {
} }
turnOn(id, command) { turnOn(id, command) {
logger('turning on ' + id + ' at brightness ' + (!command.brightness ? 255 : command.brightness)); console.log('Plejd got turn on command for ', id, ', brightness ', command.brightness, ', transition ', command.transition);
const brightness = command.brightness ? command.brightness : 0; this._transitionTo(id, command.brightness, command.transition);
if (command.transition) {
// we have a transition time, split the target brightness
// into pieces spread of the transition time
const steps = command.transition * 2;
const brightnessStep = brightness / steps;
let i = 0;
const transitionRef = setInterval(() => {
let currentBrightness = parseInt((brightnessStep * i) + 1);
if (currentBrightness > 254) {
currentBrightness = 254;
}
this._turnOn(id, currentBrightness);
if (i >= steps) {
clearInterval(transitionRef);
}
i++;
}, 400);
} else {
this._turnOn(id, brightness);
}
}
_turnOn(id, brightness) {
var payload;
if (!brightness || brightness === 0) {
logger('no brightness specified, setting to previous known.');
payload = Buffer.from((id).toString(16).padStart(2, '0') + '0110009701', 'hex');
} else {
logger('brightness is ' + brightness);
brightness = brightness << 8 | brightness;
payload = Buffer.from((id).toString(16).padStart(2, '0') + '0110009801' + (brightness).toString(16).padStart(4, '0'), 'hex');
}
this.writeQueue.unshift(payload);
} }
turnOff(id, command) { turnOff(id, command) {
logger('turning off ' + id); console.log('Plejd got turn off command for ', id, ', transition ', command.transition);
this._transitionTo(id, 0, command.transition);
}
if (command.transition) {
// we have a transition time, split the target brightness (which will be 0)
// into pieces spread of the transition time
const initialBrightness = this.plejdDevices[id] ? this.plejdDevices[id].dim : 250;
console.log('initial brightness for ' + id + ' is ' + initialBrightness);
const steps = command.transition * 2; _clearDeviceTransitionTimer(id) {
const brightnessStep = initialBrightness / steps; if (this.bleDeviceTransitionTimers[id]) {
let currentBrightness = initialBrightness; clearInterval(this.bleDeviceTransitionTimers[id]);
}
}
let i = 0; _transitionTo(id, targetBrightness, transition) {
const transitionRef = setInterval(() => { const initialBrightness = this.plejdDevices[id] ? this.plejdDevices[id].dim : null;
currentBrightness = parseInt(initialBrightness - (brightnessStep * i)); this._clearDeviceTransitionTimer(id);
if (currentBrightness <= 0 || i >= steps) {
clearInterval(transitionRef);
// finally, we turn it off const isDimmable = this.devices.find(d => d.id === id).dimmable;
this._turnOff(id);
return; 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('transitioning from', initialBrightness, 'to', targetBrightness, 'in', transition, 'seconds.');
logger('delta brightness', deltaBrightness, ', steps ', transitionSteps, ', interval', transitionInterval, 'ms');
const dtStart = new Date();
let nSteps = 0;
let nSkippedSteps = 0;
this.bleDeviceTransitionTimers[id] = setInterval(() => {
let tElapsedMs = (new Date().getTime() - dtStart.getTime());
let tElapsed = tElapsedMs / 1000;
if (tElapsed > transition || tElapsed < 0) {
tElapsed = transition;
} }
this._turnOn(id, currentBrightness); let newBrightness = parseInt(initialBrightness + deltaBrightness * tElapsed / transition);
i++; if (tElapsed === transition) {
}, 500); nSteps++;
} else { this._clearDeviceTransitionTimer(id);
this._turnOff(id); newBrightness = targetBrightness;
logger('Completing transition from', initialBrightness, 'to', targetBrightness, 'in ', tElapsedMs, 'ms. Done steps', nSteps, ', skipped ' + nSkippedSteps + '. Average interval', tElapsedMs/(nSteps||1), 'ms.');
this._setBrightness(id, newBrightness);
}
else if (this.writeQueue.length <= this.maxQueueLengthTarget) {
nSteps++;
this._setBrightness(id, newBrightness);
}
else {
nSkippedSteps++;
logger('Skipping transition step due to write queue full as configured. Queue length', this.writeQueue.length, ', max', this.maxQueueLengthTarget);
}
}, transitionInterval);
}
else {
if (transition && isDimmable) {
logger('Could not transition light change. Either initial value is unknown or change is too small. Requested from', initialBrightness, 'to', targetBrightness)
}
this._setBrightness(id, targetBrightness);
}
}
_setBrightness(id, brightness) {
if (!brightness && brightness !== 0) {
logger('no brightness specified, setting ', id, ' to previous known.');
var payload = Buffer.from((id).toString(16).padStart(2, '0') + '0110009701', 'hex');
this.writeQueue.unshift(payload);
}
else {
if (brightness <= 0) {
this._turnOff(id);
}
else {
if (brightness > 255) {
brightness = 255;
}
logger('Setting ', id, 'brightness to ' + brightness);
brightness = brightness << 8 | brightness;
var payload = Buffer.from((id).toString(16).padStart(2, '0') + '0110009801' + (brightness).toString(16).padStart(4, '0'), 'hex');
}
this.writeQueue.unshift(payload);
} }
} }
_turnOff(id) { _turnOff(id) {
logger('Turning off ', id);
var payload = Buffer.from((id).toString(16).padStart(2, '0') + '0110009700', 'hex'); var payload = Buffer.from((id).toString(16).padStart(2, '0') + '0110009700', 'hex');
this.writeQueue.unshift(payload); this.writeQueue.unshift(payload);
} }
triggerScene(sceneIndex) { triggerScene(sceneIndex) {
console.log('triggering scene with ID ' + sceneIndex); console.log('triggering scene with ID', sceneIndex);
this.sceneManager.executeScene(sceneIndex, this); this.sceneManager.executeScene(sceneIndex, this);
} }
@ -354,7 +385,7 @@ class PlejdService extends EventEmitter {
} }
try { try {
console.log('plejd-ble: sending ' + data.length + ' byte(s) of data to Plejd'); logger('sending ', data.length, ' byte(s) of data to Plejd', data);
const encryptedData = this._encryptDecrypt(this.cryptoKey, this.plejdService.addr, data); const encryptedData = this._encryptDecrypt(this.cryptoKey, this.plejdService.addr, data);
await this.characteristics.data.WriteValue([...encryptedData], {}); await this.characteristics.data.WriteValue([...encryptedData], {});
} catch (err) { } catch (err) {
@ -567,7 +598,7 @@ class PlejdService extends EventEmitter {
const cmd = decoded.toString('hex', 3, 5); const cmd = decoded.toString('hex', 3, 5);
if (debug) { if (debug) {
logger('raw event received: ' + decoded.toString('hex')); logger('raw event received: ', decoded.toString('hex'));
} }
if (cmd === BLE_CMD_DIM_CHANGE || cmd === BLE_CMD_DIM2_CHANGE) { if (cmd === BLE_CMD_DIM_CHANGE || cmd === BLE_CMD_DIM2_CHANGE) {
@ -625,14 +656,14 @@ class PlejdService extends EventEmitter {
_encryptDecrypt(key, addr, data) { _encryptDecrypt(key, addr, data) {
var buf = Buffer.concat([addr, addr, addr.subarray(0, 4)]); var buf = Buffer.concat([addr, addr, addr.subarray(0, 4)]);
var cipher = crypto.createCipheriv("aes-128-ecb", key, ''); var cipher = crypto.createCipheriv('aes-128-ecb', key, '');
cipher.setAutoPadding(false); cipher.setAutoPadding(false);
var ct = cipher.update(buf).toString('hex'); var ct = cipher.update(buf).toString('hex');
ct += cipher.final().toString('hex'); ct += cipher.final().toString('hex');
ct = Buffer.from(ct, 'hex'); ct = Buffer.from(ct, 'hex');
var output = ""; var output = '';
for (var i = 0, length = data.length; i < length; i++) { for (var i = 0, length = data.length; i < length; i++) {
output += String.fromCharCode(data[i] ^ ct[i % 16]); output += String.fromCharCode(data[i] ^ ct[i % 16]);
} }