Improve MQTT behavior and add experimental parsing for color temp support from Plejd API (#298)

* Update underlying docker containers and dependencies.
* Minor linting and code issues fixes
* Update supported devices section
* Improve Mqtt message properties - retain, etc.
- Retain discovery messages
- Don't retain others
- Set QoS to 1 consistently to ensure at least once delivery
- Set session timeout to ensure a reasonable TTL on messages
* Code and logic to remove any retained mqtt messages for SET/STATE/AVAILABILITY
* Temporary restructure of init flow for mqtt.
- No longer wait for HA birth message
- Don't listen to incoming messages until old retained messages have been purged.
- More details in https://github.com/icanos/hassio-plejd/issues/218
* Fix lingering incorrect access of connectedDevice.id
* Fix to avoid Home Assistant setting retain flag on MQTT SET STATE messages
* Parse new TRAIT=15, assuming this means dimmable and tunable white
* Clarify TRAITS bitmask values
* Add experimental parsing for color temp support from Plejd API
* Lint fixes
* Remove null coalescing operator since we don't compile code
* Handle case where outputSettings is null in PlejdApi
* Solve MQTT errors due to deprecated api color_temp and unsupported removal of retained state messages
This commit is contained in:
Victor 2025-08-05 19:55:50 +02:00 committed by GitHub
parent a789d913d7
commit b3c6334f0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 255 additions and 147 deletions

View file

@ -17,7 +17,10 @@ module.exports = {
// 'prettier', // 'prettier',
// 'plugin:prettier/recommended' // 'plugin:prettier/recommended'
], ],
parser: 'babel-eslint', parser: '@babel/eslint-parser',
parserOptions: {
requireConfigFile: false,
},
// plugins: ['prettier'], // plugins: ['prettier'],
rules: getRules(), rules: getRules(),
}; };

View file

@ -1,4 +1,4 @@
ARG BUILD_FROM=hassioaddons/base:8.0.6 ARG BUILD_FROM=hassioaddons/base:14.2.2
FROM $BUILD_FROM FROM $BUILD_FROM
ENV LANG C.UTF-8 ENV LANG C.UTF-8

View file

