Compare commits

...

10 Commits

Author SHA1 Message Date
Gabby Morgan 9e888d9afa refactor gesture handling to use timers and callbacks instead of loops 2026-06-05 01:53:25 -05:00
Gabby Morgan 884325d1c6 refactor app registration with whisplay daemon 2026-06-03 23:57:43 -05:00
Gabby Morgan 3b5bac5d2c set speaker and mic volume on startup 2026-05-31 17:41:31 -05:00
Gabby Morgan 63422c9a75 use hardware mute to manage PTT 2026-05-31 16:32:56 -05:00
Gabby Morgan 561c014c33 update AGENTS.md 2026-05-30 19:18:33 -05:00
Gabby Morgan b4cd86ed78 remove CLI mode 2026-05-30 19:15:25 -05:00
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
5 changed files with 255 additions and 182 deletions
+20 -22
View File
@@ -1,8 +1,8 @@
# LXST Client # LXST Client
Single-file Python application for managing LXST Channel connections via Reticulum Network (RNS) and LXMF protocols. Supports CLI and Whisplay HAT GUI modes. Single-file Python application for managing LXST Channel connections via Reticulum Network (RNS) and LXMF protocols using the Whisplay HAT.
## Running (CLI mode, default) ## Running
python client.py [-c CONFIGDIR] [-w WHITELIST] python client.py [-c CONFIGDIR] [-w WHITELIST]
@@ -11,40 +11,39 @@ Single-file Python application for managing LXST Channel connections via Reticul
-w, --whitelist : path to bridge_whitelist.json -w, --whitelist : path to bridge_whitelist.json
-v, --verbose : enable debug logging -v, --verbose : enable debug logging
--rnsconfig : alternative Reticulum config directory --rnsconfig : alternative Reticulum config directory
--whisplay, -W : enable Whisplay HAT GUI mode
## Running (Whisplay GUI mode)
python client.py -W [-c CONFIGDIR] [-w WHITELIST]
## CLI Commands
Inside the interactive client:
d Discover channels from whitelist
l List discovered channels
<NUM> Connect to channel at index NUM (1-based)
h / ? Show help
q Quit
## 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
1. Ensure ~/.lxst-client or /etc/lxst-client exists 1. Ensure ~/.lxst-client or /etc/lxst-client exists
2. Place bridge_whitelist.json in either directory (or pass with -w) 2. Place bridge_whitelist.json in either directory (or pass with -w)
3. First run creates identity automatically at configdir/identity 3. First run creates identity automatically at configdir/identity
4. In CLI mode: run `python client.py` then `d` to discover channels 4. In GUI mode: run `python client.py -W` — auto-discovers on startup
5. In GUI mode: run `python client.py -W` — auto-discovers on startup 5. Messages appear in configdir/lxmf/
6. Messages appear in configdir/lxmf/
## Known quirks ## Known quirks
@@ -53,4 +52,3 @@ Single-file Python application for managing LXST Channel connections via Reticul
- Use -v for RNS debug output (path discovery is verbose here). - Use -v for RNS debug output (path discovery is verbose here).
- Path discovery waits up to 10s for relays; increase timeout in client code if needed. - Path discovery waits up to 10s for relays; increase timeout in client code if needed.
- Whisplay mode requires Pillow and the Whisplay runtime (whisplay.py / whisplay_client.py). - Whisplay mode requires Pillow and the Whisplay runtime (whisplay.py / whisplay_client.py).
- Falls back to CLI gracefully if Whisplay hardware or dependencies are unavailable.
+1 -1
View File
@@ -1,3 +1,3 @@
[ [
"8567367936f085f93bf4ecdf494fafd0" "8567367936f085f93bf4ecdf494fafd0", "c64bc5a948096bd624bd9c186c35bdb1"
] ]
+120 -158
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():
@@ -57,7 +35,7 @@ class ChannelClient:
STATE_IN_CALL = 4 STATE_IN_CALL = 4
def __init__(self, configdir=None, rnsconfigdir=None, whitelist_path=None, def __init__(self, configdir=None, rnsconfigdir=None, whitelist_path=None,
verbosity=0, announce_interval=0, use_whisplay=False): verbosity=0, announce_interval=0):
self.state = self.STATE_IDLE self.state = self.STATE_IDLE
self.channels = [] self.channels = []
self.telephone = None self.telephone = None
@@ -67,16 +45,13 @@ 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._init_whisplay() self.whisplay_interface = None
except Exception as e:
RNS.log(f"Whisplay init failed: {e}", RNS.LOG_WARNING) self._init_whisplay()
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,25 +115,21 @@ 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( self.whisplay_interface.board.set_backlight(70)
app_id="lxst-client", if hasattr(self.whisplay_interface.board, "on_exit_request"):
display_name="LXST Client", self.whisplay_interface.board.on_exit_request(self._on_exit_request)
icon="LC", if hasattr(self.whisplay_interface.board, "on_focus_revoked"):
use_daemon_default_log=True, self.whisplay_interface.board.on_focus_revoked(self._on_focus_revoked)
)
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() self._load_fonts()
self._setup_whisplay_audio() self._setup_whisplay_audio()
self.whisplay_interface.on_press(self._on_press)
self.whisplay_interface.on_release(self._on_release)
self.whisplay_interface.on_long_press(self._on_long_press)
self.whisplay_interface.on_long_release(self._on_long_release)
self.whisplay_interface.on_double_press(self._on_double_press)
self.whisplay_interface.on_double_release(self._on_double_release)
def _find_wm8960_card(self): def _find_wm8960_card(self):
try: try:
@@ -242,7 +213,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 +249,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 +266,96 @@ 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", "release to mute"]
self.board.draw_image(0, 0, self.board.LCD_WIDTH, self.board.LCD_HEIGHT, fb) footer = "Short: always-on | 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 _on_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: if self.state == self.STATE_IN_CALL:
self.telephone.hangup() if self._ptt_enabled and not self._ptt_active:
return self._ptt_active = True
if self.state != self.STATE_READY: self._hw_set_mic_muted(False)
return if self.whisplay_interface.board:
if elapsed >= LONG_PRESS_THRESHOLD: self.whisplay_interface.board.set_rgb(255, 0, 0)
if self.channels:
self.board.set_rgb(255, 180, 0) def _on_double_press(self):
self.call_channel(self.selected_index) if self.state == self.STATE_READY:
else: self._on_press()
def _on_long_press(self):
pass
def _on_release(self):
if self.state == self.STATE_READY:
if 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()
elif self.state == self.STATE_IN_CALL:
if self._ptt_enabled:
self._ptt_enabled = False
self._ptt_active = False
if self.whisplay_interface.board:
self.whisplay_interface.board.set_rgb(255, 0, 0)
else:
self._ptt_enabled = True
self._ptt_active = False
self._hw_set_mic_muted(True)
if self.whisplay_interface.board:
self.whisplay_interface.board.set_rgb(0, 0, 0)
if self.whisplay_interface.board:
self._show_in_call()
def _on_double_release(self):
if self.state == self.STATE_IN_CALL:
self.telephone.hangup()
self.selected_index = (self.selected_index + 1) % len(self.channels)
self._show_channels()
def _on_long_release(self):
if self.state == self.STATE_IN_CALL and self._ptt_enabled:
self._ptt_active = False
self._hw_set_mic_muted(True)
if self.whisplay_interface.board:
self.whisplay_interface.board.set_rgb(0, 0, 0)
elif self.state == self.STATE_READY:
if self.channels:
if self._ptt_enabled:
self.whisplay_interface.board.set_rgb(0, 0, 0)
else:
self.whisplay_interface.board.set_rgb(255, 0, 0)
self.call_channel(self.selected_index)
else:
self._on_release()
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,25 +475,41 @@ 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._hw_set_mic_muted(True)
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)
self._show_channels() self._show_channels()
def _hw_set_mic_muted(self, muted):
if self._wm8960_device is None:
return
card = self._wm8960_device.split(",")[0].split(":")[-1]
state = "off" if muted else "on"
try:
subprocess.run(
["amixer", "-c", card, "cset", "name='Capture Switch'", state],
check=False, capture_output=True, timeout=2,
)
except Exception:
pass
def call_channel(self, index): def call_channel(self, index):
if index < 0 or index >= len(self.channels): if index < 0 or index >= len(self.channels):
print("Invalid channel index") print("Invalid channel index")
@@ -549,16 +583,13 @@ 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: self._run_gui()
self._run_gui()
else:
self._run_cli()
def _run_gui(self): def _run_gui(self):
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()
while self.should_run: while self.should_run:
if self.state == self.STATE_CALLING: if self.state == self.STATE_CALLING:
@@ -568,75 +599,12 @@ class ChannelClient:
continue continue
time.sleep(0.1) time.sleep(0.1)
def _run_cli(self):
print(
f"\n{Terminal.BOLD}"
f"LXST Channel Client"
f"{Terminal.END}"
)
print(f" Identity: {RNS.prettyhexrep(self.identity.hash)}")
print(f" Whitelist: {self.whitelist_path}")
print()
self.discover(timeout=15)
self.show_channels()
self.show_help()
while self.should_run:
if self.state == self.STATE_IN_CALL:
try:
input(" In call, press Enter to hang up > ")
self.telephone.hangup()
self.state = self.STATE_READY
except (EOFError, KeyboardInterrupt):
break
continue
if self.state == self.STATE_CALLING:
time.sleep(0.5)
continue
if self.state == self.STATE_DISCOVERING:
time.sleep(0.5)
continue
try:
inp = input("> ").strip().lower()
except (EOFError, KeyboardInterrupt):
print()
break
if not inp:
continue
if inp == "q":
break
elif inp in ("h", "?"):
self.show_help()
elif inp == "d":
self.discover()
self.show_channels()
elif inp == "l":
self.show_channels()
else:
try:
idx = int(inp) - 1
if 0 <= idx < len(self.channels):
self.call_channel(idx)
else:
print(
f"Invalid index. "
f"Enter a number between 1 and {len(self.channels)}"
)
except ValueError:
print(f"Unknown command: {inp}. Type 'h' for help.")
def cleanup(self): def cleanup(self):
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.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()
@@ -672,11 +640,6 @@ def main():
help="seconds between LXMF destination announces (0 = announce once at startup, default)", help="seconds between LXMF destination announces (0 = announce once at startup, default)",
type=int, type=int,
) )
parser.add_argument(
"--whisplay", "-W",
action="store_true",
help="enable Whisplay HAT GUI mode",
)
args = parser.parse_args() args = parser.parse_args()
client = ChannelClient( client = ChannelClient(
@@ -685,7 +648,6 @@ def main():
whitelist_path=args.whitelist, whitelist_path=args.whitelist,
verbosity=args.verbose, verbosity=args.verbose,
announce_interval=args.announce_interval, announce_interval=args.announce_interval,
use_whisplay=args.whisplay,
) )
try: try:
+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 subprocess
import time
import threading
import os
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._is_pressing = False
self._is_double_pressing = False
self._is_long_pressing = False
self._long_press_timer_thread = None
self._button_press_start = None
self._button_press_end = None
self._on_press_callback = None
self._on_release_callback = None
self._on_long_press_callback = None
self._on_long_release_callback = None
self._on_double_press_callback = None
self._on_double_release_callback = None
self.set_wm8960_volume_stable(speaker=200, mic=80)
def create_board(self):
self.board = runtime.whisplay_client.create_whisplay_hardware(
app_id=os.getenv("WHISPLAY_APP_ID", "whisplay-lxst-client"),
display_name="LXST Client",
icon="L",
exit_gesture="quad_press",
use_daemon_default_log=False,
)
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_press(self, callback):
self._on_press_callback = callback
def on_release(self, callback):
self._on_release_callback = callback
def on_long_press(self, callback):
self._on_long_press_callback = callback
def on_long_release(self, callback):
self._on_long_release_callback = callback
def on_double_press(self, callback):
self._on_double_press_callback = callback
def on_double_release(self, callback):
self._on_double_release_callback = callback
def _on_button_press(self):
with self._lock:
self._is_pressing = True
self._button_press_start = time.time()
if self._button_press_end is not None:
time_since_last_press = self._button_press_start- self._button_press_end
if time_since_last_press <= self.DOUBLE_PRESS_THRESHOLD:
self._is_double_pressing = True
if self._on_double_press_callback:
self._on_double_press_callback()
if not self._is_double_pressing:
if self._on_press_callback:
self._on_press_callback()
self._long_press_timer_thread = threading.Timer(self.LONG_PRESS_THRESHOLD, self._long_press_timer)
self._long_press_timer_thread.start()
def _on_button_release(self):
with self._lock:
self._long_press_timer_thread.cancel()
self._is_pressing = False
self._button_press_end = time.time()
if self._is_double_pressing:
if self._on_double_release_callback:
self._on_double_release_callback()
self._is_double_pressing = False
elif self._is_long_pressing:
if self._on_long_release_callback:
self._on_long_release_callback()
self._is_long_pressing = False
else:
if self._on_release_callback:
self._on_release_callback()
def _long_press_timer(self):
with self._lock:
if self._is_pressing:
self._is_long_pressing = True
if self._on_long_press_callback:
self._on_long_press_callback()
def set_wm8960_volume_stable(self, speaker: int = 0, mic: int = 0):
CARD_NAME = 'wm8960soundcard'
DEVICE_ARG = f'hw:{CARD_NAME}'
try:
subprocess.run(['amixer', '-D', DEVICE_ARG, 'sset', 'Speaker',
str(speaker)], check=False, capture_output=True)
subprocess.run(['amixer', '-D', DEVICE_ARG, 'sset',
'Capture', str(mic)], check=False, capture_output=True)
except Exception as e:
print(f"ERROR: Failed to set volume: {e}")