Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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.

---

Expand Down
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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**
Expand Down Expand Up @@ -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

2 changes: 1 addition & 1 deletion custom_components/openclaw/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion custom_components/openclaw/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
117 changes: 114 additions & 3 deletions custom_components/openclaw/www/openclaw-chat-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Expand All @@ -2258,6 +2347,21 @@ class OpenClawChatCardEditor extends HTMLElement {
<label for="session_id">Session ID (optional)</label>
<input type="text" id="session_id" value="${this._esc(c.session_id || "")}" />
</div>
<div class="row">
<label for="voice_output_mode">Voice output mode</label>
<select id="voice_output_mode">
<option value="local" ${c.voice_output_mode !== "media_player" ? "selected" : ""}>This device</option>
<option value="media_player" ${c.voice_output_mode === "media_player" ? "selected" : ""}>Home Assistant media player</option>
</select>
</div>
<div class="row">
<label for="ha_tts_engine">TTS entity (for example tts.openai_tts)</label>
<input type="text" id="ha_tts_engine" value="${this._esc(c.ha_tts_engine || "")}" />
</div>
<div class="row">
<label for="voice_output_media_player">Media player entity</label>
<input type="text" id="voice_output_media_player" value="${this._esc(c.voice_output_media_player || "")}" />
</div>
<div class="row">
<label for="show_timestamps">Show timestamps</label>
<input type="checkbox" id="show_timestamps" ${c.show_timestamps !== false ? "checked" : ""} />
Expand All @@ -2274,7 +2378,14 @@ class OpenClawChatCardEditor extends HTMLElement {
`;

// Bind events
for (const id of ["title", "height", "session_id"]) {
for (const id of [
"title",
"height",
"session_id",
"voice_output_mode",
"ha_tts_engine",
"voice_output_media_player",
]) {
const el = this.shadowRoot.getElementById(id);
if (el) {
el.addEventListener("change", (e) => this._fireChanged(id, e.target.value));
Expand Down
65 changes: 65 additions & 0 deletions tests/test_chat_card_media_player.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";

const registry = new Map();

globalThis.HTMLElement = class {
attachShadow() {
this.shadowRoot = {
innerHTML: "",
getElementById: () => null,
};
}
};
globalThis.customElements = {
define: (name, value) => registry.set(name, value),
get: (name) => registry.get(name),
};
globalThis.window = { customCards: [] };
globalThis.document = { createElement: () => ({}) };
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { language: "en-US" },
});

await import("../custom_components/openclaw/www/openclaw-chat-card.js");

const Card = registry.get("openclaw-chat-card");
assert.ok(Card, "chat card should register itself");

const calls = [];
const card = new Card();
card._config = {
ha_tts_engine: "tts.openai_tts",
voice_output_media_player: "media_player.bedroom_speaker",
};
card._hass = {
states: { "media_player.bedroom_speaker": { state: "off" } },
callService: async (...args) => calls.push(args),
};
let waitArgs;
card._waitForMediaPlayerPlayback = async (...args) => { waitArgs = args; };

assert.equal(await card._speakViaMediaPlayer("Hello from OpenClaw"), true);
assert.deepEqual(calls, [
[
"tts",
"speak",
{
media_player_entity_id: "media_player.bedroom_speaker",
message: "Hello from OpenClaw",
cache: true,
},
{ entity_id: "tts.openai_tts" },
],
]);
assert.deepEqual(waitArgs, ["media_player.bedroom_speaker", "off"]);

const invalidCard = new Card();
invalidCard._config = {
ha_tts_engine: "openai_tts",
voice_output_media_player: "bedroom_speaker",
};
invalidCard._hass = { callService: async () => assert.fail("must not call service") };

assert.equal(await invalidCard._speakViaMediaPlayer("Hello"), false);
assert.equal(invalidCard._lastHaTtsAttempt, "ha_tts_engine_must_be_a_tts_entity");
2 changes: 1 addition & 1 deletion www/openclaw-chat-card.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
(async () => {
try {
if (!customElements.get("openclaw-chat-card")) {
const src = "/openclaw/openclaw-chat-card.js?v=0.1.63";
const src = "/openclaw/openclaw-chat-card.js?v=0.1.65-rc.3";
console.info("OpenClaw loader importing", src);
await import(src);
}
Expand Down
Loading