Turn a supported UniFi Protect camera into a full Home Assistant Assist satellite: camera microphone in, wake-word/Assist processing through Linux Voice Assistant (LVA), and spoken responses back through the camera speaker.
This project is based on OHF-Voice/linux-voice-assistant, which provides the Linux Voice Assistant runtime used to expose the camera as a Home Assistant Assist satellite.
The repository can be installed directly as a custom Home Assistant app (formerly called an add-on):
- In Home Assistant, go to Settings > Apps > App store.
- Open the repository menu, add
https://github.com/BrandtWoolf/lva-unifi-camera, and refresh the store. - Install UniFi Camera Voice Assistant.
- Complete every required field on the Configuration tab.
- Start the app and review its log.
- Create a Home Assistant voice assistant, then add the ESPHome integration
using the Home Assistant host's LAN IP and port
6053.
The add-on combines the microphone bridge, Linux Voice Assistant, and speaker bridge in one container because Home Assistant OS apps cannot run a Compose stack. LVA downloads and configuration persist in the app's private data directory and are included in Home Assistant backups.
See the app's Documentation tab for the complete setup and credential requirements.
Run the isolated startup smoke test:
./test-addon.shTo test with a real camera, copy and fill in the options template, then run:
cp unifi-camera-voice/options.example.json options.json
chmod 600 options.json
./test-addon.sh --run options.jsonThe real-camera test publishes ESPHome on host port 16053 and the peripheral
API on 16055, so it can run beside the standalone Compose stack. Override
them with ADDON_ESPHOME_PORT and ADDON_PERIPHERAL_PORT. Test data is
temporary and removed when the container stops.
git clone https://github.com/BrandtWoolf/lva-unifi-camera.git
cd lva-unifi-camera
./start.shOn the first run, the script:
- validates Docker, Compose v2, and the Docker daemon;
- securely prompts for the RTSP(S) URL, exact Protect camera name, controller, dedicated local username, and a hidden password;
- writes owner-readable
.envandufp.jsonfiles; - builds and starts the microphone, LVA, and speaker bridges; and
- waits until the full stack is healthy.
Later runs reuse the saved configuration, pull updates, rebuild the local bridges, and start the stack:
./start.shThe script rejects example placeholders and malformed values. It never prints
the password or configured RTSP URL. Existing configuration is not replaced
unless you explicitly run ./start.sh --configure.
UniFi camera
RTSP(S) audio -> ffmpeg -> PulseAudio camera_mic -> Linux Voice Assistant
| ESPHome API :6053
v
Home Assistant
|
response audio -> PulseAudio lva_out.monitor
|
Protect talkback API -> camera speaker
Everything audio-related runs inside Docker. The host does not need a sound card, PipeWire, or PulseAudio. The stack assumes:
- an x64 or ARM64 Docker host reachable by Home Assistant;
- a UniFi Protect camera with an enabled microphone, RTSP(S), and speaker talkback support;
- AAC audio in the selected Protect stream;
- LVA's required 16 kHz mono microphone input; and
- ports
6053/tcp(ESPHome API) and6055/tcp(LVA peripheral API) available on the Docker host.
It was built around a G4 Instant. Other Protect cameras should work when they provide the same RTSP audio and Protect talkback capabilities, but are not all tested.
| Device | Microphone | Speaker talkback | Status |
|---|---|---|---|
| UniFi Protect G4 Instant | Yes | Yes | Tested and supported |
Other UniFi Protect cameras are currently unverified. A camera must provide an audio-enabled RTSP(S) stream and report speaker talkback support through the Protect API to be compatible.
- For the add-on: Home Assistant OS with access to the custom app repository
- Docker Desktop, or Docker Engine with the Compose v2 plugin
- Home Assistant with an Assist voice pipeline and wake-word support configured
- Network access from this host to the Protect controller/camera
- Network access from Home Assistant to this host on TCP port
6053
The Docker requirements apply only to the standalone installation. start.sh
stops with a specific error if Docker, docker compose, or the daemon is
unavailable.
Start Docker Desktop before running the script. On macOS and Windows, ensure the
repository is in a directory Docker Desktop can share. Linux containers are
required. Named volumes retain LVA downloads/preferences and PulseAudio state
across container replacement; docker compose down -v intentionally deletes
that persistent state.
In Protect, open the camera and go to Settings > Advanced > RTSP:
- enable the camera microphone;
- enable one RTSP stream; and
- copy its complete URL.
Typical forms are:
rtsp://CONTROLLER_OR_CAMERA_IP:7447/STREAM_ID
rtsps://CONTROLLER_OR_CAMERA_IP:7441/STREAM_ID?enableSrtp
Use the exact URL Protect provides. The bridge uses TCP transport and automatically reconnects if the stream drops.
RTSP is receive-only; camera speaker talkback uses Protect's authenticated API. Create a dedicated local UniFi OS user, not a UI.com/cloud/SSO user. Grant that account Protect Full Management for the selected camera.
This broad local permission is currently required for Protect talkback. Use a unique password, limit the account to the needed console/camera where your Protect version permits it, and do not reuse a personal administrator account. The setup prompt asks for:
- the controller hostname/IP (an
https://prefix and custom port are accepted); - this local username and password; and
- the camera's exact Protect display name.
Self-signed local controller certificates are supported by the default
"verifyTls": false. See Credential security and persistence.
After ./start.sh reports Ready:
- Follow Home Assistant's local voice assistant guide to install local speech-to-text and text-to-speech services and create an Assist voice assistant. In a typical local setup, Speech-to-Phrase or Whisper handles speech-to-text and Piper handles text-to-speech.
- Go to Settings > Devices & services > Add integration.
- Select ESPHome.
- Enter the Docker host's LAN IP or hostname and port
6053. - Finish setup, then assign the voice assistant you created and the desired wake word to the new Assist satellite.
The voice assistant defines how Home Assistant processes requests and generates spoken responses; the satellite provides the camera microphone and speaker. Also expose the entities you want to control to Assist so voice commands can access them.
The default device name is unifi-camera-satellite. Port 6055 exposes LVA's
peripheral API; most Home Assistant setups only need 6053.
Home Assistant's Assist Satellite triggers can turn the camera's status light on while the satellite listens and off when the interaction returns to idle. Use the status-light switch that the UniFi Protect integration adds to the camera's device in Home Assistant. Replace both entity IDs below with your Assist satellite and camera status-light switch entities:
alias: UniFi camera satellite status light
mode: restart
triggers:
- trigger: assist_satellite.started_listening
target:
entity_id: assist_satellite.unifi_camera_satellite
id: listening
- trigger: assist_satellite.idle
target:
entity_id: assist_satellite.unifi_camera_satellite
id: idle
actions:
- choose:
- conditions: "{{ trigger.id == 'listening' }}"
sequence:
- action: switch.turn_on
target:
entity_id: switch.g4_instant_status_light
default:
- action: switch.turn_off
target:
entity_id: switch.g4_instant_status_lightThese are the integration's assist_satellite.started_listening and
assist_satellite.idle triggers, rather than fragile raw state matching. The
example switch.g4_instant_status_light entity is supplied by the UniFi
Protect integration; your entity ID will reflect your camera's name.
Confirm the virtual 16 kHz mono source exists:
docker compose exec audio-bridge pactl list sources shortThe output should include camera_mic, s16le, 1ch, and 16000Hz.
Measure three seconds while speaking near the camera:
docker compose exec audio-bridge bash -c \
'parec -d camera_mic --format=s16le --rate=16000 --channels=1 --raw |
ffmpeg -hide_banner -f s16le -ar 16000 -ac 1 -t 3 -i - \
-af volumedetect -f null - 2>&1 |
grep -E "mean_volume|max_volume"'Speech commonly produces a mean around -25 to -45 dB; approximately
-91 dB indicates silence.
Use Home Assistant's Assist Satellite Announce action or run a normal voice request that produces speech. Watch the bridge while testing:
docker compose logs -f speaker-bridge linux-voice-assistantThe speaker bridge should log that it connected to the exact camera and show the camera-provided AAC format. If it repeatedly reconnects, check the local account, Full Management permission, camera name, and speaker support.
| Task | Command |
|---|---|
| Start, update images, rebuild, and wait | ./start.sh |
| Stop containers (keep configuration/data) | docker compose down |
| Restart without pulling/building | docker compose up -d |
| Follow all logs | docker compose logs -f |
| Follow one bridge | docker compose logs -f audio-bridge |
| Show status/health | docker compose ps |
| Validate without starting | ./start.sh --check |
| Re-enter and replace configuration | ./start.sh --configure |
Because .env sets COMPOSE_PROFILES=speaker, ordinary Compose commands include
the speaker bridge. If you remove that setting during advanced configuration,
add --profile speaker to commands that should include talkback.
The interactive script is recommended. For automation or manual provisioning:
cp .env.example .env
cp ufp.json.example ufp.json
chmod 600 .env ufp.json
# Replace every REPLACE_* / CONSOLE_IP / stream placeholder.
docker compose --profile speaker config --quiet
docker compose --profile speaker pull linux-voice-assistant
docker compose --profile speaker up -d --buildDo not source .env in a shell: an RTSP URL may contain shell-significant
characters. Compose reads it directly.
| Setting | Default | Purpose |
|---|---|---|
COMPOSE_PROFILES |
speaker |
Enables the talkback service |
RTSP_URL |
required | Complete camera RTSP(S) stream URL |
MIC_RATE |
16000 |
Mono microphone sample rate required by LVA |
CLIENT_NAME |
unifi-camera-satellite |
ESPHome/LVA device name |
HOST |
0.0.0.0 |
LVA bind address inside the container |
LVA_PORT |
6053 |
Published ESPHome API port |
LVA_PERIPHERAL_PORT |
6055 |
Published LVA peripheral API port |
AUDIO_INPUT_DEVICE |
camera_mic |
PulseAudio virtual microphone |
AUDIO_INPUT_CHANNELS |
1 |
Mono input |
AUDIO_OUTPUT_DEVICE |
pulse/lva_out |
Valid MPV PulseAudio output |
TALKBACK_OUTPUT_MONITOR |
lva_out.monitor |
Source captured for talkback |
LISTEN_DURING_WAKE_SOUND |
1 |
Avoids missing speech while wake audio plays |
LVA_USER_ID / LVA_USER_GROUP |
1000 |
Ownership for persistent LVA data |
LVA_IMAGE / LVA_IMAGE_TAG |
upstream / latest |
Optional LVA image override |
ENABLE_DEBUG |
unset | Optional verbose LVA diagnostics |
LIST_DEVICES |
unset | Optional LVA audio-device listing |
ufp.json contains controller, username, password, and verifyTls.
Keep verifyTls: false for the usual self-signed local Protect certificate.
Set it to true only when the controller presents a certificate trusted inside
the container.
.env may contain credentials embedded in an RTSP URL. ufp.json contains the
local Protect password. Both are ignored by Git and start.sh sets mode 0600.
The Protect file is mounted read-only into only the speaker bridge. The setup
script writes temporary files with a private umask, atomically replaces the
final files, hides RTSP URL and password input, and does not echo either secret.
The files remain on the Docker host until you remove them. Docker named volumes
retain LVA configuration, downloaded wake models, custom sounds, and runtime
data. docker compose down preserves all of these; docker compose down -v
removes the named volumes but not .env or ufp.json.
The initial ESPHome setup dialog can keep spinning even though Home Assistant has added the device successfully. Go to Settings > Devices & services > ESPHome and open the new satellite device. Confirm that its Assistant and Wake word settings are assigned correctly, then review the remaining device settings before testing a voice request.
- Verify live microphone levels with the command above.
- Confirm the camera microphone and the selected RTSP stream's audio are enabled.
- In Home Assistant, assign a pipeline with a wake-word engine/model.
- Check
AUDIO_INPUT_DEVICE=camera_mic,AUDIO_INPUT_CHANNELS=1, andMIC_RATE=16000. - Inspect
docker compose logs -f audio-bridge linux-voice-assistant.
MPV must receive its own device syntax, not a raw PulseAudio monitor/source. Keep:
AUDIO_OUTPUT_DEVICE="pulse/lva_out"
TALKBACK_OUTPUT_MONITOR="lva_out.monitor"
LISTEN_DURING_WAKE_SOUND="1"An invalid MPV output device can block wake-sound playback and make listening
appear hung. Re-run ./start.sh --configure to restore known-working defaults.
- Speak while running the volume test; a static source listing only proves the virtual device exists.
- Verify the RTSP(S) URL and camera microphone setting.
- Check
audio-bridgelogs for ffmpeg authentication, TLS, codec, or reconnect errors. - Prefer the URL copied directly from Protect; do not invent the stream ID.
- Use a local UniFi OS account, not a cloud/SSO account.
- Grant Protect Full Management and verify the account can access the exact camera.
- Camera matching is case-insensitive, but the full display name must otherwise
match
PROTECT_CAMERA. - Run
./start.sh --configureafter changing credentials. - Confirm the camera reports speaker support in
speaker-bridgelogs.
First recreate the containers and shared runtime volume:
docker compose down
docker volume ls \
--filter "label=com.docker.compose.project=$(basename "$PWD")" \
--filter "label=com.docker.compose.volume=pulse"
# After confirming the listed volume belongs to this checkout:
docker volume rm "$(docker volume ls -q \
--filter "label=com.docker.compose.project=$(basename "$PWD")" \
--filter "label=com.docker.compose.volume=pulse")"
./start.shIf no volume is listed, inspect docker volume ls and remove only this
project's PulseAudio volume. Do not remove the LVA data volumes unless you
intend to reset LVA preferences/models.
- Use wired Ethernet where possible and keep the Docker host/controller/camera on a low-latency LAN.
- RTSP microphone input commonly adds roughly 1–2 seconds before processing.
- The bridge keeps talkback connected and uses low-buffer capture/encoding, but Protect and camera firmware add their own latency.
- Check host CPU pressure, packet loss, and repeated reconnects in both bridge logs.
- Avoid changing
lva_out.monitor; capturing the wrong source causes silence or buffering.
The camera microphone can hear its own speaker, and this virtual path does not provide hardware acoustic echo cancellation. Lower the camera speaker volume, shorten responses, increase physical separation, or use a camera/placement with better built-in echo handling.
repository.yaml- Home Assistant custom app repository metadataunifi-camera-voice/- installable Home Assistant OS apptest-addon.sh- local image build, smoke test, and real-camera test runnerstart.sh- safe first-run configuration, update/start, and health waitingdocker-compose.yml- LVA, bridge services, health checks, and named volumesaudio-bridge/- RTSP(S) to PulseAudiocamera_micspeaker-bridge/-lva_out.monitorto authenticated Protect talkback.env.example/ufp.json.example- advanced/manual configuration templates