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 +8 -0
- package/build/lib/piAgent.js +2 -0
- package/build/lib/piAgent.js.map +1 -1
- package/build/lib/timers.js +7 -0
- package/build/lib/timers.js.map +1 -1
- package/build/lib/voice/esphome.js +52 -24
- package/build/lib/voice/esphome.js.map +1 -1
- package/build/lib/voice/esphomeEntities.js +10 -0
- package/build/lib/voice/esphomeEntities.js.map +1 -1
- package/build/lib/voice/esphomeProto.js.map +1 -1
- package/build/lib/voice/mediaServer.js +69 -0
- package/build/lib/voice/mediaServer.js.map +1 -1
- package/build/lib/voice/mediaStream.js +264 -0
- package/build/lib/voice/mediaStream.js.map +1 -0
- package/build/main.js +247 -16
- package/build/main.js.map +1 -1
- package/io-package.json +27 -27
- package/package.json +1 -1
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
|
package/build/lib/piAgent.js
CHANGED
|
@@ -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) {
|
package/build/lib/piAgent.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"piAgent.js","sourceRoot":"","sources":["../../src/lib/piAgent.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;GAgBG;;;
|
|
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"]}
|
package/build/lib/timers.js
CHANGED
|
@@ -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++;
|
package/build/lib/timers.js.map
CHANGED
|
@@ -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":";AAAA;;;;;;;;;;;;;;;GAeG;;;AAkQH,wCA8BC;AAnPD,6FAA6F;AAC7F,MAAM,WAAW,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;AAEhC;;;GAGG;AACH,MAAa,YAAY;IAMQ;IALZ,MAAM,GAAG,IAAI,GAAG,EAAqB,CAAC;IACtC,OAAO,GAAG,IAAI,GAAG,EAAyC,CAAC;IACpE,GAAG,GAAG,CAAC,CAAC;IACC,GAAG,CAAe;IAEnC,YAA6B,IAAyB;QAAzB,SAAI,GAAJ,IAAI,CAAqB;QAClD,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC;IACpC,CAAC;IAED,kFAAkF;IAClF,GAAG,CAAC,CAAkB;QAClB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC;QACrD,MAAM,IAAI,GAAc;YACpB,EAAE,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC;YACnB,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE;YAC7B,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE;YAC3B,MAAM,EAAE,CAAC,CAAC,MAAM,IAAI,EAAE;YACtB,SAAS,EAAE,GAAG;YACd,QAAQ;YACR,MAAM,EAAE,GAAG,GAAG,QAAQ,GAAG,IAAI;SAChC,CAAC;QACF,MAAM,QAAQ,GAAG,CAAC,CAAC,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QAC3C,IAAI,QAAQ,EAAE,CAAC;YACX,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QAC7B,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC;QAC/B,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,gBAAgB,IAAI,CAAC,EAAE,KAAK,IAAI,CAAC,KAAK,QAAQ,QAAQ,GAAG,CAAC,CAAC;QAChF,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACf,IAAI,CAAC,UAAU,EAAE,CAAC;QAClB,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,0DAA0D;IAC1D,MAAM,CAAC,EAAU;QACb,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;QACrB,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC;YAC1B,OAAO,KAAK,CAAC;QACjB,CAAC;QACD,IAAI,CAAC,UAAU,EAAE,CAAC;QAClB,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,gGAAgG;IAChG,SAAS,CAAC,IAAa;QACnB,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC;QAClE,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;YACtB,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YACvB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QAC7B,CAAC;QACD,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;YACjB,IAAI,CAAC,UAAU,EAAE,CAAC;QACtB,CAAC;QACD,OAAO,OAAO,CAAC,MAAM,CAAC;IAC1B,CAAC;IAED,gDAAgD;IAChD,IAAI;QACA,OAAO,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC;IACzE,CAAC;IAED,GAAG,CAAC,EAAU;QACV,OAAO,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAC/B,CAAC;IAED;;;;OAIG;IACH,OAAO,CAAC,IAAiB;QACrB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,IAAI,QAAQ,GAAG,CAAC,CAAC;QACjB,IAAI,OAAO,GAAG,CAAC,CAAC;QAChB,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;YACnB,IAAI,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,MAAM,KAAK,QAAQ,IAAI,OAAO,CAAC,CAAC,EAAE,KAAK,QAAQ,EAAE,CAAC;gBACjE,SAAS;YACb,CAAC;YACD,IAAI,CAAC,CAAC,MAAM,GAAG,GAAG,EAAE,CAAC;gBACjB,MAAM,IAAI,GAAc;oBACpB,EAAE,EAAE,CAAC,CAAC,EAAE;oBACR,KAAK,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE;oBACpB,IAAI,EAAE,CAAC,CAAC,IAAI,IAAI,EAAE;oBAClB,MAAM,EAAE,CAAC,CAAC,MAAM,IAAI,EAAE;oBACtB,SAAS,EAAE,CAAC,CAAC,SAAS,IAAI,GAAG;oBAC7B,QAAQ,EAAE,CAAC,CAAC,QAAQ,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,GAAG,CAAC,GAAG,IAAI,CAAC;oBAC3D,MAAM,EAAE,CAAC,CAAC,MAAM;iBACnB,CAAC;gBACF,IAAI,OAAO,CAAC,CAAC,QAAQ,KAAK,QAAQ,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC;oBAC/C,IAAI,CAAC,QAAQ,GAAG,CAAC,CAAC,QAAQ,CAAC;gBAC/B,CAAC;gBACD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC;gBAC/B,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;gBACf,QAAQ,EAAE,CAAC;YACf,CAAC;iBAAM,CAAC;gBACJ,OAAO,EAAE,CAAC;YACd,CAAC;QACL,CAAC;QACD,IAAI,CAAC,UAAU,EAAE,CAAC;QAClB,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC;IACjC,CAAC;IAED;;;OAGG;IACH,IAAI;QACA,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC;QACrD,KAAK,MAAM,CAAC,IAAI,GAAG,EAAE,CAAC;YAClB,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;QAC3B,CAAC;QACD,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC;YACb,IAAI,CAAC,UAAU,EAAE,CAAC;QACtB,CAAC;IACL,CAAC;IAED,+FAA+F;IAC/F,OAAO;QACH,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC;YACpC,YAAY,CAAC,CAAC,CAAC,CAAC;QACpB,CAAC;QACD,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;QACrB,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;IACxB,CAAC;IAED,sGAAsG;IAC9F,IAAI,CAAC,EAAU,EAAE,IAAa;QAClC,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACjC,IAAI,CAAC,IAAI,EAAE,CAAC;YACR,OAAO;QACX,CAAC;QACD,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;QACrB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACvB,IAAI,CAAC;YACD,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QAC3B,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACT,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,wBAAyB,CAAW,CAAC,OAAO,EAAE,CAAC,CAAC;QACxE,CAAC;QACD,IAAI,IAAI,EAAE,CAAC;YACP,IAAI,CAAC,UAAU,EAAE,CAAC;QACtB,CAAC;IACL,CAAC;IAED,yFAAyF;IACjF,GAAG,CAAC,IAAe;QACvB,IAAI,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC;YACvB,OAAO;QACX,CAAC;QACD,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC1B,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;QACpD,MAAM,MAAM,GAAG,UAAU,CACrB,GAAG,EAAE;YACD,wEAAwE;YACxE,IAAI,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,EAAE,CAAC;gBAC3D,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;YACnB,CAAC;iBAAM,CAAC;gBACJ,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC;YAC7B,CAAC;QACL,CAAC,EACD,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,WAAW,CAAC,CAC/B,CAAC;QACD,MAAiC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,2CAA2C;QACzF,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;IACtC,CAAC;IAEO,WAAW,CAAC,EAAU;QAC1B,MAAM,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAC/B,IAAI,CAAC,EAAE,CAAC;YACJ,YAAY,CAAC,CAAC,CAAC,CAAC;YAChB,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QAC5B,CAAC;IACL,CAAC;IAEO,KAAK,CAAC,GAAW;QACrB,OAAO,IAAI,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAC;IAC9D,CAAC;IAEO,UAAU;QACd,IAAI,CAAC;YACD,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;QACpC,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACT,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,0BAA2B,CAAW,CAAC,OAAO,EAAE,CAAC,CAAC;QAC1E,CAAC;IACL,CAAC;CACJ;AA1LD,oCA0LC;AAED,gGAAgG;AAChG,SAAS,QAAQ,CAAC,CAAS,EAAE,GAAW,EAAE,GAAW,EAAE,IAAY;IAC/D,MAAM,GAAG,GAAG,CAAC,GAAG,EAAE,CAAC;IACnB,MAAM,IAAI,GAAG,CAAC,GAAG,GAAG,CAAC;IACrB,IAAI,GAAG,KAAK,CAAC,IAAI,IAAI,KAAK,EAAE,EAAE,CAAC;QAC3B,OAAO,GAAG,CAAC;IACf,CAAC;IACD,IAAI,GAAG,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,GAAG,EAAE,IAAI,IAAI,IAAI,EAAE,CAAC,EAAE,CAAC;QACpD,OAAO,GAAG,CAAC;IACf,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED;;;;GAIG;AACH,SAAgB,cAAc,CAAC,QAAgB,EAAE,IAAY;IACzD,MAAM,CAAC,GAAG,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;IAC7D,IAAI,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC;IAC5C,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,IAAI,CAAC,CAAC;IACjC,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC;IAChB,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,EAAE,CAAC,CAAC;IAC/B,MAAM,CAAC,GAAG,GAAG,GAAG,CAAC,GAAG,EAAE,CAAC;IAEvB,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,MAAM,GAAG,GAAG,CAAC,CAAS,EAAE,EAA4B,EAAE,EAAoB,EAAE,EAAoB,EAAQ,EAAE;QACtG,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;YACb,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,QAAQ,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QAC3D,CAAC;aAAM,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;YACpB,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QAClD,CAAC;aAAM,CAAC;YACJ,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QAClD,CAAC;IACL,CAAC,CAAC;IAEF,IAAI,CAAC,EAAE,CAAC;QACJ,GAAG,CAAC,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC/E,CAAC;IACD,IAAI,CAAC,EAAE,CAAC;QACJ,GAAG,CAAC,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC,CAAC;IACxF,CAAC;IACD,oGAAoG;IACpG,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC;QAC7B,GAAG,CAAC,CAAC,EAAE,CAAC,SAAS,EAAE,SAAS,EAAE,QAAQ,CAAC,EAAE,CAAC,SAAS,EAAE,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC,CAAC;IAC7F,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC3B,CAAC","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
|
|
186
|
-
*
|
|
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
|
|
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
|
|
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.
|
|
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 (
|
|
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
|
-
//
|
|
881
|
-
//
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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:
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
941
|
-
|
|
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
|
|
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 +
|
|
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);
|