iobroker.zigbee2mqtt 3.0.21 → 3.1.1

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/lib/exposes.js CHANGED
@@ -1,1315 +1,1350 @@
1
-
2
- 'use strict';
3
-
4
- const statesDefs = require('./states').states;
5
- const rgb = require('./rgb');
6
- const utils = require('./utils');
7
- const colors = require('./colors');
8
- const getNonGenDevStatesDefs = require('./nonGenericDevicesExtension').getStateDefinition;
9
-
10
- // https://www.zigbee2mqtt.io/guide/usage/exposes.html#access
11
- const z2mAccess = {
12
- /**
13
- * Bit 0: The property can be found in the published state of this device
14
- */
15
- STATE: 1,
16
- /**
17
- * Bit 1: The property can be set with a /set command
18
- */
19
- SET: 2,
20
- /**
21
- * Bit 2: The property can be retrieved with a /get command
22
- */
23
- GET: 4,
24
- /**
25
- * Bitwise inclusive OR of STATE and SET : 0b001 | 0b010
26
- */
27
- STATE_SET: 3,
28
- /**
29
- * Bitwise inclusive OR of STATE and GET : 0b001 | 0b100
30
- */
31
- STATE_GET: 5,
32
- /**
33
- * Bitwise inclusive OR of STATE and GET and SET : 0b001 | 0b100 | 0b010
34
- */
35
- ALL: 7,
36
- };
37
-
38
- function genState(expose, role, name, desc) {
39
- let state;
40
- const readable = (expose.access & z2mAccess.STATE) > 0;
41
- const writable = (expose.access & z2mAccess.SET) > 0;
42
- const stname = name || expose.property;
43
-
44
- if (typeof stname !== 'string') {
45
- return;
46
- }
47
-
48
- const stateId = stname.replace(/\*/g, '');
49
- const stateName = desc || expose.description || expose.name;
50
- const propName = expose.property;
51
-
52
- switch (expose.type) {
53
- case 'binary':
54
- state = {
55
- id: stateId,
56
- prop: propName,
57
- name: stateName,
58
- icon: undefined,
59
- role: role || 'state',
60
- write: writable,
61
- read: true,
62
- type: 'boolean',
63
- };
64
-
65
- if (readable) {
66
- state.getter = (payload) => payload[propName] === (expose.value_on || 'ON');
67
- } else {
68
- state.getter = (_payload) => undefined;
69
- }
70
-
71
- if (writable) {
72
- state.setter = (payload) =>
73
- payload ? expose.value_on || 'ON' : expose.value_off != undefined ? expose.value_off : 'OFF';
74
- state.setattr = expose.property;
75
- }
76
-
77
- if (expose.endpoint) {
78
- state.epname = expose.endpoint;
79
- }
80
- break;
81
-
82
- case 'numeric':
83
- state = {
84
- id: stateId,
85
- prop: propName,
86
- name: stateName,
87
- icon: undefined,
88
- role: role || 'state',
89
- write: writable,
90
- read: true,
91
- type: 'number',
92
- min: expose.value_min,
93
- max: expose.value_max,
94
- unit: expose.unit,
95
- };
96
-
97
- if (expose.endpoint) {
98
- state.epname = expose.endpoint;
99
- }
100
- break;
101
-
102
- case 'enum':
103
- state = {
104
- id: stateId,
105
- prop: propName,
106
- name: stateName,
107
- icon: undefined,
108
- role: role || 'state',
109
- write: writable,
110
- read: true,
111
- states: {},
112
- };
113
-
114
- for (const val of expose.values) {
115
- // if a definition of a button (eg. Aqara presence detector FP1)
116
- if (val == '') {
117
- state.states[propName] = propName;
118
- } else {
119
- state.states[val] = val;
120
- }
121
- state.type = typeof val;
122
- }
123
-
124
- if (expose.endpoint) {
125
- state.epname = expose.endpoint;
126
- state.setattr = expose.name;
127
- }
128
- break;
129
-
130
- case 'text':
131
- state = {
132
- id: stateId,
133
- prop: propName,
134
- name: stateName,
135
- role: role || 'state',
136
- write: writable,
137
- read: true,
138
- type: 'string',
139
- };
140
- if (propName == 'action') {
141
- state.isEvent = true;
142
- state.getter = (payload) => {
143
- return payload[propName];
144
- };
145
- }
146
- if (expose.endpoint) {
147
- state.epname = expose.endpoint;
148
- }
149
- break;
150
-
151
- default:
152
- break;
153
- }
154
-
155
- // Try to set the state defaults
156
- if (state && state.type) {
157
- switch (state.type) {
158
- case 'boolean':
159
- state.def = false;
160
- break;
161
- case 'number':
162
- state.def = state.min || 0;
163
- break;
164
- case 'object':
165
- state.def = {};
166
- break;
167
- case 'string':
168
- state.def = '';
169
- break;
170
- }
171
- }
172
-
173
- return state;
174
- }
175
-
176
- /**
177
- *
178
- * @param devicesMessag
179
- * @param adapter
180
- */
181
- async function createDeviceFromExposes(devicesMessag, adapter) {
182
- const states = [];
183
- let scenes = [];
184
- const config = adapter.config;
185
- const deviceID = devicesMessag.friendly_name;
186
- const ieee_address = devicesMessag.ieee_address;
187
- const definition = devicesMessag.definition;
188
- const power_source = devicesMessag.power_source;
189
- const disabled = devicesMessag.disabled && devicesMessag.disabled == true;
190
- const description = devicesMessag.description ? devicesMessag.description : undefined;
191
-
192
- function pushToStates(state, access) {
193
- if (state === undefined) {
194
- return 0;
195
- }
196
- if (access === undefined) {access = z2mAccess.ALL;}
197
- state.readable = (access & z2mAccess.STATE) > 0;
198
- state.writable = (access & z2mAccess.SET) > 0;
199
- const stateExists = states.findIndex((x, _index, _array) => x.id === state.id);
200
-
201
- if (stateExists < 0) {
202
- state.write = state.writable;
203
- if (!state.writable) {
204
- if (state.hasOwnProperty('setter')) {
205
- delete state.setter;
206
- }
207
-
208
- if (state.hasOwnProperty('setattr')) {
209
- delete state.setattr;
210
- }
211
- }
212
-
213
- if (!state.readable) {
214
- if (state.hasOwnProperty('getter')) {
215
- //to awid some worning on unprocessed data
216
- state.getter = (_payload) => undefined;
217
- }
218
- }
219
-
220
- return states.push(state);
221
- }
222
- if (state.readable && !states[stateExists].readable) {
223
- states[stateExists].read = state.read;
224
- // as state is readable, it can't be button or event
225
- if (states[stateExists].role === 'button') {
226
- states[stateExists].role = state.role;
227
- }
228
-
229
- if (states[stateExists].hasOwnProperty('isEvent')) {
230
- delete states[stateExists].isEvent;
231
- }
232
-
233
- // we have to use the getter from "new" state
234
- if (state.hasOwnProperty('getter')) {
235
- states[stateExists].getter = state.getter;
236
- }
237
-
238
- // trying to remove the `prop` property, as main key for get and set,
239
- // as it can be different in new and old states, and leave only:
240
- // setattr for old and id for new
241
- if (state.hasOwnProperty('prop') && state.prop === state.id) {
242
- if (states[stateExists].hasOwnProperty('prop')) {
243
- if (states[stateExists].prop !== states[stateExists].id) {
244
- if (!states[stateExists].hasOwnProperty('setattr')) {
245
- states[stateExists].setattr = states[stateExists].prop;
246
- }
247
- }
248
- delete states[stateExists].prop;
249
- }
250
- } else if (state.hasOwnProperty('prop')) {
251
- states[stateExists].prop = state.prop;
252
- }
253
- states[stateExists].readable = true;
254
- }
255
-
256
- if (state.writable && !states[stateExists].writable) {
257
- states[stateExists].write = state.writable;
258
- // use new state `setter`
259
- if (state.hasOwnProperty('setter')) {
260
- states[stateExists].setter = state.setter;
261
- }
262
-
263
- // use new state `setterOpt`
264
- if (state.hasOwnProperty('setterOpt')) {
265
- states[stateExists].setterOpt = state.setterOpt;
266
- }
267
-
268
- // use new state `inOptions`
269
- if (state.hasOwnProperty('inOptions')) {
270
- states[stateExists].inOptions = state.inOptions;
271
- }
272
-
273
- // as we have new state, responsible for set, we have to use new `isOption`
274
- // or remove it
275
- if (
276
- (!state.hasOwnProperty('isOption') || state.isOption === false) && states[stateExists].hasOwnProperty('isOption')) {
277
- delete states[stateExists].isOption;
278
- } else {
279
- states[stateExists].isOption = state.isOption;
280
- }
281
-
282
- // use new `setattr` or `prop` as `setattr`
283
- if (state.hasOwnProperty('setattr')) {
284
- states[stateExists].setattr = state.setattr;
285
- } else if (state.hasOwnProperty('prop')) {
286
- states[stateExists].setattr = state.prop;
287
- }
288
-
289
- // remove `prop` equal to if, due to prop is uses as key in set and get
290
- if (states[stateExists].prop === states[stateExists].id) {
291
- delete states[stateExists].prop;
292
- }
293
-
294
- if (state.hasOwnProperty('epname')) {
295
- states[stateExists].epname = state.epname;
296
- }
297
- states[stateExists].writable = true;
298
- }
299
-
300
- return states.length;
301
-
302
- }
303
-
304
- // search for scenes in the endpoints and build them into an array
305
- for (const key in devicesMessag.endpoints) {
306
- if (devicesMessag.endpoints[key].scenes) {
307
- scenes = scenes.concat(devicesMessag.endpoints[key].scenes);
308
- }
309
- }
310
- try {
311
- for (const expose of definition.exposes) {
312
- let state;
313
-
314
- switch (expose.type) {
315
- case 'light':
316
- for (const prop of expose.features) {
317
- switch (prop.name) {
318
- case 'state': {
319
- const stateName = expose.endpoint ? `state_${expose.endpoint}` : 'state';
320
- pushToStates(
321
- {
322
- id: stateName,
323
- name: `Switch state ${expose.endpoint ? expose.endpoint : ''}`.trim(),
324
- options: ['transition'],
325
- icon: undefined,
326
- role: 'switch',
327
- write: true,
328
- read: true,
329
- type: 'boolean',
330
- getter: (payload) => payload[stateName] === (prop.value_on || 'ON'),
331
- setter: (value) =>
332
- value
333
- ? prop.value_on || 'ON'
334
- : prop.value_off != undefined
335
- ? prop.value_off
336
- : 'OFF',
337
- epname: expose.endpoint,
338
- //setattr: stateName,
339
- },
340
- prop.access
341
- );
342
- // features contains TOGGLE?
343
- if (prop.value_toggle) {
344
- pushToStates({
345
- id: `${stateName}_toggle`,
346
- prop: `${stateName}_toggle`,
347
- name: `Toggle state of the ${stateName}`,
348
- options: ['transition'],
349
- icon: undefined,
350
- role: 'button',
351
- write: true,
352
- read: true,
353
- type: 'boolean',
354
- def: true,
355
- setattr: stateName,
356
- setter: (value) => (value ? prop.value_toggle : undefined),
357
- });
358
- }
359
- break;
360
- }
361
- case 'brightness': {
362
- const stateName = expose.endpoint ? `brightness_${expose.endpoint}` : 'brightness';
363
- pushToStates(
364
- {
365
- id: stateName,
366
- name: `Brightness ${expose.endpoint ? expose.endpoint : ''}`.trim(),
367
- options: ['transition'],
368
- icon: undefined,
369
- role: 'level.dimmer',
370
- write: true,
371
- read: true,
372
- type: 'number',
373
- min: 0, // ignore expose.value_min
374
- max: 100, // ignore expose.value_max
375
- def: 100,
376
- unit: '%',
377
- getter: (value) => {
378
- return utils.bulbLevelToAdapterLevel(value[stateName]);
379
- },
380
- setter: (value) => {
381
- return utils.adapterLevelToBulbLevel(value);
382
- },
383
- },
384
- prop.access
385
- );
386
- // brightnessMoveOnOff
387
- const brmPropName =
388
- config.brightnessMoveOnOff == true
389
- ? `${stateName}_move_onoff`
390
- : `${stateName}_move`;
391
- pushToStates(
392
- {
393
- id: `${stateName}_move`,
394
- prop: brmPropName,
395
- name: 'Increases or decreases the brightness by X units per second',
396
- icon: undefined,
397
- role: 'state',
398
- write: true,
399
- read: false,
400
- type: 'number',
401
- min: -50,
402
- max: 50,
403
- def: 0,
404
- },
405
- z2mAccess.SET
406
- );
407
- // brightnessStepOnOff
408
- const brspropName =
409
- config.brightnessStepOnOff == true
410
- ? `${stateName}_step_onoff`
411
- : `${stateName}_step`;
412
- pushToStates(
413
- {
414
- id: `${stateName}_step`,
415
- prop: brspropName,
416
- name: 'Increases or decreases brightness by X steps',
417
- options: ['transition'],
418
- icon: undefined,
419
- role: 'state',
420
- write: true,
421
- read: false,
422
- type: 'number',
423
- min: -255,
424
- max: 255,
425
- def: 0,
426
- },
427
- z2mAccess.SET
428
- );
429
- break;
430
- }
431
- case 'color_temp': {
432
- const stateName = expose.endpoint ? `colortemp_${expose.endpoint}` : 'colortemp';
433
- const propName = expose.endpoint ? `color_temp_${expose.endpoint}` : 'color_temp';
434
- const colorMode = expose.endpoint ? `color_mode_${expose.endpoint}` : 'color_mode';
435
- pushToStates(
436
- {
437
- id: stateName,
438
- prop: propName,
439
- name: `Color temperature ${expose.endpoint ? expose.endpoint : ''}`.trim(),
440
- options: ['transition'],
441
- icon: undefined,
442
- role: 'level.color.temperature',
443
- write: true,
444
- read: true,
445
- type: 'number',
446
- min:
447
- config.useKelvin == true
448
- ? utils.miredKelvinConversion(prop.value_max)
449
- : prop.value_min,
450
- max:
451
- config.useKelvin == true
452
- ? utils.miredKelvinConversion(prop.value_min)
453
- : prop.value_max,
454
- def:
455
- config.useKelvin == true
456
- ? utils.miredKelvinConversion(prop.value_min)
457
- : prop.value_max,
458
- unit: config.useKelvin == true ? 'K' : 'mired',
459
- setter: (value) => {
460
- return utils.toMired(value);
461
- },
462
- getter: (payload) => {
463
- if (payload[colorMode] != 'color_temp') {
464
- return undefined;
465
- }
466
- if (config.useKelvin == true) {
467
- return utils.miredKelvinConversion(payload[propName]);
468
- }
469
- return payload[propName];
470
-
471
- },
472
- },
473
- prop.access
474
- );
475
- // Colortemp
476
- pushToStates(
477
- {
478
- id: `${stateName}_move`,
479
- prop: `${propName}_move`,
480
- name: 'Colortemp change',
481
- icon: undefined,
482
- role: 'state',
483
- write: true,
484
- read: false,
485
- type: 'number',
486
- min: -50,
487
- max: 50,
488
- def: 0,
489
- },
490
- prop.access
491
- );
492
- break;
493
- }
494
- case 'color_temp_startup': {
495
- //const stateName = expose.endpoint
496
- // ? `colortempstartup_${expose.endpoint}`
497
- // : 'colortempstartup';
498
- const propName = expose.endpoint
499
- ? `color_temp_startup_${expose.endpoint}`
500
- : 'color_temp_startup';
501
- //const colorMode = expose.endpoint ? `color_mode_${expose.endpoint}` : 'color_mode';
502
- pushToStates(
503
- {
504
- id: propName,
505
- prop: propName,
506
- name: `${prop.description} ${expose.endpoint ? `(${expose.endpoint})` : ''}`.trim(),
507
- //options: ['transition'],
508
- icon: undefined,
509
- role: 'level', // Changed role to level to avoid double level.temperature in one device
510
- write: true,
511
- read: true,
512
- type: 'number',
513
- min: 0,
514
- max: 65535,
515
- def: undefined,
516
- unit: config.useKelvin == true ? 'K' : 'mired',
517
- setter: (value) => {
518
- return utils.toMired(value);
519
- },
520
- getter: (payload) => {
521
- //if (payload[colorMode] != 'color_temp') {
522
- // return undefined;
523
- //}
524
- if (config.useKelvin == true) {
525
- return utils.miredKelvinConversion(payload[propName]);
526
- }
527
- return payload[propName];
528
-
529
- },
530
- },
531
- prop.access
532
- );
533
- break;
534
- }
535
- case 'color_xy': {
536
- const stateName = expose.endpoint ? `color_${expose.endpoint}` : 'color';
537
- const colorMode = expose.endpoint ? `color_mode_${expose.endpoint}` : 'color_mode';
538
- pushToStates(
539
- {
540
- id: stateName,
541
- name: `Color ${expose.endpoint ? expose.endpoint : ''}`.trim(),
542
- options: ['transition'],
543
- icon: undefined,
544
- role: 'level.color.rgb',
545
- write: true,
546
- read: true,
547
- type: 'string',
548
- def: '#ff00ff',
549
- setter: (value) => {
550
- let xy = [0, 0];
551
- const rgbcolor = colors.ParseColor(value);
552
-
553
- xy = rgb.rgb_to_cie(rgbcolor.r, rgbcolor.g, rgbcolor.b);
554
- return {
555
- x: xy[0],
556
- y: xy[1],
557
- };
558
- },
559
- getter: (payload) => {
560
- if (payload[colorMode] != 'xy' && config.colorTempSyncColor == false) {
561
- return undefined;
562
- }
563
- if (
564
- payload[stateName] &&
565
- payload[stateName].hasOwnProperty('x') &&
566
- payload[stateName].hasOwnProperty('y')
567
- ) {
568
- const colorval = rgb.cie_to_rgb(
569
- payload[stateName].x,
570
- payload[stateName].y
571
- );
572
- return (
573
- `#${
574
- utils.decimalToHex(colorval[0])
575
- }${utils.decimalToHex(colorval[1])
576
- }${utils.decimalToHex(colorval[2])}`
577
- );
578
- }
579
- return undefined;
580
-
581
- },
582
- epname: expose.endpoint,
583
- },
584
- prop.access
585
- );
586
- break;
587
- }
588
- case 'color_hs': {
589
- const stateName = expose.endpoint ? `color_${expose.endpoint}` : 'color';
590
- const colorMode = expose.endpoint ? `color_mode_${expose.endpoint}` : 'color_mode';
591
- pushToStates(
592
- {
593
- id: stateName,
594
- name: `Color ${expose.endpoint ? expose.endpoint : ''}`.trim(),
595
- options: ['transition'],
596
- icon: undefined,
597
- role: 'level.color.rgb',
598
- write: true,
599
- read: true,
600
- type: 'string',
601
- def: '#ff00ff',
602
- setter: (value) => {
603
- const _rgb = colors.ParseColor(value);
604
- const hsv = rgb.rgbToHSV(_rgb.r, _rgb.g, _rgb.b, true);
605
- return {
606
- h: Math.min(Math.max(hsv.h, 1), 359),
607
- s: hsv.s,
608
- //b: Math.round(hsv.v * 2.55),
609
- };
610
- },
611
- getter: (payload) => {
612
- if (
613
- !['hs', 'xy'].includes(payload[colorMode]) &&
614
- config.colorTempSyncColor == false
615
- ) {
616
- return undefined;
617
- }
618
-
619
- if (
620
- payload[stateName] &&
621
- payload[stateName].hasOwnProperty('h') &&
622
- payload[stateName].hasOwnProperty('s') &&
623
- payload[stateName].hasOwnProperty('b')
624
- ) {
625
- return rgb.hsvToRGBString(
626
- payload[stateName].h,
627
- payload[stateName].s,
628
- Math.round(payload[stateName].b / 2.55)
629
- );
630
- }
631
-
632
- if (
633
- payload[stateName] &&
634
- payload[stateName].hasOwnProperty('x') &&
635
- payload[stateName].hasOwnProperty('y')
636
- ) {
637
- const colorval = rgb.cie_to_rgb(
638
- payload[stateName].x,
639
- payload[stateName].y
640
- );
641
- return (
642
- `#${
643
- utils.decimalToHex(colorval[0])
644
- }${utils.decimalToHex(colorval[1])
645
- }${utils.decimalToHex(colorval[2])}`
646
- );
647
- }
648
- return undefined;
649
- },
650
- },
651
- prop.access
652
- );
653
- break;
654
- }
655
- default:
656
- pushToStates(genState(prop), prop.access);
657
- break;
658
- }
659
- }
660
- pushToStates(statesDefs.transition, z2mAccess.SET);
661
- break;
662
-
663
- case 'switch':
664
- for (const prop of expose.features) {
665
- switch (prop.name) {
666
- case 'state':
667
- pushToStates(genState(prop, 'switch'), prop.access);
668
- // features contains TOGGLE?
669
- if (prop.value_toggle) {
670
- pushToStates({
671
- id: `${prop.property}_toggle`,
672
- prop: `${prop.property}_toggle`,
673
- name: `Toggle state of the ${prop.property}`,
674
- icon: undefined,
675
- role: 'button',
676
- write: true,
677
- read: true,
678
- type: 'boolean',
679
- def: true,
680
- setattr: prop.property,
681
- setter: (value) => (value ? prop.value_toggle : undefined),
682
- });
683
- }
684
- break;
685
- default:
686
- pushToStates(genState(prop), prop.access);
687
- break;
688
- }
689
- }
690
- break;
691
-
692
- case 'numeric':
693
- if (expose.endpoint) {
694
- state = genState(expose);
695
- } else {
696
- switch (expose.name) {
697
- case 'linkquality':
698
- state = statesDefs.link_quality;
699
- break;
700
-
701
- case 'battery':
702
- state = statesDefs.battery;
703
- break;
704
-
705
- case 'temperature':
706
- state = statesDefs.temperature;
707
- break;
708
-
709
- case 'device_temperature':
710
- state = statesDefs.device_temperature;
711
- break;
712
-
713
- case 'humidity':
714
- state = statesDefs.humidity;
715
- break;
716
-
717
- case 'pressure':
718
- state = statesDefs.pressure;
719
- break;
720
-
721
- case 'illuminance':
722
- state = statesDefs.illuminance;
723
- break;
724
-
725
- case 'illuminance_lux':
726
- state = statesDefs.illuminance;
727
- break;
728
-
729
- case 'power':
730
- state = statesDefs.load_power;
731
- break;
732
-
733
- case 'current':
734
- state = statesDefs.load_current;
735
- break;
736
-
737
- case 'voltage':
738
- state = statesDefs.voltage;
739
- if (power_source == 'Battery') {
740
- state = statesDefs.battery_voltage;
741
- }
742
- if (expose.unit == 'mV') {
743
- state.getter = (payload) => payload.voltage / 1000;
744
- }
745
- break;
746
-
747
- case 'energy':
748
- state = statesDefs.energy;
749
- break;
750
-
751
- default:
752
- state = genState(expose);
753
- break;
754
- }
755
- }
756
- if (state) {pushToStates(state, expose.access);}
757
- break;
758
-
759
- case 'enum':
760
- switch (expose.name) {
761
- case 'action': {
762
- // generate an 'action' state
763
- state = genState(expose);
764
- state.isEvent = true;
765
- state.getter = (payload) => payload.action;
766
- pushToStates(state, expose.access);
767
- state = null;
768
-
769
- if (!Array.isArray(expose.values)) {
770
- break;
771
- }
772
-
773
- // Support for DIYRuZ Device
774
- const wildcardValues = expose.values.filter((x) => x.startsWith('*'));
775
- if (wildcardValues && wildcardValues.length > 0) {
776
- for (const endpointName of [
777
- ...new Set(definition.exposes.filter((x) => x.endpoint).map((x) => x.endpoint)),
778
- ]) {
779
- for (const value of wildcardValues) {
780
- const actionName = value.replace('*', endpointName);
781
- pushToStates(
782
- {
783
- id: actionName,
784
- prop: 'action',
785
- name: `Triggered action ${value.replace('*_', endpointName)}`,
786
- icon: undefined,
787
- role: 'button',
788
- write: false,
789
- read: true,
790
- type: 'boolean',
791
- def: false,
792
- isEvent: true,
793
- getter: (payload) => (payload.action === actionName ? true : undefined),
794
- },
795
- expose.access
796
- );
797
- }
798
- }
799
- break;
800
- }
801
-
802
- for (const actionName of expose.values) {
803
- // is release -> hold state? - skip
804
- if (
805
- config.simpleHoldReleaseState == true &&
806
- actionName.endsWith('release') &&
807
- expose.values.find((x) => x == actionName.replace('release', 'hold'))
808
- ) {
809
- continue;
810
- }
811
-
812
- // is stop - move state? - skip
813
- if (
814
- config.simpleMoveStopState == true &&
815
- actionName.endsWith('stop') &&
816
- expose.values.find((x) => x.includes(actionName.replace('stop', 'move')))
817
- ) {
818
- continue;
819
- }
820
-
821
- // is release -> press state? - skip
822
- if (
823
- config.simplePressReleaseState == true &&
824
- actionName.endsWith('release') &&
825
- expose.values.find((x) => x == actionName.replace('release', 'press'))
826
- ) {
827
- continue;
828
- }
829
-
830
- // is hold -> release state ?
831
- if (
832
- config.simpleHoldReleaseState == true &&
833
- actionName.endsWith('hold') &&
834
- expose.values.find((x) => x == actionName.replace('hold', 'release'))
835
- ) {
836
- pushToStates(
837
- {
838
- id: actionName.replace(/\*/g, ''),
839
- prop: 'action',
840
- name: actionName,
841
- icon: undefined,
842
- role: 'button',
843
- write: false,
844
- read: true,
845
- def: false,
846
- type: 'boolean',
847
- getter: (payload) => {
848
- if (payload.action === actionName) {
849
- return true;
850
- }
851
- if (payload.action === actionName.replace('hold', 'release')) {
852
- return false;
853
- }
854
- if (payload.action === `${actionName}_release`) {
855
- return false;
856
- }
857
- return undefined;
858
- },
859
- },
860
- expose.access
861
- );
862
- }
863
- // is move -> stop state ?
864
- else if (
865
- config.simpleMoveStopState == true &&
866
- actionName.includes('move') &&
867
- expose.values.find((x) => x == `${actionName.split('_')[0]}_stop`)
868
- ) {
869
- pushToStates(
870
- {
871
- id: actionName.replace(/\*/g, ''),
872
- prop: 'action',
873
- name: actionName,
874
- icon: undefined,
875
- role: 'button',
876
- write: false,
877
- read: true,
878
- def: false,
879
- type: 'boolean',
880
- getter: (payload) => {
881
- if (payload.action === actionName) {
882
- return true;
883
- }
884
- if (payload.action === `${actionName.split('_')[0]}_stop`) {
885
- return false;
886
- }
887
- return undefined;
888
- },
889
- },
890
- expose.access
891
- );
892
- }
893
- // is press -> release state ?
894
- else if (
895
- config.simplePressReleaseState == true &&
896
- actionName.endsWith('press') &&
897
- expose.values.find((x) => x == actionName.replace('press', 'release'))
898
- ) {
899
- pushToStates(
900
- {
901
- id: actionName.replace(/\*/g, ''),
902
- prop: 'action',
903
- name: actionName,
904
- icon: undefined,
905
- role: 'button',
906
- write: false,
907
- read: true,
908
- def: false,
909
- type: 'boolean',
910
- getter: (payload) => {
911
- if (payload.action === actionName) {
912
- return true;
913
- }
914
- if (payload.action === actionName.replace('press', 'release')) {
915
- return false;
916
- }
917
- return undefined;
918
- },
919
- },
920
- expose.access
921
- );
922
- } else if (actionName == 'color_temperature_move') {
923
- pushToStates(
924
- {
925
- id: 'color_temperature_move',
926
- prop: 'action',
927
- name: 'Color temperature move value',
928
- icon: undefined,
929
- role: 'level.color.temperature',
930
- write: false,
931
- read: true,
932
- type: 'number',
933
- def: config.useKelvin == true ? utils.miredKelvinConversion(150) : 500,
934
- min: config.useKelvin == true ? utils.miredKelvinConversion(500) : 150,
935
- max: config.useKelvin == true ? utils.miredKelvinConversion(150) : 500,
936
- unit: config.useKelvin == true ? 'K' : 'mired',
937
- isEvent: true,
938
- getter: (payload) => {
939
- if (payload.action != 'color_temperature_move') {
940
- return undefined;
941
- }
942
-
943
- if (payload.action_color_temperature) {
944
- if (config.useKelvin == true) {
945
- return utils.miredKelvinConversion(
946
- payload.action_color_temperature
947
- );
948
- }
949
- return payload.action_color_temperature;
950
-
951
- }
952
- },
953
- },
954
- expose.access
955
- );
956
- } else if (actionName == 'color_move') {
957
- pushToStates(
958
- {
959
- id: 'color_move',
960
- prop: 'action',
961
- name: 'Color move value',
962
- icon: undefined,
963
- role: 'level.color.rgb',
964
- write: false,
965
- read: true,
966
- type: 'string',
967
- def: '#ffffff',
968
- isEvent: true,
969
- getter: (payload) => {
970
- if (payload.action != 'color_move') {
971
- return undefined;
972
- }
973
-
974
- if (
975
- payload.action_color &&
976
- payload.action_color.hasOwnProperty('x') &&
977
- payload.action_color.hasOwnProperty('y')
978
- ) {
979
- const colorval = rgb.cie_to_rgb(
980
- payload.action_color.x,
981
- payload.action_color.y
982
- );
983
- return (
984
- `#${
985
- utils.decimalToHex(colorval[0])
986
- }${utils.decimalToHex(colorval[1])
987
- }${utils.decimalToHex(colorval[2])}`
988
- );
989
- }
990
- return undefined;
991
-
992
- },
993
- },
994
- expose.access
995
- );
996
- } else if (actionName == 'brightness_move_to_level') {
997
- pushToStates(
998
- {
999
- id: 'brightness_move_to_level',
1000
- name: 'Brightness move to level',
1001
- icon: undefined,
1002
- role: 'level.dimmer',
1003
- write: false,
1004
- read: true,
1005
- type: 'number',
1006
- min: 0,
1007
- max: 100,
1008
- def: 100,
1009
- unit: '%',
1010
- isEvent: true,
1011
- getter: (payload) => {
1012
- if (payload.action != 'brightness_move_to_level') {
1013
- return undefined;
1014
- }
1015
-
1016
- if (payload.action_level) {
1017
- return utils.bulbLevelToAdapterLevel(payload.action_level);
1018
- }
1019
- return undefined;
1020
-
1021
- },
1022
- },
1023
- expose.access
1024
- );
1025
- } else if (actionName == 'move_to_saturation') {
1026
- pushToStates(
1027
- {
1028
- id: 'move_to_saturation',
1029
- name: 'Move to level saturation',
1030
- icon: undefined,
1031
- role: 'level.color.saturation',
1032
- write: false,
1033
- read: true,
1034
- type: 'number',
1035
- // min: 0,
1036
- // max: 100,
1037
- def: 0,
1038
- isEvent: true,
1039
- getter: (payload) => {
1040
- if (payload.action != 'move_to_saturation') {
1041
- return undefined;
1042
- }
1043
-
1044
- if (payload.action_level) {
1045
- return payload.action_saturation;
1046
- }
1047
- return undefined;
1048
-
1049
- },
1050
- },
1051
- expose.access
1052
- );
1053
- } else if (actionName == 'enhanced_move_to_hue_and_saturation') {
1054
- pushToStates(
1055
- {
1056
- id: 'enhanced_move_to_hue_and_saturation',
1057
- prop: 'action',
1058
- name: 'Enhanced move to hue and saturation value',
1059
- icon: undefined,
1060
- role: 'level.color.hue',
1061
- write: false,
1062
- read: true,
1063
- type: 'number',
1064
- min: 0,
1065
- max: 65536,
1066
- def: 0,
1067
- isEvent: true,
1068
- getter: (payload) => {
1069
- if (payload.action != 'enhanced_move_to_hue_and_saturation') {
1070
- return undefined;
1071
- }
1072
-
1073
- if (payload.action_enhanced_hue) {
1074
- return payload.action_enhanced_hue;
1075
- }
1076
- return undefined;
1077
-
1078
- },
1079
- },
1080
- expose.access
1081
- );
1082
- }
1083
-
1084
- else {
1085
- pushToStates(
1086
- {
1087
- id: actionName.replace(/\*/g, ''),
1088
- prop: 'action',
1089
- name: actionName,
1090
- icon: undefined,
1091
- role: 'button',
1092
- write: false,
1093
- read: true,
1094
- type: 'boolean',
1095
- def: false,
1096
- isEvent: true,
1097
- getter: (payload) => (payload.action === actionName ? true : undefined),
1098
- },
1099
- expose.access
1100
- );
1101
- }
1102
- }
1103
- // Can the device simulated_brightness?
1104
- if (
1105
- definition.options &&
1106
- definition.options.find((x) => x.property == 'simulated_brightness')
1107
- ) {
1108
- pushToStates(statesDefs.simulated_brightness, z2mAccess.STATE);
1109
- }
1110
- state = null;
1111
- break;
1112
- }
1113
- default:
1114
- state = genState(expose);
1115
- break;
1116
- }
1117
- if (state) {pushToStates(state, expose.access);}
1118
- break;
1119
-
1120
- case 'binary':
1121
- if (expose.endpoint) {
1122
- state = genState(expose);
1123
- } else {
1124
- switch (expose.name) {
1125
- case 'contact':
1126
- state = statesDefs.contact;
1127
- pushToStates(statesDefs.opened, expose.access);
1128
- break;
1129
-
1130
- case 'battery_low':
1131
- state = statesDefs.batt_low_t_f;
1132
- break;
1133
-
1134
- case 'tamper':
1135
- state = statesDefs.tamper;
1136
- break;
1137
-
1138
- case 'water_leak':
1139
- state = statesDefs.water_leak;
1140
- break;
1141
-
1142
- case 'lock':
1143
- state = statesDefs.child_lock;
1144
- break;
1145
-
1146
- case 'occupancy':
1147
- state = statesDefs.occupancy;
1148
- break;
1149
-
1150
- default:
1151
- state = genState(expose);
1152
- break;
1153
- }
1154
- }
1155
- if (state) {pushToStates(state, expose.access);}
1156
- break;
1157
-
1158
- case 'text':
1159
- state = genState(expose);
1160
- pushToStates(state, expose.access);
1161
- break;
1162
-
1163
- case 'lock':
1164
- case 'fan':
1165
- case 'cover':
1166
- for (const prop of expose.features) {
1167
- switch (prop.name) {
1168
- case 'state':
1169
- pushToStates(genState(prop, 'switch'), prop.access);
1170
- // features contains TOGGLE?
1171
- if (prop.value_toggle) {
1172
- pushToStates({
1173
- id: `${prop.property}_toggle`,
1174
- prop: `${prop.property}_toggle`,
1175
- name: `Toggle state of the ${prop.property}`,
1176
- icon: undefined,
1177
- role: 'button',
1178
- write: true,
1179
- read: true,
1180
- def: true,
1181
- type: 'boolean',
1182
- setattr: prop.property,
1183
- setter: (value) => (value ? prop.value_toggle : undefined),
1184
- });
1185
- }
1186
- break;
1187
- default:
1188
- pushToStates(genState(prop), prop.access);
1189
- break;
1190
- }
1191
- }
1192
- break;
1193
-
1194
- case 'climate':
1195
- for (const prop of expose.features) {
1196
- switch (prop.name) {
1197
- case 'away_mode':
1198
- pushToStates(statesDefs.climate_away_mode, prop.access);
1199
- break;
1200
- case 'system_mode':
1201
- pushToStates(statesDefs.climate_system_mode, prop.access);
1202
- break;
1203
- case 'running_mode':
1204
- pushToStates(statesDefs.climate_running_mode, prop.access);
1205
- break;
1206
- case 'local_temperature':
1207
- pushToStates(statesDefs.local_temperature, prop.access);
1208
- break;
1209
- case 'local_temperature_calibration':
1210
- pushToStates(statesDefs.local_temperature_calibration, prop.access);
1211
- break;
1212
- default:
1213
- {
1214
- if (prop.name.includes('heating_setpoint')) {
1215
- pushToStates(genState(prop, 'level.temperature'), prop.access);
1216
- } else {
1217
- pushToStates(genState(prop), prop.access);
1218
- }
1219
- }
1220
- break;
1221
- }
1222
- }
1223
- break;
1224
-
1225
- case 'composite': {
1226
- const options = [];
1227
- for (const prop of expose.features) {
1228
- prop.type = 'text'; // to avoid problems with numbers, booleans, etc.
1229
-
1230
- const state = genState(prop);
1231
- // Workaround for FP1 new state (region_upsert)
1232
- if (!state) {
1233
- break;
1234
- }
1235
-
1236
- state.prop = expose.property;
1237
- state.inOptions = true;
1238
- state.isOption = true;
1239
-
1240
- if (expose.access & z2mAccess.STATE) {
1241
- state.getter = (payload) => {
1242
- if (
1243
- payload.hasOwnProperty(expose.property) &&
1244
- payload[expose.property] !== null &&
1245
- payload[expose.property].hasOwnProperty(prop.property)
1246
- ) {
1247
- return !isNaN(payload[expose.property][prop.property])
1248
- ? payload[expose.property][prop.property]
1249
- : undefined;
1250
- }
1251
- return undefined;
1252
-
1253
- };
1254
- } else {
1255
- state.getter = (payload) => {
1256
- return payload[expose.property][prop.property];
1257
- };
1258
- }
1259
-
1260
- pushToStates(state, z2mAccess.STATE);
1261
- }
1262
-
1263
- break;
1264
- }
1265
- default:
1266
- console.log(`Unhandled expose type ${expose.type} for device ${deviceID}`);
1267
- }
1268
- }
1269
- } catch (err) {
1270
- console.log(`ERROR in expose for device ${deviceID} : ${err}`);
1271
- }
1272
-
1273
- // If necessary, add states defined for this device model.
1274
- // Unfortunately this is necessary for some device models because they do not adhere to the standard
1275
- for (const state of getNonGenDevStatesDefs(definition.model)) {
1276
- pushToStates(state, state.write ? z2mAccess.SET : z2mAccess.STATE);
1277
- }
1278
-
1279
- // Add default states
1280
- pushToStates(statesDefs.available, z2mAccess.STATE);
1281
- pushToStates(statesDefs.last_seen, z2mAccess.STATE);
1282
- pushToStates(statesDefs.send_payload, z2mAccess.SET);
1283
-
1284
- // Create buttons for scenes
1285
- for (const scene of scenes) {
1286
- pushToStates({
1287
- id: `scene_${scene.id}`,
1288
- prop: `scene_recall`,
1289
- name: scene.name,
1290
- icon: undefined,
1291
- role: 'button',
1292
- write: true,
1293
- read: true,
1294
- def: true,
1295
- type: 'boolean',
1296
- setter: (value) => (value ? scene.id : undefined),
1297
- });
1298
- }
1299
-
1300
- const newDevice = {
1301
- id: deviceID,
1302
- ieee_address: ieee_address,
1303
- power_source: power_source,
1304
- disabled: disabled,
1305
- description: description,
1306
- optionsValues: {},
1307
- states: states,
1308
- };
1309
-
1310
- return newDevice;
1311
- }
1312
-
1313
- module.exports = {
1314
- createDeviceFromExposes: createDeviceFromExposes,
1315
- };
1
+
2
+ 'use strict';
3
+
4
+ const statesDefs = require('./states').states;
5
+ const rgb = require('./rgb');
6
+ const utils = require('./utils');
7
+ const colors = require('./colors');
8
+ const getNonGenDevStatesDefs = require('./nonGenericDevicesExtension').getStateDefinition;
9
+
10
+ // https://www.zigbee2mqtt.io/guide/usage/exposes.html#access
11
+ const z2mAccess = {
12
+ /**
13
+ * Bit 0: The property can be found in the published state of this device
14
+ */
15
+ STATE: 1,
16
+ /**
17
+ * Bit 1: The property can be set with a /set command
18
+ */
19
+ SET: 2,
20
+ /**
21
+ * Bit 2: The property can be retrieved with a /get command
22
+ */
23
+ GET: 4,
24
+ /**
25
+ * Bitwise inclusive OR of STATE and SET : 0b001 | 0b010
26
+ */
27
+ STATE_SET: 3,
28
+ /**
29
+ * Bitwise inclusive OR of STATE and GET : 0b001 | 0b100
30
+ */
31
+ STATE_GET: 5,
32
+ /**
33
+ * Bitwise inclusive OR of STATE and GET and SET : 0b001 | 0b100 | 0b010
34
+ */
35
+ ALL: 7,
36
+ };
37
+
38
+ function genState(expose, role, name, desc) {
39
+ let state;
40
+ const readable = (expose.access & z2mAccess.STATE) > 0;
41
+ const writable = (expose.access & z2mAccess.SET) > 0;
42
+ const stname = name || expose.property;
43
+
44
+ if (typeof stname !== 'string') {
45
+ return;
46
+ }
47
+
48
+ const stateId = stname.replace(/\*/g, '');
49
+ const stateName = desc || expose.description || expose.name;
50
+ const propName = expose.property;
51
+
52
+ switch (expose.type) {
53
+ case 'binary':
54
+ state = {
55
+ id: stateId,
56
+ prop: propName,
57
+ name: stateName,
58
+ icon: undefined,
59
+ role: role || 'state',
60
+ write: writable,
61
+ read: true,
62
+ type: 'boolean',
63
+ };
64
+
65
+ if (readable) {
66
+ state.getter = (payload) => payload[propName] === (expose.value_on || 'ON');
67
+ } else {
68
+ state.getter = (_payload) => undefined;
69
+ }
70
+
71
+ if (writable) {
72
+ state.setter = (payload) =>
73
+ payload ? expose.value_on || 'ON' : expose.value_off !== undefined ? expose.value_off : 'OFF';
74
+ state.setattr = expose.property;
75
+ }
76
+
77
+ if (expose.endpoint) {
78
+ state.epname = expose.endpoint;
79
+ }
80
+ break;
81
+
82
+ case 'numeric':
83
+ state = {
84
+ id: stateId,
85
+ prop: propName,
86
+ name: stateName,
87
+ icon: undefined,
88
+ role: role || 'state',
89
+ write: writable,
90
+ read: true,
91
+ type: 'number',
92
+ min: expose.value_min,
93
+ max: expose.value_max,
94
+ unit: expose.unit,
95
+ };
96
+
97
+ if (expose.endpoint) {
98
+ state.epname = expose.endpoint;
99
+ }
100
+ break;
101
+
102
+ case 'enum':
103
+ state = {
104
+ id: stateId,
105
+ prop: propName,
106
+ name: stateName,
107
+ icon: undefined,
108
+ role: role || 'state',
109
+ write: writable,
110
+ read: true,
111
+ states: {},
112
+ };
113
+
114
+ for (const val of expose.values) {
115
+ // if a definition of a button (eg. Aqara presence detector FP1)
116
+ if (val === '') {
117
+ state.states[propName] = propName;
118
+ } else {
119
+ state.states[val] = val;
120
+ }
121
+ state.type = typeof val;
122
+ }
123
+ // Fix 4: wenn values leer → type bleibt undefined → Fallback auf 'string'
124
+ if (!state.type) {
125
+ state.type = 'string';
126
+ }
127
+
128
+ if (expose.endpoint) {
129
+ state.epname = expose.endpoint;
130
+ state.setattr = expose.name;
131
+ }
132
+ break;
133
+
134
+ case 'text':
135
+ state = {
136
+ id: stateId,
137
+ prop: propName,
138
+ name: stateName,
139
+ role: role || 'state',
140
+ write: writable,
141
+ read: true,
142
+ type: 'string',
143
+ };
144
+ if (propName === 'action') {
145
+ state.isEvent = true;
146
+ state.getter = (payload) => {
147
+ return payload[propName];
148
+ };
149
+ }
150
+ if (expose.endpoint) {
151
+ state.epname = expose.endpoint;
152
+ }
153
+ break;
154
+
155
+ default:
156
+ break;
157
+ }
158
+
159
+ // Try to set the state defaults
160
+ if (state && state.type) {
161
+ switch (state.type) {
162
+ case 'boolean':
163
+ state.def = false;
164
+ break;
165
+ case 'number':
166
+ // Fix 7: negative min-Werte sollen nicht als Default dienen (z.B. Kalibrierung min=-10 → def=0)
167
+ state.def = (state.min != null && state.min >= 0) ? state.min : 0;
168
+ break;
169
+ case 'object':
170
+ state.def = {};
171
+ break;
172
+ case 'string':
173
+ state.def = '';
174
+ break;
175
+ }
176
+ }
177
+
178
+ return state;
179
+ }
180
+
181
+ /**
182
+ *
183
+ * @param devicesMessag
184
+ * @param adapter
185
+ */
186
+ async function createDeviceFromExposes(devicesMessag, adapter) {
187
+ const states = [];
188
+ let scenes = [];
189
+ const config = adapter.config;
190
+ const deviceID = devicesMessag.friendly_name;
191
+ const ieee_address = devicesMessag.ieee_address;
192
+ const definition = devicesMessag.definition;
193
+ const power_source = devicesMessag.power_source;
194
+ const disabled = devicesMessag.disabled === true;
195
+ const description = devicesMessag.description || undefined;
196
+
197
+ function pushToStates(state, access) {
198
+ if (state === undefined) {
199
+ return 0;
200
+ }
201
+ // FIX: Shallow clone verhindert Mutation von geteilten statesDefs-Objekten.
202
+ // Getter/Setter sind Funktionsreferenzen und müssen nicht tief geklont werden.
203
+ state = Object.assign({}, state);
204
+
205
+ if (access === undefined) {access = z2mAccess.ALL;}
206
+ state.readable = (access & z2mAccess.STATE) > 0;
207
+ state.writable = (access & z2mAccess.SET) > 0;
208
+ const stateExists = states.findIndex((x, _index, _array) => x.id === state.id);
209
+
210
+ if (stateExists < 0) {
211
+ state.write = state.writable;
212
+ if (!state.writable) {
213
+ if (state.hasOwnProperty('setter')) {
214
+ delete state.setter;
215
+ }
216
+
217
+ if (state.hasOwnProperty('setattr')) {
218
+ delete state.setattr;
219
+ }
220
+ }
221
+
222
+ if (!state.readable) {
223
+ if (state.hasOwnProperty('getter')) {
224
+ //to awid some worning on unprocessed data
225
+ state.getter = (_payload) => undefined;
226
+ }
227
+ }
228
+
229
+ return states.push(state);
230
+ }
231
+ if (state.readable && !states[stateExists].readable) {
232
+ states[stateExists].read = state.read;
233
+ // as state is readable, it can't be button or event
234
+ if (states[stateExists].role === 'button') {
235
+ states[stateExists].role = state.role;
236
+ }
237
+
238
+ if (states[stateExists].hasOwnProperty('isEvent')) {
239
+ delete states[stateExists].isEvent;
240
+ }
241
+
242
+ // we have to use the getter from "new" state
243
+ if (state.hasOwnProperty('getter')) {
244
+ states[stateExists].getter = state.getter;
245
+ }
246
+
247
+ // trying to remove the `prop` property, as main key for get and set,
248
+ // as it can be different in new and old states, and leave only:
249
+ // setattr for old and id for new
250
+ if (state.hasOwnProperty('prop') && state.prop === state.id) {
251
+ if (states[stateExists].hasOwnProperty('prop')) {
252
+ if (states[stateExists].prop !== states[stateExists].id) {
253
+ if (!states[stateExists].hasOwnProperty('setattr')) {
254
+ states[stateExists].setattr = states[stateExists].prop;
255
+ }
256
+ }
257
+ delete states[stateExists].prop;
258
+ }
259
+ } else if (state.hasOwnProperty('prop')) {
260
+ states[stateExists].prop = state.prop;
261
+ }
262
+ states[stateExists].readable = true;
263
+ }
264
+
265
+ if (state.writable && !states[stateExists].writable) {
266
+ states[stateExists].write = state.writable;
267
+ // use new state `setter`
268
+ if (state.hasOwnProperty('setter')) {
269
+ states[stateExists].setter = state.setter;
270
+ }
271
+
272
+ // use new state `setterOpt`
273
+ if (state.hasOwnProperty('setterOpt')) {
274
+ states[stateExists].setterOpt = state.setterOpt;
275
+ }
276
+
277
+ // use new state `inOptions`
278
+ if (state.hasOwnProperty('inOptions')) {
279
+ states[stateExists].inOptions = state.inOptions;
280
+ }
281
+
282
+ // as we have new state, responsible for set, we have to use new `isOption`
283
+ // or remove it
284
+ if (
285
+ (!state.hasOwnProperty('isOption') || state.isOption === false) && states[stateExists].hasOwnProperty('isOption')) {
286
+ delete states[stateExists].isOption;
287
+ } else {
288
+ states[stateExists].isOption = state.isOption;
289
+ }
290
+
291
+ // use new `setattr` or `prop` as `setattr`
292
+ if (state.hasOwnProperty('setattr')) {
293
+ states[stateExists].setattr = state.setattr;
294
+ } else if (state.hasOwnProperty('prop')) {
295
+ states[stateExists].setattr = state.prop;
296
+ }
297
+
298
+ // remove `prop` equal to if, due to prop is uses as key in set and get
299
+ if (states[stateExists].prop === states[stateExists].id) {
300
+ delete states[stateExists].prop;
301
+ }
302
+
303
+ if (state.hasOwnProperty('epname')) {
304
+ states[stateExists].epname = state.epname;
305
+ }
306
+ states[stateExists].writable = true;
307
+ }
308
+
309
+ return states.length;
310
+
311
+ }
312
+
313
+ // search for scenes in the endpoints and build them into an array
314
+ for (const key of Object.keys(devicesMessag.endpoints || {})) {
315
+ if (devicesMessag.endpoints[key].scenes) {
316
+ scenes = scenes.concat(devicesMessag.endpoints[key].scenes);
317
+ }
318
+ }
319
+ try {
320
+ for (const expose of definition.exposes) {
321
+ let state;
322
+
323
+ switch (expose.type) {
324
+ case 'light':
325
+ for (const prop of expose.features) {
326
+ switch (prop.name) {
327
+ case 'state': {
328
+ const stateName = expose.endpoint ? `state_${expose.endpoint}` : 'state';
329
+ pushToStates(
330
+ {
331
+ id: stateName,
332
+ name: `Switch state ${expose.endpoint ? expose.endpoint : ''}`.trim(),
333
+ options: ['transition'],
334
+ icon: undefined,
335
+ role: 'switch',
336
+ write: true,
337
+ read: true,
338
+ type: 'boolean',
339
+ getter: (payload) => payload[stateName] === (prop.value_on || 'ON'),
340
+ setter: (value) =>
341
+ value
342
+ ? prop.value_on || 'ON'
343
+ : prop.value_off !== undefined
344
+ ? prop.value_off
345
+ : 'OFF',
346
+ epname: expose.endpoint,
347
+ //setattr: stateName,
348
+ },
349
+ prop.access
350
+ );
351
+ // features contains TOGGLE?
352
+ if (prop.value_toggle) {
353
+ pushToStates({
354
+ id: `${stateName}_toggle`,
355
+ prop: `${stateName}_toggle`,
356
+ name: `Toggle state of the ${stateName}`,
357
+ options: ['transition'],
358
+ icon: undefined,
359
+ role: 'button',
360
+ write: true,
361
+ read: true,
362
+ type: 'boolean',
363
+ def: true,
364
+ setattr: stateName,
365
+ setter: (value) => (value ? prop.value_toggle : undefined),
366
+ });
367
+ }
368
+ break;
369
+ }
370
+ case 'brightness': {
371
+ const stateName = expose.endpoint ? `brightness_${expose.endpoint}` : 'brightness';
372
+ pushToStates(
373
+ {
374
+ id: stateName,
375
+ name: `Brightness ${expose.endpoint ? expose.endpoint : ''}`.trim(),
376
+ options: ['transition'],
377
+ icon: undefined,
378
+ role: 'level.dimmer',
379
+ write: true,
380
+ read: true,
381
+ type: 'number',
382
+ min: 0, // ignore expose.value_min
383
+ max: 100, // ignore expose.value_max
384
+ def: 100,
385
+ unit: '%',
386
+ getter: (payload) => {
387
+ return utils.bulbLevelToAdapterLevel(payload[stateName]);
388
+ },
389
+ setter: (value) => {
390
+ return utils.adapterLevelToBulbLevel(value);
391
+ },
392
+ },
393
+ prop.access
394
+ );
395
+ // brightnessMoveOnOff
396
+ const brmPropName =
397
+ config.brightnessMoveOnOff === true
398
+ ? `${stateName}_move_onoff`
399
+ : `${stateName}_move`;
400
+ pushToStates(
401
+ {
402
+ id: `${stateName}_move`,
403
+ prop: brmPropName,
404
+ name: 'Increases or decreases the brightness by X units per second',
405
+ icon: undefined,
406
+ role: 'state',
407
+ write: true,
408
+ read: false,
409
+ type: 'number',
410
+ min: -50,
411
+ max: 50,
412
+ def: 0,
413
+ },
414
+ z2mAccess.SET
415
+ );
416
+ // brightnessStepOnOff
417
+ const brspropName =
418
+ config.brightnessStepOnOff === true
419
+ ? `${stateName}_step_onoff`
420
+ : `${stateName}_step`;
421
+ pushToStates(
422
+ {
423
+ id: `${stateName}_step`,
424
+ prop: brspropName,
425
+ name: 'Increases or decreases brightness by X steps',
426
+ options: ['transition'],
427
+ icon: undefined,
428
+ role: 'state',
429
+ write: true,
430
+ read: false,
431
+ type: 'number',
432
+ min: -255,
433
+ max: 255,
434
+ def: 0,
435
+ },
436
+ z2mAccess.SET
437
+ );
438
+ break;
439
+ }
440
+ case 'color_temp': {
441
+ const stateName = expose.endpoint ? `colortemp_${expose.endpoint}` : 'colortemp';
442
+ const propName = expose.endpoint ? `color_temp_${expose.endpoint}` : 'color_temp';
443
+ const colorMode = expose.endpoint ? `color_mode_${expose.endpoint}` : 'color_mode';
444
+ pushToStates(
445
+ {
446
+ id: stateName,
447
+ prop: propName,
448
+ name: `Color temperature ${expose.endpoint ? expose.endpoint : ''}`.trim(),
449
+ options: ['transition'],
450
+ icon: undefined,
451
+ role: 'level.color.temperature',
452
+ write: true,
453
+ read: true,
454
+ type: 'number',
455
+ min:
456
+ config.useKelvin === true
457
+ ? utils.miredKelvinConversion(prop.value_max)
458
+ : prop.value_min,
459
+ max:
460
+ config.useKelvin === true
461
+ ? utils.miredKelvinConversion(prop.value_min)
462
+ : prop.value_max,
463
+ def:
464
+ config.useKelvin === true
465
+ ? utils.miredKelvinConversion(prop.value_min)
466
+ : prop.value_max,
467
+ unit: config.useKelvin === true ? 'K' : 'mired',
468
+ setter: (value) => {
469
+ return utils.toMired(value);
470
+ },
471
+ getter: (payload) => {
472
+ if (payload[colorMode] !== 'color_temp') {
473
+ return undefined;
474
+ }
475
+ // Fix 10: null-Check vor miredKelvinConversion
476
+ const val = payload[propName];
477
+ if (val == null) {return undefined;}
478
+ if (config.useKelvin === true) {
479
+ return utils.miredKelvinConversion(val);
480
+ }
481
+ return val;
482
+ },
483
+ },
484
+ prop.access
485
+ );
486
+ // Colortemp move – nur schreibbar (SET), nicht readable
487
+ pushToStates(
488
+ {
489
+ id: `${stateName}_move`,
490
+ prop: `${propName}_move`,
491
+ name: 'Colortemp change',
492
+ icon: undefined,
493
+ role: 'state',
494
+ write: true,
495
+ read: false,
496
+ type: 'number',
497
+ min: -50,
498
+ max: 50,
499
+ def: 0,
500
+ },
501
+ z2mAccess.SET // FIX: war prop.access konnte fälschlicherweise readable werden
502
+ );
503
+ break;
504
+ }
505
+ case 'color_temp_startup': {
506
+ //const stateName = expose.endpoint
507
+ // ? `colortempstartup_${expose.endpoint}`
508
+ // : 'colortempstartup';
509
+ const propName = expose.endpoint
510
+ ? `color_temp_startup_${expose.endpoint}`
511
+ : 'color_temp_startup';
512
+ //const colorMode = expose.endpoint ? `color_mode_${expose.endpoint}` : 'color_mode';
513
+ pushToStates(
514
+ {
515
+ id: propName,
516
+ prop: propName,
517
+ name: `${prop.description} ${expose.endpoint ? `(${expose.endpoint})` : ''}`.trim(),
518
+ //options: ['transition'],
519
+ icon: undefined,
520
+ role: 'level', // Changed role to level to avoid double level.temperature in one device
521
+ write: true,
522
+ read: true,
523
+ type: 'number',
524
+ min: 0,
525
+ max: 65535,
526
+ def: undefined,
527
+ unit: config.useKelvin === true ? 'K' : 'mired',
528
+ setter: (value) => {
529
+ return utils.toMired(value);
530
+ },
531
+ getter: (payload) => {
532
+ const val = payload[propName];
533
+ if (val == null) {
534
+ return undefined;
535
+ }
536
+ if (config.useKelvin === true) {
537
+ return utils.miredKelvinConversion(val);
538
+ }
539
+ return val;
540
+ },
541
+ },
542
+ prop.access
543
+ );
544
+ break;
545
+ }
546
+ case 'color_xy': {
547
+ const stateName = expose.endpoint ? `color_${expose.endpoint}` : 'color';
548
+ const colorMode = expose.endpoint ? `color_mode_${expose.endpoint}` : 'color_mode';
549
+ pushToStates(
550
+ {
551
+ id: stateName,
552
+ name: `Color ${expose.endpoint ? expose.endpoint : ''}`.trim(),
553
+ options: ['transition'],
554
+ icon: undefined,
555
+ role: 'level.color.rgb',
556
+ write: true,
557
+ read: true,
558
+ type: 'string',
559
+ def: '#ff00ff',
560
+ setter: (value) => {
561
+ // Fix 1: redundante [0,0]-Initialisierung entfernt
562
+ const rgbcolor = colors.ParseColor(value);
563
+ const xy = rgb.rgb_to_cie(rgbcolor.r, rgbcolor.g, rgbcolor.b);
564
+ return {
565
+ x: xy[0],
566
+ y: xy[1],
567
+ };
568
+ },
569
+ getter: (payload) => {
570
+ if (payload[colorMode] !== 'xy' && !config.colorTempSyncColor) {
571
+ return undefined;
572
+ }
573
+ // Fix 2: x=0/y=0 sind gültige CIE-Koordinaten → != null
574
+ if (
575
+ payload[stateName] &&
576
+ payload[stateName].x != null &&
577
+ payload[stateName].y != null
578
+ ) {
579
+ const colorval = rgb.cie_to_rgb(
580
+ payload[stateName].x,
581
+ payload[stateName].y
582
+ );
583
+ return (
584
+ `#${utils.decimalToHex(colorval[0])
585
+ }${utils.decimalToHex(colorval[1])
586
+ }${utils.decimalToHex(colorval[2])}`
587
+ );
588
+ }
589
+ return undefined;
590
+ },
591
+ epname: expose.endpoint,
592
+ },
593
+ prop.access
594
+ );
595
+ break;
596
+ }
597
+ case 'color_hs': {
598
+ const stateName = expose.endpoint ? `color_${expose.endpoint}` : 'color';
599
+ const colorMode = expose.endpoint ? `color_mode_${expose.endpoint}` : 'color_mode';
600
+ pushToStates(
601
+ {
602
+ id: stateName,
603
+ name: `Color ${expose.endpoint ? expose.endpoint : ''}`.trim(),
604
+ options: ['transition'],
605
+ icon: undefined,
606
+ role: 'level.color.rgb',
607
+ write: true,
608
+ read: true,
609
+ type: 'string',
610
+ def: '#ff00ff',
611
+ setter: (value) => {
612
+ const _rgb = colors.ParseColor(value);
613
+ const hsv = rgb.rgbToHSV(_rgb.r, _rgb.g, _rgb.b, true);
614
+ return {
615
+ h: Math.min(Math.max(hsv.h, 1), 359),
616
+ s: hsv.s,
617
+ //b: Math.round(hsv.v * 2.55),
618
+ };
619
+ },
620
+ getter: (payload) => {
621
+ if (
622
+ !['hs', 'xy'].includes(payload[colorMode]) &&
623
+ !config.colorTempSyncColor
624
+ ) {
625
+ return undefined;
626
+ }
627
+ // Fix 3: h=0 (Rot) ist falsy → != null verwenden
628
+ if (
629
+ payload[stateName] &&
630
+ payload[stateName].h != null &&
631
+ payload[stateName].s != null &&
632
+ payload[stateName].b != null
633
+ ) {
634
+ return rgb.hsvToRGBString(
635
+ payload[stateName].h,
636
+ payload[stateName].s,
637
+ Math.round(payload[stateName].b / 2.55)
638
+ );
639
+ }
640
+ if (
641
+ payload[stateName] &&
642
+ payload[stateName].x != null &&
643
+ payload[stateName].y != null
644
+ ) {
645
+ const colorval = rgb.cie_to_rgb(
646
+ payload[stateName].x,
647
+ payload[stateName].y
648
+ );
649
+ return (
650
+ `#${utils.decimalToHex(colorval[0])
651
+ }${utils.decimalToHex(colorval[1])
652
+ }${utils.decimalToHex(colorval[2])}`
653
+ );
654
+ }
655
+ return undefined;
656
+ },
657
+ },
658
+ prop.access
659
+ );
660
+ break;
661
+ }
662
+ default:
663
+ pushToStates(genState(prop), prop.access);
664
+ break;
665
+ }
666
+ }
667
+ pushToStates(statesDefs.transition, z2mAccess.SET);
668
+ break;
669
+
670
+ case 'switch':
671
+ for (const prop of expose.features) {
672
+ switch (prop.name) {
673
+ case 'state':
674
+ pushToStates(genState(prop, 'switch'), prop.access);
675
+ // features contains TOGGLE?
676
+ if (prop.value_toggle) {
677
+ pushToStates({
678
+ id: `${prop.property}_toggle`,
679
+ prop: `${prop.property}_toggle`,
680
+ name: `Toggle state of the ${prop.property}`,
681
+ icon: undefined,
682
+ role: 'button',
683
+ write: true,
684
+ read: true,
685
+ type: 'boolean',
686
+ def: true,
687
+ setattr: prop.property,
688
+ setter: (value) => (value ? prop.value_toggle : undefined),
689
+ });
690
+ }
691
+ break;
692
+ default:
693
+ pushToStates(genState(prop), prop.access);
694
+ break;
695
+ }
696
+ }
697
+ break;
698
+
699
+ case 'numeric':
700
+ if (expose.endpoint) {
701
+ state = genState(expose);
702
+ } else {
703
+ switch (expose.name) {
704
+ case 'linkquality':
705
+ state = statesDefs.link_quality;
706
+ break;
707
+
708
+ case 'battery':
709
+ state = statesDefs.battery;
710
+ break;
711
+
712
+ case 'temperature':
713
+ state = statesDefs.temperature;
714
+ break;
715
+
716
+ case 'device_temperature':
717
+ state = statesDefs.device_temperature;
718
+ break;
719
+
720
+ case 'humidity':
721
+ state = statesDefs.humidity;
722
+ break;
723
+
724
+ case 'pressure':
725
+ state = statesDefs.pressure;
726
+ break;
727
+
728
+ case 'illuminance':
729
+ // Z2M sendet 'illuminance' = Rohwert (kein lux)
730
+ // 'illuminance_lux' = Wert in Lux → eigener case unten
731
+ state = statesDefs.illuminance_raw;
732
+ break;
733
+
734
+ case 'illuminance_lux':
735
+ state = statesDefs.illuminance;
736
+ break;
737
+
738
+ case 'power':
739
+ state = statesDefs.load_power;
740
+ break;
741
+
742
+ case 'current':
743
+ state = statesDefs.load_current;
744
+ break;
745
+
746
+ case 'voltage':
747
+ // FIX: Klone vor Mutation, damit statesDefs.voltage nicht dauerhaft verändert wird
748
+ state = Object.assign({}, power_source === 'Battery' ? statesDefs.battery_voltage : statesDefs.voltage);
749
+ if (expose.unit === 'mV') {
750
+ // Fix 7: expose.property statt hardcoded 'voltage'
751
+ const voltProp = expose.property;
752
+ state.getter = (payload) => payload[voltProp] != null ? payload[voltProp] / 1000 : undefined;
753
+ }
754
+ break;
755
+
756
+ case 'energy':
757
+ state = statesDefs.energy;
758
+ break;
759
+
760
+ default:
761
+ state = genState(expose);
762
+ break;
763
+ }
764
+ }
765
+ if (state) {pushToStates(state, expose.access);}
766
+ break;
767
+
768
+ case 'enum':
769
+ switch (expose.name) {
770
+ case 'action': {
771
+ // generate an 'action' state
772
+ state = genState(expose);
773
+ state.isEvent = true;
774
+ state.getter = (payload) => payload.action;
775
+ pushToStates(state, expose.access);
776
+ state = null;
777
+
778
+ if (!Array.isArray(expose.values)) {
779
+ break;
780
+ }
781
+
782
+ // Support for DIYRuZ Device
783
+ const wildcardValues = expose.values.filter((x) => x.startsWith('*'));
784
+ if (wildcardValues && wildcardValues.length > 0) {
785
+ for (const endpointName of [
786
+ ...new Set(definition.exposes.filter((x) => x.endpoint).map((x) => x.endpoint)),
787
+ ]) {
788
+ for (const value of wildcardValues) {
789
+ const actionName = value.replace('*', endpointName);
790
+ pushToStates(
791
+ {
792
+ id: actionName,
793
+ prop: 'action',
794
+ name: `Triggered action ${value.replace('*_', endpointName)}`,
795
+ icon: undefined,
796
+ role: 'button',
797
+ write: false,
798
+ read: true,
799
+ type: 'boolean',
800
+ def: false,
801
+ isEvent: true,
802
+ getter: (payload) => (payload.action === actionName ? true : undefined),
803
+ },
804
+ expose.access
805
+ );
806
+ }
807
+ }
808
+ break;
809
+ }
810
+
811
+ for (const actionName of expose.values) {
812
+ // is release -> hold state? - skip
813
+ if (
814
+ config.simpleHoldReleaseState === true &&
815
+ actionName.endsWith('release') &&
816
+ expose.values.find((x) => x === actionName.replace('release', 'hold'))
817
+ ) {
818
+ continue;
819
+ }
820
+
821
+ // is stop - move state? - skip
822
+ if (
823
+ config.simpleMoveStopState === true &&
824
+ actionName.endsWith('stop') &&
825
+ expose.values.find((x) => x.includes(actionName.replace('stop', 'move')))
826
+ ) {
827
+ continue;
828
+ }
829
+
830
+ // is release -> press state? - skip
831
+ if (
832
+ config.simplePressReleaseState === true &&
833
+ actionName.endsWith('release') &&
834
+ expose.values.find((x) => x === actionName.replace('release', 'press'))
835
+ ) {
836
+ continue;
837
+ }
838
+
839
+ // is hold -> release state ?
840
+ if (
841
+ config.simpleHoldReleaseState === true &&
842
+ actionName.endsWith('hold') &&
843
+ expose.values.find((x) => x === actionName.replace('hold', 'release'))
844
+ ) {
845
+ pushToStates(
846
+ {
847
+ id: actionName.replace(/\*/g, ''),
848
+ prop: 'action',
849
+ name: actionName,
850
+ icon: undefined,
851
+ role: 'button',
852
+ write: false,
853
+ read: true,
854
+ def: false,
855
+ type: 'boolean',
856
+ getter: (payload) => {
857
+ if (payload.action === actionName) {
858
+ return true;
859
+ }
860
+ if (payload.action === actionName.replace('hold', 'release')) {
861
+ return false;
862
+ }
863
+ if (payload.action === `${actionName}_release`) {
864
+ return false;
865
+ }
866
+ return undefined;
867
+ },
868
+ },
869
+ expose.access
870
+ );
871
+ }
872
+ // is move -> stop state ?
873
+ else if (
874
+ config.simpleMoveStopState === true &&
875
+ actionName.includes('move') &&
876
+ expose.values.find((x) => x === `${actionName.split('_')[0]}_stop`)
877
+ ) {
878
+ pushToStates(
879
+ {
880
+ id: actionName.replace(/\*/g, ''),
881
+ prop: 'action',
882
+ name: actionName,
883
+ icon: undefined,
884
+ role: 'button',
885
+ write: false,
886
+ read: true,
887
+ def: false,
888
+ type: 'boolean',
889
+ getter: (payload) => {
890
+ if (payload.action === actionName) {
891
+ return true;
892
+ }
893
+ if (payload.action === `${actionName.split('_')[0]}_stop`) {
894
+ return false;
895
+ }
896
+ return undefined;
897
+ },
898
+ },
899
+ expose.access
900
+ );
901
+ }
902
+ // is press -> release state ?
903
+ else if (
904
+ config.simplePressReleaseState === true &&
905
+ actionName.endsWith('press') &&
906
+ expose.values.find((x) => x === actionName.replace('press', 'release'))
907
+ ) {
908
+ pushToStates(
909
+ {
910
+ id: actionName.replace(/\*/g, ''),
911
+ prop: 'action',
912
+ name: actionName,
913
+ icon: undefined,
914
+ role: 'button',
915
+ write: false,
916
+ read: true,
917
+ def: false,
918
+ type: 'boolean',
919
+ getter: (payload) => {
920
+ if (payload.action === actionName) {
921
+ return true;
922
+ }
923
+ if (payload.action === actionName.replace('press', 'release')) {
924
+ return false;
925
+ }
926
+ return undefined;
927
+ },
928
+ },
929
+ expose.access
930
+ );
931
+ } else if (actionName === 'color_temperature_move') {
932
+ pushToStates(
933
+ {
934
+ id: 'color_temperature_move',
935
+ prop: 'action',
936
+ name: 'Color temperature move value',
937
+ icon: undefined,
938
+ role: 'level.color.temperature',
939
+ write: false,
940
+ read: true,
941
+ type: 'number',
942
+ def: config.useKelvin === true ? utils.miredKelvinConversion(150) : 500,
943
+ min: config.useKelvin === true ? utils.miredKelvinConversion(500) : 150,
944
+ max: config.useKelvin === true ? utils.miredKelvinConversion(150) : 500,
945
+ unit: config.useKelvin === true ? 'K' : 'mired',
946
+ isEvent: true,
947
+ getter: (payload) => {
948
+ if (payload.action !== 'color_temperature_move') {
949
+ return undefined;
950
+ }
951
+ // Fix 4: != null statt truthy (0 ist ein gültiger Mired-Wert)
952
+ if (payload.action_color_temperature != null) {
953
+ if (config.useKelvin === true) {
954
+ return utils.miredKelvinConversion(
955
+ payload.action_color_temperature
956
+ );
957
+ }
958
+ return payload.action_color_temperature;
959
+ }
960
+ return undefined;
961
+ },
962
+ },
963
+ expose.access
964
+ );
965
+ } else if (actionName === 'color_move') {
966
+ pushToStates(
967
+ {
968
+ id: 'color_move',
969
+ prop: 'action',
970
+ name: 'Color move value',
971
+ icon: undefined,
972
+ role: 'level.color.rgb',
973
+ write: false,
974
+ read: true,
975
+ type: 'string',
976
+ def: '#ffffff',
977
+ isEvent: true,
978
+ getter: (payload) => {
979
+ if (payload.action !== 'color_move') {
980
+ return undefined;
981
+ }
982
+
983
+ if (
984
+ payload.action_color &&
985
+ payload.action_color.hasOwnProperty('x') &&
986
+ payload.action_color.hasOwnProperty('y')
987
+ ) {
988
+ const colorval = rgb.cie_to_rgb(
989
+ payload.action_color.x,
990
+ payload.action_color.y
991
+ );
992
+ return (
993
+ `#${
994
+ utils.decimalToHex(colorval[0])
995
+ }${utils.decimalToHex(colorval[1])
996
+ }${utils.decimalToHex(colorval[2])}`
997
+ );
998
+ }
999
+ return undefined;
1000
+
1001
+ },
1002
+ },
1003
+ expose.access
1004
+ );
1005
+ } else if (actionName === 'brightness_move_to_level') {
1006
+ pushToStates(
1007
+ {
1008
+ id: 'brightness_move_to_level',
1009
+ prop: 'action', // Fix 1: fehlte → statesController fand State nie
1010
+ name: 'Brightness move to level',
1011
+ icon: undefined,
1012
+ role: 'level.dimmer',
1013
+ write: false,
1014
+ read: true,
1015
+ type: 'number',
1016
+ min: 0,
1017
+ max: 100,
1018
+ def: 100,
1019
+ unit: '%',
1020
+ isEvent: true,
1021
+ getter: (payload) => {
1022
+ if (payload.action !== 'brightness_move_to_level') {
1023
+ return undefined;
1024
+ }
1025
+ // Fix 3: != null statt truthy (action_level=0 ist gültig)
1026
+ if (payload.action_level != null) {
1027
+ return utils.bulbLevelToAdapterLevel(payload.action_level);
1028
+ }
1029
+ return undefined;
1030
+ },
1031
+ },
1032
+ expose.access
1033
+ );
1034
+ } else if (actionName === 'move_to_saturation') {
1035
+ pushToStates(
1036
+ {
1037
+ id: 'move_to_saturation',
1038
+ prop: 'action', // Fix 2: fehlte → statesController fand State nie
1039
+ name: 'Move to level saturation',
1040
+ icon: undefined,
1041
+ role: 'level.color.saturation',
1042
+ write: false,
1043
+ read: true,
1044
+ type: 'number',
1045
+ // min: 0,
1046
+ // max: 100,
1047
+ def: 0,
1048
+ isEvent: true,
1049
+ getter: (payload) => {
1050
+ if (payload.action !== 'move_to_saturation') {
1051
+ return undefined;
1052
+ }
1053
+ // FIX: action_saturation prüfen, nicht action_level
1054
+ if (payload.action_saturation != null) {
1055
+ return payload.action_saturation;
1056
+ }
1057
+ return undefined;
1058
+ },
1059
+ },
1060
+ expose.access
1061
+ );
1062
+ } else if (actionName === 'enhanced_move_to_hue_and_saturation') {
1063
+ pushToStates(
1064
+ {
1065
+ id: 'enhanced_move_to_hue_and_saturation',
1066
+ prop: 'action',
1067
+ name: 'Enhanced move to hue and saturation value',
1068
+ icon: undefined,
1069
+ role: 'level.color.hue',
1070
+ write: false,
1071
+ read: true,
1072
+ type: 'number',
1073
+ min: 0,
1074
+ max: 65536,
1075
+ def: 0,
1076
+ isEvent: true,
1077
+ getter: (payload) => {
1078
+ if (payload.action !== 'enhanced_move_to_hue_and_saturation') {
1079
+ return undefined;
1080
+ }
1081
+ // Fix 5: != null statt truthy (0 ist gültiger Hue-Wert)
1082
+ if (payload.action_enhanced_hue != null) {
1083
+ return payload.action_enhanced_hue;
1084
+ }
1085
+ return undefined;
1086
+ },
1087
+ },
1088
+ expose.access
1089
+ );
1090
+ }
1091
+
1092
+ else {
1093
+ pushToStates(
1094
+ {
1095
+ id: actionName.replace(/\*/g, ''),
1096
+ prop: 'action',
1097
+ name: actionName,
1098
+ icon: undefined,
1099
+ role: 'button',
1100
+ write: false,
1101
+ read: true,
1102
+ type: 'boolean',
1103
+ def: false,
1104
+ isEvent: true,
1105
+ getter: (payload) => (payload.action === actionName ? true : undefined),
1106
+ },
1107
+ expose.access
1108
+ );
1109
+ }
1110
+ }
1111
+ // Can the device simulated_brightness?
1112
+ if (
1113
+ definition.options &&
1114
+ definition.options.find((x) => x.property === 'simulated_brightness')
1115
+ ) {
1116
+ pushToStates(statesDefs.simulated_brightness, z2mAccess.STATE);
1117
+ }
1118
+ state = null;
1119
+ break;
1120
+ }
1121
+ default:
1122
+ state = genState(expose);
1123
+ break;
1124
+ }
1125
+ if (state) {pushToStates(state, expose.access);}
1126
+ break;
1127
+
1128
+ case 'binary':
1129
+ if (expose.endpoint) {
1130
+ state = genState(expose);
1131
+ } else {
1132
+ switch (expose.name) {
1133
+ case 'contact':
1134
+ state = statesDefs.contact;
1135
+ pushToStates(statesDefs.opened, expose.access);
1136
+ break;
1137
+
1138
+ case 'battery_low':
1139
+ state = statesDefs.batt_low_t_f;
1140
+ break;
1141
+
1142
+ case 'tamper':
1143
+ state = statesDefs.tamper;
1144
+ break;
1145
+
1146
+ case 'water_leak':
1147
+ state = statesDefs.water_leak;
1148
+ break;
1149
+
1150
+ case 'lock':
1151
+ state = statesDefs.child_lock;
1152
+ break;
1153
+
1154
+ case 'occupancy':
1155
+ state = statesDefs.occupancy;
1156
+ break;
1157
+
1158
+ default:
1159
+ state = genState(expose);
1160
+ break;
1161
+ }
1162
+ }
1163
+ if (state) {pushToStates(state, expose.access);}
1164
+ break;
1165
+
1166
+ case 'list':
1167
+ // Z2M 'list' type → Array-Wert als JSON-String speichern
1168
+ pushToStates({
1169
+ id: expose.property,
1170
+ prop: expose.property,
1171
+ name: expose.description || expose.name || expose.property,
1172
+ icon: undefined,
1173
+ role: 'json',
1174
+ write: (expose.access & z2mAccess.SET) > 0,
1175
+ read: true,
1176
+ type: 'string',
1177
+ def: '[]',
1178
+ getter: (payload) => {
1179
+ const val = payload[expose.property];
1180
+ if (val == null) {return undefined;}
1181
+ return typeof val === 'string' ? val : JSON.stringify(val);
1182
+ },
1183
+ }, expose.access);
1184
+ break;
1185
+
1186
+ case 'text':
1187
+ state = genState(expose);
1188
+ pushToStates(state, expose.access);
1189
+ break;
1190
+
1191
+ case 'lock':
1192
+ case 'fan':
1193
+ case 'cover':
1194
+ for (const prop of expose.features) {
1195
+ switch (prop.name) {
1196
+ case 'state':
1197
+ pushToStates(genState(prop, 'switch'), prop.access);
1198
+ // features contains TOGGLE?
1199
+ if (prop.value_toggle) {
1200
+ pushToStates({
1201
+ id: `${prop.property}_toggle`,
1202
+ prop: `${prop.property}_toggle`,
1203
+ name: `Toggle state of the ${prop.property}`,
1204
+ icon: undefined,
1205
+ role: 'button',
1206
+ write: true,
1207
+ read: true,
1208
+ def: true,
1209
+ type: 'boolean',
1210
+ setattr: prop.property,
1211
+ setter: (value) => (value ? prop.value_toggle : undefined),
1212
+ });
1213
+ }
1214
+ break;
1215
+ default:
1216
+ pushToStates(genState(prop), prop.access);
1217
+ break;
1218
+ }
1219
+ }
1220
+ break;
1221
+
1222
+ case 'climate':
1223
+ for (const prop of expose.features) {
1224
+ switch (prop.name) {
1225
+ case 'away_mode':
1226
+ pushToStates(statesDefs.climate_away_mode, prop.access);
1227
+ break;
1228
+ case 'system_mode':
1229
+ pushToStates(statesDefs.climate_system_mode, prop.access);
1230
+ break;
1231
+ case 'running_mode':
1232
+ case 'running_state': // Z2M nutzt beide Bezeichnungen je nach Version
1233
+ pushToStates(statesDefs.climate_running_mode, prop.access);
1234
+ break;
1235
+ case 'local_temperature':
1236
+ pushToStates(statesDefs.local_temperature, prop.access);
1237
+ break;
1238
+ case 'local_temperature_calibration':
1239
+ pushToStates(statesDefs.local_temperature_calibration, prop.access);
1240
+ break;
1241
+ default:
1242
+ {
1243
+ if (prop.name.includes('heating_setpoint')) {
1244
+ pushToStates(genState(prop, 'level.temperature'), prop.access);
1245
+ } else {
1246
+ pushToStates(genState(prop), prop.access);
1247
+ }
1248
+ }
1249
+ break;
1250
+ }
1251
+ }
1252
+ break;
1253
+
1254
+ case 'composite': {
1255
+ for (const propRaw of expose.features) {
1256
+ // FIX: Clone um Mutation des originalen Z2M-Expose-Objekts zu vermeiden
1257
+ // (prop.type = 'text' würde sonst beim nächsten bridge/devices-Event noch 'text' sein)
1258
+ const prop = Object.assign({}, propRaw);
1259
+ prop.type = 'text'; // to avoid problems with numbers, booleans, etc.
1260
+
1261
+ const state = genState(prop);
1262
+ // Workaround for FP1 new state (region_upsert)
1263
+ if (!state) {
1264
+ break;
1265
+ }
1266
+
1267
+ state.prop = expose.property;
1268
+ state.inOptions = true;
1269
+ state.isOption = true;
1270
+
1271
+ if (expose.access & z2mAccess.STATE) {
1272
+ state.getter = (payload) => {
1273
+ if (
1274
+ payload.hasOwnProperty(expose.property) &&
1275
+ payload[expose.property] !== null &&
1276
+ payload[expose.property].hasOwnProperty(prop.property)
1277
+ ) {
1278
+ return !isNaN(payload[expose.property][prop.property])
1279
+ ? payload[expose.property][prop.property]
1280
+ : undefined;
1281
+ }
1282
+ return undefined;
1283
+
1284
+ };
1285
+ } else {
1286
+ state.getter = (payload) => {
1287
+ // Fix: null-Check vor Zugriff auf property
1288
+ if (!payload[expose.property]) {
1289
+ return undefined;
1290
+ }
1291
+ return payload[expose.property][prop.property];
1292
+ };
1293
+ }
1294
+
1295
+ pushToStates(state, z2mAccess.STATE);
1296
+ }
1297
+
1298
+ break;
1299
+ }
1300
+ default:
1301
+ adapter.log.debug(`Unhandled expose type ${expose.type} for device ${deviceID}`);
1302
+ }
1303
+ }
1304
+ } catch (err) {
1305
+ adapter.log.error(`ERROR in expose for device ${deviceID} : ${err}`);
1306
+ }
1307
+
1308
+ // Fix 8: definition.model kann undefined sein → Fallback auf ''
1309
+ for (const state of getNonGenDevStatesDefs(definition.model || '')) {
1310
+ pushToStates(state, state.write ? z2mAccess.SET : z2mAccess.STATE);
1311
+ }
1312
+
1313
+ // Add default states
1314
+ pushToStates(statesDefs.available, z2mAccess.STATE);
1315
+ pushToStates(statesDefs.last_seen, z2mAccess.STATE);
1316
+ pushToStates(statesDefs.send_payload, z2mAccess.SET);
1317
+
1318
+ // Create buttons for scenes
1319
+ for (const scene of scenes) {
1320
+ // Fix 9: scene.name kann null/undefined sein
1321
+ pushToStates({
1322
+ id: `scene_${scene.id}`,
1323
+ prop: `scene_recall`,
1324
+ name: scene.name || `Scene ${scene.id}`,
1325
+ icon: undefined,
1326
+ role: 'button',
1327
+ write: true,
1328
+ read: true,
1329
+ def: true,
1330
+ type: 'boolean',
1331
+ setter: (value) => (value ? scene.id : undefined),
1332
+ });
1333
+ }
1334
+
1335
+ const newDevice = {
1336
+ id: deviceID,
1337
+ ieee_address: ieee_address,
1338
+ power_source: power_source,
1339
+ disabled: disabled,
1340
+ description: description,
1341
+ optionsValues: {},
1342
+ states: states,
1343
+ };
1344
+
1345
+ return newDevice;
1346
+ }
1347
+
1348
+ module.exports = {
1349
+ createDeviceFromExposes: createDeviceFromExposes,
1350
+ };