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',
// 'plugin:prettier/recommended'
],
parser: 'babel-eslint',
parser: '@babel/eslint-parser',
parserOptions: {
requireConfigFile: false,
},
// plugins: ['prettier'],
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
ENV LANG C.UTF-8

View file

@ -4,7 +4,7 @@ const mqtt = require('mqtt');
const Configuration = require('./Configuration');
const Logger = require('./Logger');
const startTopics = ['hass/status', 'homeassistant/status'];
// const startTopics = ['hass/status', 'homeassistant/status'];
const logger = Logger.getLogger('plejd-mqtt');
@ -24,7 +24,7 @@ const TOPIC_TYPES = {
CONFIG: 'config',
STATE: 'state',
AVAILABILITY: 'availability',
COMMAND: 'set',
SET: 'set',
};
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)}`;
const getSubscribePath = () => `${discoveryPrefix}/+/${nodeId}/#`;
const decodeTopicRegexp = new RegExp(
/(?<prefix>[^[]+)\/(?<type>.+)\/plejd\/(?<id>.+)\/(?<command>config|state|availability|set|scene)/,
);
const decodeTopicRegexp =
/(?<prefix>[^[]+)\/(?<type>.+)\/plejd\/(?<id>.+)\/(?<command>config|state|availability|set|scene)/;
const decodeTopic = (topic) => {
const matches = decodeTopicRegexp.exec(topic);
@ -62,11 +61,11 @@ const getOutputDeviceDiscoveryPayload = (
unique_id: device.uniqueId,
'~': getBaseTopic(device.uniqueId, device.type),
state_topic: `~/${TOPIC_TYPES.STATE}`,
command_topic: `~/${TOPIC_TYPES.COMMAND}`,
command_topic: `~/${TOPIC_TYPES.SET}`,
availability_topic: `~/${TOPIC_TYPES.AVAILABILITY}`,
optimistic: false,
qos: 1,
retain: true,
retain: false, // State update messages from HA should not be retained
device: {
identifiers: `${device.uniqueId}`,
manufacturer: 'Plejd',
@ -76,6 +75,15 @@ const getOutputDeviceDiscoveryPayload = (
sw_version: device.version,
},
...(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 = (
@ -84,11 +92,11 @@ const getSceneDiscoveryPayload = (
name: sceneDevice.name,
unique_id: sceneDevice.uniqueId,
'~': getBaseTopic(sceneDevice.uniqueId, MQTT_TYPES.SCENE),
command_topic: `~/${TOPIC_TYPES.COMMAND}`,
command_topic: `~/${TOPIC_TYPES.SET}`,
availability_topic: `~/${TOPIC_TYPES.AVAILABILITY}`,
payload_on: 'ON',
qos: 1,
retain: false,
retain: false, // State update messages from HA should not be retained
});
const getInputDeviceTriggerDiscoveryPayload = (
@ -98,9 +106,10 @@ const getInputDeviceTriggerDiscoveryPayload = (
payload: `${inputDevice.input}`,
'~': getBaseTopic(inputDevice.deviceId, MQTT_TYPES.DEVICE_AUTOMATION),
qos: 1,
retain: true, // Discovery messages should be retained to account for HA restarts
subtype: `button_${inputDevice.input + 1}`,
topic: `~/${TOPIC_TYPES.STATE}`,
type: 'button_short_press',
subtype: `button_${inputDevice.input + 1}`,
device: {
identifiers: `${inputDevice.deviceId}`,
manufacturer: 'Plejd',
@ -115,6 +124,7 @@ const getSceneDeviceTriggerhDiscoveryPayload = (
automation_type: 'trigger',
'~': getBaseTopic(`${sceneDevice.uniqueId}_trig`, MQTT_TYPES.DEVICE_AUTOMATION),
qos: 1,
retain: true, // Discovery messages should be retained to account for HA restarts
topic: `~/${TOPIC_TYPES.STATE}`,
type: 'scene',
subtype: 'trigger',
@ -127,7 +137,7 @@ const getSceneDeviceTriggerhDiscoveryPayload = (
});
const getMqttStateString = (/** @type {boolean} */ state) => (state ? 'ON' : 'OFF');
const AVAILABLILITY = { ONLINE: 'online', OFFLINE: 'offline' };
const AVAILABLITY = { ONLINE: 'online', OFFLINE: 'offline' };
class MqttClient extends EventEmitter {
/** @type {import('DeviceRegistry')} */
@ -152,9 +162,13 @@ class MqttClient extends EventEmitter {
logger.info('Initializing MQTT connection for Plejd addon');
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)}`,
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,
username: this.config.mqttUsername,
});
@ -166,28 +180,25 @@ class MqttClient extends EventEmitter {
this.client.on('connect', () => {
logger.info('Connected to MQTT.');
this.client.subscribe(
startTopics,
// Add below when mqtt v5 is supported in Mosquitto 1.6 or 2.0 and forward
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);
}
// (err) => {
// if (err) {
// logger.error('Unable to subscribe to status topics', err);
// }
this.emit(MqttClient.EVENTS.connected);
},
);
this.client.subscribe(getSubscribePath(), (err) => {
if (err) {
logger.error('Unable to subscribe to control topics');
}
});
// this.emit(MqttClient.EVENTS.connected);
// },
// );
});
this.client.on('close', () => {
@ -197,10 +208,6 @@ class MqttClient extends EventEmitter {
this.client.on('message', (topic, message) => {
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}`);
const decodedTopic = decodeTopic(topic);
if (decodedTopic) {
@ -254,7 +261,7 @@ class MqttClient extends EventEmitter {
`Warning: Got unrecognized mqtt command on '${topic}': ${message.toString()}`,
);
}
}
// }
} catch (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;
this.client.publish(
getTopicName(outputDevice.uniqueId, mqttType, 'availability'),
AVAILABLILITY.OFFLINE,
AVAILABLITY.OFFLINE,
{
retain: true,
qos: 1,
@ -287,7 +294,7 @@ class MqttClient extends EventEmitter {
allSceneDevices.forEach((sceneDevice) => {
this.client.publish(
getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.AVAILABILITY),
AVAILABLILITY.OFFLINE,
AVAILABLITY.OFFLINE,
{
retain: true,
qos: 1,
@ -298,37 +305,67 @@ class MqttClient extends EventEmitter {
}
sendDiscoveryToHomeAssistant() {
// -------- DISCOVERY FOR OUTPUT DEVICES -------------
const allOutputDevices = this.deviceRegistry.getAllOutputDevices();
logger.info(`Sending discovery for ${allOutputDevices.length} Plejd output devices`);
allOutputDevices.forEach((outputDevice) => {
logger.debug(`Sending discovery for ${outputDevice.name}`);
const configPayload = getOutputDeviceDiscoveryPayload(outputDevice);
logger.info(
`Discovered ${outputDevice.typeName} (${outputDevice.type}) named ${outputDevice.name} (${outputDevice.bleOutputAddress} : ${outputDevice.uniqueId}).`,
);
// Publish mqtt CONFIG message which will create the device in Home Assistant
const mqttType = outputDevice.type === 'switch' ? MQTT_TYPES.SWITCH : MQTT_TYPES.LIGHT;
this.client.publish(
getTopicName(outputDevice.uniqueId, mqttType, TOPIC_TYPES.CONFIG),
JSON.stringify(configPayload),
{
retain: true,
retain: true, // Discovery messages should be retained to account for HA and MQTT broker restarts
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(
getTopicName(outputDevice.uniqueId, mqttType, TOPIC_TYPES.AVAILABILITY),
AVAILABLILITY.ONLINE,
AVAILABLITY.ONLINE,
{
retain: true,
retain: false, // Availability messages should NOT be retained
qos: 1,
},
);
}, 2000);
});
// -------- DISCOVERY FOR INPUT DEVICES -------------
const allInputDevices = this.deviceRegistry.getAllInputDevices();
logger.info(`Sending discovery for ${allInputDevices.length} Plejd input devices`);
allInputDevices.forEach((inputDevice) => {
@ -349,12 +386,14 @@ class MqttClient extends EventEmitter {
getTopicName(inputDevice.uniqueId, MQTT_TYPES.DEVICE_AUTOMATION, TOPIC_TYPES.CONFIG),
JSON.stringify(inputInputPayload),
{
retain: true,
retain: true, // Discovery messages should be retained to account for HA restarts
qos: 1,
},
);
});
// -------- DISCOVERY FOR SCENE DEVICES -------------
const allSceneDevices = this.deviceRegistry.getAllSceneDevices();
logger.info(`Sending discovery for ${allSceneDevices.length} Plejd scene devices`);
allSceneDevices.forEach((sceneDevice) => {
@ -369,7 +408,7 @@ class MqttClient extends EventEmitter {
getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.CONFIG),
JSON.stringify(sceneConfigPayload),
{
retain: true,
retain: true, // Discovery messages should be retained to account for HA restarts
qos: 1,
},
);
@ -384,22 +423,39 @@ class MqttClient extends EventEmitter {
),
JSON.stringify(sceneTriggerConfigPayload),
{
retain: true,
retain: true, // Discovery messages should be retained to account for HA restarts
qos: 1,
},
);
setTimeout(() => {
// setTimeout(() => {
this.client.publish(
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,
},
);
}, 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;
this.client.publish(getTopicName(device.uniqueId, mqttType, TOPIC_TYPES.STATE), payload, {
retain: true,
retain: false,
qos: 1,
});
// this.client.publish(
// getTopicName(device.uniqueId, mqttType, TOPIC_TYPES.AVAILABILITY),
// AVAILABLILITY.ONLINE,
// { retain: true, qos: 1 },
// AVAILABILITY.ONLINE,
// { retain: false, qos: 1 },
// );
}
@ -456,7 +512,10 @@ class MqttClient extends EventEmitter {
*/
buttonPressed(deviceId, deviceInput) {
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) {
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 TRAITS = {
NO_LOAD: 0,
NON_DIMMABLE: 9,
DIMMABLE: 11,
NO_LOAD: 0, // 0b0000
NON_DIMMABLE: 9, // 0b1001
DIMMABLE: 11, // 0b1011
DIMMABLE_COLORTEMP: 15, // 0b1111
};
const logger = Logger.getLogger('plejd-api');
@ -338,8 +339,10 @@ class PlejdApi {
description: 'Dali broadcast with dimmer and tuneable white support',
type: 'light',
dimmable: true,
colorTemp: true,
broadcastClicks: false,
};
// 13: Non-dimmable generic light
case 14:
return {
name: 'DIM-01',
@ -395,6 +398,7 @@ class PlejdApi {
description: '1-channel LED dimmer/driver with tuneable white, 10 W',
type: 'light',
dimmable: true,
colorTemp: true,
broadcastClicks: false,
};
case 167:
@ -403,6 +407,7 @@ class PlejdApi {
description: 'Smart tunable downlight with a built-in dimmer function, 8W',
type: 'light',
dimmable: true,
colorTemp: true,
broadcastClicks: false,
};
case 199:
@ -411,6 +416,7 @@ class PlejdApi {
description: 'Smart tunable downlight with a built-in dimmer function, 8W',
type: 'light',
dimmable: true,
colorTemp: true,
broadcastClicks: false,
};
// 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,
);
const dimmable = device.traits === TRAITS.DIMMABLE;
// dimmable = settings.dimCurve !== 'NonDimmable';
const dimmable =
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 {
const decodedDeviceType = this._getDeviceType(plejdDevice);
@ -499,6 +515,8 @@ class PlejdApi {
/** @type {import('types/DeviceRegistry').OutputDevice} */
const outputDevice = {
bleOutputAddress,
colorTemp,
colorTempSettings: outputSettings ? outputSettings.colorTemperature : null,
deviceId: device.deviceId,
dimmable,
name: device.title,
@ -604,6 +622,7 @@ class PlejdApi {
const newDevice = {
bleOutputAddress: roomAddress,
deviceId: null,
colorTemp: false,
dimmable,
name: room.title,
output: undefined,
@ -633,6 +652,7 @@ class PlejdApi {
/** @type {import('types/DeviceRegistry').OutputDevice} */
const newScene = {
bleOutputAddress: sceneNum,
colorTemp: false,
deviceId: undefined,
dimmable: false,
name: scene.title,

View file

@ -42,7 +42,10 @@ const GATT_CHRC_ID = 'org.bluez.GattCharacteristic1';
const PAYLOAD_POSITION_OFFSET = 5;
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 {
adapter;
@ -303,11 +306,14 @@ class PlejBLEHandler extends EventEmitter {
// After we've authenticated, we need to hook up the event listener
// for changes to lastData.
this.characteristics.lastDataProperties.on('PropertiesChanged', (
this.characteristics.lastDataProperties.on(
'PropertiesChanged',
(
iface,
properties,
// invalidated (third param),
) => this._onLastDataUpdated(iface, properties));
) => this._onLastDataUpdated(iface, properties),
);
this.characteristics.lastData.StartNotify();
this.consecutiveReconnectAttempts = 0;
this.emit(PlejBLEHandler.EVENTS.connected);
@ -657,7 +663,7 @@ class PlejBLEHandler extends EventEmitter {
logger.info('Requesting current Plejd time...');
const payload = this._createHexPayload(
this.connectedDevice.id,
this.connectedDeviceId,
BLE_CMD_TIME_UPDATE,
'',
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
- Mac OS Catalina 10.15.1 with Node v. 13.2.0
- 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.

View file

@ -1,22 +1,22 @@
{
"dependencies": {
"@abandonware/bluetooth-hci-socket": "~0.5.3-7",
"axios": "~0.21.1",
"@abandonware/bluetooth-hci-socket": "~0.5.3-10",
"axios": "~1.6.1",
"buffer-xor": "~2.0.2",
"dbus-next": "~0.9.2",
"dbus-next": "~0.10.2",
"fs": "0.0.1-security",
"jspack": "~0.0.4",
"mqtt": "~4.2.6",
"winston": "~3.3.3"
"mqtt": "~5.1.2",
"winston": "~3.11.0"
},
"devDependencies": {
"babel-eslint": "~10.1.0",
"eslint": "~7.23.0",
"eslint-config-airbnb": "~18.2.1",
"eslint-config-prettier": "~8.1.0",
"eslint-plugin-import": "~2.22.1",
"eslint-plugin-prettier": "~3.3.1",
"prettier": "~2.2.1"
"@babel/eslint-parser": "~7.23.3",
"eslint": "~8.53.0",
"eslint-config-airbnb": "~19.0.4",
"eslint-config-prettier": "~9.0.0",
"eslint-plugin-import": "~2.29.0",
"eslint-plugin-prettier": "~5.0.1",
"prettier": "~3.0.3"
},
"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 'Docker env updated 2023-10-17...'
# Change working directory
cd /plejd || bashio::exit.nok 'Unable to change working directory'

View file

@ -260,6 +260,7 @@ export interface OutputSetting {
deviceParseId: string;
siteId: string;
predefinedLoad: OutputSettingPredefinedLoad;
colorTemperature: OutputSettingColorTemperature;
createdAt: Date;
updatedAt: Date;
dimMin: number;
@ -322,6 +323,16 @@ export interface OutputSettingPredefinedLoad {
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 {
'*': Empty;
}

View file

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