iobroker.sonoff 4.2.1 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +39 -24
  2. package/admin/i18n/de.json +3 -1
  3. package/admin/i18n/en.json +3 -1
  4. package/admin/i18n/es.json +4 -2
  5. package/admin/i18n/fr.json +4 -2
  6. package/admin/i18n/it.json +5 -3
  7. package/admin/i18n/nl.json +5 -3
  8. package/admin/i18n/pl.json +4 -2
  9. package/admin/i18n/pt.json +4 -2
  10. package/admin/i18n/ru.json +6 -4
  11. package/admin/i18n/uk.json +5 -3
  12. package/admin/i18n/zh-cn.json +4 -2
  13. package/admin/jsonConfig.json +10 -0
  14. package/build/lib/datapoints.js +213 -114
  15. package/build/lib/datapoints.js.map +1 -1
  16. package/build/lib/deviceManager.js +437 -24
  17. package/build/lib/deviceManager.js.map +1 -1
  18. package/build/lib/discovery.js +206 -0
  19. package/build/lib/discovery.js.map +1 -0
  20. package/build/lib/firmware.js +65 -0
  21. package/build/lib/firmware.js.map +1 -0
  22. package/build/lib/i18n/de.json +33 -1
  23. package/build/lib/i18n/en.json +33 -1
  24. package/build/lib/i18n/es.json +33 -1
  25. package/build/lib/i18n/fr.json +33 -1
  26. package/build/lib/i18n/it.json +33 -1
  27. package/build/lib/i18n/nl.json +33 -1
  28. package/build/lib/i18n/pl.json +33 -1
  29. package/build/lib/i18n/pt.json +33 -1
  30. package/build/lib/i18n/ru.json +33 -1
  31. package/build/lib/i18n/uk.json +33 -1
  32. package/build/lib/i18n/zh-cn.json +33 -1
  33. package/build/lib/mqttBase.js +115 -18
  34. package/build/lib/mqttBase.js.map +1 -1
  35. package/build/lib/server.js +11 -1
  36. package/build/lib/server.js.map +1 -1
  37. package/build/lib/usedResources.js +67 -0
  38. package/build/lib/usedResources.js.map +1 -0
  39. package/build/main.js +20 -0
  40. package/build/main.js.map +1 -1
  41. package/io-package.json +86 -28
  42. package/package.json +3 -3
@@ -2,6 +2,10 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const adapter_core_1 = require("@iobroker/adapter-core");
4
4
  const dm_utils_1 = require("@iobroker/dm-utils");
5
+ const firmware_1 = require("./firmware");
6
+ const discovery_1 = require("./discovery");
7
+ /** How long the firmware update of a device may take, including the download and the restart */
8
+ const FIRMWARE_UPDATE_TIMEOUT = 5 * 60 * 1000;
5
9
  /** Icon shown for every device, since the adapter does not ship per-model icons */
6
10
  const DEVICE_ICON = 'adapter/sonoff/admin/sonoff.png';
7
11
  /** Group metadata: display name key (for i18n) */
