iobroker.assistant 0.1.6 → 1.0.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 (108) hide show
  1. package/README.md +61 -4
  2. package/admin/custom/.vite/manifest.json +122 -77
  3. package/admin/custom/assets/Components-7BDDNrGP.js +1 -0
  4. package/admin/custom/assets/_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-DcAGnE2z.js +1 -0
  5. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-CzBMxGFr.js +1 -0
  6. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-1XsLCK2M.js +1 -0
  7. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-CMv8bcyA.js +1217 -0
  8. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare__shared-3mzaJWDF.js +2 -0
  9. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__prebuild___mf_0_iobroker_mf_1_json_mf_2_config__prebuild__-DS8oaQQt.js +122 -0
  10. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__prebuild__react_mf_1_jsx_mf_2_runtime__prebuild__-B_v57Qfr.js +1 -0
  11. package/admin/custom/assets/dist-DiU1y0R9.js +1 -0
  12. package/admin/custom/assets/helpers-Dot7vgQQ.js +1 -0
  13. package/admin/custom/assets/hostInit-MshDicd1.js +1 -0
  14. package/admin/custom/assets/iconLibraryData-B5fiyl1C.js +1 -0
  15. package/admin/custom/assets/index-P2Xu9kJm.js +1 -0
  16. package/admin/custom/assets/lib-DjwHKtRi.js +3 -0
  17. package/admin/custom/assets/{mf-entry-bootstrap-0-87f15505.js → mf-entry-bootstrap-0-7c1ddf47.js} +14 -2
  18. package/admin/custom/assets/pendingShares-CaJDCKLB.js +2 -0
  19. package/admin/custom/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
  20. package/admin/custom/assets/utils-towTPKqG.js +4 -0
  21. package/admin/custom/assets/vite-preload-helper-BbbETgeG.js +1 -0
  22. package/admin/custom/customComponents.js +2 -2
  23. package/admin/custom/i18n/de.json +64 -57
  24. package/admin/custom/i18n/en.json +8 -1
  25. package/admin/custom/i18n/es.json +64 -57
  26. package/admin/custom/i18n/fr.json +64 -57
  27. package/admin/custom/i18n/it.json +64 -57
  28. package/admin/custom/i18n/nl.json +64 -57
  29. package/admin/custom/i18n/pl.json +64 -57
  30. package/admin/custom/i18n/pt.json +64 -57
  31. package/admin/custom/i18n/ru.json +64 -57
  32. package/admin/custom/i18n/uk.json +64 -57
  33. package/admin/custom/i18n/zh-cn.json +64 -57
  34. package/admin/custom/index.html +2 -2
  35. package/admin/custom/mf-manifest.json +1 -1
  36. package/admin/custom/mf-stats.json +1 -1
  37. package/admin/i18n/de.json +59 -3
  38. package/admin/i18n/en.json +59 -3
  39. package/admin/i18n/es.json +59 -3
  40. package/admin/i18n/fr.json +59 -3
  41. package/admin/i18n/it.json +59 -3
  42. package/admin/i18n/nl.json +59 -3
  43. package/admin/i18n/pl.json +59 -3
  44. package/admin/i18n/pt.json +59 -3
  45. package/admin/i18n/ru.json +59 -3
  46. package/admin/i18n/uk.json +59 -3
  47. package/admin/i18n/zh-cn.json +59 -3
  48. package/admin/jsonConfig.json +589 -75
  49. package/build/lib/ask.js +130 -0
  50. package/build/lib/ask.js.map +1 -0
  51. package/build/lib/llm.js +135 -13
  52. package/build/lib/llm.js.map +1 -1
  53. package/build/lib/nlu.js +74 -1
  54. package/build/lib/nlu.js.map +1 -1
  55. package/build/lib/notifications.js +112 -0
  56. package/build/lib/notifications.js.map +1 -0
  57. package/build/lib/presence.js +188 -0
  58. package/build/lib/presence.js.map +1 -0
  59. package/build/lib/routines.js +115 -0
  60. package/build/lib/routines.js.map +1 -0
  61. package/build/lib/targets.js +96 -0
  62. package/build/lib/targets.js.map +1 -0
  63. package/build/lib/triggers.js +632 -0
  64. package/build/lib/triggers.js.map +1 -0
  65. package/build/lib/voice/aws.js +9 -1
  66. package/build/lib/voice/aws.js.map +1 -1
  67. package/build/lib/voice/azure.js +9 -2
  68. package/build/lib/voice/azure.js.map +1 -1
  69. package/build/lib/voice/engines.js +82 -0
  70. package/build/lib/voice/engines.js.map +1 -1
  71. package/build/lib/voice/esphome.js +732 -0
  72. package/build/lib/voice/esphome.js.map +1 -0
  73. package/build/lib/voice/esphomeEntities.js +303 -0
  74. package/build/lib/voice/esphomeEntities.js.map +1 -0
  75. package/build/lib/voice/esphomeProto.js +181 -0
  76. package/build/lib/voice/esphomeProto.js.map +1 -0
  77. package/build/lib/voice/fallback.js +85 -0
  78. package/build/lib/voice/fallback.js.map +1 -0
  79. package/build/lib/voice/mediaServer.js +151 -0
  80. package/build/lib/voice/mediaServer.js.map +1 -0
  81. package/build/lib/voice/protocol.js.map +1 -1
  82. package/build/lib/voice/tone.js +32 -0
  83. package/build/lib/voice/tone.js.map +1 -0
  84. package/build/lib/voice/tts.js.map +1 -1
  85. package/build/lib/voice/ttsCache.js +274 -0
  86. package/build/lib/voice/ttsCache.js.map +1 -0
  87. package/build/lib/voice/vad.js +173 -0
  88. package/build/lib/voice/vad.js.map +1 -0
  89. package/build/lib/voice/voiceServer.js +13 -0
  90. package/build/lib/voice/voiceServer.js.map +1 -1
  91. package/build/main.js +1721 -67
  92. package/build/main.js.map +1 -1
  93. package/io-package.json +241 -3
  94. package/package.json +13 -12
  95. package/admin/custom/assets/Components-xWdtqvTH.js +0 -1
  96. package/admin/custom/assets/_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-dybYZgsu.js +0 -1
  97. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-BnSUsPHa.js +0 -1
  98. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-DlSVT49A.js +0 -1
  99. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-Cfsgderv.js +0 -1243
  100. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_iobroker_mf_1_json_mf_2_config__loadShare__.js-ByJf4zv_.js +0 -1494
  101. package/admin/custom/assets/hostInit-CZawMH9B.js +0 -1
  102. package/admin/custom/assets/index-JB7g1jgx.js +0 -1
  103. package/admin/custom/assets/lib-bB-LHRhZ.js +0 -3
  104. package/admin/custom/assets/rolldown-runtime-C0FnF6B9.js +0 -1
  105. package/admin/custom/assets/virtualExposes-brKKZvi0.js +0 -1
  106. package/admin/custom/assets/virtual_mf-REMOTE_ENTRY_ID___mfe_internal__ConfigCustomAssistant__customComponents_js-CRhwj12f.js +0 -1
  107. package/admin/custom/assets/virtual_mf-exposes___mfe_internal__ConfigCustomAssistant__customComponents_js-C5XHUc1J.js +0 -2
  108. package/admin/custom/assets/vite-preload-helper-B7qeedMF.js +0 -1
package/build/main.js CHANGED
@@ -49,14 +49,117 @@ const weather_1 = require("./lib/weather");
49
49
  const localLlm_1 = require("./lib/localLlm");
50
50
  const credentials_1 = require("./lib/credentials");
51
51
  const context_1 = require("./lib/context");
52
+ const ask_1 = require("./lib/ask");
53
+ const presence_1 = require("./lib/presence");
54
+ const targets_1 = require("./lib/targets");
55
+ const routines_1 = require("./lib/routines");
56
+ const notifications_1 = require("./lib/notifications");
57
+ const triggers_1 = require("./lib/triggers");
52
58
  const voiceServer_1 = require("./lib/voice/voiceServer");
53
59
  const wyoming_1 = require("./lib/voice/wyoming");
60
+ const esphome_1 = require("./lib/voice/esphome");
61
+ const esphomeEntities_1 = require("./lib/voice/esphomeEntities");
62
+ const mediaServer_1 = require("./lib/voice/mediaServer");
63
+ const tone_1 = require("./lib/voice/tone");
54
64
  const engines_1 = require("./lib/voice/engines");
55
65
  /**
56
66
  * How long a rendered weather context line stays valid. Weather adapters refresh every few minutes at best,
57
67
  * so re-reading their whole state tree on every single request would be pure overhead.
58
68
  */
59
69
  const WEATHER_CTX_TTL = 5 * 60 * 1000;
70
+ /**
71
+ * The replies the assistant speaks verbatim, whatever the question was. They are pre-synthesised into
72
+ * the TTS cache at startup; everything else is cached on first use.
73
+ */
74
+ /** How long to wait for a device to confirm a write, and how often to look. */
75
+ const ACK_TIMEOUT_MS = 2000;
76
+ const ACK_POLL_MS = 150;
77
+ /**
78
+ * Did the device end up with the value we asked for? Compared loosely: a dimmer told `30` may report
79
+ * `30.0`, a switch told `true` may report `1`, and a lamp told 100 % may settle at 99 % — insisting on
80
+ * strict equality would report a failure for a command that plainly worked.
81
+ */
82
+ function valuesMatch(actual, expected) {
83
+ if (actual === expected) {
84
+ return true;
85
+ }
86
+ if (typeof expected === 'boolean' || typeof actual === 'boolean') {
87
+ return Boolean(actual) === Boolean(expected);
88
+ }
89
+ const a = Number(actual);
90
+ const b = Number(expected);
91
+ if (Number.isFinite(a) && Number.isFinite(b)) {
92
+ return Math.abs(a - b) <= Math.max(1, Math.abs(b) * 0.02);
93
+ }
94
+ return String(actual).trim().toLowerCase() === String(expected).trim().toLowerCase();
95
+ }
96
+ /**
97
+ * ioBroker role for a measurement, derived from its unit — a `value.temperature` shows up with the right
98
+ * icon and in the right place in vis, where a bare `value` is just a number.
99
+ */
100
+ function sensorRole(entity) {
101
+ const unit = (entity.unit || '').trim().toLowerCase();
102
+ if (unit === '°c' || unit === '°f' || unit === 'k') {
103
+ return 'value.temperature';
104
+ }
105
+ if (unit === '%') {
106
+ return 'value.humidity'; // the only percentage a voice box reports is its humidity
107
+ }
108
+ if (unit === 'lx' || unit === 'lux') {
109
+ return 'value.brightness';
110
+ }
111
+ if (unit === 'hpa' || unit === 'mbar' || unit === 'pa') {
112
+ return 'value.pressure';
113
+ }
114
+ if (unit === 'ppm' || unit === 'ppb') {
115
+ return 'value.co2';
116
+ }
117
+ if (unit === 'db' || unit === 'dba') {
118
+ return 'value.volume';
119
+ }
120
+ return 'value';
121
+ }
122
+ /** Heading for a category answer, so "18 °C, 21 °C" says what it is about. */
123
+ const CATEGORY_LABELS = {
124
+ temperature: { de: 'Temperatur', en: 'Temperature', ru: 'Температура' },
125
+ humidity: { de: 'Luftfeuchtigkeit', en: 'Humidity', ru: 'Влажность' },
126
+ illuminance: { de: 'Helligkeit', en: 'Brightness', ru: 'Освещённость' },
127
+ pressure: { de: 'Luftdruck', en: 'Pressure', ru: 'Давление' },
128
+ airQuality: { de: 'Luftqualität', en: 'Air quality', ru: 'Качество воздуха' },
129
+ };
130
+ /**
131
+ * Put a word to an air-quality number, because the number alone means nothing when spoken. Only for
132
+ * the IAQ scale (0–500, unitless or labelled IAQ) — a CO₂ value in ppm is a different scale and is
133
+ * better left unlabelled than labelled wrongly. Thresholds from the BME680/BSEC IAQ classification,
134
+ * as used by the Python original.
135
+ */
136
+ function iaqLabel(value, unit, lang) {
137
+ const iaq = Number(value);
138
+ const u = unit.trim().toLowerCase();
139
+ if (!Number.isFinite(iaq) || (u !== '' && u !== 'iaq')) {
140
+ return '';
141
+ }
142
+ const scale = [
143
+ [50, 'sehr gut', 'excellent', 'отлично'],
144
+ [100, 'gut', 'good', 'хорошо'],
145
+ [150, 'mäßig', 'moderate', 'умеренно'],
146
+ [200, 'schlecht', 'poor', 'плохо'],
147
+ [300, 'sehr schlecht', 'very poor', 'очень плохо'],
148
+ [Number.POSITIVE_INFINITY, 'extrem schlecht', 'extremely poor', 'крайне плохо'],
149
+ ];
150
+ const row = scale.find(([limit]) => iaq <= limit);
151
+ if (!row) {
152
+ return '';
153
+ }
154
+ return lang === 'ru' ? row[3] : lang === 'de' ? row[1] : row[2];
155
+ }
156
+ /** NLU actions that only switch something; their success is what the confirmation tone replaces. */
157
+ const CONTROL_ACTIONS = new Set(['on', 'off', 'level', 'color']);
158
+ const FIXED_REPLIES = {
159
+ de: ['Ok.', 'Erledigt.'],
160
+ en: ['Okay.', 'Done.'],
161
+ ru: ['Хорошо.', 'Готово.'],
162
+ };
60
163
  /** A tts value is treated as an audio file (not text) when it looks like an mp3/wav/… URL or path. */
