Skip to content

feat: inject device area context for room-aware voice responses#12

Open
DarrenBenson wants to merge 16 commits intotechartdev:mainfrom
DarrenBenson:main
Open

feat: inject device area context for room-aware voice responses#12
DarrenBenson wants to merge 16 commits intotechartdev:mainfrom
DarrenBenson:main

Conversation

@DarrenBenson
Copy link
Copy Markdown

Summary

  • Resolves the originating voice satellite's area from the HA device/area registry and injects [Voice command from: <room>] into the system prompt sent to OpenClaw
  • Sends x-openclaw-area and x-openclaw-device-id headers for structured access
  • When no device or area is available, behaviour is identical to the unmodified code

Motivation

When a user speaks to a voice satellite (e.g. Voice PE in the Study), OpenClaw has no way to know which room they're in. This means "turn off the lights" or "what's the temperature in here?" can't be resolved to the correct room.

HA's ConversationInput already carries device_id (since HA 2024.4), and the device registry knows which area each device belongs to. This PR connects those dots.

How it works

  1. Reads device_id from ConversationInput (already provided by the Assist pipeline)
  2. Looks up the device in hass.helpers.device_registry to get its area_id
  3. Looks up the area in hass.helpers.area_registry to get the area name
  4. Prepends [Voice command from: Study] to the system prompt
  5. Adds x-openclaw-area and x-openclaw-device-id headers

Backwards compatible

  • If device_id is not available (e.g. non-satellite input), no context is added
  • If the device has no area assigned, only x-openclaw-device-id is sent
  • If neither is available, the request is identical to the current code

Test plan

  • Voice command from satellite with assigned area -- system prompt includes room context
  • Voice command from device without area -- no room context, device ID header only
  • Non-voice input (dashboard, API) -- no change in behaviour

🤖 Generated with Claude Code

dalehamel and others added 8 commits March 18, 2026 12:03
Partial/incomplete debug logging references (CONF_DEBUG_LOGGING,
broken options.get() calls) were accidentally included in this branch.
The full debug logging feature lives in feature/debug-logging.
When the assistant's response ends with a question mark or contains
common follow-up patterns (EN/DE), set continue_conversation=True
on the ConversationResult. This tells Voice PE and other HA voice
satellites to automatically re-listen after the response finishes
playing, enabling natural back-and-forth dialog without requiring
the wake word between turns.

Fixes techartdev#7
Resolves the originating voice satellite's area from the HA device
registry and injects it as "[Voice command from: <room>]" in the
system prompt sent to OpenClaw. Also passes x-openclaw-area and
x-openclaw-device-id headers for structured access.

This enables room-aware voice responses -- the agent knows which
room the user is speaking from without being told.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When no area is found (no device_id, device has no area, etc.),
the request is now identical to the original unmodified code --
no extra headers, no area context in the system prompt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The device ID is useful on its own for logging, debugging, and
future device-specific logic. Area header is still only sent when
the area is successfully resolved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 29, 2026 04:49
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds room-awareness to OpenClaw Assist requests by resolving the originating voice device’s area and passing it along in both prompt context and HTTP headers.

Changes:

  • Resolve device_idarea_id → area name via HA device/area registries.
  • Prepend [Voice command from: <room>] to the system prompt when available.
  • Send x-openclaw-device-id and x-openclaw-area headers to OpenClaw; extend _get_response to accept per-request headers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +158 to +160
if device_area_context:
area_name = device_area_context.removeprefix("[Voice command from: ").removesuffix("]")
voice_headers["x-openclaw-area"] = area_name
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracting area_name by parsing device_area_context (removeprefix/removesuffix) is brittle because any future change to the prompt wording or formatting will silently break the header value. Prefer having _resolve_device_area return the raw area_name (or a small dataclass/tuple like (area_name, context_line)) and build both the system prompt line and the x-openclaw-area header from that.

Copilot uses AI. Check for mistakes.
extra_headers: dict[str, str] | None = None,
) -> str:
"""Get a response from OpenClaw, trying streaming first."""
headers = extra_headers or _VOICE_REQUEST_HEADERS
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

