iobroker.assistant 1.4.8 → 1.4.10

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 CHANGED
@@ -237,6 +237,14 @@ train it twice.
237
237
  Placeholder for the next version (at the beginning of the line):
238
238
  ### **WORK IN PROGRESS**
239
239
  -->
240
+ ### 1.4.10 (2026-11-05)
241
+ * (@GermanBluefox) ESPHome satellites: internet radio and music from a URL — `controls.<media player>.url` plays it as media (replies and announcements only duck it, play/pause/stop/volume act on it), empty or `stop` stops. Playlists (.pls/.m3u) are resolved; a stream the device cannot decode (AAC, HLS, …) runs through ffmpeg and is served to it as FLAC; `transcode` forces that
242
+
243
+ ### 1.4.9 (2026-11-05)
244
+ * (@GermanBluefox) ESPHome satellites with a screen: the yes/no button did not answer pi-agent's question — the follow-up microphone carries the last wake word (ESPHome never clears it) and was taken for a new request. A follow-up is now recognised by the open question (until the next answered request, at most 30 s after the reply), and a stop the firmware sends right after the press no longer drops the answer
245
+ * (@GermanBluefox) pi-agent: a request from a satellite also carries `satellite` (its id under `satellites.*`) and `tts` (its `tts` state), so pi-agent can speak there later (reminders, background tasks)
246
+ * (@GermanBluefox) Timers of a satellite: `setTimer` takes `satellite`, `seconds`, `name` and `announce` (said word for word when it fires); `listTimers`/`cancelTimer` take `satellite`. An ESPHome satellite shows and rings such a timer like one set there by voice
247
+
240
248
  ### 1.4.8 (2026-11-05)
241
249
  * (@GermanBluefox) ESPHome satellites: a firmware update entity gets a "check" button (`controls.<update>.check`) that makes the device look for a new version now, instead of at its next boot or its 6-hour check
242
250
  * (@GermanBluefox) ESPHome satellites: `text` entities are mirrored as writable states (labels and units of a satellite with a screen can be set from ioBroker). Values outside the device's length or pattern are refused with a warning; a password text is not mirrored
@@ -78,6 +78,8 @@ class PiAgentHarness {
78
78
  instructions: req.instructions,
79
79
  ...(req.language ? { language: req.language } : {}),
80
80
  ...(req.images?.length ? { images: req.images } : {}),
81
+ ...(req.satellite ? { satellite: req.satellite } : {}),
82
+ ...(req.tts ? { tts: req.tts } : {}),
81
83
  requestId,
82
84
  }, { timeout: this.timeoutMs }));
