iobroker.assistant 0.1.6 → 1.0.2
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 +61 -4
- package/admin/custom/.vite/manifest.json +122 -77
- package/admin/custom/assets/Components-7BDDNrGP.js +1 -0
- package/admin/custom/assets/_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-DcAGnE2z.js +1 -0
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-CzBMxGFr.js +1 -0
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-1XsLCK2M.js +1 -0
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-CMv8bcyA.js +1217 -0
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare__shared-3mzaJWDF.js +2 -0
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__prebuild___mf_0_iobroker_mf_1_json_mf_2_config__prebuild__-DS8oaQQt.js +122 -0
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__prebuild__react_mf_1_jsx_mf_2_runtime__prebuild__-B_v57Qfr.js +1 -0
- package/admin/custom/assets/dist-DiU1y0R9.js +1 -0
- package/admin/custom/assets/helpers-Dot7vgQQ.js +1 -0
- package/admin/custom/assets/hostInit-MshDicd1.js +1 -0
- package/admin/custom/assets/iconLibraryData-B5fiyl1C.js +1 -0
- package/admin/custom/assets/index-P2Xu9kJm.js +1 -0
- package/admin/custom/assets/lib-DjwHKtRi.js +3 -0
- package/admin/custom/assets/{mf-entry-bootstrap-0-87f15505.js → mf-entry-bootstrap-0-7c1ddf47.js} +14 -2
- package/admin/custom/assets/pendingShares-CaJDCKLB.js +2 -0
- package/admin/custom/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
- package/admin/custom/assets/utils-towTPKqG.js +4 -0
- package/admin/custom/assets/vite-preload-helper-BbbETgeG.js +1 -0
- package/admin/custom/customComponents.js +2 -2
- package/admin/custom/i18n/de.json +64 -57
- package/admin/custom/i18n/en.json +8 -1
- package/admin/custom/i18n/es.json +64 -57
- package/admin/custom/i18n/fr.json +64 -57
- package/admin/custom/i18n/it.json +64 -57
- package/admin/custom/i18n/nl.json +64 -57
- package/admin/custom/i18n/pl.json +64 -57
- package/admin/custom/i18n/pt.json +64 -57
- package/admin/custom/i18n/ru.json +64 -57
- package/admin/custom/i18n/uk.json +64 -57
- package/admin/custom/i18n/zh-cn.json +64 -57
- package/admin/custom/index.html +2 -2
- package/admin/custom/mf-manifest.json +1 -1
- package/admin/custom/mf-stats.json +1 -1
- package/admin/i18n/de.json +59 -3
- package/admin/i18n/en.json +59 -3
- package/admin/i18n/es.json +59 -3
- package/admin/i18n/fr.json +59 -3
- package/admin/i18n/it.json +59 -3
- package/admin/i18n/nl.json +59 -3
- package/admin/i18n/pl.json +59 -3
- package/admin/i18n/pt.json +59 -3
- package/admin/i18n/ru.json +59 -3
- package/admin/i18n/uk.json +59 -3
- package/admin/i18n/zh-cn.json +59 -3
- package/admin/jsonConfig.json +589 -75
- package/build/lib/ask.js +130 -0
- package/build/lib/ask.js.map +1 -0
- package/build/lib/llm.js +135 -13
- package/build/lib/llm.js.map +1 -1
- package/build/lib/nlu.js +74 -1
- package/build/lib/nlu.js.map +1 -1
- package/build/lib/notifications.js +112 -0
- package/build/lib/notifications.js.map +1 -0
- package/build/lib/presence.js +188 -0
- package/build/lib/presence.js.map +1 -0
- package/build/lib/routines.js +115 -0
- package/build/lib/routines.js.map +1 -0
- package/build/lib/targets.js +96 -0
- package/build/lib/targets.js.map +1 -0
- package/build/lib/triggers.js +632 -0
- package/build/lib/triggers.js.map +1 -0
- package/build/lib/voice/aws.js +9 -1
- package/build/lib/voice/aws.js.map +1 -1
- package/build/lib/voice/azure.js +9 -2
- package/build/lib/voice/azure.js.map +1 -1
- package/build/lib/voice/engines.js +82 -0
- package/build/lib/voice/engines.js.map +1 -1
- package/build/lib/voice/esphome.js +732 -0
- package/build/lib/voice/esphome.js.map +1 -0
- package/build/lib/voice/esphomeEntities.js +303 -0
- package/build/lib/voice/esphomeEntities.js.map +1 -0
- package/build/lib/voice/esphomeProto.js +181 -0
- package/build/lib/voice/esphomeProto.js.map +1 -0
- package/build/lib/voice/fallback.js +85 -0
- package/build/lib/voice/fallback.js.map +1 -0
- package/build/lib/voice/mediaServer.js +151 -0
- package/build/lib/voice/mediaServer.js.map +1 -0
- package/build/lib/voice/protocol.js.map +1 -1
- package/build/lib/voice/tone.js +32 -0
- package/build/lib/voice/tone.js.map +1 -0
- package/build/lib/voice/tts.js.map +1 -1
- package/build/lib/voice/ttsCache.js +274 -0
- package/build/lib/voice/ttsCache.js.map +1 -0
- package/build/lib/voice/vad.js +173 -0
- package/build/lib/voice/vad.js.map +1 -0
- package/build/lib/voice/voiceServer.js +13 -0
- package/build/lib/voice/voiceServer.js.map +1 -1
- package/build/main.js +1721 -67
- package/build/main.js.map +1 -1
- package/io-package.json +241 -3
- package/package.json +13 -12
- package/admin/custom/assets/Components-xWdtqvTH.js +0 -1
- package/admin/custom/assets/_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-dybYZgsu.js +0 -1
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-BnSUsPHa.js +0 -1
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-DlSVT49A.js +0 -1
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-Cfsgderv.js +0 -1243
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_iobroker_mf_1_json_mf_2_config__loadShare__.js-ByJf4zv_.js +0 -1494
- package/admin/custom/assets/hostInit-CZawMH9B.js +0 -1
- package/admin/custom/assets/index-JB7g1jgx.js +0 -1
- package/admin/custom/assets/lib-bB-LHRhZ.js +0 -3
- package/admin/custom/assets/rolldown-runtime-C0FnF6B9.js +0 -1
- package/admin/custom/assets/virtualExposes-brKKZvi0.js +0 -1
- package/admin/custom/assets/virtual_mf-REMOTE_ENTRY_ID___mfe_internal__ConfigCustomAssistant__customComponents_js-CRhwj12f.js +0 -1
- package/admin/custom/assets/virtual_mf-exposes___mfe_internal__ConfigCustomAssistant__customComponents_js-C5XHUc1J.js +0 -2
- package/admin/custom/assets/vite-preload-helper-B7qeedMF.js +0 -1
package/build/main.js
CHANGED
|
@@ -49,14 +49,117 @@ const weather_1 = require("./lib/weather");
|
|
|
49
49
|
const localLlm_1 = require("./lib/localLlm");
|
|
50
50
|
const credentials_1 = require("./lib/credentials");
|
|
51
51
|
const context_1 = require("./lib/context");
|
|
52
|
+
const ask_1 = require("./lib/ask");
|
|
53
|
+
const presence_1 = require("./lib/presence");
|
|
54
|
+
const targets_1 = require("./lib/targets");
|
|
55
|
+
const routines_1 = require("./lib/routines");
|
|
56
|
+
const notifications_1 = require("./lib/notifications");
|
|
57
|
+
const triggers_1 = require("./lib/triggers");
|
|
52
58
|
const voiceServer_1 = require("./lib/voice/voiceServer");
|
|
53
59
|
const wyoming_1 = require("./lib/voice/wyoming");
|
|
60
|
+
const esphome_1 = require("./lib/voice/esphome");
|
|
61
|
+
const esphomeEntities_1 = require("./lib/voice/esphomeEntities");
|
|
62
|
+
const mediaServer_1 = require("./lib/voice/mediaServer");
|
|
63
|
+
const tone_1 = require("./lib/voice/tone");
|
|
54
64
|
const engines_1 = require("./lib/voice/engines");
|
|
55
65
|
/**
|
|
56
66
|
* How long a rendered weather context line stays valid. Weather adapters refresh every few minutes at best,
|
|
57
67
|
* so re-reading their whole state tree on every single request would be pure overhead.
|
|
58
68
|
*/
|
|
59
69
|
const WEATHER_CTX_TTL = 5 * 60 * 1000;
|
|
70
|
+
/**
|
|
71
|
+
* The replies the assistant speaks verbatim, whatever the question was. They are pre-synthesised into
|
|
72
|
+
* the TTS cache at startup; everything else is cached on first use.
|
|
73
|
+
*/
|
|
74
|
+
/** How long to wait for a device to confirm a write, and how often to look. */
|
|
75
|
+
const ACK_TIMEOUT_MS = 2000;
|
|
76
|
+
const ACK_POLL_MS = 150;
|
|
77
|
+
/**
|
|
78
|
+
* Did the device end up with the value we asked for? Compared loosely: a dimmer told `30` may report
|
|
79
|
+
* `30.0`, a switch told `true` may report `1`, and a lamp told 100 % may settle at 99 % — insisting on
|
|
80
|
+
* strict equality would report a failure for a command that plainly worked.
|
|
81
|
+
*/
|
|
82
|
+
function valuesMatch(actual, expected) {
|
|
83
|
+
if (actual === expected) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if (typeof expected === 'boolean' || typeof actual === 'boolean') {
|
|
87
|
+
return Boolean(actual) === Boolean(expected);
|
|
88
|
+
}
|
|
89
|
+
const a = Number(actual);
|
|
90
|
+
const b = Number(expected);
|
|
91
|
+
if (Number.isFinite(a) && Number.isFinite(b)) {
|
|
92
|
+
return Math.abs(a - b) <= Math.max(1, Math.abs(b) * 0.02);
|
|
93
|
+
}
|
|
94
|
+
return String(actual).trim().toLowerCase() === String(expected).trim().toLowerCase();
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* ioBroker role for a measurement, derived from its unit — a `value.temperature` shows up with the right
|
|
98
|
+
* icon and in the right place in vis, where a bare `value` is just a number.
|
|
99
|
+
*/
|
|
100
|
+
function sensorRole(entity) {
|
|
101
|
+
const unit = (entity.unit || '').trim().toLowerCase();
|
|
102
|
+
if (unit === '°c' || unit === '°f' || unit === 'k') {
|
|
103
|
+
return 'value.temperature';
|
|
104
|
+
}
|
|
105
|
+
if (unit === '%') {
|
|
106
|
+
return 'value.humidity'; // the only percentage a voice box reports is its humidity
|
|
107
|
+
}
|
|
108
|
+
if (unit === 'lx' || unit === 'lux') {
|
|
109
|
+
return 'value.brightness';
|
|
110
|
+
}
|
|
111
|
+
if (unit === 'hpa' || unit === 'mbar' || unit === 'pa') {
|
|
112
|
+
return 'value.pressure';
|
|
113
|
+
}
|
|
114
|
+
if (unit === 'ppm' || unit === 'ppb') {
|
|
115
|
+
return 'value.co2';
|
|
116
|
+
}
|
|
117
|
+
if (unit === 'db' || unit === 'dba') {
|
|
118
|
+
return 'value.volume';
|
|
119
|
+
}
|
|
120
|
+
return 'value';
|
|
121
|
+
}
|
|
122
|
+
/** Heading for a category answer, so "18 °C, 21 °C" says what it is about. */
|
|
123
|
+
const CATEGORY_LABELS = {
|
|
124
|
+
temperature: { de: 'Temperatur', en: 'Temperature', ru: 'Температура' },
|
|
125
|
+
humidity: { de: 'Luftfeuchtigkeit', en: 'Humidity', ru: 'Влажность' },
|
|
126
|
+
illuminance: { de: 'Helligkeit', en: 'Brightness', ru: 'Освещённость' },
|
|
127
|
+
pressure: { de: 'Luftdruck', en: 'Pressure', ru: 'Давление' },
|
|
128
|
+
airQuality: { de: 'Luftqualität', en: 'Air quality', ru: 'Качество воздуха' },
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Put a word to an air-quality number, because the number alone means nothing when spoken. Only for
|
|
132
|
+
* the IAQ scale (0–500, unitless or labelled IAQ) — a CO₂ value in ppm is a different scale and is
|
|
133
|
+
* better left unlabelled than labelled wrongly. Thresholds from the BME680/BSEC IAQ classification,
|
|
134
|
+
* as used by the Python original.
|
|
135
|
+
*/
|
|
136
|
+
function iaqLabel(value, unit, lang) {
|
|
137
|
+
const iaq = Number(value);
|
|
138
|
+
const u = unit.trim().toLowerCase();
|
|
139
|
+
if (!Number.isFinite(iaq) || (u !== '' && u !== 'iaq')) {
|
|
140
|
+
return '';
|
|
141
|
+
}
|
|
142
|
+
const scale = [
|
|
143
|
+
[50, 'sehr gut', 'excellent', 'отлично'],
|
|
144
|
+
[100, 'gut', 'good', 'хорошо'],
|
|
145
|
+
[150, 'mäßig', 'moderate', 'умеренно'],
|
|
146
|
+
[200, 'schlecht', 'poor', 'плохо'],
|
|
147
|
+
[300, 'sehr schlecht', 'very poor', 'очень плохо'],
|
|
148
|
+
[Number.POSITIVE_INFINITY, 'extrem schlecht', 'extremely poor', 'крайне плохо'],
|
|
149
|
+
];
|
|
150
|
+
const row = scale.find(([limit]) => iaq <= limit);
|
|
151
|
+
if (!row) {
|
|
152
|
+
return '';
|
|
153
|
+
}
|
|
154
|
+
return lang === 'ru' ? row[3] : lang === 'de' ? row[1] : row[2];
|
|
155
|
+
}
|
|
156
|
+
/** NLU actions that only switch something; their success is what the confirmation tone replaces. */
|
|
157
|
+
const CONTROL_ACTIONS = new Set(['on', 'off', 'level', 'color']);
|
|
158
|
+
const FIXED_REPLIES = {
|
|
159
|
+
de: ['Ok.', 'Erledigt.'],
|
|
160
|
+
en: ['Okay.', 'Done.'],
|
|
161
|
+
ru: ['Хорошо.', 'Готово.'],
|
|
162
|
+
};
|
|
60
163
|
/** A tts value is treated as an audio file (not text) when it looks like an mp3/wav/… URL or path. */
|
|
61
164
|
function isAudioRef(v) {
|
|
62
165
|
return /\.(mp3|wav|ogg|flac|m4a|aac|opus)(\?.*)?$/i.test(v.trim());
|
|
@@ -79,7 +182,10 @@ function languageLabel(lang) {
|
|
|
79
182
|
return map[lang || 'en'] || 'English';
|
|
80
183
|
}
|
|
81
184
|
class Assistant extends adapter_core_1.Adapter {
|
|
185
|
+
/** Cloud LLM; `null` with provider "none" (local tiers only) or before `onReady` finished. */
|
|
82
186
|
agent = null;
|
|
187
|
+
/** Set once `onReady` initialised the pipeline — with or without a cloud {@link agent}. */
|
|
188
|
+
ready = false;
|
|
83
189
|
mcp = null;
|
|
84
190
|
/** Short-TTL cache for the expensive device/room/function listings; invalidated on enum changes. */
|
|
85
191
|
listCache = null;
|
|
@@ -95,12 +201,24 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
95
201
|
voice = null;
|
|
96
202
|
/** Wyoming TCP endpoint (only when wyomingEnabled); null otherwise. */
|
|
97
203
|
wyoming = null;
|
|
204
|
+
/** ESPHome voice satellites we dial out to (only when esphomeEnabled); null otherwise. */
|
|
205
|
+
esphome = null;
|
|
206
|
+
/** Serves spoken replies to ESPHome satellites over HTTP; null unless those are enabled. */
|
|
207
|
+
media = null;
|
|
98
208
|
/** Short-term per-source conversation memory (in-memory, TTL) for follow-up questions. */
|
|
99
209
|
context = new context_1.ConversationStore();
|
|
210
|
+
/** Questions the assistant asked and is waiting for an answer to (`askUser`), keyed by source. */
|
|
211
|
+
pending = new ask_1.PendingQuestions();
|
|
100
212
|
/** Satellite ids whose state objects have already been created (avoid re-creating on every update). */
|
|
101
213
|
satStatesEnsured = new Set();
|
|
102
214
|
/** Sanitised satellite state-id → real device name (for the per-satellite `tts` announce state). */
|
|
103
215
|
satDeviceById = new Map();
|
|
216
|
+
/** Satellite ids that already have the wake-word states — only ESPHome devices report any. */
|
|
217
|
+
satWakeWordStatesEnsured = new Set();
|
|
218
|
+
/** Timers already mirrored onto the ESPHome satellites, so we can tell a cancel from an update. */
|
|
219
|
+
mirroredTimers = new Map();
|
|
220
|
+
/** Per satellite: resolves once its control objects exist, so state writes can wait for them. */
|
|
221
|
+
satControlsReady = new Map();
|
|
104
222
|
/** Native (ioBroker) satellite state-id → sender instance id, so we can push announcements back to it. */
|
|
105
223
|
nativeSatFrom = new Map();
|
|
106
224
|
/** Countdown timers / reminders (roadmap #2); mirrored into `timers.*` states. Null until onReady. */
|
|
@@ -111,10 +229,28 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
111
229
|
alarms = null;
|
|
112
230
|
/** Per-alarm `alarms.items.<id>` channels currently rendered. */
|
|
113
231
|
alarmObjIds = new Set();
|
|
232
|
+
/** Proactive triggers (roadmap A2); mirrored into `triggers.*` states. Null until onReady. */
|
|
233
|
+
triggers = null;
|
|
234
|
+
/** Per-trigger `triggers.items.<id>` channels currently rendered. */
|
|
235
|
+
triggerObjIds = new Set();
|
|
236
|
+
/** Foreign states the loaded triggers watch (exactly what we subscribed to). */
|
|
237
|
+
triggerStateIds = new Set();
|
|
238
|
+
/** Master switch (`triggers.enabled`): false suppresses every trigger's effect. */
|
|
239
|
+
triggersEnabled = true;
|
|
114
240
|
/** Long-term memory (roadmap #6); mirrored into `memory.*` states. Null until onReady / when disabled. */
|
|
115
241
|
memory = null;
|
|
116
242
|
/** Per-memory `memory.items.<id>` channels currently rendered. */
|
|
117
243
|
memoryObjIds = new Set();
|
|
244
|
+
/** Phrase-triggered macros from the configuration ("Gute Nacht" → five actions). */
|
|
245
|
+
routines = [];
|
|
246
|
+
/** Named announcement targets (roadmap B2): groups of rooms and people, from the configuration. */
|
|
247
|
+
announceTargets = [];
|
|
248
|
+
/** Who is at home (roadmap B1); mirrored into `presence.*`. Null until onReady. */
|
|
249
|
+
presence = null;
|
|
250
|
+
/** Satellites currently set to Do-Not-Disturb, by state id (mirrors `satellites.<id>.dnd`). */
|
|
251
|
+
dndById = new Map();
|
|
252
|
+
/** Global Do-Not-Disturb (`dnd`): suppresses every announcement except priority/alert ones. */
|
|
253
|
+
globalDnd = false;
|
|
118
254
|
/** Cached weather context line (see `buildWeatherContext`); `key` = source + language. */
|
|
119
255
|
weatherCtx = null;
|
|
120
256
|
/** Active "ringing" sessions (a timer/alarm looping its sound until stopped or timed out). */
|
|
@@ -129,8 +265,12 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
129
265
|
}
|
|
130
266
|
async onReady() {
|
|
131
267
|
const cfg = this.config;
|
|
132
|
-
|
|
133
|
-
|
|
268
|
+
// Provider "none": no cloud LLM at all — only the local tiers (routines, rule-based NLU, timers,
|
|
269
|
+
// alarms, the local model) answer. The ioBroker tools are still built: the NLU switches through them.
|
|
270
|
+
const provider = cfg.provider || 'openai';
|
|
271
|
+
const localOnly = provider === 'none';
|
|
272
|
+
const apiKey = localOnly ? '' : (0, llm_1.effectiveApiKey)(cfg.provider, await (0, credentials_1.resolveApiKey)(this, cfg));
|
|
273
|
+
if (!localOnly && !apiKey) {
|
|
134
274
|
this.log.warn(cfg.credentialType === 'manager'
|
|
135
275
|
? 'No credential selected (manager mode) — pick an API-key credential in the adapter settings.'
|
|
136
276
|
: 'No API key configured — open the adapter settings and enter your LLM API key.');
|
|
@@ -154,6 +294,10 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
154
294
|
if ((cfg.weatherInstance || '').trim()) {
|
|
155
295
|
tools.push(this.buildWeatherTool());
|
|
156
296
|
}
|
|
297
|
+
if (cfg.voiceEnabled) {
|
|
298
|
+
// Only useful with satellites: without voice there is nothing to speak on.
|
|
299
|
+
tools.push(this.buildAnnounceTool());
|
|
300
|
+
}
|
|
157
301
|
this.log.info(`ioBroker tools enabled (${tools.length}): ${tools.map(t => t.name).join(', ')}`);
|
|
158
302
|
if (denied.length) {
|
|
159
303
|
this.log.debug(`Tools denied by access settings: ${denied.join(', ')}`);
|
|
@@ -161,17 +305,19 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
161
305
|
// Bust the device/room cache when room/function memberships change (new device shows within TTL anyway).
|
|
162
306
|
this.subscribeForeignObjects('enum.rooms.*');
|
|
163
307
|
this.subscribeForeignObjects('enum.functions.*');
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
308
|
+
if (provider !== 'none') {
|
|
309
|
+
const prov = (0, llm_1.resolveProvider)(provider, cfg.baseUrl);
|
|
310
|
+
this.agent = new llm_1.LlmAgent({
|
|
311
|
+
provider,
|
|
312
|
+
apiKey,
|
|
313
|
+
model: cfg.model || prov.defaultModel,
|
|
314
|
+
baseUrl: prov.baseUrl,
|
|
315
|
+
systemPrompt: cfg.systemPrompt || '',
|
|
316
|
+
maxTokens: cfg.maxTokens || 1024,
|
|
317
|
+
tools,
|
|
318
|
+
log: this.log,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
175
321
|
}
|
|
176
322
|
catch (e) {
|
|
177
323
|
this.log.error(`Could not initialise assistant: ${e.message}`);
|
|
@@ -179,6 +325,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
179
325
|
return;
|
|
180
326
|
}
|
|
181
327
|
await this.setStateAsync('info.connection', { val: true, ack: true });
|
|
328
|
+
this.ready = true;
|
|
182
329
|
this.subscribeStates('text.request');
|
|
183
330
|
// Meta object backing this instance's file storage (so the admin can upload sound assets and the
|
|
184
331
|
// adapter can read them back for timer/alarm jingles).
|
|
@@ -189,12 +336,32 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
189
336
|
});
|
|
190
337
|
await this.ensureBuiltinSounds();
|
|
191
338
|
this.subscribeStates('stopRinging');
|
|
339
|
+
// Spoken system notifications + Do-Not-Disturb (per satellite: see ensureSatelliteObjects).
|
|
340
|
+
this.subscribeStates('notify.text');
|
|
341
|
+
this.subscribeStates('notify.alert');
|
|
342
|
+
this.subscribeStates('dnd');
|
|
343
|
+
this.globalDnd = (await this.getStateAsync('dnd'))?.val === true;
|
|
344
|
+
if (this.globalDnd) {
|
|
345
|
+
this.log.info('Do-Not-Disturb is on — only alerts will be announced.');
|
|
346
|
+
}
|
|
192
347
|
await this.setupTimers();
|
|
193
348
|
await this.setupAlarms();
|
|
194
349
|
if (cfg.useLongTermMemory !== false) {
|
|
195
350
|
await this.setupMemory();
|
|
196
351
|
}
|
|
197
|
-
this.
|
|
352
|
+
this.routines = (0, routines_1.parseRoutineRows)(cfg.routines, { warn: message => this.log.warn(message) });
|
|
353
|
+
if (this.routines.length) {
|
|
354
|
+
this.log.info(`Routines: ${this.routines.map(r => r.name).join(', ')}.`);
|
|
355
|
+
}
|
|
356
|
+
this.announceTargets = (0, targets_1.parseTargetRows)(cfg.announceTargets);
|
|
357
|
+
if (this.announceTargets.length) {
|
|
358
|
+
this.log.info(`Announcement targets: ${this.announceTargets.map(t => `${t.name} (${t.kind})`).join(', ')}.`);
|
|
359
|
+
}
|
|
360
|
+
await this.setupPresence();
|
|
361
|
+
await this.setupTriggers();
|
|
362
|
+
this.log.info(this.agent
|
|
363
|
+
? `Assistant ready (provider=${cfg.provider}, model=${this.agent.model || 'auto'}).`
|
|
364
|
+
: 'Assistant ready without an LLM provider — answering locally only.');
|
|
198
365
|
if (cfg.useLocalLlm) {
|
|
199
366
|
void this.ensureLocalLlm(); // background: model load/download must not block onReady
|
|
200
367
|
}
|
|
@@ -209,6 +376,9 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
209
376
|
if (cfg.wyomingEnabled) {
|
|
210
377
|
await this.startWyoming(apiKey);
|
|
211
378
|
}
|
|
379
|
+
if (cfg.esphomeEnabled) {
|
|
380
|
+
await this.startEsphome(apiKey);
|
|
381
|
+
}
|
|
212
382
|
}
|
|
213
383
|
/** Start the Wyoming TCP endpoint, bridging to the same STT/answer/TTS pipeline as the UDP server. */
|
|
214
384
|
async startWyoming(mainApiKey) {
|
|
@@ -238,6 +408,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
238
408
|
log: this.log,
|
|
239
409
|
});
|
|
240
410
|
await this.wyoming.start();
|
|
411
|
+
this.declarePort('tcpPort', cfg.wyomingPort || 10700, cfg.bind);
|
|
241
412
|
if (!this.voice) {
|
|
242
413
|
this.warmupEngines(stt, tts, language); // only if the UDP server didn't already warm up
|
|
243
414
|
}
|
|
@@ -247,6 +418,77 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
247
418
|
this.wyoming = null;
|
|
248
419
|
}
|
|
249
420
|
}
|
|
421
|
+
/**
|
|
422
|
+
* Connect to the configured ESPHome voice satellites (ThirdReality V&M Assistant, HA Voice PE,
|
|
423
|
+
* linux-voice-assistant). The adapter is the client here — the devices listen on TCP 6053 — and the
|
|
424
|
+
* spoken reply is handed over as a URL, so the little media server comes up together with them.
|
|
425
|
+
*/
|
|
426
|
+
async startEsphome(mainApiKey) {
|
|
427
|
+
const cfg = this.config;
|
|
428
|
+
const devices = (cfg.esphomeDevices || []).filter(d => d?.ip?.trim());
|
|
429
|
+
if (!devices.length) {
|
|
430
|
+
this.log.warn('ESPHome satellites are enabled, but no device is configured.');
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, mainApiKey);
|
|
434
|
+
const ctx = this.voiceContext(cfg, creds);
|
|
435
|
+
const language = cfg.voiceLanguage || this.language || '';
|
|
436
|
+
let stt;
|
|
437
|
+
let tts;
|
|
438
|
+
try {
|
|
439
|
+
stt = (0, engines_1.createSttEngine)(cfg.sttProvider || 'openai', ctx);
|
|
440
|
+
tts = (0, engines_1.createTtsEngine)(cfg.ttsProvider || 'openai', ctx);
|
|
441
|
+
}
|
|
442
|
+
catch (e) {
|
|
443
|
+
this.log.warn(`ESPHome satellites not started — ${e.message}. Check the Voice tab settings.`);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
const media = new mediaServer_1.MediaServer({
|
|
448
|
+
port: cfg.esphomeMediaPort || 8099,
|
|
449
|
+
bindAddress: cfg.bind || '0.0.0.0',
|
|
450
|
+
log: this.log,
|
|
451
|
+
});
|
|
452
|
+
await media.start();
|
|
453
|
+
this.media = media;
|
|
454
|
+
this.declarePort('tcpPort', media.port, cfg.bind);
|
|
455
|
+
}
|
|
456
|
+
catch (e) {
|
|
457
|
+
this.log.error(`ESPHome satellites not started — media server failed: ${e.message}`);
|
|
458
|
+
this.media = null;
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
this.esphome = new esphome_1.EsphomeSatellites({
|
|
462
|
+
devices,
|
|
463
|
+
language,
|
|
464
|
+
stt,
|
|
465
|
+
tts,
|
|
466
|
+
answer: (question, origin) => this.answerVoice(question, origin.device),
|
|
467
|
+
getHints: () => this.buildSttHints(),
|
|
468
|
+
media: this.media,
|
|
469
|
+
mediaHost: (cfg.esphomeMediaHost || '').trim(),
|
|
470
|
+
silenceMs: cfg.esphomeSilenceMs || 0,
|
|
471
|
+
log: this.log,
|
|
472
|
+
onStatus: (device, room, state) => {
|
|
473
|
+
this.updateSatelliteState(device, room, state).catch(e => this.log.debug(`satellite state update failed: ${e.message}`));
|
|
474
|
+
},
|
|
475
|
+
onWakeWords: (device, room, config) => {
|
|
476
|
+
this.updateSatelliteWakeWords(device, room, config).catch(e => this.log.debug(`satellite wake word update failed: ${e.message}`));
|
|
477
|
+
},
|
|
478
|
+
onEntities: (device, room, entities) => {
|
|
479
|
+
this.createSatelliteControls(device, room, entities).catch(e => this.log.debug(`satellite control objects failed: ${e.message}`));
|
|
480
|
+
},
|
|
481
|
+
onEntityState: (device, room, entity) => {
|
|
482
|
+
this.updateSatelliteControl(device, room, entity).catch(e => this.log.debug(`satellite control update failed: ${e.message}`));
|
|
483
|
+
},
|
|
484
|
+
expectsFollowUp: answer => this.expectsFollowUp(answer),
|
|
485
|
+
});
|
|
486
|
+
await this.esphome.start();
|
|
487
|
+
this.log.info(`ESPHome satellites: ${devices.length} device(s), STT=${cfg.sttProvider || 'openai'}, TTS=${cfg.ttsProvider || 'openai'}.`);
|
|
488
|
+
if (!this.voice && !this.wyoming) {
|
|
489
|
+
this.warmupEngines(stt, tts, language); // nobody warmed the engines up yet
|
|
490
|
+
}
|
|
491
|
+
}
|
|
250
492
|
/** Build the engine context (creds + local-model settings + data dir) for the STT/TTS factory. */
|
|
251
493
|
voiceContext(cfg, creds) {
|
|
252
494
|
return {
|
|
@@ -257,6 +499,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
257
499
|
aws: (cfg.awsVoice || '').trim(),
|
|
258
500
|
piper: (cfg.piperVoice || '').trim(),
|
|
259
501
|
},
|
|
502
|
+
sttFallback: cfg.sttFallback || '',
|
|
503
|
+
ttsFallback: cfg.ttsFallback || '',
|
|
260
504
|
dataDir: this.instanceDataDir(),
|
|
261
505
|
log: this.log,
|
|
262
506
|
voskModel: (cfg.voskModel || '').trim(),
|
|
@@ -300,6 +544,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
300
544
|
},
|
|
301
545
|
});
|
|
302
546
|
await this.voice.start();
|
|
547
|
+
this.declarePort('udpPort', cfg.port || 7775, cfg.bind, 4); // dgram 'udp4' socket
|
|
303
548
|
this.log.info(`Voice server: STT=${cfg.sttProvider || 'openai'}, TTS=${cfg.ttsProvider || 'openai'}.`);
|
|
304
549
|
// Warm up local engines now (install/download) instead of on the first spoken command.
|
|
305
550
|
this.warmupEngines(stt, tts, language);
|
|
@@ -318,8 +563,22 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
318
563
|
}
|
|
319
564
|
if (tts.prepare) {
|
|
320
565
|
this.log.info('Preparing text-to-speech engine (download in background) …');
|
|
321
|
-
tts.prepare(lang)
|
|
566
|
+
tts.prepare(lang)
|
|
567
|
+
.then(() => this.warmTtsCache(tts, lang))
|
|
568
|
+
.catch(e => this.log.warn(`TTS warm-up failed: ${e.message}`));
|
|
322
569
|
}
|
|
570
|
+
else {
|
|
571
|
+
void this.warmTtsCache(tts, lang);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Pre-synthesise the handful of replies the assistant says verbatim, so the first one of the day is
|
|
576
|
+
* not a cloud round-trip. Everything else lands in the cache on first use anyway — this only covers
|
|
577
|
+
* the fixed strings, which are the ones that repeat forever.
|
|
578
|
+
*/
|
|
579
|
+
async warmTtsCache(tts, lang) {
|
|
580
|
+
const phrases = FIXED_REPLIES[lang === 'ru' ? 'ru' : lang === 'de' ? 'de' : 'en'];
|
|
581
|
+
await tts.warm?.(phrases, lang).catch(e => this.log.debug(`TTS cache warm-up: ${e.message}`));
|
|
323
582
|
}
|
|
324
583
|
/**
|
|
325
584
|
* Answer a voice request and mirror it into the text states: the recognised text into
|
|
@@ -468,6 +727,22 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
468
727
|
}
|
|
469
728
|
/** Reflect a satellite's status into `satellites.<room>.*` states (created on first sight). */
|
|
470
729
|
async updateSatelliteState(device, room, state) {
|
|
730
|
+
const id = await this.ensureSatelliteObjects(device, room);
|
|
731
|
+
const base = `satellites.${id}`;
|
|
732
|
+
await this.setStateAsync(`${base}.status`, { val: state, ack: true });
|
|
733
|
+
await this.setStateAsync(`${base}.alive`, { val: state !== 'offline', ack: true });
|
|
734
|
+
await this.setStateAsync(`${base}.lastSeen`, { val: Date.now(), ack: true });
|
|
735
|
+
if (room) {
|
|
736
|
+
await this.setStateAsync(`${base}.room`, { val: room, ack: true });
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Create the object tree for one satellite and return its (sanitised) state id, without touching
|
|
741
|
+
* any value. Callers that only need somewhere to hang a child object use this rather than
|
|
742
|
+
* {@link updateSatelliteState} — going through that would write `status`, and a wake-word or
|
|
743
|
+
* settings update arriving mid-announcement would knock the satellite back to `idle`.
|
|
744
|
+
*/
|
|
745
|
+
async ensureSatelliteObjects(device, room) {
|
|
471
746
|
const id = this.satelliteStateId(device, room);
|
|
472
747
|
const roomName = (room || '').replace(/^(enum\.rooms\.|system\.rooms\.)/, '');
|
|
473
748
|
const base = `satellites.${id}`;
|
|
@@ -533,16 +808,298 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
533
808
|
},
|
|
534
809
|
native: {},
|
|
535
810
|
});
|
|
811
|
+
await this.setObjectNotExistsAsync(`${base}.dnd`, {
|
|
812
|
+
type: 'state',
|
|
813
|
+
common: {
|
|
814
|
+
name: 'Do-Not-Disturb: suppress announcements on this satellite (alerts still play)',
|
|
815
|
+
type: 'boolean',
|
|
816
|
+
role: 'switch.mode.silent',
|
|
817
|
+
read: true,
|
|
818
|
+
write: true,
|
|
819
|
+
def: false,
|
|
820
|
+
},
|
|
821
|
+
native: {},
|
|
822
|
+
});
|
|
536
823
|
this.subscribeStates(`${base}.tts`);
|
|
824
|
+
this.subscribeStates(`${base}.dnd`);
|
|
825
|
+
// A satellite that was silenced before the restart stays silenced.
|
|
826
|
+
const dnd = await this.getStateAsync(`${base}.dnd`);
|
|
827
|
+
this.dndById.set(id, dnd?.val === true);
|
|
537
828
|
this.satStatesEnsured.add(id);
|
|
538
829
|
}
|
|
539
830
|
// Map the (sanitised) state id back to the real device name for the per-satellite tts state.
|
|
540
831
|
this.satDeviceById.set(id, device);
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
832
|
+
return id;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Publish a satellite's wake-word configuration and let it be changed from ioBroker.
|
|
836
|
+
*
|
|
837
|
+
* Only ESPHome satellites have this: the wake word runs on the device, and the ESPHome API lets the
|
|
838
|
+
* controller pick which of the built-in models listen. `wakeWords` is writable and takes a
|
|
839
|
+
* comma-separated list of ids; the device stores the choice itself, so it survives a restart of the
|
|
840
|
+
* adapter and is read back from the device rather than mirrored optimistically.
|
|
841
|
+
*/
|
|
842
|
+
async updateSatelliteWakeWords(device, room, config) {
|
|
843
|
+
const id = await this.ensureSatelliteObjects(device, room);
|
|
844
|
+
const base = `satellites.${id}`;
|
|
845
|
+
if (!this.satWakeWordStatesEnsured.has(id)) {
|
|
846
|
+
await this.setObjectNotExistsAsync(`${base}.wakeWords`, {
|
|
847
|
+
type: 'state',
|
|
848
|
+
common: {
|
|
849
|
+
name: 'Active wake words (comma-separated ids)',
|
|
850
|
+
type: 'string',
|
|
851
|
+
role: 'text',
|
|
852
|
+
read: true,
|
|
853
|
+
write: true,
|
|
854
|
+
def: '',
|
|
855
|
+
},
|
|
856
|
+
native: {},
|
|
857
|
+
});
|
|
858
|
+
await this.setObjectNotExistsAsync(`${base}.availableWakeWords`, {
|
|
859
|
+
type: 'state',
|
|
860
|
+
common: {
|
|
861
|
+
name: 'Wake words this device offers',
|
|
862
|
+
type: 'string',
|
|
863
|
+
role: 'json',
|
|
864
|
+
read: true,
|
|
865
|
+
write: false,
|
|
866
|
+
def: '[]',
|
|
867
|
+
},
|
|
868
|
+
native: {},
|
|
869
|
+
});
|
|
870
|
+
this.subscribeStates(`${base}.wakeWords`);
|
|
871
|
+
this.satWakeWordStatesEnsured.add(id);
|
|
872
|
+
}
|
|
873
|
+
await this.setStateAsync(`${base}.wakeWords`, { val: config.active.join(','), ack: true });
|
|
874
|
+
await this.setStateAsync(`${base}.availableWakeWords`, {
|
|
875
|
+
val: JSON.stringify({ max: config.max, words: config.available }),
|
|
876
|
+
ack: true,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Mirror everything an ESPHome satellite exposes besides the voice pipeline into
|
|
881
|
+
* `satellites.<id>.controls.*` — microphone gain and volume, noise suppression, wake-word and
|
|
882
|
+
* stop-word sensitivities, the mute and thinking-sound switches, its media player and its firmware
|
|
883
|
+
* update entity. Which of these exist is up to the device, so the objects are built from what it
|
|
884
|
+
* announced rather than from a hard-coded list.
|
|
885
|
+
*
|
|
886
|
+
* The compound entities get a small folder instead of a single state, because one value cannot
|
|
887
|
+
* carry them: a media player has a state, a volume and a mute flag, and a firmware entity has two
|
|
888
|
+
* versions plus progress.
|
|
889
|
+
*/
|
|
890
|
+
createSatelliteControls(device, room, entities) {
|
|
891
|
+
if (!entities.length) {
|
|
892
|
+
return Promise.resolve();
|
|
893
|
+
}
|
|
894
|
+
// Publish the promise *before* awaiting anything: the device starts pushing entity states while
|
|
895
|
+
// these objects are still being created, and those writes have to wait for it.
|
|
896
|
+
const satId = this.satelliteStateId(device, room);
|
|
897
|
+
const task = this.buildSatelliteControls(satId, device, room, entities);
|
|
898
|
+
this.satControlsReady.set(satId, task);
|
|
899
|
+
return task;
|
|
900
|
+
}
|
|
901
|
+
/** The actual object creation behind {@link createSatelliteControls}. */
|
|
902
|
+
async buildSatelliteControls(satId, device, room, entities) {
|
|
903
|
+
await this.ensureSatelliteObjects(device, room); // make sure the parent objects exist
|
|
904
|
+
const base = `satellites.${satId}.controls`;
|
|
905
|
+
await this.setObjectNotExistsAsync(base, {
|
|
906
|
+
type: 'channel',
|
|
907
|
+
common: { name: 'Device settings' },
|
|
908
|
+
native: {},
|
|
909
|
+
});
|
|
910
|
+
for (const entity of entities) {
|
|
911
|
+
const id = `${base}.${entity.objectId}`;
|
|
912
|
+
const name = entity.name || entity.objectId;
|
|
913
|
+
if (entity.kind === 'mediaPlayer') {
|
|
914
|
+
await this.setObjectNotExistsAsync(id, { type: 'channel', common: { name }, native: {} });
|
|
915
|
+
await this.ensureState(`${id}.state`, 'Playback state', 'string', 'media.state', false);
|
|
916
|
+
await this.ensureState(`${id}.volume`, 'Volume (0…1)', 'number', 'level.volume', true);
|
|
917
|
+
await this.ensureState(`${id}.command`, `Command (${Object.keys(esphomeEntities_1.MEDIA_COMMANDS).join(', ')})`, 'string', 'text', true);
|
|
918
|
+
await this.ensureState(`${id}.muted`, 'Muted', 'boolean', 'media.mute', false);
|
|
919
|
+
this.subscribeStates(`${id}.volume`);
|
|
920
|
+
this.subscribeStates(`${id}.command`);
|
|
921
|
+
}
|
|
922
|
+
else if (entity.kind === 'update') {
|
|
923
|
+
await this.setObjectNotExistsAsync(id, { type: 'channel', common: { name }, native: {} });
|
|
924
|
+
await this.ensureState(`${id}.currentVersion`, 'Installed version', 'string', 'text', false);
|
|
925
|
+
await this.ensureState(`${id}.latestVersion`, 'Available version', 'string', 'text', false);
|
|
926
|
+
await this.ensureState(`${id}.inProgress`, 'Update running', 'boolean', 'indicator', false);
|
|
927
|
+
await this.ensureState(`${id}.progress`, 'Update progress', 'number', 'value', false);
|
|
928
|
+
await this.ensureState(`${id}.install`, 'Install the update', 'boolean', 'button', true);
|
|
929
|
+
this.subscribeStates(`${id}.install`);
|
|
930
|
+
}
|
|
931
|
+
else if (entity.kind === 'event') {
|
|
932
|
+
await this.setObjectNotExistsAsync(id, {
|
|
933
|
+
type: 'state',
|
|
934
|
+
common: {
|
|
935
|
+
name: `${name} (last event)`,
|
|
936
|
+
type: 'string',
|
|
937
|
+
role: 'text',
|
|
938
|
+
read: true,
|
|
939
|
+
write: false,
|
|
940
|
+
def: '',
|
|
941
|
+
...(entity.eventTypes?.length
|
|
942
|
+
? { states: Object.fromEntries(entity.eventTypes.map(t => [t, t])) }
|
|
943
|
+
: {}),
|
|
944
|
+
},
|
|
945
|
+
native: {},
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
else if (entity.kind === 'sensor' || entity.kind === 'binarySensor' || entity.kind === 'textSensor') {
|
|
949
|
+
// What the box measures about its room (temperature, presence, a status text). Read-only,
|
|
950
|
+
// so there is nothing to subscribe to — the device pushes the values.
|
|
951
|
+
const common = entity.kind === 'sensor'
|
|
952
|
+
? {
|
|
953
|
+
name,
|
|
954
|
+
type: 'number',
|
|
955
|
+
role: sensorRole(entity),
|
|
956
|
+
read: true,
|
|
957
|
+
write: false,
|
|
958
|
+
unit: entity.unit || undefined,
|
|
959
|
+
}
|
|
960
|
+
: entity.kind === 'binarySensor'
|
|
961
|
+
? { name, type: 'boolean', role: 'indicator', read: true, write: false, def: false }
|
|
962
|
+
: { name, type: 'string', role: 'text', read: true, write: false, def: '' };
|
|
963
|
+
await this.setObjectNotExistsAsync(id, { type: 'state', common, native: {} });
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
const common = entity.kind === 'switch'
|
|
967
|
+
? { name, type: 'boolean', role: 'switch', read: true, write: true, def: false }
|
|
968
|
+
: entity.kind === 'select'
|
|
969
|
+
? {
|
|
970
|
+
name,
|
|
971
|
+
type: 'string',
|
|
972
|
+
role: 'text',
|
|
973
|
+
read: true,
|
|
974
|
+
write: true,
|
|
975
|
+
def: '',
|
|
976
|
+
states: Object.fromEntries((entity.options || []).map(o => [o, o])),
|
|
977
|
+
}
|
|
978
|
+
: {
|
|
979
|
+
name,
|
|
980
|
+
type: 'number',
|
|
981
|
+
role: 'level',
|
|
982
|
+
read: true,
|
|
983
|
+
write: true,
|
|
984
|
+
min: entity.min,
|
|
985
|
+
max: entity.max,
|
|
986
|
+
step: entity.step,
|
|
987
|
+
unit: entity.unit || undefined,
|
|
988
|
+
};
|
|
989
|
+
await this.setObjectNotExistsAsync(id, { type: 'state', common, native: {} });
|
|
990
|
+
this.subscribeStates(id);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
this.satDeviceById.set(satId, device);
|
|
994
|
+
// Now that the objects exist, publish the values the device already reported. Anything that
|
|
995
|
+
// arrived while we were still creating objects was skipped, so this is what fills those in.
|
|
996
|
+
for (const entity of entities) {
|
|
997
|
+
if (entity.value !== undefined) {
|
|
998
|
+
await this.writeSatelliteControl(satId, entity);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
this.log.debug(`Satellite ${device}: ${entities.length} control object(s) under ${base}`);
|
|
1002
|
+
}
|
|
1003
|
+
/** Small helper so the control objects above stay readable. */
|
|
1004
|
+
async ensureState(id, name, type, role, write) {
|
|
1005
|
+
await this.setObjectNotExistsAsync(id, {
|
|
1006
|
+
type: 'state',
|
|
1007
|
+
common: { name, type, role, read: true, write },
|
|
1008
|
+
native: {},
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* A device pushed a new value for one of its entities.
|
|
1013
|
+
*
|
|
1014
|
+
* The device starts reporting states while {@link buildSatelliteControls} is still creating the
|
|
1015
|
+
* objects, so this waits for that to finish. Before the entities have even been announced there is
|
|
1016
|
+
* nothing to wait on and nothing to write into — the value is kept in the registry and published by
|
|
1017
|
+
* the initial sync at the end of the build.
|
|
1018
|
+
*/
|
|
1019
|
+
async updateSatelliteControl(device, room, entity) {
|
|
1020
|
+
const satId = this.satelliteStateId(device, room);
|
|
1021
|
+
const ready = this.satControlsReady.get(satId);
|
|
1022
|
+
if (!ready) {
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
await ready;
|
|
1026
|
+
await this.writeSatelliteControl(satId, entity);
|
|
1027
|
+
}
|
|
1028
|
+
/** Write one entity's current value into its state(s). Always acked — this is the device talking. */
|
|
1029
|
+
async writeSatelliteControl(satId, entity) {
|
|
1030
|
+
const base = `satellites.${satId}.controls.${entity.objectId}`;
|
|
1031
|
+
if (entity.kind === 'mediaPlayer') {
|
|
1032
|
+
const v = entity.value;
|
|
1033
|
+
await this.setStateAsync(`${base}.state`, { val: v.state, ack: true });
|
|
1034
|
+
await this.setStateAsync(`${base}.volume`, { val: v.volume, ack: true });
|
|
1035
|
+
await this.setStateAsync(`${base}.muted`, { val: v.muted, ack: true });
|
|
1036
|
+
}
|
|
1037
|
+
else if (entity.kind === 'update') {
|
|
1038
|
+
const v = entity.value;
|
|
1039
|
+
await this.setStateAsync(`${base}.currentVersion`, { val: v.currentVersion, ack: true });
|
|
1040
|
+
await this.setStateAsync(`${base}.latestVersion`, { val: v.latestVersion, ack: true });
|
|
1041
|
+
await this.setStateAsync(`${base}.inProgress`, { val: v.inProgress, ack: true });
|
|
1042
|
+
await this.setStateAsync(`${base}.progress`, { val: v.progress, ack: true });
|
|
1043
|
+
}
|
|
1044
|
+
else {
|
|
1045
|
+
await this.setStateAsync(base, { val: entity.value, ack: true });
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Apply a write to any `satellites.<id>.controls.*` state. Like the wake words, nothing is acked
|
|
1050
|
+
* here: the device reports the value it actually took and {@link updateSatelliteControl} writes
|
|
1051
|
+
* that, so a clamped or rejected write never leaves ioBroker showing a value the device ignored.
|
|
1052
|
+
*/
|
|
1053
|
+
setSatelliteControl(satId, path, value) {
|
|
1054
|
+
const device = this.satDeviceById.get(satId) || satId;
|
|
1055
|
+
if (!this.esphome) {
|
|
1056
|
+
this.log.warn(`Cannot write ${path} on ${device}: ESPHome satellites are not enabled.`);
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
// Compound entities address a leaf: `<objectId>.volume`, `<objectId>.install`, …
|
|
1060
|
+
const [objectId, leaf] = path.split('.');
|
|
1061
|
+
let payload = value;
|
|
1062
|
+
if (leaf === 'install') {
|
|
1063
|
+
if (!value) {
|
|
1064
|
+
return; // button released
|
|
1065
|
+
}
|
|
1066
|
+
payload = 'install';
|
|
1067
|
+
}
|
|
1068
|
+
else if (leaf === 'volume' || leaf === 'command') {
|
|
1069
|
+
payload = value;
|
|
1070
|
+
}
|
|
1071
|
+
else if (leaf) {
|
|
1072
|
+
return; // read-only leaf of a compound entity
|
|
1073
|
+
}
|
|
1074
|
+
if (!this.esphome.setEntity(device, objectId, payload)) {
|
|
1075
|
+
this.log.warn(`Writing ${objectId} on ${device} was rejected — see the warning above.`);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Apply a write to `satellites.<id>.wakeWords`. The state is *not* acked here: the device echoes its
|
|
1080
|
+
* new configuration back and {@link updateSatelliteWakeWords} writes the acked value, so what ioBroker
|
|
1081
|
+
* shows is always what the device actually does — including when it rejected or trimmed the list.
|
|
1082
|
+
*/
|
|
1083
|
+
async setSatelliteWakeWords(satId, value) {
|
|
1084
|
+
const device = this.satDeviceById.get(satId) || satId;
|
|
1085
|
+
const ids = value
|
|
1086
|
+
.split(/[,;]/)
|
|
1087
|
+
.map(s => s.trim())
|
|
1088
|
+
.filter(Boolean);
|
|
1089
|
+
if (!this.esphome) {
|
|
1090
|
+
this.log.warn(`Cannot set wake words on ${device}: ESPHome satellites are not enabled.`);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (!this.esphome.setWakeWords(device, ids)) {
|
|
1094
|
+
this.log.warn(`Cannot set wake words: satellite ${device} is not connected.`);
|
|
1095
|
+
// Put the last known value back, so the state does not keep showing a change that never happened.
|
|
1096
|
+
const known = this.esphome.wakeWords(device);
|
|
1097
|
+
if (known) {
|
|
1098
|
+
await this.setStateAsync(`satellites.${satId}.wakeWords`, {
|
|
1099
|
+
val: known.active.join(','),
|
|
1100
|
+
ack: true,
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
546
1103
|
}
|
|
547
1104
|
}
|
|
548
1105
|
/**
|
|
@@ -568,19 +1125,38 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
568
1125
|
* the configured TTS engine; a URL/path to an audio file (mp3/wav/…) is decoded with ffmpeg. Delivered
|
|
569
1126
|
* to **both** transports: ioBroker-native satellites via a `sendTo(from, 'announce', …)` message and UDP
|
|
570
1127
|
* satellites via the UDP server.
|
|
1128
|
+
*
|
|
1129
|
+
* With `opts.listen` the satellite re-opens its microphone afterwards, for the answer to a question
|
|
1130
|
+
* (`askUser`); `opts.priority` plays even on a satellite set to Do-Not-Disturb (an alert does, see
|
|
1131
|
+
* {@link notify}). Returns how many channels the announcement reached — 0 means nobody heard it.
|
|
571
1132
|
*/
|
|
572
|
-
async announceToSatellites(value,
|
|
1133
|
+
async announceToSatellites(value, target, opts = {}) {
|
|
1134
|
+
const listen = opts.listen === true;
|
|
573
1135
|
let v = value.trim();
|
|
574
1136
|
if (!v) {
|
|
575
|
-
return;
|
|
1137
|
+
return 0;
|
|
1138
|
+
}
|
|
1139
|
+
// A name may stand for one satellite, a room, or a configured group/person — resolve it before
|
|
1140
|
+
// spending a TTS call on a target that does not exist.
|
|
1141
|
+
const targets = this.resolveTargets(target);
|
|
1142
|
+
if (targets && !targets.length) {
|
|
1143
|
+
this.log.warn(`Announcement not delivered — '${String(target)}' is no known satellite, room or group.`);
|
|
1144
|
+
return 0;
|
|
1145
|
+
}
|
|
1146
|
+
// Checked before the text is synthesised, so talking to an empty house costs neither an LLM nor a
|
|
1147
|
+
// TTS call.
|
|
1148
|
+
if (opts.onlyWhenHome && this.emptyHouse()) {
|
|
1149
|
+
this.log.debug('Announcement held back — nobody is at home.');
|
|
1150
|
+
return 0;
|
|
576
1151
|
}
|
|
577
1152
|
// A leading "!" marks a priority announcement: strip it and let it bypass a satellite's
|
|
578
1153
|
// Do-Not-Disturb (e.g. "!Water leak detected" is played even in DND).
|
|
579
|
-
|
|
580
|
-
if (
|
|
1154
|
+
let priority = opts.priority === true;
|
|
1155
|
+
if (v.startsWith('!')) {
|
|
1156
|
+
priority = true;
|
|
581
1157
|
v = v.slice(1).trim();
|
|
582
1158
|
if (!v) {
|
|
583
|
-
return;
|
|
1159
|
+
return 0;
|
|
584
1160
|
}
|
|
585
1161
|
}
|
|
586
1162
|
const isAudio = isAudioRef(v);
|
|
@@ -597,43 +1173,122 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
597
1173
|
}
|
|
598
1174
|
catch (e) {
|
|
599
1175
|
this.log.error(`Announcement failed: ${e.message}`);
|
|
600
|
-
return;
|
|
1176
|
+
return 0;
|
|
1177
|
+
}
|
|
1178
|
+
let delivered = 0;
|
|
1179
|
+
if (targets === null) {
|
|
1180
|
+
delivered = await this.deliverPcm(pcm, sampleRate, null, priority, listen);
|
|
1181
|
+
}
|
|
1182
|
+
else {
|
|
1183
|
+
// Synthesised once, handed to each member: a group of three speakers is one TTS call.
|
|
1184
|
+
for (const id of targets) {
|
|
1185
|
+
delivered += await this.deliverPcm(pcm, sampleRate, id, priority, listen);
|
|
1186
|
+
}
|
|
601
1187
|
}
|
|
602
|
-
|
|
603
|
-
this.log.info(`Announce → ${targetId || 'all satellites'} (${delivered} channel(s)): ${isAudio ? v : `"${v}"`}`);
|
|
1188
|
+
this.log.info(`Announce → ${targets ? targets.join(', ') : 'all satellites'} (${delivered} channel(s)${listen ? ', mic on' : ''}): ${isAudio ? v : `"${v}"`}`);
|
|
604
1189
|
if (!delivered) {
|
|
605
1190
|
this.log.warn('Announcement not delivered — no satellites registered (native or UDP).');
|
|
606
1191
|
}
|
|
1192
|
+
return delivered;
|
|
607
1193
|
}
|
|
608
1194
|
/**
|
|
609
1195
|
* Deliver a ready 16-bit-mono-PCM buffer to one satellite (`targetId`) or all (`null`), over both
|
|
610
1196
|
* transports (ioBroker-native message bus + UDP). Returns how many channels it reached.
|
|
1197
|
+
*
|
|
1198
|
+
* `listen` asks the satellite to re-open its microphone when the clip has played, so the answer to a
|
|
1199
|
+
* question is captured without a wake word. Each transport has its own way: ESPHome carries it in the
|
|
1200
|
+
* announce request (`start_conversation`), UDP gets a `listen` control message, and a native satellite
|
|
1201
|
+
* receives the flag in the announce message.
|
|
611
1202
|
*/
|
|
612
|
-
async deliverPcm(pcm, sampleRate, targetId, priority) {
|
|
1203
|
+
async deliverPcm(pcm, sampleRate, targetId, priority, listen = false) {
|
|
613
1204
|
let delivered = 0;
|
|
1205
|
+
if (this.silenced(targetId, priority)) {
|
|
1206
|
+
this.log.debug(`Announcement suppressed — Do-Not-Disturb (${targetId || 'all satellites'}).`);
|
|
1207
|
+
return 0;
|
|
1208
|
+
}
|
|
614
1209
|
// ── ioBroker-native satellites: push over the message bus ───────────────
|
|
615
1210
|
const nativeTargets = targetId
|
|
616
1211
|
? this.nativeSatFrom.has(targetId)
|
|
617
1212
|
? [[targetId, this.nativeSatFrom.get(targetId)]]
|
|
618
1213
|
: []
|
|
619
1214
|
: [...this.nativeSatFrom.entries()];
|
|
620
|
-
for (const [, from] of nativeTargets) {
|
|
621
|
-
this.
|
|
1215
|
+
for (const [satId, from] of nativeTargets) {
|
|
1216
|
+
if (this.silenced(satId, priority)) {
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
this.sendTo(from, 'announce', {
|
|
1220
|
+
audio: pcm.toString('base64'),
|
|
1221
|
+
sampleRate,
|
|
1222
|
+
format: 'pcm',
|
|
1223
|
+
priority,
|
|
1224
|
+
listen,
|
|
1225
|
+
});
|
|
622
1226
|
delivered++;
|
|
623
1227
|
}
|
|
624
1228
|
// ── UDP satellites (if the UDP server runs and the target isn't a native one) ──
|
|
625
1229
|
if (this.voice && !(targetId && this.nativeSatFrom.has(targetId))) {
|
|
626
1230
|
const device = targetId ? this.satDeviceById.get(targetId) || targetId : null;
|
|
1231
|
+
// Addressed per device rather than broadcast, so one silenced satellite in the house does not
|
|
1232
|
+
// silence the others — and so the count reflects who actually got it (announce() itself only
|
|
1233
|
+
// warns when nobody is registered, and askUser needs to know whether the question was heard).
|
|
1234
|
+
const allowed = this.voice
|
|
1235
|
+
.devices()
|
|
1236
|
+
.filter(d => (!device || d === device) && !this.isDeviceSilenced(d, priority));
|
|
1237
|
+
if (allowed.length) {
|
|
1238
|
+
try {
|
|
1239
|
+
await Promise.all(allowed.map(d => this.voice.announce(d, pcm, sampleRate)));
|
|
1240
|
+
delivered += allowed.length;
|
|
1241
|
+
if (listen) {
|
|
1242
|
+
allowed.forEach(d => this.voice.listen(d));
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
catch (e) {
|
|
1246
|
+
this.log.debug(`UDP announce failed: ${e.message}`);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
// ── ESPHome satellites: they fetch the clip from the media server by URL ──
|
|
1251
|
+
if (this.esphome && !(targetId && this.nativeSatFrom.has(targetId))) {
|
|
1252
|
+
const device = targetId ? this.satDeviceById.get(targetId) || targetId : null;
|
|
1253
|
+
const allowed = this.esphome
|
|
1254
|
+
.devices()
|
|
1255
|
+
.filter(d => (!device || d === device) && !this.isDeviceSilenced(d, priority));
|
|
627
1256
|
try {
|
|
628
|
-
await this.
|
|
629
|
-
delivered
|
|
1257
|
+
const counts = await Promise.all(allowed.map(d => this.esphome.announce(d, pcm, sampleRate, listen)));
|
|
1258
|
+
delivered += counts.reduce((sum, n) => sum + n, 0);
|
|
630
1259
|
}
|
|
631
1260
|
catch (e) {
|
|
632
|
-
this.log.debug(`
|
|
1261
|
+
this.log.debug(`ESPHome announce failed: ${e.message}`);
|
|
633
1262
|
}
|
|
634
1263
|
}
|
|
635
1264
|
return delivered;
|
|
636
1265
|
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Do-Not-Disturb check by satellite state id (`null` = the broadcast itself). A priority announcement
|
|
1268
|
+
* — an `alert` notification or a text starting with `!` — is never suppressed; that is the whole point
|
|
1269
|
+
* of the flag, and a water leak has to be heard at night.
|
|
1270
|
+
*/
|
|
1271
|
+
silenced(satId, priority) {
|
|
1272
|
+
if (priority) {
|
|
1273
|
+
return false;
|
|
1274
|
+
}
|
|
1275
|
+
if (this.globalDnd) {
|
|
1276
|
+
return true;
|
|
1277
|
+
}
|
|
1278
|
+
return satId ? this.dndById.get(satId) === true : false;
|
|
1279
|
+
}
|
|
1280
|
+
/** Same check, for a transport that knows its satellites by device name rather than by state id. */
|
|
1281
|
+
isDeviceSilenced(device, priority) {
|
|
1282
|
+
if (priority) {
|
|
1283
|
+
return false;
|
|
1284
|
+
}
|
|
1285
|
+
for (const [satId, dev] of this.satDeviceById) {
|
|
1286
|
+
if (dev === device) {
|
|
1287
|
+
return this.silenced(satId, priority);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
return this.globalDnd;
|
|
1291
|
+
}
|
|
637
1292
|
/**
|
|
638
1293
|
* Install the bundled default jingles (`admin/sounds/{timer,alarm}.wav`) into this instance's file
|
|
639
1294
|
* storage the first time, so they appear in the settings sound dropdown and can be played out of the
|
|
@@ -743,7 +1398,9 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
743
1398
|
if (session.stopped) {
|
|
744
1399
|
return;
|
|
745
1400
|
}
|
|
746
|
-
|
|
1401
|
+
// Priority like the jingle above: a timer the user set themselves is not an unsolicited
|
|
1402
|
+
// announcement, and hearing the gong followed by silence would be worse than either.
|
|
1403
|
+
await this.announceToSatellites(message, target, { priority: true }).catch(() => { });
|
|
747
1404
|
}
|
|
748
1405
|
if (session.stopped) {
|
|
749
1406
|
return;
|
|
@@ -794,7 +1451,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
794
1451
|
this.log.debug(`sound play failed: ${e.message}`);
|
|
795
1452
|
}
|
|
796
1453
|
if (announce) {
|
|
797
|
-
|
|
1454
|
+
// Priority, for the same reason as in startRing: the jingle already bypasses Do-Not-Disturb.
|
|
1455
|
+
await this.announceToSatellites(text, targetId, { priority: true });
|
|
798
1456
|
}
|
|
799
1457
|
}
|
|
800
1458
|
/** Decode an audio file/URL (mp3/wav/…) to mono 16-bit PCM via ffmpeg. */
|
|
@@ -833,7 +1491,18 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
833
1491
|
}
|
|
834
1492
|
}
|
|
835
1493
|
async onStateChange(id, state) {
|
|
836
|
-
if (!state
|
|
1494
|
+
if (!state) {
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
// Trigger-watched states are routed first and regardless of `ack`: a device confirms its value with
|
|
1498
|
+
// ack:true, which the filter below drops — and those are exactly the changes a trigger reacts to.
|
|
1499
|
+
if (this.triggerStateIds.has(id)) {
|
|
1500
|
+
await this.triggers?.onStateChange(id, state.val);
|
|
1501
|
+
}
|
|
1502
|
+
// Presence states come from devices too (a phone ping, the residents adapter), so they are
|
|
1503
|
+
// read here for the same reason — and one state may well be watched by both.
|
|
1504
|
+
this.presence?.update(id, state.val ?? null);
|
|
1505
|
+
if (state.ack) {
|
|
837
1506
|
return;
|
|
838
1507
|
} // ignore our own ack-writes
|
|
839
1508
|
// Announcements: `tts.text` → all satellites; `satellites.<id>.tts` → that satellite.
|
|
@@ -846,6 +1515,18 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
846
1515
|
await this.announceToSatellites(String(state.val ?? ''), satTts[1]); // satTts[1] = satellite state id
|
|
847
1516
|
return;
|
|
848
1517
|
}
|
|
1518
|
+
// Pick which wake words listen on an ESPHome satellite (comma-separated ids).
|
|
1519
|
+
const satWake = id.match(/\.satellites\.([^.]+)\.wakeWords$/);
|
|
1520
|
+
if (satWake) {
|
|
1521
|
+
await this.setSatelliteWakeWords(satWake[1], String(state.val ?? ''));
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
// Device settings: mic gain/volume, noise suppression, sensitivities, media player, firmware.
|
|
1525
|
+
const satControl = id.match(/\.satellites\.([^.]+)\.controls\.(.+)$/);
|
|
1526
|
+
if (satControl) {
|
|
1527
|
+
this.setSatelliteControl(satControl[1], satControl[2], state.val ?? null);
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
849
1530
|
// Silence a ringing timer/alarm from vis/JS.
|
|
850
1531
|
if (id.endsWith('.stopRinging')) {
|
|
851
1532
|
if (state.val) {
|
|
@@ -892,6 +1573,52 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
892
1573
|
this.alarms?.setEnabled(alarmEnable[1], !!state.val);
|
|
893
1574
|
return;
|
|
894
1575
|
}
|
|
1576
|
+
// System notifications: `notify.text` (severity notify) and `notify.alert` (urgent, ignores DND).
|
|
1577
|
+
if (id.endsWith('.notify.text') || id.endsWith('.notify.alert')) {
|
|
1578
|
+
const alert = id.endsWith('.notify.alert');
|
|
1579
|
+
const text = String(state.val ?? '');
|
|
1580
|
+
await this.setStateAsync(alert ? 'notify.alert' : 'notify.text', { val: '', ack: true });
|
|
1581
|
+
await this.notify(text, alert ? 'alert' : 'notify');
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
// Do-Not-Disturb: globally and per satellite.
|
|
1585
|
+
if (id.endsWith('.dnd') && !id.includes('.satellites.')) {
|
|
1586
|
+
this.globalDnd = !!state.val;
|
|
1587
|
+
this.log.info(`Do-Not-Disturb ${this.globalDnd ? 'on' : 'off'} (all satellites).`);
|
|
1588
|
+
await this.setStateAsync('dnd', { val: this.globalDnd, ack: true });
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
const satDnd = id.match(/\.satellites\.([^.]+)\.dnd$/);
|
|
1592
|
+
if (satDnd) {
|
|
1593
|
+
this.dndById.set(satDnd[1], !!state.val);
|
|
1594
|
+
this.log.info(`Do-Not-Disturb ${state.val ? 'on' : 'off'} for satellite '${satDnd[1]}'.`);
|
|
1595
|
+
await this.setStateAsync(`satellites.${satDnd[1]}.dnd`, { val: !!state.val, ack: true });
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
// Trigger controls: the master switch, the per-trigger enable switch and its test button.
|
|
1599
|
+
if (id.endsWith('.triggers.enabled')) {
|
|
1600
|
+
this.triggersEnabled = !!state.val;
|
|
1601
|
+
this.log.info(`Triggers ${this.triggersEnabled ? 'enabled' : 'disabled'} via triggers.enabled.`);
|
|
1602
|
+
await this.setStateAsync('triggers.enabled', { val: this.triggersEnabled, ack: true });
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
const triggerEnable = id.match(/\.triggers\.items\.([^.]+)\.enabled$/);
|
|
1606
|
+
if (triggerEnable) {
|
|
1607
|
+
if (this.triggers?.setEnabled(triggerEnable[1], !!state.val)) {
|
|
1608
|
+
this.log.info(`Trigger ${triggerEnable[1]} ${state.val ? 'enabled' : 'disabled'}.`);
|
|
1609
|
+
}
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
const triggerFire = id.match(/\.triggers\.items\.([^.]+)\.fire$/);
|
|
1613
|
+
if (triggerFire) {
|
|
1614
|
+
// Ack the button back to false right away, so it can be pressed again while the trigger runs
|
|
1615
|
+
// (a trigger that asks a question keeps us here for up to a minute).
|
|
1616
|
+
await this.setStateAsync(`triggers.items.${triggerFire[1]}.fire`, { val: false, ack: true });
|
|
1617
|
+
if (state.val && !(await this.triggers?.fireNow(triggerFire[1]))) {
|
|
1618
|
+
this.log.warn(`Trigger ${triggerFire[1]} not found.`);
|
|
1619
|
+
}
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
895
1622
|
// Long-term memory controls: add a fact, forget by id/key, clear all, edit/delete a single fact.
|
|
896
1623
|
if (id.endsWith('.memory.add')) {
|
|
897
1624
|
const text = String(state.val ?? '').trim();
|
|
@@ -933,7 +1660,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
933
1660
|
if (!id.endsWith('.text.request')) {
|
|
934
1661
|
return;
|
|
935
1662
|
}
|
|
936
|
-
if (!this.
|
|
1663
|
+
if (!this.ready) {
|
|
937
1664
|
return;
|
|
938
1665
|
}
|
|
939
1666
|
const question = String(state.val ?? '').trim();
|
|
@@ -1187,6 +1914,77 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1187
1914
|
}
|
|
1188
1915
|
return;
|
|
1189
1916
|
}
|
|
1917
|
+
// Scripts / settings: which wake words an ESPHome satellite offers and which are listening.
|
|
1918
|
+
// message = { device? } — omitted means every connected one.
|
|
1919
|
+
if (obj.command === 'getWakeWords') {
|
|
1920
|
+
const m = (obj.message || {});
|
|
1921
|
+
const devices = m.device ? [m.device] : this.esphome?.devices() || [];
|
|
1922
|
+
const payload = this.esphome
|
|
1923
|
+
? { devices: devices.map(d => ({ device: d, ...(this.esphome?.wakeWords(d) || {}) })) }
|
|
1924
|
+
: { error: 'ESPHome satellites are not enabled' };
|
|
1925
|
+
if (obj.callback) {
|
|
1926
|
+
this.sendTo(obj.from, obj.command, payload, obj.callback);
|
|
1927
|
+
}
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
// Scripts: choose the active wake words. message = { device, wakeWords: string[] | "a,b" }.
|
|
1931
|
+
if (obj.command === 'setWakeWords') {
|
|
1932
|
+
const m = (obj.message || {});
|
|
1933
|
+
const ids = (Array.isArray(m.wakeWords) ? m.wakeWords : String(m.wakeWords ?? '').split(/[,;]/))
|
|
1934
|
+
.map(s => String(s).trim())
|
|
1935
|
+
.filter(Boolean);
|
|
1936
|
+
let payload;
|
|
1937
|
+
if (!this.esphome) {
|
|
1938
|
+
payload = { error: 'ESPHome satellites are not enabled' };
|
|
1939
|
+
}
|
|
1940
|
+
else if (!m.device) {
|
|
1941
|
+
payload = { error: 'device is required' };
|
|
1942
|
+
}
|
|
1943
|
+
else if (!this.esphome.setWakeWords(m.device, ids)) {
|
|
1944
|
+
payload = { error: `satellite ${m.device} is not connected` };
|
|
1945
|
+
}
|
|
1946
|
+
else {
|
|
1947
|
+
payload = { ok: true };
|
|
1948
|
+
}
|
|
1949
|
+
if (obj.callback) {
|
|
1950
|
+
this.sendTo(obj.from, obj.command, payload, obj.callback);
|
|
1951
|
+
}
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
// Scripts: everything an ESPHome satellite exposes besides the voice pipeline.
|
|
1955
|
+
// message = { device? }; without a device, every connected one is listed.
|
|
1956
|
+
if (obj.command === 'getControls') {
|
|
1957
|
+
const m = (obj.message || {});
|
|
1958
|
+
const devices = m.device ? [m.device] : this.esphome?.devices() || [];
|
|
1959
|
+
const payload = this.esphome
|
|
1960
|
+
? { devices: devices.map(d => ({ device: d, entities: this.esphome?.entities(d) || [] })) }
|
|
1961
|
+
: { error: 'ESPHome satellites are not enabled' };
|
|
1962
|
+
if (obj.callback) {
|
|
1963
|
+
this.sendTo(obj.from, obj.command, payload, obj.callback);
|
|
1964
|
+
}
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
// Scripts: write one of them. message = { device, control: 'mic_volume', value: 2000 }.
|
|
1968
|
+
if (obj.command === 'setControl') {
|
|
1969
|
+
const m = (obj.message || {});
|
|
1970
|
+
let payload;
|
|
1971
|
+
if (!this.esphome) {
|
|
1972
|
+
payload = { error: 'ESPHome satellites are not enabled' };
|
|
1973
|
+
}
|
|
1974
|
+
else if (!m.device || !m.control) {
|
|
1975
|
+
payload = { error: 'device and control are required' };
|
|
1976
|
+
}
|
|
1977
|
+
else if (!this.esphome.setEntity(m.device, m.control, m.value)) {
|
|
1978
|
+
payload = { error: `${m.control} on ${m.device} rejected the value (see the log)` };
|
|
1979
|
+
}
|
|
1980
|
+
else {
|
|
1981
|
+
payload = { ok: true };
|
|
1982
|
+
}
|
|
1983
|
+
if (obj.callback) {
|
|
1984
|
+
this.sendTo(obj.from, obj.command, payload, obj.callback);
|
|
1985
|
+
}
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1190
1988
|
// Scripts: silence a ringing timer/alarm.
|
|
1191
1989
|
if (obj.command === 'stopRinging') {
|
|
1192
1990
|
const n = this.stopRinging();
|
|
@@ -1237,7 +2035,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1237
2035
|
const { text, targetLang } = (obj.message || {});
|
|
1238
2036
|
if (!this.agent) {
|
|
1239
2037
|
if (obj.callback) {
|
|
1240
|
-
this.sendTo(obj.from, obj.command, { error: 'agent not ready' }, obj.callback);
|
|
2038
|
+
this.sendTo(obj.from, obj.command, { error: this.ready ? 'translating needs an LLM provider' : 'agent not ready' }, obj.callback);
|
|
1241
2039
|
}
|
|
1242
2040
|
return;
|
|
1243
2041
|
}
|
|
@@ -1280,6 +2078,74 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1280
2078
|
}
|
|
1281
2079
|
return;
|
|
1282
2080
|
}
|
|
2081
|
+
// A notification routed here by the ioBroker notification-manager.
|
|
2082
|
+
if (obj.command === 'sendNotification') {
|
|
2083
|
+
const result = await this.handleSystemNotification(obj.message);
|
|
2084
|
+
if (obj.callback) {
|
|
2085
|
+
this.sendTo(obj.from, obj.command, result, obj.callback);
|
|
2086
|
+
}
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
// Speak a system notification: sendTo('assistant.0', 'notify', { text, severity, target }).
|
|
2090
|
+
if (obj.command === 'notify') {
|
|
2091
|
+
const msg = (obj.message || {});
|
|
2092
|
+
const text = String(msg.text ?? '');
|
|
2093
|
+
let result;
|
|
2094
|
+
if (!text.trim()) {
|
|
2095
|
+
result = { error: 'no text provided' };
|
|
2096
|
+
}
|
|
2097
|
+
else {
|
|
2098
|
+
const where = (msg.target || msg.room || '').trim();
|
|
2099
|
+
const ids = this.resolveTargets(where);
|
|
2100
|
+
const target = where || null;
|
|
2101
|
+
result =
|
|
2102
|
+
ids && !ids.length
|
|
2103
|
+
? { error: `unknown satellite, room or group '${where}'` }
|
|
2104
|
+
: {
|
|
2105
|
+
spoken: await this.notify(text, (0, notifications_1.parseSeverity)(msg.severity), target, msg.onlyWhenHome === true),
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2108
|
+
if (obj.callback) {
|
|
2109
|
+
this.sendTo(obj.from, obj.command, result, obj.callback);
|
|
2110
|
+
}
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
// Trigger management for scripts: list them, run one now, enable/disable one.
|
|
2114
|
+
if (obj.command === 'listTriggers') {
|
|
2115
|
+
if (obj.callback) {
|
|
2116
|
+
this.sendTo(obj.from, obj.command, { triggers: this.triggers?.list() ?? [] }, obj.callback);
|
|
2117
|
+
}
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
if (obj.command === 'fireTrigger' || obj.command === 'setTriggerEnabled') {
|
|
2121
|
+
const msg = (obj.message || {});
|
|
2122
|
+
const tid = String(msg.id ?? '').trim();
|
|
2123
|
+
let result;
|
|
2124
|
+
if (!tid) {
|
|
2125
|
+
result = { ok: false, error: 'no trigger id provided' };
|
|
2126
|
+
}
|
|
2127
|
+
else if (obj.command === 'fireTrigger') {
|
|
2128
|
+
const ok = (await this.triggers?.fireNow(tid)) === true;
|
|
2129
|
+
result = ok ? { ok } : { ok, error: `unknown trigger '${tid}'` };
|
|
2130
|
+
}
|
|
2131
|
+
else {
|
|
2132
|
+
const ok = this.triggers?.setEnabled(tid, msg.enabled !== false) === true;
|
|
2133
|
+
result = ok ? { ok } : { ok, error: `unknown trigger '${tid}'` };
|
|
2134
|
+
}
|
|
2135
|
+
if (obj.callback) {
|
|
2136
|
+
this.sendTo(obj.from, obj.command, result, obj.callback);
|
|
2137
|
+
}
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
// The other direction: ask the user something and hand the answer back to the caller.
|
|
2141
|
+
// sendTo('assistant.0', 'askUser', { question, room|target|source, timeoutMs }, cb)
|
|
2142
|
+
if (obj.command === 'askUser') {
|
|
2143
|
+
const result = await this.askUser((obj.message || {}));
|
|
2144
|
+
if (obj.callback) {
|
|
2145
|
+
this.sendTo(obj.from, obj.command, result, obj.callback);
|
|
2146
|
+
}
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
1283
2149
|
if (obj.command !== 'ask') {
|
|
1284
2150
|
return;
|
|
1285
2151
|
}
|
|
@@ -1287,7 +2153,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1287
2153
|
const text = typeof message === 'string' ? message : message?.text;
|
|
1288
2154
|
// Origin for text.querySource: caller-provided (e.g. 'telegram:Max') or 'chat' for the admin chat.
|
|
1289
2155
|
const source = (typeof message === 'string' ? '' : message?.source?.trim()) || 'chat';
|
|
1290
|
-
if (!this.
|
|
2156
|
+
if (!this.ready) {
|
|
1291
2157
|
if (obj.callback) {
|
|
1292
2158
|
this.sendTo(obj.from, obj.command, { error: 'agent not ready' }, obj.callback);
|
|
1293
2159
|
}
|
|
@@ -1415,6 +2281,25 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1415
2281
|
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
1416
2282
|
return lang === 'ru' ? 'Хорошо.' : lang === 'de' ? 'Ok.' : 'Okay.';
|
|
1417
2283
|
}
|
|
2284
|
+
// An open question from `askUser` claims the next utterance of that source as its answer — before
|
|
2285
|
+
// any tier sees it, because the NLU would read a bare "yes"/"the kitchen" as a command of its own.
|
|
2286
|
+
// We stay silent (empty reply): whoever asked decides what to say about the answer.
|
|
2287
|
+
if (this.pending.has(source)) {
|
|
2288
|
+
const asked = this.pending.question(source);
|
|
2289
|
+
if (this.pending.deliver(source, question)) {
|
|
2290
|
+
this.log.info(`Answer to "${asked}" from source '${source || 'any'}': ${question}`);
|
|
2291
|
+
return '';
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
// A routine is a macro the user wrote down, so it wins over everything that interprets: the NLU
|
|
2295
|
+
// would find "Licht" in "Gute Nacht, Licht aus" and do half of it, and the LLM would cost a
|
|
2296
|
+
// round-trip for a decision that has already been made.
|
|
2297
|
+
const routine = (0, routines_1.matchRoutine)(question, this.routines);
|
|
2298
|
+
if (routine) {
|
|
2299
|
+
this.log.info(`Routine '${routine.name}' triggered (source='${source}').`);
|
|
2300
|
+
await this.runRoutine(routine, source);
|
|
2301
|
+
return routine.reply;
|
|
2302
|
+
}
|
|
1418
2303
|
// Tier 0: rule-based NLU (device commands) — fastest, offline, free.
|
|
1419
2304
|
if (this.config.useLocalNlu) {
|
|
1420
2305
|
try {
|
|
@@ -1435,10 +2320,14 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1435
2320
|
// Tier 1a: local LLM — answers general questions offline; emits HANDOFF for anything needing tools.
|
|
1436
2321
|
// Skipped inside an ongoing conversation (history present): the tool-free local model gets no history,
|
|
1437
2322
|
// so it would answer a follow-up like "yes" context-free and swallow it — let the cloud LLM (which has
|
|
1438
|
-
// the thread and the tools) handle follow-ups instead.
|
|
1439
|
-
|
|
2323
|
+
// the thread and the tools) handle follow-ups instead. Without a cloud LLM there is nobody to hand
|
|
2324
|
+
// follow-ups to, so the local model takes them too.
|
|
2325
|
+
if (this.config.useLocalLlm && this.localLlm && (!history.length || !this.agent)) {
|
|
1440
2326
|
try {
|
|
1441
|
-
|
|
2327
|
+
// The local model has no tools: without these lines it would invent the weather and guess
|
|
2328
|
+
// who is at home.
|
|
2329
|
+
const facts = [weather, this.buildPresenceContext()].filter(Boolean).join('\n\n');
|
|
2330
|
+
const ans = await this.localLlm.ask(facts ? `${facts}\n\n${question}` : question);
|
|
1442
2331
|
if (ans && !(0, localLlm_1.isHandoff)(ans)) {
|
|
1443
2332
|
this.log.info(`Answered by local LLM (source='${source}').`);
|
|
1444
2333
|
return ans;
|
|
@@ -1455,7 +2344,10 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1455
2344
|
// Tier 2: cloud LLM with full tool access. Inject a compact device list into the system prompt so
|
|
1456
2345
|
// the model can act without a first `list_devices` round-trip (kept cached via prompt caching).
|
|
1457
2346
|
if (!this.agent) {
|
|
1458
|
-
|
|
2347
|
+
// Provider "none": no tier further down. Say so instead of failing — over voice an error is
|
|
2348
|
+
// read out as a technical message.
|
|
2349
|
+
this.log.info(`No local tier could answer and no LLM provider is configured (source='${source}').`);
|
|
2350
|
+
return this.localOnlyFallback();
|
|
1459
2351
|
}
|
|
1460
2352
|
const ctx = await this.buildDeviceContext();
|
|
1461
2353
|
const mem = this.buildMemoryContext();
|
|
@@ -1463,12 +2355,23 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1463
2355
|
const followUp = this.config.voiceEnabled ? this.followUpHint() : '';
|
|
1464
2356
|
const parts = [this.config.systemPrompt || '', followUp, mem, ctx].filter(Boolean);
|
|
1465
2357
|
const sys = parts.length ? parts.join('\n\n') : undefined;
|
|
1466
|
-
this.log.info(`Cloud LLM [${this.agent.model}] (source='${source}', context=${history.length} turn(s)${mem ? ', memory' : ''}${weather ? ', weather' : ''}) — running tool loop…`);
|
|
2358
|
+
this.log.info(`Cloud LLM [${this.agent.model || 'auto'}] (source='${source}', context=${history.length} turn(s)${mem ? ', memory' : ''}${weather ? ', weather' : ''}${this.presence?.configured ? ', presence' : ''}) — running tool loop…`);
|
|
1467
2359
|
// Prepend the current date/time (and the weather line, if a source is configured) to the user turn —
|
|
1468
2360
|
// cache-safe, see buildTimeContext. The stored history keeps the untouched question.
|
|
1469
2361
|
const questionForLlm = [this.buildTimeContext(), weather, question].filter(Boolean).join('\n\n');
|
|
1470
2362
|
return this.agent.ask(questionForLlm, sys, history);
|
|
1471
2363
|
}
|
|
2364
|
+
/** Reply when nothing local understood the request and there is no cloud LLM (provider "none"). */
|
|
2365
|
+
localOnlyFallback() {
|
|
2366
|
+
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
2367
|
+
if (lang === 'ru') {
|
|
2368
|
+
return 'Извини, этого я не поняла. Без облачной языковой модели я понимаю только простые команды для устройств, таймеры и будильники.';
|
|
2369
|
+
}
|
|
2370
|
+
if (lang === 'de') {
|
|
2371
|
+
return 'Das habe ich leider nicht verstanden. Ohne Cloud-Sprachmodell verstehe ich nur einfache Gerätebefehle, Timer und Wecker.';
|
|
2372
|
+
}
|
|
2373
|
+
return "Sorry, I didn't understand that. Without a cloud language model I only understand simple device commands, timers and alarms.";
|
|
2374
|
+
}
|
|
1472
2375
|
/**
|
|
1473
2376
|
* Compact device listing for the LLM system prompt: "Name (Room, type): stateId, …" per device,
|
|
1474
2377
|
* so the model can call set_state/get_states directly without a `list_devices` round-trip. Honors
|
|
@@ -1506,6 +2409,28 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1506
2409
|
: 'Known devices — "name (room, type): stateId". Use these stateIds directly with set_state/get_states; only call list_devices if a needed device is missing here.';
|
|
1507
2410
|
return `${header}\n${lines.join('\n')}`;
|
|
1508
2411
|
}
|
|
2412
|
+
/**
|
|
2413
|
+
* List a port we listen on in the host's used-resources registry (js-controller ≥ 8), so the admin shows it
|
|
2414
|
+
* as occupied when someone configures another instance. `common.declareUsedResources` is set because the
|
|
2415
|
+
* controller would otherwise derive a TCP port from `native.port` — ours is the UDP satellite port and is
|
|
2416
|
+
* usually not open at all. Nothing to free on shutdown: the host unblocks our entries when the process exits
|
|
2417
|
+
* and drops them on the next start. Best effort — the registry is a hint, it must never keep a server down.
|
|
2418
|
+
*/
|
|
2419
|
+
declarePort(type, port, bind, family) {
|
|
2420
|
+
const api = this;
|
|
2421
|
+
if (!api.registerUsedResource || !api.supportsFeature('CONTROLLER_USED_RESOURCES')) {
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
// A wildcard bind is left out: "not named" means every address, which is what 0.0.0.0/:: occupies.
|
|
2425
|
+
const data = { port };
|
|
2426
|
+
if (bind && bind !== '0.0.0.0' && bind !== '::') {
|
|
2427
|
+
data.bind = bind;
|
|
2428
|
+
}
|
|
2429
|
+
if (family) {
|
|
2430
|
+
data.family = family;
|
|
2431
|
+
}
|
|
2432
|
+
api.registerUsedResource(type, data).catch(e => this.log.debug(`could not register ${type} ${port}: ${e.message}`));
|
|
2433
|
+
}
|
|
1509
2434
|
/** Writable per-instance data dir via the adapter-core function (with a fallback for old runtimes). */
|
|
1510
2435
|
instanceDataDir() {
|
|
1511
2436
|
try {
|
|
@@ -1635,11 +2560,13 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1635
2560
|
return null; // nothing matched, or one part cannot run here → let the LLM answer the whole thing
|
|
1636
2561
|
}
|
|
1637
2562
|
const answers = [];
|
|
2563
|
+
let failed = false;
|
|
1638
2564
|
for (const intent of intents) {
|
|
1639
2565
|
try {
|
|
1640
2566
|
answers.push(await this.executeNluIntent(intent, source));
|
|
1641
2567
|
}
|
|
1642
2568
|
catch (e) {
|
|
2569
|
+
failed = true;
|
|
1643
2570
|
// Nothing executed yet → hand the whole utterance to the LLM. Afterwards a state has already
|
|
1644
2571
|
// been written and the LLM would repeat it, so report the failed part instead of bailing out.
|
|
1645
2572
|
if (!answers.length) {
|
|
@@ -1652,7 +2579,19 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1652
2579
|
if (intents.length > 1) {
|
|
1653
2580
|
this.log.debug(`NLU handled a combined command (${intents.length} parts).`);
|
|
1654
2581
|
}
|
|
1655
|
-
|
|
2582
|
+
const answer = answers.filter(Boolean).join(' ');
|
|
2583
|
+
// A switch command that worked needs no sentence: a beep says the same thing a second earlier.
|
|
2584
|
+
// Only for voice (a chat reads its answer), only for control intents (a query's answer IS the
|
|
2585
|
+
// information), and only when nothing failed (an error has to be spoken).
|
|
2586
|
+
if (this.config.confirmWithTone &&
|
|
2587
|
+
answer &&
|
|
2588
|
+
!failed &&
|
|
2589
|
+
intents.every(i => CONTROL_ACTIONS.has(i.action)) &&
|
|
2590
|
+
(await this.playConfirmationTone(source))) {
|
|
2591
|
+
this.log.debug(`NLU confirmed with a tone instead of "${answer}".`);
|
|
2592
|
+
return '';
|
|
2593
|
+
}
|
|
2594
|
+
return answer;
|
|
1656
2595
|
}
|
|
1657
2596
|
/**
|
|
1658
2597
|
* Can this intent run locally at all? Checked for **every** part of a combined command before anything
|
|
@@ -1861,6 +2800,10 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1861
2800
|
if (intent.action === 'listByState') {
|
|
1862
2801
|
return this.executeListByState(intent);
|
|
1863
2802
|
}
|
|
2803
|
+
// Category query ("how is the air in here") — same idea, but reports measurements.
|
|
2804
|
+
if (intent.action === 'categoryQuery') {
|
|
2805
|
+
return this.executeCategoryQuery(intent);
|
|
2806
|
+
}
|
|
1864
2807
|
const device = intent.device;
|
|
1865
2808
|
if (!device) {
|
|
1866
2809
|
return '';
|
|
@@ -1906,6 +2849,9 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1906
2849
|
}
|
|
1907
2850
|
this.log.debug(`NLU control ${device.name} (${intent.action}): set_state ${intent.stateId} = ${JSON.stringify(intent.value)}`);
|
|
1908
2851
|
await mcp.callTool('set_state', { id: intent.stateId, value: intent.value });
|
|
2852
|
+
if (!(await this.writeConfirmed(intent.stateId, intent.value))) {
|
|
2853
|
+
return pick(`${dev}${where} не ответило.`, `${dev}${where} hat nicht reagiert.`, `${dev}${where} did not respond.`);
|
|
2854
|
+
}
|
|
1909
2855
|
// Secondary write: also flip the device's on/off switch when setting a level (e.g. dimmer that needs
|
|
1910
2856
|
// an explicit power-on besides the level). Same device → covered by the write-ACL check above.
|
|
1911
2857
|
if (intent.also) {
|
|
@@ -1924,10 +2870,57 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1924
2870
|
return pick(`Цвет ${dev}${where} установлен.`, `Farbe von ${dev}${where} gesetzt.`, `Color of ${dev}${where} set.`);
|
|
1925
2871
|
}
|
|
1926
2872
|
/**
|
|
1927
|
-
* Aggregate query "which windows are open": read every window's state and name the open ones
|
|
1928
|
-
* (respecting the per-device read ACL). Answers in the interaction language (de/en/ru).
|
|
2873
|
+
* Aggregate query "which windows are open": read every window's state and name the open ones
|
|
2874
|
+
* (respecting the per-device read ACL). Answers in the interaction language (de/en/ru).
|
|
2875
|
+
*/
|
|
2876
|
+
async executeListByState(intent) {
|
|
2877
|
+
const mcp = this.mcp;
|
|
2878
|
+
if (!mcp) {
|
|
2879
|
+
throw new Error('mcp not ready');
|
|
2880
|
+
}
|
|
2881
|
+
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
2882
|
+
const ru = lang === 'ru';
|
|
2883
|
+
const de = lang === 'de';
|
|
2884
|
+
const pick = (rus, ger, eng) => (ru ? rus : de ? ger : eng);
|
|
2885
|
+
// Each window's readable state (skip read-disabled devices), deduped.
|
|
2886
|
+
const acl = this.config.deviceAcl || {};
|
|
2887
|
+
const list = (intent.devices || [])
|
|
2888
|
+
.map(d => {
|
|
2889
|
+
const ids = Object.values(d.controls);
|
|
2890
|
+
return { name: d.name, room: d.room, key: (0, tools_1.deviceKey)(ids), stateId: ids[0] };
|
|
2891
|
+
})
|
|
2892
|
+
.filter(w => w.stateId && acl[w.key]?.read !== false);
|
|
2893
|
+
if (!list.length) {
|
|
2894
|
+
return pick('Окна не найдены.', 'Keine Fenster gefunden.', 'No windows found.');
|
|
2895
|
+
}
|
|
2896
|
+
let states = [];
|
|
2897
|
+
try {
|
|
2898
|
+
const res = await mcp.callTool('get_states', { ids: list.map(w => w.stateId) });
|
|
2899
|
+
states =
|
|
2900
|
+
JSON.parse(res.text).data?.states || [];
|
|
2901
|
+
}
|
|
2902
|
+
catch {
|
|
2903
|
+
states = [];
|
|
2904
|
+
}
|
|
2905
|
+
const valueById = new Map(states.map(s => [s.id, s.value]));
|
|
2906
|
+
// A window/contact sensor reports open as a truthy value.
|
|
2907
|
+
const isOpen = (v) => v === true || v === 'true' || v === 1 || v === 'open';
|
|
2908
|
+
const open = list.filter(w => isOpen(valueById.get(w.stateId)));
|
|
2909
|
+
if (!open.length) {
|
|
2910
|
+
return pick('Все окна закрыты.', 'Alle Fenster sind geschlossen.', 'All windows are closed.');
|
|
2911
|
+
}
|
|
2912
|
+
const names = open.map(w => (w.room ? `${w.name} (${w.room})` : w.name)).join(', ');
|
|
2913
|
+
return pick(`Открыты: ${names}.`, `Offen: ${names}.`, `Open: ${names}.`);
|
|
2914
|
+
}
|
|
2915
|
+
/**
|
|
2916
|
+
* Answer a question about a kind of measurement: read every device of that kind (optionally in one
|
|
2917
|
+
* room) and name the values. One device gives a short sentence, several give a list — "wie warm ist
|
|
2918
|
+
* es überall" is one question, not five.
|
|
2919
|
+
*
|
|
2920
|
+
* Air quality additionally gets a word for the number, because an IAQ of 85 means nothing to anyone
|
|
2921
|
+
* (the scale and the wording come from the Python original, `core/hannah/iobroker.py` `_iaq_label`).
|
|
1929
2922
|
*/
|
|
1930
|
-
async
|
|
2923
|
+
async executeCategoryQuery(intent) {
|
|
1931
2924
|
const mcp = this.mcp;
|
|
1932
2925
|
if (!mcp) {
|
|
1933
2926
|
throw new Error('mcp not ready');
|
|
@@ -1936,35 +2929,57 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1936
2929
|
const ru = lang === 'ru';
|
|
1937
2930
|
const de = lang === 'de';
|
|
1938
2931
|
const pick = (rus, ger, eng) => (ru ? rus : de ? ger : eng);
|
|
1939
|
-
// Each window's readable state (skip read-disabled devices), deduped.
|
|
1940
2932
|
const acl = this.config.deviceAcl || {};
|
|
1941
2933
|
const list = (intent.devices || [])
|
|
1942
2934
|
.map(d => {
|
|
1943
2935
|
const ids = Object.values(d.controls);
|
|
1944
2936
|
return { name: d.name, room: d.room, key: (0, tools_1.deviceKey)(ids), stateId: ids[0] };
|
|
1945
2937
|
})
|
|
1946
|
-
.filter(
|
|
2938
|
+
.filter(d => d.stateId && acl[d.key]?.read !== false);
|
|
1947
2939
|
if (!list.length) {
|
|
1948
|
-
return pick('
|
|
2940
|
+
return pick('Нет подходящих датчиков.', 'Keine passenden Sensoren gefunden.', 'No matching sensors found.');
|
|
1949
2941
|
}
|
|
1950
2942
|
let states = [];
|
|
1951
2943
|
try {
|
|
1952
|
-
const res = await mcp.callTool('get_states', { ids: list.map(
|
|
2944
|
+
const res = await mcp.callTool('get_states', { ids: list.map(d => d.stateId) });
|
|
1953
2945
|
states =
|
|
1954
2946
|
JSON.parse(res.text).data?.states || [];
|
|
1955
2947
|
}
|
|
1956
2948
|
catch {
|
|
1957
2949
|
states = [];
|
|
1958
2950
|
}
|
|
1959
|
-
const valueById = new Map(states.map(
|
|
1960
|
-
|
|
1961
|
-
const
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
2951
|
+
const valueById = new Map(states.map(st => [st.id, st.value]));
|
|
2952
|
+
const parts = [];
|
|
2953
|
+
for (const device of list) {
|
|
2954
|
+
const value = valueById.get(device.stateId);
|
|
2955
|
+
if (value === undefined || value === null) {
|
|
2956
|
+
continue; // a sensor without a reading adds nothing to a spoken answer
|
|
2957
|
+
}
|
|
2958
|
+
let unit = '';
|
|
2959
|
+
try {
|
|
2960
|
+
const obj = await this.getForeignObjectAsync(device.stateId);
|
|
2961
|
+
unit = obj?.common?.unit || '';
|
|
2962
|
+
}
|
|
2963
|
+
catch {
|
|
2964
|
+
/* no unit */
|
|
2965
|
+
}
|
|
2966
|
+
let text = this.describeValue(value, lang, unit);
|
|
2967
|
+
if (intent.category === 'airQuality') {
|
|
2968
|
+
const label = iaqLabel(value, unit, lang);
|
|
2969
|
+
if (label) {
|
|
2970
|
+
text = `${text} (${label})`;
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
// In one room the room name is already given; across rooms it is the useful half.
|
|
2974
|
+
const where = intent.room ? device.name : device.room || device.name;
|
|
2975
|
+
parts.push(`${where}: ${text}`);
|
|
1965
2976
|
}
|
|
1966
|
-
|
|
1967
|
-
|
|
2977
|
+
if (!parts.length) {
|
|
2978
|
+
return pick('Нет данных.', 'Dazu liegen keine Werte vor.', 'There are no readings for that.');
|
|
2979
|
+
}
|
|
2980
|
+
const where = intent.room ? ` (${intent.room})` : '';
|
|
2981
|
+
const heading = CATEGORY_LABELS[intent.category || '']?.[ru ? 'ru' : de ? 'de' : 'en'] || '';
|
|
2982
|
+
return `${heading}${where}: ${parts.join(', ')}.`;
|
|
1968
2983
|
}
|
|
1969
2984
|
/** Human-readable rendering of a state value for NLU query responses (spoken aloud). */
|
|
1970
2985
|
describeValue(value, lang, unit = '') {
|
|
@@ -2003,8 +3018,14 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2003
3018
|
this.timers = new timers_1.TimerManager({
|
|
2004
3019
|
now: () => Date.now(),
|
|
2005
3020
|
log: this.log,
|
|
2006
|
-
onFire: t =>
|
|
2007
|
-
|
|
3021
|
+
onFire: t => {
|
|
3022
|
+
this.mirrorTimer(t, 'finished');
|
|
3023
|
+
this.onTimerFired(t);
|
|
3024
|
+
},
|
|
3025
|
+
onChange: list => {
|
|
3026
|
+
this.mirrorTimerList(list);
|
|
3027
|
+
void this.renderTimers(list).catch(e => this.log.debug(`renderTimers: ${e}`));
|
|
3028
|
+
},
|
|
2008
3029
|
});
|
|
2009
3030
|
// Cancel controls: the global "cancel all" and each per-timer `.cancel` button.
|
|
2010
3031
|
this.subscribeStates('timers.cancelAll');
|
|
@@ -2014,6 +3035,50 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2014
3035
|
this.timerObjIds.clear();
|
|
2015
3036
|
await this.restoreTimers();
|
|
2016
3037
|
}
|
|
3038
|
+
/**
|
|
3039
|
+
* Push the active timer list to the ESPHome satellites, which show a running timer on their own LED
|
|
3040
|
+
* ring and ring it themselves (feature flag TIMERS). The device keeps its own copy, so it only needs
|
|
3041
|
+
* the transitions: a timer it has not seen is `started`, one that vanished without firing was
|
|
3042
|
+
* `cancelled`, and {@link mirrorTimer} reports `finished` from the fire callback.
|
|
3043
|
+
*
|
|
3044
|
+
* A timer is mirrored to the satellite it was set from, or broadcast when it came from elsewhere
|
|
3045
|
+
* (chat, telegram, a script) — otherwise setting a timer by text would leave every speaker silent.
|
|
3046
|
+
*/
|
|
3047
|
+
mirrorTimerList(list) {
|
|
3048
|
+
if (!this.esphome) {
|
|
3049
|
+
return;
|
|
3050
|
+
}
|
|
3051
|
+
const live = new Map(list.map(t => [t.id, t]));
|
|
3052
|
+
for (const [id, previous] of this.mirroredTimers) {
|
|
3053
|
+
if (!live.has(id)) {
|
|
3054
|
+
this.mirrorTimer(previous, 'cancelled');
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
for (const timer of list) {
|
|
3058
|
+
this.mirrorTimer(timer, this.mirroredTimers.has(timer.id) ? 'updated' : 'started');
|
|
3059
|
+
}
|
|
3060
|
+
this.mirroredTimers = live;
|
|
3061
|
+
}
|
|
3062
|
+
/** Send one timer transition to the satellite it belongs to (or all, when its origin is not one). */
|
|
3063
|
+
mirrorTimer(timer, type) {
|
|
3064
|
+
if (!this.esphome) {
|
|
3065
|
+
return;
|
|
3066
|
+
}
|
|
3067
|
+
const known = this.esphome.devices();
|
|
3068
|
+
const target = known.includes(timer.source) ? timer.source : null;
|
|
3069
|
+
const event = {
|
|
3070
|
+
type,
|
|
3071
|
+
id: timer.id,
|
|
3072
|
+
name: timer.label || '',
|
|
3073
|
+
totalSeconds: timer.duration,
|
|
3074
|
+
secondsLeft: Math.max(0, Math.round((timer.fireAt - Date.now()) / 1000)),
|
|
3075
|
+
active: type === 'started' || type === 'updated',
|
|
3076
|
+
};
|
|
3077
|
+
this.esphome.timerEvent(target, event);
|
|
3078
|
+
if (type === 'cancelled' || type === 'finished') {
|
|
3079
|
+
this.mirroredTimers.delete(timer.id);
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
2017
3082
|
/** Restore timers persisted in `timers.list` across a restart (future ones rescheduled, expired dropped). */
|
|
2018
3083
|
async restoreTimers() {
|
|
2019
3084
|
if (!this.timers) {
|
|
@@ -2145,6 +3210,149 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2145
3210
|
}
|
|
2146
3211
|
return null;
|
|
2147
3212
|
}
|
|
3213
|
+
/**
|
|
3214
|
+
* Resolve an announcement target to satellite state ids: `null` means every satellite (an empty name
|
|
3215
|
+
* or `all`), an empty array means the name is unknown — the caller decides whether that is an error
|
|
3216
|
+
* (`askUser`) or a warning (an announcement).
|
|
3217
|
+
*
|
|
3218
|
+
* A concrete satellite or room wins over a configured group of the same name, so naming a group after
|
|
3219
|
+
* a room can never make that room's speaker unreachable (the rule comes from the Python original,
|
|
3220
|
+
* `core/main.py:717`).
|
|
3221
|
+
*/
|
|
3222
|
+
resolveTargets(target) {
|
|
3223
|
+
if ((0, targets_1.isBroadcast)(target)) {
|
|
3224
|
+
return null;
|
|
3225
|
+
}
|
|
3226
|
+
const wanted = String(target).trim();
|
|
3227
|
+
const direct = this.resolveSatelliteId(wanted);
|
|
3228
|
+
if (direct) {
|
|
3229
|
+
return [direct];
|
|
3230
|
+
}
|
|
3231
|
+
const group = (0, targets_1.findTarget)(wanted, this.announceTargets);
|
|
3232
|
+
if (!group) {
|
|
3233
|
+
return [];
|
|
3234
|
+
}
|
|
3235
|
+
const ids = [...new Set(group.members.map(m => this.resolveSatelliteId(m)).filter((id) => !!id))];
|
|
3236
|
+
if (!ids.length) {
|
|
3237
|
+
this.log.warn(`Target '${group.name}' has no known satellite among: ${group.members.join(', ')}`);
|
|
3238
|
+
}
|
|
3239
|
+
else if (ids.length < group.members.length) {
|
|
3240
|
+
this.log.debug(`Target '${group.name}' → ${ids.join(', ')} (some members are not known satellites)`);
|
|
3241
|
+
}
|
|
3242
|
+
return ids;
|
|
3243
|
+
}
|
|
3244
|
+
/**
|
|
3245
|
+
* Resolve what a caller named — a satellite state id (`satellites.kitchen`, `kitchen`), a room name or
|
|
3246
|
+
* a device name — to a known satellite state id, or null if we have never seen it. Rooms resolve like
|
|
3247
|
+
* ids because a satellite's state id *is* its sanitised room name (see {@link satelliteStateId}).
|
|
3248
|
+
*/
|
|
3249
|
+
resolveSatelliteId(nameOrId) {
|
|
3250
|
+
const raw = (nameOrId || '').trim();
|
|
3251
|
+
if (!raw) {
|
|
3252
|
+
return null;
|
|
3253
|
+
}
|
|
3254
|
+
const bare = raw.replace(`${this.namespace}.`, '').replace(/^satellites\./, '');
|
|
3255
|
+
const id = this.satelliteStateId(bare, '');
|
|
3256
|
+
if (this.satDeviceById.has(id) || this.nativeSatFrom.has(id)) {
|
|
3257
|
+
return id;
|
|
3258
|
+
}
|
|
3259
|
+
// Not an id — maybe the device name behind one.
|
|
3260
|
+
return this.announceTargetForSource(raw);
|
|
3261
|
+
}
|
|
3262
|
+
/**
|
|
3263
|
+
* Ask the user something and wait for the answer: speak the question on the target satellite(s), have
|
|
3264
|
+
* them re-open the microphone, and resolve with whatever is said next there — the utterance is routed
|
|
3265
|
+
* to the caller instead of to the NLU/LLM (see {@link PendingQuestions}).
|
|
3266
|
+
*
|
|
3267
|
+
* `target`/`room` pick a satellite (omit both → every satellite, first answer wins). `source` instead
|
|
3268
|
+
* arms a text channel (e.g. `'chat'`, `'telegram:Max'`) without speaking anything: there the caller
|
|
3269
|
+
* sends the question itself and we only claim the reply.
|
|
3270
|
+
*
|
|
3271
|
+
* This is what the proactive triggers (roadmap A2) will use, and it is available to scripts:
|
|
3272
|
+
* `sendTo('assistant.0', 'askUser', { question: 'Fenster schließen?', room: 'Küche' }, cb)`.
|
|
3273
|
+
*/
|
|
3274
|
+
async askUser(msg) {
|
|
3275
|
+
const question = String(msg.question ?? msg.text ?? '').trim();
|
|
3276
|
+
if (!question) {
|
|
3277
|
+
return { error: 'no question provided' };
|
|
3278
|
+
}
|
|
3279
|
+
const timeoutMs = Math.max(1000, Number(msg.timeoutMs) || ask_1.ASK_TIMEOUT_MS);
|
|
3280
|
+
// Text channel: arm the named sources only — the caller does its own output.
|
|
3281
|
+
const sources = (Array.isArray(msg.source) ? msg.source : msg.source ? [msg.source] : [])
|
|
3282
|
+
.map(s => String(s).trim())
|
|
3283
|
+
.filter(Boolean);
|
|
3284
|
+
if (sources.length) {
|
|
3285
|
+
this.log.info(`askUser (waiting on ${sources.join(', ')}, ${timeoutMs} ms): ${question}`);
|
|
3286
|
+
const answer = await this.pending.ask(sources, question, timeoutMs);
|
|
3287
|
+
return answer === null ? { question, timeout: true } : { question, answer };
|
|
3288
|
+
}
|
|
3289
|
+
// Voice: resolve the target before arming, so a typo doesn't leave a question hanging. A group
|
|
3290
|
+
// or a person resolves to several satellites — the question is asked on all of them and the
|
|
3291
|
+
// first answer counts, which is what asking a room full of speakers means.
|
|
3292
|
+
const target = (msg.target || msg.room || '').trim();
|
|
3293
|
+
const targetIds = this.resolveTargets(target);
|
|
3294
|
+
if (targetIds && !targetIds.length) {
|
|
3295
|
+
return { error: `unknown satellite, room or group '${target}'` };
|
|
3296
|
+
}
|
|
3297
|
+
const keys = targetIds ? targetIds.map(id => this.satDeviceById.get(id) || id) : [ask_1.ANY_SOURCE];
|
|
3298
|
+
this.log.info(`askUser → ${targetIds ? targetIds.join(', ') : 'all satellites'} (${timeoutMs} ms): ${question}`);
|
|
3299
|
+
if (!(await this.announceToSatellites(question, target || null, { listen: true }))) {
|
|
3300
|
+
return { error: 'question not asked — no satellite reachable' };
|
|
3301
|
+
}
|
|
3302
|
+
// Armed only now, on purpose: the answer cannot arrive before the device has played the question,
|
|
3303
|
+
// so arming earlier would just risk claiming an utterance that was never meant as the answer.
|
|
3304
|
+
const answer = await this.pending.ask(keys, question, timeoutMs);
|
|
3305
|
+
if (answer === null) {
|
|
3306
|
+
this.log.info(`askUser: no answer within ${timeoutMs} ms.`);
|
|
3307
|
+
return { question, timeout: true };
|
|
3308
|
+
}
|
|
3309
|
+
return { question, answer };
|
|
3310
|
+
}
|
|
3311
|
+
/**
|
|
3312
|
+
* Wait for the device to confirm a write, if the user asked for that (`verifyWrites`). ioBroker's
|
|
3313
|
+
* convention is that a command is written with `ack:false` and the device echoes the value back with
|
|
3314
|
+
* `ack:true` once it really happened — so without this check the assistant reports success for a
|
|
3315
|
+
* lamp that never answered.
|
|
3316
|
+
*
|
|
3317
|
+
* Polled rather than subscribed: a one-off poll of one state is cheap, while a temporary
|
|
3318
|
+
* subscription would have to be registered, routed through `onStateChange` and torn down again for
|
|
3319
|
+
* every single command. Returns true when it is confirmed — and also when verification is switched
|
|
3320
|
+
* off or impossible, because a check that cannot run must not turn into a false alarm.
|
|
3321
|
+
*/
|
|
3322
|
+
async writeConfirmed(stateId, expected) {
|
|
3323
|
+
if (!this.config.verifyWrites || !stateId) {
|
|
3324
|
+
return true;
|
|
3325
|
+
}
|
|
3326
|
+
const deadline = Date.now() + ACK_TIMEOUT_MS;
|
|
3327
|
+
let last;
|
|
3328
|
+
while (Date.now() < deadline) {
|
|
3329
|
+
await this.delay(ACK_POLL_MS);
|
|
3330
|
+
last = await this.getForeignStateAsync(stateId).catch(() => null);
|
|
3331
|
+
if (last?.ack && valuesMatch(last.val, expected)) {
|
|
3332
|
+
this.log.debug(`write to ${stateId} confirmed by the device.`);
|
|
3333
|
+
return true;
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
this.log.warn(`No confirmation for ${stateId} within ${ACK_TIMEOUT_MS} ms ` +
|
|
3337
|
+
`(last: ${JSON.stringify(last?.val)}, ack=${String(last?.ack)}). ` +
|
|
3338
|
+
'If this device never acknowledges, switch "Verify device feedback" off.');
|
|
3339
|
+
return false;
|
|
3340
|
+
}
|
|
3341
|
+
/**
|
|
3342
|
+
* Play the confirmation beep on the satellite a command came from. Returns false when there is nowhere
|
|
3343
|
+
* to play it (a text channel, or no satellite reachable) — the caller then speaks the reply instead,
|
|
3344
|
+
* because a confirmation nobody hears is worse than one that costs a TTS call.
|
|
3345
|
+
*/
|
|
3346
|
+
async playConfirmationTone(source) {
|
|
3347
|
+
const target = this.announceTargetForSource(source);
|
|
3348
|
+
if (!target) {
|
|
3349
|
+
return false; // chat/telegram/unknown origin: there is no speaker to beep on
|
|
3350
|
+
}
|
|
3351
|
+
const { pcm, sampleRate } = (0, tone_1.confirmationTone)();
|
|
3352
|
+
// Priority: this is the answer to something the user just asked for, not an unsolicited noise.
|
|
3353
|
+
const delivered = await this.deliverPcm(pcm, sampleRate, target, true);
|
|
3354
|
+
return delivered > 0;
|
|
3355
|
+
}
|
|
2148
3356
|
/** Execute a timer intent (set/query/cancel) from the NLU and return a spoken-style reply. */
|
|
2149
3357
|
executeTimerIntent(intent, source) {
|
|
2150
3358
|
const mgr = this.timers;
|
|
@@ -2560,6 +3768,421 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2560
3768
|
},
|
|
2561
3769
|
];
|
|
2562
3770
|
}
|
|
3771
|
+
// ── Announcements by name (roadmap B2) ──────────────────────────────────
|
|
3772
|
+
/**
|
|
3773
|
+
* Tool that lets the model speak somewhere instead of answering: "tell Denis dinner is ready", "let
|
|
3774
|
+
* everyone upstairs know". Without it a model can read and switch states but has no way to make a
|
|
3775
|
+
* speaker say something, so the configured groups and people would only be reachable from scripts.
|
|
3776
|
+
*
|
|
3777
|
+
* The configured names go into the description, because the model cannot discover them otherwise.
|
|
3778
|
+
*/
|
|
3779
|
+
buildAnnounceTool() {
|
|
3780
|
+
const named = (0, targets_1.describeTargets)(this.announceTargets);
|
|
3781
|
+
return {
|
|
3782
|
+
name: 'announce',
|
|
3783
|
+
description: `Speak a message out loud on a voice satellite — use it when the user asks you to tell ` +
|
|
3784
|
+
`somebody something, or to announce something in a room. Not for answering the user: your ` +
|
|
3785
|
+
`normal reply is already spoken. "target" is a room, a satellite name${named ? `, or one of the configured ${named}` : ''}; leave it out to announce everywhere.`,
|
|
3786
|
+
parameters: {
|
|
3787
|
+
type: 'object',
|
|
3788
|
+
properties: {
|
|
3789
|
+
text: { type: 'string', description: 'What to say, as one spoken sentence' },
|
|
3790
|
+
target: { type: 'string', description: 'Room, satellite, group or person (empty = all)' },
|
|
3791
|
+
},
|
|
3792
|
+
required: ['text'],
|
|
3793
|
+
additionalProperties: false,
|
|
3794
|
+
},
|
|
3795
|
+
run: async (args) => {
|
|
3796
|
+
// The arguments come from the model, so a non-string is simply not a text.
|
|
3797
|
+
const text = typeof args.text === 'string' ? args.text.trim() : '';
|
|
3798
|
+
if (!text) {
|
|
3799
|
+
return JSON.stringify({ ok: false, error: 'no text given' });
|
|
3800
|
+
}
|
|
3801
|
+
const target = typeof args.target === 'string' ? args.target.trim() : '';
|
|
3802
|
+
const spoken = await this.announceToSatellites(text, target || null);
|
|
3803
|
+
return spoken
|
|
3804
|
+
? JSON.stringify({ ok: true, data: { spoken } })
|
|
3805
|
+
: JSON.stringify({
|
|
3806
|
+
ok: false,
|
|
3807
|
+
error: target
|
|
3808
|
+
? `nothing was played — '${target}' is no known satellite, room or group, or it is offline`
|
|
3809
|
+
: 'nothing was played — no satellite is reachable',
|
|
3810
|
+
});
|
|
3811
|
+
},
|
|
3812
|
+
};
|
|
3813
|
+
}
|
|
3814
|
+
// ── Presence: who is at home (roadmap B1) ───────────────────────────────
|
|
3815
|
+
/**
|
|
3816
|
+
* Build the presence tracker from the configured states, subscribe to them and mirror the result into
|
|
3817
|
+
* `presence.*`. The values are read once up front, because a presence state only reports when it
|
|
3818
|
+
* changes — without that, the assistant would believe nobody is home until the first person moves.
|
|
3819
|
+
*/
|
|
3820
|
+
async setupPresence() {
|
|
3821
|
+
const entries = (0, presence_1.parsePresenceRows)(this.config.presence);
|
|
3822
|
+
this.presence = new presence_1.PresenceTracker({
|
|
3823
|
+
entries,
|
|
3824
|
+
log: this.log,
|
|
3825
|
+
onChange: list => void this.renderPresence(list).catch(e => this.log.debug(`renderPresence: ${e}`)),
|
|
3826
|
+
onArrival: who => {
|
|
3827
|
+
this.setStateAsync('presence.lastArrival', { val: who.name, ack: true }).catch(() => { });
|
|
3828
|
+
},
|
|
3829
|
+
onDeparture: who => {
|
|
3830
|
+
this.setStateAsync('presence.lastDeparture', { val: who.name, ack: true }).catch(() => { });
|
|
3831
|
+
},
|
|
3832
|
+
});
|
|
3833
|
+
if (!entries.length) {
|
|
3834
|
+
return;
|
|
3835
|
+
}
|
|
3836
|
+
for (const id of this.presence.stateIds()) {
|
|
3837
|
+
await this.subscribeForeignStatesAsync(id).catch(e => this.log.warn(`presence: cannot watch '${id}': ${e.message}`));
|
|
3838
|
+
const st = await this.getForeignStateAsync(id).catch(() => null);
|
|
3839
|
+
this.presence.update(id, st?.val ?? null);
|
|
3840
|
+
}
|
|
3841
|
+
await this.renderPresence(this.presence.list());
|
|
3842
|
+
this.log.info(`Presence: ${entries.length} source(s) configured, ${this.presence.home().length} at home right now.`);
|
|
3843
|
+
}
|
|
3844
|
+
/** Mirror who is at home into `presence.{anyoneHome,count,list}`. */
|
|
3845
|
+
async renderPresence(list) {
|
|
3846
|
+
const home = list.filter(p => p.home === true && p.kind !== 'pet');
|
|
3847
|
+
await this.setStateAsync('presence.anyoneHome', { val: home.length > 0, ack: true });
|
|
3848
|
+
await this.setStateAsync('presence.count', { val: home.length, ack: true });
|
|
3849
|
+
await this.setStateAsync('presence.list', { val: JSON.stringify(list), ack: true });
|
|
3850
|
+
}
|
|
3851
|
+
/**
|
|
3852
|
+
* Who is at home, as one line for the user turn — never for the (prompt-cached) system prompt: it
|
|
3853
|
+
* changes with every arrival and would bust the cache, device list included. Empty when no presence
|
|
3854
|
+
* source is configured or nothing is known yet.
|
|
3855
|
+
*/
|
|
3856
|
+
buildPresenceContext() {
|
|
3857
|
+
if (!this.presence?.configured) {
|
|
3858
|
+
return '';
|
|
3859
|
+
}
|
|
3860
|
+
return (0, presence_1.buildPresencePrompt)(this.presence.list(), String(this.config.voiceLanguage || this.language || 'en'));
|
|
3861
|
+
}
|
|
3862
|
+
/**
|
|
3863
|
+
* Should an announcement that asked to be spoken only to an occupied house be held back? With no
|
|
3864
|
+
* presence source configured the answer is no — "nobody configured" must not read as "nobody home",
|
|
3865
|
+
* or the feature would silence every announcement the moment someone ticks the box.
|
|
3866
|
+
*/
|
|
3867
|
+
emptyHouse() {
|
|
3868
|
+
return this.presence?.configured === true && !this.presence.anyoneHome();
|
|
3869
|
+
}
|
|
3870
|
+
// ── System notifications (roadmap A3) ───────────────────────────────────
|
|
3871
|
+
/**
|
|
3872
|
+
* Speak a system notification. The text is cleaned of ioBroker's origin prefixes and — unless the
|
|
3873
|
+
* severity is `direct` or rewording is switched off — reworded by the LLM into one spoken sentence
|
|
3874
|
+
* with a tone matching the severity, because the raw texts are written for a log viewer.
|
|
3875
|
+
*
|
|
3876
|
+
* An `alert` is delivered as a **priority** announcement, so it is heard even on a satellite set to
|
|
3877
|
+
* Do-Not-Disturb; everything else respects DND. Returns how many channels it reached.
|
|
3878
|
+
*/
|
|
3879
|
+
async notify(raw, severity, target = null, onlyWhenHome = false) {
|
|
3880
|
+
const clean = (0, notifications_1.cleanupNotificationText)(raw);
|
|
3881
|
+
if (!clean) {
|
|
3882
|
+
return 0;
|
|
3883
|
+
}
|
|
3884
|
+
let text = clean;
|
|
3885
|
+
if (severity !== 'direct' && this.config.notifyRephrase !== false && this.agent) {
|
|
3886
|
+
text = await this.agent.rewordNotification(clean, (0, notifications_1.toneFor)(severity), this.config.voiceLanguage || this.language || '', this.config.systemPrompt);
|
|
3887
|
+
}
|
|
3888
|
+
this.log.info(`Notification (${severity}): ${text}`);
|
|
3889
|
+
this.setStateAsync('notify.last', { val: text, ack: true }).catch(() => { });
|
|
3890
|
+
return this.announceToSatellites(text, target, { priority: (0, notifications_1.bypassesDnd)(severity), onlyWhenHome });
|
|
3891
|
+
}
|
|
3892
|
+
/**
|
|
3893
|
+
* Handle a notification routed here by the ioBroker notification-manager (`sendNotification` message,
|
|
3894
|
+
* advertised through `common.supportedMessages.notifications`). Answers `{ sent }`, which tells the
|
|
3895
|
+
* manager whether it may mark the notification as handled — so we only claim it when it was actually
|
|
3896
|
+
* spoken somewhere.
|
|
3897
|
+
*
|
|
3898
|
+
* The payload shape belongs to the notification-manager and is parsed defensively
|
|
3899
|
+
* ({@link flattenNotification}): a changed field degrades the spoken sentence, it never throws.
|
|
3900
|
+
*/
|
|
3901
|
+
async handleSystemNotification(message) {
|
|
3902
|
+
const flat = (0, notifications_1.flattenNotification)(message, String(this.config.voiceLanguage || this.language || 'en'));
|
|
3903
|
+
if (!flat.text) {
|
|
3904
|
+
this.log.warn('Notification from the notification-manager had nothing to speak.');
|
|
3905
|
+
return { sent: false };
|
|
3906
|
+
}
|
|
3907
|
+
this.log.debug(`Notification (${flat.severity}) from category '${flat.category}': ${flat.text}`);
|
|
3908
|
+
const delivered = await this.notify(flat.text, flat.severity);
|
|
3909
|
+
return { sent: delivered > 0 };
|
|
3910
|
+
}
|
|
3911
|
+
// ── Proactive triggers (roadmap A2) ─────────────────────────────────────
|
|
3912
|
+
/**
|
|
3913
|
+
* Build the trigger engine from the configured definitions, mirror it into `triggers.*` and subscribe
|
|
3914
|
+
* to exactly the foreign states the conditions reference — nothing broader, because a wildcard
|
|
3915
|
+
* subscription on a busy system would wake this adapter for every state in the house.
|
|
3916
|
+
*/
|
|
3917
|
+
async setupTriggers() {
|
|
3918
|
+
this.triggers = new triggers_1.TriggerEngine({
|
|
3919
|
+
now: () => Date.now(),
|
|
3920
|
+
log: this.log,
|
|
3921
|
+
getState: async (id) => {
|
|
3922
|
+
const st = await this.getForeignStateAsync(id).catch(() => null);
|
|
3923
|
+
return st?.val ?? undefined;
|
|
3924
|
+
},
|
|
3925
|
+
execute: def => this.executeTrigger(def),
|
|
3926
|
+
onChange: status => void this.renderTriggers(status).catch(e => this.log.debug(`renderTriggers: ${e}`)),
|
|
3927
|
+
});
|
|
3928
|
+
this.subscribeStates('triggers.enabled');
|
|
3929
|
+
this.subscribeStates('triggers.items.*');
|
|
3930
|
+
// Per-trigger objects are rebuilt from the configuration, so drop whatever a previous run left.
|
|
3931
|
+
await this.delObjectAsync('triggers.items', { recursive: true }).catch(() => { });
|
|
3932
|
+
this.triggerObjIds.clear();
|
|
3933
|
+
const master = await this.getStateAsync('triggers.enabled');
|
|
3934
|
+
this.triggersEnabled = master?.val !== false;
|
|
3935
|
+
const persisted = await this.readTriggerStatus();
|
|
3936
|
+
this.triggers.load(this.getTriggerDefs());
|
|
3937
|
+
if (persisted.length) {
|
|
3938
|
+
this.triggers.restore(persisted);
|
|
3939
|
+
}
|
|
3940
|
+
await this.syncTriggerSubscriptions();
|
|
3941
|
+
// Remember the current values before the first change arrives, so a device that re-reports an
|
|
3942
|
+
// unchanged value right after the start does not look like a transition.
|
|
3943
|
+
await this.triggers.prime();
|
|
3944
|
+
const n = this.triggers.list().length;
|
|
3945
|
+
if (n) {
|
|
3946
|
+
this.log.info(`Triggers: ${n} loaded, watching ${this.triggerStateIds.size} state(s)${this.triggersEnabled ? '' : ' — all suppressed by triggers.enabled'}.`);
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3949
|
+
/** The configured triggers, with the table's JSON columns parsed (see {@link parseTriggerRows}). */
|
|
3950
|
+
getTriggerDefs() {
|
|
3951
|
+
return (0, triggers_1.parseTriggerRows)(this.config.triggers, {
|
|
3952
|
+
rephraseDefault: this.config.triggerRephrase === true,
|
|
3953
|
+
warn: message => this.log.warn(message),
|
|
3954
|
+
});
|
|
3955
|
+
}
|
|
3956
|
+
/** Subscribe to the states the loaded triggers watch, and drop subscriptions nothing watches any more. */
|
|
3957
|
+
async syncTriggerSubscriptions() {
|
|
3958
|
+
const wanted = new Set(this.triggers?.stateIds() ?? []);
|
|
3959
|
+
// A presence source may be the same state a trigger watches — dropping the subscription here would
|
|
3960
|
+
// silently stop the presence updates too.
|
|
3961
|
+
const keep = new Set(this.presence?.stateIds() ?? []);
|
|
3962
|
+
for (const id of [...this.triggerStateIds]) {
|
|
3963
|
+
if (!wanted.has(id)) {
|
|
3964
|
+
if (!keep.has(id)) {
|
|
3965
|
+
await this.unsubscribeForeignStatesAsync(id).catch(() => { });
|
|
3966
|
+
}
|
|
3967
|
+
this.triggerStateIds.delete(id);
|
|
3968
|
+
}
|
|
3969
|
+
}
|
|
3970
|
+
for (const id of wanted) {
|
|
3971
|
+
if (!this.triggerStateIds.has(id)) {
|
|
3972
|
+
await this.subscribeForeignStatesAsync(id).catch(e => this.log.warn(`trigger: cannot watch '${id}': ${e.message}`));
|
|
3973
|
+
this.triggerStateIds.add(id);
|
|
3974
|
+
}
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3977
|
+
/** Live status (enabled flag, last fire) persisted in `triggers.list`, so a restart keeps both. */
|
|
3978
|
+
async readTriggerStatus() {
|
|
3979
|
+
try {
|
|
3980
|
+
const st = await this.getStateAsync('triggers.list');
|
|
3981
|
+
if (typeof st?.val === 'string' && st.val) {
|
|
3982
|
+
const arr = JSON.parse(st.val);
|
|
3983
|
+
return Array.isArray(arr) ? arr : [];
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3986
|
+
catch (e) {
|
|
3987
|
+
this.log.debug(`readTriggerStatus failed: ${e.message}`);
|
|
3988
|
+
}
|
|
3989
|
+
return [];
|
|
3990
|
+
}
|
|
3991
|
+
/** Mirror the trigger status into the summary states and the per-trigger `triggers.items.<id>` objects. */
|
|
3992
|
+
async renderTriggers(status) {
|
|
3993
|
+
await this.setStateAsync('triggers.count', { val: status.length, ack: true });
|
|
3994
|
+
await this.setStateAsync('triggers.list', { val: JSON.stringify(status), ack: true });
|
|
3995
|
+
const wanted = new Set(status.map(t => t.id));
|
|
3996
|
+
for (const id of [...this.triggerObjIds]) {
|
|
3997
|
+
if (!wanted.has(id)) {
|
|
3998
|
+
await this.delObjectAsync(`triggers.items.${id}`, { recursive: true }).catch(() => { });
|
|
3999
|
+
this.triggerObjIds.delete(id);
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
if (status.length) {
|
|
4003
|
+
await this.setObjectNotExistsAsync('triggers.items', {
|
|
4004
|
+
type: 'channel',
|
|
4005
|
+
common: { name: 'Triggers' },
|
|
4006
|
+
native: {},
|
|
4007
|
+
});
|
|
4008
|
+
}
|
|
4009
|
+
for (const t of status) {
|
|
4010
|
+
await this.ensureTriggerObject(t);
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
/** Create (once) and update the `triggers.items.<id>.*` states for a single trigger. */
|
|
4014
|
+
async ensureTriggerObject(t) {
|
|
4015
|
+
const base = `triggers.items.${t.id}`;
|
|
4016
|
+
if (!this.triggerObjIds.has(t.id)) {
|
|
4017
|
+
await this.setObjectNotExistsAsync(base, {
|
|
4018
|
+
type: 'channel',
|
|
4019
|
+
common: { name: t.name || t.id },
|
|
4020
|
+
native: {},
|
|
4021
|
+
});
|
|
4022
|
+
const mk = (sub, common) => this.setObjectNotExistsAsync(`${base}.${sub}`, { type: 'state', common, native: {} });
|
|
4023
|
+
await mk('name', { name: 'Name', type: 'string', role: 'text', read: true, write: false });
|
|
4024
|
+
await mk('lastFired', { name: 'Last fired', type: 'number', role: 'value.time', read: true, write: false });
|
|
4025
|
+
await mk('nextFireAt', {
|
|
4026
|
+
name: 'Next scheduled fire (time triggers)',
|
|
4027
|
+
type: 'number',
|
|
4028
|
+
role: 'value.time',
|
|
4029
|
+
read: true,
|
|
4030
|
+
write: false,
|
|
4031
|
+
});
|
|
4032
|
+
await mk('pendingUntil', {
|
|
4033
|
+
name: 'Pending delay runs at (0 = nothing pending)',
|
|
4034
|
+
type: 'number',
|
|
4035
|
+
role: 'value.time',
|
|
4036
|
+
read: true,
|
|
4037
|
+
write: false,
|
|
4038
|
+
});
|
|
4039
|
+
await mk('enabled', {
|
|
4040
|
+
name: 'Enabled',
|
|
4041
|
+
type: 'boolean',
|
|
4042
|
+
role: 'switch.enable',
|
|
4043
|
+
read: true,
|
|
4044
|
+
write: true,
|
|
4045
|
+
def: true,
|
|
4046
|
+
});
|
|
4047
|
+
await mk('fire', {
|
|
4048
|
+
name: 'Run this trigger now (ignores cooldown and delay)',
|
|
4049
|
+
type: 'boolean',
|
|
4050
|
+
role: 'button',
|
|
4051
|
+
read: false,
|
|
4052
|
+
write: true,
|
|
4053
|
+
def: false,
|
|
4054
|
+
});
|
|
4055
|
+
this.triggerObjIds.add(t.id);
|
|
4056
|
+
}
|
|
4057
|
+
await this.setStateAsync(`${base}.name`, { val: t.name, ack: true });
|
|
4058
|
+
await this.setStateAsync(`${base}.lastFired`, { val: t.lastFired, ack: true });
|
|
4059
|
+
await this.setStateAsync(`${base}.nextFireAt`, { val: t.nextFireAt, ack: true });
|
|
4060
|
+
await this.setStateAsync(`${base}.pendingUntil`, { val: t.pendingUntil, ack: true });
|
|
4061
|
+
await this.setStateAsync(`${base}.enabled`, { val: t.enabled, ack: true });
|
|
4062
|
+
}
|
|
4063
|
+
/**
|
|
4064
|
+
* Run a trigger: either ask the user and act on the answer, or run its actions. The master switch
|
|
4065
|
+
* (`triggers.enabled`) is checked here rather than in the engine, so the schedule keeps running and
|
|
4066
|
+
* only the visible/audible effect is suppressed.
|
|
4067
|
+
*/
|
|
4068
|
+
async executeTrigger(def) {
|
|
4069
|
+
if (!this.triggersEnabled) {
|
|
4070
|
+
this.log.debug(`trigger '${def.id}' suppressed — triggers.enabled is off`);
|
|
4071
|
+
return;
|
|
4072
|
+
}
|
|
4073
|
+
this.setStateAsync('triggers.lastFired', { val: def.name || def.id, ack: true }).catch(() => { });
|
|
4074
|
+
if (def.ask) {
|
|
4075
|
+
await this.askTrigger(def);
|
|
4076
|
+
return;
|
|
4077
|
+
}
|
|
4078
|
+
for (const action of (0, triggers_1.effectiveActions)(def)) {
|
|
4079
|
+
await this.runTriggerAction(def, action);
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
/**
|
|
4083
|
+
* Where a trigger speaks or asks. Empty (or Hannah's `all`, which people copy from its examples) means
|
|
4084
|
+
* every satellite; a configured room we do not know is worth a warning, because announcing everywhere
|
|
4085
|
+
* instead of in one room is the kind of surprise that should not stay silent in the log.
|
|
4086
|
+
*/
|
|
4087
|
+
triggerTarget(def, room) {
|
|
4088
|
+
if ((0, targets_1.isBroadcast)(room)) {
|
|
4089
|
+
return null;
|
|
4090
|
+
}
|
|
4091
|
+
const wanted = String(room).trim();
|
|
4092
|
+
const ids = this.resolveTargets(wanted);
|
|
4093
|
+
if (ids && !ids.length) {
|
|
4094
|
+
this.log.warn(`trigger '${def.id}': '${wanted}' is no known satellite, room or group — using all of them`);
|
|
4095
|
+
return null;
|
|
4096
|
+
}
|
|
4097
|
+
return wanted;
|
|
4098
|
+
}
|
|
4099
|
+
/** Ask the trigger's question, then let the first matching response rule decide what happens. */
|
|
4100
|
+
async askTrigger(def) {
|
|
4101
|
+
const question = await this.triggerText(def, def.ask || '');
|
|
4102
|
+
const target = this.triggerTarget(def, def.room);
|
|
4103
|
+
const result = await this.askUser({ question, target: target || undefined });
|
|
4104
|
+
if (result.error) {
|
|
4105
|
+
this.log.warn(`trigger '${def.id}': could not ask — ${result.error}`);
|
|
4106
|
+
return;
|
|
4107
|
+
}
|
|
4108
|
+
if (result.timeout || !result.answer) {
|
|
4109
|
+
this.log.info(`trigger '${def.id}': no answer — nothing done`);
|
|
4110
|
+
return;
|
|
4111
|
+
}
|
|
4112
|
+
const rules = def.onResponse || [];
|
|
4113
|
+
let fallback;
|
|
4114
|
+
for (const rule of rules) {
|
|
4115
|
+
const want = (rule.match || '').trim();
|
|
4116
|
+
if (!want) {
|
|
4117
|
+
fallback ??= rule; // a rule without a category is the "didn't understand" case
|
|
4118
|
+
continue;
|
|
4119
|
+
}
|
|
4120
|
+
if (!this.agent) {
|
|
4121
|
+
this.log.warn(`trigger '${def.id}': no LLM to match the answer against '${want}'`);
|
|
4122
|
+
break;
|
|
4123
|
+
}
|
|
4124
|
+
if (await this.agent.classify(result.answer, want)) {
|
|
4125
|
+
this.log.info(`trigger '${def.id}': answer matched '${want}'`);
|
|
4126
|
+
await this.runTriggerAction(def, rule);
|
|
4127
|
+
return;
|
|
4128
|
+
}
|
|
4129
|
+
}
|
|
4130
|
+
if (fallback) {
|
|
4131
|
+
await this.runTriggerAction(def, fallback);
|
|
4132
|
+
}
|
|
4133
|
+
else {
|
|
4134
|
+
this.log.info(`trigger '${def.id}': answer "${result.answer}" matched no rule`);
|
|
4135
|
+
}
|
|
4136
|
+
}
|
|
4137
|
+
/** Speak a trigger's `say` and/or write its `setState` — shared by the actions and the response rules. */
|
|
4138
|
+
async runTriggerAction(def, action, fallbackTarget = null) {
|
|
4139
|
+
const say = (action.say || '').trim();
|
|
4140
|
+
if (say) {
|
|
4141
|
+
const room = 'room' in action ? action.room : def.room;
|
|
4142
|
+
// A routine speaks where it was asked for, unless the action names a room of its own.
|
|
4143
|
+
const target = this.triggerTarget(def, room) ?? fallbackTarget;
|
|
4144
|
+
await this.announceToSatellites(await this.triggerText(def, say), target);
|
|
4145
|
+
}
|
|
4146
|
+
const write = action.setState;
|
|
4147
|
+
if (write?.id) {
|
|
4148
|
+
// Deliberately gated: someone who switched device control off does not expect a trigger to
|
|
4149
|
+
// write states behind that setting.
|
|
4150
|
+
if (!this.config.allowWriteStates) {
|
|
4151
|
+
this.log.warn(`trigger '${def.id}': not writing ${write.id} — device control is disabled in the settings`);
|
|
4152
|
+
return;
|
|
4153
|
+
}
|
|
4154
|
+
try {
|
|
4155
|
+
await this.setForeignStateAsync(write.id, { val: write.value, ack: false });
|
|
4156
|
+
this.log.info(`trigger '${def.id}': ${write.id} = ${JSON.stringify(write.value)}`);
|
|
4157
|
+
}
|
|
4158
|
+
catch (e) {
|
|
4159
|
+
this.log.warn(`trigger '${def.id}': cannot write ${write.id}: ${e.message}`);
|
|
4160
|
+
}
|
|
4161
|
+
}
|
|
4162
|
+
}
|
|
4163
|
+
/**
|
|
4164
|
+
* Run a routine's actions. They are {@link TriggerAction}s, so this borrows the trigger executor — a
|
|
4165
|
+
* routine really is a trigger whose condition is a spoken phrase. A failing action is logged and the
|
|
4166
|
+
* rest still runs: most of a "good night" is better than none of it.
|
|
4167
|
+
*/
|
|
4168
|
+
async runRoutine(routine, source) {
|
|
4169
|
+
const origin = this.announceTargetForSource(source);
|
|
4170
|
+
for (const action of routine.actions) {
|
|
4171
|
+
try {
|
|
4172
|
+
await this.runTriggerAction({ id: `routine:${routine.name}`, when: [] }, action, origin);
|
|
4173
|
+
}
|
|
4174
|
+
catch (e) {
|
|
4175
|
+
this.log.warn(`Routine '${routine.name}': an action failed — ${e.message}`);
|
|
4176
|
+
}
|
|
4177
|
+
}
|
|
4178
|
+
}
|
|
4179
|
+
/** A trigger's text, optionally reworded by the LLM so a recurring announcement doesn't sound canned. */
|
|
4180
|
+
async triggerText(def, text) {
|
|
4181
|
+
if (!def.rephrase || !this.agent || !text) {
|
|
4182
|
+
return text;
|
|
4183
|
+
}
|
|
4184
|
+
return this.agent.rephrase(text, this.config.voiceLanguage || this.language || '', this.config.systemPrompt);
|
|
4185
|
+
}
|
|
2563
4186
|
// ── Long-term memory (roadmap #6) ───────────────────────────────────────
|
|
2564
4187
|
/** Create the memory store and mirror it into `memory.*` (+ per-entry `memory.items.<id>.*`). */
|
|
2565
4188
|
async setupMemory() {
|
|
@@ -3147,11 +4770,14 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3147
4770
|
async getModels(msg) {
|
|
3148
4771
|
const cfg = this.config;
|
|
3149
4772
|
const provider = msg.provider || cfg.provider || 'openai';
|
|
3150
|
-
|
|
4773
|
+
if (provider === 'none') {
|
|
4774
|
+
return [];
|
|
4775
|
+
}
|
|
4776
|
+
const apiKey = (0, llm_1.effectiveApiKey)(provider, await (0, credentials_1.resolveApiKey)(this, cfg, {
|
|
3151
4777
|
credentialType: msg.credentialType,
|
|
3152
4778
|
apiKey: msg.apiKey,
|
|
3153
4779
|
credentialId: msg.credentialId,
|
|
3154
|
-
});
|
|
4780
|
+
}));
|
|
3155
4781
|
if (!apiKey) {
|
|
3156
4782
|
return [];
|
|
3157
4783
|
}
|
|
@@ -3176,11 +4802,14 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3176
4802
|
async testApiConnection(msg) {
|
|
3177
4803
|
const cfg = this.config;
|
|
3178
4804
|
const provider = msg.provider || cfg.provider || 'openai';
|
|
3179
|
-
|
|
4805
|
+
if (provider === 'none') {
|
|
4806
|
+
return { error: 'No LLM provider selected.' };
|
|
4807
|
+
}
|
|
4808
|
+
const apiKey = (0, llm_1.effectiveApiKey)(provider, await (0, credentials_1.resolveApiKey)(this, cfg, {
|
|
3180
4809
|
credentialType: msg.credentialType,
|
|
3181
4810
|
apiKey: msg.apiKey,
|
|
3182
4811
|
credentialId: msg.credentialId,
|
|
3183
|
-
});
|
|
4812
|
+
}));
|
|
3184
4813
|
if (!apiKey) {
|
|
3185
4814
|
return { error: 'No API key / credential available.' };
|
|
3186
4815
|
}
|
|
@@ -3204,6 +4833,13 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3204
4833
|
catch {
|
|
3205
4834
|
// ignore
|
|
3206
4835
|
}
|
|
4836
|
+
try {
|
|
4837
|
+
// Resolve open questions with null instead of leaving their callers waiting for a timeout.
|
|
4838
|
+
this.pending.cancelAll();
|
|
4839
|
+
}
|
|
4840
|
+
catch {
|
|
4841
|
+
// ignore
|
|
4842
|
+
}
|
|
3207
4843
|
try {
|
|
3208
4844
|
this.timers?.dispose();
|
|
3209
4845
|
}
|
|
@@ -3216,6 +4852,12 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3216
4852
|
catch {
|
|
3217
4853
|
// ignore
|
|
3218
4854
|
}
|
|
4855
|
+
try {
|
|
4856
|
+
this.triggers?.dispose();
|
|
4857
|
+
}
|
|
4858
|
+
catch {
|
|
4859
|
+
// ignore
|
|
4860
|
+
}
|
|
3219
4861
|
try {
|
|
3220
4862
|
await this.voice?.stop();
|
|
3221
4863
|
}
|
|
@@ -3228,6 +4870,18 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3228
4870
|
catch {
|
|
3229
4871
|
// ignore
|
|
3230
4872
|
}
|
|
4873
|
+
try {
|
|
4874
|
+
await this.esphome?.stop();
|
|
4875
|
+
}
|
|
4876
|
+
catch {
|
|
4877
|
+
// ignore
|
|
4878
|
+
}
|
|
4879
|
+
try {
|
|
4880
|
+
await this.media?.stop();
|
|
4881
|
+
}
|
|
4882
|
+
catch {
|
|
4883
|
+
// ignore
|
|
4884
|
+
}
|
|
3231
4885
|
try {
|
|
3232
4886
|
await this.localLlm?.dispose();
|
|
3233
4887
|
}
|