iobroker.assistant 1.4.5 → 1.4.7
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 +16 -1
- package/admin/i18n/de.json +11 -2
- package/admin/i18n/en.json +11 -2
- package/admin/i18n/es.json +11 -2
- package/admin/i18n/fr.json +11 -2
- package/admin/i18n/it.json +11 -2
- package/admin/i18n/nl.json +11 -2
- package/admin/i18n/pl.json +11 -2
- package/admin/i18n/pt.json +11 -2
- package/admin/i18n/ru.json +11 -2
- package/admin/i18n/uk.json +11 -2
- package/admin/i18n/zh-cn.json +11 -2
- package/admin/jsonConfig.json +65 -2
- package/build/lib/ask.js +10 -0
- package/build/lib/ask.js.map +1 -1
- package/build/lib/journal.js.map +1 -1
- package/build/lib/llm.js +50 -12
- package/build/lib/llm.js.map +1 -1
- package/build/lib/media.js +226 -0
- package/build/lib/media.js.map +1 -0
- package/build/lib/piAgent.js +1 -0
- package/build/lib/piAgent.js.map +1 -1
- package/build/lib/voice/engines.js +3 -0
- package/build/lib/voice/engines.js.map +1 -1
- package/build/lib/voice/esphome.js +258 -5
- package/build/lib/voice/esphome.js.map +1 -1
- package/build/lib/voice/ffmpegStatic.js +137 -0
- package/build/lib/voice/ffmpegStatic.js.map +1 -0
- package/build/lib/voice/stt.js +35 -3
- package/build/lib/voice/stt.js.map +1 -1
- package/build/main.js +306 -32
- package/build/main.js.map +1 -1
- package/io-package.json +34 -29
- package/package.json +1 -1
|
@@ -34,6 +34,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.EsphomeSatellites = exports.TIMER_EVENT = exports.DEFAULT_SPEECH_LEVEL = void 0;
|
|
37
|
+
exports.yesNoWord = yesNoWord;
|
|
38
|
+
exports.confirmAnswerOf = confirmAnswerOf;
|
|
39
|
+
exports.displayLanguageOption = displayLanguageOption;
|
|
37
40
|
exports.speechLevelOf = speechLevelOf;
|
|
38
41
|
exports.esphomeAddress = esphomeAddress;
|
|
39
42
|
/**
|
|
@@ -170,6 +173,106 @@ const NO_TEXT_ERROR = { code: 'stt-no-text-recognized', message: 'No text recogn
|
|
|
170
173
|
* that was too quiet, not an empty room — worth telling the user where to lower the threshold.
|
|
171
174
|
*/
|
|
172
175
|
const QUIET_VOICE_SHARE = 0.4;
|
|
176
|
+
/**
|
|
177
|
+
* Event entity of a satellite with a screen: the user answered the open question by touch, event type `yes` or `no`.
|
|
178
|
+
* Pressing it is the same as saying "yes"/"no" into the open microphone — no speech recognition to get it wrong
|
|
179
|
+
* (a German "Ja" came back as "Я" once).
|
|
180
|
+
*/
|
|
181
|
+
const CONFIRM_ENTITY = 'confirm_answer';
|
|
182
|
+
/** Select entity of a satellite with a screen: the language of the date and the hints it shows. */
|
|
183
|
+
const DISPLAY_LANGUAGE_ENTITY = 'display_language';
|
|
184
|
+
/**
|
|
185
|
+
* After a reply that asks something: how long, beyond the reply's own length, the device may take to play it and
|
|
186
|
+
* open the microphone. Until then a button press answers the question.
|
|
187
|
+
*/
|
|
188
|
+
const FOLLOW_UP_START_MS = 10_000;
|
|
189
|
+
/**
|
|
190
|
+
* A button press still answers the question this long after the follow-up microphone closed: the firmware may stop
|
|
191
|
+
* the microphone on the press before it reports which button it was.
|
|
192
|
+
*/
|
|
193
|
+
const CONFIRM_GRACE_MS = 3000;
|
|
194
|
+
/**
|
|
195
|
+
* After a button answer: a follow-up microphone the device opens this soon was asked for by the question the button
|
|
196
|
+
* already answered, and is closed at once (unless our reply asked something again).
|
|
197
|
+
*/
|
|
198
|
+
const IGNORE_FOLLOW_UP_MS = 15_000;
|
|
199
|
+
/** "Yes" and "no" as a button answer is passed on: the words of the satellite's language, which pi-agent also takes. */
|
|
200
|
+
const YES_NO = {
|
|
201
|
+
en: ['Yes', 'No'],
|
|
202
|
+
de: ['Ja', 'Nein'],
|
|
203
|
+
ru: ['Да', 'Нет'],
|
|
204
|
+
pt: ['Sim', 'Não'],
|
|
205
|
+
nl: ['Ja', 'Nee'],
|
|
206
|
+
fr: ['Oui', 'Non'],
|
|
207
|
+
it: ['Sì', 'No'],
|
|
208
|
+
es: ['Sí', 'No'],
|
|
209
|
+
pl: ['Tak', 'Nie'],
|
|
210
|
+
uk: ['Так', 'Ні'],
|
|
211
|
+
zh: ['是', '不'],
|
|
212
|
+
};
|
|
213
|
+
/** The word a yes/no button stands for in `language` (ISO 639-1, `zh-cn` too); English for an unknown one. */
|
|
214
|
+
function yesNoWord(language, yes) {
|
|
215
|
+
const base = (language || 'en').trim().toLowerCase().split(/[-_]/)[0];
|
|
216
|
+
return (YES_NO[base] ?? YES_NO.en)[yes ? 0 : 1];
|
|
217
|
+
}
|
|
218
|
+
/** The event type of a {@link CONFIRM_ENTITY} as yes (true) or no (false); null for anything else. */
|
|
219
|
+
function confirmAnswerOf(eventType) {
|
|
220
|
+
const type = typeof eventType === 'string' ? eventType.trim().toLowerCase() : '';
|
|
221
|
+
if (type === 'yes' || type === 'confirm') {
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
if (type === 'no' || type === 'cancel') {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Names of the adapter's languages in that language, for a display-language select. `Intl.DisplayNames` covers the
|
|
231
|
+
* rest, but a Node.js built with only English ICU data knows no "Deutsch" or "Русский".
|
|
232
|
+
*/
|
|
233
|
+
const NATIVE_LANGUAGE_NAMES = {
|
|
234
|
+
en: 'english',
|
|
235
|
+
de: 'deutsch',
|
|
236
|
+
ru: 'русский',
|
|
237
|
+
pt: 'português',
|
|
238
|
+
nl: 'nederlands',
|
|
239
|
+
fr: 'français',
|
|
240
|
+
it: 'italiano',
|
|
241
|
+
es: 'español',
|
|
242
|
+
pl: 'polski',
|
|
243
|
+
uk: 'українська',
|
|
244
|
+
zh: '中文',
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* The option of a display-language select that means `language`: its name in that language ("Deutsch"), in English
|
|
248
|
+
* ("German") or its code ("de", "de-DE"), case ignored. Null when the device offers none of them.
|
|
249
|
+
*/
|
|
250
|
+
function displayLanguageOption(options, language) {
|
|
251
|
+
const code = (language || '').trim().toLowerCase();
|
|
252
|
+
const base = code.split(/[-_]/)[0];
|
|
253
|
+
if (!base) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
const names = new Set([code, base]);
|
|
257
|
+
if (NATIVE_LANGUAGE_NAMES[base]) {
|
|
258
|
+
names.add(NATIVE_LANGUAGE_NAMES[base]);
|
|
259
|
+
}
|
|
260
|
+
for (const locale of [base, 'en']) {
|
|
261
|
+
try {
|
|
262
|
+
const name = new Intl.DisplayNames([locale], { type: 'language' }).of(base);
|
|
263
|
+
if (name) {
|
|
264
|
+
names.add(name.toLocaleLowerCase(locale));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
/* not a language code Intl knows */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const norm = (option) => option.trim().toLocaleLowerCase();
|
|
272
|
+
return (options.find(o => names.has(norm(o))) ??
|
|
273
|
+
options.find(o => norm(o).split(/[-_]/)[0] === base && /^[a-z]{2,3}([-_][a-z0-9]+)?$/.test(norm(o))) ??
|
|
274
|
+
null);
|
|
275
|
+
}
|
|
173
276
|
/** The configured speech threshold of a device, or 0 for the default. Table cells may arrive as strings. */
|
|
174
277
|
function speechLevelOf(device) {
|
|
175
278
|
const level = Math.round(Number(device.speechLevel));
|
|
@@ -325,6 +428,15 @@ class EsphomeConnection {
|
|
|
325
428
|
errorSentAt = 0;
|
|
326
429
|
/** The reply format last reported in the log, to log it again only when it changes. */
|
|
327
430
|
loggedFormat = '';
|
|
431
|
+
/**
|
|
432
|
+
* The last reply asked something (`continue_conversation`, or an announcement with `start_conversation`): until
|
|
433
|
+
* when a yes/no button press answers it while no follow-up recording is open. 0 = no open question.
|
|
434
|
+
*/
|
|
435
|
+
questionUntil = 0;
|
|
436
|
+
/** After a button answer: until when a follow-up microphone is the stale one of the answered question. */
|
|
437
|
+
ignoreFollowUpUntil = 0;
|
|
438
|
+
/** The display language was compared with ours since the last connect. */
|
|
439
|
+
displayLanguageChecked = false;
|
|
328
440
|
constructor(device, opts) {
|
|
329
441
|
this.opts = opts;
|
|
330
442
|
this.host = device.ip.trim();
|
|
@@ -367,6 +479,7 @@ class EsphomeConnection {
|
|
|
367
479
|
// reboots, so starting from a clean slate is the only safe option.
|
|
368
480
|
this.entities.clear();
|
|
369
481
|
this.configChecks = [];
|
|
482
|
+
this.displayLanguageChecked = false;
|
|
370
483
|
const socket = net.createConnection({ host: this.host, port: this.port }, () => {
|
|
371
484
|
this.opts.log.debug(`ESPHome ${this.host}:${this.port} connected`);
|
|
372
485
|
this.handshake();
|
|
@@ -391,6 +504,8 @@ class EsphomeConnection {
|
|
|
391
504
|
// A reply still being worked out must not land on the next connection, in the middle of another run.
|
|
392
505
|
this.run++;
|
|
393
506
|
this.runOpen = false;
|
|
507
|
+
this.questionUntil = 0;
|
|
508
|
+
this.ignoreFollowUpUntil = 0;
|
|
394
509
|
if (this.online) {
|
|
395
510
|
this.opts.log.info(`ESPHome satellite offline: ${this.deviceName}`);
|
|
396
511
|
}
|
|
@@ -599,11 +714,110 @@ class EsphomeConnection {
|
|
|
599
714
|
const entity = this.entities.applyState(name, message);
|
|
600
715
|
if (entity) {
|
|
601
716
|
this.opts.onEntityState?.(this.deviceName, this.room, entity);
|
|
717
|
+
this.onEntityChange(entity);
|
|
602
718
|
}
|
|
603
719
|
}
|
|
604
720
|
return;
|
|
605
721
|
}
|
|
606
722
|
}
|
|
723
|
+
/** Entities of a satellite with a screen that take part in the conversation (see {@link CONFIRM_ENTITY}). */
|
|
724
|
+
onEntityChange(entity) {
|
|
725
|
+
if (entity.objectId === CONFIRM_ENTITY && entity.kind === 'event') {
|
|
726
|
+
this.onConfirmButton(entity.value);
|
|
727
|
+
}
|
|
728
|
+
else if (entity.objectId === DISPLAY_LANGUAGE_ENTITY && entity.kind === 'select') {
|
|
729
|
+
// Once per connect, with the state the device reports on subscribing: a language the user picks on the
|
|
730
|
+
// device afterwards stays until the next connect (a change of the voice language restarts the adapter).
|
|
731
|
+
if (!this.displayLanguageChecked) {
|
|
732
|
+
this.displayLanguageChecked = true;
|
|
733
|
+
this.syncDisplayLanguage(entity);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/** Show the device's screen in the voice language, if it offers that language. */
|
|
738
|
+
syncDisplayLanguage(entity) {
|
|
739
|
+
const language = this.opts.language;
|
|
740
|
+
if (!language) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const option = displayLanguageOption(entity.options ?? [], language);
|
|
744
|
+
if (!option) {
|
|
745
|
+
this.opts.log.debug(`ESPHome ${this.deviceName}: ${DISPLAY_LANGUAGE_ENTITY} has no option for "${language}" ` +
|
|
746
|
+
`(${(entity.options ?? []).join(', ')})`);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (entity.value === option) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const shown = typeof entity.value === 'string' ? entity.value : '?';
|
|
753
|
+
this.opts.log.info(`ESPHome ${this.deviceName}: display language ${shown} → ${option}`);
|
|
754
|
+
this.setEntity(DISPLAY_LANGUAGE_ENTITY, option);
|
|
755
|
+
}
|
|
756
|
+
/** Whether a yes/no button press now answers a question the last reply asked. */
|
|
757
|
+
questionOpen() {
|
|
758
|
+
return !!this.capture?.answersQuestion || Date.now() < this.questionUntil;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* The user pressed yes or no on the device's screen. While a reply waits for an answer that is the answer: the
|
|
762
|
+
* microphone opened for it is closed, the yes or no word goes on as the user's reply (pi-agent decides its
|
|
763
|
+
* confirmation on it like on a spoken one), and the reply to that is announced on this device.
|
|
764
|
+
*/
|
|
765
|
+
onConfirmButton(eventType) {
|
|
766
|
+
const yes = confirmAnswerOf(eventType);
|
|
767
|
+
if (yes === null) {
|
|
768
|
+
this.opts.log.debug(`ESPHome ${this.deviceName}: ${CONFIRM_ENTITY} "${String(eventType)}" — not yes/no, ignored`);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (!this.questionOpen()) {
|
|
772
|
+
this.opts.log.debug(`ESPHome ${this.deviceName}: ${CONFIRM_ENTITY} "${yes ? 'yes' : 'no'}" without an open question — ignored`);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
this.questionUntil = 0;
|
|
776
|
+
if (this.capture) {
|
|
777
|
+
// A RUN_END while the microphone streams stops it and puts the device back to idle — without the error
|
|
778
|
+
// display an empty recognition would end with.
|
|
779
|
+
this.abortCapture();
|
|
780
|
+
this.event(EVENT.RUN_END);
|
|
781
|
+
}
|
|
782
|
+
// Whatever is still in flight for the question stays silent; so does the follow-up microphone the device may
|
|
783
|
+
// still open for it.
|
|
784
|
+
const run = ++this.run;
|
|
785
|
+
this.ignoreFollowUpUntil = Date.now() + IGNORE_FOLLOW_UP_MS;
|
|
786
|
+
void this.answerButton(yes, run);
|
|
787
|
+
}
|
|
788
|
+
/** Pass a button answer on and announce the reply (see {@link onConfirmButton}). */
|
|
789
|
+
async answerButton(yes, run) {
|
|
790
|
+
const text = yesNoWord(this.opts.language, yes);
|
|
791
|
+
this.opts.log.info(`ESPHome ${this.deviceName}: answered "${text}" with the button`);
|
|
792
|
+
this.status('processing');
|
|
793
|
+
try {
|
|
794
|
+
const answer = (await this.opts.answer(text, { device: this.deviceName, room: this.room, button: yes ? 'yes' : 'no' })).trim();
|
|
795
|
+
if (run !== this.run) {
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (!answer) {
|
|
799
|
+
this.status('idle');
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
const followUp = this.opts.expectsFollowUp?.(answer, this.deviceName) === true;
|
|
803
|
+
const ttsStart = Date.now();
|
|
804
|
+
const { pcm, sampleRate } = await this.opts.tts.synthesize(answer, this.opts.language);
|
|
805
|
+
if (run !== this.run) {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
this.opts.onSpoken?.(this.deviceName, {
|
|
809
|
+
ms: Date.now() - ttsStart,
|
|
810
|
+
audioMs: (pcm.length / 2 / sampleRate) * 1000,
|
|
811
|
+
});
|
|
812
|
+
await this.announce(pcm, sampleRate, followUp, answer);
|
|
813
|
+
}
|
|
814
|
+
catch (e) {
|
|
815
|
+
this.opts.log.warn(`ESPHome ${this.deviceName}: answer to the button failed — ${e.message}`);
|
|
816
|
+
if (run === this.run) {
|
|
817
|
+
this.status('idle');
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
607
821
|
/** Write one of the device's entities. See {@link EsphomeSatellites.setEntity}. */
|
|
608
822
|
setEntity(objectId, value) {
|
|
609
823
|
const message = this.entities.command(objectId, value);
|
|
@@ -644,13 +858,27 @@ class EsphomeConnection {
|
|
|
644
858
|
this.opts.log.debug(`ESPHome ${this.deviceName}: pipeline already running — ignoring wake`);
|
|
645
859
|
return;
|
|
646
860
|
}
|
|
861
|
+
const phrase = request.getWakeWordPhrase();
|
|
862
|
+
if (!phrase && Date.now() < this.ignoreFollowUpUntil) {
|
|
863
|
+
// The microphone for the question a button already answered: close it at once. In this state the device
|
|
864
|
+
// takes a RUN_END as "stop the microphone" and goes back to idle.
|
|
865
|
+
this.ignoreFollowUpUntil = 0;
|
|
866
|
+
this.opts.log.debug(`ESPHome ${this.deviceName}: follow-up of a question answered by button — closing it`);
|
|
867
|
+
this.event(EVENT.RUN_END);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
647
870
|
if (this.runOpen) {
|
|
648
871
|
// The device started over while the last request was still being answered: that answer is not
|
|
649
872
|
// wanted any more, and its events would land in the middle of the new run.
|
|
650
873
|
this.opts.log.debug(`ESPHome ${this.deviceName}: new wake while answering — dropping the old reply`);
|
|
651
874
|
}
|
|
652
875
|
this.run++;
|
|
653
|
-
|
|
876
|
+
// Without a wake word the device re-opened the mic for the reply that asked something; with one, the user
|
|
877
|
+
// started over and the question is gone.
|
|
878
|
+
const answersQuestion = !phrase && Date.now() < this.questionUntil;
|
|
879
|
+
if (phrase) {
|
|
880
|
+
this.questionUntil = 0;
|
|
881
|
+
}
|
|
654
882
|
this.opts.log.debug(`ESPHome ${this.deviceName}: wake "${phrase || '(follow-up)'}"`);
|
|
655
883
|
const response = new esphomeProto_1.pb.VoiceAssistantResponse();
|
|
656
884
|
response.setPort(0); // 0 = stream the audio over this socket
|
|
@@ -682,6 +910,7 @@ class EsphomeConnection {
|
|
|
682
910
|
}),
|
|
683
911
|
conversationId: request.getConversationId(),
|
|
684
912
|
wakeWord: phrase || '',
|
|
913
|
+
answersQuestion,
|
|
685
914
|
timer,
|
|
686
915
|
stallCheck,
|
|
687
916
|
startedAt: Date.now(),
|
|
@@ -704,6 +933,8 @@ class EsphomeConnection {
|
|
|
704
933
|
*/
|
|
705
934
|
onStopRequest() {
|
|
706
935
|
const running = !!this.capture || this.runOpen;
|
|
936
|
+
// A device with yes/no buttons may stop its microphone on the press before it reports which button it was.
|
|
937
|
+
this.questionUntil = this.questionOpen() ? Date.now() + CONFIRM_GRACE_MS : 0;
|
|
707
938
|
this.abortCapture();
|
|
708
939
|
if (running) {
|
|
709
940
|
this.run++; // whatever is still being answered stays silent
|
|
@@ -803,6 +1034,11 @@ class EsphomeConnection {
|
|
|
803
1034
|
this.opts.log.debug(`ESPHome ${this.deviceName}: captured ${seconds.toFixed(2)} s ` +
|
|
804
1035
|
`(peak RMS ${vad.peak}, noise floor ${vad.noiseFloor}, speech/silence gate ${vad.startLevel}/${vad.endLevel}` +
|
|
805
1036
|
`${startedAt}, ${endedBy})`);
|
|
1037
|
+
if (capture.answersQuestion) {
|
|
1038
|
+
// Spoken: that is the answer, a button press now would be a second one. Silent: the buttons still answer
|
|
1039
|
+
// for a moment, as the screen may still show them.
|
|
1040
|
+
this.questionUntil = capture.detector.sawSpeech ? 0 : Date.now() + CONFIRM_GRACE_MS;
|
|
1041
|
+
}
|
|
806
1042
|
if (!pcm.length || !capture.detector.sawSpeech) {
|
|
807
1043
|
const loudest = Math.round(capture.detector.analysedPeak);
|
|
808
1044
|
const gate = Math.round(capture.detector.startLevel);
|
|
@@ -846,6 +1082,9 @@ class EsphomeConnection {
|
|
|
846
1082
|
// language than the STT listens in (a German "Ja" to a Russian STT comes back empty).
|
|
847
1083
|
this.opts.log.info(`Voice Q (${this.deviceName}): (empty) — speech recognition returned no text for ${seconds.toFixed(1)} s of audio`);
|
|
848
1084
|
this.discard(capture, pcm, 'empty-stt', vad);
|
|
1085
|
+
if (capture.answersQuestion) {
|
|
1086
|
+
this.questionUntil = Date.now() + CONFIRM_GRACE_MS;
|
|
1087
|
+
}
|
|
849
1088
|
// No STT_END without text — like Home Assistant, which reports this case as an error only.
|
|
850
1089
|
this.endWithoutRequest();
|
|
851
1090
|
return;
|
|
@@ -865,7 +1104,11 @@ class EsphomeConnection {
|
|
|
865
1104
|
return;
|
|
866
1105
|
}
|
|
867
1106
|
const followUp = !!answer && this.opts.expectsFollowUp?.(answer, this.deviceName) === true;
|
|
868
|
-
|
|
1107
|
+
// Always say it, "0" included: ESPHome keeps the flag of the last INTENT_END that carried it, so after
|
|
1108
|
+
// one question every later reply re-opened the microphone (field report 2026-11-04). Home Assistant
|
|
1109
|
+
// sends "0" just the same.
|
|
1110
|
+
const conversation = { continue_conversation: followUp ? '1' : '0' };
|
|
1111
|
+
this.event(EVENT.INTENT_END, conversation);
|
|
869
1112
|
if (!answer) {
|
|
870
1113
|
this.event(EVENT.RUN_END);
|
|
871
1114
|
this.status('idle');
|
|
@@ -887,7 +1130,11 @@ class EsphomeConnection {
|
|
|
887
1130
|
this.status('speaking');
|
|
888
1131
|
this.event(EVENT.TTS_START, { text: answer });
|
|
889
1132
|
this.event(EVENT.TTS_END, { url });
|
|
890
|
-
|
|
1133
|
+
// linux-voice-assistant reads the flag here (ignoring "0"), ESPHome at INTENT_END
|
|
1134
|
+
this.event(EVENT.RUN_END, conversation);
|
|
1135
|
+
this.questionUntil = followUp
|
|
1136
|
+
? Date.now() + (ttsPcm.length / 2 / sampleRate) * 1000 + FOLLOW_UP_START_MS
|
|
1137
|
+
: 0;
|
|
891
1138
|
this.armPlaybackTimeout();
|
|
892
1139
|
}
|
|
893
1140
|
catch (e) {
|
|
@@ -989,17 +1236,23 @@ class EsphomeConnection {
|
|
|
989
1236
|
* Speak a ready PCM buffer on this device (timers, alarms, `tts.text`, jingles). With
|
|
990
1237
|
* `startConversation` the microphone opens once the clip has played, to catch the answer to a question.
|
|
991
1238
|
*/
|
|
992
|
-
async announce(pcm, sampleRate, startConversation = false) {
|
|
1239
|
+
async announce(pcm, sampleRate, startConversation = false, text = '') {
|
|
993
1240
|
if (!this.online || !pcm.length) {
|
|
994
1241
|
return;
|
|
995
1242
|
}
|
|
996
1243
|
const request = new esphomeProto_1.pb.VoiceAssistantAnnounceRequest();
|
|
997
1244
|
request.setMediaId(await this.publish(pcm, sampleRate));
|
|
998
|
-
|
|
1245
|
+
// What a device with a screen shows while it plays (its on_tts_start)
|
|
1246
|
+
request.setText(text);
|
|
999
1247
|
request.setPreannounceMediaId('');
|
|
1000
1248
|
// Needs feature flag 32 (START_CONVERSATION); on a device without it the clip still plays and
|
|
1001
1249
|
// only the microphone stays shut, so there is nothing to guard here.
|
|
1002
1250
|
request.setStartConversation(startConversation);
|
|
1251
|
+
if (startConversation) {
|
|
1252
|
+
// A question: the microphone the device opens after it is wanted, and a button press answers it
|
|
1253
|
+
this.ignoreFollowUpUntil = 0;
|
|
1254
|
+
this.questionUntil = Date.now() + (pcm.length / 2 / sampleRate) * 1000 + FOLLOW_UP_START_MS;
|
|
1255
|
+
}
|
|
1003
1256
|
this.status('speaking');
|
|
1004
1257
|
this.send(request);
|
|
1005
1258
|
this.armPlaybackTimeout();
|