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.
Files changed (65) hide show
  1. package/README.md +6 -0
  2. package/admin/custom/.vite/manifest.json +91 -87
  3. package/admin/custom/assets/Components-C6qcpaRC.js +1 -0
  4. 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
  5. 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
  6. 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
  7. 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
  8. 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
  9. 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
  10. 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
  11. package/admin/custom/assets/{dist-DiU1y0R9.js → dist-B3E7IYuQ.js} +1 -1
  12. package/admin/custom/assets/helpers-DE61bb74.js +1 -0
  13. package/admin/custom/assets/{hostInit-MshDicd1.js → hostInit-4roC4hEj.js} +1 -1
  14. package/admin/custom/assets/{lib-DjwHKtRi.js → lib-CchzgTUs.js} +1 -1
  15. package/admin/custom/assets/{mf-entry-bootstrap-0-7c1ddf47.js → mf-entry-bootstrap-0-4806bbe4.js} +2 -2
  16. package/admin/custom/assets/{pendingShares-CaJDCKLB.js → pendingShares-DLlv8yFY.js} +2 -2
  17. package/admin/custom/assets/prod-ChPGnyiA.js +15 -0
  18. package/admin/custom/assets/rolldown-runtime-COiEfhsA.js +1 -0
  19. package/admin/custom/assets/utils-Bi7ATSfV.js +4 -0
  20. package/admin/custom/customComponents.js +2 -2
  21. package/admin/custom/i18n/de.json +25 -0
  22. package/admin/custom/i18n/en.json +25 -0
  23. package/admin/custom/i18n/es.json +25 -0
  24. package/admin/custom/i18n/fr.json +25 -0
  25. package/admin/custom/i18n/it.json +25 -0
  26. package/admin/custom/i18n/nl.json +25 -0
  27. package/admin/custom/i18n/pl.json +25 -0
  28. package/admin/custom/i18n/pt.json +25 -0
  29. package/admin/custom/i18n/ru.json +25 -0
  30. package/admin/custom/i18n/uk.json +25 -0
  31. package/admin/custom/i18n/zh-cn.json +25 -0
  32. package/admin/custom/index.html +2 -2
  33. package/admin/custom/mf-manifest.json +1 -1
  34. package/admin/custom/mf-stats.json +1 -1
  35. package/admin/i18n/de.json +9 -1
  36. package/admin/i18n/en.json +9 -1
  37. package/admin/i18n/es.json +9 -1
  38. package/admin/i18n/fr.json +9 -1
  39. package/admin/i18n/it.json +9 -1
  40. package/admin/i18n/nl.json +9 -1
  41. package/admin/i18n/pl.json +9 -1
  42. package/admin/i18n/pt.json +9 -1
  43. package/admin/i18n/ru.json +9 -1
  44. package/admin/i18n/uk.json +9 -1
  45. package/admin/i18n/zh-cn.json +9 -1
  46. package/admin/jsonConfig.json +45 -0
  47. package/build/lib/journal.js +273 -0
  48. package/build/lib/journal.js.map +1 -0
  49. package/build/lib/voice/esphome.js +9 -1
  50. package/build/lib/voice/esphome.js.map +1 -1
  51. package/build/lib/voice/voiceServer.js +3 -1
  52. package/build/lib/voice/voiceServer.js.map +1 -1
  53. package/build/lib/voice/wyoming.js +3 -1
  54. package/build/lib/voice/wyoming.js.map +1 -1
  55. package/build/lib/youtube.js +625 -0
  56. package/build/lib/youtube.js.map +1 -0
  57. package/build/main.js +580 -12
  58. package/build/main.js.map +1 -1
  59. package/io-package.json +50 -1
  60. package/package.json +7 -7
  61. package/admin/custom/assets/Components-7BDDNrGP.js +0 -1
  62. 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
  63. package/admin/custom/assets/helpers-Dot7vgQQ.js +0 -1
  64. package/admin/custom/assets/rolldown-runtime-Dd_uD5pT.js +0 -1
  65. 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.sendTo(obj.from, obj.command, { answer }, obj.callback);
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
- async answer(question, source = '') {
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
  }