Summary
Add thread-safe locking for wake word reload logic
Problem
Wake word reload logic accessed self.state.wake_words_changed flag without synchronization, creating a potential race condition when wake words are dynamically switched via Home Assistant.
Changes
Added self._wake_words_lock = threading.Lock() in __init__
Wrapped wake word reload block with with self._wake_words_lock: context manager
Ensures atomic read-modify-write of wake_words_changed flag
Impact
Risk: Very low - purely defensive improvement
Benefit: Eliminates race condition in wake word switching
Performance: Negligible overhead (lock only held during reload check)
What this fixes:
On boot, if PipeWire/Pulse starts with the sink at ~40%, LVA now pushes the sink volume to match the persisted LVA volume (0.0–1.0).
When you move the HA media_player volume slider, LVA now updates:
wpctl (PipeWire) if available
pactl (PulseAudio / PipeWire-Pulse) if available
amixer (ALSA) as a fallback
No reboot / alsactl store required — LVA simply re-applies the persisted volume at start.
What this fixes:
On boot, if PipeWire/Pulse starts with the sink at ~40%, LVA now pushes the sink volume to match the persisted LVA volume (0.0–1.0).
When you move the HA media_player volume slider, LVA now updates:
wpctl (PipeWire) if available
pactl (PulseAudio / PipeWire-Pulse) if available
amixer (ALSA) as a fallback
No reboot / alsactl store required — LVA simply re-applies the persisted volume at start.
What this fixes:
On boot, if PipeWire/Pulse starts with the sink at ~40%, LVA now pushes the sink volume to match the persisted LVA volume (0.0–1.0).
When you move the HA media_player volume slider, LVA now updates:
wpctl (PipeWire) if available
pactl (PulseAudio / PipeWire-Pulse) if available
amixer (ALSA) as a fallback
No reboot / alsactl store required — LVA simply re-applies the persisted volume at start.
Updated to align with the new XVF3800 initialization pathway / preflight behavior.
Keeps XVF3800 behavior isolated to XVF3800 mode so other LED backends remain unchanged.
Extends the existing XVF3800 USB control backend beyond LEDs to support startup preflight commands needed for audio reliability.
Implements command execution internally (no reliance on xvf_host.py) using the same USB control-transfer approach already used for LED features.
Adds XVF3800-specific initialization flow during controller startup.
Ensures the XVF3800 preflight runs at the right time so audio initialization sees the correct device state.
Added support for the XVF3800 per-LED command LED_RING_COLOR (12-LED ring, 48-byte payload).
Implemented/readied the device control logic so the backend can:
write per-LED frames (set_ring_rgb / set_ring_solid / clear_ring),
detect availability (supports_per_led) and fall back if unsupported.
Improved robustness/diagnostics for USB control transfers (more consistent request composition + debug logging around writes).
Prevented MQTT command/state feedback loops while keeping HA-friendly behavior:
accepts retained /state topics during a short startup bootstrap window to restore last-known settings,
then stops processing /state messages and unsubscribes from .../+/state after bootstrap.
Ensures runtime control is driven only by /set topics, while /state remains publish-only for HA UI synchronization.
Adds explicit logging around bootstrap completion to make startup behavior clear.
Integrated per-LED rendering into the existing effect/state pipeline:
solid/off uses ring frames directly,
blink toggles between frames (color ↔ off),
pulse now rewrites LED_RING_COLOR with scaled RGB per step (instead of relying on global brightness),
spin uses a single “dot” moving around the 12 LEDs.
Ensured the controller uses the XVF3800’s fixed ring size (12) where appropriate.
Fixed the “stuck in Responding” issue by subscribing the voice_idle handler so Idle effects reapply correctly when returning to IDLE.
Keeps all existing DotStar/NeoPixel behavior intact.
Adds a USB-based XVF3800 backend using the legacy LED controls (LED_EFFECT, LED_BRIGHTNESS, LED_SPEED, LED_COLOR).
Plugs it into LedController by treating "led_type": "xvf3800", "interface": "usb" as a new backend.
Behavioral changes:
Each wake word has its own timer.
Rapid repeats of the same phrase within the refractory window still get rejected (which is desirable).
A misfire of “Hey Jarvis” no longer locks out “Okay Nabu” immediately afterward.
If a user really wants to disable the gating, they can set:
"wake_word": {
"refractory_seconds": 0
}
and this code will treat it as “no refractory” (max(..., 0.0) above).
What’s in it:
Xvf3800RuntimeConfig – simple dataclass for VID/PID, timeouts, etc.
Xvf3800Controller(EventHandler) – structured very similar to your other controllers:
On init:
Checks enabled and hid availability.
Uses hid.enumerate() to find the XVF3800 by VID/PID.
Opens the first HID interface (non-blocking).
Starts a background thread _poll_loop().
_poll_loop():
Reads HID reports (read(64, timeout_ms=...)).
For each non-empty report:
Calls _parse_hw_mute_from_report(report) → Optional[bool].
If it returns True/False and the value changed, it publishes:
self.loop.call_soon_threadsafe(
self.event_bus.publish,
"set_mic_mute",
{"state": new_state},
)
Exits cleanly when self._running is false or state.shutdown is true.
@subscribe def set_mic_mute(self, data: dict):
Listens to the same set_mic_mute event MQTT and ButtonController use.
Updates _last_hw_mute_state.
Calls _send_hw_mute_state(desired) to push mute+LED down to hardware.
Placeholders:
def _parse_hw_mute_from_report(self, report: bytes) -> Optional[bool]:
# TODO: implement once we know the report layout
_LOGGER.debug("XVF3800 HID report (len=%d): %s", len(report), report.hex())
return None
def _send_hw_mute_state(self, muted: bool) -> None:
# TODO: implement once we know output report format
_LOGGER.debug("(stub) Would send HID command to set XVF3800 mute=%s", muted)
What it does:
Uses hidapi (pip install hidapi) to:
Enumerate devices with VID/PID 0x2886:0x001a (Seeed / XVF3800).
Open the first matching HID interface (or a specific --path).
Read reports in a loop and print them as timestamps + hex blobs.
You (or testers) run it, press/release the XVF mute button a few times, and paste the output into the Discussion so we can see which byte/bit is the mute state.
Summary
Add support for the ReSpeaker 2-Mic HAT momentary button as a first-class control surface for the Linux Voice Assistant. The button now behaves like:
Short press
If TTS or music is playing → stop playback (equivalent to the Stop wake word)
Otherwise → start a new conversation (equivalent to a wake word trigger)
Long press
Toggle microphone mute (wired through the existing set_mic_mute event, so MQTT state and LEDs stay in sync)
The implementation uses a polling-based GPIO loop (RPi.GPIO) instead of kernel edge-detection to avoid “Failed to add edge detection” issues on some HAT/overlay setups. Button behavior is fully configurable via config.json.
Extended the satellite state machine with a manual wake entrypoint:
Added manual_wakeup(self, phrase: str = "button"):
Mirrors the semantics of wakeup(), but does not require a wake-word model instance.
Respects the current state: only triggers from STARTING or IDLE.
If a timer alarm is ringing, calls the existing _stop_timer_alarm(...) instead of starting a new conversation (consistent with wake-word behavior).
Otherwise calls a shared helper _start_conversation(phrase).
Introduced _start_conversation(wake_word_phrase: str) to encapsulate:
Sending VoiceAssistantRequest(start=True, wake_word_phrase=...),
Transitioning to LISTENING,
Setting _is_streaming_audio = True,
Playing the configured wakeup_sound via tts_player.
Kept prior timer/alarm logic intact (including auto stop and Stop wake word behavior), so the button integrates cleanly with existing timer and TTS flows.
Imported the new ButtonController.
In _init_controllers():
Instantiated ButtonController when config.button.enabled is True.
Passed loop, event_bus, state, and config.button.
Stored the instance on state.button_controller to keep it alive for the lifetime of the process.
Left the rest of startup orchestration unchanged (audio engine, MQTT, LEDs, etc.), so the button is just an additional input surface plugged into the existing architecture.
Introduced a new ButtonController responsible for handling the ReSpeaker (or generic GPIO) button:
Uses RPi.GPIO with BCM numbering and internal pull-up (PUD_UP), assuming an active-low button.
Starts a dedicated polling thread that:
Reads the GPIO level at a configurable interval (default 10 ms / 100 Hz),
Detects press and release transitions,
Computes press duration to classify short vs long press.
Short press behavior:
If state.tts_player.is_playing or state.music_player.is_playing:
Schedules state.satellite.stop() on the asyncio loop,
Also stops music via state.music_player.stop().
Otherwise:
Schedules state.satellite.manual_wakeup("button") to start a new voice interaction (wake-like behavior without needing a model instance).
Long press behavior:
Toggles mic mute using the existing event path:
Publishes set_mic_mute on the EventBus with {"state": not state.mic_muted}.
This ensures MicMuteHandler remains the single writer for mic state, updates mic_muted_event, and syncs MQTT mute state/LEDs.
Designed to fail gracefully:
If ButtonConfig.enabled is False or RPi.GPIO is unavailable, logs a message and disables button support without affecting the rest of the system.
Added a new ButtonConfig dataclass:
enabled: bool = False
pin: int = 17 (BCM pin for the button)
long_press_seconds: float = 1.0
Extended the main Config dataclass with a button: ButtonConfig field.
Updated load_config_from_json() to construct button_config from the "button" section in config.json.
This allows enabling/disabling the button, changing the GPIO pin, and tuning the long-press threshold (and optional poll interval) without code changes.
Added a new user preference: alarm_duration_seconds (0 = infinite / Stop-only, >0 = auto-stop after N seconds).
Exposed that preference via MQTT/HA as a number entity (Alarm Duration) in seconds.
Updated the satellite timer logic so the alarm:
loops as before,
stops on the Stop wake word or wake wake word (existing semantics),
and optionally auto-stops after alarm_duration_seconds if configured.
Ensured all paths that stop the alarm cleanly restore audio state and wake word activation.
Added internal tracking for an alarm auto-stop:
self._timer_auto_stop_handle: Optional[asyncio.TimerHandle]
On VOICE_ASSISTANT_TIMER_FINISHED:
enables the Stop wake word,
starts the repeating timer_finished_sound alarm as before,
reads state.preferences.alarm_duration_seconds and, if > 0, schedules an auto-stop callback after that duration.
Centralized alarm stopping in helper methods:
_clear_timer_auto_stop() – cancels any pending auto-stop timer.
_stop_timer_alarm(reason: str) – stops the alarm sound, clears flags, unducks music, and removes the Stop wake word from active_wake_words.
_auto_stop_timer_alarm() – invoked by the scheduled timer, calls _stop_timer_alarm("auto_timeout").
Updated behavior paths:
wakeup() now stops a ringing alarm instead of starting a new conversation if a timer is active.
stop() (Stop wake word) stops a ringing alarm first; only falls back to stopping TTS if no alarm is active.
_play_timer_finished() continues to loop the sound until _timer_finished is cleared, then unducks and exits cleanly.
Added MQTT topics for the new alarm duration control:
lva/<device_id>/alarm_duration/set
lva/<device_id>/alarm_duration/state
Published a new Home Assistant number discovery config:
number.<device_id>_alarm_duration (0–3600 seconds, step 5, unit s).
On /alarm_duration/set:
validates and parses the payload as a non-negative integer,
publishes a set_alarm_duration event on the EventBus,
echoes the value back to /alarm_duration/state.
On connect, publishes the current alarm_duration_seconds from preferences to the state topic for retained startup behavior.
Updated Preferences dataclass with a new field:
alarm_duration_seconds: int = 0
Kept ServerState.save_preferences() unchanged so alarm_duration_seconds is automatically serialized into preferences.json.
Extended Preferences loading to initialize and backfill a new alarm_duration_seconds field (default 0).
Added a set_alarm_duration event handler to MicMuteHandler which:
validates and normalizes the duration value,
updates state.preferences.alarm_duration_seconds,
persists the updated preferences to disk.
Kept the existing “single writer” pattern (MicMuteHandler) for preferences/state updates.
File: linux_voice_assistant/__main__.py
In _init_basics(), the --list-output-devices block is updated so that:
It exits with 0 on success.
It exits with 1 only if an exception occurs.
Wrap the board import in a try/except and introduce a _BOARD_AVAILABLE flag.
If board (or Blinka/platformdetect) is not available or fails, log a clear warning and skip hardware LED initialization.
Keep the event subscriptions intact, but ensure all LED actions become no-ops when _is_ready is False.
Removed the home-grown backend probing / priority logic (pipewire → pulse → alsa) when no device is explicitly configured.
Kept support for explicitly specifying an audio_output_device in the config/CLI; if provided, it is passed through to mpv.
Added debug logging after mpv initialization to show:
current ao
current audio-device
the full audio-device-list
New CLI helpers
Added support for:
script/run --list-input-devices
script/run --list-output-devices
These code paths:
Initialize just enough of the audio stack to enumerate devices.
Print a clean, human-readable list of input/output devices (including PipeWire / PulseAudio virtual devices like Echo-Cancel Source and pipewire/echo-cancel-sink).
Exit cleanly with status 0 instead of trying to start the full LVA process afterward (fixing the previous CalledProcessError when --list-output-devices was used).
Behavior change
__main__.py now treats the “list devices” modes as early-exit utility modes, not “run LVA and then also print devices”.
This makes it practical to use these commands in setup docs and shell scripts.