61
164
  function isAudioRef(v) {
62
165
  return /\.(mp3|wav|ogg|flac|m4a|aac|opus)(\?.*)?$/i.test(v.trim());
@@ -79,7 +182,10 @@ function languageLabel(lang) {
79
182
  return map[lang || 'en'] || 'English';
80
183
  }
81
184
  class Assistant extends adapter_core_1.Adapter {
185
+ /** Cloud LLM; `null` with provider "none" (local tiers only) or before `onReady` finished. */
82
186
  agent = null;
187
+ /** Set once `onReady` initialised the pipeline — with or without a cloud {@link agent}. */
188
+ ready = false;
83
189
  mcp = null;
84
190
  /** Short-TTL cache for the expensive device/room/function listings; invalidated on enum changes. */
85
191
  listCache = null;
@@ -95,12 +201,24 @@ class Assistant extends adapter_core_1.Adapter {
95
201
  voice = null;
96
202
  /** Wyoming TCP endpoint (only when wyomingEnabled); null otherwise. */
97
203
  wyoming = null;
204
+ /** ESPHome voice satellites we dial out to (only when esphomeEnabled); null otherwise. */
205
+ esphome = null;
206
+ /** Serves spoken replies to ESPHome satellites over HTTP; null unless those are enabled. */
207
+ media = null;
98
208
  /** Short-term per-source conversation memory (in-memory, TTL) for follow-up questions. */
99
209
  context = new context_1.ConversationStore();
210
+ /** Questions the assistant asked and is waiting for an answer to (`askUser`), keyed by source. */
211
+ pending = new ask_1.PendingQuestions();
100
212
  /** Satellite ids whose state objects have already been created (avoid re-creating on every update). */
101
213
  satStatesEnsured = new Set();
102
214
  /** Sanitised satellite state-id → real device name (for the per-satellite `tts` announce state). */
103
215
  satDeviceById = new Map();
216
+ /** Satellite ids that already have the wake-word states — only ESPHome devices report any. */
217
+ satWakeWordStatesEnsured = new Set();
218
+ /** Timers already mirrored onto the ESPHome satellites, so we can tell a cancel from an update. */
219
+ mirroredTimers = new Map();
220
+ /** Per satellite: resolves once its control objects exist, so state writes can wait for them. */
221
+ satControlsReady = new Map();
104
222
  /** Native (ioBroker) satellite state-id → sender instance id, so we can push announcements back to it. */
105
223
  nativeSatFrom = new Map();
106
224
  /** Countdown timers / reminders (roadmap #2); mirrored into `timers.*` states. Null until onReady. */
@@ -111,10 +229,28 @@ class Assistant extends adapter_core_1.Adapter {
111
229
  alarms = null;
112
230
  /** Per-alarm `alarms.items.<id>` channels currently rendered. */
113
231
  alarmObjIds = new Set();
232
+ /** Proactive triggers (roadmap A2); mirrored into `triggers.*` states. Null until onReady. */
233
+ triggers = null;
234
+ /** Per-trigger `triggers.items.<id>` channels currently rendered. */
235
+ triggerObjIds = new Set();
236
+ /** Foreign states the loaded triggers watch (exactly what we subscribed to). */
237
+ triggerStateIds = new Set();
238
+ /** Master switch (`triggers.enabled`): false suppresses every trigger's effect. */
239
+ triggersEnabled = true;
114
240
  /** Long-term memory (roadmap #6); mirrored into `memory.*` states. Null until onReady / when disabled. */
115
241
  memory = null;
116
242
  /** Per-memory `memory.items.<id>` channels currently rendered. */
117
243
  memoryObjIds = new Set();
244
+ /** Phrase-triggered macros from the configuration ("Gute Nacht" → five actions). */
245
+ routines = [];
246
+ /** Named announcement targets (roadmap B2): groups of rooms and people, from the configuration. */
247
+ announceTargets = [];
248
+ /** Who is at home (roadmap B1); mirrored into `presence.*`. Null until onReady. */
249
+ presence = null;
250
+ /** Satellites currently set to Do-Not-Disturb, by state id (mirrors `satellites.<id>.dnd`). */
251
+ dndById = new Map();
252
+ /** Global Do-Not-Disturb (`dnd`): suppresses every announcement except priority/alert ones. */
253
+ globalDnd = false;
118
254
  /** Cached weather context line (see `buildWeatherContext`); `key` = source + language. */
119
255
  weatherCtx = null;
120
256
  /** Active "ringing" sessions (a timer/alarm looping its sound until stopped or timed out). */
@@ -129,8 +265,12 @@ class Assistant extends adapter_core_1.Adapter {
129
265
  }
130
266
  async onReady() {
131
267
  const cfg = this.config;
132
- const apiKey = await (0, credentials_1.resolveApiKey)(this, cfg);
133
- if (!apiKey) {
268
+ // Provider "none": no cloud LLM at all — only the local tiers (routines, rule-based NLU, timers,
269
+ // alarms, the local model) answer. The ioBroker tools are still built: the NLU switches through them.
270
+ const provider = cfg.provider || 'openai';
271
+ const localOnly = provider === 'none';
272
+ const apiKey = localOnly ? '' : (0, llm_1.effectiveApiKey)(cfg.provider, await (0, credentials_1.resolveApiKey)(this, cfg));
273
+ if (!localOnly && !apiKey) {
134
274
  this.log.warn(cfg.credentialType === 'manager'
135
275
  ? 'No credential selected (manager mode) — pick an API-key credential in the adapter settings.'
136
276
  : 'No API key configured — open the adapter settings and enter your LLM API key.');
@@ -154,6 +294,10 @@ class Assistant extends adapter_core_1.Adapter {
154
294
  if ((cfg.weatherInstance || '').trim()) {
155
295
  tools.push(this.buildWeatherTool());
156
296
  }
297
+ if (cfg.voiceEnabled) {
298
+ // Only useful with satellites: without voice there is nothing to speak on.
299
+ tools.push(this.buildAnnounceTool());
300
+ }
157
301
  this.log.info(`ioBroker tools enabled (${tools.length}): ${tools.map(t => t.name).join(', ')}`);
158
302
  if (denied.length) {
159
303
  this.log.debug(`Tools denied by access settings: ${denied.join(', ')}`);
@@ -161,17 +305,19 @@ class Assistant extends adapter_core_1.Adapter {
161
305
  // Bust the device/room cache when room/function memberships change (new device shows within TTL anyway).
162
306
  this.subscribeForeignObjects('enum.rooms.*');
163
307
  this.subscribeForeignObjects('enum.functions.*');
164
- const prov = (0, llm_1.resolveProvider)(cfg.provider, cfg.baseUrl);
165
- this.agent = new llm_1.LlmAgent({
166
- provider: cfg.provider || 'openai',
167
- apiKey,
168
- model: cfg.model || prov.defaultModel,
169
- baseUrl: prov.baseUrl,
170
- systemPrompt: cfg.systemPrompt || '',
171
- maxTokens: cfg.maxTokens || 1024,
172
- tools,
173
- log: this.log,
174
- });
308
+ if (provider !== 'none') {
309
+ const prov = (0, llm_1.resolveProvider)(provider, cfg.baseUrl);
310
+ this.agent = new llm_1.LlmAgent({
311
+ provider,
312
+ apiKey,
313
+ model: cfg.model || prov.defaultModel,
314
+ baseUrl: prov.baseUrl,
315
+ systemPrompt: cfg.systemPrompt || '',
316
+ maxTokens: cfg.maxTokens || 1024,
317
+ tools,
318
+ log: this.log,
319
+ });
320
+ }
175
321
  }
176
322
  catch (e) {
177
323
  this.log.error(`Could not initialise assistant: ${e.message}`);
@@ -179,6 +325,7 @@ class Assistant extends adapter_core_1.Adapter {
179
325
  return;
180
326
  }
181
327
  await this.setStateAsync('info.connection', { val: true, ack: true });
328
+ this.ready = true;
182
329
  this.subscribeStates('text.request');
183
330
  // Meta object backing this instance's file storage (so the admin can upload sound assets and the
184
331
  // adapter can read them back for timer/alarm jingles).
@@ -189,12 +336,32 @@ class Assistant extends adapter_core_1.Adapter {
189
336
  });
190
337
  await this.ensureBuiltinSounds();
191
338
  this.subscribeStates('stopRinging');
339
+ // Spoken system notifications + Do-Not-Disturb (per satellite: see ensureSatelliteObjects).
340
+ this.subscribeStates('notify.text');
341
+ this.subscribeStates('notify.alert');
342
+ this.subscribeStates('dnd');
343
+ this.globalDnd = (await this.getStateAsync('dnd'))?.val === true;
344
+ if (this.globalDnd) {
345
+ this.log.info('Do-Not-Disturb is on — only alerts will be announced.');
346
+ }
192
347
  await this.setupTimers();
193
348
  await this.setupAlarms();
194
349
  if (cfg.useLongTermMemory !== false) {
195
350
  await this.setupMemory();
196
351
  }
197
- this.log.info(`Assistant ready (provider=${cfg.provider}, model=${this.agent.model}).`);
352
+ this.routines = (0, routines_1.parseRoutineRows)(cfg.routines, { warn: message => this.log.warn(message) });
353
+ if (this.routines.length) {
354
+ this.log.info(`Routines: ${this.routines.map(r => r.name).join(', ')}.`);
355
+ }
356
+ this.announceTargets = (0, targets_1.parseTargetRows)(cfg.announceTargets);
357
+ if (this.announceTargets.length) {
358
+ this.log.info(`Announcement targets: ${this.announceTargets.map(t => `${t.name} (${t.kind})`).join(', ')}.`);
359
+ }
360
+ await this.setupPresence();
361
+ await this.setupTriggers();
362
+ this.log.info(this.agent
363
+ ? `Assistant ready (provider=${cfg.provider}, model=${this.agent.model || 'auto'}).`
364
+ : 'Assistant ready without an LLM provider — answering locally only.');
198
365
  if (cfg.useLocalLlm) {
199
366
  void this.ensureLocalLlm(); // background: model load/download must not block onReady
200
367
  }
@@ -209,6 +376,9 @@ class Assistant extends adapter_core_1.Adapter {
209
376
  if (cfg.wyomingEnabled) {
210
377
  await this.startWyoming(apiKey);
211
378
  }
379
+ if (cfg.esphomeEnabled) {
380
+ await this.startEsphome(apiKey);
381
+ }
212
382
  }
213
383
  /** Start the Wyoming TCP endpoint, bridging to the same STT/answer/TTS pipeline as the UDP server. */
214
384
  async startWyoming(mainApiKey) {
@@ -238,6 +408,7 @@ class Assistant extends adapter_core_1.Adapter {
238
408
  log: this.log,
239
409
  });
240
410
  await this.wyoming.start();
411
+ this.declarePort('tcpPort', cfg.wyomingPort || 10700, cfg.bind);
241
412
  if (!this.voice) {
242
413
  this.warmupEngines(stt, tts, language); // only if the UDP server didn't already warm up
243
414
  }
@@ -247,6 +418,77 @@ class Assistant extends adapter_core_1.Adapter {
247
418
  this.wyoming = null;
248
419
  }
249
420
  }
421
+ /**
422
+ * Connect to the configured ESPHome voice satellites (ThirdReality V&M Assistant, HA Voice PE,
423
+ * linux-voice-assistant). The adapter is the client here — the devices listen on TCP 6053 — and the
424
+ * spoken reply is handed over as a URL, so the little media server comes up together with them.
425
+ */
426
+ async startEsphome(mainApiKey) {
427
+ const cfg = this.config;
428
+ const devices = (cfg.esphomeDevices || []).filter(d => d?.ip?.trim());
429
+ if (!devices.length) {
430
+ this.log.warn('ESPHome satellites are enabled, but no device is configured.');
431
+ return;
432
+ }
433
+ const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, mainApiKey);
434
+ const ctx = this.voiceContext(cfg, creds);
435
+ const language = cfg.voiceLanguage || this.language || '';
436
+ let stt;
437
+ let tts;
438
+ try {
439
+ stt = (0, engines_1.createSttEngine)(cfg.sttProvider || 'openai', ctx);
440
+ tts = (0, engines_1.createTtsEngine)(cfg.ttsProvider || 'openai', ctx);
441
+ }
442
+ catch (e) {
443
+ this.log.warn(`ESPHome satellites not started — ${e.message}. Check the Voice tab settings.`);
444
+ return;
445
+ }
446
+ try {
447
+ const media = new mediaServer_1.MediaServer({
448
+ port: cfg.esphomeMediaPort || 8099,
449
+ bindAddress: cfg.bind || '0.0.0.0',
450
+ log: this.log,
451
+ });
452
+ await media.start();
453
+ this.media = media;
454
+ this.declarePort('tcpPort', media.port, cfg.bind);
455
+ }
456
+ catch (e) {
457
+ this.log.error(`ESPHome satellites not started — media server failed: ${e.message}`);
458
+ this.media = null;
459
+ return;
460
+ }
461
+ this.esphome = new esphome_1.EsphomeSatellites({
462
+ devices,
463
+ language,
464
+ stt,
465
+ tts,
466
+ answer: (question, origin) => this.answerVoice(question, origin.device),
467
+ getHints: () => this.buildSttHints(),
468
+ media: this.media,
469
+ mediaHost: (cfg.esphomeMediaHost || '').trim(),
470
+ silenceMs: cfg.esphomeSilenceMs || 0,
471
+ log: this.log,
472
+ onStatus: (device, room, state) => {
473
+ this.updateSatelliteState(device, room, state).catch(e => this.log.debug(`satellite state update failed: ${e.message}`));
474
+ },
475
+ onWakeWords: (device, room, config) => {
476
+ this.updateSatelliteWakeWords(device, room, config).catch(e => this.log.debug(`satellite wake word update failed: ${e.message}`));
477
+ },
478
+ onEntities: (device, room, entities) => {
479
+ this.createSatelliteControls(device, room, entities).catch(e => this.log.debug(`satellite control objects failed: ${e.message}`));
480
+ },
481
+ onEntityState: (device, room, entity) => {
482
+ this.updateSatelliteControl(device, room, entity).catch(e => this.log.debug(`satellite control update failed: ${e.message}`));
483
+ },
484
+ expectsFollowUp: answer => this.expectsFollowUp(answer),
485
+ });
486
+ await this.esphome.start();
487
+ this.log.info(`ESPHome satellites: ${devices.length} device(s), STT=${cfg.sttProvider || 'openai'}, TTS=${cfg.ttsProvider || 'openai'}.`);
488
+ if (!this.voice && !this.wyoming) {
489
+ this.warmupEngines(stt, tts, language); // nobody warmed the engines up yet
490
+ }
491
+ }
250
492
  /** Build the engine context (creds + local-model settings + data dir) for the STT/TTS factory. */
251
493
  voiceContext(cfg, creds) {
252
494
  return {
@@ -257,6 +499,8 @@ class Assistant extends adapter_core_1.Adapter {
257
499
  aws: (cfg.awsVoice || '').trim(),
258
500
  piper: (cfg.piperVoice || '').trim(),
259
501
  },
502
+ sttFallback: cfg.sttFallback || '',
503
+ ttsFallback: cfg.ttsFallback || '',
260
504
  dataDir: this.instanceDataDir(),
261
505
  log: this.log,
262
506
  voskModel: (cfg.voskModel || '').trim(),
@@ -300,6 +544,7 @@ class Assistant extends adapter_core_1.Adapter {
300
544
  },
301
545
  });
302
546
  await this.voice.start();
547
+ this.declarePort('udpPort', cfg.port || 7775, cfg.bind, 4); // dgram 'udp4' socket
303
548
  this.log.info(`Voice server: STT=${cfg.sttProvider || 'openai'}, TTS=${cfg.ttsProvider || 'openai'}.`);
304
549
  // Warm up local engines now (install/download) instead of on the first spoken command.
305
550
  this.warmupEngines(stt, tts, language);
@@ -318,8 +563,22 @@ class Assistant extends adapter_core_1.Adapter {
318
563
  }
319
564
  if (tts.prepare) {
320
565
  this.log.info('Preparing text-to-speech engine (download in background) …');
321
- tts.prepare(lang).catch(e => this.log.warn(`TTS warm-up failed: ${e.message}`));
566
+ tts.prepare(lang)
567
+ .then(() => this.warmTtsCache(tts, lang))
568
+ .catch(e => this.log.warn(`TTS warm-up failed: ${e.message}`));
322
569
  }
570
+ else {
571
+ void this.warmTtsCache(tts, lang);
572
+ }
573
+ }
574
+ /**
575
+ * Pre-synthesise the handful of replies the assistant says verbatim, so the first one of the day is
576
+ * not a cloud round-trip. Everything else lands in the cache on first use anyway — this only covers
577
+ * the fixed strings, which are the ones that repeat forever.
578
+ */
579
+ async warmTtsCache(tts, lang) {
580
+ const phrases = FIXED_REPLIES[lang === 'ru' ? 'ru' : lang === 'de' ? 'de' : 'en'];
581
+ await tts.warm?.(phrases, lang).catch(e => this.log.debug(`TTS cache warm-up: ${e.message}`));
323
582
  }
324
583
  /**
325
584
  * Answer a voice request and mirror it into the text states: the recognised text into
@@ -468,6 +727,22 @@ class Assistant extends adapter_core_1.Adapter {
468
727
  }
469
728
  /** Reflect a satellite's status into `satellites.<room>.*` states (created on first sight). */
470
729
  async updateSatelliteState(device, room, state) {
730
+ const id = await this.ensureSatelliteObjects(device, room);
731
+ const base = `satellites.${id}`;
732
+ await this.setStateAsync(`${base}.status`, { val: state, ack: true });
733
+ await this.setStateAsync(`${base}.alive`, { val: state !== 'offline', ack: true });
734
+ await this.setStateAsync(`${base}.lastSeen`, { val: Date.now(), ack: true });
735
+ if (room) {
736
+ await this.setStateAsync(`${base}.room`, { val: room, ack: true });
737
+ }
738
+ }
739
+ /**
740
+ * Create the object tree for one satellite and return its (sanitised) state id, without touching
741
+ * any value. Callers that only need somewhere to hang a child object use this rather than
742
+ * {@link updateSatelliteState} — going through that would write `status`, and a wake-word or
743
+ * settings update arriving mid-announcement would knock the satellite back to `idle`.
744
+ */
745
+ async ensureSatelliteObjects(device, room) {
471
746
  const id = this.satelliteStateId(device, room);
472
747
  const roomName = (room || '').replace(/^(enum\.rooms\.|system\.rooms\.)/, '');
473
748
  const base = `satellites.${id}`;
@@ -533,16 +808,298 @@ class Assistant extends adapter_core_1.Adapter {
533
808
  },
534
809
  native: {},
535
810
  });
811
+ await this.setObjectNotExistsAsync(`${base}.dnd`, {
812
+ type: 'state',
813
+ common: {
814
+ name: 'Do-Not-Disturb: suppress announcements on this satellite (alerts still play)',
815
+ type: 'boolean',
816
+ role: 'switch.mode.silent',
817
+ read: true,
818
+ write: true,
819
+ def: false,
820
+ },
821
+ native: {},
822
+ });
536
823
  this.subscribeStates(`${base}.tts`);
824
+ this.subscribeStates(`${base}.dnd`);
825
+ // A satellite that was silenced before the restart stays silenced.
826
+ const dnd = await this.getStateAsync(`${base}.dnd`);
827
+ this.dndById.set(id, dnd?.val === true);
537
828
  this.satStatesEnsured.add(id);
538
829
  }
539
830
  // Map the (sanitised) state id back to the real device name for the per-satellite tts state.
540
831
  this.satDeviceById.set(id, device);
541
- await this.setStateAsync(`${base}.status`, { val: state, ack: true });
542
- await this.setStateAsync(`${base}.alive`, { val: state !== 'offline', ack: true });
543
- await this.setStateAsync(`${base}.lastSeen`, { val: Date.now(), ack: true });
544
- if (room) {
545
- await this.setStateAsync(`${base}.room`, { val: room, ack: true });
832
+ return id;
833
+ }
834
+ /**
835
+ * Publish a satellite's wake-word configuration and let it be changed from ioBroker.
836
+ *
837
+ * Only ESPHome satellites have this: the wake word runs on the device, and the ESPHome API lets the
838
+ * controller pick which of the built-in models listen. `wakeWords` is writable and takes a
839
+ * comma-separated list of ids; the device stores the choice itself, so it survives a restart of the
840
+ * adapter and is read back from the device rather than mirrored optimistically.
841
+ */
842
+ async updateSatelliteWakeWords(device, room, config) {
843
+ const id = await this.ensureSatelliteObjects(device, room);
844
+ const base = `satellites.${id}`;
845
+ if (!this.satWakeWordStatesEnsured.has(id)) {
846
+ await this.setObjectNotExistsAsync(`${base}.wakeWords`, {
847
+ type: 'state',
848
+ common: {
849
+ name: 'Active wake words (comma-separated ids)',
850
+ type: 'string',
851
+ role: 'text',
852
+ read: true,
853
+ write: true,
854
+ def: '',
855
+ },
856
+ native: {},
857
+ });
858
+ await this.setObjectNotExistsAsync(`${base}.availableWakeWords`, {
859
+ type: 'state',
860
+ common: {
861
+ name: 'Wake words this device offers',
862
+ type: 'string',
863
+ role: 'json',
864
+ read: true,
865
+ write: false,
866
+ def: '[]',
867
+ },
868
+ native: {},
869
+ });
870
+ this.subscribeStates(`${base}.wakeWords`);
871
+ this.satWakeWordStatesEnsured.add(id);
872
+ }
873
+ await this.setStateAsync(`${base}.wakeWords`, { val: config.active.join(','), ack: true });
874
+ await this.setStateAsync(`${base}.availableWakeWords`, {
875
+ val: JSON.stringify({ max: config.max, words: config.available }),
876
+ ack: true,
877
+ });
878
+ }
879
+ /**
880
+ * Mirror everything an ESPHome satellite exposes besides the voice pipeline into
881
+ * `satellites.<id>.controls.*` — microphone gain and volume, noise suppression, wake-word and
882
+ * stop-word sensitivities, the mute and thinking-sound switches, its media player and its firmware
883
+ * update entity. Which of these exist is up to the device, so the objects are built from what it
884
+ * announced rather than from a hard-coded list.
885
+ *
886
+ * The compound entities get a small folder instead of a single state, because one value cannot
887
+ * carry them: a media player has a state, a volume and a mute flag, and a firmware entity has two
888
+ * versions plus progress.
889
+ */
890
+ createSatelliteControls(device, room, entities) {
891
+ if (!entities.length) {
892
+ return Promise.resolve();
893
+ }
894
+ // Publish the promise *before* awaiting anything: the device starts pushing entity states while
895
+ // these objects are still being created, and those writes have to wait for it.
896
+ const satId = this.satelliteStateId(device, room);
897
+ const task = this.buildSatelliteControls(satId, device, room, entities);
898
+ this.satControlsReady.set(satId, task);
899
+ return task;
900
+ }
901
+ /** The actual object creation behind {@link createSatelliteControls}. */
902
+ async buildSatelliteControls(satId, device, room, entities) {
903
+ await this.ensureSatelliteObjects(device, room); // make sure the parent objects exist
904
+ const base = `satellites.${satId}.controls`;
905
+ await this.setObjectNotExistsAsync(base, {
906
+ type: 'channel',
907
+ common: { name: 'Device settings' },
908
+ native: {},
909
+ });
910
+ for (const entity of entities) {
911
+ const id = `${base}.${entity.objectId}`;
912
+ const name = entity.name || entity.objectId;
913
+ if (entity.kind === 'mediaPlayer') {
914
+ await this.setObjectNotExistsAsync(id, { type: 'channel', common: { name }, native: {} });
915
+ await this.ensureState(`${id}.state`, 'Playback state', 'string', 'media.state', false);
916
+ await this.ensureState(`${id}.volume`, 'Volume (0…1)', 'number', 'level.volume', true);
917
+ await this.ensureState(`${id}.command`, `Command (${Object.keys(esphomeEntities_1.MEDIA_COMMANDS).join(', ')})`, 'string', 'text', true);
918
+ await this.ensureState(`${id}.muted`, 'Muted', 'boolean', 'media.mute', false);
919
+ this.subscribeStates(`${id}.volume`);
920
+ this.subscribeStates(`${id}.command`);
921
+ }
922
+ else if (entity.kind === 'update') {
923
+ await this.setObjectNotExistsAsync(id, { type: 'channel', common: { name }, native: {} });
924
+ await this.ensureState(`${id}.currentVersion`, 'Installed version', 'string', 'text', false);
925
+ await this.ensureState(`${id}.latestVersion`, 'Available version', 'string', 'text', false);
926
+ await this.ensureState(`${id}.inProgress`, 'Update running', 'boolean', 'indicator', false);
927
+ await this.ensureState(`${id}.progress`, 'Update progress', 'number', 'value', false);
928
+ await this.ensureState(`${id}.install`, 'Install the update', 'boolean', 'button', true);
929
+ this.subscribeStates(`${id}.install`);
930
+ }
931
+ else if (entity.kind === 'event') {
932
+ await this.setObjectNotExistsAsync(id, {
933
+ type: 'state',
934
+ common: {
935
+ name: `${name} (last event)`,
936
+ type: 'string',
937
+ role: 'text',
938
+ read: true,
939
+ write: false,
940
+ def: '',
941
+ ...(entity.eventTypes?.length
942
+ ? { states: Object.fromEntries(entity.eventTypes.map(t => [t, t])) }
943
+ : {}),
944
+ },
945
+ native: {},
946
+ });
947
+ }
948
+ else if (entity.kind === 'sensor' || entity.kind === 'binarySensor' || entity.kind === 'textSensor') {
949
+ // What the box measures about its room (temperature, presence, a status text). Read-only,
950
+ // so there is nothing to subscribe to — the device pushes the values.
951
+ const common = entity.kind === 'sensor'
952
+ ? {
953
+ name,
954
+ type: 'number',
955
+ role: sensorRole(entity),
956
+ read: true,
957
+ write: false,
958
+ unit: entity.unit || undefined,
959
+ }
960
+ : entity.kind === 'binarySensor'
961
+ ? { name, type: 'boolean', role: 'indicator', read: true, write: false, def: false }
962
+ : { name, type: 'string', role: 'text', read: true, write: false, def: '' };
963
+ await this.setObjectNotExistsAsync(id, { type: 'state', common, native: {} });
964
+ }
965
+ else {
966
+ const common = entity.kind === 'switch'
967
+ ? { name, type: 'boolean', role: 'switch', read: true, write: true, def: false }
968
+ : entity.kind === 'select'
969
+ ? {
970
+ name,
971
+ type: 'string',
972
+ role: 'text',
973
+ read: true,
974
+ write: true,
975
+ def: '',
976
+ states: Object.fromEntries((entity.options || []).map(o => [o, o])),
977
+ }
978
+ : {
979
+ name,
980
+ type: 'number',
981
+ role: 'level',
982
+ read: true,
983
+ write: true,
984
+ min: entity.min,
985
+ max: entity.max,
986
+ step: entity.step,
987
+ unit: entity.unit || undefined,
988
+ };
989
+ await this.setObjectNotExistsAsync(id, { type: 'state', common, native: {} });
990
+ this.subscribeStates(id);
991
+ }
992
+ }
993
+ this.satDeviceById.set(satId, device);
994
+ // Now that the objects exist, publish the values the device already reported. Anything that
995
+ // arrived while we were still creating objects was skipped, so this is what fills those in.
996
+ for (const entity of entities) {
997
+ if (entity.value !== undefined) {
998
+ await this.writeSatelliteControl(satId, entity);
999
+ }
1000
+ }
1001
+ this.log.debug(`Satellite ${device}: ${entities.length} control object(s) under ${base}`);
1002
+ }
1003
+ /** Small helper so the control objects above stay readable. */
1004
+ async ensureState(id, name, type, role, write) {
1005
+ await this.setObjectNotExistsAsync(id, {
1006
+ type: 'state',
1007
+ common: { name, type, role, read: true, write },
1008
+ native: {},
1009
+ });
1010
+ }
1011
+ /**
1012
+ * A device pushed a new value for one of its entities.
1013
+ *
1014
+ * The device starts reporting states while {@link buildSatelliteControls} is still creating the
1015
+ * objects, so this waits for that to finish. Before the entities have even been announced there is
1016
+ * nothing to wait on and nothing to write into — the value is kept in the registry and published by
1017
+ * the initial sync at the end of the build.
1018
+ */
1019
+ async updateSatelliteControl(device, room, entity) {
1020
+ const satId = this.satelliteStateId(device, room);
1021
+ const ready = this.satControlsReady.get(satId);
1022
+ if (!ready) {
1023
+ return;
1024
+ }
1025
+ await ready;
1026
+ await this.writeSatelliteControl(satId, entity);
1027
+ }
1028
+ /** Write one entity's current value into its state(s). Always acked — this is the device talking. */
1029
+ async writeSatelliteControl(satId, entity) {
1030
+ const base = `satellites.${satId}.controls.${entity.objectId}`;
1031
+ if (entity.kind === 'mediaPlayer') {
1032
+ const v = entity.value;
1033
+ await this.setStateAsync(`${base}.state`, { val: v.state, ack: true });
1034
+ await this.setStateAsync(`${base}.volume`, { val: v.volume, ack: true });
1035
+ await this.setStateAsync(`${base}.muted`, { val: v.muted, ack: true });
1036
+ }
1037
+ else if (entity.kind === 'update') {
1038
+ const v = entity.value;
1039
+ await this.setStateAsync(`${base}.currentVersion`, { val: v.currentVersion, ack: true });
1040
+ await this.setStateAsync(`${base}.latestVersion`, { val: v.latestVersion, ack: true });
1041
+ await this.setStateAsync(`${base}.inProgress`, { val: v.inProgress, ack: true });
1042
+ await this.setStateAsync(`${base}.progress`, { val: v.progress, ack: true });
1043
+ }
1044
+ else {
1045
+ await this.setStateAsync(base, { val: entity.value, ack: true });
1046
+ }
1047
+ }
1048
+ /**
1049
+ * Apply a write to any `satellites.<id>.controls.*` state. Like the wake words, nothing is acked
1050
+ * here: the device reports the value it actually took and {@link updateSatelliteControl} writes
1051
+ * that, so a clamped or rejected write never leaves ioBroker showing a value the device ignored.
1052
+ */
1053
+ setSatelliteControl(satId, path, value) {
1054
+ const device = this.satDeviceById.get(satId) || satId;
1055
+ if (!this.esphome) {
1056
+ this.log.warn(`Cannot write ${path} on ${device}: ESPHome satellites are not enabled.`);
1057
+ return;
1058
+ }
1059
+ // Compound entities address a leaf: `<objectId>.volume`, `<objectId>.install`, …
1060
+ const [objectId, leaf] = path.split('.');
1061
+ let payload = value;
1062
+ if (leaf === 'install') {
1063
+ if (!value) {
1064
+ return; // button released
1065
+ }
1066
+ payload = 'install';
1067
+ }
1068
+ else if (leaf === 'volume' || leaf === 'command') {
1069
+ payload = value;
1070
+ }
1071
+ else if (leaf) {
1072
+ return; // read-only leaf of a compound entity
1073
+ }
1074
+ if (!this.esphome.setEntity(device, objectId, payload)) {
1075
+ this.log.warn(`Writing ${objectId} on ${device} was rejected — see the warning above.`);
1076
+ }
1077
+ }
1078
+ /**
1079
+ * Apply a write to `satellites.<id>.wakeWords`. The state is *not* acked here: the device echoes its
1080
+ * new configuration back and {@link updateSatelliteWakeWords} writes the acked value, so what ioBroker
1081
+ * shows is always what the device actually does — including when it rejected or trimmed the list.
1082
+ */
1083
+ async setSatelliteWakeWords(satId, value) {
1084
+ const device = this.satDeviceById.get(satId) || satId;
1085
+ const ids = value
1086
+ .split(/[,;]/)
1087
+ .map(s => s.trim())
1088
+ .filter(Boolean);
1089
+ if (!this.esphome) {
1090
+ this.log.warn(`Cannot set wake words on ${device}: ESPHome satellites are not enabled.`);
1091
+ return;
1092
+ }
1093
+ if (!this.esphome.setWakeWords(device, ids)) {
1094
+ this.log.warn(`Cannot set wake words: satellite ${device} is not connected.`);
1095
+ // Put the last known value back, so the state does not keep showing a change that never happened.
1096
+ const known = this.esphome.wakeWords(device);
1097
+ if (known) {
1098
+ await this.setStateAsync(`satellites.${satId}.wakeWords`, {
1099
+ val: known.active.join(','),
1100
+ ack: true,
1101
+ });
1102
+ }
546
1103
  }
547
1104
  }
548
1105
  /**
@@ -568,19 +1125,38 @@ class Assistant extends adapter_core_1.Adapter {
568
1125
  * the configured TTS engine; a URL/path to an audio file (mp3/wav/…) is decoded with ffmpeg. Delivered
569
1126
  * to **both** transports: ioBroker-native satellites via a `sendTo(from, 'announce', …)` message and UDP
570
1127
  * satellites via the UDP server.
1128
+ *
1129
+ * With `opts.listen` the satellite re-opens its microphone afterwards, for the answer to a question
1130
+ * (`askUser`); `opts.priority` plays even on a satellite set to Do-Not-Disturb (an alert does, see
1131
+ * {@link notify}). Returns how many channels the announcement reached — 0 means nobody heard it.
571
1132
  */
572
- async announceToSatellites(value, targetId) {
1133
+ async announceToSatellites(value, target, opts = {}) {
1134
+ const listen = opts.listen === true;
573
1135
  let v = value.trim();
574
1136
  if (!v) {
575
- return;
1137
+ return 0;
1138
+ }
1139
+ // A name may stand for one satellite, a room, or a configured group/person — resolve it before
1140
+ // spending a TTS call on a target that does not exist.
1141
+ const targets = this.resolveTargets(target);
1142
+ if (targets && !targets.length) {
1143
+ this.log.warn(`Announcement not delivered — '${String(target)}' is no known satellite, room or group.`);
1144
+ return 0;
1145
+ }
1146
+ // Checked before the text is synthesised, so talking to an empty house costs neither an LLM nor a
1147
+ // TTS call.
1148
+ if (opts.onlyWhenHome && this.emptyHouse()) {
1149
+ this.log.debug('Announcement held back — nobody is at home.');
1150
+ return 0;
576
1151
  }
577
1152
  // A leading "!" marks a priority announcement: strip it and let it bypass a satellite's
578
1153
  // Do-Not-Disturb (e.g. "!Water leak detected" is played even in DND).
579
- const priority = v.startsWith('!');
580
- if (priority) {
1154
+ let priority = opts.priority === true;
1155
+ if (v.startsWith('!')) {
1156
+ priority = true;
581
1157
  v = v.slice(1).trim();
582
1158
  if (!v) {
583
- return;
1159
+ return 0;
584
1160
  }
585
1161
  }
586
1162
  const isAudio = isAudioRef(v);
@@ -597,43 +1173,122 @@ class Assistant extends adapter_core_1.Adapter {
597
1173
  }
598
1174
  catch (e) {
599
1175
  this.log.error(`Announcement failed: ${e.message}`);
600
- return;
1176
+ return 0;
1177
+ }
1178
+ let delivered = 0;
1179
+ if (targets === null) {
1180
+ delivered = await this.deliverPcm(pcm, sampleRate, null, priority, listen);
1181
+ }
1182
+ else {
1183
+ // Synthesised once, handed to each member: a group of three speakers is one TTS call.
1184
+ for (const id of targets) {
1185
+ delivered += await this.deliverPcm(pcm, sampleRate, id, priority, listen);
1186
+ }
601
1187
  }
602
- const delivered = await this.deliverPcm(pcm, sampleRate, targetId, priority);
603
- this.log.info(`Announce → ${targetId || 'all satellites'} (${delivered} channel(s)): ${isAudio ? v : `"${v}"`}`);
1188
+ this.log.info(`Announce → ${targets ? targets.join(', ') : 'all satellites'} (${delivered} channel(s)${listen ? ', mic on' : ''}): ${isAudio ? v : `"${v}"`}`);
604
1189
  if (!delivered) {
605
1190
  this.log.warn('Announcement not delivered — no satellites registered (native or UDP).');
606
1191
  }
1192
+ return delivered;
607
1193
  }
608
1194
  /**
609
1195
  * Deliver a ready 16-bit-mono-PCM buffer to one satellite (`targetId`) or all (`null`), over both
610
1196
  * transports (ioBroker-native message bus + UDP). Returns how many channels it reached.
1197
+ *
1198
+ * `listen` asks the satellite to re-open its microphone when the clip has played, so the answer to a
1199
+ * question is captured without a wake word. Each transport has its own way: ESPHome carries it in the
1200
+ * announce request (`start_conversation`), UDP gets a `listen` control message, and a native satellite
1201
+ * receives the flag in the announce message.
611
1202
  */
612
- async deliverPcm(pcm, sampleRate, targetId, priority) {
1203
+ async deliverPcm(pcm, sampleRate, targetId, priority, listen = false) {
613
1204
  let delivered = 0;
1205
+ if (this.silenced(targetId, priority)) {
1206
+ this.log.debug(`Announcement suppressed — Do-Not-Disturb (${targetId || 'all satellites'}).`);
1207
+ return 0;
1208
+ }
614
1209
  // ── ioBroker-native satellites: push over the message bus ───────────────
615
1210
  const nativeTargets = targetId
616
1211
  ? this.nativeSatFrom.has(targetId)
617
1212
  ? [[targetId, this.nativeSatFrom.get(targetId)]]
618
1213
  : []
619
1214
  : [...this.nativeSatFrom.entries()];
620
- for (const [, from] of nativeTargets) {
621
- this.sendTo(from, 'announce', { audio: pcm.toString('base64'), sampleRate, format: 'pcm', priority });
1215
+ for (const [satId, from] of nativeTargets) {
1216
+ if (this.silenced(satId, priority)) {
1217
+ continue;
1218
+ }
1219
+ this.sendTo(from, 'announce', {
1220
+ audio: pcm.toString('base64'),
1221
+ sampleRate,
1222
+ format: 'pcm',
1223
+ priority,
1224
+ listen,
1225
+ });
622
1226
  delivered++;
623
1227
  }
624
1228
  // ── UDP satellites (if the UDP server runs and the target isn't a native one) ──
625
1229
  if (this.voice && !(targetId && this.nativeSatFrom.has(targetId))) {
626
1230
  const device = targetId ? this.satDeviceById.get(targetId) || targetId : null;
1231
+ // Addressed per device rather than broadcast, so one silenced satellite in the house does not
1232
+ // silence the others — and so the count reflects who actually got it (announce() itself only
1233
+ // warns when nobody is registered, and askUser needs to know whether the question was heard).
1234
+ const allowed = this.voice
1235
+ .devices()
1236
+ .filter(d => (!device || d === device) && !this.isDeviceSilenced(d, priority));
1237
+ if (allowed.length) {
1238
+ try {
1239
+ await Promise.all(allowed.map(d => this.voice.announce(d, pcm, sampleRate)));
1240
+ delivered += allowed.length;
1241
+ if (listen) {
1242
+ allowed.forEach(d => this.voice.listen(d));
1243
+ }
1244
+ }
1245
+ catch (e) {
1246
+ this.log.debug(`UDP announce failed: ${e.message}`);
1247
+ }
1248
+ }
1249
+ }
1250
+ // ── ESPHome satellites: they fetch the clip from the media server by URL ──
1251
+ if (this.esphome && !(targetId && this.nativeSatFrom.has(targetId))) {
1252
+ const device = targetId ? this.satDeviceById.get(targetId) || targetId : null;
1253
+ const allowed = this.esphome
1254
+ .devices()
1255
+ .filter(d => (!device || d === device) && !this.isDeviceSilenced(d, priority));
627
1256
  try {
628
- await this.voice.announce(device, pcm, sampleRate);
629
- delivered++;
1257
+ const counts = await Promise.all(allowed.map(d => this.esphome.announce(d, pcm, sampleRate, listen)));
1258
+ delivered += counts.reduce((sum, n) => sum + n, 0);
630
1259
  }
631
1260
  catch (e) {
632
- this.log.debug(`UDP announce failed: ${e.message}`);
1261
+ this.log.debug(`ESPHome announce failed: ${e.message}`);
633
1262
  }
634
1263
  }
635
1264
  return delivered;
636
1265
  }
1266
+ /**
1267
+ * Do-Not-Disturb check by satellite state id (`null` = the broadcast itself). A priority announcement
1268
+ * — an `alert` notification or a text starting with `!` — is never suppressed; that is the whole point
1269
+ * of the flag, and a water leak has to be heard at night.
1270
+ */
1271
+ silenced(satId, priority) {
1272
+ if (priority) {
1273
+ return false;
1274
+ }
1275
+ if (this.globalDnd) {
1276
+ return true;
1277
+ }
1278
+ return satId ? this.dndById.get(satId) === true : false;
1279
+ }
1280
+ /** Same check, for a transport that knows its satellites by device name rather than by state id. */
1281
+ isDeviceSilenced(device, priority) {
1282
+ if (priority) {
1283
+ return false;
1284
+ }
1285
+ for (const [satId, dev] of this.satDeviceById) {
1286
+ if (dev === device) {
1287
+ return this.silenced(satId, priority);
1288
+ }
1289
+ }
1290
+ return this.globalDnd;
1291
+ }
637
1292
  /**
638
1293
  * Install the bundled default jingles (`admin/sounds/{timer,alarm}.wav`) into this instance's file
639
1294
  * storage the first time, so they appear in the settings sound dropdown and can be played out of the
@@ -743,7 +1398,9 @@ class Assistant extends adapter_core_1.Adapter {
743
1398
  if (session.stopped) {
744
1399
  return;
745
1400
  }
746
- await this.announceToSatellites(message, target).catch(() => { });
1401
+ // Priority like the jingle above: a timer the user set themselves is not an unsolicited
1402
+ // announcement, and hearing the gong followed by silence would be worse than either.
1403
+ await this.announceToSatellites(message, target, { priority: true }).catch(() => { });
747
1404
  }
748
1405
  if (session.stopped) {
749
1406
  return;
@@ -794,7 +1451,8 @@ class Assistant extends adapter_core_1.Adapter {
794
1451
  this.log.debug(`sound play failed: ${e.message}`);
795
1452
  }
796
1453
  if (announce) {
797
- await this.announceToSatellites(text, targetId);
1454
+ // Priority, for the same reason as in startRing: the jingle already bypasses Do-Not-Disturb.
1455
+ await this.announceToSatellites(text, targetId, { priority: true });
798
1456
  }
799
1457
  }
800
1458
  /** Decode an audio file/URL (mp3/wav/…) to mono 16-bit PCM via ffmpeg. */
@@ -833,7 +1491,18 @@ class Assistant extends adapter_core_1.Adapter {
833
1491
  }
834
1492
  }
835
1493
  async onStateChange(id, state) {
836
- if (!state || state.ack) {
1494
+ if (!state) {
1495
+ return;
1496
+ }
1497
+ // Trigger-watched states are routed first and regardless of `ack`: a device confirms its value with
1498
+ // ack:true, which the filter below drops — and those are exactly the changes a trigger reacts to.
1499
+ if (this.triggerStateIds.has(id)) {
1500
+ await this.triggers?.onStateChange(id, state.val);
1501
+ }
1502
+ // Presence states come from devices too (a phone ping, the residents adapter), so they are
1503
+ // read here for the same reason — and one state may well be watched by both.
1504
+ this.presence?.update(id, state.val ?? null);
1505
+ if (state.ack) {
837
1506
  return;
838
1507
  } // ignore our own ack-writes
839
1508
  // Announcements: `tts.text` → all satellites; `satellites.<id>.tts` → that satellite.
@@ -846,6 +1515,18 @@ class Assistant extends adapter_core_1.Adapter {
846
1515
  await this.announceToSatellites(String(state.val ?? ''), satTts[1]); // satTts[1] = satellite state id
847
1516
  return;
848
1517
  }
1518
+ // Pick which wake words listen on an ESPHome satellite (comma-separated ids).
1519
+ const satWake = id.match(/\.satellites\.([^.]+)\.wakeWords$/);
1520
+ if (satWake) {
1521
+ await this.setSatelliteWakeWords(satWake[1], String(state.val ?? ''));
1522
+ return;
1523
+ }
1524
+ // Device settings: mic gain/volume, noise suppression, sensitivities, media player, firmware.
1525
+ const satControl = id.match(/\.satellites\.([^.]+)\.controls\.(.+)$/);
1526
+ if (satControl) {
1527
+ this.setSatelliteControl(satControl[1], satControl[2], state.val ?? null);
1528
+ return;
1529
+ }
849
1530
  // Silence a ringing timer/alarm from vis/JS.
850
1531
  if (id.endsWith('.stopRinging')) {
851
1532
  if (state.val) {
@@ -892,6 +1573,52 @@ class Assistant extends adapter_core_1.Adapter {
892
1573
  this.alarms?.setEnabled(alarmEnable[1], !!state.val);
893
1574
  return;
894
1575
  }
1576
+ // System notifications: `notify.text` (severity notify) and `notify.alert` (urgent, ignores DND).
1577
+ if (id.endsWith('.notify.text') || id.endsWith('.notify.alert')) {
1578
+ const alert = id.endsWith('.notify.alert');
1579
+ const text = String(state.val ?? '');
1580
+ await this.setStateAsync(alert ? 'notify.alert' : 'notify.text', { val: '', ack: true });
1581
+ await this.notify(text, alert ? 'alert' : 'notify');
1582
+ return;
1583
+ }
1584
+ // Do-Not-Disturb: globally and per satellite.
1585
+ if (id.endsWith('.dnd') && !id.includes('.satellites.')) {
1586
+ this.globalDnd = !!state.val;
1587
+ this.log.info(`Do-Not-Disturb ${this.globalDnd ? 'on' : 'off'} (all satellites).`);
1588
+ await this.setStateAsync('dnd', { val: this.globalDnd, ack: true });
1589
+ return;
1590
+ }
1591
+ const satDnd = id.match(/\.satellites\.([^.]+)\.dnd$/);
1592
+ if (satDnd) {
1593
+ this.dndById.set(satDnd[1], !!state.val);
1594
+ this.log.info(`Do-Not-Disturb ${state.val ? 'on' : 'off'} for satellite '${satDnd[1]}'.`);
1595
+ await this.setStateAsync(`satellites.${satDnd[1]}.dnd`, { val: !!state.val, ack: true });
1596
+ return;
1597
+ }
1598
+ // Trigger controls: the master switch, the per-trigger enable switch and its test button.
1599
+ if (id.endsWith('.triggers.enabled')) {
1600
+ this.triggersEnabled = !!state.val;
1601
+ this.log.info(`Triggers ${this.triggersEnabled ? 'enabled' : 'disabled'} via triggers.enabled.`);
1602
+ await this.setStateAsync('triggers.enabled', { val: this.triggersEnabled, ack: true });
1603
+ return;
1604
+ }
1605
+ const triggerEnable = id.match(/\.triggers\.items\.([^.]+)\.enabled$/);
1606
+ if (triggerEnable) {
1607
+ if (this.triggers?.setEnabled(triggerEnable[1], !!state.val)) {
1608
+ this.log.info(`Trigger ${triggerEnable[1]} ${state.val ? 'enabled' : 'disabled'}.`);
1609
+ }
1610
+ return;
1611
+ }
1612
+ const triggerFire = id.match(/\.triggers\.items\.([^.]+)\.fire$/);
1613
+ if (triggerFire) {
1614
+ // Ack the button back to false right away, so it can be pressed again while the trigger runs
1615
+ // (a trigger that asks a question keeps us here for up to a minute).
1616
+ await this.setStateAsync(`triggers.items.${triggerFire[1]}.fire`, { val: false, ack: true });
1617
+ if (state.val && !(await this.triggers?.fireNow(triggerFire[1]))) {
1618
+ this.log.warn(`Trigger ${triggerFire[1]} not found.`);
1619
+ }
1620
+ return;
1621
+ }
895
1622
  // Long-term memory controls: add a fact, forget by id/key, clear all, edit/delete a single fact.
896
1623
  if (id.endsWith('.memory.add')) {
897
1624
  const text = String(state.val ?? '').trim();
@@ -933,7 +1660,7 @@ class Assistant extends adapter_core_1.Adapter {
933
1660
  if (!id.endsWith('.text.request')) {
934
1661
  return;
935
1662
  }
936
- if (!this.agent) {
1663
+ if (!this.ready) {
937
1664
  return;
938
1665
  }
939
1666
  const question = String(state.val ?? '').trim();
@@ -1187,6 +1914,77 @@ class Assistant extends adapter_core_1.Adapter {
1187
1914
  }
1188
1915
  return;
1189
1916
  }
1917
+ // Scripts / settings: which wake words an ESPHome satellite offers and which are listening.
1918
+ // message = { device? } — omitted means every connected one.
1919
+ if (obj.command === 'getWakeWords') {
1920
+ const m = (obj.message || {});
1921
+ const devices = m.device ? [m.device] : this.esphome?.devices() || [];
1922
+ const payload = this.esphome
1923
+ ? { devices: devices.map(d => ({ device: d, ...(this.esphome?.wakeWords(d) || {}) })) }
1924
+ : { error: 'ESPHome satellites are not enabled' };
1925
+ if (obj.callback) {
1926
+ this.sendTo(obj.from, obj.command, payload, obj.callback);
1927
+ }
1928
+ return;
1929
+ }
1930
+ // Scripts: choose the active wake words. message = { device, wakeWords: string[] | "a,b" }.
1931
+ if (obj.command === 'setWakeWords') {
1932
+ const m = (obj.message || {});
1933
+ const ids = (Array.isArray(m.wakeWords) ? m.wakeWords : String(m.wakeWords ?? '').split(/[,;]/))
1934
+ .map(s => String(s).trim())
1935
+ .filter(Boolean);
1936
+ let payload;
1937
+ if (!this.esphome) {
1938
+ payload = { error: 'ESPHome satellites are not enabled' };
1939
+ }
1940
+ else if (!m.device) {
1941
+ payload = { error: 'device is required' };
1942
+ }
1943
+ else if (!this.esphome.setWakeWords(m.device, ids)) {
1944
+ payload = { error: `satellite ${m.device} is not connected` };
1945
+ }
1946
+ else {
1947
+ payload = { ok: true };
1948
+ }
1949
+ if (obj.callback) {
1950
+ this.sendTo(obj.from, obj.command, payload, obj.callback);
1951
+ }
1952
+ return;
1953
+ }
1954
+ // Scripts: everything an ESPHome satellite exposes besides the voice pipeline.
1955
+ // message = { device? }; without a device, every connected one is listed.
1956
+ if (obj.command === 'getControls') {
1957
+ const m = (obj.message || {});
1958
+ const devices = m.device ? [m.device] : this.esphome?.devices() || [];
1959
+ const payload = this.esphome
1960
+ ? { devices: devices.map(d => ({ device: d, entities: this.esphome?.entities(d) || [] })) }
1961
+ : { error: 'ESPHome satellites are not enabled' };
1962
+ if (obj.callback) {
1963
+ this.sendTo(obj.from, obj.command, payload, obj.callback);
1964
+ }
1965
+ return;
1966
+ }
1967
+ // Scripts: write one of them. message = { device, control: 'mic_volume', value: 2000 }.
1968
+ if (obj.command === 'setControl') {
1969
+ const m = (obj.message || {});
1970
+ let payload;
1971
+ if (!this.esphome) {
1972
+ payload = { error: 'ESPHome satellites are not enabled' };
1973
+ }
1974
+ else if (!m.device || !m.control) {
1975
+ payload = { error: 'device and control are required' };
1976
+ }
1977
+ else if (!this.esphome.setEntity(m.device, m.control, m.value)) {
1978
+ payload = { error: `${m.control} on ${m.device} rejected the value (see the log)` };
1979
+ }
1980
+ else {
1981
+ payload = { ok: true };
1982
+ }
1983
+ if (obj.callback) {
1984
+ this.sendTo(obj.from, obj.command, payload, obj.callback);
1985
+ }
1986
+ return;
1987
+ }
1190
1988
  // Scripts: silence a ringing timer/alarm.
1191
1989
  if (obj.command === 'stopRinging') {
1192
1990
  const n = this.stopRinging();
@@ -1237,7 +2035,7 @@ class Assistant extends adapter_core_1.Adapter {
1237
2035
  const { text, targetLang } = (obj.message || {});
1238
2036
  if (!this.agent) {
1239
2037
  if (obj.callback) {
1240
- this.sendTo(obj.from, obj.command, { error: 'agent not ready' }, obj.callback);
2038
+ this.sendTo(obj.from, obj.command, { error: this.ready ? 'translating needs an LLM provider' : 'agent not ready' }, obj.callback);
1241
2039
  }
1242
2040
  return;
1243
2041
  }
@@ -1280,6 +2078,74 @@ class Assistant extends adapter_core_1.Adapter {
1280
2078
  }
1281
2079
  return;
1282
2080
  }
2081
+ // A notification routed here by the ioBroker notification-manager.
2082
+ if (obj.command === 'sendNotification') {
2083
+ const result = await this.handleSystemNotification(obj.message);
2084
+ if (obj.callback) {
2085
+ this.sendTo(obj.from, obj.command, result, obj.callback);
2086
+ }
2087
+ return;
2088
+ }
2089
+ // Speak a system notification: sendTo('assistant.0', 'notify', { text, severity, target }).
2090
+ if (obj.command === 'notify') {
2091
+ const msg = (obj.message || {});
2092
+ const text = String(msg.text ?? '');
2093
+ let result;
2094
+ if (!text.trim()) {
2095
+ result = { error: 'no text provided' };
2096
+ }
2097
+ else {
2098
+ const where = (msg.target || msg.room || '').trim();
2099
+ const ids = this.resolveTargets(where);
2100
+ const target = where || null;
2101
+ result =
2102
+ ids && !ids.length
2103
+ ? { error: `unknown satellite, room or group '${where}'` }
2104
+ : {
2105
+ spoken: await this.notify(text, (0, notifications_1.parseSeverity)(msg.severity), target, msg.onlyWhenHome === true),
2106
+ };
2107
+ }
2108
+ if (obj.callback) {
2109
+ this.sendTo(obj.from, obj.command, result, obj.callback);
2110
+ }
2111
+ return;
2112
+ }
2113
+ // Trigger management for scripts: list them, run one now, enable/disable one.
2114
+ if (obj.command === 'listTriggers') {
2115
+ if (obj.callback) {
2116
+ this.sendTo(obj.from, obj.command, { triggers: this.triggers?.list() ?? [] }, obj.callback);
2117
+ }
2118
+ return;
2119
+ }
2120
+ if (obj.command === 'fireTrigger' || obj.command === 'setTriggerEnabled') {
2121
+ const msg = (obj.message || {});
2122
+ const tid = String(msg.id ?? '').trim();
2123
+ let result;
2124
+ if (!tid) {
2125
+ result = { ok: false, error: 'no trigger id provided' };
2126
+ }
2127
+ else if (obj.command === 'fireTrigger') {
2128
+ const ok = (await this.triggers?.fireNow(tid)) === true;
2129
+ result = ok ? { ok } : { ok, error: `unknown trigger '${tid}'` };
2130
+ }
2131
+ else {
2132
+ const ok = this.triggers?.setEnabled(tid, msg.enabled !== false) === true;
2133
+ result = ok ? { ok } : { ok, error: `unknown trigger '${tid}'` };
2134
+ }
2135
+ if (obj.callback) {
2136
+ this.sendTo(obj.from, obj.command, result, obj.callback);
2137
+ }
2138
+ return;
2139
+ }
2140
+ // The other direction: ask the user something and hand the answer back to the caller.
2141
+ // sendTo('assistant.0', 'askUser', { question, room|target|source, timeoutMs }, cb)
2142
+ if (obj.command === 'askUser') {
2143
+ const result = await this.askUser((obj.message || {}));
2144
+ if (obj.callback) {
2145
+ this.sendTo(obj.from, obj.command, result, obj.callback);
2146
+ }
2147
+ return;
2148
+ }
1283
2149
  if (obj.command !== 'ask') {
1284
2150
  return;
1285
2151
  }
@@ -1287,7 +2153,7 @@ class Assistant extends adapter_core_1.Adapter {
1287
2153
  const text = typeof message === 'string' ? message : message?.text;
1288
2154
  // Origin for text.querySource: caller-provided (e.g. 'telegram:Max') or 'chat' for the admin chat.
1289
2155
  const source = (typeof message === 'string' ? '' : message?.source?.trim()) || 'chat';
1290
- if (!this.agent) {
2156
+ if (!this.ready) {
1291
2157
  if (obj.callback) {
1292
2158
  this.sendTo(obj.from, obj.command, { error: 'agent not ready' }, obj.callback);
1293
2159
  }
@@ -1415,6 +2281,25 @@ class Assistant extends adapter_core_1.Adapter {
1415
2281
  const lang = String(this.config.voiceLanguage || this.language || 'en');
1416
2282
  return lang === 'ru' ? 'Хорошо.' : lang === 'de' ? 'Ok.' : 'Okay.';
1417
2283
  }
2284
+ // An open question from `askUser` claims the next utterance of that source as its answer — before
2285
+ // any tier sees it, because the NLU would read a bare "yes"/"the kitchen" as a command of its own.
2286
+ // We stay silent (empty reply): whoever asked decides what to say about the answer.
2287
+ if (this.pending.has(source)) {
2288
+ const asked = this.pending.question(source);
2289
+ if (this.pending.deliver(source, question)) {
2290
+ this.log.info(`Answer to "${asked}" from source '${source || 'any'}': ${question}`);
2291
+ return '';
2292
+ }
2293
+ }
2294
+ // A routine is a macro the user wrote down, so it wins over everything that interprets: the NLU
2295
+ // would find "Licht" in "Gute Nacht, Licht aus" and do half of it, and the LLM would cost a
2296
+ // round-trip for a decision that has already been made.
2297
+ const routine = (0, routines_1.matchRoutine)(question, this.routines);
2298
+ if (routine) {
2299
+ this.log.info(`Routine '${routine.name}' triggered (source='${source}').`);
2300
+ await this.runRoutine(routine, source);
2301
+ return routine.reply;
2302
+ }
1418
2303
  // Tier 0: rule-based NLU (device commands) — fastest, offline, free.
1419
2304
  if (this.config.useLocalNlu) {
1420
2305
  try {
@@ -1435,10 +2320,14 @@ class Assistant extends adapter_core_1.Adapter {
1435
2320
  // Tier 1a: local LLM — answers general questions offline; emits HANDOFF for anything needing tools.
1436
2321
  // Skipped inside an ongoing conversation (history present): the tool-free local model gets no history,
1437
2322
  // so it would answer a follow-up like "yes" context-free and swallow it — let the cloud LLM (which has
1438
- // the thread and the tools) handle follow-ups instead.
1439
- if (this.config.useLocalLlm && this.localLlm && !history.length) {
2323
+ // the thread and the tools) handle follow-ups instead. Without a cloud LLM there is nobody to hand
2324
+ // follow-ups to, so the local model takes them too.
2325
+ if (this.config.useLocalLlm && this.localLlm && (!history.length || !this.agent)) {
1440
2326
  try {
1441
- const ans = await this.localLlm.ask(weather ? `${weather}\n\n${question}` : question);
2327
+ // The local model has no tools: without these lines it would invent the weather and guess
2328
+ // who is at home.
2329
+ const facts = [weather, this.buildPresenceContext()].filter(Boolean).join('\n\n');
2330
+ const ans = await this.localLlm.ask(facts ? `${facts}\n\n${question}` : question);
1442
2331
  if (ans && !(0, localLlm_1.isHandoff)(ans)) {
1443
2332
  this.log.info(`Answered by local LLM (source='${source}').`);
1444
2333
  return ans;
@@ -1455,7 +2344,10 @@ class Assistant extends adapter_core_1.Adapter {
1455
2344
  // Tier 2: cloud LLM with full tool access. Inject a compact device list into the system prompt so
1456
2345
  // the model can act without a first `list_devices` round-trip (kept cached via prompt caching).
1457
2346
  if (!this.agent) {
1458
- throw new Error('agent not ready');
2347
+ // Provider "none": no tier further down. Say so instead of failing — over voice an error is
2348
+ // read out as a technical message.
2349
+ this.log.info(`No local tier could answer and no LLM provider is configured (source='${source}').`);
2350
+ return this.localOnlyFallback();
1459
2351
  }
1460
2352
  const ctx = await this.buildDeviceContext();
1461
2353
  const mem = this.buildMemoryContext();
@@ -1463,12 +2355,23 @@ class Assistant extends adapter_core_1.Adapter {
1463
2355
  const followUp = this.config.voiceEnabled ? this.followUpHint() : '';
1464
2356
  const parts = [this.config.systemPrompt || '', followUp, mem, ctx].filter(Boolean);
1465
2357
  const sys = parts.length ? parts.join('\n\n') : undefined;
1466
- this.log.info(`Cloud LLM [${this.agent.model}] (source='${source}', context=${history.length} turn(s)${mem ? ', memory' : ''}${weather ? ', weather' : ''}) — running tool loop…`);
2358
+ this.log.info(`Cloud LLM [${this.agent.model || 'auto'}] (source='${source}', context=${history.length} turn(s)${mem ? ', memory' : ''}${weather ? ', weather' : ''}${this.presence?.configured ? ', presence' : ''}) — running tool loop…`);
1467
2359
  // Prepend the current date/time (and the weather line, if a source is configured) to the user turn —
1468
2360
  // cache-safe, see buildTimeContext. The stored history keeps the untouched question.
1469
2361
  const questionForLlm = [this.buildTimeContext(), weather, question].filter(Boolean).join('\n\n');
1470
2362
  return this.agent.ask(questionForLlm, sys, history);
1471
2363
  }
2364
+ /** Reply when nothing local understood the request and there is no cloud LLM (provider "none"). */
2365
+ localOnlyFallback() {
2366
+ const lang = String(this.config.voiceLanguage || this.language || 'en');
2367
+ if (lang === 'ru') {
2368
+ return 'Извини, этого я не поняла. Без облачной языковой модели я понимаю только простые команды для устройств, таймеры и будильники.';
2369
+ }
2370
+ if (lang === 'de') {
2371
+ return 'Das habe ich leider nicht verstanden. Ohne Cloud-Sprachmodell verstehe ich nur einfache Gerätebefehle, Timer und Wecker.';
2372
+ }
2373
+ return "Sorry, I didn't understand that. Without a cloud language model I only understand simple device commands, timers and alarms.";
2374
+ }
1472
2375
  /**
1473
2376
  * Compact device listing for the LLM system prompt: "Name (Room, type): stateId, …" per device,
1474
2377
  * so the model can call set_state/get_states directly without a `list_devices` round-trip. Honors
@@ -1506,6 +2409,28 @@ class Assistant extends adapter_core_1.Adapter {
1506
2409
  : 'Known devices — "name (room, type): stateId". Use these stateIds directly with set_state/get_states; only call list_devices if a needed device is missing here.';
1507
2410
  return `${header}\n${lines.join('\n')}`;
1508
2411
  }
2412
+ /**
2413
+ * List a port we listen on in the host's used-resources registry (js-controller ≥ 8), so the admin shows it
2414
+ * as occupied when someone configures another instance. `common.declareUsedResources` is set because the
2415
+ * controller would otherwise derive a TCP port from `native.port` — ours is the UDP satellite port and is
2416
+ * usually not open at all. Nothing to free on shutdown: the host unblocks our entries when the process exits
2417
+ * and drops them on the next start. Best effort — the registry is a hint, it must never keep a server down.
2418
+ */
2419
+ declarePort(type, port, bind, family) {
2420
+ const api = this;
2421
+ if (!api.registerUsedResource || !api.supportsFeature('CONTROLLER_USED_RESOURCES')) {
2422
+ return;
2423
+ }
2424
+ // A wildcard bind is left out: "not named" means every address, which is what 0.0.0.0/:: occupies.
2425
+ const data = { port };
2426
+ if (bind && bind !== '0.0.0.0' && bind !== '::') {
2427
+ data.bind = bind;
2428
+ }
2429
+ if (family) {
2430
+ data.family = family;
2431
+ }
2432
+ api.registerUsedResource(type, data).catch(e => this.log.debug(`could not register ${type} ${port}: ${e.message}`));
2433
+ }
1509
2434
  /** Writable per-instance data dir via the adapter-core function (with a fallback for old runtimes). */
1510
2435
  instanceDataDir() {
1511
2436
  try {
@@ -1635,11 +2560,13 @@ class Assistant extends adapter_core_1.Adapter {
1635
2560
  return null; // nothing matched, or one part cannot run here → let the LLM answer the whole thing
1636
2561
  }
1637
2562
  const answers = [];
2563
+ let failed = false;
1638
2564
  for (const intent of intents) {
1639
2565
  try {
1640
2566
  answers.push(await this.executeNluIntent(intent, source));
1641
2567
  }
1642
2568
  catch (e) {
2569
+ failed = true;
1643
2570
  // Nothing executed yet → hand the whole utterance to the LLM. Afterwards a state has already
1644
2571
  // been written and the LLM would repeat it, so report the failed part instead of bailing out.
1645
2572
  if (!answers.length) {
@@ -1652,7 +2579,19 @@ class Assistant extends adapter_core_1.Adapter {
1652
2579
  if (intents.length > 1) {
1653
2580
  this.log.debug(`NLU handled a combined command (${intents.length} parts).`);
1654
2581
  }
1655
- return answers.filter(Boolean).join(' ');
2582
+ const answer = answers.filter(Boolean).join(' ');
2583
+ // A switch command that worked needs no sentence: a beep says the same thing a second earlier.
2584
+ // Only for voice (a chat reads its answer), only for control intents (a query's answer IS the
2585
+ // information), and only when nothing failed (an error has to be spoken).
2586
+ if (this.config.confirmWithTone &&
2587
+ answer &&
2588
+ !failed &&
2589
+ intents.every(i => CONTROL_ACTIONS.has(i.action)) &&
2590
+ (await this.playConfirmationTone(source))) {
2591
+ this.log.debug(`NLU confirmed with a tone instead of "${answer}".`);
2592
+ return '';
2593
+ }
2594
+ return answer;
1656
2595
  }
1657
2596
  /**
1658
2597
  * Can this intent run locally at all? Checked for **every** part of a combined command before anything
@@ -1861,6 +2800,10 @@ class Assistant extends adapter_core_1.Adapter {
1861
2800
  if (intent.action === 'listByState') {
1862
2801
  return this.executeListByState(intent);
1863
2802
  }
2803
+ // Category query ("how is the air in here") — same idea, but reports measurements.
2804
+ if (intent.action === 'categoryQuery') {
2805
+ return this.executeCategoryQuery(intent);
2806
+ }
1864
2807
  const device = intent.device;
1865
2808
  if (!device) {
1866
2809
  return '';
@@ -1906,6 +2849,9 @@ class Assistant extends adapter_core_1.Adapter {
1906
2849
  }
1907
2850
  this.log.debug(`NLU control ${device.name} (${intent.action}): set_state ${intent.stateId} = ${JSON.stringify(intent.value)}`);
1908
2851
  await mcp.callTool('set_state', { id: intent.stateId, value: intent.value });
2852
+ if (!(await this.writeConfirmed(intent.stateId, intent.value))) {
2853
+ return pick(`${dev}${where} не ответило.`, `${dev}${where} hat nicht reagiert.`, `${dev}${where} did not respond.`);
2854
+ }
1909
2855
  // Secondary write: also flip the device's on/off switch when setting a level (e.g. dimmer that needs
1910
2856
  // an explicit power-on besides the level). Same device → covered by the write-ACL check above.
1911
2857
  if (intent.also) {
@@ -1924,10 +2870,57 @@ class Assistant extends adapter_core_1.Adapter {
1924
2870
  return pick(`Цвет ${dev}${where} установлен.`, `Farbe von ${dev}${where} gesetzt.`, `Color of ${dev}${where} set.`);
1925
2871
  }
1926
2872
  /**
1927
- * Aggregate query "which windows are open": read every window's state and name the open ones
1928
- * (respecting the per-device read ACL). Answers in the interaction language (de/en/ru).
2873
+ * Aggregate query "which windows are open": read every window's state and name the open ones
2874
+ * (respecting the per-device read ACL). Answers in the interaction language (de/en/ru).
2875
+ */
2876
+ async executeListByState(intent) {
2877
+ const mcp = this.mcp;
2878
+ if (!mcp) {
2879
+ throw new Error('mcp not ready');
2880
+ }
2881
+ const lang = String(this.config.voiceLanguage || this.language || 'en');
2882
+ const ru = lang === 'ru';
2883
+ const de = lang === 'de';
2884
+ const pick = (rus, ger, eng) => (ru ? rus : de ? ger : eng);
2885
+ // Each window's readable state (skip read-disabled devices), deduped.
2886
+ const acl = this.config.deviceAcl || {};
2887
+ const list = (intent.devices || [])
2888
+ .map(d => {
2889
+ const ids = Object.values(d.controls);
2890
+ return { name: d.name, room: d.room, key: (0, tools_1.deviceKey)(ids), stateId: ids[0] };
2891
+ })
2892
+ .filter(w => w.stateId && acl[w.key]?.read !== false);
2893
+ if (!list.length) {
2894
+ return pick('Окна не найдены.', 'Keine Fenster gefunden.', 'No windows found.');
2895
+ }
2896
+ let states = [];
2897
+ try {
2898
+ const res = await mcp.callTool('get_states', { ids: list.map(w => w.stateId) });
2899
+ states =
2900
+ JSON.parse(res.text).data?.states || [];
2901
+ }
2902
+ catch {
2903
+ states = [];
2904
+ }
2905
+ const valueById = new Map(states.map(s => [s.id, s.value]));
2906
+ // A window/contact sensor reports open as a truthy value.
2907
+ const isOpen = (v) => v === true || v === 'true' || v === 1 || v === 'open';
2908
+ const open = list.filter(w => isOpen(valueById.get(w.stateId)));
2909
+ if (!open.length) {
2910
+ return pick('Все окна закрыты.', 'Alle Fenster sind geschlossen.', 'All windows are closed.');
2911
+ }
2912
+ const names = open.map(w => (w.room ? `${w.name} (${w.room})` : w.name)).join(', ');
2913
+ return pick(`Открыты: ${names}.`, `Offen: ${names}.`, `Open: ${names}.`);
2914
+ }
2915
+ /**
2916
+ * Answer a question about a kind of measurement: read every device of that kind (optionally in one
2917
+ * room) and name the values. One device gives a short sentence, several give a list — "wie warm ist
2918
+ * es überall" is one question, not five.
2919
+ *
2920
+ * Air quality additionally gets a word for the number, because an IAQ of 85 means nothing to anyone
2921
+ * (the scale and the wording come from the Python original, `core/hannah/iobroker.py` `_iaq_label`).
1929
2922
  */
1930
- async executeListByState(intent) {
2923
+ async executeCategoryQuery(intent) {
1931
2924
  const mcp = this.mcp;
1932
2925
  if (!mcp) {
1933
2926
  throw new Error('mcp not ready');
@@ -1936,35 +2929,57 @@ class Assistant extends adapter_core_1.Adapter {
1936
2929
  const ru = lang === 'ru';
1937
2930
  const de = lang === 'de';
1938
2931
  const pick = (rus, ger, eng) => (ru ? rus : de ? ger : eng);
1939
- // Each window's readable state (skip read-disabled devices), deduped.
1940
2932
  const acl = this.config.deviceAcl || {};
1941
2933
  const list = (intent.devices || [])
1942
2934
  .map(d => {
1943
2935
  const ids = Object.values(d.controls);
1944
2936
  return { name: d.name, room: d.room, key: (0, tools_1.deviceKey)(ids), stateId: ids[0] };
1945
2937
  })
1946
- .filter(w => w.stateId && acl[w.key]?.read !== false);
2938
+ .filter(d => d.stateId && acl[d.key]?.read !== false);
1947
2939
  if (!list.length) {
1948
- return pick('Окна не найдены.', 'Keine Fenster gefunden.', 'No windows found.');
2940
+ return pick('Нет подходящих датчиков.', 'Keine passenden Sensoren gefunden.', 'No matching sensors found.');
1949
2941
  }
1950
2942
  let states = [];
1951
2943
  try {
1952
- const res = await mcp.callTool('get_states', { ids: list.map(w => w.stateId) });
2944
+ const res = await mcp.callTool('get_states', { ids: list.map(d => d.stateId) });
1953
2945
  states =
1954
2946
  JSON.parse(res.text).data?.states || [];
1955
2947
  }
1956
2948
  catch {
1957
2949
  states = [];
1958
2950
  }
1959
- const valueById = new Map(states.map(s => [s.id, s.value]));
1960
- // A window/contact sensor reports open as a truthy value.
1961
- const isOpen = (v) => v === true || v === 'true' || v === 1 || v === 'open';
1962
- const open = list.filter(w => isOpen(valueById.get(w.stateId)));
1963
- if (!open.length) {
1964
- return pick('Все окна закрыты.', 'Alle Fenster sind geschlossen.', 'All windows are closed.');
2951
+ const valueById = new Map(states.map(st => [st.id, st.value]));
2952
+ const parts = [];
2953
+ for (const device of list) {
2954
+ const value = valueById.get(device.stateId);
2955
+ if (value === undefined || value === null) {
2956
+ continue; // a sensor without a reading adds nothing to a spoken answer
2957
+ }
2958
+ let unit = '';
2959
+ try {
2960
+ const obj = await this.getForeignObjectAsync(device.stateId);
2961
+ unit = obj?.common?.unit || '';
2962
+ }
2963
+ catch {
2964
+ /* no unit */
2965
+ }
2966
+ let text = this.describeValue(value, lang, unit);
2967
+ if (intent.category === 'airQuality') {
2968
+ const label = iaqLabel(value, unit, lang);
2969
+ if (label) {
2970
+ text = `${text} (${label})`;
2971
+ }
2972
+ }
2973
+ // In one room the room name is already given; across rooms it is the useful half.
2974
+ const where = intent.room ? device.name : device.room || device.name;
2975
+ parts.push(`${where}: ${text}`);
1965
2976
  }
1966
- const names = open.map(w => (w.room ? `${w.name} (${w.room})` : w.name)).join(', ');
1967
- return pick(`Открыты: ${names}.`, `Offen: ${names}.`, `Open: ${names}.`);
2977
+ if (!parts.length) {
2978
+ return pick('Нет данных.', 'Dazu liegen keine Werte vor.', 'There are no readings for that.');
2979
+ }
2980
+ const where = intent.room ? ` (${intent.room})` : '';
2981
+ const heading = CATEGORY_LABELS[intent.category || '']?.[ru ? 'ru' : de ? 'de' : 'en'] || '';
2982
+ return `${heading}${where}: ${parts.join(', ')}.`;
1968
2983
  }
1969
2984
  /** Human-readable rendering of a state value for NLU query responses (spoken aloud). */
1970
2985
  describeValue(value, lang, unit = '') {
@@ -2003,8 +3018,14 @@ class Assistant extends adapter_core_1.Adapter {
2003
3018
  this.timers = new timers_1.TimerManager({
2004
3019
  now: () => Date.now(),
2005
3020
  log: this.log,
2006
- onFire: t => this.onTimerFired(t),
2007
- onChange: list => void this.renderTimers(list).catch(e => this.log.debug(`renderTimers: ${e}`)),
3021
+ onFire: t => {
3022
+ this.mirrorTimer(t, 'finished');
3023
+ this.onTimerFired(t);
3024
+ },
3025
+ onChange: list => {
3026
+ this.mirrorTimerList(list);
3027
+ void this.renderTimers(list).catch(e => this.log.debug(`renderTimers: ${e}`));
3028
+ },
2008
3029
  });
2009
3030
  // Cancel controls: the global "cancel all" and each per-timer `.cancel` button.
2010
3031
  this.subscribeStates('timers.cancelAll');
@@ -2014,6 +3035,50 @@ class Assistant extends adapter_core_1.Adapter {
2014
3035
  this.timerObjIds.clear();
2015
3036
  await this.restoreTimers();
2016
3037
  }
3038
+ /**
3039
+ * Push the active timer list to the ESPHome satellites, which show a running timer on their own LED
3040
+ * ring and ring it themselves (feature flag TIMERS). The device keeps its own copy, so it only needs
3041
+ * the transitions: a timer it has not seen is `started`, one that vanished without firing was
3042
+ * `cancelled`, and {@link mirrorTimer} reports `finished` from the fire callback.
3043
+ *
3044
+ * A timer is mirrored to the satellite it was set from, or broadcast when it came from elsewhere
3045
+ * (chat, telegram, a script) — otherwise setting a timer by text would leave every speaker silent.
3046
+ */
3047
+ mirrorTimerList(list) {
3048
+ if (!this.esphome) {
3049
+ return;
3050
+ }
3051
+ const live = new Map(list.map(t => [t.id, t]));
3052
+ for (const [id, previous] of this.mirroredTimers) {
3053
+ if (!live.has(id)) {
3054
+ this.mirrorTimer(previous, 'cancelled');
3055
+ }
3056
+ }
3057
+ for (const timer of list) {
3058
+ this.mirrorTimer(timer, this.mirroredTimers.has(timer.id) ? 'updated' : 'started');
3059
+ }
3060
+ this.mirroredTimers = live;
3061
+ }
3062
+ /** Send one timer transition to the satellite it belongs to (or all, when its origin is not one). */
3063
+ mirrorTimer(timer, type) {
3064
+ if (!this.esphome) {
3065
+ return;
3066
+ }
3067
+ const known = this.esphome.devices();
3068
+ const target = known.includes(timer.source) ? timer.source : null;
3069
+ const event = {
3070
+ type,
3071
+ id: timer.id,
3072
+ name: timer.label || '',
3073
+ totalSeconds: timer.duration,
3074
+ secondsLeft: Math.max(0, Math.round((timer.fireAt - Date.now()) / 1000)),
3075
+ active: type === 'started' || type === 'updated',
3076
+ };
3077
+ this.esphome.timerEvent(target, event);
3078
+ if (type === 'cancelled' || type === 'finished') {
3079
+ this.mirroredTimers.delete(timer.id);
3080
+ }
3081
+ }
2017
3082
  /** Restore timers persisted in `timers.list` across a restart (future ones rescheduled, expired dropped). */
2018
3083
  async restoreTimers() {
2019
3084
  if (!this.timers) {
@@ -2145,6 +3210,149 @@ class Assistant extends adapter_core_1.Adapter {
2145
3210
  }
2146
3211
  return null;
2147
3212
  }
3213
+ /**
3214
+ * Resolve an announcement target to satellite state ids: `null` means every satellite (an empty name
3215
+ * or `all`), an empty array means the name is unknown — the caller decides whether that is an error
3216
+ * (`askUser`) or a warning (an announcement).
3217
+ *
3218
+ * A concrete satellite or room wins over a configured group of the same name, so naming a group after
3219
+ * a room can never make that room's speaker unreachable (the rule comes from the Python original,
3220
+ * `core/main.py:717`).
3221
+ */
3222
+ resolveTargets(target) {
3223
+ if ((0, targets_1.isBroadcast)(target)) {
3224
+ return null;
3225
+ }
3226
+ const wanted = String(target).trim();
3227
+ const direct = this.resolveSatelliteId(wanted);
3228
+ if (direct) {
3229
+ return [direct];
3230
+ }
3231
+ const group = (0, targets_1.findTarget)(wanted, this.announceTargets);
3232
+ if (!group) {
3233
+ return [];
3234
+ }
3235
+ const ids = [...new Set(group.members.map(m => this.resolveSatelliteId(m)).filter((id) => !!id))];
3236
+ if (!ids.length) {
3237
+ this.log.warn(`Target '${group.name}' has no known satellite among: ${group.members.join(', ')}`);
3238
+ }
3239
+ else if (ids.length < group.members.length) {
3240
+ this.log.debug(`Target '${group.name}' → ${ids.join(', ')} (some members are not known satellites)`);
3241
+ }
3242
+ return ids;
3243
+ }
3244
+ /**
3245
+ * Resolve what a caller named — a satellite state id (`satellites.kitchen`, `kitchen`), a room name or
3246
+ * a device name — to a known satellite state id, or null if we have never seen it. Rooms resolve like
3247
+ * ids because a satellite's state id *is* its sanitised room name (see {@link satelliteStateId}).
3248
+ */
3249
+ resolveSatelliteId(nameOrId) {
3250
+ const raw = (nameOrId || '').trim();
3251
+ if (!raw) {
3252
+ return null;
3253
+ }
3254
+ const bare = raw.replace(`${this.namespace}.`, '').replace(/^satellites\./, '');
3255
+ const id = this.satelliteStateId(bare, '');
3256
+ if (this.satDeviceById.has(id) || this.nativeSatFrom.has(id)) {
3257
+ return id;
3258
+ }
3259
+ // Not an id — maybe the device name behind one.
3260
+ return this.announceTargetForSource(raw);
3261
+ }
3262
+ /**
3263
+ * Ask the user something and wait for the answer: speak the question on the target satellite(s), have
3264
+ * them re-open the microphone, and resolve with whatever is said next there — the utterance is routed
3265
+ * to the caller instead of to the NLU/LLM (see {@link PendingQuestions}).
3266
+ *
3267
+ * `target`/`room` pick a satellite (omit both → every satellite, first answer wins). `source` instead
3268
+ * arms a text channel (e.g. `'chat'`, `'telegram:Max'`) without speaking anything: there the caller
3269
+ * sends the question itself and we only claim the reply.
3270
+ *
3271
+ * This is what the proactive triggers (roadmap A2) will use, and it is available to scripts:
3272
+ * `sendTo('assistant.0', 'askUser', { question: 'Fenster schließen?', room: 'Küche' }, cb)`.
3273
+ */
3274
+ async askUser(msg) {
3275
+ const question = String(msg.question ?? msg.text ?? '').trim();
3276
+ if (!question) {
3277
+ return { error: 'no question provided' };
3278
+ }
3279
+ const timeoutMs = Math.max(1000, Number(msg.timeoutMs) || ask_1.ASK_TIMEOUT_MS);
3280
+ // Text channel: arm the named sources only — the caller does its own output.
3281
+ const sources = (Array.isArray(msg.source) ? msg.source : msg.source ? [msg.source] : [])
3282
+ .map(s => String(s).trim())
3283
+ .filter(Boolean);
3284
+ if (sources.length) {
3285
+ this.log.info(`askUser (waiting on ${sources.join(', ')}, ${timeoutMs} ms): ${question}`);
3286
+ const answer = await this.pending.ask(sources, question, timeoutMs);
3287
+ return answer === null ? { question, timeout: true } : { question, answer };
3288
+ }
3289
+ // Voice: resolve the target before arming, so a typo doesn't leave a question hanging. A group
3290
+ // or a person resolves to several satellites — the question is asked on all of them and the
3291
+ // first answer counts, which is what asking a room full of speakers means.
3292
+ const target = (msg.target || msg.room || '').trim();
3293
+ const targetIds = this.resolveTargets(target);
3294
+ if (targetIds && !targetIds.length) {
3295
+ return { error: `unknown satellite, room or group '${target}'` };
3296
+ }
3297
+ const keys = targetIds ? targetIds.map(id => this.satDeviceById.get(id) || id) : [ask_1.ANY_SOURCE];
3298
+ this.log.info(`askUser → ${targetIds ? targetIds.join(', ') : 'all satellites'} (${timeoutMs} ms): ${question}`);
3299
+ if (!(await this.announceToSatellites(question, target || null, { listen: true }))) {
3300
+ return { error: 'question not asked — no satellite reachable' };
3301
+ }
3302
+ // Armed only now, on purpose: the answer cannot arrive before the device has played the question,
3303
+ // so arming earlier would just risk claiming an utterance that was never meant as the answer.
3304
+ const answer = await this.pending.ask(keys, question, timeoutMs);
3305
+ if (answer === null) {
3306
+ this.log.info(`askUser: no answer within ${timeoutMs} ms.`);
3307
+ return { question, timeout: true };
3308
+ }
3309
+ return { question, answer };
3310
+ }
3311
+ /**
3312
+ * Wait for the device to confirm a write, if the user asked for that (`verifyWrites`). ioBroker's
3313
+ * convention is that a command is written with `ack:false` and the device echoes the value back with
3314
+ * `ack:true` once it really happened — so without this check the assistant reports success for a
3315
+ * lamp that never answered.
3316
+ *
3317
+ * Polled rather than subscribed: a one-off poll of one state is cheap, while a temporary
3318
+ * subscription would have to be registered, routed through `onStateChange` and torn down again for
3319
+ * every single command. Returns true when it is confirmed — and also when verification is switched
3320
+ * off or impossible, because a check that cannot run must not turn into a false alarm.
3321
+ */
3322
+ async writeConfirmed(stateId, expected) {
3323
+ if (!this.config.verifyWrites || !stateId) {
3324
+ return true;
3325
+ }
3326
+ const deadline = Date.now() + ACK_TIMEOUT_MS;
3327
+ let last;
3328
+ while (Date.now() < deadline) {
3329
+ await this.delay(ACK_POLL_MS);
3330
+ last = await this.getForeignStateAsync(stateId).catch(() => null);
3331
+ if (last?.ack && valuesMatch(last.val, expected)) {
3332
+ this.log.debug(`write to ${stateId} confirmed by the device.`);
3333
+ return true;
3334
+ }
3335
+ }
3336
+ this.log.warn(`No confirmation for ${stateId} within ${ACK_TIMEOUT_MS} ms ` +
3337
+ `(last: ${JSON.stringify(last?.val)}, ack=${String(last?.ack)}). ` +
3338
+ 'If this device never acknowledges, switch "Verify device feedback" off.');
3339
+ return false;
3340
+ }
3341
+ /**
3342
+ * Play the confirmation beep on the satellite a command came from. Returns false when there is nowhere
3343
+ * to play it (a text channel, or no satellite reachable) — the caller then speaks the reply instead,
3344
+ * because a confirmation nobody hears is worse than one that costs a TTS call.
3345
+ */
3346
+ async playConfirmationTone(source) {
3347
+ const target = this.announceTargetForSource(source);
3348
+ if (!target) {
3349
+ return false; // chat/telegram/unknown origin: there is no speaker to beep on
3350
+ }
3351
+ const { pcm, sampleRate } = (0, tone_1.confirmationTone)();
3352
+ // Priority: this is the answer to something the user just asked for, not an unsolicited noise.
3353
+ const delivered = await this.deliverPcm(pcm, sampleRate, target, true);
3354
+ return delivered > 0;
3355
+ }
2148
3356
  /** Execute a timer intent (set/query/cancel) from the NLU and return a spoken-style reply. */
2149
3357
  executeTimerIntent(intent, source) {
2150
3358
  const mgr = this.timers;
@@ -2560,6 +3768,421 @@ class Assistant extends adapter_core_1.Adapter {
2560
3768
  },
2561
3769
  ];
2562
3770
  }
3771
+ // ── Announcements by name (roadmap B2) ──────────────────────────────────
3772
+ /**
3773
+ * Tool that lets the model speak somewhere instead of answering: "tell Denis dinner is ready", "let
3774
+ * everyone upstairs know". Without it a model can read and switch states but has no way to make a
3775
+ * speaker say something, so the configured groups and people would only be reachable from scripts.
3776
+ *
3777
+ * The configured names go into the description, because the model cannot discover them otherwise.
3778
+ */
3779
+ buildAnnounceTool() {
3780
+ const named = (0, targets_1.describeTargets)(this.announceTargets);
3781
+ return {
3782
+ name: 'announce',
3783
+ description: `Speak a message out loud on a voice satellite — use it when the user asks you to tell ` +
3784
+ `somebody something, or to announce something in a room. Not for answering the user: your ` +
3785
+ `normal reply is already spoken. "target" is a room, a satellite name${named ? `, or one of the configured ${named}` : ''}; leave it out to announce everywhere.`,
3786
+ parameters: {
3787
+ type: 'object',
3788
+ properties: {
3789
+ text: { type: 'string', description: 'What to say, as one spoken sentence' },
3790
+ target: { type: 'string', description: 'Room, satellite, group or person (empty = all)' },
3791
+ },
3792
+ required: ['text'],
3793
+ additionalProperties: false,
3794
+ },
3795
+ run: async (args) => {
3796
+ // The arguments come from the model, so a non-string is simply not a text.
3797
+ const text = typeof args.text === 'string' ? args.text.trim() : '';
3798
+ if (!text) {
3799
+ return JSON.stringify({ ok: false, error: 'no text given' });
3800
+ }
3801
+ const target = typeof args.target === 'string' ? args.target.trim() : '';
3802
+ const spoken = await this.announceToSatellites(text, target || null);
3803
+ return spoken
3804
+ ? JSON.stringify({ ok: true, data: { spoken } })
3805
+ : JSON.stringify({
3806
+ ok: false,
3807
+ error: target
3808
+ ? `nothing was played — '${target}' is no known satellite, room or group, or it is offline`
3809
+ : 'nothing was played — no satellite is reachable',
3810
+ });
3811
+ },
3812
+ };
3813
+ }
3814
+ // ── Presence: who is at home (roadmap B1) ───────────────────────────────
3815
+ /**
3816
+ * Build the presence tracker from the configured states, subscribe to them and mirror the result into
3817
+ * `presence.*`. The values are read once up front, because a presence state only reports when it
3818
+ * changes — without that, the assistant would believe nobody is home until the first person moves.
3819
+ */
3820
+ async setupPresence() {
3821
+ const entries = (0, presence_1.parsePresenceRows)(this.config.presence);
3822
+ this.presence = new presence_1.PresenceTracker({
3823
+ entries,
3824
+ log: this.log,
3825
+ onChange: list => void this.renderPresence(list).catch(e => this.log.debug(`renderPresence: ${e}`)),
3826
+ onArrival: who => {
3827
+ this.setStateAsync('presence.lastArrival', { val: who.name, ack: true }).catch(() => { });
3828
+ },
3829
+ onDeparture: who => {
3830
+ this.setStateAsync('presence.lastDeparture', { val: who.name, ack: true }).catch(() => { });
3831
+ },
3832
+ });
3833
+ if (!entries.length) {
3834
+ return;
3835
+ }
3836
+ for (const id of this.presence.stateIds()) {
3837
+ await this.subscribeForeignStatesAsync(id).catch(e => this.log.warn(`presence: cannot watch '${id}': ${e.message}`));
3838
+ const st = await this.getForeignStateAsync(id).catch(() => null);
3839
+ this.presence.update(id, st?.val ?? null);
3840
+ }
3841
+ await this.renderPresence(this.presence.list());
3842
+ this.log.info(`Presence: ${entries.length} source(s) configured, ${this.presence.home().length} at home right now.`);
3843
+ }
3844
+ /** Mirror who is at home into `presence.{anyoneHome,count,list}`. */
3845
+ async renderPresence(list) {
3846
+ const home = list.filter(p => p.home === true && p.kind !== 'pet');
3847
+ await this.setStateAsync('presence.anyoneHome', { val: home.length > 0, ack: true });
3848
+ await this.setStateAsync('presence.count', { val: home.length, ack: true });
3849
+ await this.setStateAsync('presence.list', { val: JSON.stringify(list), ack: true });
3850
+ }
3851
+ /**
3852
+ * Who is at home, as one line for the user turn — never for the (prompt-cached) system prompt: it
3853
+ * changes with every arrival and would bust the cache, device list included. Empty when no presence
3854
+ * source is configured or nothing is known yet.
3855
+ */
3856
+ buildPresenceContext() {
3857
+ if (!this.presence?.configured) {
3858
+ return '';
3859
+ }
3860
+ return (0, presence_1.buildPresencePrompt)(this.presence.list(), String(this.config.voiceLanguage || this.language || 'en'));
3861
+ }
3862
+ /**
3863
+ * Should an announcement that asked to be spoken only to an occupied house be held back? With no
3864
+ * presence source configured the answer is no — "nobody configured" must not read as "nobody home",
3865
+ * or the feature would silence every announcement the moment someone ticks the box.
3866
+ */
3867
+ emptyHouse() {
3868
+ return this.presence?.configured === true && !this.presence.anyoneHome();
3869
+ }
3870
+ // ── System notifications (roadmap A3) ───────────────────────────────────
3871
+ /**
3872
+ * Speak a system notification. The text is cleaned of ioBroker's origin prefixes and — unless the
3873
+ * severity is `direct` or rewording is switched off — reworded by the LLM into one spoken sentence
3874
+ * with a tone matching the severity, because the raw texts are written for a log viewer.
3875
+ *
3876
+ * An `alert` is delivered as a **priority** announcement, so it is heard even on a satellite set to
3877
+ * Do-Not-Disturb; everything else respects DND. Returns how many channels it reached.
3878
+ */
3879
+ async notify(raw, severity, target = null, onlyWhenHome = false) {
3880
+ const clean = (0, notifications_1.cleanupNotificationText)(raw);
3881
+ if (!clean) {
3882
+ return 0;
3883
+ }
3884
+ let text = clean;
3885
+ if (severity !== 'direct' && this.config.notifyRephrase !== false && this.agent) {
3886
+ text = await this.agent.rewordNotification(clean, (0, notifications_1.toneFor)(severity), this.config.voiceLanguage || this.language || '', this.config.systemPrompt);
3887
+ }
3888
+ this.log.info(`Notification (${severity}): ${text}`);
3889
+ this.setStateAsync('notify.last', { val: text, ack: true }).catch(() => { });
3890
+ return this.announceToSatellites(text, target, { priority: (0, notifications_1.bypassesDnd)(severity), onlyWhenHome });
3891
+ }
3892
+ /**
3893
+ * Handle a notification routed here by the ioBroker notification-manager (`sendNotification` message,
3894
+ * advertised through `common.supportedMessages.notifications`). Answers `{ sent }`, which tells the
3895
+ * manager whether it may mark the notification as handled — so we only claim it when it was actually
3896
+ * spoken somewhere.
3897
+ *
3898
+ * The payload shape belongs to the notification-manager and is parsed defensively
3899
+ * ({@link flattenNotification}): a changed field degrades the spoken sentence, it never throws.
3900
+ */
3901
+ async handleSystemNotification(message) {
3902
+ const flat = (0, notifications_1.flattenNotification)(message, String(this.config.voiceLanguage || this.language || 'en'));
3903
+ if (!flat.text) {
3904
+ this.log.warn('Notification from the notification-manager had nothing to speak.');
3905
+ return { sent: false };
3906
+ }
3907
+ this.log.debug(`Notification (${flat.severity}) from category '${flat.category}': ${flat.text}`);
3908
+ const delivered = await this.notify(flat.text, flat.severity);
3909
+ return { sent: delivered > 0 };
3910
+ }
3911
+ // ── Proactive triggers (roadmap A2) ─────────────────────────────────────
3912
+ /**
3913
+ * Build the trigger engine from the configured definitions, mirror it into `triggers.*` and subscribe
3914
+ * to exactly the foreign states the conditions reference — nothing broader, because a wildcard
3915
+ * subscription on a busy system would wake this adapter for every state in the house.
3916
+ */
3917
+ async setupTriggers() {
3918
+ this.triggers = new triggers_1.TriggerEngine({
3919
+ now: () => Date.now(),
3920
+ log: this.log,
3921
+ getState: async (id) => {
3922
+ const st = await this.getForeignStateAsync(id).catch(() => null);
3923
+ return st?.val ?? undefined;
3924
+ },
3925
+ execute: def => this.executeTrigger(def),
3926
+ onChange: status => void this.renderTriggers(status).catch(e => this.log.debug(`renderTriggers: ${e}`)),
3927
+ });
3928
+ this.subscribeStates('triggers.enabled');
3929
+ this.subscribeStates('triggers.items.*');
3930
+ // Per-trigger objects are rebuilt from the configuration, so drop whatever a previous run left.
3931
+ await this.delObjectAsync('triggers.items', { recursive: true }).catch(() => { });
3932
+ this.triggerObjIds.clear();
3933
+ const master = await this.getStateAsync('triggers.enabled');
3934
+ this.triggersEnabled = master?.val !== false;
3935
+ const persisted = await this.readTriggerStatus();
3936
+ this.triggers.load(this.getTriggerDefs());
3937
+ if (persisted.length) {
3938
+ this.triggers.restore(persisted);
3939
+ }
3940
+ await this.syncTriggerSubscriptions();
3941
+ // Remember the current values before the first change arrives, so a device that re-reports an
3942
+ // unchanged value right after the start does not look like a transition.
3943
+ await this.triggers.prime();
3944
+ const n = this.triggers.list().length;
3945
+ if (n) {
3946
+ this.log.info(`Triggers: ${n} loaded, watching ${this.triggerStateIds.size} state(s)${this.triggersEnabled ? '' : ' — all suppressed by triggers.enabled'}.`);
3947
+ }
3948
+ }
3949
+ /** The configured triggers, with the table's JSON columns parsed (see {@link parseTriggerRows}). */
3950
+ getTriggerDefs() {
3951
+ return (0, triggers_1.parseTriggerRows)(this.config.triggers, {
3952
+ rephraseDefault: this.config.triggerRephrase === true,
3953
+ warn: message => this.log.warn(message),
3954
+ });
3955
+ }
3956
+ /** Subscribe to the states the loaded triggers watch, and drop subscriptions nothing watches any more. */
3957
+ async syncTriggerSubscriptions() {
3958
+ const wanted = new Set(this.triggers?.stateIds() ?? []);
3959
+ // A presence source may be the same state a trigger watches — dropping the subscription here would
3960
+ // silently stop the presence updates too.
3961
+ const keep = new Set(this.presence?.stateIds() ?? []);
3962
+ for (const id of [...this.triggerStateIds]) {
3963
+ if (!wanted.has(id)) {
3964
+ if (!keep.has(id)) {
3965
+ await this.unsubscribeForeignStatesAsync(id).catch(() => { });
3966
+ }
3967
+ this.triggerStateIds.delete(id);
3968
+ }
3969
+ }
3970
+ for (const id of wanted) {
3971
+ if (!this.triggerStateIds.has(id)) {
3972
+ await this.subscribeForeignStatesAsync(id).catch(e => this.log.warn(`trigger: cannot watch '${id}': ${e.message}`));
3973
+ this.triggerStateIds.add(id);
3974
+ }
3975
+ }
3976
+ }
3977
+ /** Live status (enabled flag, last fire) persisted in `triggers.list`, so a restart keeps both. */
3978
+ async readTriggerStatus() {
3979
+ try {
3980
+ const st = await this.getStateAsync('triggers.list');
3981
+ if (typeof st?.val === 'string' && st.val) {
3982
+ const arr = JSON.parse(st.val);
3983
+ return Array.isArray(arr) ? arr : [];
3984
+ }
3985
+ }
3986
+ catch (e) {
3987
+ this.log.debug(`readTriggerStatus failed: ${e.message}`);
3988
+ }
3989
+ return [];
3990
+ }
3991
+ /** Mirror the trigger status into the summary states and the per-trigger `triggers.items.<id>` objects. */
3992
+ async renderTriggers(status) {
3993
+ await this.setStateAsync('triggers.count', { val: status.length, ack: true });
3994
+ await this.setStateAsync('triggers.list', { val: JSON.stringify(status), ack: true });
3995
+ const wanted = new Set(status.map(t => t.id));
3996
+ for (const id of [...this.triggerObjIds]) {
3997
+ if (!wanted.has(id)) {
3998
+ await this.delObjectAsync(`triggers.items.${id}`, { recursive: true }).catch(() => { });
3999
+ this.triggerObjIds.delete(id);
4000
+ }
4001
+ }
4002
+ if (status.length) {
4003
+ await this.setObjectNotExistsAsync('triggers.items', {
4004
+ type: 'channel',
4005
+ common: { name: 'Triggers' },
4006
+ native: {},
4007
+ });
4008
+ }
4009
+ for (const t of status) {
4010
+ await this.ensureTriggerObject(t);
4011
+ }
4012
+ }
4013
+ /** Create (once) and update the `triggers.items.<id>.*` states for a single trigger. */
4014
+ async ensureTriggerObject(t) {
4015
+ const base = `triggers.items.${t.id}`;
4016
+ if (!this.triggerObjIds.has(t.id)) {
4017
+ await this.setObjectNotExistsAsync(base, {
4018
+ type: 'channel',
4019
+ common: { name: t.name || t.id },
4020
+ native: {},
4021
+ });
4022
+ const mk = (sub, common) => this.setObjectNotExistsAsync(`${base}.${sub}`, { type: 'state', common, native: {} });
4023
+ await mk('name', { name: 'Name', type: 'string', role: 'text', read: true, write: false });
4024
+ await mk('lastFired', { name: 'Last fired', type: 'number', role: 'value.time', read: true, write: false });
4025
+ await mk('nextFireAt', {
4026
+ name: 'Next scheduled fire (time triggers)',
4027
+ type: 'number',
4028
+ role: 'value.time',
4029
+ read: true,
4030
+ write: false,
4031
+ });
4032
+ await mk('pendingUntil', {
4033
+ name: 'Pending delay runs at (0 = nothing pending)',
4034
+ type: 'number',
4035
+ role: 'value.time',
4036
+ read: true,
4037
+ write: false,
4038
+ });
4039
+ await mk('enabled', {
4040
+ name: 'Enabled',
4041
+ type: 'boolean',
4042
+ role: 'switch.enable',
4043
+ read: true,
4044
+ write: true,
4045
+ def: true,
4046
+ });
4047
+ await mk('fire', {
4048
+ name: 'Run this trigger now (ignores cooldown and delay)',
4049
+ type: 'boolean',
4050
+ role: 'button',
4051
+ read: false,
4052
+ write: true,
4053
+ def: false,
4054
+ });
4055
+ this.triggerObjIds.add(t.id);
4056
+ }
4057
+ await this.setStateAsync(`${base}.name`, { val: t.name, ack: true });
4058
+ await this.setStateAsync(`${base}.lastFired`, { val: t.lastFired, ack: true });
4059
+ await this.setStateAsync(`${base}.nextFireAt`, { val: t.nextFireAt, ack: true });
4060
+ await this.setStateAsync(`${base}.pendingUntil`, { val: t.pendingUntil, ack: true });
4061
+ await this.setStateAsync(`${base}.enabled`, { val: t.enabled, ack: true });
4062
+ }
4063
+ /**
4064
+ * Run a trigger: either ask the user and act on the answer, or run its actions. The master switch
4065
+ * (`triggers.enabled`) is checked here rather than in the engine, so the schedule keeps running and
4066
+ * only the visible/audible effect is suppressed.
4067
+ */
4068
+ async executeTrigger(def) {
4069
+ if (!this.triggersEnabled) {
4070
+ this.log.debug(`trigger '${def.id}' suppressed — triggers.enabled is off`);
4071
+ return;
4072
+ }
4073
+ this.setStateAsync('triggers.lastFired', { val: def.name || def.id, ack: true }).catch(() => { });
4074
+ if (def.ask) {
4075
+ await this.askTrigger(def);
4076
+ return;
4077
+ }
4078
+ for (const action of (0, triggers_1.effectiveActions)(def)) {
4079
+ await this.runTriggerAction(def, action);
4080
+ }
4081
+ }
4082
+ /**
4083
+ * Where a trigger speaks or asks. Empty (or Hannah's `all`, which people copy from its examples) means
4084
+ * every satellite; a configured room we do not know is worth a warning, because announcing everywhere
4085
+ * instead of in one room is the kind of surprise that should not stay silent in the log.
4086
+ */
4087
+ triggerTarget(def, room) {
4088
+ if ((0, targets_1.isBroadcast)(room)) {
4089
+ return null;
4090
+ }
4091
+ const wanted = String(room).trim();
4092
+ const ids = this.resolveTargets(wanted);
4093
+ if (ids && !ids.length) {
4094
+ this.log.warn(`trigger '${def.id}': '${wanted}' is no known satellite, room or group — using all of them`);
4095
+ return null;
4096
+ }
4097
+ return wanted;
4098
+ }
4099
+ /** Ask the trigger's question, then let the first matching response rule decide what happens. */
4100
+ async askTrigger(def) {
4101
+ const question = await this.triggerText(def, def.ask || '');
4102
+ const target = this.triggerTarget(def, def.room);
4103
+ const result = await this.askUser({ question, target: target || undefined });
4104
+ if (result.error) {
4105
+ this.log.warn(`trigger '${def.id}': could not ask — ${result.error}`);
4106
+ return;
4107
+ }
4108
+ if (result.timeout || !result.answer) {
4109
+ this.log.info(`trigger '${def.id}': no answer — nothing done`);
4110
+ return;
4111
+ }
4112
+ const rules = def.onResponse || [];
4113
+ let fallback;
4114
+ for (const rule of rules) {
4115
+ const want = (rule.match || '').trim();
4116
+ if (!want) {
4117
+ fallback ??= rule; // a rule without a category is the "didn't understand" case
4118
+ continue;
4119
+ }
4120
+ if (!this.agent) {
4121
+ this.log.warn(`trigger '${def.id}': no LLM to match the answer against '${want}'`);
4122
+ break;
4123
+ }
4124
+ if (await this.agent.classify(result.answer, want)) {
4125
+ this.log.info(`trigger '${def.id}': answer matched '${want}'`);
4126
+ await this.runTriggerAction(def, rule);
4127
+ return;
4128
+ }
4129
+ }
4130
+ if (fallback) {
4131
+ await this.runTriggerAction(def, fallback);
4132
+ }
4133
+ else {
4134
+ this.log.info(`trigger '${def.id}': answer "${result.answer}" matched no rule`);
4135
+ }
4136
+ }
4137
+ /** Speak a trigger's `say` and/or write its `setState` — shared by the actions and the response rules. */
4138
+ async runTriggerAction(def, action, fallbackTarget = null) {
4139
+ const say = (action.say || '').trim();
4140
+ if (say) {
4141
+ const room = 'room' in action ? action.room : def.room;
4142
+ // A routine speaks where it was asked for, unless the action names a room of its own.
4143
+ const target = this.triggerTarget(def, room) ?? fallbackTarget;
4144
+ await this.announceToSatellites(await this.triggerText(def, say), target);
4145
+ }
4146
+ const write = action.setState;
4147
+ if (write?.id) {
4148
+ // Deliberately gated: someone who switched device control off does not expect a trigger to
4149
+ // write states behind that setting.
4150
+ if (!this.config.allowWriteStates) {
4151
+ this.log.warn(`trigger '${def.id}': not writing ${write.id} — device control is disabled in the settings`);
4152
+ return;
4153
+ }
4154
+ try {
4155
+ await this.setForeignStateAsync(write.id, { val: write.value, ack: false });
4156
+ this.log.info(`trigger '${def.id}': ${write.id} = ${JSON.stringify(write.value)}`);
4157
+ }
4158
+ catch (e) {
4159
+ this.log.warn(`trigger '${def.id}': cannot write ${write.id}: ${e.message}`);
4160
+ }
4161
+ }
4162
+ }
4163
+ /**
4164
+ * Run a routine's actions. They are {@link TriggerAction}s, so this borrows the trigger executor — a
4165
+ * routine really is a trigger whose condition is a spoken phrase. A failing action is logged and the
4166
+ * rest still runs: most of a "good night" is better than none of it.
4167
+ */
4168
+ async runRoutine(routine, source) {
4169
+ const origin = this.announceTargetForSource(source);
4170
+ for (const action of routine.actions) {
4171
+ try {
4172
+ await this.runTriggerAction({ id: `routine:${routine.name}`, when: [] }, action, origin);
4173
+ }
4174
+ catch (e) {
4175
+ this.log.warn(`Routine '${routine.name}': an action failed — ${e.message}`);
4176
+ }
4177
+ }
4178
+ }
4179
+ /** A trigger's text, optionally reworded by the LLM so a recurring announcement doesn't sound canned. */
4180
+ async triggerText(def, text) {
4181
+ if (!def.rephrase || !this.agent || !text) {
4182
+ return text;
4183
+ }
4184
+ return this.agent.rephrase(text, this.config.voiceLanguage || this.language || '', this.config.systemPrompt);
4185
+ }
2563
4186
  // ── Long-term memory (roadmap #6) ───────────────────────────────────────
2564
4187
  /** Create the memory store and mirror it into `memory.*` (+ per-entry `memory.items.<id>.*`). */
2565
4188
  async setupMemory() {
@@ -3147,11 +4770,14 @@ class Assistant extends adapter_core_1.Adapter {
3147
4770
  async getModels(msg) {
3148
4771
  const cfg = this.config;
3149
4772
  const provider = msg.provider || cfg.provider || 'openai';
3150
- const apiKey = await (0, credentials_1.resolveApiKey)(this, cfg, {
4773
+ if (provider === 'none') {
4774
+ return [];
4775
+ }
4776
+ const apiKey = (0, llm_1.effectiveApiKey)(provider, await (0, credentials_1.resolveApiKey)(this, cfg, {
3151
4777
  credentialType: msg.credentialType,
3152
4778
  apiKey: msg.apiKey,
3153
4779
  credentialId: msg.credentialId,
3154
- });
4780
+ }));
3155
4781
  if (!apiKey) {
3156
4782
  return [];
3157
4783
  }
@@ -3176,11 +4802,14 @@ class Assistant extends adapter_core_1.Adapter {
3176
4802
  async testApiConnection(msg) {
3177
4803
  const cfg = this.config;
3178
4804
  const provider = msg.provider || cfg.provider || 'openai';
3179
- const apiKey = await (0, credentials_1.resolveApiKey)(this, cfg, {
4805
+ if (provider === 'none') {
4806
+ return { error: 'No LLM provider selected.' };
4807
+ }
4808
+ const apiKey = (0, llm_1.effectiveApiKey)(provider, await (0, credentials_1.resolveApiKey)(this, cfg, {
3180
4809
  credentialType: msg.credentialType,
3181
4810
  apiKey: msg.apiKey,
3182
4811
  credentialId: msg.credentialId,
3183
- });
4812
+ }));
3184
4813
  if (!apiKey) {
3185
4814
  return { error: 'No API key / credential available.' };
3186
4815
  }
@@ -3204,6 +4833,13 @@ class Assistant extends adapter_core_1.Adapter {
3204
4833
  catch {
3205
4834
  // ignore
3206
4835
  }
4836
+ try {
4837
+ // Resolve open questions with null instead of leaving their callers waiting for a timeout.
4838
+ this.pending.cancelAll();
4839
+ }
4840
+ catch {
4841
+ // ignore
4842
+ }
3207
4843
  try {
3208
4844
  this.timers?.dispose();
3209
4845
  }
@@ -3216,6 +4852,12 @@ class Assistant extends adapter_core_1.Adapter {
3216
4852
  catch {
3217
4853
  // ignore
3218
4854
  }
4855
+ try {
4856
+ this.triggers?.dispose();
4857
+ }
4858
+ catch {
4859
+ // ignore
4860
+ }
3219
4861
  try {
3220
4862
  await this.voice?.stop();
3221
4863
  }
@@ -3228,6 +4870,18 @@ class Assistant extends adapter_core_1.Adapter {
3228
4870
  catch {
3229
4871
  // ignore
3230
4872
  }
4873
+ try {
4874
+ await this.esphome?.stop();
4875
+ }
4876
+ catch {
4877
+ // ignore
4878
+ }
4879
+ try {
4880
+ await this.media?.stop();
4881
+ }
4882
+ catch {
4883
+ // ignore
4884
+ }
3231
4885
  try {
3232
4886
  await this.localLlm?.dispose();
3233
4887
  }