This commit is contained in:
Gabby Morgan
2026-05-28 13:04:37 -05:00
parent 0615cbd6fb
commit 014fd41691
4 changed files with 1549 additions and 0 deletions
+605
View File
@@ -0,0 +1,605 @@
#!/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()