UNPKG

iobroker.shelly

Version:
1,176 lines 83.4 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); const adapter_core_1 = require("@iobroker/adapter-core"); const dm_utils_1 = require("@iobroker/dm-utils"); const crypto = __importStar(require("node:crypto")); const dgram = __importStar(require("node:dgram")); const http = __importStar(require("node:http")); const os = __importStar(require("node:os")); class HttpAuthError extends Error { constructor() { super('Device is password-protected'); this.name = 'HttpAuthError'; } } // eslint-disable-next-line @typescript-eslint/no-require-imports const datapoints = require('../../lib/datapoints'); /** Group metadata: display name key (for i18n) and representative icon */ const groupMeta = { relay: { nameKey: 'Relays & Switches', icon: 'adapter/shelly/icons/shellyplus1.png' }, dimmer: { nameKey: 'Dimmers', icon: 'adapter/shelly/icons/shellydimmerg3.png' }, plug: { nameKey: 'Plugs', icon: 'adapter/shelly/icons/shellyplusplugs.png' }, light: { nameKey: 'Lights', icon: 'adapter/shelly/icons/shellybulbduo.png' }, meter: { nameKey: 'Energy Meters', icon: 'adapter/shelly/icons/shellyem3.png' }, sensor: { nameKey: 'Sensors', icon: 'adapter/shelly/icons/shellyhtg3.png' }, cover: { nameKey: 'Shutters & Covers', icon: 'adapter/shelly/icons/shellyshutter.png' }, input: { nameKey: 'Buttons & Inputs', icon: 'adapter/shelly/icons/shellyplusi4.png' }, climate: { nameKey: 'Climate', icon: 'adapter/shelly/icons/shellytrv.png' }, gateway: { nameKey: 'Gateways', icon: 'adapter/shelly/icons/shellyblugw.png' }, ble: { nameKey: 'BLE Devices', icon: 'adapter/shelly/icons/ble.svg' }, other: { nameKey: 'Other', icon: 'adapter/shelly/icons/shellyplus1.png' }, }; /** * DeviceManager Class */ class ShellyDeviceManagement extends dm_utils_1.DeviceManagement { ready; states = {}; objects = {}; constructor(adapter) { super(adapter); // Initialize i18n this.ready = adapter_core_1.I18n.init(__dirname, adapter) .catch(error => this.adapter.log.error(`Cannot initialize i18n: ${error}`)) .then(() => this.init()); } async init() { const devices = await this.adapter.getDevicesAsync(); const stateObjects = await this.adapter.getStatesOfAsync(); const states = await this.adapter.getStatesAsync('*'); for (const device of devices) { this.objects[device._id] = device; } for (const state of stateObjects) { this.objects[state._id] = state; } for (const id in states) { this.states[id] = states[id]; } // Set icon on existing devices if not yet set or still using old base64 SVG icon for (const device of devices) { if (!device.common.icon || String(device.common.icon).startsWith('data:')) { const shortDeviceId = device._id.substring(this.adapter.namespace.length + 1); const deviceClass = this.states[`${this.adapter.namespace}.${shortDeviceId}.class`]?.val; if (deviceClass) { const iconName = datapoints.getDeviceIcon(deviceClass); await this.adapter.extendObjectAsync(device._id, { common: { icon: `icons/${iconName}.png` } }); device.common.icon = `icons/${iconName}.png`; } } } await this.adapter.subscribeStatesAsync('*'); await this.adapter.subscribeObjectsAsync('*'); } getInstanceInfo() { const protocol = this.adapter.config.protocol || 'coap'; const actions = protocol === 'coap' ? [] : [ { id: 'discover', icon: 'search', title: adapter_core_1.I18n.getTranslatedObject('Discover devices'), description: adapter_core_1.I18n.getTranslatedObject('Scan network for Shelly devices via mDNS'), timeout: 40_000, handler: async (context) => await this.handleDiscoverDevices(context), }, ]; return { apiVersion: 'v3', actions, smallCards: true, }; } onStateChange(id, state) { if (state) { if (!this.states[id]) { // trigger DM update this.states[id] = state; } else if (this.states[id].val !== state.val) { // trigger DM update this.states[id] = state; } } else if (this.states[id]) { // trigger DM update delete this.states[id]; } } onObjectChange(id, obj) { if (obj) { this.objects[id] = obj; } else { delete this.objects[id]; } } /** * Load all shelly devices * * @param context */ async loadDevices(context) { // Wait that i18n is initialized await this.ready; for (const deviceId in this.objects) { const device = this.objects[deviceId]; if (device.type !== 'device') { continue; } const shortDeviceId = device._id.substring(this.adapter.namespace.length + 1); const ns = this.adapter.namespace; const isBle = shortDeviceId.startsWith('ble.'); const isOnline = isBle || !!this.states[`${ns}.${shortDeviceId}.online`]?.val; const hostname = this.states[`${ns}.${shortDeviceId}.hostname`]?.val; const firmwareUpdate = this.states[`${ns}.${shortDeviceId}.firmware`]?.val; const firmwareVersion = this.states[`${ns}.${shortDeviceId}.version`]?.val; const battery = this.states[`${ns}.${shortDeviceId}.DevicePower0.BatteryPercent`] ? this.states[`${ns}.${shortDeviceId}.DevicePower0.BatteryPercent`].val : this.states[`${ns}.${shortDeviceId}.sensor.battery`]?.val || this.states[`${ns}.${shortDeviceId}.battery`]?.val || undefined; const rssi = isOnline ? this.states[`${ns}.${shortDeviceId}.rssi`]?.val || undefined : undefined; const bleInfo = isBle ? this.classifyBleDevice(shortDeviceId) : undefined; const deviceClass = this.states[`${ns}.${shortDeviceId}.class`]?.val; const group = this.getDeviceGroup(deviceClass, isBle); const res = { id: device._id, identifier: hostname || undefined, name: device.common.name, icon: isBle ? `adapter/shelly/icons/${bleInfo.icon}.png` : this.getIcon(device._id), color: !isBle && !isOnline ? '#fff' : undefined, backgroundColor: !isBle && !isOnline ? '#f44336' : undefined, group, model: isBle ? bleInfo.model : this.states[`${ns}.${shortDeviceId}.model`]?.val || this.states[`${ns}.${shortDeviceId}.type`]?.val || adapter_core_1.I18n.getTranslatedObject('unknown'), status: { connection: isBle ? undefined : isOnline ? 'connected' : 'disconnected', rssi, battery, }, // Firmware update indicator. BLE devices are updated through their gateway, not here. // `available` is read live from the firmware state, so the indicator stays in sync // without a full device-list refresh and the device can be filtered by "update available". update: isBle || firmwareUpdate === undefined ? undefined : { available: { stateId: `${ns}.${shortDeviceId}.firmware` }, version: firmwareVersion || undefined, }, hasDetails: true, customInfo: this.buildCustomInfo(device._id, shortDeviceId, isBle), controls: await this.buildControls(shortDeviceId, isBle), actions: [ { id: 'rename', icon: 'edit', description: adapter_core_1.I18n.getTranslatedObject('Rename this device'), handler: async (deviceId, context) => await this.handleRenameDevice(deviceId, context), }, ...(this.states[`${this.adapter.namespace}.${shortDeviceId}.hostname`]?.val ? [ { id: 'web', icon: 'web', description: adapter_core_1.I18n.getTranslatedObject('Open device interface'), url: `http://${this.states[`${this.adapter.namespace}.${shortDeviceId}.hostname`]?.val}`, }, ] : []), ...(isOnline && firmwareUpdate ? [ { id: dm_utils_1.ACTIONS.UPDATE, icon: 'update', description: adapter_core_1.I18n.getTranslatedObject('Update firmware'), handler: async (deviceId, context) => await this.handleFirmwareUpdate(deviceId, context), }, ] : []), ], }; context.addDevice(res); } } getDeviceDetails(deviceId) { const device = this.objects[deviceId]; if (device?.type !== 'device') { return null; } const shortDeviceId = device._id.substring(this.adapter.namespace.length + 1); const ns = this.adapter.namespace; const isBle = shortDeviceId.startsWith('ble.'); if (isBle) { return this.getBleDeviceDetails(deviceId, shortDeviceId); } const hostname = this.states[`${ns}.${shortDeviceId}.hostname`]?.val; const version = this.states[`${ns}.${shortDeviceId}.version`]?.val; const model = this.states[`${ns}.${shortDeviceId}.model`]?.val; const gen = this.states[`${ns}.${shortDeviceId}.gen`]?.val; const id = this.states[`${ns}.${shortDeviceId}.id`]?.val; const type = this.states[`${ns}.${shortDeviceId}.type`]?.val; const rssi = this.states[`${ns}.${shortDeviceId}.rssi`]?.val; const uptime = this.states[`${ns}.${shortDeviceId}.uptime`]?.val; const firmware = this.states[`${ns}.${shortDeviceId}.firmware`]?.val; const protocol = this.states[`${ns}.${shortDeviceId}.protocol`]?.val; const items = {}; if (hostname) { items._deviceLink = { // @ts-expect-error staticLink is OK type: 'staticLink', href: `http://${hostname}`, label: adapter_core_1.I18n.getTranslatedObject('Open device web interface'), button: true, icon: 'web', newLine: true, }; } if (id) { items.id = { type: 'staticInfo', label: adapter_core_1.I18n.getTranslatedObject('Device ID'), data: id, addColon: true, copyToClipboard: true, }; } if (model) { items.model = { type: 'staticInfo', label: adapter_core_1.I18n.getTranslatedObject('Model'), data: model, addColon: true, }; } if (type) { items.deviceType = { type: 'staticInfo', label: adapter_core_1.I18n.getTranslatedObject('Type'), data: type, addColon: true, }; } if (gen) { items.gen = { type: 'staticInfo', label: adapter_core_1.I18n.getTranslatedObject('Generation'), data: gen, addColon: true, }; } if (hostname) { items.hostname = { type: 'staticInfo', label: adapter_core_1.I18n.getTranslatedObject('IP address'), data: hostname, addColon: true, copyToClipboard: true, }; } if (version) { items.version = { type: 'staticInfo', label: adapter_core_1.I18n.getTranslatedObject('Firmware version'), data: version, addColon: true, }; } if (firmware !== undefined) { items.firmware = { type: 'staticInfo', label: adapter_core_1.I18n.getTranslatedObject('Firmware update available'), data: firmware ? '✓' : '✗', addColon: true, }; } if (protocol) { items.protocol = { type: 'staticInfo', label: adapter_core_1.I18n.getTranslatedObject('Protocol'), data: protocol, addColon: true, }; } if (rssi !== undefined) { items.rssi = { type: 'staticInfo', label: adapter_core_1.I18n.getTranslatedObject('RSSI'), data: rssi, unit: 'dBm', addColon: true, }; } if (uptime !== undefined) { items.uptime = { type: 'staticInfo', label: adapter_core_1.I18n.getTranslatedObject('Uptime'), data: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`, addColon: true, }; } return { id: deviceId, schema: { type: 'panel', items, }, }; } getBleDeviceDetails(deviceId, shortDeviceId) { const ns = this.adapter.namespace; const prefix = `${ns}.${shortDeviceId}.`; const items = {}; // Collect all states for this BLE device and show them as staticInfo const stateEntries = []; for (const id in this.objects) { if (!id.startsWith(prefix) || this.objects[id].type !== 'state') { continue; } stateEntries.push({ name: id.substring(prefix.length), obj: this.objects[id], }); } stateEntries.sort((a, b) => a.name.localeCompare(b.name)); for (const entry of stateEntries) { const state = this.states[`${prefix}${entry.name}`]; const common = entry.obj.common; const val = state?.val; if (val === undefined || val === null || entry.name === 'ReceivedBy' || entry.name === 'pid') { continue; } // Format the label from state name (e.g. "temperature" -> "Temperature") const label = entry.name.charAt(0).toUpperCase() + entry.name.slice(1).replace(/_/g, ' '); // Format value: use common.states mapping if available, otherwise format by type let displayVal; const states = common.states; if (states && String(val) in states) { displayVal = states[String(val)] !== undefined ? `${states[String(val)]}(${String(val)})` : String(val); } else if (typeof val === 'number') { displayVal = common.unit ? `${Math.round(val * 100) / 100} ${common.unit}` : Math.round(val * 100) / 100; } else if (typeof val === 'boolean') { displayVal = val ? '✓' : '✗'; } else { displayVal = String(val); } items[entry.name] = { type: 'staticInfo', label, data: displayVal, addColon: true, }; } return { id: deviceId, schema: { type: 'panel', items, }, }; } classifyBleDevice(shortDeviceId) { const prefix = `${this.adapter.namespace}.${shortDeviceId}.`; const stateNames = new Set(); for (const id in this.states) { if (id.startsWith(prefix)) { stateNames.add(id.substring(prefix.length)); } } // Also check objects for states that may not have a value yet for (const id in this.objects) { if (id.startsWith(prefix) && this.objects[id].type === 'state') { stateNames.add(id.substring(prefix.length)); } } if (stateNames.has('precipitation') || stateNames.has('gust_speed') || stateNames.has('rain_status')) { return { model: 'BLU Outdoor Weather Station', icon: 'ble-ht' }; } if (stateNames.has('motion')) { return { model: 'BLU Motion Sensor', icon: 'ble-motion' }; } if (stateNames.has('window') || stateNames.has('rotation')) { return { model: 'BLU Door/Window Sensor', icon: 'ble-door-window' }; } if (stateNames.has('humidity') && stateNames.has('temperature')) { return { model: 'BLU H&T Sensor', icon: 'ble-ht' }; } if (stateNames.has('button_4')) { return { model: 'BLU Button Tough 4', icon: 'ble-button4' }; } if (stateNames.has('button_1')) { return { model: 'BLU Button 1', icon: 'ble-button1' }; } return { model: 'Bluetooth', icon: 'ble-button1' }; } getDeviceGroup(deviceClass, isBle) { const groupKey = isBle ? 'ble' : datapoints.getDeviceGroupByClass(deviceClass || ''); const meta = groupMeta[groupKey] || groupMeta.other; return { key: groupKey, name: adapter_core_1.I18n.getTranslatedObject(meta.nameKey), icon: meta.icon, }; } buildCustomInfo(deviceId, shortDeviceId, isBle) { const ns = this.adapter.namespace; const prefix = `${ns}.${shortDeviceId}.`; const items = {}; if (isBle) { // BLE devices use direct state names if (this.states[`${prefix}temperature`] !== undefined) { items.temperature = { type: 'state', oid: `${shortDeviceId}.temperature`, control: 'text', unit: '°C', digits: 2, label: adapter_core_1.I18n.getTranslatedObject('Temperature'), size: 12, style: { opacity: 0.7, }, }; } if (this.states[`${prefix}humidity`] !== undefined) { items.humidity = { type: 'state', oid: `${shortDeviceId}.humidity`, control: 'text', unit: '%', digits: 1, label: adapter_core_1.I18n.getTranslatedObject('Humidity'), size: 12, style: { opacity: 0.7 }, }; } if (this.states[`${prefix}illuminance`] !== undefined) { items.illuminance = { type: 'state', oid: `${shortDeviceId}.illuminance`, control: 'text', unit: 'lux', digits: 0, label: adapter_core_1.I18n.getTranslatedObject('Illuminance'), size: 12, style: { opacity: 0.7 }, }; } // BLE boolean/alarm sensor states const bleBoolSensors = [ { stateId: 'motion', key: 'motion', label: 'Motion', trueText: 'Detected', falseText: 'Clear', trueColor: 'green', }, { stateId: 'door', key: 'door', label: 'Door', trueText: 'Open', falseText: 'Closed', trueColor: 'orange', }, { stateId: 'window', key: 'window', label: 'Window', trueText: 'Open', falseText: 'Closed', trueColor: 'orange', }, { stateId: 'smoke', key: 'smoke', label: 'Smoke', trueText: 'Alarm', falseText: 'OK', trueColor: 'red', }, { stateId: 'vibration', key: 'vibration', label: 'Vibration', trueText: 'Detected', falseText: 'Clear', trueColor: 'orange', }, ]; for (const sensor of bleBoolSensors) { if (this.states[`${prefix}${sensor.stateId}`] !== undefined) { items[sensor.key] = { type: 'state', oid: `${shortDeviceId}.${sensor.stateId}`, readOnly: true, options: [ { label: adapter_core_1.I18n.getTranslatedObject(sensor.falseText), value: 0 }, { label: adapter_core_1.I18n.getTranslatedObject(sensor.trueText), value: 1, color: sensor.trueColor }, ], trueText: adapter_core_1.I18n.getTranslatedObject(sensor.trueText), falseText: adapter_core_1.I18n.getTranslatedObject(sensor.falseText), trueTextStyle: { color: sensor.trueColor }, blinkOnUpdate: true, label: adapter_core_1.I18n.getTranslatedObject(sensor.label), size: 12, style: { opacity: 0.7 }, }; } } // BLE numeric sensors if (this.states[`${prefix}rotation`] !== undefined) { items.rotation = { type: 'state', oid: `${shortDeviceId}.rotation`, control: 'text', unit: '°', label: adapter_core_1.I18n.getTranslatedObject('Tilt'), size: 12, style: { opacity: 0.7 }, }; } } else { // Gen1 sensor paths // Gen1 numeric sensors const gen1NumericSensors = [ { stateId: 'tmp.temperatureC', key: 'temperature', label: 'Temperature', unit: '°C', digits: 2 }, { stateId: 'sensor.temperatureC', key: 'sensorTemperature', label: 'Temperature', unit: '°C', digits: 2, }, { stateId: 'hum.value', key: 'humidity', label: 'Humidity', unit: '%', digits: 1 }, { stateId: 'sensor.humidity', key: 'sensorHumidity', label: 'Humidity', unit: '%', digits: 1 }, { stateId: 'sensor.lux', key: 'lux', label: 'Illuminance', unit: 'lux', digits: 0 }, { stateId: 'sensor.tilt', key: 'tilt', label: 'Tilt', unit: '°', digits: 0 }, { stateId: 'tmp.valvePosition', key: 'valve', label: 'Valve position', unit: '%', digits: 0 }, ]; for (const sensor of gen1NumericSensors) { if (this.states[`${prefix}${sensor.stateId}`] !== undefined) { items[sensor.key] = { type: 'state', oid: `${shortDeviceId}.${sensor.stateId}`, control: 'text', unit: sensor.unit, digits: sensor.digits, label: adapter_core_1.I18n.getTranslatedObject(sensor.label), size: 12, style: { opacity: 0.7 }, }; } } // Gen1 boolean sensors (true/false values - use trueText/falseText) const gen1BoolSensors = [ { stateId: 'sensor.motion', key: 'motion', label: 'Motion', trueText: 'Detected', falseText: 'Clear', trueColor: 'green', }, { stateId: 'sensor.door', key: 'door', label: 'Door', trueText: 'Open', falseText: 'Closed', trueColor: 'orange', }, { stateId: 'sensor.vibration', key: 'vibration', label: 'Vibration', trueText: 'Detected', falseText: 'Clear', trueColor: 'orange', }, { stateId: 'sensor.flood', key: 'flood', label: 'Flood', trueText: 'Alarm', falseText: 'OK', trueColor: 'red', }, { stateId: 'smoke.value', key: 'smoke', label: 'Smoke', trueText: 'Alarm', falseText: 'OK', trueColor: 'red', }, ]; for (const sensor of gen1BoolSensors) { if (this.states[`${prefix}${sensor.stateId}`] !== undefined) { items[sensor.key] = { type: 'state', oid: `${shortDeviceId}.${sensor.stateId}`, options: [ { label: adapter_core_1.I18n.getTranslatedObject(sensor.falseText), value: 0 }, { label: adapter_core_1.I18n.getTranslatedObject(sensor.trueText), value: 1, color: sensor.trueColor }, ], trueText: adapter_core_1.I18n.getTranslatedObject(sensor.trueText), falseText: adapter_core_1.I18n.getTranslatedObject(sensor.falseText), trueTextStyle: { color: sensor.trueColor }, blinkOnUpdate: true, label: adapter_core_1.I18n.getTranslatedObject(sensor.label), size: 12, style: { opacity: 0.7 }, }; } } // Gen2+: scan for Temperature{id}.Celsius, Humidity{id}.Relative, Illuminance{id}.Lux for (const stateId of Object.keys(this.states)) { if (!stateId.startsWith(prefix)) { continue; } const suffix = stateId.substring(prefix.length); const tempMatch = suffix.match(/^(Temperature\d+)\.Celsius$/); if (tempMatch) { items[`temp_${tempMatch[1]}`] = { type: 'state', oid: `${shortDeviceId}.${suffix}`, control: 'text', unit: '°C', digits: 2, label: adapter_core_1.I18n.getTranslatedObject('Temperature'), size: 12, style: { opacity: 0.7 }, }; } const humMatch = suffix.match(/^(Humidity\d+)\.Relative$/); if (humMatch) { items[`hum_${humMatch[1]}`] = { type: 'state', oid: `${shortDeviceId}.${suffix}`, control: 'text', unit: '%', digits: 1, label: adapter_core_1.I18n.getTranslatedObject('Humidity'), size: 12, style: { opacity: 0.7 }, }; } const luxMatch = suffix.match(/^(Illuminance\d+)\.Lux$/); if (luxMatch) { items[`lux_${luxMatch[1]}`] = { type: 'state', oid: `${shortDeviceId}.${suffix}`, control: 'text', unit: 'lux', digits: 0, label: adapter_core_1.I18n.getTranslatedObject('Illuminance'), size: 12, style: { opacity: 0.7 }, }; } // Flood alarm const floodMatch = suffix.match(/^(Flood\d+)\.alarm$/); if (floodMatch) { items[`flood_${floodMatch[1]}`] = { type: 'state', oid: `${shortDeviceId}.${suffix}`, readOnly: true, options: [ { label: adapter_core_1.I18n.getTranslatedObject('OK'), value: 0 }, { label: adapter_core_1.I18n.getTranslatedObject('Alarm'), value: 1, color: 'red' }, ], trueText: adapter_core_1.I18n.getTranslatedObject('OK'), falseText: adapter_core_1.I18n.getTranslatedObject('Alarm'), trueTextStyle: { color: 'red' }, blinkOnUpdate: true, label: adapter_core_1.I18n.getTranslatedObject('Flood'), size: 12, style: { opacity: 0.7 }, }; } // Cover position const coverMatch = suffix.match(/^(Cover\d+)\.Position$/); if (coverMatch) { items[`cover_${coverMatch[1]}`] = { type: 'state', oid: `${shortDeviceId}.${suffix}`, control: 'text', unit: '%', label: adapter_core_1.I18n.getTranslatedObject('Cover position'), size: 12, style: { opacity: 0.7, }, }; } } } if (Object.keys(items).length === 0) { return undefined; } return { id: deviceId, schema: { type: 'panel', items, }, }; } async buildControls(shortDeviceId, isBle) { if (isBle) { return []; } const ns = this.adapter.namespace; const prefix = `${ns}.${shortDeviceId}.`; const controls = []; let labelForFirstControl; // Check device mode to filter controls for multi-mode devices const deviceMode = this.states[`${ns}.${shortDeviceId}.Sys.deviceMode`]?.val; // Map device mode to allowed channel prefixes const allowedChannels = { rgb: ['RGB'], rgbw: ['RGBW'], light: ['Light'], color: ['lights'], white: ['white'], switch: ['Relay'], relay: ['Relay'], cover: ['Cover'], roller: ['Relay'], // Gen1 roller mode still uses Relay channels }; const allowed = deviceMode ? allowedChannels[deviceMode] : undefined; const ownStates = []; const usedStates = []; // Collect all switch states for this device const keys = Object.keys(this.objects); for (const stateId of keys) { if (!stateId.startsWith(prefix)) { continue; } const obj = this.objects[stateId]; if (obj?.type !== 'state') { continue; } ownStates.push(obj._id); const common = obj.common; if (!common || common.role !== 'switch' || common.type !== 'boolean' || common.write === false || common.read === false || usedStates.includes(stateId)) { continue; } const suffix = stateId.substring(prefix.length); // Match Relay{id}.Switch, Light{id}.Switch, RGB{id}.Switch, RGBW{id}.Switch const match = suffix.match(/^(Relay|Light|RGB|RGBW|lights|white)(\d*)\.Switch$/); if (!match) { continue; } const channelType = match[1]; // Filter by device mode if set if (allowed && !allowed.includes(channelType)) { continue; } const channelIdx = match[2]; // Build a label like "Relay 0", "Light 1", etc. let label; if (channelType === 'lights') { label = 'Light'; } else if (channelType === 'white') { label = `White ${channelIdx}`; } else { label = channelIdx ? `${channelType} ${channelIdx}` : channelType; } const control = { id: suffix.replace('.', '_'), type: 'switch', stateId: `${shortDeviceId}.${suffix}`, state: (await this.adapter.getForeignStateAsync(obj._id)) || { val: null, ts: Date.now(), ack: true }, handler: async (_deviceId, _actionId, state) => { await this.adapter.setForeignStateAsync(obj._id, state); return { val: state, ts: Date.now(), ack: true }; }, }; usedStates.push(obj._id); if (!controls.length) { // If control has no label, it will be shown directly on labelForFirstControl = label; controls.push(control); continue; } else if (controls.length === 1 && labelForFirstControl) { controls[0].label = labelForFirstControl; } control.label = label; controls.push(control); } // Collect slider states (brightness, cover position) for (const stateId of ownStates) { const obj = this.objects[stateId]; const common = obj.common; if (common?.type !== 'number' || common?.write === false || (common.max === undefined && common.unit !== '%') || usedStates.includes(stateId)) { continue; } const role = common.role || ''; if (!role.startsWith('level.') && role !== 'level') { continue; } const suffix = stateId.substring(prefix.length); // Match brightness: Light{id}.Brightness, RGB{id}.Brightness, RGBW{id}.Brightness, lights.brightness, white{id}.brightness // Match cover: Cover{id}.Position const match = suffix.match(/^(Light|RGB|RGBW|lights|white|Cover|Number)(\d*)\.(Brightness|brightness|Position|Value)$/); if (!match) { continue; } const channelType = match[1]; const channelIdx = match[2]; // Filter by device mode if set if (allowed && !allowed.includes(channelType)) { continue; } let label; if (channelType === 'Cover') { label = channelIdx ? `Cover ${channelIdx}` : 'Cover'; } else if (channelType === 'lights') { label = 'Brightness'; } else if (channelType === 'white') { label = `White ${channelIdx}`; } else { label = channelIdx ? `${channelType} ${channelIdx}` : channelType; } usedStates.push(obj._id); const control = { id: suffix.replace('.', '_'), type: 'slider', stateId: `${shortDeviceId}.${suffix}`, min: common.min ?? 0, max: common.max ?? 100, unit: common.unit || (common.max ?? 100) === 100 ? '%' : undefined, label, state: (await this.adapter.getForeignStateAsync(obj._id)) || { val: null, ts: Date.now(), ack: true }, handler: async (_deviceId, _actionId, state) => { await this.adapter.setForeignStateAsync(obj._id, state); return { val: state, ts: Date.now(), ack: true }; }, }; controls.push(control); } // Collect color controls (hex color states) for (const stateId of ownStates) { const obj = this.objects[stateId]; const common = obj.common; if (common?.type !== 'string' || !common?.write || usedStates.includes(stateId)) { continue; } const suffix = stateId.substring(prefix.length); // Match Gen2+: RGB{id}.Color, RGBW{id}.Color; Gen1: lights.rgbw const match = suffix.match(/^(RGB|RGBW)(\d*)\.Color$/) || suffix.match(/^(lights)\.(rgbw)$/); if (!match) { continue; } const channelType = match[1]; // Filter by device mode if set if (allowed && !allowed.includes(channelType)) { continue; } const control = { id: suffix.replace('.', '_'), type: 'color', stateId: `${shortDeviceId}.${suffix}`, label: adapter_core_1.I18n.getTranslatedObject('Color'), state: (await this.adapter.getForeignStateAsync(obj._id)) || { val: null, ts: Date.now(), ack: true }, handler: async (_deviceId, _actionId, state) => { await this.adapter.setForeignStateAsync(obj._id, state); return { val: state, ts: Date.now(), ack: true }; }, }; controls.push(control); } let group = { id: 'group_settings', type: 'group', label: adapter_core_1.I18n.getTranslatedObject('Settings'), }; // Collect writable number states without common.max (not sliders) for (const stateId of ownStates) { const obj = this.objects[stateId]; const common = obj.common; if (common?.write === false || (common.type !== 'number' && common.type !== 'string') || usedStates.includes(stateId)) { continue; } const suffix = stateId.substring(prefix.length); // Skip states already handled above if (controls.some(c => c.id === suffix.replace('.', '_'))) { continue; } // Extract channel type for device mode filtering const channelMatch = suffix.match(/^(Relay|Light|RGB|RGBW|lights|white|Cover)(\d*)\./); if (channelMatch) { const channelType = channelMatch[1]; if (allowed && !allowed.includes(channelType)) { continue; } } // Build label from state name: "Relay0.transition" → "Relay 0 transition" const label = suffix.replace(/(\d+)/, ' $1 ').replace(/\./g, ' ').trim(); let options; if (common.states) { if (Array.isArray(common.states)) { options = []; common.states.forEach((state) => options.push({ value: state, label: state })); } else { options = []; Object.keys(common.states).forEach(key => options.push({ value: key, label: String(common.states[key]), })); } } usedStates.push(obj._id); const control = { group: 'group_settings', id: suffix.replace('.', '_'), type: options ? 'select' : common.type === 'number' ? 'number' : 'text', stateId: `${shortDeviceId}.${suffix}`, min: common.min, max: common.max, unit: common.unit, options, label, state: (await this.adapter.getForeignStateAsync(obj._id)) || { val: null, ts: Date.now(), ack: true }, handler: async (_deviceId, _actionId, state) => { await this.adapter.setForeignStateAsync(obj._id, state); return { val: state, ts: Date.now(), ack: true }; }, }; if (group) { controls.push(group); group = null; } controls.push(control); } // Collect buttons for (const stateId of ownStates) { const obj = this.objects[stateId]; const common = obj.common; if (common?.write === false || common.type !== 'boolean' || usedStates.includes(stateId)) { continue; } const suffix = stateId.substring(prefix.length); // Skip states already handled above if (controls.some(c => c.id === suffix.replace('.', '_'))) { continue; } // Extract channel type for device mode filtering const channelMatch = suffix.match(/^(Relay|Light|RGB|RGBW|lights|white|Cover)(\d*)\./); if (channelMatch) { const channelType = channelMatch[1]; if (allowed && !allowed.includes(channelType)) { continue; } } // Build label from state name: "Relay0.transition" → "Relay 0 transition" const label = suffix.replace(/(\d+)/, ' $1 ').replace(/\./g, ' ').trim(); usedStates.push(obj._id); const control = { group: 'group_settings', id: suffix.replace('.', '_'), type: 'button', stateId: `${shortDeviceId}.${suffix}`, label, variant: 'outlined', handler: async (_deviceId, _actionId, state) => { await this.adapter.setForeignStateAsync(obj._id, state); return { val: state, ts: Date.now(), ack: true }; }, }; if (group) { controls.push(group); group = null; } controls.push(control); } // Sort by ID for consistent order // controls.sort((a, b) => a.id.localeCompare(b.id)); return controls; } getIcon(deviceId) { const device = this.objects[deviceId]; if (device && 'icon' in device.common && device.common.icon) { const icon = String(device.common.icon); if (icon.startsWith('data:')) { // Old base64 SVG icon - skip, will be migrated on next restart return 'node'; } return `adapter/shelly/${icon}`; } return 'node'; } /** * * @param id ID to rename * @param context context sendet from Backend */ async handleRenameDevice(id, context) { const result = await context.showForm({ type: 'panel', items: { newName: { type: 'text', trim: false, placeholder: '', }, }, }, { data: { newName: '', }, title: adapter_core_1.I18n.getTranslatedObject('Enter new name'), }); if (result?.newName === undefined || result?.newName === '') { return { refresh: 'none' }; } const obj = { common: { name: result.newName, }, }; const res = await this.adapter.extendForeignObjectAsync(id, obj); if (res === null) { this.adapter.log.warn(`Can not rename device ${id}: ${JSON.stringify(res)}`); return { refresh: 'none' }; } return { refresh: 'device' }; } async handleFirmwareUpdate(id, context) { const shortDeviceId = id.substring(this.adapter.namespace.length + 1); const ns = this.adapter.namespace; const stateId = `${ns}.${shortDeviceId}.firmwareupdate`; const gen = this.states[`${ns}.${shortDeviceId}.gen`]?.val; const hasProgress = gen !== undefined && gen >= 2; const progress = await context.openProgress('Updating firmware...', { indeterminate: !hasProgress, value: hasProgress ? 0 : undefined, label: adapter_core_1.I18n.getTranslatedObject('Starting update...'), }); try { await this.adapter.setStateAsync(stateId, true, false); this.adapter.log.info(`[DeviceManager] Firmware update triggered for ${shortDeviceId}`); const progressStateId = `${ns}.${shortDeviceId}.firmwareupdateProgress`; const firmwareStateId = `${ns}.${shortDeviceId}.firmware`; const TIMEOUT = 5 * 60 * 1000; // 5 minutes await new Promise(resolve => { const startTime = Date.now(); const checkInterval = setInterval(async () => { // Timeout if (Date.now() - startTime > TIMEOUT) { clearInterval(checkInterval); resolve(); return; } // Check progress for Gen2+ if (hasProgress) { const progressState = this.states[progressStateId]; if (progressState?.val !== undefined && progressState?.val !== null) { const pct = Number(progressState.val); await progress.update({ value: pct, label: `${pct}%`, }); } } // Check if firmware update flag cleared (update complete) const firmwareState = this.states[firmwareStateId]; if (firmwareState?.val === false) { clearInterval(checkInterval); await progress.update({ value: 100, label: adapter_core_1.I18n.getTranslatedObject('Firmware update complete'), }); resolve(); } // Check if device went offline and came back (Gen1 reboot after update) if (!hasProgress) { const online = this.states[`${ns}.${shortDeviceId}.online`]; if (online?.val === false) { await progress.update({ label: adapter_core_1.I18n.getTranslatedObject('Device is rebooting...'), }); } } }, 2000); }); } catch (err)