@@ -69,7 +73,6 @@ const INFO_ITEMS = [
69
73
  { stateId: 'Mac', label: 'MAC address', copy: true },
70
74
  { stateId: 'Gateway', label: 'Gateway' },
71
75
  { stateId: 'Subnetmask', label: 'Subnet mask' },
72
- { stateId: 'Version', label: 'Firmware version' },
73
76
  { stateId: 'Hardware', label: 'Hardware' },
74
77
  { stateId: 'BuildDateTime', label: 'Build date' },
75
78
  { stateId: 'Core', label: 'Core' },
@@ -166,8 +169,12 @@ class SonoffDeviceManagement extends dm_utils_1.DeviceManagement {
166
169
  ready;
167
170
  states = {};
168
171
  objects = {};
172
+ /** Version of the latest Tasmota release, see `setLatestFirmware` */
173
+ latestFirmware = null;
169
174
  constructor(adapter) {
170
- super(adapter);
175
+ // `true` creates `info.deviceManager` - the state through which the adapter updates an open device
176
+ // manager, e.g. when a device goes offline or a new firmware is available
177
+ super(adapter, true);
171
178
  // Initialize i18n
172
179
  this.ready = adapter_core_1.I18n.init(__dirname, adapter)
173
180
  .catch(error => this.adapter.log.error(`Cannot initialize i18n: ${error}`))
@@ -193,18 +200,308 @@ class SonoffDeviceManagement extends dm_utils_1.DeviceManagement {
193
200
  return {
194
201
  apiVersion: 'v3',
195
202
  smallCards: true,
203
+ communicationStateId: `${this.adapter.namespace}.info.deviceManager`,
204
+ actions: [
205
+ {
206
+ id: 'discover',
207
+ icon: 'search',
208
+ title: adapter_core_1.I18n.getTranslatedObject('Search for devices'),
209
+ description: adapter_core_1.I18n.getTranslatedObject('Search the network for Tasmota devices and connect them with ioBroker'),
210
+ timeout: 120_000,
211
+ handler: async (context) => await this.handleDiscoverDevices(context),
212
+ },
213
+ ],
214
+ };
215
+ }
216
+ /**
217
+ * MQTT settings the devices need to connect with ioBroker: the built-in server of the adapter or, in
218
+ * bridge mode, the external broker
219
+ *
220
+ * @param deviceIp address of a found device, to find the own address in its network
221
+ */
222
+ getMqttDefaults(deviceIp) {
223
+ const config = this.adapter.config;
224
+ if (config.useExternalBroker && config.externalBrokerUrl) {
225
+ try {
226
+ const url = new URL(config.externalBrokerUrl.includes('://')
227
+ ? config.externalBrokerUrl
228
+ : `mqtt://${config.externalBrokerUrl}`);
229
+ return {
230
+ host: url.hostname,
231
+ port: parseInt(url.port, 10) || (url.protocol === 'mqtts:' ? 8883 : 1883),
232
+ user: config.externalBrokerUser || '',
233
+ password: config.externalBrokerPassword || '',
234
+ };
235
+ }
236
+ catch {
237
+ // invalid URL, use the own address
238
+ }
239
+ }
240
+ return {
241
+ host: (0, discovery_1.getLocalIp)(deviceIp) || '',
242
+ port: parseInt(config.port, 10) || 1883,
243
+ user: config.user || '',
244
+ password: config.password || '',
245
+ };
246
+ }
247
+ /**
248
+ * Searches the network for Tasmota devices and connects the selected ones with ioBroker: enables MQTT
249
+ * and writes the address, the port and the credentials of the MQTT server. The devices restart and
250
+ * appear in the list as soon as they are connected.
251
+ *
252
+ * @param context context to show the progress and the forms
253
+ * @param addresses addresses to ask, by default the local networks (for tests)
254
+ */
255
+ async handleDiscoverDevices(context, addresses) {
256
+ await this.ready;
257
+ const progress = await context.openProgress(adapter_core_1.I18n.getTranslatedObject('Searching for Tasmota devices...'), {
258
+ value: 0,
259
+ });
260
+ // Every message to the GUI is the answer to the previous one, so a progress update may only be sent
261
+ // after the previous one was confirmed - otherwise the answers get mixed up and "close" is lost
262
+ const pending = { update: null };
263
+ let lastPercent = 0;
264
+ let found;
265
+ try {
266
+ found = await (0, discovery_1.scanNetwork)((done, total) => {
267
+ const percent = Math.floor((done / total) * 100);
268
+ if (percent !== lastPercent && !pending.update) {
269
+ lastPercent = percent;
270
+ pending.update = progress.update({ value: percent }).finally(() => (pending.update = null));
271
+ }
272
+ }, addresses);
273
+ }
274
+ finally {
275
+ await pending.update;
276
+ await progress.close();
277
+ }
278
+ if (!found.length) {
279
+ await context.showMessage(adapter_core_1.I18n.getTranslatedObject('No Tasmota devices found'));
280
+ return { refresh: false };
281
+ }
282
+ // A device which is connected with this instance needs no configuration
283
+ const connected = new Set();
284
+ for (const id in this.states) {
285
+ const match = id.match(/^(.+)\.INFO\.(Mac|IPAddress|Info2_IPAddress)$/);
286
+ if (match && this.states[`${match[1]}.alive`]?.val === true && this.states[id].val) {
287
+ connected.add(String(this.states[id].val).toUpperCase());
288
+ }
289
+ }
290
+ const isConnected = (device) => connected.has(device.ip) || (!!device.mac && connected.has(device.mac.toUpperCase()));
291
+ const candidates = found.filter(device => !isConnected(device));
292
+ const known = found.filter(device => isConnected(device));
293
+ if (!candidates.length) {
294
+ await context.showMessage(adapter_core_1.I18n.getTranslatedObject('All %s found devices are already connected', known.length));
295
+ return { refresh: false };
296
+ }
297
+ const defaults = this.getMqttDefaults(found[0].ip);
298
+ const items = {};
299
+ const data = { ...defaults, webPassword: '', selectAll: false };
300
+ const noneSelected = [];
301
+ if (candidates.length) {
302
+ items._mqttHeader = {
303
+ type: 'header',
304
+ text: adapter_core_1.I18n.getTranslatedObject('MQTT settings'),
305
+ size: 5,
306
+ };
307
+ items.host = { type: 'text', label: adapter_core_1.I18n.getTranslatedObject('MQTT server'), xs: 12, sm: 8, newLine: true };
308
+ items.port = {
309
+ type: 'number',
310
+ label: adapter_core_1.I18n.getTranslatedObject('MQTT port'),
311
+ min: 1,
312
+ max: 65535,
313
+ xs: 12,
314
+ sm: 4,
315
+ };
316
+ items.user = { type: 'text', label: adapter_core_1.I18n.getTranslatedObject('MQTT user'), xs: 12, sm: 6, newLine: true };
317
+ items.password = { type: 'password', label: adapter_core_1.I18n.getTranslatedObject('MQTT password'), xs: 12, sm: 6 };
318
+ items.webPassword = {
319
+ type: 'password',
320
+ label: adapter_core_1.I18n.getTranslatedObject('Web password of the devices'),
321
+ help: adapter_core_1.I18n.getTranslatedObject('Only required for devices with a web password'),
322
+ xs: 12,
323
+ newLine: true,
324
+ };
325
+ items._newHeader = {
326
+ type: 'header',
327
+ text: adapter_core_1.I18n.getTranslatedObject('Devices (%s)', candidates.length),
328
+ size: 5,
329
+ };
330
+ if (candidates.length > 1) {
331
+ items.selectAll = {
332
+ type: 'checkbox',
333
+ label: adapter_core_1.I18n.getTranslatedObject('Select all'),
334
+ xs: 12,
335
+ newLine: true,
336
+ };
337
+ }
338
+ candidates.forEach((device, i) => {
339
+ let mqtt;
340
+ if (device.protected) {
341
+ mqtt = adapter_core_1.I18n.translate('password protected');
342
+ }
343
+ else if (!device.mqttEnabled) {
344
+ mqtt = adapter_core_1.I18n.translate('MQTT disabled');
345
+ }
346
+ else {
347
+ mqtt = `MQTT: ${device.mqttHost || '?'}:${device.mqttPort || '?'}`;
348
+ }
349
+ items[`add_${i}`] = {
350
+ type: 'checkbox',
351
+ label: `${device.name || device.hostname || device.ip} — ${device.ip} (${mqtt})`,
352
+ xs: 12,
353
+ newLine: true,
354
+ onChange: { alsoDependsOn: ['selectAll'], calculateFunc: 'data.selectAll', ignoreOwnChanges: true },
355
+ };
356
+ data[`add_${i}`] = false;
357
+ noneSelected.push(`!data.add_${i}`);
358
+ });
359
+ }
360
+ if (known.length) {
361
+ items._knownHeader = {
362
+ type: 'header',
363
+ text: adapter_core_1.I18n.getTranslatedObject('Already connected (%s)', known.length),
364
+ size: 5,
365
+ };
366
+ known.forEach((device, i) => {
367
+ items[`known_${i}`] = {
368
+ type: 'staticInfo',
369
+ label: device.name || device.hostname || device.ip,
370
+ data: device.ip,
371
+ addColon: true,
372
+ newLine: true,
373
+ };
374
+ });
375
+ }
376
+ const result = await context.showForm({ type: 'panel', items }, {
377
+ data,
378
+ title: adapter_core_1.I18n.getTranslatedObject('Connect devices with ioBroker'),
379
+ ...(noneSelected.length
380
+ ? { applyDisabledRule: noneSelected.join(' && ') }
381
+ : { ignoreApplyDisabled: true }),
382
+ });
383
+ const selected = candidates.filter((_device, i) => result?.[`add_${i}`]);
384
+ if (!result || !selected.length) {
385
+ return { refresh: false };
386
+ }
387
+ const settings = {
388
+ host: String(result.host || '').trim(),
389
+ port: parseInt(String(result.port), 10) || 1883,
390
+ user: String(result.user || '').trim(),
391
+ password: String(result.password || ''),
196
392
  };
393
+ if (!settings.host) {
394
+ await context.showMessage(adapter_core_1.I18n.getTranslatedObject('The MQTT server is missing'));
395
+ return { refresh: false };
396
+ }
397
+ const webPassword = String(result.webPassword || '') || undefined;
398
+ const configProgress = await context.openProgress(adapter_core_1.I18n.getTranslatedObject('Configuring devices...'), {
399
+ value: 0,
400
+ });
401
+ const lines = [];
402
+ for (let i = 0; i < selected.length; i++) {
403
+ const device = selected[i];
404
+ const label = device.name || device.hostname || device.ip;
405
+ await configProgress.update({ value: Math.round((i / selected.length) * 100), label });
406
+ try {
407
+ await (0, discovery_1.configureMqtt)(device.ip, settings, webPassword);
408
+ lines.push(`✓ ${label} (${device.ip})`);
409
+ this.adapter.log.info(`Device ${label} (${device.ip}) connected with MQTT server ${settings.host}`);
410
+ }
411
+ catch (err) {
412
+ const reason = err instanceof discovery_1.TasmotaAuthError
413
+ ? adapter_core_1.I18n.translate('the web password is missing or wrong')
414
+ : err.message;
415
+ lines.push(`✗ ${label} (${device.ip}): ${reason}`);
416
+ this.adapter.log.warn(`Cannot configure ${label} (${device.ip}): ${reason}`);
417
+ }
418
+ }
419
+ await configProgress.close();
420
+ await context.showMessage(`${adapter_core_1.I18n.translate('The devices restart and connect with ioBroker')}:\n${lines.join('\n')}`);
421
+ return { refresh: true };
197
422
  }
198
423
  onStateChange(id, state) {
424
+ const previous = this.states[id];
199
425
  if (state) {
200
426
  // Also on an unchanged value: the timestamp decides which copy of a data point that exists
201
427
  // more than once is the current one (see `buildControls` and `getPowerEntries`)
202
428
  this.states[id] = state;
203
429
  }
204
- else if (this.states[id]) {
205
- // trigger DM update
430
+ else if (previous) {
206
431
  delete this.states[id];
207
432
  }
433
+ if (previous?.val === state?.val) {
434
+ return;
435
+ }
436
+ // Connection, signal and battery are bound to their states (see `buildDeviceStatus`), the GUI
437
+ // follows them by itself
438
+ if (/\.INFO\.(\w+_)?Version$/.test(id) && previous) {
439
+ // A new firmware version (e.g. after an update) changes the update indicator and the actions
440
+ this.refreshGui();
441
+ }
442
+ }
443
+ /**
444
+ * Stores the version of the latest Tasmota release. Devices with an older firmware get the update
445
+ * indicator and the action to update it.
446
+ *
447
+ * @param version e.g. "15.0.1", null if unknown
448
+ */
449
+ setLatestFirmware(version) {
450
+ if (this.latestFirmware !== version) {
451
+ this.latestFirmware = version;
452
+ this.refreshGui();
453
+ }
454
+ }
455
+ /** Reloads all devices in an open device manager */
456
+ refreshGui() {
457
+ this.sendCommandToGui({ command: 'all' }).catch(e => this.adapter.log.debug(`Cannot update the device manager: ${e}`));
458
+ }
459
+ /**
460
+ * Firmware version of a device, e.g. "14.2.0(tasmota)". Tasmota reports it on boot in INFO1
461
+ * ("INFO.Info1_Version", older versions "INFO.Version") and when asked for STATUS2 ("INFO.Version"),
462
+ * so the most recently updated one is the current one.
463
+ *
464
+ * @param prefix `<namespace>.<deviceId>.`
465
+ */
466
+ getFirmwareVersion(prefix) {
467
+ let newest;
468
+ for (const id in this.states) {
469
+ if (!id.startsWith(`${prefix}INFO.`) || !/^(\w+_)?Version$/.test(id.substring(prefix.length + 5))) {
470
+ continue;
471
+ }
472
+ const state = this.states[id];
473
+ if ((0, firmware_1.parseVersion)(state?.val) && (!newest || state.ts > newest.ts)) {
474
+ newest = state;
475
+ }
476
+ }
477
+ return newest?.val;
478
+ }
479
+ /**
480
+ * Connection, signal strength and battery of a device, as shown in the header of its card. They are
481
+ * bound to their states, so an open device manager shows every change without reloading the devices.
482
+ *
483
+ * The device manager expects the signal strength in dBm. Tasmota reports it as "Signal" (dBm) and
484
+ * as "RSSI", which is the link quality in percent - so "RSSI" is only used if there is no "Signal"
485
+ * and it is already in dBm (negative, e.g. from a Zigbee device).
486
+ *
487
+ * @param prefix `<namespace>.<deviceId>.`
488
+ */
489
+ buildDeviceStatus(prefix) {
490
+ let rssiSuffix = this.findDataPointSuffix(prefix, 'Signal');
491
+ if (!rssiSuffix) {
492
+ const suffix = this.findDataPointSuffix(prefix, 'RSSI');
493
+ const val = suffix ? this.states[`${prefix}${suffix}`]?.val : undefined;
494
+ rssiSuffix = typeof val === 'number' && val < 0 ? suffix : undefined;
495
+ }
496
+ const batterySuffix = this.findDataPointSuffix(prefix, 'BatteryPercentage');
497
+ return {
498
+ connection: {
499
+ stateId: `${prefix}alive`,
500
+ mapping: { true: 'connected', false: 'disconnected' },
501
+ },
502
+ rssi: rssiSuffix ? { stateId: `${prefix}${rssiSuffix}` } : undefined,
503
+ battery: batterySuffix ? { stateId: `${prefix}${batterySuffix}` } : undefined,
504
+ };
208
505
  }
209
506
  onObjectChange(id, obj) {
210
507
  if (obj) {
@@ -382,30 +679,29 @@ class SonoffDeviceManagement extends dm_utils_1.DeviceManagement {
382
679
  const hostname = this.states[`${prefix}INFO.Hostname`]?.val;
383
680
  const ip = this.states[`${prefix}INFO.IPAddress`]?.val;
384
681
  const model = this.states[`${prefix}INFO.Module`]?.val || undefined;
385
- const rssiSuffix = this.findDataPointSuffix(prefix, 'RSSI');
386
- const rssi = rssiSuffix
387
- ? this.states[`${prefix}${rssiSuffix}`]?.val
388
- : undefined;
389
- const batterySuffix = this.findDataPointSuffix(prefix, 'BatteryPercentage');
390
- const battery = batterySuffix
391
- ? this.states[`${prefix}${batterySuffix}`]?.val
392
- : undefined;
393
682
  const clientId = device.native?.clientId;
394
683
  const group = this.getDeviceGroup(suffixes);
684
+ const firmware = this.getFirmwareVersion(prefix);
685
+ const updateAvailable = (0, firmware_1.isNewerVersion)(this.latestFirmware, firmware);
395
686
  const res = {
396
687
  id: device._id,
397
688
  identifier: hostname || ip || clientId || undefined,
398
689
  name: device.common?.name || shortDeviceId,
399
690
  icon: DEVICE_ICON,
400
- color: !alive ? '#fff' : undefined,
401
- backgroundColor: !alive ? '#f44336' : undefined,
691
+ // Red header while the device is offline, follows "alive" without reloading
692
+ color: { stateId: `${prefix}alive`, mapping: { false: '#fff' } },
693
+ backgroundColor: { stateId: `${prefix}alive`, mapping: { false: '#f44336' } },
402
694
  group,
403
695
  model,
404
- status: {
405
- connection: alive ? 'connected' : 'disconnected',
406
- rssi,
407
- battery,
408
- },
696
+ status: this.buildDeviceStatus(prefix),
697
+ // Only Tasmota devices report a firmware version which can be compared with the release
698
+ update: firmware
699
+ ? {
700
+ available: updateAvailable,
701
+ version: firmware,
702
+ newVersion: updateAvailable ? this.latestFirmware : undefined,
703
+ }
704
+ : undefined,
409
705
  hasDetails: true,
410
706
  customInfo: this.buildCustomInfo(device._id, prefix),
411
707
  controls: this.buildControls(shortDeviceId, prefix),
@@ -423,6 +719,20 @@ class SonoffDeviceManagement extends dm_utils_1.DeviceManagement {
423
719
  confirmation: adapter_core_1.I18n.getTranslatedObject('This deletes all data points of this device (except its name). They will be recreated automatically the next time the device reports its state. Continue?'),
424
720
  handler: async (deviceId, context) => await this.handleRecreateDevice(deviceId, context),
425
721
  },
722
+ ...(alive && updateAvailable
723
+ ? [
724
+ {
725
+ // Also called when the user clicks on the update indicator
726
+ id: dm_utils_1.ACTIONS.UPDATE,
727
+ icon: 'update',
728
+ description: adapter_core_1.I18n.getTranslatedObject('Update firmware'),
729
+ confirmation: adapter_core_1.I18n.getTranslatedObject('Update the firmware from %s to %s? The device downloads the firmware from its OTA URL and restarts.', firmware, this.latestFirmware),
730
+ // The GUI waits for the answer until the device has restarted
731
+ timeout: FIRMWARE_UPDATE_TIMEOUT + 30_000,
732
+ handler: async (deviceId, context) => await this.handleFirmwareUpdate(deviceId, context),
733
+ },
734
+ ]
735
+ : []),
426
736
  ...(hostname || ip
427
737
  ? [
428
738
  {
@@ -489,14 +799,33 @@ class SonoffDeviceManagement extends dm_utils_1.DeviceManagement {
489
799
  copyToClipboard: info.copy,
490
800
  };
491
801
  }
492
- const rssiSuffix = this.findDataPointSuffix(prefix, 'RSSI');
802
+ // The version is reported in two places, see `getFirmwareVersion`
803
+ const firmware = this.getFirmwareVersion(prefix);
804
+ if (firmware) {
805
+ items._firmware = {
806
+ type: 'staticInfo',
807
+ label: adapter_core_1.I18n.getTranslatedObject('Firmware version'),
808
+ data: firmware,
809
+ addColon: true,
810
+ };
811
+ if ((0, firmware_1.isNewerVersion)(this.latestFirmware, firmware)) {
812
+ items._newFirmware = {
813
+ type: 'staticInfo',
814
+ label: adapter_core_1.I18n.getTranslatedObject('Available firmware'),
815
+ data: this.latestFirmware,
816
+ addColon: true,
817
+ };
818
+ }
819
+ }
820
+ // "Signal" is in dBm, Tasmota's "RSSI" is the link quality in percent (see `buildDeviceStatus`)
821
+ const rssiSuffix = this.findDataPointSuffix(prefix, 'Signal') || this.findDataPointSuffix(prefix, 'RSSI');
493
822
  const rssi = rssiSuffix ? this.states[`${prefix}${rssiSuffix}`]?.val : undefined;
494
- if (rssi !== undefined) {
823
+ if (typeof rssi === 'number') {
495
824
  items._rssi = {
496
825
  type: 'staticInfo',
497
826
  label: adapter_core_1.I18n.getTranslatedObject('RSSI'),
498
827
  data: rssi,
499
- unit: 'dBm',
828
+ unit: rssi < 0 ? 'dBm' : '%',
500
829
  addColon: true,
501
830
  };
502
831
  }
@@ -800,6 +1129,20 @@ class SonoffDeviceManagement extends dm_utils_1.DeviceManagement {
800
1129
  group = null;
801
1130
  }
802
1131
  if (pass === 'button') {
1132
+ // A toggle like "Fade" can be switched on and off. A button would only ever send
1133
+ // "true", so it could never switch it off again - only real buttons stay buttons.
1134
+ if (!state.common.role?.startsWith('button')) {
1135
+ controls.push({
1136
+ group: 'group_settings',
1137
+ id: controlId(state.name),
1138
+ type: 'switch',
1139
+ stateId: `${shortDeviceId}.${state.suffix}`,
1140
+ label: state.name.replace(/_/g, ' '),
1141
+ state: currentState(state.id),
1142
+ handler: stateHandler(state.id),
1143
+ });
1144
+ continue;
1145
+ }
803
1146
  controls.push({
804
1147
  group: 'group_settings',
805
1148
  id: controlId(state.name),
@@ -871,7 +1214,77 @@ class SonoffDeviceManagement extends dm_utils_1.DeviceManagement {
871
1214
  this.adapter.log.warn(`Can not rename device ${id}: ${JSON.stringify(res)}`);
872
1215
  return { refresh: 'none' };
873
1216
  }
874
- return { refresh: 'device' };
1217
+ return { refresh: 'devices' };
1218
+ }
1219
+ /**
1220
+ * Starts the firmware update of a Tasmota device with "Upgrade 1": the device downloads the firmware
1221
+ * from its own OTA URL (by default the official release of its build variant) and restarts. Tasmota
1222
+ * does not report the progress, so the dialog follows the restart and waits for the new version, which
1223
+ * the device reports after the restart (INFO1, and STATUS2 on request of the adapter).
1224
+ *
1225
+ * @param id device (channel) ID
1226
+ * @param context context to show the progress
1227
+ */
1228
+ async handleFirmwareUpdate(id, context) {
1229
+ const ns = this.adapter.namespace;
1230
+ const prefix = `${id}.`;
1231
+ const shortDeviceId = id.substring(ns.length + 1);
1232
+ const oldVersion = this.getFirmwareVersion(prefix);
1233
+ const start = Date.now();
1234
+ if (!this.adapter.server?.sendCommand(shortDeviceId, 'Upgrade', '1')) {
1235
+ await context.showMessage(adapter_core_1.I18n.getTranslatedObject('The device is not connected'));
1236
+ return { refresh: 'none' };
1237
+ }
1238
+ this.adapter.log.info(`Firmware update of ${shortDeviceId} started (installed: ${oldVersion})`);
1239
+ const progress = await context.openProgress(adapter_core_1.I18n.getTranslatedObject('Updating firmware...'), {
1240
+ indeterminate: true,
1241
+ label: adapter_core_1.I18n.getTranslatedObject('The device downloads the firmware...'),
1242
+ });
1243
+ // "Upgrade" is only stored if objects are created from RESULT, it tells e.g. from where the
1244
+ // firmware is downloaded or that the download failed
1245
+ let lastMessage = null;
1246
+ let restarted = false;
1247
+ let result;
1248
+ // Every progress update is awaited: the GUI has to confirm one message before the next one is sent
1249
+ while (!result) {
1250
+ await new Promise(resolve => setTimeout(resolve, 2000));
1251
+ const upgradeSuffix = this.findDataPointSuffix(prefix, 'Upgrade');
1252
+ const upgrade = upgradeSuffix ? this.states[`${prefix}${upgradeSuffix}`] : undefined;
1253
+ if (upgrade && upgrade.ts >= start && upgrade.val !== lastMessage) {
1254
+ lastMessage = upgrade.val;
1255
+ if (/fail|error|abort/i.test(String(upgrade.val))) {
1256
+ result = 'failed';
1257
+ break;
1258
+ }
1259
+ await progress.update({ label: String(upgrade.val) });
1260
+ }
1261
+ if (!restarted && this.states[`${prefix}alive`]?.val === false) {
1262
+ restarted = true;
1263
+ await progress.update({ label: adapter_core_1.I18n.getTranslatedObject('The device restarts...') });
1264
+ }
1265
+ const version = this.getFirmwareVersion(prefix);
1266
+ if (version && version !== oldVersion) {
1267
+ result = 'done';
1268
+ }
1269
+ else if (Date.now() - start > FIRMWARE_UPDATE_TIMEOUT) {
1270
+ result = 'timeout';
1271
+ }
1272
+ }
1273
+ await progress.close();
1274
+ const newVersion = this.getFirmwareVersion(prefix);
1275
+ if (result === 'done') {
1276
+ this.adapter.log.info(`Firmware of ${shortDeviceId} updated from ${oldVersion} to ${newVersion}`);
1277
+ await context.showMessage(adapter_core_1.I18n.getTranslatedObject('The firmware was updated to %s', newVersion));
1278
+ }
1279
+ else if (result === 'failed') {
1280
+ this.adapter.log.warn(`Firmware update of ${shortDeviceId} failed: ${lastMessage}`);
1281
+ await context.showMessage(adapter_core_1.I18n.getTranslatedObject('The firmware update failed: %s', String(lastMessage)));
1282
+ }
1283
+ else {
1284
+ this.adapter.log.warn(`Firmware update of ${shortDeviceId}: no new version within 5 minutes`);
1285
+ await context.showMessage(adapter_core_1.I18n.getTranslatedObject('The device did not report a new firmware within 5 minutes. The update may still be running, please check the device.'));
1286
+ }
1287
+ return { refresh: 'devices' };
875
1288
  }
876
1289
  /**
877
1290
  * Deletes every data point of a device - its channel object (and therefore a custom name set via
@@ -912,7 +1325,7 @@ class SonoffDeviceManagement extends dm_utils_1.DeviceManagement {
912
1325
  // too - otherwise they only come back after a restart of the adapter
913
1326
  this.adapter.server?.forgetObjects(id);
914
1327
  this.adapter.log.info(`Removed ${removed} data point(s) of ${id}, they will be recreated automatically`);
915
- return { refresh: 'device' };
1328
+ return { refresh: 'devices' };
916
1329
  }
917
1330
  }
918
1331
  exports.default = SonoffDeviceManagement;