iobroker.assistant 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -0
  3. package/admin/assistant.svg +19 -0
  4. package/admin/custom/.vite/manifest.json +169 -0
  5. package/admin/custom/assets/Components-DcnuVFM6.js +1 -0
  6. package/admin/custom/assets/_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-DogEjv3U.js +1 -0
  7. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-Dw5TkU1t.js +1 -0
  8. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-BEFjTs9S.js +1 -0
  9. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-C4r3udVz.js +1243 -0
  10. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_iobroker_mf_1_json_mf_2_config__loadShare__.js-DlOBHPL0.js +1494 -0
  11. package/admin/custom/assets/hostInit-DKMI-sLp.js +1 -0
  12. package/admin/custom/assets/index-B8wW0kQx.js +1 -0
  13. package/admin/custom/assets/lib-DpE8sc4h.js +3 -0
  14. package/admin/custom/assets/mf-entry-bootstrap-0-e7fa582d.js +36 -0
  15. package/admin/custom/assets/rolldown-runtime-C0FnF6B9.js +1 -0
  16. package/admin/custom/assets/virtualExposes-DOTO22UK.js +1 -0
  17. package/admin/custom/assets/virtual_mf-REMOTE_ENTRY_ID___mfe_internal__ConfigCustomAssistant__customComponents_js-CQa59yVi.js +1 -0
  18. package/admin/custom/assets/virtual_mf-exposes-ssr___mfe_internal__ConfigCustomAssistant__customComponents_js-9g2Fj8tk.js +2 -0
  19. package/admin/custom/assets/virtual_mf-exposes___mfe_internal__ConfigCustomAssistant__customComponents_js-BTUQTbYo.js +2 -0
  20. package/admin/custom/assets/vite-preload-helper-B7qeedMF.js +1 -0
  21. package/admin/custom/customComponents.js +1 -0
  22. package/admin/custom/customComponents.ssr.js +1 -0
  23. package/admin/custom/i18n/de.json +58 -0
  24. package/admin/custom/i18n/en.json +58 -0
  25. package/admin/custom/i18n/es.json +34 -0
  26. package/admin/custom/i18n/fr.json +34 -0
  27. package/admin/custom/i18n/it.json +34 -0
  28. package/admin/custom/i18n/nl.json +34 -0
  29. package/admin/custom/i18n/pl.json +34 -0
  30. package/admin/custom/i18n/pt.json +34 -0
  31. package/admin/custom/i18n/ru.json +34 -0
  32. package/admin/custom/i18n/uk.json +34 -0
  33. package/admin/custom/i18n/zh-cn.json +34 -0
  34. package/admin/custom/index.html +32 -0
  35. package/admin/custom/mf-manifest.json +1 -0
  36. package/admin/custom/mf-stats.json +1 -0
  37. package/admin/i18n/de.json +130 -0
  38. package/admin/i18n/en.json +130 -0
  39. package/admin/i18n/es.json +106 -0
  40. package/admin/i18n/fr.json +106 -0
  41. package/admin/i18n/it.json +106 -0
  42. package/admin/i18n/nl.json +106 -0
  43. package/admin/i18n/pl.json +106 -0
  44. package/admin/i18n/pt.json +106 -0
  45. package/admin/i18n/ru.json +106 -0
  46. package/admin/i18n/uk.json +106 -0
  47. package/admin/i18n/zh-cn.json +106 -0
  48. package/admin/jsonConfig.json +743 -0
  49. package/admin/sounds/alarm.wav +0 -0
  50. package/admin/sounds/timer.wav +0 -0
  51. package/build/lib/alarms.js +281 -0
  52. package/build/lib/alarms.js.map +1 -0
  53. package/build/lib/context.js +52 -0
  54. package/build/lib/context.js.map +1 -0
  55. package/build/lib/credentials.js +121 -0
  56. package/build/lib/credentials.js.map +1 -0
  57. package/build/lib/llm.js +303 -0
  58. package/build/lib/llm.js.map +1 -0
  59. package/build/lib/localLlm.js +221 -0
  60. package/build/lib/localLlm.js.map +1 -0
  61. package/build/lib/memory.js +190 -0
  62. package/build/lib/memory.js.map +1 -0
  63. package/build/lib/nlu.js +929 -0
  64. package/build/lib/nlu.js.map +1 -0
  65. package/build/lib/timers.js +245 -0
  66. package/build/lib/timers.js.map +1 -0
  67. package/build/lib/tools.js +299 -0
  68. package/build/lib/tools.js.map +1 -0
  69. package/build/lib/voice/aws.js +102 -0
  70. package/build/lib/voice/aws.js.map +1 -0
  71. package/build/lib/voice/azure.js +144 -0
  72. package/build/lib/voice/azure.js.map +1 -0
  73. package/build/lib/voice/download.js +76 -0
  74. package/build/lib/voice/download.js.map +1 -0
  75. package/build/lib/voice/engines.js +89 -0
  76. package/build/lib/voice/engines.js.map +1 -0
  77. package/build/lib/voice/lang.js +33 -0
  78. package/build/lib/voice/lang.js.map +1 -0
  79. package/build/lib/voice/piper.js +191 -0
  80. package/build/lib/voice/piper.js.map +1 -0
  81. package/build/lib/voice/protocol.js +35 -0
  82. package/build/lib/voice/protocol.js.map +1 -0
  83. package/build/lib/voice/stt.js +116 -0
  84. package/build/lib/voice/stt.js.map +1 -0
  85. package/build/lib/voice/tts.js +36 -0
  86. package/build/lib/voice/tts.js.map +1 -0
  87. package/build/lib/voice/voiceServer.js +249 -0
  88. package/build/lib/voice/voiceServer.js.map +1 -0
  89. package/build/lib/voice/vosk.js +260 -0
  90. package/build/lib/voice/vosk.js.map +1 -0
  91. package/build/lib/voice/wyoming.js +320 -0
  92. package/build/lib/voice/wyoming.js.map +1 -0
  93. package/build/lib/weather.js +423 -0
  94. package/build/lib/weather.js.map +1 -0
  95. package/build/main.js +3152 -0
  96. package/build/main.js.map +1 -0
  97. package/io-package.json +528 -0
  98. package/package.json +68 -0
