hassio-plejd/plejd/PlejdDeviceCommunication.js

364 lines
13 KiB
JavaScript
Raw Permalink Normal View History

const { EventEmitter } = require('events');
const Configuration = require('./Configuration');
const { COMMANDS } = require('./constants');
const Logger = require('./Logger');
const PlejBLEHandler = require('./PlejdBLEHandler');
const logger = Logger.getLogger('device-comm');
const MAX_TRANSITION_STEPS_PER_SECOND = 5; // Could be made a setting
const MAX_RETRY_COUNT = 10; // Could be made a setting
class PlejdDeviceCommunication extends EventEmitter {
bleConnected;
bleOutputTransitionTimers = {};
plejdBleHandler;
config;
/** @type {import('./DeviceRegistry')} */
deviceRegistry;
// eslint-disable-next-line max-len
/** @type {{uniqueOutputId: string, command: {command: keyof typeof COMMANDS, brightness: number?, color_temp: number? }, shouldRetry: boolean, retryCount?: number}[]} */
writeQueue = [];
writeQueueRef = null;
static EVENTS = {
sceneTriggered: 'sceneTriggered',
stateChanged: 'stateChanged',
};
constructor(deviceRegistry) {
super();
logger.info('Starting Plejd communication handler.');
this.plejdBleHandler = new PlejBLEHandler(deviceRegistry);
this.config = Configuration.getOptions();
this.deviceRegistry = deviceRegistry;
2021-02-20 15:33:06 +01:00
}
2021-02-20 15:33:06 +01:00
cleanup() {
Object.values(this.bleOutputTransitionTimers).forEach((t) => clearTimeout(t));
2021-02-20 15:33:06 +01:00
this.plejdBleHandler.cleanup();
this.plejdBleHandler.removeAllListeners(PlejBLEHandler.EVENTS.commandReceived);
this.plejdBleHandler.removeAllListeners(PlejBLEHandler.EVENTS.connected);
this.plejdBleHandler.removeAllListeners(PlejBLEHandler.EVENTS.reconnecting);
}
async init() {
try {
this.cleanup();
this.bleConnected = false;
2021-02-20 15:33:06 +01:00
// eslint-disable-next-line max-len
this.plejdBleHandler.on(
PlejBLEHandler.EVENTS.commandReceived,
(uniqueOutputId, command, data) => this._bleCommandReceived(uniqueOutputId, command, data),
);
2021-02-20 15:33:06 +01:00
this.plejdBleHandler.on(PlejBLEHandler.EVENTS.connected, () => {
logger.info('Bluetooth connected. Plejd BLE up and running!');
logger.verbose(`Starting writeQueue loop. Write queue length: ${this.writeQueue.length}`);
this.bleConnected = true;
this._startWriteQueue();
2021-02-20 15:33:06 +01:00
});
this.plejdBleHandler.on(PlejBLEHandler.EVENTS.reconnecting, () => {
logger.info('Bluetooth reconnecting...');
logger.verbose(
`Stopping writeQueue loop until connection is established. Write queue length: ${this.writeQueue.length}`,
);
this.bleConnected = false;
2021-02-20 15:33:06 +01:00
clearTimeout(this.writeQueueRef);
});
await this.plejdBleHandler.init();
} catch (err) {
logger.error('Failed init() of BLE. Starting reconnect loop.');
await this.plejdBleHandler.startReconnectPeriodicallyLoop();
}
}
turnOn(uniqueOutputId, command) {
const deviceName = this.deviceRegistry.getOutputDeviceName(uniqueOutputId);
logger.info(
`Plejd got turn on command for ${deviceName} (${uniqueOutputId})${JSON.stringify(command)}`,
);
this._transitionTo(uniqueOutputId, command, deviceName);
}
turnOff(uniqueOutputId, command) {
const deviceName = this.deviceRegistry.getOutputDeviceName(uniqueOutputId);
logger.info(
`Plejd got turn off command for ${deviceName} (${uniqueOutputId})${
command.transition ? `, transition: ${command.transition}` : ''
}`,
);
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, {
2021-02-20 10:49:00 +01:00
state: 1,
});
} else if (command === COMMANDS.TURN_OFF) {
this.deviceRegistry.setOutputState(uniqueOutputId, false);
this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, uniqueOutputId, {
2021-02-20 10:49:00 +01:00
state: 0,
});
} else if (command === COMMANDS.TRIGGER_SCENE) {
this.emit(PlejdDeviceCommunication.EVENTS.sceneTriggered, data.sceneId);
} else if (command === COMMANDS.BUTTON_CLICK) {
this.emit(PlejdDeviceCommunication.EVENTS.buttonPressed, data.deviceId, data.deviceInput);
} else {
logger.warn(`Unknown ble command ${command}`);
}
} catch (error) {
logger.error('Error processing ble command', error);
}
}
_clearDeviceTransitionTimer(uniqueOutputId) {
if (this.bleOutputTransitionTimers[uniqueOutputId]) {
clearInterval(this.bleOutputTransitionTimers[uniqueOutputId]);
}
}
/**
* @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);
2021-02-20 15:33:06 +01:00
const initialBrightness = device ? device.state && device.dim : null;
this._clearDeviceTransitionTimer(uniqueOutputId);
const isDimmable = this.deviceRegistry.getOutputDevice(uniqueOutputId).dimmable;
if (
command.transition > 1 &&
2023-08-16 15:32:53 +02:00
isDimmable &&
(initialBrightness || initialBrightness === 0) &&
(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
// 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 = command.brightness - initialBrightness;
const transitionSteps = Math.min(
Math.abs(deltaBrightness),
MAX_TRANSITION_STEPS_PER_SECOND * command.transition,
);
const transitionInterval = (command.transition * 1000) / transitionSteps;
logger.debug(
`transitioning from ${initialBrightness} to ${command.brightness} ${
command.transition ? `in ${command.transition} seconds` : ''
}.`,
);
logger.verbose(
`delta brightness ${deltaBrightness}, steps ${transitionSteps}, interval ${transitionInterval} ms`,
);
const dtStart = new Date();
let nSteps = 0;
this.bleOutputTransitionTimers[uniqueOutputId] = setInterval(() => {
const tElapsedMs = new Date().getTime() - dtStart.getTime();
let tElapsed = tElapsedMs / 1000;
if (tElapsed > command.transition || tElapsed < 0) {
tElapsed = command.transition;
}
let newBrightness = Math.round(
initialBrightness + (deltaBrightness * tElapsed) / command.transition,
);
if (tElapsed === command.transition) {
nSteps++;
this._clearDeviceTransitionTimer(uniqueOutputId);
newBrightness = command.brightness;
logger.debug(
`Queueing finalize ${deviceName} (${uniqueOutputId}) transition from ${initialBrightness} to ${
command.brightness
} in ${tElapsedMs}ms. Done steps ${nSteps}. Average interval ${
tElapsedMs / (nSteps || 1)
} ms.`,
);
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._setLightState(
uniqueOutputId,
{ ...command, brightness: newBrightness },
false,
deviceName,
);
}
}, transitionInterval);
} else {
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 ${command.brightness}`,
);
}
this._setLightState(uniqueOutputId, command, true, deviceName);
}
}
/**
* @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} (${uniqueOutputId}). No brightness specified, setting DIM to previous.`,
);
lightCommand.command = COMMANDS.TURN_ON;
} else if (command.brightness <= 0) {
logger.debug(`Queueing turn off ${uniqueOutputId}`);
lightCommand.command = COMMANDS.TURN_OFF;
} else {
if (command.brightness > 255) {
// eslint-disable-next-line no-param-reassign
command.brightness = 255;
}
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,
);
}
/**
* @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,
shouldRetry,
});
}
_startWriteQueue() {
logger.info('startWriteQueue()');
clearTimeout(this.writeQueueRef);
this.writeQueueRef = setTimeout(() => this._runWriteQueue(), this.config.writeQueueWaitTime);
}
async _runWriteQueue() {
try {
while (this.writeQueue.length > 0) {
if (!this.bleConnected) {
logger.warn('BLE not connected, stopping write queue until connection is up again.');
return;
}
const queueItem = this.writeQueue.pop();
2021-03-31 23:28:25 +02:00
const device = this.deviceRegistry.getOutputDevice(queueItem.uniqueOutputId);
logger.debug(
`Write queue: Processing ${device.name} (${
queueItem.uniqueOutputId
}). Command ${JSON.stringify(queueItem.command)}. Total queue length: ${
2021-02-20 10:49:00 +01:00
this.writeQueue.length
}`,
);
if (this.writeQueue.some((item) => item.uniqueOutputId === queueItem.uniqueOutputId)) {
logger.verbose(
2023-08-16 15:32:53 +02:00
`Skipping ${device.name} (${queueItem.uniqueOutputId}) ` +
`${queueItem.command} due to more recent command in queue.`,
);
// Skip commands if new ones exist for the same uniqueOutputId
// still process all messages in order
} else {
/* eslint-disable no-await-in-loop */
try {
await this.plejdBleHandler.sendCommand(
queueItem.command.command,
2021-03-31 23:28:25 +02:00
device.bleOutputAddress,
queueItem.command.brightness,
queueItem.command.color_temp,
);
} catch (err) {
if (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(
2021-03-31 23:28:25 +02:00
`Write queue: Exceeed max retry count (${MAX_RETRY_COUNT}) for ${device.name} (${queueItem.uniqueOutputId}). Command ${queueItem.command} failed.`,
);
break;
}
if (queueItem.retryCount > 1) {
break; // First retry directly, consecutive after writeQueueWaitTime ms
}
}
}
/* eslint-enable no-await-in-loop */
}
}
} catch (e) {
logger.error('Error in writeQueue loop, values probably not written to Plejd', e);
}
this.writeQueueRef = setTimeout(() => this._runWriteQueue(), this.config.writeQueueWaitTime);
}
}
module.exports = PlejdDeviceCommunication;