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.
- package/README.md +39 -24
- package/admin/i18n/de.json +3 -1
- package/admin/i18n/en.json +3 -1
- package/admin/i18n/es.json +4 -2
- package/admin/i18n/fr.json +4 -2
- package/admin/i18n/it.json +5 -3
- package/admin/i18n/nl.json +5 -3
- package/admin/i18n/pl.json +4 -2
- package/admin/i18n/pt.json +4 -2
- package/admin/i18n/ru.json +6 -4
- package/admin/i18n/uk.json +5 -3
- package/admin/i18n/zh-cn.json +4 -2
- package/admin/jsonConfig.json +10 -0
- package/build/lib/datapoints.js +213 -114
- package/build/lib/datapoints.js.map +1 -1
- package/build/lib/deviceManager.js +437 -24
- package/build/lib/deviceManager.js.map +1 -1
- package/build/lib/discovery.js +206 -0
- package/build/lib/discovery.js.map +1 -0
- package/build/lib/firmware.js +65 -0
- package/build/lib/firmware.js.map +1 -0
- package/build/lib/i18n/de.json +33 -1
- package/build/lib/i18n/en.json +33 -1
- package/build/lib/i18n/es.json +33 -1
- package/build/lib/i18n/fr.json +33 -1
- package/build/lib/i18n/it.json +33 -1
- package/build/lib/i18n/nl.json +33 -1
- package/build/lib/i18n/pl.json +33 -1
- package/build/lib/i18n/pt.json +33 -1
- package/build/lib/i18n/ru.json +33 -1
- package/build/lib/i18n/uk.json +33 -1
- package/build/lib/i18n/zh-cn.json +33 -1
- package/build/lib/mqttBase.js +115 -18
- package/build/lib/mqttBase.js.map +1 -1
- package/build/lib/server.js +11 -1
- package/build/lib/server.js.map +1 -1
- package/build/lib/usedResources.js +67 -0
- package/build/lib/usedResources.js.map +1 -0
- package/build/main.js +20 -0
- package/build/main.js.map +1 -1
- package/io-package.json +86 -28
- 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
|
-
|
|
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 (
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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: '
|
|
1328
|
+
return { refresh: 'devices' };
|
|
916
1329
|
}
|
|
917
1330
|
}
|
|
918
1331
|
exports.default = SonoffDeviceManagement;
|