Compare commits

...

4 Commits

Author SHA1 Message Date
Gabby Pelton feed1145f8 transition WhisplayInterface to pure state machine 2026-05-30 17:04:03 -05:00
Gabby Pelton 5d4e7aa313 abstract gesture handling to it's own module 2026-05-30 12:38:16 -05:00
Gabby Pelton 183c055f83 triple press to exit call 2026-05-30 01:48:07 -05:00
Gabby Pelton c5f3cf54f5 PTT for whisplay 2026-05-30 01:40:34 -05:00
4 changed files with 223 additions and 86 deletions
+16 -2
View File
@@ -28,14 +28,28 @@ Single-file Python application for managing LXST Channel connections via Reticul
## Whisplay GUI Controls ## Whisplay GUI Controls
### Channel list (STATE_READY)
Short press (<1s) Cycle to next channel in list Short press (<1s) Cycle to next channel in list
Long press (>=1s) Call the selected channel Long press (>=1s) Call the selected channel
Press (while in call) Hang up
RGB LED indicates state: ### In call — Push-to-Talk mode (default)
Hold (>=1s) Talk (unmute microphone)
Release Stop talking (mute microphone)
Short press (<1s) Switch to always-on mode
Double press Hang up
### In call — Always-on mode (toggled)
Short press (<1s) Switch to push-to-talk mode
Double press Hang up
### RGB LED indicates state:
Blue = ready, showing channels Blue = ready, showing channels
Orange = calling Orange = calling
Green = in call Green = in call
Yellow = transmitting (PTT mode, mic active)
## Initial Setup Order ## Initial Setup Order
+88 -78
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
LONG_PRESS_THRESHOLD = 1.0
class Terminal: class Terminal:
if not RNS.vendor.platformutils.is_windows(): if not RNS.vendor.platformutils.is_windows():
@@ -67,16 +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._press_time = 0.0
self._wm8960_device = None self._wm8960_device = None
if use_whisplay and _WHISPLAY_AVAILABLE and _HAS_PIL: self._ptt_enabled = True
try: self._ptt_active = False
self.whisplay_interface = None
self._gesture_thread = None
if use_whisplay:
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"):
@@ -140,23 +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)
self.board.on_button_press(self._on_button_press) # self.whisplay_interface.board.on_exit_request(self._on_exit_request)
self.board.on_button_release(self._on_button_release) # self.whisplay_interface.board.on_focus_revoked(self._on_focus_revoked)
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()
@@ -242,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,
@@ -278,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"],
@@ -295,49 +265,82 @@ 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}"]
if ch: if ch:
lines.append(ch["hash"][:16] + "..") lines.append(ch["hash"][:16] + "..")
lines += ["", "Press to hang up"] if self._ptt_enabled:
fb = self._render("In Call", lines, accent=(60, 220, 120), footer="Press button to end") lines += ["", "PTT: hold to talk", "Short: always-on"]
self.board.draw_image(0, 0, self.board.LCD_WIDTH, self.board.LCD_HEIGHT, fb) footer = "Dbl: leave"
else:
lines += ["", "Always-on (unmuted)"]
footer = "Short: PTT Dbl: leave"
fb = self._render("In Call", lines, accent=(60, 220, 120), footer=footer)
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):
self._press_time = time.time() while self.should_run:
self.whisplay_interface.update_gestures()
def _on_button_release(self): # Double-press (highest priority)
elapsed = time.time() - self._press_time if self.whisplay_interface.was_double_pressed and self.state == self.STATE_IN_CALL:
if self.state == self.STATE_IN_CALL:
self.telephone.hangup() self.telephone.hangup()
return time.sleep(0.02)
if self.state != self.STATE_READY: continue
return
if elapsed >= LONG_PRESS_THRESHOLD: # STATE_READY: release actions
if self.channels: if self.state == self.STATE_READY and self.whisplay_interface.was_pressed:
self.board.set_rgb(255, 180, 0) if self.whisplay_interface.was_long_pressed and self.channels:
self.whisplay_interface.board.set_rgb(255, 180, 0)
self.call_channel(self.selected_index) self.call_channel(self.selected_index)
else: elif self.channels:
if self.channels:
self.selected_index = (self.selected_index + 1) % len(self.channels) self.selected_index = (self.selected_index + 1) % len(self.channels)
self._show_channels() self._show_channels()
# STATE_IN_CALL: hold-to-talk + release actions
if self.state == self.STATE_IN_CALL:
if self.whisplay_interface.is_pressed and self._ptt_enabled and not self._ptt_active:
self._ptt_active = True
self.telephone.unmute_transmit()
if self.whisplay_interface.board:
self.whisplay_interface.board.set_rgb(255, 255, 0)
if self.whisplay_interface.was_pressed:
if self._ptt_enabled:
if not self.whisplay_interface.was_long_pressed:
self._ptt_active = False
self._ptt_enabled = False
if self.whisplay_interface.board:
self._show_in_call()
else:
self._ptt_active = False
self.telephone.mute_transmit()
if self.whisplay_interface.board:
self.whisplay_interface.board.set_rgb(0, 255, 80)
elif not self.whisplay_interface.was_long_pressed:
self._ptt_enabled = True
self.telephone.mute_transmit()
if self.whisplay_interface.board:
self._show_in_call()
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()
@@ -457,20 +460,23 @@ class ChannelClient:
f"\nCall established with " f"\nCall established with "
f"{RNS.prettyhexrep(remote_identity.hash)}" f"{RNS.prettyhexrep(remote_identity.hash)}"
) )
if self.board: if self._ptt_enabled:
self.board.set_rgb(0, 255, 80) self.telephone.mute_transmit()
if self.whisplay_interface.board:
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):
was_in_call = self.state == self.STATE_IN_CALL was_in_call = self.state == self.STATE_IN_CALL
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
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)
@@ -549,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()
@@ -558,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}",
@@ -635,8 +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.board: if self._gesture_thread:
self.board.cleanup() self._gesture_thread.join(timeout=1)
if self.whisplay_interface.board:
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"
+113
View File
@@ -0,0 +1,113 @@
import time
import threading
import runtime.whisplay_client
class WhisplayInterface:
LONG_PRESS_THRESHOLD = 0.5
DOUBLE_PRESS_THRESHOLD = 0.3
def __init__(self):
self.board = None
self._lock = threading.Lock()
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_was_double_pressed = False
# Edge-detection state
self._prev_is_pressed = False
self._prev_press_end = None
# -- Properties --
@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 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
# -- Attachment --
def create_board(self,
app_id,
display_name,
icon,
use_daemon_default_log=True):
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)
# -- Internal press/release (called from GPIO thread) --
def _on_button_press(self):
with self._lock:
self._button_is_pressed = True
self._button_press_start = time.time()
def _on_button_release(self):
with self._lock:
self._button_is_pressed = False
self._button_press_end = time.time()
# -- Polled update (called from gesture thread) --
def update_gestures(self):
self._button_was_pressed = False
self._button_was_long_pressed = False
self._button_was_double_pressed = False
with self._lock:
is_pressed = self._button_is_pressed
press_start = self._button_press_start
press_end = self._button_press_end
now = time.time()
# Press edge: transitioned from not-pressed to pressed
if not self._prev_is_pressed and is_pressed:
if self._prev_press_end is not None and press_start is not None:
gap = press_start - self._prev_press_end
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