iobroker.assistant 1.4.3 → 1.4.5
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 +11 -0
- package/admin/custom/.vite/manifest.json +1 -1
- package/admin/custom/assets/Components-BR57MZLJ.js +2 -0
- package/admin/custom/customComponents.js +1 -1
- package/admin/custom/i18n/de.json +3 -0
- package/admin/custom/i18n/en.json +3 -0
- package/admin/custom/i18n/es.json +3 -0
- package/admin/custom/i18n/fr.json +3 -0
- package/admin/custom/i18n/it.json +3 -0
- package/admin/custom/i18n/nl.json +3 -0
- package/admin/custom/i18n/pl.json +3 -0
- package/admin/custom/i18n/pt.json +3 -0
- package/admin/custom/i18n/ru.json +3 -0
- package/admin/custom/i18n/uk.json +3 -0
- package/admin/custom/i18n/zh-cn.json +3 -0
- package/admin/custom/index.html +1 -1
- package/admin/custom/mf-manifest.json +1 -1
- package/admin/custom/mf-stats.json +1 -1
- package/build/lib/voice/esphome.js +209 -23
- package/build/lib/voice/esphome.js.map +1 -1
- package/build/lib/voice/esphomeEntities.js +76 -6
- package/build/lib/voice/esphomeEntities.js.map +1 -1
- package/build/lib/voice/esphomeProto.js.map +1 -1
- package/build/lib/voice/flac.js +162 -0
- package/build/lib/voice/flac.js.map +1 -0
- package/build/lib/voice/mediaServer.js +8 -1
- package/build/lib/voice/mediaServer.js.map +1 -1
- package/build/lib/voice/replyFormat.js +158 -0
- package/build/lib/voice/replyFormat.js.map +1 -0
- package/build/lib/voice/stt.js +2 -3
- package/build/lib/voice/stt.js.map +1 -1
- package/build/lib/voice/vad.js +42 -5
- package/build/lib/voice/vad.js.map +1 -1
- package/build/main.js +153 -3
- package/build/main.js.map +1 -1
- package/io-package.json +27 -27
- package/package.json +1 -1
- package/admin/custom/assets/Components-CCl7INIF.js +0 -2
package/build/main.js
CHANGED
|
@@ -162,6 +162,10 @@ function valuesMatch(actual, expected) {
|
|
|
162
162
|
}
|
|
163
163
|
return String(actual).trim().toLowerCase() === String(expected).trim().toLowerCase();
|
|
164
164
|
}
|
|
165
|
+
/** Whether an ESPHome entity id can be the last part of an ioBroker id (empty ones came from ESPHome 2026). */
|
|
166
|
+
function isControlId(objectId) {
|
|
167
|
+
return !!objectId && !/[^\p{Ll}\p{Lu}\p{Nd}_-]/u.test(objectId);
|
|
168
|
+
}
|
|
165
169
|
/**
|
|
166
170
|
* ioBroker role for a measurement, derived from its unit — a `value.temperature` shows up with the right
|
|
167
171
|
* icon and in the right place in vis, where a bare `value` is just a number.
|
|
@@ -334,6 +338,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
334
338
|
satControlsReady = new Map();
|
|
335
339
|
/** Satellites whose `volume` state exists and is subscribed (see updateSatelliteVolume). */
|
|
336
340
|
satVolumeEnsured = new Set();
|
|
341
|
+
/** ESPHome satellite id → the table address already written to its object (see markEsphomeSatellite). */
|
|
342
|
+
satEsphomeMarked = new Map();
|
|
337
343
|
/** Native (ioBroker) satellite state-id → sender instance id, so we can push announcements back to it. */
|
|
338
344
|
nativeSatFrom = new Map();
|
|
339
345
|
/** Countdown timers / reminders (roadmap #2); mirrored into `timers.*` states. Null until onReady. */
|
|
@@ -588,6 +594,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
588
594
|
async startEsphome(llmKey) {
|
|
589
595
|
const cfg = this.config;
|
|
590
596
|
const devices = (cfg.esphomeDevices || []).filter(d => d?.ip?.trim());
|
|
597
|
+
// Rows deleted from the table leave their satellite objects behind otherwise — forever offline in the list.
|
|
598
|
+
await this.removeStaleEsphomeSatellites(devices).catch(e => this.log.warn(`Could not clean up removed ESPHome satellites: ${e.message}`));
|
|
591
599
|
if (!devices.length) {
|
|
592
600
|
this.log.warn('ESPHome satellites are enabled, but no device is configured.');
|
|
593
601
|
return;
|
|
@@ -643,9 +651,12 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
643
651
|
mediaHost: (cfg.esphomeMediaHost || '').trim(),
|
|
644
652
|
silenceMs: cfg.esphomeSilenceMs || 0,
|
|
645
653
|
log: this.log,
|
|
646
|
-
onStatus: (device, room, state) => {
|
|
647
|
-
this.updateSatelliteState(device, room, state)
|
|
654
|
+
onStatus: (device, room, state, address) => {
|
|
655
|
+
this.updateSatelliteState(device, room, state)
|
|
656
|
+
.then(() => this.markEsphomeSatellite(device, room, address))
|
|
657
|
+
.catch(e => this.log.debug(`satellite state update failed: ${e.message}`));
|
|
648
658
|
},
|
|
659
|
+
encodeAudio: (pcm, sampleRate, target) => this.runFfmpegEncode(pcm, sampleRate, target),
|
|
649
660
|
onWakeWords: (device, room, config) => {
|
|
650
661
|
this.updateSatelliteWakeWords(device, room, config).catch(e => this.log.debug(`satellite wake word update failed: ${e.message}`));
|
|
651
662
|
},
|
|
@@ -1100,6 +1111,93 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1100
1111
|
this.satDeviceById.set(id, device);
|
|
1101
1112
|
return id;
|
|
1102
1113
|
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Note which table row an ESPHome satellite belongs to (`native.esphome` on `satellites.<id>`) and show its
|
|
1116
|
+
* address in the host column. That is what lets {@link removeStaleEsphomeSatellites} remove it once the row is
|
|
1117
|
+
* deleted — the id alone is the room or the device name and says nothing about the configuration.
|
|
1118
|
+
*/
|
|
1119
|
+
async markEsphomeSatellite(device, room, address) {
|
|
1120
|
+
const id = await this.ensureSatelliteObjects(device, room);
|
|
1121
|
+
if (this.satEsphomeMarked.get(id) === address) {
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
this.satEsphomeMarked.set(id, address);
|
|
1125
|
+
const obj = await this.getObjectAsync(`satellites.${id}`);
|
|
1126
|
+
if (obj?.native?.esphome !== address) {
|
|
1127
|
+
await this.extendObjectAsync(`satellites.${id}`, { native: { esphome: address } });
|
|
1128
|
+
}
|
|
1129
|
+
await this.setStateAsync(`satellites.${id}.host`, { val: address, ack: true });
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Remove the objects of ESPHome satellites whose row was deleted from the table (or whose room changed, which
|
|
1133
|
+
* changes the id). Runs before the devices connect, so nothing is re-created meanwhile.
|
|
1134
|
+
*
|
|
1135
|
+
* A satellite marked by {@link markEsphomeSatellite} is matched by its address. One from an older version
|
|
1136
|
+
* carries no mark; it is recognised by `wakeWords` (only ESPHome satellites have it) and removed only when
|
|
1137
|
+
* every row has a room — otherwise a row without one could be it, under its device name.
|
|
1138
|
+
*/
|
|
1139
|
+
async removeStaleEsphomeSatellites(devices) {
|
|
1140
|
+
const expected = new Map(devices.map(d => [(0, esphome_1.esphomeAddress)(d), d.room?.trim() ? this.satelliteStateId('', d.room) : null]));
|
|
1141
|
+
const allHaveRooms = [...expected.values()].every(id => id !== null);
|
|
1142
|
+
const roomIds = new Set([...expected.values()].filter((id) => !!id));
|
|
1143
|
+
const prefix = `${this.namespace}.satellites.`;
|
|
1144
|
+
const view = await this.getObjectViewAsync('system', 'device', {
|
|
1145
|
+
startkey: prefix,
|
|
1146
|
+
endkey: `${prefix}香`,
|
|
1147
|
+
});
|
|
1148
|
+
for (const row of view.rows) {
|
|
1149
|
+
const id = row.id.slice(prefix.length);
|
|
1150
|
+
if (!id || id.includes('.')) {
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
const address = row.value?.native?.esphome;
|
|
1154
|
+
let reason = '';
|
|
1155
|
+
if (address) {
|
|
1156
|
+
if (!expected.has(address)) {
|
|
1157
|
+
reason = `${address} is no longer in the ESPHome satellites table`;
|
|
1158
|
+
}
|
|
1159
|
+
else {
|
|
1160
|
+
const want = expected.get(address);
|
|
1161
|
+
if (want && want !== id) {
|
|
1162
|
+
reason = `${address} is now in room ${want}`;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
else if (allHaveRooms && !roomIds.has(id) && (await this.getObjectAsync(`satellites.${id}.wakeWords`))) {
|
|
1167
|
+
reason = 'no row of the ESPHome satellites table has this room any more';
|
|
1168
|
+
}
|
|
1169
|
+
if (reason) {
|
|
1170
|
+
this.log.info(`Satellite ${id} removed: ${reason}`);
|
|
1171
|
+
await this.forgetSatellite(id);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
/** Delete a satellite's objects (Satellites tab). Refused while it is online: it would be re-created at once. */
|
|
1176
|
+
async deleteSatellite(id) {
|
|
1177
|
+
if (!id || /[^\w-]/.test(id)) {
|
|
1178
|
+
throw new Error(`invalid satellite id "${id}"`);
|
|
1179
|
+
}
|
|
1180
|
+
if (!(await this.getObjectAsync(`satellites.${id}`))) {
|
|
1181
|
+
throw new Error(`no satellite ${id}`);
|
|
1182
|
+
}
|
|
1183
|
+
const alive = await this.getStateAsync(`satellites.${id}.alive`);
|
|
1184
|
+
if (alive?.val === true) {
|
|
1185
|
+
throw new Error(`satellite ${id} is online`);
|
|
1186
|
+
}
|
|
1187
|
+
await this.forgetSatellite(id);
|
|
1188
|
+
this.log.info(`Satellite ${id} deleted`);
|
|
1189
|
+
}
|
|
1190
|
+
/** Drop the object tree of one satellite and everything cached about it. */
|
|
1191
|
+
async forgetSatellite(id) {
|
|
1192
|
+
await this.delObjectAsync(`satellites.${id}`, { recursive: true });
|
|
1193
|
+
this.satStatesEnsured.delete(id);
|
|
1194
|
+
this.satWakeWordStatesEnsured.delete(id);
|
|
1195
|
+
this.satVolumeEnsured.delete(id);
|
|
1196
|
+
this.satControlsReady.delete(id);
|
|
1197
|
+
this.satEsphomeMarked.delete(id);
|
|
1198
|
+
this.satDeviceById.delete(id);
|
|
1199
|
+
this.dndById.delete(id);
|
|
1200
|
+
}
|
|
1103
1201
|
/**
|
|
1104
1202
|
* Publish a satellite's wake-word configuration and let it be changed from ioBroker.
|
|
1105
1203
|
*
|
|
@@ -1303,6 +1401,10 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1303
1401
|
native: {},
|
|
1304
1402
|
});
|
|
1305
1403
|
for (const entity of entities) {
|
|
1404
|
+
if (!isControlId(entity.objectId)) {
|
|
1405
|
+
this.log.debug(`Satellite ${device}: no usable id for "${entity.name}" (${entity.kind}) — skipped`);
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1306
1408
|
const id = `${base}.${entity.objectId}`;
|
|
1307
1409
|
const name = entity.name || entity.objectId;
|
|
1308
1410
|
if (entity.kind === 'mediaPlayer') {
|
|
@@ -1439,6 +1541,9 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1439
1541
|
}
|
|
1440
1542
|
/** Write one entity's current value into its state(s). Always acked — this is the device talking. */
|
|
1441
1543
|
async writeSatelliteControl(satId, entity) {
|
|
1544
|
+
if (!isControlId(entity.objectId)) {
|
|
1545
|
+
return; // never created (see buildSatelliteControls)
|
|
1546
|
+
}
|
|
1442
1547
|
const base = `satellites.${satId}.controls.${entity.objectId}`;
|
|
1443
1548
|
if (entity.kind === 'mediaPlayer') {
|
|
1444
1549
|
const v = entity.value;
|
|
@@ -1904,7 +2009,35 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1904
2009
|
}
|
|
1905
2010
|
});
|
|
1906
2011
|
}
|
|
1907
|
-
/**
|
|
2012
|
+
/**
|
|
2013
|
+
* Encode mono 16-bit PCM as MP3 or Opus (Ogg) via ffmpeg — for an ESPHome media player that was built with
|
|
2014
|
+
* nothing but that decoder. FLAC and WAV need no tool and are written in `replyFormat.ts`.
|
|
2015
|
+
*/
|
|
2016
|
+
runFfmpegEncode(pcm, sampleRate, target) {
|
|
2017
|
+
return new Promise((resolve, reject) => {
|
|
2018
|
+
// prettier-ignore
|
|
2019
|
+
const input = ['-hide_banner', '-loglevel', 'error', '-f', 's16le', '-ar', String(sampleRate), '-ac', '1', '-i', 'pipe:0'];
|
|
2020
|
+
// prettier-ignore
|
|
2021
|
+
const output = target.codec === 'mp3'
|
|
2022
|
+
? ['-c:a', 'libmp3lame', '-b:a', '128k', '-f', 'mp3']
|
|
2023
|
+
: ['-c:a', 'libopus', '-b:a', '64k', '-f', 'ogg'];
|
|
2024
|
+
// Opus runs at 48/24/16/12/8 kHz only; anything else would make libopus refuse.
|
|
2025
|
+
const rate = target.codec === 'opus' && ![48000, 24000, 16000, 12000, 8000].includes(target.sampleRate)
|
|
2026
|
+
? 48000
|
|
2027
|
+
: target.sampleRate;
|
|
2028
|
+
const args = [...input, '-ar', String(rate), '-ac', String(target.channels), ...output, '-'];
|
|
2029
|
+
const proc = (0, node_child_process_1.spawn)('ffmpeg', args);
|
|
2030
|
+
const chunks = [];
|
|
2031
|
+
proc.stdout.on('data', (d) => chunks.push(d));
|
|
2032
|
+
proc.stderr.on('data', (d) => this.log.debug(`ffmpeg: ${String(d).trim()}`));
|
|
2033
|
+
proc.on('error', e => reject(new Error(`ffmpeg failed: ${e.message}`)));
|
|
2034
|
+
proc.on('close', code => code === 0 && chunks.length
|
|
2035
|
+
? resolve(Buffer.concat(chunks))
|
|
2036
|
+
: reject(new Error(`ffmpeg exited with code ${code}`)));
|
|
2037
|
+
proc.stdin.on('error', () => { }); // ignore EPIPE if ffmpeg exits early
|
|
2038
|
+
proc.stdin.end(pcm);
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
1908
2041
|
/** Vendor and key of the cloud LLM: speech of the same vendor (OpenAI, OpenRouter) reuses the key. */
|
|
1909
2042
|
async speechLlmKey() {
|
|
1910
2043
|
const provider = this.config.provider || 'openai';
|
|
@@ -2509,6 +2642,23 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2509
2642
|
}
|
|
2510
2643
|
return;
|
|
2511
2644
|
}
|
|
2645
|
+
// Satellites tab: forget a satellite that is gone. message = { id } (the state id under `satellites.`).
|
|
2646
|
+
if (obj.command === 'deleteSatellite') {
|
|
2647
|
+
const raw = obj.message?.id;
|
|
2648
|
+
const id = typeof raw === 'string' ? raw : '';
|
|
2649
|
+
let payload;
|
|
2650
|
+
try {
|
|
2651
|
+
await this.deleteSatellite(id);
|
|
2652
|
+
payload = { ok: true };
|
|
2653
|
+
}
|
|
2654
|
+
catch (e) {
|
|
2655
|
+
payload = { error: e.message };
|
|
2656
|
+
}
|
|
2657
|
+
if (obj.callback) {
|
|
2658
|
+
this.sendTo(obj.from, obj.command, payload, obj.callback);
|
|
2659
|
+
}
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2512
2662
|
// Scripts: silence a ringing timer/alarm.
|
|
2513
2663
|
if (obj.command === 'stopRinging') {
|
|
2514
2664
|
const n = this.stopRinging();
|