Merge pull request #1 from icanos/develop

Merge develop
This commit is contained in:
faanskit 2021-05-05 12:23:40 +02:00 committed by GitHub
commit a9f31d188d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1516 additions and 385 deletions

View file

@ -1,5 +1,20 @@
# Changelog hassio-plejd Home Assistant Plejd addon # Changelog hassio-plejd Home Assistant Plejd addon
## 0.8.0-dev
**BREAKING - READ BELOW FIRST**
Release 0.8 will break ALL EXISTING DEVICES. Unique mqtt id:s will change, meaning HA will create new devices. Scenes will be added as scenes not as switches.
Recommendations to minimize impact
- Optionally install MQTT explorer to bulk-delete discovered devices. If so - start MQTT explorer, connect, restart Plejd addon and then delete from MQTT explorer
- Shut down Plejd addon, disable autostart
- Reboot HA
- Go to Configuration => Integration => MQTT. Go to entities and after that devices and remove all Plejd devices (should be listed as unavailable)
- Upgrade addon to latest version and start
- All devices should now be back. With luck they will have the same HA id:s as before so most things should work. Room assignments, icons, etc will be gone though.
## [0.7.1](https://github.com/icanos/hassio-plejd/tree/0.7.1) (2021-03-25) ## [0.7.1](https://github.com/icanos/hassio-plejd/tree/0.7.1) (2021-03-25)
[Full Changelog](https://github.com/icanos/hassio-plejd/compare/0.7.0...0.7.1) [Full Changelog](https://github.com/icanos/hassio-plejd/compare/0.7.0...0.7.1)

View file

@ -1,9 +1,12 @@
const fs = require('fs'); const fs = require('fs');
class Configuration { class Configuration {
/** @type {import('types/Configuration').Options} */
static _options = null; static _options = null;
/** @type {import('types/Configuration').AddonInfo} */
static _addonInfo = null; static _addonInfo = null;
/** @returns Options */
static getOptions() { static getOptions() {
if (!Configuration._options) { if (!Configuration._options) {
Configuration._hydrateCache(); Configuration._hydrateCache();
@ -11,6 +14,7 @@ class Configuration {
return Configuration._options; return Configuration._options;
} }
/** @returns AddonInfo */
static getAddonInfo() { static getAddonInfo() {
if (!Configuration._addonInfo) { if (!Configuration._addonInfo) {
Configuration._hydrateCache(); Configuration._hydrateCache();
@ -20,10 +24,10 @@ class Configuration {
static _hydrateCache() { static _hydrateCache() {
const rawData = fs.readFileSync('/data/options.json'); const rawData = fs.readFileSync('/data/options.json');
const config = JSON.parse(rawData); const config = JSON.parse(rawData.toString());
const defaultRawData = fs.readFileSync('/plejd/config.json'); const defaultRawData = fs.readFileSync('/plejd/config.json');
const defaultConfig = JSON.parse(defaultRawData); const defaultConfig = JSON.parse(defaultRawData.toString());
Configuration._options = { ...defaultConfig.options, ...config }; Configuration._options = { ...defaultConfig.options, ...config };
Configuration._addonInfo = { Configuration._addonInfo = {

View file

@ -2,154 +2,195 @@ const Logger = require('./Logger');
const logger = Logger.getLogger('device-registry'); const logger = Logger.getLogger('device-registry');
class DeviceRegistry { class DeviceRegistry {
apiSite; /** @type {string} */
cryptoKey = null; cryptoKey = null;
deviceIdsByRoom = {}; /** @private @type {Object.<string, import('types/ApiSite').Device>} */
deviceIdsBySerial = {}; devices = {};
/** @private @type {Object.<string, string[]>} */
outputDeviceUniqueIdsByRoomId = {};
/** @private @type {Object.<number, string>} */
outputUniqueIdByBleOutputAddress = {};
/** @private @type {Object.<number, string>} */
sceneUniqueIdByBleOutputAddress = {};
/** @private @type {import('./types/ApiSite').ApiSite} */
apiSite;
// Dictionaries of [id]: device per type // Dictionaries of [id]: device per type
plejdDevices = {}; /** @private @type {import('types/DeviceRegistry').OutputDevices} */
roomDevices = {}; outputDevices = {};
/** @private @type {import('types/DeviceRegistry').OutputDevices} */
sceneDevices = {}; sceneDevices = {};
get allDevices() { /** @param device {import('./types/ApiSite').Device} */
return [ addPhysicalDevice(device) {
...Object.values(this.plejdDevices), this.devices[device.deviceId] = device;
...Object.values(this.roomDevices),
...Object.values(this.sceneDevices),
];
} }
addPlejdDevice(device) { /** @param outputDevice {import('types/DeviceRegistry').OutputDevice} */
const added = { addOutputDevice(outputDevice) {
...this.plejdDevices[device.id], if (outputDevice.hiddenFromIntegrations || outputDevice.hiddenFromRoomList) {
...device, logger.verbose(`Device ${outputDevice.name} is hidden and will not be included.
}; Hidden from room list: ${outputDevice.hiddenFromRoomList}
Hidden from integrations: ${outputDevice.hiddenFromIntegrations}`);
return;
}
this.plejdDevices = { this.outputDevices = {
...this.plejdDevices, ...this.outputDevices,
[added.id]: added, [outputDevice.uniqueId]: outputDevice,
}; };
this.deviceIdsBySerial[added.serialNumber] = added.id;
logger.verbose( logger.verbose(
`Added/updated device: ${JSON.stringify(added)}. ${ `Added/updated output device: ${JSON.stringify(outputDevice)}. ${
Object.keys(this.plejdDevices).length Object.keys(this.outputDevices).length
} plejd devices in total.`, } output devices in total.`,
); );
if (added.roomId) { this.outputUniqueIdByBleOutputAddress[outputDevice.bleOutputAddress] = outputDevice.uniqueId;
if (!this.deviceIdsByRoom[added.roomId]) {
this.deviceIdsByRoom[added.roomId] = []; if (!this.outputDeviceUniqueIdsByRoomId[outputDevice.roomId]) {
} this.outputDeviceUniqueIdsByRoomId[outputDevice.roomId] = [];
const room = this.deviceIdsByRoom[added.roomId]; }
if (!room.includes(added.id)) { if (
this.deviceIdsByRoom[added.roomId] = [...room, added.id]; outputDevice.roomId !== outputDevice.uniqueId
} && !this.outputDeviceUniqueIdsByRoomId[outputDevice.roomId].includes(outputDevice.uniqueId)
) {
this.outputDeviceUniqueIdsByRoomId[outputDevice.roomId].push(outputDevice.uniqueId);
logger.verbose( logger.verbose(
`Added device to room ${added.roomId}: ${JSON.stringify( `Added device to room ${outputDevice.roomId}: ${JSON.stringify(
this.deviceIdsByRoom[added.roomId], this.outputDeviceUniqueIdsByRoomId[outputDevice.roomId],
)}`, )}`,
); );
} }
return added;
}
addRoomDevice(device) {
const added = {
...this.roomDevices[device.id],
...device,
};
this.roomDevices = {
...this.roomDevices,
[added.id]: added,
};
logger.verbose(
`Added/updated room device: ${JSON.stringify(added)}. ${
Object.keys(this.roomDevices).length
} room devices total.`,
);
return added;
} }
/** @param scene {import('types/DeviceRegistry').OutputDevice} */
addScene(scene) { addScene(scene) {
const added = {
...this.sceneDevices[scene.id],
...scene,
};
this.sceneDevices = { this.sceneDevices = {
...this.sceneDevices, ...this.sceneDevices,
[added.id]: added, [scene.uniqueId]: scene,
}; };
this.sceneUniqueIdByBleOutputAddress[scene.bleOutputAddress] = scene.uniqueId;
logger.verbose( logger.verbose(
`Added/updated scene: ${JSON.stringify(added)}. ${ `Added/updated scene: ${JSON.stringify(scene)}. ${
Object.keys(this.sceneDevices).length Object.keys(this.sceneDevices).length
} scenes in total.`, } scenes in total.`,
); );
return added;
} }
clearPlejdDevices() { clearPlejdDevices() {
this.plejdDevices = {}; this.devices = {};
this.deviceIdsByRoom = {}; this.outputDevices = {};
this.deviceIdsBySerial = {}; this.outputDeviceUniqueIdsByRoomId = {};
} this.outputUniqueIdByBleOutputAddress = {};
clearRoomDevices() {
this.roomDevices = {};
} }
clearSceneDevices() { clearSceneDevices() {
this.sceneDevices = {}; this.sceneDevices = {};
this.sceneUniqueIdByBleOutputAddress = {};
} }
getDevice(deviceId) { /**
return this.plejdDevices[deviceId] || this.roomDevices[deviceId]; * @returns {import('./types/DeviceRegistry').OutputDevice[]}
*/
getAllOutputDevices() {
return Object.values(this.outputDevices);
} }
getDeviceIdsByRoom(roomId) { /**
return this.deviceIdsByRoom[roomId]; * @returns {import('./types/DeviceRegistry').OutputDevice[]}
*/
getAllSceneDevices() {
return Object.values(this.sceneDevices);
} }
getDeviceBySerialNumber(serialNumber) { /** @returns {import('./types/ApiSite').ApiSite} */
return this.getDevice(this.deviceIdsBySerial[serialNumber]); getApiSite() {
return this.apiSite;
} }
getDeviceName(deviceId) { /**
return (this.plejdDevices[deviceId] || {}).name; * @param {string} uniqueOutputId
*/
getOutputDevice(uniqueOutputId) {
return this.outputDevices[uniqueOutputId];
} }
getScene(sceneId) { /** @returns {import('./types/DeviceRegistry').OutputDevice} */
return this.sceneDevices[sceneId]; getOutputDeviceByBleOutputAddress(bleOutputAddress) {
return this.outputDevices[this.outputUniqueIdByBleOutputAddress[bleOutputAddress]];
} }
getSceneName(sceneId) { /** @returns {string[]} */
return (this.sceneDevices[sceneId] || {}).name; getOutputDeviceIdsByRoomId(roomId) {
return this.outputDeviceUniqueIdsByRoomId[roomId];
} }
getState(deviceId) { getOutputDeviceName(uniqueOutputId) {
const device = this.getDevice(deviceId) || {}; return (this.outputDevices[uniqueOutputId] || {}).name;
if (device.dimmable) { }
return {
state: device.state, /**
dim: device.dim, * @param {string } deviceId The physical device serial number
}; * @return {import('./types/ApiSite').Device}
*/
getPhysicalDevice(deviceId) {
return this.devices[deviceId];
}
/**
* @param {string} sceneUniqueId
*/
getScene(sceneUniqueId) {
return this.sceneDevices[sceneUniqueId];
}
/**
* @param {number} sceneBleAddress
*/
getSceneByBleAddress(sceneBleAddress) {
const sceneUniqueId = this.sceneUniqueIdByBleOutputAddress[sceneBleAddress];
if (!sceneUniqueId) {
return null;
} }
return { return this.sceneDevices[sceneUniqueId];
state: device.state,
};
} }
setApiSite(siteDetails) { /**
this.apiSite = siteDetails; * @param {string} sceneUniqueId
*/
getSceneName(sceneUniqueId) {
return (this.sceneDevices[sceneUniqueId] || {}).name;
} }
setState(deviceId, state, dim) { // eslint-disable-next-line class-methods-use-this
const device = this.getDevice(deviceId) || this.addPlejdDevice({ id: deviceId }); getUniqueOutputId(deviceId, outputIndex) {
return `${deviceId}_${outputIndex}`;
}
/** @param apiSite {import('./types/ApiSite').ApiSite} */
setApiSite(apiSite) {
this.apiSite = apiSite;
this.cryptoKey = apiSite.plejdMesh.cryptoKey;
}
/**
* @param {string} uniqueOutputId
* @param {boolean} state
* @param {number?} [dim]
*/
setOutputState(uniqueOutputId, state, dim) {
const device = this.getOutputDevice(uniqueOutputId);
if (!device) {
logger.warn(
`Trying to set state for ${uniqueOutputId} which is not in the list of known outputs.`,
);
return;
}
device.state = state; device.state = state;
if (dim && device.dimmable) { if (dim && device.dimmable) {
device.dim = dim; device.dim = dim;

View file

@ -8,18 +8,36 @@ const startTopics = ['hass/status', 'homeassistant/status'];
const logger = Logger.getLogger('plejd-mqtt'); const logger = Logger.getLogger('plejd-mqtt');
// #region discovery
const discoveryPrefix = 'homeassistant'; const discoveryPrefix = 'homeassistant';
const nodeId = 'plejd'; 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 = {
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}`;
const getTriggerUniqueId = (/** @type { string } */ uniqueId) => `${uniqueId}_trigger`;
const getSceneEventTopic = (/** @type {string} */ sceneId) => `${getTopicName(getTriggerUniqueId(sceneId), MQTT_TYPES.DEVICE_AUTOMATION, TOPIC_TYPES.STATE)}`;
const getSubscribePath = () => `${discoveryPrefix}/+/${nodeId}/#`; const getSubscribePath = () => `${discoveryPrefix}/+/${nodeId}/#`;
const getPath = ({ id, type }) => `${discoveryPrefix}/${type}/${nodeId}/${id}`;
const getConfigPath = (plug) => `${getPath(plug)}/config`;
const getStateTopic = (plug) => `${getPath(plug)}/state`;
const getAvailabilityTopic = (plug) => `${getPath(plug)}/availability`;
const getCommandTopic = (plug) => `${getPath(plug)}/set`;
const getSceneEventTopic = () => 'plejd/event/scene';
const decodeTopicRegexp = new RegExp( const decodeTopicRegexp = new RegExp(
/(?<prefix>[^[]+)\/(?<type>.+)\/plejd\/(?<id>.+)\/(?<command>config|state|availability|set|scene)/, /(?<prefix>[^[]+)\/(?<type>.+)\/plejd\/(?<id>.+)\/(?<command>config|state|availability|set|scene)/,
@ -33,41 +51,63 @@ const decodeTopic = (topic) => {
return matches.groups; return matches.groups;
}; };
const getDiscoveryPayload = (device) => ({ const getOutputDeviceDiscoveryPayload = (
schema: 'json', /** @type {import('./types/DeviceRegistry').OutputDevice} */ device,
) => ({
name: device.name, name: device.name,
unique_id: `light.plejd.${device.name.toLowerCase().replace(/ /g, '')}`, unique_id: device.uniqueId,
state_topic: getStateTopic(device), '~': getBaseTopic(device.uniqueId, device.type),
command_topic: getCommandTopic(device), state_topic: `~/${TOPIC_TYPES.STATE}`,
availability_topic: getAvailabilityTopic(device), command_topic: `~/${TOPIC_TYPES.COMMAND}`,
availability_topic: `~/${TOPIC_TYPES.AVAILABILITY}`,
optimistic: false, optimistic: false,
brightness: `${device.dimmable}`, qos: 1,
retain: true,
device: { device: {
identifiers: `${device.serialNumber}_${device.id}`, identifiers: `${device.deviceId}`,
manufacturer: 'Plejd', manufacturer: 'Plejd',
model: device.typeName, model: device.typeName,
name: device.name, name: device.name,
sw_version: device.version, sw_version: device.version,
}, },
...(device.type === MQTT_TYPES.LIGHT ? { brightness: device.dimmable, schema: 'json' } : {}),
}); });
const getSwitchPayload = (device) => ({ const getSceneDiscoveryPayload = (
name: device.name, /** @type {import('./types/DeviceRegistry').OutputDevice} */ sceneDevice,
state_topic: getStateTopic(device), ) => ({
command_topic: getCommandTopic(device), name: sceneDevice.name,
optimistic: false, 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,
});
const getSceneDeviceTriggerhDiscoveryPayload = (
/** @type {import('./types/DeviceRegistry').OutputDevice} */ sceneDevice,
) => ({
automation_type: 'trigger',
'~': getBaseTopic(sceneDevice.uniqueId, MQTT_TYPES.DEVICE_AUTOMATION),
qos: 1,
topic: `~/${TOPIC_TYPES.STATE}`,
type: 'scene',
subtype: 'trigger',
device: { device: {
identifiers: `${device.serialNumber}_${device.id}`, identifiers: `${sceneDevice.uniqueId}`,
manufacturer: 'Plejd', manufacturer: 'Plejd',
model: device.typeName, model: sceneDevice.typeName,
name: device.name, name: sceneDevice.name,
sw_version: device.version,
}, },
}); });
// #endregion const getMqttStateString = (/** @type {boolean} */ state) => (state ? 'ON' : 'OFF');
const AVAILABLILITY = { ONLINE: 'online', OFFLINE: 'offline' };
class MqttClient extends EventEmitter { class MqttClient extends EventEmitter {
/** @type {import('DeviceRegistry')} */
deviceRegistry; deviceRegistry;
static EVENTS = { static EVENTS = {
@ -75,6 +115,9 @@ class MqttClient extends EventEmitter {
stateChanged: 'stateChanged', stateChanged: 'stateChanged',
}; };
/**
* @param {import("DeviceRegistry")} deviceRegistry
*/
constructor(deviceRegistry) { constructor(deviceRegistry) {
super(); super();
@ -86,8 +129,11 @@ 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, {
username: this.config.mqttUsername, 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
queueQoSZero: true,
username: this.config.mqttUsername,
}); });
this.client.on('error', (err) => { this.client.on('error', (err) => {
@ -97,13 +143,22 @@ 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(startTopics, (err) => { this.client.subscribe(
if (err) { startTopics,
logger.error('Unable to subscribe to status topics', err); // 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);
}
this.emit(MqttClient.EVENTS.connected); this.emit(MqttClient.EVENTS.connected);
}); },
);
this.client.subscribe(getSubscribePath(), (err) => { this.client.subscribe(getSubscribePath(), (err) => {
if (err) { if (err) {
@ -123,27 +178,24 @@ class MqttClient extends EventEmitter {
logger.info('Home Assistant has started. lets do discovery.'); logger.info('Home Assistant has started. lets do discovery.');
this.emit(MqttClient.EVENTS.connected); this.emit(MqttClient.EVENTS.connected);
} else { } else {
logger.verbose(`Received mqtt message on ${topic}`);
const decodedTopic = decodeTopic(topic); const decodedTopic = decodeTopic(topic);
if (decodedTopic) { if (decodedTopic) {
let device = this.deviceRegistry.getDevice(decodedTopic.id); /** @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 messageString = message.toString();
const isJsonMessage = messageString.startsWith('{'); const isJsonMessage = messageString.startsWith('{');
const command = isJsonMessage ? JSON.parse(messageString) : messageString; const command = isJsonMessage ? JSON.parse(messageString) : messageString;
if (
!isJsonMessage
&& messageString === 'ON'
&& this.deviceRegistry.getScene(decodedTopic.id)
) {
// Guess that id that got state command without dim value belongs to Scene, not Device
// This guess could very well be wrong depending on the installation...
logger.warn(
`Device id ${decodedTopic.id} belongs to both scene and device, guessing Scene is what should be set to ON. `
+ 'OFF commands still sent to device.',
);
device = this.deviceRegistry.getScene(decodedTopic.id);
}
const deviceName = device ? device.name : ''; const deviceName = device ? device.name : '';
switch (decodedTopic.command) { switch (decodedTopic.command) {
@ -195,35 +247,121 @@ class MqttClient extends EventEmitter {
} }
disconnect(callback) { disconnect(callback) {
this.deviceRegistry.allDevices.forEach((device) => { logger.info('Mqtt disconnect requested. Setting all devices as unavailable in HA...');
this.client.publish(getAvailabilityTopic(device), 'offline'); 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,
},
);
}); });
this.client.end(callback); this.client.end(callback);
} }
sendDiscoveryToHomeAssistant() { sendDiscoveryToHomeAssistant() {
logger.debug(`Sending discovery of ${this.deviceRegistry.allDevices.length} device(s).`); 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}`);
this.deviceRegistry.allDevices.forEach((device) => { const configPayload = getOutputDeviceDiscoveryPayload(outputDevice);
logger.debug(`Sending discovery for ${device.name}`);
const payload = device.type === 'switch' ? getSwitchPayload(device) : getDiscoveryPayload(device);
logger.info( logger.info(
`Discovered ${device.type} (${device.typeName}) named ${device.name} with PID ${device.id}.`, `Discovered ${outputDevice.typeName} (${outputDevice.type}) named ${outputDevice.name} (${outputDevice.bleOutputAddress} : ${outputDevice.uniqueId}).`,
); );
this.client.publish(getConfigPath(device), JSON.stringify(payload)); 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(() => { setTimeout(() => {
this.client.publish(getAvailabilityTopic(device), 'online'); this.client.publish(
getTopicName(outputDevice.uniqueId, mqttType, TOPIC_TYPES.AVAILABILITY),
AVAILABLILITY.ONLINE,
{
retain: true,
qos: 1,
},
);
}, 2000);
});
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}`);
const sceneConfigPayload = getSceneDiscoveryPayload(sceneDevice);
logger.info(
`Discovered ${sceneDevice.typeName} (${sceneDevice.type}) named ${sceneDevice.name} (${sceneDevice.bleOutputAddress} : ${sceneDevice.uniqueId}).`,
);
this.client.publish(
getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.CONFIG),
JSON.stringify(sceneConfigPayload),
{
retain: true,
qos: 1,
},
);
const sceneTriggerConfigPayload = getSceneDeviceTriggerhDiscoveryPayload(sceneDevice);
this.client.publish(
getTopicName(
getTriggerUniqueId(sceneDevice.uniqueId),
MQTT_TYPES.DEVICE_AUTOMATION,
TOPIC_TYPES.CONFIG,
),
JSON.stringify(sceneTriggerConfigPayload),
{
retain: true,
qos: 1,
},
);
setTimeout(() => {
this.client.publish(
getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.AVAILABILITY),
AVAILABLILITY.ONLINE,
{
retain: true,
qos: 1,
},
);
}, 2000); }, 2000);
}); });
} }
updateState(deviceId, data) { /**
const device = this.deviceRegistry.getDevice(deviceId); * @param {string} uniqueOutputId
* @param {{ state: boolean; brightness?: number; }} data
*/
updateOutputState(uniqueOutputId, data) {
const device = this.deviceRegistry.getOutputDevice(uniqueOutputId);
if (!device) { if (!device) {
logger.warn(`Unknown device id ${deviceId} - not handled by us.`); logger.warn(`Unknown output id ${uniqueOutputId} - not handled by us.`);
return; return;
} }
@ -235,29 +373,40 @@ class MqttClient extends EventEmitter {
let payload = null; let payload = null;
if (device.type === 'switch') { if (device.type === 'switch') {
payload = data.state === 1 ? 'ON' : 'OFF'; payload = getMqttStateString(data.state);
} else { } else {
if (device.dimmable) { if (device.dimmable) {
payload = { payload = {
state: data.state === 1 ? 'ON' : 'OFF', state: getMqttStateString(data.state),
brightness: data.brightness, brightness: data.brightness,
}; };
} else { } else {
payload = { payload = {
state: data.state === 1 ? 'ON' : 'OFF', state: getMqttStateString(data.state),
}; };
} }
payload = JSON.stringify(payload); payload = JSON.stringify(payload);
} }
this.client.publish(getStateTopic(device), payload); const mqttType = device.type === 'switch' ? MQTT_TYPES.SWITCH : MQTT_TYPES.LIGHT;
this.client.publish(getAvailabilityTopic(device), 'online'); 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 },
// );
} }
/**
* @param {string} sceneId
*/
sceneTriggered(sceneId) { sceneTriggered(sceneId) {
logger.verbose(`Scene triggered: ${sceneId}`); logger.verbose(`Scene triggered: ${sceneId}`);
this.client.publish(getSceneEventTopic(), JSON.stringify({ scene: sceneId })); this.client.publish(getSceneEventTopic(sceneId), '', { qos: 1 });
} }
} }

View file

@ -65,57 +65,61 @@ class PlejdAddon extends EventEmitter {
}); });
// subscribe to changes from HA // subscribe to changes from HA
this.mqttClient.on(MqttClient.EVENTS.stateChanged, (device, command) => { this.mqttClient.on(
try { MqttClient.EVENTS.stateChanged,
const deviceId = device.id; /** @param device {import('./types/DeviceRegistry').OutputDevice} */
(device, command) => {
try {
const { uniqueId } = device;
if (device.typeName === 'Scene') { if (device.typeName === 'Scene') {
// we're triggering a scene, lets do that and jump out. // we're triggering a scene, lets do that and jump out.
// since scenes aren't "real" devices. // since scenes aren't "real" devices.
this.sceneManager.executeScene(device.id); this.sceneManager.executeScene(uniqueId);
return; return;
}
let state = false;
let commandObj = {};
if (typeof command === 'string') {
// switch command
state = command === 'ON';
commandObj = {
state,
};
// since the switch doesn't get any updates on whether it's on or not,
// we fake this by directly send the updateState back to HA in order for
// it to change state.
this.mqttClient.updateOutputState(uniqueId, {
state,
});
} else {
// eslint-disable-next-line prefer-destructuring
state = command.state === 'ON';
commandObj = command;
}
if (state) {
this.plejdDeviceCommunication.turnOn(uniqueId, commandObj);
} else {
this.plejdDeviceCommunication.turnOff(uniqueId, commandObj);
}
} catch (err) {
logger.error('Error in MqttClient.stateChanged callback', err);
} }
},
let state = 'OFF'; );
let commandObj = {};
if (typeof command === 'string') {
// switch command
state = command;
commandObj = {
state,
};
// since the switch doesn't get any updates on whether it's on or not,
// we fake this by directly send the updateState back to HA in order for
// it to change state.
this.mqttClient.updateState(deviceId, {
state: state === 'ON' ? 1 : 0,
});
} else {
// eslint-disable-next-line prefer-destructuring
state = command.state;
commandObj = command;
}
if (state === 'ON') {
this.plejdDeviceCommunication.turnOn(deviceId, commandObj);
} else {
this.plejdDeviceCommunication.turnOff(deviceId, commandObj);
}
} catch (err) {
logger.error('Error in MqttClient.stateChanged callback', err);
}
});
this.mqttClient.init(); this.mqttClient.init();
// subscribe to changes from Plejd // subscribe to changes from Plejd
this.plejdDeviceCommunication.on( this.plejdDeviceCommunication.on(
PlejdDeviceCommunication.EVENTS.stateChanged, PlejdDeviceCommunication.EVENTS.stateChanged,
(deviceId, command) => { (uniqueOutputId, command) => {
try { try {
this.mqttClient.updateState(deviceId, command); this.mqttClient.updateOutputState(uniqueOutputId, command);
} catch (err) { } catch (err) {
logger.error('Error in PlejdService.stateChanged callback', err); logger.error('Error in PlejdService.stateChanged callback', err);
} }

View file

@ -10,15 +10,33 @@ const API_LOGIN_URL = 'login';
const API_SITE_LIST_URL = 'functions/getSiteList'; const API_SITE_LIST_URL = 'functions/getSiteList';
const API_SITE_DETAILS_URL = 'functions/getSiteById'; const API_SITE_DETAILS_URL = 'functions/getSiteById';
const TRAITS = {
NO_LOAD: 0,
NON_DIMMABLE: 9,
DIMMABLE: 11,
};
const logger = Logger.getLogger('plejd-api'); const logger = Logger.getLogger('plejd-api');
class PlejdApi { class PlejdApi {
/** @private @type {import('types/Configuration').Options} */
config; config;
/** @private @type {import('DeviceRegistry')} */
deviceRegistry; deviceRegistry;
/** @private @type {string} */
sessionToken; sessionToken;
/** @private @type {string} */
siteId; siteId;
/** @private @type {import('types/ApiSite').ApiSite} */
siteDetails; siteDetails;
/**
* @param {import("./DeviceRegistry")} deviceRegistry
*/
constructor(deviceRegistry) { constructor(deviceRegistry) {
this.config = Configuration.getOptions(); this.config = Configuration.getOptions();
this.deviceRegistry = deviceRegistry; this.deviceRegistry = deviceRegistry;
@ -58,19 +76,19 @@ class PlejdApi {
} }
} }
} }
this.deviceRegistry.setApiSite(this.siteDetails);
this.deviceRegistry.cryptoKey = this.siteDetails.plejdMesh.cryptoKey;
this.deviceRegistry.setApiSite(this.siteDetails);
this.getDevices(); this.getDevices();
} }
/** @returns {Promise<import('types/ApiSite').CachedSite>} */
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
async getCachedCopy() { async getCachedCopy() {
logger.info('Getting cached api response from disk'); logger.info('Getting cached api response from disk');
try { try {
const rawData = await fs.promises.readFile('/data/cachedApiResponse.json'); const rawData = await fs.promises.readFile('/data/cachedApiResponse.json');
const cachedCopy = JSON.parse(rawData); const cachedCopy = JSON.parse(rawData.toString());
return cachedCopy; return cachedCopy;
} catch (err) { } catch (err) {
@ -82,12 +100,14 @@ class PlejdApi {
async saveCachedCopy() { async saveCachedCopy() {
logger.info('Saving cached copy'); logger.info('Saving cached copy');
try { try {
const rawData = JSON.stringify({ /** @type {import('types/ApiSite').CachedSite} */
const cachedSite = {
siteId: this.siteId, siteId: this.siteId,
siteDetails: this.siteDetails, siteDetails: this.siteDetails,
sessionToken: this.sessionToken, sessionToken: this.sessionToken,
dtCache: new Date().toISOString(), dtCache: new Date().toISOString(),
}); };
const rawData = JSON.stringify(cachedSite);
await fs.promises.writeFile('/data/cachedApiResponse.json', rawData); await fs.promises.writeFile('/data/cachedApiResponse.json', rawData);
} catch (err) { } catch (err) {
logger.error('Failed to save cache of api response', err); logger.error('Failed to save cache of api response', err);
@ -194,6 +214,14 @@ class PlejdApi {
getDevices() { getDevices() {
logger.info('Getting devices from site details response...'); logger.info('Getting devices from site details response...');
if (this.siteDetails.gateways && this.siteDetails.gateways.length) {
this.siteDetails.gateways.forEach((gwy) => {
logger.info(`Plejd gateway '${gwy.title}' found on site`);
});
} else {
logger.info('No Plejd gateway found on site');
}
this._getPlejdDevices(); this._getPlejdDevices();
this._getRoomDevices(); this._getRoomDevices();
this._getSceneDevices(); this._getSceneDevices();
@ -216,8 +244,11 @@ class PlejdApi {
} }
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
_getDeviceType(hardwareId) { _getDeviceType(plejdDevice) {
switch (parseInt(hardwareId, 10)) { // Type name is also sometimes available in device.hardware.name
// (maybe only when GWY-01 is present?)
switch (parseInt(plejdDevice.hardwareId, 10)) {
case 1: case 1:
case 11: case 11:
return { name: 'DIM-01', type: 'light', dimmable: true }; return { name: 'DIM-01', type: 'light', dimmable: true };
@ -259,69 +290,115 @@ class PlejdApi {
case 20: case 20:
return { name: 'SPR-01', type: 'switch', dimmable: false }; return { name: 'SPR-01', type: 'switch', dimmable: false };
default: default:
throw new Error(`Unknown device type with id ${hardwareId}`); throw new Error(`Unknown device type with id ${plejdDevice.hardwareId}`);
} }
} }
/**
* Plejd API properties parsed
*
* * `devices` - physical Plejd devices, duplicated for devices with multiple outputs
* devices: [{deviceId, title, objectId, ...}, {...}]
* * `deviceAddress` - BLE address of each physical device
* deviceAddress: {[deviceId]: bleDeviceAddress}
* * `outputSettings` - lots of info about load settings, also links devices to output index
* outputSettings: [{deviceId, output, deviceParseId, ...}] //deviceParseId === objectId above
* * `outputAddress`: BLE address of [0] main output and [n] other output (loads)
* outputAddress: {[deviceId]: {[output]: bleDeviceAddress}}
* * `inputSettings` - detailed settings for inputs (buttons, RTR-01, ...), scenes triggered, ...
* inputSettings: [{deviceId, input, ...}] //deviceParseId === objectId above
* * `inputAddress` - Links inputs to what BLE device they control, or 255 for unassigned/scene
* inputAddress: {[deviceId]: {[input]: bleDeviceAddress}}
*/
_getPlejdDevices() { _getPlejdDevices() {
this.deviceRegistry.clearPlejdDevices(); this.deviceRegistry.clearPlejdDevices();
this.siteDetails.devices.forEach((device) => { this.siteDetails.devices.forEach((device) => {
const { deviceId } = device; this.deviceRegistry.addPhysicalDevice(device);
const settings = this.siteDetails.outputSettings.find( const outputSettings = this.siteDetails.outputSettings.find(
(x) => x.deviceParseId === device.objectId, (x) => x.deviceParseId === device.objectId,
); );
let deviceNum = this.siteDetails.deviceAddress[deviceId]; if (!outputSettings) {
logger.verbose(
`No outputSettings found for ${device.title} (${device.deviceId}), assuming output 0`,
);
}
const deviceOutput = outputSettings ? outputSettings.output : 0;
const outputAddress = this.siteDetails.outputAddress[device.deviceId];
if (settings) { if (outputAddress) {
const outputs = this.siteDetails.outputAddress[deviceId]; const bleOutputAddress = outputAddress[deviceOutput];
deviceNum = outputs[settings.output];
if (device.traits === TRAITS.NO_LOAD) {
logger.warn(
`Device ${device.title} (${device.deviceId}) has no load configured and will be excluded`,
);
} else {
const uniqueOutputId = this.deviceRegistry.getUniqueOutputId(
device.deviceId,
deviceOutput,
);
const plejdDevice = this.siteDetails.plejdDevices.find(
(x) => x.deviceId === device.deviceId,
);
const dimmable = device.traits === TRAITS.DIMMABLE;
// dimmable = settings.dimCurve !== 'NonDimmable';
const { name: typeName, type: deviceType } = this._getDeviceType(plejdDevice);
let loadType = deviceType;
if (device.outputType === 'RELAY') {
loadType = 'switch';
} else if (device.outputType === 'LIGHT') {
loadType = 'light';
}
/** @type {import('types/DeviceRegistry').OutputDevice} */
const outputDevice = {
bleOutputAddress,
deviceId: device.deviceId,
dimmable,
hiddenFromRoomList: device.hiddenFromRoomList,
hiddenFromIntegrations: device.hiddenFromIntegrations,
name: device.title,
output: deviceOutput,
roomId: device.roomId,
state: undefined,
type: loadType,
typeName,
version: plejdDevice.firmware.version,
uniqueId: uniqueOutputId,
};
this.deviceRegistry.addOutputDevice(outputDevice);
}
} }
// check if device is dimmable // What should we do with inputs?!
const plejdDevice = this.siteDetails.plejdDevices.find((x) => x.deviceId === deviceId); // if (outputDevice.typeName === 'WPH-01') {
const deviceType = this._getDeviceType(plejdDevice.hardwareId); // // WPH-01 is special, it has two buttons which needs to be
const { name, type } = deviceType; // // registered separately.
let { dimmable } = deviceType; // const inputs = this.siteDetails.inputAddress[deviceId];
// const first = inputs[0];
// const second = inputs[1];
if (settings) { // this.deviceRegistry.addPlejdDevice({
dimmable = settings.dimCurve !== 'NonDimmable'; // ...outputDevice,
} // id: first,
// name: `${device.title} left`,
// });
const newDevice = { // this.deviceRegistry.addPlejdDevice({
id: deviceNum, // ...outputDevice,
name: device.title, // id: second,
type, // name: `${device.title} right`,
typeName: name, // });
dimmable, // } else {
roomId: device.roomId, // this.deviceRegistry.addPlejdDevice(outputDevice);
version: plejdDevice.firmware.version, // }
serialNumber: plejdDevice.deviceId,
};
if (newDevice.typeName === 'WPH-01') {
// WPH-01 is special, it has two buttons which needs to be
// registered separately.
const inputs = this.siteDetails.inputAddress[deviceId];
const first = inputs[0];
const second = inputs[1];
this.deviceRegistry.addPlejdDevice({
...newDevice,
id: first,
name: `${device.title} left`,
});
this.deviceRegistry.addPlejdDevice({
...newDevice,
id: second,
name: `${device.title} right`,
});
} else {
this.deviceRegistry.addPlejdDevice(newDevice);
}
}); });
} }
@ -332,39 +409,57 @@ class PlejdApi {
const { roomId } = room; const { roomId } = room;
const roomAddress = this.siteDetails.roomAddress[roomId]; const roomAddress = this.siteDetails.roomAddress[roomId];
const deviceIdsByRoom = this.deviceRegistry.getDeviceIdsByRoom(roomId); const deviceIdsByRoom = this.deviceRegistry.getOutputDeviceIdsByRoomId(roomId);
const dimmable = deviceIdsByRoom const dimmable = deviceIdsByRoom
&& deviceIdsByRoom.some((deviceId) => this.deviceRegistry.getDevice(deviceId).dimmable); && deviceIdsByRoom.some(
(deviceId) => this.deviceRegistry.getOutputDevice(deviceId).dimmable,
);
/** @type {import('types/DeviceRegistry').OutputDevice} */
const newDevice = { const newDevice = {
id: roomAddress, bleOutputAddress: roomAddress,
deviceId: null,
dimmable,
hiddenFromRoomList: false,
hiddenFromIntegrations: false,
name: room.title, name: room.title,
output: undefined,
roomId,
state: undefined,
type: 'light', type: 'light',
typeName: 'Room', typeName: 'Room',
dimmable, uniqueId: roomId,
version: undefined,
}; };
this.deviceRegistry.addRoomDevice(newDevice); this.deviceRegistry.addOutputDevice(newDevice);
}); });
logger.debug('includeRoomsAsLights done.'); logger.debug('includeRoomsAsLights done.');
} }
} }
_getSceneDevices() { _getSceneDevices() {
this.deviceRegistry.clearSceneDevices();
// add scenes as switches // add scenes as switches
const scenes = this.siteDetails.scenes.filter((x) => x.hiddenFromSceneList === false); const scenes = this.siteDetails.scenes.filter((x) => x.hiddenFromSceneList === false);
scenes.forEach((scene) => { scenes.forEach((scene) => {
const sceneNum = this.siteDetails.sceneIndex[scene.sceneId]; const sceneNum = this.siteDetails.sceneIndex[scene.sceneId];
/** @type {import('types/DeviceRegistry').OutputDevice} */
const newScene = { const newScene = {
id: sceneNum, bleOutputAddress: sceneNum,
name: scene.title, deviceId: undefined,
type: 'switch',
typeName: 'Scene',
dimmable: false, dimmable: false,
version: '1.0', hiddenFromSceneList: scene.hiddenFromSceneList,
serialNumber: scene.objectId, name: scene.title,
output: undefined,
roomId: undefined,
state: false,
type: 'scene',
typeName: 'Scene',
version: undefined,
uniqueId: scene.sceneId,
}; };
this.deviceRegistry.addScene(newScene); this.deviceRegistry.addScene(newScene);

View file

@ -1,7 +1,7 @@
const dbus = require('dbus-next'); const dbus = require('dbus-next');
const crypto = require('crypto'); const crypto = require('crypto');
const xor = require('buffer-xor'); const xor = require('buffer-xor');
const EventEmitter = require('events'); const { EventEmitter } = require('events');
const Configuration = require('./Configuration'); const Configuration = require('./Configuration');
const constants = require('./constants'); const constants = require('./constants');
@ -49,6 +49,8 @@ class PlejBLEHandler extends EventEmitter {
connectedDevice = null; connectedDevice = null;
consecutiveWriteFails; consecutiveWriteFails;
consecutiveReconnectAttempts = 0; consecutiveReconnectAttempts = 0;
/** @type {import('./DeviceRegistry')} */
deviceRegistry;
discoveryTimeout = null; discoveryTimeout = null;
plejdService = null; plejdService = null;
pingRef = null; pingRef = null;
@ -152,21 +154,26 @@ class PlejBLEHandler extends EventEmitter {
logger.info('BLE init done, waiting for devices.'); logger.info('BLE init done, waiting for devices.');
} }
async sendCommand(command, deviceId, data) { /**
* @param {string} command
* @param {number} bleOutputAddress
* @param {number} data
*/
async sendCommand(command, bleOutputAddress, data) {
let payload; let payload;
let brightnessVal; let brightnessVal;
switch (command) { switch (command) {
case COMMANDS.TURN_ON: case COMMANDS.TURN_ON:
payload = this._createHexPayload(deviceId, BLE_CMD_STATE_CHANGE, '01'); payload = this._createHexPayload(bleOutputAddress, BLE_CMD_STATE_CHANGE, '01');
break; break;
case COMMANDS.TURN_OFF: case COMMANDS.TURN_OFF:
payload = this._createHexPayload(deviceId, BLE_CMD_STATE_CHANGE, '00'); payload = this._createHexPayload(bleOutputAddress, BLE_CMD_STATE_CHANGE, '00');
break; break;
case COMMANDS.DIM: case COMMANDS.DIM:
// eslint-disable-next-line no-bitwise // eslint-disable-next-line no-bitwise
brightnessVal = (data << 8) | data; brightnessVal = (data << 8) | data;
payload = this._createHexPayload( payload = this._createHexPayload(
deviceId, bleOutputAddress,
BLE_CMD_DIM2_CHANGE, BLE_CMD_DIM2_CHANGE,
`01${brightnessVal.toString(16).padStart(4, '0')}`, `01${brightnessVal.toString(16).padStart(4, '0')}`,
); );
@ -194,9 +201,9 @@ class PlejBLEHandler extends EventEmitter {
plejd.instance = device; plejd.instance = device;
const segments = plejd.path.split('/'); const segments = plejd.path.split('/');
let fixedPlejdPath = segments[segments.length - 1].replace('dev_', ''); let plejdSerialNumber = segments[segments.length - 1].replace('dev_', '');
fixedPlejdPath = fixedPlejdPath.replace(/_/g, ''); plejdSerialNumber = plejdSerialNumber.replace(/_/g, '');
plejd.device = this.deviceRegistry.getDeviceBySerialNumber(fixedPlejdPath); plejd.device = this.deviceRegistry.getPhysicalDevice(plejdSerialNumber);
if (plejd.device) { if (plejd.device) {
logger.debug( logger.debug(
@ -204,7 +211,7 @@ class PlejBLEHandler extends EventEmitter {
); );
this.bleDevices.push(plejd); this.bleDevices.push(plejd);
} else { } else {
logger.warn(`Device registry does not contain device with serial ${fixedPlejdPath}`); logger.warn(`Device registry does not contain device with serial ${plejdSerialNumber}`);
} }
} catch (err) { } catch (err) {
logger.error(`Failed inspecting ${path}. `, err); logger.error(`Failed inspecting ${path}. `, err);
@ -796,7 +803,7 @@ class PlejBLEHandler extends EventEmitter {
return; return;
} }
const deviceId = decoded.readUInt8(0); const bleOutputAddress = decoded.readUInt8(0);
// Bytes 2-3 is Command/Request // Bytes 2-3 is Command/Request
const cmd = decoded.readUInt16BE(3); const cmd = decoded.readUInt16BE(3);
@ -810,38 +817,52 @@ class PlejBLEHandler extends EventEmitter {
logger.silly(`Dim: ${dim.toString(16)}, full precision: ${dimFull.toString(16)}`); logger.silly(`Dim: ${dim.toString(16)}, full precision: ${dimFull.toString(16)}`);
} }
const deviceName = this.deviceRegistry.getDeviceName(deviceId); const device = this.deviceRegistry.getOutputDeviceByBleOutputAddress(bleOutputAddress);
const deviceName = device ? device.name : 'Unknown';
const outputUniqueId = device ? device.uniqueId : null;
if (Logger.shouldLog('verbose')) { if (Logger.shouldLog('verbose')) {
// decoded.toString() could potentially be expensive // decoded.toString() could potentially be expensive
logger.verbose(`Raw event received: ${decoded.toString('hex')}`); logger.verbose(`Raw event received: ${decoded.toString('hex')}`);
logger.verbose( logger.verbose(
`Decoded: Device ${deviceId}, cmd ${cmd.toString(16)}, state ${state}, dim ${dim}`, `Decoded: Device ${outputUniqueId} (BLE address ${bleOutputAddress}), cmd ${cmd.toString(
16,
)}, state ${state}, dim ${dim}`,
); );
} }
let command; let command;
let data = {}; let data = {};
if (cmd === BLE_CMD_DIM_CHANGE || cmd === BLE_CMD_DIM2_CHANGE) { if (cmd === BLE_CMD_DIM_CHANGE || cmd === BLE_CMD_DIM2_CHANGE) {
logger.debug(`${deviceName} (${deviceId}) got state+dim update. S: ${state}, D: ${dim}`); logger.debug(
`${deviceName} (${outputUniqueId}) got state+dim update. S: ${state}, D: ${dim}`,
);
command = COMMANDS.DIM; command = COMMANDS.DIM;
data = { state, dim }; data = { state, dim };
this.emit(PlejBLEHandler.EVENTS.commandReceived, deviceId, command, data); this.emit(PlejBLEHandler.EVENTS.commandReceived, outputUniqueId, command, data);
} else if (cmd === BLE_CMD_STATE_CHANGE) { } else if (cmd === BLE_CMD_STATE_CHANGE) {
logger.debug(`${deviceName} (${deviceId}) got state update. S: ${state}`); logger.debug(`${deviceName} (${outputUniqueId}) got state update. S: ${state}`);
command = state ? COMMANDS.TURN_ON : COMMANDS.TURN_OFF; command = state ? COMMANDS.TURN_ON : COMMANDS.TURN_OFF;
this.emit(PlejBLEHandler.EVENTS.commandReceived, deviceId, command, data); this.emit(PlejBLEHandler.EVENTS.commandReceived, outputUniqueId, command, data);
} else if (cmd === BLE_CMD_SCENE_TRIG) { } else if (cmd === BLE_CMD_SCENE_TRIG) {
const sceneId = state; const sceneBleAddress = state;
const sceneName = this.deviceRegistry.getSceneName(sceneId); const scene = this.deviceRegistry.getSceneByBleAddress(sceneBleAddress);
if (!scene) {
logger.warn(
`Scene with BLE address ${sceneBleAddress} could not be found, can't process message`,
);
return;
}
logger.debug( logger.debug(
`${sceneName} (${sceneId}) scene triggered (device id ${deviceId}). Name can be misleading if there is a device with the same numeric id.`, `${scene.name} (${sceneBleAddress}) scene triggered (device id ${outputUniqueId}).`,
); );
command = COMMANDS.TRIGGER_SCENE; command = COMMANDS.TRIGGER_SCENE;
data = { sceneId }; data = { sceneId: scene.uniqueId };
this.emit(PlejBLEHandler.EVENTS.commandReceived, deviceId, command, data); this.emit(PlejBLEHandler.EVENTS.commandReceived, outputUniqueId, command, data);
} else if (cmd === BLE_CMD_TIME_UPDATE) { } else if (cmd === BLE_CMD_TIME_UPDATE) {
const now = new Date(); const now = new Date();
// Guess Plejd timezone based on HA time zone // Guess Plejd timezone based on HA time zone
@ -851,7 +872,7 @@ class PlejBLEHandler extends EventEmitter {
const plejdTimestampUTC = (decoded.readInt32LE(5) + offsetSecondsGuess) * 1000; const plejdTimestampUTC = (decoded.readInt32LE(5) + offsetSecondsGuess) * 1000;
const diffSeconds = Math.round((plejdTimestampUTC - now.getTime()) / 1000); const diffSeconds = Math.round((plejdTimestampUTC - now.getTime()) / 1000);
if ( if (
deviceId !== BLE_BROADCAST_DEVICE_ID bleOutputAddress !== BLE_BROADCAST_DEVICE_ID
|| Logger.shouldLog('verbose') || Logger.shouldLog('verbose')
|| Math.abs(diffSeconds) > 60 || Math.abs(diffSeconds) > 60
) { ) {
@ -863,7 +884,7 @@ class PlejBLEHandler extends EventEmitter {
logger.warn( logger.warn(
`Plejd clock time off by more than 1 minute. Reported time: ${plejdTime.toString()}, diff ${diffSeconds} seconds. Time will be set hourly.`, `Plejd clock time off by more than 1 minute. Reported time: ${plejdTime.toString()}, diff ${diffSeconds} seconds. Time will be set hourly.`,
); );
if (this.connectedDevice && deviceId === this.connectedDevice.id) { if (this.connectedDevice && bleOutputAddress === this.connectedDevice.id) {
// Requested time sync by us // Requested time sync by us
const newLocalTimestamp = now.getTime() / 1000 - offsetSecondsGuess; const newLocalTimestamp = now.getTime() / 1000 - offsetSecondsGuess;
logger.info(`Setting time to ${now.toString()}`); logger.info(`Setting time to ${now.toString()}`);
@ -874,14 +895,14 @@ class PlejBLEHandler extends EventEmitter {
(pl) => pl.writeInt32LE(Math.trunc(newLocalTimestamp), 5), (pl) => pl.writeInt32LE(Math.trunc(newLocalTimestamp), 5),
); );
try { try {
this.write(payload); this._write(payload);
} catch (err) { } catch (err) {
logger.error( logger.error(
'Failed writing new time to Plejd. Will try again in one hour or at restart.', 'Failed writing new time to Plejd. Will try again in one hour or at restart.',
); );
} }
} }
} else if (deviceId !== BLE_BROADCAST_DEVICE_ID) { } else if (bleOutputAddress !== BLE_BROADCAST_DEVICE_ID) {
logger.info('Got time response. Plejd clock time in sync with Home Assistant time'); logger.info('Got time response. Plejd clock time in sync with Home Assistant time');
} }
} }
@ -889,19 +910,19 @@ class PlejBLEHandler extends EventEmitter {
logger.verbose( logger.verbose(
`Command ${cmd.toString(16)} unknown. ${decoded.toString( `Command ${cmd.toString(16)} unknown. ${decoded.toString(
'hex', 'hex',
)}. Device ${deviceName} (${deviceId})`, )}. Device ${deviceName} (${bleOutputAddress}: ${outputUniqueId})`,
); );
} }
} }
_createHexPayload( _createHexPayload(
deviceId, bleOutputAddress,
command, command,
hexDataString, hexDataString,
requestResponseCommand = BLE_REQUEST_NO_RESPONSE, requestResponseCommand = BLE_REQUEST_NO_RESPONSE,
) { ) {
return this._createPayload( return this._createPayload(
deviceId, bleOutputAddress,
command, command,
5 + Math.ceil(hexDataString.length / 2), 5 + Math.ceil(hexDataString.length / 2),
(payload) => payload.write(hexDataString, 5, 'hex'), (payload) => payload.write(hexDataString, 5, 'hex'),
@ -911,14 +932,14 @@ class PlejBLEHandler extends EventEmitter {
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
_createPayload( _createPayload(
deviceId, bleOutputAddress,
command, command,
bufferLength, bufferLength,
payloadBufferAddDataFunc, payloadBufferAddDataFunc,
requestResponseCommand = BLE_REQUEST_NO_RESPONSE, requestResponseCommand = BLE_REQUEST_NO_RESPONSE,
) { ) {
const payload = Buffer.alloc(bufferLength); const payload = Buffer.alloc(bufferLength);
payload.writeUInt8(deviceId); payload.writeUInt8(bleOutputAddress);
payload.writeUInt16BE(requestResponseCommand, 1); payload.writeUInt16BE(requestResponseCommand, 1);
payload.writeUInt16BE(command, 3); payload.writeUInt16BE(command, 3);
payloadBufferAddDataFunc(payload); payloadBufferAddDataFunc(payload);
@ -945,12 +966,12 @@ class PlejBLEHandler extends EventEmitter {
let ct = cipher.update(buf).toString('hex'); let ct = cipher.update(buf).toString('hex');
ct += cipher.final().toString('hex'); ct += cipher.final().toString('hex');
ct = Buffer.from(ct, 'hex'); const ctBuf = Buffer.from(ct, 'hex');
let output = ''; let output = '';
for (let i = 0, { length } = data; i < length; i++) { for (let i = 0, { length } = data; i < length; i++) {
// eslint-disable-next-line no-bitwise // eslint-disable-next-line no-bitwise
output += String.fromCharCode(data[i] ^ ct[i % 16]); output += String.fromCharCode(data[i] ^ ctBuf[i % 16]);
} }
return Buffer.from(output, 'ascii'); return Buffer.from(output, 'ascii');

View file

@ -1,4 +1,4 @@
const EventEmitter = require('events'); const { EventEmitter } = require('events');
const Configuration = require('./Configuration'); const Configuration = require('./Configuration');
const constants = require('./constants'); const constants = require('./constants');
const Logger = require('./Logger'); const Logger = require('./Logger');
@ -12,10 +12,13 @@ const MAX_RETRY_COUNT = 10; // Could be made a setting
class PlejdDeviceCommunication extends EventEmitter { class PlejdDeviceCommunication extends EventEmitter {
bleConnected; bleConnected;
bleDeviceTransitionTimers = {}; bleOutputTransitionTimers = {};
plejdBleHandler; plejdBleHandler;
config; config;
/** @type {import('./DeviceRegistry')} */
deviceRegistry; deviceRegistry;
// eslint-disable-next-line max-len
/** @type {{uniqueOutputId: string, command: string, data: any, shouldRetry: boolean, retryCount?: number}[]} */
writeQueue = []; writeQueue = [];
writeQueueRef = null; writeQueueRef = null;
@ -34,7 +37,7 @@ class PlejdDeviceCommunication extends EventEmitter {
} }
cleanup() { cleanup() {
Object.values(this.bleDeviceTransitionTimers).forEach((t) => clearTimeout(t)); Object.values(this.bleOutputTransitionTimers).forEach((t) => clearTimeout(t));
this.plejdBleHandler.cleanup(); this.plejdBleHandler.cleanup();
this.plejdBleHandler.removeAllListeners(PlejBLEHandler.EVENTS.commandReceived); this.plejdBleHandler.removeAllListeners(PlejBLEHandler.EVENTS.commandReceived);
this.plejdBleHandler.removeAllListeners(PlejBLEHandler.EVENTS.connected); this.plejdBleHandler.removeAllListeners(PlejBLEHandler.EVENTS.connected);
@ -46,7 +49,10 @@ class PlejdDeviceCommunication extends EventEmitter {
this.cleanup(); this.cleanup();
this.bleConnected = false; this.bleConnected = false;
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
this.plejdBleHandler.on(PlejBLEHandler.EVENTS.commandReceived, (deviceId, command, data) => this._bleCommandReceived(deviceId, command, data)); this.plejdBleHandler.on(
PlejBLEHandler.EVENTS.commandReceived,
(uniqueOutputId, command, data) => this._bleCommandReceived(uniqueOutputId, command, data),
);
this.plejdBleHandler.on(PlejBLEHandler.EVENTS.connected, () => { this.plejdBleHandler.on(PlejBLEHandler.EVENTS.connected, () => {
logger.info('Bluetooth connected. Plejd BLE up and running!'); logger.info('Bluetooth connected. Plejd BLE up and running!');
@ -70,42 +76,42 @@ class PlejdDeviceCommunication extends EventEmitter {
} }
} }
turnOn(deviceId, command) { turnOn(uniqueOutputId, command) {
const deviceName = this.deviceRegistry.getDeviceName(deviceId); const deviceName = this.deviceRegistry.getOutputDeviceName(uniqueOutputId);
logger.info( logger.info(
`Plejd got turn on command for ${deviceName} (${deviceId}), brightness ${command.brightness}${ `Plejd got turn on command for ${deviceName} (${uniqueOutputId}), brightness ${
command.brightness
}${command.transition ? `, transition: ${command.transition}` : ''}`,
);
this._transitionTo(uniqueOutputId, command.brightness, command.transition, 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}` : '' command.transition ? `, transition: ${command.transition}` : ''
}`, }`,
); );
this._transitionTo(deviceId, command.brightness, command.transition, deviceName); this._transitionTo(uniqueOutputId, 0, command.transition, deviceName);
} }
turnOff(deviceId, command) { _bleCommandReceived(uniqueOutputId, command, data) {
const deviceName = this.deviceRegistry.getDeviceName(deviceId);
logger.info(
`Plejd got turn off command for ${deviceName} (${deviceId})${
command.transition ? `, transition: ${command.transition}` : ''
}`,
);
this._transitionTo(deviceId, 0, command.transition, deviceName);
}
_bleCommandReceived(deviceId, command, data) {
try { try {
if (command === COMMANDS.DIM) { if (command === COMMANDS.DIM) {
this.deviceRegistry.setState(deviceId, data.state, data.dim); this.deviceRegistry.setOutputState(uniqueOutputId, data.state, data.dim);
this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, deviceId, { this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, uniqueOutputId, {
state: data.state, state: !!data.state,
brightness: data.dim, brightness: data.dim,
}); });
} else if (command === COMMANDS.TURN_ON) { } else if (command === COMMANDS.TURN_ON) {
this.deviceRegistry.setState(deviceId, 1); this.deviceRegistry.setOutputState(uniqueOutputId, true);
this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, deviceId, { this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, uniqueOutputId, {
state: 1, state: 1,
}); });
} else if (command === COMMANDS.TURN_OFF) { } else if (command === COMMANDS.TURN_OFF) {
this.deviceRegistry.setState(deviceId, 0); this.deviceRegistry.setOutputState(uniqueOutputId, false);
this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, deviceId, { this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, uniqueOutputId, {
state: 0, state: 0,
}); });
} else if (command === COMMANDS.TRIGGER_SCENE) { } else if (command === COMMANDS.TRIGGER_SCENE) {
@ -118,18 +124,18 @@ class PlejdDeviceCommunication extends EventEmitter {
} }
} }
_clearDeviceTransitionTimer(deviceId) { _clearDeviceTransitionTimer(uniqueOutputId) {
if (this.bleDeviceTransitionTimers[deviceId]) { if (this.bleOutputTransitionTimers[uniqueOutputId]) {
clearInterval(this.bleDeviceTransitionTimers[deviceId]); clearInterval(this.bleOutputTransitionTimers[uniqueOutputId]);
} }
} }
_transitionTo(deviceId, targetBrightness, transition, deviceName) { _transitionTo(uniqueOutputId, targetBrightness, transition, deviceName) {
const device = this.deviceRegistry.getDevice(deviceId); const device = this.deviceRegistry.getOutputDevice(uniqueOutputId);
const initialBrightness = device ? device.state && device.dim : null; const initialBrightness = device ? device.state && device.dim : null;
this._clearDeviceTransitionTimer(deviceId); this._clearDeviceTransitionTimer(uniqueOutputId);
const isDimmable = this.deviceRegistry.getDevice(deviceId).dimmable; const isDimmable = this.deviceRegistry.getOutputDevice(uniqueOutputId).dimmable;
if ( if (
transition > 1 transition > 1
@ -164,7 +170,7 @@ class PlejdDeviceCommunication extends EventEmitter {
let nSteps = 0; let nSteps = 0;
this.bleDeviceTransitionTimers[deviceId] = setInterval(() => { this.bleOutputTransitionTimers[uniqueOutputId] = setInterval(() => {
const tElapsedMs = new Date().getTime() - dtStart.getTime(); const tElapsedMs = new Date().getTime() - dtStart.getTime();
let tElapsed = tElapsedMs / 1000; let tElapsed = tElapsedMs / 1000;
@ -178,20 +184,20 @@ class PlejdDeviceCommunication extends EventEmitter {
if (tElapsed === transition) { if (tElapsed === transition) {
nSteps++; nSteps++;
this._clearDeviceTransitionTimer(deviceId); this._clearDeviceTransitionTimer(uniqueOutputId);
newBrightness = targetBrightness; newBrightness = targetBrightness;
logger.debug( logger.debug(
`Queueing finalize ${deviceName} (${deviceId}) transition from ${initialBrightness} to ${targetBrightness} in ${tElapsedMs}ms. Done steps ${nSteps}. Average interval ${ `Queueing finalize ${deviceName} (${uniqueOutputId}) transition from ${initialBrightness} to ${targetBrightness} in ${tElapsedMs}ms. Done steps ${nSteps}. Average interval ${
tElapsedMs / (nSteps || 1) tElapsedMs / (nSteps || 1)
} ms.`, } ms.`,
); );
this._setBrightness(deviceId, newBrightness, true, deviceName); this._setBrightness(uniqueOutputId, newBrightness, true, deviceName);
} else { } else {
nSteps++; nSteps++;
logger.verbose( logger.verbose(
`Queueing dim transition for ${deviceName} (${deviceId}) to ${newBrightness}. Total queue length ${this.writeQueue.length}`, `Queueing dim transition for ${deviceName} (${uniqueOutputId}) to ${newBrightness}. Total queue length ${this.writeQueue.length}`,
); );
this._setBrightness(deviceId, newBrightness, false, deviceName); this._setBrightness(uniqueOutputId, newBrightness, false, deviceName);
} }
}, transitionInterval); }, transitionInterval);
} else { } else {
@ -200,34 +206,34 @@ class PlejdDeviceCommunication extends EventEmitter {
`Could not transition light change. Either initial value is unknown or change is too small. Requested from ${initialBrightness} to ${targetBrightness}`, `Could not transition light change. Either initial value is unknown or change is too small. Requested from ${initialBrightness} to ${targetBrightness}`,
); );
} }
this._setBrightness(deviceId, targetBrightness, true, deviceName); this._setBrightness(uniqueOutputId, targetBrightness, true, deviceName);
} }
} }
_setBrightness(deviceId, brightness, shouldRetry, deviceName) { _setBrightness(unqiueOutputId, brightness, shouldRetry, deviceName) {
if (!brightness && brightness !== 0) { if (!brightness && brightness !== 0) {
logger.debug( logger.debug(
`Queueing turn on ${deviceName} (${deviceId}). No brightness specified, setting DIM to previous.`, `Queueing turn on ${deviceName} (${unqiueOutputId}). No brightness specified, setting DIM to previous.`,
); );
this._appendCommandToWriteQueue(deviceId, COMMANDS.TURN_ON, null, shouldRetry); this._appendCommandToWriteQueue(unqiueOutputId, COMMANDS.TURN_ON, null, shouldRetry);
} else if (brightness <= 0) { } else if (brightness <= 0) {
logger.debug(`Queueing turn off ${deviceId}`); logger.debug(`Queueing turn off ${unqiueOutputId}`);
this._appendCommandToWriteQueue(deviceId, COMMANDS.TURN_OFF, null, shouldRetry); this._appendCommandToWriteQueue(unqiueOutputId, COMMANDS.TURN_OFF, null, shouldRetry);
} else { } else {
if (brightness > 255) { if (brightness > 255) {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
brightness = 255; brightness = 255;
} }
logger.debug(`Queueing ${deviceId} set brightness to ${brightness}`); logger.debug(`Queueing ${unqiueOutputId} set brightness to ${brightness}`);
// eslint-disable-next-line no-bitwise // eslint-disable-next-line no-bitwise
this._appendCommandToWriteQueue(deviceId, COMMANDS.DIM, brightness, shouldRetry); this._appendCommandToWriteQueue(unqiueOutputId, COMMANDS.DIM, brightness, shouldRetry);
} }
} }
_appendCommandToWriteQueue(deviceId, command, data, shouldRetry) { _appendCommandToWriteQueue(uniqueOutputId, command, data, shouldRetry) {
this.writeQueue.unshift({ this.writeQueue.unshift({
deviceId, uniqueOutputId,
command, command,
data, data,
shouldRetry, shouldRetry,
@ -249,28 +255,29 @@ class PlejdDeviceCommunication extends EventEmitter {
return; return;
} }
const queueItem = this.writeQueue.pop(); const queueItem = this.writeQueue.pop();
const deviceName = this.deviceRegistry.getDeviceName(queueItem.deviceId); const device = this.deviceRegistry.getOutputDevice(queueItem.uniqueOutputId);
logger.debug( logger.debug(
`Write queue: Processing ${deviceName} (${queueItem.deviceId}). Command ${ `Write queue: Processing ${device.name} (${queueItem.uniqueOutputId}). Command ${
queueItem.command queueItem.command
}${queueItem.data ? ` ${queueItem.data}` : ''}. Total queue length: ${ }${queueItem.data ? ` ${queueItem.data}` : ''}. Total queue length: ${
this.writeQueue.length this.writeQueue.length
}`, }`,
); );
if (this.writeQueue.some((item) => item.deviceId === queueItem.deviceId)) { if (this.writeQueue.some((item) => item.uniqueOutputId === queueItem.uniqueOutputId)) {
logger.verbose( logger.verbose(
`Skipping ${deviceName} (${queueItem.deviceId}) ` `Skipping ${device.name} (${queueItem.uniqueOutputId}) `
+ `${queueItem.command} due to more recent command in queue.`, + `${queueItem.command} due to more recent command in queue.`,
); );
// Skip commands if new ones exist for the same deviceId // Skip commands if new ones exist for the same uniqueOutputId
// still process all messages in order // still process all messages in order
} else { } else {
/* eslint-disable no-await-in-loop */ /* eslint-disable no-await-in-loop */
try { try {
await this.plejdBleHandler.sendCommand( await this.plejdBleHandler.sendCommand(
queueItem.command, queueItem.command,
queueItem.deviceId, device.bleOutputAddress,
queueItem.data, queueItem.data,
); );
} catch (err) { } catch (err) {
@ -281,7 +288,7 @@ class PlejdDeviceCommunication extends EventEmitter {
this.writeQueue.push(queueItem); // Add back to top of queue to be processed next; this.writeQueue.push(queueItem); // Add back to top of queue to be processed next;
} else { } else {
logger.error( logger.error(
`Write queue: Exceeed max retry count (${MAX_RETRY_COUNT}) for ${deviceName} (${queueItem.deviceId}). Command ${queueItem.command} failed.`, `Write queue: Exceeed max retry count (${MAX_RETRY_COUNT}) for ${device.name} (${queueItem.uniqueOutputId}). Command ${queueItem.command} failed.`,
); );
break; break;
} }

View file

@ -189,6 +189,11 @@ The code in this project follows the [Airbnb JavaScript guide](https://github.co
For a nice developer experience it is very convenient to have `eslint` and `prettier` installed in your favorite editor (such as VS Code) and use the "format on save" option (or invoke formatting by Alt+Shift+F in VS Code). Any code issues should appear in the problems window inside the editor, as well as when running the command above. For a nice developer experience it is very convenient to have `eslint` and `prettier` installed in your favorite editor (such as VS Code) and use the "format on save" option (or invoke formatting by Alt+Shift+F in VS Code). Any code issues should appear in the problems window inside the editor, as well as when running the command above.
For partial type hinting you can run
- `npm install --global typings`
- `typings install`
When contributing, please do so by forking the repo and then using pull requests towards the dev branch. When contributing, please do so by forking the repo and then using pull requests towards the dev branch.
### Logs ### Logs

View file

@ -1,18 +1,20 @@
const SceneStep = require('./SceneStep'); const SceneStep = require('./SceneStep');
class Scene { class Scene {
constructor(idx, scene, steps) { /**
* @param {import('./DeviceRegistry')} deviceRegistry
* @param {number} idx
* @param {import("./types/ApiSite").Scene} scene
*/
constructor(deviceRegistry, idx, scene) {
this.id = idx; this.id = idx;
this.title = scene.title; this.title = scene.title;
this.sceneId = scene.sceneId; this.sceneId = scene.sceneId;
const sceneSteps = steps.filter((x) => x.sceneId === scene.sceneId); this.steps = deviceRegistry
this.steps = []; .getApiSite()
.sceneSteps.filter((step) => step.sceneId === scene.sceneId)
// eslint-disable-next-line no-restricted-syntax .map((step) => new SceneStep(step));
for (const step of sceneSteps) {
this.steps.push(new SceneStep(step));
}
} }
} }

View file

@ -3,45 +3,52 @@ const Scene = require('./Scene');
const logger = Logger.getLogger('scene-manager'); const logger = Logger.getLogger('scene-manager');
class SceneManager { class SceneManager {
/** @private @type {import('./DeviceRegistry')} */
deviceRegistry; deviceRegistry;
plejdBle; /** @private @type {import('./PlejdDeviceCommunication')} */
plejdDeviceCommunication;
/** @private @type {Object.<string,Scene>} */
scenes; scenes;
constructor(deviceRegistry, plejdBle) { constructor(deviceRegistry, plejdDeviceCommunication) {
this.deviceRegistry = deviceRegistry; this.deviceRegistry = deviceRegistry;
this.plejdBle = plejdBle; this.plejdDeviceCommunication = plejdDeviceCommunication;
this.scenes = {}; this.scenes = {};
} }
init() { init() {
const scenes = this.deviceRegistry.apiSite.scenes.filter( const scenes = this.deviceRegistry
(x) => x.hiddenFromSceneList === false, .getApiSite()
); .scenes.filter((x) => x.hiddenFromSceneList === false);
this.scenes = {}; this.scenes = {};
scenes.forEach((scene) => { scenes.forEach((scene) => {
const idx = this.deviceRegistry.apiSite.sceneIndex[scene.sceneId]; const sceneBleAddress = this.deviceRegistry.getApiSite().sceneIndex[scene.sceneId];
this.scenes[idx] = new Scene(idx, scene, this.deviceRegistry.apiSite.sceneSteps); this.scenes[scene.sceneId] = new Scene(this.deviceRegistry, sceneBleAddress, scene);
}); });
} }
executeScene(sceneId) { /**
const scene = this.scenes[sceneId]; * @param {string} sceneUniqueId
*/
executeScene(sceneUniqueId) {
const scene = this.scenes[sceneUniqueId];
if (!scene) { if (!scene) {
logger.info(`Scene with id ${sceneId} not found`); logger.info(`Scene with id ${sceneUniqueId} not found`);
logger.verbose(`Scenes: ${JSON.stringify(this.scenes, null, 2)}`); logger.verbose(`Scenes: ${JSON.stringify(this.scenes, null, 2)}`);
return; return;
} }
scene.steps.forEach((step) => { scene.steps.forEach((step) => {
const device = this.deviceRegistry.getDeviceBySerialNumber(step.deviceId); const uniqueId = this.deviceRegistry.getUniqueOutputId(step.deviceId, step.output);
const device = this.deviceRegistry.getOutputDevice(uniqueId);
if (device) { if (device) {
if (device.dimmable && step.state) { if (device.dimmable && step.state) {
this.plejdBle.turnOn(device.id, { brightness: step.brightness }); this.plejdDeviceCommunication.turnOn(uniqueId, { brightness: step.brightness });
} else if (!device.dimmable && step.state) { } else if (!device.dimmable && step.state) {
this.plejdBle.turnOn(device.id, {}); this.plejdDeviceCommunication.turnOn(uniqueId, {});
} else if (!step.state) { } else if (!step.state) {
this.plejdBle.turnOff(device.id, {}); this.plejdDeviceCommunication.turnOff(uniqueId, {});
} }
} }
}); });

View file

@ -1,7 +1,11 @@
class SceneStep { class SceneStep {
/**
* @param {import("./types/ApiSite").SceneStep} step
*/
constructor(step) { constructor(step) {
this.sceneId = step.sceneId; this.sceneId = step.sceneId;
this.deviceId = step.deviceId; this.deviceId = step.deviceId;
this.output = step.output;
this.state = step.state === 'On' ? 1 : 0; this.state = step.state === 'On' ? 1 : 0;
this.brightness = step.value; this.brightness = step.value;
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "Plejd", "name": "Plejd",
"version": "0.7.1", "version": "0.8.0-dev",
"slug": "plejd", "slug": "plejd",
"description": "Adds support for the Swedish home automation devices from Plejd.", "description": "Adds support for the Swedish home automation devices from Plejd.",
"url": "https://github.com/icanos/hassio-plejd/", "url": "https://github.com/icanos/hassio-plejd/",

10
plejd/jsconfig.json Normal file
View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"checkJs": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es6"
},
"exclude": ["node_modules", "rootfs"]
}

View file

@ -3,17 +3,17 @@
"@abandonware/bluetooth-hci-socket": "~0.5.3-7", "@abandonware/bluetooth-hci-socket": "~0.5.3-7",
"axios": "~0.21.1", "axios": "~0.21.1",
"buffer-xor": "~2.0.2", "buffer-xor": "~2.0.2",
"dbus-next": "~0.9.1", "dbus-next": "~0.9.2",
"fs": "0.0.1-security", "fs": "0.0.1-security",
"jspack": "~0.0.4", "jspack": "~0.0.4",
"mqtt": "~3.0.0", "mqtt": "~4.2.6",
"winston": "~3.3.3" "winston": "~3.3.3"
}, },
"devDependencies": { "devDependencies": {
"babel-eslint": "~10.1.0", "babel-eslint": "~10.1.0",
"eslint": "~7.18.0", "eslint": "~7.23.0",
"eslint-config-airbnb": "~18.2.1", "eslint-config-airbnb": "~18.2.1",
"eslint-config-prettier": "~7.2.0", "eslint-config-prettier": "~8.1.0",
"eslint-plugin-import": "~2.22.1", "eslint-plugin-import": "~2.22.1",
"eslint-plugin-prettier": "~3.3.1", "eslint-plugin-prettier": "~3.3.1",
"prettier": "~2.2.1" "prettier": "~2.2.1"

655
plejd/types/ApiSite.d.ts vendored Normal file
View file

@ -0,0 +1,655 @@
/* eslint-disable camelcase */
/* eslint-disable no-use-before-define */
export interface CachedSite {
siteId: string;
siteDetails: ApiSite;
sessionToken: string;
dtCache: string;
}
export interface ApiSite {
site: SiteDetailsSite;
plejdMesh: PlejdMesh;
rooms: Room[];
scenes: Scene[];
devices: Device[];
plejdDevices: PlejdDevice[];
gateways: Gateway[];
resourceSets: ResourceSet[];
timeEvents: TimeEvent[];
sceneSteps: SceneStep[];
astroEvents: AstroEvent[];
inputSettings: InputSetting[];
outputSettings: OutputSetting[];
stateTimers: StateTimers;
sitePermission: SitePermission;
inputAddress: { [key: string]: { [key: string]: number } };
outputAddress: { [key: string]: OutputAddress };
deviceAddress: { [key: string]: number };
outputGroups: { [key: string]: OutputGroup };
roomAddress: { [key: string]: number };
sceneIndex: { [key: string]: number };
images: Images;
deviceLimit: number;
}
export interface AstroEvent {
dirtyDevices?: any[];
dirtyRemovedDevices?: any[];
deviceId: string;
siteId: string;
sceneId: string;
fadeTime: number;
activated: boolean;
astroEventId: string;
index: number;
sunriseOffset: number;
sunsetOffset: number;
pauseStart: string;
pauseEnd: string;
createdAt: Date;
updatedAt: Date;
dirtyRemove?: boolean;
ACL: AstroEventACL;
targetDevices: AstroEventTargetDevice[];
objectId: string;
__type: AstroEventType;
className: string;
}
export interface AstroEventACL {}
export enum AstroEventType {
Object = 'Object',
}
export interface AstroEventTargetDevice {
deviceId: string;
index: number;
}
export interface Device {
deviceId: string;
siteId: string;
roomId: string;
title: string;
traits: number;
hardware?: Hardware;
hiddenFromRoomList: boolean;
createdAt: Date;
updatedAt: Date;
outputType: OutputType;
ACL: AstroEventACL;
objectId: string;
__type: AstroEventType;
className: DeviceClassName;
hiddenFromIntegrations?: boolean;
}
export enum DeviceClassName {
Device = 'Device',
}
export interface Hardware {
createdAt: Date;
updatedAt: Date;
name: Name;
hardwareId: string;
minSupportedFirmware: PlejdMeshClass;
latestFirmware: PlejdMeshClass;
brand: Brand;
type: Type;
image: Image;
requiredAccountType: RequiredAccountType[];
numberOfDevices: number;
predefinedLoad: PredefinedLoad;
supportedFirmware: PredefinedLoad;
ACL: AstroEventACL;
objectId: HardwareObjectID;
__type: AstroEventType;
className: HardwareClassName;
}
export enum Brand {
PlejdLight = 'Plejd Light',
}
export enum HardwareClassName {
Hardware = 'Hardware',
}
export interface Image {
__type: ImageType;
name: string;
url: string;
}
export enum ImageType {
File = 'File',
}
export interface PlejdMeshClass {
__type: InstallerType;
className: SiteClassName;
objectId: ObjectID;
}
export enum InstallerType {
Pointer = 'Pointer',
}
export enum SiteClassName {
DimCurve = 'DimCurve',
Firmware = 'Firmware',
PlejdMesh = 'PlejdMesh',
Site = 'Site',
User = '_User',
UserProfile = 'UserProfile',
}
export enum ObjectID {
BBBJO2Cufm = 'BBBJO2cufm',
D4Dw87Hq21 = 'D4DW87HQ21',
FCrrS1NJHH = 'FCrrS1nJHH',
GX1W4P06QS = 'gX1W4p06QS',
Ndlvzgh4Df = 'ndlvzgh4df',
UHoKQLuXqZ = 'uHoKQLuXqZ',
VfHiawBPA8 = 'vfHiawBPA8',
WgAFPloWjK = 'wgAfPloWjK',
YkyNDotBNa = 'YkyNDotBNa',
}
export enum Name {
Ctr01 = 'CTR-01',
Dim01 = 'DIM-01',
}
export enum HardwareObjectID {
R3Gfd6ACAu = 'R3gfd6ACAu',
XjslOltgvi = 'xjslOltgvi',
}
export interface PredefinedLoad {
__type: SupportedFirmwareType;
className: PredefinedLoadClassName;
}
export enum SupportedFirmwareType {
Relation = 'Relation',
}
export enum PredefinedLoadClassName {
DimCurve = 'DimCurve',
Firmware = 'Firmware',
PredefinedLoad = 'PredefinedLoad',
}
export enum RequiredAccountType {
Installer = 'installer',
}
export enum Type {
Controller = 'Controller',
LEDDimmer = 'LED Dimmer',
}
export enum OutputType {
Light = 'LIGHT',
Relay = 'RELAY',
}
export interface Gateway {
title: string;
deviceId: string;
siteId: string;
hardwareId: string;
installer: ObjectID;
firmware: number;
firmwareObject: Firmware;
dirtyInstall: boolean;
dirtyUpdate: boolean;
createdAt: Date;
updatedAt: Date;
factoryKey: string;
resourceSetId: string;
ACL: AstroEventACL;
objectId: string;
__type: AstroEventType;
className: string;
}
export interface Firmware {
notes: Notes;
createdAt: Date;
updatedAt: Date;
data: Image;
metaData: Image;
version: Version;
buildTime: number;
firmwareApi: string;
ACL: AstroEventACL;
objectId: FirmwareObjectObjectID;
__type: AstroEventType;
className: SiteClassName;
}
export enum Notes {
Ctr01 = 'CTR-01',
Ctr20ReleaseCandidate1 = 'Ctr 2.0 Release candidate 1',
Dim20ReleaseCandidate1 = 'Dim 2.0 Release candidate 1',
Dim221ReleaseCandidate = 'Dim 2.2.1 Release Candidate',
GWY10ReleaseCandidate = 'GWY 1.0 Release Candidate',
}
export enum FirmwareObjectObjectID {
BBBJO2Cufm = 'BBBJO2cufm',
E6YxfREDuF = 'E6yxfREDuF',
JYSZ0EvyCU = 'JYSZ0EvyCU',
Ndlvzgh4Df = 'ndlvzgh4df',
RlglTfVHDe = 'rlglTfVHDe',
}
export enum Version {
The12 = '1.2',
The20 = '2.0',
The221 = '2.2.1',
The304 = '3.0.4',
}
export interface Images {
'2afc6c6e-7a26-466a-b8ec-febbca90f5f7': string;
}
export interface InputSetting {
deviceId: string;
input: number;
siteId: string;
dimSpeed: number;
buttonType: ButtonType;
createdAt: Date;
updatedAt: Date;
ACL: AstroEventACL;
objectId: string;
__type: AstroEventType;
className: InputSettingClassName;
doubleClick?: string;
singleClick?: null;
doubleSidedDirectionButton?: boolean;
}
export enum ButtonType {
PushButton = 'PushButton',
RotateMesh = 'RotateMesh',
Scene = 'Scene',
}
export enum InputSettingClassName {
PlejdDeviceInputSetting = 'PlejdDeviceInputSetting',
}
export interface OutputAddress {
'0': number;
}
export interface OutputGroup {
'0': number[];
}
export interface OutputSetting {
deviceId: string;
output: number;
deviceParseId: string;
siteId: string;
predefinedLoad: OutputSettingPredefinedLoad;
createdAt: Date;
updatedAt: Date;
dimMin: number;
dimMax: number;
dimStart: number;
outputStartTime: number;
outputSpeed: number;
bootState: BootState;
dimCurve: DimCurve;
curveLogarithm: number;
curveSinusCompensation: number;
curveRectification: boolean;
output_0_10V_Mode?: Output0_10_VMode;
zeroCrossing?: Output0_10_VMode;
minimumRelayOffTime?: number;
ACL: AstroEventACL;
objectId: string;
__type: AstroEventType;
className: OutputSettingClassName;
ledCurrent?: number;
ledVoltage?: number;
relayConfig?: Output0_10_VMode;
}
export enum BootState {
UseLast = 'UseLast',
}
export enum OutputSettingClassName {
PlejdDeviceOutputSetting = 'PlejdDeviceOutputSetting',
}
export enum DimCurve {
LinearLogarithmicSlidingProportion = 'LinearLogarithmicSlidingProportion',
NonDimmable = 'NonDimmable',
}
export enum Output0_10_VMode {
Unknown = 'Unknown',
}
export interface OutputSettingPredefinedLoad {
updatedAt: Date;
createdAt: Date;
loadType: string;
predefinedLoadData: string;
defaultDimCurve: PlejdMeshClass;
description_en?: DescriptionEn;
title_en?: TitleEn;
title_sv?: TitleSv;
description_sv?: DescriptionSv;
titleKey: string;
descriptionKey: string;
allowedDimCurves: PredefinedLoad;
ACL: PredefinedLoadACL;
objectId: string;
__type: AstroEventType;
className: PredefinedLoadClassName;
supportMessage?: SupportMessage;
filters?: Filters;
}
export interface PredefinedLoadACL {
'*': Empty;
}
export interface Empty {
read: boolean;
}
export enum DescriptionEn {
OnOff = 'On / Off',
OnlySwitchingOffOn = 'Only switching off/on',
The230VDimmableLEDLightSourceMax100VA = '230V dimmable LED light source - Max 100VA',
The230VIncandescentHalogenElectronicTransformatorMax300W = '230V Incandescent / Halogen, Electronic transformator - Max 300W',
WithoutRelay = 'Without relay',
}
export enum DescriptionSv {
EndastBrytningAVPå = 'Endast brytning av/på',
ReläbrytningAVPå = 'Reläbrytning av/på',
The230VDimbarLEDLjuskällaMax100VA = '230V dimbar LED ljuskälla - Max 100VA',
The230VDimbarLEDLjuskällaMax200VA = '230V dimbar LED ljuskälla - Max 200VA',
The230VHalogenGlödljusElektroniskTransformatorMax300W = '230V Halogen / Glödljus, Elektronisk transformator - Max 300W',
UtanReläbrytning = 'Utan reläbrytning',
}
export interface Filters {
allowedCountriesFilter: AllowedCountriesFilter;
}
export interface AllowedCountriesFilter {
countryCodes: CountryCode[];
}
export enum CountryCode {
Fi = 'FI',
No = 'NO',
SE = 'SE',
}
export enum SupportMessage {
PredefinedLoadNonDimmableSupportMessageHTML = 'PredefinedLoadNonDimmableSupportMessageHTML',
}
export enum TitleEn {
IncandescentHalogen = 'Incandescent / Halogen',
LEDTrailingEdgeCommon = 'LED Trailing Edge (Common)',
LeadingEdge = 'Leading edge',
NonDimmableLEDLightSourceMax200VA = 'Non-dimmable LED light source (Max 200VA)',
RelayOnly = 'Relay only',
The010V = '0-10V',
}
export enum TitleSv {
EjDimbarLEDLjuskällaMax200VA = 'Ej dimbar LED-ljuskälla (Max 200VA)',
HalogenGlödljus = 'Halogen / Glödljus',
LEDBakkantVanligast = 'LED Bakkant (Vanligast)',
LEDFramkant = 'LED Framkant',
Reläfunktion = 'Reläfunktion',
The010V = '0-10V',
}
export interface PlejdDevice {
deviceId: string;
installer: PlejdMeshClass;
dirtyInstall: boolean;
dirtyUpdate: boolean;
dirtyClock: boolean;
hardwareId: string;
faceplateId: string;
firmware: Firmware;
createdAt: Date;
updatedAt: Date;
coordinates: Coordinates;
dirtySettings: boolean;
diagnostics: string;
siteId: string;
predefinedLoad: OutputSettingPredefinedLoad;
ACL: AstroEventACL;
objectId: string;
__type: AstroEventType;
className: PlejdDeviceClassName;
}
export enum PlejdDeviceClassName {
PlejdDevice = 'PlejdDevice',
}
export interface Coordinates {
__type: CoordinatesType;
latitude: number;
longitude: number;
}
export enum CoordinatesType {
GeoPoint = 'GeoPoint',
}
export interface PlejdMesh {
siteId: string;
plejdMeshId: string;
meshKey: string;
cryptoKey: string;
createdAt: Date;
updatedAt: Date;
site: PlejdMeshClass;
ACL: AstroEventACL;
objectId: ObjectID;
__type: AstroEventType;
className: SiteClassName;
}
export interface ResourceSet {
scopes: string[];
remoteAccessUsers: string[];
name: string;
type: string;
createdAt: Date;
updatedAt: Date;
ACL: AstroEventACL;
objectId: string;
__type: AstroEventType;
className: string;
}
export interface Room {
siteId: string;
roomId: string;
title: string;
category: string;
imageHash: number;
createdAt: Date;
updatedAt: Date;
ACL: AstroEventACL;
objectId: string;
__type: AstroEventType;
className: RoomClassName;
}
export enum RoomClassName {
Room = 'Room',
}
export interface SceneStep {
sceneId: string;
siteId: string;
deviceId: string;
state: State;
value: number;
output: number;
createdAt: Date;
updatedAt: Date;
ACL: AstroEventACL;
objectId: string;
__type: AstroEventType;
className: SceneStepClassName;
dirty?: boolean;
dirtyRemoved?: boolean;
}
export enum SceneStepClassName {
SceneStep = 'SceneStep',
}
export enum State {
Off = 'Off',
On = 'On',
}
export interface Scene {
title: string;
sceneId: string;
siteId: string;
hiddenFromSceneList: boolean;
settings: string;
createdAt: Date;
updatedAt: Date;
ACL: AstroEventACL;
objectId: string;
__type: AstroEventType;
className: ButtonType;
}
export interface SiteDetailsSite {
installers: ObjectID[];
title: string;
siteId: string;
version: number;
createdAt: Date;
updatedAt: Date;
plejdMesh: PlejdMeshClass;
coordinates: Coordinates;
astroTable: AstroTable;
deviceAstroTable: DeviceAstroTable;
zipCode: string;
city: string;
country: string;
previousOwners: string[];
ACL: AstroEventACL;
objectId: ObjectID;
__type: AstroEventType;
className: SiteClassName;
}
export interface AstroTable {
sunrise: string[];
sunset: string[];
}
export interface DeviceAstroTable {
sunrise: number[];
sunset: number[];
}
export interface SitePermission {
siteId: string;
userId: ObjectID;
user: User;
isOwner: boolean;
isInstaller: boolean;
isUser: boolean;
site: SiteDetailsSite;
createdAt: Date;
updatedAt: Date;
ACL: AstroEventACL;
objectId: string;
__type: AstroEventType;
className: string;
}
export interface User {
profileName: string;
isInstaller: boolean;
email: string;
locale: string;
username: string;
emailVerified: boolean;
createdAt: Date;
updatedAt: Date;
profile: PlejdMeshClass;
_failed_login_count: number;
hasIntegration: boolean;
ACL: UserACL;
objectId: ObjectID;
__type: AstroEventType;
className: SiteClassName;
}
export interface UserACL {
gX1W4p06QS: GX1W4P06QS;
}
export interface GX1W4P06QS {
read: boolean;
write: boolean;
}
export interface StateTimers {
SafetyTimer: any[];
}
export interface TimeEvent {
dirtyDevices?: any[];
dirtyRemovedDevices?: any[];
scheduledDays: number[];
deviceId: string;
siteId: string;
sceneId: string;
fadeTime: number;
activated: boolean;
timeEventId: string;
startTimeIndex: number;
endTimeIndex: number;
startTime: string;
endTime: string;
createdAt: Date;
updatedAt: Date;
dirtyRemove?: boolean;
ACL: AstroEventACL;
targetDevices: TimeEventTargetDevice[];
objectId: string;
__type: AstroEventType;
className: string;
}
export interface TimeEventTargetDevice {
deviceId: string;
startTimeIndex: number;
endTimeIndex: number;
}

50
plejd/types/Configuration.d.ts vendored Normal file
View file

@ -0,0 +1,50 @@
/* eslint-disable no-use-before-define */
export interface AddonInfo {
name: string;
version: string;
slug: string;
description: string;
url: string;
arch: string[];
startup: string;
boot: string;
host_network: boolean;
host_dbus: boolean;
apparmor: boolean;
}
export interface Configuration extends AddonInfo {
options: Options;
schema: Schema;
}
export interface Options {
site: string;
username: string;
password: string;
mqttBroker: string;
mqttUsername: string;
mqttPassword: string;
includeRoomsAsLights: boolean;
preferCachedApiResponse: boolean;
updatePlejdClock: boolean;
logLevel: string;
connectionTimeout: number;
writeQueueWaitTime: number;
}
export interface Schema {
site: string;
username: string;
password: string;
mqttBroker: string;
mqttUsername: string;
mqttPassword: string;
includeRoomsAsLights: string;
preferCachedApiResponse: string;
updatePlejdClock: string;
logLevel: string;
connectionTimeout: string;
writeQueueWaitTime: string;
}

21
plejd/types/DeviceRegistry.d.ts vendored Normal file
View file

@ -0,0 +1,21 @@
/* eslint-disable no-use-before-define */
export type OutputDevices = { [deviceIdAndOutput: string]: OutputDevice };
export interface OutputDevice {
bleOutputAddress: number;
deviceId: string;
dim?: number;
dimmable: boolean;
hiddenFromRoomList?: boolean;
hiddenFromIntegrations?: boolean;
hiddenFromSceneList?: boolean;
name: string;
output: number;
roomId: string;
state: boolean | undefined;
type: string;
typeName: string;
version: string;
uniqueId: string;
}

25
plejd/types/Mqtt.d.ts vendored Normal file
View file

@ -0,0 +1,25 @@
/* eslint-disable no-use-before-define */
export type TopicType = 'config' | 'state' | 'availability' | 'set';
export type TOPIC_TYPES = { [key: string]: TopicType };
export type MqttType = 'light' | 'scene' | 'switch' | 'device_automation';
export type MQTT_TYPES = { [key: string]: MqttType };
export interface OutputDevice {
bleOutputAddress: number;
deviceId: string;
dim?: number;
dimmable: boolean;
hiddenFromRoomList?: boolean;
hiddenFromIntegrations?: boolean;
hiddenFromSceneList?: boolean;
name: string;
output: number;
roomId: string;
state: boolean | undefined;
type: string;
typeName: string;
version: string;
uniqueId: string;
}

11
plejd/types/PlejdApi.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
/* eslint-disable no-use-before-define */
import { ApiSite } from './ApiSite.d.ts';
export type PlejdApi = {
config: any;
deviceRegistry: any;
sessionToken: string;
siteId: string;
siteDetails: ApiSite;
};

5
plejd/typings.json Normal file
View file

@ -0,0 +1,5 @@
{
"globalDependencies": {
"node": "registry:dt/node#7.0.0+20170322231424"
}
}