hassio-plejd/plejd/MqttClient.js

469 lines
15 KiB
JavaScript
Raw Normal View History

2019-12-04 11:17:06 +01:00
const EventEmitter = require('events');
const mqtt = require('mqtt');
const Configuration = require('./Configuration');
const Logger = require('./Logger');
2019-12-04 11:17:06 +01:00
const startTopics = ['hass/status', 'homeassistant/status'];
2019-12-04 11:17:06 +01:00
const logger = Logger.getLogger('plejd-mqtt');
2019-12-04 11:17:06 +01:00
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 = {
2021-04-26 13:13:10 +02:00
CONFIG: 'config',
STATE: 'state',
AVAILABILITY: 'availability',
COMMAND: 'set',
};
const getBaseTopic = (/** @type { string } */ uniqueId, /** @type { string } */ mqttDeviceType) => `${discoveryPrefix}/${mqttDeviceType}/${nodeId}/${uniqueId}`;
const getTopicName = (
/** @type { string } */ uniqueId,
/** @type { import('./types/Mqtt').MqttType } */ mqttDeviceType,
/** @type { import('./types/Mqtt').TopicType } */ topicType,
) => `${getBaseTopic(uniqueId, mqttDeviceType)}/${topicType}`;
2021-05-05 19:18:43 +02:00
const getButtonEventTopic = (/** @type {string} */ deviceId) => `${getTopicName(deviceId, MQTT_TYPES.DEVICE_AUTOMATION, TOPIC_TYPES.STATE)}`;
const getTriggerUniqueId = (/** @type { string } */ uniqueId) => `${uniqueId}_trigger`;
const getSceneEventTopic = (/** @type {string} */ sceneId) => `${getTopicName(getTriggerUniqueId(sceneId), MQTT_TYPES.DEVICE_AUTOMATION, TOPIC_TYPES.STATE)}`;
2021-04-26 13:13:10 +02:00
const getSubscribePath = () => `${discoveryPrefix}/+/${nodeId}/#`;
const decodeTopicRegexp = new RegExp(
/(?<prefix>[^[]+)\/(?<type>.+)\/plejd\/(?<id>.+)\/(?<command>config|state|availability|set|scene)/,
);
const decodeTopic = (topic) => {
const matches = decodeTopicRegexp.exec(topic);
if (!matches) {
return null;
}
return matches.groups;
};
const getOutputDeviceDiscoveryPayload = (
/** @type {import('./types/DeviceRegistry').OutputDevice} */ device,
) => ({
name: device.name,
2021-03-31 23:28:25 +02:00
unique_id: device.uniqueId,
'~': getBaseTopic(device.uniqueId, device.type),
state_topic: `~/${TOPIC_TYPES.STATE}`,
command_topic: `~/${TOPIC_TYPES.COMMAND}`,
availability_topic: `~/${TOPIC_TYPES.AVAILABILITY}`,
2019-12-21 15:01:15 +00:00
optimistic: false,
qos: 1,
retain: true,
device: {
identifiers: `${device.deviceId}`,
manufacturer: 'Plejd',
model: device.typeName,
name: device.name,
...(device.roomName !== undefined ? { suggested_area: device.roomName } : {}),
sw_version: device.version,
},
...(device.type === MQTT_TYPES.LIGHT ? { brightness: device.dimmable, schema: 'json' } : {}),
});
2021-04-26 13:13:10 +02:00
const getSceneDiscoveryPayload = (
/** @type {import('./types/DeviceRegistry').OutputDevice} */ sceneDevice,
) => ({
name: sceneDevice.name,
unique_id: sceneDevice.uniqueId,
'~': getBaseTopic(sceneDevice.uniqueId, MQTT_TYPES.SCENE),
command_topic: `~/${TOPIC_TYPES.COMMAND}`,
availability_topic: `~/${TOPIC_TYPES.AVAILABILITY}`,
payload_on: 'ON',
qos: 1,
retain: false,
2020-02-20 13:02:47 +01:00
});
const getInputDeviceTriggerDiscoveryPayload = (
/** @type {import('./types/DeviceRegistry').InputDevice} */ inputDevice,
) => ({
automation_type: 'trigger',
payload: `${inputDevice.input}`,
'~': getBaseTopic(inputDevice.deviceId, MQTT_TYPES.DEVICE_AUTOMATION),
qos: 1,
topic: `~/${TOPIC_TYPES.STATE}`,
type: 'button_short_press',
2021-05-05 19:34:34 +02:00
subtype: `button_${inputDevice.input + 1}`,
device: {
identifiers: `${inputDevice.deviceId}`,
manufacturer: 'Plejd',
model: inputDevice.typeName,
name: inputDevice.name,
},
});
2021-04-26 13:13:10 +02:00
const getSceneDeviceTriggerhDiscoveryPayload = (
/** @type {import('./types/DeviceRegistry').OutputDevice} */ sceneDevice,
) => ({
automation_type: 'trigger',
'~': getBaseTopic(sceneDevice.uniqueId, MQTT_TYPES.DEVICE_AUTOMATION),
2021-04-26 13:13:10 +02:00
qos: 1,
topic: `~/${TOPIC_TYPES.STATE}`,
2021-04-26 13:13:10 +02:00
type: 'scene',
subtype: 'trigger',
device: {
identifiers: `${sceneDevice.uniqueId}`,
manufacturer: 'Plejd',
model: sceneDevice.typeName,
name: sceneDevice.name,
},
});
const getMqttStateString = (/** @type {boolean} */ state) => (state ? 'ON' : 'OFF');
const AVAILABLILITY = { ONLINE: 'online', OFFLINE: 'offline' };
2019-12-04 11:17:06 +01:00
class MqttClient extends EventEmitter {
/** @type {import('DeviceRegistry')} */
deviceRegistry;
2021-02-20 15:33:06 +01:00
static EVENTS = {
connected: 'connected',
stateChanged: 'stateChanged',
};
/**
* @param {import("DeviceRegistry")} deviceRegistry
*/
constructor(deviceRegistry) {
2019-12-04 11:17:06 +01:00
super();
this.config = Configuration.getOptions();
this.deviceRegistry = deviceRegistry;
2019-12-04 11:17:06 +01:00
}
init() {
logger.info('Initializing MQTT connection for Plejd addon');
2019-12-04 11:17:06 +01:00
this.client = mqtt.connect(this.config.mqttBroker, {
clientId: `hassio-plejd_${Math.random().toString(16).substr(2, 8)}`,
password: this.config.mqttPassword,
protocolVersion: 4, // v5 not supported by HassIO Mosquitto
queueQoSZero: true,
username: this.config.mqttUsername,
2019-12-04 11:17:06 +01:00
});
this.client.on('error', (err) => {
logger.warn('Error emitted from mqtt client', err);
});
2019-12-04 11:17:06 +01:00
this.client.on('connect', () => {
logger.info('Connected to MQTT.');
2019-12-04 11:17:06 +01:00
this.client.subscribe(
startTopics,
// Add below when mqtt v5 is supported in Mosquitto 1.6 or 2.0 and forward
// {
// qos: 1,
// nl: true, // don't echo back messages sent
// rap: true, // retain as published - don't force retain = 0
// },
(err) => {
if (err) {
logger.error('Unable to subscribe to status topics', err);
}
2019-12-04 11:17:06 +01:00
this.emit(MqttClient.EVENTS.connected);
},
);
2019-12-04 11:17:06 +01:00
this.client.subscribe(getSubscribePath(), (err) => {
if (err) {
logger.error('Unable to subscribe to control topics');
2019-12-04 11:17:06 +01:00
}
});
});
this.client.on('close', () => {
logger.verbose('Warning: mqtt channel closed event, reconnecting...');
this.reconnect();
2019-12-04 11:17:06 +01:00
});
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) {
/** @type {import('types/DeviceRegistry').OutputDevice} */
let device;
if (decodedTopic.type === MQTT_TYPES.SCENE) {
logger.verbose(`Getting scene ${decodedTopic.id} from registry`);
device = this.deviceRegistry.getScene(decodedTopic.id);
} else {
logger.verbose(`Getting device ${decodedTopic.id} from registry`);
device = this.deviceRegistry.getOutputDevice(decodedTopic.id);
}
const messageString = message.toString();
const isJsonMessage = messageString.startsWith('{');
const command = isJsonMessage ? JSON.parse(messageString) : messageString;
const deviceName = device ? device.name : '';
switch (decodedTopic.command) {
case 'set':
logger.verbose(
`Got mqtt SET command for ${decodedTopic.type}, ${deviceName} (${decodedTopic.id}): ${messageString}`,
);
if (device) {
this.emit(MqttClient.EVENTS.stateChanged, device, command);
} else {
logger.warn(
`Device for topic ${topic} not found! Can happen if HA calls previously existing devices.`,
);
}
break;
case 'state':
case 'config':
case 'availability':
logger.verbose(
`Sent mqtt ${decodedTopic.command} command for ${
decodedTopic.type
}, ${deviceName} (${decodedTopic.id}). ${
decodedTopic.command === 'availability' ? messageString : ''
}`,
);
break;
default:
logger.verbose(`Warning: Unknown command ${decodedTopic.command} in decoded topic`);
}
} else {
logger.verbose(
`Warning: Got unrecognized mqtt command on '${topic}': ${message.toString()}`,
);
}
}
} catch (err) {
logger.error(`Error processing mqtt message on topic ${topic}`, err);
}
2019-12-04 11:17:06 +01:00
});
}
reconnect() {
this.client.reconnect();
}
2021-02-20 15:33:06 +01:00
cleanup() {
this.client.removeAllListeners();
}
2021-01-29 21:25:34 +01:00
disconnect(callback) {
logger.info('Mqtt disconnect requested. Setting all devices as unavailable in HA...');
this.deviceRegistry.getAllOutputDevices().forEach((outputDevice) => {
const mqttType = outputDevice.type === 'switch' ? MQTT_TYPES.SWITCH : MQTT_TYPES.LIGHT;
this.client.publish(
getTopicName(outputDevice.uniqueId, mqttType, 'availability'),
AVAILABLILITY.OFFLINE,
{
retain: true,
qos: 1,
},
);
});
const allSceneDevices = this.deviceRegistry.getAllSceneDevices();
allSceneDevices.forEach((sceneDevice) => {
this.client.publish(
getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.AVAILABILITY),
AVAILABLILITY.OFFLINE,
{
retain: true,
qos: 1,
},
);
2021-01-29 21:25:34 +01:00
});
this.client.end(callback);
}
sendDiscoveryToHomeAssistant() {
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}).`,
);
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,
qos: 1,
},
);
setTimeout(() => {
this.client.publish(
getTopicName(outputDevice.uniqueId, mqttType, TOPIC_TYPES.AVAILABILITY),
AVAILABLILITY.ONLINE,
{
retain: true,
qos: 1,
},
);
}, 2000);
});
2019-12-04 11:17:06 +01:00
const allInputDevices = this.deviceRegistry.getAllInputDevices();
logger.info(`Sending discovery for ${allInputDevices.length} Plejd input devices`);
allInputDevices.forEach((inputDevice) => {
logger.debug(`Sending discovery for ${inputDevice.name}`);
const inputInputPayload = getInputDeviceTriggerDiscoveryPayload(inputDevice);
logger.info(
`Discovered ${inputDevice.typeName} (${inputDevice.type}) named ${inputDevice.name} (${inputDevice.bleInputAddress} : ${inputDevice.uniqueId}).`,
);
2021-05-05 19:34:34 +02:00
logger.verbose(
`Publishing ${getTopicName(
inputDevice.uniqueId,
MQTT_TYPES.DEVICE_AUTOMATION,
TOPIC_TYPES.CONFIG,
)} with payload ${JSON.stringify(inputInputPayload)}`,
);
2021-05-05 19:34:34 +02:00
this.client.publish(
getTopicName(inputDevice.uniqueId, MQTT_TYPES.DEVICE_AUTOMATION, TOPIC_TYPES.CONFIG),
JSON.stringify(inputInputPayload),
{
retain: true,
qos: 1,
},
);
});
const allSceneDevices = this.deviceRegistry.getAllSceneDevices();
logger.info(`Sending discovery for ${allSceneDevices.length} Plejd scene devices`);
allSceneDevices.forEach((sceneDevice) => {
logger.debug(`Sending discovery for ${sceneDevice.name}`);
2019-12-04 11:17:06 +01:00
2021-04-26 13:13:10 +02:00
const sceneConfigPayload = getSceneDiscoveryPayload(sceneDevice);
logger.info(
`Discovered ${sceneDevice.typeName} (${sceneDevice.type}) named ${sceneDevice.name} (${sceneDevice.bleOutputAddress} : ${sceneDevice.uniqueId}).`,
);
2019-12-13 14:13:00 +01:00
this.client.publish(
getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.CONFIG),
JSON.stringify(sceneConfigPayload),
{
retain: true,
qos: 1,
},
);
2021-04-26 13:13:10 +02:00
const sceneTriggerConfigPayload = getSceneDeviceTriggerhDiscoveryPayload(sceneDevice);
this.client.publish(
getTopicName(
getTriggerUniqueId(sceneDevice.uniqueId),
MQTT_TYPES.DEVICE_AUTOMATION,
TOPIC_TYPES.CONFIG,
2021-04-26 13:13:10 +02:00
),
JSON.stringify(sceneTriggerConfigPayload),
{
retain: true,
qos: 1,
},
);
2021-01-29 21:25:34 +01:00
setTimeout(() => {
this.client.publish(
getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.AVAILABILITY),
AVAILABLILITY.ONLINE,
{
retain: true,
qos: 1,
},
);
2021-01-29 21:25:34 +01:00
}, 2000);
2019-12-04 11:17:06 +01:00
});
}
/**
* @param {string} uniqueOutputId
* @param {{ state: boolean; brightness?: number; }} data
*/
updateOutputState(uniqueOutputId, data) {
const device = this.deviceRegistry.getOutputDevice(uniqueOutputId);
2019-12-04 11:17:06 +01:00
if (!device) {
logger.warn(`Unknown output id ${uniqueOutputId} - not handled by us.`);
2019-12-04 11:17:06 +01:00
return;
}
logger.verbose(
`Updating state for ${device.name}: ${data.state}${
data.brightness ? `, dim: ${data.brightness}` : ''
}`,
);
2019-12-21 15:01:15 +00:00
let payload = null;
2019-12-04 11:17:06 +01:00
if (device.type === 'switch') {
payload = getMqttStateString(data.state);
} else {
if (device.dimmable) {
payload = {
state: getMqttStateString(data.state),
brightness: data.brightness,
};
} else {
payload = {
state: getMqttStateString(data.state),
};
}
payload = JSON.stringify(payload);
2019-12-04 11:17:06 +01:00
}
const mqttType = device.type === 'switch' ? MQTT_TYPES.SWITCH : MQTT_TYPES.LIGHT;
this.client.publish(getTopicName(device.uniqueId, mqttType, TOPIC_TYPES.STATE), payload, {
retain: true,
qos: 1,
});
// this.client.publish(
// getTopicName(device.uniqueId, mqttType, TOPIC_TYPES.AVAILABILITY),
// AVAILABLILITY.ONLINE,
// { retain: true, qos: 1 },
// );
2019-12-04 11:17:06 +01:00
}
2019-12-22 17:48:16 +00:00
/**
* @param {string} deviceId
* @param {string} deviceInput
*/
buttonPressed(deviceId, deviceInput) {
logger.verbose(`Button ${deviceInput} pressed for deviceId ${deviceId}`);
this.client.publish(getButtonEventTopic(deviceId), `${deviceInput}`, { qos: 1 });
}
2021-05-05 19:34:34 +02:00
/**
* @param {string} sceneId
*/
sceneTriggered(sceneId) {
logger.verbose(`Scene triggered: ${sceneId}`);
2021-04-26 13:13:10 +02:00
this.client.publish(getSceneEventTopic(sceneId), '', { qos: 1 });
2019-12-22 17:48:16 +00:00
}
2019-12-04 11:17:06 +01:00
}
module.exports = MqttClient;