diff --git a/examples/Satellite1 HAT Board/DOCS.md b/examples/Satellite1 HAT Board/DOCS.md index 6d2e722..d6591a6 100644 --- a/examples/Satellite1 HAT Board/DOCS.md +++ b/examples/Satellite1 HAT Board/DOCS.md @@ -104,10 +104,14 @@ target: ## Button behaviour ### 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 -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 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` | | 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 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 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): - **0°** – Red - **60°** – Yellow @@ -163,12 +173,12 @@ The color wheel provides **36 distinct color stops** (0°–360° in 10° increm - **240°** – Blue - **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:** 1. Press and hold the action button -2. Press volume up repeatedly to cycle through warm colors (red → orange → yellow) -3. Release action button to confirm and exit color mode +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 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 --- @@ -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 | | 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 | +| 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. @@ -296,6 +307,11 @@ BTN_ACTION = 23 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 MULTIPRESS_TIMEOUT_MS = 500 # Time window between presses (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). 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. -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._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._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 diff --git a/examples/Satellite1 HAT Board/Satellite1_HAT_Board.py b/examples/Satellite1 HAT Board/Satellite1_HAT_Board.py index 35e19d0..5b7ef86 100644 --- a/examples/Satellite1 HAT Board/Satellite1_HAT_Board.py +++ b/examples/Satellite1 HAT Board/Satellite1_HAT_Board.py @@ -47,7 +47,7 @@ import sys import threading import time 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 @@ -90,6 +90,14 @@ 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 @@ -278,6 +286,11 @@ class SharedState: 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: @@ -304,6 +317,7 @@ class SharedState: "hsv_hue": self.hsv_hue, "hsv_saturation": self.hsv_saturation, "hsv_value": self.hsv_value, + "action_button_down": self.action_button_down, } @@ -454,6 +468,25 @@ class LEDRing: 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 @@ -657,12 +690,24 @@ class LEDRing: 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() @@ -763,11 +808,24 @@ class ButtonHandler: coroutines onto the asyncio event loop thread-safely. Supports: - - Volume +/- for volume control - - Mute button for microphone toggle + - 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__( @@ -784,6 +842,15 @@ class ButtonHandler: 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") @@ -796,12 +863,14 @@ class ButtonHandler: 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 = self._on_volume_up - btn_down.when_pressed = self._on_volume_down + 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 + btn_act.when_pressed = self._on_action_pressed 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 # Keep references — gpiozero Buttons are released when garbage-collected @@ -813,6 +882,9 @@ class ButtonHandler: ) 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() @@ -834,7 +906,95 @@ class ButtonHandler: 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 @@ -857,13 +1017,26 @@ class ButtonHandler: 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 and send light_command to LVA.""" - # Update local HSV state - self._state.update(hsv_hue=hue, hsv_saturation=saturation, hsv_value=value) - - # Convert HSV to RGB for light command + """ + 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",