iobroker.assistant 1.4.4 → 1.4.5

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 (36) hide show
  1. package/README.md +7 -0
  2. package/admin/custom/.vite/manifest.json +1 -1
  3. package/admin/custom/assets/Components-BR57MZLJ.js +2 -0
  4. package/admin/custom/customComponents.js +1 -1
  5. package/admin/custom/i18n/de.json +3 -0
  6. package/admin/custom/i18n/en.json +3 -0
  7. package/admin/custom/i18n/es.json +3 -0
  8. package/admin/custom/i18n/fr.json +3 -0
  9. package/admin/custom/i18n/it.json +3 -0
  10. package/admin/custom/i18n/nl.json +3 -0
  11. package/admin/custom/i18n/pl.json +3 -0
  12. package/admin/custom/i18n/pt.json +3 -0
  13. package/admin/custom/i18n/ru.json +3 -0
  14. package/admin/custom/i18n/uk.json +3 -0
  15. package/admin/custom/i18n/zh-cn.json +3 -0
  16. package/admin/custom/index.html +1 -1
  17. package/admin/custom/mf-manifest.json +1 -1
  18. package/admin/custom/mf-stats.json +1 -1
  19. package/build/lib/voice/esphome.js +160 -19
  20. package/build/lib/voice/esphome.js.map +1 -1
  21. package/build/lib/voice/esphomeEntities.js +76 -6
  22. package/build/lib/voice/esphomeEntities.js.map +1 -1
  23. package/build/lib/voice/esphomeProto.js.map +1 -1
  24. package/build/lib/voice/flac.js +162 -0
  25. package/build/lib/voice/flac.js.map +1 -0
  26. package/build/lib/voice/mediaServer.js +8 -1
  27. package/build/lib/voice/mediaServer.js.map +1 -1
  28. package/build/lib/voice/replyFormat.js +158 -0
  29. package/build/lib/voice/replyFormat.js.map +1 -0
  30. package/build/lib/voice/stt.js +2 -3
  31. package/build/lib/voice/stt.js.map +1 -1
  32. package/build/main.js +153 -3
  33. package/build/main.js.map +1 -1
  34. package/io-package.json +14 -14
  35. package/package.json +1 -1
  36. package/admin/custom/assets/Components-CCl7INIF.js +0 -2
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.EsphomeSatellites = exports.TIMER_EVENT = exports.DEFAULT_SPEECH_LEVEL = void 0;
37
37
  exports.speechLevelOf = speechLevelOf;
38
+ exports.esphomeAddress = esphomeAddress;
38
39
  /**
39
40
  * ESPHome voice satellites — the fourth transport, next to the UDP server (`voiceServer.ts`) and the
40
41
  * Wyoming endpoint (`wyoming.ts`).
@@ -63,7 +64,7 @@ const esphomeProto_1 = require("./esphomeProto");
63
64
  const esphomeEntities_1 = require("./esphomeEntities");
64
65
  const vad_1 = require("./vad");
65
66
  const speechAudio_1 = require("./speechAudio");
66
- const stt_1 = require("./stt");
67
+ const replyFormat_1 = require("./replyFormat");
67
68
  const captures_1 = require("./captures");
68
69
  /** `VoiceAssistantEvent` (api.proto) — the subset we emit. */