@@ -0,0 +1,929 @@
1
+ "use strict";
2
+ /**
3
+ * Tier-0 rule-based NLU (no model, no install, offline) — a TypeScript port of the proven matching
4
+ * approach from the Python assistant "Hannah" (`core/hannah/nlu.py`), reduced to the intents this
5
+ * adapter needs: turn on/off, set level, set color, and status queries.
6
+ *
7
+ * It matches a free-text command against the known rooms and devices (room + device + action, longest
8
+ * match wins) and returns a structured {@link NluIntent} the caller executes directly via the ioBroker
9
+ * API — bypassing the LLM entirely for the common commands. Anything it cannot confidently resolve
10
+ * returns `null`, so the caller falls through to the local/cloud LLM.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.Nlu = void 0;
14
+ exports.parseClockTime = parseClockTime;
15
+ exports.parseWeekdays = parseWeekdays;
16
+ exports.parseDurationSeconds = parseDurationSeconds;
17
+ exports.isStopCommand = isStopCommand;
18
+ exports.normalize = normalize;
19
+ const TURN_ON = new Set([
20
+ // de/en
21
+ 'an',
22
+ 'ein',
23
+ 'einschalten',
24
+ 'anschalten',
25
+ 'anmachen',
26
+ 'aktiviere',
27
+ 'aktivieren',
28
+ 'starte',
29
+ 'start',
30
+ 'on',
31
+ // ru
32
+ 'включи',
33
+ 'включить',
34
+ 'вруби',
35
+ 'врубить',
36
+ 'зажги',
37
+ 'запусти',
38
+ ]);
39
+ const TURN_OFF = new Set([
40
+ // de/en
41
+ 'aus',
42
+ 'ausschalten',
43
+ 'abschalten',
44
+ 'ausmachen',
45
+ 'deaktiviere',
46
+ 'deaktivieren',
47
+ 'stoppe',
48
+ 'stop',
49
+ 'off',
50
+ // ru
51
+ 'выключи',
52
+ 'выключить',
53
+ 'выруби',
54
+ 'вырубить',
55
+ 'погаси',
56
+ 'потуши',
57
+ 'отключи',
58
+ ]);
59
+ const QUERY_WORDS = new Set([
60
+ // de/en
61
+ 'wie',
62
+ 'was',
63
+ 'ist',
64
+ 'sind',
65
+ 'status',
66
+ 'wieviel',
67
+ 'welche',
68
+ 'welcher',
69
+ 'zeig',
70
+ 'zeige',
71
+ 'laeuft',
72
+ 'how',
73
+ 'what',
74
+ 'is',
75
+ 'are',
76
+ 'show',
77
+ // ru
78
+ 'какая',
79
+ 'какой',
80
+ 'какое',
81
+ 'сколько',
82
+ 'покажи',
83
+ 'что',
84
+ 'статус',
85
+ ]);
86
+ /** Filler words removed before matching (so "mach das Licht an" → ["licht","an"]). */
87
+ const FILLER = new Set([
88
+ // de/en
89
+ 'bitte',
90
+ 'mal',
91
+ 'doch',
92
+ 'denn',
93
+ 'einfach',
94
+ 'kannst',
95
+ 'du',
96
+ 'koenntest',
97
+ 'mach',
98
+ 'die',
99
+ 'das',
100
+ 'den',
101
+ 'der',
102
+ 'the',
103
+ 'please',
104
+ 'und',
105
+ 'im',
106
+ 'in',
107
+ 'auf',
108
+ 'zum',
109
+ 'zur',
110
+ // ru
111
+ 'пожалуйста',
112
+ 'давай',
113
+ 'ну',
114
+ 'мне',
115
+ 'на',
116
+ 'в',
117
+ 'и',
118
+ ]);
119
+ /** Color names (de + ru) → hex, or a special token for color temperature. */
120
+ const COLORS = {
121
+ rot: '#FF0000',
122
+ gruen: '#00FF00',
123
+ blau: '#0000FF',
124
+ gelb: '#FFFF00',
125
+ orange: '#FF8000',
126
+ lila: '#8000FF',
127
+ pink: '#FF69B4',
128
+ magenta: '#FF00FF',
129
+ cyan: '#00FFFF',
130
+ tuerkis: '#00CED1',
131
+ weiss: '#FFFFFF',
132
+ warmweiss: 'warm',
133
+ kaltweiss: 'cold',
134
+ красный: '#FF0000',
135
+ зеленый: '#00FF00',
136
+ синий: '#0000FF',
137
+ голубой: '#00BFFF',
138
+ желтый: '#FFFF00',
139
+ оранжевый: '#FF8000',
140
+ фиолетовый: '#8000FF',
141
+ розовый: '#FF69B4',
142
+ белый: '#FFFFFF',
143
+ };
144
+ /** Device types that accept a color. */
145
+ const COLOR_TYPES = new Set(['rgb', 'rgbSingle', 'rgbwSingle', 'hue', 'cie', 'ct']);
146
+ /** type-detector types that count as "windows" for the "which windows are open" aggregate query. */
147
+ const WINDOW_TYPES = new Set(['window', 'windowTilt']);
148
+ /** Words (de/en/ru, stemmed) that name a window. */
149
+ const WINDOW_WORDS = ['fenster', 'window', 'windows', 'окно', 'окна', 'окон'];
150
+ /** Words (de/en/ru, stemmed) that mean "open". */
151
+ const OPEN_WORDS = ['offen', 'geoeffnet', 'geoffnet', 'auf', 'open', 'открыт', 'открыта', 'открыты', 'открытые'];
152
+ /** Words (de/en/ru, stemmed) that make a query an on/off question ("is the light on?"). */
153
+ const ON_OFF_QUERY_WORDS = [
154
+ 'an',
155
+ 'ein',
156
+ 'aus',
157
+ 'eingeschaltet',
158
+ 'ausgeschaltet',
159
+ 'on',
160
+ 'off',
161
+ 'включен',
162
+ 'включена',
163
+ 'включено',
164
+ 'выключен',
165
+ 'выключена',
166
+ 'работает',
167
+ ];
168
+ /**
169
+ * State-id suffixes of pure measurements/indicators — never the on/off switch or level (but note ACTUAL
170
+ * is NOT here: it is a valid read-back for "is it on"). Used to keep pickControl off metering states.
171
+ */
172
+ const METER_SUFFIX = /(CONSUMPTION|ELECTRIC_POWER|CURRENT|VOLTAGE|ENERGY|POWER|LOWBAT|BATTERY|UNREACH|RSSI|TEMPERATURE)$/i;
173
+ // ── Timers / reminders (de/en/ru) ───────────────────────────────────────────
174
+ /**
175
+ * Marks a command as being about a countdown timer. Never fires on the bare word "time" ("what time is
176
+ * it") — it needs the full "timer". "timers?" carries no leading word boundary so German compounds
177
+ * ("Küchentimer", "Eieruhr"-style) and the plural still match; the prefix forms keep a boundary.
178
+ */
179
+ const TIMER_RE = /(?:(?<![\p{L}])(reminder|remind|erinner\p{L}*|таймер\p{L}*|напомн\p{L}*)|timers?)(?![\p{L}])/iu;
180
+ /** Words that turn a timer command into "cancel/stop the timer" (de/en/ru). */
181
+ const TIMER_CANCEL_RE = /(?<![\p{L}])(abbrech\p{L}*|beend\p{L}*|stopp?\p{L}*|loesch\p{L}*|cancel|delete|remove|clear|отмен\p{L}*|удал\p{L}*|сброс\p{L}*|стоп)(?![\p{L}])/iu;
182
+ /** Number words → digits (normalized: umlauts expanded), de/en/ru, plus common fractions. */
183
+ const NUMBER_WORDS = {
184
+ ein: '1',
185
+ eine: '1',
186
+ einen: '1',
187
+ eins: '1',
188
+ zwei: '2',
189
+ drei: '3',
190
+ vier: '4',
191
+ fuenf: '5',
192
+ sechs: '6',
193
+ sieben: '7',
194
+ acht: '8',
195
+ neun: '9',
196
+ zehn: '10',
197
+ elf: '11',
198
+ zwoelf: '12',
199
+ anderthalb: '1.5',
200
+ eineinhalb: '1.5',
201
+ halbe: '0.5',
202
+ halb: '0.5',
203
+ viertel: '0.25',
204
+ dreiviertel: '0.75',
205
+ one: '1',
206
+ two: '2',
207
+ three: '3',
208
+ four: '4',
209
+ five: '5',
210
+ six: '6',
211
+ seven: '7',
212
+ eight: '8',
213
+ nine: '9',
214
+ ten: '10',
215
+ eleven: '11',
216
+ twelve: '12',
217
+ half: '0.5',
218
+ quarter: '0.25',
219
+ один: '1',
220
+ одну: '1',
221
+ два: '2',
222
+ две: '2',
223
+ три: '3',
224
+ четыре: '4',
225
+ пять: '5',
226
+ шесть: '6',
227
+ семь: '7',
228
+ восемь: '8',
229
+ девять: '9',
230
+ десять: '10',
231
+ };
232
+ /** Fixed phrases replaced up front so their fractions don't collide with the general number-word pass. */
233
+ const DURATION_PHRASES = [
234
+ [/(?<![\p{L}])dreiviertelstunde(?![\p{L}])/iu, '45 minuten'],
235
+ [/(?<![\p{L}])viertelstunde(?![\p{L}])/iu, '15 minuten'],
236
+ [/(?<![\p{L}])eine? halbe stunde(?![\p{L}])/iu, '30 minuten'],
237
+ [/(?<![\p{L}])halbe stunde(?![\p{L}])/iu, '30 minuten'],
238
+ [/half an hour/iu, '30 minutes'],
239
+ [/a half hour/iu, '30 minutes'],
240
+ [/quarter of an hour/iu, '15 minutes'],
241
+ [/quarter hour/iu, '15 minutes'],
242
+ [/(?<![\p{L}])полтора часа(?![\p{L}])/iu, '90 минут'],
243
+ [/(?<![\p{L}])полчаса(?![\p{L}])/iu, '30 минут'],
244
+ ];
245
+ const HOUR_RE = /(\d+(?:[.,]\d+)?)\s*(?:stunden?|hours?|hrs?|h|час(?:а|ов)?)(?![\p{L}])/giu;
246
+ const MIN_RE = /(\d+(?:[.,]\d+)?)\s*(?:minuten?|mins?|minutes?|минут[уы]?)(?![\p{L}])/giu;
247
+ const SEC_RE = /(\d+(?:[.,]\d+)?)\s*(?:sekunden?|secs?|seconds?|секунд[уы]?)(?![\p{L}])/giu;
248
+ /** Duration/number tokens stripped from a timer label ("timer für 5 minuten für die Nudeln" → "die Nudeln"). */
249
+ const LABEL_STOP_WORDS = new Set([
250
+ 'in',
251
+ 'im',
252
+ 'mich',
253
+ 'me',
254
+ 'my',
255
+ 'den',
256
+ 'die',
257
+ 'das',
258
+ 'der',
259
+ 'the',
260
+ 'a',
261
+ 'an',
262
+ 'auf',
263
+ 'noch',
264
+ 'ein',
265
+ 'eine',
266
+ 'einen',
267
+ 'timer',
268
+ 'reminder',
269
+ 'remind',
270
+ 'таймер',
271
+ 'на',
272
+ 'через',
273
+ 'про',
274
+ 'about',
275
+ 'to',
276
+ 'for',
277
+ 'fuer',
278
+ 'stunde',
279
+ 'stunden',
280
+ 'minute',
281
+ 'minuten',
282
+ 'min',
283
+ 'sekunde',
284
+ 'sekunden',
285
+ 'sec',
286
+ 'hour',
287
+ 'hours',
288
+ 'minutes',
289
+ 'seconds',
290
+ 'second',
291
+ 'час',
292
+ 'часа',
293
+ 'часов',
294
+ 'минут',
295
+ 'минуту',
296
+ 'минуты',
297
+ 'секунд',
298
+ 'секунду',
299
+ 'секунды',
300
+ ]);
301
+ /** Marker words after which a timer label follows ("… an die Nudeln", "remind me to call mom"). */
302
+ const LABEL_MARKERS = new Set(['an', 'fuer', 'for', 'to', 'about', 'про']);
303
+ // ── Alarms / wake-ups at a fixed clock time (de/en/ru) ──────────────────────
304
+ /**
305
+ * Marks a command as being about an alarm / wake-up ("Wecker", "weck mich", "alarm", "будильник"). Like
306
+ * {@link TIMER_RE}, "alarms?"/"wecker" carry no leading boundary so compounds ("Küchenwecker") and the
307
+ * plural still match; the verb-prefix forms ("weck…", "разбуд…") keep a boundary.
308
+ */
309
+ const ALARM_RE = /(?:(?<![\p{L}])(weck\p{L}*|wake|буд\p{L}*|разбуд\p{L}*)|alarms?|wecker)(?![\p{L}])/iu;
310
+ /** Hour number words → digits (no fractions — those are handled by the clock regexes). de/en/ru, 1–12. */
311
+ const HOUR_WORDS = {
312
+ ein: '1',
313
+ eine: '1',
314
+ einen: '1',
315
+ eins: '1',
316
+ zwei: '2',
317
+ drei: '3',
318
+ vier: '4',
319
+ fuenf: '5',
320
+ sechs: '6',
321
+ sieben: '7',
322
+ acht: '8',
323
+ neun: '9',
324
+ zehn: '10',
325
+ elf: '11',
326
+ zwoelf: '12',
327
+ one: '1',
328
+ two: '2',
329
+ three: '3',
330
+ four: '4',
331
+ five: '5',
332
+ six: '6',
333
+ seven: '7',
334
+ eight: '8',
335
+ nine: '9',
336
+ ten: '10',
337
+ eleven: '11',
338
+ twelve: '12',
339
+ один: '1',
340
+ два: '2',
341
+ три: '3',
342
+ четыре: '4',
343
+ пять: '5',
344
+ шесть: '6',
345
+ семь: '7',
346
+ восемь: '8',
347
+ девять: '9',
348
+ десять: '10',
349
+ одиннадцать: '11',
350
+ двенадцать: '12',
351
+ };
352
+ /** Weekday word (normalized, prefix-matched for inflection) → 0 (Sun) … 6 (Sat). */
353
+ const WEEKDAYS = [
354
+ [/(?<![\p{L}])(montag|monday|понедельник)/iu, 1],
355
+ [/(?<![\p{L}])(dienstag|tuesday|вторник)/iu, 2],
356
+ [/(?<![\p{L}])(mittwoch|wednesday|сред)/iu, 3],
357
+ [/(?<![\p{L}])(donnerstag|thursday|четверг)/iu, 4],
358
+ [/(?<![\p{L}])(freitag|friday|пятниц)/iu, 5],
359
+ [/(?<![\p{L}])(samstag|sonnabend|saturday|суббот)/iu, 6],
360
+ [/(?<![\p{L}])(sonntag|sunday|воскресень)/iu, 0],
361
+ ];
362
+ /**
363
+ * Parse a clock time from free text (de/en/ru): "7:30", "7 Uhr", "7 Uhr 30", "halb acht" (7:30),
364
+ * "viertel nach sieben" (7:15), "viertel vor acht"/"dreiviertel acht" (7:45), "half past seven",
365
+ * "quarter past/to", "в 7 часов", plus am/pm. Returns {hour 0-23, minute 0-59} or null.
366
+ */
367
+ function parseClockTime(text) {
368
+ let t = normalize(text);
369
+ // Substitute hour number words → digits ("halb acht" → "halb 8"), leaving fraction words intact.
370
+ t = t
371
+ .split(/\s+/)
372
+ .map(w => (Object.prototype.hasOwnProperty.call(HOUR_WORDS, w) ? HOUR_WORDS[w] : w))
373
+ .join(' ');
374
+ const pm = /(?<![\p{L}])(pm|nachmittags|abends|вечера)(?![\p{L}])/u.test(t);
375
+ const am = /(?<![\p{L}])(am|morgens|vormittags|utra|утра|nachts)(?![\p{L}])/u.test(t);
376
+ const applyMeridiem = (h) => {
377
+ if (pm && h < 12) {
378
+ return h + 12;
379
+ }
380
+ if (am && h === 12) {
381
+ return 0;
382
+ }
383
+ return h;
384
+ };
385
+ const ok = (h, m) => h >= 0 && h <= 23 && m >= 0 && m <= 59 ? { hour: h, minute: m } : null;
386
+ let m;
387
+ // HH:MM
388
+ if ((m = t.match(/(?<!\d)(\d{1,2}):(\d{2})(?!\d)/))) {
389
+ return ok(applyMeridiem(+m[1]), +m[2]);
390
+ }
391
+ // "viertel nach H" → H:15 ; "viertel vor H" / "dreiviertel H" → (H-1):45
392
+ if ((m = t.match(/viertel\s+nach\s+(\d{1,2})/))) {
393
+ return ok(applyMeridiem(+m[1]), 15);
394
+ }
395
+ if ((m = t.match(/(?:viertel\s+vor|dreiviertel)\s+(\d{1,2})/))) {
396
+ return ok(applyMeridiem((+m[1] + 23) % 24), 45);
397
+ }
398
+ if ((m = t.match(/quarter\s+past\s+(\d{1,2})/))) {
399
+ return ok(applyMeridiem(+m[1]), 15);
400
+ }
401
+ if ((m = t.match(/quarter\s+to\s+(\d{1,2})/))) {
402
+ return ok(applyMeridiem((+m[1] + 23) % 24), 45);
403
+ }
404
+ // "halb H" (German) → (H-1):30 ; "half past H" (English) → H:30
405
+ if ((m = t.match(/half\s+past\s+(\d{1,2})/))) {
406
+ return ok(applyMeridiem(+m[1]), 30);
407
+ }
408
+ if ((m = t.match(/(?<![\p{L}])halb\s+(\d{1,2})/u))) {
409
+ return ok(applyMeridiem((+m[1] + 23) % 24), 30);
410
+ }
411
+ // "H Uhr MM" / "H Uhr" / "H часов MM"
412
+ if ((m = t.match(/(\d{1,2})\s*(?:uhr|часов|часа|час)\s*(\d{1,2})(?![\p{L}])/u))) {
413
+ return ok(applyMeridiem(+m[1]), +m[2]);
414
+ }
415
+ if ((m = t.match(/(\d{1,2})\s*(?:uhr|o.?clock|часов|часа|час)(?![\p{L}])/u))) {
416
+ return ok(applyMeridiem(+m[1]), 0);
417
+ }
418
+ // "um/at/в H" (bare hour after an explicit time preposition) — but not when a duration unit follows,
419
+ // so "Timer um 5 Minuten" is not misread as 5 o'clock.
420
+ if ((m = t.match(/(?<![\p{L}])(?:um|at|в|во)\s+(\d{1,2})(?![\d:])(?!\s*(?:minut|stund|sekund|min|sek|hour|second))/u))) {
421
+ return ok(applyMeridiem(+m[1]), 0);
422
+ }
423
+ // Bare hour qualified only by am/pm ("7 pm", "8 am").
424
+ if ((pm || am) && (m = t.match(/(?<![\d.])(\d{1,2})(?![\d.:])/))) {
425
+ return ok(applyMeridiem(+m[1]), 0);
426
+ }
427
+ return null;
428
+ }
429
+ /**
430
+ * Parse recurrence from free text → weekday numbers (0 = Sun … 6 = Sat). "täglich"/"jeden Tag"/"daily"
431
+ * → all 7; "wochentags"/"werktags"/"weekdays" → Mon–Fri; "wochenende"/"weekend" → Sat+Sun; otherwise the
432
+ * union of the named weekdays. Empty array = one-shot.
433
+ */
434
+ function parseWeekdays(text) {
435
+ const t = normalize(text);
436
+ if (/(?<![\p{L}])(taeglich|jeden tag|daily|every day|ежедневно|каждый день)(?![\p{L}])/u.test(t)) {
437
+ return [0, 1, 2, 3, 4, 5, 6];
438
+ }
439
+ if (/(?<![\p{L}])(wochentags|werktags|weekdays|по будням|будни)(?![\p{L}])/u.test(t)) {
440
+ return [1, 2, 3, 4, 5];
441
+ }
442
+ if (/(?<![\p{L}])(wochenende|weekend|выходн\p{L}*)(?![\p{L}])/u.test(t)) {
443
+ return [0, 6];
444
+ }
445
+ const days = new Set();
446
+ for (const [re, day] of WEEKDAYS) {
447
+ if (re.test(t)) {
448
+ days.add(day);
449
+ }
450
+ }
451
+ return [...days].sort();
452
+ }
453
+ /**
454
+ * Parse a free-text duration ("1 Stunde 30 Minuten", "in 5 minuten", "полтора часа") into seconds; null
455
+ * if none is found. With `assumeMinutes`, a bare number with no unit ("timer 5") counts as minutes.
456
+ */
457
+ function parseDurationSeconds(text, assumeMinutes = false) {
458
+ let t = normalize(text);
459
+ for (const [re, rep] of DURATION_PHRASES) {
460
+ t = t.replace(re, rep);
461
+ }
462
+ // Replace standalone number words with digits so "fünf minuten" → "5 minuten".
463
+ t = t
464
+ .split(/\s+/)
465
+ .map(w => (Object.prototype.hasOwnProperty.call(NUMBER_WORDS, w) ? NUMBER_WORDS[w] : w))
466
+ .join(' ');
467
+ let total = 0;
468
+ let matched = false;
469
+ const sum = (re, factor) => {
470
+ re.lastIndex = 0;
471
+ let m;
472
+ while ((m = re.exec(t))) {
473
+ total += parseFloat(m[1].replace(',', '.')) * factor;
474
+ matched = true;
475
+ }
476
+ };
477
+ sum(HOUR_RE, 3600);
478
+ sum(MIN_RE, 60);
479
+ sum(SEC_RE, 1);
480
+ if (!matched && assumeMinutes) {
481
+ const bare = t.match(/(?<![\d.])(\d+(?:[.,]\d+)?)(?![\d.])/);
482
+ if (bare) {
483
+ total = parseFloat(bare[1].replace(',', '.')) * 60;
484
+ matched = true;
485
+ }
486
+ }
487
+ return matched && total > 0 ? Math.round(total) : null;
488
+ }
489
+ /**
490
+ * "Make it stop" words (de/en/ru), used to silence a ringing timer/alarm. Kept separate from the device
491
+ * TURN_OFF set (no "aus") so it only reacts to an explicit stop/quiet phrase, not "turn the light off".
492
+ */
493
+ const STOP_RE = /(?<![\p{L}])(stopp?\p{L}*|halt|aufhoer\p{L}*|ruhe|ruhig|genug|schluss|abbrech\p{L}*|beend\p{L}*|ende|still|cancel|enough|quiet|dismiss|silence|стоп|хватит|тихо|довольно|прекрат\p{L}*|отмен\p{L}*|замолч\p{L}*|тишин\p{L}*)(?![\p{L}])/iu;
494
+ /** True if the text is a "stop / be quiet" command (used to silence a ringing timer/alarm). */
495
+ function isStopCommand(text) {
496
+ return STOP_RE.test(normalize(text));
497
+ }
498
+ /** Normalize for matching: lowercase, German umlauts → ascii, ß → ss, Russian ё → е. */
499
+ function normalize(s) {
500
+ return s
501
+ .toLowerCase()
502
+ .replace(/ä/g, 'ae')
503
+ .replace(/ö/g, 'oe')
504
+ .replace(/ü/g, 'ue')
505
+ .replace(/ß/g, 'ss')
506
+ .replace(/ё/g, 'е');
507
+ }
508
+ const STRIP = /[.,!?;:()[\]]/g;
509
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
510
+ /**
511
+ * Does a name word appear in the text? Matches on a Unicode word boundary and allows a differing
512
+ * suffix (crude stemming) so inflected forms match too — essential for Russian ("подсветку" for
513
+ * "подсветка", "кокпита" for "кокпит"). Longer words drop more trailing characters.
514
+ */
515
+ function wordInText(word, text) {
516
+ const stem = word.length >= 6 ? word.slice(0, word.length - 2) : word.length >= 5 ? word.slice(0, word.length - 1) : word;
517
+ try {
518
+ return new RegExp(`(?<![\\p{L}\\p{N}])${escapeRe(stem)}`, 'u').test(text);
519
+ }
520
+ catch {
521
+ return text.includes(stem);
522
+ }
523
+ }
524
+ class Nlu {
525
+ rooms;
526
+ devices;
527
+ /** Synonym dictionary as normalized [from, to] pairs, applied to the input before matching. */
528
+ aliases;
529
+ constructor(rooms, devices, aliases = []) {
530
+ this.rooms = rooms.filter(Boolean);
531
+ this.devices = devices;
532
+ this.aliases = aliases
533
+ .map(a => [normalize(a.from || ''), normalize(a.to || '')])
534
+ .filter(([from]) => from);
535
+ }
536
+ /**
537
+ * Rewrite synonyms to canonical terms on the (normalized) input, matching whole words/phrases only
538
+ * (Unicode boundaries). E.g. "tv" → "fernseher", "deckenlicht" → "licht wohnzimmer". No-op when the
539
+ * dictionary is empty. Applied once up front so all downstream matching sees the canonical wording.
540
+ */
541
+ applyAliases(norm) {
542
+ let out = norm;
543
+ for (const [from, to] of this.aliases) {
544
+ try {
545
+ out = out.replace(new RegExp(`(?<![\\p{L}\\p{N}])${escapeRe(from)}(?![\\p{L}\\p{N}])`, 'gu'), to);
546
+ }
547
+ catch {
548
+ // Defensive: a malformed alias must never break parsing.
549
+ }
550
+ }
551
+ return out;
552
+ }
553
+ /** Parse a command; returns a structured intent or null if nothing could be resolved confidently. */
554
+ parse(text) {
555
+ const raw = text;
556
+ const norm = this.applyAliases(normalize(text.replace(STRIP, ' ')));
557
+ const tokens = norm.split(/\s+/).filter(t => t && !FILLER.has(t));
558
+ if (!tokens.length) {
559
+ return null;
560
+ }
561
+ const joined = tokens.join(' ');
562
+ const tokenSet = new Set(tokens);
563
+ // Timer / alarm intents ("timer 5 minuten", "weck mich um 7 Uhr", "wie lange noch") — before
564
+ // window/device matching, and device-independent.
565
+ const schedule = this.parseSchedule(raw, norm, joined);
566
+ if (schedule) {
567
+ return schedule;
568
+ }
569
+ // Time / date query ("what time is it", "which day is today") — device-independent, answered from
570
+ // the host clock. After the schedule parse so a clock phrase with a timer/alarm keyword still wins.
571
+ const timeQuery = this.parseTimeQuery(joined);
572
+ if (timeQuery) {
573
+ return timeQuery;
574
+ }
575
+ // Aggregate query first ("which windows are open") — before single-device matching.
576
+ const windowsQuery = this.parseWindowsOpen(joined);
577
+ if (windowsQuery) {
578
+ return windowsQuery;
579
+ }
580
+ const roomName = this.findRoom(joined);
581
+ const device = this.findDevice(joined, roomName);
582
+ if (!device) {
583
+ return null; // no device → let the LLM handle it
584
+ }
585
+ const action = this.findAction(tokenSet); // 'on' | 'off' | null
586
+ const level = this.findLevel(norm); // number | null
587
+ const color = this.findColor(joined);
588
+ const isQuery = tokens.some(t => QUERY_WORDS.has(t)) || raw.trim().endsWith('?');
589
+ // A question ("ist das Licht an?") must never trigger a write — only the query branch below.
590
+ // 1. Set level (dimmer/blind …) — needs a numeric level-like control.
591
+ if (level !== null && !isQuery) {
592
+ const stateId = this.pickControl(device, ['level', 'brightness', 'dimmer'], true, 'number');
593
+ if (stateId) {
594
+ const intent = {
595
+ action: 'level',
596
+ device,
597
+ stateId,
598
+ value: level,
599
+ room: roomName,
600
+ confidence: 0.9,
601
+ };
602
+ // Many dimmers have a separate on/off switch that setting the level does not flip — so also
603
+ // turn it on (level > 0) / off (0%) if the device has one. Devices without a switch are
604
+ // unaffected. The switch is found by role/type, not by name (not every device names it ON_SET).
605
+ const sw = this.findSwitch(device, stateId);
606
+ if (sw) {
607
+ intent.also = { stateId: sw, value: level > 0 };
608
+ }
609
+ return intent;
610
+ }
611
+ }
612
+ // 2. Set color — only a hex color on a color-capable device (warm/cold color-temp is out of scope for v1).
613
+ if (color && color.startsWith('#') && COLOR_TYPES.has(device.type) && !isQuery) {
614
+ const stateId = this.pickControl(device, ['rgb', 'color', 'hue', 'cie', 'ct'], true);
615
+ if (stateId) {
616
+ return { action: 'color', device, stateId, value: color, room: roomName, confidence: 0.85 };
617
+ }
618
+ }
619
+ // 3. Turn on / off — prefer a writable boolean switch (never a metering state like CONSUMPTION).
620
+ if (action && !isQuery) {
621
+ const stateId = this.pickControl(device, ['power', 'switch', 'on', 'level'], true, 'boolean');
622
+ if (stateId) {
623
+ // If only a numeric (dimmer/level) control is available, on/off means full/zero level, not a
624
+ // boolean 1/0 (writing `true` to a 0–100 state would land on ~1%).
625
+ const numeric = this.valueTypeOf(device, stateId) === 'number';
626
+ return {
627
+ action,
628
+ device,
629
+ stateId,
630
+ value: numeric ? (action === 'on' ? 100 : 0) : action === 'on',
631
+ room: roomName,
632
+ confidence: 0.9,
633
+ };
634
+ }
635
+ }
636
+ // 4. Status query — read the right control: an on/off question ("is the light on?") reads the
637
+ // boolean switch/feedback; a value question ("what temperature?") reads the actual value.
638
+ if (isQuery) {
639
+ const onOff = ON_OFF_QUERY_WORDS.some(w => wordInText(w, joined));
640
+ const order = onOff ? ['power', 'switch', 'on', 'actual', 'level'] : ['actual', 'value', 'level', 'power'];
641
+ const stateId = this.pickControl(device, order, false, onOff ? 'boolean' : undefined);
642
+ if (stateId) {
643
+ return { action: 'query', device, stateId, room: roomName, confidence: 0.7 };
644
+ }
645
+ }
646
+ return null;
647
+ }
648
+ /**
649
+ * Timer (countdown) / alarm (fixed clock time) command (de/en/ru). Requires a timer or alarm keyword,
650
+ * then classifies in priority order:
651
+ * - a cancel word → `timerCancel` / `alarmCancel`,
652
+ * - a parseable clock time → `alarmSet` (with weekdays / label / room),
653
+ * - an explicit duration with a unit ("5 Minuten") → `timerSet` (so "weck mich in 5 Minuten" is a
654
+ * countdown, not a clock time),
655
+ * - a bare number: an hour in the alarm domain ("Wecker auf 8"), else minutes → `timerSet`,
656
+ * - otherwise → `timerQuery` / `alarmQuery` (list what is running).
657
+ */
658
+ parseSchedule(raw, norm, joined) {
659
+ const isTimer = TIMER_RE.test(norm);
660
+ const isAlarm = ALARM_RE.test(norm);
661
+ if (!isTimer && !isAlarm) {
662
+ return null;
663
+ }
664
+ // Prefer the alarm reading only when it is the sole keyword (so "erinnere mich um 7" → alarm, but
665
+ // "timer …" stays a timer even if it also mentions "wecker").
666
+ const alarmDomain = isAlarm && !isTimer;
667
+ const room = this.findRoom(joined) || undefined;
668
+ const label = () => this.findTimerLabel(norm) || undefined;
669
+ const alarm = (hour, minute, confidence) => ({
670
+ action: 'alarmSet',
671
+ hour,
672
+ minute,
673
+ weekdays: parseWeekdays(norm),
674
+ label: label(),
675
+ room,
676
+ confidence,
677
+ });
678
+ if (TIMER_CANCEL_RE.test(norm)) {
679
+ return { action: alarmDomain ? 'alarmCancel' : 'timerCancel', label: label(), room, confidence: 0.9 };
680
+ }
681
+ // Clock time first (parsed from the raw text — the normalized `norm` has ':' stripped, which would
682
+ // turn "6:30" into "6 30"). The "um H" rule already refuses a following duration unit, and the ru
683
+ // "H часов" o'clock form is resolved here before it could be mistaken for "H hours".
684
+ const clock = parseClockTime(raw);
685
+ if (clock) {
686
+ return alarm(clock.hour, clock.minute, 0.9);
687
+ }
688
+ // Explicit duration with a unit ("5 Minuten", "1 Stunde") → a countdown timer, even for "Wecker in …".
689
+ const explicit = parseDurationSeconds(norm, false);
690
+ if (explicit) {
691
+ return { action: 'timerSet', durationSec: explicit, label: label(), room, confidence: 0.9 };
692
+ }
693
+ // A bare number: an hour for an alarm ("Wecker auf 8"), otherwise minutes for a timer ("Timer 5").
694
+ const bare = parseDurationSeconds(norm, true);
695
+ if (bare) {
696
+ const hour = Math.round(bare / 60);
697
+ if (alarmDomain && hour >= 0 && hour <= 23) {
698
+ return alarm(hour, 0, 0.8);
699
+ }
700
+ return { action: 'timerSet', durationSec: bare, label: label(), room, confidence: 0.9 };
701
+ }
702
+ return { action: alarmDomain ? 'alarmQuery' : 'timerQuery', room, confidence: 0.7 };
703
+ }
704
+ /**
705
+ * Extract a timer label: the text after the last marker word ("an", "for", "to", "про", …), stripped
706
+ * of duration/number/filler tokens. "erinnere mich in 5 minuten an den ofen" → "ofen"; "timer für 5
707
+ * minuten" → "" (no label). Operates on the normalized text (lowercased) — fine for a spoken reply.
708
+ */
709
+ findTimerLabel(norm) {
710
+ const tokens = norm.split(/\s+/).filter(Boolean);
711
+ let start = -1;
712
+ for (let i = 0; i < tokens.length; i++) {
713
+ if (LABEL_MARKERS.has(tokens[i])) {
714
+ start = i + 1;
715
+ }
716
+ }
717
+ if (start < 0 || start >= tokens.length) {
718
+ return '';
719
+ }
720
+ const words = tokens.slice(start).filter(w => !LABEL_STOP_WORDS.has(w) && !/^\d+([.,]\d+)?$/.test(w));
721
+ return words.join(' ').trim();
722
+ }
723
+ /**
724
+ * "What time is it?" / "What's the date/day today?" (de/en/ru) → a device-independent time or date
725
+ * query, answered directly by the adapter from the host clock (no LLM). The date branch is checked
726
+ * first so "which day is it" is not swallowed by the time branch. Matching is stem-tolerant via
727
+ * {@link wordInText}; "сейчас"/"today"/"monday" don't false-match "час"/"day" (word-boundary guarded).
728
+ */
729
+ parseTimeQuery(text) {
730
+ const has = (w) => wordInText(w, text);
731
+ const qualifier = () => has('heute') ||
732
+ has('welch') ||
733
+ has('what') ||
734
+ has('which') ||
735
+ has('today') ||
736
+ has('сегодня') ||
737
+ has('какой') ||
738
+ has('какое');
739
+ // Date / weekday question.
740
+ const isDate = has('datum') ||
741
+ has('wochentag') ||
742
+ has('date') ||
743
+ has('число') ||
744
+ has('дата') ||
745
+ ((has('tag') || has('day') || has('день')) && qualifier());
746
+ if (isDate) {
747
+ return { action: 'dateQuery', confidence: 0.9 };
748
+ }
749
+ // Time-of-day question. "wie viel Uhr" needs both words so a bare "Uhr" (rare here) doesn't trigger.
750
+ const isTime = has('spaet') ||
751
+ has('uhrzeit') ||
752
+ has('time') ||
753
+ has('clock') ||
754
+ has('час') ||
755
+ has('время') ||
756
+ (has('uhr') && has('viel'));
757
+ if (isTime) {
758
+ return { action: 'timeQuery', confidence: 0.9 };
759
+ }
760
+ return null;
761
+ }
762
+ /**
763
+ * "Which windows are open?" (de/en/ru) → an aggregate query over all window devices. Matches when the
764
+ * text mentions a window word AND an "open" word; optionally restricted to a matched room.
765
+ */
766
+ parseWindowsOpen(text) {
767
+ if (!WINDOW_WORDS.some(w => wordInText(w, text)) || !OPEN_WORDS.some(w => wordInText(w, text))) {
768
+ return null;
769
+ }
770
+ const roomName = this.findRoom(text);
771
+ const windows = this.devices.filter(d => WINDOW_TYPES.has(d.type) && (!roomName || d.room === roomName));
772
+ if (!windows.length) {
773
+ return null; // no window devices known → let the LLM handle it
774
+ }
775
+ return {
776
+ action: 'listByState',
777
+ category: 'window',
778
+ stateFilter: 'open',
779
+ devices: windows,
780
+ room: roomName || undefined,
781
+ confidence: 0.9,
782
+ };
783
+ }
784
+ /** The room whose significant words all appear in the text (stem-tolerant); longest name wins. */
785
+ findRoom(text) {
786
+ let best = '';
787
+ let bestLen = 0;
788
+ for (const name of this.rooms) {
789
+ const n = normalize(name);
790
+ const words = n.split(/\s+/).filter(w => w.length >= 3);
791
+ if (!words.length) {
792
+ continue;
793
+ }
794
+ if (words.every(w => wordInText(w, text)) && n.length > bestLen) {
795
+ best = name;
796
+ bestLen = n.length;
797
+ }
798
+ }
799
+ return best;
800
+ }
801
+ /**
802
+ * Device whose significant name words all appear in the text (stem-tolerant for inflection).
803
+ * Prefers devices in the matched room; the longest matching name wins.
804
+ */
805
+ findDevice(text, roomName) {
806
+ const normRoom = roomName ? normalize(roomName) : '';
807
+ const spaces = [];
808
+ if (roomName) {
809
+ spaces.push(this.devices.filter(d => d.room === roomName));
810
+ }
811
+ spaces.push(this.devices.filter(d => !roomName || d.room !== roomName));
812
+ for (const space of spaces) {
813
+ let best = null;
814
+ let bestLen = 0;
815
+ for (const dev of space) {
816
+ const nk = normalize(dev.name);
817
+ if (!nk || (normRoom && normRoom.includes(nk))) {
818
+ continue; // skip a device whose name is part of the room name
819
+ }
820
+ const words = nk.split(/\s+/).filter(w => w.length >= 3);
821
+ if (!words.length) {
822
+ continue;
823
+ }
824
+ if (words.every(w => wordInText(w, text)) && nk.length > bestLen) {
825
+ best = dev;
826
+ bestLen = nk.length;
827
+ }
828
+ }
829
+ if (best) {
830
+ return best;
831
+ }
832
+ }
833
+ return null;
834
+ }
835
+ findAction(tokens) {
836
+ for (const t of tokens) {
837
+ if (TURN_OFF.has(t)) {
838
+ return 'off';
839
+ }
840
+ if (TURN_ON.has(t)) {
841
+ return 'on';
842
+ }
843
+ }
844
+ return null;
845
+ }
846
+ findLevel(text) {
847
+ const m = text.match(/(\d+(?:[.,]\d+)?)\s*(?:prozent|процент\w*|%)/u);
848
+ return m ? parseFloat(m[1].replace(',', '.')) : null;
849
+ }
850
+ findColor(text) {
851
+ for (const [name, hex] of Object.entries(COLORS)) {
852
+ if (wordInText(name, text)) {
853
+ return hex;
854
+ }
855
+ }
856
+ return null;
857
+ }
858
+ /**
859
+ * Pick a control state id. Tries the ordered controlType keys (preferring `preferType`, then any type),
860
+ * then falls back to any usable **non-metering** control — so a query/command never lands on a
861
+ * CONSUMPTION/CURRENT/… measurement. `mustWrite` additionally requires the control to be writable.
862
+ */
863
+ pickControl(device, order, mustWrite, preferType) {
864
+ const types = device.types || {};
865
+ const usable = (key) => !!device.controls[key] && (!mustWrite || !!device.writable[key]);
866
+ if (preferType) {
867
+ // 1. an ordered key of the preferred value type (e.g. a boolean switch for on/off).
868
+ for (const key of order) {
869
+ if (usable(key) && types[key] === preferType) {
870
+ return device.controls[key];
871
+ }
872
+ }
873
+ // 2. ANY control of the preferred type (non-metering). Catches a boolean switch stored under a
874
+ // non-standard control key (e.g. `ON_SET`), so on/off never lands on a numeric level/dimmer
875
+ // control when a real boolean switch exists on the device.
876
+ for (const [key, id] of Object.entries(device.controls)) {
877
+ if (usable(key) && types[key] === preferType && !METER_SUFFIX.test(id)) {
878
+ return id;
879
+ }
880
+ }
881
+ }
882
+ // 3. an ordered key of any type.
883
+ for (const key of order) {
884
+ if (usable(key)) {
885
+ return device.controls[key];
886
+ }
887
+ }
888
+ // 4. fallback: any usable non-metering control.
889
+ for (const [key, id] of Object.entries(device.controls)) {
890
+ if (usable(key) && !METER_SUFFIX.test(id)) {
891
+ return id;
892
+ }
893
+ }
894
+ return '';
895
+ }
896
+ /** The ioBroker value type ('boolean'|'number'|…) of a device control by its state id, '' if unknown. */
897
+ valueTypeOf(device, stateId) {
898
+ for (const [key, id] of Object.entries(device.controls)) {
899
+ if (id === stateId) {
900
+ return device.types[key] || '';
901
+ }
902
+ }
903
+ return '';
904
+ }
905
+ /**
906
+ * The device's on/off switch control (state id) other than `excludeStateId`, so a level command can also
907
+ * power the device on. Role-based: a writable control with a `switch…` role wins; otherwise any writable
908
+ * boolean, non-metering control; '' if the device has no separate switch.
909
+ */
910
+ findSwitch(device, excludeStateId) {
911
+ const roles = device.roles || {};
912
+ let fallback = '';
913
+ for (const [key, id] of Object.entries(device.controls)) {
914
+ if (id === excludeStateId || !device.writable[key] || METER_SUFFIX.test(id)) {
915
+ continue;
916
+ }
917
+ const role = roles[key] || '';
918
+ if (/^switch(\.|$)/i.test(role)) {
919
+ return id; // a role-confirmed switch is the best match
920
+ }
921
+ if (!fallback && device.types[key] === 'boolean') {
922
+ fallback = id; // any writable boolean control as a fallback
923
+ }
924
+ }
925
+ return fallback;
926
+ }
927
+ }
928
+ exports.Nlu = Nlu;
929
+ //# sourceMappingURL=nlu.js.map