83
85
  if (result?.error) {
@@ -1 +1 @@
1
- {"version":3,"file":"piAgent.js","sourceRoot":"","sources":["../../src/lib/piAgent.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The adapter `pi-agent` as the cloud tier (\"harness\"): instead of running its own tool loop, the assistant\n * hands the question to a pi-agent instance via `sendTo(<instance>, 'ask', …)`. pi-agent stores every model\n * and tool call as a checkpoint before it runs (an interrupted request continues after a restart, a write is\n * never repeated), enforces its own write permissions and confirmations, and can use the system-wide AI\n * settings of admin — none of which the built-in `LlmAgent` does.\n *\n * Each request source (satellite, `chat`, `telegram:<user>`) gets a conversation of its own in pi-agent\n * (`conversation`), so two rooms do not see each other's follow-ups. The source-independent part of the\n * prompt (style, follow-up marker, memory, device list) goes along as the `instructions` of that\n * conversation; what changes every turn (time, weather, presence) is prepended to the question.\n * pi-agent 0.3.0 and older ignore both fields and answer everything in their root conversation.\n *\n * With \"Confirmation by text in sendTo conversations\", pi-agent answers a write it may not do unasked with a\n * question ending in `[[LISTEN]]`, and the next `ask` of the conversation is the yes or no. `language` makes it\n * ask that question in the language the satellite listens in.\n */\n\nimport type { ImageInput } from './media';\n\n/** Longest instructions pi-agent accepts for a conversation; anything beyond is cut there. */\nexport const PI_AGENT_MAX_INSTRUCTIONS = 20000;\n\n/** Default for `piAgentTimeoutSec`: a tool loop with several reads can take a while. */\nexport const PI_AGENT_TIMEOUT_SEC = 60;\n\n/** The part of the adapter this module needs (kept small so tests can fake it). */\nexport interface PiAgentHost {\n sendToAsync(instance: string, command: string, message: unknown, options?: { timeout?: number }): Promise<unknown>;\n getForeignStateAsync(id: string): Promise<ioBroker.State | null | undefined>;\n}\n\nexport interface PiAgentRequest {\n /** The text pi-agent answers: context lines + the question. */\n text: string;\n /** Conversation of the caller, e.g. the satellite name. */\n conversation: string;\n /** Instructions of that conversation. */\n instructions: string;\n /**\n * Language the user speaks, e.g. `ru`. pi-agent asks its questions to confirm in it; older versions ask in the\n * system language, and a satellite listening in Russian does not hear a German \"Ja\" (the STT returns nothing).\n */\n language?: string;\n /**\n * Pictures that came with the question (`{ data: base64, mimeType }`, the form of pi-ai's `ImageContent`).\n * Versions of pi-agent before image support ignore them; the text then says that pictures came along.\n */\n images?: ImageInput[];\n}\n\n/**\n * Join instruction blocks, leaving out optional ones from the end until the result fits into pi-agent's\n * limit — pi-agent would cut the text in the middle, and a half device list is worse than none.\n *\n * @param required blocks that always go along (style, follow-up marker)\n * @param optional blocks in order of importance (memory before the device list)\n */\nexport function composeInstructions(required: string[], optional: string[]): { text: string; dropped: number } {\n const blocks = [...required.filter(Boolean)];\n const extra = optional.filter(Boolean);\n let dropped = 0;\n while (extra.length) {\n const text = [...blocks, ...extra].join('\\n\\n');\n if (text.length <= PI_AGENT_MAX_INSTRUCTIONS) {\n return { text, dropped };\n }\n extra.pop();\n dropped++;\n }\n return { text: blocks.join('\\n\\n').slice(0, PI_AGENT_MAX_INSTRUCTIONS), dropped };\n}\n\n/** Conversation name for a request source; pi-agent uses \"/\" as separator in its keys. */\nexport function conversationOf(source: string): string {\n return (source || 'default').replace(/\\//g, '_').slice(0, 100);\n}\n\nexport class PiAgentHarness {\n private seq = 0;\n\n public constructor(\n private readonly host: PiAgentHost,\n /** e.g. `pi-agent.0` */\n public readonly instance: string,\n private readonly timeoutMs: number = PI_AGENT_TIMEOUT_SEC * 1000,\n ) {}\n\n /**\n * Ask pi-agent. Fails fast when the instance is not running (otherwise the caller would wait for the whole\n * timeout), and turns pi-agent's `{error}` into an exception so the caller can fall back to the local model.\n */\n public async ask(req: PiAgentRequest): Promise<string> {\n const alive = await this.host.getForeignStateAsync(`system.adapter.${this.instance}.alive`);\n if (!alive?.val) {\n throw new Error(`${this.instance} is not running`);\n }\n // Unique per request: pi-agent answers a repeated requestId only once.\n const requestId = `assistant-${Date.now().toString(36)}-${(++this.seq).toString(36)}`;\n const result = (await this.host.sendToAsync(\n this.instance,\n 'ask',\n {\n text: req.text,\n conversation: conversationOf(req.conversation),\n instructions: req.instructions,\n ...(req.language ? { language: req.language } : {}),\n ...(req.images?.length ? { images: req.images } : {}),\n requestId,\n },\n { timeout: this.timeoutMs },\n )) as { answer?: unknown; error?: unknown } | null | undefined;\n if (result?.error) {\n const error = typeof result.error === 'string' ? result.error : JSON.stringify(result.error);\n throw new Error(`${this.instance}: ${error}`);\n }\n const answer = typeof result?.answer === 'string' ? result.answer.trim() : '';\n if (!answer) {\n throw new Error(`${this.instance} gave no answer`);\n }\n return answer;\n }\n}\n"]}
1
+ {"version":3,"file":"piAgent.js","sourceRoot":"","sources":["../../src/lib/piAgent.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The adapter `pi-agent` as the cloud tier (\"harness\"): instead of running its own tool loop, the assistant\n * hands the question to a pi-agent instance via `sendTo(<instance>, 'ask', …)`. pi-agent stores every model\n * and tool call as a checkpoint before it runs (an interrupted request continues after a restart, a write is\n * never repeated), enforces its own write permissions and confirmations, and can use the system-wide AI\n * settings of admin — none of which the built-in `LlmAgent` does.\n *\n * Each request source (satellite, `chat`, `telegram:<user>`) gets a conversation of its own in pi-agent\n * (`conversation`), so two rooms do not see each other's follow-ups. The source-independent part of the\n * prompt (style, follow-up marker, memory, device list) goes along as the `instructions` of that\n * conversation; what changes every turn (time, weather, presence) is prepended to the question.\n * pi-agent 0.3.0 and older ignore both fields and answer everything in their root conversation.\n *\n * With \"Confirmation by text in sendTo conversations\", pi-agent answers a write it may not do unasked with a\n * question ending in `[[LISTEN]]`, and the next `ask` of the conversation is the yes or no. `language` makes it\n * ask that question in the language the satellite listens in.\n */\n\nimport type { ImageInput } from './media';\n\n/** Longest instructions pi-agent accepts for a conversation; anything beyond is cut there. */\nexport const PI_AGENT_MAX_INSTRUCTIONS = 20000;\n\n/** Default for `piAgentTimeoutSec`: a tool loop with several reads can take a while. */\nexport const PI_AGENT_TIMEOUT_SEC = 60;\n\n/** The part of the adapter this module needs (kept small so tests can fake it). */\nexport interface PiAgentHost {\n sendToAsync(instance: string, command: string, message: unknown, options?: { timeout?: number }): Promise<unknown>;\n getForeignStateAsync(id: string): Promise<ioBroker.State | null | undefined>;\n}\n\nexport interface PiAgentRequest {\n /** The text pi-agent answers: context lines + the question. */\n text: string;\n /** Conversation of the caller, e.g. the satellite name. */\n conversation: string;\n /** Instructions of that conversation. */\n instructions: string;\n /**\n * Language the user speaks, e.g. `ru`. pi-agent asks its questions to confirm in it; older versions ask in the\n * system language, and a satellite listening in Russian does not hear a German \"Ja\" (the STT returns nothing).\n */\n language?: string;\n /**\n * Pictures that came with the question (`{ data: base64, mimeType }`, the form of pi-ai's `ImageContent`).\n * Versions of pi-agent before image support ignore them; the text then says that pictures came along.\n */\n images?: ImageInput[];\n /**\n * Only for a request from a voice satellite: its id under `assistant.N.satellites.*`, and the full id of its `tts`\n * state. Writing text there speaks it on that satellite — so pi-agent can answer later where it was asked (a\n * reminder, the end of a background task). Chat and Telegram requests have neither.\n */\n satellite?: string;\n tts?: string;\n}\n\n/**\n * Join instruction blocks, leaving out optional ones from the end until the result fits into pi-agent's\n * limit — pi-agent would cut the text in the middle, and a half device list is worse than none.\n *\n * @param required blocks that always go along (style, follow-up marker)\n * @param optional blocks in order of importance (memory before the device list)\n */\nexport function composeInstructions(required: string[], optional: string[]): { text: string; dropped: number } {\n const blocks = [...required.filter(Boolean)];\n const extra = optional.filter(Boolean);\n let dropped = 0;\n while (extra.length) {\n const text = [...blocks, ...extra].join('\\n\\n');\n if (text.length <= PI_AGENT_MAX_INSTRUCTIONS) {\n return { text, dropped };\n }\n extra.pop();\n dropped++;\n }\n return { text: blocks.join('\\n\\n').slice(0, PI_AGENT_MAX_INSTRUCTIONS), dropped };\n}\n\n/** Conversation name for a request source; pi-agent uses \"/\" as separator in its keys. */\nexport function conversationOf(source: string): string {\n return (source || 'default').replace(/\\//g, '_').slice(0, 100);\n}\n\nexport class PiAgentHarness {\n private seq = 0;\n\n public constructor(\n private readonly host: PiAgentHost,\n /** e.g. `pi-agent.0` */\n public readonly instance: string,\n private readonly timeoutMs: number = PI_AGENT_TIMEOUT_SEC * 1000,\n ) {}\n\n /**\n * Ask pi-agent. Fails fast when the instance is not running (otherwise the caller would wait for the whole\n * timeout), and turns pi-agent's `{error}` into an exception so the caller can fall back to the local model.\n */\n public async ask(req: PiAgentRequest): Promise<string> {\n const alive = await this.host.getForeignStateAsync(`system.adapter.${this.instance}.alive`);\n if (!alive?.val) {\n throw new Error(`${this.instance} is not running`);\n }\n // Unique per request: pi-agent answers a repeated requestId only once.\n const requestId = `assistant-${Date.now().toString(36)}-${(++this.seq).toString(36)}`;\n const result = (await this.host.sendToAsync(\n this.instance,\n 'ask',\n {\n text: req.text,\n conversation: conversationOf(req.conversation),\n instructions: req.instructions,\n ...(req.language ? { language: req.language } : {}),\n ...(req.images?.length ? { images: req.images } : {}),\n ...(req.satellite ? { satellite: req.satellite } : {}),\n ...(req.tts ? { tts: req.tts } : {}),\n requestId,\n },\n { timeout: this.timeoutMs },\n )) as { answer?: unknown; error?: unknown } | null | undefined;\n if (result?.error) {\n const error = typeof result.error === 'string' ? result.error : JSON.stringify(result.error);\n throw new Error(`${this.instance}: ${error}`);\n }\n const answer = typeof result?.answer === 'string' ? result.answer.trim() : '';\n if (!answer) {\n throw new Error(`${this.instance} gave no answer`);\n }\n return answer;\n }\n}\n"]}
@@ -47,6 +47,10 @@ class TimerManager {
47
47
  duration,
48
48
  fireAt: now + duration * 1000,
49
49
  };
50
+ const announce = (o.announce || '').trim();
51
+ if (announce) {
52
+ info.announce = announce;
53
+ }
50
54
  this.timers.set(info.id, info);
51
55
  this.opts.log?.debug(`timer added: ${info.id} "${info.label}" in ${duration}s`);
52
56
  this.arm(info);
@@ -104,6 +108,9 @@ class TimerManager {
104
108
  duration: t.duration || Math.round((t.fireAt - now) / 1000),
105
109
  fireAt: t.fireAt,
106
110
  };
111
+ if (typeof t.announce === 'string' && t.announce) {
112
+ info.announce = t.announce;
113
+ }
107
114
  this.timers.set(info.id, info);
108
115
  this.arm(info);
109
116
  restored++;
@@ -1 +1 @@
1
- {"version":3,"file":"timers.js","sourceRoot":"","sources":["../../src/lib/timers.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Countdown timers / reminders — the model-free, self-contained timer engine (roadmap #2).\n *\n * Unlike the Python assistant \"Hannah\" (which keeps timers only in SQLite and signals satellites over\n * gRPC/MQTT, creating no ioBroker objects), this manager is deliberately ioBroker-first: it holds the\n * active timers in memory and fires each one with its own `setTimeout`, while `main.ts` mirrors every\n * timer into `assistant.0.timers.*` states so they can be visualised or reacted to from JavaScript.\n *\n * States are written only on change events (add/cancel/fire/restore) — never on a periodic tick. The\n * live countdown is derived from the absolute `fireAt` timestamp by the consumer (vis widget / script),\n * so nothing hammers the states/history every second.\n *\n * The manager itself is transport-agnostic and testable: it schedules, cancels and restores timers and\n * emits callbacks (`onFire`, `onChange`); the adapter owns the ioBroker representation and the spoken\n * announcement.\n */\n\n/** One active countdown timer. Plain data so it round-trips through JSON for persistence. */\nexport interface TimerInfo {\n /** Short unique id (also the `timers.items.<id>` state-object segment). */\n id: string;\n /** Optional label (\"die Nudeln\"); '' when the user only gave a duration. */\n label: string;\n /** Room the command came from / was scoped to ('' when unknown). */\n room: string;\n /** Origin of the request (satellite device name / 'chat' / '') — used to target the announcement. */\n source: string;\n /** Epoch ms the timer was created. */\n createdAt: number;\n /** Original duration in seconds. */\n duration: number;\n /** Epoch ms the timer fires at. */\n fireAt: number;\n}\n\n/** Options for creating a new timer. */\nexport interface AddTimerOptions {\n label?: string;\n room?: string;\n source?: string;\n /** Duration in seconds (> 0). */\n duration: number;\n}\n\nexport interface TimerManagerOptions {\n /** Called once per timer when it expires (already removed from the active list). */\n onFire: (timer: TimerInfo) => void;\n /** Called whenever the active list changes (add/cancel/fire/restore) with the new sorted list. */\n onChange: (timers: TimerInfo[]) => void;\n log?: Pick<ioBroker.Log, 'debug' | 'info' | 'warn'>;\n /** Injectable clock (tests). Defaults to `Date.now`. */\n now?: () => number;\n /** Don't arm real `setTimeout`s — the caller drives {@link TimerManager.tick} (tests). */\n manualTick?: boolean;\n}\n\n/** Largest delay `setTimeout` accepts (~24.8 days); longer timers are re-armed in chunks. */\nconst MAX_TIMEOUT = 2 ** 31 - 1;\n\n/**\n * In-memory countdown timer scheduler. Each timer fires from its own `setTimeout` (no polling loop), and\n * the manager never touches any state on a periodic schedule — only on add/cancel/fire/restore.\n */\nexport class TimerManager {\n private readonly timers = new Map<string, TimerInfo>();\n private readonly handles = new Map<string, ReturnType<typeof setTimeout>>();\n private seq = 0;\n private readonly now: () => number;\n\n constructor(private readonly opts: TimerManagerOptions) {\n this.now = opts.now || Date.now;\n }\n\n /** Add a timer, schedule it and notify. Returns the created {@link TimerInfo}. */\n add(o: AddTimerOptions): TimerInfo {\n const now = this.now();\n const duration = Math.max(1, Math.round(o.duration));\n const info: TimerInfo = {\n id: this.genId(now),\n label: (o.label || '').trim(),\n room: (o.room || '').trim(),\n source: o.source || '',\n createdAt: now,\n duration,\n fireAt: now + duration * 1000,\n };\n this.timers.set(info.id, info);\n this.opts.log?.debug(`timer added: ${info.id} \"${info.label}\" in ${duration}s`);\n this.arm(info);\n this.emitChange();\n return info;\n }\n\n /** Cancel one timer by id. Returns true if it existed. */\n cancel(id: string): boolean {\n this.clearHandle(id);\n if (!this.timers.delete(id)) {\n return false;\n }\n this.emitChange();\n return true;\n }\n\n /** Cancel all active timers (optionally only those in a room). Returns the number cancelled. */\n cancelAll(room?: string): number {\n const victims = this.list().filter(t => !room || t.room === room);\n for (const t of victims) {\n this.clearHandle(t.id);\n this.timers.delete(t.id);\n }\n if (victims.length) {\n this.emitChange();\n }\n return victims.length;\n }\n\n /** The active timers, soonest-to-fire first. */\n list(): TimerInfo[] {\n return [...this.timers.values()].sort((a, b) => a.fireAt - b.fireAt);\n }\n\n get(id: string): TimerInfo | undefined {\n return this.timers.get(id);\n }\n\n /**\n * Restore persisted timers after a restart: timers still in the future are rescheduled, ones that\n * already expired while the adapter was down are dropped (announcing them late would be surprising).\n * Returns how many were restored / dropped.\n */\n restore(list: TimerInfo[]): { restored: number; dropped: number } {\n const now = this.now();\n let restored = 0;\n let dropped = 0;\n for (const t of list) {\n if (!t || typeof t.fireAt !== 'number' || typeof t.id !== 'string') {\n continue;\n }\n if (t.fireAt > now) {\n const info: TimerInfo = {\n id: t.id,\n label: t.label || '',\n room: t.room || '',\n source: t.source || '',\n createdAt: t.createdAt || now,\n duration: t.duration || Math.round((t.fireAt - now) / 1000),\n fireAt: t.fireAt,\n };\n this.timers.set(info.id, info);\n this.arm(info);\n restored++;\n } else {\n dropped++;\n }\n }\n this.emitChange();\n return { restored, dropped };\n }\n\n /**\n * Fire every timer whose `fireAt` has passed — public so tests can drive expiry with a mocked clock\n * (production fires each timer from its own `setTimeout`, so this is a no-op there).\n */\n tick(): void {\n const now = this.now();\n const due = this.list().filter(t => t.fireAt <= now);\n for (const t of due) {\n this.fire(t.id, false);\n }\n if (due.length) {\n this.emitChange();\n }\n }\n\n /** Clear all pending timeouts and drop the in-memory timers (no callbacks). Call on unload. */\n dispose(): void {\n for (const h of this.handles.values()) {\n clearTimeout(h);\n }\n this.handles.clear();\n this.timers.clear();\n }\n\n /** Fire one timer: remove it, then invoke `onFire`. `emit` controls whether to notify immediately. */\n private fire(id: string, emit: boolean): void {\n const info = this.timers.get(id);\n if (!info) {\n return;\n }\n this.clearHandle(id);\n this.timers.delete(id);\n try {\n this.opts.onFire(info);\n } catch (e) {\n this.opts.log?.warn(`timer onFire failed: ${(e as Error).message}`);\n }\n if (emit) {\n this.emitChange();\n }\n }\n\n /** Arm the `setTimeout` for a timer (chunked for delays beyond the setTimeout limit). */\n private arm(info: TimerInfo): void {\n if (this.opts.manualTick) {\n return;\n }\n this.clearHandle(info.id);\n const delay = Math.max(0, info.fireAt - this.now());\n const handle = setTimeout(\n () => {\n // Re-arm if this was only a chunk of a very long delay; otherwise fire.\n if (this.timers.has(info.id) && info.fireAt - this.now() > 0) {\n this.arm(info);\n } else {\n this.fire(info.id, true);\n }\n },\n Math.min(delay, MAX_TIMEOUT),\n );\n (handle as { unref?: () => void }).unref?.(); // don't keep the process alive for a timer\n this.handles.set(info.id, handle);\n }\n\n private clearHandle(id: string): void {\n const h = this.handles.get(id);\n if (h) {\n clearTimeout(h);\n this.handles.delete(id);\n }\n }\n\n private genId(now: number): string {\n return `t${now.toString(36)}${(this.seq++).toString(36)}`;\n }\n\n private emitChange(): void {\n try {\n this.opts.onChange(this.list());\n } catch (e) {\n this.opts.log?.warn(`timer onChange failed: ${(e as Error).message}`);\n }\n }\n}\n\n/** Simple Russian plural selector (1 → one, 2–4 → few, else many; with the 11–14 exception). */\nfunction ruPlural(n: number, one: string, few: string, many: string): string {\n const m10 = n % 10;\n const m100 = n % 100;\n if (m10 === 1 && m100 !== 11) {\n return one;\n }\n if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) {\n return few;\n }\n return many;\n}\n\n/**\n * Human-readable, spoken-friendly duration (\"1 Stunde 30 Minuten\", \"5 minutes\", \"2 минуты\"). Seconds are\n * only shown when there is no hour part (so a 90-minute timer reads \"1 Stunde 30 Minuten\", not with a\n * dangling \"0 Sekunden\"). Falls back to English for languages other than de/en/ru.\n */\nexport function formatDuration(totalSec: number, lang: string): string {\n const l = lang === 'ru' ? 'ru' : lang === 'de' ? 'de' : 'en';\n let sec = Math.max(0, Math.round(totalSec));\n const h = Math.floor(sec / 3600);\n sec -= h * 3600;\n const m = Math.floor(sec / 60);\n const s = sec - m * 60;\n\n const parts: string[] = [];\n const add = (n: number, ru: [string, string, string], de: [string, string], en: [string, string]): void => {\n if (l === 'ru') {\n parts.push(`${n} ${ruPlural(n, ru[0], ru[1], ru[2])}`);\n } else if (l === 'de') {\n parts.push(`${n} ${n === 1 ? de[0] : de[1]}`);\n } else {\n parts.push(`${n} ${n === 1 ? en[0] : en[1]}`);\n }\n };\n\n if (h) {\n add(h, ['час', 'часа', 'часов'], ['Stunde', 'Stunden'], ['hour', 'hours']);\n }\n if (m) {\n add(m, ['минута', 'минуты', 'минут'], ['Minute', 'Minuten'], ['minute', 'minutes']);\n }\n // Show seconds when there is no hour part, or when nothing else was added (e.g. a 10-second timer).\n if ((s && !h) || !parts.length) {\n add(s, ['секунда', 'секунды', 'секунд'], ['Sekunde', 'Sekunden'], ['second', 'seconds']);\n }\n return parts.join(' ');\n}\n"]}
1
+ {"version":3,"file":"timers.js","sourceRoot":"","sources":["../../src/lib/timers.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Countdown timers / reminders — the model-free, self-contained timer engine (roadmap #2).\n *\n * Unlike the Python assistant \"Hannah\" (which keeps timers only in SQLite and signals satellites over\n * gRPC/MQTT, creating no ioBroker objects), this manager is deliberately ioBroker-first: it holds the\n * active timers in memory and fires each one with its own `setTimeout`, while `main.ts` mirrors every\n * timer into `assistant.0.timers.*` states so they can be visualised or reacted to from JavaScript.\n *\n * States are written only on change events (add/cancel/fire/restore) — never on a periodic tick. The\n * live countdown is derived from the absolute `fireAt` timestamp by the consumer (vis widget / script),\n * so nothing hammers the states/history every second.\n *\n * The manager itself is transport-agnostic and testable: it schedules, cancels and restores timers and\n * emits callbacks (`onFire`, `onChange`); the adapter owns the ioBroker representation and the spoken\n * announcement.\n */\n\n/** One active countdown timer. Plain data so it round-trips through JSON for persistence. */\nexport interface TimerInfo {\n /** Short unique id (also the `timers.items.<id>` state-object segment). */\n id: string;\n /** Optional label (\"die Nudeln\"); '' when the user only gave a duration. */\n label: string;\n /** Room the command came from / was scoped to ('' when unknown). */\n room: string;\n /** Origin of the request (satellite device name / 'chat' / '') — used to target the announcement. */\n source: string;\n /** Epoch ms the timer was created. */\n createdAt: number;\n /** Original duration in seconds. */\n duration: number;\n /** Epoch ms the timer fires at. */\n fireAt: number;\n /** What to say when it fires, word for word (a reminder set by a script); absent = the usual \"timer finished\". */\n announce?: string;\n}\n\n/** Options for creating a new timer. */\nexport interface AddTimerOptions {\n label?: string;\n room?: string;\n source?: string;\n /** Duration in seconds (> 0). */\n duration: number;\n /** See {@link TimerInfo.announce}. */\n announce?: string;\n}\n\nexport interface TimerManagerOptions {\n /** Called once per timer when it expires (already removed from the active list). */\n onFire: (timer: TimerInfo) => void;\n /** Called whenever the active list changes (add/cancel/fire/restore) with the new sorted list. */\n onChange: (timers: TimerInfo[]) => void;\n log?: Pick<ioBroker.Log, 'debug' | 'info' | 'warn'>;\n /** Injectable clock (tests). Defaults to `Date.now`. */\n now?: () => number;\n /** Don't arm real `setTimeout`s — the caller drives {@link TimerManager.tick} (tests). */\n manualTick?: boolean;\n}\n\n/** Largest delay `setTimeout` accepts (~24.8 days); longer timers are re-armed in chunks. */\nconst MAX_TIMEOUT = 2 ** 31 - 1;\n\n/**\n * In-memory countdown timer scheduler. Each timer fires from its own `setTimeout` (no polling loop), and\n * the manager never touches any state on a periodic schedule — only on add/cancel/fire/restore.\n */\nexport class TimerManager {\n private readonly timers = new Map<string, TimerInfo>();\n private readonly handles = new Map<string, ReturnType<typeof setTimeout>>();\n private seq = 0;\n private readonly now: () => number;\n\n constructor(private readonly opts: TimerManagerOptions) {\n this.now = opts.now || Date.now;\n }\n\n /** Add a timer, schedule it and notify. Returns the created {@link TimerInfo}. */\n add(o: AddTimerOptions): TimerInfo {\n const now = this.now();\n const duration = Math.max(1, Math.round(o.duration));\n const info: TimerInfo = {\n id: this.genId(now),\n label: (o.label || '').trim(),\n room: (o.room || '').trim(),\n source: o.source || '',\n createdAt: now,\n duration,\n fireAt: now + duration * 1000,\n };\n const announce = (o.announce || '').trim();\n if (announce) {\n info.announce = announce;\n }\n this.timers.set(info.id, info);\n this.opts.log?.debug(`timer added: ${info.id} \"${info.label}\" in ${duration}s`);\n this.arm(info);\n this.emitChange();\n return info;\n }\n\n /** Cancel one timer by id. Returns true if it existed. */\n cancel(id: string): boolean {\n this.clearHandle(id);\n if (!this.timers.delete(id)) {\n return false;\n }\n this.emitChange();\n return true;\n }\n\n /** Cancel all active timers (optionally only those in a room). Returns the number cancelled. */\n cancelAll(room?: string): number {\n const victims = this.list().filter(t => !room || t.room === room);\n for (const t of victims) {\n this.clearHandle(t.id);\n this.timers.delete(t.id);\n }\n if (victims.length) {\n this.emitChange();\n }\n return victims.length;\n }\n\n /** The active timers, soonest-to-fire first. */\n list(): TimerInfo[] {\n return [...this.timers.values()].sort((a, b) => a.fireAt - b.fireAt);\n }\n\n get(id: string): TimerInfo | undefined {\n return this.timers.get(id);\n }\n\n /**\n * Restore persisted timers after a restart: timers still in the future are rescheduled, ones that\n * already expired while the adapter was down are dropped (announcing them late would be surprising).\n * Returns how many were restored / dropped.\n */\n restore(list: TimerInfo[]): { restored: number; dropped: number } {\n const now = this.now();\n let restored = 0;\n let dropped = 0;\n for (const t of list) {\n if (!t || typeof t.fireAt !== 'number' || typeof t.id !== 'string') {\n continue;\n }\n if (t.fireAt > now) {\n const info: TimerInfo = {\n id: t.id,\n label: t.label || '',\n room: t.room || '',\n source: t.source || '',\n createdAt: t.createdAt || now,\n duration: t.duration || Math.round((t.fireAt - now) / 1000),\n fireAt: t.fireAt,\n };\n if (typeof t.announce === 'string' && t.announce) {\n info.announce = t.announce;\n }\n this.timers.set(info.id, info);\n this.arm(info);\n restored++;\n } else {\n dropped++;\n }\n }\n this.emitChange();\n return { restored, dropped };\n }\n\n /**\n * Fire every timer whose `fireAt` has passed — public so tests can drive expiry with a mocked clock\n * (production fires each timer from its own `setTimeout`, so this is a no-op there).\n */\n tick(): void {\n const now = this.now();\n const due = this.list().filter(t => t.fireAt <= now);\n for (const t of due) {\n this.fire(t.id, false);\n }\n if (due.length) {\n this.emitChange();\n }\n }\n\n /** Clear all pending timeouts and drop the in-memory timers (no callbacks). Call on unload. */\n dispose(): void {\n for (const h of this.handles.values()) {\n clearTimeout(h);\n }\n this.handles.clear();\n this.timers.clear();\n }\n\n /** Fire one timer: remove it, then invoke `onFire`. `emit` controls whether to notify immediately. */\n private fire(id: string, emit: boolean): void {\n const info = this.timers.get(id);\n if (!info) {\n return;\n }\n this.clearHandle(id);\n this.timers.delete(id);\n try {\n this.opts.onFire(info);\n } catch (e) {\n this.opts.log?.warn(`timer onFire failed: ${(e as Error).message}`);\n }\n if (emit) {\n this.emitChange();\n }\n }\n\n /** Arm the `setTimeout` for a timer (chunked for delays beyond the setTimeout limit). */\n private arm(info: TimerInfo): void {\n if (this.opts.manualTick) {\n return;\n }\n this.clearHandle(info.id);\n const delay = Math.max(0, info.fireAt - this.now());\n const handle = setTimeout(\n () => {\n // Re-arm if this was only a chunk of a very long delay; otherwise fire.\n if (this.timers.has(info.id) && info.fireAt - this.now() > 0) {\n this.arm(info);\n } else {\n this.fire(info.id, true);\n }\n },\n Math.min(delay, MAX_TIMEOUT),\n );\n (handle as { unref?: () => void }).unref?.(); // don't keep the process alive for a timer\n this.handles.set(info.id, handle);\n }\n\n private clearHandle(id: string): void {\n const h = this.handles.get(id);\n if (h) {\n clearTimeout(h);\n this.handles.delete(id);\n }\n }\n\n private genId(now: number): string {\n return `t${now.toString(36)}${(this.seq++).toString(36)}`;\n }\n\n private emitChange(): void {\n try {\n this.opts.onChange(this.list());\n } catch (e) {\n this.opts.log?.warn(`timer onChange failed: ${(e as Error).message}`);\n }\n }\n}\n\n/** Simple Russian plural selector (1 → one, 2–4 → few, else many; with the 11–14 exception). */\nfunction ruPlural(n: number, one: string, few: string, many: string): string {\n const m10 = n % 10;\n const m100 = n % 100;\n if (m10 === 1 && m100 !== 11) {\n return one;\n }\n if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) {\n return few;\n }\n return many;\n}\n\n/**\n * Human-readable, spoken-friendly duration (\"1 Stunde 30 Minuten\", \"5 minutes\", \"2 минуты\"). Seconds are\n * only shown when there is no hour part (so a 90-minute timer reads \"1 Stunde 30 Minuten\", not with a\n * dangling \"0 Sekunden\"). Falls back to English for languages other than de/en/ru.\n */\nexport function formatDuration(totalSec: number, lang: string): string {\n const l = lang === 'ru' ? 'ru' : lang === 'de' ? 'de' : 'en';\n let sec = Math.max(0, Math.round(totalSec));\n const h = Math.floor(sec / 3600);\n sec -= h * 3600;\n const m = Math.floor(sec / 60);\n const s = sec - m * 60;\n\n const parts: string[] = [];\n const add = (n: number, ru: [string, string, string], de: [string, string], en: [string, string]): void => {\n if (l === 'ru') {\n parts.push(`${n} ${ruPlural(n, ru[0], ru[1], ru[2])}`);\n } else if (l === 'de') {\n parts.push(`${n} ${n === 1 ? de[0] : de[1]}`);\n } else {\n parts.push(`${n} ${n === 1 ? en[0] : en[1]}`);\n }\n };\n\n if (h) {\n add(h, ['час', 'часа', 'часов'], ['Stunde', 'Stunden'], ['hour', 'hours']);\n }\n if (m) {\n add(m, ['минута', 'минуты', 'минут'], ['Minute', 'Minuten'], ['minute', 'minutes']);\n }\n // Show seconds when there is no hour part, or when nothing else was added (e.g. a 10-second timer).\n if ((s && !h) || !parts.length) {\n add(s, ['секунда', 'секунды', 'секунд'], ['Sekunde', 'Sekunden'], ['second', 'seconds']);\n }\n return parts.join(' ');\n}\n"]}
@@ -182,10 +182,12 @@ const CONFIRM_ENTITY = 'confirm_answer';
182
182
  /** Select entity of a satellite with a screen: the language of the date and the hints it shows. */
183
183
  const DISPLAY_LANGUAGE_ENTITY = 'display_language';
184
184
  /**
185
- * After a reply that asks something: how long, beyond the reply's own length, the device may take to play it and
186
- * open the microphone. Until then a button press answers the question.
185
+ * After a reply that asks something: how long, beyond the reply's own length, the question stays open — the next
186
+ * pipeline the device starts answers it, and so does a yes/no button press. Answering it (by voice or button) closes
187
+ * it earlier. The wake word phrase cannot tell a follow-up from a new request: ESPHome sends the phrase of the last
188
+ * wake word with every request, the follow-up microphone included (field report 2026-11-05).
187
189
  */
188
- const FOLLOW_UP_START_MS = 10_000;
190
+ const QUESTION_OPEN_MS = 30_000;
189
191
  /**
190
192
  * A button press still answers the question this long after the follow-up microphone closed: the firmware may stop
191
193
  * the microphone on the press before it reports which button it was.
@@ -355,6 +357,14 @@ class EsphomeSatellites {
355
357
  const target = this.connections.find(c => c.online && c.deviceName === device);
356
358
  return target ? target.setEntity(objectId, value) : false;
357
359
  }
360
+ /**
361
+ * Absolute URL of a path on our media server (a live stream) with a host `device` can reach — the local end of
362
+ * its connection unless a media host is configured. null when it is offline.
363
+ */
364
+ mediaUrl(device, path) {
365
+ const target = this.connections.find(c => c.online && c.deviceName === device);
366
+ return target ? target.mediaUrl(path) : null;
367
+ }
358
368
  /**
359
369
  * Mirror an assistant timer onto the satellites so they can show it on their own LED ring and ring
360
370
  * it themselves. `device` null broadcasts. Returns how many devices were told.
@@ -430,11 +440,15 @@ class EsphomeConnection {
430
440
  loggedFormat = '';
431
441
  /**
432
442
  * The last reply asked something (`continue_conversation`, or an announcement with `start_conversation`): until
433
- * when a yes/no button press answers it while no follow-up recording is open. 0 = no open question.
443
+ * when the next request of the device, or a yes/no button press, answers it. 0 = no open question.
434
444
  */
435
445
  questionUntil = 0;
436
446
  /** After a button answer: until when a follow-up microphone is the stale one of the answered question. */
437
447
  ignoreFollowUpUntil = 0;
448
+ /** When a yes/no button press last answered a question. */
449
+ buttonAt = 0;
450
+ /** When the device last asked to stop (`VoiceAssistantRequest{start:false}`). */
451
+ stoppedAt = 0;
438
452
  /** The display language was compared with ours since the last connect. */
439
453
  displayLanguageChecked = false;
440
454
  constructor(device, opts) {
@@ -773,6 +787,10 @@ class EsphomeConnection {
773
787
  return;
774
788
  }
775
789
  this.questionUntil = 0;
790
+ // Pressed while the question still played: the device may open the microphone for it afterwards. Not if the
791
+ // microphone is open already (our RUN_END closes it for good), and not if the device just stopped its
792
+ // conversation itself — the firmware does that on the press, which also drops its continue flag.
793
+ const micStillComes = !this.capture && Date.now() - this.stoppedAt >= CONFIRM_GRACE_MS;
776
794
  if (this.capture) {
777
795
  // A RUN_END while the microphone streams stops it and puts the device back to idle — without the error
778
796
  // display an empty recognition would end with.
@@ -782,7 +800,8 @@ class EsphomeConnection {
782
800
  // Whatever is still in flight for the question stays silent; so does the follow-up microphone the device may
783
801
  // still open for it.
784
802
  const run = ++this.run;
785
- this.ignoreFollowUpUntil = Date.now() + IGNORE_FOLLOW_UP_MS;
803
+ this.buttonAt = Date.now();
804
+ this.ignoreFollowUpUntil = micStillComes ? Date.now() + IGNORE_FOLLOW_UP_MS : 0;
786
805
  void this.answerButton(yes, run);
787
806
  }
788
807
  /** Pass a button answer on and announce the reply (see {@link onConfirmButton}). */
@@ -863,7 +882,7 @@ class EsphomeConnection {
863
882
  return;
864
883
  }
865
884
  const phrase = request.getWakeWordPhrase();
866
- if (!phrase && Date.now() < this.ignoreFollowUpUntil) {
885
+ if (Date.now() < this.ignoreFollowUpUntil) {
867
886
  // The microphone for the question a button already answered: close it at once. In this state the device
868
887
  // takes a RUN_END as "stop the microphone" and goes back to idle.
869
888
  this.ignoreFollowUpUntil = 0;
@@ -877,13 +896,12 @@ class EsphomeConnection {
877
896
  this.opts.log.debug(`ESPHome ${this.deviceName}: new wake while answering — dropping the old reply`);
878
897
  }
879
898
  this.run++;
880
- // Without a wake word the device re-opened the mic for the reply that asked something; with one, the user
881
- // started over and the question is gone.
882
- const answersQuestion = !phrase && Date.now() < this.questionUntil;
883
- if (phrase) {
884
- this.questionUntil = 0;
885
- }
886
- this.opts.log.debug(`ESPHome ${this.deviceName}: wake "${phrase || '(follow-up)'}"`);
899
+ // While a question is open the next request answers it. The phrase says nothing here: the follow-up
900
+ // microphone carries the last wake word too. Should the user really have said the wake word, the utterance
901
+ // is still the next one after the question — which is what pi-agent and askUser take as the answer anyway.
902
+ const answersQuestion = Date.now() < this.questionUntil;
903
+ const followUp = answersQuestion || !phrase;
904
+ this.opts.log.debug(`ESPHome ${this.deviceName}: ${answersQuestion ? 'answer to the question' : followUp ? 'follow-up' : `wake "${phrase}"`}`);
887
905
  const response = new esphomeProto_1.pb.VoiceAssistantResponse();
888
906
  response.setPort(0); // 0 = stream the audio over this socket
889
907
  response.setError(false);
@@ -907,13 +925,13 @@ class EsphomeConnection {
907
925
  // travel: derive them from the room instead, once the wake chirp is out of the way.
908
926
  skipMs: WAKE_CHIRP_MS,
909
927
  adaptive: true,
910
- startTimeoutMs: phrase ? NO_SPEECH_MS : NO_SPEECH_FOLLOW_UP_MS,
928
+ startTimeoutMs: followUp ? NO_SPEECH_FOLLOW_UP_MS : NO_SPEECH_MS,
911
929
  minStartLevel: this.speechLevel || exports.DEFAULT_SPEECH_LEVEL,
912
930
  minEndLevel: Math.round((this.speechLevel || exports.DEFAULT_SPEECH_LEVEL) * SILENCE_SHARE),
913
931
  minSpeechMs: MIN_SPEECH_MS,
914
932
  }),
915
933
  conversationId: request.getConversationId(),
916
- wakeWord: phrase || '',
934
+ wakeWord: followUp ? '' : phrase,
917
935
  answersQuestion,
918
936
  timer,
919
937
  stallCheck,
@@ -923,7 +941,7 @@ class EsphomeConnection {
923
941
  };
924
942
  this.status('listening');
925
943
  this.event(EVENT.RUN_START);
926
- if (phrase) {
944
+ if (!followUp) {
927
945
  this.event(EVENT.WAKE_WORD_END, { wake_word_phrase: phrase });
928
946
  }
929
947
  this.event(EVENT.STT_START);
@@ -936,9 +954,19 @@ class EsphomeConnection {
936
954
  * every further wake word just sends another stop (field report 2026-11-03).
937
955
  */
938
956
  onStopRequest() {
957
+ this.stoppedAt = Date.now();
939
958
  const running = !!this.capture || this.runOpen;
940
- // A device with yes/no buttons may stop its microphone on the press before it reports which button it was.
941
- this.questionUntil = this.questionOpen() ? Date.now() + CONFIRM_GRACE_MS : 0;
959
+ if (!running && Date.now() - this.buttonAt < CONFIRM_GRACE_MS) {
960
+ // A device with yes/no buttons reports the press and then stops its conversation itself: the button
961
+ // answer ended our run already, and its reply is on the way — nothing to end, nothing to drop. With the
962
+ // stop the device dropped its continue flag too, so no microphone opens for the answered question.
963
+ this.ignoreFollowUpUntil = 0;
964
+ this.opts.log.debug(`ESPHome ${this.deviceName}: the device ended the conversation after the button`);
965
+ return;
966
+ }
967
+ // A device with yes/no buttons may also stop the microphone on the press before it reports which button it
968
+ // was (state messages can be batched): the question stays open a moment longer for that report.
969
+ this.questionUntil = this.questionOpen() ? Math.max(this.questionUntil, Date.now() + CONFIRM_GRACE_MS) : 0;
942
970
  this.abortCapture();
943
971
  if (running) {
944
972
  this.run++; // whatever is still being answered stays silent
@@ -1041,7 +1069,9 @@ class EsphomeConnection {
1041
1069
  if (capture.answersQuestion) {
1042
1070
  // Spoken: that is the answer, a button press now would be a second one. Silent: the buttons still answer
1043
1071
  // for a moment, as the screen may still show them.
1044
- this.questionUntil = capture.detector.sawSpeech ? 0 : Date.now() + CONFIRM_GRACE_MS;
1072
+ this.questionUntil = capture.detector.sawSpeech
1073
+ ? 0
1074
+ : Math.max(this.questionUntil, Date.now() + CONFIRM_GRACE_MS);
1045
1075
  }
1046
1076
  if (!pcm.length || !capture.detector.sawSpeech) {
1047
1077
  const loudest = Math.round(capture.detector.analysedPeak);
@@ -1087,7 +1117,7 @@ class EsphomeConnection {
1087
1117
  this.opts.log.info(`Voice Q (${this.deviceName}): (empty) — speech recognition returned no text for ${seconds.toFixed(1)} s of audio`);
1088
1118
  this.discard(capture, pcm, 'empty-stt', vad);
1089
1119
  if (capture.answersQuestion) {
1090
- this.questionUntil = Date.now() + CONFIRM_GRACE_MS;
1120
+ this.questionUntil = Math.max(this.questionUntil, Date.now() + CONFIRM_GRACE_MS);
1091
1121
  }
1092
1122
  // No STT_END without text — like Home Assistant, which reports this case as an error only.
1093
1123
  this.endWithoutRequest();
@@ -1136,9 +1166,7 @@ class EsphomeConnection {
1136
1166
  this.event(EVENT.TTS_END, { url });
1137
1167
  // linux-voice-assistant reads the flag here (ignoring "0"), ESPHome at INTENT_END
1138
1168
  this.event(EVENT.RUN_END, conversation);
1139
- this.questionUntil = followUp
1140
- ? Date.now() + (ttsPcm.length / 2 / sampleRate) * 1000 + FOLLOW_UP_START_MS
1141
- : 0;
1169
+ this.questionUntil = followUp ? Date.now() + (ttsPcm.length / 2 / sampleRate) * 1000 + QUESTION_OPEN_MS : 0;
1142
1170
  this.armPlaybackTimeout();
1143
1171
  }
1144
1172
  catch (e) {
@@ -1255,7 +1283,7 @@ class EsphomeConnection {
1255
1283
  if (startConversation) {
1256
1284
  // A question: the microphone the device opens after it is wanted, and a button press answers it
1257
1285
  this.ignoreFollowUpUntil = 0;
1258
- this.questionUntil = Date.now() + (pcm.length / 2 / sampleRate) * 1000 + FOLLOW_UP_START_MS;
1286
+ this.questionUntil = Date.now() + (pcm.length / 2 / sampleRate) * 1000 + QUESTION_OPEN_MS;
1259
1287
  }
1260
1288
  this.status('speaking');
1261
1289
  this.send(request);