whisplay renders list of channels, selects channel, and connects

This commit is contained in:
Gabby Pelton
2026-05-29 23:48:50 -05:00
parent eca36af7b3
commit 2d2972166d
4 changed files with 1222 additions and 16 deletions
+248 -2
View File
@@ -13,6 +13,29 @@ import LXMF
from LXST.Primitives.Telephony import Telephone
# ── 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:
if not RNS.vendor.platformutils.is_windows():
UNDERLINE = "\033[4m"
@@ -31,7 +54,8 @@ class ChannelClient:
STATE_CALLING = 3
STATE_IN_CALL = 4
def __init__(self, configdir=None, rnsconfigdir=None, whitelist_path=None, verbosity=0, announce_interval=0):
def __init__(self, configdir=None, rnsconfigdir=None, whitelist_path=None,
verbosity=0, announce_interval=0, use_whisplay=False):
self.state = self.STATE_IDLE
self.channels = []
self.telephone = None
@@ -40,6 +64,17 @@ class ChannelClient:
self._responses = []
self._response_event = threading.Event()
# ── Whisplay HAT init ──
self.board = None
self.selected_index = 0
self._press_time = 0.0
if use_whisplay and _WHISPLAY_AVAILABLE and _HAS_PIL:
try:
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 os.path.isdir("/etc/lxst-client"):
self.configdir = "/etc/lxst-client"
@@ -99,6 +134,181 @@ class ChannelClient:
if self.announce_interval > 0:
threading.Thread(target=self._announce_loop, daemon=True).start()
# ── Whisplay HAT GUI ─────────────────────────────────────────────
def _init_whisplay(self):
from whisplay_client import create_whisplay_hardware
self.board = create_whisplay_hardware(
app_id="lxst-client",
display_name="LXST Client",
icon="LC",
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)
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()
def _load_fonts(self):
def load(size, bold=False):
cand = []
if bold:
cand.extend([
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
])
cand.extend([
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
])
for c in cand:
if os.path.exists(c):
try:
return ImageFont.truetype(c, size)
except Exception:
continue
return ImageFont.load_default()
self._title_font = load(20, True)
self._body_font = load(16)
self._small_font = load(12)
def _rgb565(self, image):
rgb = image.convert("RGB")
out = bytearray()
for y in range(rgb.height):
for x in range(rgb.width):
r, g, b = rgb.getpixel((x, y))
v = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
out.append((v >> 8) & 0xFF)
out.append(v & 0xFF)
return bytes(out)
def _wrap(self, draw, text, font, max_w):
if not text:
return [""]
words = text.split()
if not words:
return [text]
lines, cur = [], words[0]
for w in words[1:]:
test = f"{cur} {w}"
bb = draw.textbbox((0, 0), test, font=font)
if (bb[2] - bb[0]) <= max_w:
cur = test
else:
lines.append(cur)
cur = w
lines.append(cur)
return lines
def _render(self, title, body_lines, accent=(60, 150, 255), footer=""):
w, h = self.board.LCD_WIDTH, self.board.LCD_HEIGHT
img = Image.new("RGB", (w, h), (11, 16, 24))
draw = ImageDraw.Draw(img)
draw.rounded_rectangle((12, 16, w - 12, h - 16), radius=18,
fill=(20, 28, 40), outline=(40, 55, 72), width=2)
draw.rounded_rectangle((20, 22, w - 20, 54), radius=12, fill=accent)
draw.text((28, 30), title, fill=(255, 255, 255), font=self._title_font)
y = 70
max_w = w - 56
for line in body_lines:
if y >= h - 50:
break
if not line:
y += 8
continue
is_sel = False
txt = line
if isinstance(line, tuple):
txt, is_sel = line
wrapped = self._wrap(draw, txt, self._body_font, max_w)
for wl in wrapped:
if is_sel:
bb = draw.textbbox((0, 0), wl, font=self._body_font)
lw = bb[2] - bb[0]
draw.rounded_rectangle((22, y - 2, 22 + lw + 12, y + 20),
radius=6, fill=(accent[0] // 3, accent[1] // 3, accent[2] // 3))
draw.text((28, y), wl, fill=(255, 255, 255), font=self._body_font)
else:
draw.text((28, y), wl, fill=(180, 195, 210), font=self._body_font)
y += 22
if footer:
draw.rounded_rectangle((20, h - 38, w - 20, h - 22), radius=10, fill=(28, 37, 50))
draw.text((28, h - 34), footer, fill=(140, 195, 255), font=self._small_font)
return self._rgb565(img)
def _flash_screen(self):
if self.board is None:
return
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)
def _show_channels(self):
if self.board is None:
return
if not self.channels:
fb = self._render("LXST Client", ["No channels", "", "Re-discover to find channels"],
accent=(200, 100, 60), footer="No channels")
else:
lines = []
for i, ch in enumerate(self.channels):
lines.append((f"{i+1}: {ch['hash'][:16]}..", i == self.selected_index))
fb = self._render(f"Channels ({len(self.channels)})", lines,
footer="Short: next | Long: select")
self.board.draw_image(0, 0, self.board.LCD_WIDTH, self.board.LCD_HEIGHT, fb)
def _show_in_call(self):
if self.board is None:
return
ch = self.channels[self.selected_index] if self.channels else None
lines = [f"Channel {self.selected_index + 1}"]
if ch:
lines.append(ch["hash"][:16] + "..")
lines += ["", "Press to hang up"]
fb = self._render("In Call", lines, accent=(60, 220, 120), footer="Press button to end")
self.board.draw_image(0, 0, self.board.LCD_WIDTH, self.board.LCD_HEIGHT, fb)
def _show_status(self, title, detail="", accent=(255, 180, 60)):
if self.board is None:
return
lines = [detail] if detail else []
fb = self._render(title, lines, accent=accent)
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()
def _on_button_release(self):
elapsed = time.time() - self._press_time
if self.state == self.STATE_IN_CALL:
self.telephone.hangup()
return
if self.state != self.STATE_READY:
return
if elapsed >= LONG_PRESS_THRESHOLD:
if self.channels:
self.board.set_rgb(255, 180, 0)
self.call_channel(self.selected_index)
else:
if self.channels:
self.selected_index = (self.selected_index + 1) % len(self.channels)
self._show_channels()
def _on_exit_request(self, payload=None):
self.cleanup()
if self.board:
self.board.prepare_exit()
def _on_focus_revoked(self, payload=None):
self.cleanup()
def _announce_loop(self):
while self.should_run:
time.sleep(self.announce_interval)
@@ -210,6 +420,9 @@ class ChannelClient:
f"\nCall established with "
f"{RNS.prettyhexrep(remote_identity.hash)}"
)
if self.board:
self.board.set_rgb(0, 255, 80)
self._show_in_call()
def _on_call_ended(self, remote_identity):
was_in_call = self.state == self.STATE_IN_CALL
@@ -219,6 +432,12 @@ class ChannelClient:
print(f"\nCall ended")
elif was_calling:
print(f"\nCall could not be connected")
if self.board:
self.board.set_rgb(0, 80, 255)
if was_in_call:
self._show_status("Call Ended")
time.sleep(1)
self._show_channels()
def call_channel(self, index):
if index < 0 or index >= len(self.channels):
@@ -293,6 +512,26 @@ class ChannelClient:
signal.signal(signal.SIGTERM, self._sigterm_handler)
self.should_run = True
if self.board:
self._run_gui()
else:
self._run_cli()
def _run_gui(self):
self._flash_screen()
self.discover(timeout=15)
self.selected_index = 0
self.board.set_rgb(0, 80, 255)
self._show_channels()
while self.should_run:
if self.state == self.STATE_CALLING:
self._show_status("Calling...", f"Channel {self.selected_index + 1}",
accent=(255, 180, 0))
time.sleep(0.5)
continue
time.sleep(0.1)
def _run_cli(self):
print(
f"\n{Terminal.BOLD}"
f"LXST Channel Client"
@@ -359,6 +598,8 @@ class ChannelClient:
if self.telephone and self.state == self.STATE_IN_CALL:
self.telephone.hangup()
self.should_run = False
if self.board:
self.board.cleanup()
def _sigint_handler(self, sig, frame):
self.cleanup()
@@ -394,7 +635,11 @@ def main():
help="seconds between LXMF destination announces (0 = announce once at startup, default)",
type=int,
)
parser.add_argument(
"--whisplay", "-W",
action="store_true",
help="enable Whisplay HAT GUI mode",
)
args = parser.parse_args()
client = ChannelClient(
@@ -403,6 +648,7 @@ def main():
whitelist_path=args.whitelist,
verbosity=args.verbose,
announce_interval=args.announce_interval,
use_whisplay=args.whisplay,
)
try: