Media URLs unreachable when Home Assistant uses external URL — satellite cannot play TTS/announcements
Problem
When the LVA satellite registers via mDNS, Home Assistant may construct media URLs (TTS, announcements, wake word downloads) using its external URL — e.g. a Nabu Casa cloud address. The satellite sits on the same LAN and cannot reach that external address, so mpv fails silently and no audio is played.
Example
HA advertises via _home-assistant._tcp.local.:
internal_url=https://example.duckdns.org:8123 ← with port → routes through LAN
external_url=https://example.duckdns.org ← without port → routes through cloud
HA sends a TTS URL using the external address:
https://example.duckdns.org/api/tts_proxy/abc123.mp3
The satellite cannot reach this → no audio.
Using the internal_url instead would work:
https://example.duckdns.org:8123/api/tts_proxy/abc123.mp3
Affected functionality
- TTS responses — voice assistant replies are silent
- Announcements (including pre-announce sounds) —
assist_satellite.announce produces no audio
- External wake word model downloads — model files hosted behind the HA API cannot be fetched
Root cause in code
HA constructs media URLs server-side. The LVA receives and passes them to mpv/urlopen without any transformation in three places inside satellite.py:
| Location |
Code |
What it receives |
handle_voice_event() |
self._tts_url = data.get("url") (lines ~193, ~219) |
TTS audio URL |
handle_message() — VoiceAssistantAnnounceRequest |
msg.media_id, msg.preannounce_media_id (lines ~259-262) |
Announcement URLs |
_download_external_wake_word() |
external_wake_word.url (lines ~548, ~566) |
Wake word config/model URLs |
Proposed solution: auto-discover HA internal URL via mDNS
Home Assistant already broadcasts its internal_url in the TXT record of its _home-assistant._tcp.local. mDNS service. The LVA already uses the zeroconf library for its own registration — we can reuse the same AsyncZeroconf instance to browse for HA and extract internal_url automatically.
When a media URL arrives from HA, replace scheme://host:port with the discovered internal_url values, keeping path/query/fragment intact.
Behavior
| Config |
Source of rewrite URL |
| Nothing set (default) |
Auto-discovered from _home-assistant._tcp.local. TXT internal_url |
--ha-url / HA_URL set |
Manual override — skip discovery, use this value |
| Discovery finds nothing |
No rewriting, original URLs pass through (current behavior) |
Implementation details
1. linux_voice_assistant/zeroconf.py — add HA discovery
Extend HomeAssistantZeroconf to browse for _home-assistant._tcp.local. and extract internal_url from the TXT record:
class HomeAssistantZeroconf:
def __init__(self, port, mac_address, host_ip_address, name=None):
# ... existing init ...
self.ha_internal_url: Optional[str] = None
async def register_server(self) -> None:
# ... existing service registration ...
pass
async def discover_ha(self) -> Optional[str]:
"""Browse for Home Assistant and return internal_url from TXT record."""
info = AsyncServiceInfo(
"_home-assistant._tcp.local.",
"Home._home-assistant._tcp.local.",
)
if await info.async_request(self._aiozc.zeroconf, timeout=5000):
properties = {
k.decode() if isinstance(k, bytes) else k:
v.decode() if isinstance(v, bytes) else v
for k, v in info.properties.items()
}
internal_url = properties.get("internal_url")
if internal_url:
# Strip trailing slash if present
self.ha_internal_url = internal_url.rstrip("/")
_LOGGER.info(
"Discovered HA internal URL via mDNS: %s",
self.ha_internal_url,
)
return self.ha_internal_url
_LOGGER.warning(
"Could not discover Home Assistant via mDNS — "
"media URL rewriting disabled"
)
return None
2. linux_voice_assistant/util.py — add rewrite_ha_url()
from urllib.parse import urlparse, urlunparse
def rewrite_ha_url(url, ha_url):
"""Rewrite media URL(s) to use the internal HA URL.
Replaces scheme://host:port with ha_url while keeping
path, query, and fragment intact.
Local file paths and non-HTTP URLs pass through unchanged.
Returns input unchanged when ha_url is None.
"""
if not ha_url:
return url
if isinstance(url, list):
return [_rewrite_single(u, ha_url) for u in url]
return _rewrite_single(url, ha_url)
def _rewrite_single(url, ha_url):
if not url or not url.startswith(("http://", "https://")):
return url
target = urlparse(ha_url)
original = urlparse(url)
rewritten = urlunparse((
target.scheme,
target.netloc,
original.path,
original.params,
original.query,
original.fragment,
))
if rewritten != url:
_LOGGER.debug("Rewrote URL: %s -> %s", url, rewritten)
return rewritten
3. linux_voice_assistant/models.py — add field to ServerState
@dataclass
class ServerState:
# ... existing fields ...
ha_url: Optional[str] = None # internal HA URL for media rewriting
4. linux_voice_assistant/__main__.py — wire it up
Add --ha-url as optional manual override:
parser.add_argument(
"--ha-url",
help="Internal Home Assistant URL (e.g. http://192.168.1.50:8123). "
"Overrides auto-discovery. When set, media URLs from HA are "
"rewritten to use this address.",
)
In the startup sequence, after zeroconf registration:
# Auto discovery (zeroconf, mDNS)
discovery = HomeAssistantZeroconf(
port=args.port, name=state.name,
mac_address=state.mac_address, host_ip_address=host_ip_address,
)
await discovery.register_server()
# Resolve HA internal URL for media rewriting
if args.ha_url:
state.ha_url = args.ha_url
_LOGGER.info("Using manual HA URL override: %s", state.ha_url)
else:
state.ha_url = await discovery.discover_ha()
Pass to ServerState:
state = ServerState(
# ... existing args ...
ha_url=args.ha_url, # may be None, updated after discovery
)
5. linux_voice_assistant/satellite.py — apply rewriting at the 3 entry points
from .util import call_all, rewrite_ha_url
# --- TTS URLs (handle_voice_event, ~line 193 and ~219): ---
# Before:
self._tts_url = data.get("url")
# After:
self._tts_url = rewrite_ha_url(data.get("url"), self.state.ha_url)
# --- Announcement URLs (handle_message, ~line 258-262): ---
# Before:
urls = []
if msg.preannounce_media_id:
urls.append(msg.preannounce_media_id)
urls.append(msg.media_id)
# After:
urls = []
if msg.preannounce_media_id:
urls.append(rewrite_ha_url(msg.preannounce_media_id, self.state.ha_url))
urls.append(rewrite_ha_url(msg.media_id, self.state.ha_url))
# --- Wake word downloads (_download_external_wake_word, ~line 548 and ~566): ---
# Before:
with urlopen(external_wake_word.url) as request:
# After:
with urlopen(rewrite_ha_url(external_wake_word.url, self.state.ha_url)) as request:
# (same for model_url further down)
6. docker-entrypoint.sh — map env variable
if [ -n "${HA_URL}" ]; then
EXTRA_ARGS+=( "--ha-url" "$HA_URL" )
fi
7. .env.example — document the new variable
### Internal Home Assistant URL override (optional):
# Normally auto-discovered via mDNS. Set this only if auto-discovery
# does not work or you need a specific URL.
# HA_URL="http://192.168.1.50:8123"
Summary of changes
| File |
Change |
zeroconf.py |
Add discover_ha() — browse _home-assistant._tcp.local., extract internal_url from TXT |
util.py |
Add rewrite_ha_url() — replace scheme+host+port, keep path |
models.py |
Add ha_url: Optional[str] to ServerState |
__main__.py |
Add --ha-url argument; call discover_ha() on startup; set state.ha_url |
satellite.py |
Apply rewrite_ha_url() at the 3 URL entry points |
docker-entrypoint.sh |
Map HA_URL env var to --ha-url |
.env.example |
Document HA_URL |
Environment
- LVA version: v1.1.9
- Home Assistant: 2026.3.1 with Cloud / external URL configured
- Hardware: Raspberry Pi 4 on same LAN as HA server
- Deployment: Docker / systemd
Media URLs unreachable when Home Assistant uses external URL — satellite cannot play TTS/announcements
Problem
When the LVA satellite registers via mDNS, Home Assistant may construct media URLs (TTS, announcements, wake word downloads) using its external URL — e.g. a Nabu Casa cloud address. The satellite sits on the same LAN and cannot reach that external address, so
mpvfails silently and no audio is played.Example
HA advertises via
_home-assistant._tcp.local.:HA sends a TTS URL using the external address:
The satellite cannot reach this → no audio.
Using the
internal_urlinstead would work:Affected functionality
assist_satellite.announceproduces no audioRoot cause in code
HA constructs media URLs server-side. The LVA receives and passes them to
mpv/urlopenwithout any transformation in three places insidesatellite.py:handle_voice_event()self._tts_url = data.get("url")(lines ~193, ~219)handle_message()—VoiceAssistantAnnounceRequestmsg.media_id,msg.preannounce_media_id(lines ~259-262)_download_external_wake_word()external_wake_word.url(lines ~548, ~566)Proposed solution: auto-discover HA internal URL via mDNS
Home Assistant already broadcasts its
internal_urlin the TXT record of its_home-assistant._tcp.local.mDNS service. The LVA already uses thezeroconflibrary for its own registration — we can reuse the sameAsyncZeroconfinstance to browse for HA and extractinternal_urlautomatically.When a media URL arrives from HA, replace
scheme://host:portwith the discoveredinternal_urlvalues, keeping path/query/fragment intact.Behavior
_home-assistant._tcp.local.TXTinternal_url--ha-url/HA_URLsetImplementation details
1.
linux_voice_assistant/zeroconf.py— add HA discoveryExtend
HomeAssistantZeroconfto browse for_home-assistant._tcp.local.and extractinternal_urlfrom the TXT record:2.
linux_voice_assistant/util.py— addrewrite_ha_url()3.
linux_voice_assistant/models.py— add field toServerState4.
linux_voice_assistant/__main__.py— wire it upAdd
--ha-urlas optional manual override:In the startup sequence, after zeroconf registration:
Pass to
ServerState:5.
linux_voice_assistant/satellite.py— apply rewriting at the 3 entry points6.
docker-entrypoint.sh— map env variable7.
.env.example— document the new variableSummary of changes
zeroconf.pydiscover_ha()— browse_home-assistant._tcp.local., extractinternal_urlfrom TXTutil.pyrewrite_ha_url()— replace scheme+host+port, keep pathmodels.pyha_url: Optional[str]toServerState__main__.py--ha-urlargument; calldiscover_ha()on startup; setstate.ha_urlsatellite.pyrewrite_ha_url()at the 3 URL entry pointsdocker-entrypoint.shHA_URLenv var to--ha-url.env.exampleHA_URLEnvironment