Update version to 0.1.49 and enhance voice mode stability and chat functionality

This commit is contained in:
techartdev
2026-02-21 02:39:39 +02:00
parent 2fe88a7170
commit 21649dd441
5 changed files with 206 additions and 57 deletions
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the OpenClaw Home Assistant Integration will be documented in this file. All notable changes to the OpenClaw Home Assistant Integration will be documented in this file.
## [0.1.49] - 2026-02-21
### Fixed
- Reduced repeated voice-mode start/stop cycles in silence to avoid frequent mobile chime sounds.
- Prevented voice mode from hearing and re-triggering on its own spoken TTS responses.
- Improved chat auto-scroll behavior so manual scroll position is respected when reading older messages.
- Improved cross-device chat consistency by forcing quick backend history sync after message events.
## [0.1.48] - 2026-02-21 ## [0.1.48] - 2026-02-21
### Fixed ### Fixed
+1 -1
View File
@@ -92,7 +92,7 @@ _CARD_PATH = Path(__file__).parent / "www" / _CARD_FILENAME
# URL at which the card JS is served (registered via register_static_path) # URL at which the card JS is served (registered via register_static_path)
_CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}" _CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}"
# Versioned URL used for Lovelace resource registration to avoid stale browser cache # Versioned URL used for Lovelace resource registration to avoid stale browser cache
_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.48" _CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.49"
OpenClawConfigEntry = ConfigEntry OpenClawConfigEntry = ConfigEntry
+1 -1
View File
@@ -8,7 +8,7 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues", "issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues",
"requirements": [], "requirements": [],
"version": "0.1.48", "version": "0.1.49",
"dependencies": ["conversation"], "dependencies": ["conversation"],
"after_dependencies": ["hassio", "lovelace"] "after_dependencies": ["hassio", "lovelace"]
} }
@@ -13,7 +13,7 @@
* + subscribes to openclaw_message_received events. * + subscribes to openclaw_message_received events.
*/ */
const CARD_VERSION = "0.3.7"; const CARD_VERSION = "0.3.8";
// Max time (ms) to show the thinking indicator before falling back to an error // Max time (ms) to show the thinking indicator before falling back to an error
const THINKING_TIMEOUT_MS = 120_000; const THINKING_TIMEOUT_MS = 120_000;
@@ -64,8 +64,11 @@ class OpenClawChatCard extends HTMLElement {
this._wakeWord = "hey openclaw"; this._wakeWord = "hey openclaw";
this._voiceStatus = ""; this._voiceStatus = "";
this._voiceRetryTimer = null; this._voiceRetryTimer = null;
this._voiceIdleRestartTimer = null;
this._voiceRetryCount = 0; this._voiceRetryCount = 0;
this._voiceNetworkErrorCount = 0; this._voiceNetworkErrorCount = 0;
this._speechDetectedInCycle = false;
this._lastRecognitionError = null;
this._pendingResponses = 0; this._pendingResponses = 0;
this._speechLangOverride = null; this._speechLangOverride = null;
this._integrationBrowserVoiceLanguage = null; this._integrationBrowserVoiceLanguage = null;
@@ -90,6 +93,7 @@ class OpenClawChatCard extends HTMLElement {
this._assistInputSampleRate = 16000; this._assistInputSampleRate = 16000;
this._assistAutoStopTimer = null; this._assistAutoStopTimer = null;
this._lastAssistantEventSignature = null; this._lastAssistantEventSignature = null;
this._autoScrollPinned = true;
} }
// ── HA card interface ─────────────────────────────────────────────── // ── HA card interface ───────────────────────────────────────────────
@@ -251,8 +255,10 @@ class OpenClawChatCard extends HTMLElement {
this._render(); this._render();
this._scrollToBottom(); this._scrollToBottom();
this._syncHistoryFromBackend(1);
// In voice mode, speak the response // In voice mode, speak the response
if (this._isVoiceMode && this._recognition && data.message) { if (this._isVoiceMode && data.message) {
this._speak(data.message); this._speak(data.message);
} }
} }
@@ -1050,6 +1056,12 @@ class OpenClawChatCard extends HTMLElement {
_startBrowserVoiceRecognition() { _startBrowserVoiceRecognition() {
this._voiceBackendBlocked = false; this._voiceBackendBlocked = false;
this._voiceStopRequested = false; this._voiceStopRequested = false;
this._speechDetectedInCycle = false;
this._lastRecognitionError = null;
if (this._voiceIdleRestartTimer) {
clearTimeout(this._voiceIdleRestartTimer);
this._voiceIdleRestartTimer = null;
}
const allowBraveWebSpeech = const allowBraveWebSpeech =
this._config.allow_brave_webspeech || this._allowBraveWebSpeechIntegration; this._config.allow_brave_webspeech || this._allowBraveWebSpeechIntegration;
@@ -1069,7 +1081,7 @@ class OpenClawChatCard extends HTMLElement {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
this._recognition = new SpeechRecognition(); this._recognition = new SpeechRecognition();
this._recognition.continuous = false; this._recognition.continuous = this._isVoiceMode;
this._recognition.interimResults = false; this._recognition.interimResults = false;
this._recognition.maxAlternatives = 1; this._recognition.maxAlternatives = 1;
this._recognition.lang = this._getSpeechRecognitionLanguage(); this._recognition.lang = this._getSpeechRecognitionLanguage();
@@ -1084,6 +1096,8 @@ class OpenClawChatCard extends HTMLElement {
if (result.isFinal) { if (result.isFinal) {
const text = result[0].transcript?.trim(); const text = result[0].transcript?.trim();
if (!text) return; if (!text) return;
this._speechDetectedInCycle = true;
this._lastRecognitionError = null;
const requireWakeWord = this._wakeWordEnabled && this._isVoiceMode; const requireWakeWord = this._wakeWordEnabled && this._isVoiceMode;
@@ -1120,6 +1134,9 @@ class OpenClawChatCard extends HTMLElement {
if (this._voiceStopRequested) { if (this._voiceStopRequested) {
return; return;
} }
if (this._isVoiceMode && !this._speechDetectedInCycle) {
return;
}
try { try {
this._recognition.stop(); this._recognition.stop();
} catch (e) { } catch (e) {
@@ -1129,6 +1146,7 @@ class OpenClawChatCard extends HTMLElement {
this._recognition.onerror = (event) => { this._recognition.onerror = (event) => {
const err = event?.error || "unknown"; const err = event?.error || "unknown";
this._lastRecognitionError = err;
if (err === "aborted") { if (err === "aborted") {
if (this._voiceStopRequested) { if (this._voiceStopRequested) {
return; return;
@@ -1191,6 +1209,29 @@ class OpenClawChatCard extends HTMLElement {
} }
if (this._isVoiceMode) { if (this._isVoiceMode) {
const previousError = this._lastRecognitionError;
const hadSpeech = this._speechDetectedInCycle;
this._speechDetectedInCycle = false;
this._lastRecognitionError = null;
if (previousError === "no-speech" && !hadSpeech) {
if (this._voiceIdleRestartTimer) {
clearTimeout(this._voiceIdleRestartTimer);
}
this._voiceIdleRestartTimer = setTimeout(() => {
this._voiceIdleRestartTimer = null;
if (!this._isVoiceMode || !this._recognition) {
return;
}
try {
this._recognition.start();
} catch (e) {
// Ignore — may already be started
}
}, 1500);
return;
}
// Restart recognition in voice mode // Restart recognition in voice mode
try { try {
this._recognition.start(); this._recognition.start();
@@ -1232,8 +1273,14 @@ class OpenClawChatCard extends HTMLElement {
clearTimeout(this._voiceRetryTimer); clearTimeout(this._voiceRetryTimer);
this._voiceRetryTimer = null; this._voiceRetryTimer = null;
} }
if (this._voiceIdleRestartTimer) {
clearTimeout(this._voiceIdleRestartTimer);
this._voiceIdleRestartTimer = null;
}
this._voiceRetryCount = 0; this._voiceRetryCount = 0;
this._voiceNetworkErrorCount = 0; this._voiceNetworkErrorCount = 0;
this._speechDetectedInCycle = false;
this._lastRecognitionError = null;
this._isVoiceMode = false; this._isVoiceMode = false;
this._voiceStatus = ""; this._voiceStatus = "";
} }
@@ -1264,6 +1311,28 @@ class OpenClawChatCard extends HTMLElement {
}, delayMs); }, delayMs);
} }
_pauseVoiceInputForTts() {
if (!this._isVoiceMode) return;
if (!this._recognition) return;
try {
this._voiceStopRequested = true;
this._recognition.abort();
} catch (err) {
console.debug("OpenClaw: could not pause voice recognition during TTS", err);
}
this._recognition = null;
}
_resumeVoiceInputAfterTts() {
if (!this._isVoiceMode) return;
if (this._recognition) return;
this._startVoiceRecognition().catch((err) => {
console.error("OpenClaw: failed to resume voice recognition after TTS", err);
});
}
async _toggleVoiceMode() { async _toggleVoiceMode() {
await this._loadIntegrationSettings(false); await this._loadIntegrationSettings(false);
this._isVoiceMode = !this._isVoiceMode; this._isVoiceMode = !this._isVoiceMode;
@@ -1280,6 +1349,7 @@ class OpenClawChatCard extends HTMLElement {
_speak(text) { _speak(text) {
if (!("speechSynthesis" in window)) return; if (!("speechSynthesis" in window)) return;
this._pauseVoiceInputForTts();
// Strip markdown for TTS // Strip markdown for TTS
const plain = text.replace(/[*_`#\[\]()]/g, ""); const plain = text.replace(/[*_`#\[\]()]/g, "");
const preferredLanguage = this._getSpeechSynthesisLanguage(); const preferredLanguage = this._getSpeechSynthesisLanguage();
@@ -1342,6 +1412,7 @@ class OpenClawChatCard extends HTMLElement {
if (ok) { if (ok) {
this._voiceStatus = ""; this._voiceStatus = "";
this._render(); this._render();
this._resumeVoiceInputAfterTts();
return; return;
} }
@@ -1368,6 +1439,7 @@ class OpenClawChatCard extends HTMLElement {
if (ok) { if (ok) {
this._voiceStatus = ""; this._voiceStatus = "";
this._render(); this._render();
this._resumeVoiceInputAfterTts();
return; return;
} }
@@ -1379,6 +1451,7 @@ class OpenClawChatCard extends HTMLElement {
const reason = event?.error ? ` (${event.error})` : ""; const reason = event?.error ? ` (${event.error})` : "";
this._voiceStatus = `TTS error for language ${targetLanguage}${reason}`; this._voiceStatus = `TTS error for language ${targetLanguage}${reason}`;
this._render(); this._render();
this._resumeVoiceInputAfterTts();
}); });
return; return;
} }
@@ -1391,6 +1464,11 @@ class OpenClawChatCard extends HTMLElement {
const reason = event?.error ? ` (${event.error})` : ""; const reason = event?.error ? ` (${event.error})` : "";
this._voiceStatus = `TTS error for language ${targetLanguage}${reason}`; this._voiceStatus = `TTS error for language ${targetLanguage}${reason}`;
this._render(); this._render();
this._resumeVoiceInputAfterTts();
};
utterance.onend = () => {
this._resumeVoiceInputAfterTts();
}; };
try { try {
@@ -1433,6 +1511,12 @@ class OpenClawChatCard extends HTMLElement {
if (!engineCandidates.includes(normalized)) { if (!engineCandidates.includes(normalized)) {
engineCandidates.push(normalized); engineCandidates.push(normalized);
} }
if (normalized.startsWith("tts.")) {
const alias = normalized.slice(4);
if (alias && !engineCandidates.includes(alias)) {
engineCandidates.push(alias);
}
}
} }
if (!engineCandidates.length) { if (!engineCandidates.length) {
@@ -1446,33 +1530,52 @@ class OpenClawChatCard extends HTMLElement {
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}; };
const languageCandidates = [];
const normalizedLanguage = typeof language === "string" ? language.trim() : "";
if (normalizedLanguage) {
languageCandidates.push(normalizedLanguage);
const baseLanguage = normalizedLanguage.split("-")[0];
if (baseLanguage && baseLanguage !== normalizedLanguage) {
languageCandidates.push(baseLanguage);
}
}
languageCandidates.push(null);
let audioPath = null; let audioPath = null;
let selectedEngine = null; let selectedEngine = null;
let selectedLanguage = null;
let lastError = "unknown_error"; let lastError = "unknown_error";
for (const engine of engineCandidates) { for (const engine of engineCandidates) {
console.info("OpenClaw: trying HA TTS engine", engine, "language", language); for (const candidateLanguage of languageCandidates) {
const payloads = [ const payloads = [
{ {
platform: engine, platform: engine,
message: text, message: text,
language,
cache: true, cache: true,
...(candidateLanguage ? { language: candidateLanguage } : {}),
}, },
{ {
engine_id: engine, engine_id: engine,
message: text, message: text,
language,
cache: true, cache: true,
...(candidateLanguage ? { language: candidateLanguage } : {}),
}, },
{ {
tts_engine_id: engine, tts_engine_id: engine,
message: text, message: text,
language,
cache: true, cache: true,
...(candidateLanguage ? { language: candidateLanguage } : {}),
}, },
]; ];
console.info(
"OpenClaw: trying HA TTS engine",
engine,
"language",
candidateLanguage || "auto"
);
for (const payload of payloads) { for (const payload of payloads) {
try { try {
const response = await fetch("/api/tts_get_url", { const response = await fetch("/api/tts_get_url", {
@@ -1482,14 +1585,24 @@ class OpenClawChatCard extends HTMLElement {
}); });
if (!response.ok) { if (!response.ok) {
lastError = `engine=${engine}, tts_get_url_http=${response.status}`; let responseSnippet = "";
try {
responseSnippet = (await response.text()).trim().slice(0, 120);
} catch (_err) {
// ignore response body parsing failure
}
lastError =
`engine=${engine}, lang=${candidateLanguage || "auto"}, ` +
`tts_get_url_http=${response.status}` +
(responseSnippet ? `, detail=${responseSnippet}` : "");
continue; continue;
} }
const body = await response.json(); const body = await response.json();
const candidate = body?.url || body?.path || body?.audio_url || body?.audioUrl; const candidate = body?.url || body?.path || body?.audio_url || body?.audioUrl;
if (!candidate) { if (!candidate) {
lastError = `engine=${engine}, no_audio_url_in_response`; lastError =
`engine=${engine}, lang=${candidateLanguage || "auto"}, no_audio_url_in_response`;
continue; continue;
} }
@@ -1499,10 +1612,17 @@ class OpenClawChatCard extends HTMLElement {
? String(candidate) ? String(candidate)
: `/${String(candidate)}`; : `/${String(candidate)}`;
selectedEngine = engine; selectedEngine = engine;
selectedLanguage = candidateLanguage;
break; break;
} catch (err) { } catch (err) {
console.debug("OpenClaw: HA TTS URL request failed", err); console.debug("OpenClaw: HA TTS URL request failed", err);
lastError = `engine=${engine}, tts_get_url_request_failed`; lastError =
`engine=${engine}, lang=${candidateLanguage || "auto"}, tts_get_url_request_failed`;
}
}
if (audioPath) {
break;
} }
} }
@@ -1521,7 +1641,9 @@ class OpenClawChatCard extends HTMLElement {
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}); });
if (!audioResponse.ok) { if (!audioResponse.ok) {
this._lastHaTtsAttempt = `engine=${selectedEngine}, audio_fetch_http=${audioResponse.status}`; this._lastHaTtsAttempt =
`engine=${selectedEngine}, lang=${selectedLanguage || "auto"}, ` +
`audio_fetch_http=${audioResponse.status}`;
return false; return false;
} }
@@ -1549,11 +1671,12 @@ class OpenClawChatCard extends HTMLElement {
} }
}); });
this._lastHaTtsAttempt = `engine=${selectedEngine}, ok`; this._lastHaTtsAttempt = `engine=${selectedEngine}, lang=${selectedLanguage || "auto"}, ok`;
return true; return true;
} catch (err) { } catch (err) {
console.debug("OpenClaw: HA TTS playback failed", err); console.debug("OpenClaw: HA TTS playback failed", err);
this._lastHaTtsAttempt = `engine=${selectedEngine}, audio_playback_failed`; this._lastHaTtsAttempt =
`engine=${selectedEngine}, lang=${selectedLanguage || "auto"}, audio_playback_failed`;
return false; return false;
} }
} }
@@ -1579,10 +1702,16 @@ class OpenClawChatCard extends HTMLElement {
// ── UI helpers ────────────────────────────────────────────────────── // ── UI helpers ──────────────────────────────────────────────────────
_scrollToBottom() { _isNearBottom(container, threshold = 48) {
if (!container) return true;
const distance = container.scrollHeight - (container.scrollTop + container.clientHeight);
return distance <= threshold;
}
_scrollToBottom(force = false) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const container = this.shadowRoot?.querySelector(".messages"); const container = this.shadowRoot?.querySelector(".messages");
if (container) { if (container && (force || this._autoScrollPinned)) {
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
} }
}); });
@@ -1601,6 +1730,11 @@ class OpenClawChatCard extends HTMLElement {
// ── Render ────────────────────────────────────────────────────────── // ── Render ──────────────────────────────────────────────────────────
_render() { _render() {
const previousContainer = this.shadowRoot?.querySelector(".messages");
if (previousContainer) {
this._autoScrollPinned = this._isNearBottom(previousContainer);
}
const config = this._config; const config = this._config;
const messages = this._messages; const messages = this._messages;
@@ -1969,6 +2103,13 @@ class OpenClawChatCard extends HTMLElement {
}); });
} }
const messagesContainer = this.shadowRoot.getElementById("messages");
if (messagesContainer) {
messagesContainer.addEventListener("scroll", () => {
this._autoScrollPinned = this._isNearBottom(messagesContainer);
});
}
this._scrollToBottom(); this._scrollToBottom();
} }
+1 -1
View File
@@ -1,7 +1,7 @@
(async () => { (async () => {
try { try {
if (!customElements.get("openclaw-chat-card")) { if (!customElements.get("openclaw-chat-card")) {
const src = "/openclaw/openclaw-chat-card.js?v=0.1.48"; const src = "/openclaw/openclaw-chat-card.js?v=0.1.49";
console.info("OpenClaw loader importing", src); console.info("OpenClaw loader importing", src);
await import(src); await import(src);
} }