iobroker.assistant 1.4.6 → 1.4.7
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 +13 -1
- package/admin/i18n/de.json +6 -1
- package/admin/i18n/en.json +6 -1
- package/admin/i18n/es.json +6 -1
- package/admin/i18n/fr.json +6 -1
- package/admin/i18n/it.json +6 -1
- package/admin/i18n/nl.json +6 -1
- package/admin/i18n/pl.json +6 -1
- package/admin/i18n/pt.json +6 -1
- package/admin/i18n/ru.json +6 -1
- package/admin/i18n/uk.json +6 -1
- package/admin/i18n/zh-cn.json +6 -1
- package/admin/jsonConfig.json +41 -0
- package/build/lib/ask.js +10 -0
- package/build/lib/ask.js.map +1 -1
- package/build/lib/journal.js.map +1 -1
- package/build/lib/llm.js +50 -12
- package/build/lib/llm.js.map +1 -1
- package/build/lib/media.js +226 -0
- package/build/lib/media.js.map +1 -0
- package/build/lib/piAgent.js +1 -0
- package/build/lib/piAgent.js.map +1 -1
- package/build/lib/voice/esphome.js +258 -5
- package/build/lib/voice/esphome.js.map +1 -1
- package/build/lib/voice/ffmpegStatic.js +137 -0
- package/build/lib/voice/ffmpegStatic.js.map +1 -0
- package/build/main.js +294 -31
- package/build/main.js.map +1 -1
- package/io-package.json +15 -14
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media.js","sourceRoot":"","sources":["../../src/lib/media.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Pictures and videos that come with a question (`ask` with `images` / `video`, e.g. from Telegram).\n *\n * Pictures go to the cloud model as they are (base64, the vendors take JPEG/PNG/GIF/WebP). Videos are not looked at\n * yet: the sender keeps them in the ioBroker file storage and only says where, so a video never travels through the\n * message bus as base64.\n */\n\n/** A picture for the model: base64 without a `data:` prefix and its type. */\nexport interface ImageInput {\n data: string;\n mimeType: string;\n}\n\n/** Where the sender stored a video (ioBroker file storage), with what it knows about it. */\nexport interface VideoRef {\n /** File storage namespace, e.g. `telegram.0` */\n adapter: string;\n /** Path in it, e.g. `assistant/video_2026-11-04_10-00-00.mp4` */\n path: string;\n mimeType?: string;\n durationSec?: number;\n size?: number;\n /** Set when the sender could not store it (e.g. too big to download) */\n error?: string;\n}\n\n/** At most this many pictures per question (an album of Telegram has up to 10) */\nexport const MAX_IMAGES = 4;\n\n/** Largest picture as base64 text: Anthropic refuses images over 5 MB */\nexport const MAX_IMAGE_BASE64 = 5 * 1024 * 1024;\n\nconst IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);\n\n/**\n * The type of a picture from its first bytes, `null` if it is none of the four the vendors take.\n *\n * @param buf the start of the file (16 bytes are enough)\n */\nexport function sniffImageType(buf: Buffer): string | null {\n if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {\n return 'image/jpeg';\n }\n if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {\n return 'image/png';\n }\n if (buf.length >= 6 && /^GIF8[79]a$/.test(buf.subarray(0, 6).toString('latin1'))) {\n return 'image/gif';\n }\n if (buf.length >= 12 && buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') {\n return 'image/webp';\n }\n return null;\n}\n\n/**\n * Check the `images` of an `ask`: base64 of JPEG/PNG/GIF/WebP, at most {@link MAX_IMAGES} and\n * {@link MAX_IMAGE_BASE64} each. The type is taken from the content, not from what the sender says.\n *\n * @param raw the `images` field: a list of `{ data, mimeType? }` (one object or a bare base64 string work too)\n * @returns the pictures, or the reason they are refused\n */\nexport function parseAskImages(raw: unknown): { images: ImageInput[]; error?: string } {\n if (raw === undefined || raw === null || raw === '') {\n return { images: [] };\n }\n const list = Array.isArray(raw) ? raw : [raw];\n if (list.length > MAX_IMAGES) {\n return { images: [], error: `too many pictures (${list.length}, at most ${MAX_IMAGES})` };\n }\n const images: ImageInput[] = [];\n for (const item of list) {\n const data = typeof item === 'string' ? item : (item as { data?: unknown } | null)?.data;\n if (typeof data !== 'string' || !data) {\n return { images: [], error: 'a picture without data' };\n }\n // a data URL is fine as well\n const base64 = data.replace(/^data:[^;,]*;base64,/, '').replace(/\\s+/g, '');\n if (base64.length > MAX_IMAGE_BASE64) {\n return {\n images: [],\n error: `picture too big (${Math.round((base64.length * 3) / 4 / 1024)} KB, at most ${(MAX_IMAGE_BASE64 * 3) / 4 / 1024} KB)`,\n };\n }\n const mimeType = sniffImageType(Buffer.from(base64.slice(0, 32), 'base64'));\n if (!mimeType || !IMAGE_TYPES.has(mimeType)) {\n return { images: [], error: 'not a JPEG, PNG, GIF or WebP picture' };\n }\n images.push({ data: base64, mimeType });\n }\n return { images };\n}\n\n/**\n * Check the `video` of an `ask`: only a reference into the ioBroker file storage, never the content.\n *\n * @param raw the `video` field\n * @returns the reference, `null` without one, or the reason it is refused (e.g. base64 content)\n */\nexport function parseAskVideo(raw: unknown): VideoRef | null | { error: string } {\n if (raw === undefined || raw === null || raw === '') {\n return null;\n }\n if (typeof raw !== 'object' || Array.isArray(raw)) {\n return { error: 'video must be { adapter, path } of the ioBroker file storage' };\n }\n const v = raw as Record<string, unknown>;\n if ('data' in v) {\n return { error: 'a video is not sent as content - store it and send { adapter, path }' };\n }\n const str = (x: unknown): string => (typeof x === 'string' ? x.trim() : '');\n const num = (x: unknown): number | undefined =>\n typeof x === 'number' && Number.isFinite(x) && x >= 0 ? x : undefined;\n const ref: VideoRef = {\n adapter: str(v.adapter),\n path: str(v.path).replace(/^\\/+/, ''),\n mimeType: str(v.mimeType) || undefined,\n durationSec: num(v.durationSec),\n size: num(v.size),\n error: str(v.error) || undefined,\n };\n if ((!ref.adapter || !ref.path) && !ref.error) {\n return { error: 'video without adapter and path' };\n }\n return ref;\n}\n\n/** Whether {@link parseAskVideo} refused the video */\nexport function isVideoError(v: VideoRef | null | { error: string }): v is { error: string } {\n return !!v && !('adapter' in v);\n}\n\n/**\n * Copy of a request / message list with the pictures replaced by a short note — for the trace and the debug log,\n * where hundreds of KB of base64 per round help nobody. Understands the OpenAI (`image_url` with a data URL) and the\n * Anthropic form (`image` with a base64 `source`).\n *\n * @param value what to copy\n */\nexport function redactImages<T>(value: T): T {\n const walk = (v: unknown): unknown => {\n if (Array.isArray(v)) {\n return v.map(walk);\n }\n if (!v || typeof v !== 'object') {\n return v;\n }\n const o = v as Record<string, any>;\n if (o.type === 'image_url' && typeof o.image_url?.url === 'string' && o.image_url.url.startsWith('data:')) {\n return { ...o, image_url: { ...o.image_url, url: imageNote(o.image_url.url) } };\n }\n if (o.type === 'image' && o.source?.type === 'base64' && typeof o.source.data === 'string') {\n return { ...o, source: { ...o.source, data: imageNote(o.source.data, o.source.media_type) } };\n }\n const out: Record<string, unknown> = {};\n for (const [k, x] of Object.entries(o)) {\n out[k] = walk(x);\n }\n return out;\n };\n return walk(value) as T;\n}\n\nfunction imageNote(data: string, mimeType?: string): string {\n const m = /^data:([^;,]+)/.exec(data);\n const base64 = data.replace(/^data:[^,]*,/, '');\n return `<${mimeType || m?.[1] || 'image'}, ${Math.round((base64.length * 3) / 4 / 1024)} KB>`;\n}\n\n/** Language of the texts below: de, ru, else en */\ntype Lang = string;\n\n/**\n * The question for pictures that came without one (a photo without caption).\n *\n * @param lang language of the answer\n * @param count number of pictures\n */\nexport function imageOnlyQuestion(lang: Lang, count = 1): string {\n if (lang === 'ru') {\n return count > 1 ? 'Что на этих картинках?' : 'Что на этой картинке?';\n }\n if (lang === 'de') {\n return count > 1 ? 'Was ist auf diesen Bildern?' : 'Was ist auf diesem Bild?';\n }\n return count > 1 ? 'What is in these pictures?' : 'What is in this picture?';\n}\n\n/**\n * The answer to a video that came without a question.\n *\n * @param lang language of the answer\n */\nexport function videoNotSupportedText(lang: Lang): string {\n if (lang === 'ru') {\n return 'Видео я пока смотреть не умею.';\n }\n if (lang === 'de') {\n return 'Videos kann ich mir noch nicht ansehen.';\n }\n return \"I can't watch videos yet.\";\n}\n\n/**\n * The answer to pictures when there is no cloud model to look at them.\n *\n * @param lang language of the answer\n */\nexport function noVisionText(lang: Lang): string {\n if (lang === 'ru') {\n return 'Картинки я могу посмотреть только с облачной моделью.';\n }\n if (lang === 'de') {\n return 'Bilder kann ich mir nur mit einem Cloud-Modell ansehen.';\n }\n return 'I can only look at pictures with a cloud model.';\n}\n\n/**\n * Note for the model in the user turn: what came along besides the text. A model that does not get the pictures\n * (an older pi-agent) at least knows that there were some.\n *\n * @param images number of pictures attached\n * @param video whether a video came along\n */\nexport function attachmentNote(images: number, video: boolean): string {\n const parts: string[] = [];\n if (images) {\n parts.push(images > 1 ? `${images} pictures are attached.` : 'A picture is attached.');\n }\n if (video) {\n parts.push('The user also sent a video, which you cannot watch yet - say so if the question is about it.');\n }\n return parts.length ? `(${parts.join(' ')})` : '';\n}\n\n/**\n * How the question is kept in the conversation history: the pictures are not repeated in later turns, a marker says\n * there were some.\n *\n * @param question the question\n * @param images number of pictures\n * @param video whether a video came along\n */\nexport function historyText(question: string, images: number, video: boolean): string {\n const marks = [images ? `[${images > 1 ? `${images} pictures` : 'picture'}]` : '', video ? '[video]' : ''];\n return [...marks, question].filter(Boolean).join(' ');\n}\n"]}
|
package/build/lib/piAgent.js
CHANGED
|
@@ -77,6 +77,7 @@ class PiAgentHarness {
|
|
|
77
77
|
conversation: conversationOf(req.conversation),
|
|
78
78
|
instructions: req.instructions,
|
|
79
79
|
...(req.language ? { language: req.language } : {}),
|
|
80
|
+
...(req.images?.length ? { images: req.images } : {}),
|
|
80
81
|
requestId,
|
|
81
82
|
}, { timeout: this.timeoutMs }));
|
|
82
83
|
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\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"]}
|
|
@@ -34,6 +34,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.EsphomeSatellites = exports.TIMER_EVENT = exports.DEFAULT_SPEECH_LEVEL = void 0;
|
|
37
|
+
exports.yesNoWord = yesNoWord;
|
|
38
|
+
exports.confirmAnswerOf = confirmAnswerOf;
|
|
39
|
+
exports.displayLanguageOption = displayLanguageOption;
|
|
37
40
|
exports.speechLevelOf = speechLevelOf;
|
|
38
41
|
exports.esphomeAddress = esphomeAddress;
|
|
39
42
|
/**
|
|
@@ -170,6 +173,106 @@ const NO_TEXT_ERROR = { code: 'stt-no-text-recognized', message: 'No text recogn
|
|
|
170
173
|
* that was too quiet, not an empty room — worth telling the user where to lower the threshold.
|
|
171
174
|
*/
|
|
172
175
|
const QUIET_VOICE_SHARE = 0.4;
|
|
176
|
+
/**
|
|
177
|
+
* Event entity of a satellite with a screen: the user answered the open question by touch, event type `yes` or `no`.
|
|
178
|
+
* Pressing it is the same as saying "yes"/"no" into the open microphone — no speech recognition to get it wrong
|
|
179
|
+
* (a German "Ja" came back as "Я" once).
|
|
180
|
+
*/
|
|
181
|
+
const CONFIRM_ENTITY = 'confirm_answer';
|
|
182
|
+
/** Select entity of a satellite with a screen: the language of the date and the hints it shows. */
|
|
183
|
+
const DISPLAY_LANGUAGE_ENTITY = 'display_language';
|
|
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.
|
|
187
|
+
*/
|
|
188
|
+
const FOLLOW_UP_START_MS = 10_000;
|
|
189
|
+
/**
|
|
190
|
+
* A button press still answers the question this long after the follow-up microphone closed: the firmware may stop
|
|
191
|
+
* the microphone on the press before it reports which button it was.
|
|
192
|
+
*/
|
|
193
|
+
const CONFIRM_GRACE_MS = 3000;
|
|
194
|
+
/**
|
|
195
|
+
* After a button answer: a follow-up microphone the device opens this soon was asked for by the question the button
|
|
196
|
+
* already answered, and is closed at once (unless our reply asked something again).
|
|
197
|
+
*/
|
|
198
|
+
const IGNORE_FOLLOW_UP_MS = 15_000;
|
|
199
|
+
/** "Yes" and "no" as a button answer is passed on: the words of the satellite's language, which pi-agent also takes. */
|
|
200
|
+
const YES_NO = {
|
|
201
|
+
en: ['Yes', 'No'],
|
|
202
|
+
de: ['Ja', 'Nein'],
|
|
203
|
+
ru: ['Да', 'Нет'],
|
|
204
|
+
pt: ['Sim', 'Não'],
|
|
205
|
+
nl: ['Ja', 'Nee'],
|
|
206
|
+
fr: ['Oui', 'Non'],
|
|
207
|
+
it: ['Sì', 'No'],
|
|
208
|
+
es: ['Sí', 'No'],
|
|
209
|
+
pl: ['Tak', 'Nie'],
|
|
210
|
+
uk: ['Так', 'Ні'],
|
|
211
|
+
zh: ['是', '不'],
|
|
212
|
+
};
|
|
213
|
+
/** The word a yes/no button stands for in `language` (ISO 639-1, `zh-cn` too); English for an unknown one. */
|
|
214
|
+
function yesNoWord(language, yes) {
|
|
215
|
+
const base = (language || 'en').trim().toLowerCase().split(/[-_]/)[0];
|
|
216
|
+
return (YES_NO[base] ?? YES_NO.en)[yes ? 0 : 1];
|
|
217
|
+
}
|
|
218
|
+
/** The event type of a {@link CONFIRM_ENTITY} as yes (true) or no (false); null for anything else. */
|
|
219
|
+
function confirmAnswerOf(eventType) {
|
|
220
|
+
const type = typeof eventType === 'string' ? eventType.trim().toLowerCase() : '';
|
|
221
|
+
if (type === 'yes' || type === 'confirm') {
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
if (type === 'no' || type === 'cancel') {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Names of the adapter's languages in that language, for a display-language select. `Intl.DisplayNames` covers the
|
|
231
|
+
* rest, but a Node.js built with only English ICU data knows no "Deutsch" or "Русский".
|
|
232
|
+
*/
|
|
233
|
+
const NATIVE_LANGUAGE_NAMES = {
|
|
234
|
+
en: 'english',
|
|
235
|
+
de: 'deutsch',
|
|
236
|
+
ru: 'русский',
|
|
237
|
+
pt: 'português',
|
|
238
|
+
nl: 'nederlands',
|
|
239
|
+
fr: 'français',
|
|
240
|
+
it: 'italiano',
|
|
241
|
+
es: 'español',
|
|
242
|
+
pl: 'polski',
|
|
243
|
+
uk: 'українська',
|
|
244
|
+
zh: '中文',
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* The option of a display-language select that means `language`: its name in that language ("Deutsch"), in English
|
|
248
|
+
* ("German") or its code ("de", "de-DE"), case ignored. Null when the device offers none of them.
|
|
249
|
+
*/
|
|
250
|
+
function displayLanguageOption(options, language) {
|
|
251
|
+
const code = (language || '').trim().toLowerCase();
|
|
252
|
+
const base = code.split(/[-_]/)[0];
|
|
253
|
+
if (!base) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
const names = new Set([code, base]);
|
|
257
|
+
if (NATIVE_LANGUAGE_NAMES[base]) {
|
|
258
|
+
names.add(NATIVE_LANGUAGE_NAMES[base]);
|
|
259
|
+
}
|
|
260
|
+
for (const locale of [base, 'en']) {
|
|
261
|
+
try {
|
|
262
|
+
const name = new Intl.DisplayNames([locale], { type: 'language' }).of(base);
|
|
263
|
+
if (name) {
|
|
264
|
+
names.add(name.toLocaleLowerCase(locale));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
/* not a language code Intl knows */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const norm = (option) => option.trim().toLocaleLowerCase();
|
|
272
|
+
return (options.find(o => names.has(norm(o))) ??
|
|
273
|
+
options.find(o => norm(o).split(/[-_]/)[0] === base && /^[a-z]{2,3}([-_][a-z0-9]+)?$/.test(norm(o))) ??
|
|
274
|
+
null);
|
|
275
|
+
}
|
|
173
276
|
/** The configured speech threshold of a device, or 0 for the default. Table cells may arrive as strings. */
|
|
174
277
|
function speechLevelOf(device) {
|
|
175
278
|
const level = Math.round(Number(device.speechLevel));
|
|
@@ -325,6 +428,15 @@ class EsphomeConnection {
|
|
|
325
428
|
errorSentAt = 0;
|
|
326
429
|
/** The reply format last reported in the log, to log it again only when it changes. */
|
|
327
430
|
loggedFormat = '';
|
|
431
|
+
/**
|
|
432
|
+
* 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.
|
|
434
|
+
*/
|
|
435
|
+
questionUntil = 0;
|
|
436
|
+
/** After a button answer: until when a follow-up microphone is the stale one of the answered question. */
|
|
437
|
+
ignoreFollowUpUntil = 0;
|
|
438
|
+
/** The display language was compared with ours since the last connect. */
|
|
439
|
+
displayLanguageChecked = false;
|
|
328
440
|
constructor(device, opts) {
|
|
329
441
|
this.opts = opts;
|
|
330
442
|
this.host = device.ip.trim();
|
|
@@ -367,6 +479,7 @@ class EsphomeConnection {
|
|
|
367
479
|
// reboots, so starting from a clean slate is the only safe option.
|
|
368
480
|
this.entities.clear();
|
|
369
481
|
this.configChecks = [];
|
|
482
|
+
this.displayLanguageChecked = false;
|
|
370
483
|
const socket = net.createConnection({ host: this.host, port: this.port }, () => {
|
|
371
484
|
this.opts.log.debug(`ESPHome ${this.host}:${this.port} connected`);
|
|
372
485
|
this.handshake();
|
|
@@ -391,6 +504,8 @@ class EsphomeConnection {
|
|
|
391
504
|
// A reply still being worked out must not land on the next connection, in the middle of another run.
|
|
392
505
|
this.run++;
|
|
393
506
|
this.runOpen = false;
|
|
507
|
+
this.questionUntil = 0;
|
|
508
|
+
this.ignoreFollowUpUntil = 0;
|
|
394
509
|
if (this.online) {
|
|
395
510
|
this.opts.log.info(`ESPHome satellite offline: ${this.deviceName}`);
|
|
396
511
|
}
|
|
@@ -599,11 +714,110 @@ class EsphomeConnection {
|
|
|
599
714
|
const entity = this.entities.applyState(name, message);
|
|
600
715
|
if (entity) {
|
|
601
716
|
this.opts.onEntityState?.(this.deviceName, this.room, entity);
|
|
717
|
+
this.onEntityChange(entity);
|
|
602
718
|
}
|
|
603
719
|
}
|
|
604
720
|
return;
|
|
605
721
|
}
|
|
606
722
|
}
|
|
723
|
+
/** Entities of a satellite with a screen that take part in the conversation (see {@link CONFIRM_ENTITY}). */
|
|
724
|
+
onEntityChange(entity) {
|
|
725
|
+
if (entity.objectId === CONFIRM_ENTITY && entity.kind === 'event') {
|
|
726
|
+
this.onConfirmButton(entity.value);
|
|
727
|
+
}
|
|
728
|
+
else if (entity.objectId === DISPLAY_LANGUAGE_ENTITY && entity.kind === 'select') {
|
|
729
|
+
// Once per connect, with the state the device reports on subscribing: a language the user picks on the
|
|
730
|
+
// device afterwards stays until the next connect (a change of the voice language restarts the adapter).
|
|
731
|
+
if (!this.displayLanguageChecked) {
|
|
732
|
+
this.displayLanguageChecked = true;
|
|
733
|
+
this.syncDisplayLanguage(entity);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/** Show the device's screen in the voice language, if it offers that language. */
|
|
738
|
+
syncDisplayLanguage(entity) {
|
|
739
|
+
const language = this.opts.language;
|
|
740
|
+
if (!language) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const option = displayLanguageOption(entity.options ?? [], language);
|
|
744
|
+
if (!option) {
|
|
745
|
+
this.opts.log.debug(`ESPHome ${this.deviceName}: ${DISPLAY_LANGUAGE_ENTITY} has no option for "${language}" ` +
|
|
746
|
+
`(${(entity.options ?? []).join(', ')})`);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (entity.value === option) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const shown = typeof entity.value === 'string' ? entity.value : '?';
|
|
753
|
+
this.opts.log.info(`ESPHome ${this.deviceName}: display language ${shown} → ${option}`);
|
|
754
|
+
this.setEntity(DISPLAY_LANGUAGE_ENTITY, option);
|
|
755
|
+
}
|
|
756
|
+
/** Whether a yes/no button press now answers a question the last reply asked. */
|
|
757
|
+
questionOpen() {
|
|
758
|
+
return !!this.capture?.answersQuestion || Date.now() < this.questionUntil;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* The user pressed yes or no on the device's screen. While a reply waits for an answer that is the answer: the
|
|
762
|
+
* microphone opened for it is closed, the yes or no word goes on as the user's reply (pi-agent decides its
|
|
763
|
+
* confirmation on it like on a spoken one), and the reply to that is announced on this device.
|
|
764
|
+
*/
|
|
765
|
+
onConfirmButton(eventType) {
|
|
766
|
+
const yes = confirmAnswerOf(eventType);
|
|
767
|
+
if (yes === null) {
|
|
768
|
+
this.opts.log.debug(`ESPHome ${this.deviceName}: ${CONFIRM_ENTITY} "${String(eventType)}" — not yes/no, ignored`);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (!this.questionOpen()) {
|
|
772
|
+
this.opts.log.debug(`ESPHome ${this.deviceName}: ${CONFIRM_ENTITY} "${yes ? 'yes' : 'no'}" without an open question — ignored`);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
this.questionUntil = 0;
|
|
776
|
+
if (this.capture) {
|
|
777
|
+
// A RUN_END while the microphone streams stops it and puts the device back to idle — without the error
|
|
778
|
+
// display an empty recognition would end with.
|
|
779
|
+
this.abortCapture();
|
|
780
|
+
this.event(EVENT.RUN_END);
|
|
781
|
+
}
|
|
782
|
+
// Whatever is still in flight for the question stays silent; so does the follow-up microphone the device may
|
|
783
|
+
// still open for it.
|
|
784
|
+
const run = ++this.run;
|
|
785
|
+
this.ignoreFollowUpUntil = Date.now() + IGNORE_FOLLOW_UP_MS;
|
|
786
|
+
void this.answerButton(yes, run);
|
|
787
|
+
}
|
|
788
|
+
/** Pass a button answer on and announce the reply (see {@link onConfirmButton}). */
|
|
789
|
+
async answerButton(yes, run) {
|
|
790
|
+
const text = yesNoWord(this.opts.language, yes);
|
|
791
|
+
this.opts.log.info(`ESPHome ${this.deviceName}: answered "${text}" with the button`);
|
|
792
|
+
this.status('processing');
|
|
793
|
+
try {
|
|
794
|
+
const answer = (await this.opts.answer(text, { device: this.deviceName, room: this.room, button: yes ? 'yes' : 'no' })).trim();
|
|
795
|
+
if (run !== this.run) {
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (!answer) {
|
|
799
|
+
this.status('idle');
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
const followUp = this.opts.expectsFollowUp?.(answer, this.deviceName) === true;
|
|
803
|
+
const ttsStart = Date.now();
|
|
804
|
+
const { pcm, sampleRate } = await this.opts.tts.synthesize(answer, this.opts.language);
|
|
805
|
+
if (run !== this.run) {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
this.opts.onSpoken?.(this.deviceName, {
|
|
809
|
+
ms: Date.now() - ttsStart,
|
|
810
|
+
audioMs: (pcm.length / 2 / sampleRate) * 1000,
|
|
811
|
+
});
|
|
812
|
+
await this.announce(pcm, sampleRate, followUp, answer);
|
|
813
|
+
}
|
|
814
|
+
catch (e) {
|
|
815
|
+
this.opts.log.warn(`ESPHome ${this.deviceName}: answer to the button failed — ${e.message}`);
|
|
816
|
+
if (run === this.run) {
|
|
817
|
+
this.status('idle');
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
607
821
|
/** Write one of the device's entities. See {@link EsphomeSatellites.setEntity}. */
|
|
608
822
|
setEntity(objectId, value) {
|
|
609
823
|
const message = this.entities.command(objectId, value);
|
|
@@ -644,13 +858,27 @@ class EsphomeConnection {
|
|
|
644
858
|
this.opts.log.debug(`ESPHome ${this.deviceName}: pipeline already running — ignoring wake`);
|
|
645
859
|
return;
|
|
646
860
|
}
|
|
861
|
+
const phrase = request.getWakeWordPhrase();
|
|
862
|
+
if (!phrase && Date.now() < this.ignoreFollowUpUntil) {
|
|
863
|
+
// The microphone for the question a button already answered: close it at once. In this state the device
|
|
864
|
+
// takes a RUN_END as "stop the microphone" and goes back to idle.
|
|
865
|
+
this.ignoreFollowUpUntil = 0;
|
|
866
|
+
this.opts.log.debug(`ESPHome ${this.deviceName}: follow-up of a question answered by button — closing it`);
|
|
867
|
+
this.event(EVENT.RUN_END);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
647
870
|
if (this.runOpen) {
|
|
648
871
|
// The device started over while the last request was still being answered: that answer is not
|
|
649
872
|
// wanted any more, and its events would land in the middle of the new run.
|
|
650
873
|
this.opts.log.debug(`ESPHome ${this.deviceName}: new wake while answering — dropping the old reply`);
|
|
651
874
|
}
|
|
652
875
|
this.run++;
|
|
653
|
-
|
|
876
|
+
// Without a wake word the device re-opened the mic for the reply that asked something; with one, the user
|
|
877
|
+
// started over and the question is gone.
|
|
878
|
+
const answersQuestion = !phrase && Date.now() < this.questionUntil;
|
|
879
|
+
if (phrase) {
|
|
880
|
+
this.questionUntil = 0;
|
|
881
|
+
}
|
|
654
882
|
this.opts.log.debug(`ESPHome ${this.deviceName}: wake "${phrase || '(follow-up)'}"`);
|
|
655
883
|
const response = new esphomeProto_1.pb.VoiceAssistantResponse();
|
|
656
884
|
response.setPort(0); // 0 = stream the audio over this socket
|
|
@@ -682,6 +910,7 @@ class EsphomeConnection {
|
|
|
682
910
|
}),
|
|
683
911
|
conversationId: request.getConversationId(),
|
|
684
912
|
wakeWord: phrase || '',
|
|
913
|
+
answersQuestion,
|
|
685
914
|
timer,
|
|
686
915
|
stallCheck,
|
|
687
916
|
startedAt: Date.now(),
|
|
@@ -704,6 +933,8 @@ class EsphomeConnection {
|
|
|
704
933
|
*/
|
|
705
934
|
onStopRequest() {
|
|
706
935
|
const running = !!this.capture || this.runOpen;
|
|
936
|
+
// A device with yes/no buttons may stop its microphone on the press before it reports which button it was.
|
|
937
|
+
this.questionUntil = this.questionOpen() ? Date.now() + CONFIRM_GRACE_MS : 0;
|
|
707
938
|
this.abortCapture();
|
|
708
939
|
if (running) {
|
|
709
940
|
this.run++; // whatever is still being answered stays silent
|
|
@@ -803,6 +1034,11 @@ class EsphomeConnection {
|
|
|
803
1034
|
this.opts.log.debug(`ESPHome ${this.deviceName}: captured ${seconds.toFixed(2)} s ` +
|
|
804
1035
|
`(peak RMS ${vad.peak}, noise floor ${vad.noiseFloor}, speech/silence gate ${vad.startLevel}/${vad.endLevel}` +
|
|
805
1036
|
`${startedAt}, ${endedBy})`);
|
|
1037
|
+
if (capture.answersQuestion) {
|
|
1038
|
+
// Spoken: that is the answer, a button press now would be a second one. Silent: the buttons still answer
|
|
1039
|
+
// for a moment, as the screen may still show them.
|
|
1040
|
+
this.questionUntil = capture.detector.sawSpeech ? 0 : Date.now() + CONFIRM_GRACE_MS;
|
|
1041
|
+
}
|
|
806
1042
|
if (!pcm.length || !capture.detector.sawSpeech) {
|
|
807
1043
|
const loudest = Math.round(capture.detector.analysedPeak);
|
|
808
1044
|
const gate = Math.round(capture.detector.startLevel);
|
|
@@ -846,6 +1082,9 @@ class EsphomeConnection {
|
|
|
846
1082
|
// language than the STT listens in (a German "Ja" to a Russian STT comes back empty).
|
|
847
1083
|
this.opts.log.info(`Voice Q (${this.deviceName}): (empty) — speech recognition returned no text for ${seconds.toFixed(1)} s of audio`);
|
|
848
1084
|
this.discard(capture, pcm, 'empty-stt', vad);
|
|
1085
|
+
if (capture.answersQuestion) {
|
|
1086
|
+
this.questionUntil = Date.now() + CONFIRM_GRACE_MS;
|
|
1087
|
+
}
|
|
849
1088
|
// No STT_END without text — like Home Assistant, which reports this case as an error only.
|
|
850
1089
|
this.endWithoutRequest();
|
|
851
1090
|
return;
|
|
@@ -865,7 +1104,11 @@ class EsphomeConnection {
|
|
|
865
1104
|
return;
|
|
866
1105
|
}
|
|
867
1106
|
const followUp = !!answer && this.opts.expectsFollowUp?.(answer, this.deviceName) === true;
|
|
868
|
-
|
|
1107
|
+
// Always say it, "0" included: ESPHome keeps the flag of the last INTENT_END that carried it, so after
|
|
1108
|
+
// one question every later reply re-opened the microphone (field report 2026-11-04). Home Assistant
|
|
1109
|
+
// sends "0" just the same.
|
|
1110
|
+
const conversation = { continue_conversation: followUp ? '1' : '0' };
|
|
1111
|
+
this.event(EVENT.INTENT_END, conversation);
|
|
869
1112
|
if (!answer) {
|
|
870
1113
|
this.event(EVENT.RUN_END);
|
|
871
1114
|
this.status('idle');
|
|
@@ -887,7 +1130,11 @@ class EsphomeConnection {
|
|
|
887
1130
|
this.status('speaking');
|
|
888
1131
|
this.event(EVENT.TTS_START, { text: answer });
|
|
889
1132
|
this.event(EVENT.TTS_END, { url });
|
|
890
|
-
|
|
1133
|
+
// linux-voice-assistant reads the flag here (ignoring "0"), ESPHome at INTENT_END
|
|
1134
|
+
this.event(EVENT.RUN_END, conversation);
|
|
1135
|
+
this.questionUntil = followUp
|
|
1136
|
+
? Date.now() + (ttsPcm.length / 2 / sampleRate) * 1000 + FOLLOW_UP_START_MS
|
|
1137
|
+
: 0;
|
|
891
1138
|
this.armPlaybackTimeout();
|
|
892
1139
|
}
|
|
893
1140
|
catch (e) {
|
|
@@ -989,17 +1236,23 @@ class EsphomeConnection {
|
|
|
989
1236
|
* Speak a ready PCM buffer on this device (timers, alarms, `tts.text`, jingles). With
|
|
990
1237
|
* `startConversation` the microphone opens once the clip has played, to catch the answer to a question.
|
|
991
1238
|
*/
|
|
992
|
-
async announce(pcm, sampleRate, startConversation = false) {
|
|
1239
|
+
async announce(pcm, sampleRate, startConversation = false, text = '') {
|
|
993
1240
|
if (!this.online || !pcm.length) {
|
|
994
1241
|
return;
|
|
995
1242
|
}
|
|
996
1243
|
const request = new esphomeProto_1.pb.VoiceAssistantAnnounceRequest();
|
|
997
1244
|
request.setMediaId(await this.publish(pcm, sampleRate));
|
|
998
|
-
|
|
1245
|
+
// What a device with a screen shows while it plays (its on_tts_start)
|
|
1246
|
+
request.setText(text);
|
|
999
1247
|
request.setPreannounceMediaId('');
|
|
1000
1248
|
// Needs feature flag 32 (START_CONVERSATION); on a device without it the clip still plays and
|
|
1001
1249
|
// only the microphone stays shut, so there is nothing to guard here.
|
|
1002
1250
|
request.setStartConversation(startConversation);
|
|
1251
|
+
if (startConversation) {
|
|
1252
|
+
// A question: the microphone the device opens after it is wanted, and a button press answers it
|
|
1253
|
+
this.ignoreFollowUpUntil = 0;
|
|
1254
|
+
this.questionUntil = Date.now() + (pcm.length / 2 / sampleRate) * 1000 + FOLLOW_UP_START_MS;
|
|
1255
|
+
}
|
|
1003
1256
|
this.status('speaking');
|
|
1004
1257
|
this.send(request);
|
|
1005
1258
|
this.armPlaybackTimeout();
|