Files
linux-voice-assistant/examples/Satellite1 HAT Board/Satellite1_HAT_Board.py
T
Omar Amin 99ee08f7b1 Enhance Satellite1 HAT Board volume button functionality with hold-repeat (#415)
Refactor the Satellite1 HAT Board volume button handling to support continuous hold-repeat behavior.
2026-09-02 01:23:16 +03:00

1475 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Satellite 1 HAT Board – Linux Voice Assistant peripheral controller.
Mirrors the Home Assistant Voice PE LED ring animations and maps the four
hardware buttons to LVA peripheral API commands.
Hardware layout (Satellite 1 HAT on Raspberry Pi)
--------------------------------------------------
LED ring : 24 x WS2812 RGBW NeoPixels → GPIO 12 (PWM0)
Right btn : Volume Up → GPIO 17
Left btn : Volume Down → GPIO 27
Top btn : Mute / Unmute mic → GPIO 22
Bottom btn : Context action → GPIO 23
Single press:
idle → start_listening
listening → stop_pipeline
thinking → stop_pipeline
tts_speaking → stop_pipeline
timer_ringing → stop_timer_ringing
media_playing → stop_media_player
Multi-press (detected via timing):
double press (< 250ms between releases) → button_double_press
triple press (< 250ms between releases) → button_triple_press
long press (held > 1000ms) → button_long_press
Install dependencies
---------------------
pip install websockets rpi-ws281x gpiozero lgpio
Run
---
python3 Satellite1_HAT_Board.py
python3 Satellite1_HAT_Board.py --host 127.0.0.1 --port 6055 --debug
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import math
import random
import signal
import sys
import threading
import time
from enum import Enum
from typing import Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# Hardware dependencies (gracefully stubbed when not on real Pi hardware so
# the script can be imported / syntax-checked on a dev machine)
# ---------------------------------------------------------------------------
try:
from gpiozero import Button # type: ignore[import]
_HAS_GPIO = True
except ImportError:
_HAS_GPIO = False
logging.warning("gpiozero not found – button input disabled")
try:
from rpi_ws281x import PixelStrip, Color as _NeoColor # type: ignore[import]
_HAS_NEOPIXEL = True
except ImportError:
_HAS_NEOPIXEL = False
logging.warning("rpi_ws281x not found – LED output disabled")
try:
import websockets # type: ignore[import]
except ImportError:
sys.exit("websockets not installed. Run: pip install websockets")
# ===========================================================================
# Configuration – edit these to match your wiring
# ===========================================================================
DEFAULT_LVA_HOST = "localhost"
DEFAULT_LVA_PORT = 6055
# GPIO (BCM numbering)
LED_GPIO_PIN = 12
BTN_VOLUME_UP = 17 # Right button
BTN_VOLUME_DOWN = 27 # Left button
BTN_MUTE = 22 # Top button
BTN_ACTION = 23 # Bottom button
BTN_DEBOUNCE_MS = 30 # Milliseconds
# Continuous hold-repeat behaviour for the volume buttons (and, while the
# action button is held at the same time, the color wheel rotation). Once a
# volume button has been held past VOLUME_HOLD_THRESHOLD_MS, the associated
# step (volume up/down, or hue up/down when in color change mode) repeats
# every VOLUME_HOLD_REPEAT_S seconds until the button is released.
VOLUME_HOLD_THRESHOLD_MS = 1000 # Milliseconds before continuous repeat starts
VOLUME_HOLD_REPEAT_S = 0.15 # Seconds between repeated steps while held
# LED ring
LED_COUNT = 24
LED_FREQ_HZ = 800_000
LED_DMA = 10
LED_BRIGHTNESS = 168 # 66 % of 255 – matches ESPHome default
LED_INVERT = False
LED_CHANNEL = 0
# Four mic positions at the cardinal points (top, right, bottom, left),
# spaced LED_COUNT/4 apart. On the 24-LED ring: LEDs 0, 6, 12, 18.
MIC_LED_POSITIONS = tuple(i * (LED_COUNT // 4) for i in range(4))
# LEDs physically sitting in the AUX (3.5 mm) audio jack corner of the board,
# identified by their silkscreen designators on the Satellite1 HAT (D35, D1,
# D2). These three ring positions light up solid red whenever the media
# player volume is at zero (the `volume_muted` peripheral event), independent
# of whatever pipeline animation is currently running. Scaled to the 24-LED
# ring to cover the same angular span as the original 12-LED mapping —
# verify against your board revision and adjust if it differs.
AUX_JACK_LED_INDICES = (22, 23, 0, 1, 2)
# Volume Display effect
# Mirrors the ESPHome Voice PE "Volume Display" addressable_lambda, but
# rotated so the arc originates at the AUX jack corner LED (D1, index 0)
# instead of Voice PE's origin — matching this board's physical AUX jack
# placement. Shown as a temporary overlay for this many seconds after
# volume_changed.
VOLUME_DISPLAY_SECONDS = 2.5
# Default ring color (ESPHome default: 9.4 % R, 73.3 % G, 94.9 % B)
DEFAULT_R, DEFAULT_G, DEFAULT_B = 24, 187, 242
# HA Light entity registered with LVA via register_light. The same object_id
# is used to route incoming light_command events back to this script.
LIGHT_OBJECT_ID = "led_ring"
LIGHT_NAME = "LED Ring"
LIGHT_ICON = "mdi:circle-outline"
EFFECT_VOICE_ASSISTANT = "Voice Assistant"
# Reconnect delay when WebSocket connection to LVA is lost
RECONNECT_DELAY_S = 3.0
# Color wheel parameters (matches Home Assistant Voice PE)
# HSV hue rotation: ±10° per volume button press
HUE_INCREMENT = 10 # Degrees
DEFAULT_HUE = 195 # Cyan (matches default RGB 24, 187, 242)
DEFAULT_SATURATION = 1.0 # 100%
DEFAULT_VALUE = 1.0 # 100%
# ===========================================================================
# Color conversion utilities (HSV ↔ RGB)
# ===========================================================================
def hsv_to_rgb(hue: int, saturation: float, value: float) -> Tuple[int, int, int]:
"""
Convert HSV (Hue: 0-360, Saturation: 0-1, Value: 0-1) to RGB (0-255).
Mirrors the Home Assistant Voice PE color wheel.
"""
# Normalize hue to 0-360 range
hue = hue % 360
# Clamp saturation and value to 0-1
sat = max(0.0, min(1.0, saturation))
val = max(0.0, min(1.0, value))
# Ensure saturation is at least 5% to avoid desaturated colors
if sat < 0.05:
sat = 1.0
c = val * sat # Chroma
h_prime = hue / 60.0
x = c * (1.0 - abs(h_prime % 2.0 - 1.0))
if h_prime < 1:
r_prime, g_prime, b_prime = c, x, 0.0
elif h_prime < 2:
r_prime, g_prime, b_prime = x, c, 0.0
elif h_prime < 3:
r_prime, g_prime, b_prime = 0.0, c, x
elif h_prime < 4:
r_prime, g_prime, b_prime = 0.0, x, c
elif h_prime < 5:
r_prime, g_prime, b_prime = x, 0.0, c
else:
r_prime, g_prime, b_prime = c, 0.0, x
m = val - c
r = int((r_prime + m) * 255)
g = int((g_prime + m) * 255)
b = int((b_prime + m) * 255)
return (r, g, b)
def rgb_to_hsv(r: int, g: int, b: int) -> Tuple[int, float, float]:
"""
Convert RGB (0-255) to HSV (Hue: 0-360, Saturation: 0-1, Value: 0-1).
"""
r_norm = r / 255.0
g_norm = g / 255.0
b_norm = b / 255.0
max_c = max(r_norm, g_norm, b_norm)
min_c = min(r_norm, g_norm, b_norm)
delta = max_c - min_c
# Value
value = max_c
# Saturation
if max_c == 0:
saturation = 0.0
else:
saturation = delta / max_c
# Hue
if delta == 0:
hue = 0
elif max_c == r_norm:
hue = (60 * ((g_norm - b_norm) / delta) + 360) % 360
elif max_c == g_norm:
hue = (60 * ((b_norm - r_norm) / delta) + 120) % 360
else: # max_c == b_norm
hue = (60 * ((r_norm - g_norm) / delta) + 240) % 360
return (int(hue), saturation, value)
# ===========================================================================
# Logging
# ===========================================================================
_LOGGER = logging.getLogger("satellite1")
# ===========================================================================
# Assistant state
# ===========================================================================
class AssistState(str, Enum):
NOT_READY = "not_ready"
IDLE = "idle"
WAKE_WORD = "wake_word_detected"
LISTENING = "listening"
THINKING = "thinking"
SPEAKING = "tts_speaking"
TTS_FINISHED = "tts_finished"
ERROR = "error"
MUTED = "muted"
TIMER_TICKING = "timer_ticking"
TIMER_RINGING = "timer_ringing"
MEDIA_PLAYING = "media_player_playing"
VOLUME_MUTED = "volume_muted"
# ---------------------------------------------------------------------------
# Shared state (written by WS thread, read by LED thread + button callbacks)
# ---------------------------------------------------------------------------
class SharedState:
def __init__(self) -> None:
self._lock = threading.Lock()
self.assist_state: AssistState = AssistState.NOT_READY
self.ha_connected: bool = False
self.muted: bool = False # mic mute
self.volume: float = 1.0
# Monotonic deadline until which the Volume Display arc takes over
# the ring, set on each volume_changed event. 0 (or in the past)
# means the arc is not showing.
self.volume_display_until: float = 0.0
self.volume_muted: bool = False # media player volume zero
self.timer_total_seconds: int = 0
self.timer_seconds_left: int = 0
# Light entity state, driven by HA via light_command events.
# Defaults match the LEDLightEntity in LVA core so the script
# behaves sensibly before the first light_command arrives: off by
# default, so idle stays dark until the user turns the light on.
self.light_is_on: bool = False
self.light_brightness: float = 0.66
self.light_red: float = 0.094
self.light_green: float = 0.733
self.light_blue: float = 0.949
# HSV color tracking for hue rotation via volume buttons
self.hsv_hue: int = DEFAULT_HUE
self.hsv_saturation: float = DEFAULT_SATURATION
self.hsv_value: float = DEFAULT_VALUE
# True for as long as the action (bottom) button is physically held
# down, regardless of press duration. Drives the "light up while
# pressed" tactile feedback in LEDRing (only applied when the HA
# Light entity is off — see LEDRing._anim_action_held).
self.action_button_down: bool = False
def update(self, **kwargs) -> None:
with self._lock:
for key, val in kwargs.items():
setattr(self, key, val)
@property
def snapshot(self) -> dict:
with self._lock:
return {
"assist_state": self.assist_state,
"ha_connected": self.ha_connected,
"muted": self.muted,
"volume": self.volume,
"volume_display_until": self.volume_display_until,
"volume_muted": self.volume_muted,
"timer_total_seconds": self.timer_total_seconds,
"timer_seconds_left": self.timer_seconds_left,
"light_is_on": self.light_is_on,
"light_brightness": self.light_brightness,
"light_red": self.light_red,
"light_green": self.light_green,
"light_blue": self.light_blue,
"hsv_hue": self.hsv_hue,
"hsv_saturation": self.hsv_saturation,
"hsv_value": self.hsv_value,
"action_button_down": self.action_button_down,
}
# ===========================================================================
# LED ring controller
# Mirrors every animation from the ESPHome home-assistant-voice.yaml exactly.
# ===========================================================================
RGB = Tuple[int, int, int]
BLACK: RGB = (0, 0, 0)
RED: RGB = (255, 0, 0)
def _scale(color: RGB, factor: float) -> RGB:
"""Scale an RGB colour by 0.0–1.0."""
f = max(0.0, min(1.0, factor))
return (int(color[0] * f), int(color[1] * f), int(color[2] * f))
def _neo(color: RGB) -> int:
"""Convert (r, g, b) to a rpi_ws281x 24-bit integer."""
if _HAS_NEOPIXEL:
return _NeoColor(color[0], color[1], color[2])
return 0
class LEDRing:
"""
Manages the 12-LED NeoPixel ring.
All animations run in a dedicated daemon thread. Calling
``set_animation()`` switches cleanly to the new pattern.
"""
# Animation names mirror ESPHome effect names for easy cross-reference
ANIM_IDLE = "idle" # Idle with light color when on
ANIM_OFF = "off"
ANIM_TWINKLE = "twinkle" # Not-ready / no HA
ANIM_TWINKLE_BLUE = "twinkle_blue" # Init / connecting
ANIM_WAIT_CMD = "waiting" # Wake word detected (slow spin)
ANIM_LISTENING = "listening" # STT active (fast spin)
ANIM_THINKING = "thinking" # Intent processing (pulse pair)
ANIM_REPLYING = "replying" # TTS speaking (reverse spin)
ANIM_MUTED = "muted" # Muted indicators
ANIM_ERROR = "error" # Red pulse
ANIM_TIMER_TICK = "timer_tick" # Countdown arc
ANIM_TIMER_RING = "timer_ring" # Ringing pulse
def __init__(self, state: SharedState) -> None:
self._state = state
self._pixels: List[RGB] = [BLACK] * LED_COUNT
self._strip = None
self._animation = self.ANIM_OFF
self._anim_lock = threading.Lock()
self._stop_event = threading.Event()
self._thread = threading.Thread(
target=self._loop, daemon=True, name="led-ring"
)
# Per-animation internal counters (reset on animation change)
self._index: int = 0
self._brightness_step: int = 0
self._brightness_dec: bool = True
self._twinkle_state: List[float] = [0.0] * LED_COUNT
if _HAS_NEOPIXEL:
self._strip = PixelStrip(
LED_COUNT, LED_GPIO_PIN, LED_FREQ_HZ,
LED_DMA, LED_INVERT, LED_BRIGHTNESS, LED_CHANNEL,
)
self._strip.begin()
_LOGGER.info("LED ring initialised (%d LEDs on GPIO %d)", LED_COUNT, LED_GPIO_PIN)
def start(self) -> None:
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
self._thread.join(timeout=2)
self._all_off()
def set_animation(self, name: str) -> None:
with self._anim_lock:
if self._animation != name:
_LOGGER.debug("LED animation → %s", name)
self._animation = name
# Reset counters on animation change (mirrors ESPHome initial_run)
self._index = 0
self._brightness_step = 0
self._brightness_dec = True
self._twinkle_state = [random.random() for _ in range(LED_COUNT)]
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _write(self) -> None:
if not _HAS_NEOPIXEL or self._strip is None:
return
for i, color in enumerate(self._pixels):
self._strip.setPixelColor(i, _neo(color))
self._strip.show()
def _all_off(self) -> None:
self._pixels = [BLACK] * LED_COUNT
self._write()
def _set(self, i: int, color: RGB) -> None:
self._pixels[i % LED_COUNT] = color
def _color(self) -> RGB:
"""Return the current user-configured ring colour from light entity state."""
snap = self._state.snapshot
return (
int(max(0.0, min(1.0, snap["light_red"])) * 255),
int(max(0.0, min(1.0, snap["light_green"])) * 255),
int(max(0.0, min(1.0, snap["light_blue"])) * 255),
)
def _pulse_step(self, steps: int = 10) -> float:
"""Advance pulse counter and return brightness 0.0–1.0."""
factor = (steps - self._brightness_step) / steps
if self._brightness_dec:
self._brightness_step += 1
else:
self._brightness_step -= 1
if self._brightness_step <= 0 or self._brightness_step >= steps:
self._brightness_dec = not self._brightness_dec
return factor
# ------------------------------------------------------------------
# Animation implementations (each returns sleep time in seconds)
# All logic mirrors the ESPHome addressable_lambda effects verbatim.
# ------------------------------------------------------------------
def _anim_idle(self) -> float:
"""Resting state with user color when light is on, else dark."""
snap = self._state.snapshot
if snap["light_is_on"]:
# Show user color at configured brightness
color = self._color()
brightness = snap["light_brightness"]
for i in range(LED_COUNT):
self._set(i, _scale(color, brightness))
self._write()
else:
self._all_off()
return 0.1
def _anim_action_held(self, color: RGB, brightness: float) -> float:
"""
Force the ring fully on at the current (or default, if none has been
chosen yet) light color while the action button is physically held
down and the HA Light entity is off.
Only takes over when the Light entity is off — if it's already on,
the idle animation is already showing the color and the ring is
left alone. Gives instant visual feedback for every press,
including a very short tap (turns off the moment the button is
released), and refreshes quickly so that rotating the color wheel
(holding action + a volume button) shows up on the ring in real
time.
"""
for i in range(LED_COUNT):
self._set(i, _scale(color, brightness))
self._write()
return 0.05
def _anim_off(self) -> float:
self._all_off()
return 0.1
def _anim_twinkle(self, color: RGB) -> float:
"""Random sparkle – used for Not Ready and No HA connection states."""
FADE = 0.85
SPARK_PROB = 0.15
for i in range(LED_COUNT):
if random.random() < SPARK_PROB:
self._twinkle_state[i] = 1.0
else:
self._twinkle_state[i] *= FADE
self._set(i, _scale(color, self._twinkle_state[i]))
self._write()
return 0.05
def _anim_spin(self, color: RGB, interval: float, reverse: bool = False) -> float:
"""
Shared spin pattern for Waiting / Listening / Replying.
Mirrors the ESPHome "Waiting for Command", "Listening For Command"
and "Replying" addressable_lambda effects.
"""
if reverse:
# Replying goes anticlockwise: index decrements
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
offsets = [(0, 1.0), (1, 192/255), (2, 128/255),
(6, 1.0), (7, 192/255), (8, 128/255)]
else:
offsets = [(0, 1.0), (11, 192/255), (10, 128/255),
(6, 1.0), (5, 192/255), (4, 128/255)]
for i in range(LED_COUNT):
self._set(i, BLACK)
for offset, brightness in offsets:
self._set((self._index + offset) % LED_COUNT, _scale(color, brightness))
if not reverse:
self._index = (self._index + 1) % LED_COUNT
self._write()
return interval
def _anim_thinking(self, color: RGB) -> float:
"""
Two opposing LEDs pulsing in brightness.
Mirrors ESPHome "Thinking" effect. Index does NOT advance.
"""
factor = self._pulse_step(10)
for i in range(LED_COUNT):
if i == self._index % LED_COUNT or i == (self._index + 6) % LED_COUNT:
self._set(i, _scale(color, factor))
else:
self._set(i, BLACK)
self._write()
return 0.01
def _anim_muted(self, color: RGB, muted: bool) -> float:
"""
Solid ring with red indicators at all 4 mic positions when muted.
"""
for i in range(LED_COUNT):
self._set(i, color)
if muted:
self._apply_mic_indicators()
self._write()
return 0.016
def _anim_error(self) -> float:
"""
All LEDs red, pulsing. Mirrors ESPHome "Error" effect.
"""
factor = self._pulse_step(10)
for i in range(LED_COUNT):
self._set(i, _scale(RED, factor))
self._write()
return 0.01
def _anim_timer_ring(self, color: RGB, muted: bool) -> float:
"""
All LEDs pulse with ring colour; red at all 4 mic positions if muted.
Mirrors ESPHome "Timer Ring" effect.
"""
factor = self._pulse_step(10)
for i in range(LED_COUNT):
self._set(i, _scale(color, factor))
if muted:
# 4 mic positions: LEDs 0, 3, 6, 9
self._set(0, _scale(RED, factor))
self._set(3, _scale(RED, factor))
self._set(6, _scale(RED, factor))
self._set(9, _scale(RED, factor))
self._write()
return 0.01
def _anim_timer_tick(
self, color: RGB, muted: bool,
seconds_left: int, total_seconds: int,
) -> float:
"""
Arc of LEDs proportional to remaining time.
Mirrors ESPHome "Timer Tick" effect exactly, including the
brightness-dip on the sweep marker LED.
"""
total = max(total_seconds, 1)
timer_ratio = LED_COUNT * seconds_left / total
last_led_on = max(0, math.ceil(timer_ratio) - 1)
# Index sweeps anticlockwise (matches ESPHome anticlockwise decrement)
for i in range(LED_COUNT):
brightness_dip = (
0.9 if (i == self._index % LED_COUNT and i != last_led_on) else 1.0
)
if i <= timer_ratio:
brightness = min(brightness_dip * (timer_ratio - i), brightness_dip)
self._set(i, _scale(color, brightness))
else:
self._set(i, BLACK)
if muted:
self._apply_mic_indicators()
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
self._write()
return 0.1
def _anim_volume_display(self, color: RGB, volume: float) -> float:
"""
Arc showing the current volume level, originating at the AUX jack
corner LED (D1, index 0) and sweeping clockwise around the ring.
Mirrors the ESPHome Voice PE "Volume Display" addressable_lambda
exactly, except the arc's origin is rotated from LED 6 to LED 0 to
match this board's AUX jack corner LED placement.
"""
silenced_color = RED
volume_ratio = LED_COUNT * max(0.0, min(1.0, volume))
for i in range(LED_COUNT):
if i <= volume_ratio:
brightness = min(volume_ratio - i, 1.0)
self._set(i, _scale(color, brightness))
else:
self._set(i, BLACK)
if volume <= 0.0:
self._set(0, silenced_color)
self._write()
return 0.05 # matches the 50ms update_interval of the ESPHome effect
def _apply_mic_indicators(self) -> None:
"""
Mark all 4 mic positions (cardinal points) red on the already
rendered frame, blanking immediate neighbours so each indicator
reads as a distinct dot. Positions are LED_COUNT/4 apart —
LEDs 0, 6, 12, 18 on the 24-LED ring.
"""
for pos in MIC_LED_POSITIONS:
self._set((pos - 1) % LED_COUNT, BLACK)
self._set(pos, RED)
self._set((pos + 1) % LED_COUNT, BLACK)
def _apply_mic_indicators_pulsed(self, factor: float) -> None:
"""Pulsed version for timer_ring — keeps indicators in sync with ring brightness."""
for pos in MIC_LED_POSITIONS:
self._set(pos, _scale(RED, factor))
def _apply_volume_muted_indicator(self) -> None:
"""
Overlay a solid red indicator on the AUX jack corner LEDs
(designators D35, D1, D2 — see ``AUX_JACK_LED_INDICES``) when the
media player output is muted.
Applied as a post-render overlay after the current pipeline
animation has already written its frame, so the indicator shows up
consistently no matter which animation is active, without having to
thread the flag through every individual ``_anim_*`` method.
"""
for i in AUX_JACK_LED_INDICES:
self._set(i, RED)
self._write()
# ------------------------------------------------------------------
# Animation loop
# ------------------------------------------------------------------
def _loop(self) -> None:
while not self._stop_event.is_set():
with self._anim_lock:
anim = self._animation
snap = self._state.snapshot
color = self._color()
muted = snap["muted"]
volume_muted = snap["volume_muted"]
t_total = snap["timer_total_seconds"]
t_left = snap["timer_seconds_left"]
ha_connected = snap["ha_connected"]
action_button_down = snap["action_button_down"]
# Volume Display takes over the ring temporarily, regardless of
# the current pipeline state, then falls back to the normal
# animation once its deadline passes.
if snap["volume_display_until"] > time.monotonic():
sleep = self._anim_volume_display(color, snap["volume"])
# The action button forces the ring on at the current (or
# default) color while physically held down — but only when the
# HA Light entity is off; if it's already on, the idle animation
# is already showing the color and there's nothing extra to do.
# This also carries live updates while the color wheel is being
# rotated (holding action + a volume button), since that
# rotation writes the new color straight into shared state.
elif action_button_down and not snap["light_is_on"]:
sleep = self._anim_action_held(color, snap["light_brightness"])
elif anim == self.ANIM_IDLE:
sleep = self._anim_idle()
elif anim == self.ANIM_OFF:
sleep = self._anim_off()
elif anim == self.ANIM_TWINKLE:
sleep = self._anim_twinkle(RED)
elif anim == self.ANIM_TWINKLE_BLUE:
sleep = self._anim_twinkle(color)
elif anim == self.ANIM_WAIT_CMD:
sleep = self._anim_spin(color, interval=0.1, reverse=False)
elif anim == self.ANIM_LISTENING:
sleep = self._anim_spin(color, interval=0.05, reverse=False)
elif anim == self.ANIM_THINKING:
sleep = self._anim_thinking(color)
elif anim == self.ANIM_REPLYING:
sleep = self._anim_spin(color, interval=0.05, reverse=True)
elif anim == self.ANIM_MUTED:
sleep = self._anim_muted(color, muted=True)
elif anim == self.ANIM_ERROR:
sleep = self._anim_error()
elif anim == self.ANIM_TIMER_RING:
sleep = self._anim_timer_ring(color, muted)
elif anim == self.ANIM_TIMER_TICK:
sleep = self._anim_timer_tick(color, muted, t_left, t_total)
else:
sleep = self._anim_off()
if volume_muted:
self._apply_volume_muted_indicator()
time.sleep(sleep)
# ===========================================================================
# State → animation mapping
# Mirrors the control_leds script priority logic from ESPHome
# ===========================================================================
def state_to_animation(state: AssistState, ha_connected: bool, muted: bool) -> str:
if not ha_connected:
return LEDRing.ANIM_TWINKLE # Red twinkle = no HA connection
if state == AssistState.NOT_READY:
return LEDRing.ANIM_TWINKLE # Red twinkle
if state == AssistState.TIMER_RINGING:
return LEDRing.ANIM_TIMER_RING
if state == AssistState.WAKE_WORD:
return LEDRing.ANIM_WAIT_CMD # Slow clockwise spin
if state == AssistState.LISTENING:
return LEDRing.ANIM_LISTENING # Fast clockwise spin
if state == AssistState.THINKING:
return LEDRing.ANIM_THINKING # Pulsing pair
if state == AssistState.SPEAKING:
return LEDRing.ANIM_REPLYING # Anticlockwise spin
if state == AssistState.ERROR:
return LEDRing.ANIM_ERROR # Red pulse
if state == AssistState.MUTED or muted:
return LEDRing.ANIM_MUTED
if state == AssistState.TIMER_TICKING:
return LEDRing.ANIM_TIMER_TICK # Countdown arc
if state == AssistState.IDLE:
return LEDRing.ANIM_IDLE # User color when light on, else off
# MEDIA_PLAYING, TTS_FINISHED → off
return LEDRing.ANIM_OFF
# ===========================================================================
# Button handler — uses gpiozero (works on Pi 3/4/5 without RPi.GPIO)
# ===========================================================================
class ButtonHandler:
"""
Manages the four hardware buttons using gpiozero.
All callbacks run in the GPIO event-detection thread and schedule
coroutines onto the asyncio event loop thread-safely.
Supports:
- Volume +/- for volume control. A short press sends a single step;
holding the button past VOLUME_HOLD_THRESHOLD_MS repeats that step
every VOLUME_HOLD_REPEAT_S seconds until released, so volume moves
gradually while held instead of one step per press.
- Mute button for microphone toggle.
- Action button for context-aware commands or color changing:
* Hold + Volume +: Increase hue by 10°
* Hold + Volume -: Decrease hue by 10°
Holding the volume button past VOLUME_HOLD_THRESHOLD_MS while the
action button is held rotates continuously through the color wheel
using the same repeat mechanism, until the volume button is released.
Color changes are applied to the LED ring immediately so the wheel
rotation previews in real time.
- The action button also lights up the LED ring at the chosen (or
default) color for as long as it's physically held down — even for a
quick tap — turning off again on release. This only happens when the
HA Light entity is off; if it's already on, the idle animation is
already showing the color.
"""
def __init__(
self,
state: SharedState,
loop: asyncio.AbstractEventLoop,
command_queue: asyncio.Queue,
) -> None:
self._state = state
self._loop = loop
self._queue = command_queue
self._buttons: List = []
# Track action button hold state for color changing
self._action_button_held = False
self._action_button_obj = None
# Continuous hold-repeat state for the volume buttons, keyed by
# "up" / "down". `_volume_hold_timers` fires once the button has
# been held past VOLUME_HOLD_THRESHOLD_MS to kick off the repeat
# loop; `_volume_repeat_stop` / `_volume_repeat_threads` drive that
# loop until the button is released.
self._volume_hold_timers: Dict[str, Optional[threading.Timer]] = {"up": None, "down": None}
self._volume_repeat_stop: Dict[str, Optional[threading.Event]] = {"up": None, "down": None}
self._volume_repeat_threads: Dict[str, Optional[threading.Thread]] = {"up": None, "down": None}
def setup(self) -> None:
if not _HAS_GPIO:
_LOGGER.warning("GPIO unavailable – buttons not configured")
return
debounce = BTN_DEBOUNCE_MS / 1000.0
btn_up = Button(BTN_VOLUME_UP, pull_up=True, bounce_time=debounce)
btn_down = Button(BTN_VOLUME_DOWN, pull_up=True, bounce_time=debounce)
btn_mute = Button(BTN_MUTE, pull_up=True, bounce_time=debounce)
btn_act = Button(BTN_ACTION, pull_up=True, bounce_time=debounce)
btn_up.when_pressed = lambda: self._on_volume_button_pressed("up")
btn_up.when_released = lambda: self._on_volume_button_released("up")
btn_down.when_pressed = lambda: self._on_volume_button_pressed("down")
btn_down.when_released = lambda: self._on_volume_button_released("down")
btn_mute.when_pressed = self._on_mute
btn_act.when_pressed = self._on_action_pressed
btn_act.when_held = self._on_action_held
btn_act.when_released = self._on_action_button_released
btn_act.hold_time = 0.1 # 100ms to detect hold
# Keep references — gpiozero Buttons are released when garbage-collected
self._buttons = [btn_up, btn_down, btn_mute, btn_act]
self._action_button_obj = btn_act
_LOGGER.info(
"Buttons configured (gpiozero BCM %d/%d/%d/%d)",
BTN_VOLUME_UP, BTN_VOLUME_DOWN, BTN_MUTE, BTN_ACTION,
)
def cleanup(self) -> None:
for which in ("up", "down"):
self._cancel_volume_hold_timer(which)
self._stop_volume_repeat(which)
for btn in self._buttons:
btn.close()
self._buttons.clear()
def _send(self, command: str) -> None:
_LOGGER.info("Button → %s", command)
asyncio.run_coroutine_threadsafe(
self._queue.put(command), self._loop
)
def _on_volume_up(self) -> None:
if self._action_button_held:
self._on_color_hue_increase()
else:
self._send("volume_up")
def _on_volume_down(self) -> None:
if self._action_button_held:
self._on_color_hue_decrease()
else:
self._send("volume_down")
def _on_volume_button_pressed(self, which: str) -> None:
"""
Handle a Volume Up/Down press: fire the immediate single step (volume,
or hue when the action button is held for color changing), then arm a
timer that starts continuous repeat if the button is still held past
VOLUME_HOLD_THRESHOLD_MS.
"""
if which == "up":
self._on_volume_up()
else:
self._on_volume_down()
self._cancel_volume_hold_timer(which)
timer = threading.Timer(
VOLUME_HOLD_THRESHOLD_MS / 1000.0, self._start_volume_repeat, args=(which,)
)
timer.daemon = True
self._volume_hold_timers[which] = timer
timer.start()
def _on_volume_button_released(self, which: str) -> None:
"""Stop any pending hold detection and any active continuous repeat."""
self._cancel_volume_hold_timer(which)
self._stop_volume_repeat(which)
def _cancel_volume_hold_timer(self, which: str) -> None:
timer = self._volume_hold_timers.get(which)
if timer is not None:
timer.cancel()
self._volume_hold_timers[which] = None
def _start_volume_repeat(self, which: str) -> None:
"""
Begin repeating the volume/hue step every VOLUME_HOLD_REPEAT_S while
the button stays held. Runs in its own daemon thread so the GPIO
event-detection thread is never blocked; stopped via a threading.Event
set from `_on_volume_button_released`.
"""
self._stop_volume_repeat(which) # Safety: never overlap two loops
stop_event = threading.Event()
self._volume_repeat_stop[which] = stop_event
def _repeat_loop() -> None:
while not stop_event.wait(VOLUME_HOLD_REPEAT_S):
if which == "up":
self._on_volume_up()
else:
self._on_volume_down()
thread = threading.Thread(
target=_repeat_loop, daemon=True, name=f"volume-hold-repeat-{which}"
)
self._volume_repeat_threads[which] = thread
thread.start()
def _stop_volume_repeat(self, which: str) -> None:
stop_event = self._volume_repeat_stop.get(which)
if stop_event is not None:
stop_event.set()
thread = self._volume_repeat_threads.get(which)
if thread is not None and thread.is_alive():
thread.join(timeout=0.5)
self._volume_repeat_stop[which] = None
self._volume_repeat_threads[which] = None
def _on_action_pressed(self) -> None:
"""
Action button physically pressed down.
Fires the usual context-sensitive command immediately (unchanged
behaviour), and also marks the button as held down so the LED ring
can react instantly — even for a very short tap. The ring itself
only lights up in response to this when the HA Light entity is off
(see LEDRing._anim_action_held); if it's already on, the idle
animation is already showing the color and there's nothing extra
to do.
"""
self._state.update(action_button_down=True)
self._on_action()
def _on_action_button_released(self) -> None:
"""Action button released – exit color-change mode and stop forcing the ring on."""
self._on_action_released()
self._state.update(action_button_down=False)
def _on_action_held(self) -> None:
"""Action button held – enter color change mode."""
self._action_button_held = True
_LOGGER.info("Color change mode active (hold action + use volume buttons)")
def _on_action_released(self) -> None:
"""Action button released – exit color change mode."""
self._action_button_held = False
def _on_color_hue_increase(self) -> None:
"""Increase hue by 10° (volume up while action held)."""
snap = self._state.snapshot
new_hue = (snap["hsv_hue"] + HUE_INCREMENT) % 360
self._send_color_command(new_hue, snap["hsv_saturation"], snap["hsv_value"])
def _on_color_hue_decrease(self) -> None:
"""Decrease hue by 10° (volume down while action held)."""
snap = self._state.snapshot
new_hue = (snap["hsv_hue"] - HUE_INCREMENT) % 360
self._send_color_command(new_hue, snap["hsv_saturation"], snap["hsv_value"])
def _send_color_command(self, hue: int, saturation: float, value: float) -> None:
"""
Convert HSV to RGB, apply it to shared state immediately for a
real-time preview on the LED ring, and notify LVA/Home Assistant.
"""
r, g, b = hsv_to_rgb(hue, saturation, value)
# Update local state right away so the ring reflects the new color
# as it's being picked, without waiting on a light_command
# round-trip through LVA/Home Assistant. This also turns the light
# "on" — picking a color is an explicit, deliberate action.
self._state.update(
hsv_hue=hue,
hsv_saturation=saturation,
hsv_value=value,
light_is_on=True,
light_red=r / 255.0,
light_green=g / 255.0,
light_blue=b / 255.0,
)
# Normalize RGB to 0-1 range
light_cmd = {
"command": "light_command",
"data": {
"object_id": LIGHT_OBJECT_ID,
"state": True,
"brightness": self._state.snapshot["light_brightness"],
"red": r / 255.0,
"green": g / 255.0,
"blue": b / 255.0,
},
}
_LOGGER.info("Color change: Hue=%d° → RGB(%d, %d, %d)", hue, r, g, b)
# Send command to LVA via command queue
asyncio.run_coroutine_threadsafe(
self._queue.put(json.dumps(light_cmd)), self._loop
)
def _on_mute(self) -> None:
"""Toggle mute: sends mute_mic or unmute_mic based on current state."""
if self._state.muted:
self._send("unmute_mic")
else:
self._send("mute_mic")
def _on_action(self) -> None:
"""
Context-sensitive bottom button — mirrors HA Voice PE priority:
1. Timer ringing → stop_timer_ringing
2. Pipeline active → stop_pipeline
3. Media playing → stop_media_player
4. Idle / anything else → start_listening
"""
assist = self._state.assist_state
if assist == AssistState.TIMER_RINGING:
self._send("stop_timer_ringing")
elif assist in (AssistState.WAKE_WORD, AssistState.LISTENING, AssistState.THINKING, AssistState.SPEAKING):
# stop_pipeline aborts the voice pipeline at any of these phases
self._send("stop_pipeline")
elif assist == AssistState.MEDIA_PLAYING:
self._send("stop_media_player")
else:
self._send("start_listening")
class ButtonMultipressHandler:
"""Handles multiple presses of the action button (double, triple, and long press) to trigger different commands. """
def __init__(self):
self.button_pin = BTN_ACTION
self.last_press_time = 0
self.press_count = 0
def _send(self, command: str) -> None:
_LOGGER.info("Button → %s", command)
asyncio.run_coroutine_threadsafe(
self._queue.put(command), self._loop
)
def button_pressed(self):
current_time = time.time()
time_since_last_press = current_time - self.last_press_time
if time_since_last_press <= 0.25:
self.press_count += 1
else:
self.press_count = 1
self.last_press_time = current_time
if self.press_count == 2:
self.handle_double_press()
elif self.press_count == 3:
self.handle_triple_press()
elif time_since_last_press > 1: # Long press considered after 1 second
self.handle_long_press()
def handle_double_press(self):
# Call LVA peripheral API for double press
self._send('button_double_press')
def handle_triple_press(self):
# Call LVA peripheral API for triple press
self._send('button_triple_press')
def handle_long_press(self):
# Call LVA peripheral API for long press
self._send('button_long_press')
# ===========================================================================
# WebSocket client
# ===========================================================================
class LVAClient:
"""
Maintains a persistent WebSocket connection to the LVA peripheral API.
- Receives events and updates SharedState + LEDRing.
- Drains command_queue and sends commands.
"""
def __init__(
self,
host: str,
port: int,
state: SharedState,
leds: LEDRing,
command_queue: asyncio.Queue,
) -> None:
self._uri = f"ws://{host}:{port}"
self._state = state
self._leds = leds
self._queue = command_queue
async def run_forever(self) -> None:
while True:
try:
await self._connect()
except Exception as exc: # pylint: disable=broad-except
_LOGGER.warning(
"LVA connection lost (%s) – retrying in %.0fs",
exc, RECONNECT_DELAY_S,
)
self._state.update(
ha_connected=False,
assist_state=AssistState.NOT_READY,
)
self._leds.set_animation(LEDRing.ANIM_TWINKLE)
await asyncio.sleep(RECONNECT_DELAY_S)
async def _connect(self) -> None:
_LOGGER.info("Connecting to LVA at %s …", self._uri)
async with websockets.connect(self._uri) as ws:
_LOGGER.info("Connected to LVA peripheral API")
# Register the LED Light entity with LVA so HA can control it.
# LVA treats repeat registrations for the same object_id as a
# no-op, so it's safe to send this on every connect.
await ws.send(json.dumps({
"command": "register_light",
"data": {
"name": LIGHT_NAME,
"object_id": LIGHT_OBJECT_ID,
"icon": LIGHT_ICON,
"effects": [EFFECT_VOICE_ASSISTANT],
"supports_rgb": True,
"supports_brightness": True,
},
}))
# Declare that this peripheral has physical buttons so LVA
# creates a Button Press event entity in Home Assistant.
# Idempotent: safe to send on every reconnect.
await ws.send(json.dumps({"command": "register_button"}))
recv_task = asyncio.create_task(self._recv_loop(ws))
send_task = asyncio.create_task(self._send_loop(ws))
done, pending = await asyncio.wait(
[recv_task, send_task],
return_when=asyncio.FIRST_EXCEPTION,
)
for task in pending:
task.cancel()
# Re-raise the first exception so run_forever() can handle it
for task in done:
if not task.cancelled() and task.exception():
raise task.exception()
async def _recv_loop(self, ws) -> None:
async for raw in ws:
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
await self._handle_event(msg)
async def _send_loop(self, ws) -> None:
while True:
item = await self._queue.get()
try:
# Item can be either a string (command) or a pre-formatted JSON dict (light_command)
if isinstance(item, str):
# Try to parse as JSON (for light_command) or wrap as command
try:
msg = json.loads(item)
except json.JSONDecodeError:
# Plain string command
msg = {"command": item}
else:
msg = item
await ws.send(json.dumps(msg))
except Exception as exc: # pylint: disable=broad-except
_LOGGER.warning("Failed to send item %s: %s", item, exc)
# Put it back so it's not silently dropped
self._queue.put_nowait(item)
raise
async def _handle_event(self, msg: dict) -> None:
event = msg.get("event", "")
data = msg.get("data") or {}
_LOGGER.debug("Event: %s data=%s", event, data)
# --- Snapshot (sent on connect) ------------------------------------
if event == "snapshot":
self._state.update(
muted=data.get("muted", False),
volume=data.get("volume", 1.0),
# Not currently sent by LVA's snapshot payload, but read it
# defensively in case a future LVA version adds it.
volume_muted=data.get("volume_muted", False),
ha_connected=data.get("ha_connected", False),
)
snap = self._state.snapshot
anim = state_to_animation(
snap["assist_state"], snap["ha_connected"], snap["muted"]
)
self._leds.set_animation(anim)
return
# --- Voice pipeline events -----------------------------------------
elif event == "wake_word_detected":
self._state.update(assist_state=AssistState.WAKE_WORD)
elif event == "listening":
self._state.update(assist_state=AssistState.LISTENING)
elif event == "thinking":
self._state.update(assist_state=AssistState.THINKING)
elif event == "tts_speaking":
self._state.update(assist_state=AssistState.SPEAKING)
elif event in ("tts_finished", "idle"):
self._state.update(assist_state=AssistState.IDLE)
elif event == "muted":
# Carries the mic mute state in both directions. Default True so a
# bare "muted" event (no data) still reads as muted.
muted = data.get("muted", True)
self._state.update(muted=muted)
if muted:
self._state.update(assist_state=AssistState.MUTED)
elif self._state.assist_state == AssistState.MUTED:
self._state.update(assist_state=AssistState.IDLE)
elif event == "pipeline_error":
_LOGGER.warning("LVA pipeline error: %s", data.get("reason", ""))
self._state.update(assist_state=AssistState.ERROR)
elif event == "disconnected":
_LOGGER.warning("Home Assistant disconnected")
self._state.update(
ha_connected=False,
assist_state=AssistState.NOT_READY,
)
elif event == "error":
reason = data.get("reason", "")
_LOGGER.warning("LVA error: %s", reason)
if reason == "ha_disconnected":
self._state.update(
ha_connected=False,
assist_state=AssistState.NOT_READY,
)
else:
self._state.update(assist_state=AssistState.ERROR)
# --- Timer events --------------------------------------------------
elif event == "timer_ticking":
self._state.update(
assist_state=AssistState.TIMER_TICKING,
timer_total_seconds=data.get("total_seconds", 0),
timer_seconds_left=data.get("seconds_left", 0),
)
elif event == "timer_updated":
self._state.update(
timer_total_seconds=data.get("total_seconds", 0),
timer_seconds_left=data.get("seconds_left", 0),
)
elif event == "timer_ringing":
self._state.update(
assist_state=AssistState.TIMER_RINGING,
timer_total_seconds=data.get("total_seconds", 0),
timer_seconds_left=data.get("seconds_left", 0),
)
# --- Media / volume events -----------------------------------------
elif event == "media_player_playing":
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
elif event == "volume_changed":
self._state.update(
volume=data.get("volume", 1.0),
volume_display_until=time.monotonic() + VOLUME_DISPLAY_SECONDS,
)
elif event == "volume_muted":
# Media player (speaker) mute state, distinct from the
# microphone `muted` event above. Drives the red indicator on
# the AUX jack corner LEDs (see AUX_JACK_LED_INDICES) rather
# than swapping the whole ring animation, since the assist
# pipeline can still be doing something else at the same time.
volume_muted = data.get("muted", True)
self._state.update(volume_muted=volume_muted)
if volume_muted:
self._state.update(assist_state=AssistState.VOLUME_MUTED)
elif self._state.assist_state == AssistState.VOLUME_MUTED:
self._state.update(assist_state=AssistState.IDLE)
# --- Zeroconf / connection events ----------------------------------
elif event == "zeroconf":
status = data.get("status", "")
if status == "connected":
self._state.update(ha_connected=True)
_LOGGER.info("Home Assistant connected")
elif status == "getting_started":
_LOGGER.info("LVA starting up, waiting for HA …")
# --- Light command events ------------------------------------------
elif event == "light_command":
# LVA broadcasts to every connected peripheral; only act on
# commands targeting our registered Light. The Light exposes a
# single "Voice Assistant" effect, so there is no effect to
# switch on: we apply on/off, brightness, and color and let the
# pipeline animations run.
if data.get("object_id") != LIGHT_OBJECT_ID:
return
r = float(data.get("red", 0.094))
g = float(data.get("green", 0.733))
b = float(data.get("blue", 0.949))
# Convert RGB back to HSV and track hue for color change mode
hue, sat, val = rgb_to_hsv(
int(r * 255), int(g * 255), int(b * 255)
)
self._state.update(
light_is_on=bool(data.get("state", True)),
light_brightness=float(data.get("brightness", 0.66)),
light_red=r,
light_green=g,
light_blue=b,
hsv_hue=hue,
hsv_saturation=sat,
hsv_value=val,
)
# Force re-render animation with new light state
snap = self._state.snapshot
anim = state_to_animation(
snap["assist_state"], snap["ha_connected"], snap["muted"]
)
self._leds.set_animation(anim)
return
# --- Recompute LED animation after every event ---------------------
snap = self._state.snapshot
anim = state_to_animation(
snap["assist_state"], snap["ha_connected"], snap["muted"]
)
self._leds.set_animation(anim)
# ===========================================================================
# Main
# ===========================================================================
async def async_main(host: str, port: int) -> None:
state = SharedState()
command_queue: asyncio.Queue = asyncio.Queue()
loop = asyncio.get_running_loop()
leds = LEDRing(state)
buttons = ButtonHandler(state, loop, command_queue)
client = LVAClient(host, port, state, leds, command_queue)
# Start hardware
leds.start()
buttons.setup()
# Start with "not ready" animation
leds.set_animation(LEDRing.ANIM_TWINKLE)
# Graceful shutdown on SIGINT / SIGTERM
def _shutdown(signum, frame) -> None:
_LOGGER.info("Shutdown requested")
leds.stop()
buttons.cleanup()
sys.exit(0)
signal.signal(signal.SIGINT, _shutdown)
signal.signal(signal.SIGTERM, _shutdown)
_LOGGER.info(
"Satellite 1 HAT controller started – connecting to ws://%s:%d",
host, port,
)
try:
await client.run_forever()
finally:
leds.stop()
buttons.cleanup()
def main() -> None:
parser = argparse.ArgumentParser(
description="Satellite 1 HAT Board controller for Linux Voice Assistant"
)
parser.add_argument(
"--host", default=DEFAULT_LVA_HOST,
help=f"LVA container hostname or IP (default: {DEFAULT_LVA_HOST})",
)
parser.add_argument(
"--port", type=int, default=DEFAULT_LVA_PORT,
help=f"LVA peripheral API port (default: {DEFAULT_LVA_PORT})",
)
parser.add_argument(
"--debug", action="store_true",
help="Enable DEBUG logging",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
)
asyncio.run(async_main(args.host, args.port))
if __name__ == "__main__":
main()