@ -4,7 +4,7 @@ const mqtt = require('mqtt');
const Configuration = require('./Configuration'); const Configuration = require('./Configuration');
const Logger = require('./Logger'); const Logger = require('./Logger');
const startTopics = ['hass/status', 'homeassistant/status']; // const startTopics = ['hass/status', 'homeassistant/status'];
const logger = Logger.getLogger('plejd-mqtt'); const logger = Logger.getLogger('plejd-mqtt');
@ -24,7 +24,7 @@ const TOPIC_TYPES = {
CONFIG: 'config', CONFIG: 'config',
STATE: 'state', STATE: 'state',
AVAILABILITY: 'availability', AVAILABILITY: 'availability',
COMMAND: 'set', SET: 'set',
}; };
const getBaseTopic = (/** @type { string } */ uniqueId, /** @type { string } */ mqttDeviceType) => const getBaseTopic = (/** @type { string } */ uniqueId, /** @type { string } */ mqttDeviceType) =>
@ -43,9 +43,8 @@ const getSceneEventTopic = (/** @type {string} */ sceneId) =>
`${getTopicName(getTriggerUniqueId(sceneId), MQTT_TYPES.DEVICE_AUTOMATION, TOPIC_TYPES.STATE)}`; `${getTopicName(getTriggerUniqueId(sceneId), MQTT_TYPES.DEVICE_AUTOMATION, TOPIC_TYPES.STATE)}`;
const getSubscribePath = () => `${discoveryPrefix}/+/${nodeId}/#`; const getSubscribePath = () => `${discoveryPrefix}/+/${nodeId}/#`;
const decodeTopicRegexp = new RegExp( const decodeTopicRegexp =
/(?<prefix>[^[]+)\/(?<type>.+)\/plejd\/(?<id>.+)\/(?<command>config|state|availability|set|scene)/, /(?<prefix>[^[]+)\/(?<type>.+)\/plejd\/(?<id>.+)\/(?<command>config|state|availability|set|scene)/;
);
const decodeTopic = (topic) => { const decodeTopic = (topic) => {
const matches = decodeTopicRegexp.exec(topic); const matches = decodeTopicRegexp.exec(topic);
@ -62,11 +61,11 @@ const getOutputDeviceDiscoveryPayload = (
unique_id: device.uniqueId, unique_id: device.uniqueId,
'~': getBaseTopic(device.uniqueId, device.type), '~': getBaseTopic(device.uniqueId, device.type),
state_topic: `~/${TOPIC_TYPES.STATE}`, state_topic: `~/${TOPIC_TYPES.STATE}`,
command_topic: `~/${TOPIC_TYPES.COMMAND}`, command_topic: `~/${TOPIC_TYPES.SET}`,
availability_topic: `~/${TOPIC_TYPES.AVAILABILITY}`, availability_topic: `~/${TOPIC_TYPES.AVAILABILITY}`,
optimistic: false, optimistic: false,
qos: 1, qos: 1,
retain: true, retain: false, // State update messages from HA should not be retained
device: { device: {
identifiers: `${device.uniqueId}`, identifiers: `${device.uniqueId}`,
manufacturer: 'Plejd', manufacturer: 'Plejd',
@ -76,6 +75,15 @@ const getOutputDeviceDiscoveryPayload = (
sw_version: device.version, sw_version: device.version,
}, },
...(device.type === MQTT_TYPES.LIGHT ? { brightness: device.dimmable, schema: 'json' } : {}), ...(device.type === MQTT_TYPES.LIGHT ? { brightness: device.dimmable, schema: 'json' } : {}),
...(device.type === MQTT_TYPES.LIGHT &&
device.colorTempSettings &&
device.colorTempSettings.behavior === 'adjustable'
? {
min_mireds: 1000000 / device.colorTempSettings.minTemperatureLimit,
max_mireds: 1000000 / device.colorTempSettings.maxTemperatureLimit,
supported_color_modes: ['color_temp'],
}
: {}),
}); });
const getSceneDiscoveryPayload = ( const getSceneDiscoveryPayload = (
@ -84,11 +92,11 @@ const getSceneDiscoveryPayload = (
name: sceneDevice.name, name: sceneDevice.name,
unique_id: sceneDevice.uniqueId, unique_id: sceneDevice.uniqueId,
'~': getBaseTopic(sceneDevice.uniqueId, MQTT_TYPES.SCENE), '~': getBaseTopic(sceneDevice.uniqueId, MQTT_TYPES.SCENE),
command_topic: `~/${TOPIC_TYPES.COMMAND}`, command_topic: `~/${TOPIC_TYPES.SET}`,
availability_topic: `~/${TOPIC_TYPES.AVAILABILITY}`, availability_topic: `~/${TOPIC_TYPES.AVAILABILITY}`,
payload_on: 'ON', payload_on: 'ON',
qos: 1, qos: 1,
retain: false, retain: false, // State update messages from HA should not be retained
}); });
const getInputDeviceTriggerDiscoveryPayload = ( const getInputDeviceTriggerDiscoveryPayload = (
@ -98,9 +106,10 @@ const getInputDeviceTriggerDiscoveryPayload = (
payload: `${inputDevice.input}`, payload: `${inputDevice.input}`,
'~': getBaseTopic(inputDevice.deviceId, MQTT_TYPES.DEVICE_AUTOMATION), '~': getBaseTopic(inputDevice.deviceId, MQTT_TYPES.DEVICE_AUTOMATION),
qos: 1, qos: 1,
retain: true, // Discovery messages should be retained to account for HA restarts
subtype: `button_${inputDevice.input + 1}`,
topic: `~/${TOPIC_TYPES.STATE}`, topic: `~/${TOPIC_TYPES.STATE}`,
type: 'button_short_press', type: 'button_short_press',
subtype: `button_${inputDevice.input + 1}`,
device: { device: {
identifiers: `${inputDevice.deviceId}`, identifiers: `${inputDevice.deviceId}`,
manufacturer: 'Plejd', manufacturer: 'Plejd',
@ -115,6 +124,7 @@ const getSceneDeviceTriggerhDiscoveryPayload = (
automation_type: 'trigger', automation_type: 'trigger',
'~': getBaseTopic(`${sceneDevice.uniqueId}_trig`, MQTT_TYPES.DEVICE_AUTOMATION), '~': getBaseTopic(`${sceneDevice.uniqueId}_trig`, MQTT_TYPES.DEVICE_AUTOMATION),
qos: 1, qos: 1,
retain: true, // Discovery messages should be retained to account for HA restarts
topic: `~/${TOPIC_TYPES.STATE}`, topic: `~/${TOPIC_TYPES.STATE}`,
type: 'scene', type: 'scene',
subtype: 'trigger', subtype: 'trigger',
@ -127,7 +137,7 @@ const getSceneDeviceTriggerhDiscoveryPayload = (
}); });
const getMqttStateString = (/** @type {boolean} */ state) => (state ? 'ON' : 'OFF'); const getMqttStateString = (/** @type {boolean} */ state) => (state ? 'ON' : 'OFF');
const AVAILABLILITY = { ONLINE: 'online', OFFLINE: 'offline' }; const AVAILABLITY = { ONLINE: 'online', OFFLINE: 'offline' };
class MqttClient extends EventEmitter { class MqttClient extends EventEmitter {
/** @type {import('DeviceRegistry')} */ /** @type {import('DeviceRegistry')} */
@ -152,9 +162,13 @@ class MqttClient extends EventEmitter {
logger.info('Initializing MQTT connection for Plejd addon'); logger.info('Initializing MQTT connection for Plejd addon');
this.client = mqtt.connect(this.config.mqttBroker, { this.client = mqtt.connect(this.config.mqttBroker, {
clean: true, // We're moving to not saving mqtt messages
clientId: `hassio-plejd_${Math.random().toString(16).substr(2, 8)}`, clientId: `hassio-plejd_${Math.random().toString(16).substr(2, 8)}`,
password: this.config.mqttPassword, password: this.config.mqttPassword,
protocolVersion: 4, // v5 not supported by HassIO Mosquitto properties: {
sessionExpiryInterval: 120, // 2 minutes sessions for the QoS, after that old messages are discarded
},
protocolVersion: 5,
queueQoSZero: true, queueQoSZero: true,
username: this.config.mqttUsername, username: this.config.mqttUsername,
}); });
@ -166,28 +180,25 @@ class MqttClient extends EventEmitter {
this.client.on('connect', () => { this.client.on('connect', () => {
logger.info('Connected to MQTT.'); logger.info('Connected to MQTT.');
this.client.subscribe( this.emit(MqttClient.EVENTS.connected);
startTopics,
// Add below when mqtt v5 is supported in Mosquitto 1.6 or 2.0 and forward // Testing to skip listening to HA birth messages all together
// this.client.subscribe(
// startTopics,
// { // {
// qos: 1, // qos: 1,
// nl: true, // don't echo back messages sent // nl: true, // don't echo back messages sent
// rap: true, // retain as published - don't force retain = 0 // rap: true, // retain as published - don't force retain = 0
// rh: 0, // Retain handling 0 presumably ignores retained messages
// }, // },
(err) => { // (err) => {
if (err) { // if (err) {
logger.error('Unable to subscribe to status topics', err); // logger.error('Unable to subscribe to status topics', err);
} // }
this.emit(MqttClient.EVENTS.connected); // this.emit(MqttClient.EVENTS.connected);
}, // },
); // );
this.client.subscribe(getSubscribePath(), (err) => {
if (err) {
logger.error('Unable to subscribe to control topics');
}
});
}); });
this.client.on('close', () => { this.client.on('close', () => {
@ -197,10 +208,6 @@ class MqttClient extends EventEmitter {
this.client.on('message', (topic, message) => { this.client.on('message', (topic, message) => {
try { try {
if (startTopics.includes(topic)) {
logger.info('Home Assistant has started. lets do discovery.');
this.emit(MqttClient.EVENTS.connected);
} else {
logger.verbose(`Received mqtt message on ${topic}`); logger.verbose(`Received mqtt message on ${topic}`);
const decodedTopic = decodeTopic(topic); const decodedTopic = decodeTopic(topic);
if (decodedTopic) { if (decodedTopic) {
@ -254,7 +261,7 @@ class MqttClient extends EventEmitter {
`Warning: Got unrecognized mqtt command on '${topic}': ${message.toString()}`, `Warning: Got unrecognized mqtt command on '${topic}': ${message.toString()}`,
); );
} }
} // }
} catch (err) { } catch (err) {
logger.error(`Error processing mqtt message on topic ${topic}`, err); logger.error(`Error processing mqtt message on topic ${topic}`, err);
} }
@ -275,7 +282,7 @@ class MqttClient extends EventEmitter {
const mqttType = outputDevice.type === 'switch' ? MQTT_TYPES.SWITCH : MQTT_TYPES.LIGHT; const mqttType = outputDevice.type === 'switch' ? MQTT_TYPES.SWITCH : MQTT_TYPES.LIGHT;
this.client.publish( this.client.publish(
getTopicName(outputDevice.uniqueId, mqttType, 'availability'), getTopicName(outputDevice.uniqueId, mqttType, 'availability'),
AVAILABLILITY.OFFLINE, AVAILABLITY.OFFLINE,
{ {
retain: true, retain: true,
qos: 1, qos: 1,
@ -287,7 +294,7 @@ class MqttClient extends EventEmitter {
allSceneDevices.forEach((sceneDevice) => { allSceneDevices.forEach((sceneDevice) => {
this.client.publish( this.client.publish(
getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.AVAILABILITY), getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.AVAILABILITY),
AVAILABLILITY.OFFLINE, AVAILABLITY.OFFLINE,
{ {
retain: true, retain: true,
qos: 1, qos: 1,
@ -298,37 +305,67 @@ class MqttClient extends EventEmitter {
} }
sendDiscoveryToHomeAssistant() { sendDiscoveryToHomeAssistant() {
// -------- DISCOVERY FOR OUTPUT DEVICES -------------
const allOutputDevices = this.deviceRegistry.getAllOutputDevices(); const allOutputDevices = this.deviceRegistry.getAllOutputDevices();
logger.info(`Sending discovery for ${allOutputDevices.length} Plejd output devices`); logger.info(`Sending discovery for ${allOutputDevices.length} Plejd output devices`);
allOutputDevices.forEach((outputDevice) => { allOutputDevices.forEach((outputDevice) => {
logger.debug(`Sending discovery for ${outputDevice.name}`); logger.debug(`Sending discovery for ${outputDevice.name}`);
const configPayload = getOutputDeviceDiscoveryPayload(outputDevice); const configPayload = getOutputDeviceDiscoveryPayload(outputDevice);
logger.info( // Publish mqtt CONFIG message which will create the device in Home Assistant
`Discovered ${outputDevice.typeName} (${outputDevice.type}) named ${outputDevice.name} (${outputDevice.bleOutputAddress} : ${outputDevice.uniqueId}).`,
);
const mqttType = outputDevice.type === 'switch' ? MQTT_TYPES.SWITCH : MQTT_TYPES.LIGHT; const mqttType = outputDevice.type === 'switch' ? MQTT_TYPES.SWITCH : MQTT_TYPES.LIGHT;
this.client.publish( this.client.publish(
getTopicName(outputDevice.uniqueId, mqttType, TOPIC_TYPES.CONFIG), getTopicName(outputDevice.uniqueId, mqttType, TOPIC_TYPES.CONFIG),
JSON.stringify(configPayload), JSON.stringify(configPayload),
{ {
retain: true, retain: true, // Discovery messages should be retained to account for HA and MQTT broker restarts
qos: 1, qos: 1,
}, },
); );
setTimeout(() => {
logger.info(
`Sent discovery message for ${outputDevice.typeName} (${outputDevice.type}) named ${outputDevice.name} (${outputDevice.bleOutputAddress} : ${outputDevice.uniqueId}).`,
);
// -------- CLEANUP RETAINED MESSAGES FOR OUTPUT DEVICES -------------
logger.debug(
`Forcefully removing any retained SET, STATE, and AVAILABILITY messages for ${outputDevice.name}`,
);
// Forcefully remove retained (from Home Assistant) SET messages (wanted state from HA)
this.client.publish(getTopicName(outputDevice.uniqueId, mqttType, TOPIC_TYPES.SET), null, {
retain: true, // Retain true to remove previously retained message
qos: 1,
});
// Forcefully remove retained (from us, v0.11 and before) AVAILABILITY messages
this.client.publish(
getTopicName(outputDevice.uniqueId, mqttType, TOPIC_TYPES.AVAILABLILITY),
null,
{
retain: true, // Retain true to remove previously retained message
qos: 1,
},
);
logger.debug(`Removal messages sent for ${outputDevice.name}`);
logger.debug(`Setting device as AVAILABILITY = ONLINE: ${outputDevice.name}`);
this.client.publish( this.client.publish(
getTopicName(outputDevice.uniqueId, mqttType, TOPIC_TYPES.AVAILABILITY), getTopicName(outputDevice.uniqueId, mqttType, TOPIC_TYPES.AVAILABILITY),
AVAILABLILITY.ONLINE, AVAILABLITY.ONLINE,
{ {
retain: true, retain: false, // Availability messages should NOT be retained
qos: 1, qos: 1,
}, },
); );
}, 2000);
}); });
// -------- DISCOVERY FOR INPUT DEVICES -------------
const allInputDevices = this.deviceRegistry.getAllInputDevices(); const allInputDevices = this.deviceRegistry.getAllInputDevices();
logger.info(`Sending discovery for ${allInputDevices.length} Plejd input devices`); logger.info(`Sending discovery for ${allInputDevices.length} Plejd input devices`);
allInputDevices.forEach((inputDevice) => { allInputDevices.forEach((inputDevice) => {
@ -349,12 +386,14 @@ class MqttClient extends EventEmitter {
getTopicName(inputDevice.uniqueId, MQTT_TYPES.DEVICE_AUTOMATION, TOPIC_TYPES.CONFIG), getTopicName(inputDevice.uniqueId, MQTT_TYPES.DEVICE_AUTOMATION, TOPIC_TYPES.CONFIG),
JSON.stringify(inputInputPayload), JSON.stringify(inputInputPayload),
{ {
retain: true, retain: true, // Discovery messages should be retained to account for HA restarts
qos: 1, qos: 1,
}, },
); );
}); });
// -------- DISCOVERY FOR SCENE DEVICES -------------
const allSceneDevices = this.deviceRegistry.getAllSceneDevices(); const allSceneDevices = this.deviceRegistry.getAllSceneDevices();
logger.info(`Sending discovery for ${allSceneDevices.length} Plejd scene devices`); logger.info(`Sending discovery for ${allSceneDevices.length} Plejd scene devices`);
allSceneDevices.forEach((sceneDevice) => { allSceneDevices.forEach((sceneDevice) => {
@ -369,7 +408,7 @@ class MqttClient extends EventEmitter {
getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.CONFIG), getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.CONFIG),
JSON.stringify(sceneConfigPayload), JSON.stringify(sceneConfigPayload),
{ {
retain: true, retain: true, // Discovery messages should be retained to account for HA restarts
qos: 1, qos: 1,
}, },
); );
@ -384,22 +423,39 @@ class MqttClient extends EventEmitter {
), ),
JSON.stringify(sceneTriggerConfigPayload), JSON.stringify(sceneTriggerConfigPayload),
{ {
retain: true, retain: true, // Discovery messages should be retained to account for HA restarts
qos: 1, qos: 1,
}, },
); );
setTimeout(() => { // setTimeout(() => {
this.client.publish( this.client.publish(
getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.AVAILABILITY), getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.AVAILABILITY),
AVAILABLILITY.ONLINE, AVAILABLITY.ONLINE,
{ {
retain: true, retain: true, // Discovery messages should be retained to account for HA restarts
qos: 1, qos: 1,
}, },
); );
}, 2000); // }, 2000);
}); });
// -------- SUBSCRIBE TO INCOMING MESSAGES -------------
this.client.subscribe(
getSubscribePath(),
{
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 control topics');
}
},
);
} }
/** /**
@ -440,13 +496,13 @@ class MqttClient extends EventEmitter {
const mqttType = device.type === 'switch' ? MQTT_TYPES.SWITCH : MQTT_TYPES.LIGHT; const mqttType = device.type === 'switch' ? MQTT_TYPES.SWITCH : MQTT_TYPES.LIGHT;
this.client.publish(getTopicName(device.uniqueId, mqttType, TOPIC_TYPES.STATE), payload, { this.client.publish(getTopicName(device.uniqueId, mqttType, TOPIC_TYPES.STATE), payload, {
retain: true, retain: false,
qos: 1, qos: 1,
}); });
// this.client.publish( // this.client.publish(
// getTopicName(device.uniqueId, mqttType, TOPIC_TYPES.AVAILABILITY), // getTopicName(device.uniqueId, mqttType, TOPIC_TYPES.AVAILABILITY),
// AVAILABLILITY.ONLINE, // AVAILABILITY.ONLINE,
// { retain: true, qos: 1 }, // { retain: false, qos: 1 },
// ); // );
} }
@ -456,7 +512,10 @@ class MqttClient extends EventEmitter {
*/ */
buttonPressed(deviceId, deviceInput) { buttonPressed(deviceId, deviceInput) {
logger.verbose(`Button ${deviceInput} pressed for deviceId ${deviceId}`); logger.verbose(`Button ${deviceInput} pressed for deviceId ${deviceId}`);
this.client.publish(getButtonEventTopic(deviceId), `${deviceInput}`, { qos: 1 }); this.client.publish(getButtonEventTopic(deviceId), `${deviceInput}`, {
retain: false,
qos: 1,
});
} }
/** /**
@ -464,7 +523,10 @@ class MqttClient extends EventEmitter {
*/ */
sceneTriggered(sceneId) { sceneTriggered(sceneId) {
logger.verbose(`Scene triggered: ${sceneId}`); logger.verbose(`Scene triggered: ${sceneId}`);
this.client.publish(getSceneEventTopic(sceneId), '', { qos: 1 }); this.client.publish(getSceneEventTopic(sceneId), '', {
qos: 1,
retain: false,
});
} }
} }

View file

@ -11,9 +11,10 @@ const API_SITE_LIST_URL = 'functions/getSiteList';
const API_SITE_DETAILS_URL = 'functions/getSiteById'; const API_SITE_DETAILS_URL = 'functions/getSiteById';
const TRAITS = { const TRAITS = {
NO_LOAD: 0, NO_LOAD: 0, // 0b0000
NON_DIMMABLE: 9, NON_DIMMABLE: 9, // 0b1001
DIMMABLE: 11, DIMMABLE: 11, // 0b1011
DIMMABLE_COLORTEMP: 15, // 0b1111
}; };
const logger = Logger.getLogger('plejd-api'); const logger = Logger.getLogger('plejd-api');
@ -338,8 +339,10 @@ class PlejdApi {
description: 'Dali broadcast with dimmer and tuneable white support', description: 'Dali broadcast with dimmer and tuneable white support',
type: 'light', type: 'light',
dimmable: true, dimmable: true,
colorTemp: true,
broadcastClicks: false, broadcastClicks: false,
}; };
// 13: Non-dimmable generic light
case 14: case 14:
return { return {
name: 'DIM-01', name: 'DIM-01',
@ -395,6 +398,7 @@ class PlejdApi {
description: '1-channel LED dimmer/driver with tuneable white, 10 W', description: '1-channel LED dimmer/driver with tuneable white, 10 W',
type: 'light', type: 'light',
dimmable: true, dimmable: true,
colorTemp: true,
broadcastClicks: false, broadcastClicks: false,
}; };
case 167: case 167:
@ -403,6 +407,7 @@ class PlejdApi {
description: 'Smart tunable downlight with a built-in dimmer function, 8W', description: 'Smart tunable downlight with a built-in dimmer function, 8W',
type: 'light', type: 'light',
dimmable: true, dimmable: true,
colorTemp: true,
broadcastClicks: false, broadcastClicks: false,
}; };
case 199: case 199:
@ -411,6 +416,7 @@ class PlejdApi {
description: 'Smart tunable downlight with a built-in dimmer function, 8W', description: 'Smart tunable downlight with a built-in dimmer function, 8W',
type: 'light', type: 'light',
dimmable: true, dimmable: true,
colorTemp: true,
broadcastClicks: false, broadcastClicks: false,
}; };
// PLEASE CREATE AN ISSUE WITH THE HARDWARE ID if you own one of these devices! // PLEASE CREATE AN ISSUE WITH THE HARDWARE ID if you own one of these devices!
@ -480,8 +486,18 @@ class PlejdApi {
(x) => x.deviceId === device.deviceId, (x) => x.deviceId === device.deviceId,
); );
const dimmable = device.traits === TRAITS.DIMMABLE; const dimmable =
// dimmable = settings.dimCurve !== 'NonDimmable'; device.traits === TRAITS.DIMMABLE || device.traits === TRAITS.DIMMABLE_COLORTEMP;
// Alternate approach looks at outputSettings.dimCurve and outputSettings.predefinedLoad
// 1. outputSettings.dimCurve === null: Not dimmable
// 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 { try {
const decodedDeviceType = this._getDeviceType(plejdDevice); const decodedDeviceType = this._getDeviceType(plejdDevice);
@ -499,6 +515,8 @@ class PlejdApi {
/** @type {import('types/DeviceRegistry').OutputDevice} */ /** @type {import('types/DeviceRegistry').OutputDevice} */
const outputDevice = { const outputDevice = {
bleOutputAddress, bleOutputAddress,
colorTemp,
colorTempSettings: outputSettings ? outputSettings.colorTemperature : null,
deviceId: device.deviceId, deviceId: device.deviceId,
dimmable, dimmable,
name: device.title, name: device.title,
@ -604,6 +622,7 @@ class PlejdApi {
const newDevice = { const newDevice = {
bleOutputAddress: roomAddress, bleOutputAddress: roomAddress,
deviceId: null, deviceId: null,
colorTemp: false,
dimmable, dimmable,
name: room.title, name: room.title,
output: undefined, output: undefined,
@ -633,6 +652,7 @@ class PlejdApi {
/** @type {import('types/DeviceRegistry').OutputDevice} */ /** @type {import('types/DeviceRegistry').OutputDevice} */
const newScene = { const newScene = {
bleOutputAddress: sceneNum, bleOutputAddress: sceneNum,
colorTemp: false,
deviceId: undefined, deviceId: undefined,
dimmable: false, dimmable: false,
name: scene.title, name: scene.title,

View file

@ -42,7 +42,10 @@ const GATT_CHRC_ID = 'org.bluez.GattCharacteristic1';
const PAYLOAD_POSITION_OFFSET = 5; const PAYLOAD_POSITION_OFFSET = 5;
const DIM_LEVEL_POSITION_OFFSET = 7; const DIM_LEVEL_POSITION_OFFSET = 7;
const delay = (timeout) => new Promise((resolve) => setTimeout(resolve, timeout)); const delay = (timeout) =>
new Promise((resolve) => {
setTimeout(resolve, timeout);
});
class PlejBLEHandler extends EventEmitter { class PlejBLEHandler extends EventEmitter {
adapter; adapter;
@ -303,11 +306,14 @@ class PlejBLEHandler extends EventEmitter {
// After we've authenticated, we need to hook up the event listener // After we've authenticated, we need to hook up the event listener
// for changes to lastData. // for changes to lastData.
this.characteristics.lastDataProperties.on('PropertiesChanged', ( this.characteristics.lastDataProperties.on(
'PropertiesChanged',
(
iface, iface,
properties, properties,
// invalidated (third param), // invalidated (third param),
) => this._onLastDataUpdated(iface, properties)); ) => this._onLastDataUpdated(iface, properties),
);
this.characteristics.lastData.StartNotify(); this.characteristics.lastData.StartNotify();
this.consecutiveReconnectAttempts = 0; this.consecutiveReconnectAttempts = 0;
this.emit(PlejBLEHandler.EVENTS.connected); this.emit(PlejBLEHandler.EVENTS.connected);
@ -657,7 +663,7 @@ class PlejBLEHandler extends EventEmitter {
logger.info('Requesting current Plejd time...'); logger.info('Requesting current Plejd time...');
const payload = this._createHexPayload( const payload = this._createHexPayload(
this.connectedDevice.id, this.connectedDeviceId,
BLE_CMD_TIME_UPDATE, BLE_CMD_TIME_UPDATE,
'', '',
BLE_REQUEST_RESPONSE, BLE_REQUEST_RESPONSE,

View file

@ -33,6 +33,7 @@ The add-on has been tested on the following platforms:
- Windows 10 host / Oracle Virtualbox 6.1 / Home Assistant VBox image / ASUS BT400 - Windows 10 host / Oracle Virtualbox 6.1 / Home Assistant VBox image / ASUS BT400
- Mac OS Catalina 10.15.1 with Node v. 13.2.0 - Mac OS Catalina 10.15.1 with Node v. 13.2.0
- Home Assistant Yellow with RPI 4 compute module / Built-in BT - Home Assistant Yellow with RPI 4 compute module / Built-in BT
- HP EliteDesk 800 G3 DM / Proxmox 7.5-3 / HAOS / Deltaco BT-118 with Cambridge Silicon Radio chipset
Supported Plejd devices are detailed in a specific "Plejd devices" section below. Supported Plejd devices are detailed in a specific "Plejd devices" section below.

View file

@ -1,22 +1,22 @@
{ {
"dependencies": { "dependencies": {
"@abandonware/bluetooth-hci-socket": "~0.5.3-7", "@abandonware/bluetooth-hci-socket": "~0.5.3-10",
"axios": "~0.21.1", "axios": "~1.6.1",
"buffer-xor": "~2.0.2", "buffer-xor": "~2.0.2",
"dbus-next": "~0.9.2", "dbus-next": "~0.10.2",
"fs": "0.0.1-security", "fs": "0.0.1-security",
"jspack": "~0.0.4", "jspack": "~0.0.4",
"mqtt": "~4.2.6", "mqtt": "~5.1.2",
"winston": "~3.3.3" "winston": "~3.11.0"
}, },
"devDependencies": { "devDependencies": {
"babel-eslint": "~10.1.0", "@babel/eslint-parser": "~7.23.3",
"eslint": "~7.23.0", "eslint": "~8.53.0",
"eslint-config-airbnb": "~18.2.1", "eslint-config-airbnb": "~19.0.4",
"eslint-config-prettier": "~8.1.0", "eslint-config-prettier": "~9.0.0",
"eslint-plugin-import": "~2.22.1", "eslint-plugin-import": "~2.29.0",
"eslint-plugin-prettier": "~3.3.1", "eslint-plugin-prettier": "~5.0.1",
"prettier": "~2.2.1" "prettier": "~3.0.3"
}, },
"scripts": { "scripts": {
"lint": "npm run lint:prettier & npm run lint:scripts", "lint": "npm run lint:prettier & npm run lint:scripts",

View file

@ -5,6 +5,7 @@
# ============================================================================== # ==============================================================================
bashio::log.info 'Starting the Plejd service...' bashio::log.info 'Starting the Plejd service...'
bashio::log.info 'Docker env updated 2023-10-17...'
# Change working directory # Change working directory
cd /plejd || bashio::exit.nok 'Unable to change working directory' cd /plejd || bashio::exit.nok 'Unable to change working directory'

View file

@ -260,6 +260,7 @@ export interface OutputSetting {
deviceParseId: string; deviceParseId: string;
siteId: string; siteId: string;
predefinedLoad: OutputSettingPredefinedLoad; predefinedLoad: OutputSettingPredefinedLoad;
colorTemperature: OutputSettingColorTemperature;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
dimMin: number; dimMin: number;
@ -322,6 +323,16 @@ export interface OutputSettingPredefinedLoad {
filters?: Filters; filters?: Filters;
} }
export interface OutputSettingColorTemperature {
"minTemperature": number,
"maxTemperature": number,
"slewRate": number,
"minTemperatureLimit": number,
"maxTemperatureLimit": number,
"behavior": "adjustable" | "UNKNOWN", // Todo: Fill with alternate values after finding more site jsons. UNKNOWN is placeholder for now.
"startTemperature": number
}
export interface PredefinedLoadACL { export interface PredefinedLoadACL {
'*': Empty; '*': Empty;
} }

View file

@ -1,9 +1,13 @@
/* eslint-disable no-use-before-define */ /* eslint-disable no-use-before-define */
import { OutputSettingColorTemperature } from "./ApiSite";
export type OutputDevices = { [deviceIdAndOutput: string]: OutputDevice }; export type OutputDevices = { [deviceIdAndOutput: string]: OutputDevice };
export interface OutputDevice { export interface OutputDevice {
bleOutputAddress: number; bleOutputAddress: number;
colorTemp: boolean;
colorTempSettings?: OutputSettingColorTemperature
deviceId: string; deviceId: string;
dim?: number; dim?: number;
dimmable: boolean; dimmable: boolean;