iobroker.shelly
Version:
Integrate your Shelly devices into ioBroker via MQTT or CoAP
747 lines (636 loc) • 29.6 kB
JavaScript
'use strict';
const crypto = require('node:crypto');
const utils = require('@iobroker/adapter-core');
const objectHelper = require('@apollon/iobroker-tools').objectHelper; // Common adapter utils
const protocolMqtt = require('./lib/protocol/mqtt');
const protocolCoap = require('./lib/protocol/coap');
const BleDecoder = require('./lib/ble-decoder').BleDecoder;
const adapterName = require('./package.json').name.split('.').pop();
const tcpPing = require('tcp-ping');
const EventEmitter = require('events').EventEmitter;
class Shelly extends utils.Adapter {
constructor(options) {
super({
...options,
name: adapterName,
});
this.isUnloaded = false;
this.serverMqtt = null;
this.serverCoap = null;
this.firmwareUpdateTimeout = null;
this.onlineCheckTimeout = null;
this.onlineDevices = {};
this.eventEmitter = new EventEmitter();
this.bleDecoder = new BleDecoder();
this.on('ready', this.onReady.bind(this));
this.on('stateChange', this.onStateChange.bind(this));
this.on('fileChange', this.onFileChange.bind(this));
this.on('unload', this.onUnload.bind(this));
}
async onReady() {
try {
// Upgrade older config
if (await this.migrateConfig()) {
return;
}
this.eventEmitter.setMaxListeners(Infinity);
await this.mkdirAsync(this.namespace, 'scripts');
this.subscribeForeignFiles(this.namespace, '*');
this.subscribeStates('*');
objectHelper.init(this);
const protocol = this.config.protocol || 'coap';
await this.setOnlineFalse();
// Start online check
await this.onlineCheck();
// Start MQTT server
setImmediate(() => {
if (protocol === 'both' || protocol === 'mqtt') {
this.validateQosConfig();
this.log.info(
`Starting in MQTT mode. Listening on ${this.config.bind}:${this.config.port} (QoS ${this.config.qos})`,
);
if (!this.config.mqttusername || this.config.mqttusername.length === 0) {
this.log.error('MQTT Username is missing!');
}
if (!this.config.mqttpassword || this.config.mqttpassword.length === 0) {
this.log.error('MQTT Password is missing!');
}
this.serverMqtt = new protocolMqtt.MQTTServer(this, objectHelper, this.eventEmitter);
this.serverMqtt.listen();
}
});
// Start CoAP server
setImmediate(() => {
if (protocol === 'both' || protocol === 'coap') {
this.log.info(`Starting in CoAP mode. Listening on ${this.config.coapbind}:5683`);
this.serverCoap = new protocolCoap.CoAPServer(this, objectHelper, this.eventEmitter);
this.serverCoap.listen();
}
});
if (this.config.autoupdate) {
this.log.info(`[firmwareUpdate] Auto-Update enabled - devices will be updated automatically`);
// Wait 10 seconds for devices to connect
this.setTimeout(() => this.autoFirmwareUpdate(), 10 * 1000);
} else {
// Wait 10 seconds for devices to connect
this.setTimeout(() => this.firmwareNotify(), 10 * 1000);
}
} catch (err) {
this.log.error(`[onReady] Startup error: ${err}`);
}
}
/**
* Validates and normalizes the QoS configuration value.
* This is the central place where config.qos is retrieved and validated.
* Note: This modifies config.qos as a runtime override only; it does not persist to the configuration file.
*/
validateQosConfig() {
// Default to 0 if qos is not set
if (this.config.qos === undefined || this.config.qos === null) {
this.config.qos = 0;
}
const qos = parseInt(this.config.qos, 10);
// Check for invalid values (< 0 or > 2)
if (isNaN(qos) || qos < 0 || qos > 2) {
this.log.error(
`[MQTT] Invalid QoS value configured: ${this.config.qos}. QoS must be 0, 1, or 2. Setting QoS to 0.`,
);
this.config.qos = 0;
return;
}
// Log warning if QoS 2 is configured
if (qos === 2) {
this.log.warn(
`[MQTT] QoS 2 is configured but not officially supported. Using QoS 2 anyway. Consider using QoS 0 or 1 instead.`,
);
}
// Normalize to integer type
this.config.qos = qos;
}
onStateChange(id, state) {
// Warning, state can be null if it was deleted
if (state && !state.ack) {
const stateId = this.removeNamespace(id);
if (stateId === 'info.update') {
this.log.debug(`[onStateChange] "info.update" state changed - starting update on every device`);
this.eventEmitter.emit('onFirmwareUpdate');
} else if (stateId === 'info.downloadScripts') {
this.log.debug(
`[onStateChange] "info.downloadScripts" state changed - starting script download of every device`,
);
this.eventEmitter.emit('onScriptDownload');
} else if (stateId.startsWith('ble.') && stateId.endsWith('.encryptionKey')) {
this.log.debug(`[onStateChange] "${stateId}" state changed - checking new encryption key`);
const encryptionKey = String(state.val).replaceAll('-', '').toUpperCase();
if (encryptionKey.length == 32) {
this.setState(stateId, { val: encryptionKey, ack: true });
}
} else {
this.log.debug(
`[onStateChange] "${id}" state changed: ${JSON.stringify(state)} - forwarding to objectHelper`,
);
if (objectHelper) {
objectHelper.handleStateChange(id, state);
}
}
}
}
onFileChange(id, fileName, size) {
this.log.debug(`[onFileChange]: id: ${id}, fileName: ${fileName}, size: ${size}`);
}
/**
* @param callback
*/
onUnload(callback) {
this.isUnloaded = true;
if (this.onlineCheckTimeout) {
this.clearTimeout(this.onlineCheckTimeout);
this.onlineCheckTimeout = null;
}
this.setOnlineFalse();
if (this.firmwareUpdateTimeout) {
this.clearTimeout(this.firmwareUpdateTimeout);
this.firmwareUpdateTimeout = null;
}
try {
this.log.debug('[onUnload] Closing adapter');
if (this.serverCoap) {
try {
this.log.debug(`[onUnload] Stopping CoAP server`);
this.serverCoap.destroy();
} catch {
// ignore
}
}
if (this.serverMqtt) {
try {
this.log.debug(`[onUnload] Stopping MQTT server`);
this.serverMqtt.destroy();
} catch {
// ignore
}
}
callback();
} catch {
// this.log.error('Error');
callback();
}
}
/**
* Online-Check via TCP ping (when using CoAP)
*/
async onlineCheck() {
const valPort = 80;
if (this.onlineCheckTimeout) {
this.clearTimeout(this.onlineCheckTimeout);
this.onlineCheckTimeout = null;
}
try {
const deviceIds = await this.getAllDeviceIds();
for (const deviceId of deviceIds) {
const stateHostaname = await this.getStateAsync(`${deviceId}.hostname`);
const valHostname = stateHostaname ? stateHostaname.val : undefined;
if (valHostname) {
this.log.debug(`[onlineCheck] Checking ${deviceId} on ${valHostname}:${valPort}`);
tcpPing.probe(valHostname, valPort, (error, isAlive) => this.deviceStatusUpdate(deviceId, isAlive));
}
}
} catch (e) {
this.log.error(e.toString());
}
this.onlineCheckTimeout = this.setTimeout(() => {
this.onlineCheckTimeout = null;
this.onlineCheck();
}, 60 * 1000); // Restart online check in 60 seconds
}
async deviceStatusUpdate(deviceId, status) {
if (this.isUnloaded) {
return;
}
if (!deviceId) {
return;
}
this.log.debug(`[deviceStatusUpdate] ${deviceId}: ${status}`);
// Check if device object exists
const knownDeviceIds = await this.getAllDeviceIds();
if (!knownDeviceIds.includes(deviceId)) {
this.log.silly(
`[deviceStatusUpdate] ${deviceId} is not in list of known devices: ${JSON.stringify(knownDeviceIds)}`,
);
return;
}
// Update online status
const idOnline = `${deviceId}.online`;
const onlineState = await this.getStateAsync(idOnline);
if (onlineState) {
// Compare to previous value
const prevValue = onlineState.val ? onlineState.val === 'true' || onlineState.val === true : false;
if (prevValue != status) {
await this.setState(idOnline, { val: status, ack: true });
}
}
// Update connection state
const oldOnlineDeviceCount = Object.keys(this.onlineDevices).length;
if (status) {
this.onlineDevices[deviceId] = true;
} else if (Object.prototype.hasOwnProperty.call(this.onlineDevices, deviceId)) {
delete this.onlineDevices[deviceId];
}
const newOnlineDeviceCount = Object.keys(this.onlineDevices).length;
// Check online devices
if (oldOnlineDeviceCount !== newOnlineDeviceCount) {
this.log.debug(`[deviceStatusUpdate] Online devices: ${JSON.stringify(Object.keys(this.onlineDevices))}`);
if (newOnlineDeviceCount > 0) {
await this.setState('info.connection', { val: true, ack: true });
} else {
await this.setState('info.connection', { val: false, ack: true });
}
}
}
isOnline(deviceId) {
return Object.prototype.hasOwnProperty.call(this.onlineDevices, deviceId);
}
async getAllDeviceIds() {
const devices = await this.getDevicesAsync();
return devices.map(device => this.removeNamespace(device._id));
}
async setOnlineFalse() {
const deviceIds = await this.getAllDeviceIds();
for (const d in deviceIds) {
const deviceId = deviceIds[d];
const idOnline = `${deviceId}.online`;
const onlineState = await this.getStateAsync(idOnline);
if (onlineState) {
await this.setState(idOnline, { val: false, ack: true });
}
await this.extendObject(deviceId, {
common: {
color: null, // Remove color from previous versions
},
});
}
this.onlineDevices = {};
await this.setState('info.connection', { val: false, ack: true });
}
autoFirmwareUpdate() {
if (this.isUnloaded) {
return;
}
if (this.config.autoupdate) {
this.log.debug(`[firmwareUpdate] Starting update on every device`);
this.eventEmitter.emit('onFirmwareUpdate');
this.firmwareUpdateTimeout = this.setTimeout(
() => {
this.firmwareUpdateTimeout = null;
this.autoFirmwareUpdate();
},
15 * 60 * 1000,
); // Restart firmware update in 15 minutes
}
}
async firmwareNotify() {
if (this.isUnloaded) {
return;
}
if (!this.config.autoupdate) {
this.log.debug(`[firmwareNotify] Starting firmware check on every device`);
const availableUpdates = [];
try {
const deviceIds = await this.getAllDeviceIds();
for (const deviceId of deviceIds) {
const stateFirmware = await this.getStateAsync(`${deviceId}.firmware`);
const hasNewFirmware = stateFirmware && stateFirmware.ack ? stateFirmware.val : false;
if (hasNewFirmware) {
const deviceObj = await this.getObjectAsync(deviceId);
availableUpdates.push(`${deviceObj?.common.name} (${deviceId})`);
}
}
} catch (e) {
this.log.error(e.toString());
}
if (availableUpdates.length > 0) {
this.registerNotification('shelly', 'deviceUpdates', availableUpdates.join('\n'));
}
this.firmwareUpdateTimeout = this.setTimeout(
() => {
this.firmwareUpdateTimeout = null;
this.firmwareNotify();
},
15 * 60 * 1000,
); // Restart firmware check in 15 minutes
}
}
convertFromHex(hexStr) {
let bytes = [];
for (let i = 0; i < hexStr.length; i += 2) {
let byte = parseInt(hexStr.slice(i, i + 2), 16);
bytes.push(byte);
}
return bytes;
}
async processBleMessage(val) {
if (val && val.scriptVersion && val.src && val.payload) {
this.log.debug(`[processBleMessage] Received payload ${JSON.stringify(val.payload)} from ${val.src}`);
const expectedScriptVersion = '1.2';
if (val.scriptVersion !== expectedScriptVersion) {
this.log.warn(
`[BLE] ${val.srcBle.mac} (via ${val.src}): Script version ${val.scriptVersion} is not supported (expected ${expectedScriptVersion}), see documentation for latest version`,
);
}
await this.extendObject(
`ble.${val.srcBle.mac}`,
{
type: 'device',
common: {
name: val.srcBle.mac,
icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMjAgNTEyIj48IS0tISBGb250IEF3ZXNvbWUgUHJvIDYuNC4yIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlIChDb21tZXJjaWFsIExpY2Vuc2UpIENvcHlyaWdodCAyMDIzIEZvbnRpY29ucywgSW5jLiAtLT48cGF0aCBkPSJNMTk2LjQ4IDI2MC4wMjNsOTIuNjI2LTEwMy4zMzNMMTQzLjEyNSAwdjIwNi4zM2wtODYuMTExLTg2LjExMS0zMS40MDYgMzEuNDA1IDEwOC4wNjEgMTA4LjM5OUwyNS42MDggMzY4LjQyMmwzMS40MDYgMzEuNDA1IDg2LjExMS04Ni4xMTFMMTQ1Ljg0IDUxMmwxNDguNTUyLTE0OC42NDQtOTcuOTEyLTEwMy4zMzN6bTQwLjg2LTEwMi45OTZsLTQ5Ljk3NyA0OS45NzgtLjMzOC0xMDAuMjk1IDUwLjMxNSA1MC4zMTd6TTE4Ny4zNjMgMzEzLjA0bDQ5Ljk3NyA0OS45NzgtNTAuMzE1IDUwLjMxNi4zMzgtMTAwLjI5NHoiLz48L3N2Zz4=',
},
native: {},
},
{ preserve: { common: ['name'] } },
);
await this.delObjectAsync(`ble.${val.srcBle.mac}.rssi`); // moved to receivedBy
await this.setObjectNotExistsAsync(`ble.${val.srcBle.mac}.pid`, {
type: 'state',
common: {
name: {
en: 'Packet ID',
de: 'Paket-ID',
ru: 'ID пакета',
pt: 'ID da embalagem',
nl: 'Pakket-ID',
fr: 'ID du paquet',
it: 'ID pacchetto',
es: 'ID de paquete',
pl: 'Identyfikator opakowania',
uk: 'Код товару',
'zh-cn': '软件包标识',
},
type: 'number',
role: 'value',
read: true,
write: false,
},
native: {},
});
await this.setObjectNotExistsAsync(`ble.${val.srcBle.mac}.receivedBy`, {
type: 'state',
common: {
name: {
en: 'Received by devices',
de: 'Empfangen von Geräten',
ru: 'Получено устройствами',
pt: 'Recebido por dispositivos',
nl: 'Ontvangen door apparaten',
fr: 'Reçu par les appareils',
it: 'Ricevuto da dispositivi',
es: 'Recibido por dispositivos',
pl: 'Otrzymane przez urządzenia',
uk: 'Пристрої',
'zh-cn': '设备接收',
},
type: 'string',
role: 'json',
read: true,
write: false,
def: '{}',
},
native: {},
});
await this.setObjectNotExistsAsync(`ble.${val.srcBle.mac}.encrypted`, {
type: 'state',
common: {
name: {
en: 'Encrypted',
de: 'Verschlüsselt',
ru: 'Зашифрованный',
pt: 'Criptografado',
nl: 'Versleuteld',
fr: 'Chiffres',
it: 'Crittografia',
es: 'Encriptado',
pl: 'Zaszyfrowane',
uk: 'Зашифрований',
'zh-cn': '已加密',
},
type: 'boolean',
role: 'value',
read: true,
write: false,
def: false,
},
native: {},
});
await this.setObjectNotExistsAsync(`ble.${val.srcBle.mac}.encryptionKey`, {
type: 'state',
common: {
name: {
en: 'Encryption key',
de: 'Schlüssel zur Verschlüsselung',
ru: 'Ключ шифрования',
pt: 'Chave de criptografia',
nl: 'Versleutelingssleutel',
fr: 'Clé de chiffrement',
it: 'Chiave di crittografia',
es: 'Clave de cifrado',
pl: 'Klucz szyfrowania',
uk: 'Ключ шифрування',
'zh-cn': '加密密钥',
},
type: 'string',
role: 'value',
read: true,
write: true,
def: '00000000-0000-0000-0000-000000000000',
},
native: {},
});
const rawData = this.convertFromHex(val.payload); // convert hex to bytes
let unpackedData = this.bleDecoder.unpack(rawData);
if (unpackedData !== null) {
this.log.debug(
`[processBleMessage] Received unpacked payload ${JSON.stringify(unpackedData)} from ${val.src}`,
);
await this.setState(`ble.${val.srcBle.mac}.encrypted`, { val: unpackedData.encryption, ack: true });
if (unpackedData.encryption) {
const encryptionKeyState = await this.getStateAsync(`ble.${val.srcBle.mac}.encryptionKey`);
const encryptionKeyHex = encryptionKeyState
? String(encryptionKeyState.val)
: '00000000-0000-0000-0000-000000000000';
const encryptionKey = Buffer.from(encryptionKeyHex.replaceAll('-', ''), 'hex');
const macBuffer = Buffer.from(val.srcBle.mac.replaceAll(':', ''), 'hex');
const dataPrefix = Buffer.concat([Buffer.from('d2fc', 'hex'), Buffer.from(rawData)]);
const uuid = Buffer.from(dataPrefix.slice(0, 2));
const deviceInfo = Buffer.from([dataPrefix[2]]);
const ciphertext = Buffer.from(dataPrefix.slice(3, -8));
const counter = Buffer.from(dataPrefix.slice(-8, -4));
const mic = Buffer.from(dataPrefix.slice(-4));
const nonce = Buffer.concat([macBuffer, uuid, deviceInfo, counter]);
try {
const decipher = crypto.createDecipheriv('aes-128-ccm', encryptionKey, nonce, {
authTagLength: 4,
});
decipher.setAuthTag(mic);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
unpackedData = this.bleDecoder.unpack(decrypted, true);
} catch (err) {
this.log.error(`[processBleMessage] unable to process encrypted payload by ${val.src}: ${err}`);
}
}
if (unpackedData !== null) {
this.log.debug(
`[processBleMessage] Processing (decrypted) payload ${JSON.stringify(unpackedData)} from ${val.src}`,
);
const typesList = {
battery: { type: 'number', unit: '%' },
battery_low: { type: 'number' },
button: {
type: 'number',
states: { 1: 'Single', 2: 'Double', 3: 'Triple', 4: 'Long', 254: 'Long' },
},
dewpoint: { type: 'number', unit: '°C' },
direction: { type: 'number', unit: '°' },
distance_m: { type: 'number', unit: 'm' },
distance_mm: { type: 'number', unit: 'mm' },
gust_speed: { type: 'number', unit: 'm/s' },
humidity: { type: 'number', unit: '%' },
illuminance: { type: 'number' },
light: { type: 'number', states: { 0: 'Dark', 1: 'Twilight', 2: 'Light' } },
moisture: { type: 'number', unit: '%' },
motion: { type: 'number', states: { 0: 'Clear', 1: 'Detected' } },
precipitation: { type: 'number', unit: 'mm' },
pressure: { type: 'number', unit: 'hPa' },
rain_status: { type: 'number', states: { 0: 'Not raining', 1: 'Raining' } },
rotation: { type: 'number' },
speed: { type: 'number', unit: 'm/s' },
temperature: { type: 'number', unit: '°C' },
uv_index: { type: 'number' },
voltage: { type: 'number', unit: 'V' },
window: { type: 'number', states: { 0: 'Closed', 1: 'Open' } },
};
const pidState = await this.getStateAsync(`ble.${val.srcBle.mac}.pid`);
const pidOld = pidState && pidState.val ? pidState.val : -1;
const pidNew = unpackedData.pid;
// Check if same message has been received by other Shellys
if (pidOld !== pidNew) {
await this.setState(`ble.${val.srcBle.mac}.pid`, { val: pidNew, ack: true, c: val.src });
await this.setState(`ble.${val.srcBle.mac}.receivedBy`, {
val: JSON.stringify(
{
[val.src]: {
rssi: val.srcBle.rssi,
ts: Date.now(),
},
},
null,
2,
),
ack: true,
});
for (const [key, value] of Object.entries(unpackedData)) {
const typeListKey = key.includes('button_') ? 'button' : key;
if (Object.keys(typesList).includes(typeListKey)) {
const stateType = typesList[typeListKey].type;
let stateValue = undefined;
if (stateType === 'number') {
stateValue = value;
} else if (stateType === 'boolean') {
stateValue = value ? true : false;
}
await this.extendObject(`ble.${val.srcBle.mac}.${key}`, {
type: 'state',
common: {
name: key,
type: stateType,
role: 'value',
read: true,
write: false,
unit: typesList[typeListKey]?.unit,
states: typesList[typeListKey]?.states,
},
native: {},
});
if (stateValue != undefined) {
await this.setState(`ble.${val.srcBle.mac}.${key}`, {
val: stateValue,
ack: true,
c: val.src,
});
}
} else {
this.log.debug(`[processBleMessage] skipping unknown attribute ${key} from ${val.src}`);
}
}
} else {
try {
const receivedByState = await this.getStateAsync(`ble.${val.srcBle.mac}.receivedBy`);
if (receivedByState) {
const deviceList = JSON.parse(receivedByState.val);
deviceList[val.src] = {
rssi: val.srcBle.rssi,
ts: Date.now(),
};
await this.setState(`ble.${val.srcBle.mac}.receivedBy`, {
val: JSON.stringify(deviceList, null, 2),
ack: true,
});
}
} catch (err) {
this.log.error(
`[processBleMessage] Unable to extend device list (receivedBy) of ${val.srcBle.mac}: ${err}`,
);
}
}
}
}
}
}
removeNamespace(id) {
const re = new RegExp(`${this.namespace}*\\.`, 'g');
return id.replace(re, '');
}
async migrateConfig() {
const native = {};
if (this.config?.http_username) {
native.httpusername = this.config.http_username;
native.http_username = '';
}
if (this.config?.http_password) {
native.httppassword = this.config.http_password;
native.http_password = '';
}
if (this.config?.user) {
native.mqttusername = this.config.user;
native.user = '';
}
if (this.config?.password) {
native.mqttpassword = this.config.password;
native.password = '';
}
if (this.config?.keys) {
native.blacklist = this.config.keys.map(b => {
return { id: b.blacklist };
});
native.keys = null;
}
if (this.config) {
this.config.polltime = parseInt(this.config?.polltime, 10);
}
if (Object.keys(native).length) {
this.log.info('Migrate some data from old Shelly Adapter version. Restarting Shelly Adapter now!');
await this.extendForeignObjectAsync(`system.adapter.${this.namespace}`, { native });
return true;
}
return false;
}
}
if (module.parent) {
// Export the constructor in compact mode
/**
* @param [options]
*/
module.exports = options => new Shelly(options);
} else {
// otherwise start the instance directly
new Shelly();
}