diff --git a/CHANGELOG.md b/CHANGELOG.md index dd5726a..a75f1f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to the OpenClaw Home Assistant Integration will be documented in this file. +## [0.1.65-rc.3] - 2026-09-26 + +### Added +- Added optional `voice_output_mode: media_player` chat-card output. Replies can + now be sent through Home Assistant `tts.speak` using configured + `ha_tts_engine` and `voice_output_media_player` entities, bypassing unreliable + Android/iOS WebView audio playback (issues #1 and #29). +- Added matching visual card-editor fields and playback-state waiting so + continuous voice recognition does not immediately capture the spoken reply. + ## [0.1.64] - 2026-09-26 ### Fixed diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index db218fe..8640515 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -14,7 +14,7 @@ This plan reflects the actual implementation status as of release `0.1.34`. | 4 | Lovelace chat card (text + voice) | P0 | β Done | | 5 | Voice provider choice (`browser` / `assist_stt`) | P0 | β Done | | 6 | Production polish and broad compatibility hardening | P1 | π§ In progress | -| 7 | Optional native media player/TTS routing entity | P2 | β³ Not started | +| 7 | Optional Home Assistant media-player TTS routing | P2 | β Done | --- @@ -144,9 +144,8 @@ OpenClawHomeAssistantIntegration/ ### R3 β Optional enhancements (after stabilization) -1. Evaluate media-player based TTS routing entity. -2. Explore optional continuous flow for HA STT/TTS pipeline mode. -3. Add automated tests around settings websocket payload and card voice state transitions. +1. Explore optional continuous flow for HA STT/TTS pipeline mode. +2. Add broader automated tests around settings websocket payload and card voice state transitions. --- diff --git a/README.md b/README.md index e5bc630..34f21f2 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,9 @@ show_timestamps: true show_voice_button: true show_clear_button: true session_id: default +voice_output_mode: media_player +ha_tts_engine: tts.openai_tts +voice_output_media_player: media_player.bedroom_speaker ``` Minimal config: @@ -156,6 +159,23 @@ Minimal config: type: custom:openclaw-chat-card ``` +### Voice output + +By default, the card speaks on the device displaying Home Assistant. To avoid +mobile WebView audio restrictions, set `voice_output_mode: media_player` and +select a Home Assistant TTS entity plus a target media player: + +```yaml +type: custom:openclaw-chat-card +voice_output_mode: media_player +ha_tts_engine: tts.openai_tts +voice_output_media_player: media_player.bedroom_speaker +``` + +In media-player mode the card calls `tts.speak` and pauses continuous voice +recognition until playback finishes (with a timeout fallback for players that +do not report playback state). + --- ## Assist entity exposure context @@ -188,6 +208,17 @@ Open **Settings β Devices & Services β OpenClaw β Configure**. When enabled, OpenClaw tool-call responses can execute Home Assistant services. +These options only control data and actions handled by this Home Assistant +integration: + +- Disabling exposed-entity context stops the integration from adding HA entity + state to the prompt. It does not clear OpenClaw's conversation history or + change OpenClaw/MCP context. +- The maximum context size applies only to the exposed HA entity block. +- Disabling tool calls prevents this integration from executing returned Home + Assistant service calls. It does not disable tools or MCP servers configured + inside OpenClaw itself. + ### Voice options - **Wake word enabled** @@ -410,4 +441,3 @@ MIT. See [LICENSE](LICENSE). If you find this useful and you want to bring me a coffee to make more nice stuff, or support the project, use the link below: - https://revolut.me/vanyo6dhw - diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index 44e5dc4..1a009c3 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -107,7 +107,7 @@ # URL at which the card JS is served (registered via register_static_path) _CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}" # Versioned URL used for Lovelace resource registration to avoid stale browser cache -_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.63" +_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.65-rc.3" OpenClawConfigEntry = ConfigEntry diff --git a/custom_components/openclaw/manifest.json b/custom_components/openclaw/manifest.json index d7981e4..5125e95 100644 --- a/custom_components/openclaw/manifest.json +++ b/custom_components/openclaw/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistantIntegration/issues", "requirements": [], - "version": "0.1.64", + "version": "0.1.65-rc.3", "dependencies": ["conversation"], "after_dependencies": ["hassio", "lovelace"] } diff --git a/custom_components/openclaw/www/openclaw-chat-card.js b/custom_components/openclaw/www/openclaw-chat-card.js index a9ab1c4..763b04a 100644 --- a/custom_components/openclaw/www/openclaw-chat-card.js +++ b/custom_components/openclaw/www/openclaw-chat-card.js @@ -13,7 +13,7 @@ * + subscribes to openclaw_message_received events. */ -const CARD_VERSION = "0.3.13"; +const CARD_VERSION = "0.3.15"; // Max time (ms) to show the thinking indicator before falling back to an error (default; overridable via card config `thinking_timeout` in seconds) const THINKING_TIMEOUT_MS = 120_000; @@ -117,6 +117,7 @@ class OpenClawChatCard extends HTMLElement { show_clear_button: config.show_clear_button !== false, allow_brave_webspeech: config.allow_brave_webspeech === true, voice_provider: config.voice_provider || null, + voice_output_mode: config.voice_output_mode || "local", session_id: config.session_id || null, thinking_timeout: config.thinking_timeout ?? 120, ...config, @@ -1402,6 +1403,22 @@ class OpenClawChatCard extends HTMLElement { this._hass?.locale?.language || this._hass?.selectedLanguage || this._hass?.language || navigator.language || "en-US" ); + if (this._config?.voice_output_mode === "media_player") { + this._voiceStatus = "Speaking through Home Assistant media playerβ¦"; + this._render(); + this._speakViaMediaPlayer(plain).then((ok) => { + if (ok) { + this._voiceStatus = ""; + } else { + const reason = this._lastHaTtsAttempt ? ` (${this._lastHaTtsAttempt})` : ""; + this._voiceStatus = `Media-player TTS unavailable${reason}`; + } + this._render(); + this._resumeVoiceInputAfterTts(); + }); + return; + } + if (!hasBrowserTts) { this._voiceStatus = "Browser TTS unavailable, trying Home Assistant TTSβ¦"; this._render(); @@ -1766,6 +1783,78 @@ class OpenClawChatCard extends HTMLElement { } } + async _speakViaMediaPlayer(text) { + if (!this._hass?.callService) return false; + + this._lastHaTtsAttempt = null; + const engine = + typeof this._config?.ha_tts_engine === "string" + ? this._config.ha_tts_engine.trim() + : ""; + const mediaPlayer = + typeof this._config?.voice_output_media_player === "string" + ? this._config.voice_output_media_player.trim() + : ""; + + if (!engine || !engine.startsWith("tts.")) { + this._lastHaTtsAttempt = "ha_tts_engine_must_be_a_tts_entity"; + return false; + } + if (!mediaPlayer || !mediaPlayer.startsWith("media_player.")) { + this._lastHaTtsAttempt = "voice_output_media_player_must_be_a_media_player_entity"; + return false; + } + + try { + const initialPlayerState = this._hass?.states?.[mediaPlayer]?.state; + await this._hass.callService( + "tts", + "speak", + { + media_player_entity_id: mediaPlayer, + message: text, + cache: true, + }, + { entity_id: engine } + ); + await this._waitForMediaPlayerPlayback(mediaPlayer, initialPlayerState); + this._lastHaTtsAttempt = `engine=${engine}, media_player=${mediaPlayer}, ok`; + return true; + } catch (err) { + console.debug("OpenClaw: media-player TTS failed", err); + this._lastHaTtsAttempt = + `engine=${engine}, media_player=${mediaPlayer}, service_call_failed`; + return false; + } + } + + async _waitForMediaPlayerPlayback(entityId, initialState) { + const activeStates = new Set(["buffering", "playing"]); + const startedAt = Date.now(); + const activityDeadline = startedAt + 10_000; + const timeoutAt = startedAt + 120_000; + const initiallyActive = activeStates.has(initialState); + let sawPlayback = false; + let sawStateChange = false; + + while (Date.now() < timeoutAt) { + const state = this._hass?.states?.[entityId]?.state; + if (state !== initialState) { + sawStateChange = true; + } + if (activeStates.has(state) && (!initiallyActive || sawStateChange)) { + sawPlayback = true; + } else if (sawPlayback) { + return; + } else if (Date.now() >= activityDeadline) { + // Some players complete short clips without exposing a playing state to + // the frontend. Avoid blocking voice input for the full timeout. + return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + // ββ File attachments ββββββββββββββββββββββββββββββββββββββββββββββββ _handleFileAttachment() { @@ -2232,7 +2321,7 @@ class OpenClawChatCardEditor extends HTMLElement { :host { display: block; } .row { display: flex; align-items: center; padding: 8px 0; } .row label { flex: 1; font-size: 14px; color: var(--primary-text-color); } - .row input[type="text"] { + .row input[type="text"], .row select { flex: 1; padding: 6px 10px; border: 1px solid var(--divider-color, #ccc); @@ -2258,6 +2347,21 @@ class OpenClawChatCardEditor extends HTMLElement { +