#!/usr/bin/env python3 """ LXST Whisplay Client - Group voice chat for Raspberry Pi Zero 2W with PiSugar Whisplay HAT. Connects to lxst-relay voice channels via Reticulum/LXST. Uses the Whisplay HAT for LCD display, button (PTT), RGB LED, and WM8960 audio I/O. """ import RNS import os import sys import time import threading import argparse import numpy as np import signal as signal_module from LXST import Pipeline from LXST.Network import SignallingReceiver, Packetizer, LinkSource from LXST.Sinks import LineSink from LXST.Sources import LineSource from LXST.Codecs import Opus from LXST.Primitives.Telephony import Signalling script_dir = os.path.dirname(os.path.abspath(__file__)) whisplay_runtime = os.path.abspath(os.path.join(script_dir, "..", "Whisplay", "runtime")) if whisplay_runtime not in sys.path: sys.path.insert(0, whisplay_runtime) from lib.whisplay_client import create_whisplay_hardware HAS_PIL = False try: from PIL import Image, ImageDraw, ImageFont HAS_PIL = True except ImportError: pass APP_NAME = "lxst" PRIMITIVE_NAME = "telephony" STATE_IDLE = 0 STATE_CONNECTING = 1 STATE_CONNECTED = 2 STATE_TRANSMITTING = 3 STATE_FAILED = 4 STATUS_STR = { STATE_IDLE: "Disconnected", STATE_CONNECTING: "Connecting...", STATE_CONNECTED: "Connected", STATE_TRANSMITTING: "TX", STATE_FAILED: "Failed", } ACCENT_IDLE = (60, 150, 255) ACCENT_CONNECTING = (255, 180, 0) ACCENT_CONNECTED = (0, 200, 80) ACCENT_TX = (255, 50, 50) ACCENT_FAILED = (200, 50, 50) RGB_IDLE = (0, 0, 100) RGB_CONNECTING = (100, 80, 0) RGB_CONNECTED = (0, 100, 0) RGB_TX = (255, 0, 0) RGB_FAILED = (100, 0, 0) WHISPLAY_APP_ID = "lxst-whisplay-client" DISPLAY_NAME = "LXST Voice" class LXSTWhisplayClient(SignallingReceiver): def __init__(self, channel_hash, identity_path=None, rns_config=None, verbosity=0): super().__init__() self.channel_hash = channel_hash self.state = STATE_IDLE self.link = None self.link_source = None self.packetizer = None self.line_source = None self.line_sink = None self.tx_pipeline = None self.participant_count = 0 self._exit = False self._cancel_connect = False self._lock = threading.Lock() self._press_time = 0 self._press_state = STATE_IDLE self._error_msg = "" self._init_rns(identity_path, rns_config, verbosity) self._init_whisplay() self._init_fonts() def _init_rns(self, identity_path, rns_config, verbosity): if identity_path and os.path.isfile(identity_path): self.identity = RNS.Identity.from_file(identity_path) if self.identity is None: RNS.log("Could not load identity, generating new", RNS.LOG_ERROR) self.identity = RNS.Identity() if identity_path: self.identity.to_file(identity_path) else: RNS.log("No identity found, generating new", RNS.LOG_NOTICE) self.identity = RNS.Identity() if identity_path: self.identity.to_file(identity_path) self.reticulum = RNS.Reticulum(configdir=rns_config, loglevel=3 + verbosity) self.destination = RNS.Destination( self.identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, PRIMITIVE_NAME, ) self.destination.set_proof_strategy(RNS.Destination.PROVE_NONE) RNS.log( f"LXST Client identity: {RNS.prettyhexrep(self.identity.hash)}", RNS.LOG_NOTICE, ) def _init_whisplay(self): self.board = create_whisplay_hardware( app_id=WHISPLAY_APP_ID, display_name=DISPLAY_NAME, icon="LX", 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 not HAS_PIL: self.board.fill_screen(0) def _init_fonts(self): self._fonts = {} if not HAS_PIL: return for size, bold in [(22, True), (18, False), (14, False), (12, False)]: self._fonts[(size, bold)] = self._load_font(size, bold) def _load_font(self, size, bold=False): candidates = [] if bold: candidates.extend([ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", ]) candidates.extend([ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/freefont/FreeSans.ttf", ]) for path in candidates: if os.path.exists(path): try: return ImageFont.truetype(path, size) except Exception: continue return ImageFont.load_default() def _wrap_text(self, draw, text, font, max_width): if not text: return [""] words = text.split() if not words: return [text] lines = [] current = words[0] for word in words[1:]: cand = f"{current} {word}" bbox = draw.textbbox((0, 0), cand, font=font) if (bbox[2] - bbox[0]) <= max_width: current = cand else: lines.append(current) current = word lines.append(current) return lines def _rgb565_bytes(self, image): rgb = image.convert("RGB") w, h = rgb.width, rgb.height buf = bytearray(w * h * 2) i = 0 for y in range(h): for x in range(w): r, g, b = rgb.getpixel((x, y)) v = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3) buf[i] = (v >> 8) & 0xFF buf[i + 1] = v & 0xFF i += 2 return bytes(buf) def _render_frame(self, accent=None): if not HAS_PIL: return None w = self.board.LCD_WIDTH h = self.board.LCD_HEIGHT accent = accent or ACCENT_IDLE bg = (11, 16, 24) img = Image.new("RGB", (w, h), bg) draw = ImageDraw.Draw(img) font_title = self._fonts.get((22, True), ImageFont.load_default()) font_body = self._fonts.get((18, False), ImageFont.load_default()) font_small = self._fonts.get((14, False), ImageFont.load_default()) font_tiny = self._fonts.get((12, False), ImageFont.load_default()) 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, 58), radius=12, fill=accent, ) state_str = STATUS_STR.get(self.state, "Unknown") r_accent, g_accent, b_accent = accent luminance = 0.299 * r_accent + 0.587 * g_accent + 0.114 * b_accent text_color = (18, 24, 32) if luminance >= 170 else (255, 255, 255) draw.text((30, 31), state_str, fill=text_color, font=font_small) cy = 74 draw.text((24, cy), "LXST Voice", fill=(255, 255, 255), font=font_title) cy += 32 ch = self.channel_hash display_ch = ch[:16] + "..." if len(ch) > 19 else ch draw.text((24, cy), display_ch, fill=(180, 190, 200), font=font_body) cy += 26 if self.state in (STATE_CONNECTED, STATE_TRANSMITTING): pc = self.participant_count draw.text( (24, cy), f"Participants: {pc}", fill=(150, 200, 150), font=font_body, ) cy += 28 cy += 8 if self.state == STATE_IDLE: for line in self._wrap_text(draw, "Press button to connect", font_body, w - 56): draw.text((24, cy), line, fill=(160, 180, 210), font=font_body) cy += 24 elif self.state == STATE_CONNECTING: for line in self._wrap_text(draw, "Connecting to channel...", font_body, w - 56): draw.text((24, cy), line, fill=(255, 200, 100), font=font_body) cy += 24 for line in self._wrap_text(draw, "Press to cancel", font_small, w - 56): draw.text((24, cy), line, fill=(200, 170, 100), font=font_small) cy += 20 elif self.state == STATE_CONNECTED: for line in self._wrap_text(draw, "Hold button to talk", font_body, w - 56): draw.text((24, cy), line, fill=(160, 220, 180), font=font_body) cy += 24 for line in self._wrap_text(draw, "Long press 3s to leave", font_small, w - 56): draw.text((24, cy), line, fill=(150, 180, 160), font=font_small) cy += 20 elif self.state == STATE_TRANSMITTING: draw.rounded_rectangle( (28, cy, w - 28, cy + 36), radius=8, fill=(220, 40, 40), outline=(255, 80, 80), width=2, ) tx_label = "TRANSMITTING" bbox = draw.textbbox((0, 0), tx_label, font=font_title) tw = bbox[2] - bbox[0] draw.text( ((w - tw) // 2, cy + 6), tx_label, fill=(255, 255, 255), font=font_title, ) cy += 48 for line in self._wrap_text(draw, "Release to stop", font_small, w - 56): draw.text((24, cy), line, fill=(255, 150, 150), font=font_small) cy += 20 elif self.state == STATE_FAILED: for line in self._wrap_text(draw, self._error_msg, font_body, w - 56): draw.text((24, cy), line, fill=(255, 150, 150), font=font_body) cy += 24 for line in self._wrap_text(draw, "Press to retry", font_small, w - 56): draw.text((24, cy), line, fill=(200, 150, 150), font=font_small) cy += 20 y_pos = h - 40 draw.rounded_rectangle( (20, y_pos, w - 20, h - 22), radius=12, fill=(28, 37, 50), ) if self.state == STATE_CONNECTED: draw.text( (28, y_pos + 8), "RX", fill=(100, 200, 100), font=font_tiny, ) bar_w = w - 80 bar_x = 60 draw.rounded_rectangle( (bar_x, y_pos + 10, bar_x + bar_w, y_pos + 22), radius=4, fill=(40, 55, 70), ) draw.rounded_rectangle( (bar_x, y_pos + 10, bar_x + int(bar_w * 0.6), y_pos + 22), radius=4, fill=(0, 200, 80), ) elif self.state == STATE_TRANSMITTING: draw.text( (28, y_pos + 8), "TX", fill=(255, 100, 100), font=font_tiny, ) bar_w = w - 80 bar_x = 60 draw.rounded_rectangle( (bar_x, y_pos + 10, bar_x + bar_w, y_pos + 22), radius=4, fill=(40, 55, 70), ) draw.rounded_rectangle( (bar_x, y_pos + 10, bar_x + bar_w, y_pos + 22), radius=4, fill=(255, 50, 50), ) elif self.state == STATE_IDLE: draw.text( (28, y_pos + 8), "Standby", fill=(150, 170, 190), font=font_tiny, ) return self._rgb565_bytes(img) def _update_display(self): if self.state == STATE_IDLE: accent = ACCENT_IDLE elif self.state == STATE_CONNECTING: accent = ACCENT_CONNECTING elif self.state == STATE_CONNECTED: accent = ACCENT_CONNECTED elif self.state == STATE_TRANSMITTING: accent = ACCENT_TX elif self.state == STATE_FAILED: accent = ACCENT_FAILED else: accent = ACCENT_IDLE frame = self._render_frame(accent=accent) if frame: self.board.draw_image( 0, 0, self.board.LCD_WIDTH, self.board.LCD_HEIGHT, frame, ) def _set_state(self, state, error_msg=""): with self._lock: self.state = state self._error_msg = error_msg self._update_display() def _fail(self, msg): self._cleanup_all() self.board.set_rgb(*RGB_FAILED) self._set_state(STATE_FAILED, msg) RNS.log(msg, RNS.LOG_ERROR) time.sleep(4) with self._lock: if self.state == STATE_FAILED: self.state = STATE_IDLE self._update_display() self.board.set_rgb(*RGB_IDLE) def connect(self): with self._lock: if self.state not in (STATE_IDLE, STATE_FAILED): return self.state = STATE_CONNECTING self._update_display() self.board.set_rgb(*RGB_CONNECTING) try: channel_id = RNS.Identity.from_hex(self.channel_hash) except Exception as e: self._fail(f"Invalid channel hash: {e}") return dest = RNS.Destination( channel_id, RNS.Destination.OUT, RNS.Destination.SINGLE, APP_NAME, PRIMITIVE_NAME, ) self.link = RNS.Link(dest) self.link.set_link_closed_callback(self._on_link_closed) deadline = time.time() + 20 while self.link.status != RNS.Link.ACTIVE and time.time() < deadline: time.sleep(0.05) if self._cancel_connect: self._cancel_connect = False self._cleanup_link() self._set_state(STATE_IDLE) self.board.set_rgb(*RGB_IDLE) return if self.link.status != RNS.Link.ACTIVE: self._fail("Connection timeout - channel unreachable") return try: self.line_sink = LineSink() self.link_source = LinkSource(self.link, self, sink=self.line_sink) self.link_source.start() self.tx_codec = Opus(profile=Opus.PROFILE_VOICE_MEDIUM) self.packetizer = Packetizer(self.link) self.line_source = LineSource(codec=self.tx_codec, sink=self.packetizer) self.tx_pipeline = Pipeline( source=self.line_source, codec=self.tx_codec, sink=self.packetizer, ) except Exception as e: self._fail(f"Audio setup failed: {e}") return with self._lock: self.state = STATE_CONNECTED self._update_display() self.board.set_rgb(*RGB_CONNECTED) RNS.log( f"Connected to channel {RNS.prettyhexrep(channel_id.hash)}", RNS.LOG_NOTICE, ) def _cleanup_link(self): if self.link: try: if self.link.status == RNS.Link.ACTIVE: self.link.teardown() except Exception: pass self.link = None def _cleanup_pipelines(self): if self.tx_pipeline and self.tx_pipeline.running: self.tx_pipeline.stop() self.tx_pipeline = None if self.link_source: self.link_source.stop() self.link_source = None if self.line_sink: self.line_sink.stop() self.line_sink = None if self.line_source: self.line_source = None if self.packetizer: self.packetizer.stop() self.packetizer = None def _cleanup_all(self): self._cleanup_pipelines() self._cleanup_link() def disconnect(self): with self._lock: was_connected = self.state not in (STATE_IDLE,) self._cleanup_all() self.state = STATE_IDLE self.participant_count = 0 if was_connected: self._update_display() self.board.set_rgb(*RGB_IDLE) RNS.log("Disconnected from channel", RNS.LOG_NOTICE) def _on_link_closed(self, link): self.disconnect() def _ptt_start(self): with self._lock: if self.state != STATE_CONNECTED: return if self.tx_pipeline and not self.tx_pipeline.running: self.tx_pipeline.start() self.state = STATE_TRANSMITTING self.board.set_rgb(*RGB_TX) self._update_display() def _ptt_stop(self): with self._lock: if self.state != STATE_TRANSMITTING: return if self.tx_pipeline and self.tx_pipeline.running: self.tx_pipeline.stop() self.state = STATE_CONNECTED self.board.set_rgb(*RGB_CONNECTED) self._update_display() def _on_button_press(self): self._press_time = time.time() with self._lock: self._press_state = self.state state = self.state if state == STATE_CONNECTED: self._ptt_start() elif state == STATE_IDLE or state == STATE_FAILED: threading.Thread(target=self.connect, daemon=True).start() elif state == STATE_CONNECTING: self._cancel_connect = True def _on_button_release(self): held = time.time() - self._press_time if self._press_state == STATE_CONNECTED and held >= 3.0: threading.Thread(target=self.disconnect, daemon=True).start() return self._ptt_stop() def _on_exit_request(self, payload=None): self._exit = True self.disconnect() def _on_focus_revoked(self, payload=None): self.disconnect() def signalling_received(self, signals, source): pass def run(self): self.board.set_rgb(*RGB_IDLE) self._update_display() signal_module.signal(signal_module.SIGINT, self._sigint_handler) signal_module.signal(signal_module.SIGTERM, self._sigterm_handler) RNS.log( f"LXST Whisplay Client ready on channel hash: {self.channel_hash}", RNS.LOG_NOTICE, ) RNS.log("Press button to connect", RNS.LOG_NOTICE) while not self._exit: time.sleep(0.1) self.cleanup() def _sigint_handler(self, sig, frame): self._exit = True def _sigterm_handler(self, sig, frame): self._exit = True def cleanup(self): self._exit = True self._cancel_connect = True self.disconnect() try: self.board.set_rgb(0, 0, 0) self.board.cleanup() except Exception: pass def main(): parser = argparse.ArgumentParser( description="LXST Whisplay Client - Group voice chat for Pi Zero 2W", ) parser.add_argument( "channel", help="Channel destination hash (hex string from lxst-relay bot)", ) parser.add_argument( "--identity", "-i", default=None, help="Path to persistent identity file", ) parser.add_argument( "--rns-config", default=None, help="Path to alternative Reticulum config directory", ) parser.add_argument( "-v", "--verbose", action="count", default=0, ) args = parser.parse_args() client = LXSTWhisplayClient( channel_hash=args.channel, identity_path=args.identity, rns_config=args.rns_config, verbosity=args.verbose, ) try: client.run() except KeyboardInterrupt: client.cleanup() print("") if __name__ == "__main__": main()