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
@@ -0,0 +1,732 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.EsphomeSatellites = exports.TIMER_EVENT = void 0;
37
+ /**
38
+ * ESPHome voice satellites — the fourth transport, next to the UDP server (`voiceServer.ts`) and the
39
+ * Wyoming endpoint (`wyoming.ts`).
40
+ *
41
+ * Unlike those two, this one is a **client**: an ESPHome voice satellite is a *server* that waits on
42
+ * TCP 6053 for the home-automation controller to connect and drive it. We therefore dial out to each
43
+ * configured device and play the role Home Assistant normally plays. That covers the ThirdReality
44
+ * Voice & Music Assistant (Dev Edition), the Home Assistant Voice PE and any box running
45
+ * OHF-Voice/linux-voice-assistant.
46
+ *
47
+ * Flow for one utterance (verified against the firmware's `src/satellite/Satellite.cpp`):
48
+ *
49
+ * device → VoiceAssistantRequest{start, wake_word_phrase} wake word fired on the device
50
+ * we → VoiceAssistantResponse{port:0} 0 = audio over the API, no UDP
51
+ * we → RUN_START, WAKE_WORD_END, STT_START, STT_VAD_START
52
+ * device → VoiceAssistantAudio … 16 kHz mono s16le, *until told to stop*
53
+ * we → STT_VAD_END ← the device has no VAD of its own
54
+ * we → STT_END{text}, INTENT_START, INTENT_END, TTS_START{text}, TTS_END{url}, RUN_END
55
+ * device → fetches the URL over HTTP, plays it, VoiceAssistantAnnounceFinished
56
+ *
57
+ * Two consequences shape this file: end-of-speech detection is ours (`vad.ts`), and the spoken reply
58
+ * has to be reachable over HTTP (`mediaServer.ts`) rather than streamed down the socket.
59
+ */
60
+ const net = __importStar(require("node:net"));
61
+ const esphomeProto_1 = require("./esphomeProto");
62
+ const esphomeEntities_1 = require("./esphomeEntities");
63
+ const vad_1 = require("./vad");
64
+ const stt_1 = require("./stt");
65
+ /** `VoiceAssistantEvent` (api.proto) — the subset we emit. */
66
+ const EVENT = {
67
+ ERROR: 0,
68
+ RUN_START: 1,
69
+ RUN_END: 2,
70
+ STT_START: 3,
71
+ STT_END: 4,
72
+ INTENT_START: 5,
73
+ INTENT_END: 6,
74
+ TTS_START: 7,
75
+ TTS_END: 8,
76
+ WAKE_WORD_END: 10,
77
+ STT_VAD_START: 11,
78
+ STT_VAD_END: 12,
79
+ };
80
+ /** `SubscribeVoiceAssistantRequest.flags`: audio over the API socket instead of a UDP side channel. */
81
+ const SUBSCRIBE_API_AUDIO = 1;
82
+ /** ESPHome voice satellites always capture at 16 kHz mono 16-bit. */
83
+ const MIC_SAMPLE_RATE = 16000;
84
+ /**
85
+ * Leading audio to keep out of the end-of-speech analysis. These devices acknowledge the wake word
86
+ * with a chirp through their own speaker and hear it back on their own microphone, far louder than
87
+ * the speech that follows. Without this the chirp alone satisfies the detector's speech test — so
88
+ * `sawSpeech` stops meaning "somebody said something" and a false wake is billed to STT — and it
89
+ * drags the noise floor up with it.
90
+ *
91
+ * Measured over four captures from a ThirdReality Voice & Music Assistant (RMS per 50 ms, remarkably
92
+ * repeatable across all four):
93
+ *
94
+ * ms 0 50 100 150 200 250 300 350 400 450 500 550 600
95
+ * rms ~20 ~900 ~11k ~19k ~14k ~1.3k ~990 ~780 ~610 ~370 ~330 ~165 ~180
96
+ *
97
+ * so the chirp is still above the gate at 500 ms and only clears it around 550–600. 600 ms is the
98
+ * smallest value that rejects a chirp-only wake while still detecting the speech in every one of the
99
+ * four captures, with no change to when the utterance closes. The audio itself is untouched and still
100
+ * goes to STT in full, so a speaker who starts inside this window loses nothing but a late gate open.
101
+ */
102
+ const WAKE_CHIRP_MS = 600;
103
+ /** Default API port of an ESPHome device. */
104
+ const DEFAULT_PORT = 6053;
105
+ /** Keepalive interval; two missed replies count as a dead link. */
106
+ const PING_INTERVAL_MS = 20_000;
107
+ /** Wait between reconnect attempts. Devices reboot, and a Wi-Fi speaker drops off now and then. */
108
+ const RECONNECT_MS = 10_000;
109
+ /** Give up on a pipeline that produced no audio at all (device wedged mid-stream). */
110
+ const PIPELINE_TIMEOUT_MS = 30_000;
111
+ /** How long we wait for the device to report that it finished playing before forcing it back to idle. */
112
+ const PLAYBACK_TIMEOUT_MS = 60_000;
113
+ /** `VoiceAssistantTimerEvent` in api.proto. */
114
+ exports.TIMER_EVENT = { started: 0, updated: 1, cancelled: 2, finished: 3 };
115
+ /**
116
+ * All configured ESPHome satellites. Owns one connection per device and offers the same surface the
117
+ * adapter already uses for the UDP server: `devices()` and `announce()`.
118
+ */
119
+ class EsphomeSatellites {
120
+ opts;
121
+ connections = [];
122
+ constructor(opts) {
123
+ this.opts = opts;
124
+ }
125
+ async start() {
126
+ for (const device of this.opts.devices) {
127
+ if (!device.ip?.trim()) {
128
+ continue;
129
+ }
130
+ const connection = new EsphomeConnection(device, this.opts);
131
+ this.connections.push(connection);
132
+ connection.start();
133
+ }
134
+ if (!this.connections.length) {
135
+ this.opts.log.warn('ESPHome satellites enabled, but no device addresses are configured.');
136
+ }
137
+ return Promise.resolve();
138
+ }
139
+ async stop() {
140
+ await Promise.all(this.connections.map(c => c.stop()));
141
+ this.connections.length = 0;
142
+ }
143
+ /** Device names of the satellites currently connected. */
144
+ devices() {
145
+ return this.connections.filter(c => c.online).map(c => c.deviceName);
146
+ }
147
+ /** Wake-word configuration of one connected satellite, or null if it is unknown/offline. */
148
+ wakeWords(device) {
149
+ return this.connections.find(c => c.online && c.deviceName === device)?.wakeWordConfig ?? null;
150
+ }
151
+ /**
152
+ * Choose which wake words listen on one satellite. Ids come from {@link WakeWordConfig.available};
153
+ * the device stores the selection itself, so it survives a restart of the adapter.
154
+ */
155
+ setWakeWords(device, ids) {
156
+ const target = this.connections.find(c => c.online && c.deviceName === device);
157
+ if (!target) {
158
+ return false;
159
+ }
160
+ target.setWakeWords(ids);
161
+ return true;
162
+ }
163
+ /** Everything one satellite exposes besides the voice pipeline (gain, noise suppression, …). */
164
+ entities(device) {
165
+ return this.connections.find(c => c.online && c.deviceName === device)?.entities.list() ?? [];
166
+ }
167
+ /**
168
+ * Write one of those entities. Returns false when the satellite is offline, the entity is unknown
169
+ * or read-only, or the value is not one the device accepts.
170
+ */
171
+ setEntity(device, objectId, value) {
172
+ const target = this.connections.find(c => c.online && c.deviceName === device);
173
+ return target ? target.setEntity(objectId, value) : false;
174
+ }
175
+ /**
176
+ * Mirror an assistant timer onto the satellites so they can show it on their own LED ring and ring
177
+ * it themselves. `device` null broadcasts. Returns how many devices were told.
178
+ */
179
+ timerEvent(device, event) {
180
+ const targets = this.connections.filter(c => c.online && (!device || c.deviceName === device));
181
+ targets.forEach(c => c.sendTimerEvent(event));
182
+ return targets.length;
183
+ }
184
+ /**
185
+ * Play a ready PCM buffer on one satellite (by device name) or all of them. Returns how many
186
+ * devices it was handed to.
187
+ *
188
+ * With `startConversation` the device opens its microphone as soon as the clip has played, without
189
+ * a wake word — that is how a question gets its answer (see `PendingQuestions`).
190
+ */
191
+ async announce(device, pcm, sampleRate, startConversation = false) {
192
+ const targets = this.connections.filter(c => c.online && (!device || c.deviceName === device));
193
+ if (!targets.length) {
194
+ return 0;
195
+ }
196
+ await Promise.all(targets.map(c => c
197
+ .announce(pcm, sampleRate, startConversation)
198
+ .catch(e => this.opts.log.warn(`announce to ${c.deviceName} failed: ${e.message}`))));
199
+ return targets.length;
200
+ }
201
+ }
202
+ exports.EsphomeSatellites = EsphomeSatellites;
203
+ /** One satellite: socket lifecycle, handshake, and the voice pipeline. */
204
+ class EsphomeConnection {
205
+ opts;
206
+ /** Device name as reported by the device; falls back to its address until the handshake answers. */
207
+ deviceName;
208
+ online = false;
209
+ /** Last wake-word configuration the device reported; null until the handshake answers. */
210
+ wakeWordConfig = null;
211
+ /** Everything else the device exposes — rebuilt from scratch on every reconnect. */
212
+ entities = new esphomeEntities_1.EntityRegistry();
213
+ host;
214
+ port;
215
+ room;
216
+ socket = null;
217
+ rx = Buffer.alloc(0);
218
+ pingTimer = null;
219
+ reconnectTimer = null;
220
+ missedPings = 0;
221
+ stopping = false;
222
+ capture = null;
223
+ playbackTimer = null;
224
+ password;
225
+ constructor(device, opts) {
226
+ this.opts = opts;
227
+ this.host = device.ip.trim();
228
+ this.port = device.port || DEFAULT_PORT;
229
+ this.room = (device.room || '').trim();
230
+ this.password = device.password || '';
231
+ this.deviceName = this.host;
232
+ }
233
+ // ── lifecycle ───────────────────────────────────────────────────────────────────────────────────
234
+ start() {
235
+ this.connect();
236
+ }
237
+ async stop() {
238
+ this.stopping = true;
239
+ this.clearTimers();
240
+ this.abortCapture();
241
+ const socket = this.socket;
242
+ this.socket = null;
243
+ if (socket) {
244
+ try {
245
+ socket.write((0, esphomeProto_1.encode)(new esphomeProto_1.pb.DisconnectRequest()));
246
+ }
247
+ catch {
248
+ /* socket already gone — nothing to say goodbye on */
249
+ }
250
+ socket.destroy();
251
+ }
252
+ this.setOffline();
253
+ return Promise.resolve();
254
+ }
255
+ connect() {
256
+ if (this.stopping) {
257
+ return;
258
+ }
259
+ this.rx = Buffer.alloc(0);
260
+ this.missedPings = 0;
261
+ // The device re-announces everything after a reconnect, and the keys are not stable across
262
+ // reboots, so starting from a clean slate is the only safe option.
263
+ this.entities.clear();
264
+ const socket = net.createConnection({ host: this.host, port: this.port }, () => {
265
+ this.opts.log.debug(`ESPHome ${this.host}:${this.port} connected`);
266
+ this.handshake();
267
+ });
268
+ this.socket = socket;
269
+ socket.setNoDelay(true);
270
+ socket.on('data', data => this.onData(data));
271
+ socket.on('error', e => {
272
+ // A satellite that is switched off is normal, so this is not an error-level event.
273
+ this.opts.log.debug(`ESPHome ${this.host}: ${e.message}`);
274
+ });
275
+ socket.on('close', () => {
276
+ if (this.socket === socket) {
277
+ this.socket = null;
278
+ this.onDisconnected();
279
+ }
280
+ });
281
+ }
282
+ onDisconnected() {
283
+ this.clearTimers();
284
+ this.abortCapture();
285
+ if (this.online) {
286
+ this.opts.log.info(`ESPHome satellite offline: ${this.deviceName}`);
287
+ }
288
+ this.setOffline();
289
+ this.scheduleReconnect();
290
+ }
291
+ scheduleReconnect() {
292
+ if (this.stopping || this.reconnectTimer) {
293
+ return;
294
+ }
295
+ this.reconnectTimer = setTimeout(() => {
296
+ this.reconnectTimer = null;
297
+ this.connect();
298
+ }, RECONNECT_MS);
299
+ this.reconnectTimer.unref();
300
+ }
301
+ clearTimers() {
302
+ if (this.pingTimer) {
303
+ clearInterval(this.pingTimer);
304
+ this.pingTimer = null;
305
+ }
306
+ if (this.reconnectTimer) {
307
+ clearTimeout(this.reconnectTimer);
308
+ this.reconnectTimer = null;
309
+ }
310
+ if (this.playbackTimer) {
311
+ clearTimeout(this.playbackTimer);
312
+ this.playbackTimer = null;
313
+ }
314
+ }
315
+ setOffline() {
316
+ if (this.online) {
317
+ this.online = false;
318
+ this.opts.onStatus?.(this.deviceName, this.room, 'offline');
319
+ }
320
+ }
321
+ status(state) {
322
+ this.opts.onStatus?.(this.deviceName, this.room, state);
323
+ }
324
+ // ── protocol plumbing ───────────────────────────────────────────────────────────────────────────
325
+ send(message) {
326
+ const socket = this.socket;
327
+ if (!socket || socket.destroyed) {
328
+ return;
329
+ }
330
+ try {
331
+ socket.write((0, esphomeProto_1.encode)(message));
332
+ }
333
+ catch (e) {
334
+ this.opts.log.debug(`ESPHome ${this.deviceName}: send failed — ${e.message}`);
335
+ }
336
+ }
337
+ onData(data) {
338
+ this.rx = this.rx.length ? Buffer.concat([this.rx, data]) : data;
339
+ let frames;
340
+ try {
341
+ ({ frames, rest: this.rx } = (0, esphomeProto_1.decode)(this.rx));
342
+ }
343
+ catch (e) {
344
+ this.opts.log.error(`ESPHome ${this.deviceName}: ${e.message}`);
345
+ this.socket?.destroy();
346
+ return;
347
+ }
348
+ for (const frame of frames) {
349
+ if (frame.error) {
350
+ this.opts.log.debug(`ESPHome ${this.deviceName}: cannot parse ${frame.name}`);
351
+ continue;
352
+ }
353
+ if (!frame.name || !frame.message) {
354
+ continue; // unknown id — already skipped by its declared length
355
+ }
356
+ try {
357
+ this.onMessage(frame.name, frame.message);
358
+ }
359
+ catch (e) {
360
+ this.opts.log.warn(`ESPHome ${this.deviceName}: ${frame.name} handler failed — ${e.message}`);
361
+ }
362
+ }
363
+ }
364
+ handshake() {
365
+ const hello = new esphomeProto_1.pb.HelloRequest();
366
+ hello.setClientInfo('ioBroker.assistant');
367
+ hello.setApiVersionMajor(1);
368
+ hello.setApiVersionMinor(10);
369
+ this.send(hello);
370
+ const auth = new esphomeProto_1.pb.AuthenticationRequest();
371
+ auth.setPassword(this.password);
372
+ this.send(auth);
373
+ this.send(new esphomeProto_1.pb.DeviceInfoRequest());
374
+ // Ask for the entity announcements *before* subscribing to their states: a state message whose
375
+ // entity we have not seen yet has nothing to attach to and is dropped.
376
+ this.send(new esphomeProto_1.pb.ListEntitiesRequest());
377
+ this.send(new esphomeProto_1.pb.SubscribeStatesRequest());
378
+ const subscribe = new esphomeProto_1.pb.SubscribeVoiceAssistantRequest();
379
+ subscribe.setSubscribe(true);
380
+ subscribe.setFlags(SUBSCRIBE_API_AUDIO);
381
+ this.send(subscribe);
382
+ this.send(new esphomeProto_1.pb.VoiceAssistantConfigurationRequest());
383
+ this.pingTimer = setInterval(() => {
384
+ if (++this.missedPings > 2) {
385
+ this.opts.log.info(`ESPHome ${this.deviceName}: no reply to keepalive — reconnecting`);
386
+ this.socket?.destroy();
387
+ return;
388
+ }
389
+ this.send(new esphomeProto_1.pb.PingRequest());
390
+ }, PING_INTERVAL_MS);
391
+ this.pingTimer.unref();
392
+ }
393
+ onMessage(name, message) {
394
+ switch (name) {
395
+ case 'HelloResponse': {
396
+ const hello = message;
397
+ this.opts.log.debug(`ESPHome ${this.host}: ${hello.getServerInfo()} (API ${hello.getApiVersionMajor()}.${hello.getApiVersionMinor()})`);
398
+ return;
399
+ }
400
+ case 'AuthenticationResponse':
401
+ if (message.getInvalidPassword()) {
402
+ this.opts.log.error(`ESPHome ${this.host}: API password rejected — check the device's password in the settings.`);
403
+ this.socket?.destroy();
404
+ }
405
+ return;
406
+ case 'DeviceInfoResponse': {
407
+ const info = message;
408
+ this.deviceName = info.getName() || this.host;
409
+ this.online = true;
410
+ this.opts.log.info(`ESPHome satellite online: ${this.deviceName} (${info.getModel()}) @ ${this.host}${this.room ? `, room ${this.room}` : ''}`);
411
+ this.status('idle');
412
+ return;
413
+ }
414
+ case 'VoiceAssistantConfigurationResponse': {
415
+ const config = message;
416
+ this.wakeWordConfig = {
417
+ active: config.getActiveWakeWordsList(),
418
+ available: config.getAvailableWakeWordsList().map(w => ({
419
+ id: w.getId(),
420
+ phrase: w.getWakeWord(),
421
+ languages: w.getTrainedLanguagesList(),
422
+ })),
423
+ max: config.getMaxActiveWakeWords(),
424
+ };
425
+ const active = this.wakeWordConfig.active.join(', ') || '(none active)';
426
+ const available = this.wakeWordConfig.available.map(w => w.id);
427
+ this.opts.log.info(`ESPHome ${this.deviceName}: wake words ${active}${available.length ? ` (available: ${available.join(', ')})` : ''}`);
428
+ this.opts.onWakeWords?.(this.deviceName, this.room, this.wakeWordConfig);
429
+ return;
430
+ }
431
+ case 'VoiceAssistantRequest':
432
+ this.onPipelineRequest(message);
433
+ return;
434
+ case 'VoiceAssistantAudio':
435
+ this.onAudio(message);
436
+ return;
437
+ case 'VoiceAssistantAnnounceFinished':
438
+ if (this.playbackTimer) {
439
+ clearTimeout(this.playbackTimer);
440
+ this.playbackTimer = null;
441
+ }
442
+ this.opts.log.debug(`ESPHome ${this.deviceName}: playback finished (success=${message.getSuccess()})`);
443
+ this.status('idle');
444
+ return;
445
+ case 'PingRequest':
446
+ this.send(new esphomeProto_1.pb.PingResponse());
447
+ return;
448
+ case 'PingResponse':
449
+ this.missedPings = 0;
450
+ return;
451
+ case 'GetTimeRequest': {
452
+ // The device has no RTC and syncs its clock from us — its timer ring depends on it.
453
+ const time = new esphomeProto_1.pb.GetTimeResponse();
454
+ time.setEpochSeconds(Math.floor(Date.now() / 1000));
455
+ this.send(time);
456
+ return;
457
+ }
458
+ case 'DisconnectRequest':
459
+ this.send(new esphomeProto_1.pb.DisconnectResponse());
460
+ this.socket?.destroy();
461
+ return;
462
+ case 'ListEntitiesDoneResponse': {
463
+ const list = this.entities.list();
464
+ if (list.length) {
465
+ this.opts.log.debug(`ESPHome ${this.deviceName}: ${list.length} entities — ${list.map(e => e.objectId).join(', ')}`);
466
+ }
467
+ this.opts.onEntities?.(this.deviceName, this.room, list);
468
+ return;
469
+ }
470
+ default:
471
+ if ((0, esphomeEntities_1.isListEntityMessage)(name)) {
472
+ this.entities.addFromAnnouncement(name, message);
473
+ }
474
+ else if ((0, esphomeEntities_1.isEntityStateMessage)(name)) {
475
+ const entity = this.entities.applyState(name, message);
476
+ if (entity) {
477
+ this.opts.onEntityState?.(this.deviceName, this.room, entity);
478
+ }
479
+ }
480
+ return;
481
+ }
482
+ }
483
+ /** Write one of the device's entities. See {@link EsphomeSatellites.setEntity}. */
484
+ setEntity(objectId, value) {
485
+ const message = this.entities.command(objectId, value);
486
+ if (!message) {
487
+ const entity = this.entities.get(objectId);
488
+ const known = this.entities
489
+ .list()
490
+ .map(e => e.objectId)
491
+ .join(', ');
492
+ const options = entity?.options ? ` (one of: ${entity.options.join(', ')})` : '';
493
+ this.opts.log.warn(entity
494
+ ? `ESPHome ${this.deviceName}: ${JSON.stringify(value)} is not a value "${objectId}" accepts${options}`
495
+ : `ESPHome ${this.deviceName}: no entity "${objectId}" (have: ${known})`);
496
+ return false;
497
+ }
498
+ this.send(message);
499
+ this.opts.log.debug(`ESPHome ${this.deviceName}: ${objectId} := ${JSON.stringify(value)}`);
500
+ return true;
501
+ }
502
+ /** Push one assistant timer to the device so it can show and ring it itself. */
503
+ sendTimerEvent(event) {
504
+ const message = new esphomeProto_1.pb.VoiceAssistantTimerEventResponse();
505
+ message.setEventType(exports.TIMER_EVENT[event.type]);
506
+ message.setTimerId(event.id);
507
+ message.setName(event.name);
508
+ message.setTotalSeconds(Math.max(0, Math.round(event.totalSeconds)));
509
+ message.setSecondsLeft(Math.max(0, Math.round(event.secondsLeft)));
510
+ message.setIsActive(event.active);
511
+ this.send(message);
512
+ }
513
+ // ── voice pipeline ──────────────────────────────────────────────────────────────────────────────
514
+ onPipelineRequest(request) {
515
+ if (!request.getStart()) {
516
+ this.abortCapture();
517
+ return;
518
+ }
519
+ if (this.capture) {
520
+ this.opts.log.debug(`ESPHome ${this.deviceName}: pipeline already running — ignoring wake`);
521
+ return;
522
+ }
523
+ const phrase = request.getWakeWordPhrase();
524
+ this.opts.log.debug(`ESPHome ${this.deviceName}: wake "${phrase || '(follow-up)'}"`);
525
+ const response = new esphomeProto_1.pb.VoiceAssistantResponse();
526
+ response.setPort(0); // 0 = stream the audio over this socket
527
+ response.setError(false);
528
+ this.send(response);
529
+ const timer = setTimeout(() => {
530
+ this.opts.log.warn(`ESPHome ${this.deviceName}: no end of speech within ${PIPELINE_TIMEOUT_MS} ms`);
531
+ void this.finishCapture();
532
+ }, PIPELINE_TIMEOUT_MS);
533
+ timer.unref();
534
+ this.capture = {
535
+ chunks: [],
536
+ detector: new vad_1.SpeechDetector({
537
+ sampleRate: MIC_SAMPLE_RATE,
538
+ silenceMs: this.opts.silenceMs || undefined,
539
+ // Microphone gain differs wildly between these devices, so fixed RMS levels do not
540
+ // travel: derive them from the room instead, once the wake chirp is out of the way.
541
+ skipMs: WAKE_CHIRP_MS,
542
+ adaptive: true,
543
+ }),
544
+ conversationId: request.getConversationId(),
545
+ timer,
546
+ closed: false,
547
+ };
548
+ this.status('listening');
549
+ this.event(EVENT.RUN_START);
550
+ if (phrase) {
551
+ this.event(EVENT.WAKE_WORD_END, { wake_word_phrase: phrase });
552
+ }
553
+ this.event(EVENT.STT_START);
554
+ this.event(EVENT.STT_VAD_START);
555
+ }
556
+ onAudio(audio) {
557
+ const capture = this.capture;
558
+ if (!capture || capture.closed) {
559
+ return;
560
+ }
561
+ const chunk = Buffer.from(audio.getData_asU8());
562
+ if (chunk.length) {
563
+ capture.chunks.push(chunk);
564
+ if (capture.detector.push(chunk)) {
565
+ void this.finishCapture();
566
+ return;
567
+ }
568
+ }
569
+ if (audio.getEnd()) {
570
+ void this.finishCapture();
571
+ }
572
+ }
573
+ /** Stop the mic stream and run STT → answer → TTS for what was captured. */
574
+ async finishCapture() {
575
+ const capture = this.capture;
576
+ if (!capture || capture.closed) {
577
+ return;
578
+ }
579
+ capture.closed = true;
580
+ this.capture = null;
581
+ clearTimeout(capture.timer);
582
+ // Send this first: the device streams until it sees it.
583
+ this.event(EVENT.STT_VAD_END);
584
+ const pcm = Buffer.concat(capture.chunks);
585
+ const seconds = pcm.length / 2 / MIC_SAMPLE_RATE;
586
+ this.opts.log.debug(`ESPHome ${this.deviceName}: captured ${seconds.toFixed(2)} s ` +
587
+ `(peak RMS ${Math.round(capture.detector.peak)}, noise floor ${Math.round(capture.detector.noiseFloor)}, ` +
588
+ `speech/silence gate ${Math.round(capture.detector.startLevel)}/${Math.round(capture.detector.endLevel)})`);
589
+ if (!pcm.length || !capture.detector.sawSpeech) {
590
+ this.opts.log.debug(`ESPHome ${this.deviceName}: nothing but silence — dropping the utterance`);
591
+ this.event(EVENT.RUN_END);
592
+ this.status('idle');
593
+ return;
594
+ }
595
+ this.status('processing');
596
+ try {
597
+ const hints = this.opts.getHints ? await this.opts.getHints() : undefined;
598
+ const text = (await this.opts.stt.transcribe(pcm, MIC_SAMPLE_RATE, this.opts.language, hints)).trim();
599
+ this.event(EVENT.STT_END, { text });
600
+ if (!text) {
601
+ this.event(EVENT.RUN_END);
602
+ this.status('idle');
603
+ return;
604
+ }
605
+ this.event(EVENT.INTENT_START);
606
+ const answer = (await this.opts.answer(text, { device: this.deviceName, room: this.room })).trim();
607
+ const followUp = !!answer && this.opts.expectsFollowUp?.(answer) === true;
608
+ this.event(EVENT.INTENT_END, followUp ? { continue_conversation: '1' } : undefined);
609
+ if (!answer) {
610
+ this.event(EVENT.RUN_END);
611
+ this.status('idle');
612
+ return;
613
+ }
614
+ const { pcm: ttsPcm, sampleRate } = await this.opts.tts.synthesize(answer, this.opts.language);
615
+ const url = this.publish(ttsPcm, sampleRate);
616
+ this.status('speaking');
617
+ this.event(EVENT.TTS_START, { text: answer });
618
+ this.event(EVENT.TTS_END, { url });
619
+ this.event(EVENT.RUN_END, followUp ? { continue_conversation: '1' } : undefined);
620
+ this.armPlaybackTimeout();
621
+ }
622
+ catch (e) {
623
+ const reason = e.message;
624
+ this.opts.log.error(`ESPHome ${this.deviceName}: voice pipeline failed — ${reason}`);
625
+ // Without this the device stays ducked and its LED keeps spinning.
626
+ this.event(EVENT.ERROR, { code: 'pipeline-failed', message: reason });
627
+ this.event(EVENT.RUN_END);
628
+ this.status('idle');
629
+ }
630
+ }
631
+ /**
632
+ * Select which wake words listen on this device. Unknown ids are dropped and the list is trimmed
633
+ * to what the firmware allows, because the device answers a rejected list with silence rather than
634
+ * an error — better to send something valid and report what we changed.
635
+ *
636
+ * The device persists the selection and echoes it back in a `VoiceAssistantConfigurationResponse`,
637
+ * so we ask for one instead of assuming the write took.
638
+ */
639
+ setWakeWords(ids) {
640
+ const known = this.wakeWordConfig?.available.map(w => w.id) ?? [];
641
+ const wanted = [...new Set(ids.map(i => i.trim()).filter(Boolean))];
642
+ const unknown = wanted.filter(i => !known.includes(i));
643
+ if (unknown.length) {
644
+ this.opts.log.warn(`ESPHome ${this.deviceName}: unknown wake word(s) ${unknown.join(', ')} — ignored. ` +
645
+ `Available: ${known.join(', ') || '(none reported)'}`);
646
+ }
647
+ let valid = wanted.filter(i => known.includes(i));
648
+ const max = this.wakeWordConfig?.max || 0;
649
+ if (max > 0 && valid.length > max) {
650
+ this.opts.log.warn(`ESPHome ${this.deviceName}: device allows ${max} active wake word(s), keeping ${valid
651
+ .slice(0, max)
652
+ .join(', ')}`);
653
+ valid = valid.slice(0, max);
654
+ }
655
+ const message = new esphomeProto_1.pb.VoiceAssistantSetConfiguration();
656
+ message.setActiveWakeWordsList(valid);
657
+ this.send(message);
658
+ this.opts.log.info(`ESPHome ${this.deviceName}: wake words set to ${valid.join(', ') || '(none)'}`);
659
+ // Read back, so the reported state is the device's and not our guess.
660
+ this.send(new esphomeProto_1.pb.VoiceAssistantConfigurationRequest());
661
+ }
662
+ /** Drop a half-finished capture (device went away, or told us to stop). */
663
+ abortCapture() {
664
+ const capture = this.capture;
665
+ if (!capture) {
666
+ return;
667
+ }
668
+ this.capture = null;
669
+ capture.closed = true;
670
+ clearTimeout(capture.timer);
671
+ }
672
+ // ── announcements ───────────────────────────────────────────────────────────────────────────────
673
+ /**
674
+ * Speak a ready PCM buffer on this device (timers, alarms, `tts.text`, jingles). With
675
+ * `startConversation` the microphone opens once the clip has played, to catch the answer to a question.
676
+ */
677
+ announce(pcm, sampleRate, startConversation = false) {
678
+ if (!this.online || !pcm.length) {
679
+ return Promise.resolve();
680
+ }
681
+ const request = new esphomeProto_1.pb.VoiceAssistantAnnounceRequest();
682
+ request.setMediaId(this.publish(pcm, sampleRate));
683
+ request.setText('');
684
+ request.setPreannounceMediaId('');
685
+ // Needs feature flag 32 (START_CONVERSATION); on a device without it the clip still plays and
686
+ // only the microphone stays shut, so there is nothing to guard here.
687
+ request.setStartConversation(startConversation);
688
+ this.status('speaking');
689
+ this.send(request);
690
+ this.armPlaybackTimeout();
691
+ return Promise.resolve();
692
+ }
693
+ /**
694
+ * Publish PCM as a WAV on the media server and build the URL the device should fetch.
695
+ *
696
+ * The host matters: on a multi-homed ioBroker machine the device can usually only reach one of the
697
+ * addresses, and the local end of *this* socket is by definition one it can talk to.
698
+ */
699
+ publish(pcm, sampleRate) {
700
+ const path = this.opts.media.publish((0, stt_1.pcmToWav)(pcm, sampleRate), 'audio/wav');
701
+ const local = (this.socket?.localAddress || '').replace(/^::ffff:/, '');
702
+ const host = this.opts.mediaHost?.trim() || local || '127.0.0.1';
703
+ // An IPv6 literal has to be bracketed in a URL.
704
+ const authority = host.includes(':') ? `[${host}]` : host;
705
+ return `http://${authority}:${this.opts.media.port}${path}`;
706
+ }
707
+ /** Fall back to `idle` if the device never reports that it finished playing. */
708
+ armPlaybackTimeout() {
709
+ if (this.playbackTimer) {
710
+ clearTimeout(this.playbackTimer);
711
+ }
712
+ this.playbackTimer = setTimeout(() => {
713
+ this.playbackTimer = null;
714
+ this.status('idle');
715
+ }, PLAYBACK_TIMEOUT_MS);
716
+ this.playbackTimer.unref();
717
+ }
718
+ event(type, data) {
719
+ const message = new esphomeProto_1.pb.VoiceAssistantEventResponse();
720
+ message.setEventType(type);
721
+ const items = [];
722
+ for (const [name, value] of Object.entries(data || {})) {
723
+ const item = new esphomeProto_1.pb.VoiceAssistantEventData();
724
+ item.setName(name);
725
+ item.setValue(value);
726
+ items.push(item);
727
+ }
728
+ message.setDataList(items);
729
+ this.send(message);
730
+ }
731
+ }
732
+ //# sourceMappingURL=esphome.js.map