transition WhisplayInterface to pure state machine

This commit is contained in:
Gabby Pelton
2026-05-30 17:04:03 -05:00
parent 5d4e7aa313
commit feed1145f8
3 changed files with 144 additions and 207 deletions
+79 -115
View File
@@ -7,36 +7,14 @@ import time
import threading import threading
import signal import signal
import argparse import argparse
import subprocess import subprocess
import RNS import RNS
import LXMF import LXMF
from PIL import Image, ImageDraw, ImageFont
from LXST.Primitives.Telephony import Telephone from LXST.Primitives.Telephony import Telephone
from whisplay_interface import WhisplayInterface
# ── Whisplay HAT GUI support ──────────────────────────────────────────
_RUNTIME_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "runtime")
if os.path.isdir(_RUNTIME_DIR):
sys.path.insert(0, _RUNTIME_DIR)
_WHISPLAY_AVAILABLE = False
try:
from whisplay_client import create_whisplay_hardware
_WHISPLAY_AVAILABLE = True
except ImportError:
pass
_HAS_PIL = False
if _WHISPLAY_AVAILABLE:
try:
from PIL import Image, ImageDraw, ImageFont
_HAS_PIL = True
except ImportError:
pass
class Terminal: class Terminal:
if not RNS.vendor.platformutils.is_windows(): if not RNS.vendor.platformutils.is_windows():
@@ -67,19 +45,15 @@ class ChannelClient:
self._response_event = threading.Event() self._response_event = threading.Event()
# ── Whisplay HAT init ── # ── Whisplay HAT init ──
self.board = None
self.selected_index = 0 self.selected_index = 0
self._wm8960_device = None self._wm8960_device = None
self._ptt_enabled = True self._ptt_enabled = True
self._ptt_active = False self._ptt_active = False
self.gestures = None self.whisplay_interface = None
self._gesture_thread = None
if use_whisplay and _WHISPLAY_AVAILABLE and _HAS_PIL: if use_whisplay:
try: self._init_whisplay()
self._init_whisplay()
except Exception as e:
RNS.log(f"Whisplay init failed: {e}", RNS.LOG_WARNING)
self.board = None
if configdir is None: if configdir is None:
if os.path.isdir("/etc/lxst-client"): if os.path.isdir("/etc/lxst-client"):
@@ -143,25 +117,16 @@ class ChannelClient:
# ── Whisplay HAT GUI ───────────────────────────────────────────── # ── Whisplay HAT GUI ─────────────────────────────────────────────
def _init_whisplay(self): def _init_whisplay(self):
from whisplay_client import create_whisplay_hardware self.whisplay_interface = WhisplayInterface()
self.whisplay_interface.create_board(
self.board = create_whisplay_hardware(
app_id="lxst-client", app_id="lxst-client",
display_name="LXST Client", display_name="LXST Client",
icon="LC", icon="LC",
use_daemon_default_log=True, use_daemon_default_log=True,
) )
self.board.set_backlight(70) self.whisplay_interface.board.set_backlight(70)
from whisplay_interface import WhisplayInterface # self.whisplay_interface.board.on_exit_request(self._on_exit_request)
self.gestures = WhisplayInterface(self.board) # self.whisplay_interface.board.on_focus_revoked(self._on_focus_revoked)
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"):
self.board.on_focus_revoked(self._on_focus_revoked)
if hasattr(self.board, "start_event_listener"):
self.board.start_event_listener()
self._load_fonts() self._load_fonts()
self._setup_whisplay_audio() self._setup_whisplay_audio()
@@ -247,7 +212,7 @@ class ChannelClient:
return lines return lines
def _render(self, title, body_lines, accent=(60, 150, 255), footer=""): def _render(self, title, body_lines, accent=(60, 150, 255), footer=""):
w, h = self.board.LCD_WIDTH, self.board.LCD_HEIGHT w, h = self.whisplay_interface.board.LCD_WIDTH, self.whisplay_interface.board.LCD_HEIGHT
img = Image.new("RGB", (w, h), (11, 16, 24)) img = Image.new("RGB", (w, h), (11, 16, 24))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
draw.rounded_rectangle((12, 16, w - 12, h - 16), radius=18, draw.rounded_rectangle((12, 16, w - 12, h - 16), radius=18,
@@ -283,13 +248,13 @@ class ChannelClient:
return self._rgb565(img) return self._rgb565(img)
def _flash_screen(self): def _flash_screen(self):
if self.board is None: if self.whisplay_interface.board is None:
return return
fb = self._render("LXST Client", ["Channels loading..."], footer="Please wait") fb = self._render("LXST Client", ["Channels loading..."], footer="Please wait")
self.board.draw_image(0, 0, self.board.LCD_WIDTH, self.board.LCD_HEIGHT, fb) self.whisplay_interface.board.draw_image(0, 0, self.whisplay_interface.board.LCD_WIDTH, self.whisplay_interface.board.LCD_HEIGHT, fb)
def _show_channels(self): def _show_channels(self):
if self.board is None: if self.whisplay_interface.board is None:
return return
if not self.channels: if not self.channels:
fb = self._render("LXST Client", ["No channels", "", "Re-discover to find channels"], fb = self._render("LXST Client", ["No channels", "", "Re-discover to find channels"],
@@ -300,10 +265,10 @@ class ChannelClient:
lines.append((f"{i+1}: {ch['hash'][:16]}..", i == self.selected_index)) lines.append((f"{i+1}: {ch['hash'][:16]}..", i == self.selected_index))
fb = self._render(f"Channels ({len(self.channels)})", lines, fb = self._render(f"Channels ({len(self.channels)})", lines,
footer="Short: next | Long: select") footer="Short: next | Long: select")
self.board.draw_image(0, 0, self.board.LCD_WIDTH, self.board.LCD_HEIGHT, fb) self.whisplay_interface.board.draw_image(0, 0, self.whisplay_interface.board.LCD_WIDTH, self.whisplay_interface.board.LCD_HEIGHT, fb)
def _show_in_call(self): def _show_in_call(self):
if self.board is None: if self.whisplay_interface.board is None:
return return
ch = self.channels[self.selected_index] if self.channels else None ch = self.channels[self.selected_index] if self.channels else None
lines = [f"Channel {self.selected_index + 1}"] lines = [f"Channel {self.selected_index + 1}"]
@@ -316,67 +281,66 @@ class ChannelClient:
lines += ["", "Always-on (unmuted)"] lines += ["", "Always-on (unmuted)"]
footer = "Short: PTT Dbl: leave" footer = "Short: PTT Dbl: leave"
fb = self._render("In Call", lines, accent=(60, 220, 120), footer=footer) 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) self.whisplay_interface.board.draw_image(0, 0, self.whisplay_interface.board.LCD_WIDTH, self.whisplay_interface.board.LCD_HEIGHT, fb)
def _show_status(self, title, detail="", accent=(255, 180, 60)): def _show_status(self, title, detail="", accent=(255, 180, 60)):
if self.board is None: if self.whisplay_interface.board is None:
return return
lines = [detail] if detail else [] lines = [detail] if detail else []
fb = self._render(title, lines, accent=accent) fb = self._render(title, lines, accent=accent)
self.board.draw_image(0, 0, self.board.LCD_WIDTH, self.board.LCD_HEIGHT, fb) self.whisplay_interface.board.draw_image(0, 0, self.whisplay_interface.board.LCD_WIDTH, self.whisplay_interface.board.LCD_HEIGHT, fb)
def _on_button_press(self): def _gesture_loop(self):
if self.state == self.STATE_IN_CALL: while self.should_run:
if self.gestures.is_double_pressed: self.whisplay_interface.update_gestures()
# Double-press (highest priority)
if self.whisplay_interface.was_double_pressed and self.state == self.STATE_IN_CALL:
self.telephone.hangup() self.telephone.hangup()
return time.sleep(0.02)
if self._ptt_enabled: continue
self._ptt_active = True
self.telephone.unmute_transmit()
if self.board:
self.board.set_rgb(255, 255, 0)
def _on_button_release(self): # STATE_READY: release actions
if self.gestures.was_double_pressed: if self.state == self.STATE_READY and self.whisplay_interface.was_pressed:
self.gestures.acknowledge_double_press() if self.whisplay_interface.was_long_pressed and self.channels:
return self.whisplay_interface.board.set_rgb(255, 180, 0)
if self.state == self.STATE_IN_CALL: self.call_channel(self.selected_index)
elapsed = self.gestures.press_duration elif self.channels:
if self._ptt_enabled: self.selected_index = (self.selected_index + 1) % len(self.channels)
if elapsed < self.gestures.long_press_threshold: self._show_channels()
self._ptt_active = False
self._ptt_enabled = False # STATE_IN_CALL: hold-to-talk + release actions
if self.board: if self.state == self.STATE_IN_CALL:
self._show_in_call() if self.whisplay_interface.is_pressed and self._ptt_enabled and not self._ptt_active:
else: self._ptt_active = True
self._ptt_active = False self.telephone.unmute_transmit()
self.telephone.mute_transmit() if self.whisplay_interface.board:
if self.board: self.whisplay_interface.board.set_rgb(255, 255, 0)
self.board.set_rgb(0, 255, 80)
else: if self.whisplay_interface.was_pressed:
if elapsed < self.gestures.long_press_threshold: if self._ptt_enabled:
self._ptt_enabled = True if not self.whisplay_interface.was_long_pressed:
self.telephone.mute_transmit() self._ptt_active = False
if self.board: self._ptt_enabled = False
self._show_in_call() if self.whisplay_interface.board:
return self._show_in_call()
if self.state != self.STATE_READY: else:
return self._ptt_active = False
elapsed = self.gestures.press_duration self.telephone.mute_transmit()
if elapsed >= self.gestures.long_press_threshold: if self.whisplay_interface.board:
if self.channels: self.whisplay_interface.board.set_rgb(0, 255, 80)
self.board.set_rgb(255, 180, 0) elif not self.whisplay_interface.was_long_pressed:
self.call_channel(self.selected_index) self._ptt_enabled = True
else: self.telephone.mute_transmit()
if self.channels: if self.whisplay_interface.board:
self.selected_index = (self.selected_index + 1) % len(self.channels) self._show_in_call()
self._show_channels()
self.gestures.acknowledge_press() time.sleep(0.02)
def _on_exit_request(self, payload=None): def _on_exit_request(self, payload=None):
self.cleanup() self.cleanup()
if self.board: if self.whisplay_interface.board:
self.board.prepare_exit() self.whisplay_interface.board.prepare_exit()
def _on_focus_revoked(self, payload=None): def _on_focus_revoked(self, payload=None):
self.cleanup() self.cleanup()
@@ -498,8 +462,8 @@ class ChannelClient:
) )
if self._ptt_enabled: if self._ptt_enabled:
self.telephone.mute_transmit() self.telephone.mute_transmit()
if self.board: if self.whisplay_interface.board:
self.board.set_rgb(0, 255, 80) self.whisplay_interface.board.set_rgb(0, 255, 80)
self._show_in_call() self._show_in_call()
def _on_call_ended(self, remote_identity): def _on_call_ended(self, remote_identity):
@@ -507,14 +471,12 @@ class ChannelClient:
was_calling = self.state == self.STATE_CALLING was_calling = self.state == self.STATE_CALLING
self.state = self.STATE_READY self.state = self.STATE_READY
self._ptt_active = False self._ptt_active = False
if self.gestures:
self.gestures.acknowledge_double_press()
if was_in_call: if was_in_call:
print(f"\nCall ended") print(f"\nCall ended")
elif was_calling: elif was_calling:
print(f"\nCall could not be connected") print(f"\nCall could not be connected")
if self.board: if self.whisplay_interface.board:
self.board.set_rgb(0, 80, 255) self.whisplay_interface.board.set_rgb(0, 80, 255)
if was_in_call: if was_in_call:
self._show_status("Call Ended") self._show_status("Call Ended")
time.sleep(1) time.sleep(1)
@@ -593,7 +555,7 @@ class ChannelClient:
signal.signal(signal.SIGTERM, self._sigterm_handler) signal.signal(signal.SIGTERM, self._sigterm_handler)
self.should_run = True self.should_run = True
if self.board: if self.whisplay_interface.board:
self._run_gui() self._run_gui()
else: else:
self._run_cli() self._run_cli()
@@ -602,8 +564,10 @@ class ChannelClient:
self._flash_screen() self._flash_screen()
self.discover(timeout=15) self.discover(timeout=15)
self.selected_index = 0 self.selected_index = 0
self.board.set_rgb(0, 80, 255) self.whisplay_interface.board.set_rgb(0, 80, 255)
self._show_channels() self._show_channels()
self._gesture_thread = threading.Thread(target=self._gesture_loop, daemon=True)
self._gesture_thread.start()
while self.should_run: while self.should_run:
if self.state == self.STATE_CALLING: if self.state == self.STATE_CALLING:
self._show_status("Calling...", f"Channel {self.selected_index + 1}", self._show_status("Calling...", f"Channel {self.selected_index + 1}",
@@ -679,10 +643,10 @@ class ChannelClient:
if self.telephone and self.state == self.STATE_IN_CALL: if self.telephone and self.state == self.STATE_IN_CALL:
self.telephone.hangup() self.telephone.hangup()
self.should_run = False self.should_run = False
if self.gestures: if self._gesture_thread:
self.gestures.stop() self._gesture_thread.join(timeout=1)
if self.board: if self.whisplay_interface.board:
self.board.cleanup() self.whisplay_interface.board.cleanup()
def _sigint_handler(self, sig, frame): def _sigint_handler(self, sig, frame):
self.cleanup() self.cleanup()
+1 -1
View File
@@ -4,7 +4,7 @@ import socket
import threading import threading
import time import time
from whisplay import WhisplayBoard from runtime.whisplay import WhisplayBoard
DEFAULT_DAEMON_SOCKET_PATH = "/tmp/whisplay-daemon.sock" DEFAULT_DAEMON_SOCKET_PATH = "/tmp/whisplay-daemon.sock"
+63 -90
View File
@@ -1,38 +1,28 @@
import time import time
import threading import threading
import runtime.whisplay_client
class WhisplayInterface: class WhisplayInterface:
def __init__(self, board=None, long_press_threshold=0.5, double_press_threshold=0.3): LONG_PRESS_THRESHOLD = 0.5
self._board = board DOUBLE_PRESS_THRESHOLD = 0.3
self._long_press_threshold = long_press_threshold
self._double_press_threshold = double_press_threshold
self._button_is_double_pressed_set = False
def __init__(self):
self.board = None
self._lock = threading.Lock()
self._button_press_start = None self._button_press_start = None
self._button_press_end = None self._button_press_end = None
self._button_is_pressed = False self._button_is_pressed = False
self._button_was_pressed = False self._button_was_pressed = False
self._button_is_long_pressed = False self._button_is_long_pressed = False
self._button_was_long_pressed = False self._button_was_long_pressed = False
self._button_is_double_pressed = False
self._button_was_double_pressed = False self._button_was_double_pressed = False
self._press_callbacks = [] # Edge-detection state
self._release_callbacks = [] self._prev_is_pressed = False
self._running = False self._prev_press_end = None
self._gesture_thread = None
if board is not None: # -- Properties --
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 @property
def is_pressed(self): def is_pressed(self):
@@ -50,10 +40,6 @@ class WhisplayInterface:
def was_long_pressed(self): def was_long_pressed(self):
return self._button_was_long_pressed return self._button_was_long_pressed
@property
def is_double_pressed(self):
return self._button_is_double_pressed
@property @property
def was_double_pressed(self): def was_double_pressed(self):
return self._button_was_double_pressed return self._button_was_double_pressed
@@ -65,76 +51,63 @@ class WhisplayInterface:
end = self._button_press_end if self._button_press_end is not None else time.time() end = self._button_press_end if self._button_press_end is not None else time.time()
return end - self._button_press_start return end - self._button_press_start
def attach(self, board): # -- Attachment --
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): def create_board(self,
if self._gesture_thread is not None: app_id,
return display_name,
self._running = True icon,
self._gesture_thread = threading.Thread(daemon=True, target=self._gesture_loop) use_daemon_default_log=True):
self._gesture_thread.start() self.board = runtime.whisplay_client.create_whisplay_hardware(app_id=app_id, display_name=display_name, icon=icon, use_daemon_default_log=use_daemon_default_log)
self.board.on_button_press(self._on_button_press)
self.board.on_button_release(self._on_button_release)
def stop(self): # -- Internal press/release (called from GPIO thread) --
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): def _on_button_press(self):
self._button_is_pressed = True with self._lock:
self._button_press_start = time.time() self._button_is_pressed = True
if self._button_press_end is not None: self._button_press_start = time.time()
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): def _on_button_release(self):
self._button_was_pressed = self._button_is_pressed with self._lock:
self._button_press_end = time.time() self._button_is_pressed = False
self._button_was_long_pressed = self._button_is_long_pressed self._button_press_end = time.time()
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): # -- Polled update (called from gesture thread) --
while self._running:
try: def update_gestures(self):
current_time = time.time() self._button_was_pressed = False
if self._button_is_pressed: self._button_was_long_pressed = False
if not self._button_is_double_pressed_set: self._button_was_double_pressed = False
self._button_is_double_pressed = (
self._button_was_pressed with self._lock:
and self._button_press_end is not None is_pressed = self._button_is_pressed
and current_time - self._button_press_end < self._double_press_threshold press_start = self._button_press_start
) press_end = self._button_press_end
if self._button_is_double_pressed:
self._button_is_double_pressed_set = True now = time.time()
self._button_is_long_pressed = (
current_time - self._button_press_start > self._long_press_threshold # Press edge: transitioned from not-pressed to pressed
) if not self._prev_is_pressed and is_pressed:
except Exception: if self._prev_press_end is not None and press_start is not None:
pass gap = press_start - self._prev_press_end
time.sleep(0.05) if gap < WhisplayInterface.DOUBLE_PRESS_THRESHOLD:
self._button_was_double_pressed = True
# Release edge: transitioned from pressed to not-pressed
if self._prev_is_pressed and not is_pressed:
self._button_was_pressed = True
if press_start is not None and press_end is not None:
duration = press_end - press_start
if duration >= WhisplayInterface.LONG_PRESS_THRESHOLD:
self._button_was_long_pressed = True
self._prev_press_end = press_end
# Level flag: currently held past threshold
if is_pressed and press_start is not None:
self._button_is_long_pressed = (now - press_start) >= WhisplayInterface.LONG_PRESS_THRESHOLD
else:
self._button_is_long_pressed = False
self._prev_is_pressed = is_pressed