headers = extra_headers or _VOICE_REQUEST_HEADERS treats extra_headers as a full replacement, even though the parameter name suggests these are additional headers. To avoid accidentally dropping the voice routing headers in future call sites, consider always merging with the defaults (e.g., start from _VOICE_REQUEST_HEADERS and update with extra_headers when provided).

Suggested change
headers = extra_headers or _VOICE_REQUEST_HEADERS
headers: dict[str, str] = dict(_VOICE_REQUEST_HEADERS)
if extra_headers:
headers.update(extra_headers)

Copilot uses AI. Check for mistakes.

return f"[Voice command from: {area_entry.name}]"
except Exception:
_LOGGER.debug("Could not resolve area for device %s", device_id)
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catching a broad Exception here can hide unexpected bugs (e.g., registry API changes) and the log message drops the stack trace. Consider narrowing the exception types you expect from the registry lookups and/or logging with exc_info=True so failures can be diagnosed from logs without reproducing locally.

Suggested change
_LOGGER.debug("Could not resolve area for device %s", device_id)
_LOGGER.debug("Could not resolve area for device %s", device_id, exc_info=True)

Copilot uses AI. Check for mistakes.
Comment on lines +158 to +160
if device_area_context:
area_name = device_area_context.removeprefix("[Voice command from: ").removesuffix("]")
voice_headers["x-openclaw-area"] = area_name
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

area_name is derived from device_area_context and then sent as an HTTP header value. Because area_entry.name is user-controlled, it should be sanitized to prevent CRLF/newline characters (which can cause aiohttp header validation errors or enable header injection) and to keep the injected system-prompt line single-line. Consider deriving area_name directly from the registry lookup and normalizing it (strip, replace \r/\n with spaces, and optionally enforce a reasonable max length) before using it in both the prompt and headers.

Copilot uses AI. Check for mistakes.
DarrenBenson and others added 8 commits March 29, 2026 05:53
Merges dalehamel's fix for agent ID routing (issue techartdev#8):
- Agent ID sent as model field (openclaw:<agent_id>) for gateway routing
- Sticky sessions persisted to disk, durable across HA restarts
- Resolved agent ID used consistently (voice_agent_id || configured_agent_id)

Merged on top of our room awareness feature (device area injection).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds opt-in CONF_DEBUG_LOGGING toggle for API request tracing.
Enhanced debug log to include resolved area name alongside
agent ID and session ID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tier 1 improvements from code review:

- Extract _extract_text_recursive and _normalize_optional_text to
  shared utils.py module (was duplicated in __init__.py and conversation.py)
- Map OpenClawConnectionError/AuthError to FAILED_TO_HANDLE instead of
  UNKNOWN, helping HA's Assist pipeline make smarter fallback decisions
- Fix api.py session fallback to log warnings when HA-managed session
  is unexpectedly closed instead of silently creating orphan sessions
- Merge PR techartdev#11 continue_conversation for Voice PE follow-up dialog
- Fix regex syntax warning in _should_continue

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tier 2 improvements from code review:

- exposure.py: include area assignments, useful state attributes
  (brightness, temperature, volume, etc.), and current date/time
  in the entity context sent to OpenClaw
- api.py: add async_send_message_with_retry() with 2 retries and
  1s delay for transient connection failures (skips auth errors)
- config_flow.py: document _find_addon_config_dir as blocking I/O
  (already correctly wrapped in async_add_executor_job by caller)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace upstream README with fork-specific documentation covering
room-aware voice, richer entity context, merged community PRs,
and code quality improvements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR techartdev#9 debug logging added _log_request() but referenced undefined
variables (agent_id, payload, session_id, headers) in the
async_check_connection method, causing 500 errors during config flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ky sessions)

The static session ID override is no longer needed now that PR techartdev#10
provides durable, agent-scoped sticky sessions persisted to disk.
The old option caused confusion by routing all requests to whichever
agent owned the named session, bypassing voice agent routing.

Removed from: config_flow, conversation, strings, translations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants