iobroker.assistant 1.0.2 → 1.1.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 +6 -0
- package/admin/custom/.vite/manifest.json +91 -87
- package/admin/custom/assets/Components-C6qcpaRC.js +1 -0
- package/admin/custom/assets/{_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-DcAGnE2z.js → _virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-A4gakomR.js} +1 -1
- package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-CzBMxGFr.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-BkH7fzDw.js} +1 -1
- package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-1XsLCK2M.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-CwqmfMvn.js} +1 -1
- package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-CMv8bcyA.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-BMuLYe6f.js} +80 -97
- package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare__shared-3mzaJWDF.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__loadShare__shared-BxjZmnpc.js} +2 -2
- 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 +122 -0
- package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__prebuild__react_mf_1_jsx_mf_2_runtime__prebuild__-B_v57Qfr.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__prebuild__react_mf_1_jsx_mf_2_runtime__prebuild__-M61rjtmp.js} +1 -1
- package/admin/custom/assets/{dist-DiU1y0R9.js → dist-B3E7IYuQ.js} +1 -1
- package/admin/custom/assets/helpers-DE61bb74.js +1 -0
- package/admin/custom/assets/{hostInit-MshDicd1.js → hostInit-4roC4hEj.js} +1 -1
- package/admin/custom/assets/{lib-DjwHKtRi.js → lib-CchzgTUs.js} +1 -1
- package/admin/custom/assets/{mf-entry-bootstrap-0-7c1ddf47.js → mf-entry-bootstrap-0-4806bbe4.js} +2 -2
- package/admin/custom/assets/{pendingShares-CaJDCKLB.js → pendingShares-DLlv8yFY.js} +2 -2
- package/admin/custom/assets/prod-ChPGnyiA.js +15 -0
- package/admin/custom/assets/rolldown-runtime-COiEfhsA.js +1 -0
- package/admin/custom/assets/utils-Bi7ATSfV.js +4 -0
- package/admin/custom/customComponents.js +2 -2
- package/admin/custom/i18n/de.json +25 -0
- package/admin/custom/i18n/en.json +25 -0
- package/admin/custom/i18n/es.json +25 -0
- package/admin/custom/i18n/fr.json +25 -0
- package/admin/custom/i18n/it.json +25 -0
- package/admin/custom/i18n/nl.json +25 -0
- package/admin/custom/i18n/pl.json +25 -0
- package/admin/custom/i18n/pt.json +25 -0
- package/admin/custom/i18n/ru.json +25 -0
- package/admin/custom/i18n/uk.json +25 -0
- package/admin/custom/i18n/zh-cn.json +25 -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 +9 -1
- package/admin/i18n/en.json +9 -1
- package/admin/i18n/es.json +9 -1
- package/admin/i18n/fr.json +9 -1
- package/admin/i18n/it.json +9 -1
- package/admin/i18n/nl.json +9 -1
- package/admin/i18n/pl.json +9 -1
- package/admin/i18n/pt.json +9 -1
- package/admin/i18n/ru.json +9 -1
- package/admin/i18n/uk.json +9 -1
- package/admin/i18n/zh-cn.json +9 -1
- package/admin/jsonConfig.json +45 -0
- package/build/lib/journal.js +273 -0
- package/build/lib/journal.js.map +1 -0
- package/build/lib/voice/esphome.js +9 -1
- package/build/lib/voice/esphome.js.map +1 -1
- package/build/lib/voice/voiceServer.js +3 -1
- package/build/lib/voice/voiceServer.js.map +1 -1
- package/build/lib/voice/wyoming.js +3 -1
- package/build/lib/voice/wyoming.js.map +1 -1
- package/build/lib/youtube.js +625 -0
- package/build/lib/youtube.js.map +1 -0
- package/build/main.js +580 -12
- package/build/main.js.map +1 -1
- package/io-package.json +50 -1
- package/package.json +7 -7
- package/admin/custom/assets/Components-7BDDNrGP.js +0 -1
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__prebuild___mf_0_iobroker_mf_1_json_mf_2_config__prebuild__-DS8oaQQt.js +0 -122
- package/admin/custom/assets/helpers-Dot7vgQQ.js +0 -1
- package/admin/custom/assets/rolldown-runtime-Dd_uD5pT.js +0 -1
- package/admin/custom/assets/utils-towTPKqG.js +0 -4
package/build/main.js
CHANGED
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
const os = __importStar(require("node:os"));
|
|
37
37
|
const fs = __importStar(require("node:fs"));
|
|
38
38
|
const node_child_process_1 = require("node:child_process");
|
|
39
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
39
40
|
const adapter_core_1 = require("@iobroker/adapter-core");
|
|
40
41
|
const path = __importStar(require("node:path"));
|
|
41
42
|
const mcp_server_1 = require("@iobroker/mcp-server");
|
|
@@ -46,6 +47,7 @@ const timers_1 = require("./lib/timers");
|
|
|
46
47
|
const alarms_1 = require("./lib/alarms");
|
|
47
48
|
const memory_1 = require("./lib/memory");
|
|
48
49
|
const weather_1 = require("./lib/weather");
|
|
50
|
+
const journal_1 = require("./lib/journal");
|
|
49
51
|
const localLlm_1 = require("./lib/localLlm");
|
|
50
52
|
const credentials_1 = require("./lib/credentials");
|
|
51
53
|
const context_1 = require("./lib/context");
|
|
@@ -53,6 +55,7 @@ const ask_1 = require("./lib/ask");
|
|
|
53
55
|
const presence_1 = require("./lib/presence");
|
|
54
56
|
const targets_1 = require("./lib/targets");
|
|
55
57
|
const routines_1 = require("./lib/routines");
|
|
58
|
+
const youtube_1 = require("./lib/youtube");
|
|
56
59
|
const notifications_1 = require("./lib/notifications");
|
|
57
60
|
const triggers_1 = require("./lib/triggers");
|
|
58
61
|
const voiceServer_1 = require("./lib/voice/voiceServer");
|
|
@@ -74,6 +77,12 @@ const WEATHER_CTX_TTL = 5 * 60 * 1000;
|
|
|
74
77
|
/** How long to wait for a device to confirm a write, and how often to look. */
|
|
75
78
|
const ACK_TIMEOUT_MS = 2000;
|
|
76
79
|
const ACK_POLL_MS = 150;
|
|
80
|
+
/** How long a TV may take to report "on" after it was switched on for YouTube. */
|
|
81
|
+
const TV_POWER_TIMEOUT_MS = 30_000;
|
|
82
|
+
/** How long youtube-remote may take to acknowledge a play command (it goes through Google's lounge). */
|
|
83
|
+
const YOUTUBE_ACK_TIMEOUT_MS = 8000;
|
|
84
|
+
/** Pause before the play command is repeated once on a TV that just booted. */
|
|
85
|
+
const YOUTUBE_RETRY_MS = 5000;
|
|
77
86
|
/**
|
|
78
87
|
* Did the device end up with the value we asked for? Compared loosely: a dimmer told `30` may report
|
|
79
88
|
* `30.0`, a switch told `true` may report `1`, and a lamp told 100 % may settle at 99 % — insisting on
|
|
@@ -187,6 +196,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
187
196
|
/** Set once `onReady` initialised the pipeline — with or without a cloud {@link agent}. */
|
|
188
197
|
ready = false;
|
|
189
198
|
mcp = null;
|
|
199
|
+
/** Who asked what and how it was answered (admin chat); null until `onReady` read the config. */
|
|
200
|
+
journal = null;
|
|
190
201
|
/** Short-TTL cache for the expensive device/room/function listings; invalidated on enum changes. */
|
|
191
202
|
listCache = null;
|
|
192
203
|
/** Cache of a `<base>.SET` level state id → its writable boolean sibling switch (`<base>.ON_SET`/`.ON`), or ''. */
|
|
@@ -245,6 +256,13 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
245
256
|
routines = [];
|
|
246
257
|
/** Named announcement targets (roadmap B2): groups of rooms and people, from the configuration. */
|
|
247
258
|
announceTargets = [];
|
|
259
|
+
/** YouTube players from the configuration; the first one is the default. */
|
|
260
|
+
youtubePlayers = [];
|
|
261
|
+
/**
|
|
262
|
+
* Where the request being answered came from, for the LLM tools: a tool only gets the model's
|
|
263
|
+
* arguments, but "play X" from the bedroom satellite should go to the bedroom TV.
|
|
264
|
+
*/
|
|
265
|
+
requestOrigin = new node_async_hooks_1.AsyncLocalStorage();
|
|
248
266
|
/** Who is at home (roadmap B1); mirrored into `presence.*`. Null until onReady. */
|
|
249
267
|
presence = null;
|
|
250
268
|
/** Satellites currently set to Do-Not-Disturb, by state id (mirrors `satellites.<id>.dnd`). */
|
|
@@ -270,6 +288,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
270
288
|
const provider = cfg.provider || 'openai';
|
|
271
289
|
const localOnly = provider === 'none';
|
|
272
290
|
const apiKey = localOnly ? '' : (0, llm_1.effectiveApiKey)(cfg.provider, await (0, credentials_1.resolveApiKey)(this, cfg));
|
|
291
|
+
await this.setupJournal();
|
|
273
292
|
if (!localOnly && !apiKey) {
|
|
274
293
|
this.log.warn(cfg.credentialType === 'manager'
|
|
275
294
|
? 'No credential selected (manager mode) — pick an API-key credential in the adapter settings.'
|
|
@@ -278,12 +297,12 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
278
297
|
return;
|
|
279
298
|
}
|
|
280
299
|
try {
|
|
281
|
-
this.mcp = await (0, mcp_server_1.createInProcessMcp)({
|
|
300
|
+
this.mcp = this.journalMcp(await (0, mcp_server_1.createInProcessMcp)({
|
|
282
301
|
adapter: this,
|
|
283
302
|
language: this.language,
|
|
284
303
|
allowSetState: cfg.allowWriteStates,
|
|
285
304
|
allowObjectChange: cfg.allowObjectChange,
|
|
286
|
-
});
|
|
305
|
+
}));
|
|
287
306
|
this.listCache = new tools_1.ListCache();
|
|
288
307
|
const { tools, denied } = await (0, tools_1.buildMcpTools)(this.mcp, cfg, this.listCache, (id, fb) => this.resolveDeviceName(id, fb));
|
|
289
308
|
// Let the cloud LLM handle timers/alarms too (for phrasings the rule-based NLU misses).
|
|
@@ -298,6 +317,18 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
298
317
|
// Only useful with satellites: without voice there is nothing to speak on.
|
|
299
318
|
tools.push(this.buildAnnounceTool());
|
|
300
319
|
}
|
|
320
|
+
this.youtubePlayers = (0, youtube_1.parsePlayerRows)(cfg.youtubePlayers);
|
|
321
|
+
if (this.youtubePlayers.length) {
|
|
322
|
+
// Before the tools are built: their descriptions carry the player names, which an instance
|
|
323
|
+
// row only gets from its device.
|
|
324
|
+
for (const player of this.youtubePlayers) {
|
|
325
|
+
if (!(await this.resolveYouTubePlayer(player))) {
|
|
326
|
+
this.log.warn(`YouTube: ${player.instance} has no paired TV yet — pair one in its settings.`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
tools.push(...this.buildYouTubeTools());
|
|
330
|
+
this.log.info(`YouTube players: ${this.youtubePlayers.map(p => p.name).join(', ')}.`);
|
|
331
|
+
}
|
|
301
332
|
this.log.info(`ioBroker tools enabled (${tools.length}): ${tools.map(t => t.name).join(', ')}`);
|
|
302
333
|
if (denied.length) {
|
|
303
334
|
this.log.debug(`Tools denied by access settings: ${denied.join(', ')}`);
|
|
@@ -314,7 +345,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
314
345
|
baseUrl: prov.baseUrl,
|
|
315
346
|
systemPrompt: cfg.systemPrompt || '',
|
|
316
347
|
maxTokens: cfg.maxTokens || 1024,
|
|
317
|
-
tools,
|
|
348
|
+
tools: tools.map(t => this.journalTool(t)),
|
|
318
349
|
log: this.log,
|
|
319
350
|
});
|
|
320
351
|
}
|
|
@@ -403,7 +434,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
403
434
|
language,
|
|
404
435
|
stt,
|
|
405
436
|
tts,
|
|
406
|
-
answer: question => this.answer(question, 'wyoming'),
|
|
437
|
+
answer: (question, sttMs) => this.answer(question, 'wyoming', { transport: 'wyoming', sttMs }),
|
|
407
438
|
getHints: () => this.buildSttHints(),
|
|
408
439
|
log: this.log,
|
|
409
440
|
});
|
|
@@ -463,7 +494,12 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
463
494
|
language,
|
|
464
495
|
stt,
|
|
465
496
|
tts,
|
|
466
|
-
answer: (question, origin) => this.answerVoice(question, origin.device
|
|
497
|
+
answer: (question, origin) => this.answerVoice(question, origin.device, {
|
|
498
|
+
transport: 'esphome',
|
|
499
|
+
room: origin.room,
|
|
500
|
+
wakeWord: origin.wakeWord,
|
|
501
|
+
sttMs: origin.sttMs,
|
|
502
|
+
}),
|
|
467
503
|
getHints: () => this.buildSttHints(),
|
|
468
504
|
media: this.media,
|
|
469
505
|
mediaHost: (cfg.esphomeMediaHost || '').trim(),
|
|
@@ -536,7 +572,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
536
572
|
language,
|
|
537
573
|
stt,
|
|
538
574
|
tts,
|
|
539
|
-
answer: (question, ctx) => this.answerVoice(question, ctx.device),
|
|
575
|
+
answer: (question, ctx) => this.answerVoice(question, ctx.device, { transport: 'udp', room: ctx.room, sttMs: ctx.sttMs }),
|
|
540
576
|
getHints: () => this.buildSttHints(),
|
|
541
577
|
log: this.log,
|
|
542
578
|
onStatus: (device, room, state) => {
|
|
@@ -586,11 +622,11 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
586
622
|
* not re-trigger onStateChange). The state writes must not block the spoken reply, so they are
|
|
587
623
|
* fire-and-forget.
|
|
588
624
|
*/
|
|
589
|
-
async answerVoice(question, source) {
|
|
625
|
+
async answerVoice(question, source, origin = {}) {
|
|
590
626
|
this.setStateAsync('text.request', { val: question, ack: true }).catch(() => { });
|
|
591
627
|
this.setQuerySource(source);
|
|
592
628
|
this.log.info(`Voice Q (${source}): ${question}`);
|
|
593
|
-
const answer = await this.answer(question, source);
|
|
629
|
+
const answer = await this.answer(question, source, { satellite: source, ...origin });
|
|
594
630
|
this.log.info(`Voice A (${source}): ${answer}`);
|
|
595
631
|
this.setStateAsync('text.response', { val: answer, ack: true }).catch(() => { });
|
|
596
632
|
return answer;
|
|
@@ -659,11 +695,17 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
659
695
|
pcm = pcm.subarray(44);
|
|
660
696
|
}
|
|
661
697
|
const language = msg.language || engines.language;
|
|
698
|
+
const sttStart = Date.now();
|
|
662
699
|
const text = (await engines.stt.transcribe(pcm, rate, language, await this.buildSttHints())).trim();
|
|
663
700
|
if (!text) {
|
|
664
701
|
return { text: '', answer: '' };
|
|
665
702
|
}
|
|
666
|
-
const answer = await this.answerVoice(text, source
|
|
703
|
+
const answer = await this.answerVoice(text, source, {
|
|
704
|
+
transport: 'native',
|
|
705
|
+
room,
|
|
706
|
+
wakeWord: typeof msg.wakeWord === 'string' ? msg.wakeWord : undefined,
|
|
707
|
+
sttMs: Date.now() - sttStart,
|
|
708
|
+
});
|
|
667
709
|
if (!answer) {
|
|
668
710
|
return { text, answer: '' };
|
|
669
711
|
}
|
|
@@ -1914,6 +1956,27 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1914
1956
|
}
|
|
1915
1957
|
return;
|
|
1916
1958
|
}
|
|
1959
|
+
// Scripts: find and play something on a configured YouTube player, the TV switched on first.
|
|
1960
|
+
// message = { query, kind?: 'channel'|'video'|'playlist', player? } → { ok, text, title?, id?, player? }
|
|
1961
|
+
// Settings: the "Player" column of the YouTube table (selectSendTo).
|
|
1962
|
+
if (obj.command === 'getYouTubeInstances') {
|
|
1963
|
+
const list = await this.getYouTubeInstances();
|
|
1964
|
+
if (obj.callback) {
|
|
1965
|
+
this.sendTo(obj.from, obj.command, list, obj.callback);
|
|
1966
|
+
}
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
if (obj.command === 'playYouTube') {
|
|
1970
|
+
const m = (obj.message || {});
|
|
1971
|
+
const kind = m.kind === 'channel' || m.kind === 'playlist' || m.kind === 'video' ? m.kind : 'auto';
|
|
1972
|
+
const res = String(m.query || '').trim()
|
|
1973
|
+
? await this.playYouTube(String(m.query).trim(), kind, m.player)
|
|
1974
|
+
: { ok: false, text: 'no query given' };
|
|
1975
|
+
if (obj.callback) {
|
|
1976
|
+
this.sendTo(obj.from, obj.command, this.youtubeResultData(res), obj.callback);
|
|
1977
|
+
}
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1917
1980
|
// Scripts / settings: which wake words an ESPHome satellite offers and which are listening.
|
|
1918
1981
|
// message = { device? } — omitted means every connected one.
|
|
1919
1982
|
if (obj.command === 'getWakeWords') {
|
|
@@ -2146,6 +2209,24 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2146
2209
|
}
|
|
2147
2210
|
return;
|
|
2148
2211
|
}
|
|
2212
|
+
// Admin chat: the journal of the last hours (message = { since?: ms }), and wiping it.
|
|
2213
|
+
if (obj.command === 'getJournal') {
|
|
2214
|
+
const since = Number(obj.message?.since) || 0;
|
|
2215
|
+
const result = this.journal?.enabled
|
|
2216
|
+
? { enabled: true, entries: this.journal.list(since) }
|
|
2217
|
+
: { enabled: false, entries: [] };
|
|
2218
|
+
if (obj.callback) {
|
|
2219
|
+
this.sendTo(obj.from, obj.command, result, obj.callback);
|
|
2220
|
+
}
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
if (obj.command === 'clearJournal') {
|
|
2224
|
+
this.journal?.clear();
|
|
2225
|
+
if (obj.callback) {
|
|
2226
|
+
this.sendTo(obj.from, obj.command, { ok: true }, obj.callback);
|
|
2227
|
+
}
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2149
2230
|
if (obj.command !== 'ask') {
|
|
2150
2231
|
return;
|
|
2151
2232
|
}
|
|
@@ -2168,13 +2249,14 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2168
2249
|
this.log.info(`A: ${answer}`);
|
|
2169
2250
|
this.setStateAsync('text.response', { val: answer, ack: true }).catch(() => { });
|
|
2170
2251
|
if (obj.callback) {
|
|
2171
|
-
this
|
|
2252
|
+
// `journaled`: the admin chat gets this exchange from the journal and must not add it twice.
|
|
2253
|
+
this.sendTo(obj.from, obj.command, { answer, journaled: !!this.journal?.enabled }, obj.callback);
|
|
2172
2254
|
}
|
|
2173
2255
|
}
|
|
2174
2256
|
catch (e) {
|
|
2175
2257
|
this.log.error(`Assistant error: ${e.message}`);
|
|
2176
2258
|
if (obj.callback) {
|
|
2177
|
-
this.sendTo(obj.from, obj.command, { error: e.message }, obj.callback);
|
|
2259
|
+
this.sendTo(obj.from, obj.command, { error: e.message, journaled: !!this.journal?.enabled }, obj.callback);
|
|
2178
2260
|
}
|
|
2179
2261
|
}
|
|
2180
2262
|
}
|
|
@@ -2187,7 +2269,12 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2187
2269
|
* is applied to the cloud LLM tier; every completed exchange (whichever tier answered) is recorded so
|
|
2188
2270
|
* later turns have the full thread.
|
|
2189
2271
|
*/
|
|
2190
|
-
|
|
2272
|
+
answer(question, source = '', origin = {}) {
|
|
2273
|
+
return this.requestOrigin.run({ source, room: origin.room || '' }, () => this.journal
|
|
2274
|
+
? this.journal.run(question, source, origin, () => this.answerTracked(question, source))
|
|
2275
|
+
: this.answerTracked(question, source));
|
|
2276
|
+
}
|
|
2277
|
+
async answerTracked(question, source) {
|
|
2191
2278
|
const useCtx = this.config.useConversationContext !== false;
|
|
2192
2279
|
const history = useCtx ? this.context.get(source) : [];
|
|
2193
2280
|
const raw = await this.produceAnswer(question, history, source);
|
|
@@ -2195,6 +2282,9 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2195
2282
|
// (see followUpHint). Strip it here so it's neither stored, shown nor spoken, and expose it as the
|
|
2196
2283
|
// follow-up flag. Single-user voice → a shared field is race-free enough (read right after await).
|
|
2197
2284
|
this.lastFollowUp = /\[\[LISTEN\]\]/i.test(raw);
|
|
2285
|
+
if (this.lastFollowUp) {
|
|
2286
|
+
this.journal?.note({ followUp: true });
|
|
2287
|
+
}
|
|
2198
2288
|
const result = raw.replace(/\s*\[\[LISTEN\]\]\s*/gi, ' ').trim();
|
|
2199
2289
|
if (useCtx && result) {
|
|
2200
2290
|
this.context.add(source, question, result);
|
|
@@ -2278,6 +2368,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2278
2368
|
if (this.rings.length && (0, nlu_1.isStopCommand)(question)) {
|
|
2279
2369
|
const n = this.stopRinging();
|
|
2280
2370
|
this.log.info(`Ring stopped by voice (${n} session(s)).`);
|
|
2371
|
+
this.journal?.note({ tier: 'stop' });
|
|
2281
2372
|
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
2282
2373
|
return lang === 'ru' ? 'Хорошо.' : lang === 'de' ? 'Ok.' : 'Okay.';
|
|
2283
2374
|
}
|
|
@@ -2288,6 +2379,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2288
2379
|
const asked = this.pending.question(source);
|
|
2289
2380
|
if (this.pending.deliver(source, question)) {
|
|
2290
2381
|
this.log.info(`Answer to "${asked}" from source '${source || 'any'}': ${question}`);
|
|
2382
|
+
this.journal?.note({ tier: 'askReply', detail: asked || undefined });
|
|
2291
2383
|
return '';
|
|
2292
2384
|
}
|
|
2293
2385
|
}
|
|
@@ -2297,11 +2389,21 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2297
2389
|
const routine = (0, routines_1.matchRoutine)(question, this.routines);
|
|
2298
2390
|
if (routine) {
|
|
2299
2391
|
this.log.info(`Routine '${routine.name}' triggered (source='${source}').`);
|
|
2392
|
+
this.journal?.note({ tier: 'routine', detail: routine.name });
|
|
2300
2393
|
await this.runRoutine(routine, source);
|
|
2301
2394
|
return routine.reply;
|
|
2302
2395
|
}
|
|
2303
2396
|
// Tier 0: rule-based NLU (device commands) — fastest, offline, free.
|
|
2304
2397
|
if (this.config.useLocalNlu) {
|
|
2398
|
+
// "Spiele den Kanal X ab" first: the device NLU would find the TV in "… auf dem Fernseher" and
|
|
2399
|
+
// answer with its power state.
|
|
2400
|
+
const yt = this.youtubePlayers.length ? (0, youtube_1.parseYouTubeCommand)(question, this.youtubePlayers) : null;
|
|
2401
|
+
if (yt) {
|
|
2402
|
+
this.log.info(`YouTube request understood locally: ${yt.kind} "${yt.query}" (source='${source}').`);
|
|
2403
|
+
this.journal?.note({ tier: 'nlu', detail: `youtube ${yt.kind}` });
|
|
2404
|
+
const res = await this.playYouTube(yt.query, yt.kind, yt.player);
|
|
2405
|
+
return res.text;
|
|
2406
|
+
}
|
|
2305
2407
|
try {
|
|
2306
2408
|
const handled = await this.tryLocalNlu(question, source);
|
|
2307
2409
|
if (handled !== null) {
|
|
@@ -2330,6 +2432,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2330
2432
|
const ans = await this.localLlm.ask(facts ? `${facts}\n\n${question}` : question);
|
|
2331
2433
|
if (ans && !(0, localLlm_1.isHandoff)(ans)) {
|
|
2332
2434
|
this.log.info(`Answered by local LLM (source='${source}').`);
|
|
2435
|
+
this.journal?.note({ tier: 'localLlm', detail: this.localLlmName() });
|
|
2333
2436
|
return ans;
|
|
2334
2437
|
}
|
|
2335
2438
|
this.log.debug('Local LLM handed off to the cloud LLM.');
|
|
@@ -2347,6 +2450,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2347
2450
|
// Provider "none": no tier further down. Say so instead of failing — over voice an error is
|
|
2348
2451
|
// read out as a technical message.
|
|
2349
2452
|
this.log.info(`No local tier could answer and no LLM provider is configured (source='${source}').`);
|
|
2453
|
+
this.journal?.note({ tier: 'none' });
|
|
2350
2454
|
return this.localOnlyFallback();
|
|
2351
2455
|
}
|
|
2352
2456
|
const ctx = await this.buildDeviceContext();
|
|
@@ -2359,6 +2463,10 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2359
2463
|
// Prepend the current date/time (and the weather line, if a source is configured) to the user turn —
|
|
2360
2464
|
// cache-safe, see buildTimeContext. The stored history keeps the untouched question.
|
|
2361
2465
|
const questionForLlm = [this.buildTimeContext(), weather, question].filter(Boolean).join('\n\n');
|
|
2466
|
+
this.journal?.note({
|
|
2467
|
+
tier: 'cloud',
|
|
2468
|
+
detail: `${this.config.provider || 'openai'} · ${this.agent.model || 'auto'}`,
|
|
2469
|
+
});
|
|
2362
2470
|
return this.agent.ask(questionForLlm, sys, history);
|
|
2363
2471
|
}
|
|
2364
2472
|
/** Reply when nothing local understood the request and there is no cloud LLM (provider "none"). */
|
|
@@ -2431,6 +2539,81 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2431
2539
|
}
|
|
2432
2540
|
api.registerUsedResource(type, data).catch(e => this.log.debug(`could not register ${type} ${port}: ${e.message}`));
|
|
2433
2541
|
}
|
|
2542
|
+
/**
|
|
2543
|
+
* Open the conversation journal (shown in the admin chat). Every new or finished entry is mirrored to
|
|
2544
|
+
* `journal.last` as JSON — the chat subscribes to it, which is what makes the view live.
|
|
2545
|
+
*/
|
|
2546
|
+
async setupJournal() {
|
|
2547
|
+
const hours = Math.max(0, Math.min(168, Number(this.config.journalHours ?? 24) || 0));
|
|
2548
|
+
const file = path.join(this.instanceDataDir(), 'journal.json');
|
|
2549
|
+
// Also in io-package `instanceObjects`; created here for instances that predate it.
|
|
2550
|
+
await this.setObjectNotExistsAsync('journal', {
|
|
2551
|
+
type: 'channel',
|
|
2552
|
+
common: { name: 'Conversation journal' },
|
|
2553
|
+
native: {},
|
|
2554
|
+
});
|
|
2555
|
+
await this.setObjectNotExistsAsync('journal.last', {
|
|
2556
|
+
type: 'state',
|
|
2557
|
+
common: {
|
|
2558
|
+
name: 'Latest journal entry (JSON; written when a request starts and when it is answered)',
|
|
2559
|
+
type: 'string',
|
|
2560
|
+
role: 'json',
|
|
2561
|
+
read: true,
|
|
2562
|
+
write: false,
|
|
2563
|
+
def: '',
|
|
2564
|
+
},
|
|
2565
|
+
native: {},
|
|
2566
|
+
});
|
|
2567
|
+
this.journal = new journal_1.Journal({
|
|
2568
|
+
hours,
|
|
2569
|
+
file,
|
|
2570
|
+
log: this.log,
|
|
2571
|
+
onChange: entry => {
|
|
2572
|
+
this.setStateAsync('journal.last', { val: JSON.stringify(entry), ack: true }).catch(() => { });
|
|
2573
|
+
},
|
|
2574
|
+
});
|
|
2575
|
+
if (hours) {
|
|
2576
|
+
await this.journal.load();
|
|
2577
|
+
}
|
|
2578
|
+
else {
|
|
2579
|
+
// Switched off: do not keep what was recorded while it was on.
|
|
2580
|
+
await fs.promises.rm(file, { force: true }).catch(() => { });
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
/** Wrap the MCP connection so every state the pipeline reads or writes lands in the journal entry. */
|
|
2584
|
+
journalMcp(mcp) {
|
|
2585
|
+
return {
|
|
2586
|
+
listTools: () => mcp.listTools(),
|
|
2587
|
+
callTool: (name, args) => {
|
|
2588
|
+
this.journal?.stateAccess(name, args);
|
|
2589
|
+
return mcp.callTool(name, args);
|
|
2590
|
+
},
|
|
2591
|
+
close: () => mcp.close(),
|
|
2592
|
+
};
|
|
2593
|
+
}
|
|
2594
|
+
/** Wrap an LLM tool so its calls (arguments, result, duration) land in the journal entry. */
|
|
2595
|
+
journalTool(tool) {
|
|
2596
|
+
return {
|
|
2597
|
+
...tool,
|
|
2598
|
+
run: async (args) => {
|
|
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
|
+
},
|
|
2610
|
+
};
|
|
2611
|
+
}
|
|
2612
|
+
/** Short name of the local model for the journal ("qwen2.5-1.5b-instruct-q4_k_m"). */
|
|
2613
|
+
localLlmName() {
|
|
2614
|
+
const url = (this.config.localLlmModelUrl || '').trim() || localLlm_1.DEFAULT_LOCAL_MODEL_URL;
|
|
2615
|
+
return (url.split(/[/\\]/).pop() || url).replace(/\?.*$/, '').replace(/\.gguf$/i, '');
|
|
2616
|
+
}
|
|
2434
2617
|
/** Writable per-instance data dir via the adapter-core function (with a fallback for old runtimes). */
|
|
2435
2618
|
instanceDataDir() {
|
|
2436
2619
|
try {
|
|
@@ -2559,6 +2742,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2559
2742
|
if (!intents.length || !intents.every(i => this.canExecuteNlu(i, devices.length))) {
|
|
2560
2743
|
return null; // nothing matched, or one part cannot run here → let the LLM answer the whole thing
|
|
2561
2744
|
}
|
|
2745
|
+
this.journal?.note({ tier: 'nlu', detail: intents.map(i => i.action).join(' + ') });
|
|
2562
2746
|
const answers = [];
|
|
2563
2747
|
let failed = false;
|
|
2564
2748
|
for (const intent of intents) {
|
|
@@ -3811,6 +3995,383 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3811
3995
|
},
|
|
3812
3996
|
};
|
|
3813
3997
|
}
|
|
3998
|
+
// ── YouTube on the TV (youtube-remote players) ──────────────────────────
|
|
3999
|
+
/** The player a request means: the named one, else the one in the requesting room, else the first. */
|
|
4000
|
+
youtubePlayerFor(name) {
|
|
4001
|
+
return (0, youtube_1.findPlayer)(this.youtubePlayers, name, this.requestOrigin.getStore()?.room || '');
|
|
4002
|
+
}
|
|
4003
|
+
/**
|
|
4004
|
+
* Find the device of a row that names a youtube-remote instance. Done again before every command: the
|
|
4005
|
+
* device id comes from the TV's screen id, so pairing the TV again replaces the device while we run.
|
|
4006
|
+
* false = nothing is paired with that instance.
|
|
4007
|
+
*/
|
|
4008
|
+
async resolveYouTubePlayer(player) {
|
|
4009
|
+
if (!player.instance) {
|
|
4010
|
+
return true;
|
|
4011
|
+
}
|
|
4012
|
+
let devices = [];
|
|
4013
|
+
try {
|
|
4014
|
+
const objs = await this.getForeignObjectsAsync(`${player.instance}.*`, 'device');
|
|
4015
|
+
devices = Object.values(objs).map(o => ({ id: o._id, name: this.youtubeDeviceName(o) }));
|
|
4016
|
+
}
|
|
4017
|
+
catch (e) {
|
|
4018
|
+
this.log.debug(`YouTube: cannot read the devices of ${player.instance}: ${e.message}`);
|
|
4019
|
+
}
|
|
4020
|
+
const device = (0, youtube_1.pickDevice)(devices, player);
|
|
4021
|
+
player.base = device?.id || '';
|
|
4022
|
+
player.target = device ? `${device.id}.youtube` : '';
|
|
4023
|
+
if (device && !player.name) {
|
|
4024
|
+
player.name = device.name || player.instance;
|
|
4025
|
+
}
|
|
4026
|
+
return !!device;
|
|
4027
|
+
}
|
|
4028
|
+
/** Display name of an object, in the voice language if it is translated. */
|
|
4029
|
+
youtubeDeviceName(obj) {
|
|
4030
|
+
const name = obj.common?.name;
|
|
4031
|
+
if (typeof name === 'string') {
|
|
4032
|
+
return name;
|
|
4033
|
+
}
|
|
4034
|
+
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
4035
|
+
return name?.[lang] || name?.en || obj._id.split('.').pop() || obj._id;
|
|
4036
|
+
}
|
|
4037
|
+
/**
|
|
4038
|
+
* Options of the "Player" column: every youtube-remote instance (it finds its TV itself), and for an
|
|
4039
|
+
* instance with several paired TVs also each of them, to pin a row to one.
|
|
4040
|
+
*/
|
|
4041
|
+
async getYouTubeInstances() {
|
|
4042
|
+
const options = [];
|
|
4043
|
+
let instances = [];
|
|
4044
|
+
try {
|
|
4045
|
+
const view = await this.getObjectViewAsync('system', 'instance', {
|
|
4046
|
+
startkey: 'system.adapter.youtube-remote.',
|
|
4047
|
+
endkey: 'system.adapter.youtube-remote.香',
|
|
4048
|
+
});
|
|
4049
|
+
instances = (view?.rows || []).map(r => r.id.replace(/^system\.adapter\./, '')).sort();
|
|
4050
|
+
}
|
|
4051
|
+
catch (e) {
|
|
4052
|
+
this.log.warn(`getYouTubeInstances failed: ${e.message}`);
|
|
4053
|
+
}
|
|
4054
|
+
for (const instance of instances) {
|
|
4055
|
+
const objs = await this.getForeignObjectsAsync(`${instance}.*`, 'device').catch(() => ({}));
|
|
4056
|
+
const devices = Object.values(objs)
|
|
4057
|
+
.map(o => ({ id: o._id, name: this.youtubeDeviceName(o) }))
|
|
4058
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
4059
|
+
const names = devices.map(d => d.name).join(', ');
|
|
4060
|
+
options.push({ label: names ? `${instance} (${names})` : `${instance} (no TV paired)`, value: instance });
|
|
4061
|
+
if (devices.length > 1) {
|
|
4062
|
+
for (const d of devices) {
|
|
4063
|
+
options.push({ label: `${instance} › ${d.name}`, value: d.id });
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
return options;
|
|
4068
|
+
}
|
|
4069
|
+
/**
|
|
4070
|
+
* Find `query` on YouTube and play it on a player, switching its TV on first. The search runs before
|
|
4071
|
+
* anything is switched: a TV woken for a channel that does not exist would be the worst outcome.
|
|
4072
|
+
*
|
|
4073
|
+
* A TV that has to boot takes 10–30 s, so in that case the reply comes right away and the rest runs
|
|
4074
|
+
* in the background ({@link playWhenTvIsOn}) — over voice, half a minute of silence reads as "did not
|
|
4075
|
+
* hear me". An already running TV is answered only after the player acknowledged the command.
|
|
4076
|
+
*/
|
|
4077
|
+
async playYouTube(query, kind, playerName) {
|
|
4078
|
+
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
4079
|
+
const player = this.youtubePlayerFor(playerName);
|
|
4080
|
+
if (!player) {
|
|
4081
|
+
const names = this.youtubePlayers.map(p => p.name).join(', ');
|
|
4082
|
+
return { ok: false, text: (0, youtube_1.youtubeReply)(lang, 'unknownPlayer', { query: playerName, names }) };
|
|
4083
|
+
}
|
|
4084
|
+
if (!(await this.resolveYouTubePlayer(player))) {
|
|
4085
|
+
return {
|
|
4086
|
+
ok: false,
|
|
4087
|
+
player: player.name,
|
|
4088
|
+
text: (0, youtube_1.youtubeReply)(lang, 'noDevice', { instance: player.instance }),
|
|
4089
|
+
};
|
|
4090
|
+
}
|
|
4091
|
+
if (!this.config.allowWriteStates) {
|
|
4092
|
+
return { ok: false, player: player.name, text: (0, youtube_1.youtubeReply)(lang, 'noControl') };
|
|
4093
|
+
}
|
|
4094
|
+
let hit = null;
|
|
4095
|
+
let alternatives = [];
|
|
4096
|
+
// Unsaid kind: what is named like a channel is the channel ("spiele Sailing OceanAir auf YouTube"),
|
|
4097
|
+
// anything else a video search — a video search for a channel name finds some video about it.
|
|
4098
|
+
const searchKind = kind === 'auto' ? 'video' : kind;
|
|
4099
|
+
const direct = (0, youtube_1.directYouTubeTarget)(query, searchKind);
|
|
4100
|
+
if (direct) {
|
|
4101
|
+
hit = { kind: searchKind, id: direct, title: query };
|
|
4102
|
+
}
|
|
4103
|
+
else {
|
|
4104
|
+
try {
|
|
4105
|
+
if (kind === 'auto') {
|
|
4106
|
+
const channels = await (0, youtube_1.searchYouTube)(query, 'channel', { language: lang });
|
|
4107
|
+
hit = channels.find(h => (0, youtube_1.isNamedChannel)(h, query)) || null;
|
|
4108
|
+
alternatives = channels.filter(h => h !== hit).slice(0, 3);
|
|
4109
|
+
}
|
|
4110
|
+
if (!hit) {
|
|
4111
|
+
const hits = await (0, youtube_1.searchYouTube)(query, searchKind, { language: lang });
|
|
4112
|
+
hit = (0, youtube_1.pickBest)(hits, query);
|
|
4113
|
+
alternatives = hits.filter(h => h !== hit).slice(0, 3);
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
catch (e) {
|
|
4117
|
+
this.log.warn(`YouTube search for "${query}" failed: ${e.message}`);
|
|
4118
|
+
return { ok: false, player: player.name, text: (0, youtube_1.youtubeReply)(lang, 'searchFailed') };
|
|
4119
|
+
}
|
|
4120
|
+
if (!hit) {
|
|
4121
|
+
return {
|
|
4122
|
+
ok: false,
|
|
4123
|
+
player: player.name,
|
|
4124
|
+
text: (0, youtube_1.youtubeReply)(lang, 'notFound', { kind: searchKind, query }),
|
|
4125
|
+
};
|
|
4126
|
+
}
|
|
4127
|
+
}
|
|
4128
|
+
const title = (0, youtube_1.spokenTitle)(hit);
|
|
4129
|
+
this.log.info(`YouTube: ${kind} "${query}" → ${hit.kind} ${hit.id} (${hit.title}) on ${player.name}.`);
|
|
4130
|
+
const reply = { kind: hit.kind, title, player: player.name };
|
|
4131
|
+
if ((await this.tvPower(player)) === 'off') {
|
|
4132
|
+
try {
|
|
4133
|
+
await this.switchTvOn(player);
|
|
4134
|
+
}
|
|
4135
|
+
catch (e) {
|
|
4136
|
+
this.log.warn(`YouTube: cannot switch ${player.power} on: ${e.message}`);
|
|
4137
|
+
}
|
|
4138
|
+
void this.playWhenTvIsOn(player, hit);
|
|
4139
|
+
return {
|
|
4140
|
+
ok: true,
|
|
4141
|
+
hit,
|
|
4142
|
+
alternatives,
|
|
4143
|
+
player: player.name,
|
|
4144
|
+
poweringOn: true,
|
|
4145
|
+
text: (0, youtube_1.youtubeReply)(lang, 'poweringOn', reply),
|
|
4146
|
+
};
|
|
4147
|
+
}
|
|
4148
|
+
if (await this.sendToYouTubePlayer(player, hit.id)) {
|
|
4149
|
+
return { ok: true, hit, alternatives, player: player.name, text: (0, youtube_1.youtubeReply)(lang, 'playing', reply) };
|
|
4150
|
+
}
|
|
4151
|
+
const instance = player.base.split('.').slice(0, 2).join('.');
|
|
4152
|
+
const alive = (await this.getForeignStateAsync(`system.adapter.${instance}.alive`).catch(() => null))?.val;
|
|
4153
|
+
return {
|
|
4154
|
+
ok: false,
|
|
4155
|
+
hit,
|
|
4156
|
+
player: player.name,
|
|
4157
|
+
text: alive ? (0, youtube_1.youtubeReply)(lang, 'noConfirm', reply) : (0, youtube_1.youtubeReply)(lang, 'adapterDown', { instance }),
|
|
4158
|
+
};
|
|
4159
|
+
}
|
|
4160
|
+
/**
|
|
4161
|
+
* Whether the TV of a player is on. `unknown` when there is nothing to tell it by: no power state, or
|
|
4162
|
+
* one that is a button — pressing a toggle button on a TV that is already on would switch it off.
|
|
4163
|
+
*/
|
|
4164
|
+
async tvPower(player) {
|
|
4165
|
+
if (!player.power) {
|
|
4166
|
+
return 'unknown';
|
|
4167
|
+
}
|
|
4168
|
+
const obj = await this.getForeignObjectAsync(player.power).catch(() => null);
|
|
4169
|
+
if (!obj) {
|
|
4170
|
+
this.log.warn(`YouTube player ${player.name}: power state ${player.power} does not exist.`);
|
|
4171
|
+
return 'unknown';
|
|
4172
|
+
}
|
|
4173
|
+
const common = obj.common;
|
|
4174
|
+
if (common.read === false || String(common.role || '').startsWith('button')) {
|
|
4175
|
+
this.log.debug(`YouTube player ${player.name}: ${player.power} is a button, the TV state is unknown.`);
|
|
4176
|
+
return 'unknown';
|
|
4177
|
+
}
|
|
4178
|
+
const state = await this.getForeignStateAsync(player.power).catch(() => null);
|
|
4179
|
+
return (0, youtube_1.isPowerOn)(state?.val) ? 'on' : 'off';
|
|
4180
|
+
}
|
|
4181
|
+
/** Write "on" to the power state, in the type the state has. */
|
|
4182
|
+
async switchTvOn(player) {
|
|
4183
|
+
const obj = await this.getForeignObjectAsync(player.power);
|
|
4184
|
+
const type = obj?.common?.type;
|
|
4185
|
+
const val = type === 'number' ? 1 : type === 'string' ? 'on' : true;
|
|
4186
|
+
await this.setForeignStateAsync(player.power, { val, ack: false });
|
|
4187
|
+
this.journal?.stateAccess('set_state', { id: player.power, value: val });
|
|
4188
|
+
this.log.info(`YouTube: switching ${player.name} on (${player.power} = ${JSON.stringify(val)}).`);
|
|
4189
|
+
}
|
|
4190
|
+
/**
|
|
4191
|
+
* After the TV was switched on: wait until its power state confirms (an `ack` from the TV adapter — our
|
|
4192
|
+
* own write already says `true`), give the TV its boot time, then play. The YouTube app is often not
|
|
4193
|
+
* reachable over the lounge right after boot, so a command that is not acknowledged is sent once more.
|
|
4194
|
+
*/
|
|
4195
|
+
async playWhenTvIsOn(player, hit) {
|
|
4196
|
+
try {
|
|
4197
|
+
const until = Date.now() + TV_POWER_TIMEOUT_MS;
|
|
4198
|
+
let on = false;
|
|
4199
|
+
while (!on && Date.now() < until) {
|
|
4200
|
+
await this.delay(1000);
|
|
4201
|
+
const st = await this.getForeignStateAsync(player.power).catch(() => null);
|
|
4202
|
+
on = !!st?.ack && (0, youtube_1.isPowerOn)(st.val);
|
|
4203
|
+
}
|
|
4204
|
+
if (!on) {
|
|
4205
|
+
this.log.warn(`YouTube: ${player.power} did not report "on" within ${TV_POWER_TIMEOUT_MS / 1000} s — trying to play anyway.`);
|
|
4206
|
+
}
|
|
4207
|
+
await this.delay(player.delaySec * 1000);
|
|
4208
|
+
let ok = await this.sendToYouTubePlayer(player, hit.id);
|
|
4209
|
+
if (!ok) {
|
|
4210
|
+
await this.delay(YOUTUBE_RETRY_MS);
|
|
4211
|
+
ok = await this.sendToYouTubePlayer(player, hit.id);
|
|
4212
|
+
}
|
|
4213
|
+
if (ok) {
|
|
4214
|
+
this.log.info(`YouTube: playing ${hit.id} (${hit.title}) on ${player.name}.`);
|
|
4215
|
+
}
|
|
4216
|
+
else {
|
|
4217
|
+
this.log.warn(`YouTube: ${player.name} did not take ${hit.id} after power-on. If the TV needs longer to start, raise its start delay.`);
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
catch (e) {
|
|
4221
|
+
this.log.warn(`YouTube: playing on ${player.name} failed: ${e.message}`);
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
/**
|
|
4225
|
+
* Hand a video/playlist/channel id to the player and wait for its acknowledgement: youtube-remote acks
|
|
4226
|
+
* `youtube` (back to '') only once the TV took the command, and leaves it unacknowledged on failure.
|
|
4227
|
+
*/
|
|
4228
|
+
async sendToYouTubePlayer(player, id) {
|
|
4229
|
+
await this.setForeignStateAsync(player.target, { val: id, ack: false });
|
|
4230
|
+
this.journal?.stateAccess('set_state', { id: player.target, value: id });
|
|
4231
|
+
const until = Date.now() + YOUTUBE_ACK_TIMEOUT_MS;
|
|
4232
|
+
while (Date.now() < until) {
|
|
4233
|
+
await this.delay(250);
|
|
4234
|
+
if ((await this.getForeignStateAsync(player.target).catch(() => null))?.ack) {
|
|
4235
|
+
return true;
|
|
4236
|
+
}
|
|
4237
|
+
}
|
|
4238
|
+
return false;
|
|
4239
|
+
}
|
|
4240
|
+
/** A play result as tool / sendTo data: without the internal objects, with the alternatives as titles. */
|
|
4241
|
+
youtubeResultData(res) {
|
|
4242
|
+
return {
|
|
4243
|
+
ok: res.ok,
|
|
4244
|
+
text: res.text,
|
|
4245
|
+
...(res.player ? { player: res.player } : {}),
|
|
4246
|
+
...(res.hit ? { kind: res.hit.kind, id: res.hit.id, title: res.hit.title } : {}),
|
|
4247
|
+
...(res.hit?.channel ? { channel: res.hit.channel } : {}),
|
|
4248
|
+
...(res.poweringOn ? { poweringOn: true } : {}),
|
|
4249
|
+
...(res.alternatives?.length
|
|
4250
|
+
? { otherResults: res.alternatives.map(h => ({ id: h.id, title: h.title })) }
|
|
4251
|
+
: {}),
|
|
4252
|
+
};
|
|
4253
|
+
}
|
|
4254
|
+
/**
|
|
4255
|
+
* LLM tools for the YouTube players: `play_youtube` (search + power-on + play) and `youtube_player`
|
|
4256
|
+
* (pause, skip, volume, what is playing). The configured names go into the descriptions, the model
|
|
4257
|
+
* cannot discover them otherwise.
|
|
4258
|
+
*/
|
|
4259
|
+
buildYouTubeTools() {
|
|
4260
|
+
const names = this.youtubePlayers.map((p, i) => (i === 0 ? `${p.name} (default)` : p.name)).join(', ');
|
|
4261
|
+
const playerParam = {
|
|
4262
|
+
type: 'string',
|
|
4263
|
+
description: `Player name or room: ${names}. Leave it out for the player in the room the user is in, else the default.`,
|
|
4264
|
+
};
|
|
4265
|
+
const play = {
|
|
4266
|
+
name: 'play_youtube',
|
|
4267
|
+
description: 'Play something from YouTube on the TV: finds the channel, video or playlist by name, switches ' +
|
|
4268
|
+
'the TV on if it is off and starts playing. Use this for every "play … on YouTube", ' +
|
|
4269
|
+
'"spiele den Kanal …", "zeig das Video …" — never write the player states yourself. kind ' +
|
|
4270
|
+
'"channel" plays the newest uploads of a channel. The reply text of the result can be said as is; ' +
|
|
4271
|
+
'otherResults are the next search hits, offer them if the user says it was the wrong one ' +
|
|
4272
|
+
'(pass the id as query to play one).',
|
|
4273
|
+
parameters: {
|
|
4274
|
+
type: 'object',
|
|
4275
|
+
properties: {
|
|
4276
|
+
query: {
|
|
4277
|
+
type: 'string',
|
|
4278
|
+
description: 'Name of the channel, title of the video/playlist, a search phrase, or an id/URL',
|
|
4279
|
+
},
|
|
4280
|
+
kind: {
|
|
4281
|
+
type: 'string',
|
|
4282
|
+
enum: ['channel', 'video', 'playlist'],
|
|
4283
|
+
description: 'Leave it out if the user did not say: a channel of that name wins, else videos',
|
|
4284
|
+
},
|
|
4285
|
+
player: playerParam,
|
|
4286
|
+
},
|
|
4287
|
+
required: ['query'],
|
|
4288
|
+
additionalProperties: false,
|
|
4289
|
+
},
|
|
4290
|
+
run: async (args) => {
|
|
4291
|
+
const query = typeof args.query === 'string' ? args.query.trim() : '';
|
|
4292
|
+
if (!query) {
|
|
4293
|
+
return JSON.stringify({ ok: false, error: 'no query given' });
|
|
4294
|
+
}
|
|
4295
|
+
const kind = args.kind === 'channel' || args.kind === 'playlist' || args.kind === 'video' ? args.kind : 'auto';
|
|
4296
|
+
const player = typeof args.player === 'string' ? args.player : undefined;
|
|
4297
|
+
return JSON.stringify(this.youtubeResultData(await this.playYouTube(query, kind, player)));
|
|
4298
|
+
},
|
|
4299
|
+
};
|
|
4300
|
+
const control = {
|
|
4301
|
+
name: 'youtube_player',
|
|
4302
|
+
description: 'Control a YouTube player or ask what it plays: action play (resume), pause, next, previous, ' +
|
|
4303
|
+
'volume (with "volume" 0-100), mute, unmute, or status (title, channel, play state, TV power).',
|
|
4304
|
+
parameters: {
|
|
4305
|
+
type: 'object',
|
|
4306
|
+
properties: {
|
|
4307
|
+
action: {
|
|
4308
|
+
type: 'string',
|
|
4309
|
+
enum: ['play', 'pause', 'next', 'previous', 'volume', 'mute', 'unmute', 'status'],
|
|
4310
|
+
},
|
|
4311
|
+
volume: { type: 'number', description: 'Volume 0-100, only for action volume' },
|
|
4312
|
+
player: playerParam,
|
|
4313
|
+
},
|
|
4314
|
+
required: ['action'],
|
|
4315
|
+
additionalProperties: false,
|
|
4316
|
+
},
|
|
4317
|
+
run: async (args) => {
|
|
4318
|
+
const player = this.youtubePlayerFor(typeof args.player === 'string' ? args.player : undefined);
|
|
4319
|
+
if (!player) {
|
|
4320
|
+
return JSON.stringify({ ok: false, error: `unknown player, known are: ${names}` });
|
|
4321
|
+
}
|
|
4322
|
+
if (!(await this.resolveYouTubePlayer(player))) {
|
|
4323
|
+
return JSON.stringify({ ok: false, error: `no TV is paired with ${player.instance}` });
|
|
4324
|
+
}
|
|
4325
|
+
const action = typeof args.action === 'string' ? args.action : '';
|
|
4326
|
+
if (action === 'status') {
|
|
4327
|
+
const ids = ['title', 'artist', 'state', 'connected', 'volume'].map(s => `${player.base}.${s}`);
|
|
4328
|
+
const states = await this.getForeignStatesAsync(`${player.base}.*`).catch(() => ({}));
|
|
4329
|
+
const val = (i) => states[ids[i]]?.val ?? null;
|
|
4330
|
+
const power = await this.tvPower(player);
|
|
4331
|
+
return JSON.stringify({
|
|
4332
|
+
ok: true,
|
|
4333
|
+
data: {
|
|
4334
|
+
player: player.name,
|
|
4335
|
+
title: val(0),
|
|
4336
|
+
channel: val(1),
|
|
4337
|
+
state: { 0: 'paused', 1: 'playing', 2: 'stopped' }[String(val(2))],
|
|
4338
|
+
connected: val(3),
|
|
4339
|
+
volume: val(4),
|
|
4340
|
+
...(power !== 'unknown' ? { tv: power } : {}),
|
|
4341
|
+
},
|
|
4342
|
+
});
|
|
4343
|
+
}
|
|
4344
|
+
if (!this.config.allowWriteStates) {
|
|
4345
|
+
return JSON.stringify({ ok: false, error: 'device control is disabled in the settings' });
|
|
4346
|
+
}
|
|
4347
|
+
let id;
|
|
4348
|
+
let value;
|
|
4349
|
+
if (action === 'volume') {
|
|
4350
|
+
const v = Number(args.volume);
|
|
4351
|
+
if (!Number.isFinite(v)) {
|
|
4352
|
+
return JSON.stringify({ ok: false, error: 'volume 0-100 needed' });
|
|
4353
|
+
}
|
|
4354
|
+
id = `${player.base}.volume`;
|
|
4355
|
+
value = Math.min(100, Math.max(0, Math.round(v)));
|
|
4356
|
+
}
|
|
4357
|
+
else if (action === 'mute' || action === 'unmute') {
|
|
4358
|
+
id = `${player.base}.muted`;
|
|
4359
|
+
value = action === 'mute';
|
|
4360
|
+
}
|
|
4361
|
+
else if (['play', 'pause', 'next', 'previous'].includes(action)) {
|
|
4362
|
+
id = `${player.base}.${action === 'previous' ? 'prev' : action}`;
|
|
4363
|
+
value = true;
|
|
4364
|
+
}
|
|
4365
|
+
else {
|
|
4366
|
+
return JSON.stringify({ ok: false, error: `unknown action '${action}'` });
|
|
4367
|
+
}
|
|
4368
|
+
await this.setForeignStateAsync(id, { val: value, ack: false });
|
|
4369
|
+
this.journal?.stateAccess('set_state', { id, value });
|
|
4370
|
+
return JSON.stringify({ ok: true, data: { player: player.name, [action]: value } });
|
|
4371
|
+
},
|
|
4372
|
+
};
|
|
4373
|
+
return [play, control];
|
|
4374
|
+
}
|
|
3814
4375
|
// ── Presence: who is at home (roadmap B1) ───────────────────────────────
|
|
3815
4376
|
/**
|
|
3816
4377
|
* Build the presence tracker from the configured states, subscribe to them and mirror the result into
|
|
@@ -4153,6 +4714,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
4153
4714
|
}
|
|
4154
4715
|
try {
|
|
4155
4716
|
await this.setForeignStateAsync(write.id, { val: write.value, ack: false });
|
|
4717
|
+
this.journal?.stateAccess('set_state', { id: write.id, value: write.value });
|
|
4156
4718
|
this.log.info(`trigger '${def.id}': ${write.id} = ${JSON.stringify(write.value)}`);
|
|
4157
4719
|
}
|
|
4158
4720
|
catch (e) {
|
|
@@ -4888,6 +5450,12 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
4888
5450
|
catch {
|
|
4889
5451
|
// ignore
|
|
4890
5452
|
}
|
|
5453
|
+
try {
|
|
5454
|
+
await this.journal?.flush();
|
|
5455
|
+
}
|
|
5456
|
+
catch {
|
|
5457
|
+
// ignore
|
|
5458
|
+
}
|
|
4891
5459
|
try {
|
|
4892
5460
|
await this.mcp?.close();
|
|
4893
5461
|
}
|