69
70
  const EVENT = {
@@ -154,6 +155,16 @@ const STREAM_START_MS = 5000;
154
155
  const STALL_CHECK_MS = 250;
155
156
  /** Silence threshold as a share of the speech threshold — keeps the default pair 250/150. */
156
157
  const SILENCE_SHARE = 0.6;
158
+ /**
159
+ * The firmware answers an ERROR event with a stop request of its own (`signal_stop_`). One arriving this soon after
160
+ * our error belongs to it and needs no reply — the run is already over.
161
+ */
162
+ const ERROR_ECHO_MS = 2000;
163
+ /**
164
+ * What ends a run that produced no request. Home Assistant sends the same code when the speech recognition finds no
165
+ * text, and the Voice PE firmware deliberately plays no error sound for it.
166
+ */
167
+ const NO_TEXT_ERROR = { code: 'stt-no-text-recognized', message: 'No text recognized' };
157
168
  /**
158
169
  * A dropped utterance whose loudest frame reached this share of the speech threshold was probably a voice
159
170
  * that was too quiet, not an empty room — worth telling the user where to lower the threshold.
@@ -164,6 +175,12 @@ function speechLevelOf(device) {
164
175
  const level = Math.round(Number(device.speechLevel));
165
176
  return Number.isFinite(level) && level > 0 ? level : 0;
166
177
  }
178
+ /** How a configured device is identified: `ip`, or `ip:port` when it does not use the default port. */
179
+ function esphomeAddress(device) {
180
+ const ip = (device.ip || '').trim().toLowerCase();
181
+ const port = Number(device.port) || DEFAULT_PORT;
182
+ return port === DEFAULT_PORT ? ip : `${ip}:${port}`;
183
+ }
167
184
  /** `VoiceAssistantTimerEvent` in api.proto. */
168
185
  exports.TIMER_EVENT = { started: 0, updated: 1, cancelled: 2, finished: 3 };
169
186
  /**
@@ -293,13 +310,29 @@ class EsphomeConnection {
293
310
  password;
294
311
  /** Configured speech threshold, 0 = {@link DEFAULT_SPEECH_LEVEL}. */
295
312
  speechLevel;
313
+ /** `ip`, or `ip:port` for another port than the default — how the adapter recognises the table row. */
314
+ address;
315
+ /** The device's friendly name, for entities without a name of their own. */
316
+ friendlyName = '';
317
+ /**
318
+ * Number of the current pipeline run. A run whose number is no longer current was ended — the device asked to
319
+ * stop, started over, or went away — and whatever STT, answer or TTS is still in flight for it stays silent.
320
+ */
321
+ run = 0;
322
+ /** From RUN_START until we sent RUN_END: the device waits for that RUN_END. */
323
+ runOpen = false;
324
+ /** When we last sent an ERROR event (see {@link ERROR_ECHO_MS}). */
325
+ errorSentAt = 0;
326
+ /** The reply format last reported in the log, to log it again only when it changes. */
327
+ loggedFormat = '';
296
328
  constructor(device, opts) {
297
329
  this.opts = opts;
298
330
  this.host = device.ip.trim();
299
- this.port = device.port || DEFAULT_PORT;
331
+ this.port = Number(device.port) || DEFAULT_PORT;
300
332
  this.room = (device.room || '').trim();
301
333
  this.password = device.password || '';
302
334
  this.speechLevel = speechLevelOf(device);
335
+ this.address = esphomeAddress(device);
303
336
  this.deviceName = this.host;
304
337
  }
305
338
  // ── lifecycle ───────────────────────────────────────────────────────────────────────────────────
@@ -355,6 +388,9 @@ class EsphomeConnection {
355
388
  onDisconnected() {
356
389
  this.clearTimers();
357
390
  this.abortCapture();
391
+ // A reply still being worked out must not land on the next connection, in the middle of another run.
392
+ this.run++;
393
+ this.runOpen = false;
358
394
  if (this.online) {
359
395
  this.opts.log.info(`ESPHome satellite offline: ${this.deviceName}`);
360
396
  }
@@ -388,11 +424,11 @@ class EsphomeConnection {
388
424
  setOffline() {
389
425
  if (this.online) {
390
426
  this.online = false;
391
- this.opts.onStatus?.(this.deviceName, this.room, 'offline');
427
+ this.opts.onStatus?.(this.deviceName, this.room, 'offline', this.address);
392
428
  }
393
429
  }
394
430
  status(state) {
395
- this.opts.onStatus?.(this.deviceName, this.room, state);
431
+ this.opts.onStatus?.(this.deviceName, this.room, state, this.address);
396
432
  }
397
433
  // ── protocol plumbing ───────────────────────────────────────────────────────────────────────────
398
434
  send(message) {
@@ -479,6 +515,7 @@ class EsphomeConnection {
479
515
  case 'DeviceInfoResponse': {
480
516
  const info = message;
481
517
  this.deviceName = info.getName() || this.host;
518
+ this.friendlyName = info.getFriendlyName?.() || info.getName() || '';
482
519
  this.online = true;
483
520
  this.opts.log.info(`ESPHome satellite online: ${this.deviceName} (${info.getModel()}) @ ${this.host}${this.room ? `, room ${this.room}` : ''}`);
484
521
  this.status('idle');
@@ -556,7 +593,7 @@ class EsphomeConnection {
556
593
  }
557
594
  default:
558
595
  if ((0, esphomeEntities_1.isListEntityMessage)(name)) {
559
- this.entities.addFromAnnouncement(name, message);
596
+ this.entities.addFromAnnouncement(name, message, this.friendlyName);
560
597
  }
561
598
  else if ((0, esphomeEntities_1.isEntityStateMessage)(name)) {
562
599
  const entity = this.entities.applyState(name, message);
@@ -600,13 +637,19 @@ class EsphomeConnection {
600
637
  // ── voice pipeline ──────────────────────────────────────────────────────────────────────────────
601
638
  onPipelineRequest(request) {
602
639
  if (!request.getStart()) {
603
- this.abortCapture();
640
+ this.onStopRequest();
604
641
  return;
605
642
  }
606
643
  if (this.capture) {
607
644
  this.opts.log.debug(`ESPHome ${this.deviceName}: pipeline already running — ignoring wake`);
608
645
  return;
609
646
  }
647
+ if (this.runOpen) {
648
+ // The device started over while the last request was still being answered: that answer is not
649
+ // wanted any more, and its events would land in the middle of the new run.
650
+ this.opts.log.debug(`ESPHome ${this.deviceName}: new wake while answering — dropping the old reply`);
651
+ }
652
+ this.run++;
610
653
  const phrase = request.getWakeWordPhrase();
611
654
  this.opts.log.debug(`ESPHome ${this.deviceName}: wake "${phrase || '(follow-up)'}"`);
612
655
  const response = new esphomeProto_1.pb.VoiceAssistantResponse();
@@ -653,6 +696,29 @@ class EsphomeConnection {
653
696
  this.event(EVENT.STT_START);
654
697
  this.event(EVENT.STT_VAD_START);
655
698
  }
699
+ /**
700
+ * The device asks to end the conversation (`VoiceAssistantRequest{start:false}`): a button, or the wake word
701
+ * said again while a request is running — that is how ESPHome's voice_assistant stops. While it waits for an
702
+ * answer, the firmware leaves that state only on our RUN_END; without one it stays "in the conversation", and
703
+ * every further wake word just sends another stop (field report 2026-11-03).
704
+ */
705
+ onStopRequest() {
706
+ const running = !!this.capture || this.runOpen;
707
+ this.abortCapture();
708
+ if (running) {
709
+ this.run++; // whatever is still being answered stays silent
710
+ this.opts.log.debug(`ESPHome ${this.deviceName}: the device ended the conversation`);
711
+ this.event(EVENT.RUN_END);
712
+ this.status('idle');
713
+ return;
714
+ }
715
+ if (Date.now() - this.errorSentAt < ERROR_ECHO_MS) {
716
+ return; // the device's own reaction to our ERROR event
717
+ }
718
+ // Nothing is open on our side, but the device still thinks so (it missed a RUN_END): end it there too.
719
+ this.opts.log.debug(`ESPHome ${this.deviceName}: stop outside a conversation — ending it on the device`);
720
+ this.event(EVENT.RUN_END);
721
+ }
656
722
  onAudio(audio) {
657
723
  const capture = this.capture;
658
724
  if (!capture || capture.closed) {
@@ -718,6 +784,15 @@ class EsphomeConnection {
718
784
  this.capture = null;
719
785
  clearTimeout(capture.timer);
720
786
  clearInterval(capture.stallCheck);
787
+ const run = this.run;
788
+ /** False once the run was ended elsewhere — then nothing more may be sent for it. */
789
+ const current = () => {
790
+ if (run === this.run) {
791
+ return true;
792
+ }
793
+ this.opts.log.debug(`ESPHome ${this.deviceName}: conversation ended meanwhile — dropping the reply`);
794
+ return false;
795
+ };
721
796
  // Send this first: the device streams until it sees it.
722
797
  this.event(EVENT.STT_VAD_END);
723
798
  const pcm = Buffer.concat(capture.chunks);
@@ -747,13 +822,15 @@ class EsphomeConnection {
747
822
  this.opts.log.debug(`ESPHome ${this.deviceName}: nothing but silence${end === 'no-speech' ? ' (nobody started speaking)' : ''} — dropping the utterance`);
748
823
  }
749
824
  this.discard(capture, pcm, end === 'no-speech' ? 'no-speech' : 'silence', vad);
750
- this.event(EVENT.RUN_END);
751
- this.status('idle');
825
+ this.endWithoutRequest();
752
826
  return;
753
827
  }
754
828
  this.status('processing');
755
829
  try {
756
830
  const hints = this.opts.getHints ? await this.opts.getHints() : undefined;
831
+ if (!current()) {
832
+ return;
833
+ }
757
834
  const speech = (0, speechAudio_1.prepareForStt)(pcm, MIC_SAMPLE_RATE, { startMs: vad.speechStartMs, endLevel: vad.endLevel });
758
835
  vad.stt = { fromMs: speech.fromMs, toMs: speech.toMs, gain: speech.gain };
759
836
  this.opts.log.debug(`ESPHome ${this.deviceName}: speech recognition gets ${(speech.fromMs / 1000).toFixed(2)}–` +
@@ -761,16 +838,19 @@ class EsphomeConnection {
761
838
  const sttStart = Date.now();
762
839
  const text = (await this.opts.stt.transcribe(speech.pcm, MIC_SAMPLE_RATE, this.opts.language, hints)).trim();
763
840
  const sttMs = Date.now() - sttStart;
764
- this.event(EVENT.STT_END, { text });
841
+ if (!current()) {
842
+ return;
843
+ }
765
844
  if (!text) {
766
845
  // Speech was heard, so the user waits for an answer: say why none comes. Typically a word in another
767
846
  // language than the STT listens in (a German "Ja" to a Russian STT comes back empty).
768
847
  this.opts.log.info(`Voice Q (${this.deviceName}): (empty) — speech recognition returned no text for ${seconds.toFixed(1)} s of audio`);
769
848
  this.discard(capture, pcm, 'empty-stt', vad);
770
- this.event(EVENT.RUN_END);
771
- this.status('idle');
849
+ // No STT_END without text — like Home Assistant, which reports this case as an error only.
850
+ this.endWithoutRequest();
772
851
  return;
773
852
  }
853
+ this.event(EVENT.STT_END, { text });
774
854
  this.event(EVENT.INTENT_START);
775
855
  const answer = (await this.opts.answer(text, {
776
856
  device: this.deviceName,
@@ -781,6 +861,9 @@ class EsphomeConnection {
781
861
  // Numbers only, no audio: shows where a request was cut off without keeping recordings.
782
862
  vad: { ...vad, levels: (0, captures_1.levelProfile)(pcm, MIC_SAMPLE_RATE) },
783
863
  })).trim();
864
+ if (!current()) {
865
+ return;
866
+ }
784
867
  const followUp = !!answer && this.opts.expectsFollowUp?.(answer, this.deviceName) === true;
785
868
  this.event(EVENT.INTENT_END, followUp ? { continue_conversation: '1' } : undefined);
786
869
  if (!answer) {
@@ -790,11 +873,17 @@ class EsphomeConnection {
790
873
  }
791
874
  const ttsStart = Date.now();
792
875
  const { pcm: ttsPcm, sampleRate } = await this.opts.tts.synthesize(answer, this.opts.language);
876
+ if (!current()) {
877
+ return;
878
+ }
793
879
  this.opts.onSpoken?.(this.deviceName, {
794
880
  ms: Date.now() - ttsStart,
795
881
  audioMs: (ttsPcm.length / 2 / sampleRate) * 1000,
796
882
  });
797
- const url = this.publish(ttsPcm, sampleRate);
883
+ const url = await this.publish(ttsPcm, sampleRate);
884
+ if (!current()) {
885
+ return;
886
+ }
798
887
  this.status('speaking');
799
888
  this.event(EVENT.TTS_START, { text: answer });
800
889
  this.event(EVENT.TTS_END, { url });
@@ -804,12 +893,26 @@ class EsphomeConnection {
804
893
  catch (e) {
805
894
  const reason = e.message;
806
895
  this.opts.log.error(`ESPHome ${this.deviceName}: voice pipeline failed — ${reason}`);
896
+ if (run !== this.run) {
897
+ return; // the conversation is over already — nothing to tell the device
898
+ }
807
899
  // Without this the device stays ducked and its LED keeps spinning.
808
900
  this.event(EVENT.ERROR, { code: 'pipeline-failed', message: reason });
809
901
  this.event(EVENT.RUN_END);
810
902
  this.status('idle');
811
903
  }
812
904
  }
905
+ /**
906
+ * End a run that produced no request. A RUN_END right after STT_VAD_END gets lost: the firmware is still stopping
907
+ * its microphone then, ignores RUN_END in that state, and afterwards waits for an answer that never comes — the
908
+ * satellite stays in the conversation (field report 2026-11-03). An ERROR event puts it back to idle in any state,
909
+ * so it goes first, with the code Home Assistant uses for this case.
910
+ */
911
+ endWithoutRequest() {
912
+ this.event(EVENT.ERROR, NO_TEXT_ERROR);
913
+ this.event(EVENT.RUN_END);
914
+ this.status('idle');
915
+ }
813
916
  /**
814
917
  * Select which wake words listen on this device. Unknown ids are dropped and the list is trimmed
815
918
  * to what the firmware allows, because the device answers a rejected list with silence rather than
@@ -886,12 +989,12 @@ class EsphomeConnection {
886
989
  * Speak a ready PCM buffer on this device (timers, alarms, `tts.text`, jingles). With
887
990
  * `startConversation` the microphone opens once the clip has played, to catch the answer to a question.
888
991
  */
889
- announce(pcm, sampleRate, startConversation = false) {
992
+ async announce(pcm, sampleRate, startConversation = false) {
890
993
  if (!this.online || !pcm.length) {
891
- return Promise.resolve();
994
+ return;
892
995
  }
893
996
  const request = new esphomeProto_1.pb.VoiceAssistantAnnounceRequest();
894
- request.setMediaId(this.publish(pcm, sampleRate));
997
+ request.setMediaId(await this.publish(pcm, sampleRate));
895
998
  request.setText('');
896
999
  request.setPreannounceMediaId('');
897
1000
  // Needs feature flag 32 (START_CONVERSATION); on a device without it the clip still plays and
@@ -900,16 +1003,45 @@ class EsphomeConnection {
900
1003
  this.status('speaking');
901
1004
  this.send(request);
902
1005
  this.armPlaybackTimeout();
903
- return Promise.resolve();
1006
+ }
1007
+ /** The format replies should have on this device, from what its media player announced. */
1008
+ replyFormat() {
1009
+ const formats = this.entities
1010
+ .list()
1011
+ .filter(e => e.kind === 'mediaPlayer')
1012
+ .flatMap(e => e.formats ?? []);
1013
+ return (0, replyFormat_1.chooseReplyFormat)(formats, !!this.opts.encodeAudio);
904
1014
  }
905
1015
  /**
906
- * Publish PCM as a WAV on the media server and build the URL the device should fetch.
1016
+ * Publish PCM on the media server, in the format the device plays, and build the URL it should fetch.
907
1017
  *
908
1018
  * The host matters: on a multi-homed ioBroker machine the device can usually only reach one of the
909
1019
  * addresses, and the local end of *this* socket is by definition one it can talk to.
910
1020
  */
911
- publish(pcm, sampleRate) {
912
- return this.mediaUrl(this.opts.media.publish((0, stt_1.pcmToWav)(pcm, sampleRate), 'audio/wav'));
1021
+ async publish(pcm, sampleRate) {
1022
+ let format = this.replyFormat();
1023
+ let encoded;
1024
+ try {
1025
+ encoded = await (0, replyFormat_1.encodeReply)(pcm, sampleRate, format, this.opts.encodeAudio);
1026
+ }
1027
+ catch (e) {
1028
+ // MP3/Opus need ffmpeg. Without it a native format is the only chance, whether the device lists it or not.
1029
+ const fallback = { ...format, codec: 'flac', source: `${format.source}, FLAC as fallback` };
1030
+ this.opts.log.warn(`ESPHome ${this.deviceName}: cannot write the reply as ${format.codec.toUpperCase()} ` +
1031
+ `(${e.message}) — sending FLAC, which the device may not play. Install ffmpeg.`);
1032
+ format = fallback;
1033
+ encoded = await (0, replyFormat_1.encodeReply)(pcm, sampleRate, format);
1034
+ }
1035
+ const key = `${format.codec}/${format.sampleRate}/${format.channels}`;
1036
+ if (key !== this.loggedFormat) {
1037
+ this.loggedFormat = key;
1038
+ const what = !format.source
1039
+ ? 'WAV (the device lists no formats)'
1040
+ : `${format.codec.toUpperCase()}${format.sampleRate ? ` ${format.sampleRate} Hz` : ''}` +
1041
+ `${format.channels === 2 ? ' stereo' : ''} (device: ${format.source})`;
1042
+ this.opts.log.info(`ESPHome ${this.deviceName}: spoken replies as ${what}`);
1043
+ }
1044
+ return this.mediaUrl(this.opts.media.publish(encoded.body, encoded.contentType));
913
1045
  }
914
1046
  /** Absolute URL of a path on the media server, with a host this device can reach. */
915
1047
  mediaUrl(path) {
@@ -931,6 +1063,15 @@ class EsphomeConnection {
931
1063
  this.playbackTimer.unref();
932
1064
  }
933
1065
  event(type, data) {
1066
+ if (type === EVENT.RUN_START) {
1067
+ this.runOpen = true;
1068
+ }
1069
+ else if (type === EVENT.RUN_END) {
1070
+ this.runOpen = false;
1071
+ }
1072
+ else if (type === EVENT.ERROR) {
1073
+ this.errorSentAt = Date.now();
1074
+ }
934
1075
  const message = new esphomeProto_1.pb.VoiceAssistantEventResponse();
935
1076
  message.setEventType(type);
936
1077
  const items = [];