iobroker.sonoff 4.0.0 → 4.2.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 +38 -38
- package/admin/i18n/de.json +26 -24
- package/admin/i18n/en.json +26 -24
- package/admin/i18n/es.json +26 -24
- package/admin/i18n/fr.json +26 -24
- package/admin/i18n/it.json +26 -24
- package/admin/i18n/nl.json +26 -24
- package/admin/i18n/pl.json +26 -24
- package/admin/i18n/pt.json +26 -24
- package/admin/i18n/ru.json +26 -24
- package/admin/i18n/uk.json +26 -24
- package/admin/i18n/zh-cn.json +26 -24
- package/admin/jsonConfig.json +423 -390
- package/build/lib/deviceManager.js +898 -0
- package/build/lib/deviceManager.js.map +1 -0
- package/build/lib/i18n/de.json +61 -0
- package/build/lib/i18n/en.json +61 -0
- package/build/lib/i18n/es.json +61 -0
- package/build/lib/i18n/fr.json +61 -0
- package/build/lib/i18n/it.json +61 -0
- package/build/lib/i18n/nl.json +61 -0
- package/build/lib/i18n/pl.json +61 -0
- package/build/lib/i18n/pt.json +61 -0
- package/build/lib/i18n/ru.json +61 -0
- package/build/lib/i18n/uk.json +61 -0
- package/build/lib/i18n/zh-cn.json +61 -0
- package/build/lib/mqttBase.js +29 -1
- package/build/lib/mqttBase.js.map +1 -1
- package/build/main.js +7 -0
- package/build/main.js.map +1 -1
- package/io-package.json +17 -14
- package/package.json +9 -6
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const adapter_core_1 = require("@iobroker/adapter-core");
|
|
4
|
+
const dm_utils_1 = require("@iobroker/dm-utils");
|
|
5
|
+
/** Icon shown for every device, since the adapter does not ship per-model icons */
|
|
6
|
+
const DEVICE_ICON = 'adapter/sonoff/admin/sonoff.png';
|
|
7
|
+
/** Group metadata: display name key (for i18n) */
|
|
8
|
+
const groupMeta = {
|
|
9
|
+
relay: { nameKey: 'Relays & Switches' },
|
|
10
|
+
light: { nameKey: 'Lights & Dimmers' },
|
|
11
|
+
cover: { nameKey: 'Shutters & Covers' },
|
|
12
|
+
zigbee: { nameKey: 'Zigbee bridges' },
|
|
13
|
+
meter: { nameKey: 'Energy Meters' },
|
|
14
|
+
sensor: { nameKey: 'Sensors' },
|
|
15
|
+
other: { nameKey: 'Other' },
|
|
16
|
+
};
|
|
17
|
+
/** Well-known sensor data points shown on the main tile */
|
|
18
|
+
const SENSOR_ITEMS = [
|
|
19
|
+
{ stateId: 'Temperature', label: 'Temperature', unit: '°C', digits: 1 },
|
|
20
|
+
{ stateId: 'Humidity', label: 'Humidity', unit: '%', digits: 1 },
|
|
21
|
+
{ stateId: 'Pressure', label: 'Pressure', unit: 'hPa', digits: 1 },
|
|
22
|
+
{ stateId: 'Illuminance', label: 'Illuminance', unit: 'lx', digits: 0 },
|
|
23
|
+
{ stateId: 'CarbonDioxide', label: 'CO2', unit: 'ppm', digits: 0 },
|
|
24
|
+
{ stateId: 'TVOC', label: 'TVOC', unit: 'ppb', digits: 0 },
|
|
25
|
+
{ stateId: 'DewPoint', label: 'Dew point', unit: '°C', digits: 1 },
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* Power-metering data points, keyed by their data point name (see `lib/datapoints.js`). Sonoff/Tasmota
|
|
29
|
+
* devices have no fixed "model" that tells us which power values to expect, and unlike the built-in
|
|
30
|
+
* `ENERGY.*` group, bridged external meters (SML smart-meter heads, PZEM sensors, ...) publish the same
|
|
31
|
+
* data points nested under their own custom group name, e.g. `SML_Total_in`. So instead of only looking
|
|
32
|
+
* at one fixed state ID, every state belonging to a device is matched against this table by its data
|
|
33
|
+
* point name (the last path segment) - wherever it is found, the same way ioBroker.shelly reads its
|
|
34
|
+
* power values from whichever states actually exist on a device (see shelly PR #1562).
|
|
35
|
+
*/
|
|
36
|
+
const POWER_METRIC_LABELS = {
|
|
37
|
+
Power: { label: 'Power', unit: 'W', order: 0 },
|
|
38
|
+
Power_curr: { label: 'Power', unit: 'W', order: 0 },
|
|
39
|
+
Leistung: { label: 'Power', unit: 'W', order: 0 },
|
|
40
|
+
ApparentPower: { label: 'Apparent power', unit: 'VA', order: 1 },
|
|
41
|
+
ReactivePower: { label: 'Reactive power', unit: 'var', order: 2 },
|
|
42
|
+
Voltage: { label: 'Voltage', unit: 'V', order: 3 },
|
|
43
|
+
Spannung: { label: 'Voltage', unit: 'V', order: 3 },
|
|
44
|
+
Current: { label: 'Current', unit: 'A', order: 4 },
|
|
45
|
+
Strom: { label: 'Current', unit: 'A', order: 4 },
|
|
46
|
+
CurrentNeutral: { label: 'Neutral current', unit: 'A', order: 5 },
|
|
47
|
+
Factor: { label: 'Power factor', unit: '', order: 6 },
|
|
48
|
+
Frequency: { label: 'Frequency', unit: 'Hz', order: 7 },
|
|
49
|
+
Frequenz: { label: 'Frequency', unit: 'Hz', order: 7 },
|
|
50
|
+
Today: { label: 'Today', unit: 'kWh', order: 8 },
|
|
51
|
+
heute: { label: 'Today', unit: 'kWh', order: 8 },
|
|
52
|
+
TodaySumImport: { label: 'Today (import)', unit: 'kWh', order: 8 },
|
|
53
|
+
TodaySumExport: { label: 'Today (export)', unit: 'kWh', order: 9 },
|
|
54
|
+
Yesterday: { label: 'Yesterday', unit: 'kWh', order: 10 },
|
|
55
|
+
gestern: { label: 'Yesterday', unit: 'kWh', order: 10 },
|
|
56
|
+
Total: { label: 'Total', unit: 'kWh', order: 11 },
|
|
57
|
+
Total_in: { label: 'Total (import)', unit: 'kWh', order: 11 },
|
|
58
|
+
Total_out: { label: 'Total (export)', unit: 'kWh', order: 12 },
|
|
59
|
+
ExportActive: { label: 'Returned energy', unit: 'kWh', order: 12 },
|
|
60
|
+
PowerLow: { label: 'Power low threshold', unit: 'W', order: 14 },
|
|
61
|
+
PowerHigh: { label: 'Power high threshold', unit: 'W', order: 15 },
|
|
62
|
+
PowerDelta: { label: 'Power delta threshold', unit: 'W', order: 16 },
|
|
63
|
+
};
|
|
64
|
+
/** Fields taken from `INFO.*` (filled from Tasmota's `StatusNET`/`StatusFWR`) for the details panel */
|
|
65
|
+
const INFO_ITEMS = [
|
|
66
|
+
{ stateId: 'Module', label: 'Model' },
|
|
67
|
+
{ stateId: 'Hostname', label: 'Hostname' },
|
|
68
|
+
{ stateId: 'IPAddress', label: 'IP address', copy: true },
|
|
69
|
+
{ stateId: 'Mac', label: 'MAC address', copy: true },
|
|
70
|
+
{ stateId: 'Gateway', label: 'Gateway' },
|
|
71
|
+
{ stateId: 'Subnetmask', label: 'Subnet mask' },
|
|
72
|
+
{ stateId: 'Version', label: 'Firmware version' },
|
|
73
|
+
{ stateId: 'Hardware', label: 'Hardware' },
|
|
74
|
+
{ stateId: 'BuildDateTime', label: 'Build date' },
|
|
75
|
+
{ stateId: 'Core', label: 'Core' },
|
|
76
|
+
{ stateId: 'SDK', label: 'SDK' },
|
|
77
|
+
];
|
|
78
|
+
/**
|
|
79
|
+
* Splits a state suffix into the group it belongs to and the data point name, e.g.
|
|
80
|
+
* "ENERGY.Voltage" -> { channel: "ENERGY", key: "Voltage" }, "SML_Total_in" (a bridged external
|
|
81
|
+
* meter nested under a custom "SML" group) -> { channel: "SML", key: "Total_in" }, and
|
|
82
|
+
* "SENSOR.ENERGY.Power" (nested two levels deep with the "Create object tree" option enabled) ->
|
|
83
|
+
* { channel: "SENSOR.ENERGY", key: "Power" }. A bare data point without any group, e.g. "Voltage",
|
|
84
|
+
* resolves to `{ channel: "", key: "Voltage" }`. Returns `undefined` if the suffix's data point name
|
|
85
|
+
* (last path segment, or underscore-joined tail) isn't one of `knownKeys`.
|
|
86
|
+
*
|
|
87
|
+
* @param suffix state ID without the device prefix
|
|
88
|
+
* @param knownKeys data point names to match against, e.g. the keys of `POWER_METRIC_LABELS`
|
|
89
|
+
*/
|
|
90
|
+
function splitDataPoint(suffix, knownKeys) {
|
|
91
|
+
const lastDotIdx = suffix.lastIndexOf('.');
|
|
92
|
+
if (lastDotIdx > -1) {
|
|
93
|
+
const key = suffix.substring(lastDotIdx + 1);
|
|
94
|
+
return knownKeys.includes(key) ? { channel: suffix.substring(0, lastDotIdx), key } : undefined;
|
|
95
|
+
}
|
|
96
|
+
if (knownKeys.includes(suffix)) {
|
|
97
|
+
return { channel: '', key: suffix };
|
|
98
|
+
}
|
|
99
|
+
for (const key of knownKeys) {
|
|
100
|
+
if (suffix.endsWith(`_${key}`)) {
|
|
101
|
+
return { channel: suffix.substring(0, suffix.length - key.length - 1), key };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* A channel's leading "SENSOR."/"STATE."/"RESULT."/"WAKEUP." segment is only there because the
|
|
108
|
+
* "Create object tree" (OBJ_TREE) option is/was enabled - it does not identify a different meter.
|
|
109
|
+
* Stripping it gives the channel identity that is stable across that setting, so the same physical
|
|
110
|
+
* meter (e.g. the built-in ENERGY group) is recognized as one meter even if its states exist twice,
|
|
111
|
+
* once from before and once from after the option was toggled.
|
|
112
|
+
*/
|
|
113
|
+
function canonicalizeChannel(channel) {
|
|
114
|
+
return channel.replace(/^(SENSOR|STATE|RESULT|WAKEUP)\./, '');
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Whether a (raw, non-canonicalized) channel was nested under one of the topic-level groups created
|
|
118
|
+
* by the "Create object tree" (OBJ_TREE) adapter option, e.g. "SENSOR.ENERGY" or "STATE.Wifi".
|
|
119
|
+
*/
|
|
120
|
+
function isObjTreeStyleChannel(channel) {
|
|
121
|
+
return /^(SENSOR|STATE|RESULT|WAKEUP)\./.test(channel);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The suffix of a state as it looks with the "Create object tree" (OBJ_TREE) option turned off. That
|
|
125
|
+
* option changes nothing about which data points a device has, it only moves them into a folder per
|
|
126
|
+
* MQTT topic group and nests the payload path instead of joining it with "_":
|
|
127
|
+
*
|
|
128
|
+
* | OBJ_TREE on | OBJ_TREE off | flatName |
|
|
129
|
+
* | ---------------------------- | ---------------------- | -------------------- |
|
|
130
|
+
* | `RESULT.Dimmer` | `Dimmer` | `Dimmer` |
|
|
131
|
+
* | `RESULT.Shutter1.Position` | `Shutter1_Position` | `Shutter1_Position` |
|
|
132
|
+
* | `STATE.Wifi.RSSI` | `Wifi_RSSI` | `Wifi_RSSI` |
|
|
133
|
+
* | `SENSOR.AM2301.Temperature` | `AM2301_Temperature` | `AM2301_Temperature` |
|
|
134
|
+
*
|
|
135
|
+
* Both settings therefore end up at the same name, so everything that recognizes a device by its data
|
|
136
|
+
* points (see `getDeviceGroup` and `buildControls`) works the same with and without the option.
|
|
137
|
+
*/
|
|
138
|
+
function flatName(suffix) {
|
|
139
|
+
return suffix.replace(/^(SENSOR|STATE|RESULT|WAKEUP)\./, '').replace(/\./g, '_');
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Data point names that make a device a sensor. Matched against the flattened suffix
|
|
143
|
+
* (see `flatName`) either as the whole name or as its "_"-separated tail, because Tasmota puts the
|
|
144
|
+
* name of the reporting sensor in front of the reading, e.g. "AM2301_Temperature".
|
|
145
|
+
*/
|
|
146
|
+
const SENSOR_KEYS = [
|
|
147
|
+
'Temperature',
|
|
148
|
+
'Humidity',
|
|
149
|
+
'Pressure',
|
|
150
|
+
'Illuminance',
|
|
151
|
+
'CarbonDioxide',
|
|
152
|
+
'TVOC',
|
|
153
|
+
'eCO2',
|
|
154
|
+
'DewPoint',
|
|
155
|
+
'AirQuality',
|
|
156
|
+
'PM2_5',
|
|
157
|
+
'PM10',
|
|
158
|
+
'UvIndex',
|
|
159
|
+
'Distance',
|
|
160
|
+
'Noise',
|
|
161
|
+
];
|
|
162
|
+
/**
|
|
163
|
+
* DeviceManager Class
|
|
164
|
+
*/
|
|
165
|
+
class SonoffDeviceManagement extends dm_utils_1.DeviceManagement {
|
|
166
|
+
ready;
|
|
167
|
+
states = {};
|
|
168
|
+
objects = {};
|
|
169
|
+
constructor(adapter) {
|
|
170
|
+
super(adapter);
|
|
171
|
+
// Initialize i18n
|
|
172
|
+
this.ready = adapter_core_1.I18n.init(__dirname, adapter)
|
|
173
|
+
.catch(error => this.adapter.log.error(`Cannot initialize i18n: ${error}`))
|
|
174
|
+
.then(() => this.init());
|
|
175
|
+
}
|
|
176
|
+
async init() {
|
|
177
|
+
const channels = await this.adapter.getChannelsOfAsync();
|
|
178
|
+
const stateObjects = await this.adapter.getStatesOfAsync();
|
|
179
|
+
const states = await this.adapter.getStatesAsync('*');
|
|
180
|
+
for (const channel of channels) {
|
|
181
|
+
this.objects[channel._id] = channel;
|
|
182
|
+
}
|
|
183
|
+
for (const state of stateObjects) {
|
|
184
|
+
this.objects[state._id] = state;
|
|
185
|
+
}
|
|
186
|
+
for (const id in states) {
|
|
187
|
+
this.states[id] = states[id];
|
|
188
|
+
}
|
|
189
|
+
await this.adapter.subscribeStatesAsync('*');
|
|
190
|
+
await this.adapter.subscribeObjectsAsync('*');
|
|
191
|
+
}
|
|
192
|
+
getInstanceInfo() {
|
|
193
|
+
return {
|
|
194
|
+
apiVersion: 'v3',
|
|
195
|
+
smallCards: true,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
onStateChange(id, state) {
|
|
199
|
+
if (state) {
|
|
200
|
+
if (!this.states[id] || this.states[id].val !== state.val) {
|
|
201
|
+
// trigger DM update
|
|
202
|
+
this.states[id] = state;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
else if (this.states[id]) {
|
|
206
|
+
// trigger DM update
|
|
207
|
+
delete this.states[id];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
onObjectChange(id, obj) {
|
|
211
|
+
if (obj) {
|
|
212
|
+
this.objects[id] = obj;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
delete this.objects[id];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Collects the short suffixes (state ID without the device prefix) of all currently known
|
|
220
|
+
* objects that belong to one device, e.g. "POWER1", "ENERGY.Voltage" or "Shutter1_Position".
|
|
221
|
+
*
|
|
222
|
+
* @param prefix `<namespace>.<deviceId>.`
|
|
223
|
+
*/
|
|
224
|
+
getSuffixes(prefix) {
|
|
225
|
+
const suffixes = new Set();
|
|
226
|
+
for (const id in this.objects) {
|
|
227
|
+
if (id.startsWith(prefix)) {
|
|
228
|
+
suffixes.add(id.substring(prefix.length));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return suffixes;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Finds a state whose data point name (its last path segment) is `key`, regardless of which group
|
|
235
|
+
* it is nested under - a data point can end up at very different state IDs depending on the
|
|
236
|
+
* "Create object tree" (OBJ_TREE) adapter option and on which MQTT topic published it, e.g. RSSI is
|
|
237
|
+
* "Wifi_RSSI" with OBJ_TREE off but "STATE.Wifi.RSSI" with it on. Returns the matching suffix (state
|
|
238
|
+
* ID without the device prefix), or `undefined` if the device has no such data point.
|
|
239
|
+
*
|
|
240
|
+
* A key can exist under more than one group at once, e.g. Tasmota echoes "Uptime" as part of a
|
|
241
|
+
* one-off command response ("RESULT.Uptime") in addition to reporting it with every regular
|
|
242
|
+
* "STATE" telemetry message - only the latter keeps being updated, so when both exist, the periodic
|
|
243
|
+
* telemetry groups (STATE, then SENSOR) are preferred over anything else.
|
|
244
|
+
*
|
|
245
|
+
* @param prefix `<namespace>.<deviceId>.`
|
|
246
|
+
* @param key data point name, e.g. "RSSI" or "Temperature"
|
|
247
|
+
*/
|
|
248
|
+
findDataPointSuffix(prefix, key) {
|
|
249
|
+
const matches = [];
|
|
250
|
+
for (const stateId of Object.keys(this.states)) {
|
|
251
|
+
if (!stateId.startsWith(prefix)) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const suffix = stateId.substring(prefix.length);
|
|
255
|
+
if (suffix === key || suffix.endsWith(`.${key}`) || suffix.endsWith(`_${key}`)) {
|
|
256
|
+
matches.push(suffix);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (matches.length > 1) {
|
|
260
|
+
for (const group of ['STATE', 'SENSOR']) {
|
|
261
|
+
const preferred = matches.find(s => s.startsWith(`${group}.`));
|
|
262
|
+
if (preferred) {
|
|
263
|
+
return preferred;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return matches[0];
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Splits a state suffix into the group it belongs to and the power-metering data point name.
|
|
271
|
+
* Thin wrapper around `splitDataPoint` fixed to the keys of `POWER_METRIC_LABELS`, see there.
|
|
272
|
+
*
|
|
273
|
+
* @param suffix state ID without the device prefix
|
|
274
|
+
*/
|
|
275
|
+
splitPowerSuffix(suffix) {
|
|
276
|
+
return splitDataPoint(suffix, Object.keys(POWER_METRIC_LABELS));
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Human-readable label for a (canonicalized) power-metering channel, used when a device has more
|
|
280
|
+
* than one meter and each one needs to be told apart. The built-in Tasmota group is translated
|
|
281
|
+
* ("ENERGY" -> "Power"/"Leistung"/...); a bridged external meter has no fixed translation - its
|
|
282
|
+
* group name comes directly from the MQTT payload (e.g. "SML", "PZEM"), so it is shown as-is.
|
|
283
|
+
*/
|
|
284
|
+
channelLabel(channel) {
|
|
285
|
+
if (channel === 'ENERGY') {
|
|
286
|
+
return adapter_core_1.I18n.getTranslatedObject('Power');
|
|
287
|
+
}
|
|
288
|
+
// Not a second meter, but the alarm thresholds Tasmota reports for the built-in one
|
|
289
|
+
if (channel === 'MARGINS') {
|
|
290
|
+
return adapter_core_1.I18n.getTranslatedObject('Thresholds');
|
|
291
|
+
}
|
|
292
|
+
return channel.replace(/[._]/g, ' ');
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Finds all power-metering data points of a device, wherever they are: in the built-in `ENERGY.*`
|
|
296
|
+
* group, in a custom group of a bridged external meter, or as a bare top-level data point.
|
|
297
|
+
*
|
|
298
|
+
* The same reading can end up under more than one state ID for the same meter and metric (channel +
|
|
299
|
+
* label): toggling "Create object tree" moves a data point without deleting the old one, and some
|
|
300
|
+
* Tasmota firmware reports energy readings both nested inside its periodic "SENSOR" telemetry *and*
|
|
301
|
+
* via its own dedicated "ENERGY" topic (both handled by `mqttBase.ts`, see there). Whenever a copy
|
|
302
|
+
* nested under SENSOR/STATE/RESULT/WAKEUP (see `isObjTreeStyleChannel`) exists, it is the
|
|
303
|
+
* authoritative one - a device that also sends its own dedicated topic still sends the regular
|
|
304
|
+
* telemetry that ends up nested, so the bare, non-nested copy is always redundant when a nested one
|
|
305
|
+
* exists. Only as a fallback, when no nested copy exists (or several do, e.g. two aliased data point
|
|
306
|
+
* names), the entry whose state was updated most recently wins - stale leftovers stop being updated,
|
|
307
|
+
* so their timestamp falls behind.
|
|
308
|
+
*
|
|
309
|
+
* @param prefix `<namespace>.<deviceId>.`
|
|
310
|
+
*/
|
|
311
|
+
getPowerEntries(prefix) {
|
|
312
|
+
const entries = [];
|
|
313
|
+
for (const stateId of Object.keys(this.states)) {
|
|
314
|
+
if (!stateId.startsWith(prefix)) {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const suffix = stateId.substring(prefix.length);
|
|
318
|
+
const common = this.objects[stateId]?.common;
|
|
319
|
+
if (!common || common.write === true) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const split = this.splitPowerSuffix(suffix);
|
|
323
|
+
if (!split) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const meta = POWER_METRIC_LABELS[split.key];
|
|
327
|
+
entries.push({
|
|
328
|
+
suffix,
|
|
329
|
+
channel: split.channel,
|
|
330
|
+
key: split.key,
|
|
331
|
+
label: meta.label,
|
|
332
|
+
unit: meta.unit,
|
|
333
|
+
order: meta.order,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
const byMeter = new Map();
|
|
337
|
+
for (const entry of entries) {
|
|
338
|
+
const groupKey = `${canonicalizeChannel(entry.channel)} ${entry.label}`;
|
|
339
|
+
const group = byMeter.get(groupKey);
|
|
340
|
+
if (group) {
|
|
341
|
+
group.push(entry);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
byMeter.set(groupKey, [entry]);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const deduped = [];
|
|
348
|
+
for (const group of byMeter.values()) {
|
|
349
|
+
const nested = group.filter(e => isObjTreeStyleChannel(e.channel));
|
|
350
|
+
const candidates = nested.length ? nested : group;
|
|
351
|
+
const preferred = candidates.reduce((newest, entry) => {
|
|
352
|
+
const newestTs = this.states[`${prefix}${newest.suffix}`]?.ts ?? 0;
|
|
353
|
+
const entryTs = this.states[`${prefix}${entry.suffix}`]?.ts ?? 0;
|
|
354
|
+
return entryTs > newestTs ? entry : newest;
|
|
355
|
+
});
|
|
356
|
+
deduped.push({ ...preferred, channel: canonicalizeChannel(preferred.channel) });
|
|
357
|
+
}
|
|
358
|
+
return deduped.sort((a, b) => a.channel.localeCompare(b.channel) || a.order - b.order);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Load all sonoff/Tasmota devices
|
|
362
|
+
*
|
|
363
|
+
* @param context context used to report the found devices to the backend
|
|
364
|
+
*/
|
|
365
|
+
async loadDevices(context) {
|
|
366
|
+
// Wait that i18n is initialized
|
|
367
|
+
await this.ready;
|
|
368
|
+
const ns = this.adapter.namespace;
|
|
369
|
+
for (const deviceId in this.objects) {
|
|
370
|
+
const device = this.objects[deviceId];
|
|
371
|
+
if (device.type !== 'channel') {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const shortDeviceId = device._id.substring(ns.length + 1);
|
|
375
|
+
// Only direct children of the namespace are devices, "info" is the adapter's own channel
|
|
376
|
+
if (!shortDeviceId || shortDeviceId.includes('.') || shortDeviceId === 'info') {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const prefix = `${device._id}.`;
|
|
380
|
+
const suffixes = this.getSuffixes(prefix);
|
|
381
|
+
const alive = this.states[`${device._id}.alive`]?.val === true;
|
|
382
|
+
const hostname = this.states[`${prefix}INFO.Hostname`]?.val;
|
|
383
|
+
const ip = this.states[`${prefix}INFO.IPAddress`]?.val;
|
|
384
|
+
const model = this.states[`${prefix}INFO.Module`]?.val || undefined;
|
|
385
|
+
const rssiSuffix = this.findDataPointSuffix(prefix, 'RSSI');
|
|
386
|
+
const rssi = rssiSuffix ? this.states[`${prefix}${rssiSuffix}`]?.val : undefined;
|
|
387
|
+
const batterySuffix = this.findDataPointSuffix(prefix, 'BatteryPercentage');
|
|
388
|
+
const battery = batterySuffix
|
|
389
|
+
? this.states[`${prefix}${batterySuffix}`]?.val
|
|
390
|
+
: undefined;
|
|
391
|
+
const clientId = device.native?.clientId;
|
|
392
|
+
const group = this.getDeviceGroup(suffixes);
|
|
393
|
+
const res = {
|
|
394
|
+
id: device._id,
|
|
395
|
+
identifier: hostname || ip || clientId || undefined,
|
|
396
|
+
name: device.common.name,
|
|
397
|
+
icon: DEVICE_ICON,
|
|
398
|
+
color: !alive ? '#fff' : undefined,
|
|
399
|
+
backgroundColor: !alive ? '#f44336' : undefined,
|
|
400
|
+
group,
|
|
401
|
+
model,
|
|
402
|
+
status: {
|
|
403
|
+
connection: alive ? 'connected' : 'disconnected',
|
|
404
|
+
rssi,
|
|
405
|
+
battery,
|
|
406
|
+
},
|
|
407
|
+
hasDetails: true,
|
|
408
|
+
customInfo: this.buildCustomInfo(device._id, prefix),
|
|
409
|
+
controls: this.buildControls(shortDeviceId, prefix),
|
|
410
|
+
actions: [
|
|
411
|
+
{
|
|
412
|
+
id: 'rename',
|
|
413
|
+
icon: 'edit',
|
|
414
|
+
description: adapter_core_1.I18n.getTranslatedObject('Rename this device'),
|
|
415
|
+
handler: async (deviceId, context) => await this.handleRenameDevice(deviceId, context),
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
id: 'recreate',
|
|
419
|
+
icon: 'refresh',
|
|
420
|
+
description: adapter_core_1.I18n.getTranslatedObject('Delete and recreate all data points of this device'),
|
|
421
|
+
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?'),
|
|
422
|
+
handler: async (deviceId, context) => await this.handleRecreateDevice(deviceId, context),
|
|
423
|
+
},
|
|
424
|
+
...(hostname || ip
|
|
425
|
+
? [
|
|
426
|
+
{
|
|
427
|
+
id: 'web',
|
|
428
|
+
icon: 'web',
|
|
429
|
+
description: adapter_core_1.I18n.getTranslatedObject('Open device web interface'),
|
|
430
|
+
url: `http://${hostname || ip}`,
|
|
431
|
+
},
|
|
432
|
+
]
|
|
433
|
+
: []),
|
|
434
|
+
],
|
|
435
|
+
};
|
|
436
|
+
context.addDevice(res);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async getDeviceDetails(deviceId) {
|
|
440
|
+
// Wait that i18n is initialized
|
|
441
|
+
await this.ready;
|
|
442
|
+
const device = this.objects[deviceId];
|
|
443
|
+
if (device?.type !== 'channel') {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
const ns = this.adapter.namespace;
|
|
447
|
+
const shortDeviceId = device._id.substring(ns.length + 1);
|
|
448
|
+
const prefix = `${deviceId}.`;
|
|
449
|
+
const items = {};
|
|
450
|
+
const clientId = device.native?.clientId;
|
|
451
|
+
items._clientId = {
|
|
452
|
+
type: 'staticInfo',
|
|
453
|
+
label: adapter_core_1.I18n.getTranslatedObject('Device ID'),
|
|
454
|
+
data: clientId || shortDeviceId,
|
|
455
|
+
addColon: true,
|
|
456
|
+
copyToClipboard: true,
|
|
457
|
+
};
|
|
458
|
+
const hostname = this.states[`${prefix}INFO.Hostname`]?.val;
|
|
459
|
+
const ip = this.states[`${prefix}INFO.IPAddress`]?.val;
|
|
460
|
+
if (hostname || ip) {
|
|
461
|
+
items._deviceLink = {
|
|
462
|
+
// @ts-expect-error staticLink is OK
|
|
463
|
+
type: 'staticLink',
|
|
464
|
+
href: `http://${hostname || ip}`,
|
|
465
|
+
label: adapter_core_1.I18n.getTranslatedObject('Open device web interface'),
|
|
466
|
+
button: true,
|
|
467
|
+
icon: 'web',
|
|
468
|
+
newLine: true,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
for (const info of INFO_ITEMS) {
|
|
472
|
+
const val = this.states[`${prefix}INFO.${info.stateId}`]?.val;
|
|
473
|
+
if (val === undefined || val === null || val === '') {
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
items[`info_${info.stateId}`] = {
|
|
477
|
+
type: 'staticInfo',
|
|
478
|
+
label: adapter_core_1.I18n.getTranslatedObject(info.label),
|
|
479
|
+
data: val,
|
|
480
|
+
addColon: true,
|
|
481
|
+
copyToClipboard: info.copy,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
const rssiSuffix = this.findDataPointSuffix(prefix, 'RSSI');
|
|
485
|
+
const rssi = rssiSuffix ? this.states[`${prefix}${rssiSuffix}`]?.val : undefined;
|
|
486
|
+
if (rssi !== undefined) {
|
|
487
|
+
items._rssi = {
|
|
488
|
+
type: 'staticInfo',
|
|
489
|
+
label: adapter_core_1.I18n.getTranslatedObject('RSSI'),
|
|
490
|
+
data: rssi,
|
|
491
|
+
unit: 'dBm',
|
|
492
|
+
addColon: true,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
const uptimeSuffix = this.findDataPointSuffix(prefix, 'Uptime');
|
|
496
|
+
const uptime = uptimeSuffix ? this.states[`${prefix}${uptimeSuffix}`]?.val : undefined;
|
|
497
|
+
if (uptime !== undefined && uptime !== null && uptime !== '') {
|
|
498
|
+
items._uptime = {
|
|
499
|
+
type: 'staticInfo',
|
|
500
|
+
label: adapter_core_1.I18n.getTranslatedObject('Uptime'),
|
|
501
|
+
data: uptime,
|
|
502
|
+
addColon: true,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
// Power metering section – only shown when power data points exist. Bridged external meters
|
|
506
|
+
// (SML, PZEM, ...) are shown as their own sub-section when a device has more than one meter.
|
|
507
|
+
const powerEntries = this.getPowerEntries(prefix);
|
|
508
|
+
if (powerEntries.length) {
|
|
509
|
+
items._energyHeader = {
|
|
510
|
+
type: 'header',
|
|
511
|
+
text: adapter_core_1.I18n.getTranslatedObject('Power metering'),
|
|
512
|
+
size: 6,
|
|
513
|
+
newLine: true,
|
|
514
|
+
};
|
|
515
|
+
const channels = [...new Set(powerEntries.map(e => e.channel))];
|
|
516
|
+
const multiChannel = channels.length > 1;
|
|
517
|
+
for (const channel of channels) {
|
|
518
|
+
if (multiChannel && channel) {
|
|
519
|
+
items[`energy_ch_${channel.replace(/[.:]/g, '_')}`] = {
|
|
520
|
+
type: 'staticInfo',
|
|
521
|
+
label: this.channelLabel(channel),
|
|
522
|
+
data: '',
|
|
523
|
+
newLine: true,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
for (const entry of powerEntries.filter(e => e.channel === channel)) {
|
|
527
|
+
const val = this.states[`${prefix}${entry.suffix}`]?.val;
|
|
528
|
+
items[`energy_${entry.suffix.replace(/[.:]/g, '_')}`] = {
|
|
529
|
+
type: 'staticInfo',
|
|
530
|
+
label: adapter_core_1.I18n.getTranslatedObject(entry.label),
|
|
531
|
+
data: typeof val === 'number' ? Math.round(val * 100) / 100 : String(val ?? '—'),
|
|
532
|
+
unit: entry.unit || undefined,
|
|
533
|
+
addColon: true,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// Digital inputs (physical switches/buttons wired to the device)
|
|
539
|
+
const inputEntries = [];
|
|
540
|
+
for (const stateId of Object.keys(this.states)) {
|
|
541
|
+
if (!stateId.startsWith(prefix)) {
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const suffix = stateId.substring(prefix.length);
|
|
545
|
+
// Only the last path segment is the actual data point name (may be nested, e.g. "STATE.Switch1")
|
|
546
|
+
const lastSegment = suffix.substring(suffix.lastIndexOf('.') + 1);
|
|
547
|
+
const match = lastSegment.match(/^(Switch|Button)(\d+)$/);
|
|
548
|
+
if (match) {
|
|
549
|
+
inputEntries.push({ suffix, label: `${match[1]} ${match[2]}` });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (inputEntries.length) {
|
|
553
|
+
items._inputsHeader = {
|
|
554
|
+
type: 'header',
|
|
555
|
+
text: adapter_core_1.I18n.getTranslatedObject('Digital inputs'),
|
|
556
|
+
size: 6,
|
|
557
|
+
newLine: true,
|
|
558
|
+
};
|
|
559
|
+
inputEntries.sort((a, b) => a.suffix.localeCompare(b.suffix));
|
|
560
|
+
for (const entry of inputEntries) {
|
|
561
|
+
const val = this.states[`${prefix}${entry.suffix}`]?.val;
|
|
562
|
+
items[`input_${entry.suffix}`] = {
|
|
563
|
+
type: 'staticInfo',
|
|
564
|
+
label: entry.label,
|
|
565
|
+
data: val ? '✓' : '✗',
|
|
566
|
+
addColon: true,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
id: deviceId,
|
|
572
|
+
schema: {
|
|
573
|
+
type: 'panel',
|
|
574
|
+
items,
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
getDeviceGroup(suffixes) {
|
|
579
|
+
const list = [...suffixes].map(flatName);
|
|
580
|
+
const test = (re) => list.some(s => re.test(s));
|
|
581
|
+
let key = 'other';
|
|
582
|
+
if (test(/^Shutter\d+_Position$/)) {
|
|
583
|
+
key = 'cover';
|
|
584
|
+
}
|
|
585
|
+
else if (test(/^(Color|CT|led_basecolor_rgb|led_basecolor_rgbcw|Dimmer|led_dimmer)$/)) {
|
|
586
|
+
key = 'light';
|
|
587
|
+
}
|
|
588
|
+
else if (test(/^(POWER\d*|led_enableAll)$/)) {
|
|
589
|
+
key = 'relay';
|
|
590
|
+
}
|
|
591
|
+
else if (test(/^(ZbReceived_|ZbPower)/)) {
|
|
592
|
+
key = 'zigbee';
|
|
593
|
+
}
|
|
594
|
+
else if (list.some(s => this.splitPowerSuffix(s) !== undefined)) {
|
|
595
|
+
key = 'meter';
|
|
596
|
+
}
|
|
597
|
+
else if (list.some(s => SENSOR_KEYS.some(k => s === k || s.endsWith(`_${k}`)))) {
|
|
598
|
+
key = 'sensor';
|
|
599
|
+
}
|
|
600
|
+
const meta = groupMeta[key] || groupMeta.other;
|
|
601
|
+
return { key, name: adapter_core_1.I18n.getTranslatedObject(meta.nameKey) };
|
|
602
|
+
}
|
|
603
|
+
buildCustomInfo(deviceId, prefix) {
|
|
604
|
+
const shortDeviceId = deviceId.substring(this.adapter.namespace.length + 1);
|
|
605
|
+
const items = {};
|
|
606
|
+
for (const sensor of SENSOR_ITEMS) {
|
|
607
|
+
const suffix = this.findDataPointSuffix(prefix, sensor.stateId);
|
|
608
|
+
if (suffix !== undefined) {
|
|
609
|
+
items[sensor.stateId] = {
|
|
610
|
+
type: 'state',
|
|
611
|
+
oid: `${shortDeviceId}.${suffix}`,
|
|
612
|
+
control: 'text',
|
|
613
|
+
unit: sensor.unit,
|
|
614
|
+
digits: sensor.digits,
|
|
615
|
+
label: adapter_core_1.I18n.getTranslatedObject(sensor.label),
|
|
616
|
+
addColon: true,
|
|
617
|
+
style: { opacity: 0.7 },
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
// Power on the main tile: only the actual "Power" (W) readings, never voltage/current/energy
|
|
622
|
+
const powerItems = this.getPowerEntries(prefix).filter(e => e.label === 'Power');
|
|
623
|
+
const multiPower = powerItems.length > 1;
|
|
624
|
+
for (const entry of powerItems) {
|
|
625
|
+
const label = multiPower ? this.channelLabel(entry.channel) : adapter_core_1.I18n.getTranslatedObject('Power');
|
|
626
|
+
items[`power_${entry.suffix.replace(/[.:]/g, '_')}`] = {
|
|
627
|
+
type: 'state',
|
|
628
|
+
oid: `${shortDeviceId}.${entry.suffix}`,
|
|
629
|
+
control: 'text',
|
|
630
|
+
unit: 'W',
|
|
631
|
+
digits: 1,
|
|
632
|
+
label,
|
|
633
|
+
addColon: true,
|
|
634
|
+
style: { opacity: 0.7 },
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
if (powerItems.length) {
|
|
638
|
+
items._powerSpacer = {
|
|
639
|
+
type: 'divider',
|
|
640
|
+
color: 'transparent',
|
|
641
|
+
height: 2,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
if (Object.keys(items).length === 0) {
|
|
645
|
+
return undefined;
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
id: deviceId,
|
|
649
|
+
schema: {
|
|
650
|
+
type: 'panel',
|
|
651
|
+
items,
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Builds the controls of a device from its writable data points. Every data point is matched by its
|
|
657
|
+
* flattened name (see `flatName`), so the same control shows up with and without the "Create object
|
|
658
|
+
* tree" option. The current values come from the cached states instead of a request per control -
|
|
659
|
+
* the cache holds all states of the adapter anyway (see `init`).
|
|
660
|
+
*/
|
|
661
|
+
buildControls(shortDeviceId, prefix) {
|
|
662
|
+
const controls = [];
|
|
663
|
+
const used = new Set();
|
|
664
|
+
const ownStates = [];
|
|
665
|
+
for (const id in this.objects) {
|
|
666
|
+
if (id.startsWith(prefix) && this.objects[id].type === 'state') {
|
|
667
|
+
const suffix = id.substring(prefix.length);
|
|
668
|
+
ownStates.push({
|
|
669
|
+
id,
|
|
670
|
+
suffix,
|
|
671
|
+
name: flatName(suffix),
|
|
672
|
+
common: this.objects[id].common,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const stateHandler = (fullId) => async (_deviceId, _actionId, state) => {
|
|
677
|
+
await this.adapter.setForeignStateAsync(fullId, state);
|
|
678
|
+
return { val: state, ts: Date.now(), ack: true };
|
|
679
|
+
};
|
|
680
|
+
const currentState = (fullId) => this.states[fullId] || { val: null, ts: Date.now(), ack: true };
|
|
681
|
+
// A control is named after the data point it belongs to, not after where that data point is
|
|
682
|
+
// stored, so its ID stays the same when "Create object tree" is toggled
|
|
683
|
+
const controlId = (name) => name.replace(/[.:]/g, '_');
|
|
684
|
+
// Primary switches: POWER, POWER1..29, Zigbee bridge relays, OpenBeken LED enable
|
|
685
|
+
const switchRe = /^(POWER\d*|ZbReceived_.+_Power|led_enableAll)$/;
|
|
686
|
+
for (const state of ownStates) {
|
|
687
|
+
if (state.common?.type !== 'boolean' || state.common.write === false || !switchRe.test(state.name)) {
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
used.add(state.id);
|
|
691
|
+
const label = state.name === 'POWER'
|
|
692
|
+
? 'Power'
|
|
693
|
+
: state.name.startsWith('POWER')
|
|
694
|
+
? `Power ${state.name.substring(5)}`
|
|
695
|
+
: state.name.replace(/_/g, ' ');
|
|
696
|
+
controls.push({
|
|
697
|
+
id: controlId(state.name),
|
|
698
|
+
type: 'switch',
|
|
699
|
+
stateId: `${shortDeviceId}.${state.suffix}`,
|
|
700
|
+
label: adapter_core_1.I18n.getTranslatedObject(label),
|
|
701
|
+
state: currentState(state.id),
|
|
702
|
+
handler: stateHandler(state.id),
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
// Sliders: dimmer, color temperature, shutter position/tilt
|
|
706
|
+
const sliderDefs = [
|
|
707
|
+
{ re: /^Dimmer$/, label: () => 'Dimmer', min: 0, max: 100, unit: '%' },
|
|
708
|
+
{ re: /^led_dimmer$/, label: () => 'Dimmer', min: 0, max: 100, unit: '%' },
|
|
709
|
+
{ re: /^CT$/, label: () => 'Color temperature', min: 153, max: 500 },
|
|
710
|
+
{ re: /^led_temperature$/, label: () => 'Color temperature', min: 154, max: 500 },
|
|
711
|
+
{ re: /^Shutter(\d+)_Position$/, label: m => `Shutter ${m[1]} position`, min: 0, max: 100, unit: '%' },
|
|
712
|
+
{ re: /^Shutter(\d+)_Tilt$/, label: m => `Shutter ${m[1]} tilt`, min: 0, max: 100, unit: '%' },
|
|
713
|
+
];
|
|
714
|
+
for (const state of ownStates) {
|
|
715
|
+
if (used.has(state.id) || state.common?.type !== 'number' || state.common.write === false) {
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
for (const def of sliderDefs) {
|
|
719
|
+
const match = state.name.match(def.re);
|
|
720
|
+
if (!match) {
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
used.add(state.id);
|
|
724
|
+
controls.push({
|
|
725
|
+
id: controlId(state.name),
|
|
726
|
+
type: 'slider',
|
|
727
|
+
stateId: `${shortDeviceId}.${state.suffix}`,
|
|
728
|
+
min: state.common.min ?? def.min,
|
|
729
|
+
max: state.common.max ?? def.max,
|
|
730
|
+
unit: state.common.unit || def.unit,
|
|
731
|
+
label: adapter_core_1.I18n.getTranslatedObject(def.label(match)),
|
|
732
|
+
state: currentState(state.id),
|
|
733
|
+
handler: stateHandler(state.id),
|
|
734
|
+
});
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// Color controls
|
|
739
|
+
const colorRe = /^(Color|led_basecolor_rgb|led_basecolor_rgbcw)$/;
|
|
740
|
+
for (const state of ownStates) {
|
|
741
|
+
if (used.has(state.id) ||
|
|
742
|
+
state.common?.type !== 'string' ||
|
|
743
|
+
!state.common.write ||
|
|
744
|
+
!colorRe.test(state.name)) {
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
used.add(state.id);
|
|
748
|
+
controls.push({
|
|
749
|
+
id: controlId(state.name),
|
|
750
|
+
type: 'color',
|
|
751
|
+
stateId: `${shortDeviceId}.${state.suffix}`,
|
|
752
|
+
label: adapter_core_1.I18n.getTranslatedObject('Color'),
|
|
753
|
+
state: currentState(state.id),
|
|
754
|
+
handler: stateHandler(state.id),
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
// Everything else: writable settings not shown elsewhere. INFO/ENERGY/MARGINS are read-only
|
|
758
|
+
// anyway, "Time" is the timestamp of the telemetry message a group of data points came with
|
|
759
|
+
const skipRe = /^(INFO_|ENERGY_|MARGINS_|Wifi_|Time$|alive$)/;
|
|
760
|
+
let group = {
|
|
761
|
+
id: 'group_settings',
|
|
762
|
+
type: 'group',
|
|
763
|
+
label: adapter_core_1.I18n.getTranslatedObject('Settings'),
|
|
764
|
+
};
|
|
765
|
+
// Writable numbers/strings first, then the leftover booleans (toggles like Fade, LED exor
|
|
766
|
+
// mode, ...) as buttons
|
|
767
|
+
for (const pass of ['value', 'button']) {
|
|
768
|
+
for (const state of ownStates) {
|
|
769
|
+
if (used.has(state.id) || skipRe.test(state.name) || state.common?.write === false) {
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
const type = state.common.type;
|
|
773
|
+
if (pass === 'value' ? type !== 'number' && type !== 'string' : type !== 'boolean') {
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
used.add(state.id);
|
|
777
|
+
if (group) {
|
|
778
|
+
controls.push(group);
|
|
779
|
+
group = null;
|
|
780
|
+
}
|
|
781
|
+
if (pass === 'button') {
|
|
782
|
+
controls.push({
|
|
783
|
+
group: 'group_settings',
|
|
784
|
+
id: controlId(state.name),
|
|
785
|
+
type: 'button',
|
|
786
|
+
stateId: `${shortDeviceId}.${state.suffix}`,
|
|
787
|
+
label: state.name.replace(/_/g, ' '),
|
|
788
|
+
variant: 'outlined',
|
|
789
|
+
handler: stateHandler(state.id),
|
|
790
|
+
});
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
let options;
|
|
794
|
+
const commonStates = state.common.states;
|
|
795
|
+
if (commonStates) {
|
|
796
|
+
options = Array.isArray(commonStates)
|
|
797
|
+
? commonStates.map((s) => ({ value: s, label: s }))
|
|
798
|
+
: Object.keys(commonStates).map(k => ({
|
|
799
|
+
value: k,
|
|
800
|
+
label: String(commonStates[k]),
|
|
801
|
+
}));
|
|
802
|
+
}
|
|
803
|
+
controls.push({
|
|
804
|
+
group: 'group_settings',
|
|
805
|
+
id: controlId(state.name),
|
|
806
|
+
type: options ? 'select' : type === 'number' ? 'number' : 'text',
|
|
807
|
+
stateId: `${shortDeviceId}.${state.suffix}`,
|
|
808
|
+
min: state.common.min,
|
|
809
|
+
max: state.common.max,
|
|
810
|
+
unit: state.common.unit,
|
|
811
|
+
options,
|
|
812
|
+
label: state.name.replace(/_/g, ' '),
|
|
813
|
+
state: currentState(state.id),
|
|
814
|
+
handler: stateHandler(state.id),
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return controls;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* @param id ID to rename
|
|
822
|
+
* @param context context sent from backend
|
|
823
|
+
*/
|
|
824
|
+
async handleRenameDevice(id, context) {
|
|
825
|
+
const result = await context.showForm({
|
|
826
|
+
type: 'panel',
|
|
827
|
+
items: {
|
|
828
|
+
newName: {
|
|
829
|
+
type: 'text',
|
|
830
|
+
trim: false,
|
|
831
|
+
placeholder: '',
|
|
832
|
+
},
|
|
833
|
+
},
|
|
834
|
+
}, {
|
|
835
|
+
data: {
|
|
836
|
+
newName: '',
|
|
837
|
+
},
|
|
838
|
+
title: adapter_core_1.I18n.getTranslatedObject('Enter new name'),
|
|
839
|
+
});
|
|
840
|
+
if (result?.newName === undefined || result?.newName === '') {
|
|
841
|
+
return { refresh: 'none' };
|
|
842
|
+
}
|
|
843
|
+
const obj = {
|
|
844
|
+
common: {
|
|
845
|
+
name: result.newName,
|
|
846
|
+
},
|
|
847
|
+
};
|
|
848
|
+
const res = await this.adapter.extendForeignObjectAsync(id, obj);
|
|
849
|
+
if (res === null) {
|
|
850
|
+
this.adapter.log.warn(`Can not rename device ${id}: ${JSON.stringify(res)}`);
|
|
851
|
+
return { refresh: 'none' };
|
|
852
|
+
}
|
|
853
|
+
return { refresh: 'device' };
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Deletes every data point of a device - its channel object (and therefore a custom name set via
|
|
857
|
+
* `rename`) is kept, everything else gets recreated automatically the next time the device reports
|
|
858
|
+
* its state. This is a manual, user-triggered fix for data points stuck in an outdated structure,
|
|
859
|
+
* e.g. leftovers from before the "Create object tree" adapter option was changed.
|
|
860
|
+
*
|
|
861
|
+
* `alive` is kept as well: it is not reported by the device but written by the adapter when the
|
|
862
|
+
* MQTT client connects (see `createClient` in `mqttBase.ts`), so a deleted `alive` would only come
|
|
863
|
+
* back the next time the device reconnects - which can take days for a device that is simply up.
|
|
864
|
+
*
|
|
865
|
+
* @param id device (channel) ID to recreate
|
|
866
|
+
* @param _context unused - the destructive confirmation is handled declaratively by the action itself
|
|
867
|
+
*/
|
|
868
|
+
async handleRecreateDevice(id, _context) {
|
|
869
|
+
const prefix = `${id}.`;
|
|
870
|
+
let removed = 0;
|
|
871
|
+
for (const stateId in this.objects) {
|
|
872
|
+
if (!stateId.startsWith(prefix) || this.objects[stateId].type !== 'state') {
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
if (stateId === `${prefix}alive`) {
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
try {
|
|
879
|
+
await this.adapter.delForeignStateAsync(stateId);
|
|
880
|
+
await this.adapter.delForeignObjectAsync(stateId);
|
|
881
|
+
delete this.objects[stateId];
|
|
882
|
+
delete this.states[stateId];
|
|
883
|
+
removed++;
|
|
884
|
+
}
|
|
885
|
+
catch (error) {
|
|
886
|
+
this.adapter.log.warn(`Cannot remove data point ${stateId}: ${error}`);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
// The server/bridge remembers which objects it has already created and would not create them a
|
|
890
|
+
// second time within this adapter run, so the deleted data points have to be forgotten there
|
|
891
|
+
// too - otherwise they only come back after a restart of the adapter
|
|
892
|
+
this.adapter.server?.forgetObjects(id);
|
|
893
|
+
this.adapter.log.info(`Removed ${removed} data point(s) of ${id}, they will be recreated automatically`);
|
|
894
|
+
return { refresh: 'device' };
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
exports.default = SonoffDeviceManagement;
|
|
898
|
+
//# sourceMappingURL=deviceManager.js.map
|