iobroker.assistant 1.1.0 → 1.2.0
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 +63 -1
- package/admin/custom/.vite/manifest.json +62 -62
- package/admin/custom/assets/Components-BSewCBfS.js +2 -0
- package/admin/custom/assets/{_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-A4gakomR.js → _virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-C5NPM0Of.js} +1 -1
- package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-CwqmfMvn.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-IaosaymG.js} +1 -1
- package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-BMuLYe6f.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-Cdyjt5KT.js} +39 -39
- package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__loadShare__shared-BxjZmnpc.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare__shared-Dsw5zWS8.js} +1 -1
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__prebuild___mf_0_iobroker_mf_1_json_mf_2_config__prebuild__-SgSagTOv.js +122 -0
- package/admin/custom/assets/{hostInit-4roC4hEj.js → hostInit-jJe8bIfV.js} +1 -1
- package/admin/custom/assets/{lib-CchzgTUs.js → lib-CL8mXvI6.js} +1 -1
- package/admin/custom/assets/{mf-entry-bootstrap-0-4806bbe4.js → mf-entry-bootstrap-0-ab227aa6.js} +2 -2
- package/admin/custom/assets/{pendingShares-DLlv8yFY.js → pendingShares-wrGtfliQ.js} +1 -1
- package/admin/custom/customComponents.js +2 -2
- package/admin/custom/i18n/de.json +40 -0
- package/admin/custom/i18n/en.json +40 -0
- package/admin/custom/i18n/es.json +40 -0
- package/admin/custom/i18n/fr.json +40 -0
- package/admin/custom/i18n/it.json +40 -0
- package/admin/custom/i18n/nl.json +40 -0
- package/admin/custom/i18n/pl.json +40 -0
- package/admin/custom/i18n/pt.json +40 -0
- package/admin/custom/i18n/ru.json +40 -0
- package/admin/custom/i18n/uk.json +40 -0
- package/admin/custom/i18n/zh-cn.json +40 -0
- 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 +6 -2
- package/admin/i18n/en.json +6 -2
- package/admin/i18n/es.json +6 -2
- package/admin/i18n/fr.json +6 -2
- package/admin/i18n/it.json +6 -2
- package/admin/i18n/nl.json +6 -2
- package/admin/i18n/pl.json +6 -2
- package/admin/i18n/pt.json +6 -2
- package/admin/i18n/ru.json +6 -2
- package/admin/i18n/uk.json +6 -2
- package/admin/i18n/zh-cn.json +6 -2
- package/admin/jsonConfig.json +29 -2
- package/build/lib/journal.js +298 -2
- package/build/lib/journal.js.map +1 -1
- package/build/lib/llm.js +105 -4
- package/build/lib/llm.js.map +1 -1
- package/build/lib/localLlm.js +47 -10
- package/build/lib/localLlm.js.map +1 -1
- package/build/lib/nlu.js +124 -5
- package/build/lib/nlu.js.map +1 -1
- package/build/lib/tools.js +3 -0
- package/build/lib/tools.js.map +1 -1
- package/build/lib/voice/esphome.js +81 -7
- package/build/lib/voice/esphome.js.map +1 -1
- package/build/lib/voice/esphomeProto.js.map +1 -1
- package/build/lib/voice/mediaServer.js +29 -1
- package/build/lib/voice/mediaServer.js.map +1 -1
- package/build/lib/voice/protocol.js.map +1 -1
- package/build/lib/voice/voiceServer.js +81 -7
- package/build/lib/voice/voiceServer.js.map +1 -1
- package/build/lib/voice/wakeWords.js +284 -0
- package/build/lib/voice/wakeWords.js.map +1 -0
- package/build/lib/voice/wyoming.js +12 -4
- package/build/lib/voice/wyoming.js.map +1 -1
- package/build/lib/weather.js +160 -0
- package/build/lib/weather.js.map +1 -1
- package/build/main.js +716 -74
- package/build/main.js.map +1 -1
- package/io-package.json +27 -25
- package/package.json +5 -5
- package/admin/custom/assets/Components-C6qcpaRC.js +0 -1
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__prebuild___mf_0_iobroker_mf_1_json_mf_2_config__prebuild__-TM4l0OYm.js +0 -122
- /package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-BkH7fzDw.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-BkH7fzDw.js} +0 -0
- /package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__prebuild__react_mf_1_jsx_mf_2_runtime__prebuild__-M61rjtmp.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__prebuild__react_mf_1_jsx_mf_2_runtime__prebuild__-M61rjtmp.js} +0 -0
package/build/main.js
CHANGED
|
@@ -63,6 +63,7 @@ const wyoming_1 = require("./lib/voice/wyoming");
|
|
|
63
63
|
const esphome_1 = require("./lib/voice/esphome");
|
|
64
64
|
const esphomeEntities_1 = require("./lib/voice/esphomeEntities");
|
|
65
65
|
const mediaServer_1 = require("./lib/voice/mediaServer");
|
|
66
|
+
const wakeWords_1 = require("./lib/voice/wakeWords");
|
|
66
67
|
const tone_1 = require("./lib/voice/tone");
|
|
67
68
|
const engines_1 = require("./lib/voice/engines");
|
|
68
69
|
/**
|
|
@@ -70,6 +71,20 @@ const engines_1 = require("./lib/voice/engines");
|
|
|
70
71
|
* so re-reading their whole state tree on every single request would be pure overhead.
|
|
71
72
|
*/
|
|
72
73
|
const WEATHER_CTX_TTL = 5 * 60 * 1000;
|
|
74
|
+
/**
|
|
75
|
+
* How long the NLU device model stays valid. Building it costs a `list_devices` plus a name lookup per
|
|
76
|
+
* device, and one spoken command needs it up to three times (STT hints, NLU, the cloud device list).
|
|
77
|
+
* Enum and name changes drop it at once (see {@link ListCache.onClear}); the TTL only catches devices
|
|
78
|
+
* that were added or renamed elsewhere.
|
|
79
|
+
*/
|
|
80
|
+
const NLU_DEVICES_TTL_MS = 60_000;
|
|
81
|
+
/** Default for `localLlmTimeoutSec`: after this the local model is stopped and the next tier answers. */
|
|
82
|
+
const LOCAL_LLM_TIMEOUT_SEC = 15;
|
|
83
|
+
/**
|
|
84
|
+
* Answer length cap of the local model. It answers small talk and general questions, mostly by voice, and
|
|
85
|
+
* on a Raspberry Pi every token costs about a fifth of a second — 512 tokens would be a minute and a half.
|
|
86
|
+
*/
|
|
87
|
+
const LOCAL_LLM_MAX_TOKENS = 256;
|
|
73
88
|
/**
|
|
74
89
|
* The replies the assistant speaks verbatim, whatever the question was. They are pre-synthesised into
|
|
75
90
|
* the TTS cache at startup; everything else is cached on first use.
|
|
@@ -163,7 +178,27 @@ function iaqLabel(value, unit, lang) {
|
|
|
163
178
|
return lang === 'ru' ? row[3] : lang === 'de' ? row[1] : row[2];
|
|
164
179
|
}
|
|
165
180
|
/** NLU actions that only switch something; their success is what the confirmation tone replaces. */
|
|
166
|
-
const CONTROL_ACTIONS = new Set(['on', 'off', 'level', 'color']);
|
|
181
|
+
const CONTROL_ACTIONS = new Set(['on', 'off', 'level', 'color', 'volumeSet']);
|
|
182
|
+
/** An intent in one line for the request trace: "level Rollo (Büro) = 30", "timerSet 300 s", "alarmSet 7:05". */
|
|
183
|
+
function describeIntent(intent) {
|
|
184
|
+
let text = intent.action;
|
|
185
|
+
if (intent.device) {
|
|
186
|
+
text += ` ${intent.device.name}${intent.device.room ? ` (${intent.device.room})` : ''}`;
|
|
187
|
+
}
|
|
188
|
+
else if (intent.room) {
|
|
189
|
+
text += ` (${intent.room})`;
|
|
190
|
+
}
|
|
191
|
+
if (intent.value !== undefined) {
|
|
192
|
+
text += ` = ${JSON.stringify(intent.value)}`;
|
|
193
|
+
}
|
|
194
|
+
else if (intent.durationSec !== undefined) {
|
|
195
|
+
text += ` ${intent.durationSec} s`;
|
|
196
|
+
}
|
|
197
|
+
else if (intent.hour !== undefined) {
|
|
198
|
+
text += ` ${intent.hour}:${String(intent.minute ?? 0).padStart(2, '0')}`;
|
|
199
|
+
}
|
|
200
|
+
return text;
|
|
201
|
+
}
|
|
167
202
|
const FIXED_REPLIES = {
|
|
168
203
|
de: ['Ok.', 'Erledigt.'],
|
|
169
204
|
en: ['Okay.', 'Done.'],
|
|
@@ -200,6 +235,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
200
235
|
journal = null;
|
|
201
236
|
/** Short-TTL cache for the expensive device/room/function listings; invalidated on enum changes. */
|
|
202
237
|
listCache = null;
|
|
238
|
+
/** The NLU device model of {@link getNluDevices}, shared by all callers (and concurrent requests). */
|
|
239
|
+
nluDevices = null;
|
|
203
240
|
/** Cache of a `<base>.SET` level state id → its writable boolean sibling switch (`<base>.ON_SET`/`.ON`), or ''. */
|
|
204
241
|
siblingSwitch = new Map();
|
|
205
242
|
/** Tier-1a local LLM (node-llama-cpp), lazily loaded when enabled + installed; null otherwise. */
|
|
@@ -216,6 +253,10 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
216
253
|
esphome = null;
|
|
217
254
|
/** Serves spoken replies to ESPHome satellites over HTTP; null unless those are enabled. */
|
|
218
255
|
media = null;
|
|
256
|
+
/** Uploaded wake words (`assistant.0/wakewords/`), offered to every ESPHome satellite. */
|
|
257
|
+
customWakeWords = new Map();
|
|
258
|
+
/** Loads {@link customWakeWords} once from the file storage; later calls wait for the same load. */
|
|
259
|
+
customWakeWordsLoaded = null;
|
|
219
260
|
/** Short-term per-source conversation memory (in-memory, TTL) for follow-up questions. */
|
|
220
261
|
context = new context_1.ConversationStore();
|
|
221
262
|
/** Questions the assistant asked and is waiting for an answer to (`askUser`), keyed by source. */
|
|
@@ -230,6 +271,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
230
271
|
mirroredTimers = new Map();
|
|
231
272
|
/** Per satellite: resolves once its control objects exist, so state writes can wait for them. */
|
|
232
273
|
satControlsReady = new Map();
|
|
274
|
+
/** Satellites whose `volume` state exists and is subscribed (see updateSatelliteVolume). */
|
|
275
|
+
satVolumeEnsured = new Set();
|
|
233
276
|
/** Native (ioBroker) satellite state-id → sender instance id, so we can push announcements back to it. */
|
|
234
277
|
nativeSatFrom = new Map();
|
|
235
278
|
/** Countdown timers / reminders (roadmap #2); mirrored into `timers.*` states. Null until onReady. */
|
|
@@ -304,6 +347,9 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
304
347
|
allowObjectChange: cfg.allowObjectChange,
|
|
305
348
|
}));
|
|
306
349
|
this.listCache = new tools_1.ListCache();
|
|
350
|
+
this.listCache.onClear = () => {
|
|
351
|
+
this.nluDevices = null;
|
|
352
|
+
};
|
|
307
353
|
const { tools, denied } = await (0, tools_1.buildMcpTools)(this.mcp, cfg, this.listCache, (id, fb) => this.resolveDeviceName(id, fb));
|
|
308
354
|
// Let the cloud LLM handle timers/alarms too (for phrasings the rule-based NLU misses).
|
|
309
355
|
tools.push(...this.buildTimerTools(), ...this.buildAlarmTools());
|
|
@@ -347,6 +393,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
347
393
|
maxTokens: cfg.maxTokens || 1024,
|
|
348
394
|
tools: tools.map(t => this.journalTool(t)),
|
|
349
395
|
log: this.log,
|
|
396
|
+
trace: step => this.journal?.step(step),
|
|
350
397
|
});
|
|
351
398
|
}
|
|
352
399
|
}
|
|
@@ -434,7 +481,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
434
481
|
language,
|
|
435
482
|
stt,
|
|
436
483
|
tts,
|
|
437
|
-
answer: (question, sttMs) => this.answer(question, 'wyoming', { transport: 'wyoming', sttMs }),
|
|
484
|
+
answer: (question, sttMs, audioMs) => this.answer(question, 'wyoming', { transport: 'wyoming', sttMs, audioMs }),
|
|
485
|
+
onSpoken: info => this.reportSpoken('wyoming', info),
|
|
438
486
|
getHints: () => this.buildSttHints(),
|
|
439
487
|
log: this.log,
|
|
440
488
|
});
|
|
@@ -489,6 +537,10 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
489
537
|
this.media = null;
|
|
490
538
|
return;
|
|
491
539
|
}
|
|
540
|
+
await this.loadCustomWakeWords();
|
|
541
|
+
for (const word of this.customWakeWords.values()) {
|
|
542
|
+
this.serveWakeWord(word);
|
|
543
|
+
}
|
|
492
544
|
this.esphome = new esphome_1.EsphomeSatellites({
|
|
493
545
|
devices,
|
|
494
546
|
language,
|
|
@@ -499,7 +551,10 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
499
551
|
room: origin.room,
|
|
500
552
|
wakeWord: origin.wakeWord,
|
|
501
553
|
sttMs: origin.sttMs,
|
|
554
|
+
audioMs: origin.audioMs,
|
|
555
|
+
vad: origin.vad,
|
|
502
556
|
}),
|
|
557
|
+
onSpoken: (device, info) => this.reportSpoken(device, info),
|
|
503
558
|
getHints: () => this.buildSttHints(),
|
|
504
559
|
media: this.media,
|
|
505
560
|
mediaHost: (cfg.esphomeMediaHost || '').trim(),
|
|
@@ -517,7 +572,15 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
517
572
|
onEntityState: (device, room, entity) => {
|
|
518
573
|
this.updateSatelliteControl(device, room, entity).catch(e => this.log.debug(`satellite control update failed: ${e.message}`));
|
|
519
574
|
},
|
|
520
|
-
expectsFollowUp: answer => this.
|
|
575
|
+
expectsFollowUp: (answer, device) => this.followUpFor(device, answer),
|
|
576
|
+
externalWakeWords: () => [...this.customWakeWords.values()].map(w => ({
|
|
577
|
+
id: w.id,
|
|
578
|
+
phrase: w.phrase,
|
|
579
|
+
languages: w.languages,
|
|
580
|
+
size: w.size,
|
|
581
|
+
sha256: w.sha256,
|
|
582
|
+
path: `${wakeWords_1.WAKE_WORD_URL_PREFIX}${w.id}.json`,
|
|
583
|
+
})),
|
|
521
584
|
});
|
|
522
585
|
await this.esphome.start();
|
|
523
586
|
this.log.info(`ESPHome satellites: ${devices.length} device(s), STT=${cfg.sttProvider || 'openai'}, TTS=${cfg.ttsProvider || 'openai'}.`);
|
|
@@ -572,7 +635,17 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
572
635
|
language,
|
|
573
636
|
stt,
|
|
574
637
|
tts,
|
|
575
|
-
answer: (question, ctx) => this.answerVoice(question, ctx.device, {
|
|
638
|
+
answer: (question, ctx) => this.answerVoice(question, ctx.device, {
|
|
639
|
+
transport: 'udp',
|
|
640
|
+
room: ctx.room,
|
|
641
|
+
sttMs: ctx.sttMs,
|
|
642
|
+
audioMs: ctx.audioMs,
|
|
643
|
+
}),
|
|
644
|
+
onSpoken: (device, info) => this.reportSpoken(device, info),
|
|
645
|
+
expectsFollowUp: (answer, device) => this.followUpFor(device, answer),
|
|
646
|
+
onVolume: (device, room, level) => {
|
|
647
|
+
this.updateSatelliteVolume(device, room, level).catch(e => this.log.debug(`satellite volume update failed: ${e.message}`));
|
|
648
|
+
},
|
|
576
649
|
getHints: () => this.buildSttHints(),
|
|
577
650
|
log: this.log,
|
|
578
651
|
onStatus: (device, room, state) => {
|
|
@@ -639,6 +712,23 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
639
712
|
expectsFollowUp(answer) {
|
|
640
713
|
return /[??]/.test(answer || '');
|
|
641
714
|
}
|
|
715
|
+
/** A transport synthesised the reply to `source`'s latest request — the journal shows it with that request. */
|
|
716
|
+
reportSpoken(source, info) {
|
|
717
|
+
const cfg = this.config;
|
|
718
|
+
const provider = cfg.ttsProvider || 'openai';
|
|
719
|
+
const voices = {
|
|
720
|
+
openai: cfg.ttsVoice || 'alloy',
|
|
721
|
+
azure: cfg.azureVoice,
|
|
722
|
+
aws: cfg.awsVoice,
|
|
723
|
+
piper: cfg.piperVoice,
|
|
724
|
+
};
|
|
725
|
+
this.journal?.spoken(source, {
|
|
726
|
+
...info,
|
|
727
|
+
provider,
|
|
728
|
+
fallback: cfg.ttsFallback || undefined,
|
|
729
|
+
voice: (voices[provider] || '').trim() || undefined,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
642
732
|
/** Record the origin of the current text.request/response ('' = state write, 'chat' = message, else satellite). */
|
|
643
733
|
setQuerySource(source) {
|
|
644
734
|
this.setStateAsync('text.querySource', { val: source, ack: true }).catch(() => { });
|
|
@@ -705,17 +795,18 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
705
795
|
room,
|
|
706
796
|
wakeWord: typeof msg.wakeWord === 'string' ? msg.wakeWord : undefined,
|
|
707
797
|
sttMs: Date.now() - sttStart,
|
|
798
|
+
audioMs: Math.round((pcm.length / 2 / rate) * 1000),
|
|
708
799
|
});
|
|
709
800
|
if (!answer) {
|
|
710
801
|
return { text, answer: '' };
|
|
711
802
|
}
|
|
803
|
+
const ttsStart = Date.now();
|
|
712
804
|
const reply = await engines.tts.synthesize(answer, language);
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
}
|
|
805
|
+
this.reportSpoken(source, {
|
|
806
|
+
ms: Date.now() - ttsStart,
|
|
807
|
+
audioMs: (reply.pcm.length / 2 / reply.sampleRate) * 1000,
|
|
808
|
+
});
|
|
809
|
+
const listen = this.followUpFor(source, answer);
|
|
719
810
|
return { text, answer, audio: reply.pcm.toString('base64'), sampleRate: reply.sampleRate, listen };
|
|
720
811
|
}
|
|
721
812
|
catch (e) {
|
|
@@ -778,6 +869,33 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
778
869
|
await this.setStateAsync(`${base}.room`, { val: room, ack: true });
|
|
779
870
|
}
|
|
780
871
|
}
|
|
872
|
+
/**
|
|
873
|
+
* Mirror the playback volume a UDP satellite reported into `satellites.<id>.volume`. The state only
|
|
874
|
+
* appears for satellites that speak the `volume` message — an older one would never answer a write.
|
|
875
|
+
*/
|
|
876
|
+
async updateSatelliteVolume(device, room, level) {
|
|
877
|
+
const id = await this.ensureSatelliteObjects(device, room);
|
|
878
|
+
const stateId = `satellites.${id}.volume`;
|
|
879
|
+
if (!this.satVolumeEnsured.has(id)) {
|
|
880
|
+
await this.setObjectNotExistsAsync(stateId, {
|
|
881
|
+
type: 'state',
|
|
882
|
+
common: {
|
|
883
|
+
name: 'Playback volume',
|
|
884
|
+
type: 'number',
|
|
885
|
+
role: 'level.volume',
|
|
886
|
+
unit: '%',
|
|
887
|
+
min: 0,
|
|
888
|
+
max: 100,
|
|
889
|
+
read: true,
|
|
890
|
+
write: true,
|
|
891
|
+
},
|
|
892
|
+
native: {},
|
|
893
|
+
});
|
|
894
|
+
this.subscribeStates(stateId);
|
|
895
|
+
this.satVolumeEnsured.add(id);
|
|
896
|
+
}
|
|
897
|
+
await this.setStateAsync(stateId, { val: level, ack: true });
|
|
898
|
+
}
|
|
781
899
|
/**
|
|
782
900
|
* Create the object tree for one satellite and return its (sanitised) state id, without touching
|
|
783
901
|
* any value. Callers that only need somewhere to hang a child object use this rather than
|
|
@@ -913,11 +1031,111 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
913
1031
|
this.satWakeWordStatesEnsured.add(id);
|
|
914
1032
|
}
|
|
915
1033
|
await this.setStateAsync(`${base}.wakeWords`, { val: config.active.join(','), ack: true });
|
|
1034
|
+
// `custom` marks the uploaded ones, so the settings dialog can offer to delete them.
|
|
1035
|
+
const words = config.available.map(w => (this.customWakeWords.has(w.id) ? { ...w, custom: true } : w));
|
|
916
1036
|
await this.setStateAsync(`${base}.availableWakeWords`, {
|
|
917
|
-
val: JSON.stringify({ max: config.max, words
|
|
1037
|
+
val: JSON.stringify({ max: config.max, words }),
|
|
918
1038
|
ack: true,
|
|
919
1039
|
});
|
|
920
1040
|
}
|
|
1041
|
+
/** Read the uploaded wake words (`<instance>/wakewords/<id>.json` + `.tflite`) once. */
|
|
1042
|
+
loadCustomWakeWords() {
|
|
1043
|
+
this.customWakeWordsLoaded ||= (async () => {
|
|
1044
|
+
let entries = [];
|
|
1045
|
+
try {
|
|
1046
|
+
entries = await this.readDirAsync(this.namespace, wakeWords_1.WAKE_WORD_DIR);
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
return; // nothing uploaded yet
|
|
1050
|
+
}
|
|
1051
|
+
for (const entry of entries) {
|
|
1052
|
+
const id = entry.file.replace(/\.json$/, '');
|
|
1053
|
+
if (entry.isDir || !entry.file.endsWith('.json') || !wakeWords_1.WAKE_WORD_ID_RE.test(id)) {
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
try {
|
|
1057
|
+
const config = await this.readStoredFile(`${wakeWords_1.WAKE_WORD_DIR}/${id}.json`, 'utf8');
|
|
1058
|
+
const model = await this.readStoredFile(`${wakeWords_1.WAKE_WORD_DIR}/${id}.tflite`, 'binary');
|
|
1059
|
+
const word = (0, wakeWords_1.loadWakeWord)(id, config, model);
|
|
1060
|
+
this.customWakeWords.set(id, word);
|
|
1061
|
+
// Uploaded before the upload checked the model: keep it, so it can be deleted in the dialog.
|
|
1062
|
+
const problem = (0, wakeWords_1.microModelProblem)(model);
|
|
1063
|
+
if (problem) {
|
|
1064
|
+
this.log.warn(`Custom wake word ${word.phrase} (${id}) cannot run on the device: ${problem}. Delete it in the satellite settings.`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
catch (e) {
|
|
1068
|
+
this.log.warn(`Custom wake word ${id} skipped: ${e.message}`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
if (this.customWakeWords.size) {
|
|
1072
|
+
this.log.info(`Custom wake words: ${[...this.customWakeWords.values()].map(w => `${w.phrase} (${w.id})`).join(', ')}`);
|
|
1073
|
+
}
|
|
1074
|
+
})();
|
|
1075
|
+
return this.customWakeWordsLoaded;
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Read a file from this instance's file storage as a Buffer. Depending on the backend and the file's
|
|
1079
|
+
* type it comes back as a Buffer or a string — `encoding` says how to read the latter.
|
|
1080
|
+
*/
|
|
1081
|
+
async readStoredFile(name, encoding) {
|
|
1082
|
+
const res = (await this.readFileAsync(this.namespace, name));
|
|
1083
|
+
const f = Buffer.isBuffer(res) || typeof res === 'string' ? res : res.file;
|
|
1084
|
+
if (f == null) {
|
|
1085
|
+
throw new Error(`${name} not found`);
|
|
1086
|
+
}
|
|
1087
|
+
return Buffer.isBuffer(f) ? f : Buffer.from(f, encoding);
|
|
1088
|
+
}
|
|
1089
|
+
serveWakeWord(word) {
|
|
1090
|
+
this.media?.serve(`${wakeWords_1.WAKE_WORD_URL_PREFIX}${word.id}.json`, word.config, 'application/json');
|
|
1091
|
+
this.media?.serve(`${wakeWords_1.WAKE_WORD_URL_PREFIX}${word.id}.tflite`, word.model, 'application/octet-stream');
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Store an uploaded wake word and offer it to the connected satellites. It shows up in their wake
|
|
1095
|
+
* word list right away; a device downloads the model only when it is selected there.
|
|
1096
|
+
*/
|
|
1097
|
+
async addCustomWakeWord(message) {
|
|
1098
|
+
await this.loadCustomWakeWords();
|
|
1099
|
+
const text = (value) => (typeof value === 'string' ? value : undefined);
|
|
1100
|
+
const languages = (Array.isArray(message.languages)
|
|
1101
|
+
? message.languages.map(String)
|
|
1102
|
+
: (text(message.languages) ?? '').split(/[,;\s]+/)).filter(Boolean);
|
|
1103
|
+
const word = (0, wakeWords_1.prepareWakeWord)({
|
|
1104
|
+
model: Buffer.from(text(message.model) ?? '', 'base64'),
|
|
1105
|
+
fileName: text(message.fileName),
|
|
1106
|
+
config: message.config && typeof message.config === 'object'
|
|
1107
|
+
? message.config
|
|
1108
|
+
: text(message.config),
|
|
1109
|
+
phrase: text(message.phrase),
|
|
1110
|
+
languages,
|
|
1111
|
+
}, this.config.voiceLanguage || this.language || '');
|
|
1112
|
+
if (!this.customWakeWords.has(word.id)) {
|
|
1113
|
+
await this.writeFileAsync(this.namespace, `${wakeWords_1.WAKE_WORD_DIR}/${word.id}.tflite`, word.model);
|
|
1114
|
+
await this.writeFileAsync(this.namespace, `${wakeWords_1.WAKE_WORD_DIR}/${word.id}.json`, word.config);
|
|
1115
|
+
this.customWakeWords.set(word.id, word);
|
|
1116
|
+
this.serveWakeWord(word);
|
|
1117
|
+
this.log.info(`Custom wake word uploaded: ${word.phrase} (${word.id}, ${word.size} bytes)`);
|
|
1118
|
+
}
|
|
1119
|
+
const devices = this.esphome?.refreshWakeWords() ?? 0;
|
|
1120
|
+
return { id: word.id, phrase: word.phrase, languages: word.languages, size: word.size, devices };
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Remove an uploaded wake word: it is no longer offered or served. A device that already downloaded
|
|
1124
|
+
* it keeps its copy — the ESPHome API cannot delete a model from a device.
|
|
1125
|
+
*/
|
|
1126
|
+
async deleteCustomWakeWord(id) {
|
|
1127
|
+
await this.loadCustomWakeWords();
|
|
1128
|
+
if (!wakeWords_1.WAKE_WORD_ID_RE.test(id) || !this.customWakeWords.has(id)) {
|
|
1129
|
+
throw new Error(`unknown wake word ${id}`);
|
|
1130
|
+
}
|
|
1131
|
+
for (const ext of ['json', 'tflite']) {
|
|
1132
|
+
await this.delFileAsync(this.namespace, `${wakeWords_1.WAKE_WORD_DIR}/${id}.${ext}`).catch(() => undefined);
|
|
1133
|
+
this.media?.unserve(`${wakeWords_1.WAKE_WORD_URL_PREFIX}${id}.${ext}`);
|
|
1134
|
+
}
|
|
1135
|
+
this.customWakeWords.delete(id);
|
|
1136
|
+
this.esphome?.refreshWakeWords();
|
|
1137
|
+
this.log.info(`Custom wake word deleted: ${id}`);
|
|
1138
|
+
}
|
|
921
1139
|
/**
|
|
922
1140
|
* Mirror everything an ESPHome satellite exposes besides the voice pipeline into
|
|
923
1141
|
* `satellites.<id>.controls.*` — microphone gain and volume, noise suppression, wake-word and
|
|
@@ -1281,7 +1499,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1281
1499
|
await Promise.all(allowed.map(d => this.voice.announce(d, pcm, sampleRate)));
|
|
1282
1500
|
delivered += allowed.length;
|
|
1283
1501
|
if (listen) {
|
|
1284
|
-
|
|
1502
|
+
const playMs = (pcm.length / 2 / sampleRate) * 1000;
|
|
1503
|
+
allowed.forEach(d => this.voice.listen(d, playMs));
|
|
1285
1504
|
}
|
|
1286
1505
|
}
|
|
1287
1506
|
catch (e) {
|
|
@@ -1630,6 +1849,15 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1630
1849
|
await this.setStateAsync('dnd', { val: this.globalDnd, ack: true });
|
|
1631
1850
|
return;
|
|
1632
1851
|
}
|
|
1852
|
+
// Playback volume of a UDP satellite — not acked here: the satellite reports the level it took.
|
|
1853
|
+
const satVolume = id.match(/\.satellites\.([^.]+)\.volume$/);
|
|
1854
|
+
if (satVolume) {
|
|
1855
|
+
const device = this.satDeviceById.get(satVolume[1]) || satVolume[1];
|
|
1856
|
+
if (!this.voice?.volume(device, { level: Number(state.val) })) {
|
|
1857
|
+
this.log.warn(`Cannot set the volume of '${device}': it is not a registered UDP satellite.`);
|
|
1858
|
+
}
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1633
1861
|
const satDnd = id.match(/\.satellites\.([^.]+)\.dnd$/);
|
|
1634
1862
|
if (satDnd) {
|
|
1635
1863
|
this.dndById.set(satDnd[1], !!state.val);
|
|
@@ -2014,6 +2242,41 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2014
2242
|
}
|
|
2015
2243
|
return;
|
|
2016
2244
|
}
|
|
2245
|
+
// Settings dialog / scripts: own wake words for the ESPHome satellites (microWakeWord models).
|
|
2246
|
+
// uploadWakeWord: { model: base64 .tflite, fileName?, config?: JSON text, phrase?, languages? }
|
|
2247
|
+
// → { ok, id, phrase, languages, size, devices } | { error }
|
|
2248
|
+
// deleteWakeWord: { id } → { ok } | { error }; listWakeWords → { wakeWords: [{id, phrase, …}] }
|
|
2249
|
+
if (obj.command === 'uploadWakeWord' || obj.command === 'deleteWakeWord' || obj.command === 'listWakeWords') {
|
|
2250
|
+
const m = (obj.message || {});
|
|
2251
|
+
let payload;
|
|
2252
|
+
try {
|
|
2253
|
+
if (obj.command === 'uploadWakeWord') {
|
|
2254
|
+
payload = { ok: true, ...(await this.addCustomWakeWord(m)) };
|
|
2255
|
+
}
|
|
2256
|
+
else if (obj.command === 'deleteWakeWord') {
|
|
2257
|
+
await this.deleteCustomWakeWord(typeof m.id === 'string' ? m.id : '');
|
|
2258
|
+
payload = { ok: true };
|
|
2259
|
+
}
|
|
2260
|
+
else {
|
|
2261
|
+
await this.loadCustomWakeWords();
|
|
2262
|
+
payload = {
|
|
2263
|
+
wakeWords: [...this.customWakeWords.values()].map(w => ({
|
|
2264
|
+
id: w.id,
|
|
2265
|
+
phrase: w.phrase,
|
|
2266
|
+
languages: w.languages,
|
|
2267
|
+
size: w.size,
|
|
2268
|
+
})),
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
catch (e) {
|
|
2273
|
+
payload = { error: e.message };
|
|
2274
|
+
}
|
|
2275
|
+
if (obj.callback) {
|
|
2276
|
+
this.sendTo(obj.from, obj.command, payload, obj.callback);
|
|
2277
|
+
}
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2017
2280
|
// Scripts: everything an ESPHome satellite exposes besides the voice pipeline.
|
|
2018
2281
|
// message = { device? }; without a device, every connected one is listed.
|
|
2019
2282
|
if (obj.command === 'getControls') {
|
|
@@ -2220,6 +2483,18 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2220
2483
|
}
|
|
2221
2484
|
return;
|
|
2222
2485
|
}
|
|
2486
|
+
// Admin chat debug view: one request with every step of its processing (message = { id }).
|
|
2487
|
+
if (obj.command === 'getJournalTrace') {
|
|
2488
|
+
const id = String(obj.message?.id || '');
|
|
2489
|
+
const entry = this.journal?.get(id);
|
|
2490
|
+
const result = entry
|
|
2491
|
+
? { entry, steps: await this.journal.trace(id) }
|
|
2492
|
+
: { error: this.journal?.enabled ? 'unknown request' : 'journal disabled' };
|
|
2493
|
+
if (obj.callback) {
|
|
2494
|
+
this.sendTo(obj.from, obj.command, result, obj.callback);
|
|
2495
|
+
}
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2223
2498
|
if (obj.command === 'clearJournal') {
|
|
2224
2499
|
this.journal?.clear();
|
|
2225
2500
|
if (obj.callback) {
|
|
@@ -2271,28 +2546,92 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2271
2546
|
*/
|
|
2272
2547
|
answer(question, source = '', origin = {}) {
|
|
2273
2548
|
return this.requestOrigin.run({ source, room: origin.room || '' }, () => this.journal
|
|
2274
|
-
? this.journal.run(question, source, origin, () =>
|
|
2549
|
+
? this.journal.run(question, source, origin, () => {
|
|
2550
|
+
this.traceSpeechInput(question, origin);
|
|
2551
|
+
return this.answerTracked(question, source);
|
|
2552
|
+
})
|
|
2275
2553
|
: this.answerTracked(question, source));
|
|
2276
2554
|
}
|
|
2555
|
+
/** Speech recognition happened before the request: put it at the head of its trace. */
|
|
2556
|
+
traceSpeechInput(question, origin) {
|
|
2557
|
+
if (origin.sttMs === undefined) {
|
|
2558
|
+
return;
|
|
2559
|
+
}
|
|
2560
|
+
const cfg = this.config;
|
|
2561
|
+
const provider = cfg.sttProvider || 'openai';
|
|
2562
|
+
const audio = origin.audioMs !== undefined ? `, ${(origin.audioMs / 1000).toFixed(1)} s of audio` : '';
|
|
2563
|
+
this.journal?.step({
|
|
2564
|
+
stage: 'stt',
|
|
2565
|
+
title: `Speech recognition: ${provider}${audio}`,
|
|
2566
|
+
ms: origin.sttMs,
|
|
2567
|
+
data: {
|
|
2568
|
+
provider,
|
|
2569
|
+
fallback: cfg.sttFallback || undefined,
|
|
2570
|
+
model: (cfg.sttModel || cfg.voskModel || '').trim() || undefined,
|
|
2571
|
+
language: cfg.voiceLanguage || this.language || '',
|
|
2572
|
+
audioMs: origin.audioMs,
|
|
2573
|
+
wakeWord: origin.wakeWord,
|
|
2574
|
+
vad: origin.vad,
|
|
2575
|
+
transcript: question,
|
|
2576
|
+
},
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2277
2579
|
async answerTracked(question, source) {
|
|
2278
2580
|
const useCtx = this.config.useConversationContext !== false;
|
|
2279
2581
|
const history = useCtx ? this.context.get(source) : [];
|
|
2582
|
+
if (history.length) {
|
|
2583
|
+
this.journal?.step({
|
|
2584
|
+
stage: 'context',
|
|
2585
|
+
title: `Conversation context: ${history.length} earlier turn(s) of '${source}'`,
|
|
2586
|
+
data: history,
|
|
2587
|
+
});
|
|
2588
|
+
}
|
|
2280
2589
|
const raw = await this.produceAnswer(question, history, source);
|
|
2281
2590
|
// The LLM signals "I'm waiting for the user's reply" by appending the [[LISTEN]] control marker
|
|
2282
|
-
// (see followUpHint). Strip it here so it's neither stored, shown nor spoken, and
|
|
2283
|
-
//
|
|
2284
|
-
|
|
2285
|
-
|
|
2591
|
+
// (see followUpHint). Strip it here so it's neither stored, shown nor spoken, and keep it per source:
|
|
2592
|
+
// two satellites answered at the same time must not hand each other their mic-on.
|
|
2593
|
+
const followUp = /\[\[LISTEN\]\]/i.test(raw);
|
|
2594
|
+
this.followUps.set(source, followUp);
|
|
2595
|
+
if (followUp) {
|
|
2286
2596
|
this.journal?.note({ followUp: true });
|
|
2287
2597
|
}
|
|
2288
2598
|
const result = raw.replace(/\s*\[\[LISTEN\]\]\s*/gi, ' ').trim();
|
|
2599
|
+
this.journal?.step({
|
|
2600
|
+
stage: 'answer',
|
|
2601
|
+
title: result
|
|
2602
|
+
? `Answer: ${result.length} characters${followUp ? ', waits for a reply' : ''}`
|
|
2603
|
+
: 'No spoken answer',
|
|
2604
|
+
data: { answer: result, raw: raw !== result ? raw : undefined, followUp },
|
|
2605
|
+
});
|
|
2289
2606
|
if (useCtx && result) {
|
|
2290
2607
|
this.context.add(source, question, result);
|
|
2291
2608
|
}
|
|
2292
2609
|
return result;
|
|
2293
2610
|
}
|
|
2294
|
-
/**
|
|
2295
|
-
|
|
2611
|
+
/** Per source: whether its last {@link answer} asked the user something (LLM-signalled via [[LISTEN]]). */
|
|
2612
|
+
followUps = new Map();
|
|
2613
|
+
/**
|
|
2614
|
+
* Should the satellite behind `source` re-open its mic after this reply? Primarily the LLM's own
|
|
2615
|
+
* [[LISTEN]] signal, with a question mark in the reply as the fallback (the NLU and the local tiers
|
|
2616
|
+
* cannot emit the marker, but they do ask "Meinst du Büro oder Wohnzimmer?").
|
|
2617
|
+
*/
|
|
2618
|
+
wantsFollowUp(source, answer) {
|
|
2619
|
+
if (!answer) {
|
|
2620
|
+
return null;
|
|
2621
|
+
}
|
|
2622
|
+
if (this.followUps.get(source)) {
|
|
2623
|
+
return 'llm';
|
|
2624
|
+
}
|
|
2625
|
+
return this.expectsFollowUp(answer) ? 'question' : null;
|
|
2626
|
+
}
|
|
2627
|
+
/** {@link wantsFollowUp} for a voice transport, logged, as the `listen` flag of the reply. */
|
|
2628
|
+
followUpFor(source, answer) {
|
|
2629
|
+
const why = this.wantsFollowUp(source, answer);
|
|
2630
|
+
if (why) {
|
|
2631
|
+
this.log.info(`→ mic-on to satellite '${source}' (${why === 'llm' ? 'LLM signalled a follow-up' : 'reply is a question'}).`);
|
|
2632
|
+
}
|
|
2633
|
+
return !!why;
|
|
2634
|
+
}
|
|
2296
2635
|
/**
|
|
2297
2636
|
* System-prompt instruction letting the LLM explicitly signal a follow-up: append [[LISTEN]] when it
|
|
2298
2637
|
* asks the user something and waits for an answer. More reliable than a "?" heuristic and lets a
|
|
@@ -2369,6 +2708,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2369
2708
|
const n = this.stopRinging();
|
|
2370
2709
|
this.log.info(`Ring stopped by voice (${n} session(s)).`);
|
|
2371
2710
|
this.journal?.note({ tier: 'stop' });
|
|
2711
|
+
this.journal?.step({ stage: 'stop', title: `Stop word: silenced ${n} ringing timer/alarm(s)` });
|
|
2372
2712
|
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
2373
2713
|
return lang === 'ru' ? 'Хорошо.' : lang === 'de' ? 'Ok.' : 'Okay.';
|
|
2374
2714
|
}
|
|
@@ -2380,6 +2720,11 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2380
2720
|
if (this.pending.deliver(source, question)) {
|
|
2381
2721
|
this.log.info(`Answer to "${asked}" from source '${source || 'any'}': ${question}`);
|
|
2382
2722
|
this.journal?.note({ tier: 'askReply', detail: asked || undefined });
|
|
2723
|
+
this.journal?.step({
|
|
2724
|
+
stage: 'askReply',
|
|
2725
|
+
title: `Reply to the open question "${asked}"`,
|
|
2726
|
+
data: { question: asked, reply: question },
|
|
2727
|
+
});
|
|
2383
2728
|
return '';
|
|
2384
2729
|
}
|
|
2385
2730
|
}
|
|
@@ -2390,9 +2735,17 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2390
2735
|
if (routine) {
|
|
2391
2736
|
this.log.info(`Routine '${routine.name}' triggered (source='${source}').`);
|
|
2392
2737
|
this.journal?.note({ tier: 'routine', detail: routine.name });
|
|
2738
|
+
this.journal?.step({ stage: 'routine', title: `Routine "${routine.name}" matched`, data: routine });
|
|
2393
2739
|
await this.runRoutine(routine, source);
|
|
2394
2740
|
return routine.reply;
|
|
2395
2741
|
}
|
|
2742
|
+
if (this.routines.length) {
|
|
2743
|
+
this.journal?.step({
|
|
2744
|
+
stage: 'routine',
|
|
2745
|
+
title: `No routine matched (${this.routines.length} configured)`,
|
|
2746
|
+
data: { phrases: this.routines.map(r => ({ name: r.name, phrases: r.phrases })) },
|
|
2747
|
+
});
|
|
2748
|
+
}
|
|
2396
2749
|
// Tier 0: rule-based NLU (device commands) — fastest, offline, free.
|
|
2397
2750
|
if (this.config.useLocalNlu) {
|
|
2398
2751
|
// "Spiele den Kanal X ab" first: the device NLU would find the TV in "… auf dem Fernseher" and
|
|
@@ -2401,9 +2754,19 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2401
2754
|
if (yt) {
|
|
2402
2755
|
this.log.info(`YouTube request understood locally: ${yt.kind} "${yt.query}" (source='${source}').`);
|
|
2403
2756
|
this.journal?.note({ tier: 'nlu', detail: `youtube ${yt.kind}` });
|
|
2757
|
+
const start = Date.now();
|
|
2404
2758
|
const res = await this.playYouTube(yt.query, yt.kind, yt.player);
|
|
2759
|
+
this.journal?.step({
|
|
2760
|
+
stage: 'youtube',
|
|
2761
|
+
title: `YouTube ${yt.kind} "${yt.query}"${yt.player ? ` on "${yt.player}"` : ''}`,
|
|
2762
|
+
ms: Date.now() - start,
|
|
2763
|
+
data: { parsed: yt, result: res },
|
|
2764
|
+
});
|
|
2405
2765
|
return res.text;
|
|
2406
2766
|
}
|
|
2767
|
+
if (this.youtubePlayers.length) {
|
|
2768
|
+
this.journal?.step({ stage: 'youtube', title: 'Not a YouTube command' });
|
|
2769
|
+
}
|
|
2407
2770
|
try {
|
|
2408
2771
|
const handled = await this.tryLocalNlu(question, source);
|
|
2409
2772
|
if (handled !== null) {
|
|
@@ -2413,36 +2776,49 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2413
2776
|
}
|
|
2414
2777
|
catch (e) {
|
|
2415
2778
|
this.log.debug(`NLU skipped: ${e.message}`);
|
|
2779
|
+
this.journal?.step({
|
|
2780
|
+
stage: 'nlu',
|
|
2781
|
+
level: 'warn',
|
|
2782
|
+
title: `NLU gave up, the request goes on: ${e.message}`,
|
|
2783
|
+
data: (0, journal_1.errorInfo)(e),
|
|
2784
|
+
});
|
|
2416
2785
|
}
|
|
2417
2786
|
}
|
|
2787
|
+
else {
|
|
2788
|
+
this.journal?.step({ stage: 'nlu', title: 'Rule-based NLU is switched off (useLocalNlu)' });
|
|
2789
|
+
}
|
|
2418
2790
|
// Current weather from the configured adapter — prepended to the user turn of BOTH LLM tiers so
|
|
2419
2791
|
// "how's the weather?" is answered from real data (the local model has no tools and would otherwise
|
|
2420
2792
|
// invent a forecast; the cloud model saves a get_weather round-trip). Cached, see buildWeatherContext.
|
|
2421
2793
|
const weather = await this.buildWeatherContext();
|
|
2422
2794
|
// Tier 1a: local LLM — answers general questions offline; emits HANDOFF for anything needing tools.
|
|
2795
|
+
// With a cloud LLM it only goes first when asked to (`localLlmFirst`): on a Raspberry Pi a small model
|
|
2796
|
+
// needs 10–25 s, often only to hand off, and the cloud then starts from zero. By default it is the
|
|
2797
|
+
// fallback for when the cloud fails (see below).
|
|
2423
2798
|
// Skipped inside an ongoing conversation (history present): the tool-free local model gets no history,
|
|
2424
2799
|
// so it would answer a follow-up like "yes" context-free and swallow it — let the cloud LLM (which has
|
|
2425
2800
|
// the thread and the tools) handle follow-ups instead. Without a cloud LLM there is nobody to hand
|
|
2426
2801
|
// follow-ups to, so the local model takes them too.
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
if (ans && !(0, localLlm_1.isHandoff)(ans)) {
|
|
2434
|
-
this.log.info(`Answered by local LLM (source='${source}').`);
|
|
2435
|
-
this.journal?.note({ tier: 'localLlm', detail: this.localLlmName() });
|
|
2436
|
-
return ans;
|
|
2437
|
-
}
|
|
2438
|
-
this.log.debug('Local LLM handed off to the cloud LLM.');
|
|
2439
|
-
}
|
|
2440
|
-
catch (e) {
|
|
2441
|
-
this.log.debug(`Local LLM error: ${e.message}`);
|
|
2802
|
+
const useLocal = !!(this.config.useLocalLlm && this.localLlm);
|
|
2803
|
+
const localFirst = useLocal && (!this.agent || !!this.config.localLlmFirst);
|
|
2804
|
+
if (localFirst && (!history.length || !this.agent)) {
|
|
2805
|
+
const ans = await this.askLocalLlm(question, weather, source);
|
|
2806
|
+
if (ans) {
|
|
2807
|
+
return ans;
|
|
2442
2808
|
}
|
|
2443
2809
|
}
|
|
2444
|
-
else if (
|
|
2810
|
+
else if (localFirst) {
|
|
2445
2811
|
this.log.debug('Local LLM skipped (follow-up in an ongoing conversation → cloud LLM).');
|
|
2812
|
+
this.journal?.step({
|
|
2813
|
+
stage: 'localLlm',
|
|
2814
|
+
title: 'Local LLM skipped: follow-up in an ongoing conversation goes to the cloud LLM',
|
|
2815
|
+
});
|
|
2816
|
+
}
|
|
2817
|
+
else if (useLocal) {
|
|
2818
|
+
this.journal?.step({
|
|
2819
|
+
stage: 'localLlm',
|
|
2820
|
+
title: 'Local LLM not asked first: it is the fallback for when the cloud LLM fails (localLlmFirst)',
|
|
2821
|
+
});
|
|
2446
2822
|
}
|
|
2447
2823
|
// Tier 2: cloud LLM with full tool access. Inject a compact device list into the system prompt so
|
|
2448
2824
|
// the model can act without a first `list_devices` round-trip (kept cached via prompt caching).
|
|
@@ -2451,6 +2827,11 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2451
2827
|
// read out as a technical message.
|
|
2452
2828
|
this.log.info(`No local tier could answer and no LLM provider is configured (source='${source}').`);
|
|
2453
2829
|
this.journal?.note({ tier: 'none' });
|
|
2830
|
+
this.journal?.step({
|
|
2831
|
+
stage: 'none',
|
|
2832
|
+
level: 'warn',
|
|
2833
|
+
title: 'Nothing local understood the request and no LLM provider is configured',
|
|
2834
|
+
});
|
|
2454
2835
|
return this.localOnlyFallback();
|
|
2455
2836
|
}
|
|
2456
2837
|
const ctx = await this.buildDeviceContext();
|
|
@@ -2462,12 +2843,99 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2462
2843
|
this.log.info(`Cloud LLM [${this.agent.model || 'auto'}] (source='${source}', context=${history.length} turn(s)${mem ? ', memory' : ''}${weather ? ', weather' : ''}${this.presence?.configured ? ', presence' : ''}) — running tool loop…`);
|
|
2463
2844
|
// Prepend the current date/time (and the weather line, if a source is configured) to the user turn —
|
|
2464
2845
|
// cache-safe, see buildTimeContext. The stored history keeps the untouched question.
|
|
2465
|
-
const
|
|
2846
|
+
const time = this.buildTimeContext();
|
|
2847
|
+
const questionForLlm = [time, weather, question].filter(Boolean).join('\n\n');
|
|
2466
2848
|
this.journal?.note({
|
|
2467
2849
|
tier: 'cloud',
|
|
2468
2850
|
detail: `${this.config.provider || 'openai'} · ${this.agent.model || 'auto'}`,
|
|
2469
2851
|
});
|
|
2470
|
-
|
|
2852
|
+
// What went into the prompt, piece by piece. The device list is only counted: the model's opening
|
|
2853
|
+
// step carries the whole system prompt, and with it the list.
|
|
2854
|
+
const deviceLines = ctx ? ctx.split('\n').length - 1 : 0;
|
|
2855
|
+
this.journal?.step({
|
|
2856
|
+
stage: 'context',
|
|
2857
|
+
title: `Prompt: ${deviceLines} devices${mem ? ', memory' : ''}${weather ? ', weather' : ''}${followUp ? ', follow-up marker' : ''}`,
|
|
2858
|
+
data: {
|
|
2859
|
+
systemPrompt: this.config.systemPrompt || '',
|
|
2860
|
+
followUpHint: followUp || undefined,
|
|
2861
|
+
memory: mem || undefined,
|
|
2862
|
+
devices: deviceLines,
|
|
2863
|
+
time,
|
|
2864
|
+
weather: weather || undefined,
|
|
2865
|
+
},
|
|
2866
|
+
});
|
|
2867
|
+
try {
|
|
2868
|
+
return await this.agent.ask(questionForLlm, sys, history);
|
|
2869
|
+
}
|
|
2870
|
+
catch (e) {
|
|
2871
|
+
// Offline, quota, outage: the local model is the fallback — unless it was already asked above.
|
|
2872
|
+
if (useLocal && !localFirst) {
|
|
2873
|
+
this.log.warn(`Cloud LLM failed (${e.message}) — asking the local LLM instead.`);
|
|
2874
|
+
this.journal?.step({
|
|
2875
|
+
stage: 'localLlm',
|
|
2876
|
+
level: 'warn',
|
|
2877
|
+
title: `Cloud LLM failed (${e.message}) — asking the local LLM instead`,
|
|
2878
|
+
});
|
|
2879
|
+
const ans = await this.askLocalLlm(question, weather, source);
|
|
2880
|
+
if (ans) {
|
|
2881
|
+
return ans;
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
throw e;
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
/**
|
|
2888
|
+
* Ask the local model, bounded by `localLlmTimeoutSec`. Returns its answer, or null when it handed off,
|
|
2889
|
+
* timed out or failed — the caller then goes on to the next tier.
|
|
2890
|
+
*/
|
|
2891
|
+
async askLocalLlm(question, weather, source) {
|
|
2892
|
+
if (!this.localLlm) {
|
|
2893
|
+
return null;
|
|
2894
|
+
}
|
|
2895
|
+
const timeoutMs = Math.max(1, Number(this.config.localLlmTimeoutSec) || LOCAL_LLM_TIMEOUT_SEC) * 1000;
|
|
2896
|
+
const name = this.localLlmName();
|
|
2897
|
+
// The local model has no tools: without these lines it would invent the weather and guess who is
|
|
2898
|
+
// at home.
|
|
2899
|
+
const facts = [weather, this.buildPresenceContext()].filter(Boolean).join('\n\n');
|
|
2900
|
+
const prompt = facts ? `${facts}\n\n${question}` : question;
|
|
2901
|
+
const busy = this.localLlm.running;
|
|
2902
|
+
const data = { model: name, timeoutMs, system: this.localLlm.systemPrompt(), prompt };
|
|
2903
|
+
const start = Date.now();
|
|
2904
|
+
try {
|
|
2905
|
+
const ans = await this.localLlm.ask(prompt, timeoutMs);
|
|
2906
|
+
const ms = Date.now() - start;
|
|
2907
|
+
if (ans && !(0, localLlm_1.isHandoff)(ans)) {
|
|
2908
|
+
this.log.info(`Answered by local LLM (source='${source}').`);
|
|
2909
|
+
this.journal?.note({ tier: 'localLlm', detail: name });
|
|
2910
|
+
this.journal?.step({
|
|
2911
|
+
stage: 'localLlm',
|
|
2912
|
+
title: `Local LLM ${name}: answered`,
|
|
2913
|
+
ms,
|
|
2914
|
+
data: { ...data, answer: ans },
|
|
2915
|
+
});
|
|
2916
|
+
return ans;
|
|
2917
|
+
}
|
|
2918
|
+
this.log.debug('Local LLM handed off.');
|
|
2919
|
+
this.journal?.step({
|
|
2920
|
+
stage: 'localLlm',
|
|
2921
|
+
title: busy
|
|
2922
|
+
? `Local LLM ${name}: busy with another question → handed off`
|
|
2923
|
+
: `Local LLM ${name}: handed off`,
|
|
2924
|
+
ms,
|
|
2925
|
+
data: { ...data, answer: ans },
|
|
2926
|
+
});
|
|
2927
|
+
}
|
|
2928
|
+
catch (e) {
|
|
2929
|
+
this.log.info(`Local LLM gave no answer: ${e.message}`);
|
|
2930
|
+
this.journal?.step({
|
|
2931
|
+
stage: 'localLlm',
|
|
2932
|
+
level: 'warn',
|
|
2933
|
+
title: `Local LLM ${name}: ${e.message}`,
|
|
2934
|
+
ms: Date.now() - start,
|
|
2935
|
+
data: { ...data, error: (0, journal_1.errorInfo)(e) },
|
|
2936
|
+
});
|
|
2937
|
+
}
|
|
2938
|
+
return null;
|
|
2471
2939
|
}
|
|
2472
2940
|
/** Reply when nothing local understood the request and there is no cloud LLM (provider "none"). */
|
|
2473
2941
|
localOnlyFallback() {
|
|
@@ -2578,35 +3046,27 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2578
3046
|
else {
|
|
2579
3047
|
// Switched off: do not keep what was recorded while it was on.
|
|
2580
3048
|
await fs.promises.rm(file, { force: true }).catch(() => { });
|
|
3049
|
+
await fs.promises.rm((0, journal_1.traceDirFor)(file), { recursive: true, force: true }).catch(() => { });
|
|
2581
3050
|
}
|
|
2582
3051
|
}
|
|
2583
|
-
/**
|
|
3052
|
+
/**
|
|
3053
|
+
* Wrap the MCP connection so every state the pipeline reads or writes lands in the journal entry — and a
|
|
3054
|
+
* call the NLU makes directly, with its result, in the request's trace.
|
|
3055
|
+
*/
|
|
2584
3056
|
journalMcp(mcp) {
|
|
2585
3057
|
return {
|
|
2586
3058
|
listTools: () => mcp.listTools(),
|
|
2587
|
-
callTool: (name, args) =>
|
|
2588
|
-
this.journal
|
|
2589
|
-
|
|
2590
|
-
},
|
|
3059
|
+
callTool: (name, args) => this.journal
|
|
3060
|
+
? this.journal.stateCall(name, args, () => mcp.callTool(name, args))
|
|
3061
|
+
: mcp.callTool(name, args),
|
|
2591
3062
|
close: () => mcp.close(),
|
|
2592
3063
|
};
|
|
2593
3064
|
}
|
|
2594
|
-
/** Wrap an LLM tool so its calls (arguments, result, duration) land in the journal entry. */
|
|
3065
|
+
/** Wrap an LLM tool so its calls (arguments, result, duration) land in the journal entry and its trace. */
|
|
2595
3066
|
journalTool(tool) {
|
|
2596
3067
|
return {
|
|
2597
3068
|
...tool,
|
|
2598
|
-
run:
|
|
2599
|
-
const start = Date.now();
|
|
2600
|
-
try {
|
|
2601
|
-
const result = await tool.run(args);
|
|
2602
|
-
this.journal?.tool(tool.name, args, Date.now() - start, result);
|
|
2603
|
-
return result;
|
|
2604
|
-
}
|
|
2605
|
-
catch (e) {
|
|
2606
|
-
this.journal?.tool(tool.name, args, Date.now() - start, undefined, e.message);
|
|
2607
|
-
throw e;
|
|
2608
|
-
}
|
|
2609
|
-
},
|
|
3069
|
+
run: args => (this.journal ? this.journal.toolCall(tool.name, args, () => tool.run(args)) : tool.run(args)),
|
|
2610
3070
|
};
|
|
2611
3071
|
}
|
|
2612
3072
|
/** Short name of the local model for the journal ("qwen2.5-1.5b-instruct-q4_k_m"). */
|
|
@@ -2704,7 +3164,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2704
3164
|
dataDir,
|
|
2705
3165
|
modelUrl: (this.config.localLlmModelUrl || '').trim() || localLlm_1.DEFAULT_LOCAL_MODEL_URL,
|
|
2706
3166
|
systemPrompt: this.config.systemPrompt || '',
|
|
2707
|
-
maxTokens: this.config.maxTokens ||
|
|
3167
|
+
maxTokens: Math.min(this.config.maxTokens || LOCAL_LLM_MAX_TOKENS, LOCAL_LLM_MAX_TOKENS),
|
|
2708
3168
|
log: this.log,
|
|
2709
3169
|
});
|
|
2710
3170
|
this.lastLlmPct = -1;
|
|
@@ -2737,20 +3197,56 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2737
3197
|
}
|
|
2738
3198
|
// Timer intents match device-independently (parse() checks them first), so build the NLU even
|
|
2739
3199
|
// when no devices are known and always try timers before bailing out.
|
|
3200
|
+
const start = Date.now();
|
|
2740
3201
|
const { rooms, devices } = await this.getNluDevices();
|
|
2741
|
-
const
|
|
2742
|
-
|
|
3202
|
+
const aliases = this.getNluAliases();
|
|
3203
|
+
const intents = new nlu_1.Nlu(rooms, devices, aliases, {
|
|
3204
|
+
weather: !!(this.config.weatherInstance || '').trim(),
|
|
3205
|
+
}).parseAll(question);
|
|
3206
|
+
const blockers = intents.map(i => this.nluBlocker(i, devices.length));
|
|
3207
|
+
this.journal?.step({
|
|
3208
|
+
stage: 'nlu',
|
|
3209
|
+
title: !intents.length
|
|
3210
|
+
? `NLU: nothing recognised (${devices.length} devices, ${rooms.length} rooms known)`
|
|
3211
|
+
: blockers.some(Boolean)
|
|
3212
|
+
? `NLU: ${intents.map(i => i.action).join(' + ')} — cannot run here: ${blockers.filter(Boolean).join('; ')}`
|
|
3213
|
+
: `NLU: ${intents.map(describeIntent).join(' + ')}`,
|
|
3214
|
+
level: intents.length && blockers.some(Boolean) ? 'warn' : undefined,
|
|
3215
|
+
ms: Date.now() - start,
|
|
3216
|
+
data: {
|
|
3217
|
+
devices: devices.length,
|
|
3218
|
+
rooms,
|
|
3219
|
+
aliases: aliases.length ? aliases : undefined,
|
|
3220
|
+
intents: intents.map((intent, i) => ({ ...intent, blockedBecause: blockers[i] || undefined })),
|
|
3221
|
+
},
|
|
3222
|
+
});
|
|
3223
|
+
if (!intents.length || blockers.some(Boolean)) {
|
|
2743
3224
|
return null; // nothing matched, or one part cannot run here → let the LLM answer the whole thing
|
|
2744
3225
|
}
|
|
2745
3226
|
this.journal?.note({ tier: 'nlu', detail: intents.map(i => i.action).join(' + ') });
|
|
2746
3227
|
const answers = [];
|
|
2747
3228
|
let failed = false;
|
|
2748
3229
|
for (const intent of intents) {
|
|
3230
|
+
const started = Date.now();
|
|
2749
3231
|
try {
|
|
2750
|
-
|
|
3232
|
+
const answer = await this.executeNluIntent(intent, source);
|
|
3233
|
+
answers.push(answer);
|
|
3234
|
+
this.journal?.step({
|
|
3235
|
+
stage: 'nlu',
|
|
3236
|
+
title: `Executed ${describeIntent(intent)}`,
|
|
3237
|
+
ms: Date.now() - started,
|
|
3238
|
+
data: { answer },
|
|
3239
|
+
});
|
|
2751
3240
|
}
|
|
2752
3241
|
catch (e) {
|
|
2753
3242
|
failed = true;
|
|
3243
|
+
this.journal?.step({
|
|
3244
|
+
stage: 'nlu',
|
|
3245
|
+
level: 'error',
|
|
3246
|
+
title: `${describeIntent(intent)} failed: ${e.message}${answers.length ? '' : ' → the LLM takes over'}`,
|
|
3247
|
+
ms: Date.now() - started,
|
|
3248
|
+
data: (0, journal_1.errorInfo)(e),
|
|
3249
|
+
});
|
|
2754
3250
|
// Nothing executed yet → hand the whole utterance to the LLM. Afterwards a state has already
|
|
2755
3251
|
// been written and the LLM would repeat it, so report the failed part instead of bailing out.
|
|
2756
3252
|
if (!answers.length) {
|
|
@@ -2773,35 +3269,44 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2773
3269
|
intents.every(i => CONTROL_ACTIONS.has(i.action)) &&
|
|
2774
3270
|
(await this.playConfirmationTone(source))) {
|
|
2775
3271
|
this.log.debug(`NLU confirmed with a tone instead of "${answer}".`);
|
|
3272
|
+
this.journal?.step({ stage: 'nlu', title: 'Confirmed with a tone instead of words', data: { answer } });
|
|
2776
3273
|
return '';
|
|
2777
3274
|
}
|
|
2778
3275
|
return answer;
|
|
2779
3276
|
}
|
|
2780
3277
|
/**
|
|
2781
|
-
*
|
|
2782
|
-
* is executed: a missing manager, no known devices or disabled writes send the whole
|
|
2783
|
-
* LLM (which can explain why) instead of silently doing nothing — or only half of it.
|
|
3278
|
+
* Why this intent cannot run locally ('' = it can). Checked for **every** part of a combined command
|
|
3279
|
+
* before anything is executed: a missing manager, no known devices or disabled writes send the whole
|
|
3280
|
+
* utterance to the LLM (which can explain why) instead of silently doing nothing — or only half of it.
|
|
2784
3281
|
*/
|
|
2785
|
-
|
|
3282
|
+
nluBlocker(intent, deviceCount) {
|
|
2786
3283
|
switch (intent.action) {
|
|
2787
3284
|
case 'timerSet':
|
|
2788
3285
|
case 'timerQuery':
|
|
2789
3286
|
case 'timerCancel':
|
|
2790
|
-
return
|
|
3287
|
+
return this.timers ? '' : 'timers are not running';
|
|
2791
3288
|
case 'alarmSet':
|
|
2792
3289
|
case 'alarmQuery':
|
|
2793
3290
|
case 'alarmCancel':
|
|
2794
|
-
return
|
|
3291
|
+
return this.alarms ? '' : 'alarms are not running';
|
|
2795
3292
|
case 'timeQuery':
|
|
2796
3293
|
case 'dateQuery':
|
|
2797
|
-
return
|
|
3294
|
+
return '';
|
|
3295
|
+
case 'weatherQuery':
|
|
3296
|
+
return (this.config.weatherInstance || '').trim() ? '' : 'no weather instance configured';
|
|
3297
|
+
case 'volumeSet':
|
|
3298
|
+
return this.voice || this.esphome || this.nativeSatFrom.size ? '' : 'no satellites';
|
|
2798
3299
|
default:
|
|
2799
3300
|
// Device intents; writes additionally require the coarse "allow control" toggle.
|
|
2800
|
-
|
|
2801
|
-
|
|
3301
|
+
if (!deviceCount) {
|
|
3302
|
+
return 'no devices known';
|
|
3303
|
+
}
|
|
3304
|
+
return ['on', 'off', 'level', 'color'].includes(intent.action) && !this.config.allowWriteStates
|
|
3305
|
+
? 'writing states is not allowed (allowWriteStates)'
|
|
3306
|
+
: '';
|
|
2802
3307
|
}
|
|
2803
3308
|
}
|
|
2804
|
-
/** Execute one intent that {@link
|
|
3309
|
+
/** Execute one intent that {@link nluBlocker} accepted, and return its spoken answer. */
|
|
2805
3310
|
async executeNluIntent(intent, source) {
|
|
2806
3311
|
switch (intent.action) {
|
|
2807
3312
|
case 'timerSet':
|
|
@@ -2815,10 +3320,97 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2815
3320
|
case 'timeQuery':
|
|
2816
3321
|
case 'dateQuery':
|
|
2817
3322
|
return this.executeTimeIntent(intent);
|
|
3323
|
+
case 'weatherQuery':
|
|
3324
|
+
return this.executeWeatherIntent(intent);
|
|
3325
|
+
case 'volumeSet':
|
|
3326
|
+
return this.executeVolumeIntent(intent, source);
|
|
2818
3327
|
default:
|
|
2819
3328
|
return this.executeIntent(intent);
|
|
2820
3329
|
}
|
|
2821
3330
|
}
|
|
3331
|
+
/**
|
|
3332
|
+
* Speak the weather straight from the configured adapter. Throws when the report has nothing for the
|
|
3333
|
+
* asked day (or the adapter has no mapper), so the question goes on to the LLM and its tool instead.
|
|
3334
|
+
*/
|
|
3335
|
+
async executeWeatherIntent(intent) {
|
|
3336
|
+
const res = await this.readWeather(this.config.weatherInstance || '');
|
|
3337
|
+
if (!res.report) {
|
|
3338
|
+
throw new Error(res.error || 'no weather report');
|
|
3339
|
+
}
|
|
3340
|
+
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
3341
|
+
const text = (0, weather_1.buildWeatherAnswer)(res.report, lang, intent.when);
|
|
3342
|
+
if (!text) {
|
|
3343
|
+
throw new Error(`the weather report has no data for '${intent.when || 'now'}'`);
|
|
3344
|
+
}
|
|
3345
|
+
return text;
|
|
3346
|
+
}
|
|
3347
|
+
/**
|
|
3348
|
+
* Change the playback volume of a satellite: the one in the named room, else the one that heard the
|
|
3349
|
+
* command. Each transport has its own knob — the native satellite's `volume` state, the media player
|
|
3350
|
+
* of an ESPHome device, the `volume` message of a UDP satellite.
|
|
3351
|
+
*/
|
|
3352
|
+
async executeVolumeIntent(intent, source) {
|
|
3353
|
+
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
3354
|
+
const satId = intent.room ? this.resolveSatelliteId(intent.room) : this.announceTargetForSource(source);
|
|
3355
|
+
const absolute = typeof intent.value === 'number' ? intent.value : null;
|
|
3356
|
+
const delta = intent.delta || 0;
|
|
3357
|
+
const next = (current) => Math.min(100, Math.max(0, Math.round(absolute ?? current + delta)));
|
|
3358
|
+
// 1 процент, 2 процента, 5 процентов (11–14 take the last form).
|
|
3359
|
+
const ruPercent = (n) => n % 100 > 10 && n % 100 < 20
|
|
3360
|
+
? 'процентов'
|
|
3361
|
+
: n % 10 === 1
|
|
3362
|
+
? 'процент'
|
|
3363
|
+
: n % 10 >= 2 && n % 10 <= 4
|
|
3364
|
+
? 'процента'
|
|
3365
|
+
: 'процентов';
|
|
3366
|
+
const confirm = (level) => lang === 'ru'
|
|
3367
|
+
? `Громкость ${level} ${ruPercent(level)}.`
|
|
3368
|
+
: lang === 'de'
|
|
3369
|
+
? `Lautstärke ${level} Prozent.`
|
|
3370
|
+
: `Volume ${level} percent.`;
|
|
3371
|
+
if (satId) {
|
|
3372
|
+
const device = this.satDeviceById.get(satId) || satId;
|
|
3373
|
+
const instance = this.nativeSatFrom.get(satId);
|
|
3374
|
+
if (instance) {
|
|
3375
|
+
const stateId = `${instance}.volume`;
|
|
3376
|
+
const current = Number((await this.getForeignStateAsync(stateId))?.val ?? 50);
|
|
3377
|
+
const level = next(Number.isFinite(current) ? current : 50);
|
|
3378
|
+
await this.setForeignStateAsync(stateId, level);
|
|
3379
|
+
return confirm(level);
|
|
3380
|
+
}
|
|
3381
|
+
const player = this.esphome?.entities(device).find(e => e.kind === 'mediaPlayer');
|
|
3382
|
+
if (player) {
|
|
3383
|
+
const current = (player.value?.volume ?? 0.5) * 100;
|
|
3384
|
+
const level = next(current);
|
|
3385
|
+
if (this.esphome.setEntity(device, player.objectId, level / 100)) {
|
|
3386
|
+
return confirm(level);
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
// A UDP satellite works out a relative step itself (it knows its level) and reports the result.
|
|
3390
|
+
if (this.voice?.has(device)) {
|
|
3391
|
+
this.voice.volume(device, absolute !== null ? { level: absolute } : { delta });
|
|
3392
|
+
if (absolute !== null) {
|
|
3393
|
+
return confirm(next(0));
|
|
3394
|
+
}
|
|
3395
|
+
return lang === 'ru'
|
|
3396
|
+
? delta > 0
|
|
3397
|
+
? 'Сделала громче.'
|
|
3398
|
+
: 'Сделала тише.'
|
|
3399
|
+
: lang === 'de'
|
|
3400
|
+
? delta > 0
|
|
3401
|
+
? 'Etwas lauter.'
|
|
3402
|
+
: 'Etwas leiser.'
|
|
3403
|
+
: delta > 0
|
|
3404
|
+
? 'A bit louder.'
|
|
3405
|
+
: 'A bit quieter.';
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
return lang === 'ru'
|
|
3409
|
+
? 'Я не знаю, громкость какой колонки изменить.'
|
|
3410
|
+
: lang === 'de'
|
|
3411
|
+
? 'Ich weiß nicht, bei welchem Lautsprecher ich die Lautstärke ändern soll.'
|
|
3412
|
+
: "I don't know which speaker's volume to change.";
|
|
3413
|
+
}
|
|
2822
3414
|
/** Localized "this part did not work" sentence for a failed part of a combined command. */
|
|
2823
3415
|
nluFailureText(intent) {
|
|
2824
3416
|
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
@@ -2868,14 +3460,36 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2868
3460
|
return [];
|
|
2869
3461
|
}
|
|
2870
3462
|
}
|
|
2871
|
-
/**
|
|
2872
|
-
|
|
3463
|
+
/**
|
|
3464
|
+
* Device model for the NLU: friendly name, room, type and controls (controlType → state id). Cached
|
|
3465
|
+
* for {@link NLU_DEVICES_TTL_MS}; requests arriving while it is being built wait for the same build.
|
|
3466
|
+
*/
|
|
3467
|
+
getNluDevices() {
|
|
2873
3468
|
if (!this.mcp) {
|
|
2874
|
-
return { rooms: [], devices: [] };
|
|
3469
|
+
return Promise.resolve({ rooms: [], devices: [] });
|
|
2875
3470
|
}
|
|
2876
3471
|
// Match names in the assistant's interaction language (voice language, else system) so a
|
|
2877
3472
|
// Russian command matches Russian smartNames.
|
|
2878
3473
|
const nluLang = (this.config.voiceLanguage || this.language || 'en');
|
|
3474
|
+
const now = Date.now();
|
|
3475
|
+
const hit = this.nluDevices;
|
|
3476
|
+
if (hit && hit.key === nluLang && now - hit.at < NLU_DEVICES_TTL_MS) {
|
|
3477
|
+
return hit.result;
|
|
3478
|
+
}
|
|
3479
|
+
const entry = { key: nluLang, at: now, result: this.buildNluDevices(nluLang) };
|
|
3480
|
+
this.nluDevices = entry;
|
|
3481
|
+
// An empty model is usually a failed build (the manager not ready yet) — don't keep it for a minute.
|
|
3482
|
+
void entry.result.then(r => {
|
|
3483
|
+
if (!r.devices.length && this.nluDevices === entry) {
|
|
3484
|
+
this.nluDevices = null;
|
|
3485
|
+
}
|
|
3486
|
+
});
|
|
3487
|
+
return entry.result;
|
|
3488
|
+
}
|
|
3489
|
+
async buildNluDevices(nluLang) {
|
|
3490
|
+
if (!this.mcp) {
|
|
3491
|
+
return { rooms: [], devices: [] };
|
|
3492
|
+
}
|
|
2879
3493
|
try {
|
|
2880
3494
|
const res = await this.mcp.callTool('list_devices', { language: nluLang });
|
|
2881
3495
|
const parsed = JSON.parse(res.text);
|
|
@@ -3507,16 +4121,30 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3507
4121
|
if (!this.config.verifyWrites || !stateId) {
|
|
3508
4122
|
return true;
|
|
3509
4123
|
}
|
|
3510
|
-
const
|
|
4124
|
+
const start = Date.now();
|
|
4125
|
+
const deadline = start + ACK_TIMEOUT_MS;
|
|
3511
4126
|
let last;
|
|
3512
4127
|
while (Date.now() < deadline) {
|
|
3513
4128
|
await this.delay(ACK_POLL_MS);
|
|
3514
4129
|
last = await this.getForeignStateAsync(stateId).catch(() => null);
|
|
3515
4130
|
if (last?.ack && valuesMatch(last.val, expected)) {
|
|
3516
4131
|
this.log.debug(`write to ${stateId} confirmed by the device.`);
|
|
4132
|
+
this.journal?.step({
|
|
4133
|
+
stage: 'state',
|
|
4134
|
+
title: `${stateId} confirmed by the device`,
|
|
4135
|
+
ms: Date.now() - start,
|
|
4136
|
+
data: { expected, state: last },
|
|
4137
|
+
});
|
|
3517
4138
|
return true;
|
|
3518
4139
|
}
|
|
3519
4140
|
}
|
|
4141
|
+
this.journal?.step({
|
|
4142
|
+
stage: 'state',
|
|
4143
|
+
level: 'warn',
|
|
4144
|
+
title: `${stateId}: no confirmation within ${ACK_TIMEOUT_MS} ms`,
|
|
4145
|
+
ms: Date.now() - start,
|
|
4146
|
+
data: { expected, state: last },
|
|
4147
|
+
});
|
|
3520
4148
|
this.log.warn(`No confirmation for ${stateId} within ${ACK_TIMEOUT_MS} ms ` +
|
|
3521
4149
|
`(last: ${JSON.stringify(last?.val)}, ack=${String(last?.ack)}). ` +
|
|
3522
4150
|
'If this device never acknowledges, switch "Verify device feedback" off.');
|
|
@@ -4729,12 +5357,26 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
4729
5357
|
*/
|
|
4730
5358
|
async runRoutine(routine, source) {
|
|
4731
5359
|
const origin = this.announceTargetForSource(source);
|
|
4732
|
-
for (const action of routine.actions) {
|
|
5360
|
+
for (const [i, action] of routine.actions.entries()) {
|
|
5361
|
+
const start = Date.now();
|
|
4733
5362
|
try {
|
|
4734
5363
|
await this.runTriggerAction({ id: `routine:${routine.name}`, when: [] }, action, origin);
|
|
5364
|
+
this.journal?.step({
|
|
5365
|
+
stage: 'routine',
|
|
5366
|
+
title: `Routine action ${i + 1}/${routine.actions.length}`,
|
|
5367
|
+
ms: Date.now() - start,
|
|
5368
|
+
data: action,
|
|
5369
|
+
});
|
|
4735
5370
|
}
|
|
4736
5371
|
catch (e) {
|
|
4737
5372
|
this.log.warn(`Routine '${routine.name}': an action failed — ${e.message}`);
|
|
5373
|
+
this.journal?.step({
|
|
5374
|
+
stage: 'routine',
|
|
5375
|
+
level: 'error',
|
|
5376
|
+
title: `Routine action ${i + 1}/${routine.actions.length} failed: ${e.message}`,
|
|
5377
|
+
ms: Date.now() - start,
|
|
5378
|
+
data: { action, error: (0, journal_1.errorInfo)(e) },
|
|
5379
|
+
});
|
|
4738
5380
|
}
|
|
4739
5381
|
}
|
|
4740
5382
|
}
|