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.
This commit is contained in:
Omar Amin authored and GitHub committed 2026-09-02 01:23:16 +03:00
1 parent cfae3c55e2
commit 99ee08f7b1
2 files changed
+215 -20

No files matched your search

+28 -6
View File
@@ -104,10 +104,14 @@ target:
## Button behaviour ## Button behaviour
### Right button — Volume Up ### Right button — Volume Up
Sends `volume_up` to LVA. Each press increases volume by one step. Sends `volume_up` to LVA. A quick press increases volume by one step.
**Hold to adjust gradually:** keep the button held past 1000 ms and the controller keeps sending `volume_up` every ~150 ms — volume rises smoothly for as long as the button stays down, instead of one step per press. Release the button to stop.
### Left button — Volume Down ### Left button — Volume Down
Sends `volume_down` to LVA. Each press decreases volume by one step. Sends `volume_down` to LVA. A quick press decreases volume by one step.
**Hold to adjust gradually:** the same behaviour applies in reverse — holding the button past 1000 ms repeats `volume_down` every ~150 ms until released.
### Top button — Mute / Unmute ### Top button — Mute / Unmute
Toggles microphone mute. Sends `mute_mic` when unmuted, `unmute_mic` when muted. The LED ring switches to the muted indicator pattern immediately. Toggles microphone mute. Sends `mute_mic` when unmuted, `unmute_mic` when muted. The LED ring switches to the muted indicator pattern immediately.
@@ -127,6 +131,8 @@ Context-aware command based on current assistant state, mirroring the Home Assis
| Music / media playing | `stop_media_player` | | Music / media playing | `stop_media_player` |
| Any other (idle) | `start_listening` | | Any other (idle) | `start_listening` |
**LED feedback while pressed:** whenever the button is physically held down — even for a very short tap — the LED ring lights up at the currently chosen color (or the default cyan if no color has been picked yet), then turns off again the instant the button is released. This only kicks in when the HA Light entity is currently **off**; if the light is already on, the idle animation is already showing the color, so nothing changes.
#### Multi-press gestures #### Multi-press gestures
When the action button is released quickly (not held), it detects multiple presses: When the action button is released quickly (not held), it detects multiple presses:
@@ -155,6 +161,10 @@ Hold down the action button and use the volume buttons to rotate through the HSV
| **Hold + Volume Up** | Rotate hue +10° (clockwise around color wheel) | | **Hold + Volume Up** | Rotate hue +10° (clockwise around color wheel) |
| **Hold + Volume Down** | Rotate hue -10° (counter-clockwise around color wheel) | | **Hold + Volume Down** | Rotate hue -10° (counter-clockwise around color wheel) |
**Hold the volume button to keep rotating:** a quick press of a volume button while holding the action button steps the hue by 10° once, same as above. If you keep the volume button held past 1000 ms, the controller keeps stepping the hue every ~150 ms — the color keeps rotating around the wheel for as long as you hold it, and stops the moment you release the volume button (the action button can stay held throughout).
**Real-time preview:** every hue step — whether from a single tap or the continuous hold-rotation — is applied to shared state immediately and shows up on the LED ring right away, without waiting on Home Assistant to echo the change back. This means the ring always reflects exactly the color you're about to select while you're still picking it.
The color wheel provides **36 distinct color stops** (0°–360° in 10° increments): The color wheel provides **36 distinct color stops** (0°–360° in 10° increments):
- **0°** – Red - **0°** – Red
- **60°** – Yellow - **60°** – Yellow
@@ -163,12 +173,12 @@ The color wheel provides **36 distinct color stops** (0°–360° in 10° increm
- **240°** – Blue - **240°** – Blue
- **300°** – Magenta - **300°** – Magenta
Each press changes the hue by 10°, allowing smooth navigation around the full color spectrum. The LED ring updates immediately to show the new color during idle/standby (when the light is on). Each press (or hold-repeat step) changes the hue by 10°, allowing smooth navigation around the full color spectrum. The LED ring updates immediately to show the new color during idle/standby (when the light is on).
**Example workflow:** **Example workflow:**
1. Press and hold the action button 1. Press and hold the action button
2. Press volume up repeatedly to cycle through warm colors (red → orange → yellow) 2. Press and hold volume up to rotate continuously through warm colors (red → orange → yellow), or tap it repeatedly for single 10° steps
3. Release action button to confirm and exit color mode 3. Release the volume button once you like the color, then release the action button to confirm and exit color mode
4. The new color persists in Home Assistant 4. The new color persists in Home Assistant
--- ---
@@ -191,6 +201,7 @@ All animations mirror the Home Assistant Voice PE ESPHome firmware exactly. The
| Timer ringing | Pulse + optional red | Full ring pulsing in your color; red at 3 & 9 if muted | | Timer ringing | Pulse + optional red | Full ring pulsing in your color; red at 3 & 9 if muted |
| Volume changed | Volume Display arc | Temporary arc (2.5s) showing the current volume level, starting from the AUX jack corner LED (D1, index 0) and sweeping clockwise across all 24 LEDs | | Volume changed | Volume Display arc | Temporary arc (2.5s) showing the current volume level, starting from the AUX jack corner LED (D1, index 0) and sweeping clockwise across all 24 LEDs |
| Volume muted | Solid ring + red indicator | red at the AUX jack corner LEDs positions 11, 0, 1 to indicate the media player volume is zero | | Volume muted | Solid ring + red indicator | red at the AUX jack corner LEDs positions 11, 0, 1 to indicate the media player volume is zero |
| Action button held (light off) | Solid ring in chosen/default color | Overrides whatever else is showing for as long as the action button is physically held down, only while the HA Light entity is off — gives instant tactile feedback and previews color-wheel changes live |
**Note:** All animations except twinkle, off, and error animations use the color from the HA Light entity. This means you can customize the entire LED experience from Home Assistant by adjusting the light's RGB color and brightness. **Note:** All animations except twinkle, off, and error animations use the color from the HA Light entity. This means you can customize the entire LED experience from Home Assistant by adjusting the light's RGB color and brightness.
@@ -296,6 +307,11 @@ BTN_ACTION = 23
BTN_DEBOUNCE_MS = 150 # Button debounce in milliseconds BTN_DEBOUNCE_MS = 150 # Button debounce in milliseconds
# Volume button hold-to-repeat (also drives continuous color wheel rotation
# while the action button is held — see "Hold + Volume buttons" above)
VOLUME_HOLD_THRESHOLD_MS = 1000 # Hold time before gradual repeat kicks in
VOLUME_HOLD_REPEAT_S = 0.15 # Seconds between repeated steps while held
# Button multipress timing # Button multipress timing
MULTIPRESS_TIMEOUT_MS = 500 # Time window between presses (ms) MULTIPRESS_TIMEOUT_MS = 500 # Time window between presses (ms)
LONG_PRESS_MS = 1000 # Duration to detect long press (ms) LONG_PRESS_MS = 1000 # Duration to detect long press (ms)
@@ -417,7 +433,13 @@ automation:
2. Confirm onboard audio is disabled (`dtparam=audio=on` is commented out). 2. Confirm onboard audio is disabled (`dtparam=audio=on` is commented out).
3. Check the container is running as root (`user: "0:0"` in compose file) — `rpi-ws281x` requires root for DMA access via `/dev/mem`. 3. Check the container is running as root (`user: "0:0"` in compose file) — `rpi-ws281x` requires root for DMA access via `/dev/mem`.
4. Run with `--debug` and look for `LED ring initialised` in the logs. If `rpi_ws281x not found` appears, the Python package failed to install — rebuild the image. 4. Run with `--debug` and look for `LED ring initialised` in the logs. If `rpi_ws281x not found` appears, the Python package failed to install — rebuild the image.
5. **If you see only pipeline animations (listening, thinking, etc.) but no idle glow:** The light entity defaults to **off** in Home Assistant to match Voice PE behavior. Turn on the `light.<satellite>_leds` entity in HA to show the idle LED color. 5. **If you see only pipeline animations (listening, thinking, etc.) but no idle glow:** The light entity defaults to **off** in Home Assistant to match Voice PE behavior. Turn on the `light.<satellite>_leds` entity in HA to show the idle LED color, or simply hold the action button — it lights the ring at the current color while held whenever the light is off.
### Volume doesn't change smoothly while holding the button
1. Confirm you're holding the button past 1000 ms (`VOLUME_HOLD_THRESHOLD_MS`) — a shorter press only sends a single step, by design.
2. Run with `--debug` — each repeated step logs `Button → volume_up` / `Button → volume_down` roughly every 150 ms while held.
3. If the repeat feels too fast/slow, adjust `VOLUME_HOLD_REPEAT_S` at the top of `Satellite1_HAT_Board.py`.
### Buttons do not respond ### Buttons do not respond
@@ -47,7 +47,7 @@ import sys
import threading import threading
import time import time
from enum import Enum from enum import Enum
from typing import List, Optional, Tuple from typing import Dict, List, Optional, Tuple
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Hardware dependencies (gracefully stubbed when not on real Pi hardware so # Hardware dependencies (gracefully stubbed when not on real Pi hardware so
@@ -90,6 +90,14 @@ BTN_ACTION = 23 # Bottom button
BTN_DEBOUNCE_MS = 30 # Milliseconds 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 ring
LED_COUNT = 24 LED_COUNT = 24
LED_FREQ_HZ = 800_000 LED_FREQ_HZ = 800_000
@@ -278,6 +286,11 @@ class SharedState:
self.hsv_hue: int = DEFAULT_HUE self.hsv_hue: int = DEFAULT_HUE
self.hsv_saturation: float = DEFAULT_SATURATION self.hsv_saturation: float = DEFAULT_SATURATION
self.hsv_value: float = DEFAULT_VALUE 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: def update(self, **kwargs) -> None:
with self._lock: with self._lock:
@@ -304,6 +317,7 @@ class SharedState:
"hsv_hue": self.hsv_hue, "hsv_hue": self.hsv_hue,
"hsv_saturation": self.hsv_saturation, "hsv_saturation": self.hsv_saturation,
"hsv_value": self.hsv_value, "hsv_value": self.hsv_value,
"action_button_down": self.action_button_down,
} }
@@ -454,6 +468,25 @@ class LEDRing:
self._all_off() self._all_off()
return 0.1 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: def _anim_off(self) -> float:
self._all_off() self._all_off()
return 0.1 return 0.1
@@ -657,12 +690,24 @@ class LEDRing:
t_total = snap["timer_total_seconds"] t_total = snap["timer_total_seconds"]
t_left = snap["timer_seconds_left"] t_left = snap["timer_seconds_left"]
ha_connected = snap["ha_connected"] ha_connected = snap["ha_connected"]
action_button_down = snap["action_button_down"]
# Volume Display takes over the ring temporarily, regardless of # Volume Display takes over the ring temporarily, regardless of
# the current pipeline state, then falls back to the normal # the current pipeline state, then falls back to the normal
# animation once its deadline passes. # animation once its deadline passes.
if snap["volume_display_until"] > time.monotonic(): if snap["volume_display_until"] > time.monotonic():
sleep = self._anim_volume_display(color, snap["volume"]) 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: elif anim == self.ANIM_IDLE:
sleep = self._anim_idle() sleep = self._anim_idle()
@@ -763,11 +808,24 @@ class ButtonHandler:
coroutines onto the asyncio event loop thread-safely. coroutines onto the asyncio event loop thread-safely.
Supports: Supports:
- Volume +/- for volume control - Volume +/- for volume control. A short press sends a single step;
- Mute button for microphone toggle 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: - Action button for context-aware commands or color changing:
* Hold + Volume +: Increase hue by 10° * Hold + Volume +: Increase hue by 10°
* Hold + Volume -: Decrease 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__( def __init__(
@@ -784,6 +842,15 @@ class ButtonHandler:
self._action_button_held = False self._action_button_held = False
self._action_button_obj = None 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: def setup(self) -> None:
if not _HAS_GPIO: if not _HAS_GPIO:
_LOGGER.warning("GPIO unavailable – buttons not configured") _LOGGER.warning("GPIO unavailable – buttons not configured")
@@ -796,12 +863,14 @@ class ButtonHandler:
btn_mute = Button(BTN_MUTE, 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_act = Button(BTN_ACTION, pull_up=True, bounce_time=debounce)
btn_up.when_pressed = self._on_volume_up btn_up.when_pressed = lambda: self._on_volume_button_pressed("up")
btn_down.when_pressed = self._on_volume_down 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_mute.when_pressed = self._on_mute
btn_act.when_pressed = self._on_action btn_act.when_pressed = self._on_action_pressed
btn_act.when_held = self._on_action_held btn_act.when_held = self._on_action_held
btn_act.when_released = self._on_action_released btn_act.when_released = self._on_action_button_released
btn_act.hold_time = 0.1 # 100ms to detect hold btn_act.hold_time = 0.1 # 100ms to detect hold
# Keep references — gpiozero Buttons are released when garbage-collected # Keep references — gpiozero Buttons are released when garbage-collected
@@ -813,6 +882,9 @@ class ButtonHandler:
) )
def cleanup(self) -> None: 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: for btn in self._buttons:
btn.close() btn.close()
self._buttons.clear() self._buttons.clear()
@@ -834,7 +906,95 @@ class ButtonHandler:
self._on_color_hue_decrease() self._on_color_hue_decrease()
else: else:
self._send("volume_down") 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: def _on_action_held(self) -> None:
"""Action button held – enter color change mode.""" """Action button held – enter color change mode."""
self._action_button_held = True self._action_button_held = True
@@ -857,13 +1017,26 @@ class ButtonHandler:
self._send_color_command(new_hue, snap["hsv_saturation"], snap["hsv_value"]) self._send_color_command(new_hue, snap["hsv_saturation"], snap["hsv_value"])
def _send_color_command(self, hue: int, saturation: float, value: float) -> None: def _send_color_command(self, hue: int, saturation: float, value: float) -> None:
"""Convert HSV to RGB and send light_command to LVA.""" """
# Update local HSV state Convert HSV to RGB, apply it to shared state immediately for a
self._state.update(hsv_hue=hue, hsv_saturation=saturation, hsv_value=value) real-time preview on the LED ring, and notify LVA/Home Assistant.
"""
# Convert HSV to RGB for light command
r, g, b = hsv_to_rgb(hue, saturation, value) 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 # Normalize RGB to 0-1 range
light_cmd = { light_cmd = {
"command": "light_command", "command": "light_command",