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.
- package/README.md +61 -4
- package/admin/custom/.vite/manifest.json +122 -77
- package/admin/custom/assets/Components-7BDDNrGP.js +1 -0
- package/admin/custom/assets/_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-DcAGnE2z.js +1 -0
- 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
- 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
- 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
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare__shared-3mzaJWDF.js +2 -0
- 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
- 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
- package/admin/custom/assets/dist-DiU1y0R9.js +1 -0
- package/admin/custom/assets/helpers-Dot7vgQQ.js +1 -0
- package/admin/custom/assets/hostInit-MshDicd1.js +1 -0
- package/admin/custom/assets/iconLibraryData-B5fiyl1C.js +1 -0
- package/admin/custom/assets/index-P2Xu9kJm.js +1 -0
- package/admin/custom/assets/lib-DjwHKtRi.js +3 -0
- package/admin/custom/assets/{mf-entry-bootstrap-0-87f15505.js → mf-entry-bootstrap-0-7c1ddf47.js} +14 -2
- package/admin/custom/assets/pendingShares-CaJDCKLB.js +2 -0
- package/admin/custom/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
- package/admin/custom/assets/utils-towTPKqG.js +4 -0
- package/admin/custom/assets/vite-preload-helper-BbbETgeG.js +1 -0
- package/admin/custom/customComponents.js +2 -2
- package/admin/custom/i18n/de.json +64 -57
- package/admin/custom/i18n/en.json +8 -1
- package/admin/custom/i18n/es.json +64 -57
- package/admin/custom/i18n/fr.json +64 -57
- package/admin/custom/i18n/it.json +64 -57
- package/admin/custom/i18n/nl.json +64 -57
- package/admin/custom/i18n/pl.json +64 -57
- package/admin/custom/i18n/pt.json +64 -57
- package/admin/custom/i18n/ru.json +64 -57
- package/admin/custom/i18n/uk.json +64 -57
- package/admin/custom/i18n/zh-cn.json +64 -57
- package/admin/custom/index.html +2 -2
- package/admin/custom/mf-manifest.json +1 -1
- package/admin/custom/mf-stats.json +1 -1
- package/admin/i18n/de.json +59 -3
- package/admin/i18n/en.json +59 -3
- package/admin/i18n/es.json +59 -3
- package/admin/i18n/fr.json +59 -3
- package/admin/i18n/it.json +59 -3
- package/admin/i18n/nl.json +59 -3
- package/admin/i18n/pl.json +59 -3
- package/admin/i18n/pt.json +59 -3
- package/admin/i18n/ru.json +59 -3
- package/admin/i18n/uk.json +59 -3
- package/admin/i18n/zh-cn.json +59 -3
- package/admin/jsonConfig.json +589 -75
- package/build/lib/ask.js +130 -0
- package/build/lib/ask.js.map +1 -0
- package/build/lib/llm.js +135 -13
- package/build/lib/llm.js.map +1 -1
- package/build/lib/nlu.js +74 -1
- package/build/lib/nlu.js.map +1 -1
- package/build/lib/notifications.js +112 -0
- package/build/lib/notifications.js.map +1 -0
- package/build/lib/presence.js +188 -0
- package/build/lib/presence.js.map +1 -0
- package/build/lib/routines.js +115 -0
- package/build/lib/routines.js.map +1 -0
- package/build/lib/targets.js +96 -0
- package/build/lib/targets.js.map +1 -0
- package/build/lib/triggers.js +632 -0
- package/build/lib/triggers.js.map +1 -0
- package/build/lib/voice/aws.js +9 -1
- package/build/lib/voice/aws.js.map +1 -1
- package/build/lib/voice/azure.js +9 -2
- package/build/lib/voice/azure.js.map +1 -1
- package/build/lib/voice/engines.js +82 -0
- package/build/lib/voice/engines.js.map +1 -1
- package/build/lib/voice/esphome.js +732 -0
- package/build/lib/voice/esphome.js.map +1 -0
- package/build/lib/voice/esphomeEntities.js +303 -0
- package/build/lib/voice/esphomeEntities.js.map +1 -0
- package/build/lib/voice/esphomeProto.js +181 -0
- package/build/lib/voice/esphomeProto.js.map +1 -0
- package/build/lib/voice/fallback.js +85 -0
- package/build/lib/voice/fallback.js.map +1 -0
- package/build/lib/voice/mediaServer.js +151 -0
- package/build/lib/voice/mediaServer.js.map +1 -0
- package/build/lib/voice/protocol.js.map +1 -1
- package/build/lib/voice/tone.js +32 -0
- package/build/lib/voice/tone.js.map +1 -0
- package/build/lib/voice/tts.js.map +1 -1
- package/build/lib/voice/ttsCache.js +274 -0
- package/build/lib/voice/ttsCache.js.map +1 -0
- package/build/lib/voice/vad.js +173 -0
- package/build/lib/voice/vad.js.map +1 -0
- package/build/lib/voice/voiceServer.js +13 -0
- package/build/lib/voice/voiceServer.js.map +1 -1
- package/build/main.js +1721 -67
- package/build/main.js.map +1 -1
- package/io-package.json +241 -3
- package/package.json +13 -12
- package/admin/custom/assets/Components-xWdtqvTH.js +0 -1
- package/admin/custom/assets/_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-dybYZgsu.js +0 -1
- 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
- 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
- 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
- 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
- package/admin/custom/assets/hostInit-CZawMH9B.js +0 -1
- package/admin/custom/assets/index-JB7g1jgx.js +0 -1
- package/admin/custom/assets/lib-bB-LHRhZ.js +0 -3
- package/admin/custom/assets/rolldown-runtime-C0FnF6B9.js +0 -1
- package/admin/custom/assets/virtualExposes-brKKZvi0.js +0 -1
- package/admin/custom/assets/virtual_mf-REMOTE_ENTRY_ID___mfe_internal__ConfigCustomAssistant__customComponents_js-CRhwj12f.js +0 -1
- package/admin/custom/assets/virtual_mf-exposes___mfe_internal__ConfigCustomAssistant__customComponents_js-C5XHUc1J.js +0 -2
- 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
|