From 5d4e7aa313219f2ecbce025b8c56fee1f202b77d Mon Sep 17 00:00:00 2001 From: Gabby Pelton Date: Sat, 30 May 2026 12:38:16 -0500 Subject: [PATCH] abstract gesture handling to it's own module --- AGENTS.md | 11 ++-- client.py | 93 ++++++++++++---------------- whisplay_interface.py | 140 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 60 deletions(-) create mode 100644 whisplay_interface.py diff --git a/AGENTS.md b/AGENTS.md index 7e50fa6..4dec917 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,16 +33,17 @@ Single-file Python application for managing LXST Channel connections via Reticul Short press (<1s) Cycle to next channel in list Long press (>=1s) Call the selected channel - ### In call (STATE_IN_CALL) — Push-to-Talk mode (default) + ### In call — Push-to-Talk mode (default) - Press & hold Talk (unmute microphone) + Hold (>=1s) Talk (unmute microphone) Release Stop talking (mute microphone) - Double-press Toggle to always-on mode + Short press (<1s) Switch to always-on mode + Double press Hang up ### In call — Always-on mode (toggled) - Press Hang up - Double-press Toggle to push-to-talk mode + Short press (<1s) Switch to push-to-talk mode + Double press Hang up ### RGB LED indicates state: Blue = ready, showing channels diff --git a/client.py b/client.py index f9af052..0334597 100644 --- a/client.py +++ b/client.py @@ -35,8 +35,7 @@ if _WHISPLAY_AVAILABLE: except ImportError: pass -LONG_PRESS_THRESHOLD = 1.0 -DOUBLE_PRESS_THRESHOLD = 0.4 + class Terminal: @@ -70,14 +69,11 @@ class ChannelClient: # ── Whisplay HAT init ── self.board = None self.selected_index = 0 - self._press_time = 0.0 self._wm8960_device = None self._ptt_enabled = True self._ptt_active = False - self._last_release_time = 0.0 - self._double_pressed = False - self._press_count = 0 - self._triple_pressed = False + self.gestures = None + if use_whisplay and _WHISPLAY_AVAILABLE and _HAS_PIL: try: self._init_whisplay() @@ -156,8 +152,10 @@ class ChannelClient: use_daemon_default_log=True, ) self.board.set_backlight(70) - self.board.on_button_press(self._on_button_press) - self.board.on_button_release(self._on_button_release) + from whisplay_interface import WhisplayInterface + self.gestures = WhisplayInterface(self.board) + self.gestures.on_press(self._on_button_press) + self.gestures.on_release(self._on_button_release) if hasattr(self.board, "on_exit_request"): self.board.on_exit_request(self._on_exit_request) if hasattr(self.board, "on_focus_revoked"): @@ -312,11 +310,11 @@ class ChannelClient: if ch: lines.append(ch["hash"][:16] + "..") if self._ptt_enabled: - lines += ["", "PTT: hold to talk", "Release to mute"] - footer = "Dbl: toggle Trip: leave" + lines += ["", "PTT: hold to talk", "Short: always-on"] + footer = "Dbl: leave" else: - lines += ["", "Mic always-on", "Press to hang up"] - footer = "Dbl: toggle Trip: leave" + lines += ["", "Always-on (unmuted)"] + footer = "Short: PTT Dbl: leave" fb = self._render("In Call", lines, accent=(60, 220, 120), footer=footer) self.board.draw_image(0, 0, self.board.LCD_WIDTH, self.board.LCD_HEIGHT, fb) @@ -328,33 +326,10 @@ class ChannelClient: self.board.draw_image(0, 0, self.board.LCD_WIDTH, self.board.LCD_HEIGHT, fb) def _on_button_press(self): - self._press_time = time.time() if self.state == self.STATE_IN_CALL: - gap = time.time() - self._last_release_time - if gap < DOUBLE_PRESS_THRESHOLD: - self._press_count += 1 - else: - self._press_count = 1 - - if self._press_count >= 3: - self._press_count = 0 - self._triple_pressed = True - self._double_pressed = False - self._ptt_active = False + if self.gestures.is_double_pressed: self.telephone.hangup() return - - if self._press_count == 2: - self._double_pressed = True - self._ptt_enabled = not self._ptt_enabled - if self._ptt_enabled: - self.telephone.mute_transmit() - else: - self.telephone.unmute_transmit() - if self.board: - self._show_in_call() - return - if self._ptt_enabled: self._ptt_active = True self.telephone.unmute_transmit() @@ -362,27 +337,33 @@ class ChannelClient: self.board.set_rgb(255, 255, 0) def _on_button_release(self): - self._last_release_time = time.time() - if self._triple_pressed: - self._triple_pressed = False + if self.gestures.was_double_pressed: + self.gestures.acknowledge_double_press() return if self.state == self.STATE_IN_CALL: - if self._double_pressed: - self._double_pressed = False - return + elapsed = self.gestures.press_duration if self._ptt_enabled: - self._ptt_active = False - self.telephone.mute_transmit() - if self.board: - self.board.set_rgb(0, 255, 80) - return + if elapsed < self.gestures.long_press_threshold: + self._ptt_active = False + self._ptt_enabled = False + if self.board: + self._show_in_call() + else: + self._ptt_active = False + self.telephone.mute_transmit() + if self.board: + self.board.set_rgb(0, 255, 80) else: - self.telephone.hangup() - return + if elapsed < self.gestures.long_press_threshold: + self._ptt_enabled = True + self.telephone.mute_transmit() + if self.board: + self._show_in_call() + return if self.state != self.STATE_READY: return - elapsed = time.time() - self._press_time - if elapsed >= LONG_PRESS_THRESHOLD: + elapsed = self.gestures.press_duration + if elapsed >= self.gestures.long_press_threshold: if self.channels: self.board.set_rgb(255, 180, 0) self.call_channel(self.selected_index) @@ -390,6 +371,7 @@ class ChannelClient: if self.channels: self.selected_index = (self.selected_index + 1) % len(self.channels) self._show_channels() + self.gestures.acknowledge_press() def _on_exit_request(self, payload=None): self.cleanup() @@ -525,9 +507,8 @@ class ChannelClient: was_calling = self.state == self.STATE_CALLING self.state = self.STATE_READY self._ptt_active = False - self._double_pressed = False - self._triple_pressed = False - self._press_count = 0 + if self.gestures: + self.gestures.acknowledge_double_press() if was_in_call: print(f"\nCall ended") elif was_calling: @@ -698,6 +679,8 @@ class ChannelClient: if self.telephone and self.state == self.STATE_IN_CALL: self.telephone.hangup() self.should_run = False + if self.gestures: + self.gestures.stop() if self.board: self.board.cleanup() diff --git a/whisplay_interface.py b/whisplay_interface.py new file mode 100644 index 0000000..dd9b6e6 --- /dev/null +++ b/whisplay_interface.py @@ -0,0 +1,140 @@ +import time +import threading + + +class WhisplayInterface: + def __init__(self, board=None, long_press_threshold=0.5, double_press_threshold=0.3): + self._board = board + self._long_press_threshold = long_press_threshold + self._double_press_threshold = double_press_threshold + self._button_is_double_pressed_set = False + + self._button_press_start = None + self._button_press_end = None + self._button_is_pressed = False + self._button_was_pressed = False + self._button_is_long_pressed = False + self._button_was_long_pressed = False + self._button_is_double_pressed = False + self._button_was_double_pressed = False + + self._press_callbacks = [] + self._release_callbacks = [] + self._running = False + self._gesture_thread = None + + if board is not None: + self.attach(board) + + @property + def long_press_threshold(self): + return self._long_press_threshold + + @property + def double_press_threshold(self): + return self._double_press_threshold + + @property + def is_pressed(self): + return self._button_is_pressed + + @property + def was_pressed(self): + return self._button_was_pressed + + @property + def is_long_pressed(self): + return self._button_is_long_pressed + + @property + def was_long_pressed(self): + return self._button_was_long_pressed + + @property + def is_double_pressed(self): + return self._button_is_double_pressed + + @property + def was_double_pressed(self): + return self._button_was_double_pressed + + @property + def press_duration(self): + if self._button_press_start is None: + return 0.0 + end = self._button_press_end if self._button_press_end is not None else time.time() + return end - self._button_press_start + + def attach(self, board): + self._board = board + self._board.on_button_press(self._on_button_press) + self._board.on_button_release(self._on_button_release) + self.start() + + def start(self): + if self._gesture_thread is not None: + return + self._running = True + self._gesture_thread = threading.Thread(daemon=True, target=self._gesture_loop) + self._gesture_thread.start() + + def stop(self): + self._running = False + + def on_press(self, callback): + self._press_callbacks.append(callback) + + def on_release(self, callback): + self._release_callbacks.append(callback) + + def acknowledge_press(self): + self._button_was_pressed = False + + def acknowledge_long_press(self): + self._button_was_long_pressed = False + + def acknowledge_double_press(self): + self._button_was_double_pressed = False + + def _on_button_press(self): + self._button_is_pressed = True + self._button_press_start = time.time() + if self._button_press_end is not None: + gap = self._button_press_start - self._button_press_end + if gap < self._double_press_threshold: + self._button_is_double_pressed = True + self._button_is_double_pressed_set = True + for cb in self._press_callbacks: + cb() + + def _on_button_release(self): + self._button_was_pressed = self._button_is_pressed + self._button_press_end = time.time() + self._button_was_long_pressed = self._button_is_long_pressed + self._button_was_double_pressed = self._button_is_double_pressed + self._button_is_pressed = False + self._button_is_double_pressed = False + self._button_is_long_pressed = False + self._button_is_double_pressed_set = False + for cb in self._release_callbacks: + cb() + + def _gesture_loop(self): + while self._running: + try: + current_time = time.time() + if self._button_is_pressed: + if not self._button_is_double_pressed_set: + self._button_is_double_pressed = ( + self._button_was_pressed + and self._button_press_end is not None + and current_time - self._button_press_end < self._double_press_threshold + ) + if self._button_is_double_pressed: + self._button_is_double_pressed_set = True + self._button_is_long_pressed = ( + current_time - self._button_press_start > self._long_press_threshold + ) + except Exception: + pass + time.sleep(0.05)