iobroker.assistant 1.4.8 → 1.4.9

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,11 @@ 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.9 (2026-11-05)
241
+ * (@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
242
+ * (@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)
243
+ * (@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
244
+
240
245
  ### 1.4.8 (2026-11-05)
241
246
  * (@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
247
  * (@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.
@@ -430,11 +432,15 @@ class EsphomeConnection {
430
432
  loggedFormat = '';
431
433
  /**
432
434
  * 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.
435
+ * when the next request of the device, or a yes/no button press, answers it. 0 = no open question.
434
436
  */
435
437
  questionUntil = 0;
436
438
  /** After a button answer: until when a follow-up microphone is the stale one of the answered question. */
437
439
  ignoreFollowUpUntil = 0;
440
+ /** When a yes/no button press last answered a question. */
441
+ buttonAt = 0;
442
+ /** When the device last asked to stop (`VoiceAssistantRequest{start:false}`). */
443
+ stoppedAt = 0;
438
444
  /** The display language was compared with ours since the last connect. */
439
445
  displayLanguageChecked = false;
440
446
  constructor(device, opts) {
@@ -773,6 +779,10 @@ class EsphomeConnection {
773
779
  return;
774
780
  }
775
781
  this.questionUntil = 0;
782
+ // Pressed while the question still played: the device may open the microphone for it afterwards. Not if the
783
+ // microphone is open already (our RUN_END closes it for good), and not if the device just stopped its
784
+ // conversation itself — the firmware does that on the press, which also drops its continue flag.
785
+ const micStillComes = !this.capture && Date.now() - this.stoppedAt >= CONFIRM_GRACE_MS;
776
786
  if (this.capture) {
777
787
  // A RUN_END while the microphone streams stops it and puts the device back to idle — without the error
778
788
  // display an empty recognition would end with.
@@ -782,7 +792,8 @@ class EsphomeConnection {
782
792
  // Whatever is still in flight for the question stays silent; so does the follow-up microphone the device may
783
793
  // still open for it.
784
794
  const run = ++this.run;
785
- this.ignoreFollowUpUntil = Date.now() + IGNORE_FOLLOW_UP_MS;
795
+ this.buttonAt = Date.now();
796
+ this.ignoreFollowUpUntil = micStillComes ? Date.now() + IGNORE_FOLLOW_UP_MS : 0;
786
797
  void this.answerButton(yes, run);
787
798
  }
788
799
  /** Pass a button answer on and announce the reply (see {@link onConfirmButton}). */
@@ -863,7 +874,7 @@ class EsphomeConnection {
863
874
  return;
864
875
  }
865
876
  const phrase = request.getWakeWordPhrase();
866
- if (!phrase && Date.now() < this.ignoreFollowUpUntil) {
877
+ if (Date.now() < this.ignoreFollowUpUntil) {
867
878
  // The microphone for the question a button already answered: close it at once. In this state the device
868
879
  // takes a RUN_END as "stop the microphone" and goes back to idle.
869
880
  this.ignoreFollowUpUntil = 0;
@@ -877,13 +888,12 @@ class EsphomeConnection {
877
888
  this.opts.log.debug(`ESPHome ${this.deviceName}: new wake while answering — dropping the old reply`);
878
889
  }
879
890
  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)'}"`);
891
+ // While a question is open the next request answers it. The phrase says nothing here: the follow-up
892
+ // microphone carries the last wake word too. Should the user really have said the wake word, the utterance
893
+ // is still the next one after the question — which is what pi-agent and askUser take as the answer anyway.
894
+ const answersQuestion = Date.now() < this.questionUntil;
895
+ const followUp = answersQuestion || !phrase;
896
+ this.opts.log.debug(`ESPHome ${this.deviceName}: ${answersQuestion ? 'answer to the question' : followUp ? 'follow-up' : `wake "${phrase}"`}`);
887
897
  const response = new esphomeProto_1.pb.VoiceAssistantResponse();
888
898
  response.setPort(0); // 0 = stream the audio over this socket
889
899
  response.setError(false);
@@ -907,13 +917,13 @@ class EsphomeConnection {
907
917
  // travel: derive them from the room instead, once the wake chirp is out of the way.
908
918
  skipMs: WAKE_CHIRP_MS,
909
919
  adaptive: true,
910
- startTimeoutMs: phrase ? NO_SPEECH_MS : NO_SPEECH_FOLLOW_UP_MS,
920
+ startTimeoutMs: followUp ? NO_SPEECH_FOLLOW_UP_MS : NO_SPEECH_MS,
911
921
  minStartLevel: this.speechLevel || exports.DEFAULT_SPEECH_LEVEL,
912
922
  minEndLevel: Math.round((this.speechLevel || exports.DEFAULT_SPEECH_LEVEL) * SILENCE_SHARE),
913
923
  minSpeechMs: MIN_SPEECH_MS,
914
924
  }),
915
925
  conversationId: request.getConversationId(),
916
- wakeWord: phrase || '',
926
+ wakeWord: followUp ? '' : phrase,
917
927
  answersQuestion,
918
928
  timer,
919
929
  stallCheck,
@@ -923,7 +933,7 @@ class EsphomeConnection {
923
933
  };
924
934
  this.status('listening');
925
935
  this.event(EVENT.RUN_START);
926
- if (phrase) {
936
+ if (!followUp) {
927
937
  this.event(EVENT.WAKE_WORD_END, { wake_word_phrase: phrase });
928
938
  }
929
939
  this.event(EVENT.STT_START);
@@ -936,9 +946,19 @@ class EsphomeConnection {
936
946
  * every further wake word just sends another stop (field report 2026-11-03).
937
947
  */
938
948
  onStopRequest() {
949
+ this.stoppedAt = Date.now();
939
950
  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;
951
+ if (!running && Date.now() - this.buttonAt < CONFIRM_GRACE_MS) {
952
+ // A device with yes/no buttons reports the press and then stops its conversation itself: the button
953
+ // answer ended our run already, and its reply is on the way — nothing to end, nothing to drop. With the
954
+ // stop the device dropped its continue flag too, so no microphone opens for the answered question.
955
+ this.ignoreFollowUpUntil = 0;
956
+ this.opts.log.debug(`ESPHome ${this.deviceName}: the device ended the conversation after the button`);
957
+ return;
958
+ }
959
+ // A device with yes/no buttons may also stop the microphone on the press before it reports which button it
960
+ // was (state messages can be batched): the question stays open a moment longer for that report.
961
+ this.questionUntil = this.questionOpen() ? Math.max(this.questionUntil, Date.now() + CONFIRM_GRACE_MS) : 0;
942
962
  this.abortCapture();
943
963
  if (running) {
944
964
  this.run++; // whatever is still being answered stays silent
@@ -1041,7 +1061,9 @@ class EsphomeConnection {
1041
1061
  if (capture.answersQuestion) {
1042
1062
  // Spoken: that is the answer, a button press now would be a second one. Silent: the buttons still answer
1043
1063
  // for a moment, as the screen may still show them.
1044
- this.questionUntil = capture.detector.sawSpeech ? 0 : Date.now() + CONFIRM_GRACE_MS;
1064
+ this.questionUntil = capture.detector.sawSpeech
1065
+ ? 0
1066
+ : Math.max(this.questionUntil, Date.now() + CONFIRM_GRACE_MS);
1045
1067
  }
1046
1068
  if (!pcm.length || !capture.detector.sawSpeech) {
1047
1069
  const loudest = Math.round(capture.detector.analysedPeak);
@@ -1087,7 +1109,7 @@ class EsphomeConnection {
1087
1109
  this.opts.log.info(`Voice Q (${this.deviceName}): (empty) — speech recognition returned no text for ${seconds.toFixed(1)} s of audio`);
1088
1110
  this.discard(capture, pcm, 'empty-stt', vad);
1089
1111
  if (capture.answersQuestion) {
1090
- this.questionUntil = Date.now() + CONFIRM_GRACE_MS;
1112
+ this.questionUntil = Math.max(this.questionUntil, Date.now() + CONFIRM_GRACE_MS);
1091
1113
  }
1092
1114
  // No STT_END without text — like Home Assistant, which reports this case as an error only.
1093
1115
  this.endWithoutRequest();
@@ -1136,9 +1158,7 @@ class EsphomeConnection {
1136
1158
  this.event(EVENT.TTS_END, { url });
1137
1159
  // linux-voice-assistant reads the flag here (ignoring "0"), ESPHome at INTENT_END
1138
1160
  this.event(EVENT.RUN_END, conversation);
1139
- this.questionUntil = followUp
1140
- ? Date.now() + (ttsPcm.length / 2 / sampleRate) * 1000 + FOLLOW_UP_START_MS
1141
- : 0;
1161
+ this.questionUntil = followUp ? Date.now() + (ttsPcm.length / 2 / sampleRate) * 1000 + QUESTION_OPEN_MS : 0;
1142
1162
  this.armPlaybackTimeout();
1143
1163
  }
1144
1164
  catch (e) {
@@ -1255,7 +1275,7 @@ class EsphomeConnection {
1255
1275
  if (startConversation) {
1256
1276
  // A question: the microphone the device opens after it is wanted, and a button press answers it
1257
1277
  this.ignoreFollowUpUntil = 0;
1258
- this.questionUntil = Date.now() + (pcm.length / 2 / sampleRate) * 1000 + FOLLOW_UP_START_MS;
1278
+ this.questionUntil = Date.now() + (pcm.length / 2 / sampleRate) * 1000 + QUESTION_OPEN_MS;
1259
1279
  }
1260
1280
  this.status('speaking');
1261
1281
  this.send(request);